Repository navigation
fix(build): emit spec .d.ts in program order so two builds of one sha match byte for byte - #30131
Merged
Merged
Conversation
…h byte for byte tsup's bundled rollup-plugin-dts emitted each declaration file when rollup transformed it. Rollup transforms in the order its async loads finish, which changes from run to run, and TypeScript prints union members in the order the one shared program's checker created them. Two clean builds of one commit of @objectstack/spec therefore differed in 18 of 134 .d.ts files, chunk names included, with the same public surface. The tsup patch now emits every source file once, in the program's own file order, before rollup reads any; the transform hook reads the stored text. Each file is still emitted exactly once. A pin builds a two-entry fixture through the real tsup CLI twice, forcing opposite load orders, and requires byte-equal output. Its controls prove the fixture is order-sensitive under this TypeScript and that the forced order reached rollup's loader. Co-Authored-By: Claude <[email protected]> Claude-Session: https://claude.ai/code/session_01WGufzeza8Qg2GQUtJQtxRW
Contributor
📓 Docs Drift Check
What this run could not see
Coarse fallback — 170 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
Contributor
Author
|
domain:devx#1 · os-support-ai · session_01WGufzgBDcBNVmSmo7VFHpj Stamp: 2026-11-04T15:17Z 维护者速读
席位核对
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Requested by Yibo Wang · project thread
Part of #30061 (the seat closes the card on landing; a
claude/project-thread-*branch cannot satisfycheck:closing-target-claim).维护者速读
patches/[email protected](新增的 emitInProgramOrder 与 transform 钩子里的读取);packages/spec/scripts/dts-emit-order.pin.test.ts;packages/spec/tsup.config.ts中改写的文档块。pnpm --filter @objectstack/spec exec vitest run --project local scripts/dts-emit-order.pin.test.ts,预期 2 个测试通过,约 5 秒。Clause-②: no
Metadata-protocol: none
What changes
The cause.
@objectstack/spec's DTS pass runs every entry through onets.Program(the existing tsup patch). Its checker creates each type once, on first use, and TypeScript prints union members, and the object members derived from them, in type-creation order. tsup's bundled rollup-plugin-dts emitted each file's declarations inside rollup'stransformhook, so whichever file rollup transformed first created the shared literal types and fixed the member order of every later file. Rollup transforms in the order its asyncfs.promises.readFileloads finish, which changes from run to run. Measured: in two DTS passes of one tree the transform order differed at 389 of 445 positions, starting with the first module.The parallel-build suspect does not hold. tsup runs the DTS pass as ONE rollup build in one worker, transforms each module once, and writes the
esmandcjsoutputs from that one module graph. The.d.tsand.d.mtstwins carry the same member order within a run. The order moves between runs, not between the formats.The fix.
patches/[email protected](regenerated withpnpm patch/pnpm patch-commit; the lockfile'spatch_hashmoves with it) addsemitInProgramOrder(program): on the first transform, it emits every non-declaration, non-external source file of the program once, inprogram.getSourceFiles()order, and stores each file's text and diagnostics. The transform hook then reads the stored text instead of emitting. Each file is still emitted exactly once, and thenoEmitOnErrorerror path is unchanged. The first hunk, which gives every entry one program, is unchanged.Docs.
packages/spec/tsup.config.ts's "NOT BYTE-STABLE" paragraph is rewritten: one tree now gives one set of bytes, but the order is the program's, so comparing two DIFFERENT sources still needs the order-insensitive reading.pnpm-workspace.yaml's note on the tsup patch gains the second change.The pin.
packages/spec/scripts/dts-emit-order.pin.test.ts(local project, ~4 s) builds a two-entry fixture through the real tsup CLI twice. A preload delays rollup's read ofa.tsin one run andb.tsin the other, so the two runs transform the entries in opposite orders, and the test requires byte-equal output (.d.tsand.d.mts). Two controls: the fixture is order-sensitive under this TypeScript (a hand emit in the two orders gives"zeta" | "alpha" | "mid"and"mid" | "alpha" | "zeta"), and each run's read log shows the forced order reached rollup.Why a fixture and not two full builds in CI: one spec DTS pass is ~45 s and ~3.5 GB RSS, and of four unforced builds of
main, three agreed byte for byte. Two full builds would double the cost and still miss a regression most of the time. The forced order fails on every run.Its radius, measured: a commit changing only
patches/[email protected]andpnpm-lock.yamlmakesturbo ls --affectedreport 131 packages,@objectstack/specamong them, and the root external-dependency hash is in turbo's global cache inputs. So a later tsup patch change re-runs this pin.Changeset:
skip-changeset. Nothing published changes. The post-fix spec declaration tree is byte-identical, 134/134, to one of the two treesmainalready builds (base1below).tsup.config.ts, the test,patches/and the lockfile are not in anyfiles[].Tests
rollup.jsswapped for the pre-fix one (one-program hunk only), the byte-equality test fails (expected { …(4) } to deeply equal { …(4) }) and the order-sensitivity control still passes. Restored, sha256 checked equal to the fixed file.pnpm --filter @objectstack/spec test: exit 0, 812/812 files, 24397 passed, 1 todo.NODE_OPTIONS=--max-old-space-size=6144 pnpm --filter @objectstack/spec typecheck: exit 0.eslint --no-inline-configon the two touched TypeScript files: exit 0.Acceptance notes
packages/spec. The rollup-plugin-dts behaviour lives in the root tsup patch, so the diff also touchespatches/[email protected],pnpm-lock.yaml(the patch hash) andpnpm-workspace.yaml(comment only).d79eecfe'spackages/spec/srcchange reverted and the current tree gave 134/134 byte-identical declarations under the fix.--dtscheck inscripts/codemod-split-*.mjsis untouched (Codemod split: put driver-sql sql-driver.ts into facade form with a deterministic script, like protocol.ts and engine.ts #30105 is in flight there).Verification
Spec builds, before and after (raw sha256 per declaration file)
All on the tree of
1f13eb31(currentmain),rm -rf distthenpnpm buildper run; per-file sha256 lists in the project files underissue-30061/.The 18 differing files before the fix:
ai,api,automation,contracts,kernel,marketplace,system(index.d.tsand.d.mtseach), and theautomation-api.zod-HASHandnode-executor.zod-HASHchunks (both formats, renamed between runs).Surface reading (
dtsnorm.mjsfromissue-29987/, base2 vs head1): same file set, 116 raw-equal, 18 differ only in union order, surface digest EQUAL in all 18. The fix moves no published surface; it picks one ofmain's existing orders every time.DTS pass wall time: 44-47 s before, 43-47 s after (shared box).
Derived gates
All on head
6d4463b4.node scripts/pm/dispatch-gates.mjs --commandsderived 66 families for this change set (the lockfile pulls in the wide set); each was run and its exit code recorded before any pipe (issue-30061/gates-ran.listin the project files).check:cross-package-test-inputs,check:test-source-alias,check:nul-bytes,check:published-files,check:dts-closure(38/38 declared declaration files present),check:type-check-coverage,check:type-check-debt,check:ci-filter-parity,check:pm-governed-mergesand spec'scheck:liveness,check:llms-txt,check:strictness-ledger,check:variant-docs,check:empty-state.check-plugin-teardown-shape.mjs --self-test(shallow clone, wantsgit fetch --unshallow),check:dual-build-cjs-loadsandcheck:lean-entry-closure(both want the whole workspace built; onlypackages/specwas). CI runs all three.dispatch-gates --ran: exit 0, 66 derived, 63 run, 3 NOT-MEASURED, 0 UNRUN.check-dev-prereqs(local-only family): NOT MEASURED, exit 1 because 35 other workspace packages are unbuilt in this container; spec's own build stamped its prerequisites (check-dev-prereqs --stampinsidepnpm build, exit 0).Generated by Claude Code