mjolnir-qa 2.0.2 → 3.0.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.
@@ -1,8 +1,8 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { createInterface } from "node:readline";
3
3
  import { Buffer as Buffer$1 } from "node:buffer";
4
- import { createHash } from "node:crypto";
5
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
+ import { createHash, randomBytes } from "node:crypto";
5
+ import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
6
6
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
7
  import * as ts$2 from "ts-morph";
8
8
  import ts, { Project, SyntaxKind, ts as ts$1 } from "ts-morph";
@@ -56,6 +56,38 @@ const DEDUCTIONS = {
56
56
  info: 1
57
57
  };
58
58
  //#endregion
59
+ //#region src/engine/completion.ts
60
+ function deriveCompletion(input) {
61
+ const truncationReasons = [...new Set(input.truncationReasons)].sort();
62
+ const reasons = /* @__PURE__ */ new Set();
63
+ if (input.discoveryTruncated) reasons.add("discovery-truncated");
64
+ if (input.rulesPartial) reasons.add("rules-partial");
65
+ if (input.skippedFiles > 0) reasons.add(`skipped-files:${input.skippedFiles}`);
66
+ if (input.rulesCrashed > 0) reasons.add(`rules-crashed:${input.rulesCrashed}`);
67
+ if (input.scopeIgnored > 0) reasons.add(`scope-ignored:${input.scopeIgnored}`);
68
+ if (input.scopeUnrecognized > 0) reasons.add(`scope-unrecognized:${input.scopeUnrecognized}`);
69
+ if (input.parseFailed > 0) reasons.add(`parse-failed:${input.parseFailed}`);
70
+ if (input.parseFallbacks && input.parseFallbacks > 0) reasons.add(`parse-fallbacks:${input.parseFallbacks}`);
71
+ if (input.scopeDegraded) reasons.add(`scope-degraded:${input.scopeDegraded}`);
72
+ if (input.runtimeIncomplete) reasons.add("runtime-incomplete");
73
+ if (input.identityIncomplete) reasons.add("identity-incomplete");
74
+ for (const reason of truncationReasons) reasons.add(`truncated:${reason}`);
75
+ const discoveryPartial = input.discoveryTruncated || input.scopeIgnored > 0 || input.scopeUnrecognized > 0 || input.scopeDegraded !== void 0;
76
+ const rulesPartial = input.rulesPartial || input.rulesCrashed > 0 || input.parseFailed > 0;
77
+ return {
78
+ partial: discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0,
79
+ analysisStatus: {
80
+ discovery: discoveryPartial ? "partial" : "complete",
81
+ rules: rulesPartial ? "partial" : "complete",
82
+ skippedFiles: input.skippedFiles,
83
+ rulesCrashed: input.rulesCrashed,
84
+ parseFallbacks: input.parseFallbacks ?? 0,
85
+ ...truncationReasons.length > 0 ? { truncationReasons } : {},
86
+ reasons: [...reasons].sort()
87
+ }
88
+ };
89
+ }
90
+ //#endregion
59
91
  //#region src/engine/runtime-corroboration.ts
60
92
  /**
61
93
  * Stamp runtime corroboration + trust levels onto findings (mutates in
@@ -822,7 +854,10 @@ function sha256$1(text) {
822
854
  return createHash("sha256").update(text).digest("hex");
823
855
  }
824
856
  function canonical(value) {
825
- return JSON.stringify(value);
857
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
858
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
859
+ const record = value;
860
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`;
826
861
  }
827
862
  function buildRunIdentity(input) {
828
863
  const inputFingerprint = sha256$1([...input.files].map((f) => f.hash ? `${f.path}:${f.hash}` : `${f.path}:${f.size}`).sort().join("\n"));
@@ -904,7 +939,7 @@ function buildEvidenceGraph(parts) {
904
939
  * scripts/sync-sarif-version.cjs and guarded by the version-consistency
905
940
  * spec. cli.ts re-exports this as CLI_VERSION.
906
941
  */
907
- const ENGINE_VERSION = "2.0.2";
942
+ const ENGINE_VERSION = "3.0.0";
908
943
  //#endregion
909
944
  //#region src/engine/contract-versions.ts
910
945
  /**
@@ -1065,7 +1100,9 @@ function basename$1(p) {
1065
1100
  * Internal files (cache, hashes) use `isRecord`; user-facing files
1066
1101
  * (config, plugins) use stricter checks that throw descriptive errors.
1067
1102
  */
1103
+ const MAX_JSON_BYTES = 16777216;
1068
1104
  function parseJsonFile(text, source, validate) {
1105
+ if (Buffer.byteLength(text, "utf8") > MAX_JSON_BYTES) throw new Error(`JSON source exceeds the ${MAX_JSON_BYTES}-byte limit: ${source}`);
1069
1106
  let parsed;
1070
1107
  try {
1071
1108
  parsed = JSON.parse(text);
@@ -1084,6 +1121,59 @@ function isRecord(v) {
1084
1121
  return typeof v === "object" && v !== null && !Array.isArray(v);
1085
1122
  }
1086
1123
  //#endregion
1124
+ //#region src/lib/fs-bounded.ts
1125
+ function closeQuiet(fd) {
1126
+ try {
1127
+ closeSync(fd);
1128
+ } catch {
1129
+ return;
1130
+ }
1131
+ }
1132
+ function readFileBounded(path, maxBytes) {
1133
+ let fd;
1134
+ try {
1135
+ const pathStat = lstatSync(path);
1136
+ if (pathStat.isSymbolicLink()) return {
1137
+ ok: false,
1138
+ reason: "symlink"
1139
+ };
1140
+ if (!pathStat.isFile()) return {
1141
+ ok: false,
1142
+ reason: "unreadable"
1143
+ };
1144
+ fd = openSync(path, 0);
1145
+ const fdStat = fstatSync(fd);
1146
+ if (!fdStat.isFile() || fdStat.size > maxBytes) return {
1147
+ ok: false,
1148
+ reason: fdStat.size > maxBytes ? "too-large" : "unreadable"
1149
+ };
1150
+ const chunks = [];
1151
+ let total = 0;
1152
+ const buffer = Buffer.allocUnsafe(Math.min(65536, maxBytes));
1153
+ while (true) {
1154
+ const read = readSync(fd, buffer, 0, buffer.length, null);
1155
+ if (read === 0) break;
1156
+ total += read;
1157
+ if (total > maxBytes) return {
1158
+ ok: false,
1159
+ reason: "too-large"
1160
+ };
1161
+ chunks.push(Buffer.from(buffer.subarray(0, read)));
1162
+ }
1163
+ return {
1164
+ ok: true,
1165
+ data: Buffer.concat(chunks)
1166
+ };
1167
+ } catch {
1168
+ return {
1169
+ ok: false,
1170
+ reason: "unreadable"
1171
+ };
1172
+ } finally {
1173
+ if (fd !== void 0) closeQuiet(fd);
1174
+ }
1175
+ }
1176
+ //#endregion
1087
1177
  //#region src/config/config-schema.ts
1088
1178
  const VALID_GATES = /* @__PURE__ */ new Set([
1089
1179
  "advisory",
@@ -1182,7 +1272,11 @@ const CONFIG_NAMES = ["mjolnir.config.json", ".mjolnir.json"];
1182
1272
  function findConfigPath(root) {
1183
1273
  for (const name of CONFIG_NAMES) {
1184
1274
  const p = join(root, name);
1185
- if (existsSync(p)) return p;
1275
+ try {
1276
+ if (existsSync(p) && !lstatSync(p).isSymbolicLink()) return p;
1277
+ } catch {
1278
+ continue;
1279
+ }
1186
1280
  }
1187
1281
  return null;
1188
1282
  }
@@ -1202,7 +1296,10 @@ function loadConfig(root, options = {}) {
1202
1296
  const p = join(root, name);
1203
1297
  if (!existsSync(p)) continue;
1204
1298
  try {
1205
- const parsed = parseJsonFile(readFileSync(p, "utf8"), p, (v) => isRecord(v));
1299
+ if (!existsSync(p) || lstatSync(p).isSymbolicLink()) continue;
1300
+ const read = readFileBounded(p, 1048576);
1301
+ if (!read.ok) throw new Error("config source could not be read safely");
1302
+ const parsed = parseJsonFile(read.data.toString("utf8"), p, (v) => isRecord(v));
1206
1303
  return {
1207
1304
  config: parsed,
1208
1305
  path: p,
@@ -1496,8 +1593,9 @@ function sharedWalk(options) {
1496
1593
  }
1497
1594
  const full = join(dir, entry.name);
1498
1595
  const rel = relative(options.root, full).replaceAll("\\", "/");
1596
+ if (entry.isDirectory() && options.skipDirs.includes(entry.name)) continue;
1499
1597
  if (options.ignoreMatcher.isIgnored(rel)) {
1500
- options.onIgnored?.();
1598
+ if (!rel.startsWith(".mjolnir/")) options.onIgnored?.(rel);
1501
1599
  continue;
1502
1600
  }
1503
1601
  if (entry.isSymbolicLink()) {
@@ -1505,7 +1603,6 @@ function sharedWalk(options) {
1505
1603
  continue;
1506
1604
  }
1507
1605
  if (entry.isDirectory()) {
1508
- if (options.skipDirs.includes(entry.name)) continue;
1509
1606
  if (rel.split("/").length > LIMITS$1.maxDepth) {
1510
1607
  options.onSkipped("max-depth");
1511
1608
  continue;
@@ -1523,7 +1620,7 @@ function sharedWalk(options) {
1523
1620
  } catch {
1524
1621
  options.onSkipped("stat-failed");
1525
1622
  }
1526
- else if (entry.isFile()) options.onUnrecognized?.();
1623
+ else if (entry.isFile()) options.onUnrecognized?.(rel);
1527
1624
  }
1528
1625
  };
1529
1626
  walk(options.root);
@@ -3625,7 +3722,7 @@ const SCAN_ADAPTERS = [
3625
3722
  * the adapter that claims it; per-adapter caps still apply.
3626
3723
  */
3627
3724
  function discoverAllTestFiles(ctx, languageAdapters, buckets, fixtureDirMemo) {
3628
- const walkSkips = languageAdapters.reduce((common, a) => common.filter((name) => a.dirSkips.includes(name)), languageAdapters[0]?.dirSkips ?? []);
3725
+ const walkSkips = [.../* @__PURE__ */ new Set([...languageAdapters.reduce((common, a) => common.filter((name) => a.dirSkips.includes(name)), languageAdapters[0]?.dirSkips ?? []), ".mjolnir"])];
3629
3726
  sharedWalk({
3630
3727
  root: ctx.workspace.root,
3631
3728
  deadline: ctx.deadline,
@@ -3651,10 +3748,35 @@ function discoverAllTestFiles(ctx, languageAdapters, buckets, fixtureDirMemo) {
3651
3748
  },
3652
3749
  isFull: () => languageAdapters.every((a) => (buckets.get(a.id)?.length ?? 0) >= ctx.maxFiles),
3653
3750
  fixtureDirMemo,
3654
- onIgnored: ctx.onIgnored,
3655
- onUnrecognized: ctx.onUnrecognized
3751
+ onIgnored: (path) => {
3752
+ if (isScopeRelevantIgnored(path)) ctx.onIgnored?.(path);
3753
+ },
3754
+ onUnrecognized: (path) => {
3755
+ if (typeof path === "string" && isUnrecognizedSourceCandidate(path)) ctx.onUnrecognized?.(path);
3756
+ }
3656
3757
  });
3657
3758
  }
3759
+ function isScopeRelevantIgnored(path) {
3760
+ const name = path.replaceAll("\\", "/").split("/").pop() ?? path;
3761
+ return /\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml|min\.js)$/i.test(name);
3762
+ }
3763
+ function isUnrecognizedSourceCandidate(path) {
3764
+ const normalized = path.replaceAll("\\", "/");
3765
+ const name = normalized.split("/").pop() ?? normalized;
3766
+ if (normalized.includes(".github/workflows/") || name === "azure-pipelines.yml" || name === "Jenkinsfile") return false;
3767
+ const extensionIndex = name.lastIndexOf(".");
3768
+ const stem = extensionIndex > 0 ? name.slice(0, extensionIndex) : name;
3769
+ if (name === "mjolnir.config.json" || name === ".mjolnir.json" || name === "package.json" || name === "package-lock.json" || name === "pnpm-lock.yaml" || name === "yarn.lock" || [
3770
+ "playwright",
3771
+ "vitest",
3772
+ "vite",
3773
+ "eslint",
3774
+ "tsconfig",
3775
+ "jsconfig"
3776
+ ].some((prefix) => stem === prefix || stem.startsWith(`${prefix}.`))) return false;
3777
+ const testLike = /(?:^|\/)__tests__\//i.test(normalized) || /\.(?:spec|test)\.[cm]?[jt]sx?$/i.test(name);
3778
+ return /\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml)$/i.test(name) && testLike;
3779
+ }
3658
3780
  /** Whether ANY shipped adapter would discover this path as a test file. */
3659
3781
  function isKnownTestFile(path) {
3660
3782
  return SCAN_ADAPTERS.some((a) => a.isTestFile(path));
@@ -3681,17 +3803,14 @@ typescriptAdapter.testFileGlobs, pythonAdapter.testFileGlobs, javaAdapter.testFi
3681
3803
  * The fix: resolve git ONCE per process to an ABSOLUTE path by walking
3682
3804
  * the PATH directly (never consulting the CWD), verify the candidate is
3683
3805
  * an existing file, and pass that path to execFileSync. Resolution is
3684
- * memoized; a failure to find a real git anywhere on PATH degrades to
3685
- * the plain name (previous behavior) with the resolution error recorded
3686
- * — degraded git data already means full-file attribution, never a
3687
- * crash.
3806
+ * memoized; a failure to find a real git anywhere on PATH is recorded
3807
+ * and callers degrade without invoking a bare name.
3688
3808
  */
3689
3809
  let resolvedGit = void 0;
3690
3810
  /**
3691
3811
  * The absolute path of the git binary Mjölnir will invoke, or null when
3692
- * PATH carries no executable `git` at all (S1 degradation: callers fall
3693
- * back to the bare name and their own try/catch — same honest degrade
3694
- * as before, minus the CWD-hijack surface).
3812
+ * PATH carries no executable `git` at all. Callers must degrade without
3813
+ * invoking a bare name.
3695
3814
  */
3696
3815
  function resolveGitPath() {
3697
3816
  if (resolvedGit !== void 0) return resolvedGit;
@@ -3724,7 +3843,8 @@ function resolveGitPath() {
3724
3843
  * the previous inline `git()` helpers, with the hijack surface closed.
3725
3844
  */
3726
3845
  function runGit(root, args) {
3727
- const exe = resolveGitPath() ?? "git";
3846
+ const exe = resolveGitPath();
3847
+ if (!exe) return null;
3728
3848
  try {
3729
3849
  return execFileSync(exe, [
3730
3850
  "-C",
@@ -3860,7 +3980,7 @@ function computeChangedScope(root, baseBranch) {
3860
3980
  "--",
3861
3981
  ...chunk
3862
3982
  ]);
3863
- if (output === null) continue;
3983
+ if (output === null) return null;
3864
3984
  const sections = output.split("\ndiff --git ");
3865
3985
  for (const section of sections) {
3866
3986
  const body = section.startsWith("diff --git ") ? section : `diff --git ${section}`;
@@ -3888,6 +4008,11 @@ function computeChangedScope(root, baseBranch) {
3888
4008
  "--unified=0",
3889
4009
  "HEAD"
3890
4010
  ], changedFiles);
4011
+ if (committedDiffs === null || workingDiffs === null) return {
4012
+ changed: {},
4013
+ degraded: true,
4014
+ reason: "diff-failed"
4015
+ };
3891
4016
  for (const file of changedFiles) {
3892
4017
  const committedDiff = committedDiffs.get(file);
3893
4018
  const workingDiff = workingDiffs.get(file);
@@ -3913,8 +4038,11 @@ const LIMITS_MAX_LINES = 1e6;
3913
4038
  function allLinesOf(root, file) {
3914
4039
  const full = join(root, file);
3915
4040
  try {
3916
- if (statSync(full).size > LIMITS$1.maxFileBytes) return null;
3917
- const lineCount = readFileSync(full, "utf8").split("\n").length;
4041
+ const stat = lstatSync(full);
4042
+ if (stat.isSymbolicLink() || !stat.isFile()) return null;
4043
+ const read = readFileBounded(full, LIMITS$1.maxFileBytes);
4044
+ if (!read.ok) return null;
4045
+ const lineCount = read.data.toString("utf8").split("\n").length;
3918
4046
  return Array.from({ length: lineCount }, (_, i) => i + 1);
3919
4047
  } catch {
3920
4048
  return null;
@@ -11967,6 +12095,85 @@ function configurableAggregation(results, config) {
11967
12095
  };
11968
12096
  }
11969
12097
  //#endregion
12098
+ //#region src/lib/fs-atomic.ts
12099
+ /**
12100
+ * Atomic file writes (audit S9).
12101
+ *
12102
+ * Every durability-critical write in Mjölnir (baseline, stats, badge,
12103
+ * TRIAGE.md, scaffolded rule files) used to hand-roll
12104
+ * `writeFileSync(path, data)` — a crash mid-write left a TRUNCATED file
12105
+ * at the real path, and a subsequent read (diff, badge endpoint) served
12106
+ * confident nonsense from it.
12107
+ *
12108
+ * `writeFileAtomic` writes to a temp sibling, then RENAMES. On the same
12109
+ * volume rename is atomic: readers see either the complete old file or
12110
+ * the complete new file, never a half-written one. The temp name is
12111
+ * created with `wx` (exclusive) so concurrent writers cannot interleave,
12112
+ * stale temps are cleaned up on failure, and on Windows the rename is
12113
+ * retried briefly because a concurrent reader can hold the destination
12114
+ * open (EBUSY/EPERM).
12115
+ */
12116
+ function atomicTempPath(path) {
12117
+ return `${path}.mjolnir-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}.tmp`;
12118
+ }
12119
+ /**
12120
+ * Atomically replace `path` with `data`.
12121
+ */
12122
+ function writeFileAtomic(path, data, opts = {}) {
12123
+ const dir = dirname(path);
12124
+ if (opts.mkdirs !== false && !existsSync(dir)) mkdirSync(dir, { recursive: true });
12125
+ const tmp = atomicTempPath(path);
12126
+ let fd;
12127
+ try {
12128
+ fd = openSync(tmp, "wx", opts.mode ?? 420);
12129
+ writeSync(fd, data, null, opts.encoding ?? "utf8");
12130
+ } finally {
12131
+ if (fd !== void 0) closeSync(fd);
12132
+ }
12133
+ try {
12134
+ renameWithWindowsRetry(tmp, path);
12135
+ } catch (err) {
12136
+ try {
12137
+ if (existsSync(tmp)) unlinkSync(tmp);
12138
+ } catch {}
12139
+ throw err;
12140
+ }
12141
+ }
12142
+ /**
12143
+ * renameSync retry loop for Windows: a concurrent reader (another scan,
12144
+ * a badge endpoint, an editor) holding the destination open makes
12145
+ * rename fail with EBUSY/EPERM. A short bounded retry closes the race
12146
+ * without turning an atomic swap into a partial write.
12147
+ *
12148
+ * Internal contract test hook: the platform check keeps this loop off
12149
+ * the POSIX hot path; on win32 the EBUSY/EPERM arms are exercised by
12150
+ * the fs-atomic-retry spec (mocked renameSync).
12151
+ */
12152
+ const RENAME_RETRIES = 8;
12153
+ const RENAME_RETRY_DELAY_MS = 25;
12154
+ /** Synchronous sleep that does not spin the CPU. */
12155
+ function sleepSync(ms) {
12156
+ try {
12157
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
12158
+ } catch {
12159
+ const until = Date.now() + ms;
12160
+ while (Date.now() < until);
12161
+ }
12162
+ }
12163
+ function renameWithWindowsRetry(from, to) {
12164
+ for (let attempt = 0;; attempt++) try {
12165
+ renameSync(from, to);
12166
+ return;
12167
+ } catch (err) {
12168
+ const code = err?.code;
12169
+ if (process.platform === "win32" && (code === "EBUSY" || code === "EPERM") && attempt < RENAME_RETRIES) {
12170
+ sleepSync(RENAME_RETRY_DELAY_MS);
12171
+ continue;
12172
+ }
12173
+ throw err;
12174
+ }
12175
+ }
12176
+ //#endregion
11970
12177
  //#region src/engine/scan-cache.ts
11971
12178
  /**
11972
12179
  * Local incremental scan cache (Beta-to-Stable 1.0 plan, M5.2 / A-2).
@@ -12002,6 +12209,17 @@ const MAX_ENTRIES$1 = 4096;
12002
12209
  * serialized size; the newest entries win (real LRU-by-use).
12003
12210
  */
12004
12211
  const MAX_TOTAL_BYTES = 33554432;
12212
+ const MAX_FINDINGS_PER_ENTRY = 1e4;
12213
+ function isCachedFinding(value) {
12214
+ if (!isRecord(value)) return false;
12215
+ return typeof value["ruleId"] === "string" && typeof value["category"] === "string" && typeof value["severity"] === "string" && typeof value["confidence"] === "string" && typeof value["findingType"] === "string" && typeof value["file"] === "string" && Number.isSafeInteger(value["line"]) && Number.isSafeInteger(value["column"]) && typeof value["message"] === "string" && typeof value["why"] === "string" && typeof value["fix"] === "string";
12216
+ }
12217
+ function isCacheFile(value) {
12218
+ if (!isRecord(value) || value["version"] !== CACHE_VERSION) return false;
12219
+ const entries = value["entries"];
12220
+ if (!isRecord(entries) || Object.keys(entries).length > MAX_ENTRIES$1) return false;
12221
+ return Object.values(entries).every((entry) => isRecord(entry) && Array.isArray(entry["findings"]) && entry["findings"].length <= MAX_FINDINGS_PER_ENTRY && entry["findings"].every(isCachedFinding));
12222
+ }
12005
12223
  /** sha256 hex of a string — the only hash this module needs. */
12006
12224
  function sha256(text) {
12007
12225
  return createHash("sha256").update(text).digest("hex");
@@ -12100,12 +12318,15 @@ function createScanCache(root) {
12100
12318
  let totalBytes = 0;
12101
12319
  try {
12102
12320
  if (existsSync(file)) {
12103
- entries = parseJsonFile(readFileSync(file, "utf8"), file, (v) => isRecord(v) && v["version"] === CACHE_VERSION && isRecord(v["entries"])).entries;
12321
+ const stat = lstatSync(file);
12322
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_TOTAL_BYTES) throw new Error("invalid cache file");
12323
+ entries = parseJsonFile(readFileSync(file, "utf8"), file, isCacheFile).entries;
12104
12324
  for (const [k, v] of Object.entries(entries)) {
12105
- const size = JSON.stringify(v).length + k.length + 4;
12325
+ const size = Buffer.byteLength(JSON.stringify(v), "utf8") + k.length + 4;
12106
12326
  entryBytes.set(k, size);
12107
12327
  totalBytes += size;
12108
12328
  }
12329
+ if (totalBytes > MAX_TOTAL_BYTES) throw new Error("cache byte budget exceeded");
12109
12330
  }
12110
12331
  } catch {
12111
12332
  entries = {};
@@ -12131,11 +12352,13 @@ function createScanCache(root) {
12131
12352
  },
12132
12353
  store(key, findings, fileBudgetExceeded) {
12133
12354
  if (fileBudgetExceeded) return;
12355
+ if (findings.length > MAX_FINDINGS_PER_ENTRY) return;
12356
+ const entryJson = JSON.stringify(findings);
12357
+ const newBytes = Buffer.byteLength(entryJson, "utf8") + key.length + 4;
12358
+ if (newBytes > MAX_TOTAL_BYTES) return;
12134
12359
  const replacedBytes = entryBytes.get(key) ?? 0;
12135
12360
  delete entries[key];
12136
- const entryJson = JSON.stringify(findings);
12137
12361
  entries[key] = { findings: structuredClone(findings) };
12138
- const newBytes = entryJson.length + key.length + 4;
12139
12362
  entryBytes.set(key, newBytes);
12140
12363
  totalBytes = totalBytes - replacedBytes + newBytes;
12141
12364
  let count = Object.keys(entries).length;
@@ -12152,10 +12375,10 @@ function createScanCache(root) {
12152
12375
  if (!dirty) return;
12153
12376
  try {
12154
12377
  mkdirSync(dir, { recursive: true });
12155
- writeFileSync(file, JSON.stringify({
12378
+ writeFileAtomic(file, JSON.stringify({
12156
12379
  version: CACHE_VERSION,
12157
12380
  entries
12158
- }), "utf8");
12381
+ }), { encoding: "utf8" });
12159
12382
  } catch {}
12160
12383
  }
12161
12384
  };
@@ -13798,6 +14021,31 @@ function loadPlugins(root, gateOpen = false) {
13798
14021
  * next catalog render (locked by tests/local-rules.spec.ts).
13799
14022
  */
13800
14023
  const LOCAL_RULES_DIR = "mjolnir-rules";
14024
+ const MAX_EXTERNAL_RULES = 1e3;
14025
+ const MAX_RULE_FILE_BYTES = 1048576;
14026
+ const MAX_PATTERNS_PER_RULE = 100;
14027
+ function hasNestedQuantifier(pattern) {
14028
+ for (let i = 0; i < pattern.length; i++) {
14029
+ if (pattern.charAt(i) !== "(") continue;
14030
+ let bodyHasQuantifier = false;
14031
+ let j = i + 1;
14032
+ for (; j < pattern.length && pattern.charAt(j) !== ")"; j++) {
14033
+ const bodyChar = pattern.charAt(j);
14034
+ if (bodyChar === "+" || bodyChar === "*" || bodyChar === "?") {
14035
+ bodyHasQuantifier = true;
14036
+ break;
14037
+ }
14038
+ }
14039
+ if (bodyHasQuantifier) {
14040
+ while (j < pattern.length && pattern.charAt(j) !== ")") j++;
14041
+ if (j + 1 < pattern.length) {
14042
+ const next = pattern.charAt(j + 1);
14043
+ if (next === "+" || next === "*" || next === "{") return true;
14044
+ }
14045
+ }
14046
+ }
14047
+ return false;
14048
+ }
13801
14049
  const ALLOWED_CATEGORIES = /* @__PURE__ */ new Set([
13802
14050
  "QA-TEST",
13803
14051
  "QA-TQUAL",
@@ -13827,7 +14075,7 @@ const ALLOWED_QA_IMPACTS = /* @__PURE__ */ new Set([
13827
14075
  * Missing directory → empty result (not an error — most workspaces
13828
14076
  * carry none).
13829
14077
  */
13830
- async function loadLocalRules(root, gateOpen = true) {
14078
+ async function loadLocalRules(root, gateOpen = false) {
13831
14079
  const result = {
13832
14080
  rules: [],
13833
14081
  errors: [],
@@ -13835,6 +14083,15 @@ async function loadLocalRules(root, gateOpen = true) {
13835
14083
  };
13836
14084
  const dir = join(root, LOCAL_RULES_DIR);
13837
14085
  if (!existsSync(dir)) return result;
14086
+ try {
14087
+ if (lstatSync(dir).isSymbolicLink()) {
14088
+ result.errors.push(`external rules directory "${LOCAL_RULES_DIR}/" is a symlink — skipped`);
14089
+ return result;
14090
+ }
14091
+ } catch (err) {
14092
+ result.errors.push(`external rules directory "${LOCAL_RULES_DIR}/" could not be inspected: ${err instanceof Error ? err.message : String(err)}`);
14093
+ return result;
14094
+ }
13838
14095
  let entries;
13839
14096
  try {
13840
14097
  entries = readdirSync(dir);
@@ -13843,7 +14100,20 @@ async function loadLocalRules(root, gateOpen = true) {
13843
14100
  return result;
13844
14101
  }
13845
14102
  for (const entry of entries.sort()) {
14103
+ if (result.rules.length >= MAX_EXTERNAL_RULES) {
14104
+ result.errors.push(`external rule budget exceeded (${MAX_EXTERNAL_RULES})`);
14105
+ break;
14106
+ }
13846
14107
  const path = join(dir, entry);
14108
+ try {
14109
+ if (lstatSync(path).isSymbolicLink()) {
14110
+ result.errors.push(`external rule "${LOCAL_RULES_DIR}/${entry}" is a symlink — skipped`);
14111
+ continue;
14112
+ }
14113
+ } catch (err) {
14114
+ result.errors.push(`external rule "${LOCAL_RULES_DIR}/${entry}" could not be inspected: ${err instanceof Error ? err.message : String(err)}`);
14115
+ continue;
14116
+ }
13847
14117
  if (entry.endsWith(".json")) loadJsonRule(path, result);
13848
14118
  else if (entry.endsWith(".mjs") || entry.endsWith(".js")) {
13849
14119
  if (!gateOpen) result.skipped.push(`${LOCAL_RULES_DIR}/${entry}`);
@@ -13856,6 +14126,10 @@ function loadJsonRule(path, result) {
13856
14126
  const name = `${LOCAL_RULES_DIR}/${path.split(/[\\/]/).pop()}`;
13857
14127
  let raw;
13858
14128
  try {
14129
+ if (lstatSync(path).size > MAX_RULE_FILE_BYTES) {
14130
+ result.errors.push(`external rule "${name}" exceeds the file size budget`);
14131
+ return;
14132
+ }
13859
14133
  raw = JSON.parse(readFileSync(path, "utf8"));
13860
14134
  } catch (err) {
13861
14135
  result.errors.push(`external rule "${name}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
@@ -13876,7 +14150,7 @@ function loadJsonRule(path, result) {
13876
14150
  return;
13877
14151
  }
13878
14152
  const patterns = decl["patterns"];
13879
- if (!Array.isArray(patterns) || patterns.length === 0 || !patterns.every((p) => typeof p === "string" && p.length > 0)) {
14153
+ if (!Array.isArray(patterns) || patterns.length === 0 || patterns.length > MAX_PATTERNS_PER_RULE || !patterns.every((p) => typeof p === "string" && p.length > 0)) {
13880
14154
  result.errors.push(`external rule ${id} must declare a non-empty "patterns" array of regex source strings.`);
13881
14155
  return;
13882
14156
  }
@@ -13892,6 +14166,10 @@ function loadJsonRule(path, result) {
13892
14166
  result.errors.push(`external rule ${id} has a pattern longer than 512 chars — likely a copy-paste error.`);
13893
14167
  return;
13894
14168
  }
14169
+ if (hasNestedQuantifier(p)) {
14170
+ result.errors.push(`external rule ${id} has a nested quantifier that may cause catastrophic backtracking`);
14171
+ return;
14172
+ }
13895
14173
  const quantifiers = p.match(/[+*?]|\{\d/g);
13896
14174
  if (quantifiers && quantifiers.length > 64) {
13897
14175
  result.errors.push(`external rule ${id} has a pattern with more than 64 quantifier/wildcard tokens — likely catastrophic backtracking.`);
@@ -14071,6 +14349,8 @@ function renderGateNotice(skipped) {
14071
14349
  * module moved. Re-exports in cli.ts keep the historical import surface.
14072
14350
  */
14073
14351
  const UNIVERSAL_RULES = RULES.map(asUniversal);
14352
+ const DEFAULT_MAX_DURATION_MS$1 = 6e5;
14353
+ const MAX_DURATION_MS$1 = 36e5;
14074
14354
  /** Registered rule IDs — used to warn on unknown severityOverrides keys (M4). */
14075
14355
  const KNOWN_RULE_IDS = new Set(RULES.map((r) => r.id));
14076
14356
  /**
@@ -14287,7 +14567,9 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14287
14567
  let testDeclarationCount = 0;
14288
14568
  let rulesPartial = false;
14289
14569
  let parseFailed = 0;
14570
+ let parseFallbacks = 0;
14290
14571
  let scanned = 0;
14572
+ let analyzed = 0;
14291
14573
  for (const path of testFiles) {
14292
14574
  if (Date.now() > deadline) {
14293
14575
  rulesPartial = true;
@@ -14301,7 +14583,12 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14301
14583
  if (!isCiAdapter) testFileCount++;
14302
14584
  let text;
14303
14585
  try {
14304
- text = readFileSync(path, "utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
14586
+ const readResult = readFileBounded(path, LIMITS$1.maxFileBytes);
14587
+ if (!readResult.ok) {
14588
+ skippedFiles++;
14589
+ continue;
14590
+ }
14591
+ text = readResult.data.toString("utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
14305
14592
  } catch {
14306
14593
  skippedFiles++;
14307
14594
  continue;
@@ -14335,6 +14622,7 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14335
14622
  const cachedFindings = cache.lookup(cacheKey);
14336
14623
  if (cachedFindings) {
14337
14624
  for (const f of cachedFindings) findings.push(f);
14625
+ analyzed++;
14338
14626
  continue;
14339
14627
  }
14340
14628
  hooks.onProgress?.({
@@ -14361,10 +14649,12 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14361
14649
  }
14362
14650
  const actualMode = parsed ? "ast" : "regex";
14363
14651
  if (wantsAst && actualMode === "regex") {
14652
+ parseFallbacks++;
14364
14653
  cacheKey = fileCacheKey(rulesDigest, text, identity(actualMode));
14365
14654
  const fallbackFindings = cache.lookup(cacheKey);
14366
14655
  if (fallbackFindings) {
14367
14656
  for (const f of fallbackFindings) findings.push(f);
14657
+ analyzed++;
14368
14658
  continue;
14369
14659
  }
14370
14660
  }
@@ -14395,8 +14685,10 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14395
14685
  fileBudgetExceeded = true;
14396
14686
  }
14397
14687
  });
14688
+ if (!fileRuleFailed && !fileBudgetExceeded) analyzed++;
14398
14689
  if (!fileRuleFailed) cache.store(cacheKey, findings.slice(findingsStart), fileBudgetExceeded);
14399
14690
  } catch {
14691
+ if (wantsAst) parseFallbacks++;
14400
14692
  skippedFiles++;
14401
14693
  parseFailed++;
14402
14694
  } finally {
@@ -14409,7 +14701,9 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14409
14701
  testDeclarationCount,
14410
14702
  rulesPartial,
14411
14703
  parseFailed,
14412
- scanned
14704
+ parseFallbacks,
14705
+ scanned,
14706
+ analyzed
14413
14707
  };
14414
14708
  }
14415
14709
  /**
@@ -14455,13 +14749,15 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
14455
14749
  for (const w of warnings) hooks.onConfigWarning?.(w);
14456
14750
  applySeverityOverrides(findings, config);
14457
14751
  const active = loadSuppressions(workspace.root).entries.filter((e) => e.status === "active");
14458
- const suppressionCount = active.length;
14752
+ hooks.onPreSuppressionFindings?.([...findings]);
14753
+ let suppressionCount = 0;
14459
14754
  if (active.length > 0) {
14460
14755
  const ruleOnly = new Set(active.filter((e) => !e.files?.length).map((e) => e.ruleId));
14461
14756
  const kept = findings.filter((f) => {
14462
14757
  if (ruleOnly.has(f.ruleId)) return false;
14463
14758
  return !active.some((e) => e.files?.length && e.ruleId === f.ruleId && e.files.some((g) => pathMatchesGlob(f.file, g)));
14464
14759
  });
14760
+ suppressionCount = findings.length - kept.length;
14465
14761
  findings.length = 0;
14466
14762
  for (const f of kept) findings.push(f);
14467
14763
  }
@@ -14485,6 +14781,7 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
14485
14781
  for (const f of findings) f.fixGroupId = f.ruleId;
14486
14782
  const discoveredReport = discoverAndParseRuntimeReport(scanRoot.root);
14487
14783
  const runtimeReportPath = discoveredReport?.path;
14784
+ const runtimeIncomplete = discoveredReport !== void 0 && discoveredReport.report.analysisComplete !== true;
14488
14785
  let forensicVerdicts;
14489
14786
  if (discoveredReport && discoveredReport.report.analysisComplete === true) try {
14490
14787
  buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
@@ -14497,6 +14794,7 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
14497
14794
  suppressionCount,
14498
14795
  frameworks,
14499
14796
  runtimeReportPath,
14797
+ runtimeIncomplete,
14500
14798
  forensicVerdicts,
14501
14799
  config
14502
14800
  };
@@ -14565,10 +14863,13 @@ function assembleScanResult(o) {
14565
14863
  if (o.scopeIgnored > 0) scopeReasons.push(`ignored:${o.scopeIgnored}`);
14566
14864
  if (o.scopeUnrecognized > 0) scopeReasons.push(`unrecognized:${o.scopeUnrecognized}`);
14567
14865
  if (o.parseFailed > 0) scopeReasons.push(`parseFailed:${o.parseFailed}`);
14866
+ if (o.skippedFiles > 0) scopeReasons.push(`skipped:${o.skippedFiles}`);
14867
+ if (o.scopeInfo.degraded) scopeReasons.push(`degraded:${o.scopeInfo.degraded}`);
14868
+ if (o.runtimeIncomplete) scopeReasons.push("runtime-incomplete");
14568
14869
  for (const reason of o.truncationReasons) scopeReasons.push(`truncated:${reason}`);
14569
14870
  const scopeIntegrity = {
14570
14871
  discovered: o.testFiles.length,
14571
- analyzed: Math.max(0, o.scanned),
14872
+ analyzed: Math.max(0, o.analyzed ?? o.scanned),
14572
14873
  ignored: o.scopeIgnored,
14573
14874
  unrecognized: o.scopeUnrecognized,
14574
14875
  parseFailed: o.parseFailed,
@@ -14577,28 +14878,59 @@ function assembleScanResult(o) {
14577
14878
  ...scopeReasons.length > 0 ? { reasons: scopeReasons } : {}
14578
14879
  };
14579
14880
  const scopeAdjustedTotal = scopeReasons.length > 0 && total >= 100 ? 99 : total;
14881
+ let identityIncomplete = false;
14580
14882
  const inputSnapshot = o.testFiles.map((p) => {
14581
14883
  const relPath = relative(o.workspace.root, p).replaceAll("\\", "/");
14582
14884
  try {
14583
- const content = readFileSync(p);
14584
- const hash = createHash("sha256").update(content).digest("hex").slice(0, 16);
14885
+ const read = readFileBounded(resolve(o.workspace.root, p), LIMITS$1.maxFileBytes);
14886
+ if (!read.ok) {
14887
+ if (existsSync(o.workspace.root)) identityIncomplete = true;
14888
+ return {
14889
+ path: relPath,
14890
+ size: 0,
14891
+ hash: "UNAVAILABLE"
14892
+ };
14893
+ }
14894
+ const hash = createHash("sha256").update(read.data).digest("hex");
14585
14895
  return {
14586
14896
  path: relPath,
14587
- size: content.length,
14897
+ size: read.data.length,
14588
14898
  hash
14589
14899
  };
14590
14900
  } catch {
14901
+ if (existsSync(o.workspace.root)) identityIncomplete = true;
14591
14902
  return {
14592
14903
  path: relPath,
14593
- size: 0
14904
+ size: 0,
14905
+ hash: "UNAVAILABLE"
14594
14906
  };
14595
14907
  }
14596
14908
  });
14597
14909
  let reportDigest;
14598
14910
  if (o.runtimeReportPath) try {
14599
- const reportBytes = readFileSync(o.runtimeReportPath);
14600
- reportDigest = createHash("sha256").update(reportBytes).digest("hex").slice(0, 16);
14601
- } catch {}
14911
+ const reportPath = resolve(o.workspace.root, o.runtimeReportPath);
14912
+ if (lstatSync(reportPath).isFile()) {
14913
+ const read = readFileBounded(reportPath, LIMITS$1.maxFileBytes);
14914
+ if (!read.ok) identityIncomplete = true;
14915
+ else reportDigest = createHash("sha256").update(read.data).digest("hex");
14916
+ }
14917
+ } catch {
14918
+ identityIncomplete = true;
14919
+ }
14920
+ const completion = deriveCompletion({
14921
+ discoveryTruncated: o.discoveryTruncated,
14922
+ rulesPartial: o.rulesPartial,
14923
+ skippedFiles: o.skippedFiles,
14924
+ rulesCrashed: o.rulesCrashed,
14925
+ truncationReasons: o.truncationReasons,
14926
+ scopeIgnored: o.scopeIgnored,
14927
+ scopeUnrecognized: o.scopeUnrecognized,
14928
+ parseFailed: o.parseFailed,
14929
+ parseFallbacks: o.parseFallbacks ?? 0,
14930
+ ...o.scopeInfo.degraded !== void 0 ? { scopeDegraded: o.scopeInfo.degraded } : {},
14931
+ ...o.runtimeIncomplete !== void 0 ? { runtimeIncomplete: o.runtimeIncomplete } : {},
14932
+ identityIncomplete
14933
+ });
14602
14934
  const runIdentity = buildRunIdentity({
14603
14935
  files: inputSnapshot,
14604
14936
  rules: [...o.REVISION_BY_RULE_ID.entries()].map(([id, detectorRevision]) => ({
@@ -14616,13 +14948,14 @@ function assembleScanResult(o) {
14616
14948
  const evidenceGraph = buildEvidenceGraph({ runId: runIdentity });
14617
14949
  const hasTests = o.testFileCount > 0 && o.testDeclarationCount > 0;
14618
14950
  const suiteInvalidatedBy = [...new Set(o.findings.filter((f) => SUITE_INVALIDATING_RULE_IDS.has(f.ruleId)).map((f) => f.ruleId))].sort();
14951
+ const finalScore = hasTests ? completion.partial && scopeAdjustedTotal >= 100 ? 99 : scopeAdjustedTotal : null;
14619
14952
  const result = {
14620
14953
  schemaVersion: 1,
14621
- partial: o.discoveryTruncated || o.rulesPartial || o.skippedFiles > 0 || o.rulesCrashed > 0 || o.scopeInfo.degraded !== void 0,
14954
+ partial: completion.partial,
14622
14955
  scopeIntegrity,
14623
14956
  runIdentity,
14624
14957
  evidenceGraph,
14625
- score: hasTests ? scopeAdjustedTotal : null,
14958
+ score: finalScore,
14626
14959
  ...hasTests ? {} : { reason: "no-tests-found" },
14627
14960
  frameworks: o.frameworks.frameworks,
14628
14961
  frameworkDetectionUnknown: o.frameworks.unknown,
@@ -14648,12 +14981,8 @@ function assembleScanResult(o) {
14648
14981
  file: o.cache.stats.file
14649
14982
  } } : {},
14650
14983
  analysisStatus: {
14651
- discovery: o.discoveryTruncated ? "partial" : "complete",
14652
- rules: o.rulesPartial ? "partial" : "complete",
14653
- skippedFiles: o.skippedFiles,
14654
- durationMs: elapsed,
14655
- rulesCrashed: o.rulesCrashed,
14656
- ...o.truncationReasons.size > 0 ? { truncationReasons: [...o.truncationReasons].sort() } : {}
14984
+ ...completion.analysisStatus,
14985
+ durationMs: elapsed
14657
14986
  },
14658
14987
  scoringModelVersion: SCORING_MODEL_VERSION
14659
14988
  };
@@ -14701,7 +15030,8 @@ function assembleScanResult(o) {
14701
15030
  */
14702
15031
  async function runScan(args, hooks = {}) {
14703
15032
  const started = Date.now();
14704
- const deadline = started + args.maxDurationMs;
15033
+ const requestedDuration = Number.isFinite(args.maxDurationMs) ? args.maxDurationMs : DEFAULT_MAX_DURATION_MS$1;
15034
+ const deadline = started + Math.min(Math.max(1, requestedDuration), MAX_DURATION_MS$1);
14705
15035
  const discovered = discoverWorkspace(args.target);
14706
15036
  const targetAbs = resolve(args.target);
14707
15037
  const scanRoot = discovered && discovered.root !== targetAbs && targetAbs.startsWith(discovered.root + sep) ? {
@@ -14780,6 +15110,7 @@ async function runScan(args, hooks = {}) {
14780
15110
  scopeUnrecognized++;
14781
15111
  }
14782
15112
  });
15113
+ hooks.onTestFilesDiscovered?.(testFiles);
14783
15114
  const analysis = await runFileAnalysisPhase(findings, testFiles, workspace, activeRules, hooks, cache, rulesDigest, deadline, truncationReasons, declarationsByFile, fileProvenance, (ruleId, file, error) => {
14784
15115
  rulesCrashed++;
14785
15116
  hooks.onRuleCrash?.(ruleId, file, error);
@@ -14789,7 +15120,9 @@ async function runScan(args, hooks = {}) {
14789
15120
  testDeclarationCount = analysis.testDeclarationCount;
14790
15121
  rulesPartial = analysis.rulesPartial;
14791
15122
  parseFailed = analysis.parseFailed;
15123
+ const parseFallbacks = analysis.parseFallbacks;
14792
15124
  const scanned = analysis.scanned;
15125
+ const analyzed = analysis.analyzed;
14793
15126
  const postScan = applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, declarationsByFile, testDeclarationCount, tierByRuleId, REVISION_BY_RULE_ID);
14794
15127
  testDeclarationCount = postScan.testDeclarationCount;
14795
15128
  const result = assembleScanResult({
@@ -14805,7 +15138,9 @@ async function runScan(args, hooks = {}) {
14805
15138
  scopeIgnored,
14806
15139
  scopeUnrecognized,
14807
15140
  parseFailed,
15141
+ parseFallbacks,
14808
15142
  scanned,
15143
+ analyzed,
14809
15144
  testFiles,
14810
15145
  workspace,
14811
15146
  scanRoot,
@@ -14818,6 +15153,7 @@ async function runScan(args, hooks = {}) {
14818
15153
  suppressionCount: postScan.suppressionCount,
14819
15154
  frameworks: postScan.frameworks,
14820
15155
  runtimeReportPath: postScan.runtimeReportPath,
15156
+ runtimeIncomplete: postScan.runtimeIncomplete,
14821
15157
  forensicVerdicts: postScan.forensicVerdicts,
14822
15158
  config: postScan.config,
14823
15159
  fileProvenance,
@@ -14849,6 +15185,10 @@ function digestView(f) {
14849
15185
  trustLevel: f.trustLevel ?? null,
14850
15186
  confidence: f.confidence,
14851
15187
  findingType: f.findingType,
15188
+ qaImpact: f.qaImpact,
15189
+ findingId: f.findingId ?? null,
15190
+ rootCauseId: f.rootCauseId ?? null,
15191
+ deduplicationGroup: f.deduplicationGroup ?? null,
14852
15192
  fixGroupId: f.fixGroupId ?? null
14853
15193
  };
14854
15194
  }
@@ -14869,8 +15209,12 @@ function canonicalScanJson(result) {
14869
15209
  rules: result.analysisStatus.rules,
14870
15210
  skippedFiles: result.analysisStatus.skippedFiles,
14871
15211
  rulesCrashed: result.analysisStatus.rulesCrashed ?? 0,
14872
- truncationReasons: result.analysisStatus.truncationReasons ?? []
15212
+ parseFallbacks: result.analysisStatus.parseFallbacks ?? 0,
15213
+ truncationReasons: result.analysisStatus.truncationReasons ?? [],
15214
+ reasons: result.analysisStatus.reasons ?? []
14873
15215
  },
15216
+ scopeIntegrity: result.scopeIntegrity ?? null,
15217
+ runIdentity: result.runIdentity ?? null,
14874
15218
  findings: result.findings.map(digestView)
14875
15219
  });
14876
15220
  }
@@ -14927,9 +15271,13 @@ function buildMachineContract(result) {
14927
15271
  rules: result.analysisStatus.rules,
14928
15272
  skippedFiles: result.analysisStatus.skippedFiles,
14929
15273
  rulesCrashed: result.analysisStatus.rulesCrashed ?? 0,
15274
+ ...result.analysisStatus.parseFallbacks !== void 0 ? { parseFallbacks: result.analysisStatus.parseFallbacks } : {},
14930
15275
  truncationReasons: result.analysisStatus.truncationReasons ?? [],
15276
+ ...result.analysisStatus.reasons !== void 0 ? { reasons: result.analysisStatus.reasons } : {},
14931
15277
  frameworkDetectionUnknown: result.frameworkDetectionUnknown,
14932
- durationMs: result.analysisStatus.durationMs
15278
+ durationMs: result.analysisStatus.durationMs,
15279
+ ...result.scopeIntegrity !== void 0 ? { scopeIntegrity: result.scopeIntegrity } : {},
15280
+ ...result.runIdentity !== void 0 ? { runIdentity: result.runIdentity } : {}
14933
15281
  },
14934
15282
  ...result.trustSummary !== void 0 ? { trustSummary: result.trustSummary } : {},
14935
15283
  ...result.agenticProfile !== void 0 ? { provenance: result.agenticProfile } : {},
@@ -15290,12 +15638,15 @@ function trustVerdictFor(v) {
15290
15638
  * today the honest command set is repeat/compare/inspect, not
15291
15639
  * "collect a trace", because trace.zip is not yet ingested.
15292
15640
  */
15641
+ function safeTriageText(value) {
15642
+ return value.replace(/[;&|`$()\\\r\n]/g, " ").slice(0, 200);
15643
+ }
15293
15644
  function nextActionFor(v) {
15294
15645
  switch (classifyVerdict(v)) {
15295
- case "RETRY-DEPENDENT": return `repeat execution of this test (3+ runs) to confirm the flake rate, then quarantine + ticket: ${v.file}`;
15296
- case "TIMEOUT": return `re-run in isolation to separate slowness from a hang: npx playwright test ${v.file} --timeout 60000`;
15297
- case "FAILING": return `reproduce locally: npx playwright test ${v.file} -g "${v.title.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}" — then fix and re-run`;
15298
- case "SKIPPED": return `inspect the skip condition in ${v.file} — a skip after a failure usually hides an environment problem`;
15646
+ case "RETRY-DEPENDENT": return `repeat execution of this test (3+ runs) to confirm the flake rate, then quarantine + ticket: ${safeTriageText(v.file)}`;
15647
+ case "TIMEOUT": return `re-run this test in isolation with a 60-second timeout to separate slowness from a hang: ${safeTriageText(v.file)}`;
15648
+ case "FAILING": return `reproduce this test in isolation, then fix and re-run: ${safeTriageText(v.file)} — ${safeTriageText(v.title)}`;
15649
+ case "SKIPPED": return `inspect the skip condition in ${safeTriageText(v.file)} — a skip after a failure usually hides an environment problem`;
15299
15650
  }
15300
15651
  }
15301
15652
  /** Deterministic guided rows, worst first (same order law as triageRows). */
@@ -15390,7 +15741,16 @@ function renderPwRunSummary(s) {
15390
15741
  */
15391
15742
  /** Hard caps (§21 threat model: parameter size + resource bounds). */
15392
15743
  const MAX_PARAM_BYTES = 65536;
15744
+ const MAX_DURATION_MS = 36e5;
15745
+ const DEFAULT_MAX_DURATION_MS = 6e5;
15393
15746
  const PROTOCOL_VERSION = "2025-06-18";
15747
+ const CONFIGURED_ROOT = process.env.MJOLNIR_WORKSPACE_ROOT ? resolve(process.env.MJOLNIR_WORKSPACE_ROOT) : null;
15748
+ function isPathAllowed(path) {
15749
+ if (!CONFIGURED_ROOT) return true;
15750
+ const candidate = resolve(path);
15751
+ const rel = relative(CONFIGURED_ROOT, candidate);
15752
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
15753
+ }
15394
15754
  /** JSON-RPC error codes (subset used here). */
15395
15755
  const MCP_ERRORS = {
15396
15756
  PARSE: -32700,
@@ -15509,7 +15869,10 @@ function validateParams(name, args) {
15509
15869
  if (Buffer$1.byteLength(JSON.stringify(args), "utf8") > MAX_PARAM_BYTES) return `parameters exceed ${MAX_PARAM_BYTES} bytes (threat model §21)`;
15510
15870
  if (name === "scan") {
15511
15871
  if (typeof args["path"] !== "string" || args["path"].length === 0) return "scan requires a non-empty string `path`";
15512
- if (args["maxDurationMs"] !== void 0 && typeof args["maxDurationMs"] !== "number") return "`maxDurationMs` must be a number";
15872
+ if (args["maxDurationMs"] !== void 0) {
15873
+ const duration = args["maxDurationMs"];
15874
+ 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";
15875
+ }
15513
15876
  }
15514
15877
  if (name === "explain") {
15515
15878
  if (typeof args["ruleId"] !== "string" || !/^QA-[A-Z]+-\d{3}$/.test(args["ruleId"])) return "explain requires `ruleId` matching /^QA-[A-Z]+-\\d{3}$/";
@@ -15554,6 +15917,24 @@ async function handleToolCall(call) {
15554
15917
  message: paramError
15555
15918
  }
15556
15919
  };
15920
+ const pathArgument = call.args["path"];
15921
+ if (typeof pathArgument === "string" && !isPathAllowed(pathArgument)) return {
15922
+ jsonrpc: "2.0",
15923
+ id: call.id,
15924
+ error: {
15925
+ code: MCP_ERRORS.INVALID_PARAMS,
15926
+ message: "path is outside the configured workspace root"
15927
+ }
15928
+ };
15929
+ const fixturesRoot = call.args["fixturesRoot"];
15930
+ if (typeof fixturesRoot === "string" && !isPathAllowed(fixturesRoot)) return {
15931
+ jsonrpc: "2.0",
15932
+ id: call.id,
15933
+ error: {
15934
+ code: MCP_ERRORS.INVALID_PARAMS,
15935
+ message: "fixturesRoot is outside the configured workspace root"
15936
+ }
15937
+ };
15557
15938
  try {
15558
15939
  if (call.name === "scan") {
15559
15940
  const target = resolve(call.args["path"]);
@@ -15565,7 +15946,7 @@ async function handleToolCall(call) {
15565
15946
  message: `scan target does not exist: ${target}`
15566
15947
  }
15567
15948
  };
15568
- const maxDurationMs = typeof call.args["maxDurationMs"] === "number" ? call.args["maxDurationMs"] : Number.POSITIVE_INFINITY;
15949
+ const maxDurationMs = typeof call.args["maxDurationMs"] === "number" ? call.args["maxDurationMs"] : DEFAULT_MAX_DURATION_MS;
15569
15950
  const result = await serializeScan(() => runScan({
15570
15951
  target,
15571
15952
  json: true,