mjolnir-qa 2.1.0 → 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.1.0";
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
  /**
@@ -14487,6 +14781,7 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
14487
14781
  for (const f of findings) f.fixGroupId = f.ruleId;
14488
14782
  const discoveredReport = discoverAndParseRuntimeReport(scanRoot.root);
14489
14783
  const runtimeReportPath = discoveredReport?.path;
14784
+ const runtimeIncomplete = discoveredReport !== void 0 && discoveredReport.report.analysisComplete !== true;
14490
14785
  let forensicVerdicts;
14491
14786
  if (discoveredReport && discoveredReport.report.analysisComplete === true) try {
14492
14787
  buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
@@ -14499,6 +14794,7 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
14499
14794
  suppressionCount,
14500
14795
  frameworks,
14501
14796
  runtimeReportPath,
14797
+ runtimeIncomplete,
14502
14798
  forensicVerdicts,
14503
14799
  config
14504
14800
  };
@@ -14567,10 +14863,13 @@ function assembleScanResult(o) {
14567
14863
  if (o.scopeIgnored > 0) scopeReasons.push(`ignored:${o.scopeIgnored}`);
14568
14864
  if (o.scopeUnrecognized > 0) scopeReasons.push(`unrecognized:${o.scopeUnrecognized}`);
14569
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");
14570
14869
  for (const reason of o.truncationReasons) scopeReasons.push(`truncated:${reason}`);
14571
14870
  const scopeIntegrity = {
14572
14871
  discovered: o.testFiles.length,
14573
- analyzed: Math.max(0, o.scanned),
14872
+ analyzed: Math.max(0, o.analyzed ?? o.scanned),
14574
14873
  ignored: o.scopeIgnored,
14575
14874
  unrecognized: o.scopeUnrecognized,
14576
14875
  parseFailed: o.parseFailed,
@@ -14579,28 +14878,59 @@ function assembleScanResult(o) {
14579
14878
  ...scopeReasons.length > 0 ? { reasons: scopeReasons } : {}
14580
14879
  };
14581
14880
  const scopeAdjustedTotal = scopeReasons.length > 0 && total >= 100 ? 99 : total;
14881
+ let identityIncomplete = false;
14582
14882
  const inputSnapshot = o.testFiles.map((p) => {
14583
14883
  const relPath = relative(o.workspace.root, p).replaceAll("\\", "/");
14584
14884
  try {
14585
- const content = readFileSync(p);
14586
- 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");
14587
14895
  return {
14588
14896
  path: relPath,
14589
- size: content.length,
14897
+ size: read.data.length,
14590
14898
  hash
14591
14899
  };
14592
14900
  } catch {
14901
+ if (existsSync(o.workspace.root)) identityIncomplete = true;
14593
14902
  return {
14594
14903
  path: relPath,
14595
- size: 0
14904
+ size: 0,
14905
+ hash: "UNAVAILABLE"
14596
14906
  };
14597
14907
  }
14598
14908
  });
14599
14909
  let reportDigest;
14600
14910
  if (o.runtimeReportPath) try {
14601
- const reportBytes = readFileSync(o.runtimeReportPath);
14602
- reportDigest = createHash("sha256").update(reportBytes).digest("hex").slice(0, 16);
14603
- } 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
+ });
14604
14934
  const runIdentity = buildRunIdentity({
14605
14935
  files: inputSnapshot,
14606
14936
  rules: [...o.REVISION_BY_RULE_ID.entries()].map(([id, detectorRevision]) => ({
@@ -14618,13 +14948,14 @@ function assembleScanResult(o) {
14618
14948
  const evidenceGraph = buildEvidenceGraph({ runId: runIdentity });
14619
14949
  const hasTests = o.testFileCount > 0 && o.testDeclarationCount > 0;
14620
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;
14621
14952
  const result = {
14622
14953
  schemaVersion: 1,
14623
- partial: o.discoveryTruncated || o.rulesPartial || o.skippedFiles > 0 || o.rulesCrashed > 0 || o.scopeInfo.degraded !== void 0,
14954
+ partial: completion.partial,
14624
14955
  scopeIntegrity,
14625
14956
  runIdentity,
14626
14957
  evidenceGraph,
14627
- score: hasTests ? scopeAdjustedTotal : null,
14958
+ score: finalScore,
14628
14959
  ...hasTests ? {} : { reason: "no-tests-found" },
14629
14960
  frameworks: o.frameworks.frameworks,
14630
14961
  frameworkDetectionUnknown: o.frameworks.unknown,
@@ -14650,12 +14981,8 @@ function assembleScanResult(o) {
14650
14981
  file: o.cache.stats.file
14651
14982
  } } : {},
14652
14983
  analysisStatus: {
14653
- discovery: o.discoveryTruncated ? "partial" : "complete",
14654
- rules: o.rulesPartial ? "partial" : "complete",
14655
- skippedFiles: o.skippedFiles,
14656
- durationMs: elapsed,
14657
- rulesCrashed: o.rulesCrashed,
14658
- ...o.truncationReasons.size > 0 ? { truncationReasons: [...o.truncationReasons].sort() } : {}
14984
+ ...completion.analysisStatus,
14985
+ durationMs: elapsed
14659
14986
  },
14660
14987
  scoringModelVersion: SCORING_MODEL_VERSION
14661
14988
  };
@@ -14703,7 +15030,8 @@ function assembleScanResult(o) {
14703
15030
  */
14704
15031
  async function runScan(args, hooks = {}) {
14705
15032
  const started = Date.now();
14706
- 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);
14707
15035
  const discovered = discoverWorkspace(args.target);
14708
15036
  const targetAbs = resolve(args.target);
14709
15037
  const scanRoot = discovered && discovered.root !== targetAbs && targetAbs.startsWith(discovered.root + sep) ? {
@@ -14792,7 +15120,9 @@ async function runScan(args, hooks = {}) {
14792
15120
  testDeclarationCount = analysis.testDeclarationCount;
14793
15121
  rulesPartial = analysis.rulesPartial;
14794
15122
  parseFailed = analysis.parseFailed;
15123
+ const parseFallbacks = analysis.parseFallbacks;
14795
15124
  const scanned = analysis.scanned;
15125
+ const analyzed = analysis.analyzed;
14796
15126
  const postScan = applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, declarationsByFile, testDeclarationCount, tierByRuleId, REVISION_BY_RULE_ID);
14797
15127
  testDeclarationCount = postScan.testDeclarationCount;
14798
15128
  const result = assembleScanResult({
@@ -14808,7 +15138,9 @@ async function runScan(args, hooks = {}) {
14808
15138
  scopeIgnored,
14809
15139
  scopeUnrecognized,
14810
15140
  parseFailed,
15141
+ parseFallbacks,
14811
15142
  scanned,
15143
+ analyzed,
14812
15144
  testFiles,
14813
15145
  workspace,
14814
15146
  scanRoot,
@@ -14821,6 +15153,7 @@ async function runScan(args, hooks = {}) {
14821
15153
  suppressionCount: postScan.suppressionCount,
14822
15154
  frameworks: postScan.frameworks,
14823
15155
  runtimeReportPath: postScan.runtimeReportPath,
15156
+ runtimeIncomplete: postScan.runtimeIncomplete,
14824
15157
  forensicVerdicts: postScan.forensicVerdicts,
14825
15158
  config: postScan.config,
14826
15159
  fileProvenance,
@@ -14852,6 +15185,10 @@ function digestView(f) {
14852
15185
  trustLevel: f.trustLevel ?? null,
14853
15186
  confidence: f.confidence,
14854
15187
  findingType: f.findingType,
15188
+ qaImpact: f.qaImpact,
15189
+ findingId: f.findingId ?? null,
15190
+ rootCauseId: f.rootCauseId ?? null,
15191
+ deduplicationGroup: f.deduplicationGroup ?? null,
14855
15192
  fixGroupId: f.fixGroupId ?? null
14856
15193
  };
14857
15194
  }
@@ -14872,8 +15209,12 @@ function canonicalScanJson(result) {
14872
15209
  rules: result.analysisStatus.rules,
14873
15210
  skippedFiles: result.analysisStatus.skippedFiles,
14874
15211
  rulesCrashed: result.analysisStatus.rulesCrashed ?? 0,
14875
- truncationReasons: result.analysisStatus.truncationReasons ?? []
15212
+ parseFallbacks: result.analysisStatus.parseFallbacks ?? 0,
15213
+ truncationReasons: result.analysisStatus.truncationReasons ?? [],
15214
+ reasons: result.analysisStatus.reasons ?? []
14876
15215
  },
15216
+ scopeIntegrity: result.scopeIntegrity ?? null,
15217
+ runIdentity: result.runIdentity ?? null,
14877
15218
  findings: result.findings.map(digestView)
14878
15219
  });
14879
15220
  }
@@ -14930,9 +15271,13 @@ function buildMachineContract(result) {
14930
15271
  rules: result.analysisStatus.rules,
14931
15272
  skippedFiles: result.analysisStatus.skippedFiles,
14932
15273
  rulesCrashed: result.analysisStatus.rulesCrashed ?? 0,
15274
+ ...result.analysisStatus.parseFallbacks !== void 0 ? { parseFallbacks: result.analysisStatus.parseFallbacks } : {},
14933
15275
  truncationReasons: result.analysisStatus.truncationReasons ?? [],
15276
+ ...result.analysisStatus.reasons !== void 0 ? { reasons: result.analysisStatus.reasons } : {},
14934
15277
  frameworkDetectionUnknown: result.frameworkDetectionUnknown,
14935
- 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 } : {}
14936
15281
  },
14937
15282
  ...result.trustSummary !== void 0 ? { trustSummary: result.trustSummary } : {},
14938
15283
  ...result.agenticProfile !== void 0 ? { provenance: result.agenticProfile } : {},
@@ -15293,12 +15638,15 @@ function trustVerdictFor(v) {
15293
15638
  * today the honest command set is repeat/compare/inspect, not
15294
15639
  * "collect a trace", because trace.zip is not yet ingested.
15295
15640
  */
15641
+ function safeTriageText(value) {
15642
+ return value.replace(/[;&|`$()\\\r\n]/g, " ").slice(0, 200);
15643
+ }
15296
15644
  function nextActionFor(v) {
15297
15645
  switch (classifyVerdict(v)) {
15298
- case "RETRY-DEPENDENT": return `repeat execution of this test (3+ runs) to confirm the flake rate, then quarantine + ticket: ${v.file}`;
15299
- case "TIMEOUT": return `re-run in isolation to separate slowness from a hang: npx playwright test ${v.file} --timeout 60000`;
15300
- case "FAILING": return `reproduce locally: npx playwright test ${v.file} -g "${v.title.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}" — then fix and re-run`;
15301
- 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`;
15302
15650
  }
15303
15651
  }
15304
15652
  /** Deterministic guided rows, worst first (same order law as triageRows). */
@@ -15393,7 +15741,16 @@ function renderPwRunSummary(s) {
15393
15741
  */
15394
15742
  /** Hard caps (§21 threat model: parameter size + resource bounds). */
15395
15743
  const MAX_PARAM_BYTES = 65536;
15744
+ const MAX_DURATION_MS = 36e5;
15745
+ const DEFAULT_MAX_DURATION_MS = 6e5;
15396
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
+ }
15397
15754
  /** JSON-RPC error codes (subset used here). */
15398
15755
  const MCP_ERRORS = {
15399
15756
  PARSE: -32700,
@@ -15512,7 +15869,10 @@ function validateParams(name, args) {
15512
15869
  if (Buffer$1.byteLength(JSON.stringify(args), "utf8") > MAX_PARAM_BYTES) return `parameters exceed ${MAX_PARAM_BYTES} bytes (threat model §21)`;
15513
15870
  if (name === "scan") {
15514
15871
  if (typeof args["path"] !== "string" || args["path"].length === 0) return "scan requires a non-empty string `path`";
15515
- 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
+ }
15516
15876
  }
15517
15877
  if (name === "explain") {
15518
15878
  if (typeof args["ruleId"] !== "string" || !/^QA-[A-Z]+-\d{3}$/.test(args["ruleId"])) return "explain requires `ruleId` matching /^QA-[A-Z]+-\\d{3}$/";
@@ -15557,6 +15917,24 @@ async function handleToolCall(call) {
15557
15917
  message: paramError
15558
15918
  }
15559
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
+ };
15560
15938
  try {
15561
15939
  if (call.name === "scan") {
15562
15940
  const target = resolve(call.args["path"]);
@@ -15568,7 +15946,7 @@ async function handleToolCall(call) {
15568
15946
  message: `scan target does not exist: ${target}`
15569
15947
  }
15570
15948
  };
15571
- 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;
15572
15950
  const result = await serializeScan(() => runScan({
15573
15951
  target,
15574
15952
  json: true,