mjolnir-qa 0.5.17 → 0.5.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,18 @@ Rule behavior changes (new rules, FP-rate changes against the corpus,
9
9
  severity changes) are first-class entries here — rule IDs are immutable
10
10
  once shipped, so this file is the record of what changed between versions.
11
11
 
12
+ ## [0.5.19] — 2026-09-07
13
+
14
+ ### Changes since 0.5.18
15
+
16
+ - Detector revision integrity: manifest, doctor check, CI WARN base-diff (D8v2, G4) (#54)
17
+
18
+ ## [0.5.18] — 2026-09-07
19
+
20
+ ### Changes since 0.5.17
21
+
22
+ - README: add npm downloads badge
23
+
12
24
  ## [0.5.17] — 2026-09-07
13
25
 
14
26
  ### Changes since 0.5.16
package/README.md CHANGED
@@ -8,6 +8,7 @@
8
8
  pipelines, reports a worthiness score, and shows exactly where trust breaks.
9
9
 
10
10
  [![npm](https://img.shields.io/npm/v/mjolnir-qa.svg?style=flat-square&color=C9A227&labelColor=0B0F17)](https://www.npmjs.com/package/mjolnir-qa)
11
+ [![downloads](https://img.shields.io/npm/dm/mjolnir-qa.svg?style=flat-square&color=C9A227&labelColor=0B0F17)](https://www.npmjs.com/package/mjolnir-qa)
11
12
  [![ci](https://img.shields.io/github/actions/workflow/status/Sergey-Bar/Mjolnir/ci.yml?branch=main&style=flat-square&label=ci&labelColor=0B0F17)](https://github.com/Sergey-Bar/Mjolnir/actions/workflows/ci.yml)
12
13
  [![license](https://img.shields.io/badge/license-MIT-C9A227.svg?style=flat-square&labelColor=0B0F17)](LICENSE)
13
14
  [![node](https://img.shields.io/badge/node-%E2%89%A5%2022.18-2E8C7F.svg?style=flat-square&labelColor=0B0F17)](https://nodejs.org)
package/dist/cli.d.mts CHANGED
@@ -699,7 +699,7 @@ declare const runScan: typeof runScan$1, buildUniversalRules: typeof buildUniver
699
699
  * `scripts/sync-sarif-version.cjs` on release and guarded by
700
700
  * `tests/version-consistency.spec.ts` locally.
701
701
  */
702
- declare const CLI_VERSION = "0.5.17";
702
+ declare const CLI_VERSION = "0.5.19";
703
703
  /** A usage-error detail: the offending token, when one exists. */
704
704
  interface UsageErrorDetail {
705
705
  /** The unknown flag or rejected value (e.g. `--nope`, `loud`). */
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.17",
13267
+ version: "0.5.19",
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,192 @@ function renderFixReport(results, dryRun) {
17630
17630
  return lines.join("\n");
17631
17631
  }
17632
17632
  //#endregion
17633
+ //#region src/engine/detector-hash.ts
17634
+ /**
17635
+ * Detector revision integrity engine (certification-audit Phase 3,
17636
+ * G4/D8v2/D17).
17637
+ *
17638
+ * The manifest records, per rule, a `logicHash` = source-identity hash
17639
+ * over the rule's DECLARED METADATA (the gating/participation surface)
17640
+ * plus the FULL TOKEN STREAM of its defining module (the detection
17641
+ * surface). It is deliberately NOT a behavior-equivalence hash (D17):
17642
+ * behavior-neutral edits that touch source tokens (renames, unrelated
17643
+ * imports) DO churn it — that errs in the safe direction. What it
17644
+ * mechanically guarantees: no source-token change can pass without
17645
+ * either a declared revision bump or an explicit, review-visible
17646
+ * manifest regeneration.
17647
+ *
17648
+ * Token-stream contract (G4): every non-trivia token of the module with
17649
+ * regex/string/template-literal token texts preserved EXACTLY (whitespace
17650
+ * inside a literal is semantic); comments and inter-token whitespace
17651
+ * excluded (a prettier reformat or comment edit cannot churn the hash).
17652
+ * Module-scope detection constants, control-flow edits, and metadata
17653
+ * edits all change the token stream → detected. `String(rule.run)` is
17654
+ * proven insufficient for exactly this (module-scope constants live in
17655
+ * ≥7 rule files).
17656
+ *
17657
+ * Implementation note: tokenization goes through ts-morph's bundled
17658
+ * TypeScript compiler (ts-morph is a runtime dependency — the doctor
17659
+ * computes current hashes at runtime; `typescript` itself is dev-only
17660
+ * and must not leak into the runtime surface).
17661
+ */
17662
+ /** The record separator between the metadata JSON and the token stream. */
17663
+ const SECTION_SEP = "␞";
17664
+ /** The unit separator between consecutive tokens (length-agnostic, unambiguous). */
17665
+ const TOKEN_SEP = "␟";
17666
+ /**
17667
+ * A context-aware, trivia-free token walk over `moduleText`. Yields
17668
+ * {kind, text} for every non-trivia token with regex/template resolution
17669
+ * identical to the compiler:
17670
+ * - A SlashToken is re-scanned as RegularExpressionLiteral exactly when
17671
+ * the previous token cannot end an expression (the lexer's own
17672
+ * context rule); a division operator stays SlashToken.
17673
+ * - A CloseBraceToken inside a template continuation is re-scanned via
17674
+ * reScanTemplateToken (TemplateHead opens; TemplateMiddle/Tail close
17675
+ * a level). A brace outside a template is plain punctuation —
17676
+ * re-scanning it would swallow the rest of the file (proven).
17677
+ * Regex/string/template bodies keep their exact internal text; comments
17678
+ * and whitespace are trivia and never yielded.
17679
+ */
17680
+ function* walkTokens(moduleText) {
17681
+ const scanner = ts.createScanner(ts.ScriptTarget.ES2022, true, ts.LanguageVariant.Standard, moduleText);
17682
+ let templateDepth = 0;
17683
+ let prevKind;
17684
+ const endsExpression = (kind) => {
17685
+ if (kind === void 0) return false;
17686
+ 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;
17687
+ };
17688
+ let tok = scanner.scan();
17689
+ while (tok !== ts.SyntaxKind.EndOfFileToken) {
17690
+ if (tok === ts.SyntaxKind.SlashToken && !endsExpression(prevKind)) tok = scanner.reScanSlashToken();
17691
+ else if (tok === ts.SyntaxKind.CloseBraceToken && templateDepth > 0) tok = scanner.reScanTemplateToken(false);
17692
+ const text = scanner.getTokenText();
17693
+ yield {
17694
+ kind: tok,
17695
+ text
17696
+ };
17697
+ if (tok === ts.SyntaxKind.TemplateHead) templateDepth++;
17698
+ else if (tok === ts.SyntaxKind.LastTemplateToken) templateDepth = Math.max(0, templateDepth - 1);
17699
+ prevKind = tok;
17700
+ tok = scanner.scan();
17701
+ }
17702
+ }
17703
+ /**
17704
+ * The deterministic token stream consumed by the logic hash: token texts
17705
+ * joined with the unit separator.
17706
+ */
17707
+ function moduleTokenStream(moduleText) {
17708
+ const parts = [];
17709
+ for (const { text } of walkTokens(moduleText)) parts.push(text);
17710
+ return parts.join(TOKEN_SEP);
17711
+ }
17712
+ /** The canonical logic hash for one rule: metadata JSON ‖ module token stream. */
17713
+ function computeRuleLogicHash(metadata, moduleText) {
17714
+ return createHash("sha256").update(JSON.stringify(metadata)).update(SECTION_SEP).update(moduleTokenStream(moduleText)).digest("hex");
17715
+ }
17716
+ /**
17717
+ * The metadata surface a hash covers, extracted from a live rule object.
17718
+ * Field order must stay in sync with RuleHashMetadata (the object literal
17719
+ * order defines the JSON serialization).
17720
+ */
17721
+ function metadataForRule(rule) {
17722
+ return {
17723
+ appliesTo: rule.appliesTo,
17724
+ configRule: rule.configRule,
17725
+ configFiles: rule.configFiles,
17726
+ frameworks: rule.frameworks,
17727
+ tier: rule.tier,
17728
+ severity: rule.severity,
17729
+ confidence: rule.confidence,
17730
+ findingType: rule.findingType,
17731
+ evidenceLevel: rule.evidenceLevel,
17732
+ overlapWith: rule.overlapWith
17733
+ };
17734
+ }
17735
+ function declaredRevision(rule) {
17736
+ return rule.detectorRevision ?? 1;
17737
+ }
17738
+ /**
17739
+ * Walks the rule source tree and maps every rule to its defining module —
17740
+ * STATICALLY. A module claims a rule when its tokens contain the property
17741
+ * sequence `id` `:` `<QA-rule-id string literal>` (defineRule options and
17742
+ * family variants both have exactly this shape). No dynamic import: the
17743
+ * doctor computes current hashes at runtime (also from dist, where .ts
17744
+ * imports are neither available nor needed — only file text is hashed),
17745
+ * so a runtime import of rule sources would break the installed case and
17746
+ * tie the integrity check to the module loader.
17747
+ *
17748
+ * Each rule id must be claimed exactly once — a double claim means the
17749
+ * module layout drifted and the manifest would be ambiguous.
17750
+ */
17751
+ function collectRuleModules(rulesDir) {
17752
+ const claimed = /* @__PURE__ */ new Map();
17753
+ const files = listRuleModules(rulesDir).sort();
17754
+ const RULE_ID_LIT = /^QA-[A-Z]{1,6}-\d{3}$/;
17755
+ for (const file of files) {
17756
+ const moduleText = readFileSync(file, "utf8");
17757
+ let pendingIdKey = false;
17758
+ let prevTok;
17759
+ for (const { kind: tok, text: raw } of walkTokens(moduleText)) {
17760
+ const isStringLit = tok === ts.SyntaxKind.StringLiteral;
17761
+ const literalValue = isStringLit && raw.length >= 2 ? raw.slice(1, -1) : raw;
17762
+ if (isStringLit && RULE_ID_LIT.exec(literalValue) !== null && (prevTok === ts.SyntaxKind.ColonToken && pendingIdKey || prevTok === ts.SyntaxKind.OpenParenToken)) {
17763
+ const prev = claimed.get(literalValue);
17764
+ 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`);
17765
+ claimed.set(literalValue, file);
17766
+ pendingIdKey = false;
17767
+ } else if (tok === ts.SyntaxKind.Identifier && raw === "id") pendingIdKey = true;
17768
+ else if (tok !== ts.SyntaxKind.ColonToken) pendingIdKey = false;
17769
+ prevTok = tok;
17770
+ }
17771
+ }
17772
+ return [...claimed.entries()].map(([ruleId, modulePath]) => ({
17773
+ ruleId,
17774
+ modulePath
17775
+ }));
17776
+ }
17777
+ function listRuleModules(rulesDir) {
17778
+ const out = [];
17779
+ const visit = (dir) => {
17780
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
17781
+ const full = join(dir, entry.name);
17782
+ if (entry.isDirectory()) visit(full);
17783
+ else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) {
17784
+ if (entry.name === "index.ts" || entry.name.endsWith(".generated.ts")) continue;
17785
+ out.push(full);
17786
+ }
17787
+ }
17788
+ };
17789
+ visit(rulesDir);
17790
+ return out;
17791
+ }
17792
+ /**
17793
+ * Computes the full manifest from the live source tree. `rules` supplies
17794
+ * the registry (for detectorRevision + metadata of every registered rule);
17795
+ * `rulesDir` supplies the defining modules. Rules present in one but not
17796
+ * the other fail loudly — the manifest must cover exactly the registry.
17797
+ */
17798
+ function computeDetectorHashes(rules, rulesDir) {
17799
+ const modules = collectRuleModules(rulesDir);
17800
+ const byId = new Map(modules.map((m) => [m.ruleId, m]));
17801
+ const registryIds = new Set(rules.map((r) => r.id));
17802
+ const manifest = {};
17803
+ for (const rule of rules) {
17804
+ const module = byId.get(rule.id);
17805
+ if (!module) throw new Error(`rule ${rule.id} is registered but has no defining module under ${rulesDir}`);
17806
+ const moduleText = readFileSync(module.modulePath, "utf8");
17807
+ manifest[rule.id] = {
17808
+ logicHash: computeRuleLogicHash(metadataForRule(rule), moduleText),
17809
+ detectorRevision: declaredRevision(rule)
17810
+ };
17811
+ }
17812
+ 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`);
17813
+ return manifest;
17814
+ }
17815
+ function loadManifest(path) {
17816
+ return JSON.parse(readFileSync(path, "utf8"));
17817
+ }
17818
+ //#endregion
17633
17819
  //#region src/commands/doctor.ts
17634
17820
  /**
17635
17821
  * `mjolnir doctor` — self-audit of Mjolnir's own rule base.
@@ -17646,6 +17832,10 @@ function renderFixReport(results, dryRun) {
17646
17832
  *
17647
17833
  * Exit codes reuse the frozen set: 0 healthy · 1 violations · 20 crash.
17648
17834
  */
17835
+ /** Uniform error rendering for doctor details (Error or thrown-as-string). */
17836
+ function errorText(e) {
17837
+ return e instanceof Error ? e.message : String(e);
17838
+ }
17649
17839
  const ui$1 = plainContext();
17650
17840
  const VALID_ID = RULE_ID_RE;
17651
17841
  function nonHiddenFiles(dir) {
@@ -17883,6 +18073,85 @@ function checkFixtureIntegrity(fixturesRoot, rules = RULES) {
17883
18073
  details
17884
18074
  };
17885
18075
  }
18076
+ /**
18077
+ * Check 9 (certification-audit Phase 3.3, G4/D8v2 — HARD-BLOCKING):
18078
+ * revision-integrity. The manifest tests/corpus/detector-hashes.json
18079
+ * attests each rule's source identity (logicHash = sha256 of the rule's
18080
+ * metadata ‖ its defining module's token stream) against the declared
18081
+ * detectorRevision:
18082
+ * check A — current logicHash ≠ manifest logicHash → FAIL ("bump +
18083
+ * re-measure + regen, or regen with stated behavior-neutrality");
18084
+ * check B — declared revision ≠ manifest revision → FAIL (stale manifest);
18085
+ * manifest missing/unreadable, or the source tree unavailable (the
18086
+ * installed-package case) → the check CANNOT be evaluated
18087
+ * honestly → reported as INCONCLUSIVE and ok=false — a
18088
+ * certification-critical INCONCLUSIVE never renders as pass
18089
+ * (G2); Phase 5's status migration carries the same rule.
18090
+ *
18091
+ * Where the check runs: the doctor self-audits THIS repo, so `repoRoot`
18092
+ * must be the Mjölnir checkout (src/ present). On an installed package
18093
+ * the src tree does not exist → honest INCONCLUSIVE, same as above.
18094
+ */
18095
+ function checkRevisionIntegrity(repoRoot, rules = RULES) {
18096
+ const details = [];
18097
+ const manifestPath = join(repoRoot, "tests", "corpus", "detector-hashes.json");
18098
+ const rulesDir = join(repoRoot, "src", "rules");
18099
+ if (!existsSync(manifestPath)) return {
18100
+ name: "revision-integrity",
18101
+ ok: false,
18102
+ details: ["INCONCLUSIVE: tests/corpus/detector-hashes.json is missing — run `npm run detector-hashes:update` (certification-critical: an unevaluable check never renders as pass)"]
18103
+ };
18104
+ if (!existsSync(rulesDir)) return {
18105
+ name: "revision-integrity",
18106
+ ok: false,
18107
+ details: ["INCONCLUSIVE: src/rules is not present (installed package) — detector source identity cannot be verified here"]
18108
+ };
18109
+ let manifest;
18110
+ try {
18111
+ manifest = loadManifest(manifestPath);
18112
+ } catch {
18113
+ return {
18114
+ name: "revision-integrity",
18115
+ ok: false,
18116
+ details: ["INCONCLUSIVE: tests/corpus/detector-hashes.json is unreadable/malformed — regenerate with `npm run detector-hashes:update`"]
18117
+ };
18118
+ }
18119
+ let current;
18120
+ try {
18121
+ current = computeDetectorHashes(rules, rulesDir);
18122
+ } catch (e) {
18123
+ return {
18124
+ name: "revision-integrity",
18125
+ ok: false,
18126
+ details: [`INCONCLUSIVE: detector hash computation failed — ${errorText(e)}`]
18127
+ };
18128
+ }
18129
+ const failures = [];
18130
+ for (const rule of rules) {
18131
+ const attested = manifest[rule.id];
18132
+ if (!attested) {
18133
+ failures.push(`${rule.id}: not attested in the manifest (stale manifest — regenerate)`);
18134
+ continue;
18135
+ }
18136
+ 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)`);
18137
+ if (attested.detectorRevision !== declaredDetectorRevision(rule)) failures.push(`${rule.id}: manifest revision ${attested.detectorRevision} ≠ declared ${declaredDetectorRevision(rule)} (stale manifest — regenerate)`);
18138
+ }
18139
+ for (const id of Object.keys(manifest)) if (!rules.some((r) => r.id === id)) failures.push(`${id}: attested in the manifest but not in the registry`);
18140
+ if (failures.length > 0) {
18141
+ details.push(...failures);
18142
+ return {
18143
+ name: "revision-integrity",
18144
+ ok: false,
18145
+ details
18146
+ };
18147
+ }
18148
+ details.push(`${rules.length} rules attested: source identity and declared revision match the manifest (check A + check B, G4)`);
18149
+ return {
18150
+ name: "revision-integrity",
18151
+ ok: true,
18152
+ details
18153
+ };
18154
+ }
17886
18155
  function runDoctorSelfAudit(fixturesRoot) {
17887
18156
  const verdictsDir = join(fixturesRoot, "..", "corpus", "verdicts");
17888
18157
  const checks = [
@@ -17893,7 +18162,8 @@ function runDoctorSelfAudit(fixturesRoot) {
17893
18162
  checkTierEnforcement(verdictsDir),
17894
18163
  checkAntiCreep(),
17895
18164
  checkQuarantineEnforcement(),
17896
- checkFixtureIntegrity(fixturesRoot)
18165
+ checkFixtureIntegrity(fixturesRoot),
18166
+ checkRevisionIntegrity(join(fixturesRoot, "..", ".."))
17897
18167
  ];
17898
18168
  return {
17899
18169
  checks,
@@ -18132,7 +18402,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
18132
18402
  * `scripts/sync-sarif-version.cjs` on release and guarded by
18133
18403
  * `tests/version-consistency.spec.ts` locally.
18134
18404
  */
18135
- const CLI_VERSION = "0.5.17";
18405
+ const CLI_VERSION = "0.5.19";
18136
18406
  function parseArgs(argv, onError) {
18137
18407
  const args = {
18138
18408
  target: ".",
@@ -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.17",
13266
+ version: "0.5.19",
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, {
@@ -17112,6 +17112,192 @@ function isProvisional(rule) {
17112
17112
  return effectiveTier(rule) === "extended" && !hasValidMeasurement(rule);
17113
17113
  }
17114
17114
  //#endregion
17115
+ //#region src/engine/detector-hash.ts
17116
+ /**
17117
+ * Detector revision integrity engine (certification-audit Phase 3,
17118
+ * G4/D8v2/D17).
17119
+ *
17120
+ * The manifest records, per rule, a `logicHash` = source-identity hash
17121
+ * over the rule's DECLARED METADATA (the gating/participation surface)
17122
+ * plus the FULL TOKEN STREAM of its defining module (the detection
17123
+ * surface). It is deliberately NOT a behavior-equivalence hash (D17):
17124
+ * behavior-neutral edits that touch source tokens (renames, unrelated
17125
+ * imports) DO churn it — that errs in the safe direction. What it
17126
+ * mechanically guarantees: no source-token change can pass without
17127
+ * either a declared revision bump or an explicit, review-visible
17128
+ * manifest regeneration.
17129
+ *
17130
+ * Token-stream contract (G4): every non-trivia token of the module with
17131
+ * regex/string/template-literal token texts preserved EXACTLY (whitespace
17132
+ * inside a literal is semantic); comments and inter-token whitespace
17133
+ * excluded (a prettier reformat or comment edit cannot churn the hash).
17134
+ * Module-scope detection constants, control-flow edits, and metadata
17135
+ * edits all change the token stream → detected. `String(rule.run)` is
17136
+ * proven insufficient for exactly this (module-scope constants live in
17137
+ * ≥7 rule files).
17138
+ *
17139
+ * Implementation note: tokenization goes through ts-morph's bundled
17140
+ * TypeScript compiler (ts-morph is a runtime dependency — the doctor
17141
+ * computes current hashes at runtime; `typescript` itself is dev-only
17142
+ * and must not leak into the runtime surface).
17143
+ */
17144
+ /** The record separator between the metadata JSON and the token stream. */
17145
+ const SECTION_SEP = "␞";
17146
+ /** The unit separator between consecutive tokens (length-agnostic, unambiguous). */
17147
+ const TOKEN_SEP = "␟";
17148
+ /**
17149
+ * A context-aware, trivia-free token walk over `moduleText`. Yields
17150
+ * {kind, text} for every non-trivia token with regex/template resolution
17151
+ * identical to the compiler:
17152
+ * - A SlashToken is re-scanned as RegularExpressionLiteral exactly when
17153
+ * the previous token cannot end an expression (the lexer's own
17154
+ * context rule); a division operator stays SlashToken.
17155
+ * - A CloseBraceToken inside a template continuation is re-scanned via
17156
+ * reScanTemplateToken (TemplateHead opens; TemplateMiddle/Tail close
17157
+ * a level). A brace outside a template is plain punctuation —
17158
+ * re-scanning it would swallow the rest of the file (proven).
17159
+ * Regex/string/template bodies keep their exact internal text; comments
17160
+ * and whitespace are trivia and never yielded.
17161
+ */
17162
+ function* walkTokens(moduleText) {
17163
+ const scanner = ts.createScanner(ts.ScriptTarget.ES2022, true, ts.LanguageVariant.Standard, moduleText);
17164
+ let templateDepth = 0;
17165
+ let prevKind;
17166
+ const endsExpression = (kind) => {
17167
+ if (kind === void 0) return false;
17168
+ 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;
17169
+ };
17170
+ let tok = scanner.scan();
17171
+ while (tok !== ts.SyntaxKind.EndOfFileToken) {
17172
+ if (tok === ts.SyntaxKind.SlashToken && !endsExpression(prevKind)) tok = scanner.reScanSlashToken();
17173
+ else if (tok === ts.SyntaxKind.CloseBraceToken && templateDepth > 0) tok = scanner.reScanTemplateToken(false);
17174
+ const text = scanner.getTokenText();
17175
+ yield {
17176
+ kind: tok,
17177
+ text
17178
+ };
17179
+ if (tok === ts.SyntaxKind.TemplateHead) templateDepth++;
17180
+ else if (tok === ts.SyntaxKind.LastTemplateToken) templateDepth = Math.max(0, templateDepth - 1);
17181
+ prevKind = tok;
17182
+ tok = scanner.scan();
17183
+ }
17184
+ }
17185
+ /**
17186
+ * The deterministic token stream consumed by the logic hash: token texts
17187
+ * joined with the unit separator.
17188
+ */
17189
+ function moduleTokenStream(moduleText) {
17190
+ const parts = [];
17191
+ for (const { text } of walkTokens(moduleText)) parts.push(text);
17192
+ return parts.join(TOKEN_SEP);
17193
+ }
17194
+ /** The canonical logic hash for one rule: metadata JSON ‖ module token stream. */
17195
+ function computeRuleLogicHash(metadata, moduleText) {
17196
+ return createHash("sha256").update(JSON.stringify(metadata)).update(SECTION_SEP).update(moduleTokenStream(moduleText)).digest("hex");
17197
+ }
17198
+ /**
17199
+ * The metadata surface a hash covers, extracted from a live rule object.
17200
+ * Field order must stay in sync with RuleHashMetadata (the object literal
17201
+ * order defines the JSON serialization).
17202
+ */
17203
+ function metadataForRule(rule) {
17204
+ return {
17205
+ appliesTo: rule.appliesTo,
17206
+ configRule: rule.configRule,
17207
+ configFiles: rule.configFiles,
17208
+ frameworks: rule.frameworks,
17209
+ tier: rule.tier,
17210
+ severity: rule.severity,
17211
+ confidence: rule.confidence,
17212
+ findingType: rule.findingType,
17213
+ evidenceLevel: rule.evidenceLevel,
17214
+ overlapWith: rule.overlapWith
17215
+ };
17216
+ }
17217
+ function declaredRevision(rule) {
17218
+ return rule.detectorRevision ?? 1;
17219
+ }
17220
+ /**
17221
+ * Walks the rule source tree and maps every rule to its defining module —
17222
+ * STATICALLY. A module claims a rule when its tokens contain the property
17223
+ * sequence `id` `:` `<QA-rule-id string literal>` (defineRule options and
17224
+ * family variants both have exactly this shape). No dynamic import: the
17225
+ * doctor computes current hashes at runtime (also from dist, where .ts
17226
+ * imports are neither available nor needed — only file text is hashed),
17227
+ * so a runtime import of rule sources would break the installed case and
17228
+ * tie the integrity check to the module loader.
17229
+ *
17230
+ * Each rule id must be claimed exactly once — a double claim means the
17231
+ * module layout drifted and the manifest would be ambiguous.
17232
+ */
17233
+ function collectRuleModules(rulesDir) {
17234
+ const claimed = /* @__PURE__ */ new Map();
17235
+ const files = listRuleModules(rulesDir).sort();
17236
+ const RULE_ID_LIT = /^QA-[A-Z]{1,6}-\d{3}$/;
17237
+ for (const file of files) {
17238
+ const moduleText = readFileSync(file, "utf8");
17239
+ let pendingIdKey = false;
17240
+ let prevTok;
17241
+ for (const { kind: tok, text: raw } of walkTokens(moduleText)) {
17242
+ const isStringLit = tok === ts.SyntaxKind.StringLiteral;
17243
+ const literalValue = isStringLit && raw.length >= 2 ? raw.slice(1, -1) : raw;
17244
+ if (isStringLit && RULE_ID_LIT.exec(literalValue) !== null && (prevTok === ts.SyntaxKind.ColonToken && pendingIdKey || prevTok === ts.SyntaxKind.OpenParenToken)) {
17245
+ const prev = claimed.get(literalValue);
17246
+ 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`);
17247
+ claimed.set(literalValue, file);
17248
+ pendingIdKey = false;
17249
+ } else if (tok === ts.SyntaxKind.Identifier && raw === "id") pendingIdKey = true;
17250
+ else if (tok !== ts.SyntaxKind.ColonToken) pendingIdKey = false;
17251
+ prevTok = tok;
17252
+ }
17253
+ }
17254
+ return [...claimed.entries()].map(([ruleId, modulePath]) => ({
17255
+ ruleId,
17256
+ modulePath
17257
+ }));
17258
+ }
17259
+ function listRuleModules(rulesDir) {
17260
+ const out = [];
17261
+ const visit = (dir) => {
17262
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
17263
+ const full = join(dir, entry.name);
17264
+ if (entry.isDirectory()) visit(full);
17265
+ else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) {
17266
+ if (entry.name === "index.ts" || entry.name.endsWith(".generated.ts")) continue;
17267
+ out.push(full);
17268
+ }
17269
+ }
17270
+ };
17271
+ visit(rulesDir);
17272
+ return out;
17273
+ }
17274
+ /**
17275
+ * Computes the full manifest from the live source tree. `rules` supplies
17276
+ * the registry (for detectorRevision + metadata of every registered rule);
17277
+ * `rulesDir` supplies the defining modules. Rules present in one but not
17278
+ * the other fail loudly — the manifest must cover exactly the registry.
17279
+ */
17280
+ function computeDetectorHashes(rules, rulesDir) {
17281
+ const modules = collectRuleModules(rulesDir);
17282
+ const byId = new Map(modules.map((m) => [m.ruleId, m]));
17283
+ const registryIds = new Set(rules.map((r) => r.id));
17284
+ const manifest = {};
17285
+ for (const rule of rules) {
17286
+ const module = byId.get(rule.id);
17287
+ if (!module) throw new Error(`rule ${rule.id} is registered but has no defining module under ${rulesDir}`);
17288
+ const moduleText = readFileSync(module.modulePath, "utf8");
17289
+ manifest[rule.id] = {
17290
+ logicHash: computeRuleLogicHash(metadataForRule(rule), moduleText),
17291
+ detectorRevision: declaredRevision(rule)
17292
+ };
17293
+ }
17294
+ 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`);
17295
+ return manifest;
17296
+ }
17297
+ function loadManifest(path) {
17298
+ return JSON.parse(readFileSync(path, "utf8"));
17299
+ }
17300
+ //#endregion
17115
17301
  //#region src/commands/doctor.ts
17116
17302
  /**
17117
17303
  * `mjolnir doctor` — self-audit of Mjolnir's own rule base.
@@ -17128,6 +17314,10 @@ function isProvisional(rule) {
17128
17314
  *
17129
17315
  * Exit codes reuse the frozen set: 0 healthy · 1 violations · 20 crash.
17130
17316
  */
17317
+ /** Uniform error rendering for doctor details (Error or thrown-as-string). */
17318
+ function errorText(e) {
17319
+ return e instanceof Error ? e.message : String(e);
17320
+ }
17131
17321
  const ui$2 = plainContext();
17132
17322
  const VALID_ID = RULE_ID_RE;
17133
17323
  function nonHiddenFiles(dir) {
@@ -17365,6 +17555,85 @@ function checkFixtureIntegrity(fixturesRoot, rules = RULES) {
17365
17555
  details
17366
17556
  };
17367
17557
  }
17558
+ /**
17559
+ * Check 9 (certification-audit Phase 3.3, G4/D8v2 — HARD-BLOCKING):
17560
+ * revision-integrity. The manifest tests/corpus/detector-hashes.json
17561
+ * attests each rule's source identity (logicHash = sha256 of the rule's
17562
+ * metadata ‖ its defining module's token stream) against the declared
17563
+ * detectorRevision:
17564
+ * check A — current logicHash ≠ manifest logicHash → FAIL ("bump +
17565
+ * re-measure + regen, or regen with stated behavior-neutrality");
17566
+ * check B — declared revision ≠ manifest revision → FAIL (stale manifest);
17567
+ * manifest missing/unreadable, or the source tree unavailable (the
17568
+ * installed-package case) → the check CANNOT be evaluated
17569
+ * honestly → reported as INCONCLUSIVE and ok=false — a
17570
+ * certification-critical INCONCLUSIVE never renders as pass
17571
+ * (G2); Phase 5's status migration carries the same rule.
17572
+ *
17573
+ * Where the check runs: the doctor self-audits THIS repo, so `repoRoot`
17574
+ * must be the Mjölnir checkout (src/ present). On an installed package
17575
+ * the src tree does not exist → honest INCONCLUSIVE, same as above.
17576
+ */
17577
+ function checkRevisionIntegrity(repoRoot, rules = RULES) {
17578
+ const details = [];
17579
+ const manifestPath = join(repoRoot, "tests", "corpus", "detector-hashes.json");
17580
+ 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
+ };
17591
+ let manifest;
17592
+ try {
17593
+ manifest = loadManifest(manifestPath);
17594
+ } 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
+ };
17600
+ }
17601
+ let current;
17602
+ try {
17603
+ current = computeDetectorHashes(rules, rulesDir);
17604
+ } catch (e) {
17605
+ return {
17606
+ name: "revision-integrity",
17607
+ ok: false,
17608
+ details: [`INCONCLUSIVE: detector hash computation failed — ${errorText(e)}`]
17609
+ };
17610
+ }
17611
+ const failures = [];
17612
+ for (const rule of rules) {
17613
+ const attested = manifest[rule.id];
17614
+ if (!attested) {
17615
+ failures.push(`${rule.id}: not attested in the manifest (stale manifest — regenerate)`);
17616
+ continue;
17617
+ }
17618
+ 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)`);
17619
+ if (attested.detectorRevision !== declaredDetectorRevision(rule)) failures.push(`${rule.id}: manifest revision ${attested.detectorRevision} ≠ declared ${declaredDetectorRevision(rule)} (stale manifest — regenerate)`);
17620
+ }
17621
+ 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
+ if (failures.length > 0) {
17623
+ details.push(...failures);
17624
+ return {
17625
+ name: "revision-integrity",
17626
+ ok: false,
17627
+ details
17628
+ };
17629
+ }
17630
+ 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
+ };
17636
+ }
17368
17637
  function runDoctorSelfAudit(fixturesRoot) {
17369
17638
  const verdictsDir = join(fixturesRoot, "..", "corpus", "verdicts");
17370
17639
  const checks = [
@@ -17375,7 +17644,8 @@ function runDoctorSelfAudit(fixturesRoot) {
17375
17644
  checkTierEnforcement(verdictsDir),
17376
17645
  checkAntiCreep(),
17377
17646
  checkQuarantineEnforcement(),
17378
- checkFixtureIntegrity(fixturesRoot)
17647
+ checkFixtureIntegrity(fixturesRoot),
17648
+ checkRevisionIntegrity(join(fixturesRoot, "..", ".."))
17379
17649
  ];
17380
17650
  return {
17381
17651
  checks,
@@ -17768,7 +18038,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
17768
18038
  * `scripts/sync-sarif-version.cjs` on release and guarded by
17769
18039
  * `tests/version-consistency.spec.ts` locally.
17770
18040
  */
17771
- const CLI_VERSION = "0.5.17";
18041
+ const CLI_VERSION = "0.5.19";
17772
18042
  function parseArgs(argv, onError) {
17773
18043
  const args = {
17774
18044
  target: ".",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mjolnir-qa",
3
- "version": "0.5.17",
3
+ "version": "0.5.19",
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",