Skip to content

Commit 59b85c0

Browse files
os-zhuangclaude
andauthored
ci(spec): run the check:generated ledger reconciliation on every PR (#4255) (#4257)
The aggregate reconciles its GATED/NO_GENERATOR ledger against package.json on every run, in both directions — but only where the aggregate runs, which was locally. CI runs the gates as individual steps, so a PR adding a check:/gen: script without classifying it kept every CI gate green while the wrapper AGENTS.md prescribes exited red on main at the reconcile stage, running zero gates. Twice in three days. #4177 added check:variant-docs and main stayed red for local wrappers until #4194 collided with the same wall (#4203). Then #4232 added check:strictness-ledger — found unclassified while wiring this fix, so the wrapper was red on main again at the moment of writing. Classified here as NO_GENERATOR: it audits the hand-written strictness ledger (docs/audits/) against the code it describes; there is no generator to name. The gate is a --reconcile-only mode (reconcile, report, exit — no gates; reads package.json only, no build, sub-second) wired into lint.yml's "TypeScript Type Check" job. Deliberately not ci.yml's check-generated job: its `generated` paths filter does not watch packages/spec/package.json — the one file every offending PR must touch — and both offenders' changed files missed every entry in that filter, so both skipped the job entirely. The typecheck job is unfiltered and required, so the meta-gate cannot go dormant — the reasoning that already placed check:docs, check:skill-refs and check:react-blocks there. Verified: --reconcile-only exits 0 on this tree (16 check: + 10 gen:, all classified) after catching #4232's script pre-classification; an injected unclassified script and a deleted ledger-named script both fail with the existing diagnostics; the default mode still reconciles and then runs the gates. AGENTS.md's wrapper section now states the CI backstop. Closes #4255 Refs #4203, #4232 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCrKazC5o5a77yXd7ykaZg
1 parent 92a5e68 commit 59b85c0

4 files changed

Lines changed: 81 additions & 1 deletion

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
`check:generated --reconcile-only` runs the package.json ↔ ledger reconciliation and
6+
nothing else, and lint.yml's unfiltered required job now runs it on every PR.
7+
8+
The aggregate already reconciled its GATED/NO_GENERATOR ledger against `package.json`
9+
on every run, in both directions, so an unclassified `check:`/`gen:` script fails the
10+
run instead of quietly dropping out of coverage. But the reconciliation only executed
11+
where the aggregate executed — locally. CI runs the gates as separate steps, so a PR
12+
adding a script without classifying it kept every CI gate green while
13+
`pnpm --filter @objectstack/spec check:generated` — the wrapper AGENTS.md prescribes
14+
before every spec push — exited at the reconcile stage on `main`, running zero gates.
15+
16+
Not hypothetical: twice in three days. #4177 added `check:variant-docs` and `main`
17+
stayed red for local wrappers until #4194 happened to collide with the same wall
18+
(#4203); #4232 added `check:strictness-ledger`, caught while wiring this very step
19+
and classified in the same change — it audits the hand-written strictness ledger
20+
against the code it describes, so NO_GENERATOR.
21+
22+
The step lives in lint.yml's "TypeScript Type Check" job rather than ci.yml's
23+
`check-generated` job because the latter is gated on a `generated` paths filter that
24+
does not watch `packages/spec/package.json` — the one file every offending PR must
25+
touch; both offenders skipped that job entirely. The typecheck job is unfiltered and
26+
required, so the meta-gate cannot go dormant — the same reasoning that already placed
27+
`check:docs`, `check:skill-refs` and `check:react-blocks` there.

.github/workflows/lint.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,23 @@ jobs:
223223
- name: Type check (@objectstack/spec)
224224
run: pnpm --filter @objectstack/spec exec tsc --noEmit
225225

226+
# Meta-gate (#4203): the `check:generated` aggregate reconciles its
227+
# GATED/NO_GENERATOR ledger against packages/spec/package.json on every run,
228+
# in both directions, so a `check:`/`gen:` script nobody classified fails
229+
# instead of quietly dropping out of coverage. But the reconciliation only
230+
# ran where the aggregate ran — locally. CI runs the gates as individual
231+
# steps, so a PR adding an unclassified script kept every CI gate green
232+
# while the wrapper AGENTS.md prescribes exited red on `main`, running zero
233+
# gates. Twice in three days: #4177 (`check:variant-docs`, fixed only when
234+
# #4194 collided with the same wall) and #4232 (`check:strictness-ledger`,
235+
# caught while wiring this step). Not in ci.yml's `check-generated` job:
236+
# its `generated` paths filter does not watch packages/spec/package.json —
237+
# the one file every offending PR must touch — so both offenders skipped
238+
# that job entirely. This job is unfiltered and required, so the meta-gate
239+
# cannot go dormant. Reads package.json only; no build, sub-second.
240+
- name: "Check every check:/gen: script is classified in the check:generated ledger"
241+
run: pnpm --filter @objectstack/spec check:generated --reconcile-only
242+
226243
# Generated-docs gate: content/docs/references/** is generated from the spec
227244
# by `gen:schema && gen:docs` and committed. Nothing regenerated it in CI, so
228245
# it drifted silently — #3076 added RowCrudActionOverride to the spec and the

AGENTS.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,10 @@ The script carries its own ledger of gate → generator and **reconciles it agai
265265
`package.json` on every run**, in both directions. A new `check:`/`gen:` script that
266266
nobody classified fails the run rather than quietly dropping out of coverage — the
267267
failure mode a hardcoded list here would have had. (It caught its own `package.json`
268-
entry on the very first run.)
268+
entry on the very first run.) CI runs the same reconciliation on every PR
269+
(`--reconcile-only`, in lint.yml's required typecheck job), so an unclassified script
270+
fails its own PR instead of landing on `main` and turning this wrapper red for
271+
everyone else — which happened twice before the CI step existed (#4203, #4232).
269272

270273
⚠️ **`check:api-surface` reads the built `dist/*.d.ts`, not `src/`.** A stale `dist`
271274
makes it report exports as **removed** — "N breaking (removed/narrowed)" — when nothing

packages/spec/scripts/check-generated.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
* Usage:
2323
* pnpm --filter @objectstack/spec check:generated # report every stale artifact
2424
* pnpm --filter @objectstack/spec check:generated --fix # + regenerate exactly those
25+
* pnpm --filter @objectstack/spec check:generated --reconcile-only # ledger audit only, no gates (CI)
2526
*/
2627

2728
import { execSync } from 'node:child_process';
@@ -67,6 +68,15 @@ const NO_GENERATOR: ReadonlyArray<{ check: string; why: string }> = [
6768
// failing on `main` itself. The doc it checks against is hand-written, so there
6869
// is no generator to name.
6970
{ check: 'check:variant-docs', why: 'audits that each schema variant appears in its hand-written doc — no artifact' },
71+
// The #4177 story again, one day after #4203 closed it: #4232 added this script
72+
// and nothing in CI runs this reconciliation, so `main` went red for every local
73+
// wrapper run a second time. Caught while wiring `--reconcile-only` into
74+
// lint.yml's unfiltered job — the fix for exactly this class. The ledger it
75+
// audits is a hand-maintained doc (docs/audits/), so there is no generator.
76+
{
77+
check: 'check:strictness-ledger',
78+
why: 'audits the hand-written strictness ledger against the code it describes — no artifact',
79+
},
7080
// The odd one out: it audits the source's TYPES, but reads them from the BUILT
7181
// `dist/*.d.ts` — the surface a consumer's import actually resolves to, which
7282
// is the only place the defect is visible (#4171). So the `readsDist` caveat
@@ -146,9 +156,32 @@ function run(script: string): { ok: boolean; output: string } {
146156
}
147157

148158
const fix = process.argv.includes('--fix');
159+
// CI mode (#4203): reconcile and stop — no gates. The reconciliation above only
160+
// ever ran where this aggregate ran, which was locally: CI runs the gates as
161+
// individual steps, so an unclassified `check:`/`gen:` script kept every CI gate
162+
// green while this wrapper exited red on `main` before running a single gate.
163+
// Twice in three days — #4177 (fixed only by colliding with #4194) and #4232
164+
// (caught wiring this flag in). ci.yml's `check-generated` job cannot host the
165+
// fix: its `generated` paths filter does not watch packages/spec/package.json,
166+
// the one file every offending PR must touch — both offenders skipped that job
167+
// entirely. So lint.yml's unfiltered, required "TypeScript Type Check" job runs
168+
// this mode instead. Reads package.json and the arrays above; no build, <1s.
169+
const reconcileOnly = process.argv.includes('--reconcile-only');
149170
const scripts = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')).scripts ?? {};
150171
reconcileLedger(scripts);
151172

173+
if (reconcileOnly) {
174+
const checks = Object.keys(scripts).filter((n) => n.startsWith('check:')).length;
175+
const gens = Object.keys(scripts).filter((n) => n.startsWith('gen:')).length;
176+
console.log(
177+
`✓ check:generated ledger reconciles with package.json: ${checks} check: + ${gens} gen: scripts, ` +
178+
`all classified (${GATED.length} gated, ${NO_GENERATOR.length} source audits, ` +
179+
`${UNGATED_GENERATORS.length} ungated generators, 1 aggregate).\n` +
180+
` --reconcile-only: no gates were run — this verifies coverage, not artifacts.`,
181+
);
182+
process.exit(0);
183+
}
184+
152185
console.log(`Checking ${GATED.length} generated artifacts (every gate runs — the first failure does not stop the rest).\n`);
153186

154187
const stale: typeof GATED[number][] = [];

0 commit comments

Comments
 (0)