mjolnir-qa 2.1.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 { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } 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
- import { createHash } from "node:crypto";
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)
@@ -97,6 +123,38 @@ const DEDUCTIONS = {
97
123
  info: 1
98
124
  };
99
125
  //#endregion
126
+ //#region src/engine/completion.ts
127
+ function deriveCompletion(input) {
128
+ const truncationReasons = [...new Set(input.truncationReasons)].sort();
129
+ const reasons = /* @__PURE__ */ new Set();
130
+ if (input.discoveryTruncated) reasons.add("discovery-truncated");
131
+ if (input.rulesPartial) reasons.add("rules-partial");
132
+ if (input.skippedFiles > 0) reasons.add(`skipped-files:${input.skippedFiles}`);
133
+ if (input.rulesCrashed > 0) reasons.add(`rules-crashed:${input.rulesCrashed}`);
134
+ if (input.scopeIgnored > 0) reasons.add(`scope-ignored:${input.scopeIgnored}`);
135
+ if (input.scopeUnrecognized > 0) reasons.add(`scope-unrecognized:${input.scopeUnrecognized}`);
136
+ if (input.parseFailed > 0) reasons.add(`parse-failed:${input.parseFailed}`);
137
+ if (input.parseFallbacks && input.parseFallbacks > 0) reasons.add(`parse-fallbacks:${input.parseFallbacks}`);
138
+ if (input.scopeDegraded) reasons.add(`scope-degraded:${input.scopeDegraded}`);
139
+ if (input.runtimeIncomplete) reasons.add("runtime-incomplete");
140
+ if (input.identityIncomplete) reasons.add("identity-incomplete");
141
+ for (const reason of truncationReasons) reasons.add(`truncated:${reason}`);
142
+ const discoveryPartial = input.discoveryTruncated || input.scopeIgnored > 0 || input.scopeUnrecognized > 0 || input.scopeDegraded !== void 0;
143
+ const rulesPartial = input.rulesPartial || input.rulesCrashed > 0 || input.parseFailed > 0;
144
+ return {
145
+ partial: discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0,
146
+ analysisStatus: {
147
+ discovery: discoveryPartial ? "partial" : "complete",
148
+ rules: rulesPartial ? "partial" : "complete",
149
+ skippedFiles: input.skippedFiles,
150
+ rulesCrashed: input.rulesCrashed,
151
+ parseFallbacks: input.parseFallbacks ?? 0,
152
+ ...truncationReasons.length > 0 ? { truncationReasons } : {},
153
+ reasons: [...reasons].sort()
154
+ }
155
+ };
156
+ }
157
+ //#endregion
100
158
  //#region src/engine/runtime-corroboration.ts
101
159
  /**
102
160
  * Stamp runtime corroboration + trust levels onto findings (mutates in
@@ -710,21 +768,13 @@ const MEASURED_FP = {
710
768
  * empty scan is an observation, not a proof).
711
769
  */
712
770
  function summaryTrustLevel(findings) {
713
- const order = [
714
- "L0",
715
- "L1",
716
- "L2",
717
- "L3",
718
- "L4",
719
- "L5"
720
- ];
721
771
  let best = 0;
722
772
  for (const f of findings) {
723
773
  const t = f.trustLevel ?? "L2";
724
- const idx = order.indexOf(t);
774
+ const idx = TRUST_ORDER.indexOf(t);
725
775
  if (idx > best) best = idx;
726
776
  }
727
- return order[best];
777
+ return TRUST_ORDER[best];
728
778
  }
729
779
  /**
730
780
  * A finding's trust rung (0–5) on the canonical ladder. Corroborated
@@ -735,14 +785,7 @@ function summaryTrustLevel(findings) {
735
785
  */
736
786
  function trustRung(f) {
737
787
  const t = f.trustLevel ?? deriveTrustLevel(f);
738
- return [
739
- "L0",
740
- "L1",
741
- "L2",
742
- "L3",
743
- "L4",
744
- "L5"
745
- ].indexOf(t);
788
+ return TRUST_ORDER.indexOf(t);
746
789
  }
747
790
  /**
748
791
  * Hard incompleteness ceilings (plan §6: partial/crash/truncation;
@@ -863,7 +906,10 @@ function sha256$1(text) {
863
906
  return createHash("sha256").update(text).digest("hex");
864
907
  }
865
908
  function canonical(value) {
866
- return JSON.stringify(value);
909
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
910
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
911
+ const record = value;
912
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`;
867
913
  }
868
914
  function buildRunIdentity(input) {
869
915
  const inputFingerprint = sha256$1([...input.files].map((f) => f.hash ? `${f.path}:${f.hash}` : `${f.path}:${f.size}`).sort().join("\n"));
@@ -876,6 +922,17 @@ function buildRunIdentity(input) {
876
922
  engineVersion: input.engineVersion
877
923
  };
878
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
+ };
879
936
  if (input.trustModelVersion !== void 0) verdictInputs.trustModelVersion = input.trustModelVersion;
880
937
  if (input.scoringModelVersion !== void 0) verdictInputs.scoringModelVersion = input.scoringModelVersion;
881
938
  if (input.frameworkSupportMatrixVersion !== void 0) verdictInputs.frameworkSupportMatrixVersion = input.frameworkSupportMatrixVersion;
@@ -883,15 +940,31 @@ function buildRunIdentity(input) {
883
940
  if (input.suppressionFingerprint !== void 0) verdictInputs.suppressionFingerprint = input.suppressionFingerprint;
884
941
  if (input.policyFingerprint !== void 0) verdictInputs.policyFingerprint = input.policyFingerprint;
885
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");
886
954
  return {
887
- scanId: sha256$1(canonical(verdictInputs)),
955
+ scanId,
888
956
  inputFingerprint,
889
957
  rulesDigest,
890
958
  configFingerprint,
891
959
  engineVersion: input.engineVersion,
960
+ boundLinks,
892
961
  ...input.trustModelVersion !== void 0 ? { trustModelVersion: input.trustModelVersion } : {},
893
962
  ...input.scoringModelVersion !== void 0 ? { scoringModelVersion: input.scoringModelVersion } : {},
894
- ...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 } : {}
895
968
  };
896
969
  }
897
970
  /**
@@ -932,9 +1005,218 @@ function buildEvidenceGraph(parts) {
932
1005
  ...parts.reproduction !== void 0 ? { ref: parts.reproduction } : {}
933
1006
  }
934
1007
  ],
935
- ...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
936
1210
  };
937
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
+ }
938
1220
  //#endregion
939
1221
  //#region src/engine/version.ts
940
1222
  /**
@@ -945,7 +1227,7 @@ function buildEvidenceGraph(parts) {
945
1227
  * scripts/sync-sarif-version.cjs and guarded by the version-consistency
946
1228
  * spec. cli.ts re-exports this as CLI_VERSION.
947
1229
  */
948
- const ENGINE_VERSION = "2.1.0";
1230
+ const ENGINE_VERSION = "4.0.0";
949
1231
  //#endregion
950
1232
  //#region src/engine/contract-versions.ts
951
1233
  /**
@@ -1106,7 +1388,9 @@ function basename$1(p) {
1106
1388
  * Internal files (cache, hashes) use `isRecord`; user-facing files
1107
1389
  * (config, plugins) use stricter checks that throw descriptive errors.
1108
1390
  */
1391
+ const MAX_JSON_BYTES = 16777216;
1109
1392
  function parseJsonFile(text, source, validate) {
1393
+ if (Buffer.byteLength(text, "utf8") > MAX_JSON_BYTES) throw new Error(`JSON source exceeds the ${MAX_JSON_BYTES}-byte limit: ${source}`);
1110
1394
  let parsed;
1111
1395
  try {
1112
1396
  parsed = JSON.parse(text);
@@ -1125,6 +1409,59 @@ function isRecord(v) {
1125
1409
  return typeof v === "object" && v !== null && !Array.isArray(v);
1126
1410
  }
1127
1411
  //#endregion
1412
+ //#region src/lib/fs-bounded.ts
1413
+ function closeQuiet(fd) {
1414
+ try {
1415
+ closeSync(fd);
1416
+ } catch {
1417
+ return;
1418
+ }
1419
+ }
1420
+ function readFileBounded(path, maxBytes) {
1421
+ let fd;
1422
+ try {
1423
+ const pathStat = lstatSync(path);
1424
+ if (pathStat.isSymbolicLink()) return {
1425
+ ok: false,
1426
+ reason: "symlink"
1427
+ };
1428
+ if (!pathStat.isFile()) return {
1429
+ ok: false,
1430
+ reason: "unreadable"
1431
+ };
1432
+ fd = openSync(path, 0);
1433
+ const fdStat = fstatSync(fd);
1434
+ if (!fdStat.isFile() || fdStat.size > maxBytes) return {
1435
+ ok: false,
1436
+ reason: fdStat.size > maxBytes ? "too-large" : "unreadable"
1437
+ };
1438
+ const chunks = [];
1439
+ let total = 0;
1440
+ const buffer = Buffer.allocUnsafe(Math.min(65536, maxBytes));
1441
+ while (true) {
1442
+ const read = readSync(fd, buffer, 0, buffer.length, null);
1443
+ if (read === 0) break;
1444
+ total += read;
1445
+ if (total > maxBytes) return {
1446
+ ok: false,
1447
+ reason: "too-large"
1448
+ };
1449
+ chunks.push(Buffer.from(buffer.subarray(0, read)));
1450
+ }
1451
+ return {
1452
+ ok: true,
1453
+ data: Buffer.concat(chunks)
1454
+ };
1455
+ } catch {
1456
+ return {
1457
+ ok: false,
1458
+ reason: "unreadable"
1459
+ };
1460
+ } finally {
1461
+ if (fd !== void 0) closeQuiet(fd);
1462
+ }
1463
+ }
1464
+ //#endregion
1128
1465
  //#region src/config/config-schema.ts
1129
1466
  const VALID_GATES = /* @__PURE__ */ new Set([
1130
1467
  "advisory",
@@ -1223,7 +1560,11 @@ const CONFIG_NAMES = ["mjolnir.config.json", ".mjolnir.json"];
1223
1560
  function findConfigPath(root) {
1224
1561
  for (const name of CONFIG_NAMES) {
1225
1562
  const p = join(root, name);
1226
- if (existsSync(p)) return p;
1563
+ try {
1564
+ if (existsSync(p) && !lstatSync(p).isSymbolicLink()) return p;
1565
+ } catch {
1566
+ continue;
1567
+ }
1227
1568
  }
1228
1569
  return null;
1229
1570
  }
@@ -1243,7 +1584,10 @@ function loadConfig(root, options = {}) {
1243
1584
  const p = join(root, name);
1244
1585
  if (!existsSync(p)) continue;
1245
1586
  try {
1246
- const parsed = parseJsonFile(readFileSync(p, "utf8"), p, (v) => isRecord(v));
1587
+ if (!existsSync(p) || lstatSync(p).isSymbolicLink()) continue;
1588
+ const read = readFileBounded(p, 1048576);
1589
+ if (!read.ok) throw new Error("config source could not be read safely");
1590
+ const parsed = parseJsonFile(read.data.toString("utf8"), p, (v) => isRecord(v));
1247
1591
  return {
1248
1592
  config: parsed,
1249
1593
  path: p,
@@ -1291,20 +1635,9 @@ function validate(cfg, knownRuleIds) {
1291
1635
  return warnings;
1292
1636
  }
1293
1637
  /**
1294
- * Bug-audit QA-2026-08-30 QA-6: the 90-day policy in the README was only
1295
- * applied at WRITE time by the `ignore` command — a hand-written entry
1296
- * without `expires` stayed active forever, silently bypassing the
1297
- * documented window.
1298
- *
1299
- * Audit S4 (remediation plan): the config-file MTIME is no longer an
1300
- * expiry anchor. Anchoring the 90-day default at mtime meant ANY edit to
1301
- * mjolnir.config.json — a reformat, an unrelated key, a `touch` — reset
1302
- * the 90-day window for EVERY hand-authored entry: suppressions could be
1303
- * extended indefinitely without touching their own fields. The expiry is
1304
- * now the entry's explicit `expires` date alone; an entry without one
1305
- * stays active and is honestly labeled "(no expiry set)" in the
1306
- * suppressions report. Hand-authored entries should declare `expires` at
1307
- * 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.
1308
1641
  */
1309
1642
  function isSuppressionActive(ign, now = /* @__PURE__ */ new Date()) {
1310
1643
  if (!ign.expires) return true;
@@ -1542,8 +1875,9 @@ function sharedWalk(options) {
1542
1875
  }
1543
1876
  const full = join(dir, entry.name);
1544
1877
  const rel = relative(options.root, full).replaceAll("\\", "/");
1878
+ if (entry.isDirectory() && options.skipDirs.includes(entry.name)) continue;
1545
1879
  if (options.ignoreMatcher.isIgnored(rel)) {
1546
- options.onIgnored?.();
1880
+ if (!rel.startsWith(".mjolnir/")) options.onIgnored?.(rel);
1547
1881
  continue;
1548
1882
  }
1549
1883
  if (entry.isSymbolicLink()) {
@@ -1551,7 +1885,6 @@ function sharedWalk(options) {
1551
1885
  continue;
1552
1886
  }
1553
1887
  if (entry.isDirectory()) {
1554
- if (options.skipDirs.includes(entry.name)) continue;
1555
1888
  if (rel.split("/").length > LIMITS$1.maxDepth) {
1556
1889
  options.onSkipped("max-depth");
1557
1890
  continue;
@@ -1569,7 +1902,7 @@ function sharedWalk(options) {
1569
1902
  } catch {
1570
1903
  options.onSkipped("stat-failed");
1571
1904
  }
1572
- else if (entry.isFile()) options.onUnrecognized?.();
1905
+ else if (entry.isFile()) options.onUnrecognized?.(rel);
1573
1906
  }
1574
1907
  };
1575
1908
  walk(options.root);
@@ -1587,7 +1920,33 @@ function sharedWalk(options) {
1587
1920
  *
1588
1921
  * When nothing is detectable we report `unknown` and the scanner analyzes
1589
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.
1590
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.
1944
+ */
1945
+ const DETECTABLE_TEST_FRAMEWORKS = [
1946
+ "jest",
1947
+ "vitest",
1948
+ "playwright"
1949
+ ];
1591
1950
  const CONFIG_FILES = {
1592
1951
  jest: [
1593
1952
  "jest.config.ts",
@@ -1606,7 +1965,7 @@ const CONFIG_FILES = {
1606
1965
  };
1607
1966
  function detectFrameworks(ws) {
1608
1967
  const found = /* @__PURE__ */ new Set();
1609
- 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);
1610
1969
  if (!found.has("jest") && ws.packageJson["jest"] !== void 0) found.add("jest");
1611
1970
  const deps = {
1612
1971
  ...ws.packageJson["dependencies"],
@@ -1628,11 +1987,7 @@ function detectFrameworks(ws) {
1628
1987
  };
1629
1988
  }
1630
1989
  return {
1631
- frameworks: [
1632
- "jest",
1633
- "vitest",
1634
- "playwright"
1635
- ].filter((f) => found.has(f)),
1990
+ frameworks: DETECTABLE_TEST_FRAMEWORKS.filter((f) => found.has(f)),
1636
1991
  unknown: false
1637
1992
  };
1638
1993
  }
@@ -1649,6 +2004,13 @@ function detectFrameworks(ws) {
1649
2004
  * byte-identical — a score shift is a regression, not an improvement.
1650
2005
  */
1651
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
+ */
1652
2014
  function getProject() {
1653
2015
  if (!project) project = new Project({
1654
2016
  useInMemoryFileSystem: true,
@@ -2175,7 +2537,36 @@ function frameworkFilterApplies(rule, file) {
2175
2537
  * "cypress", `@jest/globals` → "jest", `vitest` → "vitest". Config
2176
2538
  * gating is rule-declared (`configFiles`), not hard-coded here.
2177
2539
  */
2178
- 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)$/;
2179
2570
  /**
2180
2571
  * Fallback config list for `configOnly` rules that do not declare
2181
2572
  * `configFiles` (the legacy playwright.config.* gating, preserved
@@ -2282,11 +2673,42 @@ const typescriptAdapter = {
2282
2673
  fixtureDirMemo: /* @__PURE__ */ new Map()
2283
2674
  });
2284
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
+ },
2285
2707
  runRules(rules, file, emit, onCrash, budget) {
2286
- const withAst = {
2708
+ const withAst = file.ast === void 0 ? {
2287
2709
  ...file,
2288
2710
  ast: parseTsFile(file)
2289
- };
2711
+ } : file;
2290
2712
  const withTags = {
2291
2713
  ...withAst,
2292
2714
  frameworkTags: frameworkTagsFromImports(withAst.text)
@@ -3671,7 +4093,7 @@ const SCAN_ADAPTERS = [
3671
4093
  * the adapter that claims it; per-adapter caps still apply.
3672
4094
  */
3673
4095
  function discoverAllTestFiles(ctx, languageAdapters, buckets, fixtureDirMemo) {
3674
- const walkSkips = languageAdapters.reduce((common, a) => common.filter((name) => a.dirSkips.includes(name)), languageAdapters[0]?.dirSkips ?? []);
4096
+ const walkSkips = [.../* @__PURE__ */ new Set([...languageAdapters.reduce((common, a) => common.filter((name) => a.dirSkips.includes(name)), languageAdapters[0]?.dirSkips ?? []), ".mjolnir"])];
3675
4097
  sharedWalk({
3676
4098
  root: ctx.workspace.root,
3677
4099
  deadline: ctx.deadline,
@@ -3697,10 +4119,63 @@ function discoverAllTestFiles(ctx, languageAdapters, buckets, fixtureDirMemo) {
3697
4119
  },
3698
4120
  isFull: () => languageAdapters.every((a) => (buckets.get(a.id)?.length ?? 0) >= ctx.maxFiles),
3699
4121
  fixtureDirMemo,
3700
- onIgnored: ctx.onIgnored,
3701
- onUnrecognized: ctx.onUnrecognized
4122
+ onIgnored: (path) => {
4123
+ if (isScopeRelevantIgnored(path)) ctx.onIgnored?.(path);
4124
+ },
4125
+ onUnrecognized: (path) => {
4126
+ if (typeof path === "string" && isUnrecognizedSourceCandidate(path)) ctx.onUnrecognized?.(path);
4127
+ }
3702
4128
  });
3703
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
+ */
4157
+ function isScopeRelevantIgnored(path) {
4158
+ const name = path.replaceAll("\\", "/").split("/").pop() ?? path;
4159
+ if (!/\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml|min\.js)$/i.test(name)) return false;
4160
+ return isKnownTestFile(path);
4161
+ }
4162
+ function isUnrecognizedSourceCandidate(path) {
4163
+ const normalized = path.replaceAll("\\", "/");
4164
+ const name = normalized.split("/").pop() ?? normalized;
4165
+ if (normalized.includes(".github/workflows/") || name === "azure-pipelines.yml" || name === "Jenkinsfile") return false;
4166
+ const extensionIndex = name.lastIndexOf(".");
4167
+ const stem = extensionIndex > 0 ? name.slice(0, extensionIndex) : name;
4168
+ if (name === "mjolnir.config.json" || name === ".mjolnir.json" || name === "package.json" || name === "package-lock.json" || name === "pnpm-lock.yaml" || name === "yarn.lock" || [
4169
+ "playwright",
4170
+ "vitest",
4171
+ "vite",
4172
+ "eslint",
4173
+ "tsconfig",
4174
+ "jsconfig"
4175
+ ].some((prefix) => stem === prefix || stem.startsWith(`${prefix}.`))) return false;
4176
+ const testLike = /(?:^|\/)__tests__\//i.test(normalized) || /\.(?:spec|test)\.[cm]?[jt]sx?$/i.test(name);
4177
+ return /\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml)$/i.test(name) && testLike;
4178
+ }
3704
4179
  /** Whether ANY shipped adapter would discover this path as a test file. */
3705
4180
  function isKnownTestFile(path) {
3706
4181
  return SCAN_ADAPTERS.some((a) => a.isTestFile(path));
@@ -3764,17 +4239,14 @@ const SEARCHED_FOR = [
3764
4239
  * The fix: resolve git ONCE per process to an ABSOLUTE path by walking
3765
4240
  * the PATH directly (never consulting the CWD), verify the candidate is
3766
4241
  * an existing file, and pass that path to execFileSync. Resolution is
3767
- * memoized; a failure to find a real git anywhere on PATH degrades to
3768
- * the plain name (previous behavior) with the resolution error recorded
3769
- * — degraded git data already means full-file attribution, never a
3770
- * crash.
4242
+ * memoized; a failure to find a real git anywhere on PATH is recorded
4243
+ * and callers degrade without invoking a bare name.
3771
4244
  */
3772
4245
  let resolvedGit = void 0;
3773
4246
  /**
3774
4247
  * The absolute path of the git binary Mjölnir will invoke, or null when
3775
- * PATH carries no executable `git` at all (S1 degradation: callers fall
3776
- * back to the bare name and their own try/catch — same honest degrade
3777
- * as before, minus the CWD-hijack surface).
4248
+ * PATH carries no executable `git` at all. Callers must degrade without
4249
+ * invoking a bare name.
3778
4250
  */
3779
4251
  function resolveGitPath() {
3780
4252
  if (resolvedGit !== void 0) return resolvedGit;
@@ -3807,7 +4279,8 @@ function resolveGitPath() {
3807
4279
  * the previous inline `git()` helpers, with the hijack surface closed.
3808
4280
  */
3809
4281
  function runGit(root, args) {
3810
- const exe = resolveGitPath() ?? "git";
4282
+ const exe = resolveGitPath();
4283
+ if (!exe) return null;
3811
4284
  try {
3812
4285
  return execFileSync(exe, [
3813
4286
  "-C",
@@ -3943,7 +4416,7 @@ function computeChangedScope(root, baseBranch) {
3943
4416
  "--",
3944
4417
  ...chunk
3945
4418
  ]);
3946
- if (output === null) continue;
4419
+ if (output === null) return null;
3947
4420
  const sections = output.split("\ndiff --git ");
3948
4421
  for (const section of sections) {
3949
4422
  const body = section.startsWith("diff --git ") ? section : `diff --git ${section}`;
@@ -3971,6 +4444,11 @@ function computeChangedScope(root, baseBranch) {
3971
4444
  "--unified=0",
3972
4445
  "HEAD"
3973
4446
  ], changedFiles);
4447
+ if (committedDiffs === null || workingDiffs === null) return {
4448
+ changed: {},
4449
+ degraded: true,
4450
+ reason: "diff-failed"
4451
+ };
3974
4452
  for (const file of changedFiles) {
3975
4453
  const committedDiff = committedDiffs.get(file);
3976
4454
  const workingDiff = workingDiffs.get(file);
@@ -3996,8 +4474,11 @@ const LIMITS_MAX_LINES = 1e6;
3996
4474
  function allLinesOf(root, file) {
3997
4475
  const full = join(root, file);
3998
4476
  try {
3999
- if (statSync(full).size > LIMITS$1.maxFileBytes) return null;
4000
- const lineCount = readFileSync(full, "utf8").split("\n").length;
4477
+ const stat = lstatSync(full);
4478
+ if (stat.isSymbolicLink() || !stat.isFile()) return null;
4479
+ const read = readFileBounded(full, LIMITS$1.maxFileBytes);
4480
+ if (!read.ok) return null;
4481
+ const lineCount = read.data.toString("utf8").split("\n").length;
4001
4482
  return Array.from({ length: lineCount }, (_, i) => i + 1);
4002
4483
  } catch {
4003
4484
  return null;
@@ -7563,7 +8044,6 @@ const pyBareTruthinessAssert = defineRule({
7563
8044
  const re = /^[ \t]*assert\s+([A-Za-z_][\w.]*(?:\([^()]*\))?)[ \t]*$/gm;
7564
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*\()/;
7565
8046
  const isGuardFollowedByRealUse = (text, matchIndex, target) => {
7566
- const root = target.split(".")[0];
7567
8047
  const lineEnd = text.indexOf("\n", matchIndex);
7568
8048
  if (lineEnd === -1) return false;
7569
8049
  const lines = text.slice(lineEnd + 1).split("\n");
@@ -7573,8 +8053,11 @@ const pyBareTruthinessAssert = defineRule({
7573
8053
  if (/^\s*def\s/.test(l) && window.length > 0) break;
7574
8054
  window.push(l);
7575
8055
  }
7576
- const usesRoot = new RegExp(`\\b${root}\\b`);
7577
- 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
+ });
7578
8061
  };
7579
8062
  let m;
7580
8063
  while ((m = re.exec(text)) !== null) {
@@ -11577,6 +12060,22 @@ function normalizeOne(report, artifact, v) {
11577
12060
  function buildEvidenceRecords(report, artifact) {
11578
12061
  return report.verdicts.map((v) => normalizeOne(report, artifact, v)).sort(compareEvidenceRecords);
11579
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
+ }
11580
12079
  //#endregion
11581
12080
  //#region src/engine/provenance.ts
11582
12081
  const GENERATED_HEADER_RE = /^\s*(?:\/\/|#|\/\*)\s*(?:auto[- ]?generated|generated by|do not edit)/i;
@@ -11864,6 +12363,11 @@ var DependencyGraph = class {
11864
12363
  get size() {
11865
12364
  return this.nodes.size;
11866
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
+ }
11867
12371
  };
11868
12372
  function parsePackageJson(filePath) {
11869
12373
  if (!existsSync(filePath)) return void 0;
@@ -11951,20 +12455,40 @@ function parseManifest(filePath) {
11951
12455
  if (filePath.endsWith("pom.xml")) return parsePomXml(filePath);
11952
12456
  }
11953
12457
  /**
11954
- * Given a set of starting files (e.g. test files), return all files
11955
- * 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.
11956
12471
  */
11957
12472
  function getReachableFiles(fromFiles, graph) {
11958
12473
  const reachable = /* @__PURE__ */ new Set();
12474
+ const unresolvedStarts = [];
11959
12475
  const stack = [...fromFiles];
11960
12476
  while (stack.length > 0) {
11961
12477
  const current = stack.pop();
11962
12478
  if (current === void 0) continue;
11963
12479
  if (reachable.has(current)) continue;
11964
- reachable.add(current);
12480
+ if (graph.has(current)) reachable.add(current);
12481
+ else if (fromFiles.includes(current)) {
12482
+ unresolvedStarts.push(current);
12483
+ continue;
12484
+ }
11965
12485
  for (const dep of graph.getDependencies(current)) if (!reachable.has(dep)) stack.push(dep);
11966
12486
  }
11967
- return [...reachable].sort();
12487
+ return {
12488
+ reachable: [...reachable].sort(),
12489
+ unresolvedStarts: unresolvedStarts.sort(),
12490
+ resolvedAny: unresolvedStarts.length < fromFiles.length
12491
+ };
11968
12492
  }
11969
12493
  //#endregion
11970
12494
  //#region src/engine/incremental-analysis.ts
@@ -12004,607 +12528,934 @@ function isIncrementalSafe(changedFiles) {
12004
12528
  };
12005
12529
  }
12006
12530
  //#endregion
12007
- //#region src/engine/monorepo-analysis.ts
12008
- const WORTHY_THRESHOLD = 80;
12009
- const NEEDS_WORK_THRESHOLD = 50;
12010
- function verdictOf(score) {
12011
- if (score === null) return "fail";
12012
- if (score >= WORTHY_THRESHOLD) return "pass";
12013
- if (score >= NEEDS_WORK_THRESHOLD) return "warn";
12014
- return "fail";
12015
- }
12016
- function hasBlocker(findings) {
12017
- return findings.some((f) => f.severity === "error");
12018
- }
12531
+ //#region src/brand/tokens.ts
12019
12532
  /**
12020
- * Aggregate per-package results into an overall verdict.
12533
+ * The single source of brand truth.
12021
12534
  *
12022
- * Worst-package propagation: if ANY package has a blocker (error-severity
12023
- * finding) or a failing score, the overall result fails regardless of
12024
- * strategy. This is the safety net — a single poisoned package must not
12025
- * 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.
12026
12567
  */
12027
- function analyzeMonorepo(packages, config) {
12028
- const results = packages.map((p) => ({
12029
- ...p,
12030
- verdict: hasBlocker(p.findings) ? "fail" : verdictOf(p.score)
12031
- }));
12032
- if (results.length === 0) return {
12033
- packages: results,
12034
- overallScore: null,
12035
- overallVerdict: "fail",
12036
- strategy: config.weightingStrategy
12037
- };
12038
- const blockerPkg = results.find((r) => r.verdict === "fail" || hasBlocker(r.findings));
12039
- if (blockerPkg) return {
12040
- packages: results,
12041
- overallScore: blockerPkg.score,
12042
- overallVerdict: "fail",
12043
- strategy: config.weightingStrategy,
12044
- blockerPackage: blockerPkg.packageName
12045
- };
12046
- switch (config.weightingStrategy) {
12047
- case "worst-package": return worstPackageAggregation(results, config);
12048
- case "average": return averageAggregation(results, config);
12049
- case "configurable": return configurableAggregation(results, config);
12050
- }
12051
- }
12052
- function worstPackageAggregation(results, config) {
12053
- const firstResult = results[0];
12054
- if (firstResult === void 0) return {
12055
- packages: results,
12056
- overallScore: null,
12057
- overallVerdict: "fail",
12058
- strategy: config.weightingStrategy
12059
- };
12060
- let worst = firstResult;
12061
- for (const r of results) if ((r.score ?? 0) < (worst.score ?? 0)) worst = r;
12062
- return {
12063
- packages: results,
12064
- overallScore: worst.score,
12065
- overallVerdict: worst.verdict,
12066
- strategy: config.weightingStrategy,
12067
- ...worst.verdict === "fail" ? { blockerPackage: worst.packageName } : {}
12068
- };
12069
- }
12070
- function averageAggregation(results, config) {
12071
- const scored = results.filter((r) => r.score !== null);
12072
- if (scored.length === 0) return {
12073
- packages: results,
12074
- overallScore: null,
12075
- overallVerdict: "fail",
12076
- strategy: config.weightingStrategy
12077
- };
12078
- const avg = scored.reduce((sum, r) => sum + (r.score ?? 0), 0) / scored.length;
12079
- return {
12080
- packages: results,
12081
- overallScore: Math.round(avg),
12082
- overallVerdict: verdictOf(Math.round(avg)),
12083
- strategy: config.weightingStrategy
12084
- };
12085
- }
12086
- function configurableAggregation(results, config) {
12087
- const weights = config.packageWeights ?? {};
12088
- let totalWeight = 0;
12089
- let weightedSum = 0;
12090
- for (const r of results) {
12091
- const w = weights[r.packageName] ?? 1;
12092
- totalWeight += w;
12093
- weightedSum += (r.score ?? 0) * w;
12568
+ /**
12569
+ * The two brand hues plus the neutral they sit on.
12570
+ *
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.
12575
+ *
12576
+ * AURORA is verification energy — the secondary, and the hue that marks
12577
+ * the runtime half of the trust ladder.
12578
+ */
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
+ };
12595
+ /**
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.
12602
+ */
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
+ };
12626
+ /**
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.
12643
+ *
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.
12648
+ */
12649
+ const SCORE = {
12650
+ critical: "#EC6B66",
12651
+ warning: "#E6BD57",
12652
+ trusted: "#5CC4E0",
12653
+ forged: "#F4DC9C",
12654
+ unmeasured: "#8B939D"
12655
+ };
12656
+ /**
12657
+ * E0 → E1 → E2 is a certainty ramp, and it is deliberately HUE-FREE.
12658
+ *
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:
12663
+ *
12664
+ * E0 open ring observation, no weight
12665
+ * E1 half-filled pattern evidence, half weight
12666
+ * E2 sealed deterministic proof, full weight
12667
+ *
12668
+ * Colour never carries this alone (R11): the geometry is the signal and
12669
+ * survives `--ascii`, `NO_COLOR` and monochrome print.
12670
+ */
12671
+ const EVIDENCE = {
12672
+ e0: "#8B939D",
12673
+ e1: "#ABB6C6",
12674
+ e2: "#EAEEF5"
12675
+ };
12676
+ /**
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.
12686
+ */
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"
12094
12721
  }
12095
- if (totalWeight === 0) return {
12096
- packages: results,
12097
- overallScore: null,
12098
- overallVerdict: "fail",
12099
- strategy: config.weightingStrategy
12100
- };
12101
- const score = Math.round(weightedSum / totalWeight);
12102
- return {
12103
- packages: results,
12104
- overallScore: score,
12105
- overallVerdict: verdictOf(score),
12106
- strategy: config.weightingStrategy
12107
- };
12108
- }
12722
+ };
12723
+ /**
12724
+ * The score bands as the badge Mjölnir itself generates renders them.
12725
+ *
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.
12755
+ */
12756
+ const BADGE_BAND = {
12757
+ critical: "A83A35",
12758
+ warning: "7A5F16",
12759
+ trusted: "1F6F7C",
12760
+ forged: "8A6D1E",
12761
+ unmeasured: "5C646E"
12762
+ };
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
+ }));
12109
12788
  //#endregion
12110
- //#region src/engine/scan-cache.ts
12789
+ //#region src/reporter/presentation.ts
12111
12790
  /**
12112
- * Local incremental scan cache (Beta-to-Stable 1.0 plan, M5.2 / A-2).
12791
+ * presentation.ts — the one place a presentation DECISION is made.
12113
12792
  *
12114
- * Content-addressed, local-only verdict cache: `--cache` reuses the
12115
- * per-file rule outputs of a previous scan when the file's bytes AND the
12116
- * active rule set are unchanged, and invalidates everything else. The
12117
- * key is `sha256(fileText) + rulesDigest` + the file's own identity
12118
- * (repo-relative path + adapter id + parse mode — audit C1/W9), where
12119
- * the rules digest folds
12120
- * in every active rule's id + `detectorRevision ?? 1` (the existing
12121
- * stale-measurement machinery — Verification Trust Evolution Plan §07 —
12122
- * reused as the cache invalidation signal, per A-2) plus a source hash
12123
- * of each external plugin/local rule's `run` function, so a plugin that
12124
- * changes code without bumping its revision still misses.
12793
+ * BITTERSWEET `BW-030a`. Before this module the same decision was written
12794
+ * down four different times and every surface drifted:
12125
12795
  *
12126
- * Privacy posture: the cache lives under `<repo>/.mjolnir/cache/`, is
12127
- * gitignored, never leaves the machine, and this module performs zero
12128
- * network I/O — fs and crypto only (asserted by the privacy spec).
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.
12129
12805
  *
12130
- * Only raw rule-loop outputs are cached. Everything after the loop
12131
- * (severity overrides, suppressions, overlap dedup, evidence stamping,
12132
- * tier policy, runtime corroboration, scoring) re-runs on every scan,
12133
- * so a cached scan is byte-equivalent to a fresh one by construction.
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.
12811
+ *
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.
12814
+ *
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.
12134
12820
  */
12135
- const CACHE_VERSION = 2;
12136
- /** Entry cap: a monorepo-scale suite stays far below this; bounded file. */
12137
- const MAX_ENTRIES$1 = 4096;
12138
12821
  /**
12139
- * Audit M5: total byte budget. The old cap counted only ENTRIES, so
12140
- * 4096 files × ~100KB of findings each could still produce a
12141
- * multi-hundred-MB writeFileSync on persist. The budget bounds the
12142
- * serialized size; the newest entries win (real LRU-by-use).
12822
+ * What a surface prints when a number was never measured.
12823
+ *
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.
12143
12831
  */
12144
- const MAX_TOTAL_BYTES = 33554432;
12145
- /** sha256 hex of a string — the only hash this module needs. */
12146
- function sha256(text) {
12147
- return createHash("sha256").update(text).digest("hex");
12832
+ const UNMEASURED = "unknown — not measured";
12833
+ /**
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}`;
12148
12841
  }
12149
12842
  /**
12150
- * Digest of the active rule set: id + effective detectorRevision for
12151
- * every rule (sorted for determinism), plus a content hash of all rule
12152
- * source files. The directory-tree hash catches changes to ANY file
12153
- * under src/rules/ — same-file helpers, cross-module imports, shared
12154
- * utilities — without needing to trace the import graph. This is the
12155
- * only reliable way to detect helper function changes in a bundled
12156
- * ESM codebase where String(fn) doesn't reveal transitive dependencies.
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.
12157
12847
  */
12158
- function computeRulesDigest(rules) {
12159
- const parts = rules.map((r) => `${r.id}:${r.detectorRevision ?? 1}`).sort();
12160
- const rulesDirHash = hashRulesSourceTree(rules);
12161
- return sha256(parts.join("|") + "\0" + rulesDirHash);
12848
+ function countOrNull(value) {
12849
+ return value ?? null;
12162
12850
  }
12163
12851
  /**
12164
- * Hash all .ts files under src/rules/ (when the first rule's modulePath
12165
- * reveals the project root). Falls back to per-rule function source
12166
- * hashing when the directory cannot be resolved.
12852
+ * The "Tests analyzed" cell, shared by every human surface that shows it.
12853
+ *
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.
12167
12863
  */
12168
- function hashRulesSourceTree(rules) {
12169
- const firstPath = rules.find((r) => r.modulePath)?.modulePath;
12170
- if (!firstPath) return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
12171
- const idx = firstPath.replace(/\\/g, "/").indexOf("/src/rules/");
12172
- if (idx < 0) return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
12173
- const rulesDir = firstPath.replace(/\\/g, "/").slice(0, idx + 11);
12174
- try {
12175
- const hash = createHash("sha256");
12176
- hashDir(rulesDir, hash, 0);
12177
- return hash.digest("hex").slice(0, 16);
12178
- } catch {
12179
- return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
12180
- }
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"}`;
12181
12869
  }
12182
- function hashDir(dir, hash, depth) {
12183
- if (depth > 8) return;
12184
- let entries;
12185
- try {
12186
- entries = readdirSync(dir, { withFileTypes: true });
12187
- } catch {
12188
- return;
12189
- }
12190
- entries.sort((a, b) => a.name.localeCompare(b.name));
12191
- for (const entry of entries) {
12192
- if (entry.name === "node_modules" || entry.name === ".git") continue;
12193
- const full = join(dir, entry.name);
12194
- if (entry.isDirectory()) hashDir(full, hash, depth + 1);
12195
- else if (entry.isFile() && /\.tsx?$/.test(entry.name)) try {
12196
- hash.update(entry.name);
12197
- hash.update(readFileSync(full));
12198
- } catch {}
12199
- }
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."
12876
+ };
12877
+ const RUNES = {
12878
+ critical: "ᚲ",
12879
+ warning: "ᚦ",
12880
+ trusted: "ᛏ",
12881
+ forged: "ᛟ",
12882
+ unmeasured: "ᛁ"
12883
+ };
12884
+ /**
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
12897
+ };
12898
+ /**
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));
12200
12953
  }
12201
12954
  /**
12202
- * Content-addressed key for one file's rule-loop verdicts.
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.
12203
12959
  *
12204
- * Audit C1: the key MUST identify the verdict's producer, not just the
12205
- * bytes — two files with byte-identical text (a copied spec, a generated
12206
- * snapshot) previously shared one entry, and the first file's cached
12207
- * findings were re-emitted for the second with the wrong `file` stamp.
12208
- * The key therefore folds in the repo-relative path AND the adapter id,
12209
- * plus a parse-mode token (audit W9): a file whose analysis degraded to
12210
- * the regex fallback (or skipped the AST path) must not collide with a
12211
- * fully-AST-analyzed verdict for the same bytes — the fallback output
12212
- * belongs only to the fallback mode.
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.
12213
12965
  */
12214
- function fileCacheKey(rulesDigest, fileText, identity) {
12215
- const parseMode = identity.parseMode ?? "ast";
12216
- return sha256(`${CACHE_VERSION}\u0000${rulesDigest}\u0000${identity.relPath}\u0000${identity.adapterId}\u0000${parseMode}\u0000${fileText}`);
12966
+ function verdictFor(score) {
12967
+ const verdict = deriveScoreState(score).verdict;
12968
+ return verdict === "FORGED" ? "WORTHY" : verdict;
12217
12969
  }
12218
- /** No-op cache used when --cache is absent: zero stats, zero I/O. */
12219
- const disabledScanCache = {
12220
- stats: {
12221
- hits: 0,
12222
- misses: 0,
12223
- file: ""
12224
- },
12225
- lookup: () => void 0,
12226
- store: () => {},
12227
- persist: () => {}
12970
+ const EVIDENCE_KIND = {
12971
+ E0: "observation",
12972
+ E1: "heuristic",
12973
+ E2: "deterministic"
12228
12974
  };
12229
12975
  /**
12230
- * Opens (and lazily creates) `<root>/.mjolnir/cache/scan-v<CACHE_VERSION>.json`. A
12231
- * corrupt, hostile or future-versioned cache file degrades to a cold
12232
- * cache — never fails the scan.
12976
+ * The effective evidence level of a finding.
12977
+ *
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.
12233
12983
  */
12234
- function createScanCache(root) {
12235
- const dir = join(root, ".mjolnir", "cache");
12236
- const file = join(dir, `scan-v${CACHE_VERSION}.json`);
12237
- let entries = {};
12238
- let dirty = false;
12239
- const entryBytes = /* @__PURE__ */ new Map();
12240
- let totalBytes = 0;
12241
- try {
12242
- if (existsSync(file)) {
12243
- entries = parseJsonFile(readFileSync(file, "utf8"), file, (v) => isRecord(v) && v["version"] === CACHE_VERSION && isRecord(v["entries"])).entries;
12244
- for (const [k, v] of Object.entries(entries)) {
12245
- const size = JSON.stringify(v).length + k.length + 4;
12246
- entryBytes.set(k, size);
12247
- totalBytes += size;
12248
- }
12249
- }
12250
- } catch {
12251
- entries = {};
12252
- entryBytes.clear();
12253
- totalBytes = 0;
12254
- }
12255
- return {
12256
- stats: {
12257
- hits: 0,
12258
- misses: 0,
12259
- file
12260
- },
12261
- lookup(key) {
12262
- const entry = entries[key];
12263
- if (!entry) {
12264
- this.stats.misses++;
12265
- return;
12266
- }
12267
- this.stats.hits++;
12268
- delete entries[key];
12269
- entries[key] = entry;
12270
- return structuredClone(entry.findings);
12271
- },
12272
- store(key, findings, fileBudgetExceeded) {
12273
- if (fileBudgetExceeded) return;
12274
- const replacedBytes = entryBytes.get(key) ?? 0;
12275
- delete entries[key];
12276
- const entryJson = JSON.stringify(findings);
12277
- entries[key] = { findings: structuredClone(findings) };
12278
- const newBytes = entryJson.length + key.length + 4;
12279
- entryBytes.set(key, newBytes);
12280
- totalBytes = totalBytes - replacedBytes + newBytes;
12281
- let count = Object.keys(entries).length;
12282
- while ((count > MAX_ENTRIES$1 || totalBytes > MAX_TOTAL_BYTES) && count > 1) {
12283
- const oldest = Object.keys(entries)[0];
12284
- totalBytes -= entryBytes.get(oldest);
12285
- delete entries[oldest];
12286
- entryBytes.delete(oldest);
12287
- count--;
12288
- }
12289
- dirty = true;
12290
- },
12291
- persist() {
12292
- if (!dirty) return;
12293
- try {
12294
- mkdirSync(dir, { recursive: true });
12295
- writeFileSync(file, JSON.stringify({
12296
- version: CACHE_VERSION,
12297
- entries
12298
- }), "utf8");
12299
- } catch {}
12300
- }
12301
- };
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";
12302
12994
  }
12303
- //#endregion
12304
- //#region src/brand/tokens.ts
12305
12995
  /**
12306
- * The single source of brand truth.
12307
- *
12308
- * Every colour, typeface and motion constant Mjölnir shows a human —
12309
- * terminal, README SVGs, demo video, website, docs, badges — resolves to
12310
- * a value in this file. Nothing else may define one.
12311
- *
12312
- * WHY THIS EXISTS. Before it, the palette existed in six independent
12313
- * copies: `site/.vitepress/theme/styles/vars.css`, `NORSE` in
12314
- * `src/reporter/theme.ts`, `scripts/readme-svg.ts`,
12315
- * `scripts/video/terminal-page.ts`, `scripts/generate-readme-architecture.ts`
12316
- * and the table in `assets/brand/README.md`. Exactly one pair of those
12317
- * was guarded (site-doctor Check 8, doc ↔ vars.css). The unguarded edges
12318
- * are where the shipped surfaces drifted apart: the terminal and the site
12319
- * disagreed on six semantic roles, the architecture diagram invented its
12320
- * own neutral ramp, and the README badges still carried a palette retired
12321
- * two releases earlier. `scripts/brand-doctor.mjs` now checks every edge
12322
- * against this file.
12323
- *
12324
- * PURITY. Pure data. No I/O, no rendering, no environment access, no
12325
- * imports, no logic. Consumers convert (hex → ANSI triplet, hex → CSS)
12326
- * themselves. Same reason `score-state.ts` is pure: it makes the whole
12327
- * thing golden-testable and safe to ship inside the npm package, where
12328
- * it costs a few hundred bytes and replaces values the package already
12329
- * carried anyway.
12996
+ * The canonical evidence descriptor — `[E2 · deterministic · measured FP
12997
+ * 8% · n=14 · trust L3 · runtime: file executed]`.
12330
12998
  *
12331
- * DERIVATION. The palette is the one derived from the logo in PR #20
12332
- * (brushed steel and forge gold under an aurora, over midnight iron).
12333
- * Where the terminal disagreed with it, the terminal converges — see
12334
- * `PENDING_TERMINAL` below. Full rationale: `assets/brand/README.md`.
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.
12335
13004
  *
12336
- * ACCESSIBILITY. Every foreground token in `BRAND` meets WCAG AA
12337
- * (≥ 4.5:1) against every surface token it is allowed to sit on. That is
12338
- * not a claim, it is `brand-doctor` rule 8, which computes the ratios.
12339
- * The weakest legal pairing is `steelDim` on `ink800` at 5.00:1.
13005
+ * Every clause is conditional on the finding actually carrying that
13006
+ * evidence. Nothing here is ever defaulted into existence.
12340
13007
  */
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
12341
13022
  /**
12342
- * The two brand hues plus the neutral they sit on.
12343
- *
12344
- * GOLD IS SCARCE. It means forged / certified / earned / decisive — the
12345
- * primary mark, the FORGED state, one call to action. It is not a paint
12346
- * bucket: gold as default text, default border or default heading is a
12347
- * brand-doctor finding, not a style choice.
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.
12348
13040
  *
12349
- * AURORA is verification energy — the secondary, and the hue that marks
12350
- * the runtime half of the trust ladder.
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.
12351
13045
  */
12352
- const BRAND = {
12353
- gold: "#C19A34",
12354
- goldBright: "#E6BD57",
12355
- goldHot: "#F4DC9C",
12356
- /** Pressed / deepest gold — the only step dark enough to carry white. */
12357
- goldDeep: "#A5811C",
12358
- aurora: "#37ABBD",
12359
- auroraBright: "#45C1D4",
12360
- auroraCyan: "#5CBDE0",
12361
- /** The aurora's outer curtains: atmosphere and section identity only,
12362
- * never a verdict or a status. */
12363
- auroraGreen: "#5FD6A4",
12364
- auroraViolet: "#9D8CF5",
12365
- steel: "#C8CBCF",
12366
- steelDim: "#8B939D"
12367
- };
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
12368
13130
  /**
12369
- * Midnight iron. One ramp, four steps, darkest first.
13131
+ * Atomic file writes (audit S9).
12370
13132
  *
12371
- * `terminal` and `terminalBar` share one tone deliberately: the window's
12372
- * only seam is a hairline ring and an inset shadow, never a second fill.
12373
- * `chromeDot` is the three window dots — see the note on
12374
- * `PENDING_TERMINAL.chromeDots` for why they are no longer red/amber/green.
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).
12375
13146
  */
12376
- const SURFACE = {
12377
- ink950: "#0A1119",
12378
- ink900: "#0C1420",
12379
- ink850: "#111A29",
12380
- ink800: "#18243A",
12381
- /** Raised panel (cards, elevated surfaces). */
12382
- panel: "#141F33",
12383
- /** Soft fill (inline code, quiet chips). */
12384
- soft: "#1A2740",
12385
- /** Terminal body — the deepest tone, so a terminal reads as recessed. */
12386
- terminal: "#0A1119",
12387
- /** Terminal title bar — the same tone; the seam is shadow, not colour. */
12388
- terminalBar: "#0A1119",
12389
- /** The three window dots. One neutral, not a traffic light. */
12390
- chromeDot: "#18243A"
12391
- };
12392
- const TEXT = {
12393
- primary: "#EAEEF5",
12394
- secondary: "#ABB6C6",
12395
- muted: "#8B939D",
12396
- /** Ink for text set ON gold (buttons, the FORGED chip). 7.17:1 on `gold`. */
12397
- onGold: "#0A1119"
12398
- };
13147
+ function atomicTempPath(path) {
13148
+ return `${path}.mjolnir-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}.tmp`;
13149
+ }
12399
13150
  /**
12400
- * Non-score status. `ok` is the one green in the system and it is NOT a
12401
- * score colour — it survives only for contexts with no worthiness
12402
- * meaning ("autofix applied", "analysis complete"). A green score would
12403
- * say "your software is fine", which is the exact claim this product
12404
- * refuses to make.
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.
12405
13156
  */
12406
- const STATUS = {
12407
- ok: "#4FB477",
12408
- info: "#5CC4E0",
12409
- warning: "#E6BD57",
12410
- error: "#EC6B66"
12411
- };
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
+ }
12412
13178
  /**
12413
- * The four ScoreState bands plus the unmeasured state. Band thresholds
12414
- * and runes live in `src/reporter/score-state.ts`, which stays free of
12415
- * colour — it emits a palette KEY and each surface resolves it here.
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.
12416
13183
  *
12417
- * `unmeasured` is steel-dim on purpose. UNKNOWN is a legitimate answer,
12418
- * not a failure: colouring it red would make "we did not measure this"
12419
- * look like "this is broken", which is precisely the dishonesty the
12420
- * north-star law exists to prevent.
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).
12421
13187
  */
12422
- const SCORE = {
12423
- critical: "#EC6B66",
12424
- warning: "#E6BD57",
12425
- trusted: "#5CC4E0",
12426
- forged: "#F4DC9C",
12427
- unmeasured: "#8B939D"
12428
- };
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
12429
13251
  /**
12430
- * E0 → E1 → E2 is a certainty ramp, and it is deliberately HUE-FREE.
13252
+ * Local incremental scan cache (Beta-to-Stable 1.0 plan, M5.2 / A-2).
12431
13253
  *
12432
- * Evidence level says how sure we are, not whether the news is good. A
12433
- * deterministic proof (E2) is a defect we are certain about — painting
12434
- * it gold or green would read as an achievement. So certainty is carried
12435
- * by brightness alone, and the *shape* does the real work:
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.
12436
13265
  *
12437
- * E0 open ring observation, no weight
12438
- * E1 half-filled pattern evidence, half weight
12439
- * E2 sealed deterministic proof, full weight
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).
12440
13269
  *
12441
- * Colour never carries this alone (R11): the geometry is the signal and
12442
- * survives `--ascii`, `NO_COLOR` and monochrome print.
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.
12443
13274
  */
12444
- const EVIDENCE = {
12445
- e0: "#8B939D",
12446
- e1: "#ABB6C6",
12447
- e2: "#EAEEF5"
12448
- };
13275
+ const CACHE_VERSION = 2;
13276
+ /** Entry cap: a monorepo-scale suite stays far below this; bounded file. */
13277
+ const MAX_ENTRIES$1 = 4096;
12449
13278
  /**
12450
- * L0–L5, and the most important boundary in the product.
12451
- *
12452
- * L0–L2 are STATIC: the neutral steel ramp, brightening to the static
12453
- * ceiling at L2. L3–L5 require a real run, and the hue changes to aurora
12454
- * exactly there. The boundary is a hue break, not a gradient step,
12455
- * because it is a change of kind and not of degree — a static-only
12456
- * finding can never climb past L2, however confident it is.
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;
13340
+ }
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
+ }
13352
+ /**
13353
+ * Content-addressed key for one file's rule-loop verdicts.
12457
13354
  *
12458
- * Every surface that draws the ladder must draw that break.
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.
12459
13364
  */
12460
- const TRUST = {
12461
- l0: "#8B939D",
12462
- l1: "#ABB6C6",
12463
- l2: "#C8CBCF",
12464
- l3: "#37ABBD",
12465
- l4: "#45C1D4",
12466
- l5: "#5CC4E0"
12467
- };
12468
- const TINT = {
12469
- gold: {
12470
- fill: "#F6EBCC",
12471
- stroke: "#7A5F16",
12472
- text: "#4A3A0E"
12473
- },
12474
- aurora: {
12475
- fill: "#D9F0F4",
12476
- stroke: "#1F6F7C",
12477
- text: "#10353C"
12478
- },
12479
- error: {
12480
- fill: "#FADEDD",
12481
- stroke: "#A83A35",
12482
- text: "#4E1B19"
12483
- },
12484
- /** The unmeasured / unknown state. Neutral, never the error tint. */
12485
- neutral: {
12486
- fill: "#E4E7EB",
12487
- stroke: "#5C646E",
12488
- text: "#262B31"
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: ""
12489
13375
  },
12490
- ok: {
12491
- fill: "#DCF0E4",
12492
- stroke: "#276B45",
12493
- text: "#163A26"
12494
- }
13376
+ lookup: () => void 0,
13377
+ store: () => {},
13378
+ persist: () => {}
12495
13379
  };
12496
13380
  /**
12497
- * The score bands as the badge Mjölnir itself generates renders them.
12498
- *
12499
- * These are DEEPER than the score tokens on purpose, and it is not a
12500
- * style preference: shields.io sets the message text in white and gives
12501
- * you no say in it. `score.forged` (#F4DC9C) under white text measures
12502
- * 1.35:1 — an unreadable badge, shipped to look on-brand. The brand's
12503
- * own deep steps put every band between 4.9 and 6.3:1.
12504
- *
12505
- * What they replace was worse than off-brand, it was wrong:
12506
- *
12507
- * band was rendered as white-on now white-on
12508
- * ────────────────────────────────────────────────────────────────────────────
12509
- * 0-49 `red` #dd4343 4.24 #A83A35 6.32
12510
- * 50-79 `yellow` #d8b800 1.95 #7A5F16 6.04
12511
- * 80-99 `important` #ea7233 ORANGE 3.02 #1F6F7C 5.80
12512
- * 100 `success` #44bb00 GREEN 2.51 #8A6D1E 4.90
12513
- * unmeasured `lightgrey` #939393 3.07 #5C646E 5.99
12514
- *
12515
- * Two of those were defects, not preferences. `success` is green, and
12516
- * green is not a score colour here — a 100 badge said "your software is
12517
- * fine", which is the one claim this product refuses to make. And
12518
- * `important` is ORANGE, not the blue-family colour the code's own
12519
- * comment claimed for eight releases: every WORTHY badge ever rendered
12520
- * showed the trusted band in a warning colour. Nobody had resolved a
12521
- * shields name to a value and looked.
12522
- *
12523
- * Values are hex without `#`, the form shields.io's endpoint takes.
12524
- * `A83A35`, `7A5F16`, `1F6F7C` and `5C646E` are the `TINT` strokes —
12525
- * the same deep steps the mermaid diagrams use, for the same reason.
12526
- * `8A6D1E` is the gold the brand document already named as the light
12527
- * FORGED gradient's start.
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.
12528
13384
  */
12529
- const BADGE_BAND = {
12530
- critical: "A83A35",
12531
- warning: "7A5F16",
12532
- trusted: "1F6F7C",
12533
- forged: "8A6D1E",
12534
- unmeasured: "5C646E"
12535
- };
12536
- 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;
12537
- 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;
12538
- //#endregion
12539
- //#region src/reporter/score-state.ts
12540
- const HEADLINES = {
12541
- critical: "The hammer is cracked — {n} findings break its edge.",
12542
- warning: "The hammer holds — but {n} findings weigh it down.",
12543
- trusted: "Held in worthy hands — {n} findings remain.",
12544
- forged: "Static score 100 — no findings on the analyzed surface.",
12545
- unmeasured: "No tests found — the hammer cannot be weighed."
12546
- };
12547
- const RUNES = {
12548
- critical: "ᚲ",
12549
- warning: "ᚦ",
12550
- trusted: "ᛏ",
12551
- forged: "ᛟ",
12552
- unmeasured: "ᛁ"
12553
- };
12554
- /** Band mapping (the one mapping, three consumers): <50 critical, 50–79
12555
- * warning, 80–99 trusted, 100 forged, null unmeasured. */
12556
- function deriveScoreState(score) {
12557
- if (score === null) return {
12558
- score: null,
12559
- band: "unmeasured",
12560
- verdict: "UNWORTHY",
12561
- color: "dim",
12562
- powerLevel: 0,
12563
- headline: HEADLINES.unmeasured,
12564
- rune: RUNES.unmeasured
12565
- };
12566
- if (score >= 100) return {
12567
- score,
12568
- band: "forged",
12569
- verdict: "FORGED",
12570
- color: "forged",
12571
- powerLevel: score,
12572
- headline: HEADLINES.forged,
12573
- rune: RUNES.forged
12574
- };
12575
- if (score >= 80) return {
12576
- score,
12577
- band: "trusted",
12578
- verdict: "WORTHY",
12579
- color: "trusted",
12580
- powerLevel: score,
12581
- headline: HEADLINES.trusted,
12582
- rune: RUNES.trusted
12583
- };
12584
- if (score >= 50) return {
12585
- score,
12586
- band: "warning",
12587
- verdict: "NEEDS WORK",
12588
- color: "warning",
12589
- powerLevel: score,
12590
- headline: HEADLINES.warning,
12591
- rune: RUNES.warning
12592
- };
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
+ }
12593
13409
  return {
12594
- score,
12595
- band: "critical",
12596
- verdict: "UNWORTHY",
12597
- color: "error",
12598
- powerLevel: score,
12599
- headline: HEADLINES.critical,
12600
- 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
+ }
12601
13457
  };
12602
13458
  }
12603
- /** Substitute a findings count into a headline template. Pure text
12604
- * helper so every surface formats identically. */
12605
- function headlineFor(state, findings) {
12606
- return state.headline.replace("{n}", String(findings));
12607
- }
12608
13459
  //#endregion
12609
13460
  //#region src/reporter/theme.ts
12610
13461
  /**
@@ -12620,8 +13471,8 @@ function headlineFor(state, findings) {
12620
13471
  *
12621
13472
  * Symbols always accompany color (color-blind safe, R11).
12622
13473
  *
12623
- * Score-state colors come from ScoreState (score-state.ts) — the single
12624
- * 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.
12625
13476
  *
12626
13477
  * Terminal robustness (Master-Stabilization-Plan Sprint 5 Task 22):
12627
13478
  * box-drawing/gauge helpers accept an explicit width so callers can
@@ -12798,14 +13649,11 @@ function padTo(s, width) {
12798
13649
  */
12799
13650
  function scoreGauge(score, p, width = 30, ascii = false) {
12800
13651
  const filled = Math.round(score / 100 * width);
12801
- const color = gaugeColor(score, p);
13652
+ const color = gaugeColorForBand(deriveScoreState(score).band, p);
12802
13653
  if (ascii) return color("#".repeat(filled)) + ".".repeat(Math.max(0, width - filled));
12803
13654
  const head = filled > 0 && filled < width ? "▓" : "";
12804
13655
  return color("█".repeat(Math.max(0, filled - (head ? 1 : 0)))) + color(head) + "░".repeat(width - filled);
12805
13656
  }
12806
- function gaugeColor(score, p) {
12807
- return gaugeColorForBand(deriveScoreState(score).band, p);
12808
- }
12809
13657
  /**
12810
13658
  * The single band → palette-key mapping, delegated from ScoreState so
12811
13659
  * gauge, verdict and badge colors can never disagree again.
@@ -13960,7 +14808,7 @@ function runForensics(target, options = {}) {
13960
14808
  const base = stat.isFile() ? dirname(target) : target;
13961
14809
  flakyMdPath = join(base, "FLAKY.md");
13962
14810
  try {
13963
- writeFileSync(flakyMdPath, renderFlakyMd(report));
14811
+ writeFileAtomic(flakyMdPath, renderFlakyMd(report));
13964
14812
  } catch {
13965
14813
  flakyMdPath = void 0;
13966
14814
  }
@@ -14276,6 +15124,31 @@ function loadPlugins(root, gateOpen = false) {
14276
15124
  * next catalog render (locked by tests/local-rules.spec.ts).
14277
15125
  */
14278
15126
  const LOCAL_RULES_DIR = "mjolnir-rules";
15127
+ const MAX_EXTERNAL_RULES = 1e3;
15128
+ const MAX_RULE_FILE_BYTES = 1048576;
15129
+ const MAX_PATTERNS_PER_RULE = 100;
15130
+ function hasNestedQuantifier(pattern) {
15131
+ for (let i = 0; i < pattern.length; i++) {
15132
+ if (pattern.charAt(i) !== "(") continue;
15133
+ let bodyHasQuantifier = false;
15134
+ let j = i + 1;
15135
+ for (; j < pattern.length && pattern.charAt(j) !== ")"; j++) {
15136
+ const bodyChar = pattern.charAt(j);
15137
+ if (bodyChar === "+" || bodyChar === "*" || bodyChar === "?") {
15138
+ bodyHasQuantifier = true;
15139
+ break;
15140
+ }
15141
+ }
15142
+ if (bodyHasQuantifier) {
15143
+ while (j < pattern.length && pattern.charAt(j) !== ")") j++;
15144
+ if (j + 1 < pattern.length) {
15145
+ const next = pattern.charAt(j + 1);
15146
+ if (next === "+" || next === "*" || next === "{") return true;
15147
+ }
15148
+ }
15149
+ }
15150
+ return false;
15151
+ }
14279
15152
  const ALLOWED_CATEGORIES = /* @__PURE__ */ new Set([
14280
15153
  "QA-TEST",
14281
15154
  "QA-TQUAL",
@@ -14305,7 +15178,7 @@ const ALLOWED_QA_IMPACTS = /* @__PURE__ */ new Set([
14305
15178
  * Missing directory → empty result (not an error — most workspaces
14306
15179
  * carry none).
14307
15180
  */
14308
- async function loadLocalRules(root, gateOpen = true) {
15181
+ async function loadLocalRules(root, gateOpen = false) {
14309
15182
  const result = {
14310
15183
  rules: [],
14311
15184
  errors: [],
@@ -14313,6 +15186,15 @@ async function loadLocalRules(root, gateOpen = true) {
14313
15186
  };
14314
15187
  const dir = join(root, LOCAL_RULES_DIR);
14315
15188
  if (!existsSync(dir)) return result;
15189
+ try {
15190
+ if (lstatSync(dir).isSymbolicLink()) {
15191
+ result.errors.push(`external rules directory "${LOCAL_RULES_DIR}/" is a symlink — skipped`);
15192
+ return result;
15193
+ }
15194
+ } catch (err) {
15195
+ result.errors.push(`external rules directory "${LOCAL_RULES_DIR}/" could not be inspected: ${err instanceof Error ? err.message : String(err)}`);
15196
+ return result;
15197
+ }
14316
15198
  let entries;
14317
15199
  try {
14318
15200
  entries = readdirSync(dir);
@@ -14321,7 +15203,20 @@ async function loadLocalRules(root, gateOpen = true) {
14321
15203
  return result;
14322
15204
  }
14323
15205
  for (const entry of entries.sort()) {
15206
+ if (result.rules.length >= MAX_EXTERNAL_RULES) {
15207
+ result.errors.push(`external rule budget exceeded (${MAX_EXTERNAL_RULES})`);
15208
+ break;
15209
+ }
14324
15210
  const path = join(dir, entry);
15211
+ try {
15212
+ if (lstatSync(path).isSymbolicLink()) {
15213
+ result.errors.push(`external rule "${LOCAL_RULES_DIR}/${entry}" is a symlink — skipped`);
15214
+ continue;
15215
+ }
15216
+ } catch (err) {
15217
+ result.errors.push(`external rule "${LOCAL_RULES_DIR}/${entry}" could not be inspected: ${err instanceof Error ? err.message : String(err)}`);
15218
+ continue;
15219
+ }
14325
15220
  if (entry.endsWith(".json")) loadJsonRule(path, result);
14326
15221
  else if (entry.endsWith(".mjs") || entry.endsWith(".js")) {
14327
15222
  if (!gateOpen) result.skipped.push(`${LOCAL_RULES_DIR}/${entry}`);
@@ -14334,6 +15229,10 @@ function loadJsonRule(path, result) {
14334
15229
  const name = `${LOCAL_RULES_DIR}/${path.split(/[\\/]/).pop()}`;
14335
15230
  let raw;
14336
15231
  try {
15232
+ if (lstatSync(path).size > MAX_RULE_FILE_BYTES) {
15233
+ result.errors.push(`external rule "${name}" exceeds the file size budget`);
15234
+ return;
15235
+ }
14337
15236
  raw = JSON.parse(readFileSync(path, "utf8"));
14338
15237
  } catch (err) {
14339
15238
  result.errors.push(`external rule "${name}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
@@ -14354,7 +15253,7 @@ function loadJsonRule(path, result) {
14354
15253
  return;
14355
15254
  }
14356
15255
  const patterns = decl["patterns"];
14357
- if (!Array.isArray(patterns) || patterns.length === 0 || !patterns.every((p) => typeof p === "string" && p.length > 0)) {
15256
+ if (!Array.isArray(patterns) || patterns.length === 0 || patterns.length > MAX_PATTERNS_PER_RULE || !patterns.every((p) => typeof p === "string" && p.length > 0)) {
14358
15257
  result.errors.push(`external rule ${id} must declare a non-empty "patterns" array of regex source strings.`);
14359
15258
  return;
14360
15259
  }
@@ -14370,6 +15269,10 @@ function loadJsonRule(path, result) {
14370
15269
  result.errors.push(`external rule ${id} has a pattern longer than 512 chars — likely a copy-paste error.`);
14371
15270
  return;
14372
15271
  }
15272
+ if (hasNestedQuantifier(p)) {
15273
+ result.errors.push(`external rule ${id} has a nested quantifier that may cause catastrophic backtracking`);
15274
+ return;
15275
+ }
14373
15276
  const quantifiers = p.match(/[+*?]|\{\d/g);
14374
15277
  if (quantifiers && quantifiers.length > 64) {
14375
15278
  result.errors.push(`external rule ${id} has a pattern with more than 64 quantifier/wildcard tokens — likely catastrophic backtracking.`);
@@ -14567,6 +15470,8 @@ var scan_pipeline_exports = /* @__PURE__ */ __exportAll({
14567
15470
  summarizeForensicVerdicts: () => summarizeForensicVerdicts
14568
15471
  });
14569
15472
  const UNIVERSAL_RULES = RULES.map(asUniversal);
15473
+ const DEFAULT_MAX_DURATION_MS = 6e5;
15474
+ const MAX_DURATION_MS = 36e5;
14570
15475
  /** Registered rule IDs — used to warn on unknown severityOverrides keys (M4). */
14571
15476
  const KNOWN_RULE_IDS = new Set(RULES.map((r) => r.id));
14572
15477
  /**
@@ -14783,7 +15688,9 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14783
15688
  let testDeclarationCount = 0;
14784
15689
  let rulesPartial = false;
14785
15690
  let parseFailed = 0;
15691
+ let parseFallbacks = 0;
14786
15692
  let scanned = 0;
15693
+ let analyzed = 0;
14787
15694
  for (const path of testFiles) {
14788
15695
  if (Date.now() > deadline) {
14789
15696
  rulesPartial = true;
@@ -14797,13 +15704,16 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14797
15704
  if (!isCiAdapter) testFileCount++;
14798
15705
  let text;
14799
15706
  try {
14800
- text = readFileSync(path, "utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
15707
+ const readResult = readFileBounded(path, LIMITS$1.maxFileBytes);
15708
+ if (!readResult.ok) {
15709
+ skippedFiles++;
15710
+ continue;
15711
+ }
15712
+ text = readResult.data.toString("utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
14801
15713
  } catch {
14802
15714
  skippedFiles++;
14803
15715
  continue;
14804
15716
  }
14805
- let fileBudgetExceeded = false;
14806
- let fileRuleFailed = false;
14807
15717
  const relPath = relative(workspace.root, path).replaceAll("\\", "/");
14808
15718
  if (!isCiAdapter) {
14809
15719
  const lang = {
@@ -14829,74 +15739,106 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14829
15739
  });
14830
15740
  let cacheKey = fileCacheKey(rulesDigest, text, identity(wantsAst ? "ast" : "regex"));
14831
15741
  const cachedFindings = cache.lookup(cacheKey);
14832
- if (cachedFindings) {
14833
- for (const f of cachedFindings) findings.push(f);
14834
- continue;
14835
- }
14836
- hooks.onProgress?.({
14837
- phase: "parse",
14838
- done: scanned,
14839
- total: testFiles.length,
14840
- detail: relPath
14841
- });
14842
- const parsedFile = {
14843
- path: relPath,
14844
- text
14845
- };
14846
- let parsed;
14847
- const findingsStart = findings.length;
14848
- try {
14849
- if (adapter.parseAst && wantsAst) {
14850
- hooks.onProgress?.({
14851
- phase: "rules",
14852
- done: scanned,
14853
- total: testFiles.length,
14854
- detail: relPath
14855
- });
14856
- parsed = await adapter.parseAst(parsedFile);
14857
- }
14858
- const actualMode = parsed ? "ast" : "regex";
14859
- if (wantsAst && actualMode === "regex") {
14860
- cacheKey = fileCacheKey(rulesDigest, text, identity(actualMode));
14861
- const fallbackFindings = cache.lookup(cacheKey);
14862
- if (fallbackFindings) {
14863
- for (const f of fallbackFindings) findings.push(f);
14864
- 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
+ });
14865
15765
  }
14866
- }
14867
- const fileForRules = parsed ? {
14868
- ...parsedFile,
14869
- ast: parsed.ast
14870
- } : parsedFile;
14871
- adapter.runRules(activeRules, fileForRules, (f, ruleId, category) => {
14872
- if (!isValidFindingRecord(f)) {
14873
- fileRuleFailed = true;
14874
- onRuleCrash?.(ruleId, relPath, /* @__PURE__ */ new Error(`malformed finding record rejected (severity/line/message must be present, severity ∈ error|warning|info): ${JSON.stringify(f)}`));
14875
- 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
+ };
14876
15778
  }
14877
- findings.push({
14878
- ...f,
14879
- ruleId,
14880
- 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
+ }
14881
15810
  });
14882
- }, (ruleId, error) => {
14883
- fileRuleFailed = true;
14884
- onRuleCrash?.(ruleId, relPath, error);
14885
- }, {
14886
- deadline: Math.min(deadline, Date.now() + LIMITS$1.maxFileAnalysisMs),
14887
- onExceeded: () => {
14888
- rulesPartial = true;
14889
- skippedFiles++;
14890
- truncationReasons.add("file-budget");
14891
- fileBudgetExceeded = true;
14892
- }
14893
- });
14894
- if (!fileRuleFailed) cache.store(cacheKey, findings.slice(findingsStart), fileBudgetExceeded);
14895
- } catch {
14896
- skippedFiles++;
14897
- parseFailed++;
14898
- } finally {
14899
- 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++;
14900
15842
  }
14901
15843
  }
14902
15844
  return {
@@ -14905,7 +15847,9 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14905
15847
  testDeclarationCount,
14906
15848
  rulesPartial,
14907
15849
  parseFailed,
14908
- scanned
15850
+ parseFallbacks,
15851
+ scanned,
15852
+ analyzed
14909
15853
  };
14910
15854
  }
14911
15855
  /**
@@ -14983,18 +15927,24 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
14983
15927
  for (const f of findings) f.fixGroupId = f.ruleId;
14984
15928
  const discoveredReport = discoverAndParseRuntimeReport(scanRoot.root);
14985
15929
  const runtimeReportPath = discoveredReport?.path;
15930
+ const runtimeIncomplete = discoveredReport !== void 0 && discoveredReport.report.analysisComplete !== true;
14986
15931
  let forensicVerdicts;
15932
+ let evidenceRecords = [];
14987
15933
  if (discoveredReport && discoveredReport.report.analysisComplete === true) try {
14988
- buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
15934
+ evidenceRecords = buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
14989
15935
  stampRuntimeCorroboration(findings, discoveredReport.report, workspace.root);
14990
15936
  forensicVerdicts = summarizeForensicVerdicts(discoveredReport.report);
14991
- } catch {}
15937
+ } catch {
15938
+ evidenceRecords = [];
15939
+ }
14992
15940
  return {
15941
+ evidenceRecords,
14993
15942
  testDeclarationCount,
14994
15943
  scopeInfo,
14995
15944
  suppressionCount,
14996
15945
  frameworks,
14997
15946
  runtimeReportPath,
15947
+ runtimeIncomplete,
14998
15948
  forensicVerdicts,
14999
15949
  config
15000
15950
  };
@@ -15063,10 +16013,13 @@ function assembleScanResult(o) {
15063
16013
  if (o.scopeIgnored > 0) scopeReasons.push(`ignored:${o.scopeIgnored}`);
15064
16014
  if (o.scopeUnrecognized > 0) scopeReasons.push(`unrecognized:${o.scopeUnrecognized}`);
15065
16015
  if (o.parseFailed > 0) scopeReasons.push(`parseFailed:${o.parseFailed}`);
16016
+ if (o.skippedFiles > 0) scopeReasons.push(`skipped:${o.skippedFiles}`);
16017
+ if (o.scopeInfo.degraded) scopeReasons.push(`degraded:${o.scopeInfo.degraded}`);
16018
+ if (o.runtimeIncomplete) scopeReasons.push("runtime-incomplete");
15066
16019
  for (const reason of o.truncationReasons) scopeReasons.push(`truncated:${reason}`);
15067
16020
  const scopeIntegrity = {
15068
16021
  discovered: o.testFiles.length,
15069
- analyzed: Math.max(0, o.scanned),
16022
+ analyzed: Math.max(0, o.analyzed ?? o.scanned),
15070
16023
  ignored: o.scopeIgnored,
15071
16024
  unrecognized: o.scopeUnrecognized,
15072
16025
  parseFailed: o.parseFailed,
@@ -15075,28 +16028,61 @@ function assembleScanResult(o) {
15075
16028
  ...scopeReasons.length > 0 ? { reasons: scopeReasons } : {}
15076
16029
  };
15077
16030
  const scopeAdjustedTotal = scopeReasons.length > 0 && total >= 100 ? 99 : total;
16031
+ let identityIncomplete = false;
15078
16032
  const inputSnapshot = o.testFiles.map((p) => {
15079
16033
  const relPath = relative(o.workspace.root, p).replaceAll("\\", "/");
15080
16034
  try {
15081
- const content = readFileSync(p);
15082
- const hash = createHash("sha256").update(content).digest("hex").slice(0, 16);
16035
+ const read = readFileBounded(resolve(o.workspace.root, p), LIMITS$1.maxFileBytes);
16036
+ if (!read.ok) {
16037
+ if (existsSync(o.workspace.root)) identityIncomplete = true;
16038
+ return {
16039
+ path: relPath,
16040
+ size: 0,
16041
+ hash: "UNAVAILABLE"
16042
+ };
16043
+ }
16044
+ const hash = createHash("sha256").update(read.data).digest("hex");
15083
16045
  return {
15084
16046
  path: relPath,
15085
- size: content.length,
16047
+ size: read.data.length,
15086
16048
  hash
15087
16049
  };
15088
16050
  } catch {
16051
+ if (existsSync(o.workspace.root)) identityIncomplete = true;
15089
16052
  return {
15090
16053
  path: relPath,
15091
- size: 0
16054
+ size: 0,
16055
+ hash: "UNAVAILABLE"
15092
16056
  };
15093
16057
  }
15094
16058
  });
15095
16059
  let reportDigest;
15096
16060
  if (o.runtimeReportPath) try {
15097
- const reportBytes = readFileSync(o.runtimeReportPath);
15098
- reportDigest = createHash("sha256").update(reportBytes).digest("hex").slice(0, 16);
15099
- } catch {}
16061
+ const reportPath = resolve(o.workspace.root, o.runtimeReportPath);
16062
+ if (lstatSync(reportPath).isFile()) {
16063
+ const read = readFileBounded(reportPath, LIMITS$1.maxFileBytes);
16064
+ if (!read.ok) identityIncomplete = true;
16065
+ else reportDigest = createHash("sha256").update(read.data).digest("hex");
16066
+ }
16067
+ } catch {
16068
+ identityIncomplete = true;
16069
+ }
16070
+ const completion = deriveCompletion({
16071
+ discoveryTruncated: o.discoveryTruncated,
16072
+ rulesPartial: o.rulesPartial,
16073
+ skippedFiles: o.skippedFiles,
16074
+ rulesCrashed: o.rulesCrashed,
16075
+ truncationReasons: o.truncationReasons,
16076
+ scopeIgnored: o.scopeIgnored,
16077
+ scopeUnrecognized: o.scopeUnrecognized,
16078
+ parseFailed: o.parseFailed,
16079
+ parseFallbacks: o.parseFallbacks ?? 0,
16080
+ ...o.scopeInfo.degraded !== void 0 ? { scopeDegraded: o.scopeInfo.degraded } : {},
16081
+ ...o.runtimeIncomplete !== void 0 ? { runtimeIncomplete: o.runtimeIncomplete } : {},
16082
+ identityIncomplete
16083
+ });
16084
+ const repository = bindRepository(o.scanRoot.root);
16085
+ const candidate = readCandidateBinding(o.scanRoot.root);
15100
16086
  const runIdentity = buildRunIdentity({
15101
16087
  files: inputSnapshot,
15102
16088
  rules: [...o.REVISION_BY_RULE_ID.entries()].map(([id, detectorRevision]) => ({
@@ -15109,18 +16095,32 @@ function assembleScanResult(o) {
15109
16095
  trustModelVersion: TRUST_MODEL_VERSION,
15110
16096
  scoringModelVersion: SCORING_MODEL_VERSION,
15111
16097
  frameworkSupportMatrixVersion: FRAMEWORK_SUPPORT_MATRIX_VERSION,
15112
- 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
+ } } : {}
15113
16108
  });
15114
- const evidenceGraph = buildEvidenceGraph({ runId: runIdentity });
15115
16109
  const hasTests = o.testFileCount > 0 && o.testDeclarationCount > 0;
15116
16110
  const suiteInvalidatedBy = [...new Set(o.findings.filter((f) => SUITE_INVALIDATING_RULE_IDS.has(f.ruleId)).map((f) => f.ruleId))].sort();
16111
+ const finalScore = hasTests ? completion.partial && scopeAdjustedTotal >= 100 ? 99 : scopeAdjustedTotal : null;
15117
16112
  const result = {
15118
16113
  schemaVersion: 1,
15119
- partial: o.discoveryTruncated || o.rulesPartial || o.skippedFiles > 0 || o.rulesCrashed > 0 || o.scopeInfo.degraded !== void 0,
16114
+ partial: completion.partial,
15120
16115
  scopeIntegrity,
15121
16116
  runIdentity,
15122
16117
  evidenceGraph,
15123
- score: hasTests ? scopeAdjustedTotal : null,
16118
+ evidence: {
16119
+ records: o.evidenceRecords ?? [],
16120
+ counts: countEvidence(o.evidenceRecords ?? []),
16121
+ artifact: o.runtimeReportPath ?? null
16122
+ },
16123
+ score: finalScore,
15124
16124
  ...hasTests ? {} : { reason: "no-tests-found" },
15125
16125
  frameworks: o.frameworks.frameworks,
15126
16126
  frameworkDetectionUnknown: o.frameworks.unknown,
@@ -15146,12 +16146,8 @@ function assembleScanResult(o) {
15146
16146
  file: o.cache.stats.file
15147
16147
  } } : {},
15148
16148
  analysisStatus: {
15149
- discovery: o.discoveryTruncated ? "partial" : "complete",
15150
- rules: o.rulesPartial ? "partial" : "complete",
15151
- skippedFiles: o.skippedFiles,
15152
- durationMs: elapsed,
15153
- rulesCrashed: o.rulesCrashed,
15154
- ...o.truncationReasons.size > 0 ? { truncationReasons: [...o.truncationReasons].sort() } : {}
16149
+ ...completion.analysisStatus,
16150
+ durationMs: elapsed
15155
16151
  },
15156
16152
  scoringModelVersion: SCORING_MODEL_VERSION
15157
16153
  };
@@ -15199,7 +16195,8 @@ function assembleScanResult(o) {
15199
16195
  */
15200
16196
  async function runScan(args, hooks = {}) {
15201
16197
  const started = Date.now();
15202
- const deadline = started + args.maxDurationMs;
16198
+ const requestedDuration = Number.isFinite(args.maxDurationMs) ? args.maxDurationMs : DEFAULT_MAX_DURATION_MS;
16199
+ const deadline = started + Math.min(Math.max(1, requestedDuration), MAX_DURATION_MS);
15203
16200
  const discovered = discoverWorkspace(args.target);
15204
16201
  const targetAbs = resolve(args.target);
15205
16202
  const scanRoot = discovered && discovered.root !== targetAbs && targetAbs.startsWith(discovered.root + sep) ? {
@@ -15288,7 +16285,9 @@ async function runScan(args, hooks = {}) {
15288
16285
  testDeclarationCount = analysis.testDeclarationCount;
15289
16286
  rulesPartial = analysis.rulesPartial;
15290
16287
  parseFailed = analysis.parseFailed;
16288
+ const parseFallbacks = analysis.parseFallbacks;
15291
16289
  const scanned = analysis.scanned;
16290
+ const analyzed = analysis.analyzed;
15292
16291
  const postScan = applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, declarationsByFile, testDeclarationCount, tierByRuleId, REVISION_BY_RULE_ID);
15293
16292
  testDeclarationCount = postScan.testDeclarationCount;
15294
16293
  const result = assembleScanResult({
@@ -15304,7 +16303,9 @@ async function runScan(args, hooks = {}) {
15304
16303
  scopeIgnored,
15305
16304
  scopeUnrecognized,
15306
16305
  parseFailed,
16306
+ parseFallbacks,
15307
16307
  scanned,
16308
+ analyzed,
15308
16309
  testFiles,
15309
16310
  workspace,
15310
16311
  scanRoot,
@@ -15317,6 +16318,7 @@ async function runScan(args, hooks = {}) {
15317
16318
  suppressionCount: postScan.suppressionCount,
15318
16319
  frameworks: postScan.frameworks,
15319
16320
  runtimeReportPath: postScan.runtimeReportPath,
16321
+ runtimeIncomplete: postScan.runtimeIncomplete,
15320
16322
  forensicVerdicts: postScan.forensicVerdicts,
15321
16323
  config: postScan.config,
15322
16324
  fileProvenance,
@@ -15329,4 +16331,6 @@ async function runScan(args, hooks = {}) {
15329
16331
  return result;
15330
16332
  }
15331
16333
  //#endregion
15332
- export { massCeiling as $, box as A, isAdvisoryFinding as At, headlineFor as B, buildFooter as C, buildEvidenceGraph as Ct, plainContext as D, RULE_CATEGORIES as Dt, panel as E, QA_IMPACT_LABELS as Et, scoreGauge as F, buildDependencyGraph as G, EVIDENCE as H, shouldColorize as I, classifyProvenance as J, getReachableFiles as K, shouldUseAscii as L, padTo as M, palette as N, sectionHeader as O, SEVERITY_ORDER as Ot, sanitizeData as P, deductionFor as Q, wrapText as R, FLAKE_GLYPH as S, ENGINE_VERSION as St, okIcon as T, DEDUCTIONS as Tt, TINT as U, BADGE_BAND as V, TRUST as W, capForTier as X, computeAgenticProfile as Y, computeDimensions as Z, summarizeForensicVerdicts as _, ConfigValidationError as _t, applyPostScanProcessing as a, SEARCHED_FOR as at, renderSuppressions as b, isRecord as bt, discoverAndParseRuntimeReport as c, parseWorkflow as ct, isValidFindingRecord as d, parseTsFile as dt, RETIRED_RULE_IDS as et, pathMatchesGlob as f, detectFrameworks as ft, selectAdapter as g, isLintFixtureDir as gt, scan_pipeline_exports as h, globToRegExp as ht, SUITE_INVALIDATING_RULE_IDS as i, SCAN_ADAPTERS as it, measure as j, isValidCategory as jt, severityIcon as k, deriveEvidenceLevel as kt, discoverTestFilesPhase as l, parseYamlGuarded as lt, runScan as m, createIgnoreMatcher as mt, KNOWN_RULE_IDS as n, getRule as nt, assembleScanResult as o, isAzurePipelineFixture as ot, runFileAnalysisPhase as p, DEFAULT_IGNORE_MATCHER as pt, correlateFindings as q, OVERLAP_META_BY_RULE_ID as r, resolveGitPath as rt, buildUniversalRules as s, parseAzurePipeline as st, EVIDENCE_OVERRIDES as t, RULES as tt, fallbackWorkspace as u, computeCodeText as ut, loadLocalRules as v, isSuppressionActive as vt, nextStep as w, MEASURED_FP as wt, runForensics as x, parseJsonFile as xt, loadSuppressions as y, loadConfig as yt, deriveScoreState as z };
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