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,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
|