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/CHANGELOG.md +12 -0
- package/dist/cli.d.mts +64 -2
- package/dist/cli.mjs +538 -15
- package/dist/mcp/stdio.mjs +537 -14
- package/package.json +1 -1
package/dist/mcp/stdio.mjs
CHANGED
|
@@ -11177,19 +11177,19 @@ function parseJestJson(json) {
|
|
|
11177
11177
|
}
|
|
11178
11178
|
//#endregion
|
|
11179
11179
|
//#region src/forensics/parse-junit.ts
|
|
11180
|
-
const MAX_INPUT = 20971520;
|
|
11181
|
-
function decodeEntities(s) {
|
|
11180
|
+
const MAX_INPUT$1 = 20971520;
|
|
11181
|
+
function decodeEntities$1(s) {
|
|
11182
11182
|
return s.replaceAll("<", "<").replaceAll(">", ">").replaceAll(""", "\"").replaceAll("'", "'").replaceAll("'", "'").replaceAll("&", "&");
|
|
11183
11183
|
}
|
|
11184
11184
|
function attr(tag, name) {
|
|
11185
11185
|
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
11186
11186
|
const m = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*"([^"]*)"`, "i").exec(tag);
|
|
11187
|
-
if (m?.[1] !== void 0) return decodeEntities(m[1]);
|
|
11187
|
+
if (m?.[1] !== void 0) return decodeEntities$1(m[1]);
|
|
11188
11188
|
const m2 = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*'([^']*)'`, "i").exec(tag);
|
|
11189
|
-
return m2?.[1] !== void 0 ? decodeEntities(m2[1]) : void 0;
|
|
11189
|
+
return m2?.[1] !== void 0 ? decodeEntities$1(m2[1]) : void 0;
|
|
11190
11190
|
}
|
|
11191
11191
|
function parseJunitXml(xml) {
|
|
11192
|
-
if (xml.length > MAX_INPUT) return [];
|
|
11192
|
+
if (xml.length > MAX_INPUT$1) return [];
|
|
11193
11193
|
const out = [];
|
|
11194
11194
|
const startRe = /<testcase\b/gi;
|
|
11195
11195
|
const closeRe = /<\/testcase\s*>/gi;
|
|
@@ -11344,7 +11344,7 @@ function parseVitestJson(json) {
|
|
|
11344
11344
|
*
|
|
11345
11345
|
* Writes FLAKY.md next to the scan target unless --no-flaky-md.
|
|
11346
11346
|
*/
|
|
11347
|
-
const MAX_FILES = 500;
|
|
11347
|
+
const MAX_FILES$1 = 500;
|
|
11348
11348
|
function runForensics(target, options = {}) {
|
|
11349
11349
|
const records = [];
|
|
11350
11350
|
let source = "playwright-json";
|
|
@@ -11368,7 +11368,7 @@ function runForensics(target, options = {}) {
|
|
|
11368
11368
|
else {
|
|
11369
11369
|
let count = 0;
|
|
11370
11370
|
for (const full of listFiles(target)) {
|
|
11371
|
-
if (++count > MAX_FILES) break;
|
|
11371
|
+
if (++count > MAX_FILES$1) break;
|
|
11372
11372
|
try {
|
|
11373
11373
|
const parsed = parseFile(full, readFileSync(full, "utf8"));
|
|
11374
11374
|
if (parsed.records.length === 0) continue;
|
|
@@ -11436,7 +11436,7 @@ function parseFile(path, text) {
|
|
|
11436
11436
|
function listFiles(dir) {
|
|
11437
11437
|
const out = [];
|
|
11438
11438
|
const walk = (d, depth) => {
|
|
11439
|
-
if (depth > 4 || out.length > MAX_FILES) return;
|
|
11439
|
+
if (depth > 4 || out.length > MAX_FILES$1) return;
|
|
11440
11440
|
let entries;
|
|
11441
11441
|
try {
|
|
11442
11442
|
entries = readdirSync(d, { withFileTypes: true });
|
|
@@ -13463,7 +13463,7 @@ function renderSarif(result, repoRootUri) {
|
|
|
13463
13463
|
tool: { driver: {
|
|
13464
13464
|
name: "Mjölnir",
|
|
13465
13465
|
informationUri: "https://github.com/Sergey-Bar/Mjolnir",
|
|
13466
|
-
version: "0.5.
|
|
13466
|
+
version: "0.5.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("<", "<").replaceAll(">", ">").replaceAll(""", "\"").replaceAll("'", "'").replaceAll("'", "'").replaceAll("&", "&");
|
|
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: "
|
|
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.
|
|
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,
|