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