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): the json-schema tree freshness rule can be answered — a generation stamp acquits a tree whose sources were re-checked-out unchanged (#16175) (#17789)
Closes#16175
Clause-②: no
`scripts/check-regen-pending.mjs` exports three freshness predicates
over the same
"the newest artifact mtime is older than the newest source mtime"
comparison, and all three share one blind spot: `git merge`,
`git checkout` and `git worktree add` re-check-out a source file with
**identical bytes** and bump its
mtime, the build that follows correctly does not run (turbo's cache
hashes content), and the rule then
refuses an artifact that is exactly current.
## What each predicate could actually read — measured first, on a built
tree at the merge base
The card says the two unfixed siblings "need different evidence, and one
of them has no evidence to read
at all". Measured before writing anything, by calling each predicate and
each stamp reader against the
real `packages/spec` after a clean build and then after `touch
packages/spec/src/data/query.zod.ts` with
`git status` empty:
| predicate | evidence file on disk | reader verdict | after the touch |
|:--|:--|:--|:--|
| `distIsStale` | `dist/.build-input-hash-dts` PRESENT | `match` |
`false` — acquitted |
| `bundlesAreStale` | `dist/.build-input-hash` PRESENT | `match` |
`false` — acquitted |
| `schemaTreeIsStale` | none | no reader exists | **`true` — the false
refusal** |
⚠️ **One premise of the card body is stale, and triage had already
recorded it.** The card's table marks
`bundlesAreStale` "not fixed". It was fixed by PR #16240 (`6971170791`),
and triage's `pm:retriage`
answer says so in as many words: 「已落地的一半(`bundlesAreStale`,PR
#16240,`697117079`)⛔ 不在范围内;
本卡剩下的**只有 `schemaTreeIsStale` 这一支**。」 Measured on `origin/main` at the
merge base — the
predicate ends `return buildStamp(specDir).state !== 'match';` — so this
PR carries only the schema half,
which is exactly the file surface triage declared. `bundlesAreStale` is
still exercised here as a control.
## The evidence given to the one that could read nothing, and where it
comes from
⛔ Neither `dist/` stamp could stand in, and reaching for one would be
#7122's rejected direction
relocated rather than relaxed: both are written at the **END** of the
build, whereas `gen:schema` is its
**FIRST** step and is also run standalone (this rule's own refusal
message says so) and again by
`check:authorable-surface`. A `dist/` stamp is evidence about `dist/`,
and in the standalone case there
would be none at all.
So the evidence is new. `packages/spec/scripts/build-schemas.ts` writes
`json-schema/.build-input-hash-schema` as **the last thing it does**,
over the digest of the inputs that
generation consumed — computed by the same `buildInputHash` the readers
use, so the two cannot drift.
Why that write point is sound, and why one is enough:
- the script rebuilds the **whole tree unconditionally**, before the
`--check` / `--update-base` fork, so
one write point covers `gen:schema`, `check:authorable-surface` and
`gen:authorable-surface-base`;
- it sits after every ratchet that can `exit 1`, and `clearOwnedOutputs`
removes the previous stamp at
the top of every run — so a stamp exists only for a run that emitted the
tree beside it **and** reached
the end. A generation that died halfway leaves none;
- `json-schema/` is a turbo build output, gitignored, cleared by this
generator and no other, so the
stamp lives and dies with the tree it speaks for. That is the same
argument `--stamp` makes for writing
inside `dist/`.
⭐ **It may only ACQUIT.** Absent, unreadable, or not 64 hex characters
is `unstamped` — no evidence — and
leaves the mtime refusal exactly where it stood (#4690). Nothing that
passes today can start failing, and
mtimes remain the only instrument that convicts.
**The digest's input set widens in the same commit**, which is why this
is one PR and not two: the
generators live under `packages/spec/scripts/`, which was in none of the
previous input sets (`src/**`,
`PACKAGE_BUILD_CONFIG`, turbo's `globalDependencies`). An edited
generator kept a digest that had not
moved, so a stamp written by the OLD generator would acquit a tree the
new one emits differently. A
superset can only ever WITHHOLD an acquittal, never grant one, so the
two `dist/` stamps become strictly
more honest too.
## Acceptance, measured
**1 — the false refusal is gone.** After a clean build, `touch` on three
sources with `git status` empty:
all three predicates `false`, all three stamps `match`. End to end, the
gate the card names:
```
BEFORE pnpm --filter @objectstack/spec check:docs exit 1
packages/spec/json-schema is older than packages/spec/src.
AFTER pnpm --filter @objectstack/spec check:docs exit 0
222 generated files in sync with packages/spec (no rebuild)
```
⭐ **2 — positive control.** A *genuine* byte-level source change
(appended line; blob
`341bb947` → `42c300d1`, `git status` shows ` M`) still convicts all
three:
`distIsStale`/`schemaTreeIsStale`/`bundlesAreStale` all `true`, all
three stamps `mismatch`. Without this
row, a predicate that stopped refusing and a predicate that went blind
read identically. Restored by
`git checkout HEAD --`, verified by blob hash equality.
**3 — ablation.** The acquittal line removed from `schemaTreeIsStale`
(on-disk landing proved by an
occurrence count 1 → 0 and a changed blob hash; restored under a trap
and verified back to the HEAD blob):
acceptance 1 reverts to `schemaTreeIsStale = true` while its two
siblings stay `false` — the differential
isolates the change to this predicate alone. No build/`dist` leg
applies: `check-regen-pending.mjs` is a
root script the probe imports by path, resolved through no package
`exports`.
## Declared file surface — and where I went past it
Triage declared three implementation sites. All three, and nothing else
on that axis:
- `scripts/build-input-hash.mjs` — widened input set,
`SCHEMA_STAMP_BASENAME`, `inspectSchemaStamp`, `writeSchemaStamp`
- `scripts/check-regen-pending.mjs` — `schemaTreeIsStale` wired;
`schemaStamp` / `recordSchemaStamp` wrappers ·
`scripts/check-regen-pending.d.mts` follows (`check:declaration-mirrors`
green)
- `packages/spec/scripts/build-schemas.ts` — **one** write point at the
end of generation
⚠️ Touching `packages/spec` is the cross-domain exception triage
authorized (precedent PR #16240); the
`domain:spec` seat carries the notification duty from this declaration.
⚠️ **Beyond it, and why** — reported rather than assumed. Five further
files, none of them a second
implementation site:
1. `packages/spec/scripts/schema-tree-freshness.test.ts` and
`packages/spec/scripts/build-schemas-check-mode.test.ts` — **tests**,
required by triage in its own
words (「照已落地那一半的做法用消融证明单向性」). The second is the only place that can
notice the write
point going missing: without it the rule degrades to `unstamped`, which
is the conservative verdict, so
every gate stays green and the only symptom is the false refusal quietly
returning.
2. `packages/spec/scripts/def-key-collisions.test.ts` — **mechanically
forced.** Both suites spawn the real
generator out of a copied `scripts/` in a flat tmpdir; its new repo-root
import walks off the top of the
filesystem from there, and the spawn dies with `MODULE_NOT_FOUND` before
any assertion runs. Both
fixtures now sit at the repo's own depth with the root scripts symlinked
beside them, so they keep
running the production import graph instead of a reduced one.
3. `packages/spec/vitest.repo-tests.json` — **a gate named it.** Both
suites now genuinely read outside
their package; `check:cross-package-test-inputs` refused until they were
declared.
4. `scripts/check-dev-prereqs.mjs` and
`packages/spec/scripts/lib/json-schema-out-dir.ts` — **comment-only**,
and both are reverse-read hits (below).
## Reverse-read — which existing sentence does this make false
Scanned before pushing; both hits fixed in place, zeros reported as
zeros.
**Made false, fixed:**
- `scripts/check-dev-prereqs.mjs` — "THE INPUT SET, and why each part is
in it" enumerates the digest's
inputs and is that gate's stated authority on them. The widening makes
the list incomplete. One bullet added.
- `packages/spec/scripts/lib/json-schema-out-dir.ts` — "gen:schema emits
`(category)/(Name).json` plus the
bundled `objectstack.json`" reads as exhaustive and no longer is.
Corrected, with the one thing a future
reader could get actively wrong stated: ⛔ the stamp may never get a
`FOREIGN_JSON_SCHEMA_ARTIFACTS`
entry — exempting it from the sweep would leave a stamp acquitting a
tree nobody emitted.
- `scripts/check-regen-pending.d.mts` header ("five functions"),
`scripts/build-input-hash.mjs` header
("the two stamps") and `inspectStamp`'s docblock ("ONE of the two
stamps") — all updated with the edit.
**Zeros — read and unaffected:**
- `packages/spec/scripts/lib/dist-freshness.ts` — cites
`schemaTreeIsStale` as precedent for guarding in
the generator rather than its callers. Still true; that is exactly what
this write point does.
- `packages/spec/scripts/lib/json-schema-out-dir.ts` module title — "the
one output directory **two
generators** write into". Still two: the stamp is `gen:schema`'s own
output, not a third writer.
- `check-generated.ts`'s `readsSchemaTree` ordering rules,
`schema-closure.ts`'s empty-category rule,
`regen-artifacts.mjs`'s `packages/spec/json-schema/**` row — none says
anything this changes. (That last
row's `.gitignore:61` line number is drift that predates this branch —
the pattern is at `:63`; noted,
not filed, no PR or reader is routed through it.)
## Tests re-judged in place, never deleted
All six original cases in `schema-tree-freshness.test.ts` still assert
exactly what they were written to
assert, because **none of those sandboxes carries a stamp** — and that
is now the property being pinned:
no stamp is `unstamped`, `unstamped` is no evidence, and no evidence
leaves the mtime verdict standing. The
re-judgement is recorded in the file header and on the false-green case
itself. Six new cases supply the
evidence and pin what it may and may not do: acquittal on `match`;
refusal held on `mismatch`; refusal held
on a non-digest stamp; a missing tree stays stale however good the
stamp; a mismatched stamp may not
overturn a verdict of fresh; and the stamp is invisible to both sides of
the mtime rule, so it cannot vouch
for itself.
## Changeset — measured, not defaulted
`skip-changeset` would have been wrong. `json-schema` is in
`@objectstack/spec`'s published `files[]`, so
the stamp travels in the tarball — `npm pack --dry-run --json`, on the
built tree:
```
STAMP FILES IN TARBALL: dist/.build-input-hash, dist/.build-input-hash-dts, json-schema/.build-input-hash-schema
positive control (json-schema/ ships): json-schema/.build-input-hash-schema, json-schema/ai/Agent.json, …
```
One 65-byte file joins the published package; no export, schema key,
registry entry or runtime behaviour
moves. `@objectstack/spec: patch`.
## Verification
`dispatch-gates --ran` reconciliation on the final commit (`git
rev-parse --short HEAD` = `7d1292f393`):
**87 derived, 84 run, 3 NOT MEASURED, 0 unrun.**
- `pnpm --filter @objectstack/spec build` — green (all three stamps
written, identical digest, which also
cross-checks the new writer against `--stamp`'s)
- `pnpm --filter @objectstack/spec typecheck` — green (`tsc`,
`check:scripts-typecheck`, `check:test-typecheck`)
- `pnpm --filter @objectstack/spec test` — 470 passed, 1 skipped (471
files) · `test:repo` — 29 files, 503 passed
- spec gates green: `check:authorable-surface`, `check:docs`,
`check:generated` (15/15 artifacts current),
`check:browser-reachable-entries`
- root gates green: `check:declaration-mirrors` (+self-test),
`check:merge-driver`, `check:nul-bytes`,
`check:cross-package-test-inputs`, `check:published-files`,
`check-dev-prereqs --self-test`,
`check:self-test-wired`, `check:scripts-symbol-anchors`,
`check:type-check-coverage`,
`check:test-source-alias`, `check:tier-file-adoption`, changeset gates,
and the rest of the derived 84
- ⊘ **NOT MEASURED (3+1), left to CI:** `check:dual-build-cjs-loads`,
`check:lean-entry-closure` and
`check:type-check-debt` each exit **3** — PREREQUISITE NOT MET, they
read a whole-repo `dist/` this
container has not built (`Build Core` owns that run).
`check:pm-dispatch-gates` exceeded the container's
foreground cap at 560s (exit 124) — a checker-health self-test for a
file this diff does not touch.
⛔ None of the four is a pass or a failure.
**A measured cost, reported rather than buried:** because the digest now
covers `packages/spec/scripts/**`,
editing anything there — including a script test — moves the digest and
makes `check:browser-reachable-entries`,
`check:generated` and the boot gate demand a rebuild first. Observed
twice during this run. It is the same
ergonomics that `src/**/*.test.ts` already carries (those are in the
digest today), and the alternative — a
rule deciding which files under `scripts/` are "really" inputs — decides
wrong the day someone extracts a
helper, in the acquitting direction. Triage's stop condition was 大量无关包的
boot gate 失效; measured,
`AMPLIFIERS = ['packages/spec']`, so exactly one package stamps and the
blast radius is that one package.
Draft, not armed.
---
_Generated by [Claude
Code](https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU)_
Co-authored-by: Claude <noreply@anthropic.com>
fix(devx): the json-schema tree's freshness rule can be answered — a generation stamp acquits a tree whose sources were re-checked-out unchanged (#16175)
6
+
7
+
`scripts/check-regen-pending.mjs` exports three freshness predicates over the
8
+
same `newestMtime(artifact) < newestMtime(src)` comparison, and all three share
9
+
one blind spot: `git merge`, `git checkout` and `git worktree add` re-check-out a
10
+
source file with **identical bytes** and bump its mtime, the build that follows
11
+
correctly does not run (turbo's cache hashes content), and the rule then refuses
12
+
an artifact that is exactly current.
13
+
14
+
Two of them were answered already — `distIsStale` by `dist/.build-input-hash-dts`
15
+
(#14985/#16176) and `bundlesAreStale` by `dist/.build-input-hash` (#16240).
16
+
`schemaTreeIsStale` was the third, and the one with **no evidence of any kind to
17
+
read**: nothing recorded which sources `packages/spec/json-schema/` came from.
18
+
Measured on a checkout whose `git status` was empty, after a bare
19
+
`touch packages/spec/src/data/query.zod.ts`:
20
+
21
+
```
22
+
pnpm --filter @objectstack/spec check:docs exit 1
23
+
packages/spec/json-schema is older than packages/spec/src.
24
+
```
25
+
26
+
The only remedy on offer was a full `gen:schema` — minutes under a shared verify
27
+
lock — for a tree that needed nothing. The same command now exits 0 with no
28
+
rebuild, and a genuine source edit still refuses.
29
+
30
+
**The evidence is new, because neither `dist/` stamp could stand in.** Both are
31
+
written at the END of the build, whereas `gen:schema` is its FIRST step and is
32
+
also run standalone and again by `check:authorable-surface` — so a `dist/` stamp
33
+
is evidence about `dist/`, and in the standalone case there would be none at all.
34
+
`build-schemas.ts` now writes `json-schema/.build-input-hash-schema` as the last
35
+
thing it does: one write point, after the unconditional whole-tree regeneration
36
+
that precedes its `--check` / `--update-base` fork, so all three entry points are
37
+
covered, and after every ratchet that can exit 1, so a refused run vouches for
38
+
nothing.
39
+
40
+
**⛔ The digest may only ACQUIT, never accuse.** A missing, unreadable or
41
+
non-64-hex stamp is `unstamped` — no evidence — and leaves the mtime refusal
42
+
exactly where it stood (#4690). Nothing that passes today can start failing, and
43
+
the rule keeps its only conviction instrument: mtimes still see the hand-edited
44
+
tree and the toolchain change a content digest is blind to.
45
+
46
+
**Why this ships, and why it is a changeset rather than `skip-changeset`.**
47
+
`json-schema` is in `@objectstack/spec`'s published `files[]`, so the new stamp
48
+
travels in the tarball — measured with `npm pack --dry-run`:
49
+
`json-schema/.build-input-hash-schema` is present alongside the two existing
50
+
`dist/` stamps. One 65-byte file is added to the published package. No export, no
51
+
schema key, no runtime behaviour and no authorable surface moves.
52
+
53
+
**One other published-adjacent change**, for the same soundness reason: the build
54
+
digest (`scripts/build-input-hash.mjs`) now also hashes `<pkg>/scripts/**` for
55
+
packages that have it. `packages/spec`'s generators live there and were in none of
56
+
the previous input sets, so an edited generator kept a digest that had not moved —
57
+
and a stamp written by the OLD generator would then acquit a tree the new one
58
+
emits differently. Widening a digest can only ever WITHHOLD an acquittal, never
59
+
grant one, so the two `dist/` stamps become strictly more honest as well; the
0 commit comments