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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a0bae61006af0fcdb91d543ab37539ed5f01bc653faa3a1732b56ee4f450d075
4
- data.tar.gz: ee1b11b2668ee764377741beeac44b50e9507029c1469b5db866f1aa1e61ac93
3
+ metadata.gz: 32e83a5c20961feae0830c519f4bf0b3363bcd4774d4644bab49053ce02c8cd5
4
+ data.tar.gz: 8fbaaa46298c2ea2805b2b291354d36cf521fd6f11f948290d62e2796bbafb24
5
5
  SHA512:
6
- metadata.gz: fe558c786b8286a5f5766c265e1b40b581ef139d05a4b989744ee6d98e1b2dc5d885fc9c1470853d393b23d9c10d99be70c5a1cd7127da6afab653aa0bee21ee
7
- data.tar.gz: 89651a0654dcd990829f37e402c9a7b8d214573acf655c54c7460a3708cf7d6d1c8e4f34bdae064fb8b676faa0d02d96fa624d94c8bec492cedc86e967973e86
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
  ---
@@ -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
 
@@ -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 — 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.6.0"
4
+ VERSION = "1.7.1"
5
5
  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.6.0
4
+ version: 1.7.1
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