From 77a06fdb704c25eaf443eca9c64808c46b6c8a29 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 9 Nov 2026 11:36:06 -0700 Subject: [PATCH 1/2] fix(oracles): never instrument a super() call as a mutation site The statement-deletion mutant wrapped an expression statement that calls `super(...)` (`G.m(id) || super(...)`). A derived constructor then ran with `super` conditional or moved, and with TypeScript's parameter properties or field initializers it read `this` before `super` and threw ("Must call super constructor..."), so every construction failed with no mutant active. Expression-position super calls were already excluded; statement-position ones were not. The fix excludes a `super(...)` call from `delete-call` in schemata.mjs. The static oracle gains a derived-class grammar (DERIVED file: a base class and a derived class whose constructor runs statements before and after `super('a')`, with and without parameter properties and a field initializer). Its laws: the source runs as the model says; the instrumented class with no mutant active runs as its source does; under every mutant the constructor completes with the base tag; and the model's expected sites contain no site for the super call. Law: The static half of the gap report keeps each derived class's super call unconditional (no mutant removes or moves it; with no mutant active the instrumented class behaves as its source). Authority: 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), and ECMAScript's rule that a derived constructor calls super before it reads this and before it returns. Maintainer report G11 (TanStack DB subscription.ts, 2026-11-09). Reach: 21 generated histories (fixed seed 20261105) and 25 (random seed) entered `a derived class constructed`, and every derived state (`a derived class with a parameter property`, `... with a plain parameter`, `... with a field initializer`, `... with statements before super`, `... with no statement before super`, `... with a conditional before super`, `... with a statement after super`, `... reading this after super`) is reached under both seeds, with two constructed histories naming them so each is reached on every campaign. RED: on the parent code (schemata.mjs unchanged, new grammar), the static oracle fails law `no-mutant behavior` at checkpoint `derived`, fixed seed 20261105 (the shrunk history has `derived: {before: [], after: []}`): the instrumented class with no mutant active throws "Must call super constructor in derived class before accessing 'this' or returning from derived constructor", the source returns labels [] and tag "a". Both calibrations of the new laws are then also red, because their clean trace fails. GREEN: `vitest --run --config oracle-gaps/vitest.config.mjs` (all four files, 34 tests, exit 0) on the final tree, including the static oracle with its two new calibrations (`super unconditional`, `no-mutant behavior`) detected. Mutant: a hostile "fix" that exempts only a leading `super(...)` statement (the first statement of the constructor body) leaves a later super call wrapped. The static oracle rejects it with `no-mutant behavior` (fixed and random seeds). With no mutant active, the wrapped super call throws in the TypeScript-emitted class, so the no-mutant law catches it before the per-mutant law does. Harness fix found by the end-to-end run: the determinism law compared the CLI's raw stderr, and a refusal's stack names the CLI copy's directory (`oracle-gaps-mutant cli #`), which differs per run for a spaced-path history. The daemon branch already names those directories; the `cli` observation now names them the same way, in raw and file-URL form. The law still compares everything else the CLI reports. Mutant check: the failing determinism run (before the normalization) is the RED for this harness change; the GREEN is the full run above. Limits: derived classes are judged only in the static oracle's in-process run. No test imports the derived file, so the end-to-end oracle does not run it, and the run transpiles it to CommonJS at ES2022 (other downlevel targets and Vitest's transform are not run). The mutant switch restates the contract in schemata.mjs. The grammar has no `super.method()` call, no super in a value position, and no class of a container other than `Derived`. Verifying the TanStack DB subscription.ts case itself (~80 oracle files) is not in this change: it needs a run against that checkout. `createOracleReach` is TanStack DB's; this package declares reach with `states` and `constructed` histories (oracle-property.ts). Co-authored-by: Isaac --- .changeset/gap-report-super-calls.md | 5 + .../oracle-gaps/gap-report-model-oracle.mjs | 134 +++++++++- .../gap-report-oracle.property.test.mjs | 30 ++- ...gap-report-static-oracle.property.test.mjs | 228 +++++++++++++++++- packages/oracles/oracle-gaps/schemata.mjs | 7 +- 5 files changed, 396 insertions(+), 8 deletions(-) create mode 100644 .changeset/gap-report-super-calls.md diff --git a/.changeset/gap-report-super-calls.md b/.changeset/gap-report-super-calls.md new file mode 100644 index 0000000..e294af3 --- /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 an expression statement that calls `super(...)` (`m(15) || super()`), so a derived class's constructor could skip `super()` or read `this` before it, and every construction failed with no mutant active. The instrumenter no longer makes a `super(...)` call a mutation site, so no mutant can make it conditional, remove it, or move it. diff --git a/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs b/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs index b77dbdc..f0d0371 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,27 @@ const runeStatement = fc.oneof( rune: fc.constantFrom(`$effect`, `$effect.pre`), }), ) +// A derived class (DERIVED's `Derived`, which extends `DerivedBase`). Its +// constructor runs statements before `super(...)` that touch neither `this` +// nor `super`, then statements after it, which may read `this`. Its +// parameters may be parameter properties (`public n`), and it may have a +// field initializer. No mutant may make its `super(...)` call conditional, +// remove it, or move it, since the class would then read `this` before it +// is set, or return without it, and throw. +const derivedBefore = fc.oneof( + fc.record({ kind: fc.constant(`push`) }), + fc.record({ kind: fc.constant(`if`), op: comparison, k: constant }), +) +const derivedAfter = fc.oneof( + fc.record({ kind: fc.constant(`push`) }), + fc.record({ kind: fc.constant(`tag`) }), +) +const derivedClass = fc.record({ + params: fc.constantFrom(`plain`, `property`), + field: fc.boolean(), + before: fc.array(derivedBefore, { maxLength: 2 }), + after: fc.array(derivedAfter, { maxLength: 2 }), +}) // 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 +343,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 +1484,79 @@ 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 statements +// before `super('a')`, then those after it. Each statement is one line, so a +// site's line is its statement's line. A `push` before `super` labels its +// output `b`, an `if` labels `c` when its condition holds, and a push +// after it labels `a`, or pushes `this.tag` (`a`). The class's +// parameters may be parameter properties, and it may have a field +// initializer. `derivedMake` returns a new `Derived`. +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 = (text, call, extra) => { + lines.push(` ${text}`) + statements.push({ ...extra, text, call, line: lines.length }) + } + spec.before.forEach((s, i) => { + if (s.kind === `push`) + statement(`out.push('b${i}');`, `out.push('b${i}');`, { kind: `push` }) + else + statement( + `if (n ${s.op} ${s.k}) out.push('c${i}');`, + `out.push('c${i}');`, + { kind: `if` }, + ) + }) + lines.push(` super('a');`) + spec.after.forEach((s, i) => { + const call = + s.kind === `push` ? `out.push('a${i}');` : `out.push(this.tag);` + statement(call, call, { kind: s.kind }) + }) + lines.push(` }`, `}`) + lines.push( + `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`), the tag the base class sets, `n` for a parameter property, and +// `count` for a field initializer. The source's and the instrumented copy's +// runs must both agree with it. +export function derivedExpected(spec, x) { + const labels = [] + spec.before.forEach((s, i) => { + if (s.kind === `push`) labels.push(`b${i}`) + else if (holds(x, s.op, s.k)) labels.push(`c${i}`) + }) + spec.after.forEach((s, i) => labels.push(s.kind === `push` ? `a${i}` : `a`)) + 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 +1687,9 @@ function resolveHistory(history) { body, tests: [], })) + // The derived class's file, which is no function of the model's (see + // `derivedFile`). + const derived = history.derived ? derivedFile(history.derived) : undefined const functions = [ ...generated, ...library, @@ -1873,12 +1979,16 @@ function resolveHistory(history) { LIB, ...(runesSource ? [RUNES] : []), ...(dotGenSource ? [DOTGEN] : []), + ...(derived ? [DERIVED] : []), ], dotGenSource, history, source, librarySource, runesSource, + derivedSpec: history.derived ?? undefined, + derivedSource: derived?.source, + derivedStatements: derived?.statements, } } @@ -2263,6 +2373,25 @@ function sitesOf(plan) { } } } + // The derived class's file (`derivedFile`), which no oracle owns. Each + // `if` has the sites of its condition and of its call, and each other + // statement the site of its call. The `super(...)` call has no site: no + // mutant may make it conditional, remove it, or move it (a mutant is a + // legal program variant, and a derived constructor that skips `super` + // throws, or reads `this` before it). + for (const s of plan.derivedStatements ?? []) { + const at = (operator, text) => + add(DERIVED_FN, s.line, operator, text, { + file: DERIVED, + function: DERIVED_FN, + }) + 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 +2477,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 +3337,7 @@ function fixtureFiles(plan, state = initialState(plan)) { : plan.source, [LIB]: plan.librarySource, ...(plan.runesSource ? { [RUNES]: plan.runesSource } : {}), + ...(plan.derivedSource ? { [DERIVED]: plan.derivedSource } : {}), ...(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 +3658,8 @@ export { SOURCE, LIB, RUNES, + DERIVED, + DERIVED_FN, 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..e18ecdf 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' @@ -458,6 +458,18 @@ async function runClisAtOnce(dir, files, hows) { ) } +// 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) { let results @@ -858,8 +870,20 @@ async function driver(plan, observe) { } const cli = runCli(dir, files, how) // How many units were computed or reused says how the result was - // produced, not what it is, so it is not observed. - observe(`cli`, { ...cli, evidence: undefined }) + // produced, not what it is, so it is not observed. A refusal's stack + // names the CLI copy and the scratch package, each a fresh directory per + // run, so its output names them as the daemon's below does. + observe(`cli`, { + ...cli, + stderr: + cli.stderr === undefined + ? undefined + : namedScratch(cli.stderr, [ + [copy, ``], + [dir, ``], + ]), + evidence: undefined, + }) // Reach comes from what the CLI reported, mapped onto the plan. const states = new Set() // Each kind of declaration fault is its own state, so each refusal has 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..7978a53 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,91 @@ function analyzeFixture(dir, files, plan) { runes: plan.runesSource ? instrumented.get(realpathSync(join(dir, RUNES))) : undefined, + derived: plan.derivedSource + ? { + 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) { + return { error: String(error?.message ?? error) } + } finally { + globalThis.__oracleGaps = previous + } +} +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), + mutants: numbers.map((number) => ({ number, runs: runs(code, number) })), + } +} + const ownedStates = { w_leaf: `owned through a call chain`, 'W0.step': `method owned through this`, @@ -194,6 +276,16 @@ async function driver(plan, observe) { const files = fixtureFiles(plan) const before = analyzeFixture(dir, files, plan) observe(`static`, before) + if (before.derived) + observe( + `derived`, + observeDerived( + ts, + plan.derivedSource, + before.derived.code, + before.derived.numbers, + ), + ) const states = new Set() if (before.refusal && plan.fault !== `none`) states.add(`refused ${plan.fault}`) @@ -283,6 +375,27 @@ async function driver(plan, observe) { if (plan.dotGenSource) states.add(`a source under a ..-named directory`) if (plan.sourceSpelling === `cased`) states.add(`a source spelled in another letter case`) + if (plan.derivedSpec) { + const spec = plan.derivedSpec + states.add(`a derived class constructed`) + states.add( + spec.params === `property` + ? `a derived class with a parameter property` + : `a derived class with a plain parameter`, + ) + if (spec.field) states.add(`a derived class with a field initializer`) + states.add( + spec.before.length > 0 + ? `a derived class with statements before super` + : `a derived class with no statement before super`, + ) + if (spec.before.some((s) => s.kind === `if`)) + states.add(`a derived class with a conditional before super`) + if (spec.after.length > 0) + states.add(`a derived class with a statement after super`) + if (spec.after.some((s) => s.kind === `tag`)) + states.add(`a derived class reading this after super`) + } } if (plan.stability !== `none` && !before.refusal) { observe( @@ -351,6 +464,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.derivedSpec, 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 +548,11 @@ function check(plan, observations, expected) { `rune placement`, ) } + if (plan.derivedSource) { + 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 +663,28 @@ 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: (trace) => { + const index = trace.findIndex((o) => o.checkpoint === `derived`) + if (index < 0) return undefined + const value = structuredClone(trace[index].value) + value.instrumented[0] = { error: `Must call super constructor` } + return trace.map((o, i) => (i === index ? { ...o, value } : o)) + }, + }, { name: `a key drops its statement`, law: `keys`, @@ -534,8 +711,20 @@ const calibrations = [ ] const faults = [`badEntry`, `missingSource`, `nonLiteral`, `outsideSource`] +const derivedStates = [ + `a derived class constructed`, + `a derived class with a parameter property`, + `a derived class with a plain parameter`, + `a derived class with a field initializer`, + `a derived class with statements before super`, + `a derived class with no statement before super`, + `a derived class with a conditional before super`, + `a derived class with a statement after super`, + `a derived class reading this after super`, +] const required = [ ...Object.values(ownedStates), + ...derivedStates, ...Object.values(namedStates).map(([, state]) => state), `a $-named call outside Svelte judged`, `a Svelte module instrumented`, @@ -572,9 +761,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: with no mutant active the instrumented class behaves as its source does, no mutant removes or moves the call, and under every mutant the class's constructor still completes with its base class's tag.`, + 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), with ECMAScript's rule that a derived constructor calls \`super\` before it reads \`this\` and before it returns, and the maintainer's report of TanStack DB's subscription.ts failing with no mutant active (G11, 2026-11-09).`, + 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 only in this oracle's in-process run: its file is no test's import, so no test runs it, the end-to-end oracle does not load it, and the run transpiles it to CommonJS at ES2022 (other downlevel targets and Vitest's own transform are not run here). The mutant switch is a restatement of the contract in schemata.mjs, not the capture setup's runtime.`, owner: `packages/oracles/oracle-gaps`, }, histories: { @@ -592,6 +781,10 @@ oracleProperty({ `a source spelled through a redundant segment`, `a source spelled through a link to the package`, `a source spelled in another letter case`, + // A plain-parameter class without statements before super is + // the next history's. + `a derived class with a plain parameter`, + `a derived class with no statement before super`, ...faults.map((f) => `refused ${f}`), ].includes(s), ), @@ -605,6 +798,14 @@ oracleProperty({ sourceSpelling: `dot`, ownsSpelling: `dot`, dotGen: true, + // A parameter property, a field, statements before and after + // `super(...)`, a conditional before it, and `this` read after it. + derived: { + params: `property`, + field: true, + before: [{ kind: `push` }, { kind: `if`, op: `>`, k: 4 }], + after: [{ kind: `push` }, { kind: `tag` }], + }, // 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 +847,27 @@ oracleProperty({ sourceSpelling: `redundant`, }), }, + { + name: `a plain derived class with no statement before super`, + enters: [ + `a derived class with a plain parameter`, + `a derived class with no statement before super`, + ], + build: (generated) => ({ + ...generated, + witness: false, + wholeFile: `none`, + fault: `none`, + stability: `none`, + steps: null, + derived: { + params: `plain`, + field: false, + before: [], + after: [{ kind: `push` }], + }, + }), + }, ...(caseless ? [ { diff --git a/packages/oracles/oracle-gaps/schemata.mjs b/packages/oracles/oracle-gaps/schemata.mjs index 0a025e0..ac434ec 100644 --- a/packages/oracles/oracle-gaps/schemata.mjs +++ b/packages/oracles/oracle-gaps/schemata.mjs @@ -22,7 +22,8 @@ // - `if-true` / `if-false`: force an `if` condition. // - `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()`). +// - `delete-call`: skip a call statement (`switch || call()`), but not a +// `super(...)` call, which a derived constructor must make unconditionally. // // In a Svelte module (`.svelte.ts` or `.svelte.js`), a rune call that Svelte // allows in one place only (`$state`, `$derived`, `$effect`, `$props`, @@ -513,9 +514,13 @@ export function instrument(ts, path, text, source, offset = 0) { ids.ifTrue = add(node.expression, `if-true`, condition, `true`) ids.ifFalse = add(node.expression, `if-false`, condition, `false`) } + // A `super(...)` call is no site: a derived constructor must call it + // unconditionally, before it reads `this` and before it returns, so a + // mutant that skips it, or wraps it, breaks every construction. if ( ts.isExpressionStatement(node) && ts.isCallExpression(node.expression) && + node.expression.expression.kind !== ts.SyntaxKind.SuperKeyword && !isRune(node.expression) ) ids.call = add(node, `delete-call`, node.getText(file), `;`) From 287eca09c3b66374f29931128a1c0fef21bb26b3 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 9 Nov 2026 12:21:50 -0700 Subject: [PATCH 2/2] fix(oracles): no super-region mutation sites in derived constructors Review follow-up to 77a06fd. That commit excluded a `super(...)` statement from `delete-call`, which left shapes where a mutant still prevents or delays the call: a `super` in an arrow argument, a `super` as a logical operand, and a statement before `super` that returns early. Maintainer decision (2026-11-09): one rule for every shape. A derived constructor gets no mutation site in its super region, which is its parameters, the statements before its first statement that calls `super`, and that statement. An arrow inherits `super`, so its body counts; a function, method, accessor, or class does not. Statements after the call keep their sites. `superGuarded` in schemata.mjs implements it. Law: no mutant, when active, prevents or delays a derived constructor's super call; with no mutant active the instrumented class behaves as its source; under every mutant the constructor completes with its base tag, unless an early return ends it first; statements after super keep their sites. Authority: the gap report's behavior-preservation and legal-variant contract (schemata.mjs operator contract), ECMAScript's rule that a derived constructor calls super before it reads this and before it returns, and the maintainer decision above. Grammar (gap-report-model-oracle.mjs): a derived class's before statements (push, conditional push, early `return {}`), one of four super forms (a statement; `[0].forEach(() => super('a'))`; `out && super('a')`; `nothing(n) || super('a')`), and after statements (push, `this.tag` read, conditional push). The model gives sites only to after statements. The form filter keeps sources legal TypeScript: a non-statement super needs plain parameters and no field, since TypeScript emits parameter-property assignments before the body and requires a root-level super beside a field. Reach: derive-from-observation (static oracle). Counts from the static run, fixed seed 20261105 / random seed: constructed 30 / 24; plain parameter 25 / 14; parameter property 5 / 7; field 9 / 7; early return 7 / 6; push before 9 / 6; conditional push before 7 / 5; push after 19 / 13; conditional push after 5 / 4; this read after 7 / 6; super as statement 10 / 15; super in an arrow 6 / 3; super after && 6 / 1; super after || 8 / 5. Plus constructed histories for each form and for the early return. RED: static oracle, on the 77a06fd schemata.mjs with the new grammar, fixed seed 20261105: law `super unconditional` at checkpoint `derived`, "under mutant 21 the class for 0 has tag undefined" (and mutant 30). The arrow, `&&`, and `||` forms let a mutant skip super; the `&&` and `||` forms return the `out` array with no error, so only the tag check sees it. The before-super sites that the rule removes are not separately shown red: the derived law fails first for them. Two harness defects were also red before the fix: the grammar's list cap (3 statements) and the form builds, which the oracle correctly rejected as outside the language. GREEN: static oracle 11/11 on the final tree (fixed and random seed, with the calibrations `super unconditional` and `no-mutant behavior` detected). Full suite (`vitest --run --config oracle-gaps/vitest.config.mjs`): 4 files, 34 tests, exit 0, on this commit's tree. Mutant: a hostile rule that covers only the super statement, not the statements before it, is rejected by `super unconditional` (mutant 21 and 23: a forced early return ends the constructor with no tag). The fix is restored byte-for-byte after the check. Harness hygiene (e2e, gap-report-oracle.property.test.mjs), no RED of its own: stderr is named (CLI directory and scratch package) before it is cut to its last 1500 characters (`readRun`); the daemon's first line reads that already named text; the scratch directory is resolved with realpathSync, as the static driver does. Comparison change: the `cli` observation now names the CLI copy by how.cli and the package directory before the cut, and the daemon path no longer calls replaceAll on a possibly undefined copy. No law is relaxed; the normalization is the determinism law's existing rule for scratch paths. Simplifications: one `derivedStates` table shared by the reach derivation and `required`; reach states come from the observed runs and the instrumented copy's super form, not the plan; `isSuperCall` and `holdsSuper` are shared; the `no-mutant behavior` calibration uses `corrupt`; `DERIVED_FN` is no longer exported; the plan carries one `derived` object (`spec`, `source`, `statements`). Limits: - The in-process run transpiles to CommonJS at ES2022; Vitest's transform (CR-6) and other downlevel targets are not exercised. - The mutant switch (`switchFor`) is a hand copy of the switch's contract, not the capture setup's runtime (CR-5): the capture setup imports Vitest and installs timers and globals on load, so it cannot run in-process. - The end-to-end oracle instruments the derived file and judges its sites as unowned, but no test imports or constructs the class; its no-mutant and super-region behavior is judged only in-process. - The model special case (CR-8) is kept as written. - Not generated: super in a method, a nested class, a `super.method()` call, a constructor default parameter, or a before-super `return` other than the early-object form. Co-authored-by: Isaac --- .changeset/gap-report-super-calls.md | 2 +- .../oracle-gaps/gap-report-model-oracle.mjs | 149 +++++++----- .../gap-report-oracle.property.test.mjs | 57 +++-- ...gap-report-static-oracle.property.test.mjs | 215 ++++++++++++------ packages/oracles/oracle-gaps/schemata.mjs | 52 ++++- 5 files changed, 312 insertions(+), 163 deletions(-) diff --git a/.changeset/gap-report-super-calls.md b/.changeset/gap-report-super-calls.md index e294af3..85b2ea2 100644 --- a/.changeset/gap-report-super-calls.md +++ b/.changeset/gap-report-super-calls.md @@ -2,4 +2,4 @@ '@tanstack/oracles': patch --- -Fix `oracle-gaps` instrumenting `super(...)` calls. Its statement-deletion mutant wrapped an expression statement that calls `super(...)` (`m(15) || super()`), so a derived class's constructor could skip `super()` or read `this` before it, and every construction failed with no mutant active. The instrumenter no longer makes a `super(...)` call a mutation site, so no mutant can make it conditional, remove it, or move it. +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 f0d0371..12a7fc5 100644 --- a/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs +++ b/packages/oracles/oracle-gaps/gap-report-model-oracle.mjs @@ -190,26 +190,36 @@ const runeStatement = fc.oneof( }), ) // A derived class (DERIVED's `Derived`, which extends `DerivedBase`). Its -// constructor runs statements before `super(...)` that touch neither `this` -// nor `super`, then statements after it, which may read `this`. Its -// parameters may be parameter properties (`public n`), and it may have a -// field initializer. No mutant may make its `super(...)` call conditional, -// remove it, or move it, since the class would then read `this` before it -// is set, or return without it, and throw. +// 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: 2 }), - after: fc.array(derivedAfter, { maxLength: 2 }), -}) +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. @@ -1486,13 +1496,22 @@ function renderStatement(stmt, callText) { // ## The derived class's file // -// `Derived` extends `DerivedBase`. Its constructor runs the spec's statements -// before `super('a')`, then those after it. Each statement is one line, so a -// site's line is its statement's line. A `push` before `super` labels its -// output `b`, an `if` labels `c` when its condition holds, and a push -// after it labels `a`, or pushes `this.tag` (`a`). The class's -// parameters may be parameter properties, and it may have a field -// initializer. `derivedMake` returns a new `Derived`. +// `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 {`, @@ -1508,28 +1527,41 @@ function derivedFile(spec) { ` constructor(${spec.params === `property` ? `public ` : ``}n: number, out: Array) {`, ) const statements = [] - const statement = (text, call, extra) => { + const statement = (phase, kind, text, call) => { lines.push(` ${text}`) - statements.push({ ...extra, text, call, line: lines.length }) + statements.push({ phase, kind, text, call, line: lines.length }) } - spec.before.forEach((s, i) => { + for (const [i, s] of spec.before.entries()) { if (s.kind === `push`) - statement(`out.push('b${i}');`, `out.push('b${i}');`, { kind: `push` }) - else + 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}');`, - { kind: `if` }, ) - }) - lines.push(` super('a');`) - spec.after.forEach((s, i) => { - const call = - s.kind === `push` ? `out.push('a${i}');` : `out.push(this.tag);` - statement(call, call, { kind: s.kind }) - }) + 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);`, `}`, @@ -1539,16 +1571,23 @@ function derivedFile(spec) { // 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`), the tag the base class sets, `n` for a parameter property, and -// `count` for a field initializer. The source's and the instrumented copy's -// runs must both agree with it. +// 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 = [] - spec.before.forEach((s, i) => { + for (const [i, s] of spec.before.entries()) { if (s.kind === `push`) labels.push(`b${i}`) - else if (holds(x, s.op, s.k)) labels.push(`c${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}`) }) - spec.after.forEach((s, i) => labels.push(s.kind === `push` ? `a${i}` : `a`)) return { labels, tag: `a`, @@ -1689,7 +1728,7 @@ function resolveHistory(history) { })) // The derived class's file, which is no function of the model's (see // `derivedFile`). - const derived = history.derived ? derivedFile(history.derived) : undefined + const file = history.derived ? derivedFile(history.derived) : undefined const functions = [ ...generated, ...library, @@ -1979,16 +2018,18 @@ function resolveHistory(history) { LIB, ...(runesSource ? [RUNES] : []), ...(dotGenSource ? [DOTGEN] : []), - ...(derived ? [DERIVED] : []), + ...(file ? [DERIVED] : []), ], dotGenSource, history, source, librarySource, runesSource, - derivedSpec: history.derived ?? undefined, - derivedSource: derived?.source, - derivedStatements: derived?.statements, + derived: file && { + spec: history.derived, + source: file.source, + statements: file.statements, + }, } } @@ -2373,18 +2414,15 @@ function sitesOf(plan) { } } } - // The derived class's file (`derivedFile`), which no oracle owns. Each - // `if` has the sites of its condition and of its call, and each other - // statement the site of its call. The `super(...)` call has no site: no - // mutant may make it conditional, remove it, or move it (a mutant is a - // legal program variant, and a derived constructor that skips `super` - // throws, or reads `this` before it). - for (const s of plan.derivedStatements ?? []) { + // 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, - function: DERIVED_FN, - }) + add(DERIVED_FN, s.line, operator, text, { file: DERIVED }) if (s.kind === `if`) { at(`if-true`, s.text) at(`if-false`, s.text) @@ -3337,7 +3375,7 @@ function fixtureFiles(plan, state = initialState(plan)) { : plan.source, [LIB]: plan.librarySource, ...(plan.runesSource ? { [RUNES]: plan.runesSource } : {}), - ...(plan.derivedSource ? { [DERIVED]: plan.derivedSource } : {}), + ...(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. @@ -3659,7 +3697,6 @@ export { LIB, RUNES, DERIVED, - DERIVED_FN, 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 e18ecdf..086c23a 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.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,7 +453,7 @@ async function runClisAtOnce(dir, files, hows) { resolvePromise(code) }) }) - return readRun(dir, out, status, stderr) + return readRun(dir, out, status, stderr, how.cli ?? here) }), ) } @@ -471,7 +471,16 @@ function namedScratch(text, names) { } /** 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 = `` @@ -484,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 @@ -505,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, @@ -652,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`) @@ -848,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 @@ -870,20 +877,8 @@ async function driver(plan, observe) { } const cli = runCli(dir, files, how) // How many units were computed or reused says how the result was - // produced, not what it is, so it is not observed. A refusal's stack - // names the CLI copy and the scratch package, each a fresh directory per - // run, so its output names them as the daemon's below does. - observe(`cli`, { - ...cli, - stderr: - cli.stderr === undefined - ? undefined - : namedScratch(cli.stderr, [ - [copy, ``], - [dir, ``], - ]), - evidence: undefined, - }) + // produced, not what it is, so it is not observed. + observe(`cli`, { ...cli, evidence: undefined }) // Reach comes from what the CLI reported, mapped onto the plan. const states = new Set() // Each kind of declaration fault is its own state, so each refusal has @@ -1680,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 7978a53..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 @@ -145,7 +145,7 @@ function analyzeFixture(dir, files, plan) { runes: plan.runesSource ? instrumented.get(realpathSync(join(dir, RUNES))) : undefined, - derived: plan.derivedSource + derived: plan.derived ? { code: instrumented.get(realpathSync(join(dir, DERIVED))), numbers: mutants @@ -215,20 +215,105 @@ function runDerived(ts, code, active, x) { const made = exported.derivedMake(x, out) return { labels: out, tag: made.tag, n: made.n, count: made.count } } catch (error) { - return { error: String(error?.message ?? 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`, @@ -276,17 +361,16 @@ async function driver(plan, observe) { const files = fixtureFiles(plan) const before = analyzeFixture(dir, files, plan) observe(`static`, before) - if (before.derived) - observe( - `derived`, - observeDerived( + const derived = before.derived + ? observeDerived( ts, - plan.derivedSource, + plan.derived.source, before.derived.code, before.derived.numbers, - ), - ) - const states = new Set() + ) + : 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. @@ -375,27 +459,6 @@ async function driver(plan, observe) { if (plan.dotGenSource) states.add(`a source under a ..-named directory`) if (plan.sourceSpelling === `cased`) states.add(`a source spelled in another letter case`) - if (plan.derivedSpec) { - const spec = plan.derivedSpec - states.add(`a derived class constructed`) - states.add( - spec.params === `property` - ? `a derived class with a parameter property` - : `a derived class with a plain parameter`, - ) - if (spec.field) states.add(`a derived class with a field initializer`) - states.add( - spec.before.length > 0 - ? `a derived class with statements before super` - : `a derived class with no statement before super`, - ) - if (spec.before.some((s) => s.kind === `if`)) - states.add(`a derived class with a conditional before super`) - if (spec.after.length > 0) - states.add(`a derived class with a statement after super`) - if (spec.after.some((s) => s.kind === `tag`)) - states.add(`a derived class reading this after super`) - } } if (plan.stability !== `none` && !before.refusal) { observe( @@ -472,7 +535,7 @@ 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.derivedSpec, x)) + 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( @@ -548,7 +611,7 @@ function check(plan, observations, expected) { `rune placement`, ) } - if (plan.derivedSource) { + if (plan.derived) { const observed = observations.find((o) => o.checkpoint === `derived`)?.value const broken = checkDerived(plan, observed) if (broken) return broken @@ -677,13 +740,9 @@ const calibrations = [ { name: `the instrumented derived class differs with no mutant active`, law: `no-mutant behavior`, - corrupt: (trace) => { - const index = trace.findIndex((o) => o.checkpoint === `derived`) - if (index < 0) return undefined - const value = structuredClone(trace[index].value) - value.instrumented[0] = { error: `Must call super constructor` } - return trace.map((o, i) => (i === index ? { ...o, value } : o)) - }, + corrupt: corrupt(`derived`, (v) => { + v.instrumented[0] = { error: `Must call super constructor` } + }), }, { name: `a key drops its statement`, @@ -711,20 +770,9 @@ const calibrations = [ ] const faults = [`badEntry`, `missingSource`, `nonLiteral`, `outsideSource`] -const derivedStates = [ - `a derived class constructed`, - `a derived class with a parameter property`, - `a derived class with a plain parameter`, - `a derived class with a field initializer`, - `a derived class with statements before super`, - `a derived class with no statement before super`, - `a derived class with a conditional before super`, - `a derived class with a statement after super`, - `a derived class reading this after super`, -] const required = [ ...Object.values(ownedStates), - ...derivedStates, + ...Object.values(derivedStates), ...Object.values(namedStates).map(([, state]) => state), `a $-named call outside Svelte judged`, `a Svelte module instrumented`, @@ -761,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, keeps keys, probe marks, and ownership stable under unrelated edits, and keeps each derived class's \`super(...)\` call unconditional: with no mutant active the instrumented class behaves as its source does, no mutant removes or moves the call, and under every mutant the class's constructor still completes with its base class's tag.`, - 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), with ECMAScript's rule that a derived constructor calls \`super\` before it reads \`this\` and before it returns, and the maintainer's report of TanStack DB's subscription.ts failing with no mutant active (G11, 2026-11-09).`, - 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 only in this oracle's in-process run: its file is no test's import, so no test runs it, the end-to-end oracle does not load it, and the run transpiles it to CommonJS at ES2022 (other downlevel targets and Vitest's own transform are not run here). The mutant switch is a restatement of the contract in schemata.mjs, not the capture setup's runtime.`, + 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: { @@ -781,10 +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`, - // A plain-parameter class without statements before super is - // the next history's. - `a derived class with a plain parameter`, - `a derived class with no statement before super`, + // 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), ), @@ -798,13 +847,24 @@ oracleProperty({ sourceSpelling: `dot`, ownsSpelling: `dot`, dotGen: true, - // A parameter property, a field, statements before and after - // `super(...)`, a conditional before it, and `this` read after it. + // 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 }], - after: [{ kind: `push` }, { kind: `tag` }], + 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), @@ -848,11 +908,8 @@ oracleProperty({ }), }, { - name: `a plain derived class with no statement before super`, - enters: [ - `a derived class with a plain parameter`, - `a derived class with no statement before super`, - ], + name: `a plain-parameter derived class`, + enters: [derivedStates.plain], build: (generated) => ({ ...generated, witness: false, @@ -864,10 +921,32 @@ oracleProperty({ 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 ac434ec..0d0f3b3 100644 --- a/packages/oracles/oracle-gaps/schemata.mjs +++ b/packages/oracles/oracle-gaps/schemata.mjs @@ -22,8 +22,9 @@ // - `if-true` / `if-false`: force an `if` condition. // - `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()`), but not a -// `super(...)` call, which a derived constructor must make unconditionally. +// - `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`, @@ -94,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)) @@ -126,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 @@ -491,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) ) @@ -514,13 +555,10 @@ export function instrument(ts, path, text, source, offset = 0) { ids.ifTrue = add(node.expression, `if-true`, condition, `true`) ids.ifFalse = add(node.expression, `if-false`, condition, `false`) } - // A `super(...)` call is no site: a derived constructor must call it - // unconditionally, before it reads `this` and before it returns, so a - // mutant that skips it, or wraps it, breaks every construction. if ( ts.isExpressionStatement(node) && ts.isCallExpression(node.expression) && - node.expression.expression.kind !== ts.SyntaxKind.SuperKeyword && + !isSuperCall(node.expression) && !isRune(node.expression) ) ids.call = add(node, `delete-call`, node.getText(file), `;`)