mjolnir-qa 0.5.20 → 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 +21 -0
- package/README.md +1 -0
- package/dist/cli.d.mts +12 -3
- package/dist/cli.mjs +185 -15
- package/dist/mcp/stdio.mjs +185 -15
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,27 @@ 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.23] — 2026-09-08
|
|
13
|
+
|
|
14
|
+
### Changes since 0.5.22
|
|
15
|
+
|
|
16
|
+
- docs(corpus): D14 as amended by owner — re-baseline-per-wave is blocking, PARTIAL repos are tracked debt (never baselines, never a certification gate); zero-PARTIAL is the end state, not the gate
|
|
17
|
+
|
|
18
|
+
## [0.5.22] — 2026-09-07
|
|
19
|
+
|
|
20
|
+
### Changes since 0.5.21
|
|
21
|
+
|
|
22
|
+
- chore: gitignore bench artifacts (machine-local timings + fixture scratch)
|
|
23
|
+
- test(corpus): D14 re-baseline after Wave-1 merges — QA-PW-124 now adapter-gated (configRule metadata), QA-TEST-003/010 surface on repos whose baselines predate the TS project split, corpus regen of partial scans pending quiet-machine rerun
|
|
24
|
+
- chore(qa): eslint-ignore the verbatim QA evidence area (raw probes are committed DATA, certification protocol)
|
|
25
|
+
- chore(qa): commit FINAL-RELEASE certification evidence verbatim (cycle 0, RC 151186b) + lint/format exclusions for raw evidence area (certification plan 1788804968910 protocol)
|
|
26
|
+
|
|
27
|
+
## [0.5.21] — 2026-09-07
|
|
28
|
+
|
|
29
|
+
### Changes since 0.5.20
|
|
30
|
+
|
|
31
|
+
- Integrity layer: category + measurement consistency, measurement census, §27 design pass (Phases 1+4+7) (#56)
|
|
32
|
+
|
|
12
33
|
## [0.5.20] — 2026-09-07
|
|
13
34
|
|
|
14
35
|
### Changes since 0.5.19
|
package/README.md
CHANGED
|
@@ -200,6 +200,7 @@ and you're done. Everything else is optional.
|
|
|
200
200
|
| `mjolnir badge` | shields.io endpoint JSON + snippet |
|
|
201
201
|
| `mjolnir rules --md` | Full rule catalog (JSON or Markdown) |
|
|
202
202
|
| `mjolnir doctor` | Self-audit of Mjölnir's own rule base |
|
|
203
|
+
| `mjolnir doctor --json` | The same self-audit as a machine-readable contract |
|
|
203
204
|
| `mjolnir create-rule <ID>` | Scaffold a new rule + fixtures |
|
|
204
205
|
| `mjolnir --format mermaid` | Test-architecture diagram for a PR comment |
|
|
205
206
|
|
package/dist/cli.d.mts
CHANGED
|
@@ -32,8 +32,17 @@ type EvidenceLevel = (typeof EVIDENCE_ORDER)[number];
|
|
|
32
32
|
* for the QA engineer's actual job, in their vocabulary.
|
|
33
33
|
*/
|
|
34
34
|
type QaImpact = "BLOCKS-RELEASE" | "FLAKY-RISK" | "FALSE-GREEN" | "HYGIENE";
|
|
35
|
-
/**
|
|
36
|
-
|
|
35
|
+
/**
|
|
36
|
+
* The closed set of rule categories (plan §5.5, certification-audit D6).
|
|
37
|
+
* Declared FIRST as the single source of truth: the RuleCategory type is
|
|
38
|
+
* DERIVED from this list (compile-time exhaustiveness — a category added
|
|
39
|
+
* to the type without the value list, or vice versa, cannot compile).
|
|
40
|
+
* `--category` values are validated against this list — unknown
|
|
41
|
+
* categories are a usage error, not a silent no-op. Rule namespaces are
|
|
42
|
+
* frozen public API (§18.4): IDs are never reused.
|
|
43
|
+
*/
|
|
44
|
+
declare const RULE_CATEGORIES: readonly ["QA-TEST", "QA-TQUAL", "QA-PW", "QA-CI", "QA-PY", "QA-ENV", "QA-JV", "QA-CS", "QA-CYP", "QA-SE", "QA-WDIO", "QA-PPTR", "QA-APM"];
|
|
45
|
+
type RuleCategory = (typeof RULE_CATEGORIES)[number];
|
|
37
46
|
/**
|
|
38
47
|
* Trust levels (Verification Trust Evolution Plan §16): the OVERALL
|
|
39
48
|
* trust a consumer can place in one finding, combining the static
|
|
@@ -719,7 +728,7 @@ declare const runScan: typeof runScan$1, buildUniversalRules: typeof buildUniver
|
|
|
719
728
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
720
729
|
* `tests/version-consistency.spec.ts` locally.
|
|
721
730
|
*/
|
|
722
|
-
declare const CLI_VERSION = "0.5.
|
|
731
|
+
declare const CLI_VERSION = "0.5.23";
|
|
723
732
|
/** A usage-error detail: the offending token, when one exists. */
|
|
724
733
|
interface UsageErrorDetail {
|
|
725
734
|
/** The unknown flag or rejected value (e.g. `--nope`, `loud`). */
|
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
|
}
|
|
@@ -17930,6 +17995,25 @@ function checkEvidenceHonesty(rules = RULES) {
|
|
|
17930
17995
|
return check("evidence-honesty", ok ? "pass" : "fail", details);
|
|
17931
17996
|
}
|
|
17932
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");
|
|
18009
|
+
return {
|
|
18010
|
+
measured: measured.length,
|
|
18011
|
+
unmeasured: rules.length - measured.length,
|
|
18012
|
+
total: rules.length,
|
|
18013
|
+
quarantine: quarantine.length
|
|
18014
|
+
};
|
|
18015
|
+
}
|
|
18016
|
+
/**
|
|
17933
18017
|
* Check 5 (Phase 4 — Tempering Plan, ratcheted per audit H-2):
|
|
17934
18018
|
* tier enforcement. Core-tier rules must have a measured FP rate
|
|
17935
18019
|
* (n ≥ 10 classified verdicts in tests/corpus/verdicts/) at a matching
|
|
@@ -18117,8 +18201,90 @@ function checkRevisionIntegrity(repoRoot, rules = RULES) {
|
|
|
18117
18201
|
details.push(`${rules.length} rules attested: source identity and declared revision match the manifest (check A + check B, G4)`);
|
|
18118
18202
|
return check("revision-integrity", "pass", details);
|
|
18119
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);
|
|
18284
|
+
}
|
|
18120
18285
|
function runDoctorSelfAudit(fixturesRoot) {
|
|
18121
18286
|
const verdictsDir = join(fixturesRoot, "..", "corpus", "verdicts");
|
|
18287
|
+
const repoRoot = join(fixturesRoot, "..", "..");
|
|
18122
18288
|
const checks = [
|
|
18123
18289
|
checkFixtureFirewall(fixturesRoot),
|
|
18124
18290
|
checkRegistry(),
|
|
@@ -18127,12 +18293,15 @@ function runDoctorSelfAudit(fixturesRoot) {
|
|
|
18127
18293
|
checkTierEnforcement(verdictsDir),
|
|
18128
18294
|
checkAntiCreep(),
|
|
18129
18295
|
checkQuarantineEnforcement(),
|
|
18296
|
+
checkCategoryIntegrity(),
|
|
18130
18297
|
checkFixtureIntegrity(fixturesRoot),
|
|
18131
|
-
checkRevisionIntegrity(
|
|
18298
|
+
checkRevisionIntegrity(repoRoot),
|
|
18299
|
+
checkMeasurementConsistency(verdictsDir, join(repoRoot, "tests", "corpus", "detector-revisions.json"))
|
|
18132
18300
|
];
|
|
18133
18301
|
return {
|
|
18134
18302
|
checks,
|
|
18135
|
-
healthy: checks.every((c) => c.status === "pass")
|
|
18303
|
+
healthy: checks.every((c) => c.status === "pass"),
|
|
18304
|
+
measurement: measurementBlock()
|
|
18136
18305
|
};
|
|
18137
18306
|
}
|
|
18138
18307
|
function renderDoctorReport(report) {
|
|
@@ -18181,7 +18350,8 @@ function doctorReportJson(report, opts = {}) {
|
|
|
18181
18350
|
schema: DOCTOR_REPORT_SCHEMA,
|
|
18182
18351
|
healthy: report.healthy,
|
|
18183
18352
|
summary,
|
|
18184
|
-
checks
|
|
18353
|
+
checks,
|
|
18354
|
+
measurement: report.measurement
|
|
18185
18355
|
};
|
|
18186
18356
|
}
|
|
18187
18357
|
//#endregion
|
|
@@ -18449,7 +18619,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
|
|
|
18449
18619
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
18450
18620
|
* `tests/version-consistency.spec.ts` locally.
|
|
18451
18621
|
*/
|
|
18452
|
-
const CLI_VERSION = "0.5.
|
|
18622
|
+
const CLI_VERSION = "0.5.23";
|
|
18453
18623
|
function parseArgs(argv, onError) {
|
|
18454
18624
|
const args = {
|
|
18455
18625
|
target: ".",
|
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
|
}
|
|
@@ -17412,6 +17477,25 @@ function checkEvidenceHonesty(rules = RULES) {
|
|
|
17412
17477
|
return check("evidence-honesty", ok ? "pass" : "fail", details);
|
|
17413
17478
|
}
|
|
17414
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");
|
|
17491
|
+
return {
|
|
17492
|
+
measured: measured.length,
|
|
17493
|
+
unmeasured: rules.length - measured.length,
|
|
17494
|
+
total: rules.length,
|
|
17495
|
+
quarantine: quarantine.length
|
|
17496
|
+
};
|
|
17497
|
+
}
|
|
17498
|
+
/**
|
|
17415
17499
|
* Check 5 (Phase 4 — Tempering Plan, ratcheted per audit H-2):
|
|
17416
17500
|
* tier enforcement. Core-tier rules must have a measured FP rate
|
|
17417
17501
|
* (n ≥ 10 classified verdicts in tests/corpus/verdicts/) at a matching
|
|
@@ -17599,8 +17683,90 @@ function checkRevisionIntegrity(repoRoot, rules = RULES) {
|
|
|
17599
17683
|
details.push(`${rules.length} rules attested: source identity and declared revision match the manifest (check A + check B, G4)`);
|
|
17600
17684
|
return check("revision-integrity", "pass", details);
|
|
17601
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);
|
|
17766
|
+
}
|
|
17602
17767
|
function runDoctorSelfAudit(fixturesRoot) {
|
|
17603
17768
|
const verdictsDir = join(fixturesRoot, "..", "corpus", "verdicts");
|
|
17769
|
+
const repoRoot = join(fixturesRoot, "..", "..");
|
|
17604
17770
|
const checks = [
|
|
17605
17771
|
checkFixtureFirewall(fixturesRoot),
|
|
17606
17772
|
checkRegistry(),
|
|
@@ -17609,12 +17775,15 @@ function runDoctorSelfAudit(fixturesRoot) {
|
|
|
17609
17775
|
checkTierEnforcement(verdictsDir),
|
|
17610
17776
|
checkAntiCreep(),
|
|
17611
17777
|
checkQuarantineEnforcement(),
|
|
17778
|
+
checkCategoryIntegrity(),
|
|
17612
17779
|
checkFixtureIntegrity(fixturesRoot),
|
|
17613
|
-
checkRevisionIntegrity(
|
|
17780
|
+
checkRevisionIntegrity(repoRoot),
|
|
17781
|
+
checkMeasurementConsistency(verdictsDir, join(repoRoot, "tests", "corpus", "detector-revisions.json"))
|
|
17614
17782
|
];
|
|
17615
17783
|
return {
|
|
17616
17784
|
checks,
|
|
17617
|
-
healthy: checks.every((c) => c.status === "pass")
|
|
17785
|
+
healthy: checks.every((c) => c.status === "pass"),
|
|
17786
|
+
measurement: measurementBlock()
|
|
17618
17787
|
};
|
|
17619
17788
|
}
|
|
17620
17789
|
function renderDoctorReport(report) {
|
|
@@ -17663,7 +17832,8 @@ function doctorReportJson(report, opts = {}) {
|
|
|
17663
17832
|
schema: DOCTOR_REPORT_SCHEMA,
|
|
17664
17833
|
healthy: report.healthy,
|
|
17665
17834
|
summary,
|
|
17666
|
-
checks
|
|
17835
|
+
checks,
|
|
17836
|
+
measurement: report.measurement
|
|
17667
17837
|
};
|
|
17668
17838
|
}
|
|
17669
17839
|
//#endregion
|
|
@@ -18085,7 +18255,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
|
|
|
18085
18255
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
18086
18256
|
* `tests/version-consistency.spec.ts` locally.
|
|
18087
18257
|
*/
|
|
18088
|
-
const CLI_VERSION = "0.5.
|
|
18258
|
+
const CLI_VERSION = "0.5.23";
|
|
18089
18259
|
function parseArgs(argv, onError) {
|
|
18090
18260
|
const args = {
|
|
18091
18261
|
target: ".",
|
package/package.json
CHANGED