ui_guardrails 1.2.0 → 1.3.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.
@@ -9,214 +9,63 @@ namespace :guardrails do
9
9
  Guardrails::Init.new(root: root, force: force).run
10
10
  end
11
11
 
12
- desc "Audit views and components for UI drift (SUGGEST=1, APPLY=1, FORMAT=json)"
12
+ desc "Audit views and components for UI drift (SUGGEST=1, APPLY=1, FORMAT=json|html)"
13
13
  task :audit do
14
- require "guardrails/audit"
15
- require "guardrails/stimulus_audit"
16
- require "guardrails/partial_similarity"
17
- require "guardrails/view_component_audit"
18
- require "guardrails/a11y_audit"
19
- require "guardrails/cross_codebase_patterns"
20
- require "guardrails/class_itis"
21
- require "guardrails/a11y_deep"
22
- require "guardrails/visual_diff"
23
- require "stringio"
14
+ require "guardrails/report/run"
15
+ require "guardrails/report/style"
24
16
  root = defined?(Rails) ? Rails.root : Pathname(Dir.pwd)
25
- axe_json_path = ENV["AXE_JSON"]
26
17
 
27
- # Visual-diff is opt-in (baselines need deliberate setup). Enabled
28
- # when either VISUAL_DIFF=1 is set in the env (sidecar mode) or
29
- # Guardrails.configuration.visual_diff.enabled was flipped on by a
30
- # Rails initializer (embedded mode). Env overrides Configuration.
31
- visual_diff_env = %w[1 true yes].include?(ENV["VISUAL_DIFF"]&.downcase)
32
- visual_diff_on = visual_diff_env || Guardrails.configuration.visual_diff.enabled
33
- # Strip + reject blank env values — an empty VISUAL_DIFF_DIR would
34
- # otherwise be applied as snap_diff_dir = "" and glob from the repo
35
- # root (potentially scanning the whole tree).
36
- if (dir = ENV["VISUAL_DIFF_DIR"]) && !dir.strip.empty?
37
- Guardrails.configure { |c| c.visual_diff.snap_diff_dir = dir.strip }
38
- end
39
- if (thr = ENV["VISUAL_DIFF_THRESHOLD"]) && !thr.strip.empty?
40
- Guardrails.configure { |c| c.visual_diff.threshold = thr.strip }
41
- end
42
- suggest = %w[1 true yes].include?(ENV["SUGGEST"]&.downcase)
43
- apply = %w[1 true yes].include?(ENV["APPLY"]&.downcase)
44
- format = ENV["FORMAT"]&.downcase == "json" ? :json : :text
45
- similarity_opts = { root: root }
46
- similarity_opts[:threshold] = ENV["SIMILARITY_THRESHOLD"].to_f if ENV["SIMILARITY_THRESHOLD"]
47
- pattern_opts = { root: root }
48
- pattern_opts[:min_size] = ENV["PATTERN_MIN_SIZE"].to_i if ENV["PATTERN_MIN_SIZE"]
49
- pattern_opts[:min_occurrences] = ENV["PATTERN_MIN_OCCURRENCES"].to_i if ENV["PATTERN_MIN_OCCURRENCES"]
50
- classitis_opts = { root: root }
51
- classitis_opts[:min_classes] = ENV["CLASSITIS_MIN_CLASSES"].to_i if ENV["CLASSITIS_MIN_CLASSES"]
52
- classitis_opts[:min_occurrences] = ENV["CLASSITIS_MIN_OCCURRENCES"].to_i if ENV["CLASSITIS_MIN_OCCURRENCES"]
18
+ format = { "json" => :json, "html" => :html }.fetch(ENV["FORMAT"].to_s.downcase, :text)
53
19
 
54
- if format == :json
55
- # Run sub-audits silently so the only thing printed to stdout is one
56
- # JSON document. Audit's own JSON output goes through @output, so we
57
- # capture it instead of re-emitting.
58
- sink = StringIO.new
59
- violations = Guardrails::Audit.new(
60
- root: root, output: sink, suggest: suggest, apply: apply, format: :text
61
- ).run
62
- stimulus = Guardrails::StimulusAudit.new(root: root, output: sink).run
63
- similarity_opts[:output] = sink
64
- similarity = Guardrails::PartialSimilarity.new(**similarity_opts).run
65
- vc = Guardrails::ViewComponentAudit.new(root: root, output: sink).run
66
- a11y = Guardrails::A11yAudit.new(root: root, output: sink).run
67
- pattern_opts[:output] = sink
68
- patterns = Guardrails::CrossCodebasePatterns.new(**pattern_opts).run
69
- classitis_opts[:output] = sink
70
- classitis = Guardrails::ClassItis.new(**classitis_opts).run
71
- a11y_deep_runner = axe_json_path ? Guardrails::A11yDeep.new(input: axe_json_path, output: sink) : nil
72
- a11y_deep = a11y_deep_runner&.run || []
73
- visual_diff_runner = visual_diff_on ? Guardrails::VisualDiff.new(root: root, output: sink) : nil
74
- visual_diff = visual_diff_runner&.run || []
20
+ # One Style bound to the real terminal, threaded through every
21
+ # detector so the whole report tracks the same TTY/NO_COLOR signal
22
+ # (see Report::Run). JSON and HTML modes discard the report body,
23
+ # so they run unstyled.
24
+ report_style = format == :text ? Guardrails::Report::Style.new(io: $stdout) : nil
25
+ run = Guardrails::Report::Run.from_env(root: root, style: report_style).call
75
26
 
27
+ if format == :json
76
28
  require "json"
77
- payload = {
78
- summary: {
79
- violations: violations.length,
80
- stimulus_orphaned: stimulus.orphaned.length,
81
- stimulus_dead: stimulus.dead.length,
82
- similar_partials: similarity.length,
83
- missing_previews: vc.missing_previews.length,
84
- orphan_slots: vc.orphan_slots.length,
85
- a11y: a11y.length,
86
- a11y_deep: a11y_deep.length,
87
- patterns: patterns.length,
88
- classitis: classitis.length,
89
- visual_diff: visual_diff.length
90
- },
91
- violations: violations.map(&:to_h),
92
- stimulus: { orphaned: stimulus.orphaned, dead: stimulus.dead },
93
- similar_partials: similarity.map(&:to_h),
94
- view_components: {
95
- missing_previews: vc.missing_previews,
96
- orphan_slots: vc.orphan_slots.map(&:to_h)
97
- },
98
- a11y: a11y.map(&:to_h),
99
- a11y_deep: a11y_deep.map(&:to_h),
100
- patterns: patterns.map { |p|
101
- { fingerprint: p.fingerprint, shape: p.shape, size: p.size, count: p.count, occurrences: p.occurrences.map(&:to_h) }
102
- },
103
- classitis: classitis.map { |c|
104
- { tag: c.tag, classes: c.classes, count: c.count, occurrences: c.occurrences.map(&:to_h) }
105
- },
106
- visual_diff: visual_diff.map(&:to_h)
107
- }
108
- $stdout.puts JSON.pretty_generate(payload)
29
+ $stdout.puts JSON.pretty_generate(run.to_h)
30
+ elsif format == :html
31
+ # One self-contained file — for a browser, a PR attachment, or a
32
+ # CI artifact. OUTPUT= overrides the default tmp/ location.
33
+ require "guardrails/report/html"
34
+ output = ENV["OUTPUT"].to_s.strip
35
+ path = Guardrails::Report::Html.new(categories: run.categories, root: root)
36
+ .write(output.empty? ? Guardrails::Report::Html::DEFAULT_PATH : output)
37
+ $stdout.puts "Guardrails audit: #{run.findings.length} findings → #{path}"
109
38
  else
110
- # Run each sub-audit against an in-memory sink first so we can
111
- # render the top-of-report summary before the per-category
112
- # details (the summary needs every detector's count). Then
113
- # write the sink + per-category sections out together.
114
- #
115
- # Important: detectors write into `sink` (StringIO) but make
116
- # ANSI-color decisions against `$stdout`. If we let each
117
- # detector instantiate its own Style bound to the sink, every
118
- # color() call would be false (StringIO isn't a TTY) and the
119
- # body of the report would print plain even on a real terminal,
120
- # while the top/bottom summary printed straight to $stdout
121
- # would still be colored. Pre-build one Style here and thread
122
- # it through every detector so the whole report tracks the
123
- # real terminal's TTY/NO_COLOR signal consistently.
124
- require "guardrails/report/summary"
125
- require "guardrails/report/style"
126
- sink = StringIO.new
127
- report_style = Guardrails::Report::Style.new(io: $stdout)
128
- violations = Guardrails::Audit.new(
129
- root: root, output: sink, suggest: suggest, apply: apply, format: :text,
130
- style: report_style
131
- ).run
132
- stimulus = Guardrails::StimulusAudit.new(root: root, output: sink, style: report_style).run
133
- similarity_opts[:output] = sink
134
- similarity_opts[:style] = report_style
135
- similarity = Guardrails::PartialSimilarity.new(**similarity_opts).run
136
- vc = Guardrails::ViewComponentAudit.new(root: root, output: sink, style: report_style).run
137
- a11y = Guardrails::A11yAudit.new(root: root, output: sink, style: report_style).run
138
- pattern_opts[:output] = sink
139
- pattern_opts[:style] = report_style
140
- patterns = Guardrails::CrossCodebasePatterns.new(**pattern_opts).run
141
- classitis_opts[:output] = sink
142
- classitis_opts[:style] = report_style
143
- classitis = Guardrails::ClassItis.new(**classitis_opts).run
144
- a11y_deep_runner = axe_json_path ? Guardrails::A11yDeep.new(input: axe_json_path, output: sink, style: report_style) : nil
145
- a11y_deep = a11y_deep_runner&.run || []
146
- visual_diff_runner = visual_diff_on ? Guardrails::VisualDiff.new(root: root, output: sink, style: report_style) : nil
147
- visual_diff = visual_diff_runner&.run || []
148
-
149
- summary_entries = [
150
- Guardrails::Report::Summary::Entry.new(
151
- category: "raw_color", count: violations.count { |v| v.type == :raw_color },
152
- severity: :error, auto_fix: true
153
- ),
154
- Guardrails::Report::Summary::Entry.new(
155
- category: "tailwind_arbitrary", count: violations.count { |v| v.type == :tailwind_arbitrary },
156
- severity: :error, auto_fix: true
157
- ),
158
- Guardrails::Report::Summary::Entry.new(
159
- category: "inline_style", count: violations.count { |v| v.type == :inline_style },
160
- severity: :warning
161
- ),
162
- Guardrails::Report::Summary::Entry.new(
163
- category: "helper_recommended", count: violations.count { |v| v.type == :helper_recommended },
164
- severity: :warning
165
- ),
166
- Guardrails::Report::Summary::Entry.new(
167
- category: "a11y (static)", count: a11y.length, severity: :error
168
- ),
169
- Guardrails::Report::Summary::Entry.new(
170
- category: "a11y (deep)", count: a11y_deep.length, severity: :error
171
- ),
172
- Guardrails::Report::Summary::Entry.new(
173
- category: "stimulus orphaned", count: stimulus.orphaned.length, severity: :warning
174
- ),
175
- Guardrails::Report::Summary::Entry.new(
176
- category: "stimulus dead", count: stimulus.dead.length, severity: :warning
177
- ),
178
- Guardrails::Report::Summary::Entry.new(
179
- category: "missing previews", count: vc.missing_previews.length, severity: :warning
180
- ),
181
- Guardrails::Report::Summary::Entry.new(
182
- category: "orphan slots", count: vc.orphan_slots.length, severity: :warning
183
- ),
184
- Guardrails::Report::Summary::Entry.new(
185
- category: "visual diff", count: visual_diff.length, severity: :error
186
- ),
187
- Guardrails::Report::Summary::Entry.new(
188
- category: "similar partials", count: similarity.length, severity: :suggestion,
189
- unit: "pairs", action: "consider deduplicating"
190
- ),
191
- Guardrails::Report::Summary::Entry.new(
192
- category: "cross-codebase patterns", count: patterns.length, severity: :suggestion,
193
- unit: "candidates", action: "consider extracting partials"
194
- ),
195
- Guardrails::Report::Summary::Entry.new(
196
- category: "class-itis", count: classitis.length, severity: :suggestion,
197
- unit: "clusters", action: "consider extracting component / @apply"
198
- )
199
- ]
200
-
201
39
  # Render the summary twice — once at the top so the reader
202
40
  # knows what to expect, once at the bottom as a recap so they
203
41
  # don't have to scroll back up after the per-detector dump.
204
42
  # On a long output (Patchvault has 981 findings) the bottom
205
43
  # recap is the load-bearing one.
206
- summary = Guardrails::Report::Summary.new(entries: summary_entries, output: $stdout, style: report_style)
44
+ summary = Guardrails::Report::Summary.new(entries: run.summary_entries, output: $stdout, style: report_style)
207
45
  summary.render
208
- $stdout.write sink.string
46
+ $stdout.write run.body
209
47
  summary.render(recap: true)
210
48
  end
211
49
 
212
- # Deep a11y findings only fail the audit when their impact crosses
213
- # the configured threshold (default: any impact fails, same as
214
- # static a11y). When AXE_JSON is not set, a11y_deep is [] and the
215
- # check is a no-op. Same logic for visual_diff: opt-in via
216
- # VISUAL_DIFF=1 / Configuration; threshold gates failure.
217
- a11y_deep_failing = a11y_deep_runner&.any_failing?(a11y_deep) || false
218
- visual_diff_failing = visual_diff_runner&.any_failing?(visual_diff) || false
219
- exit 1 if violations.any? || stimulus.violations? || similarity.any? || vc.violations? || a11y.any? || a11y_deep_failing || visual_diff_failing
50
+ exit 1 if run.failing?
51
+ end
52
+
53
+ desc "Browse audit findings interactively (same env options as guardrails:audit)"
54
+ task :tui do
55
+ require "guardrails/report/run"
56
+ require "guardrails/tui"
57
+ root = defined?(Rails) ? Rails.root : Pathname(Dir.pwd)
58
+
59
+ # APPLY / SUGGEST are deliberately not honored here: browsing
60
+ # shouldn't rewrite views or drop markdown files, least of all
61
+ # again on every `r` re-run.
62
+ env = ENV.to_h.reject { |key, _| %w[APPLY SUGGEST].include?(key) }
63
+ runner = -> { Guardrails::Report::Run.from_env(root: root, env: env).call }
64
+ begin
65
+ Guardrails::TUI.new(runner: runner, root: root).start
66
+ rescue Guardrails::TUI::NotInteractive => e
67
+ abort e.message
68
+ end
220
69
  end
221
70
 
222
71
  desc "Parse axe-core JSON output and report deep a11y findings (AXE_JSON=path/to/axe.json)"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ui_guardrails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - John Athayde
@@ -105,12 +105,21 @@ files:
105
105
  - lib/guardrails/lookbook/views/lookbook_panels/_guardrails.html.erb
106
106
  - lib/guardrails/partial_similarity.rb
107
107
  - lib/guardrails/railtie.rb
108
+ - lib/guardrails/report/finding.rb
109
+ - lib/guardrails/report/html.rb
110
+ - lib/guardrails/report/html/template.html.erb
111
+ - lib/guardrails/report/run.rb
108
112
  - lib/guardrails/report/style.rb
109
113
  - lib/guardrails/report/summary.rb
110
114
  - lib/guardrails/stimulus_audit.rb
111
115
  - lib/guardrails/token_matcher.rb
112
116
  - lib/guardrails/tokens.rb
113
117
  - lib/guardrails/tokens/tailwind_config_parser.rb
118
+ - lib/guardrails/tui.rb
119
+ - lib/guardrails/tui/editor.rb
120
+ - lib/guardrails/tui/keys.rb
121
+ - lib/guardrails/tui/screen.rb
122
+ - lib/guardrails/tui/state.rb
114
123
  - lib/guardrails/version.rb
115
124
  - lib/guardrails/view_component_audit.rb
116
125
  - lib/guardrails/visual_diff.rb