mjolnir-qa 0.5.20 → 0.5.24
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 +12 -3
- package/dist/cli.mjs +192 -18
- package/dist/mcp/stdio.mjs +192 -18
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,33 @@ 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.24] — 2026-09-08
|
|
13
|
+
|
|
14
|
+
### Changes since 0.5.23
|
|
15
|
+
|
|
16
|
+
- L4 ruling: tier-enforcement INCONCLUSIVE without live verdicts (MEASURED_FP = historical artifact only) (#58)
|
|
17
|
+
|
|
18
|
+
## [0.5.23] — 2026-09-08
|
|
19
|
+
|
|
20
|
+
### Changes since 0.5.22
|
|
21
|
+
|
|
22
|
+
- 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
|
|
23
|
+
|
|
24
|
+
## [0.5.22] — 2026-09-07
|
|
25
|
+
|
|
26
|
+
### Changes since 0.5.21
|
|
27
|
+
|
|
28
|
+
- chore: gitignore bench artifacts (machine-local timings + fixture scratch)
|
|
29
|
+
- 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
|
|
30
|
+
- chore(qa): eslint-ignore the verbatim QA evidence area (raw probes are committed DATA, certification protocol)
|
|
31
|
+
- chore(qa): commit FINAL-RELEASE certification evidence verbatim (cycle 0, RC 151186b) + lint/format exclusions for raw evidence area (certification plan 1788804968910 protocol)
|
|
32
|
+
|
|
33
|
+
## [0.5.21] — 2026-09-07
|
|
34
|
+
|
|
35
|
+
### Changes since 0.5.20
|
|
36
|
+
|
|
37
|
+
- Integrity layer: category + measurement consistency, measurement census, §27 design pass (Phases 1+4+7) (#56)
|
|
38
|
+
|
|
12
39
|
## [0.5.20] — 2026-09-07
|
|
13
40
|
|
|
14
41
|
### 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.24";
|
|
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.24",
|
|
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
|
|
@@ -17941,14 +18025,16 @@ function checkEvidenceHonesty(rules = RULES) {
|
|
|
17941
18025
|
*/
|
|
17942
18026
|
function checkTierEnforcement(verdictsDir, rules = RULES) {
|
|
17943
18027
|
const details = [];
|
|
18028
|
+
if (!existsSync(verdictsDir)) return check("tier-enforcement", "inconclusive", ["INCONCLUSIVE: live verdicts unavailable (tests/corpus/verdicts missing — installed package) — tier claims cannot be proven without live evidence; MEASURED_FP is a historical artifact and does not substitute (owner ruling 2026-09-08, L4)"]);
|
|
17944
18029
|
const classifiedPerRule = /* @__PURE__ */ new Map();
|
|
17945
18030
|
const byId = new Map(rules.map((r) => [r.id, r]));
|
|
17946
18031
|
for (const [id, m] of Object.entries(MEASURED_FP)) {
|
|
17947
18032
|
const rule = byId.get(id);
|
|
17948
18033
|
if (rule && m.detectorRevision === declaredDetectorRevision(rule)) classifiedPerRule.set(id, m.n);
|
|
17949
18034
|
}
|
|
18035
|
+
let live;
|
|
17950
18036
|
try {
|
|
17951
|
-
|
|
18037
|
+
live = /* @__PURE__ */ new Map();
|
|
17952
18038
|
const files = readdirSync(verdictsDir).filter((f) => f.endsWith(".jsonl"));
|
|
17953
18039
|
for (const f of files) {
|
|
17954
18040
|
const lines = readFileSync(join(verdictsDir, f), "utf8").split("\n").filter((l) => l.trim().length > 0);
|
|
@@ -17957,8 +18043,10 @@ function checkTierEnforcement(verdictsDir, rules = RULES) {
|
|
|
17957
18043
|
if (entry.verdict === "TP" || entry.verdict === "FP") live.set(entry.ruleId, (live.get(entry.ruleId) ?? 0) + 1);
|
|
17958
18044
|
} catch {}
|
|
17959
18045
|
}
|
|
17960
|
-
|
|
17961
|
-
|
|
18046
|
+
} catch (e) {
|
|
18047
|
+
return check("tier-enforcement", "inconclusive", [`INCONCLUSIVE: live verdicts unreadable — ${errorText(e)} (owner ruling 2026-09-08, L4)`]);
|
|
18048
|
+
}
|
|
18049
|
+
if (live.size > 0) for (const [id, n] of live) classifiedPerRule.set(id, n);
|
|
17962
18050
|
const coreRules = rules.filter((r) => effectiveTier(r) === "core");
|
|
17963
18051
|
for (const r of coreRules) {
|
|
17964
18052
|
const n = classifiedPerRule.get(r.id) ?? 0;
|
|
@@ -18117,8 +18205,90 @@ function checkRevisionIntegrity(repoRoot, rules = RULES) {
|
|
|
18117
18205
|
details.push(`${rules.length} rules attested: source identity and declared revision match the manifest (check A + check B, G4)`);
|
|
18118
18206
|
return check("revision-integrity", "pass", details);
|
|
18119
18207
|
}
|
|
18208
|
+
/**
|
|
18209
|
+
* Check 8 (certification-audit Phase 1.4, D6 — HARD-BLOCKING):
|
|
18210
|
+
* category-integrity. Every registry rule's category must be a member of
|
|
18211
|
+
* the closed RULE_CATEGORIES set (compile-time exhaustive via the D6
|
|
18212
|
+
* derivation, so this check is the runtime backstop for values arriving
|
|
18213
|
+
* from external rule sources), and no two rules may collide on an id.
|
|
18214
|
+
* Registry ids are already unique-checked by registry-sanity; this check
|
|
18215
|
+
* owns the category dimension.
|
|
18216
|
+
*/
|
|
18217
|
+
function checkCategoryIntegrity(rules = RULES) {
|
|
18218
|
+
const details = [];
|
|
18219
|
+
const known = RULE_CATEGORIES;
|
|
18220
|
+
const unknown = rules.filter((r) => !known.includes(r.category));
|
|
18221
|
+
for (const r of unknown) details.push(`${r.id}: category "${r.category}" is not in RULE_CATEGORIES — registries may not invent categories (D6)`);
|
|
18222
|
+
const byCategory = /* @__PURE__ */ new Map();
|
|
18223
|
+
for (const r of rules) byCategory.set(r.category, (byCategory.get(r.category) ?? 0) + 1);
|
|
18224
|
+
return check("category-integrity", unknown.length === 0 ? "pass" : "fail", [`${byCategory.size} categories in use across ${rules.length} rules (closed set: ${RULE_CATEGORIES.length})`, ...details]);
|
|
18225
|
+
}
|
|
18226
|
+
/**
|
|
18227
|
+
* Check 10 (certification-audit Phase 4.1, D7 — HARD-BLOCKING):
|
|
18228
|
+
* measurement-consistency. Three invariants over the measurement surface:
|
|
18229
|
+
* (1) every MEASURED_FP entry's `n` equals the LIVE classified verdict
|
|
18230
|
+
* count (TP+FP rows in tests/corpus/verdicts/*.jsonl) — a snapshot
|
|
18231
|
+
* that drifted from the corpus is a lie about evidence;
|
|
18232
|
+
* (2) MEASURED_FP's detectorRevision equals the sidecar's
|
|
18233
|
+
* (tests/corpus/detector-revisions.json) — a measurement taken
|
|
18234
|
+
* against a different detector revision than attested is stale;
|
|
18235
|
+
* (3) every sidecar entry maps to a registered rule.
|
|
18236
|
+
* Mismatch → FAIL (certification-critical).
|
|
18237
|
+
*/
|
|
18238
|
+
function checkMeasurementConsistency(verdictsDir, sidecarPath, rules = RULES, measuredFp = MEASURED_FP) {
|
|
18239
|
+
const details = [];
|
|
18240
|
+
const failures = [];
|
|
18241
|
+
const registered = new Map(rules.map((r) => [r.id, r]));
|
|
18242
|
+
const liveCounts = /* @__PURE__ */ new Map();
|
|
18243
|
+
if (existsSync(verdictsDir)) for (const f of readdirSync(verdictsDir)) {
|
|
18244
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
18245
|
+
const lines = readFileSync(join(verdictsDir, f), "utf8").split("\n");
|
|
18246
|
+
for (const line of lines) {
|
|
18247
|
+
const trimmed = line.trim();
|
|
18248
|
+
if (trimmed.length === 0) continue;
|
|
18249
|
+
let row;
|
|
18250
|
+
try {
|
|
18251
|
+
row = JSON.parse(trimmed);
|
|
18252
|
+
} catch {
|
|
18253
|
+
failures.push(`${f}: unparseable verdict row (corpus integrity)`);
|
|
18254
|
+
continue;
|
|
18255
|
+
}
|
|
18256
|
+
if (row.ruleId === void 0) continue;
|
|
18257
|
+
if (row.verdict === "TP" || row.verdict === "FP") liveCounts.set(row.ruleId, (liveCounts.get(row.ruleId) ?? 0) + 1);
|
|
18258
|
+
}
|
|
18259
|
+
}
|
|
18260
|
+
else return check("measurement-consistency", "inconclusive", ["INCONCLUSIVE: verdicts directory missing — measurement consistency cannot be evaluated (certification-critical: never renders as pass)"]);
|
|
18261
|
+
let sidecar = {};
|
|
18262
|
+
if (existsSync(sidecarPath)) try {
|
|
18263
|
+
sidecar = JSON.parse(readFileSync(sidecarPath, "utf8"));
|
|
18264
|
+
} catch (e) {
|
|
18265
|
+
failures.push(`sidecar unreadable/malformed: ${errorText(e)} — regenerate the measurement artifacts`);
|
|
18266
|
+
}
|
|
18267
|
+
else {
|
|
18268
|
+
const needsSidecar = Object.keys(measuredFp).filter((id) => measuredFp[id]?.detectorRevision !== 1);
|
|
18269
|
+
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`]);
|
|
18270
|
+
}
|
|
18271
|
+
for (const [id, m] of Object.entries(measuredFp)) {
|
|
18272
|
+
const side = sidecar[id];
|
|
18273
|
+
if (side !== void 0 && side.detectorRevision !== void 0) {
|
|
18274
|
+
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)`);
|
|
18275
|
+
}
|
|
18276
|
+
const live = liveCounts.get(id);
|
|
18277
|
+
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)`);
|
|
18278
|
+
if (live === void 0 && m.n > 0) failures.push(`${id}: MEASURED_FP.n=${m.n} but no classified verdicts exist in the live corpus`);
|
|
18279
|
+
}
|
|
18280
|
+
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`);
|
|
18281
|
+
if (failures.length > 0) {
|
|
18282
|
+
details.push(...failures);
|
|
18283
|
+
return check("measurement-consistency", "fail", details);
|
|
18284
|
+
}
|
|
18285
|
+
const block = measurementBlock(rules);
|
|
18286
|
+
details.push(`${block.measured} measured / ${block.unmeasured} unmeasured / ${block.total} total (${block.quarantine} quarantine) — MEASURED_FP, live verdicts and sidecar revisions agree`);
|
|
18287
|
+
return check("measurement-consistency", "pass", details);
|
|
18288
|
+
}
|
|
18120
18289
|
function runDoctorSelfAudit(fixturesRoot) {
|
|
18121
18290
|
const verdictsDir = join(fixturesRoot, "..", "corpus", "verdicts");
|
|
18291
|
+
const repoRoot = join(fixturesRoot, "..", "..");
|
|
18122
18292
|
const checks = [
|
|
18123
18293
|
checkFixtureFirewall(fixturesRoot),
|
|
18124
18294
|
checkRegistry(),
|
|
@@ -18127,12 +18297,15 @@ function runDoctorSelfAudit(fixturesRoot) {
|
|
|
18127
18297
|
checkTierEnforcement(verdictsDir),
|
|
18128
18298
|
checkAntiCreep(),
|
|
18129
18299
|
checkQuarantineEnforcement(),
|
|
18300
|
+
checkCategoryIntegrity(),
|
|
18130
18301
|
checkFixtureIntegrity(fixturesRoot),
|
|
18131
|
-
checkRevisionIntegrity(
|
|
18302
|
+
checkRevisionIntegrity(repoRoot),
|
|
18303
|
+
checkMeasurementConsistency(verdictsDir, join(repoRoot, "tests", "corpus", "detector-revisions.json"))
|
|
18132
18304
|
];
|
|
18133
18305
|
return {
|
|
18134
18306
|
checks,
|
|
18135
|
-
healthy: checks.every((c) => c.status === "pass")
|
|
18307
|
+
healthy: checks.every((c) => c.status === "pass"),
|
|
18308
|
+
measurement: measurementBlock()
|
|
18136
18309
|
};
|
|
18137
18310
|
}
|
|
18138
18311
|
function renderDoctorReport(report) {
|
|
@@ -18181,7 +18354,8 @@ function doctorReportJson(report, opts = {}) {
|
|
|
18181
18354
|
schema: DOCTOR_REPORT_SCHEMA,
|
|
18182
18355
|
healthy: report.healthy,
|
|
18183
18356
|
summary,
|
|
18184
|
-
checks
|
|
18357
|
+
checks,
|
|
18358
|
+
measurement: report.measurement
|
|
18185
18359
|
};
|
|
18186
18360
|
}
|
|
18187
18361
|
//#endregion
|
|
@@ -18449,7 +18623,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
|
|
|
18449
18623
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
18450
18624
|
* `tests/version-consistency.spec.ts` locally.
|
|
18451
18625
|
*/
|
|
18452
|
-
const CLI_VERSION = "0.5.
|
|
18626
|
+
const CLI_VERSION = "0.5.24";
|
|
18453
18627
|
function parseArgs(argv, onError) {
|
|
18454
18628
|
const args = {
|
|
18455
18629
|
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.24",
|
|
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
|
|
@@ -17423,14 +17507,16 @@ function checkEvidenceHonesty(rules = RULES) {
|
|
|
17423
17507
|
*/
|
|
17424
17508
|
function checkTierEnforcement(verdictsDir, rules = RULES) {
|
|
17425
17509
|
const details = [];
|
|
17510
|
+
if (!existsSync(verdictsDir)) return check("tier-enforcement", "inconclusive", ["INCONCLUSIVE: live verdicts unavailable (tests/corpus/verdicts missing — installed package) — tier claims cannot be proven without live evidence; MEASURED_FP is a historical artifact and does not substitute (owner ruling 2026-09-08, L4)"]);
|
|
17426
17511
|
const classifiedPerRule = /* @__PURE__ */ new Map();
|
|
17427
17512
|
const byId = new Map(rules.map((r) => [r.id, r]));
|
|
17428
17513
|
for (const [id, m] of Object.entries(MEASURED_FP)) {
|
|
17429
17514
|
const rule = byId.get(id);
|
|
17430
17515
|
if (rule && m.detectorRevision === declaredDetectorRevision(rule)) classifiedPerRule.set(id, m.n);
|
|
17431
17516
|
}
|
|
17517
|
+
let live;
|
|
17432
17518
|
try {
|
|
17433
|
-
|
|
17519
|
+
live = /* @__PURE__ */ new Map();
|
|
17434
17520
|
const files = readdirSync(verdictsDir).filter((f) => f.endsWith(".jsonl"));
|
|
17435
17521
|
for (const f of files) {
|
|
17436
17522
|
const lines = readFileSync(join(verdictsDir, f), "utf8").split("\n").filter((l) => l.trim().length > 0);
|
|
@@ -17439,8 +17525,10 @@ function checkTierEnforcement(verdictsDir, rules = RULES) {
|
|
|
17439
17525
|
if (entry.verdict === "TP" || entry.verdict === "FP") live.set(entry.ruleId, (live.get(entry.ruleId) ?? 0) + 1);
|
|
17440
17526
|
} catch {}
|
|
17441
17527
|
}
|
|
17442
|
-
|
|
17443
|
-
|
|
17528
|
+
} catch (e) {
|
|
17529
|
+
return check("tier-enforcement", "inconclusive", [`INCONCLUSIVE: live verdicts unreadable — ${errorText(e)} (owner ruling 2026-09-08, L4)`]);
|
|
17530
|
+
}
|
|
17531
|
+
if (live.size > 0) for (const [id, n] of live) classifiedPerRule.set(id, n);
|
|
17444
17532
|
const coreRules = rules.filter((r) => effectiveTier(r) === "core");
|
|
17445
17533
|
for (const r of coreRules) {
|
|
17446
17534
|
const n = classifiedPerRule.get(r.id) ?? 0;
|
|
@@ -17599,8 +17687,90 @@ function checkRevisionIntegrity(repoRoot, rules = RULES) {
|
|
|
17599
17687
|
details.push(`${rules.length} rules attested: source identity and declared revision match the manifest (check A + check B, G4)`);
|
|
17600
17688
|
return check("revision-integrity", "pass", details);
|
|
17601
17689
|
}
|
|
17690
|
+
/**
|
|
17691
|
+
* Check 8 (certification-audit Phase 1.4, D6 — HARD-BLOCKING):
|
|
17692
|
+
* category-integrity. Every registry rule's category must be a member of
|
|
17693
|
+
* the closed RULE_CATEGORIES set (compile-time exhaustive via the D6
|
|
17694
|
+
* derivation, so this check is the runtime backstop for values arriving
|
|
17695
|
+
* from external rule sources), and no two rules may collide on an id.
|
|
17696
|
+
* Registry ids are already unique-checked by registry-sanity; this check
|
|
17697
|
+
* owns the category dimension.
|
|
17698
|
+
*/
|
|
17699
|
+
function checkCategoryIntegrity(rules = RULES) {
|
|
17700
|
+
const details = [];
|
|
17701
|
+
const known = RULE_CATEGORIES;
|
|
17702
|
+
const unknown = rules.filter((r) => !known.includes(r.category));
|
|
17703
|
+
for (const r of unknown) details.push(`${r.id}: category "${r.category}" is not in RULE_CATEGORIES — registries may not invent categories (D6)`);
|
|
17704
|
+
const byCategory = /* @__PURE__ */ new Map();
|
|
17705
|
+
for (const r of rules) byCategory.set(r.category, (byCategory.get(r.category) ?? 0) + 1);
|
|
17706
|
+
return check("category-integrity", unknown.length === 0 ? "pass" : "fail", [`${byCategory.size} categories in use across ${rules.length} rules (closed set: ${RULE_CATEGORIES.length})`, ...details]);
|
|
17707
|
+
}
|
|
17708
|
+
/**
|
|
17709
|
+
* Check 10 (certification-audit Phase 4.1, D7 — HARD-BLOCKING):
|
|
17710
|
+
* measurement-consistency. Three invariants over the measurement surface:
|
|
17711
|
+
* (1) every MEASURED_FP entry's `n` equals the LIVE classified verdict
|
|
17712
|
+
* count (TP+FP rows in tests/corpus/verdicts/*.jsonl) — a snapshot
|
|
17713
|
+
* that drifted from the corpus is a lie about evidence;
|
|
17714
|
+
* (2) MEASURED_FP's detectorRevision equals the sidecar's
|
|
17715
|
+
* (tests/corpus/detector-revisions.json) — a measurement taken
|
|
17716
|
+
* against a different detector revision than attested is stale;
|
|
17717
|
+
* (3) every sidecar entry maps to a registered rule.
|
|
17718
|
+
* Mismatch → FAIL (certification-critical).
|
|
17719
|
+
*/
|
|
17720
|
+
function checkMeasurementConsistency(verdictsDir, sidecarPath, rules = RULES, measuredFp = MEASURED_FP) {
|
|
17721
|
+
const details = [];
|
|
17722
|
+
const failures = [];
|
|
17723
|
+
const registered = new Map(rules.map((r) => [r.id, r]));
|
|
17724
|
+
const liveCounts = /* @__PURE__ */ new Map();
|
|
17725
|
+
if (existsSync(verdictsDir)) for (const f of readdirSync(verdictsDir)) {
|
|
17726
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
17727
|
+
const lines = readFileSync(join(verdictsDir, f), "utf8").split("\n");
|
|
17728
|
+
for (const line of lines) {
|
|
17729
|
+
const trimmed = line.trim();
|
|
17730
|
+
if (trimmed.length === 0) continue;
|
|
17731
|
+
let row;
|
|
17732
|
+
try {
|
|
17733
|
+
row = JSON.parse(trimmed);
|
|
17734
|
+
} catch {
|
|
17735
|
+
failures.push(`${f}: unparseable verdict row (corpus integrity)`);
|
|
17736
|
+
continue;
|
|
17737
|
+
}
|
|
17738
|
+
if (row.ruleId === void 0) continue;
|
|
17739
|
+
if (row.verdict === "TP" || row.verdict === "FP") liveCounts.set(row.ruleId, (liveCounts.get(row.ruleId) ?? 0) + 1);
|
|
17740
|
+
}
|
|
17741
|
+
}
|
|
17742
|
+
else return check("measurement-consistency", "inconclusive", ["INCONCLUSIVE: verdicts directory missing — measurement consistency cannot be evaluated (certification-critical: never renders as pass)"]);
|
|
17743
|
+
let sidecar = {};
|
|
17744
|
+
if (existsSync(sidecarPath)) try {
|
|
17745
|
+
sidecar = JSON.parse(readFileSync(sidecarPath, "utf8"));
|
|
17746
|
+
} catch (e) {
|
|
17747
|
+
failures.push(`sidecar unreadable/malformed: ${errorText(e)} — regenerate the measurement artifacts`);
|
|
17748
|
+
}
|
|
17749
|
+
else {
|
|
17750
|
+
const needsSidecar = Object.keys(measuredFp).filter((id) => measuredFp[id]?.detectorRevision !== 1);
|
|
17751
|
+
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`]);
|
|
17752
|
+
}
|
|
17753
|
+
for (const [id, m] of Object.entries(measuredFp)) {
|
|
17754
|
+
const side = sidecar[id];
|
|
17755
|
+
if (side !== void 0 && side.detectorRevision !== void 0) {
|
|
17756
|
+
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)`);
|
|
17757
|
+
}
|
|
17758
|
+
const live = liveCounts.get(id);
|
|
17759
|
+
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)`);
|
|
17760
|
+
if (live === void 0 && m.n > 0) failures.push(`${id}: MEASURED_FP.n=${m.n} but no classified verdicts exist in the live corpus`);
|
|
17761
|
+
}
|
|
17762
|
+
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`);
|
|
17763
|
+
if (failures.length > 0) {
|
|
17764
|
+
details.push(...failures);
|
|
17765
|
+
return check("measurement-consistency", "fail", details);
|
|
17766
|
+
}
|
|
17767
|
+
const block = measurementBlock(rules);
|
|
17768
|
+
details.push(`${block.measured} measured / ${block.unmeasured} unmeasured / ${block.total} total (${block.quarantine} quarantine) — MEASURED_FP, live verdicts and sidecar revisions agree`);
|
|
17769
|
+
return check("measurement-consistency", "pass", details);
|
|
17770
|
+
}
|
|
17602
17771
|
function runDoctorSelfAudit(fixturesRoot) {
|
|
17603
17772
|
const verdictsDir = join(fixturesRoot, "..", "corpus", "verdicts");
|
|
17773
|
+
const repoRoot = join(fixturesRoot, "..", "..");
|
|
17604
17774
|
const checks = [
|
|
17605
17775
|
checkFixtureFirewall(fixturesRoot),
|
|
17606
17776
|
checkRegistry(),
|
|
@@ -17609,12 +17779,15 @@ function runDoctorSelfAudit(fixturesRoot) {
|
|
|
17609
17779
|
checkTierEnforcement(verdictsDir),
|
|
17610
17780
|
checkAntiCreep(),
|
|
17611
17781
|
checkQuarantineEnforcement(),
|
|
17782
|
+
checkCategoryIntegrity(),
|
|
17612
17783
|
checkFixtureIntegrity(fixturesRoot),
|
|
17613
|
-
checkRevisionIntegrity(
|
|
17784
|
+
checkRevisionIntegrity(repoRoot),
|
|
17785
|
+
checkMeasurementConsistency(verdictsDir, join(repoRoot, "tests", "corpus", "detector-revisions.json"))
|
|
17614
17786
|
];
|
|
17615
17787
|
return {
|
|
17616
17788
|
checks,
|
|
17617
|
-
healthy: checks.every((c) => c.status === "pass")
|
|
17789
|
+
healthy: checks.every((c) => c.status === "pass"),
|
|
17790
|
+
measurement: measurementBlock()
|
|
17618
17791
|
};
|
|
17619
17792
|
}
|
|
17620
17793
|
function renderDoctorReport(report) {
|
|
@@ -17663,7 +17836,8 @@ function doctorReportJson(report, opts = {}) {
|
|
|
17663
17836
|
schema: DOCTOR_REPORT_SCHEMA,
|
|
17664
17837
|
healthy: report.healthy,
|
|
17665
17838
|
summary,
|
|
17666
|
-
checks
|
|
17839
|
+
checks,
|
|
17840
|
+
measurement: report.measurement
|
|
17667
17841
|
};
|
|
17668
17842
|
}
|
|
17669
17843
|
//#endregion
|
|
@@ -18085,7 +18259,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
|
|
|
18085
18259
|
* `scripts/sync-sarif-version.cjs` on release and guarded by
|
|
18086
18260
|
* `tests/version-consistency.spec.ts` locally.
|
|
18087
18261
|
*/
|
|
18088
|
-
const CLI_VERSION = "0.5.
|
|
18262
|
+
const CLI_VERSION = "0.5.24";
|
|
18089
18263
|
function parseArgs(argv, onError) {
|
|
18090
18264
|
const args = {
|
|
18091
18265
|
target: ".",
|
package/package.json
CHANGED