///
/**
* 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