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.
@@ -85,6 +85,7 @@ const DEDUCTIONS = {
85
85
  //#region src/engine/completion.ts
86
86
  function deriveCompletion(input) {
87
87
  const truncationReasons = [...new Set(input.truncationReasons)].sort();
88
+ const degradations = [...input.degradations ?? []].sort((a, b) => a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0);
88
89
  const reasons = /* @__PURE__ */ new Set();
89
90
  if (input.discoveryTruncated) reasons.add("discovery-truncated");
90
91
  if (input.rulesPartial) reasons.add("rules-partial");
@@ -94,14 +95,16 @@ function deriveCompletion(input) {
94
95
  if (input.scopeUnrecognized > 0) reasons.add(`scope-unrecognized:${input.scopeUnrecognized}`);
95
96
  if (input.parseFailed > 0) reasons.add(`parse-failed:${input.parseFailed}`);
96
97
  if (input.parseFallbacks && input.parseFallbacks > 0) reasons.add(`parse-fallbacks:${input.parseFallbacks}`);
98
+ if (input.astFallbackFiles && input.astFallbackFiles > 0) reasons.add(`ast-budget-fallback-files:${input.astFallbackFiles}`);
97
99
  if (input.scopeDegraded) reasons.add(`scope-degraded:${input.scopeDegraded}`);
98
100
  if (input.runtimeIncomplete) reasons.add("runtime-incomplete");
99
101
  if (input.identityIncomplete) reasons.add("identity-incomplete");
100
102
  for (const reason of truncationReasons) reasons.add(`truncated:${reason}`);
103
+ for (const entry of degradations) reasons.add(`degraded:${entry.reason}:${entry.count}`);
101
104
  const discoveryPartial = input.discoveryTruncated || input.scopeIgnored > 0 || input.scopeUnrecognized > 0 || input.scopeDegraded !== void 0;
102
105
  const rulesPartial = input.rulesPartial || input.rulesCrashed > 0 || input.parseFailed > 0;
103
106
  return {
104
- partial: discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0,
107
+ partial: discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0 || degradations.length > 0,
105
108
  analysisStatus: {
106
109
  discovery: discoveryPartial ? "partial" : "complete",
107
110
  rules: rulesPartial ? "partial" : "complete",
@@ -109,11 +112,49 @@ function deriveCompletion(input) {
109
112
  rulesCrashed: input.rulesCrashed,
110
113
  parseFallbacks: input.parseFallbacks ?? 0,
111
114
  ...truncationReasons.length > 0 ? { truncationReasons } : {},
115
+ ...degradations.length > 0 ? { degradations } : {},
112
116
  reasons: [...reasons].sort()
113
117
  }
114
118
  };
115
119
  }
116
120
  //#endregion
121
+ //#region src/engine/degradation-ledger.ts
122
+ /**
123
+ * Append-only. Records are never edited and never removed by a recording
124
+ * site; a reader takes a window, and the window is defined by index, so
125
+ * "delete my entry" is not an operation this module offers.
126
+ */
127
+ const records = [];
128
+ /** Record one capability loss. `count` is for a site that fails N at once. */
129
+ function recordDegradation(reason, count = 1) {
130
+ if (!Number.isInteger(count) || count < 1) return;
131
+ for (let index = 0; index < count; index++) records.push({ reason });
132
+ }
133
+ /**
134
+ * Mark where this reader's window opens. Everything appended from here on is
135
+ * what the reader will see; see the file header for what a concurrent scan
136
+ * does to that boundary.
137
+ */
138
+ function beginDegradationWindow() {
139
+ return records.length;
140
+ }
141
+ /** The records appended since `marker`, without consuming them. */
142
+ function degradationsSince(marker) {
143
+ return records.slice(marker);
144
+ }
145
+ /**
146
+ * Per-reason counts for a window, sorted by reason so the output is
147
+ * byte-deterministic for the same set of degradations.
148
+ */
149
+ function summarizeDegradations(window) {
150
+ const counts = /* @__PURE__ */ new Map();
151
+ for (const record of window) counts.set(record.reason, (counts.get(record.reason) ?? 0) + 1);
152
+ return [...counts.entries()].map(([reason, count]) => ({
153
+ reason,
154
+ count
155
+ })).sort((a, b) => a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0);
156
+ }
157
+ //#endregion
117
158
  //#region src/engine/runtime-corroboration.ts
118
159
  /**
119
160
  * Stamp runtime corroboration + trust levels onto findings (mutates in
@@ -1064,6 +1105,120 @@ function createExecutor(name, concurrency, runOne) {
1064
1105
  };
1065
1106
  }
1066
1107
  //#endregion
1108
+ //#region src/scope/git-resolve.ts
1109
+ /**
1110
+ * Absolute-path git resolution (audit S1).
1111
+ *
1112
+ * `execFileSync("git", …)` resolves through PATH — including the CWD on
1113
+ * Windows (CreateProcess searches the current directory before PATH for
1114
+ * extension-less names depending on NoDefaultCurrentDirectoryInExePath).
1115
+ * Scanning an UNTRUSTED repo therefore let a checked-in `git.exe` (or
1116
+ * `git.bat`/`git.cmd`) hijack Mjölnir's own git invocations: the attacker
1117
+ * controls the diff output, the merge-base decision, and
1118
+ * `--scope changed`'s "what is new" answer — the exact data CI gates on.
1119
+ *
1120
+ * The fix: resolve git ONCE per process to an ABSOLUTE path by walking
1121
+ * the PATH directly (never consulting the CWD), verify the candidate is
1122
+ * an existing file, and pass that path to execFileSync. Resolution is
1123
+ * memoized; a failure to find a real git anywhere on PATH is recorded
1124
+ * and callers degrade without invoking a bare name.
1125
+ */
1126
+ let resolvedGit = void 0;
1127
+ /**
1128
+ * The absolute path of the git binary Mjölnir will invoke, or null when
1129
+ * PATH carries no executable `git` at all. Callers must degrade without
1130
+ * invoking a bare name.
1131
+ */
1132
+ function resolveGitPath() {
1133
+ if (resolvedGit !== void 0) return resolvedGit;
1134
+ const candidates = [process.platform === "win32" ? "git.exe" : "git"];
1135
+ if (process.platform === "win32") {
1136
+ const pathExt = process.env["PATHEXT"]?.split(";").filter(Boolean) ?? [];
1137
+ for (const ext of pathExt) candidates.push(`git${ext.toLowerCase()}`);
1138
+ }
1139
+ const pathValue = process.env["PATH"] ?? "";
1140
+ for (const dir of pathValue.split(delimiter)) {
1141
+ if (dir === "") continue;
1142
+ if (!isAbsolute(dir)) continue;
1143
+ for (const candidate of candidates) {
1144
+ const full = join(dir, candidate);
1145
+ try {
1146
+ if (existsSync(full)) {
1147
+ resolvedGit = full;
1148
+ return resolvedGit;
1149
+ }
1150
+ } catch {}
1151
+ }
1152
+ }
1153
+ `${pathValue.length}`;
1154
+ resolvedGit = null;
1155
+ return resolvedGit;
1156
+ }
1157
+ /**
1158
+ * Run git with the S1-resolved absolute binary path. Returns stdout, or
1159
+ * null when git is missing or the command fails — the same contract as
1160
+ * the previous inline `git()` helpers, with the hijack surface closed.
1161
+ */
1162
+ function runGit(root, args) {
1163
+ const exe = resolveGitPath();
1164
+ if (!exe) return null;
1165
+ try {
1166
+ return execFileSync(exe, [
1167
+ "-C",
1168
+ root,
1169
+ ...args
1170
+ ], {
1171
+ encoding: "utf8",
1172
+ stdio: [
1173
+ "ignore",
1174
+ "pipe",
1175
+ "ignore"
1176
+ ],
1177
+ timeout: 15e3
1178
+ });
1179
+ } catch {
1180
+ return null;
1181
+ }
1182
+ }
1183
+ //#endregion
1184
+ //#region src/lib/safe-json.ts
1185
+ /**
1186
+ * Reusable safe-JSON parse-and-validate helper.
1187
+ *
1188
+ * Replaces the `JSON.parse(text) as T` pattern with a two-step process:
1189
+ * 1. Try/catch around JSON.parse (descriptive error on bad JSON)
1190
+ * 2. Runtime validation of the parsed shape (caller-supplied predicate)
1191
+ *
1192
+ * The `validate` predicate is intentionally lightweight — it checks the
1193
+ * expected shape exists rather than exhaustively type-guarding every field.
1194
+ * Internal files (cache, hashes) use `isRecord`; user-facing files
1195
+ * (config, plugins) use stricter checks that throw descriptive errors.
1196
+ */
1197
+ const MAX_JSON_BYTES = 16777216;
1198
+ function parseJsonFile(text, source, validate) {
1199
+ if (Buffer.byteLength(text, "utf8") > MAX_JSON_BYTES) throw new Error(`JSON source exceeds the ${MAX_JSON_BYTES}-byte limit: ${source}`);
1200
+ let parsed;
1201
+ try {
1202
+ parsed = JSON.parse(text);
1203
+ } catch (err) {
1204
+ throw new Error(`invalid JSON in ${source}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
1205
+ }
1206
+ if (!validate(parsed)) throw new Error(`unexpected shape in ${source}`, { cause: parsed });
1207
+ return parsed;
1208
+ }
1209
+ /**
1210
+ * Minimal validator: value is a plain object (not null, not array).
1211
+ * Sufficient for internal files (cache, hashes) where downstream
1212
+ * code already checks individual fields.
1213
+ */
1214
+ function isRecord(v) {
1215
+ try {
1216
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1217
+ } catch {
1218
+ return false;
1219
+ }
1220
+ }
1221
+ //#endregion
1067
1222
  //#region src/engine/candidate-binding.ts
1068
1223
  /**
1069
1224
  * Candidate binding (plan V5-010).
@@ -1084,9 +1239,6 @@ const SHA40 = /^[a-f0-9]{40}$/;
1084
1239
  const SHA256 = /^[a-f0-9]{64}$/;
1085
1240
  const STATES = ["WORKING_CANDIDATE", "RELEASE_CANDIDATE"];
1086
1241
  const AUTHORIZATION = ["NOT_AUTHORIZED", "AUTHORIZED"];
1087
- function isRecord$1(value) {
1088
- return typeof value === "object" && value !== null && !Array.isArray(value);
1089
- }
1090
1242
  function sha256File(path) {
1091
1243
  try {
1092
1244
  return createHash("sha256").update(readFileSync(path)).digest("hex");
@@ -1094,17 +1246,41 @@ function sha256File(path) {
1094
1246
  return;
1095
1247
  }
1096
1248
  }
1249
+ /**
1250
+ * One git line, or nothing.
1251
+ *
1252
+ * Audit S1: the binary is the ABSOLUTE path `resolveGitPath()` found by
1253
+ * walking PATH itself, never the bare name. `bindRepository()` runs on every
1254
+ * scan (`scan-pipeline.ts`), and a bare `execFileSync("git", …)` lets a
1255
+ * `git.exe`/`git.bat`/`git.cmd` CHECKED INTO AN UNTRUSTED REPO hijack the
1256
+ * call on Windows, where CreateProcess searches the current directory before
1257
+ * PATH. The attacker then controls the `commit` and `tree` that land in
1258
+ * `runIdentity` and the evidence graph — the provenance a release decision
1259
+ * reads. This was the last bare-name exec left in `src/`; the invariant that
1260
+ * keeps it that way is `tests/contract/no-bare-process-exec.spec.ts`.
1261
+ *
1262
+ * The `-C <root>` shape (not `cwd`) matches every other hardened call site.
1263
+ * With no git on PATH at all, the answer is nothing — a scan of an unpacked
1264
+ * tarball has no commit, and its identity must say so rather than carrying a
1265
+ * placeholder that looks like provenance.
1266
+ */
1097
1267
  function gitLine(root, args) {
1268
+ const exe = resolveGitPath();
1269
+ if (!exe) return void 0;
1098
1270
  try {
1099
- const trimmed = execFileSync("git", args, {
1100
- cwd: root,
1271
+ const trimmed = execFileSync(exe, [
1272
+ "-C",
1273
+ root,
1274
+ ...args
1275
+ ], {
1101
1276
  encoding: "utf8",
1102
1277
  stdio: [
1103
1278
  "ignore",
1104
1279
  "pipe",
1105
1280
  "ignore"
1106
1281
  ],
1107
- windowsHide: true
1282
+ windowsHide: true,
1283
+ timeout: 15e3
1108
1284
  }).trim();
1109
1285
  return trimmed.length > 0 ? trimmed : void 0;
1110
1286
  } catch {
@@ -1135,9 +1311,9 @@ function bindRepository(root) {
1135
1311
  * contradiction the release decision evaluator treats as BLOCKED).
1136
1312
  */
1137
1313
  function parseCandidateBinding(raw) {
1138
- if (!isRecord$1(raw)) return null;
1314
+ if (!isRecord(raw)) return null;
1139
1315
  const identity = raw.identity;
1140
- if (!isRecord$1(identity)) return null;
1316
+ if (!isRecord(identity)) return null;
1141
1317
  const state = identity.state;
1142
1318
  const candidateSha = identity.candidateSha;
1143
1319
  const baseSha = identity.baseSha;
@@ -1186,7 +1362,7 @@ function readCandidateBinding(root, manifestPath = CANDIDATE_MANIFEST_PATH) {
1186
1362
  * scripts/sync-sarif-version.cjs and guarded by the version-consistency
1187
1363
  * spec. cli.ts re-exports this as CLI_VERSION.
1188
1364
  */
1189
- const ENGINE_VERSION = "4.0.0";
1365
+ const ENGINE_VERSION = "5.0.0";
1190
1366
  //#endregion
1191
1367
  //#region src/engine/contract-versions.ts
1192
1368
  /**
@@ -1315,9 +1491,12 @@ function discoverWorkspace(rootDir) {
1315
1491
  const root = findProjectRoot(rootDir);
1316
1492
  if (!root) return null;
1317
1493
  let pkg = {};
1318
- try {
1319
- pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
1320
- } catch {}
1494
+ const manifestPath = join(root, "package.json");
1495
+ if (existsSync(manifestPath)) try {
1496
+ pkg = JSON.parse(readFileSync(manifestPath, "utf8"));
1497
+ } catch {
1498
+ recordDegradation("workspace-manifest-unreadable");
1499
+ }
1321
1500
  const rawWorkspaces = pkg["workspaces"];
1322
1501
  let globs = [];
1323
1502
  if (Array.isArray(rawWorkspaces)) globs = rawWorkspaces.filter((w) => typeof w === "string");
@@ -1334,40 +1513,6 @@ function basename$1(p) {
1334
1513
  return parts[parts.length - 1];
1335
1514
  }
1336
1515
  //#endregion
1337
- //#region src/lib/safe-json.ts
1338
- /**
1339
- * Reusable safe-JSON parse-and-validate helper.
1340
- *
1341
- * Replaces the `JSON.parse(text) as T` pattern with a two-step process:
1342
- * 1. Try/catch around JSON.parse (descriptive error on bad JSON)
1343
- * 2. Runtime validation of the parsed shape (caller-supplied predicate)
1344
- *
1345
- * The `validate` predicate is intentionally lightweight — it checks the
1346
- * expected shape exists rather than exhaustively type-guarding every field.
1347
- * Internal files (cache, hashes) use `isRecord`; user-facing files
1348
- * (config, plugins) use stricter checks that throw descriptive errors.
1349
- */
1350
- const MAX_JSON_BYTES = 16777216;
1351
- function parseJsonFile(text, source, validate) {
1352
- if (Buffer.byteLength(text, "utf8") > MAX_JSON_BYTES) throw new Error(`JSON source exceeds the ${MAX_JSON_BYTES}-byte limit: ${source}`);
1353
- let parsed;
1354
- try {
1355
- parsed = JSON.parse(text);
1356
- } catch (err) {
1357
- throw new Error(`invalid JSON in ${source}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
1358
- }
1359
- if (!validate(parsed)) throw new Error(`unexpected shape in ${source}`, { cause: parsed });
1360
- return parsed;
1361
- }
1362
- /**
1363
- * Minimal validator: value is a plain object (not null, not array).
1364
- * Sufficient for internal files (cache, hashes) where downstream
1365
- * code already checks individual fields.
1366
- */
1367
- function isRecord(v) {
1368
- return typeof v === "object" && v !== null && !Array.isArray(v);
1369
- }
1370
- //#endregion
1371
1516
  //#region src/lib/fs-bounded.ts
1372
1517
  function closeQuiet(fd) {
1373
1518
  try {
@@ -1597,6 +1742,12 @@ function validate(cfg, knownRuleIds) {
1597
1742
  * Expiry is evaluated only from an explicit ISO `expires` date. A missing
1598
1743
  * date remains active and is reported as having no expiry. Config-file mtime
1599
1744
  * is never an expiry anchor.
1745
+ *
1746
+ * `now` defaults to the current instant so existing callers stay correct, but
1747
+ * a verdict function that reads the clock invisibly is a verdict nobody can
1748
+ * test at a boundary. `tests/contract/deterministic-clock.spec.ts` audits
1749
+ * `src/config/**` for a call site that supplies its own `new Date()` instead
1750
+ * of threading one through.
1600
1751
  */
1601
1752
  function isSuppressionActive(ign, now = /* @__PURE__ */ new Date()) {
1602
1753
  if (!ign.expires) return true;
@@ -1992,6 +2143,7 @@ function parseTsFile(file) {
1992
2143
  }
1993
2144
  return p.createSourceFile(file.path, file.text);
1994
2145
  } catch {
2146
+ recordDegradation("ast-parse-failed");
1995
2147
  return;
1996
2148
  }
1997
2149
  }
@@ -2047,6 +2199,7 @@ function commentAndStringRanges(ctx) {
2047
2199
  }
2048
2200
  return ranges;
2049
2201
  } catch {
2202
+ recordDegradation("ast-range-scan-failed");
2050
2203
  return [];
2051
2204
  }
2052
2205
  }
@@ -2094,6 +2247,7 @@ function getCodeOnlyText(file) {
2094
2247
  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] = " ";
2095
2248
  return chars.join("");
2096
2249
  } catch {
2250
+ recordDegradation("ast-mask-unavailable");
2097
2251
  return file.text;
2098
2252
  }
2099
2253
  }
@@ -2478,6 +2632,44 @@ function frameworkFilterApplies(rule, file) {
2478
2632
  return rule.frameworks.some((f) => tags.includes(f));
2479
2633
  }
2480
2634
  //#endregion
2635
+ //#region src/engine/shared-run-rules.ts
2636
+ /**
2637
+ * Run ONE rule with crash isolation, and return what it produced.
2638
+ *
2639
+ * This is the block that was copy-pasted into all seven language adapters:
2640
+ *
2641
+ * try { for (const f of rule.run(x)) emit(f, rule.id, rule.category); }
2642
+ * catch (error) { onCrash?.(rule.id, error); }
2643
+ *
2644
+ * Extracting it is worth doing for one reason beyond line count. Crash
2645
+ * isolation is the mechanism that lets a third-party or experimental
2646
+ * detector fail without ending a scan, and it is the mechanism the whole
2647
+ * `rulesCrashed` count depends on. Seven copies means seven chances to
2648
+ * write `catch {}` and lose the count, and `tests/contract/
2649
+ * no-uncounted-degradation.spec.ts` can only audit what it can see.
2650
+ *
2651
+ * `null` means the rule CRASHED, which is not the same as an empty array:
2652
+ * an empty array is the rule abstaining, and the adapters skip emission for
2653
+ * both but the distinction is why the caller must not conflate them.
2654
+ *
2655
+ * Deliberately NOT extracted: the per-adapter filter chain and the budget
2656
+ * check. Those genuinely differ — the TypeScript adapter injects an AST,
2657
+ * framework tags, a lazy `codeText` getter, `appliesTo` scoping, config-only
2658
+ * gating and `configGateMatches`, and it ABORTS the whole file on budget
2659
+ * exhaustion where `runRulesShared` only `continue`s to the next rule.
2660
+ * Routing every adapter through `runRulesShared` as written would silently
2661
+ * change which rules run and when, which is precisely the unmeasured claim
2662
+ * this program exists to delete. This helper takes the part they share.
2663
+ */
2664
+ function runRuleIsolated(rule, file, onCrash) {
2665
+ try {
2666
+ return rule.run(file);
2667
+ } catch (error) {
2668
+ onCrash?.(rule.id, error);
2669
+ return null;
2670
+ }
2671
+ }
2672
+ //#endregion
2481
2673
  //#region src/adapters/typescript.ts
2482
2674
  /**
2483
2675
  * TypeScript/JavaScript adapter (R1).
@@ -2687,11 +2879,9 @@ const typescriptAdapter = {
2687
2879
  budget.onExceeded();
2688
2880
  return;
2689
2881
  }
2690
- try {
2691
- for (const f of rule.run(enriched)) emit(f, rule.id, rule.category);
2692
- } catch (error) {
2693
- onCrash?.(rule.id, error);
2694
- }
2882
+ const produced = runRuleIsolated(rule, enriched, onCrash);
2883
+ if (produced === null) continue;
2884
+ for (const f of produced) emit(f, rule.id, rule.category);
2695
2885
  }
2696
2886
  }
2697
2887
  };
@@ -2719,6 +2909,7 @@ function loadWorkspaceShim(root) {
2719
2909
  workspaceGlobs: []
2720
2910
  };
2721
2911
  } catch {
2912
+ recordDegradation("workspace-manifest-unreadable");
2722
2913
  return null;
2723
2914
  }
2724
2915
  }
@@ -3073,11 +3264,9 @@ const pythonAdapter = {
3073
3264
  budget.onExceeded();
3074
3265
  return;
3075
3266
  }
3076
- try {
3077
- for (const f of rule.run(enriched)) emit(f, rule.id, rule.category);
3078
- } catch (error) {
3079
- onCrash?.(rule.id, error);
3080
- }
3267
+ const produced = runRuleIsolated(rule, enriched, onCrash);
3268
+ if (produced === null) continue;
3269
+ for (const f of produced) emit(f, rule.id, rule.category);
3081
3270
  }
3082
3271
  },
3083
3272
  async parseAst(file) {
@@ -3230,11 +3419,9 @@ const javaAdapter = {
3230
3419
  budget.onExceeded();
3231
3420
  return;
3232
3421
  }
3233
- try {
3234
- for (const f of rule.run(enriched)) emit(f, rule.id, rule.category);
3235
- } catch (error) {
3236
- onCrash?.(rule.id, error);
3237
- }
3422
+ const produced = runRuleIsolated(rule, enriched, onCrash);
3423
+ if (produced === null) continue;
3424
+ for (const f of produced) emit(f, rule.id, rule.category);
3238
3425
  }
3239
3426
  }
3240
3427
  };
@@ -3388,11 +3575,9 @@ const csharpAdapter = {
3388
3575
  budget.onExceeded();
3389
3576
  return;
3390
3577
  }
3391
- try {
3392
- for (const f of rule.run(enriched)) emit(f, rule.id, rule.category);
3393
- } catch (error) {
3394
- onCrash?.(rule.id, error);
3395
- }
3578
+ const produced = runRuleIsolated(rule, enriched, onCrash);
3579
+ if (produced === null) continue;
3580
+ for (const f of produced) emit(f, rule.id, rule.category);
3396
3581
  }
3397
3582
  }
3398
3583
  };
@@ -3570,6 +3755,7 @@ const githubActionsAdapter = {
3570
3755
  try {
3571
3756
  entries = readdirSync(wfDir);
3572
3757
  } catch {
3758
+ recordDegradation("ci-workflow-listing-unreadable");
3573
3759
  return;
3574
3760
  }
3575
3761
  for (const name of entries) {
@@ -3610,15 +3796,13 @@ const githubActionsAdapter = {
3610
3796
  budget.onExceeded();
3611
3797
  return;
3612
3798
  }
3613
- try {
3614
- for (const f of rule.run({
3615
- path: file.path,
3616
- text: file.text,
3617
- ast: doc
3618
- })) emit(f, rule.id, rule.category);
3619
- } catch (error) {
3620
- onCrash?.(rule.id, error);
3621
- }
3799
+ const produced = runRuleIsolated(rule, {
3800
+ path: file.path,
3801
+ text: file.text,
3802
+ ast: doc
3803
+ }, onCrash);
3804
+ if (produced === null) continue;
3805
+ for (const f of produced) emit(f, rule.id, rule.category);
3622
3806
  }
3623
3807
  }
3624
3808
  };
@@ -3928,15 +4112,13 @@ const azurePipelinesAdapter = {
3928
4112
  budget.onExceeded();
3929
4113
  return;
3930
4114
  }
3931
- try {
3932
- for (const f of rule.run({
3933
- path: file.path,
3934
- text: file.text,
3935
- ast: doc
3936
- })) emit(f, rule.id, rule.category);
3937
- } catch (error) {
3938
- onCrash?.(rule.id, error);
3939
- }
4115
+ const produced = runRuleIsolated(rule, {
4116
+ path: file.path,
4117
+ text: file.text,
4118
+ ast: doc
4119
+ }, onCrash);
4120
+ if (produced === null) continue;
4121
+ for (const f of produced) emit(f, rule.id, rule.category);
3940
4122
  }
3941
4123
  }
3942
4124
  };
@@ -4007,14 +4189,12 @@ const jenkinsAdapter = {
4007
4189
  budget.onExceeded();
4008
4190
  return;
4009
4191
  }
4010
- try {
4011
- for (const f of rule.run({
4012
- path: file.path,
4013
- text: file.text
4014
- })) emit(f, rule.id, rule.category);
4015
- } catch (error) {
4016
- onCrash?.(rule.id, error);
4017
- }
4192
+ const produced = runRuleIsolated(rule, {
4193
+ path: file.path,
4194
+ text: file.text
4195
+ }, onCrash);
4196
+ if (produced === null) continue;
4197
+ for (const f of produced) emit(f, rule.id, rule.category);
4018
4198
  }
4019
4199
  }
4020
4200
  };
@@ -4141,82 +4321,6 @@ function isInsideSkippedDir(root, absPath, skipNames) {
4141
4321
  }
4142
4322
  typescriptAdapter.testFileGlobs, pythonAdapter.testFileGlobs, javaAdapter.testFileGlobs, csharpAdapter.testFileGlobs, githubActionsAdapter.testFileGlobs, azurePipelinesAdapter.testFileGlobs, jenkinsAdapter.testFileGlobs;
4143
4323
  //#endregion
4144
- //#region src/scope/git-resolve.ts
4145
- /**
4146
- * Absolute-path git resolution (audit S1).
4147
- *
4148
- * `execFileSync("git", …)` resolves through PATH — including the CWD on
4149
- * Windows (CreateProcess searches the current directory before PATH for
4150
- * extension-less names depending on NoDefaultCurrentDirectoryInExePath).
4151
- * Scanning an UNTRUSTED repo therefore let a checked-in `git.exe` (or
4152
- * `git.bat`/`git.cmd`) hijack Mjölnir's own git invocations: the attacker
4153
- * controls the diff output, the merge-base decision, and
4154
- * `--scope changed`'s "what is new" answer — the exact data CI gates on.
4155
- *
4156
- * The fix: resolve git ONCE per process to an ABSOLUTE path by walking
4157
- * the PATH directly (never consulting the CWD), verify the candidate is
4158
- * an existing file, and pass that path to execFileSync. Resolution is
4159
- * memoized; a failure to find a real git anywhere on PATH is recorded
4160
- * and callers degrade without invoking a bare name.
4161
- */
4162
- let resolvedGit = void 0;
4163
- /**
4164
- * The absolute path of the git binary Mjölnir will invoke, or null when
4165
- * PATH carries no executable `git` at all. Callers must degrade without
4166
- * invoking a bare name.
4167
- */
4168
- function resolveGitPath() {
4169
- if (resolvedGit !== void 0) return resolvedGit;
4170
- const candidates = [process.platform === "win32" ? "git.exe" : "git"];
4171
- if (process.platform === "win32") {
4172
- const pathExt = process.env["PATHEXT"]?.split(";").filter(Boolean) ?? [];
4173
- for (const ext of pathExt) candidates.push(`git${ext.toLowerCase()}`);
4174
- }
4175
- const pathValue = process.env["PATH"] ?? "";
4176
- for (const dir of pathValue.split(delimiter)) {
4177
- if (dir === "") continue;
4178
- if (!isAbsolute(dir)) continue;
4179
- for (const candidate of candidates) {
4180
- const full = join(dir, candidate);
4181
- try {
4182
- if (existsSync(full)) {
4183
- resolvedGit = full;
4184
- return resolvedGit;
4185
- }
4186
- } catch {}
4187
- }
4188
- }
4189
- `${pathValue.length}`;
4190
- resolvedGit = null;
4191
- return resolvedGit;
4192
- }
4193
- /**
4194
- * Run git with the S1-resolved absolute binary path. Returns stdout, or
4195
- * null when git is missing or the command fails — the same contract as
4196
- * the previous inline `git()` helpers, with the hijack surface closed.
4197
- */
4198
- function runGit(root, args) {
4199
- const exe = resolveGitPath();
4200
- if (!exe) return null;
4201
- try {
4202
- return execFileSync(exe, [
4203
- "-C",
4204
- root,
4205
- ...args
4206
- ], {
4207
- encoding: "utf8",
4208
- stdio: [
4209
- "ignore",
4210
- "pipe",
4211
- "ignore"
4212
- ],
4213
- timeout: 15e3
4214
- });
4215
- } catch {
4216
- return null;
4217
- }
4218
- }
4219
- //#endregion
4220
4324
  //#region src/scope/changed.ts
4221
4325
  /**
4222
4326
  * Changed-scope engine (Sprint-Plan W6, Product-MVP §9 `--scope changed`).
@@ -11657,6 +11761,53 @@ function getRule(id) {
11657
11761
  return RULES.find((r) => r.id === id);
11658
11762
  }
11659
11763
  //#endregion
11764
+ //#region src/lib/compare.ts
11765
+ /**
11766
+ * Order that does not depend on who is asking.
11767
+ *
11768
+ * `String.prototype.localeCompare` with no locale argument resolves against
11769
+ * the PROCESS's default locale — the ICU collation chosen by `LANG`,
11770
+ * `LC_ALL` or the OS. So a sort written as
11771
+ * `[...xs].sort((a, b) => a.localeCompare(b))` produces a different order on
11772
+ * a developer's `de_DE` machine than on an en-US CI runner. For a display
11773
+ * list that is a nuisance; for anything that reaches a hash, a cache key, a
11774
+ * digest or a machine contract it is a correctness bug, because the same
11775
+ * repository then produces two different fingerprints.
11776
+ *
11777
+ * Two comparators, one per kind of site:
11778
+ *
11779
+ * compareCodePoints — pure UTF-16 code-unit order. Identical everywhere,
11780
+ * independent of ICU version, and already what `Array.prototype.sort`
11781
+ * defaults to. Use for every value that reaches a fingerprint, a digest,
11782
+ * a cache key, or a contract, and for any ordering with no human-facing
11783
+ * reason to be pretty.
11784
+ *
11785
+ * compareLocalized(locale) — the same collator, PINNED to a named locale
11786
+ * instead of inheriting the environment, for a surface that genuinely
11787
+ * wants readable order (a table of rule titles, a Mermaid legend). Pinned
11788
+ * rather than removed because a reader-facing list of "Test / Logic /
11789
+ * Mock" should not be ordered by code unit.
11790
+ *
11791
+ * A bare `localeCompare` with no second argument in `src/` is a defect and
11792
+ * `tests/contract/deterministic-ordering.spec.ts` fails the build on it.
11793
+ */
11794
+ /** Code-unit order. Locale-free, ICU-free, and the Array.sort default. */
11795
+ function compareCodePoints(a, b) {
11796
+ if (a === b) return 0;
11797
+ return a < b ? -1 : 1;
11798
+ }
11799
+ /**
11800
+ * A readable-order comparator pinned to `locale`.
11801
+ *
11802
+ * The pin is the point: `a.localeCompare(b)` reads the environment, and the
11803
+ * environment is a machine setting, not a property of the code. Callers that
11804
+ * want stable output ask for a named locale and get the same answer on every
11805
+ * machine that ships the same ICU data.
11806
+ */
11807
+ function compareLocalized(locale) {
11808
+ return (a, b) => a.localeCompare(b, locale);
11809
+ }
11810
+ //#endregion
11660
11811
  //#region src/scorer/scorer.ts
11661
11812
  /**
11662
11813
  * Transparent scorer (Product-MVP §8).
@@ -11713,7 +11864,7 @@ function computeDimensions(findings) {
11713
11864
  deductions.set(f.category, deductions.get(f.category) + deductionFor(f));
11714
11865
  }
11715
11866
  for (const dim of byCategory.values()) dim.score = Math.max(0, 100 - deductions.get(dim.category));
11716
- return [...byCategory.values()].sort((a, b) => a.category.localeCompare(b.category));
11867
+ return [...byCategory.values()].sort((a, b) => compareCodePoints(a.category, b.category));
11717
11868
  }
11718
11869
  /**
11719
11870
  * Deduction-mass ceilings (product-gap-remediation master plan P2,
@@ -12155,7 +12306,7 @@ function groupByRootCause(findings) {
12155
12306
  function correlateFindings(findings) {
12156
12307
  const conclusions = [];
12157
12308
  const rootCauseGroups = groupByRootCause(findings);
12158
- for (const [rootCause, group] of Array.from(rootCauseGroups.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
12309
+ for (const [rootCause, group] of Array.from(rootCauseGroups.entries()).sort((a, b) => compareLocalized("en")(a[0], b[0]))) {
12159
12310
  if (group.length < 2) continue;
12160
12311
  const findingIds = group.map((f) => f.findingId ?? f.ruleId);
12161
12312
  conclusions.push({
@@ -12167,7 +12318,7 @@ function correlateFindings(findings) {
12167
12318
  });
12168
12319
  }
12169
12320
  const fileGroups = groupByFile(findings);
12170
- for (const [file, group] of Array.from(fileGroups.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
12321
+ for (const [file, group] of Array.from(fileGroups.entries()).sort((a, b) => compareCodePoints(a[0], b[0]))) {
12171
12322
  if (group.length < 2) continue;
12172
12323
  const alreadyConvergent = /* @__PURE__ */ new Set();
12173
12324
  for (const c of conclusions) if (c.conclusionType === "CONVERGENT") for (const id of c.findingIds) alreadyConvergent.add(id);
@@ -12918,9 +13069,10 @@ function hashDir(dir, hash, depth) {
12918
13069
  try {
12919
13070
  entries = readdirSync(dir, { withFileTypes: true });
12920
13071
  } catch {
13072
+ recordDegradation("rules-tree-listing-unreadable");
12921
13073
  return;
12922
13074
  }
12923
- entries.sort((a, b) => a.name.localeCompare(b.name));
13075
+ entries.sort((a, b) => compareCodePoints(a.name, b.name));
12924
13076
  for (const entry of entries) {
12925
13077
  if (entry.name === "node_modules" || entry.name === ".git") continue;
12926
13078
  const full = join(dir, entry.name);
@@ -14319,11 +14471,11 @@ function listFiles(dir) {
14319
14471
  return out.map((p) => p);
14320
14472
  }
14321
14473
  plainContext();
14322
- function loadSuppressions(root) {
14474
+ function loadSuppressions(root, now = /* @__PURE__ */ new Date()) {
14323
14475
  const { config } = loadConfig(root);
14324
14476
  const entries = (config.ignore ?? []).map((ign) => ({
14325
14477
  ...ign,
14326
- status: isSuppressionActive(ign, /* @__PURE__ */ new Date()) ? "active" : "expired"
14478
+ status: isSuppressionActive(ign, now) ? "active" : "expired"
14327
14479
  }));
14328
14480
  return {
14329
14481
  total: entries.length,
@@ -15056,6 +15208,7 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
15056
15208
  let rulesPartial = false;
15057
15209
  let parseFailed = 0;
15058
15210
  let parseFallbacks = 0;
15211
+ let astFallbackFiles = 0;
15059
15212
  let scanned = 0;
15060
15213
  let analyzed = 0;
15061
15214
  for (const path of testFiles) {
@@ -15099,6 +15252,10 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
15099
15252
  });
15100
15253
  }
15101
15254
  const wantsAst = adapter.parseAst !== void 0 && Date.now() <= deadline;
15255
+ if (adapter.parseAst !== void 0 && !wantsAst) {
15256
+ truncationReasons.add("ast-budget-fallback");
15257
+ astFallbackFiles++;
15258
+ }
15102
15259
  const identity = (mode) => ({
15103
15260
  relPath,
15104
15261
  adapterId: adapter.id,
@@ -15216,7 +15373,8 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
15216
15373
  parseFailed,
15217
15374
  parseFallbacks,
15218
15375
  scanned,
15219
- analyzed
15376
+ analyzed,
15377
+ astFallbackFiles
15220
15378
  };
15221
15379
  }
15222
15380
  /**
@@ -15444,6 +15602,8 @@ function assembleScanResult(o) {
15444
15602
  scopeUnrecognized: o.scopeUnrecognized,
15445
15603
  parseFailed: o.parseFailed,
15446
15604
  parseFallbacks: o.parseFallbacks ?? 0,
15605
+ ...o.astFallbackFiles !== void 0 ? { astFallbackFiles: o.astFallbackFiles } : {},
15606
+ ...o.degradations !== void 0 ? { degradations: o.degradations } : {},
15447
15607
  ...o.scopeInfo.degraded !== void 0 ? { scopeDegraded: o.scopeInfo.degraded } : {},
15448
15608
  ...o.runtimeIncomplete !== void 0 ? { runtimeIncomplete: o.runtimeIncomplete } : {},
15449
15609
  identityIncomplete
@@ -15562,13 +15722,15 @@ function assembleScanResult(o) {
15562
15722
  */
15563
15723
  async function runScan(args, hooks = {}) {
15564
15724
  const started = Date.now();
15725
+ const degradationWindow = beginDegradationWindow();
15565
15726
  const requestedDuration = Number.isFinite(args.maxDurationMs) ? args.maxDurationMs : DEFAULT_MAX_DURATION_MS$1;
15566
15727
  const deadline = started + Math.min(Math.max(1, requestedDuration), MAX_DURATION_MS$1);
15567
15728
  const discovered = discoverWorkspace(args.target);
15568
15729
  const targetAbs = resolve(args.target);
15569
15730
  const scanRoot = discovered && discovered.root !== targetAbs && targetAbs.startsWith(discovered.root + sep) ? {
15570
15731
  ...discovered,
15571
- root: targetAbs
15732
+ root: targetAbs,
15733
+ packageJson: {}
15572
15734
  } : discovered ?? fallbackWorkspace(targetAbs);
15573
15735
  const workspace = scanRoot;
15574
15736
  if (args.verbose) hooks.onConfigWarning?.(`verbose: scan root (config/ignore anchor): ${workspace.root}`);
@@ -15653,6 +15815,7 @@ async function runScan(args, hooks = {}) {
15653
15815
  rulesPartial = analysis.rulesPartial;
15654
15816
  parseFailed = analysis.parseFailed;
15655
15817
  const parseFallbacks = analysis.parseFallbacks;
15818
+ const astFallbackFiles = analysis.astFallbackFiles;
15656
15819
  const scanned = analysis.scanned;
15657
15820
  const analyzed = analysis.analyzed;
15658
15821
  const postScan = applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, declarationsByFile, testDeclarationCount, tierByRuleId, REVISION_BY_RULE_ID);
@@ -15671,6 +15834,8 @@ async function runScan(args, hooks = {}) {
15671
15834
  scopeUnrecognized,
15672
15835
  parseFailed,
15673
15836
  parseFallbacks,
15837
+ astFallbackFiles,
15838
+ degradations: summarizeDegradations(degradationsSince(degradationWindow)),
15674
15839
  scanned,
15675
15840
  analyzed,
15676
15841
  testFiles,
@@ -15697,6 +15862,39 @@ async function runScan(args, hooks = {}) {
15697
15862
  resetTsMorphProject();
15698
15863
  return result;
15699
15864
  }
15865
+ //#endregion
15866
+ //#region src/cli-io.ts
15867
+ /**
15868
+ * The ONE error-to-message derivation in `src/`.
15869
+ *
15870
+ * This tree carried four near-copies: two `errorMessage` (byte-identical) and
15871
+ * two `errorText`. The two `errorText` copies were not identical, which is the
15872
+ * part that mattered — `String(someObject)` renders `[object Object]`, so the
15873
+ * copy that lost the payload turned a hostile non-Error throw into a message
15874
+ * that names nothing. The richer arms are kept here, so every call site gets
15875
+ * the informative one and the difference stops being a per-file accident.
15876
+ *
15877
+ * The arms, in order:
15878
+ * Error -> `.message`. The 99% case, and the only one that
15879
+ * carries a stack the caller may still want.
15880
+ * string -> itself. A thrown string is already the message.
15881
+ * plain object -> `JSON.stringify`. A non-Error throw of `{ code:
15882
+ * "EISDIR" }` is a REAL failure shape in Node, and
15883
+ * `[object Object]` hides the only field that says
15884
+ * what went wrong.
15885
+ * anything else -> `String(err)`. Symbols and undefined included, so
15886
+ * nothing vanishes.
15887
+ *
15888
+ * The call this replaces was `err instanceof Error ? err.message : String(err)`
15889
+ * in `src/mcp/server.ts` and `src/commands/doctor.ts`; `tests/cli/summary.spec.ts`
15890
+ * already pins the object arm.
15891
+ */
15892
+ function errorMessage(err) {
15893
+ if (err instanceof Error) return err.message;
15894
+ if (typeof err === "string") return err;
15895
+ if (typeof err === "object" && err !== null) return JSON.stringify(err);
15896
+ return String(err);
15897
+ }
15700
15898
  /**
15701
15899
  * Fields that participate in the verification digest: identity (ruleId,
15702
15900
  * detectorRevision, file, line, column) + evidence (severity,
@@ -15849,6 +16047,11 @@ function explainRule(ruleId, fixturesRoot) {
15849
16047
  ok: false,
15850
16048
  error: `Unknown rule ID "${ruleId}". Run \`mjolnir rules\` for the full catalog.`
15851
16049
  };
16050
+ const window = beginDegradationWindow();
16051
+ const degraded = () => {
16052
+ const counts = summarizeDegradations(degradationsSince(window));
16053
+ return counts.length > 0 ? { exampleDegraded: counts } : {};
16054
+ };
15852
16055
  const fixturePath = firstFixtureFile(join(fixturesRoot, ruleId, "must-fire"));
15853
16056
  if (!fixturePath) return {
15854
16057
  ok: true,
@@ -15858,9 +16061,11 @@ function explainRule(ruleId, fixturesRoot) {
15858
16061
  try {
15859
16062
  text = readFileSync(fixturePath, "utf8").replace(/\r\n/g, "\n");
15860
16063
  } catch {
16064
+ recordDegradation("explain-fixture-unreadable");
15861
16065
  return {
15862
16066
  ok: true,
15863
- rule
16067
+ rule,
16068
+ ...degraded()
15864
16069
  };
15865
16070
  }
15866
16071
  const normalizedPath = fixturePath.replaceAll("\\", "/");
@@ -15870,9 +16075,11 @@ function explainRule(ruleId, fixturesRoot) {
15870
16075
  else try {
15871
16076
  ast = isAzurePipelineFixture(normalizedPath) ? parseAzurePipeline(text) : parseWorkflow(text);
15872
16077
  } catch {
16078
+ recordDegradation("explain-workflow-parse-failed");
15873
16079
  return {
15874
16080
  ok: true,
15875
- rule
16081
+ rule,
16082
+ ...degraded()
15876
16083
  };
15877
16084
  }
15878
16085
  }
@@ -15889,9 +16096,11 @@ function explainRule(ruleId, fixturesRoot) {
15889
16096
  codeText
15890
16097
  });
15891
16098
  } catch {
16099
+ recordDegradation("explain-rule-crash");
15892
16100
  return {
15893
16101
  ok: true,
15894
- rule
16102
+ rule,
16103
+ ...degraded()
15895
16104
  };
15896
16105
  }
15897
16106
  const example = findings[0];
@@ -16652,15 +16861,11 @@ async function handleToolCall(call) {
16652
16861
  id: call.id,
16653
16862
  error: {
16654
16863
  code: MCP_ERRORS.INTERNAL,
16655
- message: errorMessage$1(err)
16864
+ message: errorMessage(err)
16656
16865
  }
16657
16866
  };
16658
16867
  }
16659
16868
  }
16660
- /** Normalize any thrown value to a string (unit-tested both arms). */
16661
- function errorMessage$1(err) {
16662
- return err instanceof Error ? err.message : String(err);
16663
- }
16664
16869
  /** JSON-RPC request shape validation (strict — §21). */
16665
16870
  function validateRequest(msg) {
16666
16871
  if (typeof msg !== "object" || msg === null) return {
@@ -16763,14 +16968,6 @@ async function handleMcpMessage(msg) {
16763
16968
  };
16764
16969
  }
16765
16970
  //#endregion
16766
- //#region src/cli-io.ts
16767
- /**
16768
- * The single error-to-message derivation shared by every catch site:
16769
- * Error instances render their message; anything else is stringified
16770
- * honestly (a hostile non-Error throw must surface, not vanish).
16771
- */
16772
- const errorMessage = (err) => err instanceof Error ? err.message : String(err);
16773
- //#endregion
16774
16971
  //#region src/mcp/transport.ts
16775
16972
  /**
16776
16973
  * MCP stdio transport loop (blueprint §21 — the framing around the