From 3924eb61967385fdbe65ebfbfca050ea2e8ed0b6 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 13 Nov 2026 10:33:20 -0700 Subject: [PATCH 01/20] fix(oracle-gaps): retire a batch's copies from Vitest's state A check runs each mutant in a new copy of its test file. Vitest's state kept every file and test task it ever ran (filesMap by path, idMap by task id), so a check held every test of every copy: about 377k test cases on a four-file suite, which exhausted a 2 GB heap. Once a batch's units are plain data, its copies leave the state: their stale idMap entries go first (shape-guarded), then clearFiles replaces each path's file entry. Co-authored-by: Isaac --- packages/oracles/oracle-gaps/check.mjs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/oracles/oracle-gaps/check.mjs b/packages/oracles/oracle-gaps/check.mjs index a1139a1..502c17f 100644 --- a/packages/oracles/oracle-gaps/check.mjs +++ b/packages/oracles/oracle-gaps/check.mjs @@ -1256,10 +1256,33 @@ export default async (env) => { } } finally { for (const copy of copies) globalThis.__oracleGapsCopies.delete(copy) + retireCopies(context, copies) rmSync(capture, { force: true }) } } + /** + * Drops a batch's copies from Vitest's state once their units are plain + * data. Each copy is a new file, and the state keeps every file and test + * task it ever ran: `filesMap` by path and `idMap` by task id, so a check + * holds every test of every copy (about 377k test cases in the four-file + * repro, which runs out of a 2 GB heap). `clearFiles` (Vitest 4.1 and 5.1 + * alike) replaces each path's file entry, but it leaves the copies' test + * tasks in `idMap`, so those go first. The map shapes are guarded: a + * Vitest that changes them throws here rather than leaking quietly. + */ + function retireCopies(context, copies) { + const state = context.state + if (!(state?.filesMap instanceof Map) || !(state?.idMap instanceof Map)) + throw new Error( + `oracle-gaps: this Vitest keeps no filesMap or idMap in its state, so a check cannot retire its copies`, + ) + const gone = new Set(copies) + for (const [id, task] of state.idMap) + if (gone.has(task.file?.filepath ?? task.filepath)) state.idMap.delete(id) + state.clearFiles(context.getRootProject(), copies) + } + async function check(options, store, log = console.error) { const authored = options.mutants ? loadAuthored(options.mutants) : [] seedVariable = `${options.envPrefix}_SEED` From 46dfd3e39ab21d64cfc9b3990b6940ad644204ec Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 13 Nov 2026 11:31:59 -0700 Subject: [PATCH 02/20] test(oracle-gaps): bounded-retention law after each batch The gap CLI's e2e driver sets ORACLE_GAPS_TEST_RETENTION on each run and records one line per batch: the count of Vitest state entries (filesMap, pathsSet, idMap) and Vite module nodes that still name the batch's retired copies. The law: each count is 0 after retirement. This commit adds the hook and the law, not the fix, so CI records the RED on the parent code. Co-authored-by: Isaac --- packages/oracles/oracle-gaps/check.mjs | 32 ++++++++++++++++ .../gap-report-oracle.property.test.mjs | 38 +++++++++++++++++-- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/packages/oracles/oracle-gaps/check.mjs b/packages/oracles/oracle-gaps/check.mjs index 502c17f..0583929 100644 --- a/packages/oracles/oracle-gaps/check.mjs +++ b/packages/oracles/oracle-gaps/check.mjs @@ -1281,6 +1281,38 @@ export default async (env) => { for (const [id, task] of state.idMap) if (gone.has(task.file?.filepath ?? task.filepath)) state.idMap.delete(id) state.clearFiles(context.getRootProject(), copies) + retiredEntries(context, copies) + } + + // The test hook (ORACLE_GAPS_TEST_RETENTION, a file path): after a batch's + // copies are retired, one line counts the state entries and module nodes + // that still name a retired copy. The e2e oracle's bounded-retention law + // reads it: each count is 0. + function retiredEntries(context, copies) { + const file = process.env.ORACLE_GAPS_TEST_RETENTION + if (!file) return + const gone = new Set(copies) + const named = (path) => gone.has(path) + const { filesMap, idMap, pathsSet } = context.state + let tasks = 0 + for (const task of idMap.values()) + if (named(task.file?.filepath ?? task.filepath)) tasks += 1 + let nodes = 0 + for (const environment of Object.values( + context.getRootProject().vite?.environments ?? {}, + )) + for (const copy of gone) + nodes += environment.moduleGraph?.fileToModulesMap.get(copy)?.size ?? 0 + appendFileSync( + file, + `${JSON.stringify({ + copies: copies.length, + filesMap: [...filesMap.keys()].filter(named).length, + pathsSet: [...pathsSet].filter(named).length, + idMap: tasks, + moduleNodes: nodes, + })}\n`, + ) } async function check(options, store, log = console.error) { diff --git a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs index 86cc8cc..5fe7342 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -647,13 +647,25 @@ function runCli(dir, files, how = {}) { writeFixture(dir, files) const before = new Set(filesUnder(dir)) const { args, out } = cliArgs(dir, files, how) + // The retention hook (ORACLE_GAPS_TEST_RETENTION): one line per batch, + // written beside the run's own temporary files, not in the package. + const hook = join( + mkdtempSync(join(tmpdir(), `gap-retention-`)), + `retention.jsonl`, + ) const child = spawnSync(process.execPath, args, { cwd: repo, encoding: `utf8`, timeout: 300_000, maxBuffer: 64 * 1024 * 1024, - env: cleanEnv(), + env: { ...cleanEnv(), ORACLE_GAPS_TEST_RETENTION: hook }, }) + const retention = existsSync(hook) + ? readFileSync(hook, `utf8`) + .split(`\n`) + .filter(Boolean) + .map((line) => JSON.parse(line)) + : [] const run = readRun( dir, out, @@ -667,7 +679,7 @@ function runCli(dir, files, how = {}) { .split(`\n`) .find((line) => line.startsWith(` replay: `)) ?.slice(` replay: `.length) - return { ...run, strayWrites: strays(dir, before), replay } + return { ...run, strayWrites: strays(dir, before), replay, retention } } /** @@ -1600,10 +1612,11 @@ async function driver(plan, observe) { cli: plan.cliPath === `spaced` ? copy : here, ...configArgs(plan), } - const { replay, ...cli } = runCli(dir, files, how) + const { replay, retention, ...cli } = runCli(dir, files, how) // How many units were computed or reused says how the result was // produced, not what it is, so it is not observed. observe(`cli`, { ...cli, evidence: undefined }) + observe(`retention`, retention) // A failing owner's replay, run as the author would run it (law 3). if (plan.fault === `brokenOwner`) observe(`replay`, { command: replay, ran: replayed(dir, replay) }) @@ -2170,6 +2183,25 @@ function check(plan, observations, expected) { ) const stream = streamBreach(`cli`, cli, expected, plan) if (stream) return stream + // Bounded retention (the register's C entry): once a batch's copies are + // retired, Vitest's filesMap, pathsSet, and idMap and Vite's module graph + // name none of them, so each count the hook reports is 0. + const retention = observations.find( + (o) => o.checkpoint === `retention`, + )?.value + const held = (retention ?? []).find( + (line) => line.filesMap + line.pathsSet + line.idMap + line.moduleNodes > 0, + ) + if (held) + return violation( + `retention`, + `a batch left its retired copies in Vitest or Vite state: ${JSON.stringify(held)}`, + `bounded retention`, + { + actual: held, + expected: { filesMap: 0, pathsSet: 0, idMap: 0, moduleNodes: 0 }, + }, + ) // Through the daemon, each check streams the same kinds of line, and a // completed one ends with its done line (law 15). The state of each step // differs, so its verdict and counts are judged by law 9's comparison. From 84a1085eb1505d4de5b2c62b1804a0798d0057a9 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 13 Nov 2026 12:04:57 -0700 Subject: [PATCH 03/20] fix(oracle-gaps): retire a copy's Vitest and Vite state, not only its file entry Vitest's clearFiles replaces a copy's file entry, and leaves its path in pathsSet, one placeholder task in idMap, and Vite's module node and transform for the copy. The retention law (bounded retention, after each batch) fails on 46dfd3e in CI: each count is nonzero. retireCopies now removes the copy's filesMap key, pathsSet entry, and idMap tasks. retireModules invalidates each copy's module through Vite's API and removes it from every environment's graph maps. Both guard the shapes they touch and throw if one changes, so a Vitest or Vite that changes them fails loudly rather than leaking. Co-authored-by: Isaac --- packages/oracles/oracle-gaps/check.mjs | 115 +++++++++++++++++++------ 1 file changed, 91 insertions(+), 24 deletions(-) diff --git a/packages/oracles/oracle-gaps/check.mjs b/packages/oracles/oracle-gaps/check.mjs index 0583929..1a63f92 100644 --- a/packages/oracles/oracle-gaps/check.mjs +++ b/packages/oracles/oracle-gaps/check.mjs @@ -1262,33 +1262,101 @@ export default async (env) => { } /** - * Drops a batch's copies from Vitest's state once their units are plain - * data. Each copy is a new file, and the state keeps every file and test - * task it ever ran: `filesMap` by path and `idMap` by task id, so a check - * holds every test of every copy (about 377k test cases in the four-file - * repro, which runs out of a 2 GB heap). `clearFiles` (Vitest 4.1 and 5.1 - * alike) replaces each path's file entry, but it leaves the copies' test - * tasks in `idMap`, so those go first. The map shapes are guarded: a - * Vitest that changes them throws here rather than leaking quietly. + * Removes a batch's copies from Vitest's state and from Vite's module + * graphs once their units are plain data (the register's C entry: the gap + * CLI keeps no Vitest or Vite state for a mutant copy after the copy's + * results are plain data). Each copy is a new file. Vitest keeps its path + * in `filesMap` and `pathsSet`, and the file and test tasks it ran in + * `idMap`, and Vite keeps a module node, with its transform, per copy. + * Vitest's `clearFiles` replaces the path's file entry only, and leaves + * `pathsSet` and the test tasks, so it is not used: each entry is removed. + * Every map touched is guarded, and a Vitest or Vite whose shape changes + * throws here rather than leaking quietly. During a run, Vitest holds the + * run's copies until it ends, so the in-run peak scales with the batch's + * share of copies, not with the suite. */ function retireCopies(context, copies) { - const state = context.state - if (!(state?.filesMap instanceof Map) || !(state?.idMap instanceof Map)) + const { filesMap, idMap, pathsSet } = context.state ?? {} + if ( + !(filesMap instanceof Map) || + !(idMap instanceof Map) || + !(pathsSet instanceof Set) + ) throw new Error( - `oracle-gaps: this Vitest keeps no filesMap or idMap in its state, so a check cannot retire its copies`, + `oracle-gaps: this Vitest keeps no filesMap, idMap, or pathsSet in its state, so a check cannot retire its copies`, ) const gone = new Set(copies) - for (const [id, task] of state.idMap) - if (gone.has(task.file?.filepath ?? task.filepath)) state.idMap.delete(id) - state.clearFiles(context.getRootProject(), copies) - retiredEntries(context, copies) + for (const [id, task] of idMap) + if (gone.has(task.file?.filepath ?? task.filepath)) idMap.delete(id) + for (const copy of copies) { + filesMap.delete(copy) + pathsSet.delete(copy) + } + const modulesRemoved = retireModules(context.getRootProject().vite, gone) + retiredEntries(context, copies, modulesRemoved) + } + + /** + * Removes the copies' module nodes from every environment's Vite graph. + * Each node is invalidated through Vite's API (which drops its transform), + * then removed from the graph's id, url, etag, and file maps, and from its + * neighbours' import sets. The graph's map shapes are guarded. + */ + function retireModules(server, gone) { + const environments = viteEnvironments(server) + let removed = 0 + for (const environment of environments) { + const graph = environment.moduleGraph + const { + urlToModuleMap, + idToModuleMap, + etagToModuleMap, + fileToModulesMap, + } = graph ?? {} + if ( + !(urlToModuleMap instanceof Map) || + !(idToModuleMap instanceof Map) || + !(etagToModuleMap instanceof Map) || + !(fileToModulesMap instanceof Map) || + typeof graph.invalidateModule !== `function` + ) + throw new Error( + `oracle-gaps: this Vite's module graph has no id, url, etag, or file maps, so a check cannot retire its copies`, + ) + for (const copy of gone) { + for (const mod of fileToModulesMap.get(copy) ?? []) { + removed += 1 + graph.invalidateModule(mod) + for (const imported of mod.importedModules) + imported.importers.delete(mod) + for (const importer of mod.importers) + importer.importedModules.delete(mod) + idToModuleMap.delete(mod.id) + urlToModuleMap.delete(mod.url) + if (mod.etag !== undefined) etagToModuleMap.delete(mod.etag) + } + fileToModulesMap.delete(copy) + } + } + return removed } - // The test hook (ORACLE_GAPS_TEST_RETENTION, a file path): after a batch's - // copies are retired, one line counts the state entries and module nodes - // that still name a retired copy. The e2e oracle's bounded-retention law - // reads it: each count is 0. - function retiredEntries(context, copies) { + // Vite's environments, each with its module graph. A Vite that exposes none + // would make the module step a silent no-op, so it throws here. + function viteEnvironments(server) { + const environments = Object.values(server?.environments ?? {}) + if (environments.length === 0 || environments.some((e) => !e?.moduleGraph)) + throw new Error( + `oracle-gaps: this Vitest exposes no Vite environments with module graphs, so a check cannot retire its copies' module nodes`, + ) + return environments + } + + // The test hook (ORACLE_GAPS_TEST_RETENTION, a file path): after each + // batch's copies are retired, one line counts the state entries and module + // nodes that still name a retired copy. The retention law reads it: each + // count is 0. + function retiredEntries(context, copies, modulesRemoved) { const file = process.env.ORACLE_GAPS_TEST_RETENTION if (!file) return const gone = new Set(copies) @@ -1298,11 +1366,9 @@ export default async (env) => { for (const task of idMap.values()) if (named(task.file?.filepath ?? task.filepath)) tasks += 1 let nodes = 0 - for (const environment of Object.values( - context.getRootProject().vite?.environments ?? {}, - )) + for (const environment of viteEnvironments(context.getRootProject().vite)) for (const copy of gone) - nodes += environment.moduleGraph?.fileToModulesMap.get(copy)?.size ?? 0 + nodes += environment.moduleGraph.fileToModulesMap.get(copy)?.size ?? 0 appendFileSync( file, `${JSON.stringify({ @@ -1311,6 +1377,7 @@ export default async (env) => { pathsSet: [...pathsSet].filter(named).length, idMap: tasks, moduleNodes: nodes, + modulesRemoved, })}\n`, ) } From ce46f4bf078468c58f3a81e029c5969adc0706cb Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 13 Nov 2026 14:20:59 -0700 Subject: [PATCH 04/20] test(oracle-gaps): the incremental violation names a cold run's error log When the cold run exits 3, its stack is in its error.log. The incremental violation named only the exit code, so a failure printed no stack. It now appends the error log the driver already reads for a run that exits 3. Co-authored-by: Isaac --- .../oracles/oracle-gaps/gap-report-oracle.property.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs index 5fe7342..6d0c48a 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -2103,7 +2103,7 @@ function incrementalCheck(observations) { ) return violation( `incremental`, - `after ${step}, the daemon's check exited ${daemon.exit} (${daemon.refusal ?? daemon.stderr ?? `no refusal`}) and the cold run exited ${cold.exit} (${cold.refusal ?? `no refusal`})`, + `after ${step}, the daemon's check exited ${daemon.exit} (${daemon.refusal ?? daemon.stderr ?? `no refusal`}) and the cold run exited ${cold.exit} (${cold.refusal ?? `no refusal`})${cold.exit === 3 && cold.stderr ? `; its error log: ${cold.stderr}` : ``}`, `incremental equals cold`, { actual: daemon, expected: cold }, ) From 2de2b9eab86e12ce09d894570ea36e164f1a1e22 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 13 Nov 2026 14:34:34 -0700 Subject: [PATCH 05/20] test(oracle-gaps): the incremental observation carries a cold run's error log ce46f4b appends cold.stderr to the incremental violation when the cold run exits 3, but the incremental observation's cold run had no stderr, so the message stayed empty. It now carries the run's stderr, with its error.log, when it exits 3, and only then: a completed run's stderr holds timings, which differ between two runs of one history. Co-authored-by: Isaac --- .../oracles/oracle-gaps/gap-report-oracle.property.test.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs index 6d0c48a..38d9f33 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -1330,6 +1330,11 @@ async function incremental(dir, plan, cli, copy, observe, states) { refusal: coldRun.refusal, report: coldRun.report, notes: coldRun.stream.notes, + // An internal error's stack (its error.log, which readRun reads + // for a run that exits 3), for the violation's message. Only + // then: a completed run's stderr holds timings, which differ + // between two runs of one history. + stderr: coldRun.exit === 3 ? coldRun.stderr : undefined, }, daemon: { exit: run.exit, From ca327dca8b7e439d51b96a613fadce189f7fe319 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 13 Nov 2026 15:57:34 -0700 Subject: [PATCH 06/20] fix(oracle-gaps): a replaced context leaves its maps; a cut-off run is not retired A replaced context stayed reachable through indexOf and contextSpecs, which were never cleared for it, so its Vitest and Vite state stayed too. replace now drops both entries. A run cut off by a hang is not retired: its worker may still send updates, and its context is replaced by the caller. Also: the etag map is cleaned by node identity (Vite keys it by the etag), and the retention observation is the count of batches left with a retired copy's state, which is deterministic, not the per-batch lines. Co-authored-by: Isaac --- packages/oracles/oracle-gaps/check.mjs | 13 +++++++-- .../gap-report-oracle.property.test.mjs | 27 ++++++++++--------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/oracles/oracle-gaps/check.mjs b/packages/oracles/oracle-gaps/check.mjs index 1a63f92..bf90827 100644 --- a/packages/oracles/oracle-gaps/check.mjs +++ b/packages/oracles/oracle-gaps/check.mjs @@ -1256,7 +1256,10 @@ export default async (env) => { } } finally { for (const copy of copies) globalThis.__oracleGapsCopies.delete(copy) - retireCopies(context, copies) + // A run cut off by a hang is still running: its worker may still send + // updates, so its state is not touched here. Its context is replaced + // by the caller (replace), which closes it and drops its state. + if (result !== undefined) retireCopies(context, copies) rmSync(capture, { force: true }) } } @@ -1333,7 +1336,9 @@ export default async (env) => { importer.importedModules.delete(mod) idToModuleMap.delete(mod.id) urlToModuleMap.delete(mod.url) - if (mod.etag !== undefined) etagToModuleMap.delete(mod.etag) + // Vite keys the etag map by the etag, whose value is the node. + for (const [etag, node] of etagToModuleMap) + if (node === mod) etagToModuleMap.delete(etag) } fileToModulesMap.delete(copy) } @@ -1829,6 +1834,10 @@ export default async (env) => { closeContext(slot.context).catch(() => undefined), sleep(10_000), ]) + // The replaced context leaves every map keyed by it, so it is no longer + // reachable (its Vitest and Vite state with it). + indexOf.delete(slot.context) + contextSpecs.delete(slot.context) slot.context = await warmContext(settings, slot.index) await globSpecs(slot.context) contexts[slot.index] = slot.context diff --git a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs index 38d9f33..e6061d9 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -1621,7 +1621,15 @@ async function driver(plan, observe) { // How many units were computed or reused says how the result was // produced, not what it is, so it is not observed. observe(`cli`, { ...cli, evidence: undefined }) - observe(`retention`, retention) + // Deterministic: how many batches left a retired copy's state behind (0), + // not the per-batch lines, whose batch splits vary with hang cuts. + observe( + `retention`, + retention.filter( + (line) => + line.filesMap + line.pathsSet + line.idMap + line.moduleNodes > 0, + ).length, + ) // A failing owner's replay, run as the author would run it (law 3). if (plan.fault === `brokenOwner`) observe(`replay`, { command: replay, ran: replayed(dir, replay) }) @@ -2191,21 +2199,14 @@ function check(plan, observations, expected) { // Bounded retention (the register's C entry): once a batch's copies are // retired, Vitest's filesMap, pathsSet, and idMap and Vite's module graph // name none of them, so each count the hook reports is 0. - const retention = observations.find( - (o) => o.checkpoint === `retention`, - )?.value - const held = (retention ?? []).find( - (line) => line.filesMap + line.pathsSet + line.idMap + line.moduleNodes > 0, - ) - if (held) + const held = + observations.find((o) => o.checkpoint === `retention`)?.value ?? 0 + if (held > 0) return violation( `retention`, - `a batch left its retired copies in Vitest or Vite state: ${JSON.stringify(held)}`, + `${held} batch(es) left a retired copy's Vitest or Vite state behind`, `bounded retention`, - { - actual: held, - expected: { filesMap: 0, pathsSet: 0, idMap: 0, moduleNodes: 0 }, - }, + { actual: held, expected: 0 }, ) // Through the daemon, each check streams the same kinds of line, and a // completed one ends with its done line (law 15). The state of each step From ce6c93dbe5b93636b8693149af33d7d2a50e98a9 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 13 Nov 2026 16:03:26 -0700 Subject: [PATCH 07/20] test(oracle-gaps): the retention law compares every structure with its size before the batch The hook now snapshots every structure a copy can leave state in (Vitest's filesMap, pathsSet, idMap, errorsSet, leakSet, and each Vite environment's url, id, etag and file maps and transformed nodes) before a batch, and the law requires each to return to its size after the batch's retirement. The law was tautological before: it counted the same predicate the deletion used. The retention observation is observed from every run kind (cold, daemon, stability, partial), and a run that computed a unit must have written a batch line. A check that ran more than one batch reaches a declared state. Co-authored-by: Isaac --- packages/oracles/oracle-gaps/check.mjs | 67 ++++++++++++------- .../gap-report-oracle.property.test.mjs | 60 ++++++++++++++--- 2 files changed, 92 insertions(+), 35 deletions(-) diff --git a/packages/oracles/oracle-gaps/check.mjs b/packages/oracles/oracle-gaps/check.mjs index bf90827..f6b43e9 100644 --- a/packages/oracles/oracle-gaps/check.mjs +++ b/packages/oracles/oracle-gaps/check.mjs @@ -1004,6 +1004,9 @@ export default async (env) => { let copyCounter = 0 async function runBatch(context, out, items, seed) { const capture = join(out, `run-${process.pid}-${++runCounter}.jsonl`) + const before = process.env.ORACLE_GAPS_TEST_RETENTION + ? retentionTotals(context) + : undefined // Each copy is named beside its file, so that its imports resolve as the // file's do, and the wrapper's plugin serves its text: nothing is // written there. @@ -1259,7 +1262,10 @@ export default async (env) => { // A run cut off by a hang is still running: its worker may still send // updates, so its state is not touched here. Its context is replaced // by the caller (replace), which closes it and drops its state. - if (result !== undefined) retireCopies(context, copies) + if (result !== undefined) { + retireCopies(context, copies) + retiredEntries(context, before) + } rmSync(capture, { force: true }) } } @@ -1295,8 +1301,7 @@ export default async (env) => { filesMap.delete(copy) pathsSet.delete(copy) } - const modulesRemoved = retireModules(context.getRootProject().vite, gone) - retiredEntries(context, copies, modulesRemoved) + retireModules(context.getRootProject().vite, gone) } /** @@ -1357,33 +1362,43 @@ export default async (env) => { return environments } - // The test hook (ORACLE_GAPS_TEST_RETENTION, a file path): after each - // batch's copies are retired, one line counts the state entries and module - // nodes that still name a retired copy. The retention law reads it: each - // count is 0. - function retiredEntries(context, copies, modulesRemoved) { + // Every structure a copy can leave state in, unfiltered: Vitest's state + // (filesMap, pathsSet, idMap, errorsSet, leakSet) and each Vite environment's + // module graph (its four maps, and the nodes that hold a transform). + function retentionTotals(context) { + const { filesMap, pathsSet, idMap, errorsSet, leakSet } = context.state + const totals = { + filesMap: filesMap.size, + pathsSet: pathsSet.size, + idMap: idMap.size, + errorsSet: errorsSet.size, + leakSet: leakSet.size, + } + for (const [name, environment] of Object.entries( + context.getRootProject().vite?.environments ?? {}, + )) { + const graph = environment.moduleGraph + totals[`${name}.url`] = graph.urlToModuleMap.size + totals[`${name}.id`] = graph.idToModuleMap.size + totals[`${name}.etag`] = graph.etagToModuleMap.size + totals[`${name}.file`] = graph.fileToModulesMap.size + totals[`${name}.transformed`] = [...graph.idToModuleMap.values()].filter( + (node) => node.transformResult != null, + ).length + } + return totals + } + + // The test hook (ORACLE_GAPS_TEST_RETENTION, a file path): after a run + // that settled and its copies are retired, one line holds each structure's + // total before the batch and after its retirement. The bounded-retention + // law reads it: every total returns to its size before the batch. + function retiredEntries(context, before) { const file = process.env.ORACLE_GAPS_TEST_RETENTION if (!file) return - const gone = new Set(copies) - const named = (path) => gone.has(path) - const { filesMap, idMap, pathsSet } = context.state - let tasks = 0 - for (const task of idMap.values()) - if (named(task.file?.filepath ?? task.filepath)) tasks += 1 - let nodes = 0 - for (const environment of viteEnvironments(context.getRootProject().vite)) - for (const copy of gone) - nodes += environment.moduleGraph.fileToModulesMap.get(copy)?.size ?? 0 appendFileSync( file, - `${JSON.stringify({ - copies: copies.length, - filesMap: [...filesMap.keys()].filter(named).length, - pathsSet: [...pathsSet].filter(named).length, - idMap: tasks, - moduleNodes: nodes, - modulesRemoved, - })}\n`, + `${JSON.stringify({ before, after: retentionTotals(context) })}\n`, ) } diff --git a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs index e6061d9..e2451d7 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -639,6 +639,16 @@ const configArgs = (plan) => ({ contexts: plan.contexts, }) +// A run that computed a unit ran at least one batch (its evidence counts it), so it +// owes a retention line; a run that computed none (a full reuse, or a refusal) may write none. +const missingLine = (run) => + run.evidence?.computed > 0 && run.retention.length === 0 ? 1 : 0 + +// A batch line (ORACLE_GAPS_TEST_RETENTION) left state behind when a total +// after its retirement differs from its size before the batch. +const leftBehind = (line) => + Object.keys(line.before).some((key) => line.before[key] !== line.after[key]) + /** * Writes the fixture's files and runs the CLI on it. A file of an earlier * state that this state lacks is removed. @@ -1322,7 +1332,16 @@ async function incremental(dir, plan, cli, copy, observe, states) { : await daemonChecks(step, files, how) if (!cold.has(key)) cold.set(key, runCli(dir, files, how)) const coldRun = cold.get(key) - for (const [i, run] of [daemon, ...others].entries()) + for (const [i, run] of [daemon, ...others].entries()) { + observe( + `retention:daemon`, + (run.retention ?? []).filter(leftBehind).length, + ) + observe(`retentionLines:daemon`, missingLine(run)) + observe( + `retention:cold`, + (coldRun.retention ?? []).filter(leftBehind).length, + ) observe(`incremental`, { step: i === 0 ? step : `${step} (a second check at once)`, cold: { @@ -1349,6 +1368,7 @@ async function incremental(dir, plan, cli, copy, observe, states) { stderr: run.stderr?.split(`\n`)[0], }, }) + } if (twice && step === `start`) states.add(`two checks started the daemon at once`) if ( @@ -1623,18 +1643,17 @@ async function driver(plan, observe) { observe(`cli`, { ...cli, evidence: undefined }) // Deterministic: how many batches left a retired copy's state behind (0), // not the per-batch lines, whose batch splits vary with hang cuts. + observe(`retention`, retention.filter(leftBehind).length) observe( - `retention`, - retention.filter( - (line) => - line.filesMap + line.pathsSet + line.idMap + line.moduleNodes > 0, - ).length, + `retentionLines:cold`, + missingLine({ evidence: cli.evidence, retention }), ) // A failing owner's replay, run as the author would run it (law 3). if (plan.fault === `brokenOwner`) observe(`replay`, { command: replay, ran: replayed(dir, replay) }) // Reach comes from what the CLI reported, mapped onto the plan. const states = new Set() + if (retention.length > 1) states.add(`a check that ran more than one batch`) if (cli.exit === 0 && !plan.consoleOutput) states.add(`a completed run whose tests wrote nothing`) // Law 14: a partial run of one owned function's mutants, whose baseline @@ -1651,6 +1670,8 @@ async function driver(plan, observe) { only: ids, outName: `out-only`, }) + observe(`retention:partial`, partial.retention.filter(leftBehind).length) + observe(`retentionLines:partial`, missingLine(partial)) observe(`partial`, { function: chosen.function, source: chosen.source, @@ -2075,7 +2096,13 @@ async function driver(plan, observe) { const moved = { ...files, [SOURCE]: edited(plan) } // The evidence counts are not observed, as for `cli`: they depend on // timing (oracles-followups.md G1). Verdicts and keys are. - observe(`stability`, { ...runCli(dir, moved, how), evidence: undefined }) + const { retention: movedRetention, ...movedRun } = runCli(dir, moved, how) + observe(`stability`, { ...movedRun, evidence: undefined }) + observe(`retention:stability`, movedRetention.filter(leftBehind).length) + observe( + `retentionLines:stability`, + missingLine({ evidence: movedRun.evidence, retention: movedRetention }), + ) states.add(`stability rerun`) } if (plan.steps.length > 0) @@ -2199,8 +2226,23 @@ function check(plan, observations, expected) { // Bounded retention (the register's C entry): once a batch's copies are // retired, Vitest's filesMap, pathsSet, and idMap and Vite's module graph // name none of them, so each count the hook reports is 0. - const held = - observations.find((o) => o.checkpoint === `retention`)?.value ?? 0 + const unlogged = observations + .filter((o) => o.checkpoint.startsWith(`retentionLines`)) + .reduce((sum, o) => sum + o.value, 0) + if (unlogged > 0) + return violation( + `retention`, + `${unlogged} run(s) computed a unit but wrote no batch retention line`, + `bounded retention`, + { actual: unlogged, expected: 0 }, + ) + const held = observations + .filter( + (o) => + o.checkpoint.startsWith(`retention`) && + !o.checkpoint.startsWith(`retentionLines`), + ) + .reduce((sum, o) => sum + o.value, 0) if (held > 0) return violation( `retention`, From 390025ee4c5ac4c5fbaee81685917d2c7958e111 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sat, 14 Nov 2026 07:55:31 -0700 Subject: [PATCH 08/20] test(oracle-gaps): the retention law counts stale replaced contexts; a batch line is owed by mutant results The totals now include the excess of indexOf and contextSpecs over the live contexts, which must be zero before each batch: a context replaced between batches is caught there, not by a single batch's before and after. The missing-line expectation read evidence.computed, which counts single-file runs that write no batch line, so it failed on ce6c93d. A run owes a batch line when a mutant has owner files, since such a mutant's units ran in batches. Without that check, a retirement that never runs writes no line and passes the count law vacuously: the no-retire ablation exited 0. Co-authored-by: Isaac --- packages/oracles/oracle-gaps/check.mjs | 4 ++++ .../gap-report-oracle.property.test.mjs | 21 +++++++++++++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/oracles/oracle-gaps/check.mjs b/packages/oracles/oracle-gaps/check.mjs index f6b43e9..99b7751 100644 --- a/packages/oracles/oracle-gaps/check.mjs +++ b/packages/oracles/oracle-gaps/check.mjs @@ -1368,6 +1368,10 @@ export default async (env) => { function retentionTotals(context) { const { filesMap, pathsSet, idMap, errorsSet, leakSet } = context.state const totals = { + // Maps keyed by a context: each holds one entry per live context. A replaced + // context left in one is stale: the excess over the live contexts. + staleIndexed: indexOf.size - contexts.length, + staleSpecs: contextSpecs.size - contexts.length, filesMap: filesMap.size, pathsSet: pathsSet.size, idMap: idMap.size, diff --git a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs index e2451d7..aa822b3 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -639,15 +639,24 @@ const configArgs = (plan) => ({ contexts: plan.contexts, }) -// A run that computed a unit ran at least one batch (its evidence counts it), so it -// owes a retention line; a run that computed none (a full reuse, or a refusal) may write none. +// A mutant whose owner files reached it ran their units in batches (runOwners), so a +// run with such a mutant owes at least one batch line. Single-file runs (unmutated and +// selected) write none and are not counted: `evidence.computed` includes them, so it +// does not say whether a batch ran. A refused or fully reused run may write none. const missingLine = (run) => - run.evidence?.computed > 0 && run.retention.length === 0 ? 1 : 0 + run.mutants?.some((m) => m.oracleFiles.length > 0) && + run.retention.length === 0 + ? 1 + : 0 // A batch line (ORACLE_GAPS_TEST_RETENTION) left state behind when a total // after its retirement differs from its size before the batch. const leftBehind = (line) => - Object.keys(line.before).some((key) => line.before[key] !== line.after[key]) + Object.keys(line.before).some( + (key) => + line.before[key] !== line.after[key] || + (key.startsWith(`stale`) && line.before[key] !== 0), + ) /** * Writes the fixture's files and runs the CLI on it. A file of an earlier @@ -1646,7 +1655,7 @@ async function driver(plan, observe) { observe(`retention`, retention.filter(leftBehind).length) observe( `retentionLines:cold`, - missingLine({ evidence: cli.evidence, retention }), + missingLine({ mutants: cli.mutants, retention }), ) // A failing owner's replay, run as the author would run it (law 3). if (plan.fault === `brokenOwner`) @@ -2101,7 +2110,7 @@ async function driver(plan, observe) { observe(`retention:stability`, movedRetention.filter(leftBehind).length) observe( `retentionLines:stability`, - missingLine({ evidence: movedRun.evidence, retention: movedRetention }), + missingLine({ mutants: movedRun.mutants, retention: movedRetention }), ) states.add(`stability rerun`) } From 59363c88788d5ed1087d8cdef83c864d13a5b9eb Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sat, 14 Nov 2026 09:00:50 -0700 Subject: [PATCH 09/20] fix(oracle-gaps): a cold check keeps no unit for reuse The cold check's store kept every computed unit, with its rows, for the whole run: 207 store entries and 103,585 row entries in the ce6c93d heap snapshot. A cold run serves no lookup (evidence.reused is 0 on every klona run), so the store now keeps nothing. The daemon's disk store is unchanged. The retention totals include the store's size, which must match before and after each batch. Paired identity runs (pre-change and this commit, both cut 0) give the same 391 results, and the baseline matches both. Co-authored-by: Isaac --- packages/oracles/oracle-gaps/check.mjs | 5 +++++ packages/oracles/oracle-gaps/evidence.mjs | 14 +++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/oracles/oracle-gaps/check.mjs b/packages/oracles/oracle-gaps/check.mjs index 3a918a8..8458b21 100644 --- a/packages/oracles/oracle-gaps/check.mjs +++ b/packages/oracles/oracle-gaps/check.mjs @@ -553,6 +553,8 @@ export default async (env) => { return lastServed.get(index) } const indexOf = new Map() + // The units the check's store holds, set by check() (its store's size). + let heldUnits = () => 0 let contexts = [] let contextsKey let contextsConfig @@ -1387,6 +1389,8 @@ export default async (env) => { function retentionTotals(context) { const { filesMap, pathsSet, idMap, errorsSet, leakSet } = context.state const totals = { + // The cold check's store: units it keeps for reuse (none, see evidence.mjs). + store: heldUnits(), // Maps keyed by a context: each holds one entry per live context. A replaced // context left in one is stale: the excess over the live contexts. staleIndexed: indexOf.size - contexts.length, @@ -1426,6 +1430,7 @@ export default async (env) => { } async function check(options, store, log = console.error) { + heldUnits = () => store.size?.() ?? 0 const authored = options.mutants ? loadAuthored(options.mutants) : [] seedVariable = `${options.envPrefix}_SEED` if (options.hangLimit > options.mutantLimit) diff --git a/packages/oracles/oracle-gaps/evidence.mjs b/packages/oracles/oracle-gaps/evidence.mjs index dd018c1..5a2e958 100644 --- a/packages/oracles/oracle-gaps/evidence.mjs +++ b/packages/oracles/oracle-gaps/evidence.mjs @@ -57,14 +57,14 @@ const withVersion = (row, variant) => ), ].slice(0, VERSIONS) -/** A store that lives for one check. */ +/** + * The store of a cold check. It keeps no unit: a cold run serves no lookup + * (its evidence reports `reused` 0 on every klona run measured), so a unit + * kept for reuse would only be retained, per mutant copy, for the whole run. + * `size` counts what it holds, for the retention law (check.mjs). + */ export function memoryStore() { - const rows = new Map() - return { - get: (id) => rows.get(id), - put: (id, variant) => - rows.set(id, { id, variants: withVersion(rows.get(id), variant) }), - } + return { get: () => undefined, put: () => {}, size: () => 0 } } /** From 969a7a8f61e253aa5859ffa613c0eb03860b4eec Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sat, 14 Nov 2026 09:16:35 -0700 Subject: [PATCH 10/20] test(oracle-gaps): the retention law counts batches exactly; cut batches are logged evidence.batches counts every batch run (cut or not). Each batch writes one retention line, so each run's line count must equal batches: a run with no line for a batch it ran (the no-retire mutant) fails the law. A cut batch writes its before line, so the stale totals of a replaced context are checked there. Co-authored-by: Isaac --- packages/oracles/oracle-gaps/check.mjs | 14 +++++-- .../gap-report-oracle.property.test.mjs | 39 +++++++------------ 2 files changed, 26 insertions(+), 27 deletions(-) diff --git a/packages/oracles/oracle-gaps/check.mjs b/packages/oracles/oracle-gaps/check.mjs index 8458b21..1cf8227 100644 --- a/packages/oracles/oracle-gaps/check.mjs +++ b/packages/oracles/oracle-gaps/check.mjs @@ -563,6 +563,8 @@ export default async (env) => { let seedVariable = `ORACLE_SEED` const contextSpecs = new Map() let runCounter = 0 + // The current check's evidence, which runBatch counts its batches in (set by check). + let evidenceOf // A context loads the package's own config through its wrapper, so an // error there is the author's: the run refuses, naming the config file @@ -1020,6 +1022,7 @@ export default async (env) => { */ let copyCounter = 0 async function runBatch(context, out, items, seed) { + evidenceOf.batches += 1 const capture = join(out, `run-${process.pid}-${++runCounter}.jsonl`) const before = process.env.ORACLE_GAPS_TEST_RETENTION ? retentionTotals(context) @@ -1286,7 +1289,7 @@ export default async (env) => { if (result !== undefined) { retireCopies(context, copies) retiredEntries(context, before) - } + } else retiredEntries(context, before, true) rmSync(capture, { force: true }) } } @@ -1420,12 +1423,14 @@ export default async (env) => { // that settled and its copies are retired, one line holds each structure's // total before the batch and after its retirement. The bounded-retention // law reads it: every total returns to its size before the batch. - function retiredEntries(context, before) { + function retiredEntries(context, before, cut = false) { const file = process.env.ORACLE_GAPS_TEST_RETENTION if (!file) return + // A cut run is not retired (its context is replaced), so its line has no after: its before still + // names the stale totals the next batch starts from. appendFileSync( file, - `${JSON.stringify({ before, after: retentionTotals(context) })}\n`, + `${JSON.stringify(cut ? { before, cut: true } : { before, after: retentionTotals(context) })}\n`, ) } @@ -1563,7 +1568,10 @@ export default async (env) => { unchanged: 0, // Confirmations that a mutant's budget cut short or left no time. budget: 0, + // Every batch run (runBatch), cut or not: each writes one retention line. + batches: 0, } + evidenceOf = evidence // Each file's imports in its unmutated run. A run that timed out reports // none, so it is stored under these: a mutant changes no import. const fileInputs = new Map() diff --git a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs index d4dc1d8..11bd4b9 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -639,23 +639,18 @@ const configArgs = (plan) => ({ contexts: plan.contexts, }) -// A mutant whose owner files reached it ran their units in batches (runOwners), so a -// run with such a mutant owes at least one batch line. Single-file runs (unmutated and -// selected) write none and are not counted: `evidence.computed` includes them, so it -// does not say whether a batch ran. A refused or fully reused run may write none. -const missingLine = (run) => - run.mutants?.some((m) => m.oracleFiles.length > 0) && - run.retention.length === 0 - ? 1 - : 0 +// Exact: a run writes one retention line per batch it ran (evidence.batches, cut batches +// included), so its line count must equal that counter. Zero batches write none. +const unlogged = (run) => + run.retention.length === run.evidence.batches ? 0 : 1 // A batch line (ORACLE_GAPS_TEST_RETENTION) left state behind when a total // after its retirement differs from its size before the batch. const leftBehind = (line) => Object.keys(line.before).some( (key) => - line.before[key] !== line.after[key] || - (key.startsWith(`stale`) && line.before[key] !== 0), + (key.startsWith(`stale`) && line.before[key] !== 0) || + (line.after !== undefined && line.before[key] !== line.after[key]), ) /** @@ -1346,7 +1341,7 @@ async function incremental(dir, plan, cli, copy, observe, states) { `retention:daemon`, (run.retention ?? []).filter(leftBehind).length, ) - observe(`retentionLines:daemon`, missingLine(run)) + observe(`retentionLines:daemon`, unlogged(run)) observe( `retention:cold`, (coldRun.retention ?? []).filter(leftBehind).length, @@ -1655,7 +1650,7 @@ async function driver(plan, observe) { observe(`retention`, retention.filter(leftBehind).length) observe( `retentionLines:cold`, - missingLine({ mutants: cli.mutants, retention }), + unlogged({ retention, evidence: cli.evidence }), ) // A failing owner's replay, run as the author would run it (law 3). if (plan.fault === `brokenOwner`) @@ -1680,7 +1675,7 @@ async function driver(plan, observe) { outName: `out-only`, }) observe(`retention:partial`, partial.retention.filter(leftBehind).length) - observe(`retentionLines:partial`, missingLine(partial)) + observe(`retentionLines:partial`, unlogged(partial)) observe(`partial`, { function: chosen.function, source: chosen.source, @@ -2110,7 +2105,7 @@ async function driver(plan, observe) { observe(`retention:stability`, movedRetention.filter(leftBehind).length) observe( `retentionLines:stability`, - missingLine({ mutants: movedRun.mutants, retention: movedRetention }), + unlogged({ retention: movedRetention, evidence: movedRun.evidence }), ) states.add(`stability rerun`) } @@ -2235,22 +2230,18 @@ function check(plan, observations, expected) { // Bounded retention (the register's C entry): once a batch's copies are // retired, Vitest's filesMap, pathsSet, and idMap and Vite's module graph // name none of them, so each count the hook reports is 0. - const unlogged = observations + const unlogged_ = observations .filter((o) => o.checkpoint.startsWith(`retentionLines`)) .reduce((sum, o) => sum + o.value, 0) - if (unlogged > 0) + if (unlogged_ > 0) return violation( `retention`, - `${unlogged} run(s) computed a unit but wrote no batch retention line`, + `${unlogged_} run(s) wrote a retention line count different from their batches`, `bounded retention`, - { actual: unlogged, expected: 0 }, + { actual: unlogged_, expected: 0 }, ) const held = observations - .filter( - (o) => - o.checkpoint.startsWith(`retention`) && - !o.checkpoint.startsWith(`retentionLines`), - ) + .filter((o) => o.checkpoint.startsWith(`retention`)) .reduce((sum, o) => sum + o.value, 0) if (held > 0) return violation( From 67f057dd95db29ccdd5339cab0c23bfde375015f Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sat, 14 Nov 2026 09:30:56 -0700 Subject: [PATCH 11/20] test(oracle-gaps): the retention law counts copy-owned Vite nodes, not every cached module Vite's graph caches non-copy modules (a source or harness file, on first import), which legitimately grow during a batch; the before-and-after equality was wrong for Vite keys. The law now requires the copies' own module nodes to be zero after each settled batch, and keeps the before-and-after equality for Vitest's state, the store, and the stale context totals. Co-authored-by: Isaac --- packages/oracles/oracle-gaps/check.mjs | 18 ++++++++++++++---- .../gap-report-oracle.property.test.mjs | 7 +++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/oracles/oracle-gaps/check.mjs b/packages/oracles/oracle-gaps/check.mjs index 1cf8227..bdc5984 100644 --- a/packages/oracles/oracle-gaps/check.mjs +++ b/packages/oracles/oracle-gaps/check.mjs @@ -1288,8 +1288,8 @@ export default async (env) => { // by the caller (replace), which closes it and drops its state. if (result !== undefined) { retireCopies(context, copies) - retiredEntries(context, before) - } else retiredEntries(context, before, true) + retiredEntries(context, before, copies) + } else retiredEntries(context, before, copies, true) rmSync(capture, { force: true }) } } @@ -1423,14 +1423,24 @@ export default async (env) => { // that settled and its copies are retired, one line holds each structure's // total before the batch and after its retirement. The bounded-retention // law reads it: every total returns to its size before the batch. - function retiredEntries(context, before, cut = false) { + function retiredEntries(context, before, copies, cut = false) { const file = process.env.ORACLE_GAPS_TEST_RETENTION if (!file) return // A cut run is not retired (its context is replaced), so its line has no after: its before still // names the stale totals the next batch starts from. + // Vite's graph also holds modules that are not copies (a source or harness file is cached on its + // first import, legitimately), so for Vite the law counts the copies' own nodes, which must be 0. + const owned = new Set(copies) + let copyNodes = 0 + for (const environment of Object.values( + context.getRootProject().vite?.environments ?? {}, + )) + for (const copy of owned) + copyNodes += + environment.moduleGraph.fileToModulesMap.get(copy)?.size ?? 0 appendFileSync( file, - `${JSON.stringify(cut ? { before, cut: true } : { before, after: retentionTotals(context) })}\n`, + `${JSON.stringify(cut ? { before, cut: true } : { before, after: retentionTotals(context), copyNodes })}\n`, ) } diff --git a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs index 11bd4b9..2480a0e 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -647,10 +647,13 @@ const unlogged = (run) => // A batch line (ORACLE_GAPS_TEST_RETENTION) left state behind when a total // after its retirement differs from its size before the batch. const leftBehind = (line) => + (line.copyNodes ?? 0) !== 0 || Object.keys(line.before).some( (key) => - (key.startsWith(`stale`) && line.before[key] !== 0) || - (line.after !== undefined && line.before[key] !== line.after[key]), + // Vite's keys (`ssr.*`, `client.*`) hold non-copy modules too: only the copies' nodes count there. + !key.includes(`.`) && + ((key.startsWith(`stale`) && line.before[key] !== 0) || + (line.after !== undefined && line.before[key] !== line.after[key])), ) /** From 2ed6f1eca668ae2f7522fe186569685acfab6bfc Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sat, 14 Nov 2026 09:34:03 -0700 Subject: [PATCH 12/20] test(oracle-gaps): the retention law names Vite's totals and bounds non-copy nodes to one per file Vite's totals are named vite.., so the law tells them from Vitest's state by an explicit prefix the hook generates. After each settled batch, the copies' own module nodes must be 0, and each non-copy file (query stripped) has at most one node in each environment's graph: a file cached once per batch under a fresh id fails the law. Co-authored-by: Isaac --- packages/oracles/oracle-gaps/check.mjs | 35 ++++++++++++++----- .../gap-report-oracle.property.test.mjs | 6 ++-- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/packages/oracles/oracle-gaps/check.mjs b/packages/oracles/oracle-gaps/check.mjs index bdc5984..f8d85dd 100644 --- a/packages/oracles/oracle-gaps/check.mjs +++ b/packages/oracles/oracle-gaps/check.mjs @@ -1408,13 +1408,14 @@ export default async (env) => { context.getRootProject().vite?.environments ?? {}, )) { const graph = environment.moduleGraph - totals[`${name}.url`] = graph.urlToModuleMap.size - totals[`${name}.id`] = graph.idToModuleMap.size - totals[`${name}.etag`] = graph.etagToModuleMap.size - totals[`${name}.file`] = graph.fileToModulesMap.size - totals[`${name}.transformed`] = [...graph.idToModuleMap.values()].filter( - (node) => node.transformResult != null, - ).length + // Vite's keys are named `vite..`, so the law can tell them from Vitest's state. + totals[`vite.${name}.url`] = graph.urlToModuleMap.size + totals[`vite.${name}.id`] = graph.idToModuleMap.size + totals[`vite.${name}.etag`] = graph.etagToModuleMap.size + totals[`vite.${name}.file`] = graph.fileToModulesMap.size + totals[`vite.${name}.transformed`] = [ + ...graph.idToModuleMap.values(), + ].filter((node) => node.transformResult != null).length } return totals } @@ -1423,6 +1424,24 @@ export default async (env) => { // that settled and its copies are retired, one line holds each structure's // total before the batch and after its retirement. The bounded-retention // law reads it: every total returns to its size before the batch. + // Files, without their query, with more than one node in an environment's graph, outside the copies: + // a non-copy file is cached once, so each count must be 0 (the law's bound on originals). + function nonCopyDuplicates(context, owned) { + let dup = 0 + for (const environment of Object.values( + context.getRootProject().vite?.environments ?? {}, + )) { + const perFile = new Map() + for (const node of environment.moduleGraph.idToModuleMap.values()) { + const file = node.id.split(`?`)[0] + if (owned.has(file)) continue + perFile.set(file, (perFile.get(file) ?? 0) + 1) + } + for (const n of perFile.values()) if (n > 1) dup += 1 + } + return dup + } + function retiredEntries(context, before, copies, cut = false) { const file = process.env.ORACLE_GAPS_TEST_RETENTION if (!file) return @@ -1440,7 +1459,7 @@ export default async (env) => { environment.moduleGraph.fileToModulesMap.get(copy)?.size ?? 0 appendFileSync( file, - `${JSON.stringify(cut ? { before, cut: true } : { before, after: retentionTotals(context), copyNodes })}\n`, + `${JSON.stringify(cut ? { before, cut: true } : { before, after: retentionTotals(context), copyNodes, nonCopyDup: nonCopyDuplicates(context, owned) })}\n`, ) } diff --git a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs index 2480a0e..075ecb9 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -648,10 +648,12 @@ const unlogged = (run) => // after its retirement differs from its size before the batch. const leftBehind = (line) => (line.copyNodes ?? 0) !== 0 || + (line.nonCopyDup ?? 0) !== 0 || Object.keys(line.before).some( (key) => - // Vite's keys (`ssr.*`, `client.*`) hold non-copy modules too: only the copies' nodes count there. - !key.includes(`.`) && + // Vite's totals are `vite..`: its non-copy modules are cached legitimately + // (checked by the copies' own nodes, copyNodes, and by the bound still to add). + !key.startsWith(`vite.`) && ((key.startsWith(`stale`) && line.before[key] !== 0) || (line.after !== undefined && line.before[key] !== line.after[key])), ) From 2b7efe6262d60eb60c5f1890484dd12ef74ebfc4 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sat, 14 Nov 2026 10:05:42 -0700 Subject: [PATCH 13/20] test(oracle-gaps): the batch-line check defers a run with no report to the completion law; the multi-batch state is declared A run with no report owes no batch line here: the completion law judges it, keyed on the model's prediction (a report unless a refusal is predicted), and fails it with its exit status and stderr. The multi-batch reach state is declared in the property's measured list. Co-authored-by: Isaac --- .../oracle-gaps/gap-report-oracle.property.test.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs index 075ecb9..5898988 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -641,8 +641,13 @@ const configArgs = (plan) => ({ // Exact: a run writes one retention line per batch it ran (evidence.batches, cut batches // included), so its line count must equal that counter. Zero batches write none. +// The batch-line count is owed only with a report: a run with no report is judged by the completion +// law (cli: the model predicts a report unless it predicts a refusal), which fails it there with its +// stderr. This check does not fail a run for lacking a report, so the two laws never both judge it. const unlogged = (run) => - run.retention.length === run.evidence.batches ? 0 : 1 + run.evidence === undefined || run.retention.length === run.evidence.batches + ? 0 + : 1 // A batch line (ORACLE_GAPS_TEST_RETENTION) left state behind when a total // after its retirement differs from its size before the batch. @@ -3435,6 +3440,8 @@ oracleProperty({ ...staticStates, `extra work in a generated function`, `killed by a work bound in a generated function`, + // A check whose runs have more than one batch: the retention law's multi-batch reach. + `a check that ran more than one batch`, ], runs: 2, fixedSeeds: [20261105], From fefc857bdc981624f35c8821f96f3a6f540bd4cb Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sat, 14 Nov 2026 10:10:24 -0700 Subject: [PATCH 14/20] test(oracle-gaps): the batch-line violation names the run that mismatched Co-authored-by: Isaac --- .../oracles/oracle-gaps/gap-report-oracle.property.test.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs index 5898988..d169f4d 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -2246,7 +2246,10 @@ function check(plan, observations, expected) { if (unlogged_ > 0) return violation( `retention`, - `${unlogged_} run(s) wrote a retention line count different from their batches`, + `${unlogged_} run(s) wrote a retention line count different from their batches (${observations + .filter((o) => o.checkpoint.startsWith(`retentionLines`) && o.value > 0) + .map((o) => o.checkpoint) + .join(`, `)})`, `bounded retention`, { actual: unlogged_, expected: 0 }, ) From 41d47db58c109275ab582c25aa52955e280b3397 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sat, 14 Nov 2026 10:13:35 -0700 Subject: [PATCH 15/20] test(oracle-gaps): a run's retention lines are its own: one hook file per package, counted as a delta A daemon keeps the environment of the call that started it, so a later daemon check wrote its retention lines to the hook file of the call that started the daemon, and its own (new) hook file was empty: the exact batch-line count failed on a correct run (retentionLines:daemon on the hang history). Counting side: every call for a package writes to one file, and a run's lines are the ones after its start. The production batch-line contract is unchanged. Co-authored-by: Isaac --- .../gap-report-oracle.property.test.mjs | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs index d169f4d..2b72d87 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -663,6 +663,23 @@ const leftBehind = (line) => (line.after !== undefined && line.before[key] !== line.after[key])), ) +const hookFiles = new Map() +const hookOf = (dir) => { + if (!hookFiles.has(dir)) + hookFiles.set( + dir, + join(mkdtempSync(join(tmpdir(), `gap-retention-`)), `retention.jsonl`), + ) + return hookFiles.get(dir) +} +const retentionLinesOf = (file) => + existsSync(file) + ? readFileSync(file, `utf8`) + .split(`\n`) + .filter(Boolean) + .map((line) => JSON.parse(line)) + : [] + /** * Writes the fixture's files and runs the CLI on it. A file of an earlier * state that this state lacks is removed. @@ -673,10 +690,10 @@ function runCli(dir, files, how = {}) { const { args, out } = cliArgs(dir, files, how) // The retention hook (ORACLE_GAPS_TEST_RETENTION): one line per batch, // written beside the run's own temporary files, not in the package. - const hook = join( - mkdtempSync(join(tmpdir(), `gap-retention-`)), - `retention.jsonl`, - ) + // One hook file per package: a daemon keeps the environment of the call that started it, so every call + // for this package writes to the same file, and this run's lines are those after its start (the delta). + const hook = hookOf(dir) + const hookBefore = retentionLinesOf(hook) const child = spawnSync(process.execPath, args, { cwd: repo, encoding: `utf8`, @@ -684,12 +701,7 @@ function runCli(dir, files, how = {}) { maxBuffer: 64 * 1024 * 1024, env: { ...cleanEnv(), ORACLE_GAPS_TEST_RETENTION: hook }, }) - const retention = existsSync(hook) - ? readFileSync(hook, `utf8`) - .split(`\n`) - .filter(Boolean) - .map((line) => JSON.parse(line)) - : [] + const retention = retentionLinesOf(hook).slice(hookBefore) const run = readRun( dir, out, From a21287cc569627e126a2aed3654a4d12947af00c Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sat, 14 Nov 2026 10:15:10 -0700 Subject: [PATCH 16/20] test(oracle-gaps): each retention line names its check's output directory; a run keeps its own lines Replaces the delta: a daemon serves many calls and keeps the first caller's environment, so a line is attributed to the check that wrote it (its out directory, the test hook's only addition), and the run keeps the lines that name its own out directory. Two daemon checks at once against one hook file cannot mix. No production outcome changes. Co-authored-by: Isaac --- packages/oracles/oracle-gaps/check.mjs | 5 ++++- .../oracles/oracle-gaps/gap-report-oracle.property.test.mjs | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/oracles/oracle-gaps/check.mjs b/packages/oracles/oracle-gaps/check.mjs index f8d85dd..1166267 100644 --- a/packages/oracles/oracle-gaps/check.mjs +++ b/packages/oracles/oracle-gaps/check.mjs @@ -565,6 +565,8 @@ export default async (env) => { let runCounter = 0 // The current check's evidence, which runBatch counts its batches in (set by check). let evidenceOf + // The output directory of the check being run (the test hook names each line with it). + let hookRun // A context loads the package's own config through its wrapper, so an // error there is the author's: the run refuses, naming the config file @@ -1459,11 +1461,12 @@ export default async (env) => { environment.moduleGraph.fileToModulesMap.get(copy)?.size ?? 0 appendFileSync( file, - `${JSON.stringify(cut ? { before, cut: true } : { before, after: retentionTotals(context), copyNodes, nonCopyDup: nonCopyDuplicates(context, owned) })}\n`, + `${JSON.stringify(cut ? { run: hookRun, before, cut: true } : { run: hookRun, before, after: retentionTotals(context), copyNodes, nonCopyDup: nonCopyDuplicates(context, owned) })}\n`, ) } async function check(options, store, log = console.error) { + hookRun = options.out heldUnits = () => store.size?.() ?? 0 const authored = options.mutants ? loadAuthored(options.mutants) : [] seedVariable = `${options.envPrefix}_SEED` diff --git a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs index 2b72d87..e21ad49 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -691,9 +691,8 @@ function runCli(dir, files, how = {}) { // The retention hook (ORACLE_GAPS_TEST_RETENTION): one line per batch, // written beside the run's own temporary files, not in the package. // One hook file per package: a daemon keeps the environment of the call that started it, so every call - // for this package writes to the same file, and this run's lines are those after its start (the delta). + // for this package writes to the same file; each line names its run (below). const hook = hookOf(dir) - const hookBefore = retentionLinesOf(hook) const child = spawnSync(process.execPath, args, { cwd: repo, encoding: `utf8`, @@ -701,7 +700,8 @@ function runCli(dir, files, how = {}) { maxBuffer: 64 * 1024 * 1024, env: { ...cleanEnv(), ORACLE_GAPS_TEST_RETENTION: hook }, }) - const retention = retentionLinesOf(hook).slice(hookBefore) + // Each line names its check's output directory, so this run keeps only its own (a daemon serves many calls). + const retention = retentionLinesOf(hook).filter((line) => line.run === out) const run = readRun( dir, out, From e223349df64b7fe15d852366bde47fe52cc350c2 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sat, 14 Nov 2026 10:17:38 -0700 Subject: [PATCH 17/20] test(oracle-gaps): retention lines carry their batch index; a debug table names each run's batches and lines Diagnostic for the daemon line mismatch (the test hook only; no production outcome changes). Co-authored-by: Isaac --- packages/oracles/oracle-gaps/check.mjs | 2 +- .../oracle-gaps/gap-report-oracle.property.test.mjs | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/oracles/oracle-gaps/check.mjs b/packages/oracles/oracle-gaps/check.mjs index 1166267..7acd11e 100644 --- a/packages/oracles/oracle-gaps/check.mjs +++ b/packages/oracles/oracle-gaps/check.mjs @@ -1461,7 +1461,7 @@ export default async (env) => { environment.moduleGraph.fileToModulesMap.get(copy)?.size ?? 0 appendFileSync( file, - `${JSON.stringify(cut ? { run: hookRun, before, cut: true } : { run: hookRun, before, after: retentionTotals(context), copyNodes, nonCopyDup: nonCopyDuplicates(context, owned) })}\n`, + `${JSON.stringify(cut ? { run: hookRun, batch: evidenceOf.batches, before, cut: true } : { run: hookRun, batch: evidenceOf.batches, before, after: retentionTotals(context), copyNodes, nonCopyDup: nonCopyDuplicates(context, owned) })}\n`, ) } diff --git a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs index e21ad49..fae2af8 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -471,6 +471,7 @@ */ import { spawn, spawnSync } from 'node:child_process' import { + appendFileSync, cpSync, existsSync, mkdirSync, @@ -702,6 +703,12 @@ function runCli(dir, files, how = {}) { }) // Each line names its check's output directory, so this run keeps only its own (a daemon serves many calls). const retention = retentionLinesOf(hook).filter((line) => line.run === out) + // Debug table (ORACLE_GAPS_RETENTION_TABLE): each run's batches against its lines, for the mismatch. + if (process.env.ORACLE_GAPS_RETENTION_TABLE) + appendFileSync( + process.env.ORACLE_GAPS_RETENTION_TABLE, + `${JSON.stringify({ out, batches: JSON.parse(readFileSync(join(out, `results.json`), `utf8`)).evidence?.batches ?? null, lines: retention.map((l) => [l.batch, l.cut === true]) })}\n`, + ) const run = readRun( dir, out, From 30e6caded5feef7f7e269c0c6a185cb2a1d98acc Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sat, 14 Nov 2026 10:20:03 -0700 Subject: [PATCH 18/20] test(oracle-gaps): each CLI call has its own output directory, so its retention lines are its own The table showed the cause: a daemon serves several checks of one test, and two of them shared an out directory, so a run kept lines written by an earlier check (batches 1, lines 4; a run with 0 batches kept 1). Production wrote one line per batch in every run. The counting side is wrong: each call gets its own directory, so the run filter sees only its lines. The debug table is removed (it was for this diagnosis). No production change. Co-authored-by: Isaac --- .../gap-report-oracle.property.test.mjs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs index fae2af8..a544bfb 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -471,7 +471,6 @@ */ import { spawn, spawnSync } from 'node:child_process' import { - appendFileSync, cpSync, existsSync, mkdirSync, @@ -665,6 +664,7 @@ const leftBehind = (line) => ) const hookFiles = new Map() +let runSeq = 0 const hookOf = (dir) => { if (!hookFiles.has(dir)) hookFiles.set( @@ -688,7 +688,11 @@ const retentionLinesOf = (file) => function runCli(dir, files, how = {}) { writeFixture(dir, files) const before = new Set(filesUnder(dir)) - const { args, out } = cliArgs(dir, files, how) + const { args, out: outBase } = cliArgs(dir, files, how) + // Each call has its own output directory: its retention lines are then its own, even when a + // daemon serves several checks of one test. + const out = `${outBase}-${++runSeq}` + args[args.indexOf(`--out`) + 1] = out // The retention hook (ORACLE_GAPS_TEST_RETENTION): one line per batch, // written beside the run's own temporary files, not in the package. // One hook file per package: a daemon keeps the environment of the call that started it, so every call @@ -703,12 +707,6 @@ function runCli(dir, files, how = {}) { }) // Each line names its check's output directory, so this run keeps only its own (a daemon serves many calls). const retention = retentionLinesOf(hook).filter((line) => line.run === out) - // Debug table (ORACLE_GAPS_RETENTION_TABLE): each run's batches against its lines, for the mismatch. - if (process.env.ORACLE_GAPS_RETENTION_TABLE) - appendFileSync( - process.env.ORACLE_GAPS_RETENTION_TABLE, - `${JSON.stringify({ out, batches: JSON.parse(readFileSync(join(out, `results.json`), `utf8`)).evidence?.batches ?? null, lines: retention.map((l) => [l.batch, l.cut === true]) })}\n`, - ) const run = readRun( dir, out, From 6b1aa1de0108218e13ec89e925ed0f15bfa3fcfa Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sat, 14 Nov 2026 10:22:29 -0700 Subject: [PATCH 19/20] test(oracle-gaps): the namespace law allows the numbered output directories runCli gives each call Each call's out directory is numbered (out-, out-daemon-, ...), so the outputs of a run are its own. The law's list of own outputs names the fixed ones; the numbered ones match the same pattern the driver creates (OWN_NUMBERED). Without it, a run's out-25 writes read as strays. Co-authored-by: Isaac --- .../oracle-gaps/gap-report-oracle.property.test.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs index a544bfb..9543892 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -735,7 +735,11 @@ const strays = (dir, before, running = false) => (file) => !before.has(file) && !(running && /^\.oracle-gaps-\d+\//.test(file)) && - !OWN_WRITES.some((home) => file === home || file.startsWith(`${home}/`)), + !OWN_WRITES.some( + (home) => file === home || file.startsWith(`${home}/`), + ) && + // The numbered output directories a run's CLI calls get (runCli): one per call. + !OWN_NUMBERED.test(file), ) /** @@ -948,6 +952,7 @@ function filesUnder(root, within = ``) { // of the repository), so a package, and a workspace dependency in it, keeps // nothing of a run. const OWN_WRITES = [`out`, `out-daemon`, `out-stop`, `out-only`] +const OWN_NUMBERED = /^out(-daemon|-stop|-only)?-\d+(\/|$)/ /** Runs the CLI several times at once on one state of the fixture. */ async function runClisAtOnce(dir, files, hows) { From 11105c5cb08bdb34f8299d3d2da31844c015df96 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sat, 14 Nov 2026 10:24:28 -0700 Subject: [PATCH 20/20] test(oracle-gaps): the namespace law allows exactly the --out a run was given Repair, not widening (P7): the law listed fixed output names (out, out-daemon, out-stop, out-only), and numbered names were a pattern. Now each run's allowed output is the one path the model predicts (its --out, from cliArgs): runCli passes its numbered out, the stop law passes out-stop. A CLI that writes under any other directory, such as out--next, is a stray. The lists are removed. Co-authored-by: Isaac --- .../gap-report-oracle.property.test.mjs | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs index 9543892..1280029 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -720,7 +720,12 @@ function runCli(dir, files, how = {}) { .split(`\n`) .find((line) => line.startsWith(` replay: `)) ?.slice(` replay: `.length) - return { ...run, strayWrites: strays(dir, before), replay, retention } + return { + ...run, + strayWrites: strays(dir, before, relative(dir, out)), + replay, + retention, + } } /** @@ -730,16 +735,13 @@ function runCli(dir, files, how = {}) { * directory, `.oracle-gaps-/` (its capture setup and Vitest configs, * which resolve the package's modules from there), is its own too. */ -const strays = (dir, before, running = false) => +const strays = (dir, before, own, running = false) => filesUnder(dir).filter( (file) => !before.has(file) && !(running && /^\.oracle-gaps-\d+\//.test(file)) && - !OWN_WRITES.some( - (home) => file === home || file.startsWith(`${home}/`), - ) && - // The numbered output directories a run's CLI calls get (runCli): one per call. - !OWN_NUMBERED.test(file), + // The one path a run may write under: the --out the driver gave it (cliArgs names it). + !(file === own || file.startsWith(`${own}/`)), ) /** @@ -837,7 +839,12 @@ async function stoppedRun(dir, files, how, ids, { signal, pool, when }) { : warm ? existsSync(join(pids, `warming`)) && !exited : readdirSync(pids).length >= 2 - const strayWhileRunning = strays(dir, before, true) + const strayWhileRunning = strays( + dir, + before, + relative(dir, join(dir, `out-stop`)), + true, + ) if (!abort) child.kill(signal) const ended = await Promise.race([exit, sleep(30_000).then(() => false)]) await sleep(1000) @@ -892,7 +899,7 @@ async function stoppedRun(dir, files, how, ids, { signal, pool, when }) { /^\.oracle-gaps-\d+$/.test(name), ), // What else it left in the package. - left: strays(dir, before).filter( + left: strays(dir, before, relative(dir, join(dir, `out-stop`))).filter( (file) => !/^\.oracle-gaps-\d+\//.test(file), ), } @@ -951,8 +958,6 @@ function filesUnder(root, within = ``) { // (maintainer decision, 2026-11-11: the gap check's working files stay out // of the repository), so a package, and a workspace dependency in it, keeps // nothing of a run. -const OWN_WRITES = [`out`, `out-daemon`, `out-stop`, `out-only`] -const OWN_NUMBERED = /^out(-daemon|-stop|-only)?-\d+(\/|$)/ /** Runs the CLI several times at once on one state of the fixture. */ async function runClisAtOnce(dir, files, hows) {