Skip to content

fix(spec): check:generated --fix rebuilds packages/spec before it measures or writes - #19500

Merged
os-warren merged 4 commits into
mainfrom
claude/issue-19086-fix-builds-what-it-writes-from
Sep 21, 2026
Merged

os-warren merged 4 commits into
mainfrom
claude/issue-19086-fix-builds-what-it-writes-from

Conversation

@os-warren

@os-warren os-warren commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

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:

Ruled: check:generated --fix rebuilds before it writes. The write path takes a forced packages/spec build inside the same invocation and generates from that dist, never from whatever dist happens to be on disk — positive proof by construction, no new artefact, no new stamp (A's dist digest is ⛔ not added; B is measured ineffective; D alone does not stop the wrong write). D's one sentence rides the same PR: the 「All N generated artifacts are up to date」 line names the dist it measured against (its build stamp / tree), and --fix re-runs the check after writing so a write can never be its own only witness.

Three clauses, no fourth. ⛔ No dist-digest artefact (letter A), ⛔ no new stamp file, and this is not a rider on anything.

⚠️ The card's TITLE names a mechanism that is refuted by measurement

#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 build never 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:

  • the readsDist refusal lived only inside the --fix loop (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", then return 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 describing src is 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:

  1. --fix forces a packages/spec build before a single gate runs, and every verdict and every write in that invocation is taken against the dist that build emitted. OS_SKIP_DTS is 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.
  2. The report names the dist. 「All N generated artifacts are up to date」 now carries the tree it measured against, its .d.ts count, 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.
  3. --fix re-runs each gate it wrote for, after the writes. ⚠️ This is the acceptance-set change: a --fix whose generator succeeded but whose gate is still unsatisfied used to exit 0; it now exits 1 and prints the gate's own verdict. check:liveness and check:strictness-ledger are the live shapes — their gen: 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/main code and then on this branch, against a real dist from a real 166s build (distIsStale=false, declaration stamp match — the acquitted state, in both).

Pin 1 — B1, a dist with two exports deleted. dist/meta-spelling/index.d.ts loses two names from its final export { ... } statement (sha256 d708f62aea9f9a3b to 804182a5ff14152a, anchor count 1 to 0, both declare function bodies still present).

origin/main 5e7d83c this branch
check:generated --fix exit 0, no REFUSED, ✓ gen:api-surface exit 0, ✓ build (151s) then ✓ check:api-surface
tracked files written 2 — api-surface/meta-spelling.json, api-surface/shared.json 0 (git status --porcelain empty)
live exports deleted from the baseline 3 (pluralToSingular, singularToPlural, unrecognisedMetaTypeRefusal) plus 2 downgraded (function) to (other) 0
what the report said 「All 15 generated artifacts are up to date.」 on the next run, over the dirty tree 「All 15 generated artifacts are up to date — measured against packages/spec/dist, BUILT BY THIS RUN in 151s.」

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-emitted reports 34/34 and exits 0 over this tree.

origin/main 5e7d83c this branch
check:api-surface alone exit 1, public API changed: 325 breaking (removed/narrowed), 313 added, refusal-marker count 0 identical reading before the fix runs
check:generated --fix exit 0, ✓ gen:api-surface exit 0, ✓ build (155s), chunks back to 46/46, ✓ check:api-surface
tracked files written 5 — four api-surface/ shards plus api-surface-signatures.json 0
baseline lines removed 322 (and three define* signature rows: defineForm, defineView, defineViewItem) 0

Every damaged tree was restored under a trap and the restore proven by blob identity against HEAD with git diff HEAD empty.

Committed pins — packages/spec/scripts/check-generated-fix-rebuild.pin.test.ts, 6 cases, 806ms. A real packages/spec build 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's build re-emits a dist that describes its src, its check:api-surface reads the surface off the dist, and its gen:api-surface writes 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 HEAD empty):

ablation pins red control
forced build removed (if (fix) to if (false)) 3 — B1 (expected [ 'META_URL_TO_SINGULAR', …(5) ] to include 'singularToPlural'), E1, build-failure 6/6 green unablated, before and after
post-write re-check removed 2 — both re-check cases same
provenance clause removed from the up-to-date line 1 — the report-sentence case same

Cost — the ruled price, measured

One forced build per --fix: 151s and 155s in the two end-to-end runs above (whole --fix invocation 209s and 217s under the shared verify lock). That is the documented 「Build first」 precondition made automatic. The read-only check:generated is unchanged: no build, same ~75s.

⚠️ One consequence worth naming rather than discovering: the package's build runs gen:schema, so a --fix invocation can now also repair the tracked json-schema.manifest/ (14 files), authorable-surface/ (14) and authorable-defaults/ (13, writeShards under defaultsChanged && !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 through git status. That is exactly what running the documented precondition by hand already does — no new class of write — but it does mean --fix is no longer strictly limited to the artifacts its own gates proved stale. gen:authorable-surface-base is untouched: it stays a deliberate, manual-only act and --fix still 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.
  • Full repo lint at this head: eslint . --no-inline-config --format json — 6958 files, 0 errors, 0 warnings, exit 0. No narrowing was needed.
  • Derived gate union (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 with check:pm-dispatch-gates still pending; it finished exit 0 in 839.1s). The four are check-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-closure and check:type-check-debt — the last three all refuse with PREREQUISITE NOT MET because 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-gates exceeded two 9-minute foreground budgets and is reported in the card comment.

Changeset — skip-changeset, measured

This diff publishes nothing. packages/spec's files[] is dist, json-schema, liveness, prompts, llms.txt, README.md, src/**/*.zod.ts, CHANGELOG.md, api-surface, spec-changes.json; npm pack --dry-run packs 2028 files, 0 of them under scripts/, 0 matching check-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 for ObjectSchema and 69 for defineStack, 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.mjs counts only the 34 DECLARED entry declarations, not the 46 emitted chunk declarations. Re-measured here: with two chunk declarations absent it still prints 34/34 declared declaration file(s) present and 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 makes gen:api-surface throw 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 — every packages/spec card.
  • A wrong --fix also writes packages/spec/api-surface-signatures.json, not just api-surface/. The E1 reproduction on origin/main dropped three define* signature rows from it. Prior write-ups of this card's blast radius name only api-surface/. Noted because the next person restoring after a bad --fix will 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 about inspectDistFreshness'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

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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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): node scripts/docs-audit/affected-docs.mjs --json 2cac3636cab1cecef8f7a0453e4963dd1d01fb21 → packageMentionDocs.

@os-warren os-warren added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 21, 2026 — with Claude

os-warren commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

skip-changeset hung by the seat — the measurement it stands on, not the dev's word

domain:spec seat 2(座位贴 #18549),os-warren · session_01UDXER3sdqfeVYpEWZs5mZx。dev 按派发令写了零个标签并把这一步交回席位,连同它的理由;⛔ 本席未凭该理由挂标,先自己测了一遍。

Check Changeset 红在哪一步(从 jobs API 的 steps[] 读,⛔ 不从日志就近猜):第 11 步 「Require a changeset (or the skip-changeset label)」,第 12–15 步随之 skipped(fail-fast)。

本席的读数 —— packages/spec/package.json 在本 PR head 12068864 上的 files[]:

["dist","json-schema","liveness","prompts","llms.txt","README.md","src/**/*.zod.ts","CHANGELOG.md","api-surface","spec-changes.json"]

没有 scripts/。 ⇒ 本 PR 改的两个文件(packages/spec/scripts/check-generated.ts 与其同目录的 pin 测试)都不随包发布,消费方看不到任何行为变化。dev 另给了同向的装包实测(npm pack --dry-run 打 2028 个文件,0 个在 scripts/ 下;⭐ 亮控:同仪器同树对 ObjectSchema 命中 50、defineStack 命中 69,所以那个 0 是读数不是哑火),与本席的 files[] 读数相互独立且同向。

⇒ skip-changeset 是对的,已按四步写入并回读:size/l, tests, tooling, skip-changeset, needs:contract-review。

⚠️ 为什么这和 Clause-②: yes 不矛盾

两者回答的是不同的问题,⛔ 不是一个问题的两个答案:

  • changeset 问的是「已发布的包的行为变了吗」。答:没有——改的文件不在 files[] 里。
  • 条款② 问的是「接受集或公开面变了吗,因而欠一次达档契约复核」。答:变了——--fix 在生成器成功而门禁仍不满足时,从 exit 0 变成 exit 1(check:liveness 与 check:strictness-ledger 是活形态)。那是一个开发者命令的接受集变化,够不上发布面,却完全够得上一次达档复核。

⇒ 所以 needs:contract-review 留在 PR 与卡 #19086 两侧,skip-changeset 只清掉发布侧那一问。⛔ 本席不因为挂了 skip 就免掉复核。

待办

本 PR 的达档契约复核尚未起;CI 其余项仍在跑(挂标会触发 Check Changeset 重跑,本席等它出结论再判)。dev 的报告 5755337640 已在卡上,复核起在 CI 收敛之后。

Reading taken 2026-09-21T04:23Z.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 116/116 CONTRACT_REVIEW_TIER
Head-sha: 12068864a502aa70547b44cf6cf637d75214347f

⚠️ Tier provenance. The isolated reviewer reported that no per-request stamp is visible anywhere in its harness and left this line for the seat rather than typing a number — the correct refusal, and the second time this shift a reviewer has made it. The seat read it where the fuse says it lives (「子代理档只取其转录 harness 逐请求 model 盖章」): 116 assistant requests, 116 carrying one identical model stamp, 0 carrying anything else, and that value IS CONTRACT_REVIEW_TIER. ⛔ get_session was not used. Everything below is the reviewer's own text, adopted verbatim — the seat filled this one line and rewrote nothing else.

① Derived judgments

Clause (a) delivered: --fix forces pnpm -s build (package.json build, never turbo) inside the invocation before any gate, strips OS_SKIP_DTS for the build alone, returns 1 on build failure with zero gates and zero generators run, and every gate and generator afterwards runs against that dist in the same process; a post-build distIsStale() floor and the in-loop readsDist refusal are kept. Clause (b) delivered: the up-to-date line and the stale-path summary both carry measured against packages/spec/dist, BUILT BY THIS RUN in Ns or AS FOUND ON DISK — this run did not build it, plus .d.ts count, newest mtime, declaration-stamp state and digest, and the reader gates. Clause (c) delivered: each written entry's check: is re-run after the write loop and a red re-check exits 1. No dist-digest artefact, no stamp file, no new write site in the script (new fs imports are reads only). The read-only path does not build (guarded by if (fix); pinned). Ledger arrays, docblocks and reconcileLedger logic byte-identical to base except the two import lines and the void to string[] return.

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 gen:schema makes to tracked projections are not a new class; the check:authorable-surface gate runs after the build in the same invocation and witnesses them. But the disclosure undercounts: build-schemas.ts default mode writes three tracked projections when behind — json-schema.manifest/ (14 files), authorable-surface/ (14) and authorable-defaults/ (13, writeShards at line 3300 under defaultsChanged && !CHECK) — while the pre-build log line and the PR body name only the first two, and the build's own "updated — commit it" stdout is captured and not surfaced on success. gen:authorable-surface-base is unreachable from --fix, confirmed.

Seam: realIO is the only implementation production runs with (invokedDirectly then checkGenerated(argv, scripts, realIO); no other importer of checkGenerated at head; node-level realpath control direct/symlink true, imported false). Not published: files[] excludes scripts/, no .npmignore, no exports entry; npm pack --dry-run over the head tree packs 276 files with 0 under scripts/ while 145 files exist there, controls 17 api-surface/ and 203 *.zod.ts.

Pins: 6 cases drive the real reconcileLedger over the real package.json scripts with a fixture IO, each with a lit control that the fixture is damaged; B1 and E1 assert the harm before the mechanism. The fail-on-base direction is carried honestly: the committed file cannot compile on base (no export there, verified), and the direction rests on the base source reading (no build, no re-check, exit 0 on generator success — verified) plus the reported reproductions and ablation matrix, which are consistent with the pin assertions by reading but were not executed here.

Changeset: skip is right and is consistent with Clause-②: yes — the published surface is unchanged; the accept set of a developer command changed; the contract-review carrier stays. The latest Check Changeset reading is skipped by the job-level label exemption, which is the designed path.

② Semver level

None. Nothing under packages/spec's files[] or exports changes; the only behavioural change is the exit code of a developer command that never ships. skip-changeset upheld; no version bump owed.

③ Boundary flags

  • Head 12068864 unchanged at every read; Lint & Repo Gates still in progress at the last read (35/188 steps); every other check name is success or designed-skipped, 0 failure.
  • Disclosure undercount, one edit in two places: the pre-build log line in check-generated.ts and the PR body cost paragraph should name authorable-defaults/ beside authorable-surface/ and json-schema.manifest/; the build's "commit it" lines are swallowed on success, so a reader learns of those tracked writes only from git status.
  • AGENTS.md lines 712 to 714 ("build REQUIRED first", "regenerate ONLY the ones it proved stale") are now imprecise for --fix; governed surface outside this dispatch's file surface, so a follow-up, not this PR.
  • PR body says the read-only path "runs on every CI lap": CI runs only check:generated --reconcile-only (lint.yml line 5279); the full aggregate is a local and dispatch-gates lap. Wording only; the decision not to build there stands.
  • PR body Verification line "55 exit 0 · 1 pending" is superseded by the dev report (56 exit 0, 0 pending); body not patched.
  • NOT MEASURED here: executing the pin file, the ablations and the end-to-end reproductions (no node_modules); whether a CI shard ran the pin file (job logs refused by proxy, 403); the realpath guard under tsx as opposed to plain node.

Implemented-by: claude/issue-19086-fix-builds-what-it-writes-from
Reviewed-by: session_01UDXER3sdqfeVYpEWZs5mZx

VERDICT: PASS


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 21, 2026 05:15
@os-warren
os-warren added this pull request to the merge queue Sep 21, 2026
Merged via the queue into main with commit 4251c4a Sep 21, 2026
53 of 55 checks passed
@os-warren
os-warren deleted the claude/issue-19086-fix-builds-what-it-writes-from branch September 21, 2026 05:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate tests tooling

Projects

None yet

2 participants