Skip to content

Commit aeea708

Browse files
jathaydeclaude
andcommitted
feat(audit): token-aware suggestions in markdown checklist
Adds a value field to Violation that detectors populate (raw color, Tailwind arbitrary, inline style). MarkdownWriter accepts a tokens list and matches each violation's value against defined token values using shared HexNormalizer (case-folded, short-form expanded, alpha stripped). When an exact match is found, the suggestion shows the concrete token reference (var(--primary-500) or $primary) and points at the definition file:line. Stock suggestions remain the fallback. Audit#run loads tokens via Tokens#parse_tokens lazily and only when SUGGEST=1 is set, so the default path is unaffected. Tokens task and the new MarkdownWriter both consume HexNormalizer so hex normalization stays consistent across the gem. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f4ba6c2 commit aeea708

5 files changed

Lines changed: 162 additions & 35 deletions

File tree

lib/guardrails/audit.rb

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
# frozen_string_literal: true
22

33
require "pathname"
4+
require "stringio"
45

56
module Guardrails
67
class Audit
7-
Violation = Struct.new(:type, :file, :line, :column, :snippet, keyword_init: true)
8+
Violation = Struct.new(:type, :file, :line, :column, :snippet, :value, keyword_init: true)
89

910
SCAN_PATTERNS = [
1011
"app/views/**/*.html.erb",
@@ -57,38 +58,41 @@ def scan_file(file)
5758
end
5859

5960
def detect_inline_styles(content, file, original_lines)
60-
scan_lines(content, INLINE_STYLE_PATTERN) do |idx, column|
61+
scan_lines(content, INLINE_STYLE_PATTERN) do |idx, column, _line, match_text|
6162
Violation.new(
6263
type: :inline_style,
6364
file: relative(file),
6465
line: idx + 1,
6566
column: column,
66-
snippet: snippet(original_lines, idx)
67+
snippet: snippet(original_lines, idx),
68+
value: match_text
6769
)
6870
end
6971
end
7072

7173
def detect_raw_color_literals(content, file, original_lines)
72-
violations = scan_lines(content, HEX_LITERAL_PATTERN) do |idx, column, line|
74+
violations = scan_lines(content, HEX_LITERAL_PATTERN) do |idx, column, line, match_text|
7375
next unless inside_quoted_attribute?(line, column - 1)
7476

7577
Violation.new(
7678
type: :raw_color,
7779
file: relative(file),
7880
line: idx + 1,
7981
column: column,
80-
snippet: snippet(original_lines, idx)
82+
snippet: snippet(original_lines, idx),
83+
value: match_text
8184
)
8285
end
83-
violations += scan_lines(content, RGB_LITERAL_PATTERN) do |idx, column, line|
86+
violations += scan_lines(content, RGB_LITERAL_PATTERN) do |idx, column, line, match_text|
8487
next unless inside_quoted_attribute?(line, column - 1)
8588

8689
Violation.new(
8790
type: :raw_color,
8891
file: relative(file),
8992
line: idx + 1,
9093
column: column,
91-
snippet: snippet(original_lines, idx)
94+
snippet: snippet(original_lines, idx),
95+
value: match_text
9296
)
9397
end
9498
violations
@@ -105,12 +109,15 @@ def detect_tailwind_arbitrary(content, file, original_lines)
105109

106110
class_value.scan(ARBITRARY_VALUE_PATTERN) do
107111
offset = Regexp.last_match.begin(0)
112+
bracket_match = Regexp.last_match[0]
113+
inner_value = bracket_match[1..-2] # strip [ and ]
108114
violations << Violation.new(
109115
type: :tailwind_arbitrary,
110116
file: relative(file),
111117
line: idx + 1,
112118
column: value_start + offset + 1,
113-
snippet: snippet(original_lines, idx)
119+
snippet: snippet(original_lines, idx),
120+
value: inner_value
114121
)
115122
end
116123
end
@@ -123,8 +130,9 @@ def scan_lines(content, pattern)
123130
violations = []
124131
content.each_line.with_index do |line, idx|
125132
line.scan(pattern) do
126-
column = Regexp.last_match.begin(0) + 1
127-
violation = yield(idx, column, line)
133+
m = Regexp.last_match
134+
column = m.begin(0) + 1
135+
violation = yield(idx, column, line, m[0])
128136
violations << violation if violation
129137
end
130138
end
@@ -163,7 +171,14 @@ def snippet(lines, idx)
163171

164172
def write_suggestions(violations)
165173
require_relative "audit/markdown_writer"
166-
MarkdownWriter.new(@root, output: @output).write(violations)
174+
MarkdownWriter.new(@root, output: @output, tokens: load_tokens).write(violations)
175+
end
176+
177+
def load_tokens
178+
require_relative "tokens"
179+
Tokens.new(root: @root, output: StringIO.new).parse_tokens
180+
rescue StandardError
181+
[]
167182
end
168183

169184
def print_report(violations)

lib/guardrails/audit/markdown_writer.rb

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require "pathname"
4+
require_relative "../hex_normalizer"
45

56
module Guardrails
67
class Audit
@@ -22,10 +23,11 @@ class MarkdownWriter
2223
}
2324
}.freeze
2425

25-
def initialize(root, output: $stdout, now: Time.now)
26+
def initialize(root, output: $stdout, now: Time.now, tokens: [])
2627
@root = Pathname(root)
2728
@output = output
2829
@now = now
30+
@token_lookup = tokens.to_h { |t| [HexNormalizer.normalize(t.value), t] }
2931
end
3032

3133
def write(violations)
@@ -89,10 +91,31 @@ def format_type_section(type, violations)
8991
violations.each do |v|
9092
lines << "- [ ] **Line #{v.line}, col #{v.column}:** `#{v.snippet}`"
9193
lines << " - **Rule:** #{suggestion[:rule]}"
92-
lines << " - **Suggested replacement:** #{suggestion[:replacement]}" unless suggestion[:replacement].empty?
94+
95+
matched = match_token(v)
96+
if matched
97+
lines << " - **Suggested replacement:** Use `#{format_token_reference(matched)}` (matches token `#{matched.name}` defined in `#{matched.file}:#{matched.line}`)."
98+
elsif !suggestion[:replacement].empty?
99+
lines << " - **Suggested replacement:** #{suggestion[:replacement]}"
100+
end
93101
end
94102
lines.join("\n") + "\n"
95103
end
104+
105+
def match_token(violation)
106+
return nil if violation.value.nil?
107+
return nil if @token_lookup.empty?
108+
109+
@token_lookup[HexNormalizer.normalize(violation.value)]
110+
end
111+
112+
def format_token_reference(token)
113+
case token.syntax
114+
when :css_var then "var(--#{token.name})"
115+
when :scss_var then "$#{token.name}"
116+
else token.name
117+
end
118+
end
96119
end
97120
end
98121
end

lib/guardrails/hex_normalizer.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# frozen_string_literal: true
2+
3+
module Guardrails
4+
module HexNormalizer
5+
module_function
6+
7+
# Normalize a hex color literal for equality comparison:
8+
# - lowercase
9+
# - expand 3-char shorthand (#fa3 -> #ffaa33)
10+
# - strip alpha channel (#ffaa3380 -> #ffaa33)
11+
# - return non-hex input unchanged (after lowercasing)
12+
def normalize(value)
13+
v = value.to_s.downcase.strip
14+
return v unless v.start_with?("#")
15+
16+
case v.length
17+
when 4 # #fa3 -> #ffaa33
18+
"#" + v[1..].chars.map { |c| c * 2 }.join
19+
when 5 # #fa3a -> #ffaa33 (drop alpha)
20+
("#" + v[1..].chars.map { |c| c * 2 }.join)[0..6]
21+
when 7 # #ffaa33 (canonical)
22+
v
23+
when 9 # #ffaa3380 -> #ffaa33
24+
v[0..6]
25+
else
26+
v
27+
end
28+
end
29+
end
30+
end

lib/guardrails/tokens.rb

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require "pathname"
44
require "yaml"
5+
require_relative "hex_normalizer"
56

67
module Guardrails
78
class Tokens
@@ -42,7 +43,7 @@ def parse_tokens
4243
end
4344

4445
def detect_drift(tokens)
45-
lookup = tokens.to_h { |t| [normalize_hex(t.value), t] }
46+
lookup = tokens.to_h { |t| [HexNormalizer.normalize(t.value), t] }
4647
drift = []
4748

4849
stylesheets.each do |file|
@@ -60,7 +61,7 @@ def detect_drift(tokens)
6061
line: idx + 1,
6162
column: column,
6263
value: value,
63-
matched_token: lookup[normalize_hex(value)]
64+
matched_token: lookup[HexNormalizer.normalize(value)]
6465
)
6566
end
6667
end
@@ -112,24 +113,6 @@ def variable_definition_line?(line)
112113
line.match?(SCSS_VAR_PATTERN) || line.match?(CSS_VAR_PATTERN)
113114
end
114115

115-
def normalize_hex(value)
116-
v = value.downcase.strip
117-
return v unless v.start_with?("#")
118-
119-
case v.length
120-
when 4 # #fa3 -> #ffaa33
121-
"#" + v[1..].chars.map { |c| c * 2 }.join
122-
when 5 # #fa3a -> #ffaa33 (strip alpha)
123-
("#" + v[1..].chars.map { |c| c * 2 }.join)[0..6]
124-
when 7 # #ffaa33
125-
v
126-
when 9 # #ffaa3380 -> #ffaa33 (strip alpha)
127-
v[0..6]
128-
else
129-
v
130-
end
131-
end
132-
133116
def print_drift(drift)
134117
return if drift.empty?
135118

spec/guardrails/audit/markdown_writer_spec.rb

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@
55
require "stringio"
66
require "guardrails/audit"
77
require "guardrails/audit/markdown_writer"
8+
require "guardrails/tokens"
89

910
RSpec.describe Guardrails::Audit::MarkdownWriter do
1011
let(:root) { Pathname(Dir.mktmpdir) }
1112
let(:now) { Time.utc(2026, 5, 4, 16, 30, 0) }
1213
after { FileUtils.rm_rf(root) }
1314

14-
def violation(type:, file:, line: 1, column: 1, snippet: "x")
15+
def violation(type:, file:, line: 1, column: 1, snippet: "x", value: nil)
1516
Guardrails::Audit::Violation.new(
16-
type: type, file: file, line: line, column: column, snippet: snippet
17+
type: type, file: file, line: line, column: column, snippet: snippet, value: value
1718
)
1819
end
1920

@@ -86,4 +87,79 @@ def violation(type:, file:, line: 1, column: 1, snippet: "x")
8687
expect(output.string).to include("doc/guardrails-suggestions-20260504T163000Z.md")
8788
end
8889
end
90+
91+
describe "token-aware suggestions" do
92+
def token(name:, value:, syntax: :scss_var, file: "tokens.scss", line: 1)
93+
Guardrails::Tokens::Token.new(
94+
name: name, value: value, syntax: syntax, file: file, line: line
95+
)
96+
end
97+
98+
it "suggests the matching token reference for raw_color when an exact match exists" do
99+
v = violation(type: :raw_color, file: "app/views/x.html.erb", value: "#0066ff",
100+
snippet: '<svg fill="#0066ff"></svg>')
101+
tokens = [token(name: "primary", value: "#0066ff", syntax: :scss_var)]
102+
103+
described_class.new(root, output: StringIO.new, now: now, tokens: tokens).write([v])
104+
105+
content = root.join("doc/guardrails-suggestions-20260504T163000Z.md").read(encoding: Encoding::UTF_8)
106+
expect(content).to include("Use `$primary`")
107+
expect(content).to include("matches token `primary`")
108+
end
109+
110+
it "suggests var(--name) for CSS custom property tokens" do
111+
v = violation(type: :raw_color, file: "app/views/x.html.erb", value: "#0066ff",
112+
snippet: '<svg fill="#0066ff"></svg>')
113+
tokens = [token(name: "primary-500", value: "#0066ff", syntax: :css_var)]
114+
115+
described_class.new(root, output: StringIO.new, now: now, tokens: tokens).write([v])
116+
117+
content = root.join("doc/guardrails-suggestions-20260504T163000Z.md").read(encoding: Encoding::UTF_8)
118+
expect(content).to include("Use `var(--primary-500)`")
119+
end
120+
121+
it "matches normalized hex (case + short form)" do
122+
v = violation(type: :raw_color, file: "app/views/x.html.erb", value: "#fa3",
123+
snippet: '<svg fill="#fa3"></svg>')
124+
tokens = [token(name: "secondary", value: "#FFAA33")]
125+
126+
described_class.new(root, output: StringIO.new, now: now, tokens: tokens).write([v])
127+
128+
content = root.join("doc/guardrails-suggestions-20260504T163000Z.md").read(encoding: Encoding::UTF_8)
129+
expect(content).to include("matches token `secondary`")
130+
end
131+
132+
it "matches Tailwind arbitrary values against token values" do
133+
v = violation(type: :tailwind_arbitrary, file: "app/views/x.html.erb", value: "#0066ff",
134+
snippet: '<div class="bg-[#0066ff]">x</div>')
135+
tokens = [token(name: "primary", value: "#0066ff", syntax: :scss_var)]
136+
137+
described_class.new(root, output: StringIO.new, now: now, tokens: tokens).write([v])
138+
139+
content = root.join("doc/guardrails-suggestions-20260504T163000Z.md").read(encoding: Encoding::UTF_8)
140+
expect(content).to include("matches token `primary`")
141+
end
142+
143+
it "falls back to the stock suggestion when no token matches" do
144+
v = violation(type: :raw_color, file: "app/views/x.html.erb", value: "#abcdef",
145+
snippet: '<svg fill="#abcdef"></svg>')
146+
tokens = [token(name: "primary", value: "#0066ff")]
147+
148+
described_class.new(root, output: StringIO.new, now: now, tokens: tokens).write([v])
149+
150+
content = root.join("doc/guardrails-suggestions-20260504T163000Z.md").read(encoding: Encoding::UTF_8)
151+
expect(content).to include("Use a CSS custom property or SCSS variable")
152+
expect(content).not_to include("matches token")
153+
end
154+
155+
it "uses stock suggestions when no tokens are provided" do
156+
v = violation(type: :raw_color, file: "app/views/x.html.erb", value: "#0066ff",
157+
snippet: '<svg fill="#0066ff"></svg>')
158+
159+
described_class.new(root, output: StringIO.new, now: now).write([v])
160+
161+
content = root.join("doc/guardrails-suggestions-20260504T163000Z.md").read(encoding: Encoding::UTF_8)
162+
expect(content).to include("Use a CSS custom property or SCSS variable")
163+
end
164+
end
89165
end

0 commit comments

Comments
 (0)