mjolnir-qa 0.5.35 → 0.5.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +2 -1
- package/dist/cli.d.mts +63 -4
- package/dist/cli.mjs +448 -18
- package/dist/mcp/stdio.mjs +447 -17
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,18 @@ Rule behavior changes (new rules, FP-rate changes against the corpus,
|
|
|
9
9
|
severity changes) are first-class entries here — rule IDs are immutable
|
|
10
10
|
once shipped, so this file is the record of what changed between versions.
|
|
11
11
|
|
|
12
|
+
## [0.5.37] — 2026-09-08
|
|
13
|
+
|
|
14
|
+
### Changes since 0
|
|
15
|
+
|
|
16
|
+
- P5: mutation evidence reader — Stryker/mutmut, E1→E2 derivation, report-only (#67)
|
|
17
|
+
|
|
18
|
+
## [0.5.36] — 2026-09-08
|
|
19
|
+
|
|
20
|
+
### Changes since 0
|
|
21
|
+
|
|
22
|
+
- P4: forensics breadth — Jest + Vitest JSON ingestion (honest degradation) (#66)
|
|
23
|
+
|
|
12
24
|
## [0.5.35] — 2026-09-08
|
|
13
25
|
|
|
14
26
|
### Changes since 0
|
package/README.md
CHANGED
|
@@ -499,7 +499,8 @@ scanning.
|
|
|
499
499
|
## Runtime forensics
|
|
500
500
|
|
|
501
501
|
Static analysis reasons about code that was never run. Forensics reads what
|
|
502
|
-
actually happened — Playwright JSON
|
|
502
|
+
actually happened — Playwright JSON, Jest JSON, Vitest JSON, and JUnit XML
|
|
503
|
+
from any runner:
|
|
503
504
|
|
|
504
505
|
```text
|
|
505
506
|
Static analysis → what the code appears to do
|
package/dist/cli.d.mts
CHANGED
|
@@ -72,8 +72,15 @@ type TrustLevel = (typeof TRUST_ORDER)[number];
|
|
|
72
72
|
interface RuntimeCorroboration {
|
|
73
73
|
/** Granularity of what the runtime report could vouch for. */
|
|
74
74
|
level: "file" | "test" | "defect";
|
|
75
|
-
/**
|
|
76
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Report format the evidence came from. Widened additively in P4
|
|
77
|
+
* (plan 1788853205786) to mirror ForensicsReport's source union:
|
|
78
|
+
* Jest/Vitest JSON reports corroborate at file/test level exactly
|
|
79
|
+
* like Playwright's — their one-attempt-per-record shape only
|
|
80
|
+
* constrains TRUE-FLAKE derivation, which lives in the analysis, not
|
|
81
|
+
* in the provenance label.
|
|
82
|
+
*/
|
|
83
|
+
source: "playwright-json" | "junit-xml" | "jest-json" | "vitest-json";
|
|
77
84
|
/** Number of tests executed in the finding's file (any level). */
|
|
78
85
|
testsExecuted: number;
|
|
79
86
|
/**
|
|
@@ -134,6 +141,17 @@ interface Finding {
|
|
|
134
141
|
* stamped with the corroboration pass. Additive within schemaVersion 1.
|
|
135
142
|
*/
|
|
136
143
|
trustLevel?: TrustLevel;
|
|
144
|
+
/**
|
|
145
|
+
* Mutation evidence (master plan P5, plan 1788853205786): what a
|
|
146
|
+
* mutation-testing report says about the code this finding points at.
|
|
147
|
+
* PROVENANCE, not proof — a survived mutant is code the suite would
|
|
148
|
+
* not notice changing; matching findings consolidate E1→E2 BY
|
|
149
|
+
* DERIVATION (documented in docs/RULE-LIFECYCLE.md), and trustLevel
|
|
150
|
+
* never rises from mutation evidence alone (nothing ran). Absent
|
|
151
|
+
* means no mutation report was available or nothing matched — never
|
|
152
|
+
* fabricated. Additive within schemaVersion 1.
|
|
153
|
+
*/
|
|
154
|
+
mutationEvidence?: MutationEvidence | undefined;
|
|
137
155
|
/** Repo-relative path with forward slashes, regardless of OS. */
|
|
138
156
|
file: string;
|
|
139
157
|
/** 1-based. */
|
|
@@ -162,6 +180,34 @@ interface Finding {
|
|
|
162
180
|
*/
|
|
163
181
|
fixGroupId?: string;
|
|
164
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* Mutation evidence (master plan P5, plan 1788853205786 — flag 6,
|
|
185
|
+
* decision 8): provenance from a mutation-testing report
|
|
186
|
+
* (`mjolnir mutation <report>`), stamped on matching findings.
|
|
187
|
+
*
|
|
188
|
+
* PROVENANCE IS NOT TRUTH: a survived mutant is code the suite would
|
|
189
|
+
* not notice changing — evidence FOR a nearby finding, never a claim
|
|
190
|
+
* the finding is "proven real". The E1→E2 consolidation is BY
|
|
191
|
+
* DERIVATION and lives in src/mutation/derive.ts (documented in
|
|
192
|
+
* docs/RULE-LIFECYCLE.md + the machine-contract docs). trustLevel never
|
|
193
|
+
* rises from mutation evidence alone: nothing ran.
|
|
194
|
+
*/
|
|
195
|
+
interface MutationEvidence {
|
|
196
|
+
/** Which mutation tool produced the report. */
|
|
197
|
+
source: "stryker" | "mutmut";
|
|
198
|
+
/** How many survived mutants matched this finding. */
|
|
199
|
+
matchedMutants: number;
|
|
200
|
+
/** The mutator names of the matched mutants (deduped, sorted). */
|
|
201
|
+
mutators: string[];
|
|
202
|
+
/**
|
|
203
|
+
* "line" = the mutant's span contains the finding's position
|
|
204
|
+
* (Stryker evidence); "file" = file-level match only (mutmut —
|
|
205
|
+
* its JUnit report carries no per-mutant lines — or an unplaceable
|
|
206
|
+
* Stryker span). Same prefer-claiming-less rule as the runtime
|
|
207
|
+
* corroboration's granularity fallback.
|
|
208
|
+
*/
|
|
209
|
+
granularity: "line" | "file";
|
|
210
|
+
}
|
|
165
211
|
interface DimensionScore {
|
|
166
212
|
category: RuleCategory;
|
|
167
213
|
score: number;
|
|
@@ -737,7 +783,7 @@ declare const runScan: typeof runScan$1, buildUniversalRules: typeof buildUniver
|
|
|
737
783
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
738
784
|
* `tests/version-consistency.spec.ts` locally.
|
|
739
785
|
*/
|
|
740
|
-
declare const CLI_VERSION = "0.5.
|
|
786
|
+
declare const CLI_VERSION = "0.5.37";
|
|
741
787
|
/** A usage-error detail: the offending token, when one exists. */
|
|
742
788
|
interface UsageErrorDetail {
|
|
743
789
|
/** The unknown flag or rejected value (e.g. `--nope`, `loud`). */
|
|
@@ -808,6 +854,19 @@ declare function runTriageCommand(argv: string[], io?: {
|
|
|
808
854
|
out: Output;
|
|
809
855
|
err: Output;
|
|
810
856
|
}): number;
|
|
857
|
+
/** Testable `mutation` handler (master plan P5, plan 1788853205786).
|
|
858
|
+
* Reads a mutation report (Stryker JSON / mutmut junitxml), renders the
|
|
859
|
+
* survived-mutant leaderboard, and — with `--scan <path>` — re-scans the
|
|
860
|
+
* target, stamps matching findings with `mutationEvidence` (E1→E2 by
|
|
861
|
+
* derivation) and renders the stamped findings. NEVER spawns mutation
|
|
862
|
+
* tools: it reads reports the user already produced. Exit 2 when
|
|
863
|
+
* nothing in the report is recognized — honest no-evidence, not an
|
|
864
|
+
* error. Exit 0 in every other case: the reader is report-only, never a
|
|
865
|
+
* gate. */
|
|
866
|
+
declare function runMutationCommand(argv: string[], io?: {
|
|
867
|
+
out: Output;
|
|
868
|
+
err: Output;
|
|
869
|
+
}): Promise<number>;
|
|
811
870
|
/** Testable `badge` handler (Tier 1 #5). */
|
|
812
871
|
declare function runBadgeCommand(argv: string[], io?: {
|
|
813
872
|
out: Output;
|
|
@@ -884,4 +943,4 @@ declare function runHelpCommand(argv: string[], io?: {
|
|
|
884
943
|
}): number;
|
|
885
944
|
declare function isEntryPoint(): boolean;
|
|
886
945
|
//#endregion
|
|
887
|
-
export { CLI_VERSION, type CliArgs, EVIDENCE_OVERRIDES, KNOWN_RULE_IDS, OVERLAP_META_BY_RULE_ID, type Output, SUITE_INVALIDATING_RULE_IDS, type ScanHooks, UsageErrorDetail, buildUniversalRules, discoverRuntimeReport, err, exitForFindings, fallbackWorkspace, internalErrorMessage, isEntryPoint, isValidFindingRecord, levenshtein, main, nearestFlags, out, parseArgs, pathMatchesGlob, runBadgeCommand, runBaselineCommand, runCiInstall, runCreateRuleCommand, runDebtCommand, runDiffCommand, runDoctorCommand, runDoctorPlaywright, runExplainCommand, runFixCommand, runForensicsCommand, runHandoverCommand, runHelpCommand, runImpactCommand, runInitCommand, runPrCommentCommand, runPwReportCommand, runRulesCommand, runScan, runScanCommand, runStatsCommand, runSuppressions, runTriageCommand, usageErrorMessage };
|
|
946
|
+
export { CLI_VERSION, type CliArgs, EVIDENCE_OVERRIDES, KNOWN_RULE_IDS, OVERLAP_META_BY_RULE_ID, type Output, SUITE_INVALIDATING_RULE_IDS, type ScanHooks, UsageErrorDetail, buildUniversalRules, discoverRuntimeReport, err, exitForFindings, fallbackWorkspace, internalErrorMessage, isEntryPoint, isValidFindingRecord, levenshtein, main, nearestFlags, out, parseArgs, pathMatchesGlob, runBadgeCommand, runBaselineCommand, runCiInstall, runCreateRuleCommand, runDebtCommand, runDiffCommand, runDoctorCommand, runDoctorPlaywright, runExplainCommand, runFixCommand, runForensicsCommand, runHandoverCommand, runHelpCommand, runImpactCommand, runInitCommand, runMutationCommand, runPrCommentCommand, runPwReportCommand, runRulesCommand, runScan, runScanCommand, runStatsCommand, runSuppressions, runTriageCommand, usageErrorMessage };
|
package/dist/cli.mjs
CHANGED
|
@@ -11123,20 +11123,74 @@ function renderFlakyMd(report) {
|
|
|
11123
11123
|
return lines.join("\n");
|
|
11124
11124
|
}
|
|
11125
11125
|
//#endregion
|
|
11126
|
+
//#region src/forensics/parse-jest-json.ts
|
|
11127
|
+
function toStatus$2(raw) {
|
|
11128
|
+
switch (raw) {
|
|
11129
|
+
case "passed": return "passed";
|
|
11130
|
+
case "failed": return "failed";
|
|
11131
|
+
case "skipped":
|
|
11132
|
+
case "pending":
|
|
11133
|
+
case "todo":
|
|
11134
|
+
case "disabled": return "skipped";
|
|
11135
|
+
default: return "interrupted";
|
|
11136
|
+
}
|
|
11137
|
+
}
|
|
11138
|
+
/**
|
|
11139
|
+
* Sniff: does this parsed JSON have the Jest shape? Exported for the
|
|
11140
|
+
* discovery dispatcher (run.ts) — Jest and Vitest share the top-level
|
|
11141
|
+
* `testResults` name; the discriminator is the per-file array's member
|
|
11142
|
+
* name (`testResults` = Jest, `assertionResults` = Vitest).
|
|
11143
|
+
*/
|
|
11144
|
+
function looksLikeJestJson(json) {
|
|
11145
|
+
if (!json || typeof json !== "object") return false;
|
|
11146
|
+
const testResults = json.testResults;
|
|
11147
|
+
if (!Array.isArray(testResults) || testResults.length === 0) return false;
|
|
11148
|
+
return testResults.some((r) => r && typeof r === "object" && Array.isArray(r.testResults));
|
|
11149
|
+
}
|
|
11150
|
+
function parseJestJson(json) {
|
|
11151
|
+
if (!json || typeof json !== "object") return [];
|
|
11152
|
+
const root = json;
|
|
11153
|
+
const files = Array.isArray(root.testResults) ? root.testResults : [];
|
|
11154
|
+
const out = [];
|
|
11155
|
+
for (const fileResult of files.slice(0, 1e4)) {
|
|
11156
|
+
if (!fileResult || typeof fileResult !== "object") continue;
|
|
11157
|
+
const assertions = Array.isArray(fileResult.testResults) ? fileResult.testResults : [];
|
|
11158
|
+
const file = fileResult.testFilePath ?? "unknown";
|
|
11159
|
+
for (const assertion of assertions) {
|
|
11160
|
+
if (!assertion || typeof assertion !== "object") continue;
|
|
11161
|
+
const durationMs = Number.isFinite(assertion.duration) && assertion.duration >= 0 ? Math.round(assertion.duration) : 0;
|
|
11162
|
+
const attempt = {
|
|
11163
|
+
index: 1,
|
|
11164
|
+
status: toStatus$2(assertion.status),
|
|
11165
|
+
durationMs
|
|
11166
|
+
};
|
|
11167
|
+
const line = typeof assertion.location?.line === "number" && assertion.location.line > 0 ? assertion.location.line : void 0;
|
|
11168
|
+
const title = assertion.title ?? "(unnamed)";
|
|
11169
|
+
out.push({
|
|
11170
|
+
file,
|
|
11171
|
+
title,
|
|
11172
|
+
attempts: [attempt],
|
|
11173
|
+
...line !== void 0 ? { line } : {}
|
|
11174
|
+
});
|
|
11175
|
+
}
|
|
11176
|
+
}
|
|
11177
|
+
return out;
|
|
11178
|
+
}
|
|
11179
|
+
//#endregion
|
|
11126
11180
|
//#region src/forensics/parse-junit.ts
|
|
11127
|
-
const MAX_INPUT = 20971520;
|
|
11128
|
-
function decodeEntities(s) {
|
|
11181
|
+
const MAX_INPUT$1 = 20971520;
|
|
11182
|
+
function decodeEntities$1(s) {
|
|
11129
11183
|
return s.replaceAll("<", "<").replaceAll(">", ">").replaceAll(""", "\"").replaceAll("'", "'").replaceAll("'", "'").replaceAll("&", "&");
|
|
11130
11184
|
}
|
|
11131
11185
|
function attr(tag, name) {
|
|
11132
11186
|
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
11133
11187
|
const m = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*"([^"]*)"`, "i").exec(tag);
|
|
11134
|
-
if (m?.[1] !== void 0) return decodeEntities(m[1]);
|
|
11188
|
+
if (m?.[1] !== void 0) return decodeEntities$1(m[1]);
|
|
11135
11189
|
const m2 = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*'([^']*)'`, "i").exec(tag);
|
|
11136
|
-
return m2?.[1] !== void 0 ? decodeEntities(m2[1]) : void 0;
|
|
11190
|
+
return m2?.[1] !== void 0 ? decodeEntities$1(m2[1]) : void 0;
|
|
11137
11191
|
}
|
|
11138
11192
|
function parseJunitXml(xml) {
|
|
11139
|
-
if (xml.length > MAX_INPUT) return [];
|
|
11193
|
+
if (xml.length > MAX_INPUT$1) return [];
|
|
11140
11194
|
const out = [];
|
|
11141
11195
|
const startRe = /<testcase\b/gi;
|
|
11142
11196
|
const closeRe = /<\/testcase\s*>/gi;
|
|
@@ -11178,7 +11232,7 @@ function parseJunitXml(xml) {
|
|
|
11178
11232
|
//#endregion
|
|
11179
11233
|
//#region src/forensics/parse-playwright-json.ts
|
|
11180
11234
|
const MAX_DEPTH = 32;
|
|
11181
|
-
function toStatus(raw) {
|
|
11235
|
+
function toStatus$1(raw) {
|
|
11182
11236
|
switch (raw) {
|
|
11183
11237
|
case "passed": return "passed";
|
|
11184
11238
|
case "failed": return "failed";
|
|
@@ -11211,7 +11265,7 @@ function parsePlaywrightJson(json) {
|
|
|
11211
11265
|
if (!r || typeof r !== "object") continue;
|
|
11212
11266
|
attempts.push({
|
|
11213
11267
|
index: ++i,
|
|
11214
|
-
status: toStatus(r.status),
|
|
11268
|
+
status: toStatus$1(r.status),
|
|
11215
11269
|
durationMs: Number.isFinite(r.duration) ? Math.max(0, r.duration) : 0
|
|
11216
11270
|
});
|
|
11217
11271
|
}
|
|
@@ -11230,6 +11284,54 @@ function parsePlaywrightJson(json) {
|
|
|
11230
11284
|
return out;
|
|
11231
11285
|
}
|
|
11232
11286
|
//#endregion
|
|
11287
|
+
//#region src/forensics/parse-vitest-json.ts
|
|
11288
|
+
function toStatus(raw) {
|
|
11289
|
+
switch (raw) {
|
|
11290
|
+
case "passed": return "passed";
|
|
11291
|
+
case "failed": return "failed";
|
|
11292
|
+
case "skipped":
|
|
11293
|
+
case "pending":
|
|
11294
|
+
case "todo":
|
|
11295
|
+
case "disabled": return "skipped";
|
|
11296
|
+
default: return "interrupted";
|
|
11297
|
+
}
|
|
11298
|
+
}
|
|
11299
|
+
/** Sniff: the Vitest variant of the Jest-shaped report (see module doc). */
|
|
11300
|
+
function looksLikeVitestJson(json) {
|
|
11301
|
+
if (!json || typeof json !== "object") return false;
|
|
11302
|
+
const testResults = json.testResults;
|
|
11303
|
+
if (!Array.isArray(testResults) || testResults.length === 0) return false;
|
|
11304
|
+
return testResults.some((r) => r && typeof r === "object" && Array.isArray(r.assertionResults));
|
|
11305
|
+
}
|
|
11306
|
+
function parseVitestJson(json) {
|
|
11307
|
+
if (!json || typeof json !== "object") return [];
|
|
11308
|
+
const root = json;
|
|
11309
|
+
const files = Array.isArray(root.testResults) ? root.testResults : [];
|
|
11310
|
+
const out = [];
|
|
11311
|
+
for (const fileResult of files.slice(0, 1e4)) {
|
|
11312
|
+
if (!fileResult || typeof fileResult !== "object") continue;
|
|
11313
|
+
const assertions = Array.isArray(fileResult.assertionResults) ? fileResult.assertionResults : [];
|
|
11314
|
+
const file = fileResult.filepath ?? "unknown";
|
|
11315
|
+
for (const assertion of assertions) {
|
|
11316
|
+
if (!assertion || typeof assertion !== "object") continue;
|
|
11317
|
+
const durationMs = Number.isFinite(assertion.duration) && assertion.duration >= 0 ? Math.round(assertion.duration) : 0;
|
|
11318
|
+
const attempt = {
|
|
11319
|
+
index: 1,
|
|
11320
|
+
status: toStatus(assertion.status),
|
|
11321
|
+
durationMs
|
|
11322
|
+
};
|
|
11323
|
+
const line = typeof assertion.location?.line === "number" && assertion.location.line > 0 ? assertion.location.line : void 0;
|
|
11324
|
+
out.push({
|
|
11325
|
+
file,
|
|
11326
|
+
title: assertion.title ?? "(unnamed)",
|
|
11327
|
+
attempts: [attempt],
|
|
11328
|
+
...line !== void 0 ? { line } : {}
|
|
11329
|
+
});
|
|
11330
|
+
}
|
|
11331
|
+
}
|
|
11332
|
+
return out;
|
|
11333
|
+
}
|
|
11334
|
+
//#endregion
|
|
11233
11335
|
//#region src/forensics/run.ts
|
|
11234
11336
|
/**
|
|
11235
11337
|
* `mjolnir forensics <dir-or-file>` — runtime evidence entry point (R4).
|
|
@@ -11237,11 +11339,13 @@ function parsePlaywrightJson(json) {
|
|
|
11237
11339
|
* Accepts either a single report file or a directory. In a directory it
|
|
11238
11340
|
* looks for, in priority order:
|
|
11239
11341
|
* 1. report.json / playwright-report.json — Playwright JSON report
|
|
11240
|
-
* 2.
|
|
11342
|
+
* 2. jest-report.json / vitest-report.json — Jest/Vitest JSON reports
|
|
11343
|
+
* 3. *.json (shape-sniffed: Playwright, Jest, Vitest)
|
|
11344
|
+
* 4. *.xml — JUnit XML reports
|
|
11241
11345
|
*
|
|
11242
11346
|
* Writes FLAKY.md next to the scan target unless --no-flaky-md.
|
|
11243
11347
|
*/
|
|
11244
|
-
const MAX_FILES = 500;
|
|
11348
|
+
const MAX_FILES$1 = 500;
|
|
11245
11349
|
function runForensics(target, options = {}) {
|
|
11246
11350
|
const records = [];
|
|
11247
11351
|
let source = "playwright-json";
|
|
@@ -11265,7 +11369,7 @@ function runForensics(target, options = {}) {
|
|
|
11265
11369
|
else {
|
|
11266
11370
|
let count = 0;
|
|
11267
11371
|
for (const full of listFiles(target)) {
|
|
11268
|
-
if (++count > MAX_FILES) break;
|
|
11372
|
+
if (++count > MAX_FILES$1) break;
|
|
11269
11373
|
try {
|
|
11270
11374
|
const parsed = parseFile(full, readFileSync(full, "utf8"));
|
|
11271
11375
|
if (parsed.records.length === 0) continue;
|
|
@@ -11308,22 +11412,32 @@ function parseFile(path, text) {
|
|
|
11308
11412
|
records: parseJunitXml(text),
|
|
11309
11413
|
source: "junit-xml"
|
|
11310
11414
|
};
|
|
11415
|
+
let json;
|
|
11311
11416
|
try {
|
|
11312
|
-
|
|
11313
|
-
records: parsePlaywrightJson(JSON.parse(text)),
|
|
11314
|
-
source: "playwright-json"
|
|
11315
|
-
};
|
|
11417
|
+
json = JSON.parse(text);
|
|
11316
11418
|
} catch {
|
|
11317
11419
|
return {
|
|
11318
11420
|
records: [],
|
|
11319
11421
|
source: "playwright-json"
|
|
11320
11422
|
};
|
|
11321
11423
|
}
|
|
11424
|
+
if (looksLikeJestJson(json)) return {
|
|
11425
|
+
records: parseJestJson(json),
|
|
11426
|
+
source: "jest-json"
|
|
11427
|
+
};
|
|
11428
|
+
if (looksLikeVitestJson(json)) return {
|
|
11429
|
+
records: parseVitestJson(json),
|
|
11430
|
+
source: "vitest-json"
|
|
11431
|
+
};
|
|
11432
|
+
return {
|
|
11433
|
+
records: parsePlaywrightJson(json),
|
|
11434
|
+
source: "playwright-json"
|
|
11435
|
+
};
|
|
11322
11436
|
}
|
|
11323
11437
|
function listFiles(dir) {
|
|
11324
11438
|
const out = [];
|
|
11325
11439
|
const walk = (d, depth) => {
|
|
11326
|
-
if (depth > 4 || out.length > MAX_FILES) return;
|
|
11440
|
+
if (depth > 4 || out.length > MAX_FILES$1) return;
|
|
11327
11441
|
let entries;
|
|
11328
11442
|
try {
|
|
11329
11443
|
entries = readdirSync(d, { withFileTypes: true });
|
|
@@ -13350,7 +13464,7 @@ function renderSarif(result, repoRootUri) {
|
|
|
13350
13464
|
tool: { driver: {
|
|
13351
13465
|
name: "Mjölnir",
|
|
13352
13466
|
informationUri: "https://github.com/Sergey-Bar/Mjolnir",
|
|
13353
|
-
version: "0.5.
|
|
13467
|
+
version: "0.5.37",
|
|
13354
13468
|
rules: [...rules.values()].map((r) => {
|
|
13355
13469
|
const meta = RULES.find((x) => x.id === r.id);
|
|
13356
13470
|
return {
|
|
@@ -16200,6 +16314,220 @@ function renderTriageMd(report) {
|
|
|
16200
16314
|
return lines.join("\n");
|
|
16201
16315
|
}
|
|
16202
16316
|
//#endregion
|
|
16317
|
+
//#region src/mutation/parse-stryker.ts
|
|
16318
|
+
const MAX_FILES = 5e3;
|
|
16319
|
+
const MAX_MUTANTS_PER_FILE = 2e4;
|
|
16320
|
+
/** Sniff: does this parsed JSON look like a Stryker mutation report? */
|
|
16321
|
+
function looksLikeStrykerJson(json) {
|
|
16322
|
+
if (!json || typeof json !== "object") return false;
|
|
16323
|
+
return typeof json.files === "object" && json.files !== null;
|
|
16324
|
+
}
|
|
16325
|
+
function parseStrykerJson(json) {
|
|
16326
|
+
if (!json || typeof json !== "object") return {
|
|
16327
|
+
tool: "stryker",
|
|
16328
|
+
survived: [],
|
|
16329
|
+
noCoverage: 0,
|
|
16330
|
+
killed: 0
|
|
16331
|
+
};
|
|
16332
|
+
const files = json.files;
|
|
16333
|
+
if (!files || typeof files !== "object") return {
|
|
16334
|
+
tool: "stryker",
|
|
16335
|
+
survived: [],
|
|
16336
|
+
noCoverage: 0,
|
|
16337
|
+
killed: 0
|
|
16338
|
+
};
|
|
16339
|
+
const survived = [];
|
|
16340
|
+
let noCoverage = 0;
|
|
16341
|
+
let killed = 0;
|
|
16342
|
+
const entries = Object.entries(files).slice(0, MAX_FILES);
|
|
16343
|
+
for (const [rawPath, file] of entries) {
|
|
16344
|
+
if (!file || typeof file !== "object") continue;
|
|
16345
|
+
const mutants = Array.isArray(file.mutants) ? file.mutants : [];
|
|
16346
|
+
const path = rawPath.replaceAll("\\", "/");
|
|
16347
|
+
for (const m of mutants.slice(0, MAX_MUTANTS_PER_FILE)) {
|
|
16348
|
+
if (!m || typeof m !== "object") continue;
|
|
16349
|
+
if (m.status === "Survived") {
|
|
16350
|
+
const startLine = toOneBased(m.location?.start?.line);
|
|
16351
|
+
const endLine = toOneBased(m.location?.end?.line);
|
|
16352
|
+
if (startLine === void 0) continue;
|
|
16353
|
+
survived.push({
|
|
16354
|
+
file: path,
|
|
16355
|
+
mutator: typeof m.mutatorName === "string" ? m.mutatorName : "unknown",
|
|
16356
|
+
startLine,
|
|
16357
|
+
endLine: endLine !== void 0 ? Math.max(startLine, endLine) : startLine
|
|
16358
|
+
});
|
|
16359
|
+
} else if (m.status === "NoCoverage") noCoverage++;
|
|
16360
|
+
else if (m.status === "Killed" || m.status === "Timeout") killed++;
|
|
16361
|
+
}
|
|
16362
|
+
}
|
|
16363
|
+
return {
|
|
16364
|
+
tool: "stryker",
|
|
16365
|
+
survived,
|
|
16366
|
+
noCoverage,
|
|
16367
|
+
killed
|
|
16368
|
+
};
|
|
16369
|
+
}
|
|
16370
|
+
function toOneBased(line) {
|
|
16371
|
+
return typeof line === "number" && line >= 0 ? line + 1 : void 0;
|
|
16372
|
+
}
|
|
16373
|
+
//#endregion
|
|
16374
|
+
//#region src/mutation/parse-mutmut.ts
|
|
16375
|
+
const MAX_INPUT = 20971520;
|
|
16376
|
+
/** Sniff: does this XML look like mutmut's junit output? */
|
|
16377
|
+
function looksLikeMutmutXml(xml) {
|
|
16378
|
+
return xml.length <= MAX_INPUT && /mutmut/i.test(xml.slice(0, 4e3));
|
|
16379
|
+
}
|
|
16380
|
+
function decodeEntities(s) {
|
|
16381
|
+
return s.replaceAll("<", "<").replaceAll(">", ">").replaceAll(""", "\"").replaceAll("'", "'").replaceAll("'", "'").replaceAll("&", "&");
|
|
16382
|
+
}
|
|
16383
|
+
/**
|
|
16384
|
+
* The file for a testcase: mutmut 2.x writes
|
|
16385
|
+
* `classname="tests.test_auth-mutmut0::x_mutmut_1"`-style classes whose
|
|
16386
|
+
* FIRST dot-separated segment before `-mutmut` is the module path;
|
|
16387
|
+
* mutmut 3.x writes the path directly in `classname`. Both are
|
|
16388
|
+
* normalized here to a repo-relative path with `.py` intact.
|
|
16389
|
+
*/
|
|
16390
|
+
function fileFromClassname(classname) {
|
|
16391
|
+
const decoded = decodeEntities(classname);
|
|
16392
|
+
const cut = decoded.indexOf("-mutmut");
|
|
16393
|
+
const candidate = (cut !== -1 ? decoded.slice(0, cut) : decoded).trim();
|
|
16394
|
+
if (candidate.length === 0) return null;
|
|
16395
|
+
return candidate.replaceAll(".", "/") + ".py";
|
|
16396
|
+
}
|
|
16397
|
+
function parseMutmutXml(xml) {
|
|
16398
|
+
if (xml.length > MAX_INPUT) return {
|
|
16399
|
+
tool: "mutmut",
|
|
16400
|
+
survived: [],
|
|
16401
|
+
noCoverage: 0,
|
|
16402
|
+
killed: 0
|
|
16403
|
+
};
|
|
16404
|
+
const survived = [];
|
|
16405
|
+
let noCoverage = 0;
|
|
16406
|
+
let killed = 0;
|
|
16407
|
+
const startRe = /<testcase\b/gi;
|
|
16408
|
+
const closeRe = /<\/testcase\s*>/gi;
|
|
16409
|
+
let start;
|
|
16410
|
+
while ((start = startRe.exec(xml)) !== null) {
|
|
16411
|
+
const gt = xml.indexOf(">", start.index);
|
|
16412
|
+
if (gt === -1) break;
|
|
16413
|
+
const attrs = xml.slice(start.index + 9, gt);
|
|
16414
|
+
let inner;
|
|
16415
|
+
if (attrs.endsWith("/")) {
|
|
16416
|
+
inner = "";
|
|
16417
|
+
startRe.lastIndex = gt + 1;
|
|
16418
|
+
} else {
|
|
16419
|
+
closeRe.lastIndex = gt + 1;
|
|
16420
|
+
const close = closeRe.exec(xml);
|
|
16421
|
+
if (close === null) break;
|
|
16422
|
+
inner = xml.slice(gt + 1, close.index);
|
|
16423
|
+
startRe.lastIndex = close.index + close[0].length;
|
|
16424
|
+
}
|
|
16425
|
+
const classname = /(?:^|\s)classname\s*=\s*"([^"]*)"/i.exec(attrs)?.[1] ?? "";
|
|
16426
|
+
if (/<skipped\b/i.test(inner)) {
|
|
16427
|
+
noCoverage++;
|
|
16428
|
+
continue;
|
|
16429
|
+
}
|
|
16430
|
+
const killedMutant = /<(?:failure|error)\b/i.test(inner);
|
|
16431
|
+
const verdict = {
|
|
16432
|
+
file: fileFromClassname(classname),
|
|
16433
|
+
survived: !killedMutant
|
|
16434
|
+
};
|
|
16435
|
+
if (killedMutant) killed++;
|
|
16436
|
+
else if (verdict.file !== null) survived.push({
|
|
16437
|
+
file: verdict.file,
|
|
16438
|
+
mutator: "mutmut",
|
|
16439
|
+
startLine: 1,
|
|
16440
|
+
endLine: Number.MAX_SAFE_INTEGER
|
|
16441
|
+
});
|
|
16442
|
+
else noCoverage++;
|
|
16443
|
+
}
|
|
16444
|
+
return {
|
|
16445
|
+
tool: "mutmut",
|
|
16446
|
+
survived,
|
|
16447
|
+
noCoverage,
|
|
16448
|
+
killed
|
|
16449
|
+
};
|
|
16450
|
+
}
|
|
16451
|
+
//#endregion
|
|
16452
|
+
//#region src/mutation/derive.ts
|
|
16453
|
+
/**
|
|
16454
|
+
* Stamp mutation evidence onto findings (mutates in place, the same
|
|
16455
|
+
* contract as stampEvidenceLevels). Returns the stamp stats.
|
|
16456
|
+
*/
|
|
16457
|
+
function stampMutationEvidence(findings, report) {
|
|
16458
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
16459
|
+
for (const m of report.survived) {
|
|
16460
|
+
const list = byFile.get(m.file) ?? [];
|
|
16461
|
+
list.push(m);
|
|
16462
|
+
byFile.set(m.file, list);
|
|
16463
|
+
}
|
|
16464
|
+
let stamped = 0;
|
|
16465
|
+
let derived = 0;
|
|
16466
|
+
for (const f of findings) {
|
|
16467
|
+
const mutants = byFile.get(f.file);
|
|
16468
|
+
if (!mutants || mutants.length === 0) continue;
|
|
16469
|
+
let matched = mutants.filter((m) => f.line >= m.startLine && f.line <= m.endLine);
|
|
16470
|
+
let granularity = "line";
|
|
16471
|
+
if (matched.length === 0) {
|
|
16472
|
+
matched = mutants;
|
|
16473
|
+
granularity = "file";
|
|
16474
|
+
}
|
|
16475
|
+
f.mutationEvidence = {
|
|
16476
|
+
source: report.tool,
|
|
16477
|
+
matchedMutants: matched.length,
|
|
16478
|
+
mutators: [...new Set(matched.map((m) => m.mutator))].sort(),
|
|
16479
|
+
granularity
|
|
16480
|
+
};
|
|
16481
|
+
stamped++;
|
|
16482
|
+
if (f.evidenceLevel === "E1") {
|
|
16483
|
+
f.evidenceLevel = "E2";
|
|
16484
|
+
derived++;
|
|
16485
|
+
}
|
|
16486
|
+
}
|
|
16487
|
+
return {
|
|
16488
|
+
stamped,
|
|
16489
|
+
derived
|
|
16490
|
+
};
|
|
16491
|
+
}
|
|
16492
|
+
/**
|
|
16493
|
+
* Survived-mutant leaderboard: most-mutated files first. The summary's
|
|
16494
|
+
* honesty headline: files here are code the suite demonstrably does not
|
|
16495
|
+
* constrain — regardless of what any static tool found.
|
|
16496
|
+
*/
|
|
16497
|
+
function survivedLeaderboard(report, top = 25) {
|
|
16498
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
16499
|
+
for (const m of report.survived) {
|
|
16500
|
+
const list = byFile.get(m.file) ?? [];
|
|
16501
|
+
list.push(m);
|
|
16502
|
+
byFile.set(m.file, list);
|
|
16503
|
+
}
|
|
16504
|
+
return [...byFile.entries()].map(([file, mutants]) => ({
|
|
16505
|
+
file,
|
|
16506
|
+
survived: mutants.length,
|
|
16507
|
+
mutators: [...new Set(mutants.map((m) => m.mutator))].sort()
|
|
16508
|
+
})).sort((a, b) => b.survived - a.survived).slice(0, top);
|
|
16509
|
+
}
|
|
16510
|
+
function renderMutationSummary(report) {
|
|
16511
|
+
const lines = [];
|
|
16512
|
+
lines.push(`MUTATION EVIDENCE — ${report.tool}`);
|
|
16513
|
+
lines.push("");
|
|
16514
|
+
lines.push(`${report.survived.length} survived · ${report.killed} killed · ${report.noCoverage} no-coverage`);
|
|
16515
|
+
lines.push("");
|
|
16516
|
+
const top = survivedLeaderboard(report);
|
|
16517
|
+
if (top.length === 0) {
|
|
16518
|
+
lines.push("No survived mutants — every executed mutant was killed.");
|
|
16519
|
+
lines.push("");
|
|
16520
|
+
lines.push("Interpretation: the suite constrains the code the mutation run reached. This is evidence about THOSE mutants only — untested surfaces are the no-coverage column, and no-coverage is not survived.");
|
|
16521
|
+
return lines.join("\n");
|
|
16522
|
+
}
|
|
16523
|
+
lines.push("Files the suite does not constrain (most survived first):");
|
|
16524
|
+
lines.push("");
|
|
16525
|
+
for (const row of top) lines.push(` ${row.survived}× ${row.file} [${row.mutators.join(", ")}]`);
|
|
16526
|
+
lines.push("");
|
|
16527
|
+
lines.push("A survived mutant is not a bug — it is code the suite would not notice changing. Findings near these lines were consolidated E1→E2 by derivation (see docs/RULE-LIFECYCLE.md).");
|
|
16528
|
+
return lines.join("\n");
|
|
16529
|
+
}
|
|
16530
|
+
//#endregion
|
|
16203
16531
|
//#region src/commands/badge.ts
|
|
16204
16532
|
/**
|
|
16205
16533
|
* `mjolnir badge` — evidentiary shields.io endpoint JSON (Tier 1 #5).
|
|
@@ -16308,6 +16636,12 @@ const HELP_ENTRIES = [
|
|
|
16308
16636
|
usage: "mjolnir triage <test-results-dir-or-report-file> [--no-md]",
|
|
16309
16637
|
examples: ["mjolnir triage test-results --no-md"]
|
|
16310
16638
|
},
|
|
16639
|
+
{
|
|
16640
|
+
verb: "mutation",
|
|
16641
|
+
summary: "mutation-evidence reader: survived-mutant leaderboard + E1→E2 derivation",
|
|
16642
|
+
usage: "mjolnir mutation <mutation-report> [--scan .]",
|
|
16643
|
+
examples: ["mjolnir mutation reports/mutation-report.json", "mjolnir mutation reports/… --scan ."]
|
|
16644
|
+
},
|
|
16311
16645
|
{
|
|
16312
16646
|
verb: "pw-report",
|
|
16313
16647
|
summary: "Playwright run summary (counts, true flakes, slowest tests)",
|
|
@@ -16590,6 +16924,10 @@ const GROUPS = [
|
|
|
16590
16924
|
"doctor:playwright"
|
|
16591
16925
|
]
|
|
16592
16926
|
},
|
|
16927
|
+
{
|
|
16928
|
+
title: "Mutation evidence",
|
|
16929
|
+
verbs: ["mutation"]
|
|
16930
|
+
},
|
|
16593
16931
|
{
|
|
16594
16932
|
title: "Maintenance",
|
|
16595
16933
|
verbs: [
|
|
@@ -18908,7 +19246,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
|
|
|
18908
19246
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
18909
19247
|
* `tests/version-consistency.spec.ts` locally.
|
|
18910
19248
|
*/
|
|
18911
|
-
const CLI_VERSION = "0.5.
|
|
19249
|
+
const CLI_VERSION = "0.5.37";
|
|
18912
19250
|
function parseArgs(argv, onError) {
|
|
18913
19251
|
const args = {
|
|
18914
19252
|
target: ".",
|
|
@@ -19394,6 +19732,97 @@ function runTriageCommand(argv, io = {
|
|
|
19394
19732
|
return 20;
|
|
19395
19733
|
}
|
|
19396
19734
|
}
|
|
19735
|
+
/**
|
|
19736
|
+
* Sniff + parse a mutation report: Stryker JSON, mutmut junitxml, else
|
|
19737
|
+
* an empty report (the caller renders the honest "nothing recognized").
|
|
19738
|
+
*/
|
|
19739
|
+
function ingestMutationReport(text) {
|
|
19740
|
+
const trimmed = text.trimStart();
|
|
19741
|
+
if (trimmed.startsWith("<?xml") || trimmed.startsWith("<testsuite")) return parseMutmutXml(text);
|
|
19742
|
+
try {
|
|
19743
|
+
const json = JSON.parse(text);
|
|
19744
|
+
if (looksLikeStrykerJson(json)) return parseStrykerJson(json);
|
|
19745
|
+
} catch {}
|
|
19746
|
+
if (looksLikeMutmutXml(text)) return parseMutmutXml(text);
|
|
19747
|
+
return {
|
|
19748
|
+
tool: "stryker",
|
|
19749
|
+
survived: [],
|
|
19750
|
+
noCoverage: 0,
|
|
19751
|
+
killed: 0
|
|
19752
|
+
};
|
|
19753
|
+
}
|
|
19754
|
+
/** Testable `mutation` handler (master plan P5, plan 1788853205786).
|
|
19755
|
+
* Reads a mutation report (Stryker JSON / mutmut junitxml), renders the
|
|
19756
|
+
* survived-mutant leaderboard, and — with `--scan <path>` — re-scans the
|
|
19757
|
+
* target, stamps matching findings with `mutationEvidence` (E1→E2 by
|
|
19758
|
+
* derivation) and renders the stamped findings. NEVER spawns mutation
|
|
19759
|
+
* tools: it reads reports the user already produced. Exit 2 when
|
|
19760
|
+
* nothing in the report is recognized — honest no-evidence, not an
|
|
19761
|
+
* error. Exit 0 in every other case: the reader is report-only, never a
|
|
19762
|
+
* gate. */
|
|
19763
|
+
async function runMutationCommand(argv, io = {
|
|
19764
|
+
out,
|
|
19765
|
+
err
|
|
19766
|
+
}) {
|
|
19767
|
+
const targetArg = argv.find((a) => !a.startsWith("-"));
|
|
19768
|
+
if (!targetArg) {
|
|
19769
|
+
io.err("Usage: mjolnir mutation <mutation-report> [--scan <path>]");
|
|
19770
|
+
return 10;
|
|
19771
|
+
}
|
|
19772
|
+
const scanIdx = argv.indexOf("--scan");
|
|
19773
|
+
const scanArg = scanIdx !== -1 ? argv[scanIdx + 1] : void 0;
|
|
19774
|
+
if (scanIdx !== -1 && (!scanArg || scanArg.startsWith("-"))) {
|
|
19775
|
+
io.err("--scan requires a path argument");
|
|
19776
|
+
return 10;
|
|
19777
|
+
}
|
|
19778
|
+
let report;
|
|
19779
|
+
try {
|
|
19780
|
+
const path = resolve(targetArg);
|
|
19781
|
+
if (!existsSync(path)) {
|
|
19782
|
+
io.err(`No such file: ${path}`);
|
|
19783
|
+
return 2;
|
|
19784
|
+
}
|
|
19785
|
+
report = ingestMutationReport(readFileSync(path, "utf8"));
|
|
19786
|
+
if (report.survived.length === 0 && report.killed === 0 && report.noCoverage === 0) {
|
|
19787
|
+
io.err("No mutants recognized. Expected a Stryker JSON report (mutation-report.json) or a mutmut junitxml report.");
|
|
19788
|
+
return 2;
|
|
19789
|
+
}
|
|
19790
|
+
} catch (err) {
|
|
19791
|
+
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
19792
|
+
return 20;
|
|
19793
|
+
}
|
|
19794
|
+
io.out(renderMutationSummary(report));
|
|
19795
|
+
if (scanArg === void 0) return 0;
|
|
19796
|
+
try {
|
|
19797
|
+
const scanPath = resolve(scanArg);
|
|
19798
|
+
const invalid = validateScanTarget(scanPath, io.err);
|
|
19799
|
+
if (invalid !== null) return invalid;
|
|
19800
|
+
const result = await runScan({
|
|
19801
|
+
target: scanPath,
|
|
19802
|
+
json: true,
|
|
19803
|
+
verbose: true,
|
|
19804
|
+
maxDurationMs: 12e4,
|
|
19805
|
+
scopeChanged: false,
|
|
19806
|
+
format: "json"
|
|
19807
|
+
});
|
|
19808
|
+
const stats = stampMutationEvidence(result.findings, report);
|
|
19809
|
+
io.out("");
|
|
19810
|
+
if (stats.stamped === 0) io.out("No findings intersect the survived-mutant surface — nothing to derive.");
|
|
19811
|
+
else {
|
|
19812
|
+
io.out(`${stats.stamped} finding(s) carry mutationEvidence (${stats.derived} consolidated E1→E2 by derivation — docs/RULE-LIFECYCLE.md):`);
|
|
19813
|
+
for (const f of result.findings) {
|
|
19814
|
+
if (!f.mutationEvidence) continue;
|
|
19815
|
+
io.out(` ${f.ruleId} ${f.file}:${f.line} — ${f.evidenceLevel} · ${f.mutationEvidence.matchedMutants} mutant(s) @ ${f.mutationEvidence.granularity} granularity`);
|
|
19816
|
+
}
|
|
19817
|
+
io.out("");
|
|
19818
|
+
io.out("Machine form: re-run with --json — mutationEvidence rides the findings additively.");
|
|
19819
|
+
}
|
|
19820
|
+
return 0;
|
|
19821
|
+
} catch (err) {
|
|
19822
|
+
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
19823
|
+
return 20;
|
|
19824
|
+
}
|
|
19825
|
+
}
|
|
19397
19826
|
/** Testable `badge` handler (Tier 1 #5). */
|
|
19398
19827
|
async function runBadgeCommand(argv, io = {
|
|
19399
19828
|
out,
|
|
@@ -19772,6 +20201,7 @@ async function main(argv = process$1.argv.slice(2), io = {
|
|
|
19772
20201
|
if (argv[0] === "suppressions") return runSuppressions();
|
|
19773
20202
|
if (argv[0] === "forensics") return runForensicsCommand(argv.slice(1));
|
|
19774
20203
|
if (argv[0] === "triage") return runTriageCommand(argv.slice(1));
|
|
20204
|
+
if (argv[0] === "mutation") return runMutationCommand(argv.slice(1), io);
|
|
19775
20205
|
if (argv[0] === "badge") return runBadgeCommand(argv.slice(1));
|
|
19776
20206
|
if (argv[0] === "debt") return runDebtCommand(argv.slice(1));
|
|
19777
20207
|
if (argv[0] === "impact") return runImpactCommand(argv.slice(1));
|
|
@@ -19848,4 +20278,4 @@ function isEntryPoint() {
|
|
|
19848
20278
|
}
|
|
19849
20279
|
if (isEntryPoint()) process$1.exitCode = await main();
|
|
19850
20280
|
//#endregion
|
|
19851
|
-
export { CLI_VERSION, EVIDENCE_OVERRIDES, KNOWN_RULE_IDS, OVERLAP_META_BY_RULE_ID, SUITE_INVALIDATING_RULE_IDS, buildUniversalRules, discoverRuntimeReport, err, exitForFindings, fallbackWorkspace, internalErrorMessage, isEntryPoint, isValidFindingRecord, levenshtein, main, nearestFlags, out, parseArgs, pathMatchesGlob, runBadgeCommand, runBaselineCommand, runCiInstall, runCreateRuleCommand, runDebtCommand, runDiffCommand, runDoctorCommand, runDoctorPlaywright, runExplainCommand, runFixCommand, runForensicsCommand, runHandoverCommand, runHelpCommand, runImpactCommand, runInitCommand, runPrCommentCommand, runPwReportCommand, runRulesCommand, runScan, runScanCommand, runStatsCommand, runSuppressions, runTriageCommand, usageErrorMessage };
|
|
20281
|
+
export { CLI_VERSION, EVIDENCE_OVERRIDES, KNOWN_RULE_IDS, OVERLAP_META_BY_RULE_ID, SUITE_INVALIDATING_RULE_IDS, buildUniversalRules, discoverRuntimeReport, err, exitForFindings, fallbackWorkspace, internalErrorMessage, isEntryPoint, isValidFindingRecord, levenshtein, main, nearestFlags, out, parseArgs, pathMatchesGlob, runBadgeCommand, runBaselineCommand, runCiInstall, runCreateRuleCommand, runDebtCommand, runDiffCommand, runDoctorCommand, runDoctorPlaywright, runExplainCommand, runFixCommand, runForensicsCommand, runHandoverCommand, runHelpCommand, runImpactCommand, runInitCommand, runMutationCommand, runPrCommentCommand, runPwReportCommand, runRulesCommand, runScan, runScanCommand, runStatsCommand, runSuppressions, runTriageCommand, usageErrorMessage };
|
package/dist/mcp/stdio.mjs
CHANGED
|
@@ -11122,20 +11122,74 @@ function renderFlakyMd(report) {
|
|
|
11122
11122
|
return lines.join("\n");
|
|
11123
11123
|
}
|
|
11124
11124
|
//#endregion
|
|
11125
|
+
//#region src/forensics/parse-jest-json.ts
|
|
11126
|
+
function toStatus$2(raw) {
|
|
11127
|
+
switch (raw) {
|
|
11128
|
+
case "passed": return "passed";
|
|
11129
|
+
case "failed": return "failed";
|
|
11130
|
+
case "skipped":
|
|
11131
|
+
case "pending":
|
|
11132
|
+
case "todo":
|
|
11133
|
+
case "disabled": return "skipped";
|
|
11134
|
+
default: return "interrupted";
|
|
11135
|
+
}
|
|
11136
|
+
}
|
|
11137
|
+
/**
|
|
11138
|
+
* Sniff: does this parsed JSON have the Jest shape? Exported for the
|
|
11139
|
+
* discovery dispatcher (run.ts) — Jest and Vitest share the top-level
|
|
11140
|
+
* `testResults` name; the discriminator is the per-file array's member
|
|
11141
|
+
* name (`testResults` = Jest, `assertionResults` = Vitest).
|
|
11142
|
+
*/
|
|
11143
|
+
function looksLikeJestJson(json) {
|
|
11144
|
+
if (!json || typeof json !== "object") return false;
|
|
11145
|
+
const testResults = json.testResults;
|
|
11146
|
+
if (!Array.isArray(testResults) || testResults.length === 0) return false;
|
|
11147
|
+
return testResults.some((r) => r && typeof r === "object" && Array.isArray(r.testResults));
|
|
11148
|
+
}
|
|
11149
|
+
function parseJestJson(json) {
|
|
11150
|
+
if (!json || typeof json !== "object") return [];
|
|
11151
|
+
const root = json;
|
|
11152
|
+
const files = Array.isArray(root.testResults) ? root.testResults : [];
|
|
11153
|
+
const out = [];
|
|
11154
|
+
for (const fileResult of files.slice(0, 1e4)) {
|
|
11155
|
+
if (!fileResult || typeof fileResult !== "object") continue;
|
|
11156
|
+
const assertions = Array.isArray(fileResult.testResults) ? fileResult.testResults : [];
|
|
11157
|
+
const file = fileResult.testFilePath ?? "unknown";
|
|
11158
|
+
for (const assertion of assertions) {
|
|
11159
|
+
if (!assertion || typeof assertion !== "object") continue;
|
|
11160
|
+
const durationMs = Number.isFinite(assertion.duration) && assertion.duration >= 0 ? Math.round(assertion.duration) : 0;
|
|
11161
|
+
const attempt = {
|
|
11162
|
+
index: 1,
|
|
11163
|
+
status: toStatus$2(assertion.status),
|
|
11164
|
+
durationMs
|
|
11165
|
+
};
|
|
11166
|
+
const line = typeof assertion.location?.line === "number" && assertion.location.line > 0 ? assertion.location.line : void 0;
|
|
11167
|
+
const title = assertion.title ?? "(unnamed)";
|
|
11168
|
+
out.push({
|
|
11169
|
+
file,
|
|
11170
|
+
title,
|
|
11171
|
+
attempts: [attempt],
|
|
11172
|
+
...line !== void 0 ? { line } : {}
|
|
11173
|
+
});
|
|
11174
|
+
}
|
|
11175
|
+
}
|
|
11176
|
+
return out;
|
|
11177
|
+
}
|
|
11178
|
+
//#endregion
|
|
11125
11179
|
//#region src/forensics/parse-junit.ts
|
|
11126
|
-
const MAX_INPUT = 20971520;
|
|
11127
|
-
function decodeEntities(s) {
|
|
11180
|
+
const MAX_INPUT$1 = 20971520;
|
|
11181
|
+
function decodeEntities$1(s) {
|
|
11128
11182
|
return s.replaceAll("<", "<").replaceAll(">", ">").replaceAll(""", "\"").replaceAll("'", "'").replaceAll("'", "'").replaceAll("&", "&");
|
|
11129
11183
|
}
|
|
11130
11184
|
function attr(tag, name) {
|
|
11131
11185
|
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
11132
11186
|
const m = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*"([^"]*)"`, "i").exec(tag);
|
|
11133
|
-
if (m?.[1] !== void 0) return decodeEntities(m[1]);
|
|
11187
|
+
if (m?.[1] !== void 0) return decodeEntities$1(m[1]);
|
|
11134
11188
|
const m2 = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*'([^']*)'`, "i").exec(tag);
|
|
11135
|
-
return m2?.[1] !== void 0 ? decodeEntities(m2[1]) : void 0;
|
|
11189
|
+
return m2?.[1] !== void 0 ? decodeEntities$1(m2[1]) : void 0;
|
|
11136
11190
|
}
|
|
11137
11191
|
function parseJunitXml(xml) {
|
|
11138
|
-
if (xml.length > MAX_INPUT) return [];
|
|
11192
|
+
if (xml.length > MAX_INPUT$1) return [];
|
|
11139
11193
|
const out = [];
|
|
11140
11194
|
const startRe = /<testcase\b/gi;
|
|
11141
11195
|
const closeRe = /<\/testcase\s*>/gi;
|
|
@@ -11177,7 +11231,7 @@ function parseJunitXml(xml) {
|
|
|
11177
11231
|
//#endregion
|
|
11178
11232
|
//#region src/forensics/parse-playwright-json.ts
|
|
11179
11233
|
const MAX_DEPTH = 32;
|
|
11180
|
-
function toStatus(raw) {
|
|
11234
|
+
function toStatus$1(raw) {
|
|
11181
11235
|
switch (raw) {
|
|
11182
11236
|
case "passed": return "passed";
|
|
11183
11237
|
case "failed": return "failed";
|
|
@@ -11210,7 +11264,7 @@ function parsePlaywrightJson(json) {
|
|
|
11210
11264
|
if (!r || typeof r !== "object") continue;
|
|
11211
11265
|
attempts.push({
|
|
11212
11266
|
index: ++i,
|
|
11213
|
-
status: toStatus(r.status),
|
|
11267
|
+
status: toStatus$1(r.status),
|
|
11214
11268
|
durationMs: Number.isFinite(r.duration) ? Math.max(0, r.duration) : 0
|
|
11215
11269
|
});
|
|
11216
11270
|
}
|
|
@@ -11229,6 +11283,54 @@ function parsePlaywrightJson(json) {
|
|
|
11229
11283
|
return out;
|
|
11230
11284
|
}
|
|
11231
11285
|
//#endregion
|
|
11286
|
+
//#region src/forensics/parse-vitest-json.ts
|
|
11287
|
+
function toStatus(raw) {
|
|
11288
|
+
switch (raw) {
|
|
11289
|
+
case "passed": return "passed";
|
|
11290
|
+
case "failed": return "failed";
|
|
11291
|
+
case "skipped":
|
|
11292
|
+
case "pending":
|
|
11293
|
+
case "todo":
|
|
11294
|
+
case "disabled": return "skipped";
|
|
11295
|
+
default: return "interrupted";
|
|
11296
|
+
}
|
|
11297
|
+
}
|
|
11298
|
+
/** Sniff: the Vitest variant of the Jest-shaped report (see module doc). */
|
|
11299
|
+
function looksLikeVitestJson(json) {
|
|
11300
|
+
if (!json || typeof json !== "object") return false;
|
|
11301
|
+
const testResults = json.testResults;
|
|
11302
|
+
if (!Array.isArray(testResults) || testResults.length === 0) return false;
|
|
11303
|
+
return testResults.some((r) => r && typeof r === "object" && Array.isArray(r.assertionResults));
|
|
11304
|
+
}
|
|
11305
|
+
function parseVitestJson(json) {
|
|
11306
|
+
if (!json || typeof json !== "object") return [];
|
|
11307
|
+
const root = json;
|
|
11308
|
+
const files = Array.isArray(root.testResults) ? root.testResults : [];
|
|
11309
|
+
const out = [];
|
|
11310
|
+
for (const fileResult of files.slice(0, 1e4)) {
|
|
11311
|
+
if (!fileResult || typeof fileResult !== "object") continue;
|
|
11312
|
+
const assertions = Array.isArray(fileResult.assertionResults) ? fileResult.assertionResults : [];
|
|
11313
|
+
const file = fileResult.filepath ?? "unknown";
|
|
11314
|
+
for (const assertion of assertions) {
|
|
11315
|
+
if (!assertion || typeof assertion !== "object") continue;
|
|
11316
|
+
const durationMs = Number.isFinite(assertion.duration) && assertion.duration >= 0 ? Math.round(assertion.duration) : 0;
|
|
11317
|
+
const attempt = {
|
|
11318
|
+
index: 1,
|
|
11319
|
+
status: toStatus(assertion.status),
|
|
11320
|
+
durationMs
|
|
11321
|
+
};
|
|
11322
|
+
const line = typeof assertion.location?.line === "number" && assertion.location.line > 0 ? assertion.location.line : void 0;
|
|
11323
|
+
out.push({
|
|
11324
|
+
file,
|
|
11325
|
+
title: assertion.title ?? "(unnamed)",
|
|
11326
|
+
attempts: [attempt],
|
|
11327
|
+
...line !== void 0 ? { line } : {}
|
|
11328
|
+
});
|
|
11329
|
+
}
|
|
11330
|
+
}
|
|
11331
|
+
return out;
|
|
11332
|
+
}
|
|
11333
|
+
//#endregion
|
|
11232
11334
|
//#region src/forensics/run.ts
|
|
11233
11335
|
/**
|
|
11234
11336
|
* `mjolnir forensics <dir-or-file>` — runtime evidence entry point (R4).
|
|
@@ -11236,11 +11338,13 @@ function parsePlaywrightJson(json) {
|
|
|
11236
11338
|
* Accepts either a single report file or a directory. In a directory it
|
|
11237
11339
|
* looks for, in priority order:
|
|
11238
11340
|
* 1. report.json / playwright-report.json — Playwright JSON report
|
|
11239
|
-
* 2.
|
|
11341
|
+
* 2. jest-report.json / vitest-report.json — Jest/Vitest JSON reports
|
|
11342
|
+
* 3. *.json (shape-sniffed: Playwright, Jest, Vitest)
|
|
11343
|
+
* 4. *.xml — JUnit XML reports
|
|
11240
11344
|
*
|
|
11241
11345
|
* Writes FLAKY.md next to the scan target unless --no-flaky-md.
|
|
11242
11346
|
*/
|
|
11243
|
-
const MAX_FILES = 500;
|
|
11347
|
+
const MAX_FILES$1 = 500;
|
|
11244
11348
|
function runForensics(target, options = {}) {
|
|
11245
11349
|
const records = [];
|
|
11246
11350
|
let source = "playwright-json";
|
|
@@ -11264,7 +11368,7 @@ function runForensics(target, options = {}) {
|
|
|
11264
11368
|
else {
|
|
11265
11369
|
let count = 0;
|
|
11266
11370
|
for (const full of listFiles(target)) {
|
|
11267
|
-
if (++count > MAX_FILES) break;
|
|
11371
|
+
if (++count > MAX_FILES$1) break;
|
|
11268
11372
|
try {
|
|
11269
11373
|
const parsed = parseFile(full, readFileSync(full, "utf8"));
|
|
11270
11374
|
if (parsed.records.length === 0) continue;
|
|
@@ -11307,22 +11411,32 @@ function parseFile(path, text) {
|
|
|
11307
11411
|
records: parseJunitXml(text),
|
|
11308
11412
|
source: "junit-xml"
|
|
11309
11413
|
};
|
|
11414
|
+
let json;
|
|
11310
11415
|
try {
|
|
11311
|
-
|
|
11312
|
-
records: parsePlaywrightJson(JSON.parse(text)),
|
|
11313
|
-
source: "playwright-json"
|
|
11314
|
-
};
|
|
11416
|
+
json = JSON.parse(text);
|
|
11315
11417
|
} catch {
|
|
11316
11418
|
return {
|
|
11317
11419
|
records: [],
|
|
11318
11420
|
source: "playwright-json"
|
|
11319
11421
|
};
|
|
11320
11422
|
}
|
|
11423
|
+
if (looksLikeJestJson(json)) return {
|
|
11424
|
+
records: parseJestJson(json),
|
|
11425
|
+
source: "jest-json"
|
|
11426
|
+
};
|
|
11427
|
+
if (looksLikeVitestJson(json)) return {
|
|
11428
|
+
records: parseVitestJson(json),
|
|
11429
|
+
source: "vitest-json"
|
|
11430
|
+
};
|
|
11431
|
+
return {
|
|
11432
|
+
records: parsePlaywrightJson(json),
|
|
11433
|
+
source: "playwright-json"
|
|
11434
|
+
};
|
|
11321
11435
|
}
|
|
11322
11436
|
function listFiles(dir) {
|
|
11323
11437
|
const out = [];
|
|
11324
11438
|
const walk = (d, depth) => {
|
|
11325
|
-
if (depth > 4 || out.length > MAX_FILES) return;
|
|
11439
|
+
if (depth > 4 || out.length > MAX_FILES$1) return;
|
|
11326
11440
|
let entries;
|
|
11327
11441
|
try {
|
|
11328
11442
|
entries = readdirSync(d, { withFileTypes: true });
|
|
@@ -13349,7 +13463,7 @@ function renderSarif(result, repoRootUri) {
|
|
|
13349
13463
|
tool: { driver: {
|
|
13350
13464
|
name: "Mjölnir",
|
|
13351
13465
|
informationUri: "https://github.com/Sergey-Bar/Mjolnir",
|
|
13352
|
-
version: "0.5.
|
|
13466
|
+
version: "0.5.37",
|
|
13353
13467
|
rules: [...rules.values()].map((r) => {
|
|
13354
13468
|
const meta = RULES.find((x) => x.id === r.id);
|
|
13355
13469
|
return {
|
|
@@ -15297,6 +15411,220 @@ function renderTriageMd(report) {
|
|
|
15297
15411
|
return lines.join("\n");
|
|
15298
15412
|
}
|
|
15299
15413
|
//#endregion
|
|
15414
|
+
//#region src/mutation/parse-stryker.ts
|
|
15415
|
+
const MAX_FILES = 5e3;
|
|
15416
|
+
const MAX_MUTANTS_PER_FILE = 2e4;
|
|
15417
|
+
/** Sniff: does this parsed JSON look like a Stryker mutation report? */
|
|
15418
|
+
function looksLikeStrykerJson(json) {
|
|
15419
|
+
if (!json || typeof json !== "object") return false;
|
|
15420
|
+
return typeof json.files === "object" && json.files !== null;
|
|
15421
|
+
}
|
|
15422
|
+
function parseStrykerJson(json) {
|
|
15423
|
+
if (!json || typeof json !== "object") return {
|
|
15424
|
+
tool: "stryker",
|
|
15425
|
+
survived: [],
|
|
15426
|
+
noCoverage: 0,
|
|
15427
|
+
killed: 0
|
|
15428
|
+
};
|
|
15429
|
+
const files = json.files;
|
|
15430
|
+
if (!files || typeof files !== "object") return {
|
|
15431
|
+
tool: "stryker",
|
|
15432
|
+
survived: [],
|
|
15433
|
+
noCoverage: 0,
|
|
15434
|
+
killed: 0
|
|
15435
|
+
};
|
|
15436
|
+
const survived = [];
|
|
15437
|
+
let noCoverage = 0;
|
|
15438
|
+
let killed = 0;
|
|
15439
|
+
const entries = Object.entries(files).slice(0, MAX_FILES);
|
|
15440
|
+
for (const [rawPath, file] of entries) {
|
|
15441
|
+
if (!file || typeof file !== "object") continue;
|
|
15442
|
+
const mutants = Array.isArray(file.mutants) ? file.mutants : [];
|
|
15443
|
+
const path = rawPath.replaceAll("\\", "/");
|
|
15444
|
+
for (const m of mutants.slice(0, MAX_MUTANTS_PER_FILE)) {
|
|
15445
|
+
if (!m || typeof m !== "object") continue;
|
|
15446
|
+
if (m.status === "Survived") {
|
|
15447
|
+
const startLine = toOneBased(m.location?.start?.line);
|
|
15448
|
+
const endLine = toOneBased(m.location?.end?.line);
|
|
15449
|
+
if (startLine === void 0) continue;
|
|
15450
|
+
survived.push({
|
|
15451
|
+
file: path,
|
|
15452
|
+
mutator: typeof m.mutatorName === "string" ? m.mutatorName : "unknown",
|
|
15453
|
+
startLine,
|
|
15454
|
+
endLine: endLine !== void 0 ? Math.max(startLine, endLine) : startLine
|
|
15455
|
+
});
|
|
15456
|
+
} else if (m.status === "NoCoverage") noCoverage++;
|
|
15457
|
+
else if (m.status === "Killed" || m.status === "Timeout") killed++;
|
|
15458
|
+
}
|
|
15459
|
+
}
|
|
15460
|
+
return {
|
|
15461
|
+
tool: "stryker",
|
|
15462
|
+
survived,
|
|
15463
|
+
noCoverage,
|
|
15464
|
+
killed
|
|
15465
|
+
};
|
|
15466
|
+
}
|
|
15467
|
+
function toOneBased(line) {
|
|
15468
|
+
return typeof line === "number" && line >= 0 ? line + 1 : void 0;
|
|
15469
|
+
}
|
|
15470
|
+
//#endregion
|
|
15471
|
+
//#region src/mutation/parse-mutmut.ts
|
|
15472
|
+
const MAX_INPUT = 20971520;
|
|
15473
|
+
/** Sniff: does this XML look like mutmut's junit output? */
|
|
15474
|
+
function looksLikeMutmutXml(xml) {
|
|
15475
|
+
return xml.length <= MAX_INPUT && /mutmut/i.test(xml.slice(0, 4e3));
|
|
15476
|
+
}
|
|
15477
|
+
function decodeEntities(s) {
|
|
15478
|
+
return s.replaceAll("<", "<").replaceAll(">", ">").replaceAll(""", "\"").replaceAll("'", "'").replaceAll("'", "'").replaceAll("&", "&");
|
|
15479
|
+
}
|
|
15480
|
+
/**
|
|
15481
|
+
* The file for a testcase: mutmut 2.x writes
|
|
15482
|
+
* `classname="tests.test_auth-mutmut0::x_mutmut_1"`-style classes whose
|
|
15483
|
+
* FIRST dot-separated segment before `-mutmut` is the module path;
|
|
15484
|
+
* mutmut 3.x writes the path directly in `classname`. Both are
|
|
15485
|
+
* normalized here to a repo-relative path with `.py` intact.
|
|
15486
|
+
*/
|
|
15487
|
+
function fileFromClassname(classname) {
|
|
15488
|
+
const decoded = decodeEntities(classname);
|
|
15489
|
+
const cut = decoded.indexOf("-mutmut");
|
|
15490
|
+
const candidate = (cut !== -1 ? decoded.slice(0, cut) : decoded).trim();
|
|
15491
|
+
if (candidate.length === 0) return null;
|
|
15492
|
+
return candidate.replaceAll(".", "/") + ".py";
|
|
15493
|
+
}
|
|
15494
|
+
function parseMutmutXml(xml) {
|
|
15495
|
+
if (xml.length > MAX_INPUT) return {
|
|
15496
|
+
tool: "mutmut",
|
|
15497
|
+
survived: [],
|
|
15498
|
+
noCoverage: 0,
|
|
15499
|
+
killed: 0
|
|
15500
|
+
};
|
|
15501
|
+
const survived = [];
|
|
15502
|
+
let noCoverage = 0;
|
|
15503
|
+
let killed = 0;
|
|
15504
|
+
const startRe = /<testcase\b/gi;
|
|
15505
|
+
const closeRe = /<\/testcase\s*>/gi;
|
|
15506
|
+
let start;
|
|
15507
|
+
while ((start = startRe.exec(xml)) !== null) {
|
|
15508
|
+
const gt = xml.indexOf(">", start.index);
|
|
15509
|
+
if (gt === -1) break;
|
|
15510
|
+
const attrs = xml.slice(start.index + 9, gt);
|
|
15511
|
+
let inner;
|
|
15512
|
+
if (attrs.endsWith("/")) {
|
|
15513
|
+
inner = "";
|
|
15514
|
+
startRe.lastIndex = gt + 1;
|
|
15515
|
+
} else {
|
|
15516
|
+
closeRe.lastIndex = gt + 1;
|
|
15517
|
+
const close = closeRe.exec(xml);
|
|
15518
|
+
if (close === null) break;
|
|
15519
|
+
inner = xml.slice(gt + 1, close.index);
|
|
15520
|
+
startRe.lastIndex = close.index + close[0].length;
|
|
15521
|
+
}
|
|
15522
|
+
const classname = /(?:^|\s)classname\s*=\s*"([^"]*)"/i.exec(attrs)?.[1] ?? "";
|
|
15523
|
+
if (/<skipped\b/i.test(inner)) {
|
|
15524
|
+
noCoverage++;
|
|
15525
|
+
continue;
|
|
15526
|
+
}
|
|
15527
|
+
const killedMutant = /<(?:failure|error)\b/i.test(inner);
|
|
15528
|
+
const verdict = {
|
|
15529
|
+
file: fileFromClassname(classname),
|
|
15530
|
+
survived: !killedMutant
|
|
15531
|
+
};
|
|
15532
|
+
if (killedMutant) killed++;
|
|
15533
|
+
else if (verdict.file !== null) survived.push({
|
|
15534
|
+
file: verdict.file,
|
|
15535
|
+
mutator: "mutmut",
|
|
15536
|
+
startLine: 1,
|
|
15537
|
+
endLine: Number.MAX_SAFE_INTEGER
|
|
15538
|
+
});
|
|
15539
|
+
else noCoverage++;
|
|
15540
|
+
}
|
|
15541
|
+
return {
|
|
15542
|
+
tool: "mutmut",
|
|
15543
|
+
survived,
|
|
15544
|
+
noCoverage,
|
|
15545
|
+
killed
|
|
15546
|
+
};
|
|
15547
|
+
}
|
|
15548
|
+
//#endregion
|
|
15549
|
+
//#region src/mutation/derive.ts
|
|
15550
|
+
/**
|
|
15551
|
+
* Stamp mutation evidence onto findings (mutates in place, the same
|
|
15552
|
+
* contract as stampEvidenceLevels). Returns the stamp stats.
|
|
15553
|
+
*/
|
|
15554
|
+
function stampMutationEvidence(findings, report) {
|
|
15555
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
15556
|
+
for (const m of report.survived) {
|
|
15557
|
+
const list = byFile.get(m.file) ?? [];
|
|
15558
|
+
list.push(m);
|
|
15559
|
+
byFile.set(m.file, list);
|
|
15560
|
+
}
|
|
15561
|
+
let stamped = 0;
|
|
15562
|
+
let derived = 0;
|
|
15563
|
+
for (const f of findings) {
|
|
15564
|
+
const mutants = byFile.get(f.file);
|
|
15565
|
+
if (!mutants || mutants.length === 0) continue;
|
|
15566
|
+
let matched = mutants.filter((m) => f.line >= m.startLine && f.line <= m.endLine);
|
|
15567
|
+
let granularity = "line";
|
|
15568
|
+
if (matched.length === 0) {
|
|
15569
|
+
matched = mutants;
|
|
15570
|
+
granularity = "file";
|
|
15571
|
+
}
|
|
15572
|
+
f.mutationEvidence = {
|
|
15573
|
+
source: report.tool,
|
|
15574
|
+
matchedMutants: matched.length,
|
|
15575
|
+
mutators: [...new Set(matched.map((m) => m.mutator))].sort(),
|
|
15576
|
+
granularity
|
|
15577
|
+
};
|
|
15578
|
+
stamped++;
|
|
15579
|
+
if (f.evidenceLevel === "E1") {
|
|
15580
|
+
f.evidenceLevel = "E2";
|
|
15581
|
+
derived++;
|
|
15582
|
+
}
|
|
15583
|
+
}
|
|
15584
|
+
return {
|
|
15585
|
+
stamped,
|
|
15586
|
+
derived
|
|
15587
|
+
};
|
|
15588
|
+
}
|
|
15589
|
+
/**
|
|
15590
|
+
* Survived-mutant leaderboard: most-mutated files first. The summary's
|
|
15591
|
+
* honesty headline: files here are code the suite demonstrably does not
|
|
15592
|
+
* constrain — regardless of what any static tool found.
|
|
15593
|
+
*/
|
|
15594
|
+
function survivedLeaderboard(report, top = 25) {
|
|
15595
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
15596
|
+
for (const m of report.survived) {
|
|
15597
|
+
const list = byFile.get(m.file) ?? [];
|
|
15598
|
+
list.push(m);
|
|
15599
|
+
byFile.set(m.file, list);
|
|
15600
|
+
}
|
|
15601
|
+
return [...byFile.entries()].map(([file, mutants]) => ({
|
|
15602
|
+
file,
|
|
15603
|
+
survived: mutants.length,
|
|
15604
|
+
mutators: [...new Set(mutants.map((m) => m.mutator))].sort()
|
|
15605
|
+
})).sort((a, b) => b.survived - a.survived).slice(0, top);
|
|
15606
|
+
}
|
|
15607
|
+
function renderMutationSummary(report) {
|
|
15608
|
+
const lines = [];
|
|
15609
|
+
lines.push(`MUTATION EVIDENCE — ${report.tool}`);
|
|
15610
|
+
lines.push("");
|
|
15611
|
+
lines.push(`${report.survived.length} survived · ${report.killed} killed · ${report.noCoverage} no-coverage`);
|
|
15612
|
+
lines.push("");
|
|
15613
|
+
const top = survivedLeaderboard(report);
|
|
15614
|
+
if (top.length === 0) {
|
|
15615
|
+
lines.push("No survived mutants — every executed mutant was killed.");
|
|
15616
|
+
lines.push("");
|
|
15617
|
+
lines.push("Interpretation: the suite constrains the code the mutation run reached. This is evidence about THOSE mutants only — untested surfaces are the no-coverage column, and no-coverage is not survived.");
|
|
15618
|
+
return lines.join("\n");
|
|
15619
|
+
}
|
|
15620
|
+
lines.push("Files the suite does not constrain (most survived first):");
|
|
15621
|
+
lines.push("");
|
|
15622
|
+
for (const row of top) lines.push(` ${row.survived}× ${row.file} [${row.mutators.join(", ")}]`);
|
|
15623
|
+
lines.push("");
|
|
15624
|
+
lines.push("A survived mutant is not a bug — it is code the suite would not notice changing. Findings near these lines were consolidated E1→E2 by derivation (see docs/RULE-LIFECYCLE.md).");
|
|
15625
|
+
return lines.join("\n");
|
|
15626
|
+
}
|
|
15627
|
+
//#endregion
|
|
15300
15628
|
//#region src/lib/fs-atomic.ts
|
|
15301
15629
|
/**
|
|
15302
15630
|
* Atomic file writes (audit S9).
|
|
@@ -15499,6 +15827,12 @@ const HELP_ENTRIES = [
|
|
|
15499
15827
|
usage: "mjolnir triage <test-results-dir-or-report-file> [--no-md]",
|
|
15500
15828
|
examples: ["mjolnir triage test-results --no-md"]
|
|
15501
15829
|
},
|
|
15830
|
+
{
|
|
15831
|
+
verb: "mutation",
|
|
15832
|
+
summary: "mutation-evidence reader: survived-mutant leaderboard + E1→E2 derivation",
|
|
15833
|
+
usage: "mjolnir mutation <mutation-report> [--scan .]",
|
|
15834
|
+
examples: ["mjolnir mutation reports/mutation-report.json", "mjolnir mutation reports/… --scan ."]
|
|
15835
|
+
},
|
|
15502
15836
|
{
|
|
15503
15837
|
verb: "pw-report",
|
|
15504
15838
|
summary: "Playwright run summary (counts, true flakes, slowest tests)",
|
|
@@ -15781,6 +16115,10 @@ const GROUPS = [
|
|
|
15781
16115
|
"doctor:playwright"
|
|
15782
16116
|
]
|
|
15783
16117
|
},
|
|
16118
|
+
{
|
|
16119
|
+
title: "Mutation evidence",
|
|
16120
|
+
verbs: ["mutation"]
|
|
16121
|
+
},
|
|
15784
16122
|
{
|
|
15785
16123
|
title: "Maintenance",
|
|
15786
16124
|
verbs: [
|
|
@@ -18544,7 +18882,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
|
|
|
18544
18882
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
18545
18883
|
* `tests/version-consistency.spec.ts` locally.
|
|
18546
18884
|
*/
|
|
18547
|
-
const CLI_VERSION = "0.5.
|
|
18885
|
+
const CLI_VERSION = "0.5.37";
|
|
18548
18886
|
function parseArgs(argv, onError) {
|
|
18549
18887
|
const args = {
|
|
18550
18888
|
target: ".",
|
|
@@ -19030,6 +19368,97 @@ function runTriageCommand(argv, io = {
|
|
|
19030
19368
|
return 20;
|
|
19031
19369
|
}
|
|
19032
19370
|
}
|
|
19371
|
+
/**
|
|
19372
|
+
* Sniff + parse a mutation report: Stryker JSON, mutmut junitxml, else
|
|
19373
|
+
* an empty report (the caller renders the honest "nothing recognized").
|
|
19374
|
+
*/
|
|
19375
|
+
function ingestMutationReport(text) {
|
|
19376
|
+
const trimmed = text.trimStart();
|
|
19377
|
+
if (trimmed.startsWith("<?xml") || trimmed.startsWith("<testsuite")) return parseMutmutXml(text);
|
|
19378
|
+
try {
|
|
19379
|
+
const json = JSON.parse(text);
|
|
19380
|
+
if (looksLikeStrykerJson(json)) return parseStrykerJson(json);
|
|
19381
|
+
} catch {}
|
|
19382
|
+
if (looksLikeMutmutXml(text)) return parseMutmutXml(text);
|
|
19383
|
+
return {
|
|
19384
|
+
tool: "stryker",
|
|
19385
|
+
survived: [],
|
|
19386
|
+
noCoverage: 0,
|
|
19387
|
+
killed: 0
|
|
19388
|
+
};
|
|
19389
|
+
}
|
|
19390
|
+
/** Testable `mutation` handler (master plan P5, plan 1788853205786).
|
|
19391
|
+
* Reads a mutation report (Stryker JSON / mutmut junitxml), renders the
|
|
19392
|
+
* survived-mutant leaderboard, and — with `--scan <path>` — re-scans the
|
|
19393
|
+
* target, stamps matching findings with `mutationEvidence` (E1→E2 by
|
|
19394
|
+
* derivation) and renders the stamped findings. NEVER spawns mutation
|
|
19395
|
+
* tools: it reads reports the user already produced. Exit 2 when
|
|
19396
|
+
* nothing in the report is recognized — honest no-evidence, not an
|
|
19397
|
+
* error. Exit 0 in every other case: the reader is report-only, never a
|
|
19398
|
+
* gate. */
|
|
19399
|
+
async function runMutationCommand(argv, io = {
|
|
19400
|
+
out,
|
|
19401
|
+
err
|
|
19402
|
+
}) {
|
|
19403
|
+
const targetArg = argv.find((a) => !a.startsWith("-"));
|
|
19404
|
+
if (!targetArg) {
|
|
19405
|
+
io.err("Usage: mjolnir mutation <mutation-report> [--scan <path>]");
|
|
19406
|
+
return 10;
|
|
19407
|
+
}
|
|
19408
|
+
const scanIdx = argv.indexOf("--scan");
|
|
19409
|
+
const scanArg = scanIdx !== -1 ? argv[scanIdx + 1] : void 0;
|
|
19410
|
+
if (scanIdx !== -1 && (!scanArg || scanArg.startsWith("-"))) {
|
|
19411
|
+
io.err("--scan requires a path argument");
|
|
19412
|
+
return 10;
|
|
19413
|
+
}
|
|
19414
|
+
let report;
|
|
19415
|
+
try {
|
|
19416
|
+
const path = resolve(targetArg);
|
|
19417
|
+
if (!existsSync(path)) {
|
|
19418
|
+
io.err(`No such file: ${path}`);
|
|
19419
|
+
return 2;
|
|
19420
|
+
}
|
|
19421
|
+
report = ingestMutationReport(readFileSync(path, "utf8"));
|
|
19422
|
+
if (report.survived.length === 0 && report.killed === 0 && report.noCoverage === 0) {
|
|
19423
|
+
io.err("No mutants recognized. Expected a Stryker JSON report (mutation-report.json) or a mutmut junitxml report.");
|
|
19424
|
+
return 2;
|
|
19425
|
+
}
|
|
19426
|
+
} catch (err) {
|
|
19427
|
+
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
19428
|
+
return 20;
|
|
19429
|
+
}
|
|
19430
|
+
io.out(renderMutationSummary(report));
|
|
19431
|
+
if (scanArg === void 0) return 0;
|
|
19432
|
+
try {
|
|
19433
|
+
const scanPath = resolve(scanArg);
|
|
19434
|
+
const invalid = validateScanTarget(scanPath, io.err);
|
|
19435
|
+
if (invalid !== null) return invalid;
|
|
19436
|
+
const result = await runScan({
|
|
19437
|
+
target: scanPath,
|
|
19438
|
+
json: true,
|
|
19439
|
+
verbose: true,
|
|
19440
|
+
maxDurationMs: 12e4,
|
|
19441
|
+
scopeChanged: false,
|
|
19442
|
+
format: "json"
|
|
19443
|
+
});
|
|
19444
|
+
const stats = stampMutationEvidence(result.findings, report);
|
|
19445
|
+
io.out("");
|
|
19446
|
+
if (stats.stamped === 0) io.out("No findings intersect the survived-mutant surface — nothing to derive.");
|
|
19447
|
+
else {
|
|
19448
|
+
io.out(`${stats.stamped} finding(s) carry mutationEvidence (${stats.derived} consolidated E1→E2 by derivation — docs/RULE-LIFECYCLE.md):`);
|
|
19449
|
+
for (const f of result.findings) {
|
|
19450
|
+
if (!f.mutationEvidence) continue;
|
|
19451
|
+
io.out(` ${f.ruleId} ${f.file}:${f.line} — ${f.evidenceLevel} · ${f.mutationEvidence.matchedMutants} mutant(s) @ ${f.mutationEvidence.granularity} granularity`);
|
|
19452
|
+
}
|
|
19453
|
+
io.out("");
|
|
19454
|
+
io.out("Machine form: re-run with --json — mutationEvidence rides the findings additively.");
|
|
19455
|
+
}
|
|
19456
|
+
return 0;
|
|
19457
|
+
} catch (err) {
|
|
19458
|
+
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
19459
|
+
return 20;
|
|
19460
|
+
}
|
|
19461
|
+
}
|
|
19033
19462
|
/** Testable `badge` handler (Tier 1 #5). */
|
|
19034
19463
|
async function runBadgeCommand(argv, io = {
|
|
19035
19464
|
out,
|
|
@@ -19408,6 +19837,7 @@ async function main(argv = process$1.argv.slice(2), io = {
|
|
|
19408
19837
|
if (argv[0] === "suppressions") return runSuppressions();
|
|
19409
19838
|
if (argv[0] === "forensics") return runForensicsCommand(argv.slice(1));
|
|
19410
19839
|
if (argv[0] === "triage") return runTriageCommand(argv.slice(1));
|
|
19840
|
+
if (argv[0] === "mutation") return runMutationCommand(argv.slice(1), io);
|
|
19411
19841
|
if (argv[0] === "badge") return runBadgeCommand(argv.slice(1));
|
|
19412
19842
|
if (argv[0] === "debt") return runDebtCommand(argv.slice(1));
|
|
19413
19843
|
if (argv[0] === "impact") return runImpactCommand(argv.slice(1));
|
package/package.json
CHANGED