Uh oh!
There was an error while loading. Please reload this page.
fix(scripts): keep turbo's packages.count true across --union-into, and assert the agreement on read - #10059
Conversation
…t it on read
`check-cross-package-test-inputs.mjs --union-into` appended to
`packages.items` and never touched `packages.count`, so the document it
handed to `partition-test-shards.mjs` said `count: 0` while carrying two
items. Inert only because nothing reads `count` -- but the consumer's
stated job is to assert this payload's shape loudly so a `turbo ls`
upgrade becomes a red step naming the cause rather than a silently empty
shard, and it was being fed a document that contradicts itself.
`count` is turbo's own field, not this script's invention: `turbo ls
--output=json` emits `{packageManager, packages:{count, items}}` and
keeps count === items.length (measured on turbo 2.10.10 across the bare,
--filter and --affected forms). So it is maintained, not deleted.
Both halves ship together:
- the writer reconciles `count` with `items.length` before writing;
- the reader (`readPackageItems()`) refuses a payload whose `count`
disagrees with `items.length`, naming the contradiction. A payload
with no `count` is accepted -- a redundant field's absence cannot
mis-shard anything, its disagreement can.
Both `--self-test` suites pin the invariant, so it cannot rot back.
No change to which packages are selected, sharded or tested.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja…e path A `reconcile(); write();` pair re-creates the original defect the moment someone adds a second write path -- "appended to items but forgot to move count" stays a reachable state. Reconciling inside the serializer makes it unreachable: `unionInto()` has exactly one `writeFileSync` and it has no other source of bytes. The self-test cases now assert on the parsed-back document, so they pin what actually lands on disk rather than an intermediate object. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
`scripts/partition-test-shards.mjs --self-test` existed but nothing ever invoked it -- not package.json, not any workflow -- so every assertion in it evaluated never, the partitioner's determinism and coverage pins included. A pin nobody runs is not a weaker pin, it is no pin, and the reader-side refusal added in this branch needs a live one or it rots the same way the writer's count did. Invoked as `node` rather than a `pnpm check:*` alias because root package.json is declared territory of the @changesets/cli v3 lane (#9465), matching the release-rehearsal self-test step above it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
PM review — ACCEPT. Ruling 1 answered on schema evidence, and H3 found the bigger hole and left it. Arming.Verified at ⭐ Ruling 1 → MAINTAIN, and the evidence is whose field it isI told you not to default to maintaining. You went and asked whose schema
Confirmed independently here — ⇒ it is turbo's field, not ours to delete. And the consequence you drew is the right one: deleting it would make our hand-mutated document stop being a valid ⭐ Where you put the reconciliation
That is the difference between fixing an instance and closing a class. A reconcile step adjacent to a write is correct exactly until someone adds a second write; a reconcile inside the only serializer cannot be bypassed without deleting it. And the reader's posture is right in both directions: a payload with no Ruling 2 satisfied in production, not in a self-test: the reader runs on every Test Core shard, so a future disagreement is a red step naming the cause. ⭐ H3 — the bigger finding, correctly reported rather than fixed
Measured across five payload shapes, with the damning row: And then the part that makes this a good H3 answer rather than an alarm:
You did not import #4690's rule by analogy; you checked whether its premise holds here and found it does not. Then you found the one signal that is decidable — an empty changed-file list on a The closing observation is the sharpest thing in the report: the shard attestation (#6082) does not cover it — it attests "shard N ran and every step passed", which is exactly what a shard that tested nothing does. H4 — the full field audit found a SECOND untrue fieldFive fields, verified exhaustively ( Correctly not fixed here: I asked for the audit because "fixing one field while three others rot is the shape this lane has hit repeatedly today." It was two of five. Ruling 3 — proven, not assertedOld vs new partitioner over the same real 77-package document: shards 23/29/24, weights 781/780/780, package lists identical on all three. Plus the unioned path unchanged. Nothing moved between shards. The declared change beyond the minimal diff — accepted, and it is the right call
Adding reader-side pins to an unrun self-test would have been dead code, and the card asked for a pin that keeps this from rotting back — which an unrun pin cannot do. Wiring it as a direct This is the same class as #9898 (a The ablation legsBoth predicted before running, both observed as predicted: the writer leg red on exactly 2 of 5 new cases with the reason stated in advance ("the other 3 already have a correct count, so a missing assignment is invisible to them"), and the reader leg red at the first must-throw case "since Restores confirmed with The one declared narrowing — legitimate
Both new cards labelled Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10046
check-cross-package-test-inputs.mjs --union-intoappended topackages.itemsand never touchedpackages.count, so the document it handed topartition-test-shards.mjssaidcount: 0while carrying two entries. Reproduced on20b9a9ce1bwith a changed-file list of exactlyscripts/sync-template-versions.mjs:Inert today because nothing reads
count— but the consumer's stated job is to assert this payload's shape loudly, so aturbo lsupgrade becomes a red step naming the cause rather than a silently empty shard, and it was being fed a document that contradicts itself about its own size.Decision:
countis MAINTAINED, not removed — because it is turbo's fieldThe card left maintain-vs-remove open, conditional on whose schema
countbelongs to. Measured, not assumed.turbo ls --output=jsonon turbo 2.10.10 emits it, and turbo keepscount === items.lengthin every form:turbo lsturbo ls --filter=@objectstack/specturbo ls --affectedSo
countis not this script's invention and is not ours to delete. Removing it would make our hand-mutated document stop being a validturbo lspayload — the opposite of what the partitioner's assert-the-upstream-shape-loudly posture is for, and it would diverge our documents from realturbo lsoutput on a second axis. The "delete it so a future consumer fails loudly" option assumes the field is ours; it is not.Both halves ship together
Writer —
serializePackageList()reconcilescountwithitems.length, and is the only thing that turns the parsed document into bytes. Reconciling inside the serializer rather than as a statement beside the write is deliberate:unionInto()has exactly onewriteFileSyncand no other source of bytes, so "appended toitemsbut forgot to movecount" is not a state this script can reach. Areconcile(); write();pair would re-create the original defect the first time someone added a second write path.Reader —
readPackageItems()inpartition-test-shards.mjsnow refuses a payload whosecountdisagrees withitems.length, naming the contradiction:This is what makes the invariant a checked fact across the two scripts rather than a convention someone has to remember. A payload carrying no
countis accepted on purpose: a redundant field's absence cannot mis-shard anything, while its disagreement can, and requiring its presence would turn a turbo upgrade that merely dropped an unread field into a redTest Coreon every PR. Acountthat is present and wrong is rejected, never repaired.The partitioner's
--self-testwas wired to nothingCalled out because it is the one change here beyond the minimal diff, and it is load-bearing for the card's second ruling (a future disagreement must be caught, not merely unlikely).
scripts/partition-test-shards.mjs --self-testexisted but nothing invoked it — notpackage.json, not any workflow. Every assertion in it evaluated never, the partitioner's pre-existing determinism, coverage and balance pins included. Adding reader-side pins to an unrun self-test would have been adding dead code, and the issue explicitly asked for a pin that "would keep it from rotting back" — which an unrun pin cannot do.It is now a step in
Lint & Repo Gates, invoked asnode … --self-testrather than through apnpm check:*alias, matching the release-rehearsal self-test step directly above it and for the same stated reason: that alias would belong in rootpackage.json, which is declared territory of the @changesets/cli v3 lane (#9465) while it runs. Pure functions, no IO, milliseconds.Verification
Gate union re-derived from the real change set with
node scripts/pm/dispatch-gates.mjs(no paths passed — it reads the merge base itself), run ata0b947783d, the final commit:check:cross-package-test-inputsOK: 12 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.check:node-versionOK (30 setup-node step(s) across 27 workflow(s), all on Node 22).check:required-contextscheck:shard-attestationcheck:type-check-coverageOK — 64/77 workspace packages type-checked (plus the root), 13 in the DEBT ledgercheck:workflow-status-functionsOK (scanned 27 workflow file(s), 46 job(s), 24 job-level if: expression(s))check:nul-bytesOK (scanned 6358 text file(s) … 0 untracked-not-ignored; skipped 5 binary)partition-test-shards.mjs --self-testpartition-test-shards: self-test OKcheck-cross-package-test-inputs.mjs --self-testAll 57 self-test cases passed.Six of those families were not in the dispatch list — the
.github/workflows/lint.ymledit pulled them in, which is why the union was re-derived from the actual diff rather than the brief.One declared narrowing:
check:type-check-debtexits 1 in this worktree, refusing to--re-measurebecause 55 workspace dependencies have no builtdist/*.d.ts. It is not caused by this branch — the identical refusal reproduces on the unmodified sharedorigin/maincheckout with none of this diff present, and this diff touches zero TypeScript files (3 files: 2.mjs, 1 workflow YAML). The half of that gate which actually readslint.ymlpassed and printed its OK line before the closure precondition stopped the re-measurement. Building the full 77-package closure would hold the container's shared heavy-verify lock for ~10 minutes to re-measure a TypeScript debt ledger this change cannot move; CI builds the closure before that step and will run it properly.Reverse verification
Both halves ablated from the committed state, each with its direction predicted first, then restored (
git checkout HEAD -- …, working tree and index confirmed clean viagit status --porcelain, notgit diff HEAD). No rebuild leg applies and that is checked rather than assumed: both scripts import onlynode:builtins, run directly as source, are absent from the test-source-alias ledger, and are reached through no package build — there is nodist/between the edit and the measurement.2/57 self-test case(s) failed,count follows an appended itemandcount follows an empty list down. End to end, the document regressed tocount 0 / items 2and the reader refused it (exit 1, 0 packages emitted) — the cross-script guard catching the original defect.selfTest()throws rather than collecting. ObservedError: payload: count 0 beside 2 items was accepted, and the contradictory document was silently accepted again — exit 0,@objectstack/specselected. The pre-fix behaviour, reproduced on demand.No package moves between shards
The card fenced this: the change is comment/data-shape only. Old and new partitioner run against the same real 77-package document, all three shards,
--exclude @objectstack/dogfood:All 76 placed packages land on identical shards with identical weights and bin totals. The unioned-document path is likewise unchanged: same shard output before and after the fix (
shard 1/3: 1/2 packages, weight 414,@objectstack/spec).Full field audit of the document
Every field, exhaustively (all 77 items share one key set), and whether the union step keeps it true:
packageManagerpackages.countpackages.itemspackages.items[].namepackages.items[].pathNo timestamp, no filter echo, no
packageCount— the document has exactly these five fields.Out of scope, filed
--union-intopushes an ABSOLUTEpackages.items[].pathwhileturbo lsemits repo-relative ones, so the document it hands the shard partitioner is heterogeneous #10056 — the second field the union writer leaves untrue: it pushes an absolutepathwhile turbo emits repo-relative ones (0 of 77 turbo entries are absolute). Inert today (identical weights measured from the repo root) but deliberately not fixed here:pathfeedscountTestFiles()feeds the package weight feeds shard placement, so touching it could move packages between shards — exactly what this card's claim declared it would not do. The correct normalisation is also a genuine judgment call, not a mechanical fix.changed-files.txtis never checked #10057 — nothing distinguishes "zero packages affected" from "the affected-set computation silently produced nothing":ci.ymlchecks the exit status ofgit diff --name-only "$SCM_BASE" HEADbut never its emptiness, and an empty changed-file list on apull_requestevent is a decidable "selection failed" signal. Reported rather than fixed, per the card. Note the partitioner cannot fix this at its own layer — zero is frequently the correct answer there (a docs-only PR; any run with fewer packages than shards).Neither is addressed by this PR; #10056 and #10057 both remain open.
Notes
No changeset: this is
scripts/and CI tooling and publishes nothing, and.changeset/**sits inside the #9465 fence.skip-changesetapplied accordingly. No governed surface is touched — nothing underdocs/adr/,.claude/,skills/,AGENTS.mdorCLAUDE.md.Generated by Claude Code