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