diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 8cbab1b..7fe3edc 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -168,10 +168,11 @@ jobs: - test-oracle-gaps-fast - test-oracle-gaps-end-to-end - test-vitest-5 - # The blocking Windows jobs (the non-blocking static and e2e jobs are not - # a dependency until they are green). + # The blocking Windows jobs (the non-blocking end-to-end job is not a + # dependency until it is green). - windows-unit - windows-gap-classify + - windows-gap-oracles - windows-gap-cli runs-on: ubuntu-latest steps: @@ -223,10 +224,6 @@ jobs: windows-gap-oracles: name: Windows gap report static oracle (${{ matrix.shard }}/6) runs-on: windows-latest - # Not blocking yet: these suites fail on Windows, tracked by class in - # https://github.com/TanStack/oracles/issues/72. Remove this line when they - # are green. - continue-on-error: true timeout-minutes: 20 strategy: fail-fast: false @@ -287,7 +284,8 @@ jobs: working-directory: packages/oracles env: SHARD: ${{ matrix.shard }} - run: pnpm exec vitest --run --config oracle-gaps/vitest.config.mjs gap-report-oracle -t "$(node scripts/pr-shards.mjs pattern oracle-gaps-fixed-seed-windows "$SHARD")" + # The verbose reporter prints each test with its duration, which sizes the shards. + run: pnpm exec vitest --run --reporter=verbose --config oracle-gaps/vitest.config.mjs gap-report-oracle -t "$(node scripts/pr-shards.mjs pattern oracle-gaps-fixed-seed-windows "$SHARD")" windows-gap-cli: name: Windows gap CLI on a fixture runs-on: windows-latest diff --git a/packages/oracles/oracle-gaps/capture-setup.mjs b/packages/oracles/oracle-gaps/capture-setup.mjs index 5255684..18fd410 100644 --- a/packages/oracles/oracle-gaps/capture-setup.mjs +++ b/packages/oracles/oracle-gaps/capture-setup.mjs @@ -953,6 +953,10 @@ if (state.capture || idleProfile) { let since let last let longest = 0 + // Profiling only: how many samples each condition blocked the idle clock. + let blocked = new Map() + // The test whose samples are being recorded (profiling). + let key let use = realUse() let reported = false beforeEach(() => { @@ -960,16 +964,18 @@ if (state.capture || idleProfile) { baseTimers = timersListed(process.getActiveResourcesInfo()) since = undefined longest = 0 + blocked = new Map() sawIo = false }) beforeEach((context) => { if (continuing) return + key = testKey(context.task) context.onTestFinished(() => { baseTimers = undefined if (idleProfile) appendFileSync( idleProfile, - `${JSON.stringify({ key: testKey(context.task), longest })}\n`, + `${JSON.stringify({ key: testKey(context.task), longest, blocked: Object.fromEntries(blocked) })}\n`, ) }) }) @@ -988,14 +994,27 @@ if (state.capture || idleProfile) { // a timeout at the limit anyway (a collection's five-minute GC timer). const horizon = now + (state.idleHorizonMs ?? Infinity) const timerDue = [...pending.values()].some((due) => due <= horizon) - if ( - late || - io || - used.utilization > 0.05 || - timerDue || - timersListed(kinds) > baseTimers - ) { + const blocker = late + ? `late` + : io + ? `io ${kinds.filter((kind) => !quiet.has(kind)).join(`,`)}` + : used.utilization > 0.05 + ? `busy` + : timerDue + ? `timer due` + : timersListed(kinds) > baseTimers + ? `timers listed` + : undefined + // Each sample of a copy that can be cut off is written as it happens, so + // a copy the watchdog kills keeps its samples (profiling). + if (idleProfile && state.idleMs) + appendFileSync( + idleProfile, + `${JSON.stringify({ sample: blocker ?? `idle`, key, at: Math.round(now) })}\n`, + ) + if (blocker !== undefined) { since = undefined + if (idleProfile) blocked.set(blocker, (blocked.get(blocker) ?? 0) + 1) return } since ??= now diff --git a/packages/oracles/oracle-gaps/check.mjs b/packages/oracles/oracle-gaps/check.mjs index 9ca8b5b..af9e62a 100644 --- a/packages/oracles/oracle-gaps/check.mjs +++ b/packages/oracles/oracle-gaps/check.mjs @@ -778,7 +778,7 @@ export default async (env) => { for (const path of paths) { const hash = servedHash(path, index) if (served.get(path) === hash) continue - context.invalidateFile(path) + context.invalidateFile(posix(path)) served.set(path, hash) } } @@ -1457,7 +1457,7 @@ export default async (env) => { const hash = started.get(path) ?? now const read = served.get(path) ?? hash // The context may hold other text than the path's text now. - if (read !== now || hash !== now) context.invalidateFile(path) + if (read !== now || hash !== now) context.invalidateFile(posix(path)) if (hash !== now) stable = false served.set(path, now) return [path, hash] diff --git a/packages/oracles/oracle-gaps/daemon.mjs b/packages/oracles/oracle-gaps/daemon.mjs index 6ea0215..a5f59e6 100644 --- a/packages/oracles/oracle-gaps/daemon.mjs +++ b/packages/oracles/oracle-gaps/daemon.mjs @@ -29,6 +29,7 @@ import { spawn } from 'node:child_process' import { randomUUID } from 'node:crypto' import { + existsSync, linkSync, mkdirSync, openSync, @@ -370,6 +371,22 @@ async function serve(pkg) { stderr.push(`${line}\n`), ) await store.flush() + // A test-only pause (ORACLE_GAPS_TEST_PAUSE_AT=daemon-reply, as + // ORACLE_GAPS_TEST_STOP): the check is done and its reply is held, + // so the CLI waits as it would for a daemon that is slow and dies. + // The pause is marked in the check's output directory, with this + // daemon's pid, for the oracle to kill it by (no signal pauses a + // process on win32). + // Once: a daemon that the CLI restarts after this one died inherits the + // variable, and must answer, or the CLI waits as long as the run allows. + const mark = join(options.out, `paused`) + if ( + process.env.ORACLE_GAPS_TEST_PAUSE_AT === `daemon-reply` && + !existsSync(mark) + ) { + writeFileSync(mark, `${process.pid}`) + await new Promise(() => {}) + } } catch (error) { failed = sent(error) } 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 9f0fe4c..667c736 100644 --- a/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs +++ b/packages/oracles/oracle-gaps/gap-report-oracle.property.test.mjs @@ -483,10 +483,11 @@ import { writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join, relative, resolve } from 'node:path' +import { delimiter, dirname, join, relative, resolve } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import fc from 'fast-check' import { socketOf, stateDirOf } from './daemon.mjs' +import { isWithin } from './paths.mjs' import { oracleProperty, pass, violation } from '../src/oracle-property.ts' import { @@ -663,10 +664,13 @@ function runCli(dir, files, how = {}) { child.stdout, ) // The command the baseline printed to replay a failing owner (law 3). + // On win32 the CLI prints the PowerShell line (its POSIX form is not printed). + const label = + process.platform === `win32` ? ` replay (PowerShell): ` : ` replay: ` const replay = `${child.stderr ?? ``}` .split(`\n`) - .find((line) => line.startsWith(` replay: `)) - ?.slice(` replay: `.length) + .find((line) => line.startsWith(label)) + ?.slice(label.length) return { ...run, strayWrites: strays(dir, before), replay } } @@ -719,6 +723,11 @@ async function stoppedRun(dir, files, how, ids, { signal, pool, when }) { writeFixture(dir, files) const before = new Set(filesUnder(dir)) const pids = mkdtempSync(join(realpathSync(tmpdir()), `gap-stop-`)) + // win32 cannot deliver a signal to a Node process: kill ends it at once, with + // no stopped line. There the stop is the CLI's test stop hook (the file it + // names, as a SIGINT), so the run is observed as SIGINT (see below). + const win = process.platform === `win32` + const stopFile = `${pids}-stop` const { args } = cliArgs(dir, files, { ...how, outName: `out-stop`, @@ -726,15 +735,17 @@ async function stoppedRun(dir, files, how, ids, { signal, pool, when }) { only: ids, pool: abort ? `threads` : pool, }) + const env = after + ? { ...cleanEnv(), ORACLE_GAPS_TEST_HOLD_EXIT_MS: `500` } + : warm + ? { ...cleanEnv(), ORACLE_GAPS_TEST_WARM_PROBE: join(pids, `warming`) } + : abort + ? { ...cleanEnv(), GAP_ABORT: `1` } + : { ...cleanEnv(), GAP_STOP_DIR: pids } + if (win) env.ORACLE_GAPS_TEST_STOP = stopFile const child = spawn(process.execPath, args, { cwd: repo, - env: after - ? { ...cleanEnv(), ORACLE_GAPS_TEST_HOLD_EXIT_MS: `500` } - : warm - ? { ...cleanEnv(), ORACLE_GAPS_TEST_WARM_PROBE: join(pids, `warming`) } - : abort - ? { ...cleanEnv(), GAP_ABORT: `1` } - : { ...cleanEnv(), GAP_STOP_DIR: pids }, + env, stdio: [`ignore`, `pipe`, `pipe`], }) let stdout = `` @@ -781,7 +792,10 @@ async function stoppedRun(dir, files, how, ids, { signal, pool, when }) { ? existsSync(join(pids, `warming`)) && !exited : readdirSync(pids).length >= 2 const strayWhileRunning = strays(dir, before, true) - if (!abort) child.kill(signal) + if (!abort) { + if (win) writeFileSync(stopFile, ``) + else child.kill(signal) + } const ended = await Promise.race([exit, sleep(30_000).then(() => false)]) await sleep(1000) const alive = readdirSync(pids) @@ -805,8 +819,10 @@ async function stoppedRun(dir, files, how, ids, { signal, pool, when }) { } if (!ended) child.kill(`SIGKILL`) rmSync(pids, { recursive: true, force: true }) + rmSync(stopFile, { force: true }) return { - signal, + // The signal the CLI took: the hook's SIGINT on win32. + signal: win ? `SIGINT` : signal, pool, spun, ended, @@ -852,10 +868,22 @@ function replayed(dir, command) { const scratch = mkdtempSync(join(realpathSync(tmpdir()), `gap-replay-`)) try { const report = join(scratch, `report.json`) - spawnSync( - `sh`, - [`-c`, `${command} --reporter=json --outputFile='${report}'`], - { + const full = `${command} --reporter=json --outputFile='${report}'` + // On win32 the line runs in PowerShell, as a user's typed line does: on stdin. + if (process.platform === `win32`) + spawnSync(`pwsh`, [`-NoProfile`, `-Command`, `-`], { + cwd: dir, + encoding: `utf8`, + input: full, + env: { + ...process.env, + PATH: [join(here, `../node_modules/.bin`), process.env.PATH].join( + delimiter, + ), + }, + }) + else + spawnSync(`sh`, [`-c`, full], { cwd: dir, encoding: `utf8`, timeout: 120_000, @@ -863,8 +891,7 @@ function replayed(dir, command) { ...cleanEnv(), PATH: `${join(here, `..`, `node_modules`, `.bin`)}:${process.env.PATH}`, }, - }, - ) + }) if (!existsSync(report)) return [] return JSON.parse(readFileSync(report, `utf8`)) .testResults.flatMap((file) => file.assertionResults) @@ -943,10 +970,13 @@ function namedScratch(text, names) { const longestFirst = names .filter(([path]) => path !== undefined) .sort(([a], [b]) => b.length - a.length) + // The CLI names its paths in forward slashes (the Windows paths rule), so a + // scratch path is named in both spellings. for (const [path, name] of longestFirst) named = named .replaceAll(pathToFileURL(path).href, name) .replaceAll(path, name) + .replaceAll(path.replaceAll(`\\`, `/`), name) return named .replaceAll(/\.oracle-gaps-\d+/g, `.oracle-gaps-`) .replaceAll(/session-\d+/g, `session-`) @@ -1077,7 +1107,12 @@ function readRun(dir, out, status, stderr, cli, stdout) { lastLine: printed.at(-1) && namedScratch(printed.at(-1), scratch), // The stdout's own lines of the stream's kinds: the gap lines under // their functions, as the stream prints them (law 15). - stdoutGaps: printed.filter((line) => streamedGap.test(line)).length, + stdoutGaps: printed + .filter((line) => streamedGap.test(line)) + .map((line) => namedScratch(line, scratch)), + stderrGaps: lines + .filter((line) => streamedGap.test(line)) + .map((line) => namedScratch(line, scratch)), baselineFiles: lines.flatMap((line) => { const file = line.match( /^oracle-gaps: baseline (?:pass|fail) (\S+)(?: \(ordinary\))?: /, @@ -1154,8 +1189,13 @@ function readRun(dir, out, status, stderr, cli, stdout) { mutants: (results ?? []).map((r) => ({ id: r.id, // The owner files whose tests reached it, relative to the package. - oracleFiles: (r.oracleFiles ?? []).map((file) => relative(dir, file)), - ordinaryFiles: (r.ordinaryFiles ?? []).map((file) => relative(dir, file)), + // Forward slashes, as the CLI names its files (the Windows paths rule). + oracleFiles: (r.oracleFiles ?? []).map((file) => + relative(dir, file).replaceAll(`\\`, `/`), + ), + ordinaryFiles: (r.ordinaryFiles ?? []).map((file) => + relative(dir, file).replaceAll(`\\`, `/`), + ), key: r.key, source: r.source, function: r.function, @@ -1462,35 +1502,39 @@ async function incremental(dir, plan, cli, copy, observe, states) { { ...how, daemon: true, - env: step === `dieMidCheck` ? { ORACLE_GAPS_WAIT_MS: `3000` } : {}, + env: + step === `dieMidCheck` + ? { + ORACLE_GAPS_WAIT_MS: `3000`, + ORACLE_GAPS_TEST_PAUSE_AT: `daemon-reply`, + } + : {}, }, ]) - let paused = false + let held = false if (step === `resetBeforeRead`) { await new Promise((r) => setTimeout(r, 500)) if (pid) process.kill(pid, `SIGKILL`) } else { // Mid-check: the daemon has written this check's state file. A check // that ends first (a refusal runs nothing) leaves its daemon alone. - const started = join(dir, `out-daemon`, `state.json`) + const pauseMark = join(dir, `out-daemon`, `paused`) let finished = false running.then(() => { finished = true }) for (let waited = 0; !finished && waited < 60_000; waited += 50) { - const [running] = daemonsOf(dir) - if (running && existsSync(started)) { - process.kill(running, `SIGSTOP`) + if (existsSync(pauseMark)) { await new Promise((r) => setTimeout(r, 4000)) - process.kill(running, `SIGKILL`) - paused = !finished + process.kill(Number(readFileSync(pauseMark, `utf8`)), `SIGKILL`) + held = !finished break } await new Promise((r) => setTimeout(r, 50)) } } const runs = await running - if (paused && runs[0].reported) + if (held && runs[0].reported) states.add(`a check whose daemon held it past the CLI's wait, then died`) return runs } @@ -1678,7 +1722,7 @@ async function driver(plan, observe) { states.add(`a run whose process aborted`) continue } - states.add(`a run stopped by ${stop.signal} while a worker spun`) + states.add(`a run stopped by ${stopped.signal} while a worker spun`) states.add( `a stopped run whose workers are ${stop.pool === `forks` ? `child processes` : `threads`}`, ) @@ -1697,7 +1741,7 @@ async function driver(plan, observe) { // A refused run's output is recorded with its scratch paths named, and // this package lies inside the CLI's directory: the case in which the // naming order decides whether a random name stays in the trace. - if (cli.exit !== 0 && dir.startsWith(`${how.cli}/`)) + if (cli.exit !== 0 && dir !== how.cli && isWithin(how.cli, dir)) states.add(`a refused run whose package lies inside the CLI's directory`) // A failing owner's replay printed in a package whose config only // `--config` names, beside a discovered config that includes no test. @@ -2553,9 +2597,9 @@ function streamBreach(checkpoint, run, expected, plan) { `the stream's decisive baseline lines are ${JSON.stringify(run.stream.verdicts)}, owed ${JSON.stringify([verdict])}`, { actual: run.stream.verdicts, expected: [verdict] }, ) - if (run.stdoutGaps !== expected.gaps.length) + if (run.stdoutGaps.length !== expected.gaps.length) return breach( - `the stdout has ${run.stdoutGaps} gap lines for ${expected.gaps.length} gaps`, + `the stdout has ${run.stdoutGaps.length} gap lines for ${expected.gaps.length} gaps: ${JSON.stringify(run.stdoutGaps)}; the stderr has ${JSON.stringify(run.stderrGaps)}`, ) return undefined } @@ -3168,11 +3212,9 @@ const required = [ `a hang in one owner and a survivor in another timed out`, `confirmations past a mutant's budget timed out`, `a heartbeat named a busy mutant`, - `a run stopped by SIGTERM while a worker spun`, `a run stopped by SIGINT while a worker spun`, `a stop signal after the completed line`, `a stop signal while the config warms`, - `a run whose process aborted`, `a config refusal through the daemon`, `a completed run whose tests wrote nothing`, `a stopped run whose workers are child processes`, @@ -3310,7 +3352,16 @@ const separate = [ `edit lazy changed the cold result`, `a lazy edit changed the cold result after a batch was cut off`, ] -const witnessed = required.filter((s) => !separate.includes(s)) +// A stop by SIGTERM exists only where Node can deliver it: on win32 kill is +// TerminateProcess, so there the stop law is judged by SIGINT (the test stop +// hook), and the SIGTERM state is not required (a declared platform bound). +const posixStops = + process.platform === `win32` + ? [] + : [`a run stopped by SIGTERM while a worker spun`] +const witnessed = [...required, ...posixStops].filter( + (s) => !separate.includes(s), +) // A constructed history builds from a generated base whose first function is // owned by file a (one owned function, not a whole file), so a random @@ -3335,7 +3386,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 bounds the runs that confirm a mutant's cut-off copies by one budget per mutant and names each busy mutant in a heartbeat while it runs, leaves nothing in the package and writes nothing where Vitest's include could find it while it runs, leaves no process running once it is stopped, 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; leads its report with every test file's baseline, blocks only the mutants a failing oracle file owns and judges the rest, prints every line of its stream with the prefix oracle-gaps: and its kind, one decisive baseline line before any mutant, and a done line last, and every run that does not complete ends, cold or through the daemon, with a refusal that exits 2, prints a refused: block and one fix: directive with no stack trace, and writes the refusal to the run's output directory.`, 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; the maintainer's dispositions G1, G2, G4, G5, G6, and G13 of the framework papercut ledger (2026-11-11) and the v20 freeze's namespacing, one-loop, and provisional-report decisions (2026-11-10); the maintainer's request for a greppable stream (2026-11-12, relayed by the coordinator with its decisions of that day: the same prefixes on stdout and stderr, a done line for a stopped run, and no --max-gaps).`, - limits: `Law 12 judges refusals only: no legal history reaches an internal error, so its shape (exit 3, the stack in error.log) is not judged; argument refusals are the static oracle's; the check's rejection of a stack trace, a missing fix, and a wrong exit code is calibrated in the static oracle, through the same refusalBreach, and here only a refused run reported as complete is calibrated, because each calibration here runs real CLI histories. Hangs only as an owner test that awaits a promise that never settles, under a 1 s first budget and a 20 s full limit, and a slow run only as two witnesses whose tests wait where the output changed, 3 s in one that then judges and 10 s in two one-test owners of one that does not; 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; --only only in a stopped run and a partial run of one function's mutants, and no --sample or --diff-base; the baseline's failures only as one failing owner test that throws an error and a setup file that throws, so a baseline law violation's law and counterexample lines are the classify oracle's; 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 as vite.config or vitest.config with one of the extensions .ts, .mts, .cts, .js, .mjs, and .cjs (not every pair), as config/gap-vitest with the same extension through --config, or none, as an object or a sync or async function, with no plugins of their own and test.projects only as a refusal, and only under the package's own package.json; a top-level await only in an ES module config and require only in a CommonJS one, as Vitest itself loads no other; a --config outside the package is refused by design, through the rule for paths outside the package (args.mjs), and no history generates one; contexts only one, four, or eleven, and process warnings judged only as MaxListenersExceededWarning on the CLI's own stderr (a daemon's process warnings go to its log); a context that fails to open gives back its listener allowance, which no observation shows (a kept allowance only raises the limit, so no warning follows), so that accounting is fixed by inspection; no probe-bound states, so reach agreement's killing of probe mutants is not checked; no gap answers or gate; law 9 judges the witness module's outcome classes along one constructed edit sequence only (a test edit, a helper edit, and a revert), and the budget witness's along one constructed second check with nothing changed, because a generated history with the witness module or the budget witness takes no steps (maintainer decision, 2026-11-11, and the runtime review's P1-a, 2026-11-12; this oracle owns the gap); law 9's edits come only between checks, so a file saved during a check is not generated (the runner invalidates a known input that changed while a run ran and does not store that run, which only inspection fixes; a witness needs a step that edits a file between a unit's start and its end), and a module that a run first loads and that changes during that same run is stored under its later hash; 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; the runner's copies of the test files exist only in memory, and its harness and Vitest configs sit in the package's .oracle-gaps-, which the next run removes once a killed run has left it (judged after two stopped runs, not after a SIGKILL); a default --out is judged by the static oracle, and these runs name --out in the package; the namespace is checked after each run and, in a stopped run, while the run runs; a slow kill in a later owner, after earlier owners' confirmations spent the budget, becomes a timeout (owner order; not generated); the Vitest 5 path of the copies' pragma read and of the runner is verified from Vitest 5's source only, since the Vitest 5 CI job runs no gap oracle; a batch whose run reports an error outside any test reruns each copy alone (unitsBatch, runAlone) with its first budget, and a copy cut there is kept as a timeout with no confirmation, a defect older than this oracle's budget work that no history reaches (no history has an error outside any test beside a slow copy), recorded here with that trace for the maintainer; stale-session removal is scoped to this host's session directories (a host file), and is not judged across pid namespaces; the stop law covers SIGTERM and SIGINT sent to the CLI, not a SIGKILL of the CLI (its group then ends at the next check of its parent, by inspection) or a signal sent to the daemon, and its witness is one test that spins synchronously and starts one child process; the mutant budget is witnessed as two slow owners whose confirmations pass the --mutant-limit floor, not through its multiple of the owners' unmutated time, and authored mutants have no confirmations to budget; the witness w_slowpair is a wall-clock window, since the CLI's budget has no controlled clock and a fixture cannot see the time the CLI measures for its file: with one-test owner files p and q of measured unmutated time t each (a file first in its context pays the instrumented transform too), a wait W of 10 s, a 20 s --hang-limit and a 15 s --mutant-limit, it tells a budget from none only while (a) its first attempt is cut, W > 1 s + 3t, (b) its two confirmations do not fit the budget, 2W > max(15 s, 10 x 2t), and (c) each would fit its own full limit, W < 20 s + 3t; (b) is the binding one, so it holds while the two files' time stays under about 2 s together (about 1 s each, some 20 times the authoring machine's idle and 5 to 10 times its loaded measurements), and under one budget a run's overhead must stay under 10 s + 3t; CI runners measure 1.5 to 2.3 times the idle machine; beyond that w_slowpair would read not-judged; a heartbeat is owed only where a slow test waits 3 s against a 2 s interval, twice the first budget, so that no copy cut at its first budget beats, and each beat is observed as its mutant and file, not how long or how often it beat; a config only in the package's parent directory is generated one level up, in a temporary directory with no package.json (so its .js or .ts name is CommonJS), as a plain config, one that throws, one with no default export, or one that defines test.projects; a config two or more levels up, or a parent config beside a package config, is not generated, and the order in one directory follows Vitest's configFiles list by inspection; a package with no config lives in a temporary directory, where no parent has a config; a counterexample shrinks through at most six histories, so it can be larger than the smallest that fails; module-level data only as four \`const\` declarations of the main source (an object literal, an array that holds a function, a boolean, and an object of two methods), read in place, with no data imported from another file, destructured, reassigned, or declared as an enum or a default export; the work law only through shortcut witnesses (an early return before a counting loop with a bound twin of ratio 2, and a push beside one that calls String with a bound twin of ratio 1), the early return in tests that reach the clock by each watched path and in tests that wait a turn, a generated test's bound of 1 derived from its plan (which no generated function's mutant reached in the 36 fixed-seed units), and one warm-cache witness whose selection reproduces observations but not work; work is the capture's count of site evaluations per test, so code with no site (a plain assignment, a for...of pass, a builtin's own work) counts nothing and a test's histories are summed, and a test whose work varies between the unmutated runs while its observations agree is not generated.`, + limits: `On win32 Node cannot deliver SIGTERM (its kill is TerminateProcess, which ends the process with no stopped line), so the stop states that name SIGTERM are required on POSIX only, and a stop there goes through the CLI's test stop hook and is observed as SIGINT; every SIGINT state stays required on win32. Law 12 judges refusals only: no legal history reaches an internal error, so its shape (exit 3, the stack in error.log) is not judged; argument refusals are the static oracle's; the check's rejection of a stack trace, a missing fix, and a wrong exit code is calibrated in the static oracle, through the same refusalBreach, and here only a refused run reported as complete is calibrated, because each calibration here runs real CLI histories. Hangs only as an owner test that awaits a promise that never settles, under a 1 s first budget and a 20 s full limit, and a slow run only as two witnesses whose tests wait where the output changed, 3 s in one that then judges and 10 s in two one-test owners of one that does not; 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; --only only in a stopped run and a partial run of one function's mutants, and no --sample or --diff-base; the baseline's failures only as one failing owner test that throws an error and a setup file that throws, so a baseline law violation's law and counterexample lines are the classify oracle's; 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 as vite.config or vitest.config with one of the extensions .ts, .mts, .cts, .js, .mjs, and .cjs (not every pair), as config/gap-vitest with the same extension through --config, or none, as an object or a sync or async function, with no plugins of their own and test.projects only as a refusal, and only under the package's own package.json; a top-level await only in an ES module config and require only in a CommonJS one, as Vitest itself loads no other; a --config outside the package is refused by design, through the rule for paths outside the package (args.mjs), and no history generates one; contexts only one, four, or eleven, and process warnings judged only as MaxListenersExceededWarning on the CLI's own stderr (a daemon's process warnings go to its log); a context that fails to open gives back its listener allowance, which no observation shows (a kept allowance only raises the limit, so no warning follows), so that accounting is fixed by inspection; no probe-bound states, so reach agreement's killing of probe mutants is not checked; no gap answers or gate; law 9 judges the witness module's outcome classes along one constructed edit sequence only (a test edit, a helper edit, and a revert), and the budget witness's along one constructed second check with nothing changed, because a generated history with the witness module or the budget witness takes no steps (maintainer decision, 2026-11-11, and the runtime review's P1-a, 2026-11-12; this oracle owns the gap); law 9's edits come only between checks, so a file saved during a check is not generated (the runner invalidates a known input that changed while a run ran and does not store that run, which only inspection fixes; a witness needs a step that edits a file between a unit's start and its end), and a module that a run first loads and that changes during that same run is stored under its later hash; 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; the runner's copies of the test files exist only in memory, and its harness and Vitest configs sit in the package's .oracle-gaps-, which the next run removes once a killed run has left it (judged after two stopped runs, not after a SIGKILL); a default --out is judged by the static oracle, and these runs name --out in the package; the namespace is checked after each run and, in a stopped run, while the run runs; a slow kill in a later owner, after earlier owners' confirmations spent the budget, becomes a timeout (owner order; not generated); the Vitest 5 path of the copies' pragma read and of the runner is verified from Vitest 5's source only, since the Vitest 5 CI job runs no gap oracle; a batch whose run reports an error outside any test reruns each copy alone (unitsBatch, runAlone) with its first budget, and a copy cut there is kept as a timeout with no confirmation, a defect older than this oracle's budget work that no history reaches (no history has an error outside any test beside a slow copy), recorded here with that trace for the maintainer; stale-session removal is scoped to this host's session directories (a host file), and is not judged across pid namespaces; the stop law covers SIGTERM and SIGINT sent to the CLI, not a SIGKILL of the CLI (its group then ends at the next check of its parent, by inspection) or a signal sent to the daemon, and its witness is one test that spins synchronously and starts one child process; the mutant budget is witnessed as two slow owners whose confirmations pass the --mutant-limit floor, not through its multiple of the owners' unmutated time, and authored mutants have no confirmations to budget; the witness w_slowpair is a wall-clock window, since the CLI's budget has no controlled clock and a fixture cannot see the time the CLI measures for its file: with one-test owner files p and q of measured unmutated time t each (a file first in its context pays the instrumented transform too), a wait W of 10 s, a 20 s --hang-limit and a 15 s --mutant-limit, it tells a budget from none only while (a) its first attempt is cut, W > 1 s + 3t, (b) its two confirmations do not fit the budget, 2W > max(15 s, 10 x 2t), and (c) each would fit its own full limit, W < 20 s + 3t; (b) is the binding one, so it holds while the two files' time stays under about 2 s together (about 1 s each, some 20 times the authoring machine's idle and 5 to 10 times its loaded measurements), and under one budget a run's overhead must stay under 10 s + 3t; CI runners measure 1.5 to 2.3 times the idle machine; beyond that w_slowpair would read not-judged; a heartbeat is owed only where a slow test waits 3 s against a 2 s interval, twice the first budget, so that no copy cut at its first budget beats, and each beat is observed as its mutant and file, not how long or how often it beat; a config only in the package's parent directory is generated one level up, in a temporary directory with no package.json (so its .js or .ts name is CommonJS), as a plain config, one that throws, one with no default export, or one that defines test.projects; a config two or more levels up, or a parent config beside a package config, is not generated, and the order in one directory follows Vitest's configFiles list by inspection; a package with no config lives in a temporary directory, where no parent has a config; a counterexample shrinks through at most six histories, so it can be larger than the smallest that fails; module-level data only as four \`const\` declarations of the main source (an object literal, an array that holds a function, a boolean, and an object of two methods), read in place, with no data imported from another file, destructured, reassigned, or declared as an enum or a default export; the work law only through shortcut witnesses (an early return before a counting loop with a bound twin of ratio 2, and a push beside one that calls String with a bound twin of ratio 1), the early return in tests that reach the clock by each watched path and in tests that wait a turn, a generated test's bound of 1 derived from its plan (which no generated function's mutant reached in the 36 fixed-seed units), and one warm-cache witness whose selection reproduces observations but not work; work is the capture's count of site evaluations per test, so code with no site (a plain assignment, a for...of pass, a builtin's own work) counts nothing and a test's histories are summed, and a test whose work varies between the unmutated runs while its observations agree is not generated.`, }, histories: { // A failing history shrinks through at most six smaller ones. Each runs @@ -3345,7 +3396,7 @@ oracleProperty({ // verdict its first failure holds. The counterexample is the smallest // of those seven histories, its law the first failure's. arbitrary: fc.limitShrink(histories, 6), - states: required, + states: [...required, ...posixStops], measured: [ ...staticStates, `extra work in a generated function`, 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 97e4a23..5ccab44 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 @@ -135,6 +135,7 @@ import { written, } from './gap-report-model-oracle.mjs' import { parseArgs, usage } from './args.mjs' +import { posix } from './paths.mjs' import { keyAndSelect } from './report.mjs' const here = dirname(fileURLToPath(import.meta.url)) @@ -227,14 +228,15 @@ function defaultOutputsOf(dir, sources) { const run = (pkg) => parseArgs([`--package`, pkg, `--source`, sources.join(`,`)], pkg).out const outs = [run(dir), run(dir), run(other)] - const temp = realpathSync(tmpdir()) + sep + // The OS temporary directory by its native real path (the realpath rule): + // a JavaScript realpath keeps an 8.3 name such as RUNNER~1 on Windows. + const temp = realpathSync.native(tmpdir()) + sep return { // Under the OS temp directory, which the OS cleans, and never in the // package (maintainer decision, 2026-11-11). contained: outs.map( (out, i) => - (out.startsWith(temp) || out.startsWith(tmpdir() + sep)) && - !out.startsWith([dir, dir, other][i] + sep), + out.startsWith(temp) && !out.startsWith([dir, dir, other][i] + sep), ), // Each package's outputs lie in a directory of their own, which is not // the temp directory itself or a shared `oracle-gaps` directory. @@ -504,7 +506,10 @@ function argumentsRunOf(dir, plan) { run.status, run.stderr, out, - (text) => text.replaceAll(dir, ``), + // The CLI names its paths in forward slashes (the Windows paths rule), so + // the package is named in both spellings. + (text) => + text.replaceAll(posix(dir), ``).replaceAll(dir, ``), run.stdout, ) }