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/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
- /** The closed set of rule categories (plan §5.5). `--category` values
42
- * are validated against this list — unknown categories are a usage
43
- * error, not a silent no-op. */
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.19",
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 targetIdx = argv.findIndex((a, i) => !a.startsWith("-") && i !== 0 && (jsonIdx === -1 || i !== jsonIdx + 1));
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
- name: "evidence-honesty",
17923
- ok,
17924
- details
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
- name: "revision-integrity",
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
- name: "revision-integrity",
18151
- ok: true,
18152
- details
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(join(fixturesRoot, "..", ".."))
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.ok)
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.ok ? "✓" : "✗";
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.19";
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;