diff --git a/packages/oracles/oracle-gaps/capture-setup.mjs b/packages/oracles/oracle-gaps/capture-setup.mjs index 8e5d1cf..a06de15 100644 --- a/packages/oracles/oracle-gaps/capture-setup.mjs +++ b/packages/oracles/oracle-gaps/capture-setup.mjs @@ -420,6 +420,14 @@ globalThis.__oracleGaps = { infect(id) 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. + chain(id, value) { + hit(id) + if (id === active && (value === undefined || value === null)) + throw new TypeError(`Cannot read properties of ${value}`) + return value + }, // Records whether the condition was truthy or falsy, then applies a forced // value if one of this `if`'s two mutants is active. cond(idTrue, idFalse, value) { diff --git a/packages/oracles/oracle-gaps/classify.mjs b/packages/oracles/oracle-gaps/classify.mjs index 91eb006..d9a81e8 100644 --- a/packages/oracles/oracle-gaps/classify.mjs +++ b/packages/oracles/oracle-gaps/classify.mjs @@ -260,14 +260,17 @@ export function targetsOf(mutant, baseline, owners, oracleFiles) { // comparison's operands (its operators disagree on them), and the operators // that always change a value when reached (`!`, booleans, arithmetic). The // rest are not measured: a logical operator, a loop's condition, an -// `undefined` call value, a dropped property, and a skipped call change a -// value only on some inputs, and no capture shows which. +// `undefined` call value, a dropped property, a skipped call, a returned +// value made `undefined`, and a plain read in place of `?.` change a value +// only on some inputs, and no capture shows which. const comparisonOps = new Set([`===`, `!==`, `<`, `<=`, `>`, `>=`]) const unmeasuredOps = new Set([ `loop-false`, `value-undefined`, `field-drop`, `delete-call`, + `return-undefined`, + `optional-chain`, ]) const isIf = (mutant) => mutant.operator === `if-true` || mutant.operator === `if-false` @@ -340,7 +343,9 @@ const changedIn = (rows, keys, baseline) => * `unreached`. A survivor gets one class from what the run measured (the * classes are in the design's decision 11): `not-judged` (a changed value * no law rejected), `indeterminate` (only nondeterministic tests reached - * it), `dead-code` (no input distinguishes it), `branch-never-taken` (an + * it), `dead-code` (no input distinguishes it), `outside-annotation` (no + * input of the annotations distinguishes it; `input` names the input class + * outside them that does), `branch-never-taken` (an * `if` one of whose outcomes never occurred; `wanted` names the missing * one), `not-infected` (a comparison whose operators agreed on every * operand the owners reached; `operands` names them), and `not-observed` @@ -377,7 +382,12 @@ export function judgeMutant(mutant, { owners, targets, units, baseline }) { baseline.nondeterministic.has(k), ) } else if (mutant.evidence === `dead code`) result.class = `dead-code` - else if (isUnmeasured(mutant)) result.class = `not-distinguished` + else if (mutant.outside) { + // No input of the annotations distinguishes it: the type analysis names + // the input outside them that does (maintainer decision, 2026-11-11). + result.class = `outside-annotation` + result.input = mutant.outside + } else if (isUnmeasured(mutant)) result.class = `not-distinguished` else { const { infected, infectedBy } = infectionOf(mutant, targets, baseline) if (isIf(mutant) && !infected) { diff --git a/packages/oracles/oracle-gaps/gap-report-classify-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-classify-oracle.property.test.mjs index 401e2dc..9f5f7fa 100644 --- a/packages/oracles/oracle-gaps/gap-report-classify-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-classify-oracle.property.test.mjs @@ -50,6 +50,11 @@ * reaches every test of that file, maintainer decision of 2026-11-06); * `indeterminate` when no such test judged it; `dead-code` when the * mutant carries the `dead code` evidence (no input distinguishes it); + * `outside-annotation` when the type analysis names an input class + * outside the declared types, and none of its inputs distinguishes the + * mutant (maintainer decision, 2026-11-11: types never skip a mutant or + * mark it dead, they label its survivor; an argument typed \`any\` is + * an input the types allow, so the class claims no more); * `branch-never-taken` when it is an `if`-operator mutant and an owner * test reached its `if` with one outcome only; `not-infected` when it is * a comparison whose two operators agree on every operand pair the @@ -433,6 +438,8 @@ const records = fc.record({ `loop-false`, `value-undefined`, `field-drop`, + `return-undefined`, + `optional-chain`, ), original: fc.constantFrom(`>`, `>=`, `===`, `&&`, `||`, `??`), // The code any other operator replaces, and its replacement. @@ -443,6 +450,16 @@ const records = fc.record({ `dead code`, `boundary: -0`, ), + // The input class that the type analysis names for a mutant that no + // input of the declared types distinguishes (maintainer decision, + // 2026-11-11): a label for the survivor, never a skip. + outside: fc.constantFrom( + undefined, + undefined, + undefined, + `a string of the literal's digits`, + `\`null\``, + ), anchor: fc.constantFrom( `out . push ( 'a' ) ;`, `if ( x > 4 ) out . push ( \`b\` ) ;`, @@ -667,6 +684,12 @@ function modelOutcome(h, mi) { } if (m.evidence === `dead code`) return sized(`dead-code`, { mention: [`line ${m.line + 1}.`] }) + // The type analysis found no input of the declared types that + // distinguishes it, and names the input class outside them that does. + if (m.outside) + return sized(`outside-annotation`, { + mention: [`line ${m.line + 1} with ${m.outside},`], + }) // An `if` mutant forces its condition: an if-true mutant changes a value // only where the condition was false, an if-false mutant only where it was // true. Its branch is never taken when no deterministic owner test (or the @@ -742,6 +765,10 @@ function distinguishingDirective(m) { return `Add an owner history that reads the property dropped on line ${line}, so that its absence changes an asserted result, and assert on the result.` if (m.operator === `delete-call`) return `Add an owner history where the call on line ${line} changes an asserted result when it does not run, and assert on the result.` + if (m.operator === `return-undefined`) + return `Add an owner history that uses the value returned on line ${line}, so that \`undefined\` in its place changes an asserted result, and assert on the result.` + if (m.operator === `optional-chain`) + return `Add an owner history where the object read with \`?.\` on line ${line} is \`undefined\`, so that a plain read throws, and assert on the result.` if (m.original === `??`) return `Add an owner history where the left operand of \`??\` on line ${line} is truthy, so \`??\` and \`&&\` give different results, and assert on the result.` return `Add an owner history where one operand of \`${m.original}\` on line ${line} is falsy and the other is truthy, so \`&&\` and \`||\` give different results, and assert on the result.` @@ -1043,6 +1070,8 @@ const directives = { indeterminate: /^(Make `[^`]+` deterministic: control its clock and its random seed, so its failures judge this code|Add an owner test that runs line \d+ and asserts on its result)\.$/, 'dead-code': /^Remove the redundant code on line \d+\.$/, + 'outside-annotation': + /^Add an owner history that reaches line \d+ with .+, which the annotation excludes, so that it changes an asserted result, and assert on the result\.$/, 'random-only-kill': /^Keep the counterexample that `[^`]+` found in the random campaign as a fixed history\.$/, 'agreement-only-kill': @@ -1054,7 +1083,7 @@ const directives = { crashed: /^Production threw `[^`]*` in `[^`]+`; record the error as an observation in the driver, and state the law that decides this input's outcome\.$/, 'not-distinguished': - /^Add an owner history (where one operand of `[&|]{2}` on line \d+ is falsy and the other is truthy, so `&&` and `\|\|` give different results|where the left operand of `\?\?` on line \d+ is truthy, so `\?\?` and `&&` give different results|that enters the loop on line \d+, so that forcing its condition to false changes an asserted result|where the call on line \d+ returns a value that changes an asserted result when it is `undefined`|that reads the property dropped on line \d+, so that its absence changes an asserted result|where the call on line \d+ changes an asserted result when it does not run), and assert on the result\.$/, + /^Add an owner history (where one operand of `[&|]{2}` on line \d+ is falsy and the other is truthy, so `&&` and `\|\|` give different results|where the left operand of `\?\?` on line \d+ is truthy, so `\?\?` and `&&` give different results|that enters the loop on line \d+, so that forcing its condition to false changes an asserted result|where the call on line \d+ returns a value that changes an asserted result when it is `undefined`|that reads the property dropped on line \d+, so that its absence changes an asserted result|where the call on line \d+ changes an asserted result when it does not run|that uses the value returned on line \d+, so that `undefined` in its place changes an asserted result|where the object read with `\?\.` on line \d+ is `undefined`, so that a plain read throws), and assert on the result\.$/, timeout: /^Bound the run of the owner tests so that they end, and let a law reject a run that does not\.$/, 'setup-error': @@ -1511,6 +1540,7 @@ async function driver(h, observe) { ? { original: m.original, replacement: swapFor[m.original] } : { original: m.code[0], replacement: m.code[1] }), ...(m.evidence ? { evidence: m.evidence } : {}), + ...(m.outside ? { outside: m.outside } : {}), ...(m.probe ? { probeGuard: true } : {}), })) const fileIndex = (file) => fileIndexOf(h, file) @@ -2324,6 +2354,7 @@ const required = [ `not-observed`, `not-distinguished`, `dead-code`, + `outside-annotation`, `ordinary killed`, `a key with backticks`, `authored not-observed`, @@ -2413,6 +2444,7 @@ const single = ( original: `>`, code: codes[1], evidence: undefined, + outside: undefined, anchor: `out . push ( 'a' ) ;`, probe: false, ...mutant, @@ -2697,6 +2729,17 @@ const constructed = [ mutant: { operator: `binary-swap`, original: `>` }, test: { pair: four([2, 2]) }, }), + single( + `a comparison that only an input outside its annotation distinguishes`, + [`outside-annotation`], + { + mutant: { + operator: `binary-swap`, + original: `>`, + outside: `a string of the literal's digits`, + }, + }, + ), single(`a logical swap that is dead code`, [`dead-code`], { mutant: { operator: `binary-swap`, @@ -2916,7 +2959,7 @@ const constructed = [ oracleProperty({ id: `oracle-gaps.report-classify`, law: { - statement: `From the records of the runs that judged it, each mutant, operator or authored, gets the outcome the law assigns: kills only by law verdicts on reproducible routes, agreement-only, random-only, and unregistered kills, timeouts, crashes, framework and setup errors as gaps, survivors split into not-judged, indeterminate, dead-code, branch-never-taken, not-infected, not-observed, and not-distinguished, and ordinary tests for information only. Every gap has one directive message from its kind's fixed set, with no exit; a not-observed directive also directs a history where the mutated code computes something different, and a not-infected message never prints two different operand values alike. A message or list that names tests or files (a gap's Owners line, the files that failed with no mutant active, and the nondeterministic tests included) names them in report order, by test file path compared by code unit and within a file in the baseline's record order, whatever order the runner joined the baseline's files in, a mutant run wrote a file's records in, or the owners were declared in; an operator mutant's owner files run in that order, and a kill names laws of the first that kills. The summary lists every gap and unowned mutant once with consistent counts; the stdout summary and the streamed gap lines cover every gap once, each on one line with its code or intent cut at 60 characters, and the results keep the full text. Each failing oracle file's baseline replay command, read by a POSIX shell, runs exactly its first failing test under Vitest 4's and Vitest 5's name joins, and the whole file when no test failed.`, + statement: `From the records of the runs that judged it, each mutant, operator or authored, gets the outcome the law assigns: kills only by law verdicts on reproducible routes, agreement-only, random-only, and unregistered kills, timeouts, crashes, framework and setup errors as gaps, survivors split into not-judged, indeterminate, dead-code, outside-annotation, branch-never-taken, not-infected, not-observed, and not-distinguished, and ordinary tests for information only. Every gap has one directive message from its kind's fixed set, with no exit; a not-observed directive also directs a history where the mutated code computes something different, and a not-infected message never prints two different operand values alike. A message or list that names tests or files (a gap's Owners line, the files that failed with no mutant active, and the nondeterministic tests included) names them in report order, by test file path compared by code unit and within a file in the baseline's record order, whatever order the runner joined the baseline's files in, a mutant run wrote a file's records in, or the owners were declared in; an operator mutant's owner files run in that order, and a kill names laws of the first that kills. The summary lists every gap and unowned mutant once with consistent counts; the stdout summary and the streamed gap lines cover every gap once, each on one line with its code or intent cut at 60 characters, and the results keep the full text. Each failing oracle file's baseline replay command, read by a POSIX shell, runs exactly its first failing test under Vitest 4's and Vitest 5's name joins, and the whole file when no test failed.`, authority: `The maintainer's gap-check decisions in design runs v11, v14, and v19 (2026-11-04 and 2026-11-05), as for oracle-gaps.report; the oracle rubric's outcome kinds (ORC-006); the frozen design's oracle over the checker's pure core (v19 P10, F4); the maintainer's few-seconds budget for an incremental test run (2026-11-05); the maintainer's findings F22 and the leftovers of #12 (2026-11-10); for report order, the gap CLI's determinism requirement (oracle-gaps.report's determinism test and law 9, and the runner's "listed alike in every run") and the maintainer's decision of 2026-11-11 that the tests a report names depend on neither --contexts, worker timing, nor how owners are declared; Vitest's runner (@vitest/runner 4.1: -t is a regular expression matched against getTaskFullName, the suites' names and the test's joined by a space); POSIX.1-2017 Shell Command Language 2.2 and 2.6 for the replay's quoting.`, limits: `That real runs produce these records is oracle-gaps.report's, and so is a real replay. The driver encodes operands as capture-setup.mjs \`shown\` does, by a copy of it; long strings are told apart by a 32-bit digest; no symbol operand is generated, and two symbols of one description print alike. Only the runner's single-file path records a test's names, and only it feeds the baseline. Test names hold no backtick and no \` > \`, and names within a file are distinct; the replay's file argument is judged as text, not as Vitest's substring filter over every file. Authored intents hold no word that the exit law forbids, and an author is not shortened. Report order within a file is the baseline's record order: that a file whose tests run concurrently writes its records in one order is not judged, and a failing test is ordered within its file as the reporter lists it, which a run cut off by a hang takes from the records. The "Changed observations in" list is the changed tests' first three, and only its first is compared. A test that only a mutant run records is not generated. A file that fails with no mutant active keeps its records and its mutants are classified; the refusal text and the summary's left-out list are not judged, only the list of failing files they print.`, }, diff --git a/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs b/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs index 5480904..f55f107 100644 --- a/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs +++ b/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs @@ -38,6 +38,8 @@ const RUNES = `src/state.svelte.ts` const DERIVED = `src/derived.ts` // The derived class's constructor, as the model names it: no oracle owns it. const DERIVED_FN = `Derived.constructor` +// Its helper `nothing`, which no oracle owns either. +const DERIVED_HELPER = `nothing` // The inputs a derived class is run with, each judged against the model. export const DERIVED_INPUTS = [0, 4, 9] // Where this package's own dependencies are installed. @@ -240,6 +242,8 @@ const statement = fc.oneof( kind: fc.constant(`readData`), shape: fc.constantFrom(`object`, `array`, `primitive`, `methods`), }), + // An object that only a truthy `x` makes, read through `?.`. + fc.record({ kind: fc.constant(`optional`) }), ) const libStatement = fc.oneof( fc.record({ kind: fc.constant(`push`), quote }), @@ -1407,6 +1411,14 @@ const witness = [ ], tests: [judging(`a`, `fixed seed`, 1)], }, + // An object read through `?.`, made for the test's input: the plain read + // in its place is not distinguished, since the object is there. + { + id: `w_optional`, + owners: [`a`], + body: [{ kind: `optional` }], + tests: [judging(`a`, `fixed seed`, 1)], + }, // 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. @@ -1490,6 +1502,8 @@ const captureWitness = new Set([ `w_fcprop`, `w_record`, `w_data`, + `w_optional`, + `w_oldprobe`, `w_spin`, ]) @@ -1741,6 +1755,11 @@ function renderStatement(stmt, callText) { return [`out.push(String(memo.get(x)));`] case `pushLoaded`: return [`out.push(...loaded);`] + case `optional`: + return [ + `const o${stmt.label} = x ? { v: 'p${stmt.label}' } : undefined;`, + `out.push(\`\${o${stmt.label}?.v}\`);`, + ] case `readData`: return [`out.push(${dataOf(stmt.shape).read});`] case `call`: @@ -1823,11 +1842,14 @@ function derivedFile(spec) { `function nothing(x: number): number {`, ` return x - x;`, `}`, - `export function derivedMake(x: number, out: Array): Derived {`, - ` return new Derived(x, out);`, - `}`, + // A concise arrow, which has no `return` statement to mutate: what + // `derivedMake` returns is what the derived runs read. + `export const derivedMake = (x: number, out: Array): Derived =>`, + ` new Derived(x, out);`, ) - return { source: `${lines.join(`\n`)}\n`, statements } + // `nothing`'s `return`, a site of its own outside the constructor. + const returnLine = lines.indexOf(` return x - x;`) + 1 + return { source: `${lines.join(`\n`)}\n`, statements, returnLine } } // What a derived class's constructor pushes for `x`, and the fields it sets: @@ -2482,6 +2504,7 @@ function resolveHistory(history) { spec: history.derived, source: file.source, statements: file.statements, + returnLine: file.returnLine, }, } } @@ -2648,8 +2671,14 @@ function sitesOf(plan) { add(`devBuild`, 4, `binary-swap`, preambleStatements.devBuild, { role: `devBuild`, }) + add(`devBuild`, 4, `return-undefined`, preambleStatements.devBuild, { + role: `devBuildReturn`, + }) // Its `return false` runs only when reading the environment throws. add(`devBuild`, 6, `bool-flip`, `return false`, { role: `devBuildFalse` }) + add(`devBuild`, 6, `return-undefined`, `return false`, { + role: `devBuildFalseReturn`, + }) declaration(`report`, 10, `'`) guard(`report`, 11, `'gap#report'`, `true`) add(`reportTwice`, 14, `delete-call`, preambleStatements.reportTwice, { @@ -2699,6 +2728,15 @@ function sitesOf(plan) { at(`delete-call`, `push`, stmt.line, whole) // A call of a nested function where it is defined. if (stmt.kind === `inner`) at(`delete-call`, `call`, stmt.line, whole) + // A returned nested function, which is not `undefined`. + if (stmt.kind === `innerReturned`) + at(`return-undefined`, `returned`, stmt.line, whole) + // 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]) + } // 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 // its argument to `w_pair`. @@ -2714,8 +2752,10 @@ function sitesOf(plan) { at(`delete-call`, `push`, stmt.line + 1, call) at(`value-undefined`, `string`, stmt.line + 1, call) } + // A returned call is a `return-undefined` site too (the sixth review). if (stmt.kind === `getterReturn`) { at(`value-undefined`, `string`, stmt.line, whole) + at(`return-undefined`, `returned`, stmt.line, whole) at(`bool-flip`, `flag`, stmt.line, whole) } // `$derived` is an ordinary name outside a Svelte module. @@ -2723,8 +2763,10 @@ function sitesOf(plan) { at(`delete-call`, `push`, stmt.line, whole) at(`value-undefined`, `value`, stmt.line, whole) } - if (stmt.kind === `dollarReturn`) + if (stmt.kind === `dollarReturn`) { at(`value-undefined`, `string`, stmt.line, whole) + at(`return-undefined`, `returned`, stmt.line, whole) + } // A rune call stays where Svelte requires it, so it is no site of its // own (its value is not replaced, nor its statement skipped); the code // in its argument is. @@ -2885,6 +2927,17 @@ function sitesOf(plan) { } } } + // `nothing`'s returned `x - x`, a number. + if (plan.derived) + add( + `nothing`, + plan.derived.returnLine, + `return-undefined`, + `return x - x;`, + { + file: DERIVED, + }, + ) // The derived class's file (`derivedFile`), which no oracle owns. A // statement before `super(...)`, and the `super` statement itself, have no // site: no mutant, when active, may prevent or delay the constructor's @@ -2917,7 +2970,10 @@ function sitesOf(plan) { [`used`, `return 1 + 2;`], [`spare`, `return 3 + 4;`], ]) - add(name, d.line, `arithmetic-swap`, text, { role: `${name}Arith` }) + for (const operator of [`arithmetic-swap`, `return-undefined`]) + add(name, d.line, operator, text, { + role: `${name}${operator === `arithmetic-swap` ? `Arith` : `Return`}`, + }) } // The `..gen/` file's top-level loop: its variable's `-`, its condition, // and its `<`, in no function, and owned by no entry (a whole-file entry @@ -3025,7 +3081,8 @@ function ownersOf(plan) { ...(plan.wholeFile === `none` || inLibrary.has(id) || id === TOP_LOOP || - id === DERIVED_FN + id === DERIVED_FN || + id === DERIVED_HELPER ? [] : [plan.wholeFile]), ]) @@ -3213,6 +3270,28 @@ function interpret(plan, fnId, x, sites, active) { } } } + // `x` makes the object only when truthy, so the field drop is reached + // only then. A plain read in place of `?.` throws on no object. + if (stmt.kind === `optional`) { + const drop = siteOf(stmt, `dropV`) + if (x) reached.add(drop.id) + const made = x + ? isActive(drop) + ? {} + : { v: `p${stmt.label}` } + : 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`) + trace.push(String(made?.v)) + } + } + if (stmt.kind === `innerReturned`) + reached.add(siteOf(stmt, `returned`).id) // A read of module data, as the module evaluated it at load under the // same mutant: a dropped field reads undefined, the swapped `+` gives // 1, and the flipped literal false. @@ -3229,6 +3308,10 @@ function interpret(plan, fnId, x, sites, active) { const value = siteOf(stmt, `value`) reached.add(value.id) if (isActive(value)) return `undefined` + // Its `return`: undefined in place of what it returns. + const returned = sites.find((s) => s.role === `usedReturn`) + reached.add(returned.id) + if (isActive(returned)) return `undefined` const arith = sites.find((s) => s.role === `usedArith`) reached.add(arith.id) return String(isActive(arith) ? -1 : 3) @@ -3294,6 +3377,9 @@ function interpret(plan, fnId, x, sites, active) { ]) reached.add(siteOf(stmt, role).id) reached.add(sites.find((s) => s.role === `devBuild`).id) + // Its `return`, whose undefined value leaves the probe uncalled, and + // no output changes. + reached.add(sites.find((s) => s.role === `devBuildReturn`).id) outcomes.push([stmt.line, true]) // The guard is true, so its if-false site changes it; and its `!==` // compares 'test' with 'production', on which `!==` and `===` @@ -3581,9 +3667,14 @@ function model(plan) { // probe form and of a probe in another shape). Its directive names the // input that distinguishes it. if ( - [`loop-false`, `value-undefined`, `field-drop`, `delete-call`].includes( - site.operator, - ) || + [ + `loop-false`, + `value-undefined`, + `field-drop`, + `delete-call`, + `return-undefined`, + `optional-chain`, + ].includes(site.operator) || site.logical === true ) return `not-distinguished` 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 0c7f993..fd5f41a 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 @@ -1,37 +1,48 @@ /** - * # The gap report's mutants are judged over the framework's data domain + * # Types never hide a mutant + * + * The law (maintainer decision, 2026-11-11): the gap CLI never skips a + * mutant, and never marks it dead code, on the basis of types. A mutant that + * no input of its declared types distinguishes, and that an input JavaScript + * allows does, is emitted with the `outside` label, which names that input's + * class, and its survivor is reported as `outside-annotation`. An argument + * typed `any` is an input the types allow, so the label claims no more than + * that the annotations exclude the input. An unsound type analysis now only + * mislabels a survivor; it never hides one. * * Law 1: every emitted mutant that is not marked dead code differs from the - * original function on some input. Law 2: every site that the instrumenter - * skips equals the original on every input. Dead code: a site is marked dead - * code exactly when no input distinguishes it, and no site is skipped as - * undetectable: an undetectable site is dead code, not a skip. Boundary: a - * site marked `boundary: -0` is distinguished by a history of 0 and -0 alone, - * so the hint's claim holds. A distinguishable site need not carry the hint. - * Refinement: every emitted mutant's run, dead-marked or not, agrees with the - * model. + * original function on some input that JavaScript allows. Law 2: no site of + * the operator contract is skipped (the contract's own syntactic exclusions, + * such as `return undefined`, are no sites). Dead code: a site is marked dead + * code exactly when no input distinguishes it, by its syntax alone. The label + * law: a mutant carries the label exactly when an annotation excludes every + * input that distinguishes it (in the first family and the coerced + * programs); everywhere else it carries none. Boundary: a + * site marked `boundary: -0` is distinguished by a history of 0 and -0 alone. + * Refinement: every emitted mutant's run agrees with the model. Types change + * nothing (the hostile programs): erasing a program's types changes none of + * its sites and no dead-code mark, and every dead-code mark there holds. * - * Authority: the frozen design v20's decision of 2026-11-10, "anything an - * oracle can't possibly detect is an invalid mutant" (freeze.md, "Undetectable - * mutants are invalid"), and its decision 5, that a reported equivalent is a - * framework bug and not an answer. The domain is the framework's data domain - * (maintainer, 2026-11-10, relayed by the coordinator): signed zero counts - * apart from zero, NaN is a value, and a string compares with coercion beside - * a number of the same digits. A skip that the integers alone would justify is - * unsound on that domain, so no site is skipped. The dead-code and boundary - * evidence follow the coordinator's revision and the review of PR #8. + * Authority: the maintainer's decision of 2026-11-11 above, relayed by the + * coordinator after the fifth soundness review, and the frozen design v20's + * decision of 2026-11-10, "anything an oracle can't possibly detect is an + * invalid mutant" (freeze.md, "Undetectable mutants are invalid"). The + * domain counts -0 apart from 0, NaN as a value, a string beside a number of + * the same digits, `null` beside a zero, a number beside a bigint, and an + * object whose `valueOf` has an effect. The dead-code and boundary evidence + * follow the coordinator's revision and the review of PR #8. * * Domain. Each program is one function `f(a: T, b: T, c: T)`, where T is one - * of three declared types: `number`, `bigint`, or `string | number`. The - * admitted domain of a type is the values it admits, in every combination of - * three parameters: number admits -1, 0, -0, 1, 2, and NaN; bigint admits -1n, - * 0n, 1n, and 2n (no signed zero); `string | number` admits those numbers and - * the strings '1' and '2'. A program may also read the free name `g`, a global - * whose getter counts its reads. Two functions are equal when they return - * `Object.is`-equal values on every admitted input and make the same reads. A - * site is distinguishable when some admitted input separates the mutant from - * the original. The grammar is enumerated at every type: each shape is one - * history per type. + * of three declared types: `number`, `bigint`, or `string | number`. Its + * declared inputs are the values T admits, in every combination of three + * parameters: number admits -1, 0, -0, 1, 2, and NaN; bigint admits -1n, 0n, + * 1n, and 2n; `string | number` admits those numbers and the strings '1' and + * '2'. What JavaScript allows adds foreign inputs (`FOREIGN`): two values + * that tie at different identities in two of the parameters, the third 0 or + * 1. A program may read the free name `g`, a global whose getter counts its + * reads, and the coerced object counts its `valueOf` calls. Two functions + * are equal when they return `Object.is`-equal values on every input and + * make the same reads. * * Grammar. A program is one of: a comparison (of two operands, a variable or * the literal 0 or 1, in either written order, with each of `>`, `>=`, `<`, @@ -40,59 +51,62 @@ * variable twice, or on the global `g` twice; a clamp, a ternary whose false * branch is another ternary; or a ternary or a guard (`if (C) return T; return * F;`, or the same with an `else`). A test is a comparison, a negated - * comparison, or a logical operator with a comparison and a variable. A - * ternary's or guard's branches are the two compared operands in either order, - * the variable that is not an operand with one of them, the same variable - * twice, or an empty return. The grammar is enumerated: every program is one - * history of the oracle. + * comparison, a logical operator with a comparison and a variable, or an + * ordering comparison beside the impure `g > c`. A ternary's or guard's + * branches are the two compared operands in either order, the variable that + * is not an operand with one of them, the same variable twice, or an empty + * return. Every program is one history of the oracle. * * Model. The model interprets the grammar on each input, with JavaScript's - * short-circuit order and reads of `g`. A mutant is a change to the program: a - * comparison's operator swapped to its partner (`>` with `>=`, `<` with `<=`, - * `===` with `!==`), a logical operator swapped to its partner (`&&` with `||`, - * `??` with `&&`), a negation dropped, or an `if`'s condition forced to true or - * to false. The model reads the grammar's own rendered text and its sites, - * never the instrumenter's text. + * short-circuit order, coercions, and reads of `g`. A mutant is a change to + * the program: a comparison's operator swapped to its partner (`>` with + * `>=`, `<` with `<=`, `===` with `!==`), a logical operator swapped to its + * partner (`&&` with `||`, `??` with `&&`), a negation dropped, an `if`'s + * condition forced to true or to false, or a returned value made + * `undefined` after it is evaluated. The model reads the grammar's own + * rendered text and its sites, never the instrumenter's text. * * Production path. The CLI's `instrument` (schemata.mjs) instruments the * rendered text. The driver runs the instrumented function with no mutant * active, which must equal the original, and then with each emitted mutant * active, recording whether its outputs and reads differ from the original's, - * and the evidence the instrumenter tags on it. + * and the evidence and label the instrumenter tags on it. * * Observations. `instrumented` reports the clean run's result and, for each - * emitted mutant, its operator, position, whether its run differs, and its - * evidence. + * emitted mutant, its operator, position, whether its run differs, its + * evidence, and its label. A hostile program reports its mutants, and those + * of its text with the types erased. + * + * Comparison, for each site the model names: it is emitted (law 2); if it is + * not dead code it is distinguishable (law 1); a dead-code mark sits exactly + * on the undistinguishable sites; a label sits on every site the label law + * names; its run agrees with the model; a boundary hint sits on a site that + * a history of 0 and -0 distinguishes; and each emitted mutant is a site of + * the grammar. * - * Comparison, for each site the model names. An emitted site that is not dead - * code must be distinguishable in the model (law 1). A site marked dead code - * must be indistinguishable in the model (dead-code law). A site that is not - * emitted must equal the original on every input (law 2), and an - * indistinguishable one must be marked dead code, not skipped. Every emitted - * mutant's run must agree with the model (refinement). A boundary hint must sit - * on a site that a history of 0 and -0 distinguishes (boundary law). Each - * emitted mutant must be a site of the grammar. + * Every returned expression is a `return-undefined` site (F17), returned + * calls and `import()` included, except `return undefined` where nothing + * binds the name and the file has no direct `eval` and no `with`, and + * `return void x`. Its mutant evaluates the returned expression and returns `undefined` + * in its place, so the two left out are exact equivalents, and the returned + * values family judges them. The optional chains family judges + * `optional-chain`. * - * Limits. The domain is finite: the admitted values of each type, three - * parameters, and one free name. A clamp at type bigint is left out: its outer swap is undetectable over bigint, because at a tie the inner ternary returns the same value (witness: `function f(a: bigint, b: bigint, c: bigint) { return a > c ? c : a === b ? b : a; }`, the swap at offset 57). No syntactic rule decides that, so it is an open family for the maintainer. The grammar has no property reads, calls, - * getters beyond `g`, or equal branches with a non-operand expression (`a + - * 0`). Dead code is judged for grammar programs only, and the production rule - * requires a pure condition (local names, literals, comparisons, logical - * operators, `!`), so a site whose condition calls a function is left untagged. - * Logical operators on literals (`1 && 1`) are not tagged. The boundary hint - * is one-directional: it claims that a signed zero distinguishes the mutant. It - * is given only to a number-typed ordering swap at a top-level test whose tie - * can be zero (both operands variables, or the literal 0). A string-admitting or - * untyped operand gets no hint: the mutant is valid, and strings distinguish it. - * An ordering swap with a nonzero literal tie over `number` operands, or over - * `bigint` operands, is skipped: no admitted input distinguishes it. The - * parameter type is read from its annotation in schemata.mjs, because - * `instrument` takes the text alone; the checker's program belongs to the - * caller. A skipped site is judged by the model, not by a run of the emitter. - * The switch below restates the operator contract in schemata.mjs. No shared - * helper exists for it: gap-report-static-oracle.property.test.mjs keeps its - * own copy for derived classes, so this copy stays here, and both restate the - * same contract. + * Limits. The domain is finite: each type's values, a fixed set of foreign + * ties, and one free name. A clamp at type bigint is left out. The grammar + * has no property reads, calls, getters beyond `g`, or equal branches with + * a non-operand expression (`a + 0`). Dead code is tagged only under a pure + * condition (local names, literals, comparisons, logical operators, `!`) + * where no ordering comparison runs or not because of the site. Logical + * operators on literals (`1 && 1`) are not tagged. The boundary hint is + * one-directional. The label law covers the first family and an annotated + * optional-chain receiver only: the narrowed family's narrowings are not + * labelled, and their survivors take their other classes. The type analysis + * reads annotations by syntax and makes no claim beyond the label; its known + * mislabels (overload merging, module augmentation, a project's other + * tsconfig options) only name a survivor's class. The switch below restates + * the operator contract in schemata.mjs; the static oracle keeps its own + * copy for derived classes. */ import { createRequire } from 'node:module' import { dirname, join } from 'node:path' @@ -107,8 +121,10 @@ const ts = createRequire(join(here, `../package.json`))( ) const LAW_1 = `every emitted mutant that is not dead code differs from the original on some input` -const LAW_2 = `every skipped site equals the original on every input` +const LAW_2 = `no site is skipped on the basis of types, and a skipped site equals the original on every input` const LAW_DEAD = `a site is marked dead code exactly when no input distinguishes it` +const LAW_LABEL = `a mutant carries the outside-annotation label exactly when an annotation excludes every input that distinguishes it` +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_KNOWN = `every emitted mutant is a site of the grammar` @@ -137,6 +153,51 @@ const VALUES = { const cube = (values) => values.flatMap((a) => values.flatMap((b) => values.map((c) => [a, b, c]))) const DOMAIN = Object.fromEntries(TYPES.map((t) => [t, cube(VALUES[t])])) +// What JavaScript allows (maintainer decision, 2026-11-11: types never skip +// a mutant or mark it dead). Beside each type's own inputs, a fixed set of +// foreign inputs puts two values that tie at different identities in two +// parameters: a string beside the number of its digits, `null` beside a +// zero, a number beside a bigint, -0 beside 0, and an object whose `valueOf` +// has an effect beside the number it yields. The third parameter is 0 or 1. +// An object's `valueOf` counts in `effects`, which a run reads as it reads +// `g`, so a mutant that changes how often an operand is coerced shows. +const effects = { count: 0 } +const VALUED = { + valueOf() { + effects.count += 1 + return 1 + }, +} +const TIES = [ + [`1`, 1], + [null, 0], + [null, 0n], + [1n, 1], + [0, -0], + [VALUED, 1], + [VALUED, VALUED], + [undefined, 0], +].flatMap(([x, y]) => [ + [x, y], + [y, x], +]) +const FOREIGN = [ + [0, 1], + [0, 2], + [1, 2], +].flatMap(([i, j]) => + TIES.flatMap(([x, y]) => + [0, 1].map((third) => { + const input = [third, third, third] + input[i] = x + input[j] = y + return input + }), + ), +) +const JS_DOMAIN = Object.fromEntries( + TYPES.map((t) => [t, [...DOMAIN[t], ...FOREIGN]]), +) // The inputs that hold only zeros, with their signs; a bigint has none. const ZERO_DOMAIN = Object.fromEntries( TYPES.map((t) => [t, t === `bigint` ? [] : cube([0, -0])]), @@ -166,159 +227,2646 @@ const BOUNDARY = { '===': `!==`, '!==': `===`, } -const LOGIC_PARTNER = { '&&': `||`, '||': `&&`, '??': `&&` } -const ref = (name) => ({ kind: `ref`, name }) -const ternary = (test, then, otherwise) => ({ - kind: `cond`, - test, - then, - else: otherwise, -}) -const VOID = { kind: `void` } -const negation = (operand) => ({ kind: `not`, operand }) -const logic = (op, left, right) => ({ kind: `logic`, op, left, right }) +const LOGIC_PARTNER = { '&&': `||`, '||': `&&`, '??': `&&` } +const ref = (name) => ({ kind: `ref`, name }) +const ternary = (test, then, otherwise) => ({ + kind: `cond`, + test, + then, + else: otherwise, +}) +const VOID = { kind: `void` } +const negation = (operand) => ({ kind: `not`, operand }) +const logic = (op, left, right) => ({ kind: `logic`, op, left, right }) + +// The twelve comparisons of two operands, each op in both written orders. +function comparisonsOf(left, right) { + return OPS.flatMap((op) => [ + { kind: `cmp`, left: ref(left), op, right: ref(right) }, + { kind: `cmp`, left: ref(right), op: REVERSE[op], right: ref(left) }, + ]) +} + +// Every pair of variables, and each variable with the literals 0 and 1. A pair +// is generated once, in both orders through `comparisonsOf`. +const pairs = [] +for (const [i, left] of VARIABLES.entries()) + for (const right of [...VARIABLES.slice(i + 1), `1`, `0`]) + pairs.push([left, right]) +const tests = pairs.flatMap(([left, right]) => comparisonsOf(left, right)) + +// The comparison a test is built on. +const comparisonOf = (test) => + test.kind === `cmp` + ? test + : test.kind === `not` + ? test.operand + : test.left.kind === `cmp` + ? test.left + : test.right + +// A test built on a comparison: its negation, and the comparison with a +// variable that is not one of its operands, in either order. +const compoundsOf = (cmp) => { + const other = VARIABLES.find( + (v) => v !== cmp.left.name && v !== cmp.right.name, + ) + return [ + negation(cmp), + logic(`&&`, cmp, ref(other)), + logic(`||`, ref(other), cmp), + ] +} +const compounds = tests.flatMap(compoundsOf) + +// The branches of a ternary or guard on a comparison: the two operands in +// either order, the variable that is not an operand with one of them in either +// position, and the same variable twice. +function branchesOf(test) { + const [l, r] = [test.left, test.right] + const others = VARIABLES.filter((v) => v !== l.name && v !== r.name).map(ref) + return [ + [l, r], + [r, l], + ...others.flatMap((x) => [ + [x, l], + [x, r], + [l, x], + [r, x], + ]), + ...VARIABLES.map((v) => [ref(v), ref(v)]), + ] +} +// The branches of a guard that returns nothing on one side or both. +const EMPTY_BRANCHES = [ + ...VARIABLES.flatMap((v) => [ + [VOID, ref(v)], + [ref(v), VOID], + ]), + [VOID, VOID], +] +// The branches of a compound test: its comparison's operands in either order, +// and one variable twice. +const compoundBranches = (test) => { + const cmp = comparisonOf(test) + return [ + [cmp.left, cmp.right], + [cmp.right, cmp.left], + ...VARIABLES.map((v) => [ref(v), ref(v)]), + ] +} + +const shapes = [] +// A ternary, a guard, and a guard with `else` for each branch pair. A ternary +// cannot return nothing, so only guards take an empty branch. +const branched = (test, branches) => { + for (const [then, otherwise] of branches) { + if (then.kind !== `void` && otherwise.kind !== `void`) + shapes.push({ kind: `return`, expr: ternary(test, then, otherwise) }) + shapes.push({ + kind: `guard`, + form: `return`, + test, + then, + rest: otherwise, + }) + shapes.push({ kind: `guard`, form: `else`, test, then, rest: otherwise }) + } +} +for (const test of tests) { + shapes.push({ kind: `return`, expr: test }) + branched(test, [...branchesOf(test), ...EMPTY_BRANCHES]) +} +for (const test of compounds) branched(test, compoundBranches(test)) +// A comparison beside an impure operand, `g > c`, which reads the global +// `g` (its value is the read count, 1 here). Under `&&` and `||` the +// comparison decides whether `g` is read. At a tie an ordering swap changes +// that read, which the reads show, so a skip there is unsound (the third +// review, 2026-11-11). The comparison orders its operands, as the swap's +// skip needs, and does not read `c`, so `g > c` can take either value. Its +// branches are its operands. +const READ = { kind: `cmp`, left: ref(FREE), op: `>`, right: ref(`c`) } +const impure = tests + .filter( + (cmp) => + ORDERING.includes(cmp.op) && + cmp.left.name !== `c` && + cmp.right.name !== `c`, + ) + .flatMap((cmp) => [logic(`&&`, cmp, READ), logic(`||`, cmp, READ)]) +for (const test of impure) + branched(test, [ + [test.left.left, test.left.right], + [test.left.right, test.left.left], + ]) +// Same-variable comparisons, returned as values. +for (const v of VARIABLES) + for (const op of OPS) + shapes.push({ + kind: `return`, + expr: { kind: `cmp`, left: ref(v), op, right: ref(v) }, + }) +// Logical operators: two variables, one variable twice, and the free global. +for (const left of VARIABLES) + for (const right of VARIABLES) + for (const op of LOGICAL) + shapes.push({ kind: `return`, expr: logic(op, ref(left), ref(right)) }) +for (const op of LOGICAL) + shapes.push({ kind: `return`, expr: logic(op, ref(FREE), ref(FREE)) }) +// A clamp: `a > c ? c : a < b ? b : a`, with each test in either order. +for (const outer of comparisonsOf(`a`, `c`)) + for (const inner of comparisonsOf(`a`, `b`)) + shapes.push({ + kind: `return`, + expr: ternary(outer, ref(`c`), ternary(inner, ref(`b`), ref(`a`))), + }) + +// Every shape at each parameter type: the history of the oracle. +// A clamp at type bigint is left out, and is an open family (see the limits): +// at a tie of the outer test, the inner ternary's value is the same as the +// operand's, so the outer swap is undetectable over bigint without a semantic +// argument that no syntactic rule here makes. +const isClamp = (shape) => + shape.kind === `return` && + shape.expr.kind === `cond` && + shape.expr.else.kind === `cond` +const typedPrograms = shapes.flatMap((shape) => + TYPES.filter((type) => !(type === `bigint` && isClamp(shape))).map( + (type) => ({ + ...shape, + type, + }), + ), +) + +const programs = typedPrograms + +// ## Narrowed unions +// +// A second family, for the types TypeScript narrows. Its programs are +// `function f(t: T, a: number)`, where the subject `t` is a union of two +// members, `'x' | 'y'` (plain, read as `t`) or `{ type: 'x' } | { type: 'y' }` +// (tagged, read as `t.type`), or its wide counterpart, `string` or +// `{ type: string }`. The typed inputs of a union are its two members; of a +// wide type, those and a third, `'z'`; `a` takes the number values. Each +// program tests the subject against `'x'` and then `'y'`. At a union the +// second test meets the last member: the subject is narrowed to `'y'` there, +// the test is constant, and the code after both members is narrowed to +// `never`. The same text at the wide type narrows nothing. No narrowing is +// trusted, so the model judges every program over the cast domain. +// +// Each program is checked under strict null checks and, as a package with +// them off is, without them (loose). Without them a declared type admits +// `undefined` too: a plain subject may be `undefined`, and a tagged one's +// `type` may be, so narrowing proves nothing and no site is skipped. +// +// A program's form says how it tests and what follows: +// +// - `chain`, `reversed`, `negated`: `if (D === 'x') return a > 0;`, then the +// second test (`D === 'y'`, `'y' === D`, or `!(D !== 'y')`) returning +// `a > 1`, then `return a > 2;`; +// - `guard`: the second test as `D !== 'y'` (constant false) returning +// `a > 1`, then `return a > 2;`; +// - `else`: the second test with `else return a > 2;`; +// - `loop`: in `for (const u of [t])`, `if (U === 'x') continue;`, then +// `if (U === 'y') return a > 1;` and `return a > 0;`, and `return a > 2;` +// after the loop, which the first member reaches; +// - `loopWork`: the same loop, whose first branch does work before it +// continues (`{ a = a + 1; continue; }`), as path-to-regexp's token loop +// does, and `return a < 2;` after the loop: on the path to the second test +// only conditions run, so its narrowing holds; +// - `switch`: `switch (D)` with the cases `'x'` and `'y'` and a `default` +// returning `a > 2`; +// - `satisfies`: the same switch with no `default`, then `t satisfies never;` +// and `return a > 2;`; +// - `labelled`: a plain `'x' | number` subject (wide: `string | number`, with +// the numbers 0 and 1 its members too), switched on `case 'x'` and on +// `case other`, a `let other = 0`, with a `default`: a label with no single +// value, and a member with none, leave the switch open; +// - `hoisted`: the second test returns `check(a)`, a function declared after +// it, which the call reaches though it follows the narrowed test; +// - `assigned`: a flag that a callback sets: the checker narrows `done` to +// `false` at its test, though the callback has set it when the subject is +// `'y'`; +// - `retag`: after the first test, a callback that, when `a > 1`, changes the +// subject (a destructuring `[t] = ['x']`, or the tagged object's `type`), +// then the second test returning `a > 1` and `return a < 2;`: a narrowing +// that a call, or an assignment the checker does not follow, has undone; +// - `retagLoop`: the same with a plain subject assigned by `for (t of …)`. +// +// The sites are the if-operators, the comparison swaps, the dropped +// negation, a boolean literal's flip, a skipped call statement, the `+`'s +// swap, and a call's value; a switch, a case label, a loop over an array, +// and `satisfies` have none. +const NARROWED_TYPE = { + plain: { union: `'x' | 'y'`, wide: `string` }, + tagged: { union: `{ type: 'x' } | { type: 'y' }`, wide: `{ type: string }` }, +} +const WIDTHS = [`union`, `wide`] +const MEMBERS = { union: [`x`, `y`], wide: [`x`, `y`, `z`] } +const STRICTNESS = [`strict`, `loose`] +// The `labelled` form's own subject types and members. +const LABELLED = { + type: { union: `'x' | number`, wide: `string | number` }, + members: { union: [`x`, 0, 1], wide: [`x`, `z`, 0, 1] }, +} +// A narrowed program is judged over the cast domain, which JavaScript +// allows (maintainer decision, 2026-11-11: types never skip a mutant or mark +// it dead): the members, the wide type's third string, which a cast passes, +// and without strict null checks `undefined`. No narrowing is trusted, so +// the code a narrowing excludes stays emitted. The switch forms below each +// reach the default, or the code after the switch, in their own way. +function narrowedDomain(program) { + const flow = + program.form === `labelled` + ? LABELLED.members[program.width] + : MEMBERS[program.width] + const members = flow.includes(`z`) ? flow : [...flow, `z`] + const loose = program.strictness === `loose` ? [undefined] : [] + return [...members, ...loose].flatMap((name) => + VALUES.number.map((a) => [ + program.subject === `tagged` ? { type: name } : name, + a, + ]), + ) +} +const subjectRef = (name) => ({ kind: `subject`, name }) +const member = (value) => ({ kind: `member`, value }) +const number = (value) => ({ kind: `number`, value }) +const compared = (op, k) => ({ + kind: `return`, + expr: { kind: `cmp`, left: ref(`a`), op, right: number(k) }, +}) +const above = (k) => compared(`>`, k) +const when = (test, then, otherwise) => ({ + kind: `if`, + test, + then, + else: otherwise, +}) +const is = (left, op, right) => ({ kind: `cmp`, left, op, right }) +const bool = (value) => ({ kind: `bool`, value }) +const SUBJECT = subjectRef(`t`) +const firstMember = () => when(is(SUBJECT, `===`, member(`x`)), above(0)) +const secondMember = (then) => when(is(SUBJECT, `===`, member(`y`)), then) +const twoCases = (fallback) => ({ + kind: `switch`, + cases: [ + [member(`x`), above(0)], + [member(`y`), above(1)], + ], + fallback, +}) +const loopOf = (first, after) => [ + { + kind: `loop`, + body: [ + when(is(subjectRef(`u`), `===`, member(`x`)), first), + when(is(subjectRef(`u`), `===`, member(`y`)), above(1)), + above(0), + ], + }, + after, +] +// A callback that changes the subject when `a > 1`, then the second test. +const retagged = (change) => [ + firstMember(), + { + kind: `each`, + body: when(is(ref(`a`), `>`, number(1)), change), + }, + secondMember(above(1)), + compared(`<`, 2), +] +// Each form's statements, and the subjects it takes. +const NARROWED_FORMS = { + chain: () => [firstMember(), secondMember(above(1)), above(2)], + reversed: () => [ + firstMember(), + when(is(member(`y`), `===`, SUBJECT), above(1)), + above(2), + ], + negated: () => [ + firstMember(), + when(negation(is(SUBJECT, `!==`, member(`y`))), above(1)), + above(2), + ], + guard: () => [ + firstMember(), + when(is(SUBJECT, `!==`, member(`y`)), above(1)), + above(2), + ], + else: () => [ + firstMember(), + when(is(SUBJECT, `===`, member(`y`)), above(1), above(2)), + ], + loop: () => loopOf({ kind: `continue` }, above(2)), + // After the loop, `a < 2`, so that the `+ 1` and the work's skip both show. + loopWork: () => + loopOf( + { kind: `block`, body: [{ kind: `bump` }, { kind: `continue` }] }, + compared(`<`, 2), + ), + switch: () => [twoCases(above(2))], + satisfies: () => [twoCases(undefined), { kind: `satisfies` }, above(2)], + // Switch forms that reach the default, or the code after the switch, with + // a member: each broke one condition of an earlier exhaustive-switch skip. + // `fallthrough`: the case `'y'` has no statement and falls into the + // default. + fallthrough: () => [ + { + kind: `switch`, + cases: [ + [member(`x`), above(0)], + [member(`y`), undefined], + ], + fallback: above(2), + }, + ], + // `broken`: the case `'x'` breaks, so the code after the switch runs. + broken: () => [ + { + kind: `switch`, + cases: [ + [member(`x`), { kind: `break` }], + [member(`y`), above(1)], + ], + }, + above(2), + ], + // `recast`: when `a > 1` the program assigns the subject a value that a + // cast passes, so the default runs. + recast: () => [ + when(is(ref(`a`), `>`, number(1)), { kind: `recast` }), + twoCases(above(2)), + ], + // `hoistedSwitch`: the case `'x'` calls a function declared after the + // switch, which is hoisted: its body runs, and the return after the + // switch does not. + hoistedSwitch: () => [ + { + kind: `switch`, + cases: [ + [ + member(`x`), + { + kind: `return`, + expr: { kind: `call`, name: `check`, argument: ref(`a`) }, + }, + ], + [member(`y`), above(1)], + ], + }, + { kind: `satisfies` }, + above(2), + { kind: `function`, name: `check`, body: compared(`>`, 1) }, + ], + labelled: () => [ + { kind: `let`, name: `other`, init: number(0) }, + { + kind: `switch`, + cases: [ + [member(`x`), above(0)], + [{ kind: `name`, name: `other` }, above(1)], + ], + fallback: above(2), + }, + ], + hoisted: () => [ + firstMember(), + secondMember({ + kind: `return`, + expr: { kind: `call`, name: `check`, argument: ref(`a`) }, + }), + { kind: `function`, name: `check`, body: compared(`>`, 1) }, + ], + assigned: () => [ + { kind: `let`, name: `done`, init: bool(false) }, + { + kind: `each`, + body: when(is(subjectRef(`u`), `===`, member(`y`)), { + kind: `set`, + name: `done`, + value: bool(true), + }), + }, + when({ kind: `name`, name: `done` }, above(1)), + above(2), + ], + // A returned `void a`, whose value is `undefined` whatever `a` is. + voided: () => [ + firstMember(), + { kind: `return`, expr: { kind: `voidOf`, name: `a` } }, + ], + retag: () => retagged({ kind: `retag` }), + retagLoop: () => retagged({ kind: `retagLoop` }), +} +const PLAIN_ONLY = new Set([`labelled`, `retagLoop`]) +const narrowedPrograms = Object.keys(NARROWED_FORMS).flatMap((form) => + [`plain`, `tagged`] + .filter((subject) => subject === `plain` || !PLAIN_ONLY.has(form)) + .flatMap((subject) => + WIDTHS.flatMap((width) => + STRICTNESS.map((strictness) => ({ + kind: `narrowed`, + form, + subject, + width, + strictness, + body: NARROWED_FORMS[form](), + })), + ), + ), +) + +// The source of a narrowed program and its sites, as `render` gives them for +// the first family. +function renderNarrowed(program) { + const type = + program.form === `labelled` + ? LABELLED.type[program.width] + : NARROWED_TYPE[program.subject][program.width] + let text = `function f(t: ${type}, a: number) {\n` + const sites = [] + const put = (piece) => { + text += piece + } + const site = (operator, change) => + sites.push({ operator, position: text.length, change }) + const read = (name) => (program.subject === `tagged` ? `${name}.type` : name) + const expression = (expr) => { + if (expr.kind === `subject`) return put(read(expr.name)) + if (expr.kind === `voidOf`) return put(`void ${expr.name}`) + if (expr.kind === `member`) + return put( + typeof expr.value === `string` ? `'${expr.value}'` : `${expr.value}`, + ) + if (expr.kind === `number`) return put(String(expr.value)) + if (expr.kind === `ref` || expr.kind === `name`) return put(expr.name) + if (expr.kind === `call`) { + site(`value-undefined`, { kind: `undefine`, node: expr }) + put(`${expr.name}(`) + expression(expr.argument) + return put(`)`) + } + if (expr.kind === `bool`) { + site(`bool-flip`, { kind: `flip`, node: expr }) + return put(String(expr.value)) + } + if (expr.kind === `not`) { + site(`drop-not`, { kind: `drop`, node: expr }) + put(`!(`) + expression(expr.operand) + return put(`)`) + } + expression(expr.left) + put(` `) + site(`binary-swap`, { kind: `flip`, node: expr }) + put(`${expr.op} `) + return expression(expr.right) + } + const statement = (s, indent) => { + if (s.kind === `return`) { + put(`${indent}return `) + // `void a` is `undefined` already, so it is no site. A returned call + // is a site (the sixth review: a test that checks only the call's + // effect kills `value-undefined`, never this). + if (s.expr.kind !== `voidOf`) + sites.push({ + operator: `return-undefined`, + position: text.length, + change: { kind: `undefine`, node: s }, + }) + expression(s.expr) + return put(`;\n`) + } + if (s.kind === `continue`) return put(`continue;`) + if (s.kind === `bump`) { + put(`a = a `) + site(`arithmetic-swap`, { kind: `swap`, node: s }) + return put(`+ 1;`) + } + if (s.kind === `block`) { + put(`{ `) + for (const [i, inner] of s.body.entries()) { + if (i > 0) put(` `) + statement(inner, ``) + } + return put(` }\n`) + } + if (s.kind === `retag`) + return put( + program.subject === `tagged` + ? `(u as { type: string }).type = 'x';` + : `[t] = ['x'];`, + ) + if (s.kind === `retagLoop`) return put(`for (t of ['x' as const]);`) + if (s.kind === `recast`) + return put( + program.subject === `tagged` + ? `(t as { type: string }).type = 'z';` + : `t = 'z' as 'x';`, + ) + if (s.kind === `break`) return put(`break;\n`) + if (s.kind === `set`) { + put(`${s.name} = `) + expression(s.value) + return put(`;`) + } + if (s.kind === `let`) { + put(`${indent}let ${s.name} = `) + expression(s.init) + return put(`;\n`) + } + if (s.kind === `each`) { + put(indent) + site(`delete-call`, { kind: `delete`, node: s }) + put(`[t].forEach((u) => { `) + statement(s.body, ``) + return put(` });\n`) + } + if (s.kind === `function`) { + put(`${indent}function ${s.name}(a: number) {\n`) + statement(s.body, `${indent} `) + return put(`${indent}}\n`) + } + if (s.kind === `satisfies`) return put(`${indent}t satisfies never;\n`) + if (s.kind === `if`) { + put(`${indent}if (`) + for (const taken of [true, false]) + site(taken ? `if-true` : `if-false`, { kind: `force`, node: s, taken }) + expression(s.test) + put(`) `) + statement(s.then, ``) + if (s.then.kind !== `return` && s.then.kind !== `block`) put(`\n`) + if (s.else) { + put(`${indent}else `) + statement(s.else, ``) + } + return undefined + } + if (s.kind === `loop`) { + put(`${indent}for (const u of [t]) {\n`) + for (const inner of s.body) statement(inner, `${indent} `) + return put(`${indent}}\n`) + } + put(`${indent}switch (${read(`t`)}) {\n`) + for (const [label, body] of s.cases) { + put(`${indent} case `) + expression(label) + put(`: `) + // A case with no statement falls through into the next. + if (body) statement(body, ``) + else put(`\n`) + } + if (s.fallback) { + put(`${indent} default: `) + statement(s.fallback, ``) + } + return put(`${indent}}\n`) + } + for (const s of program.body) statement(s, ` `) + put(`}`) + return { text, sites } +} + +// A narrowed program on one input, under at most one change. A statement ends +// the function with its value, or continues the loop. The subject is copied, +// so a run that changes it leaves the input as it was. +function evaluateNarrowed(program, input, change) { + const [t, a] = input + const env = { t: typeof t === `object` ? { ...t } : t, a } + const functions = new Map( + program.body.filter((s) => s.kind === `function`).map((s) => [s.name, s]), + ) + const subject = (name) => + program.subject === `tagged` ? env[name].type : env[name] + const value = (expr) => { + if (expr.kind === `subject`) return subject(expr.name) + if (expr.kind === `voidOf`) return undefined + if (expr.kind === `member` || expr.kind === `number`) return expr.value + if (expr.kind === `ref` || expr.kind === `name`) return env[expr.name] + if (expr.kind === `call`) { + if (change?.kind === `undefine` && change.node === expr) return undefined + // A function's parameter shadows `a` while it runs. + const outer = env.a + env.a = value(expr.argument) + const result = execute(functions.get(expr.name).body)?.value + env.a = outer + return result + } + if (expr.kind === `bool`) + return change?.kind === `flip` && change.node === expr + ? !expr.value + : expr.value + if (expr.kind === `not`) + return change?.kind === `drop` && change.node === expr + ? value(expr.operand) + : !value(expr.operand) + const op = + change?.kind === `flip` && change.node === expr + ? BOUNDARY[expr.op] + : expr.op + return COMPARE[op](value(expr.left), value(expr.right)) + } + const run = (statements) => { + for (const s of statements) { + const ended = execute(s) + if (ended) return ended + } + return undefined + } + const execute = (s) => { + if (s === undefined || s.kind === `satisfies` || s.kind === `function`) + return undefined + if (s.kind === `return`) { + // The returned expression runs even when its value is dropped. + const result = value(s.expr) + return { + value: + change?.kind === `undefine` && change.node === s ? undefined : result, + } + } + if (s.kind === `continue`) return { next: true } + if (s.kind === `block`) return run(s.body) + if (s.kind === `bump`) { + env.a = + change?.kind === `swap` && change.node === s ? env.a - 1 : env.a + 1 + return undefined + } + if (s.kind === `break`) return { broke: true } + if (s.kind === `recast`) { + if (program.subject === `tagged`) env.t.type = `z` + else env.t = `z` + return undefined + } + if (s.kind === `retag` || s.kind === `retagLoop`) { + if (program.subject === `tagged`) env.t.type = `x` + else env.t = `x` + return undefined + } + if (s.kind === `let` || s.kind === `set`) { + env[s.name] = value(s.kind === `let` ? s.init : s.value) + return undefined + } + if (s.kind === `each`) { + if (change?.kind !== `delete` || change.node !== s) + for (const u of [env.t]) { + env.u = u + execute(s.body) + } + return undefined + } + if (s.kind === `if`) { + // The condition is evaluated even when a change forces the branch. + const truth = Boolean(value(s.test)) + const forced = change?.kind === `force` && change.node === s + return execute((forced ? change.taken : truth) ? s.then : s.else) + } + if (s.kind === `loop`) { + for (const u of [env.t]) { + env.u = u + const ended = run(s.body) + if (ended && !ended.next) return ended + } + return undefined + } + // A switch runs from the matching case, or else from the default, through + // the clauses after it until one ends the function or breaks. + const bodies = [...s.cases.map(([, body]) => body), s.fallback] + const found = s.cases.findIndex(([label]) => value(label) === subject(`t`)) + for (const body of bodies.slice(found < 0 ? s.cases.length : found)) { + const ended = execute(body) + if (ended?.broke) return undefined + if (ended) return ended + } + return undefined + } + return run(program.body)?.value +} + +// ## Optional chains +// +// A fourth family: `function f(o: T, a: number) { return R === a; }`, where +// R reads `v` of a receiver through an optional chain, as `?.v` (property) +// or `?.['v']` (element), and T is `{ v: number } | undefined` (nullable) +// or `{ v: number }` (present). The `optional-chain` mutant reads through a +// plain access, which throws on a nullish receiver. +// +// Every receiver is judged over the inputs JavaScript allows: the typed +// objects, `undefined`, and `null`. Where the receiver is the parameter its +// own annotation declares (`param`), no declared input makes the present +// type's receiver nullish under strict null checks, but without them the +// annotation admits `null`. The CLI does not read the package's strictness, +// so the plain read carries no label (the sixth review: a label only where +// an annotation excludes the input), and its survivor's directive already +// names `undefined`. The other receivers: a local that holds the parameter +// (`local`, `const p = o`), a property of a wrapper (`property`, `w.o`), the +// same text in JavaScript with a JSDoc type, and a literal constant +// (`constant`, `const K = 'v'`, read as `K?.length`), which is never +// nullish, so its plain read is dead code: emitted, and marked. +const CHAIN_TYPE = { + nullable: `{ v: number } | undefined`, + present: `{ v: number }`, +} +const CHAIN_WIDTHS = [`nullable`, `present`] +const CHAIN_OBJECTS = { + nullable: [{ v: 1 }, undefined], + present: [{ v: 1 }, { v: 2 }], +} +const RECEIVERS = [`param`, `local`, `property`, `constant`] +const LANGS = [`ts`, `js`] +// A run that throws, in the model and the driver alike. +const THREW = `threw TypeError` +const chainPrograms = [`property`, `element`].flatMap((access) => + RECEIVERS.flatMap((receiver) => + LANGS.flatMap((lang) => + CHAIN_WIDTHS.flatMap((width) => + STRICTNESS.map((strictness) => ({ + kind: `optional`, + access, + receiver, + lang, + width, + strictness, + })), + ), + ), + ), +) +// Whether the receiver is the parameter whose own annotation declares it, in +// TypeScript under strict null checks: then its declared types are the +// typed objects, and the label law applies. +const annotatedChain = (program) => + program.receiver === `param` && + program.lang === `ts` && + program.strictness === `strict` +function renderChain(program) { + const type = + program.receiver === `property` + ? `{ o: ${CHAIN_TYPE[program.width]} }` + : CHAIN_TYPE[program.width] + const name = program.receiver === `property` ? `w` : `o` + const declared = + program.lang === `ts` + ? `function f(${name}: ${type}, a: number) {\n` + : `/** @param {${type}} ${name} @param {number} a */\nfunction f(${name}, a) {\n` + const constant = program.receiver === `constant` ? `const K = 'v';\n` : `` + const local = program.receiver === `local` ? ` const p = o;\n` : `` + const head = `${constant}${declared}${local} return ` + const at = head.length + const object = { param: `o`, local: `p`, property: `w.o`, constant: `K` }[ + program.receiver + ] + const key = program.receiver === `constant` ? `length` : `v` + const read = + program.access === `property` + ? `${object}?.${key}` + : `${object}?.['${key}']` + return { + text: `${head}${read} === a;\n}`, + sites: [ + { + operator: `return-undefined`, + position: at, + change: { kind: `undefine` }, + }, + { operator: `optional-chain`, position: at, change: { kind: `chain` } }, + { + operator: `binary-swap`, + position: at + read.length + 1, + change: { kind: `flip`, node: { kind: `cmp`, op: `===` } }, + }, + ], + } +} +function evaluateChain(program, [o, a], change) { + const object = + program.receiver === `constant` + ? `v` + : program.receiver === `property` + ? o.o + : o + if (change?.kind === `chain` && (object === undefined || object === null)) + return THREW + const read = program.receiver === `constant` ? object.length : object?.v + const value = COMPARE[change?.kind === `flip` ? `!==` : `===`](read, a) + return change?.kind === `undefine` ? undefined : value +} +// What JavaScript allows: the typed objects, `undefined`, and `null`. The +// declared types' inputs are the typed objects alone. +function chainDomain(program, typed = false) { + const objects = [ + ...new Set([ + ...CHAIN_OBJECTS[program.width], + ...(typed ? [] : [undefined, null]), + ]), + ] + return objects + .map((o) => (program.receiver === `property` ? { o } : o)) + .flatMap((o) => VALUES.number.map((a) => [o, a])) +} + +// ## Returned values +// +// A fifth family: `function f(a: number) { R }`, where R returns `a` +// (`plain`), `void a` (`voided`), `void g` (`voidRead`, which reads the +// global), or `undefined` (`undefinedName`), and the same `return undefined` +// where a parameter named `undefined` shadows the global (`shadowed`), or a +// `var` in a nested block does (`varShadowed`, function-scoped). The +// `return-undefined` mutant evaluates the returned expression and returns +// `undefined` in its place, so its effects stay (the reads of `g`). Its +// contract leaves out a returned call, which `value-undefined` covers, and a +// returned `undefined` or `void x`, which already return `undefined` after +// the same effects: those are no site. A shadowed `undefined` is a name +// like any other, and its return is a site. Each is judged in TypeScript and +// in JavaScript, under strict null checks and without them, over the number +// values of `a`, `undefined`, and `null`. +const RETURN_FORMS = [ + `plain`, + `voided`, + `voidRead`, + `undefinedName`, + `shadowed`, + `varShadowed`, +] +const returnPrograms = RETURN_FORMS.flatMap((form) => + LANGS.flatMap((lang) => + STRICTNESS.map((strictness) => ({ + kind: `returned`, + form, + lang, + strictness, + })), + ), +) +function renderReturned(program) { + const name = program.form === `shadowed` ? `undefined` : `a` + const block = + program.form === `varShadowed` ? ` { var undefined = a; }\n` : `` + const head = + program.lang === `ts` + ? `function f(${name}: number) {\n${block} return ` + : `function f(${name}) {\n${block} return ` + const value = { + plain: `a`, + voided: `void a`, + voidRead: `void g`, + undefinedName: `undefined`, + shadowed: `undefined`, + varShadowed: `undefined`, + }[program.form] + const site = [`plain`, `shadowed`, `varShadowed`].includes(program.form) + return { + text: `${head}${value};\n}`, + sites: site + ? [ + { + operator: `return-undefined`, + position: head.length, + change: { kind: `undefine` }, + }, + ] + : [], + } +} +// The program on one input, with the reads of `g` it made. +function evaluateReturned(program, [a], change, counter = { reads: 0 }) { + if (program.form === `voidRead`) counter.reads += 1 + const value = [`plain`, `shadowed`, `varShadowed`].includes(program.form) + ? a + : undefined + return change?.kind === `undefine` ? undefined : value +} +const returnDomain = () => [...VALUES.number, undefined, null].map((a) => [a]) + +// ## Scopes, bindings, and coercions (the sixth review) +// +// A sixth family, of short programs that the model judges by running their +// text (round 6 of the soundness review, 2026-11-11: fixed programs missed +// whole classes twice, so each cause is a generated dimension here). Each +// program's sites are named with the text that applies each one's mutant, +// and the model runs the program and each mutant on every input, counting +// the coerced object's `valueOf` calls and a getter's reads as effects. +// Results compare by kind and value, so a fresh function or object returned +// by two runs is the same result. +// +// - `tdz`: a logical operator's operand that may be in its temporal dead +// zone. The condition sits in a parameter's default (`b = (c && a ? 1 : +// 1)`), a `for (let …)` head, or a `for (const … of …)` head, and its +// operand names a binding declared later (still uninitialized there) or +// one declared earlier; a condition in the body is the control. Under +// identical branches the swap changes only whether the operand is read, +// and a read in the dead zone throws. +// - `binds`: each form that can bind the name `undefined` (a nested `var`, +// a block `let`, a parameter, a destructured parameter, a caught error, a +// function or class declaration, a function or class expression's own +// name), and forms that name it without binding it (a method name, a +// label, an object key) or not at all. A returned bound `undefined` is a +// site; a returned global one is not. +// - `scope`: a sloppy script with a direct `eval`, with a `with`, or with +// neither, around a returned `undefined`, an optional read of a constant, +// and `a && a`. `eval` can declare a `var` that shadows a name, and `with` +// reads names through an object, whose getter runs. +// - `called`: a returned call, which is a site like any returned value. +// - `coerced`: `if (c || p < q) return 1; return 1;` with `p` and `q` +// annotated `number`, `string | number`, `object`, or `unknown`, and in +// JavaScript. Only the comparison's coercion of an object distinguishes +// the `||` swap; the label belongs only where the annotation excludes the +// object (`number` and `string | number`). +const TEXT_LOGICAL = [`&&`, `||`, `??`] +const TEXT_PARTNER = { '&&': `||`, '||': `&&`, '??': `&&`, '<': `<=` } +const TDZ_CONTEXTS = [`body`, `default`, `forLet`, `forOf`] +const textPrograms = [] +// A site at the first occurrence of `marker` (after `from`), whose mutant +// replaces `length` characters there with `replacement`, or wraps a +// returned expression in `void (…)`, which evaluates it and returns +// `undefined`. +function textSite(text, operator, marker, from = 0) { + const position = text.indexOf(marker, from) + if (position < 0) throw new Error(`no ${marker} in ${text}`) + return { operator, position, marker } +} +function mutateSite(text, site) { + const { operator, position, marker } = site + const at = (replacement, length = marker.length) => + `${text.slice(0, position)}${replacement}${text.slice(position + length)}` + if (operator === `binary-swap`) return at(TEXT_PARTNER[marker] ?? marker) + if (operator === `return-undefined`) return at(`void (${marker})`) + if (operator === `optional-chain`) return at(marker.replace(`?.`, `.`)) + if (operator === `delete-call`) return at(`;`) + if (operator === `value-undefined`) return at(`void 0`) + if (operator === `field-drop`) return at(``) + if (operator === `if-true`) return at(`(${marker}) || true`) + if (operator === `if-false`) return at(`(${marker}) && false`) + throw new Error(`no text mutant for ${operator}`) +} +for (const context of TDZ_CONTEXTS) + for (const op of TEXT_LOGICAL) + for (const binding of context === `body` + ? [`earlier`] + : [`later`, `earlier`]) { + const condition = (operand) => `c ${op} ${operand} ? ` + let text + let returned + if (context === `body`) { + returned = `c ${op} a ? 1 : 1` + text = `function f(c, a) {\n return ${returned};\n}` + } else if (context === `default`) { + returned = `b` + text = + binding === `later` + ? `function f(c, b = (${condition(`a`)}1 : 1), a) {\n return b;\n}` + : `function f(c, a, b = (${condition(`a`)}1 : 1)) {\n return b;\n}` + } else if (context === `forLet`) { + returned = `i` + text = `function f(c, a) {\n for (let i = (${condition(binding === `later` ? `i` : `a`)}1 : 1); ; ) return i;\n}` + } else { + returned = `x` + text = `function f(c, a, xs = [1]) {\n for (const x of (${condition(binding === `later` ? `x` : `a`)}xs : xs)) return x;\n}` + } + const sites = [ + textSite(text, `binary-swap`, op, text.indexOf(`c ${op}`)), + textSite( + text, + `return-undefined`, + returned, + text.lastIndexOf(`return `), + ), + ] + textPrograms.push({ + kind: `text`, + lang: `js`, + family: `tdz`, + name: `${context} ${op} ${binding}`, + text, + sites, + inputs: context === `default` ? `defaultArgs` : `pairs`, + }) + } +const BINDS = { + none: [`function f(x) {\n return undefined;\n}`, false], + varNested: [ + `function f(x) {\n { var undefined = x; }\n return undefined;\n}`, + true, + ], + letBlock: [ + `function f(x) {\n {\n let undefined = x;\n return undefined;\n }\n}`, + true, + ], + param: [`function f(undefined) {\n return undefined;\n}`, true], + destructured: [`function f({ undefined }) {\n return undefined;\n}`, true], + caught: [ + `function f(x) {\n try { throw x; } catch (undefined) {\n return undefined;\n }\n}`, + true, + ], + functionDeclaration: [ + `function f(x) {\n function undefined() {}\n return undefined;\n}`, + true, + ], + classDeclaration: [ + `function f(x) {\n class undefined {}\n return undefined;\n}`, + true, + ], + functionExpression: [ + `const f = function undefined(x) {\n return undefined;\n};`, + true, + ], + classExpression: [ + `const C = class undefined {\n static m(x) {\n return undefined;\n }\n};\nconst f = C.m;`, + true, + ], + methodName: [ + `function f(x) {\n const o = { undefined() {} };\n return undefined;\n}`, + false, + ], + label: [ + `function f(x) {\n undefined: for (;;) break undefined;\n return undefined;\n}`, + false, + ], +} +for (const [form, [text, bound]] of Object.entries(BINDS)) { + const at = text.lastIndexOf(`return undefined`) + `return `.length + textPrograms.push({ + kind: `text`, + lang: `js`, + family: `binds`, + name: form, + text, + sites: bound + ? [{ operator: `return-undefined`, position: at, marker: `undefined` }] + : [], + inputs: form === `destructured` ? `objects` : `single`, + }) +} +const SCOPE_PREFIX = { + none: ``, + eval: ` eval(code);\n`, +} +const SCOPE_TARGETS = { + undefined: `undefined`, + constant: `s?.length`, + local: `a && a`, +} +for (const scope of [`none`, `eval`, `with`]) + for (const [target, expression] of Object.entries(SCOPE_TARGETS)) { + const head = target === `constant` ? `const s = 'x';\n` : `` + const body = + scope === `with` + ? ` with /* scope */ (o) {\n return ${expression};\n }\n` + : `${SCOPE_PREFIX[scope]} return ${expression};\n` + const text = `${head}function f(a, code, o) {\n${body}}` + const sites = [] + if (scope === `eval`) + sites.push(textSite(text, `delete-call`, `eval(code);`)) + const returned = text.indexOf(`return `) + `return `.length + const bindsBreak = scope !== `none` + if (target !== `undefined` || bindsBreak) + sites.push({ + operator: `return-undefined`, + position: returned, + marker: expression, + }) + if (target === `constant`) + sites.push({ + operator: `optional-chain`, + position: returned, + marker: `s?.length`, + }) + if (target === `local`) + sites.push(textSite(text, `binary-swap`, `&&`, returned)) + textPrograms.push({ + kind: `text`, + lang: `js`, + family: `scope`, + name: `${scope} ${target}`, + text, + sites, + inputs: `scoped`, + }) + } +{ + const text = `function f(a) {\n return h(a);\n}\nfunction h(x) {\n return x;\n}` + textPrograms.push({ + kind: `text`, + lang: `js`, + family: `called`, + name: `a returned call`, + text, + sites: [ + { + operator: `return-undefined`, + position: text.indexOf(`h(a)`), + marker: `h(a)`, + }, + { + operator: `value-undefined`, + position: text.indexOf(`h(a)`), + marker: `h(a)`, + }, + { + operator: `return-undefined`, + position: text.indexOf(`x;`), + marker: `x`, + }, + ], + inputs: `single`, + }) +} +const COERCED_TYPES = [ + `number`, + `string | number`, + `object`, + `unknown`, + undefined, +] +for (const type of COERCED_TYPES) { + const annotation = (t) => (type === undefined ? `` : `: ${t}`) + const text = `function f(c${annotation(`boolean`)}, p${annotation(type)}, q${annotation(type)}) {\n if (c || p < q) return 1;\n return 1;\n}` + const condition = text.indexOf(`c || p < q`) + textPrograms.push({ + kind: `text`, + family: `coerced`, + name: type ?? `JavaScript`, + lang: type === undefined ? `js` : `ts`, + annotation: type, + text, + sites: [ + { operator: `if-true`, position: condition, marker: `c || p < q` }, + { operator: `if-false`, position: condition, marker: `c || p < q` }, + textSite(text, `binary-swap`, `||`), + textSite(text, `binary-swap`, `<`), + textSite(text, `return-undefined`, `1`, text.indexOf(`return 1`)), + textSite(text, `return-undefined`, `1`, text.lastIndexOf(`return 1`)), + ], + inputs: `coerced`, + }) +} +// The seventh review's dimensions (2026-11-11): every binding form crossed +// with every position of a read, so that a dead zone, a scope boundary, or +// a spelling of `eval` is generated, not found one construct at a time. +// +// - `zone`: a read of `x` in `c OP x ? 1 : 1`, under identical branches, +// where `x` is bound by a parameter, a destructured parameter, a `let`, a +// `const`, a class, a caught error's pattern, a `for…of` or `for…in` +// declaration's pattern, a nested pattern, or a `var` (in a nested block, +// or in a class's static block, which keeps it from the function). The +// read sits before the binding's declaration ends, inside its own +// initializer or pattern, after it, or in a hoisted function that runs +// before it. A read in the dead zone throws, so the swap shows there. +// - `scope` gains `(eval)(code)`, `(eval as any)(code)` (both direct), and +// `(0, eval)(code)` (indirect, which declares nothing in the function). +// - `chain`: an ordering swap beside a second comparison in the same `&&` +// chain (`a < 5 && p < q ? a : 5`, and the bigint form), with `p` and `q` +// annotated with a primitive or with `object`. An admitted object's +// `valueOf` distinguishes the swap, so the label belongs only where every +// comparison in the chain compares primitives. +const ZONE = [ + [ + `param before`, + `function f(c, b = (c OP x ? 1 : 1), x) {\n return b;\n}`, + `zoneDefault`, + ], + [ + `param inside`, + `function f(c, x = (c OP x ? 1 : 1)) {\n return x;\n}`, + `zone`, + ], + [ + `param after`, + `function f(c, x, b = (c OP x ? 1 : 1)) {\n return b;\n}`, + `zonePairs`, + ], + [ + `destructured before`, + `function f(c, { p = (c OP x ? 1 : 1), x } = o) {\n return p;\n}`, + `zone`, + ], + [ + `destructured after`, + `function f(c, { x, p = (c OP x ? 1 : 1) } = o) {\n return p;\n}`, + `zone`, + ], + [ + `let before`, + `function f(c) {\n const r = (c OP x ? 1 : 1);\n let x = 1;\n return r;\n}`, + `zone`, + ], + [ + `let inside`, + `function f(c) {\n let x = (c OP x ? 1 : 1);\n return x;\n}`, + `zone`, + ], + [ + `let after`, + `function f(c) {\n let x = 1;\n return (c OP x ? 1 : 1);\n}`, + `zone`, + ], + [ + `const hoisted`, + `function f(c) {\n const r = g();\n const x = 1;\n return r;\n function g() {\n return (c OP x ? 1 : 1);\n }\n}`, + `zone`, + ], + [ + `class before`, + `function f(c) {\n const r = (c OP x ? 1 : 1);\n class x {}\n return r;\n}`, + `zone`, + ], + [ + `caught before`, + `function f(c) {\n try {\n throw o;\n } catch ({ p = (c OP x ? 1 : 1), x }) {\n return p;\n }\n}`, + `zone`, + ], + [ + `caught after`, + `function f(c) {\n try {\n throw o;\n } catch ({ x, p = (c OP x ? 1 : 1) }) {\n return p;\n }\n}`, + `zone`, + ], + [ + `for-of pattern`, + `function f(c) {\n for (const [p = (c OP x ? 1 : 1), x] of rows) return p;\n}`, + `zone`, + ], + [ + `for-in pattern`, + `function f(c) {\n for (const [p = (c OP x ? 1 : 1), x] in o) return p;\n}`, + `zone`, + ], + [ + `for-of body`, + `function f(c) {\n for (const x of rows) return (c OP x ? 1 : 1);\n}`, + `zone`, + ], + [ + `nested pattern`, + `function f(c) {\n for (const [[p = (c OP x ? 1 : 1)], x] of nested) return p;\n}`, + `zone`, + ], + [ + `var nested`, + `function f(c) {\n {\n var x = 1;\n }\n return (c OP x ? 1 : 1);\n}`, + `zone`, + ], + [ + `var before`, + `function f(c) {\n const r = (c OP x ? 1 : 1);\n var x = 1;\n return r;\n}`, + `zone`, + ], + [ + `static block`, + `function f(c) {\n class K {\n static {\n var x = 1;\n }\n }\n return (c OP x ? 1 : 1);\n}`, + `zone`, + ], +] +for (const [name, template, inputs] of ZONE) + for (const op of [`&&`, `||`, `??`]) { + const text = template.replace(`OP`, op) + const sites = [textSite(text, `binary-swap`, op, text.indexOf(`c ${op}`))] + for ( + let at = text.indexOf(`return `); + at >= 0; + at = text.indexOf(`return `, at + 1) + ) { + const start = at + `return `.length + sites.push({ + operator: `return-undefined`, + position: start, + marker: text.slice(start, text.indexOf(`;`, start)), + }) + } + if (text.includes(`g()`)) + sites.push(textSite(text, `value-undefined`, `g()`)) + textPrograms.push({ + kind: `text`, + lang: `js`, + family: `zone`, + name: `${name} ${op}`, + text, + sites, + inputs, + }) + } +for (const [spelling, lang] of [ + [`(eval)(code)`, `js`], + [`(eval as any)(code)`, `ts`], + [`(0, eval)(code)`, `js`], +]) + for (const [target, expression] of Object.entries(SCOPE_TARGETS)) { + const head = target === `constant` ? `const s = 'x';\n` : `` + const text = `${head}function f(a${lang === `ts` ? `: any` : ``}, code${lang === `ts` ? `: any` : ``}, o${lang === `ts` ? `: any` : ``}) {\n ${spelling};\n return ${expression};\n}` + const direct = !spelling.startsWith(`(0,`) + const returned = text.indexOf(`return `) + `return `.length + const sites = [ + { + operator: `delete-call`, + position: text.indexOf(spelling), + marker: `${spelling};`, + }, + ] + if (target !== `undefined` || direct) + sites.push({ + operator: `return-undefined`, + position: returned, + marker: expression, + }) + if (target === `constant`) + sites.push({ + operator: `optional-chain`, + position: returned, + marker: `s?.length`, + }) + if (target === `local`) + sites.push(textSite(text, `binary-swap`, `&&`, returned)) + textPrograms.push({ + kind: `text`, + lang, + family: `scope`, + name: `${spelling} ${target}`, + text, + sites, + inputs: `scoped`, + }) + } +// A direct eval through a local binding named `eval` (the eighth review): a +// sloppy call of the identifier `eval` holding the real `eval` is direct, +// whatever binding holds it. +const EVAL_BINDINGS = { + parameter: [ + `function f(a, code, o, eval = globalThis.eval) {\n`, + ` eval(code);\n return T;\n}`, + ], + destructured: [ + `function f(a, code, o) {\n var { eval } = globalThis;\n`, + ` eval(code);\n return T;\n}`, + ], + caught: [ + `function f(a, code, o) {\n try {\n throw globalThis.eval;\n } catch (eval) {\n`, + ` eval(code);\n return T;\n }\n}`, + ], +} +for (const [binding, [open, close]] of Object.entries(EVAL_BINDINGS)) + for (const [target, expression] of Object.entries(SCOPE_TARGETS)) { + const head = target === `constant` ? `const s = 'x';\n` : `` + const text = `${head}${open}${close.replace(`T`, expression)}` + const returned = text.indexOf(`return `) + `return `.length + const sites = [ + { + operator: `delete-call`, + position: text.indexOf(`eval(code);`), + marker: `eval(code);`, + }, + { operator: `return-undefined`, position: returned, marker: expression }, + ] + if (target === `constant`) + sites.push({ + operator: `optional-chain`, + position: returned, + marker: `s?.length`, + }) + if (target === `local`) + sites.push(textSite(text, `binary-swap`, `&&`, returned)) + textPrograms.push({ + kind: `text`, + lang: `js`, + family: `scope`, + name: `eval as a ${binding} ${target}`, + text, + sites, + inputs: `scoped`, + }) + } +// The generator witness of the eighth review: a yield that reads a `let` +// before its declaration. +for (const op of [`&&`, `||`, `??`]) { + const text = `function f(c) {\n yield (c ${op} x ? 1 : 1);\n let x = 1;\n}` + textPrograms.push({ + kind: `text`, + lang: `js`, + family: `zone`, + name: `let before a yield ${op}`, + text, + sites: [textSite(text, `binary-swap`, op)], + inputs: `zone`, + generatorOnly: true, + }) +} +// Each program of the dead-zone, scope, binding, and returned-call +// dimensions again as an async function, a generator, and an async +// generator (the eighth review: a rejected promise or a throwing iterator +// is a result too). The first `function` keyword is the one rewritten, and +// every site after it moves by what the rewrite inserts. +const KINDS = { + async: [`async `, 0], + generator: [`*`, 8], + asyncGenerator: [`async *`, 0], +} +for (const program of textPrograms.splice(0)) { + const at = program.text.indexOf(`function `) + const kinds = [ + `sync`, + ...(at < 0 || ![`zone`, `scope`, `binds`, `called`].includes(program.family) + ? [] + : Object.keys(KINDS)), + ] + for (const kind of kinds) { + if (program.generatorOnly && !kind.toLowerCase().includes(`generator`)) + continue + if (kind === `sync`) { + if (!program.generatorOnly) textPrograms.push(program) + continue + } + const [insert, offset] = KINDS[kind] + const pieces = + insert === `async *` + ? [ + [`async `, 0], + [`*`, 8], + ] + : [[insert, offset]] + let text = program.text + let sites = program.sites + let shift = 0 + for (const [piece, after] of pieces) { + const where = at + after + shift + text = `${text.slice(0, where)}${piece}${text.slice(where)}` + sites = sites.map((site) => + site.position >= where + ? { ...site, position: site.position + piece.length } + : site, + ) + shift += piece.length + } + textPrograms.push({ + ...program, + name: `${program.name}, ${kind}`, + text, + sites, + generatorOnly: undefined, + }) + } +} +const CHAIN_FORMS = { + number: [`number`, `a < 5 && p < q ? a : 5`], + bigint: [`bigint`, `a < b && p < q ? a : b`], +} +for (const [form, [type, expression]] of Object.entries(CHAIN_FORMS)) + for (const other of [`number`, `object`]) { + const params = form === `number` ? `a: number` : `a: bigint, b: bigint` + const text = `function f(${params}, p: ${other}, q: ${other}) {\n return ${expression};\n}` + const returned = text.indexOf(`return `) + `return `.length + textPrograms.push({ + kind: `text`, + family: `chain`, + name: `${form} beside ${other}`, + lang: `ts`, + annotation: other, + operandType: type, + text, + sites: [ + { + operator: `return-undefined`, + position: returned, + marker: expression, + }, + textSite(text, `binary-swap`, `<`, returned), + textSite(text, `binary-swap`, `&&`, returned), + textSite(text, `binary-swap`, `<`, text.indexOf(`p <`)), + ], + inputs: form === `number` ? `chainNumber` : `chainBigint`, + }) + } +// A getter read through `with`, counted as an effect. +const READ_ONCE = () => ({ + get a() { + effects.count += 1 + return 1 + }, + get undefined() { + effects.count += 1 + return 5 + }, +}) +// The names the zone programs read from outside: a thrown object, whose +// one key is empty (so a `for…in` pattern destructures `''`), rows to +// iterate, and a global `x` whose getter counts its reads (a static block's +// `var` is not the function's, so its `x` is this global). +function installZone() { + globalThis.o = { '': 1 } + globalThis.rows = [[]] + globalThis.nested = [[[]]] + Object.defineProperty(globalThis, `x`, { + configurable: true, + get() { + effects.count += 1 + return 1 + }, + }) +} +const ZONE_C = [true, false, 0, 1, null, undefined] +const TEXT_INPUTS = { + zone: () => { + installZone() + return ZONE_C.map((c) => [c]) + }, + zoneDefault: () => { + installZone() + return ZONE_C.map((c) => [c, undefined, 1]) + }, + zonePairs: () => { + installZone() + return ZONE_C.map((c) => [c, 1]) + }, + chainNumber: () => + [4, 5, 6, `5`].flatMap((a) => + [1, 2, VALUED, {}].flatMap((p) => + [1, 2, VALUED, {}].map((q) => [a, p, q]), + ), + ), + chainBigint: () => + [4n, 5n, 5, null].flatMap((a) => + [5n, 5, 0n].flatMap((b) => + [1, 2, VALUED, {}].flatMap((p) => + [1, 2, VALUED, {}].map((q) => [a, b, p, q]), + ), + ), + ), + pairs: () => + [true, false, 0, 1, null, undefined].flatMap((c) => + [0, 1, null].map((a) => [c, a]), + ), + defaultArgs: () => + [true, false, 0, 1, null, undefined].flatMap((c) => + [0, 1, null].map((a) => [c, undefined, a]), + ), + single: () => [[5], [0], [null], [undefined]], + objects: () => [[{ undefined: 5 }], [{}]], + scoped: () => [ + [1, ``, {}], + [1, `var undefined = 5`, {}], + [1, `var s = null`, {}], + [1, `a = 2`, {}], + [1, `throw 1`, {}], + [1, ``, READ_ONCE()], + [0, ``, { s: null }], + ], + coerced: () => + [true, false].flatMap((c) => + [1, 2, `1`, VALUED, {}].flatMap((p) => + [1, 2, VALUED, {}].map((q) => [c, p, q]), + ), + ), +} +// The inputs of the declared types, where an annotation declares them. +const COERCED_TYPED = { + number: (p) => typeof p === `number`, + 'string | number': (p) => typeof p === `number` || typeof p === `string`, + object: (p) => typeof p === `object` && p !== null, + unknown: () => true, +} +// A result by kind and value: a function or an object is its kind alone, +// since each run makes a fresh one. +const encoded = (value) => + typeof value === `function` + ? `function` + : typeof value === `object` && value !== null + ? value === VALUED + ? `the coerced object` + : `object` + : shown(value) +const loadText = (text) => { + const js = ts.transpileModule(text, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.None, + }, + }).outputText + return new Function(`${js}\nreturn f`)() +} +// A thrown value's name: an error's name, and otherwise the value's kind. A +// `throw null`, or an object whose `name` getter throws, has a name too. +function errorName(error) { + try { + return error instanceof Error ? String(error.name) : typeof error + } catch { + return `unnamed` + } +} +// A run that has not settled after this long is reported, not awaited. +const SETTLE_LIMIT = 2_000 +const UNSETTLED = `did not settle` +const bounded = (promise) => { + let timer + return Promise.race([ + promise, + new Promise((resolve) => { + timer = setTimeout(() => resolve(UNSETTLED), SETTLE_LIMIT) + }), + ]).finally(() => clearTimeout(timer)) +} +// A result as it settles (the eighth review: a promise, or an iterator, is +// a result only once it settles). A promise is awaited: fulfilled with an +// encoded value, or rejected with an error's name. An iterator is driven, +// eight steps at most: each yield, the return, or a throw. +const ITERATION_BOUND = 8 +async function settled(value) { + if (typeof value?.then === `function`) { + try { + return `fulfilled ${encoded(await value)}` + } catch (error) { + return `rejected ${errorName(error)}` + } + } + if ( + typeof value?.next === `function` && + (Symbol.iterator in value || Symbol.asyncIterator in value) + ) { + const steps = [] + try { + for (let i = 0; i < ITERATION_BOUND; i += 1) { + const step = await value.next() + steps.push(`${step.done ? `return` : `yield`} ${encoded(step.value)}`) + if (step.done) break + } + } catch (error) { + steps.push(`threw ${errorName(error)}`) + } + return `iterated ${steps.join(`, `)}` + } + return encoded(value) +} +// Each run of a function over a domain, settled, with its effects. +async function settledRuns(f, domain) { + const runs = [] + for (const input of domain) { + const before = effects.count + let result + try { + result = await bounded(settled(f(...input))) + } catch (error) { + result = `threw ${errorName(error)}` + } + runs.push({ result, reads: effects.count - before }) + } + return runs +} +// The model of a text program: the runs of its text and of each site's +// mutant text, settled. They are computed when this file loads, since a +// settled run is asynchronous and the model is not. +const textRuns = new Map() +const textKey = (program, text) => + `${program.family}\u0000${program.name}\u0000${text}` +for (const program of textPrograms) + for (const text of [ + program.text, + ...program.sites.map((site) => mutateSite(program.text, site)), + ]) + textRuns.set( + textKey(program, text), + await settledRuns(loadText(text), TEXT_INPUTS[program.inputs]()), + ) +const sameRun = (x, y) => x.result === y.result && x.reads === y.reads +// The model's expectation for a text program, from its settled runs: a +// site is distinct where some input's run differs, and labelled where no +// input of the declared types does. +function textModel(program) { + const original = textRuns.get(textKey(program, program.text)) + const typed = textTypedMask(program) + const unsettled = [ + program.text, + ...program.sites.map((site) => mutateSite(program.text, site)), + ].some((text) => + textRuns + .get(textKey(program, text)) + .some((run) => run.result === UNSETTLED), + ) + return { + source: program.text, + unsettled, + sites: program.sites.map((site) => { + const runs = textRuns.get( + textKey(program, mutateSite(program.text, site)), + ) + const distinct = runs.some((run, i) => !sameRun(run, original[i])) + return { + operator: site.operator, + position: site.position, + distinct, + label: + typed !== undefined && + distinct && + runs.every((run, i) => !typed[i] || sameRun(run, original[i])), + dead: !distinct, + zeroDistinct: false, + ...(site.operator === `return-undefined` ? { runs: digest(runs) } : {}), + } + }), + } +} +function renderText(program) { + return { + text: program.text, + sites: program.sites.map((site) => ({ + operator: site.operator, + position: site.position, + change: { kind: `text`, text: mutateSite(program.text, site) }, + })), + } +} +const textDomain = (program) => TEXT_INPUTS[program.inputs]() +// Which inputs of a text program's domain its declared types admit, where +// an annotation declares them. +const textTypedMask = (program) => { + const typed = textTypedDomain(program) + if (typed === undefined) return undefined + const domain = TEXT_INPUTS[program.inputs]() + const admitted = new Set(typed.map((input) => input.map(encoded).join())) + return domain.map((input) => admitted.has(input.map(encoded).join())) +} +const textTypedDomain = (program) => { + if (program.family === `coerced` && program.annotation !== undefined) + return textDomain(program).filter( + ([, p, q]) => + COERCED_TYPED[program.annotation](p) && + COERCED_TYPED[program.annotation](q), + ) + if (program.family === `chain`) { + // Without strict null checks an annotated operand admits `null`, and the + // CLI does not know the package's strictness (the eighth review). + const operand = (v) => typeof v === program.operandType || v === null + const other = COERCED_TYPED[program.annotation] + return textDomain(program).filter((input) => { + const [p, q] = input.slice(-2) + return input.slice(0, -2).every(operand) && other(p) && other(q) + }) + } + return undefined +} + +// ## Hostile programs +// +// A family of fixed programs from five reviews (2026-11-10 to 2026-11-11). +// Each held a static judgment that some input defeats: a narrowing, a type, +// a literal, a binding, or a coercion. The shapes: (a) a `case` that falls +// through into `default`; (b) a numeric enum, where a number is admitted, +// and a string enum; (c) a property read with effects between its guard and +// its read, through a getter, a generator, a `toString`, a Proxy's `has` +// trap, `Symbol.hasInstance`, a nested or an optional read, and +// path-to-regexp's last union member; (d) a default parameter that runs +// before the body; (e) a `never` default fed by an `as` cast, and ms's +// `satisfies never` fed by a regex through `as`; (f) an extra label that a +// template computes; (g) operand types from `bigint | null` and `bigint` +// without strict null checks, a local fed by `any`, a cast, or +// `JSON.parse`, JavaScript with or without JSDoc, and generics instantiated +// with `any`; the conditions of an exhaustive-switch skip, each broken +// once; a comparison beside a call; overloads; ambient constants; and the +// fifth review's nested `var`s, imports, zero literals, changed constants, +// coercions, and type-only merges; and a closure that reads a `let` before +// its assignment. Each program's inputs include one that +// distinguishes a mutant there, which its reach state records. +// +// Laws (see `checkEscape`): erasing the types changes no emitted site and no +// dead-code mark; each dead-code mark holds on the inputs; and a returned +// `undefined` that a declaration binds is a site. A mutant is applied as +// text (its operator's `original` replaced at its position by its +// `replacement`), and the program and the mutant run on each input, by +// their results and their `log()` calls. +const ESCAPE_PROGRAMS = [ + { + name: `a case that falls through into default`, + text: `export function f(x: 'a' | 'b') {\n switch (x) {\n case 'a':\n case 'b':\n default:\n log()\n return 1\n }\n}`, + inputs: () => [[`a`], [`b`]], + }, + { + name: `a numeric enum switched`, + text: `export enum E { A, B }\nexport function f(e: E) {\n switch (e) {\n case 0: return 'a'\n case 1: return 'b'\n default: log(); return 'z'\n }\n}`, + inputs: () => [[0], [1], [2]], + }, + { + name: `a numeric enum tested`, + text: `export enum E { A, B }\nexport function f(e: E) {\n if (e === 0) return 'a'\n if (e === 1) return 'b'\n log()\n return 'z'\n}`, + inputs: () => [[0], [1], [2]], + }, + { + name: `a class getter`, + text: `export class C { n = 0; get k(): 'a' | 'b' { return this.n++ % 2 ? 'b' : 'a' } }\nexport function f(c: C) {\n if (c.k !== 'a') return 0\n if (c.k === 'a') return 1\n log()\n return 2\n}`, + inputs: (m) => [[new m.C()]], + }, + { + name: `a generator iterated between the guard and the read`, + text: `export function f(o: { k: 'a' | 'b' }, it: Iterable) {\n if (o.k !== 'a') return 0\n for (const _ of it) {}\n if (o.k === 'a') return 1\n log()\n return 2\n}`, + inputs: () => [[{ k: `a` }, []], [{ k: `b` }, []], changing(`k`, `b`)], + }, + { + name: `a generator spread between the guard and the read`, + text: `export function f(o: { k: 'a' | 'b' }, it: Iterable) {\n if (o.k !== 'a') return 0\n const xs = [...it]\n if (o.k === 'a') return xs.length\n log()\n return 2\n}`, + inputs: () => [[{ k: `a` }, []], changing(`k`, `b`)], + }, + { + name: `a template that calls toString`, + text: "export function f(o: { k: 'a' | 'b' }, t: { toString(): string }) {\n if (o.k !== 'a') return 0\n const s = `${t}`\n if (o.k === 'a') return s\n log()\n return 2\n}", + inputs: () => { + const o = { k: `a` } + return [ + [{ k: `a` }, `t`], + [ + o, + { + toString() { + o.k = `b` + return `u` + }, + }, + ], + ] + }, + }, + { + name: `an in test through a Proxy`, + text: `export function f(o: { k: 'a' | 'b' }, p: object) {\n if (o.k !== 'a') return 0\n const has = 'x' in p\n if (o.k === 'a') return has\n log()\n return 2\n}`, + inputs: () => { + const o = { k: `a` } + const trap = new Proxy( + {}, + { + has() { + o.k = `b` + return true + }, + }, + ) + return [ + [{ k: `a` }, {}], + [o, trap], + ] + }, + }, + { + name: `an instanceof through Symbol.hasInstance`, + text: `export function f(o: { k: 'a' | 'b' }, v: unknown, K: { [Symbol.hasInstance](v: unknown): boolean }) {\n if (o.k !== 'a') return 0\n const is = v instanceof K\n if (o.k === 'a') return is\n log()\n return 2\n}`, + inputs: () => { + const o = { k: `a` } + const K = { + [Symbol.hasInstance]() { + o.k = `b` + return false + }, + } + return [ + [{ k: `a` }, 1, { [Symbol.hasInstance]: () => true }], + [o, 1, K], + ] + }, + }, + { + name: `a getter read into a const`, + text: `export function f(o: { k: 'a' | 'b'; readonly j: number }) {\n if (o.k !== 'a') return 0\n const j = o.j\n if (o.k === 'a') return j\n log()\n return 2\n}`, + inputs: () => { + const o = { + k: `a`, + get j() { + o.k = `b` + return 1 + }, + } + return [[{ k: `a`, j: 1 }], [o]] + }, + }, + { + name: `a nested discriminant`, + text: `export function f(o: { v: { t: 'a' } | { t: 'b' } }, it: Iterable) {\n if (o.v.t !== 'a') return 0\n for (const _ of it) {}\n if (o.v.t === 'a') return 1\n log(); return 2\n}`, + inputs: () => { + const o = { v: { t: `a` } } + function* change() { + o.v = { t: `b` } + yield 1 + } + return [ + [{ v: { t: `a` } }, []], + [o, change()], + ] + }, + }, + { + name: `an optional read through a getter`, + text: `export function f(o: { k: 'a' | 'b' } | undefined) {\n if (o?.k !== 'a') return 0\n if (o?.k === 'a') return 1\n log()\n return 2\n}`, + inputs: () => { + let reads = 0 + return [ + [{ k: `a` }], + [undefined], + [ + { + get k() { + reads += 1 + return reads > 1 ? `b` : `a` + }, + }, + ], + ] + }, + }, + { + name: `a default parameter that runs first`, + text: `export function f(o: { k: 'a' }, m: () => void, _u: void = m()) {\n if (o.k === 'a') return 1\n log(); return 2\n}`, + inputs: () => { + const o = { k: `a` } + return [ + [{ k: `a` }, () => {}], + [ + o, + () => { + o.k = `b` + }, + ], + ] + }, + }, + { + name: `a never default fed by a cast`, + text: `type K = 'a' | 'b'\nexport function f(raw: string) {\n const k = raw as K\n switch (k) {\n case 'a': return 1\n case 'b': return 2\n default: { const n: never = k; log(); return n }\n }\n}`, + inputs: () => [[`a`], [`b`], [`z`]], + }, + { + name: `an extra label that a template computes`, + text: "export function f(x: 'a', o: { k: 'a' | 'b' }, t: { toString(): string }) {\n if (o.k !== 'a') return 0\n switch (x) {\n case `${t}`: return 1\n case o.k: return 2\n default: log(); return 3\n }\n}", + inputs: () => { + const o = { k: `a` } + return [ + [`a`, { k: `a` }, `a`], + [ + `a`, + o, + { + toString() { + o.k = `b` + return `z` + }, + }, + ], + ] + }, + }, + // The switch rule's own conditions, each broken once with a typed input + // that reaches the default. + { + name: `a string enum switched`, + text: `export enum E { A = 'a', B = 'b' }\nexport function f(e: E) {\n switch (e) {\n case E.A: return 1\n case E.B: return 2\n default: log(); return 3\n }\n}`, + inputs: () => [[`a`], [`b`], [`z`]], + }, + { + name: `a subject with a default value`, + text: `export function f(t: 'a' | 'b' = 'z' as 'a') {\n switch (t) {\n case 'a': return 1\n case 'b': return 2\n default: log(); return 3\n }\n}`, + inputs: () => [[`a`], [`b`], []], + }, + { + name: `an optional subject`, + text: `export function f(t?: 'a' | 'b') {\n switch (t) {\n case 'a': return 1\n case 'b': return 2\n default: log(); return 3\n }\n}`, + inputs: () => [[`a`], [`b`], []], + }, + { + name: `a subject assigned from a cast`, + text: `export function f(t: 'a' | 'b', m: boolean) {\n if (m) t = 'z' as 'a'\n switch (t) {\n case 'a': return 1\n case 'b': return 2\n default: log(); return 3\n }\n}`, + inputs: () => [ + [`a`, false], + [`a`, true], + ], + }, + { + name: `a subject that a callback assigns`, + text: `export function f(t: 'a' | 'b', run: (set: () => void) => void) {\n run(() => { t = 'z' as 'a' })\n switch (t) {\n case 'a': return 1\n case 'b': return 2\n default: log(); return 3\n }\n}`, + inputs: () => [ + [`a`, () => {}], + [`a`, (set) => set()], + ], + }, + { + name: `a subject that eval assigns`, + text: `export function f(t: 'a' | 'b', code: string) {\n eval(code)\n switch (t) {\n case 'a': return 1\n case 'b': return 2\n default: log(); return 3\n }\n}`, + inputs: () => [ + [`a`, ``], + [`a`, `t = 'z'`], + ], + }, + { + name: `a switch that misses a member`, + text: `export function f(t: 'a' | 'b') {\n switch (t) {\n case 'a': return 1\n default: log(); return 3\n }\n}`, + inputs: () => [[`a`], [`b`]], + }, + { + name: `a function the default declares`, + text: `export function f(t: 'a' | 'b') {\n switch (t) {\n case 'a': return g()\n case 'b': return 2\n default:\n function g() { log(); return 1 }\n }\n}`, + inputs: () => [[`a`], [`b`]], + }, + { + name: `a property's last member, as path-to-regexp tests it`, + text: `type Token = { type: 'text'; value: string } | { type: 'param'; name: string } | { type: 'wildcard'; name: string }\nexport function f(tokens: Token[]) {\n let out = ''\n for (const token of tokens) {\n if (token.type === 'text') { out += token.value; continue }\n if (token.type === 'param') { out += ':' + token.name; continue }\n if (token.type === 'wildcard') { out += '*' + token.name; continue }\n log()\n }\n return out\n}`, + inputs: () => [ + [[{ type: `text`, value: `a` }]], + [[{ type: `wildcard`, name: `w` }]], + [[{ type: `other`, name: `o` }]], + ], + }, + { + name: `a satisfies never whose subject a regex feeds through as, as in ms`, + text: `type Unit = 'ms' | 's'\nexport function f(str: string) {\n const match = /^(\\d+)(ms|s|h)?$/.exec(str)\n if (!match) return NaN\n const type = (match[2] || 'ms').toLowerCase() as Unit\n const n = Number(match[1])\n switch (type) {\n case 'ms': return n\n case 's': return n * 1000\n default:\n type satisfies never\n log()\n return NaN\n }\n}`, + inputs: () => [[`1ms`], [`1s`], [`1h`]], + }, + // The ordering swap's operand types: each from a source the rule does not + // trust, with an input that ties its operands at different values. + { + name: `a bigint | null parameter without strict null checks`, + strict: false, + text: `export function f(a: bigint | null, b: bigint) { return a < b ? a : b }`, + inputs: () => [ + [1n, 0n], + [null, 0n], + ], + }, + { + name: `bigint parameters without strict null checks`, + strict: false, + text: `export function f(a: bigint, b: bigint) { return a < b ? a : b }`, + inputs: () => [ + [1n, 0n], + [null, 0n], + ], + }, + { + name: `a bigint parameter and a zero without strict null checks`, + strict: false, + text: `export function f(a: bigint) { return a < 0n ? a : 0n }`, + inputs: () => [[1n], [null]], + }, + { + name: `a local number fed by any`, + text: `export function f(o: any) { const x: number = o; return x < 5 ? x : 5 }`, + inputs: () => [[1], [`5`]], + }, + { + name: `a local number fed by a cast`, + text: `export function f(s: string) { const x = s as unknown as number; return x < 5 ? x : 5 }`, + inputs: () => [[`1`], [`5`]], + }, + { + name: `a local number fed by JSON.parse`, + text: `export function f(s: string) { const x: number = JSON.parse(s); return x < 5 ? x : 5 }`, + inputs: () => [[`1`], [`"5"`]], + }, + { + name: `a number parameter assigned from a cast`, + text: `export function f(a: number, s: string) { a = s as unknown as number; return a < 5 ? a : 5 }`, + inputs: () => [[0, `5`]], + }, + { + name: `a number parameter assigned by destructuring`, + text: `export function f(a: number, o: any) { [a] = o; return a < 5 ? a : 5 }`, + inputs: () => [[0, [`5`]]], + }, + { + name: `a number parameter that eval assigns`, + text: `export function f(a: number, code: string) { eval(code); return a < 5 ? a : 5 }`, + inputs: () => [[0, `a = '5'`]], + }, + { + name: `a number parameter that a var redeclares`, + text: `export function f(a: number, s: any) { { var a: number = s } return a < 5 ? a : 5 }`, + inputs: () => [[0, `5`]], + }, + { + name: `JavaScript with a JSDoc number`, + ext: `js`, + text: `/** @param {number} x */\nexport function f(x) { return x < 5 ? x : 5 }`, + inputs: () => [[1], [`5`]], + }, + { + name: `JavaScript with JSDoc bigints`, + ext: `js`, + text: `/** @param {bigint} a @param {bigint} b */\nexport function f(a, b) { return a < b ? a : b }`, + inputs: () => [ + [1n, 0n], + [0n, 0], + ], + }, + { + name: `JavaScript with a JSDoc cast`, + ext: `js`, + text: `export function f(s) { const x = /** @type {number} */ (s); return x < 5 ? x : 5 }`, + inputs: () => [[1], [`5`]], + }, + { + name: `untyped JavaScript that reassigns a number`, + ext: `js`, + text: `export function f(s) { let x = 0; x = s; return x < 5 ? x : 5 }`, + inputs: () => [[1], [`5`]], + }, + { + name: `a generic instantiated with any`, + text: `export function f(a: T, b: T) { return a < b ? a : b }`, + inputs: () => [ + [1n, 0n], + [0n, 0], + ], + }, + { + name: `a generic call fed by any`, + text: `function g(v: T): T { return v }\nexport function f(a: bigint, s: any) { const b = g(s); return a < b ? a : b }`, + inputs: () => [ + [1n, 0n], + [0n, 0], + ], + }, + // The third review (2026-11-11): a comparison beside a call, whose + // evaluation an ordering swap changes at a tie, and overloads, whose + // callers see only the overload signatures. TypeScript erases an + // overload's type parameters when it checks the implementation, so a + // caller can pass what the implementation's annotation excludes. And an + // ambient constant, whose value the environment gives. + { + name: `a comparison beside a call under &&`, + text: `export function f(a: number, check: () => boolean): number {\n return a < 1 && check() ? a : 1\n}`, + inputs: () => [ + [0, () => true], + [ + 1, + () => { + throw new Error(`called`) + }, + ], + ], + }, + { + name: `a bigint comparison beside a call under ||`, + text: `export function f(a: bigint, b: bigint, check: () => boolean): bigint {\n if (a < b || check()) return a\n return b\n}`, + inputs: () => [ + [0n, 1n, () => true], + [ + 1n, + 1n, + () => { + throw new Error(`called`) + }, + ], + ], + }, + { + name: `a switch under a generic overload`, + text: `export function f(x: T): number\nexport function f(x: 'a' | 'b'): number {\n switch (x) {\n case 'a': return 1\n case 'b': return 2\n default: log(); return 3\n }\n}`, + inputs: () => [[`a`], [`zzz`]], + }, + { + name: `a bigint swap under a generic overload`, + text: `export function f(a: T, b: T): T\nexport function f(a: bigint, b: bigint): bigint {\n return a < b ? a : b\n}`, + inputs: () => [ + [1n, 0n], + [0, -0], + ], + }, + { + name: `a switch in an overloaded method`, + text: `export class C {\n m(x: T): number\n m(x: 'a' | 'b'): number {\n switch (x) {\n case 'a': return 1\n case 'b': return 2\n default: log(); return 3\n }\n }\n}\nexport const f = (x: string) => new C().m(x)`, + inputs: () => [[`a`], [`zzz`]], + }, + { + name: `a switch on an ambient constant`, + text: `declare const K = 'a'\nexport function f(): number {\n switch (K) {\n case 'a': return 1\n default: log(); return 3\n }\n}`, + inputs: () => { + globalThis.K = `z` + return [[]] + }, + }, + { + name: `a swap with an ambient constant`, + text: `declare const K = 5\nexport function f(x: number): number { return x < K ? x : K }`, + inputs: () => { + globalThis.K = `5` + return [[1], [5]] + }, + }, + { + name: `an equality tie of locals fed by any`, + text: `export function f(s: any, t: any) {\n const a: bigint = s\n const c: bigint = t\n if (a !== c) return a\n return c\n}`, + inputs: () => [ + [1n, 0n], + [0, -0], + ], + }, + // F17's operators: an optional read whose receiver's type the rule does + // not trust, and a returned value typed `undefined` or `void`, each with + // an input that JavaScript allows. + { + name: `an optional read of a local fed by any`, + text: `export function f(s: any) { const p: { v: number } = s; return p?.v }`, + inputs: () => [[{ v: 1 }], [undefined]], + }, + { + name: `an optional read of a local fed by a cast`, + text: `export function f(s: unknown) { const p = s as { v: number }; return p?.v }`, + inputs: () => [[{ v: 1 }], [undefined]], + }, + { + name: `an optional read of a property`, + text: `export function f(w: { o: { v: number } }) { return w.o?.v }`, + inputs: () => [[{ o: { v: 1 } }], [{ o: undefined }]], + }, + { + name: `an optional read of a generic parameter`, + text: `export function f(o: T) { return o?.v }`, + inputs: () => [[{ v: 1 }], [undefined]], + }, + { + name: `an optional read of an optional parameter`, + text: `export function f(o?: { v: number }) { return o?.v }`, + inputs: () => [[{ v: 1 }], []], + }, + { + name: `an optional read of a defaulted parameter`, + text: `export function f(o: { v: number } = undefined as any) { return o?.v }`, + inputs: () => [[{ v: 1 }], []], + }, + { + name: `an optional read of an assigned parameter`, + text: `export function f(o: { v: number }, s: any) { o = s; return o?.v }`, + inputs: () => [ + [{ v: 1 }, { v: 2 }], + [{ v: 1 }, undefined], + ], + }, + { + name: `an optional read of a parameter that eval assigns`, + text: `export function f(o: { v: number }, code: string) { eval(code); return o?.v }`, + inputs: () => [ + [{ v: 1 }, ``], + [{ v: 1 }, `o = undefined`], + ], + }, + { + name: `an optional read in JavaScript with a JSDoc type`, + ext: `js`, + text: `/** @param {{ v: number }} o */\nexport function f(o) { return o?.v }`, + inputs: () => [[{ v: 1 }], [undefined]], + }, + { + name: `an optional read of a parameter without strict null checks`, + strict: false, + text: `export function f(o: { v: number }) { return o?.v }`, + inputs: () => [[{ v: 1 }], [undefined]], + }, + { + name: `an optional read under a generic overload`, + text: `export function f(x: T): number | undefined\nexport function f(x: { n: number }): number | undefined {\n return x?.n\n}`, + inputs: () => [[{ n: 1 }], [null]], + }, + { + name: `a returned parameter typed undefined`, + text: `export function f(x: undefined) { return x }`, + inputs: () => [[undefined], [5]], + }, + { + name: `a returned parameter typed void`, + text: `export function f(x: void) { return x }`, + inputs: () => [[undefined], [5]], + }, + // The fifth review (2026-11-11): each program defeats a static judgment + // that the CLI made without types or with them. A `var` in a nested block + // is the function's, and an import binds a name. `0x0`, `0.0`, `0b0`, + // and `0x0n` are zeros. A namespace's or a CommonJS module's exported + // `const` is a property that code can change. An object's `valueOf` runs + // when a comparison coerces it. The rest change nothing but types: an + // overload named by a default export or a quoted method, an interface + // merged with a class, and a function type without strict function types. + { + name: `a nested var that shadows a constant beside a swap`, + text: `const k = 5\nexport function f(a: number): number {\n { var k = 0 }\n return a < k ? a : k\n}`, + inputs: () => [[-0], [1]], + }, + { + name: `a nested var that shadows a constant beside a guard`, + text: `const k = 5\nexport function f(a: number): number {\n { var k = 0 }\n if (a !== k) return a\n return k\n}`, + inputs: () => [[-0], [1]], + }, + { + name: `a nested var that shadows a constant receiver`, + text: `const s = 'x'\nexport function f(): number | undefined {\n { var s: string | undefined = undefined }\n return s?.length\n}`, + inputs: () => [[]], + }, + { + name: `a nested var named undefined`, + text: `export function f(): number | undefined {\n { var undefined = 5 }\n return undefined\n}`, + inputs: () => [[]], + }, + { + name: `an import named undefined`, + text: `import { five as undefined } from './five'\nexport function f(): number { return undefined }`, + inputs: () => [[]], + }, + { + name: `a destructured parameter named undefined`, + text: `export function f({ undefined }: { undefined: number }): number { return undefined }`, + inputs: () => [[{ undefined: 5 }]], + }, + { + name: `a hexadecimal zero beside a guard`, + text: `export function f(a: number): number {\n if (a !== 0x0) return a\n return 0x0\n}`, + inputs: () => [[-0], [1]], + }, + { + name: `a decimal zero with a fraction beside a guard`, + text: `export function f(a: number): number {\n if (a !== 0.0) return a\n return 0.0\n}`, + inputs: () => [[-0], [1]], + }, + { + name: `a binary zero beside a guard in JavaScript`, + ext: `js`, + text: `export function f(a) {\n if (a !== 0b0) return a\n return 0b0\n}`, + inputs: () => [[-0], [1]], + }, + { + name: `a hexadecimal bigint zero beside a swap`, + text: `export function f(a: bigint): bigint {\n return a < 0x0n ? a : 0x0n\n}`, + inputs: () => [[1n], [null], [0]], + }, + { + name: `a namespace constant that code changes`, + text: `export namespace N {\n export const s: string | undefined = 'x'\n export function f(): number | undefined { return s?.length }\n}\nexport const f = N.f`, + inputs: (m) => { + m.N.s = undefined + return [[]] + }, + }, + { + name: `an exported constant that a CommonJS caller changes`, + text: `export const s: string | undefined = 'x'\nexport function f(): number | undefined { return s?.length }`, + inputs: (m) => { + m.s = undefined + return [[]] + }, + }, + { + name: `a comparison that coerces an object beside identical branches`, + text: `export function f(c: boolean, p: object, q: object): number {\n if (c || p < q) return 1\n return 1\n}`, + inputs: () => { + const loud = { + valueOf() { + throw new Error(`coerced`) + }, + } + return [ + [true, loud, loud], + [false, {}, {}], + ] + }, + }, + { + name: `a comparison that coerces an object under && beside identical branches`, + text: `export function f(c: boolean, p: object, q: object): number {\n return c && p < q ? 1 : 1\n}`, + inputs: () => { + const loud = { + valueOf() { + throw new Error(`coerced`) + }, + } + return [ + [false, loud, loud], + [true, {}, {}], + ] + }, + }, + { + name: `a swap beside a comparison of objects`, + text: `export function f(a: number, p: object, q: object): number {\n return a < 1 && p < q ? a : 1\n}`, + inputs: () => { + const loud = { + valueOf() { + throw new Error(`coerced`) + }, + } + return [ + [1, loud, loud], + [0, 1, 2], + ] + }, + }, + { + name: `a closure that reads a let before its assignment`, + text: `export function f(c: boolean): number {\n const read = () => (c && x ? 1 : 1)\n const r = read()\n let x = 1\n return r + x\n}`, + inputs: () => [[false], [true]], + }, + { + name: `a returned dynamic import`, + text: `export function f() {\n return import('./five')\n}`, + inputs: () => [[]], + }, + { + name: `an overloaded default export`, + text: `export default function g(x: T): number\nexport default function g(x: 'a' | 'b'): number {\n switch (x) {\n case 'a': return 1\n case 'b': return 2\n default: log(); return 3\n }\n}\nexport const f = g`, + inputs: () => [[`a`], [`zzz`]], + }, + { + name: `an overload named by a quoted method`, + text: `export class K {\n 'm'(x: T): number\n m(x: 'a' | 'b'): number {\n switch (x) {\n case 'a': return 1\n case 'b': return 2\n default: log(); return 3\n }\n }\n}\nexport const f = (x: string) => new K().m(x)`, + inputs: () => [[`a`], [`zzz`]], + }, + { + name: `an interface merged with a class`, + text: `export interface K { m(x: string): number }\nexport class K {\n m(x: 'a' | 'b'): number {\n switch (x) {\n case 'a': return 1\n case 'b': return 2\n default: log(); return 3\n }\n }\n}\nexport const f = (x: string) => new K().m(x)`, + inputs: () => [[`a`], [`zzz`]], + }, + { + name: `an optional read under a wider function type`, + text: `type H = (x: { n: number } | undefined) => number | undefined\nexport const f: H = (x: { n: number }): number | undefined => x?.n`, + inputs: () => [[{ n: 1 }], [undefined]], + }, +] +// A history is data, so each program's inputs are found by its name. +const ESCAPE_INPUTS = new Map( + ESCAPE_PROGRAMS.map(({ name, inputs }) => [name, inputs]), +) +const ESCAPES = ESCAPE_PROGRAMS.map(({ name, text, strict, ext }) => ({ + kind: `escape`, + name, + strict: strict ?? true, + ext: ext ?? `ts`, + // JavaScript has no `declare`: there `log` is a free name. + text: ext === `js` ? text : `${text}\ndeclare function log(): void\n`, +})) +// An object whose `key` a generator changes to `value` when iterated. +function changing(key, value) { + const o = { [key]: `a` } + function* change() { + o[key] = value + yield 1 + } + return [o, change()] +} -// The twelve comparisons of two operands, each op in both written orders. -function comparisonsOf(left, right) { - return OPS.flatMap((op) => [ - { kind: `cmp`, left: ref(left), op, right: ref(right) }, - { kind: `cmp`, left: ref(right), op: REVERSE[op], right: ref(left) }, - ]) +// A program's runs on its inputs: each result, or the error it threw, and +// its `log()` calls. Each run builds its inputs afresh, so state that one run +// changes does not reach the next. +function runEscape(program, text) { + const js = ts.transpileModule(text, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, + }, + }).outputText + const load = () => { + const exported = {} + let calls = 0 + // An import reads `./five`, a module whose `five` is 5. + const required = (name) => + name === `./five` ? { five: 5 } : { __proto__: null } + new Function(`exports`, `log`, `require`, js)( + exported, + () => { + calls += 1 + }, + required, + ) + return { exported, calls: () => calls } + } + const inputs = ESCAPE_INPUTS.get(program.name) + return inputs(load().exported).map((_, index) => { + const { exported, calls } = load() + const input = inputs(exported)[index] + let result + try { + result = shown(exported.f(...input)) + } catch (error) { + result = `threw ${errorName(error)}` + } + return `${result}:${calls()}` + }) } - -// Every pair of variables, and each variable with the literals 0 and 1. A pair -// is generated once, in both orders through `comparisonsOf`. -const pairs = [] -for (const [i, left] of VARIABLES.entries()) - for (const right of [...VARIABLES.slice(i + 1), `1`, `0`]) - pairs.push([left, right]) -const tests = pairs.flatMap(([left, right]) => comparisonsOf(left, right)) - -// The comparison a test is built on. -const comparisonOf = (test) => - test.kind === `cmp` - ? test - : test.kind === `not` - ? test.operand - : test.left.kind === `cmp` - ? test.left - : test.right - -// A test built on a comparison: its negation, and the comparison with a -// variable that is not one of its operands, in either order. -const compoundsOf = (cmp) => { - const other = VARIABLES.find( - (v) => v !== cmp.left.name && v !== cmp.right.name, +// A result as text that tells apart what `Object.is` does: a bigint from a +// number, -0 from 0, NaN, `null`, and `undefined`. +function shown(value) { + if (typeof value === `bigint`) return `${value}n` + if (typeof value === `number`) + return Object.is(value, -0) ? `-0` : String(value) + if (value === undefined) return `undefined` + return JSON.stringify(value, (_, inner) => + typeof inner === `bigint` ? `${inner}n` : inner, ) - return [ - negation(cmp), - logic(`&&`, cmp, ref(other)), - logic(`||`, ref(other), cmp), - ] } -const compounds = tests.flatMap(compoundsOf) - -// The branches of a ternary or guard on a comparison: the two operands in -// either order, the variable that is not an operand with one of them in either -// position, and the same variable twice. -function branchesOf(test) { - const [l, r] = [test.left, test.right] - const others = VARIABLES.filter((v) => v !== l.name && v !== r.name).map(ref) - return [ - [l, r], - [r, l], - ...others.flatMap((x) => [ - [x, l], - [x, r], - [l, x], - [r, x], - ]), - ...VARIABLES.map((v) => [ref(v), ref(v)]), - ] +// A mutant applied as text: its original at its position, replaced. An +// optional chain's `?.` is the first after the access's start. +function mutateText(text, mutant) { + const at = + mutant.operator === `optional-chain` + ? text.indexOf(`?.`, mutant.position) + : mutant.position + // A returned value made undefined reads the global's own value, which a + // name that shadows `undefined` would not. + const replacement = + mutant.operator === `return-undefined` ? `void 0` : mutant.replacement + return `${text.slice(0, at)}${replacement}${text.slice(at + mutant.original.length)}` } -// The branches of a guard that returns nothing on one side or both. -const EMPTY_BRANCHES = [ - ...VARIABLES.flatMap((v) => [ - [VOID, ref(v)], - [ref(v), VOID], - ]), - [VOID, VOID], + +// The families, each program one history. +const histories = [ + ...programs, + ...narrowedPrograms, + ...chainPrograms, + ...returnPrograms, + ...textPrograms, + ...ESCAPES, ] -// The branches of a compound test: its comparison's operands in either order, -// and one variable twice. -const compoundBranches = (test) => { - const cmp = comparisonOf(test) - return [ - [cmp.left, cmp.right], - [cmp.right, cmp.left], - ...VARIABLES.map((v) => [ref(v), ref(v)]), - ] -} -const shapes = [] -// A ternary, a guard, and a guard with `else` for each branch pair. A ternary -// cannot return nothing, so only guards take an empty branch. -const branched = (test, branches) => { - for (const [then, otherwise] of branches) { - if (then.kind !== `void` && otherwise.kind !== `void`) - shapes.push({ kind: `return`, expr: ternary(test, then, otherwise) }) - shapes.push({ - kind: `guard`, - form: `return`, - test, - then, - rest: otherwise, - }) - shapes.push({ kind: `guard`, form: `else`, test, then, rest: otherwise }) - } +// ## Reach +// +// The states where the laws matter, each derived from the grammar and the +// model: a mutant that only an input outside its annotation distinguishes, +// which must carry the label; an ordering swap beside an impure operand; an +// optional read of an annotated parameter, and of a literal constant; the +// returns that are no site, and a shadowed `undefined`; and each hostile +// program whose inputs distinguish a mutant that the CLI emits. +const REACH = { + label: `a mutant that only an input outside its annotation distinguishes`, + impure: `an ordering swap beside an impure operand that its reads distinguish`, + hostile: (name) => `a hostile shape stays emitted: ${name}`, + chainAnnotated: `an optional read of an annotated parameter that only null reaches, unlabelled`, + chainDead: (lang) => `an optional read of a literal constant in ${lang}`, + returnExcluded: (form) => `a returned ${form} that is no site`, + text: (program) => + `a ${program.family} program the law decides: ${program.name}`, + shadowed: (form) => `a returned undefined that a ${form} shadows`, } -for (const test of tests) { - shapes.push({ kind: `return`, expr: test }) - branched(test, [...branchesOf(test), ...EMPTY_BRANCHES]) +const REQUIRED_STATES = [ + REACH.label, + REACH.impure, + ...ESCAPE_PROGRAMS.map(({ name }) => REACH.hostile(name)), + REACH.chainAnnotated, + ...LANGS.map(REACH.chainDead), + ...[`voided`, `voidRead`, `undefinedName`].map(REACH.returnExcluded), + ...[`shadowed`, `varShadowed`].map(REACH.shadowed), + ...textPrograms.map(REACH.text), +] +// The ordering swaps of a first-family program, each with the model's site. +const orderingSites = (program, expected) => + sourceOf(program) + .sites.map((site, index) => [site, expected.sites[index], index]) + .filter( + ([site]) => + site.change.kind === `flip` && + site.change.node.kind === `cmp` && + ORDERING.includes(site.change.node.op), + ) +function reached(program, trace, expected) { + // A text program decides a law when a site there is distinguished, or + // when it has no site at all (a control). + if (program.kind === `text`) + return expected.sites.length === 0 || + expected.sites.some((site) => site.distinct) + ? [REACH.text(program)] + : [] + if (program.kind === `optional`) { + const chain = expected.sites.find((s) => s.operator === `optional-chain`) + if (program.receiver === `constant`) + return chain.dead ? [REACH.chainDead(program.lang)] : [] + return annotatedChain(program) && + program.width === `present` && + chain.distinct + ? [REACH.chainAnnotated] + : [] + } + if (program.kind === `returned`) { + if ([`shadowed`, `varShadowed`].includes(program.form)) + return [REACH.shadowed(program.form)] + return program.form === `plain` ? [] : [REACH.returnExcluded(program.form)] + } + if (program.kind === `escape`) { + const found = trace.find((o) => o.checkpoint === `instrumented`)?.value + const runs = runEscape(program, program.text) + const detected = (found?.mutants ?? []).some( + (mutant) => + runEscape(program, mutateText(program.text, mutant)).join() !== + runs.join(), + ) + return detected ? [REACH.hostile(program.name)] : [] + } + if (program.kind === `narrowed`) return [] + const states = expected.sites.some((site) => site.label) ? [REACH.label] : [] + const test = program.kind === `guard` ? program.test : program.expr.test + if ( + test?.kind === `logic` && + test.right.left?.name === FREE && + orderingSites(program, expected).some(([, site]) => site.distinct) + ) + states.push(REACH.impure) + return states } -for (const test of compounds) branched(test, compoundBranches(test)) -// Same-variable comparisons, returned as values. -for (const v of VARIABLES) - for (const op of OPS) - shapes.push({ - kind: `return`, - expr: { kind: `cmp`, left: ref(v), op, right: ref(v) }, - }) -// Logical operators: two variables, one variable twice, and the free global. -for (const left of VARIABLES) - for (const right of VARIABLES) - for (const op of LOGICAL) - shapes.push({ kind: `return`, expr: logic(op, ref(left), ref(right)) }) -for (const op of LOGICAL) - shapes.push({ kind: `return`, expr: logic(op, ref(FREE), ref(FREE)) }) -// A clamp: `a > c ? c : a < b ? b : a`, with each test in either order. -for (const outer of comparisonsOf(`a`, `c`)) - for (const inner of comparisonsOf(`a`, `b`)) - shapes.push({ - kind: `return`, - expr: ternary(outer, ref(`c`), ternary(inner, ref(`b`), ref(`a`))), - }) - -// Every shape at each parameter type: the history of the oracle. -// A clamp at type bigint is left out, and is an open family (see the limits): -// at a tie of the outer test, the inner ternary's value is the same as the -// operand's, so the outer swap is undetectable over bigint without a semantic -// argument that no syntactic rule here makes. -const isClamp = (shape) => - shape.kind === `return` && - shape.expr.kind === `cond` && - shape.expr.else.kind === `cond` -const programs = shapes.flatMap((shape) => - TYPES.filter((type) => !(type === `bigint` && isClamp(shape))).map( - (type) => ({ - ...shape, - type, - }), - ), -) // The structural state a program enters, derived from the grammar. function stateOf(program) { + 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`) + return `a ${program.form} return in ${program.lang}, ${program.strictness}` + if (program.kind === `escape`) return `a hostile program: ${program.name}` + if (program.kind === `text`) + return `a ${program.family} program: ${program.name}` + if (program.kind === `narrowed`) + return `a narrowed ${program.subject} subject's ${program.form} at its ${program.width} type, ${program.strictness}` if (program.kind === `return`) { const expr = program.expr if (expr.kind === `cmp`) @@ -369,12 +2917,13 @@ function stateOf(program) { function render(program) { const annotation = ANNOTATION[program.type] let text = `function f(a: ${annotation}, b: ${annotation}, c: ${annotation}) {\n ` + const operandText = (name) => literal(name, program.type) const sites = [] const put = (piece) => { text += piece } const expression = (expr) => { - if (expr.kind === `ref`) return put(literal(expr.name, program.type)) + if (expr.kind === `ref`) return put(operandText(expr.name)) if (expr.kind === `void`) return undefined if (expr.kind === `cond`) { expression(expr.test) @@ -407,14 +2956,25 @@ function render(program) { } // An operand of a negation or a binary: parenthesised unless it is a name. const operand = (expr) => { - if (expr.kind === `ref`) return put(literal(expr.name, program.type)) + if (expr.kind === `ref`) return put(operandText(expr.name)) put(`(`) expression(expr) put(`)`) } + // A returned expression's site: replaced with `undefined`, unevaluated. + // An empty return has none. + const returned = (expr, branch) => { + if (expr.kind !== `void`) + sites.push({ + operator: `return-undefined`, + position: text.length, + change: { kind: `undefine`, branch }, + }) + expression(expr) + } if (program.kind === `return`) { put(`return `) - expression(program.expr) + returned(program.expr, `expr`) put(`;\n}`) } else { put(`if (`) @@ -433,9 +2993,9 @@ function render(program) { ) expression(program.test) put(`) return `) - expression(program.then) + returned(program.then, `then`) put(program.form === `else` ? `;\n else return ` : `;\n return `) - expression(program.rest) + returned(program.rest, `rest`) put(`;\n}`) } return { text, sites } @@ -455,13 +3015,16 @@ const COMPARE = { '!==': (x, y) => x !== y, } -function evaluate(program, input, change) { +function evaluate(program, input, change, counter = { reads: 0 }) { const env = { a: input[0], b: input[1], c: input[2] } let reads = 0 const flipped = (node) => change?.kind === `flip` && change.node === node const value = (expr) => { if (expr.kind === `ref`) { - if (expr.name === FREE) return ++reads + if (expr.name === FREE) { + counter.reads += 1 + return ++reads + } if (/^[01]$/.test(expr.name)) return program.type === `bigint` ? BigInt(expr.name) : Number(expr.name) return env[expr.name] @@ -483,11 +3046,20 @@ function evaluate(program, input, change) { const op = flipped(expr) ? BOUNDARY[expr.op] : expr.op return COMPARE[op](value(expr.left), value(expr.right)) } - if (program.kind === `return`) return value(program.expr) + // A returned expression replaced with `undefined` is still evaluated. + const returned = (expr, branch) => { + const result = value(expr) + return change?.kind === `undefine` && change.branch === branch + ? undefined + : result + } + if (program.kind === `return`) return returned(program.expr, `expr`) // The condition is evaluated even when a change forces the branch. const truth = Boolean(value(program.test)) const forced = change?.kind === `force` && change.node === program - return value((forced ? change.taken : truth) ? program.then : program.rest) + return (forced ? change.taken : truth) + ? returned(program.then, `then`) + : returned(program.rest, `rest`) } // The model's expectation for a program: its source, and for each site whether @@ -495,38 +3067,130 @@ function evaluate(program, input, change) { // (`distinct`), whether no admitted input of any declared type does (`dead`: // the program is redundant, whatever its parameters' types), and whether a // history of 0 and -0 alone does (for an ordering comparison at its own type). +// A program's source and sites, its run on one input, its typed inputs, and +// the declared types its text may take, for either family. +const families = { + // An escape program is judged by its runs as text (`checkEscape`), and has + // no sites of its own. + escape: { + render: (program) => ({ text: program.text, sites: [] }), + domain: () => [], + }, + narrowed: { + render: renderNarrowed, + evaluate: evaluateNarrowed, + domain: (program) => narrowedDomain(program), + }, + optional: { + render: renderChain, + evaluate: evaluateChain, + domain: chainDomain, + }, + returned: { + render: renderReturned, + evaluate: evaluateReturned, + domain: returnDomain, + }, + // A text program's model is its settled runs (`textModel`). + text: { + render: renderText, + domain: textDomain, + typedDomain: textTypedDomain, + }, +} +const familyOf = (program) => families[program.kind] +const sourceOf = (program) => (familyOf(program)?.render ?? render)(program) +const run = (program, input, change, counter) => + (familyOf(program)?.evaluate ?? evaluate)(program, input, change, counter) +// Whether a change shows on one input: in the value, or in the reads of `g` +// and the coercions of an object (two functions are equal only when they +// make the same reads). +const shows = (program, input, change) => { + const [mutant, original] = [change, null].map((c) => { + const counter = { reads: 0 } + const before = effects.count + const result = run(program, input, c, counter) + return [result, counter.reads + effects.count - before] + }) + return !Object.is(mutant[0], original[0]) || mutant[1] !== original[1] +} +// What JavaScript allows, and what the declared types allow where the label +// law applies (the first family's annotations, and an annotated receiver). +const domainOf = (program) => + familyOf(program)?.domain(program) ?? JS_DOMAIN[program.type] +// The first family's declared inputs admit `null` and `undefined` too: the +// CLI does not know whether the package has strict null checks, so a label +// holds only where it holds without them (the eighth review). +const LOOSE_DOMAIN = Object.fromEntries( + TYPES.map((t) => [t, cube([...VALUES[t], null, undefined])]), +) +const typedDomainOf = (program) => + familyOf(program) + ? familyOf(program).typedDomain?.(program) + : LOOSE_DOMAIN[program.type] +const zeroDomainOf = (program) => + familyOf(program) + ? domainOf(program).filter(([, a]) => a === 0) + : ZERO_DOMAIN[program.type] + +// A digest of a mutant's runs over the domain, each its result and its reads +// of `g`, for the refinement of a `return-undefined` site: its mutant must +// keep the returned expression's effects, as the model's does. +function digest(runs) { + let hash = 2166136261 + for (const char of runs.map((r) => `${shown(r.result)}:${r.reads}`).join(` `)) + hash = Math.imul(hash ^ char.charCodeAt(0), 16777619) >>> 0 + return hash.toString(16) +} +function modelRuns(program, change) { + return digest( + domainOf(program).map((input) => { + const counter = { reads: 0 } + const before = effects.count + const result = (familyOf(program)?.evaluate ?? evaluate)( + program, + input, + change, + counter, + ) + return { result, reads: counter.reads + effects.count - before } + }), + ) +} + function model(program) { - const { text, sites } = render(program) - const distinctAt = (type, index) => { - const copy = { ...program, type } - const change = render(copy).sites[index].change - return DOMAIN[type].some( - (input) => - !Object.is(evaluate(copy, input, change), evaluate(copy, input, null)), - ) + if (program.kind === `escape`) return { source: program.text, sites: [] } + if (program.kind === `text`) return textModel(program) + const { text, sites } = sourceOf(program) + const distinctIn = (copy, index) => { + const change = sourceOf(copy).sites[index].change + return domainOf(copy).some((input) => shows(copy, input, change)) } return { source: text, sites: sites.map((site, index) => { - const distinct = distinctAt(program.type, index) + const distinct = distinctIn(program, index) const differs = (domain, change) => - domain.some( - (input) => - !Object.is( - evaluate(program, input, change), - evaluate(program, input, null), - ), - ) + domain.some((input) => shows(program, input, change)) + // The label law: no input of the declared types distinguishes the + // mutant, and an input that JavaScript allows does. + const typed = typedDomainOf(program) return { operator: site.operator, position: site.position, distinct, - dead: TYPES.every((type) => !distinctAt(type, index)), + label: typed !== undefined && distinct && !differs(typed, site.change), + // Dead code: no input that JavaScript allows distinguishes it, in + // this program's own text. + dead: !distinct, zeroDistinct: site.change.kind === `flip` && site.change.node.kind === `cmp` && ORDERING.includes(site.change.node.op) && - differs(ZERO_DOMAIN[program.type], site.change), + differs(zeroDomainOf(program), site.change), + ...(site.operator === `return-undefined` + ? { runs: modelRuns(program, site.change) } + : {}), } }), } @@ -556,6 +3220,12 @@ function switchFor(active) { b: (id, value) => (id === active ? !value : value), cond: (ifTrue, ifFalse, value) => active === ifTrue ? true : active === ifFalse ? false : value, + // A plain access in place of `?.`: it throws on a nullish object. + chain: (id, value) => { + if (id === active && (value === undefined || value === null)) + throw new TypeError(`Cannot read properties of ${value}`) + return value + }, } } @@ -570,8 +3240,23 @@ function runAll(f, domain) { try { return domain.map((input) => { globalReads = 0 - const result = f(...input) - return { result, reads: globalReads } + const before = effects.count + // A run may change an object it is given, so each gets a copy (but + // the coerced object, which no run changes, and whose identity a + // result keeps); one that throws records the error's name. + let result + try { + result = f( + ...input.map((value) => + typeof value === `object` && value !== null && value !== VALUED + ? { ...value } + : value, + ), + ) + } catch (error) { + result = `threw ${errorName(error)}` + } + return { result, reads: globalReads + effects.count - before } }) } finally { delete globalThis[FREE] @@ -591,23 +3276,150 @@ function runsUnder(code, active, domain) { } } +// An escape program's checker has the real standard library (it uses +// iterables, `Symbol.hasInstance`, and a Proxy), under strict null checks. +const escapeOptions = { + noEmit: true, + strict: true, + types: [], + target: ts.ScriptTarget.ESNext, + lib: [`lib.esnext.d.ts`], +} +const escapeHost = ts.createCompilerHost(escapeOptions) +const escapeLibs = new Map() +function escapeTyped(path, text, strict) { + const options = { ...escapeOptions, strictNullChecks: strict, allowJs: true } + const file = ts.createSourceFile(path, text, ts.ScriptTarget.ESNext, true) + const base = ts.createCompilerHost(options) + const host = { + ...escapeHost, + getSourceFile: (name, language) => { + if (name === path) return file + if (!escapeLibs.has(name)) + escapeLibs.set(name, base.getSourceFile(name, language)) + return escapeLibs.get(name) + }, + fileExists: (name) => name === path || base.fileExists(name), + } + const program = ts.createProgram([path], options, host) + return { file, checker: program.getTypeChecker(), strict } +} +// The types of an escape program erased: every type in a runtime position +// becomes `any`, and type arguments go. Types alone, without the runtime +// code (an enum, a class, a heritage clause), change. +function eraseTypes(text) { + const file = ts.createSourceFile( + `/erase.ts`, + text, + ts.ScriptTarget.ESNext, + true, + ) + const any = () => ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword) + const visit = (context) => (node) => { + if (ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node)) + return node + if (ts.isHeritageClause(node)) return node + if (ts.isTypeNode(node) && !ts.isExpressionWithTypeArguments(node)) + return any() + if (ts.isCallExpression(node) && node.typeArguments) + return ts.visitEachChild( + ts.factory.updateCallExpression( + node, + node.expression, + undefined, + node.arguments, + ), + visit(context), + context, + ) + return ts.visitEachChild(node, visit(context), context) + } + const [erased] = ts.transform(file, [visit]).transformed + return ts.createPrinter().printFile(erased) +} +// The escape driver instruments the text, and the text with its types +// erased, and records both mutant lists. +function escapeDriver(program, observe) { + const path = `/mutant-validity-escape.${program.ext}` + const of = (text) => + instrument(ts, path, text, `src/escape.${program.ext}`).mutants.map( + ({ operator, position, original, replacement, evidence }) => ({ + operator, + position, + original, + replacement, + ...(evidence ? { evidence } : {}), + }), + ) + observe(`instrumented`, { + mutants: of(program.text), + erased: of(program.ext === `js` ? program.text : eraseTypes(program.text)), + }) +} + +// A text program's driver awaits each run's settling, as its model does. +async function textDriver(program, observe) { + const domain = domainOf(program) + const original = await settledRuns(loadText(program.text), domain) + const ext = program.lang === `js` ? `js` : `ts` + const path = `/mutant-validity.${ext}` + const { code, mutants } = instrument( + ts, + path, + program.text, + `src/mutant-validity.${ext}`, + ) + const under = async (active) => { + const previous = globalThis.__oracleGaps + globalThis.__oracleGaps = switchFor(active) + try { + return await settledRuns(load(code), domainOf(program)) + } finally { + globalThis.__oracleGaps = previous + } + } + const same = (runs) => runs.every((run, i) => sameRun(run, original[i])) + const clean = same(await under(null)) + const emitted = [] + for (const mutant of mutants) { + const runs = await under(mutant.number) + emitted.push({ + operator: mutant.operator, + position: mutant.position, + differs: !same(runs), + evidence: mutant.evidence, + ...(mutant.outside ? { outside: mutant.outside } : {}), + ...(mutant.operator === `return-undefined` ? { runs: digest(runs) } : {}), + }) + } + observe(`instrumented`, { clean, emitted }) +} function driver(program, observe) { - const { text } = render(program) - const domain = DOMAIN[program.type] + if (program.kind === `escape`) return escapeDriver(program, observe) + if (program.kind === `text`) return textDriver(program, observe) + const { text } = sourceOf(program) + const domain = domainOf(program) const original = runAll(load(text), domain) + const ext = program.lang === `js` ? `js` : `ts` + const path = `/mutant-validity.${ext}` const { code, mutants } = instrument( ts, - `mutant-validity.ts`, + path, text, - `src/mutant-validity.ts`, + `src/mutant-validity.${ext}`, ) const clean = sameRuns(runsUnder(code, null, domain), original) - const emitted = mutants.map((mutant) => ({ - operator: mutant.operator, - position: mutant.position, - differs: !sameRuns(runsUnder(code, mutant.number, domain), original), - evidence: mutant.evidence, - })) + const emitted = mutants.map((mutant) => { + const runs = runsUnder(code, mutant.number, domain) + return { + operator: mutant.operator, + position: mutant.position, + differs: !sameRuns(runs, original), + evidence: mutant.evidence, + ...(mutant.outside ? { outside: mutant.outside } : {}), + ...(mutant.operator === `return-undefined` ? { runs: digest(runs) } : {}), + } + }) observe(`instrumented`, { clean, emitted }) } @@ -616,10 +3428,81 @@ const keyOf = (site) => `${site.operator}@${site.position}` const DEAD = `dead code` const BOUNDARY_EVIDENCE = `boundary: -0` +// A hostile program's laws. Types change nothing the CLI emits: its sites +// and dead-code marks are the same with the types erased (compared as +// operators and marks, since erasing moves positions). A dead-code mark +// holds: the mutant, applied as text, equals the program on every input. +// And a returned `undefined` that a declaration binds, by the oracle's own +// checker, is a site. +const markOf = (m) => `${m.operator}${m.evidence === DEAD ? ` (dead)` : ``}` +const tally = (mutants) => mutants.map(markOf).sort().join(`, `) +function checkEscape(program, found) { + if (tally(found.mutants) !== tally(found.erased)) + return violation( + `instrumented`, + `erasing the types of ${program.name} changes its sites: ${tally(found.mutants)} against ${tally(found.erased)}`, + LAW_TYPELESS, + ) + const runs = runEscape(program, program.text) + for (const mutant of found.mutants) { + if (mutant.evidence !== DEAD) continue + const mutated = runEscape(program, mutateText(program.text, mutant)) + if (mutated.join() !== runs.join()) + return violation( + `instrumented`, + `marks ${keyOf(mutant)} of ${program.name} dead code, but an input distinguishes it: ${runs.join(` `)} against ${mutated.join(` `)}`, + LAW_DEAD, + ) + } + const { file, checker } = escapeTyped( + `/mutant-validity-escape.${program.ext}`, + program.text, + true, + ) + // Every return of a value is a site, but `return void x` and a return of + // the global `undefined`, which the oracle's own checker resolves to no + // declaration (and only where the file has no direct `eval` and no + // `with`, which can bind it at run time). + const sites = new Set(found.mutants.map(keyOf)) + const dynamic = /\beval\s*\(|\bwith\b/.test(program.text) + const owed = [] + const visit = (node) => { + if (ts.isReturnStatement(node) && node.expression) { + const value = node.expression + const global = + ts.isIdentifier(value) && + value.text === `undefined` && + !dynamic && + (checker.getSymbolAtLocation(value)?.declarations?.length ?? 0) === 0 + if (!ts.isVoidExpression(value) && !global) + owed.push(value.getStart(file)) + } + ts.forEachChild(node, visit) + } + visit(file) + const missed = owed.find( + (position) => !sites.has(`return-undefined@${position}`), + ) + if (missed !== undefined) + return violation( + `instrumented`, + `leaves out the returned value at offset ${missed} of ${program.name}, which is no exact equivalent of its mutant`, + LAW_2, + ) + return pass +} + function check(program, observations, expected) { const found = observations.find((o) => o.checkpoint === `instrumented`)?.value if (!found) return violation(`instrumented`, `nothing was instrumented`, LAW_INERT) + if (program.kind === `escape`) return checkEscape(program, found) + if (expected.unsettled) + return violation( + `instrumented`, + `a run of ${JSON.stringify(expected.source)} or of one of its mutants did not settle within ${SETTLE_LIMIT} ms`, + LAW_INERT, + ) if (!found.clean) return violation( `instrumented`, @@ -630,32 +3513,25 @@ function check(program, observations, expected) { for (const site of expected.sites) { const where = `${site.operator} at offset ${site.position} of ${JSON.stringify(expected.source)}` const mutant = emitted.get(keyOf(site)) - if (!mutant) { - if (site.distinct) - return violation( - `instrumented`, - `skipped ${where}, but the model finds an input that distinguishes it`, - LAW_2, - ) - if (site.dead) - return violation( - `instrumented`, - `skipped ${where}, which no declared type can distinguish: it is dead code, not a skip`, - LAW_DEAD, - ) - continue - } + if (!mutant) + return violation( + `instrumented`, + site.distinct + ? `skipped ${where}, but the model finds an input that distinguishes it` + : `skipped ${where}: no site of the contract is skipped, and an undistinguished one is dead code`, + LAW_2, + ) const marked = mutant.evidence === DEAD if (!marked && !site.distinct) return violation( `instrumented`, - `emitted ${where}, which equals the original on every input of its type, and is not marked dead code`, + `emitted ${where}, which equals the original on every input, and is not marked dead code`, LAW_1, ) if (marked !== site.dead) return violation( `instrumented`, - `${marked ? `marked dead code` : `left unmarked`} ${where}, but ${site.dead ? `no declared type can distinguish it` : `some declared type can distinguish it`}`, + `${marked ? `marked dead code` : `left unmarked`} ${where}, but ${site.dead ? `no input distinguishes it` : `an input distinguishes it`}`, LAW_DEAD, ) if (mutant.differs !== site.distinct) @@ -664,6 +3540,24 @@ function check(program, observations, expected) { `${marked ? `marked dead code` : `emitted`} ${where}: its run ${mutant.differs ? `differs` : `matches`} the original, but the model says ${site.distinct ? `it is distinguishable` : `it is not`}`, LAW_REFINE, ) + if (site.runs !== undefined && mutant.runs !== site.runs) + return violation( + `instrumented`, + `emitted ${where}: its runs and reads differ from the model's, which evaluates the returned expression and then returns undefined`, + LAW_REFINE, + ) + if (site.label && !mutant.outside) + return violation( + `instrumented`, + `emitted ${where} without the outside-annotation label, though no input of its declared types distinguishes it`, + LAW_LABEL, + ) + if (!site.label && mutant.outside) + return violation( + `instrumented`, + `labels ${where} outside-annotation (${mutant.outside}), though no annotation excludes every input that distinguishes it`, + LAW_LABEL, + ) if (mutant.evidence === BOUNDARY_EVIDENCE && !site.zeroDistinct) return violation( `instrumented`, @@ -685,7 +3579,10 @@ function check(program, observations, expected) { // Five calibrations, each a wrong answer the check must reject. const observed = (trace) => { const index = trace.findIndex((o) => o.checkpoint === `instrumented`) - return index < 0 ? undefined : { index, value: trace[index].value } + // An escape program's observation has no emitted list to corrupt. + return index < 0 || !trace[index].value.emitted + ? undefined + : { index, value: trace[index].value } } const withEmitted = (trace, index, value, emitted) => trace.map((o, i) => @@ -769,14 +3666,15 @@ const calibrations = [ oracleProperty({ id: `oracle-gaps.report-mutant-validity`, law: { - statement: `Over the admitted domain of each declared parameter type (number: -1, 0, -0, 1, 2, NaN; bigint: -1n, 0n, 1n, 2n; string | number: the numbers and the strings '1' and '2'; in three parameters, with the free global g counting its reads), every emitted mutant of a grammar program that is not marked dead code differs from the program on some input (law 1). Every site the instrumenter skips equals the program on every input (law 2). A site is marked dead code exactly when no input distinguishes it, and no site is skipped as undetectable. Every emitted mutant's run agrees with the model. A boundary hint sits only on a site that a history of 0 and -0 distinguishes.`, - authority: `The frozen design v20's maintainer decision of 2026-11-10 that anything an oracle can't possibly detect is an invalid mutant (freeze.md, "Undetectable mutants are invalid"), and its decision 5, that an equivalent is a framework bug and not an answer. The domain is the framework's data domain, which counts -0 apart from 0 and compares strings with coercion (maintainer, 2026-11-10, relayed by the coordinator). The dead-code and boundary evidence follow the coordinator's revision and the review of PR #8.`, - limits: `A finite domain of eight values per variable and one free name. A small grammar, without property reads, calls, getters beyond g, or equal branches with a non-operand expression (\`a + 0\`). Dead code is judged on grammar programs, and tagged only under a pure condition. Logical operators on literals are not tagged. The boundary hint is one-directional, and is given only where both operands are variables or the literal 0; a nonzero tie, such as \`n > 10 ? 10 : n\`, carries none, because within numbers that mutant is undetectable and only a coerced string detects it. Whether the domain should come from the TypeScript types is a design question, not settled here. A skipped site is judged by the model, not by a run of the emitter.`, + statement: `No mutant is skipped or marked dead code on the basis of types (maintainer decision, 2026-11-11). Over the inputs JavaScript allows (each declared type's values, and foreign ties: a string beside the number of its digits, null beside a zero, a number beside a bigint, -0 beside 0, and an object whose valueOf has an effect; with the free global g counting its reads), every emitted mutant that is not marked dead code differs from the program on some input (law 1), no site of the operator contract is skipped (law 2), and a site is marked dead code exactly when no input distinguishes it. A mutant carries the outside-annotation label exactly when an annotation excludes every input that distinguishes it, in the first family and the coerced programs, and carries none elsewhere. A return-undefined mutant's runs and reads equal the model's, which evaluates the returned expression and then returns undefined. Erasing a hostile program's types changes none of its sites and no dead-code mark, every dead-code mark there holds on its inputs, and a returned undefined that a declaration binds is a site. Every emitted mutant's run agrees with the model, and a boundary hint sits only on a site that a history of 0 and -0 distinguishes.`, + authority: `The maintainer's decision of 2026-11-11, relayed by the coordinator after the fifth soundness review: the gap CLI never skips a mutant, and never marks it dead code, on the basis of types; a mutant the type analysis judges undetectable by inputs of the declared types is a survivor in its own class, whose directive names the distinguishing input; an argument typed any counts as an input the types allow. The frozen design v20's decision of 2026-11-10 that anything an oracle can't possibly detect is an invalid mutant (freeze.md, "Undetectable mutants are invalid"). The dead-code and boundary evidence follow the coordinator's revision and the review of PR #8. The F17 operators follow the coordinator's F17 brief (2026-11-10).`, + limits: `A finite domain: each type's values, a fixed set of foreign ties, and one free name. A small grammar, without property reads, calls, getters beyond g, or equal branches with a non-operand expression. A clamp at type bigint is left out. Dead code is tagged only under a pure condition where no ordering comparison runs or not because of the site. The label law requires a label in the first family and the coerced programs, and forbids one everywhere else; the narrowed family's narrowings and the optional chains are not labelled. The sixth review's dimensions are small generated programs judged by running their text: a dead-zone read in a parameter default or a loop head, each binding form of \`undefined\`, a sloppy script with or without a direct \`eval\` or a \`with\`, a returned call, and a coerced comparison at five annotations. The optional chains read one property of a parameter, a local, a wrapper's property, or a literal constant. The returned values are one return of a name, \`void a\`, \`void g\`, or \`undefined\`. Only a return-undefined site's runs are compared in full with the model's. The hostile programs are fixed texts with chosen inputs, compared with their own text with the types erased, as operators and marks; their dead-code marks are judged on their inputs only. Its mutants are applied as text, and an optional chain's \`?.\` is found as the first after the access's start, which a nested chain would misplace. The settled comparison of a text program has blind spots, none of which reaches a defect in today's grammar (the ninth review): an iterator is compared for at most eight steps; only \`next()\` is driven, never \`return()\`, \`throw()\`, or a \`finally\`; a rejection or a throw compares by its error's name, not its message, and a thrown value that is no error by its kind alone, so \`throw 1\` and \`throw 2\` are alike; a fulfilled object compares by its kind; a thenable is awaited as a promise is; and a rejection that floats unhandled is not compared. A run that does not settle within two seconds fails its test.`, }, // Enumerated: every program of the grammar is one history, in order. - histories: programs, - label: (program) => `${stateOf(program)}: ${render(program).text}`, + histories: { enumerated: histories, states: REQUIRED_STATES }, + label: (program) => `${stateOf(program)}: ${sourceOf(program).text}`, model, + reached, driver, check, calibrations, 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 87f4e0c..eabe3f4 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -17,8 +17,11 @@ * `!`, `bool-flip` for a boolean literal outside a type, * `value-undefined` for a call whose value is used (not a callee or the * object of a property or element access), `loop-false` for the - * condition of a `for`, `while`, or `do` loop, and `delete-call` for a - * call statement. Each names its function and its line: a class's + * condition of a `for`, `while`, or `do` loop, `delete-call` for a + * call statement, `return-undefined` for a returned expression that is + * not a call (value-undefined covers one) and not already `undefined`, + * and `optional-chain` for a property or element read through `?.` + * whose object may be nullish (F17). Each names its function and its line: a class's * method, accessor, or field function by `Class.member`, any other * function by its own name (an object literal's method by its property), * a function exported with no name of its own by the name an import @@ -156,7 +159,8 @@ * operators agreed on every operand an owner test reached, a * `not-distinguished` when its operator changes a value only on some * inputs and no capture shows which (a loop's condition, an undefined call - * value, a dropped field, a skipped call, a logical operator), and + * value, a dropped field, a skipped call, a logical operator, a returned + * value made undefined, a plain read in place of `?.`), and * `not-observed` otherwise: the mutant changed a value that no recorded * observation showed. A test in a file that is not an * oracle file never changes any of this, and runs only when the run asks @@ -641,7 +645,7 @@ function namedScratch(text, names) { // A gap line the CLI streams to stderr as its mutant is judged (stream.mjs): // its class, its line, and its id. const streamedGap = - /^(?:not-judged|indeterminate|dead-code|branch-never-taken|not-infected|not-observed|not-distinguished|unreached|random-only-kill|agreement-only-kill|unregistered-kill|framework-error|timeout|setup-error|crashed|does-not-apply) L(?:\d+|\?) (\S+) / + /^(?:not-judged|indeterminate|dead-code|outside-annotation|branch-never-taken|not-infected|not-observed|not-distinguished|unreached|random-only-kill|agreement-only-kill|unregistered-kill|framework-error|timeout|setup-error|crashed|does-not-apply) L(?:\d+|\?) (\S+) / function readRun(dir, out, status, stderr, cli) { // The output names the CLI directory that ran and the package, as scratch @@ -1254,6 +1258,10 @@ async function driver(plan, observe) { states.add(`a loop that never runs killed`) if (m.operator === `field-drop` && m.outcome === `killed`) states.add(`a dropped field killed`) + if (m.operator === `return-undefined` && m.class === `not-distinguished`) + 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.operator === `arithmetic-swap` && m.outcome === `killed`) states.add(`an arithmetic swap killed`) if (m.function === `w_leaf` && m.outcome !== `unowned`) @@ -1937,6 +1945,8 @@ const required = [ `a kill through state an earlier test stored`, `a mutant of code run at module load killed`, `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 module-level cache judged afresh`, `ordinary tests run when asked`, `ordinary tests left out by default`, diff --git a/packages/oracles/oracle-gaps/index.mjs b/packages/oracles/oracle-gaps/index.mjs index 827bae5..b37d85c 100755 --- a/packages/oracles/oracle-gaps/index.mjs +++ b/packages/oracles/oracle-gaps/index.mjs @@ -27,7 +27,7 @@ // warm Vitest process, and classifies it (see `outcomeOf`): `killed` only // by a law verdict on a reproducible route; otherwise a gap. A mutant in a // function no oracle owns is `unowned`, a warning. Survivors are sorted -// into `not-judged`, `indeterminate`, `dead-code`, `branch-never-taken`, +// into `not-judged`, `indeterminate`, `dead-code`, `outside-annotation`, `branch-never-taken`, // `not-infected`, and `not-observed` (classify.mjs). // 5. With --ordinary, runs a survivor against the ordinary tests that reach // it, for information only: an ordinary test never closes a gap. Without diff --git a/packages/oracles/oracle-gaps/report.mjs b/packages/oracles/oracle-gaps/report.mjs index d83b75b..3e4f280 100644 --- a/packages/oracles/oracle-gaps/report.mjs +++ b/packages/oracles/oracle-gaps/report.mjs @@ -110,6 +110,8 @@ const directive = { ? `Make \`${g.nondeterministicBy.map(testName).join(`, `)}\` deterministic: control its clock and its random seed, so its failures judge this code.` : `Add an owner test that runs line ${g.line} and asserts on its result.`, 'dead-code': (g) => `Remove the redundant code on line ${g.line}.`, + 'outside-annotation': (g) => + `Add an owner history that reaches line ${g.line} with ${g.input}, which the annotation excludes, so that it changes an asserted result, and assert on the result.`, 'random-only-kill': (g) => `Keep the counterexample that \`${first(g.by)}\` found in the random campaign as a fixed history.`, 'agreement-only-kill': (g) => @@ -140,6 +142,10 @@ function distinguishingDirective(g) { return `Add an owner history that reads the property dropped on line ${line}, so that its absence changes an asserted result, and assert on the result.` if (g.operator === `delete-call`) return `Add an owner history where the call on line ${line} changes an asserted result when it does not run, and assert on the result.` + if (g.operator === `return-undefined`) + return `Add an owner history that uses the value returned on line ${line}, so that \`undefined\` in its place changes an asserted result, and assert on the result.` + if (g.operator === `optional-chain`) + return `Add an owner history where the object read with \`?.\` on line ${line} is \`undefined\`, so that a plain read throws, and assert on the result.` if (g.original === `??`) return `Add an owner history where the left operand of \`??\` on line ${line} is truthy, so \`??\` and \`&&\` give different results, and assert on the result.` if (g.original === `&&` || g.original === `||`) @@ -168,6 +174,7 @@ const placeholders = { by: [``], detail: ``, operator: ``, + input: ``, } const legendOf = (kind) => directive[kind]({ ...placeholders, outcome: kind }) diff --git a/packages/oracles/oracle-gaps/schemata.mjs b/packages/oracles/oracle-gaps/schemata.mjs index 93fd32f..525d53e 100644 --- a/packages/oracles/oracle-gaps/schemata.mjs +++ b/packages/oracles/oracle-gaps/schemata.mjs @@ -28,8 +28,19 @@ // - `loop-false`: force the condition of a `for`, `while`, or `do` loop to // `false` (a `for` with no condition, `for...of`, and `for...in` have none). // - `delete-call`: skip a call statement (`switch || call()`). +// - `return-undefined`: a returned expression is evaluated, and `undefined` +// is returned in its place. A returned call or `import()` is a site too: +// a test that checks only a call's effect kills `value-undefined`, never +// this. Not `return undefined` or `return void x`, which already return +// `undefined` after the same evaluation. A type never removes a site. +// - `optional-chain`: `a?.b` → `a.b` and `a?.[k]` → `a[k]`, which throw where +// `a` is nullish (an optional call `f?.()` is no site). // // No site lies in a derived constructor's super region (see `superGuarded`). +// Types never skip a site or mark it dead (maintainer decision, 2026-11-11). +// A site that no input of its parameters' annotations distinguishes carries +// `outside`, the input class outside them that does (see `outsideOf`), and +// its survivor is reported as `outside-annotation`. // // In a Svelte module (`.svelte.ts` or `.svelte.js`), a rune call that Svelte // allows in one place only (`$state`, `$derived`, `$effect`, `$props`, @@ -178,8 +189,8 @@ export function instrument(ts, path, text, source, offset = 0) { // `at` is the node whose line the mutant reports: the operator token for a // swap and the condition for an `if`, as the earlier runner reported them. // `evidence` is a hint for the report, and never changes whether a site is - // emitted: see `evidenceOf`. - const add = (at, operator, original, replacement, evidence) => { + // emitted: see `evidenceOf`, and neither does `outside`. + const add = (at, operator, original, replacement, evidence, outside) => { if (mentionsSuper && superGuarded(at)) return undefined const probeGuard = guarded const local = mutants.length + 1 @@ -198,6 +209,7 @@ export function instrument(ts, path, text, source, offset = 0) { anchor: anchorOf(ts, file, at), ...(probeGuard ? { probeGuard: true } : {}), ...(evidence ? { evidence } : {}), + ...(outside ? { outside } : {}), }) return id } @@ -359,16 +371,11 @@ export function instrument(ts, path, text, source, offset = 0) { ) return false const guard = bare(expression.left) - const found = ts.isIdentifier(guard) && declarationOf(guard, true) + const found = ts.isIdentifier(guard) ? bindingOf(guard) : undefined return ( guard.text === `probing` && - found !== undefined && - ts.isVariableStatement(found) && - isGuardDeclarator( - found.declarationList.declarations.find((d) => - bindsName(d.name, `probing`), - ), - ) && + isGuardDeclarator(found) && + functionOf(found) === functionOf(guard) && isHook(expression.right) ) } @@ -383,7 +390,7 @@ export function instrument(ts, path, text, source, offset = 0) { return ( target !== undefined && ts.isIdentifier(target) && - helpers.has(declarationOf(target)) + helpers.has(bindingOf(target)) ) } const isProbeOnly = (node) => @@ -396,62 +403,50 @@ export function instrument(ts, path, text, source, offset = 0) { (ts.isBlock(node) && node.statements.length > 0 && node.statements.every(isProbeOnly)) - // Whether a binding name (an identifier, or a destructuring pattern) - // binds `name`. - const bindsName = (binding, name) => - ts.isIdentifier(binding) - ? binding.text === name - : (ts.isObjectBindingPattern(binding) || - ts.isArrayBindingPattern(binding)) && - binding.elements.some( - (element) => - !ts.isOmittedExpression(element) && bindsName(element.name, name), - ) - const declaresName = (statement, name) => - (ts.isVariableStatement(statement) && - statement.declarationList.declarations.some((d) => - bindsName(d.name, name), - )) || - // An overload signature names the function its implementation declares. - (((ts.isFunctionDeclaration(statement) && statement.body !== undefined) || - ts.isClassDeclaration(statement)) && - statement.name?.text === name) - // What declares `identifier`'s name, found from the innermost scope - // outward as JavaScript resolves it: a statement (a variable, function, or - // class declaration; case clauses share their switch's scope), the loop - // statement or catch clause that binds it, or a parameter; undefined when - // nothing in the file declares it. `withinFunction` stops at the nearest - // function, returning its parameter of that name or undefined. - function declarationOf(identifier, withinFunction = false) { - const name = identifier.text - for (let node = identifier.parent; node; node = node.parent) { - const statements = ts.isCaseBlock(node) - ? node.clauses.flatMap((clause) => clause.statements) - : ts.isBlock(node) || ts.isSourceFile(node) || ts.isModuleBlock(node) - ? node.statements - : [] - const found = statements.find((statement) => - declaresName(statement, name), - ) - if (found) return found - // A loop's own variables, and a caught error. - const own = - (ts.isForStatement(node) || - ts.isForOfStatement(node) || - ts.isForInStatement(node)) && - node.initializer !== undefined && - ts.isVariableDeclarationList(node.initializer) - ? node.initializer.declarations.some((d) => bindsName(d.name, name)) - : ts.isCatchClause(node) && - node.variableDeclaration !== undefined && - bindsName(node.variableDeclaration.name, name) - if (own) return node - if (ts.isFunctionLike(node)) { - const parameter = node.parameters?.find((p) => bindsName(p.name, name)) - if (parameter || withinFunction) return parameter + // Scope questions come from TypeScript's binder: which declaration a name + // resolves to, through every scope boundary JavaScript has (hoisting, + // blocks, static blocks, catch clauses, patterns, imports). Only symbols + // and declarations are read, never types (maintainer decisions, + // 2026-11-11). The program holds this one file, so it binds it alone. + let checker + const symbolOf = (identifier) => { + if (checker === undefined) { + const named = (name) => ts.normalizePath(name) === file.fileName + const host = { + getSourceFile: (name) => (named(name) ? file : undefined), + fileExists: named, + readFile: () => undefined, + writeFile: () => undefined, + getDefaultLibFileName: () => `lib.d.ts`, + getCurrentDirectory: () => `/`, + getCanonicalFileName: (name) => name, + useCaseSensitiveFileNames: () => true, + getNewLine: () => `\n`, } + checker = ts + .createProgram( + [file.fileName], + { noLib: true, noResolve: true, allowJs: true, noEmit: true }, + host, + ) + .getTypeChecker() } - return undefined + return checker.getSymbolAtLocation(identifier) + } + // The declaration a name resolves to: a function's implementation over its + // overload signatures, and otherwise its value's declaration. + const bindingOf = (identifier) => { + const symbol = symbolOf(identifier) + return ( + symbol?.declarations?.find((d) => ts.isFunctionLike(d) && d.body) ?? + symbol?.valueDeclaration ?? + symbol?.declarations?.[0] + ) + } + const functionOf = (node) => { + let n = node.parent + while (n && !ts.isFunctionLike(n)) n = n.parent + return n } const declarations = [] const collect = (node) => { @@ -488,22 +483,43 @@ export function instrument(ts, path, text, source, offset = 0) { ? inner.getText(file) : undefined } - // The type an operand admits, read from its declaration: `number` for a number - // literal or a parameter annotated `number`, `bigint` likewise, and `other` for - // anything else (a union with a string, `any`, `unknown`, or no annotation). - // The annotation is the parameter's own syntax: `instrument` takes the text - // alone, so no checker program is available here. + // What a name's own annotation declares, for the `outside` label only: the + // annotation of the parameter that binds it, when it is a plain name, in a + // TypeScript file. Any unsoundness here mislabels a survivor; it never + // hides one (maintainer decision, 2026-11-11). + const typeScript = /\.[cm]?tsx?$/.test(path) + const annotationOf = (expression) => { + const inner = bare(expression) + if (!typeScript || !ts.isIdentifier(inner)) return undefined + const found = bindingOf(inner) + return found && ts.isParameter(found) && ts.isIdentifier(found.name) + ? found.type + : undefined + } + // The type an operand admits: `number` for a number literal or a name + // annotated `number`, `bigint` likewise, and `other` for anything else. const typeOf = (expression) => { const inner = bare(expression) if (ts.isNumericLiteral(inner)) return `number` if (ts.isBigIntLiteral(inner)) return `bigint` - if (!ts.isIdentifier(inner)) return `other` - const found = declarationOf(inner) - if (!found || !ts.isParameter(found) || !found.type) return `other` - if (found.type.kind === ts.SyntaxKind.NumberKeyword) return `number` - if (found.type.kind === ts.SyntaxKind.BigIntKeyword) return `bigint` + const kind = annotationOf(inner)?.kind + if (kind === ts.SyntaxKind.NumberKeyword) return `number` + if (kind === ts.SyntaxKind.BigIntKeyword) return `bigint` return `other` } + // A literal's value, read as JavaScript reads it: `0x0`, `0.0`, `0b0`, and + // `0x0n` are zeros. Undefined for anything else, or a literal it cannot + // read. + const literalValue = (expression) => { + const inner = bare(expression) + try { + if (ts.isNumericLiteral(inner)) return Number(inner.text) + if (ts.isBigIntLiteral(inner)) return BigInt(inner.text.slice(0, -1)) + } catch { + return undefined + } + return undefined + } // The operand text a branch returns: the value of a `return` statement, or // of a block of one such statement. An empty `return` reads `void`, which no // operand text equals. @@ -549,28 +565,50 @@ export function instrument(ts, path, text, source, offset = 0) { ? holder : undefined } - // A local binding: a parameter, or a `let`, `const`, `var`, or loop or catch - // variable of an enclosing function, with no `with` statement between the - // name and its binding. A global, a module-level name, or a name in a `with` - // body can read an accessor, so reading it twice is not reading it once. - const enclosedByFunction = (node) => { - for (let n = node.parent; n; n = n.parent) - if (ts.isFunctionLike(n)) return true - return false + // A local binding: a name that resolves to a parameter, a variable, or a + // caught error of a function (a binding element of one included). The + // binder resolves no name inside a `with`, which reads names through an + // object, so a name there is no local binding. A global or a + // module-level name can read an accessor, so reading it twice is not + // reading it once. Dead zones, one rule: a parameter, a caught error, a + // `let`, or a `const` read before its declaration ends (inside its own + // initializer or pattern, or before it) is in its dead zone, where a read + // throws. A `let` or `const` read from another function, which may run + // before the declaration, or from a `switch` case past it, may be too. A + // `var` has no dead zone. + const root = (declaration) => { + let n = declaration + while ( + ts.isBindingElement(n) || + ts.isObjectBindingPattern(n) || + ts.isArrayBindingPattern(n) + ) + n = n.parent + return n } const localBinding = (identifier) => { - for (let n = identifier.parent; n; n = n.parent) - if (ts.isWithStatement(n)) return false - const found = declarationOf(identifier) + const found = bindingOf(identifier) if (found === undefined) return false + const declared = root(found) + const scope = functionOf(declared) + if (scope === undefined) return false + if (ts.isParameter(declared)) return identifier.getStart(file) >= found.end + if (!ts.isVariableDeclaration(declared)) return false + const lexical = + ts.isCatchClause(declared.parent) || + (declared.parent.flags & ts.NodeFlags.BlockScoped) !== 0 + // A `for…of` or `for…in` head's iterated value runs in its variables' + // dead zone too. + const loop = declared.parent.parent + const ends = + ts.isForOfStatement(loop) || ts.isForInStatement(loop) + ? loop.expression.end + : found.end return ( - ts.isParameter(found) || - (enclosedByFunction(found) && - (ts.isVariableStatement(found) || - ts.isForStatement(found) || - ts.isForOfStatement(found) || - ts.isForInStatement(found) || - ts.isCatchClause(found))) + !lexical || + (identifier.getStart(file) >= ends && + functionOf(identifier) === scope && + !ts.isCaseClause(declared.parent.parent?.parent)) ) } // A pure condition reads local names and numbers, and applies comparisons, @@ -594,9 +632,70 @@ export function instrument(ts, path, text, source, offset = 0) { ) return false } + // But an ordering comparison coerces an object through `valueOf`, + // `Symbol.toPrimitive`, or a Proxy, and that runs code. A site's change is + // unseen only when no ordering comparison runs or not because of it: none + // in the right operand of a logical operator whose left operand holds the + // site, nor in the site's own right operand when it is a logical operator + // (the fifth review, 2026-11-11). + const coercing = (expression) => { + const inner = bare(expression) + if (ts.isPrefixUnaryExpression(inner)) return coercing(inner.operand) + if (!ts.isBinaryExpression(inner)) return false + return ( + ordering.has(inner.operatorToken.getText(file)) || + coercing(inner.left) || + coercing(inner.right) + ) + } + // Whether every ordering comparison in a condition compares literals and + // names whose annotations admit primitives only, which no `valueOf` of + // theirs can run: the coercion label's claim, that the annotation + // excludes the distinguishing object. + const primitive = new Set([ + ts.SyntaxKind.NumberKeyword, + ts.SyntaxKind.BigIntKeyword, + ts.SyntaxKind.StringKeyword, + ts.SyntaxKind.BooleanKeyword, + ]) + const primitiveType = (type) => + type !== undefined && + (primitive.has(type.kind) || + ts.isLiteralTypeNode(type) || + (ts.isUnionTypeNode(type) && type.types.every(primitiveType))) + const primitiveComparisons = (expression) => { + const inner = bare(expression) + if (ts.isPrefixUnaryExpression(inner)) + return primitiveComparisons(inner.operand) + if (!ts.isBinaryExpression(inner)) return true + if (!ordering.has(inner.operatorToken.getText(file))) + return ( + primitiveComparisons(inner.left) && primitiveComparisons(inner.right) + ) + return [inner.left, inner.right].every( + (operand) => + literalValue(operand) !== undefined || + primitiveType(annotationOf(operand)), + ) + } + const isLogical = (node) => + ts.isBinaryExpression(node) && logical.has(node.operatorToken.getText(file)) + const unseenIn = (node, condition) => { + if (isLogical(node) && coercing(node.right)) return false + for (let child = node; child !== condition; child = child.parent) + if ( + isLogical(child.parent) && + child.parent.left === child && + coercing(child.parent.right) + ) + return false + return true + } // Whether `node` sits in the pure condition of a ternary or `if` whose two // values are identical. Both branches then yield the same value, so no change // to the condition can show. + // `dead` when no input distinguishes the change, `coerced` when only an + // object's coercion does (the label's case), and undefined otherwise. const deadUnderHolder = (node) => { let child = node for (let parent = node.parent; parent; parent = parent.parent) { @@ -607,11 +706,16 @@ export function instrument(ts, path, text, source, offset = 0) { : undefined if (condition === child && pureCondition(condition)) { const [yes, no] = valuesOf(parent) - if (yes !== undefined && yes === no) return true + if (yes !== undefined && yes === no) + return unseenIn(node, condition) + ? `dead` + : primitiveComparisons(condition) + ? `coerced` + : undefined } child = parent } - return false + return undefined } // An operand that can hold a signed zero: a variable (of a type that admits // numbers) or the literal 0. A bigint has no signed zero. 0 and -0 are equal @@ -621,7 +725,7 @@ export function instrument(ts, path, text, source, offset = 0) { const inner = bare(expression) return ( ts.isIdentifier(inner) || - (ts.isNumericLiteral(inner) && Number(inner.text) === 0) + (ts.isNumericLiteral(inner) && literalValue(inner) === 0) ) } // Whether `node` sits inside a branch of another ternary or `if`. An outer @@ -673,6 +777,10 @@ export function instrument(ts, path, text, source, offset = 0) { // and logical operators. The comparison's value then decides the branch alone // (the rest of the condition is not changed by the swap), so at a tie both // branches yield the same value: this is the skip's reach, not the hint's. + // A logical operator's other operand must be a pure condition: at a tie + // the swap changes whether that operand runs, and only a pure one runs + // unseen (the third review, 2026-11-11: `a < 1 && check()` calls `check` + // at the tie in one of the two). This is the `outside` label's reach. const conditionHolderOf = (node) => { let test = node while ( @@ -680,7 +788,10 @@ export function instrument(ts, path, text, source, offset = 0) { (ts.isPrefixUnaryExpression(test.parent) && test.parent.operator === ts.SyntaxKind.ExclamationToken) || (ts.isBinaryExpression(test.parent) && - logical.has(test.parent.operatorToken.getText(file))) + logical.has(test.parent.operatorToken.getText(file)) && + pureCondition( + test.parent.left === test ? test.parent.right : test.parent.left, + )) ) test = test.parent const holder = test.parent @@ -689,31 +800,53 @@ export function instrument(ts, path, text, source, offset = 0) { ? holder : undefined } - // An ordering swap whose two branches are its two operands is undetectable over - // the admitted domain, and is skipped, when no admitted input can tell the - // swapped tie from the original: both operands `bigint` (a tie yields equal - // values), or both `number` with a nonzero literal tie (no signed zero there). - // Operands that admit a string keep the mutant: a string distinguishes it. - const undetectableSwap = (node) => { - if (!ordering.has(node.operatorToken.getText(file))) return false + // An ordering swap whose two branches are its two operands, where no input + // of the annotations can tell the swapped tie from the original: both + // operands `bigint` (a tie yields equal values), or both `number` with a + // nonzero literal tie (no signed zero there). The label names the input + // outside the annotations that does: a number beside a bigint, or a + // string of the literal's digits. Never a skip. + const swapOutside = (node) => { + if (!ordering.has(node.operatorToken.getText(file))) return undefined const holder = conditionHolderOf(node) - if (holder === undefined || !yieldsOperands(node, holder)) return false - const [left, right] = [node.left, node.right] - if (typeOf(left) === `bigint` && typeOf(right) === `bigint`) return true + if (holder === undefined || !yieldsOperands(node, holder)) return undefined + if (!annotationOf(node.left) && !annotationOf(node.right)) return undefined + // Every comparison in the condition compares primitives, or an admitted + // object's `valueOf` would distinguish the swap (the seventh review). + const condition = ts.isIfStatement(holder) + ? holder.expression + : holder.condition + if (!primitiveComparisons(condition)) return undefined + const [left, right] = [typeOf(node.left), typeOf(node.right)] + // Without strict null checks a bigint admits `null`, which ties with a + // zero, and the CLI does not know the package's strictness: a bigint + // label needs a nonzero bigint literal at the tie (the eighth review). + if (left === `bigint` && right === `bigint`) + return [node.left, node.right].some((e) => { + const value = literalValue(e) + return typeof value === `bigint` && value !== 0n + }) + ? `a number in place of a bigint` + : undefined + return left === `number` && + right === `number` && + !(zeroable(node.left) && zeroable(node.right)) + ? `a string of the literal's digits` + : undefined + } + // A literal that `===` matches only in its own value: a bigint (it has no + // signed zero), or a number that is neither zero nor NaN. + const exactLiteral = (expression) => { + const value = literalValue(expression) return ( - typeOf(left) === `number` && - typeOf(right) === `number` && - !(zeroable(left) && zeroable(right)) + typeof value === `bigint` || + (typeof value === `number` && value !== 0 && !Number.isNaN(value)) ) } - // A literal operand that is not zero: its tie can never be a signed zero, at - // any declared type. + // A literal operand that is not zero: its tie can never be a signed zero. const nonzeroLiteral = (expression) => { - const inner = bare(expression) - return ( - (ts.isNumericLiteral(inner) || ts.isBigIntLiteral(inner)) && - !/^0n?$/.test(inner.getText(file)) - ) + const value = literalValue(expression) + return value !== undefined && value !== 0 && value !== 0n } // The equality test whose operands must be equal for the condition to have the // given truth: `a === b` for a true condition, `a !== b` for a false one. It @@ -743,15 +876,26 @@ export function instrument(ts, path, text, source, offset = 0) { // Forcing a guard's condition to `side` is undetectable when the condition's // other truth implies a tie, and the branches yield the tied operands: then the // forced branch is the one reached off the tie, and the tie yields the same - // value on both branches. The forced side is dead code when a nonzero literal - // makes the tie (no declared type has a signed zero there), and skipped when - // both operands are bigints (no signed zero at their type). Otherwise undefined. + // value on both branches. The forced side is dead code when a literal that + // `===` matches only in its own value makes the tie, and the other operand + // is a local name, read the same twice. It is labelled when both + // operands are annotated bigints: `-0` and `0` in their place tie at two + // values. Otherwise undefined. const forcedOutcome = (node, side) => { const eq = impliedEquality(node.expression, side === `false`) if (!eq || !yieldsOperands(eq, node)) return undefined - if (nonzeroLiteral(eq.left) || nonzeroLiteral(eq.right)) return `dead` - if (typeOf(eq.left) === `bigint` && typeOf(eq.right) === `bigint`) - return `skip` + const local = (e) => ts.isIdentifier(bare(e)) && localBinding(bare(e)) + if ( + (exactLiteral(eq.left) && local(eq.right)) || + (exactLiteral(eq.right) && local(eq.left)) + ) + return `dead` + if ( + typeOf(eq.left) === `bigint` && + typeOf(eq.right) === `bigint` && + primitiveComparisons(node.expression) + ) + return `\`-0\` and \`0\` in place of the bigints` return undefined } // The evidence a mutation site carries for the report, never a reason to skip @@ -759,6 +903,85 @@ export function instrument(ts, path, text, source, offset = 0) { // operator on one local name, and for a site in a pure condition whose // ternary or `if` has identical branches. Otherwise an ordering swap may carry // the boundary hint. + // Whether a returned expression is `undefined` by its syntax alone: `void + // x`, or the global `undefined`, which the binder resolves to no + // declaration, in a file whose scopes are static. Its mutant then returns + // the same `undefined` after the same evaluation. A type proves nothing + // here. A direct `eval` (a call of the identifier `eval`, through + // parentheses or a type's wrapper, whatever binding holds it: a sloppy + // call of a local `eval` that holds the real one is direct too) can + // declare a `var` at run time, and a `with` reads + // names through an object, so either one makes the file's scopes dynamic. + const unwrapped = (node) => { + let n = node + while ( + ts.isParenthesizedExpression(n) || + ts.isAsExpression(n) || + ts.isNonNullExpression(n) || + ts.isSatisfiesExpression(n) || + ts.isTypeAssertionExpression(n) + ) + n = n.expression + return n + } + const global = (identifier) => + (symbolOf(identifier)?.declarations?.length ?? 0) === 0 + const dynamicScope = { eval: false, with: false } + const scan = (node) => { + if (ts.isWithStatement(node)) dynamicScope.with = true + if (ts.isCallExpression(node)) { + const callee = unwrapped(node.expression) + if (ts.isIdentifier(callee) && callee.text === `eval`) + dynamicScope.eval = true + } + ts.forEachChild(node, scan) + } + scan(file) + const staticScopes = () => !dynamicScope.eval && !dynamicScope.with + const undefinedAlready = (expression) => { + const inner = bare(expression) + if (ts.isVoidExpression(inner)) return true + return ( + ts.isIdentifier(inner) && + inner.text === `undefined` && + staticScopes() && + global(inner) + ) + } + // A receiver that is never nullish, whatever the inputs: a string or + // number literal, or a `const` that holds one and that nothing outside the + // function can change (not exported by a modifier, whose CommonJS emit + // reads `exports.s`; an export list keeps the local read), not in a + // namespace, not ambient, with no `var` of its name in between, and in a + // file with static scopes. Its site is dead code. + const constantReceiver = (expression) => { + const inner = bare(expression) + if (ts.isStringLiteral(inner) || ts.isNumericLiteral(inner)) return true + if (!ts.isIdentifier(inner) || !staticScopes()) return false + const found = bindingOf(inner) + const statement = found?.parent?.parent + if ( + !found || + !ts.isVariableDeclaration(found) || + !ts.isIdentifier(found.name) || + (found.parent.flags & ts.NodeFlags.Const) === 0 || + !ts.isVariableStatement(statement) || + statement.modifiers?.some( + (m) => + m.kind === ts.SyntaxKind.ExportKeyword || + m.kind === ts.SyntaxKind.DeclareKeyword, + ) || + !ts.isSourceFile(statement.parent) + ) + return false + const init = found.initializer + return ( + init !== undefined && + (ts.isStringLiteral(init) || + ts.isNumericLiteral(init) || + ts.isBigIntLiteral(init)) + ) + } const evidenceOf = (node) => { const op = ts.isBinaryExpression(node) ? node.operatorToken.getText(file) @@ -773,10 +996,18 @@ export function instrument(ts, path, text, source, offset = 0) { ) return DEAD_CODE } - if (deadUnderHolder(node)) return DEAD_CODE + if (deadUnderHolder(node) === `dead`) return DEAD_CODE if (op !== undefined && ordering.has(op)) return boundaryHint(node) return undefined } + // The input class outside the annotations that distinguishes a site that + // no annotated input does, for the `outside` label (never a skip). + const outsideOf = (node) => + deadUnderHolder(node) === `coerced` + ? `an object whose \`valueOf\` has an effect` + : ts.isBinaryExpression(node) + ? swapOutside(node) + : undefined // Emit `node` with its children's instrumented text spliced in, then wrap // the node itself if it is a mutation site. Sites are numbered before their @@ -795,13 +1026,14 @@ export function instrument(ts, path, text, source, offset = 0) { if (ts.isBinaryExpression(node)) { const op = node.operatorToken.getText(file) const swap = comparisons.get(op) ?? logical.get(op) - if (swap && !undetectableSwap(node)) + if (swap) ids.binary = add( node.operatorToken, `binary-swap`, op, swap, evidenceOf(node), + evidenceOf(node) ? undefined : outsideOf(node), ) } if ( @@ -821,7 +1053,14 @@ export function instrument(ts, path, text, source, offset = 0) { ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.ExclamationToken ) - ids.not = add(node, `drop-not`, `!`, ``, evidenceOf(node)) + ids.not = add( + node, + `drop-not`, + `!`, + ``, + evidenceOf(node), + evidenceOf(node) ? undefined : outsideOf(node), + ) if ( (ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node)) && @@ -876,24 +1115,26 @@ export function instrument(ts, path, text, source, offset = 0) { const identical = yes !== undefined && yes === no const outcomeTrue = forcedOutcome(node, `true`) const outcomeFalse = forcedOutcome(node, `false`) - const evidenceFor = (side, outcome) => + const dead = (outcome) => identical || outcome === `dead` ? DEAD_CODE : undefined - if (outcomeTrue !== `skip`) - ids.ifTrue = add( - node.expression, - `if-true`, - condition, - `true`, - evidenceFor(`true`, outcomeTrue), - ) - if (outcomeFalse !== `skip`) - ids.ifFalse = add( - node.expression, - `if-false`, - condition, - `false`, - evidenceFor(`false`, outcomeFalse), - ) + const outside = (outcome) => + dead(outcome) || outcome === `dead` ? undefined : outcome + ids.ifTrue = add( + node.expression, + `if-true`, + condition, + `true`, + dead(outcomeTrue), + outside(outcomeTrue), + ) + ids.ifFalse = add( + node.expression, + `if-false`, + condition, + `false`, + dead(outcomeFalse), + outside(outcomeFalse), + ) } if ( ts.isExpressionStatement(node) && @@ -902,6 +1143,31 @@ export function instrument(ts, path, text, source, offset = 0) { !isRune(node.expression) ) ids.call = add(node, `delete-call`, node.getText(file), `;`) + if ( + ts.isReturnStatement(node) && + node.expression !== undefined && + !undefinedAlready(node.expression) + ) + ids.returned = add( + node.expression, + `return-undefined`, + node.expression.getText(file), + `undefined`, + ) + if ( + (ts.isPropertyAccessExpression(node) || + ts.isElementAccessExpression(node)) && + node.questionDotToken !== undefined + ) { + const constant = constantReceiver(node.expression) + ids.chain = add( + node, + `optional-chain`, + `?.`, + `.`, + constant ? DEAD_CODE : undefined, + ) + } const children = [] ts.forEachChild(node, (child) => { @@ -955,6 +1221,19 @@ export function instrument(ts, path, text, source, offset = 0) { const { text: condition, at } = emitted.get(node.expression) return `${out.slice(0, at)}${G}.cond(${ids.ifTrue ?? `undefined`}, ${ids.ifFalse ?? `undefined`}, ${condition})${out.slice(at + condition.length)}` } + // Each replaces one child: the returned expression, or the object read + // through `?.`, which the switch passes on or throws on when nullish, so + // the rest of the chain is kept. + if (ids.returned !== undefined || ids.chain !== undefined) { + const { text: value, at } = emitted.get(node.expression) + const replaced = + ids.returned !== undefined + ? // The value is evaluated, and then dropped when the mutant is + // active: element 1 of a one-element array is `undefined`. + `${G}.v([(${value})][${G}.m(${ids.returned}) ? 1 : 0])` + : `${G}.chain(${ids.chain}, ${value})` + return `${out.slice(0, at)}${replaced}${out.slice(at + value.length)}` + } if (ids.call !== undefined) { // `skip || call()` keeps the statement a single expression statement. // Wrapping it in an `if` would capture a following `else` (as in