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.
@@ -13263,7 +13263,7 @@ function renderSarif(result, repoRootUri) {
13263
13263
  tool: { driver: {
13264
13264
  name: "Mjölnir",
13265
13265
  informationUri: "https://github.com/Sergey-Bar/Mjolnir",
13266
- version: "0.5.18",
13266
+ version: "0.5.20",
13267
13267
  rules: [...rules.values()].map((r) => {
13268
13268
  const meta = RULES.find((x) => x.id === r.id);
13269
13269
  return {
@@ -13706,7 +13706,7 @@ function renderPrComment(result, options = {}) {
13706
13706
  * from here.
13707
13707
  */
13708
13708
  /** Human message for any thrown value — never "undefined"/"[object Object]". */
13709
- function errorText(err) {
13709
+ function errorText$1(err) {
13710
13710
  if (err instanceof Error) return err.message;
13711
13711
  if (typeof err === "string") return err;
13712
13712
  if (typeof err === "object" && err !== null) return JSON.stringify(err);
@@ -13722,7 +13722,7 @@ function validateReportJson(text) {
13722
13722
  try {
13723
13723
  parsed = JSON.parse(text);
13724
13724
  } catch (err) {
13725
- throw new Error(`not valid JSON (${errorText(err)})`, { cause: err });
13725
+ throw new Error(`not valid JSON (${errorText$1(err)})`, { cause: err });
13726
13726
  }
13727
13727
  if (typeof parsed !== "object" || parsed === null) throw new Error("the file is a JSON value but not an object");
13728
13728
  const doc = parsed;
@@ -13899,7 +13899,7 @@ function runSummaryCommand(argv, io = {
13899
13899
  try {
13900
13900
  result = loadSavedReport(reportPath);
13901
13901
  } catch (err) {
13902
- io.err(`mjolnir summary: cannot read ${reportPath}: ${errorText(err)}`);
13902
+ io.err(`mjolnir summary: cannot read ${reportPath}: ${errorText$1(err)}`);
13903
13903
  return 2;
13904
13904
  }
13905
13905
  const env = process.env;
@@ -13918,7 +13918,7 @@ function runSummaryCommand(argv, io = {
13918
13918
  if (stepSummaryPath) try {
13919
13919
  appendFileSync(stepSummaryPath, `${summary}\n`);
13920
13920
  } catch (err) {
13921
- io.err(`mjolnir summary: could not write $GITHUB_STEP_SUMMARY (${errorText(err)}); printing to stdout instead.`);
13921
+ io.err(`mjolnir summary: could not write $GITHUB_STEP_SUMMARY (${errorText$1(err)}); printing to stdout instead.`);
13922
13922
  io.out(summary);
13923
13923
  }
13924
13924
  else io.out(summary);
@@ -14049,7 +14049,7 @@ async function runWhyCommand(argv, io = {
14049
14049
  try {
14050
14050
  result = loadSavedReport(reportPath);
14051
14051
  } catch (err) {
14052
- io.err(`mjolnir why: cannot read ${reportPath}: ${errorText(err)}`);
14052
+ io.err(`mjolnir why: cannot read ${reportPath}: ${errorText$1(err)}`);
14053
14053
  return 2;
14054
14054
  }
14055
14055
  } else {
@@ -14068,7 +14068,7 @@ async function runWhyCommand(argv, io = {
14068
14068
  strict: argv.includes("--strict")
14069
14069
  });
14070
14070
  } catch (err) {
14071
- io.err(`mjolnir why: scan failed: ${errorText(err)}`);
14071
+ io.err(`mjolnir why: scan failed: ${errorText$1(err)}`);
14072
14072
  return 20;
14073
14073
  }
14074
14074
  }
@@ -14350,7 +14350,7 @@ function runHandoffCommand(argv, io = {
14350
14350
  try {
14351
14351
  result = loadSavedReport(reportPath);
14352
14352
  } catch (err) {
14353
- io.err(`mjolnir handoff: cannot read ${reportPath}: ${errorText(err)}`);
14353
+ io.err(`mjolnir handoff: cannot read ${reportPath}: ${errorText$1(err)}`);
14354
14354
  return 2;
14355
14355
  }
14356
14356
  io.out(renderHandoff(result, {
@@ -17084,6 +17084,18 @@ function renderFixReport(results, dryRun) {
17084
17084
  return lines.join("\n");
17085
17085
  }
17086
17086
  //#endregion
17087
+ //#region src/cli-io.ts
17088
+ const out = (...parts) => console.log(parts.map(String).join(" "));
17089
+ const err = (...parts) => console.error(parts.map(String).join(" "));
17090
+ function internalErrorMessage(err, emit, debug) {
17091
+ const message = err instanceof Error ? err.message : String(err);
17092
+ emit("mjolnir internal error — this is a bug in Mjölnir, not your repo:");
17093
+ emit(` ${message}`);
17094
+ if (debug && err instanceof Error && err.stack) emit(err.stack);
17095
+ emit("Rerun with --debug for the stack trace. Please report this:");
17096
+ emit(" https://github.com/Sergey-Bar/Mjolnir/issues");
17097
+ }
17098
+ //#endregion
17087
17099
  //#region src/rules/measurement.ts
17088
17100
  /** The rule's declared detector implementation revision (§07). */
17089
17101
  function declaredDetectorRevision(rule) {
@@ -17112,6 +17124,192 @@ function isProvisional(rule) {
17112
17124
  return effectiveTier(rule) === "extended" && !hasValidMeasurement(rule);
17113
17125
  }
17114
17126
  //#endregion
17127
+ //#region src/engine/detector-hash.ts
17128
+ /**
17129
+ * Detector revision integrity engine (certification-audit Phase 3,
17130
+ * G4/D8v2/D17).
17131
+ *
17132
+ * The manifest records, per rule, a `logicHash` = source-identity hash
17133
+ * over the rule's DECLARED METADATA (the gating/participation surface)
17134
+ * plus the FULL TOKEN STREAM of its defining module (the detection
17135
+ * surface). It is deliberately NOT a behavior-equivalence hash (D17):
17136
+ * behavior-neutral edits that touch source tokens (renames, unrelated
17137
+ * imports) DO churn it — that errs in the safe direction. What it
17138
+ * mechanically guarantees: no source-token change can pass without
17139
+ * either a declared revision bump or an explicit, review-visible
17140
+ * manifest regeneration.
17141
+ *
17142
+ * Token-stream contract (G4): every non-trivia token of the module with
17143
+ * regex/string/template-literal token texts preserved EXACTLY (whitespace
17144
+ * inside a literal is semantic); comments and inter-token whitespace
17145
+ * excluded (a prettier reformat or comment edit cannot churn the hash).
17146
+ * Module-scope detection constants, control-flow edits, and metadata
17147
+ * edits all change the token stream → detected. `String(rule.run)` is
17148
+ * proven insufficient for exactly this (module-scope constants live in
17149
+ * ≥7 rule files).
17150
+ *
17151
+ * Implementation note: tokenization goes through ts-morph's bundled
17152
+ * TypeScript compiler (ts-morph is a runtime dependency — the doctor
17153
+ * computes current hashes at runtime; `typescript` itself is dev-only
17154
+ * and must not leak into the runtime surface).
17155
+ */
17156
+ /** The record separator between the metadata JSON and the token stream. */
17157
+ const SECTION_SEP = "␞";
17158
+ /** The unit separator between consecutive tokens (length-agnostic, unambiguous). */
17159
+ const TOKEN_SEP = "␟";
17160
+ /**
17161
+ * A context-aware, trivia-free token walk over `moduleText`. Yields
17162
+ * {kind, text} for every non-trivia token with regex/template resolution
17163
+ * identical to the compiler:
17164
+ * - A SlashToken is re-scanned as RegularExpressionLiteral exactly when
17165
+ * the previous token cannot end an expression (the lexer's own
17166
+ * context rule); a division operator stays SlashToken.
17167
+ * - A CloseBraceToken inside a template continuation is re-scanned via
17168
+ * reScanTemplateToken (TemplateHead opens; TemplateMiddle/Tail close
17169
+ * a level). A brace outside a template is plain punctuation —
17170
+ * re-scanning it would swallow the rest of the file (proven).
17171
+ * Regex/string/template bodies keep their exact internal text; comments
17172
+ * and whitespace are trivia and never yielded.
17173
+ */
17174
+ function* walkTokens(moduleText) {
17175
+ const scanner = ts.createScanner(ts.ScriptTarget.ES2022, true, ts.LanguageVariant.Standard, moduleText);
17176
+ let templateDepth = 0;
17177
+ let prevKind;
17178
+ const endsExpression = (kind) => {
17179
+ if (kind === void 0) return false;
17180
+ 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;
17181
+ };
17182
+ let tok = scanner.scan();
17183
+ while (tok !== ts.SyntaxKind.EndOfFileToken) {
17184
+ if (tok === ts.SyntaxKind.SlashToken && !endsExpression(prevKind)) tok = scanner.reScanSlashToken();
17185
+ else if (tok === ts.SyntaxKind.CloseBraceToken && templateDepth > 0) tok = scanner.reScanTemplateToken(false);
17186
+ const text = scanner.getTokenText();
17187
+ yield {
17188
+ kind: tok,
17189
+ text
17190
+ };
17191
+ if (tok === ts.SyntaxKind.TemplateHead) templateDepth++;
17192
+ else if (tok === ts.SyntaxKind.LastTemplateToken) templateDepth = Math.max(0, templateDepth - 1);
17193
+ prevKind = tok;
17194
+ tok = scanner.scan();
17195
+ }
17196
+ }
17197
+ /**
17198
+ * The deterministic token stream consumed by the logic hash: token texts
17199
+ * joined with the unit separator.
17200
+ */
17201
+ function moduleTokenStream(moduleText) {
17202
+ const parts = [];
17203
+ for (const { text } of walkTokens(moduleText)) parts.push(text);
17204
+ return parts.join(TOKEN_SEP);
17205
+ }
17206
+ /** The canonical logic hash for one rule: metadata JSON ‖ module token stream. */
17207
+ function computeRuleLogicHash(metadata, moduleText) {
17208
+ return createHash("sha256").update(JSON.stringify(metadata)).update(SECTION_SEP).update(moduleTokenStream(moduleText)).digest("hex");
17209
+ }
17210
+ /**
17211
+ * The metadata surface a hash covers, extracted from a live rule object.
17212
+ * Field order must stay in sync with RuleHashMetadata (the object literal
17213
+ * order defines the JSON serialization).
17214
+ */
17215
+ function metadataForRule(rule) {
17216
+ return {
17217
+ appliesTo: rule.appliesTo,
17218
+ configRule: rule.configRule,
17219
+ configFiles: rule.configFiles,
17220
+ frameworks: rule.frameworks,
17221
+ tier: rule.tier,
17222
+ severity: rule.severity,
17223
+ confidence: rule.confidence,
17224
+ findingType: rule.findingType,
17225
+ evidenceLevel: rule.evidenceLevel,
17226
+ overlapWith: rule.overlapWith
17227
+ };
17228
+ }
17229
+ function declaredRevision(rule) {
17230
+ return rule.detectorRevision ?? 1;
17231
+ }
17232
+ /**
17233
+ * Walks the rule source tree and maps every rule to its defining module —
17234
+ * STATICALLY. A module claims a rule when its tokens contain the property
17235
+ * sequence `id` `:` `<QA-rule-id string literal>` (defineRule options and
17236
+ * family variants both have exactly this shape). No dynamic import: the
17237
+ * doctor computes current hashes at runtime (also from dist, where .ts
17238
+ * imports are neither available nor needed — only file text is hashed),
17239
+ * so a runtime import of rule sources would break the installed case and
17240
+ * tie the integrity check to the module loader.
17241
+ *
17242
+ * Each rule id must be claimed exactly once — a double claim means the
17243
+ * module layout drifted and the manifest would be ambiguous.
17244
+ */
17245
+ function collectRuleModules(rulesDir) {
17246
+ const claimed = /* @__PURE__ */ new Map();
17247
+ const files = listRuleModules(rulesDir).sort();
17248
+ const RULE_ID_LIT = /^QA-[A-Z]{1,6}-\d{3}$/;
17249
+ for (const file of files) {
17250
+ const moduleText = readFileSync(file, "utf8");
17251
+ let pendingIdKey = false;
17252
+ let prevTok;
17253
+ for (const { kind: tok, text: raw } of walkTokens(moduleText)) {
17254
+ const isStringLit = tok === ts.SyntaxKind.StringLiteral;
17255
+ const literalValue = isStringLit && raw.length >= 2 ? raw.slice(1, -1) : raw;
17256
+ if (isStringLit && RULE_ID_LIT.exec(literalValue) !== null && (prevTok === ts.SyntaxKind.ColonToken && pendingIdKey || prevTok === ts.SyntaxKind.OpenParenToken)) {
17257
+ const prev = claimed.get(literalValue);
17258
+ 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`);
17259
+ claimed.set(literalValue, file);
17260
+ pendingIdKey = false;
17261
+ } else if (tok === ts.SyntaxKind.Identifier && raw === "id") pendingIdKey = true;
17262
+ else if (tok !== ts.SyntaxKind.ColonToken) pendingIdKey = false;
17263
+ prevTok = tok;
17264
+ }
17265
+ }
17266
+ return [...claimed.entries()].map(([ruleId, modulePath]) => ({
17267
+ ruleId,
17268
+ modulePath
17269
+ }));
17270
+ }
17271
+ function listRuleModules(rulesDir) {
17272
+ const out = [];
17273
+ const visit = (dir) => {
17274
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
17275
+ const full = join(dir, entry.name);
17276
+ if (entry.isDirectory()) visit(full);
17277
+ else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) {
17278
+ if (entry.name === "index.ts" || entry.name.endsWith(".generated.ts")) continue;
17279
+ out.push(full);
17280
+ }
17281
+ }
17282
+ };
17283
+ visit(rulesDir);
17284
+ return out;
17285
+ }
17286
+ /**
17287
+ * Computes the full manifest from the live source tree. `rules` supplies
17288
+ * the registry (for detectorRevision + metadata of every registered rule);
17289
+ * `rulesDir` supplies the defining modules. Rules present in one but not
17290
+ * the other fail loudly — the manifest must cover exactly the registry.
17291
+ */
17292
+ function computeDetectorHashes(rules, rulesDir) {
17293
+ const modules = collectRuleModules(rulesDir);
17294
+ const byId = new Map(modules.map((m) => [m.ruleId, m]));
17295
+ const registryIds = new Set(rules.map((r) => r.id));
17296
+ const manifest = {};
17297
+ for (const rule of rules) {
17298
+ const module = byId.get(rule.id);
17299
+ if (!module) throw new Error(`rule ${rule.id} is registered but has no defining module under ${rulesDir}`);
17300
+ const moduleText = readFileSync(module.modulePath, "utf8");
17301
+ manifest[rule.id] = {
17302
+ logicHash: computeRuleLogicHash(metadataForRule(rule), moduleText),
17303
+ detectorRevision: declaredRevision(rule)
17304
+ };
17305
+ }
17306
+ 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`);
17307
+ return manifest;
17308
+ }
17309
+ function loadManifest(path) {
17310
+ return JSON.parse(readFileSync(path, "utf8"));
17311
+ }
17312
+ //#endregion
17115
17313
  //#region src/commands/doctor.ts
17116
17314
  /**
17117
17315
  * `mjolnir doctor` — self-audit of Mjolnir's own rule base.
@@ -17128,7 +17326,20 @@ function isProvisional(rule) {
17128
17326
  *
17129
17327
  * Exit codes reuse the frozen set: 0 healthy · 1 violations · 20 crash.
17130
17328
  */
17329
+ /** Uniform error rendering for doctor details (Error or thrown-as-string). */
17330
+ function errorText(e) {
17331
+ return e instanceof Error ? e.message : String(e);
17332
+ }
17131
17333
  const ui$2 = plainContext();
17334
+ /** Builds the check record: `ok` is always the pass-mirror of `status`. */
17335
+ function check(name, status, details) {
17336
+ return {
17337
+ name,
17338
+ status,
17339
+ ok: status === "pass",
17340
+ details
17341
+ };
17342
+ }
17132
17343
  const VALID_ID = RULE_ID_RE;
17133
17344
  function nonHiddenFiles(dir) {
17134
17345
  if (!existsSync(dir)) return [];
@@ -17151,11 +17362,7 @@ function checkFixtureFirewall(fixturesRoot) {
17151
17362
  details.push(`${rule.id}: missing must-not-fire fixture`);
17152
17363
  }
17153
17364
  }
17154
- return {
17155
- name: "fixture-firewall",
17156
- ok,
17157
- details
17158
- };
17365
+ return check("fixture-firewall", ok ? "pass" : "fail", details);
17159
17366
  }
17160
17367
  /** Check 2: registry sanity — IDs unique, well-formed, titles distinct. */
17161
17368
  function checkRegistry(rules = RULES) {
@@ -17178,20 +17385,12 @@ function checkRegistry(rules = RULES) {
17178
17385
  details.push(`"${r.title}": duplicate title in family (${r.id})`);
17179
17386
  }
17180
17387
  }
17181
- return {
17182
- name: "registry-sanity",
17183
- ok,
17184
- details
17185
- };
17388
+ return check("registry-sanity", ok ? "pass" : "fail", details);
17186
17389
  }
17187
17390
  /** Check 3: Trust Metadata presence (informational until full coverage). */
17188
17391
  function checkTrustMetadata(rules = RULES) {
17189
17392
  const missing = rules.filter((r) => !r.languages?.length || !r.frameworks?.length || r.falsePositiveRisk === void 0);
17190
- return {
17191
- name: "trust-metadata",
17192
- ok: missing.length === 0,
17193
- details: missing.map((r) => `${r.id}: missing trust metadata`)
17194
- };
17393
+ return check("trust-metadata", missing.length === 0 ? "pass" : "fail", missing.map((r) => `${r.id}: missing trust metadata`));
17195
17394
  }
17196
17395
  /**
17197
17396
  * Check 4 (Honesty Core): evidence-level honesty. A rule that claims a
@@ -17210,11 +17409,7 @@ function checkEvidenceHonesty(rules = RULES) {
17210
17409
  details.push(`${r.id}: declares ${r.evidenceLevel} but findingType=${r.findingType}/confidence=${r.confidence} supports at most ${derived}`);
17211
17410
  }
17212
17411
  }
17213
- return {
17214
- name: "evidence-honesty",
17215
- ok,
17216
- details
17217
- };
17412
+ return check("evidence-honesty", ok ? "pass" : "fail", details);
17218
17413
  }
17219
17414
  /**
17220
17415
  * Check 5 (Phase 4 — Tempering Plan, ratcheted per audit H-2):
@@ -17255,11 +17450,7 @@ function checkTierEnforcement(verdictsDir, rules = RULES) {
17255
17450
  const total = coreRules.length;
17256
17451
  const ok = unmeasured <= 0;
17257
17452
  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`);
17258
- return {
17259
- name: "tier-enforcement",
17260
- ok,
17261
- details
17262
- };
17453
+ return check("tier-enforcement", ok ? "pass" : "fail", details);
17263
17454
  }
17264
17455
  /**
17265
17456
  * Check 6 (Phase 7 — Tempering Plan): anti-creep law enforcement.
@@ -17278,11 +17469,7 @@ function checkAntiCreep(rules = RULES) {
17278
17469
  for (const r of overflow.slice(0, 5)) details.push(` overflow: ${r.id} — ${r.title}`);
17279
17470
  if (overflow.length > 5) details.push(` … and ${overflow.length - 5} more`);
17280
17471
  } else details.push(`Core tier: ${count}/65 rules (${65 - count} slots available)`);
17281
- return {
17282
- name: "anti-creep",
17283
- ok,
17284
- details
17285
- };
17472
+ return check("anti-creep", ok ? "pass" : "fail", details);
17286
17473
  }
17287
17474
  /**
17288
17475
  * Check 7 (audit H-1): quarantine enforcement. The tier policy must cap
@@ -17302,11 +17489,7 @@ function checkQuarantineEnforcement(rules = RULES) {
17302
17489
  }
17303
17490
  }
17304
17491
  details.unshift(`${quarantine.length} quarantine rules capped to severity=info, evidence=E0 — no quarantine rule may emit error`);
17305
- return {
17306
- name: "quarantine-enforcement",
17307
- ok,
17308
- details
17309
- };
17492
+ return check("quarantine-enforcement", ok ? "pass" : "fail", details);
17310
17493
  }
17311
17494
  /**
17312
17495
  * Check 8 (certification-audit Phase 2.5, plan G3 Layer A support):
@@ -17359,11 +17542,62 @@ function checkFixtureIntegrity(fixturesRoot, rules = RULES) {
17359
17542
  details.push("typecheck-allowlist.json is unreadable/malformed");
17360
17543
  }
17361
17544
  details.unshift(`fixture trees: ${fixtureDirs} rule dirs, ${fixtureFiles} fixture files — orphaned dirs and empty dirs are blocking`);
17362
- return {
17363
- name: "fixture-integrity",
17364
- ok,
17365
- details
17366
- };
17545
+ return check("fixture-integrity", ok ? "pass" : "fail", details);
17546
+ }
17547
+ /**
17548
+ * Check 9 (certification-audit Phase 3.3, G4/D8v2 — HARD-BLOCKING):
17549
+ * revision-integrity. The manifest tests/corpus/detector-hashes.json
17550
+ * attests each rule's source identity (logicHash = sha256 of the rule's
17551
+ * metadata ‖ its defining module's token stream) against the declared
17552
+ * detectorRevision:
17553
+ * check A — current logicHash ≠ manifest logicHash → FAIL ("bump +
17554
+ * re-measure + regen, or regen with stated behavior-neutrality");
17555
+ * check B — declared revision ≠ manifest revision → FAIL (stale manifest);
17556
+ * manifest missing/unreadable, or the source tree unavailable (the
17557
+ * installed-package case) → the check CANNOT be evaluated
17558
+ * honestly → reported as INCONCLUSIVE and ok=false — a
17559
+ * certification-critical INCONCLUSIVE never renders as pass
17560
+ * (G2); Phase 5's status migration carries the same rule.
17561
+ *
17562
+ * Where the check runs: the doctor self-audits THIS repo, so `repoRoot`
17563
+ * must be the Mjölnir checkout (src/ present). On an installed package
17564
+ * the src tree does not exist → honest INCONCLUSIVE, same as above.
17565
+ */
17566
+ function checkRevisionIntegrity(repoRoot, rules = RULES) {
17567
+ const details = [];
17568
+ const manifestPath = join(repoRoot, "tests", "corpus", "detector-hashes.json");
17569
+ const rulesDir = join(repoRoot, "src", "rules");
17570
+ 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)"]);
17571
+ if (!existsSync(rulesDir)) return check("revision-integrity", "inconclusive", ["INCONCLUSIVE: src/rules is not present (installed package) — detector source identity cannot be verified here"]);
17572
+ let manifest;
17573
+ try {
17574
+ manifest = loadManifest(manifestPath);
17575
+ } catch {
17576
+ return check("revision-integrity", "inconclusive", ["INCONCLUSIVE: tests/corpus/detector-hashes.json is unreadable/malformed — regenerate with `npm run detector-hashes:update`"]);
17577
+ }
17578
+ let current;
17579
+ try {
17580
+ current = computeDetectorHashes(rules, rulesDir);
17581
+ } catch (e) {
17582
+ return check("revision-integrity", "inconclusive", [`INCONCLUSIVE: detector hash computation failed — ${errorText(e)}`]);
17583
+ }
17584
+ const failures = [];
17585
+ for (const rule of rules) {
17586
+ const attested = manifest[rule.id];
17587
+ if (!attested) {
17588
+ failures.push(`${rule.id}: not attested in the manifest (stale manifest — regenerate)`);
17589
+ continue;
17590
+ }
17591
+ 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)`);
17592
+ if (attested.detectorRevision !== declaredDetectorRevision(rule)) failures.push(`${rule.id}: manifest revision ${attested.detectorRevision} ≠ declared ${declaredDetectorRevision(rule)} (stale manifest — regenerate)`);
17593
+ }
17594
+ 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`);
17595
+ if (failures.length > 0) {
17596
+ details.push(...failures);
17597
+ return check("revision-integrity", "fail", details);
17598
+ }
17599
+ details.push(`${rules.length} rules attested: source identity and declared revision match the manifest (check A + check B, G4)`);
17600
+ return check("revision-integrity", "pass", details);
17367
17601
  }
17368
17602
  function runDoctorSelfAudit(fixturesRoot) {
17369
17603
  const verdictsDir = join(fixturesRoot, "..", "corpus", "verdicts");
@@ -17375,11 +17609,12 @@ function runDoctorSelfAudit(fixturesRoot) {
17375
17609
  checkTierEnforcement(verdictsDir),
17376
17610
  checkAntiCreep(),
17377
17611
  checkQuarantineEnforcement(),
17378
- checkFixtureIntegrity(fixturesRoot)
17612
+ checkFixtureIntegrity(fixturesRoot),
17613
+ checkRevisionIntegrity(join(fixturesRoot, "..", ".."))
17379
17614
  ];
17380
17615
  return {
17381
17616
  checks,
17382
- healthy: checks.every((c) => c.ok)
17617
+ healthy: checks.every((c) => c.status === "pass")
17383
17618
  };
17384
17619
  }
17385
17620
  function renderDoctorReport(report) {
@@ -17389,7 +17624,7 @@ function renderDoctorReport(report) {
17389
17624
  ""
17390
17625
  ];
17391
17626
  for (const c of report.checks) {
17392
- const mark = c.ok ? "✓" : "✗";
17627
+ const mark = c.status === "pass" ? "✓" : c.status === "fail" ? "✗" : "? INCONCLUSIVE";
17393
17628
  lines.push(`${mark} ${c.name}`);
17394
17629
  for (const d of c.details.slice(0, 20)) lines.push(` ${d}`);
17395
17630
  if (c.details.length > 20) lines.push(` … and ${c.details.length - 20} more`);
@@ -17398,6 +17633,88 @@ function renderDoctorReport(report) {
17398
17633
  lines.push(report.healthy ? "Mjölnir self-audit: WORTHY" : "Mjölnir self-audit: VIOLATIONS FOUND");
17399
17634
  return lines.join("\n");
17400
17635
  }
17636
+ /** Versioned JSON schema name for `doctor --json` (G5). */
17637
+ const DOCTOR_REPORT_SCHEMA = "mjolnir.doctor-report@1";
17638
+ /**
17639
+ * Serializes the doctor report to the machine-readable contract (Phase 5,
17640
+ * G5): versioned `schema` field, stable key order (constructed once, here),
17641
+ * details limited exactly like the text render, paths already POSIX-relative
17642
+ * (the checks build them that way), and byte-identical across two runs on
17643
+ * the same tree — the CI certification job re-runs the command twice and
17644
+ * diffs, so any nondeterminism fails there.
17645
+ */
17646
+ function doctorReportJson(report, opts = {}) {
17647
+ const maxDetails = opts.maxDetails ?? 20;
17648
+ const summary = {
17649
+ pass: 0,
17650
+ fail: 0,
17651
+ inconclusive: 0
17652
+ };
17653
+ const checks = report.checks.map((c) => {
17654
+ summary[c.status]++;
17655
+ return {
17656
+ name: c.name,
17657
+ status: c.status,
17658
+ ok: c.ok,
17659
+ details: c.details.length > maxDetails ? [...c.details.slice(0, maxDetails), `… and ${c.details.length - maxDetails} more`] : c.details
17660
+ };
17661
+ });
17662
+ return {
17663
+ schema: DOCTOR_REPORT_SCHEMA,
17664
+ healthy: report.healthy,
17665
+ summary,
17666
+ checks
17667
+ };
17668
+ }
17669
+ //#endregion
17670
+ //#region src/commands/doctor-run.ts
17671
+ /**
17672
+ * Doctor CLI entry (certification-audit Phase 5, G6): the command handler
17673
+ * lives beside the check implementations instead of in cli.ts — the CLI
17674
+ * file is a dispatch table, not a business-logic home.
17675
+ *
17676
+ * Exit-code contract (stable, e2e-locked):
17677
+ * 0 — WORTHY (every check pass)
17678
+ * 1 — VIOLATIONS FOUND (any fail OR inconclusive — G2: an
17679
+ * INCONCLUSIVE check never renders as pass, in text or JSON, and
17680
+ * never exits 0)
17681
+ * 2 — no fixtures directory at the target (not an mjolnir checkout)
17682
+ * 10 — usage error (unknown flag / flag-shaped positional)
17683
+ * 20 — internal error (crash; --debug prints the stack)
17684
+ *
17685
+ * `--json`: prints the versioned machine contract (doctorReportJson)
17686
+ * instead of the text render. stdout carries EXACTLY the JSON document —
17687
+ * no banners, no progress lines — so `doctor . --json > report.json` is
17688
+ * a clean file. The exit code is the gate; the JSON is the evidence.
17689
+ */
17690
+ function runDoctorCommand(argv, io = {
17691
+ out,
17692
+ err
17693
+ }) {
17694
+ const json = argv.includes("--json");
17695
+ if (argv.filter((a) => a.startsWith("-") && a !== "--json").length > 0) {
17696
+ io.err("Usage: mjolnir doctor [--json] [repo-root]");
17697
+ return 10;
17698
+ }
17699
+ const targetArg = argv.find((a) => !a.startsWith("-")) ?? process.cwd();
17700
+ try {
17701
+ const fixturesRoot = resolve(join(targetArg, "tests", "fixtures"));
17702
+ if (!existsSync(fixturesRoot)) {
17703
+ io.err(`No fixtures directory at ${fixturesRoot}. Run from the mjolnir repo root.`);
17704
+ return 2;
17705
+ }
17706
+ const report = runDoctorSelfAudit(fixturesRoot);
17707
+ if (json) {
17708
+ io.out(JSON.stringify(doctorReportJson(report), null, 2));
17709
+ return report.healthy ? 0 : 1;
17710
+ }
17711
+ io.out(renderDoctorReport(report));
17712
+ return report.healthy ? 0 : 1;
17713
+ } catch (err) {
17714
+ internalErrorMessage(err, io.err, process.argv.includes("--debug"));
17715
+ return 20;
17716
+ }
17717
+ }
17401
17718
  //#endregion
17402
17719
  //#region src/commands/rules-catalog.ts
17403
17720
  /**
@@ -17768,7 +18085,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
17768
18085
  * `scripts/sync-sarif-version.cjs` on release and guarded by
17769
18086
  * `tests/version-consistency.spec.ts` locally.
17770
18087
  */
17771
- const CLI_VERSION = "0.5.18";
18088
+ const CLI_VERSION = "0.5.20";
17772
18089
  function parseArgs(argv, onError) {
17773
18090
  const args = {
17774
18091
  target: ".",
@@ -17933,8 +18250,6 @@ function parseArgsOrUsage(argv, io) {
17933
18250
  if (!args && !reported) printUsage(io.out);
17934
18251
  return args;
17935
18252
  }
17936
- const out = (...parts) => console.log(parts.map(String).join(" "));
17937
- const err = (...parts) => console.error(parts.map(String).join(" "));
17938
18253
  /** Testable `ci install` handler. Returns the process exit code. */
17939
18254
  function runCiInstall(argv, io = {
17940
18255
  out,
@@ -18056,30 +18371,6 @@ async function runDoctorPlaywright(argv, io = { out }) {
18056
18371
  return 20;
18057
18372
  }
18058
18373
  }
18059
- /** Testable `doctor` handler — self-audit of Mjölnir's own rule base. */
18060
- function runDoctorCommand(argv, io = {
18061
- out,
18062
- err
18063
- }) {
18064
- if (argv.some((a) => a.startsWith("-"))) {
18065
- io.err("Usage: mjolnir doctor [repo-root]");
18066
- return 10;
18067
- }
18068
- const targetArg = argv[0] ?? process$1.cwd();
18069
- try {
18070
- const fixturesRoot = resolve(join(targetArg, "tests", "fixtures"));
18071
- if (!existsSync(fixturesRoot)) {
18072
- io.err(`No fixtures directory at ${fixturesRoot}. Run from the mjolnir repo root.`);
18073
- return 2;
18074
- }
18075
- const report = runDoctorSelfAudit(fixturesRoot);
18076
- io.out(renderDoctorReport(report));
18077
- return report.healthy ? 0 : 1;
18078
- } catch (err) {
18079
- internalErrorMessage(err, io.err, process$1.argv.includes("--debug"));
18080
- return 20;
18081
- }
18082
- }
18083
18374
  /** Testable `rules` handler — rule catalog with Trust Metadata. */
18084
18375
  async function runRulesCommand(argv, io = {
18085
18376
  out,
@@ -18715,22 +19006,6 @@ function runHelpCommand(argv, io = {
18715
19006
  function printUsage(print) {
18716
19007
  print(renderRootHelp());
18717
19008
  }
18718
- /**
18719
- * Friendly exit-20 path (plan M2): the crash says it's Mjölnir's bug,
18720
- * not the user's repo, carries the underlying message for a report, and
18721
- * prints the stack ONLY when `debug` is set (uniform across
18722
- * subcommands — they don't parse scan flags). Tests pin
18723
- * /internal error/i. Exported so the --debug stack arm is directly
18724
- * spec-coverable (spawning a real crash under --debug would be flaky).
18725
- */
18726
- function internalErrorMessage(err, emit, debug) {
18727
- const message = err instanceof Error ? err.message : String(err);
18728
- emit("mjolnir internal error — this is a bug in Mjölnir, not your repo:");
18729
- emit(` ${message}`);
18730
- if (debug && err instanceof Error && err.stack) emit(err.stack);
18731
- emit("Rerun with --debug for the stack trace. Please report this:");
18732
- emit(" https://github.com/Sergey-Bar/Mjolnir/issues");
18733
- }
18734
19009
  function isEntryPoint() {
18735
19010
  const argv1 = process$1.argv[1];
18736
19011
  if (!argv1) return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mjolnir-qa",
3
- "version": "0.5.18",
3
+ "version": "0.5.20",
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": {
@@ -30,6 +30,7 @@
30
30
  "format": "prettier --write .",
31
31
  "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json && tsx scripts/typecheck-fixtures.ts",
32
32
  "golden:update": "tsx tests/golden/gen.ts",
33
+ "detector-hashes:update": "tsx scripts/generate-detector-hashes.ts",
33
34
  "corpus:regression": "tsx tests/corpus/audit.ts",
34
35
  "corpus:regression:update": "tsx tests/corpus/audit.ts --update",
35
36
  "corpus:sample": "tsx scripts/corpus-sample.ts",