Skip to content

Commit 9ff7428

Browse files
ci(test-core): retire the CLI's file-level slicing by the partitioner's own slice-count derivation (#21487)
Unblocks #20388 (the Test Core shard-timings refresh), which #16465 and #16468 wait on. This PR binds no card: #20388, #16465 and #16468 all remain open. #20388 lands by itself after this merges, on its own green, through the auto-merge the maintainer armed. ## What this changes `scripts/partition-test-shards.mjs --self-test` now passes on BOTH datasets: the `scripts/test-shard-timings.json` on `main`, and #20388's (blob `84342b45`, byte-identical to its head `8d60ae86`, overlaid unstaged in a second worktree and never committed). - **`FILE_SHARDED_PACKAGES` is emptied** (the CLI's `2` is retired). The mechanism stays: the item grammar, `expandSlices`, the vitest file-count floor, the `OS_TEST_SHARD` wiring judge, the generator's slice reassembly, and the dormant CLI wiring in `turbo.json` and `packages/cli/vitest.config.ts`. - **Pin 3c is rewritten.** It no longer substitutes the hard-coded `CLI_MEASURED = 1231.52` bridge. It asks the committed dataset, for every map entry, whether `n - 1` slices would also meet the bound (`sliceCountProblems`), and refuses an `n` that `n - 1` could replace. Five fixtures hold each refusal in both directions: needed, retire, lower, unmeasured entry, and an entry below 2. - **Pin 3b is rewritten.** It grades the real split as before. It also grades a cut of the dataset's heaviest package in two, so the spread check is not vacuous while the map slices nothing. - **The mechanism's pins read a fixture map** through new optional `sliced` parameters (default: the live map) on `sliceCountFor`, `weighItems` and `driftReport`. The re-pointed cases are the vitest floor, the slice-share prediction, drift on a sliced package, observed-run-wins, `weighItems` slicing, and the wiring reader pointed at the CLI's real tree. - **Pin 6's inversion pair is replaced.** `example-todo` over `core` flipped in the refresh (35.30s vs 57.09s). The new pair is `plugin-pinyin-search` over `sdui-parser`: 2 vs 13 test files, 14.40s vs 1.62s on `main`'s dataset, 38.77s vs 4.54s on #20388's. A new guard reads the dataset's own order, so a flipped pair asks for a new pair instead of reporting a weighing defect. - **`PREVIOUS_FILE_SHARDED_PACKAGES` (new, beside the map) holds the outgoing map, `{ '@objectstack/cli': 2 }`.** The generator's slice-digest matcher decodes a digest against both declared maps, exactly by hash, so a run summary written before this change still reads. Without it, the refresh lane refused every run on `main`; see the next section. A decoded slice is still summed within its run (an incomplete set contributes nothing), and a digest neither map names is still refused. The map has no reader once a day has passed, because run summaries are kept for one day (`retention-days: 1`). - **`scripts/measure-test-shard-timings.mjs` (outside the claimed surface, and why).** This is the matcher change above. Its live-map case also read `FILE_SHARDED_PACKAGES['@objectstack/cli']`, which reds under an empty map (measured: the unedited file exits 1). That case now covers both live maps, and a new case reads a pre-change run's two halves into their 733.33s whole while still refusing a count neither map names. The env-carried battery floor goes from 10 to 11. A stale "(2 for @objectstack/cli today)" parenthetical is dropped. Battery floors are raised to the measured counts: balancing pins 21 to 25, OS_TEST_SHARD wiring 9 to 11. File-level slice items stays at 20. The counts are the same on both datasets. ## The three bin readings, and the keep-or-retire choice Predicted bins at 6 shards against the 1.3x bound, using the partitioner's own `partition` / `balanceOf` / `expandSlices`: | dataset | case | bins (s) | max/mean | heaviest item | verdict | |:--|:--|:--|:--|:--|:--| | #20388 (72 pkgs, 7430.00s) | **K** CLI sliced at 2 | 1391/1208/1208/1207/1208/1208 | 1.124x | spec 1391s | meets | | #20388 | **R** CLI whole (733.33s) | 1391/1208/1206/1208/1208/1208 | 1.124x | spec 1391s | meets | | #20388 | **R-worst** CLI whole at 1231.52s | 1391/1308/1307/1307/1308/1307 | 1.053x | spec 1391s | meets | | `main` (70 pkgs, 3997.47s) | K | 666/666/666/667/666/666 | 1.000x | spec 404s | meets | | `main` | R | 666/666/667/667/667/666 | 1.001x | cli 458s | meets | | `main` | R-worst (the old bridge) | 1232/708/707/709/708/707 | 1.549x | cli 1232s | breach | On #20388's dataset, both R and R-worst meet the bound, and slicing changes no bin's maximum (spec is the heaviest item in all three cases). Solving C ≤ (1.3/6)(6696.67 + C), the CLI fits whole until about 1852s, which is 1.5x its worst reading. The file's own rule makes n the smallest count that meets the bound. That count is 1, so the entry is retired. Four axes: - **Real need:** measured. Slicing buys nothing in the predicted maximum on the refreshed dataset. It costs a duplicated closure build and a sequential slice leg on two shards. In #20388's run 36380128221, "Build the sliced package's dependency closure" took 6m15s on `Test Core (5/6)` and 6m43s on `(6/6)`, and 5/6 was the longest job at 19m23s. - **Long-term:** keeping `2` would leave a count no pin can justify. The old counterfactual cannot be rewritten on any measured basis, because R-worst meets. Retirement keeps the mechanism, and pin 3 (the floor) is the live trigger to slice again. - **AI-error resistance:** the new 3c reds on a slice count that is larger than needed. A future author cannot leave slicing configured without a measurement behind it. - **Startup focus:** fewer live moving parts and no new gate. 3c replaces a case inside the existing self-test, and the retirement is immediate. ## The refresh lane across the change: red, then green - **Red at `c614a094`.** This PR's `pull_request` rehearsal of `shard-timings-refresh.yml` (run 37074888579, job 111062437870) refused all ten eligible hourly runs on `main`, 37010481060 through 37070188866. It then ended with "The 0 eligible run(s) ... NOTHING was regenerated". - **Cause, reproduced locally** (the run's own log and artifacts are behind the egress policy here). A synthetic run was built from `main`'s dataset in the exact turbo 2.10.10 shape: six whole-leg summaries, plus two slice legs whose `OS_TEST_SHARD` digest is the sha256 of `1/2` or `2/2` (`d939926f…`, the measured format). It was fed to the lane's own two commands: the generator with `--run` / `--merge-into`, then `select-shard-timings-run.mjs --check-coverage`. - `aa463223` (the map `{cli: 2}`): exit 0, coverage OK. - `c614a094` (the empty map): exit 1, "matches no slice the partitioner can emit for it (none: FILE_SHARDED_PACKAGES does not slice it)". - `d049d353`: exit 0, coverage OK, and a `packages` map identical to `aa463223`'s. - **So the hypothesis holds, and its rival is falsified.** The 2026-09-30 green rehearsals predate the env carrier, so they cannot separate "the empty map refuses these slices" from "the env carrier never decoded on real summaries". The rehearsal at `d049d353` does: run 37075730446 (job 111065090886) is **green**. It accepted its first candidate, with no "dropping that run" warning, so `main`'s real env-carried summaries decode once the matcher knows the outgoing map. - **Why decode the outgoing map, not keep the 2 slices.** Read along the four axes: - Need: every run summary that exists today carries the CLI's two slices. That is the lane's only input until a day after the merge. - Long-term: the matcher stays a whitelist. It reads two declared maps by hash equality, never a guessed count, and any future map change in either direction gets the same one-day bridge by setting one constant in the same PR. - AI error: the refusal is unchanged for any digest neither map names (N4 below), and completeness is still judged within a run. - Startup: no new gate, and no grace window beyond the artifacts' own one-day life. - Keeping 2 would bring back a slice count that pin 3c refuses on both datasets, with no measured counterfactual to replace it. - **The next scheduled weekly refresh (Mon 2026-10-05 05:30Z), if this has merged by then: expected to succeed.** With one-day retention, its candidates are runs made under the new map: whole CLI, no slice digest. Measured on synthetic summaries at `d049d353`: a post-change run alone gives exit 0 (cli whole, 740s). A post-change run accumulated with a pre-change sliced run gives exit 0 (cli 599.08s, the median of 740 and 458.15). So a refresh that straddles the merge also reads. It is still a prediction: it depends on an eligible full-battery run existing, as every refresh does. ## Self-test on both datasets (head `d049d353`) ``` BEFORE main dataset (aa46322) exit 0 partition-test-shards: self-test OK (70 measured packages -> 71 shard items, 6 shards, max/mean 1.00x <= 1.3x, floor 404s, bins 666/666/666/667/666/666s) BEFORE #20388 dataset (aa46322) exit 1 Error: slice derivation: UNSLICED, @objectstack/cli at 1231.52s is 1391s against a 1321s mean and now fits under 1.3x on its own ... Re-derive the slice count (or retire it) ... AFTER main dataset (d049d35) exit 0 partition-test-shards: self-test OK (70 measured packages -> 70 shard items, 6 shards, max/mean 1.00x <= 1.3x, floor 458s, bins 666/666/667/667/667/666s, file-level slices: none) AFTER #20388 dataset (d049d35) exit 0 partition-test-shards: self-test OK (72 measured packages -> 72 shard items, 6 shards, max/mean 1.12x <= 1.3x, floor 1391s, bins 1391/1208/1206/1208/1208/1208s, file-level slices: none) ``` The BEFORE red reproduces #20388's AFTER block on today's `main` (line 1075 now, 967 in that body). It also mislabels spec's 1391s as the CLI's, because the old counterfactual read the overall heaviest item. A second red was masked behind it. With the 3c throw muted on `aa463223`, pin 6 reds on #20388's dataset with "The run is weighing test-file count again". That is a misdiagnosis: the pair had flipped. ## Mutation proof, one per rewritten or added pin Every leg was taken from committed `c614a094` through `scripts/ablation-replace.mjs`. The whole table plus N1 to N4 was then re-run from committed `d049d353` on both datasets: 48 legs, every one landed and restored. In each leg the anchor hit 1 and the replacement went 0 to 1. Each leg was restored to the HEAD blob with `git diff HEAD` empty, under a shell `trap` that re-proved the hashes in both worktrees at the end. The red line below is the self-test's first `Error:`, the same on both datasets unless shown. | # | mutation | first red | |:--|:--|:--| | M1 | re-add `'@objectstack/cli': 2` to the live map | `slice derivation, committed dataset (1 of 1 ...)`, "sliced 2 ways at 458.15s (main) / 733.33s (#20388), but at 1 the split already meets 1.3x ... Retire the entry" | | M2 | `meetsBound` always meets | `a count of 2 that 1 cannot replace was refused` | | M3 | n-1 = 1 never judged | `a package that fits whole kept its slicing with no refusal` | | M4 | only n-1 = 1 judged | `a count of 3 where 2 meets the bound was accepted` | | M5 | unmeasured entry not refused | `an entry the dataset never measured was accepted` | | M6 | the "at least 2" floor dropped | `an entry of fewer than 2 slices was accepted` | | M7 | `expandSlices` stops slicing | `slice spread: cutting @objectstack/cli (main) / @objectstack/spec (#20388) in two produced no ... pair to grade` | | M8 | pin 6 back to the old pair | **green on `main`** (the pair still holds there); on #20388: `the dataset no longer measures @objectstack/example-todo slower than @objectstack/core (35.3s vs 57.09s) ... Pick a new inversion pair` | | M9 | `weighItems` weighs test-file count | `weight: @objectstack/plugin-pinyin-search weighed 2 and @objectstack/sdui-parser weighed 13 ...` | | M10 | every package takes the map's first count | `slice count: a package outside the slice map was sliced` | | M11 | `sliceCountFor` ignores `sliced` | `slice count: the configured package did not read its configured count` | | M12 | vitest floor refusal dropped | `slice floor: slicing below the test-file count was not refused (no throw)` | | M13 | floor refuses below 1000 files | the floor's own refusal for 500 files, thrown from the "plenty of test files" case | | M14 | prediction stops dividing | `prediction: a sliced package was charged its WHOLE dataset entry` | | M15 | `driftReport` ignores `sliced` | `drift: a sliced overshoot read 0.83x and was not reported as drift` | | M16 | observed slices ignored | `drift: an observed WHOLE run was charged a slice-sized prediction (1.67x)` | | M17 | `weighItems` ignores `sliced` | `weighItems: 1 package(s) produced 1 item(s), expected 2` | | M18 | wiring reader's default is not the live map | `slice wiring: judged 1 of 0 sliced package(s) ...` | | M19 | `packages/cli/vitest.config.ts` reads `OS_TEST_SHARD_UNREAD` | `slice wiring, the tree read through a fixture map ... never reads OS_TEST_SHARD into vitest's shard` | | M20 | measure: `samplesFromSummary`'s default `sliced` is not the live map | `env slice: a @objectstack/cli digest of 1/3, a count neither live map names, was not refused listing (1/2, 2/2) (got "no throw")` | | N1 | the matcher stops reading the previous map | the live-maps case: `@objectstack/cli ran with OS_TEST_SHARD set ... matches no slice the partitioner can emit for it, or emitted under the map it replaced (none: ...)` | | N2 | `samplesFromSummary`'s default `previous` is not the live one | the same refusal, from the live-maps case | | N3 | `samplesFromSummary` drops its `previous` argument | the across-change case: `a: cli ran with OS_TEST_SHARD set (digest d939926f…) ... matches no slice ...` | | N4 | the matcher decodes a count neither map names | `env slice: a @objectstack/cli digest of 1/3, a count neither live map names, was not refused listing (1/2, 2/2)` | ## Premise checks (the dispatch's A1 to A4) - **A1** holds on `aa463223`: the map was `{ '@objectstack/cli': 2 }`, pin 3b carried the 1231.52s/800.7s prose, and pin 3c carried `CLI_MEASURED = 1231.52`. - **A2** reproduced on current `main`; see BEFORE above. - **A3** recomputed. On `main`: 70 packages, 3997.47s, cli 458.15s, spec 403.65s. On #20388: 72 packages, 7430.00s, `measuredAt` 2026-09-28, runs `[36380128221]`, spec 1391.38s (heaviest), cli 733.33s. The two new packages are `organizations` and `vitest-filter-preflight`. The package set equals today's workspace minus the CI-excluded `dogfood`. - **A4: well-formed.** Today's generator writes the identical shape: the same top-level and `provenance` keys, and byte-identical `note`, `mergeRule` and `refresh` strings. It accepts the file as a `--merge-into` target (carried `spec` on a HIT witness). `skippedAsCached`, `skippedIncompleteSlices` and `carriedOver` are all empty, and the CLI's 733.33s is a two-slice sum from one run. The 09-28 run carried its slices as a `--shard` passthrough, which today's generator still reads (`sliceOfCliArguments`). Its run-summary artifacts are no longer retained (the run lists only `test-core-timing-table` and `build-output`), so a byte-level re-generation is NOT MEASURED. ## Consequences outside this diff (known, deliberate) - **The refresh lane.** See its own section above: red at `c614a094`, green at `d049d353`. - **Nightly tiers.** `test-nightly-tiers.yml` partitions the one tier-owning package at 2 shards. Measured locally: shard 1/2 now carries `@objectstack/cli` whole, and 2/2 carries nothing ("No packages on this shard", exit 0). The run stays inside its 45-minute timeout by its own header's estimate. Its header prose and its 2-shard matrix are now stale; no carrier. - **The window between this merge and #20388's.** `main` then splits on the stale weights with the CLI whole. Modeled with #20388's weights standing in for real cost: the heaviest actual bin stays spec's (1775s sliced, 1773s whole). The CLI's bin is 1116s at 733.33s, or 1614s at 1231.52s. Merging `main` into #20388 right after this lands keeps the window short. ## Gates (head `d049d353`) `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (change set from git, 2 paths) derived 32 families. The list is the same on `c614a094` and `d049d353`. On both heads, all 32 ran with exit 0, recorded and reconciled by `--ran`: "32 derived, 32 run, 0 NOT-MEASURED, 0 UNRUN". `node scripts/report-test-timings.mjs --self-test` also ran, exit 0. The list includes the three self-tests (partition / measure / select-shard-timings-run), `check:cross-package-test-inputs`, `check:nul-bytes`, `check:pm-dispatch-gates` (1976 cases, 977s), `check-scripts-symbol-anchors` and `check-self-test-wired`. Lint, narrowed and proven. `eslint --no-inline-config --format json` on the 2 changed files reports 2 files, 0 errors and 0 warnings on both heads, and neither file is ignored. `--print-config` shows 2 per-file rules (`no-restricted-imports`, `comment-swallow/no-code-inside-block-comment`) and no `parserOptions.project` or `projectService`. Type-aware linting is off, so this diff cannot move any untouched file's verdict. The full `pnpm lint` is CI's. `skip-changeset`: root `scripts/` ships in no package's `files[]` (the root package is private). ## Acceptance notes - **Observation, not filed:** spec's 1391.38s is larger than every shard's measured test-step wall in the same run (max 1023s). That is consistent with the generator's documented fold, which sums the `test` and `test:repo` windows, and the windows overlapped. Spec is now the floor, at about 83% of its own breach point (about 1670s). #16468's ceilings will read this number. - **Observed, not filed:** when the generator refuses every candidate, the refresh lane's final `::error::` blames a coverage shortfall ("a suite failed, a package was renamed or removed, or its slices could not be assembled") rather than the refusals, as run 37074888579 showed. No public door; no carrier. - **Stale prose outside this diff after the retirement.** The `ci.yml` slice-leg comments and the 1231.52s note at its drift step are for #16465, which edits that file. `packages/cli/vitest.config.ts`'s "partition-test-shards.mjs slices this package" has no carrier. The `test-nightly-tiers.yml` header and matrix have no carrier. `shard-timings-refresh.yml`'s "pin 3c ... the day the CLI comes back under the bound" is still true in mechanism; no carrier. --- _Generated by [Claude Code](https://claude.ai/code/session_01HRYqpqGcWpJuJkDmbRF75w)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fd96a84 commit 9ff7428

2 files changed

Lines changed: 428 additions & 171 deletions

File tree

‎scripts/measure-test-shard-timings.mjs‎

Lines changed: 120 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,16 @@ import path from 'node:path';
8787
import { fileURLToPath } from 'node:url';
8888
import process from 'node:process';
8989

90-
// FILE_SHARDED_PACKAGES and SLICE_ENV are read only inside functions: this
91-
// import is circular (the partitioner imports samplesFromSummary from here), so
92-
// a top-level read would meet an uninitialised binding when the partitioner is
93-
// the entry point.
94-
import { countTestFiles, FILE_SHARDED_PACKAGES, SLICE_ENV } from './partition-test-shards.mjs';
90+
// FILE_SHARDED_PACKAGES, PREVIOUS_FILE_SHARDED_PACKAGES and SLICE_ENV are read
91+
// only inside functions: this import is circular (the partitioner imports
92+
// samplesFromSummary from here), so a top-level read would meet an
93+
// uninitialised binding when the partitioner is the entry point.
94+
import {
95+
countTestFiles,
96+
FILE_SHARDED_PACKAGES,
97+
PREVIOUS_FILE_SHARDED_PACKAGES,
98+
SLICE_ENV,
99+
} from './partition-test-shards.mjs';
95100
import { isEntrypoint } from './invoked-as.mjs';
96101

97102
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -159,24 +164,34 @@ export function sliceOfCliArguments(args) {
159164
// `OS_TEST_SHARD=` with nothing after it when the value is empty.
160165
//
161166
// So the digest is matched against every slice the partitioner CAN emit for
162-
// that package, and nothing else: `k/n` for 1 <= k <= n, where n is
163-
// FILE_SHARDED_PACKAGES[package] from partition-test-shards.mjs. The bound is
164-
// that map -- n candidates for a package it slices (2 for @objectstack/cli
165-
// today), none for one it does not.
167+
// that package, or emitted under the map it replaced, and nothing else: `k/n`
168+
// for 1 <= k <= n, for n = FILE_SHARDED_PACKAGES[package] and n =
169+
// PREVIOUS_FILE_SHARDED_PACKAGES[package] from partition-test-shards.mjs. The
170+
// bound is those two closed maps -- n candidates per map that slices the
171+
// package, none for one that does not. The previous map is what lets a run
172+
// made before a map change still be read (its header says why it exists and
173+
// when it decodes nothing); a decoded slice is then summed within its run, so
174+
// an incomplete set still contributes nothing.
166175
//
167176
// ⛔ A digest that matches no candidate is REFUSED, never read as a whole
168177
// package. That is the #16173 direction exactly: one slice's window recorded as
169178
// the package's whole cost, a number that reads right and is n times too small.
170-
// It happens when FILE_SHARDED_PACKAGES changed since the run that wrote the
171-
// summary, or when the variable was set by hand to a slice the partitioner does
172-
// not emit; either way the remedy is a summary from a run under the current map.
179+
// It happens when the map changed twice within a summary's lifetime, or when
180+
// the variable was set by hand to a slice the partitioner does not emit; either
181+
// way the remedy is a summary from a run under the current map.
173182
//
174183
// An empty value is not a slice: the package's vitest config hands vitest an
175184
// empty `shard`, which it ignores, and the package runs whole. A task with no
176185
// `environmentVariables` record at all carries no evidence either way and is
177186
// read as carrying no env slice -- turbo 2.10.10 writes one on every task, and
178187
// the self-test fixtures predate it.
179-
export function sliceOfEnvironment(environmentVariables, name, label, sliced = FILE_SHARDED_PACKAGES) {
188+
export function sliceOfEnvironment(
189+
environmentVariables,
190+
name,
191+
label,
192+
sliced = FILE_SHARDED_PACKAGES,
193+
previous = PREVIOUS_FILE_SHARDED_PACKAGES
194+
) {
180195
const configured = environmentVariables?.configured;
181196
if (configured === undefined || configured === null) return null;
182197
if (!Array.isArray(configured)) {
@@ -187,28 +202,35 @@ export function sliceOfEnvironment(environmentVariables, name, label, sliced = F
187202
if (entry === undefined) return null;
188203
const digest = entry.slice(prefix.length);
189204
if (digest === '') return null;
190-
const count = Object.hasOwn(sliced, name) ? sliced[name] : 1;
205+
const counts = [];
206+
for (const map of [sliced, previous]) {
207+
const n = Object.hasOwn(map, name) ? map[name] : 1;
208+
if (n > 1 && !counts.includes(n)) counts.push(n);
209+
}
191210
const candidates = [];
192-
for (let index = 1; count > 1 && index <= count; index++) {
193-
const spec = `${index}/${count}`;
194-
if (createHash('sha256').update(spec).digest('hex') === digest) return { index, count };
195-
candidates.push(spec);
211+
for (const count of counts) {
212+
for (let index = 1; index <= count; index++) {
213+
const spec = `${index}/${count}`;
214+
if (createHash('sha256').update(spec).digest('hex') === digest) return { index, count };
215+
candidates.push(spec);
216+
}
196217
}
197218
throw new Error(
198219
`${label}: ${name} ran with ${SLICE_ENV} set (digest ${digest.slice(0, 16)}…), and that digest ` +
199-
`matches no slice the partitioner can emit for it (${candidates.length ? candidates.join(', ') : 'none: FILE_SHARDED_PACKAGES does not slice it'}). ` +
220+
'matches no slice the partitioner can emit for it, or emitted under the map it replaced ' +
221+
`(${candidates.length ? candidates.join(', ') : 'none: neither FILE_SHARDED_PACKAGES nor PREVIOUS_FILE_SHARDED_PACKAGES slices it'}). ` +
200222
'Refusing to read the window as a whole-package sample: it is one slice of the suite, and ' +
201-
'recording it as the whole cost is the #16173 defect. If FILE_SHARDED_PACKAGES changed since ' +
202-
'this run, refresh from a run made under the current map.'
223+
'recording it as the whole cost is the #16173 defect. If FILE_SHARDED_PACKAGES changed more ' +
224+
'than once since this run, refresh from a run made under the current map.'
203225
);
204226
}
205227

206228
// The slice one measured leg ran as, from either carrier. Both present and
207229
// disagreeing is not a reading to pick from: vitest would have run the CLI's,
208230
// but no workflow here sets both, so it is refused rather than resolved.
209-
function sliceOfLeg(leg, name, label, sliced) {
231+
function sliceOfLeg(leg, name, label, sliced, previous) {
210232
const fromArgs = sliceOfCliArguments(leg.cliArguments);
211-
const fromEnv = sliceOfEnvironment(leg.environmentVariables, name, label, sliced);
233+
const fromEnv = sliceOfEnvironment(leg.environmentVariables, name, label, sliced, previous);
212234
if (fromArgs && fromEnv && (fromArgs.index !== fromEnv.index || fromArgs.count !== fromEnv.count)) {
213235
throw new Error(
214236
`${label}: ${name} carries two different slices -- --shard=${fromArgs.index}/${fromArgs.count} ` +
@@ -250,7 +272,12 @@ const SAMPLED_TASKS = ['test', 'test:repo'];
250272
// Recording one leg's seconds alone (because the other was cached or failed)
251273
// would write a partial suite's cost as the package's whole cost, a reading
252274
// worse than today's undercount by #16466's own defect this card fixes.
253-
export function samplesFromSummary(parsed, label, sliced = FILE_SHARDED_PACKAGES) {
275+
export function samplesFromSummary(
276+
parsed,
277+
label,
278+
sliced = FILE_SHARDED_PACKAGES,
279+
previous = PREVIOUS_FILE_SHARDED_PACKAGES
280+
) {
254281
const tasks = parsed?.tasks;
255282
if (!Array.isArray(tasks)) {
256283
throw new Error(
@@ -313,7 +340,7 @@ export function samplesFromSummary(parsed, label, sliced = FILE_SHARDED_PACKAGES
313340
seconds += leg.seconds;
314341
// A passthrough slice (`cliArguments`, the nightly tiers) or an
315342
// `OS_TEST_SHARD` one (Test Core, #19278) -- see sliceOfEnvironment().
316-
slice ??= sliceOfLeg(leg, name, label, sliced);
343+
slice ??= sliceOfLeg(leg, name, label, sliced, previous);
317344
}
318345
samples.set(name, seconds);
319346
if (slice) slices.set(name, slice);
@@ -559,7 +586,7 @@ export function buildDataset({ perSummary, fileCounts, provenance, carryFrom = n
559586
// remedy is to find what stopped registering, never to lower the number.
560587
const SELF_TEST_BATTERIES = Object.freeze({
561588
'measure-test-shard-timings self-test': 56,
562-
'env-carried slices (#19278)': 10,
589+
'env-carried slices (#19278)': 11,
563590
});
564591

565592
// DELETING an entry silences that battery's floor exactly as effectively as
@@ -1156,7 +1183,7 @@ function selfTest() {
11561183
// carrier, an empty value, an unrelated variable -- and pin what the digest
11571184
// path must NOT read. Each case was ablated against the code it pins.
11581185
// `sliced` stands in for FILE_SHARDED_PACKAGES so the fixtures keep the short
1159-
// names above, and one case reads the REAL map.
1186+
// names above; one case reads the REAL maps, and one crosses a map change.
11601187
battery('env-carried slices (#19278)');
11611188
const digestOf = (value) => createHash('sha256').update(value).digest('hex');
11621189
const envTask = (pkg, start, end, value, extra = {}) => ({
@@ -1181,12 +1208,72 @@ function selfTest() {
11811208
if (r.samples.get('cli') !== 118.073) throw new Error('env slice: the slice window was not kept as the sample');
11821209
});
11831210
check(() => {
1184-
// The REAL map, the real package name, a digest of the form turbo writes.
1185-
const n = FILE_SHARDED_PACKAGES['@objectstack/cli'];
1186-
const r = samplesFromSummary(summary([envTask('@objectstack/cli', 0, 1000, `${n}/${n}`)]), 'f');
1187-
const s = r.slices.get('@objectstack/cli');
1188-
if (!s || s.index !== n || s.count !== n) {
1189-
throw new Error(`env slice: the live FILE_SHARDED_PACKAGES did not resolve ${n}/${n} (got ${JSON.stringify(s ?? null)})`);
1211+
// The REAL maps, a real package name, digests of the form turbo writes: the
1212+
// defaults ARE the partitioner's live FILE_SHARDED_PACKAGES and
1213+
// PREVIOUS_FILE_SHARDED_PACKAGES. Written for whatever state those maps are
1214+
// in -- every count either names for the CLI must decode its own last
1215+
// slice, and a count neither names must be refused listing exactly their
1216+
// candidates -- so it reds when a default stops being its live map.
1217+
const name = '@objectstack/cli';
1218+
const counts = [];
1219+
for (const map of [FILE_SHARDED_PACKAGES, PREVIOUS_FILE_SHARDED_PACKAGES]) {
1220+
const n = Object.hasOwn(map, name) ? map[name] : 1;
1221+
if (n > 1 && !counts.includes(n)) counts.push(n);
1222+
}
1223+
for (const n of counts) {
1224+
const s = samplesFromSummary(summary([envTask(name, 0, 1000, `${n}/${n}`)]), 'f').slices.get(name);
1225+
if (!s || s.index !== n || s.count !== n) {
1226+
throw new Error(`env slice: the live maps did not resolve ${name} ${n}/${n} (got ${JSON.stringify(s ?? null)})`);
1227+
}
1228+
}
1229+
const stranger = Math.max(1, ...counts) + 1;
1230+
let message = '';
1231+
try {
1232+
samplesFromSummary(summary([envTask(name, 0, 1000, `1/${stranger}`)]), 'f');
1233+
} catch (e) {
1234+
message = e.message;
1235+
}
1236+
const listed =
1237+
counts.flatMap((n) => Array.from({ length: n }, (_, i) => `${i + 1}/${n}`)).join(', ') ||
1238+
'none: neither FILE_SHARDED_PACKAGES nor PREVIOUS_FILE_SHARDED_PACKAGES slices it';
1239+
if (!message.includes(`(${listed})`)) {
1240+
throw new Error(
1241+
`env slice: a ${name} digest of 1/${stranger}, a count neither live map names, was not refused ` +
1242+
`listing (${listed}) (got ${JSON.stringify(message || 'no throw')})`
1243+
);
1244+
}
1245+
});
1246+
check(() => {
1247+
// ACROSS A MAP CHANGE: a run made before it, under a map that cut `cli` in
1248+
// two, read by a generator whose current map slices nothing. Both halves
1249+
// decode against the previous map and are summed WITHIN the run to the
1250+
// whole cost -- the reading the refresh lane needs while every retained
1251+
// run predates the change -- and a count neither map names is still
1252+
// refused. Without the previous map both halves are refused and the run
1253+
// measures nothing.
1254+
const none = Object.freeze({});
1255+
const was2 = Object.freeze({ cli: 2 });
1256+
const legA = samplesFromSummary(summary([envTask('cli', 0, 300_000, '1/2')]), 'a', none, was2);
1257+
const legB = samplesFromSummary(summary([envTask('cli', 0, 433_330, '2/2')]), 'b', none, was2);
1258+
const set = buildDataset({
1259+
perSummary: [{ ...legA, run: 'r' }, { ...legB, run: 'r' }],
1260+
fileCounts: new Map([['cli', 300]]),
1261+
provenance: {},
1262+
});
1263+
if (set.packages.cli !== 733.33 || set.skippedIncompleteSlices.length !== 0) {
1264+
throw new Error(
1265+
`env slice: a pre-change run's two halves did not assemble to 733.33s (got ${set.packages.cli}; ` +
1266+
`incomplete ${JSON.stringify(set.skippedIncompleteSlices)})`
1267+
);
1268+
}
1269+
let message = '';
1270+
try {
1271+
samplesFromSummary(summary([envTask('cli', 0, 1000, '1/3')]), 'c', none, was2);
1272+
} catch (e) {
1273+
message = e.message;
1274+
}
1275+
if (!message.includes('(1/2, 2/2)')) {
1276+
throw new Error(`env slice: a digest neither map names was not refused (got ${JSON.stringify(message || 'no throw')})`);
11901277
}
11911278
});
11921279
check(() => {

0 commit comments

Comments
 (0)