mjolnir-qa 0.5.34 → 0.5.36
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 +8 -1
- package/dist/cli.d.mts +11 -4
- package/dist/cli.mjs +185 -9
- package/dist/mcp/stdio.mjs +185 -9
- 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.36] — 2026-09-08
|
|
13
|
+
|
|
14
|
+
### Changes since 0
|
|
15
|
+
|
|
16
|
+
- P4: forensics breadth — Jest + Vitest JSON ingestion (honest degradation) (#66)
|
|
17
|
+
|
|
18
|
+
## [0.5.35] — 2026-09-08
|
|
19
|
+
|
|
20
|
+
### Changes since 0
|
|
21
|
+
|
|
22
|
+
- P3a: GitLab CI — Code Quality report, MR recipe, exit-code discipline (#65)
|
|
23
|
+
|
|
12
24
|
## [0.5.34] — 2026-09-08
|
|
13
25
|
|
|
14
26
|
### Changes since 0
|
package/README.md
CHANGED
|
@@ -203,6 +203,7 @@ pinned to the `v1` major tag) by default, or plain `npx` with
|
|
|
203
203
|
| `mjolnir forensics ./test-results/` | Runtime evidence from a real run |
|
|
204
204
|
| `mjolnir handoff` | Remediation plan for a coding agent |
|
|
205
205
|
| `mjolnir --json` / `--format sarif` | Machine-readable / GitHub Code Scanning |
|
|
206
|
+
| `mjolnir --format codequality` | GitLab Code Quality report (MR widget artifact) |
|
|
206
207
|
| `mjolnir --strict` | Also run quarantine-tier rules (higher FP risk) |
|
|
207
208
|
|
|
208
209
|
<details>
|
|
@@ -498,7 +499,8 @@ scanning.
|
|
|
498
499
|
## Runtime forensics
|
|
499
500
|
|
|
500
501
|
Static analysis reasons about code that was never run. Forensics reads what
|
|
501
|
-
actually happened — Playwright JSON
|
|
502
|
+
actually happened — Playwright JSON, Jest JSON, Vitest JSON, and JUnit XML
|
|
503
|
+
from any runner:
|
|
502
504
|
|
|
503
505
|
```text
|
|
504
506
|
Static analysis → what the code appears to do
|
|
@@ -565,6 +567,10 @@ Or wire it into GitHub Code Scanning natively via SARIF:
|
|
|
565
567
|
sarif_file: mjolnir.sarif
|
|
566
568
|
```
|
|
567
569
|
|
|
570
|
+
On GitLab, `--format codequality` emits the Code Quality report the MR
|
|
571
|
+
widget and diff annotations consume
|
|
572
|
+
([docs/GITLAB-CI.md](docs/GITLAB-CI.md)).
|
|
573
|
+
|
|
568
574
|
Editor and pipeline setup: [docs/SARIF-INTEGRATION.md](docs/SARIF-INTEGRATION.md).
|
|
569
575
|
|
|
570
576
|
### Changed-scope attribution
|
|
@@ -718,6 +724,7 @@ Rule IDs (`QA-<FAMILY>-NNN`) are immutable once shipped and never reused.
|
|
|
718
724
|
| [docs/VERSIONING.md](docs/VERSIONING.md) | Semver policy, frozen surfaces, deprecation cycle |
|
|
719
725
|
| [docs/machine-contract.md](docs/machine-contract.md) | The canonical machine-readable result |
|
|
720
726
|
| [docs/SARIF-INTEGRATION.md](docs/SARIF-INTEGRATION.md) | SARIF output + editor/CI setup |
|
|
727
|
+
| [docs/GITLAB-CI.md](docs/GITLAB-CI.md) | GitLab: Code Quality report, MR recipe, gate |
|
|
721
728
|
| [docs/rules/](docs/rules/) | Generated per-rule catalog |
|
|
722
729
|
| [CONTRIBUTING.md](CONTRIBUTING.md) | Dev setup + contribution workflow |
|
|
723
730
|
| [SUPPORT.md](SUPPORT.md) | Where to ask, report and get help |
|
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
|
/**
|
|
@@ -565,7 +572,7 @@ interface CliArgs {
|
|
|
565
572
|
verbose: boolean;
|
|
566
573
|
maxDurationMs: number;
|
|
567
574
|
scopeChanged: boolean;
|
|
568
|
-
format: "terminal" | "json" | "sarif" | "mermaid";
|
|
575
|
+
format: "terminal" | "json" | "sarif" | "mermaid" | "codequality";
|
|
569
576
|
/** --width override for terminal box/gauge wrapping (Sprint 5 Task 22). */
|
|
570
577
|
width?: number;
|
|
571
578
|
/** --ascii / --no-ascii override for shouldUseAscii()'s heuristic. */
|
|
@@ -737,7 +744,7 @@ declare const runScan: typeof runScan$1, buildUniversalRules: typeof buildUniver
|
|
|
737
744
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
738
745
|
* `tests/version-consistency.spec.ts` locally.
|
|
739
746
|
*/
|
|
740
|
-
declare const CLI_VERSION = "0.5.
|
|
747
|
+
declare const CLI_VERSION = "0.5.36";
|
|
741
748
|
/** A usage-error detail: the offending token, when one exists. */
|
|
742
749
|
interface UsageErrorDetail {
|
|
743
750
|
/** The unknown flag or rejected value (e.g. `--nope`, `loud`). */
|
package/dist/cli.mjs
CHANGED
|
@@ -11123,6 +11123,60 @@ 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
11181
|
const MAX_INPUT = 20971520;
|
|
11128
11182
|
function decodeEntities(s) {
|
|
@@ -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,7 +11339,9 @@ 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
|
*/
|
|
@@ -11308,17 +11412,27 @@ 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 = [];
|
|
@@ -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.36",
|
|
13354
13468
|
rules: [...rules.values()].map((r) => {
|
|
13355
13469
|
const meta = RULES.find((x) => x.id === r.id);
|
|
13356
13470
|
return {
|
|
@@ -13422,6 +13536,62 @@ function renderSarif(result, repoRootUri) {
|
|
|
13422
13536
|
return JSON.stringify(sarif, null, 2);
|
|
13423
13537
|
}
|
|
13424
13538
|
//#endregion
|
|
13539
|
+
//#region src/reporter/codequality.ts
|
|
13540
|
+
/**
|
|
13541
|
+
* GitLab Code Quality reporter (product-gap-remediation master plan P3a,
|
|
13542
|
+
* plan 1788853205786 — flag 2, decision 2: GitLab is the in-plan second
|
|
13543
|
+
* platform).
|
|
13544
|
+
*
|
|
13545
|
+
* Emits the Code Quality report schema GitLab's `codequality` artifact
|
|
13546
|
+
* type consumes (docs.gitlab.com/ee/ci/testing/code_quality.html) so
|
|
13547
|
+
* findings render as MR widgets and diff annotations inside existing
|
|
13548
|
+
* governance — the same distribution logic as SARIF for GitHub, never a
|
|
13549
|
+
* second truth: this is a deterministic projection of the same
|
|
13550
|
+
* ScanResult the JSON contract carries.
|
|
13551
|
+
*
|
|
13552
|
+
* Usage: mjolnir --format codequality > gl-code-quality-report.json
|
|
13553
|
+
*
|
|
13554
|
+
* Severity map (documented in docs/GITLAB-CI.md):
|
|
13555
|
+
* error → major (categorical defects — the gate's core)
|
|
13556
|
+
* warning → minor
|
|
13557
|
+
* info → info
|
|
13558
|
+
*
|
|
13559
|
+
* Fingerprint stability: GitLab deduplicates MR findings across runs by
|
|
13560
|
+
* `fingerprint` — an unstable fingerprint would resurrect closed
|
|
13561
|
+
* findings on every push (the exact PR-noise the MR flow exists to
|
|
13562
|
+
* avoid). The fingerprint is sha256 over ruleId + repo-relative path +
|
|
13563
|
+
* line + column + message — the same finding shape the --why command
|
|
13564
|
+
* addresses, so a moved line legitimately re-reports and an unchanged
|
|
13565
|
+
* finding stays silent.
|
|
13566
|
+
*/
|
|
13567
|
+
function codeQualitySeverity(severity) {
|
|
13568
|
+
if (severity === "error") return "major";
|
|
13569
|
+
if (severity === "warning") return "minor";
|
|
13570
|
+
return "info";
|
|
13571
|
+
}
|
|
13572
|
+
/**
|
|
13573
|
+
* sha256 hex over the identifying tuple. Exported for the contract test:
|
|
13574
|
+
* the fingerprint MUST be stable across runs for an unchanged finding
|
|
13575
|
+
* (GitLab dedup contract) and MUST change when the finding's identity
|
|
13576
|
+
* changes (otherwise an MR could hide a moved-and-still-broken line).
|
|
13577
|
+
*/
|
|
13578
|
+
function codeQualityFingerprint(f) {
|
|
13579
|
+
return createHash("sha256").update(`${f.ruleId}\u0000${f.file}\u0000${f.line}\u0000${f.column}\u0000${f.message}`).digest("hex");
|
|
13580
|
+
}
|
|
13581
|
+
function renderCodeQuality(result) {
|
|
13582
|
+
const issues = result.findings.map((f) => ({
|
|
13583
|
+
description: f.message,
|
|
13584
|
+
check_name: f.ruleId,
|
|
13585
|
+
fingerprint: codeQualityFingerprint(f),
|
|
13586
|
+
severity: codeQualitySeverity(f.severity),
|
|
13587
|
+
location: {
|
|
13588
|
+
path: f.file.replaceAll("\\", "/"),
|
|
13589
|
+
lines: { begin: f.line }
|
|
13590
|
+
}
|
|
13591
|
+
}));
|
|
13592
|
+
return JSON.stringify(issues, null, 2) + "\n";
|
|
13593
|
+
}
|
|
13594
|
+
//#endregion
|
|
13425
13595
|
//#region src/reporter/mermaid.ts
|
|
13426
13596
|
function sanitizeId(raw) {
|
|
13427
13597
|
return raw.replace(/[^a-z0-9]/gi, "_");
|
|
@@ -16384,6 +16554,10 @@ const HELP_FLAGS = [
|
|
|
16384
16554
|
flag: "--format sarif",
|
|
16385
16555
|
summary: "SARIF 2.1 for GitHub Code Scanning"
|
|
16386
16556
|
},
|
|
16557
|
+
{
|
|
16558
|
+
flag: "--format codequality",
|
|
16559
|
+
summary: "GitLab Code Quality report (MR widget artifact)"
|
|
16560
|
+
},
|
|
16387
16561
|
{
|
|
16388
16562
|
flag: "--format mermaid",
|
|
16389
16563
|
summary: "test-architecture diagram"
|
|
@@ -18848,7 +19022,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
|
|
|
18848
19022
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
18849
19023
|
* `tests/version-consistency.spec.ts` locally.
|
|
18850
19024
|
*/
|
|
18851
|
-
const CLI_VERSION = "0.5.
|
|
19025
|
+
const CLI_VERSION = "0.5.36";
|
|
18852
19026
|
function parseArgs(argv, onError) {
|
|
18853
19027
|
const args = {
|
|
18854
19028
|
target: ".",
|
|
@@ -18871,6 +19045,7 @@ function parseArgs(argv, onError) {
|
|
|
18871
19045
|
const fmt = argv[++i];
|
|
18872
19046
|
if (fmt === "sarif") args.format = "sarif";
|
|
18873
19047
|
else if (fmt === "mermaid") args.format = "mermaid";
|
|
19048
|
+
else if (fmt === "codequality") args.format = "codequality";
|
|
18874
19049
|
else if (fmt === "json") {
|
|
18875
19050
|
args.format = "json";
|
|
18876
19051
|
args.json = true;
|
|
@@ -19255,6 +19430,7 @@ async function runScanCommand(argv, io = {
|
|
|
19255
19430
|
}
|
|
19256
19431
|
if (args.format === "sarif") io.out(renderSarif(result, pathToFileURL(target).href));
|
|
19257
19432
|
else if (args.format === "mermaid") io.out(renderMermaid(result));
|
|
19433
|
+
else if (args.format === "codequality") io.out(renderCodeQuality(result));
|
|
19258
19434
|
else if (args.json) io.out(JSON.stringify({
|
|
19259
19435
|
...result,
|
|
19260
19436
|
contract: buildMachineContract(result)
|
package/dist/mcp/stdio.mjs
CHANGED
|
@@ -11122,6 +11122,60 @@ 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
11180
|
const MAX_INPUT = 20971520;
|
|
11127
11181
|
function decodeEntities(s) {
|
|
@@ -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,7 +11338,9 @@ 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
|
*/
|
|
@@ -11307,17 +11411,27 @@ 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 = [];
|
|
@@ -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.36",
|
|
13353
13467
|
rules: [...rules.values()].map((r) => {
|
|
13354
13468
|
const meta = RULES.find((x) => x.id === r.id);
|
|
13355
13469
|
return {
|
|
@@ -13421,6 +13535,62 @@ function renderSarif(result, repoRootUri) {
|
|
|
13421
13535
|
return JSON.stringify(sarif, null, 2);
|
|
13422
13536
|
}
|
|
13423
13537
|
//#endregion
|
|
13538
|
+
//#region src/reporter/codequality.ts
|
|
13539
|
+
/**
|
|
13540
|
+
* GitLab Code Quality reporter (product-gap-remediation master plan P3a,
|
|
13541
|
+
* plan 1788853205786 — flag 2, decision 2: GitLab is the in-plan second
|
|
13542
|
+
* platform).
|
|
13543
|
+
*
|
|
13544
|
+
* Emits the Code Quality report schema GitLab's `codequality` artifact
|
|
13545
|
+
* type consumes (docs.gitlab.com/ee/ci/testing/code_quality.html) so
|
|
13546
|
+
* findings render as MR widgets and diff annotations inside existing
|
|
13547
|
+
* governance — the same distribution logic as SARIF for GitHub, never a
|
|
13548
|
+
* second truth: this is a deterministic projection of the same
|
|
13549
|
+
* ScanResult the JSON contract carries.
|
|
13550
|
+
*
|
|
13551
|
+
* Usage: mjolnir --format codequality > gl-code-quality-report.json
|
|
13552
|
+
*
|
|
13553
|
+
* Severity map (documented in docs/GITLAB-CI.md):
|
|
13554
|
+
* error → major (categorical defects — the gate's core)
|
|
13555
|
+
* warning → minor
|
|
13556
|
+
* info → info
|
|
13557
|
+
*
|
|
13558
|
+
* Fingerprint stability: GitLab deduplicates MR findings across runs by
|
|
13559
|
+
* `fingerprint` — an unstable fingerprint would resurrect closed
|
|
13560
|
+
* findings on every push (the exact PR-noise the MR flow exists to
|
|
13561
|
+
* avoid). The fingerprint is sha256 over ruleId + repo-relative path +
|
|
13562
|
+
* line + column + message — the same finding shape the --why command
|
|
13563
|
+
* addresses, so a moved line legitimately re-reports and an unchanged
|
|
13564
|
+
* finding stays silent.
|
|
13565
|
+
*/
|
|
13566
|
+
function codeQualitySeverity(severity) {
|
|
13567
|
+
if (severity === "error") return "major";
|
|
13568
|
+
if (severity === "warning") return "minor";
|
|
13569
|
+
return "info";
|
|
13570
|
+
}
|
|
13571
|
+
/**
|
|
13572
|
+
* sha256 hex over the identifying tuple. Exported for the contract test:
|
|
13573
|
+
* the fingerprint MUST be stable across runs for an unchanged finding
|
|
13574
|
+
* (GitLab dedup contract) and MUST change when the finding's identity
|
|
13575
|
+
* changes (otherwise an MR could hide a moved-and-still-broken line).
|
|
13576
|
+
*/
|
|
13577
|
+
function codeQualityFingerprint(f) {
|
|
13578
|
+
return createHash("sha256").update(`${f.ruleId}\u0000${f.file}\u0000${f.line}\u0000${f.column}\u0000${f.message}`).digest("hex");
|
|
13579
|
+
}
|
|
13580
|
+
function renderCodeQuality(result) {
|
|
13581
|
+
const issues = result.findings.map((f) => ({
|
|
13582
|
+
description: f.message,
|
|
13583
|
+
check_name: f.ruleId,
|
|
13584
|
+
fingerprint: codeQualityFingerprint(f),
|
|
13585
|
+
severity: codeQualitySeverity(f.severity),
|
|
13586
|
+
location: {
|
|
13587
|
+
path: f.file.replaceAll("\\", "/"),
|
|
13588
|
+
lines: { begin: f.line }
|
|
13589
|
+
}
|
|
13590
|
+
}));
|
|
13591
|
+
return JSON.stringify(issues, null, 2) + "\n";
|
|
13592
|
+
}
|
|
13593
|
+
//#endregion
|
|
13424
13594
|
//#region src/reporter/mermaid.ts
|
|
13425
13595
|
function sanitizeId(raw) {
|
|
13426
13596
|
return raw.replace(/[^a-z0-9]/gi, "_");
|
|
@@ -15575,6 +15745,10 @@ const HELP_FLAGS = [
|
|
|
15575
15745
|
flag: "--format sarif",
|
|
15576
15746
|
summary: "SARIF 2.1 for GitHub Code Scanning"
|
|
15577
15747
|
},
|
|
15748
|
+
{
|
|
15749
|
+
flag: "--format codequality",
|
|
15750
|
+
summary: "GitLab Code Quality report (MR widget artifact)"
|
|
15751
|
+
},
|
|
15578
15752
|
{
|
|
15579
15753
|
flag: "--format mermaid",
|
|
15580
15754
|
summary: "test-architecture diagram"
|
|
@@ -18484,7 +18658,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
|
|
|
18484
18658
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
18485
18659
|
* `tests/version-consistency.spec.ts` locally.
|
|
18486
18660
|
*/
|
|
18487
|
-
const CLI_VERSION = "0.5.
|
|
18661
|
+
const CLI_VERSION = "0.5.36";
|
|
18488
18662
|
function parseArgs(argv, onError) {
|
|
18489
18663
|
const args = {
|
|
18490
18664
|
target: ".",
|
|
@@ -18507,6 +18681,7 @@ function parseArgs(argv, onError) {
|
|
|
18507
18681
|
const fmt = argv[++i];
|
|
18508
18682
|
if (fmt === "sarif") args.format = "sarif";
|
|
18509
18683
|
else if (fmt === "mermaid") args.format = "mermaid";
|
|
18684
|
+
else if (fmt === "codequality") args.format = "codequality";
|
|
18510
18685
|
else if (fmt === "json") {
|
|
18511
18686
|
args.format = "json";
|
|
18512
18687
|
args.json = true;
|
|
@@ -18891,6 +19066,7 @@ async function runScanCommand(argv, io = {
|
|
|
18891
19066
|
}
|
|
18892
19067
|
if (args.format === "sarif") io.out(renderSarif(result, pathToFileURL(target).href));
|
|
18893
19068
|
else if (args.format === "mermaid") io.out(renderMermaid(result));
|
|
19069
|
+
else if (args.format === "codequality") io.out(renderCodeQuality(result));
|
|
18894
19070
|
else if (args.json) io.out(JSON.stringify({
|
|
18895
19071
|
...result,
|
|
18896
19072
|
contract: buildMachineContract(result)
|
package/package.json
CHANGED