|
| 1 | +#!/usr/bin/env tsx |
| 2 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 3 | + |
| 4 | +/** |
| 5 | + * Run **every** generated-artifact gate and report **all** stale artifacts in one |
| 6 | + * pass. |
| 7 | + * |
| 8 | + * CI runs these gates as separate sequential steps across two jobs, so the first |
| 9 | + * stale artifact masks the rest: you fix it, push, and discover the next one on |
| 10 | + * the following run. That happened twice on #4040 (`check:docs`, then |
| 11 | + * `check:api-surface`) and twice again on #4161 (`check:spec-changes`, then |
| 12 | + * `check:upgrade-guide`) — four pushes spent learning something one local run |
| 13 | + * could have told you. |
| 14 | + * |
| 15 | + * This is deliberately **not** a "regenerate everything" script. Blanket |
| 16 | + * regeneration destroys the signal: it rewrites artifacts whose staleness you |
| 17 | + * never saw, so a real semantic change lands silently inside a mechanical diff. |
| 18 | + * What is worth automating is the *diagnosis* — which artifacts are stale, and |
| 19 | + * the exact command for each. `--fix` then regenerates **only** the ones this run |
| 20 | + * proved stale, and says so. |
| 21 | + * |
| 22 | + * Usage: |
| 23 | + * pnpm --filter @objectstack/spec check:generated # report every stale artifact |
| 24 | + * pnpm --filter @objectstack/spec check:generated --fix # + regenerate exactly those |
| 25 | + */ |
| 26 | + |
| 27 | +import { execSync } from 'node:child_process'; |
| 28 | +import { readFileSync } from 'node:fs'; |
| 29 | +import { dirname, join } from 'node:path'; |
| 30 | +import { fileURLToPath } from 'node:url'; |
| 31 | + |
| 32 | +const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); |
| 33 | + |
| 34 | +/** |
| 35 | + * The gates that verify a checked-in artifact against its source, with the |
| 36 | + * generator that rewrites each. Order is the cheapest-first order a human would |
| 37 | + * want the answers in, not CI's. |
| 38 | + */ |
| 39 | +const GATED: ReadonlyArray<{ check: string; gen: string; artifact: string; readsDist?: true }> = [ |
| 40 | + { check: 'check:spec-changes', gen: 'gen:spec-changes', artifact: 'spec-changes.json' }, |
| 41 | + { check: 'check:upgrade-guide', gen: 'gen:upgrade-guide', artifact: 'docs/protocol-upgrade-guide.md' }, |
| 42 | + { check: 'check:skill-docs', gen: 'gen:skill-docs', artifact: 'skill docs (from SKILL.md frontmatter)' }, |
| 43 | + { check: 'check:skill-refs', gen: 'gen:skill-refs', artifact: 'skill references' }, |
| 44 | + { check: 'check:react-blocks', gen: 'gen:react-blocks', artifact: 'react-blocks contract' }, |
| 45 | + { check: 'check:authorable-surface', gen: 'gen:schema', artifact: 'authorable-surface.json + JSON schemas' }, |
| 46 | + // Reads the BUILT `dist/*.d.ts`, not the source. On a stale dist it reports |
| 47 | + // every export added since the last build as a "breaking removal" — a phantom |
| 48 | + // that has cost real triage time (AGENTS.md records the trap). Flagged so the |
| 49 | + // failure explains itself instead of sending the next reader after a ghost. |
| 50 | + { check: 'check:api-surface', gen: 'gen:api-surface', artifact: 'api-surface.json', readsDist: true }, |
| 51 | + { check: 'check:docs', gen: 'gen:docs', artifact: 'content/docs/references/**' }, |
| 52 | +]; |
| 53 | + |
| 54 | +/** |
| 55 | + * Gates this script deliberately does NOT run. They audit the source for a |
| 56 | + * property (liveness, conformance, example validity) rather than compare a |
| 57 | + * checked-in artifact against its generator — there is nothing to regenerate, |
| 58 | + * so a failure is a code change, not a `gen:` command. |
| 59 | + */ |
| 60 | +const NO_GENERATOR: ReadonlyArray<{ check: string; why: string }> = [ |
| 61 | + { check: 'check:liveness', why: 'audits whether declared spec properties have a reader — no artifact' }, |
| 62 | + { check: 'check:empty-state', why: 'audits empty-state coverage — no artifact' }, |
| 63 | + { check: 'check:react-conformance', why: 'audits react blocks against their contract — no artifact' }, |
| 64 | + { check: 'check:skill-examples', why: 'validates skill examples parse — no artifact' }, |
| 65 | +]; |
| 66 | + |
| 67 | +/** |
| 68 | + * Generators whose output NOTHING verifies. Recorded rather than ignored: each |
| 69 | + * one is an artifact that can silently drift from its source, which is the class |
| 70 | + * every gate above exists to prevent. Adding a gate for either is a real |
| 71 | + * follow-up, not a formality. |
| 72 | + */ |
| 73 | +const UNGATED_GENERATORS: ReadonlyArray<{ gen: string; why: string }> = [ |
| 74 | + { gen: 'gen:openapi', why: 'the OpenAPI document is generated but no check gate compares it to the routes' }, |
| 75 | + { gen: 'gen:sbom', why: 'the SBOM is a release artifact, regenerated at publish time rather than checked in' }, |
| 76 | +]; |
| 77 | + |
| 78 | +/** This aggregate itself — a `check:` script that gates nothing of its own. */ |
| 79 | +const SELF = 'check:generated'; |
| 80 | + |
| 81 | +/** |
| 82 | + * Reconcile the ledgers above against `package.json` — in BOTH directions, on |
| 83 | + * every run rather than behind a `--self-test` flag, because the failure this |
| 84 | + * prevents is a gate quietly dropping out of coverage. A gate absent from both |
| 85 | + * lists would simply never run here, and the summary would still say "all |
| 86 | + * artifacts up to date" — the exact shape of lie this script exists to remove. |
| 87 | + * |
| 88 | + * It works: the very first run rejected this script's own `package.json` entry |
| 89 | + * as unclassified, before it had checked a single artifact. |
| 90 | + */ |
| 91 | +function reconcileLedger(scripts: Record<string, string>): void { |
| 92 | + const problems: string[] = []; |
| 93 | + const declaredChecks = new Set([...GATED.map((g) => g.check), ...NO_GENERATOR.map((n) => n.check)]); |
| 94 | + const declaredGens = new Set([...GATED.map((g) => g.gen), ...UNGATED_GENERATORS.map((u) => u.gen)]); |
| 95 | + |
| 96 | + for (const name of Object.keys(scripts)) { |
| 97 | + if (name === SELF) continue; |
| 98 | + if (name.startsWith('check:') && !declaredChecks.has(name)) { |
| 99 | + problems.push(` \`${name}\` exists in package.json but is in neither GATED nor NO_GENERATOR.\n` + |
| 100 | + ` Classify it: does it compare a checked-in artifact against a generator, or audit source?`); |
| 101 | + } |
| 102 | + if (name.startsWith('gen:') && !declaredGens.has(name)) { |
| 103 | + problems.push(` \`${name}\` exists in package.json but no GATED entry names it and it is not in UNGATED_GENERATORS.\n` + |
| 104 | + ` Either wire its gate in, or record why its output is unverified.`); |
| 105 | + } |
| 106 | + } |
| 107 | + for (const { check } of GATED) if (!scripts[check]) problems.push(` GATED names \`${check}\`, which package.json no longer has.`); |
| 108 | + for (const { gen } of GATED) if (!scripts[gen]) problems.push(` GATED names \`${gen}\`, which package.json no longer has.`); |
| 109 | + for (const { check } of NO_GENERATOR) if (!scripts[check]) problems.push(` NO_GENERATOR names \`${check}\`, which package.json no longer has.`); |
| 110 | + for (const { gen } of UNGATED_GENERATORS) if (!scripts[gen]) problems.push(` UNGATED_GENERATORS names \`${gen}\`, which package.json no longer has.`); |
| 111 | + |
| 112 | + if (problems.length) { |
| 113 | + console.error(`✗ check:generated ledger is out of sync with package.json:\n\n${problems.join('\n')}\n`); |
| 114 | + process.exit(1); |
| 115 | + } |
| 116 | +} |
| 117 | + |
| 118 | +function run(script: string): { ok: boolean; output: string } { |
| 119 | + try { |
| 120 | + const output = execSync(`pnpm -s ${script}`, { cwd: pkgRoot, stdio: ['ignore', 'pipe', 'pipe'] }).toString(); |
| 121 | + return { ok: true, output }; |
| 122 | + } catch (err: any) { |
| 123 | + return { ok: false, output: `${err?.stdout?.toString() ?? ''}${err?.stderr?.toString() ?? ''}`.trim() }; |
| 124 | + } |
| 125 | +} |
| 126 | + |
| 127 | +const fix = process.argv.includes('--fix'); |
| 128 | +const scripts = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')).scripts ?? {}; |
| 129 | +reconcileLedger(scripts); |
| 130 | + |
| 131 | +console.log(`Checking ${GATED.length} generated artifacts (every gate runs — the first failure does not stop the rest).\n`); |
| 132 | + |
| 133 | +const stale: typeof GATED[number][] = []; |
| 134 | +for (const entry of GATED) { |
| 135 | + const { ok, output } = run(entry.check); |
| 136 | + console.log(` ${ok ? '✓' : '✗'} ${entry.check.padEnd(26)} ${entry.artifact}`); |
| 137 | + if (!ok) { |
| 138 | + stale.push(entry); |
| 139 | + // The gates print their own prescription; surface it rather than paraphrasing. |
| 140 | + const detail = output.split('\n').filter(Boolean).slice(0, 3).map((l) => ` ${l}`).join('\n'); |
| 141 | + if (detail) console.log(detail); |
| 142 | + if (entry.readsDist) { |
| 143 | + console.log(` ⚠ this gate reads the BUILT dist, not the source — if you have not run`); |
| 144 | + console.log(` \`pnpm --filter @objectstack/spec build\` since your last pull, the removals`); |
| 145 | + console.log(` above are phantoms. Build first, then re-run, before regenerating.`); |
| 146 | + } |
| 147 | + } |
| 148 | +} |
| 149 | + |
| 150 | +// Narrowing is never silent: say what was deliberately not run. |
| 151 | +console.log(`\nNot run here (${NO_GENERATOR.length} source audits with no artifact to regenerate): ` + |
| 152 | + NO_GENERATOR.map((n) => n.check).join(', ')); |
| 153 | +if (UNGATED_GENERATORS.length) { |
| 154 | + console.log(`Generated but ungated (${UNGATED_GENERATORS.length}): ` + |
| 155 | + UNGATED_GENERATORS.map((u) => u.gen).join(', ') + ' — nothing verifies these are current.'); |
| 156 | +} |
| 157 | + |
| 158 | +if (!stale.length) { |
| 159 | + console.log(`\n✓ All ${GATED.length} generated artifacts are up to date.`); |
| 160 | + process.exit(0); |
| 161 | +} |
| 162 | + |
| 163 | +console.log(`\n✗ ${stale.length} of ${GATED.length} artifact(s) stale:\n`); |
| 164 | +for (const s of stale) console.log(` ${s.artifact}\n pnpm --filter @objectstack/spec ${s.gen}`); |
| 165 | + |
| 166 | +if (!fix) { |
| 167 | + console.log(`\nRegenerate exactly these:\n ` + |
| 168 | + stale.map((s) => `pnpm --filter @objectstack/spec ${s.gen}`).join(' && ') + |
| 169 | + `\n\nOr re-run with --fix to do it now (only the ${stale.length} proved stale — never the whole set).`); |
| 170 | + process.exit(1); |
| 171 | +} |
| 172 | + |
| 173 | +console.log(`\n--fix: regenerating the ${stale.length} stale artifact(s). Review the diff before committing.\n`); |
| 174 | +let failed = 0; |
| 175 | +for (const s of stale) { |
| 176 | + const { ok, output } = run(s.gen); |
| 177 | + console.log(` ${ok ? '✓' : '✗'} ${s.gen}`); |
| 178 | + if (!ok) { |
| 179 | + failed++; |
| 180 | + console.error(output.split('\n').filter(Boolean).slice(0, 5).map((l) => ` ${l}`).join('\n')); |
| 181 | + } |
| 182 | +} |
| 183 | +process.exit(failed ? 1 : 0); |
0 commit comments