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