mjolnir-qa 5.0.0 → 6.0.1

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.
@@ -2,16 +2,17 @@ import { createRequire } from "node:module";
2
2
  import { createInterface } from "node:readline";
3
3
  import { Buffer as Buffer$1 } from "node:buffer";
4
4
  import { createHash, randomBytes } from "node:crypto";
5
- import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
5
+ import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, 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";
11
12
  import { Language, Parser } from "web-tree-sitter";
12
13
  import { parse } from "yaml";
13
14
  import { inflateRawSync } from "node:zlib";
14
- //#region src/types.ts
15
+ // src/types.ts
15
16
  /** Severity ladder. Order matters for sorting and gating. */
16
17
  const SEVERITY_ORDER = [
17
18
  "error",
@@ -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",
@@ -81,8 +99,7 @@ const DEDUCTIONS = {
81
99
  warning: 3,
82
100
  info: 1
83
101
  };
84
- //#endregion
85
- //#region src/engine/completion.ts
102
+ // src/engine/completion.ts
86
103
  function deriveCompletion(input) {
87
104
  const truncationReasons = [...new Set(input.truncationReasons)].sort();
88
105
  const degradations = [...input.degradations ?? []].sort((a, b) => a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0);
@@ -100,25 +117,31 @@ function deriveCompletion(input) {
100
117
  if (input.runtimeIncomplete) reasons.add("runtime-incomplete");
101
118
  if (input.identityIncomplete) reasons.add("identity-incomplete");
102
119
  for (const reason of truncationReasons) reasons.add(`truncated:${reason}`);
120
+ if ((input.rulesWithheld ?? 0) > 0) reasons.add(`coverage:quarantine:${input.rulesWithheld}`);
103
121
  for (const entry of degradations) reasons.add(`degraded:${entry.reason}:${entry.count}`);
104
122
  const discoveryPartial = input.discoveryTruncated || input.scopeIgnored > 0 || input.scopeUnrecognized > 0 || input.scopeDegraded !== void 0;
105
123
  const rulesPartial = input.rulesPartial || input.rulesCrashed > 0 || input.parseFailed > 0;
124
+ const partial = discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0 || degradations.length > 0;
125
+ const rulesWithheld = input.rulesWithheld ?? 0;
106
126
  return {
107
- partial: discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0 || degradations.length > 0,
127
+ partial,
128
+ coverageState: rulesWithheld > 0 ? "PARTIAL" : "COMPLETE",
108
129
  analysisStatus: {
109
130
  discovery: discoveryPartial ? "partial" : "complete",
110
131
  rules: rulesPartial ? "partial" : "complete",
111
132
  skippedFiles: input.skippedFiles,
112
133
  rulesCrashed: input.rulesCrashed,
113
134
  parseFallbacks: input.parseFallbacks ?? 0,
135
+ rulesApplied: input.rulesApplied ?? 0,
136
+ rulesWithheld,
137
+ ...input.withheldRuleIds !== void 0 && input.withheldRuleIds.length > 0 ? { withheldRuleIds: [...input.withheldRuleIds].sort() } : {},
114
138
  ...truncationReasons.length > 0 ? { truncationReasons } : {},
115
139
  ...degradations.length > 0 ? { degradations } : {},
116
140
  reasons: [...reasons].sort()
117
141
  }
118
142
  };
119
143
  }
120
- //#endregion
121
- //#region src/engine/degradation-ledger.ts
144
+ // src/engine/degradation-ledger.ts
122
145
  /**
123
146
  * Append-only. Records are never edited and never removed by a recording
124
147
  * site; a reader takes a window, and the window is defined by index, so
@@ -154,8 +177,7 @@ function summarizeDegradations(window) {
154
177
  count
155
178
  })).sort((a, b) => a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0);
156
179
  }
157
- //#endregion
158
- //#region src/engine/runtime-corroboration.ts
180
+ // src/engine/runtime-corroboration.ts
159
181
  /**
160
182
  * Stamp runtime corroboration + trust levels onto findings (mutates in
161
183
  * place, the same contract as stampEvidenceLevels). Returns the number
@@ -237,8 +259,7 @@ function deriveTrustLevel(finding, corroboration) {
237
259
  if (corroboration.level === "test") return "L4";
238
260
  return "L3";
239
261
  }
240
- //#endregion
241
- //#region src/rules/measured-fp.generated.ts
262
+ // src/rules/measured-fp.generated.ts
242
263
  const MEASURED_FP = {
243
264
  "QA-CI-001": {
244
265
  fpRate: .105,
@@ -375,10 +396,10 @@ const MEASURED_FP = {
375
396
  },
376
397
  "QA-JV-101": {
377
398
  fpRate: 0,
378
- n: 23,
399
+ n: 35,
379
400
  detectorRevision: 1,
380
401
  ciLow: 0,
381
- ciHigh: .1431
402
+ ciHigh: .0989
382
403
  },
383
404
  "QA-JV-102": {
384
405
  fpRate: .261,
@@ -494,10 +515,10 @@ const MEASURED_FP = {
494
515
  },
495
516
  "QA-PW-117": {
496
517
  fpRate: 0,
497
- n: 24,
518
+ n: 35,
498
519
  detectorRevision: 1,
499
520
  ciLow: 0,
500
- ciHigh: .138
521
+ ciHigh: .0989
501
522
  },
502
523
  "QA-PW-121": {
503
524
  fpRate: 0,
@@ -584,25 +605,18 @@ const MEASURED_FP = {
584
605
  ciHigh: .2425
585
606
  },
586
607
  "QA-PY-002": {
587
- fpRate: .043,
588
- n: 23,
608
+ fpRate: .12,
609
+ n: 25,
589
610
  detectorRevision: 1,
590
- ciLow: .0077,
591
- ciHigh: .2099
611
+ ciLow: .0417,
612
+ ciHigh: .2996
592
613
  },
593
614
  "QA-PY-003": {
594
- fpRate: .467,
595
- n: 30,
615
+ fpRate: .515,
616
+ n: 33,
596
617
  detectorRevision: 4,
597
- ciLow: .3023,
598
- ciHigh: .6386
599
- },
600
- "QA-PY-004": {
601
- fpRate: .533,
602
- n: 30,
603
- detectorRevision: 3,
604
- ciLow: .3614,
605
- ciHigh: .6977
618
+ ciLow: .3522,
619
+ ciHigh: .675
606
620
  },
607
621
  "QA-PY-005": {
608
622
  fpRate: .13,
@@ -759,8 +773,7 @@ const MEASURED_FP = {
759
773
  ciHigh: .4343
760
774
  }
761
775
  };
762
- //#endregion
763
- //#region src/engine/trust-summary.ts
776
+ // src/engine/trust-summary.ts
764
777
  /**
765
778
  * Trust level of the summary — the BEST trust level any finding in the
766
779
  * scan reached. Static-only findings settle at L2 (deriveTrustLevel's
@@ -884,8 +897,58 @@ function buildTrustSummary(result, declarationsByFile) {
884
897
  ceilingReasons: reasons
885
898
  };
886
899
  }
887
- //#endregion
888
- //#region src/engine/run-identity.ts
900
+ // src/governance/run-mode.ts
901
+ /**
902
+ * Which mode a run is in, and how the other one is reached.
903
+ *
904
+ * ADR 0012: `local-only` is the default and hosted mode is a declared state.
905
+ * This module is the enforcement point, and its shape is the decision:
906
+ *
907
+ * - `resolveRunMode` takes its inputs as an ARGUMENT, never from
908
+ * `process.env`. Nothing here can read the environment, so a stray
909
+ * `MJOLNIR_MODE` in a shell, in CI, or in a parent's environment cannot
910
+ * change what a run does. The first version of the test passed an `env`
911
+ * bag and asserted it was ignored — and that only means something because
912
+ * this function is structurally unable to consult one.
913
+ * - `isHostedModeReachable` is the single predicate every entry point has to
914
+ * pass through, and it answers the same question separately from
915
+ * `resolveRunMode`. Separating them is what stops a caller satisfying the
916
+ * easy one: the first says whether the mode MAY be entered, the second
917
+ * says which mode this run IS.
918
+ * - an unrecognised declaration THROWS. A typo in a build flag that fell back
919
+ * to `local-only` would mean a deployment believed it was hosted and was
920
+ * not — the same class of failure as a gate that reports success for
921
+ * something it did not run.
922
+ *
923
+ * Hosted mode is not implemented. This is the contract it will have to
924
+ * satisfy, and it is deliberately the smallest thing that can be wrong in only
925
+ * one direction.
926
+ */
927
+ /**
928
+ * The build-time declaration that opts a build into hosted mode.
929
+ *
930
+ * A build flag rather than an environment variable, because a build flag is
931
+ * something a release engineer sets deliberately and reviews in a diff, and
932
+ * because an environment variable is set by whatever CI last happened to run
933
+ * with.
934
+ */
935
+ const HOSTED_MODE_FLAG = "mjolnir-mode";
936
+ /**
937
+ * The mode this run is in.
938
+ *
939
+ * Throws on a declaration it does not recognise. A silent fallback here is the
940
+ * failure ADR 0012 exists to prevent: a build that asked for hosted mode,
941
+ * mistyped the flag, and ran local-only would report local-only artefacts into
942
+ * a pipeline that believed otherwise.
943
+ */
944
+ function resolveRunMode(input) {
945
+ const declared = input.flags[HOSTED_MODE_FLAG];
946
+ if (declared === void 0 || declared === false) return "local-only";
947
+ if (declared === true) throw new Error(`${HOSTED_MODE_FLAG} was given without a value. Use "${HOSTED_MODE_FLAG}=hosted" or omit it.`);
948
+ if (declared === "local-only" || declared === "hosted") return declared;
949
+ 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.`);
950
+ }
951
+ // src/engine/run-identity.ts
889
952
  /**
890
953
  * Run Identity (product-gap master plan §7, plan 1789009691197 R4c) —
891
954
  * the deterministic binding that anchors every trust claim to its
@@ -925,6 +988,7 @@ function buildRunIdentity(input) {
925
988
  if (input.commit !== void 0) verdictInputs.commit = input.commit;
926
989
  if (input.tree !== void 0) verdictInputs.tree = input.tree;
927
990
  if (input.lockfile !== void 0) verdictInputs.lockfile = input.lockfile;
991
+ if (input.runMode !== void 0) verdictInputs.runMode = input.runMode;
928
992
  if (input.candidate !== void 0) verdictInputs.candidate = {
929
993
  manifestId: input.candidate.manifestId,
930
994
  state: input.candidate.state,
@@ -957,6 +1021,7 @@ function buildRunIdentity(input) {
957
1021
  rulesDigest,
958
1022
  configFingerprint,
959
1023
  engineVersion: input.engineVersion,
1024
+ ...input.runMode !== void 0 ? { runMode: input.runMode } : {},
960
1025
  boundLinks,
961
1026
  ...input.trustModelVersion !== void 0 ? { trustModelVersion: input.trustModelVersion } : {},
962
1027
  ...input.scoringModelVersion !== void 0 ? { scoringModelVersion: input.scoringModelVersion } : {},
@@ -1012,8 +1077,7 @@ function buildEvidenceGraph(parts) {
1012
1077
  } } : {}
1013
1078
  };
1014
1079
  }
1015
- //#endregion
1016
- //#region src/engine/file-executor.ts
1080
+ // src/engine/file-executor.ts
1017
1081
  function cacheHitOutcome(job) {
1018
1082
  return {
1019
1083
  path: job.path,
@@ -1104,8 +1168,7 @@ function createExecutor(name, concurrency, runOne) {
1104
1168
  execute: runOne
1105
1169
  };
1106
1170
  }
1107
- //#endregion
1108
- //#region src/scope/git-resolve.ts
1171
+ // src/scope/git-resolve.ts
1109
1172
  /**
1110
1173
  * Absolute-path git resolution (audit S1).
1111
1174
  *
@@ -1180,8 +1243,7 @@ function runGit(root, args) {
1180
1243
  return null;
1181
1244
  }
1182
1245
  }
1183
- //#endregion
1184
- //#region src/lib/safe-json.ts
1246
+ // src/lib/safe-json.ts
1185
1247
  /**
1186
1248
  * Reusable safe-JSON parse-and-validate helper.
1187
1249
  *
@@ -1218,8 +1280,7 @@ function isRecord(v) {
1218
1280
  return false;
1219
1281
  }
1220
1282
  }
1221
- //#endregion
1222
- //#region src/engine/candidate-binding.ts
1283
+ // src/engine/candidate-binding.ts
1223
1284
  /**
1224
1285
  * Candidate binding (plan V5-010).
1225
1286
  *
@@ -1352,32 +1413,69 @@ function readCandidateBinding(root, manifestPath = CANDIDATE_MANIFEST_PATH) {
1352
1413
  return null;
1353
1414
  }
1354
1415
  }
1355
- //#endregion
1356
- //#region src/engine/version.ts
1416
+ // src/engine/version.ts
1357
1417
  /**
1358
1418
  * Engine version, as a leaf module: `runIdentity` (R4c) needs the engine
1359
1419
  * version inside scan-pipeline WITHOUT importing cli.ts (a cycle — cli
1360
1420
  * imports the pipeline). The literal follows the same discipline as
1361
1421
  * CLI_VERSION and SARIF's driver.version: kept in sync on release by
1362
- * scripts/sync-sarif-version.cjs and guarded by the version-consistency
1422
+ * `npm run version:surface:sync` (this file is an identity surface) and
1423
+ * guarded by the version-consistency
1363
1424
  * spec. cli.ts re-exports this as CLI_VERSION.
1364
1425
  */
1365
- const ENGINE_VERSION = "5.0.0";
1366
- //#endregion
1367
- //#region src/engine/contract-versions.ts
1426
+ const ENGINE_VERSION = "6.0.1";
1427
+ resolveBuildId();
1368
1428
  /**
1369
- * Versioned contract registry (VERSION-001).
1429
+ * The decision, with the process boundary already behind `runGit`.
1370
1430
  *
1371
- * Single source of truth for every version constant the engine exposes
1372
- * to consumers (run identity, evidence graph, machine contract).
1373
- * Constants are `as const` — literal types flow into the identity
1374
- * surface without widening.
1375
- */
1431
+ * Separated so every branch is reachable from a test: a runner that throws and
1432
+ * a runner that answers are both things that happen, and both are what this
1433
+ * function exists to survive. What it must never do is invent an answer.
1434
+ */
1435
+ function buildIdFrom(runGit) {
1436
+ const sha = runGit([
1437
+ "rev-parse",
1438
+ "--short=12",
1439
+ "HEAD"
1440
+ ]);
1441
+ if (!sha) return void 0;
1442
+ const status = runGit(["status", "--porcelain"]);
1443
+ if (status === void 0) return `${sha}-dirty`;
1444
+ return status ? `${sha}-dirty` : sha;
1445
+ }
1446
+ function resolveBuildId() {
1447
+ const git = resolveGitPath();
1448
+ if (!git) return void 0;
1449
+ const runGit = (args) => {
1450
+ try {
1451
+ return execFileSync(git, args, {
1452
+ encoding: "utf8",
1453
+ timeout: 2e3,
1454
+ stdio: [
1455
+ "ignore",
1456
+ "pipe",
1457
+ "ignore"
1458
+ ]
1459
+ }).trim();
1460
+ } catch {
1461
+ return;
1462
+ }
1463
+ };
1464
+ return buildIdFrom(runGit);
1465
+ }
1466
+ // src/engine/contract-versions.ts
1376
1467
  const TRUST_MODEL_VERSION = "1.0.0";
1377
- const SCORING_MODEL_VERSION = "1.0.0";
1468
+ /**
1469
+ * 2.0.0 (ADR 0014): the core tier carries an evidence FLOOR. A core rule's
1470
+ * finding is now stamped to at least E1, which changes `deductionFor` for
1471
+ * every core finding from 0 (E0) to `floor(base/2)`. The E0→E1→E2 ladder
1472
+ * itself is unchanged; what changed is which rung a core finding is allowed
1473
+ * to sit on, and therefore what it costs. Major, per the `semver + ADR
1474
+ * required` policy on the entry below.
1475
+ */
1476
+ const SCORING_MODEL_VERSION = "2.0.0";
1378
1477
  const FRAMEWORK_SUPPORT_MATRIX_VERSION = "1.0.0";
1379
- //#endregion
1380
- //#region src/discovery/evidence-discovery.ts
1478
+ // src/discovery/evidence-discovery.ts
1381
1479
  /**
1382
1480
  * Zero-config evidence discovery (Mega MVP Master Plan v3.1 §26 WI-11).
1383
1481
  *
@@ -1421,6 +1519,11 @@ const EVIDENCE_CONVENTIONS = [
1421
1519
  convention: "junit-file",
1422
1520
  kind: "file",
1423
1521
  names: ["junit-report.xml", "junit.xml"]
1522
+ },
1523
+ {
1524
+ convention: "dotnet-trx",
1525
+ kind: "dir",
1526
+ names: ["TestResults"]
1424
1527
  }
1425
1528
  ];
1426
1529
  const SEARCH_DEPTH = 2;
@@ -1464,8 +1567,7 @@ function discoverEvidenceCandidates(scanRoot) {
1464
1567
  }
1465
1568
  return out.sort((a, b) => a.depth - b.depth || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
1466
1569
  }
1467
- //#endregion
1468
- //#region src/discovery/workspace.ts
1570
+ // src/discovery/workspace.ts
1469
1571
  /**
1470
1572
  * Repository discovery (Sprint-Plan W1-03).
1471
1573
  * Finds project root, parses package.json, detects npm/yarn/pnpm workspaces.
@@ -1512,8 +1614,7 @@ function basename$1(p) {
1512
1614
  const parts = p.split(/[\\/]/);
1513
1615
  return parts[parts.length - 1];
1514
1616
  }
1515
- //#endregion
1516
- //#region src/lib/fs-bounded.ts
1617
+ // src/lib/fs-bounded.ts
1517
1618
  function closeQuiet(fd) {
1518
1619
  try {
1519
1620
  closeSync(fd);
@@ -1565,8 +1666,7 @@ function readFileBounded(path, maxBytes) {
1565
1666
  if (fd !== void 0) closeQuiet(fd);
1566
1667
  }
1567
1668
  }
1568
- //#endregion
1569
- //#region src/config/config-schema.ts
1669
+ // src/config/config-schema.ts
1570
1670
  const VALID_GATES = /* @__PURE__ */ new Set([
1571
1671
  "advisory",
1572
1672
  "error",
@@ -1636,8 +1736,7 @@ function validateConfigSchema(config) {
1636
1736
  errors
1637
1737
  };
1638
1738
  }
1639
- //#endregion
1640
- //#region src/config/config.ts
1739
+ // src/config/config.ts
1641
1740
  /**
1642
1741
  * Config file support (Sprint-Plan W7, Product-MVP §27 + GAP-F).
1643
1742
  * Zero-config preserved: config presence never changes detection
@@ -1771,8 +1870,107 @@ function applySeverityOverrides(findings, cfg) {
1771
1870
  if (override && SEVERITY_ORDER.includes(override)) f.severity = override;
1772
1871
  }
1773
1872
  }
1774
- //#endregion
1775
- //#region src/discovery/ignores.ts
1873
+ // src/lib/compare.ts
1874
+ /**
1875
+ * Order that does not depend on who is asking.
1876
+ *
1877
+ * `String.prototype.localeCompare` with no locale argument resolves against
1878
+ * the PROCESS's default locale — the ICU collation chosen by `LANG`,
1879
+ * `LC_ALL` or the OS. So a sort written as
1880
+ * `[...xs].sort((a, b) => a.localeCompare(b))` produces a different order on
1881
+ * a developer's `de_DE` machine than on an en-US CI runner. For a display
1882
+ * list that is a nuisance; for anything that reaches a hash, a cache key, a
1883
+ * digest or a machine contract it is a correctness bug, because the same
1884
+ * repository then produces two different fingerprints.
1885
+ *
1886
+ * Two comparators, one per kind of site:
1887
+ *
1888
+ * compareCodePoints — pure UTF-16 code-unit order. Identical everywhere,
1889
+ * independent of ICU version, and already what `Array.prototype.sort`
1890
+ * defaults to. Use for every value that reaches a fingerprint, a digest,
1891
+ * a cache key, or a contract, and for any ordering with no human-facing
1892
+ * reason to be pretty.
1893
+ *
1894
+ * compareLocalized(locale) — the same collator, PINNED to a named locale
1895
+ * instead of inheriting the environment, for a surface that genuinely
1896
+ * wants readable order (a table of rule titles, a Mermaid legend). Pinned
1897
+ * rather than removed because a reader-facing list of "Test / Logic /
1898
+ * Mock" should not be ordered by code unit.
1899
+ *
1900
+ * A bare `localeCompare` with no second argument in `src/` is a defect and
1901
+ * `tests/contract/deterministic-ordering.spec.ts` fails the build on it.
1902
+ */
1903
+ /** Code-unit order. Locale-free, ICU-free, and the Array.sort default. */
1904
+ function compareCodePoints(a, b) {
1905
+ if (a === b) return 0;
1906
+ return a < b ? -1 : 1;
1907
+ }
1908
+ /**
1909
+ * A readable-order comparator pinned to `locale`.
1910
+ *
1911
+ * The pin is the point: `a.localeCompare(b)` reads the environment, and the
1912
+ * environment is a machine setting, not a property of the code. Callers that
1913
+ * want stable output ask for a named locale and get the same answer on every
1914
+ * machine that ships the same ICU data.
1915
+ */
1916
+ function compareLocalized(locale) {
1917
+ return (a, b) => a.localeCompare(b, locale);
1918
+ }
1919
+ // src/lib/glob.ts
1920
+ /**
1921
+ * The shared segment compiler: a glob to a regex BODY, with no anchors.
1922
+ *
1923
+ * Module-private because the anchoring is a caller decision and the DIALECT is
1924
+ * not: the repository needs a full-path match and a bare-name-at-any-depth
1925
+ * match, and duplicating the segment walk to get the second one is exactly how
1926
+ * two compilers came to disagree. Callers pick an anchoring; the dialect is
1927
+ * decided here and nowhere else.
1928
+ *
1929
+ * `?`, `[`, `]`, `!` and every other regex metacharacter in the literal parts
1930
+ * are escaped, so nothing a user writes in a config or ledger can reach the
1931
+ * regex engine as syntax. `*` is the only metacharacter.
1932
+ */
1933
+ function globBody(glob) {
1934
+ const segments = glob.replaceAll("\\", "/").split("/");
1935
+ let re = "";
1936
+ for (const [i, segment] of segments.entries()) {
1937
+ const last = i === segments.length - 1;
1938
+ if (segment === "**") {
1939
+ re += last ? "(?:[^/]+/)*[^/]+" : "(?:[^/]+/)*";
1940
+ continue;
1941
+ }
1942
+ re += segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", "[^/]*");
1943
+ if (!last) re += "/";
1944
+ }
1945
+ return re;
1946
+ }
1947
+ /** A glob as a regex anchored to the WHOLE path. */
1948
+ function anchoredGlobRegExp(glob) {
1949
+ return new RegExp(`^${globBody(glob)}$`);
1950
+ }
1951
+ /**
1952
+ * A glob as a regex matching at ANY DEPTH — a bare name.
1953
+ *
1954
+ * `node_modules` becomes "a file or directory of this name anywhere", which is
1955
+ * gitignore semantics: every path inside the directory contains the segment, so
1956
+ * a directory match carries to its contents.
1957
+ */
1958
+ function anyDepthGlobRegExp(glob) {
1959
+ return new RegExp(`(?:^|/)${globBody(glob)}(?:/|$)`);
1960
+ }
1961
+ /**
1962
+ * Whether `path` matches `glob`, in the dialect documented above.
1963
+ *
1964
+ * Both sides are normalized to forward slashes first. Finding paths are already
1965
+ * normalized by the walker, but a suppression `files` pattern written on
1966
+ * Windows ("e2e\\x.spec.ts") compiled to a literal-backslash regex that could
1967
+ * never match any finding — the suppression silently never applied, which is
1968
+ * the defect that made the whole dialect worth unifying.
1969
+ */
1970
+ function pathMatchesGlob(path, glob) {
1971
+ return anchoredGlobRegExp(glob).test(path.replaceAll("\\", "/"));
1972
+ }
1973
+ // src/discovery/ignores.ts
1776
1974
  /**
1777
1975
  * Ignore resolution (Product-MVP.txt R1 — Tier-1 launch blocker).
1778
1976
  *
@@ -1906,32 +2104,12 @@ function compilePattern(raw) {
1906
2104
  if (pattern.trim() === "") return null;
1907
2105
  if (pattern.length > LIMITS$1.maxPatternLength) return null;
1908
2106
  if ((pattern.match(/[*?]/g) ?? []).length > LIMITS$1.maxPatternWildcards) return null;
1909
- const re = pattern.includes("/") ? new RegExp(`^${globBody(pattern)}$`) : new RegExp(`(?:^|/)${globBody(pattern)}(?:/|$)`);
2107
+ const re = pattern.includes("/") ? anchoredGlobRegExp(pattern) : anyDepthGlobRegExp(pattern);
1910
2108
  return {
1911
2109
  negated,
1912
2110
  re
1913
2111
  };
1914
2112
  }
1915
- /** The minimal glob dialect as a regex body without anchors. */
1916
- function globBody(glob) {
1917
- let re = "";
1918
- for (let i = 0; i < glob.length; i++) {
1919
- const c = glob[i];
1920
- if (c === "*") {
1921
- if (glob[i + 1] === "*") {
1922
- if (glob[i + 2] === "/") {
1923
- re += "(?:[^/]*/)*";
1924
- i += 2;
1925
- } else {
1926
- re += ".*";
1927
- i += 1;
1928
- }
1929
- } else re += "[^/]*";
1930
- } else if (c === "?") re += "[^/]";
1931
- else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
1932
- }
1933
- return re;
1934
- }
1935
2113
  createMatcherFromPatterns([]);
1936
2114
  /**
1937
2115
  * Check if a directory is a lint-fixture shape (Phase 2 auto-detection).
@@ -1948,8 +2126,7 @@ function isLintFixtureDir(absoluteDir) {
1948
2126
  return false;
1949
2127
  }
1950
2128
  }
1951
- //#endregion
1952
- //#region src/discovery/shared-walk.ts
2129
+ // src/discovery/shared-walk.ts
1953
2130
  /**
1954
2131
  * Shared directory walker (audit P-2).
1955
2132
  *
@@ -2012,8 +2189,7 @@ function sharedWalk(options) {
2012
2189
  };
2013
2190
  walk(options.root);
2014
2191
  }
2015
- //#endregion
2016
- //#region src/discovery/frameworks.ts
2192
+ // src/discovery/frameworks.ts
2017
2193
  /**
2018
2194
  * Framework detection via config resolution (Sprint-Plan W3-01, R8).
2019
2195
  *
@@ -2096,8 +2272,7 @@ function detectFrameworks(ws) {
2096
2272
  unknown: false
2097
2273
  };
2098
2274
  }
2099
- //#endregion
2100
- //#region src/engine/ts-ast.ts
2275
+ // src/engine/ts-ast.ts
2101
2276
  /**
2102
2277
  * AST precision layer (Upgrade-Plan-v3 Phase 3).
2103
2278
  *
@@ -2108,16 +2283,29 @@ function detectFrameworks(ws) {
2108
2283
  * Migration discipline (per the plan): rule-by-rule, golden lock must stay
2109
2284
  * byte-identical — a score shift is a regression, not an improvement.
2110
2285
  */
2111
- let project = null;
2112
2286
  /**
2113
- * The shared ts-morph project.
2287
+ * One shared Project PER SCAN keeps memory bounded while reusing the compiler's
2288
+ * program across files.
2114
2289
  *
2115
- * Exported so an adapter's `dispose()` can evict the file it parsed: ts-morph
2116
- * caches by file path, so without eviction a long scan holds every parsed
2117
- * SourceFile until the process exits (plan V5-021).
2118
- */
2119
- function getProject() {
2120
- if (!project) project = new Project({
2290
+ * "Per scan" has to mean per scan. The variable lived at module scope, so it
2291
+ * was one Project per PROCESS, and two scans running concurrently shared it —
2292
+ * `parseTsFile` calls `replaceWithText` on a cached SourceFile, so scan B
2293
+ * could overwrite the text scan A was mid-rule on. ts-morph's in-memory file
2294
+ * system and language service are not reentrant, and the observable result was
2295
+ * three of four concurrent scans reporting `rulesCrashed: 4` with
2296
+ * `rules: "partial"` — different machine-contract digests from the same tree,
2297
+ * which is the one thing this product claims cannot happen.
2298
+ *
2299
+ * An AsyncLocalStorage scope is the smallest change that makes the comment
2300
+ * true: each scan's async context carries its own Project, memory stays bounded
2301
+ * per scan exactly as intended, and the context is collected when the scan's
2302
+ * promise chain is. The loose project below remains for callers outside a scan
2303
+ * (tests, one-off parses), where there is nothing to collide with.
2304
+ */
2305
+ const scanProjects = new AsyncLocalStorage();
2306
+ let looseProject = null;
2307
+ function createProject() {
2308
+ return new Project({
2121
2309
  useInMemoryFileSystem: true,
2122
2310
  skipAddingFilesFromTsConfig: true,
2123
2311
  compilerOptions: {
@@ -2126,7 +2314,28 @@ function getProject() {
2126
2314
  noEmit: true
2127
2315
  }
2128
2316
  });
2129
- return project;
2317
+ }
2318
+ /**
2319
+ * Run `fn` with its own ts-morph Project.
2320
+ *
2321
+ * Async context propagates across `await`, so the Project stays the scan's own
2322
+ * for the whole scan without threading an argument through every adapter.
2323
+ */
2324
+ function runWithScanProject(fn) {
2325
+ return scanProjects.run(createProject(), fn);
2326
+ }
2327
+ /**
2328
+ * The ts-morph Project for the current scan.
2329
+ *
2330
+ * Exported so an adapter's `dispose()` can evict the file it parsed: ts-morph
2331
+ * caches by file path, so without eviction a long scan holds every parsed
2332
+ * SourceFile until the process exits (plan V5-021).
2333
+ */
2334
+ function getProject() {
2335
+ const scoped = scanProjects.getStore();
2336
+ if (scoped !== void 0) return scoped;
2337
+ looseProject ??= createProject();
2338
+ return looseProject;
2130
2339
  }
2131
2340
  /**
2132
2341
  * Parse (or fetch from cache) the ts-morph SourceFile for a scanned file.
@@ -2251,12 +2460,18 @@ function getCodeOnlyText(file) {
2251
2460
  return file.text;
2252
2461
  }
2253
2462
  }
2254
- /** Release the shared ts-morph Project between scans to free memory. */
2463
+ /**
2464
+ * Release the loose ts-morph Project used outside a scan.
2465
+ *
2466
+ * Deliberately does NOT call `scanProjects.disable()`. Scan-scoped Projects are
2467
+ * released by garbage collection when the scan's async context ends, and
2468
+ * `disable()` would tear the store out from under a scan still running — the
2469
+ * exact corruption this scoping exists to remove.
2470
+ */
2255
2471
  function resetTsMorphProject() {
2256
- project = null;
2472
+ looseProject = null;
2257
2473
  }
2258
- //#endregion
2259
- //#region src/engine/code-text.ts
2474
+ // src/engine/code-text.ts
2260
2475
  /**
2261
2476
  * Code-only text masking (Phase 1 — Tempering Plan).
2262
2477
  *
@@ -2616,8 +2831,7 @@ function getCodeTextForCounting(text, language) {
2616
2831
  function blankRange(chars, start, end) {
2617
2832
  for (let i = start; i < end && i < chars.length; i++) if (chars[i] !== "\n" && chars[i] !== "\r") chars[i] = " ";
2618
2833
  }
2619
- //#endregion
2620
- //#region src/engine/adapter.ts
2834
+ // src/engine/adapter.ts
2621
2835
  /**
2622
2836
  * The §15.1 framework filter, shared by every language adapter: a rule
2623
2837
  * that declares `frameworks` runs on a file only when the file carries
@@ -2631,8 +2845,7 @@ function frameworkFilterApplies(rule, file) {
2631
2845
  if (tags === void 0 || tags.length === 0) return true;
2632
2846
  return rule.frameworks.some((f) => tags.includes(f));
2633
2847
  }
2634
- //#endregion
2635
- //#region src/engine/shared-run-rules.ts
2848
+ // src/engine/shared-run-rules.ts
2636
2849
  /**
2637
2850
  * Run ONE rule with crash isolation, and return what it produced.
2638
2851
  *
@@ -2669,8 +2882,7 @@ function runRuleIsolated(rule, file, onCrash) {
2669
2882
  return null;
2670
2883
  }
2671
2884
  }
2672
- //#endregion
2673
- //#region src/adapters/typescript.ts
2885
+ // src/adapters/typescript.ts
2674
2886
  /**
2675
2887
  * TypeScript/JavaScript adapter (R1).
2676
2888
  * Wraps the proven regex-based engine behind the LanguageAdapter
@@ -2707,10 +2919,18 @@ function runRuleIsolated(rule, file, onCrash) {
2707
2919
  * four lines below: PW_CONFIG_RE already accepted `cts`. The config regex knew
2708
2920
  * about the Node-native extensions; the test-file regex did not.
2709
2921
  *
2710
- * `discovery/scan-adapters.ts:isUnrecognizedSourceCandidate` already counted
2711
- * these paths as uncovered surface, so the scope accounting had been reporting
2712
- * the hole the whole time — it was being read as a known limitation rather than
2713
- * as a defect.
2922
+ * `discovery/scan-adapters.ts:isUnrecognizedSourceCandidate` counts these
2923
+ * paths as uncovered surface, so the scope accounting reports a hole if one
2924
+ * exists.
2925
+ *
2926
+ * CORRECTION: the comment this replaced said the scope accounting "had been
2927
+ * reporting the hole the whole time". It had not — that checker's regex
2928
+ * already matched `mts`/`cts`, and only `extensions` was unread. What WAS
2929
+ * true, and is what this entry now fixes, is that a field every adapter
2930
+ * declares and no code consulted is a claim the interface makes to anyone who
2931
+ * reads it. `isUnrecognizedSourceCandidate` now builds its extension set from
2932
+ * this list rather than from a second hard-coded one, which is what makes the
2933
+ * two halves of the same fact stop being able to drift.
2714
2934
  */
2715
2935
  const TEST_FILE_RE = /\.(?:test|spec)\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$|\.cy\.(?:js|jsx|ts|tsx)$/;
2716
2936
  /**
@@ -2782,7 +3002,9 @@ const typescriptAdapter = {
2782
3002
  ".js",
2783
3003
  ".jsx",
2784
3004
  ".mjs",
2785
- ".cjs"
3005
+ ".cjs",
3006
+ ".mts",
3007
+ ".cts"
2786
3008
  ],
2787
3009
  testFileGlobs: [
2788
3010
  "*.test.{js,jsx,ts,tsx,mjs,cjs}",
@@ -2913,8 +3135,7 @@ function loadWorkspaceShim(root) {
2913
3135
  return null;
2914
3136
  }
2915
3137
  }
2916
- //#endregion
2917
- //#region src/engine/tree-sitter-ast.ts
3138
+ // src/engine/tree-sitter-ast.ts
2918
3139
  /**
2919
3140
  * Tree-sitter WASM AST layer for Java and C# — Sprint 8 Task 36
2920
3141
  * (Master-Stabilization-Plan.md).
@@ -2993,7 +3214,11 @@ async function withParseSlot(fn) {
2993
3214
  }
2994
3215
  function grammarPath(fileName) {
2995
3216
  const here = dirname(fileURLToPath(import.meta.url));
2996
- const candidates = [join(here, "..", "..", "node_modules", "tree-sitter-wasms", "out", fileName), join(here, "..", "node_modules", "tree-sitter-wasms", "out", fileName)];
3217
+ const candidates = [
3218
+ join(here, "..", "..", "node_modules", "tree-sitter-wasms", "out", fileName),
3219
+ join(here, "..", "node_modules", "tree-sitter-wasms", "out", fileName),
3220
+ join(here, "..", "..", "..", "node_modules", "tree-sitter-wasms", "out", fileName)
3221
+ ];
2997
3222
  return candidates.find((p) => existsSync(p)) ?? candidates[0];
2998
3223
  }
2999
3224
  async function ensureParserInitialized() {
@@ -3073,6 +3298,7 @@ async function parseJavaAst(text) {
3073
3298
  return (await getJavaParser()).parse(text) ?? void 0;
3074
3299
  });
3075
3300
  } catch {
3301
+ recordDegradation("ast-grammar-unavailable");
3076
3302
  return;
3077
3303
  }
3078
3304
  }
@@ -3086,6 +3312,7 @@ async function parseCSharpAst(text) {
3086
3312
  return (await getCSharpParser()).parse(text) ?? void 0;
3087
3313
  });
3088
3314
  } catch {
3315
+ recordDegradation("ast-grammar-unavailable");
3089
3316
  return;
3090
3317
  }
3091
3318
  }
@@ -3101,6 +3328,7 @@ async function parsePythonAst(text) {
3101
3328
  return (await getPythonParser()).parse(text) ?? void 0;
3102
3329
  });
3103
3330
  } catch {
3331
+ recordDegradation("ast-grammar-unavailable");
3104
3332
  return;
3105
3333
  }
3106
3334
  }
@@ -3126,8 +3354,7 @@ async function releaseTreeSitterResources() {
3126
3354
  } catch {}
3127
3355
  }
3128
3356
  }
3129
- //#endregion
3130
- //#region src/adapters/python.ts
3357
+ // src/adapters/python.ts
3131
3358
  /**
3132
3359
  * Python/pytest adapter (Upgrade-Plan-v2 R2).
3133
3360
  *
@@ -3199,7 +3426,9 @@ const pythonAdapter = {
3199
3426
  if (existsSync(pyproject)) try {
3200
3427
  const text = readText$2(pyproject);
3201
3428
  if (/\[tool\.pytest/i.test(text)) frameworks.add("pytest");
3202
- } catch {}
3429
+ } catch {
3430
+ recordDegradation("python-manifest-unreadable");
3431
+ }
3203
3432
  for (const reqFile of [
3204
3433
  "requirements.txt",
3205
3434
  "requirements-dev.txt",
@@ -3211,7 +3440,9 @@ const pythonAdapter = {
3211
3440
  const text = readText$2(p);
3212
3441
  if (/^selenium\b/im.test(text)) frameworks.add("selenium");
3213
3442
  if (/^playwright\b/im.test(text)) frameworks.add("playwright");
3214
- } catch {}
3443
+ } catch {
3444
+ recordDegradation("python-manifest-unreadable");
3445
+ }
3215
3446
  }
3216
3447
  if (frameworks.size === 0) return {
3217
3448
  frameworks: [],
@@ -3281,8 +3512,7 @@ const pythonAdapter = {
3281
3512
  function readText$2(p) {
3282
3513
  return readFileSync(p, "utf8");
3283
3514
  }
3284
- //#endregion
3285
- //#region src/adapters/java.ts
3515
+ // src/adapters/java.ts
3286
3516
  /**
3287
3517
  * Java/JUnit adapter (Upgrade-Plan-v3 Phase 4).
3288
3518
  *
@@ -3448,8 +3678,7 @@ function javaFileTags(file) {
3448
3678
  function readText$1(path) {
3449
3679
  return readFileSync(path, "utf8");
3450
3680
  }
3451
- //#endregion
3452
- //#region src/adapters/csharp.ts
3681
+ // src/adapters/csharp.ts
3453
3682
  /**
3454
3683
  * C#/.NET adapter (Upgrade-Plan-v3 Phase 5).
3455
3684
  *
@@ -3605,8 +3834,7 @@ function csharpFileTags(file) {
3605
3834
  function readText(path) {
3606
3835
  return readFileSync(path, "utf8");
3607
3836
  }
3608
- //#endregion
3609
- //#region src/discovery/yaml-guards.ts
3837
+ // src/discovery/yaml-guards.ts
3610
3838
  /**
3611
3839
  * Shared hostile-YAML guards (product-gap master plan P3 common law —
3612
3840
  * `azure-pipelines.yml` detection rides the same YAML machinery as the
@@ -3675,8 +3903,7 @@ function enforceDepthCap(value, max, depthMessage = GENERIC_LABELS.depthMessage)
3675
3903
  });
3676
3904
  }
3677
3905
  }
3678
- //#endregion
3679
- //#region src/discovery/workflow-parser.ts
3906
+ // src/discovery/workflow-parser.ts
3680
3907
  /**
3681
3908
  * Safe GitHub Actions workflow parser (Phase 0.3, W4-03 debt).
3682
3909
  *
@@ -3722,8 +3949,7 @@ function parseWorkflow(text) {
3722
3949
  }
3723
3950
  return { jobs };
3724
3951
  }
3725
- //#endregion
3726
- //#region src/adapters/github-actions.ts
3952
+ // src/adapters/github-actions.ts
3727
3953
  /**
3728
3954
  * GitHub Actions adapter (R1).
3729
3955
  * Owns workflow discovery and safe-YAML parsing; CI rules consume the
@@ -3808,8 +4034,7 @@ const githubActionsAdapter = {
3808
4034
  };
3809
4035
  var WorkflowParseSkipped = class extends Error {};
3810
4036
  const LIMITS_MAX_BYTES$2 = 1048576;
3811
- //#endregion
3812
- //#region src/discovery/azure-pipeline-parser.ts
4037
+ // src/discovery/azure-pipeline-parser.ts
3813
4038
  /**
3814
4039
  * Safe Azure DevOps pipeline parser (product-gap master plan P3b).
3815
4040
  *
@@ -4043,8 +4268,7 @@ function isAzurePipelineFixture(normalizedPath) {
4043
4268
  const base = normalizedPath.replaceAll("\\", "/").split("/").pop() ?? "";
4044
4269
  return AZURE_PIPELINE_FILENAMES_SET.has(base);
4045
4270
  }
4046
- //#endregion
4047
- //#region src/adapters/azure-pipelines.ts
4271
+ // src/adapters/azure-pipelines.ts
4048
4272
  /**
4049
4273
  * Azure DevOps pipeline adapter (product-gap master plan P3b).
4050
4274
  * Owns `azure-pipelines.yml` discovery (repo root, the platform's
@@ -4124,8 +4348,7 @@ const azurePipelinesAdapter = {
4124
4348
  };
4125
4349
  var AzureParseSkipped = class extends Error {};
4126
4350
  const LIMITS_MAX_BYTES$1 = 1048576;
4127
- //#endregion
4128
- //#region src/adapters/jenkins.ts
4351
+ // src/adapters/jenkins.ts
4129
4352
  /**
4130
4353
  * Jenkins adapter (product-gap master plan P3c).
4131
4354
  * The root `Jenkinsfile` is a TEXT-target file kind: declarative and
@@ -4199,8 +4422,7 @@ const jenkinsAdapter = {
4199
4422
  }
4200
4423
  };
4201
4424
  const LIMITS_MAX_BYTES = 1048576;
4202
- //#endregion
4203
- //#region src/discovery/scan-adapters.ts
4425
+ // src/discovery/scan-adapters.ts
4204
4426
  /**
4205
4427
  * The canonical adapter registry (audits H-6/H-9).
4206
4428
  *
@@ -4293,6 +4515,22 @@ function isScopeRelevantIgnored(path) {
4293
4515
  if (!/\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml|min\.js)$/i.test(name)) return false;
4294
4516
  return isKnownTestFile(path);
4295
4517
  }
4518
+ /**
4519
+ * Every source extension any shipped adapter claims.
4520
+ *
4521
+ * This is where `LanguageAdapter.extensions` is READ. The field was declared by
4522
+ * every adapter, listed a set of extensions, and consulted by nothing in
4523
+ * `src/` — so a support claim that no code checked was still a claim the
4524
+ * interface made to every reader.
4525
+ *
4526
+ * Derived rather than hand-listed so the two halves cannot drift. A test
4527
+ * already compared the old hand-written regex against this set and found them
4528
+ * equivalent, which is the honest result: the refactor removes a second
4529
+ * source of truth rather than fixing a wrong answer. The `.mts`/`.cts` gap
4530
+ * was real but lived in `extensions`, not here, and is now closed there.
4531
+ */
4532
+ const ADAPTER_CLAIMED_EXTENSIONS = new Set(SCAN_ADAPTERS.flatMap((adapter) => adapter.extensions).map((ext) => ext.replace(/^\./, "").toLowerCase()));
4533
+ /** Whether ignoring this file would have removed it from the scanned surface. */
4296
4534
  function isUnrecognizedSourceCandidate(path) {
4297
4535
  const normalized = path.replaceAll("\\", "/");
4298
4536
  const name = normalized.split("/").pop() ?? normalized;
@@ -4307,8 +4545,9 @@ function isUnrecognizedSourceCandidate(path) {
4307
4545
  "tsconfig",
4308
4546
  "jsconfig"
4309
4547
  ].some((prefix) => stem === prefix || stem.startsWith(`${prefix}.`))) return false;
4310
- const testLike = /(?:^|\/)__tests__\//i.test(normalized) || /\.(?:spec|test)\.[cm]?[jt]sx?$/i.test(name);
4311
- return /\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml)$/i.test(name) && testLike;
4548
+ if (!(/(?:^|\/)__tests__\//i.test(normalized) || /\.(?:spec|test)\.[cm]?[jt]sx?$/i.test(name))) return false;
4549
+ const extension = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
4550
+ return ADAPTER_CLAIMED_EXTENSIONS.has(extension);
4312
4551
  }
4313
4552
  /** Whether ANY shipped adapter would discover this path as a test file. */
4314
4553
  function isKnownTestFile(path) {
@@ -4320,8 +4559,7 @@ function isInsideSkippedDir(root, absPath, skipNames) {
4320
4559
  return relative(root, absPath).replaceAll("\\", "/").split("/").slice(0, -1).some((seg) => skipNames.includes(seg));
4321
4560
  }
4322
4561
  typescriptAdapter.testFileGlobs, pythonAdapter.testFileGlobs, javaAdapter.testFileGlobs, csharpAdapter.testFileGlobs, githubActionsAdapter.testFileGlobs, azurePipelinesAdapter.testFileGlobs, jenkinsAdapter.testFileGlobs;
4323
- //#endregion
4324
- //#region src/scope/changed.ts
4562
+ // src/scope/changed.ts
4325
4563
  /**
4326
4564
  * Changed-scope engine (Sprint-Plan W6, Product-MVP §9 `--scope changed`).
4327
4565
  *
@@ -4564,8 +4802,7 @@ function computeStagedFiles(root) {
4564
4802
  if (raw === null) return null;
4565
4803
  return raw.split("\0").filter((s) => s.length > 0);
4566
4804
  }
4567
- //#endregion
4568
- //#region src/rules/rule.ts
4805
+ // src/rules/rule.ts
4569
4806
  /**
4570
4807
  * Invoke an `astQuery` hook with the mandatory-fallback contract: no
4571
4808
  * hook or no AST on the context resolves to `undefined` (→ regex
@@ -4578,8 +4815,7 @@ function tryAstQuery(hook, ctx) {
4578
4815
  function defineRule(rule) {
4579
4816
  return rule;
4580
4817
  }
4581
- //#endregion
4582
- //#region src/rules/shared/positions.ts
4818
+ // src/rules/shared/positions.ts
4583
4819
  /**
4584
4820
  * Shared position helpers (Phase 6 — Tempering Plan).
4585
4821
  * One lineAt, one colAt, one matchBrace. The other 79 copies are dead.
@@ -4624,8 +4860,7 @@ function lineAt(text, index) {
4624
4860
  function colAt(text, index) {
4625
4861
  return index - (index <= 0 ? -1 : text.lastIndexOf("\n", index - 1));
4626
4862
  }
4627
- //#endregion
4628
- //#region src/rules/test/qa-test-001-focused-test.ts
4863
+ // src/rules/test/qa-test-001-focused-test.ts
4629
4864
  /**
4630
4865
  * QA-TEST-001 — Focused test committed (`fit`/`fdescribe`/`.only`).
4631
4866
  * Severity: error · Confidence: high · deterministic-defect
@@ -4691,8 +4926,7 @@ const focusedTestCommitted = defineRule({
4691
4926
  return findings;
4692
4927
  }
4693
4928
  });
4694
- //#endregion
4695
- //#region src/rules/test/qa-test-002-skipped-test.ts
4929
+ // src/rules/test/qa-test-002-skipped-test.ts
4696
4930
  /**
4697
4931
  * QA-TEST-002 — Skipped test (`it.skip`, `xit`, `test.skip`, `describe.skip`).
4698
4932
  * Severity: warning · Confidence: high · deterministic-defect
@@ -4748,8 +4982,7 @@ const skippedTest = defineRule({
4748
4982
  return findings;
4749
4983
  }
4750
4984
  });
4751
- //#endregion
4752
- //#region src/rules/shared/masking.ts
4985
+ // src/rules/shared/masking.ts
4753
4986
  /** True when the offset was blanked in codeText (inside a string or comment). */
4754
4987
  function isMasked(ctx, index) {
4755
4988
  const mask = ctx.codeText;
@@ -4810,8 +5043,7 @@ function isInsideEmbeddedCode(ctx, index) {
4810
5043
  const hasCallSyntax = /\w\s*\(/.test(inner);
4811
5044
  return hasNestedQuote && hasCallSyntax;
4812
5045
  }
4813
- //#endregion
4814
- //#region src/rules/test/qa-test-003-no-assertions.ts
5046
+ // src/rules/test/qa-test-003-no-assertions.ts
4815
5047
  /**
4816
5048
  * QA-TEST-003 — Test with no assertions.
4817
5049
  * Severity: error · Confidence: high · deterministic-defect
@@ -4889,8 +5121,7 @@ function matchBrace$2(text, open) {
4889
5121
  }
4890
5122
  return -1;
4891
5123
  }
4892
- //#endregion
4893
- //#region src/rules/test/qa-test-004-hard-sleep.ts
5124
+ // src/rules/test/qa-test-004-hard-sleep.ts
4894
5125
  /**
4895
5126
  * QA-TEST-004 — Hard sleep (`sleep()`, `waitForTimeout()`, `setTimeout` as wait).
4896
5127
  * Severity: warning · Confidence: high · deterministic-defect
@@ -4955,8 +5186,7 @@ const hardSleep = defineRule({
4955
5186
  return findings;
4956
5187
  }
4957
5188
  });
4958
- //#endregion
4959
- //#region src/rules/test/qa-test-006-retry-abuse.ts
5189
+ // src/rules/test/qa-test-006-retry-abuse.ts
4960
5190
  /**
4961
5191
  * QA-TEST-006 — Retry abuse in test config.
4962
5192
  * Severity: warning · Confidence: high · deterministic-defect
@@ -5022,8 +5252,7 @@ const retryAbuse = defineRule({
5022
5252
  return findings;
5023
5253
  }
5024
5254
  });
5025
- //#endregion
5026
- //#region src/rules/test/qa-test-010-empty-body.ts
5255
+ // src/rules/test/qa-test-010-empty-body.ts
5027
5256
  /**
5028
5257
  * QA-TEST-010 — Empty test body.
5029
5258
  * Severity: error · Confidence: high · deterministic-defect
@@ -5078,8 +5307,7 @@ const emptyTestBody = defineRule({
5078
5307
  return findings;
5079
5308
  }
5080
5309
  });
5081
- //#endregion
5082
- //#region src/rules/quality/qa-tqual-002-tautological.ts
5310
+ // src/rules/quality/qa-tqual-002-tautological.ts
5083
5311
  /**
5084
5312
  * QA-TQUAL-002 — Tautological assertion.
5085
5313
  * Severity: error · Confidence: high · deterministic-defect
@@ -5156,8 +5384,7 @@ const tautologicalAssertion = defineRule({
5156
5384
  return findings;
5157
5385
  }
5158
5386
  });
5159
- //#endregion
5160
- //#region src/rules/quality/qa-tqual-009-promise-assertion.ts
5387
+ // src/rules/quality/qa-tqual-009-promise-assertion.ts
5161
5388
  /**
5162
5389
  * QA-TQUAL-009 — Unawaited assertion inside a promise chain.
5163
5390
  * Severity: error · Confidence: high · deterministic-defect
@@ -5327,8 +5554,7 @@ function matchParen$2(text, open) {
5327
5554
  }
5328
5555
  return -1;
5329
5556
  }
5330
- //#endregion
5331
- //#region src/rules/quality/qa-tqual-011-commented-out.ts
5557
+ // src/rules/quality/qa-tqual-011-commented-out.ts
5332
5558
  /**
5333
5559
  * QA-TQUAL-011 — Commented-out tests.
5334
5560
  * Severity: warning · Confidence: high · deterministic-defect
@@ -5441,8 +5667,7 @@ function commentRanges(ctx) {
5441
5667
  }
5442
5668
  return ranges;
5443
5669
  }
5444
- //#endregion
5445
- //#region src/rules/playwright/qa-pw-002-unawaited-assertion.ts
5670
+ // src/rules/playwright/qa-pw-002-unawaited-assertion.ts
5446
5671
  /**
5447
5672
  * QA-PW-002 — Missing `await` on a Playwright locator assertion.
5448
5673
  * Severity: error · Confidence: high · deterministic-defect
@@ -5498,7 +5723,7 @@ const ASYNC_PW_MATCHERS = /* @__PURE__ */ new Set([
5498
5723
  ]);
5499
5724
  const unawaitedLocatorAssertion = defineRule({
5500
5725
  id: "QA-PW-002",
5501
- tier: "core",
5726
+ tier: "extended",
5502
5727
  category: "QA-PW",
5503
5728
  title: "Unawaited Playwright assertion",
5504
5729
  severity: "error",
@@ -5587,8 +5812,7 @@ function runRegexFallback(text, path) {
5587
5812
  });
5588
5813
  return findings;
5589
5814
  }
5590
- //#endregion
5591
- //#region src/rules/playwright/qa-pw-003-debug-artifacts.ts
5815
+ // src/rules/playwright/qa-pw-003-debug-artifacts.ts
5592
5816
  /**
5593
5817
  * QA-PW-003 — Committed `page.pause()` or `test.only()` in e2e specs.
5594
5818
  * Severity: error · Confidence: high · deterministic-defect
@@ -5612,7 +5836,7 @@ const committedDebugArtifacts = defineRule({
5612
5836
  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"
5613
5837
  },
5614
5838
  introduced: "0.1.0",
5615
- tier: "core",
5839
+ tier: "extended",
5616
5840
  run(ctx) {
5617
5841
  const text = ctx.codeText ?? ctx.text;
5618
5842
  const findings = [];
@@ -5640,8 +5864,7 @@ const committedDebugArtifacts = defineRule({
5640
5864
  return findings;
5641
5865
  }
5642
5866
  });
5643
- //#endregion
5644
- //#region src/rules/playwright/qa-pw-004-brittle-selectors.ts
5867
+ // src/rules/playwright/qa-pw-004-brittle-selectors.ts
5645
5868
  /**
5646
5869
  * QA-PW-004 — Brittle CSS/XPath selectors vs role-based locators.
5647
5870
  * Severity: warning · Confidence: medium · heuristic-risk
@@ -5733,8 +5956,7 @@ function stripComments(ctx) {
5733
5956
  } catch {}
5734
5957
  return ctx.text.replace(/\/\/[^\n]*/g, (cm) => " ".repeat(cm.length));
5735
5958
  }
5736
- //#endregion
5737
- //#region src/rules/ci/verification-gate.ts
5959
+ // src/rules/ci/verification-gate.ts
5738
5960
  /**
5739
5961
  * Shared "is this shell command a verification gate?" matcher for the QA-CI
5740
5962
  * rules. A gate is a command whose failure MUST fail the job — test runners,
@@ -5774,8 +5996,7 @@ const VERIFICATION_GATE_RE = new RegExp([
5774
5996
  function looksLikeVerificationGate(cmd) {
5775
5997
  return VERIFICATION_GATE_RE.test(cmd);
5776
5998
  }
5777
- //#endregion
5778
- //#region src/rules/ci/azure-gates.ts
5999
+ // src/rules/ci/azure-gates.ts
5779
6000
  /** Discriminator: the Azure parser is the only one emitting `platform`. */
5780
6001
  function isAzurePipelineDoc(ast) {
5781
6002
  return typeof ast === "object" && ast !== null && ast.platform === "azure-pipelines";
@@ -5859,8 +6080,7 @@ function locateAzureJobKey(text, job, keyName) {
5859
6080
  if (m) return lineAt(text, m.index);
5860
6081
  return 1;
5861
6082
  }
5862
- //#endregion
5863
- //#region src/rules/ci/qa-ci-001-continue-on-error.ts
6083
+ // src/rules/ci/qa-ci-001-continue-on-error.ts
5864
6084
  /**
5865
6085
  * QA-CI-001 — `continue-on-error: true` masks a failing job.
5866
6086
  * Severity: error · Confidence: high · deterministic-defect
@@ -6088,8 +6308,7 @@ function describeAzureStep(step) {
6088
6308
  if (step.task) return step.task;
6089
6309
  return "step";
6090
6310
  }
6091
- //#endregion
6092
- //#region src/rules/ci/qa-ci-002-swallowed-exit.ts
6311
+ // src/rules/ci/qa-ci-002-swallowed-exit.ts
6093
6312
  /**
6094
6313
  * QA-CI-002 — `|| true` swallows a command's exit code.
6095
6314
  * Severity: error · Confidence: high · deterministic-defect
@@ -6144,8 +6363,7 @@ const swallowedExitCode = defineRule({
6144
6363
  return findings;
6145
6364
  }
6146
6365
  });
6147
- //#endregion
6148
- //#region src/rules/ci/qa-ci-007-retry-masking.ts
6366
+ // src/rules/ci/qa-ci-007-retry-masking.ts
6149
6367
  /**
6150
6368
  * QA-CI-007 — Retry masking test failures.
6151
6369
  * Severity: warning · Confidence: high · deterministic-defect
@@ -6296,8 +6514,7 @@ function azureArm$1(ctx, doc, findings) {
6296
6514
  });
6297
6515
  }
6298
6516
  }
6299
- //#endregion
6300
- //#region src/rules/ci/qa-ci-005-report-never-generated.ts
6517
+ // src/rules/ci/qa-ci-005-report-never-generated.ts
6301
6518
  /**
6302
6519
  * QA-CI-005 — Report consumed but never generated.
6303
6520
  * Severity: error · Confidence: high · deterministic-defect
@@ -6455,8 +6672,7 @@ function findJobConsumerLine(text, jobName, consumerRe) {
6455
6672
  const anywhere = new RegExp(consumerRe.source, "i").exec(text);
6456
6673
  return anywhere ? lineAt(text, anywhere.index) : 1;
6457
6674
  }
6458
- //#endregion
6459
- //#region src/rules/ci/jenkins-gates.ts
6675
+ // src/rules/ci/jenkins-gates.ts
6460
6676
  /**
6461
6677
  * Jenkinsfile block scanner for the QA-CI Jenkins arms (product-gap
6462
6678
  * master plan P3c). Jenkinsfiles are Groovy text — "no new language
@@ -6702,8 +6918,7 @@ function textIsVerificationGate(text) {
6702
6918
  function lineOfOffset(text, offset) {
6703
6919
  return lineAt(text, offset);
6704
6920
  }
6705
- //#endregion
6706
- //#region src/rules/ci/qa-ci-008-always-success.ts
6921
+ // src/rules/ci/qa-ci-008-always-success.ts
6707
6922
  /**
6708
6923
  * QA-CI-008 — Always-success step at the end of a job.
6709
6924
  * Severity: error · Confidence: high · deterministic-defect
@@ -6877,8 +7092,7 @@ function jenkinsArm$1(ctx, findings) {
6877
7092
  });
6878
7093
  }
6879
7094
  }
6880
- //#endregion
6881
- //#region src/rules/ci/qa-ci-009-exit-code.ts
7095
+ // src/rules/ci/qa-ci-009-exit-code.ts
6882
7096
  /**
6883
7097
  * QA-CI-009 — Test command does not propagate exit code.
6884
7098
  * Severity: error · Confidence: high · deterministic-defect
@@ -7013,8 +7227,7 @@ function jenkinsArm(ctx, findings) {
7013
7227
  });
7014
7228
  }
7015
7229
  }
7016
- //#endregion
7017
- //#region src/rules/ci/qa-ci-010-non-blocking.ts
7230
+ // src/rules/ci/qa-ci-010-non-blocking.ts
7018
7231
  /**
7019
7232
  * QA-CI-010 — Required test job is non-blocking for branch protection.
7020
7233
  * Severity: error · Confidence: medium · deterministic-defect
@@ -7097,8 +7310,7 @@ function findLine(text, re) {
7097
7310
  function escapeRe(s) {
7098
7311
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7099
7312
  }
7100
- //#endregion
7101
- //#region src/rules/ci/qa-ci-013-can-never-fail.ts
7313
+ // src/rules/ci/qa-ci-013-can-never-fail.ts
7102
7314
  /**
7103
7315
  * QA-CI-013 — Verification stage/job conditioned so it can never fail the
7104
7316
  * pipeline (product-gap master plan P3b).
@@ -7202,8 +7414,7 @@ const canNeverFailGate = defineRule({
7202
7414
  function jobHasGate(job) {
7203
7415
  return (job.steps ?? []).some((s) => s !== void 0 && stepIsVerificationGate$1(s));
7204
7416
  }
7205
- //#endregion
7206
- //#region src/rules/ci/qa-ci-014-swallowed-verification.ts
7417
+ // src/rules/ci/qa-ci-014-swallowed-verification.ts
7207
7418
  /**
7208
7419
  * QA-CI-014 — try/catch swallowing a verification-stage failure without
7209
7420
  * rethrow or failure-marking (product-gap master plan P3c).
@@ -7260,8 +7471,7 @@ const swallowedVerificationFailure = defineRule({
7260
7471
  return findings;
7261
7472
  }
7262
7473
  });
7263
- //#endregion
7264
- //#region src/rules/python/qa-py-003-no-assertions.ts
7474
+ // src/rules/python/qa-py-003-no-assertions.ts
7265
7475
  /**
7266
7476
  * QA-PY-003 — Test function with no assertions.
7267
7477
  * Severity: error · Confidence: high · deterministic-defect
@@ -7382,8 +7592,7 @@ function extractBlock$1(text, afterColon) {
7382
7592
  const lineEnd = rest.indexOf("\n", firstIdx);
7383
7593
  return lineEnd === -1 ? rest : rest.slice(0, lineEnd);
7384
7594
  }
7385
- //#endregion
7386
- //#region src/rules/python/qa-py-005-hard-sleep.ts
7595
+ // src/rules/python/qa-py-005-hard-sleep.ts
7387
7596
  /**
7388
7597
  * QA-PY-005 — time.sleep() in tests.
7389
7598
  * Severity: warning · Confidence: high · deterministic-defect
@@ -7430,15 +7639,14 @@ const pyHardSleep = defineRule({
7430
7639
  return findings;
7431
7640
  }
7432
7641
  });
7433
- //#endregion
7434
- //#region src/rules/python/qa-py-002-skipped-test.ts
7642
+ // src/rules/python/qa-py-002-skipped-test.ts
7435
7643
  /**
7436
7644
  * QA-PY-002 — Skipped test (`@pytest.mark.skip`, `xfail` non-strict).
7437
7645
  * Severity: warning · Confidence: high · deterministic-defect
7438
7646
  */
7439
7647
  const pySkippedTest = defineRule({
7440
7648
  id: "QA-PY-002",
7441
- tier: "core",
7649
+ tier: "extended",
7442
7650
  category: "QA-TEST",
7443
7651
  title: "Skipped test",
7444
7652
  severity: "warning",
@@ -7491,8 +7699,7 @@ const pySkippedTest = defineRule({
7491
7699
  return findings;
7492
7700
  }
7493
7701
  });
7494
- //#endregion
7495
- //#region src/rules/python/qa-py-012-tautological.ts
7702
+ // src/rules/python/qa-py-012-tautological.ts
7496
7703
  /**
7497
7704
  * QA-PY-012 — Tautological assertion (`assert True`, `assert x == x`).
7498
7705
  * Severity: error · Confidence: high · deterministic-defect
@@ -7547,8 +7754,7 @@ const pyTautological = defineRule({
7547
7754
  return findings;
7548
7755
  }
7549
7756
  });
7550
- //#endregion
7551
- //#region src/rules/python/qa-py-001-focused-test.ts
7757
+ // src/rules/python/qa-py-001-focused-test.ts
7552
7758
  /**
7553
7759
  * QA-PY-001 — Focused test committed.
7554
7760
  * Severity: error · Confidence: high · deterministic-defect
@@ -7574,7 +7780,7 @@ const pyFocusedTest = defineRule({
7574
7780
  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"
7575
7781
  },
7576
7782
  introduced: "0.3.0",
7577
- tier: "core",
7783
+ tier: "extended",
7578
7784
  suiteInvalidating: true,
7579
7785
  run(ctx) {
7580
7786
  const text = ctx.text;
@@ -7602,8 +7808,7 @@ const pyFocusedTest = defineRule({
7602
7808
  return findings;
7603
7809
  }
7604
7810
  });
7605
- //#endregion
7606
- //#region src/engine/jv-cs-ast.ts
7811
+ // src/engine/jv-cs-ast.ts
7607
7812
  /** Narrow the loose `ast` seam back to a tree-sitter Tree. */
7608
7813
  function getTreeSitterTree(ast) {
7609
7814
  if (!(ast instanceof Object) || !("rootNode" in ast)) return void 0;
@@ -7807,8 +8012,7 @@ function isHelperIdiom(name) {
7807
8012
  }
7808
8013
  return false;
7809
8014
  }
7810
- //#endregion
7811
- //#region src/engine/python-ast.ts
8015
+ // src/engine/python-ast.ts
7812
8016
  function getPythonTree(ast) {
7813
8017
  return getTreeSitterTree(ast);
7814
8018
  }
@@ -7818,26 +8022,65 @@ function walk(node, visit) {
7818
8022
  for (const child of node.children) walk(child, visit);
7819
8023
  }
7820
8024
  /**
8025
+ * The `pytest.raises` call that is a DIRECT context manager of `withNode`.
8026
+ *
8027
+ * Scopes the search to the with's own item list — `with_clause` for the
8028
+ * plain form, and `parenthesized_with_items` for `with (A, B):` — rather
8029
+ * than to the whole subtree. A call found deeper down belongs to an inner
8030
+ * `with_statement`, which the walk reaches in its own right and which owns
8031
+ * the block that actually gates it.
8032
+ */
8033
+ function directRaisesItem(withNode) {
8034
+ let found;
8035
+ const scan = (n) => {
8036
+ if (found) return;
8037
+ if (n.type === "call") {
8038
+ const fn = n.childForFieldName("function");
8039
+ if (fn && /(?:^|\.)raises$/.test(fn.text)) {
8040
+ found = n;
8041
+ return;
8042
+ }
8043
+ }
8044
+ for (const child of n.children) scan(child);
8045
+ };
8046
+ for (const child of withNode.children) if (child.type === "with_clause" || child.type === "parenthesized_with_items") scan(child);
8047
+ return found;
8048
+ }
8049
+ /**
7821
8050
  * Every `with pytest.raises(...)` (optionally `... as exc`) statement in
7822
8051
  * the tree, with the structural facts the rework needs. Tree-sitter-
7823
8052
  * python grammar: a `with_statement` wraps `with_item`s and a `block`;
7824
8053
  * the raises call is a `call` whose function is an attribute chain
7825
8054
  * ending in `.raises`.
8055
+ *
8056
+ * 6.0. The raises call is now required to be a DIRECT context manager of the
8057
+ * `with_statement` being measured. It used to be found by walking the whole
8058
+ * subtree, so a `with pytest.raises(...)` nested inside another `with`
8059
+ * handed back the OUTER block's statement count — and `QA-PY-007`'s
8060
+ * single-statement gate, which exists precisely to suppress that shape,
8061
+ * could never fire for nested code.
8062
+ *
8063
+ * That is not a rare shape. In the pinned `pytest-dev/pytest` corpus, seven
8064
+ * of the eleven adjudicated findings were single-statement raises nested
8065
+ * inside `with saved_fd(1):`, and every one of them was counted as
8066
+ * multi-statement because the surrounding block has a dozen statements. The
8067
+ * rule measured the wrong block, which is why it measured 75% false
8068
+ * positives while its own unit fixtures passed.
8069
+ *
8070
+ * Requiring a direct context manager also fixes the shape by construction:
8071
+ * the outer `with` is examined, has no direct raises item, and is skipped;
8072
+ * the inner one is examined with its own block.
7826
8073
  */
7827
8074
  function pythonWithRaisesBlocks(tree) {
7828
8075
  const out = [];
7829
8076
  const root = tree.rootNode;
7830
8077
  walk(root, (node) => {
7831
8078
  if (node.type !== "with_statement") return;
7832
- let raisesCall;
7833
8079
  let blockNode;
7834
8080
  for (const child of node.children) if (child.type === "block") blockNode = child;
7835
- walk(node, (n) => {
7836
- if (raisesCall || n.type !== "call") return;
7837
- const fn = n.childForFieldName("function");
7838
- if (fn && /(?:^|\.)raises$/.test(fn.text)) raisesCall = n;
7839
- });
7840
- if (!raisesCall || !blockNode) return;
8081
+ if (!blockNode) return;
8082
+ const raisesCall = directRaisesItem(node);
8083
+ if (!raisesCall) return;
7841
8084
  const stmts = blockNode.namedChildren.filter((c) => c.type !== "comment");
7842
8085
  const exceptionText = (raisesCall.childForFieldName("arguments")?.namedChildren[0])?.text ?? "";
7843
8086
  const broadException = BROAD_EXC_RE.test(exceptionText);
@@ -7854,8 +8097,7 @@ function pythonWithRaisesBlocks(tree) {
7854
8097
  });
7855
8098
  return out;
7856
8099
  }
7857
- //#endregion
7858
- //#region src/rules/python/qa-py-007-raises-without-match.ts
8100
+ // src/rules/python/qa-py-007-raises-without-match.ts
7859
8101
  /**
7860
8102
  * QA-PY-007 — pytest.raises without match.
7861
8103
  * Severity: warning · Confidence: medium · heuristic-risk
@@ -7883,7 +8125,7 @@ const pyRaisesWithoutMatch = defineRule({
7883
8125
  },
7884
8126
  introduced: "0.3.0",
7885
8127
  tier: "quarantine",
7886
- detectorRevision: 4,
8128
+ detectorRevision: 5,
7887
8129
  run(ctx) {
7888
8130
  const findings = [];
7889
8131
  if (!ctx.path.endsWith(".py")) return findings;
@@ -7942,16 +8184,17 @@ function matchParen$1(text, open) {
7942
8184
  return -1;
7943
8185
  }
7944
8186
  /**
7945
- * AST arm (detectorRevision 4 — P6 rework): candidates come from the
7946
- * parsed `with pytest.raises(...)` statements. Two structural gates
7947
- * suppress the adjudicated FP core:
7948
- * - the with-block holds a SINGLE statement AND the exception type is
7949
- * specific → the intended line is the only line; the "unrelated bug
7950
- * raises first" diagnosis cannot hold;
7951
- * - the fallback's excinfo-use skip (`as exc` + downstream use) is
7952
- * mirrored on the raw text.
7953
- * Broad root types (Exception/BaseException/…Group) always fire — the
7954
- * type alone pins nothing.
8187
+ * AST arm (detectorRevision 5 — P6 rework, widened on adjudicated evidence):
8188
+ * candidates come from the parsed `with pytest.raises(...)` statements, and
8189
+ * the raises call must be a DIRECT context manager of the with whose block
8190
+ * is measured (see `pythonWithRaisesBlocks` for why).
8191
+ *
8192
+ * The gate: a block with fewer than two statements is exempt. That covers
8193
+ * the adjudicated FP core — pytest-dev/pytest nests its capture tests inside
8194
+ * `with saved_fd(1):`, and every one of those was scored as multi-statement
8195
+ * until the block resolution was fixed. A broad root type (Exception /
8196
+ * BaseException / …Group) still fires on a MULTI-statement block, where an
8197
+ * earlier line can raise it before the intended one.
7955
8198
  */
7956
8199
  function astArm(ctx, tree) {
7957
8200
  const raw = ctx.text;
@@ -7965,7 +8208,7 @@ function astArm(ctx, tree) {
7965
8208
  const tail = raw.slice(block.callEnd, block.callEnd + 2400);
7966
8209
  if (new RegExp(`\\b${name}\\b`).test(tail)) continue;
7967
8210
  }
7968
- if (block.blockStatements < 2 && !block.broadException) continue;
8211
+ if (block.blockStatements < 2) continue;
7969
8212
  findings.push({
7970
8213
  severity: "warning",
7971
8214
  confidence: "medium",
@@ -7981,8 +8224,7 @@ function astArm(ctx, tree) {
7981
8224
  }
7982
8225
  return findings;
7983
8226
  }
7984
- //#endregion
7985
- //#region src/rules/python/qa-py-009-commented-out.ts
8227
+ // src/rules/python/qa-py-009-commented-out.ts
7986
8228
  /**
7987
8229
  * QA-PY-009 — Commented-out tests.
7988
8230
  * Severity: warning · Confidence: high · deterministic-defect
@@ -8008,7 +8250,7 @@ const pyCommentedOutTest = defineRule({
8008
8250
  },
8009
8251
  detectionNotes: "regex heuristic",
8010
8252
  introduced: "0.3.0",
8011
- tier: "core",
8253
+ tier: "extended",
8012
8254
  run(ctx) {
8013
8255
  const findings = [];
8014
8256
  if (!ctx.path.endsWith(".py")) return findings;
@@ -8029,8 +8271,7 @@ const pyCommentedOutTest = defineRule({
8029
8271
  return findings;
8030
8272
  }
8031
8273
  });
8032
- //#endregion
8033
- //#region src/rules/python/qa-py-004-bare-truthiness.ts
8274
+ // src/rules/python/qa-py-004-bare-truthiness.ts
8034
8275
  /**
8035
8276
  * QA-PY-004 — Bare truthiness assert on complex object.
8036
8277
  * Severity: warning · Confidence: medium · heuristic-risk
@@ -8057,13 +8298,18 @@ const pyBareTruthinessAssert = defineRule({
8057
8298
  },
8058
8299
  introduced: "0.3.0",
8059
8300
  tier: "quarantine",
8060
- detectorRevision: 3,
8301
+ detectorRevision: 4,
8061
8302
  run(ctx) {
8062
8303
  const text = ctx.codeText ?? ctx.text;
8063
8304
  const findings = [];
8064
8305
  if (!ctx.path.endsWith(".py")) return findings;
8065
8306
  const re = /^[ \t]*assert\s+([A-Za-z_][\w.]*(?:\([^()]*\))?)[ \t]*$/gm;
8066
- 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*\()/;
8307
+ /**
8308
+ * Constants. `assert False` / `assert None` are deliberate mechanisms —
8309
+ * a test that manufactures an exception to assert on its shape — not a
8310
+ * truthiness check whose wrong value would slip through.
8311
+ */
8312
+ const isConstant = (target) => /^(?:True|False|None|NotImplemented|Ellipsis)$/.test(target) || /^["'\d]/.test(target);
8067
8313
  const isGuardFollowedByRealUse = (text, matchIndex, target) => {
8068
8314
  const lineEnd = text.indexOf("\n", matchIndex);
8069
8315
  if (lineEnd === -1) return false;
@@ -8083,8 +8329,9 @@ const pyBareTruthinessAssert = defineRule({
8083
8329
  let m;
8084
8330
  while ((m = re.exec(text)) !== null) {
8085
8331
  const target = m[1];
8332
+ if (target.includes("(")) continue;
8333
+ if (isConstant(target)) continue;
8086
8334
  if (/^(?:is|has|can|should|was|were)_/.test(target)) continue;
8087
- if (predicateRe.test(target)) continue;
8088
8335
  if (/\.called$/.test(target)) continue;
8089
8336
  if (isGuardFollowedByRealUse(text, m.index, target)) continue;
8090
8337
  findings.push({
@@ -8103,8 +8350,7 @@ const pyBareTruthinessAssert = defineRule({
8103
8350
  return findings;
8104
8351
  }
8105
8352
  });
8106
- //#endregion
8107
- //#region src/rules/python/qa-py-011-mutable-fixture.ts
8353
+ // src/rules/python/qa-py-011-mutable-fixture.ts
8108
8354
  /**
8109
8355
  * QA-PY-011 — Mutable fixture shared across tests.
8110
8356
  * Severity: warning · Confidence: medium · heuristic-risk
@@ -8130,7 +8376,7 @@ const pyMutableFixture = defineRule({
8130
8376
  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"
8131
8377
  },
8132
8378
  introduced: "0.3.0",
8133
- tier: "core",
8379
+ tier: "extended",
8134
8380
  run(ctx) {
8135
8381
  const text = ctx.text;
8136
8382
  const findings = [];
@@ -8155,8 +8401,7 @@ const pyMutableFixture = defineRule({
8155
8401
  return findings;
8156
8402
  }
8157
8403
  });
8158
- //#endregion
8159
- //#region src/rules/playwright/qa-pw-101-wait-for-timeout.ts
8404
+ // src/rules/playwright/qa-pw-101-wait-for-timeout.ts
8160
8405
  /**
8161
8406
  * QA-PW-101 — waitForTimeout() anywhere.
8162
8407
  * Severity: error · Confidence: high · deterministic-defect
@@ -8164,7 +8409,7 @@ const pyMutableFixture = defineRule({
8164
8409
  */
8165
8410
  const pwWaitForTimeout = defineRule({
8166
8411
  id: "QA-PW-101",
8167
- tier: "core",
8412
+ tier: "extended",
8168
8413
  category: "QA-PW",
8169
8414
  title: "Hard sleep via waitForTimeout",
8170
8415
  severity: "error",
@@ -8203,8 +8448,7 @@ const pwWaitForTimeout = defineRule({
8203
8448
  return findings;
8204
8449
  }
8205
8450
  });
8206
- //#endregion
8207
- //#region src/rules/playwright/qa-pw-102-wait-load.ts
8451
+ // src/rules/playwright/qa-pw-102-wait-load.ts
8208
8452
  /**
8209
8453
  * QA-PW-102 — waitForEvent('load') / waitForLoadState('load') instead of
8210
8454
  * web-first assertions.
@@ -8258,8 +8502,7 @@ const pwWaitForLoadEvent = defineRule({
8258
8502
  return findings;
8259
8503
  }
8260
8504
  });
8261
- //#endregion
8262
- //#region src/rules/playwright/qa-pw-113-deep-frames.ts
8505
+ // src/rules/playwright/qa-pw-113-deep-frames.ts
8263
8506
  /**
8264
8507
  * QA-PW-113 — frameLocator chains > 2 deep.
8265
8508
  * Severity: warning · Confidence: medium · heuristic-risk
@@ -8285,7 +8528,7 @@ const pwDeepFrameLocator = defineRule({
8285
8528
  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"
8286
8529
  },
8287
8530
  introduced: "0.3.0",
8288
- tier: "core",
8531
+ tier: "extended",
8289
8532
  run(ctx) {
8290
8533
  const text = ctx.codeText ?? ctx.text;
8291
8534
  const findings = [];
@@ -8309,8 +8552,7 @@ const pwDeepFrameLocator = defineRule({
8309
8552
  function count(fragment) {
8310
8553
  return fragment.match(/frameLocator\s*\(/g).length;
8311
8554
  }
8312
- //#endregion
8313
- //#region src/rules/playwright/qa-pw-117-serial.ts
8555
+ // src/rules/playwright/qa-pw-117-serial.ts
8314
8556
  /**
8315
8557
  * QA-PW-117 — test.describe.serial without justification comment.
8316
8558
  * Severity: warning · Confidence: high · deterministic-defect
@@ -8364,8 +8606,7 @@ const pwSerialNoJustification = defineRule({
8364
8606
  return findings;
8365
8607
  }
8366
8608
  });
8367
- //#endregion
8368
- //#region src/rules/playwright/qa-pw-121-config-retries.ts
8609
+ // src/rules/playwright/qa-pw-121-config-retries.ts
8369
8610
  /**
8370
8611
  * QA-PW-121 — playwright.config: retries >= 3 or workers = 0.
8371
8612
  * Severity: warning · Confidence: high · deterministic-defect
@@ -8394,7 +8635,7 @@ const pwConfigRetryAbuse = defineRule({
8394
8635
  },
8395
8636
  detectionNotes: "regex heuristic",
8396
8637
  introduced: "0.3.0",
8397
- tier: "core",
8638
+ tier: "extended",
8398
8639
  run(ctx) {
8399
8640
  const text = ctx.codeText ?? ctx.text;
8400
8641
  const findings = [];
@@ -8412,7 +8653,7 @@ const pwConfigRetryAbuse = defineRule({
8412
8653
  column: colAt(text, m.index),
8413
8654
  message: `retries: ${m[1]} — a flaky test gets ${m[1]} chances to pass by luck.`,
8414
8655
  why: "High retry counts convert real bugs into intermittent passes and inflate CI minutes; the suite reports green while hiding instability.",
8415
- fix: "Keep retries <= 2 and route repeat offenders into forensics (`mjolnir triage`)."
8656
+ fix: "Keep retries <= 2 and route repeat offenders into the evidence path (`explain --evidence`)."
8416
8657
  });
8417
8658
  const workersRe = /workers\s*:\s*0\b/g;
8418
8659
  while ((m = workersRe.exec(text)) !== null) findings.push({
@@ -8430,8 +8671,7 @@ const pwConfigRetryAbuse = defineRule({
8430
8671
  return findings;
8431
8672
  }
8432
8673
  });
8433
- //#endregion
8434
- //#region src/rules/playwright/qa-pw-122-no-trace.ts
8674
+ // src/rules/playwright/qa-pw-122-no-trace.ts
8435
8675
  /**
8436
8676
  * QA-PW-122 — No trace capture on retry in playwright.config.
8437
8677
  * Severity: warning · Confidence: high · deterministic-defect
@@ -8480,8 +8720,7 @@ const pwNoTraceOnRetry = defineRule({
8480
8720
  return findings;
8481
8721
  }
8482
8722
  });
8483
- //#endregion
8484
- //#region src/rules/playwright/qa-pw-124-project-split.ts
8723
+ // src/rules/playwright/qa-pw-124-project-split.ts
8485
8724
  /**
8486
8725
  * QA-PW-124 — Projects defined but no smoke/regression split.
8487
8726
  * Severity: info · Confidence: high · deterministic-defect
@@ -8543,8 +8782,7 @@ const pwNoProjectSplit = defineRule({
8543
8782
  return findings;
8544
8783
  }
8545
8784
  });
8546
- //#endregion
8547
- //#region src/rules/playwright/qa-pw-104-trial-click.ts
8785
+ // src/rules/playwright/qa-pw-104-trial-click.ts
8548
8786
  /**
8549
8787
  * QA-PW-104 — `{ trial: true }` misuse — check-then-act race.
8550
8788
  * Severity: warning · Confidence: medium · heuristic-risk
@@ -8569,7 +8807,7 @@ const pwTrialMisuse = defineRule({
8569
8807
  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"
8570
8808
  },
8571
8809
  introduced: "0.3.0",
8572
- tier: "core",
8810
+ tier: "extended",
8573
8811
  run(ctx) {
8574
8812
  const text = ctx.codeText ?? ctx.text;
8575
8813
  const findings = [];
@@ -8590,8 +8828,7 @@ const pwTrialMisuse = defineRule({
8590
8828
  return findings;
8591
8829
  }
8592
8830
  });
8593
- //#endregion
8594
- //#region src/rules/playwright/qa-pw-116-storage-state.ts
8831
+ // src/rules/playwright/qa-pw-116-storage-state.ts
8595
8832
  /**
8596
8833
  * QA-PW-116 — storageState reuse without expiry strategy.
8597
8834
  * Severity: warning · Confidence: medium · heuristic-risk
@@ -8637,8 +8874,7 @@ const pwStorageStateNoExpiry = defineRule({
8637
8874
  return findings;
8638
8875
  }
8639
8876
  });
8640
- //#endregion
8641
- //#region src/rules/playwright/qa-pw-125-global-setup.ts
8877
+ // src/rules/playwright/qa-pw-125-global-setup.ts
8642
8878
  /**
8643
8879
  * QA-PW-125 — globalSetup mutating shared external state.
8644
8880
  * Severity: warning · Confidence: medium · heuristic-risk
@@ -8696,8 +8932,7 @@ const pwGlobalSetupSharedState = defineRule({
8696
8932
  return findings;
8697
8933
  }
8698
8934
  });
8699
- //#endregion
8700
- //#region src/rules/playwright/qa-pw-115-shared-page.ts
8935
+ // src/rules/playwright/qa-pw-115-shared-page.ts
8701
8936
  /**
8702
8937
  * QA-PW-115 — Shared `page` across parallel tests without isolation.
8703
8938
  * Severity: warning · Confidence: medium · heuristic-risk
@@ -8745,8 +8980,7 @@ const pwSharedPage = defineRule({
8745
8980
  return findings;
8746
8981
  }
8747
8982
  });
8748
- //#endregion
8749
- //#region src/rules/playwright/qa-pw-140.ts
8983
+ // src/rules/playwright/qa-pw-140.ts
8750
8984
  /**
8751
8985
  * QA-PW-140 — Screenshot without maxDiffPixelRatio.
8752
8986
  *
@@ -8774,7 +9008,7 @@ const qaPw140 = defineRule({
8774
9008
  detail: "the detector matches a closed, exact runner API token on the code-only text; the token identifies the defect uniquely"
8775
9009
  },
8776
9010
  introduced: "0.3.0",
8777
- tier: "core",
9011
+ tier: "extended",
8778
9012
  run(ctx) {
8779
9013
  const text = ctx.codeText ?? ctx.text;
8780
9014
  const findings = [];
@@ -8820,8 +9054,7 @@ function matchParen(text, open) {
8820
9054
  }
8821
9055
  return -1;
8822
9056
  }
8823
- //#endregion
8824
- //#region src/rules/playwright/qa-pw-123-hardcoded-url.ts
9057
+ // src/rules/playwright/qa-pw-123-hardcoded-url.ts
8825
9058
  /**
8826
9059
  * QA-PW-123 — Hardcoded baseURL in specs.
8827
9060
  * Severity: warning · Confidence: high · deterministic-defect
@@ -8869,8 +9102,7 @@ const hardcodedBaseUrl = defineRule({
8869
9102
  return findings;
8870
9103
  }
8871
9104
  });
8872
- //#endregion
8873
- //#region src/rules/quality/qa-env-001-env-coupling.ts
9105
+ // src/rules/quality/qa-env-001-env-coupling.ts
8874
9106
  /**
8875
9107
  * QA-ENV-001 — "Works On My Machine" detector core (Tier 5 #24).
8876
9108
  * Environment coupling in test files: fixed ports, OS paths,
@@ -8937,8 +9169,7 @@ const envCoupling = defineRule({
8937
9169
  return findings;
8938
9170
  }
8939
9171
  });
8940
- //#endregion
8941
- //#region src/rules/playwright/qa-pw-141-retry-no-triage.ts
9172
+ // src/rules/playwright/qa-pw-141-retry-no-triage.ts
8942
9173
  /**
8943
9174
  * QA-PW-141 — playwright.config retries set high enough to mask flakes.
8944
9175
  * Severity: warning · Confidence: high · deterministic-defect
@@ -8989,14 +9220,13 @@ const pwRetryMaskingNoForensics = defineRule({
8989
9220
  column: colAt(text, m.index),
8990
9221
  message: `retries: ${m[1]} with no visible flake-triage loop.`,
8991
9222
  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.",
8992
- fix: "Keep retries <= 2 and feed retry outcomes into `mjolnir forensics`/`triage`, or add a reporter so flaky passes are reviewed."
9223
+ fix: "Keep retries <= 2 and feed retry outcomes into the evidence path (`explain --evidence`), or add a reporter so flaky passes are reviewed."
8993
9224
  });
8994
9225
  }
8995
9226
  return findings;
8996
9227
  }
8997
9228
  });
8998
- //#endregion
8999
- //#region src/rules/playwright/qa-pw-142-blanket-route.ts
9229
+ // src/rules/playwright/qa-pw-142-blanket-route.ts
9000
9230
  /**
9001
9231
  * QA-PW-142 — page.route() blanket-mocking instead of scoped intercepts.
9002
9232
  * Severity: warning · Confidence: medium · heuristic-risk
@@ -9051,8 +9281,7 @@ const pwBlanketRouteMock = defineRule({
9051
9281
  return findings;
9052
9282
  }
9053
9283
  });
9054
- //#endregion
9055
- //#region src/rules/playwright/qa-pw-143-no-artifacts.ts
9284
+ // src/rules/playwright/qa-pw-143-no-artifacts.ts
9056
9285
  /**
9057
9286
  * QA-PW-143 — No screenshot/video artifact capture on failure.
9058
9287
  * Severity: info · Confidence: high · deterministic-defect
@@ -9105,8 +9334,7 @@ const pwNoFailureArtifacts = defineRule({
9105
9334
  return findings;
9106
9335
  }
9107
9336
  });
9108
- //#endregion
9109
- //#region src/rules/playwright/qa-pw-144-single-browser.ts
9337
+ // src/rules/playwright/qa-pw-144-single-browser.ts
9110
9338
  /**
9111
9339
  * QA-PW-144 — Single-browser project matrix (no cross-browser coverage).
9112
9340
  * Severity: info · Confidence: high · deterministic-defect
@@ -9168,8 +9396,7 @@ const pwSingleBrowserMatrix = defineRule({
9168
9396
  return findings;
9169
9397
  }
9170
9398
  });
9171
- //#endregion
9172
- //#region src/rules/playwright/qa-pw-146-css-locator.ts
9399
+ // src/rules/playwright/qa-pw-146-css-locator.ts
9173
9400
  /**
9174
9401
  * QA-PW-146 — CSS/XPath string selector where Playwright's locator
9175
9402
  * standard expects a normalized locator (plan §17.3).
@@ -9248,8 +9475,7 @@ const pwLocatorNormalize = defineRule({
9248
9475
  return findings;
9249
9476
  }
9250
9477
  });
9251
- //#endregion
9252
- //#region src/rules/playwright/qa-pw-147-codegen-artifact.ts
9478
+ // src/rules/playwright/qa-pw-147-codegen-artifact.ts
9253
9479
  /**
9254
9480
  * QA-PW-147 — Playwright codegen artifact left unedited (plan §17.1).
9255
9481
  * Severity: info · Confidence: medium · observation
@@ -9308,8 +9534,7 @@ const pwCodegenArtifact = defineRule({
9308
9534
  return findings;
9309
9535
  }
9310
9536
  });
9311
- //#endregion
9312
- //#region src/rules/python/qa-py-101-sync-async-mix.ts
9537
+ // src/rules/python/qa-py-101-sync-async-mix.ts
9313
9538
  /**
9314
9539
  * QA-PY-101 — Playwright-Python: sync API in async test (or vice versa).
9315
9540
  * Severity: warning · Confidence: high · deterministic-defect
@@ -9358,8 +9583,7 @@ const pyPwSyncAsyncMix = defineRule({
9358
9583
  return findings;
9359
9584
  }
9360
9585
  });
9361
- //#endregion
9362
- //#region src/rules/python/qa-py-103-wait-for-timeout.ts
9586
+ // src/rules/python/qa-py-103-wait-for-timeout.ts
9363
9587
  /**
9364
9588
  * QA-PY-103 — Playwright-Python: wait_for_timeout() as synchronization.
9365
9589
  * Severity: warning · Confidence: high · deterministic-defect
@@ -9384,7 +9608,7 @@ const pyPwWaitForTimeout = defineRule({
9384
9608
  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"
9385
9609
  },
9386
9610
  introduced: "0.3.8",
9387
- tier: "core",
9611
+ tier: "extended",
9388
9612
  run(ctx) {
9389
9613
  const text = ctx.codeText ?? ctx.text;
9390
9614
  const findings = [];
@@ -9406,8 +9630,7 @@ const pyPwWaitForTimeout = defineRule({
9406
9630
  return findings;
9407
9631
  }
9408
9632
  });
9409
- //#endregion
9410
- //#region src/rules/python/qa-py-105-pw-no-assertions.ts
9633
+ // src/rules/python/qa-py-105-pw-no-assertions.ts
9411
9634
  /**
9412
9635
  * QA-PY-105 — Playwright-Python: no assertion in Playwright test.
9413
9636
  * Severity: error · Confidence: high · deterministic-defect
@@ -9487,8 +9710,7 @@ function extractBlock(text, afterColon) {
9487
9710
  const lineEnd = rest.indexOf("\n", firstContent.index);
9488
9711
  return lineEnd === -1 ? rest : rest.slice(0, lineEnd);
9489
9712
  }
9490
- //#endregion
9491
- //#region src/rules/java/qa-jv-101-disabled-test.ts
9713
+ // src/rules/java/qa-jv-101-disabled-test.ts
9492
9714
  /**
9493
9715
  * QA-JV-101 — @Disabled test without justification.
9494
9716
  * Severity: warning · Confidence: high · deterministic-defect
@@ -9542,8 +9764,7 @@ const jvDisabledTest = defineRule({
9542
9764
  return findings;
9543
9765
  }
9544
9766
  });
9545
- //#endregion
9546
- //#region src/rules/java/qa-jv-103-no-assertions.ts
9767
+ // src/rules/java/qa-jv-103-no-assertions.ts
9547
9768
  /**
9548
9769
  * QA-JV-103 — Test method without assertions.
9549
9770
  * Severity: error · Confidence: high · deterministic-defect
@@ -9700,8 +9921,7 @@ function matchBrace$1(text, open) {
9700
9921
  }
9701
9922
  return -1;
9702
9923
  }
9703
- //#endregion
9704
- //#region src/engine/qa-model.ts
9924
+ // src/engine/qa-model.ts
9705
9925
  /**
9706
9926
  * Extract the semantic model for one parsed file. Resolves undefined
9707
9927
  * when the language has no extractor or the parse stage produced no
@@ -10244,8 +10464,7 @@ function extractPythonModel(file) {
10244
10464
  nodes
10245
10465
  };
10246
10466
  }
10247
- //#endregion
10248
- //#region src/rules/java/qa-jv-105-wait-for-timeout.ts
10467
+ // src/rules/java/qa-jv-105-wait-for-timeout.ts
10249
10468
  /**
10250
10469
  * QA-JV-105 — waitForTimeout / hardcoded navigation waits.
10251
10470
  * Severity: warning · Confidence: high · deterministic-defect
@@ -10269,7 +10488,7 @@ function extractPythonModel(file) {
10269
10488
  */
10270
10489
  const jvWaitForTimeout = defineRule({
10271
10490
  id: "QA-JV-105",
10272
- tier: "core",
10491
+ tier: "extended",
10273
10492
  category: "QA-PW",
10274
10493
  title: "waitForTimeout hard sleep",
10275
10494
  severity: "warning",
@@ -10330,15 +10549,14 @@ const jvWaitForTimeout = defineRule({
10330
10549
  return findings;
10331
10550
  }
10332
10551
  });
10333
- //#endregion
10334
- //#region src/rules/csharp/qa-cs-101-skipped-test.ts
10552
+ // src/rules/csharp/qa-cs-101-skipped-test.ts
10335
10553
  /**
10336
10554
  * QA-CS-101 — [Ignore]/[Skip] test without justification.
10337
10555
  * Severity: warning · Confidence: high · deterministic-defect
10338
10556
  */
10339
10557
  const csSkippedTest = defineRule({
10340
10558
  id: "QA-CS-101",
10341
- tier: "core",
10559
+ tier: "extended",
10342
10560
  category: "QA-PW",
10343
10561
  title: "Skipped test",
10344
10562
  severity: "warning",
@@ -10395,8 +10613,7 @@ const csSkippedTest = defineRule({
10395
10613
  return findings;
10396
10614
  }
10397
10615
  });
10398
- //#endregion
10399
- //#region src/rules/csharp/qa-cs-103-no-assertions.ts
10616
+ // src/rules/csharp/qa-cs-103-no-assertions.ts
10400
10617
  /**
10401
10618
  * QA-CS-103 — Test method without assertions.
10402
10619
  * Severity: error · Confidence: high · deterministic-defect
@@ -10430,7 +10647,6 @@ const csSkippedTest = defineRule({
10430
10647
  * Deep-diffing those requires symbol semantics Mjölnir deliberately does
10431
10648
  * not promise for C# (plan §13.4).
10432
10649
  */
10433
- /** Helper/verification idiom: verify/check/assert-prefixed names. */
10434
10650
  const WHY$5 = "Without an assertion the test can only fail by throwing — it cannot detect behavioral regressions.";
10435
10651
  const FIX$4 = "Add an assertion on the expected outcome, or remove the test.";
10436
10652
  function makeFinding(path, position, name) {
@@ -10533,7 +10749,7 @@ const csNoAssertions = defineRule({
10533
10749
  detectionStrategy: "AST",
10534
10750
  detectionNotes: "L2 tree-sitter test-method scoping + structural assertion oracle (Shouldly/Verify/Assert receivers, throwing waits); regex fallback when no parse",
10535
10751
  introduced: "0.3.8",
10536
- tier: "core",
10752
+ tier: "extended",
10537
10753
  detectorRevision: 2,
10538
10754
  astQuery: cs103AstQuery,
10539
10755
  run(ctx) {
@@ -10594,8 +10810,7 @@ function matchBrace(text, open) {
10594
10810
  }
10595
10811
  return -1;
10596
10812
  }
10597
- //#endregion
10598
- //#region src/rules/csharp/qa-cs-105-wait-for-timeout.ts
10813
+ // src/rules/csharp/qa-cs-105-wait-for-timeout.ts
10599
10814
  /**
10600
10815
  * QA-CS-105 — Playwright-.NET: WaitForTimeoutAsync hard sleep.
10601
10816
  * Severity: warning · Confidence: high · deterministic-defect
@@ -10683,8 +10898,7 @@ const csWaitForTimeout = defineRule({
10683
10898
  return findings;
10684
10899
  }
10685
10900
  });
10686
- //#endregion
10687
- //#region src/rules/cypress/qa-cyp-001-cy-wait.ts
10901
+ // src/rules/cypress/qa-cyp-001-cy-wait.ts
10688
10902
  /**
10689
10903
  * QA-CYP-001 — Fixed `cy.wait(n)` (hard-coded wait).
10690
10904
  * Severity: warning · Confidence: high · deterministic-defect
@@ -10763,8 +10977,7 @@ function isCypressFile(ctx) {
10763
10977
  if (/\.cy\.[jt]sx?$/.test(ctx.path)) return true;
10764
10978
  return /\bcy\s*\./.test(ctx.codeText ?? ctx.text);
10765
10979
  }
10766
- //#endregion
10767
- //#region src/rules/cypress/qa-cyp-002-focused-test.ts
10980
+ // src/rules/cypress/qa-cyp-002-focused-test.ts
10768
10981
  /**
10769
10982
  * QA-CYP-002 — Focused test (`.only`).
10770
10983
  * Severity: error · Confidence: high · deterministic-defect
@@ -10827,8 +11040,7 @@ const cypFocusedTest = defineRule({
10827
11040
  return findings;
10828
11041
  }
10829
11042
  });
10830
- //#endregion
10831
- //#region src/rules/cypress/qa-cyp-003-config-security.ts
11043
+ // src/rules/cypress/qa-cyp-003-config-security.ts
10832
11044
  /**
10833
11045
  * QA-CYP-003 — Cypress config disables `chromeWebSecurity`.
10834
11046
  * Severity: error · Confidence: high · deterministic-defect
@@ -10898,8 +11110,7 @@ const cypConfigSecurity = defineRule({
10898
11110
  return findings;
10899
11111
  }
10900
11112
  });
10901
- //#endregion
10902
- //#region src/rules/selenium/qa-se-sleep-lookup.ts
11113
+ // src/rules/selenium/qa-se-sleep-lookup.ts
10903
11114
  /**
10904
11115
  * Selenium sleep-then-interact rules (Verification Trust Evolution Plan
10905
11116
  * §15.3 — "Selenium implicit-wait abuse / missing WebDriverWait",
@@ -11050,8 +11261,7 @@ const sePythonSleepLookup = defineRule({
11050
11261
  return sleepBeforeLookup(ctx, PY_SLEEP_RE, PY_LOOKUP_RE, "QA-SE-003");
11051
11262
  }
11052
11263
  });
11053
- //#endregion
11054
- //#region src/rules/shared/family.ts
11264
+ // src/rules/shared/family.ts
11055
11265
  /**
11056
11266
  * Cross-language pattern rule family generator (Phase 6 — Tempering Plan).
11057
11267
  *
@@ -11117,8 +11327,7 @@ function definePatternFamily(opts) {
11117
11327
  }
11118
11328
  }));
11119
11329
  }
11120
- //#endregion
11121
- //#region src/rules/families/hard-sleep.ts
11330
+ // src/rules/families/hard-sleep.ts
11122
11331
  /**
11123
11332
  * Hard-sleep family (Phase 6 — Tempering Plan).
11124
11333
  * Java: Thread.sleep(), C#: Thread.Sleep() / Task.Delay()
@@ -11259,7 +11468,7 @@ const hardSleepFamily = definePatternFamily({
11259
11468
  "mstest",
11260
11469
  "playwright"
11261
11470
  ],
11262
- tier: "core",
11471
+ tier: "extended",
11263
11472
  detectionStrategy: "AST",
11264
11473
  detectionNotes: "L2 tree-sitter invocation scoping (route/expose/server delegates, infinite blocks, WhenAny races, runner payload fixtures); regex fallback when no parse",
11265
11474
  detectorRevision: 2,
@@ -11269,8 +11478,7 @@ const hardSleepFamily = definePatternFamily({
11269
11478
  fix: "Use `await Assertions.Expect(locator).ToBeVisibleAsync()` or locator.WaitForAsync()."
11270
11479
  }]
11271
11480
  });
11272
- //#endregion
11273
- //#region src/rules/families/network-idle.ts
11481
+ // src/rules/families/network-idle.ts
11274
11482
  /**
11275
11483
  * Network-idle family (Phase 6 — Tempering Plan).
11276
11484
  * All three language variants of waitForLoadState(networkidle).
@@ -11331,8 +11539,7 @@ const networkIdleFamily = definePatternFamily({
11331
11539
  }
11332
11540
  ]
11333
11541
  });
11334
- //#endregion
11335
- //#region src/rules/families/hardcoded-url.ts
11542
+ // src/rules/families/hardcoded-url.ts
11336
11543
  /**
11337
11544
  * Hardcoded-URL family (Phase 6 — Tempering Plan).
11338
11545
  * Detects absolute URLs in navigation/request calls across languages.
@@ -11382,8 +11589,7 @@ const hardcodedUrlFamily = definePatternFamily({
11382
11589
  fix: "Use `--base-url` / `base_url` fixture, or `os.environ[...]`."
11383
11590
  }]
11384
11591
  });
11385
- //#endregion
11386
- //#region src/rules/families/shared-page.ts
11592
+ // src/rules/families/shared-page.ts
11387
11593
  /**
11388
11594
  * Shared-page family (Phase 6 — Tempering Plan).
11389
11595
  * Detects static/module-level Page/Browser instances shared across tests.
@@ -11444,8 +11650,7 @@ const sharedPageFamily = definePatternFamily({
11444
11650
  }
11445
11651
  ]
11446
11652
  });
11447
- //#endregion
11448
- //#region src/rules/families/brittle-selectors.ts
11653
+ // src/rules/families/brittle-selectors.ts
11449
11654
  /**
11450
11655
  * Brittle-selectors family (Phase 6 — Tempering Plan).
11451
11656
  * Detects XPath/structural CSS selectors across Java, C#, Python.
@@ -11557,8 +11762,7 @@ const brittleSelectorsFamily = [
11557
11762
  }
11558
11763
  ], "Prefer role-based locators (`get_by_role(...)`) or data-testid attributes.")
11559
11764
  ];
11560
- //#endregion
11561
- //#region src/rules/families/retry-masking.ts
11765
+ // src/rules/families/retry-masking.ts
11562
11766
  /**
11563
11767
  * Retry-masking family (Phase 6 — Tempering Plan).
11564
11768
  * Detects retry annotations/attributes that mask flaky tests.
@@ -11582,7 +11786,7 @@ const retryMaskingFamily = [defineRule({
11582
11786
  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"
11583
11787
  },
11584
11788
  introduced: "0.4.0",
11585
- tier: "core",
11789
+ tier: "extended",
11586
11790
  run(ctx) {
11587
11791
  const text = ctx.codeText ?? ctx.text;
11588
11792
  const findings = [];
@@ -11678,8 +11882,7 @@ const retryMaskingFamily = [defineRule({
11678
11882
  return findings;
11679
11883
  }
11680
11884
  })];
11681
- //#endregion
11682
- //#region src/rules/index.ts
11885
+ // src/rules/index.ts
11683
11886
  const RULES = [
11684
11887
  focusedTestCommitted,
11685
11888
  skippedTest,
@@ -11760,55 +11963,117 @@ const RULES = [
11760
11963
  function getRule(id) {
11761
11964
  return RULES.find((r) => r.id === id);
11762
11965
  }
11763
- //#endregion
11764
- //#region src/lib/compare.ts
11966
+ // src/lib/wilson.ts
11765
11967
  /**
11766
- * Order that does not depend on who is asking.
11968
+ * 95% Wilson score interval on a proportion (plan §20.2).
11767
11969
  *
11768
- * `String.prototype.localeCompare` with no locale argument resolves against
11769
- * the PROCESS's default locale — the ICU collation chosen by `LANG`,
11770
- * `LC_ALL` or the OS. So a sort written as
11771
- * `[...xs].sort((a, b) => a.localeCompare(b))` produces a different order on
11772
- * a developer's `de_DE` machine than on an en-US CI runner. For a display
11773
- * list that is a nuisance; for anything that reaches a hash, a cache key, a
11774
- * digest or a machine contract it is a correctness bug, because the same
11775
- * repository then produces two different fingerprints.
11970
+ * Lives in `src/` rather than `scripts/` because a measurement's interval is
11971
+ * now part of the ENGINE's tier decision (`src/rules/measurement.ts`), not
11972
+ * only a reporting nicety for the capability matrix. `scripts/lib/wilson.ts`
11973
+ * re-exports this module, so the number a gate prints and the number the
11974
+ * engine tiers on are the same number computed once. Two implementations
11975
+ * would be two sources of truth for the same figure, which is the failure
11976
+ * mode this whole release is about.
11776
11977
  *
11777
- * Two comparators, one per kind of site:
11978
+ * The 4-dp rounding keeps generated artifacts byte-stable (see the capability
11979
+ * matrix generator's note on deterministic output).
11980
+ */
11981
+ const Z95 = 1.959963984540054;
11982
+ /** 95% Wilson score interval on fp/n (fp successes of n trials), 4 dp. */
11983
+ function wilsonInterval(fp, n) {
11984
+ if (n <= 0) return {
11985
+ ciLow: 0,
11986
+ ciHigh: 1
11987
+ };
11988
+ const p = fp / n;
11989
+ const z2 = Z95 * Z95;
11990
+ const denom = 1 + z2 / n;
11991
+ const center = (p + z2 / (2 * n)) / denom;
11992
+ const half = Z95 / denom * Math.sqrt(p * (1 - p) / n + z2 / (4 * n * n));
11993
+ const round4 = (x) => Math.round(x * 1e4) / 1e4;
11994
+ return {
11995
+ ciLow: round4(Math.max(0, center - half)),
11996
+ ciHigh: round4(Math.min(1, center + half))
11997
+ };
11998
+ }
11999
+ /** The rule's declared detector implementation revision (§07). */
12000
+ function declaredDetectorRevision(rule) {
12001
+ return rule.detectorRevision ?? 1;
12002
+ }
12003
+ /**
12004
+ * A measurement exists AND was taken against the detector revision the
12005
+ * rule declares now. A revision mismatch (stale) does NOT count — §07:
12006
+ * stale → provisional → re-measure.
12007
+ */
12008
+ function hasValidMeasurement(rule) {
12009
+ const m = MEASURED_FP[rule.id];
12010
+ return m !== void 0 && m.detectorRevision === declaredDetectorRevision(rule);
12011
+ }
12012
+ /**
12013
+ * The confidence interval of a rule's VALID measurement, or undefined when
12014
+ * it has none. A stale measurement returns undefined rather than its stale
12015
+ * interval: a number derived from an older detector is not evidence about
12016
+ * this one, and returning it would let the straddle test pass on stale data.
11778
12017
  *
11779
- * compareCodePoints — pure UTF-16 code-unit order. Identical everywhere,
11780
- * independent of ICU version, and already what `Array.prototype.sort`
11781
- * defaults to. Use for every value that reaches a fingerprint, a digest,
11782
- * a cache key, or a contract, and for any ordering with no human-facing
11783
- * reason to be pretty.
12018
+ * The FP count is ROUNDED, and it is not an optimization. The shipped
12019
+ * measurement records a rate and a sample count, not an integer count of false
12020
+ * positives, so `fpRate * n` is generally fractional — 10.5% of 19 is 1.995.
12021
+ * `wilsonInterval` rounds internally, so passing the raw product gave this
12022
+ * function and `scripts/core-readiness.ts` two different `ciHigh` values for
12023
+ * the same rule at the same n: the ratchet decided one tier and the
12024
+ * readiness table printed another, both derived, neither reconciled. Every
12025
+ * caller now reconstructs the count the same way.
12026
+ */
12027
+ function measurementInterval(rule) {
12028
+ if (!hasValidMeasurement(rule)) return void 0;
12029
+ const m = MEASURED_FP[rule.id];
12030
+ if (m === void 0) return void 0;
12031
+ return wilsonInterval(Math.round(m.fpRate * m.n), m.n);
12032
+ }
12033
+ /**
12034
+ * The tier the MEASUREMENT alone supports, ignoring any declared tier.
11784
12035
  *
11785
- * compareLocalized(locale) — the same collator, PINNED to a named locale
11786
- * instead of inheriting the environment, for a surface that genuinely
11787
- * wants readable order (a table of rule titles, a Mermaid legend). Pinned
11788
- * rather than removed because a reader-facing list of "Test / Logic /
11789
- * Mock" should not be ordered by code unit.
12036
+ * This is the interval floor that `effectiveTier` falls back to, extracted so
12037
+ * the tightening law can ask the question `effectiveTier` structurally cannot:
12038
+ * a declared tier may only ever hold a rule DOWN, never release it.
11790
12039
  *
11791
- * A bare `localeCompare` with no second argument in `src/` is a defect and
11792
- * `tests/contract/deterministic-ordering.spec.ts` fails the build on it.
12040
+ * `effectiveTier` cannot answer that, because it short-circuits on
12041
+ * `rule.tier` — asking it what a rule's tier "would" be returns the declared
12042
+ * value, and a comparison built on it compares the declaration with itself.
12043
+ * The first B0 dry-run made exactly that mistake and reported zero
12044
+ * mismatches for a comparison that had never run.
12045
+ *
12046
+ * READ THIS BEFORE USING IT AS A FLOOR. Measured over all 79 live rules, this
12047
+ * function returns `extended` for 78 of them and `quarantine` for none: on
12048
+ * this corpus the interval rule is very nearly an empty rule, because
12049
+ * `ciLow >= 50%` is not reached at n = 10..80. A "declared tier may only
12050
+ * tighten" law written against THIS function is decorative — it can only ever
12051
+ * catch a rule declaring `core`, which no rule declares. It was written that
12052
+ * way first and measured at zero teeth.
12053
+ *
12054
+ * The floor with teeth is `defensibleTier`, below.
11793
12055
  */
11794
- /** Code-unit order. Locale-free, ICU-free, and the Array.sort default. */
11795
- function compareCodePoints(a, b) {
11796
- if (a === b) return 0;
11797
- return a < b ? -1 : 1;
12056
+ function measurementTier(rule) {
12057
+ const interval = measurementInterval(rule);
12058
+ if (interval === void 0) return "extended";
12059
+ if (interval.ciHigh <= .1) return "core";
12060
+ if (interval.ciLow >= .5) return "quarantine";
12061
+ return "extended";
11798
12062
  }
11799
12063
  /**
11800
- * A readable-order comparator pinned to `locale`.
12064
+ * The tier a rule effectively ships as: its declared tier, or — for the
12065
+ * omitted-tier case — what its measurement's interval supports (§11.2 Step 2).
11801
12066
  *
11802
- * The pin is the point: `a.localeCompare(b)` reads the environment, and the
11803
- * environment is a machine setting, not a property of the code. Callers that
11804
- * want stable output ask for a named locale and get the same answer on every
11805
- * machine that ships the same ICU data.
12067
+ * The declared tier is a floor that may only tighten, and the measurement is
12068
+ * what it is measured against. See `rule.ts`'s `tier` doc for the B0
12069
+ * measurement behind that, and `defensibleTier` for the floor it is measured
12070
+ * against.
11806
12071
  */
11807
- function compareLocalized(locale) {
11808
- return (a, b) => a.localeCompare(b, locale);
12072
+ function effectiveTier(rule) {
12073
+ if (rule.tier !== void 0) return rule.tier;
12074
+ return measurementTier(rule);
11809
12075
  }
11810
- //#endregion
11811
- //#region src/scorer/scorer.ts
12076
+ // src/scorer/scorer.ts
11812
12077
  /**
11813
12078
  * Transparent scorer (Product-MVP §8).
11814
12079
  * score = 100 − Σ(severity deductions), floor 0.
@@ -11960,9 +12225,9 @@ function countTestDeclarations(text, codeText) {
11960
12225
  const source = codeText ?? text;
11961
12226
  const patterns = [
11962
12227
  /\b(?:it|test)(?:\.\w+)*\s*(?:\(|`)/g,
11963
- /^\s*(?:async\s+)?def\s+test_\w*/gm,
11964
- /^\s*@Test\b/gm,
11965
- /^\s*\[(?:Test|Fact|Theory|TestMethod)\b/gm
12228
+ /^[ \t]*(?:async[ \t]+)?def[ \t]+test_\w*/gm,
12229
+ /^[ \t]*@Test\b/gm,
12230
+ /^[ \t]*\[(?:Test|Fact|Theory|TestMethod)\b/gm
11966
12231
  ];
11967
12232
  let count = 0;
11968
12233
  for (const re of patterns) {
@@ -11971,8 +12236,7 @@ function countTestDeclarations(text, codeText) {
11971
12236
  }
11972
12237
  return count;
11973
12238
  }
11974
- //#endregion
11975
- //#region src/engine/rule-runner.ts
12239
+ // src/engine/rule-runner.ts
11976
12240
  /**
11977
12241
  * Legacy appliesTo → adapter id mapping.
11978
12242
  *
@@ -12011,32 +12275,109 @@ function asUniversal(rule) {
12011
12275
  }
12012
12276
  };
12013
12277
  }
12014
- //#endregion
12015
- //#region src/engine/tier-policy.ts
12278
+ // src/engine/tier-policy.ts
12279
+ /**
12280
+ * Tier policy (audit H-1): the declared tier is authoritative over a
12281
+ * rule's severity and evidence claims.
12282
+ *
12283
+ * Quarantine-tier rules exist because their real-world false-positive
12284
+ * rate is unproven or proven bad (docs/FP-AUDIT.md records several at
12285
+ * 100% measured FP). A rule known to be wrong every single time it
12286
+ * fires must never gate CI or deduct score — regardless of the
12287
+ * severity/evidenceLevel its metadata declares. The pipeline therefore
12288
+ * caps every quarantine finding to severity=info, evidence=E0, which
12289
+ * makes it advisory by construction (isAdvisoryFinding excludes E0).
12290
+ *
12291
+ * The core tier carries the opposite bound (ADR 0014). A CAP says a tier
12292
+ * may not be stronger than this; a FLOOR says it may not be weaker. Core
12293
+ * is earned — it means the measurement cleared the false-positive
12294
+ * ceiling with enough samples to trust the interval — so a core rule
12295
+ * emitting a finding that costs nothing and blocks nothing is claiming a
12296
+ * promotion it has not cashed in. Before the floor existed, core and
12297
+ * extended were enforced identically, and reaching core changed only the
12298
+ * dedup tie-break, the M3 ceiling and the census readout: a consumer
12299
+ * could not tell them apart.
12300
+ */
12016
12301
  /** Cap applied to every finding of a quarantine-tier rule (H-1). */
12017
12302
  const QUARANTINE_CAP = {
12018
12303
  severity: "info",
12019
12304
  evidenceLevel: "E0"
12020
12305
  };
12021
- /** The cap that applies to a finding from the given tier (undefined tier = core). */
12022
- function capForTier(tier) {
12023
- return tier === "quarantine" ? QUARANTINE_CAP : null;
12024
- }
12306
+ /**
12307
+ * Floor applied to every finding of a core-tier rule (ADR 0014).
12308
+ *
12309
+ * `E1`, not `E2`, and the reason is the difference between the two.
12310
+ * `E2` would be a claim that the finding is proven, which is a statement
12311
+ * about the code that no tier policy may make — corroboration does that,
12312
+ * at runtime, per finding. `E1` is the weaker and sufficient claim: a
12313
+ * core rule is measured, so its finding is pattern evidence rather than
12314
+ * an observation, and it is worth half the deduction.
12315
+ *
12316
+ * `E0` is what the floor exists to forbid. `isAdvisoryFinding` is
12317
+ * `level === "E0"`, so an E0 finding is simultaneously worth zero points
12318
+ * and unable to fail a gate. A rule whose false-positive rate is
12319
+ * measured under the 10% ceiling should never be able to produce one.
12320
+ */
12321
+ const CORE_FLOOR = { evidenceLevel: "E1" };
12322
+ /**
12323
+ * The policy for a tier.
12324
+ *
12325
+ * `undefined` returns an EMPTY policy, and this is a decision, not a
12326
+ * convenience. `buildUniversalRules` in `scan-pipeline.ts` populates
12327
+ * `tierByRuleId` with `effectiveTier(rule)` for every registry rule — never
12328
+ * undefined — and adds a plugin or workspace-local rule only when that rule
12329
+ * DECLARES a tier. So an absent entry means "a third-party rule that claimed no
12330
+ * tier", and it is the one population that must not inherit a trust bound.
12331
+ *
12332
+ * It used to read "undefined tier = core" in this docstring, and that sentence
12333
+ * was load-bearing in the wrong direction. Before the floor, an absent entry
12334
+ * cost nothing, so the claim was inert. With a core floor, treating undefined as
12335
+ * core would raise every plugin's E0 finding to E1 and start charging
12336
+ * third-party detections for deductions — the exact trust escalation TI-013
12337
+ * exists to prevent, introduced by the ADR written to close a trust gap. A
12338
+ * plugin that wants the core floor has to declare `tier: "core"` and be
12339
+ * reviewed for it.
12340
+ */
12341
+ function policyForTier(tier) {
12342
+ if (tier === "quarantine") return { cap: QUARANTINE_CAP };
12343
+ if (tier === "core") return { floor: CORE_FLOOR };
12344
+ return {};
12345
+ }
12346
+ /**
12347
+ * E-ladder order. "E2" > "E1" > "E0" is a claim about how much evidence a
12348
+ * finding carries, and the floor is only meaningful if "at least E1" can be
12349
+ * stated. Declared as a ladder rather than compared as strings because
12350
+ * `"E0" > "E1"` is true in JavaScript and would invert the floor.
12351
+ */
12352
+ const EVIDENCE_RANK$1 = {
12353
+ E0: 0,
12354
+ E1: 1,
12355
+ E2: 2
12356
+ };
12025
12357
  /**
12026
12358
  * In-place enforcement over a scan's findings. Idempotent; runs AFTER
12027
- * evidence stamping so it wins over rule-declared evidenceLevel.
12359
+ * evidence stamping so it wins over rule-declared evidenceLevel — for the
12360
+ * cap and the floor alike. A rule's own `evidenceLevel` in `RuleMeta` is a
12361
+ * declaration about the rule; the tier is a decision about the rule, and
12362
+ * the decision outranks the declaration.
12363
+ *
12364
+ * Idempotence: cap-then-floor is a fixed point. A finding already at the
12365
+ * cap stays at the cap, and one already at or above the floor stays put,
12366
+ * so running this twice cannot walk a finding up the ladder.
12028
12367
  */
12029
12368
  function enforceTierPolicy(findings, tierByRuleId) {
12030
12369
  for (const f of findings) {
12031
- const cap = capForTier(tierByRuleId.get(f.ruleId));
12370
+ const { cap, floor } = policyForTier(tierByRuleId.get(f.ruleId));
12032
12371
  if (cap) {
12033
12372
  f.severity = cap.severity;
12034
12373
  f.evidenceLevel = cap.evidenceLevel;
12035
12374
  }
12375
+ if (floor?.evidenceLevel === void 0) continue;
12376
+ const effective = f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence);
12377
+ if (EVIDENCE_RANK$1[effective] < EVIDENCE_RANK$1[floor.evidenceLevel]) f.evidenceLevel = floor.evidenceLevel;
12036
12378
  }
12037
12379
  }
12038
- //#endregion
12039
- //#region src/engine/evidence-core.ts
12380
+ // src/engine/evidence-core.ts
12040
12381
  /**
12041
12382
  * The core's own version — stamped into every record's provenance so a
12042
12383
  * consumer can tell which normalization contract produced a record.
@@ -12103,8 +12444,7 @@ function countEvidence(records) {
12103
12444
  }
12104
12445
  return counts;
12105
12446
  }
12106
- //#endregion
12107
- //#region src/engine/provenance.ts
12447
+ // src/engine/provenance.ts
12108
12448
  const GENERATED_HEADER_RE = /^\s*(?:\/\/|#|\/\*)\s*(?:auto[- ]?generated|generated by|do not edit)/i;
12109
12449
  /**
12110
12450
  * Codegen fingerprint: the recorder names its first test exactly
@@ -12145,8 +12485,7 @@ function computeAgenticProfile(files, findings) {
12145
12485
  note: "Static provenance markers only (generated-file headers, Playwright codegen default titles). Absence of a marker does not prove human authorship; healed and MCP-edited files without markers are indistinguishable from hand-written ones. Provenance never changes scoring or evidence — authorship is metadata, not trust."
12146
12486
  };
12147
12487
  }
12148
- //#endregion
12149
- //#region src/engine/overlap-dedup.ts
12488
+ // src/engine/overlap-dedup.ts
12150
12489
  const TIER_RANK = {
12151
12490
  core: 0,
12152
12491
  extended: 1,
@@ -12221,8 +12560,8 @@ function isDeclaredByPresent(f, present, metaByRuleId) {
12221
12560
  function ranksBefore(a, b, metaByRuleId) {
12222
12561
  const ma = metaByRuleId.get(a.ruleId);
12223
12562
  const mb = metaByRuleId.get(b.ruleId);
12224
- const tierA = TIER_RANK[ma?.tier ?? "core"];
12225
- const tierB = TIER_RANK[mb?.tier ?? "core"];
12563
+ const tierA = TIER_RANK[ma?.tier ?? "extended"];
12564
+ const tierB = TIER_RANK[mb?.tier ?? "extended"];
12226
12565
  if (tierA !== tierB) return tierA < tierB;
12227
12566
  const sevA = SEVERITY_RANK[a.severity];
12228
12567
  const sevB = SEVERITY_RANK[b.severity];
@@ -12241,8 +12580,16 @@ function dropOrderBefore(a, b, metaByRuleId) {
12241
12580
  if (ranksBefore(b, a, metaByRuleId)) return true;
12242
12581
  return (a.column ?? Number.MAX_SAFE_INTEGER) < (b.column ?? Number.MAX_SAFE_INTEGER);
12243
12582
  }
12244
- //#endregion
12245
- //#region src/engine/correlation-engine.ts
12583
+ // src/engine/finding-identity.ts
12584
+ /**
12585
+ * Correlation identity for before/after comparison: ruleId + file +
12586
+ * message, deliberately EXCLUDING line — a source edit that shifts a
12587
+ * finding still correlates. Shared by baseline, impact, and resolution.
12588
+ */
12589
+ function findingFingerprint(f) {
12590
+ return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
12591
+ }
12592
+ // src/engine/correlation-engine.ts
12246
12593
  const EVIDENCE_RANK = {
12247
12594
  E0: 0,
12248
12595
  E1: 1,
@@ -12286,13 +12633,31 @@ function groupByFile(findings) {
12286
12633
  return groups;
12287
12634
  }
12288
12635
  /**
12289
- * Group findings by rootCauseId (or ruleId when rootCauseId is absent)
12290
- * to detect convergent findings pointing at the same root cause.
12636
+ * Group findings by root cause, falling back to the FINDING FINGERPRINT.
12637
+ *
12638
+ * The fallback used to be `ruleId`, and that is the defect this fixes. Nothing
12639
+ * in the tree assigns `rootCauseId` — the machine contract reports it `null` on
12640
+ * every finding — so `ruleId` was the key for every group, and N independent
12641
+ * defects from one rule across N files were reported as
12642
+ *
12643
+ * CONVERGENT — N findings share root cause QA-PW-002
12644
+ *
12645
+ * which is the precise inverse of what convergence means. Convergence is the
12646
+ * claim that several findings are ONE thing; the key that produced it said
12647
+ * only "these are the same rule". A fingerprint is ruleId + file + message, so
12648
+ * two `QA-PW-002` findings in different files are two keys, two groups of one,
12649
+ * and neither is reported.
12650
+ *
12651
+ * This is also why `findingId`/`rootCauseId`/`deduplicationGroup` are not
12652
+ * wired instead: they are the same idea, and an unwired field plus a fallback
12653
+ * would be two mechanisms for one claim. When a rule declares a real root
12654
+ * cause, `rootCauseId` still wins — the field is honoured the moment anything
12655
+ * populates it.
12291
12656
  */
12292
12657
  function groupByRootCause(findings) {
12293
12658
  const groups = /* @__PURE__ */ new Map();
12294
12659
  for (const f of findings) {
12295
- const key = f.rootCauseId ?? f.ruleId;
12660
+ const key = f.rootCauseId ?? findingFingerprint(f);
12296
12661
  const list = groups.get(key) ?? [];
12297
12662
  list.push(f);
12298
12663
  groups.set(key, list);
@@ -12300,6 +12665,17 @@ function groupByRootCause(findings) {
12300
12665
  return groups;
12301
12666
  }
12302
12667
  /**
12668
+ * The identity a conclusion lists a finding under.
12669
+ *
12670
+ * `findingId` when something has populated it, else the fingerprint. Never
12671
+ * `ruleId`: the fallback used to collapse every finding from one rule onto one
12672
+ * id, so a three-finding CONVERGENT group listed the same id three times and
12673
+ * the `alreadyConvergent` set below could not tell the members apart.
12674
+ */
12675
+ function conclusionId(f) {
12676
+ return f.findingId ?? findingFingerprint(f);
12677
+ }
12678
+ /**
12303
12679
  * Correlate findings and produce conclusions WITHOUT modifying them.
12304
12680
  * Deterministic — same input always produces same output.
12305
12681
  */
@@ -12308,7 +12684,7 @@ function correlateFindings(findings) {
12308
12684
  const rootCauseGroups = groupByRootCause(findings);
12309
12685
  for (const [rootCause, group] of Array.from(rootCauseGroups.entries()).sort((a, b) => compareLocalized("en")(a[0], b[0]))) {
12310
12686
  if (group.length < 2) continue;
12311
- const findingIds = group.map((f) => f.findingId ?? f.ruleId);
12687
+ const findingIds = group.map(conclusionId);
12312
12688
  conclusions.push({
12313
12689
  conclusionType: "CONVERGENT",
12314
12690
  certainty: correlationStrength(group),
@@ -12322,9 +12698,9 @@ function correlateFindings(findings) {
12322
12698
  if (group.length < 2) continue;
12323
12699
  const alreadyConvergent = /* @__PURE__ */ new Set();
12324
12700
  for (const c of conclusions) if (c.conclusionType === "CONVERGENT") for (const id of c.findingIds) alreadyConvergent.add(id);
12325
- const novel = group.filter((f) => !alreadyConvergent.has(f.findingId ?? f.ruleId));
12701
+ const novel = group.filter((f) => !alreadyConvergent.has(conclusionId(f)));
12326
12702
  if (novel.length < 2) continue;
12327
- const findingIds = novel.map((f) => f.findingId ?? f.ruleId);
12703
+ const findingIds = novel.map(conclusionId);
12328
12704
  conclusions.push({
12329
12705
  conclusionType: "AMPLIFIED",
12330
12706
  certainty: correlationStrength(novel),
@@ -12339,12 +12715,11 @@ function correlateFindings(findings) {
12339
12715
  certainty: "STRONG",
12340
12716
  corroboration: `${corroborated.length} finding(s) with runtime corroboration`,
12341
12717
  sourceCount: corroborated.length,
12342
- findingIds: corroborated.map((f) => f.findingId ?? f.ruleId)
12718
+ findingIds: corroborated.map(conclusionId)
12343
12719
  });
12344
12720
  return conclusions;
12345
12721
  }
12346
- //#endregion
12347
- //#region src/engine/dependency-graph.ts
12722
+ // src/engine/dependency-graph.ts
12348
12723
  /**
12349
12724
  * Dependency Graph (ECO-005).
12350
12725
  *
@@ -12481,8 +12856,7 @@ function parseManifest(filePath) {
12481
12856
  if (filePath.endsWith("pyproject.toml")) return parsePyprojectToml(filePath);
12482
12857
  if (filePath.endsWith("pom.xml")) return parsePomXml(filePath);
12483
12858
  }
12484
- //#endregion
12485
- //#region src/engine/incremental-analysis.ts
12859
+ // src/engine/incremental-analysis.ts
12486
12860
  /**
12487
12861
  * Semantic inputs that affect the full scan beyond individual file content.
12488
12862
  * If any of these changed, an incremental run is NOT safe — a full scan
@@ -12518,8 +12892,7 @@ function isIncrementalSafe(changedFiles) {
12518
12892
  reasons
12519
12893
  };
12520
12894
  }
12521
- //#endregion
12522
- //#region src/brand/tokens.ts
12895
+ // src/brand/tokens.ts
12523
12896
  /**
12524
12897
  * The single source of brand truth.
12525
12898
  *
@@ -12581,7 +12954,25 @@ const BRAND = {
12581
12954
  auroraGreen: "#5FD6A4",
12582
12955
  auroraViolet: "#9D8CF5",
12583
12956
  steel: "#C8CBCF",
12584
- steelDim: "#8B939D"
12957
+ steelDim: "#8B939D",
12958
+ /** The teal half of the accent rule, and the gradient's near stop. */
12959
+ readingAccent: "#73C4CA",
12960
+ /** The violet half of the accent rule, and the gradient's far stop. */
12961
+ readingAccentAlt: "#A596E5",
12962
+ /** The focus ring. A non-text affordance, held to the same AA floor. */
12963
+ readingFocus: "#8CE3ED",
12964
+ /** Hover state for the mark's monochrome glyph. */
12965
+ readingAccentBright: "#85DBED",
12966
+ /** Hover state for the wordmark's ink. */
12967
+ readingAccentInk: "#E5FCFF",
12968
+ /** UNWORTHY at reading contrast — a softer red than the terminal's. */
12969
+ readingUnworthy: "#FF9790",
12970
+ /** Muted body ink on the reading surfaces. */
12971
+ readingMuted: "#C6CFDB",
12972
+ /** Dimmed ink on the reading surfaces. */
12973
+ readingDim: "#ACB9CA",
12974
+ /** The hero lede: the brightest ink below `TEXT.primary`. */
12975
+ readingLede: "#E0E6EE"
12585
12976
  };
12586
12977
  /**
12587
12978
  * Midnight iron. One ramp, four steps, darkest first.
@@ -12684,7 +13075,7 @@ const TRUST = {
12684
13075
  l5: "#5CC4E0"
12685
13076
  };
12686
13077
  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;
12687
- 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;
13078
+ 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;
12688
13079
  EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2;
12689
13080
  const RUNG_MEANINGS = [
12690
13081
  "observation only",
@@ -12787,8 +13178,7 @@ function deriveScoreState(score) {
12787
13178
  rune: RUNES.critical
12788
13179
  };
12789
13180
  }
12790
- //#endregion
12791
- //#region src/engine/monorepo-analysis.ts
13181
+ // src/engine/monorepo-analysis.ts
12792
13182
  /**
12793
13183
  * Package verdict, derived from the one ScoreState band model (BW-104).
12794
13184
  * These two constants used to be a private copy of the band boundaries;
@@ -12895,8 +13285,7 @@ function configurableAggregation(results, config) {
12895
13285
  strategy: config.weightingStrategy
12896
13286
  };
12897
13287
  }
12898
- //#endregion
12899
- //#region src/lib/fs-atomic.ts
13288
+ // src/lib/fs-atomic.ts
12900
13289
  /**
12901
13290
  * Atomic file writes (audit S9).
12902
13291
  *
@@ -12979,8 +13368,7 @@ function renameWithWindowsRetry(from, to) {
12979
13368
  throw err;
12980
13369
  }
12981
13370
  }
12982
- //#endregion
12983
- //#region src/engine/scan-cache.ts
13371
+ // src/engine/scan-cache.ts
12984
13372
  /**
12985
13373
  * Local incremental scan cache (Beta-to-Stable 1.0 plan, M5.2 / A-2).
12986
13374
  *
@@ -13190,8 +13578,7 @@ function createScanCache(root) {
13190
13578
  }
13191
13579
  };
13192
13580
  }
13193
- //#endregion
13194
- //#region src/reporter/theme.ts
13581
+ // src/reporter/theme.ts
13195
13582
  /**
13196
13583
  * Norse-forge theme system. Pure string-in → string-out helpers.
13197
13584
  * Respects NO_COLOR and non-TTY via `palette(isTTY)` — every renderer
@@ -13291,8 +13678,7 @@ function rgb([r, g, b]) {
13291
13678
  function palette(enabled) {
13292
13679
  return enabled ? on : off;
13293
13680
  }
13294
- //#endregion
13295
- //#region src/reporter/ui.ts
13681
+ // src/reporter/ui.ts
13296
13682
  /**
13297
13683
  * Neutral context for direct calls (tests, library use) and as the
13298
13684
  * safe default when a command runner is invoked without wiring: no
@@ -13317,8 +13703,7 @@ function sectionHeader(title, ui) {
13317
13703
  const glyph = ui.ascii ? "=" : "▍";
13318
13704
  return ` ${ui.p.accent(`${glyph} ${title}`)}`;
13319
13705
  }
13320
- //#endregion
13321
- //#region src/forensics/classify.ts
13706
+ // src/forensics/classify.ts
13322
13707
  /**
13323
13708
  * Strong-signal marker tables. Kept deliberately narrow: a marker here
13324
13709
  * is a named family fact, not a vibe. Unknown text yields no signal —
@@ -13438,8 +13823,7 @@ function classifyForensicVerdict(signals) {
13438
13823
  signals: signalCounts
13439
13824
  };
13440
13825
  }
13441
- //#endregion
13442
- //#region src/forensics/analyze.ts
13826
+ // src/forensics/analyze.ts
13443
13827
  const ui$5 = plainContext();
13444
13828
  const MAX_RECORDS = 1e5;
13445
13829
  function analyze(records, source) {
@@ -13574,7 +13958,7 @@ function renderFlakyMd(report) {
13574
13958
  const lines = [];
13575
13959
  lines.push("# FLAKY.md");
13576
13960
  lines.push("");
13577
- lines.push(`> Generated by mjolnir forensics from ${report.source} run data.`);
13961
+ lines.push(`> Generated by Mjölnir from ${report.source} run data.`);
13578
13962
  lines.push("> A test that passes only on retry is not a passing test — it is a lucky test.");
13579
13963
  lines.push("");
13580
13964
  lines.push(`**${report.totalTests}** tests analyzed · **${report.flakyTests}** true flakes · **${report.retriedTests}** retried · **${report.failed}** failing`);
@@ -13602,8 +13986,7 @@ function renderFlakyMd(report) {
13602
13986
  lines.push("");
13603
13987
  return lines.join("\n");
13604
13988
  }
13605
- //#endregion
13606
- //#region src/forensics/evidence-hygiene.ts
13989
+ // src/forensics/evidence-hygiene.ts
13607
13990
  /**
13608
13991
  * Evidence hygiene — secret redaction + control-char sanitization
13609
13992
  * (ENGINE-012).
@@ -13665,8 +14048,7 @@ function sanitizeErrorText(text, options) {
13665
14048
  if (result.length > maxLength) result = result.slice(0, maxLength) + `[truncated at ${maxLength} chars]`;
13666
14049
  return result;
13667
14050
  }
13668
- //#endregion
13669
- //#region src/forensics/trace.ts
14051
+ // src/forensics/trace.ts
13670
14052
  /**
13671
14053
  * Playwright trace ingester (product-gap master plan §9 R5 / WI-17).
13672
14054
  *
@@ -13859,8 +14241,7 @@ function parseTraceArtifact(bytes, artifactName) {
13859
14241
  const { actions } = parseTraceNdjson(text);
13860
14242
  return traceActionsToRecord(actions, artifactName);
13861
14243
  }
13862
- //#endregion
13863
- //#region src/forensics/parse-jest-json.ts
14244
+ // src/forensics/parse-jest-json.ts
13864
14245
  function toStatus$2(raw) {
13865
14246
  switch (raw) {
13866
14247
  case "passed": return "passed";
@@ -13920,21 +14301,20 @@ function parseJestJson(json) {
13920
14301
  }
13921
14302
  return out;
13922
14303
  }
13923
- //#endregion
13924
- //#region src/forensics/parse-junit.ts
13925
- const MAX_INPUT = 20971520;
13926
- function decodeEntities(s) {
14304
+ // src/forensics/parse-junit.ts
14305
+ const MAX_INPUT$1 = 20971520;
14306
+ function decodeEntities$1(s) {
13927
14307
  return s.replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", "\"").replaceAll("&apos;", "'").replaceAll("&#39;", "'").replaceAll("&amp;", "&");
13928
14308
  }
13929
- function attr(tag, name) {
14309
+ function attr$1(tag, name) {
13930
14310
  const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
13931
14311
  const m = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*"([^"]*)"`, "i").exec(tag);
13932
- if (m?.[1] !== void 0) return decodeEntities(m[1]);
14312
+ if (m?.[1] !== void 0) return decodeEntities$1(m[1]);
13933
14313
  const m2 = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*'([^']*)'`, "i").exec(tag);
13934
- return m2?.[1] !== void 0 ? decodeEntities(m2[1]) : void 0;
14314
+ return m2?.[1] !== void 0 ? decodeEntities$1(m2[1]) : void 0;
13935
14315
  }
13936
14316
  function parseJunitXml(xml) {
13937
- if (xml.length > MAX_INPUT) return [];
14317
+ if (xml.length > MAX_INPUT$1) return [];
13938
14318
  const out = [];
13939
14319
  const startRe = /<testcase\b/gi;
13940
14320
  const closeRe = /<\/testcase\s*>/gi;
@@ -13955,29 +14335,40 @@ function parseJunitXml(xml) {
13955
14335
  inner = xml.slice(gt + 1, close.index);
13956
14336
  startRe.lastIndex = close.index + close[0].length;
13957
14337
  }
13958
- const name = attr(attrs, "name") ?? "(unnamed)";
13959
- const classname = attr(attrs, "classname") ?? "";
13960
- const timeRaw = attr(attrs, "time");
14338
+ const name = attr$1(attrs, "name") ?? "(unnamed)";
14339
+ const classname = attr$1(attrs, "classname") ?? "";
14340
+ const timeRaw = attr$1(attrs, "time");
13961
14341
  const timeSec = timeRaw !== void 0 && Number.isFinite(Number(timeRaw)) ? Number(timeRaw) : 0;
13962
14342
  const failed = /<(?:failure|error)\b/i.test(inner);
13963
14343
  const skipped = /<skipped\b/i.test(inner);
13964
14344
  const errors = [];
13965
14345
  if (failed) {
13966
- const failRe = /<(?:failure|error)\b([^>]*)>([\s\S]*?)<\/(?:failure|error)\s*>/gi;
13967
- let failMatch;
13968
- while ((failMatch = failRe.exec(inner)) !== null) {
13969
- const attrsStr = failMatch[1] ?? "";
14346
+ const openRe = /<(?:failure|error)\b/gi;
14347
+ const endRe = /<\/(?:failure|error)\s*>/gi;
14348
+ let open;
14349
+ while ((open = openRe.exec(inner)) !== null) {
14350
+ const gt = inner.indexOf(">", open.index);
14351
+ if (gt === -1) break;
14352
+ const attrsStr = inner.slice(open.index, gt);
14353
+ if (attrsStr.endsWith("/")) {
14354
+ openRe.lastIndex = gt + 1;
14355
+ continue;
14356
+ }
14357
+ endRe.lastIndex = gt + 1;
14358
+ const end = endRe.exec(inner);
14359
+ if (end === null) break;
13970
14360
  const msgAttr = /\smessage\s*=\s*"([^"]*)"/i.exec(attrsStr)?.[1];
13971
- if (msgAttr) errors.push(sanitizeErrorText(decodeEntities(msgAttr)));
13972
- const body = failMatch[2]?.trim();
13973
- if (body && body !== msgAttr) errors.push(sanitizeErrorText(decodeEntities(body)));
14361
+ if (msgAttr) errors.push(sanitizeErrorText(decodeEntities$1(msgAttr)));
14362
+ const body = inner.slice(gt + 1, end.index).trim();
14363
+ if (body && body !== msgAttr) errors.push(sanitizeErrorText(decodeEntities$1(body)));
14364
+ openRe.lastIndex = end.index + end[0].length;
13974
14365
  }
13975
14366
  const selfCloseRe = /<(?:failure|error)\b([^>]*)\/>/gi;
13976
14367
  let scMatch;
13977
14368
  while ((scMatch = selfCloseRe.exec(inner)) !== null) {
13978
14369
  const scAttrs = scMatch[1] ?? "";
13979
14370
  const scMsgMatch = /\smessage\s*=\s*"([^"]*)"/i.exec(scAttrs);
13980
- if (scMsgMatch?.[1]) errors.push(sanitizeErrorText(decodeEntities(scMsgMatch[1])));
14371
+ if (scMsgMatch?.[1]) errors.push(sanitizeErrorText(decodeEntities$1(scMsgMatch[1])));
13981
14372
  }
13982
14373
  }
13983
14374
  const attempt = {
@@ -13994,8 +14385,156 @@ function parseJunitXml(xml) {
13994
14385
  }
13995
14386
  return out;
13996
14387
  }
13997
- //#endregion
13998
- //#region src/forensics/parse-playwright-json.ts
14388
+ // src/forensics/parse-trx.ts
14389
+ const MAX_INPUT = 20971520;
14390
+ function decodeEntities(s) {
14391
+ return s.replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", "\"").replaceAll("&apos;", "'").replaceAll("&#39;", "'").replaceAll("&amp;", "&");
14392
+ }
14393
+ function attr(tag, name) {
14394
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14395
+ const m = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*"([^"]*)"`, "i").exec(tag);
14396
+ if (m?.[1] !== void 0) return decodeEntities(m[1]);
14397
+ const sq = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*'([^']*)'`, "i").exec(tag);
14398
+ if (sq?.[1] !== void 0) return decodeEntities(sq[1]);
14399
+ }
14400
+ /**
14401
+ * Does this document look like TRX at all?
14402
+ *
14403
+ * Shape-sniffed rather than trusted from its location, for the same reason the
14404
+ * JUnit path sniffs: a `TestResults/` directory is a convention, and a
14405
+ * convention can be wrong. A file that is not a `TestRun` must not be parsed as
14406
+ * one and produce confident nonsense.
14407
+ */
14408
+ function looksLikeTrxXml(text) {
14409
+ return /<TestRun[\s>]/i.test(text.slice(0, 4096));
14410
+ }
14411
+ /**
14412
+ * TRX durations are `HH:MM:SS.fffffff`; absent or `-` means "not timed".
14413
+ *
14414
+ * Parsed by splitting rather than by one pattern. A single regex for this has
14415
+ * to be `^(\d+):(\d{2}):(\d{2})(?:\.(\d+))?$`, and the nested quantifiers there
14416
+ * are what `security/detect-unsafe-regex` flags — correctly in general, since
14417
+ * this shape is the usual ReDoS source. Splitting removes the question rather
14418
+ * than asserting the pattern is safe today.
14419
+ */
14420
+ function parseDuration(raw) {
14421
+ if (raw === void 0) return 0;
14422
+ const text = raw.trim();
14423
+ const dot = text.indexOf(".");
14424
+ const clock = dot === -1 ? text : text.slice(0, dot);
14425
+ const fraction = dot === -1 ? "" : text.slice(dot + 1);
14426
+ const parts = clock.split(":");
14427
+ if (parts.length !== 3) return 0;
14428
+ const [h, m, s] = parts;
14429
+ if (h === void 0 || m === void 0 || s === void 0) return 0;
14430
+ if (!/^\d+$/.test(h) || !/^\d{1,2}$/.test(m) || !/^\d{1,2}$/.test(s)) return 0;
14431
+ const millis = fraction.length === 0 ? 0 : Number(fraction.slice(0, 3).padEnd(3, "0"));
14432
+ return Number(h) * 36e5 + Number(m) * 6e4 + Number(s) * 1e3 + millis;
14433
+ }
14434
+ /**
14435
+ * TRX `outcome` to a run status.
14436
+ *
14437
+ * The mapping is stated rather than inferred from a default, because the
14438
+ * dangerous direction here is a silent default: an outcome this engine has
14439
+ * never seen must not read as `passed`. Every unrecognised value maps to
14440
+ * `skipped`, which is the non-claiming direction — a skipped test contributes
14441
+ * nothing to "the suite passed" — rather than to a status that would let it.
14442
+ *
14443
+ * TRX also has outcomes that are not verdicts at all (`Warning`, `Pending`,
14444
+ * `Expired`). They are treated the same way, deliberately.
14445
+ */
14446
+ function mapTrxOutcome(outcome) {
14447
+ switch ((outcome ?? "").toLowerCase()) {
14448
+ case "passed":
14449
+ case "completed": return "passed";
14450
+ case "failed":
14451
+ case "error": return "failed";
14452
+ case "timeout": return "timedOut";
14453
+ case "aborted":
14454
+ case "interrupted": return "interrupted";
14455
+ case "notexecuted":
14456
+ case "skipped":
14457
+ case "inconclusive":
14458
+ case "notrunnable":
14459
+ case "pending": return "skipped";
14460
+ default: return "skipped";
14461
+ }
14462
+ }
14463
+ function tagOf(xml, name) {
14464
+ const out = [];
14465
+ const re = new RegExp(`<${name}\\b[^>]*>`, "gi");
14466
+ let m;
14467
+ while ((m = re.exec(xml)) !== null) {
14468
+ if (m[0] !== void 0) out.push(m[0]);
14469
+ if (out.length > 2e5) break;
14470
+ }
14471
+ return out;
14472
+ }
14473
+ /** The text of a `<Message>` child, used as the failure's error text. */
14474
+ function messageOf(text, near) {
14475
+ const window = text.slice(near, near + 2e3);
14476
+ const m = /<Message[^>]*>([\s\S]{0,1500}?)<\/Message>/i.exec(window);
14477
+ if (m?.[1] === void 0) return void 0;
14478
+ const cleaned = sanitizeErrorText(decodeEntities(m[1].replace(/<[^>]*>/g, " ")).trim());
14479
+ return cleaned.length === 0 ? void 0 : cleaned;
14480
+ }
14481
+ /**
14482
+ * Parse a TRX document into test records.
14483
+ *
14484
+ * Returns an empty array for a document that is not TRX rather than throwing:
14485
+ * discovery sniffs before parsing, and a caller that hands us a non-TRX file
14486
+ * should get "no evidence here", not an exception that aborts a whole run
14487
+ * report.
14488
+ */
14489
+ function parseTrxXml(xml) {
14490
+ if (xml.length > MAX_INPUT) return [];
14491
+ if (!looksLikeTrxXml(xml)) return [];
14492
+ const byTestId = /* @__PURE__ */ new Map();
14493
+ for (const tag of tagOf(xml, "UnitTest")) {
14494
+ const id = attr(tag, "id");
14495
+ const name = attr(tag, "name");
14496
+ if (id !== void 0 && name !== void 0) byTestId.set(id, name);
14497
+ }
14498
+ const records = [];
14499
+ const startRe = /<UnitTestResult\b/gi;
14500
+ const starts = [];
14501
+ let s;
14502
+ while ((s = startRe.exec(xml)) !== null) {
14503
+ starts.push(s.index);
14504
+ if (starts.length > 2e5) break;
14505
+ }
14506
+ const resultsEnd = xml.search(/<\/Results>/i);
14507
+ for (let i = 0; i < starts.length; i++) {
14508
+ const start = starts[i];
14509
+ const tagEnd = xml.indexOf(">", start);
14510
+ if (tagEnd === -1) break;
14511
+ const selfClosing = xml[tagEnd - 1] === "/";
14512
+ const attrs = xml.slice(start + 15, selfClosing ? tagEnd - 1 : tagEnd);
14513
+ const next = starts[i + 1] ?? -1;
14514
+ let bodyEnd = next === -1 ? xml.length : next;
14515
+ if (resultsEnd > tagEnd && resultsEnd < bodyEnd) bodyEnd = resultsEnd;
14516
+ const inner = selfClosing ? "" : xml.slice(tagEnd + 1, bodyEnd);
14517
+ const testName = attr(attrs, "testName") ?? attr(attrs, "testId");
14518
+ if (testName === void 0) continue;
14519
+ const attempt = {
14520
+ index: 1,
14521
+ status: mapTrxOutcome(attr(attrs, "outcome")),
14522
+ durationMs: parseDuration(attr(attrs, "duration"))
14523
+ };
14524
+ const messageAt = inner.search(/<Message\b/i);
14525
+ const error = messageAt === -1 ? void 0 : messageOf(inner, messageAt);
14526
+ records.push({
14527
+ file: "unknown",
14528
+ title: testName,
14529
+ attempts: [attempt],
14530
+ evidenceKind: "test",
14531
+ ...error === void 0 ? {} : { errors: [error] }
14532
+ });
14533
+ if (records.length > 2e5) break;
14534
+ }
14535
+ return records;
14536
+ }
14537
+ // src/forensics/parse-playwright-json.ts
13999
14538
  const MAX_DEPTH = 32;
14000
14539
  function toStatus$1(raw) {
14001
14540
  switch (raw) {
@@ -14060,8 +14599,7 @@ function parsePlaywrightJson(json) {
14060
14599
  walk(root, 0);
14061
14600
  return out;
14062
14601
  }
14063
- //#endregion
14064
- //#region src/forensics/parse-vitest-json.ts
14602
+ // src/forensics/parse-vitest-json.ts
14065
14603
  function toStatus(raw) {
14066
14604
  switch (raw) {
14067
14605
  case "passed": return "passed";
@@ -14115,8 +14653,7 @@ function parseVitestJson(json) {
14115
14653
  }
14116
14654
  return out;
14117
14655
  }
14118
- //#endregion
14119
- //#region src/forensics/parse-har.ts
14656
+ // src/forensics/parse-har.ts
14120
14657
  const MAX_ENTRIES = 2e4;
14121
14658
  function sanitizeTitleUrl(url) {
14122
14659
  try {
@@ -14201,10 +14738,11 @@ function parseHarJsonDetailed(json) {
14201
14738
  truncated
14202
14739
  };
14203
14740
  }
14204
- //#endregion
14205
- //#region src/forensics/run.ts
14741
+ // src/forensics/run.ts
14206
14742
  /**
14207
- * `mjolnir forensics <dir-or-file>` — runtime evidence entry point (R4).
14743
+ * Runtime evidence entry point (R4) — the library behind `explain
14744
+ * --evidence`. The `forensics` verb is retired in the v6 carve; this module
14745
+ * is what reads the run data.
14208
14746
  *
14209
14747
  * Accepts either a single report file or a directory. In a directory it
14210
14748
  * looks for, in priority order:
@@ -14404,6 +14942,13 @@ function traceArtifactName(fullPath) {
14404
14942
  return base;
14405
14943
  }
14406
14944
  function parseFile(path, text) {
14945
+ if (/\.trx$/i.test(path) || looksLikeTrxXml(text)) {
14946
+ const records = parseTrxXml(text);
14947
+ if (records.length > 0) return {
14948
+ records,
14949
+ source: "dotnet-trx"
14950
+ };
14951
+ }
14407
14952
  if (/\.xml$/i.test(path) || /^\s*<\?xml|<testsuite\b/i.test(text.slice(0, 200))) return {
14408
14953
  records: parseJunitXml(text),
14409
14954
  source: "junit-xml"
@@ -14484,8 +15029,7 @@ function loadSuppressions(root, now = /* @__PURE__ */ new Date()) {
14484
15029
  entries
14485
15030
  };
14486
15031
  }
14487
- //#endregion
14488
- //#region src/plugins/reserved-prefixes.ts
15032
+ // src/plugins/reserved-prefixes.ts
14489
15033
  /**
14490
15034
  * Core-owned rule-ID prefixes that external rule sources (npm plugins,
14491
15035
  * JS modules, JSON manifests) may never claim — one shared law.
@@ -14515,8 +15059,7 @@ function isReservedPrefix(ruleId) {
14515
15059
  const upper = ruleId.toUpperCase();
14516
15060
  return RESERVED_PREFIXES.some((p) => upper.startsWith(p));
14517
15061
  }
14518
- //#endregion
14519
- //#region src/plugins/load.ts
15062
+ // src/plugins/load.ts
14520
15063
  /**
14521
15064
  * Plugin API (Upgrade-Plan-v3 Phase 6, tier-2 item #1).
14522
15065
  *
@@ -14622,8 +15165,7 @@ function loadPlugins(root, gateOpen = false) {
14622
15165
  }
14623
15166
  return result;
14624
15167
  }
14625
- //#endregion
14626
- //#region src/plugins/local-rules.ts
15168
+ // src/plugins/local-rules.ts
14627
15169
  /**
14628
15170
  * Local plugin/rule contract (Verification Trust Evolution Plan §18 —
14629
15171
  * Local Extensibility).
@@ -14655,7 +15197,7 @@ function loadPlugins(root, gateOpen = false) {
14655
15197
  * - Load failures degrade honestly: warning entries, never a crash;
14656
15198
  * exit codes stay frozen.
14657
15199
  *
14658
- * Drift-check: the `mjolnir rules --md` catalog is generated from the
15200
+ * Drift-check: the `mjolnir explain --list --md` catalog is generated from the
14659
15201
  * LOADED rules (core + external), so the catalog can never drift from
14660
15202
  * what actually ships — an edit to a local rule file changes the very
14661
15203
  * next catalog render (locked by tests/local-rules.spec.ts).
@@ -14935,8 +15477,7 @@ async function loadModuleRules(path, result) {
14935
15477
  result.rules.push(rule);
14936
15478
  }
14937
15479
  }
14938
- //#endregion
14939
- //#region src/plugins/trust-gate.ts
15480
+ // src/plugins/trust-gate.ts
14940
15481
  /**
14941
15482
  * Plugin trust gate (audit-remediation-master-plan.md, locked decision C2).
14942
15483
  *
@@ -14972,8 +15513,7 @@ function renderGateNotice(skipped) {
14972
15513
  lines.push("To run them, re-run with --enable-plugins (or set MJOLNIR_ENABLE_PLUGINS=1).", "Declarative JSON rule manifests (mjolnir-rules/*.json) are unaffected — they execute no code.");
14973
15514
  return lines.join("\n");
14974
15515
  }
14975
- //#endregion
14976
- //#region src/engine/scan-pipeline.ts
15516
+ // src/engine/scan-pipeline.ts
14977
15517
  /**
14978
15518
  * Canonical scan pipeline (M6 — blueprint §9.2).
14979
15519
  *
@@ -15001,7 +15541,7 @@ const KNOWN_RULE_IDS = new Set(RULES.map((r) => r.id));
15001
15541
  const OVERLAP_META_BY_RULE_ID = new Map(RULES.map((r, order) => {
15002
15542
  const meta = {
15003
15543
  ...r.overlapWith ? { overlapWith: r.overlapWith } : {},
15004
- ...r.tier ? { tier: r.tier } : {},
15544
+ tier: effectiveTier(r),
15005
15545
  order
15006
15546
  };
15007
15547
  return [r.id, meta];
@@ -15029,11 +15569,15 @@ async function buildUniversalRules(root, strict, opts = {}) {
15029
15569
  rules: local.rules
15030
15570
  }].flatMap((p) => p.rules.map(asUniversal));
15031
15571
  const tierByRuleId = /* @__PURE__ */ new Map();
15032
- for (const r of RULES) if (r.tier) tierByRuleId.set(r.id, r.tier);
15572
+ for (const r of RULES) tierByRuleId.set(r.id, effectiveTier(r));
15033
15573
  for (const p of plugins) for (const r of p.rules) if (r.tier) tierByRuleId.set(r.id, r.tier);
15034
15574
  for (const r of local.rules) if (r.tier) tierByRuleId.set(r.id, r.tier);
15035
15575
  let rules = [...UNIVERSAL_RULES, ...externalRules];
15576
+ const quarantined = rules.filter((r) => tierByRuleId.get(r.id) === "quarantine").map((r) => r.id).sort();
15577
+ const withheldRuleIds = strict ? [] : quarantined;
15036
15578
  if (!strict) rules = rules.filter((r) => tierByRuleId.get(r.id) !== "quarantine");
15579
+ const rulesWithheld = withheldRuleIds.length;
15580
+ const rulesApplied = rules.length;
15037
15581
  const pluginMeta = [...plugins.map((p) => ({
15038
15582
  name: p.name,
15039
15583
  rules: p.rules.length
@@ -15046,7 +15590,10 @@ async function buildUniversalRules(root, strict, opts = {}) {
15046
15590
  pluginErrors: allErrors,
15047
15591
  tierByRuleId,
15048
15592
  pluginMeta,
15049
- externalRules: local.rules
15593
+ externalRules: local.rules,
15594
+ rulesApplied,
15595
+ rulesWithheld,
15596
+ withheldRuleIds
15050
15597
  };
15051
15598
  }
15052
15599
  /** Rule-declared evidence-level overrides (Honesty Core). */
@@ -15071,35 +15618,6 @@ function fallbackWorkspace(targetAbs) {
15071
15618
  };
15072
15619
  }
15073
15620
  /**
15074
- * Minimal glob match for suppression `files` patterns, with gitignore
15075
- * `**` semantics (bug-audit M5). Supports:
15076
- * "tests/**" — everything inside tests/
15077
- * "tests" + "/**\/*.spec.ts" — any depth UNDER tests/ (including none) ending in .spec.ts
15078
- * "**" + "/*.spec.ts" — any depth including root-level files
15079
- * "tests/foo.spec.ts" — exact path
15080
- * "*" within a segment never crosses "/".
15081
- *
15082
- * Forward slashes only (findings always use normalized paths). `?`,
15083
- * character classes and `!` negation are not metacharacters here — same
15084
- * as before this rewrite.
15085
- */
15086
- function pathMatchesGlob(path, glob) {
15087
- const p = path.replaceAll("\\", "/");
15088
- const segments = glob.replaceAll("\\", "/").split("/");
15089
- let re = "^";
15090
- for (const [i, segment] of segments.entries()) {
15091
- const last = i === segments.length - 1;
15092
- if (segment === "**") {
15093
- if (last) re += "(?:[^/]+/)*[^/]+";
15094
- else re += "(?:[^/]+/)*";
15095
- continue;
15096
- }
15097
- re += segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", "[^/]*");
15098
- if (!last) re += "/";
15099
- }
15100
- return new RegExp(`${re}$`).test(p);
15101
- }
15102
- /**
15103
15621
  * Plan §16 + WI-11: locate a runtime run report next to the scan
15104
15622
  * target, using the exact conventions the forensics ingestion already
15105
15623
  * accepts. Zero-config search over conventional artifact names at
@@ -15455,12 +15973,28 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
15455
15973
  const runtimeIncomplete = discoveredReport !== void 0 && discoveredReport.report.analysisComplete !== true;
15456
15974
  let forensicVerdicts;
15457
15975
  let evidenceRecords = [];
15458
- if (discoveredReport && discoveredReport.report.analysisComplete === true) try {
15459
- evidenceRecords = buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
15460
- stampRuntimeCorroboration(findings, discoveredReport.report, workspace.root);
15461
- forensicVerdicts = summarizeForensicVerdicts(discoveredReport.report);
15462
- } catch {
15463
- evidenceRecords = [];
15976
+ if (discoveredReport && discoveredReport.report.analysisComplete === true) {
15977
+ const before = findings.map((f) => ({
15978
+ runtimeCorroboration: f.runtimeCorroboration,
15979
+ trustLevel: f.trustLevel
15980
+ }));
15981
+ try {
15982
+ const records = buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
15983
+ stampRuntimeCorroboration(findings, discoveredReport.report, workspace.root);
15984
+ forensicVerdicts = summarizeForensicVerdicts(discoveredReport.report);
15985
+ evidenceRecords = records;
15986
+ } catch {
15987
+ findings.forEach((f, i) => {
15988
+ const prev = before[i];
15989
+ if (prev?.runtimeCorroboration === void 0) delete f.runtimeCorroboration;
15990
+ else f.runtimeCorroboration = prev.runtimeCorroboration;
15991
+ if (prev?.trustLevel === void 0) delete f.trustLevel;
15992
+ else f.trustLevel = prev.trustLevel;
15993
+ });
15994
+ forensicVerdicts = void 0;
15995
+ evidenceRecords = [];
15996
+ recordDegradation("runtime-corroboration-failed");
15997
+ }
15464
15998
  }
15465
15999
  return {
15466
16000
  evidenceRecords,
@@ -15604,6 +16138,9 @@ function assembleScanResult(o) {
15604
16138
  parseFallbacks: o.parseFallbacks ?? 0,
15605
16139
  ...o.astFallbackFiles !== void 0 ? { astFallbackFiles: o.astFallbackFiles } : {},
15606
16140
  ...o.degradations !== void 0 ? { degradations: o.degradations } : {},
16141
+ ...o.rulesWithheld !== void 0 ? { rulesWithheld: o.rulesWithheld } : {},
16142
+ ...o.withheldRuleIds !== void 0 ? { withheldRuleIds: o.withheldRuleIds } : {},
16143
+ ...o.rulesApplied !== void 0 ? { rulesApplied: o.rulesApplied } : {},
15607
16144
  ...o.scopeInfo.degraded !== void 0 ? { scopeDegraded: o.scopeInfo.degraded } : {},
15608
16145
  ...o.runtimeIncomplete !== void 0 ? { runtimeIncomplete: o.runtimeIncomplete } : {},
15609
16146
  identityIncomplete
@@ -15618,11 +16155,15 @@ function assembleScanResult(o) {
15618
16155
  })),
15619
16156
  config: o.config ?? null,
15620
16157
  engineVersion: ENGINE_VERSION,
16158
+ runMode: resolveRunMode({
16159
+ env: {},
16160
+ flags: {}
16161
+ }),
15621
16162
  reportDigest,
15622
16163
  trustModelVersion: TRUST_MODEL_VERSION,
15623
16164
  scoringModelVersion: SCORING_MODEL_VERSION,
15624
16165
  frameworkSupportMatrixVersion: FRAMEWORK_SUPPORT_MATRIX_VERSION,
15625
- evidenceSchemaVersions: [1],
16166
+ evidenceSchemaVersions: [2],
15626
16167
  ...repository,
15627
16168
  ...candidate !== null ? { candidate } : {}
15628
16169
  });
@@ -15635,7 +16176,10 @@ function assembleScanResult(o) {
15635
16176
  });
15636
16177
  const hasTests = o.testFileCount > 0 && o.testDeclarationCount > 0;
15637
16178
  const suiteInvalidatedBy = [...new Set(o.findings.filter((f) => SUITE_INVALIDATING_RULE_IDS.has(f.ruleId)).map((f) => f.ruleId))].sort();
15638
- const finalScore = hasTests ? completion.partial && scopeAdjustedTotal >= 100 ? 99 : scopeAdjustedTotal : null;
16179
+ const scopeClamped = scopeReasons.length > 0 && total >= 100;
16180
+ const partialClamped = completion.partial && scopeAdjustedTotal >= 100;
16181
+ const scoreClampReason = scopeClamped ? "scope-degraded" : partialClamped ? "partial-scan" : null;
16182
+ const finalScore = hasTests ? partialClamped ? 99 : scopeAdjustedTotal : null;
15639
16183
  const result = {
15640
16184
  schemaVersion: 1,
15641
16185
  partial: completion.partial,
@@ -15674,9 +16218,11 @@ function assembleScanResult(o) {
15674
16218
  } } : {},
15675
16219
  analysisStatus: {
15676
16220
  ...completion.analysisStatus,
16221
+ coverageState: completion.coverageState,
15677
16222
  durationMs: elapsed
15678
16223
  },
15679
- scoringModelVersion: SCORING_MODEL_VERSION
16224
+ scoringModelVersion: SCORING_MODEL_VERSION,
16225
+ ...scoreClampReason !== null ? { scoreClampReason } : {}
15680
16226
  };
15681
16227
  result.trustSummary = buildTrustSummary(result, o.declarationsByFile);
15682
16228
  if (o.findings.length > 0) result.correlationConclusions = correlateFindings(o.findings);
@@ -15720,7 +16266,23 @@ function assembleScanResult(o) {
15720
16266
  * inherently async); `runRules` and every rule stay synchronous and
15721
16267
  * consume `ParsedFile.ast`. Callers await the returned promise.
15722
16268
  */
16269
+ /**
16270
+ * Run one scan inside its own ts-morph Project scope.
16271
+ *
16272
+ * The wrapper is the whole fix. ts-morph's Project caches SourceFiles by path
16273
+ * and `parseTsFile` calls `replaceWithText` on them, so a Project shared between
16274
+ * two concurrent scans lets one rewrite the file the other is mid-rule on —
16275
+ * which surfaced as three of four concurrent scans reporting
16276
+ * `rulesCrashed: 4` and `rules: "partial"`, and therefore as two different
16277
+ * machine-contract digests from one tree. See `src/engine/ts-ast.ts`.
16278
+ *
16279
+ * Async context propagates across `await`, so the scope covers the whole scan
16280
+ * without threading a Project through every adapter.
16281
+ */
15723
16282
  async function runScan(args, hooks = {}) {
16283
+ return runWithScanProject(() => runScanInProject(args, hooks));
16284
+ }
16285
+ async function runScanInProject(args, hooks = {}) {
15724
16286
  const started = Date.now();
15725
16287
  const degradationWindow = beginDegradationWindow();
15726
16288
  const requestedDuration = Number.isFinite(args.maxDurationMs) ? args.maxDurationMs : DEFAULT_MAX_DURATION_MS$1;
@@ -15747,7 +16309,7 @@ async function runScan(args, hooks = {}) {
15747
16309
  let scopeUnrecognized = 0;
15748
16310
  let parseFailed = 0;
15749
16311
  const fileProvenance = [];
15750
- const { rules: activeRules, pluginErrors, tierByRuleId: tiers, pluginMeta } = await buildUniversalRules(workspace.root, args.strict, {
16312
+ const { rules: activeRules, pluginErrors, tierByRuleId: tiers, pluginMeta, rulesApplied, rulesWithheld, withheldRuleIds } = await buildUniversalRules(workspace.root, args.strict, {
15751
16313
  ...args.enablePlugins !== void 0 ? { enablePlugins: args.enablePlugins } : {},
15752
16314
  onGateNotice: (notice) => hooks.onGateNotice ? hooks.onGateNotice(notice) : console.error(notice)
15753
16315
  });
@@ -15836,6 +16398,9 @@ async function runScan(args, hooks = {}) {
15836
16398
  parseFallbacks,
15837
16399
  astFallbackFiles,
15838
16400
  degradations: summarizeDegradations(degradationsSince(degradationWindow)),
16401
+ rulesApplied,
16402
+ rulesWithheld,
16403
+ ...withheldRuleIds.length > 0 ? { withheldRuleIds } : {},
15839
16404
  scanned,
15840
16405
  analyzed,
15841
16406
  testFiles,
@@ -15862,8 +16427,7 @@ async function runScan(args, hooks = {}) {
15862
16427
  resetTsMorphProject();
15863
16428
  return result;
15864
16429
  }
15865
- //#endregion
15866
- //#region src/cli-io.ts
16430
+ // src/cli-io.ts
15867
16431
  /**
15868
16432
  * The ONE error-to-message derivation in `src/`.
15869
16433
  *
@@ -15940,6 +16504,10 @@ function canonicalScanJson(result) {
15940
16504
  skippedFiles: result.analysisStatus.skippedFiles,
15941
16505
  rulesCrashed: result.analysisStatus.rulesCrashed ?? 0,
15942
16506
  parseFallbacks: result.analysisStatus.parseFallbacks ?? 0,
16507
+ coverageState: result.analysisStatus.coverageState ?? "COMPLETE",
16508
+ rulesApplied: result.analysisStatus.rulesApplied ?? null,
16509
+ rulesWithheld: result.analysisStatus.rulesWithheld ?? 0,
16510
+ degradations: result.analysisStatus.degradations ?? [],
15943
16511
  truncationReasons: result.analysisStatus.truncationReasons ?? [],
15944
16512
  reasons: result.analysisStatus.reasons ?? []
15945
16513
  },
@@ -15983,7 +16551,7 @@ function buildMachineContract(result) {
15983
16551
  };
15984
16552
  });
15985
16553
  return {
15986
- contractVersion: 1,
16554
+ contractVersion: 2,
15987
16555
  summary: {
15988
16556
  digest: `sha256:${digest}`,
15989
16557
  findings: result.findings.length,
@@ -16004,6 +16572,10 @@ function buildMachineContract(result) {
16004
16572
  ...result.analysisStatus.parseFallbacks !== void 0 ? { parseFallbacks: result.analysisStatus.parseFallbacks } : {},
16005
16573
  truncationReasons: result.analysisStatus.truncationReasons ?? [],
16006
16574
  ...result.analysisStatus.reasons !== void 0 ? { reasons: result.analysisStatus.reasons } : {},
16575
+ ...result.analysisStatus.degradations !== void 0 ? { degradations: result.analysisStatus.degradations } : {},
16576
+ ...result.analysisStatus.coverageState !== void 0 ? { coverageState: result.analysisStatus.coverageState } : {},
16577
+ ...result.analysisStatus.rulesApplied !== void 0 ? { rulesApplied: result.analysisStatus.rulesApplied } : {},
16578
+ ...result.analysisStatus.rulesWithheld !== void 0 ? { rulesWithheld: result.analysisStatus.rulesWithheld } : {},
16007
16579
  frameworkDetectionUnknown: result.frameworkDetectionUnknown,
16008
16580
  durationMs: result.analysisStatus.durationMs,
16009
16581
  ...result.scopeIntegrity !== void 0 ? { scopeIntegrity: result.scopeIntegrity } : {},
@@ -16014,8 +16586,7 @@ function buildMachineContract(result) {
16014
16586
  ...result.forensicVerdicts !== void 0 ? { forensicVerdicts: result.forensicVerdicts } : {}
16015
16587
  };
16016
16588
  }
16017
- //#endregion
16018
- //#region src/commands/fixture-example.ts
16589
+ // src/commands/fixture-example.ts
16019
16590
  /**
16020
16591
  * Shared example-fixture selection for `mjolnir explain` (explain.ts)
16021
16592
  * and the generated rule docs (rule-docs.ts) — one chooser so both
@@ -16045,7 +16616,7 @@ function explainRule(ruleId, fixturesRoot) {
16045
16616
  const rule = getRule(ruleId);
16046
16617
  if (!rule) return {
16047
16618
  ok: false,
16048
- error: `Unknown rule ID "${ruleId}". Run \`mjolnir rules\` for the full catalog.`
16619
+ error: `Unknown rule ID "${ruleId}". Run \`mjolnir explain --list\` for the full catalog.`
16049
16620
  };
16050
16621
  const window = beginDegradationWindow();
16051
16622
  const degraded = () => {
@@ -16116,18 +16687,7 @@ function explainRule(ruleId, fixturesRoot) {
16116
16687
  exampleFixtureRelPath: relative(fixturesRoot, fixturePath).replaceAll("\\", "/")
16117
16688
  };
16118
16689
  }
16119
- //#endregion
16120
- //#region src/engine/finding-identity.ts
16121
- /**
16122
- * Correlation identity for before/after comparison: ruleId + file +
16123
- * message, deliberately EXCLUDING line — a source edit that shifts a
16124
- * finding still correlates. Shared by baseline, impact, and resolution.
16125
- */
16126
- function findingFingerprint(f) {
16127
- return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
16128
- }
16129
- //#endregion
16130
- //#region src/engine/resolution.ts
16690
+ // src/engine/resolution.ts
16131
16691
  /** Correlation identity (v1-compatible): ruleId\0file\0message. */
16132
16692
  const fingerprint$1 = findingFingerprint;
16133
16693
  /**
@@ -16147,6 +16707,11 @@ function resolve$1(input) {
16147
16707
  cause: "crash",
16148
16708
  comparedAgainst
16149
16709
  };
16710
+ if (current.analysisStatus.withheldRuleIds?.includes(entry.ruleId) === true) return {
16711
+ status: "INCONCLUSIVE",
16712
+ cause: "rule-withheld",
16713
+ comparedAgainst
16714
+ };
16150
16715
  if (input.skippedFiles?.has(entry.file)) return {
16151
16716
  status: "INCONCLUSIVE",
16152
16717
  cause: "skipped",
@@ -16273,16 +16838,15 @@ function diffAgainstBaseline(result, baseline) {
16273
16838
  unchangedCount
16274
16839
  };
16275
16840
  }
16276
- //#endregion
16277
- //#region src/commands/verify.ts
16841
+ // src/commands/verify.ts
16278
16842
  /**
16279
- * `mjolnir verify` — the agent-loop digest (product-gap-remediation
16843
+ * `mjolnir ci verify` — the agent-loop digest (product-gap-remediation
16280
16844
  * master plan P7, plan 1788853205786 — flag agent, decision 6).
16281
16845
  *
16282
- * An agent fixes findings, then runs `mjolnir verify` against the
16283
- * committed baseline (established once with `mjolnir baseline`). The
16846
+ * An agent fixes findings, then runs `mjolnir ci verify` against the
16847
+ * committed baseline (established once from a `mjolnir scan --json` run). The
16284
16848
  * digest answers exactly the loop's questions, derived from the SAME
16285
- * §15 comparison machinery as `mjolnir diff` (no second truth):
16849
+ * §15 comparison machinery as `mjolnir scan --scope changed` (no second truth):
16286
16850
  *
16287
16851
  * - RESOLVED — baseline findings no longer present, each with its
16288
16852
  * lifecycle resolution (only VERIFIED-RESOLVED is a fix claim);
@@ -16421,8 +16985,7 @@ function renderTriageWorkflowJson(report) {
16421
16985
  rows: workflowRows(report)
16422
16986
  }, null, 2) + "\n";
16423
16987
  }
16424
- //#endregion
16425
- //#region src/commands/pw-report.ts
16988
+ // src/commands/pw-report.ts
16426
16989
  const ui = plainContext();
16427
16990
  function summarizePwRun(report) {
16428
16991
  const slowest = [...report.verdicts].sort((a, b) => b.totalDurationMs - a.totalDurationMs).slice(0, 5).map((v) => ({
@@ -16456,8 +17019,7 @@ function renderPwRunSummary(s) {
16456
17019
  }
16457
17020
  return lines.join("\n");
16458
17021
  }
16459
- //#endregion
16460
- //#region src/mcp/server.ts
17022
+ // src/mcp/server.ts
16461
17023
  /**
16462
17024
  * MCP stdio transport (blueprint §21 — Phase 4, PURE TRANSPORT).
16463
17025
  *
@@ -16475,7 +17037,10 @@ function renderPwRunSummary(s) {
16475
17037
  * - parameter size caps; ONE scan in flight (serialized queue) reusing
16476
17038
  * the pipeline's own budgets; zero network (no fetches anywhere); no
16477
17039
  * shell execution beyond Mjölnir's own verbs.
16478
- * - filesystem boundary = scan target + `.mjolnir/` (same as CLI).
17040
+ * - filesystem boundary: the tools are read-only, and with no further
17041
+ * configuration they accept any path the parent process passes (the
17042
+ * parent is the trust boundary). `MJOLNIR_WORKSPACE_ROOT` confines every
17043
+ * path argument to that directory, symlinks resolved.
16479
17044
  * - the plugin trust gate applies unchanged (--enable-plugins is NOT
16480
17045
  * exposed over MCP: tools run with the gate CLOSED unless the server
16481
17046
  * was started with MJOLNIR_ENABLE_PLUGINS=1 in its own environment).
@@ -16485,12 +17050,27 @@ const MAX_PARAM_BYTES = 65536;
16485
17050
  const MAX_DURATION_MS = 36e5;
16486
17051
  const DEFAULT_MAX_DURATION_MS = 6e5;
16487
17052
  const PROTOCOL_VERSION = "2025-06-18";
16488
- const CONFIGURED_ROOT = process.env.MJOLNIR_WORKSPACE_ROOT ? resolve(process.env.MJOLNIR_WORKSPACE_ROOT) : null;
17053
+ /** The real path of `p`, or its lexical resolution when it does not exist. */
17054
+ function realOrResolved(p) {
17055
+ try {
17056
+ return realpathSync.native(resolve(p));
17057
+ } catch {
17058
+ return resolve(p);
17059
+ }
17060
+ }
17061
+ /**
17062
+ * The optional workspace root. When set, every path argument must resolve
17063
+ * INSIDE it after symlinks are followed.
17064
+ *
17065
+ * Unset is open by design and is documented that way in the header: the MCP
17066
+ * tools are read-only and the stdio parent is the trust boundary. Setting
17067
+ * `MJOLNIR_WORKSPACE_ROOT` is how a host confines them.
17068
+ */
17069
+ const CONFIGURED_ROOT = process.env.MJOLNIR_WORKSPACE_ROOT ? realOrResolved(process.env.MJOLNIR_WORKSPACE_ROOT) : null;
16489
17070
  function isPathAllowed(path) {
16490
17071
  if (!CONFIGURED_ROOT) return true;
16491
- const candidate = resolve(path);
16492
- const rel = relative(CONFIGURED_ROOT, candidate);
16493
- return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
17072
+ const rel = relative(CONFIGURED_ROOT, realOrResolved(path));
17073
+ return rel === "" || rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
16494
17074
  }
16495
17075
  /** JSON-RPC error codes (subset used here). */
16496
17076
  const MCP_ERRORS = {
@@ -16558,7 +17138,7 @@ const MCP_TOOLS = [
16558
17138
  },
16559
17139
  {
16560
17140
  name: "verify",
16561
- 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.",
17141
+ 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.",
16562
17142
  inputSchema: {
16563
17143
  type: "object",
16564
17144
  properties: { path: {
@@ -16570,7 +17150,7 @@ const MCP_TOOLS = [
16570
17150
  },
16571
17151
  {
16572
17152
  name: "forensics",
16573
- 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.",
17153
+ 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.",
16574
17154
  inputSchema: {
16575
17155
  type: "object",
16576
17156
  properties: { path: {
@@ -16594,7 +17174,7 @@ const MCP_TOOLS = [
16594
17174
  },
16595
17175
  {
16596
17176
  name: "pw-report",
16597
- 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.",
17177
+ 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.",
16598
17178
  inputSchema: {
16599
17179
  type: "object",
16600
17180
  properties: { path: {
@@ -16787,7 +17367,7 @@ async function handleToolCall(call) {
16787
17367
  id: call.id,
16788
17368
  result: {
16789
17369
  hasBaseline: false,
16790
- note: "no committed baseline at .mjolnir/baseline.json — establish the before-state with `mjolnir baseline` first"
17370
+ 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"
16791
17371
  }
16792
17372
  };
16793
17373
  const result = await serializeScan(() => runScan({
@@ -16967,8 +17547,7 @@ async function handleMcpMessage(msg) {
16967
17547
  }
16968
17548
  };
16969
17549
  }
16970
- //#endregion
16971
- //#region src/mcp/transport.ts
17550
+ // src/mcp/transport.ts
16972
17551
  /**
16973
17552
  * MCP stdio transport loop (blueprint §21 — the framing around the
16974
17553
  * pure-transport server).
@@ -17065,8 +17644,7 @@ async function handleStdioLine(line, write) {
17065
17644
  }
17066
17645
  write(`${JSON.stringify(response)}\n`);
17067
17646
  }
17068
- //#endregion
17069
- //#region src/integrations/sentry.ts
17647
+ // src/integrations/sentry.ts
17070
17648
  /**
17071
17649
  * Sentry crash reporting — opt-in, and inert unless a DSN is configured.
17072
17650
  *
@@ -17192,8 +17770,7 @@ async function flushSentry(timeoutMs = FLUSH_TIMEOUT_MS) {
17192
17770
  return false;
17193
17771
  }
17194
17772
  }
17195
- //#endregion
17196
- //#region src/mcp/stdio.ts
17773
+ // src/mcp/stdio.ts
17197
17774
  /**
17198
17775
  * The standalone MCP stdio binary (blueprint §21), built to
17199
17776
  * `dist/mcp/stdio.mjs` and run by `npm run mcp`.
@@ -17218,7 +17795,6 @@ runStdioTransport(process.stdin, process.stdout).then(() => process.exit(0)).cat
17218
17795
  process.stderr.write(`mjolnir mcp fatal: ${err instanceof Error ? err.message : err}\n`);
17219
17796
  process.exitCode = 20;
17220
17797
  });
17221
- //#endregion
17222
17798
  export {};
17223
17799
 
17224
17800
  //# sourceMappingURL=stdio.mjs.map