diff --git a/packages/oracles/oracle-gaps/capture-setup.mjs b/packages/oracles/oracle-gaps/capture-setup.mjs index 5255684..b4345ae 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 6ebe9f3..25a7d37 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-mutant-validity-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs index fd5f41a..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 @@ -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,218 @@ 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, + }, + 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, + 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 + } +} +// 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) => ({ + 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. +// 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], + 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`, + key: `return-undefined`, + position: head.length, + change: { kind: `undefine` }, + }, + { + 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) { + 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 +2984,7 @@ const histories = [ ...returnPrograms, ...textPrograms, ...ESCAPES, + ...throwPrograms, ] // ## Reach @@ -2845,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 ( @@ -2858,6 +3075,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 +3317,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 +3388,9 @@ function model(program) { } return { source: text, + ...(program.kind === `throws` + ? { canThrowByKey: throwsByKey(program, sites) } + : {}), sites: sites.map((site, index) => { const distinct = distinctIn(program, index) const differs = (domain, change) => @@ -3421,6 +3644,15 @@ function driver(program, observe) { } }) observe(`instrumented`, { clean, emitted }) + if (program.kind === `throws`) + observe(`declared`, { + declared: Object.fromEntries( + sourceOf(program).sites.map((site) => [ + site.key, + schemataTable.CAN_THROW?.[site.key] ?? null, + ]), + ), + }) } // ## Comparison @@ -3509,6 +3741,32 @@ 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 + 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) { const where = `${site.operator} at offset ${site.position} of ${JSON.stringify(expected.source)}` 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 9f0fe4c..6177e3b 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`, diff --git a/packages/oracles/oracle-gaps/schemata.mjs b/packages/oracles/oracle-gaps/schemata.mjs index 2366c8c..bd7846e 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 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 +// 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([