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