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/cli.mjs
CHANGED
|
@@ -38,16 +38,42 @@ const QA_IMPACT_LABELS = {
|
|
|
38
38
|
"FALSE-GREEN": "False-green risk",
|
|
39
39
|
HYGIENE: "Test hygiene debt"
|
|
40
40
|
};
|
|
41
|
-
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
41
|
+
/**
|
|
42
|
+
* The closed set of rule categories (plan §5.5, certification-audit D6).
|
|
43
|
+
* Declared FIRST as the single source of truth: the RuleCategory type is
|
|
44
|
+
* DERIVED from this list (compile-time exhaustiveness — a category added
|
|
45
|
+
* to the type without the value list, or vice versa, cannot compile).
|
|
46
|
+
* `--category` values are validated against this list — unknown
|
|
47
|
+
* categories are a usage error, not a silent no-op. Rule namespaces are
|
|
48
|
+
* frozen public API (§18.4): IDs are never reused.
|
|
49
|
+
*/
|
|
44
50
|
const RULE_CATEGORIES = [
|
|
45
51
|
"QA-TEST",
|
|
46
52
|
"QA-TQUAL",
|
|
47
53
|
"QA-PW",
|
|
48
|
-
"QA-CI"
|
|
54
|
+
"QA-CI",
|
|
55
|
+
"QA-PY",
|
|
56
|
+
"QA-ENV",
|
|
57
|
+
"QA-JV",
|
|
58
|
+
"QA-CS",
|
|
59
|
+
"QA-CYP",
|
|
60
|
+
"QA-SE",
|
|
61
|
+
"QA-WDIO",
|
|
62
|
+
"QA-PPTR",
|
|
63
|
+
"QA-APM"
|
|
49
64
|
];
|
|
50
65
|
/**
|
|
66
|
+
* Category argument validation shared by every verb accepting
|
|
67
|
+
* `--category` (scan/why/handoff — certification-audit Phase 1.2): the
|
|
68
|
+
* three verbs must never disagree about what a valid category is. A
|
|
69
|
+
* missing value or an unknown value is a usage error (exit 10) at the
|
|
70
|
+
* caller — this helper returns the verdict, the caller renders the
|
|
71
|
+
* rejection.
|
|
72
|
+
*/
|
|
73
|
+
function isValidCategory(value) {
|
|
74
|
+
return value !== void 0 && RULE_CATEGORIES.includes(value);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
51
77
|
* Honest default evidence level for a finding (Honesty Core Phase 1).
|
|
52
78
|
* Derivation is deterministic and conservative:
|
|
53
79
|
* observation → E0 (never proof)
|
|
@@ -13264,7 +13290,7 @@ function renderSarif(result, repoRootUri) {
|
|
|
13264
13290
|
tool: { driver: {
|
|
13265
13291
|
name: "Mjölnir",
|
|
13266
13292
|
informationUri: "https://github.com/Sergey-Bar/Mjolnir",
|
|
13267
|
-
version: "0.5.
|
|
13293
|
+
version: "0.5.23",
|
|
13268
13294
|
rules: [...rules.values()].map((r) => {
|
|
13269
13295
|
const meta = RULES.find((x) => x.id === r.id);
|
|
13270
13296
|
return {
|
|
@@ -14026,6 +14052,20 @@ async function runWhyCommand(argv, io = {
|
|
|
14026
14052
|
out: (line) => console.log(line),
|
|
14027
14053
|
err: (line) => console.error(line)
|
|
14028
14054
|
}) {
|
|
14055
|
+
const catIdxs = [];
|
|
14056
|
+
argv.forEach((a, i) => {
|
|
14057
|
+
if (a === "--category") catIdxs.push(i + 1);
|
|
14058
|
+
});
|
|
14059
|
+
const categories = [];
|
|
14060
|
+
for (const i of catIdxs) {
|
|
14061
|
+
const value = argv[i];
|
|
14062
|
+
if (!isValidCategory(value)) {
|
|
14063
|
+
io.err(`mjolnir why: unknown --category: ${value === void 0 ? "(missing value)" : value}`);
|
|
14064
|
+
io.err(` Valid categories: ${RULE_CATEGORIES.join(", ")}`);
|
|
14065
|
+
return 10;
|
|
14066
|
+
}
|
|
14067
|
+
categories.push(value);
|
|
14068
|
+
}
|
|
14029
14069
|
const locationToken = argv.find((a) => !a.startsWith("-"));
|
|
14030
14070
|
if (!locationToken) {
|
|
14031
14071
|
io.err("Usage: mjolnir why <file>:<line> [--json <mjolnir.json>]");
|
|
@@ -14038,7 +14078,10 @@ async function runWhyCommand(argv, io = {
|
|
|
14038
14078
|
}
|
|
14039
14079
|
const jsonIdx = argv.indexOf("--json");
|
|
14040
14080
|
const reportPath = jsonIdx !== -1 ? argv[jsonIdx + 1] : void 0;
|
|
14041
|
-
const
|
|
14081
|
+
const valuePositions = /* @__PURE__ */ new Set();
|
|
14082
|
+
if (jsonIdx !== -1) valuePositions.add(jsonIdx + 1);
|
|
14083
|
+
for (const i of catIdxs) valuePositions.add(i);
|
|
14084
|
+
const targetIdx = argv.findIndex((a, i) => !a.startsWith("-") && i !== 0 && !valuePositions.has(i));
|
|
14042
14085
|
const target = targetIdx !== -1 ? argv[targetIdx] : ".";
|
|
14043
14086
|
let result;
|
|
14044
14087
|
if (reportPath !== void 0) {
|
|
@@ -14074,11 +14117,6 @@ async function runWhyCommand(argv, io = {
|
|
|
14074
14117
|
}
|
|
14075
14118
|
}
|
|
14076
14119
|
const match = explainAt(result.findings, location.file, location.line);
|
|
14077
|
-
const catIdxs = [];
|
|
14078
|
-
argv.forEach((a, i) => {
|
|
14079
|
-
if (a === "--category") catIdxs.push(i + 1);
|
|
14080
|
-
});
|
|
14081
|
-
const categories = catIdxs.map((i) => argv[i]).filter((c) => c !== void 0);
|
|
14082
14120
|
const filtered = categories.length > 0 ? match.findings.filter((f) => categories.includes(f.category)) : match.findings;
|
|
14083
14121
|
io.out(renderWhy({
|
|
14084
14122
|
...match,
|
|
@@ -14088,6 +14126,28 @@ async function runWhyCommand(argv, io = {
|
|
|
14088
14126
|
}
|
|
14089
14127
|
//#endregion
|
|
14090
14128
|
//#region src/commands/handoff.ts
|
|
14129
|
+
/**
|
|
14130
|
+
* `mjolnir handoff [mjolnir.json]` — the deterministic fix-handoff
|
|
14131
|
+
* artifact (agent-handoff plan M3).
|
|
14132
|
+
*
|
|
14133
|
+
* Reads a saved `--json` report (shared loader, report-io.ts) and
|
|
14134
|
+
* renders a self-contained Markdown remediation plan an agent (or a
|
|
14135
|
+
* human) can execute offline. Every instruction is derived from
|
|
14136
|
+
* Mjölnir's own rule metadata — the generator produces INSTRUCTIONS,
|
|
14137
|
+
* never executable commands, and no prompt text is fetched or copied
|
|
14138
|
+
* from any external source.
|
|
14139
|
+
*
|
|
14140
|
+
* The artifact embeds the formal verification contract (plan §5.3):
|
|
14141
|
+
* what was detected, what should change, what justifies it (evidence
|
|
14142
|
+
* boundary per level), what to re-run, what counts as verified
|
|
14143
|
+
* (TARGET_RESOLVED / TARGET_REMAINS / NEW_FINDINGS_INTRODUCED /
|
|
14144
|
+
* VERIFICATION_NOT_RUN, correlated via the fingerprint rule), and the
|
|
14145
|
+
* standing caveat that a clean `--scope changed` run verifies the
|
|
14146
|
+
* changed-scope surface only — not that the repository is clean.
|
|
14147
|
+
*
|
|
14148
|
+
* Exit codes (frozen contract): 0 on success · 10 missing file ·
|
|
14149
|
+
* 2 unreadable/invalid JSON.
|
|
14150
|
+
*/
|
|
14091
14151
|
const OCCURRENCE_CAP = 25;
|
|
14092
14152
|
const EVIDENCE_BOUNDARY = {
|
|
14093
14153
|
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.",
|
|
@@ -14317,6 +14377,11 @@ function runHandoffCommand(argv, io = {
|
|
|
14317
14377
|
}));
|
|
14318
14378
|
return 10;
|
|
14319
14379
|
}
|
|
14380
|
+
if (a === "--category" && !isValidCategory(val)) {
|
|
14381
|
+
io.err(`mjolnir handoff: unknown --category: ${val}`);
|
|
14382
|
+
io.err(` Valid categories: ${RULE_CATEGORIES.join(", ")}`);
|
|
14383
|
+
return 10;
|
|
14384
|
+
}
|
|
14320
14385
|
i++;
|
|
14321
14386
|
continue;
|
|
14322
14387
|
}
|
|
@@ -17630,6 +17695,18 @@ function renderFixReport(results, dryRun) {
|
|
|
17630
17695
|
return lines.join("\n");
|
|
17631
17696
|
}
|
|
17632
17697
|
//#endregion
|
|
17698
|
+
//#region src/cli-io.ts
|
|
17699
|
+
const out = (...parts) => console.log(parts.map(String).join(" "));
|
|
17700
|
+
const err = (...parts) => console.error(parts.map(String).join(" "));
|
|
17701
|
+
function internalErrorMessage(err, emit, debug) {
|
|
17702
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
17703
|
+
emit("mjolnir internal error — this is a bug in Mjölnir, not your repo:");
|
|
17704
|
+
emit(` ${message}`);
|
|
17705
|
+
if (debug && err instanceof Error && err.stack) emit(err.stack);
|
|
17706
|
+
emit("Rerun with --debug for the stack trace. Please report this:");
|
|
17707
|
+
emit(" https://github.com/Sergey-Bar/Mjolnir/issues");
|
|
17708
|
+
}
|
|
17709
|
+
//#endregion
|
|
17633
17710
|
//#region src/engine/detector-hash.ts
|
|
17634
17711
|
/**
|
|
17635
17712
|
* Detector revision integrity engine (certification-audit Phase 3,
|
|
@@ -17837,6 +17914,15 @@ function errorText(e) {
|
|
|
17837
17914
|
return e instanceof Error ? e.message : String(e);
|
|
17838
17915
|
}
|
|
17839
17916
|
const ui$1 = plainContext();
|
|
17917
|
+
/** Builds the check record: `ok` is always the pass-mirror of `status`. */
|
|
17918
|
+
function check(name, status, details) {
|
|
17919
|
+
return {
|
|
17920
|
+
name,
|
|
17921
|
+
status,
|
|
17922
|
+
ok: status === "pass",
|
|
17923
|
+
details
|
|
17924
|
+
};
|
|
17925
|
+
}
|
|
17840
17926
|
const VALID_ID = RULE_ID_RE;
|
|
17841
17927
|
function nonHiddenFiles(dir) {
|
|
17842
17928
|
if (!existsSync(dir)) return [];
|
|
@@ -17859,11 +17945,7 @@ function checkFixtureFirewall(fixturesRoot) {
|
|
|
17859
17945
|
details.push(`${rule.id}: missing must-not-fire fixture`);
|
|
17860
17946
|
}
|
|
17861
17947
|
}
|
|
17862
|
-
return
|
|
17863
|
-
name: "fixture-firewall",
|
|
17864
|
-
ok,
|
|
17865
|
-
details
|
|
17866
|
-
};
|
|
17948
|
+
return check("fixture-firewall", ok ? "pass" : "fail", details);
|
|
17867
17949
|
}
|
|
17868
17950
|
/** Check 2: registry sanity — IDs unique, well-formed, titles distinct. */
|
|
17869
17951
|
function checkRegistry(rules = RULES) {
|
|
@@ -17886,20 +17968,12 @@ function checkRegistry(rules = RULES) {
|
|
|
17886
17968
|
details.push(`"${r.title}": duplicate title in family (${r.id})`);
|
|
17887
17969
|
}
|
|
17888
17970
|
}
|
|
17889
|
-
return
|
|
17890
|
-
name: "registry-sanity",
|
|
17891
|
-
ok,
|
|
17892
|
-
details
|
|
17893
|
-
};
|
|
17971
|
+
return check("registry-sanity", ok ? "pass" : "fail", details);
|
|
17894
17972
|
}
|
|
17895
17973
|
/** Check 3: Trust Metadata presence (informational until full coverage). */
|
|
17896
17974
|
function checkTrustMetadata(rules = RULES) {
|
|
17897
17975
|
const missing = rules.filter((r) => !r.languages?.length || !r.frameworks?.length || r.falsePositiveRisk === void 0);
|
|
17898
|
-
return {
|
|
17899
|
-
name: "trust-metadata",
|
|
17900
|
-
ok: missing.length === 0,
|
|
17901
|
-
details: missing.map((r) => `${r.id}: missing trust metadata`)
|
|
17902
|
-
};
|
|
17976
|
+
return check("trust-metadata", missing.length === 0 ? "pass" : "fail", missing.map((r) => `${r.id}: missing trust metadata`));
|
|
17903
17977
|
}
|
|
17904
17978
|
/**
|
|
17905
17979
|
* Check 4 (Honesty Core): evidence-level honesty. A rule that claims a
|
|
@@ -17918,10 +17992,25 @@ function checkEvidenceHonesty(rules = RULES) {
|
|
|
17918
17992
|
details.push(`${r.id}: declares ${r.evidenceLevel} but findingType=${r.findingType}/confidence=${r.confidence} supports at most ${derived}`);
|
|
17919
17993
|
}
|
|
17920
17994
|
}
|
|
17995
|
+
return check("evidence-honesty", ok ? "pass" : "fail", details);
|
|
17996
|
+
}
|
|
17997
|
+
/**
|
|
17998
|
+
* Measurement census (Phase 4.3): measured = rules with a valid
|
|
17999
|
+
* MEASURED_FP entry; unmeasured = the rest; quarantine = measured rules
|
|
18000
|
+
* whose effective tier is quarantine. One function, used by the doctor
|
|
18001
|
+
* report AND the JSON contract — a single reproducible answer.
|
|
18002
|
+
*/
|
|
18003
|
+
function measurementBlock(rules = RULES) {
|
|
18004
|
+
const measured = rules.filter((r) => {
|
|
18005
|
+
const m = MEASURED_FP[r.id];
|
|
18006
|
+
return m !== void 0 && m.detectorRevision === declaredDetectorRevision(r);
|
|
18007
|
+
});
|
|
18008
|
+
const quarantine = measured.filter((r) => effectiveTier(r) === "quarantine");
|
|
17921
18009
|
return {
|
|
17922
|
-
|
|
17923
|
-
|
|
17924
|
-
|
|
18010
|
+
measured: measured.length,
|
|
18011
|
+
unmeasured: rules.length - measured.length,
|
|
18012
|
+
total: rules.length,
|
|
18013
|
+
quarantine: quarantine.length
|
|
17925
18014
|
};
|
|
17926
18015
|
}
|
|
17927
18016
|
/**
|
|
@@ -17963,11 +18052,7 @@ function checkTierEnforcement(verdictsDir, rules = RULES) {
|
|
|
17963
18052
|
const total = coreRules.length;
|
|
17964
18053
|
const ok = unmeasured <= 0;
|
|
17965
18054
|
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`);
|
|
17966
|
-
return
|
|
17967
|
-
name: "tier-enforcement",
|
|
17968
|
-
ok,
|
|
17969
|
-
details
|
|
17970
|
-
};
|
|
18055
|
+
return check("tier-enforcement", ok ? "pass" : "fail", details);
|
|
17971
18056
|
}
|
|
17972
18057
|
/**
|
|
17973
18058
|
* Check 6 (Phase 7 — Tempering Plan): anti-creep law enforcement.
|
|
@@ -17986,11 +18071,7 @@ function checkAntiCreep(rules = RULES) {
|
|
|
17986
18071
|
for (const r of overflow.slice(0, 5)) details.push(` overflow: ${r.id} — ${r.title}`);
|
|
17987
18072
|
if (overflow.length > 5) details.push(` … and ${overflow.length - 5} more`);
|
|
17988
18073
|
} else details.push(`Core tier: ${count}/65 rules (${65 - count} slots available)`);
|
|
17989
|
-
return
|
|
17990
|
-
name: "anti-creep",
|
|
17991
|
-
ok,
|
|
17992
|
-
details
|
|
17993
|
-
};
|
|
18074
|
+
return check("anti-creep", ok ? "pass" : "fail", details);
|
|
17994
18075
|
}
|
|
17995
18076
|
/**
|
|
17996
18077
|
* Check 7 (audit H-1): quarantine enforcement. The tier policy must cap
|
|
@@ -18010,11 +18091,7 @@ function checkQuarantineEnforcement(rules = RULES) {
|
|
|
18010
18091
|
}
|
|
18011
18092
|
}
|
|
18012
18093
|
details.unshift(`${quarantine.length} quarantine rules capped to severity=info, evidence=E0 — no quarantine rule may emit error`);
|
|
18013
|
-
return
|
|
18014
|
-
name: "quarantine-enforcement",
|
|
18015
|
-
ok,
|
|
18016
|
-
details
|
|
18017
|
-
};
|
|
18094
|
+
return check("quarantine-enforcement", ok ? "pass" : "fail", details);
|
|
18018
18095
|
}
|
|
18019
18096
|
/**
|
|
18020
18097
|
* Check 8 (certification-audit Phase 2.5, plan G3 Layer A support):
|
|
@@ -18067,11 +18144,7 @@ function checkFixtureIntegrity(fixturesRoot, rules = RULES) {
|
|
|
18067
18144
|
details.push("typecheck-allowlist.json is unreadable/malformed");
|
|
18068
18145
|
}
|
|
18069
18146
|
details.unshift(`fixture trees: ${fixtureDirs} rule dirs, ${fixtureFiles} fixture files — orphaned dirs and empty dirs are blocking`);
|
|
18070
|
-
return
|
|
18071
|
-
name: "fixture-integrity",
|
|
18072
|
-
ok,
|
|
18073
|
-
details
|
|
18074
|
-
};
|
|
18147
|
+
return check("fixture-integrity", ok ? "pass" : "fail", details);
|
|
18075
18148
|
}
|
|
18076
18149
|
/**
|
|
18077
18150
|
* Check 9 (certification-audit Phase 3.3, G4/D8v2 — HARD-BLOCKING):
|
|
@@ -18096,35 +18169,19 @@ function checkRevisionIntegrity(repoRoot, rules = RULES) {
|
|
|
18096
18169
|
const details = [];
|
|
18097
18170
|
const manifestPath = join(repoRoot, "tests", "corpus", "detector-hashes.json");
|
|
18098
18171
|
const rulesDir = join(repoRoot, "src", "rules");
|
|
18099
|
-
if (!existsSync(manifestPath)) return
|
|
18100
|
-
|
|
18101
|
-
ok: false,
|
|
18102
|
-
details: ["INCONCLUSIVE: tests/corpus/detector-hashes.json is missing — run `npm run detector-hashes:update` (certification-critical: an unevaluable check never renders as pass)"]
|
|
18103
|
-
};
|
|
18104
|
-
if (!existsSync(rulesDir)) return {
|
|
18105
|
-
name: "revision-integrity",
|
|
18106
|
-
ok: false,
|
|
18107
|
-
details: ["INCONCLUSIVE: src/rules is not present (installed package) — detector source identity cannot be verified here"]
|
|
18108
|
-
};
|
|
18172
|
+
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)"]);
|
|
18173
|
+
if (!existsSync(rulesDir)) return check("revision-integrity", "inconclusive", ["INCONCLUSIVE: src/rules is not present (installed package) — detector source identity cannot be verified here"]);
|
|
18109
18174
|
let manifest;
|
|
18110
18175
|
try {
|
|
18111
18176
|
manifest = loadManifest(manifestPath);
|
|
18112
18177
|
} catch {
|
|
18113
|
-
return
|
|
18114
|
-
name: "revision-integrity",
|
|
18115
|
-
ok: false,
|
|
18116
|
-
details: ["INCONCLUSIVE: tests/corpus/detector-hashes.json is unreadable/malformed — regenerate with `npm run detector-hashes:update`"]
|
|
18117
|
-
};
|
|
18178
|
+
return check("revision-integrity", "inconclusive", ["INCONCLUSIVE: tests/corpus/detector-hashes.json is unreadable/malformed — regenerate with `npm run detector-hashes:update`"]);
|
|
18118
18179
|
}
|
|
18119
18180
|
let current;
|
|
18120
18181
|
try {
|
|
18121
18182
|
current = computeDetectorHashes(rules, rulesDir);
|
|
18122
18183
|
} catch (e) {
|
|
18123
|
-
return {
|
|
18124
|
-
name: "revision-integrity",
|
|
18125
|
-
ok: false,
|
|
18126
|
-
details: [`INCONCLUSIVE: detector hash computation failed — ${errorText(e)}`]
|
|
18127
|
-
};
|
|
18184
|
+
return check("revision-integrity", "inconclusive", [`INCONCLUSIVE: detector hash computation failed — ${errorText(e)}`]);
|
|
18128
18185
|
}
|
|
18129
18186
|
const failures = [];
|
|
18130
18187
|
for (const rule of rules) {
|
|
@@ -18139,21 +18196,95 @@ function checkRevisionIntegrity(repoRoot, rules = RULES) {
|
|
|
18139
18196
|
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`);
|
|
18140
18197
|
if (failures.length > 0) {
|
|
18141
18198
|
details.push(...failures);
|
|
18142
|
-
return
|
|
18143
|
-
name: "revision-integrity",
|
|
18144
|
-
ok: false,
|
|
18145
|
-
details
|
|
18146
|
-
};
|
|
18199
|
+
return check("revision-integrity", "fail", details);
|
|
18147
18200
|
}
|
|
18148
18201
|
details.push(`${rules.length} rules attested: source identity and declared revision match the manifest (check A + check B, G4)`);
|
|
18149
|
-
return
|
|
18150
|
-
|
|
18151
|
-
|
|
18152
|
-
|
|
18153
|
-
|
|
18202
|
+
return check("revision-integrity", "pass", details);
|
|
18203
|
+
}
|
|
18204
|
+
/**
|
|
18205
|
+
* Check 8 (certification-audit Phase 1.4, D6 — HARD-BLOCKING):
|
|
18206
|
+
* category-integrity. Every registry rule's category must be a member of
|
|
18207
|
+
* the closed RULE_CATEGORIES set (compile-time exhaustive via the D6
|
|
18208
|
+
* derivation, so this check is the runtime backstop for values arriving
|
|
18209
|
+
* from external rule sources), and no two rules may collide on an id.
|
|
18210
|
+
* Registry ids are already unique-checked by registry-sanity; this check
|
|
18211
|
+
* owns the category dimension.
|
|
18212
|
+
*/
|
|
18213
|
+
function checkCategoryIntegrity(rules = RULES) {
|
|
18214
|
+
const details = [];
|
|
18215
|
+
const known = RULE_CATEGORIES;
|
|
18216
|
+
const unknown = rules.filter((r) => !known.includes(r.category));
|
|
18217
|
+
for (const r of unknown) details.push(`${r.id}: category "${r.category}" is not in RULE_CATEGORIES — registries may not invent categories (D6)`);
|
|
18218
|
+
const byCategory = /* @__PURE__ */ new Map();
|
|
18219
|
+
for (const r of rules) byCategory.set(r.category, (byCategory.get(r.category) ?? 0) + 1);
|
|
18220
|
+
return check("category-integrity", unknown.length === 0 ? "pass" : "fail", [`${byCategory.size} categories in use across ${rules.length} rules (closed set: ${RULE_CATEGORIES.length})`, ...details]);
|
|
18221
|
+
}
|
|
18222
|
+
/**
|
|
18223
|
+
* Check 10 (certification-audit Phase 4.1, D7 — HARD-BLOCKING):
|
|
18224
|
+
* measurement-consistency. Three invariants over the measurement surface:
|
|
18225
|
+
* (1) every MEASURED_FP entry's `n` equals the LIVE classified verdict
|
|
18226
|
+
* count (TP+FP rows in tests/corpus/verdicts/*.jsonl) — a snapshot
|
|
18227
|
+
* that drifted from the corpus is a lie about evidence;
|
|
18228
|
+
* (2) MEASURED_FP's detectorRevision equals the sidecar's
|
|
18229
|
+
* (tests/corpus/detector-revisions.json) — a measurement taken
|
|
18230
|
+
* against a different detector revision than attested is stale;
|
|
18231
|
+
* (3) every sidecar entry maps to a registered rule.
|
|
18232
|
+
* Mismatch → FAIL (certification-critical).
|
|
18233
|
+
*/
|
|
18234
|
+
function checkMeasurementConsistency(verdictsDir, sidecarPath, rules = RULES, measuredFp = MEASURED_FP) {
|
|
18235
|
+
const details = [];
|
|
18236
|
+
const failures = [];
|
|
18237
|
+
const registered = new Map(rules.map((r) => [r.id, r]));
|
|
18238
|
+
const liveCounts = /* @__PURE__ */ new Map();
|
|
18239
|
+
if (existsSync(verdictsDir)) for (const f of readdirSync(verdictsDir)) {
|
|
18240
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
18241
|
+
const lines = readFileSync(join(verdictsDir, f), "utf8").split("\n");
|
|
18242
|
+
for (const line of lines) {
|
|
18243
|
+
const trimmed = line.trim();
|
|
18244
|
+
if (trimmed.length === 0) continue;
|
|
18245
|
+
let row;
|
|
18246
|
+
try {
|
|
18247
|
+
row = JSON.parse(trimmed);
|
|
18248
|
+
} catch {
|
|
18249
|
+
failures.push(`${f}: unparseable verdict row (corpus integrity)`);
|
|
18250
|
+
continue;
|
|
18251
|
+
}
|
|
18252
|
+
if (row.ruleId === void 0) continue;
|
|
18253
|
+
if (row.verdict === "TP" || row.verdict === "FP") liveCounts.set(row.ruleId, (liveCounts.get(row.ruleId) ?? 0) + 1);
|
|
18254
|
+
}
|
|
18255
|
+
}
|
|
18256
|
+
else return check("measurement-consistency", "inconclusive", ["INCONCLUSIVE: verdicts directory missing — measurement consistency cannot be evaluated (certification-critical: never renders as pass)"]);
|
|
18257
|
+
let sidecar = {};
|
|
18258
|
+
if (existsSync(sidecarPath)) try {
|
|
18259
|
+
sidecar = JSON.parse(readFileSync(sidecarPath, "utf8"));
|
|
18260
|
+
} catch (e) {
|
|
18261
|
+
failures.push(`sidecar unreadable/malformed: ${errorText(e)} — regenerate the measurement artifacts`);
|
|
18262
|
+
}
|
|
18263
|
+
else {
|
|
18264
|
+
const needsSidecar = Object.keys(measuredFp).filter((id) => measuredFp[id]?.detectorRevision !== 1);
|
|
18265
|
+
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`]);
|
|
18266
|
+
}
|
|
18267
|
+
for (const [id, m] of Object.entries(measuredFp)) {
|
|
18268
|
+
const side = sidecar[id];
|
|
18269
|
+
if (side !== void 0 && side.detectorRevision !== void 0) {
|
|
18270
|
+
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)`);
|
|
18271
|
+
}
|
|
18272
|
+
const live = liveCounts.get(id);
|
|
18273
|
+
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)`);
|
|
18274
|
+
if (live === void 0 && m.n > 0) failures.push(`${id}: MEASURED_FP.n=${m.n} but no classified verdicts exist in the live corpus`);
|
|
18275
|
+
}
|
|
18276
|
+
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`);
|
|
18277
|
+
if (failures.length > 0) {
|
|
18278
|
+
details.push(...failures);
|
|
18279
|
+
return check("measurement-consistency", "fail", details);
|
|
18280
|
+
}
|
|
18281
|
+
const block = measurementBlock(rules);
|
|
18282
|
+
details.push(`${block.measured} measured / ${block.unmeasured} unmeasured / ${block.total} total (${block.quarantine} quarantine) — MEASURED_FP, live verdicts and sidecar revisions agree`);
|
|
18283
|
+
return check("measurement-consistency", "pass", details);
|
|
18154
18284
|
}
|
|
18155
18285
|
function runDoctorSelfAudit(fixturesRoot) {
|
|
18156
18286
|
const verdictsDir = join(fixturesRoot, "..", "corpus", "verdicts");
|
|
18287
|
+
const repoRoot = join(fixturesRoot, "..", "..");
|
|
18157
18288
|
const checks = [
|
|
18158
18289
|
checkFixtureFirewall(fixturesRoot),
|
|
18159
18290
|
checkRegistry(),
|
|
@@ -18162,12 +18293,15 @@ function runDoctorSelfAudit(fixturesRoot) {
|
|
|
18162
18293
|
checkTierEnforcement(verdictsDir),
|
|
18163
18294
|
checkAntiCreep(),
|
|
18164
18295
|
checkQuarantineEnforcement(),
|
|
18296
|
+
checkCategoryIntegrity(),
|
|
18165
18297
|
checkFixtureIntegrity(fixturesRoot),
|
|
18166
|
-
checkRevisionIntegrity(
|
|
18298
|
+
checkRevisionIntegrity(repoRoot),
|
|
18299
|
+
checkMeasurementConsistency(verdictsDir, join(repoRoot, "tests", "corpus", "detector-revisions.json"))
|
|
18167
18300
|
];
|
|
18168
18301
|
return {
|
|
18169
18302
|
checks,
|
|
18170
|
-
healthy: checks.every((c) => c.
|
|
18303
|
+
healthy: checks.every((c) => c.status === "pass"),
|
|
18304
|
+
measurement: measurementBlock()
|
|
18171
18305
|
};
|
|
18172
18306
|
}
|
|
18173
18307
|
function renderDoctorReport(report) {
|
|
@@ -18177,7 +18311,7 @@ function renderDoctorReport(report) {
|
|
|
18177
18311
|
""
|
|
18178
18312
|
];
|
|
18179
18313
|
for (const c of report.checks) {
|
|
18180
|
-
const mark = c.
|
|
18314
|
+
const mark = c.status === "pass" ? "✓" : c.status === "fail" ? "✗" : "? INCONCLUSIVE";
|
|
18181
18315
|
lines.push(`${mark} ${c.name}`);
|
|
18182
18316
|
for (const d of c.details.slice(0, 20)) lines.push(` ${d}`);
|
|
18183
18317
|
if (c.details.length > 20) lines.push(` … and ${c.details.length - 20} more`);
|
|
@@ -18186,6 +18320,89 @@ function renderDoctorReport(report) {
|
|
|
18186
18320
|
lines.push(report.healthy ? "Mjölnir self-audit: WORTHY" : "Mjölnir self-audit: VIOLATIONS FOUND");
|
|
18187
18321
|
return lines.join("\n");
|
|
18188
18322
|
}
|
|
18323
|
+
/** Versioned JSON schema name for `doctor --json` (G5). */
|
|
18324
|
+
const DOCTOR_REPORT_SCHEMA = "mjolnir.doctor-report@1";
|
|
18325
|
+
/**
|
|
18326
|
+
* Serializes the doctor report to the machine-readable contract (Phase 5,
|
|
18327
|
+
* G5): versioned `schema` field, stable key order (constructed once, here),
|
|
18328
|
+
* details limited exactly like the text render, paths already POSIX-relative
|
|
18329
|
+
* (the checks build them that way), and byte-identical across two runs on
|
|
18330
|
+
* the same tree — the CI certification job re-runs the command twice and
|
|
18331
|
+
* diffs, so any nondeterminism fails there.
|
|
18332
|
+
*/
|
|
18333
|
+
function doctorReportJson(report, opts = {}) {
|
|
18334
|
+
const maxDetails = opts.maxDetails ?? 20;
|
|
18335
|
+
const summary = {
|
|
18336
|
+
pass: 0,
|
|
18337
|
+
fail: 0,
|
|
18338
|
+
inconclusive: 0
|
|
18339
|
+
};
|
|
18340
|
+
const checks = report.checks.map((c) => {
|
|
18341
|
+
summary[c.status]++;
|
|
18342
|
+
return {
|
|
18343
|
+
name: c.name,
|
|
18344
|
+
status: c.status,
|
|
18345
|
+
ok: c.ok,
|
|
18346
|
+
details: c.details.length > maxDetails ? [...c.details.slice(0, maxDetails), `… and ${c.details.length - maxDetails} more`] : c.details
|
|
18347
|
+
};
|
|
18348
|
+
});
|
|
18349
|
+
return {
|
|
18350
|
+
schema: DOCTOR_REPORT_SCHEMA,
|
|
18351
|
+
healthy: report.healthy,
|
|
18352
|
+
summary,
|
|
18353
|
+
checks,
|
|
18354
|
+
measurement: report.measurement
|
|
18355
|
+
};
|
|
18356
|
+
}
|
|
18357
|
+
//#endregion
|
|
18358
|
+
//#region src/commands/doctor-run.ts
|
|
18359
|
+
/**
|
|
18360
|
+
* Doctor CLI entry (certification-audit Phase 5, G6): the command handler
|
|
18361
|
+
* lives beside the check implementations instead of in cli.ts — the CLI
|
|
18362
|
+
* file is a dispatch table, not a business-logic home.
|
|
18363
|
+
*
|
|
18364
|
+
* Exit-code contract (stable, e2e-locked):
|
|
18365
|
+
* 0 — WORTHY (every check pass)
|
|
18366
|
+
* 1 — VIOLATIONS FOUND (any fail OR inconclusive — G2: an
|
|
18367
|
+
* INCONCLUSIVE check never renders as pass, in text or JSON, and
|
|
18368
|
+
* never exits 0)
|
|
18369
|
+
* 2 — no fixtures directory at the target (not an mjolnir checkout)
|
|
18370
|
+
* 10 — usage error (unknown flag / flag-shaped positional)
|
|
18371
|
+
* 20 — internal error (crash; --debug prints the stack)
|
|
18372
|
+
*
|
|
18373
|
+
* `--json`: prints the versioned machine contract (doctorReportJson)
|
|
18374
|
+
* instead of the text render. stdout carries EXACTLY the JSON document —
|
|
18375
|
+
* no banners, no progress lines — so `doctor . --json > report.json` is
|
|
18376
|
+
* a clean file. The exit code is the gate; the JSON is the evidence.
|
|
18377
|
+
*/
|
|
18378
|
+
function runDoctorCommand(argv, io = {
|
|
18379
|
+
out,
|
|
18380
|
+
err
|
|
18381
|
+
}) {
|
|
18382
|
+
const json = argv.includes("--json");
|
|
18383
|
+
if (argv.filter((a) => a.startsWith("-") && a !== "--json").length > 0) {
|
|
18384
|
+
io.err("Usage: mjolnir doctor [--json] [repo-root]");
|
|
18385
|
+
return 10;
|
|
18386
|
+
}
|
|
18387
|
+
const targetArg = argv.find((a) => !a.startsWith("-")) ?? process.cwd();
|
|
18388
|
+
try {
|
|
18389
|
+
const fixturesRoot = resolve(join(targetArg, "tests", "fixtures"));
|
|
18390
|
+
if (!existsSync(fixturesRoot)) {
|
|
18391
|
+
io.err(`No fixtures directory at ${fixturesRoot}. Run from the mjolnir repo root.`);
|
|
18392
|
+
return 2;
|
|
18393
|
+
}
|
|
18394
|
+
const report = runDoctorSelfAudit(fixturesRoot);
|
|
18395
|
+
if (json) {
|
|
18396
|
+
io.out(JSON.stringify(doctorReportJson(report), null, 2));
|
|
18397
|
+
return report.healthy ? 0 : 1;
|
|
18398
|
+
}
|
|
18399
|
+
io.out(renderDoctorReport(report));
|
|
18400
|
+
return report.healthy ? 0 : 1;
|
|
18401
|
+
} catch (err) {
|
|
18402
|
+
internalErrorMessage(err, io.err, process.argv.includes("--debug"));
|
|
18403
|
+
return 20;
|
|
18404
|
+
}
|
|
18405
|
+
}
|
|
18189
18406
|
//#endregion
|
|
18190
18407
|
//#region src/commands/rules-catalog.ts
|
|
18191
18408
|
/**
|
|
@@ -18402,7 +18619,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
|
|
|
18402
18619
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
18403
18620
|
* `tests/version-consistency.spec.ts` locally.
|
|
18404
18621
|
*/
|
|
18405
|
-
const CLI_VERSION = "0.5.
|
|
18622
|
+
const CLI_VERSION = "0.5.23";
|
|
18406
18623
|
function parseArgs(argv, onError) {
|
|
18407
18624
|
const args = {
|
|
18408
18625
|
target: ".",
|
|
@@ -18567,8 +18784,6 @@ function parseArgsOrUsage(argv, io) {
|
|
|
18567
18784
|
if (!args && !reported) printUsage(io.out);
|
|
18568
18785
|
return args;
|
|
18569
18786
|
}
|
|
18570
|
-
const out = (...parts) => console.log(parts.map(String).join(" "));
|
|
18571
|
-
const err = (...parts) => console.error(parts.map(String).join(" "));
|
|
18572
18787
|
/** Testable `ci install` handler. Returns the process exit code. */
|
|
18573
18788
|
function runCiInstall(argv, io = {
|
|
18574
18789
|
out,
|
|
@@ -18690,30 +18905,6 @@ async function runDoctorPlaywright(argv, io = { out }) {
|
|
|
18690
18905
|
return 20;
|
|
18691
18906
|
}
|
|
18692
18907
|
}
|
|
18693
|
-
/** Testable `doctor` handler — self-audit of Mjölnir's own rule base. */
|
|
18694
|
-
function runDoctorCommand(argv, io = {
|
|
18695
|
-
out,
|
|
18696
|
-
err
|
|
18697
|
-
}) {
|
|
18698
|
-
if (argv.some((a) => a.startsWith("-"))) {
|
|
18699
|
-
io.err("Usage: mjolnir doctor [repo-root]");
|
|
18700
|
-
return 10;
|
|
18701
|
-
}
|
|
18702
|
-
const targetArg = argv[0] ?? process$1.cwd();
|
|
18703
|
-
try {
|
|
18704
|
-
const fixturesRoot = resolve(join(targetArg, "tests", "fixtures"));
|
|
18705
|
-
if (!existsSync(fixturesRoot)) {
|
|
18706
|
-
io.err(`No fixtures directory at ${fixturesRoot}. Run from the mjolnir repo root.`);
|
|
18707
|
-
return 2;
|
|
18708
|
-
}
|
|
18709
|
-
const report = runDoctorSelfAudit(fixturesRoot);
|
|
18710
|
-
io.out(renderDoctorReport(report));
|
|
18711
|
-
return report.healthy ? 0 : 1;
|
|
18712
|
-
} catch (err) {
|
|
18713
|
-
internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
|
|
18714
|
-
return 20;
|
|
18715
|
-
}
|
|
18716
|
-
}
|
|
18717
18908
|
/** Testable `rules` handler — rule catalog with Trust Metadata. */
|
|
18718
18909
|
async function runRulesCommand(argv, io = {
|
|
18719
18910
|
out,
|
|
@@ -19349,22 +19540,6 @@ function runHelpCommand(argv, io = {
|
|
|
19349
19540
|
function printUsage(print) {
|
|
19350
19541
|
print(renderRootHelp());
|
|
19351
19542
|
}
|
|
19352
|
-
/**
|
|
19353
|
-
* Friendly exit-20 path (plan M2): the crash says it's Mjölnir's bug,
|
|
19354
|
-
* not the user's repo, carries the underlying message for a report, and
|
|
19355
|
-
* prints the stack ONLY when `debug` is set (uniform across
|
|
19356
|
-
* subcommands — they don't parse scan flags). Tests pin
|
|
19357
|
-
* /internal error/i. Exported so the --debug stack arm is directly
|
|
19358
|
-
* spec-coverable (spawning a real crash under --debug would be flaky).
|
|
19359
|
-
*/
|
|
19360
|
-
function internalErrorMessage(err, emit, debug) {
|
|
19361
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
19362
|
-
emit("mjolnir internal error — this is a bug in Mjölnir, not your repo:");
|
|
19363
|
-
emit(` ${message}`);
|
|
19364
|
-
if (debug && err instanceof Error && err.stack) emit(err.stack);
|
|
19365
|
-
emit("Rerun with --debug for the stack trace. Please report this:");
|
|
19366
|
-
emit(" https://github.com/Sergey-Bar/Mjolnir/issues");
|
|
19367
|
-
}
|
|
19368
19543
|
function isEntryPoint() {
|
|
19369
19544
|
const argv1 = process$1.argv[1];
|
|
19370
19545
|
if (!argv1) return false;
|