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.
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.38",
13468
13468
  rules: [...rules.values()].map((r) => {
13469
13469
  const meta = RULES.find((x) => x.id === r.id);
13470
13470
  return {
@@ -14697,6 +14697,7 @@ function agentBrief(surface, cwdNote) {
14697
14697
  "- Exit 0 = clean. Exit 1 = new findings on changed lines: fix them (smallest behavior-preserving change) or justify them to the user.",
14698
14698
  `- npx mjolnir-qa@${CLI_VERSION} why <file>:<line> explains any finding (evidence level, measured FP rate, fix).`,
14699
14699
  `- npx mjolnir-qa@${CLI_VERSION} handoff renders a full remediation plan from a saved --json report.`,
14700
+ `- 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.`,
14700
14701
  "- NEVER suppress a finding merely to obtain a green scan (suppressions live in mjolnir.config.json, require a reason, and expire after 90 days).",
14701
14702
  "- After fixing: re-run the scan, report files changed, report checks not run, report unresolved findings honestly.",
14702
14703
  "",
@@ -15525,6 +15526,113 @@ function renderBaselineDiff(diff) {
15525
15526
  return lines.join("\n");
15526
15527
  }
15527
15528
  //#endregion
15529
+ //#region src/commands/verify.ts
15530
+ /**
15531
+ * `mjolnir verify` — the agent-loop digest (product-gap-remediation
15532
+ * master plan P7, plan 1788853205786 — flag agent, decision 6).
15533
+ *
15534
+ * An agent fixes findings, then runs `mjolnir verify` against the
15535
+ * committed baseline (established once with `mjolnir baseline`). The
15536
+ * digest answers exactly the loop's questions, derived from the SAME
15537
+ * §15 comparison machinery as `mjolnir diff` (no second truth):
15538
+ *
15539
+ * - RESOLVED — baseline findings no longer present, each with its
15540
+ * lifecycle resolution (only VERIFIED-RESOLVED is a fix claim);
15541
+ * - NEW — findings the fix introduced;
15542
+ * - UNCHANGED — pre-existing debt, grouped by ruleId + location
15543
+ * (the agent-loop key: an agent works by location, not message);
15544
+ * - SCORE DELTA — before (baseline) → after (this scan).
15545
+ *
15546
+ * Exit semantics are the frozen contract: 0 clean · 1 new error
15547
+ * findings · 2 partial scan or no baseline (nothing to verify against
15548
+ * — the agent must first establish the before-state).
15549
+ */
15550
+ function buildVerifyDigest(result, baseline) {
15551
+ const diff = diffAgainstBaseline(result, baseline);
15552
+ const digest = {
15553
+ hasBaseline: diff.hasBaseline,
15554
+ ...diff.baselineCapturedAt !== void 0 ? { baselineCapturedAt: diff.baselineCapturedAt } : {},
15555
+ ...diff.baselineCommit !== void 0 ? { baselineCommit: diff.baselineCommit } : {},
15556
+ resolved: diff.resolvedFindings.map((f) => ({
15557
+ ruleId: f.ruleId,
15558
+ file: f.file,
15559
+ resolution: f.resolution.status
15560
+ })),
15561
+ new: diff.newFindings.map((f) => ({
15562
+ ruleId: f.ruleId,
15563
+ file: f.file,
15564
+ line: f.line,
15565
+ severity: f.severity
15566
+ })),
15567
+ unchanged: [],
15568
+ unchangedCount: diff.unchangedCount,
15569
+ scoreBefore: diff.baselineScore ?? null,
15570
+ scoreAfter: result.score ?? null,
15571
+ scoreDelta: diff.baselineScore !== void 0 && result.score !== null ? result.score - diff.baselineScore : null
15572
+ };
15573
+ if (diff.hasBaseline && baseline) {
15574
+ const byRule = /* @__PURE__ */ new Map();
15575
+ const headByFingerprint = /* @__PURE__ */ new Map();
15576
+ for (const f of result.findings) headByFingerprint.set(`${f.ruleId}\u0000${f.file}\u0000${f.message}`, f);
15577
+ for (const b of baseline.findings) {
15578
+ const head = headByFingerprint.get(`${b.ruleId}\u0000${b.file}\u0000${b.message}`);
15579
+ if (!head) continue;
15580
+ let group = byRule.get(b.ruleId);
15581
+ if (!group) {
15582
+ group = {
15583
+ ruleId: b.ruleId,
15584
+ locations: []
15585
+ };
15586
+ byRule.set(b.ruleId, group);
15587
+ }
15588
+ group.locations.push(`${head.file}:${head.line}`);
15589
+ }
15590
+ digest.unchanged = [...byRule.values()].map((g) => ({
15591
+ ruleId: g.ruleId,
15592
+ locations: g.locations.sort()
15593
+ }));
15594
+ }
15595
+ return digest;
15596
+ }
15597
+ function renderVerifyDigest(digest) {
15598
+ if (!digest.hasBaseline) return [
15599
+ "VERIFY — no baseline",
15600
+ "",
15601
+ "No committed baseline at .mjolnir/baseline.json — nothing to verify",
15602
+ "against. Establish the before-state once with `mjolnir baseline`,",
15603
+ "then re-run `mjolnir verify` after the fix."
15604
+ ].join("\n");
15605
+ const lines = [];
15606
+ lines.push("VERIFY — before/after digest");
15607
+ lines.push("");
15608
+ if (digest.scoreDelta !== null) {
15609
+ const dir = digest.scoreDelta > 0 ? "improved" : digest.scoreDelta < 0 ? "worse" : "unchanged";
15610
+ lines.push(`score: ${digest.scoreBefore} → ${digest.scoreAfter} (${dir}, Δ${digest.scoreDelta >= 0 ? "+" : ""}${digest.scoreDelta})`);
15611
+ }
15612
+ lines.push(`${digest.resolved.length} resolved · ${digest.new.length} new · ${digest.unchangedCount} unchanged`);
15613
+ lines.push("");
15614
+ if (digest.resolved.length > 0) {
15615
+ lines.push("RESOLVED (per §15 lifecycle — only VERIFIED-RESOLVED is a fix claim):");
15616
+ for (const r of digest.resolved) lines.push(` ${r.ruleId} ${r.file} — ${r.resolution}`);
15617
+ lines.push("");
15618
+ }
15619
+ if (digest.new.length > 0) {
15620
+ lines.push("NEW (introduced by the change under verification):");
15621
+ for (const n of digest.new) lines.push(` ${n.ruleId} ${n.file}:${n.line} (${n.severity})`);
15622
+ lines.push("");
15623
+ }
15624
+ if (digest.unchanged.length > 0) {
15625
+ lines.push("UNCHANGED (pre-existing debt — out of this fix's scope):");
15626
+ for (const g of digest.unchanged) {
15627
+ lines.push(` ${g.ruleId} × ${g.locations.length}`);
15628
+ for (const loc of g.locations.slice(0, 5)) lines.push(` ${loc}`);
15629
+ if (g.locations.length > 5) lines.push(` … and ${g.locations.length - 5} more`);
15630
+ }
15631
+ lines.push("");
15632
+ }
15633
+ return lines.join("\n").trimEnd();
15634
+ }
15635
+ //#endregion
15528
15636
  //#region src/mcp/server.ts
15529
15637
  /** Hard caps (§21 threat model: parameter size + resource bounds). */
15530
15638
  const MAX_PARAM_BYTES = 65536;
@@ -15586,6 +15694,18 @@ const MCP_TOOLS = [
15586
15694
  },
15587
15695
  required: ["path", "scanResult"]
15588
15696
  }
15697
+ },
15698
+ {
15699
+ name: "verify",
15700
+ 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.",
15701
+ inputSchema: {
15702
+ type: "object",
15703
+ properties: { path: {
15704
+ type: "string",
15705
+ description: "Directory to scan and verify."
15706
+ } },
15707
+ required: ["path"]
15708
+ }
15589
15709
  }
15590
15710
  ];
15591
15711
  /** Strict param validation — unknown/missing/mistyped ⇒ INVALID_PARAMS. */
@@ -15602,6 +15722,9 @@ function validateParams(name, args) {
15602
15722
  if (typeof args["path"] !== "string" || args["path"].length === 0) return "diff requires a non-empty string `path`";
15603
15723
  if (typeof args["scanResult"] !== "object" || args["scanResult"] === null) return "diff requires the `scanResult` object from a previous scan tool call";
15604
15724
  }
15725
+ if (name === "verify") {
15726
+ if (typeof args["path"] !== "string" || args["path"].length === 0) return "verify requires a non-empty string `path`";
15727
+ }
15605
15728
  return null;
15606
15729
  }
15607
15730
  /**
@@ -15718,6 +15841,52 @@ async function handleToolCall(call) {
15718
15841
  }
15719
15842
  };
15720
15843
  }
15844
+ if (call.name === "verify") {
15845
+ const target = resolve(call.args["path"]);
15846
+ if (!existsSync(target)) return {
15847
+ jsonrpc: "2.0",
15848
+ id: call.id,
15849
+ error: {
15850
+ code: MCP_ERRORS.INVALID_PARAMS,
15851
+ message: `verify target does not exist: ${target}`
15852
+ }
15853
+ };
15854
+ const baselinePath = join(target, DEFAULT_BASELINE_PATH);
15855
+ if (!existsSync(baselinePath)) return {
15856
+ jsonrpc: "2.0",
15857
+ id: call.id,
15858
+ result: {
15859
+ hasBaseline: false,
15860
+ note: "no committed baseline at .mjolnir/baseline.json — establish the before-state with `mjolnir baseline` first"
15861
+ }
15862
+ };
15863
+ const result = await serializeScan(() => runScan({
15864
+ target,
15865
+ json: true,
15866
+ verbose: false,
15867
+ maxDurationMs: Number.POSITIVE_INFINITY,
15868
+ scopeChanged: false,
15869
+ format: "json"
15870
+ }));
15871
+ const digest = buildVerifyDigest(result, loadBaseline(baselinePath));
15872
+ return {
15873
+ jsonrpc: "2.0",
15874
+ id: call.id,
15875
+ result: {
15876
+ hasBaseline: digest.hasBaseline,
15877
+ baselineCapturedAt: digest.baselineCapturedAt,
15878
+ baselineCommit: digest.baselineCommit,
15879
+ resolved: digest.resolved,
15880
+ new: digest.new,
15881
+ unchanged: digest.unchanged,
15882
+ unchangedCount: digest.unchangedCount,
15883
+ scoreBefore: digest.scoreBefore,
15884
+ scoreAfter: digest.scoreAfter,
15885
+ scoreDelta: digest.scoreDelta,
15886
+ partial: result.partial
15887
+ }
15888
+ };
15889
+ }
15721
15890
  return {
15722
15891
  jsonrpc: "2.0",
15723
15892
  id: call.id,
@@ -16314,6 +16483,220 @@ function renderTriageMd(report) {
16314
16483
  return lines.join("\n");
16315
16484
  }
16316
16485
  //#endregion
16486
+ //#region src/mutation/parse-stryker.ts
16487
+ const MAX_FILES = 5e3;
16488
+ const MAX_MUTANTS_PER_FILE = 2e4;
16489
+ /** Sniff: does this parsed JSON look like a Stryker mutation report? */
16490
+ function looksLikeStrykerJson(json) {
16491
+ if (!json || typeof json !== "object") return false;
16492
+ return typeof json.files === "object" && json.files !== null;
16493
+ }
16494
+ function parseStrykerJson(json) {
16495
+ if (!json || typeof json !== "object") return {
16496
+ tool: "stryker",
16497
+ survived: [],
16498
+ noCoverage: 0,
16499
+ killed: 0
16500
+ };
16501
+ const files = json.files;
16502
+ if (!files || typeof files !== "object") return {
16503
+ tool: "stryker",
16504
+ survived: [],
16505
+ noCoverage: 0,
16506
+ killed: 0
16507
+ };
16508
+ const survived = [];
16509
+ let noCoverage = 0;
16510
+ let killed = 0;
16511
+ const entries = Object.entries(files).slice(0, MAX_FILES);
16512
+ for (const [rawPath, file] of entries) {
16513
+ if (!file || typeof file !== "object") continue;
16514
+ const mutants = Array.isArray(file.mutants) ? file.mutants : [];
16515
+ const path = rawPath.replaceAll("\\", "/");
16516
+ for (const m of mutants.slice(0, MAX_MUTANTS_PER_FILE)) {
16517
+ if (!m || typeof m !== "object") continue;
16518
+ if (m.status === "Survived") {
16519
+ const startLine = toOneBased(m.location?.start?.line);
16520
+ const endLine = toOneBased(m.location?.end?.line);
16521
+ if (startLine === void 0) continue;
16522
+ survived.push({
16523
+ file: path,
16524
+ mutator: typeof m.mutatorName === "string" ? m.mutatorName : "unknown",
16525
+ startLine,
16526
+ endLine: endLine !== void 0 ? Math.max(startLine, endLine) : startLine
16527
+ });
16528
+ } else if (m.status === "NoCoverage") noCoverage++;
16529
+ else if (m.status === "Killed" || m.status === "Timeout") killed++;
16530
+ }
16531
+ }
16532
+ return {
16533
+ tool: "stryker",
16534
+ survived,
16535
+ noCoverage,
16536
+ killed
16537
+ };
16538
+ }
16539
+ function toOneBased(line) {
16540
+ return typeof line === "number" && line >= 0 ? line + 1 : void 0;
16541
+ }
16542
+ //#endregion
16543
+ //#region src/mutation/parse-mutmut.ts
16544
+ const MAX_INPUT = 20971520;
16545
+ /** Sniff: does this XML look like mutmut's junit output? */
16546
+ function looksLikeMutmutXml(xml) {
16547
+ return xml.length <= MAX_INPUT && /mutmut/i.test(xml.slice(0, 4e3));
16548
+ }
16549
+ function decodeEntities(s) {
16550
+ return s.replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", "\"").replaceAll("&apos;", "'").replaceAll("&#39;", "'").replaceAll("&amp;", "&");
16551
+ }
16552
+ /**
16553
+ * The file for a testcase: mutmut 2.x writes
16554
+ * `classname="tests.test_auth-mutmut0::x_mutmut_1"`-style classes whose
16555
+ * FIRST dot-separated segment before `-mutmut` is the module path;
16556
+ * mutmut 3.x writes the path directly in `classname`. Both are
16557
+ * normalized here to a repo-relative path with `.py` intact.
16558
+ */
16559
+ function fileFromClassname(classname) {
16560
+ const decoded = decodeEntities(classname);
16561
+ const cut = decoded.indexOf("-mutmut");
16562
+ const candidate = (cut !== -1 ? decoded.slice(0, cut) : decoded).trim();
16563
+ if (candidate.length === 0) return null;
16564
+ return candidate.replaceAll(".", "/") + ".py";
16565
+ }
16566
+ function parseMutmutXml(xml) {
16567
+ if (xml.length > MAX_INPUT) return {
16568
+ tool: "mutmut",
16569
+ survived: [],
16570
+ noCoverage: 0,
16571
+ killed: 0
16572
+ };
16573
+ const survived = [];
16574
+ let noCoverage = 0;
16575
+ let killed = 0;
16576
+ const startRe = /<testcase\b/gi;
16577
+ const closeRe = /<\/testcase\s*>/gi;
16578
+ let start;
16579
+ while ((start = startRe.exec(xml)) !== null) {
16580
+ const gt = xml.indexOf(">", start.index);
16581
+ if (gt === -1) break;
16582
+ const attrs = xml.slice(start.index + 9, gt);
16583
+ let inner;
16584
+ if (attrs.endsWith("/")) {
16585
+ inner = "";
16586
+ startRe.lastIndex = gt + 1;
16587
+ } else {
16588
+ closeRe.lastIndex = gt + 1;
16589
+ const close = closeRe.exec(xml);
16590
+ if (close === null) break;
16591
+ inner = xml.slice(gt + 1, close.index);
16592
+ startRe.lastIndex = close.index + close[0].length;
16593
+ }
16594
+ const classname = /(?:^|\s)classname\s*=\s*"([^"]*)"/i.exec(attrs)?.[1] ?? "";
16595
+ if (/<skipped\b/i.test(inner)) {
16596
+ noCoverage++;
16597
+ continue;
16598
+ }
16599
+ const killedMutant = /<(?:failure|error)\b/i.test(inner);
16600
+ const verdict = {
16601
+ file: fileFromClassname(classname),
16602
+ survived: !killedMutant
16603
+ };
16604
+ if (killedMutant) killed++;
16605
+ else if (verdict.file !== null) survived.push({
16606
+ file: verdict.file,
16607
+ mutator: "mutmut",
16608
+ startLine: 1,
16609
+ endLine: Number.MAX_SAFE_INTEGER
16610
+ });
16611
+ else noCoverage++;
16612
+ }
16613
+ return {
16614
+ tool: "mutmut",
16615
+ survived,
16616
+ noCoverage,
16617
+ killed
16618
+ };
16619
+ }
16620
+ //#endregion
16621
+ //#region src/mutation/derive.ts
16622
+ /**
16623
+ * Stamp mutation evidence onto findings (mutates in place, the same
16624
+ * contract as stampEvidenceLevels). Returns the stamp stats.
16625
+ */
16626
+ function stampMutationEvidence(findings, report) {
16627
+ const byFile = /* @__PURE__ */ new Map();
16628
+ for (const m of report.survived) {
16629
+ const list = byFile.get(m.file) ?? [];
16630
+ list.push(m);
16631
+ byFile.set(m.file, list);
16632
+ }
16633
+ let stamped = 0;
16634
+ let derived = 0;
16635
+ for (const f of findings) {
16636
+ const mutants = byFile.get(f.file);
16637
+ if (!mutants || mutants.length === 0) continue;
16638
+ let matched = mutants.filter((m) => f.line >= m.startLine && f.line <= m.endLine);
16639
+ let granularity = "line";
16640
+ if (matched.length === 0) {
16641
+ matched = mutants;
16642
+ granularity = "file";
16643
+ }
16644
+ f.mutationEvidence = {
16645
+ source: report.tool,
16646
+ matchedMutants: matched.length,
16647
+ mutators: [...new Set(matched.map((m) => m.mutator))].sort(),
16648
+ granularity
16649
+ };
16650
+ stamped++;
16651
+ if (f.evidenceLevel === "E1") {
16652
+ f.evidenceLevel = "E2";
16653
+ derived++;
16654
+ }
16655
+ }
16656
+ return {
16657
+ stamped,
16658
+ derived
16659
+ };
16660
+ }
16661
+ /**
16662
+ * Survived-mutant leaderboard: most-mutated files first. The summary's
16663
+ * honesty headline: files here are code the suite demonstrably does not
16664
+ * constrain — regardless of what any static tool found.
16665
+ */
16666
+ function survivedLeaderboard(report, top = 25) {
16667
+ const byFile = /* @__PURE__ */ new Map();
16668
+ for (const m of report.survived) {
16669
+ const list = byFile.get(m.file) ?? [];
16670
+ list.push(m);
16671
+ byFile.set(m.file, list);
16672
+ }
16673
+ return [...byFile.entries()].map(([file, mutants]) => ({
16674
+ file,
16675
+ survived: mutants.length,
16676
+ mutators: [...new Set(mutants.map((m) => m.mutator))].sort()
16677
+ })).sort((a, b) => b.survived - a.survived).slice(0, top);
16678
+ }
16679
+ function renderMutationSummary(report) {
16680
+ const lines = [];
16681
+ lines.push(`MUTATION EVIDENCE — ${report.tool}`);
16682
+ lines.push("");
16683
+ lines.push(`${report.survived.length} survived · ${report.killed} killed · ${report.noCoverage} no-coverage`);
16684
+ lines.push("");
16685
+ const top = survivedLeaderboard(report);
16686
+ if (top.length === 0) {
16687
+ lines.push("No survived mutants — every executed mutant was killed.");
16688
+ lines.push("");
16689
+ 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.");
16690
+ return lines.join("\n");
16691
+ }
16692
+ lines.push("Files the suite does not constrain (most survived first):");
16693
+ lines.push("");
16694
+ for (const row of top) lines.push(` ${row.survived}× ${row.file} [${row.mutators.join(", ")}]`);
16695
+ lines.push("");
16696
+ 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).");
16697
+ return lines.join("\n");
16698
+ }
16699
+ //#endregion
16317
16700
  //#region src/commands/badge.ts
16318
16701
  /**
16319
16702
  * `mjolnir badge` — evidentiary shields.io endpoint JSON (Tier 1 #5).
@@ -16422,6 +16805,12 @@ const HELP_ENTRIES = [
16422
16805
  usage: "mjolnir triage <test-results-dir-or-report-file> [--no-md]",
16423
16806
  examples: ["mjolnir triage test-results --no-md"]
16424
16807
  },
16808
+ {
16809
+ verb: "mutation",
16810
+ summary: "mutation-evidence reader: survived-mutant leaderboard + E1→E2 derivation",
16811
+ usage: "mjolnir mutation <mutation-report> [--scan .]",
16812
+ examples: ["mjolnir mutation reports/mutation-report.json", "mjolnir mutation reports/… --scan ."]
16813
+ },
16425
16814
  {
16426
16815
  verb: "pw-report",
16427
16816
  summary: "Playwright run summary (counts, true flakes, slowest tests)",
@@ -16449,10 +16838,16 @@ const HELP_ENTRIES = [
16449
16838
  },
16450
16839
  {
16451
16840
  verb: "diff",
16452
- summary: "compare a fresh scan against the baseline — new/worsened only",
16453
- usage: "mjolnir diff [path]",
16841
+ summary: "lifecycle diff vs the committed baseline",
16842
+ usage: "mjolnir diff [path] [--json] [scan flags]",
16454
16843
  examples: ["mjolnir diff"]
16455
16844
  },
16845
+ {
16846
+ verb: "verify",
16847
+ summary: "agent-loop digest: resolved/new/unchanged vs baseline + score delta",
16848
+ usage: "mjolnir verify [path] [--json] [scan flags]",
16849
+ examples: ["mjolnir verify"]
16850
+ },
16456
16851
  {
16457
16852
  verb: "impact",
16458
16853
  summary: "what a commit introduced vs resolved, since a prior commit",
@@ -16539,7 +16934,7 @@ const HELP_ENTRIES = [
16539
16934
  },
16540
16935
  {
16541
16936
  verb: "mcp",
16542
- summary: "run as an MCP server over stdio (scan / explain / diff tools)",
16937
+ summary: "run as an MCP server over stdio (scan / explain / diff / verify tools)",
16543
16938
  usage: "mjolnir mcp",
16544
16939
  examples: ["mjolnir mcp"]
16545
16940
  }
@@ -16692,7 +17087,8 @@ const GROUPS = [
16692
17087
  "badge",
16693
17088
  "impact",
16694
17089
  "baseline",
16695
- "diff"
17090
+ "diff",
17091
+ "verify"
16696
17092
  ]
16697
17093
  },
16698
17094
  {
@@ -16704,6 +17100,10 @@ const GROUPS = [
16704
17100
  "doctor:playwright"
16705
17101
  ]
16706
17102
  },
17103
+ {
17104
+ title: "Mutation evidence",
17105
+ verbs: ["mutation"]
17106
+ },
16707
17107
  {
16708
17108
  title: "Maintenance",
16709
17109
  verbs: [
@@ -19022,7 +19422,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
19022
19422
  * `scripts/sync-sarif-version.cjs` on release and guarded by
19023
19423
  * `tests/version-consistency.spec.ts` locally.
19024
19424
  */
19025
- const CLI_VERSION = "0.5.36";
19425
+ const CLI_VERSION = "0.5.38";
19026
19426
  function parseArgs(argv, onError) {
19027
19427
  const args = {
19028
19428
  target: ".",
@@ -19508,6 +19908,97 @@ function runTriageCommand(argv, io = {
19508
19908
  return 20;
19509
19909
  }
19510
19910
  }
19911
+ /**
19912
+ * Sniff + parse a mutation report: Stryker JSON, mutmut junitxml, else
19913
+ * an empty report (the caller renders the honest "nothing recognized").
19914
+ */
19915
+ function ingestMutationReport(text) {
19916
+ const trimmed = text.trimStart();
19917
+ if (trimmed.startsWith("<?xml") || trimmed.startsWith("<testsuite")) return parseMutmutXml(text);
19918
+ try {
19919
+ const json = JSON.parse(text);
19920
+ if (looksLikeStrykerJson(json)) return parseStrykerJson(json);
19921
+ } catch {}
19922
+ if (looksLikeMutmutXml(text)) return parseMutmutXml(text);
19923
+ return {
19924
+ tool: "stryker",
19925
+ survived: [],
19926
+ noCoverage: 0,
19927
+ killed: 0
19928
+ };
19929
+ }
19930
+ /** Testable `mutation` handler (master plan P5, plan 1788853205786).
19931
+ * Reads a mutation report (Stryker JSON / mutmut junitxml), renders the
19932
+ * survived-mutant leaderboard, and — with `--scan <path>` — re-scans the
19933
+ * target, stamps matching findings with `mutationEvidence` (E1→E2 by
19934
+ * derivation) and renders the stamped findings. NEVER spawns mutation
19935
+ * tools: it reads reports the user already produced. Exit 2 when
19936
+ * nothing in the report is recognized — honest no-evidence, not an
19937
+ * error. Exit 0 in every other case: the reader is report-only, never a
19938
+ * gate. */
19939
+ async function runMutationCommand(argv, io = {
19940
+ out,
19941
+ err
19942
+ }) {
19943
+ const targetArg = argv.find((a) => !a.startsWith("-"));
19944
+ if (!targetArg) {
19945
+ io.err("Usage: mjolnir mutation <mutation-report> [--scan <path>]");
19946
+ return 10;
19947
+ }
19948
+ const scanIdx = argv.indexOf("--scan");
19949
+ const scanArg = scanIdx !== -1 ? argv[scanIdx + 1] : void 0;
19950
+ if (scanIdx !== -1 && (!scanArg || scanArg.startsWith("-"))) {
19951
+ io.err("--scan requires a path argument");
19952
+ return 10;
19953
+ }
19954
+ let report;
19955
+ try {
19956
+ const path = resolve(targetArg);
19957
+ if (!existsSync(path)) {
19958
+ io.err(`No such file: ${path}`);
19959
+ return 2;
19960
+ }
19961
+ report = ingestMutationReport(readFileSync(path, "utf8"));
19962
+ if (report.survived.length === 0 && report.killed === 0 && report.noCoverage === 0) {
19963
+ io.err("No mutants recognized. Expected a Stryker JSON report (mutation-report.json) or a mutmut junitxml report.");
19964
+ return 2;
19965
+ }
19966
+ } catch (err) {
19967
+ internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
19968
+ return 20;
19969
+ }
19970
+ io.out(renderMutationSummary(report));
19971
+ if (scanArg === void 0) return 0;
19972
+ try {
19973
+ const scanPath = resolve(scanArg);
19974
+ const invalid = validateScanTarget(scanPath, io.err);
19975
+ if (invalid !== null) return invalid;
19976
+ const result = await runScan({
19977
+ target: scanPath,
19978
+ json: true,
19979
+ verbose: true,
19980
+ maxDurationMs: 12e4,
19981
+ scopeChanged: false,
19982
+ format: "json"
19983
+ });
19984
+ const stats = stampMutationEvidence(result.findings, report);
19985
+ io.out("");
19986
+ if (stats.stamped === 0) io.out("No findings intersect the survived-mutant surface — nothing to derive.");
19987
+ else {
19988
+ io.out(`${stats.stamped} finding(s) carry mutationEvidence (${stats.derived} consolidated E1→E2 by derivation — docs/RULE-LIFECYCLE.md):`);
19989
+ for (const f of result.findings) {
19990
+ if (!f.mutationEvidence) continue;
19991
+ io.out(` ${f.ruleId} ${f.file}:${f.line} — ${f.evidenceLevel} · ${f.mutationEvidence.matchedMutants} mutant(s) @ ${f.mutationEvidence.granularity} granularity`);
19992
+ }
19993
+ io.out("");
19994
+ io.out("Machine form: re-run with --json — mutationEvidence rides the findings additively.");
19995
+ }
19996
+ return 0;
19997
+ } catch (err) {
19998
+ internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
19999
+ return 20;
20000
+ }
20001
+ }
19511
20002
  /** Testable `badge` handler (Tier 1 #5). */
19512
20003
  async function runBadgeCommand(argv, io = {
19513
20004
  out,
@@ -19689,6 +20180,36 @@ async function runBaselineCommand(argv, io = {
19689
20180
  }
19690
20181
  }
19691
20182
  /** Testable `diff` handler (Sprint 6 Task 24) — new/worsened debt only. */
20183
+ /** Testable `verify` handler — the agent-loop verb (master plan P7,
20184
+ * plan 1788853205786). Scans the target and diffs against the committed
20185
+ * baseline, rendering the before/after digest the agent loop consumes
20186
+ * (resolved per §15 · new · unchanged by ruleId+location · score delta).
20187
+ * Exit semantics are the frozen contract: 0 clean · 1 new error
20188
+ * findings · 2 partial scan or no baseline · 20 internal. */
20189
+ async function runVerifyCommand(argv, io = {
20190
+ out,
20191
+ err
20192
+ }) {
20193
+ const args = parseArgsOrUsage(argv, io);
20194
+ if (!args) return 10;
20195
+ try {
20196
+ const target = resolve(args.target);
20197
+ const invalid = validateScanTarget(target, io.err);
20198
+ if (invalid !== null) return invalid;
20199
+ const result = await runScan({
20200
+ ...args,
20201
+ target
20202
+ });
20203
+ const digest = buildVerifyDigest(result, loadBaseline(join(target, DEFAULT_BASELINE_PATH), (w) => io.err(w)));
20204
+ io.out(renderVerifyDigest(digest));
20205
+ if (result.partial) return 2;
20206
+ if (!digest.hasBaseline) return 2;
20207
+ return digest.new.some((f) => f.severity === "error") ? 1 : 0;
20208
+ } catch (err) {
20209
+ internalErrorMessage(err, io.err, args?.debug === true);
20210
+ return 20;
20211
+ }
20212
+ }
19692
20213
  async function runDiffCommand(argv, io = {
19693
20214
  out,
19694
20215
  err
@@ -19886,11 +20407,13 @@ async function main(argv = process$1.argv.slice(2), io = {
19886
20407
  if (argv[0] === "suppressions") return runSuppressions();
19887
20408
  if (argv[0] === "forensics") return runForensicsCommand(argv.slice(1));
19888
20409
  if (argv[0] === "triage") return runTriageCommand(argv.slice(1));
20410
+ if (argv[0] === "mutation") return runMutationCommand(argv.slice(1), io);
19889
20411
  if (argv[0] === "badge") return runBadgeCommand(argv.slice(1));
19890
20412
  if (argv[0] === "debt") return runDebtCommand(argv.slice(1));
19891
20413
  if (argv[0] === "impact") return runImpactCommand(argv.slice(1));
19892
20414
  if (argv[0] === "baseline") return runBaselineCommand(argv.slice(1));
19893
20415
  if (argv[0] === "diff") return runDiffCommand(argv.slice(1));
20416
+ if (argv[0] === "verify") return runVerifyCommand(argv.slice(1), io);
19894
20417
  if (argv[0] === "pr-comment") return runPrCommentCommand(argv.slice(1));
19895
20418
  if (argv[0] === "summary") return runSummaryCommand(argv.slice(1), io);
19896
20419
  if (argv[0] === "stats") return runStatsCommand(argv.slice(1));
@@ -19962,4 +20485,4 @@ function isEntryPoint() {
19962
20485
  }
19963
20486
  if (isEntryPoint()) process$1.exitCode = await main();
19964
20487
  //#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 };
20488
+ 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, runVerifyCommand, usageErrorMessage };