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,9 +1,9 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.mjs";
2
2
  import { createRequire } from "node:module";
3
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
3
+ import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
4
4
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
- import { createHash } from "node:crypto";
6
+ import { createHash, randomBytes } from "node:crypto";
7
7
  import * as ts$2 from "ts-morph";
8
8
  import ts, { Project, SyntaxKind, ts as ts$1 } from "ts-morph";
9
9
  import { Language, Parser } from "web-tree-sitter";
@@ -97,6 +97,38 @@ const DEDUCTIONS = {
97
97
  info: 1
98
98
  };
99
99
  //#endregion
100
+ //#region src/engine/completion.ts
101
+ function deriveCompletion(input) {
102
+ const truncationReasons = [...new Set(input.truncationReasons)].sort();
103
+ const reasons = /* @__PURE__ */ new Set();
104
+ if (input.discoveryTruncated) reasons.add("discovery-truncated");
105
+ if (input.rulesPartial) reasons.add("rules-partial");
106
+ if (input.skippedFiles > 0) reasons.add(`skipped-files:${input.skippedFiles}`);
107
+ if (input.rulesCrashed > 0) reasons.add(`rules-crashed:${input.rulesCrashed}`);
108
+ if (input.scopeIgnored > 0) reasons.add(`scope-ignored:${input.scopeIgnored}`);
109
+ if (input.scopeUnrecognized > 0) reasons.add(`scope-unrecognized:${input.scopeUnrecognized}`);
110
+ if (input.parseFailed > 0) reasons.add(`parse-failed:${input.parseFailed}`);
111
+ if (input.parseFallbacks && input.parseFallbacks > 0) reasons.add(`parse-fallbacks:${input.parseFallbacks}`);
112
+ if (input.scopeDegraded) reasons.add(`scope-degraded:${input.scopeDegraded}`);
113
+ if (input.runtimeIncomplete) reasons.add("runtime-incomplete");
114
+ if (input.identityIncomplete) reasons.add("identity-incomplete");
115
+ for (const reason of truncationReasons) reasons.add(`truncated:${reason}`);
116
+ const discoveryPartial = input.discoveryTruncated || input.scopeIgnored > 0 || input.scopeUnrecognized > 0 || input.scopeDegraded !== void 0;
117
+ const rulesPartial = input.rulesPartial || input.rulesCrashed > 0 || input.parseFailed > 0;
118
+ return {
119
+ partial: discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0,
120
+ analysisStatus: {
121
+ discovery: discoveryPartial ? "partial" : "complete",
122
+ rules: rulesPartial ? "partial" : "complete",
123
+ skippedFiles: input.skippedFiles,
124
+ rulesCrashed: input.rulesCrashed,
125
+ parseFallbacks: input.parseFallbacks ?? 0,
126
+ ...truncationReasons.length > 0 ? { truncationReasons } : {},
127
+ reasons: [...reasons].sort()
128
+ }
129
+ };
130
+ }
131
+ //#endregion
100
132
  //#region src/engine/runtime-corroboration.ts
101
133
  /**
102
134
  * Stamp runtime corroboration + trust levels onto findings (mutates in
@@ -863,7 +895,10 @@ function sha256$1(text) {
863
895
  return createHash("sha256").update(text).digest("hex");
864
896
  }
865
897
  function canonical(value) {
866
- return JSON.stringify(value);
898
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
899
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
900
+ const record = value;
901
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`;
867
902
  }
868
903
  function buildRunIdentity(input) {
869
904
  const inputFingerprint = sha256$1([...input.files].map((f) => f.hash ? `${f.path}:${f.hash}` : `${f.path}:${f.size}`).sort().join("\n"));
@@ -945,7 +980,7 @@ function buildEvidenceGraph(parts) {
945
980
  * scripts/sync-sarif-version.cjs and guarded by the version-consistency
946
981
  * spec. cli.ts re-exports this as CLI_VERSION.
947
982
  */
948
- const ENGINE_VERSION = "2.0.2";
983
+ const ENGINE_VERSION = "3.0.0";
949
984
  //#endregion
950
985
  //#region src/engine/contract-versions.ts
951
986
  /**
@@ -1106,7 +1141,9 @@ function basename$1(p) {
1106
1141
  * Internal files (cache, hashes) use `isRecord`; user-facing files
1107
1142
  * (config, plugins) use stricter checks that throw descriptive errors.
1108
1143
  */
1144
+ const MAX_JSON_BYTES = 16777216;
1109
1145
  function parseJsonFile(text, source, validate) {
1146
+ if (Buffer.byteLength(text, "utf8") > MAX_JSON_BYTES) throw new Error(`JSON source exceeds the ${MAX_JSON_BYTES}-byte limit: ${source}`);
1110
1147
  let parsed;
1111
1148
  try {
1112
1149
  parsed = JSON.parse(text);
@@ -1125,6 +1162,59 @@ function isRecord(v) {
1125
1162
  return typeof v === "object" && v !== null && !Array.isArray(v);
1126
1163
  }
1127
1164
  //#endregion
1165
+ //#region src/lib/fs-bounded.ts
1166
+ function closeQuiet(fd) {
1167
+ try {
1168
+ closeSync(fd);
1169
+ } catch {
1170
+ return;
1171
+ }
1172
+ }
1173
+ function readFileBounded(path, maxBytes) {
1174
+ let fd;
1175
+ try {
1176
+ const pathStat = lstatSync(path);
1177
+ if (pathStat.isSymbolicLink()) return {
1178
+ ok: false,
1179
+ reason: "symlink"
1180
+ };
1181
+ if (!pathStat.isFile()) return {
1182
+ ok: false,
1183
+ reason: "unreadable"
1184
+ };
1185
+ fd = openSync(path, 0);
1186
+ const fdStat = fstatSync(fd);
1187
+ if (!fdStat.isFile() || fdStat.size > maxBytes) return {
1188
+ ok: false,
1189
+ reason: fdStat.size > maxBytes ? "too-large" : "unreadable"
1190
+ };
1191
+ const chunks = [];
1192
+ let total = 0;
1193
+ const buffer = Buffer.allocUnsafe(Math.min(65536, maxBytes));
1194
+ while (true) {
1195
+ const read = readSync(fd, buffer, 0, buffer.length, null);
1196
+ if (read === 0) break;
1197
+ total += read;
1198
+ if (total > maxBytes) return {
1199
+ ok: false,
1200
+ reason: "too-large"
1201
+ };
1202
+ chunks.push(Buffer.from(buffer.subarray(0, read)));
1203
+ }
1204
+ return {
1205
+ ok: true,
1206
+ data: Buffer.concat(chunks)
1207
+ };
1208
+ } catch {
1209
+ return {
1210
+ ok: false,
1211
+ reason: "unreadable"
1212
+ };
1213
+ } finally {
1214
+ if (fd !== void 0) closeQuiet(fd);
1215
+ }
1216
+ }
1217
+ //#endregion
1128
1218
  //#region src/config/config-schema.ts
1129
1219
  const VALID_GATES = /* @__PURE__ */ new Set([
1130
1220
  "advisory",
@@ -1223,7 +1313,11 @@ const CONFIG_NAMES = ["mjolnir.config.json", ".mjolnir.json"];
1223
1313
  function findConfigPath(root) {
1224
1314
  for (const name of CONFIG_NAMES) {
1225
1315
  const p = join(root, name);
1226
- if (existsSync(p)) return p;
1316
+ try {
1317
+ if (existsSync(p) && !lstatSync(p).isSymbolicLink()) return p;
1318
+ } catch {
1319
+ continue;
1320
+ }
1227
1321
  }
1228
1322
  return null;
1229
1323
  }
@@ -1243,7 +1337,10 @@ function loadConfig(root, options = {}) {
1243
1337
  const p = join(root, name);
1244
1338
  if (!existsSync(p)) continue;
1245
1339
  try {
1246
- const parsed = parseJsonFile(readFileSync(p, "utf8"), p, (v) => isRecord(v));
1340
+ if (!existsSync(p) || lstatSync(p).isSymbolicLink()) continue;
1341
+ const read = readFileBounded(p, 1048576);
1342
+ if (!read.ok) throw new Error("config source could not be read safely");
1343
+ const parsed = parseJsonFile(read.data.toString("utf8"), p, (v) => isRecord(v));
1247
1344
  return {
1248
1345
  config: parsed,
1249
1346
  path: p,
@@ -1489,6 +1586,10 @@ function globBody(glob) {
1489
1586
  }
1490
1587
  return re;
1491
1588
  }
1589
+ /** Anchored full-path glob — the primitive the matcher builds on. */
1590
+ function globToRegExp(glob) {
1591
+ return new RegExp(`^${globBody(glob)}$`);
1592
+ }
1492
1593
  /** Defaults-only matcher for callers that have no scan context. */
1493
1594
  const DEFAULT_IGNORE_MATCHER = createMatcherFromPatterns([]);
1494
1595
  /**
@@ -1538,8 +1639,9 @@ function sharedWalk(options) {
1538
1639
  }
1539
1640
  const full = join(dir, entry.name);
1540
1641
  const rel = relative(options.root, full).replaceAll("\\", "/");
1642
+ if (entry.isDirectory() && options.skipDirs.includes(entry.name)) continue;
1541
1643
  if (options.ignoreMatcher.isIgnored(rel)) {
1542
- options.onIgnored?.();
1644
+ if (!rel.startsWith(".mjolnir/")) options.onIgnored?.(rel);
1543
1645
  continue;
1544
1646
  }
1545
1647
  if (entry.isSymbolicLink()) {
@@ -1547,7 +1649,6 @@ function sharedWalk(options) {
1547
1649
  continue;
1548
1650
  }
1549
1651
  if (entry.isDirectory()) {
1550
- if (options.skipDirs.includes(entry.name)) continue;
1551
1652
  if (rel.split("/").length > LIMITS$1.maxDepth) {
1552
1653
  options.onSkipped("max-depth");
1553
1654
  continue;
@@ -1565,7 +1666,7 @@ function sharedWalk(options) {
1565
1666
  } catch {
1566
1667
  options.onSkipped("stat-failed");
1567
1668
  }
1568
- else if (entry.isFile()) options.onUnrecognized?.();
1669
+ else if (entry.isFile()) options.onUnrecognized?.(rel);
1569
1670
  }
1570
1671
  };
1571
1672
  walk(options.root);
@@ -3667,7 +3768,7 @@ const SCAN_ADAPTERS = [
3667
3768
  * the adapter that claims it; per-adapter caps still apply.
3668
3769
  */
3669
3770
  function discoverAllTestFiles(ctx, languageAdapters, buckets, fixtureDirMemo) {
3670
- const walkSkips = languageAdapters.reduce((common, a) => common.filter((name) => a.dirSkips.includes(name)), languageAdapters[0]?.dirSkips ?? []);
3771
+ const walkSkips = [.../* @__PURE__ */ new Set([...languageAdapters.reduce((common, a) => common.filter((name) => a.dirSkips.includes(name)), languageAdapters[0]?.dirSkips ?? []), ".mjolnir"])];
3671
3772
  sharedWalk({
3672
3773
  root: ctx.workspace.root,
3673
3774
  deadline: ctx.deadline,
@@ -3693,10 +3794,35 @@ function discoverAllTestFiles(ctx, languageAdapters, buckets, fixtureDirMemo) {
3693
3794
  },
3694
3795
  isFull: () => languageAdapters.every((a) => (buckets.get(a.id)?.length ?? 0) >= ctx.maxFiles),
3695
3796
  fixtureDirMemo,
3696
- onIgnored: ctx.onIgnored,
3697
- onUnrecognized: ctx.onUnrecognized
3797
+ onIgnored: (path) => {
3798
+ if (isScopeRelevantIgnored(path)) ctx.onIgnored?.(path);
3799
+ },
3800
+ onUnrecognized: (path) => {
3801
+ if (typeof path === "string" && isUnrecognizedSourceCandidate(path)) ctx.onUnrecognized?.(path);
3802
+ }
3698
3803
  });
3699
3804
  }
3805
+ function isScopeRelevantIgnored(path) {
3806
+ const name = path.replaceAll("\\", "/").split("/").pop() ?? path;
3807
+ return /\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml|min\.js)$/i.test(name);
3808
+ }
3809
+ function isUnrecognizedSourceCandidate(path) {
3810
+ const normalized = path.replaceAll("\\", "/");
3811
+ const name = normalized.split("/").pop() ?? normalized;
3812
+ if (normalized.includes(".github/workflows/") || name === "azure-pipelines.yml" || name === "Jenkinsfile") return false;
3813
+ const extensionIndex = name.lastIndexOf(".");
3814
+ const stem = extensionIndex > 0 ? name.slice(0, extensionIndex) : name;
3815
+ if (name === "mjolnir.config.json" || name === ".mjolnir.json" || name === "package.json" || name === "package-lock.json" || name === "pnpm-lock.yaml" || name === "yarn.lock" || [
3816
+ "playwright",
3817
+ "vitest",
3818
+ "vite",
3819
+ "eslint",
3820
+ "tsconfig",
3821
+ "jsconfig"
3822
+ ].some((prefix) => stem === prefix || stem.startsWith(`${prefix}.`))) return false;
3823
+ const testLike = /(?:^|\/)__tests__\//i.test(normalized) || /\.(?:spec|test)\.[cm]?[jt]sx?$/i.test(name);
3824
+ return /\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml)$/i.test(name) && testLike;
3825
+ }
3700
3826
  /** Whether ANY shipped adapter would discover this path as a test file. */
3701
3827
  function isKnownTestFile(path) {
3702
3828
  return SCAN_ADAPTERS.some((a) => a.isTestFile(path));
@@ -3760,17 +3886,14 @@ const SEARCHED_FOR = [
3760
3886
  * The fix: resolve git ONCE per process to an ABSOLUTE path by walking
3761
3887
  * the PATH directly (never consulting the CWD), verify the candidate is
3762
3888
  * an existing file, and pass that path to execFileSync. Resolution is
3763
- * memoized; a failure to find a real git anywhere on PATH degrades to
3764
- * the plain name (previous behavior) with the resolution error recorded
3765
- * — degraded git data already means full-file attribution, never a
3766
- * crash.
3889
+ * memoized; a failure to find a real git anywhere on PATH is recorded
3890
+ * and callers degrade without invoking a bare name.
3767
3891
  */
3768
3892
  let resolvedGit = void 0;
3769
3893
  /**
3770
3894
  * The absolute path of the git binary Mjölnir will invoke, or null when
3771
- * PATH carries no executable `git` at all (S1 degradation: callers fall
3772
- * back to the bare name and their own try/catch — same honest degrade
3773
- * as before, minus the CWD-hijack surface).
3895
+ * PATH carries no executable `git` at all. Callers must degrade without
3896
+ * invoking a bare name.
3774
3897
  */
3775
3898
  function resolveGitPath() {
3776
3899
  if (resolvedGit !== void 0) return resolvedGit;
@@ -3803,7 +3926,8 @@ function resolveGitPath() {
3803
3926
  * the previous inline `git()` helpers, with the hijack surface closed.
3804
3927
  */
3805
3928
  function runGit(root, args) {
3806
- const exe = resolveGitPath() ?? "git";
3929
+ const exe = resolveGitPath();
3930
+ if (!exe) return null;
3807
3931
  try {
3808
3932
  return execFileSync(exe, [
3809
3933
  "-C",
@@ -3939,7 +4063,7 @@ function computeChangedScope(root, baseBranch) {
3939
4063
  "--",
3940
4064
  ...chunk
3941
4065
  ]);
3942
- if (output === null) continue;
4066
+ if (output === null) return null;
3943
4067
  const sections = output.split("\ndiff --git ");
3944
4068
  for (const section of sections) {
3945
4069
  const body = section.startsWith("diff --git ") ? section : `diff --git ${section}`;
@@ -3967,6 +4091,11 @@ function computeChangedScope(root, baseBranch) {
3967
4091
  "--unified=0",
3968
4092
  "HEAD"
3969
4093
  ], changedFiles);
4094
+ if (committedDiffs === null || workingDiffs === null) return {
4095
+ changed: {},
4096
+ degraded: true,
4097
+ reason: "diff-failed"
4098
+ };
3970
4099
  for (const file of changedFiles) {
3971
4100
  const committedDiff = committedDiffs.get(file);
3972
4101
  const workingDiff = workingDiffs.get(file);
@@ -3992,8 +4121,11 @@ const LIMITS_MAX_LINES = 1e6;
3992
4121
  function allLinesOf(root, file) {
3993
4122
  const full = join(root, file);
3994
4123
  try {
3995
- if (statSync(full).size > LIMITS$1.maxFileBytes) return null;
3996
- const lineCount = readFileSync(full, "utf8").split("\n").length;
4124
+ const stat = lstatSync(full);
4125
+ if (stat.isSymbolicLink() || !stat.isFile()) return null;
4126
+ const read = readFileBounded(full, LIMITS$1.maxFileBytes);
4127
+ if (!read.ok) return null;
4128
+ const lineCount = read.data.toString("utf8").split("\n").length;
3997
4129
  return Array.from({ length: lineCount }, (_, i) => i + 1);
3998
4130
  } catch {
3999
4131
  return null;
@@ -11946,6 +12078,22 @@ function parseManifest(filePath) {
11946
12078
  if (filePath.endsWith("pyproject.toml")) return parsePyprojectToml(filePath);
11947
12079
  if (filePath.endsWith("pom.xml")) return parsePomXml(filePath);
11948
12080
  }
12081
+ /**
12082
+ * Given a set of starting files (e.g. test files), return all files
12083
+ * transitively reachable through the dependency graph.
12084
+ */
12085
+ function getReachableFiles(fromFiles, graph) {
12086
+ const reachable = /* @__PURE__ */ new Set();
12087
+ const stack = [...fromFiles];
12088
+ while (stack.length > 0) {
12089
+ const current = stack.pop();
12090
+ if (current === void 0) continue;
12091
+ if (reachable.has(current)) continue;
12092
+ reachable.add(current);
12093
+ for (const dep of graph.getDependencies(current)) if (!reachable.has(dep)) stack.push(dep);
12094
+ }
12095
+ return [...reachable].sort();
12096
+ }
11949
12097
  //#endregion
11950
12098
  //#region src/engine/incremental-analysis.ts
11951
12099
  /**
@@ -12087,6 +12235,122 @@ function configurableAggregation(results, config) {
12087
12235
  };
12088
12236
  }
12089
12237
  //#endregion
12238
+ //#region src/lib/fs-atomic.ts
12239
+ /**
12240
+ * Atomic file writes (audit S9).
12241
+ *
12242
+ * Every durability-critical write in Mjölnir (baseline, stats, badge,
12243
+ * TRIAGE.md, scaffolded rule files) used to hand-roll
12244
+ * `writeFileSync(path, data)` — a crash mid-write left a TRUNCATED file
12245
+ * at the real path, and a subsequent read (diff, badge endpoint) served
12246
+ * confident nonsense from it.
12247
+ *
12248
+ * `writeFileAtomic` writes to a temp sibling, then RENAMES. On the same
12249
+ * volume rename is atomic: readers see either the complete old file or
12250
+ * the complete new file, never a half-written one. The temp name is
12251
+ * created with `wx` (exclusive) so concurrent writers cannot interleave,
12252
+ * stale temps are cleaned up on failure, and on Windows the rename is
12253
+ * retried briefly because a concurrent reader can hold the destination
12254
+ * open (EBUSY/EPERM).
12255
+ */
12256
+ function atomicTempPath(path) {
12257
+ return `${path}.mjolnir-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}.tmp`;
12258
+ }
12259
+ /**
12260
+ * Atomically replace `path` with `data`.
12261
+ */
12262
+ function writeFileAtomic(path, data, opts = {}) {
12263
+ const dir = dirname(path);
12264
+ if (opts.mkdirs !== false && !existsSync(dir)) mkdirSync(dir, { recursive: true });
12265
+ const tmp = atomicTempPath(path);
12266
+ let fd;
12267
+ try {
12268
+ fd = openSync(tmp, "wx", opts.mode ?? 420);
12269
+ writeSync(fd, data, null, opts.encoding ?? "utf8");
12270
+ } finally {
12271
+ if (fd !== void 0) closeSync(fd);
12272
+ }
12273
+ try {
12274
+ renameWithWindowsRetry(tmp, path);
12275
+ } catch (err) {
12276
+ try {
12277
+ if (existsSync(tmp)) unlinkSync(tmp);
12278
+ } catch {}
12279
+ throw err;
12280
+ }
12281
+ }
12282
+ /**
12283
+ * renameSync retry loop for Windows: a concurrent reader (another scan,
12284
+ * a badge endpoint, an editor) holding the destination open makes
12285
+ * rename fail with EBUSY/EPERM. A short bounded retry closes the race
12286
+ * without turning an atomic swap into a partial write.
12287
+ *
12288
+ * Internal contract test hook: the platform check keeps this loop off
12289
+ * the POSIX hot path; on win32 the EBUSY/EPERM arms are exercised by
12290
+ * the fs-atomic-retry spec (mocked renameSync).
12291
+ */
12292
+ const RENAME_RETRIES = 8;
12293
+ const RENAME_RETRY_DELAY_MS = 25;
12294
+ /** Synchronous sleep that does not spin the CPU. */
12295
+ function sleepSync(ms) {
12296
+ try {
12297
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
12298
+ } catch {
12299
+ const until = Date.now() + ms;
12300
+ while (Date.now() < until);
12301
+ }
12302
+ }
12303
+ function renameWithWindowsRetry(from, to) {
12304
+ for (let attempt = 0;; attempt++) try {
12305
+ renameSync(from, to);
12306
+ return;
12307
+ } catch (err) {
12308
+ const code = err?.code;
12309
+ if (process.platform === "win32" && (code === "EBUSY" || code === "EPERM") && attempt < RENAME_RETRIES) {
12310
+ sleepSync(RENAME_RETRY_DELAY_MS);
12311
+ continue;
12312
+ }
12313
+ throw err;
12314
+ }
12315
+ }
12316
+ const STALE_TEMP_AGE_MS = 864e5;
12317
+ const TEMP_PID_RE = /^.+\.mjolnir-([1-9]\d{0,9})-(\d{13})-[0-9a-f]{8}\.tmp$/;
12318
+ function sweepStaleTempFiles(dir) {
12319
+ let swept = 0;
12320
+ let entries;
12321
+ try {
12322
+ entries = readdirSync(dir);
12323
+ } catch {
12324
+ return 0;
12325
+ }
12326
+ const cutoff = Date.now() - STALE_TEMP_AGE_MS;
12327
+ for (const entry of entries) {
12328
+ const match = TEMP_PID_RE.exec(entry);
12329
+ if (!match || Number(match[2]) > cutoff) continue;
12330
+ const path = join(dir, entry);
12331
+ try {
12332
+ const stat = lstatSync(path);
12333
+ if (!stat.isFile() || stat.mtimeMs > cutoff) continue;
12334
+ if (pidAlive(Number(match[1]))) continue;
12335
+ unlinkSync(path);
12336
+ swept++;
12337
+ } catch {
12338
+ continue;
12339
+ }
12340
+ }
12341
+ return swept;
12342
+ }
12343
+ function pidAlive(pid) {
12344
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 2147483647) return true;
12345
+ if (pid === process.pid) return true;
12346
+ try {
12347
+ process.kill(pid, 0);
12348
+ return true;
12349
+ } catch (err) {
12350
+ return err?.code !== "ESRCH";
12351
+ }
12352
+ }
12353
+ //#endregion
12090
12354
  //#region src/engine/scan-cache.ts
12091
12355
  /**
12092
12356
  * Local incremental scan cache (Beta-to-Stable 1.0 plan, M5.2 / A-2).
@@ -12122,6 +12386,17 @@ const MAX_ENTRIES$1 = 4096;
12122
12386
  * serialized size; the newest entries win (real LRU-by-use).
12123
12387
  */
12124
12388
  const MAX_TOTAL_BYTES = 33554432;
12389
+ const MAX_FINDINGS_PER_ENTRY = 1e4;
12390
+ function isCachedFinding(value) {
12391
+ if (!isRecord(value)) return false;
12392
+ 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";
12393
+ }
12394
+ function isCacheFile(value) {
12395
+ if (!isRecord(value) || value["version"] !== CACHE_VERSION) return false;
12396
+ const entries = value["entries"];
12397
+ if (!isRecord(entries) || Object.keys(entries).length > MAX_ENTRIES$1) return false;
12398
+ return Object.values(entries).every((entry) => isRecord(entry) && Array.isArray(entry["findings"]) && entry["findings"].length <= MAX_FINDINGS_PER_ENTRY && entry["findings"].every(isCachedFinding));
12399
+ }
12125
12400
  /** sha256 hex of a string — the only hash this module needs. */
12126
12401
  function sha256(text) {
12127
12402
  return createHash("sha256").update(text).digest("hex");
@@ -12220,12 +12495,15 @@ function createScanCache(root) {
12220
12495
  let totalBytes = 0;
12221
12496
  try {
12222
12497
  if (existsSync(file)) {
12223
- entries = parseJsonFile(readFileSync(file, "utf8"), file, (v) => isRecord(v) && v["version"] === CACHE_VERSION && isRecord(v["entries"])).entries;
12498
+ const stat = lstatSync(file);
12499
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_TOTAL_BYTES) throw new Error("invalid cache file");
12500
+ entries = parseJsonFile(readFileSync(file, "utf8"), file, isCacheFile).entries;
12224
12501
  for (const [k, v] of Object.entries(entries)) {
12225
- const size = JSON.stringify(v).length + k.length + 4;
12502
+ const size = Buffer.byteLength(JSON.stringify(v), "utf8") + k.length + 4;
12226
12503
  entryBytes.set(k, size);
12227
12504
  totalBytes += size;
12228
12505
  }
12506
+ if (totalBytes > MAX_TOTAL_BYTES) throw new Error("cache byte budget exceeded");
12229
12507
  }
12230
12508
  } catch {
12231
12509
  entries = {};
@@ -12251,11 +12529,13 @@ function createScanCache(root) {
12251
12529
  },
12252
12530
  store(key, findings, fileBudgetExceeded) {
12253
12531
  if (fileBudgetExceeded) return;
12532
+ if (findings.length > MAX_FINDINGS_PER_ENTRY) return;
12533
+ const entryJson = JSON.stringify(findings);
12534
+ const newBytes = Buffer.byteLength(entryJson, "utf8") + key.length + 4;
12535
+ if (newBytes > MAX_TOTAL_BYTES) return;
12254
12536
  const replacedBytes = entryBytes.get(key) ?? 0;
12255
12537
  delete entries[key];
12256
- const entryJson = JSON.stringify(findings);
12257
12538
  entries[key] = { findings: structuredClone(findings) };
12258
- const newBytes = entryJson.length + key.length + 4;
12259
12539
  entryBytes.set(key, newBytes);
12260
12540
  totalBytes = totalBytes - replacedBytes + newBytes;
12261
12541
  let count = Object.keys(entries).length;
@@ -12272,10 +12552,10 @@ function createScanCache(root) {
12272
12552
  if (!dirty) return;
12273
12553
  try {
12274
12554
  mkdirSync(dir, { recursive: true });
12275
- writeFileSync(file, JSON.stringify({
12555
+ writeFileAtomic(file, JSON.stringify({
12276
12556
  version: CACHE_VERSION,
12277
12557
  entries
12278
- }), "utf8");
12558
+ }), { encoding: "utf8" });
12279
12559
  } catch {}
12280
12560
  }
12281
12561
  };
@@ -12521,7 +12801,7 @@ const HEADLINES = {
12521
12801
  critical: "The hammer is cracked — {n} findings break its edge.",
12522
12802
  warning: "The hammer holds — but {n} findings weigh it down.",
12523
12803
  trusted: "Held in worthy hands — {n} findings remain.",
12524
- forged: "Forged complete. Zero findings. The suite is clean.",
12804
+ forged: "Static score 100 — no findings on the analyzed surface.",
12525
12805
  unmeasured: "No tests found — the hammer cannot be weighed."
12526
12806
  };
12527
12807
  const RUNES = {
@@ -14062,12 +14342,12 @@ function loadSuppressions(root) {
14062
14342
  };
14063
14343
  }
14064
14344
  function renderSuppressions(report) {
14065
- if (report.total === 0) return "\nNo suppressed findings. Full transparency maintained.\n";
14345
+ if (report.total === 0) return "\nNo configured suppression entries.\n";
14066
14346
  const lines = [
14067
14347
  "",
14068
14348
  sectionHeader("QUALITY GOVERNANCE", ui),
14069
14349
  "",
14070
- `Suppressed findings: ${report.total}`,
14350
+ `Configured entries: ${report.total}`,
14071
14351
  `Active: ${report.active}`,
14072
14352
  `Expired: ${report.expired}`,
14073
14353
  ""
@@ -14256,6 +14536,31 @@ function loadPlugins(root, gateOpen = false) {
14256
14536
  * next catalog render (locked by tests/local-rules.spec.ts).
14257
14537
  */
14258
14538
  const LOCAL_RULES_DIR = "mjolnir-rules";
14539
+ const MAX_EXTERNAL_RULES = 1e3;
14540
+ const MAX_RULE_FILE_BYTES = 1048576;
14541
+ const MAX_PATTERNS_PER_RULE = 100;
14542
+ function hasNestedQuantifier(pattern) {
14543
+ for (let i = 0; i < pattern.length; i++) {
14544
+ if (pattern.charAt(i) !== "(") continue;
14545
+ let bodyHasQuantifier = false;
14546
+ let j = i + 1;
14547
+ for (; j < pattern.length && pattern.charAt(j) !== ")"; j++) {
14548
+ const bodyChar = pattern.charAt(j);
14549
+ if (bodyChar === "+" || bodyChar === "*" || bodyChar === "?") {
14550
+ bodyHasQuantifier = true;
14551
+ break;
14552
+ }
14553
+ }
14554
+ if (bodyHasQuantifier) {
14555
+ while (j < pattern.length && pattern.charAt(j) !== ")") j++;
14556
+ if (j + 1 < pattern.length) {
14557
+ const next = pattern.charAt(j + 1);
14558
+ if (next === "+" || next === "*" || next === "{") return true;
14559
+ }
14560
+ }
14561
+ }
14562
+ return false;
14563
+ }
14259
14564
  const ALLOWED_CATEGORIES = /* @__PURE__ */ new Set([
14260
14565
  "QA-TEST",
14261
14566
  "QA-TQUAL",
@@ -14285,7 +14590,7 @@ const ALLOWED_QA_IMPACTS = /* @__PURE__ */ new Set([
14285
14590
  * Missing directory → empty result (not an error — most workspaces
14286
14591
  * carry none).
14287
14592
  */
14288
- async function loadLocalRules(root, gateOpen = true) {
14593
+ async function loadLocalRules(root, gateOpen = false) {
14289
14594
  const result = {
14290
14595
  rules: [],
14291
14596
  errors: [],
@@ -14293,6 +14598,15 @@ async function loadLocalRules(root, gateOpen = true) {
14293
14598
  };
14294
14599
  const dir = join(root, LOCAL_RULES_DIR);
14295
14600
  if (!existsSync(dir)) return result;
14601
+ try {
14602
+ if (lstatSync(dir).isSymbolicLink()) {
14603
+ result.errors.push(`external rules directory "${LOCAL_RULES_DIR}/" is a symlink — skipped`);
14604
+ return result;
14605
+ }
14606
+ } catch (err) {
14607
+ result.errors.push(`external rules directory "${LOCAL_RULES_DIR}/" could not be inspected: ${err instanceof Error ? err.message : String(err)}`);
14608
+ return result;
14609
+ }
14296
14610
  let entries;
14297
14611
  try {
14298
14612
  entries = readdirSync(dir);
@@ -14301,7 +14615,20 @@ async function loadLocalRules(root, gateOpen = true) {
14301
14615
  return result;
14302
14616
  }
14303
14617
  for (const entry of entries.sort()) {
14618
+ if (result.rules.length >= MAX_EXTERNAL_RULES) {
14619
+ result.errors.push(`external rule budget exceeded (${MAX_EXTERNAL_RULES})`);
14620
+ break;
14621
+ }
14304
14622
  const path = join(dir, entry);
14623
+ try {
14624
+ if (lstatSync(path).isSymbolicLink()) {
14625
+ result.errors.push(`external rule "${LOCAL_RULES_DIR}/${entry}" is a symlink — skipped`);
14626
+ continue;
14627
+ }
14628
+ } catch (err) {
14629
+ result.errors.push(`external rule "${LOCAL_RULES_DIR}/${entry}" could not be inspected: ${err instanceof Error ? err.message : String(err)}`);
14630
+ continue;
14631
+ }
14305
14632
  if (entry.endsWith(".json")) loadJsonRule(path, result);
14306
14633
  else if (entry.endsWith(".mjs") || entry.endsWith(".js")) {
14307
14634
  if (!gateOpen) result.skipped.push(`${LOCAL_RULES_DIR}/${entry}`);
@@ -14314,6 +14641,10 @@ function loadJsonRule(path, result) {
14314
14641
  const name = `${LOCAL_RULES_DIR}/${path.split(/[\\/]/).pop()}`;
14315
14642
  let raw;
14316
14643
  try {
14644
+ if (lstatSync(path).size > MAX_RULE_FILE_BYTES) {
14645
+ result.errors.push(`external rule "${name}" exceeds the file size budget`);
14646
+ return;
14647
+ }
14317
14648
  raw = JSON.parse(readFileSync(path, "utf8"));
14318
14649
  } catch (err) {
14319
14650
  result.errors.push(`external rule "${name}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
@@ -14334,7 +14665,7 @@ function loadJsonRule(path, result) {
14334
14665
  return;
14335
14666
  }
14336
14667
  const patterns = decl["patterns"];
14337
- if (!Array.isArray(patterns) || patterns.length === 0 || !patterns.every((p) => typeof p === "string" && p.length > 0)) {
14668
+ if (!Array.isArray(patterns) || patterns.length === 0 || patterns.length > MAX_PATTERNS_PER_RULE || !patterns.every((p) => typeof p === "string" && p.length > 0)) {
14338
14669
  result.errors.push(`external rule ${id} must declare a non-empty "patterns" array of regex source strings.`);
14339
14670
  return;
14340
14671
  }
@@ -14350,6 +14681,10 @@ function loadJsonRule(path, result) {
14350
14681
  result.errors.push(`external rule ${id} has a pattern longer than 512 chars — likely a copy-paste error.`);
14351
14682
  return;
14352
14683
  }
14684
+ if (hasNestedQuantifier(p)) {
14685
+ result.errors.push(`external rule ${id} has a nested quantifier that may cause catastrophic backtracking`);
14686
+ return;
14687
+ }
14353
14688
  const quantifiers = p.match(/[+*?]|\{\d/g);
14354
14689
  if (quantifiers && quantifiers.length > 64) {
14355
14690
  result.errors.push(`external rule ${id} has a pattern with more than 64 quantifier/wildcard tokens — likely catastrophic backtracking.`);
@@ -14547,6 +14882,8 @@ var scan_pipeline_exports = /* @__PURE__ */ __exportAll({
14547
14882
  summarizeForensicVerdicts: () => summarizeForensicVerdicts
14548
14883
  });
14549
14884
  const UNIVERSAL_RULES = RULES.map(asUniversal);
14885
+ const DEFAULT_MAX_DURATION_MS = 6e5;
14886
+ const MAX_DURATION_MS = 36e5;
14550
14887
  /** Registered rule IDs — used to warn on unknown severityOverrides keys (M4). */
14551
14888
  const KNOWN_RULE_IDS = new Set(RULES.map((r) => r.id));
14552
14889
  /**
@@ -14763,7 +15100,9 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14763
15100
  let testDeclarationCount = 0;
14764
15101
  let rulesPartial = false;
14765
15102
  let parseFailed = 0;
15103
+ let parseFallbacks = 0;
14766
15104
  let scanned = 0;
15105
+ let analyzed = 0;
14767
15106
  for (const path of testFiles) {
14768
15107
  if (Date.now() > deadline) {
14769
15108
  rulesPartial = true;
@@ -14777,7 +15116,12 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14777
15116
  if (!isCiAdapter) testFileCount++;
14778
15117
  let text;
14779
15118
  try {
14780
- text = readFileSync(path, "utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
15119
+ const readResult = readFileBounded(path, LIMITS$1.maxFileBytes);
15120
+ if (!readResult.ok) {
15121
+ skippedFiles++;
15122
+ continue;
15123
+ }
15124
+ text = readResult.data.toString("utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
14781
15125
  } catch {
14782
15126
  skippedFiles++;
14783
15127
  continue;
@@ -14811,6 +15155,7 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14811
15155
  const cachedFindings = cache.lookup(cacheKey);
14812
15156
  if (cachedFindings) {
14813
15157
  for (const f of cachedFindings) findings.push(f);
15158
+ analyzed++;
14814
15159
  continue;
14815
15160
  }
14816
15161
  hooks.onProgress?.({
@@ -14837,10 +15182,12 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14837
15182
  }
14838
15183
  const actualMode = parsed ? "ast" : "regex";
14839
15184
  if (wantsAst && actualMode === "regex") {
15185
+ parseFallbacks++;
14840
15186
  cacheKey = fileCacheKey(rulesDigest, text, identity(actualMode));
14841
15187
  const fallbackFindings = cache.lookup(cacheKey);
14842
15188
  if (fallbackFindings) {
14843
15189
  for (const f of fallbackFindings) findings.push(f);
15190
+ analyzed++;
14844
15191
  continue;
14845
15192
  }
14846
15193
  }
@@ -14871,8 +15218,10 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14871
15218
  fileBudgetExceeded = true;
14872
15219
  }
14873
15220
  });
15221
+ if (!fileRuleFailed && !fileBudgetExceeded) analyzed++;
14874
15222
  if (!fileRuleFailed) cache.store(cacheKey, findings.slice(findingsStart), fileBudgetExceeded);
14875
15223
  } catch {
15224
+ if (wantsAst) parseFallbacks++;
14876
15225
  skippedFiles++;
14877
15226
  parseFailed++;
14878
15227
  } finally {
@@ -14885,7 +15234,9 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14885
15234
  testDeclarationCount,
14886
15235
  rulesPartial,
14887
15236
  parseFailed,
14888
- scanned
15237
+ parseFallbacks,
15238
+ scanned,
15239
+ analyzed
14889
15240
  };
14890
15241
  }
14891
15242
  /**
@@ -14931,13 +15282,15 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
14931
15282
  for (const w of warnings) hooks.onConfigWarning?.(w);
14932
15283
  applySeverityOverrides(findings, config);
14933
15284
  const active = loadSuppressions(workspace.root).entries.filter((e) => e.status === "active");
14934
- const suppressionCount = active.length;
15285
+ hooks.onPreSuppressionFindings?.([...findings]);
15286
+ let suppressionCount = 0;
14935
15287
  if (active.length > 0) {
14936
15288
  const ruleOnly = new Set(active.filter((e) => !e.files?.length).map((e) => e.ruleId));
14937
15289
  const kept = findings.filter((f) => {
14938
15290
  if (ruleOnly.has(f.ruleId)) return false;
14939
15291
  return !active.some((e) => e.files?.length && e.ruleId === f.ruleId && e.files.some((g) => pathMatchesGlob(f.file, g)));
14940
15292
  });
15293
+ suppressionCount = findings.length - kept.length;
14941
15294
  findings.length = 0;
14942
15295
  for (const f of kept) findings.push(f);
14943
15296
  }
@@ -14961,6 +15314,7 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
14961
15314
  for (const f of findings) f.fixGroupId = f.ruleId;
14962
15315
  const discoveredReport = discoverAndParseRuntimeReport(scanRoot.root);
14963
15316
  const runtimeReportPath = discoveredReport?.path;
15317
+ const runtimeIncomplete = discoveredReport !== void 0 && discoveredReport.report.analysisComplete !== true;
14964
15318
  let forensicVerdicts;
14965
15319
  if (discoveredReport && discoveredReport.report.analysisComplete === true) try {
14966
15320
  buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
@@ -14973,6 +15327,7 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
14973
15327
  suppressionCount,
14974
15328
  frameworks,
14975
15329
  runtimeReportPath,
15330
+ runtimeIncomplete,
14976
15331
  forensicVerdicts,
14977
15332
  config
14978
15333
  };
@@ -15041,10 +15396,13 @@ function assembleScanResult(o) {
15041
15396
  if (o.scopeIgnored > 0) scopeReasons.push(`ignored:${o.scopeIgnored}`);
15042
15397
  if (o.scopeUnrecognized > 0) scopeReasons.push(`unrecognized:${o.scopeUnrecognized}`);
15043
15398
  if (o.parseFailed > 0) scopeReasons.push(`parseFailed:${o.parseFailed}`);
15399
+ if (o.skippedFiles > 0) scopeReasons.push(`skipped:${o.skippedFiles}`);
15400
+ if (o.scopeInfo.degraded) scopeReasons.push(`degraded:${o.scopeInfo.degraded}`);
15401
+ if (o.runtimeIncomplete) scopeReasons.push("runtime-incomplete");
15044
15402
  for (const reason of o.truncationReasons) scopeReasons.push(`truncated:${reason}`);
15045
15403
  const scopeIntegrity = {
15046
15404
  discovered: o.testFiles.length,
15047
- analyzed: Math.max(0, o.scanned),
15405
+ analyzed: Math.max(0, o.analyzed ?? o.scanned),
15048
15406
  ignored: o.scopeIgnored,
15049
15407
  unrecognized: o.scopeUnrecognized,
15050
15408
  parseFailed: o.parseFailed,
@@ -15053,28 +15411,59 @@ function assembleScanResult(o) {
15053
15411
  ...scopeReasons.length > 0 ? { reasons: scopeReasons } : {}
15054
15412
  };
15055
15413
  const scopeAdjustedTotal = scopeReasons.length > 0 && total >= 100 ? 99 : total;
15414
+ let identityIncomplete = false;
15056
15415
  const inputSnapshot = o.testFiles.map((p) => {
15057
15416
  const relPath = relative(o.workspace.root, p).replaceAll("\\", "/");
15058
15417
  try {
15059
- const content = readFileSync(p);
15060
- const hash = createHash("sha256").update(content).digest("hex").slice(0, 16);
15418
+ const read = readFileBounded(resolve(o.workspace.root, p), LIMITS$1.maxFileBytes);
15419
+ if (!read.ok) {
15420
+ if (existsSync(o.workspace.root)) identityIncomplete = true;
15421
+ return {
15422
+ path: relPath,
15423
+ size: 0,
15424
+ hash: "UNAVAILABLE"
15425
+ };
15426
+ }
15427
+ const hash = createHash("sha256").update(read.data).digest("hex");
15061
15428
  return {
15062
15429
  path: relPath,
15063
- size: content.length,
15430
+ size: read.data.length,
15064
15431
  hash
15065
15432
  };
15066
15433
  } catch {
15434
+ if (existsSync(o.workspace.root)) identityIncomplete = true;
15067
15435
  return {
15068
15436
  path: relPath,
15069
- size: 0
15437
+ size: 0,
15438
+ hash: "UNAVAILABLE"
15070
15439
  };
15071
15440
  }
15072
15441
  });
15073
15442
  let reportDigest;
15074
15443
  if (o.runtimeReportPath) try {
15075
- const reportBytes = readFileSync(o.runtimeReportPath);
15076
- reportDigest = createHash("sha256").update(reportBytes).digest("hex").slice(0, 16);
15077
- } catch {}
15444
+ const reportPath = resolve(o.workspace.root, o.runtimeReportPath);
15445
+ if (lstatSync(reportPath).isFile()) {
15446
+ const read = readFileBounded(reportPath, LIMITS$1.maxFileBytes);
15447
+ if (!read.ok) identityIncomplete = true;
15448
+ else reportDigest = createHash("sha256").update(read.data).digest("hex");
15449
+ }
15450
+ } catch {
15451
+ identityIncomplete = true;
15452
+ }
15453
+ const completion = deriveCompletion({
15454
+ discoveryTruncated: o.discoveryTruncated,
15455
+ rulesPartial: o.rulesPartial,
15456
+ skippedFiles: o.skippedFiles,
15457
+ rulesCrashed: o.rulesCrashed,
15458
+ truncationReasons: o.truncationReasons,
15459
+ scopeIgnored: o.scopeIgnored,
15460
+ scopeUnrecognized: o.scopeUnrecognized,
15461
+ parseFailed: o.parseFailed,
15462
+ parseFallbacks: o.parseFallbacks ?? 0,
15463
+ ...o.scopeInfo.degraded !== void 0 ? { scopeDegraded: o.scopeInfo.degraded } : {},
15464
+ ...o.runtimeIncomplete !== void 0 ? { runtimeIncomplete: o.runtimeIncomplete } : {},
15465
+ identityIncomplete
15466
+ });
15078
15467
  const runIdentity = buildRunIdentity({
15079
15468
  files: inputSnapshot,
15080
15469
  rules: [...o.REVISION_BY_RULE_ID.entries()].map(([id, detectorRevision]) => ({
@@ -15092,13 +15481,14 @@ function assembleScanResult(o) {
15092
15481
  const evidenceGraph = buildEvidenceGraph({ runId: runIdentity });
15093
15482
  const hasTests = o.testFileCount > 0 && o.testDeclarationCount > 0;
15094
15483
  const suiteInvalidatedBy = [...new Set(o.findings.filter((f) => SUITE_INVALIDATING_RULE_IDS.has(f.ruleId)).map((f) => f.ruleId))].sort();
15484
+ const finalScore = hasTests ? completion.partial && scopeAdjustedTotal >= 100 ? 99 : scopeAdjustedTotal : null;
15095
15485
  const result = {
15096
15486
  schemaVersion: 1,
15097
- partial: o.discoveryTruncated || o.rulesPartial || o.skippedFiles > 0 || o.rulesCrashed > 0 || o.scopeInfo.degraded !== void 0,
15487
+ partial: completion.partial,
15098
15488
  scopeIntegrity,
15099
15489
  runIdentity,
15100
15490
  evidenceGraph,
15101
- score: hasTests ? scopeAdjustedTotal : null,
15491
+ score: finalScore,
15102
15492
  ...hasTests ? {} : { reason: "no-tests-found" },
15103
15493
  frameworks: o.frameworks.frameworks,
15104
15494
  frameworkDetectionUnknown: o.frameworks.unknown,
@@ -15124,12 +15514,8 @@ function assembleScanResult(o) {
15124
15514
  file: o.cache.stats.file
15125
15515
  } } : {},
15126
15516
  analysisStatus: {
15127
- discovery: o.discoveryTruncated ? "partial" : "complete",
15128
- rules: o.rulesPartial ? "partial" : "complete",
15129
- skippedFiles: o.skippedFiles,
15130
- durationMs: elapsed,
15131
- rulesCrashed: o.rulesCrashed,
15132
- ...o.truncationReasons.size > 0 ? { truncationReasons: [...o.truncationReasons].sort() } : {}
15517
+ ...completion.analysisStatus,
15518
+ durationMs: elapsed
15133
15519
  },
15134
15520
  scoringModelVersion: SCORING_MODEL_VERSION
15135
15521
  };
@@ -15177,7 +15563,8 @@ function assembleScanResult(o) {
15177
15563
  */
15178
15564
  async function runScan(args, hooks = {}) {
15179
15565
  const started = Date.now();
15180
- const deadline = started + args.maxDurationMs;
15566
+ const requestedDuration = Number.isFinite(args.maxDurationMs) ? args.maxDurationMs : DEFAULT_MAX_DURATION_MS;
15567
+ const deadline = started + Math.min(Math.max(1, requestedDuration), MAX_DURATION_MS);
15181
15568
  const discovered = discoverWorkspace(args.target);
15182
15569
  const targetAbs = resolve(args.target);
15183
15570
  const scanRoot = discovered && discovered.root !== targetAbs && targetAbs.startsWith(discovered.root + sep) ? {
@@ -15256,6 +15643,7 @@ async function runScan(args, hooks = {}) {
15256
15643
  scopeUnrecognized++;
15257
15644
  }
15258
15645
  });
15646
+ hooks.onTestFilesDiscovered?.(testFiles);
15259
15647
  const analysis = await runFileAnalysisPhase(findings, testFiles, workspace, activeRules, hooks, cache, rulesDigest, deadline, truncationReasons, declarationsByFile, fileProvenance, (ruleId, file, error) => {
15260
15648
  rulesCrashed++;
15261
15649
  hooks.onRuleCrash?.(ruleId, file, error);
@@ -15265,7 +15653,9 @@ async function runScan(args, hooks = {}) {
15265
15653
  testDeclarationCount = analysis.testDeclarationCount;
15266
15654
  rulesPartial = analysis.rulesPartial;
15267
15655
  parseFailed = analysis.parseFailed;
15656
+ const parseFallbacks = analysis.parseFallbacks;
15268
15657
  const scanned = analysis.scanned;
15658
+ const analyzed = analysis.analyzed;
15269
15659
  const postScan = applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, declarationsByFile, testDeclarationCount, tierByRuleId, REVISION_BY_RULE_ID);
15270
15660
  testDeclarationCount = postScan.testDeclarationCount;
15271
15661
  const result = assembleScanResult({
@@ -15281,7 +15671,9 @@ async function runScan(args, hooks = {}) {
15281
15671
  scopeIgnored,
15282
15672
  scopeUnrecognized,
15283
15673
  parseFailed,
15674
+ parseFallbacks,
15284
15675
  scanned,
15676
+ analyzed,
15285
15677
  testFiles,
15286
15678
  workspace,
15287
15679
  scanRoot,
@@ -15294,6 +15686,7 @@ async function runScan(args, hooks = {}) {
15294
15686
  suppressionCount: postScan.suppressionCount,
15295
15687
  frameworks: postScan.frameworks,
15296
15688
  runtimeReportPath: postScan.runtimeReportPath,
15689
+ runtimeIncomplete: postScan.runtimeIncomplete,
15297
15690
  forensicVerdicts: postScan.forensicVerdicts,
15298
15691
  config: postScan.config,
15299
15692
  fileProvenance,
@@ -15306,4 +15699,4 @@ async function runScan(args, hooks = {}) {
15306
15699
  return result;
15307
15700
  }
15308
15701
  //#endregion
15309
- export { SCAN_ADAPTERS as $, box as A, headlineFor as B, buildFooter as C, plainContext as D, panel as E, scoreGauge as F, capForTier as G, EVIDENCE as H, shouldColorize as I, massCeiling as J, computeDimensions as K, shouldUseAscii as L, padTo as M, palette as N, sectionHeader as O, sanitizeData as P, resolveGitPath as Q, wrapText as R, FLAKE_GLYPH as S, isValidCategory as St, okIcon as T, TINT as U, BADGE_BAND as V, TRUST as W, RULES as X, RETIRED_RULE_IDS as Y, getRule as Z, summarizeForensicVerdicts as _, QA_IMPACT_LABELS as _t, applyPostScanProcessing as a, parseTsFile as at, renderSuppressions as b, deriveEvidenceLevel as bt, discoverAndParseRuntimeReport as c, createIgnoreMatcher as ct, isValidFindingRecord as d, loadConfig as dt, SEARCHED_FOR as et, pathMatchesGlob as f, isRecord as ft, selectAdapter as g, DEDUCTIONS as gt, scan_pipeline_exports as h, MEASURED_FP as ht, SUITE_INVALIDATING_RULE_IDS as i, computeCodeText as it, measure as j, severityIcon as k, discoverTestFilesPhase as l, isLintFixtureDir as lt, runScan as m, ENGINE_VERSION as mt, KNOWN_RULE_IDS as n, parseAzurePipeline as nt, assembleScanResult as o, detectFrameworks as ot, runFileAnalysisPhase as p, parseJsonFile as pt, deductionFor as q, OVERLAP_META_BY_RULE_ID as r, parseWorkflow as rt, buildUniversalRules as s, DEFAULT_IGNORE_MATCHER as st, EVIDENCE_OVERRIDES as t, isAzurePipelineFixture as tt, fallbackWorkspace as u, ConfigValidationError as ut, loadLocalRules as v, RULE_CATEGORIES as vt, nextStep as w, runForensics as x, isAdvisoryFinding as xt, loadSuppressions as y, SEVERITY_ORDER as yt, deriveScoreState as z };
15702
+ export { correlateFindings as $, plainContext as A, parseJsonFile as At, shouldUseAscii as B, isValidCategory as Bt, runForensics as C, globToRegExp as Ct, nextStep as D, loadConfig as Dt, buildFooter as E, isSuppressionActive as Et, padTo as F, QA_IMPACT_LABELS as Ft, EVIDENCE as G, deriveScoreState as H, palette as I, RULE_CATEGORIES as It, atomicTempPath as J, TINT as K, sanitizeData as L, SEVERITY_ORDER as Lt, severityIcon as M, buildEvidenceGraph as Mt, box as N, MEASURED_FP as Nt, okIcon as O, readFileBounded as Ot, measure as P, DEDUCTIONS as Pt, getReachableFiles as Q, scoreGauge as R, deriveEvidenceLevel as Rt, renderSuppressions as S, createIgnoreMatcher as St, FLAKE_GLYPH as T, ConfigValidationError as Tt, headlineFor as U, wrapText as V, BADGE_BAND as W, writeFileAtomic as X, sweepStaleTempFiles as Y, buildDependencyGraph as Z, summarizeForensicVerdicts as _, computeCodeText as _t, applyPostScanProcessing as a, massCeiling as at, loadLocalRules as b, DEFAULT_IGNORE_MATCHER as bt, discoverAndParseRuntimeReport as c, getRule as ct, isValidFindingRecord as d, SCAN_ADAPTERS as dt, classifyProvenance as et, pathMatchesGlob as f, SEARCHED_FOR as ft, selectAdapter as g, parseYamlGuarded as gt, scan_pipeline_exports as h, parseWorkflow as ht, SUITE_INVALIDATING_RULE_IDS as i, deductionFor as it, sectionHeader as j, ENGINE_VERSION as jt, panel as k, isRecord as kt, discoverTestFilesPhase as l, resolveGitPath as lt, runScan as m, parseAzurePipeline as mt, KNOWN_RULE_IDS as n, capForTier as nt, assembleScanResult as o, RETIRED_RULE_IDS as ot, runFileAnalysisPhase as p, isAzurePipelineFixture as pt, TRUST as q, OVERLAP_META_BY_RULE_ID as r, computeDimensions as rt, buildUniversalRules as s, RULES as st, EVIDENCE_OVERRIDES as t, computeAgenticProfile as tt, fallbackWorkspace as u, runGit as ut, pluginsGateOpen as v, parseTsFile as vt, sanitizeErrorText as w, isLintFixtureDir as wt, loadSuppressions as x, LIMITS$1 as xt, renderGateNotice as y, detectFrameworks as yt, shouldColorize as z, isAdvisoryFinding as zt };