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,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require_relative "erb_parser"
5
+
6
+ module Guardrails
7
+ # Finds repeating "class soup" — the same long class list applied to
8
+ # the same tag in many places. The classic AI-assisted-Rails failure
9
+ # mode: a 6-utility `class="px-4 py-2 text-sm font-medium bg-white
10
+ # rounded-md"` ends up copy-pasted onto 30 buttons because the
11
+ # assistant doesn't know the codebase already has a `ButtonComponent`
12
+ # or `.btn-base` class.
13
+ #
14
+ # Distinct from CrossCodebasePatterns (structural shape, ignores
15
+ # classes) and PartialSimilarity (whole-partial Jaccard). This audit
16
+ # looks at *single elements* whose class attribute is a repeated
17
+ # high-cardinality literal — the cleanest signal for "extract a
18
+ # ButtonComponent / use @apply / add a semantic class."
19
+ class ClassItis
20
+ Occurrence = Struct.new(:file, :line, :column, keyword_init: true)
21
+
22
+ Cluster = Struct.new(:tag, :classes, :occurrences, keyword_init: true) do
23
+ def count
24
+ occurrences.length
25
+ end
26
+
27
+ def class_count
28
+ classes.length
29
+ end
30
+ end
31
+
32
+ # Below this many tokens in the class list, repetition is fine —
33
+ # `<button class="primary">` everywhere is intentional, not soup.
34
+ # Soup starts when 5+ classes pile up on one element with no
35
+ # semantic name to anchor them.
36
+ DEFAULT_MIN_CLASSES = 5
37
+
38
+ # Same threshold as CrossCodebasePatterns — 2 occurrences are noise,
39
+ # 3+ implies a real recurring pattern worth extracting.
40
+ DEFAULT_MIN_OCCURRENCES = 3
41
+
42
+ DEFAULT_MAX_OCCURRENCES_SHOWN = 10
43
+
44
+ VIEW_PATTERNS = [
45
+ "app/views/**/*.html.erb",
46
+ "app/components/**/*.html.erb"
47
+ ].freeze
48
+
49
+ IMPLICIT_IGNORE_SEGMENTS = %w[vendor node_modules tmp public log].freeze
50
+ IMPLICIT_IGNORE_PATTERNS = [/\A(?:\w+_)?mailer\z/].freeze
51
+
52
+ def initialize(root:, output: $stdout,
53
+ min_classes: DEFAULT_MIN_CLASSES,
54
+ min_occurrences: DEFAULT_MIN_OCCURRENCES,
55
+ max_occurrences_shown: DEFAULT_MAX_OCCURRENCES_SHOWN)
56
+ @root = Pathname(root)
57
+ @output = output
58
+ @min_classes = min_classes
59
+ @min_occurrences = min_occurrences
60
+ @max_occurrences_shown = max_occurrences_shown
61
+ end
62
+
63
+ def run
64
+ clusters = find_clusters
65
+ print_report(clusters)
66
+ clusters
67
+ end
68
+
69
+ def find_clusters
70
+ groups = Hash.new { |h, k| h[k] = [] }
71
+
72
+ view_files.each do |file|
73
+ content = File.read(file, encoding: Encoding::UTF_8)
74
+ result = ErbParser.parse(content)
75
+ relative = file.relative_path_from(@root).to_s
76
+
77
+ ErbParser.each_node(result.document) do |node|
78
+ next unless node.is_a?(::Herb::AST::HTMLElementNode)
79
+
80
+ tag = element_tag_name(node)
81
+ next if tag.nil?
82
+
83
+ classes = static_class_tokens(node)
84
+ next if classes.length < @min_classes
85
+
86
+ line, column = ErbParser.start_position(node)
87
+ key = [tag, classes]
88
+ groups[key] << Occurrence.new(file: relative, line: line, column: column)
89
+ end
90
+ end
91
+
92
+ groups
93
+ .select { |_, occs| occs.length >= @min_occurrences }
94
+ .map { |(tag, classes), occs| Cluster.new(tag: tag, classes: classes, occurrences: occs) }
95
+ .sort_by { |c| [-c.count, -c.class_count] }
96
+ end
97
+
98
+ private
99
+
100
+ def view_files
101
+ paths = VIEW_PATTERNS.flat_map { |g| Dir.glob(@root.join(g)) }.map { |p| Pathname(p) }.uniq
102
+ paths.reject { |p| ignored?(p) }
103
+ end
104
+
105
+ def ignored?(path)
106
+ segments = path.relative_path_from(@root).to_s.split("/")
107
+ return true if (IMPLICIT_IGNORE_SEGMENTS & segments).any?
108
+
109
+ segments.any? { |seg| IMPLICIT_IGNORE_PATTERNS.any? { |pat| seg.match?(pat) } }
110
+ end
111
+
112
+ def element_tag_name(element)
113
+ return nil unless element.respond_to?(:open_tag) && element.open_tag
114
+
115
+ tok = element.open_tag.tag_name
116
+ tok && tok.respond_to?(:value) ? tok.value.to_s.downcase : nil
117
+ end
118
+
119
+ # Pull the static portion of the element's `class` attribute. If
120
+ # the attribute is missing, ERB-driven, or empty, returns []. ERB
121
+ # fragments inside a class attribute are dropped (we can't know
122
+ # what they'll render); the literal pieces are concatenated and
123
+ # whitespace-tokenized. Tokens are sorted so two identical lists
124
+ # in different source order produce the same fingerprint key.
125
+ def static_class_tokens(element)
126
+ attr = class_attribute_node(element)
127
+ return [] unless attr
128
+
129
+ static = static_attribute_value(attr)
130
+ return [] if static.nil? || static.strip.empty?
131
+
132
+ static.split(/\s+/).reject(&:empty?).sort.uniq
133
+ end
134
+
135
+ def class_attribute_node(element)
136
+ open_children = ErbParser.compact_children(element.open_tag)
137
+ open_children.find do |child|
138
+ next false unless child.is_a?(::Herb::AST::HTMLAttributeNode)
139
+
140
+ name_wrapper, _value_wrapper = ErbParser.compact_children(child)
141
+ next false unless name_wrapper
142
+
143
+ literal = ErbParser.compact_children(name_wrapper).first
144
+ next false unless literal && literal.respond_to?(:content)
145
+
146
+ name = literal.content.respond_to?(:value) ? literal.content.value.to_s : literal.content.to_s
147
+ name.downcase == "class"
148
+ end
149
+ end
150
+
151
+ def static_attribute_value(attribute_node)
152
+ _name_wrapper, value_wrapper = ErbParser.compact_children(attribute_node)
153
+ return nil unless value_wrapper
154
+
155
+ ErbParser.compact_children(value_wrapper).filter_map do |child|
156
+ next unless child.is_a?(::Herb::AST::LiteralNode)
157
+
158
+ content = child.content
159
+ content.respond_to?(:value) ? content.value.to_s : content.to_s
160
+ end.join
161
+ end
162
+
163
+ def print_report(clusters)
164
+ return if clusters.empty?
165
+
166
+ total_occurrences = clusters.sum(&:count)
167
+ noun = clusters.length == 1 ? "cluster" : "clusters"
168
+ @output.puts ""
169
+ @output.puts "Guardrails class-itis: #{clusters.length} repeating class #{noun} " \
170
+ "(#{total_occurrences} occurrences; >= #{@min_classes} classes, >= #{@min_occurrences} occurrences)"
171
+
172
+ clusters.each do |cluster|
173
+ @output.puts ""
174
+ @output.puts " <#{cluster.tag}> with #{cluster.class_count} classes, " \
175
+ "#{cluster.count} occurrences:"
176
+ @output.puts " class=#{format_classes(cluster.classes)}"
177
+ cluster.occurrences.first(@max_occurrences_shown).each do |occ|
178
+ @output.puts " #{occ.file}:#{occ.line}"
179
+ end
180
+ if cluster.occurrences.length > @max_occurrences_shown
181
+ remaining = cluster.occurrences.length - @max_occurrences_shown
182
+ @output.puts " … and #{remaining} more"
183
+ end
184
+ end
185
+ end
186
+
187
+ # Cap the displayed class string so a 30-utility soup doesn't blow
188
+ # out the terminal. Fingerprint matching is on the full sorted list,
189
+ # so display truncation is purely cosmetic.
190
+ def format_classes(classes, limit: 100)
191
+ joined = classes.join(" ")
192
+ str = joined.length <= limit ? joined : "#{joined[0, limit - 3]}..."
193
+ "\"#{str}\""
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Guardrails
4
+ # Ruby-level configuration object, intended for `config/initializers/
5
+ # guardrails.rb` in embedded (in-Gemfile) installs:
6
+ #
7
+ # Guardrails.configure do |c|
8
+ # c.visual_diff.enabled = true
9
+ # c.visual_diff.threshold = 0.02 # tolerate up to 2% mismatch
10
+ # c.visual_diff.snap_diff_dir = "spec/screenshots"
11
+ # end
12
+ #
13
+ # Precedence for the `visual_diff` section (highest → lowest):
14
+ #
15
+ # 1. Environment variables (`VISUAL_DIFF=1`, `VISUAL_DIFF_DIR=…`,
16
+ # `VISUAL_DIFF_THRESHOLD=…` — same env vars sidecar-mode users
17
+ # already rely on)
18
+ # 2. `Guardrails.configure` block (embedded-mode initializer)
19
+ # 3. Built-in defaults (`enabled: false`, `adapter: :snap_diff`,
20
+ # `snap_diff_dir: "doc/screenshots"`, `threshold: 0.0`)
21
+ #
22
+ # `guardrails.yml` does NOT participate in this object's precedence
23
+ # yet — that's how existing detectors (Audit, Tokens, etc.) read
24
+ # their config, and migrating them onto `Guardrails::Configuration`
25
+ # is a separate refactor not in 0.8.0 scope.
26
+ #
27
+ # In 0.8.0 the only section is `visual_diff`. Existing detectors
28
+ # continue using their constructor-injected configuration + yml
29
+ # access pattern.
30
+ class Configuration
31
+ attr_reader :visual_diff
32
+
33
+ def initialize
34
+ @visual_diff = VisualDiffConfig.new
35
+ end
36
+
37
+ # Nested configuration for the visual-diff audit. Adapter selection
38
+ # determines which screenshot-tool output we ingest; per-adapter
39
+ # paths and a global threshold round it out.
40
+ class VisualDiffConfig
41
+ # Built-in defaults — kept here, not in env-var fallback logic,
42
+ # so a user reading the Configuration class can see "what does
43
+ # Guardrails ship with" without grepping rake tasks.
44
+ DEFAULT_ADAPTER = :snap_diff
45
+ DEFAULT_SNAP_DIFF_DIR = "doc/screenshots"
46
+ DEFAULT_THRESHOLD = 0.0 # any non-zero mismatch fails
47
+ KNOWN_ADAPTERS = %i[snap_diff backstop].freeze
48
+
49
+ attr_accessor :enabled, :adapter, :snap_diff_dir, :backstop_json, :threshold
50
+
51
+ def initialize
52
+ @enabled = false # opt-in: visual baselines need deliberate setup
53
+ @adapter = DEFAULT_ADAPTER
54
+ @snap_diff_dir = DEFAULT_SNAP_DIFF_DIR
55
+ @backstop_json = nil
56
+ @threshold = DEFAULT_THRESHOLD
57
+ end
58
+
59
+ def adapter=(value)
60
+ raise ArgumentError, "visual_diff.adapter cannot be nil" if value.nil?
61
+ raise ArgumentError, "visual_diff.adapter cannot be blank" if value.respond_to?(:strip) && value.strip.empty?
62
+
63
+ sym = value.to_sym
64
+ unless KNOWN_ADAPTERS.include?(sym)
65
+ raise ArgumentError,
66
+ "Unknown visual_diff adapter #{value.inspect}; expected one of #{KNOWN_ADAPTERS.inspect}"
67
+ end
68
+ @adapter = sym
69
+ end
70
+
71
+ def threshold=(value)
72
+ raise ArgumentError, "visual_diff.threshold cannot be nil" if value.nil?
73
+ raise ArgumentError, "visual_diff.threshold cannot be blank" if value.respond_to?(:strip) && value.strip.empty?
74
+
75
+ float = Float(value)
76
+ unless float >= 0.0 && float <= 1.0
77
+ raise ArgumentError,
78
+ "visual_diff.threshold must be between 0.0 and 1.0; got #{value.inspect}"
79
+ end
80
+ @threshold = float
81
+ end
82
+ end
83
+ end
84
+
85
+ class << self
86
+ # Cached Configuration singleton. Tests can reset via
87
+ # `Guardrails.reset_configuration!` so changes in one example
88
+ # don't leak into another.
89
+ def configuration
90
+ @configuration ||= Configuration.new
91
+ end
92
+
93
+ def configure
94
+ yield configuration
95
+ end
96
+
97
+ def reset_configuration!
98
+ @configuration = Configuration.new
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "digest"
5
+ require "set"
6
+ require_relative "erb_parser"
7
+
8
+ module Guardrails
9
+ # Finds recurring structural patterns across the codebase — element
10
+ # subtrees that appear in 3+ places and could be extracted into a
11
+ # shared partial or ViewComponent.
12
+ #
13
+ # Distinct from PartialSimilarity: that one compares EXISTING partials
14
+ # against each other ("are these two partials near-duplicates?").
15
+ # CrossCodebasePatterns looks at the structural shape of any subtree
16
+ # in any view ("this 8-element shape appears in 12 places, only one
17
+ # of which is a partial — refactor candidate").
18
+ class CrossCodebasePatterns
19
+ Occurrence = Struct.new(:file, :line, :column, :size, keyword_init: true)
20
+
21
+ Pattern = Struct.new(:fingerprint, :shape, :size, :occurrences, keyword_init: true) do
22
+ def count
23
+ occurrences.length
24
+ end
25
+ end
26
+
27
+ # Minimum number of element nodes in a subtree before we consider it.
28
+ # Below this, the structural shape is too generic to be a refactor
29
+ # candidate — `<div>` alone, or `<span><a></a></span>`, would match
30
+ # constantly. A useful pattern starts around 5 elements (card body,
31
+ # form row, table cell with controls, etc.).
32
+ DEFAULT_MIN_SIZE = 5
33
+
34
+ # Subtree fingerprint must appear at least this many times to surface.
35
+ # 2 occurrences are common and rarely actionable; 3+ implies a real
36
+ # repeated shape.
37
+ DEFAULT_MIN_OCCURRENCES = 3
38
+
39
+ # Max occurrences printed per pattern before we elide the rest.
40
+ DEFAULT_MAX_OCCURRENCES_SHOWN = 10
41
+
42
+ VIEW_PATTERNS = [
43
+ "app/views/**/*.html.erb",
44
+ "app/components/**/*.html.erb"
45
+ ].freeze
46
+
47
+ IMPLICIT_IGNORE_SEGMENTS = %w[vendor node_modules tmp public log].freeze
48
+ IMPLICIT_IGNORE_PATTERNS = [/\A(?:\w+_)?mailer\z/].freeze
49
+
50
+ def initialize(root:, output: $stdout,
51
+ min_size: DEFAULT_MIN_SIZE,
52
+ min_occurrences: DEFAULT_MIN_OCCURRENCES,
53
+ max_occurrences_shown: DEFAULT_MAX_OCCURRENCES_SHOWN)
54
+ @root = Pathname(root)
55
+ @output = output
56
+ @min_size = min_size
57
+ @min_occurrences = min_occurrences
58
+ @max_occurrences_shown = max_occurrences_shown
59
+ end
60
+
61
+ def run
62
+ patterns = find_patterns
63
+ print_report(patterns)
64
+ patterns
65
+ end
66
+
67
+ def find_patterns
68
+ occurrences = Hash.new { |h, k| h[k] = [] }
69
+ shapes = {}
70
+
71
+ view_files.each do |file|
72
+ content = File.read(file, encoding: Encoding::UTF_8)
73
+ result = ErbParser.parse(content)
74
+ relative = file.relative_path_from(@root).to_s
75
+
76
+ walk_subtrees(result.document) do |node, fingerprint, shape, size|
77
+ next if size < @min_size
78
+
79
+ line, column = ErbParser.start_position(node)
80
+ occurrences[fingerprint] << Occurrence.new(
81
+ file: relative,
82
+ line: line,
83
+ column: column,
84
+ size: size
85
+ )
86
+ shapes[fingerprint] ||= shape
87
+ end
88
+ end
89
+
90
+ patterns = occurrences
91
+ .select { |_, occs| occs.size >= @min_occurrences }
92
+ .map { |fp, occs| Pattern.new(fingerprint: fp, shape: shapes[fp], size: occs.first.size, occurrences: occs) }
93
+ .sort_by { |p| [-p.count, -p.size] }
94
+
95
+ dedupe_nested(patterns)
96
+ end
97
+
98
+ private
99
+
100
+ # Drop redundant inner shapes. When a table repeats N times, three
101
+ # patterns end up with identical counts:
102
+ #
103
+ # table(thead(tr(th,th,th)),tbody) 8x
104
+ # thead(tr(th,th,th)) 8x
105
+ # tr(th,th,th) 8x
106
+ #
107
+ # The outer pattern is the one a refactor would extract; the inner
108
+ # shapes are just nested views of the same locations. Drop pattern A
109
+ # if there's another pattern B such that:
110
+ # - A's shape appears as a sub-shape inside B's shape, AND
111
+ # - A.count == B.count, AND
112
+ # - every file in A.occurrences is also in B.occurrences
113
+ #
114
+ # Equal-count + file-containment is a strong signal that A's
115
+ # occurrences are exactly the children of B's occurrences — not a
116
+ # distinct repeated structure.
117
+ def dedupe_nested(patterns)
118
+ patterns.reject do |inner|
119
+ patterns.any? do |outer|
120
+ next false if outer.equal?(inner)
121
+ next false unless outer.count == inner.count
122
+ next false unless outer.size > inner.size
123
+ next false unless contains_subshape?(outer.shape, inner.shape)
124
+
125
+ outer_files = outer.occurrences.map(&:file).to_set
126
+ inner.occurrences.all? { |o| outer_files.include?(o.file) }
127
+ end
128
+ end
129
+ end
130
+
131
+ # True if `outer` contains `inner` as a proper child sub-shape — i.e.
132
+ # `inner` appears inside `outer` bounded by `(` / `,` on the left and
133
+ # `)` / `,` on the right. The left bound must be a real `(` or `,`,
134
+ # never the start of the string: a prefix match like inner=`div(a)`
135
+ # against outer=`div(a,a)` would otherwise look valid (before=nil,
136
+ # after=`,`) even though it represents a structurally different
137
+ # subtree (1 child vs 2), causing dedupe_nested to drop a legitimate
138
+ # distinct pattern.
139
+ def contains_subshape?(outer, inner)
140
+ idx = 1 # i > 0 only — never accept a prefix match
141
+ while (i = outer.index(inner, idx))
142
+ before = outer[i - 1]
143
+ after = outer[i + inner.length]
144
+ return true if ["(", ","].include?(before) && [")", ","].include?(after)
145
+
146
+ idx = i + 1
147
+ end
148
+ false
149
+ end
150
+
151
+ def view_files
152
+ paths = VIEW_PATTERNS.flat_map { |g| Dir.glob(@root.join(g)) }.map { |p| Pathname(p) }.uniq
153
+ paths.reject { |p| ignored?(p) }
154
+ end
155
+
156
+ def ignored?(path)
157
+ segments = path.relative_path_from(@root).to_s.split("/")
158
+ return true if (IMPLICIT_IGNORE_SEGMENTS & segments).any?
159
+
160
+ segments.any? { |seg| IMPLICIT_IGNORE_PATTERNS.any? { |pat| seg.match?(pat) } }
161
+ end
162
+
163
+ # Walk the document yielding every HTMLElementNode subtree with its
164
+ # computed fingerprint. Recurses into element bodies so nested
165
+ # subtrees are reported alongside their parents.
166
+ def walk_subtrees(node, &block)
167
+ if node.is_a?(::Herb::AST::HTMLElementNode)
168
+ fingerprint, shape, size = compute_subtree(node)
169
+ yield node, fingerprint, shape, size if fingerprint
170
+
171
+ Array(node.body).each { |child| walk_subtrees(child, &block) }
172
+ else
173
+ # DocumentNode and other non-element wrappers — descend into
174
+ # children but don't yield for them.
175
+ ErbParser.compact_children(node).each { |child| walk_subtrees(child, &block) }
176
+ end
177
+ end
178
+
179
+ # Build a recursive shape string for an element subtree.
180
+ #
181
+ # <div><h2>x</h2><p>y</p></div> → shape "div(h2,p)", size 3
182
+ #
183
+ # Returns [fingerprint, shape, size] — fingerprint is a short SHA
184
+ # hash of the shape (cheap to compare, bounded memory). Size counts
185
+ # element nodes; text and ERB don't contribute.
186
+ def compute_subtree(node)
187
+ return [nil, "", 0] unless node.is_a?(::Herb::AST::HTMLElementNode)
188
+
189
+ tag = element_tag_name(node)
190
+ return [nil, "", 0] if tag.nil?
191
+
192
+ child_results = Array(node.body)
193
+ .map { |c| compute_subtree(c) }
194
+ .reject { |fp, _, _| fp.nil? }
195
+ child_shapes = child_results.map { |_, sh, _| sh }
196
+ child_size = child_results.sum { |_, _, sz| sz }
197
+
198
+ shape = child_shapes.empty? ? tag : "#{tag}(#{child_shapes.join(',')})"
199
+ fingerprint = Digest::SHA256.hexdigest(shape)[0, 16]
200
+
201
+ [fingerprint, shape, child_size + 1]
202
+ end
203
+
204
+ def element_tag_name(element)
205
+ return nil unless element.respond_to?(:open_tag) && element.open_tag
206
+
207
+ tok = element.open_tag.tag_name
208
+ tok && tok.respond_to?(:value) ? tok.value.to_s.downcase : nil
209
+ end
210
+
211
+ def print_report(patterns)
212
+ return if patterns.empty?
213
+
214
+ total_occurrences = patterns.sum(&:count)
215
+ noun = patterns.length == 1 ? "shape" : "shapes"
216
+ @output.puts ""
217
+ @output.puts "Guardrails patterns: #{patterns.length} recurring #{noun} " \
218
+ "(#{total_occurrences} occurrences; >= #{@min_size} elements, >= #{@min_occurrences} occurrences)"
219
+
220
+ patterns.each do |pattern|
221
+ @output.puts ""
222
+ @output.puts " Pattern (#{pattern.size} elements, #{pattern.count} occurrences): #{truncate_shape(pattern.shape)}"
223
+ pattern.occurrences.first(@max_occurrences_shown).each do |occ|
224
+ @output.puts " #{occ.file}:#{occ.line}"
225
+ end
226
+ if pattern.occurrences.length > @max_occurrences_shown
227
+ remaining = pattern.occurrences.length - @max_occurrences_shown
228
+ @output.puts " … and #{remaining} more"
229
+ end
230
+ end
231
+ end
232
+
233
+ # Cap shape display length so deep nested patterns don't blow out
234
+ # the terminal. The fingerprint is what we match on; the shape is
235
+ # just for human inspection.
236
+ def truncate_shape(shape, limit: 120)
237
+ return shape if shape.length <= limit
238
+
239
+ "#{shape[0, limit - 3]}..."
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "herb"
4
+
5
+ module Guardrails
6
+ # Thin wrapper around the Herb ERB parser. Centralizes the call so:
7
+ #
8
+ # - Detectors get a stable interface even if Herb's API drifts
9
+ # - Parse failures degrade gracefully (return an empty document
10
+ # rather than crashing the whole audit)
11
+ # - Future caching / incremental-parse hooks have a single point
12
+ # of attachment
13
+ #
14
+ # Detectors should walk `result.document`, an `Herb::AST::DocumentNode`.
15
+ # Position info on every node is `node.location`, which exposes a
16
+ # `start { line, column }` and `end { line, column }` (1-indexed lines,
17
+ # 0-indexed columns from Herb).
18
+ module ErbParser
19
+ Result = Struct.new(:document, :errors, :source, keyword_init: true) do
20
+ def success?
21
+ errors.nil? || errors.empty?
22
+ end
23
+ end
24
+
25
+ module_function
26
+
27
+ # Parse ERB source text. Returns a Result regardless of parse success
28
+ # — Herb crashes or value-less results both degrade to a safe
29
+ # empty-document Result so callers can always traverse `result.document`
30
+ # without nil-checking. Callers that care about strictness should check
31
+ # `result.success?` and inspect `result.errors`.
32
+ def parse(source)
33
+ herb_result = Herb.parse(source)
34
+ document = herb_result.value
35
+ if document.nil?
36
+ empty_result(source, ["herb returned no document for source"])
37
+ else
38
+ Result.new(
39
+ document: document,
40
+ errors: Array(herb_result.errors),
41
+ source: source
42
+ )
43
+ end
44
+ rescue StandardError => e
45
+ empty_result(source, ["#{e.class}: #{e.message}"])
46
+ end
47
+
48
+ # Build a Result around an empty parsed document so detectors that
49
+ # walk `result.document` see no elements rather than crashing.
50
+ def empty_result(source, errors)
51
+ empty = Herb.parse("")
52
+ Result.new(document: empty.value, errors: Array(errors), source: source)
53
+ rescue StandardError
54
+ # If Herb can't even parse "", give back a Result whose document
55
+ # responds to compact_child_nodes with []. Use a Struct-as-stub.
56
+ Result.new(document: NULL_DOCUMENT, errors: Array(errors), source: source)
57
+ end
58
+
59
+ NULL_DOCUMENT = Struct.new(:compact_child_nodes, :children, :location).new([], [], nil)
60
+ private_constant :NULL_DOCUMENT
61
+
62
+ # Walk an AST node depth-first, yielding each descendant (including
63
+ # the root). Callers filter by `node.class` or `node.tag_name`.
64
+ def each_node(node, &block)
65
+ return enum_for(:each_node, node) unless block_given?
66
+
67
+ yield node
68
+ compact_children(node).each { |child| each_node(child, &block) }
69
+ end
70
+
71
+ # Herb nodes expose either `compact_child_nodes` (preferred — skips
72
+ # nils and unwraps single-child wrappers) or just `children`.
73
+ def compact_children(node)
74
+ if node.respond_to?(:compact_child_nodes)
75
+ Array(node.compact_child_nodes)
76
+ elsif node.respond_to?(:children)
77
+ Array(node.children)
78
+ else
79
+ []
80
+ end
81
+ end
82
+
83
+ # Convert a Herb location to (line, column) for a node's start.
84
+ # Herb uses 1-indexed lines and 0-indexed columns; Guardrails violations
85
+ # use 1-indexed columns. This adjusts for that.
86
+ def start_position(node)
87
+ loc = node.location
88
+ [loc.start.line, loc.start.column + 1]
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Guardrails
4
+ module HexNormalizer
5
+ module_function
6
+
7
+ # Normalize a hex color literal for equality comparison:
8
+ # - lowercase
9
+ # - expand 3-char shorthand (#fa3 -> #ffaa33)
10
+ # - strip alpha channel (#ffaa3380 -> #ffaa33)
11
+ # - return non-hex input unchanged (after lowercasing)
12
+ def normalize(value)
13
+ v = value.to_s.downcase.strip
14
+ return v unless v.start_with?("#")
15
+
16
+ case v.length
17
+ when 4 # #fa3 -> #ffaa33
18
+ "#" + v[1..].chars.map { |c| c * 2 }.join
19
+ when 5 # #fa3a -> #ffaa33 (drop alpha)
20
+ ("#" + v[1..].chars.map { |c| c * 2 }.join)[0..6]
21
+ when 7 # #ffaa33 (canonical)
22
+ v
23
+ when 9 # #ffaa3380 -> #ffaa33
24
+ v[0..6]
25
+ else
26
+ v
27
+ end
28
+ end
29
+
30
+ # Maximum per-channel (R / G / B) difference between two normalized hex
31
+ # colors, on a 0..255 scale. Returns nil if either value isn't a
32
+ # 7-char hex after normalization. Distance 0 = identical color.
33
+ def distance(a, b)
34
+ a_norm = normalize(a)
35
+ b_norm = normalize(b)
36
+ return nil unless a_norm.length == 7 && a_norm.start_with?("#")
37
+ return nil unless b_norm.length == 7 && b_norm.start_with?("#")
38
+
39
+ diffs = [
40
+ (a_norm[1..2].to_i(16) - b_norm[1..2].to_i(16)).abs,
41
+ (a_norm[3..4].to_i(16) - b_norm[3..4].to_i(16)).abs,
42
+ (a_norm[5..6].to_i(16) - b_norm[5..6].to_i(16)).abs
43
+ ]
44
+ diffs.max
45
+ end
46
+ end
47
+ end