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,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "stringio"
5
+
6
+ module Guardrails
7
+ module Lookbook
8
+ # Generates a per-component findings report meant to be rendered inside
9
+ # a Lookbook panel. Returns a Hash so the consumer can choose how to
10
+ # render it (HTML partial, JSON, etc).
11
+ class ComponentReport
12
+ def initialize(root:)
13
+ @root = Pathname(root)
14
+ end
15
+
16
+ # @param component_class_name [String] e.g. "ButtonComponent" or
17
+ # "Admin::Users::ProfileComponent"
18
+ # @return [Hash, nil] nil when the component class can't be located
19
+ def for(component_class_name)
20
+ class_path = component_class_path(component_class_name)
21
+ return nil unless class_path&.exist?
22
+
23
+ template_path = class_path.sub_ext(".html.erb")
24
+ class_relative = relative(class_path)
25
+ template_relative = template_path.exist? ? relative(template_path) : nil
26
+
27
+ {
28
+ component: component_class_name,
29
+ class_file: class_relative,
30
+ template_file: template_relative,
31
+ violations: violations_for(template_relative),
32
+ orphan_slots: orphan_slots_for(class_relative),
33
+ similar_templates: similar_templates_for(template_relative)
34
+ }
35
+ end
36
+
37
+ private
38
+
39
+ def component_class_path(class_name)
40
+ relative = class_name.to_s
41
+ .gsub("::", "/")
42
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
43
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
44
+ .downcase
45
+ @root.join("app/components", "#{relative}.rb")
46
+ end
47
+
48
+ def relative(path)
49
+ path.relative_path_from(@root).to_s
50
+ end
51
+
52
+ def violations_for(template_relative)
53
+ return [] if template_relative.nil?
54
+
55
+ require_relative "../audit"
56
+ Guardrails::Audit.new(root: @root, output: StringIO.new).run
57
+ .select { |v| v.file == template_relative }
58
+ .map(&:to_h)
59
+ end
60
+
61
+ def orphan_slots_for(class_relative)
62
+ require_relative "../view_component_audit"
63
+ Guardrails::ViewComponentAudit.new(root: @root, output: StringIO.new).run.orphan_slots
64
+ .select { |o| o.file == class_relative }
65
+ .map(&:to_h)
66
+ end
67
+
68
+ def similar_templates_for(template_relative)
69
+ return [] if template_relative.nil?
70
+
71
+ require_relative "../partial_similarity"
72
+ Guardrails::PartialSimilarity.new(root: @root, output: StringIO.new).run
73
+ .select { |f| f.file_a == template_relative || f.file_b == template_relative }
74
+ .map { |f| { partner: (f.file_a == template_relative ? f.file_b : f.file_a), score: f.score } }
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "component_report"
4
+
5
+ module Guardrails
6
+ module Lookbook
7
+ # Registers a `:guardrails` Lookbook panel that renders ComponentReport
8
+ # findings inline next to each preview. The Railtie calls `register!`
9
+ # at app boot when `defined?(::Lookbook)`, so users no longer need to
10
+ # wire the panel by hand.
11
+ #
12
+ # Extracted from the Railtie so the registration logic and the
13
+ # locals lambda are unit-testable without booting Rails — pass a
14
+ # stub `lookbook` and the assertions can inspect what got registered.
15
+ module PanelRegistration
16
+ module_function
17
+
18
+ # The gem ships the panel partial inside `lib/guardrails/lookbook/views/`.
19
+ # Adding that dir to ActionView's view paths means the standard Rails
20
+ # partial-resolution machinery finds `lookbook_panels/_guardrails.html.erb`
21
+ # without users having to copy it into their app.
22
+ VIEW_PATH = File.expand_path("views", __dir__)
23
+
24
+ def register!(lookbook: ::Lookbook, view_consumer: nil)
25
+ append_view_path(view_consumer)
26
+ register_panel(lookbook)
27
+ end
28
+
29
+ # APPEND, not prepend — the host's `app/views` must keep precedence
30
+ # so that `app/views/lookbook_panels/_guardrails.html.erb` in the
31
+ # host wins over the gem's bundled default. Prepending would flip
32
+ # that and silently break the documented override mechanism.
33
+ def append_view_path(view_consumer = nil)
34
+ target = view_consumer || (defined?(::ActionController::Base) ? ::ActionController::Base : nil)
35
+ return unless target&.respond_to?(:append_view_path)
36
+
37
+ target.append_view_path(VIEW_PATH)
38
+ end
39
+
40
+ # Lookbook 2.x exposes `Lookbook.add_panel(name, partial_path, opts)`
41
+ # at the module level. opts[:locals] can be a callable that receives
42
+ # the inspector_data Store at render time and returns a Hash of
43
+ # locals for the partial. The pre-2.x API of
44
+ # `config.lookbook.preview_inspector.panels.add` doesn't exist.
45
+ def register_panel(lookbook = ::Lookbook)
46
+ return unless lookbook.respond_to?(:add_panel)
47
+
48
+ lookbook.add_panel(:guardrails, "lookbook_panels/guardrails", {
49
+ label: "Guardrails",
50
+ locals: ->(data) { { findings: report_for_preview(data) } }
51
+ })
52
+ end
53
+
54
+ def report_for_preview(data)
55
+ preview_class_name = preview_class_name_from(data)
56
+ return nil unless preview_class_name
57
+
58
+ component_class_name = component_class_name_from(preview_class_name)
59
+ return nil unless component_class_name
60
+
61
+ ComponentReport.new(root: ::Rails.root).for(component_class_name)
62
+ end
63
+
64
+ # Lookbook's inspector_data exposes the current preview's class via
65
+ # data.preview.preview_class_name (2.x) or data.preview.preview_class.name
66
+ # (older). Probe defensively rather than hardcoding one path.
67
+ def preview_class_name_from(data)
68
+ return nil if data.nil?
69
+
70
+ if data.respond_to?(:preview) && data.preview
71
+ preview = data.preview
72
+ return preview.preview_class_name if preview.respond_to?(:preview_class_name)
73
+ return preview.preview_class.name if preview.respond_to?(:preview_class)
74
+ end
75
+ return data.preview_class.name if data.respond_to?(:preview_class)
76
+
77
+ nil
78
+ end
79
+
80
+ # The preview class for a ViewComponent is conventionally
81
+ # `<Component>Preview` — Lookbook itself relies on this in
82
+ # `PreviewEntity#guess_render_targets`. Strip the suffix to get
83
+ # the component class name ComponentReport expects. If there's
84
+ # no `Preview` suffix we can't infer the target component, so
85
+ # we punt rather than guess.
86
+ def component_class_name_from(preview_class_name)
87
+ return nil unless preview_class_name.to_s.end_with?("Preview")
88
+
89
+ preview_class_name.to_s.chomp("Preview")
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,44 @@
1
+ <% findings = local_assigns[:findings] %>
2
+ <% if findings.nil? %>
3
+ <p class="lookbook-guardrails-empty">No Guardrails data for this component — Guardrails couldn't locate the component class file under <code>app/components/</code>.</p>
4
+ <% else %>
5
+ <% violations = findings[:violations] || [] %>
6
+ <% orphan_slots = findings[:orphan_slots] || [] %>
7
+ <% similar = findings[:similar_templates] || [] %>
8
+ <% any_findings = violations.any? || orphan_slots.any? || similar.any? %>
9
+
10
+ <% if violations.any? %>
11
+ <h4>Drift in template</h4>
12
+ <ul>
13
+ <% violations.each do |v| %>
14
+ <li>
15
+ <strong><%= v[:type] %></strong>
16
+ <%= v[:file] %>:<%= v[:line] %>
17
+ — <code><%= v[:snippet] %></code>
18
+ </li>
19
+ <% end %>
20
+ </ul>
21
+ <% end %>
22
+
23
+ <% if orphan_slots.any? %>
24
+ <h4>Orphan slots</h4>
25
+ <ul>
26
+ <% orphan_slots.each do |slot| %>
27
+ <li>:<%= slot[:slot] %> (<%= slot[:slot_kind] %>) declared but not rendered (<%= slot[:file] %>:<%= slot[:line] %>)</li>
28
+ <% end %>
29
+ </ul>
30
+ <% end %>
31
+
32
+ <% if similar.any? %>
33
+ <h4>Similar templates</h4>
34
+ <ul>
35
+ <% similar.each do |s| %>
36
+ <li><%= s[:partner] %> (<%= (s[:score] * 100).round %>% match)</li>
37
+ <% end %>
38
+ </ul>
39
+ <% end %>
40
+
41
+ <% unless any_findings %>
42
+ <p class="lookbook-guardrails-clean">No findings — this component is clean.</p>
43
+ <% end %>
44
+ <% end %>
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "set"
5
+ require_relative "erb_parser"
6
+
7
+ module Guardrails
8
+ class PartialSimilarity
9
+ Finding = Struct.new(:file_a, :file_b, :score, :tag_count_a, :tag_count_b, keyword_init: true)
10
+
11
+ DEFAULT_THRESHOLD = 0.7
12
+ DEFAULT_NGRAM_SIZE = 3
13
+ MIN_TAGS = 5
14
+
15
+ # Scan ERB partials (underscore-prefixed in app/views and app/components)
16
+ # AND ViewComponent sidecar templates (*_component.html.erb in app/components).
17
+ PARTIAL_PATTERNS = [
18
+ "app/views/**/_*.html.erb",
19
+ "app/components/**/_*.html.erb",
20
+ "app/components/**/*_component.html.erb"
21
+ ].freeze
22
+
23
+ def initialize(root:, output: $stdout, threshold: DEFAULT_THRESHOLD, ngram_size: DEFAULT_NGRAM_SIZE)
24
+ @root = Pathname(root)
25
+ @output = output
26
+ @threshold = threshold
27
+ @ngram_size = ngram_size
28
+ end
29
+
30
+ def run
31
+ findings = compute_findings
32
+ print_report(findings)
33
+ findings
34
+ end
35
+
36
+ def compute_findings
37
+ partials = collect_partials.filter_map do |path|
38
+ tokens = tokenize(File.read(path, encoding: Encoding::UTF_8))
39
+ next nil if tokens.length < MIN_TAGS
40
+
41
+ { path: path, tokens: tokens, ngrams: build_ngrams(tokens) }
42
+ end
43
+
44
+ findings = []
45
+ partials.combination(2).each do |a, b|
46
+ score = jaccard(a[:ngrams], b[:ngrams])
47
+ next if score < @threshold
48
+
49
+ findings << Finding.new(
50
+ file_a: a[:path].relative_path_from(@root).to_s,
51
+ file_b: b[:path].relative_path_from(@root).to_s,
52
+ score: score,
53
+ tag_count_a: a[:tokens].length,
54
+ tag_count_b: b[:tokens].length
55
+ )
56
+ end
57
+ findings.sort_by { |f| -f.score }
58
+ end
59
+
60
+ # Tokenize a partial into a flat sequence of HTML tag names by walking
61
+ # the parsed AST. Traversal is open-tag → recurse-into-body → close-tag
62
+ # so the resulting sequence preserves source order:
63
+ #
64
+ # <div><span></span></div> → ["div", "span", "span", "div"]
65
+ #
66
+ # ERB nodes don't contribute tokens. Void elements (img, input)
67
+ # produce one token; their close-tag pass is skipped.
68
+ def tokenize(content)
69
+ tokens = []
70
+ result = ErbParser.parse(content)
71
+ walk_for_tokens(result.document, tokens)
72
+ tokens
73
+ end
74
+
75
+ def walk_for_tokens(node, tokens)
76
+ case node
77
+ when ::Herb::AST::HTMLElementNode
78
+ name = element_tag_name(node)
79
+ if name
80
+ tokens << name
81
+ Array(node.body).each { |child| walk_for_tokens(child, tokens) }
82
+ tokens << name unless void_element_name?(name)
83
+ end
84
+ when ::Herb::AST::HTMLOpenTagNode
85
+ # Top-level void element not wrapped in HTMLElementNode.
86
+ name = open_tag_name(node)
87
+ tokens << name if name && void_element_name?(name)
88
+ else
89
+ ErbParser.compact_children(node).each { |child| walk_for_tokens(child, tokens) }
90
+ end
91
+ end
92
+
93
+ VOID_ELEMENT_NAMES = %w[
94
+ area base br col embed hr img input link meta param source track wbr
95
+ ].to_set.freeze
96
+
97
+ def void_element_name?(name)
98
+ VOID_ELEMENT_NAMES.include?(name)
99
+ end
100
+
101
+ def open_tag_name(node)
102
+ tok = node.respond_to?(:tag_name) ? node.tag_name : nil
103
+ tok && tok.respond_to?(:value) ? tok.value.to_s.downcase : nil
104
+ end
105
+
106
+ def element_tag_name(element)
107
+ open_tag_name(element.open_tag) if element.respond_to?(:open_tag) && element.open_tag
108
+ end
109
+
110
+ # Group findings by connected component over the similarity graph.
111
+ # When N partials are pairwise above-threshold (e.g. 8 templated
112
+ # public_activity partials all matching each other at 1.00), the
113
+ # naive pair list emits C(N,2) lines that read as noise; collapsing
114
+ # to one group of N is what the user actually cares about.
115
+ #
116
+ # Returns an Array of Hashes keyed by:
117
+ # :files — sorted Array of file paths in the component
118
+ # :score_min, :score_max — observed score range across the
119
+ # component's pairs
120
+ # :pair_count — how many original pairs fed into the group
121
+ # :sample_pair — a representative Finding (the only one for size-2
122
+ # components, used to preserve the original pair
123
+ # line's tag-count detail)
124
+ def group_findings(findings)
125
+ adj = Hash.new { |h, k| h[k] = Set.new }
126
+ pairs_by_file = Hash.new { |h, k| h[k] = [] }
127
+ findings.each do |f|
128
+ adj[f.file_a] << f.file_b
129
+ adj[f.file_b] << f.file_a
130
+ pairs_by_file[f.file_a] << f
131
+ pairs_by_file[f.file_b] << f
132
+ end
133
+
134
+ visited = Set.new
135
+ groups = []
136
+ adj.each_key do |file|
137
+ next if visited.include?(file)
138
+
139
+ component = Set.new
140
+ stack = [file]
141
+ until stack.empty?
142
+ current = stack.pop
143
+ next if component.include?(current)
144
+
145
+ component << current
146
+ visited << current
147
+ adj[current].each { |neighbor| stack << neighbor unless component.include?(neighbor) }
148
+ end
149
+
150
+ # Walk only the findings touching files in this component (via the
151
+ # pre-built index) — avoids the O(components × pairs) scan.
152
+ seen_pair_ids = Set.new
153
+ component_pairs = []
154
+ component.each do |f|
155
+ pairs_by_file[f].each do |pair|
156
+ next unless component.include?(pair.file_a) && component.include?(pair.file_b)
157
+ next if seen_pair_ids.include?(pair.object_id)
158
+
159
+ seen_pair_ids << pair.object_id
160
+ component_pairs << pair
161
+ end
162
+ end
163
+
164
+ scores = component_pairs.map(&:score)
165
+ groups << {
166
+ files: component.to_a.sort,
167
+ score_min: scores.min,
168
+ score_max: scores.max,
169
+ pair_count: component_pairs.size,
170
+ sample_pair: component_pairs.first
171
+ }
172
+ end
173
+ groups.sort_by { |g| -g[:files].size }
174
+ end
175
+
176
+ private
177
+
178
+ def collect_partials
179
+ PARTIAL_PATTERNS
180
+ .flat_map { |pattern| Dir.glob(@root.join(pattern)) }
181
+ .map { |path| Pathname(path) }
182
+ .uniq
183
+ .sort
184
+ end
185
+
186
+ def build_ngrams(tokens)
187
+ return Set.new([tokens]) if tokens.length < @ngram_size
188
+
189
+ tokens.each_cons(@ngram_size).to_set
190
+ end
191
+
192
+ def jaccard(set_a, set_b)
193
+ return 0.0 if set_a.empty? && set_b.empty?
194
+
195
+ intersection = (set_a & set_b).size.to_f
196
+ union = (set_a | set_b).size
197
+ intersection / union
198
+ end
199
+
200
+ def print_report(findings)
201
+ return if findings.empty?
202
+
203
+ groups = group_findings(findings)
204
+ total_files = groups.sum { |g| g[:files].size }
205
+
206
+ @output.puts ""
207
+ group_noun = groups.length == 1 ? "group" : "groups"
208
+ @output.puts "Guardrails templates: #{groups.length} similar #{group_noun} (#{findings.length} pairs across #{total_files} files; >= #{@threshold} structural similarity)"
209
+
210
+ groups.each do |group|
211
+ if group[:files].length == 2
212
+ # Use the original Finding so we keep the tag-count suffix
213
+ # (e.g. "(12 / 14 tags)") that single-pair output has always
214
+ # included. The sorted file list is still authoritative for
215
+ # display order.
216
+ pair = group[:sample_pair]
217
+ file_a, file_b = group[:files]
218
+ @output.puts " #{format('%.2f', group[:score_max])} #{file_a} ↔ #{file_b} (#{pair.tag_count_a} / #{pair.tag_count_b} tags)"
219
+ else
220
+ score_label = if group[:score_min] == group[:score_max]
221
+ format("%.2f", group[:score_max])
222
+ else
223
+ "#{format('%.2f', group[:score_min])}–#{format('%.2f', group[:score_max])}"
224
+ end
225
+ @output.puts " Group of #{group[:files].length} templates (#{score_label}, #{group[:pair_count]} pairs):"
226
+ group[:files].each { |f| @output.puts " #{f}" }
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Guardrails
6
+ class Railtie < Rails::Railtie
7
+ rake_tasks do
8
+ load File.expand_path("../tasks/guardrails.rake", __dir__)
9
+ end
10
+
11
+ # Auto-register the Lookbook panel when Lookbook is on the load path.
12
+ # Pre-0.5.0 users had to wire the panel by hand in an initializer
13
+ # (see doc/LOOKBOOK.md history); now the gem ships the partial and
14
+ # registers it on boot. Run after Lookbook's own initializers so
15
+ # `Lookbook.add_panel` is available.
16
+ initializer "guardrails.lookbook_panel", after: :load_config_initializers do
17
+ next unless defined?(::Lookbook)
18
+
19
+ require_relative "lookbook/panel_registration"
20
+ ::Guardrails::Lookbook::PanelRegistration.register!
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Guardrails
6
+ class StimulusAudit
7
+ Result = Struct.new(:orphaned, :dead, keyword_init: true) do
8
+ def violations?
9
+ !orphaned.empty? || !dead.empty?
10
+ end
11
+ end
12
+
13
+ # Stimulus controller files can live under different layouts depending
14
+ # on bundler / app structure:
15
+ #
16
+ # app/javascript/controllers/*_controller.{js,ts} (importmap default)
17
+ # app/javascript/js/controllers/*_controller.{js,ts} (Avo)
18
+ # app/javascript/packs/controllers/*_controller.{js,ts} (older Webpacker)
19
+ # app/frontend/controllers/*_controller.{js,ts} (Vite Rails)
20
+ #
21
+ # Glob from each accepted base; controller-name derivation hinges on
22
+ # the deepest `controllers/` segment in the path.
23
+ CONTROLLER_BASES = %w[app/javascript app/frontend].freeze
24
+ CONTROLLER_GLOB = "**/*_controller.{js,ts}"
25
+
26
+ VIEW_PATTERNS = [
27
+ "app/views/**/*.html.erb",
28
+ "app/components/**/*.html.erb"
29
+ ].freeze
30
+
31
+ DATA_CONTROLLER_PATTERN = /data-controller\s*=\s*["']([^"']+)["']/
32
+
33
+ # Ruby helper syntax: `tag.div(data: { controller: "foo" })` or
34
+ # `link_to "x", url, data: { controller: "foo bar" }`. Allow `=>` rocket
35
+ # syntax too. Capture the string passed as the `controller:` value.
36
+ RUBY_DATA_CONTROLLER_PATTERN =
37
+ /data:?\s*(?:=>)?\s*\{[^}]*?controller:?\s*(?:=>)?\s*["']([^"']+)["']/m
38
+
39
+ def initialize(root:, output: $stdout)
40
+ @root = Pathname(root)
41
+ @output = output
42
+ end
43
+
44
+ def run
45
+ defined = collect_defined_controllers
46
+ referenced = collect_referenced_controllers
47
+
48
+ result = Result.new(
49
+ orphaned: (referenced - defined).sort,
50
+ dead: (defined - referenced).sort
51
+ )
52
+
53
+ print_report(result)
54
+ result
55
+ end
56
+
57
+ private
58
+
59
+ def collect_defined_controllers
60
+ paths = CONTROLLER_BASES.flat_map do |base|
61
+ absolute = @root.join(base)
62
+ next [] unless absolute.exist?
63
+
64
+ Dir.glob(absolute.join(CONTROLLER_GLOB))
65
+ end
66
+ paths.map { |path| controller_name_from_path(path) }.compact.uniq
67
+ end
68
+
69
+ # Derive a Stimulus controller identifier from a file path. We anchor
70
+ # on the deepest `controllers/` directory in the path, so:
71
+ #
72
+ # app/javascript/controllers/users/profile_controller.js → "users--profile"
73
+ # app/javascript/js/controllers/foo_controller.js → "foo"
74
+ # app/frontend/controllers/admin/users_controller.ts → "admin--users"
75
+ #
76
+ # Falls back to the basename when no `controllers/` segment exists.
77
+ def controller_name_from_path(path)
78
+ str = path.to_s
79
+ marker = "/controllers/"
80
+ idx = str.rindex(marker)
81
+ relative = idx ? str[(idx + marker.length)..] : File.basename(str)
82
+
83
+ stripped = relative.sub(/_controller\.(js|ts)\z/, "")
84
+ return nil if stripped.empty?
85
+
86
+ stripped.gsub("/", "--").tr("_", "-")
87
+ end
88
+
89
+ def collect_referenced_controllers
90
+ VIEW_PATTERNS.flat_map { |pattern| Dir.glob(@root.join(pattern)) }
91
+ .flat_map { |path| extract_referenced(Pathname(path)) }
92
+ .uniq
93
+ end
94
+
95
+ def extract_referenced(file)
96
+ content = File.read(file, encoding: Encoding::UTF_8)
97
+ [DATA_CONTROLLER_PATTERN, RUBY_DATA_CONTROLLER_PATTERN].flat_map do |pattern|
98
+ content.scan(pattern).flat_map { |captures| captures[0].strip.split(/\s+/) }
99
+ end
100
+ end
101
+
102
+ def print_report(result)
103
+ return unless result.violations?
104
+
105
+ @output.puts ""
106
+ unless result.orphaned.empty?
107
+ noun = result.orphaned.length == 1 ? "controller" : "controllers"
108
+ @output.puts "Guardrails stimulus: #{result.orphaned.length} orphaned #{noun} (referenced in HTML, no JS file)"
109
+ result.orphaned.each { |name| @output.puts " - #{name}" }
110
+ end
111
+ unless result.dead.empty?
112
+ noun = result.dead.length == 1 ? "controller" : "controllers"
113
+ @output.puts "Guardrails stimulus: #{result.dead.length} dead #{noun} (JS file, never referenced)"
114
+ result.dead.each { |name| @output.puts " - #{name}" }
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hex_normalizer"
4
+
5
+ module Guardrails
6
+ class TokenMatcher
7
+ NEAR_MATCH_THRESHOLD = 4
8
+
9
+ Match = Struct.new(:token, :kind, :distance, keyword_init: true)
10
+
11
+ def initialize(tokens, near_match_threshold: NEAR_MATCH_THRESHOLD)
12
+ @tokens = tokens
13
+ @lookup = tokens.to_h { |t| [HexNormalizer.normalize(t.value), t] }
14
+ @near_match_threshold = near_match_threshold
15
+ end
16
+
17
+ def match(value)
18
+ return nil if value.nil?
19
+
20
+ exact = @lookup[HexNormalizer.normalize(value)]
21
+ return Match.new(token: exact, kind: :exact, distance: 0) if exact
22
+
23
+ best = nil
24
+ best_distance = @near_match_threshold + 1
25
+ @tokens.each do |t|
26
+ d = HexNormalizer.distance(value, t.value)
27
+ next unless d
28
+ next if d.zero?
29
+
30
+ if d < best_distance
31
+ best = t
32
+ best_distance = d
33
+ end
34
+ end
35
+ return nil unless best
36
+
37
+ Match.new(token: best, kind: :near, distance: best_distance)
38
+ end
39
+ end
40
+ end