mjolnir-qa 3.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,14 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.mjs";
2
2
  import { createRequire } from "node:module";
3
- import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
3
+ import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
4
4
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { createHash, randomBytes } from "node:crypto";
7
+ import { execFileSync } from "node:child_process";
7
8
  import * as ts$2 from "ts-morph";
8
9
  import ts, { Project, SyntaxKind, ts as ts$1 } from "ts-morph";
9
10
  import { Language, Parser } from "web-tree-sitter";
10
11
  import { parse } from "yaml";
11
- import { execFileSync } from "node:child_process";
12
12
  import { inflateRawSync } from "node:zlib";
13
13
  //#region src/types.ts
14
14
  /** Severity ladder. Order matters for sorting and gating. */
@@ -60,6 +60,32 @@ function isValidCategory(value) {
60
60
  return value !== void 0 && RULE_CATEGORIES.includes(value);
61
61
  }
62
62
  /**
63
+ * Trust levels (Verification Trust Evolution Plan §16): the OVERALL
64
+ * trust a consumer can place in one finding, combining the static
65
+ * evidence ladder (E0–E2) with RUNTIME corroboration from a real run
66
+ * report. Exposed honestly, never overclaimed:
67
+ * L0 — observation only (E0, no runtime evidence).
68
+ * L1 — heuristic static evidence (E1), no runtime evidence.
69
+ * L2 — deterministic static evidence (E2), no runtime evidence.
70
+ * L3 — RUNTIME: the file containing this finding appeared in a real
71
+ * run report (tests in that file executed).
72
+ * L4 — RUNTIME: the specific test containing this finding was
73
+ * identified in the report and executed (its outcome is known).
74
+ * L5 — RUNTIME: the run verdict directly corroborates the DEFECT
75
+ * class (e.g. a flake-risk finding whose test actually flaked,
76
+ * retried, or timed out in the report).
77
+ * INVARIANT (structurally enforced): L3–L5 require runtime
78
+ * corroboration — a static-only finding can never claim L4/L5.
79
+ */
80
+ const TRUST_ORDER = [
81
+ "L0",
82
+ "L1",
83
+ "L2",
84
+ "L3",
85
+ "L4",
86
+ "L5"
87
+ ];
88
+ /**
63
89
  * Honest default evidence level for a finding (Honesty Core Phase 1).
64
90
  * Derivation is deterministic and conservative:
65
91
  * observation → E0 (never proof)
@@ -100,6 +126,7 @@ const DEDUCTIONS = {
100
126
  //#region src/engine/completion.ts
101
127
  function deriveCompletion(input) {
102
128
  const truncationReasons = [...new Set(input.truncationReasons)].sort();
129
+ const degradations = [...input.degradations ?? []].sort((a, b) => a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0);
103
130
  const reasons = /* @__PURE__ */ new Set();
104
131
  if (input.discoveryTruncated) reasons.add("discovery-truncated");
105
132
  if (input.rulesPartial) reasons.add("rules-partial");
@@ -109,14 +136,16 @@ function deriveCompletion(input) {
109
136
  if (input.scopeUnrecognized > 0) reasons.add(`scope-unrecognized:${input.scopeUnrecognized}`);
110
137
  if (input.parseFailed > 0) reasons.add(`parse-failed:${input.parseFailed}`);
111
138
  if (input.parseFallbacks && input.parseFallbacks > 0) reasons.add(`parse-fallbacks:${input.parseFallbacks}`);
139
+ if (input.astFallbackFiles && input.astFallbackFiles > 0) reasons.add(`ast-budget-fallback-files:${input.astFallbackFiles}`);
112
140
  if (input.scopeDegraded) reasons.add(`scope-degraded:${input.scopeDegraded}`);
113
141
  if (input.runtimeIncomplete) reasons.add("runtime-incomplete");
114
142
  if (input.identityIncomplete) reasons.add("identity-incomplete");
115
143
  for (const reason of truncationReasons) reasons.add(`truncated:${reason}`);
144
+ for (const entry of degradations) reasons.add(`degraded:${entry.reason}:${entry.count}`);
116
145
  const discoveryPartial = input.discoveryTruncated || input.scopeIgnored > 0 || input.scopeUnrecognized > 0 || input.scopeDegraded !== void 0;
117
146
  const rulesPartial = input.rulesPartial || input.rulesCrashed > 0 || input.parseFailed > 0;
118
147
  return {
119
- partial: discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0,
148
+ partial: discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0 || degradations.length > 0,
120
149
  analysisStatus: {
121
150
  discovery: discoveryPartial ? "partial" : "complete",
122
151
  rules: rulesPartial ? "partial" : "complete",
@@ -124,11 +153,49 @@ function deriveCompletion(input) {
124
153
  rulesCrashed: input.rulesCrashed,
125
154
  parseFallbacks: input.parseFallbacks ?? 0,
126
155
  ...truncationReasons.length > 0 ? { truncationReasons } : {},
156
+ ...degradations.length > 0 ? { degradations } : {},
127
157
  reasons: [...reasons].sort()
128
158
  }
129
159
  };
130
160
  }
131
161
  //#endregion
162
+ //#region src/engine/degradation-ledger.ts
163
+ /**
164
+ * Append-only. Records are never edited and never removed by a recording
165
+ * site; a reader takes a window, and the window is defined by index, so
166
+ * "delete my entry" is not an operation this module offers.
167
+ */
168
+ const records = [];
169
+ /** Record one capability loss. `count` is for a site that fails N at once. */
170
+ function recordDegradation(reason, count = 1) {
171
+ if (!Number.isInteger(count) || count < 1) return;
172
+ for (let index = 0; index < count; index++) records.push({ reason });
173
+ }
174
+ /**
175
+ * Mark where this reader's window opens. Everything appended from here on is
176
+ * what the reader will see; see the file header for what a concurrent scan
177
+ * does to that boundary.
178
+ */
179
+ function beginDegradationWindow() {
180
+ return records.length;
181
+ }
182
+ /** The records appended since `marker`, without consuming them. */
183
+ function degradationsSince(marker) {
184
+ return records.slice(marker);
185
+ }
186
+ /**
187
+ * Per-reason counts for a window, sorted by reason so the output is
188
+ * byte-deterministic for the same set of degradations.
189
+ */
190
+ function summarizeDegradations(window) {
191
+ const counts = /* @__PURE__ */ new Map();
192
+ for (const record of window) counts.set(record.reason, (counts.get(record.reason) ?? 0) + 1);
193
+ return [...counts.entries()].map(([reason, count]) => ({
194
+ reason,
195
+ count
196
+ })).sort((a, b) => a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0);
197
+ }
198
+ //#endregion
132
199
  //#region src/engine/runtime-corroboration.ts
133
200
  /**
134
201
  * Stamp runtime corroboration + trust levels onto findings (mutates in
@@ -742,21 +809,13 @@ const MEASURED_FP = {
742
809
  * empty scan is an observation, not a proof).
743
810
  */
744
811
  function summaryTrustLevel(findings) {
745
- const order = [
746
- "L0",
747
- "L1",
748
- "L2",
749
- "L3",
750
- "L4",
751
- "L5"
752
- ];
753
812
  let best = 0;
754
813
  for (const f of findings) {
755
814
  const t = f.trustLevel ?? "L2";
756
- const idx = order.indexOf(t);
815
+ const idx = TRUST_ORDER.indexOf(t);
757
816
  if (idx > best) best = idx;
758
817
  }
759
- return order[best];
818
+ return TRUST_ORDER[best];
760
819
  }
761
820
  /**
762
821
  * A finding's trust rung (0–5) on the canonical ladder. Corroborated
@@ -767,14 +826,7 @@ function summaryTrustLevel(findings) {
767
826
  */
768
827
  function trustRung(f) {
769
828
  const t = f.trustLevel ?? deriveTrustLevel(f);
770
- return [
771
- "L0",
772
- "L1",
773
- "L2",
774
- "L3",
775
- "L4",
776
- "L5"
777
- ].indexOf(t);
829
+ return TRUST_ORDER.indexOf(t);
778
830
  }
779
831
  /**
780
832
  * Hard incompleteness ceilings (plan §6: partial/crash/truncation;
@@ -911,6 +963,17 @@ function buildRunIdentity(input) {
911
963
  engineVersion: input.engineVersion
912
964
  };
913
965
  if (input.reportDigest) verdictInputs.reportDigest = input.reportDigest;
966
+ if (input.commit !== void 0) verdictInputs.commit = input.commit;
967
+ if (input.tree !== void 0) verdictInputs.tree = input.tree;
968
+ if (input.lockfile !== void 0) verdictInputs.lockfile = input.lockfile;
969
+ if (input.candidate !== void 0) verdictInputs.candidate = {
970
+ manifestId: input.candidate.manifestId,
971
+ state: input.candidate.state,
972
+ candidateSha: input.candidate.candidateSha,
973
+ baseSha: input.candidate.baseSha,
974
+ packageSha256: input.candidate.packageSha256,
975
+ lockfileSha256: input.candidate.lockfileSha256
976
+ };
914
977
  if (input.trustModelVersion !== void 0) verdictInputs.trustModelVersion = input.trustModelVersion;
915
978
  if (input.scoringModelVersion !== void 0) verdictInputs.scoringModelVersion = input.scoringModelVersion;
916
979
  if (input.frameworkSupportMatrixVersion !== void 0) verdictInputs.frameworkSupportMatrixVersion = input.frameworkSupportMatrixVersion;
@@ -918,15 +981,31 @@ function buildRunIdentity(input) {
918
981
  if (input.suppressionFingerprint !== void 0) verdictInputs.suppressionFingerprint = input.suppressionFingerprint;
919
982
  if (input.policyFingerprint !== void 0) verdictInputs.policyFingerprint = input.policyFingerprint;
920
983
  if (input.historicalEvidenceFingerprint !== void 0) verdictInputs.historicalEvidenceFingerprint = input.historicalEvidenceFingerprint;
984
+ const scanId = sha256$1(canonical(verdictInputs));
985
+ const boundLinks = [
986
+ "input",
987
+ "rules",
988
+ "config",
989
+ "engine"
990
+ ];
991
+ if (input.commit !== void 0) boundLinks.push("commit");
992
+ if (input.tree !== void 0) boundLinks.push("tree");
993
+ if (input.lockfile !== void 0) boundLinks.push("lockfile");
994
+ if (input.candidate !== void 0) boundLinks.push("candidate");
921
995
  return {
922
- scanId: sha256$1(canonical(verdictInputs)),
996
+ scanId,
923
997
  inputFingerprint,
924
998
  rulesDigest,
925
999
  configFingerprint,
926
1000
  engineVersion: input.engineVersion,
1001
+ boundLinks,
927
1002
  ...input.trustModelVersion !== void 0 ? { trustModelVersion: input.trustModelVersion } : {},
928
1003
  ...input.scoringModelVersion !== void 0 ? { scoringModelVersion: input.scoringModelVersion } : {},
929
- ...input.frameworkSupportMatrixVersion !== void 0 ? { frameworkSupportMatrixVersion: input.frameworkSupportMatrixVersion } : {}
1004
+ ...input.frameworkSupportMatrixVersion !== void 0 ? { frameworkSupportMatrixVersion: input.frameworkSupportMatrixVersion } : {},
1005
+ ...input.commit !== void 0 ? { commit: input.commit } : {},
1006
+ ...input.tree !== void 0 ? { tree: input.tree } : {},
1007
+ ...input.lockfile !== void 0 ? { lockfile: input.lockfile } : {},
1008
+ ...input.candidate !== void 0 ? { candidate: input.candidate } : {}
930
1009
  };
931
1010
  }
932
1011
  /**
@@ -967,9 +1046,353 @@ function buildEvidenceGraph(parts) {
967
1046
  ...parts.reproduction !== void 0 ? { ref: parts.reproduction } : {}
968
1047
  }
969
1048
  ],
970
- ...parts.runId !== void 0 ? { runId: parts.runId } : {}
1049
+ ...parts.runId !== void 0 ? { runId: parts.runId } : {},
1050
+ ...parts.candidate !== void 0 ? { candidate: {
1051
+ manifestId: parts.candidate.manifestId,
1052
+ candidateSha: parts.candidate.candidateSha
1053
+ } } : {}
1054
+ };
1055
+ }
1056
+ //#endregion
1057
+ //#region src/engine/file-executor.ts
1058
+ function cacheHitOutcome(job) {
1059
+ return {
1060
+ path: job.path,
1061
+ status: "CACHE_HIT",
1062
+ findings: [...job.cacheHit ?? []],
1063
+ parseFallback: false
1064
+ };
1065
+ }
1066
+ /**
1067
+ * Drive a list of jobs through an executor and return outcomes in INPUT order.
1068
+ *
1069
+ * This is the function the pipeline calls. It is executor-agnostic on purpose:
1070
+ * swapping in a pooled executor must not require touching the caller, and the
1071
+ * ordering guarantee has to live here or it would live in each executor and
1072
+ * drift between them.
1073
+ */
1074
+ async function executeFiles(jobs, executor, context = {}) {
1075
+ const outcomes = new Array(jobs.length);
1076
+ if (executor.concurrency <= 1 || jobs.length <= 1) {
1077
+ for (let i = 0; i < jobs.length; i++) {
1078
+ const job = jobs[i];
1079
+ context.onProgress?.({
1080
+ done: i,
1081
+ total: jobs.length,
1082
+ path: job.path
1083
+ });
1084
+ outcomes[i] = await runOne(job, executor, context);
1085
+ }
1086
+ context.onProgress?.({
1087
+ done: jobs.length,
1088
+ total: jobs.length,
1089
+ path: jobs[jobs.length - 1]?.path ?? ""
1090
+ });
1091
+ return outcomes;
1092
+ }
1093
+ let next = 0;
1094
+ let completed = 0;
1095
+ const inFlight = /* @__PURE__ */ new Set();
1096
+ const limit = Math.max(1, Math.floor(executor.concurrency));
1097
+ const worker = async () => {
1098
+ for (;;) {
1099
+ const index = next++;
1100
+ if (index >= jobs.length) return;
1101
+ const job = jobs[index];
1102
+ outcomes[index] = await runOne(job, executor, context);
1103
+ completed++;
1104
+ context.onProgress?.({
1105
+ done: completed,
1106
+ total: jobs.length,
1107
+ path: job.path
1108
+ });
1109
+ }
1110
+ };
1111
+ for (let i = 0; i < Math.min(limit, jobs.length); i++) {
1112
+ const promise = worker().then(() => void 0);
1113
+ inFlight.add(promise);
1114
+ }
1115
+ await Promise.all(inFlight);
1116
+ return outcomes;
1117
+ }
1118
+ async function runOne(job, executor, context) {
1119
+ if (job.cacheHit !== void 0) return cacheHitOutcome(job);
1120
+ try {
1121
+ return await executor.execute(job, context);
1122
+ } catch (error) {
1123
+ return {
1124
+ path: job.path,
1125
+ status: "FAILED",
1126
+ findings: [],
1127
+ parseFallback: false,
1128
+ error: error instanceof Error ? error : new Error(String(error))
1129
+ };
1130
+ }
1131
+ }
1132
+ /**
1133
+ * The strategy the pipeline uses today.
1134
+ *
1135
+ * `runOne` is supplied by the pipeline because parsing and rule execution
1136
+ * need the adapter and the active rule set, which are pipeline concerns. The
1137
+ * executor's job is the BOUNDARY and the bookkeeping, not the analysis — so
1138
+ * this factory exists to bind those two without the executor importing the
1139
+ * pipeline (which would be a cycle).
1140
+ */
1141
+ function createExecutor(name, concurrency, runOne) {
1142
+ return {
1143
+ name,
1144
+ concurrency,
1145
+ execute: runOne
1146
+ };
1147
+ }
1148
+ //#endregion
1149
+ //#region src/scope/git-resolve.ts
1150
+ /**
1151
+ * Absolute-path git resolution (audit S1).
1152
+ *
1153
+ * `execFileSync("git", …)` resolves through PATH — including the CWD on
1154
+ * Windows (CreateProcess searches the current directory before PATH for
1155
+ * extension-less names depending on NoDefaultCurrentDirectoryInExePath).
1156
+ * Scanning an UNTRUSTED repo therefore let a checked-in `git.exe` (or
1157
+ * `git.bat`/`git.cmd`) hijack Mjölnir's own git invocations: the attacker
1158
+ * controls the diff output, the merge-base decision, and
1159
+ * `--scope changed`'s "what is new" answer — the exact data CI gates on.
1160
+ *
1161
+ * The fix: resolve git ONCE per process to an ABSOLUTE path by walking
1162
+ * the PATH directly (never consulting the CWD), verify the candidate is
1163
+ * an existing file, and pass that path to execFileSync. Resolution is
1164
+ * memoized; a failure to find a real git anywhere on PATH is recorded
1165
+ * and callers degrade without invoking a bare name.
1166
+ */
1167
+ let resolvedGit = void 0;
1168
+ /**
1169
+ * The absolute path of the git binary Mjölnir will invoke, or null when
1170
+ * PATH carries no executable `git` at all. Callers must degrade without
1171
+ * invoking a bare name.
1172
+ */
1173
+ function resolveGitPath() {
1174
+ if (resolvedGit !== void 0) return resolvedGit;
1175
+ const candidates = [process.platform === "win32" ? "git.exe" : "git"];
1176
+ if (process.platform === "win32") {
1177
+ const pathExt = process.env["PATHEXT"]?.split(";").filter(Boolean) ?? [];
1178
+ for (const ext of pathExt) candidates.push(`git${ext.toLowerCase()}`);
1179
+ }
1180
+ const pathValue = process.env["PATH"] ?? "";
1181
+ for (const dir of pathValue.split(delimiter)) {
1182
+ if (dir === "") continue;
1183
+ if (!isAbsolute(dir)) continue;
1184
+ for (const candidate of candidates) {
1185
+ const full = join(dir, candidate);
1186
+ try {
1187
+ if (existsSync(full)) {
1188
+ resolvedGit = full;
1189
+ return resolvedGit;
1190
+ }
1191
+ } catch {}
1192
+ }
1193
+ }
1194
+ `${pathValue.length}`;
1195
+ resolvedGit = null;
1196
+ return resolvedGit;
1197
+ }
1198
+ /**
1199
+ * Run git with the S1-resolved absolute binary path. Returns stdout, or
1200
+ * null when git is missing or the command fails — the same contract as
1201
+ * the previous inline `git()` helpers, with the hijack surface closed.
1202
+ */
1203
+ function runGit(root, args) {
1204
+ const exe = resolveGitPath();
1205
+ if (!exe) return null;
1206
+ try {
1207
+ return execFileSync(exe, [
1208
+ "-C",
1209
+ root,
1210
+ ...args
1211
+ ], {
1212
+ encoding: "utf8",
1213
+ stdio: [
1214
+ "ignore",
1215
+ "pipe",
1216
+ "ignore"
1217
+ ],
1218
+ timeout: 15e3
1219
+ });
1220
+ } catch {
1221
+ return null;
1222
+ }
1223
+ }
1224
+ //#endregion
1225
+ //#region src/lib/safe-json.ts
1226
+ /**
1227
+ * Reusable safe-JSON parse-and-validate helper.
1228
+ *
1229
+ * Replaces the `JSON.parse(text) as T` pattern with a two-step process:
1230
+ * 1. Try/catch around JSON.parse (descriptive error on bad JSON)
1231
+ * 2. Runtime validation of the parsed shape (caller-supplied predicate)
1232
+ *
1233
+ * The `validate` predicate is intentionally lightweight — it checks the
1234
+ * expected shape exists rather than exhaustively type-guarding every field.
1235
+ * Internal files (cache, hashes) use `isRecord`; user-facing files
1236
+ * (config, plugins) use stricter checks that throw descriptive errors.
1237
+ */
1238
+ const MAX_JSON_BYTES = 16777216;
1239
+ function parseJsonFile(text, source, validate) {
1240
+ if (Buffer.byteLength(text, "utf8") > MAX_JSON_BYTES) throw new Error(`JSON source exceeds the ${MAX_JSON_BYTES}-byte limit: ${source}`);
1241
+ let parsed;
1242
+ try {
1243
+ parsed = JSON.parse(text);
1244
+ } catch (err) {
1245
+ throw new Error(`invalid JSON in ${source}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
1246
+ }
1247
+ if (!validate(parsed)) throw new Error(`unexpected shape in ${source}`, { cause: parsed });
1248
+ return parsed;
1249
+ }
1250
+ /**
1251
+ * Minimal validator: value is a plain object (not null, not array).
1252
+ * Sufficient for internal files (cache, hashes) where downstream
1253
+ * code already checks individual fields.
1254
+ */
1255
+ function isRecord(v) {
1256
+ try {
1257
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1258
+ } catch {
1259
+ return false;
1260
+ }
1261
+ }
1262
+ //#endregion
1263
+ //#region src/engine/candidate-binding.ts
1264
+ /**
1265
+ * Candidate binding (plan V5-010).
1266
+ *
1267
+ * Reads a candidate trust manifest and produces the immutable
1268
+ * `CandidateBinding` that a run identity is bound to. It also computes the
1269
+ * repository facts (commit, tree, lockfile digest) that the identity was
1270
+ * missing until this module existed.
1271
+ *
1272
+ * The law here is ABSENCE over invention. A manifest that is missing,
1273
+ * unreadable, or internally contradictory yields NO binding — not a partial
1274
+ * one. A consumer that can see "not bound" can refuse to trust the run; a
1275
+ * consumer handed a half-filled binding cannot tell what is missing, and will
1276
+ * trust the parts that are there.
1277
+ */
1278
+ const CANDIDATE_MANIFEST_PATH = "candidate-trust-manifest.json";
1279
+ const SHA40 = /^[a-f0-9]{40}$/;
1280
+ const SHA256 = /^[a-f0-9]{64}$/;
1281
+ const STATES = ["WORKING_CANDIDATE", "RELEASE_CANDIDATE"];
1282
+ const AUTHORIZATION = ["NOT_AUTHORIZED", "AUTHORIZED"];
1283
+ function sha256File(path) {
1284
+ try {
1285
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
1286
+ } catch {
1287
+ return;
1288
+ }
1289
+ }
1290
+ /**
1291
+ * One git line, or nothing.
1292
+ *
1293
+ * Audit S1: the binary is the ABSOLUTE path `resolveGitPath()` found by
1294
+ * walking PATH itself, never the bare name. `bindRepository()` runs on every
1295
+ * scan (`scan-pipeline.ts`), and a bare `execFileSync("git", …)` lets a
1296
+ * `git.exe`/`git.bat`/`git.cmd` CHECKED INTO AN UNTRUSTED REPO hijack the
1297
+ * call on Windows, where CreateProcess searches the current directory before
1298
+ * PATH. The attacker then controls the `commit` and `tree` that land in
1299
+ * `runIdentity` and the evidence graph — the provenance a release decision
1300
+ * reads. This was the last bare-name exec left in `src/`; the invariant that
1301
+ * keeps it that way is `tests/contract/no-bare-process-exec.spec.ts`.
1302
+ *
1303
+ * The `-C <root>` shape (not `cwd`) matches every other hardened call site.
1304
+ * With no git on PATH at all, the answer is nothing — a scan of an unpacked
1305
+ * tarball has no commit, and its identity must say so rather than carrying a
1306
+ * placeholder that looks like provenance.
1307
+ */
1308
+ function gitLine(root, args) {
1309
+ const exe = resolveGitPath();
1310
+ if (!exe) return void 0;
1311
+ try {
1312
+ const trimmed = execFileSync(exe, [
1313
+ "-C",
1314
+ root,
1315
+ ...args
1316
+ ], {
1317
+ encoding: "utf8",
1318
+ stdio: [
1319
+ "ignore",
1320
+ "pipe",
1321
+ "ignore"
1322
+ ],
1323
+ windowsHide: true,
1324
+ timeout: 15e3
1325
+ }).trim();
1326
+ return trimmed.length > 0 ? trimmed : void 0;
1327
+ } catch {
1328
+ return;
1329
+ }
1330
+ }
1331
+ /**
1332
+ * The commit and tree the working tree is at. Both absent outside a
1333
+ * repository or with no commits — never substituted with a placeholder.
1334
+ */
1335
+ function bindRepository(root) {
1336
+ const commit = gitLine(root, ["rev-parse", "HEAD"]);
1337
+ const tree = gitLine(root, ["rev-parse", "HEAD^{tree}"]);
1338
+ const lockfile = sha256File(join(root, "package-lock.json"));
1339
+ return {
1340
+ ...commit !== void 0 ? { commit } : {},
1341
+ ...tree !== void 0 ? { tree } : {},
1342
+ ...lockfile !== void 0 ? { lockfile } : {}
1343
+ };
1344
+ }
1345
+ /**
1346
+ * Parse a candidate manifest into a binding, or return null when it cannot
1347
+ * honestly be bound.
1348
+ *
1349
+ * Null is returned for: no manifest, unreadable JSON, a missing or malformed
1350
+ * required field, or a state/authorization combination that cannot legally
1351
+ * exist (a WORKING_CANDIDATE carrying a commit, for example — the same
1352
+ * contradiction the release decision evaluator treats as BLOCKED).
1353
+ */
1354
+ function parseCandidateBinding(raw) {
1355
+ if (!isRecord(raw)) return null;
1356
+ const identity = raw.identity;
1357
+ if (!isRecord(identity)) return null;
1358
+ const state = identity.state;
1359
+ const candidateSha = identity.candidateSha;
1360
+ const baseSha = identity.baseSha;
1361
+ const packageSha256 = identity.packageSha256;
1362
+ const lockfileSha256 = identity.lockfileSha256;
1363
+ if (typeof state !== "string" || !STATES.includes(state)) return null;
1364
+ if (typeof baseSha !== "string" || !SHA40.test(baseSha)) return null;
1365
+ if (typeof packageSha256 !== "string" || !SHA256.test(packageSha256)) return null;
1366
+ if (typeof lockfileSha256 !== "string" || !SHA256.test(lockfileSha256)) return null;
1367
+ if (candidateSha !== null && (typeof candidateSha !== "string" || !SHA40.test(candidateSha))) return null;
1368
+ const manifestId = raw.manifestId;
1369
+ if (typeof manifestId !== "string" || manifestId.length === 0) return null;
1370
+ const owner = typeof raw.owner === "string" ? raw.owner : "UNASSIGNED";
1371
+ const authorization = raw.releaseAuthorizationState;
1372
+ if (typeof authorization !== "string" || !AUTHORIZATION.includes(authorization)) return null;
1373
+ if (state === "WORKING_CANDIDATE") {
1374
+ if (candidateSha !== null) return null;
1375
+ if (authorization !== "NOT_AUTHORIZED") return null;
1376
+ } else if (candidateSha === null) return null;
1377
+ return {
1378
+ manifestId,
1379
+ state,
1380
+ candidateSha,
1381
+ baseSha,
1382
+ packageSha256,
1383
+ lockfileSha256,
1384
+ owner,
1385
+ releaseAuthorizationState: authorization
971
1386
  };
972
1387
  }
1388
+ /** Read and bind the manifest from a checkout, or null. */
1389
+ function readCandidateBinding(root, manifestPath = CANDIDATE_MANIFEST_PATH) {
1390
+ try {
1391
+ return parseCandidateBinding(JSON.parse(readFileSync(join(root, manifestPath), "utf8")));
1392
+ } catch {
1393
+ return null;
1394
+ }
1395
+ }
973
1396
  //#endregion
974
1397
  //#region src/engine/version.ts
975
1398
  /**
@@ -980,7 +1403,7 @@ function buildEvidenceGraph(parts) {
980
1403
  * scripts/sync-sarif-version.cjs and guarded by the version-consistency
981
1404
  * spec. cli.ts re-exports this as CLI_VERSION.
982
1405
  */
983
- const ENGINE_VERSION = "3.0.0";
1406
+ const ENGINE_VERSION = "5.0.0";
984
1407
  //#endregion
985
1408
  //#region src/engine/contract-versions.ts
986
1409
  /**
@@ -1109,9 +1532,12 @@ function discoverWorkspace(rootDir) {
1109
1532
  const root = findProjectRoot(rootDir);
1110
1533
  if (!root) return null;
1111
1534
  let pkg = {};
1112
- try {
1113
- pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
1114
- } catch {}
1535
+ const manifestPath = join(root, "package.json");
1536
+ if (existsSync(manifestPath)) try {
1537
+ pkg = JSON.parse(readFileSync(manifestPath, "utf8"));
1538
+ } catch {
1539
+ recordDegradation("workspace-manifest-unreadable");
1540
+ }
1115
1541
  const rawWorkspaces = pkg["workspaces"];
1116
1542
  let globs = [];
1117
1543
  if (Array.isArray(rawWorkspaces)) globs = rawWorkspaces.filter((w) => typeof w === "string");
@@ -1128,40 +1554,6 @@ function basename$1(p) {
1128
1554
  return parts[parts.length - 1];
1129
1555
  }
1130
1556
  //#endregion
1131
- //#region src/lib/safe-json.ts
1132
- /**
1133
- * Reusable safe-JSON parse-and-validate helper.
1134
- *
1135
- * Replaces the `JSON.parse(text) as T` pattern with a two-step process:
1136
- * 1. Try/catch around JSON.parse (descriptive error on bad JSON)
1137
- * 2. Runtime validation of the parsed shape (caller-supplied predicate)
1138
- *
1139
- * The `validate` predicate is intentionally lightweight — it checks the
1140
- * expected shape exists rather than exhaustively type-guarding every field.
1141
- * Internal files (cache, hashes) use `isRecord`; user-facing files
1142
- * (config, plugins) use stricter checks that throw descriptive errors.
1143
- */
1144
- const MAX_JSON_BYTES = 16777216;
1145
- function parseJsonFile(text, source, validate) {
1146
- if (Buffer.byteLength(text, "utf8") > MAX_JSON_BYTES) throw new Error(`JSON source exceeds the ${MAX_JSON_BYTES}-byte limit: ${source}`);
1147
- let parsed;
1148
- try {
1149
- parsed = JSON.parse(text);
1150
- } catch (err) {
1151
- throw new Error(`invalid JSON in ${source}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
1152
- }
1153
- if (!validate(parsed)) throw new Error(`unexpected shape in ${source}`, { cause: parsed });
1154
- return parsed;
1155
- }
1156
- /**
1157
- * Minimal validator: value is a plain object (not null, not array).
1158
- * Sufficient for internal files (cache, hashes) where downstream
1159
- * code already checks individual fields.
1160
- */
1161
- function isRecord(v) {
1162
- return typeof v === "object" && v !== null && !Array.isArray(v);
1163
- }
1164
- //#endregion
1165
1557
  //#region src/lib/fs-bounded.ts
1166
1558
  function closeQuiet(fd) {
1167
1559
  try {
@@ -1388,20 +1780,15 @@ function validate(cfg, knownRuleIds) {
1388
1780
  return warnings;
1389
1781
  }
1390
1782
  /**
1391
- * Bug-audit QA-2026-08-30 QA-6: the 90-day policy in the README was only
1392
- * applied at WRITE time by the `ignore` command — a hand-written entry
1393
- * without `expires` stayed active forever, silently bypassing the
1394
- * documented window.
1783
+ * Expiry is evaluated only from an explicit ISO `expires` date. A missing
1784
+ * date remains active and is reported as having no expiry. Config-file mtime
1785
+ * is never an expiry anchor.
1395
1786
  *
1396
- * Audit S4 (remediation plan): the config-file MTIME is no longer an
1397
- * expiry anchor. Anchoring the 90-day default at mtime meant ANY edit to
1398
- * mjolnir.config.json — a reformat, an unrelated key, a `touch` — reset
1399
- * the 90-day window for EVERY hand-authored entry: suppressions could be
1400
- * extended indefinitely without touching their own fields. The expiry is
1401
- * now the entry's explicit `expires` date alone; an entry without one
1402
- * stays active and is honestly labeled "(no expiry set)" in the
1403
- * suppressions report. Hand-authored entries should declare `expires` at
1404
- * creation (README §Configuration documents the shape).
1787
+ * `now` defaults to the current instant so existing callers stay correct, but
1788
+ * a verdict function that reads the clock invisibly is a verdict nobody can
1789
+ * test at a boundary. `tests/contract/deterministic-clock.spec.ts` audits
1790
+ * `src/config/**` for a call site that supplies its own `new Date()` instead
1791
+ * of threading one through.
1405
1792
  */
1406
1793
  function isSuppressionActive(ign, now = /* @__PURE__ */ new Date()) {
1407
1794
  if (!ign.expires) return true;
@@ -1684,7 +2071,33 @@ function sharedWalk(options) {
1684
2071
  *
1685
2072
  * When nothing is detectable we report `unknown` and the scanner analyzes
1686
2073
  * all test-looking files — stated honestly in output rather than guessed.
2074
+ *
2075
+ * ## One ID space (plan V5-024)
2076
+ *
2077
+ * This detector used to declare its own `TestFramework` union of three
2078
+ * literals while `src/frameworks/framework-inventory.ts` catalogued fourteen
2079
+ * framework ids. Two vocabularies for one concept is how a support matrix
2080
+ * starts disagreeing with what the tool actually detects: a framework can be
2081
+ * catalogued as OFFICIAL_PARTIAL and never once be emitted by the code that
2082
+ * claims to detect it.
2083
+ *
2084
+ * So the ids here are `FrameworkId`s — the inventory's own vocabulary — and
2085
+ * `DETECTABLE_TEST_FRAMEWORKS` is the subset this detector can actually
2086
+ * resolve from a checkout. That subset is the HONEST limit of detection, and
2087
+ * `detectableVsCatalogued()` reports the remainder rather than letting a
2088
+ * three-item list read as the whole catalog.
2089
+ */
2090
+ /**
2091
+ * The catalogued frameworks whose detection is implemented today.
2092
+ *
2093
+ * Every entry must exist in the inventory — enforced by the parity spec, not
2094
+ * by comment.
1687
2095
  */
2096
+ const DETECTABLE_TEST_FRAMEWORKS = [
2097
+ "jest",
2098
+ "vitest",
2099
+ "playwright"
2100
+ ];
1688
2101
  const CONFIG_FILES = {
1689
2102
  jest: [
1690
2103
  "jest.config.ts",
@@ -1703,7 +2116,7 @@ const CONFIG_FILES = {
1703
2116
  };
1704
2117
  function detectFrameworks(ws) {
1705
2118
  const found = /* @__PURE__ */ new Set();
1706
- for (const fw of Object.keys(CONFIG_FILES)) if (CONFIG_FILES[fw].some((f) => existsSync(join(ws.root, f)))) found.add(fw);
2119
+ for (const fw of DETECTABLE_TEST_FRAMEWORKS) if (CONFIG_FILES[fw].some((f) => existsSync(join(ws.root, f)))) found.add(fw);
1707
2120
  if (!found.has("jest") && ws.packageJson["jest"] !== void 0) found.add("jest");
1708
2121
  const deps = {
1709
2122
  ...ws.packageJson["dependencies"],
@@ -1725,11 +2138,7 @@ function detectFrameworks(ws) {
1725
2138
  };
1726
2139
  }
1727
2140
  return {
1728
- frameworks: [
1729
- "jest",
1730
- "vitest",
1731
- "playwright"
1732
- ].filter((f) => found.has(f)),
2141
+ frameworks: DETECTABLE_TEST_FRAMEWORKS.filter((f) => found.has(f)),
1733
2142
  unknown: false
1734
2143
  };
1735
2144
  }
@@ -1746,6 +2155,13 @@ function detectFrameworks(ws) {
1746
2155
  * byte-identical — a score shift is a regression, not an improvement.
1747
2156
  */
1748
2157
  let project = null;
2158
+ /**
2159
+ * The shared ts-morph project.
2160
+ *
2161
+ * Exported so an adapter's `dispose()` can evict the file it parsed: ts-morph
2162
+ * caches by file path, so without eviction a long scan holds every parsed
2163
+ * SourceFile until the process exits (plan V5-021).
2164
+ */
1749
2165
  function getProject() {
1750
2166
  if (!project) project = new Project({
1751
2167
  useInMemoryFileSystem: true,
@@ -1773,6 +2189,7 @@ function parseTsFile(file) {
1773
2189
  }
1774
2190
  return p.createSourceFile(file.path, file.text);
1775
2191
  } catch {
2192
+ recordDegradation("ast-parse-failed");
1776
2193
  return;
1777
2194
  }
1778
2195
  }
@@ -1828,6 +2245,7 @@ function commentAndStringRanges(ctx) {
1828
2245
  }
1829
2246
  return ranges;
1830
2247
  } catch {
2248
+ recordDegradation("ast-range-scan-failed");
1831
2249
  return [];
1832
2250
  }
1833
2251
  }
@@ -1875,6 +2293,7 @@ function getCodeOnlyText(file) {
1875
2293
  for (const r of ranges) for (let i = r.start; i < r.end && i < chars.length; i++) if (chars[i] !== "\n" && chars[i] !== "\r") chars[i] = " ";
1876
2294
  return chars.join("");
1877
2295
  } catch {
2296
+ recordDegradation("ast-mask-unavailable");
1878
2297
  return file.text;
1879
2298
  }
1880
2299
  }
@@ -2259,7 +2678,45 @@ function frameworkFilterApplies(rule, file) {
2259
2678
  return rule.frameworks.some((f) => tags.includes(f));
2260
2679
  }
2261
2680
  //#endregion
2262
- //#region src/adapters/typescript.ts
2681
+ //#region src/engine/shared-run-rules.ts
2682
+ /**
2683
+ * Run ONE rule with crash isolation, and return what it produced.
2684
+ *
2685
+ * This is the block that was copy-pasted into all seven language adapters:
2686
+ *
2687
+ * try { for (const f of rule.run(x)) emit(f, rule.id, rule.category); }
2688
+ * catch (error) { onCrash?.(rule.id, error); }
2689
+ *
2690
+ * Extracting it is worth doing for one reason beyond line count. Crash
2691
+ * isolation is the mechanism that lets a third-party or experimental
2692
+ * detector fail without ending a scan, and it is the mechanism the whole
2693
+ * `rulesCrashed` count depends on. Seven copies means seven chances to
2694
+ * write `catch {}` and lose the count, and `tests/contract/
2695
+ * no-uncounted-degradation.spec.ts` can only audit what it can see.
2696
+ *
2697
+ * `null` means the rule CRASHED, which is not the same as an empty array:
2698
+ * an empty array is the rule abstaining, and the adapters skip emission for
2699
+ * both but the distinction is why the caller must not conflate them.
2700
+ *
2701
+ * Deliberately NOT extracted: the per-adapter filter chain and the budget
2702
+ * check. Those genuinely differ — the TypeScript adapter injects an AST,
2703
+ * framework tags, a lazy `codeText` getter, `appliesTo` scoping, config-only
2704
+ * gating and `configGateMatches`, and it ABORTS the whole file on budget
2705
+ * exhaustion where `runRulesShared` only `continue`s to the next rule.
2706
+ * Routing every adapter through `runRulesShared` as written would silently
2707
+ * change which rules run and when, which is precisely the unmeasured claim
2708
+ * this program exists to delete. This helper takes the part they share.
2709
+ */
2710
+ function runRuleIsolated(rule, file, onCrash) {
2711
+ try {
2712
+ return rule.run(file);
2713
+ } catch (error) {
2714
+ onCrash?.(rule.id, error);
2715
+ return null;
2716
+ }
2717
+ }
2718
+ //#endregion
2719
+ //#region src/adapters/typescript.ts
2263
2720
  /**
2264
2721
  * TypeScript/JavaScript adapter (R1).
2265
2722
  * Wraps the proven regex-based engine behind the LanguageAdapter
@@ -2272,7 +2729,36 @@ function frameworkFilterApplies(rule, file) {
2272
2729
  * "cypress", `@jest/globals` → "jest", `vitest` → "vitest". Config
2273
2730
  * gating is rule-declared (`configFiles`), not hard-coded here.
2274
2731
  */
2275
- const TEST_FILE_RE = /\.(?:test|spec)\.(?:js|jsx|ts|tsx|mjs|cjs)$|\.cy\.(?:js|jsx|ts|tsx)$/;
2732
+ /**
2733
+ * `.test.` / `.spec.` filenames, plus the Cypress `.cy.` convention.
2734
+ *
2735
+ * `mts` and `cts` are TypeScript's Node-native ESM/CJS extensions. They were
2736
+ * missing here, and the omission was invisible in the worst way — not a wrong
2737
+ * answer, but an ABSENCE.
2738
+ *
2739
+ * Reproduced: a repo containing two byte-identical tests, one at
2740
+ * `tests/control.spec.ts` and one at `tests/slow.spec.mts`, each with a
2741
+ * `QA-TEST-004` hard sleep:
2742
+ *
2743
+ * discovered: 1, analyzed: 1, unrecognized: 1, scopeVerdict: "PARTIAL"
2744
+ *
2745
+ * The `.ts` test produced the finding. The `.mts` test produced nothing at
2746
+ * all — it was never scanned, so every rule that could have caught it was
2747
+ * silent on it, with no finding, no low-evidence note, and no indication that
2748
+ * a test file had been skipped. A test scanner that cannot see a whole file
2749
+ * extension has a false green that is invisible by construction: the reader
2750
+ * has nothing to distrust, because there is nothing there.
2751
+ *
2752
+ * The corroboration that this was an oversight rather than a decision sits
2753
+ * four lines below: PW_CONFIG_RE already accepted `cts`. The config regex knew
2754
+ * about the Node-native extensions; the test-file regex did not.
2755
+ *
2756
+ * `discovery/scan-adapters.ts:isUnrecognizedSourceCandidate` already counted
2757
+ * these paths as uncovered surface, so the scope accounting had been reporting
2758
+ * the hole the whole time — it was being read as a known limitation rather than
2759
+ * as a defect.
2760
+ */
2761
+ const TEST_FILE_RE = /\.(?:test|spec)\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$|\.cy\.(?:js|jsx|ts|tsx)$/;
2276
2762
  /**
2277
2763
  * Fallback config list for `configOnly` rules that do not declare
2278
2764
  * `configFiles` (the legacy playwright.config.* gating, preserved
@@ -2379,11 +2865,42 @@ const typescriptAdapter = {
2379
2865
  fixtureDirMemo: /* @__PURE__ */ new Map()
2380
2866
  });
2381
2867
  },
2868
+ /**
2869
+ * The ONE AST seam (plan V5-021).
2870
+ *
2871
+ * This adapter used to parse inside `runRules`, on the synchronous path,
2872
+ * while Java and C# exposed the async `parseAst` hook. Two seams meant two
2873
+ * sets of consequences, both of them bad:
2874
+ *
2875
+ * - the pipeline computes `wantsAst` as `adapter.parseAst !== undefined`,
2876
+ * so a TypeScript file never took the AST path through the pipeline at
2877
+ * all, and its parse failures were invisible to the pipeline's
2878
+ * fallback counters;
2879
+ * - nothing called `dispose()` for the ts-morph path, so a scan held
2880
+ * every parsed SourceFile for its whole lifetime.
2881
+ *
2882
+ * Parsing now happens here, once, through the same contract every other
2883
+ * adapter uses. `dispose()` drops this file's SourceFile from the shared
2884
+ * project; ts-morph caches per file path, so removing it is what keeps
2885
+ * memory proportional to one file rather than to the whole scan.
2886
+ */
2887
+ parseAst(file) {
2888
+ const sourceFile = parseTsFile(file);
2889
+ if (sourceFile === void 0) return void 0;
2890
+ return {
2891
+ ast: sourceFile,
2892
+ dispose: () => {
2893
+ try {
2894
+ getProject().removeSourceFile(sourceFile);
2895
+ } catch {}
2896
+ }
2897
+ };
2898
+ },
2382
2899
  runRules(rules, file, emit, onCrash, budget) {
2383
- const withAst = {
2900
+ const withAst = file.ast === void 0 ? {
2384
2901
  ...file,
2385
2902
  ast: parseTsFile(file)
2386
- };
2903
+ } : file;
2387
2904
  const withTags = {
2388
2905
  ...withAst,
2389
2906
  frameworkTags: frameworkTagsFromImports(withAst.text)
@@ -2408,11 +2925,9 @@ const typescriptAdapter = {
2408
2925
  budget.onExceeded();
2409
2926
  return;
2410
2927
  }
2411
- try {
2412
- for (const f of rule.run(enriched)) emit(f, rule.id, rule.category);
2413
- } catch (error) {
2414
- onCrash?.(rule.id, error);
2415
- }
2928
+ const produced = runRuleIsolated(rule, enriched, onCrash);
2929
+ if (produced === null) continue;
2930
+ for (const f of produced) emit(f, rule.id, rule.category);
2416
2931
  }
2417
2932
  }
2418
2933
  };
@@ -2440,6 +2955,7 @@ function loadWorkspaceShim(root) {
2440
2955
  workspaceGlobs: []
2441
2956
  };
2442
2957
  } catch {
2958
+ recordDegradation("workspace-manifest-unreadable");
2443
2959
  return null;
2444
2960
  }
2445
2961
  }
@@ -2794,11 +3310,9 @@ const pythonAdapter = {
2794
3310
  budget.onExceeded();
2795
3311
  return;
2796
3312
  }
2797
- try {
2798
- for (const f of rule.run(enriched)) emit(f, rule.id, rule.category);
2799
- } catch (error) {
2800
- onCrash?.(rule.id, error);
2801
- }
3313
+ const produced = runRuleIsolated(rule, enriched, onCrash);
3314
+ if (produced === null) continue;
3315
+ for (const f of produced) emit(f, rule.id, rule.category);
2802
3316
  }
2803
3317
  },
2804
3318
  async parseAst(file) {
@@ -2951,11 +3465,9 @@ const javaAdapter = {
2951
3465
  budget.onExceeded();
2952
3466
  return;
2953
3467
  }
2954
- try {
2955
- for (const f of rule.run(enriched)) emit(f, rule.id, rule.category);
2956
- } catch (error) {
2957
- onCrash?.(rule.id, error);
2958
- }
3468
+ const produced = runRuleIsolated(rule, enriched, onCrash);
3469
+ if (produced === null) continue;
3470
+ for (const f of produced) emit(f, rule.id, rule.category);
2959
3471
  }
2960
3472
  }
2961
3473
  };
@@ -3109,11 +3621,9 @@ const csharpAdapter = {
3109
3621
  budget.onExceeded();
3110
3622
  return;
3111
3623
  }
3112
- try {
3113
- for (const f of rule.run(enriched)) emit(f, rule.id, rule.category);
3114
- } catch (error) {
3115
- onCrash?.(rule.id, error);
3116
- }
3624
+ const produced = runRuleIsolated(rule, enriched, onCrash);
3625
+ if (produced === null) continue;
3626
+ for (const f of produced) emit(f, rule.id, rule.category);
3117
3627
  }
3118
3628
  }
3119
3629
  };
@@ -3291,6 +3801,7 @@ const githubActionsAdapter = {
3291
3801
  try {
3292
3802
  entries = readdirSync(wfDir);
3293
3803
  } catch {
3804
+ recordDegradation("ci-workflow-listing-unreadable");
3294
3805
  return;
3295
3806
  }
3296
3807
  for (const name of entries) {
@@ -3331,15 +3842,13 @@ const githubActionsAdapter = {
3331
3842
  budget.onExceeded();
3332
3843
  return;
3333
3844
  }
3334
- try {
3335
- for (const f of rule.run({
3336
- path: file.path,
3337
- text: file.text,
3338
- ast: doc
3339
- })) emit(f, rule.id, rule.category);
3340
- } catch (error) {
3341
- onCrash?.(rule.id, error);
3342
- }
3845
+ const produced = runRuleIsolated(rule, {
3846
+ path: file.path,
3847
+ text: file.text,
3848
+ ast: doc
3849
+ }, onCrash);
3850
+ if (produced === null) continue;
3851
+ for (const f of produced) emit(f, rule.id, rule.category);
3343
3852
  }
3344
3853
  }
3345
3854
  };
@@ -3649,15 +4158,13 @@ const azurePipelinesAdapter = {
3649
4158
  budget.onExceeded();
3650
4159
  return;
3651
4160
  }
3652
- try {
3653
- for (const f of rule.run({
3654
- path: file.path,
3655
- text: file.text,
3656
- ast: doc
3657
- })) emit(f, rule.id, rule.category);
3658
- } catch (error) {
3659
- onCrash?.(rule.id, error);
3660
- }
4161
+ const produced = runRuleIsolated(rule, {
4162
+ path: file.path,
4163
+ text: file.text,
4164
+ ast: doc
4165
+ }, onCrash);
4166
+ if (produced === null) continue;
4167
+ for (const f of produced) emit(f, rule.id, rule.category);
3661
4168
  }
3662
4169
  }
3663
4170
  };
@@ -3728,14 +4235,12 @@ const jenkinsAdapter = {
3728
4235
  budget.onExceeded();
3729
4236
  return;
3730
4237
  }
3731
- try {
3732
- for (const f of rule.run({
3733
- path: file.path,
3734
- text: file.text
3735
- })) emit(f, rule.id, rule.category);
3736
- } catch (error) {
3737
- onCrash?.(rule.id, error);
3738
- }
4238
+ const produced = runRuleIsolated(rule, {
4239
+ path: file.path,
4240
+ text: file.text
4241
+ }, onCrash);
4242
+ if (produced === null) continue;
4243
+ for (const f of produced) emit(f, rule.id, rule.category);
3739
4244
  }
3740
4245
  }
3741
4246
  };
@@ -3802,9 +4307,37 @@ function discoverAllTestFiles(ctx, languageAdapters, buckets, fixtureDirMemo) {
3802
4307
  }
3803
4308
  });
3804
4309
  }
4310
+ /**
4311
+ * Would ignoring this file have removed it from the scanned surface?
4312
+ *
4313
+ * The bug this fixes, reproduced: a repo containing a real test file plus
4314
+ * `vendor.min.js` and `pnpm-lock.yaml` reported
4315
+ *
4316
+ * discovered: 1, analyzed: 1, ignored: 2, scopeVerdict: "PARTIAL"
4317
+ *
4318
+ * Every discovered test file HAD been analyzed. The surface was complete. But
4319
+ * DEFAULT_IGNORES matches minified bundles and lockfiles, and the ignored
4320
+ * counter accepted any path with a source extension — so a minified bundle and
4321
+ * a lockfile downgraded a finished scan to "unverified".
4322
+ *
4323
+ * That is not a conservative bias, it is a broken signal. The terminal tells
4324
+ * the reader "some files were not analyzed, so the surface is unverified" —
4325
+ * which was false. And because minified bundles and lockfiles exist in
4326
+ * essentially every real repository, a PROVEN scan was unreachable in
4327
+ * practice, so the corpus regression guard failed 23 of 37 repositories on
4328
+ * exactly this. A gate that cannot pass teaches people to ignore it.
4329
+ *
4330
+ * The asymmetry is the tell. `isUnrecognizedSourceCandidate` below applies a
4331
+ * test-relevance test before counting a file; this one did not. Both count
4332
+ * against the same verdict, so both must ask the same question.
4333
+ *
4334
+ * An ignored file that IS a test file still downgrades the verdict. That is
4335
+ * the case the honesty law exists for, and this does not touch it.
4336
+ */
3805
4337
  function isScopeRelevantIgnored(path) {
3806
4338
  const name = path.replaceAll("\\", "/").split("/").pop() ?? path;
3807
- return /\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml|min\.js)$/i.test(name);
4339
+ if (!/\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml|min\.js)$/i.test(name)) return false;
4340
+ return isKnownTestFile(path);
3808
4341
  }
3809
4342
  function isUnrecognizedSourceCandidate(path) {
3810
4343
  const normalized = path.replaceAll("\\", "/");
@@ -3871,82 +4404,6 @@ const SEARCHED_FOR = [
3871
4404
  }
3872
4405
  ];
3873
4406
  //#endregion
3874
- //#region src/scope/git-resolve.ts
3875
- /**
3876
- * Absolute-path git resolution (audit S1).
3877
- *
3878
- * `execFileSync("git", …)` resolves through PATH — including the CWD on
3879
- * Windows (CreateProcess searches the current directory before PATH for
3880
- * extension-less names depending on NoDefaultCurrentDirectoryInExePath).
3881
- * Scanning an UNTRUSTED repo therefore let a checked-in `git.exe` (or
3882
- * `git.bat`/`git.cmd`) hijack Mjölnir's own git invocations: the attacker
3883
- * controls the diff output, the merge-base decision, and
3884
- * `--scope changed`'s "what is new" answer — the exact data CI gates on.
3885
- *
3886
- * The fix: resolve git ONCE per process to an ABSOLUTE path by walking
3887
- * the PATH directly (never consulting the CWD), verify the candidate is
3888
- * an existing file, and pass that path to execFileSync. Resolution is
3889
- * memoized; a failure to find a real git anywhere on PATH is recorded
3890
- * and callers degrade without invoking a bare name.
3891
- */
3892
- let resolvedGit = void 0;
3893
- /**
3894
- * The absolute path of the git binary Mjölnir will invoke, or null when
3895
- * PATH carries no executable `git` at all. Callers must degrade without
3896
- * invoking a bare name.
3897
- */
3898
- function resolveGitPath() {
3899
- if (resolvedGit !== void 0) return resolvedGit;
3900
- const candidates = [process.platform === "win32" ? "git.exe" : "git"];
3901
- if (process.platform === "win32") {
3902
- const pathExt = process.env["PATHEXT"]?.split(";").filter(Boolean) ?? [];
3903
- for (const ext of pathExt) candidates.push(`git${ext.toLowerCase()}`);
3904
- }
3905
- const pathValue = process.env["PATH"] ?? "";
3906
- for (const dir of pathValue.split(delimiter)) {
3907
- if (dir === "") continue;
3908
- if (!isAbsolute(dir)) continue;
3909
- for (const candidate of candidates) {
3910
- const full = join(dir, candidate);
3911
- try {
3912
- if (existsSync(full)) {
3913
- resolvedGit = full;
3914
- return resolvedGit;
3915
- }
3916
- } catch {}
3917
- }
3918
- }
3919
- `${pathValue.length}`;
3920
- resolvedGit = null;
3921
- return resolvedGit;
3922
- }
3923
- /**
3924
- * Run git with the S1-resolved absolute binary path. Returns stdout, or
3925
- * null when git is missing or the command fails — the same contract as
3926
- * the previous inline `git()` helpers, with the hijack surface closed.
3927
- */
3928
- function runGit(root, args) {
3929
- const exe = resolveGitPath();
3930
- if (!exe) return null;
3931
- try {
3932
- return execFileSync(exe, [
3933
- "-C",
3934
- root,
3935
- ...args
3936
- ], {
3937
- encoding: "utf8",
3938
- stdio: [
3939
- "ignore",
3940
- "pipe",
3941
- "ignore"
3942
- ],
3943
- timeout: 15e3
3944
- });
3945
- } catch {
3946
- return null;
3947
- }
3948
- }
3949
- //#endregion
3950
4407
  //#region src/scope/changed.ts
3951
4408
  /**
3952
4409
  * Changed-scope engine (Sprint-Plan W6, Product-MVP §9 `--scope changed`).
@@ -7691,7 +8148,6 @@ const pyBareTruthinessAssert = defineRule({
7691
8148
  const re = /^[ \t]*assert\s+([A-Za-z_][\w.]*(?:\([^()]*\))?)[ \t]*$/gm;
7692
8149
  const predicateRe = /^(?:(?:any|all|isinstance)\s*\(|[\w.]*\.(?:startswith|endswith|exists|isdir|isfile|islink|ismount|check|isdigit|isalpha|isalnum|isnumeric|isdecimal|isspace|islower|isupper|istitle|isidentifier|isprintable|isascii)\s*\(|re\.(?:match|search|fullmatch)\s*\()/;
7693
8150
  const isGuardFollowedByRealUse = (text, matchIndex, target) => {
7694
- const root = target.split(".")[0];
7695
8151
  const lineEnd = text.indexOf("\n", matchIndex);
7696
8152
  if (lineEnd === -1) return false;
7697
8153
  const lines = text.slice(lineEnd + 1).split("\n");
@@ -7701,8 +8157,11 @@ const pyBareTruthinessAssert = defineRule({
7701
8157
  if (/^\s*def\s/.test(l) && window.length > 0) break;
7702
8158
  window.push(l);
7703
8159
  }
7704
- const usesRoot = new RegExp(`\\b${root}\\b`);
7705
- return window.some((l) => usesRoot.test(l));
8160
+ const root = target.match(/^[a-z_]\w*/i)?.[0];
8161
+ if (!root) return false;
8162
+ return window.some((line) => {
8163
+ return (line.match(/[a-z_]\w*/gi) ?? []).some((token) => token === root);
8164
+ });
7706
8165
  };
7707
8166
  let m;
7708
8167
  while ((m = re.exec(text)) !== null) {
@@ -11426,6 +11885,53 @@ const RETIRED_RULE_IDS = [
11426
11885
  "QA-CS-111"
11427
11886
  ];
11428
11887
  //#endregion
11888
+ //#region src/lib/compare.ts
11889
+ /**
11890
+ * Order that does not depend on who is asking.
11891
+ *
11892
+ * `String.prototype.localeCompare` with no locale argument resolves against
11893
+ * the PROCESS's default locale — the ICU collation chosen by `LANG`,
11894
+ * `LC_ALL` or the OS. So a sort written as
11895
+ * `[...xs].sort((a, b) => a.localeCompare(b))` produces a different order on
11896
+ * a developer's `de_DE` machine than on an en-US CI runner. For a display
11897
+ * list that is a nuisance; for anything that reaches a hash, a cache key, a
11898
+ * digest or a machine contract it is a correctness bug, because the same
11899
+ * repository then produces two different fingerprints.
11900
+ *
11901
+ * Two comparators, one per kind of site:
11902
+ *
11903
+ * compareCodePoints — pure UTF-16 code-unit order. Identical everywhere,
11904
+ * independent of ICU version, and already what `Array.prototype.sort`
11905
+ * defaults to. Use for every value that reaches a fingerprint, a digest,
11906
+ * a cache key, or a contract, and for any ordering with no human-facing
11907
+ * reason to be pretty.
11908
+ *
11909
+ * compareLocalized(locale) — the same collator, PINNED to a named locale
11910
+ * instead of inheriting the environment, for a surface that genuinely
11911
+ * wants readable order (a table of rule titles, a Mermaid legend). Pinned
11912
+ * rather than removed because a reader-facing list of "Test / Logic /
11913
+ * Mock" should not be ordered by code unit.
11914
+ *
11915
+ * A bare `localeCompare` with no second argument in `src/` is a defect and
11916
+ * `tests/contract/deterministic-ordering.spec.ts` fails the build on it.
11917
+ */
11918
+ /** Code-unit order. Locale-free, ICU-free, and the Array.sort default. */
11919
+ function compareCodePoints(a, b) {
11920
+ if (a === b) return 0;
11921
+ return a < b ? -1 : 1;
11922
+ }
11923
+ /**
11924
+ * A readable-order comparator pinned to `locale`.
11925
+ *
11926
+ * The pin is the point: `a.localeCompare(b)` reads the environment, and the
11927
+ * environment is a machine setting, not a property of the code. Callers that
11928
+ * want stable output ask for a named locale and get the same answer on every
11929
+ * machine that ships the same ICU data.
11930
+ */
11931
+ function compareLocalized(locale) {
11932
+ return (a, b) => a.localeCompare(b, locale);
11933
+ }
11934
+ //#endregion
11429
11935
  //#region src/scorer/scorer.ts
11430
11936
  /**
11431
11937
  * Transparent scorer (Product-MVP §8).
@@ -11482,7 +11988,7 @@ function computeDimensions(findings) {
11482
11988
  deductions.set(f.category, deductions.get(f.category) + deductionFor(f));
11483
11989
  }
11484
11990
  for (const dim of byCategory.values()) dim.score = Math.max(0, 100 - deductions.get(dim.category));
11485
- return [...byCategory.values()].sort((a, b) => a.category.localeCompare(b.category));
11991
+ return [...byCategory.values()].sort((a, b) => compareCodePoints(a.category, b.category));
11486
11992
  }
11487
11993
  /**
11488
11994
  * Deduction-mass ceilings (product-gap-remediation master plan P2,
@@ -11705,6 +12211,22 @@ function normalizeOne(report, artifact, v) {
11705
12211
  function buildEvidenceRecords(report, artifact) {
11706
12212
  return report.verdicts.map((v) => normalizeOne(report, artifact, v)).sort(compareEvidenceRecords);
11707
12213
  }
12214
+ function countEvidence(records) {
12215
+ const counts = {
12216
+ total: records.length,
12217
+ failed: 0,
12218
+ flaky: 0,
12219
+ skipped: 0,
12220
+ timedOut: 0
12221
+ };
12222
+ for (const r of records) {
12223
+ if (r.status.failed) counts.failed++;
12224
+ if (r.status.passedOnRetry) counts.flaky++;
12225
+ if (r.status.skipped) counts.skipped++;
12226
+ if (r.status.timedOut) counts.timedOut++;
12227
+ }
12228
+ return counts;
12229
+ }
11708
12230
  //#endregion
11709
12231
  //#region src/engine/provenance.ts
11710
12232
  const GENERATED_HEADER_RE = /^\s*(?:\/\/|#|\/\*)\s*(?:auto[- ]?generated|generated by|do not edit)/i;
@@ -11908,7 +12430,7 @@ function groupByRootCause(findings) {
11908
12430
  function correlateFindings(findings) {
11909
12431
  const conclusions = [];
11910
12432
  const rootCauseGroups = groupByRootCause(findings);
11911
- for (const [rootCause, group] of Array.from(rootCauseGroups.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
12433
+ for (const [rootCause, group] of Array.from(rootCauseGroups.entries()).sort((a, b) => compareLocalized("en")(a[0], b[0]))) {
11912
12434
  if (group.length < 2) continue;
11913
12435
  const findingIds = group.map((f) => f.findingId ?? f.ruleId);
11914
12436
  conclusions.push({
@@ -11920,7 +12442,7 @@ function correlateFindings(findings) {
11920
12442
  });
11921
12443
  }
11922
12444
  const fileGroups = groupByFile(findings);
11923
- for (const [file, group] of Array.from(fileGroups.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
12445
+ for (const [file, group] of Array.from(fileGroups.entries()).sort((a, b) => compareCodePoints(a[0], b[0]))) {
11924
12446
  if (group.length < 2) continue;
11925
12447
  const alreadyConvergent = /* @__PURE__ */ new Set();
11926
12448
  for (const c of conclusions) if (c.conclusionType === "CONVERGENT") for (const id of c.findingIds) alreadyConvergent.add(id);
@@ -11992,6 +12514,11 @@ var DependencyGraph = class {
11992
12514
  get size() {
11993
12515
  return this.nodes.size;
11994
12516
  }
12517
+ /** Whether the graph knows this exact key. Reachability must not
12518
+ * treat "not in the graph" as "nothing to traverse" without saying so. */
12519
+ has(path) {
12520
+ return this.nodes.has(path);
12521
+ }
11995
12522
  };
11996
12523
  function parsePackageJson(filePath) {
11997
12524
  if (!existsSync(filePath)) return void 0;
@@ -12079,20 +12606,40 @@ function parseManifest(filePath) {
12079
12606
  if (filePath.endsWith("pom.xml")) return parsePomXml(filePath);
12080
12607
  }
12081
12608
  /**
12082
- * Given a set of starting files (e.g. test files), return all files
12083
- * transitively reachable through the dependency graph.
12609
+ * Given a set of starting files (e.g. test files), return the files
12610
+ * transitively reachable through the dependency graph, plus an honest
12611
+ * account of what could not be resolved.
12612
+ *
12613
+ * KNOWN LIMITATION (BW-022, still open): the graph is keyed by manifest path
12614
+ * and this query is given source paths, so today `unresolvedStarts` is
12615
+ * normally every input. The traversal itself is correct for a graph whose
12616
+ * keys are the paths being queried — which is what the unit tests build, and
12617
+ * which is why they passed while production did nothing. Fixing the seam
12618
+ * means giving a file a resolvable owning manifest and deciding impact's
12619
+ * direction (dependents, not dependencies), which is a design change, not a
12620
+ * patch. Until then this function reports that it resolved nothing instead
12621
+ * of implying otherwise.
12084
12622
  */
12085
12623
  function getReachableFiles(fromFiles, graph) {
12086
12624
  const reachable = /* @__PURE__ */ new Set();
12625
+ const unresolvedStarts = [];
12087
12626
  const stack = [...fromFiles];
12088
12627
  while (stack.length > 0) {
12089
12628
  const current = stack.pop();
12090
12629
  if (current === void 0) continue;
12091
12630
  if (reachable.has(current)) continue;
12092
- reachable.add(current);
12631
+ if (graph.has(current)) reachable.add(current);
12632
+ else if (fromFiles.includes(current)) {
12633
+ unresolvedStarts.push(current);
12634
+ continue;
12635
+ }
12093
12636
  for (const dep of graph.getDependencies(current)) if (!reachable.has(dep)) stack.push(dep);
12094
12637
  }
12095
- return [...reachable].sort();
12638
+ return {
12639
+ reachable: [...reachable].sort(),
12640
+ unresolvedStarts: unresolvedStarts.sort(),
12641
+ resolvedAny: unresolvedStarts.length < fromFiles.length
12642
+ };
12096
12643
  }
12097
12644
  //#endregion
12098
12645
  //#region src/engine/incremental-analysis.ts
@@ -12132,739 +12679,935 @@ function isIncrementalSafe(changedFiles) {
12132
12679
  };
12133
12680
  }
12134
12681
  //#endregion
12135
- //#region src/engine/monorepo-analysis.ts
12136
- const WORTHY_THRESHOLD = 80;
12137
- const NEEDS_WORK_THRESHOLD = 50;
12138
- function verdictOf(score) {
12139
- if (score === null) return "fail";
12140
- if (score >= WORTHY_THRESHOLD) return "pass";
12141
- if (score >= NEEDS_WORK_THRESHOLD) return "warn";
12142
- return "fail";
12143
- }
12144
- function hasBlocker(findings) {
12145
- return findings.some((f) => f.severity === "error");
12146
- }
12682
+ //#region src/brand/tokens.ts
12147
12683
  /**
12148
- * Aggregate per-package results into an overall verdict.
12684
+ * The single source of brand truth.
12149
12685
  *
12150
- * Worst-package propagation: if ANY package has a blocker (error-severity
12151
- * finding) or a failing score, the overall result fails regardless of
12152
- * strategy. This is the safety net — a single poisoned package must not
12153
- * hide behind averaging.
12686
+ * Every colour, typeface and motion constant Mjölnir shows a human —
12687
+ * terminal, README SVGs, demo video, website, docs, badges — resolves to
12688
+ * a value in this file. Nothing else may define one.
12689
+ *
12690
+ * WHY THIS EXISTS. Before it, the palette existed in six independent
12691
+ * copies: `site/.vitepress/theme/styles/vars.css`, `NORSE` in
12692
+ * `src/reporter/theme.ts`, `scripts/readme-svg.ts`,
12693
+ * `scripts/video/terminal-page.ts`, `scripts/generate-readme-architecture.ts`
12694
+ * and the table in `assets/brand/README.md`. Exactly one pair of those
12695
+ * was guarded (site-doctor Check 8, doc ↔ vars.css). The unguarded edges
12696
+ * are where the shipped surfaces drifted apart: the terminal and the site
12697
+ * disagreed on six semantic roles, the architecture diagram invented its
12698
+ * own neutral ramp, and the README badges still carried a palette retired
12699
+ * two releases earlier. `scripts/brand-doctor.mjs` now checks every edge
12700
+ * against this file.
12701
+ *
12702
+ * PURITY. Pure data. No I/O, no rendering, no environment access, no
12703
+ * imports, no logic. Consumers convert (hex → ANSI triplet, hex → CSS)
12704
+ * themselves. Same reason `presentation.ts` is pure: it makes the whole
12705
+ * thing golden-testable and safe to ship inside the npm package, where
12706
+ * it costs a few hundred bytes and replaces values the package already
12707
+ * carried anyway.
12708
+ *
12709
+ * DERIVATION. The palette is the one derived from the logo in PR #20
12710
+ * (brushed steel and forge gold under an aurora, over midnight iron).
12711
+ * Where the terminal disagreed with it, the terminal converges — see
12712
+ * `PENDING_TERMINAL` below. Full rationale: `assets/brand/README.md`.
12713
+ *
12714
+ * ACCESSIBILITY. Every foreground token in `BRAND` meets WCAG AA
12715
+ * (≥ 4.5:1) against every surface token it is allowed to sit on. That is
12716
+ * not a claim, it is `brand-doctor` rule 8, which computes the ratios.
12717
+ * The weakest legal pairing is `steelDim` on `ink800` at 5.00:1.
12154
12718
  */
12155
- function analyzeMonorepo(packages, config) {
12156
- const results = packages.map((p) => ({
12157
- ...p,
12158
- verdict: hasBlocker(p.findings) ? "fail" : verdictOf(p.score)
12159
- }));
12160
- if (results.length === 0) return {
12161
- packages: results,
12162
- overallScore: null,
12163
- overallVerdict: "fail",
12164
- strategy: config.weightingStrategy
12165
- };
12166
- const blockerPkg = results.find((r) => r.verdict === "fail" || hasBlocker(r.findings));
12167
- if (blockerPkg) return {
12168
- packages: results,
12169
- overallScore: blockerPkg.score,
12170
- overallVerdict: "fail",
12171
- strategy: config.weightingStrategy,
12172
- blockerPackage: blockerPkg.packageName
12173
- };
12174
- switch (config.weightingStrategy) {
12175
- case "worst-package": return worstPackageAggregation(results, config);
12176
- case "average": return averageAggregation(results, config);
12177
- case "configurable": return configurableAggregation(results, config);
12178
- }
12179
- }
12180
- function worstPackageAggregation(results, config) {
12181
- const firstResult = results[0];
12182
- if (firstResult === void 0) return {
12183
- packages: results,
12184
- overallScore: null,
12185
- overallVerdict: "fail",
12186
- strategy: config.weightingStrategy
12187
- };
12188
- let worst = firstResult;
12189
- for (const r of results) if ((r.score ?? 0) < (worst.score ?? 0)) worst = r;
12190
- return {
12191
- packages: results,
12192
- overallScore: worst.score,
12193
- overallVerdict: worst.verdict,
12194
- strategy: config.weightingStrategy,
12195
- ...worst.verdict === "fail" ? { blockerPackage: worst.packageName } : {}
12196
- };
12197
- }
12198
- function averageAggregation(results, config) {
12199
- const scored = results.filter((r) => r.score !== null);
12200
- if (scored.length === 0) return {
12201
- packages: results,
12202
- overallScore: null,
12203
- overallVerdict: "fail",
12204
- strategy: config.weightingStrategy
12205
- };
12206
- const avg = scored.reduce((sum, r) => sum + (r.score ?? 0), 0) / scored.length;
12207
- return {
12208
- packages: results,
12209
- overallScore: Math.round(avg),
12210
- overallVerdict: verdictOf(Math.round(avg)),
12211
- strategy: config.weightingStrategy
12212
- };
12213
- }
12214
- function configurableAggregation(results, config) {
12215
- const weights = config.packageWeights ?? {};
12216
- let totalWeight = 0;
12217
- let weightedSum = 0;
12218
- for (const r of results) {
12219
- const w = weights[r.packageName] ?? 1;
12220
- totalWeight += w;
12221
- weightedSum += (r.score ?? 0) * w;
12222
- }
12223
- if (totalWeight === 0) return {
12224
- packages: results,
12225
- overallScore: null,
12226
- overallVerdict: "fail",
12227
- strategy: config.weightingStrategy
12228
- };
12229
- const score = Math.round(weightedSum / totalWeight);
12230
- return {
12231
- packages: results,
12232
- overallScore: score,
12233
- overallVerdict: verdictOf(score),
12234
- strategy: config.weightingStrategy
12235
- };
12236
- }
12237
- //#endregion
12238
- //#region src/lib/fs-atomic.ts
12239
12719
  /**
12240
- * Atomic file writes (audit S9).
12720
+ * The two brand hues plus the neutral they sit on.
12241
12721
  *
12242
- * Every durability-critical write in Mjölnir (baseline, stats, badge,
12243
- * TRIAGE.md, scaffolded rule files) used to hand-roll
12244
- * `writeFileSync(path, data)` — a crash mid-write left a TRUNCATED file
12245
- * at the real path, and a subsequent read (diff, badge endpoint) served
12246
- * confident nonsense from it.
12722
+ * GOLD IS SCARCE. It means forged / certified / earned / decisive — the
12723
+ * primary mark, the FORGED state, one call to action. It is not a paint
12724
+ * bucket: gold as default text, default border or default heading is a
12725
+ * brand-doctor finding, not a style choice.
12247
12726
  *
12248
- * `writeFileAtomic` writes to a temp sibling, then RENAMES. On the same
12249
- * volume rename is atomic: readers see either the complete old file or
12250
- * the complete new file, never a half-written one. The temp name is
12251
- * created with `wx` (exclusive) so concurrent writers cannot interleave,
12252
- * stale temps are cleaned up on failure, and on Windows the rename is
12253
- * retried briefly because a concurrent reader can hold the destination
12254
- * open (EBUSY/EPERM).
12727
+ * AURORA is verification energy — the secondary, and the hue that marks
12728
+ * the runtime half of the trust ladder.
12255
12729
  */
12256
- function atomicTempPath(path) {
12257
- return `${path}.mjolnir-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}.tmp`;
12258
- }
12730
+ const BRAND = {
12731
+ gold: "#C19A34",
12732
+ goldBright: "#E6BD57",
12733
+ goldHot: "#F4DC9C",
12734
+ /** Pressed / deepest gold — the only step dark enough to carry white. */
12735
+ goldDeep: "#A5811C",
12736
+ aurora: "#37ABBD",
12737
+ auroraBright: "#45C1D4",
12738
+ auroraCyan: "#5CBDE0",
12739
+ /** The aurora's outer curtains: atmosphere and section identity only,
12740
+ * never a verdict or a status. */
12741
+ auroraGreen: "#5FD6A4",
12742
+ auroraViolet: "#9D8CF5",
12743
+ steel: "#C8CBCF",
12744
+ steelDim: "#8B939D"
12745
+ };
12259
12746
  /**
12260
- * Atomically replace `path` with `data`.
12747
+ * Midnight iron. One ramp, four steps, darkest first.
12748
+ *
12749
+ * `terminal` and `terminalBar` share one tone deliberately: the window's
12750
+ * only seam is a hairline ring and an inset shadow, never a second fill.
12751
+ * `chromeDot` is the three window dots — see the note on
12752
+ * `PENDING_TERMINAL.chromeDots` for why they are no longer red/amber/green.
12261
12753
  */
12262
- function writeFileAtomic(path, data, opts = {}) {
12263
- const dir = dirname(path);
12264
- if (opts.mkdirs !== false && !existsSync(dir)) mkdirSync(dir, { recursive: true });
12265
- const tmp = atomicTempPath(path);
12266
- let fd;
12267
- try {
12268
- fd = openSync(tmp, "wx", opts.mode ?? 420);
12269
- writeSync(fd, data, null, opts.encoding ?? "utf8");
12270
- } finally {
12271
- if (fd !== void 0) closeSync(fd);
12272
- }
12273
- try {
12274
- renameWithWindowsRetry(tmp, path);
12275
- } catch (err) {
12276
- try {
12277
- if (existsSync(tmp)) unlinkSync(tmp);
12278
- } catch {}
12279
- throw err;
12280
- }
12281
- }
12754
+ const SURFACE = {
12755
+ ink950: "#0A1119",
12756
+ ink900: "#0C1420",
12757
+ ink850: "#111A29",
12758
+ ink800: "#18243A",
12759
+ /** Raised panel (cards, elevated surfaces). */
12760
+ panel: "#141F33",
12761
+ /** Soft fill (inline code, quiet chips). */
12762
+ soft: "#1A2740",
12763
+ /** Terminal body — the deepest tone, so a terminal reads as recessed. */
12764
+ terminal: "#0A1119",
12765
+ /** Terminal title bar — the same tone; the seam is shadow, not colour. */
12766
+ terminalBar: "#0A1119",
12767
+ /** The three window dots. One neutral, not a traffic light. */
12768
+ chromeDot: "#18243A"
12769
+ };
12770
+ const TEXT = {
12771
+ primary: "#EAEEF5",
12772
+ secondary: "#ABB6C6",
12773
+ muted: "#8B939D",
12774
+ /** Ink for text set ON gold (buttons, the FORGED chip). 7.17:1 on `gold`. */
12775
+ onGold: "#0A1119"
12776
+ };
12282
12777
  /**
12283
- * renameSync retry loop for Windows: a concurrent reader (another scan,
12284
- * a badge endpoint, an editor) holding the destination open makes
12285
- * rename fail with EBUSY/EPERM. A short bounded retry closes the race
12286
- * without turning an atomic swap into a partial write.
12778
+ * Non-score status. `ok` is the one green in the system and it is NOT a
12779
+ * score colour — it survives only for contexts with no worthiness
12780
+ * meaning ("autofix applied", "analysis complete"). A green score would
12781
+ * say "your software is fine", which is the exact claim this product
12782
+ * refuses to make.
12783
+ */
12784
+ const STATUS = {
12785
+ ok: "#4FB477",
12786
+ info: "#5CC4E0",
12787
+ warning: "#E6BD57",
12788
+ error: "#EC6B66"
12789
+ };
12790
+ /**
12791
+ * The four ScoreState bands plus the unmeasured state. Band thresholds
12792
+ * and runes live in `src/reporter/presentation.ts`, which stays free of
12793
+ * colour — it emits a palette KEY and each surface resolves it here.
12287
12794
  *
12288
- * Internal contract test hook: the platform check keeps this loop off
12289
- * the POSIX hot path; on win32 the EBUSY/EPERM arms are exercised by
12290
- * the fs-atomic-retry spec (mocked renameSync).
12795
+ * `unmeasured` is steel-dim on purpose. UNKNOWN is a legitimate answer,
12796
+ * not a failure: colouring it red would make "we did not measure this"
12797
+ * look like "this is broken", which is precisely the dishonesty the
12798
+ * north-star law exists to prevent.
12291
12799
  */
12292
- const RENAME_RETRIES = 8;
12293
- const RENAME_RETRY_DELAY_MS = 25;
12294
- /** Synchronous sleep that does not spin the CPU. */
12295
- function sleepSync(ms) {
12296
- try {
12297
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
12298
- } catch {
12299
- const until = Date.now() + ms;
12300
- while (Date.now() < until);
12301
- }
12302
- }
12303
- function renameWithWindowsRetry(from, to) {
12304
- for (let attempt = 0;; attempt++) try {
12305
- renameSync(from, to);
12306
- return;
12307
- } catch (err) {
12308
- const code = err?.code;
12309
- if (process.platform === "win32" && (code === "EBUSY" || code === "EPERM") && attempt < RENAME_RETRIES) {
12310
- sleepSync(RENAME_RETRY_DELAY_MS);
12311
- continue;
12312
- }
12313
- throw err;
12314
- }
12315
- }
12316
- const STALE_TEMP_AGE_MS = 864e5;
12317
- const TEMP_PID_RE = /^.+\.mjolnir-([1-9]\d{0,9})-(\d{13})-[0-9a-f]{8}\.tmp$/;
12318
- function sweepStaleTempFiles(dir) {
12319
- let swept = 0;
12320
- let entries;
12321
- try {
12322
- entries = readdirSync(dir);
12323
- } catch {
12324
- return 0;
12325
- }
12326
- const cutoff = Date.now() - STALE_TEMP_AGE_MS;
12327
- for (const entry of entries) {
12328
- const match = TEMP_PID_RE.exec(entry);
12329
- if (!match || Number(match[2]) > cutoff) continue;
12330
- const path = join(dir, entry);
12331
- try {
12332
- const stat = lstatSync(path);
12333
- if (!stat.isFile() || stat.mtimeMs > cutoff) continue;
12334
- if (pidAlive(Number(match[1]))) continue;
12335
- unlinkSync(path);
12336
- swept++;
12337
- } catch {
12338
- continue;
12339
- }
12340
- }
12341
- return swept;
12342
- }
12343
- function pidAlive(pid) {
12344
- if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 2147483647) return true;
12345
- if (pid === process.pid) return true;
12346
- try {
12347
- process.kill(pid, 0);
12348
- return true;
12349
- } catch (err) {
12350
- return err?.code !== "ESRCH";
12351
- }
12352
- }
12353
- //#endregion
12354
- //#region src/engine/scan-cache.ts
12800
+ const SCORE = {
12801
+ critical: "#EC6B66",
12802
+ warning: "#E6BD57",
12803
+ trusted: "#5CC4E0",
12804
+ forged: "#F4DC9C",
12805
+ unmeasured: "#8B939D"
12806
+ };
12355
12807
  /**
12356
- * Local incremental scan cache (Beta-to-Stable 1.0 plan, M5.2 / A-2).
12808
+ * E0 → E1 → E2 is a certainty ramp, and it is deliberately HUE-FREE.
12357
12809
  *
12358
- * Content-addressed, local-only verdict cache: `--cache` reuses the
12359
- * per-file rule outputs of a previous scan when the file's bytes AND the
12360
- * active rule set are unchanged, and invalidates everything else. The
12361
- * key is `sha256(fileText) + rulesDigest` + the file's own identity
12362
- * (repo-relative path + adapter id + parse mode — audit C1/W9), where
12363
- * the rules digest folds
12364
- * in every active rule's id + `detectorRevision ?? 1` (the existing
12365
- * stale-measurement machinery — Verification Trust Evolution Plan §07 —
12366
- * reused as the cache invalidation signal, per A-2) plus a source hash
12367
- * of each external plugin/local rule's `run` function, so a plugin that
12368
- * changes code without bumping its revision still misses.
12810
+ * Evidence level says how sure we are, not whether the news is good. A
12811
+ * deterministic proof (E2) is a defect we are certain about — painting
12812
+ * it gold or green would read as an achievement. So certainty is carried
12813
+ * by brightness alone, and the *shape* does the real work:
12369
12814
  *
12370
- * Privacy posture: the cache lives under `<repo>/.mjolnir/cache/`, is
12371
- * gitignored, never leaves the machine, and this module performs zero
12372
- * network I/O — fs and crypto only (asserted by the privacy spec).
12815
+ * E0 open ring observation, no weight
12816
+ * E1 half-filled pattern evidence, half weight
12817
+ * E2 sealed deterministic proof, full weight
12373
12818
  *
12374
- * Only raw rule-loop outputs are cached. Everything after the loop
12375
- * (severity overrides, suppressions, overlap dedup, evidence stamping,
12376
- * tier policy, runtime corroboration, scoring) re-runs on every scan,
12377
- * so a cached scan is byte-equivalent to a fresh one by construction.
12819
+ * Colour never carries this alone (R11): the geometry is the signal and
12820
+ * survives `--ascii`, `NO_COLOR` and monochrome print.
12378
12821
  */
12379
- const CACHE_VERSION = 2;
12380
- /** Entry cap: a monorepo-scale suite stays far below this; bounded file. */
12381
- const MAX_ENTRIES$1 = 4096;
12822
+ const EVIDENCE = {
12823
+ e0: "#8B939D",
12824
+ e1: "#ABB6C6",
12825
+ e2: "#EAEEF5"
12826
+ };
12382
12827
  /**
12383
- * Audit M5: total byte budget. The old cap counted only ENTRIES, so
12384
- * 4096 files × ~100KB of findings each could still produce a
12385
- * multi-hundred-MB writeFileSync on persist. The budget bounds the
12386
- * serialized size; the newest entries win (real LRU-by-use).
12387
- */
12388
- const MAX_TOTAL_BYTES = 33554432;
12389
- const MAX_FINDINGS_PER_ENTRY = 1e4;
12390
- function isCachedFinding(value) {
12391
- if (!isRecord(value)) return false;
12392
- return typeof value["ruleId"] === "string" && typeof value["category"] === "string" && typeof value["severity"] === "string" && typeof value["confidence"] === "string" && typeof value["findingType"] === "string" && typeof value["file"] === "string" && Number.isSafeInteger(value["line"]) && Number.isSafeInteger(value["column"]) && typeof value["message"] === "string" && typeof value["why"] === "string" && typeof value["fix"] === "string";
12828
+ * L0–L5, and the most important boundary in the product.
12829
+ *
12830
+ * L0–L2 are STATIC: the neutral steel ramp, brightening to the static
12831
+ * ceiling at L2. L3–L5 require a real run, and the hue changes to aurora
12832
+ * exactly there. The boundary is a hue break, not a gradient step,
12833
+ * because it is a change of kind and not of degree — a static-only
12834
+ * finding can never climb past L2, however confident it is.
12835
+ *
12836
+ * Every surface that draws the ladder must draw that break.
12837
+ */
12838
+ const TRUST = {
12839
+ l0: "#8B939D",
12840
+ l1: "#ABB6C6",
12841
+ l2: "#C8CBCF",
12842
+ l3: "#37ABBD",
12843
+ l4: "#45C1D4",
12844
+ l5: "#5CC4E0"
12845
+ };
12846
+ const TINT = {
12847
+ gold: {
12848
+ fill: "#F6EBCC",
12849
+ stroke: "#7A5F16",
12850
+ text: "#4A3A0E"
12851
+ },
12852
+ aurora: {
12853
+ fill: "#D9F0F4",
12854
+ stroke: "#1F6F7C",
12855
+ text: "#10353C"
12856
+ },
12857
+ error: {
12858
+ fill: "#FADEDD",
12859
+ stroke: "#A83A35",
12860
+ text: "#4E1B19"
12861
+ },
12862
+ /** The unmeasured / unknown state. Neutral, never the error tint. */
12863
+ neutral: {
12864
+ fill: "#E4E7EB",
12865
+ stroke: "#5C646E",
12866
+ text: "#262B31"
12867
+ },
12868
+ ok: {
12869
+ fill: "#DCF0E4",
12870
+ stroke: "#276B45",
12871
+ text: "#163A26"
12872
+ }
12873
+ };
12874
+ /**
12875
+ * The score bands as the badge Mjölnir itself generates renders them.
12876
+ *
12877
+ * These are DEEPER than the score tokens on purpose, and it is not a
12878
+ * style preference: shields.io sets the message text in white and gives
12879
+ * you no say in it. `score.forged` (#F4DC9C) under white text measures
12880
+ * 1.35:1 — an unreadable badge, shipped to look on-brand. The brand's
12881
+ * own deep steps put every band between 4.9 and 6.3:1.
12882
+ *
12883
+ * What they replace was worse than off-brand, it was wrong:
12884
+ *
12885
+ * band was rendered as white-on now white-on
12886
+ * ────────────────────────────────────────────────────────────────────────────
12887
+ * 0-49 `red` #dd4343 4.24 #A83A35 6.32
12888
+ * 50-79 `yellow` #d8b800 1.95 #7A5F16 6.04
12889
+ * 80-99 `important` #ea7233 ORANGE 3.02 #1F6F7C 5.80
12890
+ * 100 `success` #44bb00 GREEN 2.51 #8A6D1E 4.90
12891
+ * unmeasured `lightgrey` #939393 3.07 #5C646E 5.99
12892
+ *
12893
+ * Two of those were defects, not preferences. `success` is green, and
12894
+ * green is not a score colour here — a 100 badge said "your software is
12895
+ * fine", which is the one claim this product refuses to make. And
12896
+ * `important` is ORANGE, not the blue-family colour the code's own
12897
+ * comment claimed for eight releases: every WORTHY badge ever rendered
12898
+ * showed the trusted band in a warning colour. Nobody had resolved a
12899
+ * shields name to a value and looked.
12900
+ *
12901
+ * Values are hex without `#`, the form shields.io's endpoint takes.
12902
+ * `A83A35`, `7A5F16`, `1F6F7C` and `5C646E` are the `TINT` strokes —
12903
+ * the same deep steps the mermaid diagrams use, for the same reason.
12904
+ * `8A6D1E` is the gold the brand document already named as the light
12905
+ * FORGED gradient's start.
12906
+ */
12907
+ const BADGE_BAND = {
12908
+ critical: "A83A35",
12909
+ warning: "7A5F16",
12910
+ trusted: "1F6F7C",
12911
+ forged: "8A6D1E",
12912
+ unmeasured: "5C646E"
12913
+ };
12914
+ 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;
12915
+ 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;
12916
+ EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2;
12917
+ const RUNG_MEANINGS = [
12918
+ "observation only",
12919
+ "heuristic static",
12920
+ "deterministic static",
12921
+ "the finding's file executed",
12922
+ "the finding's test executed",
12923
+ "the run verdict corroborates"
12924
+ ];
12925
+ const RUNG_COLORS = [
12926
+ TRUST.l0,
12927
+ TRUST.l1,
12928
+ TRUST.l2,
12929
+ TRUST.l3,
12930
+ TRUST.l4,
12931
+ TRUST.l5
12932
+ ];
12933
+ const TRUST_RUNGS = RUNG_MEANINGS.map((meaning, i) => ({
12934
+ level: `L${i}`,
12935
+ meaning,
12936
+ runtime: i >= 3,
12937
+ color: RUNG_COLORS[i]
12938
+ }));
12939
+ //#endregion
12940
+ //#region src/reporter/presentation.ts
12941
+ /**
12942
+ * presentation.ts — the one place a presentation DECISION is made.
12943
+ *
12944
+ * BITTERSWEET `BW-030a`. Before this module the same decision was written
12945
+ * down four different times and every surface drifted:
12946
+ *
12947
+ * - score bands: `score-state.ts` (50/80), `dashboard.ts` (50/60),
12948
+ * `handover.ts` (50/90), `mermaid.ts` (50/80), `monorepo-analysis.ts`
12949
+ * (50/80);
12950
+ * - the evidence descriptor: `terminal.ts` returned the full
12951
+ * `[E2 · deterministic · measured FP 8% · n=14 · trust L3]` while
12952
+ * `evidence-tag.ts` — which called itself the "single definition
12953
+ * site" — returned four bare words and defaulted a missing level to
12954
+ * `E2`, the STRONGEST one;
12955
+ * - the score bar: `theme.ts:scoreGauge` plus four private reimplementations.
12956
+ *
12957
+ * THE ONE RULE OF THIS MODULE: it owns decisions, never data and never
12958
+ * I/O. It reads a `Finding` / a score number / a `Palette` and returns
12959
+ * the word, band, threshold or descriptor to print. It opens no file,
12960
+ * touches no clock, spawns no process and formats no HTML document. If a
12961
+ * second responsibility appears here, split it in the same change.
12962
+ *
12963
+ * Pure: the same input always yields the same output, so every decision
12964
+ * is golden-testable and two runs of the same scan cannot disagree.
12965
+ *
12966
+ * Layout note — the threshold LITERALS stay physically here rather than in
12967
+ * a registry object so that `site/scripts/gen-report.mjs`, which parses
12968
+ * `score >= N` branches out of this file to build the site's band ruler,
12969
+ * keeps parsing one canonical file. `SCORE_THRESHOLDS` is derived from
12970
+ * those literals; it is the nameable form, not a second copy of them.
12971
+ */
12972
+ /**
12973
+ * What a surface prints when a number was never measured.
12974
+ *
12975
+ * The bug this replaces: `testDeclarationCount ?? 0` rendered
12976
+ * "0 tests analyzed in 0 files" for a producer that simply did not
12977
+ * measure. A zero is a CLAIM — it says the count was taken and found
12978
+ * nothing. Absent means nobody looked. `dashboard.ts` already modelled
12979
+ * this correctly for frameworks (`frameworkCount: null` → "unknown");
12980
+ * its sibling `trust-report.ts` did not, so the same repo produced two
12981
+ * artifacts that disagreed about whether a measurement existed.
12982
+ */
12983
+ const UNMEASURED = "unknown — not measured";
12984
+ /**
12985
+ * Render a count for a human surface: the number when it was measured,
12986
+ * `unknown — not measured` when it was not. Never `0` for an absent
12987
+ * measurement.
12988
+ */
12989
+ function countOrUnknown(value, unit) {
12990
+ if (value === void 0) return UNMEASURED;
12991
+ return unit === void 0 ? String(value) : `${value} ${unit}`;
12393
12992
  }
12394
- function isCacheFile(value) {
12395
- if (!isRecord(value) || value["version"] !== CACHE_VERSION) return false;
12396
- const entries = value["entries"];
12397
- if (!isRecord(entries) || Object.keys(entries).length > MAX_ENTRIES$1) return false;
12398
- return Object.values(entries).every((entry) => isRecord(entry) && Array.isArray(entry["findings"]) && entry["findings"].length <= MAX_FINDINGS_PER_ENTRY && entry["findings"].every(isCachedFinding));
12993
+ /**
12994
+ * The machine-surface counterpart: an unmeasured count is `null`, which
12995
+ * JSON has a word for, rather than `0`, which does not. Used by the
12996
+ * trust-report JSON artifact; the scan machine contract
12997
+ * (`engine/machine-contract.ts`) is drift-locked and untouched.
12998
+ */
12999
+ function countOrNull(value) {
13000
+ return value ?? null;
12399
13001
  }
12400
- /** sha256 hex of a string — the only hash this module needs. */
12401
- function sha256(text) {
12402
- return createHash("sha256").update(text).digest("hex");
13002
+ /**
13003
+ * The "Tests analyzed" cell, shared by every human surface that shows it.
13004
+ *
13005
+ * Three renderers had this line written out separately, identically, and
13006
+ * all three rendered `testDeclarationCount ?? 0` — "0 tests analyzed in 0
13007
+ * files" — for a producer that never measured a declaration at all. Two
13008
+ * of them are the *sibling* artifacts for the same scan (the PR comment
13009
+ * and the trust report), so the same run told a reviewer both "0 tests
13010
+ * analyzed" and "no tests found".
13011
+ *
13012
+ * Declared-but-file-count-absent (and the reverse) are reported honestly
13013
+ * rather than filled in from each other.
13014
+ */
13015
+ function testsAnalyzedCell(declarations, files) {
13016
+ if (declarations === void 0 && files === void 0) return UNMEASURED;
13017
+ const declText = countOrUnknown(declarations);
13018
+ if (files === void 0) return `${declText} in unknown files`;
13019
+ return `${declText} in ${files} ${files === 1 ? "file" : "files"}`;
13020
+ }
13021
+ const HEADLINES = {
13022
+ critical: "The hammer is cracked — {n} findings break its edge.",
13023
+ warning: "The hammer holds — but {n} findings weigh it down.",
13024
+ trusted: "Held in worthy hands — {n} findings remain.",
13025
+ forged: "Static score 100 — no findings on the analyzed surface.",
13026
+ unmeasured: "No tests found — the hammer cannot be weighed."
13027
+ };
13028
+ const RUNES = {
13029
+ critical: "ᚲ",
13030
+ warning: "ᚦ",
13031
+ trusted: "ᛏ",
13032
+ forged: "ᛟ",
13033
+ unmeasured: "ᛁ"
13034
+ };
13035
+ /**
13036
+ * The named form of the band boundaries. `report:honesty` /
13037
+ * `thresholds:parity` assert that no surface outside this module
13038
+ * re-invents a boundary; a consumer that genuinely needs one names it
13039
+ * here rather than typing the number.
13040
+ */
13041
+ const SCORE_THRESHOLDS = {
13042
+ /** Below this is `critical`. */
13043
+ criticalBelow: 50,
13044
+ /** At or above this is `trusted`. */
13045
+ trustedAtOrAbove: 80,
13046
+ /** The one score that is `forged`. */
13047
+ forgedAt: 100
13048
+ };
13049
+ /**
13050
+ * Band mapping — the one mapping, every consumer: <50 critical,
13051
+ * 50–79 warning, 80–99 trusted, 100 forged, null unmeasured.
13052
+ */
13053
+ function deriveScoreState(score) {
13054
+ if (score === null) return {
13055
+ score: null,
13056
+ band: "unmeasured",
13057
+ verdict: "UNWORTHY",
13058
+ color: "dim",
13059
+ powerLevel: 0,
13060
+ headline: HEADLINES.unmeasured,
13061
+ rune: RUNES.unmeasured
13062
+ };
13063
+ if (score >= SCORE_THRESHOLDS.forgedAt) return {
13064
+ score,
13065
+ band: "forged",
13066
+ verdict: "FORGED",
13067
+ color: "forged",
13068
+ powerLevel: score,
13069
+ headline: HEADLINES.forged,
13070
+ rune: RUNES.forged
13071
+ };
13072
+ if (score >= 80) return {
13073
+ score,
13074
+ band: "trusted",
13075
+ verdict: "WORTHY",
13076
+ color: "trusted",
13077
+ powerLevel: score,
13078
+ headline: HEADLINES.trusted,
13079
+ rune: RUNES.trusted
13080
+ };
13081
+ if (score >= 50) return {
13082
+ score,
13083
+ band: "warning",
13084
+ verdict: "NEEDS WORK",
13085
+ color: "warning",
13086
+ powerLevel: score,
13087
+ headline: HEADLINES.warning,
13088
+ rune: RUNES.warning
13089
+ };
13090
+ return {
13091
+ score,
13092
+ band: "critical",
13093
+ verdict: "UNWORTHY",
13094
+ color: "error",
13095
+ powerLevel: score,
13096
+ headline: HEADLINES.critical,
13097
+ rune: RUNES.critical
13098
+ };
13099
+ }
13100
+ /** Substitute a findings count into a headline template. Pure text
13101
+ * helper so every surface formats identically. */
13102
+ function headlineFor(state, findings) {
13103
+ return state.headline.replace("{n}", String(findings));
13104
+ }
13105
+ /**
13106
+ * Contract-stable three-band verdict (property-locked in
13107
+ * tests/scoring-precision.spec.ts). Delegates to the ScoreState model —
13108
+ * 100 keeps returning WORTHY here; the FORGED premium treatment lives
13109
+ * in the dedicated block, not in this public mapping.
13110
+ *
13111
+ * The `FORGED → WORTHY` collapse is a DOCUMENTED PROJECTION that
13112
+ * preserves the three-band public contract, not a divergence to be
13113
+ * "fixed": a consumer reading the verdict word must not be able to tell
13114
+ * a 100 from a 99, because the 100 carries no runtime evidence and the
13115
+ * score alone never earned it.
13116
+ */
13117
+ function verdictFor(score) {
13118
+ const verdict = deriveScoreState(score).verdict;
13119
+ return verdict === "FORGED" ? "WORTHY" : verdict;
13120
+ }
13121
+ const EVIDENCE_KIND = {
13122
+ E0: "observation",
13123
+ E1: "heuristic",
13124
+ E2: "deterministic"
13125
+ };
13126
+ /**
13127
+ * The effective evidence level of a finding.
13128
+ *
13129
+ * The `?? deriveEvidenceLevel(...)` fallback IS the fix for BW-101. The
13130
+ * deleted `evidence-tag.ts` defaulted a missing level to `E2` — the
13131
+ * strongest claim the product can make — which is the exact inverse of
13132
+ * its own principle: absent evidence is not proof, so it is derived
13133
+ * conservatively from the finding's own type and confidence.
13134
+ */
13135
+ function evidenceLevelOf(f) {
13136
+ return f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence);
13137
+ }
13138
+ /** What the runtime report actually vouched for, in the product's words. */
13139
+ function runtimeLabel(f) {
13140
+ const c = f.runtimeCorroboration;
13141
+ if (c === void 0) return null;
13142
+ if (c.level === "defect") return "defect corroborated";
13143
+ if (c.level === "test") return "test executed";
13144
+ return "file executed";
13145
+ }
13146
+ /**
13147
+ * The canonical evidence descriptor — `[E2 · deterministic · measured FP
13148
+ * 8% · n=14 · trust L3 · runtime: file executed]`.
13149
+ *
13150
+ * This is the version `terminal.ts` already shipped, promoted to the one
13151
+ * definition site. The HTML and markdown surfaces now gain the measured
13152
+ * false-positive rate, the sample size, the trust rung and what runtime
13153
+ * corroborated — they used to show four bare words that a reader could
13154
+ * mistake for the whole evidence story.
13155
+ *
13156
+ * Every clause is conditional on the finding actually carrying that
13157
+ * evidence. Nothing here is ever defaulted into existence.
13158
+ */
13159
+ function evidenceTag(f) {
13160
+ const level = evidenceLevelOf(f);
13161
+ let tag = `${level} · ${EVIDENCE_KIND[level]}`;
13162
+ if (f.measuredFpRate !== void 0) {
13163
+ tag += ` · measured FP ${Math.round(f.measuredFpRate * 100)}%`;
13164
+ if (f.measuredFpN !== void 0) tag += ` · n=${f.measuredFpN}`;
13165
+ }
13166
+ if (f.trustLevel !== void 0) tag += ` · trust ${f.trustLevel}`;
13167
+ const runtime = runtimeLabel(f);
13168
+ if (runtime !== null) tag += ` · runtime: ${runtime}`;
13169
+ return `[${tag}]`;
13170
+ }
13171
+ //#endregion
13172
+ //#region src/engine/monorepo-analysis.ts
13173
+ /**
13174
+ * Package verdict, derived from the one ScoreState band model (BW-104).
13175
+ * These two constants used to be a private copy of the band boundaries;
13176
+ * the numeric vocabulary here is a different projection of the same bands
13177
+ * — "pass" is trusted or forged, "warn" is warning, "fail" is critical or
13178
+ * unmeasurable — so the boundaries are read, never retyped.
13179
+ */
13180
+ function verdictOf(score) {
13181
+ if (score === null) return "fail";
13182
+ const band = deriveScoreState(score).band;
13183
+ if (band === "forged" || band === "trusted") return "pass";
13184
+ return band === "warning" ? "warn" : "fail";
13185
+ }
13186
+ function hasBlocker(findings) {
13187
+ return findings.some((f) => f.severity === "error");
13188
+ }
13189
+ /**
13190
+ * Aggregate per-package results into an overall verdict.
13191
+ *
13192
+ * Worst-package propagation: if ANY package has a blocker (error-severity
13193
+ * finding) or a failing score, the overall result fails regardless of
13194
+ * strategy. This is the safety net — a single poisoned package must not
13195
+ * hide behind averaging.
13196
+ */
13197
+ function analyzeMonorepo(packages, config) {
13198
+ const results = packages.map((p) => ({
13199
+ ...p,
13200
+ verdict: hasBlocker(p.findings) ? "fail" : verdictOf(p.score)
13201
+ }));
13202
+ if (results.length === 0) return {
13203
+ packages: results,
13204
+ overallScore: null,
13205
+ overallVerdict: "fail",
13206
+ strategy: config.weightingStrategy
13207
+ };
13208
+ const blockerPkg = results.find((r) => r.verdict === "fail" || hasBlocker(r.findings));
13209
+ if (blockerPkg) return {
13210
+ packages: results,
13211
+ overallScore: blockerPkg.score,
13212
+ overallVerdict: "fail",
13213
+ strategy: config.weightingStrategy,
13214
+ blockerPackage: blockerPkg.packageName
13215
+ };
13216
+ switch (config.weightingStrategy) {
13217
+ case "worst-package": return worstPackageAggregation(results, config);
13218
+ case "average": return averageAggregation(results, config);
13219
+ case "configurable": return configurableAggregation(results, config);
13220
+ }
13221
+ }
13222
+ function worstPackageAggregation(results, config) {
13223
+ const firstResult = results[0];
13224
+ if (firstResult === void 0) return {
13225
+ packages: results,
13226
+ overallScore: null,
13227
+ overallVerdict: "fail",
13228
+ strategy: config.weightingStrategy
13229
+ };
13230
+ let worst = firstResult;
13231
+ for (const r of results) if ((r.score ?? 0) < (worst.score ?? 0)) worst = r;
13232
+ return {
13233
+ packages: results,
13234
+ overallScore: worst.score,
13235
+ overallVerdict: worst.verdict,
13236
+ strategy: config.weightingStrategy,
13237
+ ...worst.verdict === "fail" ? { blockerPackage: worst.packageName } : {}
13238
+ };
13239
+ }
13240
+ function averageAggregation(results, config) {
13241
+ const scored = results.filter((r) => r.score !== null);
13242
+ if (scored.length === 0) return {
13243
+ packages: results,
13244
+ overallScore: null,
13245
+ overallVerdict: "fail",
13246
+ strategy: config.weightingStrategy
13247
+ };
13248
+ const avg = scored.reduce((sum, r) => sum + (r.score ?? 0), 0) / scored.length;
13249
+ return {
13250
+ packages: results,
13251
+ overallScore: Math.round(avg),
13252
+ overallVerdict: verdictOf(Math.round(avg)),
13253
+ strategy: config.weightingStrategy
13254
+ };
13255
+ }
13256
+ function configurableAggregation(results, config) {
13257
+ const weights = config.packageWeights ?? {};
13258
+ let totalWeight = 0;
13259
+ let weightedSum = 0;
13260
+ for (const r of results) {
13261
+ const w = weights[r.packageName] ?? 1;
13262
+ totalWeight += w;
13263
+ weightedSum += (r.score ?? 0) * w;
13264
+ }
13265
+ if (totalWeight === 0) return {
13266
+ packages: results,
13267
+ overallScore: null,
13268
+ overallVerdict: "fail",
13269
+ strategy: config.weightingStrategy
13270
+ };
13271
+ const score = Math.round(weightedSum / totalWeight);
13272
+ return {
13273
+ packages: results,
13274
+ overallScore: score,
13275
+ overallVerdict: verdictOf(score),
13276
+ strategy: config.weightingStrategy
13277
+ };
12403
13278
  }
13279
+ //#endregion
13280
+ //#region src/lib/fs-atomic.ts
12404
13281
  /**
12405
- * Digest of the active rule set: id + effective detectorRevision for
12406
- * every rule (sorted for determinism), plus a content hash of all rule
12407
- * source files. The directory-tree hash catches changes to ANY file
12408
- * under src/rules/ — same-file helpers, cross-module imports, shared
12409
- * utilities — without needing to trace the import graph. This is the
12410
- * only reliable way to detect helper function changes in a bundled
12411
- * ESM codebase where String(fn) doesn't reveal transitive dependencies.
13282
+ * Atomic file writes (audit S9).
13283
+ *
13284
+ * Every durability-critical write in Mjölnir (baseline, stats, badge,
13285
+ * TRIAGE.md, scaffolded rule files) used to hand-roll
13286
+ * `writeFileSync(path, data)` — a crash mid-write left a TRUNCATED file
13287
+ * at the real path, and a subsequent read (diff, badge endpoint) served
13288
+ * confident nonsense from it.
13289
+ *
13290
+ * `writeFileAtomic` writes to a temp sibling, then RENAMES. On the same
13291
+ * volume rename is atomic: readers see either the complete old file or
13292
+ * the complete new file, never a half-written one. The temp name is
13293
+ * created with `wx` (exclusive) so concurrent writers cannot interleave,
13294
+ * stale temps are cleaned up on failure, and on Windows the rename is
13295
+ * retried briefly because a concurrent reader can hold the destination
13296
+ * open (EBUSY/EPERM).
12412
13297
  */
12413
- function computeRulesDigest(rules) {
12414
- const parts = rules.map((r) => `${r.id}:${r.detectorRevision ?? 1}`).sort();
12415
- const rulesDirHash = hashRulesSourceTree(rules);
12416
- return sha256(parts.join("|") + "\0" + rulesDirHash);
13298
+ function atomicTempPath(path) {
13299
+ return `${path}.mjolnir-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}.tmp`;
12417
13300
  }
12418
13301
  /**
12419
- * Hash all .ts files under src/rules/ (when the first rule's modulePath
12420
- * reveals the project root). Falls back to per-rule function source
12421
- * hashing when the directory cannot be resolved.
13302
+ * Atomically replace `path` with `data`.
13303
+ *
13304
+ * Accepts binary payloads as well as text: base-tree materialization in
13305
+ * `impact` writes git blobs, and a writer that only takes strings forces those
13306
+ * call sites back to the non-atomic path rather than keeping them contained.
12422
13307
  */
12423
- function hashRulesSourceTree(rules) {
12424
- const firstPath = rules.find((r) => r.modulePath)?.modulePath;
12425
- if (!firstPath) return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
12426
- const idx = firstPath.replace(/\\/g, "/").indexOf("/src/rules/");
12427
- if (idx < 0) return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
12428
- const rulesDir = firstPath.replace(/\\/g, "/").slice(0, idx + 11);
13308
+ function writeFileAtomic(path, data, opts = {}) {
13309
+ const dir = dirname(path);
13310
+ if (opts.mkdirs !== false && !existsSync(dir)) mkdirSync(dir, { recursive: true });
13311
+ const tmp = atomicTempPath(path);
13312
+ let fd;
12429
13313
  try {
12430
- const hash = createHash("sha256");
12431
- hashDir(rulesDir, hash, 0);
12432
- return hash.digest("hex").slice(0, 16);
12433
- } catch {
12434
- return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
13314
+ fd = openSync(tmp, "wx", opts.mode ?? 420);
13315
+ if (typeof data === "string") writeSync(fd, data, null, opts.encoding ?? "utf8");
13316
+ else writeSync(fd, data);
13317
+ } finally {
13318
+ if (fd !== void 0) closeSync(fd);
12435
13319
  }
12436
- }
12437
- function hashDir(dir, hash, depth) {
12438
- if (depth > 8) return;
12439
- let entries;
12440
13320
  try {
12441
- entries = readdirSync(dir, { withFileTypes: true });
12442
- } catch {
12443
- return;
12444
- }
12445
- entries.sort((a, b) => a.name.localeCompare(b.name));
12446
- for (const entry of entries) {
12447
- if (entry.name === "node_modules" || entry.name === ".git") continue;
12448
- const full = join(dir, entry.name);
12449
- if (entry.isDirectory()) hashDir(full, hash, depth + 1);
12450
- else if (entry.isFile() && /\.tsx?$/.test(entry.name)) try {
12451
- hash.update(entry.name);
12452
- hash.update(readFileSync(full));
13321
+ renameWithWindowsRetry(tmp, path);
13322
+ } catch (err) {
13323
+ try {
13324
+ if (existsSync(tmp)) unlinkSync(tmp);
12453
13325
  } catch {}
13326
+ throw err;
12454
13327
  }
12455
13328
  }
12456
13329
  /**
12457
- * Content-addressed key for one file's rule-loop verdicts.
13330
+ * renameSync retry loop for Windows: a concurrent reader (another scan,
13331
+ * a badge endpoint, an editor) holding the destination open makes
13332
+ * rename fail with EBUSY/EPERM. A short bounded retry closes the race
13333
+ * without turning an atomic swap into a partial write.
12458
13334
  *
12459
- * Audit C1: the key MUST identify the verdict's producer, not just the
12460
- * bytes — two files with byte-identical text (a copied spec, a generated
12461
- * snapshot) previously shared one entry, and the first file's cached
12462
- * findings were re-emitted for the second with the wrong `file` stamp.
12463
- * The key therefore folds in the repo-relative path AND the adapter id,
12464
- * plus a parse-mode token (audit W9): a file whose analysis degraded to
12465
- * the regex fallback (or skipped the AST path) must not collide with a
12466
- * fully-AST-analyzed verdict for the same bytes — the fallback output
12467
- * belongs only to the fallback mode.
12468
- */
12469
- function fileCacheKey(rulesDigest, fileText, identity) {
12470
- const parseMode = identity.parseMode ?? "ast";
12471
- return sha256(`${CACHE_VERSION}\u0000${rulesDigest}\u0000${identity.relPath}\u0000${identity.adapterId}\u0000${parseMode}\u0000${fileText}`);
12472
- }
12473
- /** No-op cache used when --cache is absent: zero stats, zero I/O. */
12474
- const disabledScanCache = {
12475
- stats: {
12476
- hits: 0,
12477
- misses: 0,
12478
- file: ""
12479
- },
12480
- lookup: () => void 0,
12481
- store: () => {},
12482
- persist: () => {}
12483
- };
12484
- /**
12485
- * Opens (and lazily creates) `<root>/.mjolnir/cache/scan-v<CACHE_VERSION>.json`. A
12486
- * corrupt, hostile or future-versioned cache file degrades to a cold
12487
- * cache — never fails the scan.
13335
+ * Internal contract test hook: the platform check keeps this loop off
13336
+ * the POSIX hot path; on win32 the EBUSY/EPERM arms are exercised by
13337
+ * the fs-atomic-retry spec (mocked renameSync).
12488
13338
  */
12489
- function createScanCache(root) {
12490
- const dir = join(root, ".mjolnir", "cache");
12491
- const file = join(dir, `scan-v${CACHE_VERSION}.json`);
12492
- let entries = {};
12493
- let dirty = false;
12494
- const entryBytes = /* @__PURE__ */ new Map();
12495
- let totalBytes = 0;
13339
+ const RENAME_RETRIES = 8;
13340
+ const RENAME_RETRY_DELAY_MS = 25;
13341
+ /** Synchronous sleep that does not spin the CPU. */
13342
+ function sleepSync(ms) {
12496
13343
  try {
12497
- if (existsSync(file)) {
12498
- const stat = lstatSync(file);
12499
- if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_TOTAL_BYTES) throw new Error("invalid cache file");
12500
- entries = parseJsonFile(readFileSync(file, "utf8"), file, isCacheFile).entries;
12501
- for (const [k, v] of Object.entries(entries)) {
12502
- const size = Buffer.byteLength(JSON.stringify(v), "utf8") + k.length + 4;
12503
- entryBytes.set(k, size);
12504
- totalBytes += size;
12505
- }
12506
- if (totalBytes > MAX_TOTAL_BYTES) throw new Error("cache byte budget exceeded");
13344
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
13345
+ } catch {
13346
+ const until = Date.now() + ms;
13347
+ while (Date.now() < until);
13348
+ }
13349
+ }
13350
+ function renameWithWindowsRetry(from, to) {
13351
+ for (let attempt = 0;; attempt++) try {
13352
+ renameSync(from, to);
13353
+ return;
13354
+ } catch (err) {
13355
+ const code = err?.code;
13356
+ if (process.platform === "win32" && (code === "EBUSY" || code === "EPERM") && attempt < RENAME_RETRIES) {
13357
+ sleepSync(RENAME_RETRY_DELAY_MS);
13358
+ continue;
12507
13359
  }
13360
+ throw err;
13361
+ }
13362
+ }
13363
+ const STALE_TEMP_AGE_MS = 864e5;
13364
+ const TEMP_PID_RE = /^.+\.mjolnir-([1-9]\d{0,9})-(\d{13})-[0-9a-f]{8}\.tmp$/;
13365
+ function sweepStaleTempFiles(dir) {
13366
+ let swept = 0;
13367
+ let entries;
13368
+ try {
13369
+ entries = readdirSync(dir);
12508
13370
  } catch {
12509
- entries = {};
12510
- entryBytes.clear();
12511
- totalBytes = 0;
13371
+ return 0;
12512
13372
  }
12513
- return {
12514
- stats: {
12515
- hits: 0,
12516
- misses: 0,
12517
- file
12518
- },
12519
- lookup(key) {
12520
- const entry = entries[key];
12521
- if (!entry) {
12522
- this.stats.misses++;
12523
- return;
12524
- }
12525
- this.stats.hits++;
12526
- delete entries[key];
12527
- entries[key] = entry;
12528
- return structuredClone(entry.findings);
12529
- },
12530
- store(key, findings, fileBudgetExceeded) {
12531
- if (fileBudgetExceeded) return;
12532
- if (findings.length > MAX_FINDINGS_PER_ENTRY) return;
12533
- const entryJson = JSON.stringify(findings);
12534
- const newBytes = Buffer.byteLength(entryJson, "utf8") + key.length + 4;
12535
- if (newBytes > MAX_TOTAL_BYTES) return;
12536
- const replacedBytes = entryBytes.get(key) ?? 0;
12537
- delete entries[key];
12538
- entries[key] = { findings: structuredClone(findings) };
12539
- entryBytes.set(key, newBytes);
12540
- totalBytes = totalBytes - replacedBytes + newBytes;
12541
- let count = Object.keys(entries).length;
12542
- while ((count > MAX_ENTRIES$1 || totalBytes > MAX_TOTAL_BYTES) && count > 1) {
12543
- const oldest = Object.keys(entries)[0];
12544
- totalBytes -= entryBytes.get(oldest);
12545
- delete entries[oldest];
12546
- entryBytes.delete(oldest);
12547
- count--;
12548
- }
12549
- dirty = true;
12550
- },
12551
- persist() {
12552
- if (!dirty) return;
12553
- try {
12554
- mkdirSync(dir, { recursive: true });
12555
- writeFileAtomic(file, JSON.stringify({
12556
- version: CACHE_VERSION,
12557
- entries
12558
- }), { encoding: "utf8" });
12559
- } catch {}
13373
+ const cutoff = Date.now() - STALE_TEMP_AGE_MS;
13374
+ for (const entry of entries) {
13375
+ const match = TEMP_PID_RE.exec(entry);
13376
+ if (!match || Number(match[2]) > cutoff) continue;
13377
+ const path = join(dir, entry);
13378
+ try {
13379
+ const stat = lstatSync(path);
13380
+ if (!stat.isFile() || stat.mtimeMs > cutoff) continue;
13381
+ if (pidAlive(Number(match[1]))) continue;
13382
+ unlinkSync(path);
13383
+ swept++;
13384
+ } catch {
13385
+ continue;
12560
13386
  }
12561
- };
13387
+ }
13388
+ return swept;
13389
+ }
13390
+ function pidAlive(pid) {
13391
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 2147483647) return true;
13392
+ if (pid === process.pid) return true;
13393
+ try {
13394
+ process.kill(pid, 0);
13395
+ return true;
13396
+ } catch (err) {
13397
+ return err?.code !== "ESRCH";
13398
+ }
12562
13399
  }
12563
13400
  //#endregion
12564
- //#region src/brand/tokens.ts
12565
- /**
12566
- * The single source of brand truth.
12567
- *
12568
- * Every colour, typeface and motion constant Mjölnir shows a human —
12569
- * terminal, README SVGs, demo video, website, docs, badges — resolves to
12570
- * a value in this file. Nothing else may define one.
12571
- *
12572
- * WHY THIS EXISTS. Before it, the palette existed in six independent
12573
- * copies: `site/.vitepress/theme/styles/vars.css`, `NORSE` in
12574
- * `src/reporter/theme.ts`, `scripts/readme-svg.ts`,
12575
- * `scripts/video/terminal-page.ts`, `scripts/generate-readme-architecture.ts`
12576
- * and the table in `assets/brand/README.md`. Exactly one pair of those
12577
- * was guarded (site-doctor Check 8, doc ↔ vars.css). The unguarded edges
12578
- * are where the shipped surfaces drifted apart: the terminal and the site
12579
- * disagreed on six semantic roles, the architecture diagram invented its
12580
- * own neutral ramp, and the README badges still carried a palette retired
12581
- * two releases earlier. `scripts/brand-doctor.mjs` now checks every edge
12582
- * against this file.
12583
- *
12584
- * PURITY. Pure data. No I/O, no rendering, no environment access, no
12585
- * imports, no logic. Consumers convert (hex → ANSI triplet, hex → CSS)
12586
- * themselves. Same reason `score-state.ts` is pure: it makes the whole
12587
- * thing golden-testable and safe to ship inside the npm package, where
12588
- * it costs a few hundred bytes and replaces values the package already
12589
- * carried anyway.
12590
- *
12591
- * DERIVATION. The palette is the one derived from the logo in PR #20
12592
- * (brushed steel and forge gold under an aurora, over midnight iron).
12593
- * Where the terminal disagreed with it, the terminal converges — see
12594
- * `PENDING_TERMINAL` below. Full rationale: `assets/brand/README.md`.
12595
- *
12596
- * ACCESSIBILITY. Every foreground token in `BRAND` meets WCAG AA
12597
- * (≥ 4.5:1) against every surface token it is allowed to sit on. That is
12598
- * not a claim, it is `brand-doctor` rule 8, which computes the ratios.
12599
- * The weakest legal pairing is `steelDim` on `ink800` at 5.00:1.
12600
- */
13401
+ //#region src/engine/scan-cache.ts
12601
13402
  /**
12602
- * The two brand hues plus the neutral they sit on.
13403
+ * Local incremental scan cache (Beta-to-Stable 1.0 plan, M5.2 / A-2).
12603
13404
  *
12604
- * GOLD IS SCARCE. It means forged / certified / earned / decisive — the
12605
- * primary mark, the FORGED state, one call to action. It is not a paint
12606
- * bucket: gold as default text, default border or default heading is a
12607
- * brand-doctor finding, not a style choice.
13405
+ * Content-addressed, local-only verdict cache: `--cache` reuses the
13406
+ * per-file rule outputs of a previous scan when the file's bytes AND the
13407
+ * active rule set are unchanged, and invalidates everything else. The
13408
+ * key is `sha256(fileText) + rulesDigest` + the file's own identity
13409
+ * (repo-relative path + adapter id + parse mode — audit C1/W9), where
13410
+ * the rules digest folds
13411
+ * in every active rule's id + `detectorRevision ?? 1` (the existing
13412
+ * stale-measurement machinery — Verification Trust Evolution Plan §07 —
13413
+ * reused as the cache invalidation signal, per A-2) plus a source hash
13414
+ * of each external plugin/local rule's `run` function, so a plugin that
13415
+ * changes code without bumping its revision still misses.
12608
13416
  *
12609
- * AURORA is verification energy — the secondary, and the hue that marks
12610
- * the runtime half of the trust ladder.
12611
- */
12612
- const BRAND = {
12613
- gold: "#C19A34",
12614
- goldBright: "#E6BD57",
12615
- goldHot: "#F4DC9C",
12616
- /** Pressed / deepest gold — the only step dark enough to carry white. */
12617
- goldDeep: "#A5811C",
12618
- aurora: "#37ABBD",
12619
- auroraBright: "#45C1D4",
12620
- auroraCyan: "#5CBDE0",
12621
- /** The aurora's outer curtains: atmosphere and section identity only,
12622
- * never a verdict or a status. */
12623
- auroraGreen: "#5FD6A4",
12624
- auroraViolet: "#9D8CF5",
12625
- steel: "#C8CBCF",
12626
- steelDim: "#8B939D"
12627
- };
12628
- /**
12629
- * Midnight iron. One ramp, four steps, darkest first.
13417
+ * Privacy posture: the cache lives under `<repo>/.mjolnir/cache/`, is
13418
+ * gitignored, never leaves the machine, and this module performs zero
13419
+ * network I/O — fs and crypto only (asserted by the privacy spec).
12630
13420
  *
12631
- * `terminal` and `terminalBar` share one tone deliberately: the window's
12632
- * only seam is a hairline ring and an inset shadow, never a second fill.
12633
- * `chromeDot` is the three window dots — see the note on
12634
- * `PENDING_TERMINAL.chromeDots` for why they are no longer red/amber/green.
12635
- */
12636
- const SURFACE = {
12637
- ink950: "#0A1119",
12638
- ink900: "#0C1420",
12639
- ink850: "#111A29",
12640
- ink800: "#18243A",
12641
- /** Raised panel (cards, elevated surfaces). */
12642
- panel: "#141F33",
12643
- /** Soft fill (inline code, quiet chips). */
12644
- soft: "#1A2740",
12645
- /** Terminal body — the deepest tone, so a terminal reads as recessed. */
12646
- terminal: "#0A1119",
12647
- /** Terminal title bar — the same tone; the seam is shadow, not colour. */
12648
- terminalBar: "#0A1119",
12649
- /** The three window dots. One neutral, not a traffic light. */
12650
- chromeDot: "#18243A"
12651
- };
12652
- const TEXT = {
12653
- primary: "#EAEEF5",
12654
- secondary: "#ABB6C6",
12655
- muted: "#8B939D",
12656
- /** Ink for text set ON gold (buttons, the FORGED chip). 7.17:1 on `gold`. */
12657
- onGold: "#0A1119"
12658
- };
12659
- /**
12660
- * Non-score status. `ok` is the one green in the system and it is NOT a
12661
- * score colour — it survives only for contexts with no worthiness
12662
- * meaning ("autofix applied", "analysis complete"). A green score would
12663
- * say "your software is fine", which is the exact claim this product
12664
- * refuses to make.
13421
+ * Only raw rule-loop outputs are cached. Everything after the loop
13422
+ * (severity overrides, suppressions, overlap dedup, evidence stamping,
13423
+ * tier policy, runtime corroboration, scoring) re-runs on every scan,
13424
+ * so a cached scan is byte-equivalent to a fresh one by construction.
12665
13425
  */
12666
- const STATUS = {
12667
- ok: "#4FB477",
12668
- info: "#5CC4E0",
12669
- warning: "#E6BD57",
12670
- error: "#EC6B66"
12671
- };
13426
+ const CACHE_VERSION = 2;
13427
+ /** Entry cap: a monorepo-scale suite stays far below this; bounded file. */
13428
+ const MAX_ENTRIES$1 = 4096;
12672
13429
  /**
12673
- * The four ScoreState bands plus the unmeasured state. Band thresholds
12674
- * and runes live in `src/reporter/score-state.ts`, which stays free of
12675
- * colour — it emits a palette KEY and each surface resolves it here.
12676
- *
12677
- * `unmeasured` is steel-dim on purpose. UNKNOWN is a legitimate answer,
12678
- * not a failure: colouring it red would make "we did not measure this"
12679
- * look like "this is broken", which is precisely the dishonesty the
12680
- * north-star law exists to prevent.
13430
+ * Audit M5: total byte budget. The old cap counted only ENTRIES, so
13431
+ * 4096 files × ~100KB of findings each could still produce a
13432
+ * multi-hundred-MB writeFileSync on persist. The budget bounds the
13433
+ * serialized size; the newest entries win (real LRU-by-use).
12681
13434
  */
12682
- const SCORE = {
12683
- critical: "#EC6B66",
12684
- warning: "#E6BD57",
12685
- trusted: "#5CC4E0",
12686
- forged: "#F4DC9C",
12687
- unmeasured: "#8B939D"
12688
- };
13435
+ const MAX_TOTAL_BYTES = 33554432;
13436
+ const MAX_FINDINGS_PER_ENTRY = 1e4;
13437
+ function isCachedFinding(value) {
13438
+ if (!isRecord(value)) return false;
13439
+ 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";
13440
+ }
13441
+ function isCacheFile(value) {
13442
+ if (!isRecord(value) || value["version"] !== CACHE_VERSION) return false;
13443
+ const entries = value["entries"];
13444
+ if (!isRecord(entries) || Object.keys(entries).length > MAX_ENTRIES$1) return false;
13445
+ return Object.values(entries).every((entry) => isRecord(entry) && Array.isArray(entry["findings"]) && entry["findings"].length <= MAX_FINDINGS_PER_ENTRY && entry["findings"].every(isCachedFinding));
13446
+ }
13447
+ /** sha256 hex of a string — the only hash this module needs. */
13448
+ function sha256(text) {
13449
+ return createHash("sha256").update(text).digest("hex");
13450
+ }
12689
13451
  /**
12690
- * E0 → E1 → E2 is a certainty ramp, and it is deliberately HUE-FREE.
12691
- *
12692
- * Evidence level says how sure we are, not whether the news is good. A
12693
- * deterministic proof (E2) is a defect we are certain about — painting
12694
- * it gold or green would read as an achievement. So certainty is carried
12695
- * by brightness alone, and the *shape* does the real work:
12696
- *
12697
- * E0 open ring observation, no weight
12698
- * E1 half-filled pattern evidence, half weight
12699
- * E2 sealed deterministic proof, full weight
12700
- *
12701
- * Colour never carries this alone (R11): the geometry is the signal and
12702
- * survives `--ascii`, `NO_COLOR` and monochrome print.
13452
+ * Digest of the active rule set: id + effective detectorRevision for
13453
+ * every rule (sorted for determinism), plus a content hash of all rule
13454
+ * source files. The directory-tree hash catches changes to ANY file
13455
+ * under src/rules/ — same-file helpers, cross-module imports, shared
13456
+ * utilities — without needing to trace the import graph. This is the
13457
+ * only reliable way to detect helper function changes in a bundled
13458
+ * ESM codebase where String(fn) doesn't reveal transitive dependencies.
12703
13459
  */
12704
- const EVIDENCE = {
12705
- e0: "#8B939D",
12706
- e1: "#ABB6C6",
12707
- e2: "#EAEEF5"
12708
- };
13460
+ function computeRulesDigest(rules) {
13461
+ const parts = rules.map((r) => `${r.id}:${r.detectorRevision ?? 1}`).sort();
13462
+ const rulesDirHash = hashRulesSourceTree(rules);
13463
+ return sha256(parts.join("|") + "\0" + rulesDirHash);
13464
+ }
12709
13465
  /**
12710
- * L0–L5, and the most important boundary in the product.
12711
- *
12712
- * L0–L2 are STATIC: the neutral steel ramp, brightening to the static
12713
- * ceiling at L2. L3–L5 require a real run, and the hue changes to aurora
12714
- * exactly there. The boundary is a hue break, not a gradient step,
12715
- * because it is a change of kind and not of degree — a static-only
12716
- * finding can never climb past L2, however confident it is.
12717
- *
12718
- * Every surface that draws the ladder must draw that break.
13466
+ * Hash all .ts files under src/rules/ (when the first rule's modulePath
13467
+ * reveals the project root). Falls back to per-rule function source
13468
+ * hashing when the directory cannot be resolved.
12719
13469
  */
12720
- const TRUST = {
12721
- l0: "#8B939D",
12722
- l1: "#ABB6C6",
12723
- l2: "#C8CBCF",
12724
- l3: "#37ABBD",
12725
- l4: "#45C1D4",
12726
- l5: "#5CC4E0"
12727
- };
12728
- const TINT = {
12729
- gold: {
12730
- fill: "#F6EBCC",
12731
- stroke: "#7A5F16",
12732
- text: "#4A3A0E"
12733
- },
12734
- aurora: {
12735
- fill: "#D9F0F4",
12736
- stroke: "#1F6F7C",
12737
- text: "#10353C"
12738
- },
12739
- error: {
12740
- fill: "#FADEDD",
12741
- stroke: "#A83A35",
12742
- text: "#4E1B19"
12743
- },
12744
- /** The unmeasured / unknown state. Neutral, never the error tint. */
12745
- neutral: {
12746
- fill: "#E4E7EB",
12747
- stroke: "#5C646E",
12748
- text: "#262B31"
12749
- },
12750
- ok: {
12751
- fill: "#DCF0E4",
12752
- stroke: "#276B45",
12753
- text: "#163A26"
13470
+ function hashRulesSourceTree(rules) {
13471
+ const firstPath = rules.find((r) => r.modulePath)?.modulePath;
13472
+ if (!firstPath) return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
13473
+ const idx = firstPath.replace(/\\/g, "/").indexOf("/src/rules/");
13474
+ if (idx < 0) return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
13475
+ const rulesDir = firstPath.replace(/\\/g, "/").slice(0, idx + 11);
13476
+ try {
13477
+ const hash = createHash("sha256");
13478
+ hashDir(rulesDir, hash, 0);
13479
+ return hash.digest("hex").slice(0, 16);
13480
+ } catch {
13481
+ return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
13482
+ }
13483
+ }
13484
+ function hashDir(dir, hash, depth) {
13485
+ if (depth > 8) return;
13486
+ let entries;
13487
+ try {
13488
+ entries = readdirSync(dir, { withFileTypes: true });
13489
+ } catch {
13490
+ recordDegradation("rules-tree-listing-unreadable");
13491
+ return;
12754
13492
  }
12755
- };
13493
+ entries.sort((a, b) => compareCodePoints(a.name, b.name));
13494
+ for (const entry of entries) {
13495
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
13496
+ const full = join(dir, entry.name);
13497
+ if (entry.isDirectory()) hashDir(full, hash, depth + 1);
13498
+ else if (entry.isFile() && /\.tsx?$/.test(entry.name)) try {
13499
+ hash.update(entry.name);
13500
+ hash.update(readFileSync(full));
13501
+ } catch {}
13502
+ }
13503
+ }
12756
13504
  /**
12757
- * The score bands as the badge Mjölnir itself generates renders them.
12758
- *
12759
- * These are DEEPER than the score tokens on purpose, and it is not a
12760
- * style preference: shields.io sets the message text in white and gives
12761
- * you no say in it. `score.forged` (#F4DC9C) under white text measures
12762
- * 1.35:1 — an unreadable badge, shipped to look on-brand. The brand's
12763
- * own deep steps put every band between 4.9 and 6.3:1.
12764
- *
12765
- * What they replace was worse than off-brand, it was wrong:
12766
- *
12767
- * band was rendered as white-on now white-on
12768
- * ────────────────────────────────────────────────────────────────────────────
12769
- * 0-49 `red` #dd4343 4.24 #A83A35 6.32
12770
- * 50-79 `yellow` #d8b800 1.95 #7A5F16 6.04
12771
- * 80-99 `important` #ea7233 ORANGE 3.02 #1F6F7C 5.80
12772
- * 100 `success` #44bb00 GREEN 2.51 #8A6D1E 4.90
12773
- * unmeasured `lightgrey` #939393 3.07 #5C646E 5.99
12774
- *
12775
- * Two of those were defects, not preferences. `success` is green, and
12776
- * green is not a score colour here — a 100 badge said "your software is
12777
- * fine", which is the one claim this product refuses to make. And
12778
- * `important` is ORANGE, not the blue-family colour the code's own
12779
- * comment claimed for eight releases: every WORTHY badge ever rendered
12780
- * showed the trusted band in a warning colour. Nobody had resolved a
12781
- * shields name to a value and looked.
13505
+ * Content-addressed key for one file's rule-loop verdicts.
12782
13506
  *
12783
- * Values are hex without `#`, the form shields.io's endpoint takes.
12784
- * `A83A35`, `7A5F16`, `1F6F7C` and `5C646E` are the `TINT` strokes —
12785
- * the same deep steps the mermaid diagrams use, for the same reason.
12786
- * `8A6D1E` is the gold the brand document already named as the light
12787
- * FORGED gradient's start.
13507
+ * Audit C1: the key MUST identify the verdict's producer, not just the
13508
+ * bytes — two files with byte-identical text (a copied spec, a generated
13509
+ * snapshot) previously shared one entry, and the first file's cached
13510
+ * findings were re-emitted for the second with the wrong `file` stamp.
13511
+ * The key therefore folds in the repo-relative path AND the adapter id,
13512
+ * plus a parse-mode token (audit W9): a file whose analysis degraded to
13513
+ * the regex fallback (or skipped the AST path) must not collide with a
13514
+ * fully-AST-analyzed verdict for the same bytes — the fallback output
13515
+ * belongs only to the fallback mode.
12788
13516
  */
12789
- const BADGE_BAND = {
12790
- critical: "A83A35",
12791
- warning: "7A5F16",
12792
- trusted: "1F6F7C",
12793
- forged: "8A6D1E",
12794
- unmeasured: "5C646E"
12795
- };
12796
- SURFACE.ink950, SURFACE.ink900, SURFACE.ink850, SURFACE.ink800, BRAND.steel, BRAND.steelDim, BRAND.gold, BRAND.goldBright, BRAND.goldHot, BRAND.aurora, BRAND.auroraBright, BRAND.auroraCyan, BRAND.auroraGreen, BRAND.auroraViolet;
12797
- SCORE.trusted, SCORE.forged, SCORE.warning, SCORE.critical, STATUS.info, TEXT.onGold, STATUS.ok, EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2, TRUST.l0, TRUST.l1, TRUST.l2, TRUST.l3, TRUST.l4, TRUST.l5;
12798
- //#endregion
12799
- //#region src/reporter/score-state.ts
12800
- const HEADLINES = {
12801
- critical: "The hammer is cracked — {n} findings break its edge.",
12802
- warning: "The hammer holds — but {n} findings weigh it down.",
12803
- trusted: "Held in worthy hands — {n} findings remain.",
12804
- forged: "Static score 100 — no findings on the analyzed surface.",
12805
- unmeasured: "No tests found — the hammer cannot be weighed."
12806
- };
12807
- const RUNES = {
12808
- critical: "ᚲ",
12809
- warning: "ᚦ",
12810
- trusted: "ᛏ",
12811
- forged: "ᛟ",
12812
- unmeasured: "ᛁ"
13517
+ function fileCacheKey(rulesDigest, fileText, identity) {
13518
+ const parseMode = identity.parseMode ?? "ast";
13519
+ return sha256(`${CACHE_VERSION}\u0000${rulesDigest}\u0000${identity.relPath}\u0000${identity.adapterId}\u0000${parseMode}\u0000${fileText}`);
13520
+ }
13521
+ /** No-op cache used when --cache is absent: zero stats, zero I/O. */
13522
+ const disabledScanCache = {
13523
+ stats: {
13524
+ hits: 0,
13525
+ misses: 0,
13526
+ file: ""
13527
+ },
13528
+ lookup: () => void 0,
13529
+ store: () => {},
13530
+ persist: () => {}
12813
13531
  };
12814
- /** Band mapping (the one mapping, three consumers): <50 critical, 50–79
12815
- * warning, 80–99 trusted, 100 forged, null unmeasured. */
12816
- function deriveScoreState(score) {
12817
- if (score === null) return {
12818
- score: null,
12819
- band: "unmeasured",
12820
- verdict: "UNWORTHY",
12821
- color: "dim",
12822
- powerLevel: 0,
12823
- headline: HEADLINES.unmeasured,
12824
- rune: RUNES.unmeasured
12825
- };
12826
- if (score >= 100) return {
12827
- score,
12828
- band: "forged",
12829
- verdict: "FORGED",
12830
- color: "forged",
12831
- powerLevel: score,
12832
- headline: HEADLINES.forged,
12833
- rune: RUNES.forged
12834
- };
12835
- if (score >= 80) return {
12836
- score,
12837
- band: "trusted",
12838
- verdict: "WORTHY",
12839
- color: "trusted",
12840
- powerLevel: score,
12841
- headline: HEADLINES.trusted,
12842
- rune: RUNES.trusted
12843
- };
12844
- if (score >= 50) return {
12845
- score,
12846
- band: "warning",
12847
- verdict: "NEEDS WORK",
12848
- color: "warning",
12849
- powerLevel: score,
12850
- headline: HEADLINES.warning,
12851
- rune: RUNES.warning
12852
- };
13532
+ /**
13533
+ * Opens (and lazily creates) `<root>/.mjolnir/cache/scan-v<CACHE_VERSION>.json`. A
13534
+ * corrupt, hostile or future-versioned cache file degrades to a cold
13535
+ * cache — never fails the scan.
13536
+ */
13537
+ function createScanCache(root) {
13538
+ const dir = join(root, ".mjolnir", "cache");
13539
+ const file = join(dir, `scan-v${CACHE_VERSION}.json`);
13540
+ let entries = {};
13541
+ let dirty = false;
13542
+ const entryBytes = /* @__PURE__ */ new Map();
13543
+ let totalBytes = 0;
13544
+ try {
13545
+ if (existsSync(file)) {
13546
+ const stat = lstatSync(file);
13547
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_TOTAL_BYTES) throw new Error("invalid cache file");
13548
+ entries = parseJsonFile(readFileSync(file, "utf8"), file, isCacheFile).entries;
13549
+ for (const [k, v] of Object.entries(entries)) {
13550
+ const size = Buffer.byteLength(JSON.stringify(v), "utf8") + k.length + 4;
13551
+ entryBytes.set(k, size);
13552
+ totalBytes += size;
13553
+ }
13554
+ if (totalBytes > MAX_TOTAL_BYTES) throw new Error("cache byte budget exceeded");
13555
+ }
13556
+ } catch {
13557
+ entries = {};
13558
+ entryBytes.clear();
13559
+ totalBytes = 0;
13560
+ }
12853
13561
  return {
12854
- score,
12855
- band: "critical",
12856
- verdict: "UNWORTHY",
12857
- color: "error",
12858
- powerLevel: score,
12859
- headline: HEADLINES.critical,
12860
- rune: RUNES.critical
13562
+ stats: {
13563
+ hits: 0,
13564
+ misses: 0,
13565
+ file
13566
+ },
13567
+ lookup(key) {
13568
+ const entry = entries[key];
13569
+ if (!entry) {
13570
+ this.stats.misses++;
13571
+ return;
13572
+ }
13573
+ this.stats.hits++;
13574
+ delete entries[key];
13575
+ entries[key] = entry;
13576
+ return structuredClone(entry.findings);
13577
+ },
13578
+ store(key, findings, fileBudgetExceeded) {
13579
+ if (fileBudgetExceeded) return;
13580
+ if (findings.length > MAX_FINDINGS_PER_ENTRY) return;
13581
+ const entryJson = JSON.stringify(findings);
13582
+ const newBytes = Buffer.byteLength(entryJson, "utf8") + key.length + 4;
13583
+ if (newBytes > MAX_TOTAL_BYTES) return;
13584
+ const replacedBytes = entryBytes.get(key) ?? 0;
13585
+ delete entries[key];
13586
+ entries[key] = { findings: structuredClone(findings) };
13587
+ entryBytes.set(key, newBytes);
13588
+ totalBytes = totalBytes - replacedBytes + newBytes;
13589
+ let count = Object.keys(entries).length;
13590
+ while ((count > MAX_ENTRIES$1 || totalBytes > MAX_TOTAL_BYTES) && count > 1) {
13591
+ const oldest = Object.keys(entries)[0];
13592
+ totalBytes -= entryBytes.get(oldest);
13593
+ delete entries[oldest];
13594
+ entryBytes.delete(oldest);
13595
+ count--;
13596
+ }
13597
+ dirty = true;
13598
+ },
13599
+ persist() {
13600
+ if (!dirty) return;
13601
+ try {
13602
+ mkdirSync(dir, { recursive: true });
13603
+ writeFileAtomic(file, JSON.stringify({
13604
+ version: CACHE_VERSION,
13605
+ entries
13606
+ }), { encoding: "utf8" });
13607
+ } catch {}
13608
+ }
12861
13609
  };
12862
13610
  }
12863
- /** Substitute a findings count into a headline template. Pure text
12864
- * helper so every surface formats identically. */
12865
- function headlineFor(state, findings) {
12866
- return state.headline.replace("{n}", String(findings));
12867
- }
12868
13611
  //#endregion
12869
13612
  //#region src/reporter/theme.ts
12870
13613
  /**
@@ -12880,8 +13623,8 @@ function headlineFor(state, findings) {
12880
13623
  *
12881
13624
  * Symbols always accompany color (color-blind safe, R11).
12882
13625
  *
12883
- * Score-state colors come from ScoreState (score-state.ts) — the single
12884
- * source of truth shared with the badge and (P2) the web.
13626
+ * Score-state colors come from ScoreState (presentation.ts) — the single
13627
+ * source of truth shared with the badge, the dashboard and the site.
12885
13628
  *
12886
13629
  * Terminal robustness (Master-Stabilization-Plan Sprint 5 Task 22):
12887
13630
  * box-drawing/gauge helpers accept an explicit width so callers can
@@ -13058,14 +13801,11 @@ function padTo(s, width) {
13058
13801
  */
13059
13802
  function scoreGauge(score, p, width = 30, ascii = false) {
13060
13803
  const filled = Math.round(score / 100 * width);
13061
- const color = gaugeColor(score, p);
13804
+ const color = gaugeColorForBand(deriveScoreState(score).band, p);
13062
13805
  if (ascii) return color("#".repeat(filled)) + ".".repeat(Math.max(0, width - filled));
13063
13806
  const head = filled > 0 && filled < width ? "▓" : "";
13064
13807
  return color("█".repeat(Math.max(0, filled - (head ? 1 : 0)))) + color(head) + "░".repeat(width - filled);
13065
13808
  }
13066
- function gaugeColor(score, p) {
13067
- return gaugeColorForBand(deriveScoreState(score).band, p);
13068
- }
13069
13809
  /**
13070
13810
  * The single band → palette-key mapping, delegated from ScoreState so
13071
13811
  * gauge, verdict and badge colors can never disagree again.
@@ -14220,7 +14960,7 @@ function runForensics(target, options = {}) {
14220
14960
  const base = stat.isFile() ? dirname(target) : target;
14221
14961
  flakyMdPath = join(base, "FLAKY.md");
14222
14962
  try {
14223
- writeFileSync(flakyMdPath, renderFlakyMd(report));
14963
+ writeFileAtomic(flakyMdPath, renderFlakyMd(report));
14224
14964
  } catch {
14225
14965
  flakyMdPath = void 0;
14226
14966
  }
@@ -14328,11 +15068,11 @@ function listFiles(dir) {
14328
15068
  * `mjolnir suppressions` — every suppression stays visible.
14329
15069
  */
14330
15070
  const ui = plainContext();
14331
- function loadSuppressions(root) {
15071
+ function loadSuppressions(root, now = /* @__PURE__ */ new Date()) {
14332
15072
  const { config } = loadConfig(root);
14333
15073
  const entries = (config.ignore ?? []).map((ign) => ({
14334
15074
  ...ign,
14335
- status: isSuppressionActive(ign, /* @__PURE__ */ new Date()) ? "active" : "expired"
15075
+ status: isSuppressionActive(ign, now) ? "active" : "expired"
14336
15076
  }));
14337
15077
  return {
14338
15078
  total: entries.length,
@@ -15101,6 +15841,7 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
15101
15841
  let rulesPartial = false;
15102
15842
  let parseFailed = 0;
15103
15843
  let parseFallbacks = 0;
15844
+ let astFallbackFiles = 0;
15104
15845
  let scanned = 0;
15105
15846
  let analyzed = 0;
15106
15847
  for (const path of testFiles) {
@@ -15126,8 +15867,6 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
15126
15867
  skippedFiles++;
15127
15868
  continue;
15128
15869
  }
15129
- let fileBudgetExceeded = false;
15130
- let fileRuleFailed = false;
15131
15870
  const relPath = relative(workspace.root, path).replaceAll("\\", "/");
15132
15871
  if (!isCiAdapter) {
15133
15872
  const lang = {
@@ -15146,6 +15885,10 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
15146
15885
  });
15147
15886
  }
15148
15887
  const wantsAst = adapter.parseAst !== void 0 && Date.now() <= deadline;
15888
+ if (adapter.parseAst !== void 0 && !wantsAst) {
15889
+ truncationReasons.add("ast-budget-fallback");
15890
+ astFallbackFiles++;
15891
+ }
15149
15892
  const identity = (mode) => ({
15150
15893
  relPath,
15151
15894
  adapterId: adapter.id,
@@ -15153,79 +15896,106 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
15153
15896
  });
15154
15897
  let cacheKey = fileCacheKey(rulesDigest, text, identity(wantsAst ? "ast" : "regex"));
15155
15898
  const cachedFindings = cache.lookup(cacheKey);
15156
- if (cachedFindings) {
15157
- for (const f of cachedFindings) findings.push(f);
15158
- analyzed++;
15159
- continue;
15160
- }
15161
- hooks.onProgress?.({
15162
- phase: "parse",
15163
- done: scanned,
15164
- total: testFiles.length,
15165
- detail: relPath
15166
- });
15167
- const parsedFile = {
15168
- path: relPath,
15169
- text
15170
- };
15171
- let parsed;
15172
- const findingsStart = findings.length;
15173
- try {
15174
- if (adapter.parseAst && wantsAst) {
15175
- hooks.onProgress?.({
15176
- phase: "rules",
15177
- done: scanned,
15178
- total: testFiles.length,
15179
- detail: relPath
15180
- });
15181
- parsed = await adapter.parseAst(parsedFile);
15182
- }
15183
- const actualMode = parsed ? "ast" : "regex";
15184
- if (wantsAst && actualMode === "regex") {
15185
- parseFallbacks++;
15186
- cacheKey = fileCacheKey(rulesDigest, text, identity(actualMode));
15187
- const fallbackFindings = cache.lookup(cacheKey);
15188
- if (fallbackFindings) {
15189
- for (const f of fallbackFindings) findings.push(f);
15190
- analyzed++;
15191
- continue;
15899
+ const executor = createExecutor("sequential", 1, async (job) => {
15900
+ if (job.cacheHit !== void 0) return {
15901
+ path: job.path,
15902
+ status: "CACHE_HIT",
15903
+ findings: [...job.cacheHit],
15904
+ parseFallback: false
15905
+ };
15906
+ let parseFallback = false;
15907
+ let fileRuleFailed = false;
15908
+ let fileBudgetExceeded = false;
15909
+ let parsedAst;
15910
+ try {
15911
+ if (adapter.parseAst && job.wantsAst) {
15912
+ hooks.onProgress?.({
15913
+ phase: "rules",
15914
+ done: scanned,
15915
+ total: testFiles.length,
15916
+ detail: job.path
15917
+ });
15918
+ parsedAst = await adapter.parseAst({
15919
+ path: job.path,
15920
+ text: job.text
15921
+ });
15192
15922
  }
15193
- }
15194
- const fileForRules = parsed ? {
15195
- ...parsedFile,
15196
- ast: parsed.ast
15197
- } : parsedFile;
15198
- adapter.runRules(activeRules, fileForRules, (f, ruleId, category) => {
15199
- if (!isValidFindingRecord(f)) {
15200
- fileRuleFailed = true;
15201
- onRuleCrash?.(ruleId, relPath, /* @__PURE__ */ new Error(`malformed finding record rejected (severity/line/message must be present, severity ∈ error|warning|info): ${JSON.stringify(f)}`));
15202
- return;
15923
+ const actualMode = parsedAst ? "ast" : "regex";
15924
+ if (job.wantsAst && actualMode === "regex") {
15925
+ parseFallback = true;
15926
+ parseFallbacks++;
15927
+ cacheKey = fileCacheKey(rulesDigest, job.text, identity(actualMode));
15928
+ const fallbackFindings = cache.lookup(cacheKey);
15929
+ if (fallbackFindings) return {
15930
+ path: job.path,
15931
+ status: "CACHE_HIT",
15932
+ findings: [...fallbackFindings],
15933
+ parseFallback: true
15934
+ };
15203
15935
  }
15204
- findings.push({
15205
- ...f,
15206
- ruleId,
15207
- category
15936
+ const fileForRules = parsedAst ? {
15937
+ path: job.path,
15938
+ text: job.text,
15939
+ ast: parsedAst.ast
15940
+ } : {
15941
+ path: job.path,
15942
+ text: job.text
15943
+ };
15944
+ const produced = [];
15945
+ adapter.runRules(activeRules, fileForRules, (f, ruleId, category) => {
15946
+ if (!isValidFindingRecord(f)) {
15947
+ fileRuleFailed = true;
15948
+ onRuleCrash?.(ruleId, job.path, /* @__PURE__ */ new Error(`malformed finding record rejected (severity/line/message must be present, severity ∈ error|warning|info): ${JSON.stringify(f)}`));
15949
+ return;
15950
+ }
15951
+ produced.push({
15952
+ ...f,
15953
+ ruleId,
15954
+ category
15955
+ });
15956
+ }, (ruleId, error) => {
15957
+ fileRuleFailed = true;
15958
+ onRuleCrash?.(ruleId, job.path, error);
15959
+ }, {
15960
+ deadline: Math.min(deadline, Date.now() + LIMITS$1.maxFileAnalysisMs),
15961
+ onExceeded: () => {
15962
+ rulesPartial = true;
15963
+ skippedFiles++;
15964
+ truncationReasons.add("file-budget");
15965
+ fileBudgetExceeded = true;
15966
+ }
15208
15967
  });
15209
- }, (ruleId, error) => {
15210
- fileRuleFailed = true;
15211
- onRuleCrash?.(ruleId, relPath, error);
15212
- }, {
15213
- deadline: Math.min(deadline, Date.now() + LIMITS$1.maxFileAnalysisMs),
15214
- onExceeded: () => {
15215
- rulesPartial = true;
15216
- skippedFiles++;
15217
- truncationReasons.add("file-budget");
15218
- fileBudgetExceeded = true;
15219
- }
15220
- });
15221
- if (!fileRuleFailed && !fileBudgetExceeded) analyzed++;
15222
- if (!fileRuleFailed) cache.store(cacheKey, findings.slice(findingsStart), fileBudgetExceeded);
15223
- } catch {
15224
- if (wantsAst) parseFallbacks++;
15225
- skippedFiles++;
15226
- parseFailed++;
15227
- } finally {
15228
- parsed?.dispose();
15968
+ if (!fileRuleFailed && !fileBudgetExceeded) analyzed++;
15969
+ if (!fileRuleFailed) cache.store(cacheKey, produced, fileBudgetExceeded);
15970
+ return {
15971
+ path: job.path,
15972
+ status: "OK",
15973
+ findings: produced,
15974
+ parseFallback
15975
+ };
15976
+ } catch {
15977
+ if (job.wantsAst) parseFallbacks++;
15978
+ skippedFiles++;
15979
+ parseFailed++;
15980
+ return {
15981
+ path: job.path,
15982
+ status: "FAILED",
15983
+ findings: [],
15984
+ parseFallback
15985
+ };
15986
+ } finally {
15987
+ parsedAst?.dispose();
15988
+ }
15989
+ });
15990
+ const outcome = (await executeFiles([{
15991
+ path: relPath,
15992
+ text,
15993
+ wantsAst,
15994
+ ...cachedFindings !== void 0 ? { cacheHit: cachedFindings } : {}
15995
+ }], executor))[0];
15996
+ if (outcome?.status === "CACHE_HIT" || outcome?.status === "OK") {
15997
+ for (const f of outcome.findings) findings.push(f);
15998
+ if (cachedFindings !== void 0 && outcome.status === "CACHE_HIT") analyzed++;
15229
15999
  }
15230
16000
  }
15231
16001
  return {
@@ -15236,7 +16006,8 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
15236
16006
  parseFailed,
15237
16007
  parseFallbacks,
15238
16008
  scanned,
15239
- analyzed
16009
+ analyzed,
16010
+ astFallbackFiles
15240
16011
  };
15241
16012
  }
15242
16013
  /**
@@ -15316,12 +16087,16 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
15316
16087
  const runtimeReportPath = discoveredReport?.path;
15317
16088
  const runtimeIncomplete = discoveredReport !== void 0 && discoveredReport.report.analysisComplete !== true;
15318
16089
  let forensicVerdicts;
16090
+ let evidenceRecords = [];
15319
16091
  if (discoveredReport && discoveredReport.report.analysisComplete === true) try {
15320
- buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
16092
+ evidenceRecords = buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
15321
16093
  stampRuntimeCorroboration(findings, discoveredReport.report, workspace.root);
15322
16094
  forensicVerdicts = summarizeForensicVerdicts(discoveredReport.report);
15323
- } catch {}
16095
+ } catch {
16096
+ evidenceRecords = [];
16097
+ }
15324
16098
  return {
16099
+ evidenceRecords,
15325
16100
  testDeclarationCount,
15326
16101
  scopeInfo,
15327
16102
  suppressionCount,
@@ -15460,10 +16235,14 @@ function assembleScanResult(o) {
15460
16235
  scopeUnrecognized: o.scopeUnrecognized,
15461
16236
  parseFailed: o.parseFailed,
15462
16237
  parseFallbacks: o.parseFallbacks ?? 0,
16238
+ ...o.astFallbackFiles !== void 0 ? { astFallbackFiles: o.astFallbackFiles } : {},
16239
+ ...o.degradations !== void 0 ? { degradations: o.degradations } : {},
15463
16240
  ...o.scopeInfo.degraded !== void 0 ? { scopeDegraded: o.scopeInfo.degraded } : {},
15464
16241
  ...o.runtimeIncomplete !== void 0 ? { runtimeIncomplete: o.runtimeIncomplete } : {},
15465
16242
  identityIncomplete
15466
16243
  });
16244
+ const repository = bindRepository(o.scanRoot.root);
16245
+ const candidate = readCandidateBinding(o.scanRoot.root);
15467
16246
  const runIdentity = buildRunIdentity({
15468
16247
  files: inputSnapshot,
15469
16248
  rules: [...o.REVISION_BY_RULE_ID.entries()].map(([id, detectorRevision]) => ({
@@ -15476,9 +16255,17 @@ function assembleScanResult(o) {
15476
16255
  trustModelVersion: TRUST_MODEL_VERSION,
15477
16256
  scoringModelVersion: SCORING_MODEL_VERSION,
15478
16257
  frameworkSupportMatrixVersion: FRAMEWORK_SUPPORT_MATRIX_VERSION,
15479
- evidenceSchemaVersions: [1]
16258
+ evidenceSchemaVersions: [1],
16259
+ ...repository,
16260
+ ...candidate !== null ? { candidate } : {}
16261
+ });
16262
+ const evidenceGraph = buildEvidenceGraph({
16263
+ runId: runIdentity,
16264
+ ...candidate !== null ? { candidate: {
16265
+ manifestId: candidate.manifestId,
16266
+ candidateSha: candidate.candidateSha
16267
+ } } : {}
15480
16268
  });
15481
- const evidenceGraph = buildEvidenceGraph({ runId: runIdentity });
15482
16269
  const hasTests = o.testFileCount > 0 && o.testDeclarationCount > 0;
15483
16270
  const suiteInvalidatedBy = [...new Set(o.findings.filter((f) => SUITE_INVALIDATING_RULE_IDS.has(f.ruleId)).map((f) => f.ruleId))].sort();
15484
16271
  const finalScore = hasTests ? completion.partial && scopeAdjustedTotal >= 100 ? 99 : scopeAdjustedTotal : null;
@@ -15488,6 +16275,11 @@ function assembleScanResult(o) {
15488
16275
  scopeIntegrity,
15489
16276
  runIdentity,
15490
16277
  evidenceGraph,
16278
+ evidence: {
16279
+ records: o.evidenceRecords ?? [],
16280
+ counts: countEvidence(o.evidenceRecords ?? []),
16281
+ artifact: o.runtimeReportPath ?? null
16282
+ },
15491
16283
  score: finalScore,
15492
16284
  ...hasTests ? {} : { reason: "no-tests-found" },
15493
16285
  frameworks: o.frameworks.frameworks,
@@ -15563,13 +16355,15 @@ function assembleScanResult(o) {
15563
16355
  */
15564
16356
  async function runScan(args, hooks = {}) {
15565
16357
  const started = Date.now();
16358
+ const degradationWindow = beginDegradationWindow();
15566
16359
  const requestedDuration = Number.isFinite(args.maxDurationMs) ? args.maxDurationMs : DEFAULT_MAX_DURATION_MS;
15567
16360
  const deadline = started + Math.min(Math.max(1, requestedDuration), MAX_DURATION_MS);
15568
16361
  const discovered = discoverWorkspace(args.target);
15569
16362
  const targetAbs = resolve(args.target);
15570
16363
  const scanRoot = discovered && discovered.root !== targetAbs && targetAbs.startsWith(discovered.root + sep) ? {
15571
16364
  ...discovered,
15572
- root: targetAbs
16365
+ root: targetAbs,
16366
+ packageJson: {}
15573
16367
  } : discovered ?? fallbackWorkspace(targetAbs);
15574
16368
  const workspace = scanRoot;
15575
16369
  if (args.verbose) hooks.onConfigWarning?.(`verbose: scan root (config/ignore anchor): ${workspace.root}`);
@@ -15654,6 +16448,7 @@ async function runScan(args, hooks = {}) {
15654
16448
  rulesPartial = analysis.rulesPartial;
15655
16449
  parseFailed = analysis.parseFailed;
15656
16450
  const parseFallbacks = analysis.parseFallbacks;
16451
+ const astFallbackFiles = analysis.astFallbackFiles;
15657
16452
  const scanned = analysis.scanned;
15658
16453
  const analyzed = analysis.analyzed;
15659
16454
  const postScan = applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, declarationsByFile, testDeclarationCount, tierByRuleId, REVISION_BY_RULE_ID);
@@ -15672,6 +16467,8 @@ async function runScan(args, hooks = {}) {
15672
16467
  scopeUnrecognized,
15673
16468
  parseFailed,
15674
16469
  parseFallbacks,
16470
+ astFallbackFiles,
16471
+ degradations: summarizeDegradations(degradationsSince(degradationWindow)),
15675
16472
  scanned,
15676
16473
  analyzed,
15677
16474
  testFiles,
@@ -15699,4 +16496,6 @@ async function runScan(args, hooks = {}) {
15699
16496
  return result;
15700
16497
  }
15701
16498
  //#endregion
15702
- export { correlateFindings as $, plainContext as A, parseJsonFile as At, shouldUseAscii as B, isValidCategory as Bt, runForensics as C, globToRegExp as Ct, nextStep as D, loadConfig as Dt, buildFooter as E, isSuppressionActive as Et, padTo as F, QA_IMPACT_LABELS as Ft, EVIDENCE as G, deriveScoreState as H, palette as I, RULE_CATEGORIES as It, atomicTempPath as J, TINT as K, sanitizeData as L, SEVERITY_ORDER as Lt, severityIcon as M, buildEvidenceGraph as Mt, box as N, MEASURED_FP as Nt, okIcon as O, readFileBounded as Ot, measure as P, DEDUCTIONS as Pt, getReachableFiles as Q, scoreGauge as R, deriveEvidenceLevel as Rt, renderSuppressions as S, createIgnoreMatcher as St, FLAKE_GLYPH as T, ConfigValidationError as Tt, headlineFor as U, wrapText as V, BADGE_BAND as W, writeFileAtomic as X, sweepStaleTempFiles as Y, buildDependencyGraph as Z, summarizeForensicVerdicts as _, computeCodeText as _t, applyPostScanProcessing as a, massCeiling as at, loadLocalRules as b, DEFAULT_IGNORE_MATCHER as bt, discoverAndParseRuntimeReport as c, getRule as ct, isValidFindingRecord as d, SCAN_ADAPTERS as dt, classifyProvenance as et, pathMatchesGlob as f, SEARCHED_FOR as ft, selectAdapter as g, parseYamlGuarded as gt, scan_pipeline_exports as h, parseWorkflow as ht, SUITE_INVALIDATING_RULE_IDS as i, deductionFor as it, sectionHeader as j, ENGINE_VERSION as jt, panel as k, isRecord as kt, discoverTestFilesPhase as l, resolveGitPath as lt, runScan as m, parseAzurePipeline as mt, KNOWN_RULE_IDS as n, capForTier as nt, assembleScanResult as o, RETIRED_RULE_IDS as ot, runFileAnalysisPhase as p, isAzurePipelineFixture as pt, TRUST as q, OVERLAP_META_BY_RULE_ID as r, computeDimensions as rt, buildUniversalRules as s, RULES as st, EVIDENCE_OVERRIDES as t, computeAgenticProfile as tt, fallbackWorkspace as u, runGit as ut, pluginsGateOpen as v, parseTsFile as vt, sanitizeErrorText as w, isLintFixtureDir as wt, loadSuppressions as x, LIMITS$1 as xt, renderGateNotice as y, detectFrameworks as yt, shouldColorize as z, isAdvisoryFinding as zt };
16499
+ export { BADGE_BAND as $, plainContext as A, ConfigValidationError as At, shouldUseAscii as B, MEASURED_FP as Bt, runForensics as C, parseTsFile as Ct, nextStep as D, createIgnoreMatcher as Dt, buildFooter as E, LIMITS$1 as Et, padTo as F, isRecord as Ft, countOrNull as G, DEDUCTIONS as Gt, atomicTempPath as H, degradationsSince as Ht, palette as I, parseJsonFile as It, evidenceTag as J, SEVERITY_ORDER as Jt, deriveScoreState as K, QA_IMPACT_LABELS as Kt, sanitizeData as L, resolveGitPath as Lt, severityIcon as M, loadConfig as Mt, box as N, readFileBounded as Nt, okIcon as O, globToRegExp as Ot, measure as P, ENGINE_VERSION as Pt, TRUST_RUNGS as Q, isValidCategory as Qt, scoreGauge as R, runGit as Rt, renderSuppressions as S, computeCodeText as St, FLAKE_GLYPH as T, DEFAULT_IGNORE_MATCHER as Tt, sweepStaleTempFiles as U, recordDegradation as Ut, wrapText as V, beginDegradationWindow as Vt, writeFileAtomic as W, summarizeDegradations as Wt, testsAnalyzedCell as X, deriveEvidenceLevel as Xt, headlineFor as Y, TRUST_ORDER as Yt, verdictFor as Z, isAdvisoryFinding as Zt, summarizeForensicVerdicts as _, SEARCHED_FOR as _t, applyPostScanProcessing as a, correlateFindings as at, loadLocalRules as b, parseWorkflow as bt, discoverAndParseRuntimeReport as c, capForTier as ct, isValidFindingRecord as d, massCeiling as dt, SCORE as et, pathMatchesGlob as f, compareCodePoints as ft, selectAdapter as g, SCAN_ADAPTERS as gt, scan_pipeline_exports as h, getRule as ht, SUITE_INVALIDATING_RULE_IDS as i, getReachableFiles as it, sectionHeader as j, isSuppressionActive as jt, panel as k, isLintFixtureDir as kt, discoverTestFilesPhase as l, computeDimensions as lt, runScan as m, RULES as mt, KNOWN_RULE_IDS as n, TINT as nt, assembleScanResult as o, classifyProvenance as ot, runFileAnalysisPhase as p, RETIRED_RULE_IDS as pt, evidenceLevelOf as q, RULE_CATEGORIES 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, isAzurePipelineFixture as vt, sanitizeErrorText as w, detectFrameworks as wt, loadSuppressions as x, parseYamlGuarded as xt, renderGateNotice as y, parseAzurePipeline as yt, shouldColorize as z, buildEvidenceGraph as zt };
16500
+
16501
+ //# sourceMappingURL=scan-pipeline-DT8DWAMl.mjs.map