mjolnir-qa 0.5.6 → 0.5.8

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
@@ -74,6 +74,21 @@ scan`. Unknown categories are a usage error (exit 10).
74
74
  - help registry gained `why`, `handoff`, `install` and the new flags;
75
75
  site/reference/cli.md documents the handoff trust model.
76
76
 
77
+ ## [0.5.8] — 2026-09-06
78
+
79
+ ### Changes since 0.5.7
80
+
81
+ - Merge pull request #41 from Sergey-Bar/eng/mcp-transport-2.2
82
+ - feat: MCP stdio transport — pure transport over the machine contract (blueprint §21, Phase 4)
83
+
84
+ ## [0.5.7] — 2026-09-06
85
+
86
+ ### Changes since 0.5.6
87
+
88
+ - Merge pull request #40 from Sergey-Bar/eng/machine-contract-2.1
89
+ - test: plural + unknown-cause arms for inconclusive resolution rendering
90
+ - feat: machine verification contract + finding detectorRevision + lifecycle resolution (blueprint §12-§15, §17, §25)
91
+
77
92
  ## [0.5.6] — 2026-09-06
78
93
 
79
94
  ### Changes since 0.5.5
package/dist/cli.d.mts CHANGED
@@ -103,6 +103,15 @@ interface Finding {
103
103
  measuredFpRate?: number;
104
104
  /** Classified (TP+FP) verdicts behind `measuredFpRate`. */
105
105
  measuredFpN?: number;
106
+ /**
107
+ * Detector revision of the rule that produced this finding, stamped
108
+ * from the registry at scan time (blueprint §13, G-16). Identity
109
+ * participates: "same ruleId, different detectorRevision" is formally
110
+ * a different detector for comparison purposes. Additive within
111
+ * schemaVersion 1; absent means the producer predates the field
112
+ * (revision-unknown).
113
+ */
114
+ detectorRevision?: number;
106
115
  /**
107
116
  * Runtime corroboration from a real run report (plan §16), stamped
108
117
  * when a report was available and matched this finding's file/test.
@@ -690,7 +699,7 @@ declare const runScan: typeof runScan$1, buildUniversalRules: typeof buildUniver
690
699
  * `scripts/sync-sarif-version.cjs` on release and guarded by
691
700
  * `tests/version-consistency.spec.ts` locally.
692
701
  */
693
- declare const CLI_VERSION = "0.5.6";
702
+ declare const CLI_VERSION = "0.5.8";
694
703
  /** A usage-error detail: the offending token, when one exists. */
695
704
  interface UsageErrorDetail {
696
705
  /** The unknown flag or rejected value (e.g. `--nope`, `loud`). */
package/dist/cli.mjs CHANGED
@@ -11407,6 +11407,7 @@ async function runScan$1(args, hooks = {}) {
11407
11407
  });
11408
11408
  const tierByRuleId = tiers;
11409
11409
  const pluginsLoaded = pluginMeta;
11410
+ const REVISION_BY_RULE_ID = new Map(activeRules.map((r) => [r.id, r.detectorRevision ?? MEASURED_FP[r.id]?.detectorRevision ?? 1]));
11410
11411
  const cache = args.cache ? createScanCache(workspace.root) : disabledScanCache;
11411
11412
  const rulesDigest = computeRulesDigest(activeRules);
11412
11413
  for (const perr of pluginErrors) findings.push({
@@ -11619,6 +11620,10 @@ async function runScan$1(args, hooks = {}) {
11619
11620
  f.measuredFpN = m.n;
11620
11621
  }
11621
11622
  }
11623
+ for (const f of findings) {
11624
+ const rev = REVISION_BY_RULE_ID.get(f.ruleId);
11625
+ if (rev !== void 0) f.detectorRevision = rev;
11626
+ }
11622
11627
  enforceTierPolicy(findings, tierByRuleId);
11623
11628
  for (const f of findings) f.fixGroupId = f.ruleId;
11624
11629
  const runtimeReportPath = discoverRuntimeReport$1(scanRoot.root);
@@ -11676,6 +11681,103 @@ async function runScan$1(args, hooks = {}) {
11676
11681
  await releaseTreeSitterResources();
11677
11682
  return result;
11678
11683
  }
11684
+ /**
11685
+ * Fields that participate in the verification digest: identity (ruleId,
11686
+ * detectorRevision, file, line, column) + evidence (severity,
11687
+ * evidenceLevel, trustLevel, confidence, findingType) + lifecycle-adjacent
11688
+ * metadata (fixGroupId). PRESENTATION (message/why/fix) is excluded by
11689
+ * design: rewording a detector's prose must not change the digest of a
11690
+ * scan whose semantics are identical.
11691
+ */
11692
+ function digestView(f) {
11693
+ return {
11694
+ ruleId: f.ruleId,
11695
+ detectorRevision: f.detectorRevision ?? null,
11696
+ file: f.file,
11697
+ line: f.line,
11698
+ column: f.column,
11699
+ severity: f.severity,
11700
+ evidenceLevel: f.evidenceLevel ?? null,
11701
+ trustLevel: f.trustLevel ?? null,
11702
+ confidence: f.confidence,
11703
+ findingType: f.findingType,
11704
+ fixGroupId: f.fixGroupId ?? null
11705
+ };
11706
+ }
11707
+ /**
11708
+ * Deterministic canonical serialization: findings in ScanResult order
11709
+ * (already compareFindings-sorted), stable key insertion order, no
11710
+ * indentation. JSON.stringify of plain objects with fixed key order is
11711
+ * deterministic across runs and platforms. `durationMs` is EXCLUDED —
11712
+ * it is wall-clock, not semantics; including it would make two runs of
11713
+ * the same repo produce different digests.
11714
+ */
11715
+ function canonicalScanJson(result) {
11716
+ return JSON.stringify({
11717
+ score: result.score,
11718
+ partial: result.partial,
11719
+ analysisStatus: {
11720
+ discovery: result.analysisStatus.discovery,
11721
+ rules: result.analysisStatus.rules,
11722
+ skippedFiles: result.analysisStatus.skippedFiles,
11723
+ rulesCrashed: result.analysisStatus.rulesCrashed ?? 0,
11724
+ truncationReasons: result.analysisStatus.truncationReasons ?? []
11725
+ },
11726
+ findings: result.findings.map(digestView)
11727
+ });
11728
+ }
11729
+ /** Advisory when the DERIVED evidence level is E0 (same rule as scoring). */
11730
+ function isAdvisory(f) {
11731
+ return (f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence)) === "E0";
11732
+ }
11733
+ function levelFor(f) {
11734
+ if (f.severity === "error") return "failure";
11735
+ if (f.severity === "warning") return "warning";
11736
+ return "notice";
11737
+ }
11738
+ function countBy(findings, predicate) {
11739
+ return findings.filter(predicate).length;
11740
+ }
11741
+ /**
11742
+ * Project a canonical ScanResult into the machine contract. Pure:
11743
+ * same input → same output, always (§16 semantic integrity, §25.1).
11744
+ */
11745
+ function buildMachineContract(result) {
11746
+ const digest = createHash("sha256").update(canonicalScanJson(result)).digest("hex");
11747
+ const annotations = result.findings.map((f) => ({
11748
+ path: f.file,
11749
+ start_line: f.line,
11750
+ annotation_level: levelFor(f),
11751
+ message: `${f.ruleId}: ${f.message}`,
11752
+ ruleId: f.ruleId,
11753
+ ...f.detectorRevision !== void 0 ? { detectorRevision: f.detectorRevision } : {},
11754
+ advisory: isAdvisory(f)
11755
+ }));
11756
+ return {
11757
+ contractVersion: 1,
11758
+ summary: {
11759
+ digest: `sha256:${digest}`,
11760
+ findings: result.findings.length,
11761
+ score: result.score,
11762
+ errors: countBy(result.findings, (f) => f.severity === "error"),
11763
+ warnings: countBy(result.findings, (f) => f.severity === "warning"),
11764
+ infos: countBy(result.findings, (f) => f.severity === "info"),
11765
+ advisory: countBy(result.findings, isAdvisory)
11766
+ },
11767
+ annotations: annotations.slice(0, 50),
11768
+ annotationsTruncated: result.findings.length > 50,
11769
+ completeness: {
11770
+ partial: result.partial,
11771
+ discovery: result.analysisStatus.discovery,
11772
+ rules: result.analysisStatus.rules,
11773
+ skippedFiles: result.analysisStatus.skippedFiles,
11774
+ rulesCrashed: result.analysisStatus.rulesCrashed ?? 0,
11775
+ truncationReasons: result.analysisStatus.truncationReasons ?? [],
11776
+ frameworkDetectionUnknown: result.frameworkDetectionUnknown,
11777
+ durationMs: result.analysisStatus.durationMs
11778
+ }
11779
+ };
11780
+ }
11679
11781
  //#endregion
11680
11782
  //#region src/scorer/prioritize.ts
11681
11783
  /**
@@ -12473,7 +12575,7 @@ function renderSarif(result, repoRootUri) {
12473
12575
  tool: { driver: {
12474
12576
  name: "Mjölnir",
12475
12577
  informationUri: "https://github.com/Sergey-Bar/Mjolnir",
12476
- version: "0.5.6",
12578
+ version: "0.5.8",
12477
12579
  rules: [...rules.values()].map((r) => {
12478
12580
  const meta = RULES.find((x) => x.id === r.id);
12479
12581
  return {
@@ -12888,8 +12990,17 @@ function renderPrComment(result, options = {}) {
12888
12990
  lines.push("");
12889
12991
  lines.push(`_Advisory only — this comment never blocks merging. Generated by [Mjölnir](${options.repoUrl ?? "https://github.com/Sergey-Bar/Mjolnir"})._`);
12890
12992
  if (usingDiff && diff.resolvedFindings.length > 0) {
12891
- lines.push("");
12892
- lines.push(`✨ ${diff.resolvedFindings.length} pre-existing finding${diff.resolvedFindings.length === 1 ? "" : "s"} fixed in this PR.`);
12993
+ const verified = diff.resolvedFindings.filter((f) => f.resolution.status === "VERIFIED-RESOLVED");
12994
+ const inconclusive = diff.resolvedFindings.filter((f) => f.resolution.status === "INCONCLUSIVE");
12995
+ if (verified.length > 0) {
12996
+ lines.push("");
12997
+ lines.push(`✨ ${verified.length} pre-existing finding${verified.length === 1 ? "" : "s"} verified as fixed in this PR.`);
12998
+ }
12999
+ if (inconclusive.length > 0) {
13000
+ const causes = [...new Set(inconclusive.map((f) => f.resolution.cause ?? "unknown"))].join(", ");
13001
+ lines.push("");
13002
+ lines.push(`ℹ ${inconclusive.length} pre-existing finding${inconclusive.length === 1 ? "" : "s"} disappeared, but this scan can't confirm a fix (${causes}).`);
13003
+ }
12893
13004
  }
12894
13005
  return lines.join("\n");
12895
13006
  }
@@ -15185,7 +15296,7 @@ function gitBuffer(root, args) {
15185
15296
  }
15186
15297
  }
15187
15298
  /** Fingerprint a finding for cross-commit matching (line numbers shift). */
15188
- function fingerprint$1(f) {
15299
+ function fingerprint$2(f) {
15189
15300
  return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
15190
15301
  }
15191
15302
  async function computeImpact(root, options) {
@@ -15292,9 +15403,9 @@ async function computeImpact(root, options) {
15292
15403
  }
15293
15404
  const headResult = await options.runScan(root);
15294
15405
  const baseSet = /* @__PURE__ */ new Map();
15295
- for (const f of baseResult.findings) baseSet.set(fingerprint$1(f), f);
15406
+ for (const f of baseResult.findings) baseSet.set(fingerprint$2(f), f);
15296
15407
  const headSet = /* @__PURE__ */ new Map();
15297
- for (const f of headResult.findings) headSet.set(fingerprint$1(f), f);
15408
+ for (const f of headResult.findings) headSet.set(fingerprint$2(f), f);
15298
15409
  const resolved = [];
15299
15410
  for (const [key, f] of baseSet) if (!headSet.has(key)) resolved.push({
15300
15411
  ruleId: f.ruleId,
@@ -15352,6 +15463,79 @@ function renderImpact(report) {
15352
15463
  return lines.join("\n");
15353
15464
  }
15354
15465
  //#endregion
15466
+ //#region src/engine/resolution.ts
15467
+ /** Correlation identity (v1-compatible): ruleId\0file\0message. */
15468
+ function fingerprint$1(entry) {
15469
+ return `${entry.ruleId}\u0000${entry.file}\u0000${entry.message}`;
15470
+ }
15471
+ /**
15472
+ * The §15 ordered algorithm. Pure; first match wins.
15473
+ */
15474
+ function resolve$1(input) {
15475
+ const { entry, baseline, current } = input;
15476
+ const fp = fingerprint$1(entry);
15477
+ const comparedAgainst = input.baselineCommit ?? baseline.commit ?? "unknown baseline";
15478
+ if (current.partial || current.analysisStatus.rules !== "complete") return {
15479
+ status: "INCONCLUSIVE",
15480
+ cause: "partial",
15481
+ comparedAgainst
15482
+ };
15483
+ if (input.crashedRuleIds?.has(entry.ruleId)) return {
15484
+ status: "INCONCLUSIVE",
15485
+ cause: "crash",
15486
+ comparedAgainst
15487
+ };
15488
+ if (input.skippedFiles?.has(entry.file)) return {
15489
+ status: "INCONCLUSIVE",
15490
+ cause: "skipped",
15491
+ comparedAgainst
15492
+ };
15493
+ if (input.suppressed?.has(`${entry.ruleId}\u0000${entry.file}`)) return {
15494
+ status: "SUPPRESSED",
15495
+ comparedAgainst
15496
+ };
15497
+ if (input.excludedFiles?.has(entry.file)) return {
15498
+ status: "DISAPPEARED-NON-FIX",
15499
+ cause: "excluded",
15500
+ comparedAgainst
15501
+ };
15502
+ const entryRev = entry.detectorRevision;
15503
+ if (entryRev === void 0) return {
15504
+ status: "INCONCLUSIVE",
15505
+ cause: "legacy-baseline",
15506
+ comparedAgainst
15507
+ };
15508
+ const registryRev = input.registryRevisions.get(entry.ruleId);
15509
+ if (registryRev === void 0) return {
15510
+ status: "DISAPPEARED-NON-FIX",
15511
+ cause: "retired",
15512
+ comparedAgainst
15513
+ };
15514
+ if (registryRev !== entryRev) return {
15515
+ status: "INCONCLUSIVE",
15516
+ cause: "revision-changed",
15517
+ comparedAgainst
15518
+ };
15519
+ if (current.findings.some((f) => fingerprint$1(f) === fp)) return {
15520
+ status: "STILL-PRESENT",
15521
+ comparedAgainst
15522
+ };
15523
+ return {
15524
+ status: "VERIFIED-RESOLVED",
15525
+ comparedAgainst
15526
+ };
15527
+ }
15528
+ /** The §15 rendering law: "FIXED" only for VERIFIED-RESOLVED. */
15529
+ function renderResolution(r) {
15530
+ switch (r.status) {
15531
+ case "VERIFIED-RESOLVED": return "FIXED SINCE BASELINE (verified by a complete same-revision scan)";
15532
+ case "STILL-PRESENT": return "STILL PRESENT";
15533
+ case "SUPPRESSED": return "SUPPRESSED (active ignore entry)";
15534
+ case "INCONCLUSIVE": return `INCONCLUSIVE (${r.cause})`;
15535
+ case "DISAPPEARED-NON-FIX": return `DISAPPEARED — NOT A FIX (${r.cause})`;
15536
+ }
15537
+ }
15538
+ //#endregion
15355
15539
  //#region src/commands/baseline.ts
15356
15540
  /**
15357
15541
  * `mjolnir baseline` / `mjolnir diff` — Sprint 6 Task 24
@@ -15376,6 +15560,14 @@ function renderImpact(report) {
15376
15560
  const ui$7 = plainContext();
15377
15561
  const DEFAULT_BASELINE_PATH = join(".mjolnir", "baseline.json");
15378
15562
  /**
15563
+ * Registry-declared detector revisions (§17): a baseline entry whose
15564
+ * revision differs from today's registry is INCONCLUSIVE(revision-
15565
+ * changed), never resolved. Omitted declarations mean revision 1 (the
15566
+ * documented RuleMeta default for first-generation detectors); a
15567
+ * ruleId ABSENT from this map means the rule is retired.
15568
+ */
15569
+ const REGISTRY_REVISIONS = new Map(RULES.map((r) => [r.id, r.detectorRevision ?? 1]));
15570
+ /**
15379
15571
  * Correlation identity for before/after comparison (agent-handoff plan
15380
15572
  * §5.2): ruleId + file + message, deliberately EXCLUDING `line` — a
15381
15573
  * source edit that shifts a finding still correlates. file:line is an
@@ -15397,7 +15589,8 @@ function buildBaseline(result, commit) {
15397
15589
  ruleId: f.ruleId,
15398
15590
  file: f.file,
15399
15591
  message: f.message,
15400
- severity: f.severity
15592
+ severity: f.severity,
15593
+ ...f.detectorRevision !== void 0 ? { detectorRevision: f.detectorRevision } : {}
15401
15594
  }))
15402
15595
  };
15403
15596
  }
@@ -15467,7 +15660,18 @@ function diffAgainstBaseline(result, baseline) {
15467
15660
  else newFindings.push(f);
15468
15661
  }
15469
15662
  const resolvedFindings = [];
15470
- for (const [key, f] of baseSet) if (!headKeys.has(key)) resolvedFindings.push(f);
15663
+ for (const [key, f] of baseSet) if (!headKeys.has(key)) {
15664
+ const resolution = resolve$1({
15665
+ entry: f,
15666
+ baseline,
15667
+ current: result,
15668
+ registryRevisions: REGISTRY_REVISIONS
15669
+ });
15670
+ resolvedFindings.push({
15671
+ ...f,
15672
+ resolution
15673
+ });
15674
+ }
15471
15675
  return {
15472
15676
  hasBaseline: true,
15473
15677
  baselineCapturedAt: baseline.capturedAt,
@@ -15507,8 +15711,17 @@ function renderBaselineDiff(diff) {
15507
15711
  }
15508
15712
  lines.push("");
15509
15713
  if (diff.resolvedFindings.length > 0) {
15510
- lines.push(`FIXED SINCE BASELINE (${diff.resolvedFindings.length}):`);
15511
- for (const f of diff.resolvedFindings) lines.push(` ✓ ${f.ruleId} (${f.severity}) · ${f.file} ${f.message}`);
15714
+ const verified = diff.resolvedFindings.filter((f) => f.resolution.status === "VERIFIED-RESOLVED");
15715
+ const unresolved = diff.resolvedFindings.filter((f) => f.resolution.status !== "VERIFIED-RESOLVED");
15716
+ if (verified.length > 0) {
15717
+ lines.push(`FIXED SINCE BASELINE (${verified.length}):`);
15718
+ for (const f of verified) lines.push(` ✓ ${f.ruleId} (${f.severity}) · ${f.file} — ${f.message}`);
15719
+ lines.push("");
15720
+ }
15721
+ if (unresolved.length > 0) {
15722
+ lines.push(`DISAPPEARED — NOT CLASSIFIED AS FIXED (${unresolved.length}):`);
15723
+ for (const f of unresolved) lines.push(` ${renderResolution(f.resolution)} · ${f.ruleId} (${f.severity}) · ${f.file} — ${f.message}`);
15724
+ }
15512
15725
  }
15513
15726
  return lines.join("\n");
15514
15727
  }
@@ -16787,7 +17000,7 @@ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isVali
16787
17000
  * `scripts/sync-sarif-version.cjs` on release and guarded by
16788
17001
  * `tests/version-consistency.spec.ts` locally.
16789
17002
  */
16790
- const CLI_VERSION = "0.5.6";
17003
+ const CLI_VERSION = "0.5.8";
16791
17004
  function parseArgs(argv, onError) {
16792
17005
  const args = {
16793
17006
  target: ".",
@@ -17214,7 +17427,10 @@ async function runScanCommand(argv, io = {
17214
17427
  }
17215
17428
  if (args.format === "sarif") io.out(renderSarif(result));
17216
17429
  else if (args.format === "mermaid") io.out(renderMermaid(result));
17217
- else if (args.json) io.out(JSON.stringify(result, null, 2));
17430
+ else if (args.json) io.out(JSON.stringify({
17431
+ ...result,
17432
+ contract: buildMachineContract(result)
17433
+ }, null, 2));
17218
17434
  else {
17219
17435
  const categories = args.categories;
17220
17436
  const visible = categories && categories.length > 0 ? result.findings.filter((f) => categories.includes(f.category)) : result.findings;
@@ -17490,7 +17706,7 @@ async function runDiffCommand(argv, io = {
17490
17706
  const statsPath = join(target, DEFAULT_STATS_PATH);
17491
17707
  const stats = recordResolved(loadStats(statsPath), diff);
17492
17708
  if (!saveStats(stats, statsPath)) io.err(" (warning: stats could not be written — read-only filesystem? counters not recorded)");
17493
- if (diff.resolvedFindings.length > 0) {
17709
+ if (diff.resolvedFindings.some((f) => f.resolution.status === "VERIFIED-RESOLVED")) {
17494
17710
  const milestone = recordMilestones(stats, ["first-debt-reduction"]);
17495
17711
  if (milestone.newlyAnnounced.length > 0) {
17496
17712
  if (saveStats(milestone.stats, statsPath)) for (const id of milestone.newlyAnnounced) io.out(MILESTONE_MESSAGES[id]);
@@ -0,0 +1 @@
1
+ export {}