mjolnir-qa 0.5.36 → 0.5.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/cli.d.mts +54 -2
- package/dist/cli.mjs +327 -11
- package/dist/mcp/stdio.mjs +326 -10
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,12 @@ Rule behavior changes (new rules, FP-rate changes against the corpus,
|
|
|
9
9
|
severity changes) are first-class entries here — rule IDs are immutable
|
|
10
10
|
once shipped, so this file is the record of what changed between versions.
|
|
11
11
|
|
|
12
|
+
## [0.5.37] — 2026-09-08
|
|
13
|
+
|
|
14
|
+
### Changes since 0
|
|
15
|
+
|
|
16
|
+
- P5: mutation evidence reader — Stryker/mutmut, E1→E2 derivation, report-only (#67)
|
|
17
|
+
|
|
12
18
|
## [0.5.36] — 2026-09-08
|
|
13
19
|
|
|
14
20
|
### Changes since 0
|
package/dist/cli.d.mts
CHANGED
|
@@ -141,6 +141,17 @@ interface Finding {
|
|
|
141
141
|
* stamped with the corroboration pass. Additive within schemaVersion 1.
|
|
142
142
|
*/
|
|
143
143
|
trustLevel?: TrustLevel;
|
|
144
|
+
/**
|
|
145
|
+
* Mutation evidence (master plan P5, plan 1788853205786): what a
|
|
146
|
+
* mutation-testing report says about the code this finding points at.
|
|
147
|
+
* PROVENANCE, not proof — a survived mutant is code the suite would
|
|
148
|
+
* not notice changing; matching findings consolidate E1→E2 BY
|
|
149
|
+
* DERIVATION (documented in docs/RULE-LIFECYCLE.md), and trustLevel
|
|
150
|
+
* never rises from mutation evidence alone (nothing ran). Absent
|
|
151
|
+
* means no mutation report was available or nothing matched — never
|
|
152
|
+
* fabricated. Additive within schemaVersion 1.
|
|
153
|
+
*/
|
|
154
|
+
mutationEvidence?: MutationEvidence | undefined;
|
|
144
155
|
/** Repo-relative path with forward slashes, regardless of OS. */
|
|
145
156
|
file: string;
|
|
146
157
|
/** 1-based. */
|
|
@@ -169,6 +180,34 @@ interface Finding {
|
|
|
169
180
|
*/
|
|
170
181
|
fixGroupId?: string;
|
|
171
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* Mutation evidence (master plan P5, plan 1788853205786 — flag 6,
|
|
185
|
+
* decision 8): provenance from a mutation-testing report
|
|
186
|
+
* (`mjolnir mutation <report>`), stamped on matching findings.
|
|
187
|
+
*
|
|
188
|
+
* PROVENANCE IS NOT TRUTH: a survived mutant is code the suite would
|
|
189
|
+
* not notice changing — evidence FOR a nearby finding, never a claim
|
|
190
|
+
* the finding is "proven real". The E1→E2 consolidation is BY
|
|
191
|
+
* DERIVATION and lives in src/mutation/derive.ts (documented in
|
|
192
|
+
* docs/RULE-LIFECYCLE.md + the machine-contract docs). trustLevel never
|
|
193
|
+
* rises from mutation evidence alone: nothing ran.
|
|
194
|
+
*/
|
|
195
|
+
interface MutationEvidence {
|
|
196
|
+
/** Which mutation tool produced the report. */
|
|
197
|
+
source: "stryker" | "mutmut";
|
|
198
|
+
/** How many survived mutants matched this finding. */
|
|
199
|
+
matchedMutants: number;
|
|
200
|
+
/** The mutator names of the matched mutants (deduped, sorted). */
|
|
201
|
+
mutators: string[];
|
|
202
|
+
/**
|
|
203
|
+
* "line" = the mutant's span contains the finding's position
|
|
204
|
+
* (Stryker evidence); "file" = file-level match only (mutmut —
|
|
205
|
+
* its JUnit report carries no per-mutant lines — or an unplaceable
|
|
206
|
+
* Stryker span). Same prefer-claiming-less rule as the runtime
|
|
207
|
+
* corroboration's granularity fallback.
|
|
208
|
+
*/
|
|
209
|
+
granularity: "line" | "file";
|
|
210
|
+
}
|
|
172
211
|
interface DimensionScore {
|
|
173
212
|
category: RuleCategory;
|
|
174
213
|
score: number;
|
|
@@ -744,7 +783,7 @@ declare const runScan: typeof runScan$1, buildUniversalRules: typeof buildUniver
|
|
|
744
783
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
745
784
|
* `tests/version-consistency.spec.ts` locally.
|
|
746
785
|
*/
|
|
747
|
-
declare const CLI_VERSION = "0.5.
|
|
786
|
+
declare const CLI_VERSION = "0.5.37";
|
|
748
787
|
/** A usage-error detail: the offending token, when one exists. */
|
|
749
788
|
interface UsageErrorDetail {
|
|
750
789
|
/** The unknown flag or rejected value (e.g. `--nope`, `loud`). */
|
|
@@ -815,6 +854,19 @@ declare function runTriageCommand(argv: string[], io?: {
|
|
|
815
854
|
out: Output;
|
|
816
855
|
err: Output;
|
|
817
856
|
}): number;
|
|
857
|
+
/** Testable `mutation` handler (master plan P5, plan 1788853205786).
|
|
858
|
+
* Reads a mutation report (Stryker JSON / mutmut junitxml), renders the
|
|
859
|
+
* survived-mutant leaderboard, and — with `--scan <path>` — re-scans the
|
|
860
|
+
* target, stamps matching findings with `mutationEvidence` (E1→E2 by
|
|
861
|
+
* derivation) and renders the stamped findings. NEVER spawns mutation
|
|
862
|
+
* tools: it reads reports the user already produced. Exit 2 when
|
|
863
|
+
* nothing in the report is recognized — honest no-evidence, not an
|
|
864
|
+
* error. Exit 0 in every other case: the reader is report-only, never a
|
|
865
|
+
* gate. */
|
|
866
|
+
declare function runMutationCommand(argv: string[], io?: {
|
|
867
|
+
out: Output;
|
|
868
|
+
err: Output;
|
|
869
|
+
}): Promise<number>;
|
|
818
870
|
/** Testable `badge` handler (Tier 1 #5). */
|
|
819
871
|
declare function runBadgeCommand(argv: string[], io?: {
|
|
820
872
|
out: Output;
|
|
@@ -891,4 +943,4 @@ declare function runHelpCommand(argv: string[], io?: {
|
|
|
891
943
|
}): number;
|
|
892
944
|
declare function isEntryPoint(): boolean;
|
|
893
945
|
//#endregion
|
|
894
|
-
export { CLI_VERSION, type CliArgs, EVIDENCE_OVERRIDES, KNOWN_RULE_IDS, OVERLAP_META_BY_RULE_ID, type Output, SUITE_INVALIDATING_RULE_IDS, type ScanHooks, UsageErrorDetail, buildUniversalRules, discoverRuntimeReport, err, exitForFindings, fallbackWorkspace, internalErrorMessage, isEntryPoint, isValidFindingRecord, levenshtein, main, nearestFlags, out, parseArgs, pathMatchesGlob, runBadgeCommand, runBaselineCommand, runCiInstall, runCreateRuleCommand, runDebtCommand, runDiffCommand, runDoctorCommand, runDoctorPlaywright, runExplainCommand, runFixCommand, runForensicsCommand, runHandoverCommand, runHelpCommand, runImpactCommand, runInitCommand, runPrCommentCommand, runPwReportCommand, runRulesCommand, runScan, runScanCommand, runStatsCommand, runSuppressions, runTriageCommand, usageErrorMessage };
|
|
946
|
+
export { CLI_VERSION, type CliArgs, EVIDENCE_OVERRIDES, KNOWN_RULE_IDS, OVERLAP_META_BY_RULE_ID, type Output, SUITE_INVALIDATING_RULE_IDS, type ScanHooks, UsageErrorDetail, buildUniversalRules, discoverRuntimeReport, err, exitForFindings, fallbackWorkspace, internalErrorMessage, isEntryPoint, isValidFindingRecord, levenshtein, main, nearestFlags, out, parseArgs, pathMatchesGlob, runBadgeCommand, runBaselineCommand, runCiInstall, runCreateRuleCommand, runDebtCommand, runDiffCommand, runDoctorCommand, runDoctorPlaywright, runExplainCommand, runFixCommand, runForensicsCommand, runHandoverCommand, runHelpCommand, runImpactCommand, runInitCommand, runMutationCommand, runPrCommentCommand, runPwReportCommand, runRulesCommand, runScan, runScanCommand, runStatsCommand, runSuppressions, runTriageCommand, usageErrorMessage };
|
package/dist/cli.mjs
CHANGED
|
@@ -11178,19 +11178,19 @@ function parseJestJson(json) {
|
|
|
11178
11178
|
}
|
|
11179
11179
|
//#endregion
|
|
11180
11180
|
//#region src/forensics/parse-junit.ts
|
|
11181
|
-
const MAX_INPUT = 20971520;
|
|
11182
|
-
function decodeEntities(s) {
|
|
11181
|
+
const MAX_INPUT$1 = 20971520;
|
|
11182
|
+
function decodeEntities$1(s) {
|
|
11183
11183
|
return s.replaceAll("<", "<").replaceAll(">", ">").replaceAll(""", "\"").replaceAll("'", "'").replaceAll("'", "'").replaceAll("&", "&");
|
|
11184
11184
|
}
|
|
11185
11185
|
function attr(tag, name) {
|
|
11186
11186
|
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
11187
11187
|
const m = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*"([^"]*)"`, "i").exec(tag);
|
|
11188
|
-
if (m?.[1] !== void 0) return decodeEntities(m[1]);
|
|
11188
|
+
if (m?.[1] !== void 0) return decodeEntities$1(m[1]);
|
|
11189
11189
|
const m2 = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*'([^']*)'`, "i").exec(tag);
|
|
11190
|
-
return m2?.[1] !== void 0 ? decodeEntities(m2[1]) : void 0;
|
|
11190
|
+
return m2?.[1] !== void 0 ? decodeEntities$1(m2[1]) : void 0;
|
|
11191
11191
|
}
|
|
11192
11192
|
function parseJunitXml(xml) {
|
|
11193
|
-
if (xml.length > MAX_INPUT) return [];
|
|
11193
|
+
if (xml.length > MAX_INPUT$1) return [];
|
|
11194
11194
|
const out = [];
|
|
11195
11195
|
const startRe = /<testcase\b/gi;
|
|
11196
11196
|
const closeRe = /<\/testcase\s*>/gi;
|
|
@@ -11345,7 +11345,7 @@ function parseVitestJson(json) {
|
|
|
11345
11345
|
*
|
|
11346
11346
|
* Writes FLAKY.md next to the scan target unless --no-flaky-md.
|
|
11347
11347
|
*/
|
|
11348
|
-
const MAX_FILES = 500;
|
|
11348
|
+
const MAX_FILES$1 = 500;
|
|
11349
11349
|
function runForensics(target, options = {}) {
|
|
11350
11350
|
const records = [];
|
|
11351
11351
|
let source = "playwright-json";
|
|
@@ -11369,7 +11369,7 @@ function runForensics(target, options = {}) {
|
|
|
11369
11369
|
else {
|
|
11370
11370
|
let count = 0;
|
|
11371
11371
|
for (const full of listFiles(target)) {
|
|
11372
|
-
if (++count > MAX_FILES) break;
|
|
11372
|
+
if (++count > MAX_FILES$1) break;
|
|
11373
11373
|
try {
|
|
11374
11374
|
const parsed = parseFile(full, readFileSync(full, "utf8"));
|
|
11375
11375
|
if (parsed.records.length === 0) continue;
|
|
@@ -11437,7 +11437,7 @@ function parseFile(path, text) {
|
|
|
11437
11437
|
function listFiles(dir) {
|
|
11438
11438
|
const out = [];
|
|
11439
11439
|
const walk = (d, depth) => {
|
|
11440
|
-
if (depth > 4 || out.length > MAX_FILES) return;
|
|
11440
|
+
if (depth > 4 || out.length > MAX_FILES$1) return;
|
|
11441
11441
|
let entries;
|
|
11442
11442
|
try {
|
|
11443
11443
|
entries = readdirSync(d, { withFileTypes: true });
|
|
@@ -13464,7 +13464,7 @@ function renderSarif(result, repoRootUri) {
|
|
|
13464
13464
|
tool: { driver: {
|
|
13465
13465
|
name: "Mjölnir",
|
|
13466
13466
|
informationUri: "https://github.com/Sergey-Bar/Mjolnir",
|
|
13467
|
-
version: "0.5.
|
|
13467
|
+
version: "0.5.37",
|
|
13468
13468
|
rules: [...rules.values()].map((r) => {
|
|
13469
13469
|
const meta = RULES.find((x) => x.id === r.id);
|
|
13470
13470
|
return {
|
|
@@ -16314,6 +16314,220 @@ function renderTriageMd(report) {
|
|
|
16314
16314
|
return lines.join("\n");
|
|
16315
16315
|
}
|
|
16316
16316
|
//#endregion
|
|
16317
|
+
//#region src/mutation/parse-stryker.ts
|
|
16318
|
+
const MAX_FILES = 5e3;
|
|
16319
|
+
const MAX_MUTANTS_PER_FILE = 2e4;
|
|
16320
|
+
/** Sniff: does this parsed JSON look like a Stryker mutation report? */
|
|
16321
|
+
function looksLikeStrykerJson(json) {
|
|
16322
|
+
if (!json || typeof json !== "object") return false;
|
|
16323
|
+
return typeof json.files === "object" && json.files !== null;
|
|
16324
|
+
}
|
|
16325
|
+
function parseStrykerJson(json) {
|
|
16326
|
+
if (!json || typeof json !== "object") return {
|
|
16327
|
+
tool: "stryker",
|
|
16328
|
+
survived: [],
|
|
16329
|
+
noCoverage: 0,
|
|
16330
|
+
killed: 0
|
|
16331
|
+
};
|
|
16332
|
+
const files = json.files;
|
|
16333
|
+
if (!files || typeof files !== "object") return {
|
|
16334
|
+
tool: "stryker",
|
|
16335
|
+
survived: [],
|
|
16336
|
+
noCoverage: 0,
|
|
16337
|
+
killed: 0
|
|
16338
|
+
};
|
|
16339
|
+
const survived = [];
|
|
16340
|
+
let noCoverage = 0;
|
|
16341
|
+
let killed = 0;
|
|
16342
|
+
const entries = Object.entries(files).slice(0, MAX_FILES);
|
|
16343
|
+
for (const [rawPath, file] of entries) {
|
|
16344
|
+
if (!file || typeof file !== "object") continue;
|
|
16345
|
+
const mutants = Array.isArray(file.mutants) ? file.mutants : [];
|
|
16346
|
+
const path = rawPath.replaceAll("\\", "/");
|
|
16347
|
+
for (const m of mutants.slice(0, MAX_MUTANTS_PER_FILE)) {
|
|
16348
|
+
if (!m || typeof m !== "object") continue;
|
|
16349
|
+
if (m.status === "Survived") {
|
|
16350
|
+
const startLine = toOneBased(m.location?.start?.line);
|
|
16351
|
+
const endLine = toOneBased(m.location?.end?.line);
|
|
16352
|
+
if (startLine === void 0) continue;
|
|
16353
|
+
survived.push({
|
|
16354
|
+
file: path,
|
|
16355
|
+
mutator: typeof m.mutatorName === "string" ? m.mutatorName : "unknown",
|
|
16356
|
+
startLine,
|
|
16357
|
+
endLine: endLine !== void 0 ? Math.max(startLine, endLine) : startLine
|
|
16358
|
+
});
|
|
16359
|
+
} else if (m.status === "NoCoverage") noCoverage++;
|
|
16360
|
+
else if (m.status === "Killed" || m.status === "Timeout") killed++;
|
|
16361
|
+
}
|
|
16362
|
+
}
|
|
16363
|
+
return {
|
|
16364
|
+
tool: "stryker",
|
|
16365
|
+
survived,
|
|
16366
|
+
noCoverage,
|
|
16367
|
+
killed
|
|
16368
|
+
};
|
|
16369
|
+
}
|
|
16370
|
+
function toOneBased(line) {
|
|
16371
|
+
return typeof line === "number" && line >= 0 ? line + 1 : void 0;
|
|
16372
|
+
}
|
|
16373
|
+
//#endregion
|
|
16374
|
+
//#region src/mutation/parse-mutmut.ts
|
|
16375
|
+
const MAX_INPUT = 20971520;
|
|
16376
|
+
/** Sniff: does this XML look like mutmut's junit output? */
|
|
16377
|
+
function looksLikeMutmutXml(xml) {
|
|
16378
|
+
return xml.length <= MAX_INPUT && /mutmut/i.test(xml.slice(0, 4e3));
|
|
16379
|
+
}
|
|
16380
|
+
function decodeEntities(s) {
|
|
16381
|
+
return s.replaceAll("<", "<").replaceAll(">", ">").replaceAll(""", "\"").replaceAll("'", "'").replaceAll("'", "'").replaceAll("&", "&");
|
|
16382
|
+
}
|
|
16383
|
+
/**
|
|
16384
|
+
* The file for a testcase: mutmut 2.x writes
|
|
16385
|
+
* `classname="tests.test_auth-mutmut0::x_mutmut_1"`-style classes whose
|
|
16386
|
+
* FIRST dot-separated segment before `-mutmut` is the module path;
|
|
16387
|
+
* mutmut 3.x writes the path directly in `classname`. Both are
|
|
16388
|
+
* normalized here to a repo-relative path with `.py` intact.
|
|
16389
|
+
*/
|
|
16390
|
+
function fileFromClassname(classname) {
|
|
16391
|
+
const decoded = decodeEntities(classname);
|
|
16392
|
+
const cut = decoded.indexOf("-mutmut");
|
|
16393
|
+
const candidate = (cut !== -1 ? decoded.slice(0, cut) : decoded).trim();
|
|
16394
|
+
if (candidate.length === 0) return null;
|
|
16395
|
+
return candidate.replaceAll(".", "/") + ".py";
|
|
16396
|
+
}
|
|
16397
|
+
function parseMutmutXml(xml) {
|
|
16398
|
+
if (xml.length > MAX_INPUT) return {
|
|
16399
|
+
tool: "mutmut",
|
|
16400
|
+
survived: [],
|
|
16401
|
+
noCoverage: 0,
|
|
16402
|
+
killed: 0
|
|
16403
|
+
};
|
|
16404
|
+
const survived = [];
|
|
16405
|
+
let noCoverage = 0;
|
|
16406
|
+
let killed = 0;
|
|
16407
|
+
const startRe = /<testcase\b/gi;
|
|
16408
|
+
const closeRe = /<\/testcase\s*>/gi;
|
|
16409
|
+
let start;
|
|
16410
|
+
while ((start = startRe.exec(xml)) !== null) {
|
|
16411
|
+
const gt = xml.indexOf(">", start.index);
|
|
16412
|
+
if (gt === -1) break;
|
|
16413
|
+
const attrs = xml.slice(start.index + 9, gt);
|
|
16414
|
+
let inner;
|
|
16415
|
+
if (attrs.endsWith("/")) {
|
|
16416
|
+
inner = "";
|
|
16417
|
+
startRe.lastIndex = gt + 1;
|
|
16418
|
+
} else {
|
|
16419
|
+
closeRe.lastIndex = gt + 1;
|
|
16420
|
+
const close = closeRe.exec(xml);
|
|
16421
|
+
if (close === null) break;
|
|
16422
|
+
inner = xml.slice(gt + 1, close.index);
|
|
16423
|
+
startRe.lastIndex = close.index + close[0].length;
|
|
16424
|
+
}
|
|
16425
|
+
const classname = /(?:^|\s)classname\s*=\s*"([^"]*)"/i.exec(attrs)?.[1] ?? "";
|
|
16426
|
+
if (/<skipped\b/i.test(inner)) {
|
|
16427
|
+
noCoverage++;
|
|
16428
|
+
continue;
|
|
16429
|
+
}
|
|
16430
|
+
const killedMutant = /<(?:failure|error)\b/i.test(inner);
|
|
16431
|
+
const verdict = {
|
|
16432
|
+
file: fileFromClassname(classname),
|
|
16433
|
+
survived: !killedMutant
|
|
16434
|
+
};
|
|
16435
|
+
if (killedMutant) killed++;
|
|
16436
|
+
else if (verdict.file !== null) survived.push({
|
|
16437
|
+
file: verdict.file,
|
|
16438
|
+
mutator: "mutmut",
|
|
16439
|
+
startLine: 1,
|
|
16440
|
+
endLine: Number.MAX_SAFE_INTEGER
|
|
16441
|
+
});
|
|
16442
|
+
else noCoverage++;
|
|
16443
|
+
}
|
|
16444
|
+
return {
|
|
16445
|
+
tool: "mutmut",
|
|
16446
|
+
survived,
|
|
16447
|
+
noCoverage,
|
|
16448
|
+
killed
|
|
16449
|
+
};
|
|
16450
|
+
}
|
|
16451
|
+
//#endregion
|
|
16452
|
+
//#region src/mutation/derive.ts
|
|
16453
|
+
/**
|
|
16454
|
+
* Stamp mutation evidence onto findings (mutates in place, the same
|
|
16455
|
+
* contract as stampEvidenceLevels). Returns the stamp stats.
|
|
16456
|
+
*/
|
|
16457
|
+
function stampMutationEvidence(findings, report) {
|
|
16458
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
16459
|
+
for (const m of report.survived) {
|
|
16460
|
+
const list = byFile.get(m.file) ?? [];
|
|
16461
|
+
list.push(m);
|
|
16462
|
+
byFile.set(m.file, list);
|
|
16463
|
+
}
|
|
16464
|
+
let stamped = 0;
|
|
16465
|
+
let derived = 0;
|
|
16466
|
+
for (const f of findings) {
|
|
16467
|
+
const mutants = byFile.get(f.file);
|
|
16468
|
+
if (!mutants || mutants.length === 0) continue;
|
|
16469
|
+
let matched = mutants.filter((m) => f.line >= m.startLine && f.line <= m.endLine);
|
|
16470
|
+
let granularity = "line";
|
|
16471
|
+
if (matched.length === 0) {
|
|
16472
|
+
matched = mutants;
|
|
16473
|
+
granularity = "file";
|
|
16474
|
+
}
|
|
16475
|
+
f.mutationEvidence = {
|
|
16476
|
+
source: report.tool,
|
|
16477
|
+
matchedMutants: matched.length,
|
|
16478
|
+
mutators: [...new Set(matched.map((m) => m.mutator))].sort(),
|
|
16479
|
+
granularity
|
|
16480
|
+
};
|
|
16481
|
+
stamped++;
|
|
16482
|
+
if (f.evidenceLevel === "E1") {
|
|
16483
|
+
f.evidenceLevel = "E2";
|
|
16484
|
+
derived++;
|
|
16485
|
+
}
|
|
16486
|
+
}
|
|
16487
|
+
return {
|
|
16488
|
+
stamped,
|
|
16489
|
+
derived
|
|
16490
|
+
};
|
|
16491
|
+
}
|
|
16492
|
+
/**
|
|
16493
|
+
* Survived-mutant leaderboard: most-mutated files first. The summary's
|
|
16494
|
+
* honesty headline: files here are code the suite demonstrably does not
|
|
16495
|
+
* constrain — regardless of what any static tool found.
|
|
16496
|
+
*/
|
|
16497
|
+
function survivedLeaderboard(report, top = 25) {
|
|
16498
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
16499
|
+
for (const m of report.survived) {
|
|
16500
|
+
const list = byFile.get(m.file) ?? [];
|
|
16501
|
+
list.push(m);
|
|
16502
|
+
byFile.set(m.file, list);
|
|
16503
|
+
}
|
|
16504
|
+
return [...byFile.entries()].map(([file, mutants]) => ({
|
|
16505
|
+
file,
|
|
16506
|
+
survived: mutants.length,
|
|
16507
|
+
mutators: [...new Set(mutants.map((m) => m.mutator))].sort()
|
|
16508
|
+
})).sort((a, b) => b.survived - a.survived).slice(0, top);
|
|
16509
|
+
}
|
|
16510
|
+
function renderMutationSummary(report) {
|
|
16511
|
+
const lines = [];
|
|
16512
|
+
lines.push(`MUTATION EVIDENCE — ${report.tool}`);
|
|
16513
|
+
lines.push("");
|
|
16514
|
+
lines.push(`${report.survived.length} survived · ${report.killed} killed · ${report.noCoverage} no-coverage`);
|
|
16515
|
+
lines.push("");
|
|
16516
|
+
const top = survivedLeaderboard(report);
|
|
16517
|
+
if (top.length === 0) {
|
|
16518
|
+
lines.push("No survived mutants — every executed mutant was killed.");
|
|
16519
|
+
lines.push("");
|
|
16520
|
+
lines.push("Interpretation: the suite constrains the code the mutation run reached. This is evidence about THOSE mutants only — untested surfaces are the no-coverage column, and no-coverage is not survived.");
|
|
16521
|
+
return lines.join("\n");
|
|
16522
|
+
}
|
|
16523
|
+
lines.push("Files the suite does not constrain (most survived first):");
|
|
16524
|
+
lines.push("");
|
|
16525
|
+
for (const row of top) lines.push(` ${row.survived}× ${row.file} [${row.mutators.join(", ")}]`);
|
|
16526
|
+
lines.push("");
|
|
16527
|
+
lines.push("A survived mutant is not a bug — it is code the suite would not notice changing. Findings near these lines were consolidated E1→E2 by derivation (see docs/RULE-LIFECYCLE.md).");
|
|
16528
|
+
return lines.join("\n");
|
|
16529
|
+
}
|
|
16530
|
+
//#endregion
|
|
16317
16531
|
//#region src/commands/badge.ts
|
|
16318
16532
|
/**
|
|
16319
16533
|
* `mjolnir badge` — evidentiary shields.io endpoint JSON (Tier 1 #5).
|
|
@@ -16422,6 +16636,12 @@ const HELP_ENTRIES = [
|
|
|
16422
16636
|
usage: "mjolnir triage <test-results-dir-or-report-file> [--no-md]",
|
|
16423
16637
|
examples: ["mjolnir triage test-results --no-md"]
|
|
16424
16638
|
},
|
|
16639
|
+
{
|
|
16640
|
+
verb: "mutation",
|
|
16641
|
+
summary: "mutation-evidence reader: survived-mutant leaderboard + E1→E2 derivation",
|
|
16642
|
+
usage: "mjolnir mutation <mutation-report> [--scan .]",
|
|
16643
|
+
examples: ["mjolnir mutation reports/mutation-report.json", "mjolnir mutation reports/… --scan ."]
|
|
16644
|
+
},
|
|
16425
16645
|
{
|
|
16426
16646
|
verb: "pw-report",
|
|
16427
16647
|
summary: "Playwright run summary (counts, true flakes, slowest tests)",
|
|
@@ -16704,6 +16924,10 @@ const GROUPS = [
|
|
|
16704
16924
|
"doctor:playwright"
|
|
16705
16925
|
]
|
|
16706
16926
|
},
|
|
16927
|
+
{
|
|
16928
|
+
title: "Mutation evidence",
|
|
16929
|
+
verbs: ["mutation"]
|
|
16930
|
+
},
|
|
16707
16931
|
{
|
|
16708
16932
|
title: "Maintenance",
|
|
16709
16933
|
verbs: [
|
|
@@ -19022,7 +19246,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
|
|
|
19022
19246
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
19023
19247
|
* `tests/version-consistency.spec.ts` locally.
|
|
19024
19248
|
*/
|
|
19025
|
-
const CLI_VERSION = "0.5.
|
|
19249
|
+
const CLI_VERSION = "0.5.37";
|
|
19026
19250
|
function parseArgs(argv, onError) {
|
|
19027
19251
|
const args = {
|
|
19028
19252
|
target: ".",
|
|
@@ -19508,6 +19732,97 @@ function runTriageCommand(argv, io = {
|
|
|
19508
19732
|
return 20;
|
|
19509
19733
|
}
|
|
19510
19734
|
}
|
|
19735
|
+
/**
|
|
19736
|
+
* Sniff + parse a mutation report: Stryker JSON, mutmut junitxml, else
|
|
19737
|
+
* an empty report (the caller renders the honest "nothing recognized").
|
|
19738
|
+
*/
|
|
19739
|
+
function ingestMutationReport(text) {
|
|
19740
|
+
const trimmed = text.trimStart();
|
|
19741
|
+
if (trimmed.startsWith("<?xml") || trimmed.startsWith("<testsuite")) return parseMutmutXml(text);
|
|
19742
|
+
try {
|
|
19743
|
+
const json = JSON.parse(text);
|
|
19744
|
+
if (looksLikeStrykerJson(json)) return parseStrykerJson(json);
|
|
19745
|
+
} catch {}
|
|
19746
|
+
if (looksLikeMutmutXml(text)) return parseMutmutXml(text);
|
|
19747
|
+
return {
|
|
19748
|
+
tool: "stryker",
|
|
19749
|
+
survived: [],
|
|
19750
|
+
noCoverage: 0,
|
|
19751
|
+
killed: 0
|
|
19752
|
+
};
|
|
19753
|
+
}
|
|
19754
|
+
/** Testable `mutation` handler (master plan P5, plan 1788853205786).
|
|
19755
|
+
* Reads a mutation report (Stryker JSON / mutmut junitxml), renders the
|
|
19756
|
+
* survived-mutant leaderboard, and — with `--scan <path>` — re-scans the
|
|
19757
|
+
* target, stamps matching findings with `mutationEvidence` (E1→E2 by
|
|
19758
|
+
* derivation) and renders the stamped findings. NEVER spawns mutation
|
|
19759
|
+
* tools: it reads reports the user already produced. Exit 2 when
|
|
19760
|
+
* nothing in the report is recognized — honest no-evidence, not an
|
|
19761
|
+
* error. Exit 0 in every other case: the reader is report-only, never a
|
|
19762
|
+
* gate. */
|
|
19763
|
+
async function runMutationCommand(argv, io = {
|
|
19764
|
+
out,
|
|
19765
|
+
err
|
|
19766
|
+
}) {
|
|
19767
|
+
const targetArg = argv.find((a) => !a.startsWith("-"));
|
|
19768
|
+
if (!targetArg) {
|
|
19769
|
+
io.err("Usage: mjolnir mutation <mutation-report> [--scan <path>]");
|
|
19770
|
+
return 10;
|
|
19771
|
+
}
|
|
19772
|
+
const scanIdx = argv.indexOf("--scan");
|
|
19773
|
+
const scanArg = scanIdx !== -1 ? argv[scanIdx + 1] : void 0;
|
|
19774
|
+
if (scanIdx !== -1 && (!scanArg || scanArg.startsWith("-"))) {
|
|
19775
|
+
io.err("--scan requires a path argument");
|
|
19776
|
+
return 10;
|
|
19777
|
+
}
|
|
19778
|
+
let report;
|
|
19779
|
+
try {
|
|
19780
|
+
const path = resolve(targetArg);
|
|
19781
|
+
if (!existsSync(path)) {
|
|
19782
|
+
io.err(`No such file: ${path}`);
|
|
19783
|
+
return 2;
|
|
19784
|
+
}
|
|
19785
|
+
report = ingestMutationReport(readFileSync(path, "utf8"));
|
|
19786
|
+
if (report.survived.length === 0 && report.killed === 0 && report.noCoverage === 0) {
|
|
19787
|
+
io.err("No mutants recognized. Expected a Stryker JSON report (mutation-report.json) or a mutmut junitxml report.");
|
|
19788
|
+
return 2;
|
|
19789
|
+
}
|
|
19790
|
+
} catch (err) {
|
|
19791
|
+
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
19792
|
+
return 20;
|
|
19793
|
+
}
|
|
19794
|
+
io.out(renderMutationSummary(report));
|
|
19795
|
+
if (scanArg === void 0) return 0;
|
|
19796
|
+
try {
|
|
19797
|
+
const scanPath = resolve(scanArg);
|
|
19798
|
+
const invalid = validateScanTarget(scanPath, io.err);
|
|
19799
|
+
if (invalid !== null) return invalid;
|
|
19800
|
+
const result = await runScan({
|
|
19801
|
+
target: scanPath,
|
|
19802
|
+
json: true,
|
|
19803
|
+
verbose: true,
|
|
19804
|
+
maxDurationMs: 12e4,
|
|
19805
|
+
scopeChanged: false,
|
|
19806
|
+
format: "json"
|
|
19807
|
+
});
|
|
19808
|
+
const stats = stampMutationEvidence(result.findings, report);
|
|
19809
|
+
io.out("");
|
|
19810
|
+
if (stats.stamped === 0) io.out("No findings intersect the survived-mutant surface — nothing to derive.");
|
|
19811
|
+
else {
|
|
19812
|
+
io.out(`${stats.stamped} finding(s) carry mutationEvidence (${stats.derived} consolidated E1→E2 by derivation — docs/RULE-LIFECYCLE.md):`);
|
|
19813
|
+
for (const f of result.findings) {
|
|
19814
|
+
if (!f.mutationEvidence) continue;
|
|
19815
|
+
io.out(` ${f.ruleId} ${f.file}:${f.line} — ${f.evidenceLevel} · ${f.mutationEvidence.matchedMutants} mutant(s) @ ${f.mutationEvidence.granularity} granularity`);
|
|
19816
|
+
}
|
|
19817
|
+
io.out("");
|
|
19818
|
+
io.out("Machine form: re-run with --json — mutationEvidence rides the findings additively.");
|
|
19819
|
+
}
|
|
19820
|
+
return 0;
|
|
19821
|
+
} catch (err) {
|
|
19822
|
+
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
19823
|
+
return 20;
|
|
19824
|
+
}
|
|
19825
|
+
}
|
|
19511
19826
|
/** Testable `badge` handler (Tier 1 #5). */
|
|
19512
19827
|
async function runBadgeCommand(argv, io = {
|
|
19513
19828
|
out,
|
|
@@ -19886,6 +20201,7 @@ async function main(argv = process$1.argv.slice(2), io = {
|
|
|
19886
20201
|
if (argv[0] === "suppressions") return runSuppressions();
|
|
19887
20202
|
if (argv[0] === "forensics") return runForensicsCommand(argv.slice(1));
|
|
19888
20203
|
if (argv[0] === "triage") return runTriageCommand(argv.slice(1));
|
|
20204
|
+
if (argv[0] === "mutation") return runMutationCommand(argv.slice(1), io);
|
|
19889
20205
|
if (argv[0] === "badge") return runBadgeCommand(argv.slice(1));
|
|
19890
20206
|
if (argv[0] === "debt") return runDebtCommand(argv.slice(1));
|
|
19891
20207
|
if (argv[0] === "impact") return runImpactCommand(argv.slice(1));
|
|
@@ -19962,4 +20278,4 @@ function isEntryPoint() {
|
|
|
19962
20278
|
}
|
|
19963
20279
|
if (isEntryPoint()) process$1.exitCode = await main();
|
|
19964
20280
|
//#endregion
|
|
19965
|
-
export { CLI_VERSION, EVIDENCE_OVERRIDES, KNOWN_RULE_IDS, OVERLAP_META_BY_RULE_ID, SUITE_INVALIDATING_RULE_IDS, buildUniversalRules, discoverRuntimeReport, err, exitForFindings, fallbackWorkspace, internalErrorMessage, isEntryPoint, isValidFindingRecord, levenshtein, main, nearestFlags, out, parseArgs, pathMatchesGlob, runBadgeCommand, runBaselineCommand, runCiInstall, runCreateRuleCommand, runDebtCommand, runDiffCommand, runDoctorCommand, runDoctorPlaywright, runExplainCommand, runFixCommand, runForensicsCommand, runHandoverCommand, runHelpCommand, runImpactCommand, runInitCommand, runPrCommentCommand, runPwReportCommand, runRulesCommand, runScan, runScanCommand, runStatsCommand, runSuppressions, runTriageCommand, usageErrorMessage };
|
|
20281
|
+
export { CLI_VERSION, EVIDENCE_OVERRIDES, KNOWN_RULE_IDS, OVERLAP_META_BY_RULE_ID, SUITE_INVALIDATING_RULE_IDS, buildUniversalRules, discoverRuntimeReport, err, exitForFindings, fallbackWorkspace, internalErrorMessage, isEntryPoint, isValidFindingRecord, levenshtein, main, nearestFlags, out, parseArgs, pathMatchesGlob, runBadgeCommand, runBaselineCommand, runCiInstall, runCreateRuleCommand, runDebtCommand, runDiffCommand, runDoctorCommand, runDoctorPlaywright, runExplainCommand, runFixCommand, runForensicsCommand, runHandoverCommand, runHelpCommand, runImpactCommand, runInitCommand, runMutationCommand, runPrCommentCommand, runPwReportCommand, runRulesCommand, runScan, runScanCommand, runStatsCommand, runSuppressions, runTriageCommand, usageErrorMessage };
|
package/dist/mcp/stdio.mjs
CHANGED
|
@@ -11177,19 +11177,19 @@ function parseJestJson(json) {
|
|
|
11177
11177
|
}
|
|
11178
11178
|
//#endregion
|
|
11179
11179
|
//#region src/forensics/parse-junit.ts
|
|
11180
|
-
const MAX_INPUT = 20971520;
|
|
11181
|
-
function decodeEntities(s) {
|
|
11180
|
+
const MAX_INPUT$1 = 20971520;
|
|
11181
|
+
function decodeEntities$1(s) {
|
|
11182
11182
|
return s.replaceAll("<", "<").replaceAll(">", ">").replaceAll(""", "\"").replaceAll("'", "'").replaceAll("'", "'").replaceAll("&", "&");
|
|
11183
11183
|
}
|
|
11184
11184
|
function attr(tag, name) {
|
|
11185
11185
|
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
11186
11186
|
const m = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*"([^"]*)"`, "i").exec(tag);
|
|
11187
|
-
if (m?.[1] !== void 0) return decodeEntities(m[1]);
|
|
11187
|
+
if (m?.[1] !== void 0) return decodeEntities$1(m[1]);
|
|
11188
11188
|
const m2 = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*'([^']*)'`, "i").exec(tag);
|
|
11189
|
-
return m2?.[1] !== void 0 ? decodeEntities(m2[1]) : void 0;
|
|
11189
|
+
return m2?.[1] !== void 0 ? decodeEntities$1(m2[1]) : void 0;
|
|
11190
11190
|
}
|
|
11191
11191
|
function parseJunitXml(xml) {
|
|
11192
|
-
if (xml.length > MAX_INPUT) return [];
|
|
11192
|
+
if (xml.length > MAX_INPUT$1) return [];
|
|
11193
11193
|
const out = [];
|
|
11194
11194
|
const startRe = /<testcase\b/gi;
|
|
11195
11195
|
const closeRe = /<\/testcase\s*>/gi;
|
|
@@ -11344,7 +11344,7 @@ function parseVitestJson(json) {
|
|
|
11344
11344
|
*
|
|
11345
11345
|
* Writes FLAKY.md next to the scan target unless --no-flaky-md.
|
|
11346
11346
|
*/
|
|
11347
|
-
const MAX_FILES = 500;
|
|
11347
|
+
const MAX_FILES$1 = 500;
|
|
11348
11348
|
function runForensics(target, options = {}) {
|
|
11349
11349
|
const records = [];
|
|
11350
11350
|
let source = "playwright-json";
|
|
@@ -11368,7 +11368,7 @@ function runForensics(target, options = {}) {
|
|
|
11368
11368
|
else {
|
|
11369
11369
|
let count = 0;
|
|
11370
11370
|
for (const full of listFiles(target)) {
|
|
11371
|
-
if (++count > MAX_FILES) break;
|
|
11371
|
+
if (++count > MAX_FILES$1) break;
|
|
11372
11372
|
try {
|
|
11373
11373
|
const parsed = parseFile(full, readFileSync(full, "utf8"));
|
|
11374
11374
|
if (parsed.records.length === 0) continue;
|
|
@@ -11436,7 +11436,7 @@ function parseFile(path, text) {
|
|
|
11436
11436
|
function listFiles(dir) {
|
|
11437
11437
|
const out = [];
|
|
11438
11438
|
const walk = (d, depth) => {
|
|
11439
|
-
if (depth > 4 || out.length > MAX_FILES) return;
|
|
11439
|
+
if (depth > 4 || out.length > MAX_FILES$1) return;
|
|
11440
11440
|
let entries;
|
|
11441
11441
|
try {
|
|
11442
11442
|
entries = readdirSync(d, { withFileTypes: true });
|
|
@@ -13463,7 +13463,7 @@ function renderSarif(result, repoRootUri) {
|
|
|
13463
13463
|
tool: { driver: {
|
|
13464
13464
|
name: "Mjölnir",
|
|
13465
13465
|
informationUri: "https://github.com/Sergey-Bar/Mjolnir",
|
|
13466
|
-
version: "0.5.
|
|
13466
|
+
version: "0.5.37",
|
|
13467
13467
|
rules: [...rules.values()].map((r) => {
|
|
13468
13468
|
const meta = RULES.find((x) => x.id === r.id);
|
|
13469
13469
|
return {
|
|
@@ -15411,6 +15411,220 @@ function renderTriageMd(report) {
|
|
|
15411
15411
|
return lines.join("\n");
|
|
15412
15412
|
}
|
|
15413
15413
|
//#endregion
|
|
15414
|
+
//#region src/mutation/parse-stryker.ts
|
|
15415
|
+
const MAX_FILES = 5e3;
|
|
15416
|
+
const MAX_MUTANTS_PER_FILE = 2e4;
|
|
15417
|
+
/** Sniff: does this parsed JSON look like a Stryker mutation report? */
|
|
15418
|
+
function looksLikeStrykerJson(json) {
|
|
15419
|
+
if (!json || typeof json !== "object") return false;
|
|
15420
|
+
return typeof json.files === "object" && json.files !== null;
|
|
15421
|
+
}
|
|
15422
|
+
function parseStrykerJson(json) {
|
|
15423
|
+
if (!json || typeof json !== "object") return {
|
|
15424
|
+
tool: "stryker",
|
|
15425
|
+
survived: [],
|
|
15426
|
+
noCoverage: 0,
|
|
15427
|
+
killed: 0
|
|
15428
|
+
};
|
|
15429
|
+
const files = json.files;
|
|
15430
|
+
if (!files || typeof files !== "object") return {
|
|
15431
|
+
tool: "stryker",
|
|
15432
|
+
survived: [],
|
|
15433
|
+
noCoverage: 0,
|
|
15434
|
+
killed: 0
|
|
15435
|
+
};
|
|
15436
|
+
const survived = [];
|
|
15437
|
+
let noCoverage = 0;
|
|
15438
|
+
let killed = 0;
|
|
15439
|
+
const entries = Object.entries(files).slice(0, MAX_FILES);
|
|
15440
|
+
for (const [rawPath, file] of entries) {
|
|
15441
|
+
if (!file || typeof file !== "object") continue;
|
|
15442
|
+
const mutants = Array.isArray(file.mutants) ? file.mutants : [];
|
|
15443
|
+
const path = rawPath.replaceAll("\\", "/");
|
|
15444
|
+
for (const m of mutants.slice(0, MAX_MUTANTS_PER_FILE)) {
|
|
15445
|
+
if (!m || typeof m !== "object") continue;
|
|
15446
|
+
if (m.status === "Survived") {
|
|
15447
|
+
const startLine = toOneBased(m.location?.start?.line);
|
|
15448
|
+
const endLine = toOneBased(m.location?.end?.line);
|
|
15449
|
+
if (startLine === void 0) continue;
|
|
15450
|
+
survived.push({
|
|
15451
|
+
file: path,
|
|
15452
|
+
mutator: typeof m.mutatorName === "string" ? m.mutatorName : "unknown",
|
|
15453
|
+
startLine,
|
|
15454
|
+
endLine: endLine !== void 0 ? Math.max(startLine, endLine) : startLine
|
|
15455
|
+
});
|
|
15456
|
+
} else if (m.status === "NoCoverage") noCoverage++;
|
|
15457
|
+
else if (m.status === "Killed" || m.status === "Timeout") killed++;
|
|
15458
|
+
}
|
|
15459
|
+
}
|
|
15460
|
+
return {
|
|
15461
|
+
tool: "stryker",
|
|
15462
|
+
survived,
|
|
15463
|
+
noCoverage,
|
|
15464
|
+
killed
|
|
15465
|
+
};
|
|
15466
|
+
}
|
|
15467
|
+
function toOneBased(line) {
|
|
15468
|
+
return typeof line === "number" && line >= 0 ? line + 1 : void 0;
|
|
15469
|
+
}
|
|
15470
|
+
//#endregion
|
|
15471
|
+
//#region src/mutation/parse-mutmut.ts
|
|
15472
|
+
const MAX_INPUT = 20971520;
|
|
15473
|
+
/** Sniff: does this XML look like mutmut's junit output? */
|
|
15474
|
+
function looksLikeMutmutXml(xml) {
|
|
15475
|
+
return xml.length <= MAX_INPUT && /mutmut/i.test(xml.slice(0, 4e3));
|
|
15476
|
+
}
|
|
15477
|
+
function decodeEntities(s) {
|
|
15478
|
+
return s.replaceAll("<", "<").replaceAll(">", ">").replaceAll(""", "\"").replaceAll("'", "'").replaceAll("'", "'").replaceAll("&", "&");
|
|
15479
|
+
}
|
|
15480
|
+
/**
|
|
15481
|
+
* The file for a testcase: mutmut 2.x writes
|
|
15482
|
+
* `classname="tests.test_auth-mutmut0::x_mutmut_1"`-style classes whose
|
|
15483
|
+
* FIRST dot-separated segment before `-mutmut` is the module path;
|
|
15484
|
+
* mutmut 3.x writes the path directly in `classname`. Both are
|
|
15485
|
+
* normalized here to a repo-relative path with `.py` intact.
|
|
15486
|
+
*/
|
|
15487
|
+
function fileFromClassname(classname) {
|
|
15488
|
+
const decoded = decodeEntities(classname);
|
|
15489
|
+
const cut = decoded.indexOf("-mutmut");
|
|
15490
|
+
const candidate = (cut !== -1 ? decoded.slice(0, cut) : decoded).trim();
|
|
15491
|
+
if (candidate.length === 0) return null;
|
|
15492
|
+
return candidate.replaceAll(".", "/") + ".py";
|
|
15493
|
+
}
|
|
15494
|
+
function parseMutmutXml(xml) {
|
|
15495
|
+
if (xml.length > MAX_INPUT) return {
|
|
15496
|
+
tool: "mutmut",
|
|
15497
|
+
survived: [],
|
|
15498
|
+
noCoverage: 0,
|
|
15499
|
+
killed: 0
|
|
15500
|
+
};
|
|
15501
|
+
const survived = [];
|
|
15502
|
+
let noCoverage = 0;
|
|
15503
|
+
let killed = 0;
|
|
15504
|
+
const startRe = /<testcase\b/gi;
|
|
15505
|
+
const closeRe = /<\/testcase\s*>/gi;
|
|
15506
|
+
let start;
|
|
15507
|
+
while ((start = startRe.exec(xml)) !== null) {
|
|
15508
|
+
const gt = xml.indexOf(">", start.index);
|
|
15509
|
+
if (gt === -1) break;
|
|
15510
|
+
const attrs = xml.slice(start.index + 9, gt);
|
|
15511
|
+
let inner;
|
|
15512
|
+
if (attrs.endsWith("/")) {
|
|
15513
|
+
inner = "";
|
|
15514
|
+
startRe.lastIndex = gt + 1;
|
|
15515
|
+
} else {
|
|
15516
|
+
closeRe.lastIndex = gt + 1;
|
|
15517
|
+
const close = closeRe.exec(xml);
|
|
15518
|
+
if (close === null) break;
|
|
15519
|
+
inner = xml.slice(gt + 1, close.index);
|
|
15520
|
+
startRe.lastIndex = close.index + close[0].length;
|
|
15521
|
+
}
|
|
15522
|
+
const classname = /(?:^|\s)classname\s*=\s*"([^"]*)"/i.exec(attrs)?.[1] ?? "";
|
|
15523
|
+
if (/<skipped\b/i.test(inner)) {
|
|
15524
|
+
noCoverage++;
|
|
15525
|
+
continue;
|
|
15526
|
+
}
|
|
15527
|
+
const killedMutant = /<(?:failure|error)\b/i.test(inner);
|
|
15528
|
+
const verdict = {
|
|
15529
|
+
file: fileFromClassname(classname),
|
|
15530
|
+
survived: !killedMutant
|
|
15531
|
+
};
|
|
15532
|
+
if (killedMutant) killed++;
|
|
15533
|
+
else if (verdict.file !== null) survived.push({
|
|
15534
|
+
file: verdict.file,
|
|
15535
|
+
mutator: "mutmut",
|
|
15536
|
+
startLine: 1,
|
|
15537
|
+
endLine: Number.MAX_SAFE_INTEGER
|
|
15538
|
+
});
|
|
15539
|
+
else noCoverage++;
|
|
15540
|
+
}
|
|
15541
|
+
return {
|
|
15542
|
+
tool: "mutmut",
|
|
15543
|
+
survived,
|
|
15544
|
+
noCoverage,
|
|
15545
|
+
killed
|
|
15546
|
+
};
|
|
15547
|
+
}
|
|
15548
|
+
//#endregion
|
|
15549
|
+
//#region src/mutation/derive.ts
|
|
15550
|
+
/**
|
|
15551
|
+
* Stamp mutation evidence onto findings (mutates in place, the same
|
|
15552
|
+
* contract as stampEvidenceLevels). Returns the stamp stats.
|
|
15553
|
+
*/
|
|
15554
|
+
function stampMutationEvidence(findings, report) {
|
|
15555
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
15556
|
+
for (const m of report.survived) {
|
|
15557
|
+
const list = byFile.get(m.file) ?? [];
|
|
15558
|
+
list.push(m);
|
|
15559
|
+
byFile.set(m.file, list);
|
|
15560
|
+
}
|
|
15561
|
+
let stamped = 0;
|
|
15562
|
+
let derived = 0;
|
|
15563
|
+
for (const f of findings) {
|
|
15564
|
+
const mutants = byFile.get(f.file);
|
|
15565
|
+
if (!mutants || mutants.length === 0) continue;
|
|
15566
|
+
let matched = mutants.filter((m) => f.line >= m.startLine && f.line <= m.endLine);
|
|
15567
|
+
let granularity = "line";
|
|
15568
|
+
if (matched.length === 0) {
|
|
15569
|
+
matched = mutants;
|
|
15570
|
+
granularity = "file";
|
|
15571
|
+
}
|
|
15572
|
+
f.mutationEvidence = {
|
|
15573
|
+
source: report.tool,
|
|
15574
|
+
matchedMutants: matched.length,
|
|
15575
|
+
mutators: [...new Set(matched.map((m) => m.mutator))].sort(),
|
|
15576
|
+
granularity
|
|
15577
|
+
};
|
|
15578
|
+
stamped++;
|
|
15579
|
+
if (f.evidenceLevel === "E1") {
|
|
15580
|
+
f.evidenceLevel = "E2";
|
|
15581
|
+
derived++;
|
|
15582
|
+
}
|
|
15583
|
+
}
|
|
15584
|
+
return {
|
|
15585
|
+
stamped,
|
|
15586
|
+
derived
|
|
15587
|
+
};
|
|
15588
|
+
}
|
|
15589
|
+
/**
|
|
15590
|
+
* Survived-mutant leaderboard: most-mutated files first. The summary's
|
|
15591
|
+
* honesty headline: files here are code the suite demonstrably does not
|
|
15592
|
+
* constrain — regardless of what any static tool found.
|
|
15593
|
+
*/
|
|
15594
|
+
function survivedLeaderboard(report, top = 25) {
|
|
15595
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
15596
|
+
for (const m of report.survived) {
|
|
15597
|
+
const list = byFile.get(m.file) ?? [];
|
|
15598
|
+
list.push(m);
|
|
15599
|
+
byFile.set(m.file, list);
|
|
15600
|
+
}
|
|
15601
|
+
return [...byFile.entries()].map(([file, mutants]) => ({
|
|
15602
|
+
file,
|
|
15603
|
+
survived: mutants.length,
|
|
15604
|
+
mutators: [...new Set(mutants.map((m) => m.mutator))].sort()
|
|
15605
|
+
})).sort((a, b) => b.survived - a.survived).slice(0, top);
|
|
15606
|
+
}
|
|
15607
|
+
function renderMutationSummary(report) {
|
|
15608
|
+
const lines = [];
|
|
15609
|
+
lines.push(`MUTATION EVIDENCE — ${report.tool}`);
|
|
15610
|
+
lines.push("");
|
|
15611
|
+
lines.push(`${report.survived.length} survived · ${report.killed} killed · ${report.noCoverage} no-coverage`);
|
|
15612
|
+
lines.push("");
|
|
15613
|
+
const top = survivedLeaderboard(report);
|
|
15614
|
+
if (top.length === 0) {
|
|
15615
|
+
lines.push("No survived mutants — every executed mutant was killed.");
|
|
15616
|
+
lines.push("");
|
|
15617
|
+
lines.push("Interpretation: the suite constrains the code the mutation run reached. This is evidence about THOSE mutants only — untested surfaces are the no-coverage column, and no-coverage is not survived.");
|
|
15618
|
+
return lines.join("\n");
|
|
15619
|
+
}
|
|
15620
|
+
lines.push("Files the suite does not constrain (most survived first):");
|
|
15621
|
+
lines.push("");
|
|
15622
|
+
for (const row of top) lines.push(` ${row.survived}× ${row.file} [${row.mutators.join(", ")}]`);
|
|
15623
|
+
lines.push("");
|
|
15624
|
+
lines.push("A survived mutant is not a bug — it is code the suite would not notice changing. Findings near these lines were consolidated E1→E2 by derivation (see docs/RULE-LIFECYCLE.md).");
|
|
15625
|
+
return lines.join("\n");
|
|
15626
|
+
}
|
|
15627
|
+
//#endregion
|
|
15414
15628
|
//#region src/lib/fs-atomic.ts
|
|
15415
15629
|
/**
|
|
15416
15630
|
* Atomic file writes (audit S9).
|
|
@@ -15613,6 +15827,12 @@ const HELP_ENTRIES = [
|
|
|
15613
15827
|
usage: "mjolnir triage <test-results-dir-or-report-file> [--no-md]",
|
|
15614
15828
|
examples: ["mjolnir triage test-results --no-md"]
|
|
15615
15829
|
},
|
|
15830
|
+
{
|
|
15831
|
+
verb: "mutation",
|
|
15832
|
+
summary: "mutation-evidence reader: survived-mutant leaderboard + E1→E2 derivation",
|
|
15833
|
+
usage: "mjolnir mutation <mutation-report> [--scan .]",
|
|
15834
|
+
examples: ["mjolnir mutation reports/mutation-report.json", "mjolnir mutation reports/… --scan ."]
|
|
15835
|
+
},
|
|
15616
15836
|
{
|
|
15617
15837
|
verb: "pw-report",
|
|
15618
15838
|
summary: "Playwright run summary (counts, true flakes, slowest tests)",
|
|
@@ -15895,6 +16115,10 @@ const GROUPS = [
|
|
|
15895
16115
|
"doctor:playwright"
|
|
15896
16116
|
]
|
|
15897
16117
|
},
|
|
16118
|
+
{
|
|
16119
|
+
title: "Mutation evidence",
|
|
16120
|
+
verbs: ["mutation"]
|
|
16121
|
+
},
|
|
15898
16122
|
{
|
|
15899
16123
|
title: "Maintenance",
|
|
15900
16124
|
verbs: [
|
|
@@ -18658,7 +18882,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
|
|
|
18658
18882
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
18659
18883
|
* `tests/version-consistency.spec.ts` locally.
|
|
18660
18884
|
*/
|
|
18661
|
-
const CLI_VERSION = "0.5.
|
|
18885
|
+
const CLI_VERSION = "0.5.37";
|
|
18662
18886
|
function parseArgs(argv, onError) {
|
|
18663
18887
|
const args = {
|
|
18664
18888
|
target: ".",
|
|
@@ -19144,6 +19368,97 @@ function runTriageCommand(argv, io = {
|
|
|
19144
19368
|
return 20;
|
|
19145
19369
|
}
|
|
19146
19370
|
}
|
|
19371
|
+
/**
|
|
19372
|
+
* Sniff + parse a mutation report: Stryker JSON, mutmut junitxml, else
|
|
19373
|
+
* an empty report (the caller renders the honest "nothing recognized").
|
|
19374
|
+
*/
|
|
19375
|
+
function ingestMutationReport(text) {
|
|
19376
|
+
const trimmed = text.trimStart();
|
|
19377
|
+
if (trimmed.startsWith("<?xml") || trimmed.startsWith("<testsuite")) return parseMutmutXml(text);
|
|
19378
|
+
try {
|
|
19379
|
+
const json = JSON.parse(text);
|
|
19380
|
+
if (looksLikeStrykerJson(json)) return parseStrykerJson(json);
|
|
19381
|
+
} catch {}
|
|
19382
|
+
if (looksLikeMutmutXml(text)) return parseMutmutXml(text);
|
|
19383
|
+
return {
|
|
19384
|
+
tool: "stryker",
|
|
19385
|
+
survived: [],
|
|
19386
|
+
noCoverage: 0,
|
|
19387
|
+
killed: 0
|
|
19388
|
+
};
|
|
19389
|
+
}
|
|
19390
|
+
/** Testable `mutation` handler (master plan P5, plan 1788853205786).
|
|
19391
|
+
* Reads a mutation report (Stryker JSON / mutmut junitxml), renders the
|
|
19392
|
+
* survived-mutant leaderboard, and — with `--scan <path>` — re-scans the
|
|
19393
|
+
* target, stamps matching findings with `mutationEvidence` (E1→E2 by
|
|
19394
|
+
* derivation) and renders the stamped findings. NEVER spawns mutation
|
|
19395
|
+
* tools: it reads reports the user already produced. Exit 2 when
|
|
19396
|
+
* nothing in the report is recognized — honest no-evidence, not an
|
|
19397
|
+
* error. Exit 0 in every other case: the reader is report-only, never a
|
|
19398
|
+
* gate. */
|
|
19399
|
+
async function runMutationCommand(argv, io = {
|
|
19400
|
+
out,
|
|
19401
|
+
err
|
|
19402
|
+
}) {
|
|
19403
|
+
const targetArg = argv.find((a) => !a.startsWith("-"));
|
|
19404
|
+
if (!targetArg) {
|
|
19405
|
+
io.err("Usage: mjolnir mutation <mutation-report> [--scan <path>]");
|
|
19406
|
+
return 10;
|
|
19407
|
+
}
|
|
19408
|
+
const scanIdx = argv.indexOf("--scan");
|
|
19409
|
+
const scanArg = scanIdx !== -1 ? argv[scanIdx + 1] : void 0;
|
|
19410
|
+
if (scanIdx !== -1 && (!scanArg || scanArg.startsWith("-"))) {
|
|
19411
|
+
io.err("--scan requires a path argument");
|
|
19412
|
+
return 10;
|
|
19413
|
+
}
|
|
19414
|
+
let report;
|
|
19415
|
+
try {
|
|
19416
|
+
const path = resolve(targetArg);
|
|
19417
|
+
if (!existsSync(path)) {
|
|
19418
|
+
io.err(`No such file: ${path}`);
|
|
19419
|
+
return 2;
|
|
19420
|
+
}
|
|
19421
|
+
report = ingestMutationReport(readFileSync(path, "utf8"));
|
|
19422
|
+
if (report.survived.length === 0 && report.killed === 0 && report.noCoverage === 0) {
|
|
19423
|
+
io.err("No mutants recognized. Expected a Stryker JSON report (mutation-report.json) or a mutmut junitxml report.");
|
|
19424
|
+
return 2;
|
|
19425
|
+
}
|
|
19426
|
+
} catch (err) {
|
|
19427
|
+
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
19428
|
+
return 20;
|
|
19429
|
+
}
|
|
19430
|
+
io.out(renderMutationSummary(report));
|
|
19431
|
+
if (scanArg === void 0) return 0;
|
|
19432
|
+
try {
|
|
19433
|
+
const scanPath = resolve(scanArg);
|
|
19434
|
+
const invalid = validateScanTarget(scanPath, io.err);
|
|
19435
|
+
if (invalid !== null) return invalid;
|
|
19436
|
+
const result = await runScan({
|
|
19437
|
+
target: scanPath,
|
|
19438
|
+
json: true,
|
|
19439
|
+
verbose: true,
|
|
19440
|
+
maxDurationMs: 12e4,
|
|
19441
|
+
scopeChanged: false,
|
|
19442
|
+
format: "json"
|
|
19443
|
+
});
|
|
19444
|
+
const stats = stampMutationEvidence(result.findings, report);
|
|
19445
|
+
io.out("");
|
|
19446
|
+
if (stats.stamped === 0) io.out("No findings intersect the survived-mutant surface — nothing to derive.");
|
|
19447
|
+
else {
|
|
19448
|
+
io.out(`${stats.stamped} finding(s) carry mutationEvidence (${stats.derived} consolidated E1→E2 by derivation — docs/RULE-LIFECYCLE.md):`);
|
|
19449
|
+
for (const f of result.findings) {
|
|
19450
|
+
if (!f.mutationEvidence) continue;
|
|
19451
|
+
io.out(` ${f.ruleId} ${f.file}:${f.line} — ${f.evidenceLevel} · ${f.mutationEvidence.matchedMutants} mutant(s) @ ${f.mutationEvidence.granularity} granularity`);
|
|
19452
|
+
}
|
|
19453
|
+
io.out("");
|
|
19454
|
+
io.out("Machine form: re-run with --json — mutationEvidence rides the findings additively.");
|
|
19455
|
+
}
|
|
19456
|
+
return 0;
|
|
19457
|
+
} catch (err) {
|
|
19458
|
+
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
19459
|
+
return 20;
|
|
19460
|
+
}
|
|
19461
|
+
}
|
|
19147
19462
|
/** Testable `badge` handler (Tier 1 #5). */
|
|
19148
19463
|
async function runBadgeCommand(argv, io = {
|
|
19149
19464
|
out,
|
|
@@ -19522,6 +19837,7 @@ async function main(argv = process$1.argv.slice(2), io = {
|
|
|
19522
19837
|
if (argv[0] === "suppressions") return runSuppressions();
|
|
19523
19838
|
if (argv[0] === "forensics") return runForensicsCommand(argv.slice(1));
|
|
19524
19839
|
if (argv[0] === "triage") return runTriageCommand(argv.slice(1));
|
|
19840
|
+
if (argv[0] === "mutation") return runMutationCommand(argv.slice(1), io);
|
|
19525
19841
|
if (argv[0] === "badge") return runBadgeCommand(argv.slice(1));
|
|
19526
19842
|
if (argv[0] === "debt") return runDebtCommand(argv.slice(1));
|
|
19527
19843
|
if (argv[0] === "impact") return runImpactCommand(argv.slice(1));
|
package/package.json
CHANGED