diff --git a/.changeset/gap-report-super-calls.md b/.changeset/gap-report-super-calls.md new file mode 100644 index 0000000..85b2ea2 --- /dev/null +++ b/.changeset/gap-report-super-calls.md @@ -0,0 +1,5 @@ +--- +'@tanstack/oracles': patch +--- + +Fix `oracle-gaps` instrumenting `super(...)` calls. Its statement-deletion mutant wrapped a `super(...)` call, so a derived class's constructor could skip the call, or read `this` before it, and every construction failed with no mutant active. A derived constructor now gets no mutation site in its super region: the statements before its first statement that calls `super(...)`, and that statement. So no mutant can prevent or delay the call. Statements after the call keep their sites. The check covers the call as a statement, as an arrow argument, and as a logical operand, and an early `return` before it. Limit: the check runs the derived class under TypeScript's ES2022 CommonJS transpile with a copy of the mutant switch, not under Vitest's transform. diff --git a/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs b/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs index b77dbdc..12a7fc5 100644 --- a/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs +++ b/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs @@ -33,6 +33,13 @@ const SOURCE = `src/sut.ts` const LIB = `src/lib.ts` // A Svelte module, which only the static half reads: no test loads it. const RUNES = `src/state.svelte.ts` +// A module with a base class and a derived class, which only the static half +// reads: no test loads it. See `derivedFile`. +const DERIVED = `src/derived.ts` +// The derived class's constructor, as the model names it: no oracle owns it. +const DERIVED_FN = `Derived.constructor` +// 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. const OWN_MODULES = join( dirname(fileURLToPath(import.meta.url)), @@ -182,6 +189,37 @@ const runeStatement = fc.oneof( rune: fc.constantFrom(`$effect`, `$effect.pre`), }), ) +// A derived class (DERIVED's `Derived`, which extends `DerivedBase`). Its +// constructor runs `before` statements, then one `super('a')` call in one of +// four forms, then `after` statements. A before statement may return early +// (an object, with no `super` call). The class's parameters may be parameter +// properties, and it may have a field initializer. +const derivedBefore = fc.oneof( + fc.record({ kind: fc.constant(`push`) }), + fc.record({ kind: fc.constant(`if`), op: comparison, k: constant }), + fc.record({ kind: fc.constant(`early`), op: comparison, k: constant }), +) +const derivedAfter = fc.oneof( + fc.record({ kind: fc.constant(`push`) }), + fc.record({ kind: fc.constant(`tag`) }), + fc.record({ kind: fc.constant(`if`), op: comparison, k: constant }), +) +const derivedClass = fc + .record({ + params: fc.constantFrom(`plain`, `property`), + field: fc.boolean(), + before: fc.array(derivedBefore, { maxLength: 3 }), + // `plain`: a statement `super('a')`; `arrow`: called in an arrow passed to + // a call; `and` and `or`: its right operand of `&&` or `||`. + form: fc.constantFrom(`plain`, `arrow`, `and`, `or`), + after: fc.array(derivedAfter, { maxLength: 3 }), + }) + // Only a statement `super` is legal TypeScript beside parameter properties + // or a field initializer: TypeScript emits the former before the body, and + // requires a root-level `super` for the latter. + .filter( + (spec) => spec.form === `plain` || (spec.params === `plain` && !spec.field), + ) // The framework's route vocabulary (oracle-route.ts), and three names it // retired, which a registration may still carry and which are unknown // routes now. @@ -315,6 +353,8 @@ const histories = fc.record({ runes: fc.array(fc.array(runeStatement, { minLength: 1, maxLength: 4 }), { maxLength: 2, }), + // The derived class of DERIVED's file, when there is one. + derived: fc.option(derivedClass, { nil: null, freq: 4 }), // How `--source` spells each path: as the package names it, with a // leading `./`, or through a redundant segment. Each names the same file, // and the report names it by its path in the package. @@ -1454,6 +1494,108 @@ function renderStatement(stmt, callText) { throw new Error(`unknown statement kind ${stmt.kind}`) } +// ## The derived class's file +// +// `Derived` extends `DerivedBase`. Its constructor runs the spec's `before` +// statements, the `super('a')` call in the spec's form, then its `after` +// statements. Each statement is one line, so a site's line is its statement's +// line. Before `super`, a push labels `b`, an `if` labels `c` when its +// condition holds, and an `early` returns an object when it holds. After +// `super`, a push labels `a`, `tag` pushes `this.tag` (`a`), and an `if` +// labels `d` when its condition holds. `nothing` returns 0, so the `or` +// form calls `super` as its right operand. `derivedMake` returns a new +// `Derived`. `statements` records each statement with its phase, its line, +// its text, and its call, which is what a site of the statement is. +const superCall = { + plain: `super('a');`, + arrow: `[0].forEach(() => super('a'));`, + and: `out && super('a');`, + or: `nothing(n) || super('a');`, +} +function derivedFile(spec) { + const lines = [ + `export class DerivedBase {`, + ` tag: string`, + ` constructor(tag: string) {`, + ` this.tag = tag`, + ` }`, + `}`, + `export class Derived extends DerivedBase {`, + ] + if (spec.field) lines.push(` count = 0`) + lines.push( + ` constructor(${spec.params === `property` ? `public ` : ``}n: number, out: Array) {`, + ) + const statements = [] + const statement = (phase, kind, text, call) => { + lines.push(` ${text}`) + statements.push({ phase, kind, text, call, line: lines.length }) + } + for (const [i, s] of spec.before.entries()) { + if (s.kind === `push`) + statement(`before`, `push`, `out.push('b${i}');`, `out.push('b${i}');`) + else if (s.kind === `if`) + statement( + `before`, + `if`, + `if (n ${s.op} ${s.k}) out.push('c${i}');`, + `out.push('c${i}');`, + ) + else statement(`before`, `early`, `if (n ${s.op} ${s.k}) return {};`) + } + statement(`super`, `super`, superCall[spec.form], undefined) + for (const [i, s] of spec.after.entries()) { + if (s.kind === `push`) + statement(`after`, `push`, `out.push('a${i}');`, `out.push('a${i}');`) + else if (s.kind === `tag`) + statement(`after`, `tag`, `out.push(this.tag);`, `out.push(this.tag);`) + else + statement( + `after`, + `if`, + `if (n ${s.op} ${s.k}) out.push('d${i}');`, + `out.push('d${i}');`, + ) + } + lines.push(` }`, `}`) + lines.push( + `function nothing(x: number): number {`, + ` return x - x;`, + `}`, + `export function derivedMake(x: number, out: Array): Derived {`, + ` return new Derived(x, out);`, + `}`, + ) + return { source: `${lines.join(`\n`)}\n`, statements } +} + +// What a derived class's constructor pushes for `x`, and the fields it sets: +// the labels in order (each `if` pushes its label when its comparison holds +// for `x`), and, unless an early `return` left the constructor first, the tag +// the base class sets, `n` for a parameter property, and `count` for a field +// initializer. An early return yields the object it returns, with no fields. +// The source's and the instrumented copy's runs must both agree with it. +export function derivedExpected(spec, x) { + const labels = [] + for (const [i, s] of spec.before.entries()) { + if (s.kind === `push`) labels.push(`b${i}`) + else if (s.kind === `if`) { + if (holds(x, s.op, s.k)) labels.push(`c${i}`) + } else if (holds(x, s.op, s.k)) return { labels } + } + spec.after.forEach((s, i) => { + if (s.kind === `push`) labels.push(`a${i}`) + else if (s.kind === `tag`) labels.push(`a`) + else if (holds(x, s.op, s.k)) labels.push(`d${i}`) + }) + return { + labels, + tag: `a`, + ...(spec.params === `property` ? { n: x } : {}), + ...(spec.field ? { count: 0 } : {}), + } +} + function resolveHistory(history) { let label = 0 const containerOf = (index) => history.containers[index] ?? `free` @@ -1584,6 +1726,9 @@ function resolveHistory(history) { body, tests: [], })) + // The derived class's file, which is no function of the model's (see + // `derivedFile`). + const file = history.derived ? derivedFile(history.derived) : undefined const functions = [ ...generated, ...library, @@ -1873,12 +2018,18 @@ function resolveHistory(history) { LIB, ...(runesSource ? [RUNES] : []), ...(dotGenSource ? [DOTGEN] : []), + ...(file ? [DERIVED] : []), ], dotGenSource, history, source, librarySource, runesSource, + derived: file && { + spec: history.derived, + source: file.source, + statements: file.statements, + }, } } @@ -2263,6 +2414,22 @@ function sitesOf(plan) { } } } + // 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 + // `super` call (maintainer decision, 2026-11-09). After `super`, a push or + // tag has its call's site, and an `if` the sites of its condition and call. + for (const s of plan.derived?.statements ?? []) { + if (s.phase !== `after`) continue + const at = (operator, text) => + add(DERIVED_FN, s.line, operator, text, { file: DERIVED }) + if (s.kind === `if`) { + at(`if-true`, s.text) + at(`if-false`, s.text) + at(`binary-swap`, s.text) + } + at(`delete-call`, s.call) + } // A call statement to a probe helper is probe-only code (law 2), at // module level too. for (const call of plan.loadCalls) @@ -2348,7 +2515,7 @@ function ownersOf(plan) { return (id) => new Set([ ...(owners.get(id) ?? []), - ...(plan.wholeFile === `none` || inLibrary.has(id) + ...(plan.wholeFile === `none` || inLibrary.has(id) || id === DERIVED_FN ? [] : [plan.wholeFile]), ]) @@ -3208,6 +3375,7 @@ function fixtureFiles(plan, state = initialState(plan)) { : plan.source, [LIB]: plan.librarySource, ...(plan.runesSource ? { [RUNES]: plan.runesSource } : {}), + ...(plan.derived ? { [DERIVED]: plan.derived.source } : {}), ...(plan.dotGenSource ? { [DOTGEN]: plan.dotGenSource } : {}), 'tests/strict.ts': `export const strict = ${state.strict}\n`, // A workspace dependency with module state, linked through node_modules. @@ -3528,6 +3696,7 @@ export { SOURCE, LIB, RUNES, + DERIVED, DOTGEN, caseless, removePath, 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 814395f..086c23a 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -297,7 +297,7 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, relative, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import fc from 'fast-check' import { declareOracle } from '../src/oracle-declaration.ts' import { socketOf, stateDirOf } from './daemon.mjs' @@ -428,7 +428,7 @@ function runCli(dir, files, how = {}) { maxBuffer: 64 * 1024 * 1024, env: cleanEnv(), }) - return readRun(dir, out, child.status, child.stderr) + return readRun(dir, out, child.status, child.stderr, how.cli ?? here) } /** Runs the CLI several times at once on one state of the fixture. */ @@ -453,13 +453,34 @@ async function runClisAtOnce(dir, files, hows) { resolvePromise(code) }) }) - return readRun(dir, out, status, stderr) + return readRun(dir, out, status, stderr, how.cli ?? here) }), ) } +// Output with each scratch directory named, as a path or as the file URL the +// stack spells it by (its escapes included). +function namedScratch(text, names) { + let named = text + for (const [path, name] of names) + if (path !== undefined) + named = named + .replaceAll(pathToFileURL(path).href, name) + .replaceAll(path, name) + return named +} + /** What one run of the CLI reported, from its exit status and its files. */ -function readRun(dir, out, status, stderr) { +function readRun(dir, out, status, stderr, cli) { + // The output names the CLI directory that ran and the package, as scratch + // directories do, before it is cut to its last characters. + const named = + status === 0 + ? undefined + : namedScratch(`${stderr}`, [ + [cli, ``], + [dir, ``], + ]) let results let report let summary = `` @@ -472,7 +493,7 @@ function readRun(dir, out, status, stderr) { } return { exit: status, - stderr: status === 0 ? undefined : `${stderr}`.slice(-1500), + stderr: named === undefined ? undefined : named.slice(-1500), reported: results !== undefined, // Law 9 compares everything the run reports except how it was // produced: durations, the options it was given (the output directory @@ -493,8 +514,7 @@ function readRun(dir, out, status, stderr) { ), evidence: report?.evidence, // A refused run is compared by the cause it names. - refusal: - status === 0 ? undefined : `${stderr}`.match(/^Error: (.*)$/m)?.[1], + refusal: named?.match(/^Error: (.*)$/m)?.[1], mutants: (results ?? []).map((r) => ({ key: r.key, source: r.source, @@ -640,12 +660,9 @@ async function incremental(dir, plan, cli, copy, observe, states) { exit: run.exit, refusal: run.refusal, report: run.report, - // The first line of a failure, for the message, without the paths - // of this history's scratch package and CLI copy. - stderr: run.stderr - ?.split(`\n`)[0] - .replaceAll(copy, ``) - .replaceAll(dir, ``), + // The first line of a failure, for the message; readRun has named + // the scratch package and CLI copy in it. + stderr: run.stderr?.split(`\n`)[0], }, }) if (twice && step === `start`) @@ -836,10 +853,12 @@ async function incremental(dir, plan, cli, copy, observe, states) { async function driver(plan, observe) { // A package that resolves no TypeScript lives outside this repository. - const dir = mkdtempSync( - plan.fault === `noTypescript` - ? join(realpathSync(tmpdir()), `gap-fixture-`) - : join(here, `.scratch-`), + const dir = realpathSync( + mkdtempSync( + plan.fault === `noTypescript` + ? join(realpathSync(tmpdir()), `gap-fixture-`) + : join(here, `.scratch-`), + ), ) // A history with steps runs its own copy of the CLI, which the `cli` edit // changes; a history that installs the CLI under a path with a space and @@ -1656,7 +1675,7 @@ oracleProperty({ law: { statement: `The gap report enumerates the promised operator mutants, leaves out exactly the probe-only code, discovers every oracle that owns a function's call tree and refuses broken ownership, judges each mutant only by its owners' tests on their registered routes and counts only law verdicts on reproducible routes as kills, which outrank a run that never ends, anchors each mutant by its function and normalized statement, lists every gap and unowned mutant once with consistent counts, keeps keys stable under unrelated edits, reports through one daemon per package what a cold run reports at every state of a sequence of edits, and runs the package as Vitest does from any install path and any --source spelling: one instance of each workspace dependency, no refusal over an unbuildable devDependency, child processes for a native addon anywhere in the tree, a capture that a test's builtin spies cannot see, and a refusal of test.projects.`, authority: `The maintainer's decisions in design runs v11, v14, and v19 (2026-11-04 and 2026-11-05), not in this package's records, including that a kill by reach agreement alone is a gap and that an incremental run through the daemon equals a cold one (v19.3); the project's oracle guide on quiet exclusions; the oracle rubric's outcome kinds (ORC-006); the CLI's documented operator list in schemata.mjs.`, - limits: `Hangs only as an owner test that awaits a promise that never settles, under a 1 s first budget and a 6 s full limit, and a slow run only as one witness that waits 3 s before it judges; busy loops only on instrumented code (cut by the call budget), no slow runs past the full limit that would end, hangs in ordinary or nondeterministic tests, setup errors other than a throw at module load, loops, multi-line conditions, two functions on one line, or await, new, and void statements; nested functions, object-literal methods, accessors, and class-field arrows only where no test calls them (their ownership is the static oracle's); two source files of one package, with no re-exports, namespace imports, or calls into another package; no --only, --sample, or --diff-base; package configs only in vite.config.ts, vitest.config.ts, or vite.config.mjs, as an object or a sync or async function, with no plugins of their own and test.projects only as a refusal; no probe-bound states, so reach agreement's killing of probe mutants is not checked; no gap answers or gate; law 9's edits leave out files read through fs, other environment variables, new files that change how an unchanged import resolves, Vite config and installed-package changes, and concurrent checks other than two that start the daemon; cut-off charging judged only under one budget; native addons only as a binding.gyp or a prebuilt .node file, not as optional platform packages; daemon races (two CLIs reading one error file, a CLI hearing closing from the failing daemon it started, a reset connection on macOS) fixed by inspection.`, + limits: `Hangs only as an owner test that awaits a promise that never settles, under a 1 s first budget and a 6 s full limit, and a slow run only as one witness that waits 3 s before it judges; busy loops only on instrumented code (cut by the call budget), no slow runs past the full limit that would end, hangs in ordinary or nondeterministic tests, setup errors other than a throw at module load, loops, multi-line conditions, two functions on one line, or await, new, and void statements; nested functions, object-literal methods, accessors, and class-field arrows only where no test calls them (their ownership is the static oracle's); two source files of one package, with no re-exports, namespace imports, or calls into another package; no --only, --sample, or --diff-base; a derived class's file is instrumented and its sites are judged as unowned, but no test imports or constructs the class, so its no-mutant and super-region behavior is the static oracle's (in-process), not this oracle's; package configs only in vite.config.ts, vitest.config.ts, or vite.config.mjs, as an object or a sync or async function, with no plugins of their own and test.projects only as a refusal; no probe-bound states, so reach agreement's killing of probe mutants is not checked; no gap answers or gate; law 9's edits leave out files read through fs, other environment variables, new files that change how an unchanged import resolves, Vite config and installed-package changes, and concurrent checks other than two that start the daemon; cut-off charging judged only under one budget; native addons only as a binding.gyp or a prebuilt .node file, not as optional platform packages; daemon races (two CLIs reading one error file, a CLI hearing closing from the failing daemon it started, a reset connection on macOS) fixed by inspection.`, owner: `packages/oracles/oracle-gaps`, }, histories: { diff --git a/packages/oracles/oracle-gaps/gap-report-static-oracle.property.test.mjs b/packages/oracles/oracle-gaps/gap-report-static-oracle.property.test.mjs index 884f2b7..ee231b9 100644 --- a/packages/oracles/oracle-gaps/gap-report-static-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-static-oracle.property.test.mjs @@ -83,9 +83,12 @@ import { declareOracle } from '../src/oracle-declaration.ts' import { oracleProperty, pass, violation } from '../src/oracle-property.ts' import { analyze, isRunnerCopy } from './analysis.mjs' import { + DERIVED, + DERIVED_INPUTS, LIB, RUNES, SOURCE, + derivedExpected, edited, fixtureFiles, histories, @@ -142,12 +145,176 @@ function analyzeFixture(dir, files, plan) { runes: plan.runesSource ? instrumented.get(realpathSync(join(dir, RUNES))) : undefined, + derived: plan.derived + ? { + code: instrumented.get(realpathSync(join(dir, DERIVED))), + numbers: mutants + .filter((m) => m.source === DERIVED) + .map((m) => m.number), + } + : undefined, } } catch (error) { return { refusal: String(error?.message ?? error) } } } +// ## The derived class, run +// +// The derived file's text runs as a CommonJS module, first as written and then +// instrumented, for each input, and under each of the instrumented copy's +// mutants. A run uses the switch the instrumented copy calls: `m` is true for +// the active mutant's site, and each forced form (`cmp`, `not`, `b`, `cond`) +// takes the mutant's rewrite. This restates the switch's contract, the one +// schemata.mjs states per operator. A run returns what the constructor pushed, +// the fields it set, or the error it threw. +const opposite = { + '<': '<=', + '<=': '<', + '>': '>=', + '>=': '>', + '===': '!==', + '!==': '===', +} +const holdsFor = (a, op, b) => + op === '<' + ? a < b + : op === '<=' + ? a <= b + : op === '>' + ? a > b + : op === '>=' + ? a >= b + : op === '===' + ? a === b + : a !== b +function switchFor(active) { + return { + m: (id) => id === active, + v: (value) => value, + cmp: (id, a, b, op) => holdsFor(a, id === active ? opposite[op] : op, b), + not: (id, value) => (id === active ? value : !value), + b: (id, value) => (id === active ? !value : value), + cond: (ifTrue, ifFalse, value) => + active === ifTrue ? true : active === ifFalse ? false : value, + } +} +function runDerived(ts, code, active, x) { + const js = ts.transpileModule(code, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, + }, + }).outputText + const exported = {} + const previous = globalThis.__oracleGaps + globalThis.__oracleGaps = switchFor(active) + try { + new Function(`exports`, js)(exported) + const out = [] + const made = exported.derivedMake(x, out) + return { labels: out, tag: made.tag, n: made.n, count: made.count } + } catch (error) { + // A derived constructor that reads `this` before `super`, or returns + // without it, throws a ReferenceError: that is the run's outcome. Any + // other error is the harness's, and is not a result. + if (!(error instanceof ReferenceError)) throw error + return { error: error.message } + } finally { + globalThis.__oracleGaps = previous + } +} +// The form of the instrumented copy's super call, read from its syntax: +// `and` and `or` for a logical operand, `arrow` for a call inside an arrow, +// and `plain` for a statement. +function superFormOf(ts, code) { + const file = ts.createSourceFile( + `derived.ts`, + code, + ts.ScriptTarget.Latest, + true, + ) + let form + const visit = (node) => { + if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.SuperKeyword + ) { + const { parent } = node + if (ts.isBinaryExpression(parent)) + form = + parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken + ? `and` + : `or` + else if (ts.isArrowFunction(parent)) form = `arrow` + else form ??= `plain` + } + ts.forEachChild(node, visit) + } + visit(file) + return form +} +function observeDerived(ts, source, code, numbers) { + const runs = (text, active) => + DERIVED_INPUTS.map((x) => runDerived(ts, text, active, x)) + return { + source: runs(source, null), + instrumented: runs(code, null), + form: superFormOf(ts, code), + mutants: numbers.map((number) => ({ number, runs: runs(code, number) })), + } +} +// The derived class's reach states, read from its observed runs: what the +// source's runs pushed and set, and the form its instrumented copy keeps. +const derivedStates = { + constructed: `a derived class constructed`, + property: `a derived class with a parameter property`, + plain: `a derived class with a plain parameter`, + field: `a derived class with a field initializer`, + early: `a derived class returned early before super`, + before: `a derived class ran a push before super`, + conditionalBefore: `a derived class ran a conditional push before super`, + after: `a derived class ran a push after super`, + conditionalAfter: `a derived class ran a conditional push after super`, + tag: `a derived class read this after super`, + formPlain: `a derived class calls super as a statement`, + arrow: `a derived class calls super in an arrow`, + and: `a derived class calls super after &&`, + or: `a derived class calls super after ||`, +} +function derivedReach(observed) { + const runs = observed.source + const labels = runs.flatMap((run) => run.labels ?? []) + const states = new Set() + if (runs.every((run) => run.error === undefined)) + states.add(derivedStates.constructed) + if (runs.some((run) => run.n !== undefined)) + states.add(derivedStates.property) + if (runs.some((run) => run.tag !== undefined && run.n === undefined)) + states.add(derivedStates.plain) + if (runs.some((run) => run.count !== undefined)) + states.add(derivedStates.field) + if (runs.some((run) => run.tag === undefined && run.error === undefined)) + states.add(derivedStates.early) + if (labels.some((label) => /^b\d/.test(label))) + states.add(derivedStates.before) + if (labels.some((label) => /^c\d/.test(label))) + states.add(derivedStates.conditionalBefore) + if (labels.some((label) => /^a\d/.test(label))) + states.add(derivedStates.after) + if (labels.some((label) => /^d\d/.test(label))) + states.add(derivedStates.conditionalAfter) + if (labels.includes(`a`)) states.add(derivedStates.tag) + const form = { + plain: derivedStates.formPlain, + arrow: derivedStates.arrow, + and: derivedStates.and, + or: derivedStates.or, + }[observed.form] + if (form) states.add(form) + return states +} + const ownedStates = { w_leaf: `owned through a call chain`, 'W0.step': `method owned through this`, @@ -194,7 +361,16 @@ async function driver(plan, observe) { const files = fixtureFiles(plan) const before = analyzeFixture(dir, files, plan) observe(`static`, before) - const states = new Set() + const derived = before.derived + ? observeDerived( + ts, + plan.derived.source, + before.derived.code, + before.derived.numbers, + ) + : undefined + if (derived) observe(`derived`, derived) + const states = new Set(derived ? derivedReach(derived) : []) if (before.refusal && plan.fault !== `none`) states.add(`refused ${plan.fault}`) // The statement whose lines hold a mutant, by the model's layout. @@ -351,6 +527,43 @@ function runePlacement(code) { return { calls, misplaced } } +// The derived class's laws, in order. Its source runs as the model says; its +// instrumented copy with no mutant active runs as its source does; and under +// every mutant the constructor still completes, with its base class's tag, +// so no mutant has made `super(...)` conditional, removed it, or moved it. +function checkDerived(plan, observed) { + if (!observed) + return violation(`derived`, `the derived class was not run`, `completion`) + const same = (a, b) => JSON.stringify(a) === JSON.stringify(b) + const owed = DERIVED_INPUTS.map((x) => derivedExpected(plan.derived.spec, x)) + for (const [i, x] of DERIVED_INPUTS.entries()) + if (!same(observed.source[i], owed[i])) + return violation( + `derived`, + `the source run for ${x} is ${JSON.stringify(observed.source[i])}, owed ${JSON.stringify(owed[i])}`, + `source behavior`, + ) + for (const [i, x] of DERIVED_INPUTS.entries()) + if (!same(observed.instrumented[i], observed.source[i])) + return violation( + `derived`, + `with no mutant active, the instrumented class for ${x} is ${JSON.stringify(observed.instrumented[i])}, the source's ${JSON.stringify(observed.source[i])}`, + `no-mutant behavior`, + { actual: observed.instrumented[i], expected: observed.source[i] }, + ) + for (const mutant of observed.mutants) + for (const [i, x] of DERIVED_INPUTS.entries()) { + const run = mutant.runs[i] + if (run.error !== undefined || run.tag !== owed[i].tag) + return violation( + `derived`, + `under mutant ${mutant.number} the class for ${x} ${run.error ?? `has tag ${JSON.stringify(run.tag)}`}`, + `super unconditional`, + ) + } + return undefined +} + function check(plan, observations, expected) { const got = observations.find((o) => o.checkpoint === `static`)?.value if (!got) return violation(`static`, `no analysis was observed`, `completion`) @@ -398,6 +611,11 @@ function check(plan, observations, expected) { `rune placement`, ) } + if (plan.derived) { + const observed = observations.find((o) => o.checkpoint === `derived`)?.value + const broken = checkDerived(plan, observed) + if (broken) return broken + } const owedMutants = runtime ? model({ ...plan, fault: `none` }).mutants : expected.mutants @@ -508,6 +726,24 @@ const calibrations = [ m.owned = false }), }, + { + name: `a derived class's super call is made conditional`, + law: `super unconditional`, + corrupt: corrupt(`derived`, (v) => { + const run = v.mutants[0]?.runs[0] + if (!run) return false + v.mutants[0].runs[0] = { + error: `Must call super constructor in derived class before accessing 'this'`, + } + }), + }, + { + name: `the instrumented derived class differs with no mutant active`, + law: `no-mutant behavior`, + corrupt: corrupt(`derived`, (v) => { + v.instrumented[0] = { error: `Must call super constructor` } + }), + }, { name: `a key drops its statement`, law: `keys`, @@ -536,6 +772,7 @@ const calibrations = [ const faults = [`badEntry`, `missingSource`, `nonLiteral`, `outsideSource`] const required = [ ...Object.values(ownedStates), + ...Object.values(derivedStates), ...Object.values(namedStates).map(([, state]) => state), `a $-named call outside Svelte judged`, `a Svelte module instrumented`, @@ -572,9 +809,9 @@ const required = [ oracleProperty({ id: `oracle-gaps.report-static`, law: { - statement: `The gap report's static half keeps every Svelte rune where Svelte allows it, enumerates the promised operator mutants, marks exactly the probe-only code, refuses broken owns declarations, gives each function the owners whose call trees contain it, anchors each mutant's key, and keeps keys, probe marks, and ownership stable under unrelated edits.`, - 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 frozen design's plan for an oracle over the checker's pure core (v19 P10, F4); the maintainer's few-seconds budget for an incremental test run (2026-11-05); for rune placement, Svelte 5's placement rule for runes (state_invalid_placement, effect_invalid_placement) and the maintainer's report of svelte-db's useLiveQuery.svelte.ts failing to compile under instrumentation (2026-11-08).`, - limits: `The grammar and model of oracle-gaps.report, with its limits. Outcomes that need test runs, and the failing-owner refusal, belong to oracle-gaps.report. Same-named functions, accessors, and field arrows are judged for ownership only; runes only as $state, $state.raw, $derived, $derived.by, $effect, and $effect.pre in a .svelte.ts module, judged by a restatement of Svelte's placement rule rather than the compiler.`, + statement: `The gap report's static half keeps every Svelte rune where Svelte allows it, enumerates the promised operator mutants, marks exactly the probe-only code, refuses broken owns declarations, gives each function the owners whose call trees contain it, anchors each mutant's key, keeps keys, probe marks, and ownership stable under unrelated edits, and keeps each derived class's \`super(...)\` call unconditional. In a derived constructor, no mutant site lies before the first statement that calls super or inside that statement (an arrow inherits super, so an arrow's call counts), and statements after it keep their sites. So no mutant, when active, prevents or delays the super call. With no mutant active the instrumented class behaves as its source does, and under every mutant the constructor completes with its base class's tag unless an early return ends it first.`, + 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 frozen design's plan for an oracle over the checker's pure core (v19 P10, F4); the maintainer's few-seconds budget for an incremental test run (2026-11-05); for rune placement, Svelte 5's placement rule for runes (state_invalid_placement, effect_invalid_placement) and the maintainer's report of svelte-db's useLiveQuery.svelte.ts failing to compile under instrumentation (2026-11-08); for derived classes, the gap report's contract that instrumentation with no mutant active does not change behavior and that a mutant is a legal program variant (the operator contract in schemata.mjs), ECMAScript's rule that a derived constructor calls \`super\` before it reads \`this\` and before it returns, the maintainer's report of TanStack DB's subscription.ts failing with no mutant active (G11, 2026-11-09), and the maintainer's decision of 2026-11-09 on the derived constructor's site region (every shape of super, including an early return before it, is covered by the one rule).`, + limits: `The grammar and model of oracle-gaps.report, with its limits. Outcomes that need test runs, and the failing-owner refusal, belong to oracle-gaps.report. Same-named functions, accessors, and field arrows are judged for ownership only; runes only as $state, $state.raw, $derived, $derived.by, $effect, and $effect.pre in a .svelte.ts module, judged by a restatement of Svelte's placement rule rather than the compiler. A derived class is judged in this oracle's in-process run and in the end-to-end oracle's static half: the end-to-end oracle instruments its file and judges its sites as unowned, but no test imports or constructs the class, so no test runs it. The in-process run transpiles the file to CommonJS at ES2022; other downlevel targets and Vitest's own transform are not exercised. The mutant switch (switchFor) is a hand copy of the switch's contract in schemata.mjs, not the capture setup's runtime: the capture setup imports Vitest and installs timers and globals when it loads, so it cannot run in-process. The derived grammar has one derived class per history, with parameter properties, a field, statements before and after super, an early return before it, and the four forms of the super call (a statement, an arrow argument, and the right operand of \`&&\` or \`||\`). It has no super call in a method, a nested class, or a \`super.method()\` call, and no default parameter.`, owner: `packages/oracles/oracle-gaps`, }, histories: { @@ -592,6 +829,11 @@ oracleProperty({ `a source spelled through a redundant segment`, `a source spelled through a link to the package`, `a source spelled in another letter case`, + // Each other form of the derived class is its own history's. + derivedStates.plain, + derivedStates.arrow, + derivedStates.and, + derivedStates.or, ...faults.map((f) => `refused ${f}`), ].includes(s), ), @@ -605,6 +847,25 @@ oracleProperty({ sourceSpelling: `dot`, ownsSpelling: `dot`, dotGen: true, + // A parameter property, a field, statements before `super(...)` + // (a conditional push, and an early return), the plain form of + // `super`, and statements after it: a push, `this` read, and a + // conditional push. + derived: { + params: `property`, + field: true, + before: [ + { kind: `push` }, + { kind: `if`, op: `>`, k: 4 }, + { kind: `early`, op: `>`, k: 4 }, + ], + form: `plain`, + after: [ + { kind: `push` }, + { kind: `tag` }, + { kind: `if`, op: `<`, k: 5 }, + ], + }, // A library function that calls the library's `$derived`. library: [[{ kind: `dollar` }], ...generated.library].slice(0, 2), // Every rune form, in a Svelte module. @@ -646,6 +907,46 @@ oracleProperty({ sourceSpelling: `redundant`, }), }, + { + name: `a plain-parameter derived class`, + enters: [derivedStates.plain], + build: (generated) => ({ + ...generated, + witness: false, + wholeFile: `none`, + fault: `none`, + stability: `none`, + steps: null, + derived: { + params: `plain`, + field: false, + before: [], + form: `plain`, + after: [{ kind: `push` }], + }, + }), + }, + // Each form of `super(...)` that is not a statement, with the + // statements around it: the arrow argument, and each logical operand. + ...[`arrow`, `and`, `or`].map((form) => ({ + name: `a derived class calling super ${form === `arrow` ? `in an arrow` : form === `and` ? `after &&` : `after ||`}`, + enters: [derivedStates[form]], + build: (generated) => ({ + ...generated, + witness: false, + wholeFile: `none`, + fault: `none`, + stability: `none`, + steps: null, + derived: { + params: `plain`, + field: false, + before: [], + form, + after: [{ kind: `push` }], + }, + }), + })), ...(caseless ? [ { diff --git a/packages/oracles/oracle-gaps/schemata.mjs b/packages/oracles/oracle-gaps/schemata.mjs index 0a025e0..0d0f3b3 100644 --- a/packages/oracles/oracle-gaps/schemata.mjs +++ b/packages/oracles/oracle-gaps/schemata.mjs @@ -24,6 +24,8 @@ // `false` (a `for` with no condition, `for...of`, and `for...in` have none). // - `delete-call`: skip a call statement (`switch || call()`). // +// No site lies in a derived constructor's super region (see `superGuarded`). +// // In a Svelte module (`.svelte.ts` or `.svelte.js`), a rune call that Svelte // allows in one place only (`$state`, `$derived`, `$effect`, `$props`, // `$bindable`, `$inspect`, and their `.raw`, `.by`, `.pre`, `.id`, and @@ -93,6 +95,8 @@ const placedRune = export function instrument(ts, path, text, source, offset = 0) { const file = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true) + // Without the word `super` no site can be in a super region: skip the walk. + const mentionsSuper = /\bsuper\b/.test(text) const svelte = /\.svelte\.[cm]?[jt]s$/.test(path) const isRune = (call) => svelte && placedRune.test(call.expression.getText(file)) @@ -125,9 +129,47 @@ export function instrument(ts, path, text, source, offset = 0) { ) } const mutants = [] + // Whether a node is a `super(...)` call. + const isSuperCall = (node) => + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.SuperKeyword + // Whether a node holds a `super(...)` call. An arrow inherits `super`, so + // its body counts; a function, method, accessor, constructor, or class does + // not, since `super` in it means something else. + const holdsSuper = (node) => + isSuperCall(node) || + ((!( + ts.isFunctionLike(node) || + ts.isClassLike(node) || + ts.isMethodDeclaration(node) || + ts.isConstructorDeclaration(node) || + ts.isGetAccessorDeclaration(node) || + ts.isSetAccessorDeclaration(node) + ) || + ts.isArrowFunction(node)) && + (ts.forEachChild(node, (child) => + holdsSuper(child) ? true : undefined, + ) ?? + false)) + // A derived constructor's super region: its parameters and the statements + // before its first statement that holds a `super(...)` call, and that + // statement. No mutant site lies in it, so no mutant, when active, can + // prevent or delay the call: a statement before it may return early or + // skip it, and the call's own statement may skip it. Statements after it + // keep their sites. Maintainer decision, 2026-11-09: one rule for every + // shape of `super`, including an early return before it. + const superGuarded = (node) => { + for (let owner = node; owner; owner = owner.parent) + if (ts.isConstructorDeclaration(owner) && owner.body) { + const first = owner.body.statements.find(holdsSuper) + if (first && node.getStart(file) < first.end) return true + } + return false + } // `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. const add = (at, operator, original, replacement) => { + if (mentionsSuper && superGuarded(at)) return undefined const probeGuard = guarded const local = mutants.length + 1 const id = offset + local @@ -490,7 +532,7 @@ export function instrument(ts, path, text, source, offset = 0) { ts.isElementAccessExpression(node.parent)) && node.parent.expression === node ) && - node.expression.kind !== ts.SyntaxKind.SuperKeyword && + !isSuperCall(node) && node.expression.kind !== ts.SyntaxKind.ImportKeyword && !isRune(node) ) @@ -516,6 +558,7 @@ export function instrument(ts, path, text, source, offset = 0) { if ( ts.isExpressionStatement(node) && ts.isCallExpression(node.expression) && + !isSuperCall(node.expression) && !isRune(node.expression) ) ids.call = add(node, `delete-call`, node.getText(file), `;`)