After each Write, Edit or shell command that changes a .tsx, .jsx, .html, .vue or .css file, runs eslint-plugin-jsx-a11y, axe-core and a WCAG 2.2 AA contrast…

Four Claude Code mods that hold Claude to your project's rules while it works. Each mod is its own plugin under mods/, written in TypeScript, and the repository is a plugin marketplace named guardrail-mods.
| Mod | What it does | Event it hooks |
|---|---|---|
wcag-enforcer | After a Write, Edit or shell command changes a .tsx, .jsx, .html, .vue or .css file, runs automated WCAG checks (eslint-plugin-jsx-a11y, axe-core, and a WCAG 2.2 AA text contrast check) and hands Claude each failure with file, line and rule | tool.call (after Write, Edit and Bash run), classic.Stop |
design-guardrail | Before a Write or Edit to a UI file, blocks hardcoded hex/rgb/hsl colors and px spacing and font values, naming the design token to use; refuses shell commands that write UI files and checks what other commands changed | tool.call (before Write and Edit, around Bash) |
qa-gate | When Claude tries to finish a turn after changing code (by any tool, the shell included), runs the lint, type check, spell check and test scripts in package.json and keeps Claude working until they pass | tool.call, classic.Stop |
secret-scrubber | Redacts API keys, tokens, passwords, .env values and private keys from tool output, subagent output and @-mentioned files before Claude reads them, with a running count in the status line | session.append (tool results), prompt.attachment, prompt.mention, tool.call |
Built and type-checked against the declarations of Claude Code 2.1.296, using only events and APIs that build declares. Mods need Claude Code 2.1.287 or later; qa-gate's package manager and scope pickers need 2.1.271 or later.
What wcag-enforcer is, and isn't. It runs automated WCAG checks on the code Claude writes. That is not a WCAG compliance audit: static checks like these catch only part of WCAG (often put at around a third of issues), and nothing here tests keyboard operation, focus order, screen reader output or how content reads in context. Keep testing those by hand and with assistive technology.
Mods are code that runs with your permissions. Read them first: claude plugin validate mods/<mod> lists every event a mod hooks and every call it makes.
In a Claude Code session in your terminal, add the marketplace once and install the mods you want:
/plugin marketplace add WasteOfADrumBum/one-small-mods-pack
/plugin install wcag-enforcer@guardrail-mods
/plugin install design-guardrail@guardrail-mods
/plugin install qa-gate@guardrail-mods
/plugin install secret-scrubber@guardrail-mods
Or install one mod in a single line, answering y to add the marketplace and then picking a scope:
/plugin install secret-scrubber --marketplace WasteOfADrumBum/one-small-mods-pack
From your shell instead: claude plugin marketplace add WasteOfADrumBum/one-small-mods-pack, then claude plugin install <mod>@guardrail-mods, then /reload-plugins in any open session.
To try a mod from a clone without installing it, start Claude Code with claude --plugin-dir ./mods/<mod> (repeat the flag for several).
| Mod | Needs |
|---|---|
wcag-enforcer | For components: ESLint with eslint-plugin-jsx-a11y (or eslint-plugin-vuejs-accessibility for .vue) turned on in your ESLint config. For HTML: npm i -D axe-core jsdom. The contrast check needs nothing. A check whose tool is missing is skipped, and the status line keeps saying so (♿ eslint not running) for as long as it is, so it never reads as a pass. |
design-guardrail | A tokens file, design-tokens.json by default. Without one it stands aside, and the status line says so (🎨 design-guardrail idle: no tokens file at design-tokens.json). |
qa-gate | A package.json with any of the scripts it looks for. The status line names the checks that have no script (✅ qa-gate: 2/2 passed · no spellcheck, test script). |
secret-scrubber | Nothing. |
Every mod has two layers of settings, and the second wins:
/config or when you install, stored under pluginConfigs in your settings: enabled (on/off), configFile (the path of layer 2), and each mod's own path options (tokensFile, packageJson, patternsFile, ...)..claude/guardrail-mods/<mod>.json by default, checked in with the project so the whole team gets the same rules. It takes every option plus the lists. Each mod's config.example.json shows every key.// .claude/guardrail-mods/design-guardrail.json
{ "tokensFile": "src/styles/tokens.css", "ignore": ["**/*.stories.tsx"] }
| Mod | Options (/config) | Extra keys in the project file |
|---|---|---|
wcag-enforcer | enabled, configFile, extensions, eslintCommand, blockStopUntilFixed | eslint, eslintRulePrefixes, axe, nodeCommand, contrast, watchBash, timeoutMs |
design-guardrail | enabled, configFile, tokensFile, extensions, ignore, allow | allowSvgColors, blockBashWrites, watchBash, maxReported |
qa-gate | enabled, configFile, packageJson, packageManager, maxAttempts, scope, timeoutSeconds | checks, scripts, codeExtensions, testArgs, watchBash |
secret-scrubber | enabled, configFile, patternsFile, showToast | disabledRules, patterns |
Claude writes Card.tsx with and 
note.css with .note { color: #aaaaaa; background: #ffffff; }. Each tool result comes back with a note Claude reads before its next step:
wcag-enforcer: 1 accessibility failure (WCAG 2.2 AA) in src/Card.tsx. Fix every one before moving on:
- src/Card.tsx:3:5 [jsx-a11y/alt-text] img elements must have an alt prop, either with meaningful text, or an empty string for decorative images.
wcag-enforcer: 1 accessibility failure (WCAG 2.2 AA) in note.css. Fix every one before moving on:
- note.css:1 [wcag/contrast-minimum] text #aaaaaa on #ffffff has contrast 2.32:1; WCAG 2.2 AA (1.4.3) needs 4.5:1
The status line shows ♿ 2 WCAG failures in 2 files until they're fixed. If Claude tries to finish with failures still open, the mod sends it back with the list (twice at most, then it lets the turn end with a toast). In a test run, Claude changed the color to #767676 (4.54:1) on its own.
Files changed through the shell are checked too: the ones a command names as written (redirects, tee, sed -i, cp/mv targets, curl -o) and, in a git repository, any UI file whose change time moved while the command ran, so a code generator's output is covered. Set watchBash to false to skip that.
HTML files go through axe-core in jsdom (mods/wcag-enforcer/scripts/axe-check.mjs), reported as [axe/image-alt], [axe/html-has-lang] and so on, with line numbers. jsdom does no layout, so axe's own contrast rule is off and the mod's contrast check covers it: literal color pairs in CSS rules, style="" attributes and JSX style objects, with var(--x) resolved when the file defines --x, and 3:1 for large text.
With this design-tokens.json:
{ "color": { "brand": { "$value": "#3366ff" } }, "space": { "3": "12px" } }
Claude tries to write .card { color: #3366ff; padding: 13px; }. The Write is refused, and Claude reads:
design-guardrail: this Write to src/card.css hardcodes 2 values that should be design tokens (from design-tokens.json):
- line 1: `#3366ff` → use `color.brand` (`var(--color-brand)`).
- line 1: padding `13px` is not a token value → use the nearest token, `space.3` (`var(--space-3)`) = 12px.
Replace each with the token named and retry. A value that truly must stay can be kept with a `guardrail-ignore` comment on its line.
It retries with .card { color: var(--color-brand); padding: var(--space-3); }. The tokens file can be JSON (nested, W3C $value or Style Dictionary value leaves) or a CSS, SCSS or Less file of variables. It catches hex, rgb(), hsl(), px in spacing and font properties, React style numbers (marginTop: 12) and Tailwind arbitrary values (p-[10px], text-[#f00]). An Edit is judged only on the values it adds, so legacy values already in a file don't block unrelated edits.
What it leaves alone, each covered by a test: borders and other properties outside spacing and type (border: 1px solid ..., box-shadow, width: 24px), 0 and 0px, media query breakpoints (@media (min-width: 768px)), SVG attributes (width="24", stroke-width, and paint colors like fill="#111827" unless allowSvgColors is false), var() references and their fallbacks, token definitions themselves, and any line marked guardrail-ignore. Add values to allow (such as 1px) to let them through everywhere.
The shell can't sidestep it. A command that writes a UI file directly (echo ... > a.css, tee, sed -i, perl -pi, curl -o) is refused with a note to use Write or Edit. Any other command that changes a UI file (a generator script, say) runs, and in a git repository the values it added since the last commit come back to Claude as a note to fix with Edit. blockBashWrites and watchBash turn those off.
Claude edits src/a.ts and tries to finish. The gate runs npm run --silent lint, typecheck, spellcheck and test (whichever exist; npm's placeholder test is skipped) with CI=true, and when one fails Claude reads:
qa-gate: 1 check failed after your code changes. Fix the errors below, then finish:
### typecheck (`npm run --silent typecheck` exited 2)
src/a.ts(3,14): error TS2322: Type 'string' is not assignable to type 'number'.
The status line tracks it: 🧪 qa-gate: running typecheck…, then ❌ qa-gate: typecheck failed or ✅ qa-gate: 3/3 passed. A turn that changed no code is never held, and code changed through the shell counts (what a command names as written, and in a git repository what changed while it ran). After maxAttempts holds in a row (3 by default) it lets the turn end with a toast. pnpm, yarn and bun are picked from the lockfile.
For bigger repositories:
timeoutSeconds (300 by default) stops a script that runs too long and tells Claude it timed out.scope: "changed" passes only the files Claude changed to the lint and spell check scripts (npm run --silent lint -- src/a.ts). Tests get them too once you set testArgs to your runner's flag, such as ["--findRelatedTests"] for Jest; otherwise the full suite runs. The type check always runs on the whole project, since it can't be judged file by file.Claude reads .env.local (with Read, cat, or an @.env.local mention in your prompt), then runs a script that prints a token. What Claude reads is:
PORT=[REDACTED:env-value]
DATABASE_URL=[REDACTED:env-value]
Using token [REDACTED:github-token] for api.github.com
The status line keeps a running count (🔒 3 secrets redacted) and a toast says what was caught. It covers private key blocks, JWTs, GitHub, Google (API keys, OAuth tokens, client secrets), AWS (key IDs and secret keys), Vercel, Todoist, Slack, Stripe, Anthropic and OpenAI keys, Bearer tokens, credentials in URLs, every value of a .env file Claude reads, and generic password=, api_key:, "secret": "..." pairs whose value looks like a secret (code like token = getToken() is left alone). Add your own with patterns.
Where it scrubs: every tool result before it's stored (in the main conversation and in every subagent's, so a subagent never reads the secret and its report back to Claude is scrubbed too), and every attachment as a request carries it, which covers files you @-mention, files changed outside Claude that it's shown, and settings hook output. Your terminal still shows the real output; only what Claude reads is scrubbed. If the scan itself fails, the output is withheld from Claude rather than passed through.
design-guardrail fails open: if the guard itself errors, the edit goes ahead.secret-scrubber fails closed: if scrubbing errors, Claude gets a placeholder instead of the output.wcag-enforcer and qa-gate never hold a turn forever (two and maxAttempts holds in a row).npm run types # copy your Claude Code build's type declarations into types/ (see below)
npm run typecheck # tsc against them
npm run validate # claude plugin validate on the marketplace and each mod
npm test # claude plugin test in each mod
The type declarations (claude-code.d.ts) are Anthropic's and are not redistributed here (Claude Code's license reserves all rights), so types/ is git-ignored. Claude Code writes them for your build beside a mod it loads from a folder it watches: start claude --plugin-dir mods/qa-gate once, then run npm run types, which copies them from mods/*/.claude-plugin/types/ into types/claude-code.d.ts. You can also pass a path: node scripts/link-types.mjs /path/to/claude-code.d.ts.
Each mod's tests live in mods/<mod>/tests/ and use claude-code/testing: they raise the events the mod hooks and stub every file, process and display call beneath it, covering pass and fail cases. claude plugin validate and claude plugin test need no types.
hooks/register.ts 244 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3import { bashWrites, changedFiles, porcelainPaths } from './bash-writes'
4import { asBool, asList, asNumber, asString, parseConfigText, pick } from './config'
5import type { RawConfig } from './config'
6import { checkContrast } from './contrast'
7import { contrastFindings, hasA11yRules, parseAxe, parseEslint, report } from './findings'
8import type { Finding, ToolFailure } from './findings'
9
10type Settings = {
11 enabled: boolean
12 extensions: string[]
13 eslint: boolean
14 eslintCommand: string[]
15 eslintRulePrefixes: string[]
16 axe: boolean
17 nodeCommand: string
18 contrast: boolean
19 blockStop: boolean
20 watchBash: boolean
21 timeoutMs: number
22}
23
24const COMPONENT = /\.(tsx|jsx|vue)$/i
25const HTML = /\.html?$/i
26
27/** Reads a JSON config file of the project; a missing or broken one is empty. */
28async function readConfigFile($: EngineInterface, path: string): Promise<RawConfig> {
29 if (path.trim() === '' || !(await $.fs.exists(path))) return {}
30 const parsed = parseConfigText(path, await $.fs.read(path))
31 if (typeof parsed === 'string') {
32 $.ui.log(parsed)
33 return {}
34 }
35 return parsed
36}
37
38async function load($: EngineInterface, options: PluginOptions): Promise<Settings> {
39 const configFile = asString(options.configFile) ?? '.claude/guardrail-mods/wcag-enforcer.json'
40 const file = await readConfigFile($, configFile)
41 return {
42 enabled: pick(file, options, 'enabled', true, asBool),
43 extensions: pick(file, options, 'extensions', ['.tsx', '.jsx', '.html', '.vue', '.css'], asList).map(x => x.toLowerCase()),
44 eslint: pick(file, options, 'eslint', true, asBool),
45 eslintCommand: pick(file, options, 'eslintCommand', 'npx --no-install eslint', asString).split(/\s+/).filter(Boolean),
46 eslintRulePrefixes: pick(file, options, 'eslintRulePrefixes', ['jsx-a11y/', 'vuejs-accessibility/'], asList),
47 axe: pick(file, options, 'axe', true, asBool),
48 nodeCommand: pick(file, options, 'nodeCommand', 'node', asString),
49 contrast: pick(file, options, 'contrast', true, asBool),
50 blockStop: pick(file, options, 'blockStopUntilFixed', true, asBool),
51 watchBash: pick(file, options, 'watchBash', true, asBool),
52 timeoutMs: pick(file, options, 'timeoutMs', 60_000, asNumber),
53 }
54}
55
56// Each tool's problem is said once a session, not after every edit.
57const warned = new Set<string>()
58// Whether the project's ESLint config turns on accessibility rules, by command.
59const eslintReady = new Map<string, boolean>()
60
61// The checks that could not run, by tool, with why: shown in the status
62// line for as long as it holds, so a skipped check never reads as a pass.
63const skipped = new Map<ToolFailure['tool'], string>()
64
65function warnOnce($: EngineInterface, failure: ToolFailure) {
66 skipped.set(failure.tool, failure.reason)
67 if (warned.has(failure.tool)) return
68 warned.add(failure.tool)
69 $.ui.toast(`wcag-enforcer: skipping ${failure.tool} checks: ${failure.reason}`)
70}
71
72async function runEslint($: EngineInterface, settings: Settings, file: string): Promise<Finding[]> {
73 const [command = 'npx', ...args] = settings.eslintCommand
74 const key = settings.eslintCommand.join(' ')
75 if (!eslintReady.has(key)) {
76 const printed = await $.process.run([command, ...args, '--print-config', file], { timeoutMs: settings.timeoutMs })
77 const ready = printed.exitCode === 0 && hasA11yRules(printed.stdout, settings.eslintRulePrefixes)
78 eslintReady.set(key, ready)
79 if (!ready) {
80 warnOnce($, {
81 tool: 'eslint',
82 reason:
83 printed.exitCode === 0
84 ? `the project's ESLint config turns on no ${settings.eslintRulePrefixes.join(' or ')} rules (add eslint-plugin-jsx-a11y)`
85 : `ESLint did not run (${printed.stderr.trim().split('\n')[0] ?? `exit ${printed.exitCode}`})`,
86 })
87 }
88 }
89 if (eslintReady.get(key) !== true) return []
90
91 const ran = await $.process.run([command, ...args, '--format', 'json', file], { timeoutMs: settings.timeoutMs })
92 const parsed = parseEslint(ran.stdout, settings.eslintRulePrefixes)
93 if (!Array.isArray(parsed)) {
94 warnOnce($, parsed)
95 return []
96 }
97 skipped.delete('eslint')
98 return parsed
99}
100
101async function runAxe($: EngineInterface, settings: Settings, file: string): Promise<Finding[]> {
102 const ran = await $.process.run([settings.nodeCommand, `${$.plugin.root}/scripts/axe-check.mjs`, file], {
103 timeoutMs: settings.timeoutMs,
104 })
105 const parsed = parseAxe(ran.stdout)
106 if (!Array.isArray(parsed)) {
107 warnOnce($, parsed)
108 return []
109 }
110 skipped.delete('axe')
111 return parsed
112}
113
114/** Every accessibility failure the checks find in the file as it is now. */
115async function check($: EngineInterface, settings: Settings, file: string): Promise<Finding[]> {
116 const findings: Finding[] = []
117 if (settings.eslint && COMPONENT.test(file)) {
118 try {
119 findings.push(...(await runEslint($, settings, file)))
120 } catch (error) {
121 warnOnce($, { tool: 'eslint', reason: `ESLint could not start (${error instanceof Error ? error.message : String(error)})` })
122 }
123 }
124 if (settings.axe && HTML.test(file)) {
125 try {
126 findings.push(...(await runAxe($, settings, file)))
127 } catch (error) {
128 warnOnce($, { tool: 'axe', reason: `the axe check could not start (${error instanceof Error ? error.message : String(error)})` })
129 }
130 }
131 if (settings.contrast) findings.push(...contrastFindings(checkContrast(await $.fs.read(file))))
132 return findings
133}
134
135const shortName = (file: string) => file.split(/[\\/]/).at(-1) ?? file
136
137// The failures each file had at its last check; a file is dropped once clean.
138const outstanding = new Map<string, Finding[]>()
139let stopBlocks = 0
140
141function showStatus($: EngineInterface) {
142 const count = [...outstanding.values()].reduce((sum, list) => sum + list.length, 0)
143 const files = outstanding.size
144 const parts: string[] = []
145 if (count > 0) parts.push(`${count} WCAG failure${count === 1 ? '' : 's'} in ${files} file${files === 1 ? '' : 's'}`)
146 if (skipped.size > 0) parts.push(`${[...skipped.keys()].join(' and ')} not running`)
147 $.ui.status(parts.length === 0 ? undefined : `♿ ${parts.join(' · ')}`)
148}
149
150/** Checks a file, records what it found, and returns the note for Claude when something failed. */
151async function checkFile($: EngineInterface, settings: Settings, file: string): Promise<string | undefined> {
152 const findings = await check($, settings, file)
153 if (findings.length === 0) outstanding.delete(file)
154 else {
155 outstanding.set(file, findings)
156 stopBlocks = 0
157 }
158 showStatus($)
159 if (findings.length === 0) return undefined
160 const notRun = [...skipped].map(([tool, reason]) => `(${tool} did not run: ${reason}.)`)
161 return [report(file, findings), ...notRun].join('\n')
162}
163
164// The repository root, once found; null outside a git repository.
165let gitRoot: string | null | undefined
166
167/** Files with uncommitted changes that pass `keep`, by absolute path, stamped with mtime and size; undefined outside git. */
168async function dirtyFiles($: EngineInterface, keep: (path: string) => boolean): Promise<Map<string, string> | undefined> {
169 if (gitRoot === undefined) {
170 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { timeoutMs: 10_000 }).catch(() => undefined)
171 gitRoot = top !== undefined && top.exitCode === 0 ? top.stdout.trim() : null
172 }
173 if (gitRoot === null) return undefined
174 const status = await $.process.run(['git', 'status', '--porcelain', '-z', '--untracked-files=all'], { timeoutMs: 10_000 }).catch(() => undefined)
175 if (status === undefined || status.exitCode !== 0) return undefined
176 const files = new Map<string, string>()
177 for (const rel of porcelainPaths(status.stdout).filter(keep).slice(0, 500)) {
178 const path = `${gitRoot}/${rel}`
179 const stat = await $.fs.stat(path).catch(() => undefined)
180 if (stat !== undefined && stat.kind === 'file') files.set(path, `${stat.mtimeMs}:${stat.size}`)
181 }
182 return files
183}
184
185export const register: Register = (on, options) => {
186 on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
187 const ran = await next(e)
188 if (e.tool !== 'Write' && e.tool !== 'Edit') return ran
189 if (ran.deny !== undefined || ran.isError === true) return ran
190
191 const settings = await load($, options)
192 const lower = e.file_path.toLowerCase()
193 if (!settings.enabled || !settings.extensions.some(ext => lower.endsWith(ext))) return ran
194
195 const note = await checkFile($, settings, e.file_path)
196 return note === undefined ? ran : { ...ran, context: [...(ran.context ?? []), note] }
197 })
198
199 // A UI file written through the shell skips the Write/Edit hook above:
200 // check what the command names as written, and what git shows it changed.
201 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
202 const settings = await load($, options)
203 if (!settings.enabled || !settings.watchBash) return next(e)
204 const checked = (path: string) => settings.extensions.some(ext => path.toLowerCase().endsWith(ext))
205
206 const before = await dirtyFiles($, checked)
207 const ran = await next(e)
208 if (ran.deny !== undefined) return ran
209 const after = before === undefined ? undefined : await dirtyFiles($, checked)
210
211 const files = new Set(before !== undefined && after !== undefined ? changedFiles(before, after) : [])
212 for (const write of bashWrites(e.command)) {
213 if (!checked(write.path) || !(await $.fs.exists(write.path))) continue
214 const stat = await $.fs.stat(write.path, { resolve: true }).catch(() => undefined)
215 files.add(stat?.realPath ?? write.path)
216 }
217
218 const notes: string[] = []
219 for (const file of files) {
220 const note = await checkFile($, settings, file)
221 if (note !== undefined) notes.push(note)
222 }
223 return notes.length === 0 ? ran : { ...ran, context: [...(ran.context ?? []), ...notes] }
224 })
225
226 on('classic.Stop', async ($, e, next) => {
227 const settings = await load($, options)
228 if (!settings.enabled || !settings.blockStop || outstanding.size === 0) return next(e)
229 // Never hold the turn forever: two reminders, then let it end.
230 if (stopBlocks >= 2) {
231 $.ui.toast(`wcag-enforcer: ending the turn with accessibility failures left in ${[...outstanding.keys()].map(shortName).join(', ')}`)
232 stopBlocks = 0
233 return next(e)
234 }
235 stopBlocks += 1
236 return {
237 block: [
238 'wcag-enforcer: these accessibility failures are still open. Fix them, then finish:',
239 ...[...outstanding.entries()].map(([file, findings]) => report(file, findings, 10)),
240 ].join('\n\n'),
241 }
242 })
243}
244hooks/bash-writes.ts 172 lines1// The files a shell command writes, read from the command itself: redirects,
2// tee, in-place sed/perl, cp/mv/install destinations, dd of=, curl -o and
3// wget -O. A best-effort reading (a script the command runs can write
4// anything), so each mod also compares the working tree before and after.
5// Shared by the guardrail mods; each plugin carries its own copy.
6
7export type BashWrite = {
8 path: string
9 /** How the command writes it: new text (`redirect`, `tee`, `in-place`, `download`) or a copy (`copy`). */
10 via: 'redirect' | 'tee' | 'in-place' | 'copy' | 'download'
11}
12
13/** Splits a command into words, honoring quotes and backslashes; operators are their own words. */
14export function shellWords(command: string): string[] {
15 const words: string[] = []
16 let word = ''
17 let hasWord = false
18 let quote: '"' | "'" | undefined
19 const push = () => {
20 if (hasWord) words.push(word)
21 word = ''
22 hasWord = false
23 }
24 for (let i = 0; i < command.length; i++) {
25 const c = command[i]!
26 if (quote !== undefined) {
27 if (c === quote) quote = undefined
28 else if (c === '\\' && quote === '"' && i + 1 < command.length) word += command[++i]
29 else word += c
30 continue
31 }
32 if (c === '"' || c === "'") {
33 quote = c
34 hasWord = true
35 } else if (c === '\\' && i + 1 < command.length) {
36 word += command[++i]
37 hasWord = true
38 } else if (/\s/.test(c)) {
39 push()
40 if (c === '\n') words.push(';')
41 } else if (c === ';' || c === '|' || c === '&' || c === '(' || c === ')') {
42 push()
43 const pair = c + (command[i + 1] ?? '')
44 if (pair === '&&' || pair === '||' || pair === '>|') {
45 words.push(pair)
46 i++
47 } else if (c === '&' && command[i + 1] === '>') {
48 words.push('&>')
49 i++
50 } else words.push(c)
51 } else if (c === '>' || c === '<') {
52 // A leading fd number (2>) belongs to the operator, not the word before it.
53 const fd = /^\d$/.test(word) ? word : ''
54 if (fd === '') push()
55 else {
56 word = ''
57 hasWord = false
58 }
59 let op = fd + c
60 while (command[i + 1] === '>' || command[i + 1] === '&' || (c === '<' && command[i + 1] === '<')) op += command[++i]
61 words.push(op)
62 } else {
63 word += c
64 hasWord = true
65 }
66 }
67 push()
68 return words
69}
70
71const SEPARATORS = new Set([';', '&&', '||', '|', '&', '(', ')'])
72const NOT_A_FILE = /^(\/dev\/|&\d|-$)/
73
74const isRedirect = (word: string) => /^(\d|&)?>{1,2}\|?$/.test(word)
75
76/** The files the command writes, by how it writes them. */
77export function bashWrites(command: string): BashWrite[] {
78 // A heredoc's body is data, not shell: keep its opening line, drop the body.
79 const shell = command.replace(/(<<-?\s*(['"]?)([\w-]+)\2[^\n]*)\n[\s\S]*?\n\s*\3\s*(?=\n|$)/g, '$1')
80 const words = shellWords(shell)
81 const writes: BashWrite[] = []
82 const add = (path: string | undefined, via: BashWrite['via']) => {
83 if (path !== undefined && path !== '' && !NOT_A_FILE.test(path) && !SEPARATORS.has(path) && !isRedirect(path)) writes.push({ path, via })
84 }
85
86 // Redirects anywhere in the command
87 words.forEach((word, i) => {
88 if (isRedirect(word)) add(words[i + 1], 'redirect')
89 })
90
91 // Each simple command: its program and arguments, redirects left out
92 const segments: string[][] = [[]]
93 for (let i = 0; i < words.length; i++) {
94 const word = words[i]!
95 if (SEPARATORS.has(word)) segments.push([])
96 else if (isRedirect(word) || /^\d?<{1,3}$/.test(word)) i++
97 else segments.at(-1)!.push(word)
98 }
99
100 for (const segment of segments) {
101 // Skip env assignments and wrappers: FOO=1 sudo sed -i ...
102 let start = 0
103 while (start < segment.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(segment[start]!) || ['sudo', 'command', 'env', 'xargs', 'time', 'nohup'].includes(segment[start]!))) start++
104 const [program = '', ...args] = segment.slice(start)
105 const name = program.split('/').at(-1) ?? program
106 const operands = args.filter(a => !a.startsWith('-'))
107
108 if (name === 'tee') {
109 for (const file of operands) add(file, 'tee')
110 } else if (name === 'sed' || name === 'gsed') {
111 if (args.some(a => /^-[a-zA-Z]*i/.test(a) || a.startsWith('--in-place'))) {
112 // The first operand is the script, unless one was given with -e/-f.
113 const hasScriptOption = args.some(a => a === '-e' || a === '-f' || a.startsWith('--expression') || a.startsWith('--file'))
114 const scriptArgs = new Set<string>()
115 args.forEach((a, i) => {
116 if ((a === '-e' || a === '-f') && args[i + 1] !== undefined) scriptArgs.add(args[i + 1]!)
117 })
118 // macOS spells no backup suffix as a separate '' after -i
119 const files = operands.filter(a => a !== '' && !scriptArgs.has(a))
120 for (const file of hasScriptOption ? files : files.slice(1)) add(file, 'in-place')
121 }
122 } else if (name === 'perl') {
123 if (args.some(a => /^-[a-zA-Z]*i/.test(a))) {
124 const scriptArgs = new Set<string>()
125 args.forEach((a, i) => {
126 if (/^-[a-zA-Z]*e$/.test(a) && args[i + 1] !== undefined) scriptArgs.add(args[i + 1]!)
127 })
128 for (const file of operands.filter(a => !scriptArgs.has(a))) add(file, 'in-place')
129 }
130 } else if (name === 'cp' || name === 'mv' || name === 'install' || name === 'rsync') {
131 const target = args.findIndex(a => a === '-t' || a === '--target-directory')
132 add(target >= 0 ? args[target + 1] : operands.at(-1), 'copy')
133 } else if (name === 'dd') {
134 for (const a of args) if (a.startsWith('of=')) add(a.slice(3), 'redirect')
135 } else if (name === 'curl' || name === 'wget') {
136 args.forEach((a, i) => {
137 if ((name === 'curl' && (a === '-o' || a === '--output')) || (name === 'wget' && (a === '-O' || a === '--output-document'))) {
138 add(args[i + 1], 'download')
139 }
140 })
141 }
142 }
143
144 const seen = new Set<string>()
145 return writes.filter(w => {
146 const key = `${w.via}:${w.path}`
147 if (seen.has(key)) return false
148 seen.add(key)
149 return true
150 })
151}
152
153/** Parses `git status --porcelain -z`: the paths with changes or untracked, relative to the repository root. */
154export function porcelainPaths(output: string): string[] {
155 const entries = output.split('\0')
156 const paths: string[] = []
157 for (let i = 0; i < entries.length; i++) {
158 const entry = entries[i]!
159 if (entry.length < 4) continue
160 const status = entry.slice(0, 2)
161 paths.push(entry.slice(3))
162 // A rename or copy is followed by its old path, which is gone.
163 if (status.includes('R') || status.includes('C')) i++
164 }
165 return paths
166}
167
168/** The files whose change time moved between two snapshots, or that only the second has. */
169export function changedFiles(before: ReadonlyMap<string, string>, after: ReadonlyMap<string, string>): string[] {
170 return [...after].filter(([path, mtime]) => before.get(path) !== mtime).map(([path]) => path)
171}
172hooks/config.ts 53 lines1// Shared by every guardrail mod (each plugin carries its own copy, since a
2// plugin may only import its own files). Settings are layered:
3// 1. the mod's built-in defaults,
4// 2. the plugin's userConfig options (/config, or pluginConfigs in settings),
5// 3. the project's JSON config file, when it exists.
6// Reading the file needs `$`, which never crosses an import, so each
7// register.ts reads it and hands the text to `parseConfigText`.
8import type { PluginOptions } from 'claude-code'
9
10export type RawConfig = Record<string, unknown>
11
12const isRecord = (value: unknown): value is RawConfig =>
13 typeof value === 'object' && value !== null && !Array.isArray(value)
14
15/** Parses a config file's text: an object, or an error message to log. */
16export function parseConfigText(path: string, text: string): RawConfig | string {
17 try {
18 const parsed: unknown = JSON.parse(text)
19 return isRecord(parsed) ? parsed : `${path} is not a JSON object; using the defaults`
20 } catch (error) {
21 return `could not parse ${path}: ${error instanceof Error ? error.message : String(error)}`
22 }
23}
24
25/** The first defined value among the file's, the option's and the default. */
26export function pick<T>(
27 file: RawConfig,
28 options: PluginOptions,
29 key: string,
30 fallback: T,
31 read: (value: unknown) => T | undefined,
32): T {
33 return read(file[key]) ?? read(options[key]) ?? fallback
34}
35
36export const asBool = (value: unknown): boolean | undefined =>
37 typeof value === 'boolean' ? value : value === 'true' ? true : value === 'false' ? false : undefined
38
39export const asString = (value: unknown): string | undefined =>
40 typeof value === 'string' ? value : undefined
41
42export const asNumber = (value: unknown): number | undefined =>
43 typeof value === 'number' && Number.isFinite(value) ? value : undefined
44
45/** A list from a JSON array, a `multiple` option, or a comma-separated string. */
46export const asList = (value: unknown): string[] | undefined => {
47 if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string')
48 if (typeof value === 'string' && value.trim() !== '') {
49 return value.split(',').map(item => item.trim()).filter(item => item !== '')
50 }
51 return undefined
52}
53hooks/contrast.ts 178 lines1// A WCAG 2.2 AA text contrast check (success criterion 1.4.3) over the
2// color pairs a file sets literally: CSS rule blocks, style="" attributes and
3// JSX style objects. A pair needs both a text color and a background in the
4// same block; values the file defines as custom properties are resolved.
5
6export type ContrastIssue = {
7 line: number
8 foreground: string
9 background: string
10 ratio: number
11 required: number
12 isLargeText: boolean
13}
14
15type Rgba = [number, number, number, number]
16
17const NAMED: Record<string, Rgba> = {
18 white: [255, 255, 255, 1],
19 black: [0, 0, 0, 1],
20 red: [255, 0, 0, 1],
21 green: [0, 128, 0, 1],
22 blue: [0, 0, 255, 1],
23 gray: [128, 128, 128, 1],
24 grey: [128, 128, 128, 1],
25 silver: [192, 192, 192, 1],
26 yellow: [255, 255, 0, 1],
27 orange: [255, 165, 0, 1],
28}
29
30function hslToRgb(h: number, s: number, l: number): [number, number, number] {
31 const a = s * Math.min(l, 1 - l)
32 const f = (n: number) => {
33 const k = (n + h / 30) % 12
34 return 255 * (l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)))
35 }
36 return [f(0), f(8), f(4)]
37}
38
39export function parseColor(value: string): Rgba | undefined {
40 const text = value.trim().toLowerCase()
41 if (NAMED[text] !== undefined) return NAMED[text]
42 const hex = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(text)?.[1]
43 if (hex !== undefined) {
44 const full = hex.length <= 4 ? [...hex].map(d => d + d).join('') : hex
45 const n = (i: number) => parseInt(full.slice(i, i + 2), 16)
46 return [n(0), n(2), n(4), full.length === 8 ? n(6) / 255 : 1]
47 }
48 const fn = /^(rgba?|hsla?)\(([^)]*)\)$/.exec(text)
49 if (fn?.[1] === undefined || fn[2] === undefined) return undefined
50 const parts = fn[2].split(/[\s,/]+/).filter(p => p !== '')
51 if (parts.length < 3) return undefined
52 const num = (p: string, scale: number) => (p.endsWith('%') ? (parseFloat(p) / 100) * scale : parseFloat(p))
53 const alpha = parts[3] === undefined ? 1 : num(parts[3], 1)
54 const rgb = fn[1].startsWith('rgb')
55 ? ([num(parts[0]!, 255), num(parts[1]!, 255), num(parts[2]!, 255)] as const)
56 : hslToRgb(parseFloat(parts[0]!), num(parts[1]!, 1), num(parts[2]!, 1))
57 const out: Rgba = [rgb[0], rgb[1], rgb[2], alpha]
58 return out.some(Number.isNaN) ? undefined : out
59}
60
61/** Lays a translucent color over an opaque one. */
62const over = (top: Rgba, bottom: Rgba): Rgba => [
63 top[0] * top[3] + bottom[0] * (1 - top[3]),
64 top[1] * top[3] + bottom[1] * (1 - top[3]),
65 top[2] * top[3] + bottom[2] * (1 - top[3]),
66 1,
67]
68
69const luminance = ([r, g, b]: Rgba) => {
70 const lin = (c: number) => {
71 const s = c / 255
72 return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
73 }
74 return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
75}
76
77/** The WCAG contrast ratio of a text color on a background (a translucent background sits on white). */
78export function contrastRatio(foreground: Rgba, background: Rgba): number {
79 const bg = background[3] < 1 ? over(background, NAMED.white!) : background
80 const fg = foreground[3] < 1 ? over(foreground, bg) : foreground
81 const [hi, lo] = [luminance(fg), luminance(bg)].sort((a, b) => b - a) as [number, number]
82 return (hi + 0.05) / (lo + 0.05)
83}
84
85type Declaration = { property: string; value: string; line: number }
86
87const lineAt = (text: string, index: number) => text.slice(0, index).split('\n').length
88
89const camelToKebab = (name: string) => name.replace(/[A-Z]/g, c => '-' + c.toLowerCase())
90
91/** Custom properties the file defines (`--name: value`), for resolving `var(--name)`. */
92function customProperties(text: string): Map<string, string> {
93 const vars = new Map<string, string>()
94 for (const m of text.matchAll(/(--[\w-]+)\s*:\s*([^;}{\n]+)/g)) vars.set(m[1]!, m[2]!.trim())
95 return vars
96}
97
98function resolve(value: string, vars: Map<string, string>, depth = 0): string {
99 const ref = /^var\(\s*(--[\w-]+)\s*(?:,\s*([^)]*))?\)$/.exec(value.trim())
100 if (ref === null || depth > 5) return value.trim()
101 const defined = vars.get(ref[1]!)
102 return defined !== undefined ? resolve(defined, vars, depth + 1) : (ref[2] ?? value).trim()
103}
104
105/** The blocks of declarations in a file: CSS rule bodies, style="" attributes, JSX style objects. */
106function blocks(text: string): Declaration[][] {
107 const found: Declaration[][] = []
108
109 // CSS rule bodies, in stylesheets and in <style> blocks: `selector { ... }`
110 for (const m of text.matchAll(/\{([^{}]*)\}/g)) {
111 const body = m[1]!
112 const start = m.index! + 1
113 const decls: Declaration[] = []
114 for (const d of body.matchAll(/([a-zA-Z-]+)\s*:\s*([^;]+?)\s*(?:;|$)/g)) {
115 decls.push({ property: d[1]!.toLowerCase(), value: d[2]!, line: lineAt(text, start + d.index!) })
116 }
117 // A JSX style object `{{ color: '#777', backgroundColor: '#fff' }}` is a block too.
118 for (const d of body.matchAll(/([a-zA-Z]+)\s*:\s*(['"`])([^'"`]+)\2/g)) {
119 decls.push({ property: camelToKebab(d[1]!), value: d[3]!, line: lineAt(text, start + d.index!) })
120 }
121 if (decls.length > 0) found.push(decls)
122 }
123
124 // style="color: #777; background: #fff"
125 for (const m of text.matchAll(/style\s*=\s*(["'])([^"']*)\1/g)) {
126 const start = m.index! + m[0].indexOf(m[2]!)
127 const decls: Declaration[] = []
128 for (const d of m[2]!.matchAll(/([a-zA-Z-]+)\s*:\s*([^;]+?)\s*(?:;|$)/g)) {
129 decls.push({ property: d[1]!.toLowerCase(), value: d[2]!, line: lineAt(text, start + d.index!) })
130 }
131 if (decls.length > 0) found.push(decls)
132 }
133 return found
134}
135
136const fontSizePx = (value: string | undefined): number | undefined => {
137 if (value === undefined) return undefined
138 const m = /^(\d*\.?\d+)(px|rem|em|pt)?$/.exec(value.trim())
139 if (m === null) return undefined
140 const n = parseFloat(m[1]!)
141 return m[2] === 'rem' || m[2] === 'em' ? n * 16 : m[2] === 'pt' ? (n * 4) / 3 : n
142}
143
144/** Every text/background pair below the WCAG 2.2 AA ratio: 4.5:1, or 3:1 for large text. */
145export function checkContrast(text: string): ContrastIssue[] {
146 const vars = customProperties(text)
147 const issues: ContrastIssue[] = []
148 const seen = new Set<string>()
149
150 for (const decls of blocks(text)) {
151 const last = (props: string[]) => [...decls].reverse().find(d => props.includes(d.property))
152 const fgDecl = last(['color'])
153 const bgDecl = last(['background-color', 'background'])
154 if (fgDecl === undefined || bgDecl === undefined) continue
155
156 const fgValue = resolve(fgDecl.value, vars)
157 // `background` may be a shorthand: take its color when it is one word.
158 const bgValue = resolve(bgDecl.value.split(/\s+(?![^(]*\))/).find(part => parseColor(resolve(part, vars)) !== undefined) ?? bgDecl.value, vars)
159 const fg = parseColor(fgValue)
160 const bg = parseColor(bgValue)
161 if (fg === undefined || bg === undefined) continue
162
163 const size = fontSizePx(last(['font-size'])?.value)
164 const weight = last(['font-weight'])?.value.trim()
165 const isBold = weight === 'bold' || weight === 'bolder' || (weight !== undefined && parseInt(weight, 10) >= 700)
166 const isLargeText = size !== undefined && (size >= 24 || (isBold && size >= 18.66))
167 const required = isLargeText ? 3 : 4.5
168 const ratio = Math.floor(contrastRatio(fg, bg) * 100) / 100
169 if (ratio >= required) continue
170
171 const key = `${fgDecl.line}:${fgValue}:${bgValue}`
172 if (seen.has(key)) continue
173 seen.add(key)
174 issues.push({ line: fgDecl.line, foreground: fgValue, background: bgValue, ratio, required, isLargeText })
175 }
176 return issues.sort((a, b) => a.line - b.line)
177}
178hooks/findings.ts 96 lines1// The checks' outputs, read into one shape and written up for Claude.
2import type { ContrastIssue } from './contrast'
3
4export type Finding = {
5 line: number | undefined
6 column?: number
7 /** The rule that failed: `jsx-a11y/alt-text`, `axe/image-alt`, `wcag/contrast-minimum`. */
8 rule: string
9 message: string
10}
11
12export type ToolFailure = { tool: 'eslint' | 'axe'; reason: string }
13
14type EslintMessage = { ruleId?: string | null; line?: number; column?: number; message?: string; severity?: number }
15type EslintResult = { filePath?: string; messages?: EslintMessage[] }
16
17/** Reads `eslint --format json` output, keeping the rules under the accessibility prefixes. */
18export function parseEslint(stdout: string, prefixes: readonly string[]): Finding[] | ToolFailure {
19 let results: unknown
20 try {
21 results = JSON.parse(stdout)
22 } catch {
23 return { tool: 'eslint', reason: 'eslint did not print JSON (is ESLint installed in this project?)' }
24 }
25 if (!Array.isArray(results)) return { tool: 'eslint', reason: 'unexpected ESLint output' }
26 return (results as EslintResult[]).flatMap(result =>
27 (result.messages ?? [])
28 .filter(m => typeof m.ruleId === 'string' && prefixes.some(p => m.ruleId!.startsWith(p)))
29 .map(m => ({ line: m.line, column: m.column, rule: m.ruleId!, message: m.message ?? '' })),
30 )
31}
32
33/** Whether the project's resolved ESLint config (`eslint --print-config`) turns on any accessibility rule. */
34export function hasA11yRules(printedConfig: string, prefixes: readonly string[]): boolean {
35 try {
36 const config = JSON.parse(printedConfig) as { rules?: Record<string, unknown> }
37 return Object.entries(config.rules ?? {}).some(([rule, setting]) => {
38 const level = Array.isArray(setting) ? setting[0] : setting
39 return prefixes.some(p => rule.startsWith(p)) && level !== 'off' && level !== 0
40 })
41 } catch {
42 return false
43 }
44}
45
46type AxeNode = { rule?: string; impact?: string; help?: string; helpUrl?: string; line?: number; target?: string }
47
48/** Reads what scripts/axe-check.mjs printed. */
49export function parseAxe(stdout: string): Finding[] | ToolFailure {
50 let parsed: { violations?: AxeNode[]; error?: string; message?: string }
51 try {
52 parsed = JSON.parse(stdout.trim().split('\n').at(-1) ?? '')
53 } catch {
54 return { tool: 'axe', reason: 'the axe check did not print JSON' }
55 }
56 if (parsed.error !== undefined) {
57 return {
58 tool: 'axe',
59 reason:
60 parsed.error === 'missing-deps'
61 ? 'axe-core and jsdom are not installed in this project (npm i -D axe-core jsdom)'
62 : `the axe check failed: ${parsed.message ?? parsed.error}`,
63 }
64 }
65 return (parsed.violations ?? []).map(v => ({
66 line: v.line,
67 rule: `axe/${v.rule ?? 'unknown'}`,
68 message: `${v.help ?? ''}${v.target ? ` (at ${v.target})` : ''}${v.impact ? ` [${v.impact}]` : ''}`.trim(),
69 }))
70}
71
72export function contrastFindings(issues: readonly ContrastIssue[]): Finding[] {
73 return issues.map(issue => ({
74 line: issue.line,
75 rule: 'wcag/contrast-minimum',
76 message:
77 `text ${issue.foreground} on ${issue.background} has contrast ${issue.ratio}:1; ` +
78 `WCAG 2.2 AA (1.4.3) needs ${issue.required}:1${issue.isLargeText ? ' for large text' : ''}`,
79 }))
80}
81
82/** The note Claude reads after its edit: one line per failure, file, line and rule. */
83export function report(file: string, findings: readonly Finding[], limit = 30): string {
84 const sorted = [...findings].sort((a, b) => (a.line ?? 0) - (b.line ?? 0))
85 const lines = sorted.slice(0, limit).map(f => {
86 const where = f.line === undefined ? file : `${file}:${f.line}${f.column === undefined ? '' : `:${f.column}`}`
87 return `- ${where} [${f.rule}] ${f.message}`
88 })
89 const more = sorted.length - lines.length
90 return [
91 `wcag-enforcer: ${findings.length} accessibility failure${findings.length === 1 ? '' : 's'} (WCAG 2.2 AA) in ${file}. Fix every one before moving on:`,
92 ...lines,
93 ...(more > 0 ? [`- …and ${more} more.`] : []),
94 ].join('\n')
95}
96