ui_guardrails 1.5.0 → 1.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b29232255e89ade665ffe0aa02661183fbf71c5918c53704d8241293be757454
4
- data.tar.gz: 5d3cf78b2091bb6e781f6bd563976c9bc1c583a8269309ce897ed90d8eae3b3b
3
+ metadata.gz: bcb56bb52cb2fe3e2d0bd155eaf5b8563177588b53d34af57f4d125653cd6f47
4
+ data.tar.gz: 5803d7480e71fe72cc6964bd424333fb888709d73104f215430a520d09cc4b49
5
5
  SHA512:
6
- metadata.gz: 99aa335b527770be65aa661262461ec9b9cc0e1a9f1f2b70153b804b584c5f3de497c0e38f18183d6eac1977d343506d99f42685a49c3bbe3e953781ba3cd176
7
- data.tar.gz: 894f7504d9e0f559b79051645f7f2c29de05215de57b2f4e04f78f9eb0bb4145568d13bd593123be401a15f626b9c3872174467dd78920a4ce3f265709bb3712
6
+ metadata.gz: 18bb8c26caf6971a74cc2a0d585acbcf57fbd27b1f703bc0fcedfa4d038724d0362623addcf06723e152f2f4cd4234829a88ba720dfcf0042a79c027067c27bb
7
+ data.tar.gz: b559b93cc20faa99f6af5b7a806117ea5925d6dacee2df064cac8e2fe4499d7763277ce0c6a47944f4c77dd7373d091fd01bba65e21a09b3e1dcd56193628d71
data/README.md CHANGED
@@ -97,6 +97,7 @@ APPLY=1 bundle exec rake guardrails:audit
97
97
  | `guardrails:tokens` | Parses your color and type-scale tokens (CSS vars / SCSS vars / Tailwind v3 config / Tailwind v4 `@theme`), reports hex literals in stylesheets that should reference a token. |
98
98
  | `guardrails:a11y:deep` | Reads axe-core JSON output and folds it into the unified report. Doesn't run axe itself (no Capybara / headless Chrome runtime deps) — point it at axe output your existing tooling produces. |
99
99
  | `guardrails:visual:deep` | Consumes screenshot-diff tool output (snap_diff-capybara today; BackstopJS in flight, [#15](https://github.com/meticulous/guardrails/issues/15)) and reports visual regressions. Same parse-only design — your existing test toolchain runs screenshots; Guardrails reports. |
100
+ | `guardrails:hooks:install` | Adds Guardrails to coding agents' hooks (Claude Code, Codex, Cursor, Gemini CLI), so drift an agent introduces goes back to it while it works. See [Coding agent hooks](#coding-agent-hooks). |
100
101
 
101
102
  ---
102
103
 
@@ -228,6 +229,23 @@ Works on the same line or the preceding line, in Ruby `#` comments, ERB `<%# %>`
228
229
 
229
230
  **No-fix policy**: emoji contribute to `guardrails:icons` exit code (part of Result#violations?). Dead icons stay reporting-only — deleting a file is a human decision.
230
231
 
232
+ ### Detectors from other gems
233
+
234
+ Other gems add detectors to `guardrails:audit` with `Guardrails::Report::Detectors.register(name, klass)`. They run after the built-ins and show up in the text report, the summary, `FORMAT=json` (under `extensions.<name>`), `FORMAT=html` and the TUI. The class answers:
235
+
236
+ | Method | Does |
237
+ | --- | --- |
238
+ | `.enabled?(env:)` | Whether this audit runs it, from the audit's env vars or the gem's own config |
239
+ | `.max_severity` | The most serious severity it reports; `SEVERITY=` above that skips it without running it |
240
+ | `.new(root:, output:, style:)` | Built like the built-in detectors |
241
+ | `#run` | Returns its result and prints its own section to `output` |
242
+ | `#categories(result)` | `Report::Category`s for the TUI, HTML and JSON findings |
243
+ | `#summary_entry(result)` | A `Report::Summary::Entry` for the rollup |
244
+ | `#to_h(result)` | Its `FORMAT=json` fragment |
245
+ | `#failing?(result)` | Its own exit-code rule |
246
+
247
+ Registration checks the class has all of these. Detectors only read what's already on disk; they never capture or call an API, because the TUI re-runs every detector on each `r`. ui_guardrails-browser registers its heuristic review this way.
248
+
231
249
  ---
232
250
 
233
251
  ## Output
@@ -275,6 +293,35 @@ The editor is `$GUARDRAILS_EDITOR`, then `$VISUAL`, then `$EDITOR`. VS Code-fami
275
293
 
276
294
  ---
277
295
 
296
+ ## Coding agent hooks
297
+
298
+ ```bash
299
+ bundle exec rake guardrails:hooks:install # Claude Code
300
+ AGENT=claude,codex bundle exec rake guardrails:hooks:install
301
+ AGENT=all bundle exec rake guardrails:hooks:install # Claude Code, Codex, Cursor, Gemini CLI
302
+ ```
303
+
304
+ This adds Guardrails to each agent's project hook config. It keeps whatever else is in that file and is safe to run again. Each hook calls `guardrails-hook <agent> <event>`, which runs only the parse-only checks: view drift (raw colours, Tailwind arbitrary values, inline styles, helper recommendations) and static a11y. A typical call takes a fraction of a second.
305
+
306
+ It reports only what the agent introduced. A template is compared with its version at `HEAD`, so drift that was already there isn't the agent's to fix. Findings are matched by content, so a finding on a line that moved isn't counted as new.
307
+
308
+ | When | What happens |
309
+ | --- | --- |
310
+ | After the agent edits a template | The drift it just introduced goes back to it as context. Nothing is blocked. |
311
+ | When it tries to finish | New **errors** in any template changed since `HEAD` send it back to work, once per turn. Warnings alone don't. |
312
+ | When a session starts | Notes from other gems (ui_guardrails-browser adds open visual-review rejections). |
313
+
314
+ | Agent | Config | Notes |
315
+ | --- | --- | --- |
316
+ | Claude Code | `.claude/settings.json` | `PostToolUse`, `Stop`, `SessionStart` |
317
+ | Codex | `.codex/hooks.json` | Same events. Edits arrive as patches, so file names come from the patch. Trust the hooks in `/hooks` once. |
318
+ | Cursor | `.cursor/hooks.json` | `stop` and `sessionStart` only: Cursor's after-edit output doesn't reliably reach the model, so the stop check covers every changed template. Cursor also runs `.claude/settings.json` hooks; the hook recognises Cursor's input and doesn't run twice. |
319
+ | Gemini CLI | `.gemini/settings.json` | `AfterTool` (`write_file`, `replace`), `AfterAgent`, `SessionStart` |
320
+
321
+ The hook never breaks the agent's turn: if anything goes wrong it prints `{}`, explains on stderr, and exits 0. Other gems add to the same events with `Guardrails::Hooks.register(name, contributor)`, shipped at `lib/guardrails/hook_contributors/<name>.rb` so the hook finds them without booting the app.
322
+
323
+ ---
324
+
278
325
  ## CI
279
326
 
280
327
  The audit task is a single shell command:
data/doc/ROADMAP.md CHANGED
@@ -132,7 +132,10 @@ The planned roadmap ended at V2. Running the gem on real codebases (Patchvault:
132
132
  | Emoji + glyph icon detection | ✅ shipped (1.5.0) | `Guardrails::Icons::EmojiScan` catches emoji pictographs and Unicode-dingbat glyphs used as UI iconography — grapheme-cluster iteration (VS16 / ZWJ / flag / keycap count as one), range-first single-codepoint classification, per-file-type comment masking (Prism for Ruby, `<%# %>` for ERB, `//` `/* */` for JS/SCSS, `#` for YAML), `guardrails-ok: emoji` inline marker (first inline-escape convention in the gem), `allow_files` + `allow_chars` config, `SUGGEST=1` sprite-name hints. `guardrails:icons` also flipped to blocking — exit 1 on inline SVGs, unknown sprite refs, or emoji icons. |
133
133
  | Lookbook panel on the new data layer | not started | The auto-registered panel still renders from its own `ComponentReport`; it could render `Report::Finding`s and pick up suggestions for free. Icons findings could also flow through — the panel doesn't list icon findings today. |
134
134
  | Icons on the new data layer | not started | Icons stays outside `Report::Run` — its rake task calls `Icons.new(root:).run` directly. Migrating gets it into the TUI + HTML report for free but changes the rake shape; hold until there's demand. |
135
- | CI test workflow | not started | Specs only run inside the release workflow, on one Ruby. A PR-time matrix (3.4 + head) would catch floor regressions before tag time. |
135
+ | Visual-diff adapter registry | ✅ shipped (1.6.0) | `Guardrails::VisualDiff.register_adapter` lets other gems add adapters; the name is checked when the adapter is built. `VisualDiff::Finding` gains `status`, `comment`, `regions`, `extra_details`. First consumer: ui_guardrails-browser's `:browser` adapter. |
136
+ | Coding agent hooks | ✅ shipped (1.7.0) | `rake guardrails:hooks:install` wires Claude Code, Codex, Cursor and Gemini CLI to `guardrails-hook`: drift an agent introduces (compared with `HEAD`) goes back to it after each edit, and new errors send it back once when it tries to finish. Other gems contribute through `Guardrails::Hooks.register`; ui_guardrails-browser adds visual-review rejections. |
137
+ | Detector registry | ✅ shipped (1.7.0) | `Guardrails::Report::Detectors.register(name, klass)` adds a detector to `Report::Run`, so it reaches the text report, summary, JSON (under `extensions`), HTML and TUI. Each decides when it's on (`enabled?(env:)`), how serious it can get (`max_severity`, so `SEVERITY=` skips it), and when it fails. First consumer: ui_guardrails-browser's heuristic review. |
138
+ | CI test workflow | ✅ shipped | `.github/workflows/test.yml` runs `bundle exec rspec` (including the `examples/demo` integration spec) and `gem build` on every pull request and push to `main`, on Ruby 3.4 (the floor) and 4.0. Floor regressions and packaging mistakes surface on the PR instead of at tag time. |
136
139
 
137
140
  ---
138
141
 
data/doc/VISUAL-DIFF.md CHANGED
@@ -61,6 +61,22 @@ With `enabled = true`, `rake guardrails:audit` always runs the visual-diff check
61
61
 
62
62
  Env always overrides Configuration.
63
63
 
64
+ ## Adapters from other gems
65
+
66
+ `Guardrails.configuration.visual_diff.adapter` names a registered adapter. snap_diff is built in; other gems register theirs:
67
+
68
+ ```ruby
69
+ Guardrails::VisualDiff.register_adapter(:browser) do |root:, config:|
70
+ MyAdapter.new(root: root) # #collect returns Guardrails::VisualDiff::Finding objects
71
+ end
72
+ ```
73
+
74
+ `config` is `Guardrails.configuration.visual_diff`. The name is checked when the adapter is built, so a host initializer can set `adapter = :browser` before the gem that registers it loads. An unregistered name fails with the registered ones listed.
75
+
76
+ Besides the fields below, adapter findings can set `status` (`"changed"`, `"new"`, ...), `comment`, `regions` (`[{ x:, y:, w:, h: }]`) and `extra_details` (`[[label, value], ...]`). They show up as report details, and a finding with a `status` but no ratio is titled `[changed] scenario`. As with snap_diff, a finding with `mismatch_ratio: nil` always fails.
77
+
78
+ [ui_guardrails-browser](https://github.com/meticulous/guardrails-browser) registers `:browser`, which reports unresolved items from its own capture and review loop.
79
+
64
80
  ## How the snap_diff adapter works
65
81
 
66
82
  The snap_diff-capybara gem commits baseline screenshots to git under `doc/screenshots/<name>.png` and, on test failure, writes a sibling `<name>.diff.png` (changed pixels in red) plus a `<name>.heatmap.diff.png` (variance density). Tests fail on baseline mismatches in CI, so the diff files are an artifact of "this regression slipped through to the artifact directory."
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Called by a coding agent's hooks: `guardrails-hook <agent> <event>`,
5
+ # with the agent's hook input on stdin. `rake guardrails:hooks:install`
6
+ # writes the agent configuration that calls it.
7
+ require "guardrails"
8
+ require "guardrails/hooks/runner"
9
+
10
+ result = Guardrails::Hooks::Runner.call(agent: ARGV[0], event: ARGV[1], stdin: $stdin.read, env: ENV)
11
+ $stdout.write(result.stdout)
12
+ $stderr.write(result.stderr)
13
+ exit result.exit_code
@@ -62,6 +62,18 @@ module Guardrails
62
62
  )]
63
63
  end
64
64
 
65
+ # One template's findings without printing. `content` scans a
66
+ # version that isn't on disk (see Audit#scan_source).
67
+ def scan_source(path, content = nil)
68
+ scan_file(Pathname(path).expand_path(@root), content)
69
+ end
70
+
71
+ # Whether a full run would scan this file.
72
+ def covers?(path)
73
+ relative = Pathname(path).expand_path(@root).relative_path_from(@root).to_s
74
+ SCAN_PATTERNS.any? { |pattern| File.fnmatch?(pattern, relative, File::FNM_PATHNAME) }
75
+ end
76
+
65
77
  private
66
78
 
67
79
  FRAMING = [
@@ -77,8 +89,8 @@ module Guardrails
77
89
  .uniq
78
90
  end
79
91
 
80
- def scan_file(path)
81
- content = File.read(path, encoding: Encoding::UTF_8)
92
+ def scan_file(path, content = nil)
93
+ content ||= File.read(path, encoding: Encoding::UTF_8)
82
94
  lines = content.lines
83
95
  result = ErbParser.parse(content)
84
96
  @current_document = result.document
@@ -123,6 +123,25 @@ module Guardrails
123
123
  end
124
124
  end
125
125
 
126
+ # One template's violations, above the severity floor, without
127
+ # printing. `content` scans a version that isn't on disk (the
128
+ # agent hooks compare a file with its version at HEAD). Doesn't
129
+ # check scan_paths or ignores; ask `covers?` first.
130
+ def scan_source(path, content = nil)
131
+ scan_file(Pathname(path).expand_path(@root), content).select do |v|
132
+ Report::Severity.include?(SEVERITY_FOR_TYPE.fetch(v.type, :warning), @min_severity)
133
+ end
134
+ end
135
+
136
+ # Whether a full run would scan this file: a template under one of
137
+ # the scan paths that isn't ignored.
138
+ def covers?(path)
139
+ path = Pathname(path).expand_path(@root)
140
+ return false unless path.to_s.end_with?(".html.erb")
141
+
142
+ scan_paths.any? { |dir| path.to_s.start_with?("#{@root.join(dir)}/") } && !ignored?(path)
143
+ end
144
+
126
145
  private
127
146
 
128
147
  def load_audit_config
@@ -164,8 +183,8 @@ module Guardrails
164
183
  end
165
184
  end
166
185
 
167
- def scan_file(file)
168
- content = File.read(file, encoding: Encoding::UTF_8)
186
+ def scan_file(file, content = nil)
187
+ content ||= File.read(file, encoding: Encoding::UTF_8)
169
188
  original_lines = content.lines
170
189
  ast_result = ErbParser.parse(content)
171
190
 
@@ -44,8 +44,9 @@ module Guardrails
44
44
  DEFAULT_ADAPTER = :snap_diff
45
45
  DEFAULT_SNAP_DIFF_DIR = "doc/screenshots"
46
46
  DEFAULT_THRESHOLD = 0.0 # any non-zero mismatch fails
47
- KNOWN_ADAPTERS = %i[snap_diff backstop].freeze
48
47
 
48
+ # `backstop_json` is reserved for the BackstopJS adapter and unused
49
+ # until it lands; kept so initializers that already set it still load.
49
50
  attr_accessor :enabled, :adapter, :snap_diff_dir, :backstop_json, :threshold
50
51
 
51
52
  def initialize
@@ -56,16 +57,14 @@ module Guardrails
56
57
  @threshold = DEFAULT_THRESHOLD
57
58
  end
58
59
 
60
+ # Whether the name is a registered adapter is checked when the
61
+ # adapter is built (VisualDiff#adapter), not here: an adapter from
62
+ # another gem may register after this initializer runs.
59
63
  def adapter=(value)
60
64
  raise ArgumentError, "visual_diff.adapter cannot be nil" if value.nil?
61
65
  raise ArgumentError, "visual_diff.adapter cannot be blank" if value.respond_to?(:strip) && value.strip.empty?
62
66
 
63
- sym = value.to_sym
64
- unless KNOWN_ADAPTERS.include?(sym)
65
- raise ArgumentError,
66
- "Unknown visual_diff adapter #{value.inspect}; expected one of #{KNOWN_ADAPTERS.inspect}"
67
- end
68
- @adapter = sym
67
+ @adapter = value.to_sym
69
68
  end
70
69
 
71
70
  def threshold=(value)
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Guardrails
4
+ module Hooks
5
+ # One per coding agent: where its hook config lives, which of its
6
+ # events map to Guardrails' edit / stop / session_start, how to read
7
+ # its hook input, and the exact output shape it expects. The shapes
8
+ # differ enough (Codex rejects unknown keys, Cursor continues a turn
9
+ # with `followup_message`) that each agent gets its own.
10
+ module Adapters
11
+ # Claude Code: `.claude/settings.json`. PostToolUse output reaches
12
+ # the model as `additionalContext`; Stop blocks with
13
+ # `decision: "block"`.
14
+ class Claude
15
+ def name = "claude"
16
+ def config_path = ".claude/settings.json"
17
+ def config_extra = {}
18
+
19
+ def config_entries(command)
20
+ {
21
+ "PostToolUse" => [group(command.call("edit"), matcher: "Edit|Write|MultiEdit", timeout: 30)],
22
+ "Stop" => [group(command.call("stop"), timeout: 60)],
23
+ "SessionStart" => [group(command.call("session_start"), matcher: "startup|resume|clear|compact", timeout: 30)]
24
+ }
25
+ end
26
+
27
+ # Hook commands run in the session's directory, which may not be
28
+ # the app's; Claude Code and Gemini CLI set the project's.
29
+ def command_prefix = %(cd "$CLAUDE_PROJECT_DIR" && )
30
+
31
+ def project_dir(input, env)
32
+ env["CLAUDE_PROJECT_DIR"] || input["cwd"]
33
+ end
34
+
35
+ def files(input, _root)
36
+ [input.dig("tool_input", "file_path")].compact
37
+ end
38
+
39
+ def continued?(input)
40
+ input["stop_hook_active"] == true
41
+ end
42
+
43
+ def output(kind, outcome)
44
+ case kind
45
+ when :stop then outcome.block ? { "decision" => "block", "reason" => outcome.message } : {}
46
+ when :edit then context("PostToolUse", outcome)
47
+ else context("SessionStart", outcome)
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def context(event_name, outcome)
54
+ return {} if outcome.notes.empty?
55
+
56
+ { "hookSpecificOutput" => { "hookEventName" => event_name, "additionalContext" => outcome.message } }
57
+ end
58
+
59
+ def group(command, matcher: nil, timeout: nil)
60
+ hook = { "type" => "command", "command" => command }
61
+ hook["timeout"] = timeout if timeout
62
+ matcher ? { "matcher" => matcher, "hooks" => [hook] } : { "hooks" => [hook] }
63
+ end
64
+ end
65
+
66
+ # Codex CLI: `.codex/hooks.json`, same events and nesting as Claude
67
+ # Code. Every edit is an `apply_patch` call whose input is the raw
68
+ # patch, so file names come from its headers. Codex rejects output
69
+ # keys it doesn't know, and treats empty stdout on Stop as invalid.
70
+ class Codex < Claude
71
+ PATCH_FILE = /^\*\*\* (?:Add File|Update File|Move to): (.+)$/
72
+
73
+ def name = "codex"
74
+ def config_path = ".codex/hooks.json"
75
+
76
+ def config_entries(command)
77
+ super.merge("PostToolUse" => [group(command.call("edit"), matcher: "^apply_patch$", timeout: 30)])
78
+ end
79
+
80
+ # Codex runs hooks in the session's directory; Bundler finds the
81
+ # app's Gemfile from there.
82
+ def command_prefix = ""
83
+
84
+ def project_dir(input, _env)
85
+ input["cwd"]
86
+ end
87
+
88
+ def files(input, _root)
89
+ patch = input.dig("tool_input", "command")
90
+ return [] unless patch.is_a?(String)
91
+
92
+ patch.scan(PATCH_FILE).flatten.map(&:strip).map { |file| File.expand_path(file, input["cwd"] || Dir.pwd) }
93
+ end
94
+ end
95
+
96
+ # Cursor: `.cursor/hooks.json` (version 1). Its after-edit output
97
+ # doesn't reliably reach the model, so Cursor gets the stop and
98
+ # session-start hooks; the stop hook covers every template changed
99
+ # since HEAD. `followup_message` becomes the next user message.
100
+ class Cursor
101
+ def name = "cursor"
102
+ def config_path = ".cursor/hooks.json"
103
+ def config_extra = { "version" => 1 }
104
+ def command_prefix = ""
105
+
106
+ def config_entries(command)
107
+ {
108
+ "stop" => [{ "command" => command.call("stop"), "timeout" => 60 }],
109
+ "sessionStart" => [{ "command" => command.call("session_start"), "timeout" => 30 }]
110
+ }
111
+ end
112
+
113
+ def project_dir(input, env)
114
+ Array(input["workspace_roots"]).first || env["CURSOR_PROJECT_DIR"] || env["CLAUDE_PROJECT_DIR"]
115
+ end
116
+
117
+ def files(input, _root)
118
+ [input["file_path"]].compact
119
+ end
120
+
121
+ # A turn the user aborted, or one a stop hook already continued,
122
+ # isn't sent back again.
123
+ def continued?(input)
124
+ input["loop_count"].to_i.positive? || (input.key?("status") && input["status"] != "completed")
125
+ end
126
+
127
+ def output(kind, outcome)
128
+ case kind
129
+ when :stop then outcome.block ? { "followup_message" => outcome.message } : {}
130
+ else outcome.notes.empty? ? {} : { "additional_context" => outcome.message }
131
+ end
132
+ end
133
+ end
134
+
135
+ # Gemini CLI: `.gemini/settings.json`. AfterTool appends
136
+ # `additionalContext` to the tool result; AfterAgent blocks with
137
+ # `decision: "block"`. Timeouts are in milliseconds.
138
+ class Gemini < Claude
139
+ def name = "gemini"
140
+ def config_path = ".gemini/settings.json"
141
+ def command_prefix = %(cd "$GEMINI_PROJECT_DIR" && )
142
+
143
+ def config_entries(command)
144
+ {
145
+ "AfterTool" => [group(command.call("edit"), matcher: "write_file|replace", timeout: 30_000)],
146
+ "AfterAgent" => [group(command.call("stop"), timeout: 60_000)],
147
+ "SessionStart" => [group(command.call("session_start"), matcher: "startup|resume|clear", timeout: 30_000)]
148
+ }
149
+ end
150
+
151
+ def project_dir(input, env)
152
+ env["GEMINI_PROJECT_DIR"] || input["cwd"]
153
+ end
154
+
155
+ def files(input, _root)
156
+ file = input.dig("tool_input", "file_path")
157
+ file ? [File.expand_path(file, input["cwd"] || Dir.pwd)] : []
158
+ end
159
+
160
+ def output(kind, outcome)
161
+ case kind
162
+ when :stop then outcome.block ? { "decision" => "block", "reason" => outcome.message } : {}
163
+ else outcome.notes.empty? ? {} : { "hookSpecificOutput" => { "additionalContext" => outcome.message } }
164
+ end
165
+ end
166
+
167
+ private
168
+
169
+ def group(command, matcher: nil, timeout: nil)
170
+ super.tap { |entry| entry["hooks"].first["name"] = "guardrails" }
171
+ end
172
+ end
173
+
174
+ ALL = [Claude.new, Codex.new, Cursor.new, Gemini.new].to_h { |adapter| [adapter.name, adapter] }.freeze
175
+
176
+ def self.fetch(name)
177
+ ALL.fetch(name.to_s) do
178
+ raise Error, "Unknown agent #{name.inspect}; expected one of #{ALL.keys.join(", ")}"
179
+ end
180
+ end
181
+
182
+ # Cursor also runs the hooks in `.claude/settings.json`, with
183
+ # Cursor's own input. The input says who's calling.
184
+ def self.cursor_input?(input)
185
+ input.key?("cursor_version") || input.key?("conversation_id")
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+
6
+ module Guardrails
7
+ module Hooks
8
+ # Adds Guardrails' hook commands to an agent's JSON config without
9
+ # touching anything else in it: other settings, and other hooks on
10
+ # the same events, stay as they were. Running it twice changes
11
+ # nothing the second time.
12
+ class ConfigFile
13
+ attr_reader :path
14
+
15
+ def initialize(path)
16
+ @path = Pathname(path)
17
+ end
18
+
19
+ # `entries` maps an event name to the list entries to add under
20
+ # `hooks.<event>` (each a Hash in the agent's own shape). An entry
21
+ # counts as present when one with the same command is there.
22
+ # `extra` merges top-level keys that must be set (Cursor's
23
+ # `version`). Returns true when the file changed.
24
+ def add(entries, extra: {})
25
+ config = read
26
+ before = JSON.generate(config)
27
+ extra.each { |key, value| config[key] = value unless config.key?(key) }
28
+ hooks = (config["hooks"] ||= {})
29
+ raise Error, "#{@path}: `hooks` isn't an object" unless hooks.is_a?(Hash)
30
+
31
+ entries.each do |event, list|
32
+ existing = (hooks[event] ||= [])
33
+ raise Error, "#{@path}: `hooks.#{event}` isn't a list" unless existing.is_a?(Array)
34
+
35
+ list.each { |entry| existing << entry unless commands(existing).include?(command(entry)) }
36
+ end
37
+ return false if JSON.generate(config) == before
38
+
39
+ @path.dirname.mkpath
40
+ @path.write("#{JSON.pretty_generate(config)}\n")
41
+ true
42
+ end
43
+
44
+ private
45
+
46
+ def read
47
+ return {} unless @path.file?
48
+
49
+ text = @path.read
50
+ return {} if text.strip.empty?
51
+
52
+ config = JSON.parse(text)
53
+ raise Error, "#{@path} isn't a JSON object" unless config.is_a?(Hash)
54
+
55
+ config
56
+ rescue JSON::ParserError => e
57
+ raise Error, "#{@path} isn't valid JSON (#{e.message.lines.first.strip}); fix it or add the hooks by hand"
58
+ end
59
+
60
+ # Commands in a list of entries, flat (`{"command": ...}`) or
61
+ # grouped (`{"matcher": ..., "hooks": [{"command": ...}]}`).
62
+ def commands(list)
63
+ list.flat_map { |entry| entry.is_a?(Hash) ? [entry["command"], *Array(entry["hooks"]).map { |h| h["command"] if h.is_a?(Hash) }] : [] }.compact
64
+ end
65
+
66
+ def command(entry)
67
+ entry["command"] || Array(entry["hooks"]).filter_map { |h| h["command"] }.first
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "pathname"
5
+ require "stringio"
6
+ require_relative "../audit"
7
+ require_relative "../a11y_audit"
8
+
9
+ module Guardrails
10
+ module Hooks
11
+ # The drift a coding agent introduced: what the parse-only checks
12
+ # (Audit, A11yAudit) find in a template now, minus what they find in
13
+ # the same file at HEAD. Drift that was already there isn't the
14
+ # agent's to fix unless someone asks, so the hooks never mention it.
15
+ #
16
+ # Lines move as a file is edited, so findings are matched by
17
+ # category and snippet, once per occurrence: a template that had two
18
+ # raw colours at HEAD and has three now has one new one.
19
+ class Drift
20
+ Item = Struct.new(:file, :line, :category, :severity, :title, :suggestion, :snippet, keyword_init: true) do
21
+ def to_s
22
+ text = "#{file}:#{line} #{title}"
23
+ suggestion ? "#{text} (#{suggestion})" : text
24
+ end
25
+
26
+ def error?
27
+ severity == :error
28
+ end
29
+
30
+ def key
31
+ [category, snippet.to_s.strip]
32
+ end
33
+ end
34
+
35
+ def initialize(root:)
36
+ @root = Pathname(root).expand_path
37
+ @audit = Audit.new(root: @root, output: StringIO.new)
38
+ @a11y = A11yAudit.new(root: @root, output: StringIO.new)
39
+ end
40
+
41
+ # New findings in the given files (absolute or relative to root).
42
+ def introduced(files)
43
+ files.filter_map { |file| relative(file) }.uniq.flat_map { |file| introduced_in(file) }
44
+ end
45
+
46
+ # Templates changed since HEAD, or untracked, relative to root.
47
+ # Empty outside a git repository.
48
+ def changed_files
49
+ (git("diff", "--name-only", "--relative", "HEAD") + git("ls-files", "--others", "--exclude-standard"))
50
+ .uniq.select { |file| file.end_with?(".html.erb") && @root.join(file).file? }
51
+ end
52
+
53
+ private
54
+
55
+ def introduced_in(file)
56
+ return [] unless @root.join(file).file?
57
+
58
+ now = findings(file, nil)
59
+ return now if now.empty?
60
+
61
+ before = head_content(file)
62
+ return now if before.nil?
63
+
64
+ remaining = findings(file, before).map(&:key).tally
65
+ now.reject do |item|
66
+ next false unless remaining.fetch(item.key, 0).positive?
67
+
68
+ remaining[item.key] -= 1
69
+ true
70
+ end
71
+ end
72
+
73
+ def findings(file, content)
74
+ categories = []
75
+ categories += @audit.categories(@audit.scan_source(file, content)) if @audit.covers?(file)
76
+ categories += @a11y.categories(@a11y.scan_source(file, content)) if @a11y.covers?(file)
77
+ categories.flat_map(&:findings).map do |finding|
78
+ Item.new(file: file, line: finding.location&.line, category: finding.category, severity: finding.severity,
79
+ title: finding.title, suggestion: finding.suggestion, snippet: finding.snippet)
80
+ end
81
+ end
82
+
83
+ def head_content(file)
84
+ out, status = Open3.capture2("git", "show", "HEAD:./#{file}", chdir: @root.to_s, err: File::NULL)
85
+ status.success? ? out.force_encoding(Encoding::UTF_8) : nil
86
+ rescue SystemCallError
87
+ nil
88
+ end
89
+
90
+ def git(*args)
91
+ out, status = Open3.capture2("git", *args, chdir: @root.to_s, err: File::NULL)
92
+ status.success? ? out.lines.map(&:chomp).reject(&:empty?) : []
93
+ rescue SystemCallError
94
+ []
95
+ end
96
+
97
+ # nil for a file outside the app.
98
+ def relative(file)
99
+ relative = Pathname(file).expand_path(@root).relative_path_from(@root).to_s
100
+ relative unless relative.start_with?("..")
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require_relative "../hooks"
5
+ require_relative "adapters"
6
+ require_relative "config_file"
7
+
8
+ module Guardrails
9
+ module Hooks
10
+ # Writes each agent's hook config for `rake guardrails:hooks:install`,
11
+ # merged into what's there.
12
+ class Installer
13
+ Change = Struct.new(:agent, :path, :changed, keyword_init: true)
14
+
15
+ def initialize(root:, agents:)
16
+ @root = Pathname(root)
17
+ @adapters = agents.map { |agent| Adapters.fetch(agent) }
18
+ end
19
+
20
+ def call
21
+ @adapters.map do |adapter|
22
+ path = @root.join(adapter.config_path)
23
+ command = ->(event) { "#{adapter.command_prefix}#{executable} #{adapter.name} #{event}" }
24
+ changed = ConfigFile.new(path).add(adapter.config_entries(command), extra: adapter.config_extra)
25
+ Change.new(agent: adapter.name, path: path.relative_path_from(@root).to_s, changed: changed)
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ # `bundle exec` when the app's Gemfile has the gem; the bare
32
+ # executable for sidecar installs.
33
+ def executable
34
+ gemfile = @root.join("Gemfile")
35
+ bundled = gemfile.file? && gemfile.read.match?(/^\s*gem\s+["']ui_guardrails["']/)
36
+ bundled ? "bundle exec guardrails-hook" : "guardrails-hook"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../hooks"
5
+ require_relative "adapters"
6
+
7
+ module Guardrails
8
+ module Hooks
9
+ # `guardrails-hook <agent> <event>`: reads the agent's hook input,
10
+ # runs the checks for the event, and answers in the agent's shape.
11
+ #
12
+ # It never breaks the agent's turn. Any failure prints `{}` (every
13
+ # agent accepts it as "nothing to say") with the error on stderr,
14
+ # and exits 0.
15
+ class Runner
16
+ Result = Struct.new(:stdout, :stderr, :exit_code, keyword_init: true)
17
+ EVENTS = { "edit" => :edit, "stop" => :stop, "session_start" => :session_start }.freeze
18
+ QUIET = Result.new(stdout: "{}\n", stderr: "", exit_code: 0).freeze
19
+
20
+ def self.call(**kwargs)
21
+ new(**kwargs).call
22
+ end
23
+
24
+ def initialize(agent:, event:, stdin:, env: ENV)
25
+ @agent = agent
26
+ @event = event
27
+ @stdin = stdin
28
+ @env = env
29
+ end
30
+
31
+ def call
32
+ kind = EVENTS.fetch(@event.to_s) do
33
+ raise Error, "Unknown event #{@event.inspect}; expected #{EVENTS.keys.join(", ")}"
34
+ end
35
+ input = parse(@stdin)
36
+ adapter = adapter_for(input)
37
+ return QUIET unless adapter
38
+
39
+ root = root_for(input, adapter)
40
+ event = Event.new(kind: kind, root: root, files: adapter.files(input, root),
41
+ continued: adapter.continued?(input))
42
+ Hooks.load_contributors
43
+ Result.new(stdout: "#{JSON.generate(adapter.output(kind, Hooks.check(event)))}\n", stderr: "", exit_code: 0)
44
+ rescue StandardError, ScriptError => e
45
+ Result.new(stdout: QUIET.stdout, stderr: "guardrails-hook: #{e.class}: #{e.message}\n", exit_code: 0)
46
+ end
47
+
48
+ private
49
+
50
+ # Strips the byte-order mark Cursor sends on Windows.
51
+ def parse(text)
52
+ text = text.to_s.dup.force_encoding(Encoding::UTF_8).delete_prefix("")
53
+ return {} if text.strip.empty?
54
+
55
+ input = JSON.parse(text)
56
+ input.is_a?(Hash) ? input : {}
57
+ end
58
+
59
+ # Cursor runs `.claude/settings.json` hooks too, with its own
60
+ # input. When the project has Cursor hooks of its own, that call
61
+ # is a duplicate and does nothing; otherwise it's answered as
62
+ # Cursor.
63
+ def adapter_for(input)
64
+ adapter = Adapters.fetch(@agent)
65
+ return adapter unless adapter.name != "cursor" && Adapters.cursor_input?(input)
66
+
67
+ cursor = Adapters.fetch("cursor")
68
+ root = root_for(input, cursor)
69
+ own = Pathname(root).join(cursor.config_path)
70
+ own.file? && own.read.include?("guardrails-hook") ? nil : cursor
71
+ end
72
+
73
+ # The app is where the Gemfile is when run with `bundle exec`;
74
+ # otherwise the agent's project directory.
75
+ def root_for(input, adapter)
76
+ gemfile = @env["BUNDLE_GEMFILE"].to_s
77
+ return Pathname(gemfile).dirname.expand_path unless gemfile.empty?
78
+
79
+ Pathname(adapter.project_dir(input, @env) || Dir.pwd).expand_path
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require_relative "../guardrails"
5
+ require_relative "hooks/drift"
6
+ require_relative "hooks/config_file"
7
+
8
+ module Guardrails
9
+ # Coding-agent hooks (Claude Code, Codex, Cursor, Gemini CLI): drift
10
+ # the agent introduces is reported back to it while it works, and it
11
+ # isn't allowed to end its turn with new errors. Adapters translate
12
+ # each agent's hook input and output; the checks here don't know
13
+ # which agent is asking.
14
+ #
15
+ # - edit: after the agent edits files, the drift it introduced in
16
+ # them, as context for its next step
17
+ # - stop: when it tries to finish, new errors in any template
18
+ # changed since HEAD send it back to work, once
19
+ # - session_start: context for a new session, from contributors
20
+ #
21
+ # Other gems contribute to the same events with `Hooks.register`
22
+ # (ui_guardrails-browser adds visual-review rejections). A gem ships
23
+ # its contributor at `lib/guardrails/hook_contributors/<name>.rb`,
24
+ # which the runner loads, so hooks work without booting the app.
25
+ module Hooks
26
+ # One hook call, whatever the agent. `continued` is true when a
27
+ # stop hook has already sent the agent back once this turn.
28
+ Event = Struct.new(:kind, :root, :files, :continued, keyword_init: true) do
29
+ def files
30
+ self[:files] || []
31
+ end
32
+ end
33
+
34
+ # What to tell the agent. `notes` reach it as context; with
35
+ # `block`, a stop is refused and the notes are the reason.
36
+ Outcome = Struct.new(:notes, :block, keyword_init: true) do
37
+ def self.none
38
+ new(notes: [], block: false)
39
+ end
40
+
41
+ def notes
42
+ self[:notes] || []
43
+ end
44
+
45
+ def message
46
+ notes.join("\n\n")
47
+ end
48
+
49
+ def merge(other)
50
+ return self if other.nil?
51
+
52
+ Outcome.new(notes: notes + other.notes, block: block || other.block)
53
+ end
54
+ end
55
+
56
+ KINDS = %i[edit stop session_start].freeze
57
+
58
+ @contributors = {}
59
+
60
+ class << self
61
+ # A contributor answers any of `on_edit(event)`, `on_stop(event)`
62
+ # and `on_session_start(event)` with an Outcome or nil.
63
+ def register(name, contributor)
64
+ @contributors[name.to_sym] = contributor
65
+ end
66
+
67
+ def unregister(name)
68
+ @contributors.delete(name.to_sym)
69
+ end
70
+
71
+ def contributors
72
+ @contributors.dup
73
+ end
74
+
75
+ # Loads contributors shipped by other gems on the load path. One
76
+ # that fails to load is skipped, with the error on stderr.
77
+ def load_contributors
78
+ Gem.find_files("guardrails/hook_contributors/*.rb").each do |path|
79
+ require path
80
+ rescue StandardError, ScriptError => e
81
+ warn "guardrails-hook: couldn't load #{path}: #{e.class}: #{e.message}"
82
+ end
83
+ end
84
+
85
+ # Guardrails' own checks, then each contributor's. A contributor
86
+ # that raises is skipped, so the rest still reach the agent.
87
+ def check(event)
88
+ outcome = case event.kind
89
+ when :edit then on_edit(event)
90
+ when :stop then on_stop(event)
91
+ else Outcome.none
92
+ end
93
+ method = :"on_#{event.kind}"
94
+ @contributors.reduce(outcome) do |result, (name, contributor)|
95
+ next result unless contributor.respond_to?(method)
96
+
97
+ result.merge(contributor.public_send(method, event))
98
+ rescue StandardError, ScriptError => e
99
+ warn "guardrails-hook: #{name}: #{e.class}: #{e.message}"
100
+ result
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ def on_edit(event)
107
+ items = Drift.new(root: event.root).introduced(event.files)
108
+ return Outcome.none if items.empty?
109
+
110
+ Outcome.new(notes: [describe(items, "Guardrails found drift you just introduced:")], block: false)
111
+ end
112
+
113
+ # Sent back once: a second refusal in the same turn could loop on
114
+ # a finding the agent can't fix.
115
+ def on_stop(event)
116
+ return Outcome.none if event.continued
117
+
118
+ drift = Drift.new(root: event.root)
119
+ items = drift.introduced(drift.changed_files)
120
+ return Outcome.none unless items.any?(&:error?)
121
+
122
+ Outcome.new(notes: [describe(items, "Fix the drift this change introduced before finishing:")], block: true)
123
+ end
124
+
125
+ def describe(items, heading)
126
+ lines = items.sort_by { |item| [item.error? ? 0 : 1, item.file, item.line.to_i] }
127
+ .map { |item| "- [#{item.severity}] #{item}" }
128
+ ([heading] + lines).join("\n")
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "summary"
4
+
5
+ module Guardrails
6
+ module Report
7
+ # Detectors other gems add to `rake guardrails:audit`, run after the
8
+ # built-ins. ui_guardrails-browser registers its heuristic review
9
+ # here.
10
+ #
11
+ # Guardrails::Report::Detectors.register(:heuristics, HeuristicsDetector)
12
+ #
13
+ # A registered detector class answers:
14
+ #
15
+ # .enabled?(env:) whether this audit runs it; env is the
16
+ # audit's environment (ENV or a Hash)
17
+ # .max_severity the most serious severity it reports, so
18
+ # SEVERITY= can skip it instead of running it
19
+ # and discarding everything
20
+ # .new(root:, output:, style:)
21
+ # #run returns its result and prints its own
22
+ # section to output
23
+ # #categories(result) Report::Categories; detail labels unique,
24
+ # values strings
25
+ # #summary_entry(result) a Summary::Entry for the rollup
26
+ # #to_h(result) its FORMAT=json fragment, under
27
+ # `extensions.<name>`
28
+ # #failing?(result) its own exit-code rule
29
+ #
30
+ # Detectors only read what's already on disk. They never capture
31
+ # or call an API: the TUI re-runs every detector on each `r`.
32
+ module Detectors
33
+ CLASS_METHODS = %i[enabled? max_severity].freeze
34
+ INSTANCE_METHODS = %i[run categories summary_entry to_h failing?].freeze
35
+
36
+ @registered = {}
37
+
38
+ class << self
39
+ def register(name, detector_class)
40
+ missing = CLASS_METHODS.reject { |m| detector_class.respond_to?(m) }.map { |m| ".#{m}" } +
41
+ INSTANCE_METHODS.reject { |m| detector_class.method_defined?(m) }.map { |m| "##{m}" }
42
+ unless missing.empty?
43
+ raise ArgumentError, "#{detector_class} can't be registered as a detector; it's missing #{missing.join(", ")}"
44
+ end
45
+ unless Summary::SEVERITY_ORDER.include?(detector_class.max_severity)
46
+ raise ArgumentError, "#{detector_class}.max_severity must be one of #{Summary::SEVERITY_ORDER.inspect}"
47
+ end
48
+
49
+ @registered[name.to_sym] = detector_class
50
+ end
51
+
52
+ def unregister(name)
53
+ @registered.delete(name.to_sym)
54
+ end
55
+
56
+ def registered
57
+ @registered.dup
58
+ end
59
+
60
+ # The registered detectors this environment switches on.
61
+ def enabled(env)
62
+ @registered.select { |_name, detector_class| detector_class.enabled?(env: env) }
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -43,9 +43,19 @@ module Guardrails
43
43
  {
44
44
  category: category, severity: severity, title: title, suggestion: suggestion,
45
45
  locations: locations.map(&:to_h), snippet: snippet,
46
- details: details.to_h, auto_fix: auto_fix || false
46
+ details: details_hash, auto_fix: auto_fix || false
47
47
  }
48
48
  end
49
+
50
+ # `details` allows a label more than once (two screenshots, several
51
+ # regions). A plain `to_h` kept only the last value; a repeated
52
+ # label collects its values into an array instead. Labels that
53
+ # appear once stay scalars, so existing JSON doesn't change.
54
+ def details_hash
55
+ details.each_with_object({}) do |(label, value), hash|
56
+ hash[label] = hash.key?(label) ? Array(hash[label]) << value : value
57
+ end
58
+ end
49
59
  end
50
60
 
51
61
  # A named group of findings plus the framing paragraph that
@@ -14,6 +14,7 @@ require_relative "../visual_diff"
14
14
  require_relative "summary"
15
15
  require_relative "finding"
16
16
  require_relative "severity"
17
+ require_relative "detectors"
17
18
  require_relative "../configuration"
18
19
 
19
20
  module Guardrails
@@ -70,7 +71,8 @@ module Guardrails
70
71
  suggest: truthy?(env["SUGGEST"]), apply: truthy?(env["APPLY"]),
71
72
  axe_json: env["AXE_JSON"], visual_diff: visual_diff_on,
72
73
  similarity: similarity, patterns: patterns, classitis: classitis,
73
- min_severity: Severity.parse(env["SEVERITY"]))
74
+ min_severity: Severity.parse(env["SEVERITY"]),
75
+ extensions: Detectors.enabled(env))
74
76
  end
75
77
 
76
78
  def self.truthy?(value)
@@ -82,11 +84,15 @@ module Guardrails
82
84
  # ANSI decisions against the terminal, or the report body prints
83
85
  # plain while the summary around it is colored. Pass nil to get
84
86
  # uncolored output (what JSON mode wants — the body is discarded).
87
+ #
88
+ # `extensions` maps names to registered detector classes (see
89
+ # Detectors); `from_env` passes the ones the environment enables.
85
90
  def initialize(root:, style: nil, suggest: false, apply: false,
86
91
  axe_json: nil, visual_diff: false,
87
92
  similarity: {}, patterns: {}, classitis: {},
88
- min_severity: Severity::DEFAULT)
93
+ min_severity: Severity::DEFAULT, extensions: {})
89
94
  @min_severity = min_severity
95
+ @extension_classes = extensions
90
96
  @root = Pathname(root)
91
97
  @style = style
92
98
  @suggest = suggest
@@ -121,9 +127,22 @@ module Guardrails
121
127
  @a11y_deep = @a11y_deep_runner ? detect(@a11y_deep_runner) : []
122
128
  @visual_diff_runner = @visual_diff_on ? VisualDiff.new(**common) : nil
123
129
  @visual_diff = @visual_diff_runner ? detect(@visual_diff_runner) : []
130
+ # Registered detectors run last, and only when they can report
131
+ # something at or above the severity floor.
132
+ @extensions = @extension_classes.filter_map do |name, detector_class|
133
+ next unless wanted?(detector_class.max_severity)
134
+
135
+ detector = detector_class.new(**common)
136
+ [name.to_sym, detector, detect(detector)]
137
+ end
124
138
  self
125
139
  end
126
140
 
141
+ # [name, detector, result] for each registered detector that ran.
142
+ def extensions
143
+ @extensions || []
144
+ end
145
+
127
146
  # Severities this run didn't look at (empty by default).
128
147
  def muted_severities
129
148
  Severity.muted(@min_severity)
@@ -168,12 +187,34 @@ module Guardrails
168
187
  unit: "candidates", action: "consider extracting partials"),
169
188
  entry("class-itis", classitis.length, :suggestion,
170
189
  unit: "clusters", action: "consider extracting component / @apply")
171
- ]
190
+ ] + extensions.map { |_name, detector, result| detector.summary_entry(result) }
172
191
  end
173
192
 
174
- # FORMAT=json payload. Shape is a public contract — unchanged
175
- # since 1.0.0.
193
+ # FORMAT=json payload. Shape is a public contract: the keys below
194
+ # are unchanged since 1.0.0, and registered detectors only add an
195
+ # `extensions` key, present when one ran.
176
196
  def to_h
197
+ built_in.tap do |hash|
198
+ hash[:extensions] = extensions.to_h { |name, detector, result| [name, detector.to_h(result)] } if extensions.any?
199
+ end
200
+ end
201
+
202
+ # Exit-code contract: each detector fails on its own rule. Built-in
203
+ # errors and warnings fail on any finding, and so do similar
204
+ # partials; cross-codebase patterns and class-itis never fail.
205
+ # Deep a11y and visual diff fail when a finding crosses their
206
+ # threshold. Registered detectors decide for themselves.
207
+ def failing?
208
+ violations.any? || stimulus.violations? || similarity.any? ||
209
+ view_components.violations? || a11y.any? ||
210
+ (@a11y_deep_runner&.any_failing?(a11y_deep) || false) ||
211
+ (@visual_diff_runner&.any_failing?(visual_diff) || false) ||
212
+ extensions.any? { |_name, detector, result| detector.failing?(result) }
213
+ end
214
+
215
+ private
216
+
217
+ def built_in
177
218
  {
178
219
  summary: {
179
220
  violations: violations.length,
@@ -207,19 +248,6 @@ module Guardrails
207
248
  }
208
249
  end
209
250
 
210
- # Exit-code contract: any error or warning fails; the suggestion-
211
- # tier pattern / class-itis detectors don't. Deep a11y and visual
212
- # diff only fail when a finding crosses their configured
213
- # threshold, and are no-ops when not opted into.
214
- def failing?
215
- violations.any? || stimulus.violations? || similarity.any? ||
216
- view_components.violations? || a11y.any? ||
217
- (@a11y_deep_runner&.any_failing?(a11y_deep) || false) ||
218
- (@visual_diff_runner&.any_failing?(visual_diff) || false)
219
- end
220
-
221
- private
222
-
223
251
  # Runs a detector and remembers the pair, so `categories` can ask
224
252
  # each detector to normalize its own result later — only
225
253
  # front-ends that want findings-as-data pay for building them.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Guardrails
4
- VERSION = "1.5.0"
4
+ VERSION = "1.7.0"
5
5
  end
@@ -14,7 +14,9 @@ module Guardrails
14
14
  #
15
15
  # Adapter selection comes from `Guardrails.configuration.visual_diff.adapter`
16
16
  # (default `:snap_diff`, the Rails-native baselines-in-git workflow).
17
- # BackstopJS support tracked in issue #15.
17
+ # Other gems add adapters with `VisualDiff.register_adapter`; the
18
+ # ui_guardrails-browser gem registers `:browser`. BackstopJS support
19
+ # tracked in issue #15.
18
20
  class VisualDiff
19
21
  # Normalized across every adapter. Fields adapter-specific shapes
20
22
  # don't supply are nil — consumers should not assume presence.
@@ -30,6 +32,10 @@ module Guardrails
30
32
  :diff_path, # relative path to the diff image, nil when no diff exists
31
33
  :url, # optional — set by adapters whose scenarios carry URLs (BackstopJS)
32
34
  :selector, # optional — same
35
+ :status, # optional — adapter's review state, e.g. "changed", "new", "rejected"
36
+ :comment, # optional — a reviewer's note on the change
37
+ :regions, # optional — changed areas, [{ x:, y:, w:, h: }, ...]
38
+ :extra_details, # optional — more [label, value] pairs for the report
33
39
  keyword_init: true
34
40
  ) do
35
41
  def to_h
@@ -37,6 +43,34 @@ module Guardrails
37
43
  end
38
44
  end
39
45
 
46
+ @adapters = {}
47
+
48
+ class << self
49
+ # Adds an adapter under `name`. The block builds it:
50
+ #
51
+ # Guardrails::VisualDiff.register_adapter(:browser) do |root:, config:|
52
+ # MyAdapter.new(root: root)
53
+ # end
54
+ #
55
+ # `config` is `Guardrails.configuration.visual_diff`. The adapter's
56
+ # `#collect` returns `VisualDiff::Finding`s; a finding with
57
+ # `mismatch_ratio: nil` always fails, one with a ratio fails above
58
+ # the threshold.
59
+ def register_adapter(name, &factory)
60
+ raise ArgumentError, "register_adapter needs a block that builds the adapter" unless factory
61
+
62
+ @adapters[name.to_sym] = factory
63
+ end
64
+
65
+ def adapters
66
+ @adapters.keys
67
+ end
68
+
69
+ def adapter_factory(name)
70
+ @adapters[name]
71
+ end
72
+ end
73
+
40
74
  def initialize(root:, output: $stdout,
41
75
  adapter: nil, threshold: nil, style: nil)
42
76
  @root = Pathname(root)
@@ -82,8 +116,7 @@ module Guardrails
82
116
  title: "#{ratio_label(f)} #{f.scenario}#{f.viewport ? " (#{f.viewport})" : ""}",
83
117
  suggestion: SUGGESTION,
84
118
  locations: [f.diff_path, f.baseline_path].compact.map { |path| Report::Location.new(file: path) },
85
- details: [["baseline", f.baseline_path], ["diff", f.diff_path], ["url", f.url], ["selector", f.selector]]
86
- .reject { |_, v| v.nil? }
119
+ details: details_for(f)
87
120
  )
88
121
  }
89
122
  )]
@@ -100,19 +133,27 @@ module Guardrails
100
133
  SUGGESTION = "compare baseline ↔ diff; accept the new baseline or fix the regression"
101
134
 
102
135
  def ratio_label(finding)
103
- finding.mismatch_ratio.nil? ? "[diff present]" : "[#{(finding.mismatch_ratio * 100).round(2)}% mismatch]"
136
+ return "[#{(finding.mismatch_ratio * 100).round(2)}% mismatch]" unless finding.mismatch_ratio.nil?
137
+
138
+ finding.status ? "[#{finding.status}]" : "[diff present]"
139
+ end
140
+
141
+ def details_for(finding)
142
+ regions = finding.regions&.map { |r| "#{r[:x] || r["x"]},#{r[:y] || r["y"]} #{r[:w] || r["w"]}x#{r[:h] || r["h"]}" }
143
+ [["baseline", finding.baseline_path], ["current", finding.current_path], ["diff", finding.diff_path],
144
+ ["url", finding.url], ["selector", finding.selector], ["status", finding.status],
145
+ ["comment", finding.comment], ["regions", regions&.join("; ")]]
146
+ .reject { |_, v| v.nil? || v == "" } + Array(finding.extra_details)
104
147
  end
105
148
 
106
149
  def adapter
107
- case @adapter_name
108
- when :snap_diff
109
- SnapDiff.new(
110
- root: @root,
111
- dir: Guardrails.configuration.visual_diff.snap_diff_dir
112
- )
113
- else
114
- raise ArgumentError, "Unknown visual_diff adapter: #{@adapter_name.inspect}"
150
+ factory = self.class.adapter_factory(@adapter_name)
151
+ unless factory
152
+ raise ArgumentError,
153
+ "Unknown visual_diff adapter: #{@adapter_name.inspect}; registered: #{self.class.adapters.inspect}"
115
154
  end
155
+
156
+ factory.call(root: @root, config: Guardrails.configuration.visual_diff)
116
157
  end
117
158
 
118
159
  def coerce_adapter(value)
@@ -149,9 +190,14 @@ module Guardrails
149
190
  @output.puts " #{@style.location("diff: #{f.diff_path}")}" if f.diff_path
150
191
  @output.puts " #{@style.location("url: #{f.url}")}" if f.url
151
192
  @output.puts " #{@style.location("selector: #{f.selector}")}" if f.selector
193
+ @output.puts " #{@style.location("comment: #{f.comment}")}" if f.comment
152
194
  end
153
195
  end
154
196
  end
155
197
  end
156
198
 
157
199
  require_relative "visual_diff/snap_diff"
200
+
201
+ Guardrails::VisualDiff.register_adapter(:snap_diff) do |root:, config:|
202
+ Guardrails::VisualDiff::SnapDiff.new(root: root, dir: config.snap_diff_dir)
203
+ end
@@ -134,4 +134,27 @@ namespace :guardrails do
134
134
  root = defined?(Rails) ? Rails.root : Pathname(Dir.pwd)
135
135
  Guardrails::Tokens.new(root: root).run
136
136
  end
137
+
138
+ namespace :hooks do
139
+ desc "Add Guardrails to coding agents' hooks (AGENT=claude,codex,cursor,gemini or all; default claude)"
140
+ task :install do
141
+ require "guardrails/hooks/installer"
142
+ root = defined?(Rails) ? Rails.root : Pathname(Dir.pwd)
143
+ agents = ENV.fetch("AGENT", "claude").split(",").map(&:strip).reject(&:empty?)
144
+ agents = Guardrails::Hooks::Adapters::ALL.keys if agents == ["all"]
145
+
146
+ begin
147
+ changes = Guardrails::Hooks::Installer.new(root: root, agents: agents).call
148
+ rescue Guardrails::Error => e
149
+ abort e.message
150
+ end
151
+
152
+ changes.each do |change|
153
+ puts "#{change.changed ? "Added hooks to" : "Already set up:"} #{change.path} (#{change.agent})"
154
+ end
155
+ agents = changes.map(&:agent)
156
+ puts "Codex runs project hooks once you trust them in /hooks." if agents.include?("codex")
157
+ puts "Cursor and Gemini CLI run project hooks in trusted folders only." if agents.intersect?(%w[cursor gemini])
158
+ end
159
+ end
137
160
  end
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ui_guardrails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - John Athayde
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
@@ -69,7 +69,8 @@ description: 'Opinionated auditing and enforcement for design-system consistency
69
69
  component inventory, icon sprites, type scale, and color token management.'
70
70
  email:
71
71
  - jmpa@meticulous.com
72
- executables: []
72
+ executables:
73
+ - guardrails-hook
73
74
  extensions: []
74
75
  extra_rdoc_files: []
75
76
  files:
@@ -83,6 +84,7 @@ files:
83
84
  - doc/ROADMAP.md
84
85
  - doc/UPSTREAM-snap_diff-issue-draft.md
85
86
  - doc/VISUAL-DIFF.md
87
+ - exe/guardrails-hook
86
88
  - lib/guardrails.rb
87
89
  - lib/guardrails/a11y_audit.rb
88
90
  - lib/guardrails/a11y_deep.rb
@@ -94,6 +96,12 @@ files:
94
96
  - lib/guardrails/cross_codebase_patterns.rb
95
97
  - lib/guardrails/erb_parser.rb
96
98
  - lib/guardrails/hex_normalizer.rb
99
+ - lib/guardrails/hooks.rb
100
+ - lib/guardrails/hooks/adapters.rb
101
+ - lib/guardrails/hooks/config_file.rb
102
+ - lib/guardrails/hooks/drift.rb
103
+ - lib/guardrails/hooks/installer.rb
104
+ - lib/guardrails/hooks/runner.rb
97
105
  - lib/guardrails/icons.rb
98
106
  - lib/guardrails/icons/emoji_scan.rb
99
107
  - lib/guardrails/init.rb
@@ -106,6 +114,7 @@ files:
106
114
  - lib/guardrails/lookbook/views/lookbook_panels/_guardrails.html.erb
107
115
  - lib/guardrails/partial_similarity.rb
108
116
  - lib/guardrails/railtie.rb
117
+ - lib/guardrails/report/detectors.rb
109
118
  - lib/guardrails/report/finding.rb
110
119
  - lib/guardrails/report/html.rb
111
120
  - lib/guardrails/report/html/template.html.erb