ui_guardrails 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +302 -0
- data/doc/A11Y.md +87 -0
- data/doc/LOOKBOOK.md +52 -0
- data/doc/PRD.md +145 -0
- data/doc/PUBLISHING.md +98 -0
- data/doc/ROADMAP.md +158 -0
- data/doc/UPSTREAM-snap_diff-issue-draft.md +63 -0
- data/doc/VISUAL-DIFF.md +135 -0
- data/lib/guardrails/a11y_audit.rb +249 -0
- data/lib/guardrails/a11y_deep.rb +119 -0
- data/lib/guardrails/audit/auto_fixer.rb +155 -0
- data/lib/guardrails/audit/markdown_writer.rb +218 -0
- data/lib/guardrails/audit.rb +472 -0
- data/lib/guardrails/class_itis.rb +196 -0
- data/lib/guardrails/configuration.rb +101 -0
- data/lib/guardrails/cross_codebase_patterns.rb +242 -0
- data/lib/guardrails/erb_parser.rb +91 -0
- data/lib/guardrails/hex_normalizer.rb +47 -0
- data/lib/guardrails/icons.rb +233 -0
- data/lib/guardrails/init/config_writer.rb +101 -0
- data/lib/guardrails/init/media_query_scaffolder.rb +60 -0
- data/lib/guardrails/init/prompter.rb +60 -0
- data/lib/guardrails/init/stack_detector.rb +108 -0
- data/lib/guardrails/init.rb +115 -0
- data/lib/guardrails/lookbook/component_report.rb +78 -0
- data/lib/guardrails/lookbook/panel_registration.rb +93 -0
- data/lib/guardrails/lookbook/views/lookbook_panels/_guardrails.html.erb +44 -0
- data/lib/guardrails/partial_similarity.rb +231 -0
- data/lib/guardrails/railtie.rb +23 -0
- data/lib/guardrails/stimulus_audit.rb +118 -0
- data/lib/guardrails/token_matcher.rb +40 -0
- data/lib/guardrails/tokens/tailwind_config_parser.rb +140 -0
- data/lib/guardrails/tokens.rb +256 -0
- data/lib/guardrails/version.rb +5 -0
- data/lib/guardrails/view_component_audit.rb +150 -0
- data/lib/guardrails/visual_diff/snap_diff.rb +81 -0
- data/lib/guardrails/visual_diff.rb +117 -0
- data/lib/guardrails.rb +14 -0
- data/lib/tasks/guardrails.rake +176 -0
- data/lib/ui_guardrails.rb +9 -0
- metadata +145 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "set"
|
|
5
|
+
require_relative "erb_parser"
|
|
6
|
+
|
|
7
|
+
module Guardrails
|
|
8
|
+
# Static a11y checks that don't require a browser — element-level rules
|
|
9
|
+
# that can be answered from the source. For runtime a11y (color contrast,
|
|
10
|
+
# focus order, ARIA tree, dynamic content) integrate axe-core-rspec
|
|
11
|
+
# alongside Guardrails; see doc/A11Y.md.
|
|
12
|
+
class A11yAudit
|
|
13
|
+
Finding = Struct.new(:rule, :file, :line, :column, :snippet, keyword_init: true) do
|
|
14
|
+
def to_h
|
|
15
|
+
{ rule: rule, file: file, line: line, column: column, snippet: snippet }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
SCAN_PATTERNS = [
|
|
20
|
+
"app/views/**/*.html.erb",
|
|
21
|
+
"app/components/**/*.html.erb"
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
NON_INTERACTIVE_INPUT_TYPES = %w[hidden submit button reset image].freeze
|
|
25
|
+
|
|
26
|
+
def initialize(root:, output: $stdout)
|
|
27
|
+
@root = Pathname(root)
|
|
28
|
+
@output = output
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run
|
|
32
|
+
findings = view_files.flat_map { |path| scan_file(path) }
|
|
33
|
+
print_report(findings)
|
|
34
|
+
findings
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def view_files
|
|
40
|
+
SCAN_PATTERNS
|
|
41
|
+
.flat_map { |pattern| Dir.glob(@root.join(pattern)) }
|
|
42
|
+
.map { |path| Pathname(path) }
|
|
43
|
+
.uniq
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def scan_file(path)
|
|
47
|
+
content = File.read(path, encoding: Encoding::UTF_8)
|
|
48
|
+
lines = content.lines
|
|
49
|
+
result = ErbParser.parse(content)
|
|
50
|
+
@current_document = result.document
|
|
51
|
+
@label_for_cache = nil
|
|
52
|
+
|
|
53
|
+
findings = []
|
|
54
|
+
ErbParser.each_node(result.document) do |node|
|
|
55
|
+
case node
|
|
56
|
+
when ::Herb::AST::HTMLElementNode then findings.concat(check_element(node, path, lines))
|
|
57
|
+
when ::Herb::AST::HTMLOpenTagNode then findings.concat(check_void(node, path, lines)) if void_tag?(node)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
findings
|
|
61
|
+
ensure
|
|
62
|
+
@current_document = nil
|
|
63
|
+
@label_for_cache = nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def check_element(element, file, lines)
|
|
67
|
+
tag = element_tag_name(element)
|
|
68
|
+
case tag
|
|
69
|
+
when "button" then check_button(element, file, lines)
|
|
70
|
+
when "a" then check_link(element, file, lines)
|
|
71
|
+
else []
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def check_void(open_tag, file, lines)
|
|
76
|
+
tag = open_tag_name(open_tag)
|
|
77
|
+
case tag
|
|
78
|
+
when "img" then check_img(open_tag, file, lines)
|
|
79
|
+
when "input" then check_input(open_tag, file, lines)
|
|
80
|
+
else []
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def check_img(open_tag, file, lines)
|
|
85
|
+
return [] if attribute_present?(open_tag, "alt")
|
|
86
|
+
|
|
87
|
+
[build_finding(:image_alt, open_tag, file, lines)]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def check_button(element, file, lines)
|
|
91
|
+
attrs_node = element.open_tag
|
|
92
|
+
return [] if attribute_present?(attrs_node, "aria-label") || attribute_present?(attrs_node, "aria-labelledby")
|
|
93
|
+
# Defer to helper_recommended for ERB-output bodies — the suggestion
|
|
94
|
+
# there is more actionable than a generic missing-name flag.
|
|
95
|
+
return [] if body_contains_erb_output?(element)
|
|
96
|
+
return [] if visible_text_in(element).length.positive?
|
|
97
|
+
|
|
98
|
+
[build_finding(:button_name, attrs_node, file, lines)]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def check_link(element, file, lines)
|
|
102
|
+
attrs_node = element.open_tag
|
|
103
|
+
return [] unless attribute_present?(attrs_node, "href")
|
|
104
|
+
return [] if attribute_present?(attrs_node, "aria-label") || attribute_present?(attrs_node, "aria-labelledby")
|
|
105
|
+
return [] if attribute_present?(attrs_node, "title")
|
|
106
|
+
return [] if body_contains_erb_output?(element)
|
|
107
|
+
return [] if visible_text_in(element).length.positive?
|
|
108
|
+
|
|
109
|
+
[build_finding(:link_name, attrs_node, file, lines)]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def check_input(open_tag, file, lines)
|
|
113
|
+
type = (attribute_static_value(open_tag, "type") || "text").downcase
|
|
114
|
+
return [] if NON_INTERACTIVE_INPUT_TYPES.include?(type)
|
|
115
|
+
return [] if attribute_present?(open_tag, "aria-label") || attribute_present?(open_tag, "aria-labelledby")
|
|
116
|
+
|
|
117
|
+
id = attribute_static_value(open_tag, "id")
|
|
118
|
+
return [] if id && labeled_by_for?(id)
|
|
119
|
+
|
|
120
|
+
[build_finding(:input_label, open_tag, file, lines)]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def void_tag?(open_tag)
|
|
124
|
+
%w[img input].include?(open_tag_name(open_tag))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def open_tag_name(open_tag)
|
|
128
|
+
tok = open_tag.respond_to?(:tag_name) ? open_tag.tag_name : nil
|
|
129
|
+
tok && tok.respond_to?(:value) ? tok.value.to_s.downcase : nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def element_tag_name(element)
|
|
133
|
+
open_tag_name(element.open_tag) if element.respond_to?(:open_tag) && element.open_tag
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def attribute_nodes(open_tag)
|
|
137
|
+
ErbParser.compact_children(open_tag).select { |c| c.is_a?(::Herb::AST::HTMLAttributeNode) }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def attribute_present?(open_tag, name)
|
|
141
|
+
attribute_nodes(open_tag).any? { |attr| attribute_name(attr) == name.downcase }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def attribute_name(attr)
|
|
145
|
+
name_wrapper, _ = ErbParser.compact_children(attr)
|
|
146
|
+
return nil unless name_wrapper
|
|
147
|
+
|
|
148
|
+
lit = ErbParser.compact_children(name_wrapper).first
|
|
149
|
+
return nil unless lit && lit.respond_to?(:content)
|
|
150
|
+
|
|
151
|
+
lit.content.respond_to?(:value) ? lit.content.value.to_s.downcase : lit.content.to_s.downcase
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def attribute_static_value(open_tag, name)
|
|
155
|
+
attr = attribute_nodes(open_tag).find { |a| attribute_name(a) == name.downcase }
|
|
156
|
+
return nil unless attr
|
|
157
|
+
|
|
158
|
+
_name_wrapper, value_wrapper = ErbParser.compact_children(attr)
|
|
159
|
+
return nil unless value_wrapper
|
|
160
|
+
|
|
161
|
+
ErbParser.compact_children(value_wrapper).filter_map do |child|
|
|
162
|
+
next unless child.is_a?(::Herb::AST::LiteralNode)
|
|
163
|
+
|
|
164
|
+
c = child.content
|
|
165
|
+
c.respond_to?(:value) ? c.value.to_s : c.to_s
|
|
166
|
+
end.join
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Walks the element's body subtree (including nested elements) for
|
|
170
|
+
# `<%=` ERB output. Descendant-aware so that
|
|
171
|
+
# `<button><span><%= label %></span></button>` is recognized as
|
|
172
|
+
# wrapping ERB output and defers to helper_recommended — same
|
|
173
|
+
# behavior the previous regex provided.
|
|
174
|
+
def body_contains_erb_output?(element)
|
|
175
|
+
body_nodes = Array(element.respond_to?(:body) ? element.body : [])
|
|
176
|
+
body_nodes.any? { |child| descendant_has_erb_output?(child) }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def descendant_has_erb_output?(node)
|
|
180
|
+
return false if node.nil?
|
|
181
|
+
|
|
182
|
+
if node.is_a?(::Herb::AST::ERBContentNode)
|
|
183
|
+
opening = node.tag_opening
|
|
184
|
+
return (opening.respond_to?(:value) ? opening.value : opening.to_s) == "<%="
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
ErbParser.compact_children(node).any? { |child| descendant_has_erb_output?(child) }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Concatenated visible text content from descendant HTMLTextNodes —
|
|
191
|
+
# walks the entire subtree, including text inside nested elements
|
|
192
|
+
# (`<button><span>Save</span></button>` returns "Save"). ERB nodes
|
|
193
|
+
# don't contribute; the dynamic-content case is handled separately
|
|
194
|
+
# by body_contains_erb_output?.
|
|
195
|
+
def visible_text_in(element)
|
|
196
|
+
text = +""
|
|
197
|
+
ErbParser.each_node(element).each do |node|
|
|
198
|
+
next unless node.is_a?(::Herb::AST::HTMLTextNode)
|
|
199
|
+
|
|
200
|
+
c = node.content
|
|
201
|
+
text << (c.respond_to?(:value) ? c.value.to_s : c.to_s)
|
|
202
|
+
end
|
|
203
|
+
text.strip
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def labeled_by_for?(id)
|
|
207
|
+
@label_for_cache ||= collect_labels_for_ids
|
|
208
|
+
@label_for_cache.include?(id)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Walk the AST once per file to collect every `<label>` element's
|
|
212
|
+
# `for` attribute value into a Set. Cached for the duration of one
|
|
213
|
+
# file's scan_file call so successive labeled_by_for? checks are O(1).
|
|
214
|
+
def collect_labels_for_ids
|
|
215
|
+
ids = Set.new
|
|
216
|
+
ErbParser.each_node(@current_document) do |node|
|
|
217
|
+
next unless node.is_a?(::Herb::AST::HTMLElementNode)
|
|
218
|
+
next unless element_tag_name(node) == "label"
|
|
219
|
+
|
|
220
|
+
v = attribute_static_value(node.open_tag, "for")
|
|
221
|
+
ids << v if v
|
|
222
|
+
end
|
|
223
|
+
ids
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def build_finding(rule, node, file, lines)
|
|
227
|
+
line, column = ErbParser.start_position(node)
|
|
228
|
+
Finding.new(
|
|
229
|
+
rule: rule,
|
|
230
|
+
file: file.relative_path_from(@root).to_s,
|
|
231
|
+
line: line,
|
|
232
|
+
column: column,
|
|
233
|
+
snippet: lines[line - 1]&.chomp&.strip
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def print_report(findings)
|
|
238
|
+
return if findings.empty?
|
|
239
|
+
|
|
240
|
+
@output.puts ""
|
|
241
|
+
noun = findings.length == 1 ? "issue" : "issues"
|
|
242
|
+
@output.puts "Guardrails a11y: #{findings.length} static #{noun} found"
|
|
243
|
+
findings.each do |f|
|
|
244
|
+
@output.puts " [#{f.rule}] #{f.file}:#{f.line}:#{f.column}"
|
|
245
|
+
@output.puts " #{f.snippet}"
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "set"
|
|
6
|
+
|
|
7
|
+
module Guardrails
|
|
8
|
+
# Consumes axe-core JSON output and folds the findings into Guardrails'
|
|
9
|
+
# unified report. Distinct from `A11yAudit`, which runs static template
|
|
10
|
+
# checks (image_alt, button_name, etc.) without rendering.
|
|
11
|
+
#
|
|
12
|
+
# Why parse-only, no run: bundling axe-core means bundling Capybara +
|
|
13
|
+
# headless Chrome as runtime deps of a static-analysis gem — too heavy
|
|
14
|
+
# for users who don't run system tests. Instead, users run axe-core
|
|
15
|
+
# however they already do (axe-core-rspec, `npx @axe-core/cli`, a
|
|
16
|
+
# CDP-driven script) and pass the JSON output to Guardrails:
|
|
17
|
+
#
|
|
18
|
+
# npx @axe-core/cli http://localhost:3000/ --save axe.json
|
|
19
|
+
# AXE_JSON=axe.json bundle exec rake guardrails:audit
|
|
20
|
+
#
|
|
21
|
+
# The result is a single unified report — static findings from
|
|
22
|
+
# A11yAudit plus runtime findings from axe — with the same exit-code
|
|
23
|
+
# semantics as the rest of the audit.
|
|
24
|
+
class A11yDeep
|
|
25
|
+
Finding = Struct.new(:rule, :impact, :description, :help_url, :url, :selector, keyword_init: true) do
|
|
26
|
+
def to_h
|
|
27
|
+
super
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Impacts we treat as audit-failing. axe emits one of: minor, moderate,
|
|
32
|
+
# serious, critical (and sometimes nil for `incomplete` findings or
|
|
33
|
+
# custom rule packs). The 0.6.0 default is "any known non-nil impact
|
|
34
|
+
# fails" — covers axe's full impact ladder and aligns with the static
|
|
35
|
+
# a11y rules which all fail unconditionally. Findings with a nil/
|
|
36
|
+
# unknown impact do NOT fail by default; tighten per-call via
|
|
37
|
+
# `failing_impacts:` if your rule pack emits custom severities.
|
|
38
|
+
DEFAULT_FAILING_IMPACTS = %w[minor moderate serious critical].freeze
|
|
39
|
+
|
|
40
|
+
def initialize(input:, output: $stdout, failing_impacts: DEFAULT_FAILING_IMPACTS)
|
|
41
|
+
@input = input
|
|
42
|
+
@output = output
|
|
43
|
+
@failing_impacts = Set.new(failing_impacts.map(&:to_s))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def run
|
|
47
|
+
findings = parse_input
|
|
48
|
+
print_report(findings)
|
|
49
|
+
findings
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Parse axe-core JSON. Accepts either a single result object
|
|
53
|
+
# `{ "url": "...", "violations": [...] }` or an array of results
|
|
54
|
+
# (multi-page runs, what `axe-core/cli --save` produces). Returns
|
|
55
|
+
# a flat list of Finding structs.
|
|
56
|
+
def parse(payload)
|
|
57
|
+
pages = payload.is_a?(Array) ? payload : [payload]
|
|
58
|
+
pages.flat_map { |page| parse_page(page) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def any_failing?(findings)
|
|
62
|
+
findings.any? { |f| @failing_impacts.include?(f.impact.to_s) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def parse_input
|
|
68
|
+
raw = @input.is_a?(Hash) || @input.is_a?(Array) ? @input : JSON.parse(File.read(@input.to_s, encoding: Encoding::UTF_8))
|
|
69
|
+
parse(raw)
|
|
70
|
+
rescue Errno::ENOENT
|
|
71
|
+
@output.puts "Guardrails a11y (deep): #{@input} not found — skipping"
|
|
72
|
+
[]
|
|
73
|
+
rescue JSON::ParserError => e
|
|
74
|
+
@output.puts "Guardrails a11y (deep): could not parse #{@input} — #{e.message}"
|
|
75
|
+
[]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def parse_page(page)
|
|
79
|
+
return [] unless page.is_a?(Hash)
|
|
80
|
+
|
|
81
|
+
url = page["url"]
|
|
82
|
+
Array(page["violations"]).flat_map do |violation|
|
|
83
|
+
rule = violation["id"]
|
|
84
|
+
impact = violation["impact"]
|
|
85
|
+
description = violation["help"] || violation["description"]
|
|
86
|
+
help_url = violation["helpUrl"]
|
|
87
|
+
Array(violation["nodes"]).map do |node|
|
|
88
|
+
Finding.new(
|
|
89
|
+
rule: rule,
|
|
90
|
+
impact: impact,
|
|
91
|
+
description: description,
|
|
92
|
+
help_url: help_url,
|
|
93
|
+
url: url,
|
|
94
|
+
selector: Array(node["target"]).first
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def print_report(findings)
|
|
101
|
+
return if findings.empty?
|
|
102
|
+
|
|
103
|
+
grouped = findings.group_by(&:url)
|
|
104
|
+
@output.puts ""
|
|
105
|
+
@output.puts "Guardrails a11y (deep): #{findings.length} finding#{'s' if findings.length != 1} from axe-core"
|
|
106
|
+
|
|
107
|
+
grouped.each do |url, page_findings|
|
|
108
|
+
@output.puts ""
|
|
109
|
+
@output.puts " #{url || '(no url)'}"
|
|
110
|
+
page_findings.each do |f|
|
|
111
|
+
impact_label = f.impact ? "[#{f.impact}]" : "[unknown]"
|
|
112
|
+
selector = f.selector ? " (#{f.selector})" : ""
|
|
113
|
+
@output.puts " #{impact_label} #{f.rule} — #{f.description}#{selector}"
|
|
114
|
+
@output.puts " #{f.help_url}" if f.help_url
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require_relative "../hex_normalizer"
|
|
5
|
+
require_relative "../token_matcher"
|
|
6
|
+
|
|
7
|
+
module Guardrails
|
|
8
|
+
class Audit
|
|
9
|
+
class AutoFixer
|
|
10
|
+
Result = Struct.new(:violation, :token, :kind, :distance, :replacement, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
# Each violation type maps to the token syntaxes that can substitute
|
|
13
|
+
# for it in source. raw_color in a view attribute can only become
|
|
14
|
+
# var(--name); tailwind_arbitrary in a class string can only become
|
|
15
|
+
# a named utility derived from a Tailwind theme color.
|
|
16
|
+
COMPATIBLE_SYNTAX = {
|
|
17
|
+
raw_color: [:css_var],
|
|
18
|
+
tailwind_arbitrary: [:tailwind]
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
def initialize(root, output: $stdout, tokens: [], near_match_policy: "notify",
|
|
22
|
+
near_match_threshold: TokenMatcher::NEAR_MATCH_THRESHOLD)
|
|
23
|
+
@root = Pathname(root)
|
|
24
|
+
@output = output
|
|
25
|
+
@near_match_policy = near_match_policy
|
|
26
|
+
@matchers = build_matchers(tokens, near_match_threshold)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def apply(violations)
|
|
30
|
+
applicable = violations.select { |v| applicable?(v) }
|
|
31
|
+
return [] if applicable.empty?
|
|
32
|
+
|
|
33
|
+
applied = applicable.group_by(&:file).flat_map do |file, file_violations|
|
|
34
|
+
process_file(@root.join(file), file_violations)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
report(applied)
|
|
38
|
+
applied
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def applicable?(violation)
|
|
42
|
+
!applicable_match(violation).nil?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def build_matchers(tokens, threshold)
|
|
48
|
+
COMPATIBLE_SYNTAX.transform_values do |syntaxes|
|
|
49
|
+
subset = tokens.select { |t| syntaxes.include?(t.syntax) }
|
|
50
|
+
TokenMatcher.new(subset, near_match_threshold: threshold)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def applicable_match(violation)
|
|
55
|
+
matcher = @matchers[violation.type]
|
|
56
|
+
return nil unless matcher
|
|
57
|
+
|
|
58
|
+
match = matcher.match(violation.value)
|
|
59
|
+
return nil unless match
|
|
60
|
+
return match if match.kind == :exact
|
|
61
|
+
return match if match.kind == :near && @near_match_policy == "fix"
|
|
62
|
+
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def process_file(path, violations)
|
|
67
|
+
return [] unless path.exist?
|
|
68
|
+
|
|
69
|
+
original = File.read(path, encoding: Encoding::UTF_8)
|
|
70
|
+
lines = original.lines
|
|
71
|
+
applied = []
|
|
72
|
+
|
|
73
|
+
violations.group_by(&:line).each do |line_num, line_violations|
|
|
74
|
+
line_idx = line_num - 1
|
|
75
|
+
next unless lines[line_idx]
|
|
76
|
+
|
|
77
|
+
line_violations.sort_by { |v| -v.column }.each do |v|
|
|
78
|
+
match = applicable_match(v)
|
|
79
|
+
next unless match
|
|
80
|
+
|
|
81
|
+
edit = build_edit(v, match, lines[line_idx])
|
|
82
|
+
next unless edit
|
|
83
|
+
|
|
84
|
+
start_idx, length, replacement = edit
|
|
85
|
+
lines[line_idx] = lines[line_idx][0...start_idx] + replacement + lines[line_idx][(start_idx + length)..]
|
|
86
|
+
applied << Result.new(
|
|
87
|
+
violation: v,
|
|
88
|
+
token: match.token,
|
|
89
|
+
kind: match.kind,
|
|
90
|
+
distance: match.distance,
|
|
91
|
+
replacement: replacement
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
new_content = lines.join
|
|
97
|
+
File.write(path, new_content, encoding: Encoding::UTF_8) if new_content != original
|
|
98
|
+
applied
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Returns [start_idx, length, replacement_string] for the in-line edit,
|
|
102
|
+
# or nil if the source no longer matches the violation's expected text.
|
|
103
|
+
def build_edit(violation, match, line)
|
|
104
|
+
case violation.type
|
|
105
|
+
when :raw_color
|
|
106
|
+
start_idx = violation.column - 1
|
|
107
|
+
length = violation.value.length
|
|
108
|
+
return nil unless line[start_idx, length] == violation.value
|
|
109
|
+
|
|
110
|
+
[start_idx, length, "var(--#{match.token.name})"]
|
|
111
|
+
when :tailwind_arbitrary
|
|
112
|
+
tailwind_edit(violation, match, line)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# For `bg-[#0066ff]`, walk back from the `[` to the start of the prefix
|
|
117
|
+
# (stopping at whitespace, quote, or `:` to preserve variants like
|
|
118
|
+
# `lg:hover:bg-[...]`), then replace the whole `prefix-[value]` span
|
|
119
|
+
# with `prefix-tokenname`.
|
|
120
|
+
def tailwind_edit(violation, match, line)
|
|
121
|
+
bracket_start = violation.column - 1
|
|
122
|
+
return nil unless line[bracket_start] == "["
|
|
123
|
+
|
|
124
|
+
bracket_end = line.index("]", bracket_start)
|
|
125
|
+
return nil unless bracket_end
|
|
126
|
+
return nil unless bracket_start.positive? && line[bracket_start - 1] == "-"
|
|
127
|
+
|
|
128
|
+
prefix_dash = bracket_start - 1
|
|
129
|
+
prefix_start = prefix_dash
|
|
130
|
+
prefix_start -= 1 while prefix_start.positive? && line[prefix_start - 1] !~ /[\s"':]/
|
|
131
|
+
|
|
132
|
+
prefix = line[prefix_start...prefix_dash]
|
|
133
|
+
return nil if prefix.empty?
|
|
134
|
+
|
|
135
|
+
full_length = bracket_end - prefix_start + 1
|
|
136
|
+
[prefix_start, full_length, "#{prefix}-#{match.token.name}"]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def report(applied)
|
|
140
|
+
return if applied.empty?
|
|
141
|
+
|
|
142
|
+
applied.group_by { |r| r.violation.type }.each do |type, results|
|
|
143
|
+
label = type == :raw_color ? "raw_color → CSS custom property" : "tailwind_arbitrary → named utility"
|
|
144
|
+
noun = results.length == 1 ? "fix" : "fixes"
|
|
145
|
+
@output.puts ""
|
|
146
|
+
@output.puts "Guardrails audit: applied #{results.length} auto-#{noun} (#{label})"
|
|
147
|
+
results.each do |r|
|
|
148
|
+
suffix = r.kind == :near ? " [NEAR MATCH, channel diff #{r.distance}]" : ""
|
|
149
|
+
@output.puts " #{r.violation.file}:#{r.violation.line} #{r.violation.value} → #{r.replacement}#{suffix}"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|