From 3506293219cfc73449478f89bb6bc205aec23a98 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 11 Nov 2026 10:48:24 -0700 Subject: [PATCH 01/12] test(oracles): types never skip a mutant or mark it dead; they label its survivor (RED) The mutant-validity oracle judges every site over the inputs JavaScript allows: each type's values and foreign ties (a string beside its digits, null beside a zero, a number beside a bigint, -0 beside 0, and an object whose valueOf has an effect). No contract site may be skipped, dead code is marked exactly where no input distinguishes it, and a site that no declared input distinguishes carries the outside-annotation label. Hostile programs from five reviews must keep their sites when their types are erased. The classify oracle gains the outside-annotation class, and the model and end-to-end oracles gain F17's two operators. Co-authored-by: Isaac --- ...p-report-classify-oracle.property.test.mjs | 47 +- .../oracle-gaps/gap-report-model-oracle.mjs | 105 +- ...t-mutant-validity-oracle.property.test.mjs | 2207 ++++++++++++++++- .../gap-report-oracle.property.test.mjs | 18 +- 4 files changed, 2227 insertions(+), 150 deletions(-) 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 80976b1..d3161cb 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 @@ -47,6 +47,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 @@ -355,6 +360,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. @@ -365,6 +372,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\` ) ;`, @@ -543,6 +560,12 @@ function modelOutcome(h, mi) { if (compared.length === 0) return sized(`indeterminate`) 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 @@ -617,6 +640,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.` @@ -899,6 +926,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': @@ -910,7 +939,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': @@ -1205,6 +1234,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) => Number(file.match(/f(\d+)\.test/)[1]) @@ -1893,6 +1923,7 @@ const required = [ `not-observed`, `not-distinguished`, `dead-code`, + `outside-annotation`, `ordinary killed`, `a key with backticks`, `authored not-observed`, @@ -1980,6 +2011,7 @@ const single = ( original: `>`, code: codes[1], evidence: undefined, + outside: undefined, anchor: `out . push ( 'a' ) ;`, probe: false, ...mutant, @@ -2088,6 +2120,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`, @@ -2307,7 +2350,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. 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. 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); 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.`, }, diff --git a/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs b/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs index 0031a37..fc8749e 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 }), @@ -1405,6 +1409,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. @@ -1488,6 +1500,8 @@ const captureWitness = new Set([ `w_fcprop`, `w_record`, `w_data`, + `w_optional`, + `w_oldprobe`, `w_spin`, ]) @@ -1739,6 +1753,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`: @@ -1821,11 +1840,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: @@ -2477,6 +2499,7 @@ function resolveHistory(history) { spec: history.derived, source: file.source, statements: file.statements, + returnLine: file.returnLine, }, } } @@ -2643,8 +2666,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, { @@ -2694,6 +2723,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`. @@ -2880,6 +2918,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 @@ -2912,7 +2961,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 @@ -3020,7 +3072,8 @@ function ownersOf(plan) { ...(plan.wholeFile === `none` || inLibrary.has(id) || id === TOP_LOOP || - id === DERIVED_FN + id === DERIVED_FN || + id === DERIVED_HELPER ? [] : [plan.wholeFile]), ]) @@ -3208,6 +3261,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. @@ -3224,6 +3299,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) @@ -3289,6 +3368,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 `===` @@ -3579,9 +3661,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 07161a2..16f6810 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 that no input of its declared types distinguishes, and some + * other input does, carries the label (in the first family and for an + * annotated optional-chain receiver; over-labelling is allowed). 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,61 @@ * 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), except a + * call, `return undefined` where nothing binds the name, 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' @@ -108,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 that only an input outside its declared types distinguishes carries the outside-annotation label` +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` @@ -138,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])]), @@ -278,6 +338,27 @@ for (const test of tests) { 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) @@ -309,7 +390,7 @@ const isClamp = (shape) => shape.kind === `return` && shape.expr.kind === `cond` && shape.expr.else.kind === `cond` -const programs = shapes.flatMap((shape) => +const typedPrograms = shapes.flatMap((shape) => TYPES.filter((type) => !(type === `bigint` && isClamp(shape))).map( (type) => ({ ...shape, @@ -318,8 +399,1581 @@ const programs = shapes.flatMap((shape) => ), ) +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, and a call is `value-undefined`'s, + // so neither is a site. + if (s.expr.kind !== `voidOf` && s.expr.kind !== `call`) + 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`), in TypeScript under strict null checks, +// the declared types' inputs are the typed objects alone: there no declared +// input makes the present type's receiver nullish, so the plain read carries +// the label, `null`. 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]) + +// ## 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. 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: `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()] +} + +// 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 ${error.name}` + } + return `${result}:${calls()}` + }) +} +// 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, + ) +} +// 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 families, each program one history. +const histories = [ + ...programs, + ...narrowedPrograms, + ...chainPrograms, + ...returnPrograms, + ...ESCAPES, +] + +// ## 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}`, + chainLabel: `an optional read of an annotated parameter that only null reaches`, + chainDead: (lang) => `an optional read of a literal constant in ${lang}`, + returnExcluded: (form) => `a returned ${form} that is no site`, + shadowed: (form) => `a returned undefined that a ${form} shadows`, +} +const REQUIRED_STATES = [ + REACH.label, + REACH.impure, + ...ESCAPE_PROGRAMS.map(({ name }) => REACH.hostile(name)), + REACH.chainLabel, + ...LANGS.map(REACH.chainDead), + ...[`voided`, `voidRead`, `undefinedName`].map(REACH.returnExcluded), + ...[`shadowed`, `varShadowed`].map(REACH.shadowed), +] +// 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) { + 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 chain.label ? [REACH.chainLabel] : [] + } + 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 +} + // 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 === `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`) @@ -370,12 +2024,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) @@ -408,14 +2063,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 (`) @@ -434,9 +2100,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 } @@ -456,13 +2122,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] @@ -484,11 +2153,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 @@ -496,38 +2174,119 @@ 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, + typedDomain: (program) => + annotatedChain(program) ? chainDomain(program, true) : undefined, + }, + returned: { + render: renderReturned, + evaluate: evaluateReturned, + domain: returnDomain, + }, +} +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] +const typedDomainOf = (program) => + familyOf(program) + ? familyOf(program).typedDomain?.(program) + : 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: [] } + 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) } + : {}), } }), } @@ -557,6 +2316,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 + }, } } @@ -571,8 +2336,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 ${error.name}` + } + return { result, reads: globalReads + effects.count - before } }) } finally { delete globalThis[FREE] @@ -592,23 +2372,112 @@ 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)), + }) +} + function driver(program, observe) { - const { text } = render(program) - const domain = DOMAIN[program.type] + if (program.kind === `escape`) return escapeDriver(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 }) } @@ -617,10 +2486,69 @@ 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, + ) + const sites = new Set(found.mutants.map(keyOf)) + const bound = [] + const visit = (node) => { + if ( + ts.isReturnStatement(node) && + node.expression && + ts.isIdentifier(node.expression) && + node.expression.text === `undefined` && + (checker.getSymbolAtLocation(node.expression)?.declarations?.length ?? + 0) > 0 + ) + bound.push(node.expression.getStart(file)) + ts.forEachChild(node, visit) + } + visit(file) + const missed = bound.find( + (position) => !sites.has(`return-undefined@${position}`), + ) + if (missed !== undefined) + return violation( + `instrumented`, + `leaves out the returned undefined at offset ${missed} of ${program.name}, which a declaration binds`, + 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 (!found.clean) return violation( `instrumented`, @@ -631,32 +2559,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) @@ -665,6 +2586,18 @@ 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 (mutant.evidence === BOUNDARY_EVIDENCE && !site.zeroDistinct) return violation( `instrumented`, @@ -686,13 +2619,16 @@ function check(program, observations, expected) { declareOracle({ check: [check], model: [model], - grammar: [fc.constantFrom(...programs)], + grammar: [fc.constantFrom(...histories)], }) // 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) => @@ -776,14 +2712,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 that no input of its declared types distinguishes, and some other input does, carries the outside-annotation label, in the first family and for an annotated optional-chain receiver. 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 covers the first family and an annotated optional-chain receiver; the narrowed family's narrowings are not labelled. 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.`, }, // 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 a8200a4..88e8e22 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 @@ -152,7 +155,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 @@ -630,7 +634,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 @@ -1238,6 +1242,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`) @@ -1914,6 +1922,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`, From c0fc574c4525f22732df4b852a932fc07a45e743 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 11 Nov 2026 11:00:33 -0700 Subject: [PATCH 02/12] feat(oracles): types label a survivor and never skip or kill a mutant PR #8's ordering-swap skip and the forced-tie skip become the outside-annotation label, which names the input class outside the annotations that distinguishes the mutant (a string of the literal's digits, a number in place of a bigint, -0 and 0 in place of bigints, an object whose valueOf has an effect, null). A survivor with the label is classed outside-annotation, and its directive names that input. F17's operators come too: return-undefined evaluates the returned value and drops it, and optional-chain is never skipped; a literal or a module-local literal constant receiver is dead code. The syntactic evidence is repaired: 0x0, 0.0, 0b0, and 0x0n are zeros, a nested var and an import bind a name, a let or const is no local binding, a dead-code mark under identical branches needs no ordering comparison to run or not because of the site, and a forced tie needs a local name. Validity oracle: 0 of 32972 fail. Co-authored-by: Isaac --- .../oracles/oracle-gaps/capture-setup.mjs | 8 + packages/oracles/oracle-gaps/classify.mjs | 18 +- packages/oracles/oracle-gaps/index.mjs | 2 +- packages/oracles/oracle-gaps/report.mjs | 7 + packages/oracles/oracle-gaps/schemata.mjs | 402 +++++++++++++++--- 5 files changed, 366 insertions(+), 71 deletions(-) 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 4104ec1..38c10dc 100644 --- a/packages/oracles/oracle-gaps/classify.mjs +++ b/packages/oracles/oracle-gaps/classify.mjs @@ -197,14 +197,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` @@ -273,7 +276,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` @@ -310,7 +315,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/index.mjs b/packages/oracles/oracle-gaps/index.mjs index 4c76e0d..dc75ca1 100755 --- a/packages/oracles/oracle-gaps/index.mjs +++ b/packages/oracles/oracle-gaps/index.mjs @@ -26,7 +26,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..1603173 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. Not a call, which `value-undefined` already +// makes `undefined`, and not `return undefined` or `return void x`, which +// already return `undefined` after the same evaluation. A type never +// removes a site: an input from JavaScript need not have it. +// - `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 } @@ -446,6 +458,21 @@ export function instrument(ts, path, text, source, offset = 0) { node.variableDeclaration !== undefined && bindsName(node.variableDeclaration.name, name) if (own) return node + // A `var` in a nested block belongs to the function or the module, + // and an import binds a name for the module. + if (ts.isFunctionLike(node) || ts.isSourceFile(node)) { + const hoisted = varOf(node, name) + if (hoisted) return hoisted + } + if (ts.isSourceFile(node)) { + const imported = node.statements.find( + (statement) => + ts.isImportDeclaration(statement) && + statement.importClause !== undefined && + importsName(statement.importClause, name), + ) + if (imported) return imported + } if (ts.isFunctionLike(node)) { const parameter = node.parameters?.find((p) => bindsName(p.name, name)) if (parameter || withinFunction) return parameter @@ -453,6 +480,29 @@ export function instrument(ts, path, text, source, offset = 0) { } return undefined } + // The `var` statement in `scope`, outside its nested functions, that binds + // `name`. + function varOf(scope, name) { + let found + const visit = (node) => { + if (found || (node !== scope && ts.isFunctionLike(node))) return + if ( + ts.isVariableStatement(node) && + (node.declarationList.flags & ts.NodeFlags.BlockScoped) === 0 && + node.declarationList.declarations.some((d) => bindsName(d.name, name)) + ) + found = node + ts.forEachChild(node, visit) + } + ts.forEachChild(scope, visit) + return found + } + const importsName = (clause, name) => + clause.name?.text === name || + (clause.namedBindings !== undefined && + (ts.isNamespaceImport(clause.namedBindings) + ? clause.namedBindings.name.text === name + : clause.namedBindings.elements.some((e) => e.name.text === name))) const declarations = [] const collect = (node) => { if (ts.isFunctionDeclaration(node) && node.name && node.body) @@ -488,22 +538,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 = declarationOf(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,10 +620,12 @@ 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. + // A local binding: a parameter, or a `var`, 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. A `let` or + // `const` is left out: a closure can read it before its assignment, and + // the read throws. const enclosedByFunction = (node) => { for (let n = node.parent; n; n = n.parent) if (ts.isFunctionLike(n)) return true @@ -566,7 +639,8 @@ export function instrument(ts, path, text, source, offset = 0) { return ( ts.isParameter(found) || (enclosedByFunction(found) && - (ts.isVariableStatement(found) || + ((ts.isVariableStatement(found) && + (found.declarationList.flags & ts.NodeFlags.BlockScoped) === 0) || ts.isForStatement(found) || ts.isForOfStatement(found) || ts.isForInStatement(found) || @@ -594,9 +668,40 @@ 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) + ) + } + 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 +712,12 @@ 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` : `coerced` } 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 +727,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 +779,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 +790,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 +802,38 @@ 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 + const [left, right] = [typeOf(node.left), typeOf(node.right)] + if (left === `bigint` && right === `bigint`) + return `a number in place of a bigint` + 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 +863,22 @@ 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` + 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`) - return `skip` + 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 +886,91 @@ 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`, where nothing in the file binds the name + // (a nested `var`, an import, or a destructured parameter included) and no + // `with` can hide it. Its mutant then returns the same `undefined` after + // the same evaluation. A type proves nothing here. + const bindsUndefined = (() => { + let found = /\bwith\s*\(/.test(text) + const visit = (node) => { + if (found) return + if ( + ts.isIdentifier(node) && + node.text === `undefined` && + node.parent && + !ts.isExpression(node.parent) && + !ts.isExpressionStatement(node.parent) && + !ts.isReturnStatement(node.parent) + ) + found = true + ts.forEachChild(node, visit) + } + visit(file) + return found + })() + const undefinedAlready = (expression) => { + const inner = bare(expression) + if (ts.isVoidExpression(inner)) return true + return ( + ts.isIdentifier(inner) && inner.text === `undefined` && !bindsUndefined + ) + } + // 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, not in a namespace, not ambient, and + // with no `var` of its name in between). 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)) return false + for (let n = inner.parent; n; n = n.parent) + if (ts.isWithStatement(n)) return false + const found = declarationOf(inner) + if ( + !found || + !ts.isVariableStatement(found) || + (found.declarationList.flags & ts.NodeFlags.Const) === 0 || + found.modifiers?.some( + (m) => + m.kind === ts.SyntaxKind.ExportKeyword || + m.kind === ts.SyntaxKind.DeclareKeyword, + ) || + !ts.isSourceFile(found.parent) + ) + return false + const init = found.declarationList.declarations.find( + (d) => ts.isIdentifier(d.name) && d.name.text === inner.text, + )?.initializer + return ( + init !== undefined && + (ts.isStringLiteral(init) || + ts.isNumericLiteral(init) || + ts.isBigIntLiteral(init)) + ) + } + // A receiver whose own annotation admits no nullish value: a parameter + // annotated with an object type, a reference, or a primitive keyword, with + // no `?`, default, or union. Its site is labelled `null`. + const nullish = new Set([ + ts.SyntaxKind.AnyKeyword, + ts.SyntaxKind.UnknownKeyword, + ts.SyntaxKind.UndefinedKeyword, + ts.SyntaxKind.NullKeyword, + ts.SyntaxKind.VoidKeyword, + ]) + const annotatedPresent = (expression) => { + const type = annotationOf(expression) + const parameter = type?.parent + return ( + type !== undefined && + !parameter.questionToken && + !parameter.initializer && + !nullish.has(type.kind) && + !ts.isUnionTypeNode(type) && + !(ts.isLiteralTypeNode(type) && nullish.has(type.literal.kind)) + ) + } const evidenceOf = (node) => { const op = ts.isBinaryExpression(node) ? node.operatorToken.getText(file) @@ -773,10 +985,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 +1015,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 +1042,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 +1104,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 +1132,33 @@ 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 && + !ts.isCallExpression(bare(node.expression)) && + !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, + !constant && annotatedPresent(node.expression) ? `\`null\`` : undefined, + ) + } const children = [] ts.forEachChild(node, (child) => { @@ -955,6 +1212,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 From 6b03ea0352a1ce458f86424a7cee99cdfc6993d2 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 11 Nov 2026 11:25:14 -0700 Subject: [PATCH 03/12] test(oracles): a closure that reads a let before its assignment keeps its dead-code site A hostile program whose condition reads a let through a closure before its assignment: the swap of && there throws or not, so no dead-code mark may sit on it. The mutant that counts a let or const as a local binding fails it (1 of 32973); production passes (32973 of 32973). Co-authored-by: Isaac --- .../gap-report-mutant-validity-oracle.property.test.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs index 16f6810..212634a 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 @@ -1155,7 +1155,8 @@ const returnDomain = () => [...VALUES.number, undefined, null].map((a) => [a]) // 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. Each program's inputs include one that +// 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 @@ -1780,6 +1781,11 @@ const ESCAPE_PROGRAMS = [ ] }, }, + { + 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: `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`, From a177d49cbbb70cb9e1230efe47ffbfee2284504d Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 11 Nov 2026 12:20:54 -0700 Subject: [PATCH 04/12] test(oracles): the sixth review's causes as generated programs (RED) A family of short programs that the model judges by running their text: a logical swap whose operand may be in its dead zone (a parameter default, a for-let or for-of head, against the body), each form that binds the name undefined and three that only name it, a sloppy script with or without a direct eval or a with, a returned call, and a coerced comparison at five annotations. The label law is now exact: a label only where an annotation excludes every distinguishing input. A returned call is a site in the narrowed family, and a hostile program returns import(). On fc6a2e2 this fails 42 of 33022. Co-authored-by: Isaac --- ...t-mutant-validity-oracle.property.test.mjs | 484 ++++++++++++++++-- 1 file changed, 444 insertions(+), 40 deletions(-) diff --git a/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs index 212634a..cdf8d20 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 @@ -15,9 +15,9 @@ * 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 that no input of its declared types distinguishes, and some - * other input does, carries the label (in the first family and for an - * annotated optional-chain receiver; over-labelling is allowed). Boundary: a + * 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 @@ -84,9 +84,10 @@ * a history of 0 and -0 distinguishes; and each emitted mutant is a site of * the grammar. * - * Every returned expression is a `return-undefined` site (F17), except a - * call, `return undefined` where nothing binds the name, and `return void - * x`. Its mutant evaluates the returned expression and returns `undefined` + * 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`. @@ -123,7 +124,7 @@ 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 = `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 that only an input outside its declared types distinguishes carries the outside-annotation label` +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` @@ -728,9 +729,10 @@ function renderNarrowed(program) { const statement = (s, indent) => { if (s.kind === `return`) { put(`${indent}return `) - // `void a` is `undefined` already, and a call is `value-undefined`'s, - // so neither is a site. - if (s.expr.kind !== `voidOf` && s.expr.kind !== `call`) + // `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, @@ -954,10 +956,12 @@ function evaluateNarrowed(program, input, change) { // // 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`), in TypeScript under strict null checks, -// the declared types' inputs are the typed objects alone: there no declared -// input makes the present type's receiver nullish, so the plain read carries -// the label, `null`. The other receivers: a local that holds the parameter +// 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 @@ -1137,6 +1141,361 @@ function evaluateReturned(program, [a], change, counter = { reads: 0 }) { } 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`, + }) +} +// 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 + }, +}) +const TEXT_INPUTS = { + 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, ``, 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`)() +} +const textCache = new Map() +function evaluateText(program, input, change) { + const text = change?.kind === `text` ? change.text : program.text + if (!textCache.has(text)) textCache.set(text, loadText(text)) + try { + return encoded(textCache.get(text)(...input)) + } catch (error) { + return `threw ${error.name}` + } +} +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]() +const textTypedDomain = (program) => + program.family === `coerced` && program.annotation !== undefined + ? textDomain(program).filter( + ([, p, q]) => + COERCED_TYPED[program.annotation](p) && + COERCED_TYPED[program.annotation](q), + ) + : undefined + // ## Hostile programs // // A family of fixed programs from five reviews (2026-11-10 to 2026-11-11). @@ -1786,6 +2145,11 @@ const ESCAPE_PROGRAMS = [ 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`, @@ -1898,6 +2262,7 @@ const histories = [ ...narrowedPrograms, ...chainPrograms, ...returnPrograms, + ...textPrograms, ...ESCAPES, ] @@ -1913,19 +2278,22 @@ 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}`, - chainLabel: `an optional read of an annotated parameter that only null reaches`, + 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`, } const REQUIRED_STATES = [ REACH.label, REACH.impure, ...ESCAPE_PROGRAMS.map(({ name }) => REACH.hostile(name)), - REACH.chainLabel, + 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) => @@ -1938,11 +2306,22 @@ const orderingSites = (program, expected) => 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 chain.label ? [REACH.chainLabel] : [] + return annotatedChain(program) && + program.width === `present` && + chain.distinct + ? [REACH.chainAnnotated] + : [] } if (program.kind === `returned`) { if ([`shadowed`, `varShadowed`].includes(program.form)) @@ -1978,6 +2357,8 @@ function stateOf(program) { 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`) { @@ -2198,14 +2579,18 @@ const families = { render: renderChain, evaluate: evaluateChain, domain: chainDomain, - typedDomain: (program) => - annotatedChain(program) ? chainDomain(program, true) : undefined, }, returned: { render: renderReturned, evaluate: evaluateReturned, domain: returnDomain, }, + text: { + render: renderText, + evaluate: evaluateText, + domain: textDomain, + typedDomain: textTypedDomain, + }, } const familyOf = (program) => families[program.kind] const sourceOf = (program) => (familyOf(program)?.render ?? render)(program) @@ -2334,7 +2719,9 @@ function switchFor(active) { // The free global `g`, whose getter counts the reads of one call. let globalReads = 0 // Each input's result and the reads it made, with `g` installed as a counter. -function runAll(f, domain) { +// A text program's run neither copies its inputs (a getter would run in the +// copy) nor keeps a result's identity (`encoded`). +function runAll(f, domain, plain = false) { Object.defineProperty(globalThis, FREE, { configurable: true, get: () => ++globalReads, @@ -2350,11 +2737,15 @@ function runAll(f, domain) { try { result = f( ...input.map((value) => - typeof value === `object` && value !== null && value !== VALUED + !plain && + typeof value === `object` && + value !== null && + value !== VALUED ? { ...value } : value, ), ) + if (plain) result = encoded(result) } catch (error) { result = `threw ${error.name}` } @@ -2368,11 +2759,11 @@ const sameRuns = (xs, ys) => xs.every( (x, i) => Object.is(x.result, ys[i].result) && x.reads === ys[i].reads, ) -function runsUnder(code, active, domain) { +function runsUnder(code, active, domain, plain = false) { const previous = globalThis.__oracleGaps globalThis.__oracleGaps = switchFor(active) try { - return runAll(load(code), domain) + return runAll(load(code), domain, plain) } finally { globalThis.__oracleGaps = previous } @@ -2463,7 +2854,8 @@ function driver(program, observe) { if (program.kind === `escape`) return escapeDriver(program, observe) const { text } = sourceOf(program) const domain = domainOf(program) - const original = runAll(load(text), domain) + const plain = program.kind === `text` + const original = runAll(load(text), domain, plain) const ext = program.lang === `js` ? `js` : `ts` const path = `/mutant-validity.${ext}` const { code, mutants } = instrument( @@ -2472,9 +2864,9 @@ function driver(program, observe) { text, `src/mutant-validity.${ext}`, ) - const clean = sameRuns(runsUnder(code, null, domain), original) + const clean = sameRuns(runsUnder(code, null, domain, plain), original) const emitted = mutants.map((mutant) => { - const runs = runsUnder(code, mutant.number, domain) + const runs = runsUnder(code, mutant.number, domain, plain) return { operator: mutant.operator, position: mutant.position, @@ -2523,28 +2915,34 @@ function checkEscape(program, found) { 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 bound = [] + const dynamic = /\beval\s*\(|\bwith\b/.test(program.text) + const owed = [] const visit = (node) => { - if ( - ts.isReturnStatement(node) && - node.expression && - ts.isIdentifier(node.expression) && - node.expression.text === `undefined` && - (checker.getSymbolAtLocation(node.expression)?.declarations?.length ?? - 0) > 0 - ) - bound.push(node.expression.getStart(file)) + 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 = bound.find( + const missed = owed.find( (position) => !sites.has(`return-undefined@${position}`), ) if (missed !== undefined) return violation( `instrumented`, - `leaves out the returned undefined at offset ${missed} of ${program.name}, which a declaration binds`, + `leaves out the returned value at offset ${missed} of ${program.name}, which is no exact equivalent of its mutant`, LAW_2, ) return pass @@ -2604,6 +3002,12 @@ function check(program, observations, expected) { `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`, @@ -2718,9 +3122,9 @@ const calibrations = [ oracleProperty({ id: `oracle-gaps.report-mutant-validity`, law: { - 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 that no input of its declared types distinguishes, and some other input does, carries the outside-annotation label, in the first family and for an annotated optional-chain receiver. 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.`, + 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 covers the first family and an annotated optional-chain receiver; the narrowed family's narrowings are not labelled. 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.`, + 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.`, }, // Enumerated: every program of the grammar is one history, in order. histories: { enumerated: histories, states: REQUIRED_STATES }, From a9322540b279c598c9a233f51d5d2c4aacb5ae6b Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 11 Nov 2026 12:38:44 -0700 Subject: [PATCH 05/12] fix(oracles): no dead mark or skip that a dead zone, a binding, eval, or with defeats; a returned call is a site From the sixth soundness review. A parameter read before its own declaration ends, and a loop's lexical variable read in its own head, are no local bindings, so a logical swap there is no dead code. A name binds undefined wherever it names a declaration (a function or class expression's own name included), not as a method name or a label. A direct eval or a with anywhere in the file, found in the syntax tree, keeps every return of undefined and every constant receiver. A returned call and import() are return-undefined sites. The coercion label needs annotations that admit primitives only, and an optional chain carries no label, since the CLI does not know the package's strict null checks. Validity oracle: 0 of 33022 fail; the static oracle models the fixture's returned calls. Co-authored-by: Isaac --- .../oracle-gaps/gap-report-model-oracle.mjs | 6 +- packages/oracles/oracle-gaps/schemata.mjs | 192 ++++++++++++------ 2 files changed, 135 insertions(+), 63 deletions(-) diff --git a/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs b/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs index fc8749e..b7d0eae 100644 --- a/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs +++ b/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs @@ -2747,8 +2747,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. @@ -2756,8 +2758,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. diff --git a/packages/oracles/oracle-gaps/schemata.mjs b/packages/oracles/oracle-gaps/schemata.mjs index 1603173..0691442 100644 --- a/packages/oracles/oracle-gaps/schemata.mjs +++ b/packages/oracles/oracle-gaps/schemata.mjs @@ -29,10 +29,10 @@ // `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. Not a call, which `value-undefined` already -// makes `undefined`, and not `return undefined` or `return void x`, which -// already return `undefined` after the same evaluation. A type never -// removes a site: an input from JavaScript need not have it. +// 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). // @@ -636,15 +636,30 @@ export function instrument(ts, path, text, source, offset = 0) { if (ts.isWithStatement(n)) return false const found = declarationOf(identifier) if (found === undefined) return false + // A parameter read before its own declaration ends (in its default, or + // an earlier one's), and a loop's lexical variable read in its own head, + // are in their dead zone, where a read throws (the sixth review). + if (ts.isParameter(found)) return identifier.getStart(file) >= found.end + const head = ts.isForStatement(found) ? found.initializer : found.expression + if ( + (ts.isForStatement(found) || + ts.isForOfStatement(found) || + ts.isForInStatement(found)) && + found.initializer !== undefined && + ts.isVariableDeclarationList(found.initializer) && + (found.initializer.flags & ts.NodeFlags.BlockScoped) !== 0 && + identifier.getStart(file) >= head.getStart(file) && + identifier.end <= head.end + ) + return false return ( - ts.isParameter(found) || - (enclosedByFunction(found) && - ((ts.isVariableStatement(found) && - (found.declarationList.flags & ts.NodeFlags.BlockScoped) === 0) || - ts.isForStatement(found) || - ts.isForOfStatement(found) || - ts.isForInStatement(found) || - ts.isCatchClause(found))) + enclosedByFunction(found) && + ((ts.isVariableStatement(found) && + (found.declarationList.flags & ts.NodeFlags.BlockScoped) === 0) || + ts.isForStatement(found) || + ts.isForOfStatement(found) || + ts.isForInStatement(found) || + ts.isCatchClause(found)) ) } // A pure condition reads local names and numbers, and applies comparisons, @@ -684,6 +699,36 @@ export function instrument(ts, path, text, source, offset = 0) { 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) => { @@ -713,7 +758,11 @@ export function instrument(ts, path, text, source, offset = 0) { if (condition === child && pureCondition(condition)) { const [yes, no] = valuesOf(parent) if (yes !== undefined && yes === no) - return unseenIn(node, condition) ? `dead` : `coerced` + return unseenIn(node, condition) + ? `dead` + : primitiveComparisons(condition) + ? `coerced` + : undefined } child = parent } @@ -812,6 +861,7 @@ export function instrument(ts, path, text, source, offset = 0) { if (!ordering.has(node.operatorToken.getText(file))) return undefined const holder = conditionHolderOf(node) if (holder === undefined || !yieldsOperands(node, holder)) return undefined + if (!annotationOf(node.left) && !annotationOf(node.right)) return undefined const [left, right] = [typeOf(node.left), typeOf(node.right)] if (left === `bigint` && right === `bigint`) return `a number in place of a bigint` @@ -888,42 +938,84 @@ export function instrument(ts, path, text, source, offset = 0) { // the boundary hint. // Whether a returned expression is `undefined` by its syntax alone: `void // x`, or the global `undefined`, where nothing in the file binds the name - // (a nested `var`, an import, or a destructured parameter included) and no - // `with` can hide it. Its mutant then returns the same `undefined` after - // the same evaluation. A type proves nothing here. - const bindsUndefined = (() => { - let found = /\bwith\s*\(/.test(text) - const visit = (node) => { - if (found) return - if ( - ts.isIdentifier(node) && - node.text === `undefined` && - node.parent && - !ts.isExpression(node.parent) && - !ts.isExpressionStatement(node.parent) && - !ts.isReturnStatement(node.parent) - ) - found = true - ts.forEachChild(node, visit) - } - visit(file) - return found - })() + // and its scopes are static. Its mutant then returns the same `undefined` + // after the same evaluation. A type proves nothing here. + // + // A name binds where it is the name of a declaration: a variable, a + // parameter or binding element, a function, a class (an expression's own + // name included), an import, an enum, or a namespace. A method's name, a + // property's, a label, and an export specifier's do not bind it. A + // direct `eval` can declare a `var` at run time, and a `with` reads names + // through an object, so either one in the file makes its scopes dynamic. + const declaring = (node) => + ts.isVariableDeclaration(node) || + ts.isParameter(node) || + ts.isBindingElement(node) || + ts.isFunctionDeclaration(node) || + ts.isFunctionExpression(node) || + ts.isClassDeclaration(node) || + ts.isClassExpression(node) || + ts.isImportClause(node) || + ts.isImportSpecifier(node) || + ts.isNamespaceImport(node) || + ts.isImportEqualsDeclaration(node) || + ts.isEnumDeclaration(node) || + ts.isModuleDeclaration(node) + let bindsUndefined = false + let dynamicScope = false + const scan = (node) => { + if ( + ts.isIdentifier(node) && + node.text === `undefined` && + declaring(node.parent) && + node.parent.name === node + ) + bindsUndefined = true + if ( + ts.isWithStatement(node) || + (ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === `eval`) + ) + dynamicScope = true + ts.forEachChild(node, scan) + } + scan(file) const undefinedAlready = (expression) => { const inner = bare(expression) if (ts.isVoidExpression(inner)) return true return ( - ts.isIdentifier(inner) && inner.text === `undefined` && !bindsUndefined + ts.isIdentifier(inner) && + inner.text === `undefined` && + !bindsUndefined && + !dynamicScope ) } // 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, not in a namespace, not ambient, and - // with no `var` of its name in between). Its site is dead code. + // function can change (not exported, by a modifier or an export list, 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 exportedNames = new Set( + file.statements.flatMap((statement) => + ts.isExportDeclaration(statement) && + statement.exportClause && + ts.isNamedExports(statement.exportClause) + ? statement.exportClause.elements.map( + (e) => (e.propertyName ?? e.name).text, + ) + : [], + ), + ) const constantReceiver = (expression) => { const inner = bare(expression) if (ts.isStringLiteral(inner) || ts.isNumericLiteral(inner)) return true - if (!ts.isIdentifier(inner)) return false + if ( + !ts.isIdentifier(inner) || + dynamicScope || + exportedNames.has(inner.text) + ) + return false for (let n = inner.parent; n; n = n.parent) if (ts.isWithStatement(n)) return false const found = declarationOf(inner) @@ -949,28 +1041,6 @@ export function instrument(ts, path, text, source, offset = 0) { ts.isBigIntLiteral(init)) ) } - // A receiver whose own annotation admits no nullish value: a parameter - // annotated with an object type, a reference, or a primitive keyword, with - // no `?`, default, or union. Its site is labelled `null`. - const nullish = new Set([ - ts.SyntaxKind.AnyKeyword, - ts.SyntaxKind.UnknownKeyword, - ts.SyntaxKind.UndefinedKeyword, - ts.SyntaxKind.NullKeyword, - ts.SyntaxKind.VoidKeyword, - ]) - const annotatedPresent = (expression) => { - const type = annotationOf(expression) - const parameter = type?.parent - return ( - type !== undefined && - !parameter.questionToken && - !parameter.initializer && - !nullish.has(type.kind) && - !ts.isUnionTypeNode(type) && - !(ts.isLiteralTypeNode(type) && nullish.has(type.literal.kind)) - ) - } const evidenceOf = (node) => { const op = ts.isBinaryExpression(node) ? node.operatorToken.getText(file) @@ -1135,7 +1205,6 @@ export function instrument(ts, path, text, source, offset = 0) { if ( ts.isReturnStatement(node) && node.expression !== undefined && - !ts.isCallExpression(bare(node.expression)) && !undefinedAlready(node.expression) ) ids.returned = add( @@ -1156,7 +1225,6 @@ export function instrument(ts, path, text, source, offset = 0) { `?.`, `.`, constant ? DEAD_CODE : undefined, - !constant && annotatedPresent(node.expression) ? `\`null\`` : undefined, ) } From 10853a2d05bd63f6075485a52d12cb302d685f32 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 11 Nov 2026 13:08:46 -0700 Subject: [PATCH 06/12] fix(oracles): an export list keeps a constant receiver TypeScript's CommonJS emit of export { s } assigns exports.s once and keeps the module's own reads of s local, so nothing outside can change the value the function reads. The check that left such a receiver unmarked had no witness: its mutant passed the oracle (0 of 33022), and the emit shows why. Validity oracle: 33022 of 33022. Co-authored-by: Isaac --- packages/oracles/oracle-gaps/schemata.mjs | 25 +++++------------------ 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/packages/oracles/oracle-gaps/schemata.mjs b/packages/oracles/oracle-gaps/schemata.mjs index 0691442..8bc964e 100644 --- a/packages/oracles/oracle-gaps/schemata.mjs +++ b/packages/oracles/oracle-gaps/schemata.mjs @@ -993,29 +993,14 @@ export function instrument(ts, path, text, source, offset = 0) { } // 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 or an export list, 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 exportedNames = new Set( - file.statements.flatMap((statement) => - ts.isExportDeclaration(statement) && - statement.exportClause && - ts.isNamedExports(statement.exportClause) - ? statement.exportClause.elements.map( - (e) => (e.propertyName ?? e.name).text, - ) - : [], - ), - ) + // 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) || - dynamicScope || - exportedNames.has(inner.text) - ) - return false + if (!ts.isIdentifier(inner) || dynamicScope) return false for (let n = inner.parent; n; n = n.parent) if (ts.isWithStatement(n)) return false const found = declarationOf(inner) From 1d2710a471bd2165907d13851c4b863f27f79ab1 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 11 Nov 2026 14:13:46 -0700 Subject: [PATCH 07/12] test(oracles): every binding form crossed with every read position, the spellings of eval, and a label across && (RED) The seventh review's causes as generated dimensions: a read of x 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 pattern, a nested pattern, or a var (nested, or in a class's static block), and read before its declaration ends, inside it, after it, or in a hoisted function. The scope family gains (eval)(code), (eval as any)(code), and the indirect (0, eval)(code). A chain family puts a second comparison in the swap's && chain, at a primitive and at object. On 10853a2 this fails 27 of 33092. Co-authored-by: Isaac --- ...t-mutant-validity-oracle.property.test.mjs | 284 +++++++++++++++++- 1 file changed, 276 insertions(+), 8 deletions(-) diff --git a/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs index cdf8d20..860f0ec 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 @@ -1403,6 +1403,221 @@ for (const type of COERCED_TYPES) { 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`, + }) + } +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() { @@ -1414,7 +1629,50 @@ const READ_ONCE = () => ({ 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].flatMap((a) => + [5n, 5].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]), @@ -1430,6 +1688,7 @@ const TEXT_INPUTS = { [1, `var undefined = 5`, {}], [1, `var s = null`, {}], [1, `a = 2`, {}], + [1, `throw 1`, {}], [1, ``, READ_ONCE()], [0, ``, { s: null }], ], @@ -1487,14 +1746,23 @@ function renderText(program) { } } const textDomain = (program) => TEXT_INPUTS[program.inputs]() -const textTypedDomain = (program) => - program.family === `coerced` && program.annotation !== undefined - ? textDomain(program).filter( - ([, p, q]) => - COERCED_TYPED[program.annotation](p) && - COERCED_TYPED[program.annotation](q), - ) - : undefined +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`) { + const operand = (v) => typeof v === program.operandType + 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 // From e847db55b2bd4c3d4de1795faf5763eb285bb018 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 11 Nov 2026 14:20:03 -0700 Subject: [PATCH 08/12] fix(oracles): resolve every scope question through TypeScript's binder The maintainer's constraint: no hand-written scope analysis. declarationOf, varOf, importsName, bindsName, declaresName, enclosedByFunction, the binding scan for undefined, and the per-construct dead-zone checks are gone (148 lines of code). A one-file program, built lazily with noLib and noResolve, answers which declaration a name resolves to; only symbols and declarations are read, never types. Dead zones follow one rule: a parameter, a caught error, a let, or a const read before its declaration ends (a for-of or for-in head's iterated value included), from another function, or across a switch case, is no local name. A direct eval is a call of the global eval through parentheses or a type's wrapper. A swap's label needs every comparison in its condition to compare primitives. Validity oracle: 0 of 33092; static, classify, probe-form: 31 of 31. Co-authored-by: Isaac --- packages/oracles/oracle-gaps/schemata.mjs | 326 +++++++++------------- 1 file changed, 136 insertions(+), 190 deletions(-) diff --git a/packages/oracles/oracle-gaps/schemata.mjs b/packages/oracles/oracle-gaps/schemata.mjs index 8bc964e..18aee05 100644 --- a/packages/oracles/oracle-gaps/schemata.mjs +++ b/packages/oracles/oracle-gaps/schemata.mjs @@ -371,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) ) } @@ -395,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) => @@ -408,101 +403,51 @@ 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 - // A `var` in a nested block belongs to the function or the module, - // and an import binds a name for the module. - if (ts.isFunctionLike(node) || ts.isSourceFile(node)) { - const hoisted = varOf(node, name) - if (hoisted) return hoisted + // 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`, } - if (ts.isSourceFile(node)) { - const imported = node.statements.find( - (statement) => - ts.isImportDeclaration(statement) && - statement.importClause !== undefined && - importsName(statement.importClause, name), + checker = ts + .createProgram( + [file.fileName], + { noLib: true, noResolve: true, allowJs: true, noEmit: true }, + host, ) - if (imported) return imported - } - if (ts.isFunctionLike(node)) { - const parameter = node.parameters?.find((p) => bindsName(p.name, name)) - if (parameter || withinFunction) return parameter - } + .getTypeChecker() } - return undefined + return checker.getSymbolAtLocation(identifier) } - // The `var` statement in `scope`, outside its nested functions, that binds - // `name`. - function varOf(scope, name) { - let found - const visit = (node) => { - if (found || (node !== scope && ts.isFunctionLike(node))) return - if ( - ts.isVariableStatement(node) && - (node.declarationList.flags & ts.NodeFlags.BlockScoped) === 0 && - node.declarationList.declarations.some((d) => bindsName(d.name, name)) - ) - found = node - ts.forEachChild(node, visit) - } - ts.forEachChild(scope, visit) - return found + // 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 importsName = (clause, name) => - clause.name?.text === name || - (clause.namedBindings !== undefined && - (ts.isNamespaceImport(clause.namedBindings) - ? clause.namedBindings.name.text === name - : clause.namedBindings.elements.some((e) => e.name.text === name))) const declarations = [] const collect = (node) => { if (ts.isFunctionDeclaration(node) && node.name && node.body) @@ -546,7 +491,7 @@ export function instrument(ts, path, text, source, offset = 0) { const annotationOf = (expression) => { const inner = bare(expression) if (!typeScript || !ts.isIdentifier(inner)) return undefined - const found = declarationOf(inner) + const found = bindingOf(inner) return found && ts.isParameter(found) && ts.isIdentifier(found.name) ? found.type : undefined @@ -620,46 +565,50 @@ export function instrument(ts, path, text, source, offset = 0) { ? holder : undefined } - // A local binding: a parameter, or a `var`, 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. A `let` or - // `const` is left out: a closure can read it before its assignment, and - // the read throws. - 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), in a + // file with no `with`, which reads names through an object. 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) + if (dynamicScope.with) return false + const found = bindingOf(identifier) if (found === undefined) return false - // A parameter read before its own declaration ends (in its default, or - // an earlier one's), and a loop's lexical variable read in its own head, - // are in their dead zone, where a read throws (the sixth review). - if (ts.isParameter(found)) return identifier.getStart(file) >= found.end - const head = ts.isForStatement(found) ? found.initializer : found.expression - if ( - (ts.isForStatement(found) || - ts.isForOfStatement(found) || - ts.isForInStatement(found)) && - found.initializer !== undefined && - ts.isVariableDeclarationList(found.initializer) && - (found.initializer.flags & ts.NodeFlags.BlockScoped) !== 0 && - identifier.getStart(file) >= head.getStart(file) && - identifier.end <= head.end - ) - 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 ( - enclosedByFunction(found) && - ((ts.isVariableStatement(found) && - (found.declarationList.flags & ts.NodeFlags.BlockScoped) === 0) || - 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, @@ -862,6 +811,12 @@ export function instrument(ts, path, text, source, offset = 0) { const holder = conditionHolderOf(node) 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)] if (left === `bigint` && right === `bigint`) return `a number in place of a bigint` @@ -927,7 +882,11 @@ export function instrument(ts, path, text, source, offset = 0) { (exactLiteral(eq.right) && local(eq.left)) ) return `dead` - if (typeOf(eq.left) === `bigint` && typeOf(eq.right) === `bigint`) + if ( + typeOf(eq.left) === `bigint` && + typeOf(eq.right) === `bigint` && + primitiveComparisons(node.expression) + ) return `\`-0\` and \`0\` in place of the bigints` return undefined } @@ -937,58 +896,46 @@ export function instrument(ts, path, text, source, offset = 0) { // 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`, where nothing in the file binds the name - // and its scopes are static. Its mutant then returns the same `undefined` - // after the same evaluation. A type proves nothing here. - // - // A name binds where it is the name of a declaration: a variable, a - // parameter or binding element, a function, a class (an expression's own - // name included), an import, an enum, or a namespace. A method's name, a - // property's, a label, and an export specifier's do not bind it. A - // direct `eval` can declare a `var` at run time, and a `with` reads names - // through an object, so either one in the file makes its scopes dynamic. - const declaring = (node) => - ts.isVariableDeclaration(node) || - ts.isParameter(node) || - ts.isBindingElement(node) || - ts.isFunctionDeclaration(node) || - ts.isFunctionExpression(node) || - ts.isClassDeclaration(node) || - ts.isClassExpression(node) || - ts.isImportClause(node) || - ts.isImportSpecifier(node) || - ts.isNamespaceImport(node) || - ts.isImportEqualsDeclaration(node) || - ts.isEnumDeclaration(node) || - ts.isModuleDeclaration(node) - let bindsUndefined = false - let dynamicScope = false - const scan = (node) => { - if ( - ts.isIdentifier(node) && - node.text === `undefined` && - declaring(node.parent) && - node.parent.name === node - ) - bindsUndefined = true - if ( - ts.isWithStatement(node) || - (ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - node.expression.text === `eval`) + // 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 global `eval`, through parentheses + // or a type's wrapper) 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) ) - dynamicScope = true + 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` && global(callee)) + 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` && - !bindsUndefined && - !dynamicScope + staticScopes() && + global(inner) ) } // A receiver that is never nullish, whatever the inputs: a string or @@ -1000,25 +947,24 @@ export function instrument(ts, path, text, source, offset = 0) { const constantReceiver = (expression) => { const inner = bare(expression) if (ts.isStringLiteral(inner) || ts.isNumericLiteral(inner)) return true - if (!ts.isIdentifier(inner) || dynamicScope) return false - for (let n = inner.parent; n; n = n.parent) - if (ts.isWithStatement(n)) return false - const found = declarationOf(inner) + if (!ts.isIdentifier(inner) || !staticScopes()) return false + const found = bindingOf(inner) + const statement = found?.parent?.parent if ( !found || - !ts.isVariableStatement(found) || - (found.declarationList.flags & ts.NodeFlags.Const) === 0 || - found.modifiers?.some( + !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(found.parent) + !ts.isSourceFile(statement.parent) ) return false - const init = found.declarationList.declarations.find( - (d) => ts.isIdentifier(d.name) && d.name.text === inner.text, - )?.initializer + const init = found.initializer return ( init !== undefined && (ts.isStringLiteral(init) || From f16fce3ceb3d3badbdfb885d45ec486cc7e75ef3 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 11 Nov 2026 15:27:43 -0700 Subject: [PATCH 09/12] fix(oracles): a name inside a with resolves to nothing, so no with check in localBinding TypeScript's binder resolves no name inside a with statement, so the localBinding check for one guarded nothing: its mutant passed the oracle (0 of 33092). The file-wide with check stays where it matters, for a returned undefined, which resolves to no declaration either way. Co-authored-by: Isaac --- packages/oracles/oracle-gaps/schemata.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/oracles/oracle-gaps/schemata.mjs b/packages/oracles/oracle-gaps/schemata.mjs index 18aee05..c9025ad 100644 --- a/packages/oracles/oracle-gaps/schemata.mjs +++ b/packages/oracles/oracle-gaps/schemata.mjs @@ -566,8 +566,9 @@ export function instrument(ts, path, text, source, offset = 0) { : undefined } // 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), in a - // file with no `with`, which reads names through an object. A global 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 @@ -586,7 +587,6 @@ export function instrument(ts, path, text, source, offset = 0) { return n } const localBinding = (identifier) => { - if (dynamicScope.with) return false const found = bindingOf(identifier) if (found === undefined) return false const declared = root(found) From 8bf7f1b9cbe143cc5359a4eb1b3dc4ff910de70b Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 11 Nov 2026 15:44:28 -0700 Subject: [PATCH 10/12] test(oracles): settled async and generator runs, eval held by a local binding, and labels without strict null checks (RED) The text programs' model and driver now await a returned promise and drive a returned iterator, eight steps at most, and compare how each settles; the model's runs are computed when the file loads. The dead-zone, scope, binding, and returned-call programs are generated again as async functions, generators, and async generators, with the generator witness of a yield before a let. The scope family calls eval through a parameter, a destructured var, and a caught error that hold the real eval. The label law's declared inputs admit null and undefined, since the CLI does not know a package's strict null checks. On f16fce3 this fails 1177 of 33395. Co-authored-by: Isaac --- ...t-mutant-validity-oracle.property.test.mjs | 301 ++++++++++++++++-- 1 file changed, 274 insertions(+), 27 deletions(-) diff --git a/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs index b5dbc58..e8c0635 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 @@ -1587,6 +1587,124 @@ for (const [spelling, lang] of [ 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`], @@ -1665,8 +1783,8 @@ const TEXT_INPUTS = { ), ), chainBigint: () => - [4n, 5n, 5].flatMap((a) => - [5n, 5].flatMap((b) => + [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]), ), @@ -1724,14 +1842,94 @@ const loadText = (text) => { }).outputText return new Function(`${js}\nreturn f`)() } -const textCache = new Map() -function evaluateText(program, input, change) { - const text = change?.kind === `text` ? change.text : program.text - if (!textCache.has(text)) textCache.set(text, loadText(text)) - try { - return encoded(textCache.get(text)(...input)) - } catch (error) { - return `threw ${error.name}` +// 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 ${error?.name}` + } + } + 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 ${error?.name}`) + } + 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 settled(f(...input)) + } catch (error) { + result = `threw ${error.name}` + } + 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) + return { + source: program.text, + 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) { @@ -1745,6 +1943,15 @@ function renderText(program) { } } 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( @@ -1753,7 +1960,9 @@ const textTypedDomain = (program) => { COERCED_TYPED[program.annotation](q), ) if (program.family === `chain`) { - const operand = (v) => typeof v === program.operandType + // 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) @@ -2852,9 +3061,9 @@ const families = { evaluate: evaluateReturned, domain: returnDomain, }, + // A text program's model is its settled runs (`textModel`). text: { render: renderText, - evaluate: evaluateText, domain: textDomain, typedDomain: textTypedDomain, }, @@ -2879,10 +3088,16 @@ const shows = (program, input, change) => { // 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) - : DOMAIN[program.type] + : LOOSE_DOMAIN[program.type] const zeroDomainOf = (program) => familyOf(program) ? domainOf(program).filter(([, a]) => a === 0) @@ -2915,6 +3130,7 @@ function modelRuns(program, change) { function model(program) { 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 @@ -2986,9 +3202,7 @@ function switchFor(active) { // The free global `g`, whose getter counts the reads of one call. let globalReads = 0 // Each input's result and the reads it made, with `g` installed as a counter. -// A text program's run neither copies its inputs (a getter would run in the -// copy) nor keeps a result's identity (`encoded`). -function runAll(f, domain, plain = false) { +function runAll(f, domain) { Object.defineProperty(globalThis, FREE, { configurable: true, get: () => ++globalReads, @@ -3004,15 +3218,11 @@ function runAll(f, domain, plain = false) { try { result = f( ...input.map((value) => - !plain && - typeof value === `object` && - value !== null && - value !== VALUED + typeof value === `object` && value !== null && value !== VALUED ? { ...value } : value, ), ) - if (plain) result = encoded(result) } catch (error) { result = `threw ${error.name}` } @@ -3026,11 +3236,11 @@ const sameRuns = (xs, ys) => xs.every( (x, i) => Object.is(x.result, ys[i].result) && x.reads === ys[i].reads, ) -function runsUnder(code, active, domain, plain = false) { +function runsUnder(code, active, domain) { const previous = globalThis.__oracleGaps globalThis.__oracleGaps = switchFor(active) try { - return runAll(load(code), domain, plain) + return runAll(load(code), domain) } finally { globalThis.__oracleGaps = previous } @@ -3117,12 +3327,49 @@ function escapeDriver(program, observe) { }) } +// 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) { 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 plain = program.kind === `text` - const original = runAll(load(text), domain, plain) + const original = runAll(load(text), domain) const ext = program.lang === `js` ? `js` : `ts` const path = `/mutant-validity.${ext}` const { code, mutants } = instrument( @@ -3131,9 +3378,9 @@ function driver(program, observe) { text, `src/mutant-validity.${ext}`, ) - const clean = sameRuns(runsUnder(code, null, domain, plain), original) + const clean = sameRuns(runsUnder(code, null, domain), original) const emitted = mutants.map((mutant) => { - const runs = runsUnder(code, mutant.number, domain, plain) + const runs = runsUnder(code, mutant.number, domain) return { operator: mutant.operator, position: mutant.position, From c8460695f3fd53de7022af45953708dec33c185e Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 11 Nov 2026 15:47:47 -0700 Subject: [PATCH 11/12] fix(oracles): a call of any eval is direct, and a bigint label needs a nonzero bigint literal From the eighth review. A sloppy call of the identifier eval is direct whenever the binding holds the real eval, whatever binding it is (a parameter, a destructured var, a caught error), so any such call makes the file's scopes dynamic. A bigint admits null without strict null checks, and null ties with 0n, so the swap label needs a nonzero bigint literal at the tie. The forced-tie label stays: === never ties null with a bigint. Validity oracle: 0 of 33395. Co-authored-by: Isaac --- packages/oracles/oracle-gaps/schemata.mjs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/oracles/oracle-gaps/schemata.mjs b/packages/oracles/oracle-gaps/schemata.mjs index c9025ad..525d53e 100644 --- a/packages/oracles/oracle-gaps/schemata.mjs +++ b/packages/oracles/oracle-gaps/schemata.mjs @@ -818,8 +818,16 @@ export function instrument(ts, path, text, source, offset = 0) { : 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 `a number in place of a 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)) @@ -899,8 +907,10 @@ export function instrument(ts, path, text, source, offset = 0) { // 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 global `eval`, through parentheses - // or a type's wrapper) can declare a `var` at run time, and a `with` reads + // 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 @@ -921,7 +931,7 @@ export function instrument(ts, path, text, source, offset = 0) { if (ts.isWithStatement(node)) dynamicScope.with = true if (ts.isCallExpression(node)) { const callee = unwrapped(node.expression) - if (ts.isIdentifier(callee) && callee.text === `eval` && global(callee)) + if (ts.isIdentifier(callee) && callee.text === `eval`) dynamicScope.eval = true } ts.forEachChild(node, scan) From 46470fc947e55e7fc20e6d01d9acaf842f3bfbff Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 11 Nov 2026 17:24:21 -0700 Subject: [PATCH 12/12] test(oracles): bound a text program's settling, name any thrown value, and state the settled comparison's limits A run that has not settled after two seconds is recorded as such, and its program's test fails, so a program that never settles cannot hang the file's collection. A thrown value's name is an error's name, and otherwise its kind, so a throw null no longer throws in the oracle. The law's limits state the blind spots of the settled comparison that the ninth review confirmed; none reaches a defect in today's grammar. Co-authored-by: Isaac --- ...t-mutant-validity-oracle.property.test.mjs | 50 ++++++++++++++++--- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-mutant-validity-oracle.property.test.mjs index e8c0635..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 @@ -1842,6 +1842,27 @@ const loadText = (text) => { }).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, @@ -1852,7 +1873,7 @@ async function settled(value) { try { return `fulfilled ${encoded(await value)}` } catch (error) { - return `rejected ${error?.name}` + return `rejected ${errorName(error)}` } } if ( @@ -1867,7 +1888,7 @@ async function settled(value) { if (step.done) break } } catch (error) { - steps.push(`threw ${error?.name}`) + steps.push(`threw ${errorName(error)}`) } return `iterated ${steps.join(`, `)}` } @@ -1880,9 +1901,9 @@ async function settledRuns(f, domain) { const before = effects.count let result try { - result = await settled(f(...input)) + result = await bounded(settled(f(...input))) } catch (error) { - result = `threw ${error.name}` + result = `threw ${errorName(error)}` } runs.push({ result, reads: effects.count - before }) } @@ -1910,8 +1931,17 @@ const sameRun = (x, y) => x.result === y.result && x.reads === y.reads 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)), @@ -2702,7 +2732,7 @@ function runEscape(program, text) { try { result = shown(exported.f(...input)) } catch (error) { - result = `threw ${error.name}` + result = `threw ${errorName(error)}` } return `${result}:${calls()}` }) @@ -3224,7 +3254,7 @@ function runAll(f, domain) { ), ) } catch (error) { - result = `threw ${error.name}` + result = `threw ${errorName(error)}` } return { result, reads: globalReads + effects.count - before } }) @@ -3467,6 +3497,12 @@ function check(program, observations, expected) { 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`, @@ -3632,7 +3668,7 @@ oracleProperty({ law: { 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.`, + 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: { enumerated: histories, states: REQUIRED_STATES },