SLOPSHOPPER

wcag-enforcer

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…

newguardtoaststatusprocess
A shopper browsing a rack in a slop shop
README

guardrail-mods

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.

ModWhat it doesEvent it hooks
wcag-enforcerAfter 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 ruletool.call (after Write, Edit and Bash run), classic.Stop
design-guardrailBefore 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 changedtool.call (before Write and Edit, around Bash)
qa-gateWhen 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 passtool.call, classic.Stop
secret-scrubberRedacts 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 linesession.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.

Install

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).

What each mod needs in your project

ModNeeds
wcag-enforcerFor 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-guardrailA 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-gateA 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-scrubberNothing.

Configure

Every mod has two layers of settings, and the second wins:

  1. Plugin options, set in /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, ...).
  2. A project config file, .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"] }
ModOptions (/config)Extra keys in the project file
wcag-enforcerenabled, configFile, extensions, eslintCommand, blockStopUntilFixedeslint, eslintRulePrefixes, axe, nodeCommand, contrast, watchBash, timeoutMs
design-guardrailenabled, configFile, tokensFile, extensions, ignore, allowallowSvgColors, blockBashWrites, watchBash, maxReported
qa-gateenabled, configFile, packageJson, packageManager, maxAttempts, scope, timeoutSecondschecks, scripts, codeExtensions, testArgs, watchBash
secret-scrubberenabled, configFile, patternsFile, showToastdisabledRules, patterns

Demos

wcag-enforcer

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.

design-guardrail

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.

qa-gate

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.

secret-scrubber

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.

How they fail

  • 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).

Develop

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.

License

MIT

Source 5 files
hooks/register.ts 244 lines
1import 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}
244
hooks/bash-writes.ts 172 lines
1// 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}
172
hooks/config.ts 53 lines
1// 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}
53
hooks/contrast.ts 178 lines
1// 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}
178
hooks/findings.ts 96 lines
1// 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