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