Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
39 changes: 22 additions & 17 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1627,23 +1627,28 @@ Before it runs the mapped tests, the runner puts every mutant through two cheap
copies, with a CPU-aware limit capped at four (`MUTATION_STATIC_JOBS` can lower
it). One- and two-mutant files stay serial to avoid copy overhead. Test batches
keep their separate `--jobs` limit and still run only after static results are
reported in mutant order. A mutant's timeout starts when its static work leaves
the queue. Static work and any wait for earlier tests both use that time. Keep
the Biome calls one-shot unless a new benchmark proves otherwise: with pinned
Biome 2.4.16, 20 warm one-file runs measured a 17.3 ms standalone median and a
51.2 ms `--use-server` median. Either gate exiting non-zero kills the mutant
without spending a full `deno test` on it — both a forbidden lint diagnostic and
a type error are build failures, so the mutant could never ship, and static
checks are far faster than the suite. The type-check gate catches the mutants
that turn valid code into a type error — e.g. a `+ → *` swap on a string
concatenation (`"a" * "b"` doesn't type-check), or any operator change that
violates a parameter/return type. Each gate is only trusted after the runner
confirms the _unmutated_ target passes it (the baseline probe): a standalone
`deno task mutation` doesn't run `lint:ci`/`typecheck` first, so if the target
isn't already clean the run aborts loudly rather than scoring a bogus 100%. This
means a mutant recorded in `equivalent-mutants/` must be one that survives
_both_ gates _and_ the tests; a mutation that produces a type error never
reaches the ignore-list because the type-check gate kills it first.
reported in mutant order. **No mutant is ever judged by a clock**: gates and
tests run to completion, so a mutant is killed only when a gate rejects it or a
test fails, and survives otherwise. A slow type-check or a long queue wait can
no longer be mistaken for a mutant being caught. The one clock left is
`--deadline`, a whole-run guard (default one hour) against a mutant that hangs
the tests: when it fires the run fails and reports nothing at all, rather than
scoring anything. Keep the Biome calls one-shot unless a new benchmark proves
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
otherwise: with pinned Biome 2.4.16, 20 warm one-file runs measured a 17.3 ms
standalone median and a 51.2 ms `--use-server` median. Either gate exiting
non-zero kills the mutant without spending a full `deno test` on it — both a
forbidden lint diagnostic and a type error are build failures, so the mutant
could never ship, and static checks are far faster than the suite. The
type-check gate catches the mutants that turn valid code into a type error —
e.g. a `+ → *` swap on a string concatenation (`"a" * "b"` doesn't type-check),
or any operator change that violates a parameter/return type. Each gate is only
trusted after the runner confirms the _unmutated_ target passes it (the baseline
probe): a standalone `deno task mutation` doesn't run `lint:ci`/`typecheck`
first, so if the target isn't already clean the run aborts loudly rather than
scoring a bogus 100%. This means a mutant recorded in `equivalent-mutants/` must
be one that survives _both_ gates _and_ the tests; a mutation that produces a
type error never reaches the ignore-list because the type-check gate kills it
first.

When a manual mutation run (or the precommit gate) surfaces survivors on a file
you are touching — even on lines you did not change in this PR — they are yours
Expand Down
8 changes: 5 additions & 3 deletions scripts/mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* Usage: deno task mutation <source-glob> <test-glob> [options]
*/

import { DEFAULT_TIMEOUT, parseArgs } from "./mutation/args.ts";
import { DEFAULT_DEADLINE, parseArgs } from "./mutation/args.ts";
import { expand } from "./mutation/expand.ts";
import { runIsolatedMutationCommand } from "./mutation/isolation.ts";
import {
Expand All @@ -40,7 +40,9 @@ Options:
or MUTATION_JOBS when set).
Static gates use separate CPU-aware workers, capped at 4;
set MUTATION_STATIC_JOBS to lower that cap.
--timeout <ms> Per-mutant timeout floor (default ${DEFAULT_TIMEOUT}).
--deadline <ms> Fail the whole run if it is still going after this
long (default ${DEFAULT_DEADLINE}). A guard against a mutant
that hangs the tests; it never decides a mutant.
-h, --help Show this help.

Examples:
Expand Down Expand Up @@ -74,10 +76,10 @@ const main = async (): Promise<number> => {
const { runMutationTesting } = await import("./mutation/runner.ts");
return await runMutationTesting({
...(args.batchJobs === undefined ? {} : { batchJobs: args.batchJobs }),
deadline: args.deadline,
exhaustive: args.exhaustive,
sourceFiles,
testFiles,
timeout: args.timeout,
useHarness: args.useHarness,
});
};
Expand Down
19 changes: 11 additions & 8 deletions scripts/mutation/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,19 @@
* thin CLI shell (`scripts/mutation.ts`) does the glob expansion and running.
*/

export const DEFAULT_TIMEOUT = 10_000;
/** How long a whole run may take before it is called stuck. Generous on
* purpose: this is a guard against a mutant that hangs the tests, not a
* performance budget, and it never decides a mutant either way. */
export const DEFAULT_DEADLINE = 60 * 60_000;

export interface ParsedArgs {
batchJobs?: number;
deadline: number;
error: string | null;
exhaustive: boolean;
help: boolean;
sources: string[];
tests: string[];
timeout: number;
useHarness: boolean;
}

Expand Down Expand Up @@ -49,7 +52,7 @@ const BOOLEAN_FLAGS = new Map<string, (parsed: ParsedArgs) => void>([

/**
* A blank token is not a valid number — `Number("")` and `Number(" ")` are both
* `0`, which would silently accept `--timeout ""` as a zero timeout. Turn a
* `0`, which would silently accept `--deadline ""` as a zero deadline. Turn a
* blank into NaN so the numeric validation rejects it with a clear message.
*/
const toNumber = (value: string): number =>
Expand All @@ -69,9 +72,9 @@ const VALUE_FLAGS = new Map<
["--source", (parsed, value) => parsed.sources.push(value)],
["--test", (parsed, value) => parsed.tests.push(value)],
[
"--timeout",
"--deadline",
(parsed, value) => {
parsed.timeout = toNumber(value);
parsed.deadline = toNumber(value);
},
],
]);
Expand Down Expand Up @@ -141,9 +144,9 @@ const applyPositionals = (parsed: ParsedArgs, positional: string[]): void => {

/** Validate the parsed numeric options, recording the first error found. */
const validateNumericArgs = (parsed: ParsedArgs): void => {
if (!Number.isFinite(parsed.timeout) || parsed.timeout < 0) {
if (!Number.isFinite(parsed.deadline) || parsed.deadline <= 0) {
parsed.error ??=
"Invalid --timeout: expected a non-negative number of milliseconds.";
"Invalid --deadline: expected a positive number of milliseconds.";
}
const invalidJobs =
parsed.batchJobs !== undefined &&
Expand All @@ -155,12 +158,12 @@ const validateNumericArgs = (parsed: ParsedArgs): void => {

export const parseArgs = (args: string[]): ParsedArgs => {
const parsed: ParsedArgs = {
deadline: DEFAULT_DEADLINE,
error: null,
exhaustive: false,
help: false,
sources: [],
tests: [],
timeout: DEFAULT_TIMEOUT,
useHarness: false,
};
const positional: string[] = [];
Expand Down
18 changes: 9 additions & 9 deletions scripts/mutation/evaluate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
runTestStages,
type TestStageResult,
} from "./phases.ts";
import type { Status } from "./summary.ts";
import type { EvaluationStatus } from "./summary.ts";
import {
createMutantTestState,
type MutantTestStateResult,
Expand All @@ -41,7 +41,7 @@ export interface FileMutationPlan {

export interface MutantEvaluation {
detectedBy: MutationPhase | null;
status: Status;
status: EvaluationStatus;
timings: PhaseTiming[];
}

Expand All @@ -68,7 +68,7 @@ const realDeps: EvaluationDeps = {

const detectedByKill = <Phase extends MutationPhase>(
phase: Phase,
status: Status,
status: EvaluationStatus,
): Phase | null => (status === "killed" ? phase : null);

const testStatus = (
Expand All @@ -86,11 +86,11 @@ const testStatus = (
const confirmStateMutation = async (
{ deps, plan, run, signal }: MutantRunContext,
failed: FailedTestState,
): Promise<Status> => {
if (failed.status === "timed-out") return "timed-out";
): Promise<EvaluationStatus> => {
if (failed.status === "cancelled") return "cancelled";
await deps.write(plan.file, plan.original);
const baseline = await deps.createState(run.env, signal);
if (baseline.status === "timed-out") return "timed-out";
if (baseline.status === "cancelled") return "cancelled";
if (baseline.status === "failed") {
throw new Error(
`Mutant test-state build failed (${failed.message}), and ` +
Expand All @@ -104,8 +104,8 @@ const confirmStateMutation = async (
const confirmAssetMutation = async (
{ deps, plan, signal }: MutantRunContext,
assets: MutantAssetHooks,
): Promise<Status> => {
if (signal.aborted) return "timed-out";
): Promise<EvaluationStatus> => {
if (signal.aborted) return "cancelled";
await deps.write(plan.file, plan.original);
if (!(await assets.rebuild())) {
throw new Error(
Expand Down Expand Up @@ -216,7 +216,7 @@ export const evaluateMutantTests = async (
return { ...stages, timings };
} catch (error) {
if (signal.aborted) {
return { detectedBy: null, status: "timed-out", timings };
return { detectedBy: null, status: "cancelled", timings };
}
throw error;
}
Expand Down
16 changes: 8 additions & 8 deletions scripts/mutation/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import { TEST_STATE_DIR_ENV } from "#test/test-utils/test-state-env.ts";
import { batchTestFiles } from "./batch.ts";
import { denoExitCode, envWith } from "./child-process.ts";
import { planIsolateEntries } from "./isolate-entry.ts";
import type { Status } from "./summary.ts";
import type { EvaluationStatus } from "./summary.ts";

export type Outcome = "failed" | "passed" | "timed-out";
export type Outcome = "cancelled" | "failed" | "passed";

export interface StaticGate {
exit(file: string, workspace: string, signal: AbortSignal): Promise<number>;
Expand Down Expand Up @@ -184,7 +184,7 @@ const runOneBatch = async (
controller.abort();
return "failed";
} catch (error) {
if (signal.aborted) return "timed-out";
if (signal.aborted) return "cancelled";
if (controller.signal.aborted) return null;
throw error;
}
Expand All @@ -201,7 +201,7 @@ const runBatchWorker = async (
const outcome = await runOneBatch(batch, context);
if (outcome) return outcome;
}
if (signal.aborted) return "timed-out";
if (signal.aborted) return "cancelled";
return null;
};

Expand All @@ -228,8 +228,8 @@ export const runTests = async (
);
let outcome: Outcome = outcomes.includes("failed")
? "failed"
: outcomes.includes("timed-out")
? "timed-out"
: outcomes.includes("cancelled")
? "cancelled"
: "passed";
if (outcome === "passed" && features.length > 0) {
outcome = (await runOneBatch(features, context)) ?? "passed";
Expand All @@ -240,9 +240,9 @@ export const runTests = async (
}
};

export const toStatus = (outcome: Outcome): Status =>
export const toStatus = (outcome: Outcome): EvaluationStatus =>
outcome === "passed"
? "survived"
: outcome === "failed"
? "killed"
: "timed-out";
: "cancelled";
4 changes: 2 additions & 2 deletions scripts/mutation/phases.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Status } from "./summary.ts";
import type { EvaluationStatus } from "./summary.ts";

export type MutationPhase =
| "lint"
Expand Down Expand Up @@ -35,7 +35,7 @@ export type TestDetectionPhase =

export interface TestStageResult {
detectedBy: TestDetectionPhase | null;
status: Status;
status: EvaluationStatus;
timings: PhaseTiming[];
}

Expand Down
43 changes: 11 additions & 32 deletions scripts/mutation/run-file.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { dim, green, red, yellow } from "#scripts/precommit/colors.ts";
import { dim, green, red } from "#scripts/precommit/colors.ts";
import { write } from "#scripts/precommit/write.ts";
import { projectRoot } from "#scripts/project-root.ts";
import {
Expand All @@ -20,15 +20,11 @@ const PROGRESS_INTERVAL = 10;
interface FileRunDeps {
evaluateStatic: typeof evaluateStaticMutants;
evaluateTests: typeof evaluateMutantTests;
now(): number;
timeoutSignal(milliseconds: number): AbortSignal;
}

const realDeps: FileRunDeps = {
evaluateStatic: evaluateStaticMutants,
evaluateTests: evaluateMutantTests,
now: performance.now.bind(performance),
timeoutSignal: AbortSignal.timeout,
};

const withTrackedOriginal = async <T>(
Expand Down Expand Up @@ -60,7 +56,6 @@ export interface FileRunOptions {
interface MutantLoopContext {
counts: Record<Status, number>;
gates: StaticGate[];
perMutantTimeout: number;
totalMutants: number;
}

Expand All @@ -72,36 +67,22 @@ const runTestsForStaticSurvivor = async (
deps: FileRunDeps,
): Promise<MutantEvaluation> => {
if (staticResult.status !== "survived") return staticResult;
const remaining = Math.max(
0,
Math.ceil(staticResult.deadlineAt - deps.now()),
);
if (remaining === 0) return { ...staticResult, status: "timed-out" };

return withTrackedOriginal(opts, plan, () => {
const signal = AbortSignal.any([
opts.abortSignal,
deps.timeoutSignal(remaining),
]);
return deps.evaluateTests(
// The tests run to completion. Whatever they say is the mutant's verdict; a
// clock could only ever invent one they never gave.
return withTrackedOriginal(opts, plan, () =>
deps.evaluateTests(
plan,
staticResult.mutant,
run,
opts.integrationTestFiles,
signal,
opts.abortSignal,
staticResult.timings,
);
});
),
);
};

const statusGlyph = (status: Status): string =>
status === "killed"
? green(".")
: status === "timed-out"
? yellow("T")
: status === "ignored"
? dim("i")
: red("S");
status === "killed" ? green(".") : status === "ignored" ? dim("i") : red("S");

const reportProgress = (
last: MutantResult,
Expand All @@ -124,7 +105,6 @@ const reportProgress = (
killed: counts.killed,
last,
survived: counts.survived,
timedOut: counts["timed-out"],
total,
}),
);
Expand All @@ -136,7 +116,7 @@ export const runFileMutants = async (
ctx: MutantLoopContext,
deps: FileRunDeps = realDeps,
): Promise<void> => {
const { counts, gates, perMutantTimeout, totalMutants } = ctx;
const { counts, gates, totalMutants } = ctx;
const run: TestRunConfig = {
batchJobs: opts.batchJobs,
env: testEnv(),
Expand All @@ -146,7 +126,6 @@ export const runFileMutants = async (
deps.evaluateStatic(plan, gates, {
abortSignal: opts.abortSignal,
jobs: opts.staticJobs,
perMutantTimeout,
root: projectRoot,
workerParent: opts.staticWorkerParent,
}),
Expand All @@ -161,7 +140,7 @@ export const runFileMutants = async (
opts,
deps,
);
if (opts.isAborted()) break;
if (opts.isAborted() || evaluation.status === "cancelled") break;
const status: Status =
evaluation.status === "survived" &&
isIgnored(opts.ignoreList, plan.file, mutant)
Expand Down
Loading