mjolnir-qa 4.0.0 → 5.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.
@@ -126,6 +126,7 @@ const DEDUCTIONS = {
126
126
  //#region src/engine/completion.ts
127
127
  function deriveCompletion(input) {
128
128
  const truncationReasons = [...new Set(input.truncationReasons)].sort();
129
+ const degradations = [...input.degradations ?? []].sort((a, b) => a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0);
129
130
  const reasons = /* @__PURE__ */ new Set();
130
131
  if (input.discoveryTruncated) reasons.add("discovery-truncated");
131
132
  if (input.rulesPartial) reasons.add("rules-partial");
@@ -135,14 +136,16 @@ function deriveCompletion(input) {
135
136
  if (input.scopeUnrecognized > 0) reasons.add(`scope-unrecognized:${input.scopeUnrecognized}`);
136
137
  if (input.parseFailed > 0) reasons.add(`parse-failed:${input.parseFailed}`);
137
138
  if (input.parseFallbacks && input.parseFallbacks > 0) reasons.add(`parse-fallbacks:${input.parseFallbacks}`);
139
+ if (input.astFallbackFiles && input.astFallbackFiles > 0) reasons.add(`ast-budget-fallback-files:${input.astFallbackFiles}`);
138
140
  if (input.scopeDegraded) reasons.add(`scope-degraded:${input.scopeDegraded}`);
139
141
  if (input.runtimeIncomplete) reasons.add("runtime-incomplete");
140
142
  if (input.identityIncomplete) reasons.add("identity-incomplete");
141
143
  for (const reason of truncationReasons) reasons.add(`truncated:${reason}`);
144
+ for (const entry of degradations) reasons.add(`degraded:${entry.reason}:${entry.count}`);
142
145
  const discoveryPartial = input.discoveryTruncated || input.scopeIgnored > 0 || input.scopeUnrecognized > 0 || input.scopeDegraded !== void 0;
143
146
  const rulesPartial = input.rulesPartial || input.rulesCrashed > 0 || input.parseFailed > 0;
144
147
  return {
145
- partial: discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0,
148
+ partial: discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0 || degradations.length > 0,
146
149
  analysisStatus: {
147
150
  discovery: discoveryPartial ? "partial" : "complete",
148
151
  rules: rulesPartial ? "partial" : "complete",
@@ -150,11 +153,49 @@ function deriveCompletion(input) {
150
153
  rulesCrashed: input.rulesCrashed,
151
154
  parseFallbacks: input.parseFallbacks ?? 0,
152
155
  ...truncationReasons.length > 0 ? { truncationReasons } : {},
156
+ ...degradations.length > 0 ? { degradations } : {},
153
157
  reasons: [...reasons].sort()
154
158
  }
155
159
  };
156
160
  }
157
161
  //#endregion
162
+ //#region src/engine/degradation-ledger.ts
163
+ /**
164
+ * Append-only. Records are never edited and never removed by a recording
165
+ * site; a reader takes a window, and the window is defined by index, so
166
+ * "delete my entry" is not an operation this module offers.
167
+ */
168
+ const records = [];
169
+ /** Record one capability loss. `count` is for a site that fails N at once. */
170
+ function recordDegradation(reason, count = 1) {
171
+ if (!Number.isInteger(count) || count < 1) return;
172
+ for (let index = 0; index < count; index++) records.push({ reason });
173
+ }
174
+ /**
175
+ * Mark where this reader's window opens. Everything appended from here on is
176
+ * what the reader will see; see the file header for what a concurrent scan
177
+ * does to that boundary.
178
+ */
179
+ function beginDegradationWindow() {
180
+ return records.length;
181
+ }
182
+ /** The records appended since `marker`, without consuming them. */
183
+ function degradationsSince(marker) {
184
+ return records.slice(marker);
185
+ }
186
+ /**
187
+ * Per-reason counts for a window, sorted by reason so the output is
188
+ * byte-deterministic for the same set of degradations.
189
+ */
190
+ function summarizeDegradations(window) {
191
+ const counts = /* @__PURE__ */ new Map();
192
+ for (const record of window) counts.set(record.reason, (counts.get(record.reason) ?? 0) + 1);
193
+ return [...counts.entries()].map(([reason, count]) => ({
194
+ reason,
195
+ count
196
+ })).sort((a, b) => a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0);
197
+ }
198
+ //#endregion
158
199
  //#region src/engine/runtime-corroboration.ts
159
200
  /**
160
201
  * Stamp runtime corroboration + trust levels onto findings (mutates in
@@ -1105,6 +1146,120 @@ function createExecutor(name, concurrency, runOne) {
1105
1146
  };
1106
1147
  }
1107
1148
  //#endregion
1149
+ //#region src/scope/git-resolve.ts
1150
+ /**
1151
+ * Absolute-path git resolution (audit S1).
1152
+ *
1153
+ * `execFileSync("git", …)` resolves through PATH — including the CWD on
1154
+ * Windows (CreateProcess searches the current directory before PATH for
1155
+ * extension-less names depending on NoDefaultCurrentDirectoryInExePath).
1156
+ * Scanning an UNTRUSTED repo therefore let a checked-in `git.exe` (or
1157
+ * `git.bat`/`git.cmd`) hijack Mjölnir's own git invocations: the attacker
1158
+ * controls the diff output, the merge-base decision, and
1159
+ * `--scope changed`'s "what is new" answer — the exact data CI gates on.
1160
+ *
1161
+ * The fix: resolve git ONCE per process to an ABSOLUTE path by walking
1162
+ * the PATH directly (never consulting the CWD), verify the candidate is
1163
+ * an existing file, and pass that path to execFileSync. Resolution is
1164
+ * memoized; a failure to find a real git anywhere on PATH is recorded
1165
+ * and callers degrade without invoking a bare name.
1166
+ */
1167
+ let resolvedGit = void 0;
1168
+ /**
1169
+ * The absolute path of the git binary Mjölnir will invoke, or null when
1170
+ * PATH carries no executable `git` at all. Callers must degrade without
1171
+ * invoking a bare name.
1172
+ */
1173
+ function resolveGitPath() {
1174
+ if (resolvedGit !== void 0) return resolvedGit;
1175
+ const candidates = [process.platform === "win32" ? "git.exe" : "git"];
1176
+ if (process.platform === "win32") {
1177
+ const pathExt = process.env["PATHEXT"]?.split(";").filter(Boolean) ?? [];
1178
+ for (const ext of pathExt) candidates.push(`git${ext.toLowerCase()}`);
1179
+ }
1180
+ const pathValue = process.env["PATH"] ?? "";
1181
+ for (const dir of pathValue.split(delimiter)) {
1182
+ if (dir === "") continue;
1183
+ if (!isAbsolute(dir)) continue;
1184
+ for (const candidate of candidates) {
1185
+ const full = join(dir, candidate);
1186
+ try {
1187
+ if (existsSync(full)) {
1188
+ resolvedGit = full;
1189
+ return resolvedGit;
1190
+ }
1191
+ } catch {}
1192
+ }
1193
+ }
1194
+ `${pathValue.length}`;
1195
+ resolvedGit = null;
1196
+ return resolvedGit;
1197
+ }
1198
+ /**
1199
+ * Run git with the S1-resolved absolute binary path. Returns stdout, or
1200
+ * null when git is missing or the command fails — the same contract as
1201
+ * the previous inline `git()` helpers, with the hijack surface closed.
1202
+ */
1203
+ function runGit(root, args) {
1204
+ const exe = resolveGitPath();
1205
+ if (!exe) return null;
1206
+ try {
1207
+ return execFileSync(exe, [
1208
+ "-C",
1209
+ root,
1210
+ ...args
1211
+ ], {
1212
+ encoding: "utf8",
1213
+ stdio: [
1214
+ "ignore",
1215
+ "pipe",
1216
+ "ignore"
1217
+ ],
1218
+ timeout: 15e3
1219
+ });
1220
+ } catch {
1221
+ return null;
1222
+ }
1223
+ }
1224
+ //#endregion
1225
+ //#region src/lib/safe-json.ts
1226
+ /**
1227
+ * Reusable safe-JSON parse-and-validate helper.
1228
+ *
1229
+ * Replaces the `JSON.parse(text) as T` pattern with a two-step process:
1230
+ * 1. Try/catch around JSON.parse (descriptive error on bad JSON)
1231
+ * 2. Runtime validation of the parsed shape (caller-supplied predicate)
1232
+ *
1233
+ * The `validate` predicate is intentionally lightweight — it checks the
1234
+ * expected shape exists rather than exhaustively type-guarding every field.
1235
+ * Internal files (cache, hashes) use `isRecord`; user-facing files
1236
+ * (config, plugins) use stricter checks that throw descriptive errors.
1237
+ */
1238
+ const MAX_JSON_BYTES = 16777216;
1239
+ function parseJsonFile(text, source, validate) {
1240
+ if (Buffer.byteLength(text, "utf8") > MAX_JSON_BYTES) throw new Error(`JSON source exceeds the ${MAX_JSON_BYTES}-byte limit: ${source}`);
1241
+ let parsed;
1242
+ try {
1243
+ parsed = JSON.parse(text);
1244
+ } catch (err) {
1245
+ throw new Error(`invalid JSON in ${source}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
1246
+ }
1247
+ if (!validate(parsed)) throw new Error(`unexpected shape in ${source}`, { cause: parsed });
1248
+ return parsed;
1249
+ }
1250
+ /**
1251
+ * Minimal validator: value is a plain object (not null, not array).
1252
+ * Sufficient for internal files (cache, hashes) where downstream
1253
+ * code already checks individual fields.
1254
+ */
1255
+ function isRecord(v) {
1256
+ try {
1257
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1258
+ } catch {
1259
+ return false;
1260
+ }
1261
+ }
1262
+ //#endregion
1108
1263
  //#region src/engine/candidate-binding.ts
1109
1264
  /**
1110
1265
  * Candidate binding (plan V5-010).
@@ -1125,9 +1280,6 @@ const SHA40 = /^[a-f0-9]{40}$/;
1125
1280
  const SHA256 = /^[a-f0-9]{64}$/;
1126
1281
  const STATES = ["WORKING_CANDIDATE", "RELEASE_CANDIDATE"];
1127
1282
  const AUTHORIZATION = ["NOT_AUTHORIZED", "AUTHORIZED"];
1128
- function isRecord$1(value) {
1129
- return typeof value === "object" && value !== null && !Array.isArray(value);
1130
- }
1131
1283
  function sha256File(path) {
1132
1284
  try {
1133
1285
  return createHash("sha256").update(readFileSync(path)).digest("hex");
@@ -1135,17 +1287,41 @@ function sha256File(path) {
1135
1287
  return;
1136
1288
  }
1137
1289
  }
1290
+ /**
1291
+ * One git line, or nothing.
1292
+ *
1293
+ * Audit S1: the binary is the ABSOLUTE path `resolveGitPath()` found by
1294
+ * walking PATH itself, never the bare name. `bindRepository()` runs on every
1295
+ * scan (`scan-pipeline.ts`), and a bare `execFileSync("git", …)` lets a
1296
+ * `git.exe`/`git.bat`/`git.cmd` CHECKED INTO AN UNTRUSTED REPO hijack the
1297
+ * call on Windows, where CreateProcess searches the current directory before
1298
+ * PATH. The attacker then controls the `commit` and `tree` that land in
1299
+ * `runIdentity` and the evidence graph — the provenance a release decision
1300
+ * reads. This was the last bare-name exec left in `src/`; the invariant that
1301
+ * keeps it that way is `tests/contract/no-bare-process-exec.spec.ts`.
1302
+ *
1303
+ * The `-C <root>` shape (not `cwd`) matches every other hardened call site.
1304
+ * With no git on PATH at all, the answer is nothing — a scan of an unpacked
1305
+ * tarball has no commit, and its identity must say so rather than carrying a
1306
+ * placeholder that looks like provenance.
1307
+ */
1138
1308
  function gitLine(root, args) {
1309
+ const exe = resolveGitPath();
1310
+ if (!exe) return void 0;
1139
1311
  try {
1140
- const trimmed = execFileSync("git", args, {
1141
- cwd: root,
1312
+ const trimmed = execFileSync(exe, [
1313
+ "-C",
1314
+ root,
1315
+ ...args
1316
+ ], {
1142
1317
  encoding: "utf8",
1143
1318
  stdio: [
1144
1319
  "ignore",
1145
1320
  "pipe",
1146
1321
  "ignore"
1147
1322
  ],
1148
- windowsHide: true
1323
+ windowsHide: true,
1324
+ timeout: 15e3
1149
1325
  }).trim();
1150
1326
  return trimmed.length > 0 ? trimmed : void 0;
1151
1327
  } catch {
@@ -1176,9 +1352,9 @@ function bindRepository(root) {
1176
1352
  * contradiction the release decision evaluator treats as BLOCKED).
1177
1353
  */
1178
1354
  function parseCandidateBinding(raw) {
1179
- if (!isRecord$1(raw)) return null;
1355
+ if (!isRecord(raw)) return null;
1180
1356
  const identity = raw.identity;
1181
- if (!isRecord$1(identity)) return null;
1357
+ if (!isRecord(identity)) return null;
1182
1358
  const state = identity.state;
1183
1359
  const candidateSha = identity.candidateSha;
1184
1360
  const baseSha = identity.baseSha;
@@ -1227,7 +1403,7 @@ function readCandidateBinding(root, manifestPath = CANDIDATE_MANIFEST_PATH) {
1227
1403
  * scripts/sync-sarif-version.cjs and guarded by the version-consistency
1228
1404
  * spec. cli.ts re-exports this as CLI_VERSION.
1229
1405
  */
1230
- const ENGINE_VERSION = "4.0.0";
1406
+ const ENGINE_VERSION = "5.0.0";
1231
1407
  //#endregion
1232
1408
  //#region src/engine/contract-versions.ts
1233
1409
  /**
@@ -1356,9 +1532,12 @@ function discoverWorkspace(rootDir) {
1356
1532
  const root = findProjectRoot(rootDir);
1357
1533
  if (!root) return null;
1358
1534
  let pkg = {};
1359
- try {
1360
- pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
1361
- } catch {}
1535
+ const manifestPath = join(root, "package.json");
1536
+ if (existsSync(manifestPath)) try {
1537
+ pkg = JSON.parse(readFileSync(manifestPath, "utf8"));
1538
+ } catch {
1539
+ recordDegradation("workspace-manifest-unreadable");
1540
+ }
1362
1541
  const rawWorkspaces = pkg["workspaces"];
1363
1542
  let globs = [];
1364
1543
  if (Array.isArray(rawWorkspaces)) globs = rawWorkspaces.filter((w) => typeof w === "string");
@@ -1375,40 +1554,6 @@ function basename$1(p) {
1375
1554
  return parts[parts.length - 1];
1376
1555
  }
1377
1556
  //#endregion
1378
- //#region src/lib/safe-json.ts
1379
- /**
1380
- * Reusable safe-JSON parse-and-validate helper.
1381
- *
1382
- * Replaces the `JSON.parse(text) as T` pattern with a two-step process:
1383
- * 1. Try/catch around JSON.parse (descriptive error on bad JSON)
1384
- * 2. Runtime validation of the parsed shape (caller-supplied predicate)
1385
- *
1386
- * The `validate` predicate is intentionally lightweight — it checks the
1387
- * expected shape exists rather than exhaustively type-guarding every field.
1388
- * Internal files (cache, hashes) use `isRecord`; user-facing files
1389
- * (config, plugins) use stricter checks that throw descriptive errors.
1390
- */
1391
- const MAX_JSON_BYTES = 16777216;
1392
- function parseJsonFile(text, source, validate) {
1393
- if (Buffer.byteLength(text, "utf8") > MAX_JSON_BYTES) throw new Error(`JSON source exceeds the ${MAX_JSON_BYTES}-byte limit: ${source}`);
1394
- let parsed;
1395
- try {
1396
- parsed = JSON.parse(text);
1397
- } catch (err) {
1398
- throw new Error(`invalid JSON in ${source}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
1399
- }
1400
- if (!validate(parsed)) throw new Error(`unexpected shape in ${source}`, { cause: parsed });
1401
- return parsed;
1402
- }
1403
- /**
1404
- * Minimal validator: value is a plain object (not null, not array).
1405
- * Sufficient for internal files (cache, hashes) where downstream
1406
- * code already checks individual fields.
1407
- */
1408
- function isRecord(v) {
1409
- return typeof v === "object" && v !== null && !Array.isArray(v);
1410
- }
1411
- //#endregion
1412
1557
  //#region src/lib/fs-bounded.ts
1413
1558
  function closeQuiet(fd) {
1414
1559
  try {
@@ -1638,6 +1783,12 @@ function validate(cfg, knownRuleIds) {
1638
1783
  * Expiry is evaluated only from an explicit ISO `expires` date. A missing
1639
1784
  * date remains active and is reported as having no expiry. Config-file mtime
1640
1785
  * is never an expiry anchor.
1786
+ *
1787
+ * `now` defaults to the current instant so existing callers stay correct, but
1788
+ * a verdict function that reads the clock invisibly is a verdict nobody can
1789
+ * test at a boundary. `tests/contract/deterministic-clock.spec.ts` audits
1790
+ * `src/config/**` for a call site that supplies its own `new Date()` instead
1791
+ * of threading one through.
1641
1792
  */
1642
1793
  function isSuppressionActive(ign, now = /* @__PURE__ */ new Date()) {
1643
1794
  if (!ign.expires) return true;
@@ -2038,6 +2189,7 @@ function parseTsFile(file) {
2038
2189
  }
2039
2190
  return p.createSourceFile(file.path, file.text);
2040
2191
  } catch {
2192
+ recordDegradation("ast-parse-failed");
2041
2193
  return;
2042
2194
  }
2043
2195
  }
@@ -2093,6 +2245,7 @@ function commentAndStringRanges(ctx) {
2093
2245
  }
2094
2246
  return ranges;
2095
2247
  } catch {
2248
+ recordDegradation("ast-range-scan-failed");
2096
2249
  return [];
2097
2250
  }
2098
2251
  }
@@ -2140,6 +2293,7 @@ function getCodeOnlyText(file) {
2140
2293
  for (const r of ranges) for (let i = r.start; i < r.end && i < chars.length; i++) if (chars[i] !== "\n" && chars[i] !== "\r") chars[i] = " ";
2141
2294
  return chars.join("");
2142
2295
  } catch {
2296
+ recordDegradation("ast-mask-unavailable");
2143
2297
  return file.text;
2144
2298
  }
2145
2299
  }
@@ -2524,6 +2678,44 @@ function frameworkFilterApplies(rule, file) {
2524
2678
  return rule.frameworks.some((f) => tags.includes(f));
2525
2679
  }
2526
2680
  //#endregion
2681
+ //#region src/engine/shared-run-rules.ts
2682
+ /**
2683
+ * Run ONE rule with crash isolation, and return what it produced.
2684
+ *
2685
+ * This is the block that was copy-pasted into all seven language adapters:
2686
+ *
2687
+ * try { for (const f of rule.run(x)) emit(f, rule.id, rule.category); }
2688
+ * catch (error) { onCrash?.(rule.id, error); }
2689
+ *
2690
+ * Extracting it is worth doing for one reason beyond line count. Crash
2691
+ * isolation is the mechanism that lets a third-party or experimental
2692
+ * detector fail without ending a scan, and it is the mechanism the whole
2693
+ * `rulesCrashed` count depends on. Seven copies means seven chances to
2694
+ * write `catch {}` and lose the count, and `tests/contract/
2695
+ * no-uncounted-degradation.spec.ts` can only audit what it can see.
2696
+ *
2697
+ * `null` means the rule CRASHED, which is not the same as an empty array:
2698
+ * an empty array is the rule abstaining, and the adapters skip emission for
2699
+ * both but the distinction is why the caller must not conflate them.
2700
+ *
2701
+ * Deliberately NOT extracted: the per-adapter filter chain and the budget
2702
+ * check. Those genuinely differ — the TypeScript adapter injects an AST,
2703
+ * framework tags, a lazy `codeText` getter, `appliesTo` scoping, config-only
2704
+ * gating and `configGateMatches`, and it ABORTS the whole file on budget
2705
+ * exhaustion where `runRulesShared` only `continue`s to the next rule.
2706
+ * Routing every adapter through `runRulesShared` as written would silently
2707
+ * change which rules run and when, which is precisely the unmeasured claim
2708
+ * this program exists to delete. This helper takes the part they share.
2709
+ */
2710
+ function runRuleIsolated(rule, file, onCrash) {
2711
+ try {
2712
+ return rule.run(file);
2713
+ } catch (error) {
2714
+ onCrash?.(rule.id, error);
2715
+ return null;
2716
+ }
2717
+ }
2718
+ //#endregion
2527
2719
  //#region src/adapters/typescript.ts
2528
2720
  /**
2529
2721
  * TypeScript/JavaScript adapter (R1).
@@ -2733,11 +2925,9 @@ const typescriptAdapter = {
2733
2925
  budget.onExceeded();
2734
2926
  return;
2735
2927
  }
2736
- try {
2737
- for (const f of rule.run(enriched)) emit(f, rule.id, rule.category);
2738
- } catch (error) {
2739
- onCrash?.(rule.id, error);
2740
- }
2928
+ const produced = runRuleIsolated(rule, enriched, onCrash);
2929
+ if (produced === null) continue;
2930
+ for (const f of produced) emit(f, rule.id, rule.category);
2741
2931
  }
2742
2932
  }
2743
2933
  };
@@ -2765,6 +2955,7 @@ function loadWorkspaceShim(root) {
2765
2955
  workspaceGlobs: []
2766
2956
  };
2767
2957
  } catch {
2958
+ recordDegradation("workspace-manifest-unreadable");
2768
2959
  return null;
2769
2960
  }
2770
2961
  }
@@ -3119,11 +3310,9 @@ const pythonAdapter = {
3119
3310
  budget.onExceeded();
3120
3311
  return;
3121
3312
  }
3122
- try {
3123
- for (const f of rule.run(enriched)) emit(f, rule.id, rule.category);
3124
- } catch (error) {
3125
- onCrash?.(rule.id, error);
3126
- }
3313
+ const produced = runRuleIsolated(rule, enriched, onCrash);
3314
+ if (produced === null) continue;
3315
+ for (const f of produced) emit(f, rule.id, rule.category);
3127
3316
  }
3128
3317
  },
3129
3318
  async parseAst(file) {
@@ -3276,11 +3465,9 @@ const javaAdapter = {
3276
3465
  budget.onExceeded();
3277
3466
  return;
3278
3467
  }
3279
- try {
3280
- for (const f of rule.run(enriched)) emit(f, rule.id, rule.category);
3281
- } catch (error) {
3282
- onCrash?.(rule.id, error);
3283
- }
3468
+ const produced = runRuleIsolated(rule, enriched, onCrash);
3469
+ if (produced === null) continue;
3470
+ for (const f of produced) emit(f, rule.id, rule.category);
3284
3471
  }
3285
3472
  }
3286
3473
  };
@@ -3434,11 +3621,9 @@ const csharpAdapter = {
3434
3621
  budget.onExceeded();
3435
3622
  return;
3436
3623
  }
3437
- try {
3438
- for (const f of rule.run(enriched)) emit(f, rule.id, rule.category);
3439
- } catch (error) {
3440
- onCrash?.(rule.id, error);
3441
- }
3624
+ const produced = runRuleIsolated(rule, enriched, onCrash);
3625
+ if (produced === null) continue;
3626
+ for (const f of produced) emit(f, rule.id, rule.category);
3442
3627
  }
3443
3628
  }
3444
3629
  };
@@ -3616,6 +3801,7 @@ const githubActionsAdapter = {
3616
3801
  try {
3617
3802
  entries = readdirSync(wfDir);
3618
3803
  } catch {
3804
+ recordDegradation("ci-workflow-listing-unreadable");
3619
3805
  return;
3620
3806
  }
3621
3807
  for (const name of entries) {
@@ -3656,15 +3842,13 @@ const githubActionsAdapter = {
3656
3842
  budget.onExceeded();
3657
3843
  return;
3658
3844
  }
3659
- try {
3660
- for (const f of rule.run({
3661
- path: file.path,
3662
- text: file.text,
3663
- ast: doc
3664
- })) emit(f, rule.id, rule.category);
3665
- } catch (error) {
3666
- onCrash?.(rule.id, error);
3667
- }
3845
+ const produced = runRuleIsolated(rule, {
3846
+ path: file.path,
3847
+ text: file.text,
3848
+ ast: doc
3849
+ }, onCrash);
3850
+ if (produced === null) continue;
3851
+ for (const f of produced) emit(f, rule.id, rule.category);
3668
3852
  }
3669
3853
  }
3670
3854
  };
@@ -3974,15 +4158,13 @@ const azurePipelinesAdapter = {
3974
4158
  budget.onExceeded();
3975
4159
  return;
3976
4160
  }
3977
- try {
3978
- for (const f of rule.run({
3979
- path: file.path,
3980
- text: file.text,
3981
- ast: doc
3982
- })) emit(f, rule.id, rule.category);
3983
- } catch (error) {
3984
- onCrash?.(rule.id, error);
3985
- }
4161
+ const produced = runRuleIsolated(rule, {
4162
+ path: file.path,
4163
+ text: file.text,
4164
+ ast: doc
4165
+ }, onCrash);
4166
+ if (produced === null) continue;
4167
+ for (const f of produced) emit(f, rule.id, rule.category);
3986
4168
  }
3987
4169
  }
3988
4170
  };
@@ -4053,14 +4235,12 @@ const jenkinsAdapter = {
4053
4235
  budget.onExceeded();
4054
4236
  return;
4055
4237
  }
4056
- try {
4057
- for (const f of rule.run({
4058
- path: file.path,
4059
- text: file.text
4060
- })) emit(f, rule.id, rule.category);
4061
- } catch (error) {
4062
- onCrash?.(rule.id, error);
4063
- }
4238
+ const produced = runRuleIsolated(rule, {
4239
+ path: file.path,
4240
+ text: file.text
4241
+ }, onCrash);
4242
+ if (produced === null) continue;
4243
+ for (const f of produced) emit(f, rule.id, rule.category);
4064
4244
  }
4065
4245
  }
4066
4246
  };
@@ -4224,82 +4404,6 @@ const SEARCHED_FOR = [
4224
4404
  }
4225
4405
  ];
4226
4406
  //#endregion
4227
- //#region src/scope/git-resolve.ts
4228
- /**
4229
- * Absolute-path git resolution (audit S1).
4230
- *
4231
- * `execFileSync("git", …)` resolves through PATH — including the CWD on
4232
- * Windows (CreateProcess searches the current directory before PATH for
4233
- * extension-less names depending on NoDefaultCurrentDirectoryInExePath).
4234
- * Scanning an UNTRUSTED repo therefore let a checked-in `git.exe` (or
4235
- * `git.bat`/`git.cmd`) hijack Mjölnir's own git invocations: the attacker
4236
- * controls the diff output, the merge-base decision, and
4237
- * `--scope changed`'s "what is new" answer — the exact data CI gates on.
4238
- *
4239
- * The fix: resolve git ONCE per process to an ABSOLUTE path by walking
4240
- * the PATH directly (never consulting the CWD), verify the candidate is
4241
- * an existing file, and pass that path to execFileSync. Resolution is
4242
- * memoized; a failure to find a real git anywhere on PATH is recorded
4243
- * and callers degrade without invoking a bare name.
4244
- */
4245
- let resolvedGit = void 0;
4246
- /**
4247
- * The absolute path of the git binary Mjölnir will invoke, or null when
4248
- * PATH carries no executable `git` at all. Callers must degrade without
4249
- * invoking a bare name.
4250
- */
4251
- function resolveGitPath() {
4252
- if (resolvedGit !== void 0) return resolvedGit;
4253
- const candidates = [process.platform === "win32" ? "git.exe" : "git"];
4254
- if (process.platform === "win32") {
4255
- const pathExt = process.env["PATHEXT"]?.split(";").filter(Boolean) ?? [];
4256
- for (const ext of pathExt) candidates.push(`git${ext.toLowerCase()}`);
4257
- }
4258
- const pathValue = process.env["PATH"] ?? "";
4259
- for (const dir of pathValue.split(delimiter)) {
4260
- if (dir === "") continue;
4261
- if (!isAbsolute(dir)) continue;
4262
- for (const candidate of candidates) {
4263
- const full = join(dir, candidate);
4264
- try {
4265
- if (existsSync(full)) {
4266
- resolvedGit = full;
4267
- return resolvedGit;
4268
- }
4269
- } catch {}
4270
- }
4271
- }
4272
- `${pathValue.length}`;
4273
- resolvedGit = null;
4274
- return resolvedGit;
4275
- }
4276
- /**
4277
- * Run git with the S1-resolved absolute binary path. Returns stdout, or
4278
- * null when git is missing or the command fails — the same contract as
4279
- * the previous inline `git()` helpers, with the hijack surface closed.
4280
- */
4281
- function runGit(root, args) {
4282
- const exe = resolveGitPath();
4283
- if (!exe) return null;
4284
- try {
4285
- return execFileSync(exe, [
4286
- "-C",
4287
- root,
4288
- ...args
4289
- ], {
4290
- encoding: "utf8",
4291
- stdio: [
4292
- "ignore",
4293
- "pipe",
4294
- "ignore"
4295
- ],
4296
- timeout: 15e3
4297
- });
4298
- } catch {
4299
- return null;
4300
- }
4301
- }
4302
- //#endregion
4303
4407
  //#region src/scope/changed.ts
4304
4408
  /**
4305
4409
  * Changed-scope engine (Sprint-Plan W6, Product-MVP §9 `--scope changed`).
@@ -11781,6 +11885,53 @@ const RETIRED_RULE_IDS = [
11781
11885
  "QA-CS-111"
11782
11886
  ];
11783
11887
  //#endregion
11888
+ //#region src/lib/compare.ts
11889
+ /**
11890
+ * Order that does not depend on who is asking.
11891
+ *
11892
+ * `String.prototype.localeCompare` with no locale argument resolves against
11893
+ * the PROCESS's default locale — the ICU collation chosen by `LANG`,
11894
+ * `LC_ALL` or the OS. So a sort written as
11895
+ * `[...xs].sort((a, b) => a.localeCompare(b))` produces a different order on
11896
+ * a developer's `de_DE` machine than on an en-US CI runner. For a display
11897
+ * list that is a nuisance; for anything that reaches a hash, a cache key, a
11898
+ * digest or a machine contract it is a correctness bug, because the same
11899
+ * repository then produces two different fingerprints.
11900
+ *
11901
+ * Two comparators, one per kind of site:
11902
+ *
11903
+ * compareCodePoints — pure UTF-16 code-unit order. Identical everywhere,
11904
+ * independent of ICU version, and already what `Array.prototype.sort`
11905
+ * defaults to. Use for every value that reaches a fingerprint, a digest,
11906
+ * a cache key, or a contract, and for any ordering with no human-facing
11907
+ * reason to be pretty.
11908
+ *
11909
+ * compareLocalized(locale) — the same collator, PINNED to a named locale
11910
+ * instead of inheriting the environment, for a surface that genuinely
11911
+ * wants readable order (a table of rule titles, a Mermaid legend). Pinned
11912
+ * rather than removed because a reader-facing list of "Test / Logic /
11913
+ * Mock" should not be ordered by code unit.
11914
+ *
11915
+ * A bare `localeCompare` with no second argument in `src/` is a defect and
11916
+ * `tests/contract/deterministic-ordering.spec.ts` fails the build on it.
11917
+ */
11918
+ /** Code-unit order. Locale-free, ICU-free, and the Array.sort default. */
11919
+ function compareCodePoints(a, b) {
11920
+ if (a === b) return 0;
11921
+ return a < b ? -1 : 1;
11922
+ }
11923
+ /**
11924
+ * A readable-order comparator pinned to `locale`.
11925
+ *
11926
+ * The pin is the point: `a.localeCompare(b)` reads the environment, and the
11927
+ * environment is a machine setting, not a property of the code. Callers that
11928
+ * want stable output ask for a named locale and get the same answer on every
11929
+ * machine that ships the same ICU data.
11930
+ */
11931
+ function compareLocalized(locale) {
11932
+ return (a, b) => a.localeCompare(b, locale);
11933
+ }
11934
+ //#endregion
11784
11935
  //#region src/scorer/scorer.ts
11785
11936
  /**
11786
11937
  * Transparent scorer (Product-MVP §8).
@@ -11837,7 +11988,7 @@ function computeDimensions(findings) {
11837
11988
  deductions.set(f.category, deductions.get(f.category) + deductionFor(f));
11838
11989
  }
11839
11990
  for (const dim of byCategory.values()) dim.score = Math.max(0, 100 - deductions.get(dim.category));
11840
- return [...byCategory.values()].sort((a, b) => a.category.localeCompare(b.category));
11991
+ return [...byCategory.values()].sort((a, b) => compareCodePoints(a.category, b.category));
11841
11992
  }
11842
11993
  /**
11843
11994
  * Deduction-mass ceilings (product-gap-remediation master plan P2,
@@ -12279,7 +12430,7 @@ function groupByRootCause(findings) {
12279
12430
  function correlateFindings(findings) {
12280
12431
  const conclusions = [];
12281
12432
  const rootCauseGroups = groupByRootCause(findings);
12282
- for (const [rootCause, group] of Array.from(rootCauseGroups.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
12433
+ for (const [rootCause, group] of Array.from(rootCauseGroups.entries()).sort((a, b) => compareLocalized("en")(a[0], b[0]))) {
12283
12434
  if (group.length < 2) continue;
12284
12435
  const findingIds = group.map((f) => f.findingId ?? f.ruleId);
12285
12436
  conclusions.push({
@@ -12291,7 +12442,7 @@ function correlateFindings(findings) {
12291
12442
  });
12292
12443
  }
12293
12444
  const fileGroups = groupByFile(findings);
12294
- for (const [file, group] of Array.from(fileGroups.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
12445
+ for (const [file, group] of Array.from(fileGroups.entries()).sort((a, b) => compareCodePoints(a[0], b[0]))) {
12295
12446
  if (group.length < 2) continue;
12296
12447
  const alreadyConvergent = /* @__PURE__ */ new Set();
12297
12448
  for (const c of conclusions) if (c.conclusionType === "CONVERGENT") for (const id of c.findingIds) alreadyConvergent.add(id);
@@ -13336,9 +13487,10 @@ function hashDir(dir, hash, depth) {
13336
13487
  try {
13337
13488
  entries = readdirSync(dir, { withFileTypes: true });
13338
13489
  } catch {
13490
+ recordDegradation("rules-tree-listing-unreadable");
13339
13491
  return;
13340
13492
  }
13341
- entries.sort((a, b) => a.name.localeCompare(b.name));
13493
+ entries.sort((a, b) => compareCodePoints(a.name, b.name));
13342
13494
  for (const entry of entries) {
13343
13495
  if (entry.name === "node_modules" || entry.name === ".git") continue;
13344
13496
  const full = join(dir, entry.name);
@@ -14916,11 +15068,11 @@ function listFiles(dir) {
14916
15068
  * `mjolnir suppressions` — every suppression stays visible.
14917
15069
  */
14918
15070
  const ui = plainContext();
14919
- function loadSuppressions(root) {
15071
+ function loadSuppressions(root, now = /* @__PURE__ */ new Date()) {
14920
15072
  const { config } = loadConfig(root);
14921
15073
  const entries = (config.ignore ?? []).map((ign) => ({
14922
15074
  ...ign,
14923
- status: isSuppressionActive(ign, /* @__PURE__ */ new Date()) ? "active" : "expired"
15075
+ status: isSuppressionActive(ign, now) ? "active" : "expired"
14924
15076
  }));
14925
15077
  return {
14926
15078
  total: entries.length,
@@ -15689,6 +15841,7 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
15689
15841
  let rulesPartial = false;
15690
15842
  let parseFailed = 0;
15691
15843
  let parseFallbacks = 0;
15844
+ let astFallbackFiles = 0;
15692
15845
  let scanned = 0;
15693
15846
  let analyzed = 0;
15694
15847
  for (const path of testFiles) {
@@ -15732,6 +15885,10 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
15732
15885
  });
15733
15886
  }
15734
15887
  const wantsAst = adapter.parseAst !== void 0 && Date.now() <= deadline;
15888
+ if (adapter.parseAst !== void 0 && !wantsAst) {
15889
+ truncationReasons.add("ast-budget-fallback");
15890
+ astFallbackFiles++;
15891
+ }
15735
15892
  const identity = (mode) => ({
15736
15893
  relPath,
15737
15894
  adapterId: adapter.id,
@@ -15849,7 +16006,8 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
15849
16006
  parseFailed,
15850
16007
  parseFallbacks,
15851
16008
  scanned,
15852
- analyzed
16009
+ analyzed,
16010
+ astFallbackFiles
15853
16011
  };
15854
16012
  }
15855
16013
  /**
@@ -16077,6 +16235,8 @@ function assembleScanResult(o) {
16077
16235
  scopeUnrecognized: o.scopeUnrecognized,
16078
16236
  parseFailed: o.parseFailed,
16079
16237
  parseFallbacks: o.parseFallbacks ?? 0,
16238
+ ...o.astFallbackFiles !== void 0 ? { astFallbackFiles: o.astFallbackFiles } : {},
16239
+ ...o.degradations !== void 0 ? { degradations: o.degradations } : {},
16080
16240
  ...o.scopeInfo.degraded !== void 0 ? { scopeDegraded: o.scopeInfo.degraded } : {},
16081
16241
  ...o.runtimeIncomplete !== void 0 ? { runtimeIncomplete: o.runtimeIncomplete } : {},
16082
16242
  identityIncomplete
@@ -16195,13 +16355,15 @@ function assembleScanResult(o) {
16195
16355
  */
16196
16356
  async function runScan(args, hooks = {}) {
16197
16357
  const started = Date.now();
16358
+ const degradationWindow = beginDegradationWindow();
16198
16359
  const requestedDuration = Number.isFinite(args.maxDurationMs) ? args.maxDurationMs : DEFAULT_MAX_DURATION_MS;
16199
16360
  const deadline = started + Math.min(Math.max(1, requestedDuration), MAX_DURATION_MS);
16200
16361
  const discovered = discoverWorkspace(args.target);
16201
16362
  const targetAbs = resolve(args.target);
16202
16363
  const scanRoot = discovered && discovered.root !== targetAbs && targetAbs.startsWith(discovered.root + sep) ? {
16203
16364
  ...discovered,
16204
- root: targetAbs
16365
+ root: targetAbs,
16366
+ packageJson: {}
16205
16367
  } : discovered ?? fallbackWorkspace(targetAbs);
16206
16368
  const workspace = scanRoot;
16207
16369
  if (args.verbose) hooks.onConfigWarning?.(`verbose: scan root (config/ignore anchor): ${workspace.root}`);
@@ -16286,6 +16448,7 @@ async function runScan(args, hooks = {}) {
16286
16448
  rulesPartial = analysis.rulesPartial;
16287
16449
  parseFailed = analysis.parseFailed;
16288
16450
  const parseFallbacks = analysis.parseFallbacks;
16451
+ const astFallbackFiles = analysis.astFallbackFiles;
16289
16452
  const scanned = analysis.scanned;
16290
16453
  const analyzed = analysis.analyzed;
16291
16454
  const postScan = applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, declarationsByFile, testDeclarationCount, tierByRuleId, REVISION_BY_RULE_ID);
@@ -16304,6 +16467,8 @@ async function runScan(args, hooks = {}) {
16304
16467
  scopeUnrecognized,
16305
16468
  parseFailed,
16306
16469
  parseFallbacks,
16470
+ astFallbackFiles,
16471
+ degradations: summarizeDegradations(degradationsSince(degradationWindow)),
16307
16472
  scanned,
16308
16473
  analyzed,
16309
16474
  testFiles,
@@ -16331,6 +16496,6 @@ async function runScan(args, hooks = {}) {
16331
16496
  return result;
16332
16497
  }
16333
16498
  //#endregion
16334
- export { BADGE_BAND as $, plainContext as A, isLintFixtureDir as At, shouldUseAscii as B, DEDUCTIONS as Bt, runForensics as C, computeCodeText as Ct, nextStep as D, LIMITS$1 as Dt, buildFooter as E, DEFAULT_IGNORE_MATCHER as Et, padTo as F, isRecord as Ft, countOrNull as G, deriveEvidenceLevel as Gt, atomicTempPath as H, RULE_CATEGORIES as Ht, palette as I, parseJsonFile as It, evidenceTag as J, deriveScoreState as K, isAdvisoryFinding as Kt, sanitizeData as L, ENGINE_VERSION as Lt, severityIcon as M, isSuppressionActive as Mt, box as N, loadConfig as Nt, okIcon as O, createIgnoreMatcher as Ot, measure as P, readFileBounded as Pt, TRUST_RUNGS as Q, scoreGauge as R, buildEvidenceGraph as Rt, renderSuppressions as S, parseYamlGuarded as St, FLAKE_GLYPH as T, detectFrameworks as Tt, sweepStaleTempFiles as U, SEVERITY_ORDER as Ut, wrapText as V, QA_IMPACT_LABELS as Vt, writeFileAtomic as W, TRUST_ORDER as Wt, testsAnalyzedCell as X, headlineFor as Y, verdictFor as Z, summarizeForensicVerdicts as _, SCAN_ADAPTERS as _t, applyPostScanProcessing as a, correlateFindings as at, loadLocalRules as b, parseAzurePipeline as bt, discoverAndParseRuntimeReport as c, capForTier as ct, isValidFindingRecord as d, massCeiling as dt, SCORE as et, pathMatchesGlob as f, RETIRED_RULE_IDS as ft, selectAdapter as g, runGit as gt, scan_pipeline_exports as h, resolveGitPath as ht, SUITE_INVALIDATING_RULE_IDS as i, getReachableFiles as it, sectionHeader as j, ConfigValidationError as jt, panel as k, globToRegExp as kt, discoverTestFilesPhase as l, computeDimensions as lt, runScan as m, getRule as mt, KNOWN_RULE_IDS as n, TINT as nt, assembleScanResult as o, classifyProvenance as ot, runFileAnalysisPhase as p, RULES as pt, evidenceLevelOf as q, isValidCategory as qt, OVERLAP_META_BY_RULE_ID as r, buildDependencyGraph as rt, buildUniversalRules as s, computeAgenticProfile as st, EVIDENCE_OVERRIDES as t, STATUS as tt, fallbackWorkspace as u, deductionFor as ut, pluginsGateOpen as v, SEARCHED_FOR as vt, sanitizeErrorText as w, parseTsFile as wt, loadSuppressions as x, parseWorkflow as xt, renderGateNotice as y, isAzurePipelineFixture as yt, shouldColorize as z, MEASURED_FP as zt };
16499
+ export { BADGE_BAND as $, plainContext as A, ConfigValidationError as At, shouldUseAscii as B, MEASURED_FP as Bt, runForensics as C, parseTsFile as Ct, nextStep as D, createIgnoreMatcher as Dt, buildFooter as E, LIMITS$1 as Et, padTo as F, isRecord as Ft, countOrNull as G, DEDUCTIONS as Gt, atomicTempPath as H, degradationsSince as Ht, palette as I, parseJsonFile as It, evidenceTag as J, SEVERITY_ORDER as Jt, deriveScoreState as K, QA_IMPACT_LABELS as Kt, sanitizeData as L, resolveGitPath as Lt, severityIcon as M, loadConfig as Mt, box as N, readFileBounded as Nt, okIcon as O, globToRegExp as Ot, measure as P, ENGINE_VERSION as Pt, TRUST_RUNGS as Q, isValidCategory as Qt, scoreGauge as R, runGit as Rt, renderSuppressions as S, computeCodeText as St, FLAKE_GLYPH as T, DEFAULT_IGNORE_MATCHER as Tt, sweepStaleTempFiles as U, recordDegradation as Ut, wrapText as V, beginDegradationWindow as Vt, writeFileAtomic as W, summarizeDegradations as Wt, testsAnalyzedCell as X, deriveEvidenceLevel as Xt, headlineFor as Y, TRUST_ORDER as Yt, verdictFor as Z, isAdvisoryFinding as Zt, summarizeForensicVerdicts as _, SEARCHED_FOR as _t, applyPostScanProcessing as a, correlateFindings as at, loadLocalRules as b, parseWorkflow as bt, discoverAndParseRuntimeReport as c, capForTier as ct, isValidFindingRecord as d, massCeiling as dt, SCORE as et, pathMatchesGlob as f, compareCodePoints as ft, selectAdapter as g, SCAN_ADAPTERS as gt, scan_pipeline_exports as h, getRule as ht, SUITE_INVALIDATING_RULE_IDS as i, getReachableFiles as it, sectionHeader as j, isSuppressionActive as jt, panel as k, isLintFixtureDir as kt, discoverTestFilesPhase as l, computeDimensions as lt, runScan as m, RULES as mt, KNOWN_RULE_IDS as n, TINT as nt, assembleScanResult as o, classifyProvenance as ot, runFileAnalysisPhase as p, RETIRED_RULE_IDS as pt, evidenceLevelOf as q, RULE_CATEGORIES as qt, OVERLAP_META_BY_RULE_ID as r, buildDependencyGraph as rt, buildUniversalRules as s, computeAgenticProfile as st, EVIDENCE_OVERRIDES as t, STATUS as tt, fallbackWorkspace as u, deductionFor as ut, pluginsGateOpen as v, isAzurePipelineFixture as vt, sanitizeErrorText as w, detectFrameworks as wt, loadSuppressions as x, parseYamlGuarded as xt, renderGateNotice as y, parseAzurePipeline as yt, shouldColorize as z, buildEvidenceGraph as zt };
16335
16500
 
16336
- //# sourceMappingURL=scan-pipeline-CCJdEKNa.mjs.map
16501
+ //# sourceMappingURL=scan-pipeline-DT8DWAMl.mjs.map