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,218 @@
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 MarkdownWriter
10
+ OUTPUT_DIR = "doc"
11
+
12
+ SUGGESTIONS = {
13
+ inline_style: {
14
+ rule: "Move styles to a stylesheet using design tokens.",
15
+ replacement: "Extract the styles to a CSS class or component stylesheet that references defined tokens."
16
+ },
17
+ raw_color: {
18
+ rule: "Replace raw color literals with a defined token.",
19
+ replacement: "Use a CSS custom property or SCSS variable from your tokens file (see guardrails.yml → tokens.colors_file)."
20
+ },
21
+ tailwind_arbitrary: {
22
+ rule: "Avoid arbitrary Tailwind values — extend the theme or use an existing utility.",
23
+ replacement: "Add this value to your Tailwind theme (e.g. theme.colors.* or theme.fontSize.*) and reference the named utility instead."
24
+ },
25
+ helper_recommended: {
26
+ rule: "Wrapping ERB output in a literal element hides intent from static analysis and a11y tooling.",
27
+ replacement: "Use the Rails helper for this element so attributes (including aria-*) flow through one place."
28
+ }
29
+ }.freeze
30
+
31
+ HELPER_REPLACEMENTS = {
32
+ "button" => "Replace with `tag.button(label, ...)` (or `button_to(label, path)` for form-submission buttons).",
33
+ "a" => "Replace with `link_to(label, path, ...)` so the link text is explicit and helper-managed."
34
+ }.freeze
35
+
36
+ # Per-violation-type token compatibility for *suggestions*, expressed
37
+ # as an ordered list of matcher layers. The first layer that produces
38
+ # any match (exact or near) wins, so the preferred substitute syntax
39
+ # is genuinely preferred even on tied near matches. tailwind_arbitrary
40
+ # tries :tailwind utility names first; if no theme entry matches, it
41
+ # falls back to :css_var (parameterized arbitrary `bg-[var(--name)]`).
42
+ COMPATIBLE_SYNTAX = {
43
+ raw_color: [[:css_var]],
44
+ tailwind_arbitrary: [[:tailwind], [:css_var]]
45
+ }.freeze
46
+
47
+ def initialize(root, output: $stdout, now: Time.now, tokens: [], near_match_policy: "notify",
48
+ near_match_threshold: TokenMatcher::NEAR_MATCH_THRESHOLD)
49
+ @root = Pathname(root)
50
+ @output = output
51
+ @now = now
52
+ @matchers = build_matchers(tokens, near_match_threshold)
53
+ @near_match_policy = near_match_policy
54
+ end
55
+
56
+ def write(violations)
57
+ path = output_path
58
+ path.dirname.mkpath
59
+ File.write(path, markdown_for(violations), encoding: Encoding::UTF_8)
60
+ @output.puts "Wrote suggestions to #{path.relative_path_from(@root)}"
61
+ path
62
+ end
63
+
64
+ private
65
+
66
+ def output_path
67
+ @root.join(OUTPUT_DIR, "guardrails-suggestions-#{timestamp}.md")
68
+ end
69
+
70
+ def timestamp
71
+ @now.utc.strftime("%Y%m%dT%H%M%SZ")
72
+ end
73
+
74
+ def markdown_for(violations)
75
+ sections = [header(violations)]
76
+ if violations.empty?
77
+ sections << "No violations to suggest fixes for. Nice work."
78
+ else
79
+ group_by_file(violations).each do |file, by_type|
80
+ sections << "## #{file}\n"
81
+ by_type.each do |type, type_violations|
82
+ sections << format_type_section(type, type_violations)
83
+ end
84
+ end
85
+ end
86
+ sections.join("\n") + "\n"
87
+ end
88
+
89
+ def header(violations)
90
+ files_touched = violations.map(&:file).uniq.length
91
+ <<~MD
92
+ # Guardrails audit — suggestions
93
+
94
+ Generated #{@now.utc.strftime('%Y-%m-%d %H:%M:%S UTC')}
95
+
96
+ - **Total violations:** #{violations.length}
97
+ - **Files touched:** #{files_touched}
98
+
99
+ ---
100
+ MD
101
+ end
102
+
103
+ def group_by_file(violations)
104
+ violations
105
+ .group_by(&:file)
106
+ .transform_values { |vs| vs.group_by(&:type) }
107
+ end
108
+
109
+ def format_type_section(type, violations)
110
+ suggestion = SUGGESTIONS.fetch(type) do
111
+ { rule: "Review this violation.", replacement: "" }
112
+ end
113
+ lines = ["### #{type} (#{violations.length})\n"]
114
+ violations.each do |v|
115
+ lines << "- [ ] **Line #{v.line}, col #{v.column}:** `#{v.snippet}`"
116
+ lines << " - **Rule:** #{suggestion[:rule]}"
117
+
118
+ match = visible_match(v)
119
+ replacement_text = helper_specific_replacement(v) || suggestion[:replacement]
120
+
121
+ if match
122
+ lines << format_match_line(v, match)
123
+ elsif !replacement_text.empty?
124
+ lines << " - **Suggested replacement:** #{replacement_text}"
125
+ end
126
+ end
127
+ lines.join("\n") + "\n"
128
+ end
129
+
130
+ def build_matchers(tokens, threshold)
131
+ COMPATIBLE_SYNTAX.transform_values do |layers|
132
+ layers.map do |syntaxes|
133
+ subset = tokens.select { |t| syntaxes.include?(t.syntax) }
134
+ TokenMatcher.new(subset, near_match_threshold: threshold)
135
+ end
136
+ end
137
+ end
138
+
139
+ def visible_match(violation)
140
+ matchers = @matchers[violation.type]
141
+ return nil unless matchers
142
+
143
+ matchers.each do |matcher|
144
+ match = matcher.match(violation.value)
145
+ next unless match
146
+ next if match.kind == :near && @near_match_policy == "leave"
147
+
148
+ return match
149
+ end
150
+ nil
151
+ end
152
+
153
+ def helper_specific_replacement(violation)
154
+ return nil unless violation.type == :helper_recommended
155
+
156
+ HELPER_REPLACEMENTS[violation.value]
157
+ end
158
+
159
+ def format_match_line(violation, match)
160
+ token = match.token
161
+ ref = format_token_reference(violation, token)
162
+ defined_at = "#{token.file}:#{token.line}"
163
+ if match.kind == :exact
164
+ " - **Suggested replacement:** Use `#{ref}` (matches token `#{token.name}` defined in `#{defined_at}`)."
165
+ else
166
+ " - **Suggested replacement (near match, channel diff #{match.distance}):** Use `#{ref}` " \
167
+ "(close to token `#{token.name}` = `#{token.value}` in `#{defined_at}`)."
168
+ end
169
+ end
170
+
171
+ def format_token_reference(violation, token)
172
+ case token.syntax
173
+ when :css_var
174
+ # For tailwind_arbitrary, the only valid replacement that stays
175
+ # inside class="..." is another arbitrary value. Wrap the var()
176
+ # in the original utility prefix so the suggestion is something
177
+ # the user can actually paste in.
178
+ if violation && violation.type == :tailwind_arbitrary
179
+ tailwind_arbitrary_with_var(violation, token)
180
+ else
181
+ "var(--#{token.name})"
182
+ end
183
+ when :scss_var then "$#{token.name}"
184
+ when :tailwind then tailwind_utility_for(violation, token)
185
+ else token.name
186
+ end
187
+ end
188
+
189
+ # Capture the *full* utility prefix (including any chained variants
190
+ # like `lg:hover:` or `[&>div]:`) from the violation's snippet, then
191
+ # append the token name. Greedy `[^\s"'`]+` followed by `-[value]`
192
+ # backtracks until the bracket boundary, so it reaches all the way to
193
+ # the start of the variant chain. Falls back to the bare token name
194
+ # if the snippet doesn't contain the original arbitrary value.
195
+ def tailwind_utility_for(violation, token)
196
+ return token.name unless violation && violation.snippet
197
+
198
+ prefix = extract_tailwind_prefix(violation)
199
+ prefix ? "#{prefix}-#{token.name}" : token.name
200
+ end
201
+
202
+ def tailwind_arbitrary_with_var(violation, token)
203
+ prefix = extract_tailwind_prefix(violation)
204
+ prefix ? "#{prefix}-[var(--#{token.name})]" : "var(--#{token.name})"
205
+ end
206
+
207
+ def extract_tailwind_prefix(violation)
208
+ return nil unless violation.snippet && violation.value
209
+
210
+ # `[^\s"'`]+` is greedy, so for `class="lg:hover:bg-[#0066ff]"` the
211
+ # engine backtracks until `-[#0066ff]` matches and the capture
212
+ # lands on the full `lg:hover:bg`.
213
+ match = violation.snippet.match(/([^\s"'`]+)-\[#{Regexp.escape(violation.value)}\]/)
214
+ match ? match[1] : nil
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,472 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "set"
5
+ require "stringio"
6
+ require "yaml"
7
+ require_relative "erb_parser"
8
+
9
+ module Guardrails
10
+ class Audit
11
+ Violation = Struct.new(:type, :file, :line, :column, :snippet, :value, keyword_init: true)
12
+
13
+ DEFAULT_SCAN_PATHS = ["app/views", "app/components"].freeze
14
+ SCAN_PATTERNS = DEFAULT_SCAN_PATHS.map { |p| "#{p}/**/*.html.erb" }.freeze
15
+
16
+ # Subtrees that should never be scanned even if they happen to contain
17
+ # ERB. These are merged with any user-supplied ignore paths from
18
+ # guardrails.yml (in addition, not in place of). Vendor / node_modules
19
+ # / tmp / public regularly contain third-party code that nobody wants
20
+ # to "fix" through this lens.
21
+ #
22
+ # Note: `audit.ignore` entries are matched as exact paths or directory
23
+ # prefixes (e.g. "app/views/layouts" excludes that subtree); they are
24
+ # NOT interpreted as shell globs.
25
+ IMPLICIT_IGNORE = %w[vendor node_modules tmp public log].freeze
26
+
27
+ # Path-component patterns that are also implicit-ignored. Mailer
28
+ # views are auto-skipped because email clients require inline
29
+ # styles — flagging them as design-system drift is incorrect by
30
+ # design.
31
+ #
32
+ # Matches both Rails conventions:
33
+ # app/views/contact_mailer/ (*_mailer convention from Talos)
34
+ # app/views/devise/mailer/ (plain `mailer` from Forem/Devise)
35
+ #
36
+ # The leading-underscore prefix is required for the prefix path so
37
+ # `_mailer_partial/` (a regular partials dir) doesn't match.
38
+ IMPLICIT_IGNORE_PATTERNS = [
39
+ /\A(?:\w+_)?mailer\z/
40
+ ].freeze
41
+
42
+ # Color literal patterns — applied to the static portion of an
43
+ # attribute value extracted via the AST.
44
+ HEX_LITERAL_PATTERN = /#[0-9a-fA-F]{3,8}\b/
45
+ RGB_LITERAL_PATTERN = /\brgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+(?:\s*,\s*[\d.]+)?\s*\)/
46
+
47
+ # Tailwind arbitrary-value bracket pattern — applied to the static
48
+ # portion of a class attribute value.
49
+ ARBITRARY_VALUE_PATTERN = /\[[^\]]+\]/
50
+
51
+ # Pattern used only to recover the on-disk `style="..."` snippet for
52
+ # the inline_style violation's `value` field. Detection itself is
53
+ # AST-based; this is just for nicer reporting.
54
+ INLINE_STYLE_PATTERN = /\bstyle\s*=\s*["'][^"']+["']/
55
+
56
+ # Elements where wrapping ERB output in literal HTML hides intent
57
+ # from static analysis. Mapped to the Rails helper that does the
58
+ # same job with attributes (including aria-*) centralized.
59
+ HELPER_RECOMMENDED_TAGS = {
60
+ "button" => "tag.button(label, ...) or button_to(label, path) for forms",
61
+ "a" => "link_to(label, path, ...)"
62
+ }.freeze
63
+
64
+ # Attributes whose values legitimately carry color literals. Scoping
65
+ # raw_color detection to these keeps href="#section" or data-id="abc"
66
+ # from being misreported as color drift.
67
+ COLOR_ATTRIBUTE_NAMES = %w[
68
+ fill stroke color bgcolor background
69
+ flood-color lighting-color stop-color
70
+ ].freeze
71
+
72
+ def initialize(root:, output: $stdout, suggest: false, format: :text, apply: false)
73
+ @root = Pathname(root)
74
+ @output = output
75
+ @suggest = suggest
76
+ @format = format
77
+ @apply = apply
78
+ @config = load_audit_config
79
+ end
80
+
81
+ def run
82
+ violations = collect_files.flat_map { |file| scan_file(file) }
83
+ print_report(violations)
84
+ remaining = @apply ? apply_auto_fixes(violations) : violations
85
+ write_suggestions(remaining) if @suggest
86
+ remaining
87
+ end
88
+
89
+ private
90
+
91
+ def load_audit_config
92
+ config_path = @root.join("guardrails.yml")
93
+ return {} unless config_path.exist?
94
+
95
+ (YAML.safe_load_file(config_path) || {}).dig("guardrails", "audit") || {}
96
+ rescue StandardError
97
+ {}
98
+ end
99
+
100
+ def scan_paths
101
+ paths = @config["scan_paths"] || DEFAULT_SCAN_PATHS
102
+ Array(paths)
103
+ end
104
+
105
+ def collect_files
106
+ patterns = scan_paths.map { |p| File.join(p, "**/*.html.erb") }
107
+ patterns
108
+ .flat_map { |pattern| Dir.glob(@root.join(pattern)) }
109
+ .map { |path| Pathname(path) }
110
+ .uniq
111
+ .reject { |path| ignored?(path) }
112
+ end
113
+
114
+ def ignored?(path)
115
+ relative = path.relative_path_from(@root).to_s
116
+ segments = relative.split("/")
117
+
118
+ # Implicit ignores match on any path component — `vendor` blocks
119
+ # both top-level `vendor/foo` and nested `app/assets/stylesheets/
120
+ # vendor/foo`. User-configured ignores keep the original prefix
121
+ # semantics so they can name specific subtrees.
122
+ return true if (IMPLICIT_IGNORE & segments).any?
123
+ return true if segments.any? { |seg| IMPLICIT_IGNORE_PATTERNS.any? { |pat| seg.match?(pat) } }
124
+
125
+ Array(@config["ignore"] || []).any? do |ignore|
126
+ relative == ignore || relative.start_with?("#{ignore}/")
127
+ end
128
+ end
129
+
130
+ def scan_file(file)
131
+ content = File.read(file, encoding: Encoding::UTF_8)
132
+ original_lines = content.lines
133
+ ast_result = ErbParser.parse(content)
134
+
135
+ detect_inline_styles_ast(ast_result, file, original_lines) +
136
+ detect_raw_color_literals_ast(ast_result, file, original_lines) +
137
+ detect_tailwind_arbitrary_ast(ast_result, file, original_lines) +
138
+ detect_helper_recommended_ast(ast_result, file, original_lines)
139
+ end
140
+
141
+ # AST-based inline_style detector. Walks the parsed document for
142
+ # HTMLElementNodes carrying a `style` attribute and emits one
143
+ # violation per such attribute.
144
+ def detect_inline_styles_ast(parse_result, file, original_lines)
145
+ results = []
146
+ ErbParser.each_node(parse_result.document) do |element|
147
+ next unless element.is_a?(::Herb::AST::HTMLElementNode)
148
+
149
+ attribute_nodes(element).each do |attr|
150
+ next unless attribute_name(attr) == "style"
151
+
152
+ line, column = ErbParser.start_position(attr)
153
+ results << Violation.new(
154
+ type: :inline_style,
155
+ file: relative(file),
156
+ line: line,
157
+ column: column,
158
+ snippet: snippet(original_lines, line - 1),
159
+ value: inline_style_text_at(original_lines, line, column)
160
+ )
161
+ end
162
+ end
163
+ results
164
+ end
165
+
166
+ # AST-based raw_color detector. Scans color-bearing attribute values
167
+ # for hex/rgb literals — but only the *static* portion of the value.
168
+ # Mixed values like `fill="<%= shade %>"` produce no static text
169
+ # against the literal pattern, so dynamic values don't false-flag.
170
+ def detect_raw_color_literals_ast(parse_result, file, original_lines)
171
+ results = []
172
+ ErbParser.each_node(parse_result.document) do |element|
173
+ next unless element.is_a?(::Herb::AST::HTMLElementNode)
174
+
175
+ attribute_nodes(element).each do |attr|
176
+ name = attribute_name(attr)
177
+ next unless color_bearing_attribute?(name)
178
+
179
+ static = static_attribute_value(attr)
180
+ next if static.nil? || static.empty?
181
+
182
+ [HEX_LITERAL_PATTERN, RGB_LITERAL_PATTERN].each do |pattern|
183
+ static.scan(pattern) do |_|
184
+ md = Regexp.last_match
185
+ line, value_start_col = attribute_value_start(attr, original_lines)
186
+ results << Violation.new(
187
+ type: :raw_color,
188
+ file: relative(file),
189
+ line: line,
190
+ column: value_start_col + md.begin(0),
191
+ snippet: snippet(original_lines, line - 1),
192
+ value: md[0]
193
+ )
194
+ end
195
+ end
196
+ end
197
+ end
198
+ results
199
+ end
200
+
201
+ COLOR_ATTRIBUTE_NAME_SET = COLOR_ATTRIBUTE_NAMES.map(&:downcase).to_set.freeze
202
+ DATA_COLOR_ATTRIBUTE_PATTERN = /\Adata-[\w-]*colou?r[\w-]*\z/i
203
+
204
+ def color_bearing_attribute?(name)
205
+ return false if name.nil?
206
+
207
+ COLOR_ATTRIBUTE_NAME_SET.include?(name) || name.match?(DATA_COLOR_ATTRIBUTE_PATTERN)
208
+ end
209
+
210
+ # Concatenate the literal portions of an attribute value. ERB-driven
211
+ # parts are skipped — we can't statically know what they'll render.
212
+ def static_attribute_value(attribute_node)
213
+ _name_wrapper, value_wrapper = ErbParser.compact_children(attribute_node)
214
+ return nil unless value_wrapper
215
+
216
+ ErbParser.compact_children(value_wrapper).filter_map do |child|
217
+ next unless child.is_a?(::Herb::AST::LiteralNode)
218
+
219
+ literal_string(child)
220
+ end.join
221
+ end
222
+
223
+ def literal_string(literal_node)
224
+ content = literal_node.content
225
+ content.respond_to?(:value) ? content.value.to_s : content.to_s
226
+ end
227
+
228
+ # Returns [line, column] for the first character of the attribute
229
+ # *value* text — past any opening quote. Used so raw_color violations
230
+ # report the column where the hex literal actually starts in source.
231
+ def attribute_value_start(attribute_node, original_lines)
232
+ _name_wrapper, value_wrapper = ErbParser.compact_children(attribute_node)
233
+ return ErbParser.start_position(attribute_node) unless value_wrapper
234
+
235
+ line, col = ErbParser.start_position(value_wrapper)
236
+ line_text = original_lines[line - 1] || ""
237
+ first_char = line_text[col - 1]
238
+ col += 1 if first_char == '"' || first_char == "'"
239
+ [line, col]
240
+ end
241
+
242
+ # Recover the on-disk `style="..."` snippet for the violation's
243
+ # `value` field — Herb doesn't surface raw attribute source.
244
+ def inline_style_text_at(original_lines, line, column)
245
+ line_text = original_lines[line - 1] || ""
246
+ tail = line_text[(column - 1)..] || ""
247
+ match = tail.match(INLINE_STYLE_PATTERN)
248
+ match ? match[0] : tail.split(/\s/, 2).first.to_s
249
+ end
250
+
251
+ # AST-based helper_recommended detector. Walks the parsed document
252
+ # for HTMLElementNodes whose tag matches HELPER_RECOMMENDED_TAGS, and
253
+ # flags ones that wrap an `<%=` ERB output (control flow `<% %>` and
254
+ # `<%# %>` comments are excluded — they're distinguished by the
255
+ # ERBContentNode#tag_opening value).
256
+ #
257
+ # Skips elements that already declare aria-label or aria-labelledby:
258
+ # the user has explicitly handled accessibility on the literal tag,
259
+ # so the suggestion to switch to `tag.button` / `link_to` is just
260
+ # idiom noise, not a real signal. Found in dogfooding against Talos
261
+ # (16 of 44 findings were on aria-labeled icon buttons).
262
+ def detect_helper_recommended_ast(parse_result, file, original_lines)
263
+ results = []
264
+ ErbParser.each_node(parse_result.document) do |node|
265
+ next unless node.is_a?(::Herb::AST::HTMLElementNode)
266
+
267
+ tag = element_tag_name(node)
268
+ next unless HELPER_RECOMMENDED_TAGS.key?(tag)
269
+ next if tag == "a" && !element_has_attribute?(node, "href")
270
+ next if element_has_attribute?(node, "aria-label") || element_has_attribute?(node, "aria-labelledby")
271
+ next unless body_contains_erb_output?(node)
272
+
273
+ line, column = ErbParser.start_position(node)
274
+ results << Violation.new(
275
+ type: :helper_recommended,
276
+ file: relative(file),
277
+ line: line,
278
+ column: column,
279
+ snippet: snippet(original_lines, line - 1),
280
+ value: tag
281
+ )
282
+ end
283
+ results
284
+ end
285
+
286
+ # HTMLElementNode#tag_name returns a Herb::Token; `.value` is the
287
+ # raw string ("button", "a", etc).
288
+ def element_tag_name(element)
289
+ return nil unless element.respond_to?(:tag_name) && element.tag_name
290
+
291
+ element.tag_name.respond_to?(:value) ? element.tag_name.value.to_s.downcase : nil
292
+ end
293
+
294
+ def element_has_attribute?(element, name)
295
+ return false unless element.respond_to?(:open_tag) && element.open_tag
296
+
297
+ attribute_nodes(element).any? { |attr| attribute_name(attr) == name.downcase }
298
+ end
299
+
300
+ def attribute_nodes(element)
301
+ open_children = ErbParser.compact_children(element.open_tag)
302
+ open_children.select { |child| child.is_a?(::Herb::AST::HTMLAttributeNode) }
303
+ end
304
+
305
+ # HTMLAttributeNode wraps a HTMLAttributeNameNode → LiteralNode chain.
306
+ # The literal's `content` is the attribute name as a Herb::Token.
307
+ def attribute_name(attribute_node)
308
+ name_wrapper, _value_wrapper = ErbParser.compact_children(attribute_node)
309
+ return nil unless name_wrapper
310
+
311
+ literal = ErbParser.compact_children(name_wrapper).first
312
+ return nil unless literal && literal.respond_to?(:content)
313
+
314
+ literal.content.respond_to?(:value) ? literal.content.value.to_s.downcase : literal.content.to_s.downcase
315
+ end
316
+
317
+ # Walks the element's body subtree (including nested elements)
318
+ # looking for any `<%=` ERB output. The previous regex scanned the
319
+ # whole body string, so deeply-nested cases like
320
+ # `<button><span><%= label %></span></button>` registered too —
321
+ # preserving that behavior matters because the helper-recommendation
322
+ # is exactly as relevant when the dynamic content is one level down.
323
+ def body_contains_erb_output?(element)
324
+ body_nodes = element.respond_to?(:body) ? Array(element.body) : []
325
+ body_nodes.any? { |child| descendant_has_erb_output?(child) }
326
+ end
327
+
328
+ def descendant_has_erb_output?(node)
329
+ return false if node.nil?
330
+
331
+ if node.is_a?(::Herb::AST::ERBContentNode)
332
+ opening = node.tag_opening
333
+ return opening.respond_to?(:value) ? opening.value == "<%=" : opening.to_s == "<%="
334
+ end
335
+
336
+ ErbParser.compact_children(node).any? { |child| descendant_has_erb_output?(child) }
337
+ end
338
+
339
+ # AST-based tailwind_arbitrary detector. Walks element class
340
+ # attributes and flags any `[...]` arbitrary value found in the
341
+ # static portion of the class string. ERB-driven class fragments
342
+ # don't contribute static text, so dynamic class names don't
343
+ # false-flag.
344
+ def detect_tailwind_arbitrary_ast(parse_result, file, original_lines)
345
+ results = []
346
+ ErbParser.each_node(parse_result.document) do |element|
347
+ next unless element.is_a?(::Herb::AST::HTMLElementNode)
348
+
349
+ attribute_nodes(element).each do |attr|
350
+ next unless attribute_name(attr) == "class"
351
+
352
+ static = static_attribute_value(attr)
353
+ next if static.nil? || static.empty?
354
+
355
+ static.scan(ARBITRARY_VALUE_PATTERN) do |_|
356
+ md = Regexp.last_match
357
+ inner_value = md[0][1..-2] # strip [ and ]
358
+ line, value_start_col = attribute_value_start(attr, original_lines)
359
+ results << Violation.new(
360
+ type: :tailwind_arbitrary,
361
+ file: relative(file),
362
+ line: line,
363
+ column: value_start_col + md.begin(0),
364
+ snippet: snippet(original_lines, line - 1),
365
+ value: inner_value
366
+ )
367
+ end
368
+ end
369
+ end
370
+ results
371
+ end
372
+
373
+ def relative(file)
374
+ file.relative_path_from(@root).to_s
375
+ end
376
+
377
+ def snippet(lines, idx)
378
+ lines[idx]&.chomp&.strip
379
+ end
380
+
381
+ def apply_auto_fixes(violations)
382
+ require_relative "audit/auto_fixer"
383
+ fixer = AutoFixer.new(
384
+ @root,
385
+ output: @output,
386
+ tokens: load_tokens,
387
+ near_match_policy: near_match_policy,
388
+ near_match_threshold: near_match_threshold
389
+ )
390
+ applied = fixer.apply(violations)
391
+ fixed_keys = applied.map { |r| [r.violation.file, r.violation.line, r.violation.column] }.to_set
392
+ violations.reject { |v| fixed_keys.include?([v.file, v.line, v.column]) }
393
+ end
394
+
395
+ def write_suggestions(violations)
396
+ require_relative "audit/markdown_writer"
397
+ MarkdownWriter.new(
398
+ @root,
399
+ output: @output,
400
+ tokens: load_tokens,
401
+ near_match_policy: near_match_policy,
402
+ near_match_threshold: near_match_threshold
403
+ ).write(violations)
404
+ end
405
+
406
+ def near_match_policy
407
+ tokens_config["near_match_policy"] || "notify"
408
+ end
409
+
410
+ def near_match_threshold
411
+ raw = tokens_config["near_match_threshold"]
412
+ raw.is_a?(Numeric) ? raw : 4
413
+ end
414
+
415
+ def tokens_config
416
+ config_path = @root.join("guardrails.yml")
417
+ return {} unless config_path.exist?
418
+
419
+ config = YAML.safe_load_file(config_path) || {}
420
+ config.dig("guardrails", "tokens") || {}
421
+ rescue StandardError
422
+ {}
423
+ end
424
+
425
+ # Returns the full token list across colors_file, type_scale_file, and
426
+ # tailwind.config.js. MarkdownWriter and AutoFixer filter by token
427
+ # syntax against the current violation type — `$primary` doesn't
428
+ # compile in HTML attrs and `bg-primary` only makes sense for
429
+ # tailwind_arbitrary contexts, so the dispatch happens at use.
430
+ def load_tokens
431
+ require_relative "tokens"
432
+ Tokens.new(root: @root, output: StringIO.new).parse_tokens
433
+ rescue StandardError
434
+ []
435
+ end
436
+
437
+ def print_report(violations)
438
+ case @format
439
+ when :json
440
+ print_json(violations)
441
+ else
442
+ print_text(violations)
443
+ end
444
+ end
445
+
446
+ def print_text(violations)
447
+ if violations.empty?
448
+ @output.puts "Guardrails audit: no violations found."
449
+ return
450
+ end
451
+
452
+ noun = violations.length == 1 ? "violation" : "violations"
453
+ @output.puts "Guardrails audit: #{violations.length} #{noun} found"
454
+ violations.each do |v|
455
+ @output.puts " [#{v.type}] #{v.file}:#{v.line}:#{v.column}"
456
+ @output.puts " #{v.snippet}"
457
+ end
458
+ end
459
+
460
+ def print_json(violations)
461
+ require "json"
462
+ payload = {
463
+ summary: {
464
+ total: violations.length,
465
+ files: violations.map(&:file).uniq.length
466
+ },
467
+ violations: violations.map(&:to_h)
468
+ }
469
+ @output.puts JSON.pretty_generate(payload)
470
+ end
471
+ end
472
+ end