mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
423927fdd8
* chore(mutation): shrink the lane to report-only (#1457, #1781 wave 2) The mutation harness's two real catches (#1474, #1475) both came from humans reading the weekly score report. The ratchet half never operated: the baseline was committed exactly twice (8cce0ef6b,60400d04b), both times with `stableRuns: 0, gating: false`, and was never updated after the very fixes it triggered — the weekly job computed a new baseline and then `git checkout --`d it, uploading a proposal nobody applied in 3+ weeks. A gate nobody arms is harness weight; the report is the part that paid. Deletes ratchet.ts + ratchet.test.ts, mutation-baselines/, and every baseline/graduation/gating path in run.ts (`--update`, `mutation:baseline`). run.ts now exits non-zero only on a harness failure, never on a score. The report renders the per-kernel table (kernel, score, killed, survived, total, timeouts) plus the surviving mutants a strengthening PR works from. Kernel scoping stays: stryker.config.json and KERNEL_MODULES are untouched. * fix(mutation): restore denominator coverage and publish the table before judging the shard set Review of #1828: - `report.test.ts` re-asserts that Ignored/CompileError/RuntimeError leave the denominator — the one behaviour `ratchet.test.ts` covered and nothing replaced. A `tally()` edit that counted tool noise would have deflated every published score with a green `mutation:test`. - `assertShardsCoverModules` now runs after `emit()`, so an incomplete shard set still publishes the kernels that completed instead of only an error string. This makes the workflow comments' claim about the job summary true rather than re-wording them down. * chore(mutation): trigger the affected lane on exactly the paths that can select mutants The PR lane returns an empty matrix unless the diff touches the harness, so the kernel-source and `**/*.test.ts` triggers only bought a 1-4 min no-op job on ~96% of PRs. `on.pull_request.paths` is now exactly `LANE_TOOLING` plus the workflow file, asserted in both directions by workflow.test.ts against the exported constant — a missing path would let a harness change merge unproven, an extra one starts a job that can only answer `[]`. Also drops the workflow header's contradictory scope paragraph: it claimed the lane selects on kernel sources and any test reaching one, which has not been true since the ratchet went. * fix(mutation): score and publish a short shard set before failing on the count The expected-count check ran inside readShardedReports, before anything was summarized, so on the weekly's real `--expect-shards 10` one dead shard threw away the nine that had reported — the earlier reorder only moved the zero-mutants check. The merge now returns the shard count, and both verdicts run after emit() with the same exit code and `score` stage. Regression uses the weekly argument shape (`--expect-shards 10`, one shard present) and asserts the reporting kernel's row reaches stdout while the run still fails.
110 lines
3.7 KiB
TypeScript
110 lines
3.7 KiB
TypeScript
// Per-module mutation scores derived from a Stryker JSON report.
|
|
|
|
import { ALL_MODULE_IDS, moduleForFile, normalizePath, type ModuleId } from './modules.ts';
|
|
|
|
/** The subset of Stryker's JSON report schema this lane reads. */
|
|
export type StrykerMutant = {
|
|
readonly mutatorName?: string;
|
|
readonly status: string;
|
|
readonly location?: { readonly start?: { readonly line?: number } };
|
|
};
|
|
|
|
export type StrykerReport = {
|
|
readonly files: Record<string, { readonly mutants: readonly StrykerMutant[] }>;
|
|
};
|
|
|
|
export type SurvivingMutant = {
|
|
readonly file: string;
|
|
readonly line: number;
|
|
readonly mutator: string;
|
|
};
|
|
|
|
export type ModuleScore = {
|
|
readonly module: ModuleId;
|
|
readonly score: number;
|
|
readonly killed: number;
|
|
readonly survived: number;
|
|
readonly total: number;
|
|
/** Timeouts, already counted in `killed` — reported so a score propped up by
|
|
* slow mutants rather than assertions is visible. */
|
|
readonly timeout: number;
|
|
readonly surviving: readonly SurvivingMutant[];
|
|
};
|
|
|
|
// Stryker counts a timeout as killed. `NoCoverage` counts as survived here: an
|
|
// uncovered mutant is precisely the "decorative test" signal this lane exists to
|
|
// surface. Every other status (Ignored, CompileError, RuntimeError) leaves the
|
|
// denominator, so tool-side noise cannot move the score.
|
|
const KILLED_STATUSES = new Set(['Killed', 'Timeout']);
|
|
const SURVIVED_STATUSES = new Set(['Survived', 'NoCoverage']);
|
|
|
|
/**
|
|
* Merge sharded Stryker reports into one. The weekly sweep runs one shard per
|
|
* kernel module so no single job approaches its time budget; the report is still
|
|
* rendered from a single full-sweep view.
|
|
*/
|
|
export function mergeReports(reports: readonly StrykerReport[]): StrykerReport {
|
|
const files: Record<string, { mutants: StrykerMutant[] }> = {};
|
|
for (const report of reports) {
|
|
for (const [file, entry] of Object.entries(report.files)) {
|
|
const existing = files[file];
|
|
if (existing) existing.mutants.push(...entry.mutants);
|
|
else files[file] = { mutants: [...entry.mutants] };
|
|
}
|
|
}
|
|
return { files };
|
|
}
|
|
|
|
function roundScore(value: number): number {
|
|
return Math.round(value * 100) / 100;
|
|
}
|
|
|
|
function compareMutants(a: SurvivingMutant, b: SurvivingMutant): number {
|
|
return a.file.localeCompare(b.file) || a.line - b.line || a.mutator.localeCompare(b.mutator);
|
|
}
|
|
|
|
type Bucket = { killed: number; survived: number; timeout: number; surviving: SurvivingMutant[] };
|
|
|
|
function tally(bucket: Bucket, file: string, mutant: StrykerMutant): void {
|
|
if (KILLED_STATUSES.has(mutant.status)) {
|
|
bucket.killed += 1;
|
|
if (mutant.status === 'Timeout') bucket.timeout += 1;
|
|
return;
|
|
}
|
|
if (!SURVIVED_STATUSES.has(mutant.status)) return;
|
|
bucket.survived += 1;
|
|
bucket.surviving.push({
|
|
file: normalizePath(file),
|
|
line: mutant.location?.start?.line ?? 0,
|
|
mutator: mutant.mutatorName ?? 'unknown',
|
|
});
|
|
}
|
|
|
|
export function summarizeReport(
|
|
report: StrykerReport,
|
|
ids: readonly ModuleId[] = ALL_MODULE_IDS,
|
|
): ModuleScore[] {
|
|
const buckets = new Map<ModuleId, Bucket>();
|
|
for (const id of ids) buckets.set(id, { killed: 0, survived: 0, timeout: 0, surviving: [] });
|
|
|
|
for (const [file, entry] of Object.entries(report.files)) {
|
|
const id = moduleForFile(file);
|
|
const bucket = id ? buckets.get(id) : undefined;
|
|
if (!bucket) continue;
|
|
for (const mutant of entry.mutants) tally(bucket, file, mutant);
|
|
}
|
|
|
|
return [...buckets].map(([module, bucket]) => {
|
|
const total = bucket.killed + bucket.survived;
|
|
return {
|
|
module,
|
|
score: total === 0 ? 0 : roundScore((bucket.killed / total) * 100),
|
|
killed: bucket.killed,
|
|
survived: bucket.survived,
|
|
total,
|
|
timeout: bucket.timeout,
|
|
surviving: bucket.surviving.sort(compareMutants),
|
|
};
|
|
});
|
|
}
|