/// /** * The editor gate: before Claude writes a `.ts` or `.tsx` under `src/`, lint the * text it is about to write with the gate, and refuse the write on an error. * Written once (docs/GATES.md ยง23): until UIG-10 Peek, Ship and this package * each carried their own copy of this script. * * A `PreToolUse` hook in the repository's committed `.claude/settings.json` * runs it. Claude Code documents exit 2 as blocking the tool call, with stderr * handed back to Claude; exit 0 raises no objection. It reads * `$CLAUDE_PROJECT_DIR`, the project root where the session started โ€” a session * started in a folder above or below the repository gets no hook (UIG-3, * UIG-4 findings). * * It runs the gate's own config and reimplements nothing: a hook and a lint * that could disagree would be worse than neither. Since 0.36.0 it runs the * token contract too, keeping only that contract's errors (B4), and a copied * look, which only warns, reaches Claude as a note on a write let through (R10). Tests are not checked * (Katerina, 13 September); stories are. This package's inward gate reads `.tsx` * only, as its lint does. * * Input, from Claude Code on stdin: `tool_name` and `tool_input` โ€” `file_path` * and `content` for Write; `file_path`, `old_string`, `new_string` and * `replace_all` for Edit. An Edit carries no whole file, so the edit is applied * to the file on disk here. Anything it cannot read passes, and the tool itself * reports its own failure. * * **Search first** (UIG-20). A Write that would create a new file drawing a new * part, and that the gate lets through, is stopped once: the hook runs * `estiva-ui find` on the part's name and hands the matches back, so a session * sees what already exists before it adds another. The same file written again * in the same session goes through. The skill asks for the search; this makes * sure it happened. A search that cannot run stops nothing. */ import { spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import type { ESLint as ESLintClass, Linter } from 'eslint' import { countGates } from '../eslint/index' import { tokenConfig } from './token-lint' import { repositoryOf } from '../registry/skill' export interface HookOptions { /** The repository's top folder. Defaults to `$CLAUDE_PROJECT_DIR`, then the tool call's `cwd`. */ root?: string /** The folder that holds the app, relative to `root`: `.` unless it sits in a folder of its own. */ app?: string /** `package` gates `.tsx` only, as this package's inward lint does. */ audience?: 'app' | 'package' /** The hook's input, when not read from stdin (tests). */ input?: unknown /** Where a session's searches are remembered. Defaults to a folder in the system's temp. */ seen?: string } export interface HookResult { /** 0 lets the write through; 2 refuses it. */ code: 0 | 2 /** What goes to stderr when refused. */ message: string /** A note for Claude on a write let through: a copied look, which only warns. */ note?: string } /** The token contract's own rules: what the hook keeps from its lint, and nothing else. */ const TOKEN_RULE_IDS = new Set(tokenConfig().flatMap((c) => Object.keys(c.rules ?? {}))) /** * The token contract's errors for the text about to be written. The app's own * token config when it has one (`eslint.tokens.config.js`, which knows the app's * other plugins' names); otherwise the package's `tokenConfig`. Only the token * rules' errors count, so an app's wider lint (TypeScript's, React's) never * blocks a write that is still being made. */ async function tokenErrors(ESLint: typeof ESLintClass, appDir: string, file: string, text: string, audience: 'app' | 'package'): Promise { const own = join(appDir, 'eslint.tokens.config.js') const eslint = existsSync(own) ? new ESLint({ cwd: appDir, overrideConfigFile: own }) : new ESLint({ cwd: appDir, overrideConfigFile: true, overrideConfig: tokenConfig({ audience }) }) const results = await eslint.lintText(text, { filePath: file }) return results.flatMap((r) => r.messages.filter((m) => m.severity === 2 && m.ruleId && TOKEN_RULE_IDS.has(m.ruleId))) } interface ToolCall { tool_name?: string session_id?: string cwd?: string tool_input?: { file_path?: unknown; content?: unknown; old_string?: unknown; new_string?: unknown; replace_all?: unknown } } const PASS: HookResult = { code: 0, message: '' } /** An app's source the gate reads: every script type, with or without JSX. */ const APP_SOURCE = /^src\/.+\.[cm]?[jt]sx?$/ export async function runHook({ root, app = '.', audience = 'app', input, seen }: HookOptions = {}): Promise { let call: ToolCall try { call = (input ?? JSON.parse(readFileSync(0, 'utf8'))) as ToolCall } catch { return PASS } const args = call.tool_input ?? {} if (typeof args.file_path !== 'string') return PASS const top = root ?? process.env.CLAUDE_PROJECT_DIR ?? call.cwd ?? process.cwd() const appDir = resolve(top, app) const file = isAbsolute(args.file_path) ? args.file_path : resolve(call.cwd ?? top, args.file_path) const rel = relative(appDir, file).split(sep).join('/') // Every script under an app's src/, as the gate reads it (a raw