Skip to content

Commit b60e404

Browse files
jathaydeclaude
andcommitted
feat(audit): APPLY=1 auto-fixes raw_color violations to var(--token)
Adds Audit::AutoFixer that rewrites source files in place when an exact-match CSS custom property token exists for a raw_color violation. Only :raw_color + :css_var combinations qualify — SCSS variables don't work in HTML attributes, and tailwind_arbitrary fixes need theme-aware logic that's not in scope here. Replacements happen right-to-left within a line so column positions stay valid, and the existing source text is verified at the expected column before any write so a stale violation list can't corrupt the file. When APPLY=1 and SUGGEST=1 are combined, the markdown checklist only lists violations that weren't auto-fixed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent aeea708 commit b60e404

4 files changed

Lines changed: 265 additions & 6 deletions

File tree

lib/guardrails/audit.rb

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

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

67
module Guardrails
@@ -22,18 +23,20 @@ class Audit
2223
CLASS_ATTRIBUTE_SINGLE = /\bclass\s*=\s*'([^']*)'/
2324
ARBITRARY_VALUE_PATTERN = /\[[^\]]+\]/
2425

25-
def initialize(root:, output: $stdout, suggest: false, format: :text)
26+
def initialize(root:, output: $stdout, suggest: false, format: :text, apply: false)
2627
@root = Pathname(root)
2728
@output = output
2829
@suggest = suggest
2930
@format = format
31+
@apply = apply
3032
end
3133

3234
def run
3335
violations = collect_files.flat_map { |file| scan_file(file) }
3436
print_report(violations)
35-
write_suggestions(violations) if @suggest
36-
violations
37+
remaining = @apply ? apply_auto_fixes(violations) : violations
38+
write_suggestions(remaining) if @suggest
39+
remaining
3740
end
3841

3942
private
@@ -169,9 +172,25 @@ def snippet(lines, idx)
169172
lines[idx]&.chomp&.strip
170173
end
171174

175+
def apply_auto_fixes(violations)
176+
require_relative "audit/auto_fixer"
177+
fixer = AutoFixer.new(@root, output: @output, tokens: view_safe_tokens)
178+
applied = fixer.apply(violations)
179+
fixed_keys = applied.map { |r| [r.violation.file, r.violation.line, r.violation.column] }.to_set
180+
violations.reject { |v| fixed_keys.include?([v.file, v.line, v.column]) }
181+
end
182+
172183
def write_suggestions(violations)
173184
require_relative "audit/markdown_writer"
174-
MarkdownWriter.new(@root, output: @output, tokens: load_tokens).write(violations)
185+
MarkdownWriter.new(@root, output: @output, tokens: view_safe_tokens).write(violations)
186+
end
187+
188+
def view_safe_tokens
189+
# Views (HTML/ERB) cannot reference SCSS variables — `$primary` only
190+
# exists at SCSS compile time. CSS custom properties (`var(--primary)`)
191+
# work in any HTML/CSS context, so those are the only tokens that map
192+
# cleanly into a view violation's source.
193+
load_tokens.select { |t| t.syntax == :css_var }
175194
end
176195

177196
def load_tokens

lib/guardrails/audit/auto_fixer.rb

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# frozen_string_literal: true
2+
3+
require "pathname"
4+
require_relative "../hex_normalizer"
5+
6+
module Guardrails
7+
class Audit
8+
class AutoFixer
9+
Result = Struct.new(:violation, :token, :replacement, keyword_init: true)
10+
11+
def initialize(root, output: $stdout, tokens: [])
12+
@root = Pathname(root)
13+
@output = output
14+
@lookup = tokens.to_h { |t| [HexNormalizer.normalize(t.value), t] }
15+
end
16+
17+
def apply(violations)
18+
applicable = violations.select { |v| applicable?(v) }
19+
return [] if applicable.empty?
20+
21+
applied = applicable.group_by(&:file).flat_map do |file, file_violations|
22+
process_file(@root.join(file), file_violations)
23+
end
24+
25+
report(applied)
26+
applied
27+
end
28+
29+
def applicable?(violation)
30+
return false unless violation.type == :raw_color
31+
32+
token = matched_token(violation)
33+
!token.nil? && token.syntax == :css_var
34+
end
35+
36+
private
37+
38+
def matched_token(violation)
39+
return nil if violation.value.nil?
40+
41+
@lookup[HexNormalizer.normalize(violation.value)]
42+
end
43+
44+
def process_file(path, violations)
45+
return [] unless path.exist?
46+
47+
original = File.read(path, encoding: Encoding::UTF_8)
48+
lines = original.lines
49+
applied = []
50+
51+
violations.group_by(&:line).each do |line_num, line_violations|
52+
line_idx = line_num - 1
53+
next unless lines[line_idx]
54+
55+
line_violations.sort_by { |v| -v.column }.each do |v|
56+
token = matched_token(v)
57+
next unless token
58+
59+
current_line = lines[line_idx]
60+
start_idx = v.column - 1
61+
value_length = v.value.length
62+
next unless current_line[start_idx, value_length] == v.value
63+
64+
replacement = "var(--#{token.name})"
65+
lines[line_idx] = current_line[0...start_idx] + replacement + current_line[(start_idx + value_length)..]
66+
applied << Result.new(violation: v, token: token, replacement: replacement)
67+
end
68+
end
69+
70+
new_content = lines.join
71+
File.write(path, new_content, encoding: Encoding::UTF_8) if new_content != original
72+
applied
73+
end
74+
75+
def report(applied)
76+
return if applied.empty?
77+
78+
@output.puts ""
79+
noun = applied.length == 1 ? "fix" : "fixes"
80+
@output.puts "Guardrails audit: applied #{applied.length} auto-#{noun} (raw_color → CSS custom property)"
81+
applied.each do |r|
82+
@output.puts " #{r.violation.file}:#{r.violation.line} #{r.violation.value}#{r.replacement}"
83+
end
84+
end
85+
end
86+
end
87+
end

lib/tasks/guardrails.rake

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ namespace :guardrails do
88
Guardrails::Init.new(root: root).run
99
end
1010

11-
desc "Audit views and components for UI drift (SUGGEST=1 writes markdown; FORMAT=json for machine-readable output)"
11+
desc "Audit views and components for UI drift (SUGGEST=1, APPLY=1, FORMAT=json)"
1212
task :audit do
1313
require "guardrails/audit"
1414
require "guardrails/stimulus_audit"
1515
root = defined?(Rails) ? Rails.root : Pathname(Dir.pwd)
1616
suggest = %w[1 true yes].include?(ENV["SUGGEST"]&.downcase)
17+
apply = %w[1 true yes].include?(ENV["APPLY"]&.downcase)
1718
format = ENV["FORMAT"]&.downcase == "json" ? :json : :text
18-
violations = Guardrails::Audit.new(root: root, suggest: suggest, format: format).run
19+
violations = Guardrails::Audit.new(root: root, suggest: suggest, apply: apply, format: format).run
1920
stimulus = Guardrails::StimulusAudit.new(root: root).run
2021
exit 1 if violations.any? || stimulus.violations?
2122
end
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# frozen_string_literal: true
2+
3+
require "tmpdir"
4+
require "fileutils"
5+
require "stringio"
6+
require "guardrails/audit"
7+
require "guardrails/audit/auto_fixer"
8+
require "guardrails/tokens"
9+
10+
RSpec.describe Guardrails::Audit::AutoFixer do
11+
let(:root) { Pathname(Dir.mktmpdir) }
12+
after { FileUtils.rm_rf(root) }
13+
14+
def write_view(relative, content)
15+
full = root.join(relative)
16+
full.dirname.mkpath
17+
full.write(content)
18+
end
19+
20+
def view_content(relative)
21+
root.join(relative).read(encoding: Encoding::UTF_8)
22+
end
23+
24+
def violation(type:, file:, line:, column:, value:, snippet: "")
25+
Guardrails::Audit::Violation.new(
26+
type: type, file: file, line: line, column: column, snippet: snippet, value: value
27+
)
28+
end
29+
30+
def token(name:, value:, syntax: :css_var)
31+
Guardrails::Tokens::Token.new(
32+
name: name, value: value, syntax: syntax,
33+
file: "tokens.css", line: 1
34+
)
35+
end
36+
37+
describe "#apply" do
38+
it "rewrites a raw_color hex with var(--token) when an exact CSS-var match exists" do
39+
write_view "app/views/x.html.erb", '<svg fill="#0066ff"></svg>'
40+
v = violation(type: :raw_color, file: "app/views/x.html.erb", line: 1, column: 12, value: "#0066ff")
41+
tokens = [token(name: "primary-500", value: "#0066ff")]
42+
43+
described_class.new(root, output: StringIO.new, tokens: tokens).apply([v])
44+
45+
expect(view_content("app/views/x.html.erb")).to eq('<svg fill="var(--primary-500)"></svg>')
46+
end
47+
48+
it "matches normalized hex (case + short form)" do
49+
write_view "app/views/x.html.erb", '<svg fill="#fa3"></svg>'
50+
v = violation(type: :raw_color, file: "app/views/x.html.erb", line: 1, column: 12, value: "#fa3")
51+
tokens = [token(name: "secondary", value: "#FFAA33")]
52+
53+
described_class.new(root, output: StringIO.new, tokens: tokens).apply([v])
54+
55+
expect(view_content("app/views/x.html.erb")).to include("var(--secondary)")
56+
end
57+
58+
it "applies multiple replacements on the same line right-to-left" do
59+
write_view "app/views/x.html.erb", '<svg fill="#0066ff" stroke="#fa3"></svg>'
60+
vs = [
61+
violation(type: :raw_color, file: "app/views/x.html.erb", line: 1, column: 12, value: "#0066ff"),
62+
violation(type: :raw_color, file: "app/views/x.html.erb", line: 1, column: 29, value: "#fa3")
63+
]
64+
tokens = [
65+
token(name: "primary", value: "#0066ff"),
66+
token(name: "secondary", value: "#fa3")
67+
]
68+
69+
described_class.new(root, output: StringIO.new, tokens: tokens).apply(vs)
70+
71+
content = view_content("app/views/x.html.erb")
72+
expect(content).to include("var(--primary)")
73+
expect(content).to include("var(--secondary)")
74+
end
75+
76+
it "does not apply for SCSS variable tokens (not valid in views)" do
77+
write_view "app/views/x.html.erb", '<svg fill="#0066ff"></svg>'
78+
v = violation(type: :raw_color, file: "app/views/x.html.erb", line: 1, column: 12, value: "#0066ff")
79+
tokens = [token(name: "primary", value: "#0066ff", syntax: :scss_var)]
80+
81+
result = described_class.new(root, output: StringIO.new, tokens: tokens).apply([v])
82+
83+
expect(result).to be_empty
84+
expect(view_content("app/views/x.html.erb")).to eq('<svg fill="#0066ff"></svg>')
85+
end
86+
87+
it "does not apply when no token matches" do
88+
write_view "app/views/x.html.erb", '<svg fill="#abcdef"></svg>'
89+
v = violation(type: :raw_color, file: "app/views/x.html.erb", line: 1, column: 12, value: "#abcdef")
90+
tokens = [token(name: "primary", value: "#0066ff")]
91+
92+
result = described_class.new(root, output: StringIO.new, tokens: tokens).apply([v])
93+
94+
expect(result).to be_empty
95+
expect(view_content("app/views/x.html.erb")).to eq('<svg fill="#abcdef"></svg>')
96+
end
97+
98+
it "skips inline_style violations" do
99+
v = violation(type: :inline_style, file: "app/views/x.html.erb", line: 1, column: 1, value: 'style="color: red"')
100+
tokens = [token(name: "primary", value: "#0066ff")]
101+
102+
fixer = described_class.new(root, output: StringIO.new, tokens: tokens)
103+
expect(fixer.applicable?(v)).to be(false)
104+
end
105+
106+
it "skips tailwind_arbitrary violations" do
107+
v = violation(type: :tailwind_arbitrary, file: "app/views/x.html.erb", line: 1, column: 1, value: "#0066ff")
108+
tokens = [token(name: "primary", value: "#0066ff")]
109+
110+
fixer = described_class.new(root, output: StringIO.new, tokens: tokens)
111+
expect(fixer.applicable?(v)).to be(false)
112+
end
113+
114+
it "verifies the value is at the expected column before replacing" do
115+
write_view "app/views/x.html.erb", "<p>different content</p>"
116+
v = violation(type: :raw_color, file: "app/views/x.html.erb", line: 1, column: 12, value: "#0066ff")
117+
tokens = [token(name: "primary", value: "#0066ff")]
118+
119+
result = described_class.new(root, output: StringIO.new, tokens: tokens).apply([v])
120+
121+
expect(result).to be_empty
122+
expect(view_content("app/views/x.html.erb")).to eq("<p>different content</p>")
123+
end
124+
125+
it "returns Result entries with the violation, token, and replacement string" do
126+
write_view "app/views/x.html.erb", '<svg fill="#0066ff"></svg>'
127+
v = violation(type: :raw_color, file: "app/views/x.html.erb", line: 1, column: 12, value: "#0066ff")
128+
tokens = [token(name: "primary-500", value: "#0066ff")]
129+
130+
result = described_class.new(root, output: StringIO.new, tokens: tokens).apply([v])
131+
132+
expect(result.length).to eq(1)
133+
expect(result.first.token.name).to eq("primary-500")
134+
expect(result.first.replacement).to eq("var(--primary-500)")
135+
end
136+
137+
it "applies fixes across multiple files" do
138+
write_view "app/views/a.html.erb", '<svg fill="#0066ff"></svg>'
139+
write_view "app/views/b.html.erb", '<svg fill="#0066ff"></svg>'
140+
vs = [
141+
violation(type: :raw_color, file: "app/views/a.html.erb", line: 1, column: 12, value: "#0066ff"),
142+
violation(type: :raw_color, file: "app/views/b.html.erb", line: 1, column: 12, value: "#0066ff")
143+
]
144+
tokens = [token(name: "primary", value: "#0066ff")]
145+
146+
described_class.new(root, output: StringIO.new, tokens: tokens).apply(vs)
147+
148+
expect(view_content("app/views/a.html.erb")).to include("var(--primary)")
149+
expect(view_content("app/views/b.html.erb")).to include("var(--primary)")
150+
end
151+
end
152+
end

0 commit comments

Comments
 (0)