mjolnir-qa 3.0.0 → 4.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.
@@ -1,14 +1,14 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.mjs";
2
2
  import { createRequire } from "node:module";
3
- import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
3
+ import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
4
4
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { createHash, randomBytes } from "node:crypto";
7
+ import { execFileSync } from "node:child_process";
7
8
  import * as ts$2 from "ts-morph";
8
9
  import ts, { Project, SyntaxKind, ts as ts$1 } from "ts-morph";
9
10
  import { Language, Parser } from "web-tree-sitter";
10
11
  import { parse } from "yaml";
11
- import { execFileSync } from "node:child_process";
12
12
  import { inflateRawSync } from "node:zlib";
13
13
  //#region src/types.ts
14
14
  /** Severity ladder. Order matters for sorting and gating. */
@@ -60,6 +60,32 @@ function isValidCategory(value) {
60
60
  return value !== void 0 && RULE_CATEGORIES.includes(value);
61
61
  }
62
62
  /**
63
+ * Trust levels (Verification Trust Evolution Plan §16): the OVERALL
64
+ * trust a consumer can place in one finding, combining the static
65
+ * evidence ladder (E0–E2) with RUNTIME corroboration from a real run
66
+ * report. Exposed honestly, never overclaimed:
67
+ * L0 — observation only (E0, no runtime evidence).
68
+ * L1 — heuristic static evidence (E1), no runtime evidence.
69
+ * L2 — deterministic static evidence (E2), no runtime evidence.
70
+ * L3 — RUNTIME: the file containing this finding appeared in a real
71
+ * run report (tests in that file executed).
72
+ * L4 — RUNTIME: the specific test containing this finding was
73
+ * identified in the report and executed (its outcome is known).
74
+ * L5 — RUNTIME: the run verdict directly corroborates the DEFECT
75
+ * class (e.g. a flake-risk finding whose test actually flaked,
76
+ * retried, or timed out in the report).
77
+ * INVARIANT (structurally enforced): L3–L5 require runtime
78
+ * corroboration — a static-only finding can never claim L4/L5.
79
+ */
80
+ const TRUST_ORDER = [
81
+ "L0",
82
+ "L1",
83
+ "L2",
84
+ "L3",
85
+ "L4",
86
+ "L5"
87
+ ];
88
+ /**
63
89
  * Honest default evidence level for a finding (Honesty Core Phase 1).
64
90
  * Derivation is deterministic and conservative:
65
91
  * observation → E0 (never proof)
@@ -742,21 +768,13 @@ const MEASURED_FP = {
742
768
  * empty scan is an observation, not a proof).
743
769
  */
744
770
  function summaryTrustLevel(findings) {
745
- const order = [
746
- "L0",
747
- "L1",
748
- "L2",
749
- "L3",
750
- "L4",
751
- "L5"
752
- ];
753
771
  let best = 0;
754
772
  for (const f of findings) {
755
773
  const t = f.trustLevel ?? "L2";
756
- const idx = order.indexOf(t);
774
+ const idx = TRUST_ORDER.indexOf(t);
757
775
  if (idx > best) best = idx;
758
776
  }
759
- return order[best];
777
+ return TRUST_ORDER[best];
760
778
  }
761
779
  /**
762
780
  * A finding's trust rung (0–5) on the canonical ladder. Corroborated
@@ -767,14 +785,7 @@ function summaryTrustLevel(findings) {
767
785
  */
768
786
  function trustRung(f) {
769
787
  const t = f.trustLevel ?? deriveTrustLevel(f);
770
- return [
771
- "L0",
772
- "L1",
773
- "L2",
774
- "L3",
775
- "L4",
776
- "L5"
777
- ].indexOf(t);
788
+ return TRUST_ORDER.indexOf(t);
778
789
  }
779
790
  /**
780
791
  * Hard incompleteness ceilings (plan §6: partial/crash/truncation;
@@ -911,6 +922,17 @@ function buildRunIdentity(input) {
911
922
  engineVersion: input.engineVersion
912
923
  };
913
924
  if (input.reportDigest) verdictInputs.reportDigest = input.reportDigest;
925
+ if (input.commit !== void 0) verdictInputs.commit = input.commit;
926
+ if (input.tree !== void 0) verdictInputs.tree = input.tree;
927
+ if (input.lockfile !== void 0) verdictInputs.lockfile = input.lockfile;
928
+ if (input.candidate !== void 0) verdictInputs.candidate = {
929
+ manifestId: input.candidate.manifestId,
930
+ state: input.candidate.state,
931
+ candidateSha: input.candidate.candidateSha,
932
+ baseSha: input.candidate.baseSha,
933
+ packageSha256: input.candidate.packageSha256,
934
+ lockfileSha256: input.candidate.lockfileSha256
935
+ };
914
936
  if (input.trustModelVersion !== void 0) verdictInputs.trustModelVersion = input.trustModelVersion;
915
937
  if (input.scoringModelVersion !== void 0) verdictInputs.scoringModelVersion = input.scoringModelVersion;
916
938
  if (input.frameworkSupportMatrixVersion !== void 0) verdictInputs.frameworkSupportMatrixVersion = input.frameworkSupportMatrixVersion;
@@ -918,15 +940,31 @@ function buildRunIdentity(input) {
918
940
  if (input.suppressionFingerprint !== void 0) verdictInputs.suppressionFingerprint = input.suppressionFingerprint;
919
941
  if (input.policyFingerprint !== void 0) verdictInputs.policyFingerprint = input.policyFingerprint;
920
942
  if (input.historicalEvidenceFingerprint !== void 0) verdictInputs.historicalEvidenceFingerprint = input.historicalEvidenceFingerprint;
943
+ const scanId = sha256$1(canonical(verdictInputs));
944
+ const boundLinks = [
945
+ "input",
946
+ "rules",
947
+ "config",
948
+ "engine"
949
+ ];
950
+ if (input.commit !== void 0) boundLinks.push("commit");
951
+ if (input.tree !== void 0) boundLinks.push("tree");
952
+ if (input.lockfile !== void 0) boundLinks.push("lockfile");
953
+ if (input.candidate !== void 0) boundLinks.push("candidate");
921
954
  return {
922
- scanId: sha256$1(canonical(verdictInputs)),
955
+ scanId,
923
956
  inputFingerprint,
924
957
  rulesDigest,
925
958
  configFingerprint,
926
959
  engineVersion: input.engineVersion,
960
+ boundLinks,
927
961
  ...input.trustModelVersion !== void 0 ? { trustModelVersion: input.trustModelVersion } : {},
928
962
  ...input.scoringModelVersion !== void 0 ? { scoringModelVersion: input.scoringModelVersion } : {},
929
- ...input.frameworkSupportMatrixVersion !== void 0 ? { frameworkSupportMatrixVersion: input.frameworkSupportMatrixVersion } : {}
963
+ ...input.frameworkSupportMatrixVersion !== void 0 ? { frameworkSupportMatrixVersion: input.frameworkSupportMatrixVersion } : {},
964
+ ...input.commit !== void 0 ? { commit: input.commit } : {},
965
+ ...input.tree !== void 0 ? { tree: input.tree } : {},
966
+ ...input.lockfile !== void 0 ? { lockfile: input.lockfile } : {},
967
+ ...input.candidate !== void 0 ? { candidate: input.candidate } : {}
930
968
  };
931
969
  }
932
970
  /**
@@ -967,9 +1005,218 @@ function buildEvidenceGraph(parts) {
967
1005
  ...parts.reproduction !== void 0 ? { ref: parts.reproduction } : {}
968
1006
  }
969
1007
  ],
970
- ...parts.runId !== void 0 ? { runId: parts.runId } : {}
1008
+ ...parts.runId !== void 0 ? { runId: parts.runId } : {},
1009
+ ...parts.candidate !== void 0 ? { candidate: {
1010
+ manifestId: parts.candidate.manifestId,
1011
+ candidateSha: parts.candidate.candidateSha
1012
+ } } : {}
1013
+ };
1014
+ }
1015
+ //#endregion
1016
+ //#region src/engine/file-executor.ts
1017
+ function cacheHitOutcome(job) {
1018
+ return {
1019
+ path: job.path,
1020
+ status: "CACHE_HIT",
1021
+ findings: [...job.cacheHit ?? []],
1022
+ parseFallback: false
1023
+ };
1024
+ }
1025
+ /**
1026
+ * Drive a list of jobs through an executor and return outcomes in INPUT order.
1027
+ *
1028
+ * This is the function the pipeline calls. It is executor-agnostic on purpose:
1029
+ * swapping in a pooled executor must not require touching the caller, and the
1030
+ * ordering guarantee has to live here or it would live in each executor and
1031
+ * drift between them.
1032
+ */
1033
+ async function executeFiles(jobs, executor, context = {}) {
1034
+ const outcomes = new Array(jobs.length);
1035
+ if (executor.concurrency <= 1 || jobs.length <= 1) {
1036
+ for (let i = 0; i < jobs.length; i++) {
1037
+ const job = jobs[i];
1038
+ context.onProgress?.({
1039
+ done: i,
1040
+ total: jobs.length,
1041
+ path: job.path
1042
+ });
1043
+ outcomes[i] = await runOne(job, executor, context);
1044
+ }
1045
+ context.onProgress?.({
1046
+ done: jobs.length,
1047
+ total: jobs.length,
1048
+ path: jobs[jobs.length - 1]?.path ?? ""
1049
+ });
1050
+ return outcomes;
1051
+ }
1052
+ let next = 0;
1053
+ let completed = 0;
1054
+ const inFlight = /* @__PURE__ */ new Set();
1055
+ const limit = Math.max(1, Math.floor(executor.concurrency));
1056
+ const worker = async () => {
1057
+ for (;;) {
1058
+ const index = next++;
1059
+ if (index >= jobs.length) return;
1060
+ const job = jobs[index];
1061
+ outcomes[index] = await runOne(job, executor, context);
1062
+ completed++;
1063
+ context.onProgress?.({
1064
+ done: completed,
1065
+ total: jobs.length,
1066
+ path: job.path
1067
+ });
1068
+ }
1069
+ };
1070
+ for (let i = 0; i < Math.min(limit, jobs.length); i++) {
1071
+ const promise = worker().then(() => void 0);
1072
+ inFlight.add(promise);
1073
+ }
1074
+ await Promise.all(inFlight);
1075
+ return outcomes;
1076
+ }
1077
+ async function runOne(job, executor, context) {
1078
+ if (job.cacheHit !== void 0) return cacheHitOutcome(job);
1079
+ try {
1080
+ return await executor.execute(job, context);
1081
+ } catch (error) {
1082
+ return {
1083
+ path: job.path,
1084
+ status: "FAILED",
1085
+ findings: [],
1086
+ parseFallback: false,
1087
+ error: error instanceof Error ? error : new Error(String(error))
1088
+ };
1089
+ }
1090
+ }
1091
+ /**
1092
+ * The strategy the pipeline uses today.
1093
+ *
1094
+ * `runOne` is supplied by the pipeline because parsing and rule execution
1095
+ * need the adapter and the active rule set, which are pipeline concerns. The
1096
+ * executor's job is the BOUNDARY and the bookkeeping, not the analysis — so
1097
+ * this factory exists to bind those two without the executor importing the
1098
+ * pipeline (which would be a cycle).
1099
+ */
1100
+ function createExecutor(name, concurrency, runOne) {
1101
+ return {
1102
+ name,
1103
+ concurrency,
1104
+ execute: runOne
1105
+ };
1106
+ }
1107
+ //#endregion
1108
+ //#region src/engine/candidate-binding.ts
1109
+ /**
1110
+ * Candidate binding (plan V5-010).
1111
+ *
1112
+ * Reads a candidate trust manifest and produces the immutable
1113
+ * `CandidateBinding` that a run identity is bound to. It also computes the
1114
+ * repository facts (commit, tree, lockfile digest) that the identity was
1115
+ * missing until this module existed.
1116
+ *
1117
+ * The law here is ABSENCE over invention. A manifest that is missing,
1118
+ * unreadable, or internally contradictory yields NO binding — not a partial
1119
+ * one. A consumer that can see "not bound" can refuse to trust the run; a
1120
+ * consumer handed a half-filled binding cannot tell what is missing, and will
1121
+ * trust the parts that are there.
1122
+ */
1123
+ const CANDIDATE_MANIFEST_PATH = "candidate-trust-manifest.json";
1124
+ const SHA40 = /^[a-f0-9]{40}$/;
1125
+ const SHA256 = /^[a-f0-9]{64}$/;
1126
+ const STATES = ["WORKING_CANDIDATE", "RELEASE_CANDIDATE"];
1127
+ const AUTHORIZATION = ["NOT_AUTHORIZED", "AUTHORIZED"];
1128
+ function isRecord$1(value) {
1129
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1130
+ }
1131
+ function sha256File(path) {
1132
+ try {
1133
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
1134
+ } catch {
1135
+ return;
1136
+ }
1137
+ }
1138
+ function gitLine(root, args) {
1139
+ try {
1140
+ const trimmed = execFileSync("git", args, {
1141
+ cwd: root,
1142
+ encoding: "utf8",
1143
+ stdio: [
1144
+ "ignore",
1145
+ "pipe",
1146
+ "ignore"
1147
+ ],
1148
+ windowsHide: true
1149
+ }).trim();
1150
+ return trimmed.length > 0 ? trimmed : void 0;
1151
+ } catch {
1152
+ return;
1153
+ }
1154
+ }
1155
+ /**
1156
+ * The commit and tree the working tree is at. Both absent outside a
1157
+ * repository or with no commits — never substituted with a placeholder.
1158
+ */
1159
+ function bindRepository(root) {
1160
+ const commit = gitLine(root, ["rev-parse", "HEAD"]);
1161
+ const tree = gitLine(root, ["rev-parse", "HEAD^{tree}"]);
1162
+ const lockfile = sha256File(join(root, "package-lock.json"));
1163
+ return {
1164
+ ...commit !== void 0 ? { commit } : {},
1165
+ ...tree !== void 0 ? { tree } : {},
1166
+ ...lockfile !== void 0 ? { lockfile } : {}
1167
+ };
1168
+ }
1169
+ /**
1170
+ * Parse a candidate manifest into a binding, or return null when it cannot
1171
+ * honestly be bound.
1172
+ *
1173
+ * Null is returned for: no manifest, unreadable JSON, a missing or malformed
1174
+ * required field, or a state/authorization combination that cannot legally
1175
+ * exist (a WORKING_CANDIDATE carrying a commit, for example — the same
1176
+ * contradiction the release decision evaluator treats as BLOCKED).
1177
+ */
1178
+ function parseCandidateBinding(raw) {
1179
+ if (!isRecord$1(raw)) return null;
1180
+ const identity = raw.identity;
1181
+ if (!isRecord$1(identity)) return null;
1182
+ const state = identity.state;
1183
+ const candidateSha = identity.candidateSha;
1184
+ const baseSha = identity.baseSha;
1185
+ const packageSha256 = identity.packageSha256;
1186
+ const lockfileSha256 = identity.lockfileSha256;
1187
+ if (typeof state !== "string" || !STATES.includes(state)) return null;
1188
+ if (typeof baseSha !== "string" || !SHA40.test(baseSha)) return null;
1189
+ if (typeof packageSha256 !== "string" || !SHA256.test(packageSha256)) return null;
1190
+ if (typeof lockfileSha256 !== "string" || !SHA256.test(lockfileSha256)) return null;
1191
+ if (candidateSha !== null && (typeof candidateSha !== "string" || !SHA40.test(candidateSha))) return null;
1192
+ const manifestId = raw.manifestId;
1193
+ if (typeof manifestId !== "string" || manifestId.length === 0) return null;
1194
+ const owner = typeof raw.owner === "string" ? raw.owner : "UNASSIGNED";
1195
+ const authorization = raw.releaseAuthorizationState;
1196
+ if (typeof authorization !== "string" || !AUTHORIZATION.includes(authorization)) return null;
1197
+ if (state === "WORKING_CANDIDATE") {
1198
+ if (candidateSha !== null) return null;
1199
+ if (authorization !== "NOT_AUTHORIZED") return null;
1200
+ } else if (candidateSha === null) return null;
1201
+ return {
1202
+ manifestId,
1203
+ state,
1204
+ candidateSha,
1205
+ baseSha,
1206
+ packageSha256,
1207
+ lockfileSha256,
1208
+ owner,
1209
+ releaseAuthorizationState: authorization
971
1210
  };
972
1211
  }
1212
+ /** Read and bind the manifest from a checkout, or null. */
1213
+ function readCandidateBinding(root, manifestPath = CANDIDATE_MANIFEST_PATH) {
1214
+ try {
1215
+ return parseCandidateBinding(JSON.parse(readFileSync(join(root, manifestPath), "utf8")));
1216
+ } catch {
1217
+ return null;
1218
+ }
1219
+ }
973
1220
  //#endregion
974
1221
  //#region src/engine/version.ts
975
1222
  /**
@@ -980,7 +1227,7 @@ function buildEvidenceGraph(parts) {
980
1227
  * scripts/sync-sarif-version.cjs and guarded by the version-consistency
981
1228
  * spec. cli.ts re-exports this as CLI_VERSION.
982
1229
  */
983
- const ENGINE_VERSION = "3.0.0";
1230
+ const ENGINE_VERSION = "4.0.0";
984
1231
  //#endregion
985
1232
  //#region src/engine/contract-versions.ts
986
1233
  /**
@@ -1388,20 +1635,9 @@ function validate(cfg, knownRuleIds) {
1388
1635
  return warnings;
1389
1636
  }
1390
1637
  /**
1391
- * Bug-audit QA-2026-08-30 QA-6: the 90-day policy in the README was only
1392
- * applied at WRITE time by the `ignore` command — a hand-written entry
1393
- * without `expires` stayed active forever, silently bypassing the
1394
- * documented window.
1395
- *
1396
- * Audit S4 (remediation plan): the config-file MTIME is no longer an
1397
- * expiry anchor. Anchoring the 90-day default at mtime meant ANY edit to
1398
- * mjolnir.config.json — a reformat, an unrelated key, a `touch` — reset
1399
- * the 90-day window for EVERY hand-authored entry: suppressions could be
1400
- * extended indefinitely without touching their own fields. The expiry is
1401
- * now the entry's explicit `expires` date alone; an entry without one
1402
- * stays active and is honestly labeled "(no expiry set)" in the
1403
- * suppressions report. Hand-authored entries should declare `expires` at
1404
- * creation (README §Configuration documents the shape).
1638
+ * Expiry is evaluated only from an explicit ISO `expires` date. A missing
1639
+ * date remains active and is reported as having no expiry. Config-file mtime
1640
+ * is never an expiry anchor.
1405
1641
  */
1406
1642
  function isSuppressionActive(ign, now = /* @__PURE__ */ new Date()) {
1407
1643
  if (!ign.expires) return true;
@@ -1684,7 +1920,33 @@ function sharedWalk(options) {
1684
1920
  *
1685
1921
  * When nothing is detectable we report `unknown` and the scanner analyzes
1686
1922
  * all test-looking files — stated honestly in output rather than guessed.
1923
+ *
1924
+ * ## One ID space (plan V5-024)
1925
+ *
1926
+ * This detector used to declare its own `TestFramework` union of three
1927
+ * literals while `src/frameworks/framework-inventory.ts` catalogued fourteen
1928
+ * framework ids. Two vocabularies for one concept is how a support matrix
1929
+ * starts disagreeing with what the tool actually detects: a framework can be
1930
+ * catalogued as OFFICIAL_PARTIAL and never once be emitted by the code that
1931
+ * claims to detect it.
1932
+ *
1933
+ * So the ids here are `FrameworkId`s — the inventory's own vocabulary — and
1934
+ * `DETECTABLE_TEST_FRAMEWORKS` is the subset this detector can actually
1935
+ * resolve from a checkout. That subset is the HONEST limit of detection, and
1936
+ * `detectableVsCatalogued()` reports the remainder rather than letting a
1937
+ * three-item list read as the whole catalog.
1938
+ */
1939
+ /**
1940
+ * The catalogued frameworks whose detection is implemented today.
1941
+ *
1942
+ * Every entry must exist in the inventory — enforced by the parity spec, not
1943
+ * by comment.
1687
1944
  */
1945
+ const DETECTABLE_TEST_FRAMEWORKS = [
1946
+ "jest",
1947
+ "vitest",
1948
+ "playwright"
1949
+ ];
1688
1950
  const CONFIG_FILES = {
1689
1951
  jest: [
1690
1952
  "jest.config.ts",
@@ -1703,7 +1965,7 @@ const CONFIG_FILES = {
1703
1965
  };
1704
1966
  function detectFrameworks(ws) {
1705
1967
  const found = /* @__PURE__ */ new Set();
1706
- for (const fw of Object.keys(CONFIG_FILES)) if (CONFIG_FILES[fw].some((f) => existsSync(join(ws.root, f)))) found.add(fw);
1968
+ for (const fw of DETECTABLE_TEST_FRAMEWORKS) if (CONFIG_FILES[fw].some((f) => existsSync(join(ws.root, f)))) found.add(fw);
1707
1969
  if (!found.has("jest") && ws.packageJson["jest"] !== void 0) found.add("jest");
1708
1970
  const deps = {
1709
1971
  ...ws.packageJson["dependencies"],
@@ -1725,11 +1987,7 @@ function detectFrameworks(ws) {
1725
1987
  };
1726
1988
  }
1727
1989
  return {
1728
- frameworks: [
1729
- "jest",
1730
- "vitest",
1731
- "playwright"
1732
- ].filter((f) => found.has(f)),
1990
+ frameworks: DETECTABLE_TEST_FRAMEWORKS.filter((f) => found.has(f)),
1733
1991
  unknown: false
1734
1992
  };
1735
1993
  }
@@ -1746,6 +2004,13 @@ function detectFrameworks(ws) {
1746
2004
  * byte-identical — a score shift is a regression, not an improvement.
1747
2005
  */
1748
2006
  let project = null;
2007
+ /**
2008
+ * The shared ts-morph project.
2009
+ *
2010
+ * Exported so an adapter's `dispose()` can evict the file it parsed: ts-morph
2011
+ * caches by file path, so without eviction a long scan holds every parsed
2012
+ * SourceFile until the process exits (plan V5-021).
2013
+ */
1749
2014
  function getProject() {
1750
2015
  if (!project) project = new Project({
1751
2016
  useInMemoryFileSystem: true,
@@ -2272,7 +2537,36 @@ function frameworkFilterApplies(rule, file) {
2272
2537
  * "cypress", `@jest/globals` → "jest", `vitest` → "vitest". Config
2273
2538
  * gating is rule-declared (`configFiles`), not hard-coded here.
2274
2539
  */
2275
- const TEST_FILE_RE = /\.(?:test|spec)\.(?:js|jsx|ts|tsx|mjs|cjs)$|\.cy\.(?:js|jsx|ts|tsx)$/;
2540
+ /**
2541
+ * `.test.` / `.spec.` filenames, plus the Cypress `.cy.` convention.
2542
+ *
2543
+ * `mts` and `cts` are TypeScript's Node-native ESM/CJS extensions. They were
2544
+ * missing here, and the omission was invisible in the worst way — not a wrong
2545
+ * answer, but an ABSENCE.
2546
+ *
2547
+ * Reproduced: a repo containing two byte-identical tests, one at
2548
+ * `tests/control.spec.ts` and one at `tests/slow.spec.mts`, each with a
2549
+ * `QA-TEST-004` hard sleep:
2550
+ *
2551
+ * discovered: 1, analyzed: 1, unrecognized: 1, scopeVerdict: "PARTIAL"
2552
+ *
2553
+ * The `.ts` test produced the finding. The `.mts` test produced nothing at
2554
+ * all — it was never scanned, so every rule that could have caught it was
2555
+ * silent on it, with no finding, no low-evidence note, and no indication that
2556
+ * a test file had been skipped. A test scanner that cannot see a whole file
2557
+ * extension has a false green that is invisible by construction: the reader
2558
+ * has nothing to distrust, because there is nothing there.
2559
+ *
2560
+ * The corroboration that this was an oversight rather than a decision sits
2561
+ * four lines below: PW_CONFIG_RE already accepted `cts`. The config regex knew
2562
+ * about the Node-native extensions; the test-file regex did not.
2563
+ *
2564
+ * `discovery/scan-adapters.ts:isUnrecognizedSourceCandidate` already counted
2565
+ * these paths as uncovered surface, so the scope accounting had been reporting
2566
+ * the hole the whole time — it was being read as a known limitation rather than
2567
+ * as a defect.
2568
+ */
2569
+ const TEST_FILE_RE = /\.(?:test|spec)\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$|\.cy\.(?:js|jsx|ts|tsx)$/;
2276
2570
  /**
2277
2571
  * Fallback config list for `configOnly` rules that do not declare
2278
2572
  * `configFiles` (the legacy playwright.config.* gating, preserved
@@ -2379,11 +2673,42 @@ const typescriptAdapter = {
2379
2673
  fixtureDirMemo: /* @__PURE__ */ new Map()
2380
2674
  });
2381
2675
  },
2676
+ /**
2677
+ * The ONE AST seam (plan V5-021).
2678
+ *
2679
+ * This adapter used to parse inside `runRules`, on the synchronous path,
2680
+ * while Java and C# exposed the async `parseAst` hook. Two seams meant two
2681
+ * sets of consequences, both of them bad:
2682
+ *
2683
+ * - the pipeline computes `wantsAst` as `adapter.parseAst !== undefined`,
2684
+ * so a TypeScript file never took the AST path through the pipeline at
2685
+ * all, and its parse failures were invisible to the pipeline's
2686
+ * fallback counters;
2687
+ * - nothing called `dispose()` for the ts-morph path, so a scan held
2688
+ * every parsed SourceFile for its whole lifetime.
2689
+ *
2690
+ * Parsing now happens here, once, through the same contract every other
2691
+ * adapter uses. `dispose()` drops this file's SourceFile from the shared
2692
+ * project; ts-morph caches per file path, so removing it is what keeps
2693
+ * memory proportional to one file rather than to the whole scan.
2694
+ */
2695
+ parseAst(file) {
2696
+ const sourceFile = parseTsFile(file);
2697
+ if (sourceFile === void 0) return void 0;
2698
+ return {
2699
+ ast: sourceFile,
2700
+ dispose: () => {
2701
+ try {
2702
+ getProject().removeSourceFile(sourceFile);
2703
+ } catch {}
2704
+ }
2705
+ };
2706
+ },
2382
2707
  runRules(rules, file, emit, onCrash, budget) {
2383
- const withAst = {
2708
+ const withAst = file.ast === void 0 ? {
2384
2709
  ...file,
2385
2710
  ast: parseTsFile(file)
2386
- };
2711
+ } : file;
2387
2712
  const withTags = {
2388
2713
  ...withAst,
2389
2714
  frameworkTags: frameworkTagsFromImports(withAst.text)
@@ -3802,9 +4127,37 @@ function discoverAllTestFiles(ctx, languageAdapters, buckets, fixtureDirMemo) {
3802
4127
  }
3803
4128
  });
3804
4129
  }
4130
+ /**
4131
+ * Would ignoring this file have removed it from the scanned surface?
4132
+ *
4133
+ * The bug this fixes, reproduced: a repo containing a real test file plus
4134
+ * `vendor.min.js` and `pnpm-lock.yaml` reported
4135
+ *
4136
+ * discovered: 1, analyzed: 1, ignored: 2, scopeVerdict: "PARTIAL"
4137
+ *
4138
+ * Every discovered test file HAD been analyzed. The surface was complete. But
4139
+ * DEFAULT_IGNORES matches minified bundles and lockfiles, and the ignored
4140
+ * counter accepted any path with a source extension — so a minified bundle and
4141
+ * a lockfile downgraded a finished scan to "unverified".
4142
+ *
4143
+ * That is not a conservative bias, it is a broken signal. The terminal tells
4144
+ * the reader "some files were not analyzed, so the surface is unverified" —
4145
+ * which was false. And because minified bundles and lockfiles exist in
4146
+ * essentially every real repository, a PROVEN scan was unreachable in
4147
+ * practice, so the corpus regression guard failed 23 of 37 repositories on
4148
+ * exactly this. A gate that cannot pass teaches people to ignore it.
4149
+ *
4150
+ * The asymmetry is the tell. `isUnrecognizedSourceCandidate` below applies a
4151
+ * test-relevance test before counting a file; this one did not. Both count
4152
+ * against the same verdict, so both must ask the same question.
4153
+ *
4154
+ * An ignored file that IS a test file still downgrades the verdict. That is
4155
+ * the case the honesty law exists for, and this does not touch it.
4156
+ */
3805
4157
  function isScopeRelevantIgnored(path) {
3806
4158
  const name = path.replaceAll("\\", "/").split("/").pop() ?? path;
3807
- return /\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml|min\.js)$/i.test(name);
4159
+ if (!/\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml|min\.js)$/i.test(name)) return false;
4160
+ return isKnownTestFile(path);
3808
4161
  }
3809
4162
  function isUnrecognizedSourceCandidate(path) {
3810
4163
  const normalized = path.replaceAll("\\", "/");
@@ -7691,7 +8044,6 @@ const pyBareTruthinessAssert = defineRule({
7691
8044
  const re = /^[ \t]*assert\s+([A-Za-z_][\w.]*(?:\([^()]*\))?)[ \t]*$/gm;
7692
8045
  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*\()/;
7693
8046
  const isGuardFollowedByRealUse = (text, matchIndex, target) => {
7694
- const root = target.split(".")[0];
7695
8047
  const lineEnd = text.indexOf("\n", matchIndex);
7696
8048
  if (lineEnd === -1) return false;
7697
8049
  const lines = text.slice(lineEnd + 1).split("\n");
@@ -7701,8 +8053,11 @@ const pyBareTruthinessAssert = defineRule({
7701
8053
  if (/^\s*def\s/.test(l) && window.length > 0) break;
7702
8054
  window.push(l);
7703
8055
  }
7704
- const usesRoot = new RegExp(`\\b${root}\\b`);
7705
- return window.some((l) => usesRoot.test(l));
8056
+ const root = target.match(/^[a-z_]\w*/i)?.[0];
8057
+ if (!root) return false;
8058
+ return window.some((line) => {
8059
+ return (line.match(/[a-z_]\w*/gi) ?? []).some((token) => token === root);
8060
+ });
7706
8061
  };
7707
8062
  let m;
7708
8063
  while ((m = re.exec(text)) !== null) {
@@ -11705,6 +12060,22 @@ function normalizeOne(report, artifact, v) {
11705
12060
  function buildEvidenceRecords(report, artifact) {
11706
12061
  return report.verdicts.map((v) => normalizeOne(report, artifact, v)).sort(compareEvidenceRecords);
11707
12062
  }
12063
+ function countEvidence(records) {
12064
+ const counts = {
12065
+ total: records.length,
12066
+ failed: 0,
12067
+ flaky: 0,
12068
+ skipped: 0,
12069
+ timedOut: 0
12070
+ };
12071
+ for (const r of records) {
12072
+ if (r.status.failed) counts.failed++;
12073
+ if (r.status.passedOnRetry) counts.flaky++;
12074
+ if (r.status.skipped) counts.skipped++;
12075
+ if (r.status.timedOut) counts.timedOut++;
12076
+ }
12077
+ return counts;
12078
+ }
11708
12079
  //#endregion
11709
12080
  //#region src/engine/provenance.ts
11710
12081
  const GENERATED_HEADER_RE = /^\s*(?:\/\/|#|\/\*)\s*(?:auto[- ]?generated|generated by|do not edit)/i;
@@ -11992,6 +12363,11 @@ var DependencyGraph = class {
11992
12363
  get size() {
11993
12364
  return this.nodes.size;
11994
12365
  }
12366
+ /** Whether the graph knows this exact key. Reachability must not
12367
+ * treat "not in the graph" as "nothing to traverse" without saying so. */
12368
+ has(path) {
12369
+ return this.nodes.has(path);
12370
+ }
11995
12371
  };
11996
12372
  function parsePackageJson(filePath) {
11997
12373
  if (!existsSync(filePath)) return void 0;
@@ -12079,20 +12455,40 @@ function parseManifest(filePath) {
12079
12455
  if (filePath.endsWith("pom.xml")) return parsePomXml(filePath);
12080
12456
  }
12081
12457
  /**
12082
- * Given a set of starting files (e.g. test files), return all files
12083
- * transitively reachable through the dependency graph.
12458
+ * Given a set of starting files (e.g. test files), return the files
12459
+ * transitively reachable through the dependency graph, plus an honest
12460
+ * account of what could not be resolved.
12461
+ *
12462
+ * KNOWN LIMITATION (BW-022, still open): the graph is keyed by manifest path
12463
+ * and this query is given source paths, so today `unresolvedStarts` is
12464
+ * normally every input. The traversal itself is correct for a graph whose
12465
+ * keys are the paths being queried — which is what the unit tests build, and
12466
+ * which is why they passed while production did nothing. Fixing the seam
12467
+ * means giving a file a resolvable owning manifest and deciding impact's
12468
+ * direction (dependents, not dependencies), which is a design change, not a
12469
+ * patch. Until then this function reports that it resolved nothing instead
12470
+ * of implying otherwise.
12084
12471
  */
12085
12472
  function getReachableFiles(fromFiles, graph) {
12086
12473
  const reachable = /* @__PURE__ */ new Set();
12474
+ const unresolvedStarts = [];
12087
12475
  const stack = [...fromFiles];
12088
12476
  while (stack.length > 0) {
12089
12477
  const current = stack.pop();
12090
12478
  if (current === void 0) continue;
12091
12479
  if (reachable.has(current)) continue;
12092
- reachable.add(current);
12480
+ if (graph.has(current)) reachable.add(current);
12481
+ else if (fromFiles.includes(current)) {
12482
+ unresolvedStarts.push(current);
12483
+ continue;
12484
+ }
12093
12485
  for (const dep of graph.getDependencies(current)) if (!reachable.has(dep)) stack.push(dep);
12094
12486
  }
12095
- return [...reachable].sort();
12487
+ return {
12488
+ reachable: [...reachable].sort(),
12489
+ unresolvedStarts: unresolvedStarts.sort(),
12490
+ resolvedAny: unresolvedStarts.length < fromFiles.length
12491
+ };
12096
12492
  }
12097
12493
  //#endregion
12098
12494
  //#region src/engine/incremental-analysis.ts
@@ -12132,739 +12528,934 @@ function isIncrementalSafe(changedFiles) {
12132
12528
  };
12133
12529
  }
12134
12530
  //#endregion
12135
- //#region src/engine/monorepo-analysis.ts
12136
- const WORTHY_THRESHOLD = 80;
12137
- const NEEDS_WORK_THRESHOLD = 50;
12138
- function verdictOf(score) {
12139
- if (score === null) return "fail";
12140
- if (score >= WORTHY_THRESHOLD) return "pass";
12141
- if (score >= NEEDS_WORK_THRESHOLD) return "warn";
12142
- return "fail";
12143
- }
12144
- function hasBlocker(findings) {
12145
- return findings.some((f) => f.severity === "error");
12146
- }
12531
+ //#region src/brand/tokens.ts
12147
12532
  /**
12148
- * Aggregate per-package results into an overall verdict.
12533
+ * The single source of brand truth.
12149
12534
  *
12150
- * Worst-package propagation: if ANY package has a blocker (error-severity
12151
- * finding) or a failing score, the overall result fails regardless of
12152
- * strategy. This is the safety net — a single poisoned package must not
12153
- * hide behind averaging.
12535
+ * Every colour, typeface and motion constant Mjölnir shows a human —
12536
+ * terminal, README SVGs, demo video, website, docs, badges — resolves to
12537
+ * a value in this file. Nothing else may define one.
12538
+ *
12539
+ * WHY THIS EXISTS. Before it, the palette existed in six independent
12540
+ * copies: `site/.vitepress/theme/styles/vars.css`, `NORSE` in
12541
+ * `src/reporter/theme.ts`, `scripts/readme-svg.ts`,
12542
+ * `scripts/video/terminal-page.ts`, `scripts/generate-readme-architecture.ts`
12543
+ * and the table in `assets/brand/README.md`. Exactly one pair of those
12544
+ * was guarded (site-doctor Check 8, doc ↔ vars.css). The unguarded edges
12545
+ * are where the shipped surfaces drifted apart: the terminal and the site
12546
+ * disagreed on six semantic roles, the architecture diagram invented its
12547
+ * own neutral ramp, and the README badges still carried a palette retired
12548
+ * two releases earlier. `scripts/brand-doctor.mjs` now checks every edge
12549
+ * against this file.
12550
+ *
12551
+ * PURITY. Pure data. No I/O, no rendering, no environment access, no
12552
+ * imports, no logic. Consumers convert (hex → ANSI triplet, hex → CSS)
12553
+ * themselves. Same reason `presentation.ts` is pure: it makes the whole
12554
+ * thing golden-testable and safe to ship inside the npm package, where
12555
+ * it costs a few hundred bytes and replaces values the package already
12556
+ * carried anyway.
12557
+ *
12558
+ * DERIVATION. The palette is the one derived from the logo in PR #20
12559
+ * (brushed steel and forge gold under an aurora, over midnight iron).
12560
+ * Where the terminal disagreed with it, the terminal converges — see
12561
+ * `PENDING_TERMINAL` below. Full rationale: `assets/brand/README.md`.
12562
+ *
12563
+ * ACCESSIBILITY. Every foreground token in `BRAND` meets WCAG AA
12564
+ * (≥ 4.5:1) against every surface token it is allowed to sit on. That is
12565
+ * not a claim, it is `brand-doctor` rule 8, which computes the ratios.
12566
+ * The weakest legal pairing is `steelDim` on `ink800` at 5.00:1.
12154
12567
  */
12155
- function analyzeMonorepo(packages, config) {
12156
- const results = packages.map((p) => ({
12157
- ...p,
12158
- verdict: hasBlocker(p.findings) ? "fail" : verdictOf(p.score)
12159
- }));
12160
- if (results.length === 0) return {
12161
- packages: results,
12162
- overallScore: null,
12163
- overallVerdict: "fail",
12164
- strategy: config.weightingStrategy
12165
- };
12166
- const blockerPkg = results.find((r) => r.verdict === "fail" || hasBlocker(r.findings));
12167
- if (blockerPkg) return {
12168
- packages: results,
12169
- overallScore: blockerPkg.score,
12170
- overallVerdict: "fail",
12171
- strategy: config.weightingStrategy,
12172
- blockerPackage: blockerPkg.packageName
12173
- };
12174
- switch (config.weightingStrategy) {
12175
- case "worst-package": return worstPackageAggregation(results, config);
12176
- case "average": return averageAggregation(results, config);
12177
- case "configurable": return configurableAggregation(results, config);
12178
- }
12179
- }
12180
- function worstPackageAggregation(results, config) {
12181
- const firstResult = results[0];
12182
- if (firstResult === void 0) return {
12183
- packages: results,
12184
- overallScore: null,
12185
- overallVerdict: "fail",
12186
- strategy: config.weightingStrategy
12187
- };
12188
- let worst = firstResult;
12189
- for (const r of results) if ((r.score ?? 0) < (worst.score ?? 0)) worst = r;
12190
- return {
12191
- packages: results,
12192
- overallScore: worst.score,
12193
- overallVerdict: worst.verdict,
12194
- strategy: config.weightingStrategy,
12195
- ...worst.verdict === "fail" ? { blockerPackage: worst.packageName } : {}
12196
- };
12197
- }
12198
- function averageAggregation(results, config) {
12199
- const scored = results.filter((r) => r.score !== null);
12200
- if (scored.length === 0) return {
12201
- packages: results,
12202
- overallScore: null,
12203
- overallVerdict: "fail",
12204
- strategy: config.weightingStrategy
12205
- };
12206
- const avg = scored.reduce((sum, r) => sum + (r.score ?? 0), 0) / scored.length;
12207
- return {
12208
- packages: results,
12209
- overallScore: Math.round(avg),
12210
- overallVerdict: verdictOf(Math.round(avg)),
12211
- strategy: config.weightingStrategy
12212
- };
12213
- }
12214
- function configurableAggregation(results, config) {
12215
- const weights = config.packageWeights ?? {};
12216
- let totalWeight = 0;
12217
- let weightedSum = 0;
12218
- for (const r of results) {
12219
- const w = weights[r.packageName] ?? 1;
12220
- totalWeight += w;
12221
- weightedSum += (r.score ?? 0) * w;
12222
- }
12223
- if (totalWeight === 0) return {
12224
- packages: results,
12225
- overallScore: null,
12226
- overallVerdict: "fail",
12227
- strategy: config.weightingStrategy
12228
- };
12229
- const score = Math.round(weightedSum / totalWeight);
12230
- return {
12231
- packages: results,
12232
- overallScore: score,
12233
- overallVerdict: verdictOf(score),
12234
- strategy: config.weightingStrategy
12235
- };
12236
- }
12237
- //#endregion
12238
- //#region src/lib/fs-atomic.ts
12239
12568
  /**
12240
- * Atomic file writes (audit S9).
12569
+ * The two brand hues plus the neutral they sit on.
12241
12570
  *
12242
- * Every durability-critical write in Mjölnir (baseline, stats, badge,
12243
- * TRIAGE.md, scaffolded rule files) used to hand-roll
12244
- * `writeFileSync(path, data)` — a crash mid-write left a TRUNCATED file
12245
- * at the real path, and a subsequent read (diff, badge endpoint) served
12246
- * confident nonsense from it.
12571
+ * GOLD IS SCARCE. It means forged / certified / earned / decisive — the
12572
+ * primary mark, the FORGED state, one call to action. It is not a paint
12573
+ * bucket: gold as default text, default border or default heading is a
12574
+ * brand-doctor finding, not a style choice.
12247
12575
  *
12248
- * `writeFileAtomic` writes to a temp sibling, then RENAMES. On the same
12249
- * volume rename is atomic: readers see either the complete old file or
12250
- * the complete new file, never a half-written one. The temp name is
12251
- * created with `wx` (exclusive) so concurrent writers cannot interleave,
12252
- * stale temps are cleaned up on failure, and on Windows the rename is
12253
- * retried briefly because a concurrent reader can hold the destination
12254
- * open (EBUSY/EPERM).
12576
+ * AURORA is verification energy — the secondary, and the hue that marks
12577
+ * the runtime half of the trust ladder.
12255
12578
  */
12256
- function atomicTempPath(path) {
12257
- return `${path}.mjolnir-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}.tmp`;
12258
- }
12579
+ const BRAND = {
12580
+ gold: "#C19A34",
12581
+ goldBright: "#E6BD57",
12582
+ goldHot: "#F4DC9C",
12583
+ /** Pressed / deepest gold — the only step dark enough to carry white. */
12584
+ goldDeep: "#A5811C",
12585
+ aurora: "#37ABBD",
12586
+ auroraBright: "#45C1D4",
12587
+ auroraCyan: "#5CBDE0",
12588
+ /** The aurora's outer curtains: atmosphere and section identity only,
12589
+ * never a verdict or a status. */
12590
+ auroraGreen: "#5FD6A4",
12591
+ auroraViolet: "#9D8CF5",
12592
+ steel: "#C8CBCF",
12593
+ steelDim: "#8B939D"
12594
+ };
12259
12595
  /**
12260
- * Atomically replace `path` with `data`.
12596
+ * Midnight iron. One ramp, four steps, darkest first.
12597
+ *
12598
+ * `terminal` and `terminalBar` share one tone deliberately: the window's
12599
+ * only seam is a hairline ring and an inset shadow, never a second fill.
12600
+ * `chromeDot` is the three window dots — see the note on
12601
+ * `PENDING_TERMINAL.chromeDots` for why they are no longer red/amber/green.
12261
12602
  */
12262
- function writeFileAtomic(path, data, opts = {}) {
12263
- const dir = dirname(path);
12264
- if (opts.mkdirs !== false && !existsSync(dir)) mkdirSync(dir, { recursive: true });
12265
- const tmp = atomicTempPath(path);
12266
- let fd;
12267
- try {
12268
- fd = openSync(tmp, "wx", opts.mode ?? 420);
12269
- writeSync(fd, data, null, opts.encoding ?? "utf8");
12270
- } finally {
12271
- if (fd !== void 0) closeSync(fd);
12272
- }
12273
- try {
12274
- renameWithWindowsRetry(tmp, path);
12275
- } catch (err) {
12276
- try {
12277
- if (existsSync(tmp)) unlinkSync(tmp);
12278
- } catch {}
12279
- throw err;
12280
- }
12281
- }
12603
+ const SURFACE = {
12604
+ ink950: "#0A1119",
12605
+ ink900: "#0C1420",
12606
+ ink850: "#111A29",
12607
+ ink800: "#18243A",
12608
+ /** Raised panel (cards, elevated surfaces). */
12609
+ panel: "#141F33",
12610
+ /** Soft fill (inline code, quiet chips). */
12611
+ soft: "#1A2740",
12612
+ /** Terminal body — the deepest tone, so a terminal reads as recessed. */
12613
+ terminal: "#0A1119",
12614
+ /** Terminal title bar — the same tone; the seam is shadow, not colour. */
12615
+ terminalBar: "#0A1119",
12616
+ /** The three window dots. One neutral, not a traffic light. */
12617
+ chromeDot: "#18243A"
12618
+ };
12619
+ const TEXT = {
12620
+ primary: "#EAEEF5",
12621
+ secondary: "#ABB6C6",
12622
+ muted: "#8B939D",
12623
+ /** Ink for text set ON gold (buttons, the FORGED chip). 7.17:1 on `gold`. */
12624
+ onGold: "#0A1119"
12625
+ };
12282
12626
  /**
12283
- * renameSync retry loop for Windows: a concurrent reader (another scan,
12284
- * a badge endpoint, an editor) holding the destination open makes
12285
- * rename fail with EBUSY/EPERM. A short bounded retry closes the race
12286
- * without turning an atomic swap into a partial write.
12627
+ * Non-score status. `ok` is the one green in the system and it is NOT a
12628
+ * score colour — it survives only for contexts with no worthiness
12629
+ * meaning ("autofix applied", "analysis complete"). A green score would
12630
+ * say "your software is fine", which is the exact claim this product
12631
+ * refuses to make.
12632
+ */
12633
+ const STATUS = {
12634
+ ok: "#4FB477",
12635
+ info: "#5CC4E0",
12636
+ warning: "#E6BD57",
12637
+ error: "#EC6B66"
12638
+ };
12639
+ /**
12640
+ * The four ScoreState bands plus the unmeasured state. Band thresholds
12641
+ * and runes live in `src/reporter/presentation.ts`, which stays free of
12642
+ * colour — it emits a palette KEY and each surface resolves it here.
12287
12643
  *
12288
- * Internal contract test hook: the platform check keeps this loop off
12289
- * the POSIX hot path; on win32 the EBUSY/EPERM arms are exercised by
12290
- * the fs-atomic-retry spec (mocked renameSync).
12644
+ * `unmeasured` is steel-dim on purpose. UNKNOWN is a legitimate answer,
12645
+ * not a failure: colouring it red would make "we did not measure this"
12646
+ * look like "this is broken", which is precisely the dishonesty the
12647
+ * north-star law exists to prevent.
12291
12648
  */
12292
- const RENAME_RETRIES = 8;
12293
- const RENAME_RETRY_DELAY_MS = 25;
12294
- /** Synchronous sleep that does not spin the CPU. */
12295
- function sleepSync(ms) {
12296
- try {
12297
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
12298
- } catch {
12299
- const until = Date.now() + ms;
12300
- while (Date.now() < until);
12301
- }
12302
- }
12303
- function renameWithWindowsRetry(from, to) {
12304
- for (let attempt = 0;; attempt++) try {
12305
- renameSync(from, to);
12306
- return;
12307
- } catch (err) {
12308
- const code = err?.code;
12309
- if (process.platform === "win32" && (code === "EBUSY" || code === "EPERM") && attempt < RENAME_RETRIES) {
12310
- sleepSync(RENAME_RETRY_DELAY_MS);
12311
- continue;
12312
- }
12313
- throw err;
12314
- }
12315
- }
12316
- const STALE_TEMP_AGE_MS = 864e5;
12317
- const TEMP_PID_RE = /^.+\.mjolnir-([1-9]\d{0,9})-(\d{13})-[0-9a-f]{8}\.tmp$/;
12318
- function sweepStaleTempFiles(dir) {
12319
- let swept = 0;
12320
- let entries;
12321
- try {
12322
- entries = readdirSync(dir);
12323
- } catch {
12324
- return 0;
12325
- }
12326
- const cutoff = Date.now() - STALE_TEMP_AGE_MS;
12327
- for (const entry of entries) {
12328
- const match = TEMP_PID_RE.exec(entry);
12329
- if (!match || Number(match[2]) > cutoff) continue;
12330
- const path = join(dir, entry);
12331
- try {
12332
- const stat = lstatSync(path);
12333
- if (!stat.isFile() || stat.mtimeMs > cutoff) continue;
12334
- if (pidAlive(Number(match[1]))) continue;
12335
- unlinkSync(path);
12336
- swept++;
12337
- } catch {
12338
- continue;
12339
- }
12340
- }
12341
- return swept;
12342
- }
12343
- function pidAlive(pid) {
12344
- if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 2147483647) return true;
12345
- if (pid === process.pid) return true;
12346
- try {
12347
- process.kill(pid, 0);
12348
- return true;
12349
- } catch (err) {
12350
- return err?.code !== "ESRCH";
12351
- }
12352
- }
12353
- //#endregion
12354
- //#region src/engine/scan-cache.ts
12649
+ const SCORE = {
12650
+ critical: "#EC6B66",
12651
+ warning: "#E6BD57",
12652
+ trusted: "#5CC4E0",
12653
+ forged: "#F4DC9C",
12654
+ unmeasured: "#8B939D"
12655
+ };
12355
12656
  /**
12356
- * Local incremental scan cache (Beta-to-Stable 1.0 plan, M5.2 / A-2).
12657
+ * E0 → E1 → E2 is a certainty ramp, and it is deliberately HUE-FREE.
12357
12658
  *
12358
- * Content-addressed, local-only verdict cache: `--cache` reuses the
12359
- * per-file rule outputs of a previous scan when the file's bytes AND the
12360
- * active rule set are unchanged, and invalidates everything else. The
12361
- * key is `sha256(fileText) + rulesDigest` + the file's own identity
12362
- * (repo-relative path + adapter id + parse mode — audit C1/W9), where
12363
- * the rules digest folds
12364
- * in every active rule's id + `detectorRevision ?? 1` (the existing
12365
- * stale-measurement machinery — Verification Trust Evolution Plan §07 —
12366
- * reused as the cache invalidation signal, per A-2) plus a source hash
12367
- * of each external plugin/local rule's `run` function, so a plugin that
12368
- * changes code without bumping its revision still misses.
12659
+ * Evidence level says how sure we are, not whether the news is good. A
12660
+ * deterministic proof (E2) is a defect we are certain about — painting
12661
+ * it gold or green would read as an achievement. So certainty is carried
12662
+ * by brightness alone, and the *shape* does the real work:
12369
12663
  *
12370
- * Privacy posture: the cache lives under `<repo>/.mjolnir/cache/`, is
12371
- * gitignored, never leaves the machine, and this module performs zero
12372
- * network I/O — fs and crypto only (asserted by the privacy spec).
12664
+ * E0 open ring observation, no weight
12665
+ * E1 half-filled pattern evidence, half weight
12666
+ * E2 sealed deterministic proof, full weight
12373
12667
  *
12374
- * Only raw rule-loop outputs are cached. Everything after the loop
12375
- * (severity overrides, suppressions, overlap dedup, evidence stamping,
12376
- * tier policy, runtime corroboration, scoring) re-runs on every scan,
12377
- * so a cached scan is byte-equivalent to a fresh one by construction.
12668
+ * Colour never carries this alone (R11): the geometry is the signal and
12669
+ * survives `--ascii`, `NO_COLOR` and monochrome print.
12378
12670
  */
12379
- const CACHE_VERSION = 2;
12380
- /** Entry cap: a monorepo-scale suite stays far below this; bounded file. */
12381
- const MAX_ENTRIES$1 = 4096;
12671
+ const EVIDENCE = {
12672
+ e0: "#8B939D",
12673
+ e1: "#ABB6C6",
12674
+ e2: "#EAEEF5"
12675
+ };
12382
12676
  /**
12383
- * Audit M5: total byte budget. The old cap counted only ENTRIES, so
12384
- * 4096 files × ~100KB of findings each could still produce a
12385
- * multi-hundred-MB writeFileSync on persist. The budget bounds the
12386
- * serialized size; the newest entries win (real LRU-by-use).
12677
+ * L0–L5, and the most important boundary in the product.
12678
+ *
12679
+ * L0–L2 are STATIC: the neutral steel ramp, brightening to the static
12680
+ * ceiling at L2. L3–L5 require a real run, and the hue changes to aurora
12681
+ * exactly there. The boundary is a hue break, not a gradient step,
12682
+ * because it is a change of kind and not of degree — a static-only
12683
+ * finding can never climb past L2, however confident it is.
12684
+ *
12685
+ * Every surface that draws the ladder must draw that break.
12387
12686
  */
12388
- const MAX_TOTAL_BYTES = 33554432;
12389
- const MAX_FINDINGS_PER_ENTRY = 1e4;
12390
- function isCachedFinding(value) {
12391
- if (!isRecord(value)) return false;
12392
- return typeof value["ruleId"] === "string" && typeof value["category"] === "string" && typeof value["severity"] === "string" && typeof value["confidence"] === "string" && typeof value["findingType"] === "string" && typeof value["file"] === "string" && Number.isSafeInteger(value["line"]) && Number.isSafeInteger(value["column"]) && typeof value["message"] === "string" && typeof value["why"] === "string" && typeof value["fix"] === "string";
12393
- }
12394
- function isCacheFile(value) {
12395
- if (!isRecord(value) || value["version"] !== CACHE_VERSION) return false;
12396
- const entries = value["entries"];
12397
- if (!isRecord(entries) || Object.keys(entries).length > MAX_ENTRIES$1) return false;
12398
- return Object.values(entries).every((entry) => isRecord(entry) && Array.isArray(entry["findings"]) && entry["findings"].length <= MAX_FINDINGS_PER_ENTRY && entry["findings"].every(isCachedFinding));
12399
- }
12400
- /** sha256 hex of a string — the only hash this module needs. */
12401
- function sha256(text) {
12402
- return createHash("sha256").update(text).digest("hex");
12403
- }
12404
- /**
12405
- * Digest of the active rule set: id + effective detectorRevision for
12406
- * every rule (sorted for determinism), plus a content hash of all rule
12407
- * source files. The directory-tree hash catches changes to ANY file
12408
- * under src/rules/ — same-file helpers, cross-module imports, shared
12409
- * utilities — without needing to trace the import graph. This is the
12410
- * only reliable way to detect helper function changes in a bundled
12411
- * ESM codebase where String(fn) doesn't reveal transitive dependencies.
12412
- */
12413
- function computeRulesDigest(rules) {
12414
- const parts = rules.map((r) => `${r.id}:${r.detectorRevision ?? 1}`).sort();
12415
- const rulesDirHash = hashRulesSourceTree(rules);
12416
- return sha256(parts.join("|") + "\0" + rulesDirHash);
12417
- }
12418
- /**
12419
- * Hash all .ts files under src/rules/ (when the first rule's modulePath
12420
- * reveals the project root). Falls back to per-rule function source
12421
- * hashing when the directory cannot be resolved.
12422
- */
12423
- function hashRulesSourceTree(rules) {
12424
- const firstPath = rules.find((r) => r.modulePath)?.modulePath;
12425
- if (!firstPath) return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
12426
- const idx = firstPath.replace(/\\/g, "/").indexOf("/src/rules/");
12427
- if (idx < 0) return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
12428
- const rulesDir = firstPath.replace(/\\/g, "/").slice(0, idx + 11);
12429
- try {
12430
- const hash = createHash("sha256");
12431
- hashDir(rulesDir, hash, 0);
12432
- return hash.digest("hex").slice(0, 16);
12433
- } catch {
12434
- return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
12435
- }
12436
- }
12437
- function hashDir(dir, hash, depth) {
12438
- if (depth > 8) return;
12439
- let entries;
12440
- try {
12441
- entries = readdirSync(dir, { withFileTypes: true });
12442
- } catch {
12443
- return;
12444
- }
12445
- entries.sort((a, b) => a.name.localeCompare(b.name));
12446
- for (const entry of entries) {
12447
- if (entry.name === "node_modules" || entry.name === ".git") continue;
12448
- const full = join(dir, entry.name);
12449
- if (entry.isDirectory()) hashDir(full, hash, depth + 1);
12450
- else if (entry.isFile() && /\.tsx?$/.test(entry.name)) try {
12451
- hash.update(entry.name);
12452
- hash.update(readFileSync(full));
12453
- } catch {}
12687
+ const TRUST = {
12688
+ l0: "#8B939D",
12689
+ l1: "#ABB6C6",
12690
+ l2: "#C8CBCF",
12691
+ l3: "#37ABBD",
12692
+ l4: "#45C1D4",
12693
+ l5: "#5CC4E0"
12694
+ };
12695
+ const TINT = {
12696
+ gold: {
12697
+ fill: "#F6EBCC",
12698
+ stroke: "#7A5F16",
12699
+ text: "#4A3A0E"
12700
+ },
12701
+ aurora: {
12702
+ fill: "#D9F0F4",
12703
+ stroke: "#1F6F7C",
12704
+ text: "#10353C"
12705
+ },
12706
+ error: {
12707
+ fill: "#FADEDD",
12708
+ stroke: "#A83A35",
12709
+ text: "#4E1B19"
12710
+ },
12711
+ /** The unmeasured / unknown state. Neutral, never the error tint. */
12712
+ neutral: {
12713
+ fill: "#E4E7EB",
12714
+ stroke: "#5C646E",
12715
+ text: "#262B31"
12716
+ },
12717
+ ok: {
12718
+ fill: "#DCF0E4",
12719
+ stroke: "#276B45",
12720
+ text: "#163A26"
12454
12721
  }
12455
- }
12722
+ };
12456
12723
  /**
12457
- * Content-addressed key for one file's rule-loop verdicts.
12724
+ * The score bands as the badge Mjölnir itself generates renders them.
12458
12725
  *
12459
- * Audit C1: the key MUST identify the verdict's producer, not just the
12460
- * bytes — two files with byte-identical text (a copied spec, a generated
12461
- * snapshot) previously shared one entry, and the first file's cached
12462
- * findings were re-emitted for the second with the wrong `file` stamp.
12463
- * The key therefore folds in the repo-relative path AND the adapter id,
12464
- * plus a parse-mode token (audit W9): a file whose analysis degraded to
12465
- * the regex fallback (or skipped the AST path) must not collide with a
12466
- * fully-AST-analyzed verdict for the same bytes — the fallback output
12467
- * belongs only to the fallback mode.
12726
+ * These are DEEPER than the score tokens on purpose, and it is not a
12727
+ * style preference: shields.io sets the message text in white and gives
12728
+ * you no say in it. `score.forged` (#F4DC9C) under white text measures
12729
+ * 1.35:1 — an unreadable badge, shipped to look on-brand. The brand's
12730
+ * own deep steps put every band between 4.9 and 6.3:1.
12731
+ *
12732
+ * What they replace was worse than off-brand, it was wrong:
12733
+ *
12734
+ * band was rendered as white-on now white-on
12735
+ * ────────────────────────────────────────────────────────────────────────────
12736
+ * 0-49 `red` #dd4343 4.24 #A83A35 6.32
12737
+ * 50-79 `yellow` #d8b800 1.95 #7A5F16 6.04
12738
+ * 80-99 `important` #ea7233 ORANGE 3.02 #1F6F7C 5.80
12739
+ * 100 `success` #44bb00 GREEN 2.51 #8A6D1E 4.90
12740
+ * unmeasured `lightgrey` #939393 3.07 #5C646E 5.99
12741
+ *
12742
+ * Two of those were defects, not preferences. `success` is green, and
12743
+ * green is not a score colour here — a 100 badge said "your software is
12744
+ * fine", which is the one claim this product refuses to make. And
12745
+ * `important` is ORANGE, not the blue-family colour the code's own
12746
+ * comment claimed for eight releases: every WORTHY badge ever rendered
12747
+ * showed the trusted band in a warning colour. Nobody had resolved a
12748
+ * shields name to a value and looked.
12749
+ *
12750
+ * Values are hex without `#`, the form shields.io's endpoint takes.
12751
+ * `A83A35`, `7A5F16`, `1F6F7C` and `5C646E` are the `TINT` strokes —
12752
+ * the same deep steps the mermaid diagrams use, for the same reason.
12753
+ * `8A6D1E` is the gold the brand document already named as the light
12754
+ * FORGED gradient's start.
12468
12755
  */
12469
- function fileCacheKey(rulesDigest, fileText, identity) {
12470
- const parseMode = identity.parseMode ?? "ast";
12471
- return sha256(`${CACHE_VERSION}\u0000${rulesDigest}\u0000${identity.relPath}\u0000${identity.adapterId}\u0000${parseMode}\u0000${fileText}`);
12472
- }
12473
- /** No-op cache used when --cache is absent: zero stats, zero I/O. */
12474
- const disabledScanCache = {
12475
- stats: {
12476
- hits: 0,
12477
- misses: 0,
12478
- file: ""
12479
- },
12480
- lookup: () => void 0,
12481
- store: () => {},
12482
- persist: () => {}
12756
+ const BADGE_BAND = {
12757
+ critical: "A83A35",
12758
+ warning: "7A5F16",
12759
+ trusted: "1F6F7C",
12760
+ forged: "8A6D1E",
12761
+ unmeasured: "5C646E"
12483
12762
  };
12484
- /**
12485
- * Opens (and lazily creates) `<root>/.mjolnir/cache/scan-v<CACHE_VERSION>.json`. A
12486
- * corrupt, hostile or future-versioned cache file degrades to a cold
12487
- * cache — never fails the scan.
12488
- */
12489
- function createScanCache(root) {
12490
- const dir = join(root, ".mjolnir", "cache");
12491
- const file = join(dir, `scan-v${CACHE_VERSION}.json`);
12492
- let entries = {};
12493
- let dirty = false;
12494
- const entryBytes = /* @__PURE__ */ new Map();
12495
- let totalBytes = 0;
12496
- try {
12497
- if (existsSync(file)) {
12498
- const stat = lstatSync(file);
12499
- if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_TOTAL_BYTES) throw new Error("invalid cache file");
12500
- entries = parseJsonFile(readFileSync(file, "utf8"), file, isCacheFile).entries;
12501
- for (const [k, v] of Object.entries(entries)) {
12502
- const size = Buffer.byteLength(JSON.stringify(v), "utf8") + k.length + 4;
12503
- entryBytes.set(k, size);
12504
- totalBytes += size;
12505
- }
12506
- if (totalBytes > MAX_TOTAL_BYTES) throw new Error("cache byte budget exceeded");
12507
- }
12508
- } catch {
12509
- entries = {};
12510
- entryBytes.clear();
12511
- totalBytes = 0;
12512
- }
12513
- return {
12514
- stats: {
12515
- hits: 0,
12516
- misses: 0,
12517
- file
12518
- },
12519
- lookup(key) {
12520
- const entry = entries[key];
12521
- if (!entry) {
12522
- this.stats.misses++;
12523
- return;
12524
- }
12525
- this.stats.hits++;
12526
- delete entries[key];
12527
- entries[key] = entry;
12528
- return structuredClone(entry.findings);
12529
- },
12530
- store(key, findings, fileBudgetExceeded) {
12531
- if (fileBudgetExceeded) return;
12532
- if (findings.length > MAX_FINDINGS_PER_ENTRY) return;
12533
- const entryJson = JSON.stringify(findings);
12534
- const newBytes = Buffer.byteLength(entryJson, "utf8") + key.length + 4;
12535
- if (newBytes > MAX_TOTAL_BYTES) return;
12536
- const replacedBytes = entryBytes.get(key) ?? 0;
12537
- delete entries[key];
12538
- entries[key] = { findings: structuredClone(findings) };
12539
- entryBytes.set(key, newBytes);
12540
- totalBytes = totalBytes - replacedBytes + newBytes;
12541
- let count = Object.keys(entries).length;
12542
- while ((count > MAX_ENTRIES$1 || totalBytes > MAX_TOTAL_BYTES) && count > 1) {
12543
- const oldest = Object.keys(entries)[0];
12544
- totalBytes -= entryBytes.get(oldest);
12545
- delete entries[oldest];
12546
- entryBytes.delete(oldest);
12547
- count--;
12548
- }
12549
- dirty = true;
12550
- },
12551
- persist() {
12552
- if (!dirty) return;
12553
- try {
12554
- mkdirSync(dir, { recursive: true });
12555
- writeFileAtomic(file, JSON.stringify({
12556
- version: CACHE_VERSION,
12557
- entries
12558
- }), { encoding: "utf8" });
12559
- } catch {}
12560
- }
12561
- };
12562
- }
12763
+ 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;
12764
+ 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;
12765
+ EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2;
12766
+ const RUNG_MEANINGS = [
12767
+ "observation only",
12768
+ "heuristic static",
12769
+ "deterministic static",
12770
+ "the finding's file executed",
12771
+ "the finding's test executed",
12772
+ "the run verdict corroborates"
12773
+ ];
12774
+ const RUNG_COLORS = [
12775
+ TRUST.l0,
12776
+ TRUST.l1,
12777
+ TRUST.l2,
12778
+ TRUST.l3,
12779
+ TRUST.l4,
12780
+ TRUST.l5
12781
+ ];
12782
+ const TRUST_RUNGS = RUNG_MEANINGS.map((meaning, i) => ({
12783
+ level: `L${i}`,
12784
+ meaning,
12785
+ runtime: i >= 3,
12786
+ color: RUNG_COLORS[i]
12787
+ }));
12563
12788
  //#endregion
12564
- //#region src/brand/tokens.ts
12789
+ //#region src/reporter/presentation.ts
12565
12790
  /**
12566
- * The single source of brand truth.
12791
+ * presentation.ts — the one place a presentation DECISION is made.
12567
12792
  *
12568
- * Every colour, typeface and motion constant Mjölnir shows a human —
12569
- * terminal, README SVGs, demo video, website, docs, badges — resolves to
12570
- * a value in this file. Nothing else may define one.
12793
+ * BITTERSWEET `BW-030a`. Before this module the same decision was written
12794
+ * down four different times and every surface drifted:
12571
12795
  *
12572
- * WHY THIS EXISTS. Before it, the palette existed in six independent
12573
- * copies: `site/.vitepress/theme/styles/vars.css`, `NORSE` in
12574
- * `src/reporter/theme.ts`, `scripts/readme-svg.ts`,
12575
- * `scripts/video/terminal-page.ts`, `scripts/generate-readme-architecture.ts`
12576
- * and the table in `assets/brand/README.md`. Exactly one pair of those
12577
- * was guarded (site-doctor Check 8, doc ↔ vars.css). The unguarded edges
12578
- * are where the shipped surfaces drifted apart: the terminal and the site
12579
- * disagreed on six semantic roles, the architecture diagram invented its
12580
- * own neutral ramp, and the README badges still carried a palette retired
12581
- * two releases earlier. `scripts/brand-doctor.mjs` now checks every edge
12582
- * against this file.
12796
+ * - score bands: `score-state.ts` (50/80), `dashboard.ts` (50/60),
12797
+ * `handover.ts` (50/90), `mermaid.ts` (50/80), `monorepo-analysis.ts`
12798
+ * (50/80);
12799
+ * - the evidence descriptor: `terminal.ts` returned the full
12800
+ * `[E2 · deterministic · measured FP 8% · n=14 · trust L3]` while
12801
+ * `evidence-tag.ts` — which called itself the "single definition
12802
+ * site" — returned four bare words and defaulted a missing level to
12803
+ * `E2`, the STRONGEST one;
12804
+ * - the score bar: `theme.ts:scoreGauge` plus four private reimplementations.
12583
12805
  *
12584
- * PURITY. Pure data. No I/O, no rendering, no environment access, no
12585
- * imports, no logic. Consumers convert (hex → ANSI triplet, hex → CSS)
12586
- * themselves. Same reason `score-state.ts` is pure: it makes the whole
12587
- * thing golden-testable and safe to ship inside the npm package, where
12588
- * it costs a few hundred bytes and replaces values the package already
12589
- * carried anyway.
12806
+ * THE ONE RULE OF THIS MODULE: it owns decisions, never data and never
12807
+ * I/O. It reads a `Finding` / a score number / a `Palette` and returns
12808
+ * the word, band, threshold or descriptor to print. It opens no file,
12809
+ * touches no clock, spawns no process and formats no HTML document. If a
12810
+ * second responsibility appears here, split it in the same change.
12590
12811
  *
12591
- * DERIVATION. The palette is the one derived from the logo in PR #20
12592
- * (brushed steel and forge gold under an aurora, over midnight iron).
12593
- * Where the terminal disagreed with it, the terminal converges — see
12594
- * `PENDING_TERMINAL` below. Full rationale: `assets/brand/README.md`.
12812
+ * Pure: the same input always yields the same output, so every decision
12813
+ * is golden-testable and two runs of the same scan cannot disagree.
12595
12814
  *
12596
- * ACCESSIBILITY. Every foreground token in `BRAND` meets WCAG AA
12597
- * (≥ 4.5:1) against every surface token it is allowed to sit on. That is
12598
- * not a claim, it is `brand-doctor` rule 8, which computes the ratios.
12599
- * The weakest legal pairing is `steelDim` on `ink800` at 5.00:1.
12815
+ * Layout note — the threshold LITERALS stay physically here rather than in
12816
+ * a registry object so that `site/scripts/gen-report.mjs`, which parses
12817
+ * `score >= N` branches out of this file to build the site's band ruler,
12818
+ * keeps parsing one canonical file. `SCORE_THRESHOLDS` is derived from
12819
+ * those literals; it is the nameable form, not a second copy of them.
12600
12820
  */
12601
12821
  /**
12602
- * The two brand hues plus the neutral they sit on.
12822
+ * What a surface prints when a number was never measured.
12603
12823
  *
12604
- * GOLD IS SCARCE. It means forged / certified / earned / decisive — the
12605
- * primary mark, the FORGED state, one call to action. It is not a paint
12606
- * bucket: gold as default text, default border or default heading is a
12607
- * brand-doctor finding, not a style choice.
12608
- *
12609
- * AURORA is verification energy — the secondary, and the hue that marks
12610
- * the runtime half of the trust ladder.
12824
+ * The bug this replaces: `testDeclarationCount ?? 0` rendered
12825
+ * "0 tests analyzed in 0 files" for a producer that simply did not
12826
+ * measure. A zero is a CLAIM — it says the count was taken and found
12827
+ * nothing. Absent means nobody looked. `dashboard.ts` already modelled
12828
+ * this correctly for frameworks (`frameworkCount: null` → "unknown");
12829
+ * its sibling `trust-report.ts` did not, so the same repo produced two
12830
+ * artifacts that disagreed about whether a measurement existed.
12611
12831
  */
12612
- const BRAND = {
12613
- gold: "#C19A34",
12614
- goldBright: "#E6BD57",
12615
- goldHot: "#F4DC9C",
12616
- /** Pressed / deepest gold — the only step dark enough to carry white. */
12617
- goldDeep: "#A5811C",
12618
- aurora: "#37ABBD",
12619
- auroraBright: "#45C1D4",
12620
- auroraCyan: "#5CBDE0",
12621
- /** The aurora's outer curtains: atmosphere and section identity only,
12622
- * never a verdict or a status. */
12623
- auroraGreen: "#5FD6A4",
12624
- auroraViolet: "#9D8CF5",
12625
- steel: "#C8CBCF",
12626
- steelDim: "#8B939D"
12627
- };
12832
+ const UNMEASURED = "unknown — not measured";
12628
12833
  /**
12629
- * Midnight iron. One ramp, four steps, darkest first.
12834
+ * Render a count for a human surface: the number when it was measured,
12835
+ * `unknown — not measured` when it was not. Never `0` for an absent
12836
+ * measurement.
12837
+ */
12838
+ function countOrUnknown(value, unit) {
12839
+ if (value === void 0) return UNMEASURED;
12840
+ return unit === void 0 ? String(value) : `${value} ${unit}`;
12841
+ }
12842
+ /**
12843
+ * The machine-surface counterpart: an unmeasured count is `null`, which
12844
+ * JSON has a word for, rather than `0`, which does not. Used by the
12845
+ * trust-report JSON artifact; the scan machine contract
12846
+ * (`engine/machine-contract.ts`) is drift-locked and untouched.
12847
+ */
12848
+ function countOrNull(value) {
12849
+ return value ?? null;
12850
+ }
12851
+ /**
12852
+ * The "Tests analyzed" cell, shared by every human surface that shows it.
12630
12853
  *
12631
- * `terminal` and `terminalBar` share one tone deliberately: the window's
12632
- * only seam is a hairline ring and an inset shadow, never a second fill.
12633
- * `chromeDot` is the three window dots — see the note on
12634
- * `PENDING_TERMINAL.chromeDots` for why they are no longer red/amber/green.
12854
+ * Three renderers had this line written out separately, identically, and
12855
+ * all three rendered `testDeclarationCount ?? 0` — "0 tests analyzed in 0
12856
+ * files" — for a producer that never measured a declaration at all. Two
12857
+ * of them are the *sibling* artifacts for the same scan (the PR comment
12858
+ * and the trust report), so the same run told a reviewer both "0 tests
12859
+ * analyzed" and "no tests found".
12860
+ *
12861
+ * Declared-but-file-count-absent (and the reverse) are reported honestly
12862
+ * rather than filled in from each other.
12635
12863
  */
12636
- const SURFACE = {
12637
- ink950: "#0A1119",
12638
- ink900: "#0C1420",
12639
- ink850: "#111A29",
12640
- ink800: "#18243A",
12641
- /** Raised panel (cards, elevated surfaces). */
12642
- panel: "#141F33",
12643
- /** Soft fill (inline code, quiet chips). */
12644
- soft: "#1A2740",
12645
- /** Terminal body — the deepest tone, so a terminal reads as recessed. */
12646
- terminal: "#0A1119",
12647
- /** Terminal title bar — the same tone; the seam is shadow, not colour. */
12648
- terminalBar: "#0A1119",
12649
- /** The three window dots. One neutral, not a traffic light. */
12650
- chromeDot: "#18243A"
12864
+ function testsAnalyzedCell(declarations, files) {
12865
+ if (declarations === void 0 && files === void 0) return UNMEASURED;
12866
+ const declText = countOrUnknown(declarations);
12867
+ if (files === void 0) return `${declText} in unknown files`;
12868
+ return `${declText} in ${files} ${files === 1 ? "file" : "files"}`;
12869
+ }
12870
+ const HEADLINES = {
12871
+ critical: "The hammer is cracked — {n} findings break its edge.",
12872
+ warning: "The hammer holds — but {n} findings weigh it down.",
12873
+ trusted: "Held in worthy hands — {n} findings remain.",
12874
+ forged: "Static score 100 — no findings on the analyzed surface.",
12875
+ unmeasured: "No tests found — the hammer cannot be weighed."
12651
12876
  };
12652
- const TEXT = {
12653
- primary: "#EAEEF5",
12654
- secondary: "#ABB6C6",
12655
- muted: "#8B939D",
12656
- /** Ink for text set ON gold (buttons, the FORGED chip). 7.17:1 on `gold`. */
12657
- onGold: "#0A1119"
12877
+ const RUNES = {
12878
+ critical: "ᚲ",
12879
+ warning: "ᚦ",
12880
+ trusted: "ᛏ",
12881
+ forged: "ᛟ",
12882
+ unmeasured: "ᛁ"
12658
12883
  };
12659
12884
  /**
12660
- * Non-score status. `ok` is the one green in the system and it is NOT a
12661
- * score colour — it survives only for contexts with no worthiness
12662
- * meaning ("autofix applied", "analysis complete"). A green score would
12663
- * say "your software is fine", which is the exact claim this product
12664
- * refuses to make.
12665
- */
12666
- const STATUS = {
12667
- ok: "#4FB477",
12668
- info: "#5CC4E0",
12669
- warning: "#E6BD57",
12670
- error: "#EC6B66"
12885
+ * The named form of the band boundaries. `report:honesty` /
12886
+ * `thresholds:parity` assert that no surface outside this module
12887
+ * re-invents a boundary; a consumer that genuinely needs one names it
12888
+ * here rather than typing the number.
12889
+ */
12890
+ const SCORE_THRESHOLDS = {
12891
+ /** Below this is `critical`. */
12892
+ criticalBelow: 50,
12893
+ /** At or above this is `trusted`. */
12894
+ trustedAtOrAbove: 80,
12895
+ /** The one score that is `forged`. */
12896
+ forgedAt: 100
12671
12897
  };
12672
12898
  /**
12673
- * The four ScoreState bands plus the unmeasured state. Band thresholds
12674
- * and runes live in `src/reporter/score-state.ts`, which stays free of
12675
- * colour — it emits a palette KEY and each surface resolves it here.
12899
+ * Band mapping — the one mapping, every consumer: <50 critical,
12900
+ * 50–79 warning, 80–99 trusted, 100 forged, null unmeasured.
12901
+ */
12902
+ function deriveScoreState(score) {
12903
+ if (score === null) return {
12904
+ score: null,
12905
+ band: "unmeasured",
12906
+ verdict: "UNWORTHY",
12907
+ color: "dim",
12908
+ powerLevel: 0,
12909
+ headline: HEADLINES.unmeasured,
12910
+ rune: RUNES.unmeasured
12911
+ };
12912
+ if (score >= SCORE_THRESHOLDS.forgedAt) return {
12913
+ score,
12914
+ band: "forged",
12915
+ verdict: "FORGED",
12916
+ color: "forged",
12917
+ powerLevel: score,
12918
+ headline: HEADLINES.forged,
12919
+ rune: RUNES.forged
12920
+ };
12921
+ if (score >= 80) return {
12922
+ score,
12923
+ band: "trusted",
12924
+ verdict: "WORTHY",
12925
+ color: "trusted",
12926
+ powerLevel: score,
12927
+ headline: HEADLINES.trusted,
12928
+ rune: RUNES.trusted
12929
+ };
12930
+ if (score >= 50) return {
12931
+ score,
12932
+ band: "warning",
12933
+ verdict: "NEEDS WORK",
12934
+ color: "warning",
12935
+ powerLevel: score,
12936
+ headline: HEADLINES.warning,
12937
+ rune: RUNES.warning
12938
+ };
12939
+ return {
12940
+ score,
12941
+ band: "critical",
12942
+ verdict: "UNWORTHY",
12943
+ color: "error",
12944
+ powerLevel: score,
12945
+ headline: HEADLINES.critical,
12946
+ rune: RUNES.critical
12947
+ };
12948
+ }
12949
+ /** Substitute a findings count into a headline template. Pure text
12950
+ * helper so every surface formats identically. */
12951
+ function headlineFor(state, findings) {
12952
+ return state.headline.replace("{n}", String(findings));
12953
+ }
12954
+ /**
12955
+ * Contract-stable three-band verdict (property-locked in
12956
+ * tests/scoring-precision.spec.ts). Delegates to the ScoreState model —
12957
+ * 100 keeps returning WORTHY here; the FORGED premium treatment lives
12958
+ * in the dedicated block, not in this public mapping.
12676
12959
  *
12677
- * `unmeasured` is steel-dim on purpose. UNKNOWN is a legitimate answer,
12678
- * not a failure: colouring it red would make "we did not measure this"
12679
- * look like "this is broken", which is precisely the dishonesty the
12680
- * north-star law exists to prevent.
12960
+ * The `FORGED → WORTHY` collapse is a DOCUMENTED PROJECTION that
12961
+ * preserves the three-band public contract, not a divergence to be
12962
+ * "fixed": a consumer reading the verdict word must not be able to tell
12963
+ * a 100 from a 99, because the 100 carries no runtime evidence and the
12964
+ * score alone never earned it.
12681
12965
  */
12682
- const SCORE = {
12683
- critical: "#EC6B66",
12684
- warning: "#E6BD57",
12685
- trusted: "#5CC4E0",
12686
- forged: "#F4DC9C",
12687
- unmeasured: "#8B939D"
12966
+ function verdictFor(score) {
12967
+ const verdict = deriveScoreState(score).verdict;
12968
+ return verdict === "FORGED" ? "WORTHY" : verdict;
12969
+ }
12970
+ const EVIDENCE_KIND = {
12971
+ E0: "observation",
12972
+ E1: "heuristic",
12973
+ E2: "deterministic"
12688
12974
  };
12689
12975
  /**
12690
- * E0 → E1 → E2 is a certainty ramp, and it is deliberately HUE-FREE.
12976
+ * The effective evidence level of a finding.
12691
12977
  *
12692
- * Evidence level says how sure we are, not whether the news is good. A
12693
- * deterministic proof (E2) is a defect we are certain about — painting
12694
- * it gold or green would read as an achievement. So certainty is carried
12695
- * by brightness alone, and the *shape* does the real work:
12978
+ * The `?? deriveEvidenceLevel(...)` fallback IS the fix for BW-101. The
12979
+ * deleted `evidence-tag.ts` defaulted a missing level to `E2` — the
12980
+ * strongest claim the product can make — which is the exact inverse of
12981
+ * its own principle: absent evidence is not proof, so it is derived
12982
+ * conservatively from the finding's own type and confidence.
12983
+ */
12984
+ function evidenceLevelOf(f) {
12985
+ return f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence);
12986
+ }
12987
+ /** What the runtime report actually vouched for, in the product's words. */
12988
+ function runtimeLabel(f) {
12989
+ const c = f.runtimeCorroboration;
12990
+ if (c === void 0) return null;
12991
+ if (c.level === "defect") return "defect corroborated";
12992
+ if (c.level === "test") return "test executed";
12993
+ return "file executed";
12994
+ }
12995
+ /**
12996
+ * The canonical evidence descriptor — `[E2 · deterministic · measured FP
12997
+ * 8% · n=14 · trust L3 · runtime: file executed]`.
12696
12998
  *
12697
- * E0 open ring observation, no weight
12698
- * E1 half-filled pattern evidence, half weight
12699
- * E2 sealed deterministic proof, full weight
12999
+ * This is the version `terminal.ts` already shipped, promoted to the one
13000
+ * definition site. The HTML and markdown surfaces now gain the measured
13001
+ * false-positive rate, the sample size, the trust rung and what runtime
13002
+ * corroborated — they used to show four bare words that a reader could
13003
+ * mistake for the whole evidence story.
12700
13004
  *
12701
- * Colour never carries this alone (R11): the geometry is the signal and
12702
- * survives `--ascii`, `NO_COLOR` and monochrome print.
13005
+ * Every clause is conditional on the finding actually carrying that
13006
+ * evidence. Nothing here is ever defaulted into existence.
12703
13007
  */
12704
- const EVIDENCE = {
12705
- e0: "#8B939D",
12706
- e1: "#ABB6C6",
12707
- e2: "#EAEEF5"
12708
- };
13008
+ function evidenceTag(f) {
13009
+ const level = evidenceLevelOf(f);
13010
+ let tag = `${level} · ${EVIDENCE_KIND[level]}`;
13011
+ if (f.measuredFpRate !== void 0) {
13012
+ tag += ` · measured FP ${Math.round(f.measuredFpRate * 100)}%`;
13013
+ if (f.measuredFpN !== void 0) tag += ` · n=${f.measuredFpN}`;
13014
+ }
13015
+ if (f.trustLevel !== void 0) tag += ` · trust ${f.trustLevel}`;
13016
+ const runtime = runtimeLabel(f);
13017
+ if (runtime !== null) tag += ` · runtime: ${runtime}`;
13018
+ return `[${tag}]`;
13019
+ }
13020
+ //#endregion
13021
+ //#region src/engine/monorepo-analysis.ts
12709
13022
  /**
12710
- * L0–L5, and the most important boundary in the product.
13023
+ * Package verdict, derived from the one ScoreState band model (BW-104).
13024
+ * These two constants used to be a private copy of the band boundaries;
13025
+ * the numeric vocabulary here is a different projection of the same bands
13026
+ * — "pass" is trusted or forged, "warn" is warning, "fail" is critical or
13027
+ * unmeasurable — so the boundaries are read, never retyped.
13028
+ */
13029
+ function verdictOf(score) {
13030
+ if (score === null) return "fail";
13031
+ const band = deriveScoreState(score).band;
13032
+ if (band === "forged" || band === "trusted") return "pass";
13033
+ return band === "warning" ? "warn" : "fail";
13034
+ }
13035
+ function hasBlocker(findings) {
13036
+ return findings.some((f) => f.severity === "error");
13037
+ }
13038
+ /**
13039
+ * Aggregate per-package results into an overall verdict.
12711
13040
  *
12712
- * L0–L2 are STATIC: the neutral steel ramp, brightening to the static
12713
- * ceiling at L2. L3–L5 require a real run, and the hue changes to aurora
12714
- * exactly there. The boundary is a hue break, not a gradient step,
12715
- * because it is a change of kind and not of degree — a static-only
12716
- * finding can never climb past L2, however confident it is.
13041
+ * Worst-package propagation: if ANY package has a blocker (error-severity
13042
+ * finding) or a failing score, the overall result fails regardless of
13043
+ * strategy. This is the safety net — a single poisoned package must not
13044
+ * hide behind averaging.
13045
+ */
13046
+ function analyzeMonorepo(packages, config) {
13047
+ const results = packages.map((p) => ({
13048
+ ...p,
13049
+ verdict: hasBlocker(p.findings) ? "fail" : verdictOf(p.score)
13050
+ }));
13051
+ if (results.length === 0) return {
13052
+ packages: results,
13053
+ overallScore: null,
13054
+ overallVerdict: "fail",
13055
+ strategy: config.weightingStrategy
13056
+ };
13057
+ const blockerPkg = results.find((r) => r.verdict === "fail" || hasBlocker(r.findings));
13058
+ if (blockerPkg) return {
13059
+ packages: results,
13060
+ overallScore: blockerPkg.score,
13061
+ overallVerdict: "fail",
13062
+ strategy: config.weightingStrategy,
13063
+ blockerPackage: blockerPkg.packageName
13064
+ };
13065
+ switch (config.weightingStrategy) {
13066
+ case "worst-package": return worstPackageAggregation(results, config);
13067
+ case "average": return averageAggregation(results, config);
13068
+ case "configurable": return configurableAggregation(results, config);
13069
+ }
13070
+ }
13071
+ function worstPackageAggregation(results, config) {
13072
+ const firstResult = results[0];
13073
+ if (firstResult === void 0) return {
13074
+ packages: results,
13075
+ overallScore: null,
13076
+ overallVerdict: "fail",
13077
+ strategy: config.weightingStrategy
13078
+ };
13079
+ let worst = firstResult;
13080
+ for (const r of results) if ((r.score ?? 0) < (worst.score ?? 0)) worst = r;
13081
+ return {
13082
+ packages: results,
13083
+ overallScore: worst.score,
13084
+ overallVerdict: worst.verdict,
13085
+ strategy: config.weightingStrategy,
13086
+ ...worst.verdict === "fail" ? { blockerPackage: worst.packageName } : {}
13087
+ };
13088
+ }
13089
+ function averageAggregation(results, config) {
13090
+ const scored = results.filter((r) => r.score !== null);
13091
+ if (scored.length === 0) return {
13092
+ packages: results,
13093
+ overallScore: null,
13094
+ overallVerdict: "fail",
13095
+ strategy: config.weightingStrategy
13096
+ };
13097
+ const avg = scored.reduce((sum, r) => sum + (r.score ?? 0), 0) / scored.length;
13098
+ return {
13099
+ packages: results,
13100
+ overallScore: Math.round(avg),
13101
+ overallVerdict: verdictOf(Math.round(avg)),
13102
+ strategy: config.weightingStrategy
13103
+ };
13104
+ }
13105
+ function configurableAggregation(results, config) {
13106
+ const weights = config.packageWeights ?? {};
13107
+ let totalWeight = 0;
13108
+ let weightedSum = 0;
13109
+ for (const r of results) {
13110
+ const w = weights[r.packageName] ?? 1;
13111
+ totalWeight += w;
13112
+ weightedSum += (r.score ?? 0) * w;
13113
+ }
13114
+ if (totalWeight === 0) return {
13115
+ packages: results,
13116
+ overallScore: null,
13117
+ overallVerdict: "fail",
13118
+ strategy: config.weightingStrategy
13119
+ };
13120
+ const score = Math.round(weightedSum / totalWeight);
13121
+ return {
13122
+ packages: results,
13123
+ overallScore: score,
13124
+ overallVerdict: verdictOf(score),
13125
+ strategy: config.weightingStrategy
13126
+ };
13127
+ }
13128
+ //#endregion
13129
+ //#region src/lib/fs-atomic.ts
13130
+ /**
13131
+ * Atomic file writes (audit S9).
12717
13132
  *
12718
- * Every surface that draws the ladder must draw that break.
13133
+ * Every durability-critical write in Mjölnir (baseline, stats, badge,
13134
+ * TRIAGE.md, scaffolded rule files) used to hand-roll
13135
+ * `writeFileSync(path, data)` — a crash mid-write left a TRUNCATED file
13136
+ * at the real path, and a subsequent read (diff, badge endpoint) served
13137
+ * confident nonsense from it.
13138
+ *
13139
+ * `writeFileAtomic` writes to a temp sibling, then RENAMES. On the same
13140
+ * volume rename is atomic: readers see either the complete old file or
13141
+ * the complete new file, never a half-written one. The temp name is
13142
+ * created with `wx` (exclusive) so concurrent writers cannot interleave,
13143
+ * stale temps are cleaned up on failure, and on Windows the rename is
13144
+ * retried briefly because a concurrent reader can hold the destination
13145
+ * open (EBUSY/EPERM).
12719
13146
  */
12720
- const TRUST = {
12721
- l0: "#8B939D",
12722
- l1: "#ABB6C6",
12723
- l2: "#C8CBCF",
12724
- l3: "#37ABBD",
12725
- l4: "#45C1D4",
12726
- l5: "#5CC4E0"
12727
- };
12728
- const TINT = {
12729
- gold: {
12730
- fill: "#F6EBCC",
12731
- stroke: "#7A5F16",
12732
- text: "#4A3A0E"
12733
- },
12734
- aurora: {
12735
- fill: "#D9F0F4",
12736
- stroke: "#1F6F7C",
12737
- text: "#10353C"
12738
- },
12739
- error: {
12740
- fill: "#FADEDD",
12741
- stroke: "#A83A35",
12742
- text: "#4E1B19"
12743
- },
12744
- /** The unmeasured / unknown state. Neutral, never the error tint. */
12745
- neutral: {
12746
- fill: "#E4E7EB",
12747
- stroke: "#5C646E",
12748
- text: "#262B31"
12749
- },
12750
- ok: {
12751
- fill: "#DCF0E4",
12752
- stroke: "#276B45",
12753
- text: "#163A26"
13147
+ function atomicTempPath(path) {
13148
+ return `${path}.mjolnir-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}.tmp`;
13149
+ }
13150
+ /**
13151
+ * Atomically replace `path` with `data`.
13152
+ *
13153
+ * Accepts binary payloads as well as text: base-tree materialization in
13154
+ * `impact` writes git blobs, and a writer that only takes strings forces those
13155
+ * call sites back to the non-atomic path rather than keeping them contained.
13156
+ */
13157
+ function writeFileAtomic(path, data, opts = {}) {
13158
+ const dir = dirname(path);
13159
+ if (opts.mkdirs !== false && !existsSync(dir)) mkdirSync(dir, { recursive: true });
13160
+ const tmp = atomicTempPath(path);
13161
+ let fd;
13162
+ try {
13163
+ fd = openSync(tmp, "wx", opts.mode ?? 420);
13164
+ if (typeof data === "string") writeSync(fd, data, null, opts.encoding ?? "utf8");
13165
+ else writeSync(fd, data);
13166
+ } finally {
13167
+ if (fd !== void 0) closeSync(fd);
13168
+ }
13169
+ try {
13170
+ renameWithWindowsRetry(tmp, path);
13171
+ } catch (err) {
13172
+ try {
13173
+ if (existsSync(tmp)) unlinkSync(tmp);
13174
+ } catch {}
13175
+ throw err;
13176
+ }
13177
+ }
13178
+ /**
13179
+ * renameSync retry loop for Windows: a concurrent reader (another scan,
13180
+ * a badge endpoint, an editor) holding the destination open makes
13181
+ * rename fail with EBUSY/EPERM. A short bounded retry closes the race
13182
+ * without turning an atomic swap into a partial write.
13183
+ *
13184
+ * Internal contract test hook: the platform check keeps this loop off
13185
+ * the POSIX hot path; on win32 the EBUSY/EPERM arms are exercised by
13186
+ * the fs-atomic-retry spec (mocked renameSync).
13187
+ */
13188
+ const RENAME_RETRIES = 8;
13189
+ const RENAME_RETRY_DELAY_MS = 25;
13190
+ /** Synchronous sleep that does not spin the CPU. */
13191
+ function sleepSync(ms) {
13192
+ try {
13193
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
13194
+ } catch {
13195
+ const until = Date.now() + ms;
13196
+ while (Date.now() < until);
13197
+ }
13198
+ }
13199
+ function renameWithWindowsRetry(from, to) {
13200
+ for (let attempt = 0;; attempt++) try {
13201
+ renameSync(from, to);
13202
+ return;
13203
+ } catch (err) {
13204
+ const code = err?.code;
13205
+ if (process.platform === "win32" && (code === "EBUSY" || code === "EPERM") && attempt < RENAME_RETRIES) {
13206
+ sleepSync(RENAME_RETRY_DELAY_MS);
13207
+ continue;
13208
+ }
13209
+ throw err;
13210
+ }
13211
+ }
13212
+ const STALE_TEMP_AGE_MS = 864e5;
13213
+ const TEMP_PID_RE = /^.+\.mjolnir-([1-9]\d{0,9})-(\d{13})-[0-9a-f]{8}\.tmp$/;
13214
+ function sweepStaleTempFiles(dir) {
13215
+ let swept = 0;
13216
+ let entries;
13217
+ try {
13218
+ entries = readdirSync(dir);
13219
+ } catch {
13220
+ return 0;
13221
+ }
13222
+ const cutoff = Date.now() - STALE_TEMP_AGE_MS;
13223
+ for (const entry of entries) {
13224
+ const match = TEMP_PID_RE.exec(entry);
13225
+ if (!match || Number(match[2]) > cutoff) continue;
13226
+ const path = join(dir, entry);
13227
+ try {
13228
+ const stat = lstatSync(path);
13229
+ if (!stat.isFile() || stat.mtimeMs > cutoff) continue;
13230
+ if (pidAlive(Number(match[1]))) continue;
13231
+ unlinkSync(path);
13232
+ swept++;
13233
+ } catch {
13234
+ continue;
13235
+ }
13236
+ }
13237
+ return swept;
13238
+ }
13239
+ function pidAlive(pid) {
13240
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 2147483647) return true;
13241
+ if (pid === process.pid) return true;
13242
+ try {
13243
+ process.kill(pid, 0);
13244
+ return true;
13245
+ } catch (err) {
13246
+ return err?.code !== "ESRCH";
13247
+ }
13248
+ }
13249
+ //#endregion
13250
+ //#region src/engine/scan-cache.ts
13251
+ /**
13252
+ * Local incremental scan cache (Beta-to-Stable 1.0 plan, M5.2 / A-2).
13253
+ *
13254
+ * Content-addressed, local-only verdict cache: `--cache` reuses the
13255
+ * per-file rule outputs of a previous scan when the file's bytes AND the
13256
+ * active rule set are unchanged, and invalidates everything else. The
13257
+ * key is `sha256(fileText) + rulesDigest` + the file's own identity
13258
+ * (repo-relative path + adapter id + parse mode — audit C1/W9), where
13259
+ * the rules digest folds
13260
+ * in every active rule's id + `detectorRevision ?? 1` (the existing
13261
+ * stale-measurement machinery — Verification Trust Evolution Plan §07 —
13262
+ * reused as the cache invalidation signal, per A-2) plus a source hash
13263
+ * of each external plugin/local rule's `run` function, so a plugin that
13264
+ * changes code without bumping its revision still misses.
13265
+ *
13266
+ * Privacy posture: the cache lives under `<repo>/.mjolnir/cache/`, is
13267
+ * gitignored, never leaves the machine, and this module performs zero
13268
+ * network I/O — fs and crypto only (asserted by the privacy spec).
13269
+ *
13270
+ * Only raw rule-loop outputs are cached. Everything after the loop
13271
+ * (severity overrides, suppressions, overlap dedup, evidence stamping,
13272
+ * tier policy, runtime corroboration, scoring) re-runs on every scan,
13273
+ * so a cached scan is byte-equivalent to a fresh one by construction.
13274
+ */
13275
+ const CACHE_VERSION = 2;
13276
+ /** Entry cap: a monorepo-scale suite stays far below this; bounded file. */
13277
+ const MAX_ENTRIES$1 = 4096;
13278
+ /**
13279
+ * Audit M5: total byte budget. The old cap counted only ENTRIES, so
13280
+ * 4096 files × ~100KB of findings each could still produce a
13281
+ * multi-hundred-MB writeFileSync on persist. The budget bounds the
13282
+ * serialized size; the newest entries win (real LRU-by-use).
13283
+ */
13284
+ const MAX_TOTAL_BYTES = 33554432;
13285
+ const MAX_FINDINGS_PER_ENTRY = 1e4;
13286
+ function isCachedFinding(value) {
13287
+ if (!isRecord(value)) return false;
13288
+ return typeof value["ruleId"] === "string" && typeof value["category"] === "string" && typeof value["severity"] === "string" && typeof value["confidence"] === "string" && typeof value["findingType"] === "string" && typeof value["file"] === "string" && Number.isSafeInteger(value["line"]) && Number.isSafeInteger(value["column"]) && typeof value["message"] === "string" && typeof value["why"] === "string" && typeof value["fix"] === "string";
13289
+ }
13290
+ function isCacheFile(value) {
13291
+ if (!isRecord(value) || value["version"] !== CACHE_VERSION) return false;
13292
+ const entries = value["entries"];
13293
+ if (!isRecord(entries) || Object.keys(entries).length > MAX_ENTRIES$1) return false;
13294
+ return Object.values(entries).every((entry) => isRecord(entry) && Array.isArray(entry["findings"]) && entry["findings"].length <= MAX_FINDINGS_PER_ENTRY && entry["findings"].every(isCachedFinding));
13295
+ }
13296
+ /** sha256 hex of a string — the only hash this module needs. */
13297
+ function sha256(text) {
13298
+ return createHash("sha256").update(text).digest("hex");
13299
+ }
13300
+ /**
13301
+ * Digest of the active rule set: id + effective detectorRevision for
13302
+ * every rule (sorted for determinism), plus a content hash of all rule
13303
+ * source files. The directory-tree hash catches changes to ANY file
13304
+ * under src/rules/ — same-file helpers, cross-module imports, shared
13305
+ * utilities — without needing to trace the import graph. This is the
13306
+ * only reliable way to detect helper function changes in a bundled
13307
+ * ESM codebase where String(fn) doesn't reveal transitive dependencies.
13308
+ */
13309
+ function computeRulesDigest(rules) {
13310
+ const parts = rules.map((r) => `${r.id}:${r.detectorRevision ?? 1}`).sort();
13311
+ const rulesDirHash = hashRulesSourceTree(rules);
13312
+ return sha256(parts.join("|") + "\0" + rulesDirHash);
13313
+ }
13314
+ /**
13315
+ * Hash all .ts files under src/rules/ (when the first rule's modulePath
13316
+ * reveals the project root). Falls back to per-rule function source
13317
+ * hashing when the directory cannot be resolved.
13318
+ */
13319
+ function hashRulesSourceTree(rules) {
13320
+ const firstPath = rules.find((r) => r.modulePath)?.modulePath;
13321
+ if (!firstPath) return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
13322
+ const idx = firstPath.replace(/\\/g, "/").indexOf("/src/rules/");
13323
+ if (idx < 0) return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
13324
+ const rulesDir = firstPath.replace(/\\/g, "/").slice(0, idx + 11);
13325
+ try {
13326
+ const hash = createHash("sha256");
13327
+ hashDir(rulesDir, hash, 0);
13328
+ return hash.digest("hex").slice(0, 16);
13329
+ } catch {
13330
+ return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
13331
+ }
13332
+ }
13333
+ function hashDir(dir, hash, depth) {
13334
+ if (depth > 8) return;
13335
+ let entries;
13336
+ try {
13337
+ entries = readdirSync(dir, { withFileTypes: true });
13338
+ } catch {
13339
+ return;
12754
13340
  }
12755
- };
13341
+ entries.sort((a, b) => a.name.localeCompare(b.name));
13342
+ for (const entry of entries) {
13343
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
13344
+ const full = join(dir, entry.name);
13345
+ if (entry.isDirectory()) hashDir(full, hash, depth + 1);
13346
+ else if (entry.isFile() && /\.tsx?$/.test(entry.name)) try {
13347
+ hash.update(entry.name);
13348
+ hash.update(readFileSync(full));
13349
+ } catch {}
13350
+ }
13351
+ }
12756
13352
  /**
12757
- * The score bands as the badge Mjölnir itself generates renders them.
12758
- *
12759
- * These are DEEPER than the score tokens on purpose, and it is not a
12760
- * style preference: shields.io sets the message text in white and gives
12761
- * you no say in it. `score.forged` (#F4DC9C) under white text measures
12762
- * 1.35:1 — an unreadable badge, shipped to look on-brand. The brand's
12763
- * own deep steps put every band between 4.9 and 6.3:1.
12764
- *
12765
- * What they replace was worse than off-brand, it was wrong:
12766
- *
12767
- * band was rendered as white-on now white-on
12768
- * ────────────────────────────────────────────────────────────────────────────
12769
- * 0-49 `red` #dd4343 4.24 #A83A35 6.32
12770
- * 50-79 `yellow` #d8b800 1.95 #7A5F16 6.04
12771
- * 80-99 `important` #ea7233 ORANGE 3.02 #1F6F7C 5.80
12772
- * 100 `success` #44bb00 GREEN 2.51 #8A6D1E 4.90
12773
- * unmeasured `lightgrey` #939393 3.07 #5C646E 5.99
12774
- *
12775
- * Two of those were defects, not preferences. `success` is green, and
12776
- * green is not a score colour here — a 100 badge said "your software is
12777
- * fine", which is the one claim this product refuses to make. And
12778
- * `important` is ORANGE, not the blue-family colour the code's own
12779
- * comment claimed for eight releases: every WORTHY badge ever rendered
12780
- * showed the trusted band in a warning colour. Nobody had resolved a
12781
- * shields name to a value and looked.
13353
+ * Content-addressed key for one file's rule-loop verdicts.
12782
13354
  *
12783
- * Values are hex without `#`, the form shields.io's endpoint takes.
12784
- * `A83A35`, `7A5F16`, `1F6F7C` and `5C646E` are the `TINT` strokes —
12785
- * the same deep steps the mermaid diagrams use, for the same reason.
12786
- * `8A6D1E` is the gold the brand document already named as the light
12787
- * FORGED gradient's start.
13355
+ * Audit C1: the key MUST identify the verdict's producer, not just the
13356
+ * bytes — two files with byte-identical text (a copied spec, a generated
13357
+ * snapshot) previously shared one entry, and the first file's cached
13358
+ * findings were re-emitted for the second with the wrong `file` stamp.
13359
+ * The key therefore folds in the repo-relative path AND the adapter id,
13360
+ * plus a parse-mode token (audit W9): a file whose analysis degraded to
13361
+ * the regex fallback (or skipped the AST path) must not collide with a
13362
+ * fully-AST-analyzed verdict for the same bytes — the fallback output
13363
+ * belongs only to the fallback mode.
12788
13364
  */
12789
- const BADGE_BAND = {
12790
- critical: "A83A35",
12791
- warning: "7A5F16",
12792
- trusted: "1F6F7C",
12793
- forged: "8A6D1E",
12794
- unmeasured: "5C646E"
12795
- };
12796
- 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;
12797
- 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;
12798
- //#endregion
12799
- //#region src/reporter/score-state.ts
12800
- const HEADLINES = {
12801
- critical: "The hammer is cracked — {n} findings break its edge.",
12802
- warning: "The hammer holds — but {n} findings weigh it down.",
12803
- trusted: "Held in worthy hands — {n} findings remain.",
12804
- forged: "Static score 100 — no findings on the analyzed surface.",
12805
- unmeasured: "No tests found — the hammer cannot be weighed."
12806
- };
12807
- const RUNES = {
12808
- critical: "ᚲ",
12809
- warning: "ᚦ",
12810
- trusted: "ᛏ",
12811
- forged: "ᛟ",
12812
- unmeasured: "ᛁ"
13365
+ function fileCacheKey(rulesDigest, fileText, identity) {
13366
+ const parseMode = identity.parseMode ?? "ast";
13367
+ return sha256(`${CACHE_VERSION}\u0000${rulesDigest}\u0000${identity.relPath}\u0000${identity.adapterId}\u0000${parseMode}\u0000${fileText}`);
13368
+ }
13369
+ /** No-op cache used when --cache is absent: zero stats, zero I/O. */
13370
+ const disabledScanCache = {
13371
+ stats: {
13372
+ hits: 0,
13373
+ misses: 0,
13374
+ file: ""
13375
+ },
13376
+ lookup: () => void 0,
13377
+ store: () => {},
13378
+ persist: () => {}
12813
13379
  };
12814
- /** Band mapping (the one mapping, three consumers): <50 critical, 50–79
12815
- * warning, 80–99 trusted, 100 forged, null unmeasured. */
12816
- function deriveScoreState(score) {
12817
- if (score === null) return {
12818
- score: null,
12819
- band: "unmeasured",
12820
- verdict: "UNWORTHY",
12821
- color: "dim",
12822
- powerLevel: 0,
12823
- headline: HEADLINES.unmeasured,
12824
- rune: RUNES.unmeasured
12825
- };
12826
- if (score >= 100) return {
12827
- score,
12828
- band: "forged",
12829
- verdict: "FORGED",
12830
- color: "forged",
12831
- powerLevel: score,
12832
- headline: HEADLINES.forged,
12833
- rune: RUNES.forged
12834
- };
12835
- if (score >= 80) return {
12836
- score,
12837
- band: "trusted",
12838
- verdict: "WORTHY",
12839
- color: "trusted",
12840
- powerLevel: score,
12841
- headline: HEADLINES.trusted,
12842
- rune: RUNES.trusted
12843
- };
12844
- if (score >= 50) return {
12845
- score,
12846
- band: "warning",
12847
- verdict: "NEEDS WORK",
12848
- color: "warning",
12849
- powerLevel: score,
12850
- headline: HEADLINES.warning,
12851
- rune: RUNES.warning
12852
- };
13380
+ /**
13381
+ * Opens (and lazily creates) `<root>/.mjolnir/cache/scan-v<CACHE_VERSION>.json`. A
13382
+ * corrupt, hostile or future-versioned cache file degrades to a cold
13383
+ * cache — never fails the scan.
13384
+ */
13385
+ function createScanCache(root) {
13386
+ const dir = join(root, ".mjolnir", "cache");
13387
+ const file = join(dir, `scan-v${CACHE_VERSION}.json`);
13388
+ let entries = {};
13389
+ let dirty = false;
13390
+ const entryBytes = /* @__PURE__ */ new Map();
13391
+ let totalBytes = 0;
13392
+ try {
13393
+ if (existsSync(file)) {
13394
+ const stat = lstatSync(file);
13395
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_TOTAL_BYTES) throw new Error("invalid cache file");
13396
+ entries = parseJsonFile(readFileSync(file, "utf8"), file, isCacheFile).entries;
13397
+ for (const [k, v] of Object.entries(entries)) {
13398
+ const size = Buffer.byteLength(JSON.stringify(v), "utf8") + k.length + 4;
13399
+ entryBytes.set(k, size);
13400
+ totalBytes += size;
13401
+ }
13402
+ if (totalBytes > MAX_TOTAL_BYTES) throw new Error("cache byte budget exceeded");
13403
+ }
13404
+ } catch {
13405
+ entries = {};
13406
+ entryBytes.clear();
13407
+ totalBytes = 0;
13408
+ }
12853
13409
  return {
12854
- score,
12855
- band: "critical",
12856
- verdict: "UNWORTHY",
12857
- color: "error",
12858
- powerLevel: score,
12859
- headline: HEADLINES.critical,
12860
- rune: RUNES.critical
13410
+ stats: {
13411
+ hits: 0,
13412
+ misses: 0,
13413
+ file
13414
+ },
13415
+ lookup(key) {
13416
+ const entry = entries[key];
13417
+ if (!entry) {
13418
+ this.stats.misses++;
13419
+ return;
13420
+ }
13421
+ this.stats.hits++;
13422
+ delete entries[key];
13423
+ entries[key] = entry;
13424
+ return structuredClone(entry.findings);
13425
+ },
13426
+ store(key, findings, fileBudgetExceeded) {
13427
+ if (fileBudgetExceeded) return;
13428
+ if (findings.length > MAX_FINDINGS_PER_ENTRY) return;
13429
+ const entryJson = JSON.stringify(findings);
13430
+ const newBytes = Buffer.byteLength(entryJson, "utf8") + key.length + 4;
13431
+ if (newBytes > MAX_TOTAL_BYTES) return;
13432
+ const replacedBytes = entryBytes.get(key) ?? 0;
13433
+ delete entries[key];
13434
+ entries[key] = { findings: structuredClone(findings) };
13435
+ entryBytes.set(key, newBytes);
13436
+ totalBytes = totalBytes - replacedBytes + newBytes;
13437
+ let count = Object.keys(entries).length;
13438
+ while ((count > MAX_ENTRIES$1 || totalBytes > MAX_TOTAL_BYTES) && count > 1) {
13439
+ const oldest = Object.keys(entries)[0];
13440
+ totalBytes -= entryBytes.get(oldest);
13441
+ delete entries[oldest];
13442
+ entryBytes.delete(oldest);
13443
+ count--;
13444
+ }
13445
+ dirty = true;
13446
+ },
13447
+ persist() {
13448
+ if (!dirty) return;
13449
+ try {
13450
+ mkdirSync(dir, { recursive: true });
13451
+ writeFileAtomic(file, JSON.stringify({
13452
+ version: CACHE_VERSION,
13453
+ entries
13454
+ }), { encoding: "utf8" });
13455
+ } catch {}
13456
+ }
12861
13457
  };
12862
13458
  }
12863
- /** Substitute a findings count into a headline template. Pure text
12864
- * helper so every surface formats identically. */
12865
- function headlineFor(state, findings) {
12866
- return state.headline.replace("{n}", String(findings));
12867
- }
12868
13459
  //#endregion
12869
13460
  //#region src/reporter/theme.ts
12870
13461
  /**
@@ -12880,8 +13471,8 @@ function headlineFor(state, findings) {
12880
13471
  *
12881
13472
  * Symbols always accompany color (color-blind safe, R11).
12882
13473
  *
12883
- * Score-state colors come from ScoreState (score-state.ts) — the single
12884
- * source of truth shared with the badge and (P2) the web.
13474
+ * Score-state colors come from ScoreState (presentation.ts) — the single
13475
+ * source of truth shared with the badge, the dashboard and the site.
12885
13476
  *
12886
13477
  * Terminal robustness (Master-Stabilization-Plan Sprint 5 Task 22):
12887
13478
  * box-drawing/gauge helpers accept an explicit width so callers can
@@ -13058,14 +13649,11 @@ function padTo(s, width) {
13058
13649
  */
13059
13650
  function scoreGauge(score, p, width = 30, ascii = false) {
13060
13651
  const filled = Math.round(score / 100 * width);
13061
- const color = gaugeColor(score, p);
13652
+ const color = gaugeColorForBand(deriveScoreState(score).band, p);
13062
13653
  if (ascii) return color("#".repeat(filled)) + ".".repeat(Math.max(0, width - filled));
13063
13654
  const head = filled > 0 && filled < width ? "▓" : "";
13064
13655
  return color("█".repeat(Math.max(0, filled - (head ? 1 : 0)))) + color(head) + "░".repeat(width - filled);
13065
13656
  }
13066
- function gaugeColor(score, p) {
13067
- return gaugeColorForBand(deriveScoreState(score).band, p);
13068
- }
13069
13657
  /**
13070
13658
  * The single band → palette-key mapping, delegated from ScoreState so
13071
13659
  * gauge, verdict and badge colors can never disagree again.
@@ -14220,7 +14808,7 @@ function runForensics(target, options = {}) {
14220
14808
  const base = stat.isFile() ? dirname(target) : target;
14221
14809
  flakyMdPath = join(base, "FLAKY.md");
14222
14810
  try {
14223
- writeFileSync(flakyMdPath, renderFlakyMd(report));
14811
+ writeFileAtomic(flakyMdPath, renderFlakyMd(report));
14224
14812
  } catch {
14225
14813
  flakyMdPath = void 0;
14226
14814
  }
@@ -15126,8 +15714,6 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
15126
15714
  skippedFiles++;
15127
15715
  continue;
15128
15716
  }
15129
- let fileBudgetExceeded = false;
15130
- let fileRuleFailed = false;
15131
15717
  const relPath = relative(workspace.root, path).replaceAll("\\", "/");
15132
15718
  if (!isCiAdapter) {
15133
15719
  const lang = {
@@ -15153,79 +15739,106 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
15153
15739
  });
15154
15740
  let cacheKey = fileCacheKey(rulesDigest, text, identity(wantsAst ? "ast" : "regex"));
15155
15741
  const cachedFindings = cache.lookup(cacheKey);
15156
- if (cachedFindings) {
15157
- for (const f of cachedFindings) findings.push(f);
15158
- analyzed++;
15159
- continue;
15160
- }
15161
- hooks.onProgress?.({
15162
- phase: "parse",
15163
- done: scanned,
15164
- total: testFiles.length,
15165
- detail: relPath
15166
- });
15167
- const parsedFile = {
15168
- path: relPath,
15169
- text
15170
- };
15171
- let parsed;
15172
- const findingsStart = findings.length;
15173
- try {
15174
- if (adapter.parseAst && wantsAst) {
15175
- hooks.onProgress?.({
15176
- phase: "rules",
15177
- done: scanned,
15178
- total: testFiles.length,
15179
- detail: relPath
15180
- });
15181
- parsed = await adapter.parseAst(parsedFile);
15182
- }
15183
- const actualMode = parsed ? "ast" : "regex";
15184
- if (wantsAst && actualMode === "regex") {
15185
- parseFallbacks++;
15186
- cacheKey = fileCacheKey(rulesDigest, text, identity(actualMode));
15187
- const fallbackFindings = cache.lookup(cacheKey);
15188
- if (fallbackFindings) {
15189
- for (const f of fallbackFindings) findings.push(f);
15190
- analyzed++;
15191
- continue;
15742
+ const executor = createExecutor("sequential", 1, async (job) => {
15743
+ if (job.cacheHit !== void 0) return {
15744
+ path: job.path,
15745
+ status: "CACHE_HIT",
15746
+ findings: [...job.cacheHit],
15747
+ parseFallback: false
15748
+ };
15749
+ let parseFallback = false;
15750
+ let fileRuleFailed = false;
15751
+ let fileBudgetExceeded = false;
15752
+ let parsedAst;
15753
+ try {
15754
+ if (adapter.parseAst && job.wantsAst) {
15755
+ hooks.onProgress?.({
15756
+ phase: "rules",
15757
+ done: scanned,
15758
+ total: testFiles.length,
15759
+ detail: job.path
15760
+ });
15761
+ parsedAst = await adapter.parseAst({
15762
+ path: job.path,
15763
+ text: job.text
15764
+ });
15192
15765
  }
15193
- }
15194
- const fileForRules = parsed ? {
15195
- ...parsedFile,
15196
- ast: parsed.ast
15197
- } : parsedFile;
15198
- adapter.runRules(activeRules, fileForRules, (f, ruleId, category) => {
15199
- if (!isValidFindingRecord(f)) {
15200
- fileRuleFailed = true;
15201
- onRuleCrash?.(ruleId, relPath, /* @__PURE__ */ new Error(`malformed finding record rejected (severity/line/message must be present, severity ∈ error|warning|info): ${JSON.stringify(f)}`));
15202
- return;
15766
+ const actualMode = parsedAst ? "ast" : "regex";
15767
+ if (job.wantsAst && actualMode === "regex") {
15768
+ parseFallback = true;
15769
+ parseFallbacks++;
15770
+ cacheKey = fileCacheKey(rulesDigest, job.text, identity(actualMode));
15771
+ const fallbackFindings = cache.lookup(cacheKey);
15772
+ if (fallbackFindings) return {
15773
+ path: job.path,
15774
+ status: "CACHE_HIT",
15775
+ findings: [...fallbackFindings],
15776
+ parseFallback: true
15777
+ };
15203
15778
  }
15204
- findings.push({
15205
- ...f,
15206
- ruleId,
15207
- category
15779
+ const fileForRules = parsedAst ? {
15780
+ path: job.path,
15781
+ text: job.text,
15782
+ ast: parsedAst.ast
15783
+ } : {
15784
+ path: job.path,
15785
+ text: job.text
15786
+ };
15787
+ const produced = [];
15788
+ adapter.runRules(activeRules, fileForRules, (f, ruleId, category) => {
15789
+ if (!isValidFindingRecord(f)) {
15790
+ fileRuleFailed = true;
15791
+ onRuleCrash?.(ruleId, job.path, /* @__PURE__ */ new Error(`malformed finding record rejected (severity/line/message must be present, severity ∈ error|warning|info): ${JSON.stringify(f)}`));
15792
+ return;
15793
+ }
15794
+ produced.push({
15795
+ ...f,
15796
+ ruleId,
15797
+ category
15798
+ });
15799
+ }, (ruleId, error) => {
15800
+ fileRuleFailed = true;
15801
+ onRuleCrash?.(ruleId, job.path, error);
15802
+ }, {
15803
+ deadline: Math.min(deadline, Date.now() + LIMITS$1.maxFileAnalysisMs),
15804
+ onExceeded: () => {
15805
+ rulesPartial = true;
15806
+ skippedFiles++;
15807
+ truncationReasons.add("file-budget");
15808
+ fileBudgetExceeded = true;
15809
+ }
15208
15810
  });
15209
- }, (ruleId, error) => {
15210
- fileRuleFailed = true;
15211
- onRuleCrash?.(ruleId, relPath, error);
15212
- }, {
15213
- deadline: Math.min(deadline, Date.now() + LIMITS$1.maxFileAnalysisMs),
15214
- onExceeded: () => {
15215
- rulesPartial = true;
15216
- skippedFiles++;
15217
- truncationReasons.add("file-budget");
15218
- fileBudgetExceeded = true;
15219
- }
15220
- });
15221
- if (!fileRuleFailed && !fileBudgetExceeded) analyzed++;
15222
- if (!fileRuleFailed) cache.store(cacheKey, findings.slice(findingsStart), fileBudgetExceeded);
15223
- } catch {
15224
- if (wantsAst) parseFallbacks++;
15225
- skippedFiles++;
15226
- parseFailed++;
15227
- } finally {
15228
- parsed?.dispose();
15811
+ if (!fileRuleFailed && !fileBudgetExceeded) analyzed++;
15812
+ if (!fileRuleFailed) cache.store(cacheKey, produced, fileBudgetExceeded);
15813
+ return {
15814
+ path: job.path,
15815
+ status: "OK",
15816
+ findings: produced,
15817
+ parseFallback
15818
+ };
15819
+ } catch {
15820
+ if (job.wantsAst) parseFallbacks++;
15821
+ skippedFiles++;
15822
+ parseFailed++;
15823
+ return {
15824
+ path: job.path,
15825
+ status: "FAILED",
15826
+ findings: [],
15827
+ parseFallback
15828
+ };
15829
+ } finally {
15830
+ parsedAst?.dispose();
15831
+ }
15832
+ });
15833
+ const outcome = (await executeFiles([{
15834
+ path: relPath,
15835
+ text,
15836
+ wantsAst,
15837
+ ...cachedFindings !== void 0 ? { cacheHit: cachedFindings } : {}
15838
+ }], executor))[0];
15839
+ if (outcome?.status === "CACHE_HIT" || outcome?.status === "OK") {
15840
+ for (const f of outcome.findings) findings.push(f);
15841
+ if (cachedFindings !== void 0 && outcome.status === "CACHE_HIT") analyzed++;
15229
15842
  }
15230
15843
  }
15231
15844
  return {
@@ -15316,12 +15929,16 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
15316
15929
  const runtimeReportPath = discoveredReport?.path;
15317
15930
  const runtimeIncomplete = discoveredReport !== void 0 && discoveredReport.report.analysisComplete !== true;
15318
15931
  let forensicVerdicts;
15932
+ let evidenceRecords = [];
15319
15933
  if (discoveredReport && discoveredReport.report.analysisComplete === true) try {
15320
- buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
15934
+ evidenceRecords = buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
15321
15935
  stampRuntimeCorroboration(findings, discoveredReport.report, workspace.root);
15322
15936
  forensicVerdicts = summarizeForensicVerdicts(discoveredReport.report);
15323
- } catch {}
15937
+ } catch {
15938
+ evidenceRecords = [];
15939
+ }
15324
15940
  return {
15941
+ evidenceRecords,
15325
15942
  testDeclarationCount,
15326
15943
  scopeInfo,
15327
15944
  suppressionCount,
@@ -15464,6 +16081,8 @@ function assembleScanResult(o) {
15464
16081
  ...o.runtimeIncomplete !== void 0 ? { runtimeIncomplete: o.runtimeIncomplete } : {},
15465
16082
  identityIncomplete
15466
16083
  });
16084
+ const repository = bindRepository(o.scanRoot.root);
16085
+ const candidate = readCandidateBinding(o.scanRoot.root);
15467
16086
  const runIdentity = buildRunIdentity({
15468
16087
  files: inputSnapshot,
15469
16088
  rules: [...o.REVISION_BY_RULE_ID.entries()].map(([id, detectorRevision]) => ({
@@ -15476,9 +16095,17 @@ function assembleScanResult(o) {
15476
16095
  trustModelVersion: TRUST_MODEL_VERSION,
15477
16096
  scoringModelVersion: SCORING_MODEL_VERSION,
15478
16097
  frameworkSupportMatrixVersion: FRAMEWORK_SUPPORT_MATRIX_VERSION,
15479
- evidenceSchemaVersions: [1]
16098
+ evidenceSchemaVersions: [1],
16099
+ ...repository,
16100
+ ...candidate !== null ? { candidate } : {}
16101
+ });
16102
+ const evidenceGraph = buildEvidenceGraph({
16103
+ runId: runIdentity,
16104
+ ...candidate !== null ? { candidate: {
16105
+ manifestId: candidate.manifestId,
16106
+ candidateSha: candidate.candidateSha
16107
+ } } : {}
15480
16108
  });
15481
- const evidenceGraph = buildEvidenceGraph({ runId: runIdentity });
15482
16109
  const hasTests = o.testFileCount > 0 && o.testDeclarationCount > 0;
15483
16110
  const suiteInvalidatedBy = [...new Set(o.findings.filter((f) => SUITE_INVALIDATING_RULE_IDS.has(f.ruleId)).map((f) => f.ruleId))].sort();
15484
16111
  const finalScore = hasTests ? completion.partial && scopeAdjustedTotal >= 100 ? 99 : scopeAdjustedTotal : null;
@@ -15488,6 +16115,11 @@ function assembleScanResult(o) {
15488
16115
  scopeIntegrity,
15489
16116
  runIdentity,
15490
16117
  evidenceGraph,
16118
+ evidence: {
16119
+ records: o.evidenceRecords ?? [],
16120
+ counts: countEvidence(o.evidenceRecords ?? []),
16121
+ artifact: o.runtimeReportPath ?? null
16122
+ },
15491
16123
  score: finalScore,
15492
16124
  ...hasTests ? {} : { reason: "no-tests-found" },
15493
16125
  frameworks: o.frameworks.frameworks,
@@ -15699,4 +16331,6 @@ async function runScan(args, hooks = {}) {
15699
16331
  return result;
15700
16332
  }
15701
16333
  //#endregion
15702
- export { correlateFindings as $, plainContext as A, parseJsonFile as At, shouldUseAscii as B, isValidCategory as Bt, runForensics as C, globToRegExp as Ct, nextStep as D, loadConfig as Dt, buildFooter as E, isSuppressionActive as Et, padTo as F, QA_IMPACT_LABELS as Ft, EVIDENCE as G, deriveScoreState as H, palette as I, RULE_CATEGORIES as It, atomicTempPath as J, TINT as K, sanitizeData as L, SEVERITY_ORDER as Lt, severityIcon as M, buildEvidenceGraph as Mt, box as N, MEASURED_FP as Nt, okIcon as O, readFileBounded as Ot, measure as P, DEDUCTIONS as Pt, getReachableFiles as Q, scoreGauge as R, deriveEvidenceLevel as Rt, renderSuppressions as S, createIgnoreMatcher as St, FLAKE_GLYPH as T, ConfigValidationError as Tt, headlineFor as U, wrapText as V, BADGE_BAND as W, writeFileAtomic as X, sweepStaleTempFiles as Y, buildDependencyGraph as Z, summarizeForensicVerdicts as _, computeCodeText as _t, applyPostScanProcessing as a, massCeiling as at, loadLocalRules as b, DEFAULT_IGNORE_MATCHER as bt, discoverAndParseRuntimeReport as c, getRule as ct, isValidFindingRecord as d, SCAN_ADAPTERS as dt, classifyProvenance as et, pathMatchesGlob as f, SEARCHED_FOR as ft, selectAdapter as g, parseYamlGuarded as gt, scan_pipeline_exports as h, parseWorkflow as ht, SUITE_INVALIDATING_RULE_IDS as i, deductionFor as it, sectionHeader as j, ENGINE_VERSION as jt, panel as k, isRecord as kt, discoverTestFilesPhase as l, resolveGitPath as lt, runScan as m, parseAzurePipeline as mt, KNOWN_RULE_IDS as n, capForTier as nt, assembleScanResult as o, RETIRED_RULE_IDS as ot, runFileAnalysisPhase as p, isAzurePipelineFixture as pt, TRUST as q, OVERLAP_META_BY_RULE_ID as r, computeDimensions as rt, buildUniversalRules as s, RULES as st, EVIDENCE_OVERRIDES as t, computeAgenticProfile as tt, fallbackWorkspace as u, runGit as ut, pluginsGateOpen as v, parseTsFile as vt, sanitizeErrorText as w, isLintFixtureDir as wt, loadSuppressions as x, LIMITS$1 as xt, renderGateNotice as y, detectFrameworks as yt, shouldColorize as z, isAdvisoryFinding as zt };
16334
+ export { BADGE_BAND as $, plainContext as A, isLintFixtureDir as At, shouldUseAscii as B, DEDUCTIONS as Bt, runForensics as C, computeCodeText as Ct, nextStep as D, LIMITS$1 as Dt, buildFooter as E, DEFAULT_IGNORE_MATCHER as Et, padTo as F, isRecord as Ft, countOrNull as G, deriveEvidenceLevel as Gt, atomicTempPath as H, RULE_CATEGORIES as Ht, palette as I, parseJsonFile as It, evidenceTag as J, deriveScoreState as K, isAdvisoryFinding as Kt, sanitizeData as L, ENGINE_VERSION as Lt, severityIcon as M, isSuppressionActive as Mt, box as N, loadConfig as Nt, okIcon as O, createIgnoreMatcher as Ot, measure as P, readFileBounded as Pt, TRUST_RUNGS as Q, scoreGauge as R, buildEvidenceGraph as Rt, renderSuppressions as S, parseYamlGuarded as St, FLAKE_GLYPH as T, detectFrameworks as Tt, sweepStaleTempFiles as U, SEVERITY_ORDER as Ut, wrapText as V, QA_IMPACT_LABELS as Vt, writeFileAtomic as W, TRUST_ORDER as Wt, testsAnalyzedCell as X, headlineFor as Y, verdictFor as Z, summarizeForensicVerdicts as _, SCAN_ADAPTERS as _t, applyPostScanProcessing as a, correlateFindings as at, loadLocalRules as b, parseAzurePipeline as bt, discoverAndParseRuntimeReport as c, capForTier as ct, isValidFindingRecord as d, massCeiling as dt, SCORE as et, pathMatchesGlob as f, RETIRED_RULE_IDS as ft, selectAdapter as g, runGit as gt, scan_pipeline_exports as h, resolveGitPath as ht, SUITE_INVALIDATING_RULE_IDS as i, getReachableFiles as it, sectionHeader as j, ConfigValidationError as jt, panel as k, globToRegExp as kt, discoverTestFilesPhase as l, computeDimensions as lt, runScan as m, getRule as mt, KNOWN_RULE_IDS as n, TINT as nt, assembleScanResult as o, classifyProvenance as ot, runFileAnalysisPhase as p, RULES as pt, evidenceLevelOf as q, isValidCategory as qt, OVERLAP_META_BY_RULE_ID as r, buildDependencyGraph as rt, buildUniversalRules as s, computeAgenticProfile as st, EVIDENCE_OVERRIDES as t, STATUS as tt, fallbackWorkspace as u, deductionFor as ut, pluginsGateOpen as v, SEARCHED_FOR as vt, sanitizeErrorText as w, parseTsFile as wt, loadSuppressions as x, parseWorkflow as xt, renderGateNotice as y, isAzurePipelineFixture as yt, shouldColorize as z, MEASURED_FP as zt };
16335
+
16336
+ //# sourceMappingURL=scan-pipeline-CCJdEKNa.mjs.map