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,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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Guardrails
4
+ VERSION = "1.0.0"
5
+ 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