From 53966e94ffd9e6f856441fc731cf59300f0f6756 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 13 Nov 2026 07:23:43 -0700 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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 From 3830a33e230b7e7310c2ae227b49066e15455597 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 13 Nov 2026 19:48:43 -0700 Subject: [PATCH 5/7] test(oracle-gaps): a canThrow declaration law for each operator's forms The mutant-validity oracle gets an appended family of eight one-operator forms (===, <, && with a read, ?? with a read, !, a true literal, a + 1, ?.). Its model states each form in JavaScript semantics and decides canThrow: whether some input of the domain makes exactly one of the two forms throw. The law: a canThrow declaration is present in schemata.mjs, and an operator declared false never makes exactly one form throw. The declaration is observed through CAN_THROW, which this commit does not yet define, so CI records the RED. Prefix check: the first 33388 enumerated histories are byte-identical to before the append (diffed in full, 8219292 bytes). Co-authored-by: Isaac --- ...t-mutant-validity-oracle.property.test.mjs | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs index fd5f41a..3552648 100644 --- a/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs @@ -113,6 +113,7 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import fc from 'fast-check' import { oracleProperty, pass, violation } from '../src/oracle-property.ts' +import * as schemataTable from './schemata.mjs' import { instrument } from './schemata.mjs' const here = dirname(fileURLToPath(import.meta.url)) @@ -127,6 +128,7 @@ const LAW_LABEL = `a mutant carries the outside-annotation label exactly when an const LAW_TYPELESS = `erasing the types changes no emitted site and no dead-code mark` const LAW_BOUNDARY = `a boundary hint sits only on a site that a 0 and -0 history distinguishes` const LAW_REFINE = `the mutant run agrees with the model` +const LAW_THROW = `a canThrow declaration is present, and an operator declared false never makes exactly one of its two forms throw (the completion recording the gap CLI relies on depends on it)` const LAW_KNOWN = `every emitted mutant is a site of the grammar` const LAW_INERT = `instrumentation with no mutant active is inert` @@ -2763,6 +2765,156 @@ function mutateText(text, mutant) { } // The families, each program one history. +// ## Throw capability +// +// A family of one-operator functions, each with one site whose mutant is an +// operator swap. Its two forms are written here as JavaScript closures (the +// model, which the production instrumenter never sees). `canThrow` is the +// question the gap CLI's completion recording depends on: is there an input +// that makes exactly one of the two forms throw? The model answers it from +// JavaScript's own semantics over the domain below. The declaration that the +// CLI keeps (`CAN_THROW` in schemata.mjs) is checked against the model: an +// operator declared `false` must never make exactly one form throw. A +// declared `true` may over-claim, which costs a run and never a skipped +// mutant, so it is not checked. +const THROW_FORMS = { + strictEq: { + text: `a === b`, + args: 2, + orig: (a, b) => a === b, + alt: (a, b) => a !== b, + }, + lessThan: { + text: `a < b`, + args: 2, + orig: (a, b) => a < b, + alt: (a, b) => a <= b, + }, + andRead: { + text: `a && b.p`, + args: 2, + orig: (a, b) => a && read(b), + alt: (a, b) => a || read(b), + }, + coalesceRead: { + text: `a ?? b.p`, + args: 2, + orig: (a, b) => a ?? read(b), + alt: (a, b) => a && read(b), + }, + not: { text: `!a`, args: 1, orig: (a) => !a, alt: (a) => a }, + boolLit: { text: `true`, args: 1, orig: () => true, alt: () => false }, + plusOne: { text: `a + 1`, args: 1, orig: (a) => a + 1, alt: (a) => a - 1 }, + optionalRead: { + text: `a?.p`, + args: 1, + orig: (a) => a?.p, + alt: (a) => read(a), + }, +} +// A plain read of a property, which throws on a nullish object. +function read(object) { + if (object === null || object === undefined) + throw new TypeError(`Cannot read properties of ${object}`) + return object.p +} +const THROW_FORM_NAMES = Object.keys(THROW_FORMS) +// The values a form draws, with the cases the declarations must hold on: the +// nullish receivers, BigInt and Symbol operands, and an object whose valueOf +// throws. +const THROW_VALUES = [ + null, + undefined, + Symbol(`s`), + 1n, + 0n, + 1, + 0, + -0, + NaN, + `1`, + `x`, + true, + false, + {}, + { p: 1 }, + { p: null }, + { + valueOf() { + throw new TypeError(`valueOf`) + }, + }, +] +const throwDomain = (form) => { + const { args } = THROW_FORMS[form] + if (args === 1) return THROW_VALUES.map((a) => [a]) + return THROW_VALUES.flatMap((a) => THROW_VALUES.map((b) => [a, b])) +} +// The model's completion of one form on one input: its value, or THREW. +function completionOf(form, input, change) { + const fn = THROW_FORMS[form][change ? `alt` : `orig`] + try { + return fn(...input) + } catch { + return THREW + } +} +// Whether some input makes exactly one of the two forms throw. +function throwsCanDiffer(form) { + return throwDomain(form).some( + (input) => + (completionOf(form, input, null) === THREW) !== + (completionOf(form, input, true) === THREW), + ) +} +const throwPrograms = THROW_FORM_NAMES.map((form) => ({ + kind: `throws`, + form, + lang: `js`, +})) +function renderThrows(program) { + const { text, args } = THROW_FORMS[program.form] + const params = args === 1 ? `a` : `a, b` + const head = `function f(${params}) {\n return ` + return { text: `${head}${text};\n}`, sites: throwSites(program, head) } +} +// A form's sites: its operator, at the token the CLI names (an operator token +// for a swap, the expression's start for `!`, a literal, and a read through +// `?.`), and the returned expression, which is a `return-undefined` site. +const OPERATOR_AT = { + strictEq: [`binary-swap`, 2], + lessThan: [`binary-swap`, 2], + andRead: [`binary-swap`, 2], + coalesceRead: [`binary-swap`, 2], + not: [`drop-not`, 0], + boolLit: [`bool-flip`, 0], + plusOne: [`arithmetic-swap`, 2], + optionalRead: [`optional-chain`, 0], +} +function throwSites(program, head) { + const [operator, offset] = OPERATOR_AT[program.form] + return [ + { + operator: `return-undefined`, + position: head.length, + change: { kind: `undefine` }, + }, + { operator, position: head.length + offset, change: { kind: `alt` } }, + ] +} +// The model's completion under a change: `alt` is the operator's other form, +// and `undefine` returns `undefined` after the expression's own completion. +function evaluateThrows(program, input, change) { + if (change?.kind === `undefine`) + return completionOf(program.form, input, null) === THREW ? THREW : undefined + return completionOf(program.form, input, change) +} +const throwsFamily = { + render: renderThrows, + evaluate: evaluateThrows, + domain: (program) => throwDomain(program.form), +} + const histories = [ ...programs, ...narrowedPrograms, @@ -2770,6 +2922,7 @@ const histories = [ ...returnPrograms, ...textPrograms, ...ESCAPES, + ...throwPrograms, ] // ## Reach @@ -2858,6 +3011,8 @@ function reached(program, trace, expected) { // The structural state a program enters, derived from the grammar. function stateOf(program) { + if (program.kind === `throws`) + return `a ${program.form} operator's canThrow declaration is checked` if (program.kind === `optional`) return `an optional ${program.access} read of a ${program.width} ${program.receiver} receiver in ${program.lang}, ${program.strictness}` if (program.kind === `returned`) @@ -3098,6 +3253,7 @@ const families = { typedDomain: textTypedDomain, }, } +families.throws = throwsFamily const familyOf = (program) => families[program.kind] const sourceOf = (program) => (familyOf(program)?.render ?? render)(program) const run = (program, input, change, counter) => @@ -3168,6 +3324,9 @@ function model(program) { } return { source: text, + ...(program.kind === `throws` + ? { canThrow: throwsCanDiffer(program.form) } + : {}), sites: sites.map((site, index) => { const distinct = distinctIn(program, index) const differs = (domain, change) => @@ -3421,6 +3580,10 @@ function driver(program, observe) { } }) observe(`instrumented`, { clean, emitted }) + if (program.kind === `throws`) + observe(`declared`, { + declared: schemataTable.CAN_THROW?.[program.form] ?? null, + }) } // ## Comparison @@ -3509,6 +3672,22 @@ function check(program, observations, expected) { `with no mutant active, the instrumented function differs from its source ${JSON.stringify(expected.source)}`, LAW_INERT, ) + if (program.kind === `throws`) { + const declared = observations.find((o) => o.checkpoint === `declared`) + ?.value?.declared + if (typeof declared !== `boolean`) + return violation( + `declared`, + `${program.form} has no canThrow declaration in schemata.mjs`, + LAW_THROW, + ) + if (!declared && expected.canThrow) + return violation( + `declared`, + `${program.form} declares canThrow false, but an input makes exactly one of its forms throw`, + LAW_THROW, + ) + } const emitted = new Map(found.emitted.map((e) => [keyOf(e), e])) for (const site of expected.sites) { const where = `${site.operator} at offset ${site.position} of ${JSON.stringify(expected.source)}` From e21177960c4880cce0a56397685b03daf83f2dbf Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 13 Nov 2026 19:53:40 -0700 Subject: [PATCH 6/7] fix(oracle-gaps): declare canThrow per operator and check each false declaration schemata.mjs exports CAN_THROW: whether a mutant's two forms can make exactly one of them throw, by operator (a swap by its operator). The mutant-validity oracle's canThrow family now observes each site's declaration and checks it against the model: a declared false must never split the two completions, and every declared false must be covered by a generated form. Declared true may over-claim (costs a record, never a skipped mutant); if-true and if-false stay true until a generated form covers them (a limit, recorded in schemata.mjs). The prefix of the enumerated histories is unchanged: the first 33388 are byte-identical to before the family's append (diffed in full). Co-authored-by: Isaac --- ...t-mutant-validity-oracle.property.test.mjs | 119 ++++++++++++++---- packages/oracles/oracle-gaps/schemata.mjs | 31 +++++ 2 files changed, 129 insertions(+), 21 deletions(-) diff --git a/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs index 3552648..54cfec2 100644 --- a/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs @@ -2784,12 +2784,36 @@ const THROW_FORMS = { orig: (a, b) => a === b, alt: (a, b) => a !== b, }, + strictNe: { + text: `a !== b`, + args: 2, + orig: (a, b) => a !== b, + alt: (a, b) => a === b, + }, lessThan: { text: `a < b`, args: 2, orig: (a, b) => a < b, alt: (a, b) => a <= b, }, + lessEq: { + text: `a <= b`, + args: 2, + orig: (a, b) => a <= b, + alt: (a, b) => a < b, + }, + greater: { + text: `a > b`, + args: 2, + orig: (a, b) => a > b, + alt: (a, b) => a >= b, + }, + greaterEq: { + text: `a >= b`, + args: 2, + orig: (a, b) => a >= b, + alt: (a, b) => a > b, + }, andRead: { text: `a && b.p`, args: 2, @@ -2859,12 +2883,18 @@ function completionOf(form, input, change) { return THREW } } -// Whether some input makes exactly one of the two forms throw. -function throwsCanDiffer(form) { - return throwDomain(form).some( - (input) => - (completionOf(form, input, null) === THREW) !== - (completionOf(form, input, true) === THREW), +// For each site key: whether some input of the domain makes exactly one of +// the program's two completions throw, the site's and the original's. +function throwsByKey(program, sites) { + return Object.fromEntries( + sites.map((site) => [ + site.key, + domainOf(program).some( + (input) => + (evaluateThrows(program, input, site.change) === THREW) !== + (evaluateThrows(program, input, null) === THREW), + ), + ]), ) } const throwPrograms = THROW_FORM_NAMES.map((form) => ({ @@ -2881,7 +2911,27 @@ function renderThrows(program) { // A form's sites: its operator, at the token the CLI names (an operator token // for a swap, the expression's start for `!`, a literal, and a read through // `?.`), and the returned expression, which is a `return-undefined` site. +// The table key of each form's sites (CAN_THROW in schemata.mjs): a swap is +// keyed by its operator, every other site by its operator. +const SITE_KEY = { + strictEq: `binary-swap:===`, + strictNe: `binary-swap:!==`, + lessThan: `binary-swap:<`, + lessEq: `binary-swap:<=`, + greater: `binary-swap:>`, + greaterEq: `binary-swap:>=`, + andRead: `binary-swap:&&`, + coalesceRead: `binary-swap:??`, + not: `drop-not`, + boolLit: `bool-flip`, + plusOne: `arithmetic-swap`, + optionalRead: `optional-chain`, +} const OPERATOR_AT = { + strictNe: [`binary-swap`, 2], + lessEq: [`binary-swap`, 2], + greater: [`binary-swap`, 2], + greaterEq: [`binary-swap`, 2], strictEq: [`binary-swap`, 2], lessThan: [`binary-swap`, 2], andRead: [`binary-swap`, 2], @@ -2896,12 +2946,24 @@ function throwSites(program, head) { return [ { operator: `return-undefined`, + key: `return-undefined`, position: head.length, change: { kind: `undefine` }, }, - { operator, position: head.length + offset, change: { kind: `alt` } }, + { + operator, + key: SITE_KEY[program.form], + position: head.length + offset, + change: { kind: `alt` }, + }, ] } +// The keys of every form's sites: a declared `false` must be one of them. +const FAMILY_KEYS = new Set( + THROW_FORM_NAMES.flatMap((form) => + throwSites({ form }, ``).map((site) => site.key), + ), +) // The model's completion under a change: `alt` is the operator's other form, // and `undefine` returns `undefined` after the expression's own completion. function evaluateThrows(program, input, change) { @@ -3325,7 +3387,7 @@ function model(program) { return { source: text, ...(program.kind === `throws` - ? { canThrow: throwsCanDiffer(program.form) } + ? { canThrowByKey: throwsByKey(program, sites) } : {}), sites: sites.map((site, index) => { const distinct = distinctIn(program, index) @@ -3582,7 +3644,12 @@ function driver(program, observe) { observe(`instrumented`, { clean, emitted }) if (program.kind === `throws`) observe(`declared`, { - declared: schemataTable.CAN_THROW?.[program.form] ?? null, + declared: Object.fromEntries( + sourceOf(program).sites.map((site) => [ + site.key, + schemataTable.CAN_THROW?.[site.key] ?? null, + ]), + ), }) } @@ -3675,18 +3742,28 @@ function check(program, observations, expected) { if (program.kind === `throws`) { const declared = observations.find((o) => o.checkpoint === `declared`) ?.value?.declared - if (typeof declared !== `boolean`) - return violation( - `declared`, - `${program.form} has no canThrow declaration in schemata.mjs`, - LAW_THROW, - ) - if (!declared && expected.canThrow) - return violation( - `declared`, - `${program.form} declares canThrow false, but an input makes exactly one of its forms throw`, - LAW_THROW, - ) + for (const [key, throws] of Object.entries(expected.canThrowByKey)) { + if (typeof declared?.[key] !== `boolean`) + return violation( + `declared`, + `${key} has no canThrow declaration in schemata.mjs`, + LAW_THROW, + ) + if (!declared[key] && throws) + return violation( + `declared`, + `${key} declares canThrow false, but an input makes exactly one of its two forms throw`, + LAW_THROW, + ) + } + // A declared false must be generated, or the claim is not checked. + for (const [key, throws] of Object.entries(schemataTable.CAN_THROW ?? {})) + if (throws === false && !FAMILY_KEYS.has(key)) + return violation( + `declared`, + `${key} is declared canThrow false, and no generated form covers it`, + LAW_THROW, + ) } const emitted = new Map(found.emitted.map((e) => [keyOf(e), e])) for (const site of expected.sites) { diff --git a/packages/oracles/oracle-gaps/schemata.mjs b/packages/oracles/oracle-gaps/schemata.mjs index 525d53e..1153eda 100644 --- a/packages/oracles/oracle-gaps/schemata.mjs +++ b/packages/oracles/oracle-gaps/schemata.mjs @@ -55,6 +55,37 @@ // written out and the switch picks one. Every replacement starts with an // identifier, so it cannot join the previous line under automatic semicolon // insertion. +// Whether a mutant's two forms can make exactly one of them throw on some +// input, by operator (a swap by its operator). The completion recording +// depends on it: a site declared `false` is never recorded, so its declaration +// is checked by the mutant-validity oracle (gap-report-mutant-validity-oracle), +// which generates each `false` operator's forms and finds no input that +// splits the two completions. A `true` may over-claim, which costs a record +// and never a skipped mutant. `if-true` and `if-false` are `true` until a +// generated form covers them (a limit of the oracle; over-claiming is safe). +export const CAN_THROW = { + 'binary-swap:===': false, + 'binary-swap:!==': false, + 'binary-swap:<': false, + 'binary-swap:<=': false, + 'binary-swap:>': false, + 'binary-swap:>=': false, + 'binary-swap:&&': true, + 'binary-swap:||': true, + 'binary-swap:??': true, + 'drop-not': false, + 'bool-flip': false, + 'arithmetic-swap': false, + 'return-undefined': false, + 'optional-chain': true, + 'value-undefined': true, + 'field-drop': true, + 'delete-call': true, + 'loop-false': true, + 'if-true': true, + 'if-false': true, +} + const G = `globalThis.__oracleGaps` const comparisons = new Map([ From 852d28a94b151730804857d9eefc94482505f623 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sat, 14 Nov 2026 07:56:24 -0700 Subject: [PATCH 7/7] fix(oracle-gaps): a throws program decides its law by its declaration, and the shipped comment names no oracle file The reached function read program.expr for every non-narrowed program, so each throws program crashed in gap report 3/3 (a TypeError, not a law verdict). A throws program now reaches no state: its law is its declaration. The CAN_THROW comment in schemata.mjs named the oracle file, which is not shipped, so the package's shipped-citations test failed. It now names no file. Co-authored-by: Isaac --- .../gap-report-mutant-validity-oracle.property.test.mjs | 2 ++ packages/oracles/oracle-gaps/schemata.mjs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs index 54cfec2..6db6ef3 100644 --- a/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs @@ -3060,6 +3060,8 @@ function reached(program, trace, expected) { return detected ? [REACH.hostile(program.name)] : [] } if (program.kind === `narrowed`) return [] + // A throw-capability program decides its law by its declaration, not a reach state. + if (program.kind === `throws`) return [] const states = expected.sites.some((site) => site.label) ? [REACH.label] : [] const test = program.kind === `guard` ? program.test : program.expr.test if ( diff --git a/packages/oracles/oracle-gaps/schemata.mjs b/packages/oracles/oracle-gaps/schemata.mjs index 1153eda..a298142 100644 --- a/packages/oracles/oracle-gaps/schemata.mjs +++ b/packages/oracles/oracle-gaps/schemata.mjs @@ -58,7 +58,7 @@ // Whether a mutant's two forms can make exactly one of them throw on some // input, by operator (a swap by its operator). The completion recording // depends on it: a site declared `false` is never recorded, so its declaration -// is checked by the mutant-validity oracle (gap-report-mutant-validity-oracle), +// is checked by the package's mutant-validity oracle (a test file, not shipped), // which generates each `false` operator's forms and finds no input that // splits the two completions. A `true` may over-claim, which costs a record // and never a skipped mutant. `if-true` and `if-false` are `true` until a