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.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +302 -0
  4. data/doc/A11Y.md +87 -0
  5. data/doc/LOOKBOOK.md +52 -0
  6. data/doc/PRD.md +145 -0
  7. data/doc/PUBLISHING.md +98 -0
  8. data/doc/ROADMAP.md +158 -0
  9. data/doc/UPSTREAM-snap_diff-issue-draft.md +63 -0
  10. data/doc/VISUAL-DIFF.md +135 -0
  11. data/lib/guardrails/a11y_audit.rb +249 -0
  12. data/lib/guardrails/a11y_deep.rb +119 -0
  13. data/lib/guardrails/audit/auto_fixer.rb +155 -0
  14. data/lib/guardrails/audit/markdown_writer.rb +218 -0
  15. data/lib/guardrails/audit.rb +472 -0
  16. data/lib/guardrails/class_itis.rb +196 -0
  17. data/lib/guardrails/configuration.rb +101 -0
  18. data/lib/guardrails/cross_codebase_patterns.rb +242 -0
  19. data/lib/guardrails/erb_parser.rb +91 -0
  20. data/lib/guardrails/hex_normalizer.rb +47 -0
  21. data/lib/guardrails/icons.rb +233 -0
  22. data/lib/guardrails/init/config_writer.rb +101 -0
  23. data/lib/guardrails/init/media_query_scaffolder.rb +60 -0
  24. data/lib/guardrails/init/prompter.rb +60 -0
  25. data/lib/guardrails/init/stack_detector.rb +108 -0
  26. data/lib/guardrails/init.rb +115 -0
  27. data/lib/guardrails/lookbook/component_report.rb +78 -0
  28. data/lib/guardrails/lookbook/panel_registration.rb +93 -0
  29. data/lib/guardrails/lookbook/views/lookbook_panels/_guardrails.html.erb +44 -0
  30. data/lib/guardrails/partial_similarity.rb +231 -0
  31. data/lib/guardrails/railtie.rb +23 -0
  32. data/lib/guardrails/stimulus_audit.rb +118 -0
  33. data/lib/guardrails/token_matcher.rb +40 -0
  34. data/lib/guardrails/tokens/tailwind_config_parser.rb +140 -0
  35. data/lib/guardrails/tokens.rb +256 -0
  36. data/lib/guardrails/version.rb +5 -0
  37. data/lib/guardrails/view_component_audit.rb +150 -0
  38. data/lib/guardrails/visual_diff/snap_diff.rb +81 -0
  39. data/lib/guardrails/visual_diff.rb +117 -0
  40. data/lib/guardrails.rb +14 -0
  41. data/lib/tasks/guardrails.rake +176 -0
  42. data/lib/ui_guardrails.rb +9 -0
  43. 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