mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
e832325e87
* refactor: split generic host mechanics into @agent-device/host-kit (#2082 W1) The shared src/utils closure that blocked the platform-family moves lands on declared owners: generic host mechanics form a new private @agent-device/host-kit package between kernel and capture-kit, and capture-kit keeps capture, snapshot, and recording behavior, depending on host-kit for the mechanics it needs. tar-stream and yauzl move with the archive code. Every seam's exported subpaths are pinned in package-boundaries.test.ts, the layering model ranks the new zone, R13's allow-list names it, and each seam carries an exact eager-closure row. ADR-0019's substrate amendment describes the layout. Tests that mocked two of the moved modules separately became duplicate same-seam vi.mock factories, where the second silently replaced the first; those are merged, and the mocks that production code reaches past are pinned at their injection points instead. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * refactor(host-kit): one narrow capability port per export The four technical barrels (exec/fs/values/request) grouped by category rather than by capability, so a consumer needing one mechanic evaluated unrelated ones. Each export is now a single capability over the host machine: command, process, diagnostics, retry, archive, file, request, version. A port re-exports only what a consumer of that capability uses, and every port carries its own eager-closure row. Most of the old values barrel was never host mechanics. Pure record readers, config-source values, result text, memoization, async scoping, coordinate validation, and device-scope parsing touch no process, file, or environment, so they join kernel's other primitives instead. Closures fall accordingly: capture-kit's png-worker-client from 20 to 10, png-resize from 28 to 18, session-teardown from 79 to 68, and the CLI from 386 to 380. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * chore: drop the migration inventories and trim the touched comments Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: trim the touched host-kit and mutation-lane comments Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: keep tool directives only in the touched files Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: keep tool directives only across the touched tree Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * fix: point the Swift parity comment at the real TS twin and test The W1 move rewrote this citation to packages/contracts/src/mobile-snapshot-semantics.ts, which does not exist: the module went to capture-kit while isTapPointInsideViewport itself went to packages/contracts/src/snapshot-visibility.ts. The TS test line was left pointing at the pre-move path. Both now resolve. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * fix: repoint comment citations at the homes this refactor moved them to The W1 move left ~20 comment citations pointing at src/utils/*.ts and src/request/*.ts paths that no longer exist. Each now names the capability port that owns the symbol, which survives further file moves: exec -> host-kit/command host-process, owner-identity -> host-kit/process diagnostics -> host-kit/diagnostics atomic-file, process-lock -> host-kit/file retry -> host-kit/retry request progress/cancel -> host-kit/request version -> host-kit/version ttl-memo, source-value, parsing, device-isolation, keyed-lock, success-text -> kernel subpaths Comment-only; no closure, budget, or behavior change. ADR citations are left as written, being dated records of the decision rather than live references. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH --------- Co-authored-by: Claude <noreply@anthropic.com>
526 lines
21 KiB
TypeScript
526 lines
21 KiB
TypeScript
// Entrypoint for the decision-kernel mutation lane (issue #1415).
|
|
//
|
|
// pnpm mutation:run full sweep, rendered as a report
|
|
// pnpm mutation:check --report <file> score an existing Stryker report
|
|
// pnpm mutation:affected --base origin/main
|
|
// PR lane: mutate only the kernel
|
|
// modules the diff touches
|
|
//
|
|
// The lane reports and never gates (#1457): a score can only ever be printed,
|
|
// written to the job summary and uploaded as an artifact. The only non-zero exit
|
|
// is a harness failure — a missing report, an incomplete shard set, a bad
|
|
// argument — never a low score.
|
|
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { runCmdStreaming, runCmdSync } from '@agent-device/host-kit/command';
|
|
import { parseScriptArgs } from '../lib/cli-args.ts';
|
|
import { laneEnvelope } from '../lib/lane-envelope.ts';
|
|
import {
|
|
ALL_MODULE_IDS,
|
|
isModuleId,
|
|
LANE_CANARY,
|
|
mutateGlobs,
|
|
normalizePath,
|
|
shardMatrix,
|
|
type ModuleId,
|
|
type ShardSpec,
|
|
} from './modules.ts';
|
|
import { derivedAffectedModules } from './ownership.ts';
|
|
import { renderReport, type Provenance } from './report.ts';
|
|
import { mergeReports, summarizeReport, type ModuleScore, type StrykerReport } from './score.ts';
|
|
import {
|
|
expandMutateFiles,
|
|
relatedTestFiles,
|
|
TEST_SCOPE_ENV,
|
|
writeTestScope,
|
|
} from './test-scope.ts';
|
|
|
|
const repoRoot = runCmdSync('git', ['rev-parse', '--show-toplevel']).stdout.trim();
|
|
|
|
const CONFIG_PATH = 'stryker.config.json';
|
|
const DEFAULT_REPORT_PATH = '.tmp/mutation/mutation.json';
|
|
const TEST_SCOPE_PATH = '.tmp/mutation/test-scope.json';
|
|
const ENVELOPE_PATH = '.tmp/mutation/lane-envelope.json';
|
|
const LANE_ID = 'mutation-decision-kernels';
|
|
|
|
const USAGE = `Usage: pnpm mutation:run [options]
|
|
|
|
--modules <a,b> Restrict to specific kernel modules (default: all)
|
|
--affected Restrict to the modules touched between --base and HEAD
|
|
--base <ref> Base ref for --affected (default: origin/main)
|
|
--report <file> Read an existing Stryker JSON report instead of running Stryker
|
|
--report-dir <d> Merge every *.json Stryker report under <d> (weekly shards)
|
|
--expect-shards <n>
|
|
Fail unless --report-dir holds exactly n shard reports
|
|
--shard <i/n> Mutate only the i-th of n balanced slices of the module's
|
|
sources (the big modules exceed one job's budget)
|
|
--summary <file> Also write the markdown report to <file>
|
|
--no-run Alias for --report with the default report path
|
|
--list-affected Print the PR lane's shard matrix as JSON and exit (empty
|
|
unless the diff touches the lane's own tooling)
|
|
--fail-envelope <reason>
|
|
Write a failed lane envelope for a step that ran before (or
|
|
instead of) the sweep, e.g. a self-test failure
|
|
`;
|
|
|
|
type Args = {
|
|
modules: readonly ModuleId[];
|
|
affected: boolean;
|
|
base: string;
|
|
report: string | undefined;
|
|
reportDir: string | undefined;
|
|
summary: string | undefined;
|
|
listAffected: boolean;
|
|
failEnvelope: string | undefined;
|
|
shard: Shard | undefined;
|
|
expectShards: number | undefined;
|
|
};
|
|
|
|
/** One-based slice of a module's mutated sources: `--shard 2/4`. */
|
|
type Shard = { index: number; count: number };
|
|
|
|
function parseShard(value: string | undefined): Shard | undefined {
|
|
if (!value) return undefined;
|
|
const match = /^(\d+)\/(\d+)$/.exec(value.trim());
|
|
const index = Number(match?.[1]);
|
|
const count = Number(match?.[2]);
|
|
if (!match || index < 1 || index > count) {
|
|
throw new Error(`--shard expects i/n with 1 <= i <= n, got "${value}"`);
|
|
}
|
|
return { index, count };
|
|
}
|
|
|
|
function parseModules(value: string | undefined): readonly ModuleId[] {
|
|
if (!value) return ALL_MODULE_IDS;
|
|
const ids = value
|
|
.split(',')
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
const unknown = ids.filter((id) => !isModuleId(id));
|
|
if (unknown.length > 0) {
|
|
throw new Error(
|
|
`Unknown mutation module(s): ${unknown.join(', ')}. Known: ${ALL_MODULE_IDS.join(', ')}`,
|
|
);
|
|
}
|
|
return ids.filter(isModuleId);
|
|
}
|
|
|
|
function parseMutationArgs(argv: readonly string[]): Args {
|
|
const values = parseScriptArgs(argv, USAGE, {
|
|
modules: { type: 'string' },
|
|
affected: { type: 'boolean', default: false },
|
|
base: { type: 'string', default: 'origin/main' },
|
|
report: { type: 'string' },
|
|
'report-dir': { type: 'string' },
|
|
summary: { type: 'string' },
|
|
'no-run': { type: 'boolean', default: false },
|
|
'list-affected': { type: 'boolean', default: false },
|
|
'fail-envelope': { type: 'string' },
|
|
shard: { type: 'string' },
|
|
'expect-shards': { type: 'string' },
|
|
});
|
|
return {
|
|
modules: parseModules(values.modules),
|
|
affected: Boolean(values.affected),
|
|
base: values.base ?? 'origin/main',
|
|
report: values.report ?? (values['no-run'] ? DEFAULT_REPORT_PATH : undefined),
|
|
reportDir: values['report-dir'],
|
|
summary: values.summary,
|
|
listAffected: Boolean(values['list-affected']),
|
|
failEnvelope: values['fail-envelope'],
|
|
shard: parseShard(values.shard),
|
|
expectShards: values['expect-shards'] ? Number(values['expect-shards']) : undefined,
|
|
};
|
|
}
|
|
|
|
/** Short, stable content hash of the Stryker config — half of a run's provenance. */
|
|
export function configHash(configText: string): string {
|
|
return `sha256:${crypto.createHash('sha256').update(configText).digest('hex').slice(0, 12)}`;
|
|
}
|
|
|
|
function readProvenance(root: string = repoRoot): Provenance {
|
|
const pkgPath = path.join(root, 'node_modules/@stryker-mutator/core/package.json');
|
|
const version = fs.existsSync(pkgPath)
|
|
? (JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { version: string }).version
|
|
: 'unknown';
|
|
return {
|
|
strykerVersion: version,
|
|
configHash: configHash(fs.readFileSync(path.join(root, CONFIG_PATH), 'utf8')),
|
|
};
|
|
}
|
|
|
|
function changedFiles(base: string): string[] {
|
|
const result = runCmdSync('git', ['diff', '--name-only', '--merge-base', base, 'HEAD'], {
|
|
cwd: repoRoot,
|
|
allowFailure: true,
|
|
});
|
|
return result.stdout.split('\n').filter(Boolean);
|
|
}
|
|
|
|
// The JSON report path comes from the config (Stryker's CLI takes no nested
|
|
// reporter options), so a run always writes DEFAULT_REPORT_PATH.
|
|
/**
|
|
* Balances a module's sources across `count` jobs. Mutant count tracks file size
|
|
* closely enough that greedy longest-first packing keeps the slowest slice near
|
|
* the mean — the alternative is one 1,277-mutant selectors job that outruns both
|
|
* the 30-minute acceptance budget and its own timeout.
|
|
*/
|
|
function shardFiles(files: readonly string[], shard: Shard, root: string): string[] {
|
|
const bins: { size: number; files: string[] }[] = Array.from({ length: shard.count }, () => ({
|
|
size: 0,
|
|
files: [],
|
|
}));
|
|
const weighed = files
|
|
.map((file) => ({ file, size: fs.statSync(path.join(root, file)).size }))
|
|
.sort((a, b) => b.size - a.size || a.file.localeCompare(b.file));
|
|
for (const { file, size } of weighed) {
|
|
const bin = bins.reduce((smallest, next) => (next.size < smallest.size ? next : smallest));
|
|
bin.files.push(file);
|
|
bin.size += size;
|
|
}
|
|
return bins[shard.index - 1]!.files.sort();
|
|
}
|
|
|
|
async function runStryker(
|
|
modules: readonly ModuleId[],
|
|
reportPath: string,
|
|
shard: Shard | undefined,
|
|
): Promise<void> {
|
|
const absolute = path.isAbsolute(reportPath) ? reportPath : path.join(repoRoot, reportPath);
|
|
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
|
fs.rmSync(absolute, { force: true });
|
|
// The suite Stryker replays per mutant is derived from Vitest's module graph
|
|
// over the mutated files, not hand-listed; see scripts/mutation/test-scope.ts.
|
|
const globs = mutateGlobs(modules);
|
|
const all = expandMutateFiles(globs, repoRoot);
|
|
// A sharded job mutates concrete files, so the slice is exact rather than a
|
|
// glob the next contributor has to keep in step with the registry.
|
|
const mutate = shard ? shardFiles(all, shard, repoRoot) : globs;
|
|
const scopePath = path.join(repoRoot, TEST_SCOPE_PATH);
|
|
const testFiles = relatedTestFiles(shard ? mutate : all, repoRoot);
|
|
writeTestScope(testFiles, scopePath);
|
|
process.stdout.write(
|
|
`mutation: ${modules.join(', ')}${shard ? ` shard ${shard.index}/${shard.count}` : ''} -> ` +
|
|
`${shard ? mutate.length : all.length} source(s), ${testFiles.length} related test file(s)\n`,
|
|
);
|
|
|
|
// Stryker's `--mutate` takes one comma-separated value, not repeated args.
|
|
const args = ['exec', 'stryker', 'run', CONFIG_PATH, '--mutate', mutate.join(',')];
|
|
const result = await runCmdStreaming('pnpm', args, {
|
|
cwd: repoRoot,
|
|
allowFailure: true,
|
|
env: { ...process.env, [TEST_SCOPE_ENV]: scopePath },
|
|
onStdoutChunk: (chunk) => void process.stdout.write(chunk),
|
|
onStderrChunk: (chunk) => void process.stderr.write(chunk),
|
|
});
|
|
// Stryker exits non-zero on a low score too; this lane reports scores rather
|
|
// than judging them, so only a missing report is fatal here.
|
|
if (!fs.existsSync(absolute)) {
|
|
throw new Error(
|
|
`Stryker produced no report at ${reportPath} (exit ${result.exitCode}). See output above.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function emit(markdown: string, summaryPath: string | undefined): void {
|
|
process.stdout.write(`\n${markdown}`);
|
|
const targets = [summaryPath, process.env.GITHUB_STEP_SUMMARY].filter(
|
|
(target): target is string => Boolean(target),
|
|
);
|
|
for (const target of targets) {
|
|
fs.mkdirSync(path.dirname(path.resolve(target)), { recursive: true });
|
|
fs.appendFileSync(target, markdown);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* How far the lane got. A run that dies in `stryker` or `report` is exactly the
|
|
* failure the freshness monitor must see, so the stage rides in the envelope
|
|
* rather than only in the job log.
|
|
*/
|
|
type Stage = 'setup' | 'select' | 'stryker' | 'report' | 'score' | 'complete';
|
|
|
|
/**
|
|
* Provenance for a lane that died before it could read any: `unknown` is a
|
|
* reported fact, whereas skipping the envelope would be silence.
|
|
*/
|
|
const UNKNOWN_PROVENANCE: Provenance = { strykerVersion: 'unknown', configHash: 'unknown' };
|
|
|
|
type LaneState = {
|
|
stage: Stage;
|
|
provenance: Provenance;
|
|
modules: readonly ModuleId[];
|
|
affected: boolean;
|
|
scores: readonly ModuleScore[];
|
|
error: string | undefined;
|
|
/**
|
|
* `--fail-envelope` keeps an existing *failure* (its reason is the specific
|
|
* one) but replaces an existing pass: a step can fail after the report was
|
|
* rendered, and publishing that job as passing is the bug the envelope exists
|
|
* to prevent.
|
|
*/
|
|
recoveryOnly: boolean;
|
|
};
|
|
|
|
/** The result of an envelope already on disk, if there is a readable one. */
|
|
function existingResult(file: string): string | undefined {
|
|
if (!fs.existsSync(file)) return undefined;
|
|
try {
|
|
return (JSON.parse(fs.readFileSync(file, 'utf8')) as { result?: string }).result;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A module's measurement for the envelope, or explicit nulls when the lane died
|
|
* before measuring it — an absent field would read as "no mutants", a score.
|
|
*/
|
|
function envelopeModule(id: ModuleId, scores: readonly ModuleScore[]) {
|
|
const score = scores.find((entry) => entry.module === id);
|
|
if (!score) {
|
|
return { id, score: null, killed: null, survived: null, total: null, timeout: null };
|
|
}
|
|
const { module: _module, surviving: _surviving, ...measured } = score;
|
|
return { id, ...measured };
|
|
}
|
|
|
|
/**
|
|
* Scheduled-lane artifact envelope (#1430). Without it a downloaded report cannot
|
|
* say which commit or Stryker/config version produced it, how long the sweep took,
|
|
* or whether it passed — freshness and tool-drift monitoring would have to parse
|
|
* logs.
|
|
*
|
|
* Written on every exit path, including a crashed setup or a Stryker run that
|
|
* produced no report: a lane that fails before it can measure anything is the
|
|
* dark-lane case, and an absent envelope is indistinguishable from a lane that
|
|
* never ran.
|
|
*/
|
|
function writeEnvelope(state: LaneState, startedAtMs: number): void {
|
|
const target = path.join(repoRoot, ENVELOPE_PATH);
|
|
if (state.recoveryOnly && existingResult(target) === 'fail') {
|
|
process.stdout.write(`mutation: ${ENVELOPE_PATH} already reports a failure; left as is.\n`);
|
|
return;
|
|
}
|
|
const envelope = laneEnvelope({
|
|
lane: LANE_ID,
|
|
commit: runCmdSync('git', ['rev-parse', 'HEAD'], {
|
|
cwd: repoRoot,
|
|
allowFailure: true,
|
|
}).stdout.trim(),
|
|
tool: { stryker: state.provenance.strykerVersion },
|
|
configHash: state.provenance.configHash,
|
|
startedAtMs,
|
|
// The lane never judges a score, so the result states whether the lane
|
|
// produced a report at all.
|
|
result: state.stage === 'complete' ? 'pass' : 'fail',
|
|
data: {
|
|
scope: state.affected ? 'affected' : 'full-sweep',
|
|
stage: state.stage,
|
|
error: state.error ?? null,
|
|
modules: state.modules.map((id) => envelopeModule(id, state.scores)),
|
|
},
|
|
});
|
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
fs.writeFileSync(target, `${JSON.stringify(envelope, null, 2)}\n`);
|
|
process.stdout.write(
|
|
`\nLane envelope (${ENVELOPE_PATH}): ${envelope.result} at stage ${state.stage} in ` +
|
|
`${Math.round(envelope.durationMs / 1000)}s at ${envelope.commit.slice(0, 12)}.\n`,
|
|
);
|
|
}
|
|
|
|
function readReport(file: string): StrykerReport {
|
|
const absolute = path.isAbsolute(file) ? file : path.join(repoRoot, file);
|
|
return JSON.parse(fs.readFileSync(absolute, 'utf8')) as StrykerReport;
|
|
}
|
|
|
|
/**
|
|
* Merges whatever shard reports are present and says how many there were. The
|
|
* expected-count verdict is the caller's, so a short set is still scored and
|
|
* published before it fails.
|
|
*/
|
|
function readShardedReports(dir: string): { report: StrykerReport; count: number } {
|
|
const root = path.isAbsolute(dir) ? dir : path.join(repoRoot, dir);
|
|
// Shard artifacts also carry the lane envelope and the derived test scope, so
|
|
// the report is selected by name rather than by "every .json here".
|
|
const files = fs
|
|
.globSync(`**/${path.basename(DEFAULT_REPORT_PATH)}`, { cwd: root })
|
|
.map((file) => path.join(root, file))
|
|
.sort();
|
|
if (files.length === 0) throw new Error(`No Stryker JSON reports found under ${dir}`);
|
|
process.stdout.write(`mutation: merging ${files.length} shard report(s) from ${dir}\n`);
|
|
return { report: mergeReports(files.map(readReport)), count: files.length };
|
|
}
|
|
|
|
async function produceReport(
|
|
modules: readonly ModuleId[],
|
|
reportPath: string | undefined,
|
|
shard: Shard | undefined,
|
|
): Promise<StrykerReport> {
|
|
if (!reportPath) await runStryker(modules, DEFAULT_REPORT_PATH, shard);
|
|
return readReport(reportPath ?? DEFAULT_REPORT_PATH);
|
|
}
|
|
|
|
/**
|
|
* A merged shard set is only a sweep if every expected shard reported and every
|
|
* requested module has mutants — sub-sharded modules make the second check too
|
|
* weak alone, since a surviving slice still covers its module. `summarizeReport`
|
|
* scores an absent module as 0, so without this a dead shard would be published
|
|
* as a `complete`/`pass` envelope claiming the sweep happened.
|
|
*
|
|
* Both verdicts run after the table is published: the kernels that did complete
|
|
* are the part of the run still worth reading on a failed-shard day.
|
|
*/
|
|
function assertCompleteSweep(state: LaneState, dir: string, shards: number, args: Args): void {
|
|
if (args.expectShards !== undefined && shards !== args.expectShards) {
|
|
throw new Error(
|
|
`Incomplete shard set from ${dir}: ${shards} report(s), expected ${args.expectShards}. ` +
|
|
'A shard job failed or its artifact is absent; the aggregate is not a sweep.',
|
|
);
|
|
}
|
|
const missing = state.scores.filter((score) => score.total === 0).map((score) => score.module);
|
|
if (missing.length === 0) return;
|
|
throw new Error(
|
|
`Incomplete shard set from ${dir}: no mutants for ${missing.join(', ')}. ` +
|
|
'A shard job failed or its artifact is absent; the aggregate is not a sweep.',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Reads shard reports, an existing report, or runs Stryker — and scores them.
|
|
* Returns the shard count when the source was a shard directory.
|
|
*/
|
|
async function scoreModules(args: Args, state: LaneState): Promise<number | undefined> {
|
|
state.stage = args.reportDir || args.report ? 'report' : 'stryker';
|
|
const shards = args.reportDir ? readShardedReports(args.reportDir) : undefined;
|
|
const report = shards?.report ?? (await produceReport(state.modules, args.report, args.shard));
|
|
|
|
state.stage = 'score';
|
|
state.scores = summarizeReport(report, state.modules);
|
|
return shards?.count;
|
|
}
|
|
|
|
async function sweep(args: Args, state: LaneState): Promise<number> {
|
|
state.stage = 'select';
|
|
// Same selection the PR matrix uses, so the reporting job can never score
|
|
// mutants the `select` job decided not to spend.
|
|
if (args.affected) {
|
|
state.modules = [...new Set(affectedMatrix(args.base).map((entry) => entry.module))];
|
|
}
|
|
if (state.modules.length === 0) {
|
|
process.stdout.write('mutation: no decision-kernel modules affected — nothing to mutate.\n');
|
|
state.stage = 'complete';
|
|
return 0;
|
|
}
|
|
|
|
const shards = await scoreModules(args, state);
|
|
|
|
const title = args.affected
|
|
? 'Mutation score — affected decision kernels'
|
|
: 'Mutation score — decision kernels';
|
|
emit(renderReport(state.scores, state.provenance, { title }), args.summary);
|
|
|
|
if (args.reportDir) assertCompleteSweep(state, args.reportDir, shards ?? 0, args);
|
|
state.stage = 'complete';
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Sources of the lane itself: a change here must prove itself on real mutants.
|
|
* These are also the only paths that can produce a non-empty matrix, so
|
|
* `mutation-affected.yml` triggers on exactly them — asserted by
|
|
* `workflow.test.ts`, since a filter that missed one would let a harness change
|
|
* merge unproven, and any wider filter only buys a no-op job.
|
|
*/
|
|
export const LANE_TOOLING = ['scripts/mutation/', 'scripts/lib/', 'stryker.config.json'];
|
|
|
|
/**
|
|
* The PR lane's matrix: empty unless the diff touches the harness, otherwise the
|
|
* canary plus whatever kernels that same diff derives. The kernel report that
|
|
* pays is the weekly sweep, so the PR lane spends mutants on one thing only —
|
|
* proving the harness still runs end to end when the harness changes. Selecting
|
|
* on derived kernel ownership alone would run the full ten-shard sweep on 24 of
|
|
* the last 40 merged PRs, a per-PR full sweep for a report nobody gates on.
|
|
*/
|
|
export function affectedMatrixFor(
|
|
changed: readonly string[],
|
|
root: string = repoRoot,
|
|
): ShardSpec[] {
|
|
const touchesLane = changed.some((file) =>
|
|
LANE_TOOLING.some((prefix) => normalizePath(file).startsWith(prefix)),
|
|
);
|
|
if (!touchesLane) return [];
|
|
const modules = new Set(derivedAffectedModules(changed, root));
|
|
// Lane sources own no kernel, so a tooling-only diff derives nothing: without
|
|
// the canary the lane would select zero mutants and prove nothing.
|
|
modules.add(LANE_CANARY);
|
|
return shardMatrix(ALL_MODULE_IDS.filter((id) => modules.has(id)));
|
|
}
|
|
|
|
function affectedMatrix(base: string): ShardSpec[] {
|
|
return affectedMatrixFor(changedFiles(base));
|
|
}
|
|
|
|
/**
|
|
* Everything after `--help` runs inside the envelope boundary, argument parsing
|
|
* and provenance reads included: a lane that cannot even read its own config is
|
|
* the failure freshness monitoring most needs to see.
|
|
*/
|
|
async function run(argv: readonly string[], state: LaneState): Promise<number> {
|
|
const args = parseMutationArgs(argv);
|
|
state.affected = args.affected;
|
|
state.provenance = readProvenance();
|
|
state.modules = args.modules;
|
|
if (args.failEnvelope) {
|
|
// A step that ran before the sweep failed (the weekly self-test, setup): the
|
|
// lane produced no measurement, and that is what the envelope must say.
|
|
state.error = args.failEnvelope;
|
|
state.recoveryOnly = true;
|
|
return 1;
|
|
}
|
|
return await sweep(args, state);
|
|
}
|
|
|
|
async function main(argv = process.argv.slice(2)): Promise<number> {
|
|
const startedAtMs = Date.now();
|
|
if (argv.includes('--list-affected')) {
|
|
// Selection only — no lane run, so no envelope. The shard jobs that consume
|
|
// this matrix each write their own.
|
|
const args = parseMutationArgs(argv);
|
|
process.stdout.write(`${JSON.stringify(affectedMatrix(args.base))}\n`);
|
|
return 0;
|
|
}
|
|
const state: LaneState = {
|
|
stage: 'setup',
|
|
provenance: UNKNOWN_PROVENANCE,
|
|
modules: [],
|
|
affected: false,
|
|
scores: [],
|
|
error: undefined,
|
|
recoveryOnly: false,
|
|
};
|
|
try {
|
|
return await run(argv, state);
|
|
} catch (error: unknown) {
|
|
state.error = error instanceof Error ? error.message : String(error);
|
|
process.stderr.write(`mutation: ${state.error}\n`);
|
|
return 1;
|
|
} finally {
|
|
writeEnvelope(state, startedAtMs);
|
|
}
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
|
main()
|
|
.then((code) => {
|
|
process.exitCode = code;
|
|
})
|
|
.catch((error: unknown) => {
|
|
process.stderr.write(`mutation: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|