mjolnir-qa 5.0.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,7 @@ import { createHash, randomBytes } from "node:crypto";
5
5
  import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
6
6
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
7
  import { execFileSync } from "node:child_process";
8
+ import { AsyncLocalStorage } from "node:async_hooks";
8
9
  import * as ts$2 from "ts-morph";
9
10
  import ts, { Project, SyntaxKind, ts as ts$1 } from "ts-morph";
10
11
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -35,6 +36,23 @@ const SEVERITY_ORDER = [
35
36
  * retried, or timed out in the report).
36
37
  * INVARIANT (structurally enforced): L3–L5 require runtime
37
38
  * corroboration — a static-only finding can never claim L4/L5.
39
+ *
40
+ * THREE LADDER VOCABULARIES now share this shape, and the letters are the
41
+ * only thing distinguishing them:
42
+ *
43
+ * `L0`–`L5` RUNTIME TRUST — how much a consumer can believe one finding.
44
+ * Here.
45
+ * `F0`–`F5` FRAMEWORK SUPPORT — a hand-declared target per framework.
46
+ * `FrameworkSupportLevel`, src/frameworks/framework-inventory.ts
47
+ * `M0`–`M5` CAPABILITY MATURITY — DERIVED from evidence, and the only
48
+ * one of the three that is a measurement.
49
+ * `Maturity`, src/v6/maturity.ts
50
+ *
51
+ * `tests/v6/maturity-vocabulary.spec.ts` holds the two vocabulary sets apart
52
+ * and fails if they ever share a member. `M` is the only DERIVED of the three,
53
+ * and that is the distinction worth remembering when reading a level out of a
54
+ * report: `F` and `L` are declarations about the world, `M` is a function of
55
+ * what this repository can demonstrate.
38
56
  */
39
57
  const TRUST_ORDER = [
40
58
  "L0",
@@ -100,17 +118,24 @@ function deriveCompletion(input) {
100
118
  if (input.runtimeIncomplete) reasons.add("runtime-incomplete");
101
119
  if (input.identityIncomplete) reasons.add("identity-incomplete");
102
120
  for (const reason of truncationReasons) reasons.add(`truncated:${reason}`);
121
+ if ((input.rulesWithheld ?? 0) > 0) reasons.add(`coverage:quarantine:${input.rulesWithheld}`);
103
122
  for (const entry of degradations) reasons.add(`degraded:${entry.reason}:${entry.count}`);
104
123
  const discoveryPartial = input.discoveryTruncated || input.scopeIgnored > 0 || input.scopeUnrecognized > 0 || input.scopeDegraded !== void 0;
105
124
  const rulesPartial = input.rulesPartial || input.rulesCrashed > 0 || input.parseFailed > 0;
125
+ const partial = discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0 || degradations.length > 0;
126
+ const rulesWithheld = input.rulesWithheld ?? 0;
106
127
  return {
107
- partial: discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0 || degradations.length > 0,
128
+ partial,
129
+ coverageState: rulesWithheld > 0 ? "PARTIAL" : "COMPLETE",
108
130
  analysisStatus: {
109
131
  discovery: discoveryPartial ? "partial" : "complete",
110
132
  rules: rulesPartial ? "partial" : "complete",
111
133
  skippedFiles: input.skippedFiles,
112
134
  rulesCrashed: input.rulesCrashed,
113
135
  parseFallbacks: input.parseFallbacks ?? 0,
136
+ rulesApplied: input.rulesApplied ?? 0,
137
+ rulesWithheld,
138
+ ...input.withheldRuleIds !== void 0 && input.withheldRuleIds.length > 0 ? { withheldRuleIds: [...input.withheldRuleIds].sort() } : {},
114
139
  ...truncationReasons.length > 0 ? { truncationReasons } : {},
115
140
  ...degradations.length > 0 ? { degradations } : {},
116
141
  reasons: [...reasons].sort()
@@ -375,10 +400,10 @@ const MEASURED_FP = {
375
400
  },
376
401
  "QA-JV-101": {
377
402
  fpRate: 0,
378
- n: 23,
403
+ n: 35,
379
404
  detectorRevision: 1,
380
405
  ciLow: 0,
381
- ciHigh: .1431
406
+ ciHigh: .0989
382
407
  },
383
408
  "QA-JV-102": {
384
409
  fpRate: .261,
@@ -494,10 +519,10 @@ const MEASURED_FP = {
494
519
  },
495
520
  "QA-PW-117": {
496
521
  fpRate: 0,
497
- n: 24,
522
+ n: 35,
498
523
  detectorRevision: 1,
499
524
  ciLow: 0,
500
- ciHigh: .138
525
+ ciHigh: .0989
501
526
  },
502
527
  "QA-PW-121": {
503
528
  fpRate: 0,
@@ -584,25 +609,18 @@ const MEASURED_FP = {
584
609
  ciHigh: .2425
585
610
  },
586
611
  "QA-PY-002": {
587
- fpRate: .043,
588
- n: 23,
612
+ fpRate: .12,
613
+ n: 25,
589
614
  detectorRevision: 1,
590
- ciLow: .0077,
591
- ciHigh: .2099
615
+ ciLow: .0417,
616
+ ciHigh: .2996
592
617
  },
593
618
  "QA-PY-003": {
594
- fpRate: .467,
595
- n: 30,
619
+ fpRate: .515,
620
+ n: 33,
596
621
  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
622
+ ciLow: .3522,
623
+ ciHigh: .675
606
624
  },
607
625
  "QA-PY-005": {
608
626
  fpRate: .13,
@@ -885,6 +903,58 @@ function buildTrustSummary(result, declarationsByFile) {
885
903
  };
886
904
  }
887
905
  //#endregion
906
+ //#region src/governance/run-mode.ts
907
+ /**
908
+ * Which mode a run is in, and how the other one is reached.
909
+ *
910
+ * ADR 0012: `local-only` is the default and hosted mode is a declared state.
911
+ * This module is the enforcement point, and its shape is the decision:
912
+ *
913
+ * - `resolveRunMode` takes its inputs as an ARGUMENT, never from
914
+ * `process.env`. Nothing here can read the environment, so a stray
915
+ * `MJOLNIR_MODE` in a shell, in CI, or in a parent's environment cannot
916
+ * change what a run does. The first version of the test passed an `env`
917
+ * bag and asserted it was ignored — and that only means something because
918
+ * this function is structurally unable to consult one.
919
+ * - `isHostedModeReachable` is the single predicate every entry point has to
920
+ * pass through, and it answers the same question separately from
921
+ * `resolveRunMode`. Separating them is what stops a caller satisfying the
922
+ * easy one: the first says whether the mode MAY be entered, the second
923
+ * says which mode this run IS.
924
+ * - an unrecognised declaration THROWS. A typo in a build flag that fell back
925
+ * to `local-only` would mean a deployment believed it was hosted and was
926
+ * not — the same class of failure as a gate that reports success for
927
+ * something it did not run.
928
+ *
929
+ * Hosted mode is not implemented. This is the contract it will have to
930
+ * satisfy, and it is deliberately the smallest thing that can be wrong in only
931
+ * one direction.
932
+ */
933
+ /**
934
+ * The build-time declaration that opts a build into hosted mode.
935
+ *
936
+ * A build flag rather than an environment variable, because a build flag is
937
+ * something a release engineer sets deliberately and reviews in a diff, and
938
+ * because an environment variable is set by whatever CI last happened to run
939
+ * with.
940
+ */
941
+ const HOSTED_MODE_FLAG = "mjolnir-mode";
942
+ /**
943
+ * The mode this run is in.
944
+ *
945
+ * Throws on a declaration it does not recognise. A silent fallback here is the
946
+ * failure ADR 0012 exists to prevent: a build that asked for hosted mode,
947
+ * mistyped the flag, and ran local-only would report local-only artefacts into
948
+ * a pipeline that believed otherwise.
949
+ */
950
+ function resolveRunMode(input) {
951
+ const declared = input.flags[HOSTED_MODE_FLAG];
952
+ if (declared === void 0 || declared === false) return "local-only";
953
+ if (declared === true) throw new Error(`${HOSTED_MODE_FLAG} was given without a value. Use "${HOSTED_MODE_FLAG}=hosted" or omit it.`);
954
+ if (declared === "local-only" || declared === "hosted") return declared;
955
+ throw new Error(`${HOSTED_MODE_FLAG}=${String(declared)} is not a run mode. Expected "hosted" or "local-only". Falling back to a default here would let a mistyped flag look like a deliberate choice.`);
956
+ }
957
+ //#endregion
888
958
  //#region src/engine/run-identity.ts
889
959
  /**
890
960
  * Run Identity (product-gap master plan §7, plan 1789009691197 R4c) —
@@ -925,6 +995,7 @@ function buildRunIdentity(input) {
925
995
  if (input.commit !== void 0) verdictInputs.commit = input.commit;
926
996
  if (input.tree !== void 0) verdictInputs.tree = input.tree;
927
997
  if (input.lockfile !== void 0) verdictInputs.lockfile = input.lockfile;
998
+ if (input.runMode !== void 0) verdictInputs.runMode = input.runMode;
928
999
  if (input.candidate !== void 0) verdictInputs.candidate = {
929
1000
  manifestId: input.candidate.manifestId,
930
1001
  state: input.candidate.state,
@@ -957,6 +1028,7 @@ function buildRunIdentity(input) {
957
1028
  rulesDigest,
958
1029
  configFingerprint,
959
1030
  engineVersion: input.engineVersion,
1031
+ ...input.runMode !== void 0 ? { runMode: input.runMode } : {},
960
1032
  boundLinks,
961
1033
  ...input.trustModelVersion !== void 0 ? { trustModelVersion: input.trustModelVersion } : {},
962
1034
  ...input.scoringModelVersion !== void 0 ? { scoringModelVersion: input.scoringModelVersion } : {},
@@ -1359,10 +1431,50 @@ function readCandidateBinding(root, manifestPath = CANDIDATE_MANIFEST_PATH) {
1359
1431
  * version inside scan-pipeline WITHOUT importing cli.ts (a cycle — cli
1360
1432
  * imports the pipeline). The literal follows the same discipline as
1361
1433
  * 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
1434
+ * scripts/archive/sync-sarif-version.cjs (archived; superseded by the release
1435
+ * workflow's own version stamping) and guarded by the version-consistency
1363
1436
  * spec. cli.ts re-exports this as CLI_VERSION.
1364
1437
  */
1365
- const ENGINE_VERSION = "5.0.0";
1438
+ const ENGINE_VERSION = "6.0.0";
1439
+ resolveBuildId();
1440
+ /**
1441
+ * The decision, with the process boundary already behind `runGit`.
1442
+ *
1443
+ * Separated so every branch is reachable from a test: a runner that throws and
1444
+ * a runner that answers are both things that happen, and both are what this
1445
+ * function exists to survive. What it must never do is invent an answer.
1446
+ */
1447
+ function buildIdFrom(runGit) {
1448
+ const sha = runGit([
1449
+ "rev-parse",
1450
+ "--short=12",
1451
+ "HEAD"
1452
+ ]);
1453
+ if (!sha) return void 0;
1454
+ const status = runGit(["status", "--porcelain"]);
1455
+ if (status === void 0) return `${sha}-dirty`;
1456
+ return status ? `${sha}-dirty` : sha;
1457
+ }
1458
+ function resolveBuildId() {
1459
+ const git = resolveGitPath();
1460
+ if (!git) return void 0;
1461
+ const runGit = (args) => {
1462
+ try {
1463
+ return execFileSync(git, args, {
1464
+ encoding: "utf8",
1465
+ timeout: 2e3,
1466
+ stdio: [
1467
+ "ignore",
1468
+ "pipe",
1469
+ "ignore"
1470
+ ]
1471
+ }).trim();
1472
+ } catch {
1473
+ return;
1474
+ }
1475
+ };
1476
+ return buildIdFrom(runGit);
1477
+ }
1366
1478
  //#endregion
1367
1479
  //#region src/engine/contract-versions.ts
1368
1480
  /**
@@ -1374,7 +1486,15 @@ const ENGINE_VERSION = "5.0.0";
1374
1486
  * surface without widening.
1375
1487
  */
1376
1488
  const TRUST_MODEL_VERSION = "1.0.0";
1377
- const SCORING_MODEL_VERSION = "1.0.0";
1489
+ /**
1490
+ * 2.0.0 (ADR 0014): the core tier carries an evidence FLOOR. A core rule's
1491
+ * finding is now stamped to at least E1, which changes `deductionFor` for
1492
+ * every core finding from 0 (E0) to `floor(base/2)`. The E0→E1→E2 ladder
1493
+ * itself is unchanged; what changed is which rung a core finding is allowed
1494
+ * to sit on, and therefore what it costs. Major, per the `semver + ADR
1495
+ * required` policy on the entry below.
1496
+ */
1497
+ const SCORING_MODEL_VERSION = "2.0.0";
1378
1498
  const FRAMEWORK_SUPPORT_MATRIX_VERSION = "1.0.0";
1379
1499
  //#endregion
1380
1500
  //#region src/discovery/evidence-discovery.ts
@@ -1421,6 +1541,11 @@ const EVIDENCE_CONVENTIONS = [
1421
1541
  convention: "junit-file",
1422
1542
  kind: "file",
1423
1543
  names: ["junit-report.xml", "junit.xml"]
1544
+ },
1545
+ {
1546
+ convention: "dotnet-trx",
1547
+ kind: "dir",
1548
+ names: ["TestResults"]
1424
1549
  }
1425
1550
  ];
1426
1551
  const SEARCH_DEPTH = 2;
@@ -1772,6 +1897,108 @@ function applySeverityOverrides(findings, cfg) {
1772
1897
  }
1773
1898
  }
1774
1899
  //#endregion
1900
+ //#region src/lib/compare.ts
1901
+ /**
1902
+ * Order that does not depend on who is asking.
1903
+ *
1904
+ * `String.prototype.localeCompare` with no locale argument resolves against
1905
+ * the PROCESS's default locale — the ICU collation chosen by `LANG`,
1906
+ * `LC_ALL` or the OS. So a sort written as
1907
+ * `[...xs].sort((a, b) => a.localeCompare(b))` produces a different order on
1908
+ * a developer's `de_DE` machine than on an en-US CI runner. For a display
1909
+ * list that is a nuisance; for anything that reaches a hash, a cache key, a
1910
+ * digest or a machine contract it is a correctness bug, because the same
1911
+ * repository then produces two different fingerprints.
1912
+ *
1913
+ * Two comparators, one per kind of site:
1914
+ *
1915
+ * compareCodePoints — pure UTF-16 code-unit order. Identical everywhere,
1916
+ * independent of ICU version, and already what `Array.prototype.sort`
1917
+ * defaults to. Use for every value that reaches a fingerprint, a digest,
1918
+ * a cache key, or a contract, and for any ordering with no human-facing
1919
+ * reason to be pretty.
1920
+ *
1921
+ * compareLocalized(locale) — the same collator, PINNED to a named locale
1922
+ * instead of inheriting the environment, for a surface that genuinely
1923
+ * wants readable order (a table of rule titles, a Mermaid legend). Pinned
1924
+ * rather than removed because a reader-facing list of "Test / Logic /
1925
+ * Mock" should not be ordered by code unit.
1926
+ *
1927
+ * A bare `localeCompare` with no second argument in `src/` is a defect and
1928
+ * `tests/contract/deterministic-ordering.spec.ts` fails the build on it.
1929
+ */
1930
+ /** Code-unit order. Locale-free, ICU-free, and the Array.sort default. */
1931
+ function compareCodePoints(a, b) {
1932
+ if (a === b) return 0;
1933
+ return a < b ? -1 : 1;
1934
+ }
1935
+ /**
1936
+ * A readable-order comparator pinned to `locale`.
1937
+ *
1938
+ * The pin is the point: `a.localeCompare(b)` reads the environment, and the
1939
+ * environment is a machine setting, not a property of the code. Callers that
1940
+ * want stable output ask for a named locale and get the same answer on every
1941
+ * machine that ships the same ICU data.
1942
+ */
1943
+ function compareLocalized(locale) {
1944
+ return (a, b) => a.localeCompare(b, locale);
1945
+ }
1946
+ //#endregion
1947
+ //#region src/lib/glob.ts
1948
+ /**
1949
+ * The shared segment compiler: a glob to a regex BODY, with no anchors.
1950
+ *
1951
+ * Module-private because the anchoring is a caller decision and the DIALECT is
1952
+ * not: the repository needs a full-path match and a bare-name-at-any-depth
1953
+ * match, and duplicating the segment walk to get the second one is exactly how
1954
+ * two compilers came to disagree. Callers pick an anchoring; the dialect is
1955
+ * decided here and nowhere else.
1956
+ *
1957
+ * `?`, `[`, `]`, `!` and every other regex metacharacter in the literal parts
1958
+ * are escaped, so nothing a user writes in a config or ledger can reach the
1959
+ * regex engine as syntax. `*` is the only metacharacter.
1960
+ */
1961
+ function globBody(glob) {
1962
+ const segments = glob.replaceAll("\\", "/").split("/");
1963
+ let re = "";
1964
+ for (const [i, segment] of segments.entries()) {
1965
+ const last = i === segments.length - 1;
1966
+ if (segment === "**") {
1967
+ re += last ? "(?:[^/]+/)*[^/]+" : "(?:[^/]+/)*";
1968
+ continue;
1969
+ }
1970
+ re += segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", "[^/]*");
1971
+ if (!last) re += "/";
1972
+ }
1973
+ return re;
1974
+ }
1975
+ /** A glob as a regex anchored to the WHOLE path. */
1976
+ function anchoredGlobRegExp(glob) {
1977
+ return new RegExp(`^${globBody(glob)}$`);
1978
+ }
1979
+ /**
1980
+ * A glob as a regex matching at ANY DEPTH — a bare name.
1981
+ *
1982
+ * `node_modules` becomes "a file or directory of this name anywhere", which is
1983
+ * gitignore semantics: every path inside the directory contains the segment, so
1984
+ * a directory match carries to its contents.
1985
+ */
1986
+ function anyDepthGlobRegExp(glob) {
1987
+ return new RegExp(`(?:^|/)${globBody(glob)}(?:/|$)`);
1988
+ }
1989
+ /**
1990
+ * Whether `path` matches `glob`, in the dialect documented above.
1991
+ *
1992
+ * Both sides are normalized to forward slashes first. Finding paths are already
1993
+ * normalized by the walker, but a suppression `files` pattern written on
1994
+ * Windows ("e2e\\x.spec.ts") compiled to a literal-backslash regex that could
1995
+ * never match any finding — the suppression silently never applied, which is
1996
+ * the defect that made the whole dialect worth unifying.
1997
+ */
1998
+ function pathMatchesGlob(path, glob) {
1999
+ return anchoredGlobRegExp(glob).test(path.replaceAll("\\", "/"));
2000
+ }
2001
+ //#endregion
1775
2002
  //#region src/discovery/ignores.ts
1776
2003
  /**
1777
2004
  * Ignore resolution (Product-MVP.txt R1 — Tier-1 launch blocker).
@@ -1906,32 +2133,12 @@ function compilePattern(raw) {
1906
2133
  if (pattern.trim() === "") return null;
1907
2134
  if (pattern.length > LIMITS$1.maxPatternLength) return null;
1908
2135
  if ((pattern.match(/[*?]/g) ?? []).length > LIMITS$1.maxPatternWildcards) return null;
1909
- const re = pattern.includes("/") ? new RegExp(`^${globBody(pattern)}$`) : new RegExp(`(?:^|/)${globBody(pattern)}(?:/|$)`);
2136
+ const re = pattern.includes("/") ? anchoredGlobRegExp(pattern) : anyDepthGlobRegExp(pattern);
1910
2137
  return {
1911
2138
  negated,
1912
2139
  re
1913
2140
  };
1914
2141
  }
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
2142
  createMatcherFromPatterns([]);
1936
2143
  /**
1937
2144
  * Check if a directory is a lint-fixture shape (Phase 2 auto-detection).
@@ -2108,16 +2315,29 @@ function detectFrameworks(ws) {
2108
2315
  * Migration discipline (per the plan): rule-by-rule, golden lock must stay
2109
2316
  * byte-identical — a score shift is a regression, not an improvement.
2110
2317
  */
2111
- let project = null;
2112
2318
  /**
2113
- * The shared ts-morph project.
2319
+ * One shared Project PER SCAN keeps memory bounded while reusing the compiler's
2320
+ * program across files.
2114
2321
  *
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({
2322
+ * "Per scan" has to mean per scan. The variable lived at module scope, so it
2323
+ * was one Project per PROCESS, and two scans running concurrently shared it —
2324
+ * `parseTsFile` calls `replaceWithText` on a cached SourceFile, so scan B
2325
+ * could overwrite the text scan A was mid-rule on. ts-morph's in-memory file
2326
+ * system and language service are not reentrant, and the observable result was
2327
+ * three of four concurrent scans reporting `rulesCrashed: 4` with
2328
+ * `rules: "partial"` — different machine-contract digests from the same tree,
2329
+ * which is the one thing this product claims cannot happen.
2330
+ *
2331
+ * An AsyncLocalStorage scope is the smallest change that makes the comment
2332
+ * true: each scan's async context carries its own Project, memory stays bounded
2333
+ * per scan exactly as intended, and the context is collected when the scan's
2334
+ * promise chain is. The loose project below remains for callers outside a scan
2335
+ * (tests, one-off parses), where there is nothing to collide with.
2336
+ */
2337
+ const scanProjects = new AsyncLocalStorage();
2338
+ let looseProject = null;
2339
+ function createProject() {
2340
+ return new Project({
2121
2341
  useInMemoryFileSystem: true,
2122
2342
  skipAddingFilesFromTsConfig: true,
2123
2343
  compilerOptions: {
@@ -2126,7 +2346,28 @@ function getProject() {
2126
2346
  noEmit: true
2127
2347
  }
2128
2348
  });
2129
- return project;
2349
+ }
2350
+ /**
2351
+ * Run `fn` with its own ts-morph Project.
2352
+ *
2353
+ * Async context propagates across `await`, so the Project stays the scan's own
2354
+ * for the whole scan without threading an argument through every adapter.
2355
+ */
2356
+ function runWithScanProject(fn) {
2357
+ return scanProjects.run(createProject(), fn);
2358
+ }
2359
+ /**
2360
+ * The ts-morph Project for the current scan.
2361
+ *
2362
+ * Exported so an adapter's `dispose()` can evict the file it parsed: ts-morph
2363
+ * caches by file path, so without eviction a long scan holds every parsed
2364
+ * SourceFile until the process exits (plan V5-021).
2365
+ */
2366
+ function getProject() {
2367
+ const scoped = scanProjects.getStore();
2368
+ if (scoped !== void 0) return scoped;
2369
+ looseProject ??= createProject();
2370
+ return looseProject;
2130
2371
  }
2131
2372
  /**
2132
2373
  * Parse (or fetch from cache) the ts-morph SourceFile for a scanned file.
@@ -2251,9 +2492,16 @@ function getCodeOnlyText(file) {
2251
2492
  return file.text;
2252
2493
  }
2253
2494
  }
2254
- /** Release the shared ts-morph Project between scans to free memory. */
2495
+ /**
2496
+ * Release the loose ts-morph Project used outside a scan.
2497
+ *
2498
+ * Deliberately does NOT call `scanProjects.disable()`. Scan-scoped Projects are
2499
+ * released by garbage collection when the scan's async context ends, and
2500
+ * `disable()` would tear the store out from under a scan still running — the
2501
+ * exact corruption this scoping exists to remove.
2502
+ */
2255
2503
  function resetTsMorphProject() {
2256
- project = null;
2504
+ looseProject = null;
2257
2505
  }
2258
2506
  //#endregion
2259
2507
  //#region src/engine/code-text.ts
@@ -2707,10 +2955,18 @@ function runRuleIsolated(rule, file, onCrash) {
2707
2955
  * four lines below: PW_CONFIG_RE already accepted `cts`. The config regex knew
2708
2956
  * about the Node-native extensions; the test-file regex did not.
2709
2957
  *
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.
2958
+ * `discovery/scan-adapters.ts:isUnrecognizedSourceCandidate` counts these
2959
+ * paths as uncovered surface, so the scope accounting reports a hole if one
2960
+ * exists.
2961
+ *
2962
+ * CORRECTION: the comment this replaced said the scope accounting "had been
2963
+ * reporting the hole the whole time". It had not — that checker's regex
2964
+ * already matched `mts`/`cts`, and only `extensions` was unread. What WAS
2965
+ * true, and is what this entry now fixes, is that a field every adapter
2966
+ * declares and no code consulted is a claim the interface makes to anyone who
2967
+ * reads it. `isUnrecognizedSourceCandidate` now builds its extension set from
2968
+ * this list rather than from a second hard-coded one, which is what makes the
2969
+ * two halves of the same fact stop being able to drift.
2714
2970
  */
2715
2971
  const TEST_FILE_RE = /\.(?:test|spec)\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$|\.cy\.(?:js|jsx|ts|tsx)$/;
2716
2972
  /**
@@ -2782,7 +3038,9 @@ const typescriptAdapter = {
2782
3038
  ".js",
2783
3039
  ".jsx",
2784
3040
  ".mjs",
2785
- ".cjs"
3041
+ ".cjs",
3042
+ ".mts",
3043
+ ".cts"
2786
3044
  ],
2787
3045
  testFileGlobs: [
2788
3046
  "*.test.{js,jsx,ts,tsx,mjs,cjs}",
@@ -2993,7 +3251,11 @@ async function withParseSlot(fn) {
2993
3251
  }
2994
3252
  function grammarPath(fileName) {
2995
3253
  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)];
3254
+ const candidates = [
3255
+ join(here, "..", "..", "node_modules", "tree-sitter-wasms", "out", fileName),
3256
+ join(here, "..", "node_modules", "tree-sitter-wasms", "out", fileName),
3257
+ join(here, "..", "..", "..", "node_modules", "tree-sitter-wasms", "out", fileName)
3258
+ ];
2997
3259
  return candidates.find((p) => existsSync(p)) ?? candidates[0];
2998
3260
  }
2999
3261
  async function ensureParserInitialized() {
@@ -3073,6 +3335,7 @@ async function parseJavaAst(text) {
3073
3335
  return (await getJavaParser()).parse(text) ?? void 0;
3074
3336
  });
3075
3337
  } catch {
3338
+ recordDegradation("ast-grammar-unavailable");
3076
3339
  return;
3077
3340
  }
3078
3341
  }
@@ -3086,6 +3349,7 @@ async function parseCSharpAst(text) {
3086
3349
  return (await getCSharpParser()).parse(text) ?? void 0;
3087
3350
  });
3088
3351
  } catch {
3352
+ recordDegradation("ast-grammar-unavailable");
3089
3353
  return;
3090
3354
  }
3091
3355
  }
@@ -3101,6 +3365,7 @@ async function parsePythonAst(text) {
3101
3365
  return (await getPythonParser()).parse(text) ?? void 0;
3102
3366
  });
3103
3367
  } catch {
3368
+ recordDegradation("ast-grammar-unavailable");
3104
3369
  return;
3105
3370
  }
3106
3371
  }
@@ -3199,7 +3464,9 @@ const pythonAdapter = {
3199
3464
  if (existsSync(pyproject)) try {
3200
3465
  const text = readText$2(pyproject);
3201
3466
  if (/\[tool\.pytest/i.test(text)) frameworks.add("pytest");
3202
- } catch {}
3467
+ } catch {
3468
+ recordDegradation("python-manifest-unreadable");
3469
+ }
3203
3470
  for (const reqFile of [
3204
3471
  "requirements.txt",
3205
3472
  "requirements-dev.txt",
@@ -3211,7 +3478,9 @@ const pythonAdapter = {
3211
3478
  const text = readText$2(p);
3212
3479
  if (/^selenium\b/im.test(text)) frameworks.add("selenium");
3213
3480
  if (/^playwright\b/im.test(text)) frameworks.add("playwright");
3214
- } catch {}
3481
+ } catch {
3482
+ recordDegradation("python-manifest-unreadable");
3483
+ }
3215
3484
  }
3216
3485
  if (frameworks.size === 0) return {
3217
3486
  frameworks: [],
@@ -4293,6 +4562,22 @@ function isScopeRelevantIgnored(path) {
4293
4562
  if (!/\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml|min\.js)$/i.test(name)) return false;
4294
4563
  return isKnownTestFile(path);
4295
4564
  }
4565
+ /**
4566
+ * Every source extension any shipped adapter claims.
4567
+ *
4568
+ * This is where `LanguageAdapter.extensions` is READ. The field was declared by
4569
+ * every adapter, listed a set of extensions, and consulted by nothing in
4570
+ * `src/` — so a support claim that no code checked was still a claim the
4571
+ * interface made to every reader.
4572
+ *
4573
+ * Derived rather than hand-listed so the two halves cannot drift. A test
4574
+ * already compared the old hand-written regex against this set and found them
4575
+ * equivalent, which is the honest result: the refactor removes a second
4576
+ * source of truth rather than fixing a wrong answer. The `.mts`/`.cts` gap
4577
+ * was real but lived in `extensions`, not here, and is now closed there.
4578
+ */
4579
+ const ADAPTER_CLAIMED_EXTENSIONS = new Set(SCAN_ADAPTERS.flatMap((adapter) => adapter.extensions).map((ext) => ext.replace(/^\./, "").toLowerCase()));
4580
+ /** Whether ignoring this file would have removed it from the scanned surface. */
4296
4581
  function isUnrecognizedSourceCandidate(path) {
4297
4582
  const normalized = path.replaceAll("\\", "/");
4298
4583
  const name = normalized.split("/").pop() ?? normalized;
@@ -4307,8 +4592,9 @@ function isUnrecognizedSourceCandidate(path) {
4307
4592
  "tsconfig",
4308
4593
  "jsconfig"
4309
4594
  ].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;
4595
+ if (!(/(?:^|\/)__tests__\//i.test(normalized) || /\.(?:spec|test)\.[cm]?[jt]sx?$/i.test(name))) return false;
4596
+ const extension = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
4597
+ return ADAPTER_CLAIMED_EXTENSIONS.has(extension);
4312
4598
  }
4313
4599
  /** Whether ANY shipped adapter would discover this path as a test file. */
4314
4600
  function isKnownTestFile(path) {
@@ -5498,7 +5784,7 @@ const ASYNC_PW_MATCHERS = /* @__PURE__ */ new Set([
5498
5784
  ]);
5499
5785
  const unawaitedLocatorAssertion = defineRule({
5500
5786
  id: "QA-PW-002",
5501
- tier: "core",
5787
+ tier: "extended",
5502
5788
  category: "QA-PW",
5503
5789
  title: "Unawaited Playwright assertion",
5504
5790
  severity: "error",
@@ -5612,7 +5898,7 @@ const committedDebugArtifacts = defineRule({
5612
5898
  detail: "page.pause() and test.only() are exact Playwright runner tokens; the detector matches the member-call identifiers on the code-only text — closed token set, unique to the defect"
5613
5899
  },
5614
5900
  introduced: "0.1.0",
5615
- tier: "core",
5901
+ tier: "extended",
5616
5902
  run(ctx) {
5617
5903
  const text = ctx.codeText ?? ctx.text;
5618
5904
  const findings = [];
@@ -7438,7 +7724,7 @@ const pyHardSleep = defineRule({
7438
7724
  */
7439
7725
  const pySkippedTest = defineRule({
7440
7726
  id: "QA-PY-002",
7441
- tier: "core",
7727
+ tier: "extended",
7442
7728
  category: "QA-TEST",
7443
7729
  title: "Skipped test",
7444
7730
  severity: "warning",
@@ -7574,7 +7860,7 @@ const pyFocusedTest = defineRule({
7574
7860
  detail: "pytest.skip/xfail/parametrize marks are runner decorators and module-level calls; the detector matches those exact tokens on the code-only text — the semantics are runner skip state"
7575
7861
  },
7576
7862
  introduced: "0.3.0",
7577
- tier: "core",
7863
+ tier: "extended",
7578
7864
  suiteInvalidating: true,
7579
7865
  run(ctx) {
7580
7866
  const text = ctx.text;
@@ -7818,26 +8104,65 @@ function walk(node, visit) {
7818
8104
  for (const child of node.children) walk(child, visit);
7819
8105
  }
7820
8106
  /**
8107
+ * The `pytest.raises` call that is a DIRECT context manager of `withNode`.
8108
+ *
8109
+ * Scopes the search to the with's own item list — `with_clause` for the
8110
+ * plain form, and `parenthesized_with_items` for `with (A, B):` — rather
8111
+ * than to the whole subtree. A call found deeper down belongs to an inner
8112
+ * `with_statement`, which the walk reaches in its own right and which owns
8113
+ * the block that actually gates it.
8114
+ */
8115
+ function directRaisesItem(withNode) {
8116
+ let found;
8117
+ const scan = (n) => {
8118
+ if (found) return;
8119
+ if (n.type === "call") {
8120
+ const fn = n.childForFieldName("function");
8121
+ if (fn && /(?:^|\.)raises$/.test(fn.text)) {
8122
+ found = n;
8123
+ return;
8124
+ }
8125
+ }
8126
+ for (const child of n.children) scan(child);
8127
+ };
8128
+ for (const child of withNode.children) if (child.type === "with_clause" || child.type === "parenthesized_with_items") scan(child);
8129
+ return found;
8130
+ }
8131
+ /**
7821
8132
  * Every `with pytest.raises(...)` (optionally `... as exc`) statement in
7822
8133
  * the tree, with the structural facts the rework needs. Tree-sitter-
7823
8134
  * python grammar: a `with_statement` wraps `with_item`s and a `block`;
7824
8135
  * the raises call is a `call` whose function is an attribute chain
7825
8136
  * ending in `.raises`.
8137
+ *
8138
+ * 6.0. The raises call is now required to be a DIRECT context manager of the
8139
+ * `with_statement` being measured. It used to be found by walking the whole
8140
+ * subtree, so a `with pytest.raises(...)` nested inside another `with`
8141
+ * handed back the OUTER block's statement count — and `QA-PY-007`'s
8142
+ * single-statement gate, which exists precisely to suppress that shape,
8143
+ * could never fire for nested code.
8144
+ *
8145
+ * That is not a rare shape. In the pinned `pytest-dev/pytest` corpus, seven
8146
+ * of the eleven adjudicated findings were single-statement raises nested
8147
+ * inside `with saved_fd(1):`, and every one of them was counted as
8148
+ * multi-statement because the surrounding block has a dozen statements. The
8149
+ * rule measured the wrong block, which is why it measured 75% false
8150
+ * positives while its own unit fixtures passed.
8151
+ *
8152
+ * Requiring a direct context manager also fixes the shape by construction:
8153
+ * the outer `with` is examined, has no direct raises item, and is skipped;
8154
+ * the inner one is examined with its own block.
7826
8155
  */
7827
8156
  function pythonWithRaisesBlocks(tree) {
7828
8157
  const out = [];
7829
8158
  const root = tree.rootNode;
7830
8159
  walk(root, (node) => {
7831
8160
  if (node.type !== "with_statement") return;
7832
- let raisesCall;
7833
8161
  let blockNode;
7834
8162
  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;
8163
+ if (!blockNode) return;
8164
+ const raisesCall = directRaisesItem(node);
8165
+ if (!raisesCall) return;
7841
8166
  const stmts = blockNode.namedChildren.filter((c) => c.type !== "comment");
7842
8167
  const exceptionText = (raisesCall.childForFieldName("arguments")?.namedChildren[0])?.text ?? "";
7843
8168
  const broadException = BROAD_EXC_RE.test(exceptionText);
@@ -7883,7 +8208,7 @@ const pyRaisesWithoutMatch = defineRule({
7883
8208
  },
7884
8209
  introduced: "0.3.0",
7885
8210
  tier: "quarantine",
7886
- detectorRevision: 4,
8211
+ detectorRevision: 5,
7887
8212
  run(ctx) {
7888
8213
  const findings = [];
7889
8214
  if (!ctx.path.endsWith(".py")) return findings;
@@ -7942,16 +8267,17 @@ function matchParen$1(text, open) {
7942
8267
  return -1;
7943
8268
  }
7944
8269
  /**
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.
8270
+ * AST arm (detectorRevision 5 — P6 rework, widened on adjudicated evidence):
8271
+ * candidates come from the parsed `with pytest.raises(...)` statements, and
8272
+ * the raises call must be a DIRECT context manager of the with whose block
8273
+ * is measured (see `pythonWithRaisesBlocks` for why).
8274
+ *
8275
+ * The gate: a block with fewer than two statements is exempt. That covers
8276
+ * the adjudicated FP core — pytest-dev/pytest nests its capture tests inside
8277
+ * `with saved_fd(1):`, and every one of those was scored as multi-statement
8278
+ * until the block resolution was fixed. A broad root type (Exception /
8279
+ * BaseException / …Group) still fires on a MULTI-statement block, where an
8280
+ * earlier line can raise it before the intended one.
7955
8281
  */
7956
8282
  function astArm(ctx, tree) {
7957
8283
  const raw = ctx.text;
@@ -7965,7 +8291,7 @@ function astArm(ctx, tree) {
7965
8291
  const tail = raw.slice(block.callEnd, block.callEnd + 2400);
7966
8292
  if (new RegExp(`\\b${name}\\b`).test(tail)) continue;
7967
8293
  }
7968
- if (block.blockStatements < 2 && !block.broadException) continue;
8294
+ if (block.blockStatements < 2) continue;
7969
8295
  findings.push({
7970
8296
  severity: "warning",
7971
8297
  confidence: "medium",
@@ -8008,7 +8334,7 @@ const pyCommentedOutTest = defineRule({
8008
8334
  },
8009
8335
  detectionNotes: "regex heuristic",
8010
8336
  introduced: "0.3.0",
8011
- tier: "core",
8337
+ tier: "extended",
8012
8338
  run(ctx) {
8013
8339
  const findings = [];
8014
8340
  if (!ctx.path.endsWith(".py")) return findings;
@@ -8057,13 +8383,18 @@ const pyBareTruthinessAssert = defineRule({
8057
8383
  },
8058
8384
  introduced: "0.3.0",
8059
8385
  tier: "quarantine",
8060
- detectorRevision: 3,
8386
+ detectorRevision: 4,
8061
8387
  run(ctx) {
8062
8388
  const text = ctx.codeText ?? ctx.text;
8063
8389
  const findings = [];
8064
8390
  if (!ctx.path.endsWith(".py")) return findings;
8065
8391
  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*\()/;
8392
+ /**
8393
+ * Constants. `assert False` / `assert None` are deliberate mechanisms —
8394
+ * a test that manufactures an exception to assert on its shape — not a
8395
+ * truthiness check whose wrong value would slip through.
8396
+ */
8397
+ const isConstant = (target) => /^(?:True|False|None|NotImplemented|Ellipsis)$/.test(target) || /^["'\d]/.test(target);
8067
8398
  const isGuardFollowedByRealUse = (text, matchIndex, target) => {
8068
8399
  const lineEnd = text.indexOf("\n", matchIndex);
8069
8400
  if (lineEnd === -1) return false;
@@ -8083,8 +8414,9 @@ const pyBareTruthinessAssert = defineRule({
8083
8414
  let m;
8084
8415
  while ((m = re.exec(text)) !== null) {
8085
8416
  const target = m[1];
8417
+ if (target.includes("(")) continue;
8418
+ if (isConstant(target)) continue;
8086
8419
  if (/^(?:is|has|can|should|was|were)_/.test(target)) continue;
8087
- if (predicateRe.test(target)) continue;
8088
8420
  if (/\.called$/.test(target)) continue;
8089
8421
  if (isGuardFollowedByRealUse(text, m.index, target)) continue;
8090
8422
  findings.push({
@@ -8130,7 +8462,7 @@ const pyMutableFixture = defineRule({
8130
8462
  detail: "pytest fixture mutation is fixture-lifecycle semantics (autouse/scope keys plus mutation calls); the detector matches the runner's fixture decorator tokens plus the mutation shapes"
8131
8463
  },
8132
8464
  introduced: "0.3.0",
8133
- tier: "core",
8465
+ tier: "extended",
8134
8466
  run(ctx) {
8135
8467
  const text = ctx.text;
8136
8468
  const findings = [];
@@ -8164,7 +8496,7 @@ const pyMutableFixture = defineRule({
8164
8496
  */
8165
8497
  const pwWaitForTimeout = defineRule({
8166
8498
  id: "QA-PW-101",
8167
- tier: "core",
8499
+ tier: "extended",
8168
8500
  category: "QA-PW",
8169
8501
  title: "Hard sleep via waitForTimeout",
8170
8502
  severity: "error",
@@ -8285,7 +8617,7 @@ const pwDeepFrameLocator = defineRule({
8285
8617
  detail: "frameLocator chaining depth is an exact Playwright token sequence; the detector matches the frameLocator call chains — the token sequence is closed and unique to the defect"
8286
8618
  },
8287
8619
  introduced: "0.3.0",
8288
- tier: "core",
8620
+ tier: "extended",
8289
8621
  run(ctx) {
8290
8622
  const text = ctx.codeText ?? ctx.text;
8291
8623
  const findings = [];
@@ -8394,7 +8726,7 @@ const pwConfigRetryAbuse = defineRule({
8394
8726
  },
8395
8727
  detectionNotes: "regex heuristic",
8396
8728
  introduced: "0.3.0",
8397
- tier: "core",
8729
+ tier: "extended",
8398
8730
  run(ctx) {
8399
8731
  const text = ctx.codeText ?? ctx.text;
8400
8732
  const findings = [];
@@ -8412,7 +8744,7 @@ const pwConfigRetryAbuse = defineRule({
8412
8744
  column: colAt(text, m.index),
8413
8745
  message: `retries: ${m[1]} — a flaky test gets ${m[1]} chances to pass by luck.`,
8414
8746
  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`)."
8747
+ fix: "Keep retries <= 2 and route repeat offenders into the evidence path (`explain --evidence`)."
8416
8748
  });
8417
8749
  const workersRe = /workers\s*:\s*0\b/g;
8418
8750
  while ((m = workersRe.exec(text)) !== null) findings.push({
@@ -8569,7 +8901,7 @@ const pwTrialMisuse = defineRule({
8569
8901
  detail: "the trial-click shape is an exact Playwright API token pair; the detector matches the call identifier on the code-only text — the token is closed and unique to the defect"
8570
8902
  },
8571
8903
  introduced: "0.3.0",
8572
- tier: "core",
8904
+ tier: "extended",
8573
8905
  run(ctx) {
8574
8906
  const text = ctx.codeText ?? ctx.text;
8575
8907
  const findings = [];
@@ -8774,7 +9106,7 @@ const qaPw140 = defineRule({
8774
9106
  detail: "the detector matches a closed, exact runner API token on the code-only text; the token identifies the defect uniquely"
8775
9107
  },
8776
9108
  introduced: "0.3.0",
8777
- tier: "core",
9109
+ tier: "extended",
8778
9110
  run(ctx) {
8779
9111
  const text = ctx.codeText ?? ctx.text;
8780
9112
  const findings = [];
@@ -8989,7 +9321,7 @@ const pwRetryMaskingNoForensics = defineRule({
8989
9321
  column: colAt(text, m.index),
8990
9322
  message: `retries: ${m[1]} with no visible flake-triage loop.`,
8991
9323
  why: "Retries convert intermittent failures into silent passes. Without a forensics/triage step consuming retry data, flaky tests pass forever and real regressions hide behind lucky reruns.",
8992
- fix: "Keep retries <= 2 and feed retry outcomes into `mjolnir forensics`/`triage`, or add a reporter so flaky passes are reviewed."
9324
+ fix: "Keep retries <= 2 and feed retry outcomes into the evidence path (`explain --evidence`), or add a reporter so flaky passes are reviewed."
8993
9325
  });
8994
9326
  }
8995
9327
  return findings;
@@ -9384,7 +9716,7 @@ const pyPwWaitForTimeout = defineRule({
9384
9716
  detail: "page.waitForTimeout is an exact Playwright token in Python tests; the detector matches the call identifier — closed token, same predicate the AST would encode"
9385
9717
  },
9386
9718
  introduced: "0.3.8",
9387
- tier: "core",
9719
+ tier: "extended",
9388
9720
  run(ctx) {
9389
9721
  const text = ctx.codeText ?? ctx.text;
9390
9722
  const findings = [];
@@ -10269,7 +10601,7 @@ function extractPythonModel(file) {
10269
10601
  */
10270
10602
  const jvWaitForTimeout = defineRule({
10271
10603
  id: "QA-JV-105",
10272
- tier: "core",
10604
+ tier: "extended",
10273
10605
  category: "QA-PW",
10274
10606
  title: "waitForTimeout hard sleep",
10275
10607
  severity: "warning",
@@ -10338,7 +10670,7 @@ const jvWaitForTimeout = defineRule({
10338
10670
  */
10339
10671
  const csSkippedTest = defineRule({
10340
10672
  id: "QA-CS-101",
10341
- tier: "core",
10673
+ tier: "extended",
10342
10674
  category: "QA-PW",
10343
10675
  title: "Skipped test",
10344
10676
  severity: "warning",
@@ -10533,7 +10865,7 @@ const csNoAssertions = defineRule({
10533
10865
  detectionStrategy: "AST",
10534
10866
  detectionNotes: "L2 tree-sitter test-method scoping + structural assertion oracle (Shouldly/Verify/Assert receivers, throwing waits); regex fallback when no parse",
10535
10867
  introduced: "0.3.8",
10536
- tier: "core",
10868
+ tier: "extended",
10537
10869
  detectorRevision: 2,
10538
10870
  astQuery: cs103AstQuery,
10539
10871
  run(ctx) {
@@ -11259,7 +11591,7 @@ const hardSleepFamily = definePatternFamily({
11259
11591
  "mstest",
11260
11592
  "playwright"
11261
11593
  ],
11262
- tier: "core",
11594
+ tier: "extended",
11263
11595
  detectionStrategy: "AST",
11264
11596
  detectionNotes: "L2 tree-sitter invocation scoping (route/expose/server delegates, infinite blocks, WhenAny races, runner payload fixtures); regex fallback when no parse",
11265
11597
  detectorRevision: 2,
@@ -11582,7 +11914,7 @@ const retryMaskingFamily = [defineRule({
11582
11914
  detail: "the retry-masking family's variants (QA-JV-109/QA-CS-109): retry masking is the runner's retry contract; the detector matches the runner's retry tokens on the code-only text"
11583
11915
  },
11584
11916
  introduced: "0.4.0",
11585
- tier: "core",
11917
+ tier: "extended",
11586
11918
  run(ctx) {
11587
11919
  const text = ctx.codeText ?? ctx.text;
11588
11920
  const findings = [];
@@ -11761,51 +12093,115 @@ function getRule(id) {
11761
12093
  return RULES.find((r) => r.id === id);
11762
12094
  }
11763
12095
  //#endregion
11764
- //#region src/lib/compare.ts
12096
+ //#region src/lib/wilson.ts
11765
12097
  /**
11766
- * Order that does not depend on who is asking.
12098
+ * 95% Wilson score interval on a proportion (plan §20.2).
11767
12099
  *
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.
12100
+ * Lives in `src/` rather than `scripts/` because a measurement's interval is
12101
+ * now part of the ENGINE's tier decision (`src/rules/measurement.ts`), not
12102
+ * only a reporting nicety for the capability matrix. `scripts/lib/wilson.ts`
12103
+ * re-exports this module, so the number a gate prints and the number the
12104
+ * engine tiers on are the same number computed once. Two implementations
12105
+ * would be two sources of truth for the same figure, which is the failure
12106
+ * mode this whole release is about.
11776
12107
  *
11777
- * Two comparators, one per kind of site:
12108
+ * The 4-dp rounding keeps generated artifacts byte-stable (see the capability
12109
+ * matrix generator's note on deterministic output).
12110
+ */
12111
+ const Z95 = 1.959963984540054;
12112
+ /** 95% Wilson score interval on fp/n (fp successes of n trials), 4 dp. */
12113
+ function wilsonInterval(fp, n) {
12114
+ if (n <= 0) return {
12115
+ ciLow: 0,
12116
+ ciHigh: 1
12117
+ };
12118
+ const p = fp / n;
12119
+ const z2 = Z95 * Z95;
12120
+ const denom = 1 + z2 / n;
12121
+ const center = (p + z2 / (2 * n)) / denom;
12122
+ const half = Z95 / denom * Math.sqrt(p * (1 - p) / n + z2 / (4 * n * n));
12123
+ const round4 = (x) => Math.round(x * 1e4) / 1e4;
12124
+ return {
12125
+ ciLow: round4(Math.max(0, center - half)),
12126
+ ciHigh: round4(Math.min(1, center + half))
12127
+ };
12128
+ }
12129
+ /** The rule's declared detector implementation revision (§07). */
12130
+ function declaredDetectorRevision(rule) {
12131
+ return rule.detectorRevision ?? 1;
12132
+ }
12133
+ /**
12134
+ * A measurement exists AND was taken against the detector revision the
12135
+ * rule declares now. A revision mismatch (stale) does NOT count — §07:
12136
+ * stale → provisional → re-measure.
12137
+ */
12138
+ function hasValidMeasurement(rule) {
12139
+ const m = MEASURED_FP[rule.id];
12140
+ return m !== void 0 && m.detectorRevision === declaredDetectorRevision(rule);
12141
+ }
12142
+ /**
12143
+ * The confidence interval of a rule's VALID measurement, or undefined when
12144
+ * it has none. A stale measurement returns undefined rather than its stale
12145
+ * interval: a number derived from an older detector is not evidence about
12146
+ * this one, and returning it would let the straddle test pass on stale data.
11778
12147
  *
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.
12148
+ * The FP count is ROUNDED, and it is not an optimization. The shipped
12149
+ * measurement records a rate and a sample count, not an integer count of false
12150
+ * positives, so `fpRate * n` is generally fractional — 10.5% of 19 is 1.995.
12151
+ * `wilsonInterval` rounds internally, so passing the raw product gave this
12152
+ * function and `scripts/core-readiness.ts` two different `ciHigh` values for
12153
+ * the same rule at the same n: the ratchet decided one tier and the
12154
+ * readiness table printed another, both derived, neither reconciled. Every
12155
+ * caller now reconstructs the count the same way.
12156
+ */
12157
+ function measurementInterval(rule) {
12158
+ if (!hasValidMeasurement(rule)) return void 0;
12159
+ const m = MEASURED_FP[rule.id];
12160
+ if (m === void 0) return void 0;
12161
+ return wilsonInterval(Math.round(m.fpRate * m.n), m.n);
12162
+ }
12163
+ /**
12164
+ * The tier the MEASUREMENT alone supports, ignoring any declared tier.
11784
12165
  *
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.
12166
+ * This is the interval floor that `effectiveTier` falls back to, extracted so
12167
+ * the tightening law can ask the question `effectiveTier` structurally cannot:
12168
+ * a declared tier may only ever hold a rule DOWN, never release it.
11790
12169
  *
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.
12170
+ * `effectiveTier` cannot answer that, because it short-circuits on
12171
+ * `rule.tier` — asking it what a rule's tier "would" be returns the declared
12172
+ * value, and a comparison built on it compares the declaration with itself.
12173
+ * The first B0 dry-run made exactly that mistake and reported zero
12174
+ * mismatches for a comparison that had never run.
12175
+ *
12176
+ * READ THIS BEFORE USING IT AS A FLOOR. Measured over all 79 live rules, this
12177
+ * function returns `extended` for 78 of them and `quarantine` for none: on
12178
+ * this corpus the interval rule is very nearly an empty rule, because
12179
+ * `ciLow >= 50%` is not reached at n = 10..80. A "declared tier may only
12180
+ * tighten" law written against THIS function is decorative — it can only ever
12181
+ * catch a rule declaring `core`, which no rule declares. It was written that
12182
+ * way first and measured at zero teeth.
12183
+ *
12184
+ * The floor with teeth is `defensibleTier`, below.
11793
12185
  */
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;
12186
+ function measurementTier(rule) {
12187
+ const interval = measurementInterval(rule);
12188
+ if (interval === void 0) return "extended";
12189
+ if (interval.ciHigh <= .1) return "core";
12190
+ if (interval.ciLow >= .5) return "quarantine";
12191
+ return "extended";
11798
12192
  }
11799
12193
  /**
11800
- * A readable-order comparator pinned to `locale`.
12194
+ * The tier a rule effectively ships as: its declared tier, or — for the
12195
+ * omitted-tier case — what its measurement's interval supports (§11.2 Step 2).
11801
12196
  *
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.
12197
+ * The declared tier is a floor that may only tighten, and the measurement is
12198
+ * what it is measured against. See `rule.ts`'s `tier` doc for the B0
12199
+ * measurement behind that, and `defensibleTier` for the floor it is measured
12200
+ * against.
11806
12201
  */
11807
- function compareLocalized(locale) {
11808
- return (a, b) => a.localeCompare(b, locale);
12202
+ function effectiveTier(rule) {
12203
+ if (rule.tier !== void 0) return rule.tier;
12204
+ return measurementTier(rule);
11809
12205
  }
11810
12206
  //#endregion
11811
12207
  //#region src/scorer/scorer.ts
@@ -12013,26 +12409,105 @@ function asUniversal(rule) {
12013
12409
  }
12014
12410
  //#endregion
12015
12411
  //#region src/engine/tier-policy.ts
12412
+ /**
12413
+ * Tier policy (audit H-1): the declared tier is authoritative over a
12414
+ * rule's severity and evidence claims.
12415
+ *
12416
+ * Quarantine-tier rules exist because their real-world false-positive
12417
+ * rate is unproven or proven bad (docs/FP-AUDIT.md records several at
12418
+ * 100% measured FP). A rule known to be wrong every single time it
12419
+ * fires must never gate CI or deduct score — regardless of the
12420
+ * severity/evidenceLevel its metadata declares. The pipeline therefore
12421
+ * caps every quarantine finding to severity=info, evidence=E0, which
12422
+ * makes it advisory by construction (isAdvisoryFinding excludes E0).
12423
+ *
12424
+ * The core tier carries the opposite bound (ADR 0014). A CAP says a tier
12425
+ * may not be stronger than this; a FLOOR says it may not be weaker. Core
12426
+ * is earned — it means the measurement cleared the false-positive
12427
+ * ceiling with enough samples to trust the interval — so a core rule
12428
+ * emitting a finding that costs nothing and blocks nothing is claiming a
12429
+ * promotion it has not cashed in. Before the floor existed, core and
12430
+ * extended were enforced identically, and reaching core changed only the
12431
+ * dedup tie-break, the M3 ceiling and the census readout: a consumer
12432
+ * could not tell them apart.
12433
+ */
12016
12434
  /** Cap applied to every finding of a quarantine-tier rule (H-1). */
12017
12435
  const QUARANTINE_CAP = {
12018
12436
  severity: "info",
12019
12437
  evidenceLevel: "E0"
12020
12438
  };
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
- }
12439
+ /**
12440
+ * Floor applied to every finding of a core-tier rule (ADR 0014).
12441
+ *
12442
+ * `E1`, not `E2`, and the reason is the difference between the two.
12443
+ * `E2` would be a claim that the finding is proven, which is a statement
12444
+ * about the code that no tier policy may make — corroboration does that,
12445
+ * at runtime, per finding. `E1` is the weaker and sufficient claim: a
12446
+ * core rule is measured, so its finding is pattern evidence rather than
12447
+ * an observation, and it is worth half the deduction.
12448
+ *
12449
+ * `E0` is what the floor exists to forbid. `isAdvisoryFinding` is
12450
+ * `level === "E0"`, so an E0 finding is simultaneously worth zero points
12451
+ * and unable to fail a gate. A rule whose false-positive rate is
12452
+ * measured under the 10% ceiling should never be able to produce one.
12453
+ */
12454
+ const CORE_FLOOR = { evidenceLevel: "E1" };
12455
+ /**
12456
+ * The policy for a tier.
12457
+ *
12458
+ * `undefined` returns an EMPTY policy, and this is a decision, not a
12459
+ * convenience. `buildUniversalRules` in `scan-pipeline.ts` populates
12460
+ * `tierByRuleId` with `effectiveTier(rule)` for every registry rule — never
12461
+ * undefined — and adds a plugin or workspace-local rule only when that rule
12462
+ * DECLARES a tier. So an absent entry means "a third-party rule that claimed no
12463
+ * tier", and it is the one population that must not inherit a trust bound.
12464
+ *
12465
+ * It used to read "undefined tier = core" in this docstring, and that sentence
12466
+ * was load-bearing in the wrong direction. Before the floor, an absent entry
12467
+ * cost nothing, so the claim was inert. With a core floor, treating undefined as
12468
+ * core would raise every plugin's E0 finding to E1 and start charging
12469
+ * third-party detections for deductions — the exact trust escalation TI-013
12470
+ * exists to prevent, introduced by the ADR written to close a trust gap. A
12471
+ * plugin that wants the core floor has to declare `tier: "core"` and be
12472
+ * reviewed for it.
12473
+ */
12474
+ function policyForTier(tier) {
12475
+ if (tier === "quarantine") return { cap: QUARANTINE_CAP };
12476
+ if (tier === "core") return { floor: CORE_FLOOR };
12477
+ return {};
12478
+ }
12479
+ /**
12480
+ * E-ladder order. "E2" > "E1" > "E0" is a claim about how much evidence a
12481
+ * finding carries, and the floor is only meaningful if "at least E1" can be
12482
+ * stated. Declared as a ladder rather than compared as strings because
12483
+ * `"E0" > "E1"` is true in JavaScript and would invert the floor.
12484
+ */
12485
+ const EVIDENCE_RANK$1 = {
12486
+ E0: 0,
12487
+ E1: 1,
12488
+ E2: 2
12489
+ };
12025
12490
  /**
12026
12491
  * In-place enforcement over a scan's findings. Idempotent; runs AFTER
12027
- * evidence stamping so it wins over rule-declared evidenceLevel.
12492
+ * evidence stamping so it wins over rule-declared evidenceLevel — for the
12493
+ * cap and the floor alike. A rule's own `evidenceLevel` in `RuleMeta` is a
12494
+ * declaration about the rule; the tier is a decision about the rule, and
12495
+ * the decision outranks the declaration.
12496
+ *
12497
+ * Idempotence: cap-then-floor is a fixed point. A finding already at the
12498
+ * cap stays at the cap, and one already at or above the floor stays put,
12499
+ * so running this twice cannot walk a finding up the ladder.
12028
12500
  */
12029
12501
  function enforceTierPolicy(findings, tierByRuleId) {
12030
12502
  for (const f of findings) {
12031
- const cap = capForTier(tierByRuleId.get(f.ruleId));
12503
+ const { cap, floor } = policyForTier(tierByRuleId.get(f.ruleId));
12032
12504
  if (cap) {
12033
12505
  f.severity = cap.severity;
12034
12506
  f.evidenceLevel = cap.evidenceLevel;
12035
12507
  }
12508
+ if (floor?.evidenceLevel === void 0) continue;
12509
+ const effective = f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence);
12510
+ if (EVIDENCE_RANK$1[effective] < EVIDENCE_RANK$1[floor.evidenceLevel]) f.evidenceLevel = floor.evidenceLevel;
12036
12511
  }
12037
12512
  }
12038
12513
  //#endregion
@@ -12221,8 +12696,8 @@ function isDeclaredByPresent(f, present, metaByRuleId) {
12221
12696
  function ranksBefore(a, b, metaByRuleId) {
12222
12697
  const ma = metaByRuleId.get(a.ruleId);
12223
12698
  const mb = metaByRuleId.get(b.ruleId);
12224
- const tierA = TIER_RANK[ma?.tier ?? "core"];
12225
- const tierB = TIER_RANK[mb?.tier ?? "core"];
12699
+ const tierA = TIER_RANK[ma?.tier ?? "extended"];
12700
+ const tierB = TIER_RANK[mb?.tier ?? "extended"];
12226
12701
  if (tierA !== tierB) return tierA < tierB;
12227
12702
  const sevA = SEVERITY_RANK[a.severity];
12228
12703
  const sevB = SEVERITY_RANK[b.severity];
@@ -12242,6 +12717,16 @@ function dropOrderBefore(a, b, metaByRuleId) {
12242
12717
  return (a.column ?? Number.MAX_SAFE_INTEGER) < (b.column ?? Number.MAX_SAFE_INTEGER);
12243
12718
  }
12244
12719
  //#endregion
12720
+ //#region src/engine/finding-identity.ts
12721
+ /**
12722
+ * Correlation identity for before/after comparison: ruleId + file +
12723
+ * message, deliberately EXCLUDING line — a source edit that shifts a
12724
+ * finding still correlates. Shared by baseline, impact, and resolution.
12725
+ */
12726
+ function findingFingerprint(f) {
12727
+ return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
12728
+ }
12729
+ //#endregion
12245
12730
  //#region src/engine/correlation-engine.ts
12246
12731
  const EVIDENCE_RANK = {
12247
12732
  E0: 0,
@@ -12286,13 +12771,31 @@ function groupByFile(findings) {
12286
12771
  return groups;
12287
12772
  }
12288
12773
  /**
12289
- * Group findings by rootCauseId (or ruleId when rootCauseId is absent)
12290
- * to detect convergent findings pointing at the same root cause.
12774
+ * Group findings by root cause, falling back to the FINDING FINGERPRINT.
12775
+ *
12776
+ * The fallback used to be `ruleId`, and that is the defect this fixes. Nothing
12777
+ * in the tree assigns `rootCauseId` — the machine contract reports it `null` on
12778
+ * every finding — so `ruleId` was the key for every group, and N independent
12779
+ * defects from one rule across N files were reported as
12780
+ *
12781
+ * CONVERGENT — N findings share root cause QA-PW-002
12782
+ *
12783
+ * which is the precise inverse of what convergence means. Convergence is the
12784
+ * claim that several findings are ONE thing; the key that produced it said
12785
+ * only "these are the same rule". A fingerprint is ruleId + file + message, so
12786
+ * two `QA-PW-002` findings in different files are two keys, two groups of one,
12787
+ * and neither is reported.
12788
+ *
12789
+ * This is also why `findingId`/`rootCauseId`/`deduplicationGroup` are not
12790
+ * wired instead: they are the same idea, and an unwired field plus a fallback
12791
+ * would be two mechanisms for one claim. When a rule declares a real root
12792
+ * cause, `rootCauseId` still wins — the field is honoured the moment anything
12793
+ * populates it.
12291
12794
  */
12292
12795
  function groupByRootCause(findings) {
12293
12796
  const groups = /* @__PURE__ */ new Map();
12294
12797
  for (const f of findings) {
12295
- const key = f.rootCauseId ?? f.ruleId;
12798
+ const key = f.rootCauseId ?? findingFingerprint(f);
12296
12799
  const list = groups.get(key) ?? [];
12297
12800
  list.push(f);
12298
12801
  groups.set(key, list);
@@ -12300,6 +12803,17 @@ function groupByRootCause(findings) {
12300
12803
  return groups;
12301
12804
  }
12302
12805
  /**
12806
+ * The identity a conclusion lists a finding under.
12807
+ *
12808
+ * `findingId` when something has populated it, else the fingerprint. Never
12809
+ * `ruleId`: the fallback used to collapse every finding from one rule onto one
12810
+ * id, so a three-finding CONVERGENT group listed the same id three times and
12811
+ * the `alreadyConvergent` set below could not tell the members apart.
12812
+ */
12813
+ function conclusionId(f) {
12814
+ return f.findingId ?? findingFingerprint(f);
12815
+ }
12816
+ /**
12303
12817
  * Correlate findings and produce conclusions WITHOUT modifying them.
12304
12818
  * Deterministic — same input always produces same output.
12305
12819
  */
@@ -12308,7 +12822,7 @@ function correlateFindings(findings) {
12308
12822
  const rootCauseGroups = groupByRootCause(findings);
12309
12823
  for (const [rootCause, group] of Array.from(rootCauseGroups.entries()).sort((a, b) => compareLocalized("en")(a[0], b[0]))) {
12310
12824
  if (group.length < 2) continue;
12311
- const findingIds = group.map((f) => f.findingId ?? f.ruleId);
12825
+ const findingIds = group.map(conclusionId);
12312
12826
  conclusions.push({
12313
12827
  conclusionType: "CONVERGENT",
12314
12828
  certainty: correlationStrength(group),
@@ -12322,9 +12836,9 @@ function correlateFindings(findings) {
12322
12836
  if (group.length < 2) continue;
12323
12837
  const alreadyConvergent = /* @__PURE__ */ new Set();
12324
12838
  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));
12839
+ const novel = group.filter((f) => !alreadyConvergent.has(conclusionId(f)));
12326
12840
  if (novel.length < 2) continue;
12327
- const findingIds = novel.map((f) => f.findingId ?? f.ruleId);
12841
+ const findingIds = novel.map(conclusionId);
12328
12842
  conclusions.push({
12329
12843
  conclusionType: "AMPLIFIED",
12330
12844
  certainty: correlationStrength(novel),
@@ -12339,7 +12853,7 @@ function correlateFindings(findings) {
12339
12853
  certainty: "STRONG",
12340
12854
  corroboration: `${corroborated.length} finding(s) with runtime corroboration`,
12341
12855
  sourceCount: corroborated.length,
12342
- findingIds: corroborated.map((f) => f.findingId ?? f.ruleId)
12856
+ findingIds: corroborated.map(conclusionId)
12343
12857
  });
12344
12858
  return conclusions;
12345
12859
  }
@@ -12581,7 +13095,25 @@ const BRAND = {
12581
13095
  auroraGreen: "#5FD6A4",
12582
13096
  auroraViolet: "#9D8CF5",
12583
13097
  steel: "#C8CBCF",
12584
- steelDim: "#8B939D"
13098
+ steelDim: "#8B939D",
13099
+ /** The teal half of the accent rule, and the gradient's near stop. */
13100
+ readingAccent: "#73C4CA",
13101
+ /** The violet half of the accent rule, and the gradient's far stop. */
13102
+ readingAccentAlt: "#A596E5",
13103
+ /** The focus ring. A non-text affordance, held to the same AA floor. */
13104
+ readingFocus: "#8CE3ED",
13105
+ /** Hover state for the mark's monochrome glyph. */
13106
+ readingAccentBright: "#85DBED",
13107
+ /** Hover state for the wordmark's ink. */
13108
+ readingAccentInk: "#E5FCFF",
13109
+ /** UNWORTHY at reading contrast — a softer red than the terminal's. */
13110
+ readingUnworthy: "#FF9790",
13111
+ /** Muted body ink on the reading surfaces. */
13112
+ readingMuted: "#C6CFDB",
13113
+ /** Dimmed ink on the reading surfaces. */
13114
+ readingDim: "#ACB9CA",
13115
+ /** The hero lede: the brightest ink below `TEXT.primary`. */
13116
+ readingLede: "#E0E6EE"
12585
13117
  };
12586
13118
  /**
12587
13119
  * Midnight iron. One ramp, four steps, darkest first.
@@ -12684,7 +13216,7 @@ const TRUST = {
12684
13216
  l5: "#5CC4E0"
12685
13217
  };
12686
13218
  SURFACE.ink950, SURFACE.ink900, SURFACE.ink850, SURFACE.ink800, BRAND.steel, BRAND.steelDim, BRAND.gold, BRAND.goldBright, BRAND.goldHot, BRAND.aurora, BRAND.auroraBright, BRAND.auroraCyan, BRAND.auroraGreen, BRAND.auroraViolet;
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;
13219
+ SCORE.trusted, SCORE.forged, SCORE.warning, SCORE.critical, STATUS.info, TEXT.onGold, STATUS.ok, EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2, TRUST.l0, TRUST.l1, TRUST.l2, TRUST.l3, TRUST.l4, TRUST.l5, BRAND.readingAccent, BRAND.readingAccentAlt, BRAND.readingFocus, BRAND.readingAccentBright, BRAND.readingAccentInk, BRAND.readingUnworthy, BRAND.readingMuted, BRAND.readingDim, BRAND.readingLede;
12688
13220
  EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2;
12689
13221
  const RUNG_MEANINGS = [
12690
13222
  "observation only",
@@ -13574,7 +14106,7 @@ function renderFlakyMd(report) {
13574
14106
  const lines = [];
13575
14107
  lines.push("# FLAKY.md");
13576
14108
  lines.push("");
13577
- lines.push(`> Generated by mjolnir forensics from ${report.source} run data.`);
14109
+ lines.push(`> Generated by Mjölnir from ${report.source} run data.`);
13578
14110
  lines.push("> A test that passes only on retry is not a passing test — it is a lucky test.");
13579
14111
  lines.push("");
13580
14112
  lines.push(`**${report.totalTests}** tests analyzed · **${report.flakyTests}** true flakes · **${report.retriedTests}** retried · **${report.failed}** failing`);
@@ -13922,19 +14454,19 @@ function parseJestJson(json) {
13922
14454
  }
13923
14455
  //#endregion
13924
14456
  //#region src/forensics/parse-junit.ts
13925
- const MAX_INPUT = 20971520;
13926
- function decodeEntities(s) {
14457
+ const MAX_INPUT$1 = 20971520;
14458
+ function decodeEntities$1(s) {
13927
14459
  return s.replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", "\"").replaceAll("&apos;", "'").replaceAll("&#39;", "'").replaceAll("&amp;", "&");
13928
14460
  }
13929
- function attr(tag, name) {
14461
+ function attr$1(tag, name) {
13930
14462
  const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
13931
14463
  const m = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*"([^"]*)"`, "i").exec(tag);
13932
- if (m?.[1] !== void 0) return decodeEntities(m[1]);
14464
+ if (m?.[1] !== void 0) return decodeEntities$1(m[1]);
13933
14465
  const m2 = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*'([^']*)'`, "i").exec(tag);
13934
- return m2?.[1] !== void 0 ? decodeEntities(m2[1]) : void 0;
14466
+ return m2?.[1] !== void 0 ? decodeEntities$1(m2[1]) : void 0;
13935
14467
  }
13936
14468
  function parseJunitXml(xml) {
13937
- if (xml.length > MAX_INPUT) return [];
14469
+ if (xml.length > MAX_INPUT$1) return [];
13938
14470
  const out = [];
13939
14471
  const startRe = /<testcase\b/gi;
13940
14472
  const closeRe = /<\/testcase\s*>/gi;
@@ -13955,9 +14487,9 @@ function parseJunitXml(xml) {
13955
14487
  inner = xml.slice(gt + 1, close.index);
13956
14488
  startRe.lastIndex = close.index + close[0].length;
13957
14489
  }
13958
- const name = attr(attrs, "name") ?? "(unnamed)";
13959
- const classname = attr(attrs, "classname") ?? "";
13960
- const timeRaw = attr(attrs, "time");
14490
+ const name = attr$1(attrs, "name") ?? "(unnamed)";
14491
+ const classname = attr$1(attrs, "classname") ?? "";
14492
+ const timeRaw = attr$1(attrs, "time");
13961
14493
  const timeSec = timeRaw !== void 0 && Number.isFinite(Number(timeRaw)) ? Number(timeRaw) : 0;
13962
14494
  const failed = /<(?:failure|error)\b/i.test(inner);
13963
14495
  const skipped = /<skipped\b/i.test(inner);
@@ -13968,16 +14500,16 @@ function parseJunitXml(xml) {
13968
14500
  while ((failMatch = failRe.exec(inner)) !== null) {
13969
14501
  const attrsStr = failMatch[1] ?? "";
13970
14502
  const msgAttr = /\smessage\s*=\s*"([^"]*)"/i.exec(attrsStr)?.[1];
13971
- if (msgAttr) errors.push(sanitizeErrorText(decodeEntities(msgAttr)));
14503
+ if (msgAttr) errors.push(sanitizeErrorText(decodeEntities$1(msgAttr)));
13972
14504
  const body = failMatch[2]?.trim();
13973
- if (body && body !== msgAttr) errors.push(sanitizeErrorText(decodeEntities(body)));
14505
+ if (body && body !== msgAttr) errors.push(sanitizeErrorText(decodeEntities$1(body)));
13974
14506
  }
13975
14507
  const selfCloseRe = /<(?:failure|error)\b([^>]*)\/>/gi;
13976
14508
  let scMatch;
13977
14509
  while ((scMatch = selfCloseRe.exec(inner)) !== null) {
13978
14510
  const scAttrs = scMatch[1] ?? "";
13979
14511
  const scMsgMatch = /\smessage\s*=\s*"([^"]*)"/i.exec(scAttrs);
13980
- if (scMsgMatch?.[1]) errors.push(sanitizeErrorText(decodeEntities(scMsgMatch[1])));
14512
+ if (scMsgMatch?.[1]) errors.push(sanitizeErrorText(decodeEntities$1(scMsgMatch[1])));
13981
14513
  }
13982
14514
  }
13983
14515
  const attempt = {
@@ -13995,6 +14527,142 @@ function parseJunitXml(xml) {
13995
14527
  return out;
13996
14528
  }
13997
14529
  //#endregion
14530
+ //#region src/forensics/parse-trx.ts
14531
+ const MAX_INPUT = 20971520;
14532
+ function decodeEntities(s) {
14533
+ return s.replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", "\"").replaceAll("&apos;", "'").replaceAll("&#39;", "'").replaceAll("&amp;", "&");
14534
+ }
14535
+ function attr(tag, name) {
14536
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14537
+ const m = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*"([^"]*)"`, "i").exec(tag);
14538
+ if (m?.[1] !== void 0) return decodeEntities(m[1]);
14539
+ const sq = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*'([^']*)'`, "i").exec(tag);
14540
+ if (sq?.[1] !== void 0) return decodeEntities(sq[1]);
14541
+ }
14542
+ /**
14543
+ * Does this document look like TRX at all?
14544
+ *
14545
+ * Shape-sniffed rather than trusted from its location, for the same reason the
14546
+ * JUnit path sniffs: a `TestResults/` directory is a convention, and a
14547
+ * convention can be wrong. A file that is not a `TestRun` must not be parsed as
14548
+ * one and produce confident nonsense.
14549
+ */
14550
+ function looksLikeTrxXml(text) {
14551
+ return /<TestRun[\s>]/i.test(text.slice(0, 4096));
14552
+ }
14553
+ /**
14554
+ * TRX durations are `HH:MM:SS.fffffff`; absent or `-` means "not timed".
14555
+ *
14556
+ * Parsed by splitting rather than by one pattern. A single regex for this has
14557
+ * to be `^(\d+):(\d{2}):(\d{2})(?:\.(\d+))?$`, and the nested quantifiers there
14558
+ * are what `security/detect-unsafe-regex` flags — correctly in general, since
14559
+ * this shape is the usual ReDoS source. Splitting removes the question rather
14560
+ * than asserting the pattern is safe today.
14561
+ */
14562
+ function parseDuration(raw) {
14563
+ if (raw === void 0) return 0;
14564
+ const text = raw.trim();
14565
+ const dot = text.indexOf(".");
14566
+ const clock = dot === -1 ? text : text.slice(0, dot);
14567
+ const fraction = dot === -1 ? "" : text.slice(dot + 1);
14568
+ const parts = clock.split(":");
14569
+ if (parts.length !== 3) return 0;
14570
+ const [h, m, s] = parts;
14571
+ if (h === void 0 || m === void 0 || s === void 0) return 0;
14572
+ if (!/^\d+$/.test(h) || !/^\d{1,2}$/.test(m) || !/^\d{1,2}$/.test(s)) return 0;
14573
+ const millis = fraction.length === 0 ? 0 : Number(fraction.slice(0, 3).padEnd(3, "0"));
14574
+ return Number(h) * 36e5 + Number(m) * 6e4 + Number(s) * 1e3 + millis;
14575
+ }
14576
+ /**
14577
+ * TRX `outcome` to a run status.
14578
+ *
14579
+ * The mapping is stated rather than inferred from a default, because the
14580
+ * dangerous direction here is a silent default: an outcome this engine has
14581
+ * never seen must not read as `passed`. Every unrecognised value maps to
14582
+ * `skipped`, which is the non-claiming direction — a skipped test contributes
14583
+ * nothing to "the suite passed" — rather than to a status that would let it.
14584
+ *
14585
+ * TRX also has outcomes that are not verdicts at all (`Warning`, `Pending`,
14586
+ * `Expired`). They are treated the same way, deliberately.
14587
+ */
14588
+ function mapTrxOutcome(outcome) {
14589
+ switch ((outcome ?? "").toLowerCase()) {
14590
+ case "passed":
14591
+ case "completed": return "passed";
14592
+ case "failed":
14593
+ case "error": return "failed";
14594
+ case "timeout": return "timedOut";
14595
+ case "aborted":
14596
+ case "interrupted": return "interrupted";
14597
+ case "notexecuted":
14598
+ case "skipped":
14599
+ case "inconclusive":
14600
+ case "notrunnable":
14601
+ case "pending": return "skipped";
14602
+ default: return "skipped";
14603
+ }
14604
+ }
14605
+ function tagOf(xml, name) {
14606
+ const out = [];
14607
+ const re = new RegExp(`<${name}\\b[^>]*>`, "gi");
14608
+ let m;
14609
+ while ((m = re.exec(xml)) !== null) {
14610
+ if (m[0] !== void 0) out.push(m[0]);
14611
+ if (out.length > 2e5) break;
14612
+ }
14613
+ return out;
14614
+ }
14615
+ /** The text of a `<Message>` child, used as the failure's error text. */
14616
+ function messageOf(text, near) {
14617
+ const window = text.slice(near, near + 2e3);
14618
+ const m = /<Message[^>]*>([\s\S]{0,1500}?)<\/Message>/i.exec(window);
14619
+ if (m?.[1] === void 0) return void 0;
14620
+ const cleaned = sanitizeErrorText(decodeEntities(m[1].replace(/<[^>]*>/g, " ")).trim());
14621
+ return cleaned.length === 0 ? void 0 : cleaned;
14622
+ }
14623
+ /**
14624
+ * Parse a TRX document into test records.
14625
+ *
14626
+ * Returns an empty array for a document that is not TRX rather than throwing:
14627
+ * discovery sniffs before parsing, and a caller that hands us a non-TRX file
14628
+ * should get "no evidence here", not an exception that aborts a whole run
14629
+ * report.
14630
+ */
14631
+ function parseTrxXml(xml) {
14632
+ if (xml.length > MAX_INPUT) return [];
14633
+ if (!looksLikeTrxXml(xml)) return [];
14634
+ const byTestId = /* @__PURE__ */ new Map();
14635
+ for (const tag of tagOf(xml, "UnitTest")) {
14636
+ const id = attr(tag, "id");
14637
+ const name = attr(tag, "name");
14638
+ if (id !== void 0 && name !== void 0) byTestId.set(id, name);
14639
+ }
14640
+ const records = [];
14641
+ const resultRe = /<UnitTestResult\b([^>]*)>([\s\S]{0,4000}?)(?=<UnitTestResult\b|<\/Results>)/gi;
14642
+ let m;
14643
+ while ((m = resultRe.exec(xml)) !== null) {
14644
+ const attrs = m[1] ?? "";
14645
+ const inner = m[2] ?? "";
14646
+ const testName = attr(attrs, "testName") ?? attr(attrs, "testId");
14647
+ if (testName === void 0) continue;
14648
+ const attempt = {
14649
+ index: 1,
14650
+ status: mapTrxOutcome(attr(attrs, "outcome")),
14651
+ durationMs: parseDuration(attr(attrs, "duration"))
14652
+ };
14653
+ const error = messageOf(inner, 0);
14654
+ records.push({
14655
+ file: "unknown",
14656
+ title: testName,
14657
+ attempts: [attempt],
14658
+ evidenceKind: "test",
14659
+ ...error === void 0 ? {} : { errors: [error] }
14660
+ });
14661
+ if (records.length > 2e5) break;
14662
+ }
14663
+ return records;
14664
+ }
14665
+ //#endregion
13998
14666
  //#region src/forensics/parse-playwright-json.ts
13999
14667
  const MAX_DEPTH = 32;
14000
14668
  function toStatus$1(raw) {
@@ -14204,7 +14872,9 @@ function parseHarJsonDetailed(json) {
14204
14872
  //#endregion
14205
14873
  //#region src/forensics/run.ts
14206
14874
  /**
14207
- * `mjolnir forensics <dir-or-file>` — runtime evidence entry point (R4).
14875
+ * Runtime evidence entry point (R4) — the library behind `explain
14876
+ * --evidence`. The `forensics` verb is retired in the v6 carve; this module
14877
+ * is what reads the run data.
14208
14878
  *
14209
14879
  * Accepts either a single report file or a directory. In a directory it
14210
14880
  * looks for, in priority order:
@@ -14404,6 +15074,13 @@ function traceArtifactName(fullPath) {
14404
15074
  return base;
14405
15075
  }
14406
15076
  function parseFile(path, text) {
15077
+ if (/\.trx$/i.test(path) || looksLikeTrxXml(text)) {
15078
+ const records = parseTrxXml(text);
15079
+ if (records.length > 0) return {
15080
+ records,
15081
+ source: "dotnet-trx"
15082
+ };
15083
+ }
14407
15084
  if (/\.xml$/i.test(path) || /^\s*<\?xml|<testsuite\b/i.test(text.slice(0, 200))) return {
14408
15085
  records: parseJunitXml(text),
14409
15086
  source: "junit-xml"
@@ -14655,7 +15332,7 @@ function loadPlugins(root, gateOpen = false) {
14655
15332
  * - Load failures degrade honestly: warning entries, never a crash;
14656
15333
  * exit codes stay frozen.
14657
15334
  *
14658
- * Drift-check: the `mjolnir rules --md` catalog is generated from the
15335
+ * Drift-check: the `mjolnir explain --list --md` catalog is generated from the
14659
15336
  * LOADED rules (core + external), so the catalog can never drift from
14660
15337
  * what actually ships — an edit to a local rule file changes the very
14661
15338
  * next catalog render (locked by tests/local-rules.spec.ts).
@@ -15001,7 +15678,7 @@ const KNOWN_RULE_IDS = new Set(RULES.map((r) => r.id));
15001
15678
  const OVERLAP_META_BY_RULE_ID = new Map(RULES.map((r, order) => {
15002
15679
  const meta = {
15003
15680
  ...r.overlapWith ? { overlapWith: r.overlapWith } : {},
15004
- ...r.tier ? { tier: r.tier } : {},
15681
+ tier: effectiveTier(r),
15005
15682
  order
15006
15683
  };
15007
15684
  return [r.id, meta];
@@ -15029,11 +15706,14 @@ async function buildUniversalRules(root, strict, opts = {}) {
15029
15706
  rules: local.rules
15030
15707
  }].flatMap((p) => p.rules.map(asUniversal));
15031
15708
  const tierByRuleId = /* @__PURE__ */ new Map();
15032
- for (const r of RULES) if (r.tier) tierByRuleId.set(r.id, r.tier);
15709
+ for (const r of RULES) tierByRuleId.set(r.id, effectiveTier(r));
15033
15710
  for (const p of plugins) for (const r of p.rules) if (r.tier) tierByRuleId.set(r.id, r.tier);
15034
15711
  for (const r of local.rules) if (r.tier) tierByRuleId.set(r.id, r.tier);
15035
15712
  let rules = [...UNIVERSAL_RULES, ...externalRules];
15713
+ const withheldRuleIds = rules.filter((r) => tierByRuleId.get(r.id) === "quarantine").map((r) => r.id).sort();
15036
15714
  if (!strict) rules = rules.filter((r) => tierByRuleId.get(r.id) !== "quarantine");
15715
+ const rulesWithheld = strict ? 0 : withheldRuleIds.length;
15716
+ const rulesApplied = rules.length;
15037
15717
  const pluginMeta = [...plugins.map((p) => ({
15038
15718
  name: p.name,
15039
15719
  rules: p.rules.length
@@ -15046,7 +15726,10 @@ async function buildUniversalRules(root, strict, opts = {}) {
15046
15726
  pluginErrors: allErrors,
15047
15727
  tierByRuleId,
15048
15728
  pluginMeta,
15049
- externalRules: local.rules
15729
+ externalRules: local.rules,
15730
+ rulesApplied,
15731
+ rulesWithheld,
15732
+ withheldRuleIds
15050
15733
  };
15051
15734
  }
15052
15735
  /** Rule-declared evidence-level overrides (Honesty Core). */
@@ -15071,35 +15754,6 @@ function fallbackWorkspace(targetAbs) {
15071
15754
  };
15072
15755
  }
15073
15756
  /**
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
15757
  * Plan §16 + WI-11: locate a runtime run report next to the scan
15104
15758
  * target, using the exact conventions the forensics ingestion already
15105
15759
  * accepts. Zero-config search over conventional artifact names at
@@ -15604,6 +16258,9 @@ function assembleScanResult(o) {
15604
16258
  parseFallbacks: o.parseFallbacks ?? 0,
15605
16259
  ...o.astFallbackFiles !== void 0 ? { astFallbackFiles: o.astFallbackFiles } : {},
15606
16260
  ...o.degradations !== void 0 ? { degradations: o.degradations } : {},
16261
+ ...o.rulesWithheld !== void 0 ? { rulesWithheld: o.rulesWithheld } : {},
16262
+ ...o.withheldRuleIds !== void 0 ? { withheldRuleIds: o.withheldRuleIds } : {},
16263
+ ...o.rulesApplied !== void 0 ? { rulesApplied: o.rulesApplied } : {},
15607
16264
  ...o.scopeInfo.degraded !== void 0 ? { scopeDegraded: o.scopeInfo.degraded } : {},
15608
16265
  ...o.runtimeIncomplete !== void 0 ? { runtimeIncomplete: o.runtimeIncomplete } : {},
15609
16266
  identityIncomplete
@@ -15618,11 +16275,15 @@ function assembleScanResult(o) {
15618
16275
  })),
15619
16276
  config: o.config ?? null,
15620
16277
  engineVersion: ENGINE_VERSION,
16278
+ runMode: resolveRunMode({
16279
+ env: {},
16280
+ flags: {}
16281
+ }),
15621
16282
  reportDigest,
15622
16283
  trustModelVersion: TRUST_MODEL_VERSION,
15623
16284
  scoringModelVersion: SCORING_MODEL_VERSION,
15624
16285
  frameworkSupportMatrixVersion: FRAMEWORK_SUPPORT_MATRIX_VERSION,
15625
- evidenceSchemaVersions: [1],
16286
+ evidenceSchemaVersions: [2],
15626
16287
  ...repository,
15627
16288
  ...candidate !== null ? { candidate } : {}
15628
16289
  });
@@ -15635,7 +16296,10 @@ function assembleScanResult(o) {
15635
16296
  });
15636
16297
  const hasTests = o.testFileCount > 0 && o.testDeclarationCount > 0;
15637
16298
  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;
16299
+ const scopeClamped = scopeReasons.length > 0 && total >= 100;
16300
+ const partialClamped = completion.partial && scopeAdjustedTotal >= 100;
16301
+ const scoreClampReason = scopeClamped ? "scope-degraded" : partialClamped ? "partial-scan" : null;
16302
+ const finalScore = hasTests ? partialClamped ? 99 : scopeAdjustedTotal : null;
15639
16303
  const result = {
15640
16304
  schemaVersion: 1,
15641
16305
  partial: completion.partial,
@@ -15674,9 +16338,11 @@ function assembleScanResult(o) {
15674
16338
  } } : {},
15675
16339
  analysisStatus: {
15676
16340
  ...completion.analysisStatus,
16341
+ coverageState: completion.coverageState,
15677
16342
  durationMs: elapsed
15678
16343
  },
15679
- scoringModelVersion: SCORING_MODEL_VERSION
16344
+ scoringModelVersion: SCORING_MODEL_VERSION,
16345
+ ...scoreClampReason !== null ? { scoreClampReason } : {}
15680
16346
  };
15681
16347
  result.trustSummary = buildTrustSummary(result, o.declarationsByFile);
15682
16348
  if (o.findings.length > 0) result.correlationConclusions = correlateFindings(o.findings);
@@ -15720,7 +16386,23 @@ function assembleScanResult(o) {
15720
16386
  * inherently async); `runRules` and every rule stay synchronous and
15721
16387
  * consume `ParsedFile.ast`. Callers await the returned promise.
15722
16388
  */
16389
+ /**
16390
+ * Run one scan inside its own ts-morph Project scope.
16391
+ *
16392
+ * The wrapper is the whole fix. ts-morph's Project caches SourceFiles by path
16393
+ * and `parseTsFile` calls `replaceWithText` on them, so a Project shared between
16394
+ * two concurrent scans lets one rewrite the file the other is mid-rule on —
16395
+ * which surfaced as three of four concurrent scans reporting
16396
+ * `rulesCrashed: 4` and `rules: "partial"`, and therefore as two different
16397
+ * machine-contract digests from one tree. See `src/engine/ts-ast.ts`.
16398
+ *
16399
+ * Async context propagates across `await`, so the scope covers the whole scan
16400
+ * without threading a Project through every adapter.
16401
+ */
15723
16402
  async function runScan(args, hooks = {}) {
16403
+ return runWithScanProject(() => runScanInProject(args, hooks));
16404
+ }
16405
+ async function runScanInProject(args, hooks = {}) {
15724
16406
  const started = Date.now();
15725
16407
  const degradationWindow = beginDegradationWindow();
15726
16408
  const requestedDuration = Number.isFinite(args.maxDurationMs) ? args.maxDurationMs : DEFAULT_MAX_DURATION_MS$1;
@@ -15747,7 +16429,7 @@ async function runScan(args, hooks = {}) {
15747
16429
  let scopeUnrecognized = 0;
15748
16430
  let parseFailed = 0;
15749
16431
  const fileProvenance = [];
15750
- const { rules: activeRules, pluginErrors, tierByRuleId: tiers, pluginMeta } = await buildUniversalRules(workspace.root, args.strict, {
16432
+ const { rules: activeRules, pluginErrors, tierByRuleId: tiers, pluginMeta, rulesApplied, rulesWithheld, withheldRuleIds } = await buildUniversalRules(workspace.root, args.strict, {
15751
16433
  ...args.enablePlugins !== void 0 ? { enablePlugins: args.enablePlugins } : {},
15752
16434
  onGateNotice: (notice) => hooks.onGateNotice ? hooks.onGateNotice(notice) : console.error(notice)
15753
16435
  });
@@ -15836,6 +16518,9 @@ async function runScan(args, hooks = {}) {
15836
16518
  parseFallbacks,
15837
16519
  astFallbackFiles,
15838
16520
  degradations: summarizeDegradations(degradationsSince(degradationWindow)),
16521
+ rulesApplied,
16522
+ rulesWithheld,
16523
+ ...withheldRuleIds.length > 0 ? { withheldRuleIds } : {},
15839
16524
  scanned,
15840
16525
  analyzed,
15841
16526
  testFiles,
@@ -15940,6 +16625,10 @@ function canonicalScanJson(result) {
15940
16625
  skippedFiles: result.analysisStatus.skippedFiles,
15941
16626
  rulesCrashed: result.analysisStatus.rulesCrashed ?? 0,
15942
16627
  parseFallbacks: result.analysisStatus.parseFallbacks ?? 0,
16628
+ coverageState: result.analysisStatus.coverageState ?? "COMPLETE",
16629
+ rulesApplied: result.analysisStatus.rulesApplied ?? null,
16630
+ rulesWithheld: result.analysisStatus.rulesWithheld ?? 0,
16631
+ degradations: result.analysisStatus.degradations ?? [],
15943
16632
  truncationReasons: result.analysisStatus.truncationReasons ?? [],
15944
16633
  reasons: result.analysisStatus.reasons ?? []
15945
16634
  },
@@ -15983,7 +16672,7 @@ function buildMachineContract(result) {
15983
16672
  };
15984
16673
  });
15985
16674
  return {
15986
- contractVersion: 1,
16675
+ contractVersion: 2,
15987
16676
  summary: {
15988
16677
  digest: `sha256:${digest}`,
15989
16678
  findings: result.findings.length,
@@ -16004,6 +16693,10 @@ function buildMachineContract(result) {
16004
16693
  ...result.analysisStatus.parseFallbacks !== void 0 ? { parseFallbacks: result.analysisStatus.parseFallbacks } : {},
16005
16694
  truncationReasons: result.analysisStatus.truncationReasons ?? [],
16006
16695
  ...result.analysisStatus.reasons !== void 0 ? { reasons: result.analysisStatus.reasons } : {},
16696
+ ...result.analysisStatus.degradations !== void 0 ? { degradations: result.analysisStatus.degradations } : {},
16697
+ ...result.analysisStatus.coverageState !== void 0 ? { coverageState: result.analysisStatus.coverageState } : {},
16698
+ ...result.analysisStatus.rulesApplied !== void 0 ? { rulesApplied: result.analysisStatus.rulesApplied } : {},
16699
+ ...result.analysisStatus.rulesWithheld !== void 0 ? { rulesWithheld: result.analysisStatus.rulesWithheld } : {},
16007
16700
  frameworkDetectionUnknown: result.frameworkDetectionUnknown,
16008
16701
  durationMs: result.analysisStatus.durationMs,
16009
16702
  ...result.scopeIntegrity !== void 0 ? { scopeIntegrity: result.scopeIntegrity } : {},
@@ -16045,7 +16738,7 @@ function explainRule(ruleId, fixturesRoot) {
16045
16738
  const rule = getRule(ruleId);
16046
16739
  if (!rule) return {
16047
16740
  ok: false,
16048
- error: `Unknown rule ID "${ruleId}". Run \`mjolnir rules\` for the full catalog.`
16741
+ error: `Unknown rule ID "${ruleId}". Run \`mjolnir explain --list\` for the full catalog.`
16049
16742
  };
16050
16743
  const window = beginDegradationWindow();
16051
16744
  const degraded = () => {
@@ -16117,16 +16810,6 @@ function explainRule(ruleId, fixturesRoot) {
16117
16810
  };
16118
16811
  }
16119
16812
  //#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
16813
  //#region src/engine/resolution.ts
16131
16814
  /** Correlation identity (v1-compatible): ruleId\0file\0message. */
16132
16815
  const fingerprint$1 = findingFingerprint;
@@ -16147,6 +16830,11 @@ function resolve$1(input) {
16147
16830
  cause: "crash",
16148
16831
  comparedAgainst
16149
16832
  };
16833
+ if (current.analysisStatus.withheldRuleIds?.includes(entry.ruleId) === true) return {
16834
+ status: "INCONCLUSIVE",
16835
+ cause: "rule-withheld",
16836
+ comparedAgainst
16837
+ };
16150
16838
  if (input.skippedFiles?.has(entry.file)) return {
16151
16839
  status: "INCONCLUSIVE",
16152
16840
  cause: "skipped",
@@ -16276,13 +16964,13 @@ function diffAgainstBaseline(result, baseline) {
16276
16964
  //#endregion
16277
16965
  //#region src/commands/verify.ts
16278
16966
  /**
16279
- * `mjolnir verify` — the agent-loop digest (product-gap-remediation
16967
+ * `mjolnir ci verify` — the agent-loop digest (product-gap-remediation
16280
16968
  * master plan P7, plan 1788853205786 — flag agent, decision 6).
16281
16969
  *
16282
- * An agent fixes findings, then runs `mjolnir verify` against the
16283
- * committed baseline (established once with `mjolnir baseline`). The
16970
+ * An agent fixes findings, then runs `mjolnir ci verify` against the
16971
+ * committed baseline (established once from a `mjolnir scan --json` run). The
16284
16972
  * digest answers exactly the loop's questions, derived from the SAME
16285
- * §15 comparison machinery as `mjolnir diff` (no second truth):
16973
+ * §15 comparison machinery as `mjolnir scan --scope changed` (no second truth):
16286
16974
  *
16287
16975
  * - RESOLVED — baseline findings no longer present, each with its
16288
16976
  * lifecycle resolution (only VERIFIED-RESOLVED is a fix claim);
@@ -16558,7 +17246,7 @@ const MCP_TOOLS = [
16558
17246
  },
16559
17247
  {
16560
17248
  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.",
17249
+ description: "Agent-loop digest (1:1 with `mjolnir ci verify`): scan the target and diff against the committed baseline. Returns resolved (per §15 lifecycle), new, unchanged (grouped by ruleId + location), and the score delta. Read-only; the same transport guardrails as every other tool.",
16562
17250
  inputSchema: {
16563
17251
  type: "object",
16564
17252
  properties: { path: {
@@ -16570,7 +17258,7 @@ const MCP_TOOLS = [
16570
17258
  },
16571
17259
  {
16572
17260
  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.",
17261
+ description: "Runtime-evidence report (1:1 with the forensics engine behind `mjolnir explain --playwright`): ingest a run report (Playwright JSON, JUnit XML, Jest/Vitest JSON, or a Playwright trace artifact) and return the ForensicsReport — per-test verdicts, retries, TRUE-FLAKE, durations. Hostile reports degrade to zero records (never a fabricated clean run). Read-only.",
16574
17262
  inputSchema: {
16575
17263
  type: "object",
16576
17264
  properties: { path: {
@@ -16594,7 +17282,7 @@ const MCP_TOOLS = [
16594
17282
  },
16595
17283
  {
16596
17284
  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.",
17285
+ description: "Playwright run summary (1:1 with `mjolnir explain --playwright`): ingest a run report and return both the ForensicsReport and the rendered run summary. Read-only.",
16598
17286
  inputSchema: {
16599
17287
  type: "object",
16600
17288
  properties: { path: {
@@ -16787,7 +17475,7 @@ async function handleToolCall(call) {
16787
17475
  id: call.id,
16788
17476
  result: {
16789
17477
  hasBaseline: false,
16790
- note: "no committed baseline at .mjolnir/baseline.json — establish the before-state with `mjolnir baseline` first"
17478
+ note: "no committed baseline at .mjolnir/baseline.json — establish the before-state with `mjolnir <target> --save-baseline`, then re-run this tool (or `mjolnir ci verify`) after the fix"
16791
17479
  }
16792
17480
  };
16793
17481
  const result = await serializeScan(() => runScan({