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,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Guardrails
|
|
4
|
+
class Tokens
|
|
5
|
+
# Best-effort regex/character-walk parser for Tailwind v3 config files.
|
|
6
|
+
# Extracts color entries from `theme.colors` and `theme.extend.colors`
|
|
7
|
+
# blocks. Handles flat scalars and one level of nested scales (per
|
|
8
|
+
# Tailwind convention: `gray: { 50: "#f9fafb" }` flattens to
|
|
9
|
+
# `gray-50`).
|
|
10
|
+
#
|
|
11
|
+
# Known limitations (out of scope for V0):
|
|
12
|
+
# - Function-valued entries (`primary: defineColor("blue")`)
|
|
13
|
+
# - Spread operators (`...palette`)
|
|
14
|
+
# - require()/import() of external palettes
|
|
15
|
+
# - Computed keys
|
|
16
|
+
# - TypeScript configs with type annotations on values
|
|
17
|
+
#
|
|
18
|
+
# For any of these the entry is silently skipped; the user is
|
|
19
|
+
# encouraged to migrate to Tailwind v4 `@theme` (CSS-first) which
|
|
20
|
+
# parses cleanly through the existing CSS custom property path.
|
|
21
|
+
class TailwindConfigParser
|
|
22
|
+
Entry = Struct.new(:name, :value, keyword_init: true)
|
|
23
|
+
|
|
24
|
+
COLORS_BLOCK_HEADER = /\bcolors\s*:\s*\{/
|
|
25
|
+
|
|
26
|
+
def self.parse(content)
|
|
27
|
+
new(content).parse
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(content)
|
|
31
|
+
@content = content
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def parse
|
|
35
|
+
entries = []
|
|
36
|
+
scan_colors_blocks do |body|
|
|
37
|
+
entries.concat(extract_entries(body))
|
|
38
|
+
end
|
|
39
|
+
entries
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def scan_colors_blocks
|
|
45
|
+
pos = 0
|
|
46
|
+
while (m = @content.match(COLORS_BLOCK_HEADER, pos))
|
|
47
|
+
brace_idx = m.end(0) - 1
|
|
48
|
+
body = balanced_extract(@content, brace_idx)
|
|
49
|
+
break unless body
|
|
50
|
+
|
|
51
|
+
yield body
|
|
52
|
+
pos = brace_idx + body.length + 2
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def balanced_extract(text, start_idx)
|
|
57
|
+
return nil unless text[start_idx] == "{"
|
|
58
|
+
|
|
59
|
+
depth = 0
|
|
60
|
+
i = start_idx
|
|
61
|
+
while i < text.length
|
|
62
|
+
case text[i]
|
|
63
|
+
when "{" then depth += 1
|
|
64
|
+
when "}" then depth -= 1
|
|
65
|
+
end
|
|
66
|
+
return text[(start_idx + 1)...i] if depth.zero?
|
|
67
|
+
|
|
68
|
+
i += 1
|
|
69
|
+
end
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def extract_entries(body)
|
|
74
|
+
entries = []
|
|
75
|
+
i = 0
|
|
76
|
+
while i < body.length
|
|
77
|
+
i = skip_separators(body, i)
|
|
78
|
+
break if i >= body.length
|
|
79
|
+
|
|
80
|
+
key, advance = parse_key(body, i)
|
|
81
|
+
unless key
|
|
82
|
+
# Unparseable token at this position (e.g. `...spread` or a
|
|
83
|
+
# syntax we don't support). Skip to the next comma and keep going
|
|
84
|
+
# rather than abandoning subsequent entries.
|
|
85
|
+
i = body.index(/,/, i) || body.length
|
|
86
|
+
i += 1 if i < body.length
|
|
87
|
+
next
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
i = skip_to_value(body, i + advance)
|
|
91
|
+
break if i >= body.length
|
|
92
|
+
|
|
93
|
+
if body[i] == "{"
|
|
94
|
+
nested = balanced_extract(body, i)
|
|
95
|
+
break unless nested
|
|
96
|
+
|
|
97
|
+
extract_entries(nested).each do |child|
|
|
98
|
+
entries << Entry.new(name: "#{key}-#{child.name}", value: child.value)
|
|
99
|
+
end
|
|
100
|
+
i += nested.length + 2
|
|
101
|
+
elsif (quote = body[i]) =~ /["']/
|
|
102
|
+
end_idx = body.index(quote, i + 1)
|
|
103
|
+
break unless end_idx
|
|
104
|
+
|
|
105
|
+
entries << Entry.new(name: key, value: body[(i + 1)...end_idx])
|
|
106
|
+
i = end_idx + 1
|
|
107
|
+
else
|
|
108
|
+
i = body.index(/,/, i) || body.length
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
entries
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def skip_separators(body, i)
|
|
115
|
+
i += 1 while i < body.length && body[i] =~ /[\s,]/
|
|
116
|
+
i
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def skip_to_value(body, i)
|
|
120
|
+
i += 1 while i < body.length && body[i] =~ /[\s:]/
|
|
121
|
+
i
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def parse_key(body, start)
|
|
125
|
+
if body[start] =~ /["']/
|
|
126
|
+
quote = body[start]
|
|
127
|
+
end_idx = body.index(quote, start + 1)
|
|
128
|
+
return [nil, 0] unless end_idx
|
|
129
|
+
|
|
130
|
+
[body[(start + 1)...end_idx], end_idx - start + 1]
|
|
131
|
+
else
|
|
132
|
+
m = body[start..].match(/\A([a-zA-Z_0-9][\w-]*)/)
|
|
133
|
+
return [nil, 0] unless m
|
|
134
|
+
|
|
135
|
+
[m[1], m[1].length]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require_relative "hex_normalizer"
|
|
6
|
+
require_relative "tokens/tailwind_config_parser"
|
|
7
|
+
|
|
8
|
+
module Guardrails
|
|
9
|
+
class Tokens
|
|
10
|
+
Token = Struct.new(:name, :value, :syntax, :file, :line, keyword_init: true)
|
|
11
|
+
Drift = Struct.new(:file, :line, :column, :value, :matched_token, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
CSS_VAR_PATTERN = /--([a-z][\w-]*):\s*([^;]+);/i
|
|
14
|
+
SCSS_VAR_PATTERN = /\$([a-z][\w-]*):\s*([^;]+);/i
|
|
15
|
+
HEX_LITERAL_PATTERN = /#[0-9a-fA-F]{3,8}\b/
|
|
16
|
+
BLOCK_COMMENT_PATTERN = /\/\*[\s\S]*?\*\//
|
|
17
|
+
LINE_COMMENT_PATTERN = /\/\/[^\n]*/
|
|
18
|
+
STYLESHEET_PATTERNS = [
|
|
19
|
+
"app/assets/stylesheets/**/*.{css,scss}",
|
|
20
|
+
"app/assets/tailwind/**/*.css"
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
# Same path-component skip-list as Audit / StackDetector — vendor
|
|
24
|
+
# stylesheets nested under app/assets/stylesheets/ shouldn't surface
|
|
25
|
+
# as drift since they're typically third-party.
|
|
26
|
+
IMPLICIT_IGNORE_SEGMENTS = %w[vendor node_modules tmp public log].freeze
|
|
27
|
+
|
|
28
|
+
def initialize(root:, output: $stdout)
|
|
29
|
+
@root = Pathname(root)
|
|
30
|
+
@output = output
|
|
31
|
+
@config = load_config
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def run
|
|
35
|
+
tokens = parse_tokens
|
|
36
|
+
drift = detect_drift(tokens)
|
|
37
|
+
print_summary(tokens)
|
|
38
|
+
print_drift(drift)
|
|
39
|
+
{ tokens: tokens, drift: drift }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def parse_tokens
|
|
43
|
+
tokens = []
|
|
44
|
+
[colors_file, type_scale_file].compact.each do |file|
|
|
45
|
+
next unless file.exist?
|
|
46
|
+
|
|
47
|
+
content = File.read(file, encoding: Encoding::UTF_8)
|
|
48
|
+
tokens.concat(scan(content, file, CSS_VAR_PATTERN, :css_var))
|
|
49
|
+
tokens.concat(scan(content, file, SCSS_VAR_PATTERN, :scss_var))
|
|
50
|
+
end
|
|
51
|
+
tokens.concat(parse_tailwind_config)
|
|
52
|
+
tokens
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def parse_tailwind_config
|
|
56
|
+
# Reset on every call — a Tokens instance can outlive a single run
|
|
57
|
+
# (callers may reuse it), and tailwind.config.js can change between
|
|
58
|
+
# runs. Without this, the hint can leak into later summaries after
|
|
59
|
+
# the config no longer matches the preset pattern.
|
|
60
|
+
@tailwind_preset_hint = nil
|
|
61
|
+
|
|
62
|
+
file = @root.join("tailwind.config.js")
|
|
63
|
+
return [] unless file.exist?
|
|
64
|
+
|
|
65
|
+
content = File.read(file, encoding: Encoding::UTF_8)
|
|
66
|
+
entries = TailwindConfigParser.parse(content)
|
|
67
|
+
|
|
68
|
+
# If the literal config has zero parseable entries but uses the
|
|
69
|
+
# preset import pattern, the actual tokens live in a JS file we
|
|
70
|
+
# can't evaluate. Surface a one-line hint so users don't think
|
|
71
|
+
# the parser is broken — found in the Avo dogfood where
|
|
72
|
+
# tailwind.config.js does `module.exports = { presets: [preset] }`.
|
|
73
|
+
if entries.empty? && tailwind_uses_presets?(content)
|
|
74
|
+
@tailwind_preset_hint =
|
|
75
|
+
"tailwind.config.js uses a `presets:` import; only the literal config file " \
|
|
76
|
+
"is parsed (we don't evaluate JS). Define non-color tokens in v4 `@theme` " \
|
|
77
|
+
"blocks for cross-tool token visibility."
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
entries.map do |entry|
|
|
81
|
+
Token.new(
|
|
82
|
+
name: entry.name,
|
|
83
|
+
value: entry.value,
|
|
84
|
+
syntax: :tailwind,
|
|
85
|
+
file: file.relative_path_from(@root).to_s,
|
|
86
|
+
line: 0
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def detect_drift(tokens)
|
|
92
|
+
lookup = tokens.to_h { |t| [HexNormalizer.normalize(t.value), t] }
|
|
93
|
+
drift = []
|
|
94
|
+
definition_files = [colors_file, type_scale_file].compact
|
|
95
|
+
|
|
96
|
+
stylesheets.each do |file|
|
|
97
|
+
next if definition_files.include?(file)
|
|
98
|
+
next if file == @root.join("tailwind.config.js")
|
|
99
|
+
|
|
100
|
+
raw_content = File.read(file, encoding: Encoding::UTF_8)
|
|
101
|
+
content = strip_comments(raw_content)
|
|
102
|
+
content.each_line.with_index do |line, idx|
|
|
103
|
+
next if variable_definition_line?(line)
|
|
104
|
+
|
|
105
|
+
line.scan(HEX_LITERAL_PATTERN) do
|
|
106
|
+
value = Regexp.last_match[0]
|
|
107
|
+
column = Regexp.last_match.begin(0) + 1
|
|
108
|
+
drift << Drift.new(
|
|
109
|
+
file: file.relative_path_from(@root).to_s,
|
|
110
|
+
line: idx + 1,
|
|
111
|
+
column: column,
|
|
112
|
+
value: value,
|
|
113
|
+
matched_token: lookup[HexNormalizer.normalize(value)]
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
drift
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def tailwind_uses_presets?(content)
|
|
124
|
+
content.match?(/\bpresets\s*:/)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def load_config
|
|
128
|
+
path = @root.join("guardrails.yml")
|
|
129
|
+
return {} unless path.exist?
|
|
130
|
+
|
|
131
|
+
YAML.safe_load_file(path) || {}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def colors_file
|
|
135
|
+
configured_token_file("colors_file")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def type_scale_file
|
|
139
|
+
configured_token_file("type_scale_file")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def configured_token_file(key)
|
|
143
|
+
relative = @config.dig("guardrails", "tokens", key)
|
|
144
|
+
return nil unless relative
|
|
145
|
+
|
|
146
|
+
@root.join(relative)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def scan(content, file, pattern, syntax)
|
|
150
|
+
tokens = []
|
|
151
|
+
content.each_line.with_index do |line, idx|
|
|
152
|
+
line.scan(pattern) do
|
|
153
|
+
match = Regexp.last_match
|
|
154
|
+
tokens << Token.new(
|
|
155
|
+
name: match[1],
|
|
156
|
+
value: match[2].strip,
|
|
157
|
+
syntax: syntax,
|
|
158
|
+
file: file.relative_path_from(@root).to_s,
|
|
159
|
+
line: idx + 1
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
tokens
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def stylesheets
|
|
167
|
+
STYLESHEET_PATTERNS
|
|
168
|
+
.flat_map { |pattern| Dir.glob(@root.join(pattern)) }
|
|
169
|
+
.map { |path| Pathname(path) }
|
|
170
|
+
.uniq
|
|
171
|
+
.reject { |path| ignored_segment?(path) }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def ignored_segment?(path)
|
|
175
|
+
segments = path.relative_path_from(@root).to_s.split("/")
|
|
176
|
+
(IMPLICIT_IGNORE_SEGMENTS & segments).any?
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def variable_definition_line?(line)
|
|
180
|
+
line.match?(SCSS_VAR_PATTERN) || line.match?(CSS_VAR_PATTERN)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Replace CSS/SCSS comments with whitespace, preserving line/column
|
|
184
|
+
# positions so reported drift coordinates remain accurate.
|
|
185
|
+
def strip_comments(content)
|
|
186
|
+
content
|
|
187
|
+
.gsub(BLOCK_COMMENT_PATTERN) { |m| mask_chars(m) }
|
|
188
|
+
.gsub(LINE_COMMENT_PATTERN) { |m| " " * m.length }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def mask_chars(string)
|
|
192
|
+
string.gsub(/[^\n]/, " ")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def print_drift(drift)
|
|
196
|
+
return if drift.empty?
|
|
197
|
+
|
|
198
|
+
@output.puts ""
|
|
199
|
+
@output.puts "Guardrails tokens: #{drift.length} color literal#{'s' if drift.length != 1} found in stylesheets outside the token file"
|
|
200
|
+
drift.each do |d|
|
|
201
|
+
suffix = d.matched_token ? " — matches #{format_token_name(d.matched_token)}" : " — no matching token"
|
|
202
|
+
@output.puts " #{d.file}:#{d.line}:#{d.column} #{d.value}#{suffix}"
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def format_token_name(token)
|
|
207
|
+
case token.syntax
|
|
208
|
+
when :css_var then "var(--#{token.name})"
|
|
209
|
+
when :scss_var then "$#{token.name}"
|
|
210
|
+
when :tailwind then "Tailwind theme color `#{token.name}`"
|
|
211
|
+
else token.name.to_s
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def print_summary(tokens)
|
|
216
|
+
# Iterate the two config entries explicitly so the missing-file
|
|
217
|
+
# message names the right YAML key even when both keys point at
|
|
218
|
+
# the same path (Pathname equality alone can't tell them apart).
|
|
219
|
+
configured_entries = [
|
|
220
|
+
["tokens.colors_file", colors_file],
|
|
221
|
+
["tokens.type_scale_file", type_scale_file]
|
|
222
|
+
].select { |_key, path| path }
|
|
223
|
+
|
|
224
|
+
tailwind_path = @root.join("tailwind.config.js")
|
|
225
|
+
tailwind_source = tailwind_path.exist? ? tailwind_path : nil
|
|
226
|
+
all_sources = configured_entries.map { |_, p| p } + [tailwind_source].compact
|
|
227
|
+
|
|
228
|
+
if all_sources.empty?
|
|
229
|
+
@output.puts "Guardrails tokens: no colors_file, type_scale_file, or tailwind.config.js found"
|
|
230
|
+
return
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
missing = configured_entries.reject { |_, path| path.exist? }
|
|
234
|
+
if missing.any?
|
|
235
|
+
missing.each do |key, path|
|
|
236
|
+
relative = path.relative_path_from(@root)
|
|
237
|
+
@output.puts "Guardrails tokens: configured #{key} does not exist (#{relative})"
|
|
238
|
+
end
|
|
239
|
+
@output.puts " → Edit guardrails.yml to point at your real token file, or set FORCE=1 and re-run guardrails:init to regenerate config."
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
existing_sources = all_sources.select(&:exist?)
|
|
243
|
+
return if existing_sources.empty?
|
|
244
|
+
|
|
245
|
+
labels = existing_sources.map { |f| f.relative_path_from(@root).to_s }.join(", ")
|
|
246
|
+
if tokens.empty?
|
|
247
|
+
@output.puts "Guardrails tokens: 0 tokens found in #{labels}"
|
|
248
|
+
@output.puts " → #{@tailwind_preset_hint}" if @tailwind_preset_hint
|
|
249
|
+
return
|
|
250
|
+
end
|
|
251
|
+
@output.puts "Guardrails tokens: #{tokens.length} token#{'s' if tokens.length != 1} found in #{labels}"
|
|
252
|
+
@output.puts " → #{@tailwind_preset_hint}" if @tailwind_preset_hint
|
|
253
|
+
tokens.each { |t| @output.puts " #{format_token_name(t)} = #{t.value}" }
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Guardrails
|
|
6
|
+
class ViewComponentAudit
|
|
7
|
+
Result = Struct.new(:missing_previews, :orphan_slots, keyword_init: true) do
|
|
8
|
+
def violations?
|
|
9
|
+
!missing_previews.empty? || !orphan_slots.empty?
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
OrphanSlot = Struct.new(:component, :slot, :slot_kind, :file, :line, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
COMPONENT_DIR = "app/components"
|
|
16
|
+
COMPONENT_GLOB = "**/*_component.rb"
|
|
17
|
+
PREVIEW_DIRS = [
|
|
18
|
+
"test/components/previews",
|
|
19
|
+
"spec/components/previews"
|
|
20
|
+
].freeze
|
|
21
|
+
PREVIEW_GLOB = "**/*_component_preview.rb"
|
|
22
|
+
|
|
23
|
+
SLOT_PATTERN = /^\s*(renders_one|renders_many)\s+:([a-z_][\w]*)/
|
|
24
|
+
|
|
25
|
+
def initialize(root:, output: $stdout)
|
|
26
|
+
@root = Pathname(root)
|
|
27
|
+
@output = output
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run
|
|
31
|
+
result = Result.new(
|
|
32
|
+
missing_previews: find_missing_previews,
|
|
33
|
+
orphan_slots: find_orphan_slots
|
|
34
|
+
)
|
|
35
|
+
print_report(result)
|
|
36
|
+
result
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def find_missing_previews
|
|
40
|
+
defined = collect_components
|
|
41
|
+
previewed = collect_previews
|
|
42
|
+
(defined - previewed).sort
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def find_orphan_slots
|
|
46
|
+
orphans = []
|
|
47
|
+
component_files.each do |path|
|
|
48
|
+
slots = parse_slot_declarations(path)
|
|
49
|
+
next if slots.empty?
|
|
50
|
+
|
|
51
|
+
template_path = template_for(path)
|
|
52
|
+
template_content = template_path.exist? ? File.read(template_path, encoding: Encoding::UTF_8) : ""
|
|
53
|
+
|
|
54
|
+
slots.each do |slot|
|
|
55
|
+
next if rendered_in_template?(template_content, slot[:name])
|
|
56
|
+
|
|
57
|
+
orphans << OrphanSlot.new(
|
|
58
|
+
component: relative_component_name(path),
|
|
59
|
+
slot: slot[:name],
|
|
60
|
+
slot_kind: slot[:kind],
|
|
61
|
+
file: path.relative_path_from(@root).to_s,
|
|
62
|
+
line: slot[:line]
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
orphans
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def component_files
|
|
72
|
+
base = @root.join(COMPONENT_DIR)
|
|
73
|
+
return [] unless base.exist?
|
|
74
|
+
|
|
75
|
+
Dir.glob(base.join(COMPONENT_GLOB)).map { |p| Pathname(p) }.sort
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def collect_components
|
|
79
|
+
base = @root.join(COMPONENT_DIR)
|
|
80
|
+
return [] unless base.exist?
|
|
81
|
+
|
|
82
|
+
Dir.glob(base.join(COMPONENT_GLOB)).map do |p|
|
|
83
|
+
component_name(Pathname(p).relative_path_from(base))
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def collect_previews
|
|
88
|
+
PREVIEW_DIRS.flat_map do |dir|
|
|
89
|
+
base = @root.join(dir)
|
|
90
|
+
next [] unless base.exist?
|
|
91
|
+
|
|
92
|
+
Dir.glob(base.join(PREVIEW_GLOB)).map do |p|
|
|
93
|
+
preview_name(Pathname(p).relative_path_from(base))
|
|
94
|
+
end
|
|
95
|
+
end.uniq
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def component_name(relative_path)
|
|
99
|
+
relative_path.to_s.sub(/_component\.rb\z/, "")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def preview_name(relative_path)
|
|
103
|
+
relative_path.to_s.sub(/_component_preview\.rb\z/, "")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def relative_component_name(path)
|
|
107
|
+
base = @root.join(COMPONENT_DIR)
|
|
108
|
+
component_name(path.relative_path_from(base))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def template_for(component_path)
|
|
112
|
+
component_path.sub_ext(".html.erb")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def parse_slot_declarations(path)
|
|
116
|
+
slots = []
|
|
117
|
+
File.read(path, encoding: Encoding::UTF_8).each_line.with_index do |line, idx|
|
|
118
|
+
m = line.match(SLOT_PATTERN)
|
|
119
|
+
next unless m
|
|
120
|
+
|
|
121
|
+
slots << { kind: m[1].to_sym, name: m[2], line: idx + 1 }
|
|
122
|
+
end
|
|
123
|
+
slots
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def rendered_in_template?(content, slot_name)
|
|
127
|
+
# Look for any reference to the slot — `<%= slot_name %>`, `slot_name?`,
|
|
128
|
+
# or `slot_name.each` / `slot_name.map` for renders_many.
|
|
129
|
+
content.match?(/\b#{Regexp.escape(slot_name)}\b/)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def print_report(result)
|
|
133
|
+
return unless result.violations?
|
|
134
|
+
|
|
135
|
+
@output.puts ""
|
|
136
|
+
unless result.missing_previews.empty?
|
|
137
|
+
noun = result.missing_previews.length == 1 ? "component" : "components"
|
|
138
|
+
@output.puts "Guardrails view_components: #{result.missing_previews.length} #{noun} without a preview"
|
|
139
|
+
result.missing_previews.each { |name| @output.puts " - #{name}_component.rb (no #{name}_component_preview.rb)" }
|
|
140
|
+
end
|
|
141
|
+
unless result.orphan_slots.empty?
|
|
142
|
+
noun = result.orphan_slots.length == 1 ? "slot declared" : "slots declared"
|
|
143
|
+
@output.puts "Guardrails view_components: #{result.orphan_slots.length} #{noun} but never referenced in template"
|
|
144
|
+
result.orphan_slots.each do |o|
|
|
145
|
+
@output.puts " - #{o.component}_component: :#{o.slot} (#{o.slot_kind} at #{o.file}:#{o.line})"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Guardrails
|
|
6
|
+
class VisualDiff
|
|
7
|
+
# Adapter for snap_diff-capybara (formerly capybara-screenshot-diff).
|
|
8
|
+
# The gem commits baselines to git under `doc/screenshots/` by
|
|
9
|
+
# convention. After a failed run there's a `<name>.diff.png` sibling
|
|
10
|
+
# next to `<name>.png`. Walking the directory tree and pairing names
|
|
11
|
+
# gives us a binary pass/fail per scenario without needing snap_diff
|
|
12
|
+
# to emit a JSON report (see upstream issue — TODO: link once filed).
|
|
13
|
+
#
|
|
14
|
+
# Limits:
|
|
15
|
+
# - No mismatch percentage; snap_diff is binary at the filesystem
|
|
16
|
+
# level. Findings emit `mismatch_ratio: nil` and VisualDiff treats
|
|
17
|
+
# nil as "unconditionally failing" (any diff fails).
|
|
18
|
+
# - No URL / viewport / selector — those live in snap_diff's
|
|
19
|
+
# Capybara tests, not the artifact tree. Adapters that have them
|
|
20
|
+
# (BackstopJS, issue #15) populate the optional fields.
|
|
21
|
+
class SnapDiff
|
|
22
|
+
DIFF_SUFFIX = ".diff.png"
|
|
23
|
+
HEATMAP_SUFFIX = ".heatmap.diff.png"
|
|
24
|
+
|
|
25
|
+
def initialize(root:, dir:)
|
|
26
|
+
@root = Pathname(root)
|
|
27
|
+
@dir = @root.join(dir)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def collect
|
|
31
|
+
return [] unless @dir.directory?
|
|
32
|
+
|
|
33
|
+
diff_files.map { |diff| build_finding(diff) }.compact
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# Find every `<name>.diff.png` under @dir, recursively. Skip
|
|
39
|
+
# `<name>.heatmap.diff.png` files — those are visualization
|
|
40
|
+
# companions, not separate findings.
|
|
41
|
+
def diff_files
|
|
42
|
+
Pathname.glob(@dir.join("**/*#{DIFF_SUFFIX}")).reject do |path|
|
|
43
|
+
path.basename.to_s.end_with?(HEATMAP_SUFFIX)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_finding(diff_path)
|
|
48
|
+
baseline_path = baseline_for(diff_path)
|
|
49
|
+
::Guardrails::VisualDiff::Finding.new(
|
|
50
|
+
scenario: scenario_name(diff_path),
|
|
51
|
+
viewport: nil,
|
|
52
|
+
mismatch_ratio: nil, # snap_diff is binary at the FS layer
|
|
53
|
+
baseline_path: relative(baseline_path),
|
|
54
|
+
current_path: nil, # snap_diff doesn't keep a separate "current" image after the test run
|
|
55
|
+
diff_path: relative(diff_path),
|
|
56
|
+
url: nil,
|
|
57
|
+
selector: nil
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# `name.diff.png` → `name.png` (sibling baseline).
|
|
62
|
+
def baseline_for(diff_path)
|
|
63
|
+
stem = diff_path.basename.to_s.sub(/#{Regexp.escape(DIFF_SUFFIX)}\z/, ".png")
|
|
64
|
+
diff_path.dirname.join(stem)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Scenario label = path under @dir without the `.diff.png` suffix,
|
|
68
|
+
# so `doc/screenshots/checkout/cart.diff.png` → "checkout/cart".
|
|
69
|
+
def scenario_name(diff_path)
|
|
70
|
+
rel = diff_path.relative_path_from(@dir).to_s
|
|
71
|
+
rel.sub(/#{Regexp.escape(DIFF_SUFFIX)}\z/, "")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def relative(path)
|
|
75
|
+
return nil if path.nil?
|
|
76
|
+
|
|
77
|
+
path.relative_path_from(@root).to_s
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|