/// /** * gates:status — where the UI Guardrails project stands, read from the code. * * estiva-gates status one row per ticket * estiva-gates status --detail every check under every row * estiva-gates status --json machine output, read by estiva-ui's run * estiva-gates status --app web the app sits in `web/` (Ship's did, until PER-19) * * Every row is decided by checks on real files, a real lint run or a real * GitHub setting. Nothing here reads a list that someone ticks by hand. * * **One copy, in the package** (docs/GATES.md §23). Until UIG-10 this engine was * `scripts/gates-status.mjs`, the same file copied by hand into estiva-ui, Peek * and Ship, with estiva-ui warning when the copies drifted. Now it ships here, * and a repo runs it with `estiva-gates status`. A repo still carrying its own * copy is named as one when estiva-ui runs it; UIG-32 moves Peek and Ship off * theirs. * * What differs per repo is `scripts/gates-checks.mjs`, beside the app: its * checks, or the checks every app runs (`appChecks`) plus its own. It sits with * the app because it imports the package: in an app that sits in a folder of * its own, the checks file sits there too (UIG-32). * * The rows and the reasons for each check are in estiva-ui docs/GATES.md §15 * and §17. */ import { execFileSync, spawnSync } from 'node:child_process' import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { createRequire } from 'node:module' import { join, relative, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import type { ESLint as ESLintClass } from 'eslint' import { escapeLines, listEscapes } from './escapes' export type CheckResult = { result: 'pass' | 'fail' | 'part' | 'unknown'; detail: string } type Maybe = T | Promise export interface LintProbe { /** The folder ESLint runs from, relative to the repo: `.` unless the app sits in a folder of its own. */ cwd?: string /** The config file, relative to `cwd`. */ config: string /** The path the text is linted as, relative to `cwd`. Nothing is written. */ file: string code: string expect: 'error' | 'warning' | 'none' /** Words the expected message must contain. */ mentions?: string } /** The helpers a checks file is written with. */ export interface GateHelpers { PASS(detail: string): CheckResult FAIL(detail: string): CheckResult PART(detail: string): CheckResult UNKNOWN(detail: string): CheckResult exists(rel: string): boolean read(rel: string): string listFiles(rel: string, test: (name: string, path: string) => boolean): string[] file(rel: string): CheckResult committed(rel: string): CheckResult contains(rel: string, needle: string | RegExp, label?: string): CheckResult lacks(rel: string, needle: string | RegExp, label?: string): CheckResult script(pkgRel: string, name: string): CheckResult loads(rel: string): Promise ci(script: string | RegExp): CheckResult ciJob(job: string, script: string): CheckResult hook(settingsRel: string, needle: string): CheckResult json(rel: string, test?: (data: unknown) => boolean, label?: string): CheckResult lint(probe: LintProbe): Promise protectedBranch(pattern: RegExp): CheckResult gh(args: string[], label: string): CheckResult share(files: string[], test: (file: string) => boolean, label: string): CheckResult /** Build the catalogue of the app in `appDir` (UIG-13): passes when every part is described and sorted. */ catalogue(appDir: string): Promise /** Every reusable part keeps the usage-page contract (UIG-19): the package's one copy of it. */ contract(appDir: string): Promise /** * What loads into every Claude session stays small (UIG-21): `CLAUDE.md` and * every file it imports with `@` come to fewer than `limit` lines, and each * `.claude/rules` file has `paths:`, so it loads only where it applies. */ instructions(limit?: number): CheckResult } export interface GateCheck { what: string run: () => Maybe aggregate?: boolean } export interface GateTicket { ref: string title?: string owner: boolean checks: GateCheck[] } export interface TicketListEntry { ref: string owner: string title: string parts?: string[] aggregate?: boolean /** Moved to the next phase, and by whose ruling: still checked, shown ⏭, and not waited for by the aggregate. */ later?: string } export interface GateSpec { repo: string tickets: GateTicket[] /** estiva-ui only: every ticket, and the repos it joins in. */ all?: TicketListEntry[] /** estiva-ui only: the repos beside it. `app` is the folder its app sits in, when not the top. */ siblings?: { name: string; path: string; env: string; app?: string }[] } interface Row { ref: string title?: string owner?: boolean ownerRepo?: string checks: (CheckResult & { repo: string; what: string })[] status?: string aggregate?: boolean later?: string } interface Report { schema: 1 repo: string branch: string | null commit: string | null engine: string rows: Row[] } const { version } = createRequire(import.meta.url)('../../package.json') as { version: string } /** What a report says ran it. A copy of the old script reports a hash instead. */ export const ENGINE = `@estiva-app/ui@${version}` const PASS = (detail: string): CheckResult => ({ result: 'pass', detail }) const FAIL = (detail: string): CheckResult => ({ result: 'fail', detail }) const PART = (detail: string): CheckResult => ({ result: 'part', detail }) const UNKNOWN = (detail: string): CheckResult => ({ result: 'unknown', detail }) const said = (e: unknown) => { const err = e as { stdout?: unknown; stderr?: unknown; message?: string; code?: string } return { text: `${err.stdout ?? ''}${err.stderr ?? ''}`, message: String(err.message ?? e), code: err.code } } /** The scripts `estiva-gates ci` runs (R13): a job that runs it runs these. */ const GATE_CI_SCRIPTS = new Set(['lint:rules', 'lint:tokens', 'lint', 'registry:check']) /** `estiva-gates ci`, as a workflow runs it, when it runs `script`; null otherwise. A comment line does not count. */ function runsGateCi(text: string, script: string | RegExp): string | null { if (typeof script !== 'string' || !GATE_CI_SCRIPTS.has(script)) return null const m = /^(?!\s*#).*?((?:npx )?estiva-gates ci\b|npm run gates:ci\b|cli\.js"? ci\b)/m.exec(text) return m ? m[1] : null } /** The helpers, bound to one repository. */ export function helpers(ROOT: string): GateHelpers { const abs = (rel: string) => resolve(ROOT, rel) const exists = (rel: string) => existsSync(abs(rel)) const read = (rel: string) => readFileSync(abs(rel), 'utf8') const git = (...a: string[]) => { try { return execFileSync('git', a, { cwd: ROOT, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() } catch { return null } } const listFiles = (rel: string, test: (name: string, path: string) => boolean) => { const out: string[] = [] const walk = (dir: string) => { if (!existsSync(dir)) return for (const name of readdirSync(dir)) { if (['node_modules', '.git', 'dist', 'storybook-static', '.verify-shots'].includes(name)) continue const p = join(dir, name) if (statSync(p).isDirectory()) walk(p) else if (test(name, p)) out.push(relative(ROOT, p).replace(/\\/g, '/')) } } walk(abs(rel)) return out } const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') return { PASS, FAIL, PART, UNKNOWN, exists, read, listFiles, // The registry bundle's own builder, loaded only when a check asks for it: // it reads TypeScript with TypeScript, which the other checks never need. async catalogue(appDir) { try { const { buildAppRegistry, validateRegistry } = await import('../registry/index') const registry = buildAppRegistry({ root: abs(appDir) }) const problems = validateRegistry(registry) if (problems.length) return FAIL(`${problems.length} problems, the first: ${problems[0]}`) return PASS(`${registry.entries.length} parts in ${registry.builtFrom.files} files, each described and sorted`) } catch (error) { const lines = String(error instanceof Error ? error.message : error).split('\n') return FAIL(lines.length > 1 ? `${lines.length - 1} problems, the first: ${lines[1].trim()}` : lines[0]) } }, async contract(appDir) { try { const { buildAppRegistry, contractProblems, nameableParts, CONTRACT_KINDS } = await import('../registry/index') const registry = buildAppRegistry({ root: abs(appDir) }) // The package's catalogue, beside its package.json: a page may name its parts too, as in CI. const pkg = createRequire(import.meta.url)('../../registry.json') as Parameters[1] const problems = contractProblems(registry, abs(appDir), nameableParts(registry, pkg)) const owed = registry.entries.filter((entry) => (CONTRACT_KINDS as readonly string[]).includes(entry.app?.class ?? '')).length if (problems.length) return FAIL(`${problems.length} of ${owed}; the first: ${problems[0]}`) return PASS(`all ${owed} reusable parts keep the usage-page contract and are drawn somewhere`) } catch (error) { return FAIL(String(error instanceof Error ? error.message : error).split('\n')[0]) } }, instructions(limit = 200) { if (!exists('CLAUDE.md')) return FAIL('there is no CLAUDE.md') // As `wc -l` counts them: a last line ending in a newline is not one more. const lines = (rel: string) => read(rel).replace(/\r?\n$/, '').split(/\r?\n/).length const imports = [...read('CLAUDE.md').matchAll(/^@(\S+)\s*$/gm)].map((m) => m[1]) const missing = imports.filter((rel) => !exists(rel)) if (missing.length) return FAIL(`CLAUDE.md imports ${missing.join(', ')}, which is not there`) const total = imports.reduce((n, rel) => n + lines(rel), lines('CLAUDE.md')) const what = imports.length ? `CLAUDE.md with ${imports.join(', ')}` : 'CLAUDE.md' if (total >= limit) return FAIL(`${what}: ${total} lines load into every session; the limit is ${limit}`) const rules = listFiles('.claude/rules', (n) => n.endsWith('.md')) const unscoped = rules.filter((rel) => { const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(read(rel)) return !front || !/^paths:/m.test(front[1]) }) if (unscoped.length) return FAIL(`${unscoped.join(', ')} has no paths:, so it loads into every session`) return PASS(`${what}: ${total} lines in every session; ${rules.length ? `${rules.length} rule files, each loading only for its paths` : 'no rule files'}`) }, file(rel) { return exists(rel) ? PASS(`${rel} exists`) : FAIL(`${rel} does not exist`) }, committed(rel) { if (!exists(rel)) return FAIL(`${rel} does not exist`) return git('ls-files', '--error-unmatch', rel) ? PASS(`${rel} is committed`) : FAIL(`${rel} exists but is not committed`) }, contains(rel, needle, label) { if (!exists(rel)) return FAIL(`${rel} does not exist`) const hit = typeof needle === 'string' ? read(rel).includes(needle) : needle.test(read(rel)) return hit ? PASS(label ?? `${rel} has ${needle}`) : FAIL(label ? `not yet: ${label}` : `${rel} has no ${needle}`) }, lacks(rel, needle, label) { if (!exists(rel)) return FAIL(`${rel} does not exist`) const hit = typeof needle === 'string' ? read(rel).includes(needle) : needle.test(read(rel)) return hit ? FAIL(`${rel} still has ${needle}`) : PASS(label ?? `${rel} has no ${needle}`) }, script(pkgRel, name) { if (!exists(pkgRel)) return FAIL(`${pkgRel} does not exist`) const cmd = (JSON.parse(read(pkgRel)) as { scripts?: Record }).scripts?.[name] return cmd ? PASS(`${pkgRel} runs "${name}": ${cmd}`) : FAIL(`${pkgRel} has no "${name}" script`) }, async loads(rel) { if (!exists(rel)) return FAIL(`${rel} does not exist`) try { const mod = (await import(pathToFileURL(abs(rel)).href)) as { default?: unknown } return mod.default ? PASS(`${rel} loads`) : FAIL(`${rel} has no default export`) } catch (e) { return FAIL(`${rel} does not load: ${said(e).message.split('\n')[0]}`) } }, /** A workflow step that runs `npm run