From 53966e94ffd9e6f856441fc731cf59300f0f6756 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 13 Nov 2026 07:23:43 -0700 Subject: [PATCH 1/4] fix(oracle-gaps): infect a ?. site whose receiver is nullish A plain read in place of ?. changes its completion (a TypeError where the original returned undefined), but the capture never recorded it, so the mutant was skipped as unchanged and reported not-distinguished in 0 s. Law: a mutant is judged without a run only when every owner's unmutated run completes its site as the original does. Witnesses w_absent and w_absentNull (undefined and null receivers) declare the crashed reach states. Co-authored-by: Isaac --- .../oracles/oracle-gaps/capture-setup.mjs | 8 ++++-- .../oracle-gaps/gap-report-model-oracle.mjs | 28 ++++++++++++++++--- .../gap-report-oracle.property.test.mjs | 13 ++++++++- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/oracles/oracle-gaps/capture-setup.mjs b/packages/oracles/oracle-gaps/capture-setup.mjs index 81a4388..b2962fc 100644 --- a/packages/oracles/oracle-gaps/capture-setup.mjs +++ b/packages/oracles/oracle-gaps/capture-setup.mjs @@ -421,10 +421,14 @@ globalThis.__oracleGaps = { return id === active ? !value : value }, // A plain read in place of `?.`: the object passes on, and when its - // mutant is active a nullish one throws, as the plain read would. + // mutant is active a nullish one throws, as the plain read would. A + // nullish object is a change of completion at the site, so it is infected + // whether or not a mutant is active. chain(id, value) { hit(id) - if (id === active && (value === undefined || value === null)) + const nullish = value === undefined || value === null + if (nullish) infect(id) + if (id === active && nullish) throw new TypeError(`Cannot read properties of ${value}`) return value }, diff --git a/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs b/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs index dc96d11..2897342 100644 --- a/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs +++ b/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs @@ -1577,6 +1577,22 @@ const witness = [ body: [{ kind: `optional` }], tests: [judging(`a`, `fixed seed`, 1)], }, + // The same read where the object is absent, `undefined` or `null`: the + // plain read throws on it, so the mutant changes the run and the test + // crashes (a bare throw, with no verdict). Each receiver must make the + // capture infect the site, or the mutant is skipped as unchanged. + { + id: `w_absent`, + owners: [`a`], + body: [{ kind: `optional`, absent: `undefined` }], + tests: [judging(`a`, `fixed seed`, 0)], + }, + { + id: `w_absentNull`, + owners: [`a`], + body: [{ kind: `optional`, absent: `null` }], + tests: [judging(`a`, `fixed seed`, 0)], + }, // A test that waits on a child process where the output differs: an I/O // handle is open, so its copy is busy, never cut for idleness, and the // confirming run kills it. @@ -1662,6 +1678,8 @@ const captureWitness = new Set([ `w_record`, `w_data`, `w_optional`, + `w_absent`, + `w_absentNull`, `w_oldprobe`, `w_spin`, ]) @@ -1916,7 +1934,7 @@ function renderStatement(stmt, callText) { return [`out.push(...loaded);`] case `optional`: return [ - `const o${stmt.label} = x ? { v: 'p${stmt.label}' } : undefined;`, + `const o${stmt.label} = x ? { v: 'p${stmt.label}' } : ${stmt.absent === `null` ? `null` : `undefined`};`, `out.push(\`\${o${stmt.label}?.v}\`);`, ] case `readData`: @@ -3453,14 +3471,16 @@ function interpret(plan, fnId, x, sites, active) { ? isActive(drop) ? {} : { v: `p${stmt.label}` } - : undefined + : stmt.absent === `null` + ? null + : undefined const site = siteOf(stmt, `push`) reached.add(site.id) if (!isActive(site)) { const chain = siteOf(stmt, `chain`) reached.add(chain.id) - if (isActive(chain) && made === undefined) - throw new TypeError(`Cannot read properties of undefined`) + if (isActive(chain) && (made === undefined || made === null)) + throw new TypeError(`Cannot read properties of ${made}`) trace.push(String(made?.v)) } } 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 b74f4fc..0ec7d82 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -168,7 +168,12 @@ * would judge such a verdict, is not built.) A copy that a batch skipped * because an earlier copy killed the mutant runs again, through the batch, * only while no kill is in hand, so every mutant gets an outcome and no - * other missing unit is rerun. A mutant that no owner test fails is a + * other missing unit is rerun. A mutant is judged without a run only when + * every owner's unmutated run completes its site as the original does, with + * the same value or the same thrown error: the capture infects every site + * whose mutant would change that completion, so a `?.` whose receiver is + * nullish (a plain read throws on it) runs, as a comparison whose operators + * disagree does. A mutant that no owner test fails is a * survivor: a `not-judged` when an owner test's observation or asserted * value changed, a `branch-never-taken` when an `if` on its line never took * one of its outcomes in an owner test, a `not-infected` when a comparison's @@ -1604,6 +1609,10 @@ async function driver(plan, observe) { states.add(`a returned value made undefined not distinguished`) if (m.operator === `optional-chain` && m.class === `not-distinguished`) states.add(`a plain read in place of ?. not distinguished`) + if (m.function === `w_absent` && m.outcome === `crashed`) + states.add(`a plain read of an absent object crashed`) + if (m.function === `w_absentNull` && m.outcome === `crashed`) + states.add(`a plain read of a null object crashed`) if (m.operator === `arithmetic-swap` && m.outcome === `killed`) states.add(`an arithmetic swap killed`) if (m.function === `w_leaf` && m.outcome !== `unowned`) @@ -2556,6 +2565,8 @@ const required = [ `a mutant of module data killed through its reader`, `a returned value made undefined not distinguished`, `a plain read in place of ?. not distinguished`, + `a plain read of an absent object crashed`, + `a plain read of a null object crashed`, `a module-level cache judged afresh`, `ordinary tests run when asked`, `ordinary tests left out by default`, From 2a343bcf02266f5fc80168b4b73bbeb959eafca5 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 13 Nov 2026 10:32:49 -0700 Subject: [PATCH 2/4] test(oracle-gaps): a caught optional read whose throw the owner swallows The model gets a caught witness (w_swallow): its owner catches the TypeError a mutated ?. throws and writes nothing, so the test passes. Law: a survivor of an optional-chain site whose owner test threw at the site is swallowed-throw (never a skip, never an exit). Reach state: a plain read whose throw the owner swallowed. Oracle-only commit; production follows. Co-authored-by: Isaac --- .../oracle-gaps/gap-report-model-oracle.mjs | 56 ++++++++++++++++--- .../gap-report-oracle.property.test.mjs | 3 + 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs b/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs index 73bde55..143027d 100644 --- a/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs +++ b/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs @@ -1729,6 +1729,14 @@ const witness = [ body: [{ kind: `optional`, absent: `null` }], tests: [judging(`a`, `fixed seed`, 0)], }, + // The same read, caught and ignored by its owner: the throw of the mutant + // is swallowed, no observation changes, and the test passes. + { + id: `w_swallow`, + owners: [`a`], + body: [{ kind: `optional`, absent: `undefined`, caught: true }], + tests: [judging(`a`, `fixed seed`, 0)], + }, // A test that waits on a child process where the output differs: an I/O // handle is open, so its copy is busy, never cut for idleness, and the // confirming run kills it. @@ -1841,6 +1849,7 @@ const captureWitness = new Set([ `w_optional`, `w_absent`, `w_absentNull`, + `w_swallow`, `w_oldprobe`, `w_spin`, ]) @@ -2111,7 +2120,10 @@ function renderStatement(stmt, callText) { case `optional`: return [ `const o${stmt.label} = x ? { v: 'p${stmt.label}' } : ${stmt.absent === `null` ? `null` : `undefined`};`, - `out.push(\`\${o${stmt.label}?.v}\`);`, + // A caught read writes nothing: its owner catches the error and goes on. + stmt.caught + ? `try { void o${stmt.label}?.v; } catch {}` + : `out.push(\`\${o${stmt.label}?.v}\`);`, ] case `readData`: return [`out.push(${dataOf(stmt.shape).read});`] @@ -3499,6 +3511,9 @@ function ownersOf(plan) { function interpret(plan, fnId, x, sites, active) { const trace = [] const reached = new Set() + // The optional reads whose active mutant threw there (the capture's `threw` + // row): a caught read's throw changes nothing else the test observes. + const threwAt = new Set() // The run's work: one for each site evaluation, as the capture counts a // test's `calls` (capture-setup.mjs), which a work bound judges. let work = 0 @@ -3701,14 +3716,23 @@ function interpret(plan, fnId, x, sites, active) { : stmt.absent === `null` ? null : undefined - const site = siteOf(stmt, `push`) - reach(site.id) - if (!isActive(site)) { - const chain = siteOf(stmt, `chain`) + const chain = siteOf(stmt, `chain`) + if (stmt.caught) { + // The owner catches the read's error and writes nothing. reach(chain.id) if (isActive(chain) && (made === undefined || made === null)) - throw new TypeError(`Cannot read properties of ${made}`) - trace.push(String(made?.v)) + threwAt.add(chain.id) + } else { + const site = siteOf(stmt, `push`) + reach(site.id) + if (!isActive(site)) { + reach(chain.id) + if (isActive(chain) && (made === undefined || made === null)) { + threwAt.add(chain.id) + throw new TypeError(`Cannot read properties of ${made}`) + } + trace.push(String(made?.v)) + } } } if (stmt.kind === `innerReturned`) reach(siteOf(stmt, `returned`).id) @@ -3891,7 +3915,7 @@ function interpret(plan, fnId, x, sites, active) { if (error instanceof Spin) spun = true else threw = true } - return { trace, threw, spun, reached, outcomes, infected, work } + return { trace, threw, spun, reached, outcomes, infected, work, threwAt } } /** A loop that never ends, as the interpreter sees one. */ @@ -4347,6 +4371,22 @@ function model(plan) { // infected site that no observation shows. const isIf = site.operator === `if-true` || site.operator === `if-false` const comparison = site.operator === `binary-swap` && site.role === `swap` + // An optional read whose mutant threw in an owner test that caught the + // error and went on: the test passed, and the throw was swallowed. Its + // directive names the read and the test, and asks for the read's + // completion (value or error) to be observed. It is never an exit. + if ( + site.operator === `optional-chain` && + tests.some((t) => + partsOf(t).some( + (part) => + interpret(plan, t.fn, part.input, sites, { site: site.id }).threwAt.has( + site.id, + ), + ), + ) + ) + return `swallowed-throw` // A mutant that changes a value only on some inputs, with no capture to // show which: a loop's condition, an undefined call value, a dropped // field, a skipped call, and a logical operator (the `&&` of the old 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 3a0d537..8badb2a 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -1868,6 +1868,8 @@ async function driver(plan, observe) { states.add(`a plain read of an absent object crashed`) if (m.function === `w_absentNull` && m.outcome === `crashed`) states.add(`a plain read of a null object crashed`) + if (m.function === `w_swallow` && m.class === `swallowed-throw`) + states.add(`a plain read whose throw the owner swallowed`) if (m.operator === `arithmetic-swap` && m.outcome === `killed`) states.add(`an arithmetic swap killed`) if (m.function === `w_leaf` && m.outcome !== `unowned`) @@ -3151,6 +3153,7 @@ const required = [ `a plain read in place of ?. not distinguished`, `a plain read of an absent object crashed`, `a plain read of a null object crashed`, + `a plain read whose throw the owner swallowed`, `a module-level cache judged afresh`, `ordinary tests run when asked`, `ordinary tests left out by default`, From c0b92b2b6687bbc45768d8e2699182a7aed0e75a Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 13 Nov 2026 11:19:44 -0700 Subject: [PATCH 3/4] test(oracle-gaps): the model enumerates only the sites its rendered fixture contains A caught optional read is rendered without its out.push, so it has no delete-call site on that push. The model still emitted one, expecting a gap the CLI cannot produce (126 expected, 125 printed), and every static enumeration unit with a caught witness failed and shrank for 41 minutes in CI (gap report oracles 2/3). This corrects the model's internal consistency to its own rendered fixture. It is not a model change toward production output: no expected class or outcome changed, only the set of sites the fixture contains. Co-authored-by: Isaac --- .../oracles/oracle-gaps/gap-report-model-oracle.mjs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs b/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs index 143027d..ca19194 100644 --- a/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs +++ b/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs @@ -3129,7 +3129,9 @@ function sitesOf(plan) { // The object literal's field, then the push and its optional read. if (stmt.kind === `optional`) { at(`field-drop`, `dropV`, stmt.line, stmt.lines[0]) - at(`delete-call`, `push`, stmt.line + 1, stmt.lines[1]) + // A caught read writes nothing, so it has no push to delete. + if (!stmt.caught) + at(`delete-call`, `push`, stmt.line + 1, stmt.lines[1]) at(`optional-chain`, `chain`, stmt.line + 1, stmt.lines[1]) } // Two arrows on one line: each property's field drop, its call, and @@ -4378,11 +4380,10 @@ function model(plan) { if ( site.operator === `optional-chain` && tests.some((t) => - partsOf(t).some( - (part) => - interpret(plan, t.fn, part.input, sites, { site: site.id }).threwAt.has( - site.id, - ), + partsOf(t).some((part) => + interpret(plan, t.fn, part.input, sites, { + site: site.id, + }).threwAt.has(site.id), ), ) ) From f9bad063130d432a9d3e14ef03c06c617a919781 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 13 Nov 2026 11:24:12 -0700 Subject: [PATCH 4/4] test(oracle-gaps): key a caught read's ?. site by its innermost statement The CLI anchors the site in the statement that holds it, which for a caught read is its own expression statement (void oN ?. v ;), not the try line. The model named the try line, so the static generated units failed on the key. This changes only the model's rendered anchor text, to match the fixture it renders; no expected class or outcome changed. Co-authored-by: Isaac --- packages/oracles/oracle-gaps/gap-report-model-oracle.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs b/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs index ca19194..db30df0 100644 --- a/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs +++ b/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs @@ -3132,7 +3132,14 @@ function sitesOf(plan) { // A caught read writes nothing, so it has no push to delete. if (!stmt.caught) at(`delete-call`, `push`, stmt.line + 1, stmt.lines[1]) - at(`optional-chain`, `chain`, stmt.line + 1, stmt.lines[1]) + // A caught read's innermost statement is its own expression statement, + // which names the site (the anchor), not the `try` line that holds it. + at( + `optional-chain`, + `chain`, + stmt.line + 1, + stmt.caught ? `void o${stmt.label} ?. v ;` : stmt.lines[1], + ) } // Two arrows on one line: each property's field drop, its call, and // the `+ 1` belong to the arrow whose text holds them; the push and