Repository navigation
fix(spec): check:generated --fix rebuilds packages/spec before it measures or writes - #19500
Conversation
Ruling on #19086 (batch #203 item 2, letter C): the write path takes a forced build inside the same invocation and generates from that dist. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
…red dist states Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
…gression names the harm Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
…x-builds-what-it-writes-from
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
|
Contract reviewServed-tier: 116/116
① Derived judgmentsClause (a) delivered: Accept-set change verified in source: base exited 0 when a generator succeeded and its gate stayed unsatisfied; head exits 1 with the gate's verdict. Stated where a person meets it: body line 2 and §3, the CLI output, the dev report, the seat's PR comment. Disclosed cost judged acceptable under the ruling: the ruling authorises the package build as the automatic form of the documented REQUIRED precondition (AGENTS.md line 712), so writes the build's default-mode Seam: Pins: 6 cases drive the real Changeset: skip is right and is consistent with ② Semver levelNone. Nothing under ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #19086
Clause-②: yes
The design here is the maintainer's ruling, not this PR's
Batch #203 item 2, letter C, maintainer 「203 同意」 — issue comment
5754204602:Three clauses, no fourth. ⛔ No dist-digest artefact (letter A), ⛔ no new stamp file, and this is not a rider on anything.
#19086's title blames the turbo shared cache. It is not the cause, and the card's own body carries a STOP block saying so. Measured previously at
1047fe1016: a cache hit serves a byte-identical dist (451 files, path+sha256 diff exit 0); a restore normalises mtime to epoch 0, so the freshness guard accuses rather than being short-circuited; and turbo's 1808-input set is a strict superset of what the stamp hashes. The card's four-step repro recipe is also not runnable as written —pnpm --filter @objectstack/spec buildnever invokes turbo, and steps 1 and 3 are the same command.The real mechanism, re-read first-hand on
5e7d83c, the head this branch left from:readsDistrefusal lived only inside the--fixloop (line 751), while the reporting path printed its advisory only when a gate FAILS (line 646) — so a gate that passed over a stale dist printed 「All 15 generated artifacts are up to date.」 (line 685) and said nothing whatever about the dist it had been decided against;distIsStale()is three statements —if (!dist) return true;then "if the newest src mtime is not newer than the newest dist mtime, return false", thenreturn declarationStamp().state !== 'match';— so it answers fresh the moment mtimes say fresh: the acquitting branch never consults the only content-based evidence there is. And any write to a dist file moves that file's mtime FORWARD, so the whole damage class where a dist stops describingsrcis by construction outside what this predicate can report.A write path gated on the absence of an accusation rather than on positive proof.
What this changes
packages/spec/scripts/check-generated.ts, and nothing else:--fixforces apackages/specbuild before a single gate runs, and every verdict and every write in that invocation is taken against the dist that build emitted.OS_SKIP_DTSis stripped from the build's environment alone (it skips the declaration pass, which would leave exactly the dist this command exists to stop writing from). A build that fails writes nothing at all and exits 1..d.tscount, its newest mtime, the declaration-stamp verdict and digest, and which gates read it — plus, on the read-only path, the sentence that the stamp hashes the build's INPUTS and therefore cannot vouch for the dist's own contents. The same block is printed on the stale path, for the same reason.--fixre-runs each gate it wrote for, after the writes.--fixwhose generator succeeded but whose gate is still unsatisfied used to exit 0; it now exits 1 and prints the gate's own verdict.check:livenessandcheck:strictness-ledgerare the live shapes — theirgen:repairs only the arithmetic half.⛔ The read-only path deliberately does not build. It writes nothing, and CI's lap on it is
check:generated --reconcile-only(lint.yml) rather than the full aggregate, which is a local and dispatch-gates lap; a build there would be a cost on a path that has nothing to be wrong about. What it owes instead is to say which dist it looked at, which is clause 2.The existing in-loop
readsDist && distIsStale()refusal is kept as a floor, no longer as the guard — so a future edit that moves or weakens the post-build check cannot let a stale dist reach a generator unremarked.The two pins the ruling names, with before and after
Both are the measurements this card's thread already holds. Both were reproduced end to end in this worktree, on
origin/maincode and then on this branch, against a real dist from a real 166s build (distIsStale=false, declaration stampmatch— the acquitted state, in both).Pin 1 — B1, a dist with two exports deleted.
dist/meta-spelling/index.d.tsloses two names from its finalexport { ... }statement (sha256d708f62aea9f9a3bto804182a5ff14152a, anchor count 1 to 0, bothdeclare functionbodies still present).origin/main5e7d83ccheck:generated --fixREFUSED,✓ gen:api-surface✓ build(151s) then✓ check:api-surfaceapi-surface/meta-spelling.json,api-surface/shared.jsongit status --porcelainempty)pluralToSingular,singularToPlural,unrecognisedMetaTypeRefusal) plus 2 downgraded(function)to(other)Pin 2 — E1, the 46-of-48 chunk case. Two of the 46 emitted chunk declarations moved aside — what an interrupted declaration pass leaves behind. Every DECLARED entry file is still present, which is why the build's own
check-dts-emittedreports34/34and exits 0 over this tree.origin/main5e7d83ccheck:api-surfacealonepublic API changed: 325 breaking (removed/narrowed), 313 added, refusal-marker count 0check:generated --fix✓ gen:api-surface✓ build(155s), chunks back to 46/46,✓ check:api-surfaceapi-surface/shards plusapi-surface-signatures.jsondefine*signature rows:defineForm,defineView,defineViewItem)Every damaged tree was restored under a
trapand the restore proven by blob identity againstHEADwithgit diff HEADempty.Committed pins —
packages/spec/scripts/check-generated-fix-rebuild.pin.test.ts, 6 cases, 806ms. A realpackages/specbuild is ~3 minutes under the shared verify lock, so the committed pins reproduce the two dist states in miniature and pin the ORDER of effects: the fixture'sbuildre-emits a dist that describes itssrc, itscheck:api-surfacereads the surface off the dist, and itsgen:api-surfacewrites that surface into the baseline. Each case carries a lit control asserting the fixture really is damaged before the run.Three ablations from the committed state, each restored and the restore proven (
blob == HEAD,git diff HEADempty):if (fix)toif (false))expected [ 'META_URL_TO_SINGULAR', …(5) ] to include 'singularToPlural'), E1, build-failureCost — the ruled price, measured
One forced build per
--fix: 151s and 155s in the two end-to-end runs above (whole--fixinvocation 209s and 217s under the shared verify lock). That is the documented 「Build first」 precondition made automatic. The read-onlycheck:generatedis unchanged: no build, same ~75s.buildrunsgen:schema, so a--fixinvocation can now also repair the trackedjson-schema.manifest/(14 files),authorable-surface/(14) andauthorable-defaults/(13,writeShardsunderdefaultsChanged && !CHECK) projections when they are behind — three, and the build's own 「updated — commit it」 lines are captured and not surfaced on success, so a reader meets them throughgit status. That is exactly what running the documented precondition by hand already does — no new class of write — but it does mean--fixis no longer strictly limited to the artifacts its own gates proved stale.gen:authorable-surface-baseis untouched: it stays a deliberate, manual-only act and--fixstill never reaches it.Verification
pnpm --filter @objectstack/spec exec vitest run --project local— 507 files / 14823 tests pass, exit 0.pnpm --filter @objectstack/spec typecheck— exit 0 (tsc --noEmit,check:scripts-typecheck,check:test-typecheck).pnpm --filter @objectstack/spec check:generated— exit 0, 15/15.eslint . --no-inline-config --format json— 6958 files, 0 errors, 0 warnings, exit 0. No narrowing was needed.scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack): 60 derived · 56 exit 0 · 4 NOT MEASURED · 0 unrun (the body was written at create withcheck:pm-dispatch-gatesstill pending; it finished exit 0 in 839.1s). The four arecheck-plugin-teardown-shape --self-test(positive-control fixture pinned to a commit this shallow clone cannot reach),check:dual-build-cjs-loads,check:lean-entry-closureandcheck:type-check-debt— the last three all refuse withPREREQUISITE NOT METbecause the workspace dependency closure is not built here; each exits 3 rather than measuring a different world. ⛔ Not passes. None of them can be moved by this diff: they read other packages' built dists, and nothing here is built or shipped.check:pm-dispatch-gatesexceeded two 9-minute foreground budgets and is reported in the card comment.Changeset —
skip-changeset, measuredThis diff publishes nothing.
packages/spec'sfiles[]isdist, json-schema, liveness, prompts, llms.txt, README.md, src/**/*.zod.ts, CHANGELOG.md, api-surface, spec-changes.json;npm pack --dry-runpacks 2028 files, 0 of them underscripts/, 0 matchingcheck-generated. Grepping every shipped path for the five symbols this diff introduces (checkGenerated,distEvidence,CheckGeneratedIO,BUILT BY THIS RUN,surfaceFromDist) returns 0 hits; the positive control in the same instrument over the same trees returns 50 hits forObjectSchemaand 69 fordefineStack, so the zero is a reading. The behaviour that changes is a developer command's, and no consumer of the published package can observe it.Acceptance notes
Out of scope, not filed by this PR and not fixed here:
scripts/check-dts-emitted.mjscounts only the 34 DECLARED entry declarations, not the 46 emitted chunk declarations. Re-measured here: with two chunk declarations absent it still prints34/34 declared declaration file(s) presentand exits 0, which is why the E1 state survives the build's own last-step guard. Lit control in the same instrument: removing one declared ENTRY declaration (dist/index.d.ts) instead makesgen:api-surfacethrow loudly (Could not resolve module symbol for .) and write nothing. A previous round on this card already recorded this as a finding to file; it is a different file and a different defect. Who hits it: whoever's declaration pass is interrupted — everypackages/speccard.--fixalso writespackages/spec/api-surface-signatures.json, not justapi-surface/. The E1 reproduction onorigin/maindropped threedefine*signature rows from it. Prior write-ups of this card's blast radius name onlyapi-surface/. Noted because the next person restoring after a bad--fixwill otherwise leave that file modified — as happened once in this round's own measurement.packages/spec/scripts/lib/dist-freshness.ts's docblock claims the mtime rule "sees the hand-edited dist and the toolchain change a content digest is blind to". That is false in the direction that matters, and the previous round measured it: a hand-edit moves the file's mtime forward, and the mtime leg can only convict a dist OLDER than src. This PR does not touch that file — its consumer-side refusal is unchanged — so the sentence is still there. Who hits it: the next reader reasoning aboutinspectDistFreshness's guarantees.Three corrections written by the dispatching seat after the at-tier contract review (
5755473591): the cost paragraph named two of the three tracked projections the build can write; the read-only path's CI lap was overstated; and the gate line predated the last family finishing. The implementer's one body write was spent at create.Generated by Claude Code