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,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Guardrails
|
|
7
|
+
class Icons
|
|
8
|
+
Violation = Struct.new(:type, :file, :line, :column, :snippet, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
DEFAULT_SOURCE = "app/assets/images/icons"
|
|
11
|
+
DEFAULT_SPRITE_OUTPUT = "app/assets/images/icons/sprite.svg"
|
|
12
|
+
DEFAULT_VIEWBOX = "0 0 24 24"
|
|
13
|
+
|
|
14
|
+
VIEW_PATTERNS = [
|
|
15
|
+
"app/views/**/*.html.erb",
|
|
16
|
+
"app/components/**/*.html.erb"
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
SVG_OPEN_TAG = /<svg\b([^>]*)>/m
|
|
20
|
+
SVG_INNER = /<svg\b[^>]*>([\s\S]*?)<\/svg>/m
|
|
21
|
+
SVG_BLOCK_PATTERN = /<svg\b[^>]*>[\s\S]*?<\/svg>/m
|
|
22
|
+
VIEWBOX_ATTR = /\bviewBox\s*=\s*["']([^"']+)["']/i
|
|
23
|
+
ERB_BLOCK_PATTERN = /<%[\s\S]*?%>/
|
|
24
|
+
|
|
25
|
+
# Patterns that indicate an icon is in use. We're conservative on this
|
|
26
|
+
# side — false negatives (saying "alive" when actually dead) just
|
|
27
|
+
# leave dead icons in source; false positives (saying "dead" when
|
|
28
|
+
# actually used) cause the user to delete files they need.
|
|
29
|
+
#
|
|
30
|
+
# Each pattern allows an optional directory prefix before the basename
|
|
31
|
+
# (e.g. `image_tag "icons/check.svg"` for files under
|
|
32
|
+
# `app/assets/images/icons/`) and captures only the bare name so it
|
|
33
|
+
# matches against icons collected from disk.
|
|
34
|
+
USAGE_PATTERNS = [
|
|
35
|
+
# Sprite reference: <use href="#icon-foo">
|
|
36
|
+
/#icon-([\w-]+)/,
|
|
37
|
+
# Rails image_tag "foo.svg" / image_tag "icons/foo.svg"
|
|
38
|
+
/\bimage_tag\s*\(?\s*["'](?:[^"']*\/)?([\w-]+)\.(?:svg|png|gif|jpe?g|webp)["']/,
|
|
39
|
+
# Rails asset_path / asset_url / image_path / image_url with optional path
|
|
40
|
+
/\b(?:asset_path|asset_url|image_path|image_url)\s*\(?\s*["'](?:[^"']*\/)?([\w-]+)\.(?:svg|png|gif|jpe?g|webp)["']/,
|
|
41
|
+
# CSS url() references in stylesheets and inline style attributes
|
|
42
|
+
/url\s*\(\s*["']?(?:[^"')]*\/)?([\w-]+)\.(?:svg|png|gif|jpe?g|webp)/i
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
USAGE_SCAN_PATTERNS = [
|
|
46
|
+
"app/views/**/*.html.erb",
|
|
47
|
+
"app/components/**/*.html.erb",
|
|
48
|
+
"app/components/**/*.rb",
|
|
49
|
+
"app/assets/stylesheets/**/*.{css,scss,sass}",
|
|
50
|
+
"app/javascript/**/*.{js,ts,jsx,tsx}"
|
|
51
|
+
].freeze
|
|
52
|
+
|
|
53
|
+
def initialize(root:, output: $stdout, source: nil, sprite_output: nil)
|
|
54
|
+
@root = Pathname(root)
|
|
55
|
+
@output = output
|
|
56
|
+
config = load_config
|
|
57
|
+
|
|
58
|
+
@source = resolve_path(source || config.dig("guardrails", "icons", "source") || DEFAULT_SOURCE)
|
|
59
|
+
@sprite_output = resolve_path(sprite_output || config.dig("guardrails", "icons", "sprite_output") || DEFAULT_SPRITE_OUTPUT)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def run
|
|
63
|
+
generate_sprite
|
|
64
|
+
violations = audit_inline_svgs
|
|
65
|
+
report_inline_svgs(violations)
|
|
66
|
+
dead_report = report_dead_icons
|
|
67
|
+
print_dead_report(dead_report)
|
|
68
|
+
{ inline_svgs: violations, dead_icons: dead_report[:dead], unknown_refs: dead_report[:unknown] }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def audit_inline_svgs
|
|
72
|
+
view_files.flat_map { |file| scan_view_for_inline_svgs(file) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def report_dead_icons
|
|
76
|
+
icon_names = collect_icon_names
|
|
77
|
+
used_names = collect_used_icon_names
|
|
78
|
+
{
|
|
79
|
+
dead: (icon_names - used_names).sort,
|
|
80
|
+
unknown: (used_names - icon_names).sort
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def generate_sprite
|
|
85
|
+
svgs = collect_svgs
|
|
86
|
+
if svgs.empty?
|
|
87
|
+
@output.puts "No SVGs found in #{relative(@source)}"
|
|
88
|
+
return nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
symbols = svgs.filter_map { |file| build_symbol(file) }
|
|
92
|
+
sprite = wrap_sprite(symbols)
|
|
93
|
+
|
|
94
|
+
@sprite_output.dirname.mkpath
|
|
95
|
+
File.write(@sprite_output, sprite, encoding: Encoding::UTF_8)
|
|
96
|
+
@output.puts "Wrote sprite with #{symbols.length} icons to #{relative(@sprite_output)}"
|
|
97
|
+
@sprite_output
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def load_config
|
|
103
|
+
path = @root.join("guardrails.yml")
|
|
104
|
+
return {} unless path.exist?
|
|
105
|
+
|
|
106
|
+
YAML.safe_load_file(path) || {}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def resolve_path(path)
|
|
110
|
+
pathname = Pathname(path)
|
|
111
|
+
pathname.absolute? ? pathname : @root.join(pathname)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def collect_svgs
|
|
115
|
+
return [] unless @source.exist?
|
|
116
|
+
|
|
117
|
+
Dir.glob(@source.join("*.svg"))
|
|
118
|
+
.map { |path| Pathname(path) }
|
|
119
|
+
.reject { |path| path == @sprite_output }
|
|
120
|
+
.sort_by { |path| path.basename.to_s }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def build_symbol(file)
|
|
124
|
+
content = File.read(file, encoding: Encoding::UTF_8)
|
|
125
|
+
open_tag = content.match(SVG_OPEN_TAG)
|
|
126
|
+
inner_match = content.match(SVG_INNER)
|
|
127
|
+
return nil unless open_tag && inner_match
|
|
128
|
+
|
|
129
|
+
viewbox = open_tag[1].match(VIEWBOX_ATTR)&.[](1) || DEFAULT_VIEWBOX
|
|
130
|
+
inner = inner_match[1].strip
|
|
131
|
+
return nil if inner.empty?
|
|
132
|
+
|
|
133
|
+
name = file.basename(".svg").to_s
|
|
134
|
+
%( <symbol id="icon-#{name}" viewBox="#{viewbox}">#{inner}</symbol>)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def wrap_sprite(symbols)
|
|
138
|
+
<<~SVG
|
|
139
|
+
<svg xmlns="http://www.w3.org/2000/svg" style="display: none">
|
|
140
|
+
#{symbols.join("\n")}
|
|
141
|
+
</svg>
|
|
142
|
+
SVG
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def relative(path)
|
|
146
|
+
path.relative_path_from(@root).to_s
|
|
147
|
+
rescue ArgumentError
|
|
148
|
+
path.to_s
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def view_files
|
|
152
|
+
VIEW_PATTERNS
|
|
153
|
+
.flat_map { |pattern| Dir.glob(@root.join(pattern)) }
|
|
154
|
+
.map { |path| Pathname(path) }
|
|
155
|
+
.uniq
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def scan_view_for_inline_svgs(file)
|
|
159
|
+
content = File.read(file, encoding: Encoding::UTF_8)
|
|
160
|
+
masked = mask_erb(content)
|
|
161
|
+
|
|
162
|
+
violations = []
|
|
163
|
+
masked.scan(SVG_BLOCK_PATTERN) do
|
|
164
|
+
match = Regexp.last_match
|
|
165
|
+
block = match[0]
|
|
166
|
+
next if block.include?("<use")
|
|
167
|
+
|
|
168
|
+
offset = match.begin(0)
|
|
169
|
+
line_num = masked[0...offset].count("\n") + 1
|
|
170
|
+
|
|
171
|
+
violations << Violation.new(
|
|
172
|
+
type: :inline_svg,
|
|
173
|
+
file: file.relative_path_from(@root).to_s,
|
|
174
|
+
line: line_num,
|
|
175
|
+
column: 1,
|
|
176
|
+
snippet: block.lines.first&.chomp&.strip
|
|
177
|
+
)
|
|
178
|
+
end
|
|
179
|
+
violations
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def mask_erb(content)
|
|
183
|
+
content.gsub(ERB_BLOCK_PATTERN) do |match|
|
|
184
|
+
newline_count = match.count("\n")
|
|
185
|
+
"\n" * newline_count + " " * (match.length - newline_count)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def collect_icon_names
|
|
190
|
+
collect_svgs.map { |path| path.basename(".svg").to_s }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def collect_used_icon_names
|
|
194
|
+
usage_files.flat_map do |file|
|
|
195
|
+
content = File.read(file, encoding: Encoding::UTF_8)
|
|
196
|
+
USAGE_PATTERNS.flat_map { |pattern| content.scan(pattern).flatten }
|
|
197
|
+
end.uniq
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def usage_files
|
|
201
|
+
USAGE_SCAN_PATTERNS
|
|
202
|
+
.flat_map { |pattern| Dir.glob(@root.join(pattern)) }
|
|
203
|
+
.map { |path| Pathname(path) }
|
|
204
|
+
.uniq
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def print_dead_report(report)
|
|
208
|
+
return if report[:dead].empty? && report[:unknown].empty?
|
|
209
|
+
|
|
210
|
+
@output.puts ""
|
|
211
|
+
unless report[:dead].empty?
|
|
212
|
+
@output.puts "Guardrails icons: #{report[:dead].length} unused icon#{'s' if report[:dead].length != 1} in source"
|
|
213
|
+
report[:dead].each { |name| @output.puts " - #{name}" }
|
|
214
|
+
end
|
|
215
|
+
unless report[:unknown].empty?
|
|
216
|
+
@output.puts "Guardrails icons: #{report[:unknown].length} reference#{'s' if report[:unknown].length != 1} to icons not in source"
|
|
217
|
+
report[:unknown].each { |name| @output.puts " - #{name}" }
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def report_inline_svgs(violations)
|
|
222
|
+
return if violations.empty?
|
|
223
|
+
|
|
224
|
+
noun = violations.length == 1 ? "inline SVG" : "inline SVGs"
|
|
225
|
+
@output.puts ""
|
|
226
|
+
@output.puts "Guardrails icons: #{violations.length} #{noun} found in views (should reference the sprite via <use>)"
|
|
227
|
+
violations.each do |v|
|
|
228
|
+
@output.puts " [#{v.type}] #{v.file}:#{v.line}"
|
|
229
|
+
@output.puts " #{v.snippet}"
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Guardrails
|
|
7
|
+
class Init
|
|
8
|
+
class ConfigWriter
|
|
9
|
+
CONFIG_FILENAME = "guardrails.yml"
|
|
10
|
+
|
|
11
|
+
DEFAULT_TOKEN_PATHS = {
|
|
12
|
+
css_custom_properties: {
|
|
13
|
+
"colors_file" => "app/assets/stylesheets/tokens/_colors.css",
|
|
14
|
+
"type_scale_file" => "app/assets/stylesheets/tokens/_type.css"
|
|
15
|
+
},
|
|
16
|
+
scss_variables: {
|
|
17
|
+
"colors_file" => "app/assets/stylesheets/tokens/_colors.scss",
|
|
18
|
+
"type_scale_file" => "app/assets/stylesheets/tokens/_type.scss"
|
|
19
|
+
},
|
|
20
|
+
raw_hex: {
|
|
21
|
+
"colors_file" => nil,
|
|
22
|
+
"type_scale_file" => nil
|
|
23
|
+
},
|
|
24
|
+
none: {
|
|
25
|
+
"colors_file" => nil,
|
|
26
|
+
"type_scale_file" => nil
|
|
27
|
+
}
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
def initialize(root, output: $stdout, now: Time.now)
|
|
31
|
+
@root = Pathname(root)
|
|
32
|
+
@output = output
|
|
33
|
+
@now = now
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def write(detection_result, overrides: {}, force: false)
|
|
37
|
+
path = @root.join(CONFIG_FILENAME)
|
|
38
|
+
existed = path.exist?
|
|
39
|
+
if existed && !force
|
|
40
|
+
@output.puts "#{CONFIG_FILENAME} already exists — refusing to overwrite."
|
|
41
|
+
@output.puts "Delete or rename it, or set FORCE=1 to re-run init."
|
|
42
|
+
return false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
path.write(yaml_for(detection_result, overrides))
|
|
46
|
+
@output.puts(existed ? "Overwrote #{CONFIG_FILENAME}" : "Wrote #{CONFIG_FILENAME}")
|
|
47
|
+
true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def yaml_for(result, overrides)
|
|
53
|
+
token_paths = DEFAULT_TOKEN_PATHS.fetch(result.strategy)
|
|
54
|
+
config = {
|
|
55
|
+
"guardrails" => {
|
|
56
|
+
"audit" => {
|
|
57
|
+
"scan_paths" => overrides.fetch(:scan_paths, ["app/views", "app/components"]),
|
|
58
|
+
"ignore" => overrides.fetch(:ignore, ["app/views/layouts"])
|
|
59
|
+
},
|
|
60
|
+
"icons" => {
|
|
61
|
+
"source" => "app/assets/images/icons",
|
|
62
|
+
"sprite_output" => "app/assets/images/icons/sprite.svg"
|
|
63
|
+
},
|
|
64
|
+
"tokens" => {
|
|
65
|
+
"strategy" => result.strategy.to_s,
|
|
66
|
+
"colors_file" => token_paths["colors_file"],
|
|
67
|
+
"type_scale_file" => token_paths["type_scale_file"],
|
|
68
|
+
"near_match_policy" => overrides.fetch(:near_match_policy, "notify"),
|
|
69
|
+
"near_match_threshold" => overrides.fetch(:near_match_threshold, 4)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
header(result) + config.to_yaml(line_width: -1) + threshold_footer
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def threshold_footer
|
|
78
|
+
<<~YAML
|
|
79
|
+
|
|
80
|
+
# near_match_threshold scale (max per-channel R/G/B difference, 0..255):
|
|
81
|
+
# 0 = exact match only — no near-match suggestions ever fire
|
|
82
|
+
# 1 = nearly identical (probably a typo in the hex)
|
|
83
|
+
# 4 = visually similar (default)
|
|
84
|
+
# 10 = loose — same color family
|
|
85
|
+
# 20+ = very loose, expect false matches
|
|
86
|
+
YAML
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def header(result)
|
|
90
|
+
unset_paths = result.strategy == :raw_hex || result.strategy == :none
|
|
91
|
+
comment = +"# Generated by `rails guardrails:init` on #{@now.strftime('%Y-%m-%d')}\n"
|
|
92
|
+
comment << "# Detected stylesheet strategy: #{result.strategy}\n"
|
|
93
|
+
if unset_paths
|
|
94
|
+
comment << "# No token system detected — colors_file and type_scale_file are unset.\n"
|
|
95
|
+
comment << "# Create a token file and update tokens.colors_file before running guardrails:tokens.\n"
|
|
96
|
+
end
|
|
97
|
+
comment << "\n"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Guardrails
|
|
6
|
+
class Init
|
|
7
|
+
class MediaQueryScaffolder
|
|
8
|
+
DARK_MODE_PATTERN = /@media\s*\(\s*prefers-color-scheme\s*:\s*dark\s*\)/
|
|
9
|
+
HIGH_CONTRAST_PATTERN = /@media\s*\(\s*prefers-contrast\s*:\s*more\s*\)/
|
|
10
|
+
|
|
11
|
+
DARK_MODE_STUB = <<~CSS
|
|
12
|
+
@media (prefers-color-scheme: dark) {
|
|
13
|
+
/* TODO: fill this in — define dark-mode token overrides here */
|
|
14
|
+
}
|
|
15
|
+
CSS
|
|
16
|
+
|
|
17
|
+
HIGH_CONTRAST_STUB = <<~CSS
|
|
18
|
+
@media (prefers-contrast: more) {
|
|
19
|
+
/* TODO: fill this in — define high-contrast token overrides here */
|
|
20
|
+
}
|
|
21
|
+
CSS
|
|
22
|
+
|
|
23
|
+
def initialize(file, output: $stdout)
|
|
24
|
+
@file = file ? Pathname(file) : nil
|
|
25
|
+
@output = output
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def scaffold
|
|
29
|
+
return [:skipped, "no colors_file configured — add prefers-color-scheme / prefers-contrast blocks to your token file manually"] if @file.nil?
|
|
30
|
+
return [:skipped, "configured colors_file does not exist (#{@file.basename}) — skipping media-query scaffold"] unless @file.exist?
|
|
31
|
+
|
|
32
|
+
content = File.read(@file, encoding: Encoding::UTF_8)
|
|
33
|
+
additions = []
|
|
34
|
+
|
|
35
|
+
unless content.match?(DARK_MODE_PATTERN)
|
|
36
|
+
content = ensure_trailing_newline(content) + "\n" + DARK_MODE_STUB
|
|
37
|
+
additions << "prefers-color-scheme: dark"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
unless content.match?(HIGH_CONTRAST_PATTERN)
|
|
41
|
+
content = ensure_trailing_newline(content) + "\n" + HIGH_CONTRAST_STUB
|
|
42
|
+
additions << "prefers-contrast: more"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if additions.empty?
|
|
46
|
+
[:already_present, "media-query stubs already present in #{@file.basename}"]
|
|
47
|
+
else
|
|
48
|
+
File.write(@file, content, encoding: Encoding::UTF_8)
|
|
49
|
+
[:appended, "appended stubs (#{additions.join(', ')}) to #{@file.basename}"]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def ensure_trailing_newline(content)
|
|
56
|
+
content.end_with?("\n") ? content : content + "\n"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Guardrails
|
|
4
|
+
class Init
|
|
5
|
+
# Minimal stdin/stdout prompter for guardrails:init. When the input
|
|
6
|
+
# stream isn't a TTY (CI, piped invocations) every method short-circuits
|
|
7
|
+
# to the configured default — no blocking reads, no surprises.
|
|
8
|
+
class Prompter
|
|
9
|
+
def initialize(input: $stdin, output: $stdout)
|
|
10
|
+
@input = input
|
|
11
|
+
@output = output
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Free-text prompt. Empty input accepts the default.
|
|
15
|
+
def ask(question, default:)
|
|
16
|
+
return default unless interactive?
|
|
17
|
+
|
|
18
|
+
@output.print "#{question} [#{default}]: "
|
|
19
|
+
@output.flush if @output.respond_to?(:flush)
|
|
20
|
+
line = @input.gets
|
|
21
|
+
return default if line.nil?
|
|
22
|
+
|
|
23
|
+
answer = line.chomp.strip
|
|
24
|
+
answer.empty? ? default : answer
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Choose one of `choices` (Strings). Accepts the choice name itself or
|
|
28
|
+
# its 1-based index. Re-prompts on bad input. Empty input accepts
|
|
29
|
+
# `default`.
|
|
30
|
+
def choose(question, choices:, default:)
|
|
31
|
+
return default unless interactive?
|
|
32
|
+
|
|
33
|
+
loop do
|
|
34
|
+
@output.puts question
|
|
35
|
+
choices.each_with_index do |c, i|
|
|
36
|
+
marker = c == default ? "*" : " "
|
|
37
|
+
@output.puts " #{i + 1}) #{c}#{marker == '*' ? ' (default)' : ''}"
|
|
38
|
+
end
|
|
39
|
+
@output.print "> "
|
|
40
|
+
@output.flush if @output.respond_to?(:flush)
|
|
41
|
+
line = @input.gets
|
|
42
|
+
return default if line.nil?
|
|
43
|
+
|
|
44
|
+
raw = line.chomp.strip
|
|
45
|
+
return default if raw.empty?
|
|
46
|
+
return choices[raw.to_i - 1] if raw.match?(/\A\d+\z/) && (1..choices.length).cover?(raw.to_i)
|
|
47
|
+
return raw if choices.include?(raw)
|
|
48
|
+
|
|
49
|
+
@output.puts " -> '#{raw}' isn't one of #{choices.join(', ')}; try again."
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def interactive?
|
|
56
|
+
@input.respond_to?(:tty?) && @input.tty?
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Guardrails
|
|
6
|
+
class Init
|
|
7
|
+
class StackDetector
|
|
8
|
+
Result = Struct.new(:strategy, :stylesheets, :evidence, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
STYLESHEET_PATTERNS = [
|
|
11
|
+
"app/assets/stylesheets/**/*.{css,scss}",
|
|
12
|
+
"app/assets/tailwind/**/*.css"
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
# Skip third-party / generated subtrees even when they're nested
|
|
16
|
+
# inside an `app/assets/stylesheets/` tree (common Sprockets
|
|
17
|
+
# convention). Match on path components, not prefix.
|
|
18
|
+
IMPLICIT_IGNORE_SEGMENTS = %w[vendor node_modules tmp public log].freeze
|
|
19
|
+
|
|
20
|
+
CUSTOM_PROPERTY_PATTERN = /--[a-z][\w-]*:\s*[^;]+;/
|
|
21
|
+
SCSS_VARIABLE_PATTERN = /^\s*\$[a-z][\w-]*:/
|
|
22
|
+
HEX_LITERAL_PATTERN = /#[0-9a-fA-F]{3,8}\b/
|
|
23
|
+
|
|
24
|
+
def initialize(root)
|
|
25
|
+
@root = Pathname(root)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def detect
|
|
29
|
+
files = collect_stylesheets
|
|
30
|
+
return Result.new(strategy: :none, stylesheets: [], evidence: empty_evidence) if files.empty?
|
|
31
|
+
|
|
32
|
+
evidence = analyze(files)
|
|
33
|
+
Result.new(
|
|
34
|
+
strategy: choose_strategy(evidence),
|
|
35
|
+
stylesheets: files,
|
|
36
|
+
evidence: evidence
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def collect_stylesheets
|
|
43
|
+
STYLESHEET_PATTERNS
|
|
44
|
+
.flat_map { |pattern| Dir.glob(@root.join(pattern)) }
|
|
45
|
+
.map { |path| Pathname(path) }
|
|
46
|
+
.uniq
|
|
47
|
+
.reject { |path| ignored_segment?(path) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def ignored_segment?(path)
|
|
51
|
+
segments = path.relative_path_from(@root).to_s.split("/")
|
|
52
|
+
(IMPLICIT_IGNORE_SEGMENTS & segments).any?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def empty_evidence
|
|
56
|
+
{
|
|
57
|
+
files_scanned: 0,
|
|
58
|
+
custom_property_files: 0,
|
|
59
|
+
scss_variable_files: 0,
|
|
60
|
+
raw_hex_files: 0
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def analyze(files)
|
|
65
|
+
evidence = empty_evidence
|
|
66
|
+
evidence[:files_scanned] = files.length
|
|
67
|
+
|
|
68
|
+
files.each do |file|
|
|
69
|
+
# Force UTF-8 — real-world stylesheets routinely contain
|
|
70
|
+
# multi-byte chars (em-dashes in comments, smart quotes in
|
|
71
|
+
# string values, etc.) and Pathname#read uses the default
|
|
72
|
+
# external encoding which can be US-ASCII on some systems.
|
|
73
|
+
content = File.read(file, encoding: Encoding::UTF_8)
|
|
74
|
+
has_custom_props = content.match?(CUSTOM_PROPERTY_PATTERN)
|
|
75
|
+
has_scss_vars = content.match?(SCSS_VARIABLE_PATTERN)
|
|
76
|
+
has_hex = content.match?(HEX_LITERAL_PATTERN)
|
|
77
|
+
|
|
78
|
+
evidence[:custom_property_files] += 1 if has_custom_props
|
|
79
|
+
evidence[:scss_variable_files] += 1 if has_scss_vars
|
|
80
|
+
evidence[:raw_hex_files] += 1 if has_hex && !has_custom_props && !has_scss_vars
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
evidence
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Pick the dominant strategy by file count, but with a strong
|
|
87
|
+
# preference for actual token systems over raw_hex. If any token
|
|
88
|
+
# files exist (CSS custom properties or SCSS variables), choose
|
|
89
|
+
# whichever has more — even if raw_hex has more files than either.
|
|
90
|
+
# raw_hex only wins when no token system is present at all.
|
|
91
|
+
def choose_strategy(evidence)
|
|
92
|
+
css_count = evidence[:custom_property_files]
|
|
93
|
+
scss_count = evidence[:scss_variable_files]
|
|
94
|
+
hex_count = evidence[:raw_hex_files]
|
|
95
|
+
|
|
96
|
+
return :none if css_count.zero? && scss_count.zero? && hex_count.zero?
|
|
97
|
+
|
|
98
|
+
# If any token system exists, pick the dominant one — never fall
|
|
99
|
+
# back to raw_hex when SCSS / CSS-vars are in play.
|
|
100
|
+
if css_count.positive? || scss_count.positive?
|
|
101
|
+
return css_count >= scss_count ? :css_custom_properties : :scss_variables
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
:raw_hex
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require_relative "init/stack_detector"
|
|
6
|
+
require_relative "init/config_writer"
|
|
7
|
+
require_relative "init/media_query_scaffolder"
|
|
8
|
+
require_relative "init/prompter"
|
|
9
|
+
|
|
10
|
+
module Guardrails
|
|
11
|
+
class Init
|
|
12
|
+
NEAR_MATCH_POLICY_CHOICES = %w[notify fix leave].freeze
|
|
13
|
+
DEFAULT_SCAN_PATHS = %w[app/views app/components].freeze
|
|
14
|
+
DEFAULT_IGNORE = %w[app/views/layouts].freeze
|
|
15
|
+
|
|
16
|
+
STRATEGY_LABELS = {
|
|
17
|
+
css_custom_properties: "CSS custom properties",
|
|
18
|
+
scss_variables: "SCSS variables",
|
|
19
|
+
raw_hex: "Raw hex literals (no token system detected)",
|
|
20
|
+
none: "No stylesheets found"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
def initialize(root:, output: $stdout, input: $stdin, force: false)
|
|
24
|
+
@root = Pathname(root)
|
|
25
|
+
@output = output
|
|
26
|
+
@input = input
|
|
27
|
+
@force = force
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run
|
|
31
|
+
result = StackDetector.new(@root).detect
|
|
32
|
+
print_summary(result)
|
|
33
|
+
|
|
34
|
+
# Don't prompt the user if we know we won't write — keeps reruns from
|
|
35
|
+
# asking questions whose answers will be discarded.
|
|
36
|
+
overrides = config_writeable? ? collect_overrides : {}
|
|
37
|
+
|
|
38
|
+
written = ConfigWriter.new(@root, output: @output).write(result, overrides: overrides, force: @force)
|
|
39
|
+
if written
|
|
40
|
+
scaffold_media_queries
|
|
41
|
+
else
|
|
42
|
+
@output.puts "Media queries: skipped (delete guardrails.yml or set FORCE=1 to re-run init)"
|
|
43
|
+
end
|
|
44
|
+
result
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def config_writeable?
|
|
50
|
+
@force || !@root.join("guardrails.yml").exist?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def collect_overrides
|
|
54
|
+
prompter = Prompter.new(input: @input, output: @output)
|
|
55
|
+
{
|
|
56
|
+
near_match_policy: prompter.choose(
|
|
57
|
+
"Near-match auto-fix policy:",
|
|
58
|
+
choices: NEAR_MATCH_POLICY_CHOICES,
|
|
59
|
+
default: "notify"
|
|
60
|
+
),
|
|
61
|
+
near_match_threshold: parse_int(
|
|
62
|
+
prompter.ask(
|
|
63
|
+
"Near-match threshold (per-channel R/G/B, 0=exact only, 4=default, 10+=loose):",
|
|
64
|
+
default: "4"
|
|
65
|
+
),
|
|
66
|
+
default: 4
|
|
67
|
+
),
|
|
68
|
+
scan_paths: csv(prompter.ask(
|
|
69
|
+
"Audit scan paths (comma-separated):",
|
|
70
|
+
default: DEFAULT_SCAN_PATHS.join(",")
|
|
71
|
+
)),
|
|
72
|
+
ignore: csv(prompter.ask(
|
|
73
|
+
"Audit ignore paths (comma-separated; matched as path prefixes, not globs):",
|
|
74
|
+
default: DEFAULT_IGNORE.join(",")
|
|
75
|
+
))
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def parse_int(value, default:)
|
|
80
|
+
Integer(value)
|
|
81
|
+
rescue ArgumentError, TypeError
|
|
82
|
+
default
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def csv(value)
|
|
86
|
+
value.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def scaffold_media_queries
|
|
90
|
+
file = configured_colors_file
|
|
91
|
+
status, message = MediaQueryScaffolder.new(file, output: @output).scaffold
|
|
92
|
+
@output.puts "Media queries: #{message}"
|
|
93
|
+
status
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def configured_colors_file
|
|
97
|
+
config_path = @root.join("guardrails.yml")
|
|
98
|
+
return nil unless config_path.exist?
|
|
99
|
+
|
|
100
|
+
config = YAML.safe_load_file(config_path) || {}
|
|
101
|
+
relative = config.dig("guardrails", "tokens", "colors_file")
|
|
102
|
+
relative ? @root.join(relative) : nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def print_summary(result)
|
|
106
|
+
@output.puts "Guardrails — stack detection"
|
|
107
|
+
@output.puts "Root: #{@root}"
|
|
108
|
+
@output.puts "Strategy: #{STRATEGY_LABELS.fetch(result.strategy)} (#{result.strategy})"
|
|
109
|
+
@output.puts "Stylesheets scanned: #{result.evidence[:files_scanned]}"
|
|
110
|
+
@output.puts " custom-property files: #{result.evidence[:custom_property_files]}"
|
|
111
|
+
@output.puts " SCSS-variable files: #{result.evidence[:scss_variable_files]}"
|
|
112
|
+
@output.puts " raw-hex files: #{result.evidence[:raw_hex_files]}"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|