Skip to content

Commit 8621cdd

Browse files
os-zhuangclaude
andauthored
feat(spec): check:generated — report every stale artifact in one run (#4183)
`packages/spec` has eight checked-in generated artifacts, each with its own gate, split across two CI jobs that run their gates SEQUENTIALLY. The first stale artifact therefore masks every one behind it: you fix it, push, and learn about the next on the following run. Two pushes on #4040 (`check:docs`, then `check:api-surface`), two more on #4161 (`check:spec-changes`, then `check:upgrade-guide`) — four round trips spent discovering something one local run could have said at once. Every gate runs; a failure does not stop the rest. The summary lists all stale artifacts with the exact `gen:` command for each. `--fix` regenerates ONLY what this run proved stale. Deliberately not a regenerate-everything button: blanket regeneration rewrites artifacts whose staleness you never saw, which is how a real semantic change lands silently inside a mechanical diff. The gate -> generator ledger reconciles against `package.json` on EVERY run, in both directions, rather than behind a `--self-test` flag. An unclassified `check:`/`gen:` script fails the run instead of quietly dropping out of coverage — otherwise the summary would still say "all artifacts up to date" while checking fewer, the exact class of lie this exists to remove. It proved itself by rejecting its own `package.json` entry on the first run. Two things it refuses to be silent about: the `check:api-surface` stale-`dist` trap is printed inline when that gate is the one failing (it reads the built `.d.ts`, so an unbuilt tree reports newly-added exports as breaking removals), and the four source audits it does NOT run are named, so "all up to date" never reads as "everything passed". It also reports that `gen:openapi` and `gen:sbom` have no gate at all. Verified both directions: clean built tree -> all 8 pass; a change injected into a migration step's `rationale` -> `check:upgrade-guide` fails and `--fix` regenerates that artifact and no other. That run also corrected an earlier reading of mine: the two ADR-0087 gates have different inputs — the conversion registry drives `spec-changes.json`, the rationale prose drives `protocol-upgrade-guide.md` — they had only appeared to fail together because #4161 changed both. AGENTS.md's hand-rolled loop over eight hardcoded gate names is replaced by the command; that list could not survive a ninth artifact, and the ledger can. Claude-Session: https://claude.ai/code/session_01QoA8AV99Ss1RRkLLAYmDzq Co-authored-by: Claude <noreply@anthropic.com>
1 parent a8dcc37 commit 8621cdd

4 files changed

Lines changed: 252 additions & 10 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
`check:generated` — one command that reports every stale generated artifact, instead of one red build per artifact.
6+
7+
`packages/spec` has eight checked-in generated artifacts, each with its own gate, split
8+
across two CI jobs that run their gates **sequentially**. So the first stale artifact
9+
masks every one behind it: you fix it, push, and learn about the next one on the
10+
following run. That cost two pushes on #4040 (`check:docs`, then `check:api-surface`) and
11+
two more on #4161 (`check:spec-changes`, then `check:upgrade-guide`) — four round trips
12+
spent discovering something one local run could have said at once.
13+
14+
- Every gate runs; a failure does not stop the rest. The summary lists all stale
15+
artifacts with the exact `gen:` command for each.
16+
- `--fix` regenerates **only** the artifacts this run proved stale. Deliberately not a
17+
"regenerate everything" button: blanket regeneration rewrites artifacts whose staleness
18+
you never saw, which is how a real semantic change lands silently inside a mechanical
19+
diff.
20+
- The gate → generator ledger is **reconciled against `package.json` on every run**, both
21+
directions, rather than behind a `--self-test` flag. A new `check:`/`gen:` script that
22+
nobody classified fails the run instead of quietly dropping out of coverage — otherwise
23+
the summary would still say "all artifacts up to date" while silently checking fewer,
24+
which is the exact class of lie this script exists to remove. It proved itself
25+
immediately by rejecting its own `package.json` entry on the first run.
26+
- The `check:api-surface` stale-`dist` trap (it reads the built `.d.ts`, so an unbuilt
27+
tree reports every newly-added export as a "breaking removal") is flagged inline when
28+
that gate is the one failing, instead of leaving the next reader to chase a phantom.
29+
- What it deliberately does **not** run is named in the output: the four source audits
30+
with no artifact (`check:liveness`, `check:empty-state`, `check:react-conformance`,
31+
`check:skill-examples`), so "all up to date" never reads as "everything passed".
32+
33+
It also surfaces a standing gap rather than staying quiet about it: `gen:openapi` and
34+
`gen:sbom` produce artifacts that **no gate verifies**.
35+
36+
Verified both directions: on a clean built tree all 8 pass; injecting a change into a
37+
migration step's `rationale` makes `check:upgrade-guide` fail and `--fix` regenerates
38+
that one artifact and no other. The same run also showed the two gates have different
39+
inputs — the conversion registry drives `spec-changes.json`, the rationale prose drives
40+
`protocol-upgrade-guide.md` — which the earlier "both failed together" reading had
41+
conflated.
42+
43+
AGENTS.md's hand-rolled loop over eight hardcoded gate names is replaced by the command;
44+
that list could not survive a ninth artifact, and the script's ledger can.

AGENTS.md

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -247,27 +247,41 @@ A `.describe()` string counts — it is not "just a comment", it lands in
247247
`content/docs/references/`. Adding one export counts — it lands in `api-surface.json`.
248248
Both were learned the hard way in #4040: two separate red builds, neither a logic error.
249249

250-
Cheapest way to be sure, since a gate is just its generator in `--check` mode — run the
251-
whole set and let it tell you which artifact is stale:
250+
Don't match by hand — one command runs **every** gate and reports **all** stale
251+
artifacts at once, which is precisely what CI cannot do:
252252

253253
```bash
254-
cd packages/spec
255-
pnpm build # REQUIRED first — see the dist caveat below
256-
for c in check:docs check:api-surface check:authorable-surface check:spec-changes \
257-
check:upgrade-guide check:skill-refs check:skill-docs check:react-blocks; do
258-
printf '%-28s ' "$c"; pnpm -s $c >/dev/null 2>&1 && echo PASS || echo FAIL
259-
done
254+
pnpm --filter @objectstack/spec build # REQUIRED first — see the dist caveat
255+
pnpm --filter @objectstack/spec check:generated # every gate; the first failure does not stop the rest
256+
pnpm --filter @objectstack/spec check:generated --fix # regenerate ONLY the ones it proved stale
260257
```
261258

259+
`--fix` is deliberately narrow. Regenerating the whole set on principle destroys the
260+
signal: it rewrites artifacts whose staleness you never saw, so a real semantic change
261+
lands silently inside a mechanical diff. Let the check tell you which are stale, then
262+
regenerate those.
263+
264+
The script carries its own ledger of gate → generator and **reconciles it against
265+
`package.json` on every run**, in both directions. A new `check:`/`gen:` script that
266+
nobody classified fails the run rather than quietly dropping out of coverage — the
267+
failure mode a hardcoded list here would have had. (It caught its own `package.json`
268+
entry on the very first run.)
269+
262270
⚠️ **`check:api-surface` reads the built `dist/*.d.ts`, not `src/`.** A stale `dist`
263271
makes it report exports as **removed** — "N breaking (removed/narrowed)" — when nothing
264272
was removed at all: the snapshot is simply newer than your build. Rebuild before you
265273
believe it, and before you file a bug about `main` being red. (Two phantom "breaking
266-
removals" this way while writing this section.)
274+
removals" this way while writing this section; `check:generated` now prints this caveat
275+
inline when that gate is the one failing.)
267276

268277
`check:liveness`, `check:empty-state`, `check:skill-examples` and
269278
`check:react-conformance` are pure checks with no generator — a failure there is a real
270-
finding to fix, not an artifact to regenerate.
279+
finding to fix, not an artifact to regenerate. `check:generated` names them as
280+
deliberately not run, so its "all up to date" never reads as "everything passed".
281+
282+
Two generators have **no** gate at all — `gen:openapi` and `gen:sbom`. Nothing verifies
283+
their output is current; the script reports that each run rather than staying silent
284+
about it.
271285

272286
---
273287

packages/spec/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@
189189
"gen:openapi": "tsx scripts/build-openapi.ts",
190190
"gen:docs": "tsx scripts/build-docs.ts",
191191
"check:docs": "pnpm gen:schema && tsx scripts/build-docs.ts --check",
192+
"check:generated": "tsx scripts/check-generated.ts",
192193
"gen:skill-refs": "tsx scripts/build-skill-references.ts",
193194
"check:skill-refs": "tsx scripts/build-skill-references.ts --check",
194195
"gen:skill-docs": "tsx scripts/build-skill-docs.ts",
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
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

Comments
 (0)