mjolnir-qa 6.0.1 → 6.1.0

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
@@ -11,6 +11,130 @@ once shipped, so this file is the record of what changed between versions.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [6.1.0] — 2026-10-28
15
+
16
+ A minor release from the second half of the maintenance review. Most of it
17
+ fixes behaviour that was wrong without saying so: report text that rendered as
18
+ escaped markup, warnings that printed twice or not at all, reads that failed
19
+ and were counted as successes, and release workflows that could publish when
20
+ told not to. No verb, flag, Action input or MCP tool was added or removed.
21
+
22
+ ### Upgrade notes
23
+
24
+ Two config checks are now strict and exit 10 with the entry named. Both
25
+ configurations used to load and then silently do nothing, so a config that
26
+ fails here was already not doing what it said.
27
+
28
+ - `ignore[].expires` must be a calendar date, `YYYY-MM-DD`. A date-time
29
+ (`2026-09-01T00:00:00Z`) or an unpadded date (`2026-9-1`) left the
30
+ suppression inactive; write the date alone.
31
+ - `plugins` must be an array of package names or `{ "package": "…" }`
32
+ objects. An object such as `{ "acme": {} }` loaded no plugin; write
33
+ `["acme"]`.
34
+
35
+ ### Fixed: the scheduled OSV scan runs again
36
+
37
+ - **OSV-Scanner 2.7.0 removed `--skip-git`**, so the push and scheduled scans
38
+ failed before scanning. The flag is gone; pull-request scans never passed it.
39
+
40
+ ### Fixed: flag suggestions name only flags that exist
41
+
42
+ - **`mjolnir scan` suggests only flags it accepts.** `--dry-run` and
43
+ `--version` were offered as "Did you mean" right after being rejected, and
44
+ ten accepted flags (`--staged`, `--blocking`, `--enable-plugins`, …) were
45
+ never suggested. A spec now reads the parser to hold the two lists together.
46
+ - **Dead code removed:** the `ci install` line that shadowed the `ci`
47
+ subcommand table, an unreachable `ci --help` arm, an `existsSync` given a
48
+ glob it cannot expand, and two copies of the scan time-budget constants,
49
+ which the CLI and the MCP server now import from the pipeline that applies
50
+ them.
51
+
52
+ ### Fixed: CI and release workflows
53
+
54
+ - **A stable release publishes to `latest` directly** (`release.yml`). It
55
+ published to `next` and relied on a token-based repair to move `latest`.
56
+ - **A stable-release resume honours `dry_run` and requires
57
+ `STABLE_RELEASE_AUTHORIZED`.** It ignored both, so a resume dispatched with
58
+ the default `dry_run: true` published, including a dry run's artifact.
59
+ - **The npm propagation wait polls `latest` as well as the version, for up to
60
+ ten minutes, and fails by name.** 6.0.1 took over five minutes to appear,
61
+ and the job timed out.
62
+ - **The Action major-tag workflow moves `vN` to the newest stable `vN.x.y`.**
63
+ A manual dispatch tagged the head of the dispatching branch, and an older
64
+ patch tag could move `vN` backwards.
65
+ - **The dogfood PR comment posts only on same-repository pull requests.** On
66
+ a fork or a manual dispatch the job failed with 403.
67
+ - `github.base_ref` reaches the shell through `env:` rather than script
68
+ interpolation. The dead badge step and a duplicate `check-version` call are
69
+ gone, and the nightly stress run no longer starts at the same minute as
70
+ fuzz.
71
+ - **`ci install` treats a Dependabot action-pin bump as not a
72
+ customisation.** Only the Mjölnir Action's SHA was ignored, so each bump of
73
+ `actions/*` made every workflow the previous release generated read as
74
+ hand-edited. The template's pins follow `mjolnir.yml` again.
75
+ - Action pins bumped: `setup-node` 7.1.0, `upload-artifact` 7.0.2,
76
+ `download-artifact` 8.0.2, CodeQL 4.39.1, OSV-Scanner 2.7.0, Scorecard
77
+ 2.5.0. The `web-tree-sitter` 0.25.10 bump is not taken: the
78
+ `package-smoke` spec holds its exact 0.25.6 pin.
79
+
80
+ ### Fixed: report rendering, config validation and swallowed errors
81
+
82
+ - **Paths and code-like fixes in the PR comment, step summary and handoff are
83
+ real code spans.** Backslash escapes are literal inside a code span, so
84
+ `__snapshots__` rendered as `\_\_snapshots\_\_`, and a backtick in a path
85
+ closed the span early. The fence is now longer than any backtick run inside.
86
+ - **The INCOMPLETE banner on a complete scan with no runtime evidence says
87
+ so**, and the quarantine coverage note is written out in words. The banner
88
+ used to name "an unreported reason".
89
+ - **`ignore[].expires` must be a calendar date, `YYYY-MM-DD` (exit 10
90
+ otherwise).** A date-time or `2026-9-1` loaded, then left the suppression
91
+ silently inactive with no expired warning, and `2026-02-30` quietly meant
92
+ March 2. The expired warning now uses the same check as
93
+ `mjolnir scan --suppressions`.
94
+ - **`plugins` must be an array of package names or `{ "package": … }`
95
+ objects (exit 10 otherwise).** An object, or an entry of another shape,
96
+ loaded cleanly and the loader ran nothing.
97
+ - **Config warnings print once.** `scan` printed each one twice, and `fix`,
98
+ `ci verify` and `handover` dropped them. Those three now also send the
99
+ plugin gate notice to their own stderr.
100
+ - **MCP `explain` finds the rule's fixture from a checkout**, defaulting to
101
+ `<cwd>/tests/fixtures` as `mjolnir explain` does. It never returned the
102
+ example finding the CLI prints.
103
+ - **Runtime corroboration matches Windows report paths whose case differs
104
+ from the scan root** (`c:\repo` and `C:\Repo`). Each verdict missed its
105
+ finding.
106
+ - **`--changed` marks an unreadable untracked file as fully changed** instead
107
+ of building a million-entry line set for it, which also dropped any finding
108
+ past line 1,000,000.
109
+ - **Swallowed reads are counted:** an unreadable `pom.xml`, `build.gradle` or
110
+ `.csproj` (`java-manifest-unreadable`, `csharp-project-unreadable`) and an
111
+ unreadable rule source file in the detector fingerprint
112
+ (`rules-tree-file-unreadable`). The evidence store's audit fails on an
113
+ unreadable records directory instead of reporting an empty, intact store,
114
+ and `forensics` says why `FLAKY.md` could not be written.
115
+ - **Census version majors sort numerically without the ambient locale**, and
116
+ the deterministic-ordering contract now also catches a `localeCompare`
117
+ whose locale argument is `undefined`.
118
+
119
+ ### Fixed: the stable release publishes the artifact it verified
120
+
121
+ - **The first 6.0.1 publish run pushed `v6.0.1` and moved `v6`, then failed
122
+ before `npm publish`.** The verified artifact holds `release-artifact/` and
123
+ `release-assets/`, so `upload-artifact` keeps both directories, and the
124
+ publish job downloaded it into `release-artifact`, one level too deep:
125
+ `sha256sum: release-artifact/mjolnir-qa-6.0.1.tgz: No such file or
126
+ directory`. The GitHub-release job and both resume jobs read the same wrong
127
+ layout. All four now download into the workspace root, and a spec reads the
128
+ upload paths and holds every consumer to them.
129
+ - **The resumed GitHub release attaches the release-trust record**, which the
130
+ first-attempt release already required.
131
+ - **Both publish jobs read no `.npmrc`**, as `release.yml` does, so the
132
+ placeholder token `setup-node` writes there cannot stand in for OIDC trusted
133
+ publishing.
134
+ - **The SBOM checksum records a relative path**, like the tarball's. It
135
+ recorded the runner's absolute workspace path, and checked out only because
136
+ every job ran on the same path.
137
+
14
138
  ## [6.0.1] — 2026-10-28
15
139
 
16
140
  A patch release. 6.0.0 shipped a GitHub Action whose report step calls a
package/README.md CHANGED
@@ -19,7 +19,7 @@
19
19
  npx mjolnir-qa@latest
20
20
  ```
21
21
 
22
- Pin it for reproducible CI: `npx mjolnir-qa@6.0.1`. Requires **Node.js ≥ 22.18** on Windows, macOS or Linux.
22
+ Pin it for reproducible CI: `npx mjolnir-qa@6.1.0`. Requires **Node.js ≥ 22.18** on Windows, macOS or Linux.
23
23
 
24
24
  [**Interactive walkthrough**](https://sergey-bar.github.io/Mjolnir/) · [Docs](https://sergey-bar.github.io/Mjolnir/) · [CHANGELOG](CHANGELOG.md) · [Video](assets/video/mjolnir-demo.mp4)
25
25
 
@@ -29,7 +29,7 @@ Pin it for reproducible CI: `npx mjolnir-qa@6.0.1`. Requires **Node.js ≥ 22.18
29
29
 
30
30
  ## Release status
31
31
 
32
- The published line is `6.0.1`, and this working tree is that line. The version table is
32
+ The published line is `6.1.0`, and this working tree is that line. The version table is
33
33
  [`docs/ROADMAP.yaml`](docs/ROADMAP.yaml). This document does not publish a tag or authorize a release.
34
34
 
35
35
  ---
@@ -431,7 +431,7 @@ Every finding in a handoff carries its boundary: **E2** = deterministic, apply f
431
431
 
432
432
  ### Status
433
433
 
434
- **Version 6.0.1 published.** The JSON schema and the exit codes are frozen contracts. What comes next, with no invented dates:
434
+ **Version 6.1.0 published.** The JSON schema and the exit codes are frozen contracts. What comes next, with no invented dates:
435
435
  [the public roadmap](https://sergey-bar.github.io/Mjolnir/reference/roadmap).
436
436
 
437
437
  ---
package/dist/cli.d.mts CHANGED
@@ -1543,7 +1543,7 @@ declare function isValidFindingRecord$1(f: unknown): f is Omit<Finding, "ruleId"
1543
1543
  * without threading a Project through every adapter.
1544
1544
  */
1545
1545
  declare function runScan$1(args: CliArgs, hooks?: ScanHooks): Promise<ScanResult>;
1546
- declare const ENGINE_VERSION = "6.0.1";
1546
+ declare const ENGINE_VERSION = "6.1.0";
1547
1547
  /**
1548
1548
  * Shared process IO sinks (certification-audit Phase 5, G6): the variadic
1549
1549
  * console sinks and the internal-error reporter lived in cli.ts, which
@@ -1753,7 +1753,7 @@ export declare function runTrustTrendCommand(argv: string[], io?: {
1753
1753
  out: Output;
1754
1754
  err: Output;
1755
1755
  }): Promise<number>;
1756
- export declare const runScan: typeof runScan$1, buildUniversalRules: typeof buildUniversalRules$1, fallbackWorkspace: typeof fallbackWorkspace$1, pathMatchesGlob: typeof pathMatchesGlob$1, isValidFindingRecord: typeof isValidFindingRecord$1, discoverAndParseRuntimeReport: typeof discoverAndParseRuntimeReport$1, KNOWN_RULE_IDS: ReadonlySet<string>, OVERLAP_META_BY_RULE_ID: ReadonlyMap<string, OverlapMeta>, EVIDENCE_OVERRIDES: ReadonlyMap<string, string>, SUITE_INVALIDATING_RULE_IDS: ReadonlySet<string>;
1756
+ export declare const runScan: typeof runScan$1, buildUniversalRules: typeof buildUniversalRules$1, fallbackWorkspace: typeof fallbackWorkspace$1, pathMatchesGlob: typeof pathMatchesGlob$1, isValidFindingRecord: typeof isValidFindingRecord$1, discoverAndParseRuntimeReport: typeof discoverAndParseRuntimeReport$1, KNOWN_RULE_IDS: ReadonlySet<string>, OVERLAP_META_BY_RULE_ID: ReadonlyMap<string, OverlapMeta>, EVIDENCE_OVERRIDES: ReadonlyMap<string, string>, SUITE_INVALIDATING_RULE_IDS: ReadonlySet<string>, DEFAULT_MAX_DURATION_MS: number, MAX_DURATION_MS: number;
1757
1757
  /** A usage-error detail: the offending token, when one exists. */
1758
1758
  export interface UsageErrorDetail {
1759
1759
  /** The unknown flag or rejected value (e.g. `--nope`, `loud`). */
@@ -1761,9 +1761,17 @@ export interface UsageErrorDetail {
1761
1761
  /** The flag whose value was rejected (`--tone` for `--tone loud`). */
1762
1762
  flag?: string | undefined;
1763
1763
  }
1764
- export declare const DEFAULT_MAX_DURATION_MS = 600000;
1765
- export declare const MAX_DURATION_MS = 3600000;
1766
1764
  export declare function parseArgs(argv: string[], onError?: (detail: UsageErrorDetail) => void): CliArgs | null;
1765
+ /**
1766
+ * Scan flags that exist — the "did you mean" candidate pool.
1767
+ *
1768
+ * Every flag `parseArgs` accepts, and nothing it rejects. The pool had
1769
+ * drifted both ways: ten accepted flags were missing, and `--dry-run` and
1770
+ * `--version` were in it although a scan rejects them, so `mjolnir scan
1771
+ * --dry-run` answered `unknown flag "--dry-run"` and then suggested it.
1772
+ * tests/cli/help.spec.ts reads `parseArgs` to hold the two lists together.
1773
+ */
1774
+ export declare const KNOWN_SCAN_FLAGS: string[];
1767
1775
  /** Hand-rolled Levenshtein distance (plan M2: no new dependencies). */
1768
1776
  export declare function levenshtein(a: string, b: string): number;
1769
1777
  /** Nearest known flags within distance ≤ 2, nearest first. */
package/dist/cli.mjs CHANGED
@@ -307,7 +307,8 @@ function stampRuntimeCorroboration(findings, report, scanRoot) {
307
307
  let n = p.replace(/\\/g, "/");
308
308
  if (scanRoot) {
309
309
  const root = scanRoot.replace(/\\/g, "/").replace(/\/?$/, "/");
310
- if (n.startsWith(root)) n = n.slice(root.length);
310
+ const head = n.slice(0, root.length);
311
+ if (/^[a-z]:\//i.test(root) ? head.toLowerCase() === root.toLowerCase() : head === root) n = n.slice(root.length);
311
312
  }
312
313
  return n.replace(/^\.\//, "").replace(/^\/+/, "");
313
314
  };
@@ -2084,7 +2085,7 @@ function readCandidateBinding(root, manifestPath = CANDIDATE_MANIFEST_PATH) {
2084
2085
  * guarded by the version-consistency
2085
2086
  * spec. cli.ts re-exports this as CLI_VERSION.
2086
2087
  */
2087
- const ENGINE_VERSION = "6.0.1";
2088
+ const ENGINE_VERSION = "6.1.0";
2088
2089
  /**
2089
2090
  * Build identity — WHICH BUILD IS RUNNING, as distinct from WHICH RELEASE IS
2090
2091
  * PUBLISHED.
@@ -2411,7 +2412,11 @@ function validateConfigSchema(config) {
2411
2412
  }
2412
2413
  }
2413
2414
  if (cfg["plugins"] !== void 0) {
2414
- if (cfg["plugins"] === null || typeof cfg["plugins"] !== "object") errors.push("plugins must be an object or array");
2415
+ if (!Array.isArray(cfg["plugins"])) errors.push(`plugins must be an array of package names or { "package": "<name>" } objects, got ${typeof cfg["plugins"]}`);
2416
+ else for (let i = 0; i < cfg["plugins"].length; i++) {
2417
+ const entry = cfg["plugins"][i];
2418
+ if (!(typeof entry === "string" || entry !== null && typeof entry === "object" && !Array.isArray(entry) && typeof entry["package"] === "string")) errors.push(`plugins[${i}] must be a package name or { "package": "<name>" }, got ${JSON.stringify(entry)}`);
2419
+ }
2415
2420
  }
2416
2421
  return {
2417
2422
  valid: errors.length === 0,
@@ -2476,7 +2481,7 @@ function loadConfig(root, options = {}) {
2476
2481
  return {
2477
2482
  config: parsed,
2478
2483
  path: p,
2479
- warnings: validate(parsed, options.knownRuleIds)
2484
+ warnings: validate(parsed, options.knownRuleIds, options.now, name)
2480
2485
  };
2481
2486
  } catch (err) {
2482
2487
  const msg = err.message;
@@ -2489,7 +2494,7 @@ function loadConfig(root, options = {}) {
2489
2494
  warnings: []
2490
2495
  };
2491
2496
  }
2492
- function validate(cfg, knownRuleIds) {
2497
+ function validate(cfg, knownRuleIds, now = /* @__PURE__ */ new Date(), fileName = "mjolnir.config.json") {
2493
2498
  const warnings = [];
2494
2499
  const schemaResult = validateConfigSchema(cfg);
2495
2500
  if (!schemaResult.valid) throw new Error(schemaResult.errors.join("; "));
@@ -2509,10 +2514,10 @@ function validate(cfg, knownRuleIds) {
2509
2514
  if (!Array.isArray(ign.files)) throw new Error(`ignore for ${ign.ruleId}: "files" must be an array of glob strings, got ${typeof ign.files}`);
2510
2515
  for (const g of ign.files) if (typeof g !== "string") throw new Error(`ignore for ${ign.ruleId}: "files" entries must be strings, got ${typeof g} (${JSON.stringify(g)})`);
2511
2516
  }
2512
- if (ign.expires !== void 0 && Number.isNaN(new Date(ign.expires).getTime())) throw new Error(`ignore for ${ign.ruleId}: "expires" must be an ISO date, got "${ign.expires}"`);
2513
- if (ign.expires !== void 0 && !Number.isNaN(new Date(ign.expires).getTime()) && new Date(ign.expires).getTime() < Date.now()) warnings.push(`warning: ignore for ${ign.ruleId} has expired (expires "${ign.expires}") — the entry is kept but currently inactive.`);
2517
+ if (ign.expires !== void 0 && Number.isNaN(utcMidnight(ign.expires))) throw new Error(`ignore for ${ign.ruleId}: "expires" must be an ISO date (YYYY-MM-DD), got "${ign.expires}"`);
2518
+ if (ign.expires !== void 0 && !isSuppressionActive(ign, now)) warnings.push(`warning: ignore for ${ign.ruleId} has expired (expires "${ign.expires}") — the entry is kept but currently inactive.`);
2514
2519
  }
2515
- for (const key of Object.keys(cfg)) if (!KNOWN_CONFIG_KEYS.has(key)) warnings.push(`warning: mjolnir.config.json has an unknown top-level key "${key}" — ignored (typo? see README §Configuration).`);
2520
+ for (const key of Object.keys(cfg)) if (!KNOWN_CONFIG_KEYS.has(key)) warnings.push(`warning: ${fileName} has an unknown top-level key "${key}" — ignored (typo? see README §Configuration).`);
2516
2521
  if (cfg.exclude !== void 0) {
2517
2522
  if (!Array.isArray(cfg.exclude)) throw new Error(`exclude must be an array of strings, got ${typeof cfg.exclude}`);
2518
2523
  for (const p of cfg.exclude) if (typeof p !== "string") throw new Error(`exclude entries must be strings, got ${typeof p} (${JSON.stringify(p)})`);
@@ -2534,12 +2539,22 @@ function isSuppressionActive(ign, now = /* @__PURE__ */ new Date()) {
2534
2539
  if (!ign.expires) return true;
2535
2540
  return utcMidnight(ign.expires) > utcMidnightOf(now);
2536
2541
  }
2537
- /** UTC-midnight timestamp of an ISO date string (YYYY-MM-DD). */
2542
+ /**
2543
+ * UTC-midnight timestamp of an ISO calendar date (YYYY-MM-DD), or NaN.
2544
+ *
2545
+ * NaN for anything that is not a real calendar day, so a load-time check and
2546
+ * the active/expired verdict share one parser and cannot disagree.
2547
+ */
2538
2548
  function utcMidnight(isoDate) {
2539
- if (!/^\d{4}-\d{2}-\d{2}$/.test(isoDate)) return NaN;
2540
2549
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
2541
2550
  if (!m) return NaN;
2542
- return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
2551
+ const year = Number(m[1]);
2552
+ const month = Number(m[2]) - 1;
2553
+ const day = Number(m[3]);
2554
+ const t = Date.UTC(year, month, day);
2555
+ const back = new Date(t);
2556
+ if (back.getUTCFullYear() !== year || back.getUTCMonth() !== month || back.getUTCDate() !== day) return NaN;
2557
+ return t;
2543
2558
  }
2544
2559
  /** UTC-midnight timestamp of a Date (calendar day, timezone-independent). */
2545
2560
  function utcMidnightOf(d) {
@@ -4464,7 +4479,9 @@ const javaAdapter = {
4464
4479
  const artifact = /<artifactId>([^<]+)<\/artifactId>/.exec(block[0])?.[1] ?? "";
4465
4480
  for (const { re, tag } of JAVA_ARTIFACT_TAGS) if (re.test(artifact)) frameworks.add(tag);
4466
4481
  }
4467
- } catch {}
4482
+ } catch {
4483
+ recordDegradation("java-manifest-unreadable");
4484
+ }
4468
4485
  for (const gradle of ["build.gradle", "build.gradle.kts"]) {
4469
4486
  const path = join(root, gradle);
4470
4487
  if (!existsSync(path)) continue;
@@ -4474,7 +4491,9 @@ const javaAdapter = {
4474
4491
  const artifact = m[1].split(":")[1];
4475
4492
  for (const { re, tag } of JAVA_ARTIFACT_TAGS) if (artifact !== void 0 && re.test(artifact)) frameworks.add(tag);
4476
4493
  }
4477
- } catch {}
4494
+ } catch {
4495
+ recordDegradation("java-manifest-unreadable");
4496
+ }
4478
4497
  }
4479
4498
  if (frameworks.size === 0) return {
4480
4499
  frameworks: [],
@@ -4632,9 +4651,13 @@ const csharpAdapter = {
4632
4651
  const pkg = m[1];
4633
4652
  for (const { re, tag } of CS_PACKAGE_TAGS) if (re.test(pkg)) frameworks.add(tag);
4634
4653
  }
4635
- } catch {}
4654
+ } catch {
4655
+ recordDegradation("csharp-project-unreadable");
4656
+ }
4636
4657
  }
4637
- } catch {}
4658
+ } catch {
4659
+ recordDegradation("csharp-project-unreadable");
4660
+ }
4638
4661
  if (frameworks.size === 0) return {
4639
4662
  frameworks: [],
4640
4663
  unknown: true
@@ -5585,6 +5608,7 @@ function computeChangedScope(root, baseBranch) {
5585
5608
  ...untrackedSet
5586
5609
  ])].sort();
5587
5610
  const changed = {};
5611
+ const fullyChanged = /* @__PURE__ */ new Set();
5588
5612
  const batchDiff = (args, files) => {
5589
5613
  const perFile = /* @__PURE__ */ new Map();
5590
5614
  if (files.length === 0) return perFile;
@@ -5636,7 +5660,8 @@ function computeChangedScope(root, baseBranch) {
5636
5660
  if (untrackedSet.has(file)) {
5637
5661
  const all = allLinesOf(root, file);
5638
5662
  if (all === null) {
5639
- changed[file] = new Set(Array.from({ length: LIMITS_MAX_LINES }, (_, i) => i + 1));
5663
+ fullyChanged.add(file);
5664
+ changed[file] = lines;
5640
5665
  continue;
5641
5666
  }
5642
5667
  for (const l of all) lines.add(l);
@@ -5645,11 +5670,10 @@ function computeChangedScope(root, baseBranch) {
5645
5670
  }
5646
5671
  return {
5647
5672
  changed,
5673
+ ...fullyChanged.size > 0 ? { fullyChanged } : {},
5648
5674
  degraded: false
5649
5675
  };
5650
5676
  }
5651
- /** Sentinel size for "fully changed" — bounded, mirrors LIMITS.maxFileBytes scale. */
5652
- const LIMITS_MAX_LINES = 1e6;
5653
5677
  /** Every 1-based line number of an untracked file (it is all new). */
5654
5678
  function allLinesOf(root, file) {
5655
5679
  const full = join(root, file);
@@ -5699,6 +5723,7 @@ function parseChangedLines(diff) {
5699
5723
  function filterToChanged(findings, diff) {
5700
5724
  if (diff.degraded) return findings;
5701
5725
  return findings.filter((f) => {
5726
+ if (diff.fullyChanged?.has(f.file)) return true;
5702
5727
  const lines = diff.changed[f.file];
5703
5728
  if (!lines) return false;
5704
5729
  for (let l = Math.max(1, f.line - 3); l <= f.line; l++) if (lines.has(l)) return true;
@@ -14755,7 +14780,9 @@ function hashDir(dir, hash, depth) {
14755
14780
  else if (entry.isFile() && /\.tsx?$/.test(entry.name)) try {
14756
14781
  hash.update(entry.name);
14757
14782
  hash.update(readFileSync(full));
14758
- } catch {}
14783
+ } catch {
14784
+ recordDegradation("rules-tree-file-unreadable");
14785
+ }
14759
14786
  }
14760
14787
  }
14761
14788
  /**
@@ -16363,13 +16390,15 @@ function runForensics(target, options = {}) {
16363
16390
  report.incompleteReasons = [.../* @__PURE__ */ new Set([...report.incompleteReasons, ...incompleteReasons])];
16364
16391
  }
16365
16392
  let flakyMdPath;
16393
+ let flakyMdWriteError;
16366
16394
  if ((options.writeFlakyMd ?? true) && report.totalTests > 0) {
16367
16395
  const base = stat.isFile() ? dirname(target) : target;
16368
16396
  flakyMdPath = join(base, "FLAKY.md");
16369
16397
  try {
16370
16398
  writeFileAtomic(flakyMdPath, renderFlakyMd(report));
16371
- } catch {
16399
+ } catch (e) {
16372
16400
  flakyMdPath = void 0;
16401
+ flakyMdWriteError = e instanceof Error ? e.message : String(e);
16373
16402
  }
16374
16403
  }
16375
16404
  return {
@@ -16377,7 +16406,7 @@ function runForensics(target, options = {}) {
16377
16406
  output: [
16378
16407
  renderLeaderboard(report),
16379
16408
  "",
16380
- flakyMdPath !== void 0 ? renderFlakyMdHint() : report.totalTests === 0 && (report.totalNetworkObservations ?? 0) > 0 && options.writeFlakyMd !== false ? "FLAKY.md was not written — network observations do not establish test outcomes." : renderFlakyMdNotWritten(options)
16409
+ flakyMdPath !== void 0 ? renderFlakyMdHint() : report.totalTests === 0 && (report.totalNetworkObservations ?? 0) > 0 && options.writeFlakyMd !== false ? "FLAKY.md was not written — network observations do not establish test outcomes." : renderFlakyMdNotWritten(options, flakyMdWriteError)
16381
16410
  ].join("\n"),
16382
16411
  flakyMdPath
16383
16412
  };
@@ -16386,9 +16415,10 @@ function renderFlakyMdHint() {
16386
16415
  return "Full details in FLAKY.md (committed artifact).";
16387
16416
  }
16388
16417
  /** Honest fallback for every case where FLAKY.md was NOT written (M2). */
16389
- function renderFlakyMdNotWritten(options) {
16418
+ function renderFlakyMdNotWritten(options, writeError) {
16390
16419
  if (options.writeFlakyMd === false) return "FLAKY.md not written (--no-flaky-md).";
16391
- return "FLAKY.md was not written — nothing recognized to report (or the target directory is not writable).";
16420
+ if (writeError !== void 0) return `FLAKY.md could not be written: ${writeError}`;
16421
+ return "FLAKY.md was not written — nothing recognized to report.";
16392
16422
  }
16393
16423
  /**
16394
16424
  * R5: the trace artifact's test identity. The per-test trace convention
@@ -17012,8 +17042,10 @@ function renderGateNotice(skipped) {
17012
17042
  * module moved. Re-exports in cli.ts keep the historical import surface.
17013
17043
  */
17014
17044
  var scan_pipeline_exports = /* @__PURE__ */ __exportAll({
17045
+ DEFAULT_MAX_DURATION_MS: () => DEFAULT_MAX_DURATION_MS$1,
17015
17046
  EVIDENCE_OVERRIDES: () => EVIDENCE_OVERRIDES$1,
17016
17047
  KNOWN_RULE_IDS: () => KNOWN_RULE_IDS$1,
17048
+ MAX_DURATION_MS: () => MAX_DURATION_MS$1,
17017
17049
  OVERLAP_META_BY_RULE_ID: () => OVERLAP_META_BY_RULE_ID$1,
17018
17050
  SUITE_INVALIDATING_RULE_IDS: () => SUITE_INVALIDATING_RULE_IDS$1,
17019
17051
  applyPostScanProcessing: () => applyPostScanProcessing,
@@ -17030,8 +17062,13 @@ var scan_pipeline_exports = /* @__PURE__ */ __exportAll({
17030
17062
  summarizeForensicVerdicts: () => summarizeForensicVerdicts
17031
17063
  });
17032
17064
  const UNIVERSAL_RULES = RULES.map(asUniversal);
17033
- const DEFAULT_MAX_DURATION_MS$2 = 6e5;
17034
- const MAX_DURATION_MS$2 = 36e5;
17065
+ /**
17066
+ * The scan time budget: the default, and the ceiling every surface enforces.
17067
+ * Owned here, where the clamp is applied; `cli.ts` and the MCP server import
17068
+ * these rather than keeping copies that only agree by coincidence.
17069
+ */
17070
+ const DEFAULT_MAX_DURATION_MS$1 = 6e5;
17071
+ const MAX_DURATION_MS$1 = 36e5;
17035
17072
  /** Registered rule IDs — used to warn on unknown severityOverrides keys (M4). */
17036
17073
  const KNOWN_RULE_IDS$1 = new Set(RULES.map((r) => r.id));
17037
17074
  /**
@@ -17786,8 +17823,8 @@ async function runScan$1(args, hooks = {}) {
17786
17823
  async function runScanInProject(args, hooks = {}) {
17787
17824
  const started = Date.now();
17788
17825
  const degradationWindow = beginDegradationWindow();
17789
- const requestedDuration = Number.isFinite(args.maxDurationMs) ? args.maxDurationMs : DEFAULT_MAX_DURATION_MS$2;
17790
- const deadline = started + Math.min(Math.max(1, requestedDuration), MAX_DURATION_MS$2);
17826
+ const requestedDuration = Number.isFinite(args.maxDurationMs) ? args.maxDurationMs : DEFAULT_MAX_DURATION_MS$1;
17827
+ const deadline = started + Math.min(Math.max(1, requestedDuration), MAX_DURATION_MS$1);
17791
17828
  const discovered = discoverWorkspace(args.target);
17792
17829
  const targetAbs = resolve(args.target);
17793
17830
  const scanRoot = discovered && discovered.root !== targetAbs && targetAbs.startsWith(discovered.root + sep) ? {
@@ -18632,10 +18669,14 @@ function isKnownTemplate(content) {
18632
18669
  /**
18633
18670
  * A generated workflow with the parts that legitimately change between
18634
18671
  * releases blanked: line endings, the pinned package version (in the npx
18635
- * tarball and in the Action's `version:` input) and the pinned Action SHA.
18672
+ * tarball and in the Action's `version:` input) and every pinned action SHA
18673
+ * with its version comment. Dependabot moves the `actions/*` pins between
18674
+ * releases, so blanking only the Mjölnir Action's SHA made each of those bumps
18675
+ * turn every workflow the previous release generated into one that reads as
18676
+ * hand-customised.
18636
18677
  */
18637
18678
  function canonicalWorkflow(text) {
18638
- return text.replace(/\r\n/g, "\n").replace(/mjolnir-qa-[0-9A-Za-z.+-]+?\.tgz/g, "mjolnir-qa-<version>.tgz").replace(/(\n {10}version: )"[^"\n]*"/g, "$1\"<version>\"").replace(/Sergey-Bar\/Mjolnir@[0-9a-f]{40}/g, "Sergey-Bar/Mjolnir@<sha>");
18679
+ return text.replace(/\r\n/g, "\n").replace(/mjolnir-qa-[0-9A-Za-z.+-]+?\.tgz/g, "mjolnir-qa-<version>.tgz").replace(/(\n {10}version: )"[^"\n]*"/g, "$1\"<version>\"").replace(/(uses: [^\s@]+)@[0-9a-f]{40}[^\n]*/g, "$1@<sha>");
18639
18680
  }
18640
18681
  /**
18641
18682
  * The action template through 6.0.0, which passed no `version:` and so ran
@@ -18699,7 +18740,7 @@ jobs:
18699
18740
  if: always()
18700
18741
  continue-on-error: true
18701
18742
  run: npx --yes https://registry.npmjs.org/mjolnir-qa/-/mjolnir-qa-${INSTALL_VERSION}.tgz mjolnir.json --format pr-comment > mjolnir-comment.md
18702
- - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
18743
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
18703
18744
  if: always()
18704
18745
  with:
18705
18746
  name: mjolnir-report
@@ -18724,7 +18765,7 @@ ${indentBlock(gateScript(gate), 10)}
18724
18765
  contents: read
18725
18766
  pull-requests: write
18726
18767
  steps:
18727
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
18768
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
18728
18769
  with:
18729
18770
  name: mjolnir-report
18730
18771
  - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
@@ -18846,7 +18887,7 @@ jobs:
18846
18887
  pr-comment: "false"
18847
18888
  trust-artifact: "false"
18848
18889
  annotations: "true"
18849
- - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
18890
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
18850
18891
  if: always()
18851
18892
  with:
18852
18893
  name: mjolnir-report
@@ -18877,7 +18918,7 @@ jobs:
18877
18918
  contents: read
18878
18919
  pull-requests: write
18879
18920
  steps:
18880
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
18921
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
18881
18922
  with:
18882
18923
  name: mjolnir-report
18883
18924
  - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
@@ -20013,8 +20054,6 @@ function renderPwRunSummary(s) {
20013
20054
  */
20014
20055
  /** Hard caps (§21 threat model: parameter size + resource bounds). */
20015
20056
  const MAX_PARAM_BYTES = 65536;
20016
- const MAX_DURATION_MS$1 = 36e5;
20017
- const DEFAULT_MAX_DURATION_MS$1 = 6e5;
20018
20057
  const PROTOCOL_VERSION = "2025-06-18";
20019
20058
  /** The real path of `p`, or its lexical resolution when it does not exist. */
20020
20059
  function realOrResolved(p) {
@@ -20078,7 +20117,7 @@ const MCP_TOOLS = [
20078
20117
  },
20079
20118
  fixturesRoot: {
20080
20119
  type: "string",
20081
- description: "Root directory for fixture lookups (defaults to cwd)."
20120
+ description: "Root directory for fixture lookups (defaults to <cwd>/tests/fixtures, as for `mjolnir explain`)."
20082
20121
  }
20083
20122
  },
20084
20123
  required: ["ruleId"]
@@ -20158,7 +20197,7 @@ function validateParams(name, args) {
20158
20197
  if (typeof args["path"] !== "string" || args["path"].length === 0) return "scan requires a non-empty string `path`";
20159
20198
  if (args["maxDurationMs"] !== void 0) {
20160
20199
  const duration = args["maxDurationMs"];
20161
- if (typeof duration !== "number" || !Number.isFinite(duration) || duration <= 0 || duration > MAX_DURATION_MS$1) return "`maxDurationMs` must be a finite positive number within the server budget";
20200
+ if (typeof duration !== "number" || !Number.isFinite(duration) || duration <= 0 || duration > 36e5) return "`maxDurationMs` must be a finite positive number within the server budget";
20162
20201
  }
20163
20202
  }
20164
20203
  if (name === "explain") {
@@ -20262,7 +20301,7 @@ async function handleToolCall(call) {
20262
20301
  message: "fixturesRoot must be a string"
20263
20302
  }
20264
20303
  };
20265
- const explanation = explainRule(ruleId, (typeof rawFixturesRoot === "string" ? rawFixturesRoot : void 0) ?? process.cwd());
20304
+ const explanation = explainRule(ruleId, (typeof rawFixturesRoot === "string" ? rawFixturesRoot : void 0) ?? join(process.cwd(), "tests", "fixtures"));
20266
20305
  if (!explanation.ok) return {
20267
20306
  jsonrpc: "2.0",
20268
20307
  id: call.id,
@@ -22252,6 +22291,24 @@ const MAX_FINDINGS_PER_GROUP = 25;
22252
22291
  function escMd(s) {
22253
22292
  return sanitizeData(s).replace(/([\\`*_{}[\]()#+!|<>])/g, "\\$1");
22254
22293
  }
22294
+ /**
22295
+ * A Markdown code span for untrusted text.
22296
+ *
22297
+ * Backslash escapes are literal inside a code span, so `escMd` there rendered
22298
+ * `__snapshots__` as `\_\_snapshots\_\_` and `app/(auth)` as `app/\(auth\)`.
22299
+ * A span cannot be broken out of with formatting characters, only with a
22300
+ * backtick run, so it is fenced with one backtick more than the longest run
22301
+ * inside (CommonMark 6.1) and padded where the text would otherwise touch the
22302
+ * fence. Kept to one line, because a newline would end the list item.
22303
+ */
22304
+ function mdCodeSpan(raw) {
22305
+ const text = sanitizeData(raw).replace(/[\r\n]+/g, " ");
22306
+ if (text.trim() === "") return "` `";
22307
+ const longest = Math.max(0, ...[...text.matchAll(/`+/g)].map((m) => m[0].length));
22308
+ const fence = "`".repeat(longest + 1);
22309
+ const pad = text.startsWith("`") || text.endsWith("`") || text.startsWith(" ") && text.endsWith(" ") ? " " : "";
22310
+ return `${fence}${pad}${text}${pad}${fence}`;
22311
+ }
22255
22312
  function looksLikeCode(s) {
22256
22313
  return /[(;={]|await |expect\(/.test(s);
22257
22314
  }
@@ -22266,10 +22323,8 @@ function evidenceTagLine(f) {
22266
22323
  }
22267
22324
  function findingLineMd(f) {
22268
22325
  const icon = f.severity === "error" ? "🔴" : f.severity === "warning" ? "🟡" : "🔵";
22269
- const fix = escMd(f.fix);
22270
- const fixBody = looksLikeCode(f.fix) ? `\`${fix}\`` : `_${fix}_`;
22271
- const safePath = escMd(f.file).replace(/[\r\n]+/g, " ");
22272
- return `${icon} **${escMd(f.ruleId)}** \`${safePath}:${f.line}\` — ${escMd(f.message)} ${escMd(`[${evidenceTagLine(f)}]`)}\n Fix: ${fixBody}`;
22326
+ const fixBody = looksLikeCode(f.fix) ? mdCodeSpan(f.fix) : `_${escMd(f.fix)}_`;
22327
+ return `${icon} **${escMd(f.ruleId)}** ${mdCodeSpan(`${f.file}:${f.line}`)} — ${escMd(f.message)} ${escMd(`[${evidenceTagLine(f)}]`)}\n Fix: ${fixBody}`;
22273
22328
  }
22274
22329
  /**
22275
22330
  * Reader-facing sentences for the `analysisStatus.reasons` tokens.
@@ -22300,7 +22355,8 @@ const REASON_PHRASES = [
22300
22355
  ["runtime-incomplete", "the runtime evidence set is incomplete"],
22301
22356
  ["identity-incomplete", "the run could not be bound to the tree it analysed"],
22302
22357
  ["degraded:", "capability was lost to a swallowed error"],
22303
- ["truncated:", "the scan was cut short"]
22358
+ ["truncated:", "the scan was cut short"],
22359
+ ["coverage:quarantine:", "quarantine-tier rule(s) withheld, as in every default scan (`--include-warn` runs them)"]
22304
22360
  ];
22305
22361
  /**
22306
22362
  * `scope-ignored:1` -> "1 path was ignored by the scope filter".
@@ -22447,7 +22503,9 @@ function renderUnifiedReport(result, options = {}) {
22447
22503
  provisionalRuleIds: [],
22448
22504
  ceilingReasons: []
22449
22505
  };
22450
- const incomplete = isIncompleteResult(result) || !result.trustSummary || summary.level === "L0" && summary.evidenceCoverage === 0;
22506
+ const analysisIncomplete = isIncompleteResult(result) || !result.trustSummary;
22507
+ const noEvidence = summary.level === "L0" && summary.evidenceCoverage === 0;
22508
+ const incomplete = analysisIncomplete || noEvidence;
22451
22509
  lines.push(`## <img width="16" height="16" alt="" src="${MARK_URL}"/> Mjölnir Verification Report`);
22452
22510
  lines.push("");
22453
22511
  lines.push("> Tests tell you what passed. Mjölnir tells you what you can trust.");
@@ -22458,8 +22516,9 @@ function renderUnifiedReport(result, options = {}) {
22458
22516
  }
22459
22517
  if (incomplete) {
22460
22518
  const reasons = result.analysisStatus?.reasons ?? [];
22519
+ const explained = [...reasons.length > 0 ? [humanizeReasons(reasons)] : [], ...!analysisIncomplete && noEvidence ? ["no runtime evidence corroborates this run, so an empty result is an observation, not a verdict"] : []];
22461
22520
  lines.push(`> [!WARNING]
22462
- > **Analysis status: INCOMPLETE.** ${reasons.length > 0 ? humanizeReasons(reasons) : "The scan did not complete a trustworthy analysis."}`);
22521
+ > **Analysis status: INCOMPLETE.** ${explained.length > 0 ? explained.join("; ") : "The scan did not complete a trustworthy analysis."}`);
22463
22522
  lines.push("");
22464
22523
  }
22465
22524
  const diff = options.diff;
@@ -23173,7 +23232,7 @@ function renderStepSummary(result, options = {}) {
23173
23232
  lines.push(`<details${open}>`, `<summary>${icons[sev]} ${list.length} ${sev}${list.length === 1 ? "" : "s"}</summary>`, "");
23174
23233
  for (const f of list.slice(0, DETAILS_PER_SEVERITY_CAP)) {
23175
23234
  const path = options.pathPrefix ? `${options.pathPrefix.replace(/\/$/, "")}/${f.file}` : f.file;
23176
- lines.push(`- **${escapeMarkdown(f.ruleId)}** \`${escapeMarkdown(path)}:${f.line}\` — ${escapeMarkdown(stripAnsiForSummary(f.message))}`);
23235
+ lines.push(`- **${escapeMarkdown(f.ruleId)}** ${mdCodeSpan(`${path}:${f.line}`)} — ${escapeMarkdown(stripAnsiForSummary(f.message))}`);
23177
23236
  lines.push(` - Fix: ${escapeMarkdown(stripAnsiForSummary(f.fix))}`);
23178
23237
  }
23179
23238
  if (list.length > DETAILS_PER_SEVERITY_CAP) lines.push(`- … and ${list.length - DETAILS_PER_SEVERITY_CAP} more — see the full JSON artifact.`);
@@ -28218,6 +28277,19 @@ function verbFailure(error, io, debug) {
28218
28277
  return 20;
28219
28278
  }
28220
28279
  /**
28280
+ * The notices a scan reports about itself, sent to the verb's own stderr.
28281
+ *
28282
+ * `fix`, `ci verify` and `handover` called `runScan` with no hooks, so a
28283
+ * config warning (an unknown key, an expired suppression) never reached the
28284
+ * user on those verbs, and the plugin gate notice bypassed `io.err`.
28285
+ */
28286
+ function scanNotices(io) {
28287
+ return {
28288
+ onConfigWarning: (message) => io.err(message),
28289
+ onGateNotice: (notice) => io.err(notice)
28290
+ };
28291
+ }
28292
+ /**
28221
28293
  * The one place a run's gate level is resolved from `--blocking`, then
28222
28294
  * `mjolnir.config.json`, then the shipped default.
28223
28295
  *
@@ -28441,9 +28513,8 @@ async function runScanCommand(argv, io = {
28441
28513
  ...args,
28442
28514
  target
28443
28515
  }, {
28444
- onConfigWarning: (message) => io.err(message),
28516
+ ...scanNotices(io),
28445
28517
  onProgress: (e) => progress.onEvent(e),
28446
- onGateNotice: (notice) => io.err(notice),
28447
28518
  ...args.debug ? { onRuleCrash: (ruleId, file, error) => {
28448
28519
  const detail = error instanceof Error ? [error.message, error.stack].filter((value) => Boolean(value)).join("\n") : String(error);
28449
28520
  crashLog.push(`${ruleId} crashed on ${file}: ${detail}`);
@@ -28469,7 +28540,7 @@ async function runScanCommand(argv, io = {
28469
28540
  for (const line of crashLog.slice(0, 50)) io.err(` ${line}`);
28470
28541
  if (crashLog.length > 50) io.err(` … and ${crashLog.length - 50} more`);
28471
28542
  }
28472
- const { config, warnings } = loadConfig(target, { knownRuleIds: KNOWN_RULE_IDS$1 });
28543
+ const { config } = loadConfig(target, { knownRuleIds: KNOWN_RULE_IDS$1 });
28473
28544
  for (const d of args.deprecatedFlags ?? []) io.err(`warning: ${d.flag} is deprecated and will be removed in 7.0 — use ${d.replacement}. It still does exactly what it did; nothing about your scan changes this release.`);
28474
28545
  const gate = resolveGateLevel(args.blocking, config);
28475
28546
  if (args.scoreOnly) {
@@ -28496,7 +28567,6 @@ async function runScanCommand(argv, io = {
28496
28567
  }
28497
28568
  }
28498
28569
  if (result.partial) return 2;
28499
- for (const w of warnings) io.err(w);
28500
28570
  if (baselineFailed) return 2;
28501
28571
  return scanExitCode({
28502
28572
  partial: false,
@@ -28525,7 +28595,7 @@ async function runFixCommand(argv, io = {
28525
28595
  ...args,
28526
28596
  target,
28527
28597
  strict: true
28528
- });
28598
+ }, scanNotices(io));
28529
28599
  const fixes = planAndApplyFixes(result, target, { dryRun });
28530
28600
  io.out(renderFixReport(fixes, dryRun));
28531
28601
  if (result.partial) return 2;
@@ -28547,7 +28617,7 @@ async function runVerifyCommand(argv, io = {
28547
28617
  const result = await runScan$1({
28548
28618
  ...args,
28549
28619
  target
28550
- });
28620
+ }, scanNotices(io));
28551
28621
  const baseline = loadBaseline(join(target, DEFAULT_BASELINE_PATH), (w) => io.err(w));
28552
28622
  const digest = buildVerifyDigest(result, baseline);
28553
28623
  io.out(renderVerifyDigest(digest));
@@ -28646,7 +28716,7 @@ async function runHandoverCommand(argv, io = {
28646
28716
  const result = await runScan$1({
28647
28717
  ...args,
28648
28718
  target
28649
- });
28719
+ }, scanNotices(io));
28650
28720
  let forensics = null;
28651
28721
  let forensicsFailed = false;
28652
28722
  const resultsDir = join(target, "test-results");
@@ -30394,7 +30464,7 @@ function verificationBlock(version) {
30394
30464
  /** The per-rule remediation copy block (fenced, self-contained). */
30395
30465
  function ruleCopyBlock(group, version) {
30396
30466
  const f = group.findings[0];
30397
- const occurrences = group.findings.slice(0, OCCURRENCE_CAP).map((x) => `- \`${escapeMarkdown(x.file)}:${x.line}\``).join("\n");
30467
+ const occurrences = group.findings.slice(0, OCCURRENCE_CAP).map((x) => `- ${mdCodeSpan(`${x.file}:${x.line}`)}`).join("\n");
30398
30468
  const overflow = group.findings.length > OCCURRENCE_CAP ? `\n- … and ${group.findings.length - OCCURRENCE_CAP} more — see the JSON report.` : "";
30399
30469
  return [
30400
30470
  "```text",
@@ -30520,7 +30590,7 @@ function renderHandoff(result, options = {}, version = ENGINE_VERSION) {
30520
30590
  lines.push("");
30521
30591
  lines.push(`**Occurrences (${g.findings.length}):**`);
30522
30592
  lines.push("");
30523
- for (const f of g.findings.slice(0, OCCURRENCE_CAP)) lines.push(`- \`${escapeMarkdown(f.file)}:${f.line}\` — ${escapeMarkdown(f.message)}`);
30593
+ for (const f of g.findings.slice(0, OCCURRENCE_CAP)) lines.push(`- ${mdCodeSpan(`${f.file}:${f.line}`)} — ${escapeMarkdown(f.message)}`);
30524
30594
  if (g.findings.length > OCCURRENCE_CAP) lines.push(`- … and ${g.findings.length - OCCURRENCE_CAP} more — see the JSON report.`);
30525
30595
  lines.push("");
30526
30596
  lines.push(ruleCopyBlock(g, version));
@@ -32848,9 +32918,7 @@ async function runTrustTrendCommand(argv, io = {
32848
32918
  * identity can carry it without a cli.ts import cycle; this re-export
32849
32919
  * keeps every existing consumer stable.
32850
32920
  */
32851
- const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isValidFindingRecord, discoverAndParseRuntimeReport, KNOWN_RULE_IDS, OVERLAP_META_BY_RULE_ID, EVIDENCE_OVERRIDES, SUITE_INVALIDATING_RULE_IDS } = scan_pipeline_exports;
32852
- const DEFAULT_MAX_DURATION_MS = 6e5;
32853
- const MAX_DURATION_MS = 36e5;
32921
+ const { runScan, buildUniversalRules, fallbackWorkspace, pathMatchesGlob, isValidFindingRecord, discoverAndParseRuntimeReport, KNOWN_RULE_IDS, OVERLAP_META_BY_RULE_ID, EVIDENCE_OVERRIDES, SUITE_INVALIDATING_RULE_IDS, DEFAULT_MAX_DURATION_MS, MAX_DURATION_MS } = scan_pipeline_exports;
32854
32922
  function parseArgs(argv, onError) {
32855
32923
  const args = {
32856
32924
  target: ".",
@@ -32909,7 +32977,7 @@ function parseArgs(argv, onError) {
32909
32977
  args.base = ref;
32910
32978
  } else if (a === "--max-duration") {
32911
32979
  const v = Number(argv[++i]);
32912
- if (!Number.isFinite(v) || v <= 0 || v * 1e3 > 36e5) return reject({
32980
+ if (!Number.isFinite(v) || v <= 0 || v * 1e3 > MAX_DURATION_MS) return reject({
32913
32981
  flag: "--max-duration",
32914
32982
  token: argv[i]
32915
32983
  });
@@ -32968,11 +33036,22 @@ function parseArgs(argv, onError) {
32968
33036
  }
32969
33037
  return args;
32970
33038
  }
32971
- /** Scan flags that exist — the "did you mean" candidate pool. */
33039
+ /**
33040
+ * Scan flags that exist — the "did you mean" candidate pool.
33041
+ *
33042
+ * Every flag `parseArgs` accepts, and nothing it rejects. The pool had
33043
+ * drifted both ways: ten accepted flags were missing, and `--dry-run` and
33044
+ * `--version` were in it although a scan rejects them, so `mjolnir scan
33045
+ * --dry-run` answered `unknown flag "--dry-run"` and then suggested it.
33046
+ * tests/cli/help.spec.ts reads `parseArgs` to hold the two lists together.
33047
+ */
32972
33048
  const KNOWN_SCAN_FLAGS = [
32973
33049
  "--json",
32974
33050
  "--format",
32975
33051
  "--verbose",
33052
+ "--suppressions",
33053
+ "--suppression-gate",
33054
+ "--policy",
32976
33055
  "--scope",
32977
33056
  "--base",
32978
33057
  "--max-duration",
@@ -32987,12 +33066,16 @@ const KNOWN_SCAN_FLAGS = [
32987
33066
  "--record-milestones",
32988
33067
  "--save-baseline",
32989
33068
  "--cache",
33069
+ "--no-progress",
33070
+ "--category",
33071
+ "--score",
33072
+ "--require-full-coverage",
33073
+ "--staged",
33074
+ "--blocking",
33075
+ "--enable-plugins",
32990
33076
  "--monorepo",
32991
33077
  "--help",
32992
- "-h",
32993
- "--version",
32994
- "-v",
32995
- "--dry-run"
33078
+ "-h"
32996
33079
  ];
32997
33080
  /** Hand-rolled Levenshtein distance (plan M2: no new dependencies). */
32998
33081
  function levenshtein(a, b) {
@@ -33249,13 +33332,8 @@ async function main(argv = process$1.argv.slice(2), io = {
33249
33332
  io.out(renderCiSubcommandHelp());
33250
33333
  return 10;
33251
33334
  }
33252
- if (argv[0] === "ci" && argv[1] === "install") return runCiInstall(argv.slice(2), io);
33253
33335
  if (argv[0] === "ci") {
33254
33336
  const sub = argv[1];
33255
- if (sub === void 0 || sub === "--help" || sub === "-h") {
33256
- io.out(renderCiSubcommandHelp());
33257
- return sub === void 0 ? 10 : 0;
33258
- }
33259
33337
  const entry = CI_SUBCOMMANDS[sub];
33260
33338
  if (entry === void 0) {
33261
33339
  io.err(`mjolnir ci: unknown subcommand "${sub}".`);
@@ -33347,6 +33425,6 @@ if (isEntryPoint()) {
33347
33425
  await flushSentry();
33348
33426
  }
33349
33427
  }
33350
- export { ENGINE_VERSION as CLI_VERSION, DEFAULT_MAX_DURATION_MS, EVIDENCE_OVERRIDES, EXPLAIN_ARMS, KNOWN_RULE_IDS, MAX_DURATION_MS, OVERLAP_META_BY_RULE_ID, SUITE_INVALIDATING_RULE_IDS, buildUniversalRules, discoverAndParseRuntimeReport, err, exitForFindings, fallbackWorkspace, internalErrorMessage, isEntryPoint, isValidFindingRecord, levenshtein, main, nearestFlags, out, parseArgs, parseArgsOrUsage, pathMatchesGlob, printUsage, renderCiSubcommandHelp, renderScanOutput, runAnalyzeCommand, runCIIntegrityCommand, runCiAdapterCommand, runCiInstall, runContractVerifyCommand, runCrossFileCommand, runDoctorCommand, runDoctorPlaywright, runEvidenceArm, runEvidenceGraphCommand, runExplainCommand, runFixCommand, runFrameworkMaturityCommand, runHandoffCommand, runHandoverCommand, runHelpCommand, runInstallCommand, runPolicyCommand, runPrCommentCommand, runPwReportCommand, runReleaseTrustCommand, runRulesCommand, runScan, runScanCommand, runStatsCommand, runSuppressionGateCommand, runSuppressions, runTrustTrendCommand, runVerifyCommand, runWhyCommand, usageErrorMessage, validateScanTarget };
33428
+ export { ENGINE_VERSION as CLI_VERSION, DEFAULT_MAX_DURATION_MS, EVIDENCE_OVERRIDES, EXPLAIN_ARMS, KNOWN_RULE_IDS, KNOWN_SCAN_FLAGS, MAX_DURATION_MS, OVERLAP_META_BY_RULE_ID, SUITE_INVALIDATING_RULE_IDS, buildUniversalRules, discoverAndParseRuntimeReport, err, exitForFindings, fallbackWorkspace, internalErrorMessage, isEntryPoint, isValidFindingRecord, levenshtein, main, nearestFlags, out, parseArgs, parseArgsOrUsage, pathMatchesGlob, printUsage, renderCiSubcommandHelp, renderScanOutput, runAnalyzeCommand, runCIIntegrityCommand, runCiAdapterCommand, runCiInstall, runContractVerifyCommand, runCrossFileCommand, runDoctorCommand, runDoctorPlaywright, runEvidenceArm, runEvidenceGraphCommand, runExplainCommand, runFixCommand, runFrameworkMaturityCommand, runHandoffCommand, runHandoverCommand, runHelpCommand, runInstallCommand, runPolicyCommand, runPrCommentCommand, runPwReportCommand, runReleaseTrustCommand, runRulesCommand, runScan, runScanCommand, runStatsCommand, runSuppressionGateCommand, runSuppressions, runTrustTrendCommand, runVerifyCommand, runWhyCommand, usageErrorMessage, validateScanTarget };
33351
33429
 
33352
33430
  //# sourceMappingURL=cli.mjs.map
@@ -193,7 +193,8 @@ function stampRuntimeCorroboration(findings, report, scanRoot) {
193
193
  let n = p.replace(/\\/g, "/");
194
194
  if (scanRoot) {
195
195
  const root = scanRoot.replace(/\\/g, "/").replace(/\/?$/, "/");
196
- if (n.startsWith(root)) n = n.slice(root.length);
196
+ const head = n.slice(0, root.length);
197
+ if (/^[a-z]:\//i.test(root) ? head.toLowerCase() === root.toLowerCase() : head === root) n = n.slice(root.length);
197
198
  }
198
199
  return n.replace(/^\.\//, "").replace(/^\/+/, "");
199
200
  };
@@ -1423,7 +1424,7 @@ function readCandidateBinding(root, manifestPath = CANDIDATE_MANIFEST_PATH) {
1423
1424
  * guarded by the version-consistency
1424
1425
  * spec. cli.ts re-exports this as CLI_VERSION.
1425
1426
  */
1426
- const ENGINE_VERSION = "6.0.1";
1427
+ const ENGINE_VERSION = "6.1.0";
1427
1428
  resolveBuildId();
1428
1429
  /**
1429
1430
  * The decision, with the process boundary already behind `runGit`.
@@ -1729,7 +1730,11 @@ function validateConfigSchema(config) {
1729
1730
  }
1730
1731
  }
1731
1732
  if (cfg["plugins"] !== void 0) {
1732
- if (cfg["plugins"] === null || typeof cfg["plugins"] !== "object") errors.push("plugins must be an object or array");
1733
+ if (!Array.isArray(cfg["plugins"])) errors.push(`plugins must be an array of package names or { "package": "<name>" } objects, got ${typeof cfg["plugins"]}`);
1734
+ else for (let i = 0; i < cfg["plugins"].length; i++) {
1735
+ const entry = cfg["plugins"][i];
1736
+ if (!(typeof entry === "string" || entry !== null && typeof entry === "object" && !Array.isArray(entry) && typeof entry["package"] === "string")) errors.push(`plugins[${i}] must be a package name or { "package": "<name>" }, got ${JSON.stringify(entry)}`);
1737
+ }
1733
1738
  }
1734
1739
  return {
1735
1740
  valid: errors.length === 0,
@@ -1794,7 +1799,7 @@ function loadConfig(root, options = {}) {
1794
1799
  return {
1795
1800
  config: parsed,
1796
1801
  path: p,
1797
- warnings: validate(parsed, options.knownRuleIds)
1802
+ warnings: validate(parsed, options.knownRuleIds, options.now, name)
1798
1803
  };
1799
1804
  } catch (err) {
1800
1805
  const msg = err.message;
@@ -1807,7 +1812,7 @@ function loadConfig(root, options = {}) {
1807
1812
  warnings: []
1808
1813
  };
1809
1814
  }
1810
- function validate(cfg, knownRuleIds) {
1815
+ function validate(cfg, knownRuleIds, now = /* @__PURE__ */ new Date(), fileName = "mjolnir.config.json") {
1811
1816
  const warnings = [];
1812
1817
  const schemaResult = validateConfigSchema(cfg);
1813
1818
  if (!schemaResult.valid) throw new Error(schemaResult.errors.join("; "));
@@ -1827,10 +1832,10 @@ function validate(cfg, knownRuleIds) {
1827
1832
  if (!Array.isArray(ign.files)) throw new Error(`ignore for ${ign.ruleId}: "files" must be an array of glob strings, got ${typeof ign.files}`);
1828
1833
  for (const g of ign.files) if (typeof g !== "string") throw new Error(`ignore for ${ign.ruleId}: "files" entries must be strings, got ${typeof g} (${JSON.stringify(g)})`);
1829
1834
  }
1830
- if (ign.expires !== void 0 && Number.isNaN(new Date(ign.expires).getTime())) throw new Error(`ignore for ${ign.ruleId}: "expires" must be an ISO date, got "${ign.expires}"`);
1831
- if (ign.expires !== void 0 && !Number.isNaN(new Date(ign.expires).getTime()) && new Date(ign.expires).getTime() < Date.now()) warnings.push(`warning: ignore for ${ign.ruleId} has expired (expires "${ign.expires}") — the entry is kept but currently inactive.`);
1835
+ if (ign.expires !== void 0 && Number.isNaN(utcMidnight(ign.expires))) throw new Error(`ignore for ${ign.ruleId}: "expires" must be an ISO date (YYYY-MM-DD), got "${ign.expires}"`);
1836
+ if (ign.expires !== void 0 && !isSuppressionActive(ign, now)) warnings.push(`warning: ignore for ${ign.ruleId} has expired (expires "${ign.expires}") — the entry is kept but currently inactive.`);
1832
1837
  }
1833
- for (const key of Object.keys(cfg)) if (!KNOWN_CONFIG_KEYS.has(key)) warnings.push(`warning: mjolnir.config.json has an unknown top-level key "${key}" — ignored (typo? see README §Configuration).`);
1838
+ for (const key of Object.keys(cfg)) if (!KNOWN_CONFIG_KEYS.has(key)) warnings.push(`warning: ${fileName} has an unknown top-level key "${key}" — ignored (typo? see README §Configuration).`);
1834
1839
  if (cfg.exclude !== void 0) {
1835
1840
  if (!Array.isArray(cfg.exclude)) throw new Error(`exclude must be an array of strings, got ${typeof cfg.exclude}`);
1836
1841
  for (const p of cfg.exclude) if (typeof p !== "string") throw new Error(`exclude entries must be strings, got ${typeof p} (${JSON.stringify(p)})`);
@@ -1852,12 +1857,22 @@ function isSuppressionActive(ign, now = /* @__PURE__ */ new Date()) {
1852
1857
  if (!ign.expires) return true;
1853
1858
  return utcMidnight(ign.expires) > utcMidnightOf(now);
1854
1859
  }
1855
- /** UTC-midnight timestamp of an ISO date string (YYYY-MM-DD). */
1860
+ /**
1861
+ * UTC-midnight timestamp of an ISO calendar date (YYYY-MM-DD), or NaN.
1862
+ *
1863
+ * NaN for anything that is not a real calendar day, so a load-time check and
1864
+ * the active/expired verdict share one parser and cannot disagree.
1865
+ */
1856
1866
  function utcMidnight(isoDate) {
1857
- if (!/^\d{4}-\d{2}-\d{2}$/.test(isoDate)) return NaN;
1858
1867
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
1859
1868
  if (!m) return NaN;
1860
- return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
1869
+ const year = Number(m[1]);
1870
+ const month = Number(m[2]) - 1;
1871
+ const day = Number(m[3]);
1872
+ const t = Date.UTC(year, month, day);
1873
+ const back = new Date(t);
1874
+ if (back.getUTCFullYear() !== year || back.getUTCMonth() !== month || back.getUTCDate() !== day) return NaN;
1875
+ return t;
1861
1876
  }
1862
1877
  /** UTC-midnight timestamp of a Date (calendar day, timezone-independent). */
1863
1878
  function utcMidnightOf(d) {
@@ -3580,7 +3595,9 @@ const javaAdapter = {
3580
3595
  const artifact = /<artifactId>([^<]+)<\/artifactId>/.exec(block[0])?.[1] ?? "";
3581
3596
  for (const { re, tag } of JAVA_ARTIFACT_TAGS) if (re.test(artifact)) frameworks.add(tag);
3582
3597
  }
3583
- } catch {}
3598
+ } catch {
3599
+ recordDegradation("java-manifest-unreadable");
3600
+ }
3584
3601
  for (const gradle of ["build.gradle", "build.gradle.kts"]) {
3585
3602
  const path = join(root, gradle);
3586
3603
  if (!existsSync(path)) continue;
@@ -3590,7 +3607,9 @@ const javaAdapter = {
3590
3607
  const artifact = m[1].split(":")[1];
3591
3608
  for (const { re, tag } of JAVA_ARTIFACT_TAGS) if (artifact !== void 0 && re.test(artifact)) frameworks.add(tag);
3592
3609
  }
3593
- } catch {}
3610
+ } catch {
3611
+ recordDegradation("java-manifest-unreadable");
3612
+ }
3594
3613
  }
3595
3614
  if (frameworks.size === 0) return {
3596
3615
  frameworks: [],
@@ -3748,9 +3767,13 @@ const csharpAdapter = {
3748
3767
  const pkg = m[1];
3749
3768
  for (const { re, tag } of CS_PACKAGE_TAGS) if (re.test(pkg)) frameworks.add(tag);
3750
3769
  }
3751
- } catch {}
3770
+ } catch {
3771
+ recordDegradation("csharp-project-unreadable");
3772
+ }
3752
3773
  }
3753
- } catch {}
3774
+ } catch {
3775
+ recordDegradation("csharp-project-unreadable");
3776
+ }
3754
3777
  if (frameworks.size === 0) return {
3755
3778
  frameworks: [],
3756
3779
  unknown: true
@@ -4664,6 +4687,7 @@ function computeChangedScope(root, baseBranch) {
4664
4687
  ...untrackedSet
4665
4688
  ])].sort();
4666
4689
  const changed = {};
4690
+ const fullyChanged = /* @__PURE__ */ new Set();
4667
4691
  const batchDiff = (args, files) => {
4668
4692
  const perFile = /* @__PURE__ */ new Map();
4669
4693
  if (files.length === 0) return perFile;
@@ -4715,7 +4739,8 @@ function computeChangedScope(root, baseBranch) {
4715
4739
  if (untrackedSet.has(file)) {
4716
4740
  const all = allLinesOf(root, file);
4717
4741
  if (all === null) {
4718
- changed[file] = new Set(Array.from({ length: LIMITS_MAX_LINES }, (_, i) => i + 1));
4742
+ fullyChanged.add(file);
4743
+ changed[file] = lines;
4719
4744
  continue;
4720
4745
  }
4721
4746
  for (const l of all) lines.add(l);
@@ -4724,11 +4749,10 @@ function computeChangedScope(root, baseBranch) {
4724
4749
  }
4725
4750
  return {
4726
4751
  changed,
4752
+ ...fullyChanged.size > 0 ? { fullyChanged } : {},
4727
4753
  degraded: false
4728
4754
  };
4729
4755
  }
4730
- /** Sentinel size for "fully changed" — bounded, mirrors LIMITS.maxFileBytes scale. */
4731
- const LIMITS_MAX_LINES = 1e6;
4732
4756
  /** Every 1-based line number of an untracked file (it is all new). */
4733
4757
  function allLinesOf(root, file) {
4734
4758
  const full = join(root, file);
@@ -4778,6 +4802,7 @@ function parseChangedLines(diff) {
4778
4802
  function filterToChanged(findings, diff) {
4779
4803
  if (diff.degraded) return findings;
4780
4804
  return findings.filter((f) => {
4805
+ if (diff.fullyChanged?.has(f.file)) return true;
4781
4806
  const lines = diff.changed[f.file];
4782
4807
  if (!lines) return false;
4783
4808
  for (let l = Math.max(1, f.line - 3); l <= f.line; l++) if (lines.has(l)) return true;
@@ -13468,7 +13493,9 @@ function hashDir(dir, hash, depth) {
13468
13493
  else if (entry.isFile() && /\.tsx?$/.test(entry.name)) try {
13469
13494
  hash.update(entry.name);
13470
13495
  hash.update(readFileSync(full));
13471
- } catch {}
13496
+ } catch {
13497
+ recordDegradation("rules-tree-file-unreadable");
13498
+ }
13472
13499
  }
13473
13500
  }
13474
13501
  /**
@@ -14903,13 +14930,15 @@ function runForensics(target, options = {}) {
14903
14930
  report.incompleteReasons = [.../* @__PURE__ */ new Set([...report.incompleteReasons, ...incompleteReasons])];
14904
14931
  }
14905
14932
  let flakyMdPath;
14933
+ let flakyMdWriteError;
14906
14934
  if ((options.writeFlakyMd ?? true) && report.totalTests > 0) {
14907
14935
  const base = stat.isFile() ? dirname(target) : target;
14908
14936
  flakyMdPath = join(base, "FLAKY.md");
14909
14937
  try {
14910
14938
  writeFileAtomic(flakyMdPath, renderFlakyMd(report));
14911
- } catch {
14939
+ } catch (e) {
14912
14940
  flakyMdPath = void 0;
14941
+ flakyMdWriteError = e instanceof Error ? e.message : String(e);
14913
14942
  }
14914
14943
  }
14915
14944
  return {
@@ -14917,7 +14946,7 @@ function runForensics(target, options = {}) {
14917
14946
  output: [
14918
14947
  renderLeaderboard(report),
14919
14948
  "",
14920
- flakyMdPath !== void 0 ? renderFlakyMdHint() : report.totalTests === 0 && (report.totalNetworkObservations ?? 0) > 0 && options.writeFlakyMd !== false ? "FLAKY.md was not written — network observations do not establish test outcomes." : renderFlakyMdNotWritten(options)
14949
+ flakyMdPath !== void 0 ? renderFlakyMdHint() : report.totalTests === 0 && (report.totalNetworkObservations ?? 0) > 0 && options.writeFlakyMd !== false ? "FLAKY.md was not written — network observations do not establish test outcomes." : renderFlakyMdNotWritten(options, flakyMdWriteError)
14921
14950
  ].join("\n"),
14922
14951
  flakyMdPath
14923
14952
  };
@@ -14926,9 +14955,10 @@ function renderFlakyMdHint() {
14926
14955
  return "Full details in FLAKY.md (committed artifact).";
14927
14956
  }
14928
14957
  /** Honest fallback for every case where FLAKY.md was NOT written (M2). */
14929
- function renderFlakyMdNotWritten(options) {
14958
+ function renderFlakyMdNotWritten(options, writeError) {
14930
14959
  if (options.writeFlakyMd === false) return "FLAKY.md not written (--no-flaky-md).";
14931
- return "FLAKY.md was not written — nothing recognized to report (or the target directory is not writable).";
14960
+ if (writeError !== void 0) return `FLAKY.md could not be written: ${writeError}`;
14961
+ return "FLAKY.md was not written — nothing recognized to report.";
14932
14962
  }
14933
14963
  /**
14934
14964
  * R5: the trace artifact's test identity. The per-test trace convention
@@ -15529,8 +15559,13 @@ function renderGateNotice(skipped) {
15529
15559
  * module moved. Re-exports in cli.ts keep the historical import surface.
15530
15560
  */
15531
15561
  const UNIVERSAL_RULES = RULES.map(asUniversal);
15532
- const DEFAULT_MAX_DURATION_MS$1 = 6e5;
15533
- const MAX_DURATION_MS$1 = 36e5;
15562
+ /**
15563
+ * The scan time budget: the default, and the ceiling every surface enforces.
15564
+ * Owned here, where the clamp is applied; `cli.ts` and the MCP server import
15565
+ * these rather than keeping copies that only agree by coincidence.
15566
+ */
15567
+ const DEFAULT_MAX_DURATION_MS = 6e5;
15568
+ const MAX_DURATION_MS = 36e5;
15534
15569
  /** Registered rule IDs — used to warn on unknown severityOverrides keys (M4). */
15535
15570
  const KNOWN_RULE_IDS = new Set(RULES.map((r) => r.id));
15536
15571
  /**
@@ -16285,8 +16320,8 @@ async function runScan(args, hooks = {}) {
16285
16320
  async function runScanInProject(args, hooks = {}) {
16286
16321
  const started = Date.now();
16287
16322
  const degradationWindow = beginDegradationWindow();
16288
- const requestedDuration = Number.isFinite(args.maxDurationMs) ? args.maxDurationMs : DEFAULT_MAX_DURATION_MS$1;
16289
- const deadline = started + Math.min(Math.max(1, requestedDuration), MAX_DURATION_MS$1);
16323
+ const requestedDuration = Number.isFinite(args.maxDurationMs) ? args.maxDurationMs : DEFAULT_MAX_DURATION_MS;
16324
+ const deadline = started + Math.min(Math.max(1, requestedDuration), MAX_DURATION_MS);
16290
16325
  const discovered = discoverWorkspace(args.target);
16291
16326
  const targetAbs = resolve(args.target);
16292
16327
  const scanRoot = discovered && discovered.root !== targetAbs && targetAbs.startsWith(discovered.root + sep) ? {
@@ -17047,8 +17082,6 @@ function renderPwRunSummary(s) {
17047
17082
  */
17048
17083
  /** Hard caps (§21 threat model: parameter size + resource bounds). */
17049
17084
  const MAX_PARAM_BYTES = 65536;
17050
- const MAX_DURATION_MS = 36e5;
17051
- const DEFAULT_MAX_DURATION_MS = 6e5;
17052
17085
  const PROTOCOL_VERSION = "2025-06-18";
17053
17086
  /** The real path of `p`, or its lexical resolution when it does not exist. */
17054
17087
  function realOrResolved(p) {
@@ -17112,7 +17145,7 @@ const MCP_TOOLS = [
17112
17145
  },
17113
17146
  fixturesRoot: {
17114
17147
  type: "string",
17115
- description: "Root directory for fixture lookups (defaults to cwd)."
17148
+ description: "Root directory for fixture lookups (defaults to <cwd>/tests/fixtures, as for `mjolnir explain`)."
17116
17149
  }
17117
17150
  },
17118
17151
  required: ["ruleId"]
@@ -17192,7 +17225,7 @@ function validateParams(name, args) {
17192
17225
  if (typeof args["path"] !== "string" || args["path"].length === 0) return "scan requires a non-empty string `path`";
17193
17226
  if (args["maxDurationMs"] !== void 0) {
17194
17227
  const duration = args["maxDurationMs"];
17195
- if (typeof duration !== "number" || !Number.isFinite(duration) || duration <= 0 || duration > MAX_DURATION_MS) return "`maxDurationMs` must be a finite positive number within the server budget";
17228
+ if (typeof duration !== "number" || !Number.isFinite(duration) || duration <= 0 || duration > 36e5) return "`maxDurationMs` must be a finite positive number within the server budget";
17196
17229
  }
17197
17230
  }
17198
17231
  if (name === "explain") {
@@ -17296,7 +17329,7 @@ async function handleToolCall(call) {
17296
17329
  message: "fixturesRoot must be a string"
17297
17330
  }
17298
17331
  };
17299
- const explanation = explainRule(ruleId, (typeof rawFixturesRoot === "string" ? rawFixturesRoot : void 0) ?? process.cwd());
17332
+ const explanation = explainRule(ruleId, (typeof rawFixturesRoot === "string" ? rawFixturesRoot : void 0) ?? join(process.cwd(), "tests", "fixtures"));
17300
17333
  if (!explanation.ok) return {
17301
17334
  jsonrpc: "2.0",
17302
17335
  id: call.id,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mjolnir-qa",
3
- "version": "6.0.1",
4
- "publishedStable": "6.0.1",
3
+ "version": "6.1.0",
4
+ "publishedStable": "6.1.0",
5
5
  "description": "Mjölnir — the Verification Trust Engine for QA. Audits test suites and CI pipelines, reports a worthiness score and prioritized findings.",
6
6
  "type": "module",
7
7
  "engines": {