mjolnir-qa 0.5.18 → 0.5.20

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
@@ -13264,7 +13264,7 @@ function renderSarif(result, repoRootUri) {
13264
13264
  tool: { driver: {
13265
13265
  name: "Mjölnir",
13266
13266
  informationUri: "https://github.com/Sergey-Bar/Mjolnir",
13267
- version: "0.5.18",
13267
+ version: "0.5.20",
13268
13268
  rules: [...rules.values()].map((r) => {
13269
13269
  const meta = RULES.find((x) => x.id === r.id);
13270
13270
  return {
@@ -13707,7 +13707,7 @@ function renderPrComment(result, options = {}) {
13707
13707
  * from here.
13708
13708
  */
13709
13709
  /** Human message for any thrown value — never "undefined"/"[object Object]". */
13710
- function errorText(err) {
13710
+ function errorText$1(err) {
13711
13711
  if (err instanceof Error) return err.message;
13712
13712
  if (typeof err === "string") return err;
13713
13713
  if (typeof err === "object" && err !== null) return JSON.stringify(err);
@@ -13723,7 +13723,7 @@ function validateReportJson(text) {
13723
13723
  try {
13724
13724
  parsed = JSON.parse(text);
13725
13725
  } catch (err) {
13726
- throw new Error(`not valid JSON (${errorText(err)})`, { cause: err });
13726
+ throw new Error(`not valid JSON (${errorText$1(err)})`, { cause: err });
13727
13727
  }
13728
13728
  if (typeof parsed !== "object" || parsed === null) throw new Error("the file is a JSON value but not an object");
13729
13729
  const doc = parsed;
@@ -13900,7 +13900,7 @@ function runSummaryCommand(argv, io = {
13900
13900
  try {
13901
13901
  result = loadSavedReport(reportPath);
13902
13902
  } catch (err) {
13903
- io.err(`mjolnir summary: cannot read ${reportPath}: ${errorText(err)}`);
13903
+ io.err(`mjolnir summary: cannot read ${reportPath}: ${errorText$1(err)}`);
13904
13904
  return 2;
13905
13905
  }
13906
13906
  const env = process.env;
@@ -13919,7 +13919,7 @@ function runSummaryCommand(argv, io = {
13919
13919
  if (stepSummaryPath) try {
13920
13920
  appendFileSync(stepSummaryPath, `${summary}\n`);
13921
13921
  } catch (err) {
13922
- io.err(`mjolnir summary: could not write $GITHUB_STEP_SUMMARY (${errorText(err)}); printing to stdout instead.`);
13922
+ io.err(`mjolnir summary: could not write $GITHUB_STEP_SUMMARY (${errorText$1(err)}); printing to stdout instead.`);
13923
13923
  io.out(summary);
13924
13924
  }
13925
13925
  else io.out(summary);
@@ -14050,7 +14050,7 @@ async function runWhyCommand(argv, io = {
14050
14050
  try {
14051
14051
  result = loadSavedReport(reportPath);
14052
14052
  } catch (err) {
14053
- io.err(`mjolnir why: cannot read ${reportPath}: ${errorText(err)}`);
14053
+ io.err(`mjolnir why: cannot read ${reportPath}: ${errorText$1(err)}`);
14054
14054
  return 2;
14055
14055
  }
14056
14056
  } else {
@@ -14069,7 +14069,7 @@ async function runWhyCommand(argv, io = {
14069
14069
  strict: argv.includes("--strict")
14070
14070
  });
14071
14071
  } catch (err) {
14072
- io.err(`mjolnir why: scan failed: ${errorText(err)}`);
14072
+ io.err(`mjolnir why: scan failed: ${errorText$1(err)}`);
14073
14073
  return 20;
14074
14074
  }
14075
14075
  }
@@ -14351,7 +14351,7 @@ function runHandoffCommand(argv, io = {
14351
14351
  try {
14352
14352
  result = loadSavedReport(reportPath);
14353
14353
  } catch (err) {
14354
- io.err(`mjolnir handoff: cannot read ${reportPath}: ${errorText(err)}`);
14354
+ io.err(`mjolnir handoff: cannot read ${reportPath}: ${errorText$1(err)}`);
14355
14355
  return 2;
14356
14356
  }
14357
14357
  io.out(renderHandoff(result, {
@@ -17630,6 +17630,204 @@ function renderFixReport(results, dryRun) {
17630
17630
  return lines.join("\n");
17631
17631
  }
17632
17632
  //#endregion
17633
+ //#region src/cli-io.ts
17634
+ const out = (...parts) => console.log(parts.map(String).join(" "));
17635
+ const err = (...parts) => console.error(parts.map(String).join(" "));
17636
+ function internalErrorMessage(err, emit, debug) {
17637
+ const message = err instanceof Error ? err.message : String(err);
17638
+ emit("mjolnir internal error — this is a bug in Mjölnir, not your repo:");
17639
+ emit(` ${message}`);
17640
+ if (debug && err instanceof Error && err.stack) emit(err.stack);
17641
+ emit("Rerun with --debug for the stack trace. Please report this:");
17642
+ emit(" https://github.com/Sergey-Bar/Mjolnir/issues");
17643
+ }
17644
+ //#endregion
17645
+ //#region src/engine/detector-hash.ts
17646
+ /**
17647
+ * Detector revision integrity engine (certification-audit Phase 3,
17648
+ * G4/D8v2/D17).
17649
+ *
17650
+ * The manifest records, per rule, a `logicHash` = source-identity hash
17651
+ * over the rule's DECLARED METADATA (the gating/participation surface)
17652
+ * plus the FULL TOKEN STREAM of its defining module (the detection
17653
+ * surface). It is deliberately NOT a behavior-equivalence hash (D17):
17654
+ * behavior-neutral edits that touch source tokens (renames, unrelated
17655
+ * imports) DO churn it — that errs in the safe direction. What it
17656
+ * mechanically guarantees: no source-token change can pass without
17657
+ * either a declared revision bump or an explicit, review-visible
17658
+ * manifest regeneration.
17659
+ *
17660
+ * Token-stream contract (G4): every non-trivia token of the module with
17661
+ * regex/string/template-literal token texts preserved EXACTLY (whitespace
17662
+ * inside a literal is semantic); comments and inter-token whitespace
17663
+ * excluded (a prettier reformat or comment edit cannot churn the hash).
17664
+ * Module-scope detection constants, control-flow edits, and metadata
17665
+ * edits all change the token stream → detected. `String(rule.run)` is
17666
+ * proven insufficient for exactly this (module-scope constants live in
17667
+ * ≥7 rule files).
17668
+ *
17669
+ * Implementation note: tokenization goes through ts-morph's bundled
17670
+ * TypeScript compiler (ts-morph is a runtime dependency — the doctor
17671
+ * computes current hashes at runtime; `typescript` itself is dev-only
17672
+ * and must not leak into the runtime surface).
17673
+ */
17674
+ /** The record separator between the metadata JSON and the token stream. */
17675
+ const SECTION_SEP = "␞";
17676
+ /** The unit separator between consecutive tokens (length-agnostic, unambiguous). */
17677
+ const TOKEN_SEP = "␟";
17678
+ /**
17679
+ * A context-aware, trivia-free token walk over `moduleText`. Yields
17680
+ * {kind, text} for every non-trivia token with regex/template resolution
17681
+ * identical to the compiler:
17682
+ * - A SlashToken is re-scanned as RegularExpressionLiteral exactly when
17683
+ * the previous token cannot end an expression (the lexer's own
17684
+ * context rule); a division operator stays SlashToken.
17685
+ * - A CloseBraceToken inside a template continuation is re-scanned via
17686
+ * reScanTemplateToken (TemplateHead opens; TemplateMiddle/Tail close
17687
+ * a level). A brace outside a template is plain punctuation —
17688
+ * re-scanning it would swallow the rest of the file (proven).
17689
+ * Regex/string/template bodies keep their exact internal text; comments
17690
+ * and whitespace are trivia and never yielded.
17691
+ */
17692
+ function* walkTokens(moduleText) {
17693
+ const scanner = ts.createScanner(ts.ScriptTarget.ES2022, true, ts.LanguageVariant.Standard, moduleText);
17694
+ let templateDepth = 0;
17695
+ let prevKind;
17696
+ const endsExpression = (kind) => {
17697
+ if (kind === void 0) return false;
17698
+ return kind === ts.SyntaxKind.Identifier || kind === ts.SyntaxKind.StringLiteral || kind === ts.SyntaxKind.NumericLiteral || kind === ts.SyntaxKind.RegularExpressionLiteral || kind === ts.SyntaxKind.NoSubstitutionTemplateLiteral || kind === ts.SyntaxKind.LastTemplateToken || kind === ts.SyntaxKind.CloseParenToken || kind === ts.SyntaxKind.CloseBracketToken || kind === ts.SyntaxKind.CloseBraceToken || kind === ts.SyntaxKind.PlusPlusToken || kind === ts.SyntaxKind.MinusMinusToken || kind === ts.SyntaxKind.ThisKeyword || kind === ts.SyntaxKind.SuperKeyword || kind === ts.SyntaxKind.TrueKeyword || kind === ts.SyntaxKind.FalseKeyword || kind === ts.SyntaxKind.NullKeyword;
17699
+ };
17700
+ let tok = scanner.scan();
17701
+ while (tok !== ts.SyntaxKind.EndOfFileToken) {
17702
+ if (tok === ts.SyntaxKind.SlashToken && !endsExpression(prevKind)) tok = scanner.reScanSlashToken();
17703
+ else if (tok === ts.SyntaxKind.CloseBraceToken && templateDepth > 0) tok = scanner.reScanTemplateToken(false);
17704
+ const text = scanner.getTokenText();
17705
+ yield {
17706
+ kind: tok,
17707
+ text
17708
+ };
17709
+ if (tok === ts.SyntaxKind.TemplateHead) templateDepth++;
17710
+ else if (tok === ts.SyntaxKind.LastTemplateToken) templateDepth = Math.max(0, templateDepth - 1);
17711
+ prevKind = tok;
17712
+ tok = scanner.scan();
17713
+ }
17714
+ }
17715
+ /**
17716
+ * The deterministic token stream consumed by the logic hash: token texts
17717
+ * joined with the unit separator.
17718
+ */
17719
+ function moduleTokenStream(moduleText) {
17720
+ const parts = [];
17721
+ for (const { text } of walkTokens(moduleText)) parts.push(text);
17722
+ return parts.join(TOKEN_SEP);
17723
+ }
17724
+ /** The canonical logic hash for one rule: metadata JSON ‖ module token stream. */
17725
+ function computeRuleLogicHash(metadata, moduleText) {
17726
+ return createHash("sha256").update(JSON.stringify(metadata)).update(SECTION_SEP).update(moduleTokenStream(moduleText)).digest("hex");
17727
+ }
17728
+ /**
17729
+ * The metadata surface a hash covers, extracted from a live rule object.
17730
+ * Field order must stay in sync with RuleHashMetadata (the object literal
17731
+ * order defines the JSON serialization).
17732
+ */
17733
+ function metadataForRule(rule) {
17734
+ return {
17735
+ appliesTo: rule.appliesTo,
17736
+ configRule: rule.configRule,
17737
+ configFiles: rule.configFiles,
17738
+ frameworks: rule.frameworks,
17739
+ tier: rule.tier,
17740
+ severity: rule.severity,
17741
+ confidence: rule.confidence,
17742
+ findingType: rule.findingType,
17743
+ evidenceLevel: rule.evidenceLevel,
17744
+ overlapWith: rule.overlapWith
17745
+ };
17746
+ }
17747
+ function declaredRevision(rule) {
17748
+ return rule.detectorRevision ?? 1;
17749
+ }
17750
+ /**
17751
+ * Walks the rule source tree and maps every rule to its defining module —
17752
+ * STATICALLY. A module claims a rule when its tokens contain the property
17753
+ * sequence `id` `:` `<QA-rule-id string literal>` (defineRule options and
17754
+ * family variants both have exactly this shape). No dynamic import: the
17755
+ * doctor computes current hashes at runtime (also from dist, where .ts
17756
+ * imports are neither available nor needed — only file text is hashed),
17757
+ * so a runtime import of rule sources would break the installed case and
17758
+ * tie the integrity check to the module loader.
17759
+ *
17760
+ * Each rule id must be claimed exactly once — a double claim means the
17761
+ * module layout drifted and the manifest would be ambiguous.
17762
+ */
17763
+ function collectRuleModules(rulesDir) {
17764
+ const claimed = /* @__PURE__ */ new Map();
17765
+ const files = listRuleModules(rulesDir).sort();
17766
+ const RULE_ID_LIT = /^QA-[A-Z]{1,6}-\d{3}$/;
17767
+ for (const file of files) {
17768
+ const moduleText = readFileSync(file, "utf8");
17769
+ let pendingIdKey = false;
17770
+ let prevTok;
17771
+ for (const { kind: tok, text: raw } of walkTokens(moduleText)) {
17772
+ const isStringLit = tok === ts.SyntaxKind.StringLiteral;
17773
+ const literalValue = isStringLit && raw.length >= 2 ? raw.slice(1, -1) : raw;
17774
+ if (isStringLit && RULE_ID_LIT.exec(literalValue) !== null && (prevTok === ts.SyntaxKind.ColonToken && pendingIdKey || prevTok === ts.SyntaxKind.OpenParenToken)) {
17775
+ const prev = claimed.get(literalValue);
17776
+ if (prev && prev !== file) throw new Error(`rule ${literalValue} is claimed by both ${prev} and ${file} — the manifest needs exactly one defining module per rule`);
17777
+ claimed.set(literalValue, file);
17778
+ pendingIdKey = false;
17779
+ } else if (tok === ts.SyntaxKind.Identifier && raw === "id") pendingIdKey = true;
17780
+ else if (tok !== ts.SyntaxKind.ColonToken) pendingIdKey = false;
17781
+ prevTok = tok;
17782
+ }
17783
+ }
17784
+ return [...claimed.entries()].map(([ruleId, modulePath]) => ({
17785
+ ruleId,
17786
+ modulePath
17787
+ }));
17788
+ }
17789
+ function listRuleModules(rulesDir) {
17790
+ const out = [];
17791
+ const visit = (dir) => {
17792
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
17793
+ const full = join(dir, entry.name);
17794
+ if (entry.isDirectory()) visit(full);
17795
+ else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) {
17796
+ if (entry.name === "index.ts" || entry.name.endsWith(".generated.ts")) continue;
17797
+ out.push(full);
17798
+ }
17799
+ }
17800
+ };
17801
+ visit(rulesDir);
17802
+ return out;
17803
+ }
17804
+ /**
17805
+ * Computes the full manifest from the live source tree. `rules` supplies
17806
+ * the registry (for detectorRevision + metadata of every registered rule);
17807
+ * `rulesDir` supplies the defining modules. Rules present in one but not
17808
+ * the other fail loudly — the manifest must cover exactly the registry.
17809
+ */
17810
+ function computeDetectorHashes(rules, rulesDir) {
17811
+ const modules = collectRuleModules(rulesDir);
17812
+ const byId = new Map(modules.map((m) => [m.ruleId, m]));
17813
+ const registryIds = new Set(rules.map((r) => r.id));
17814
+ const manifest = {};
17815
+ for (const rule of rules) {
17816
+ const module = byId.get(rule.id);
17817
+ if (!module) throw new Error(`rule ${rule.id} is registered but has no defining module under ${rulesDir}`);
17818
+ const moduleText = readFileSync(module.modulePath, "utf8");
17819
+ manifest[rule.id] = {
17820
+ logicHash: computeRuleLogicHash(metadataForRule(rule), moduleText),
17821
+ detectorRevision: declaredRevision(rule)
17822
+ };
17823
+ }
17824
+ for (const id of byId.keys()) if (!registryIds.has(id)) throw new Error(`rule ${id} defines a module under ${rulesDir} but is not in the registry — the manifest must cover exactly the registry`);
17825
+ return manifest;
17826
+ }
17827
+ function loadManifest(path) {
17828
+ return JSON.parse(readFileSync(path, "utf8"));
17829
+ }
17830
+ //#endregion
17633
17831
  //#region src/commands/doctor.ts
17634
17832
  /**
17635
17833
  * `mjolnir doctor` — self-audit of Mjolnir's own rule base.
@@ -17646,7 +17844,20 @@ function renderFixReport(results, dryRun) {
17646
17844
  *
17647
17845
  * Exit codes reuse the frozen set: 0 healthy · 1 violations · 20 crash.
17648
17846
  */
17847
+ /** Uniform error rendering for doctor details (Error or thrown-as-string). */
17848
+ function errorText(e) {
17849
+ return e instanceof Error ? e.message : String(e);
17850
+ }
17649
17851
  const ui$1 = plainContext();
17852
+ /** Builds the check record: `ok` is always the pass-mirror of `status`. */
17853
+ function check(name, status, details) {
17854
+ return {
17855
+ name,
17856
+ status,
17857
+ ok: status === "pass",
17858
+ details
17859
+ };
17860
+ }
17650
17861
  const VALID_ID = RULE_ID_RE;
17651
17862
  function nonHiddenFiles(dir) {
17652
17863
  if (!existsSync(dir)) return [];
@@ -17669,11 +17880,7 @@ function checkFixtureFirewall(fixturesRoot) {
17669
17880
  details.push(`${rule.id}: missing must-not-fire fixture`);
17670
17881
  }
17671
17882
  }
17672
- return {
17673
- name: "fixture-firewall",
17674
- ok,
17675
- details
17676
- };
17883
+ return check("fixture-firewall", ok ? "pass" : "fail", details);
17677
17884
  }
17678
17885
  /** Check 2: registry sanity — IDs unique, well-formed, titles distinct. */
17679
17886
  function checkRegistry(rules = RULES) {
@@ -17696,20 +17903,12 @@ function checkRegistry(rules = RULES) {
17696
17903
  details.push(`"${r.title}": duplicate title in family (${r.id})`);
17697
17904
  }
17698
17905
  }
17699
- return {
17700
- name: "registry-sanity",
17701
- ok,
17702
- details
17703
- };
17906
+ return check("registry-sanity", ok ? "pass" : "fail", details);
17704
17907
  }
17705
17908
  /** Check 3: Trust Metadata presence (informational until full coverage). */
17706
17909
  function checkTrustMetadata(rules = RULES) {
17707
17910
  const missing = rules.filter((r) => !r.languages?.length || !r.frameworks?.length || r.falsePositiveRisk === void 0);
17708
- return {
17709
- name: "trust-metadata",
17710
- ok: missing.length === 0,
17711
- details: missing.map((r) => `${r.id}: missing trust metadata`)
17712
- };
17911
+ return check("trust-metadata", missing.length === 0 ? "pass" : "fail", missing.map((r) => `${r.id}: missing trust metadata`));
17713
17912
  }
17714
17913
  /**
17715
17914
  * Check 4 (Honesty Core): evidence-level honesty. A rule that claims a
@@ -17728,11 +17927,7 @@ function checkEvidenceHonesty(rules = RULES) {
17728
17927
  details.push(`${r.id}: declares ${r.evidenceLevel} but findingType=${r.findingType}/confidence=${r.confidence} supports at most ${derived}`);
17729
17928
  }
17730
17929
  }
17731
- return {
17732
- name: "evidence-honesty",
17733
- ok,
17734
- details
17735
- };
17930
+ return check("evidence-honesty", ok ? "pass" : "fail", details);
17736
17931
  }
17737
17932
  /**
17738
17933
  * Check 5 (Phase 4 — Tempering Plan, ratcheted per audit H-2):
@@ -17773,11 +17968,7 @@ function checkTierEnforcement(verdictsDir, rules = RULES) {
17773
17968
  const total = coreRules.length;
17774
17969
  const ok = unmeasured <= 0;
17775
17970
  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`);
17776
- return {
17777
- name: "tier-enforcement",
17778
- ok,
17779
- details
17780
- };
17971
+ return check("tier-enforcement", ok ? "pass" : "fail", details);
17781
17972
  }
17782
17973
  /**
17783
17974
  * Check 6 (Phase 7 — Tempering Plan): anti-creep law enforcement.
@@ -17796,11 +17987,7 @@ function checkAntiCreep(rules = RULES) {
17796
17987
  for (const r of overflow.slice(0, 5)) details.push(` overflow: ${r.id} — ${r.title}`);
17797
17988
  if (overflow.length > 5) details.push(` … and ${overflow.length - 5} more`);
17798
17989
  } else details.push(`Core tier: ${count}/65 rules (${65 - count} slots available)`);
17799
- return {
17800
- name: "anti-creep",
17801
- ok,
17802
- details
17803
- };
17990
+ return check("anti-creep", ok ? "pass" : "fail", details);
17804
17991
  }
17805
17992
  /**
17806
17993
  * Check 7 (audit H-1): quarantine enforcement. The tier policy must cap
@@ -17820,11 +18007,7 @@ function checkQuarantineEnforcement(rules = RULES) {
17820
18007
  }
17821
18008
  }
17822
18009
  details.unshift(`${quarantine.length} quarantine rules capped to severity=info, evidence=E0 — no quarantine rule may emit error`);
17823
- return {
17824
- name: "quarantine-enforcement",
17825
- ok,
17826
- details
17827
- };
18010
+ return check("quarantine-enforcement", ok ? "pass" : "fail", details);
17828
18011
  }
17829
18012
  /**
17830
18013
  * Check 8 (certification-audit Phase 2.5, plan G3 Layer A support):
@@ -17877,11 +18060,62 @@ function checkFixtureIntegrity(fixturesRoot, rules = RULES) {
17877
18060
  details.push("typecheck-allowlist.json is unreadable/malformed");
17878
18061
  }
17879
18062
  details.unshift(`fixture trees: ${fixtureDirs} rule dirs, ${fixtureFiles} fixture files — orphaned dirs and empty dirs are blocking`);
17880
- return {
17881
- name: "fixture-integrity",
17882
- ok,
17883
- details
17884
- };
18063
+ return check("fixture-integrity", ok ? "pass" : "fail", details);
18064
+ }
18065
+ /**
18066
+ * Check 9 (certification-audit Phase 3.3, G4/D8v2 — HARD-BLOCKING):
18067
+ * revision-integrity. The manifest tests/corpus/detector-hashes.json
18068
+ * attests each rule's source identity (logicHash = sha256 of the rule's
18069
+ * metadata ‖ its defining module's token stream) against the declared
18070
+ * detectorRevision:
18071
+ * check A — current logicHash ≠ manifest logicHash → FAIL ("bump +
18072
+ * re-measure + regen, or regen with stated behavior-neutrality");
18073
+ * check B — declared revision ≠ manifest revision → FAIL (stale manifest);
18074
+ * manifest missing/unreadable, or the source tree unavailable (the
18075
+ * installed-package case) → the check CANNOT be evaluated
18076
+ * honestly → reported as INCONCLUSIVE and ok=false — a
18077
+ * certification-critical INCONCLUSIVE never renders as pass
18078
+ * (G2); Phase 5's status migration carries the same rule.
18079
+ *
18080
+ * Where the check runs: the doctor self-audits THIS repo, so `repoRoot`
18081
+ * must be the Mjölnir checkout (src/ present). On an installed package
18082
+ * the src tree does not exist → honest INCONCLUSIVE, same as above.
18083
+ */
18084
+ function checkRevisionIntegrity(repoRoot, rules = RULES) {
18085
+ const details = [];
18086
+ const manifestPath = join(repoRoot, "tests", "corpus", "detector-hashes.json");
18087
+ const rulesDir = join(repoRoot, "src", "rules");
18088
+ 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)"]);
18089
+ if (!existsSync(rulesDir)) return check("revision-integrity", "inconclusive", ["INCONCLUSIVE: src/rules is not present (installed package) — detector source identity cannot be verified here"]);
18090
+ let manifest;
18091
+ try {
18092
+ manifest = loadManifest(manifestPath);
18093
+ } catch {
18094
+ return check("revision-integrity", "inconclusive", ["INCONCLUSIVE: tests/corpus/detector-hashes.json is unreadable/malformed — regenerate with `npm run detector-hashes:update`"]);
18095
+ }
18096
+ let current;
18097
+ try {
18098
+ current = computeDetectorHashes(rules, rulesDir);
18099
+ } catch (e) {
18100
+ return check("revision-integrity", "inconclusive", [`INCONCLUSIVE: detector hash computation failed — ${errorText(e)}`]);
18101
+ }
18102
+ const failures = [];
18103
+ for (const rule of rules) {
18104
+ const attested = manifest[rule.id];
18105
+ if (!attested) {
18106
+ failures.push(`${rule.id}: not attested in the manifest (stale manifest — regenerate)`);
18107
+ continue;
18108
+ }
18109
+ if (attested.logicHash !== current[rule.id]?.logicHash) failures.push(`${rule.id}: source identity changed since manifest attestation — bump detectorRevision + re-measure + regen, or regen with stated behavior-neutrality (G4)`);
18110
+ if (attested.detectorRevision !== declaredDetectorRevision(rule)) failures.push(`${rule.id}: manifest revision ${attested.detectorRevision} ≠ declared ${declaredDetectorRevision(rule)} (stale manifest — regenerate)`);
18111
+ }
18112
+ 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`);
18113
+ if (failures.length > 0) {
18114
+ details.push(...failures);
18115
+ return check("revision-integrity", "fail", details);
18116
+ }
18117
+ details.push(`${rules.length} rules attested: source identity and declared revision match the manifest (check A + check B, G4)`);
18118
+ return check("revision-integrity", "pass", details);
17885
18119
  }
17886
18120
  function runDoctorSelfAudit(fixturesRoot) {
17887
18121
  const verdictsDir = join(fixturesRoot, "..", "corpus", "verdicts");
@@ -17893,11 +18127,12 @@ function runDoctorSelfAudit(fixturesRoot) {
17893
18127
  checkTierEnforcement(verdictsDir),
17894
18128
  checkAntiCreep(),
17895
18129
  checkQuarantineEnforcement(),
17896
- checkFixtureIntegrity(fixturesRoot)
18130
+ checkFixtureIntegrity(fixturesRoot),
18131
+ checkRevisionIntegrity(join(fixturesRoot, "..", ".."))
17897
18132
  ];
17898
18133
  return {
17899
18134
  checks,
17900
- healthy: checks.every((c) => c.ok)
18135
+ healthy: checks.every((c) => c.status === "pass")
17901
18136
  };
17902
18137
  }
17903
18138
  function renderDoctorReport(report) {
@@ -17907,7 +18142,7 @@ function renderDoctorReport(report) {
17907
18142
  ""
17908
18143
  ];
17909
18144
  for (const c of report.checks) {
17910
- const mark = c.ok ? "✓" : "✗";
18145
+ const mark = c.status === "pass" ? "✓" : c.status === "fail" ? "✗" : "? INCONCLUSIVE";
17911
18146
  lines.push(`${mark} ${c.name}`);
17912
18147
  for (const d of c.details.slice(0, 20)) lines.push(` ${d}`);
17913
18148
  if (c.details.length > 20) lines.push(` … and ${c.details.length - 20} more`);
@@ -17916,6 +18151,88 @@ function renderDoctorReport(report) {
17916
18151
  lines.push(report.healthy ? "Mjölnir self-audit: WORTHY" : "Mjölnir self-audit: VIOLATIONS FOUND");
17917
18152
  return lines.join("\n");
17918
18153
  }
18154
+ /** Versioned JSON schema name for `doctor --json` (G5). */
18155
+ const DOCTOR_REPORT_SCHEMA = "mjolnir.doctor-report@1";
18156
+ /**
18157
+ * Serializes the doctor report to the machine-readable contract (Phase 5,
18158
+ * G5): versioned `schema` field, stable key order (constructed once, here),
18159
+ * details limited exactly like the text render, paths already POSIX-relative
18160
+ * (the checks build them that way), and byte-identical across two runs on
18161
+ * the same tree — the CI certification job re-runs the command twice and
18162
+ * diffs, so any nondeterminism fails there.
18163
+ */
18164
+ function doctorReportJson(report, opts = {}) {
18165
+ const maxDetails = opts.maxDetails ?? 20;
18166
+ const summary = {
18167
+ pass: 0,
18168
+ fail: 0,
18169
+ inconclusive: 0
18170
+ };
18171
+ const checks = report.checks.map((c) => {
18172
+ summary[c.status]++;
18173
+ return {
18174
+ name: c.name,
18175
+ status: c.status,
18176
+ ok: c.ok,
18177
+ details: c.details.length > maxDetails ? [...c.details.slice(0, maxDetails), `… and ${c.details.length - maxDetails} more`] : c.details
18178
+ };
18179
+ });
18180
+ return {
18181
+ schema: DOCTOR_REPORT_SCHEMA,
18182
+ healthy: report.healthy,
18183
+ summary,
18184
+ checks
18185
+ };
18186
+ }
18187
+ //#endregion
18188
+ //#region src/commands/doctor-run.ts
18189
+ /**
18190
+ * Doctor CLI entry (certification-audit Phase 5, G6): the command handler
18191
+ * lives beside the check implementations instead of in cli.ts — the CLI
18192
+ * file is a dispatch table, not a business-logic home.
18193
+ *
18194
+ * Exit-code contract (stable, e2e-locked):
18195
+ * 0 — WORTHY (every check pass)
18196
+ * 1 — VIOLATIONS FOUND (any fail OR inconclusive — G2: an
18197
+ * INCONCLUSIVE check never renders as pass, in text or JSON, and
18198
+ * never exits 0)
18199
+ * 2 — no fixtures directory at the target (not an mjolnir checkout)
18200
+ * 10 — usage error (unknown flag / flag-shaped positional)
18201
+ * 20 — internal error (crash; --debug prints the stack)
18202
+ *
18203
+ * `--json`: prints the versioned machine contract (doctorReportJson)
18204
+ * instead of the text render. stdout carries EXACTLY the JSON document —
18205
+ * no banners, no progress lines — so `doctor . --json > report.json` is
18206
+ * a clean file. The exit code is the gate; the JSON is the evidence.
18207
+ */
18208
+ function runDoctorCommand(argv, io = {
18209
+ out,
18210
+ err
18211
+ }) {
18212
+ const json = argv.includes("--json");
18213
+ if (argv.filter((a) => a.startsWith("-") && a !== "--json").length > 0) {
18214
+ io.err("Usage: mjolnir doctor [--json] [repo-root]");
18215
+ return 10;
18216
+ }
18217
+ const targetArg = argv.find((a) => !a.startsWith("-")) ?? process.cwd();
18218
+ try {
18219
+ const fixturesRoot = resolve(join(targetArg, "tests", "fixtures"));
18220
+ if (!existsSync(fixturesRoot)) {
18221
+ io.err(`No fixtures directory at ${fixturesRoot}. Run from the mjolnir repo root.`);
18222
+ return 2;
18223
+ }
18224
+ const report = runDoctorSelfAudit(fixturesRoot);
18225
+ if (json) {
18226
+ io.out(JSON.stringify(doctorReportJson(report), null, 2));
18227
+ return report.healthy ? 0 : 1;
18228
+ }
18229
+ io.out(renderDoctorReport(report));
18230
+ return report.healthy ? 0 : 1;
18231
+ } catch (err) {
18232
+ internalErrorMessage(err, io.err, process.argv.includes("--debug"));
18233
+ return 20;
18234
+ }
18235
+ }
17919
18236
  //#endregion
17920
18237
  //#region src/commands/rules-catalog.ts
17921
18238
  /**
@@ -18132,7 +18449,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
18132
18449
  * `scripts/sync-sarif-version.cjs` on release and guarded by
18133
18450
  * `tests/version-consistency.spec.ts` locally.
18134
18451
  */
18135
- const CLI_VERSION = "0.5.18";
18452
+ const CLI_VERSION = "0.5.20";
18136
18453
  function parseArgs(argv, onError) {
18137
18454
  const args = {
18138
18455
  target: ".",
@@ -18297,8 +18614,6 @@ function parseArgsOrUsage(argv, io) {
18297
18614
  if (!args && !reported) printUsage(io.out);
18298
18615
  return args;
18299
18616
  }
18300
- const out = (...parts) => console.log(parts.map(String).join(" "));
18301
- const err = (...parts) => console.error(parts.map(String).join(" "));
18302
18617
  /** Testable `ci install` handler. Returns the process exit code. */
18303
18618
  function runCiInstall(argv, io = {
18304
18619
  out,
@@ -18420,30 +18735,6 @@ async function runDoctorPlaywright(argv, io = { out }) {
18420
18735
  return 20;
18421
18736
  }
18422
18737
  }
18423
- /** Testable `doctor` handler — self-audit of Mjölnir's own rule base. */
18424
- function runDoctorCommand(argv, io = {
18425
- out,
18426
- err
18427
- }) {
18428
- if (argv.some((a) => a.startsWith("-"))) {
18429
- io.err("Usage: mjolnir doctor [repo-root]");
18430
- return 10;
18431
- }
18432
- const targetArg = argv[0] ?? process$1.cwd();
18433
- try {
18434
- const fixturesRoot = resolve(join(targetArg, "tests", "fixtures"));
18435
- if (!existsSync(fixturesRoot)) {
18436
- io.err(`No fixtures directory at ${fixturesRoot}. Run from the mjolnir repo root.`);
18437
- return 2;
18438
- }
18439
- const report = runDoctorSelfAudit(fixturesRoot);
18440
- io.out(renderDoctorReport(report));
18441
- return report.healthy ? 0 : 1;
18442
- } catch (err) {
18443
- internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
18444
- return 20;
18445
- }
18446
- }
18447
18738
  /** Testable `rules` handler — rule catalog with Trust Metadata. */
18448
18739
  async function runRulesCommand(argv, io = {
18449
18740
  out,
@@ -19079,22 +19370,6 @@ function runHelpCommand(argv, io = {
19079
19370
  function printUsage(print) {
19080
19371
  print(renderRootHelp());
19081
19372
  }
19082
- /**
19083
- * Friendly exit-20 path (plan M2): the crash says it's Mjölnir's bug,
19084
- * not the user's repo, carries the underlying message for a report, and
19085
- * prints the stack ONLY when `debug` is set (uniform across
19086
- * subcommands — they don't parse scan flags). Tests pin
19087
- * /internal error/i. Exported so the --debug stack arm is directly
19088
- * spec-coverable (spawning a real crash under --debug would be flaky).
19089
- */
19090
- function internalErrorMessage(err, emit, debug) {
19091
- const message = err instanceof Error ? err.message : String(err);
19092
- emit("mjolnir internal error — this is a bug in Mjölnir, not your repo:");
19093
- emit(` ${message}`);
19094
- if (debug && err instanceof Error && err.stack) emit(err.stack);
19095
- emit("Rerun with --debug for the stack trace. Please report this:");
19096
- emit(" https://github.com/Sergey-Bar/Mjolnir/issues");
19097
- }
19098
19373
  function isEntryPoint() {
19099
19374
  const argv1 = process$1.argv[1];
19100
19375
  if (!argv1) return false;