/// /** * The gate checks every app runs, written once (docs/GATES.md §23). * * Until UIG-10 Peek and Ship each wrote them into their own * `scripts/gates-checks.mjs`. 65 different checks for finished tickets, counted * on 17 September: 28 were identical in both, 21 were the same check written two * ways, 2 read each app's real page, and 14 read code only one app has. This is * the first three groups as one list. The fourth stays in the app's own checks * file, beside this list. * * Where Peek and Ship wrote one check two ways, the check here runs both, so it * passes wherever either did: the raw-link probe with both of their texts, the * token probes against each token config the app has. * * `scripts/gates-compare.mjs` holds this list to Peek's and Ship's: every check * of theirs must be here, or be about code only that app has, or the comparison * fails and names it. * * A check for a ticket not built yet is a first guess at the evidence, taken * from the ticket's own text; the ticket that builds it changes it here. */ import type { CheckResult, GateCheck, GateHelpers, GateTicket } from './status' export interface AppCheckOptions { /** Where the app is inside the repository: `.` unless it sits in a folder of its own. */ app?: string /** A real page of the app, relative to the app, full of elements that are not controls. */ page: string /** The ticket the gate chain is listed under, and whether this repo owns it: UIG-3 in Peek, UIG-4 in Ship. */ chain?: { ref: string; owner: boolean } /** The token lint configs the app has, relative to the app. */ tokenConfigs?: string[] /** The part a hand-made column header should name (UIG-22): `Panel` since 0.37.0, `ContainerHeader` before. */ headers?: string[] } export const APP_TICKET_TITLES: Record = { 'UIG-2': 'The tracking rails — GATES.md, the status script, the guide committed', 'UIG-3': 'Tracer bullet — one rule, end to end, blocking in Peek', 'UIG-4': 'The same chain, blocking in Ship', 'UIG-6': 'Branch protection — the backstop, all three repos', 'UIG-7': 'Lint rule — every remaining raw element', 'UIG-8': 'Lint rule — forbid the reach', 'UIG-9': 'Lint rule — the className allow-list', 'UIG-10': 'create-app — a command that makes a new Estiva app that runs', 'UIG-13': "The registry widens to Peek's 115 and Ship's 74, with classification", 'UIG-19': 'Lock the contract in CI', 'UIG-20': 'The Claude skill, reading the registry', 'UIG-21': 'CLAUDE.md becomes an index, not a lecture', 'UIG-22': 'Fingerprint — a hand-made header row', 'UIG-23': 'Fingerprint — a hand-made empty state', 'UIG-24': 'Fingerprint — a browser tooltip where ours belongs', 'UIG-25': 'Fingerprint — a component copied out by hand', 'UIG-27': 'The components the apps had to build themselves — Link, ProgressBar, EmptyState padding', 'UIG-28': 'Close the two holes in the token contract — arbitrary values, and inline style', 'UIG-30': 'RichText — one component that draws a message\'s text, for both apps', 'UIG-32': 'Peek and Ship take their gate pieces from the package', 'UIG-33': 'Link — a whole row that is one link', 'UIG-37': 'The gates refuse a TypeScript eslint-disable comment — know the rule names, rules off', 'UIG-42': "What the gate can't see yet — a box around a part, a screen drawn by hand, two blind spots in the token check", } /** Run several checks as one: the first that does not pass is the answer. */ function all(h: GateHelpers, steps: (() => CheckResult | Promise)[], label: string): () => Promise { return async () => { for (const step of steps) { const r = await step() if (r.result !== 'pass') return r } return h.PASS(label) } } export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG-3', owner: false }, tokenConfigs = ['eslint.tokens.config.js', 'eslint.config.js'], headers = ['Panel'] }: AppCheckOptions): GateTicket[] { const WEB = app === '.' ? '' : `${app.replace(/\/$/, '')}/` const cwd = app === '.' ? undefined : app const PROBE = 'src/components/__gates_probe__.tsx' const GATES = 'eslint.gates.config.js' const at = (rel: string) => `${WEB}${rel}` const probe = (code: string, expect: 'error' | 'warning' | 'none', mentions?: string, file = PROBE, config = GATES) => () => h.lint({ ...(cwd ? { cwd } : {}), config, file, code, expect, mentions }) const component = (body: string) => `export function Probe() {\n return ${body}\n}\n` const installed = at('node_modules/@estiva-app/ui/dist/index.d.ts') const realPage = (): Promise => h.lint({ ...(cwd ? { cwd } : {}), config: GATES, file: page, code: h.read(at(page)), expect: 'none' }) const button = component('') /** A line TypeScript's own lint writes; ESLint refuses it where TypeScript's plugin is unknown (UIG-37). */ const typescriptDirective = '// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport const probe: any = 1\n' const ticket = (ref: string, checks: GateCheck[], owner = false): GateTicket => ({ ref, title: APP_TICKET_TITLES[ref], owner, checks }) const tokenProbe = (code: string, expect: 'error' | 'warning' | 'none', mentions?: string, file = PROBE) => all(h, tokenConfigs.map((config) => probe(code, expect, mentions, file, config)), `${tokenConfigs.join(' and ')}: as expected`) return [ ticket('UIG-2', [ { what: 'npm run gates:status is wired', run: () => h.script('package.json', 'gates:status') }, ]), ticket(chain.ref, [ { what: 'the gate lint config loads', run: () => h.loads(at(GATES)) }, { what: `${at('package.json')} has lint:rules`, run: () => h.script(at('package.json'), 'lint:rules') }, { what: 'CI runs lint:rules', run: () => h.ci('lint:rules') }, { what: 'the committed hook runs the gate lint', run: () => h.hook('.claude/settings.json', 'gates') }, { what: '.gates-count.json is committed and parses', run: () => h.json(h.exists('.gates-count.json') ? '.gates-count.json' : at('.gates-count.json')) }, { what: 'a raw