Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 6 additions & 2 deletions packages/oracles/oracle-gaps/capture-setup.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down
94 changes: 81 additions & 13 deletions packages/oracles/oracle-gaps/gap-report-model-oracle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1823,6 +1847,9 @@ const captureWitness = new Set([
`w_record`,
`w_data`,
`w_optional`,
`w_absent`,
`w_absentNull`,
`w_swallow`,
`w_oldprobe`,
`w_spin`,
])
Expand Down Expand Up @@ -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});`]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`)
Expand Down Expand Up @@ -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`,
Expand Down
Loading