diff --git a/packages/oracles/oracle-gaps/capture-setup.mjs b/packages/oracles/oracle-gaps/capture-setup.mjs index 6b9db66..42a1852 100644 --- a/packages/oracles/oracle-gaps/capture-setup.mjs +++ b/packages/oracles/oracle-gaps/capture-setup.mjs @@ -533,10 +533,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 f670836..db30df0 100644 --- a/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs +++ b/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs @@ -1713,6 +1713,30 @@ 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)], + }, + // 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. @@ -1823,6 +1847,9 @@ const captureWitness = new Set([ `w_record`, `w_data`, `w_optional`, + `w_absent`, + `w_absentNull`, + `w_swallow`, `w_oldprobe`, `w_spin`, ]) @@ -2092,8 +2119,11 @@ function renderStatement(stmt, callText) { return [`out.push(...loaded);`] case `optional`: return [ - `const o${stmt.label} = x ? { v: 'p${stmt.label}' } : undefined;`, - `out.push(\`\${o${stmt.label}?.v}\`);`, + `const o${stmt.label} = x ? { v: 'p${stmt.label}' } : ${stmt.absent === `null` ? `null` : `undefined`};`, + // 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});`] @@ -3099,8 +3129,17 @@ 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]) - at(`optional-chain`, `chain`, 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]) + // 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 @@ -3481,6 +3520,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 @@ -3680,15 +3722,26 @@ function interpret(plan, fnId, x, sites, active) { ? isActive(drop) ? {} : { v: `p${stmt.label}` } - : undefined - const site = siteOf(stmt, `push`) - reach(site.id) - if (!isActive(site)) { - const chain = siteOf(stmt, `chain`) + : stmt.absent === `null` + ? null + : undefined + 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) - throw new TypeError(`Cannot read properties of undefined`) - trace.push(String(made?.v)) + if (isActive(chain) && (made === undefined || made === null)) + 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) @@ -3871,7 +3924,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. */ @@ -4327,6 +4380,21 @@ 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 86cc8cc..8badb2a 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 test's work is the number of site + * 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 test's work is the number of site * evaluations that its instrumented production code made (the capture's * call count, never a time): a call statement, a call whose value is * used, a comparison, an `if`, each pass of a loop's condition. A @@ -1859,6 +1864,12 @@ 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.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`) @@ -3140,6 +3151,9 @@ 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 plain read whose throw the owner swallowed`, `a module-level cache judged afresh`, `ordinary tests run when asked`, `ordinary tests left out by default`,