You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
// 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);
873
1057
}finally{
874
1058
rmSync(tmp,{recursive: true,force: true});
875
1059
}
@@ -932,7 +1116,13 @@ function selfTest() {
932
1116
console.error('');
933
1117
return1;
934
1118
}
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.`,
0 commit comments