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