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 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.36";
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("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", "\"").replaceAll("&apos;", "'").replaceAll("&#39;", "'").replaceAll("&amp;", "&");
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.36",
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("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", "\"").replaceAll("&apos;", "'").replaceAll("&#39;", "'").replaceAll("&amp;", "&");
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.36";
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 };
@@ -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("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", "\"").replaceAll("&apos;", "'").replaceAll("&#39;", "'").replaceAll("&amp;", "&");
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.36",
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("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", "\"").replaceAll("&apos;", "'").replaceAll("&#39;", "'").replaceAll("&amp;", "&");
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.36";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mjolnir-qa",
3
- "version": "0.5.36",
3
+ "version": "0.5.37",
4
4
  "description": "Mjölnir — the Verification Trust Engine for QA. Audits test suites and CI pipelines, reports a worthiness score and prioritized findings.",
5
5
  "type": "module",
6
6
  "engines": {