ui_guardrails 1.6.0 → 1.7.1
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 +4 -4
- data/README.md +47 -0
- data/doc/ROADMAP.md +2 -0
- data/exe/guardrails-hook +13 -0
- data/lib/guardrails/a11y_audit.rb +14 -2
- data/lib/guardrails/audit.rb +21 -2
- data/lib/guardrails/hooks/adapters.rb +189 -0
- data/lib/guardrails/hooks/config_file.rb +97 -0
- data/lib/guardrails/hooks/drift.rb +104 -0
- data/lib/guardrails/hooks/installer.rb +43 -0
- data/lib/guardrails/hooks/runner.rb +83 -0
- data/lib/guardrails/hooks.rb +132 -0
- data/lib/guardrails/report/detectors.rb +67 -0
- data/lib/guardrails/report/run.rb +46 -18
- data/lib/guardrails/version.rb +1 -1
- data/lib/tasks/guardrails.rake +23 -0
- metadata +12 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 32e83a5c20961feae0830c519f4bf0b3363bcd4774d4644bab49053ce02c8cd5
|
|
4
|
+
data.tar.gz: 8fbaaa46298c2ea2805b2b291354d36cf521fd6f11f948290d62e2796bbafb24
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 88fe2925e7787c6b0a63861d142f9d53e5e832395c7b22e7515400954f9a16f57dab73c2fa318b3df516ee5fa12af3e104604bd63125a08eb403eb3ed93f083d
|
|
7
|
+
data.tar.gz: 9ed0ec91a1310da006bf525c05163cec82e730c17838d22ba6e5c2ed2ab7607241541769dda5c60f9a7e2681b587bc5747d2116ddbef156e952bd6f079d8cbae
|
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. The installed command ends in `|| echo '{}'`, so the same holds when the hook can't start at all. That happens on a branch whose Gemfile doesn't have the gem, since the agent's config usually isn't tracked by git and outlives the branch. Running the install again replaces an earlier install's hooks instead of adding new ones beside them. 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
|
@@ -133,6 +133,8 @@ The planned roadmap ended at V2. Running the gem on real codebases (Patchvault:
|
|
|
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
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. |
|
|
136
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. |
|
|
137
139
|
|
|
138
140
|
---
|
data/exe/guardrails-hook
ADDED
|
@@ -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
|
|
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
|
data/lib/guardrails/audit.rb
CHANGED
|
@@ -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
|
|
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
|
|
|
@@ -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,97 @@
|
|
|
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; an
|
|
22
|
+
# older form of the same Guardrails hook is replaced.
|
|
23
|
+
# `extra` merges top-level keys that must be set (Cursor's
|
|
24
|
+
# `version`). Returns true when the file changed.
|
|
25
|
+
def add(entries, extra: {})
|
|
26
|
+
config = read
|
|
27
|
+
before = JSON.generate(config)
|
|
28
|
+
extra.each { |key, value| config[key] = value unless config.key?(key) }
|
|
29
|
+
hooks = (config["hooks"] ||= {})
|
|
30
|
+
raise Error, "#{@path}: `hooks` isn't an object" unless hooks.is_a?(Hash)
|
|
31
|
+
|
|
32
|
+
entries.each do |event, list|
|
|
33
|
+
existing = (hooks[event] ||= [])
|
|
34
|
+
raise Error, "#{@path}: `hooks.#{event}` isn't a list" unless existing.is_a?(Array)
|
|
35
|
+
|
|
36
|
+
list.each do |entry|
|
|
37
|
+
wanted = command(entry)
|
|
38
|
+
next if commands(existing).include?(wanted)
|
|
39
|
+
|
|
40
|
+
identity = wanted.to_s[OURS]
|
|
41
|
+
remove(existing, identity) if identity
|
|
42
|
+
existing << entry
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
return false if JSON.generate(config) == before
|
|
46
|
+
|
|
47
|
+
@path.dirname.mkpath
|
|
48
|
+
@path.write("#{JSON.pretty_generate(config)}\n")
|
|
49
|
+
true
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# An earlier install of the same hook, whatever its exact command:
|
|
55
|
+
# reinstalling replaces it rather than adding a second.
|
|
56
|
+
OURS = /guardrails-hook \w+ \w+/
|
|
57
|
+
|
|
58
|
+
# Takes out the hook with this identity and any group it leaves
|
|
59
|
+
# empty; the user's own hooks, in the same group or not, stay.
|
|
60
|
+
def remove(list, identity)
|
|
61
|
+
list.reject! do |entry|
|
|
62
|
+
next false unless entry.is_a?(Hash)
|
|
63
|
+
next true if entry["command"].to_s.include?(identity)
|
|
64
|
+
next false unless entry["hooks"].is_a?(Array)
|
|
65
|
+
|
|
66
|
+
before = entry["hooks"].size
|
|
67
|
+
entry["hooks"].reject! { |hook| hook.is_a?(Hash) && hook["command"].to_s.include?(identity) }
|
|
68
|
+
entry["hooks"].empty? && before.positive?
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def read
|
|
73
|
+
return {} unless @path.file?
|
|
74
|
+
|
|
75
|
+
text = @path.read
|
|
76
|
+
return {} if text.strip.empty?
|
|
77
|
+
|
|
78
|
+
config = JSON.parse(text)
|
|
79
|
+
raise Error, "#{@path} isn't a JSON object" unless config.is_a?(Hash)
|
|
80
|
+
|
|
81
|
+
config
|
|
82
|
+
rescue JSON::ParserError => e
|
|
83
|
+
raise Error, "#{@path} isn't valid JSON (#{e.message.lines.first.strip}); fix it or add the hooks by hand"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Commands in a list of entries, flat (`{"command": ...}`) or
|
|
87
|
+
# grouped (`{"matcher": ..., "hooks": [{"command": ...}]}`).
|
|
88
|
+
def commands(list)
|
|
89
|
+
list.flat_map { |entry| entry.is_a?(Hash) ? [entry["command"], *Array(entry["hooks"]).map { |h| h["command"] if h.is_a?(Hash) }] : [] }.compact
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def command(entry)
|
|
93
|
+
entry["command"] || Array(entry["hooks"]).filter_map { |h| h["command"] }.first
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
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,43 @@
|
|
|
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
|
+
# Falls back to `{}`, the answer every agent reads as "nothing to
|
|
24
|
+
# say", when the hook can't start at all: a branch without the
|
|
25
|
+
# gem in its Gemfile, or a missing project directory.
|
|
26
|
+
command = ->(event) { "(#{adapter.command_prefix}#{executable} #{adapter.name} #{event}) || echo '{}'" }
|
|
27
|
+
changed = ConfigFile.new(path).add(adapter.config_entries(command), extra: adapter.config_extra)
|
|
28
|
+
Change.new(agent: adapter.name, path: path.relative_path_from(@root).to_s, changed: changed)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# `bundle exec` when the app's Gemfile has the gem; the bare
|
|
35
|
+
# executable for sidecar installs.
|
|
36
|
+
def executable
|
|
37
|
+
gemfile = @root.join("Gemfile")
|
|
38
|
+
bundled = gemfile.file? && gemfile.read.match?(/^\s*gem\s+["']ui_guardrails["']/)
|
|
39
|
+
bundled ? "bundle exec guardrails-hook" : "guardrails-hook"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
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
|
|
@@ -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
|
|
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.
|
data/lib/guardrails/version.rb
CHANGED
data/lib/tasks/guardrails.rake
CHANGED
|
@@ -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.
|
|
4
|
+
version: 1.7.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- John Athayde
|
|
8
|
-
bindir:
|
|
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
|