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.
@@ -2,14 +2,14 @@ import { createRequire } from "node:module";
2
2
  import { createInterface } from "node:readline";
3
3
  import { Buffer as Buffer$1 } from "node:buffer";
4
4
  import { createHash, randomBytes } from "node:crypto";
5
- import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
5
+ import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
6
6
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
+ 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 { fileURLToPath, pathToFileURL } from "node:url";
10
11
  import { Language, Parser } from "web-tree-sitter";
11
12
  import { parse } from "yaml";
12
- import { execFileSync } from "node:child_process";
13
13
  import { inflateRawSync } from "node:zlib";
14
14
  //#region src/types.ts
15
15
  /** Severity ladder. Order matters for sorting and gating. */
@@ -19,6 +19,32 @@ const SEVERITY_ORDER = [
19
19
  "info"
20
20
  ];
21
21
  /**
22
+ * Trust levels (Verification Trust Evolution Plan §16): the OVERALL
23
+ * trust a consumer can place in one finding, combining the static
24
+ * evidence ladder (E0–E2) with RUNTIME corroboration from a real run
25
+ * report. Exposed honestly, never overclaimed:
26
+ * L0 — observation only (E0, no runtime evidence).
27
+ * L1 — heuristic static evidence (E1), no runtime evidence.
28
+ * L2 — deterministic static evidence (E2), no runtime evidence.
29
+ * L3 — RUNTIME: the file containing this finding appeared in a real
30
+ * run report (tests in that file executed).
31
+ * L4 — RUNTIME: the specific test containing this finding was
32
+ * identified in the report and executed (its outcome is known).
33
+ * L5 — RUNTIME: the run verdict directly corroborates the DEFECT
34
+ * class (e.g. a flake-risk finding whose test actually flaked,
35
+ * retried, or timed out in the report).
36
+ * INVARIANT (structurally enforced): L3–L5 require runtime
37
+ * corroboration — a static-only finding can never claim L4/L5.
38
+ */
39
+ const TRUST_ORDER = [
40
+ "L0",
41
+ "L1",
42
+ "L2",
43
+ "L3",
44
+ "L4",
45
+ "L5"
46
+ ];
47
+ /**
22
48
  * Honest default evidence level for a finding (Honesty Core Phase 1).
23
49
  * Derivation is deterministic and conservative:
24
50
  * observation → E0 (never proof)
@@ -59,6 +85,7 @@ const DEDUCTIONS = {
59
85
  //#region src/engine/completion.ts
60
86
  function deriveCompletion(input) {
61
87
  const truncationReasons = [...new Set(input.truncationReasons)].sort();
88
+ const degradations = [...input.degradations ?? []].sort((a, b) => a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0);
62
89
  const reasons = /* @__PURE__ */ new Set();
63
90
  if (input.discoveryTruncated) reasons.add("discovery-truncated");
64
91
  if (input.rulesPartial) reasons.add("rules-partial");
@@ -68,14 +95,16 @@ function deriveCompletion(input) {
68
95
  if (input.scopeUnrecognized > 0) reasons.add(`scope-unrecognized:${input.scopeUnrecognized}`);
69
96
  if (input.parseFailed > 0) reasons.add(`parse-failed:${input.parseFailed}`);
70
97
  if (input.parseFallbacks && input.parseFallbacks > 0) reasons.add(`parse-fallbacks:${input.parseFallbacks}`);
98
+ if (input.astFallbackFiles && input.astFallbackFiles > 0) reasons.add(`ast-budget-fallback-files:${input.astFallbackFiles}`);
71
99
  if (input.scopeDegraded) reasons.add(`scope-degraded:${input.scopeDegraded}`);
72
100
  if (input.runtimeIncomplete) reasons.add("runtime-incomplete");
73
101
  if (input.identityIncomplete) reasons.add("identity-incomplete");
74
102
  for (const reason of truncationReasons) reasons.add(`truncated:${reason}`);
103
+ for (const entry of degradations) reasons.add(`degraded:${entry.reason}:${entry.count}`);
75
104
  const discoveryPartial = input.discoveryTruncated || input.scopeIgnored > 0 || input.scopeUnrecognized > 0 || input.scopeDegraded !== void 0;
76
105
  const rulesPartial = input.rulesPartial || input.rulesCrashed > 0 || input.parseFailed > 0;
77
106
  return {
78
- partial: discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0,
107
+ partial: discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0 || degradations.length > 0,
79
108
  analysisStatus: {
80
109
  discovery: discoveryPartial ? "partial" : "complete",
81
110
  rules: rulesPartial ? "partial" : "complete",
@@ -83,11 +112,49 @@ function deriveCompletion(input) {
83
112
  rulesCrashed: input.rulesCrashed,
84
113
  parseFallbacks: input.parseFallbacks ?? 0,
85
114
  ...truncationReasons.length > 0 ? { truncationReasons } : {},
115
+ ...degradations.length > 0 ? { degradations } : {},
86
116
  reasons: [...reasons].sort()
87
117
  }
88
118
  };
89
119
  }
90
120
  //#endregion
121
+ //#region src/engine/degradation-ledger.ts
122
+ /**
123
+ * Append-only. Records are never edited and never removed by a recording
124
+ * site; a reader takes a window, and the window is defined by index, so
125
+ * "delete my entry" is not an operation this module offers.
126
+ */
127
+ const records = [];
128
+ /** Record one capability loss. `count` is for a site that fails N at once. */
129
+ function recordDegradation(reason, count = 1) {
130
+ if (!Number.isInteger(count) || count < 1) return;
131
+ for (let index = 0; index < count; index++) records.push({ reason });
132
+ }
133
+ /**
134
+ * Mark where this reader's window opens. Everything appended from here on is
135
+ * what the reader will see; see the file header for what a concurrent scan
136
+ * does to that boundary.
137
+ */
138
+ function beginDegradationWindow() {
139
+ return records.length;
140
+ }
141
+ /** The records appended since `marker`, without consuming them. */
142
+ function degradationsSince(marker) {
143
+ return records.slice(marker);
144
+ }
145
+ /**
146
+ * Per-reason counts for a window, sorted by reason so the output is
147
+ * byte-deterministic for the same set of degradations.
148
+ */
149
+ function summarizeDegradations(window) {
150
+ const counts = /* @__PURE__ */ new Map();
151
+ for (const record of window) counts.set(record.reason, (counts.get(record.reason) ?? 0) + 1);
152
+ return [...counts.entries()].map(([reason, count]) => ({
153
+ reason,
154
+ count
155
+ })).sort((a, b) => a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0);
156
+ }
157
+ //#endregion
91
158
  //#region src/engine/runtime-corroboration.ts
92
159
  /**
93
160
  * Stamp runtime corroboration + trust levels onto findings (mutates in
@@ -701,21 +768,13 @@ const MEASURED_FP = {
701
768
  * empty scan is an observation, not a proof).
702
769
  */
703
770
  function summaryTrustLevel(findings) {
704
- const order = [
705
- "L0",
706
- "L1",
707
- "L2",
708
- "L3",
709
- "L4",
710
- "L5"
711
- ];
712
771
  let best = 0;
713
772
  for (const f of findings) {
714
773
  const t = f.trustLevel ?? "L2";
715
- const idx = order.indexOf(t);
774
+ const idx = TRUST_ORDER.indexOf(t);
716
775
  if (idx > best) best = idx;
717
776
  }
718
- return order[best];
777
+ return TRUST_ORDER[best];
719
778
  }
720
779
  /**
721
780
  * A finding's trust rung (0–5) on the canonical ladder. Corroborated
@@ -726,14 +785,7 @@ function summaryTrustLevel(findings) {
726
785
  */
727
786
  function trustRung(f) {
728
787
  const t = f.trustLevel ?? deriveTrustLevel(f);
729
- return [
730
- "L0",
731
- "L1",
732
- "L2",
733
- "L3",
734
- "L4",
735
- "L5"
736
- ].indexOf(t);
788
+ return TRUST_ORDER.indexOf(t);
737
789
  }
738
790
  /**
739
791
  * Hard incompleteness ceilings (plan §6: partial/crash/truncation;
@@ -870,6 +922,17 @@ function buildRunIdentity(input) {
870
922
  engineVersion: input.engineVersion
871
923
  };
872
924
  if (input.reportDigest) verdictInputs.reportDigest = input.reportDigest;
925
+ if (input.commit !== void 0) verdictInputs.commit = input.commit;
926
+ if (input.tree !== void 0) verdictInputs.tree = input.tree;
927
+ if (input.lockfile !== void 0) verdictInputs.lockfile = input.lockfile;
928
+ if (input.candidate !== void 0) verdictInputs.candidate = {
929
+ manifestId: input.candidate.manifestId,
930
+ state: input.candidate.state,
931
+ candidateSha: input.candidate.candidateSha,
932
+ baseSha: input.candidate.baseSha,
933
+ packageSha256: input.candidate.packageSha256,
934
+ lockfileSha256: input.candidate.lockfileSha256
935
+ };
873
936
  if (input.trustModelVersion !== void 0) verdictInputs.trustModelVersion = input.trustModelVersion;
874
937
  if (input.scoringModelVersion !== void 0) verdictInputs.scoringModelVersion = input.scoringModelVersion;
875
938
  if (input.frameworkSupportMatrixVersion !== void 0) verdictInputs.frameworkSupportMatrixVersion = input.frameworkSupportMatrixVersion;
@@ -877,15 +940,31 @@ function buildRunIdentity(input) {
877
940
  if (input.suppressionFingerprint !== void 0) verdictInputs.suppressionFingerprint = input.suppressionFingerprint;
878
941
  if (input.policyFingerprint !== void 0) verdictInputs.policyFingerprint = input.policyFingerprint;
879
942
  if (input.historicalEvidenceFingerprint !== void 0) verdictInputs.historicalEvidenceFingerprint = input.historicalEvidenceFingerprint;
943
+ const scanId = sha256$1(canonical(verdictInputs));
944
+ const boundLinks = [
945
+ "input",
946
+ "rules",
947
+ "config",
948
+ "engine"
949
+ ];
950
+ if (input.commit !== void 0) boundLinks.push("commit");
951
+ if (input.tree !== void 0) boundLinks.push("tree");
952
+ if (input.lockfile !== void 0) boundLinks.push("lockfile");
953
+ if (input.candidate !== void 0) boundLinks.push("candidate");
880
954
  return {
881
- scanId: sha256$1(canonical(verdictInputs)),
955
+ scanId,
882
956
  inputFingerprint,
883
957
  rulesDigest,
884
958
  configFingerprint,
885
959
  engineVersion: input.engineVersion,
960
+ boundLinks,
886
961
  ...input.trustModelVersion !== void 0 ? { trustModelVersion: input.trustModelVersion } : {},
887
962
  ...input.scoringModelVersion !== void 0 ? { scoringModelVersion: input.scoringModelVersion } : {},
888
- ...input.frameworkSupportMatrixVersion !== void 0 ? { frameworkSupportMatrixVersion: input.frameworkSupportMatrixVersion } : {}
963
+ ...input.frameworkSupportMatrixVersion !== void 0 ? { frameworkSupportMatrixVersion: input.frameworkSupportMatrixVersion } : {},
964
+ ...input.commit !== void 0 ? { commit: input.commit } : {},
965
+ ...input.tree !== void 0 ? { tree: input.tree } : {},
966
+ ...input.lockfile !== void 0 ? { lockfile: input.lockfile } : {},
967
+ ...input.candidate !== void 0 ? { candidate: input.candidate } : {}
889
968
  };
890
969
  }
891
970
  /**
@@ -926,9 +1005,353 @@ function buildEvidenceGraph(parts) {
926
1005
  ...parts.reproduction !== void 0 ? { ref: parts.reproduction } : {}
927
1006
  }
928
1007
  ],
929
- ...parts.runId !== void 0 ? { runId: parts.runId } : {}
1008
+ ...parts.runId !== void 0 ? { runId: parts.runId } : {},
1009
+ ...parts.candidate !== void 0 ? { candidate: {
1010
+ manifestId: parts.candidate.manifestId,
1011
+ candidateSha: parts.candidate.candidateSha
1012
+ } } : {}
1013
+ };
1014
+ }
1015
+ //#endregion
1016
+ //#region src/engine/file-executor.ts
1017
+ function cacheHitOutcome(job) {
1018
+ return {
1019
+ path: job.path,
1020
+ status: "CACHE_HIT",
1021
+ findings: [...job.cacheHit ?? []],
1022
+ parseFallback: false
1023
+ };
1024
+ }
1025
+ /**
1026
+ * Drive a list of jobs through an executor and return outcomes in INPUT order.
1027
+ *
1028
+ * This is the function the pipeline calls. It is executor-agnostic on purpose:
1029
+ * swapping in a pooled executor must not require touching the caller, and the
1030
+ * ordering guarantee has to live here or it would live in each executor and
1031
+ * drift between them.
1032
+ */
1033
+ async function executeFiles(jobs, executor, context = {}) {
1034
+ const outcomes = new Array(jobs.length);
1035
+ if (executor.concurrency <= 1 || jobs.length <= 1) {
1036
+ for (let i = 0; i < jobs.length; i++) {
1037
+ const job = jobs[i];
1038
+ context.onProgress?.({
1039
+ done: i,
1040
+ total: jobs.length,
1041
+ path: job.path
1042
+ });
1043
+ outcomes[i] = await runOne(job, executor, context);
1044
+ }
1045
+ context.onProgress?.({
1046
+ done: jobs.length,
1047
+ total: jobs.length,
1048
+ path: jobs[jobs.length - 1]?.path ?? ""
1049
+ });
1050
+ return outcomes;
1051
+ }
1052
+ let next = 0;
1053
+ let completed = 0;
1054
+ const inFlight = /* @__PURE__ */ new Set();
1055
+ const limit = Math.max(1, Math.floor(executor.concurrency));
1056
+ const worker = async () => {
1057
+ for (;;) {
1058
+ const index = next++;
1059
+ if (index >= jobs.length) return;
1060
+ const job = jobs[index];
1061
+ outcomes[index] = await runOne(job, executor, context);
1062
+ completed++;
1063
+ context.onProgress?.({
1064
+ done: completed,
1065
+ total: jobs.length,
1066
+ path: job.path
1067
+ });
1068
+ }
1069
+ };
1070
+ for (let i = 0; i < Math.min(limit, jobs.length); i++) {
1071
+ const promise = worker().then(() => void 0);
1072
+ inFlight.add(promise);
1073
+ }
1074
+ await Promise.all(inFlight);
1075
+ return outcomes;
1076
+ }
1077
+ async function runOne(job, executor, context) {
1078
+ if (job.cacheHit !== void 0) return cacheHitOutcome(job);
1079
+ try {
1080
+ return await executor.execute(job, context);
1081
+ } catch (error) {
1082
+ return {
1083
+ path: job.path,
1084
+ status: "FAILED",
1085
+ findings: [],
1086
+ parseFallback: false,
1087
+ error: error instanceof Error ? error : new Error(String(error))
1088
+ };
1089
+ }
1090
+ }
1091
+ /**
1092
+ * The strategy the pipeline uses today.
1093
+ *
1094
+ * `runOne` is supplied by the pipeline because parsing and rule execution
1095
+ * need the adapter and the active rule set, which are pipeline concerns. The
1096
+ * executor's job is the BOUNDARY and the bookkeeping, not the analysis — so
1097
+ * this factory exists to bind those two without the executor importing the
1098
+ * pipeline (which would be a cycle).
1099
+ */
1100
+ function createExecutor(name, concurrency, runOne) {
1101
+ return {
1102
+ name,
1103
+ concurrency,
1104
+ execute: runOne
1105
+ };
1106
+ }
1107
+ //#endregion
1108
+ //#region src/scope/git-resolve.ts
1109
+ /**
1110
+ * Absolute-path git resolution (audit S1).
1111
+ *
1112
+ * `execFileSync("git", …)` resolves through PATH — including the CWD on
1113
+ * Windows (CreateProcess searches the current directory before PATH for
1114
+ * extension-less names depending on NoDefaultCurrentDirectoryInExePath).
1115
+ * Scanning an UNTRUSTED repo therefore let a checked-in `git.exe` (or
1116
+ * `git.bat`/`git.cmd`) hijack Mjölnir's own git invocations: the attacker
1117
+ * controls the diff output, the merge-base decision, and
1118
+ * `--scope changed`'s "what is new" answer — the exact data CI gates on.
1119
+ *
1120
+ * The fix: resolve git ONCE per process to an ABSOLUTE path by walking
1121
+ * the PATH directly (never consulting the CWD), verify the candidate is
1122
+ * an existing file, and pass that path to execFileSync. Resolution is
1123
+ * memoized; a failure to find a real git anywhere on PATH is recorded
1124
+ * and callers degrade without invoking a bare name.
1125
+ */
1126
+ let resolvedGit = void 0;
1127
+ /**
1128
+ * The absolute path of the git binary Mjölnir will invoke, or null when
1129
+ * PATH carries no executable `git` at all. Callers must degrade without
1130
+ * invoking a bare name.
1131
+ */
1132
+ function resolveGitPath() {
1133
+ if (resolvedGit !== void 0) return resolvedGit;
1134
+ const candidates = [process.platform === "win32" ? "git.exe" : "git"];
1135
+ if (process.platform === "win32") {
1136
+ const pathExt = process.env["PATHEXT"]?.split(";").filter(Boolean) ?? [];
1137
+ for (const ext of pathExt) candidates.push(`git${ext.toLowerCase()}`);
1138
+ }
1139
+ const pathValue = process.env["PATH"] ?? "";
1140
+ for (const dir of pathValue.split(delimiter)) {
1141
+ if (dir === "") continue;
1142
+ if (!isAbsolute(dir)) continue;
1143
+ for (const candidate of candidates) {
1144
+ const full = join(dir, candidate);
1145
+ try {
1146
+ if (existsSync(full)) {
1147
+ resolvedGit = full;
1148
+ return resolvedGit;
1149
+ }
1150
+ } catch {}
1151
+ }
1152
+ }
1153
+ `${pathValue.length}`;
1154
+ resolvedGit = null;
1155
+ return resolvedGit;
1156
+ }
1157
+ /**
1158
+ * Run git with the S1-resolved absolute binary path. Returns stdout, or
1159
+ * null when git is missing or the command fails — the same contract as
1160
+ * the previous inline `git()` helpers, with the hijack surface closed.
1161
+ */
1162
+ function runGit(root, args) {
1163
+ const exe = resolveGitPath();
1164
+ if (!exe) return null;
1165
+ try {
1166
+ return execFileSync(exe, [
1167
+ "-C",
1168
+ root,
1169
+ ...args
1170
+ ], {
1171
+ encoding: "utf8",
1172
+ stdio: [
1173
+ "ignore",
1174
+ "pipe",
1175
+ "ignore"
1176
+ ],
1177
+ timeout: 15e3
1178
+ });
1179
+ } catch {
1180
+ return null;
1181
+ }
1182
+ }
1183
+ //#endregion
1184
+ //#region src/lib/safe-json.ts
1185
+ /**
1186
+ * Reusable safe-JSON parse-and-validate helper.
1187
+ *
1188
+ * Replaces the `JSON.parse(text) as T` pattern with a two-step process:
1189
+ * 1. Try/catch around JSON.parse (descriptive error on bad JSON)
1190
+ * 2. Runtime validation of the parsed shape (caller-supplied predicate)
1191
+ *
1192
+ * The `validate` predicate is intentionally lightweight — it checks the
1193
+ * expected shape exists rather than exhaustively type-guarding every field.
1194
+ * Internal files (cache, hashes) use `isRecord`; user-facing files
1195
+ * (config, plugins) use stricter checks that throw descriptive errors.
1196
+ */
1197
+ const MAX_JSON_BYTES = 16777216;
1198
+ function parseJsonFile(text, source, validate) {
1199
+ if (Buffer.byteLength(text, "utf8") > MAX_JSON_BYTES) throw new Error(`JSON source exceeds the ${MAX_JSON_BYTES}-byte limit: ${source}`);
1200
+ let parsed;
1201
+ try {
1202
+ parsed = JSON.parse(text);
1203
+ } catch (err) {
1204
+ throw new Error(`invalid JSON in ${source}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
1205
+ }
1206
+ if (!validate(parsed)) throw new Error(`unexpected shape in ${source}`, { cause: parsed });
1207
+ return parsed;
1208
+ }
1209
+ /**
1210
+ * Minimal validator: value is a plain object (not null, not array).
1211
+ * Sufficient for internal files (cache, hashes) where downstream
1212
+ * code already checks individual fields.
1213
+ */
1214
+ function isRecord(v) {
1215
+ try {
1216
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1217
+ } catch {
1218
+ return false;
1219
+ }
1220
+ }
1221
+ //#endregion
1222
+ //#region src/engine/candidate-binding.ts
1223
+ /**
1224
+ * Candidate binding (plan V5-010).
1225
+ *
1226
+ * Reads a candidate trust manifest and produces the immutable
1227
+ * `CandidateBinding` that a run identity is bound to. It also computes the
1228
+ * repository facts (commit, tree, lockfile digest) that the identity was
1229
+ * missing until this module existed.
1230
+ *
1231
+ * The law here is ABSENCE over invention. A manifest that is missing,
1232
+ * unreadable, or internally contradictory yields NO binding — not a partial
1233
+ * one. A consumer that can see "not bound" can refuse to trust the run; a
1234
+ * consumer handed a half-filled binding cannot tell what is missing, and will
1235
+ * trust the parts that are there.
1236
+ */
1237
+ const CANDIDATE_MANIFEST_PATH = "candidate-trust-manifest.json";
1238
+ const SHA40 = /^[a-f0-9]{40}$/;
1239
+ const SHA256 = /^[a-f0-9]{64}$/;
1240
+ const STATES = ["WORKING_CANDIDATE", "RELEASE_CANDIDATE"];
1241
+ const AUTHORIZATION = ["NOT_AUTHORIZED", "AUTHORIZED"];
1242
+ function sha256File(path) {
1243
+ try {
1244
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
1245
+ } catch {
1246
+ return;
1247
+ }
1248
+ }
1249
+ /**
1250
+ * One git line, or nothing.
1251
+ *
1252
+ * Audit S1: the binary is the ABSOLUTE path `resolveGitPath()` found by
1253
+ * walking PATH itself, never the bare name. `bindRepository()` runs on every
1254
+ * scan (`scan-pipeline.ts`), and a bare `execFileSync("git", …)` lets a
1255
+ * `git.exe`/`git.bat`/`git.cmd` CHECKED INTO AN UNTRUSTED REPO hijack the
1256
+ * call on Windows, where CreateProcess searches the current directory before
1257
+ * PATH. The attacker then controls the `commit` and `tree` that land in
1258
+ * `runIdentity` and the evidence graph — the provenance a release decision
1259
+ * reads. This was the last bare-name exec left in `src/`; the invariant that
1260
+ * keeps it that way is `tests/contract/no-bare-process-exec.spec.ts`.
1261
+ *
1262
+ * The `-C <root>` shape (not `cwd`) matches every other hardened call site.
1263
+ * With no git on PATH at all, the answer is nothing — a scan of an unpacked
1264
+ * tarball has no commit, and its identity must say so rather than carrying a
1265
+ * placeholder that looks like provenance.
1266
+ */
1267
+ function gitLine(root, args) {
1268
+ const exe = resolveGitPath();
1269
+ if (!exe) return void 0;
1270
+ try {
1271
+ const trimmed = execFileSync(exe, [
1272
+ "-C",
1273
+ root,
1274
+ ...args
1275
+ ], {
1276
+ encoding: "utf8",
1277
+ stdio: [
1278
+ "ignore",
1279
+ "pipe",
1280
+ "ignore"
1281
+ ],
1282
+ windowsHide: true,
1283
+ timeout: 15e3
1284
+ }).trim();
1285
+ return trimmed.length > 0 ? trimmed : void 0;
1286
+ } catch {
1287
+ return;
1288
+ }
1289
+ }
1290
+ /**
1291
+ * The commit and tree the working tree is at. Both absent outside a
1292
+ * repository or with no commits — never substituted with a placeholder.
1293
+ */
1294
+ function bindRepository(root) {
1295
+ const commit = gitLine(root, ["rev-parse", "HEAD"]);
1296
+ const tree = gitLine(root, ["rev-parse", "HEAD^{tree}"]);
1297
+ const lockfile = sha256File(join(root, "package-lock.json"));
1298
+ return {
1299
+ ...commit !== void 0 ? { commit } : {},
1300
+ ...tree !== void 0 ? { tree } : {},
1301
+ ...lockfile !== void 0 ? { lockfile } : {}
1302
+ };
1303
+ }
1304
+ /**
1305
+ * Parse a candidate manifest into a binding, or return null when it cannot
1306
+ * honestly be bound.
1307
+ *
1308
+ * Null is returned for: no manifest, unreadable JSON, a missing or malformed
1309
+ * required field, or a state/authorization combination that cannot legally
1310
+ * exist (a WORKING_CANDIDATE carrying a commit, for example — the same
1311
+ * contradiction the release decision evaluator treats as BLOCKED).
1312
+ */
1313
+ function parseCandidateBinding(raw) {
1314
+ if (!isRecord(raw)) return null;
1315
+ const identity = raw.identity;
1316
+ if (!isRecord(identity)) return null;
1317
+ const state = identity.state;
1318
+ const candidateSha = identity.candidateSha;
1319
+ const baseSha = identity.baseSha;
1320
+ const packageSha256 = identity.packageSha256;
1321
+ const lockfileSha256 = identity.lockfileSha256;
1322
+ if (typeof state !== "string" || !STATES.includes(state)) return null;
1323
+ if (typeof baseSha !== "string" || !SHA40.test(baseSha)) return null;
1324
+ if (typeof packageSha256 !== "string" || !SHA256.test(packageSha256)) return null;
1325
+ if (typeof lockfileSha256 !== "string" || !SHA256.test(lockfileSha256)) return null;
1326
+ if (candidateSha !== null && (typeof candidateSha !== "string" || !SHA40.test(candidateSha))) return null;
1327
+ const manifestId = raw.manifestId;
1328
+ if (typeof manifestId !== "string" || manifestId.length === 0) return null;
1329
+ const owner = typeof raw.owner === "string" ? raw.owner : "UNASSIGNED";
1330
+ const authorization = raw.releaseAuthorizationState;
1331
+ if (typeof authorization !== "string" || !AUTHORIZATION.includes(authorization)) return null;
1332
+ if (state === "WORKING_CANDIDATE") {
1333
+ if (candidateSha !== null) return null;
1334
+ if (authorization !== "NOT_AUTHORIZED") return null;
1335
+ } else if (candidateSha === null) return null;
1336
+ return {
1337
+ manifestId,
1338
+ state,
1339
+ candidateSha,
1340
+ baseSha,
1341
+ packageSha256,
1342
+ lockfileSha256,
1343
+ owner,
1344
+ releaseAuthorizationState: authorization
930
1345
  };
931
1346
  }
1347
+ /** Read and bind the manifest from a checkout, or null. */
1348
+ function readCandidateBinding(root, manifestPath = CANDIDATE_MANIFEST_PATH) {
1349
+ try {
1350
+ return parseCandidateBinding(JSON.parse(readFileSync(join(root, manifestPath), "utf8")));
1351
+ } catch {
1352
+ return null;
1353
+ }
1354
+ }
932
1355
  //#endregion
933
1356
  //#region src/engine/version.ts
934
1357
  /**
@@ -939,7 +1362,7 @@ function buildEvidenceGraph(parts) {
939
1362
  * scripts/sync-sarif-version.cjs and guarded by the version-consistency
940
1363
  * spec. cli.ts re-exports this as CLI_VERSION.
941
1364
  */
942
- const ENGINE_VERSION = "3.0.0";
1365
+ const ENGINE_VERSION = "5.0.0";
943
1366
  //#endregion
944
1367
  //#region src/engine/contract-versions.ts
945
1368
  /**
@@ -1068,9 +1491,12 @@ function discoverWorkspace(rootDir) {
1068
1491
  const root = findProjectRoot(rootDir);
1069
1492
  if (!root) return null;
1070
1493
  let pkg = {};
1071
- try {
1072
- pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
1073
- } catch {}
1494
+ const manifestPath = join(root, "package.json");
1495
+ if (existsSync(manifestPath)) try {
1496
+ pkg = JSON.parse(readFileSync(manifestPath, "utf8"));
1497
+ } catch {
1498
+ recordDegradation("workspace-manifest-unreadable");
1499
+ }
1074
1500
  const rawWorkspaces = pkg["workspaces"];
1075
1501
  let globs = [];
1076
1502
  if (Array.isArray(rawWorkspaces)) globs = rawWorkspaces.filter((w) => typeof w === "string");
@@ -1087,40 +1513,6 @@ function basename$1(p) {
1087
1513
  return parts[parts.length - 1];
1088
1514
  }
1089
1515
  //#endregion
1090
- //#region src/lib/safe-json.ts
1091
- /**
1092
- * Reusable safe-JSON parse-and-validate helper.
1093
- *
1094
- * Replaces the `JSON.parse(text) as T` pattern with a two-step process:
1095
- * 1. Try/catch around JSON.parse (descriptive error on bad JSON)
1096
- * 2. Runtime validation of the parsed shape (caller-supplied predicate)
1097
- *
1098
- * The `validate` predicate is intentionally lightweight — it checks the
1099
- * expected shape exists rather than exhaustively type-guarding every field.
1100
- * Internal files (cache, hashes) use `isRecord`; user-facing files
1101
- * (config, plugins) use stricter checks that throw descriptive errors.
1102
- */
1103
- const MAX_JSON_BYTES = 16777216;
1104
- function parseJsonFile(text, source, validate) {
1105
- if (Buffer.byteLength(text, "utf8") > MAX_JSON_BYTES) throw new Error(`JSON source exceeds the ${MAX_JSON_BYTES}-byte limit: ${source}`);
1106
- let parsed;
1107
- try {
1108
- parsed = JSON.parse(text);
1109
- } catch (err) {
1110
- throw new Error(`invalid JSON in ${source}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
1111
- }
1112
- if (!validate(parsed)) throw new Error(`unexpected shape in ${source}`, { cause: parsed });
1113
- return parsed;
1114
- }
1115
- /**
1116
- * Minimal validator: value is a plain object (not null, not array).
1117
- * Sufficient for internal files (cache, hashes) where downstream
1118
- * code already checks individual fields.
1119
- */
1120
- function isRecord(v) {
1121
- return typeof v === "object" && v !== null && !Array.isArray(v);
1122
- }
1123
- //#endregion
1124
1516
  //#region src/lib/fs-bounded.ts
1125
1517
  function closeQuiet(fd) {
1126
1518
  try {
@@ -1347,20 +1739,15 @@ function validate(cfg, knownRuleIds) {
1347
1739
  return warnings;
1348
1740
  }
1349
1741
  /**
1350
- * Bug-audit QA-2026-08-30 QA-6: the 90-day policy in the README was only
1351
- * applied at WRITE time by the `ignore` command — a hand-written entry
1352
- * without `expires` stayed active forever, silently bypassing the
1353
- * documented window.
1742
+ * Expiry is evaluated only from an explicit ISO `expires` date. A missing
1743
+ * date remains active and is reported as having no expiry. Config-file mtime
1744
+ * is never an expiry anchor.
1354
1745
  *
1355
- * Audit S4 (remediation plan): the config-file MTIME is no longer an
1356
- * expiry anchor. Anchoring the 90-day default at mtime meant ANY edit to
1357
- * mjolnir.config.json — a reformat, an unrelated key, a `touch` — reset
1358
- * the 90-day window for EVERY hand-authored entry: suppressions could be
1359
- * extended indefinitely without touching their own fields. The expiry is
1360
- * now the entry's explicit `expires` date alone; an entry without one
1361
- * stays active and is honestly labeled "(no expiry set)" in the
1362
- * suppressions report. Hand-authored entries should declare `expires` at
1363
- * creation (README §Configuration documents the shape).
1746
+ * `now` defaults to the current instant so existing callers stay correct, but
1747
+ * a verdict function that reads the clock invisibly is a verdict nobody can
1748
+ * test at a boundary. `tests/contract/deterministic-clock.spec.ts` audits
1749
+ * `src/config/**` for a call site that supplies its own `new Date()` instead
1750
+ * of threading one through.
1364
1751
  */
1365
1752
  function isSuppressionActive(ign, now = /* @__PURE__ */ new Date()) {
1366
1753
  if (!ign.expires) return true;
@@ -1638,7 +2025,33 @@ function sharedWalk(options) {
1638
2025
  *
1639
2026
  * When nothing is detectable we report `unknown` and the scanner analyzes
1640
2027
  * all test-looking files — stated honestly in output rather than guessed.
2028
+ *
2029
+ * ## One ID space (plan V5-024)
2030
+ *
2031
+ * This detector used to declare its own `TestFramework` union of three
2032
+ * literals while `src/frameworks/framework-inventory.ts` catalogued fourteen
2033
+ * framework ids. Two vocabularies for one concept is how a support matrix
2034
+ * starts disagreeing with what the tool actually detects: a framework can be
2035
+ * catalogued as OFFICIAL_PARTIAL and never once be emitted by the code that
2036
+ * claims to detect it.
2037
+ *
2038
+ * So the ids here are `FrameworkId`s — the inventory's own vocabulary — and
2039
+ * `DETECTABLE_TEST_FRAMEWORKS` is the subset this detector can actually
2040
+ * resolve from a checkout. That subset is the HONEST limit of detection, and
2041
+ * `detectableVsCatalogued()` reports the remainder rather than letting a
2042
+ * three-item list read as the whole catalog.
2043
+ */
2044
+ /**
2045
+ * The catalogued frameworks whose detection is implemented today.
2046
+ *
2047
+ * Every entry must exist in the inventory — enforced by the parity spec, not
2048
+ * by comment.
1641
2049
  */
2050
+ const DETECTABLE_TEST_FRAMEWORKS = [
2051
+ "jest",
2052
+ "vitest",
2053
+ "playwright"
2054
+ ];
1642
2055
  const CONFIG_FILES = {
1643
2056
  jest: [
1644
2057
  "jest.config.ts",
@@ -1657,7 +2070,7 @@ const CONFIG_FILES = {
1657
2070
  };
1658
2071
  function detectFrameworks(ws) {
1659
2072
  const found = /* @__PURE__ */ new Set();
1660
- for (const fw of Object.keys(CONFIG_FILES)) if (CONFIG_FILES[fw].some((f) => existsSync(join(ws.root, f)))) found.add(fw);
2073
+ for (const fw of DETECTABLE_TEST_FRAMEWORKS) if (CONFIG_FILES[fw].some((f) => existsSync(join(ws.root, f)))) found.add(fw);
1661
2074
  if (!found.has("jest") && ws.packageJson["jest"] !== void 0) found.add("jest");
1662
2075
  const deps = {
1663
2076
  ...ws.packageJson["dependencies"],
@@ -1679,11 +2092,7 @@ function detectFrameworks(ws) {
1679
2092
  };
1680
2093
  }
1681
2094
  return {
1682
- frameworks: [
1683
- "jest",
1684
- "vitest",
1685
- "playwright"
1686
- ].filter((f) => found.has(f)),
2095
+ frameworks: DETECTABLE_TEST_FRAMEWORKS.filter((f) => found.has(f)),
1687
2096
  unknown: false
1688
2097
  };
1689
2098
  }
@@ -1700,6 +2109,13 @@ function detectFrameworks(ws) {
1700
2109
  * byte-identical — a score shift is a regression, not an improvement.
1701
2110
  */
1702
2111
  let project = null;
2112
+ /**
2113
+ * The shared ts-morph project.
2114
+ *
2115
+ * Exported so an adapter's `dispose()` can evict the file it parsed: ts-morph
2116
+ * caches by file path, so without eviction a long scan holds every parsed
2117
+ * SourceFile until the process exits (plan V5-021).
2118
+ */
1703
2119
  function getProject() {
1704
2120
  if (!project) project = new Project({
1705
2121
  useInMemoryFileSystem: true,
@@ -1727,6 +2143,7 @@ function parseTsFile(file) {
1727
2143
  }
1728
2144
  return p.createSourceFile(file.path, file.text);
1729
2145
  } catch {
2146
+ recordDegradation("ast-parse-failed");
1730
2147
  return;
1731
2148
  }
1732
2149
  }
@@ -1782,6 +2199,7 @@ function commentAndStringRanges(ctx) {
1782
2199
  }
1783
2200
  return ranges;
1784
2201
  } catch {
2202
+ recordDegradation("ast-range-scan-failed");
1785
2203
  return [];
1786
2204
  }
1787
2205
  }
@@ -1829,6 +2247,7 @@ function getCodeOnlyText(file) {
1829
2247
  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] = " ";
1830
2248
  return chars.join("");
1831
2249
  } catch {
2250
+ recordDegradation("ast-mask-unavailable");
1832
2251
  return file.text;
1833
2252
  }
1834
2253
  }
@@ -2213,7 +2632,45 @@ function frameworkFilterApplies(rule, file) {
2213
2632
  return rule.frameworks.some((f) => tags.includes(f));
2214
2633
  }
2215
2634
  //#endregion
2216
- //#region src/adapters/typescript.ts
2635
+ //#region src/engine/shared-run-rules.ts
2636
+ /**
2637
+ * Run ONE rule with crash isolation, and return what it produced.
2638
+ *
2639
+ * This is the block that was copy-pasted into all seven language adapters:
2640
+ *
2641
+ * try { for (const f of rule.run(x)) emit(f, rule.id, rule.category); }
2642
+ * catch (error) { onCrash?.(rule.id, error); }
2643
+ *
2644
+ * Extracting it is worth doing for one reason beyond line count. Crash
2645
+ * isolation is the mechanism that lets a third-party or experimental
2646
+ * detector fail without ending a scan, and it is the mechanism the whole
2647
+ * `rulesCrashed` count depends on. Seven copies means seven chances to
2648
+ * write `catch {}` and lose the count, and `tests/contract/
2649
+ * no-uncounted-degradation.spec.ts` can only audit what it can see.
2650
+ *
2651
+ * `null` means the rule CRASHED, which is not the same as an empty array:
2652
+ * an empty array is the rule abstaining, and the adapters skip emission for
2653
+ * both but the distinction is why the caller must not conflate them.
2654
+ *
2655
+ * Deliberately NOT extracted: the per-adapter filter chain and the budget
2656
+ * check. Those genuinely differ — the TypeScript adapter injects an AST,
2657
+ * framework tags, a lazy `codeText` getter, `appliesTo` scoping, config-only
2658
+ * gating and `configGateMatches`, and it ABORTS the whole file on budget
2659
+ * exhaustion where `runRulesShared` only `continue`s to the next rule.
2660
+ * Routing every adapter through `runRulesShared` as written would silently
2661
+ * change which rules run and when, which is precisely the unmeasured claim
2662
+ * this program exists to delete. This helper takes the part they share.
2663
+ */
2664
+ function runRuleIsolated(rule, file, onCrash) {
2665
+ try {
2666
+ return rule.run(file);
2667
+ } catch (error) {
2668
+ onCrash?.(rule.id, error);
2669
+ return null;
2670
+ }
2671
+ }
2672
+ //#endregion
2673
+ //#region src/adapters/typescript.ts
2217
2674
  /**
2218
2675
  * TypeScript/JavaScript adapter (R1).
2219
2676
  * Wraps the proven regex-based engine behind the LanguageAdapter
@@ -2226,7 +2683,36 @@ function frameworkFilterApplies(rule, file) {
2226
2683
  * "cypress", `@jest/globals` → "jest", `vitest` → "vitest". Config
2227
2684
  * gating is rule-declared (`configFiles`), not hard-coded here.
2228
2685
  */
2229
- const TEST_FILE_RE = /\.(?:test|spec)\.(?:js|jsx|ts|tsx|mjs|cjs)$|\.cy\.(?:js|jsx|ts|tsx)$/;
2686
+ /**
2687
+ * `.test.` / `.spec.` filenames, plus the Cypress `.cy.` convention.
2688
+ *
2689
+ * `mts` and `cts` are TypeScript's Node-native ESM/CJS extensions. They were
2690
+ * missing here, and the omission was invisible in the worst way — not a wrong
2691
+ * answer, but an ABSENCE.
2692
+ *
2693
+ * Reproduced: a repo containing two byte-identical tests, one at
2694
+ * `tests/control.spec.ts` and one at `tests/slow.spec.mts`, each with a
2695
+ * `QA-TEST-004` hard sleep:
2696
+ *
2697
+ * discovered: 1, analyzed: 1, unrecognized: 1, scopeVerdict: "PARTIAL"
2698
+ *
2699
+ * The `.ts` test produced the finding. The `.mts` test produced nothing at
2700
+ * all — it was never scanned, so every rule that could have caught it was
2701
+ * silent on it, with no finding, no low-evidence note, and no indication that
2702
+ * a test file had been skipped. A test scanner that cannot see a whole file
2703
+ * extension has a false green that is invisible by construction: the reader
2704
+ * has nothing to distrust, because there is nothing there.
2705
+ *
2706
+ * The corroboration that this was an oversight rather than a decision sits
2707
+ * four lines below: PW_CONFIG_RE already accepted `cts`. The config regex knew
2708
+ * about the Node-native extensions; the test-file regex did not.
2709
+ *
2710
+ * `discovery/scan-adapters.ts:isUnrecognizedSourceCandidate` already counted
2711
+ * these paths as uncovered surface, so the scope accounting had been reporting
2712
+ * the hole the whole time — it was being read as a known limitation rather than
2713
+ * as a defect.
2714
+ */
2715
+ const TEST_FILE_RE = /\.(?:test|spec)\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$|\.cy\.(?:js|jsx|ts|tsx)$/;
2230
2716
  /**
2231
2717
  * Fallback config list for `configOnly` rules that do not declare
2232
2718
  * `configFiles` (the legacy playwright.config.* gating, preserved
@@ -2333,11 +2819,42 @@ const typescriptAdapter = {
2333
2819
  fixtureDirMemo: /* @__PURE__ */ new Map()
2334
2820
  });
2335
2821
  },
2822
+ /**
2823
+ * The ONE AST seam (plan V5-021).
2824
+ *
2825
+ * This adapter used to parse inside `runRules`, on the synchronous path,
2826
+ * while Java and C# exposed the async `parseAst` hook. Two seams meant two
2827
+ * sets of consequences, both of them bad:
2828
+ *
2829
+ * - the pipeline computes `wantsAst` as `adapter.parseAst !== undefined`,
2830
+ * so a TypeScript file never took the AST path through the pipeline at
2831
+ * all, and its parse failures were invisible to the pipeline's
2832
+ * fallback counters;
2833
+ * - nothing called `dispose()` for the ts-morph path, so a scan held
2834
+ * every parsed SourceFile for its whole lifetime.
2835
+ *
2836
+ * Parsing now happens here, once, through the same contract every other
2837
+ * adapter uses. `dispose()` drops this file's SourceFile from the shared
2838
+ * project; ts-morph caches per file path, so removing it is what keeps
2839
+ * memory proportional to one file rather than to the whole scan.
2840
+ */
2841
+ parseAst(file) {
2842
+ const sourceFile = parseTsFile(file);
2843
+ if (sourceFile === void 0) return void 0;
2844
+ return {
2845
+ ast: sourceFile,
2846
+ dispose: () => {
2847
+ try {
2848
+ getProject().removeSourceFile(sourceFile);
2849
+ } catch {}
2850
+ }
2851
+ };
2852
+ },
2336
2853
  runRules(rules, file, emit, onCrash, budget) {
2337
- const withAst = {
2854
+ const withAst = file.ast === void 0 ? {
2338
2855
  ...file,
2339
2856
  ast: parseTsFile(file)
2340
- };
2857
+ } : file;
2341
2858
  const withTags = {
2342
2859
  ...withAst,
2343
2860
  frameworkTags: frameworkTagsFromImports(withAst.text)
@@ -2362,11 +2879,9 @@ const typescriptAdapter = {
2362
2879
  budget.onExceeded();
2363
2880
  return;
2364
2881
  }
2365
- try {
2366
- for (const f of rule.run(enriched)) emit(f, rule.id, rule.category);
2367
- } catch (error) {
2368
- onCrash?.(rule.id, error);
2369
- }
2882
+ const produced = runRuleIsolated(rule, enriched, onCrash);
2883
+ if (produced === null) continue;
2884
+ for (const f of produced) emit(f, rule.id, rule.category);
2370
2885
  }
2371
2886
  }
2372
2887
  };
@@ -2394,6 +2909,7 @@ function loadWorkspaceShim(root) {
2394
2909
  workspaceGlobs: []
2395
2910
  };
2396
2911
  } catch {
2912
+ recordDegradation("workspace-manifest-unreadable");
2397
2913
  return null;
2398
2914
  }
2399
2915
  }
@@ -2748,11 +3264,9 @@ const pythonAdapter = {
2748
3264
  budget.onExceeded();
2749
3265
  return;
2750
3266
  }
2751
- try {
2752
- for (const f of rule.run(enriched)) emit(f, rule.id, rule.category);
2753
- } catch (error) {
2754
- onCrash?.(rule.id, error);
2755
- }
3267
+ const produced = runRuleIsolated(rule, enriched, onCrash);
3268
+ if (produced === null) continue;
3269
+ for (const f of produced) emit(f, rule.id, rule.category);
2756
3270
  }
2757
3271
  },
2758
3272
  async parseAst(file) {
@@ -2905,11 +3419,9 @@ const javaAdapter = {
2905
3419
  budget.onExceeded();
2906
3420
  return;
2907
3421
  }
2908
- try {
2909
- for (const f of rule.run(enriched)) emit(f, rule.id, rule.category);
2910
- } catch (error) {
2911
- onCrash?.(rule.id, error);
2912
- }
3422
+ const produced = runRuleIsolated(rule, enriched, onCrash);
3423
+ if (produced === null) continue;
3424
+ for (const f of produced) emit(f, rule.id, rule.category);
2913
3425
  }
2914
3426
  }
2915
3427
  };
@@ -3063,11 +3575,9 @@ const csharpAdapter = {
3063
3575
  budget.onExceeded();
3064
3576
  return;
3065
3577
  }
3066
- try {
3067
- for (const f of rule.run(enriched)) emit(f, rule.id, rule.category);
3068
- } catch (error) {
3069
- onCrash?.(rule.id, error);
3070
- }
3578
+ const produced = runRuleIsolated(rule, enriched, onCrash);
3579
+ if (produced === null) continue;
3580
+ for (const f of produced) emit(f, rule.id, rule.category);
3071
3581
  }
3072
3582
  }
3073
3583
  };
@@ -3245,6 +3755,7 @@ const githubActionsAdapter = {
3245
3755
  try {
3246
3756
  entries = readdirSync(wfDir);
3247
3757
  } catch {
3758
+ recordDegradation("ci-workflow-listing-unreadable");
3248
3759
  return;
3249
3760
  }
3250
3761
  for (const name of entries) {
@@ -3285,15 +3796,13 @@ const githubActionsAdapter = {
3285
3796
  budget.onExceeded();
3286
3797
  return;
3287
3798
  }
3288
- try {
3289
- for (const f of rule.run({
3290
- path: file.path,
3291
- text: file.text,
3292
- ast: doc
3293
- })) emit(f, rule.id, rule.category);
3294
- } catch (error) {
3295
- onCrash?.(rule.id, error);
3296
- }
3799
+ const produced = runRuleIsolated(rule, {
3800
+ path: file.path,
3801
+ text: file.text,
3802
+ ast: doc
3803
+ }, onCrash);
3804
+ if (produced === null) continue;
3805
+ for (const f of produced) emit(f, rule.id, rule.category);
3297
3806
  }
3298
3807
  }
3299
3808
  };
@@ -3603,15 +4112,13 @@ const azurePipelinesAdapter = {
3603
4112
  budget.onExceeded();
3604
4113
  return;
3605
4114
  }
3606
- try {
3607
- for (const f of rule.run({
3608
- path: file.path,
3609
- text: file.text,
3610
- ast: doc
3611
- })) emit(f, rule.id, rule.category);
3612
- } catch (error) {
3613
- onCrash?.(rule.id, error);
3614
- }
4115
+ const produced = runRuleIsolated(rule, {
4116
+ path: file.path,
4117
+ text: file.text,
4118
+ ast: doc
4119
+ }, onCrash);
4120
+ if (produced === null) continue;
4121
+ for (const f of produced) emit(f, rule.id, rule.category);
3615
4122
  }
3616
4123
  }
3617
4124
  };
@@ -3682,14 +4189,12 @@ const jenkinsAdapter = {
3682
4189
  budget.onExceeded();
3683
4190
  return;
3684
4191
  }
3685
- try {
3686
- for (const f of rule.run({
3687
- path: file.path,
3688
- text: file.text
3689
- })) emit(f, rule.id, rule.category);
3690
- } catch (error) {
3691
- onCrash?.(rule.id, error);
3692
- }
4192
+ const produced = runRuleIsolated(rule, {
4193
+ path: file.path,
4194
+ text: file.text
4195
+ }, onCrash);
4196
+ if (produced === null) continue;
4197
+ for (const f of produced) emit(f, rule.id, rule.category);
3693
4198
  }
3694
4199
  }
3695
4200
  };
@@ -3756,9 +4261,37 @@ function discoverAllTestFiles(ctx, languageAdapters, buckets, fixtureDirMemo) {
3756
4261
  }
3757
4262
  });
3758
4263
  }
4264
+ /**
4265
+ * Would ignoring this file have removed it from the scanned surface?
4266
+ *
4267
+ * The bug this fixes, reproduced: a repo containing a real test file plus
4268
+ * `vendor.min.js` and `pnpm-lock.yaml` reported
4269
+ *
4270
+ * discovered: 1, analyzed: 1, ignored: 2, scopeVerdict: "PARTIAL"
4271
+ *
4272
+ * Every discovered test file HAD been analyzed. The surface was complete. But
4273
+ * DEFAULT_IGNORES matches minified bundles and lockfiles, and the ignored
4274
+ * counter accepted any path with a source extension — so a minified bundle and
4275
+ * a lockfile downgraded a finished scan to "unverified".
4276
+ *
4277
+ * That is not a conservative bias, it is a broken signal. The terminal tells
4278
+ * the reader "some files were not analyzed, so the surface is unverified" —
4279
+ * which was false. And because minified bundles and lockfiles exist in
4280
+ * essentially every real repository, a PROVEN scan was unreachable in
4281
+ * practice, so the corpus regression guard failed 23 of 37 repositories on
4282
+ * exactly this. A gate that cannot pass teaches people to ignore it.
4283
+ *
4284
+ * The asymmetry is the tell. `isUnrecognizedSourceCandidate` below applies a
4285
+ * test-relevance test before counting a file; this one did not. Both count
4286
+ * against the same verdict, so both must ask the same question.
4287
+ *
4288
+ * An ignored file that IS a test file still downgrades the verdict. That is
4289
+ * the case the honesty law exists for, and this does not touch it.
4290
+ */
3759
4291
  function isScopeRelevantIgnored(path) {
3760
4292
  const name = path.replaceAll("\\", "/").split("/").pop() ?? path;
3761
- return /\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml|min\.js)$/i.test(name);
4293
+ if (!/\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml|min\.js)$/i.test(name)) return false;
4294
+ return isKnownTestFile(path);
3762
4295
  }
3763
4296
  function isUnrecognizedSourceCandidate(path) {
3764
4297
  const normalized = path.replaceAll("\\", "/");
@@ -3788,82 +4321,6 @@ function isInsideSkippedDir(root, absPath, skipNames) {
3788
4321
  }
3789
4322
  typescriptAdapter.testFileGlobs, pythonAdapter.testFileGlobs, javaAdapter.testFileGlobs, csharpAdapter.testFileGlobs, githubActionsAdapter.testFileGlobs, azurePipelinesAdapter.testFileGlobs, jenkinsAdapter.testFileGlobs;
3790
4323
  //#endregion
3791
- //#region src/scope/git-resolve.ts
3792
- /**
3793
- * Absolute-path git resolution (audit S1).
3794
- *
3795
- * `execFileSync("git", …)` resolves through PATH — including the CWD on
3796
- * Windows (CreateProcess searches the current directory before PATH for
3797
- * extension-less names depending on NoDefaultCurrentDirectoryInExePath).
3798
- * Scanning an UNTRUSTED repo therefore let a checked-in `git.exe` (or
3799
- * `git.bat`/`git.cmd`) hijack Mjölnir's own git invocations: the attacker
3800
- * controls the diff output, the merge-base decision, and
3801
- * `--scope changed`'s "what is new" answer — the exact data CI gates on.
3802
- *
3803
- * The fix: resolve git ONCE per process to an ABSOLUTE path by walking
3804
- * the PATH directly (never consulting the CWD), verify the candidate is
3805
- * an existing file, and pass that path to execFileSync. Resolution is
3806
- * memoized; a failure to find a real git anywhere on PATH is recorded
3807
- * and callers degrade without invoking a bare name.
3808
- */
3809
- let resolvedGit = void 0;
3810
- /**
3811
- * The absolute path of the git binary Mjölnir will invoke, or null when
3812
- * PATH carries no executable `git` at all. Callers must degrade without
3813
- * invoking a bare name.
3814
- */
3815
- function resolveGitPath() {
3816
- if (resolvedGit !== void 0) return resolvedGit;
3817
- const candidates = [process.platform === "win32" ? "git.exe" : "git"];
3818
- if (process.platform === "win32") {
3819
- const pathExt = process.env["PATHEXT"]?.split(";").filter(Boolean) ?? [];
3820
- for (const ext of pathExt) candidates.push(`git${ext.toLowerCase()}`);
3821
- }
3822
- const pathValue = process.env["PATH"] ?? "";
3823
- for (const dir of pathValue.split(delimiter)) {
3824
- if (dir === "") continue;
3825
- if (!isAbsolute(dir)) continue;
3826
- for (const candidate of candidates) {
3827
- const full = join(dir, candidate);
3828
- try {
3829
- if (existsSync(full)) {
3830
- resolvedGit = full;
3831
- return resolvedGit;
3832
- }
3833
- } catch {}
3834
- }
3835
- }
3836
- `${pathValue.length}`;
3837
- resolvedGit = null;
3838
- return resolvedGit;
3839
- }
3840
- /**
3841
- * Run git with the S1-resolved absolute binary path. Returns stdout, or
3842
- * null when git is missing or the command fails — the same contract as
3843
- * the previous inline `git()` helpers, with the hijack surface closed.
3844
- */
3845
- function runGit(root, args) {
3846
- const exe = resolveGitPath();
3847
- if (!exe) return null;
3848
- try {
3849
- return execFileSync(exe, [
3850
- "-C",
3851
- root,
3852
- ...args
3853
- ], {
3854
- encoding: "utf8",
3855
- stdio: [
3856
- "ignore",
3857
- "pipe",
3858
- "ignore"
3859
- ],
3860
- timeout: 15e3
3861
- });
3862
- } catch {
3863
- return null;
3864
- }
3865
- }
3866
- //#endregion
3867
4324
  //#region src/scope/changed.ts
3868
4325
  /**
3869
4326
  * Changed-scope engine (Sprint-Plan W6, Product-MVP §9 `--scope changed`).
@@ -7608,7 +8065,6 @@ const pyBareTruthinessAssert = defineRule({
7608
8065
  const re = /^[ \t]*assert\s+([A-Za-z_][\w.]*(?:\([^()]*\))?)[ \t]*$/gm;
7609
8066
  const predicateRe = /^(?:(?:any|all|isinstance)\s*\(|[\w.]*\.(?:startswith|endswith|exists|isdir|isfile|islink|ismount|check|isdigit|isalpha|isalnum|isnumeric|isdecimal|isspace|islower|isupper|istitle|isidentifier|isprintable|isascii)\s*\(|re\.(?:match|search|fullmatch)\s*\()/;
7610
8067
  const isGuardFollowedByRealUse = (text, matchIndex, target) => {
7611
- const root = target.split(".")[0];
7612
8068
  const lineEnd = text.indexOf("\n", matchIndex);
7613
8069
  if (lineEnd === -1) return false;
7614
8070
  const lines = text.slice(lineEnd + 1).split("\n");
@@ -7618,8 +8074,11 @@ const pyBareTruthinessAssert = defineRule({
7618
8074
  if (/^\s*def\s/.test(l) && window.length > 0) break;
7619
8075
  window.push(l);
7620
8076
  }
7621
- const usesRoot = new RegExp(`\\b${root}\\b`);
7622
- return window.some((l) => usesRoot.test(l));
8077
+ const root = target.match(/^[a-z_]\w*/i)?.[0];
8078
+ if (!root) return false;
8079
+ return window.some((line) => {
8080
+ return (line.match(/[a-z_]\w*/gi) ?? []).some((token) => token === root);
8081
+ });
7623
8082
  };
7624
8083
  let m;
7625
8084
  while ((m = re.exec(text)) !== null) {
@@ -11302,6 +11761,53 @@ function getRule(id) {
11302
11761
  return RULES.find((r) => r.id === id);
11303
11762
  }
11304
11763
  //#endregion
11764
+ //#region src/lib/compare.ts
11765
+ /**
11766
+ * Order that does not depend on who is asking.
11767
+ *
11768
+ * `String.prototype.localeCompare` with no locale argument resolves against
11769
+ * the PROCESS's default locale — the ICU collation chosen by `LANG`,
11770
+ * `LC_ALL` or the OS. So a sort written as
11771
+ * `[...xs].sort((a, b) => a.localeCompare(b))` produces a different order on
11772
+ * a developer's `de_DE` machine than on an en-US CI runner. For a display
11773
+ * list that is a nuisance; for anything that reaches a hash, a cache key, a
11774
+ * digest or a machine contract it is a correctness bug, because the same
11775
+ * repository then produces two different fingerprints.
11776
+ *
11777
+ * Two comparators, one per kind of site:
11778
+ *
11779
+ * compareCodePoints — pure UTF-16 code-unit order. Identical everywhere,
11780
+ * independent of ICU version, and already what `Array.prototype.sort`
11781
+ * defaults to. Use for every value that reaches a fingerprint, a digest,
11782
+ * a cache key, or a contract, and for any ordering with no human-facing
11783
+ * reason to be pretty.
11784
+ *
11785
+ * compareLocalized(locale) — the same collator, PINNED to a named locale
11786
+ * instead of inheriting the environment, for a surface that genuinely
11787
+ * wants readable order (a table of rule titles, a Mermaid legend). Pinned
11788
+ * rather than removed because a reader-facing list of "Test / Logic /
11789
+ * Mock" should not be ordered by code unit.
11790
+ *
11791
+ * A bare `localeCompare` with no second argument in `src/` is a defect and
11792
+ * `tests/contract/deterministic-ordering.spec.ts` fails the build on it.
11793
+ */
11794
+ /** Code-unit order. Locale-free, ICU-free, and the Array.sort default. */
11795
+ function compareCodePoints(a, b) {
11796
+ if (a === b) return 0;
11797
+ return a < b ? -1 : 1;
11798
+ }
11799
+ /**
11800
+ * A readable-order comparator pinned to `locale`.
11801
+ *
11802
+ * The pin is the point: `a.localeCompare(b)` reads the environment, and the
11803
+ * environment is a machine setting, not a property of the code. Callers that
11804
+ * want stable output ask for a named locale and get the same answer on every
11805
+ * machine that ships the same ICU data.
11806
+ */
11807
+ function compareLocalized(locale) {
11808
+ return (a, b) => a.localeCompare(b, locale);
11809
+ }
11810
+ //#endregion
11305
11811
  //#region src/scorer/scorer.ts
11306
11812
  /**
11307
11813
  * Transparent scorer (Product-MVP §8).
@@ -11358,7 +11864,7 @@ function computeDimensions(findings) {
11358
11864
  deductions.set(f.category, deductions.get(f.category) + deductionFor(f));
11359
11865
  }
11360
11866
  for (const dim of byCategory.values()) dim.score = Math.max(0, 100 - deductions.get(dim.category));
11361
- return [...byCategory.values()].sort((a, b) => a.category.localeCompare(b.category));
11867
+ return [...byCategory.values()].sort((a, b) => compareCodePoints(a.category, b.category));
11362
11868
  }
11363
11869
  /**
11364
11870
  * Deduction-mass ceilings (product-gap-remediation master plan P2,
@@ -11581,6 +12087,22 @@ function normalizeOne(report, artifact, v) {
11581
12087
  function buildEvidenceRecords(report, artifact) {
11582
12088
  return report.verdicts.map((v) => normalizeOne(report, artifact, v)).sort(compareEvidenceRecords);
11583
12089
  }
12090
+ function countEvidence(records) {
12091
+ const counts = {
12092
+ total: records.length,
12093
+ failed: 0,
12094
+ flaky: 0,
12095
+ skipped: 0,
12096
+ timedOut: 0
12097
+ };
12098
+ for (const r of records) {
12099
+ if (r.status.failed) counts.failed++;
12100
+ if (r.status.passedOnRetry) counts.flaky++;
12101
+ if (r.status.skipped) counts.skipped++;
12102
+ if (r.status.timedOut) counts.timedOut++;
12103
+ }
12104
+ return counts;
12105
+ }
11584
12106
  //#endregion
11585
12107
  //#region src/engine/provenance.ts
11586
12108
  const GENERATED_HEADER_RE = /^\s*(?:\/\/|#|\/\*)\s*(?:auto[- ]?generated|generated by|do not edit)/i;
@@ -11784,7 +12306,7 @@ function groupByRootCause(findings) {
11784
12306
  function correlateFindings(findings) {
11785
12307
  const conclusions = [];
11786
12308
  const rootCauseGroups = groupByRootCause(findings);
11787
- for (const [rootCause, group] of Array.from(rootCauseGroups.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
12309
+ for (const [rootCause, group] of Array.from(rootCauseGroups.entries()).sort((a, b) => compareLocalized("en")(a[0], b[0]))) {
11788
12310
  if (group.length < 2) continue;
11789
12311
  const findingIds = group.map((f) => f.findingId ?? f.ruleId);
11790
12312
  conclusions.push({
@@ -11796,7 +12318,7 @@ function correlateFindings(findings) {
11796
12318
  });
11797
12319
  }
11798
12320
  const fileGroups = groupByFile(findings);
11799
- for (const [file, group] of Array.from(fileGroups.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
12321
+ for (const [file, group] of Array.from(fileGroups.entries()).sort((a, b) => compareCodePoints(a[0], b[0]))) {
11800
12322
  if (group.length < 2) continue;
11801
12323
  const alreadyConvergent = /* @__PURE__ */ new Set();
11802
12324
  for (const c of conclusions) if (c.conclusionType === "CONVERGENT") for (const id of c.findingIds) alreadyConvergent.add(id);
@@ -11868,6 +12390,11 @@ var DependencyGraph = class {
11868
12390
  get size() {
11869
12391
  return this.nodes.size;
11870
12392
  }
12393
+ /** Whether the graph knows this exact key. Reachability must not
12394
+ * treat "not in the graph" as "nothing to traverse" without saying so. */
12395
+ has(path) {
12396
+ return this.nodes.has(path);
12397
+ }
11871
12398
  };
11872
12399
  function parsePackageJson(filePath) {
11873
12400
  if (!existsSync(filePath)) return void 0;
@@ -11992,117 +12519,391 @@ function isIncrementalSafe(changedFiles) {
11992
12519
  };
11993
12520
  }
11994
12521
  //#endregion
11995
- //#region src/engine/monorepo-analysis.ts
11996
- const WORTHY_THRESHOLD = 80;
11997
- const NEEDS_WORK_THRESHOLD = 50;
11998
- function verdictOf(score) {
11999
- if (score === null) return "fail";
12000
- if (score >= WORTHY_THRESHOLD) return "pass";
12001
- if (score >= NEEDS_WORK_THRESHOLD) return "warn";
12002
- return "fail";
12003
- }
12004
- function hasBlocker(findings) {
12005
- return findings.some((f) => f.severity === "error");
12006
- }
12522
+ //#region src/brand/tokens.ts
12007
12523
  /**
12008
- * Aggregate per-package results into an overall verdict.
12524
+ * The single source of brand truth.
12009
12525
  *
12010
- * Worst-package propagation: if ANY package has a blocker (error-severity
12011
- * finding) or a failing score, the overall result fails regardless of
12012
- * strategy. This is the safety net — a single poisoned package must not
12013
- * hide behind averaging.
12014
- */
12015
- function analyzeMonorepo(packages, config) {
12016
- const results = packages.map((p) => ({
12017
- ...p,
12018
- verdict: hasBlocker(p.findings) ? "fail" : verdictOf(p.score)
12019
- }));
12020
- if (results.length === 0) return {
12021
- packages: results,
12022
- overallScore: null,
12023
- overallVerdict: "fail",
12024
- strategy: config.weightingStrategy
12025
- };
12026
- const blockerPkg = results.find((r) => r.verdict === "fail" || hasBlocker(r.findings));
12027
- if (blockerPkg) return {
12028
- packages: results,
12029
- overallScore: blockerPkg.score,
12030
- overallVerdict: "fail",
12031
- strategy: config.weightingStrategy,
12032
- blockerPackage: blockerPkg.packageName
12033
- };
12034
- switch (config.weightingStrategy) {
12035
- case "worst-package": return worstPackageAggregation(results, config);
12036
- case "average": return averageAggregation(results, config);
12037
- case "configurable": return configurableAggregation(results, config);
12038
- }
12039
- }
12040
- function worstPackageAggregation(results, config) {
12041
- const firstResult = results[0];
12042
- if (firstResult === void 0) return {
12043
- packages: results,
12044
- overallScore: null,
12045
- overallVerdict: "fail",
12046
- strategy: config.weightingStrategy
12047
- };
12048
- let worst = firstResult;
12049
- for (const r of results) if ((r.score ?? 0) < (worst.score ?? 0)) worst = r;
12050
- return {
12051
- packages: results,
12052
- overallScore: worst.score,
12053
- overallVerdict: worst.verdict,
12054
- strategy: config.weightingStrategy,
12055
- ...worst.verdict === "fail" ? { blockerPackage: worst.packageName } : {}
12056
- };
12057
- }
12058
- function averageAggregation(results, config) {
12059
- const scored = results.filter((r) => r.score !== null);
12060
- if (scored.length === 0) return {
12061
- packages: results,
12062
- overallScore: null,
12063
- overallVerdict: "fail",
12064
- strategy: config.weightingStrategy
12065
- };
12066
- const avg = scored.reduce((sum, r) => sum + (r.score ?? 0), 0) / scored.length;
12067
- return {
12068
- packages: results,
12069
- overallScore: Math.round(avg),
12070
- overallVerdict: verdictOf(Math.round(avg)),
12071
- strategy: config.weightingStrategy
12072
- };
12073
- }
12074
- function configurableAggregation(results, config) {
12075
- const weights = config.packageWeights ?? {};
12076
- let totalWeight = 0;
12077
- let weightedSum = 0;
12078
- for (const r of results) {
12079
- const w = weights[r.packageName] ?? 1;
12080
- totalWeight += w;
12081
- weightedSum += (r.score ?? 0) * w;
12082
- }
12083
- if (totalWeight === 0) return {
12084
- packages: results,
12085
- overallScore: null,
12086
- overallVerdict: "fail",
12087
- strategy: config.weightingStrategy
12088
- };
12089
- const score = Math.round(weightedSum / totalWeight);
12090
- return {
12091
- packages: results,
12092
- overallScore: score,
12093
- overallVerdict: verdictOf(score),
12094
- strategy: config.weightingStrategy
12095
- };
12096
- }
12097
- //#endregion
12098
- //#region src/lib/fs-atomic.ts
12099
- /**
12100
- * Atomic file writes (audit S9).
12526
+ * Every colour, typeface and motion constant Mjölnir shows a human —
12527
+ * terminal, README SVGs, demo video, website, docs, badges — resolves to
12528
+ * a value in this file. Nothing else may define one.
12101
12529
  *
12102
- * Every durability-critical write in Mjölnir (baseline, stats, badge,
12103
- * TRIAGE.md, scaffolded rule files) used to hand-roll
12104
- * `writeFileSync(path, data)` — a crash mid-write left a TRUNCATED file
12105
- * at the real path, and a subsequent read (diff, badge endpoint) served
12530
+ * WHY THIS EXISTS. Before it, the palette existed in six independent
12531
+ * copies: `site/.vitepress/theme/styles/vars.css`, `NORSE` in
12532
+ * `src/reporter/theme.ts`, `scripts/readme-svg.ts`,
12533
+ * `scripts/video/terminal-page.ts`, `scripts/generate-readme-architecture.ts`
12534
+ * and the table in `assets/brand/README.md`. Exactly one pair of those
12535
+ * was guarded (site-doctor Check 8, doc ↔ vars.css). The unguarded edges
12536
+ * are where the shipped surfaces drifted apart: the terminal and the site
12537
+ * disagreed on six semantic roles, the architecture diagram invented its
12538
+ * own neutral ramp, and the README badges still carried a palette retired
12539
+ * two releases earlier. `scripts/brand-doctor.mjs` now checks every edge
12540
+ * against this file.
12541
+ *
12542
+ * PURITY. Pure data. No I/O, no rendering, no environment access, no
12543
+ * imports, no logic. Consumers convert (hex → ANSI triplet, hex → CSS)
12544
+ * themselves. Same reason `presentation.ts` is pure: it makes the whole
12545
+ * thing golden-testable and safe to ship inside the npm package, where
12546
+ * it costs a few hundred bytes and replaces values the package already
12547
+ * carried anyway.
12548
+ *
12549
+ * DERIVATION. The palette is the one derived from the logo in PR #20
12550
+ * (brushed steel and forge gold under an aurora, over midnight iron).
12551
+ * Where the terminal disagreed with it, the terminal converges — see
12552
+ * `PENDING_TERMINAL` below. Full rationale: `assets/brand/README.md`.
12553
+ *
12554
+ * ACCESSIBILITY. Every foreground token in `BRAND` meets WCAG AA
12555
+ * (≥ 4.5:1) against every surface token it is allowed to sit on. That is
12556
+ * not a claim, it is `brand-doctor` rule 8, which computes the ratios.
12557
+ * The weakest legal pairing is `steelDim` on `ink800` at 5.00:1.
12558
+ */
12559
+ /**
12560
+ * The two brand hues plus the neutral they sit on.
12561
+ *
12562
+ * GOLD IS SCARCE. It means forged / certified / earned / decisive — the
12563
+ * primary mark, the FORGED state, one call to action. It is not a paint
12564
+ * bucket: gold as default text, default border or default heading is a
12565
+ * brand-doctor finding, not a style choice.
12566
+ *
12567
+ * AURORA is verification energy — the secondary, and the hue that marks
12568
+ * the runtime half of the trust ladder.
12569
+ */
12570
+ const BRAND = {
12571
+ gold: "#C19A34",
12572
+ goldBright: "#E6BD57",
12573
+ goldHot: "#F4DC9C",
12574
+ /** Pressed / deepest gold — the only step dark enough to carry white. */
12575
+ goldDeep: "#A5811C",
12576
+ aurora: "#37ABBD",
12577
+ auroraBright: "#45C1D4",
12578
+ auroraCyan: "#5CBDE0",
12579
+ /** The aurora's outer curtains: atmosphere and section identity only,
12580
+ * never a verdict or a status. */
12581
+ auroraGreen: "#5FD6A4",
12582
+ auroraViolet: "#9D8CF5",
12583
+ steel: "#C8CBCF",
12584
+ steelDim: "#8B939D"
12585
+ };
12586
+ /**
12587
+ * Midnight iron. One ramp, four steps, darkest first.
12588
+ *
12589
+ * `terminal` and `terminalBar` share one tone deliberately: the window's
12590
+ * only seam is a hairline ring and an inset shadow, never a second fill.
12591
+ * `chromeDot` is the three window dots — see the note on
12592
+ * `PENDING_TERMINAL.chromeDots` for why they are no longer red/amber/green.
12593
+ */
12594
+ const SURFACE = {
12595
+ ink950: "#0A1119",
12596
+ ink900: "#0C1420",
12597
+ ink850: "#111A29",
12598
+ ink800: "#18243A",
12599
+ /** Raised panel (cards, elevated surfaces). */
12600
+ panel: "#141F33",
12601
+ /** Soft fill (inline code, quiet chips). */
12602
+ soft: "#1A2740",
12603
+ /** Terminal body — the deepest tone, so a terminal reads as recessed. */
12604
+ terminal: "#0A1119",
12605
+ /** Terminal title bar — the same tone; the seam is shadow, not colour. */
12606
+ terminalBar: "#0A1119",
12607
+ /** The three window dots. One neutral, not a traffic light. */
12608
+ chromeDot: "#18243A"
12609
+ };
12610
+ const TEXT = {
12611
+ primary: "#EAEEF5",
12612
+ secondary: "#ABB6C6",
12613
+ muted: "#8B939D",
12614
+ /** Ink for text set ON gold (buttons, the FORGED chip). 7.17:1 on `gold`. */
12615
+ onGold: "#0A1119"
12616
+ };
12617
+ /**
12618
+ * Non-score status. `ok` is the one green in the system and it is NOT a
12619
+ * score colour — it survives only for contexts with no worthiness
12620
+ * meaning ("autofix applied", "analysis complete"). A green score would
12621
+ * say "your software is fine", which is the exact claim this product
12622
+ * refuses to make.
12623
+ */
12624
+ const STATUS = {
12625
+ ok: "#4FB477",
12626
+ info: "#5CC4E0",
12627
+ warning: "#E6BD57",
12628
+ error: "#EC6B66"
12629
+ };
12630
+ /**
12631
+ * The four ScoreState bands plus the unmeasured state. Band thresholds
12632
+ * and runes live in `src/reporter/presentation.ts`, which stays free of
12633
+ * colour — it emits a palette KEY and each surface resolves it here.
12634
+ *
12635
+ * `unmeasured` is steel-dim on purpose. UNKNOWN is a legitimate answer,
12636
+ * not a failure: colouring it red would make "we did not measure this"
12637
+ * look like "this is broken", which is precisely the dishonesty the
12638
+ * north-star law exists to prevent.
12639
+ */
12640
+ const SCORE = {
12641
+ critical: "#EC6B66",
12642
+ warning: "#E6BD57",
12643
+ trusted: "#5CC4E0",
12644
+ forged: "#F4DC9C",
12645
+ unmeasured: "#8B939D"
12646
+ };
12647
+ /**
12648
+ * E0 → E1 → E2 is a certainty ramp, and it is deliberately HUE-FREE.
12649
+ *
12650
+ * Evidence level says how sure we are, not whether the news is good. A
12651
+ * deterministic proof (E2) is a defect we are certain about — painting
12652
+ * it gold or green would read as an achievement. So certainty is carried
12653
+ * by brightness alone, and the *shape* does the real work:
12654
+ *
12655
+ * E0 open ring observation, no weight
12656
+ * E1 half-filled pattern evidence, half weight
12657
+ * E2 sealed deterministic proof, full weight
12658
+ *
12659
+ * Colour never carries this alone (R11): the geometry is the signal and
12660
+ * survives `--ascii`, `NO_COLOR` and monochrome print.
12661
+ */
12662
+ const EVIDENCE = {
12663
+ e0: "#8B939D",
12664
+ e1: "#ABB6C6",
12665
+ e2: "#EAEEF5"
12666
+ };
12667
+ /**
12668
+ * L0–L5, and the most important boundary in the product.
12669
+ *
12670
+ * L0–L2 are STATIC: the neutral steel ramp, brightening to the static
12671
+ * ceiling at L2. L3–L5 require a real run, and the hue changes to aurora
12672
+ * exactly there. The boundary is a hue break, not a gradient step,
12673
+ * because it is a change of kind and not of degree — a static-only
12674
+ * finding can never climb past L2, however confident it is.
12675
+ *
12676
+ * Every surface that draws the ladder must draw that break.
12677
+ */
12678
+ const TRUST = {
12679
+ l0: "#8B939D",
12680
+ l1: "#ABB6C6",
12681
+ l2: "#C8CBCF",
12682
+ l3: "#37ABBD",
12683
+ l4: "#45C1D4",
12684
+ l5: "#5CC4E0"
12685
+ };
12686
+ SURFACE.ink950, SURFACE.ink900, SURFACE.ink850, SURFACE.ink800, BRAND.steel, BRAND.steelDim, BRAND.gold, BRAND.goldBright, BRAND.goldHot, BRAND.aurora, BRAND.auroraBright, BRAND.auroraCyan, BRAND.auroraGreen, BRAND.auroraViolet;
12687
+ SCORE.trusted, SCORE.forged, SCORE.warning, SCORE.critical, STATUS.info, TEXT.onGold, STATUS.ok, EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2, TRUST.l0, TRUST.l1, TRUST.l2, TRUST.l3, TRUST.l4, TRUST.l5;
12688
+ EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2;
12689
+ const RUNG_MEANINGS = [
12690
+ "observation only",
12691
+ "heuristic static",
12692
+ "deterministic static",
12693
+ "the finding's file executed",
12694
+ "the finding's test executed",
12695
+ "the run verdict corroborates"
12696
+ ];
12697
+ const RUNG_COLORS = [
12698
+ TRUST.l0,
12699
+ TRUST.l1,
12700
+ TRUST.l2,
12701
+ TRUST.l3,
12702
+ TRUST.l4,
12703
+ TRUST.l5
12704
+ ];
12705
+ RUNG_MEANINGS.map((meaning, i) => ({
12706
+ level: `L${i}`,
12707
+ meaning,
12708
+ runtime: i >= 3,
12709
+ color: RUNG_COLORS[i]
12710
+ }));
12711
+ const HEADLINES = {
12712
+ critical: "The hammer is cracked — {n} findings break its edge.",
12713
+ warning: "The hammer holds — but {n} findings weigh it down.",
12714
+ trusted: "Held in worthy hands — {n} findings remain.",
12715
+ forged: "Static score 100 — no findings on the analyzed surface.",
12716
+ unmeasured: "No tests found — the hammer cannot be weighed."
12717
+ };
12718
+ const RUNES = {
12719
+ critical: "ᚲ",
12720
+ warning: "ᚦ",
12721
+ trusted: "ᛏ",
12722
+ forged: "ᛟ",
12723
+ unmeasured: "ᛁ"
12724
+ };
12725
+ /**
12726
+ * The named form of the band boundaries. `report:honesty` /
12727
+ * `thresholds:parity` assert that no surface outside this module
12728
+ * re-invents a boundary; a consumer that genuinely needs one names it
12729
+ * here rather than typing the number.
12730
+ */
12731
+ const SCORE_THRESHOLDS = {
12732
+ /** Below this is `critical`. */
12733
+ criticalBelow: 50,
12734
+ /** At or above this is `trusted`. */
12735
+ trustedAtOrAbove: 80,
12736
+ /** The one score that is `forged`. */
12737
+ forgedAt: 100
12738
+ };
12739
+ /**
12740
+ * Band mapping — the one mapping, every consumer: <50 critical,
12741
+ * 50–79 warning, 80–99 trusted, 100 forged, null unmeasured.
12742
+ */
12743
+ function deriveScoreState(score) {
12744
+ if (score === null) return {
12745
+ score: null,
12746
+ band: "unmeasured",
12747
+ verdict: "UNWORTHY",
12748
+ color: "dim",
12749
+ powerLevel: 0,
12750
+ headline: HEADLINES.unmeasured,
12751
+ rune: RUNES.unmeasured
12752
+ };
12753
+ if (score >= SCORE_THRESHOLDS.forgedAt) return {
12754
+ score,
12755
+ band: "forged",
12756
+ verdict: "FORGED",
12757
+ color: "forged",
12758
+ powerLevel: score,
12759
+ headline: HEADLINES.forged,
12760
+ rune: RUNES.forged
12761
+ };
12762
+ if (score >= 80) return {
12763
+ score,
12764
+ band: "trusted",
12765
+ verdict: "WORTHY",
12766
+ color: "trusted",
12767
+ powerLevel: score,
12768
+ headline: HEADLINES.trusted,
12769
+ rune: RUNES.trusted
12770
+ };
12771
+ if (score >= 50) return {
12772
+ score,
12773
+ band: "warning",
12774
+ verdict: "NEEDS WORK",
12775
+ color: "warning",
12776
+ powerLevel: score,
12777
+ headline: HEADLINES.warning,
12778
+ rune: RUNES.warning
12779
+ };
12780
+ return {
12781
+ score,
12782
+ band: "critical",
12783
+ verdict: "UNWORTHY",
12784
+ color: "error",
12785
+ powerLevel: score,
12786
+ headline: HEADLINES.critical,
12787
+ rune: RUNES.critical
12788
+ };
12789
+ }
12790
+ //#endregion
12791
+ //#region src/engine/monorepo-analysis.ts
12792
+ /**
12793
+ * Package verdict, derived from the one ScoreState band model (BW-104).
12794
+ * These two constants used to be a private copy of the band boundaries;
12795
+ * the numeric vocabulary here is a different projection of the same bands
12796
+ * — "pass" is trusted or forged, "warn" is warning, "fail" is critical or
12797
+ * unmeasurable — so the boundaries are read, never retyped.
12798
+ */
12799
+ function verdictOf(score) {
12800
+ if (score === null) return "fail";
12801
+ const band = deriveScoreState(score).band;
12802
+ if (band === "forged" || band === "trusted") return "pass";
12803
+ return band === "warning" ? "warn" : "fail";
12804
+ }
12805
+ function hasBlocker(findings) {
12806
+ return findings.some((f) => f.severity === "error");
12807
+ }
12808
+ /**
12809
+ * Aggregate per-package results into an overall verdict.
12810
+ *
12811
+ * Worst-package propagation: if ANY package has a blocker (error-severity
12812
+ * finding) or a failing score, the overall result fails regardless of
12813
+ * strategy. This is the safety net — a single poisoned package must not
12814
+ * hide behind averaging.
12815
+ */
12816
+ function analyzeMonorepo(packages, config) {
12817
+ const results = packages.map((p) => ({
12818
+ ...p,
12819
+ verdict: hasBlocker(p.findings) ? "fail" : verdictOf(p.score)
12820
+ }));
12821
+ if (results.length === 0) return {
12822
+ packages: results,
12823
+ overallScore: null,
12824
+ overallVerdict: "fail",
12825
+ strategy: config.weightingStrategy
12826
+ };
12827
+ const blockerPkg = results.find((r) => r.verdict === "fail" || hasBlocker(r.findings));
12828
+ if (blockerPkg) return {
12829
+ packages: results,
12830
+ overallScore: blockerPkg.score,
12831
+ overallVerdict: "fail",
12832
+ strategy: config.weightingStrategy,
12833
+ blockerPackage: blockerPkg.packageName
12834
+ };
12835
+ switch (config.weightingStrategy) {
12836
+ case "worst-package": return worstPackageAggregation(results, config);
12837
+ case "average": return averageAggregation(results, config);
12838
+ case "configurable": return configurableAggregation(results, config);
12839
+ }
12840
+ }
12841
+ function worstPackageAggregation(results, config) {
12842
+ const firstResult = results[0];
12843
+ if (firstResult === void 0) return {
12844
+ packages: results,
12845
+ overallScore: null,
12846
+ overallVerdict: "fail",
12847
+ strategy: config.weightingStrategy
12848
+ };
12849
+ let worst = firstResult;
12850
+ for (const r of results) if ((r.score ?? 0) < (worst.score ?? 0)) worst = r;
12851
+ return {
12852
+ packages: results,
12853
+ overallScore: worst.score,
12854
+ overallVerdict: worst.verdict,
12855
+ strategy: config.weightingStrategy,
12856
+ ...worst.verdict === "fail" ? { blockerPackage: worst.packageName } : {}
12857
+ };
12858
+ }
12859
+ function averageAggregation(results, config) {
12860
+ const scored = results.filter((r) => r.score !== null);
12861
+ if (scored.length === 0) return {
12862
+ packages: results,
12863
+ overallScore: null,
12864
+ overallVerdict: "fail",
12865
+ strategy: config.weightingStrategy
12866
+ };
12867
+ const avg = scored.reduce((sum, r) => sum + (r.score ?? 0), 0) / scored.length;
12868
+ return {
12869
+ packages: results,
12870
+ overallScore: Math.round(avg),
12871
+ overallVerdict: verdictOf(Math.round(avg)),
12872
+ strategy: config.weightingStrategy
12873
+ };
12874
+ }
12875
+ function configurableAggregation(results, config) {
12876
+ const weights = config.packageWeights ?? {};
12877
+ let totalWeight = 0;
12878
+ let weightedSum = 0;
12879
+ for (const r of results) {
12880
+ const w = weights[r.packageName] ?? 1;
12881
+ totalWeight += w;
12882
+ weightedSum += (r.score ?? 0) * w;
12883
+ }
12884
+ if (totalWeight === 0) return {
12885
+ packages: results,
12886
+ overallScore: null,
12887
+ overallVerdict: "fail",
12888
+ strategy: config.weightingStrategy
12889
+ };
12890
+ const score = Math.round(weightedSum / totalWeight);
12891
+ return {
12892
+ packages: results,
12893
+ overallScore: score,
12894
+ overallVerdict: verdictOf(score),
12895
+ strategy: config.weightingStrategy
12896
+ };
12897
+ }
12898
+ //#endregion
12899
+ //#region src/lib/fs-atomic.ts
12900
+ /**
12901
+ * Atomic file writes (audit S9).
12902
+ *
12903
+ * Every durability-critical write in Mjölnir (baseline, stats, badge,
12904
+ * TRIAGE.md, scaffolded rule files) used to hand-roll
12905
+ * `writeFileSync(path, data)` — a crash mid-write left a TRUNCATED file
12906
+ * at the real path, and a subsequent read (diff, badge endpoint) served
12106
12907
  * confident nonsense from it.
12107
12908
  *
12108
12909
  * `writeFileAtomic` writes to a temp sibling, then RENAMES. On the same
@@ -12118,6 +12919,10 @@ function atomicTempPath(path) {
12118
12919
  }
12119
12920
  /**
12120
12921
  * Atomically replace `path` with `data`.
12922
+ *
12923
+ * Accepts binary payloads as well as text: base-tree materialization in
12924
+ * `impact` writes git blobs, and a writer that only takes strings forces those
12925
+ * call sites back to the non-atomic path rather than keeping them contained.
12121
12926
  */
12122
12927
  function writeFileAtomic(path, data, opts = {}) {
12123
12928
  const dir = dirname(path);
@@ -12126,7 +12931,8 @@ function writeFileAtomic(path, data, opts = {}) {
12126
12931
  let fd;
12127
12932
  try {
12128
12933
  fd = openSync(tmp, "wx", opts.mode ?? 420);
12129
- writeSync(fd, data, null, opts.encoding ?? "utf8");
12934
+ if (typeof data === "string") writeSync(fd, data, null, opts.encoding ?? "utf8");
12935
+ else writeSync(fd, data);
12130
12936
  } finally {
12131
12937
  if (fd !== void 0) closeSync(fd);
12132
12938
  }
@@ -12252,304 +13058,138 @@ function hashRulesSourceTree(rules) {
12252
13058
  try {
12253
13059
  const hash = createHash("sha256");
12254
13060
  hashDir(rulesDir, hash, 0);
12255
- return hash.digest("hex").slice(0, 16);
12256
- } catch {
12257
- return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
12258
- }
12259
- }
12260
- function hashDir(dir, hash, depth) {
12261
- if (depth > 8) return;
12262
- let entries;
12263
- try {
12264
- entries = readdirSync(dir, { withFileTypes: true });
12265
- } catch {
12266
- return;
12267
- }
12268
- entries.sort((a, b) => a.name.localeCompare(b.name));
12269
- for (const entry of entries) {
12270
- if (entry.name === "node_modules" || entry.name === ".git") continue;
12271
- const full = join(dir, entry.name);
12272
- if (entry.isDirectory()) hashDir(full, hash, depth + 1);
12273
- else if (entry.isFile() && /\.tsx?$/.test(entry.name)) try {
12274
- hash.update(entry.name);
12275
- hash.update(readFileSync(full));
12276
- } catch {}
12277
- }
12278
- }
12279
- /**
12280
- * Content-addressed key for one file's rule-loop verdicts.
12281
- *
12282
- * Audit C1: the key MUST identify the verdict's producer, not just the
12283
- * bytes — two files with byte-identical text (a copied spec, a generated
12284
- * snapshot) previously shared one entry, and the first file's cached
12285
- * findings were re-emitted for the second with the wrong `file` stamp.
12286
- * The key therefore folds in the repo-relative path AND the adapter id,
12287
- * plus a parse-mode token (audit W9): a file whose analysis degraded to
12288
- * the regex fallback (or skipped the AST path) must not collide with a
12289
- * fully-AST-analyzed verdict for the same bytes — the fallback output
12290
- * belongs only to the fallback mode.
12291
- */
12292
- function fileCacheKey(rulesDigest, fileText, identity) {
12293
- const parseMode = identity.parseMode ?? "ast";
12294
- return sha256(`${CACHE_VERSION}\u0000${rulesDigest}\u0000${identity.relPath}\u0000${identity.adapterId}\u0000${parseMode}\u0000${fileText}`);
12295
- }
12296
- /** No-op cache used when --cache is absent: zero stats, zero I/O. */
12297
- const disabledScanCache = {
12298
- stats: {
12299
- hits: 0,
12300
- misses: 0,
12301
- file: ""
12302
- },
12303
- lookup: () => void 0,
12304
- store: () => {},
12305
- persist: () => {}
12306
- };
12307
- /**
12308
- * Opens (and lazily creates) `<root>/.mjolnir/cache/scan-v<CACHE_VERSION>.json`. A
12309
- * corrupt, hostile or future-versioned cache file degrades to a cold
12310
- * cache — never fails the scan.
12311
- */
12312
- function createScanCache(root) {
12313
- const dir = join(root, ".mjolnir", "cache");
12314
- const file = join(dir, `scan-v${CACHE_VERSION}.json`);
12315
- let entries = {};
12316
- let dirty = false;
12317
- const entryBytes = /* @__PURE__ */ new Map();
12318
- let totalBytes = 0;
12319
- try {
12320
- if (existsSync(file)) {
12321
- const stat = lstatSync(file);
12322
- if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_TOTAL_BYTES) throw new Error("invalid cache file");
12323
- entries = parseJsonFile(readFileSync(file, "utf8"), file, isCacheFile).entries;
12324
- for (const [k, v] of Object.entries(entries)) {
12325
- const size = Buffer.byteLength(JSON.stringify(v), "utf8") + k.length + 4;
12326
- entryBytes.set(k, size);
12327
- totalBytes += size;
12328
- }
12329
- if (totalBytes > MAX_TOTAL_BYTES) throw new Error("cache byte budget exceeded");
12330
- }
12331
- } catch {
12332
- entries = {};
12333
- entryBytes.clear();
12334
- totalBytes = 0;
12335
- }
12336
- return {
12337
- stats: {
12338
- hits: 0,
12339
- misses: 0,
12340
- file
12341
- },
12342
- lookup(key) {
12343
- const entry = entries[key];
12344
- if (!entry) {
12345
- this.stats.misses++;
12346
- return;
12347
- }
12348
- this.stats.hits++;
12349
- delete entries[key];
12350
- entries[key] = entry;
12351
- return structuredClone(entry.findings);
12352
- },
12353
- store(key, findings, fileBudgetExceeded) {
12354
- if (fileBudgetExceeded) return;
12355
- if (findings.length > MAX_FINDINGS_PER_ENTRY) return;
12356
- const entryJson = JSON.stringify(findings);
12357
- const newBytes = Buffer.byteLength(entryJson, "utf8") + key.length + 4;
12358
- if (newBytes > MAX_TOTAL_BYTES) return;
12359
- const replacedBytes = entryBytes.get(key) ?? 0;
12360
- delete entries[key];
12361
- entries[key] = { findings: structuredClone(findings) };
12362
- entryBytes.set(key, newBytes);
12363
- totalBytes = totalBytes - replacedBytes + newBytes;
12364
- let count = Object.keys(entries).length;
12365
- while ((count > MAX_ENTRIES$1 || totalBytes > MAX_TOTAL_BYTES) && count > 1) {
12366
- const oldest = Object.keys(entries)[0];
12367
- totalBytes -= entryBytes.get(oldest);
12368
- delete entries[oldest];
12369
- entryBytes.delete(oldest);
12370
- count--;
12371
- }
12372
- dirty = true;
12373
- },
12374
- persist() {
12375
- if (!dirty) return;
12376
- try {
12377
- mkdirSync(dir, { recursive: true });
12378
- writeFileAtomic(file, JSON.stringify({
12379
- version: CACHE_VERSION,
12380
- entries
12381
- }), { encoding: "utf8" });
12382
- } catch {}
12383
- }
12384
- };
12385
- }
12386
- //#endregion
12387
- //#region src/brand/tokens.ts
12388
- /**
12389
- * The single source of brand truth.
12390
- *
12391
- * Every colour, typeface and motion constant Mjölnir shows a human —
12392
- * terminal, README SVGs, demo video, website, docs, badges — resolves to
12393
- * a value in this file. Nothing else may define one.
12394
- *
12395
- * WHY THIS EXISTS. Before it, the palette existed in six independent
12396
- * copies: `site/.vitepress/theme/styles/vars.css`, `NORSE` in
12397
- * `src/reporter/theme.ts`, `scripts/readme-svg.ts`,
12398
- * `scripts/video/terminal-page.ts`, `scripts/generate-readme-architecture.ts`
12399
- * and the table in `assets/brand/README.md`. Exactly one pair of those
12400
- * was guarded (site-doctor Check 8, doc ↔ vars.css). The unguarded edges
12401
- * are where the shipped surfaces drifted apart: the terminal and the site
12402
- * disagreed on six semantic roles, the architecture diagram invented its
12403
- * own neutral ramp, and the README badges still carried a palette retired
12404
- * two releases earlier. `scripts/brand-doctor.mjs` now checks every edge
12405
- * against this file.
12406
- *
12407
- * PURITY. Pure data. No I/O, no rendering, no environment access, no
12408
- * imports, no logic. Consumers convert (hex → ANSI triplet, hex → CSS)
12409
- * themselves. Same reason `score-state.ts` is pure: it makes the whole
12410
- * thing golden-testable and safe to ship inside the npm package, where
12411
- * it costs a few hundred bytes and replaces values the package already
12412
- * carried anyway.
12413
- *
12414
- * DERIVATION. The palette is the one derived from the logo in PR #20
12415
- * (brushed steel and forge gold under an aurora, over midnight iron).
12416
- * Where the terminal disagreed with it, the terminal converges — see
12417
- * `PENDING_TERMINAL` below. Full rationale: `assets/brand/README.md`.
12418
- *
12419
- * ACCESSIBILITY. Every foreground token in `BRAND` meets WCAG AA
12420
- * (≥ 4.5:1) against every surface token it is allowed to sit on. That is
12421
- * not a claim, it is `brand-doctor` rule 8, which computes the ratios.
12422
- * The weakest legal pairing is `steelDim` on `ink800` at 5.00:1.
12423
- */
12424
- /**
12425
- * The two brand hues plus the neutral they sit on.
12426
- *
12427
- * GOLD IS SCARCE. It means forged / certified / earned / decisive — the
12428
- * primary mark, the FORGED state, one call to action. It is not a paint
12429
- * bucket: gold as default text, default border or default heading is a
12430
- * brand-doctor finding, not a style choice.
12431
- *
12432
- * AURORA is verification energy — the secondary, and the hue that marks
12433
- * the runtime half of the trust ladder.
12434
- */
12435
- const BRAND = {
12436
- gold: "#C19A34",
12437
- goldBright: "#E6BD57",
12438
- goldHot: "#F4DC9C",
12439
- /** Pressed / deepest gold — the only step dark enough to carry white. */
12440
- goldDeep: "#A5811C",
12441
- aurora: "#37ABBD",
12442
- auroraBright: "#45C1D4",
12443
- auroraCyan: "#5CBDE0",
12444
- /** The aurora's outer curtains: atmosphere and section identity only,
12445
- * never a verdict or a status. */
12446
- auroraGreen: "#5FD6A4",
12447
- auroraViolet: "#9D8CF5",
12448
- steel: "#C8CBCF",
12449
- steelDim: "#8B939D"
12450
- };
12451
- /**
12452
- * Midnight iron. One ramp, four steps, darkest first.
12453
- *
12454
- * `terminal` and `terminalBar` share one tone deliberately: the window's
12455
- * only seam is a hairline ring and an inset shadow, never a second fill.
12456
- * `chromeDot` is the three window dots — see the note on
12457
- * `PENDING_TERMINAL.chromeDots` for why they are no longer red/amber/green.
12458
- */
12459
- const SURFACE = {
12460
- ink950: "#0A1119",
12461
- ink900: "#0C1420",
12462
- ink850: "#111A29",
12463
- ink800: "#18243A",
12464
- /** Raised panel (cards, elevated surfaces). */
12465
- panel: "#141F33",
12466
- /** Soft fill (inline code, quiet chips). */
12467
- soft: "#1A2740",
12468
- /** Terminal body — the deepest tone, so a terminal reads as recessed. */
12469
- terminal: "#0A1119",
12470
- /** Terminal title bar — the same tone; the seam is shadow, not colour. */
12471
- terminalBar: "#0A1119",
12472
- /** The three window dots. One neutral, not a traffic light. */
12473
- chromeDot: "#18243A"
12474
- };
12475
- const TEXT = {
12476
- primary: "#EAEEF5",
12477
- secondary: "#ABB6C6",
12478
- muted: "#8B939D",
12479
- /** Ink for text set ON gold (buttons, the FORGED chip). 7.17:1 on `gold`. */
12480
- onGold: "#0A1119"
12481
- };
12482
- /**
12483
- * Non-score status. `ok` is the one green in the system and it is NOT a
12484
- * score colour — it survives only for contexts with no worthiness
12485
- * meaning ("autofix applied", "analysis complete"). A green score would
12486
- * say "your software is fine", which is the exact claim this product
12487
- * refuses to make.
12488
- */
12489
- const STATUS = {
12490
- ok: "#4FB477",
12491
- info: "#5CC4E0",
12492
- warning: "#E6BD57",
12493
- error: "#EC6B66"
12494
- };
12495
- /**
12496
- * The four ScoreState bands plus the unmeasured state. Band thresholds
12497
- * and runes live in `src/reporter/score-state.ts`, which stays free of
12498
- * colour — it emits a palette KEY and each surface resolves it here.
12499
- *
12500
- * `unmeasured` is steel-dim on purpose. UNKNOWN is a legitimate answer,
12501
- * not a failure: colouring it red would make "we did not measure this"
12502
- * look like "this is broken", which is precisely the dishonesty the
12503
- * north-star law exists to prevent.
12504
- */
12505
- const SCORE = {
12506
- critical: "#EC6B66",
12507
- warning: "#E6BD57",
12508
- trusted: "#5CC4E0",
12509
- forged: "#F4DC9C",
12510
- unmeasured: "#8B939D"
12511
- };
13061
+ return hash.digest("hex").slice(0, 16);
13062
+ } catch {
13063
+ return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
13064
+ }
13065
+ }
13066
+ function hashDir(dir, hash, depth) {
13067
+ if (depth > 8) return;
13068
+ let entries;
13069
+ try {
13070
+ entries = readdirSync(dir, { withFileTypes: true });
13071
+ } catch {
13072
+ recordDegradation("rules-tree-listing-unreadable");
13073
+ return;
13074
+ }
13075
+ entries.sort((a, b) => compareCodePoints(a.name, b.name));
13076
+ for (const entry of entries) {
13077
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
13078
+ const full = join(dir, entry.name);
13079
+ if (entry.isDirectory()) hashDir(full, hash, depth + 1);
13080
+ else if (entry.isFile() && /\.tsx?$/.test(entry.name)) try {
13081
+ hash.update(entry.name);
13082
+ hash.update(readFileSync(full));
13083
+ } catch {}
13084
+ }
13085
+ }
12512
13086
  /**
12513
- * E0 → E1 → E2 is a certainty ramp, and it is deliberately HUE-FREE.
12514
- *
12515
- * Evidence level says how sure we are, not whether the news is good. A
12516
- * deterministic proof (E2) is a defect we are certain about — painting
12517
- * it gold or green would read as an achievement. So certainty is carried
12518
- * by brightness alone, and the *shape* does the real work:
12519
- *
12520
- * E0 open ring observation, no weight
12521
- * E1 half-filled pattern evidence, half weight
12522
- * E2 sealed deterministic proof, full weight
13087
+ * Content-addressed key for one file's rule-loop verdicts.
12523
13088
  *
12524
- * Colour never carries this alone (R11): the geometry is the signal and
12525
- * survives `--ascii`, `NO_COLOR` and monochrome print.
13089
+ * Audit C1: the key MUST identify the verdict's producer, not just the
13090
+ * bytes — two files with byte-identical text (a copied spec, a generated
13091
+ * snapshot) previously shared one entry, and the first file's cached
13092
+ * findings were re-emitted for the second with the wrong `file` stamp.
13093
+ * The key therefore folds in the repo-relative path AND the adapter id,
13094
+ * plus a parse-mode token (audit W9): a file whose analysis degraded to
13095
+ * the regex fallback (or skipped the AST path) must not collide with a
13096
+ * fully-AST-analyzed verdict for the same bytes — the fallback output
13097
+ * belongs only to the fallback mode.
12526
13098
  */
12527
- const EVIDENCE = {
12528
- e0: "#8B939D",
12529
- e1: "#ABB6C6",
12530
- e2: "#EAEEF5"
13099
+ function fileCacheKey(rulesDigest, fileText, identity) {
13100
+ const parseMode = identity.parseMode ?? "ast";
13101
+ return sha256(`${CACHE_VERSION}\u0000${rulesDigest}\u0000${identity.relPath}\u0000${identity.adapterId}\u0000${parseMode}\u0000${fileText}`);
13102
+ }
13103
+ /** No-op cache used when --cache is absent: zero stats, zero I/O. */
13104
+ const disabledScanCache = {
13105
+ stats: {
13106
+ hits: 0,
13107
+ misses: 0,
13108
+ file: ""
13109
+ },
13110
+ lookup: () => void 0,
13111
+ store: () => {},
13112
+ persist: () => {}
12531
13113
  };
12532
13114
  /**
12533
- * L0–L5, and the most important boundary in the product.
12534
- *
12535
- * L0–L2 are STATIC: the neutral steel ramp, brightening to the static
12536
- * ceiling at L2. L3–L5 require a real run, and the hue changes to aurora
12537
- * exactly there. The boundary is a hue break, not a gradient step,
12538
- * because it is a change of kind and not of degree — a static-only
12539
- * finding can never climb past L2, however confident it is.
12540
- *
12541
- * Every surface that draws the ladder must draw that break.
13115
+ * Opens (and lazily creates) `<root>/.mjolnir/cache/scan-v<CACHE_VERSION>.json`. A
13116
+ * corrupt, hostile or future-versioned cache file degrades to a cold
13117
+ * cache — never fails the scan.
12542
13118
  */
12543
- const TRUST = {
12544
- l0: "#8B939D",
12545
- l1: "#ABB6C6",
12546
- l2: "#C8CBCF",
12547
- l3: "#37ABBD",
12548
- l4: "#45C1D4",
12549
- l5: "#5CC4E0"
12550
- };
12551
- 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;
12552
- 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;
13119
+ function createScanCache(root) {
13120
+ const dir = join(root, ".mjolnir", "cache");
13121
+ const file = join(dir, `scan-v${CACHE_VERSION}.json`);
13122
+ let entries = {};
13123
+ let dirty = false;
13124
+ const entryBytes = /* @__PURE__ */ new Map();
13125
+ let totalBytes = 0;
13126
+ try {
13127
+ if (existsSync(file)) {
13128
+ const stat = lstatSync(file);
13129
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_TOTAL_BYTES) throw new Error("invalid cache file");
13130
+ entries = parseJsonFile(readFileSync(file, "utf8"), file, isCacheFile).entries;
13131
+ for (const [k, v] of Object.entries(entries)) {
13132
+ const size = Buffer.byteLength(JSON.stringify(v), "utf8") + k.length + 4;
13133
+ entryBytes.set(k, size);
13134
+ totalBytes += size;
13135
+ }
13136
+ if (totalBytes > MAX_TOTAL_BYTES) throw new Error("cache byte budget exceeded");
13137
+ }
13138
+ } catch {
13139
+ entries = {};
13140
+ entryBytes.clear();
13141
+ totalBytes = 0;
13142
+ }
13143
+ return {
13144
+ stats: {
13145
+ hits: 0,
13146
+ misses: 0,
13147
+ file
13148
+ },
13149
+ lookup(key) {
13150
+ const entry = entries[key];
13151
+ if (!entry) {
13152
+ this.stats.misses++;
13153
+ return;
13154
+ }
13155
+ this.stats.hits++;
13156
+ delete entries[key];
13157
+ entries[key] = entry;
13158
+ return structuredClone(entry.findings);
13159
+ },
13160
+ store(key, findings, fileBudgetExceeded) {
13161
+ if (fileBudgetExceeded) return;
13162
+ if (findings.length > MAX_FINDINGS_PER_ENTRY) return;
13163
+ const entryJson = JSON.stringify(findings);
13164
+ const newBytes = Buffer.byteLength(entryJson, "utf8") + key.length + 4;
13165
+ if (newBytes > MAX_TOTAL_BYTES) return;
13166
+ const replacedBytes = entryBytes.get(key) ?? 0;
13167
+ delete entries[key];
13168
+ entries[key] = { findings: structuredClone(findings) };
13169
+ entryBytes.set(key, newBytes);
13170
+ totalBytes = totalBytes - replacedBytes + newBytes;
13171
+ let count = Object.keys(entries).length;
13172
+ while ((count > MAX_ENTRIES$1 || totalBytes > MAX_TOTAL_BYTES) && count > 1) {
13173
+ const oldest = Object.keys(entries)[0];
13174
+ totalBytes -= entryBytes.get(oldest);
13175
+ delete entries[oldest];
13176
+ entryBytes.delete(oldest);
13177
+ count--;
13178
+ }
13179
+ dirty = true;
13180
+ },
13181
+ persist() {
13182
+ if (!dirty) return;
13183
+ try {
13184
+ mkdirSync(dir, { recursive: true });
13185
+ writeFileAtomic(file, JSON.stringify({
13186
+ version: CACHE_VERSION,
13187
+ entries
13188
+ }), { encoding: "utf8" });
13189
+ } catch {}
13190
+ }
13191
+ };
13192
+ }
12553
13193
  //#endregion
12554
13194
  //#region src/reporter/theme.ts
12555
13195
  /**
@@ -12565,8 +13205,8 @@ SCORE.trusted, SCORE.forged, SCORE.warning, SCORE.critical, STATUS.info, TEXT.on
12565
13205
  *
12566
13206
  * Symbols always accompany color (color-blind safe, R11).
12567
13207
  *
12568
- * Score-state colors come from ScoreState (score-state.ts) — the single
12569
- * source of truth shared with the badge and (P2) the web.
13208
+ * Score-state colors come from ScoreState (presentation.ts) — the single
13209
+ * source of truth shared with the badge, the dashboard and the site.
12570
13210
  *
12571
13211
  * Terminal robustness (Master-Stabilization-Plan Sprint 5 Task 22):
12572
13212
  * box-drawing/gauge helpers accept an explicit width so callers can
@@ -13729,7 +14369,7 @@ function runForensics(target, options = {}) {
13729
14369
  const base = stat.isFile() ? dirname(target) : target;
13730
14370
  flakyMdPath = join(base, "FLAKY.md");
13731
14371
  try {
13732
- writeFileSync(flakyMdPath, renderFlakyMd(report));
14372
+ writeFileAtomic(flakyMdPath, renderFlakyMd(report));
13733
14373
  } catch {
13734
14374
  flakyMdPath = void 0;
13735
14375
  }
@@ -13831,11 +14471,11 @@ function listFiles(dir) {
13831
14471
  return out.map((p) => p);
13832
14472
  }
13833
14473
  plainContext();
13834
- function loadSuppressions(root) {
14474
+ function loadSuppressions(root, now = /* @__PURE__ */ new Date()) {
13835
14475
  const { config } = loadConfig(root);
13836
14476
  const entries = (config.ignore ?? []).map((ign) => ({
13837
14477
  ...ign,
13838
- status: isSuppressionActive(ign, /* @__PURE__ */ new Date()) ? "active" : "expired"
14478
+ status: isSuppressionActive(ign, now) ? "active" : "expired"
13839
14479
  }));
13840
14480
  return {
13841
14481
  total: entries.length,
@@ -14568,6 +15208,7 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14568
15208
  let rulesPartial = false;
14569
15209
  let parseFailed = 0;
14570
15210
  let parseFallbacks = 0;
15211
+ let astFallbackFiles = 0;
14571
15212
  let scanned = 0;
14572
15213
  let analyzed = 0;
14573
15214
  for (const path of testFiles) {
@@ -14593,8 +15234,6 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14593
15234
  skippedFiles++;
14594
15235
  continue;
14595
15236
  }
14596
- let fileBudgetExceeded = false;
14597
- let fileRuleFailed = false;
14598
15237
  const relPath = relative(workspace.root, path).replaceAll("\\", "/");
14599
15238
  if (!isCiAdapter) {
14600
15239
  const lang = {
@@ -14613,6 +15252,10 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14613
15252
  });
14614
15253
  }
14615
15254
  const wantsAst = adapter.parseAst !== void 0 && Date.now() <= deadline;
15255
+ if (adapter.parseAst !== void 0 && !wantsAst) {
15256
+ truncationReasons.add("ast-budget-fallback");
15257
+ astFallbackFiles++;
15258
+ }
14616
15259
  const identity = (mode) => ({
14617
15260
  relPath,
14618
15261
  adapterId: adapter.id,
@@ -14620,79 +15263,106 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14620
15263
  });
14621
15264
  let cacheKey = fileCacheKey(rulesDigest, text, identity(wantsAst ? "ast" : "regex"));
14622
15265
  const cachedFindings = cache.lookup(cacheKey);
14623
- if (cachedFindings) {
14624
- for (const f of cachedFindings) findings.push(f);
14625
- analyzed++;
14626
- continue;
14627
- }
14628
- hooks.onProgress?.({
14629
- phase: "parse",
14630
- done: scanned,
14631
- total: testFiles.length,
14632
- detail: relPath
14633
- });
14634
- const parsedFile = {
14635
- path: relPath,
14636
- text
14637
- };
14638
- let parsed;
14639
- const findingsStart = findings.length;
14640
- try {
14641
- if (adapter.parseAst && wantsAst) {
14642
- hooks.onProgress?.({
14643
- phase: "rules",
14644
- done: scanned,
14645
- total: testFiles.length,
14646
- detail: relPath
14647
- });
14648
- parsed = await adapter.parseAst(parsedFile);
14649
- }
14650
- const actualMode = parsed ? "ast" : "regex";
14651
- if (wantsAst && actualMode === "regex") {
14652
- parseFallbacks++;
14653
- cacheKey = fileCacheKey(rulesDigest, text, identity(actualMode));
14654
- const fallbackFindings = cache.lookup(cacheKey);
14655
- if (fallbackFindings) {
14656
- for (const f of fallbackFindings) findings.push(f);
14657
- analyzed++;
14658
- continue;
15266
+ const executor = createExecutor("sequential", 1, async (job) => {
15267
+ if (job.cacheHit !== void 0) return {
15268
+ path: job.path,
15269
+ status: "CACHE_HIT",
15270
+ findings: [...job.cacheHit],
15271
+ parseFallback: false
15272
+ };
15273
+ let parseFallback = false;
15274
+ let fileRuleFailed = false;
15275
+ let fileBudgetExceeded = false;
15276
+ let parsedAst;
15277
+ try {
15278
+ if (adapter.parseAst && job.wantsAst) {
15279
+ hooks.onProgress?.({
15280
+ phase: "rules",
15281
+ done: scanned,
15282
+ total: testFiles.length,
15283
+ detail: job.path
15284
+ });
15285
+ parsedAst = await adapter.parseAst({
15286
+ path: job.path,
15287
+ text: job.text
15288
+ });
14659
15289
  }
14660
- }
14661
- const fileForRules = parsed ? {
14662
- ...parsedFile,
14663
- ast: parsed.ast
14664
- } : parsedFile;
14665
- adapter.runRules(activeRules, fileForRules, (f, ruleId, category) => {
14666
- if (!isValidFindingRecord(f)) {
14667
- fileRuleFailed = true;
14668
- onRuleCrash?.(ruleId, relPath, /* @__PURE__ */ new Error(`malformed finding record rejected (severity/line/message must be present, severity ∈ error|warning|info): ${JSON.stringify(f)}`));
14669
- return;
15290
+ const actualMode = parsedAst ? "ast" : "regex";
15291
+ if (job.wantsAst && actualMode === "regex") {
15292
+ parseFallback = true;
15293
+ parseFallbacks++;
15294
+ cacheKey = fileCacheKey(rulesDigest, job.text, identity(actualMode));
15295
+ const fallbackFindings = cache.lookup(cacheKey);
15296
+ if (fallbackFindings) return {
15297
+ path: job.path,
15298
+ status: "CACHE_HIT",
15299
+ findings: [...fallbackFindings],
15300
+ parseFallback: true
15301
+ };
14670
15302
  }
14671
- findings.push({
14672
- ...f,
14673
- ruleId,
14674
- category
15303
+ const fileForRules = parsedAst ? {
15304
+ path: job.path,
15305
+ text: job.text,
15306
+ ast: parsedAst.ast
15307
+ } : {
15308
+ path: job.path,
15309
+ text: job.text
15310
+ };
15311
+ const produced = [];
15312
+ adapter.runRules(activeRules, fileForRules, (f, ruleId, category) => {
15313
+ if (!isValidFindingRecord(f)) {
15314
+ fileRuleFailed = true;
15315
+ onRuleCrash?.(ruleId, job.path, /* @__PURE__ */ new Error(`malformed finding record rejected (severity/line/message must be present, severity ∈ error|warning|info): ${JSON.stringify(f)}`));
15316
+ return;
15317
+ }
15318
+ produced.push({
15319
+ ...f,
15320
+ ruleId,
15321
+ category
15322
+ });
15323
+ }, (ruleId, error) => {
15324
+ fileRuleFailed = true;
15325
+ onRuleCrash?.(ruleId, job.path, error);
15326
+ }, {
15327
+ deadline: Math.min(deadline, Date.now() + LIMITS$1.maxFileAnalysisMs),
15328
+ onExceeded: () => {
15329
+ rulesPartial = true;
15330
+ skippedFiles++;
15331
+ truncationReasons.add("file-budget");
15332
+ fileBudgetExceeded = true;
15333
+ }
14675
15334
  });
14676
- }, (ruleId, error) => {
14677
- fileRuleFailed = true;
14678
- onRuleCrash?.(ruleId, relPath, error);
14679
- }, {
14680
- deadline: Math.min(deadline, Date.now() + LIMITS$1.maxFileAnalysisMs),
14681
- onExceeded: () => {
14682
- rulesPartial = true;
14683
- skippedFiles++;
14684
- truncationReasons.add("file-budget");
14685
- fileBudgetExceeded = true;
14686
- }
14687
- });
14688
- if (!fileRuleFailed && !fileBudgetExceeded) analyzed++;
14689
- if (!fileRuleFailed) cache.store(cacheKey, findings.slice(findingsStart), fileBudgetExceeded);
14690
- } catch {
14691
- if (wantsAst) parseFallbacks++;
14692
- skippedFiles++;
14693
- parseFailed++;
14694
- } finally {
14695
- parsed?.dispose();
15335
+ if (!fileRuleFailed && !fileBudgetExceeded) analyzed++;
15336
+ if (!fileRuleFailed) cache.store(cacheKey, produced, fileBudgetExceeded);
15337
+ return {
15338
+ path: job.path,
15339
+ status: "OK",
15340
+ findings: produced,
15341
+ parseFallback
15342
+ };
15343
+ } catch {
15344
+ if (job.wantsAst) parseFallbacks++;
15345
+ skippedFiles++;
15346
+ parseFailed++;
15347
+ return {
15348
+ path: job.path,
15349
+ status: "FAILED",
15350
+ findings: [],
15351
+ parseFallback
15352
+ };
15353
+ } finally {
15354
+ parsedAst?.dispose();
15355
+ }
15356
+ });
15357
+ const outcome = (await executeFiles([{
15358
+ path: relPath,
15359
+ text,
15360
+ wantsAst,
15361
+ ...cachedFindings !== void 0 ? { cacheHit: cachedFindings } : {}
15362
+ }], executor))[0];
15363
+ if (outcome?.status === "CACHE_HIT" || outcome?.status === "OK") {
15364
+ for (const f of outcome.findings) findings.push(f);
15365
+ if (cachedFindings !== void 0 && outcome.status === "CACHE_HIT") analyzed++;
14696
15366
  }
14697
15367
  }
14698
15368
  return {
@@ -14703,7 +15373,8 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14703
15373
  parseFailed,
14704
15374
  parseFallbacks,
14705
15375
  scanned,
14706
- analyzed
15376
+ analyzed,
15377
+ astFallbackFiles
14707
15378
  };
14708
15379
  }
14709
15380
  /**
@@ -14783,12 +15454,16 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
14783
15454
  const runtimeReportPath = discoveredReport?.path;
14784
15455
  const runtimeIncomplete = discoveredReport !== void 0 && discoveredReport.report.analysisComplete !== true;
14785
15456
  let forensicVerdicts;
15457
+ let evidenceRecords = [];
14786
15458
  if (discoveredReport && discoveredReport.report.analysisComplete === true) try {
14787
- buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
15459
+ evidenceRecords = buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
14788
15460
  stampRuntimeCorroboration(findings, discoveredReport.report, workspace.root);
14789
15461
  forensicVerdicts = summarizeForensicVerdicts(discoveredReport.report);
14790
- } catch {}
15462
+ } catch {
15463
+ evidenceRecords = [];
15464
+ }
14791
15465
  return {
15466
+ evidenceRecords,
14792
15467
  testDeclarationCount,
14793
15468
  scopeInfo,
14794
15469
  suppressionCount,
@@ -14927,10 +15602,14 @@ function assembleScanResult(o) {
14927
15602
  scopeUnrecognized: o.scopeUnrecognized,
14928
15603
  parseFailed: o.parseFailed,
14929
15604
  parseFallbacks: o.parseFallbacks ?? 0,
15605
+ ...o.astFallbackFiles !== void 0 ? { astFallbackFiles: o.astFallbackFiles } : {},
15606
+ ...o.degradations !== void 0 ? { degradations: o.degradations } : {},
14930
15607
  ...o.scopeInfo.degraded !== void 0 ? { scopeDegraded: o.scopeInfo.degraded } : {},
14931
15608
  ...o.runtimeIncomplete !== void 0 ? { runtimeIncomplete: o.runtimeIncomplete } : {},
14932
15609
  identityIncomplete
14933
15610
  });
15611
+ const repository = bindRepository(o.scanRoot.root);
15612
+ const candidate = readCandidateBinding(o.scanRoot.root);
14934
15613
  const runIdentity = buildRunIdentity({
14935
15614
  files: inputSnapshot,
14936
15615
  rules: [...o.REVISION_BY_RULE_ID.entries()].map(([id, detectorRevision]) => ({
@@ -14943,9 +15622,17 @@ function assembleScanResult(o) {
14943
15622
  trustModelVersion: TRUST_MODEL_VERSION,
14944
15623
  scoringModelVersion: SCORING_MODEL_VERSION,
14945
15624
  frameworkSupportMatrixVersion: FRAMEWORK_SUPPORT_MATRIX_VERSION,
14946
- evidenceSchemaVersions: [1]
15625
+ evidenceSchemaVersions: [1],
15626
+ ...repository,
15627
+ ...candidate !== null ? { candidate } : {}
15628
+ });
15629
+ const evidenceGraph = buildEvidenceGraph({
15630
+ runId: runIdentity,
15631
+ ...candidate !== null ? { candidate: {
15632
+ manifestId: candidate.manifestId,
15633
+ candidateSha: candidate.candidateSha
15634
+ } } : {}
14947
15635
  });
14948
- const evidenceGraph = buildEvidenceGraph({ runId: runIdentity });
14949
15636
  const hasTests = o.testFileCount > 0 && o.testDeclarationCount > 0;
14950
15637
  const suiteInvalidatedBy = [...new Set(o.findings.filter((f) => SUITE_INVALIDATING_RULE_IDS.has(f.ruleId)).map((f) => f.ruleId))].sort();
14951
15638
  const finalScore = hasTests ? completion.partial && scopeAdjustedTotal >= 100 ? 99 : scopeAdjustedTotal : null;
@@ -14955,6 +15642,11 @@ function assembleScanResult(o) {
14955
15642
  scopeIntegrity,
14956
15643
  runIdentity,
14957
15644
  evidenceGraph,
15645
+ evidence: {
15646
+ records: o.evidenceRecords ?? [],
15647
+ counts: countEvidence(o.evidenceRecords ?? []),
15648
+ artifact: o.runtimeReportPath ?? null
15649
+ },
14958
15650
  score: finalScore,
14959
15651
  ...hasTests ? {} : { reason: "no-tests-found" },
14960
15652
  frameworks: o.frameworks.frameworks,
@@ -15030,13 +15722,15 @@ function assembleScanResult(o) {
15030
15722
  */
15031
15723
  async function runScan(args, hooks = {}) {
15032
15724
  const started = Date.now();
15725
+ const degradationWindow = beginDegradationWindow();
15033
15726
  const requestedDuration = Number.isFinite(args.maxDurationMs) ? args.maxDurationMs : DEFAULT_MAX_DURATION_MS$1;
15034
15727
  const deadline = started + Math.min(Math.max(1, requestedDuration), MAX_DURATION_MS$1);
15035
15728
  const discovered = discoverWorkspace(args.target);
15036
15729
  const targetAbs = resolve(args.target);
15037
15730
  const scanRoot = discovered && discovered.root !== targetAbs && targetAbs.startsWith(discovered.root + sep) ? {
15038
15731
  ...discovered,
15039
- root: targetAbs
15732
+ root: targetAbs,
15733
+ packageJson: {}
15040
15734
  } : discovered ?? fallbackWorkspace(targetAbs);
15041
15735
  const workspace = scanRoot;
15042
15736
  if (args.verbose) hooks.onConfigWarning?.(`verbose: scan root (config/ignore anchor): ${workspace.root}`);
@@ -15121,6 +15815,7 @@ async function runScan(args, hooks = {}) {
15121
15815
  rulesPartial = analysis.rulesPartial;
15122
15816
  parseFailed = analysis.parseFailed;
15123
15817
  const parseFallbacks = analysis.parseFallbacks;
15818
+ const astFallbackFiles = analysis.astFallbackFiles;
15124
15819
  const scanned = analysis.scanned;
15125
15820
  const analyzed = analysis.analyzed;
15126
15821
  const postScan = applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, declarationsByFile, testDeclarationCount, tierByRuleId, REVISION_BY_RULE_ID);
@@ -15139,6 +15834,8 @@ async function runScan(args, hooks = {}) {
15139
15834
  scopeUnrecognized,
15140
15835
  parseFailed,
15141
15836
  parseFallbacks,
15837
+ astFallbackFiles,
15838
+ degradations: summarizeDegradations(degradationsSince(degradationWindow)),
15142
15839
  scanned,
15143
15840
  analyzed,
15144
15841
  testFiles,
@@ -15165,6 +15862,39 @@ async function runScan(args, hooks = {}) {
15165
15862
  resetTsMorphProject();
15166
15863
  return result;
15167
15864
  }
15865
+ //#endregion
15866
+ //#region src/cli-io.ts
15867
+ /**
15868
+ * The ONE error-to-message derivation in `src/`.
15869
+ *
15870
+ * This tree carried four near-copies: two `errorMessage` (byte-identical) and
15871
+ * two `errorText`. The two `errorText` copies were not identical, which is the
15872
+ * part that mattered — `String(someObject)` renders `[object Object]`, so the
15873
+ * copy that lost the payload turned a hostile non-Error throw into a message
15874
+ * that names nothing. The richer arms are kept here, so every call site gets
15875
+ * the informative one and the difference stops being a per-file accident.
15876
+ *
15877
+ * The arms, in order:
15878
+ * Error -> `.message`. The 99% case, and the only one that
15879
+ * carries a stack the caller may still want.
15880
+ * string -> itself. A thrown string is already the message.
15881
+ * plain object -> `JSON.stringify`. A non-Error throw of `{ code:
15882
+ * "EISDIR" }` is a REAL failure shape in Node, and
15883
+ * `[object Object]` hides the only field that says
15884
+ * what went wrong.
15885
+ * anything else -> `String(err)`. Symbols and undefined included, so
15886
+ * nothing vanishes.
15887
+ *
15888
+ * The call this replaces was `err instanceof Error ? err.message : String(err)`
15889
+ * in `src/mcp/server.ts` and `src/commands/doctor.ts`; `tests/cli/summary.spec.ts`
15890
+ * already pins the object arm.
15891
+ */
15892
+ function errorMessage(err) {
15893
+ if (err instanceof Error) return err.message;
15894
+ if (typeof err === "string") return err;
15895
+ if (typeof err === "object" && err !== null) return JSON.stringify(err);
15896
+ return String(err);
15897
+ }
15168
15898
  /**
15169
15899
  * Fields that participate in the verification digest: identity (ruleId,
15170
15900
  * detectorRevision, file, line, column) + evidence (severity,
@@ -15317,6 +16047,11 @@ function explainRule(ruleId, fixturesRoot) {
15317
16047
  ok: false,
15318
16048
  error: `Unknown rule ID "${ruleId}". Run \`mjolnir rules\` for the full catalog.`
15319
16049
  };
16050
+ const window = beginDegradationWindow();
16051
+ const degraded = () => {
16052
+ const counts = summarizeDegradations(degradationsSince(window));
16053
+ return counts.length > 0 ? { exampleDegraded: counts } : {};
16054
+ };
15320
16055
  const fixturePath = firstFixtureFile(join(fixturesRoot, ruleId, "must-fire"));
15321
16056
  if (!fixturePath) return {
15322
16057
  ok: true,
@@ -15326,9 +16061,11 @@ function explainRule(ruleId, fixturesRoot) {
15326
16061
  try {
15327
16062
  text = readFileSync(fixturePath, "utf8").replace(/\r\n/g, "\n");
15328
16063
  } catch {
16064
+ recordDegradation("explain-fixture-unreadable");
15329
16065
  return {
15330
16066
  ok: true,
15331
- rule
16067
+ rule,
16068
+ ...degraded()
15332
16069
  };
15333
16070
  }
15334
16071
  const normalizedPath = fixturePath.replaceAll("\\", "/");
@@ -15338,9 +16075,11 @@ function explainRule(ruleId, fixturesRoot) {
15338
16075
  else try {
15339
16076
  ast = isAzurePipelineFixture(normalizedPath) ? parseAzurePipeline(text) : parseWorkflow(text);
15340
16077
  } catch {
16078
+ recordDegradation("explain-workflow-parse-failed");
15341
16079
  return {
15342
16080
  ok: true,
15343
- rule
16081
+ rule,
16082
+ ...degraded()
15344
16083
  };
15345
16084
  }
15346
16085
  }
@@ -15357,9 +16096,11 @@ function explainRule(ruleId, fixturesRoot) {
15357
16096
  codeText
15358
16097
  });
15359
16098
  } catch {
16099
+ recordDegradation("explain-rule-crash");
15360
16100
  return {
15361
16101
  ok: true,
15362
- rule
16102
+ rule,
16103
+ ...degraded()
15363
16104
  };
15364
16105
  }
15365
16106
  const example = findings[0];
@@ -16120,15 +16861,11 @@ async function handleToolCall(call) {
16120
16861
  id: call.id,
16121
16862
  error: {
16122
16863
  code: MCP_ERRORS.INTERNAL,
16123
- message: errorMessage$1(err)
16864
+ message: errorMessage(err)
16124
16865
  }
16125
16866
  };
16126
16867
  }
16127
16868
  }
16128
- /** Normalize any thrown value to a string (unit-tested both arms). */
16129
- function errorMessage$1(err) {
16130
- return err instanceof Error ? err.message : String(err);
16131
- }
16132
16869
  /** JSON-RPC request shape validation (strict — §21). */
16133
16870
  function validateRequest(msg) {
16134
16871
  if (typeof msg !== "object" || msg === null) return {
@@ -16231,14 +16968,6 @@ async function handleMcpMessage(msg) {
16231
16968
  };
16232
16969
  }
16233
16970
  //#endregion
16234
- //#region src/cli-io.ts
16235
- /**
16236
- * The single error-to-message derivation shared by every catch site:
16237
- * Error instances render their message; anything else is stringified
16238
- * honestly (a hostile non-Error throw must surface, not vanish).
16239
- */
16240
- const errorMessage = (err) => err instanceof Error ? err.message : String(err);
16241
- //#endregion
16242
16971
  //#region src/mcp/transport.ts
16243
16972
  /**
16244
16973
  * MCP stdio transport loop (blueprint §21 — the framing around the
@@ -16337,6 +17066,133 @@ async function handleStdioLine(line, write) {
16337
17066
  write(`${JSON.stringify(response)}\n`);
16338
17067
  }
16339
17068
  //#endregion
17069
+ //#region src/integrations/sentry.ts
17070
+ /**
17071
+ * Sentry crash reporting — opt-in, and inert unless a DSN is configured.
17072
+ *
17073
+ * Why opt-in: Mjölnir ships as an `npx` CLI over other people's private
17074
+ * repositories. A tool that reads a codebase must never phone home on its
17075
+ * own, so the ONLY switch is an explicit `SENTRY_DSN` in the environment.
17076
+ * Absent that, every function here returns immediately and `@sentry/node` is
17077
+ * never imported — zero network, zero added startup cost on a path whose
17078
+ * whole selling point is scan speed.
17079
+ *
17080
+ * Why an optional peer dependency: `@sentry/node` is ~1.5 MB unpacked and
17081
+ * nothing in a code scanner needs it. Making it a hard `dependency` would
17082
+ * tax every install for a feature most users never turn on, so it is an
17083
+ * optional peer: installs on request (`npm i @sentry/node`), absent by
17084
+ * default, and a missing module is handled as "reporting unavailable"
17085
+ * rather than as a crash.
17086
+ *
17087
+ * Capture points are deliberately the two EXISTING top-level catch blocks
17088
+ * (cli.ts entry, mcp/stdio.ts) rather than `process.on("uncaughtException")`
17089
+ * / `("unhandledRejection")` handlers. Registering those would silently
17090
+ * change the CLI's frozen exit-code contract (§24.1) and could keep a
17091
+ * process alive after a fatal error. One capture point per surface is
17092
+ * enough to answer "did Mjölnir itself break, and where".
17093
+ *
17094
+ * What is deliberately NOT sent: no user data (`sendDefaultPii: false`), no
17095
+ * traced request/span data (`tracesSampleRate: 0` — a batch CLI has no
17096
+ * request to trace), no console breadcrumbs, and no scan findings or file
17097
+ * contents. Events carry the error, its stack, the release, and the surface
17098
+ * that raised it.
17099
+ */
17100
+ /** The loaded SDK, or null while reporting is off/unavailable. */
17101
+ let sdk = null;
17102
+ /**
17103
+ * Flush budget for the process-exit paths. Long enough for one HTTPS
17104
+ * envelope on a slow link, short enough that a broken network cannot turn
17105
+ * a crashed CLI into a hung one. Sentry's own default is 2s.
17106
+ */
17107
+ const FLUSH_TIMEOUT_MS = 2e3;
17108
+ /** Writes a diagnostic without touching stdout. */
17109
+ function warn(message) {
17110
+ process.stderr.write(`mjolnir: sentry — ${message}\n`);
17111
+ }
17112
+ /**
17113
+ * The release name Sentry groups events under. Matches the npm package
17114
+ * name so a release created from a tag and a release reported by the SDK
17115
+ * are the same release, not two half-populated ones.
17116
+ */
17117
+ const SENTRY_RELEASE = `mjolnir-qa@${ENGINE_VERSION}`;
17118
+ /**
17119
+ * Resolve the environment tag. Unset means "a developer's machine" unless
17120
+ * CI says otherwise: a `npx` run is not a production event, and filing
17121
+ * those under `production` would poison the issue stream with noise from
17122
+ * end users triaging their own repos.
17123
+ */
17124
+ function resolveEnvironment() {
17125
+ const fromEnv = process.env.SENTRY_ENVIRONMENT?.trim();
17126
+ if (fromEnv) return fromEnv;
17127
+ return process.env.CI ? "ci" : "local";
17128
+ }
17129
+ /**
17130
+ * Turn reporting on if — and only if — a DSN is configured. Safe to call
17131
+ * from every entry point: the second call is a no-op that returns the
17132
+ * already-decided answer, so a process that reaches two surfaces cannot
17133
+ * initialize the SDK twice.
17134
+ *
17135
+ * Returns true when reporting is live. Never throws: a monitoring
17136
+ * integration that can take down the tool it monitors has failed at its
17137
+ * one job.
17138
+ */
17139
+ async function initSentry() {
17140
+ if (sdk) return true;
17141
+ const dsn = process.env.SENTRY_DSN?.trim();
17142
+ if (!dsn) return false;
17143
+ let loaded;
17144
+ try {
17145
+ loaded = await import("@sentry/node");
17146
+ } catch {
17147
+ warn("SENTRY_DSN is set but @sentry/node is not installed. Install it with: npm install @sentry/node");
17148
+ return false;
17149
+ }
17150
+ try {
17151
+ loaded.init({
17152
+ dsn,
17153
+ release: SENTRY_RELEASE,
17154
+ environment: resolveEnvironment(),
17155
+ tracesSampleRate: 0,
17156
+ sendDefaultPii: false,
17157
+ attachStacktrace: true
17158
+ });
17159
+ } catch (error) {
17160
+ warn(`SDK init failed (${error instanceof Error ? error.message : String(error)}). Crash reporting stays off.`);
17161
+ return false;
17162
+ }
17163
+ sdk = loaded;
17164
+ return true;
17165
+ }
17166
+ /**
17167
+ * Report one fatal error. `surface` names the entry point that raised it
17168
+ * ("cli" or "mcp") so the issue stream can be split by how Mjölnir was
17169
+ * being used when it broke. A non-Error throw is reported as-is rather
17170
+ * than dropped: a hostile throw value is exactly the case worth seeing.
17171
+ */
17172
+ function captureInternalError(error, surface) {
17173
+ if (!sdk) return;
17174
+ try {
17175
+ sdk.captureException(error, { tags: { surface } });
17176
+ } catch (captureError) {
17177
+ warn(`capture failed (${captureError instanceof Error ? captureError.message : String(captureError)}).`);
17178
+ }
17179
+ }
17180
+ /**
17181
+ * Deliver anything buffered before the process exits. Awaits the SDK's
17182
+ * own transport drain and returns whether the envelope was handed off;
17183
+ * the caller is on an exit path, so the answer is deliberately not
17184
+ * surfaced as a failure — a lost crash report must not change the exit
17185
+ * code a caller (or CI) is about to read.
17186
+ */
17187
+ async function flushSentry(timeoutMs = FLUSH_TIMEOUT_MS) {
17188
+ if (!sdk) return false;
17189
+ try {
17190
+ return await sdk.flush(timeoutMs);
17191
+ } catch {
17192
+ return false;
17193
+ }
17194
+ }
17195
+ //#endregion
16340
17196
  //#region src/mcp/stdio.ts
16341
17197
  /**
16342
17198
  * The standalone MCP stdio binary (blueprint §21), built to
@@ -16355,9 +17211,14 @@ async function handleStdioLine(line, write) {
16355
17211
  * subprocess coverage cannot merge into the parent istanbul report,
16356
17212
  * same class as dist/**), so it is excluded from the coverage ratchet.
16357
17213
  */
16358
- runStdioTransport(process.stdin, process.stdout).then(() => process.exit(0)).catch((err) => {
17214
+ initSentry();
17215
+ runStdioTransport(process.stdin, process.stdout).then(() => process.exit(0)).catch(async (err) => {
17216
+ captureInternalError(err, "mcp");
17217
+ await flushSentry();
16359
17218
  process.stderr.write(`mjolnir mcp fatal: ${err instanceof Error ? err.message : err}\n`);
16360
17219
  process.exitCode = 20;
16361
17220
  });
16362
17221
  //#endregion
16363
17222
  export {};
17223
+
17224
+ //# sourceMappingURL=stdio.mjs.map