mjolnir-qa 0.5.19 → 0.5.23
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 +27 -0
- package/README.md +1 -0
- package/dist/cli.d.mts +33 -21
- package/dist/cli.mjs +300 -125
- package/dist/mcp/stdio.mjs +300 -125
- package/package.json +1 -1
package/dist/mcp/stdio.mjs
CHANGED
|
@@ -37,16 +37,42 @@ const QA_IMPACT_LABELS = {
|
|
|
37
37
|
"FALSE-GREEN": "False-green risk",
|
|
38
38
|
HYGIENE: "Test hygiene debt"
|
|
39
39
|
};
|
|
40
|
-
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
40
|
+
/**
|
|
41
|
+
* The closed set of rule categories (plan §5.5, certification-audit D6).
|
|
42
|
+
* Declared FIRST as the single source of truth: the RuleCategory type is
|
|
43
|
+
* DERIVED from this list (compile-time exhaustiveness — a category added
|
|
44
|
+
* to the type without the value list, or vice versa, cannot compile).
|
|
45
|
+
* `--category` values are validated against this list — unknown
|
|
46
|
+
* categories are a usage error, not a silent no-op. Rule namespaces are
|
|
47
|
+
* frozen public API (§18.4): IDs are never reused.
|
|
48
|
+
*/
|
|
43
49
|
const RULE_CATEGORIES = [
|
|
44
50
|
"QA-TEST",
|
|
45
51
|
"QA-TQUAL",
|
|
46
52
|
"QA-PW",
|
|
47
|
-
"QA-CI"
|
|
53
|
+
"QA-CI",
|
|
54
|
+
"QA-PY",
|
|
55
|
+
"QA-ENV",
|
|
56
|
+
"QA-JV",
|
|
57
|
+
"QA-CS",
|
|
58
|
+
"QA-CYP",
|
|
59
|
+
"QA-SE",
|
|
60
|
+
"QA-WDIO",
|
|
61
|
+
"QA-PPTR",
|
|
62
|
+
"QA-APM"
|
|
48
63
|
];
|
|
49
64
|
/**
|
|
65
|
+
* Category argument validation shared by every verb accepting
|
|
66
|
+
* `--category` (scan/why/handoff — certification-audit Phase 1.2): the
|
|
67
|
+
* three verbs must never disagree about what a valid category is. A
|
|
68
|
+
* missing value or an unknown value is a usage error (exit 10) at the
|
|
69
|
+
* caller — this helper returns the verdict, the caller renders the
|
|
70
|
+
* rejection.
|
|
71
|
+
*/
|
|
72
|
+
function isValidCategory(value) {
|
|
73
|
+
return value !== void 0 && RULE_CATEGORIES.includes(value);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
50
76
|
* Honest default evidence level for a finding (Honesty Core Phase 1).
|
|
51
77
|
* Derivation is deterministic and conservative:
|
|
52
78
|
* observation → E0 (never proof)
|
|
@@ -13263,7 +13289,7 @@ function renderSarif(result, repoRootUri) {
|
|
|
13263
13289
|
tool: { driver: {
|
|
13264
13290
|
name: "Mjölnir",
|
|
13265
13291
|
informationUri: "https://github.com/Sergey-Bar/Mjolnir",
|
|
13266
|
-
version: "0.5.
|
|
13292
|
+
version: "0.5.23",
|
|
13267
13293
|
rules: [...rules.values()].map((r) => {
|
|
13268
13294
|
const meta = RULES.find((x) => x.id === r.id);
|
|
13269
13295
|
return {
|
|
@@ -14025,6 +14051,20 @@ async function runWhyCommand(argv, io = {
|
|
|
14025
14051
|
out: (line) => console.log(line),
|
|
14026
14052
|
err: (line) => console.error(line)
|
|
14027
14053
|
}) {
|
|
14054
|
+
const catIdxs = [];
|
|
14055
|
+
argv.forEach((a, i) => {
|
|
14056
|
+
if (a === "--category") catIdxs.push(i + 1);
|
|
14057
|
+
});
|
|
14058
|
+
const categories = [];
|
|
14059
|
+
for (const i of catIdxs) {
|
|
14060
|
+
const value = argv[i];
|
|
14061
|
+
if (!isValidCategory(value)) {
|
|
14062
|
+
io.err(`mjolnir why: unknown --category: ${value === void 0 ? "(missing value)" : value}`);
|
|
14063
|
+
io.err(` Valid categories: ${RULE_CATEGORIES.join(", ")}`);
|
|
14064
|
+
return 10;
|
|
14065
|
+
}
|
|
14066
|
+
categories.push(value);
|
|
14067
|
+
}
|
|
14028
14068
|
const locationToken = argv.find((a) => !a.startsWith("-"));
|
|
14029
14069
|
if (!locationToken) {
|
|
14030
14070
|
io.err("Usage: mjolnir why <file>:<line> [--json <mjolnir.json>]");
|
|
@@ -14037,7 +14077,10 @@ async function runWhyCommand(argv, io = {
|
|
|
14037
14077
|
}
|
|
14038
14078
|
const jsonIdx = argv.indexOf("--json");
|
|
14039
14079
|
const reportPath = jsonIdx !== -1 ? argv[jsonIdx + 1] : void 0;
|
|
14040
|
-
const
|
|
14080
|
+
const valuePositions = /* @__PURE__ */ new Set();
|
|
14081
|
+
if (jsonIdx !== -1) valuePositions.add(jsonIdx + 1);
|
|
14082
|
+
for (const i of catIdxs) valuePositions.add(i);
|
|
14083
|
+
const targetIdx = argv.findIndex((a, i) => !a.startsWith("-") && i !== 0 && !valuePositions.has(i));
|
|
14041
14084
|
const target = targetIdx !== -1 ? argv[targetIdx] : ".";
|
|
14042
14085
|
let result;
|
|
14043
14086
|
if (reportPath !== void 0) {
|
|
@@ -14073,11 +14116,6 @@ async function runWhyCommand(argv, io = {
|
|
|
14073
14116
|
}
|
|
14074
14117
|
}
|
|
14075
14118
|
const match = explainAt(result.findings, location.file, location.line);
|
|
14076
|
-
const catIdxs = [];
|
|
14077
|
-
argv.forEach((a, i) => {
|
|
14078
|
-
if (a === "--category") catIdxs.push(i + 1);
|
|
14079
|
-
});
|
|
14080
|
-
const categories = catIdxs.map((i) => argv[i]).filter((c) => c !== void 0);
|
|
14081
14119
|
const filtered = categories.length > 0 ? match.findings.filter((f) => categories.includes(f.category)) : match.findings;
|
|
14082
14120
|
io.out(renderWhy({
|
|
14083
14121
|
...match,
|
|
@@ -14087,6 +14125,28 @@ async function runWhyCommand(argv, io = {
|
|
|
14087
14125
|
}
|
|
14088
14126
|
//#endregion
|
|
14089
14127
|
//#region src/commands/handoff.ts
|
|
14128
|
+
/**
|
|
14129
|
+
* `mjolnir handoff [mjolnir.json]` — the deterministic fix-handoff
|
|
14130
|
+
* artifact (agent-handoff plan M3).
|
|
14131
|
+
*
|
|
14132
|
+
* Reads a saved `--json` report (shared loader, report-io.ts) and
|
|
14133
|
+
* renders a self-contained Markdown remediation plan an agent (or a
|
|
14134
|
+
* human) can execute offline. Every instruction is derived from
|
|
14135
|
+
* Mjölnir's own rule metadata — the generator produces INSTRUCTIONS,
|
|
14136
|
+
* never executable commands, and no prompt text is fetched or copied
|
|
14137
|
+
* from any external source.
|
|
14138
|
+
*
|
|
14139
|
+
* The artifact embeds the formal verification contract (plan §5.3):
|
|
14140
|
+
* what was detected, what should change, what justifies it (evidence
|
|
14141
|
+
* boundary per level), what to re-run, what counts as verified
|
|
14142
|
+
* (TARGET_RESOLVED / TARGET_REMAINS / NEW_FINDINGS_INTRODUCED /
|
|
14143
|
+
* VERIFICATION_NOT_RUN, correlated via the fingerprint rule), and the
|
|
14144
|
+
* standing caveat that a clean `--scope changed` run verifies the
|
|
14145
|
+
* changed-scope surface only — not that the repository is clean.
|
|
14146
|
+
*
|
|
14147
|
+
* Exit codes (frozen contract): 0 on success · 10 missing file ·
|
|
14148
|
+
* 2 unreadable/invalid JSON.
|
|
14149
|
+
*/
|
|
14090
14150
|
const OCCURRENCE_CAP = 25;
|
|
14091
14151
|
const EVIDENCE_BOUNDARY = {
|
|
14092
14152
|
E2: "The detector's evidence is deterministic for this pattern. Check the location, then apply the prescribed fix — the finding is a deterministic defect at its boundary.",
|
|
@@ -14316,6 +14376,11 @@ function runHandoffCommand(argv, io = {
|
|
|
14316
14376
|
}));
|
|
14317
14377
|
return 10;
|
|
14318
14378
|
}
|
|
14379
|
+
if (a === "--category" && !isValidCategory(val)) {
|
|
14380
|
+
io.err(`mjolnir handoff: unknown --category: ${val}`);
|
|
14381
|
+
io.err(` Valid categories: ${RULE_CATEGORIES.join(", ")}`);
|
|
14382
|
+
return 10;
|
|
14383
|
+
}
|
|
14319
14384
|
i++;
|
|
14320
14385
|
continue;
|
|
14321
14386
|
}
|
|
@@ -17084,6 +17149,18 @@ function renderFixReport(results, dryRun) {
|
|
|
17084
17149
|
return lines.join("\n");
|
|
17085
17150
|
}
|
|
17086
17151
|
//#endregion
|
|
17152
|
+
//#region src/cli-io.ts
|
|
17153
|
+
const out = (...parts) => console.log(parts.map(String).join(" "));
|
|
17154
|
+
const err = (...parts) => console.error(parts.map(String).join(" "));
|
|
17155
|
+
function internalErrorMessage(err, emit, debug) {
|
|
17156
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
17157
|
+
emit("mjolnir internal error — this is a bug in Mjölnir, not your repo:");
|
|
17158
|
+
emit(` ${message}`);
|
|
17159
|
+
if (debug && err instanceof Error && err.stack) emit(err.stack);
|
|
17160
|
+
emit("Rerun with --debug for the stack trace. Please report this:");
|
|
17161
|
+
emit(" https://github.com/Sergey-Bar/Mjolnir/issues");
|
|
17162
|
+
}
|
|
17163
|
+
//#endregion
|
|
17087
17164
|
//#region src/rules/measurement.ts
|
|
17088
17165
|
/** The rule's declared detector implementation revision (§07). */
|
|
17089
17166
|
function declaredDetectorRevision(rule) {
|
|
@@ -17319,6 +17396,15 @@ function errorText(e) {
|
|
|
17319
17396
|
return e instanceof Error ? e.message : String(e);
|
|
17320
17397
|
}
|
|
17321
17398
|
const ui$2 = plainContext();
|
|
17399
|
+
/** Builds the check record: `ok` is always the pass-mirror of `status`. */
|
|
17400
|
+
function check(name, status, details) {
|
|
17401
|
+
return {
|
|
17402
|
+
name,
|
|
17403
|
+
status,
|
|
17404
|
+
ok: status === "pass",
|
|
17405
|
+
details
|
|
17406
|
+
};
|
|
17407
|
+
}
|
|
17322
17408
|
const VALID_ID = RULE_ID_RE;
|
|
17323
17409
|
function nonHiddenFiles(dir) {
|
|
17324
17410
|
if (!existsSync(dir)) return [];
|
|
@@ -17341,11 +17427,7 @@ function checkFixtureFirewall(fixturesRoot) {
|
|
|
17341
17427
|
details.push(`${rule.id}: missing must-not-fire fixture`);
|
|
17342
17428
|
}
|
|
17343
17429
|
}
|
|
17344
|
-
return
|
|
17345
|
-
name: "fixture-firewall",
|
|
17346
|
-
ok,
|
|
17347
|
-
details
|
|
17348
|
-
};
|
|
17430
|
+
return check("fixture-firewall", ok ? "pass" : "fail", details);
|
|
17349
17431
|
}
|
|
17350
17432
|
/** Check 2: registry sanity — IDs unique, well-formed, titles distinct. */
|
|
17351
17433
|
function checkRegistry(rules = RULES) {
|
|
@@ -17368,20 +17450,12 @@ function checkRegistry(rules = RULES) {
|
|
|
17368
17450
|
details.push(`"${r.title}": duplicate title in family (${r.id})`);
|
|
17369
17451
|
}
|
|
17370
17452
|
}
|
|
17371
|
-
return
|
|
17372
|
-
name: "registry-sanity",
|
|
17373
|
-
ok,
|
|
17374
|
-
details
|
|
17375
|
-
};
|
|
17453
|
+
return check("registry-sanity", ok ? "pass" : "fail", details);
|
|
17376
17454
|
}
|
|
17377
17455
|
/** Check 3: Trust Metadata presence (informational until full coverage). */
|
|
17378
17456
|
function checkTrustMetadata(rules = RULES) {
|
|
17379
17457
|
const missing = rules.filter((r) => !r.languages?.length || !r.frameworks?.length || r.falsePositiveRisk === void 0);
|
|
17380
|
-
return {
|
|
17381
|
-
name: "trust-metadata",
|
|
17382
|
-
ok: missing.length === 0,
|
|
17383
|
-
details: missing.map((r) => `${r.id}: missing trust metadata`)
|
|
17384
|
-
};
|
|
17458
|
+
return check("trust-metadata", missing.length === 0 ? "pass" : "fail", missing.map((r) => `${r.id}: missing trust metadata`));
|
|
17385
17459
|
}
|
|
17386
17460
|
/**
|
|
17387
17461
|
* Check 4 (Honesty Core): evidence-level honesty. A rule that claims a
|
|
@@ -17400,10 +17474,25 @@ function checkEvidenceHonesty(rules = RULES) {
|
|
|
17400
17474
|
details.push(`${r.id}: declares ${r.evidenceLevel} but findingType=${r.findingType}/confidence=${r.confidence} supports at most ${derived}`);
|
|
17401
17475
|
}
|
|
17402
17476
|
}
|
|
17477
|
+
return check("evidence-honesty", ok ? "pass" : "fail", details);
|
|
17478
|
+
}
|
|
17479
|
+
/**
|
|
17480
|
+
* Measurement census (Phase 4.3): measured = rules with a valid
|
|
17481
|
+
* MEASURED_FP entry; unmeasured = the rest; quarantine = measured rules
|
|
17482
|
+
* whose effective tier is quarantine. One function, used by the doctor
|
|
17483
|
+
* report AND the JSON contract — a single reproducible answer.
|
|
17484
|
+
*/
|
|
17485
|
+
function measurementBlock(rules = RULES) {
|
|
17486
|
+
const measured = rules.filter((r) => {
|
|
17487
|
+
const m = MEASURED_FP[r.id];
|
|
17488
|
+
return m !== void 0 && m.detectorRevision === declaredDetectorRevision(r);
|
|
17489
|
+
});
|
|
17490
|
+
const quarantine = measured.filter((r) => effectiveTier(r) === "quarantine");
|
|
17403
17491
|
return {
|
|
17404
|
-
|
|
17405
|
-
|
|
17406
|
-
|
|
17492
|
+
measured: measured.length,
|
|
17493
|
+
unmeasured: rules.length - measured.length,
|
|
17494
|
+
total: rules.length,
|
|
17495
|
+
quarantine: quarantine.length
|
|
17407
17496
|
};
|
|
17408
17497
|
}
|
|
17409
17498
|
/**
|
|
@@ -17445,11 +17534,7 @@ function checkTierEnforcement(verdictsDir, rules = RULES) {
|
|
|
17445
17534
|
const total = coreRules.length;
|
|
17446
17535
|
const ok = unmeasured <= 0;
|
|
17447
17536
|
details.unshift(ok ? `Ratchet (Law #3): ${unmeasured}/${total} core rules lack a measured FP rate — cap is 0 (Phase 1 closed the unmeasured-core hole)` : `BLOCKING: ${unmeasured}/${total} core rules unmeasured — exceeds the Law #3 ratchet cap of 0`);
|
|
17448
|
-
return
|
|
17449
|
-
name: "tier-enforcement",
|
|
17450
|
-
ok,
|
|
17451
|
-
details
|
|
17452
|
-
};
|
|
17537
|
+
return check("tier-enforcement", ok ? "pass" : "fail", details);
|
|
17453
17538
|
}
|
|
17454
17539
|
/**
|
|
17455
17540
|
* Check 6 (Phase 7 — Tempering Plan): anti-creep law enforcement.
|
|
@@ -17468,11 +17553,7 @@ function checkAntiCreep(rules = RULES) {
|
|
|
17468
17553
|
for (const r of overflow.slice(0, 5)) details.push(` overflow: ${r.id} — ${r.title}`);
|
|
17469
17554
|
if (overflow.length > 5) details.push(` … and ${overflow.length - 5} more`);
|
|
17470
17555
|
} else details.push(`Core tier: ${count}/65 rules (${65 - count} slots available)`);
|
|
17471
|
-
return
|
|
17472
|
-
name: "anti-creep",
|
|
17473
|
-
ok,
|
|
17474
|
-
details
|
|
17475
|
-
};
|
|
17556
|
+
return check("anti-creep", ok ? "pass" : "fail", details);
|
|
17476
17557
|
}
|
|
17477
17558
|
/**
|
|
17478
17559
|
* Check 7 (audit H-1): quarantine enforcement. The tier policy must cap
|
|
@@ -17492,11 +17573,7 @@ function checkQuarantineEnforcement(rules = RULES) {
|
|
|
17492
17573
|
}
|
|
17493
17574
|
}
|
|
17494
17575
|
details.unshift(`${quarantine.length} quarantine rules capped to severity=info, evidence=E0 — no quarantine rule may emit error`);
|
|
17495
|
-
return
|
|
17496
|
-
name: "quarantine-enforcement",
|
|
17497
|
-
ok,
|
|
17498
|
-
details
|
|
17499
|
-
};
|
|
17576
|
+
return check("quarantine-enforcement", ok ? "pass" : "fail", details);
|
|
17500
17577
|
}
|
|
17501
17578
|
/**
|
|
17502
17579
|
* Check 8 (certification-audit Phase 2.5, plan G3 Layer A support):
|
|
@@ -17549,11 +17626,7 @@ function checkFixtureIntegrity(fixturesRoot, rules = RULES) {
|
|
|
17549
17626
|
details.push("typecheck-allowlist.json is unreadable/malformed");
|
|
17550
17627
|
}
|
|
17551
17628
|
details.unshift(`fixture trees: ${fixtureDirs} rule dirs, ${fixtureFiles} fixture files — orphaned dirs and empty dirs are blocking`);
|
|
17552
|
-
return
|
|
17553
|
-
name: "fixture-integrity",
|
|
17554
|
-
ok,
|
|
17555
|
-
details
|
|
17556
|
-
};
|
|
17629
|
+
return check("fixture-integrity", ok ? "pass" : "fail", details);
|
|
17557
17630
|
}
|
|
17558
17631
|
/**
|
|
17559
17632
|
* Check 9 (certification-audit Phase 3.3, G4/D8v2 — HARD-BLOCKING):
|
|
@@ -17578,35 +17651,19 @@ function checkRevisionIntegrity(repoRoot, rules = RULES) {
|
|
|
17578
17651
|
const details = [];
|
|
17579
17652
|
const manifestPath = join(repoRoot, "tests", "corpus", "detector-hashes.json");
|
|
17580
17653
|
const rulesDir = join(repoRoot, "src", "rules");
|
|
17581
|
-
if (!existsSync(manifestPath)) return
|
|
17582
|
-
|
|
17583
|
-
ok: false,
|
|
17584
|
-
details: ["INCONCLUSIVE: tests/corpus/detector-hashes.json is missing — run `npm run detector-hashes:update` (certification-critical: an unevaluable check never renders as pass)"]
|
|
17585
|
-
};
|
|
17586
|
-
if (!existsSync(rulesDir)) return {
|
|
17587
|
-
name: "revision-integrity",
|
|
17588
|
-
ok: false,
|
|
17589
|
-
details: ["INCONCLUSIVE: src/rules is not present (installed package) — detector source identity cannot be verified here"]
|
|
17590
|
-
};
|
|
17654
|
+
if (!existsSync(manifestPath)) return check("revision-integrity", "inconclusive", ["INCONCLUSIVE: tests/corpus/detector-hashes.json is missing — run `npm run detector-hashes:update` (certification-critical: an unevaluable check never renders as pass)"]);
|
|
17655
|
+
if (!existsSync(rulesDir)) return check("revision-integrity", "inconclusive", ["INCONCLUSIVE: src/rules is not present (installed package) — detector source identity cannot be verified here"]);
|
|
17591
17656
|
let manifest;
|
|
17592
17657
|
try {
|
|
17593
17658
|
manifest = loadManifest(manifestPath);
|
|
17594
17659
|
} catch {
|
|
17595
|
-
return
|
|
17596
|
-
name: "revision-integrity",
|
|
17597
|
-
ok: false,
|
|
17598
|
-
details: ["INCONCLUSIVE: tests/corpus/detector-hashes.json is unreadable/malformed — regenerate with `npm run detector-hashes:update`"]
|
|
17599
|
-
};
|
|
17660
|
+
return check("revision-integrity", "inconclusive", ["INCONCLUSIVE: tests/corpus/detector-hashes.json is unreadable/malformed — regenerate with `npm run detector-hashes:update`"]);
|
|
17600
17661
|
}
|
|
17601
17662
|
let current;
|
|
17602
17663
|
try {
|
|
17603
17664
|
current = computeDetectorHashes(rules, rulesDir);
|
|
17604
17665
|
} catch (e) {
|
|
17605
|
-
return {
|
|
17606
|
-
name: "revision-integrity",
|
|
17607
|
-
ok: false,
|
|
17608
|
-
details: [`INCONCLUSIVE: detector hash computation failed — ${errorText(e)}`]
|
|
17609
|
-
};
|
|
17666
|
+
return check("revision-integrity", "inconclusive", [`INCONCLUSIVE: detector hash computation failed — ${errorText(e)}`]);
|
|
17610
17667
|
}
|
|
17611
17668
|
const failures = [];
|
|
17612
17669
|
for (const rule of rules) {
|
|
@@ -17621,21 +17678,95 @@ function checkRevisionIntegrity(repoRoot, rules = RULES) {
|
|
|
17621
17678
|
for (const id of Object.keys(manifest)) if (!rules.some((r) => r.id === id)) failures.push(`${id}: attested in the manifest but not in the registry`);
|
|
17622
17679
|
if (failures.length > 0) {
|
|
17623
17680
|
details.push(...failures);
|
|
17624
|
-
return
|
|
17625
|
-
name: "revision-integrity",
|
|
17626
|
-
ok: false,
|
|
17627
|
-
details
|
|
17628
|
-
};
|
|
17681
|
+
return check("revision-integrity", "fail", details);
|
|
17629
17682
|
}
|
|
17630
17683
|
details.push(`${rules.length} rules attested: source identity and declared revision match the manifest (check A + check B, G4)`);
|
|
17631
|
-
return
|
|
17632
|
-
|
|
17633
|
-
|
|
17634
|
-
|
|
17635
|
-
|
|
17684
|
+
return check("revision-integrity", "pass", details);
|
|
17685
|
+
}
|
|
17686
|
+
/**
|
|
17687
|
+
* Check 8 (certification-audit Phase 1.4, D6 — HARD-BLOCKING):
|
|
17688
|
+
* category-integrity. Every registry rule's category must be a member of
|
|
17689
|
+
* the closed RULE_CATEGORIES set (compile-time exhaustive via the D6
|
|
17690
|
+
* derivation, so this check is the runtime backstop for values arriving
|
|
17691
|
+
* from external rule sources), and no two rules may collide on an id.
|
|
17692
|
+
* Registry ids are already unique-checked by registry-sanity; this check
|
|
17693
|
+
* owns the category dimension.
|
|
17694
|
+
*/
|
|
17695
|
+
function checkCategoryIntegrity(rules = RULES) {
|
|
17696
|
+
const details = [];
|
|
17697
|
+
const known = RULE_CATEGORIES;
|
|
17698
|
+
const unknown = rules.filter((r) => !known.includes(r.category));
|
|
17699
|
+
for (const r of unknown) details.push(`${r.id}: category "${r.category}" is not in RULE_CATEGORIES — registries may not invent categories (D6)`);
|
|
17700
|
+
const byCategory = /* @__PURE__ */ new Map();
|
|
17701
|
+
for (const r of rules) byCategory.set(r.category, (byCategory.get(r.category) ?? 0) + 1);
|
|
17702
|
+
return check("category-integrity", unknown.length === 0 ? "pass" : "fail", [`${byCategory.size} categories in use across ${rules.length} rules (closed set: ${RULE_CATEGORIES.length})`, ...details]);
|
|
17703
|
+
}
|
|
17704
|
+
/**
|
|
17705
|
+
* Check 10 (certification-audit Phase 4.1, D7 — HARD-BLOCKING):
|
|
17706
|
+
* measurement-consistency. Three invariants over the measurement surface:
|
|
17707
|
+
* (1) every MEASURED_FP entry's `n` equals the LIVE classified verdict
|
|
17708
|
+
* count (TP+FP rows in tests/corpus/verdicts/*.jsonl) — a snapshot
|
|
17709
|
+
* that drifted from the corpus is a lie about evidence;
|
|
17710
|
+
* (2) MEASURED_FP's detectorRevision equals the sidecar's
|
|
17711
|
+
* (tests/corpus/detector-revisions.json) — a measurement taken
|
|
17712
|
+
* against a different detector revision than attested is stale;
|
|
17713
|
+
* (3) every sidecar entry maps to a registered rule.
|
|
17714
|
+
* Mismatch → FAIL (certification-critical).
|
|
17715
|
+
*/
|
|
17716
|
+
function checkMeasurementConsistency(verdictsDir, sidecarPath, rules = RULES, measuredFp = MEASURED_FP) {
|
|
17717
|
+
const details = [];
|
|
17718
|
+
const failures = [];
|
|
17719
|
+
const registered = new Map(rules.map((r) => [r.id, r]));
|
|
17720
|
+
const liveCounts = /* @__PURE__ */ new Map();
|
|
17721
|
+
if (existsSync(verdictsDir)) for (const f of readdirSync(verdictsDir)) {
|
|
17722
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
17723
|
+
const lines = readFileSync(join(verdictsDir, f), "utf8").split("\n");
|
|
17724
|
+
for (const line of lines) {
|
|
17725
|
+
const trimmed = line.trim();
|
|
17726
|
+
if (trimmed.length === 0) continue;
|
|
17727
|
+
let row;
|
|
17728
|
+
try {
|
|
17729
|
+
row = JSON.parse(trimmed);
|
|
17730
|
+
} catch {
|
|
17731
|
+
failures.push(`${f}: unparseable verdict row (corpus integrity)`);
|
|
17732
|
+
continue;
|
|
17733
|
+
}
|
|
17734
|
+
if (row.ruleId === void 0) continue;
|
|
17735
|
+
if (row.verdict === "TP" || row.verdict === "FP") liveCounts.set(row.ruleId, (liveCounts.get(row.ruleId) ?? 0) + 1);
|
|
17736
|
+
}
|
|
17737
|
+
}
|
|
17738
|
+
else return check("measurement-consistency", "inconclusive", ["INCONCLUSIVE: verdicts directory missing — measurement consistency cannot be evaluated (certification-critical: never renders as pass)"]);
|
|
17739
|
+
let sidecar = {};
|
|
17740
|
+
if (existsSync(sidecarPath)) try {
|
|
17741
|
+
sidecar = JSON.parse(readFileSync(sidecarPath, "utf8"));
|
|
17742
|
+
} catch (e) {
|
|
17743
|
+
failures.push(`sidecar unreadable/malformed: ${errorText(e)} — regenerate the measurement artifacts`);
|
|
17744
|
+
}
|
|
17745
|
+
else {
|
|
17746
|
+
const needsSidecar = Object.keys(measuredFp).filter((id) => measuredFp[id]?.detectorRevision !== 1);
|
|
17747
|
+
if (needsSidecar.length > 0) return check("measurement-consistency", "inconclusive", [`INCONCLUSIVE: sidecar tests/corpus/detector-revisions.json missing but ${needsSidecar.length} measured rule(s) declare revisions — cannot verify measurement freshness`]);
|
|
17748
|
+
}
|
|
17749
|
+
for (const [id, m] of Object.entries(measuredFp)) {
|
|
17750
|
+
const side = sidecar[id];
|
|
17751
|
+
if (side !== void 0 && side.detectorRevision !== void 0) {
|
|
17752
|
+
if (side.detectorRevision !== m.detectorRevision) failures.push(`${id}: MEASURED_FP revision ${m.detectorRevision} ≠ sidecar ${side.detectorRevision} — the measurement is stale (§07: re-measure or bump)`);
|
|
17753
|
+
}
|
|
17754
|
+
const live = liveCounts.get(id);
|
|
17755
|
+
if (live !== void 0 && live !== m.n) failures.push(`${id}: MEASURED_FP.n=${m.n} but the live corpus has ${live} classified verdict(s) — regenerate (npm run fp-audit:generate)`);
|
|
17756
|
+
if (live === void 0 && m.n > 0) failures.push(`${id}: MEASURED_FP.n=${m.n} but no classified verdicts exist in the live corpus`);
|
|
17757
|
+
}
|
|
17758
|
+
for (const id of Object.keys(sidecar)) if (!registered.has(id)) failures.push(`${id}: sidecar entry for an unregistered rule — remove it from tests/corpus/detector-revisions.json`);
|
|
17759
|
+
if (failures.length > 0) {
|
|
17760
|
+
details.push(...failures);
|
|
17761
|
+
return check("measurement-consistency", "fail", details);
|
|
17762
|
+
}
|
|
17763
|
+
const block = measurementBlock(rules);
|
|
17764
|
+
details.push(`${block.measured} measured / ${block.unmeasured} unmeasured / ${block.total} total (${block.quarantine} quarantine) — MEASURED_FP, live verdicts and sidecar revisions agree`);
|
|
17765
|
+
return check("measurement-consistency", "pass", details);
|
|
17636
17766
|
}
|
|
17637
17767
|
function runDoctorSelfAudit(fixturesRoot) {
|
|
17638
17768
|
const verdictsDir = join(fixturesRoot, "..", "corpus", "verdicts");
|
|
17769
|
+
const repoRoot = join(fixturesRoot, "..", "..");
|
|
17639
17770
|
const checks = [
|
|
17640
17771
|
checkFixtureFirewall(fixturesRoot),
|
|
17641
17772
|
checkRegistry(),
|
|
@@ -17644,12 +17775,15 @@ function runDoctorSelfAudit(fixturesRoot) {
|
|
|
17644
17775
|
checkTierEnforcement(verdictsDir),
|
|
17645
17776
|
checkAntiCreep(),
|
|
17646
17777
|
checkQuarantineEnforcement(),
|
|
17778
|
+
checkCategoryIntegrity(),
|
|
17647
17779
|
checkFixtureIntegrity(fixturesRoot),
|
|
17648
|
-
checkRevisionIntegrity(
|
|
17780
|
+
checkRevisionIntegrity(repoRoot),
|
|
17781
|
+
checkMeasurementConsistency(verdictsDir, join(repoRoot, "tests", "corpus", "detector-revisions.json"))
|
|
17649
17782
|
];
|
|
17650
17783
|
return {
|
|
17651
17784
|
checks,
|
|
17652
|
-
healthy: checks.every((c) => c.
|
|
17785
|
+
healthy: checks.every((c) => c.status === "pass"),
|
|
17786
|
+
measurement: measurementBlock()
|
|
17653
17787
|
};
|
|
17654
17788
|
}
|
|
17655
17789
|
function renderDoctorReport(report) {
|
|
@@ -17659,7 +17793,7 @@ function renderDoctorReport(report) {
|
|
|
17659
17793
|
""
|
|
17660
17794
|
];
|
|
17661
17795
|
for (const c of report.checks) {
|
|
17662
|
-
const mark = c.
|
|
17796
|
+
const mark = c.status === "pass" ? "✓" : c.status === "fail" ? "✗" : "? INCONCLUSIVE";
|
|
17663
17797
|
lines.push(`${mark} ${c.name}`);
|
|
17664
17798
|
for (const d of c.details.slice(0, 20)) lines.push(` ${d}`);
|
|
17665
17799
|
if (c.details.length > 20) lines.push(` … and ${c.details.length - 20} more`);
|
|
@@ -17668,6 +17802,89 @@ function renderDoctorReport(report) {
|
|
|
17668
17802
|
lines.push(report.healthy ? "Mjölnir self-audit: WORTHY" : "Mjölnir self-audit: VIOLATIONS FOUND");
|
|
17669
17803
|
return lines.join("\n");
|
|
17670
17804
|
}
|
|
17805
|
+
/** Versioned JSON schema name for `doctor --json` (G5). */
|
|
17806
|
+
const DOCTOR_REPORT_SCHEMA = "mjolnir.doctor-report@1";
|
|
17807
|
+
/**
|
|
17808
|
+
* Serializes the doctor report to the machine-readable contract (Phase 5,
|
|
17809
|
+
* G5): versioned `schema` field, stable key order (constructed once, here),
|
|
17810
|
+
* details limited exactly like the text render, paths already POSIX-relative
|
|
17811
|
+
* (the checks build them that way), and byte-identical across two runs on
|
|
17812
|
+
* the same tree — the CI certification job re-runs the command twice and
|
|
17813
|
+
* diffs, so any nondeterminism fails there.
|
|
17814
|
+
*/
|
|
17815
|
+
function doctorReportJson(report, opts = {}) {
|
|
17816
|
+
const maxDetails = opts.maxDetails ?? 20;
|
|
17817
|
+
const summary = {
|
|
17818
|
+
pass: 0,
|
|
17819
|
+
fail: 0,
|
|
17820
|
+
inconclusive: 0
|
|
17821
|
+
};
|
|
17822
|
+
const checks = report.checks.map((c) => {
|
|
17823
|
+
summary[c.status]++;
|
|
17824
|
+
return {
|
|
17825
|
+
name: c.name,
|
|
17826
|
+
status: c.status,
|
|
17827
|
+
ok: c.ok,
|
|
17828
|
+
details: c.details.length > maxDetails ? [...c.details.slice(0, maxDetails), `… and ${c.details.length - maxDetails} more`] : c.details
|
|
17829
|
+
};
|
|
17830
|
+
});
|
|
17831
|
+
return {
|
|
17832
|
+
schema: DOCTOR_REPORT_SCHEMA,
|
|
17833
|
+
healthy: report.healthy,
|
|
17834
|
+
summary,
|
|
17835
|
+
checks,
|
|
17836
|
+
measurement: report.measurement
|
|
17837
|
+
};
|
|
17838
|
+
}
|
|
17839
|
+
//#endregion
|
|
17840
|
+
//#region src/commands/doctor-run.ts
|
|
17841
|
+
/**
|
|
17842
|
+
* Doctor CLI entry (certification-audit Phase 5, G6): the command handler
|
|
17843
|
+
* lives beside the check implementations instead of in cli.ts — the CLI
|
|
17844
|
+
* file is a dispatch table, not a business-logic home.
|
|
17845
|
+
*
|
|
17846
|
+
* Exit-code contract (stable, e2e-locked):
|
|
17847
|
+
* 0 — WORTHY (every check pass)
|
|
17848
|
+
* 1 — VIOLATIONS FOUND (any fail OR inconclusive — G2: an
|
|
17849
|
+
* INCONCLUSIVE check never renders as pass, in text or JSON, and
|
|
17850
|
+
* never exits 0)
|
|
17851
|
+
* 2 — no fixtures directory at the target (not an mjolnir checkout)
|
|
17852
|
+
* 10 — usage error (unknown flag / flag-shaped positional)
|
|
17853
|
+
* 20 — internal error (crash; --debug prints the stack)
|
|
17854
|
+
*
|
|
17855
|
+
* `--json`: prints the versioned machine contract (doctorReportJson)
|
|
17856
|
+
* instead of the text render. stdout carries EXACTLY the JSON document —
|
|
17857
|
+
* no banners, no progress lines — so `doctor . --json > report.json` is
|
|
17858
|
+
* a clean file. The exit code is the gate; the JSON is the evidence.
|
|
17859
|
+
*/
|
|
17860
|
+
function runDoctorCommand(argv, io = {
|
|
17861
|
+
out,
|
|
17862
|
+
err
|
|
17863
|
+
}) {
|
|
17864
|
+
const json = argv.includes("--json");
|
|
17865
|
+
if (argv.filter((a) => a.startsWith("-") && a !== "--json").length > 0) {
|
|
17866
|
+
io.err("Usage: mjolnir doctor [--json] [repo-root]");
|
|
17867
|
+
return 10;
|
|
17868
|
+
}
|
|
17869
|
+
const targetArg = argv.find((a) => !a.startsWith("-")) ?? process.cwd();
|
|
17870
|
+
try {
|
|
17871
|
+
const fixturesRoot = resolve(join(targetArg, "tests", "fixtures"));
|
|
17872
|
+
if (!existsSync(fixturesRoot)) {
|
|
17873
|
+
io.err(`No fixtures directory at ${fixturesRoot}. Run from the mjolnir repo root.`);
|
|
17874
|
+
return 2;
|
|
17875
|
+
}
|
|
17876
|
+
const report = runDoctorSelfAudit(fixturesRoot);
|
|
17877
|
+
if (json) {
|
|
17878
|
+
io.out(JSON.stringify(doctorReportJson(report), null, 2));
|
|
17879
|
+
return report.healthy ? 0 : 1;
|
|
17880
|
+
}
|
|
17881
|
+
io.out(renderDoctorReport(report));
|
|
17882
|
+
return report.healthy ? 0 : 1;
|
|
17883
|
+
} catch (err) {
|
|
17884
|
+
internalErrorMessage(err, io.err, process.argv.includes("--debug"));
|
|
17885
|
+
return 20;
|
|
17886
|
+
}
|
|
17887
|
+
}
|
|
17671
17888
|
//#endregion
|
|
17672
17889
|
//#region src/commands/rules-catalog.ts
|
|
17673
17890
|
/**
|
|
@@ -18038,7 +18255,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
|
|
|
18038
18255
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
18039
18256
|
* `tests/version-consistency.spec.ts` locally.
|
|
18040
18257
|
*/
|
|
18041
|
-
const CLI_VERSION = "0.5.
|
|
18258
|
+
const CLI_VERSION = "0.5.23";
|
|
18042
18259
|
function parseArgs(argv, onError) {
|
|
18043
18260
|
const args = {
|
|
18044
18261
|
target: ".",
|
|
@@ -18203,8 +18420,6 @@ function parseArgsOrUsage(argv, io) {
|
|
|
18203
18420
|
if (!args && !reported) printUsage(io.out);
|
|
18204
18421
|
return args;
|
|
18205
18422
|
}
|
|
18206
|
-
const out = (...parts) => console.log(parts.map(String).join(" "));
|
|
18207
|
-
const err = (...parts) => console.error(parts.map(String).join(" "));
|
|
18208
18423
|
/** Testable `ci install` handler. Returns the process exit code. */
|
|
18209
18424
|
function runCiInstall(argv, io = {
|
|
18210
18425
|
out,
|
|
@@ -18326,30 +18541,6 @@ async function runDoctorPlaywright(argv, io = { out }) {
|
|
|
18326
18541
|
return 20;
|
|
18327
18542
|
}
|
|
18328
18543
|
}
|
|
18329
|
-
/** Testable `doctor` handler — self-audit of Mjölnir's own rule base. */
|
|
18330
|
-
function runDoctorCommand(argv, io = {
|
|
18331
|
-
out,
|
|
18332
|
-
err
|
|
18333
|
-
}) {
|
|
18334
|
-
if (argv.some((a) => a.startsWith("-"))) {
|
|
18335
|
-
io.err("Usage: mjolnir doctor [repo-root]");
|
|
18336
|
-
return 10;
|
|
18337
|
-
}
|
|
18338
|
-
const targetArg = argv[0] ?? process$1.cwd();
|
|
18339
|
-
try {
|
|
18340
|
-
const fixturesRoot = resolve(join(targetArg, "tests", "fixtures"));
|
|
18341
|
-
if (!existsSync(fixturesRoot)) {
|
|
18342
|
-
io.err(`No fixtures directory at ${fixturesRoot}. Run from the mjolnir repo root.`);
|
|
18343
|
-
return 2;
|
|
18344
|
-
}
|
|
18345
|
-
const report = runDoctorSelfAudit(fixturesRoot);
|
|
18346
|
-
io.out(renderDoctorReport(report));
|
|
18347
|
-
return report.healthy ? 0 : 1;
|
|
18348
|
-
} catch (err) {
|
|
18349
|
-
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
18350
|
-
return 20;
|
|
18351
|
-
}
|
|
18352
|
-
}
|
|
18353
18544
|
/** Testable `rules` handler — rule catalog with Trust Metadata. */
|
|
18354
18545
|
async function runRulesCommand(argv, io = {
|
|
18355
18546
|
out,
|
|
@@ -18985,22 +19176,6 @@ function runHelpCommand(argv, io = {
|
|
|
18985
19176
|
function printUsage(print) {
|
|
18986
19177
|
print(renderRootHelp());
|
|
18987
19178
|
}
|
|
18988
|
-
/**
|
|
18989
|
-
* Friendly exit-20 path (plan M2): the crash says it's Mjölnir's bug,
|
|
18990
|
-
* not the user's repo, carries the underlying message for a report, and
|
|
18991
|
-
* prints the stack ONLY when `debug` is set (uniform across
|
|
18992
|
-
* subcommands — they don't parse scan flags). Tests pin
|
|
18993
|
-
* /internal error/i. Exported so the --debug stack arm is directly
|
|
18994
|
-
* spec-coverable (spawning a real crash under --debug would be flaky).
|
|
18995
|
-
*/
|
|
18996
|
-
function internalErrorMessage(err, emit, debug) {
|
|
18997
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
18998
|
-
emit("mjolnir internal error — this is a bug in Mjölnir, not your repo:");
|
|
18999
|
-
emit(` ${message}`);
|
|
19000
|
-
if (debug && err instanceof Error && err.stack) emit(err.stack);
|
|
19001
|
-
emit("Rerun with --debug for the stack trace. Please report this:");
|
|
19002
|
-
emit(" https://github.com/Sergey-Bar/Mjolnir/issues");
|
|
19003
|
-
}
|
|
19004
19179
|
function isEntryPoint() {
|
|
19005
19180
|
const argv1 = process$1.argv[1];
|
|
19006
19181
|
if (!argv1) return false;
|
package/package.json
CHANGED