Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3924eb6
fix(oracle-gaps): retire a batch's copies from Vitest's state
KyleAMathews Nov 13, 2026
46dfd3e
test(oracle-gaps): bounded-retention law after each batch
KyleAMathews Nov 13, 2026
84a1085
fix(oracle-gaps): retire a copy's Vitest and Vite state, not only its…
KyleAMathews Nov 13, 2026
ce46f4b
test(oracle-gaps): the incremental violation names a cold run's error…
KyleAMathews Nov 13, 2026
2de2b9e
test(oracle-gaps): the incremental observation carries a cold run's e…
KyleAMathews Nov 13, 2026
ca327dc
fix(oracle-gaps): a replaced context leaves its maps; a cut-off run i…
KyleAMathews Nov 13, 2026
ce6c93d
test(oracle-gaps): the retention law compares every structure with it…
KyleAMathews Nov 13, 2026
390025e
test(oracle-gaps): the retention law counts stale replaced contexts; …
KyleAMathews Nov 14, 2026
9b81d6c
Merge origin/main into fix/gap-cli-retention
KyleAMathews Nov 14, 2026
59363c8
fix(oracle-gaps): a cold check keeps no unit for reuse
KyleAMathews Nov 14, 2026
969a7a8
test(oracle-gaps): the retention law counts batches exactly; cut batc…
KyleAMathews Nov 14, 2026
67f057d
test(oracle-gaps): the retention law counts copy-owned Vite nodes, no…
KyleAMathews Nov 14, 2026
2ed6f1e
test(oracle-gaps): the retention law names Vite's totals and bounds n…
KyleAMathews Nov 14, 2026
2b7efe6
test(oracle-gaps): the batch-line check defers a run with no report t…
KyleAMathews Nov 14, 2026
fefc857
test(oracle-gaps): the batch-line violation names the run that mismat…
KyleAMathews Nov 14, 2026
41d47db
test(oracle-gaps): a run's retention lines are its own: one hook file…
KyleAMathews Nov 14, 2026
a21287c
test(oracle-gaps): each retention line names its check's output direc…
KyleAMathews Nov 14, 2026
e223349
test(oracle-gaps): retention lines carry their batch index; a debug t…
KyleAMathews Nov 14, 2026
30e6cad
test(oracle-gaps): each CLI call has its own output directory, so its…
KyleAMathews Nov 14, 2026
6b1aa1d
test(oracle-gaps): the namespace law allows the numbered output direc…
KyleAMathews Nov 14, 2026
11105c5
test(oracle-gaps): the namespace law allows exactly the --out a run w…
KyleAMathews Nov 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 195 additions & 0 deletions packages/oracles/oracle-gaps/check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -561,6 +563,10 @@ 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
// 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
Expand Down Expand Up @@ -1018,7 +1024,11 @@ 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)
: 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.
Expand Down Expand Up @@ -1275,11 +1285,189 @@ export default async (env) => {
}
} finally {
for (const copy of copies) globalThis.__oracleGapsCopies.delete(copy)
// 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)
retiredEntries(context, before, copies)
} else retiredEntries(context, before, copies, true)
rmSync(capture, { force: true })
}
}

/**
* 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 { 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, idMap, or pathsSet in its state, so a check cannot retire its copies`,
)
const gone = new Set(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)
}
retireModules(context.getRootProject().vite, gone)
}

/**
* 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)
// 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)
}
}
return removed
}

// 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
}

// 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 = {
// 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,
staleSpecs: contextSpecs.size - contexts.length,
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
// Vite's keys are named `vite.<environment>.<map>`, 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
}

// 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.
// 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
// 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 ? { run: hookRun, batch: evidenceOf.batches, before, cut: true } : { run: hookRun, batch: evidenceOf.batches, 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`
if (options.hangLimit > options.mutantLimit)
Expand Down Expand Up @@ -1412,7 +1600,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()
Expand Down Expand Up @@ -1726,6 +1917,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
Expand Down
14 changes: 7 additions & 7 deletions packages/oracles/oracle-gaps/evidence.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}

/**
Expand Down
Loading
Loading