mjolnir-qa 4.0.0 → 6.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.
@@ -5,6 +5,7 @@ import { createHash, randomBytes } from "node:crypto";
5
5
  import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
6
6
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
7
  import { execFileSync } from "node:child_process";
8
+ import { AsyncLocalStorage } from "node:async_hooks";
8
9
  import * as ts$2 from "ts-morph";
9
10
  import ts, { Project, SyntaxKind, ts as ts$1 } from "ts-morph";
10
11
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -35,6 +36,23 @@ const SEVERITY_ORDER = [
35
36
  * retried, or timed out in the report).
36
37
  * INVARIANT (structurally enforced): L3–L5 require runtime
37
38
  * corroboration — a static-only finding can never claim L4/L5.
39
+ *
40
+ * THREE LADDER VOCABULARIES now share this shape, and the letters are the
41
+ * only thing distinguishing them:
42
+ *
43
+ * `L0`–`L5` RUNTIME TRUST — how much a consumer can believe one finding.
44
+ * Here.
45
+ * `F0`–`F5` FRAMEWORK SUPPORT — a hand-declared target per framework.
46
+ * `FrameworkSupportLevel`, src/frameworks/framework-inventory.ts
47
+ * `M0`–`M5` CAPABILITY MATURITY — DERIVED from evidence, and the only
48
+ * one of the three that is a measurement.
49
+ * `Maturity`, src/v6/maturity.ts
50
+ *
51
+ * `tests/v6/maturity-vocabulary.spec.ts` holds the two vocabulary sets apart
52
+ * and fails if they ever share a member. `M` is the only DERIVED of the three,
53
+ * and that is the distinction worth remembering when reading a level out of a
54
+ * report: `F` and `L` are declarations about the world, `M` is a function of
55
+ * what this repository can demonstrate.
38
56
  */
39
57
  const TRUST_ORDER = [
40
58
  "L0",
@@ -85,6 +103,7 @@ const DEDUCTIONS = {
85
103
  //#region src/engine/completion.ts
86
104
  function deriveCompletion(input) {
87
105
  const truncationReasons = [...new Set(input.truncationReasons)].sort();
106
+ const degradations = [...input.degradations ?? []].sort((a, b) => a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0);
88
107
  const reasons = /* @__PURE__ */ new Set();
89
108
  if (input.discoveryTruncated) reasons.add("discovery-truncated");
90
109
  if (input.rulesPartial) reasons.add("rules-partial");
@@ -94,26 +113,73 @@ function deriveCompletion(input) {
94
113
  if (input.scopeUnrecognized > 0) reasons.add(`scope-unrecognized:${input.scopeUnrecognized}`);
95
114
  if (input.parseFailed > 0) reasons.add(`parse-failed:${input.parseFailed}`);
96
115
  if (input.parseFallbacks && input.parseFallbacks > 0) reasons.add(`parse-fallbacks:${input.parseFallbacks}`);
116
+ if (input.astFallbackFiles && input.astFallbackFiles > 0) reasons.add(`ast-budget-fallback-files:${input.astFallbackFiles}`);
97
117
  if (input.scopeDegraded) reasons.add(`scope-degraded:${input.scopeDegraded}`);
98
118
  if (input.runtimeIncomplete) reasons.add("runtime-incomplete");
99
119
  if (input.identityIncomplete) reasons.add("identity-incomplete");
100
120
  for (const reason of truncationReasons) reasons.add(`truncated:${reason}`);
121
+ if ((input.rulesWithheld ?? 0) > 0) reasons.add(`coverage:quarantine:${input.rulesWithheld}`);
122
+ for (const entry of degradations) reasons.add(`degraded:${entry.reason}:${entry.count}`);
101
123
  const discoveryPartial = input.discoveryTruncated || input.scopeIgnored > 0 || input.scopeUnrecognized > 0 || input.scopeDegraded !== void 0;
102
124
  const rulesPartial = input.rulesPartial || input.rulesCrashed > 0 || input.parseFailed > 0;
125
+ const partial = discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0 || degradations.length > 0;
126
+ const rulesWithheld = input.rulesWithheld ?? 0;
103
127
  return {
104
- partial: discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0,
128
+ partial,
129
+ coverageState: rulesWithheld > 0 ? "PARTIAL" : "COMPLETE",
105
130
  analysisStatus: {
106
131
  discovery: discoveryPartial ? "partial" : "complete",
107
132
  rules: rulesPartial ? "partial" : "complete",
108
133
  skippedFiles: input.skippedFiles,
109
134
  rulesCrashed: input.rulesCrashed,
110
135
  parseFallbacks: input.parseFallbacks ?? 0,
136
+ rulesApplied: input.rulesApplied ?? 0,
137
+ rulesWithheld,
138
+ ...input.withheldRuleIds !== void 0 && input.withheldRuleIds.length > 0 ? { withheldRuleIds: [...input.withheldRuleIds].sort() } : {},
111
139
  ...truncationReasons.length > 0 ? { truncationReasons } : {},
140
+ ...degradations.length > 0 ? { degradations } : {},
112
141
  reasons: [...reasons].sort()
113
142
  }
114
143
  };
115
144
  }
116
145
  //#endregion
146
+ //#region src/engine/degradation-ledger.ts
147
+ /**
148
+ * Append-only. Records are never edited and never removed by a recording
149
+ * site; a reader takes a window, and the window is defined by index, so
150
+ * "delete my entry" is not an operation this module offers.
151
+ */
152
+ const records = [];
153
+ /** Record one capability loss. `count` is for a site that fails N at once. */
154
+ function recordDegradation(reason, count = 1) {
155
+ if (!Number.isInteger(count) || count < 1) return;
156
+ for (let index = 0; index < count; index++) records.push({ reason });
157
+ }
158
+ /**
159
+ * Mark where this reader's window opens. Everything appended from here on is
160
+ * what the reader will see; see the file header for what a concurrent scan
161
+ * does to that boundary.
162
+ */
163
+ function beginDegradationWindow() {
164
+ return records.length;
165
+ }
166
+ /** The records appended since `marker`, without consuming them. */
167
+ function degradationsSince(marker) {
168
+ return records.slice(marker);
169
+ }
170
+ /**
171
+ * Per-reason counts for a window, sorted by reason so the output is
172
+ * byte-deterministic for the same set of degradations.
173
+ */
174
+ function summarizeDegradations(window) {
175
+ const counts = /* @__PURE__ */ new Map();
176
+ for (const record of window) counts.set(record.reason, (counts.get(record.reason) ?? 0) + 1);
177
+ return [...counts.entries()].map(([reason, count]) => ({
178
+ reason,
179
+ count
180
+ })).sort((a, b) => a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0);
181
+ }
182
+ //#endregion
117
183
  //#region src/engine/runtime-corroboration.ts
118
184
  /**
119
185
  * Stamp runtime corroboration + trust levels onto findings (mutates in
@@ -334,10 +400,10 @@ const MEASURED_FP = {
334
400
  },
335
401
  "QA-JV-101": {
336
402
  fpRate: 0,
337
- n: 23,
403
+ n: 35,
338
404
  detectorRevision: 1,
339
405
  ciLow: 0,
340
- ciHigh: .1431
406
+ ciHigh: .0989
341
407
  },
342
408
  "QA-JV-102": {
343
409
  fpRate: .261,
@@ -453,10 +519,10 @@ const MEASURED_FP = {
453
519
  },
454
520
  "QA-PW-117": {
455
521
  fpRate: 0,
456
- n: 24,
522
+ n: 35,
457
523
  detectorRevision: 1,
458
524
  ciLow: 0,
459
- ciHigh: .138
525
+ ciHigh: .0989
460
526
  },
461
527
  "QA-PW-121": {
462
528
  fpRate: 0,
@@ -543,25 +609,18 @@ const MEASURED_FP = {
543
609
  ciHigh: .2425
544
610
  },
545
611
  "QA-PY-002": {
546
- fpRate: .043,
547
- n: 23,
612
+ fpRate: .12,
613
+ n: 25,
548
614
  detectorRevision: 1,
549
- ciLow: .0077,
550
- ciHigh: .2099
615
+ ciLow: .0417,
616
+ ciHigh: .2996
551
617
  },
552
618
  "QA-PY-003": {
553
- fpRate: .467,
554
- n: 30,
619
+ fpRate: .515,
620
+ n: 33,
555
621
  detectorRevision: 4,
556
- ciLow: .3023,
557
- ciHigh: .6386
558
- },
559
- "QA-PY-004": {
560
- fpRate: .533,
561
- n: 30,
562
- detectorRevision: 3,
563
- ciLow: .3614,
564
- ciHigh: .6977
622
+ ciLow: .3522,
623
+ ciHigh: .675
565
624
  },
566
625
  "QA-PY-005": {
567
626
  fpRate: .13,
@@ -844,6 +903,58 @@ function buildTrustSummary(result, declarationsByFile) {
844
903
  };
845
904
  }
846
905
  //#endregion
906
+ //#region src/governance/run-mode.ts
907
+ /**
908
+ * Which mode a run is in, and how the other one is reached.
909
+ *
910
+ * ADR 0012: `local-only` is the default and hosted mode is a declared state.
911
+ * This module is the enforcement point, and its shape is the decision:
912
+ *
913
+ * - `resolveRunMode` takes its inputs as an ARGUMENT, never from
914
+ * `process.env`. Nothing here can read the environment, so a stray
915
+ * `MJOLNIR_MODE` in a shell, in CI, or in a parent's environment cannot
916
+ * change what a run does. The first version of the test passed an `env`
917
+ * bag and asserted it was ignored — and that only means something because
918
+ * this function is structurally unable to consult one.
919
+ * - `isHostedModeReachable` is the single predicate every entry point has to
920
+ * pass through, and it answers the same question separately from
921
+ * `resolveRunMode`. Separating them is what stops a caller satisfying the
922
+ * easy one: the first says whether the mode MAY be entered, the second
923
+ * says which mode this run IS.
924
+ * - an unrecognised declaration THROWS. A typo in a build flag that fell back
925
+ * to `local-only` would mean a deployment believed it was hosted and was
926
+ * not — the same class of failure as a gate that reports success for
927
+ * something it did not run.
928
+ *
929
+ * Hosted mode is not implemented. This is the contract it will have to
930
+ * satisfy, and it is deliberately the smallest thing that can be wrong in only
931
+ * one direction.
932
+ */
933
+ /**
934
+ * The build-time declaration that opts a build into hosted mode.
935
+ *
936
+ * A build flag rather than an environment variable, because a build flag is
937
+ * something a release engineer sets deliberately and reviews in a diff, and
938
+ * because an environment variable is set by whatever CI last happened to run
939
+ * with.
940
+ */
941
+ const HOSTED_MODE_FLAG = "mjolnir-mode";
942
+ /**
943
+ * The mode this run is in.
944
+ *
945
+ * Throws on a declaration it does not recognise. A silent fallback here is the
946
+ * failure ADR 0012 exists to prevent: a build that asked for hosted mode,
947
+ * mistyped the flag, and ran local-only would report local-only artefacts into
948
+ * a pipeline that believed otherwise.
949
+ */
950
+ function resolveRunMode(input) {
951
+ const declared = input.flags[HOSTED_MODE_FLAG];
952
+ if (declared === void 0 || declared === false) return "local-only";
953
+ if (declared === true) throw new Error(`${HOSTED_MODE_FLAG} was given without a value. Use "${HOSTED_MODE_FLAG}=hosted" or omit it.`);
954
+ if (declared === "local-only" || declared === "hosted") return declared;
955
+ throw new Error(`${HOSTED_MODE_FLAG}=${String(declared)} is not a run mode. Expected "hosted" or "local-only". Falling back to a default here would let a mistyped flag look like a deliberate choice.`);
956
+ }
957
+ //#endregion
847
958
  //#region src/engine/run-identity.ts
848
959
  /**
849
960
  * Run Identity (product-gap master plan §7, plan 1789009691197 R4c) —
@@ -884,6 +995,7 @@ function buildRunIdentity(input) {
884
995
  if (input.commit !== void 0) verdictInputs.commit = input.commit;
885
996
  if (input.tree !== void 0) verdictInputs.tree = input.tree;
886
997
  if (input.lockfile !== void 0) verdictInputs.lockfile = input.lockfile;
998
+ if (input.runMode !== void 0) verdictInputs.runMode = input.runMode;
887
999
  if (input.candidate !== void 0) verdictInputs.candidate = {
888
1000
  manifestId: input.candidate.manifestId,
889
1001
  state: input.candidate.state,
@@ -916,6 +1028,7 @@ function buildRunIdentity(input) {
916
1028
  rulesDigest,
917
1029
  configFingerprint,
918
1030
  engineVersion: input.engineVersion,
1031
+ ...input.runMode !== void 0 ? { runMode: input.runMode } : {},
919
1032
  boundLinks,
920
1033
  ...input.trustModelVersion !== void 0 ? { trustModelVersion: input.trustModelVersion } : {},
921
1034
  ...input.scoringModelVersion !== void 0 ? { scoringModelVersion: input.scoringModelVersion } : {},
@@ -1064,6 +1177,120 @@ function createExecutor(name, concurrency, runOne) {
1064
1177
  };
1065
1178
  }
1066
1179
  //#endregion
1180
+ //#region src/scope/git-resolve.ts
1181
+ /**
1182
+ * Absolute-path git resolution (audit S1).
1183
+ *
1184
+ * `execFileSync("git", …)` resolves through PATH — including the CWD on
1185
+ * Windows (CreateProcess searches the current directory before PATH for
1186
+ * extension-less names depending on NoDefaultCurrentDirectoryInExePath).
1187
+ * Scanning an UNTRUSTED repo therefore let a checked-in `git.exe` (or
1188
+ * `git.bat`/`git.cmd`) hijack Mjölnir's own git invocations: the attacker
1189
+ * controls the diff output, the merge-base decision, and
1190
+ * `--scope changed`'s "what is new" answer — the exact data CI gates on.
1191
+ *
1192
+ * The fix: resolve git ONCE per process to an ABSOLUTE path by walking
1193
+ * the PATH directly (never consulting the CWD), verify the candidate is
1194
+ * an existing file, and pass that path to execFileSync. Resolution is
1195
+ * memoized; a failure to find a real git anywhere on PATH is recorded
1196
+ * and callers degrade without invoking a bare name.
1197
+ */
1198
+ let resolvedGit = void 0;
1199
+ /**
1200
+ * The absolute path of the git binary Mjölnir will invoke, or null when
1201
+ * PATH carries no executable `git` at all. Callers must degrade without
1202
+ * invoking a bare name.
1203
+ */
1204
+ function resolveGitPath() {
1205
+ if (resolvedGit !== void 0) return resolvedGit;
1206
+ const candidates = [process.platform === "win32" ? "git.exe" : "git"];
1207
+ if (process.platform === "win32") {
1208
+ const pathExt = process.env["PATHEXT"]?.split(";").filter(Boolean) ?? [];
1209
+ for (const ext of pathExt) candidates.push(`git${ext.toLowerCase()}`);
1210
+ }
1211
+ const pathValue = process.env["PATH"] ?? "";
1212
+ for (const dir of pathValue.split(delimiter)) {
1213
+ if (dir === "") continue;
1214
+ if (!isAbsolute(dir)) continue;
1215
+ for (const candidate of candidates) {
1216
+ const full = join(dir, candidate);
1217
+ try {
1218
+ if (existsSync(full)) {
1219
+ resolvedGit = full;
1220
+ return resolvedGit;
1221
+ }
1222
+ } catch {}
1223
+ }
1224
+ }
1225
+ `${pathValue.length}`;
1226
+ resolvedGit = null;
1227
+ return resolvedGit;
1228
+ }
1229
+ /**
1230
+ * Run git with the S1-resolved absolute binary path. Returns stdout, or
1231
+ * null when git is missing or the command fails — the same contract as
1232
+ * the previous inline `git()` helpers, with the hijack surface closed.
1233
+ */
1234
+ function runGit(root, args) {
1235
+ const exe = resolveGitPath();
1236
+ if (!exe) return null;
1237
+ try {
1238
+ return execFileSync(exe, [
1239
+ "-C",
1240
+ root,
1241
+ ...args
1242
+ ], {
1243
+ encoding: "utf8",
1244
+ stdio: [
1245
+ "ignore",
1246
+ "pipe",
1247
+ "ignore"
1248
+ ],
1249
+ timeout: 15e3
1250
+ });
1251
+ } catch {
1252
+ return null;
1253
+ }
1254
+ }
1255
+ //#endregion
1256
+ //#region src/lib/safe-json.ts
1257
+ /**
1258
+ * Reusable safe-JSON parse-and-validate helper.
1259
+ *
1260
+ * Replaces the `JSON.parse(text) as T` pattern with a two-step process:
1261
+ * 1. Try/catch around JSON.parse (descriptive error on bad JSON)
1262
+ * 2. Runtime validation of the parsed shape (caller-supplied predicate)
1263
+ *
1264
+ * The `validate` predicate is intentionally lightweight — it checks the
1265
+ * expected shape exists rather than exhaustively type-guarding every field.
1266
+ * Internal files (cache, hashes) use `isRecord`; user-facing files
1267
+ * (config, plugins) use stricter checks that throw descriptive errors.
1268
+ */
1269
+ const MAX_JSON_BYTES = 16777216;
1270
+ function parseJsonFile(text, source, validate) {
1271
+ if (Buffer.byteLength(text, "utf8") > MAX_JSON_BYTES) throw new Error(`JSON source exceeds the ${MAX_JSON_BYTES}-byte limit: ${source}`);
1272
+ let parsed;
1273
+ try {
1274
+ parsed = JSON.parse(text);
1275
+ } catch (err) {
1276
+ throw new Error(`invalid JSON in ${source}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
1277
+ }
1278
+ if (!validate(parsed)) throw new Error(`unexpected shape in ${source}`, { cause: parsed });
1279
+ return parsed;
1280
+ }
1281
+ /**
1282
+ * Minimal validator: value is a plain object (not null, not array).
1283
+ * Sufficient for internal files (cache, hashes) where downstream
1284
+ * code already checks individual fields.
1285
+ */
1286
+ function isRecord(v) {
1287
+ try {
1288
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1289
+ } catch {
1290
+ return false;
1291
+ }
1292
+ }
1293
+ //#endregion
1067
1294
  //#region src/engine/candidate-binding.ts
1068
1295
  /**
1069
1296
  * Candidate binding (plan V5-010).
@@ -1084,9 +1311,6 @@ const SHA40 = /^[a-f0-9]{40}$/;
1084
1311
  const SHA256 = /^[a-f0-9]{64}$/;
1085
1312
  const STATES = ["WORKING_CANDIDATE", "RELEASE_CANDIDATE"];
1086
1313
  const AUTHORIZATION = ["NOT_AUTHORIZED", "AUTHORIZED"];
1087
- function isRecord$1(value) {
1088
- return typeof value === "object" && value !== null && !Array.isArray(value);
1089
- }
1090
1314
  function sha256File(path) {
1091
1315
  try {
1092
1316
  return createHash("sha256").update(readFileSync(path)).digest("hex");
@@ -1094,17 +1318,41 @@ function sha256File(path) {
1094
1318
  return;
1095
1319
  }
1096
1320
  }
1321
+ /**
1322
+ * One git line, or nothing.
1323
+ *
1324
+ * Audit S1: the binary is the ABSOLUTE path `resolveGitPath()` found by
1325
+ * walking PATH itself, never the bare name. `bindRepository()` runs on every
1326
+ * scan (`scan-pipeline.ts`), and a bare `execFileSync("git", …)` lets a
1327
+ * `git.exe`/`git.bat`/`git.cmd` CHECKED INTO AN UNTRUSTED REPO hijack the
1328
+ * call on Windows, where CreateProcess searches the current directory before
1329
+ * PATH. The attacker then controls the `commit` and `tree` that land in
1330
+ * `runIdentity` and the evidence graph — the provenance a release decision
1331
+ * reads. This was the last bare-name exec left in `src/`; the invariant that
1332
+ * keeps it that way is `tests/contract/no-bare-process-exec.spec.ts`.
1333
+ *
1334
+ * The `-C <root>` shape (not `cwd`) matches every other hardened call site.
1335
+ * With no git on PATH at all, the answer is nothing — a scan of an unpacked
1336
+ * tarball has no commit, and its identity must say so rather than carrying a
1337
+ * placeholder that looks like provenance.
1338
+ */
1097
1339
  function gitLine(root, args) {
1340
+ const exe = resolveGitPath();
1341
+ if (!exe) return void 0;
1098
1342
  try {
1099
- const trimmed = execFileSync("git", args, {
1100
- cwd: root,
1343
+ const trimmed = execFileSync(exe, [
1344
+ "-C",
1345
+ root,
1346
+ ...args
1347
+ ], {
1101
1348
  encoding: "utf8",
1102
1349
  stdio: [
1103
1350
  "ignore",
1104
1351
  "pipe",
1105
1352
  "ignore"
1106
1353
  ],
1107
- windowsHide: true
1354
+ windowsHide: true,
1355
+ timeout: 15e3
1108
1356
  }).trim();
1109
1357
  return trimmed.length > 0 ? trimmed : void 0;
1110
1358
  } catch {
@@ -1135,9 +1383,9 @@ function bindRepository(root) {
1135
1383
  * contradiction the release decision evaluator treats as BLOCKED).
1136
1384
  */
1137
1385
  function parseCandidateBinding(raw) {
1138
- if (!isRecord$1(raw)) return null;
1386
+ if (!isRecord(raw)) return null;
1139
1387
  const identity = raw.identity;
1140
- if (!isRecord$1(identity)) return null;
1388
+ if (!isRecord(identity)) return null;
1141
1389
  const state = identity.state;
1142
1390
  const candidateSha = identity.candidateSha;
1143
1391
  const baseSha = identity.baseSha;
@@ -1183,10 +1431,50 @@ function readCandidateBinding(root, manifestPath = CANDIDATE_MANIFEST_PATH) {
1183
1431
  * version inside scan-pipeline WITHOUT importing cli.ts (a cycle — cli
1184
1432
  * imports the pipeline). The literal follows the same discipline as
1185
1433
  * CLI_VERSION and SARIF's driver.version: kept in sync on release by
1186
- * scripts/sync-sarif-version.cjs and guarded by the version-consistency
1434
+ * scripts/archive/sync-sarif-version.cjs (archived; superseded by the release
1435
+ * workflow's own version stamping) and guarded by the version-consistency
1187
1436
  * spec. cli.ts re-exports this as CLI_VERSION.
1188
1437
  */
1189
- const ENGINE_VERSION = "4.0.0";
1438
+ const ENGINE_VERSION = "6.0.0";
1439
+ resolveBuildId();
1440
+ /**
1441
+ * The decision, with the process boundary already behind `runGit`.
1442
+ *
1443
+ * Separated so every branch is reachable from a test: a runner that throws and
1444
+ * a runner that answers are both things that happen, and both are what this
1445
+ * function exists to survive. What it must never do is invent an answer.
1446
+ */
1447
+ function buildIdFrom(runGit) {
1448
+ const sha = runGit([
1449
+ "rev-parse",
1450
+ "--short=12",
1451
+ "HEAD"
1452
+ ]);
1453
+ if (!sha) return void 0;
1454
+ const status = runGit(["status", "--porcelain"]);
1455
+ if (status === void 0) return `${sha}-dirty`;
1456
+ return status ? `${sha}-dirty` : sha;
1457
+ }
1458
+ function resolveBuildId() {
1459
+ const git = resolveGitPath();
1460
+ if (!git) return void 0;
1461
+ const runGit = (args) => {
1462
+ try {
1463
+ return execFileSync(git, args, {
1464
+ encoding: "utf8",
1465
+ timeout: 2e3,
1466
+ stdio: [
1467
+ "ignore",
1468
+ "pipe",
1469
+ "ignore"
1470
+ ]
1471
+ }).trim();
1472
+ } catch {
1473
+ return;
1474
+ }
1475
+ };
1476
+ return buildIdFrom(runGit);
1477
+ }
1190
1478
  //#endregion
1191
1479
  //#region src/engine/contract-versions.ts
1192
1480
  /**
@@ -1198,7 +1486,15 @@ const ENGINE_VERSION = "4.0.0";
1198
1486
  * surface without widening.
1199
1487
  */
1200
1488
  const TRUST_MODEL_VERSION = "1.0.0";
1201
- const SCORING_MODEL_VERSION = "1.0.0";
1489
+ /**
1490
+ * 2.0.0 (ADR 0014): the core tier carries an evidence FLOOR. A core rule's
1491
+ * finding is now stamped to at least E1, which changes `deductionFor` for
1492
+ * every core finding from 0 (E0) to `floor(base/2)`. The E0→E1→E2 ladder
1493
+ * itself is unchanged; what changed is which rung a core finding is allowed
1494
+ * to sit on, and therefore what it costs. Major, per the `semver + ADR
1495
+ * required` policy on the entry below.
1496
+ */
1497
+ const SCORING_MODEL_VERSION = "2.0.0";
1202
1498
  const FRAMEWORK_SUPPORT_MATRIX_VERSION = "1.0.0";
1203
1499
  //#endregion
1204
1500
  //#region src/discovery/evidence-discovery.ts
@@ -1245,6 +1541,11 @@ const EVIDENCE_CONVENTIONS = [
1245
1541
  convention: "junit-file",
1246
1542
  kind: "file",
1247
1543
  names: ["junit-report.xml", "junit.xml"]
1544
+ },
1545
+ {
1546
+ convention: "dotnet-trx",
1547
+ kind: "dir",
1548
+ names: ["TestResults"]
1248
1549
  }
1249
1550
  ];
1250
1551
  const SEARCH_DEPTH = 2;
@@ -1315,9 +1616,12 @@ function discoverWorkspace(rootDir) {
1315
1616
  const root = findProjectRoot(rootDir);
1316
1617
  if (!root) return null;
1317
1618
  let pkg = {};
1318
- try {
1319
- pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
1320
- } catch {}
1619
+ const manifestPath = join(root, "package.json");
1620
+ if (existsSync(manifestPath)) try {
1621
+ pkg = JSON.parse(readFileSync(manifestPath, "utf8"));
1622
+ } catch {
1623
+ recordDegradation("workspace-manifest-unreadable");
1624
+ }
1321
1625
  const rawWorkspaces = pkg["workspaces"];
1322
1626
  let globs = [];
1323
1627
  if (Array.isArray(rawWorkspaces)) globs = rawWorkspaces.filter((w) => typeof w === "string");
@@ -1334,40 +1638,6 @@ function basename$1(p) {
1334
1638
  return parts[parts.length - 1];
1335
1639
  }
1336
1640
  //#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
1641
  //#region src/lib/fs-bounded.ts
1372
1642
  function closeQuiet(fd) {
1373
1643
  try {
@@ -1597,6 +1867,12 @@ function validate(cfg, knownRuleIds) {
1597
1867
  * Expiry is evaluated only from an explicit ISO `expires` date. A missing
1598
1868
  * date remains active and is reported as having no expiry. Config-file mtime
1599
1869
  * is never an expiry anchor.
1870
+ *
1871
+ * `now` defaults to the current instant so existing callers stay correct, but
1872
+ * a verdict function that reads the clock invisibly is a verdict nobody can
1873
+ * test at a boundary. `tests/contract/deterministic-clock.spec.ts` audits
1874
+ * `src/config/**` for a call site that supplies its own `new Date()` instead
1875
+ * of threading one through.
1600
1876
  */
1601
1877
  function isSuppressionActive(ign, now = /* @__PURE__ */ new Date()) {
1602
1878
  if (!ign.expires) return true;
@@ -1621,6 +1897,108 @@ function applySeverityOverrides(findings, cfg) {
1621
1897
  }
1622
1898
  }
1623
1899
  //#endregion
1900
+ //#region src/lib/compare.ts
1901
+ /**
1902
+ * Order that does not depend on who is asking.
1903
+ *
1904
+ * `String.prototype.localeCompare` with no locale argument resolves against
1905
+ * the PROCESS's default locale — the ICU collation chosen by `LANG`,
1906
+ * `LC_ALL` or the OS. So a sort written as
1907
+ * `[...xs].sort((a, b) => a.localeCompare(b))` produces a different order on
1908
+ * a developer's `de_DE` machine than on an en-US CI runner. For a display
1909
+ * list that is a nuisance; for anything that reaches a hash, a cache key, a
1910
+ * digest or a machine contract it is a correctness bug, because the same
1911
+ * repository then produces two different fingerprints.
1912
+ *
1913
+ * Two comparators, one per kind of site:
1914
+ *
1915
+ * compareCodePoints — pure UTF-16 code-unit order. Identical everywhere,
1916
+ * independent of ICU version, and already what `Array.prototype.sort`
1917
+ * defaults to. Use for every value that reaches a fingerprint, a digest,
1918
+ * a cache key, or a contract, and for any ordering with no human-facing
1919
+ * reason to be pretty.
1920
+ *
1921
+ * compareLocalized(locale) — the same collator, PINNED to a named locale
1922
+ * instead of inheriting the environment, for a surface that genuinely
1923
+ * wants readable order (a table of rule titles, a Mermaid legend). Pinned
1924
+ * rather than removed because a reader-facing list of "Test / Logic /
1925
+ * Mock" should not be ordered by code unit.
1926
+ *
1927
+ * A bare `localeCompare` with no second argument in `src/` is a defect and
1928
+ * `tests/contract/deterministic-ordering.spec.ts` fails the build on it.
1929
+ */
1930
+ /** Code-unit order. Locale-free, ICU-free, and the Array.sort default. */
1931
+ function compareCodePoints(a, b) {
1932
+ if (a === b) return 0;
1933
+ return a < b ? -1 : 1;
1934
+ }
1935
+ /**
1936
+ * A readable-order comparator pinned to `locale`.
1937
+ *
1938
+ * The pin is the point: `a.localeCompare(b)` reads the environment, and the
1939
+ * environment is a machine setting, not a property of the code. Callers that
1940
+ * want stable output ask for a named locale and get the same answer on every
1941
+ * machine that ships the same ICU data.
1942
+ */
1943
+ function compareLocalized(locale) {
1944
+ return (a, b) => a.localeCompare(b, locale);
1945
+ }
1946
+ //#endregion
1947
+ //#region src/lib/glob.ts
1948
+ /**
1949
+ * The shared segment compiler: a glob to a regex BODY, with no anchors.
1950
+ *
1951
+ * Module-private because the anchoring is a caller decision and the DIALECT is
1952
+ * not: the repository needs a full-path match and a bare-name-at-any-depth
1953
+ * match, and duplicating the segment walk to get the second one is exactly how
1954
+ * two compilers came to disagree. Callers pick an anchoring; the dialect is
1955
+ * decided here and nowhere else.
1956
+ *
1957
+ * `?`, `[`, `]`, `!` and every other regex metacharacter in the literal parts
1958
+ * are escaped, so nothing a user writes in a config or ledger can reach the
1959
+ * regex engine as syntax. `*` is the only metacharacter.
1960
+ */
1961
+ function globBody(glob) {
1962
+ const segments = glob.replaceAll("\\", "/").split("/");
1963
+ let re = "";
1964
+ for (const [i, segment] of segments.entries()) {
1965
+ const last = i === segments.length - 1;
1966
+ if (segment === "**") {
1967
+ re += last ? "(?:[^/]+/)*[^/]+" : "(?:[^/]+/)*";
1968
+ continue;
1969
+ }
1970
+ re += segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", "[^/]*");
1971
+ if (!last) re += "/";
1972
+ }
1973
+ return re;
1974
+ }
1975
+ /** A glob as a regex anchored to the WHOLE path. */
1976
+ function anchoredGlobRegExp(glob) {
1977
+ return new RegExp(`^${globBody(glob)}$`);
1978
+ }
1979
+ /**
1980
+ * A glob as a regex matching at ANY DEPTH — a bare name.
1981
+ *
1982
+ * `node_modules` becomes "a file or directory of this name anywhere", which is
1983
+ * gitignore semantics: every path inside the directory contains the segment, so
1984
+ * a directory match carries to its contents.
1985
+ */
1986
+ function anyDepthGlobRegExp(glob) {
1987
+ return new RegExp(`(?:^|/)${globBody(glob)}(?:/|$)`);
1988
+ }
1989
+ /**
1990
+ * Whether `path` matches `glob`, in the dialect documented above.
1991
+ *
1992
+ * Both sides are normalized to forward slashes first. Finding paths are already
1993
+ * normalized by the walker, but a suppression `files` pattern written on
1994
+ * Windows ("e2e\\x.spec.ts") compiled to a literal-backslash regex that could
1995
+ * never match any finding — the suppression silently never applied, which is
1996
+ * the defect that made the whole dialect worth unifying.
1997
+ */
1998
+ function pathMatchesGlob(path, glob) {
1999
+ return anchoredGlobRegExp(glob).test(path.replaceAll("\\", "/"));
2000
+ }
2001
+ //#endregion
1624
2002
  //#region src/discovery/ignores.ts
1625
2003
  /**
1626
2004
  * Ignore resolution (Product-MVP.txt R1 — Tier-1 launch blocker).
@@ -1755,32 +2133,12 @@ function compilePattern(raw) {
1755
2133
  if (pattern.trim() === "") return null;
1756
2134
  if (pattern.length > LIMITS$1.maxPatternLength) return null;
1757
2135
  if ((pattern.match(/[*?]/g) ?? []).length > LIMITS$1.maxPatternWildcards) return null;
1758
- const re = pattern.includes("/") ? new RegExp(`^${globBody(pattern)}$`) : new RegExp(`(?:^|/)${globBody(pattern)}(?:/|$)`);
2136
+ const re = pattern.includes("/") ? anchoredGlobRegExp(pattern) : anyDepthGlobRegExp(pattern);
1759
2137
  return {
1760
2138
  negated,
1761
2139
  re
1762
2140
  };
1763
2141
  }
1764
- /** The minimal glob dialect as a regex body without anchors. */
1765
- function globBody(glob) {
1766
- let re = "";
1767
- for (let i = 0; i < glob.length; i++) {
1768
- const c = glob[i];
1769
- if (c === "*") {
1770
- if (glob[i + 1] === "*") {
1771
- if (glob[i + 2] === "/") {
1772
- re += "(?:[^/]*/)*";
1773
- i += 2;
1774
- } else {
1775
- re += ".*";
1776
- i += 1;
1777
- }
1778
- } else re += "[^/]*";
1779
- } else if (c === "?") re += "[^/]";
1780
- else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
1781
- }
1782
- return re;
1783
- }
1784
2142
  createMatcherFromPatterns([]);
1785
2143
  /**
1786
2144
  * Check if a directory is a lint-fixture shape (Phase 2 auto-detection).
@@ -1957,16 +2315,29 @@ function detectFrameworks(ws) {
1957
2315
  * Migration discipline (per the plan): rule-by-rule, golden lock must stay
1958
2316
  * byte-identical — a score shift is a regression, not an improvement.
1959
2317
  */
1960
- let project = null;
1961
2318
  /**
1962
- * The shared ts-morph project.
2319
+ * One shared Project PER SCAN keeps memory bounded while reusing the compiler's
2320
+ * program across files.
1963
2321
  *
1964
- * Exported so an adapter's `dispose()` can evict the file it parsed: ts-morph
1965
- * caches by file path, so without eviction a long scan holds every parsed
1966
- * SourceFile until the process exits (plan V5-021).
1967
- */
1968
- function getProject() {
1969
- if (!project) project = new Project({
2322
+ * "Per scan" has to mean per scan. The variable lived at module scope, so it
2323
+ * was one Project per PROCESS, and two scans running concurrently shared it —
2324
+ * `parseTsFile` calls `replaceWithText` on a cached SourceFile, so scan B
2325
+ * could overwrite the text scan A was mid-rule on. ts-morph's in-memory file
2326
+ * system and language service are not reentrant, and the observable result was
2327
+ * three of four concurrent scans reporting `rulesCrashed: 4` with
2328
+ * `rules: "partial"` — different machine-contract digests from the same tree,
2329
+ * which is the one thing this product claims cannot happen.
2330
+ *
2331
+ * An AsyncLocalStorage scope is the smallest change that makes the comment
2332
+ * true: each scan's async context carries its own Project, memory stays bounded
2333
+ * per scan exactly as intended, and the context is collected when the scan's
2334
+ * promise chain is. The loose project below remains for callers outside a scan
2335
+ * (tests, one-off parses), where there is nothing to collide with.
2336
+ */
2337
+ const scanProjects = new AsyncLocalStorage();
2338
+ let looseProject = null;
2339
+ function createProject() {
2340
+ return new Project({
1970
2341
  useInMemoryFileSystem: true,
1971
2342
  skipAddingFilesFromTsConfig: true,
1972
2343
  compilerOptions: {
@@ -1975,7 +2346,28 @@ function getProject() {
1975
2346
  noEmit: true
1976
2347
  }
1977
2348
  });
1978
- return project;
2349
+ }
2350
+ /**
2351
+ * Run `fn` with its own ts-morph Project.
2352
+ *
2353
+ * Async context propagates across `await`, so the Project stays the scan's own
2354
+ * for the whole scan without threading an argument through every adapter.
2355
+ */
2356
+ function runWithScanProject(fn) {
2357
+ return scanProjects.run(createProject(), fn);
2358
+ }
2359
+ /**
2360
+ * The ts-morph Project for the current scan.
2361
+ *
2362
+ * Exported so an adapter's `dispose()` can evict the file it parsed: ts-morph
2363
+ * caches by file path, so without eviction a long scan holds every parsed
2364
+ * SourceFile until the process exits (plan V5-021).
2365
+ */
2366
+ function getProject() {
2367
+ const scoped = scanProjects.getStore();
2368
+ if (scoped !== void 0) return scoped;
2369
+ looseProject ??= createProject();
2370
+ return looseProject;
1979
2371
  }
1980
2372
  /**
1981
2373
  * Parse (or fetch from cache) the ts-morph SourceFile for a scanned file.
@@ -1992,6 +2384,7 @@ function parseTsFile(file) {
1992
2384
  }
1993
2385
  return p.createSourceFile(file.path, file.text);
1994
2386
  } catch {
2387
+ recordDegradation("ast-parse-failed");
1995
2388
  return;
1996
2389
  }
1997
2390
  }
@@ -2047,6 +2440,7 @@ function commentAndStringRanges(ctx) {
2047
2440
  }
2048
2441
  return ranges;
2049
2442
  } catch {
2443
+ recordDegradation("ast-range-scan-failed");
2050
2444
  return [];
2051
2445
  }
2052
2446
  }
@@ -2094,12 +2488,20 @@ function getCodeOnlyText(file) {
2094
2488
  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
2489
  return chars.join("");
2096
2490
  } catch {
2491
+ recordDegradation("ast-mask-unavailable");
2097
2492
  return file.text;
2098
2493
  }
2099
2494
  }
2100
- /** Release the shared ts-morph Project between scans to free memory. */
2495
+ /**
2496
+ * Release the loose ts-morph Project used outside a scan.
2497
+ *
2498
+ * Deliberately does NOT call `scanProjects.disable()`. Scan-scoped Projects are
2499
+ * released by garbage collection when the scan's async context ends, and
2500
+ * `disable()` would tear the store out from under a scan still running — the
2501
+ * exact corruption this scoping exists to remove.
2502
+ */
2101
2503
  function resetTsMorphProject() {
2102
- project = null;
2504
+ looseProject = null;
2103
2505
  }
2104
2506
  //#endregion
2105
2507
  //#region src/engine/code-text.ts
@@ -2478,6 +2880,44 @@ function frameworkFilterApplies(rule, file) {
2478
2880
  return rule.frameworks.some((f) => tags.includes(f));
2479
2881
  }
2480
2882
  //#endregion
2883
+ //#region src/engine/shared-run-rules.ts
2884
+ /**
2885
+ * Run ONE rule with crash isolation, and return what it produced.
2886
+ *
2887
+ * This is the block that was copy-pasted into all seven language adapters:
2888
+ *
2889
+ * try { for (const f of rule.run(x)) emit(f, rule.id, rule.category); }
2890
+ * catch (error) { onCrash?.(rule.id, error); }
2891
+ *
2892
+ * Extracting it is worth doing for one reason beyond line count. Crash
2893
+ * isolation is the mechanism that lets a third-party or experimental
2894
+ * detector fail without ending a scan, and it is the mechanism the whole
2895
+ * `rulesCrashed` count depends on. Seven copies means seven chances to
2896
+ * write `catch {}` and lose the count, and `tests/contract/
2897
+ * no-uncounted-degradation.spec.ts` can only audit what it can see.
2898
+ *
2899
+ * `null` means the rule CRASHED, which is not the same as an empty array:
2900
+ * an empty array is the rule abstaining, and the adapters skip emission for
2901
+ * both but the distinction is why the caller must not conflate them.
2902
+ *
2903
+ * Deliberately NOT extracted: the per-adapter filter chain and the budget
2904
+ * check. Those genuinely differ — the TypeScript adapter injects an AST,
2905
+ * framework tags, a lazy `codeText` getter, `appliesTo` scoping, config-only
2906
+ * gating and `configGateMatches`, and it ABORTS the whole file on budget
2907
+ * exhaustion where `runRulesShared` only `continue`s to the next rule.
2908
+ * Routing every adapter through `runRulesShared` as written would silently
2909
+ * change which rules run and when, which is precisely the unmeasured claim
2910
+ * this program exists to delete. This helper takes the part they share.
2911
+ */
2912
+ function runRuleIsolated(rule, file, onCrash) {
2913
+ try {
2914
+ return rule.run(file);
2915
+ } catch (error) {
2916
+ onCrash?.(rule.id, error);
2917
+ return null;
2918
+ }
2919
+ }
2920
+ //#endregion
2481
2921
  //#region src/adapters/typescript.ts
2482
2922
  /**
2483
2923
  * TypeScript/JavaScript adapter (R1).
@@ -2515,10 +2955,18 @@ function frameworkFilterApplies(rule, file) {
2515
2955
  * four lines below: PW_CONFIG_RE already accepted `cts`. The config regex knew
2516
2956
  * about the Node-native extensions; the test-file regex did not.
2517
2957
  *
2518
- * `discovery/scan-adapters.ts:isUnrecognizedSourceCandidate` already counted
2519
- * these paths as uncovered surface, so the scope accounting had been reporting
2520
- * the hole the whole time — it was being read as a known limitation rather than
2521
- * as a defect.
2958
+ * `discovery/scan-adapters.ts:isUnrecognizedSourceCandidate` counts these
2959
+ * paths as uncovered surface, so the scope accounting reports a hole if one
2960
+ * exists.
2961
+ *
2962
+ * CORRECTION: the comment this replaced said the scope accounting "had been
2963
+ * reporting the hole the whole time". It had not — that checker's regex
2964
+ * already matched `mts`/`cts`, and only `extensions` was unread. What WAS
2965
+ * true, and is what this entry now fixes, is that a field every adapter
2966
+ * declares and no code consulted is a claim the interface makes to anyone who
2967
+ * reads it. `isUnrecognizedSourceCandidate` now builds its extension set from
2968
+ * this list rather than from a second hard-coded one, which is what makes the
2969
+ * two halves of the same fact stop being able to drift.
2522
2970
  */
2523
2971
  const TEST_FILE_RE = /\.(?:test|spec)\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$|\.cy\.(?:js|jsx|ts|tsx)$/;
2524
2972
  /**
@@ -2590,7 +3038,9 @@ const typescriptAdapter = {
2590
3038
  ".js",
2591
3039
  ".jsx",
2592
3040
  ".mjs",
2593
- ".cjs"
3041
+ ".cjs",
3042
+ ".mts",
3043
+ ".cts"
2594
3044
  ],
2595
3045
  testFileGlobs: [
2596
3046
  "*.test.{js,jsx,ts,tsx,mjs,cjs}",
@@ -2687,11 +3137,9 @@ const typescriptAdapter = {
2687
3137
  budget.onExceeded();
2688
3138
  return;
2689
3139
  }
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
- }
3140
+ const produced = runRuleIsolated(rule, enriched, onCrash);
3141
+ if (produced === null) continue;
3142
+ for (const f of produced) emit(f, rule.id, rule.category);
2695
3143
  }
2696
3144
  }
2697
3145
  };
@@ -2719,6 +3167,7 @@ function loadWorkspaceShim(root) {
2719
3167
  workspaceGlobs: []
2720
3168
  };
2721
3169
  } catch {
3170
+ recordDegradation("workspace-manifest-unreadable");
2722
3171
  return null;
2723
3172
  }
2724
3173
  }
@@ -2802,7 +3251,11 @@ async function withParseSlot(fn) {
2802
3251
  }
2803
3252
  function grammarPath(fileName) {
2804
3253
  const here = dirname(fileURLToPath(import.meta.url));
2805
- const candidates = [join(here, "..", "..", "node_modules", "tree-sitter-wasms", "out", fileName), join(here, "..", "node_modules", "tree-sitter-wasms", "out", fileName)];
3254
+ const candidates = [
3255
+ join(here, "..", "..", "node_modules", "tree-sitter-wasms", "out", fileName),
3256
+ join(here, "..", "node_modules", "tree-sitter-wasms", "out", fileName),
3257
+ join(here, "..", "..", "..", "node_modules", "tree-sitter-wasms", "out", fileName)
3258
+ ];
2806
3259
  return candidates.find((p) => existsSync(p)) ?? candidates[0];
2807
3260
  }
2808
3261
  async function ensureParserInitialized() {
@@ -2882,6 +3335,7 @@ async function parseJavaAst(text) {
2882
3335
  return (await getJavaParser()).parse(text) ?? void 0;
2883
3336
  });
2884
3337
  } catch {
3338
+ recordDegradation("ast-grammar-unavailable");
2885
3339
  return;
2886
3340
  }
2887
3341
  }
@@ -2895,6 +3349,7 @@ async function parseCSharpAst(text) {
2895
3349
  return (await getCSharpParser()).parse(text) ?? void 0;
2896
3350
  });
2897
3351
  } catch {
3352
+ recordDegradation("ast-grammar-unavailable");
2898
3353
  return;
2899
3354
  }
2900
3355
  }
@@ -2910,6 +3365,7 @@ async function parsePythonAst(text) {
2910
3365
  return (await getPythonParser()).parse(text) ?? void 0;
2911
3366
  });
2912
3367
  } catch {
3368
+ recordDegradation("ast-grammar-unavailable");
2913
3369
  return;
2914
3370
  }
2915
3371
  }
@@ -3008,7 +3464,9 @@ const pythonAdapter = {
3008
3464
  if (existsSync(pyproject)) try {
3009
3465
  const text = readText$2(pyproject);
3010
3466
  if (/\[tool\.pytest/i.test(text)) frameworks.add("pytest");
3011
- } catch {}
3467
+ } catch {
3468
+ recordDegradation("python-manifest-unreadable");
3469
+ }
3012
3470
  for (const reqFile of [
3013
3471
  "requirements.txt",
3014
3472
  "requirements-dev.txt",
@@ -3020,7 +3478,9 @@ const pythonAdapter = {
3020
3478
  const text = readText$2(p);
3021
3479
  if (/^selenium\b/im.test(text)) frameworks.add("selenium");
3022
3480
  if (/^playwright\b/im.test(text)) frameworks.add("playwright");
3023
- } catch {}
3481
+ } catch {
3482
+ recordDegradation("python-manifest-unreadable");
3483
+ }
3024
3484
  }
3025
3485
  if (frameworks.size === 0) return {
3026
3486
  frameworks: [],
@@ -3073,11 +3533,9 @@ const pythonAdapter = {
3073
3533
  budget.onExceeded();
3074
3534
  return;
3075
3535
  }
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
- }
3536
+ const produced = runRuleIsolated(rule, enriched, onCrash);
3537
+ if (produced === null) continue;
3538
+ for (const f of produced) emit(f, rule.id, rule.category);
3081
3539
  }
3082
3540
  },
3083
3541
  async parseAst(file) {
@@ -3230,11 +3688,9 @@ const javaAdapter = {
3230
3688
  budget.onExceeded();
3231
3689
  return;
3232
3690
  }
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
- }
3691
+ const produced = runRuleIsolated(rule, enriched, onCrash);
3692
+ if (produced === null) continue;
3693
+ for (const f of produced) emit(f, rule.id, rule.category);
3238
3694
  }
3239
3695
  }
3240
3696
  };
@@ -3388,11 +3844,9 @@ const csharpAdapter = {
3388
3844
  budget.onExceeded();
3389
3845
  return;
3390
3846
  }
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
- }
3847
+ const produced = runRuleIsolated(rule, enriched, onCrash);
3848
+ if (produced === null) continue;
3849
+ for (const f of produced) emit(f, rule.id, rule.category);
3396
3850
  }
3397
3851
  }
3398
3852
  };
@@ -3570,6 +4024,7 @@ const githubActionsAdapter = {
3570
4024
  try {
3571
4025
  entries = readdirSync(wfDir);
3572
4026
  } catch {
4027
+ recordDegradation("ci-workflow-listing-unreadable");
3573
4028
  return;
3574
4029
  }
3575
4030
  for (const name of entries) {
@@ -3610,15 +4065,13 @@ const githubActionsAdapter = {
3610
4065
  budget.onExceeded();
3611
4066
  return;
3612
4067
  }
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
- }
4068
+ const produced = runRuleIsolated(rule, {
4069
+ path: file.path,
4070
+ text: file.text,
4071
+ ast: doc
4072
+ }, onCrash);
4073
+ if (produced === null) continue;
4074
+ for (const f of produced) emit(f, rule.id, rule.category);
3622
4075
  }
3623
4076
  }
3624
4077
  };
@@ -3928,15 +4381,13 @@ const azurePipelinesAdapter = {
3928
4381
  budget.onExceeded();
3929
4382
  return;
3930
4383
  }
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
- }
4384
+ const produced = runRuleIsolated(rule, {
4385
+ path: file.path,
4386
+ text: file.text,
4387
+ ast: doc
4388
+ }, onCrash);
4389
+ if (produced === null) continue;
4390
+ for (const f of produced) emit(f, rule.id, rule.category);
3940
4391
  }
3941
4392
  }
3942
4393
  };
@@ -4007,14 +4458,12 @@ const jenkinsAdapter = {
4007
4458
  budget.onExceeded();
4008
4459
  return;
4009
4460
  }
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
- }
4461
+ const produced = runRuleIsolated(rule, {
4462
+ path: file.path,
4463
+ text: file.text
4464
+ }, onCrash);
4465
+ if (produced === null) continue;
4466
+ for (const f of produced) emit(f, rule.id, rule.category);
4018
4467
  }
4019
4468
  }
4020
4469
  };
@@ -4113,109 +4562,50 @@ function isScopeRelevantIgnored(path) {
4113
4562
  if (!/\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml|min\.js)$/i.test(name)) return false;
4114
4563
  return isKnownTestFile(path);
4115
4564
  }
4116
- function isUnrecognizedSourceCandidate(path) {
4117
- const normalized = path.replaceAll("\\", "/");
4118
- const name = normalized.split("/").pop() ?? normalized;
4119
- if (normalized.includes(".github/workflows/") || name === "azure-pipelines.yml" || name === "Jenkinsfile") return false;
4120
- const extensionIndex = name.lastIndexOf(".");
4121
- const stem = extensionIndex > 0 ? name.slice(0, extensionIndex) : name;
4122
- if (name === "mjolnir.config.json" || name === ".mjolnir.json" || name === "package.json" || name === "package-lock.json" || name === "pnpm-lock.yaml" || name === "yarn.lock" || [
4123
- "playwright",
4124
- "vitest",
4125
- "vite",
4126
- "eslint",
4127
- "tsconfig",
4128
- "jsconfig"
4129
- ].some((prefix) => stem === prefix || stem.startsWith(`${prefix}.`))) return false;
4130
- const testLike = /(?:^|\/)__tests__\//i.test(normalized) || /\.(?:spec|test)\.[cm]?[jt]sx?$/i.test(name);
4131
- return /\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml)$/i.test(name) && testLike;
4132
- }
4133
- /** Whether ANY shipped adapter would discover this path as a test file. */
4134
- function isKnownTestFile(path) {
4135
- return SCAN_ADAPTERS.some((a) => a.isTestFile(path));
4136
- }
4137
- /** True when any path segment (relative to the scan root) is a skip name. */
4138
- function isInsideSkippedDir(root, absPath, skipNames) {
4139
- if (skipNames.length === 0) return false;
4140
- return relative(root, absPath).replaceAll("\\", "/").split("/").slice(0, -1).some((seg) => skipNames.includes(seg));
4141
- }
4142
- typescriptAdapter.testFileGlobs, pythonAdapter.testFileGlobs, javaAdapter.testFileGlobs, csharpAdapter.testFileGlobs, githubActionsAdapter.testFileGlobs, azurePipelinesAdapter.testFileGlobs, jenkinsAdapter.testFileGlobs;
4143
- //#endregion
4144
- //#region src/scope/git-resolve.ts
4145
4565
  /**
4146
- * Absolute-path git resolution (audit S1).
4566
+ * Every source extension any shipped adapter claims.
4147
4567
  *
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.
4568
+ * This is where `LanguageAdapter.extensions` is READ. The field was declared by
4569
+ * every adapter, listed a set of extensions, and consulted by nothing in
4570
+ * `src/` — so a support claim that no code checked was still a claim the
4571
+ * interface made to every reader.
4155
4572
  *
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;
4573
+ * Derived rather than hand-listed so the two halves cannot drift. A test
4574
+ * already compared the old hand-written regex against this set and found them
4575
+ * equivalent, which is the honest result: the refactor removes a second
4576
+ * source of truth rather than fixing a wrong answer. The `.mts`/`.cts` gap
4577
+ * was real but lived in `extensions`, not here, and is now closed there.
4578
+ */
4579
+ const ADAPTER_CLAIMED_EXTENSIONS = new Set(SCAN_ADAPTERS.flatMap((adapter) => adapter.extensions).map((ext) => ext.replace(/^\./, "").toLowerCase()));
4580
+ /** Whether ignoring this file would have removed it from the scanned surface. */
4581
+ function isUnrecognizedSourceCandidate(path) {
4582
+ const normalized = path.replaceAll("\\", "/");
4583
+ const name = normalized.split("/").pop() ?? normalized;
4584
+ if (normalized.includes(".github/workflows/") || name === "azure-pipelines.yml" || name === "Jenkinsfile") return false;
4585
+ const extensionIndex = name.lastIndexOf(".");
4586
+ const stem = extensionIndex > 0 ? name.slice(0, extensionIndex) : name;
4587
+ if (name === "mjolnir.config.json" || name === ".mjolnir.json" || name === "package.json" || name === "package-lock.json" || name === "pnpm-lock.yaml" || name === "yarn.lock" || [
4588
+ "playwright",
4589
+ "vitest",
4590
+ "vite",
4591
+ "eslint",
4592
+ "tsconfig",
4593
+ "jsconfig"
4594
+ ].some((prefix) => stem === prefix || stem.startsWith(`${prefix}.`))) return false;
4595
+ if (!(/(?:^|\/)__tests__\//i.test(normalized) || /\.(?:spec|test)\.[cm]?[jt]sx?$/i.test(name))) return false;
4596
+ const extension = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
4597
+ return ADAPTER_CLAIMED_EXTENSIONS.has(extension);
4192
4598
  }
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
- }
4599
+ /** Whether ANY shipped adapter would discover this path as a test file. */
4600
+ function isKnownTestFile(path) {
4601
+ return SCAN_ADAPTERS.some((a) => a.isTestFile(path));
4602
+ }
4603
+ /** True when any path segment (relative to the scan root) is a skip name. */
4604
+ function isInsideSkippedDir(root, absPath, skipNames) {
4605
+ if (skipNames.length === 0) return false;
4606
+ return relative(root, absPath).replaceAll("\\", "/").split("/").slice(0, -1).some((seg) => skipNames.includes(seg));
4218
4607
  }
4608
+ typescriptAdapter.testFileGlobs, pythonAdapter.testFileGlobs, javaAdapter.testFileGlobs, csharpAdapter.testFileGlobs, githubActionsAdapter.testFileGlobs, azurePipelinesAdapter.testFileGlobs, jenkinsAdapter.testFileGlobs;
4219
4609
  //#endregion
4220
4610
  //#region src/scope/changed.ts
4221
4611
  /**
@@ -5394,7 +5784,7 @@ const ASYNC_PW_MATCHERS = /* @__PURE__ */ new Set([
5394
5784
  ]);
5395
5785
  const unawaitedLocatorAssertion = defineRule({
5396
5786
  id: "QA-PW-002",
5397
- tier: "core",
5787
+ tier: "extended",
5398
5788
  category: "QA-PW",
5399
5789
  title: "Unawaited Playwright assertion",
5400
5790
  severity: "error",
@@ -5508,7 +5898,7 @@ const committedDebugArtifacts = defineRule({
5508
5898
  detail: "page.pause() and test.only() are exact Playwright runner tokens; the detector matches the member-call identifiers on the code-only text — closed token set, unique to the defect"
5509
5899
  },
5510
5900
  introduced: "0.1.0",
5511
- tier: "core",
5901
+ tier: "extended",
5512
5902
  run(ctx) {
5513
5903
  const text = ctx.codeText ?? ctx.text;
5514
5904
  const findings = [];
@@ -7334,7 +7724,7 @@ const pyHardSleep = defineRule({
7334
7724
  */
7335
7725
  const pySkippedTest = defineRule({
7336
7726
  id: "QA-PY-002",
7337
- tier: "core",
7727
+ tier: "extended",
7338
7728
  category: "QA-TEST",
7339
7729
  title: "Skipped test",
7340
7730
  severity: "warning",
@@ -7470,7 +7860,7 @@ const pyFocusedTest = defineRule({
7470
7860
  detail: "pytest.skip/xfail/parametrize marks are runner decorators and module-level calls; the detector matches those exact tokens on the code-only text — the semantics are runner skip state"
7471
7861
  },
7472
7862
  introduced: "0.3.0",
7473
- tier: "core",
7863
+ tier: "extended",
7474
7864
  suiteInvalidating: true,
7475
7865
  run(ctx) {
7476
7866
  const text = ctx.text;
@@ -7714,26 +8104,65 @@ function walk(node, visit) {
7714
8104
  for (const child of node.children) walk(child, visit);
7715
8105
  }
7716
8106
  /**
8107
+ * The `pytest.raises` call that is a DIRECT context manager of `withNode`.
8108
+ *
8109
+ * Scopes the search to the with's own item list — `with_clause` for the
8110
+ * plain form, and `parenthesized_with_items` for `with (A, B):` — rather
8111
+ * than to the whole subtree. A call found deeper down belongs to an inner
8112
+ * `with_statement`, which the walk reaches in its own right and which owns
8113
+ * the block that actually gates it.
8114
+ */
8115
+ function directRaisesItem(withNode) {
8116
+ let found;
8117
+ const scan = (n) => {
8118
+ if (found) return;
8119
+ if (n.type === "call") {
8120
+ const fn = n.childForFieldName("function");
8121
+ if (fn && /(?:^|\.)raises$/.test(fn.text)) {
8122
+ found = n;
8123
+ return;
8124
+ }
8125
+ }
8126
+ for (const child of n.children) scan(child);
8127
+ };
8128
+ for (const child of withNode.children) if (child.type === "with_clause" || child.type === "parenthesized_with_items") scan(child);
8129
+ return found;
8130
+ }
8131
+ /**
7717
8132
  * Every `with pytest.raises(...)` (optionally `... as exc`) statement in
7718
8133
  * the tree, with the structural facts the rework needs. Tree-sitter-
7719
8134
  * python grammar: a `with_statement` wraps `with_item`s and a `block`;
7720
8135
  * the raises call is a `call` whose function is an attribute chain
7721
8136
  * ending in `.raises`.
8137
+ *
8138
+ * 6.0. The raises call is now required to be a DIRECT context manager of the
8139
+ * `with_statement` being measured. It used to be found by walking the whole
8140
+ * subtree, so a `with pytest.raises(...)` nested inside another `with`
8141
+ * handed back the OUTER block's statement count — and `QA-PY-007`'s
8142
+ * single-statement gate, which exists precisely to suppress that shape,
8143
+ * could never fire for nested code.
8144
+ *
8145
+ * That is not a rare shape. In the pinned `pytest-dev/pytest` corpus, seven
8146
+ * of the eleven adjudicated findings were single-statement raises nested
8147
+ * inside `with saved_fd(1):`, and every one of them was counted as
8148
+ * multi-statement because the surrounding block has a dozen statements. The
8149
+ * rule measured the wrong block, which is why it measured 75% false
8150
+ * positives while its own unit fixtures passed.
8151
+ *
8152
+ * Requiring a direct context manager also fixes the shape by construction:
8153
+ * the outer `with` is examined, has no direct raises item, and is skipped;
8154
+ * the inner one is examined with its own block.
7722
8155
  */
7723
8156
  function pythonWithRaisesBlocks(tree) {
7724
8157
  const out = [];
7725
8158
  const root = tree.rootNode;
7726
8159
  walk(root, (node) => {
7727
8160
  if (node.type !== "with_statement") return;
7728
- let raisesCall;
7729
8161
  let blockNode;
7730
8162
  for (const child of node.children) if (child.type === "block") blockNode = child;
7731
- walk(node, (n) => {
7732
- if (raisesCall || n.type !== "call") return;
7733
- const fn = n.childForFieldName("function");
7734
- if (fn && /(?:^|\.)raises$/.test(fn.text)) raisesCall = n;
7735
- });
7736
- if (!raisesCall || !blockNode) return;
8163
+ if (!blockNode) return;
8164
+ const raisesCall = directRaisesItem(node);
8165
+ if (!raisesCall) return;
7737
8166
  const stmts = blockNode.namedChildren.filter((c) => c.type !== "comment");
7738
8167
  const exceptionText = (raisesCall.childForFieldName("arguments")?.namedChildren[0])?.text ?? "";
7739
8168
  const broadException = BROAD_EXC_RE.test(exceptionText);
@@ -7779,7 +8208,7 @@ const pyRaisesWithoutMatch = defineRule({
7779
8208
  },
7780
8209
  introduced: "0.3.0",
7781
8210
  tier: "quarantine",
7782
- detectorRevision: 4,
8211
+ detectorRevision: 5,
7783
8212
  run(ctx) {
7784
8213
  const findings = [];
7785
8214
  if (!ctx.path.endsWith(".py")) return findings;
@@ -7838,16 +8267,17 @@ function matchParen$1(text, open) {
7838
8267
  return -1;
7839
8268
  }
7840
8269
  /**
7841
- * AST arm (detectorRevision 4 — P6 rework): candidates come from the
7842
- * parsed `with pytest.raises(...)` statements. Two structural gates
7843
- * suppress the adjudicated FP core:
7844
- * - the with-block holds a SINGLE statement AND the exception type is
7845
- * specific → the intended line is the only line; the "unrelated bug
7846
- * raises first" diagnosis cannot hold;
7847
- * - the fallback's excinfo-use skip (`as exc` + downstream use) is
7848
- * mirrored on the raw text.
7849
- * Broad root types (Exception/BaseException/…Group) always fire — the
7850
- * type alone pins nothing.
8270
+ * AST arm (detectorRevision 5 — P6 rework, widened on adjudicated evidence):
8271
+ * candidates come from the parsed `with pytest.raises(...)` statements, and
8272
+ * the raises call must be a DIRECT context manager of the with whose block
8273
+ * is measured (see `pythonWithRaisesBlocks` for why).
8274
+ *
8275
+ * The gate: a block with fewer than two statements is exempt. That covers
8276
+ * the adjudicated FP core — pytest-dev/pytest nests its capture tests inside
8277
+ * `with saved_fd(1):`, and every one of those was scored as multi-statement
8278
+ * until the block resolution was fixed. A broad root type (Exception /
8279
+ * BaseException / …Group) still fires on a MULTI-statement block, where an
8280
+ * earlier line can raise it before the intended one.
7851
8281
  */
7852
8282
  function astArm(ctx, tree) {
7853
8283
  const raw = ctx.text;
@@ -7861,7 +8291,7 @@ function astArm(ctx, tree) {
7861
8291
  const tail = raw.slice(block.callEnd, block.callEnd + 2400);
7862
8292
  if (new RegExp(`\\b${name}\\b`).test(tail)) continue;
7863
8293
  }
7864
- if (block.blockStatements < 2 && !block.broadException) continue;
8294
+ if (block.blockStatements < 2) continue;
7865
8295
  findings.push({
7866
8296
  severity: "warning",
7867
8297
  confidence: "medium",
@@ -7904,7 +8334,7 @@ const pyCommentedOutTest = defineRule({
7904
8334
  },
7905
8335
  detectionNotes: "regex heuristic",
7906
8336
  introduced: "0.3.0",
7907
- tier: "core",
8337
+ tier: "extended",
7908
8338
  run(ctx) {
7909
8339
  const findings = [];
7910
8340
  if (!ctx.path.endsWith(".py")) return findings;
@@ -7953,13 +8383,18 @@ const pyBareTruthinessAssert = defineRule({
7953
8383
  },
7954
8384
  introduced: "0.3.0",
7955
8385
  tier: "quarantine",
7956
- detectorRevision: 3,
8386
+ detectorRevision: 4,
7957
8387
  run(ctx) {
7958
8388
  const text = ctx.codeText ?? ctx.text;
7959
8389
  const findings = [];
7960
8390
  if (!ctx.path.endsWith(".py")) return findings;
7961
8391
  const re = /^[ \t]*assert\s+([A-Za-z_][\w.]*(?:\([^()]*\))?)[ \t]*$/gm;
7962
- const predicateRe = /^(?:(?:any|all|isinstance)\s*\(|[\w.]*\.(?:startswith|endswith|exists|isdir|isfile|islink|ismount|check|isdigit|isalpha|isalnum|isnumeric|isdecimal|isspace|islower|isupper|istitle|isidentifier|isprintable|isascii)\s*\(|re\.(?:match|search|fullmatch)\s*\()/;
8392
+ /**
8393
+ * Constants. `assert False` / `assert None` are deliberate mechanisms —
8394
+ * a test that manufactures an exception to assert on its shape — not a
8395
+ * truthiness check whose wrong value would slip through.
8396
+ */
8397
+ const isConstant = (target) => /^(?:True|False|None|NotImplemented|Ellipsis)$/.test(target) || /^["'\d]/.test(target);
7963
8398
  const isGuardFollowedByRealUse = (text, matchIndex, target) => {
7964
8399
  const lineEnd = text.indexOf("\n", matchIndex);
7965
8400
  if (lineEnd === -1) return false;
@@ -7979,8 +8414,9 @@ const pyBareTruthinessAssert = defineRule({
7979
8414
  let m;
7980
8415
  while ((m = re.exec(text)) !== null) {
7981
8416
  const target = m[1];
8417
+ if (target.includes("(")) continue;
8418
+ if (isConstant(target)) continue;
7982
8419
  if (/^(?:is|has|can|should|was|were)_/.test(target)) continue;
7983
- if (predicateRe.test(target)) continue;
7984
8420
  if (/\.called$/.test(target)) continue;
7985
8421
  if (isGuardFollowedByRealUse(text, m.index, target)) continue;
7986
8422
  findings.push({
@@ -8026,7 +8462,7 @@ const pyMutableFixture = defineRule({
8026
8462
  detail: "pytest fixture mutation is fixture-lifecycle semantics (autouse/scope keys plus mutation calls); the detector matches the runner's fixture decorator tokens plus the mutation shapes"
8027
8463
  },
8028
8464
  introduced: "0.3.0",
8029
- tier: "core",
8465
+ tier: "extended",
8030
8466
  run(ctx) {
8031
8467
  const text = ctx.text;
8032
8468
  const findings = [];
@@ -8060,7 +8496,7 @@ const pyMutableFixture = defineRule({
8060
8496
  */
8061
8497
  const pwWaitForTimeout = defineRule({
8062
8498
  id: "QA-PW-101",
8063
- tier: "core",
8499
+ tier: "extended",
8064
8500
  category: "QA-PW",
8065
8501
  title: "Hard sleep via waitForTimeout",
8066
8502
  severity: "error",
@@ -8181,7 +8617,7 @@ const pwDeepFrameLocator = defineRule({
8181
8617
  detail: "frameLocator chaining depth is an exact Playwright token sequence; the detector matches the frameLocator call chains — the token sequence is closed and unique to the defect"
8182
8618
  },
8183
8619
  introduced: "0.3.0",
8184
- tier: "core",
8620
+ tier: "extended",
8185
8621
  run(ctx) {
8186
8622
  const text = ctx.codeText ?? ctx.text;
8187
8623
  const findings = [];
@@ -8290,7 +8726,7 @@ const pwConfigRetryAbuse = defineRule({
8290
8726
  },
8291
8727
  detectionNotes: "regex heuristic",
8292
8728
  introduced: "0.3.0",
8293
- tier: "core",
8729
+ tier: "extended",
8294
8730
  run(ctx) {
8295
8731
  const text = ctx.codeText ?? ctx.text;
8296
8732
  const findings = [];
@@ -8308,7 +8744,7 @@ const pwConfigRetryAbuse = defineRule({
8308
8744
  column: colAt(text, m.index),
8309
8745
  message: `retries: ${m[1]} — a flaky test gets ${m[1]} chances to pass by luck.`,
8310
8746
  why: "High retry counts convert real bugs into intermittent passes and inflate CI minutes; the suite reports green while hiding instability.",
8311
- fix: "Keep retries <= 2 and route repeat offenders into forensics (`mjolnir triage`)."
8747
+ fix: "Keep retries <= 2 and route repeat offenders into the evidence path (`explain --evidence`)."
8312
8748
  });
8313
8749
  const workersRe = /workers\s*:\s*0\b/g;
8314
8750
  while ((m = workersRe.exec(text)) !== null) findings.push({
@@ -8465,7 +8901,7 @@ const pwTrialMisuse = defineRule({
8465
8901
  detail: "the trial-click shape is an exact Playwright API token pair; the detector matches the call identifier on the code-only text — the token is closed and unique to the defect"
8466
8902
  },
8467
8903
  introduced: "0.3.0",
8468
- tier: "core",
8904
+ tier: "extended",
8469
8905
  run(ctx) {
8470
8906
  const text = ctx.codeText ?? ctx.text;
8471
8907
  const findings = [];
@@ -8670,7 +9106,7 @@ const qaPw140 = defineRule({
8670
9106
  detail: "the detector matches a closed, exact runner API token on the code-only text; the token identifies the defect uniquely"
8671
9107
  },
8672
9108
  introduced: "0.3.0",
8673
- tier: "core",
9109
+ tier: "extended",
8674
9110
  run(ctx) {
8675
9111
  const text = ctx.codeText ?? ctx.text;
8676
9112
  const findings = [];
@@ -8885,7 +9321,7 @@ const pwRetryMaskingNoForensics = defineRule({
8885
9321
  column: colAt(text, m.index),
8886
9322
  message: `retries: ${m[1]} with no visible flake-triage loop.`,
8887
9323
  why: "Retries convert intermittent failures into silent passes. Without a forensics/triage step consuming retry data, flaky tests pass forever and real regressions hide behind lucky reruns.",
8888
- fix: "Keep retries <= 2 and feed retry outcomes into `mjolnir forensics`/`triage`, or add a reporter so flaky passes are reviewed."
9324
+ fix: "Keep retries <= 2 and feed retry outcomes into the evidence path (`explain --evidence`), or add a reporter so flaky passes are reviewed."
8889
9325
  });
8890
9326
  }
8891
9327
  return findings;
@@ -9280,7 +9716,7 @@ const pyPwWaitForTimeout = defineRule({
9280
9716
  detail: "page.waitForTimeout is an exact Playwright token in Python tests; the detector matches the call identifier — closed token, same predicate the AST would encode"
9281
9717
  },
9282
9718
  introduced: "0.3.8",
9283
- tier: "core",
9719
+ tier: "extended",
9284
9720
  run(ctx) {
9285
9721
  const text = ctx.codeText ?? ctx.text;
9286
9722
  const findings = [];
@@ -10165,7 +10601,7 @@ function extractPythonModel(file) {
10165
10601
  */
10166
10602
  const jvWaitForTimeout = defineRule({
10167
10603
  id: "QA-JV-105",
10168
- tier: "core",
10604
+ tier: "extended",
10169
10605
  category: "QA-PW",
10170
10606
  title: "waitForTimeout hard sleep",
10171
10607
  severity: "warning",
@@ -10234,7 +10670,7 @@ const jvWaitForTimeout = defineRule({
10234
10670
  */
10235
10671
  const csSkippedTest = defineRule({
10236
10672
  id: "QA-CS-101",
10237
- tier: "core",
10673
+ tier: "extended",
10238
10674
  category: "QA-PW",
10239
10675
  title: "Skipped test",
10240
10676
  severity: "warning",
@@ -10429,7 +10865,7 @@ const csNoAssertions = defineRule({
10429
10865
  detectionStrategy: "AST",
10430
10866
  detectionNotes: "L2 tree-sitter test-method scoping + structural assertion oracle (Shouldly/Verify/Assert receivers, throwing waits); regex fallback when no parse",
10431
10867
  introduced: "0.3.8",
10432
- tier: "core",
10868
+ tier: "extended",
10433
10869
  detectorRevision: 2,
10434
10870
  astQuery: cs103AstQuery,
10435
10871
  run(ctx) {
@@ -11155,7 +11591,7 @@ const hardSleepFamily = definePatternFamily({
11155
11591
  "mstest",
11156
11592
  "playwright"
11157
11593
  ],
11158
- tier: "core",
11594
+ tier: "extended",
11159
11595
  detectionStrategy: "AST",
11160
11596
  detectionNotes: "L2 tree-sitter invocation scoping (route/expose/server delegates, infinite blocks, WhenAny races, runner payload fixtures); regex fallback when no parse",
11161
11597
  detectorRevision: 2,
@@ -11478,7 +11914,7 @@ const retryMaskingFamily = [defineRule({
11478
11914
  detail: "the retry-masking family's variants (QA-JV-109/QA-CS-109): retry masking is the runner's retry contract; the detector matches the runner's retry tokens on the code-only text"
11479
11915
  },
11480
11916
  introduced: "0.4.0",
11481
- tier: "core",
11917
+ tier: "extended",
11482
11918
  run(ctx) {
11483
11919
  const text = ctx.codeText ?? ctx.text;
11484
11920
  const findings = [];
@@ -11657,6 +12093,117 @@ function getRule(id) {
11657
12093
  return RULES.find((r) => r.id === id);
11658
12094
  }
11659
12095
  //#endregion
12096
+ //#region src/lib/wilson.ts
12097
+ /**
12098
+ * 95% Wilson score interval on a proportion (plan §20.2).
12099
+ *
12100
+ * Lives in `src/` rather than `scripts/` because a measurement's interval is
12101
+ * now part of the ENGINE's tier decision (`src/rules/measurement.ts`), not
12102
+ * only a reporting nicety for the capability matrix. `scripts/lib/wilson.ts`
12103
+ * re-exports this module, so the number a gate prints and the number the
12104
+ * engine tiers on are the same number computed once. Two implementations
12105
+ * would be two sources of truth for the same figure, which is the failure
12106
+ * mode this whole release is about.
12107
+ *
12108
+ * The 4-dp rounding keeps generated artifacts byte-stable (see the capability
12109
+ * matrix generator's note on deterministic output).
12110
+ */
12111
+ const Z95 = 1.959963984540054;
12112
+ /** 95% Wilson score interval on fp/n (fp successes of n trials), 4 dp. */
12113
+ function wilsonInterval(fp, n) {
12114
+ if (n <= 0) return {
12115
+ ciLow: 0,
12116
+ ciHigh: 1
12117
+ };
12118
+ const p = fp / n;
12119
+ const z2 = Z95 * Z95;
12120
+ const denom = 1 + z2 / n;
12121
+ const center = (p + z2 / (2 * n)) / denom;
12122
+ const half = Z95 / denom * Math.sqrt(p * (1 - p) / n + z2 / (4 * n * n));
12123
+ const round4 = (x) => Math.round(x * 1e4) / 1e4;
12124
+ return {
12125
+ ciLow: round4(Math.max(0, center - half)),
12126
+ ciHigh: round4(Math.min(1, center + half))
12127
+ };
12128
+ }
12129
+ /** The rule's declared detector implementation revision (§07). */
12130
+ function declaredDetectorRevision(rule) {
12131
+ return rule.detectorRevision ?? 1;
12132
+ }
12133
+ /**
12134
+ * A measurement exists AND was taken against the detector revision the
12135
+ * rule declares now. A revision mismatch (stale) does NOT count — §07:
12136
+ * stale → provisional → re-measure.
12137
+ */
12138
+ function hasValidMeasurement(rule) {
12139
+ const m = MEASURED_FP[rule.id];
12140
+ return m !== void 0 && m.detectorRevision === declaredDetectorRevision(rule);
12141
+ }
12142
+ /**
12143
+ * The confidence interval of a rule's VALID measurement, or undefined when
12144
+ * it has none. A stale measurement returns undefined rather than its stale
12145
+ * interval: a number derived from an older detector is not evidence about
12146
+ * this one, and returning it would let the straddle test pass on stale data.
12147
+ *
12148
+ * The FP count is ROUNDED, and it is not an optimization. The shipped
12149
+ * measurement records a rate and a sample count, not an integer count of false
12150
+ * positives, so `fpRate * n` is generally fractional — 10.5% of 19 is 1.995.
12151
+ * `wilsonInterval` rounds internally, so passing the raw product gave this
12152
+ * function and `scripts/core-readiness.ts` two different `ciHigh` values for
12153
+ * the same rule at the same n: the ratchet decided one tier and the
12154
+ * readiness table printed another, both derived, neither reconciled. Every
12155
+ * caller now reconstructs the count the same way.
12156
+ */
12157
+ function measurementInterval(rule) {
12158
+ if (!hasValidMeasurement(rule)) return void 0;
12159
+ const m = MEASURED_FP[rule.id];
12160
+ if (m === void 0) return void 0;
12161
+ return wilsonInterval(Math.round(m.fpRate * m.n), m.n);
12162
+ }
12163
+ /**
12164
+ * The tier the MEASUREMENT alone supports, ignoring any declared tier.
12165
+ *
12166
+ * This is the interval floor that `effectiveTier` falls back to, extracted so
12167
+ * the tightening law can ask the question `effectiveTier` structurally cannot:
12168
+ * a declared tier may only ever hold a rule DOWN, never release it.
12169
+ *
12170
+ * `effectiveTier` cannot answer that, because it short-circuits on
12171
+ * `rule.tier` — asking it what a rule's tier "would" be returns the declared
12172
+ * value, and a comparison built on it compares the declaration with itself.
12173
+ * The first B0 dry-run made exactly that mistake and reported zero
12174
+ * mismatches for a comparison that had never run.
12175
+ *
12176
+ * READ THIS BEFORE USING IT AS A FLOOR. Measured over all 79 live rules, this
12177
+ * function returns `extended` for 78 of them and `quarantine` for none: on
12178
+ * this corpus the interval rule is very nearly an empty rule, because
12179
+ * `ciLow >= 50%` is not reached at n = 10..80. A "declared tier may only
12180
+ * tighten" law written against THIS function is decorative — it can only ever
12181
+ * catch a rule declaring `core`, which no rule declares. It was written that
12182
+ * way first and measured at zero teeth.
12183
+ *
12184
+ * The floor with teeth is `defensibleTier`, below.
12185
+ */
12186
+ function measurementTier(rule) {
12187
+ const interval = measurementInterval(rule);
12188
+ if (interval === void 0) return "extended";
12189
+ if (interval.ciHigh <= .1) return "core";
12190
+ if (interval.ciLow >= .5) return "quarantine";
12191
+ return "extended";
12192
+ }
12193
+ /**
12194
+ * The tier a rule effectively ships as: its declared tier, or — for the
12195
+ * omitted-tier case — what its measurement's interval supports (§11.2 Step 2).
12196
+ *
12197
+ * The declared tier is a floor that may only tighten, and the measurement is
12198
+ * what it is measured against. See `rule.ts`'s `tier` doc for the B0
12199
+ * measurement behind that, and `defensibleTier` for the floor it is measured
12200
+ * against.
12201
+ */
12202
+ function effectiveTier(rule) {
12203
+ if (rule.tier !== void 0) return rule.tier;
12204
+ return measurementTier(rule);
12205
+ }
12206
+ //#endregion
11660
12207
  //#region src/scorer/scorer.ts
11661
12208
  /**
11662
12209
  * Transparent scorer (Product-MVP §8).
@@ -11713,7 +12260,7 @@ function computeDimensions(findings) {
11713
12260
  deductions.set(f.category, deductions.get(f.category) + deductionFor(f));
11714
12261
  }
11715
12262
  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));
12263
+ return [...byCategory.values()].sort((a, b) => compareCodePoints(a.category, b.category));
11717
12264
  }
11718
12265
  /**
11719
12266
  * Deduction-mass ceilings (product-gap-remediation master plan P2,
@@ -11862,26 +12409,105 @@ function asUniversal(rule) {
11862
12409
  }
11863
12410
  //#endregion
11864
12411
  //#region src/engine/tier-policy.ts
12412
+ /**
12413
+ * Tier policy (audit H-1): the declared tier is authoritative over a
12414
+ * rule's severity and evidence claims.
12415
+ *
12416
+ * Quarantine-tier rules exist because their real-world false-positive
12417
+ * rate is unproven or proven bad (docs/FP-AUDIT.md records several at
12418
+ * 100% measured FP). A rule known to be wrong every single time it
12419
+ * fires must never gate CI or deduct score — regardless of the
12420
+ * severity/evidenceLevel its metadata declares. The pipeline therefore
12421
+ * caps every quarantine finding to severity=info, evidence=E0, which
12422
+ * makes it advisory by construction (isAdvisoryFinding excludes E0).
12423
+ *
12424
+ * The core tier carries the opposite bound (ADR 0014). A CAP says a tier
12425
+ * may not be stronger than this; a FLOOR says it may not be weaker. Core
12426
+ * is earned — it means the measurement cleared the false-positive
12427
+ * ceiling with enough samples to trust the interval — so a core rule
12428
+ * emitting a finding that costs nothing and blocks nothing is claiming a
12429
+ * promotion it has not cashed in. Before the floor existed, core and
12430
+ * extended were enforced identically, and reaching core changed only the
12431
+ * dedup tie-break, the M3 ceiling and the census readout: a consumer
12432
+ * could not tell them apart.
12433
+ */
11865
12434
  /** Cap applied to every finding of a quarantine-tier rule (H-1). */
11866
12435
  const QUARANTINE_CAP = {
11867
12436
  severity: "info",
11868
12437
  evidenceLevel: "E0"
11869
12438
  };
11870
- /** The cap that applies to a finding from the given tier (undefined tier = core). */
11871
- function capForTier(tier) {
11872
- return tier === "quarantine" ? QUARANTINE_CAP : null;
11873
- }
12439
+ /**
12440
+ * Floor applied to every finding of a core-tier rule (ADR 0014).
12441
+ *
12442
+ * `E1`, not `E2`, and the reason is the difference between the two.
12443
+ * `E2` would be a claim that the finding is proven, which is a statement
12444
+ * about the code that no tier policy may make — corroboration does that,
12445
+ * at runtime, per finding. `E1` is the weaker and sufficient claim: a
12446
+ * core rule is measured, so its finding is pattern evidence rather than
12447
+ * an observation, and it is worth half the deduction.
12448
+ *
12449
+ * `E0` is what the floor exists to forbid. `isAdvisoryFinding` is
12450
+ * `level === "E0"`, so an E0 finding is simultaneously worth zero points
12451
+ * and unable to fail a gate. A rule whose false-positive rate is
12452
+ * measured under the 10% ceiling should never be able to produce one.
12453
+ */
12454
+ const CORE_FLOOR = { evidenceLevel: "E1" };
12455
+ /**
12456
+ * The policy for a tier.
12457
+ *
12458
+ * `undefined` returns an EMPTY policy, and this is a decision, not a
12459
+ * convenience. `buildUniversalRules` in `scan-pipeline.ts` populates
12460
+ * `tierByRuleId` with `effectiveTier(rule)` for every registry rule — never
12461
+ * undefined — and adds a plugin or workspace-local rule only when that rule
12462
+ * DECLARES a tier. So an absent entry means "a third-party rule that claimed no
12463
+ * tier", and it is the one population that must not inherit a trust bound.
12464
+ *
12465
+ * It used to read "undefined tier = core" in this docstring, and that sentence
12466
+ * was load-bearing in the wrong direction. Before the floor, an absent entry
12467
+ * cost nothing, so the claim was inert. With a core floor, treating undefined as
12468
+ * core would raise every plugin's E0 finding to E1 and start charging
12469
+ * third-party detections for deductions — the exact trust escalation TI-013
12470
+ * exists to prevent, introduced by the ADR written to close a trust gap. A
12471
+ * plugin that wants the core floor has to declare `tier: "core"` and be
12472
+ * reviewed for it.
12473
+ */
12474
+ function policyForTier(tier) {
12475
+ if (tier === "quarantine") return { cap: QUARANTINE_CAP };
12476
+ if (tier === "core") return { floor: CORE_FLOOR };
12477
+ return {};
12478
+ }
12479
+ /**
12480
+ * E-ladder order. "E2" > "E1" > "E0" is a claim about how much evidence a
12481
+ * finding carries, and the floor is only meaningful if "at least E1" can be
12482
+ * stated. Declared as a ladder rather than compared as strings because
12483
+ * `"E0" > "E1"` is true in JavaScript and would invert the floor.
12484
+ */
12485
+ const EVIDENCE_RANK$1 = {
12486
+ E0: 0,
12487
+ E1: 1,
12488
+ E2: 2
12489
+ };
11874
12490
  /**
11875
12491
  * In-place enforcement over a scan's findings. Idempotent; runs AFTER
11876
- * evidence stamping so it wins over rule-declared evidenceLevel.
12492
+ * evidence stamping so it wins over rule-declared evidenceLevel — for the
12493
+ * cap and the floor alike. A rule's own `evidenceLevel` in `RuleMeta` is a
12494
+ * declaration about the rule; the tier is a decision about the rule, and
12495
+ * the decision outranks the declaration.
12496
+ *
12497
+ * Idempotence: cap-then-floor is a fixed point. A finding already at the
12498
+ * cap stays at the cap, and one already at or above the floor stays put,
12499
+ * so running this twice cannot walk a finding up the ladder.
11877
12500
  */
11878
12501
  function enforceTierPolicy(findings, tierByRuleId) {
11879
12502
  for (const f of findings) {
11880
- const cap = capForTier(tierByRuleId.get(f.ruleId));
12503
+ const { cap, floor } = policyForTier(tierByRuleId.get(f.ruleId));
11881
12504
  if (cap) {
11882
12505
  f.severity = cap.severity;
11883
12506
  f.evidenceLevel = cap.evidenceLevel;
11884
12507
  }
12508
+ if (floor?.evidenceLevel === void 0) continue;
12509
+ const effective = f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence);
12510
+ if (EVIDENCE_RANK$1[effective] < EVIDENCE_RANK$1[floor.evidenceLevel]) f.evidenceLevel = floor.evidenceLevel;
11885
12511
  }
11886
12512
  }
11887
12513
  //#endregion
@@ -12070,8 +12696,8 @@ function isDeclaredByPresent(f, present, metaByRuleId) {
12070
12696
  function ranksBefore(a, b, metaByRuleId) {
12071
12697
  const ma = metaByRuleId.get(a.ruleId);
12072
12698
  const mb = metaByRuleId.get(b.ruleId);
12073
- const tierA = TIER_RANK[ma?.tier ?? "core"];
12074
- const tierB = TIER_RANK[mb?.tier ?? "core"];
12699
+ const tierA = TIER_RANK[ma?.tier ?? "extended"];
12700
+ const tierB = TIER_RANK[mb?.tier ?? "extended"];
12075
12701
  if (tierA !== tierB) return tierA < tierB;
12076
12702
  const sevA = SEVERITY_RANK[a.severity];
12077
12703
  const sevB = SEVERITY_RANK[b.severity];
@@ -12091,6 +12717,16 @@ function dropOrderBefore(a, b, metaByRuleId) {
12091
12717
  return (a.column ?? Number.MAX_SAFE_INTEGER) < (b.column ?? Number.MAX_SAFE_INTEGER);
12092
12718
  }
12093
12719
  //#endregion
12720
+ //#region src/engine/finding-identity.ts
12721
+ /**
12722
+ * Correlation identity for before/after comparison: ruleId + file +
12723
+ * message, deliberately EXCLUDING line — a source edit that shifts a
12724
+ * finding still correlates. Shared by baseline, impact, and resolution.
12725
+ */
12726
+ function findingFingerprint(f) {
12727
+ return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
12728
+ }
12729
+ //#endregion
12094
12730
  //#region src/engine/correlation-engine.ts
12095
12731
  const EVIDENCE_RANK = {
12096
12732
  E0: 0,
@@ -12135,13 +12771,31 @@ function groupByFile(findings) {
12135
12771
  return groups;
12136
12772
  }
12137
12773
  /**
12138
- * Group findings by rootCauseId (or ruleId when rootCauseId is absent)
12139
- * to detect convergent findings pointing at the same root cause.
12774
+ * Group findings by root cause, falling back to the FINDING FINGERPRINT.
12775
+ *
12776
+ * The fallback used to be `ruleId`, and that is the defect this fixes. Nothing
12777
+ * in the tree assigns `rootCauseId` — the machine contract reports it `null` on
12778
+ * every finding — so `ruleId` was the key for every group, and N independent
12779
+ * defects from one rule across N files were reported as
12780
+ *
12781
+ * CONVERGENT — N findings share root cause QA-PW-002
12782
+ *
12783
+ * which is the precise inverse of what convergence means. Convergence is the
12784
+ * claim that several findings are ONE thing; the key that produced it said
12785
+ * only "these are the same rule". A fingerprint is ruleId + file + message, so
12786
+ * two `QA-PW-002` findings in different files are two keys, two groups of one,
12787
+ * and neither is reported.
12788
+ *
12789
+ * This is also why `findingId`/`rootCauseId`/`deduplicationGroup` are not
12790
+ * wired instead: they are the same idea, and an unwired field plus a fallback
12791
+ * would be two mechanisms for one claim. When a rule declares a real root
12792
+ * cause, `rootCauseId` still wins — the field is honoured the moment anything
12793
+ * populates it.
12140
12794
  */
12141
12795
  function groupByRootCause(findings) {
12142
12796
  const groups = /* @__PURE__ */ new Map();
12143
12797
  for (const f of findings) {
12144
- const key = f.rootCauseId ?? f.ruleId;
12798
+ const key = f.rootCauseId ?? findingFingerprint(f);
12145
12799
  const list = groups.get(key) ?? [];
12146
12800
  list.push(f);
12147
12801
  groups.set(key, list);
@@ -12149,15 +12803,26 @@ function groupByRootCause(findings) {
12149
12803
  return groups;
12150
12804
  }
12151
12805
  /**
12806
+ * The identity a conclusion lists a finding under.
12807
+ *
12808
+ * `findingId` when something has populated it, else the fingerprint. Never
12809
+ * `ruleId`: the fallback used to collapse every finding from one rule onto one
12810
+ * id, so a three-finding CONVERGENT group listed the same id three times and
12811
+ * the `alreadyConvergent` set below could not tell the members apart.
12812
+ */
12813
+ function conclusionId(f) {
12814
+ return f.findingId ?? findingFingerprint(f);
12815
+ }
12816
+ /**
12152
12817
  * Correlate findings and produce conclusions WITHOUT modifying them.
12153
12818
  * Deterministic — same input always produces same output.
12154
12819
  */
12155
12820
  function correlateFindings(findings) {
12156
12821
  const conclusions = [];
12157
12822
  const rootCauseGroups = groupByRootCause(findings);
12158
- for (const [rootCause, group] of Array.from(rootCauseGroups.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
12823
+ for (const [rootCause, group] of Array.from(rootCauseGroups.entries()).sort((a, b) => compareLocalized("en")(a[0], b[0]))) {
12159
12824
  if (group.length < 2) continue;
12160
- const findingIds = group.map((f) => f.findingId ?? f.ruleId);
12825
+ const findingIds = group.map(conclusionId);
12161
12826
  conclusions.push({
12162
12827
  conclusionType: "CONVERGENT",
12163
12828
  certainty: correlationStrength(group),
@@ -12167,13 +12832,13 @@ function correlateFindings(findings) {
12167
12832
  });
12168
12833
  }
12169
12834
  const fileGroups = groupByFile(findings);
12170
- for (const [file, group] of Array.from(fileGroups.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
12835
+ for (const [file, group] of Array.from(fileGroups.entries()).sort((a, b) => compareCodePoints(a[0], b[0]))) {
12171
12836
  if (group.length < 2) continue;
12172
12837
  const alreadyConvergent = /* @__PURE__ */ new Set();
12173
12838
  for (const c of conclusions) if (c.conclusionType === "CONVERGENT") for (const id of c.findingIds) alreadyConvergent.add(id);
12174
- const novel = group.filter((f) => !alreadyConvergent.has(f.findingId ?? f.ruleId));
12839
+ const novel = group.filter((f) => !alreadyConvergent.has(conclusionId(f)));
12175
12840
  if (novel.length < 2) continue;
12176
- const findingIds = novel.map((f) => f.findingId ?? f.ruleId);
12841
+ const findingIds = novel.map(conclusionId);
12177
12842
  conclusions.push({
12178
12843
  conclusionType: "AMPLIFIED",
12179
12844
  certainty: correlationStrength(novel),
@@ -12188,7 +12853,7 @@ function correlateFindings(findings) {
12188
12853
  certainty: "STRONG",
12189
12854
  corroboration: `${corroborated.length} finding(s) with runtime corroboration`,
12190
12855
  sourceCount: corroborated.length,
12191
- findingIds: corroborated.map((f) => f.findingId ?? f.ruleId)
12856
+ findingIds: corroborated.map(conclusionId)
12192
12857
  });
12193
12858
  return conclusions;
12194
12859
  }
@@ -12430,7 +13095,25 @@ const BRAND = {
12430
13095
  auroraGreen: "#5FD6A4",
12431
13096
  auroraViolet: "#9D8CF5",
12432
13097
  steel: "#C8CBCF",
12433
- steelDim: "#8B939D"
13098
+ steelDim: "#8B939D",
13099
+ /** The teal half of the accent rule, and the gradient's near stop. */
13100
+ readingAccent: "#73C4CA",
13101
+ /** The violet half of the accent rule, and the gradient's far stop. */
13102
+ readingAccentAlt: "#A596E5",
13103
+ /** The focus ring. A non-text affordance, held to the same AA floor. */
13104
+ readingFocus: "#8CE3ED",
13105
+ /** Hover state for the mark's monochrome glyph. */
13106
+ readingAccentBright: "#85DBED",
13107
+ /** Hover state for the wordmark's ink. */
13108
+ readingAccentInk: "#E5FCFF",
13109
+ /** UNWORTHY at reading contrast — a softer red than the terminal's. */
13110
+ readingUnworthy: "#FF9790",
13111
+ /** Muted body ink on the reading surfaces. */
13112
+ readingMuted: "#C6CFDB",
13113
+ /** Dimmed ink on the reading surfaces. */
13114
+ readingDim: "#ACB9CA",
13115
+ /** The hero lede: the brightest ink below `TEXT.primary`. */
13116
+ readingLede: "#E0E6EE"
12434
13117
  };
12435
13118
  /**
12436
13119
  * Midnight iron. One ramp, four steps, darkest first.
@@ -12533,7 +13216,7 @@ const TRUST = {
12533
13216
  l5: "#5CC4E0"
12534
13217
  };
12535
13218
  SURFACE.ink950, SURFACE.ink900, SURFACE.ink850, SURFACE.ink800, BRAND.steel, BRAND.steelDim, BRAND.gold, BRAND.goldBright, BRAND.goldHot, BRAND.aurora, BRAND.auroraBright, BRAND.auroraCyan, BRAND.auroraGreen, BRAND.auroraViolet;
12536
- SCORE.trusted, SCORE.forged, SCORE.warning, SCORE.critical, STATUS.info, TEXT.onGold, STATUS.ok, EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2, TRUST.l0, TRUST.l1, TRUST.l2, TRUST.l3, TRUST.l4, TRUST.l5;
13219
+ SCORE.trusted, SCORE.forged, SCORE.warning, SCORE.critical, STATUS.info, TEXT.onGold, STATUS.ok, EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2, TRUST.l0, TRUST.l1, TRUST.l2, TRUST.l3, TRUST.l4, TRUST.l5, BRAND.readingAccent, BRAND.readingAccentAlt, BRAND.readingFocus, BRAND.readingAccentBright, BRAND.readingAccentInk, BRAND.readingUnworthy, BRAND.readingMuted, BRAND.readingDim, BRAND.readingLede;
12537
13220
  EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2;
12538
13221
  const RUNG_MEANINGS = [
12539
13222
  "observation only",
@@ -12918,9 +13601,10 @@ function hashDir(dir, hash, depth) {
12918
13601
  try {
12919
13602
  entries = readdirSync(dir, { withFileTypes: true });
12920
13603
  } catch {
13604
+ recordDegradation("rules-tree-listing-unreadable");
12921
13605
  return;
12922
13606
  }
12923
- entries.sort((a, b) => a.name.localeCompare(b.name));
13607
+ entries.sort((a, b) => compareCodePoints(a.name, b.name));
12924
13608
  for (const entry of entries) {
12925
13609
  if (entry.name === "node_modules" || entry.name === ".git") continue;
12926
13610
  const full = join(dir, entry.name);
@@ -13422,7 +14106,7 @@ function renderFlakyMd(report) {
13422
14106
  const lines = [];
13423
14107
  lines.push("# FLAKY.md");
13424
14108
  lines.push("");
13425
- lines.push(`> Generated by mjolnir forensics from ${report.source} run data.`);
14109
+ lines.push(`> Generated by Mjölnir from ${report.source} run data.`);
13426
14110
  lines.push("> A test that passes only on retry is not a passing test — it is a lucky test.");
13427
14111
  lines.push("");
13428
14112
  lines.push(`**${report.totalTests}** tests analyzed · **${report.flakyTests}** true flakes · **${report.retriedTests}** retried · **${report.failed}** failing`);
@@ -13770,19 +14454,19 @@ function parseJestJson(json) {
13770
14454
  }
13771
14455
  //#endregion
13772
14456
  //#region src/forensics/parse-junit.ts
13773
- const MAX_INPUT = 20971520;
13774
- function decodeEntities(s) {
14457
+ const MAX_INPUT$1 = 20971520;
14458
+ function decodeEntities$1(s) {
13775
14459
  return s.replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", "\"").replaceAll("&apos;", "'").replaceAll("&#39;", "'").replaceAll("&amp;", "&");
13776
14460
  }
13777
- function attr(tag, name) {
14461
+ function attr$1(tag, name) {
13778
14462
  const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
13779
14463
  const m = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*"([^"]*)"`, "i").exec(tag);
13780
- if (m?.[1] !== void 0) return decodeEntities(m[1]);
14464
+ if (m?.[1] !== void 0) return decodeEntities$1(m[1]);
13781
14465
  const m2 = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*'([^']*)'`, "i").exec(tag);
13782
- return m2?.[1] !== void 0 ? decodeEntities(m2[1]) : void 0;
14466
+ return m2?.[1] !== void 0 ? decodeEntities$1(m2[1]) : void 0;
13783
14467
  }
13784
14468
  function parseJunitXml(xml) {
13785
- if (xml.length > MAX_INPUT) return [];
14469
+ if (xml.length > MAX_INPUT$1) return [];
13786
14470
  const out = [];
13787
14471
  const startRe = /<testcase\b/gi;
13788
14472
  const closeRe = /<\/testcase\s*>/gi;
@@ -13803,9 +14487,9 @@ function parseJunitXml(xml) {
13803
14487
  inner = xml.slice(gt + 1, close.index);
13804
14488
  startRe.lastIndex = close.index + close[0].length;
13805
14489
  }
13806
- const name = attr(attrs, "name") ?? "(unnamed)";
13807
- const classname = attr(attrs, "classname") ?? "";
13808
- const timeRaw = attr(attrs, "time");
14490
+ const name = attr$1(attrs, "name") ?? "(unnamed)";
14491
+ const classname = attr$1(attrs, "classname") ?? "";
14492
+ const timeRaw = attr$1(attrs, "time");
13809
14493
  const timeSec = timeRaw !== void 0 && Number.isFinite(Number(timeRaw)) ? Number(timeRaw) : 0;
13810
14494
  const failed = /<(?:failure|error)\b/i.test(inner);
13811
14495
  const skipped = /<skipped\b/i.test(inner);
@@ -13816,16 +14500,16 @@ function parseJunitXml(xml) {
13816
14500
  while ((failMatch = failRe.exec(inner)) !== null) {
13817
14501
  const attrsStr = failMatch[1] ?? "";
13818
14502
  const msgAttr = /\smessage\s*=\s*"([^"]*)"/i.exec(attrsStr)?.[1];
13819
- if (msgAttr) errors.push(sanitizeErrorText(decodeEntities(msgAttr)));
14503
+ if (msgAttr) errors.push(sanitizeErrorText(decodeEntities$1(msgAttr)));
13820
14504
  const body = failMatch[2]?.trim();
13821
- if (body && body !== msgAttr) errors.push(sanitizeErrorText(decodeEntities(body)));
14505
+ if (body && body !== msgAttr) errors.push(sanitizeErrorText(decodeEntities$1(body)));
13822
14506
  }
13823
14507
  const selfCloseRe = /<(?:failure|error)\b([^>]*)\/>/gi;
13824
14508
  let scMatch;
13825
14509
  while ((scMatch = selfCloseRe.exec(inner)) !== null) {
13826
14510
  const scAttrs = scMatch[1] ?? "";
13827
14511
  const scMsgMatch = /\smessage\s*=\s*"([^"]*)"/i.exec(scAttrs);
13828
- if (scMsgMatch?.[1]) errors.push(sanitizeErrorText(decodeEntities(scMsgMatch[1])));
14512
+ if (scMsgMatch?.[1]) errors.push(sanitizeErrorText(decodeEntities$1(scMsgMatch[1])));
13829
14513
  }
13830
14514
  }
13831
14515
  const attempt = {
@@ -13843,6 +14527,142 @@ function parseJunitXml(xml) {
13843
14527
  return out;
13844
14528
  }
13845
14529
  //#endregion
14530
+ //#region src/forensics/parse-trx.ts
14531
+ const MAX_INPUT = 20971520;
14532
+ function decodeEntities(s) {
14533
+ return s.replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", "\"").replaceAll("&apos;", "'").replaceAll("&#39;", "'").replaceAll("&amp;", "&");
14534
+ }
14535
+ function attr(tag, name) {
14536
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14537
+ const m = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*"([^"]*)"`, "i").exec(tag);
14538
+ if (m?.[1] !== void 0) return decodeEntities(m[1]);
14539
+ const sq = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*'([^']*)'`, "i").exec(tag);
14540
+ if (sq?.[1] !== void 0) return decodeEntities(sq[1]);
14541
+ }
14542
+ /**
14543
+ * Does this document look like TRX at all?
14544
+ *
14545
+ * Shape-sniffed rather than trusted from its location, for the same reason the
14546
+ * JUnit path sniffs: a `TestResults/` directory is a convention, and a
14547
+ * convention can be wrong. A file that is not a `TestRun` must not be parsed as
14548
+ * one and produce confident nonsense.
14549
+ */
14550
+ function looksLikeTrxXml(text) {
14551
+ return /<TestRun[\s>]/i.test(text.slice(0, 4096));
14552
+ }
14553
+ /**
14554
+ * TRX durations are `HH:MM:SS.fffffff`; absent or `-` means "not timed".
14555
+ *
14556
+ * Parsed by splitting rather than by one pattern. A single regex for this has
14557
+ * to be `^(\d+):(\d{2}):(\d{2})(?:\.(\d+))?$`, and the nested quantifiers there
14558
+ * are what `security/detect-unsafe-regex` flags — correctly in general, since
14559
+ * this shape is the usual ReDoS source. Splitting removes the question rather
14560
+ * than asserting the pattern is safe today.
14561
+ */
14562
+ function parseDuration(raw) {
14563
+ if (raw === void 0) return 0;
14564
+ const text = raw.trim();
14565
+ const dot = text.indexOf(".");
14566
+ const clock = dot === -1 ? text : text.slice(0, dot);
14567
+ const fraction = dot === -1 ? "" : text.slice(dot + 1);
14568
+ const parts = clock.split(":");
14569
+ if (parts.length !== 3) return 0;
14570
+ const [h, m, s] = parts;
14571
+ if (h === void 0 || m === void 0 || s === void 0) return 0;
14572
+ if (!/^\d+$/.test(h) || !/^\d{1,2}$/.test(m) || !/^\d{1,2}$/.test(s)) return 0;
14573
+ const millis = fraction.length === 0 ? 0 : Number(fraction.slice(0, 3).padEnd(3, "0"));
14574
+ return Number(h) * 36e5 + Number(m) * 6e4 + Number(s) * 1e3 + millis;
14575
+ }
14576
+ /**
14577
+ * TRX `outcome` to a run status.
14578
+ *
14579
+ * The mapping is stated rather than inferred from a default, because the
14580
+ * dangerous direction here is a silent default: an outcome this engine has
14581
+ * never seen must not read as `passed`. Every unrecognised value maps to
14582
+ * `skipped`, which is the non-claiming direction — a skipped test contributes
14583
+ * nothing to "the suite passed" — rather than to a status that would let it.
14584
+ *
14585
+ * TRX also has outcomes that are not verdicts at all (`Warning`, `Pending`,
14586
+ * `Expired`). They are treated the same way, deliberately.
14587
+ */
14588
+ function mapTrxOutcome(outcome) {
14589
+ switch ((outcome ?? "").toLowerCase()) {
14590
+ case "passed":
14591
+ case "completed": return "passed";
14592
+ case "failed":
14593
+ case "error": return "failed";
14594
+ case "timeout": return "timedOut";
14595
+ case "aborted":
14596
+ case "interrupted": return "interrupted";
14597
+ case "notexecuted":
14598
+ case "skipped":
14599
+ case "inconclusive":
14600
+ case "notrunnable":
14601
+ case "pending": return "skipped";
14602
+ default: return "skipped";
14603
+ }
14604
+ }
14605
+ function tagOf(xml, name) {
14606
+ const out = [];
14607
+ const re = new RegExp(`<${name}\\b[^>]*>`, "gi");
14608
+ let m;
14609
+ while ((m = re.exec(xml)) !== null) {
14610
+ if (m[0] !== void 0) out.push(m[0]);
14611
+ if (out.length > 2e5) break;
14612
+ }
14613
+ return out;
14614
+ }
14615
+ /** The text of a `<Message>` child, used as the failure's error text. */
14616
+ function messageOf(text, near) {
14617
+ const window = text.slice(near, near + 2e3);
14618
+ const m = /<Message[^>]*>([\s\S]{0,1500}?)<\/Message>/i.exec(window);
14619
+ if (m?.[1] === void 0) return void 0;
14620
+ const cleaned = sanitizeErrorText(decodeEntities(m[1].replace(/<[^>]*>/g, " ")).trim());
14621
+ return cleaned.length === 0 ? void 0 : cleaned;
14622
+ }
14623
+ /**
14624
+ * Parse a TRX document into test records.
14625
+ *
14626
+ * Returns an empty array for a document that is not TRX rather than throwing:
14627
+ * discovery sniffs before parsing, and a caller that hands us a non-TRX file
14628
+ * should get "no evidence here", not an exception that aborts a whole run
14629
+ * report.
14630
+ */
14631
+ function parseTrxXml(xml) {
14632
+ if (xml.length > MAX_INPUT) return [];
14633
+ if (!looksLikeTrxXml(xml)) return [];
14634
+ const byTestId = /* @__PURE__ */ new Map();
14635
+ for (const tag of tagOf(xml, "UnitTest")) {
14636
+ const id = attr(tag, "id");
14637
+ const name = attr(tag, "name");
14638
+ if (id !== void 0 && name !== void 0) byTestId.set(id, name);
14639
+ }
14640
+ const records = [];
14641
+ const resultRe = /<UnitTestResult\b([^>]*)>([\s\S]{0,4000}?)(?=<UnitTestResult\b|<\/Results>)/gi;
14642
+ let m;
14643
+ while ((m = resultRe.exec(xml)) !== null) {
14644
+ const attrs = m[1] ?? "";
14645
+ const inner = m[2] ?? "";
14646
+ const testName = attr(attrs, "testName") ?? attr(attrs, "testId");
14647
+ if (testName === void 0) continue;
14648
+ const attempt = {
14649
+ index: 1,
14650
+ status: mapTrxOutcome(attr(attrs, "outcome")),
14651
+ durationMs: parseDuration(attr(attrs, "duration"))
14652
+ };
14653
+ const error = messageOf(inner, 0);
14654
+ records.push({
14655
+ file: "unknown",
14656
+ title: testName,
14657
+ attempts: [attempt],
14658
+ evidenceKind: "test",
14659
+ ...error === void 0 ? {} : { errors: [error] }
14660
+ });
14661
+ if (records.length > 2e5) break;
14662
+ }
14663
+ return records;
14664
+ }
14665
+ //#endregion
13846
14666
  //#region src/forensics/parse-playwright-json.ts
13847
14667
  const MAX_DEPTH = 32;
13848
14668
  function toStatus$1(raw) {
@@ -14052,7 +14872,9 @@ function parseHarJsonDetailed(json) {
14052
14872
  //#endregion
14053
14873
  //#region src/forensics/run.ts
14054
14874
  /**
14055
- * `mjolnir forensics <dir-or-file>` — runtime evidence entry point (R4).
14875
+ * Runtime evidence entry point (R4) — the library behind `explain
14876
+ * --evidence`. The `forensics` verb is retired in the v6 carve; this module
14877
+ * is what reads the run data.
14056
14878
  *
14057
14879
  * Accepts either a single report file or a directory. In a directory it
14058
14880
  * looks for, in priority order:
@@ -14252,6 +15074,13 @@ function traceArtifactName(fullPath) {
14252
15074
  return base;
14253
15075
  }
14254
15076
  function parseFile(path, text) {
15077
+ if (/\.trx$/i.test(path) || looksLikeTrxXml(text)) {
15078
+ const records = parseTrxXml(text);
15079
+ if (records.length > 0) return {
15080
+ records,
15081
+ source: "dotnet-trx"
15082
+ };
15083
+ }
14255
15084
  if (/\.xml$/i.test(path) || /^\s*<\?xml|<testsuite\b/i.test(text.slice(0, 200))) return {
14256
15085
  records: parseJunitXml(text),
14257
15086
  source: "junit-xml"
@@ -14319,11 +15148,11 @@ function listFiles(dir) {
14319
15148
  return out.map((p) => p);
14320
15149
  }
14321
15150
  plainContext();
14322
- function loadSuppressions(root) {
15151
+ function loadSuppressions(root, now = /* @__PURE__ */ new Date()) {
14323
15152
  const { config } = loadConfig(root);
14324
15153
  const entries = (config.ignore ?? []).map((ign) => ({
14325
15154
  ...ign,
14326
- status: isSuppressionActive(ign, /* @__PURE__ */ new Date()) ? "active" : "expired"
15155
+ status: isSuppressionActive(ign, now) ? "active" : "expired"
14327
15156
  }));
14328
15157
  return {
14329
15158
  total: entries.length,
@@ -14503,7 +15332,7 @@ function loadPlugins(root, gateOpen = false) {
14503
15332
  * - Load failures degrade honestly: warning entries, never a crash;
14504
15333
  * exit codes stay frozen.
14505
15334
  *
14506
- * Drift-check: the `mjolnir rules --md` catalog is generated from the
15335
+ * Drift-check: the `mjolnir explain --list --md` catalog is generated from the
14507
15336
  * LOADED rules (core + external), so the catalog can never drift from
14508
15337
  * what actually ships — an edit to a local rule file changes the very
14509
15338
  * next catalog render (locked by tests/local-rules.spec.ts).
@@ -14849,7 +15678,7 @@ const KNOWN_RULE_IDS = new Set(RULES.map((r) => r.id));
14849
15678
  const OVERLAP_META_BY_RULE_ID = new Map(RULES.map((r, order) => {
14850
15679
  const meta = {
14851
15680
  ...r.overlapWith ? { overlapWith: r.overlapWith } : {},
14852
- ...r.tier ? { tier: r.tier } : {},
15681
+ tier: effectiveTier(r),
14853
15682
  order
14854
15683
  };
14855
15684
  return [r.id, meta];
@@ -14877,11 +15706,14 @@ async function buildUniversalRules(root, strict, opts = {}) {
14877
15706
  rules: local.rules
14878
15707
  }].flatMap((p) => p.rules.map(asUniversal));
14879
15708
  const tierByRuleId = /* @__PURE__ */ new Map();
14880
- for (const r of RULES) if (r.tier) tierByRuleId.set(r.id, r.tier);
15709
+ for (const r of RULES) tierByRuleId.set(r.id, effectiveTier(r));
14881
15710
  for (const p of plugins) for (const r of p.rules) if (r.tier) tierByRuleId.set(r.id, r.tier);
14882
15711
  for (const r of local.rules) if (r.tier) tierByRuleId.set(r.id, r.tier);
14883
15712
  let rules = [...UNIVERSAL_RULES, ...externalRules];
15713
+ const withheldRuleIds = rules.filter((r) => tierByRuleId.get(r.id) === "quarantine").map((r) => r.id).sort();
14884
15714
  if (!strict) rules = rules.filter((r) => tierByRuleId.get(r.id) !== "quarantine");
15715
+ const rulesWithheld = strict ? 0 : withheldRuleIds.length;
15716
+ const rulesApplied = rules.length;
14885
15717
  const pluginMeta = [...plugins.map((p) => ({
14886
15718
  name: p.name,
14887
15719
  rules: p.rules.length
@@ -14894,7 +15726,10 @@ async function buildUniversalRules(root, strict, opts = {}) {
14894
15726
  pluginErrors: allErrors,
14895
15727
  tierByRuleId,
14896
15728
  pluginMeta,
14897
- externalRules: local.rules
15729
+ externalRules: local.rules,
15730
+ rulesApplied,
15731
+ rulesWithheld,
15732
+ withheldRuleIds
14898
15733
  };
14899
15734
  }
14900
15735
  /** Rule-declared evidence-level overrides (Honesty Core). */
@@ -14919,35 +15754,6 @@ function fallbackWorkspace(targetAbs) {
14919
15754
  };
14920
15755
  }
14921
15756
  /**
14922
- * Minimal glob match for suppression `files` patterns, with gitignore
14923
- * `**` semantics (bug-audit M5). Supports:
14924
- * "tests/**" — everything inside tests/
14925
- * "tests" + "/**\/*.spec.ts" — any depth UNDER tests/ (including none) ending in .spec.ts
14926
- * "**" + "/*.spec.ts" — any depth including root-level files
14927
- * "tests/foo.spec.ts" — exact path
14928
- * "*" within a segment never crosses "/".
14929
- *
14930
- * Forward slashes only (findings always use normalized paths). `?`,
14931
- * character classes and `!` negation are not metacharacters here — same
14932
- * as before this rewrite.
14933
- */
14934
- function pathMatchesGlob(path, glob) {
14935
- const p = path.replaceAll("\\", "/");
14936
- const segments = glob.replaceAll("\\", "/").split("/");
14937
- let re = "^";
14938
- for (const [i, segment] of segments.entries()) {
14939
- const last = i === segments.length - 1;
14940
- if (segment === "**") {
14941
- if (last) re += "(?:[^/]+/)*[^/]+";
14942
- else re += "(?:[^/]+/)*";
14943
- continue;
14944
- }
14945
- re += segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", "[^/]*");
14946
- if (!last) re += "/";
14947
- }
14948
- return new RegExp(`${re}$`).test(p);
14949
- }
14950
- /**
14951
15757
  * Plan §16 + WI-11: locate a runtime run report next to the scan
14952
15758
  * target, using the exact conventions the forensics ingestion already
14953
15759
  * accepts. Zero-config search over conventional artifact names at
@@ -15056,6 +15862,7 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
15056
15862
  let rulesPartial = false;
15057
15863
  let parseFailed = 0;
15058
15864
  let parseFallbacks = 0;
15865
+ let astFallbackFiles = 0;
15059
15866
  let scanned = 0;
15060
15867
  let analyzed = 0;
15061
15868
  for (const path of testFiles) {
@@ -15099,6 +15906,10 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
15099
15906
  });
15100
15907
  }
15101
15908
  const wantsAst = adapter.parseAst !== void 0 && Date.now() <= deadline;
15909
+ if (adapter.parseAst !== void 0 && !wantsAst) {
15910
+ truncationReasons.add("ast-budget-fallback");
15911
+ astFallbackFiles++;
15912
+ }
15102
15913
  const identity = (mode) => ({
15103
15914
  relPath,
15104
15915
  adapterId: adapter.id,
@@ -15216,7 +16027,8 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
15216
16027
  parseFailed,
15217
16028
  parseFallbacks,
15218
16029
  scanned,
15219
- analyzed
16030
+ analyzed,
16031
+ astFallbackFiles
15220
16032
  };
15221
16033
  }
15222
16034
  /**
@@ -15444,6 +16256,11 @@ function assembleScanResult(o) {
15444
16256
  scopeUnrecognized: o.scopeUnrecognized,
15445
16257
  parseFailed: o.parseFailed,
15446
16258
  parseFallbacks: o.parseFallbacks ?? 0,
16259
+ ...o.astFallbackFiles !== void 0 ? { astFallbackFiles: o.astFallbackFiles } : {},
16260
+ ...o.degradations !== void 0 ? { degradations: o.degradations } : {},
16261
+ ...o.rulesWithheld !== void 0 ? { rulesWithheld: o.rulesWithheld } : {},
16262
+ ...o.withheldRuleIds !== void 0 ? { withheldRuleIds: o.withheldRuleIds } : {},
16263
+ ...o.rulesApplied !== void 0 ? { rulesApplied: o.rulesApplied } : {},
15447
16264
  ...o.scopeInfo.degraded !== void 0 ? { scopeDegraded: o.scopeInfo.degraded } : {},
15448
16265
  ...o.runtimeIncomplete !== void 0 ? { runtimeIncomplete: o.runtimeIncomplete } : {},
15449
16266
  identityIncomplete
@@ -15458,11 +16275,15 @@ function assembleScanResult(o) {
15458
16275
  })),
15459
16276
  config: o.config ?? null,
15460
16277
  engineVersion: ENGINE_VERSION,
16278
+ runMode: resolveRunMode({
16279
+ env: {},
16280
+ flags: {}
16281
+ }),
15461
16282
  reportDigest,
15462
16283
  trustModelVersion: TRUST_MODEL_VERSION,
15463
16284
  scoringModelVersion: SCORING_MODEL_VERSION,
15464
16285
  frameworkSupportMatrixVersion: FRAMEWORK_SUPPORT_MATRIX_VERSION,
15465
- evidenceSchemaVersions: [1],
16286
+ evidenceSchemaVersions: [2],
15466
16287
  ...repository,
15467
16288
  ...candidate !== null ? { candidate } : {}
15468
16289
  });
@@ -15475,7 +16296,10 @@ function assembleScanResult(o) {
15475
16296
  });
15476
16297
  const hasTests = o.testFileCount > 0 && o.testDeclarationCount > 0;
15477
16298
  const suiteInvalidatedBy = [...new Set(o.findings.filter((f) => SUITE_INVALIDATING_RULE_IDS.has(f.ruleId)).map((f) => f.ruleId))].sort();
15478
- const finalScore = hasTests ? completion.partial && scopeAdjustedTotal >= 100 ? 99 : scopeAdjustedTotal : null;
16299
+ const scopeClamped = scopeReasons.length > 0 && total >= 100;
16300
+ const partialClamped = completion.partial && scopeAdjustedTotal >= 100;
16301
+ const scoreClampReason = scopeClamped ? "scope-degraded" : partialClamped ? "partial-scan" : null;
16302
+ const finalScore = hasTests ? partialClamped ? 99 : scopeAdjustedTotal : null;
15479
16303
  const result = {
15480
16304
  schemaVersion: 1,
15481
16305
  partial: completion.partial,
@@ -15514,9 +16338,11 @@ function assembleScanResult(o) {
15514
16338
  } } : {},
15515
16339
  analysisStatus: {
15516
16340
  ...completion.analysisStatus,
16341
+ coverageState: completion.coverageState,
15517
16342
  durationMs: elapsed
15518
16343
  },
15519
- scoringModelVersion: SCORING_MODEL_VERSION
16344
+ scoringModelVersion: SCORING_MODEL_VERSION,
16345
+ ...scoreClampReason !== null ? { scoreClampReason } : {}
15520
16346
  };
15521
16347
  result.trustSummary = buildTrustSummary(result, o.declarationsByFile);
15522
16348
  if (o.findings.length > 0) result.correlationConclusions = correlateFindings(o.findings);
@@ -15560,15 +16386,33 @@ function assembleScanResult(o) {
15560
16386
  * inherently async); `runRules` and every rule stay synchronous and
15561
16387
  * consume `ParsedFile.ast`. Callers await the returned promise.
15562
16388
  */
16389
+ /**
16390
+ * Run one scan inside its own ts-morph Project scope.
16391
+ *
16392
+ * The wrapper is the whole fix. ts-morph's Project caches SourceFiles by path
16393
+ * and `parseTsFile` calls `replaceWithText` on them, so a Project shared between
16394
+ * two concurrent scans lets one rewrite the file the other is mid-rule on —
16395
+ * which surfaced as three of four concurrent scans reporting
16396
+ * `rulesCrashed: 4` and `rules: "partial"`, and therefore as two different
16397
+ * machine-contract digests from one tree. See `src/engine/ts-ast.ts`.
16398
+ *
16399
+ * Async context propagates across `await`, so the scope covers the whole scan
16400
+ * without threading a Project through every adapter.
16401
+ */
15563
16402
  async function runScan(args, hooks = {}) {
16403
+ return runWithScanProject(() => runScanInProject(args, hooks));
16404
+ }
16405
+ async function runScanInProject(args, hooks = {}) {
15564
16406
  const started = Date.now();
16407
+ const degradationWindow = beginDegradationWindow();
15565
16408
  const requestedDuration = Number.isFinite(args.maxDurationMs) ? args.maxDurationMs : DEFAULT_MAX_DURATION_MS$1;
15566
16409
  const deadline = started + Math.min(Math.max(1, requestedDuration), MAX_DURATION_MS$1);
15567
16410
  const discovered = discoverWorkspace(args.target);
15568
16411
  const targetAbs = resolve(args.target);
15569
16412
  const scanRoot = discovered && discovered.root !== targetAbs && targetAbs.startsWith(discovered.root + sep) ? {
15570
16413
  ...discovered,
15571
- root: targetAbs
16414
+ root: targetAbs,
16415
+ packageJson: {}
15572
16416
  } : discovered ?? fallbackWorkspace(targetAbs);
15573
16417
  const workspace = scanRoot;
15574
16418
  if (args.verbose) hooks.onConfigWarning?.(`verbose: scan root (config/ignore anchor): ${workspace.root}`);
@@ -15585,7 +16429,7 @@ async function runScan(args, hooks = {}) {
15585
16429
  let scopeUnrecognized = 0;
15586
16430
  let parseFailed = 0;
15587
16431
  const fileProvenance = [];
15588
- const { rules: activeRules, pluginErrors, tierByRuleId: tiers, pluginMeta } = await buildUniversalRules(workspace.root, args.strict, {
16432
+ const { rules: activeRules, pluginErrors, tierByRuleId: tiers, pluginMeta, rulesApplied, rulesWithheld, withheldRuleIds } = await buildUniversalRules(workspace.root, args.strict, {
15589
16433
  ...args.enablePlugins !== void 0 ? { enablePlugins: args.enablePlugins } : {},
15590
16434
  onGateNotice: (notice) => hooks.onGateNotice ? hooks.onGateNotice(notice) : console.error(notice)
15591
16435
  });
@@ -15653,6 +16497,7 @@ async function runScan(args, hooks = {}) {
15653
16497
  rulesPartial = analysis.rulesPartial;
15654
16498
  parseFailed = analysis.parseFailed;
15655
16499
  const parseFallbacks = analysis.parseFallbacks;
16500
+ const astFallbackFiles = analysis.astFallbackFiles;
15656
16501
  const scanned = analysis.scanned;
15657
16502
  const analyzed = analysis.analyzed;
15658
16503
  const postScan = applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, declarationsByFile, testDeclarationCount, tierByRuleId, REVISION_BY_RULE_ID);
@@ -15671,6 +16516,11 @@ async function runScan(args, hooks = {}) {
15671
16516
  scopeUnrecognized,
15672
16517
  parseFailed,
15673
16518
  parseFallbacks,
16519
+ astFallbackFiles,
16520
+ degradations: summarizeDegradations(degradationsSince(degradationWindow)),
16521
+ rulesApplied,
16522
+ rulesWithheld,
16523
+ ...withheldRuleIds.length > 0 ? { withheldRuleIds } : {},
15674
16524
  scanned,
15675
16525
  analyzed,
15676
16526
  testFiles,
@@ -15697,6 +16547,39 @@ async function runScan(args, hooks = {}) {
15697
16547
  resetTsMorphProject();
15698
16548
  return result;
15699
16549
  }
16550
+ //#endregion
16551
+ //#region src/cli-io.ts
16552
+ /**
16553
+ * The ONE error-to-message derivation in `src/`.
16554
+ *
16555
+ * This tree carried four near-copies: two `errorMessage` (byte-identical) and
16556
+ * two `errorText`. The two `errorText` copies were not identical, which is the
16557
+ * part that mattered — `String(someObject)` renders `[object Object]`, so the
16558
+ * copy that lost the payload turned a hostile non-Error throw into a message
16559
+ * that names nothing. The richer arms are kept here, so every call site gets
16560
+ * the informative one and the difference stops being a per-file accident.
16561
+ *
16562
+ * The arms, in order:
16563
+ * Error -> `.message`. The 99% case, and the only one that
16564
+ * carries a stack the caller may still want.
16565
+ * string -> itself. A thrown string is already the message.
16566
+ * plain object -> `JSON.stringify`. A non-Error throw of `{ code:
16567
+ * "EISDIR" }` is a REAL failure shape in Node, and
16568
+ * `[object Object]` hides the only field that says
16569
+ * what went wrong.
16570
+ * anything else -> `String(err)`. Symbols and undefined included, so
16571
+ * nothing vanishes.
16572
+ *
16573
+ * The call this replaces was `err instanceof Error ? err.message : String(err)`
16574
+ * in `src/mcp/server.ts` and `src/commands/doctor.ts`; `tests/cli/summary.spec.ts`
16575
+ * already pins the object arm.
16576
+ */
16577
+ function errorMessage(err) {
16578
+ if (err instanceof Error) return err.message;
16579
+ if (typeof err === "string") return err;
16580
+ if (typeof err === "object" && err !== null) return JSON.stringify(err);
16581
+ return String(err);
16582
+ }
15700
16583
  /**
15701
16584
  * Fields that participate in the verification digest: identity (ruleId,
15702
16585
  * detectorRevision, file, line, column) + evidence (severity,
@@ -15742,6 +16625,10 @@ function canonicalScanJson(result) {
15742
16625
  skippedFiles: result.analysisStatus.skippedFiles,
15743
16626
  rulesCrashed: result.analysisStatus.rulesCrashed ?? 0,
15744
16627
  parseFallbacks: result.analysisStatus.parseFallbacks ?? 0,
16628
+ coverageState: result.analysisStatus.coverageState ?? "COMPLETE",
16629
+ rulesApplied: result.analysisStatus.rulesApplied ?? null,
16630
+ rulesWithheld: result.analysisStatus.rulesWithheld ?? 0,
16631
+ degradations: result.analysisStatus.degradations ?? [],
15745
16632
  truncationReasons: result.analysisStatus.truncationReasons ?? [],
15746
16633
  reasons: result.analysisStatus.reasons ?? []
15747
16634
  },
@@ -15785,7 +16672,7 @@ function buildMachineContract(result) {
15785
16672
  };
15786
16673
  });
15787
16674
  return {
15788
- contractVersion: 1,
16675
+ contractVersion: 2,
15789
16676
  summary: {
15790
16677
  digest: `sha256:${digest}`,
15791
16678
  findings: result.findings.length,
@@ -15806,6 +16693,10 @@ function buildMachineContract(result) {
15806
16693
  ...result.analysisStatus.parseFallbacks !== void 0 ? { parseFallbacks: result.analysisStatus.parseFallbacks } : {},
15807
16694
  truncationReasons: result.analysisStatus.truncationReasons ?? [],
15808
16695
  ...result.analysisStatus.reasons !== void 0 ? { reasons: result.analysisStatus.reasons } : {},
16696
+ ...result.analysisStatus.degradations !== void 0 ? { degradations: result.analysisStatus.degradations } : {},
16697
+ ...result.analysisStatus.coverageState !== void 0 ? { coverageState: result.analysisStatus.coverageState } : {},
16698
+ ...result.analysisStatus.rulesApplied !== void 0 ? { rulesApplied: result.analysisStatus.rulesApplied } : {},
16699
+ ...result.analysisStatus.rulesWithheld !== void 0 ? { rulesWithheld: result.analysisStatus.rulesWithheld } : {},
15809
16700
  frameworkDetectionUnknown: result.frameworkDetectionUnknown,
15810
16701
  durationMs: result.analysisStatus.durationMs,
15811
16702
  ...result.scopeIntegrity !== void 0 ? { scopeIntegrity: result.scopeIntegrity } : {},
@@ -15847,7 +16738,12 @@ function explainRule(ruleId, fixturesRoot) {
15847
16738
  const rule = getRule(ruleId);
15848
16739
  if (!rule) return {
15849
16740
  ok: false,
15850
- error: `Unknown rule ID "${ruleId}". Run \`mjolnir rules\` for the full catalog.`
16741
+ error: `Unknown rule ID "${ruleId}". Run \`mjolnir explain --list\` for the full catalog.`
16742
+ };
16743
+ const window = beginDegradationWindow();
16744
+ const degraded = () => {
16745
+ const counts = summarizeDegradations(degradationsSince(window));
16746
+ return counts.length > 0 ? { exampleDegraded: counts } : {};
15851
16747
  };
15852
16748
  const fixturePath = firstFixtureFile(join(fixturesRoot, ruleId, "must-fire"));
15853
16749
  if (!fixturePath) return {
@@ -15858,9 +16754,11 @@ function explainRule(ruleId, fixturesRoot) {
15858
16754
  try {
15859
16755
  text = readFileSync(fixturePath, "utf8").replace(/\r\n/g, "\n");
15860
16756
  } catch {
16757
+ recordDegradation("explain-fixture-unreadable");
15861
16758
  return {
15862
16759
  ok: true,
15863
- rule
16760
+ rule,
16761
+ ...degraded()
15864
16762
  };
15865
16763
  }
15866
16764
  const normalizedPath = fixturePath.replaceAll("\\", "/");
@@ -15870,9 +16768,11 @@ function explainRule(ruleId, fixturesRoot) {
15870
16768
  else try {
15871
16769
  ast = isAzurePipelineFixture(normalizedPath) ? parseAzurePipeline(text) : parseWorkflow(text);
15872
16770
  } catch {
16771
+ recordDegradation("explain-workflow-parse-failed");
15873
16772
  return {
15874
16773
  ok: true,
15875
- rule
16774
+ rule,
16775
+ ...degraded()
15876
16776
  };
15877
16777
  }
15878
16778
  }
@@ -15889,9 +16789,11 @@ function explainRule(ruleId, fixturesRoot) {
15889
16789
  codeText
15890
16790
  });
15891
16791
  } catch {
16792
+ recordDegradation("explain-rule-crash");
15892
16793
  return {
15893
16794
  ok: true,
15894
- rule
16795
+ rule,
16796
+ ...degraded()
15895
16797
  };
15896
16798
  }
15897
16799
  const example = findings[0];
@@ -15908,16 +16810,6 @@ function explainRule(ruleId, fixturesRoot) {
15908
16810
  };
15909
16811
  }
15910
16812
  //#endregion
15911
- //#region src/engine/finding-identity.ts
15912
- /**
15913
- * Correlation identity for before/after comparison: ruleId + file +
15914
- * message, deliberately EXCLUDING line — a source edit that shifts a
15915
- * finding still correlates. Shared by baseline, impact, and resolution.
15916
- */
15917
- function findingFingerprint(f) {
15918
- return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
15919
- }
15920
- //#endregion
15921
16813
  //#region src/engine/resolution.ts
15922
16814
  /** Correlation identity (v1-compatible): ruleId\0file\0message. */
15923
16815
  const fingerprint$1 = findingFingerprint;
@@ -15938,6 +16830,11 @@ function resolve$1(input) {
15938
16830
  cause: "crash",
15939
16831
  comparedAgainst
15940
16832
  };
16833
+ if (current.analysisStatus.withheldRuleIds?.includes(entry.ruleId) === true) return {
16834
+ status: "INCONCLUSIVE",
16835
+ cause: "rule-withheld",
16836
+ comparedAgainst
16837
+ };
15941
16838
  if (input.skippedFiles?.has(entry.file)) return {
15942
16839
  status: "INCONCLUSIVE",
15943
16840
  cause: "skipped",
@@ -16067,13 +16964,13 @@ function diffAgainstBaseline(result, baseline) {
16067
16964
  //#endregion
16068
16965
  //#region src/commands/verify.ts
16069
16966
  /**
16070
- * `mjolnir verify` — the agent-loop digest (product-gap-remediation
16967
+ * `mjolnir ci verify` — the agent-loop digest (product-gap-remediation
16071
16968
  * master plan P7, plan 1788853205786 — flag agent, decision 6).
16072
16969
  *
16073
- * An agent fixes findings, then runs `mjolnir verify` against the
16074
- * committed baseline (established once with `mjolnir baseline`). The
16970
+ * An agent fixes findings, then runs `mjolnir ci verify` against the
16971
+ * committed baseline (established once from a `mjolnir scan --json` run). The
16075
16972
  * digest answers exactly the loop's questions, derived from the SAME
16076
- * §15 comparison machinery as `mjolnir diff` (no second truth):
16973
+ * §15 comparison machinery as `mjolnir scan --scope changed` (no second truth):
16077
16974
  *
16078
16975
  * - RESOLVED — baseline findings no longer present, each with its
16079
16976
  * lifecycle resolution (only VERIFIED-RESOLVED is a fix claim);
@@ -16349,7 +17246,7 @@ const MCP_TOOLS = [
16349
17246
  },
16350
17247
  {
16351
17248
  name: "verify",
16352
- description: "Agent-loop digest (1:1 with `mjolnir verify`): scan the target and diff against the committed baseline. Returns resolved (per §15 lifecycle), new, unchanged (grouped by ruleId + location), and the score delta. Read-only; the same transport guardrails as every other tool.",
17249
+ description: "Agent-loop digest (1:1 with `mjolnir ci verify`): scan the target and diff against the committed baseline. Returns resolved (per §15 lifecycle), new, unchanged (grouped by ruleId + location), and the score delta. Read-only; the same transport guardrails as every other tool.",
16353
17250
  inputSchema: {
16354
17251
  type: "object",
16355
17252
  properties: { path: {
@@ -16361,7 +17258,7 @@ const MCP_TOOLS = [
16361
17258
  },
16362
17259
  {
16363
17260
  name: "forensics",
16364
- description: "Runtime-evidence report (1:1 with the forensics engine behind `mjolnir pw-report`): ingest a run report (Playwright JSON, JUnit XML, Jest/Vitest JSON, or a Playwright trace artifact) and return the ForensicsReport — per-test verdicts, retries, TRUE-FLAKE, durations. Hostile reports degrade to zero records (never a fabricated clean run). Read-only.",
17261
+ description: "Runtime-evidence report (1:1 with the forensics engine behind `mjolnir explain --playwright`): ingest a run report (Playwright JSON, JUnit XML, Jest/Vitest JSON, or a Playwright trace artifact) and return the ForensicsReport — per-test verdicts, retries, TRUE-FLAKE, durations. Hostile reports degrade to zero records (never a fabricated clean run). Read-only.",
16365
17262
  inputSchema: {
16366
17263
  type: "object",
16367
17264
  properties: { path: {
@@ -16385,7 +17282,7 @@ const MCP_TOOLS = [
16385
17282
  },
16386
17283
  {
16387
17284
  name: "pw-report",
16388
- description: "Playwright run summary (1:1 with `mjolnir pw-report`): ingest a run report and return both the ForensicsReport and the rendered run summary. Read-only.",
17285
+ description: "Playwright run summary (1:1 with `mjolnir explain --playwright`): ingest a run report and return both the ForensicsReport and the rendered run summary. Read-only.",
16389
17286
  inputSchema: {
16390
17287
  type: "object",
16391
17288
  properties: { path: {
@@ -16578,7 +17475,7 @@ async function handleToolCall(call) {
16578
17475
  id: call.id,
16579
17476
  result: {
16580
17477
  hasBaseline: false,
16581
- note: "no committed baseline at .mjolnir/baseline.json — establish the before-state with `mjolnir baseline` first"
17478
+ note: "no committed baseline at .mjolnir/baseline.json — establish the before-state with `mjolnir <target> --save-baseline`, then re-run this tool (or `mjolnir ci verify`) after the fix"
16582
17479
  }
16583
17480
  };
16584
17481
  const result = await serializeScan(() => runScan({
@@ -16652,15 +17549,11 @@ async function handleToolCall(call) {
16652
17549
  id: call.id,
16653
17550
  error: {
16654
17551
  code: MCP_ERRORS.INTERNAL,
16655
- message: errorMessage$1(err)
17552
+ message: errorMessage(err)
16656
17553
  }
16657
17554
  };
16658
17555
  }
16659
17556
  }
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
17557
  /** JSON-RPC request shape validation (strict — §21). */
16665
17558
  function validateRequest(msg) {
16666
17559
  if (typeof msg !== "object" || msg === null) return {
@@ -16763,14 +17656,6 @@ async function handleMcpMessage(msg) {
16763
17656
  };
16764
17657
  }
16765
17658
  //#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
17659
  //#region src/mcp/transport.ts
16775
17660
  /**
16776
17661
  * MCP stdio transport loop (blueprint §21 — the framing around the