Skip to content

Commit 9ddaa06

Browse files
fix(devx): make the build stamp answer for an artifact, not a directory (#18347)
Part of #16529 Clause-②: no Segment 1 only — the `limit 2` question. ⛔ `AMPLIFIERS` is not extended and no cross-repo format is declared or documented, per the triage ruling quoted below. ## The question, and the answer > **先答 limit 2**:一个短路的构建(没有产出新字节)是否仍写戳记? **Yes — measured, on the real tree.** But the card's wording locates it one step off, and the correction is the useful part: the stamp is not written by "a build that short-circuited". It is written by **any run that reaches `--stamp`**, because `--stamp` is an *assertion* about the tree, not an *observation* of the build. Its only preconditions were "this package is a declared amplifier" and "a `dist/` **directory** exists". ## Every stamp × when it is written × who reads it Readings taken in this worktree at `500c1b569` (branch base), `packages/spec` built from that tree. | stamp | writer | written when | readers | |---|---|---|---| | `dist/.build-input-hash` | `--stamp` in `scripts/check-dev-prereqs.mjs` | **any** reaching run: amplifier + `dist/` dir exists. `OS_SKIP_DTS=1` included | this gate's freshness half (`pnpm dev`); `bundlesAreStale` via `inspectBuildStamp`; `check-browser-reachable-entries.ts` | | `dist/.build-input-hash-dts` | same `--stamp` | same, **minus** `OS_SKIP_DTS=1` | `distIsStale` via `inspectDeclarationStamp` (`check-generated.ts`, `check-exported-any-returns.mts`, `build-api-surface.ts`) | | `json-schema/.build-input-hash-schema` | `writeSchemaStamp` at the END of `packages/spec/scripts/build-schemas.ts` | only by a generation that emitted the tree it sits in, and refuses when that tree is absent | `schemaTreeIsStale` via `inspectSchemaStamp`; `build-docs.ts` | ⭐ **The third row is the control that makes the first two legible.** Same instrument, same tree, same moment: ``` --- after a source edit, before any stamping --- dist/.build-input-hash mismatch recorded 54998ed1b46db8fd actual f26c8c4eb9a43e20 dist/.build-input-hash-dts mismatch recorded 54998ed1b46db8fd actual f26c8c4eb9a43e20 json-schema/...-hash-schema mismatch recorded 54998ed1b46db8fd actual f26c8c4eb9a43e20 dist BYTES digest 01786aeae15dbe9b --- the short-circuited "build": `--stamp` alone, zero bytes emitted --- ✓ packages/spec/dist/.build-input-hash ← f26c8c4eb9a43e20… ✓ packages/spec/dist/.build-input-hash-dts ← f26c8c4eb9a43e20… exit 0 --- after --- dist/.build-input-hash match recorded f26c8c4eb9a43e20 actual f26c8c4eb9a43e20 dist/.build-input-hash-dts match recorded f26c8c4eb9a43e20 actual f26c8c4eb9a43e20 json-schema/...-hash-schema mismatch recorded 54998ed1b46db8fd actual f26c8c4eb9a43e20 dist BYTES digest 01786aeae15dbe9b ← unchanged ``` Both `dist/` stamps flipped to `match` over a dist whose bytes did not move. The schema stamp did not, because its writer is co-located with the tree it speaks for — which is the repository's own recorded soundness argument for it, in `scripts/build-input-hash.mjs`. **Three further readings, each correcting something that would otherwise be assumed:** 1. ⚠️ **The `-dts` sibling does not already cover this.** Its conditionality is on the `OS_SKIP_DTS` **flag**, not on the **emit**: with the flag unset, a zero-emit run refreshes it too (above). With the flag set, it correctly stays put while `.build-input-hash` still moves. So `limit 2` was **not** partly solved by #14985 for this shape. 2. ⭐ **`--stamp` into an EMPTY `dist/` exited 0 and wrote both stamps.** The refusal guarding it says *"there is no build to stamp"* while checking that a **directory** exists. Declared is not enforced. 3. **A turbo cache hit is none of the above** — the build script does not run, `--stamp` does not run, and `dist/**` (stamp included, which is why it lives there) is restored as one set, so the pair stays consistent. ⚠️ The replayed log still prints the `✓ …/.build-input-hash ← …` line from the cached run, so **a build log is not evidence that a stamp was written**. ## The fix Two halves, both spelling out "构建真的产出时才写". ⛔ No mtime criterion enters either. - **`--stamp` observes an artifact, not a directory.** It now requires the entry point the package's own manifest declares under `dist/` — the exact criterion the EXISTENCE half already applies to all 68 packages, and `scripts/check-dts-emitted.mjs`'s criterion one artifact over. A build does not get to record that it emitted over output it did not emit. - **The ordering claim becomes mechanical.** `inspectBuildStamp`'s docblock rests the whole scheme on the build script's ORDER: *"`tsup` … before `--stamp` in the same `&&` chain, so this file is never written by a run that did not emit bundles."* The coverage check was `buildScript.includes(...)` — a substring test satisfied by `tsup ; node …--stamp` (stamps after a FAILED tsup), `tsup || node …--stamp` (stamps only when tsup failed) and `node …--stamp && tsup` (stamps before anything is emitted). It now requires the invocation to be the LAST step, reached through `&&`. Its own failure text already claimed "ends with"; now the code checks it. ⭐ The second half is why the triage put `limit 2` first: extending `AMPLIFIERS` means N more hand-written build scripts, each of which has to be spelled in the one order that makes its stamp true. A convention does not survive being copied 60 times. **⛔ Not taken, and why — "carry 'did this run rebuild' in the stamp".** As a REFUSAL it cannot be built: an idempotent rebuild legitimately emits byte-identical output, so "refuse when the output bytes did not change" would red the very build this gate exists to ask for. As a RECORD it is a new stamp-format field whose only reader would be the cross-repo consumer of segment 2 — which this PR is forbidden to design. The measurement is recorded in the header so segment 2 does not have to re-derive it. **Residue, stated rather than left to be rediscovered.** A HAND-RUN `--stamp` against an already-built dist whose sources have since moved still writes a stamp that reads fresh. That is not observable from the artifact side — the bytes it would inspect are real, merely old — so it is the same class as the hand-edited dist the header already excludes, not a build shape. ⚠️ **This is an input to segment 2's decision**: every scripted path to a stamp is now one that emitted, but a stamp is still a claim, and a cross-repo consumer would be trusting it further than `pnpm dev` does. ## ⛔ The framing that is wrong, recorded so nobody proposes it a third time > "Stamp the source SHA into the build output." Rejected by the card's author and again by triage. A SHA sounds more precise, but it answers **"where is HEAD"** — exactly the half `objectstack-ai/cloud` already built and whose own docblock records as insufficient: a sibling sitting **exactly on the pin** whose `dist/` was built from an earlier commit produces an identical `TS2305`, and the HEAD comparison is silent through all of it. **A more precise answer to the wrong question.** ## Verification - **Red leg** (the exact shape measured before the change): `--stamp` into an empty `packages/spec/dist/` — **was exit 0 with both stamps written**, now `exit 1`, and `dist/` holds **0 entries** afterwards. A refusal that still stamps is not a refusal. - **Green leg**: same call over the dist that was actually emitted — `exit 0`, all three stamps `match`, dist bytes unchanged. - **Negative control, both directions kept** (triage criterion 5): `--stamp` from an UNLISTED package (`packages/core`) still exits 1; a listed amplifier whose build script stops stamping still throws `CoverageError`. "No `dist/` at all" keeps its own distinct wording. - **Ablation** — the new ordering check restored to the old `includes()` semantics: self-test `exit 1`, **6 of battery 19's 8 cases fail** (`order/semicolon-stamps-after-failure`, `order/or-stamps-only-on-failure`, `order/pipe-is-not-a-chain`, `order/stamp-before-the-emit`, `order/stamp-is-the-whole-build`, `order/names-the-separator`), while the two green-leg cases stay green — the correct asymmetry, since the old semantics accepted the good spelling too. Restored byte-identical (`git hash-object` equals the HEAD blob, `git diff HEAD` empty). No build or `dist/` is involved: `node` reads this script from source. - `node scripts/check-dev-prereqs.mjs --self-test` — **19 batteries, 76 cases**, exit 0. - `pnpm lint` (repo-wide, `--no-inline-config`) — exit 0 at `3e4758d4b`. - `dispatch-gates --ran` — **32 derived, 32 run, 0 NOT-MEASURED, 0 UNRUN**, every family carrying its recorded exit code. - `pnpm --filter @objectstack/spec exec vitest run scripts/dist-freshness.test.ts scripts/schema-tree-freshness.test.ts` — 2 files, 31 tests, passed. ### One red in the gate sweep, and it is not this diff `pnpm check:cross-package-test-inputs` exits 1 here. Three-leg control on a **pristine** `origin/main` worktree at `500c1b569` with `git status` empty and zero source changes: ``` node scripts/check-cross-package-test-inputs.mjs → exit 0 mkdir -p packages/spec/dist ; same command → exit 1 (FAIL: packages/spec/dist/ — no declared glob reaches inside it) rm -rf packages/spec/dist ; same command → exit 0 ``` ⇒ The only variable is whether the gitignored `packages/spec/dist/` **exists**. The gate reds on any built worktree and greens on an unbuilt one, independent of this branch. Filed separately; ⛔ not fixed here. ## Changeset `skip-changeset`, measured rather than assumed. The one changed path is `scripts/check-dev-prereqs.mjs`, which lives at the repo root inside `@objectstack/spec-monorepo` (`private: true`), and `find packages -name check-dev-prereqs.mjs` returns nothing — no package `files[]` entry, which is package-relative, can reach it. Symbol sweep over everything `packages/spec` actually ships (`dist`, `json-schema`, `liveness`, `prompts`, `llms.txt`, `README.md`, `api-surface`, `spec-changes.json`): `check-dev-prereqs` **0**, `stampStepOrderProblem` **0**, `STAMP_INVOCATION` **0**, against a positive control (`MetadataTypeSchema`) hitting **44** files — so the instrument is live, not dead. ⚠️ For the record: `packages/spec`'s published tarball does contain `dist/.build-input-hash`. This PR changes **when the writer refuses**, not the file's format or content, and a published build always emits — so no shipped byte moves. ## 验收备注 Triage's criterion 1, quoted verbatim: > **先答 limit 2**:一个短路的构建(没有产出新字节)是否仍写戳记?给出读数,并说明修法(构建真的产出时才写 / 戳记里带上"本次是否重建")。⛔ 在这条落定前不要扩 `AMPLIFIERS`、不要暴露跨仓格式。 Also held: criterion 5 (both directions of the coverage gate, above) and criterion 6 (⛔ no mtime criterion — both halves read content and directory entries only; the family's shared criterion, content not mtime, is untouched). Criteria 2, 3 and 7 are segments 2 and 3 and are ⛔ **not** addressed here — they remain open, per the triage ordering and the seat's finding that declaring a cross-repo format is a new public surface needing the maintainer's word. **Noted, not filed:** the self-test pass line carried `(17 cases)` as a literal while its roster had grown past it; corrected in this PR to derive from the roster, since it is the same "the text promises what the code does not check" shape being repaired one function over. Carrier: this PR. --- _Generated by [Claude Code](https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ceb6b5f commit 9ddaa06

1 file changed

Lines changed: 199 additions & 9 deletions

File tree

scripts/check-dev-prereqs.mjs

Lines changed: 199 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,37 @@
136136
* `dist/.build-input-hash-dts`, which it skips under this flag exactly so
137137
* that a `.d.ts` reader can tell the two builds apart. Nothing here reads
138138
* it; see DTS_STAMP_BASENAME and `declarationStampState` below.
139+
* ⚠ That conditionality is on the FLAG, not on the EMIT — measured: a run
140+
* that emitted nothing at all, with the flag unset, refreshes BOTH files.
141+
* The entry below is the one that answers that shape.
142+
* - A RUN THAT EMITTED NOTHING. NARROWED, and the residue is stated rather
143+
* than inherited (#16529). `--stamp` is an ASSERTION about the tree, not
144+
* an OBSERVATION of the build, and its preconditions were "this package is
145+
* an amplifier" and "a `dist/` DIRECTORY exists". Measured on the real
146+
* tree: `--stamp` into an EMPTY `dist/` exited 0 and wrote both stamps,
147+
* which then read `match` over a dist holding nothing at all. It now also
148+
* requires the entry point the package's own manifest declares — the same
149+
* criterion the EXISTENCE half applies to all 68 packages — and the
150+
* coverage check below requires the invocation to be the build script's
151+
* LAST step, reached through `&&`, so every SCRIPTED path to a stamp runs
152+
* after a step that emitted and succeeded.
153+
* WHAT REMAINS, deliberately: a HAND-RUN `--stamp` against an
154+
* already-built dist whose sources have since moved still writes a stamp
155+
* that reads fresh. That is not observable from the artifact side — the
156+
* bytes it would inspect are real, merely old — so it is the same class as
157+
* the hand-edited dist below rather than a build shape. ⛔ Nor is it
158+
* closed by "refuse when the output bytes did not change": an idempotent
159+
* rebuild legitimately emits byte-identical output, so that rule would red
160+
* the very build this gate exists to ask for.
139161
* - A HAND-EDITED dist. The hash covers inputs, not outputs. Nothing here
140162
* can see someone editing `dist/index.mjs` directly, and nothing should
141163
* have to.
164+
* - A TURBO CACHE HIT is NONE of the above, measured rather than assumed:
165+
* the build script does not run, so `--stamp` does not run either, and
166+
* `dist/**` — the stamp included, which is why it lives there — is
167+
* restored as one set, so the pair stays consistent. ⚠ The replayed log
168+
* still PRINTS the `✓ …/.build-input-hash ← …` line from the cached run,
169+
* so a build log is never evidence that a stamp was written.
142170
*
143171
* FALSE RED — says stale, is fine:
144172
* - A COMMENT-ONLY or formatting-only edit under `src/` changes the hash
@@ -178,6 +206,13 @@
178206
* at the end of its build script — and NEITHER half can be forgotten, because
179207
* a listed package whose build script does not stamp fails this gate as a
180208
* coverage error, and `--stamp` from an unlisted package exits 1.
209+
* ⚠ "At the end" is now MECHANICAL and not a convention (#16529). The
210+
* coverage error used to be `buildScript.includes(STAMP_INVOCATION)` while its
211+
* own text said "no longer ends with" — so `tsup ; node …--stamp` (stamps
212+
* after a FAILED tsup), `tsup || node …--stamp` (stamps only when tsup failed)
213+
* and `node …--stamp && tsup` (stamps before anything is emitted) all passed.
214+
* That laxity is exactly what scaling this list multiplies, which is why it is
215+
* closed BEFORE the list grows: see `stampStepOrderProblem`.
181216
*
182217
* ── WHAT IT DELIBERATELY DOES NOT CHECK ─────────────────────────────────────
183218
* - Freshness of the other ~60 packages. Existence only, as before; the pass
@@ -217,7 +252,9 @@
217252
* node scripts/check-dev-prereqs.mjs # gate the workspace
218253
* node scripts/check-dev-prereqs.mjs --self-test # prove it can go both ways
219254
* node scripts/check-dev-prereqs.mjs --stamp # write dist/.build-input-hash
220-
* # for the package in cwd
255+
* # for the package in cwd —
256+
* # the LAST '&&' step of its
257+
* # own build, never by hand
221258
* pnpm check:dev-prereqs # self-test, then gate
222259
*
223260
* WHY THE `dev` CHAIN CALLS THIS WITH `node` AND NOT `pnpm check:dev-prereqs`
@@ -242,7 +279,10 @@
242279
* a dist entry point — nothing to verify)
243280
* 1 not built; or an amplifier's dist is stale/unstamped; or the workspace
244281
* layout, an amplifier or its build inputs could not be read (a gate that
245-
* cannot enumerate members must fail loudly, not pass vacuously — #4690)
282+
* cannot enumerate members must fail loudly, not pass vacuously — #4690);
283+
* or, under `--stamp`, this package is not a declared amplifier, has no
284+
* `dist/`, or has a `dist/` without the artifact its manifest declares —
285+
* a build that emitted nothing does not get to record that it did
246286
*/
247287
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
248288
import { tmpdir } from 'node:os';
@@ -298,11 +338,13 @@ const SELF_TEST_BATTERIES = Object.freeze({
298338
'15. The hash reads the inputs it claims to. A global build input (from': 3,
299339
'16. Existence outranks freshness: a workspace that is not built reports': 4,
300340
'17. The DECLARATIONS stamp (#14985), whose only job is to be written by a': 9,
341+
'18. --stamp vouches for an ARTIFACT, not for a directory (#16529). The': 8,
342+
"19. The ORDER the stamp's soundness rests on is now mechanical (#16529).": 8,
301343
});
302344

303345
// DELETING an entry silences that battery's floor exactly as effectively as
304346
// zeroing it, so the roster's own size is pinned too.
305-
const SELF_TEST_BATTERY_FLOOR = 17;
347+
const SELF_TEST_BATTERY_FLOOR = 19;
306348

307349
// The key an assertion is filed under when no battery is open. It is not a
308350
// declared battery, so it reds by the same set difference rather than silently
@@ -322,9 +364,52 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
322364
*/
323365
const AMPLIFIERS = ['packages/spec'];
324366

325-
/** What an amplifier's build script must contain for its stamp to be maintained. */
367+
/** What an amplifier's build script must END WITH for its stamp to be maintained. */
326368
const STAMP_INVOCATION = 'check-dev-prereqs.mjs --stamp';
327369

370+
/**
371+
* Is `--stamp` reachable ONLY from a build that got all the way to the end?
372+
*
373+
* This predicate mechanises a sentence the whole scheme rests on, written in
374+
* `inspectBuildStamp`'s docblock in scripts/build-input-hash.mjs:
375+
*
376+
* "What makes the vouching sound is the build script's ORDER, not the flag:
377+
* `packages/spec`'s `build` runs the unconditional `tsup` (the JS pass)
378+
* before `--stamp` in the same `&&` chain, so this file is never written by
379+
* a run that did not emit bundles."
380+
*
381+
* That sentence was true of the one amplifier's spelling and enforced by
382+
* NOTHING: the coverage check below was `buildScript.includes(...)`, a substring
383+
* test that three lying spellings satisfy —
384+
*
385+
* `tsup ; node …--stamp` stamps after a tsup that FAILED;
386+
* `tsup || node …--stamp` stamps ONLY when tsup failed;
387+
* `node …--stamp && tsup` stamps BEFORE anything is emitted.
388+
*
389+
* Its own failure text already claimed "no longer ends with", so this is the
390+
* declared-equals-enforced repair of a message that promised more than the code
391+
* checked. It is also what makes GROWING `AMPLIFIERS` safe: every new entry is
392+
* another hand-written build script that has to be spelled in the one order that
393+
* makes its stamp true, and a convention does not survive being copied 60 times.
394+
*
395+
* Returns `null` when the spelling is sound, else the reason it is not.
396+
*/
397+
function stampStepOrderProblem(buildScript) {
398+
const script = buildScript.trimEnd().replace(/;+$/, '').trimEnd();
399+
if (!script.endsWith(STAMP_INVOCATION)) {
400+
return script.includes(STAMP_INVOCATION)
401+
? `'${STAMP_INVOCATION}' is not its LAST step — a stamp written mid-chain vouches for output the steps after it have not emitted yet`
402+
: `'${STAMP_INVOCATION}' does not appear in it at all`;
403+
}
404+
// The separator that introduces the stamp step, read off the text before it.
405+
const head = script.slice(0, script.length - STAMP_INVOCATION.length);
406+
let separator = null;
407+
for (const m of head.matchAll(/\|\||&&|;|\|/g)) separator = m[0];
408+
if (separator === null) return `nothing runs before it — a build whose only step is the stamp has emitted nothing to vouch for`;
409+
if (separator !== '&&') return `the step before it is joined by '${separator}', not '&&' — so the stamp is written even when that step failed`;
410+
return null;
411+
}
412+
328413
/**
329414
* Workspace member directories, from pnpm-workspace.yaml — the workspace's own
330415
* declaration of what it contains.
@@ -390,11 +475,15 @@ function inspectFreshness(root, amplifiers, memberDirs) {
390475
throw new CoverageError(`${relDir}/package.json is not readable as JSON (${err.message}) — cannot judge the freshness of its dist.`);
391476
}
392477
const buildScript = typeof pkg.scripts?.build === 'string' ? pkg.scripts.build : '';
393-
if (!buildScript.includes(STAMP_INVOCATION)) {
478+
const orderProblem = stampStepOrderProblem(buildScript);
479+
if (orderProblem !== null) {
394480
throw new CoverageError(
395-
`${relDir} is a declared amplifier but its build script no longer ends with '${STAMP_INVOCATION}'.\n` +
396-
` Nothing would write ${relDir}/dist/${STAMP_BASENAME}, so this check would pass on any dist,\n` +
397-
` however old. Restore the stamp step, or drop ${relDir} from AMPLIFIERS on purpose.`,
481+
`${relDir} is a declared amplifier but its build script does not end with '${STAMP_INVOCATION}':\n` +
482+
` ${orderProblem}.\n` +
483+
` ${relDir}/dist/${STAMP_BASENAME} is only evidence because the steps that EMIT the dist run\n` +
484+
` before it in the same '&&' chain — a stamp reached any other way vouches for a dist this\n` +
485+
` build did not produce, and this check would then pass on any dist, however old.\n` +
486+
` Restore the stamp as the last '&&'-joined step, or drop ${relDir} from AMPLIFIERS on purpose.`,
398487
);
399488
}
400489

@@ -538,6 +627,41 @@ function stamp(root, cwd, amplifiers = AMPLIFIERS) {
538627
console.error(`\n✗ ${relDir}/dist does not exist, so there is no build to stamp. --stamp runs as the LAST step of the build, not before it.\n`);
539628
return 1;
540629
}
630+
631+
// "There is no build to stamp" is what the refusal above SAYS. What it used
632+
// to check is that a DIRECTORY exists — and an empty `dist/` satisfies that,
633+
// measured: `--stamp` into an empty directory exited 0 and wrote both stamps,
634+
// which then read FRESH to every consumer over a dist holding nothing at all.
635+
// So the same criterion the EXISTENCE half applies to all 68 packages is
636+
// applied here to the one package about to make a freshness claim: the entry
637+
// point this package's OWN manifest promises has to be on disk. Same shape as
638+
// scripts/check-dts-emitted.mjs one artifact over — a build does not get to
639+
// report success, or to stamp, over output it did not emit.
640+
//
641+
// ⛔ NOT an mtime comparison, and deliberately not a "did the bytes change"
642+
// one either: an idempotent rebuild legitimately emits byte-identical output,
643+
// so refusing on unchanged bytes would red the build this gate exists to ask
644+
// for. Presence of the declared artifact is the observation available here.
645+
let pkg;
646+
try {
647+
pkg = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf-8'));
648+
} catch (err) {
649+
console.error(`\n✗ ${relDir}/package.json is not readable (${err.message}), so nothing here can say which artifact a stamp would vouch for.\n`);
650+
return 1;
651+
}
652+
const entry = declaredEntry(pkg);
653+
const entryPath = entry.replace(/^\.\//, '');
654+
if (isBuildArtifact(entry) && !existsSync(path.join(dir, entryPath))) {
655+
console.error(
656+
`\n✗ ${relDir}/${entryPath} is not on disk, so this run emitted nothing for a stamp to vouch for.\n\n` +
657+
` --stamp records "this dist was built from these sources". A dist without the entry point\n` +
658+
` ${relDir}'s own manifest declares is not a build, and ${STAMP_BASENAME} written over it\n` +
659+
` reads FRESH to every consumer of it.\n\n` +
660+
` Fix:\n\n pnpm --filter ${pkg.name || relDir} build\n`,
661+
);
662+
return 1;
663+
}
664+
541665
const hash = buildInputHash(root, dir);
542666
writeFileSync(path.join(dist, STAMP_BASENAME), `${hash}\n`);
543667
console.log(`✓ ${relDir}/dist/${STAMP_BASENAME}${hash.slice(0, 16)}…`);
@@ -639,6 +763,15 @@ function selfTest() {
639763
return err instanceof CoverageError ? 'CoverageError' : 'other';
640764
}
641765
};
766+
/** The refusal's own TEXT, for cases that pin what a developer is told to fix. */
767+
const threwCoverageMessage = (fn) => {
768+
try {
769+
fn();
770+
return '(nothing was thrown)';
771+
} catch (err) {
772+
return err instanceof CoverageError ? err.message : `(not a CoverageError: ${err.message})`;
773+
}
774+
};
642775

643776
try {
644777
// 1. Built workspace → green, and the count reflects what was inspected.
@@ -870,6 +1003,57 @@ function selfTest() {
8701003
expect('dts/absent-computes-nothing', inspectDeclarationStamp(noDts, noDtsSpec).actual, null);
8711004
write(noDts, 'packages/spec/dist/' + DTS_STAMP_BASENAME, 'not-a-hash\n');
8721005
expect('dts/garbled-is-unstamped', inspectDeclarationStamp(noDts, noDtsSpec).state, 'unstamped');
1006+
1007+
// 18. --stamp vouches for an ARTIFACT, not for a directory (#16529). The
1008+
// refusal above it has always SAID "there is no build to stamp" while
1009+
// checking that `dist/` exists — and an empty directory passes that,
1010+
// measured on the real tree: exit 0, both stamps written, every consumer
1011+
// reading FRESH over a dist holding nothing. Both legs, because a red
1012+
// that is never seen green is a gate nobody can trust and vice versa.
1013+
battery('18. --stamp vouches for an ARTIFACT, not for a directory (#16529). The');
1014+
const emptyDist = amplifierFixture('empty-dist');
1015+
const emptyDistSpec = path.join(emptyDist, 'packages/spec');
1016+
rmSync(path.join(emptyDistSpec, 'dist/index.js'), { force: true });
1017+
const emptyRed = capture(() => stamp(emptyDist, emptyDistSpec, ['packages/spec']));
1018+
expect('emit/empty-dist-refuses', emptyRed.code, 1);
1019+
expect('emit/empty-dist-names-the-artifact', emptyRed.text.includes('packages/spec/dist/index.js'), true);
1020+
expect('emit/empty-dist-one-fix', (emptyRed.text.match(/ build\n/g) || []).length, 1);
1021+
// The load-bearing half of the red leg: it refused BEFORE writing anything.
1022+
// A refusal that still leaves the stamp behind is not a refusal at all.
1023+
expect('emit/empty-dist-wrote-no-stamp', existsSync(path.join(emptyDistSpec, 'dist', STAMP_BASENAME)), false);
1024+
expect('emit/empty-dist-wrote-no-dts-stamp', existsSync(path.join(emptyDistSpec, 'dist', DTS_STAMP_BASENAME)), false);
1025+
// …and it is a DIFFERENT refusal from "no dist at all", which keeps its own
1026+
// wording — one precondition, one fix, and the developer reads which.
1027+
expect('emit/distinct-from-absent-dist', emptyRed.text.includes('does not exist, so there is no build to stamp'), false);
1028+
// GREEN LEG: put the emitted artifact back and the same call stamps.
1029+
write(emptyDist, 'packages/spec/dist/index.js', 'module.exports = {};');
1030+
expect('emit/emitted-dist-stamps', capture(() => stamp(emptyDist, emptyDistSpec, ['packages/spec'])).code, 0);
1031+
expect('emit/emitted-dist-is-fresh', inspect(emptyDist, ['packages/spec']).freshness[0]?.state, 'fresh');
1032+
1033+
// 19. The ORDER the stamp's soundness rests on is now mechanical (#16529).
1034+
// `inspectBuildStamp`'s docblock says the vouching is sound because the
1035+
// emitting step runs before `--stamp` in the same `&&` chain. That was
1036+
// a property of one hand-written string, checked by `includes()`. Every
1037+
// spelling below satisfies a substring test and lies.
1038+
battery('19. The ORDER the stamp\'s soundness rests on is now mechanical (#16529).');
1039+
const ordered = (name, build) => threwCoverage(() => inspect(amplifierFixture(name, { build }), ['packages/spec']));
1040+
expect('order/semicolon-stamps-after-failure', ordered('ord-semi', `tsup ; node ../../scripts/${STAMP_INVOCATION}`), 'CoverageError');
1041+
expect('order/or-stamps-only-on-failure', ordered('ord-or', `tsup || node ../../scripts/${STAMP_INVOCATION}`), 'CoverageError');
1042+
expect('order/pipe-is-not-a-chain', ordered('ord-pipe', `tsup | node ../../scripts/${STAMP_INVOCATION}`), 'CoverageError');
1043+
expect('order/stamp-before-the-emit', ordered('ord-first', `node ../../scripts/${STAMP_INVOCATION} && tsup`), 'CoverageError');
1044+
expect('order/stamp-is-the-whole-build', ordered('ord-only', `node ../../scripts/${STAMP_INVOCATION}`), 'CoverageError');
1045+
// The green leg, and the reason none of the above is vacuous: the shape the
1046+
// one real amplifier uses — a multi-step `&&` chain ending in the stamp,
1047+
// with a shell `if … ; fi` step in the middle whose internal `;` must NOT
1048+
// be mistaken for the separator that introduces the stamp step.
1049+
expect(
1050+
'order/real-shape-passes',
1051+
stampStepOrderProblem(`pnpm gen:schema && tsup && if [ -z "$OS_SKIP_DTS" ]; then BUILD_DTS=true tsup; fi && node ../../scripts/${STAMP_INVOCATION}`),
1052+
null,
1053+
);
1054+
expect('order/trailing-semicolon-is-not-a-separator', stampStepOrderProblem(`tsup && node ../../scripts/${STAMP_INVOCATION};`), null);
1055+
// The refusal has to say which spelling it found, or the fix is a guess.
1056+
expect('order/names-the-separator', threwCoverageMessage(() => inspect(amplifierFixture('ord-msg', { build: `tsup ; node ../../scripts/${STAMP_INVOCATION}` }), ['packages/spec'])).includes("joined by ';'"), true);
8731057
} finally {
8741058
rmSync(tmp, { recursive: true, force: true });
8751059
}
@@ -932,7 +1116,13 @@ function selfTest() {
9321116
console.error('');
9331117
return 1;
9341118
}
935-
console.log('✓ check:dev-prereqs --self-test — every verdict reachable, exclusions and freshness coverage pinned (17 cases), plus the shared workspace enumerator.');
1119+
// Derived, not typed: this line read "(17 cases)" as a literal while the
1120+
// roster it describes had grown past it, which is the same "the text promises
1121+
// what the code does not check" shape this change repairs one function over.
1122+
console.log(
1123+
`✓ check:dev-prereqs --self-test — every verdict reachable, exclusions and freshness coverage pinned ` +
1124+
`(${batterySeen.size} batteries, ${[...batterySeen.values()].reduce((a, b) => a + b, 0)} cases), plus the shared workspace enumerator.`,
1125+
);
9361126
selfTestReachedVerdict = true;
9371127
return 0;
9381128
}

0 commit comments

Comments
 (0)