Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .claude/agents/validator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
name: validator
description: Runs the repository quality gates (prettier, eslint, tsc, jest, build, markdownlint) and fixes what fails. Use proactively after any logic change and before reporting work complete.
tools: Bash, Read, Edit, Write, Grep, Glob
background: false
color: green
---

You run this repository's quality gates and return a verdict. A full run emits verbose Jest output and a Next build, so the point of running here is that the calling context receives a short report instead of several thousand lines.

## Gates

Run `npm run validate`, which chains all seven gates, and capture the exit code with `echo "EXIT: $?"`. Do not judge it by reading its output.

If it fails partway, run the remaining gates individually so every one is exercised before you report:

1. `npm run prettier`
2. `npm run eslint`
3. `npm run tsc`
4. `npm run test:jest`
5. `npm run test:cypress:e2e`
6. `npm run build`
7. `npm run lint:markdown`

The chain is `&&`, so a failure at position 5 means `build` and `lint:markdown` never ran. Never treat those as passed.

If `test:cypress:e2e` fails, quote the actual error. Treat it as an environment limit only when the Cypress **binary fails to launch**, an Electron or window-server error raised before any spec runs, since Cypress needs a GUI session a headless agent shell may not have. A failing assertion inside a spec is a real failure. Either way, report which gates actually ran (see [`code-qa.yaml`](../../.github/workflows/code-qa.yaml) for what CI covers).

Two ordering notes. `npm run prettier` and `npm run eslint` both write; run Prettier again after any ESLint fix, because the `curly` fix inserts braces inline where Prettier would break the statement across lines. Finish with `npm run prettier:check`, which is what CI runs.

`npm run test:jest` carries `--passWithNoTests`, so exit code 0 alone does not prove tests ran. Report the test count.

## Fixing

Fix the cause, not the symptom. Specifically:

- Never weaken, skip, or delete a test to make a gate pass. Read the test, read the source, find the cause. See [`testing.md`](../rules/testing.md).
- Never add a fallback in production code to satisfy a failing test.
- Never silence a type error with `any`, `unknown`, `@ts-ignore`, or an `eslint-disable`. Replace it with a concrete type. See [`code-style.md`](../rules/code-style.md).
- Re-run the failing gate after each fix, then re-run the gates that precede it if your fix touched files they check.

If a failure is pre-existing and unrelated to the change under test, fix it anyway when it is small, and report it plainly when it is not. Do not present it as passing.

## Reporting

Do not stage, commit, push, or otherwise touch git state.

Return a short report, not a transcript:

- One line per gate: name, exit code, and the test or file count where the gate reports one.
- For each failure that survives: the file and line, the cause in one sentence, and what you changed.
- A final verdict line: every gate at exit code 0, or the list of gates still failing.
219 changes: 219 additions & 0 deletions .claude/hooks/validate-gate.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
#!/usr/bin/env node
// Enforces the validation mandate in CLAUDE.md: a session that changed code, tests,
// config, or docs may not finish until the quality gates have been run.
//
// Registered on three events in `.claude/settings.json`:
// PostToolUse (Write|Edit|MultiEdit|Bash) - mark the session dirty, record gates run
// SubagentStop (validator) - credit every gate to the delegated run
// Stop - block once while gates are outstanding
//
// The hook is structural, never evaluative. It records whether a gate was *run*, not
// whether it *passed*, because inferring pass or fail from tool output would either nag
// after a clean run or clear after a red one. Confirming exit codes is the agent's job.
//
// Run via `node --experimental-strip-types` (no build step, no dependencies).
// Type-stripping-safe TypeScript only: type annotations / interfaces, no enums,
// namespaces, or parameter properties.

import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { isAbsolute, join, relative } from 'path';

interface ToolInput {
file_path?: string;
command?: string;
}

interface HookPayload {
hook_event_name?: string;
session_id?: string;
cwd?: string;
tool_name?: string;
tool_input?: ToolInput;
stop_hook_active?: boolean;
}

interface GateState {
dirty: boolean;
gates: string[];
}

/** Every gate `npm run validate` runs that can also run on a developer machine. */
const GATES = ['prettier', 'eslint', 'tsc', 'jest', 'build', 'markdown'];

/** Maps a shell command to the gates it runs. */
const GATE_PATTERNS: [RegExp, string[]][] = [
[/npm run validate\b/, GATES],
[/npm run prettier\b/, ['prettier']],
[/npm run eslint\b/, ['eslint']],
[/npm run tsc(?![:\w])/, ['tsc']],
[/npm run test:jest\b/, ['jest']],
[/npm run build\b/, ['build']],
[/npm run lint:markdown\b/, ['markdown']],
];

const REMINDER =
'This change requires validation. Before you finish, run the quality gates and confirm ' +
'each reaches exit code 0: `npm run prettier`, `npm run eslint`, `npm run tsc`, ' +
'`npm run test:jest`, `npm run build`, `npm run lint:markdown`. Check the actual exit ' +
'code rather than scrolling the output, and fix any failure rather than reporting around ' +
'it. Delegating the run to the `validator` subagent keeps the output out of this context.';

/** Path to this session's marker, kept outside the repository so it never reaches `git status`. */
function statePath(sessionId: string): string {
return join(tmpdir(), 'claude-validate-gate', `${sessionId}.json`);
}

function readState(sessionId: string): GateState | null {
try {
return JSON.parse(readFileSync(statePath(sessionId), 'utf-8')) as GateState;
} catch {
return null;
}
}

function writeState(sessionId: string, state: GateState): void {
const target = statePath(sessionId);

try {
mkdirSync(join(tmpdir(), 'claude-validate-gate'), { recursive: true });
writeFileSync(target, JSON.stringify(state), 'utf-8');
} catch {
// A marker that cannot be written degrades the gate to a no-op rather than
// breaking the session.
}
}

function clearState(sessionId: string): void {
try {
rmSync(statePath(sessionId), { force: true });
} catch {
// Nothing to clean up.
}
}

/**
* Whether editing this file should require validation.
*
* Markdown counts because `lint:markdown` is one of the gates. The agent-tooling tree is
* excluded: `.claude/` is ignored by Prettier, ESLint, and markdownlint alike, so no gate
* can fail because of it.
*/
function requiresValidation(filePath: string, cwd: string): boolean {
if (!filePath) return false;

const rel = isAbsolute(filePath) ? relative(cwd, filePath) : filePath;

if (rel.startsWith('..') || rel.startsWith('.claude/') || rel.includes('/.claude/')) return false;

if (rel.startsWith('src/') || rel.startsWith('cypress/') || rel.startsWith('jest/')) return true;

if (rel.endsWith('.md')) return true;

// Root-level configuration: `package.json`, `eslint.config.js`, `next.config.js`, and so on.
return !rel.includes('/') && /\.(ts|tsx|js|mjs|cjs|json)$/.test(rel);
}

/** Records gates run by a shell command, or marks the session dirty after an edit. */
function handlePostToolUse(payload: HookPayload, sessionId: string): void {
const toolName = payload.tool_name ?? '';

if (toolName === 'Bash') {
const state = readState(sessionId);

if (!state?.dirty) process.exit(0);

const command = payload.tool_input?.command ?? '';
const matched = GATE_PATTERNS.filter(([pattern]) => pattern.test(command)).flatMap(([, gates]) => gates);

if (matched.length === 0) process.exit(0);

writeState(sessionId, { dirty: true, gates: [...new Set([...state.gates, ...matched])] });
process.exit(0);
}

if (toolName !== 'Write' && toolName !== 'Edit' && toolName !== 'MultiEdit') process.exit(0);

if (!requiresValidation(payload.tool_input?.file_path ?? '', payload.cwd ?? process.cwd())) {
process.exit(0);
}

const wasClean = !readState(sessionId)?.dirty;

// A fresh change invalidates whatever was validated before it, so the gate list resets.
writeState(sessionId, { dirty: true, gates: [] });

// Remind once per clean-to-dirty transition rather than on every edit.
if (wasClean) {
process.stdout.write(
JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PostToolUse',
additionalContext: REMINDER,
},
}),
);
}

process.exit(0);
}

/** Blocks once while gates are outstanding, then releases so the gate can never deadlock. */
function handleStop(payload: HookPayload, sessionId: string): void {
const state = readState(sessionId);

if (!state?.dirty) process.exit(0);

const missing = GATES.filter((gate) => !state.gates.includes(gate));

if (missing.length === 0) {
clearState(sessionId);
process.exit(0);
}

// `stop_hook_active` means this hook already blocked this turn. Releasing keeps a gate
// that cannot be satisfied (Cypress will not launch on macOS here) from looping.
if (payload.stop_hook_active) process.exit(0);

process.stderr.write(
`This session changed code, tests, config, or docs, and ${missing.length} of ${GATES.length} ` +
`quality gates have not been run: ${missing.join(', ')}. Run \`npm run validate\` and confirm ` +
'it reaches exit code 0 before finishing. The chain is `&&`, so if it stops partway, the gates ' +
'after the failure did not run: finish them individually (`npm run prettier`, `npm run eslint`, ' +
'`npm run tsc`, `npm run test:jest`, `npm run build`, `npm run lint:markdown`) rather than ' +
'treating them as passed.',
);
process.exit(2);
}

function main(): void {
let payload: HookPayload;

try {
payload = JSON.parse(readFileSync(0, 'utf-8')) as HookPayload;
} catch {
process.exit(0);
}

const sessionId = payload.session_id ?? '';

if (!sessionId) process.exit(0);

const event = payload.hook_event_name ?? '';

if (event === 'PostToolUse') handlePostToolUse(payload, sessionId);

if (event === 'SubagentStop') {
const state = readState(sessionId);

if (state?.dirty) writeState(sessionId, { dirty: true, gates: [...GATES] });

process.exit(0);
}

if (event === 'Stop') handleStop(payload, sessionId);

process.exit(0);
}

main();
Loading
Loading