Skip to content

Commit a34fd2e

Browse files
baozhoutaoclaude
andauthored
fix(spec): check:react-declaration-parity 无 MANIFEST 时报错退出,门禁台账改记「此处跑不了」(#4690) (#5959)
The gate had no path on which it could go red: it is in no workflow, and a manual run without MANIFEST printed a warning and exited 0. Measured first, before wiring anything: the manifest cannot be produced in this repo. Its only producer drives a real browser at objectui's built console (`window.__MANIFEST`); packages/console/dist/ is gitignored, build-console.sh deliberately does not dump one, and the published @objectstack/console tarball carries no sdui.manifest.json (16.1.0: 513 files, 0 `sdui` matches). Wiring a lint.yml step would have wired a permanently-skipping step. - missing / nonexistent / malformed / empty MANIFEST now exit 1 with a prescription naming the producer, independent of --strict (that flag prices a divergence; this is "no comparison happened"). - check:generated moves it from NO_GENERATOR into a new EXTERNAL_INPUT_REQUIRED bucket recording the missing input and its runner, whose honesty is enforced: reconciliation fails if gen-sdui-manifest.sh stops invoking the gate. - Tests assert EXIT CODES: a fabricated registry-only input and a vanished block each exit non-zero naming themselves; an accepted state still exits 0. Claude-Session: https://claude.ai/code/session_01559M8FVm6W6vDLABL3jvdW Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5582e18 commit a34fd2e

6 files changed

Lines changed: 377 additions & 20 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
`check:react-declaration-parity` had no path on which it could go red — a missing manifest now fails instead of skipping, and the ledger says "cannot run here" instead of "deliberately not run".
6+
7+
Two things stacked. The gate was in **no workflow** (`grep -rl check:react-declaration-parity .github/workflows/` returned nothing), and a manual run without `MANIFEST` printed `⚠ manifest unavailable … — skipping.` and **exited 0**. So the one gate whose history is written up in AGENTS.md as worth keeping — `spec-only` / `registry-only` / `missing` are real signals — could not fail for anybody, ever. Two other issues had already started citing it as the negative example of a gate that cannot be shown to catch anything (#4804 / #4777).
8+
9+
**Where the manifest comes from, measured before anything was wired.** The right-hand side is objectui's `sdui.manifest.json`, and this repository cannot produce one: its only producer drives a real browser at objectui's built console and reads `window.__MANIFEST` (the registry pulls browser-only deps, so nothing enumerates it from Node). `packages/console/dist/` is gitignored, `scripts/build-console.sh` deliberately does not dump one — it must not drag a browser into the console build — and the published `@objectstack/console` tarball contains no `sdui.manifest.json` either (16.1.0: 513 files, zero `sdui` matches; its `dist/manifest.json` is the PWA manifest, so even the CLI's `@objectstack/console/dist/sdui.manifest.json` fallback resolves to nothing). Wiring a step into `lint.yml` would therefore have wired a **permanently skipping** step — the same defect with CI decoration.
10+
11+
- **"Could not run" is now a failure, not a skip.** No `MANIFEST`, a path that does not exist, malformed JSON, or a dump declaring zero components each exit **1** with a prescription that names the producer (`pnpm sdui:manifest`, `OBJECTUI_ROOT=../objectui pnpm objectui:build`) and the browser it needs. Deliberately independent of `--strict`: that flag prices a *divergence*, and this is the other thing entirely — no comparison happened. The empty-dump case is new coverage in the same family; objectui's dumper already refuses to *write* one, and this refuses to *read* one instead of reporting every block as missing.
12+
- **The ledger stops implying someone runs it.** `check:generated` moves it out of `NO_GENERATOR` ("runnable, deliberately not run here") into a new `EXTERNAL_INPUT_REQUIRED` bucket that records the missing input and its producer, and prints `cannot run here: check:react-declaration-parity — needs MANIFEST=…; runs in scripts/gen-sdui-manifest.sh`. Following `EXPLICIT_GENERATORS` (#5358/#5807), the classification carries an enforced claim rather than a label: the reconciliation fails if `scripts/gen-sdui-manifest.sh` stops **invoking** the gate on a non-comment line — a gate filed as "runs elsewhere" while running nowhere is the exact hole this bucket exists to expose.
13+
- **The gate is now demonstrably able to fail.** `check-react-blocks-declaration-parity.test.ts` asserts exit codes, not just report text: a fabricated registry-only input and a vanished block each exit non-zero **naming themselves**, all four "could not run" paths exit non-zero, and an accepted state still exits 0 so the red is discriminating. The pre-existing helper swallowed exit codes by design, so every earlier test would have passed against a script that always exited 0 — which is what this gate was.
14+
15+
Where the manifest should come from in CI (an objectui clone plus a browser in this repo's workflows, a published manifest artifact, or a Node-side dump in objectui) stays an open provenance decision, filed separately. Until it is answered, the honest state is a gate that runs at `pnpm sdui:manifest` and refuses to pretend otherwise.

AGENTS.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -415,8 +415,8 @@ believe it, and before you file a bug about `main` being red. (Two phantom "brea
415415
removals" this way while writing this section; `check:generated` now prints this caveat
416416
inline when that gate is the one failing.)
417417

418-
`check:liveness`, `check:empty-state`, `check:skill-examples`,
419-
`check:react-declaration-parity`, `check:exported-any` and `check:dual-source-exports` are
418+
`check:liveness`, `check:empty-state`, `check:skill-examples`, `check:exported-any` and
419+
`check:dual-source-exports` are
420420
pure checks with no generator — a failure there is a real finding to fix, not an artifact
421421
to regenerate. `check:generated` names them as deliberately not run, so its "all up to
422422
date" never reads as "everything passed". The last one asks the third question about the
@@ -436,6 +436,22 @@ and #4413 shipped four dead blocks straight through a green run of it. Renamed a
436436
re-scoped in #4472. The gate is still worth having (`spec-only`, `registry-only` and
437437
`missing` are real signals) — just don't read it as proof anything renders.
438438

439+
⚠️ **It is also the one gate `check:generated` cannot run at all**, and it says so in its
440+
own bucket (`EXTERNAL_INPUT_REQUIRED`, "cannot run here") rather than beside the source
441+
audits that are merely *deliberately* not run. Its right-hand side is objectui's
442+
`sdui.manifest.json`, and nothing here can produce one: the registry is a browser app, so
443+
the manifest exists only after `pnpm sdui:manifest` builds objectui at `.objectui-sha` and
444+
enumerates it in a real browser — `packages/console/dist/` is gitignored, the console
445+
build deliberately does not dump one, and the published `@objectstack/console` carries
446+
none either. Until #4690 that combined with a manual run that printed `⚠ manifest
447+
unavailable` and **exited 0**, so no path existed on which this gate could go red; it now
448+
**exits 1** when it has no usable manifest, because "could not run" is a failure, not a
449+
skip (Route & surface ownership §3, *Absence must be loud*). Run it the one way that
450+
works: `pnpm sdui:manifest` (or `OBJECTUI_ROOT=../objectui pnpm objectui:build` first),
451+
which dumps the manifest and runs the ratchet against it. Where the manifest *should* come
452+
from in CI is an open provenance question, tracked separately — do not "fix" the red by
453+
re-adding a skip.
454+
439455
`check:exported-any` is the one of those that also reads the built `dist/*.d.ts`, so the
440456
stale-`dist` caveat above applies to it too. It asks the other half of the
441457
`api-surface.json` question: that snapshot records an export *exists*, never what it

packages/spec/scripts/check-generated-ledger.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,27 @@ describe('check:generated --reconcile-only', () => {
8484
expect(output).toContain('1 explicit manual-only generators');
8585
});
8686

87+
it('files the gate whose input this repo cannot produce as EXTERNAL_INPUT_REQUIRED (#4690)', () => {
88+
// NO_GENERATOR would say "runnable, deliberately not run here" — which is what
89+
// `check:react-declaration-parity` said while it was wired into no workflow and
90+
// skipping by default, i.e. while running nowhere at all. The classification has
91+
// to carry the two facts a reader needs instead: WHICH input is missing, and WHO
92+
// supplies it.
93+
const { status, output } = runReconcile();
94+
expect(status, output).toBe(0);
95+
expect(output).toContain('1 needing an external input');
96+
expect(output).toContain('cannot run here: check:react-declaration-parity');
97+
expect(output).toContain('MANIFEST');
98+
expect(output).toContain('scripts/gen-sdui-manifest.sh');
99+
// And the claim is not free: `runBy` must still invoke the gate. The
100+
// reconciliation fails otherwise (a gate classified as "runs elsewhere" while
101+
// running nowhere is the hole this category exists to make visible), so this
102+
// asserts the same fact where the failure message is legible.
103+
const runner = path.resolve(SPEC, '..', '..', 'scripts/gen-sdui-manifest.sh');
104+
expect(fs.existsSync(runner)).toBe(true);
105+
expect(fs.readFileSync(runner, 'utf8')).toContain('check:react-declaration-parity');
106+
});
107+
87108
it('covers the test-layer typecheck gate and its writer (#5286)', () => {
88109
// The specific pair that failed CI on this branch. Named here so a later
89110
// change that drops either script also has to come back through this file.

packages/spec/scripts/check-generated.ts

Lines changed: 89 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
*/
2828

2929
import { execSync } from 'node:child_process';
30-
import { readFileSync } from 'node:fs';
30+
import { existsSync, readFileSync } from 'node:fs';
3131
import { dirname, join } from 'node:path';
3232
import { fileURLToPath } from 'node:url';
3333

@@ -116,10 +116,6 @@ const GATED: ReadonlyArray<{
116116
const NO_GENERATOR: ReadonlyArray<{ check: string; why: string }> = [
117117
{ check: 'check:liveness', why: 'audits whether declared spec properties have a reader — no artifact' },
118118
{ check: 'check:empty-state', why: 'audits empty-state coverage — no artifact' },
119-
{
120-
check: 'check:react-declaration-parity',
121-
why: 'compares the spec schema props against the registry-declared inputs — two declarations, no artifact (and no renderer: #4472)',
122-
},
123119
{ check: 'check:skill-examples', why: 'validates skill examples parse — no artifact' },
124120
// Landed in #4177 while this ledger landed in #4183 — neither PR could see the
125121
// other, so `main` carried an unclassified script and this reconciliation was
@@ -152,6 +148,47 @@ const NO_GENERATOR: ReadonlyArray<{ check: string; why: string }> = [
152148
},
153149
];
154150

151+
/**
152+
* Source audits that CANNOT RUN from this repository at all, because the input
153+
* they compare against does not exist here and cannot be produced here.
154+
*
155+
* A separate bucket from `NO_GENERATOR` because the two say different things to a
156+
* reader, and #4690 is what conflating them cost. `NO_GENERATOR` means "runnable,
157+
* deliberately not run in this aggregate — run it yourself and it will answer".
158+
* This one means "you cannot run it here at all, and here is the input it wants
159+
* and who produces it". Sitting in the first list, `check:react-declaration-parity`
160+
* read as the former for the entire time it was the latter: it was wired into no
161+
* workflow, and a manual run without `MANIFEST` printed a `⚠` and exited 0, so no
162+
* path existed on which the gate could go red. Whoever read "deliberately not run"
163+
* reasonably assumed someone, somewhere, was running it.
164+
*
165+
* Encoding WHY in the ledger follows EXPLICIT_GENERATORS (#5807/#5358): a
166+
* classification that records only a name is a classification the next reader has
167+
* to re-derive. `runBy` is what keeps this bucket honest rather than an escape
168+
* hatch — it names the in-repo entry point that DOES run the gate with its input,
169+
* and `reconcileLedger` fails if that file has stopped naming the check. "Cannot
170+
* run here" is a statement about this aggregate; "runs nowhere" would be the defect
171+
* this category is supposed to make visible, not hide.
172+
*/
173+
const EXTERNAL_INPUT_REQUIRED: ReadonlyArray<{
174+
check: string;
175+
input: string;
176+
runBy: string;
177+
why: string;
178+
}> = [
179+
{
180+
check: 'check:react-declaration-parity',
181+
input: 'MANIFEST=<sdui.manifest.json> — objectui\'s registry-inputs dump',
182+
runBy: 'scripts/gen-sdui-manifest.sh',
183+
why:
184+
'compares the spec schema props against the registry-declared inputs (two declarations, no renderer: #4472). ' +
185+
'The registry is a browser app, so its manifest exists only after objectui is built at .objectui-sha and ' +
186+
'enumerated in a real browser — nothing in this repo (console dist is gitignored, the published console ships ' +
187+
'no sdui.manifest.json) can hand it one. `pnpm sdui:manifest` produces it and runs the ratchet; without it the ' +
188+
'gate now exits 1 rather than skipping (#4690)',
189+
},
190+
];
191+
155192
/**
156193
* Generators whose output NOTHING verifies. Recorded rather than ignored: each
157194
* one is an artifact that can silently drift from its source, which is the class
@@ -203,7 +240,11 @@ const SELF = 'check:generated';
203240
*/
204241
function reconcileLedger(scripts: Record<string, string>): void {
205242
const problems: string[] = [];
206-
const declaredChecks = new Set([...GATED.map((g) => g.check), ...NO_GENERATOR.map((n) => n.check)]);
243+
const declaredChecks = new Set([
244+
...GATED.map((g) => g.check),
245+
...NO_GENERATOR.map((n) => n.check),
246+
...EXTERNAL_INPUT_REQUIRED.map((e) => e.check),
247+
]);
207248
const declaredGens = new Set([
208249
...GATED.map((g) => g.gen),
209250
...UNGATED_GENERATORS.map((u) => u.gen),
@@ -213,8 +254,9 @@ function reconcileLedger(scripts: Record<string, string>): void {
213254
for (const name of Object.keys(scripts)) {
214255
if (name === SELF) continue;
215256
if (name.startsWith('check:') && !declaredChecks.has(name)) {
216-
problems.push(` \`${name}\` exists in package.json but is in neither GATED nor NO_GENERATOR.\n` +
217-
` Classify it: does it compare a checked-in artifact against a generator, or audit source?`);
257+
problems.push(` \`${name}\` exists in package.json but is in neither GATED nor NO_GENERATOR (nor EXTERNAL_INPUT_REQUIRED).\n` +
258+
` Classify it: does it compare a checked-in artifact against a generator, audit source,\n` +
259+
` or audit source against an input this repo cannot produce (name where it DOES run)?`);
218260
}
219261
if (name.startsWith('gen:') && !declaredGens.has(name)) {
220262
problems.push(` \`${name}\` exists in package.json but no GATED entry names it and it is not in UNGATED_GENERATORS.\n` +
@@ -226,6 +268,30 @@ function reconcileLedger(scripts: Record<string, string>): void {
226268
for (const { check } of GATED) if (!scripts[check]) problems.push(` GATED names \`${check}\`, which package.json no longer has.`);
227269
for (const { gen } of GATED) if (!scripts[gen]) problems.push(` GATED names \`${gen}\`, which package.json no longer has.`);
228270
for (const { check } of NO_GENERATOR) if (!scripts[check]) problems.push(` NO_GENERATOR names \`${check}\`, which package.json no longer has.`);
271+
for (const { check, runBy } of EXTERNAL_INPUT_REQUIRED) {
272+
if (!scripts[check]) problems.push(` EXTERNAL_INPUT_REQUIRED names \`${check}\`, which package.json no longer has.`);
273+
// The claim that makes this category honest rather than an escape hatch: the
274+
// gate cannot run HERE, but it does run SOMEWHERE, and that somewhere is a file
275+
// in this repo that still invokes it. A `runBy` that has stopped naming the
276+
// check is #4690 all over again — a gate classified as "runs elsewhere" while
277+
// running nowhere.
278+
const runner = join(pkgRoot, '..', '..', runBy);
279+
// Named on a line that RUNS it, not merely one that talks about it: these
280+
// runners are shell scripts whose comments discuss the gate at length, and a
281+
// surviving comment is exactly the evidence a deleted invocation leaves behind.
282+
const invokes = existsSync(runner) &&
283+
readFileSync(runner, 'utf8')
284+
.split('\n')
285+
.some((line) => line.includes(check) && !line.trim().startsWith('#'));
286+
if (!existsSync(runner)) {
287+
problems.push(` EXTERNAL_INPUT_REQUIRED says \`${check}\` runs via \`${runBy}\`, which does not exist.`);
288+
} else if (!invokes) {
289+
problems.push(
290+
` EXTERNAL_INPUT_REQUIRED says \`${check}\` runs via \`${runBy}\`, which no longer invokes it.\n` +
291+
` Either restore the call or reclassify: a gate that runs nowhere is the hole this category records (#4690).`,
292+
);
293+
}
294+
}
229295
for (const { gen } of UNGATED_GENERATORS) if (!scripts[gen]) problems.push(` UNGATED_GENERATORS names \`${gen}\`, which package.json no longer has.`);
230296
for (const { gen, gatedBy } of EXPLICIT_GENERATORS) {
231297
if (!scripts[gen]) problems.push(` EXPLICIT_GENERATORS names \`${gen}\`, which package.json no longer has.`);
@@ -277,8 +343,14 @@ if (reconcileOnly) {
277343
console.log(
278344
`✓ check:generated ledger reconciles with package.json: ${checks} check: + ${gens} gen: scripts, ` +
279345
`all classified (${GATED.length} gated, ${NO_GENERATOR.length} source audits, ` +
346+
`${EXTERNAL_INPUT_REQUIRED.length} needing an external input, ` +
280347
`${UNGATED_GENERATORS.length} ungated generators, ${EXPLICIT_GENERATORS.length} explicit ` +
281348
`manual-only generators, 1 aggregate).\n` +
349+
// Named, not just counted: this bucket's whole reason for existing is that a
350+
// bare count is what let #4690 read as "someone runs it".
351+
EXTERNAL_INPUT_REQUIRED.map(
352+
(e) => ` ⚠ cannot run here: ${e.check} — needs ${e.input}; runs in ${e.runBy}.\n`,
353+
).join('') +
282354
` --reconcile-only: no gates were run — this verifies coverage, not artifacts.`,
283355
);
284356
process.exit(0);
@@ -306,6 +378,15 @@ for (const entry of GATED) {
306378
// Narrowing is never silent: say what was deliberately not run.
307379
console.log(`\nNot run here (${NO_GENERATOR.length} source audits with no artifact to regenerate): ` +
308380
NO_GENERATOR.map((n) => n.check).join(', '));
381+
// Narrowing is never silent, part three — and this one is a different sentence:
382+
// "deliberately not run" invites the reader to run it, which for these is not an
383+
// option from this repo. Say what the missing input is and who supplies it.
384+
if (EXTERNAL_INPUT_REQUIRED.length) {
385+
console.log(`Cannot run here (${EXTERNAL_INPUT_REQUIRED.length} source audit(s) whose input this repo cannot produce):`);
386+
for (const e of EXTERNAL_INPUT_REQUIRED) {
387+
console.log(` ${e.check} — needs ${e.input}\n runs in ${e.runBy}; ${e.why}`);
388+
}
389+
}
309390
if (UNGATED_GENERATORS.length) {
310391
console.log(`Generated but ungated (${UNGATED_GENERATORS.length}): ` +
311392
UNGATED_GENERATORS.map((u) => u.gen).join(', ') + ' — nothing verifies these are current.');

0 commit comments

Comments
 (0)