///
/**
* `estiva-gates ci` — CI's job `gate`, as one command (Katerina's ruling R13,
* 25 September). Until 0.36.0 each repo listed the gate's steps in its own
* workflow, and the lists had drifted: Peek ran `lint:tokens`, Ship the whole
* `lint`, and no repo checked the two things below. Now the steps live here,
* and a repo's `gate` job runs this and nothing else of the gate's.
*
* 1. The gate lint (`npm run lint:rules`), which writes `.gates-count.json`.
* 2. The count is committed (B2): a pull request that changes what the gate
* finds and leaves the old count behind fails, so the file is the truth.
* 3. The token contract (`npm run lint:tokens`, or `npm run lint` where a repo
* has no token lint of its own).
* 4. The catalogue (`npm run registry:check`).
* 5. The wall's own settings (B13): the committed `.claude/settings.json` runs
* the editor hook before every Write and Edit, and that hook refuses a raw
* button; every folder the gate skips is named on the debt page, with why.
*
* Every step runs, so one red step does not hide the others.
*/
import { spawnSync } from 'node:child_process'
import { existsSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { TOKEN_LINT_IGNORES } from './token-lint'
export interface CiOptions {
/** The repository's top folder, where the gate lints from. */
root?: string
/** `package` is this package's own gate: its skipped folders are named in docs/GATES.md. */
audience?: 'app' | 'package'
/** Print as the steps run. Defaults to stdout. */
print?: (line: string) => void
}
/** The part a CI run writes to the probe: a raw button, which every gate refuses. */
const PROBE = '/** A probe CI writes to check the editor hook; never on disk. */\nexport function GateProbe() {\n return \n}\n'
const npm = (root: string, script: string) => spawnSync('npm', ['run', script], { cwd: root, stdio: 'inherit', shell: true }).status === 0
/** `.gates-count.json` as the gate lint left it matches the committed file; the lines that say otherwise. */
export function countCommitted(root: string): string[] {
const run = spawnSync('git', ['status', '--porcelain', '--', '.gates-count.json'], { cwd: root, encoding: 'utf8' })
if (run.status !== 0) return [`git could not read .gates-count.json's state: ${run.stderr.trim()}`]
if (!run.stdout.trim()) return []
const diff = spawnSync('git', ['diff', '--', '.gates-count.json'], { cwd: root, encoding: 'utf8' }).stdout.trim()
return [
'The gate found something other than the committed count: run `npm run lint:rules` and commit `.gates-count.json` with the change that moved it.',
...(diff ? diff.split('\n').filter((l) => /^[+-]\s/.test(l)).map((l) => ` ${l}`) : [' .gates-count.json is not committed at all']),
]
}
interface HookEntry {
matcher?: string
hooks?: { type?: string; command?: string }[]
}
/** The committed hook runs before every Write and Edit and refuses a raw button; the lines that say otherwise. */
export function hookRuns(root: string): string[] {
const file = join(root, '.claude', 'settings.json')
if (!existsSync(file)) return ['.claude/settings.json is missing: the editor hook is not switched on (docs/GATES.md §23).']
let entries: HookEntry[]
try {
entries = ((JSON.parse(readFileSync(file, 'utf8')) as { hooks?: { PreToolUse?: HookEntry[] } }).hooks?.PreToolUse ?? [])
} catch (error) {
return [`.claude/settings.json does not read as JSON: ${error instanceof Error ? error.message : String(error)}`]
}
const covers = (matcher: string | undefined, tool: string) => {
if (!matcher || matcher === '*') return true
try {
return new RegExp(`^(?:${matcher})$`).test(tool)
} catch {
return false
}
}
const input = JSON.stringify({ tool_name: 'Write', session_id: 'estiva-gates-ci', cwd: root, tool_input: { file_path: join(root, 'src', 'GateProbe.tsx'), content: PROBE } })
const tried: string[] = []
for (const entry of entries) {
if (!covers(entry.matcher, 'Write') || !covers(entry.matcher, 'Edit')) continue
for (const hook of entry.hooks ?? []) {
if (hook.type !== 'command' || !hook.command) continue
// The project folder, written in: a Windows shell does not expand `$VAR`.
const command = hook.command.replace(/\$\{?CLAUDE_PROJECT_DIR\}?/g, root.split('\\').join('/'))
const run = spawnSync(command, { cwd: root, input, encoding: 'utf8', shell: true, env: { ...process.env, CLAUDE_PROJECT_DIR: root }, timeout: 120_000 })
if (run.status === 2 && /UI Guardrails/.test(run.stderr)) return []
tried.push(` ${hook.command} → exit ${run.status ?? 'none'}${run.stderr.trim() ? `: ${run.stderr.trim().split('\n')[0]}` : ''}`)
}
}
return [
tried.length
? 'The editor hook let a raw button through: no PreToolUse hook in .claude/settings.json refused it.'
: 'No PreToolUse hook in .claude/settings.json runs before both Write and Edit: the editor gate is off.',
...tried,
]
}
/** A glob as the words a page would name it by: `demo-scenarios/**` → `demo-scenarios`. */
const plain = (glob: string) => glob.replace(/\/\*\*.*$|\/$/, '')
/** The folders a gate config skips beyond the package's own three, from the config itself. */
export async function skippedFolders(root: string): Promise {
const found = new Set()
for (const name of ['eslint.gates.config.js', 'eslint.tokens.config.js']) {
const file = join(root, name)
if (!existsSync(file)) continue
// The file as it is now: Node keeps a module it has imported once, by its URL.
const loaded = (await import(`${pathToFileURL(file).href}?at=${statSync(file).mtimeMs}`)) as { default?: unknown }
const configs = [loaded.default].flat(Infinity) as { ignores?: string[]; [key: string]: unknown }[]
for (const c of configs) {
// Only a config with nothing but `ignores` skips a folder for every rule.
if (!c || typeof c !== 'object' || !Array.isArray(c.ignores) || Object.keys(c).some((k) => k !== 'ignores' && k !== 'name')) continue
for (const glob of c.ignores) if (!TOKEN_LINT_IGNORES.includes(glob)) found.add(glob)
}
}
return [...found]
}
/** Every skipped folder is named on the debt page; the lines that say otherwise. */
export async function skipsListed(root: string, audience: 'app' | 'package'): Promise {
const page = audience === 'package' ? 'docs/GATES.md' : 'docs/GATES-DEBT.md'
const skipped = await skippedFolders(root)
if (!skipped.length) return []
const text = existsSync(join(root, page)) ? readFileSync(join(root, page), 'utf8') : ''
const missing = skipped.filter((glob) => !text.includes(plain(glob)))
if (!missing.length) return []
return [`The gate skips folders ${page} does not name. Name each there, with why it is not checked (B11):`, ...missing.map((g) => ` ${g}`)]
}
export async function runCi({ root = process.cwd(), audience = 'app', print = (line) => process.stdout.write(`${line}\n`) }: CiOptions = {}): Promise {
const scripts = (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { scripts?: Record }).scripts ?? {}
const tokens = scripts['lint:tokens'] ? 'lint:tokens' : 'lint'
const failed: string[] = []
const step = async (title: string, run: () => boolean | string[] | Promise) => {
print(`\n▸ ${title}`)
const result = await run()
const problems = result === true ? [] : result === false ? [`${title}: failed (above)`] : result
if (problems.length) {
failed.push(title)
for (const line of problems) print(process.env.GITHUB_ACTIONS && !line.startsWith(' ') ? `::error::${line}` : line)
} else print(' ✓')
}
await step('The gate lint (npm run lint:rules)', () => npm(root, 'lint:rules'))
await step('The count is committed (.gates-count.json)', () => countCommitted(root))
await step(`The token contract (npm run ${tokens})`, () => npm(root, tokens))
await step('The catalogue (npm run registry:check)', () => npm(root, 'registry:check'))
await step('The editor hook refuses a raw button (.claude/settings.json)', () => hookRuns(root))
await step('Every folder the gate skips is named on the debt page', () => skipsListed(root, audience))
print(failed.length ? `\nThe gate failed: ${failed.join('; ')}.` : '\nThe gate passed.')
return failed.length ? 1 : 0
}