mjolnir-qa 0.5.36 → 0.5.38

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.
@@ -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.38",
13467
13467
  rules: [...rules.values()].map((r) => {
13468
13468
  const meta = RULES.find((x) => x.id === r.id);
13469
13469
  return {
@@ -14696,6 +14696,7 @@ function agentBrief(surface, cwdNote) {
14696
14696
  "- Exit 0 = clean. Exit 1 = new findings on changed lines: fix them (smallest behavior-preserving change) or justify them to the user.",
14697
14697
  `- npx mjolnir-qa@${CLI_VERSION} why <file>:<line> explains any finding (evidence level, measured FP rate, fix).`,
14698
14698
  `- npx mjolnir-qa@${CLI_VERSION} handoff renders a full remediation plan from a saved --json report.`,
14699
+ `- Agent loop: establish the before-state once with npx mjolnir-qa@${CLI_VERSION} baseline (commits .mjolnir/baseline.json); after fixing, npx mjolnir-qa@${CLI_VERSION} verify prints the before/after digest — resolved (per §15 lifecycle) / new / unchanged by ruleId+location, and the score delta. Exit 0 clean, 1 new errors, 2 partial or no baseline.`,
14699
14700
  "- NEVER suppress a finding merely to obtain a green scan (suppressions live in mjolnir.config.json, require a reason, and expire after 90 days).",
14700
14701
  "- After fixing: re-run the scan, report files changed, report checks not run, report unresolved findings honestly.",
14701
14702
  "",
@@ -15411,6 +15412,220 @@ function renderTriageMd(report) {
15411
15412
  return lines.join("\n");
15412
15413
  }
15413
15414
  //#endregion
15415
+ //#region src/mutation/parse-stryker.ts
15416
+ const MAX_FILES = 5e3;
15417
+ const MAX_MUTANTS_PER_FILE = 2e4;
15418
+ /** Sniff: does this parsed JSON look like a Stryker mutation report? */
15419
+ function looksLikeStrykerJson(json) {
15420
+ if (!json || typeof json !== "object") return false;
15421
+ return typeof json.files === "object" && json.files !== null;
15422
+ }
15423
+ function parseStrykerJson(json) {
15424
+ if (!json || typeof json !== "object") return {
15425
+ tool: "stryker",
15426
+ survived: [],
15427
+ noCoverage: 0,
15428
+ killed: 0
15429
+ };
15430
+ const files = json.files;
15431
+ if (!files || typeof files !== "object") return {
15432
+ tool: "stryker",
15433
+ survived: [],
15434
+ noCoverage: 0,
15435
+ killed: 0
15436
+ };
15437
+ const survived = [];
15438
+ let noCoverage = 0;
15439
+ let killed = 0;
15440
+ const entries = Object.entries(files).slice(0, MAX_FILES);
15441
+ for (const [rawPath, file] of entries) {
15442
+ if (!file || typeof file !== "object") continue;
15443
+ const mutants = Array.isArray(file.mutants) ? file.mutants : [];
15444
+ const path = rawPath.replaceAll("\\", "/");
15445
+ for (const m of mutants.slice(0, MAX_MUTANTS_PER_FILE)) {
15446
+ if (!m || typeof m !== "object") continue;
15447
+ if (m.status === "Survived") {
15448
+ const startLine = toOneBased(m.location?.start?.line);
15449
+ const endLine = toOneBased(m.location?.end?.line);
15450
+ if (startLine === void 0) continue;
15451
+ survived.push({
15452
+ file: path,
15453
+ mutator: typeof m.mutatorName === "string" ? m.mutatorName : "unknown",
15454
+ startLine,
15455
+ endLine: endLine !== void 0 ? Math.max(startLine, endLine) : startLine
15456
+ });
15457
+ } else if (m.status === "NoCoverage") noCoverage++;
15458
+ else if (m.status === "Killed" || m.status === "Timeout") killed++;
15459
+ }
15460
+ }
15461
+ return {
15462
+ tool: "stryker",
15463
+ survived,
15464
+ noCoverage,
15465
+ killed
15466
+ };
15467
+ }
15468
+ function toOneBased(line) {
15469
+ return typeof line === "number" && line >= 0 ? line + 1 : void 0;
15470
+ }
15471
+ //#endregion
15472
+ //#region src/mutation/parse-mutmut.ts
15473
+ const MAX_INPUT = 20971520;
15474
+ /** Sniff: does this XML look like mutmut's junit output? */
15475
+ function looksLikeMutmutXml(xml) {
15476
+ return xml.length <= MAX_INPUT && /mutmut/i.test(xml.slice(0, 4e3));
15477
+ }
15478
+ function decodeEntities(s) {
15479
+ return s.replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", "\"").replaceAll("&apos;", "'").replaceAll("&#39;", "'").replaceAll("&amp;", "&");
15480
+ }
15481
+ /**
15482
+ * The file for a testcase: mutmut 2.x writes
15483
+ * `classname="tests.test_auth-mutmut0::x_mutmut_1"`-style classes whose
15484
+ * FIRST dot-separated segment before `-mutmut` is the module path;
15485
+ * mutmut 3.x writes the path directly in `classname`. Both are
15486
+ * normalized here to a repo-relative path with `.py` intact.
15487
+ */
15488
+ function fileFromClassname(classname) {
15489
+ const decoded = decodeEntities(classname);
15490
+ const cut = decoded.indexOf("-mutmut");
15491
+ const candidate = (cut !== -1 ? decoded.slice(0, cut) : decoded).trim();
15492
+ if (candidate.length === 0) return null;
15493
+ return candidate.replaceAll(".", "/") + ".py";
15494
+ }
15495
+ function parseMutmutXml(xml) {
15496
+ if (xml.length > MAX_INPUT) return {
15497
+ tool: "mutmut",
15498
+ survived: [],
15499
+ noCoverage: 0,
15500
+ killed: 0
15501
+ };
15502
+ const survived = [];
15503
+ let noCoverage = 0;
15504
+ let killed = 0;
15505
+ const startRe = /<testcase\b/gi;
15506
+ const closeRe = /<\/testcase\s*>/gi;
15507
+ let start;
15508
+ while ((start = startRe.exec(xml)) !== null) {
15509
+ const gt = xml.indexOf(">", start.index);
15510
+ if (gt === -1) break;
15511
+ const attrs = xml.slice(start.index + 9, gt);
15512
+ let inner;
15513
+ if (attrs.endsWith("/")) {
15514
+ inner = "";
15515
+ startRe.lastIndex = gt + 1;
15516
+ } else {
15517
+ closeRe.lastIndex = gt + 1;
15518
+ const close = closeRe.exec(xml);
15519
+ if (close === null) break;
15520
+ inner = xml.slice(gt + 1, close.index);
15521
+ startRe.lastIndex = close.index + close[0].length;
15522
+ }
15523
+ const classname = /(?:^|\s)classname\s*=\s*"([^"]*)"/i.exec(attrs)?.[1] ?? "";
15524
+ if (/<skipped\b/i.test(inner)) {
15525
+ noCoverage++;
15526
+ continue;
15527
+ }
15528
+ const killedMutant = /<(?:failure|error)\b/i.test(inner);
15529
+ const verdict = {
15530
+ file: fileFromClassname(classname),
15531
+ survived: !killedMutant
15532
+ };
15533
+ if (killedMutant) killed++;
15534
+ else if (verdict.file !== null) survived.push({
15535
+ file: verdict.file,
15536
+ mutator: "mutmut",
15537
+ startLine: 1,
15538
+ endLine: Number.MAX_SAFE_INTEGER
15539
+ });
15540
+ else noCoverage++;
15541
+ }
15542
+ return {
15543
+ tool: "mutmut",
15544
+ survived,
15545
+ noCoverage,
15546
+ killed
15547
+ };
15548
+ }
15549
+ //#endregion
15550
+ //#region src/mutation/derive.ts
15551
+ /**
15552
+ * Stamp mutation evidence onto findings (mutates in place, the same
15553
+ * contract as stampEvidenceLevels). Returns the stamp stats.
15554
+ */
15555
+ function stampMutationEvidence(findings, report) {
15556
+ const byFile = /* @__PURE__ */ new Map();
15557
+ for (const m of report.survived) {
15558
+ const list = byFile.get(m.file) ?? [];
15559
+ list.push(m);
15560
+ byFile.set(m.file, list);
15561
+ }
15562
+ let stamped = 0;
15563
+ let derived = 0;
15564
+ for (const f of findings) {
15565
+ const mutants = byFile.get(f.file);
15566
+ if (!mutants || mutants.length === 0) continue;
15567
+ let matched = mutants.filter((m) => f.line >= m.startLine && f.line <= m.endLine);
15568
+ let granularity = "line";
15569
+ if (matched.length === 0) {
15570
+ matched = mutants;
15571
+ granularity = "file";
15572
+ }
15573
+ f.mutationEvidence = {
15574
+ source: report.tool,
15575
+ matchedMutants: matched.length,
15576
+ mutators: [...new Set(matched.map((m) => m.mutator))].sort(),
15577
+ granularity
15578
+ };
15579
+ stamped++;
15580
+ if (f.evidenceLevel === "E1") {
15581
+ f.evidenceLevel = "E2";
15582
+ derived++;
15583
+ }
15584
+ }
15585
+ return {
15586
+ stamped,
15587
+ derived
15588
+ };
15589
+ }
15590
+ /**
15591
+ * Survived-mutant leaderboard: most-mutated files first. The summary's
15592
+ * honesty headline: files here are code the suite demonstrably does not
15593
+ * constrain — regardless of what any static tool found.
15594
+ */
15595
+ function survivedLeaderboard(report, top = 25) {
15596
+ const byFile = /* @__PURE__ */ new Map();
15597
+ for (const m of report.survived) {
15598
+ const list = byFile.get(m.file) ?? [];
15599
+ list.push(m);
15600
+ byFile.set(m.file, list);
15601
+ }
15602
+ return [...byFile.entries()].map(([file, mutants]) => ({
15603
+ file,
15604
+ survived: mutants.length,
15605
+ mutators: [...new Set(mutants.map((m) => m.mutator))].sort()
15606
+ })).sort((a, b) => b.survived - a.survived).slice(0, top);
15607
+ }
15608
+ function renderMutationSummary(report) {
15609
+ const lines = [];
15610
+ lines.push(`MUTATION EVIDENCE — ${report.tool}`);
15611
+ lines.push("");
15612
+ lines.push(`${report.survived.length} survived · ${report.killed} killed · ${report.noCoverage} no-coverage`);
15613
+ lines.push("");
15614
+ const top = survivedLeaderboard(report);
15615
+ if (top.length === 0) {
15616
+ lines.push("No survived mutants — every executed mutant was killed.");
15617
+ lines.push("");
15618
+ 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.");
15619
+ return lines.join("\n");
15620
+ }
15621
+ lines.push("Files the suite does not constrain (most survived first):");
15622
+ lines.push("");
15623
+ for (const row of top) lines.push(` ${row.survived}× ${row.file} [${row.mutators.join(", ")}]`);
15624
+ lines.push("");
15625
+ 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).");
15626
+ return lines.join("\n");
15627
+ }
15628
+ //#endregion
15414
15629
  //#region src/lib/fs-atomic.ts
15415
15630
  /**
15416
15631
  * Atomic file writes (audit S9).
@@ -15613,6 +15828,12 @@ const HELP_ENTRIES = [
15613
15828
  usage: "mjolnir triage <test-results-dir-or-report-file> [--no-md]",
15614
15829
  examples: ["mjolnir triage test-results --no-md"]
15615
15830
  },
15831
+ {
15832
+ verb: "mutation",
15833
+ summary: "mutation-evidence reader: survived-mutant leaderboard + E1→E2 derivation",
15834
+ usage: "mjolnir mutation <mutation-report> [--scan .]",
15835
+ examples: ["mjolnir mutation reports/mutation-report.json", "mjolnir mutation reports/… --scan ."]
15836
+ },
15616
15837
  {
15617
15838
  verb: "pw-report",
15618
15839
  summary: "Playwright run summary (counts, true flakes, slowest tests)",
@@ -15640,10 +15861,16 @@ const HELP_ENTRIES = [
15640
15861
  },
15641
15862
  {
15642
15863
  verb: "diff",
15643
- summary: "compare a fresh scan against the baseline — new/worsened only",
15644
- usage: "mjolnir diff [path]",
15864
+ summary: "lifecycle diff vs the committed baseline",
15865
+ usage: "mjolnir diff [path] [--json] [scan flags]",
15645
15866
  examples: ["mjolnir diff"]
15646
15867
  },
15868
+ {
15869
+ verb: "verify",
15870
+ summary: "agent-loop digest: resolved/new/unchanged vs baseline + score delta",
15871
+ usage: "mjolnir verify [path] [--json] [scan flags]",
15872
+ examples: ["mjolnir verify"]
15873
+ },
15647
15874
  {
15648
15875
  verb: "impact",
15649
15876
  summary: "what a commit introduced vs resolved, since a prior commit",
@@ -15730,7 +15957,7 @@ const HELP_ENTRIES = [
15730
15957
  },
15731
15958
  {
15732
15959
  verb: "mcp",
15733
- summary: "run as an MCP server over stdio (scan / explain / diff tools)",
15960
+ summary: "run as an MCP server over stdio (scan / explain / diff / verify tools)",
15734
15961
  usage: "mjolnir mcp",
15735
15962
  examples: ["mjolnir mcp"]
15736
15963
  }
@@ -15883,7 +16110,8 @@ const GROUPS = [
15883
16110
  "badge",
15884
16111
  "impact",
15885
16112
  "baseline",
15886
- "diff"
16113
+ "diff",
16114
+ "verify"
15887
16115
  ]
15888
16116
  },
15889
16117
  {
@@ -15895,6 +16123,10 @@ const GROUPS = [
15895
16123
  "doctor:playwright"
15896
16124
  ]
15897
16125
  },
16126
+ {
16127
+ title: "Mutation evidence",
16128
+ verbs: ["mutation"]
16129
+ },
15898
16130
  {
15899
16131
  title: "Maintenance",
15900
16132
  verbs: [
@@ -16865,6 +17097,113 @@ function renderBaselineDiff(diff) {
16865
17097
  return lines.join("\n");
16866
17098
  }
16867
17099
  //#endregion
17100
+ //#region src/commands/verify.ts
17101
+ /**
17102
+ * `mjolnir verify` — the agent-loop digest (product-gap-remediation
17103
+ * master plan P7, plan 1788853205786 — flag agent, decision 6).
17104
+ *
17105
+ * An agent fixes findings, then runs `mjolnir verify` against the
17106
+ * committed baseline (established once with `mjolnir baseline`). The
17107
+ * digest answers exactly the loop's questions, derived from the SAME
17108
+ * §15 comparison machinery as `mjolnir diff` (no second truth):
17109
+ *
17110
+ * - RESOLVED — baseline findings no longer present, each with its
17111
+ * lifecycle resolution (only VERIFIED-RESOLVED is a fix claim);
17112
+ * - NEW — findings the fix introduced;
17113
+ * - UNCHANGED — pre-existing debt, grouped by ruleId + location
17114
+ * (the agent-loop key: an agent works by location, not message);
17115
+ * - SCORE DELTA — before (baseline) → after (this scan).
17116
+ *
17117
+ * Exit semantics are the frozen contract: 0 clean · 1 new error
17118
+ * findings · 2 partial scan or no baseline (nothing to verify against
17119
+ * — the agent must first establish the before-state).
17120
+ */
17121
+ function buildVerifyDigest(result, baseline) {
17122
+ const diff = diffAgainstBaseline(result, baseline);
17123
+ const digest = {
17124
+ hasBaseline: diff.hasBaseline,
17125
+ ...diff.baselineCapturedAt !== void 0 ? { baselineCapturedAt: diff.baselineCapturedAt } : {},
17126
+ ...diff.baselineCommit !== void 0 ? { baselineCommit: diff.baselineCommit } : {},
17127
+ resolved: diff.resolvedFindings.map((f) => ({
17128
+ ruleId: f.ruleId,
17129
+ file: f.file,
17130
+ resolution: f.resolution.status
17131
+ })),
17132
+ new: diff.newFindings.map((f) => ({
17133
+ ruleId: f.ruleId,
17134
+ file: f.file,
17135
+ line: f.line,
17136
+ severity: f.severity
17137
+ })),
17138
+ unchanged: [],
17139
+ unchangedCount: diff.unchangedCount,
17140
+ scoreBefore: diff.baselineScore ?? null,
17141
+ scoreAfter: result.score ?? null,
17142
+ scoreDelta: diff.baselineScore !== void 0 && result.score !== null ? result.score - diff.baselineScore : null
17143
+ };
17144
+ if (diff.hasBaseline && baseline) {
17145
+ const byRule = /* @__PURE__ */ new Map();
17146
+ const headByFingerprint = /* @__PURE__ */ new Map();
17147
+ for (const f of result.findings) headByFingerprint.set(`${f.ruleId}\u0000${f.file}\u0000${f.message}`, f);
17148
+ for (const b of baseline.findings) {
17149
+ const head = headByFingerprint.get(`${b.ruleId}\u0000${b.file}\u0000${b.message}`);
17150
+ if (!head) continue;
17151
+ let group = byRule.get(b.ruleId);
17152
+ if (!group) {
17153
+ group = {
17154
+ ruleId: b.ruleId,
17155
+ locations: []
17156
+ };
17157
+ byRule.set(b.ruleId, group);
17158
+ }
17159
+ group.locations.push(`${head.file}:${head.line}`);
17160
+ }
17161
+ digest.unchanged = [...byRule.values()].map((g) => ({
17162
+ ruleId: g.ruleId,
17163
+ locations: g.locations.sort()
17164
+ }));
17165
+ }
17166
+ return digest;
17167
+ }
17168
+ function renderVerifyDigest(digest) {
17169
+ if (!digest.hasBaseline) return [
17170
+ "VERIFY — no baseline",
17171
+ "",
17172
+ "No committed baseline at .mjolnir/baseline.json — nothing to verify",
17173
+ "against. Establish the before-state once with `mjolnir baseline`,",
17174
+ "then re-run `mjolnir verify` after the fix."
17175
+ ].join("\n");
17176
+ const lines = [];
17177
+ lines.push("VERIFY — before/after digest");
17178
+ lines.push("");
17179
+ if (digest.scoreDelta !== null) {
17180
+ const dir = digest.scoreDelta > 0 ? "improved" : digest.scoreDelta < 0 ? "worse" : "unchanged";
17181
+ lines.push(`score: ${digest.scoreBefore} → ${digest.scoreAfter} (${dir}, Δ${digest.scoreDelta >= 0 ? "+" : ""}${digest.scoreDelta})`);
17182
+ }
17183
+ lines.push(`${digest.resolved.length} resolved · ${digest.new.length} new · ${digest.unchangedCount} unchanged`);
17184
+ lines.push("");
17185
+ if (digest.resolved.length > 0) {
17186
+ lines.push("RESOLVED (per §15 lifecycle — only VERIFIED-RESOLVED is a fix claim):");
17187
+ for (const r of digest.resolved) lines.push(` ${r.ruleId} ${r.file} — ${r.resolution}`);
17188
+ lines.push("");
17189
+ }
17190
+ if (digest.new.length > 0) {
17191
+ lines.push("NEW (introduced by the change under verification):");
17192
+ for (const n of digest.new) lines.push(` ${n.ruleId} ${n.file}:${n.line} (${n.severity})`);
17193
+ lines.push("");
17194
+ }
17195
+ if (digest.unchanged.length > 0) {
17196
+ lines.push("UNCHANGED (pre-existing debt — out of this fix's scope):");
17197
+ for (const g of digest.unchanged) {
17198
+ lines.push(` ${g.ruleId} × ${g.locations.length}`);
17199
+ for (const loc of g.locations.slice(0, 5)) lines.push(` ${loc}`);
17200
+ if (g.locations.length > 5) lines.push(` … and ${g.locations.length - 5} more`);
17201
+ }
17202
+ lines.push("");
17203
+ }
17204
+ return lines.join("\n").trimEnd();
17205
+ }
17206
+ //#endregion
16868
17207
  //#region src/commands/stats.ts
16869
17208
  /**
16870
17209
  * `mjolnir stats` — Sprint 6 Task 26 (Master-Stabilization-Plan.md).
@@ -18658,7 +18997,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
18658
18997
  * `scripts/sync-sarif-version.cjs` on release and guarded by
18659
18998
  * `tests/version-consistency.spec.ts` locally.
18660
18999
  */
18661
- const CLI_VERSION = "0.5.36";
19000
+ const CLI_VERSION = "0.5.38";
18662
19001
  function parseArgs(argv, onError) {
18663
19002
  const args = {
18664
19003
  target: ".",
@@ -19144,6 +19483,97 @@ function runTriageCommand(argv, io = {
19144
19483
  return 20;
19145
19484
  }
19146
19485
  }
19486
+ /**
19487
+ * Sniff + parse a mutation report: Stryker JSON, mutmut junitxml, else
19488
+ * an empty report (the caller renders the honest "nothing recognized").
19489
+ */
19490
+ function ingestMutationReport(text) {
19491
+ const trimmed = text.trimStart();
19492
+ if (trimmed.startsWith("<?xml") || trimmed.startsWith("<testsuite")) return parseMutmutXml(text);
19493
+ try {
19494
+ const json = JSON.parse(text);
19495
+ if (looksLikeStrykerJson(json)) return parseStrykerJson(json);
19496
+ } catch {}
19497
+ if (looksLikeMutmutXml(text)) return parseMutmutXml(text);
19498
+ return {
19499
+ tool: "stryker",
19500
+ survived: [],
19501
+ noCoverage: 0,
19502
+ killed: 0
19503
+ };
19504
+ }
19505
+ /** Testable `mutation` handler (master plan P5, plan 1788853205786).
19506
+ * Reads a mutation report (Stryker JSON / mutmut junitxml), renders the
19507
+ * survived-mutant leaderboard, and — with `--scan <path>` — re-scans the
19508
+ * target, stamps matching findings with `mutationEvidence` (E1→E2 by
19509
+ * derivation) and renders the stamped findings. NEVER spawns mutation
19510
+ * tools: it reads reports the user already produced. Exit 2 when
19511
+ * nothing in the report is recognized — honest no-evidence, not an
19512
+ * error. Exit 0 in every other case: the reader is report-only, never a
19513
+ * gate. */
19514
+ async function runMutationCommand(argv, io = {
19515
+ out,
19516
+ err
19517
+ }) {
19518
+ const targetArg = argv.find((a) => !a.startsWith("-"));
19519
+ if (!targetArg) {
19520
+ io.err("Usage: mjolnir mutation <mutation-report> [--scan <path>]");
19521
+ return 10;
19522
+ }
19523
+ const scanIdx = argv.indexOf("--scan");
19524
+ const scanArg = scanIdx !== -1 ? argv[scanIdx + 1] : void 0;
19525
+ if (scanIdx !== -1 && (!scanArg || scanArg.startsWith("-"))) {
19526
+ io.err("--scan requires a path argument");
19527
+ return 10;
19528
+ }
19529
+ let report;
19530
+ try {
19531
+ const path = resolve(targetArg);
19532
+ if (!existsSync(path)) {
19533
+ io.err(`No such file: ${path}`);
19534
+ return 2;
19535
+ }
19536
+ report = ingestMutationReport(readFileSync(path, "utf8"));
19537
+ if (report.survived.length === 0 && report.killed === 0 && report.noCoverage === 0) {
19538
+ io.err("No mutants recognized. Expected a Stryker JSON report (mutation-report.json) or a mutmut junitxml report.");
19539
+ return 2;
19540
+ }
19541
+ } catch (err) {
19542
+ internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
19543
+ return 20;
19544
+ }
19545
+ io.out(renderMutationSummary(report));
19546
+ if (scanArg === void 0) return 0;
19547
+ try {
19548
+ const scanPath = resolve(scanArg);
19549
+ const invalid = validateScanTarget(scanPath, io.err);
19550
+ if (invalid !== null) return invalid;
19551
+ const result = await runScan({
19552
+ target: scanPath,
19553
+ json: true,
19554
+ verbose: true,
19555
+ maxDurationMs: 12e4,
19556
+ scopeChanged: false,
19557
+ format: "json"
19558
+ });
19559
+ const stats = stampMutationEvidence(result.findings, report);
19560
+ io.out("");
19561
+ if (stats.stamped === 0) io.out("No findings intersect the survived-mutant surface — nothing to derive.");
19562
+ else {
19563
+ io.out(`${stats.stamped} finding(s) carry mutationEvidence (${stats.derived} consolidated E1→E2 by derivation — docs/RULE-LIFECYCLE.md):`);
19564
+ for (const f of result.findings) {
19565
+ if (!f.mutationEvidence) continue;
19566
+ io.out(` ${f.ruleId} ${f.file}:${f.line} — ${f.evidenceLevel} · ${f.mutationEvidence.matchedMutants} mutant(s) @ ${f.mutationEvidence.granularity} granularity`);
19567
+ }
19568
+ io.out("");
19569
+ io.out("Machine form: re-run with --json — mutationEvidence rides the findings additively.");
19570
+ }
19571
+ return 0;
19572
+ } catch (err) {
19573
+ internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
19574
+ return 20;
19575
+ }
19576
+ }
19147
19577
  /** Testable `badge` handler (Tier 1 #5). */
19148
19578
  async function runBadgeCommand(argv, io = {
19149
19579
  out,
@@ -19325,6 +19755,36 @@ async function runBaselineCommand(argv, io = {
19325
19755
  }
19326
19756
  }
19327
19757
  /** Testable `diff` handler (Sprint 6 Task 24) — new/worsened debt only. */
19758
+ /** Testable `verify` handler — the agent-loop verb (master plan P7,
19759
+ * plan 1788853205786). Scans the target and diffs against the committed
19760
+ * baseline, rendering the before/after digest the agent loop consumes
19761
+ * (resolved per §15 · new · unchanged by ruleId+location · score delta).
19762
+ * Exit semantics are the frozen contract: 0 clean · 1 new error
19763
+ * findings · 2 partial scan or no baseline · 20 internal. */
19764
+ async function runVerifyCommand(argv, io = {
19765
+ out,
19766
+ err
19767
+ }) {
19768
+ const args = parseArgsOrUsage(argv, io);
19769
+ if (!args) return 10;
19770
+ try {
19771
+ const target = resolve(args.target);
19772
+ const invalid = validateScanTarget(target, io.err);
19773
+ if (invalid !== null) return invalid;
19774
+ const result = await runScan({
19775
+ ...args,
19776
+ target
19777
+ });
19778
+ const digest = buildVerifyDigest(result, loadBaseline(join(target, DEFAULT_BASELINE_PATH), (w) => io.err(w)));
19779
+ io.out(renderVerifyDigest(digest));
19780
+ if (result.partial) return 2;
19781
+ if (!digest.hasBaseline) return 2;
19782
+ return digest.new.some((f) => f.severity === "error") ? 1 : 0;
19783
+ } catch (err) {
19784
+ internalErrorMessage(err, io.err, args?.debug === true);
19785
+ return 20;
19786
+ }
19787
+ }
19328
19788
  async function runDiffCommand(argv, io = {
19329
19789
  out,
19330
19790
  err
@@ -19522,11 +19982,13 @@ async function main(argv = process$1.argv.slice(2), io = {
19522
19982
  if (argv[0] === "suppressions") return runSuppressions();
19523
19983
  if (argv[0] === "forensics") return runForensicsCommand(argv.slice(1));
19524
19984
  if (argv[0] === "triage") return runTriageCommand(argv.slice(1));
19985
+ if (argv[0] === "mutation") return runMutationCommand(argv.slice(1), io);
19525
19986
  if (argv[0] === "badge") return runBadgeCommand(argv.slice(1));
19526
19987
  if (argv[0] === "debt") return runDebtCommand(argv.slice(1));
19527
19988
  if (argv[0] === "impact") return runImpactCommand(argv.slice(1));
19528
19989
  if (argv[0] === "baseline") return runBaselineCommand(argv.slice(1));
19529
19990
  if (argv[0] === "diff") return runDiffCommand(argv.slice(1));
19991
+ if (argv[0] === "verify") return runVerifyCommand(argv.slice(1), io);
19530
19992
  if (argv[0] === "pr-comment") return runPrCommentCommand(argv.slice(1));
19531
19993
  if (argv[0] === "summary") return runSummaryCommand(argv.slice(1), io);
19532
19994
  if (argv[0] === "stats") return runStatsCommand(argv.slice(1));
@@ -19659,6 +20121,18 @@ const MCP_TOOLS = [
19659
20121
  },
19660
20122
  required: ["path", "scanResult"]
19661
20123
  }
20124
+ },
20125
+ {
20126
+ name: "verify",
20127
+ description: "Agent-loop digest (1:1 with `mjolnir verify`): scan the target and diff against the committed baseline. Returns resolved (per §15 lifecycle), new, unchanged (grouped by ruleId + location), and the score delta. Read-only; the same transport guardrails as every other tool.",
20128
+ inputSchema: {
20129
+ type: "object",
20130
+ properties: { path: {
20131
+ type: "string",
20132
+ description: "Directory to scan and verify."
20133
+ } },
20134
+ required: ["path"]
20135
+ }
19662
20136
  }
19663
20137
  ];
19664
20138
  /** Strict param validation — unknown/missing/mistyped ⇒ INVALID_PARAMS. */
@@ -19675,6 +20149,9 @@ function validateParams(name, args) {
19675
20149
  if (typeof args["path"] !== "string" || args["path"].length === 0) return "diff requires a non-empty string `path`";
19676
20150
  if (typeof args["scanResult"] !== "object" || args["scanResult"] === null) return "diff requires the `scanResult` object from a previous scan tool call";
19677
20151
  }
20152
+ if (name === "verify") {
20153
+ if (typeof args["path"] !== "string" || args["path"].length === 0) return "verify requires a non-empty string `path`";
20154
+ }
19678
20155
  return null;
19679
20156
  }
19680
20157
  /**
@@ -19791,6 +20268,52 @@ async function handleToolCall(call) {
19791
20268
  }
19792
20269
  };
19793
20270
  }
20271
+ if (call.name === "verify") {
20272
+ const target = resolve(call.args["path"]);
20273
+ if (!existsSync(target)) return {
20274
+ jsonrpc: "2.0",
20275
+ id: call.id,
20276
+ error: {
20277
+ code: MCP_ERRORS.INVALID_PARAMS,
20278
+ message: `verify target does not exist: ${target}`
20279
+ }
20280
+ };
20281
+ const baselinePath = join(target, DEFAULT_BASELINE_PATH);
20282
+ if (!existsSync(baselinePath)) return {
20283
+ jsonrpc: "2.0",
20284
+ id: call.id,
20285
+ result: {
20286
+ hasBaseline: false,
20287
+ note: "no committed baseline at .mjolnir/baseline.json — establish the before-state with `mjolnir baseline` first"
20288
+ }
20289
+ };
20290
+ const result = await serializeScan(() => runScan({
20291
+ target,
20292
+ json: true,
20293
+ verbose: false,
20294
+ maxDurationMs: Number.POSITIVE_INFINITY,
20295
+ scopeChanged: false,
20296
+ format: "json"
20297
+ }));
20298
+ const digest = buildVerifyDigest(result, loadBaseline(baselinePath));
20299
+ return {
20300
+ jsonrpc: "2.0",
20301
+ id: call.id,
20302
+ result: {
20303
+ hasBaseline: digest.hasBaseline,
20304
+ baselineCapturedAt: digest.baselineCapturedAt,
20305
+ baselineCommit: digest.baselineCommit,
20306
+ resolved: digest.resolved,
20307
+ new: digest.new,
20308
+ unchanged: digest.unchanged,
20309
+ unchangedCount: digest.unchangedCount,
20310
+ scoreBefore: digest.scoreBefore,
20311
+ scoreAfter: digest.scoreAfter,
20312
+ scoreDelta: digest.scoreDelta,
20313
+ partial: result.partial
20314
+ }
20315
+ };
20316
+ }
19794
20317
  return {
19795
20318
  jsonrpc: "2.0",
19796
20319
  id: call.id,