Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/gap-report-super-calls.md
Original file line number Diff line number Diff line change
@@ -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.
171 changes: 170 additions & 1 deletion packages/oracles/oracle-gaps/gap-report-model-oracle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<i>`, an `if` labels `c<i>` when its
// condition holds, and an `early` returns an object when it holds. After
// `super`, a push labels `a<i>`, `tag` pushes `this.tag` (`a`), and an `if`
// labels `d<i>` 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<string>) {`,
)
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<string>): 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`
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
},
}
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]),
])
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -3528,6 +3696,7 @@ export {
SOURCE,
LIB,
RUNES,
DERIVED,
DOTGEN,
caseless,
removePath,
Expand Down
55 changes: 37 additions & 18 deletions packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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. */
Expand All @@ -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, `<cli>`],
[dir, `<package>`],
])
let results
let report
let summary = ``
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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, `<cli>`)
.replaceAll(dir, `<package>`),
// 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`)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand Down
Loading
Loading