mjolnir-qa 2.1.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,15 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { createInterface } from "node:readline";
3
3
  import { Buffer as Buffer$1 } from "node:buffer";
4
- import { createHash } from "node:crypto";
5
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
+ import { createHash, randomBytes } from "node:crypto";
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)
@@ -56,6 +82,38 @@ const DEDUCTIONS = {
56
82
  info: 1
57
83
  };
58
84
  //#endregion
85
+ //#region src/engine/completion.ts
86
+ function deriveCompletion(input) {
87
+ const truncationReasons = [...new Set(input.truncationReasons)].sort();
88
+ const reasons = /* @__PURE__ */ new Set();
89
+ if (input.discoveryTruncated) reasons.add("discovery-truncated");
90
+ if (input.rulesPartial) reasons.add("rules-partial");
91
+ if (input.skippedFiles > 0) reasons.add(`skipped-files:${input.skippedFiles}`);
92
+ if (input.rulesCrashed > 0) reasons.add(`rules-crashed:${input.rulesCrashed}`);
93
+ if (input.scopeIgnored > 0) reasons.add(`scope-ignored:${input.scopeIgnored}`);
94
+ if (input.scopeUnrecognized > 0) reasons.add(`scope-unrecognized:${input.scopeUnrecognized}`);
95
+ if (input.parseFailed > 0) reasons.add(`parse-failed:${input.parseFailed}`);
96
+ if (input.parseFallbacks && input.parseFallbacks > 0) reasons.add(`parse-fallbacks:${input.parseFallbacks}`);
97
+ if (input.scopeDegraded) reasons.add(`scope-degraded:${input.scopeDegraded}`);
98
+ if (input.runtimeIncomplete) reasons.add("runtime-incomplete");
99
+ if (input.identityIncomplete) reasons.add("identity-incomplete");
100
+ for (const reason of truncationReasons) reasons.add(`truncated:${reason}`);
101
+ const discoveryPartial = input.discoveryTruncated || input.scopeIgnored > 0 || input.scopeUnrecognized > 0 || input.scopeDegraded !== void 0;
102
+ const rulesPartial = input.rulesPartial || input.rulesCrashed > 0 || input.parseFailed > 0;
103
+ return {
104
+ partial: discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0,
105
+ analysisStatus: {
106
+ discovery: discoveryPartial ? "partial" : "complete",
107
+ rules: rulesPartial ? "partial" : "complete",
108
+ skippedFiles: input.skippedFiles,
109
+ rulesCrashed: input.rulesCrashed,
110
+ parseFallbacks: input.parseFallbacks ?? 0,
111
+ ...truncationReasons.length > 0 ? { truncationReasons } : {},
112
+ reasons: [...reasons].sort()
113
+ }
114
+ };
115
+ }
116
+ //#endregion
59
117
  //#region src/engine/runtime-corroboration.ts
60
118
  /**
61
119
  * Stamp runtime corroboration + trust levels onto findings (mutates in
@@ -669,21 +727,13 @@ const MEASURED_FP = {
669
727
  * empty scan is an observation, not a proof).
670
728
  */
671
729
  function summaryTrustLevel(findings) {
672
- const order = [
673
- "L0",
674
- "L1",
675
- "L2",
676
- "L3",
677
- "L4",
678
- "L5"
679
- ];
680
730
  let best = 0;
681
731
  for (const f of findings) {
682
732
  const t = f.trustLevel ?? "L2";
683
- const idx = order.indexOf(t);
733
+ const idx = TRUST_ORDER.indexOf(t);
684
734
  if (idx > best) best = idx;
685
735
  }
686
- return order[best];
736
+ return TRUST_ORDER[best];
687
737
  }
688
738
  /**
689
739
  * A finding's trust rung (0–5) on the canonical ladder. Corroborated
@@ -694,14 +744,7 @@ function summaryTrustLevel(findings) {
694
744
  */
695
745
  function trustRung(f) {
696
746
  const t = f.trustLevel ?? deriveTrustLevel(f);
697
- return [
698
- "L0",
699
- "L1",
700
- "L2",
701
- "L3",
702
- "L4",
703
- "L5"
704
- ].indexOf(t);
747
+ return TRUST_ORDER.indexOf(t);
705
748
  }
706
749
  /**
707
750
  * Hard incompleteness ceilings (plan §6: partial/crash/truncation;
@@ -822,7 +865,10 @@ function sha256$1(text) {
822
865
  return createHash("sha256").update(text).digest("hex");
823
866
  }
824
867
  function canonical(value) {
825
- return JSON.stringify(value);
868
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
869
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
870
+ const record = value;
871
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`;
826
872
  }
827
873
  function buildRunIdentity(input) {
828
874
  const inputFingerprint = sha256$1([...input.files].map((f) => f.hash ? `${f.path}:${f.hash}` : `${f.path}:${f.size}`).sort().join("\n"));
@@ -835,6 +881,17 @@ function buildRunIdentity(input) {
835
881
  engineVersion: input.engineVersion
836
882
  };
837
883
  if (input.reportDigest) verdictInputs.reportDigest = input.reportDigest;
884
+ if (input.commit !== void 0) verdictInputs.commit = input.commit;
885
+ if (input.tree !== void 0) verdictInputs.tree = input.tree;
886
+ if (input.lockfile !== void 0) verdictInputs.lockfile = input.lockfile;
887
+ if (input.candidate !== void 0) verdictInputs.candidate = {
888
+ manifestId: input.candidate.manifestId,
889
+ state: input.candidate.state,
890
+ candidateSha: input.candidate.candidateSha,
891
+ baseSha: input.candidate.baseSha,
892
+ packageSha256: input.candidate.packageSha256,
893
+ lockfileSha256: input.candidate.lockfileSha256
894
+ };
838
895
  if (input.trustModelVersion !== void 0) verdictInputs.trustModelVersion = input.trustModelVersion;
839
896
  if (input.scoringModelVersion !== void 0) verdictInputs.scoringModelVersion = input.scoringModelVersion;
840
897
  if (input.frameworkSupportMatrixVersion !== void 0) verdictInputs.frameworkSupportMatrixVersion = input.frameworkSupportMatrixVersion;
@@ -842,15 +899,31 @@ function buildRunIdentity(input) {
842
899
  if (input.suppressionFingerprint !== void 0) verdictInputs.suppressionFingerprint = input.suppressionFingerprint;
843
900
  if (input.policyFingerprint !== void 0) verdictInputs.policyFingerprint = input.policyFingerprint;
844
901
  if (input.historicalEvidenceFingerprint !== void 0) verdictInputs.historicalEvidenceFingerprint = input.historicalEvidenceFingerprint;
902
+ const scanId = sha256$1(canonical(verdictInputs));
903
+ const boundLinks = [
904
+ "input",
905
+ "rules",
906
+ "config",
907
+ "engine"
908
+ ];
909
+ if (input.commit !== void 0) boundLinks.push("commit");
910
+ if (input.tree !== void 0) boundLinks.push("tree");
911
+ if (input.lockfile !== void 0) boundLinks.push("lockfile");
912
+ if (input.candidate !== void 0) boundLinks.push("candidate");
845
913
  return {
846
- scanId: sha256$1(canonical(verdictInputs)),
914
+ scanId,
847
915
  inputFingerprint,
848
916
  rulesDigest,
849
917
  configFingerprint,
850
918
  engineVersion: input.engineVersion,
919
+ boundLinks,
851
920
  ...input.trustModelVersion !== void 0 ? { trustModelVersion: input.trustModelVersion } : {},
852
921
  ...input.scoringModelVersion !== void 0 ? { scoringModelVersion: input.scoringModelVersion } : {},
853
- ...input.frameworkSupportMatrixVersion !== void 0 ? { frameworkSupportMatrixVersion: input.frameworkSupportMatrixVersion } : {}
922
+ ...input.frameworkSupportMatrixVersion !== void 0 ? { frameworkSupportMatrixVersion: input.frameworkSupportMatrixVersion } : {},
923
+ ...input.commit !== void 0 ? { commit: input.commit } : {},
924
+ ...input.tree !== void 0 ? { tree: input.tree } : {},
925
+ ...input.lockfile !== void 0 ? { lockfile: input.lockfile } : {},
926
+ ...input.candidate !== void 0 ? { candidate: input.candidate } : {}
854
927
  };
855
928
  }
856
929
  /**
@@ -891,9 +964,218 @@ function buildEvidenceGraph(parts) {
891
964
  ...parts.reproduction !== void 0 ? { ref: parts.reproduction } : {}
892
965
  }
893
966
  ],
894
- ...parts.runId !== void 0 ? { runId: parts.runId } : {}
967
+ ...parts.runId !== void 0 ? { runId: parts.runId } : {},
968
+ ...parts.candidate !== void 0 ? { candidate: {
969
+ manifestId: parts.candidate.manifestId,
970
+ candidateSha: parts.candidate.candidateSha
971
+ } } : {}
972
+ };
973
+ }
974
+ //#endregion
975
+ //#region src/engine/file-executor.ts
976
+ function cacheHitOutcome(job) {
977
+ return {
978
+ path: job.path,
979
+ status: "CACHE_HIT",
980
+ findings: [...job.cacheHit ?? []],
981
+ parseFallback: false
982
+ };
983
+ }
984
+ /**
985
+ * Drive a list of jobs through an executor and return outcomes in INPUT order.
986
+ *
987
+ * This is the function the pipeline calls. It is executor-agnostic on purpose:
988
+ * swapping in a pooled executor must not require touching the caller, and the
989
+ * ordering guarantee has to live here or it would live in each executor and
990
+ * drift between them.
991
+ */
992
+ async function executeFiles(jobs, executor, context = {}) {
993
+ const outcomes = new Array(jobs.length);
994
+ if (executor.concurrency <= 1 || jobs.length <= 1) {
995
+ for (let i = 0; i < jobs.length; i++) {
996
+ const job = jobs[i];
997
+ context.onProgress?.({
998
+ done: i,
999
+ total: jobs.length,
1000
+ path: job.path
1001
+ });
1002
+ outcomes[i] = await runOne(job, executor, context);
1003
+ }
1004
+ context.onProgress?.({
1005
+ done: jobs.length,
1006
+ total: jobs.length,
1007
+ path: jobs[jobs.length - 1]?.path ?? ""
1008
+ });
1009
+ return outcomes;
1010
+ }
1011
+ let next = 0;
1012
+ let completed = 0;
1013
+ const inFlight = /* @__PURE__ */ new Set();
1014
+ const limit = Math.max(1, Math.floor(executor.concurrency));
1015
+ const worker = async () => {
1016
+ for (;;) {
1017
+ const index = next++;
1018
+ if (index >= jobs.length) return;
1019
+ const job = jobs[index];
1020
+ outcomes[index] = await runOne(job, executor, context);
1021
+ completed++;
1022
+ context.onProgress?.({
1023
+ done: completed,
1024
+ total: jobs.length,
1025
+ path: job.path
1026
+ });
1027
+ }
1028
+ };
1029
+ for (let i = 0; i < Math.min(limit, jobs.length); i++) {
1030
+ const promise = worker().then(() => void 0);
1031
+ inFlight.add(promise);
1032
+ }
1033
+ await Promise.all(inFlight);
1034
+ return outcomes;
1035
+ }
1036
+ async function runOne(job, executor, context) {
1037
+ if (job.cacheHit !== void 0) return cacheHitOutcome(job);
1038
+ try {
1039
+ return await executor.execute(job, context);
1040
+ } catch (error) {
1041
+ return {
1042
+ path: job.path,
1043
+ status: "FAILED",
1044
+ findings: [],
1045
+ parseFallback: false,
1046
+ error: error instanceof Error ? error : new Error(String(error))
1047
+ };
1048
+ }
1049
+ }
1050
+ /**
1051
+ * The strategy the pipeline uses today.
1052
+ *
1053
+ * `runOne` is supplied by the pipeline because parsing and rule execution
1054
+ * need the adapter and the active rule set, which are pipeline concerns. The
1055
+ * executor's job is the BOUNDARY and the bookkeeping, not the analysis — so
1056
+ * this factory exists to bind those two without the executor importing the
1057
+ * pipeline (which would be a cycle).
1058
+ */
1059
+ function createExecutor(name, concurrency, runOne) {
1060
+ return {
1061
+ name,
1062
+ concurrency,
1063
+ execute: runOne
1064
+ };
1065
+ }
1066
+ //#endregion
1067
+ //#region src/engine/candidate-binding.ts
1068
+ /**
1069
+ * Candidate binding (plan V5-010).
1070
+ *
1071
+ * Reads a candidate trust manifest and produces the immutable
1072
+ * `CandidateBinding` that a run identity is bound to. It also computes the
1073
+ * repository facts (commit, tree, lockfile digest) that the identity was
1074
+ * missing until this module existed.
1075
+ *
1076
+ * The law here is ABSENCE over invention. A manifest that is missing,
1077
+ * unreadable, or internally contradictory yields NO binding — not a partial
1078
+ * one. A consumer that can see "not bound" can refuse to trust the run; a
1079
+ * consumer handed a half-filled binding cannot tell what is missing, and will
1080
+ * trust the parts that are there.
1081
+ */
1082
+ const CANDIDATE_MANIFEST_PATH = "candidate-trust-manifest.json";
1083
+ const SHA40 = /^[a-f0-9]{40}$/;
1084
+ const SHA256 = /^[a-f0-9]{64}$/;
1085
+ const STATES = ["WORKING_CANDIDATE", "RELEASE_CANDIDATE"];
1086
+ const AUTHORIZATION = ["NOT_AUTHORIZED", "AUTHORIZED"];
1087
+ function isRecord$1(value) {
1088
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1089
+ }
1090
+ function sha256File(path) {
1091
+ try {
1092
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
1093
+ } catch {
1094
+ return;
1095
+ }
1096
+ }
1097
+ function gitLine(root, args) {
1098
+ try {
1099
+ const trimmed = execFileSync("git", args, {
1100
+ cwd: root,
1101
+ encoding: "utf8",
1102
+ stdio: [
1103
+ "ignore",
1104
+ "pipe",
1105
+ "ignore"
1106
+ ],
1107
+ windowsHide: true
1108
+ }).trim();
1109
+ return trimmed.length > 0 ? trimmed : void 0;
1110
+ } catch {
1111
+ return;
1112
+ }
1113
+ }
1114
+ /**
1115
+ * The commit and tree the working tree is at. Both absent outside a
1116
+ * repository or with no commits — never substituted with a placeholder.
1117
+ */
1118
+ function bindRepository(root) {
1119
+ const commit = gitLine(root, ["rev-parse", "HEAD"]);
1120
+ const tree = gitLine(root, ["rev-parse", "HEAD^{tree}"]);
1121
+ const lockfile = sha256File(join(root, "package-lock.json"));
1122
+ return {
1123
+ ...commit !== void 0 ? { commit } : {},
1124
+ ...tree !== void 0 ? { tree } : {},
1125
+ ...lockfile !== void 0 ? { lockfile } : {}
1126
+ };
1127
+ }
1128
+ /**
1129
+ * Parse a candidate manifest into a binding, or return null when it cannot
1130
+ * honestly be bound.
1131
+ *
1132
+ * Null is returned for: no manifest, unreadable JSON, a missing or malformed
1133
+ * required field, or a state/authorization combination that cannot legally
1134
+ * exist (a WORKING_CANDIDATE carrying a commit, for example — the same
1135
+ * contradiction the release decision evaluator treats as BLOCKED).
1136
+ */
1137
+ function parseCandidateBinding(raw) {
1138
+ if (!isRecord$1(raw)) return null;
1139
+ const identity = raw.identity;
1140
+ if (!isRecord$1(identity)) return null;
1141
+ const state = identity.state;
1142
+ const candidateSha = identity.candidateSha;
1143
+ const baseSha = identity.baseSha;
1144
+ const packageSha256 = identity.packageSha256;
1145
+ const lockfileSha256 = identity.lockfileSha256;
1146
+ if (typeof state !== "string" || !STATES.includes(state)) return null;
1147
+ if (typeof baseSha !== "string" || !SHA40.test(baseSha)) return null;
1148
+ if (typeof packageSha256 !== "string" || !SHA256.test(packageSha256)) return null;
1149
+ if (typeof lockfileSha256 !== "string" || !SHA256.test(lockfileSha256)) return null;
1150
+ if (candidateSha !== null && (typeof candidateSha !== "string" || !SHA40.test(candidateSha))) return null;
1151
+ const manifestId = raw.manifestId;
1152
+ if (typeof manifestId !== "string" || manifestId.length === 0) return null;
1153
+ const owner = typeof raw.owner === "string" ? raw.owner : "UNASSIGNED";
1154
+ const authorization = raw.releaseAuthorizationState;
1155
+ if (typeof authorization !== "string" || !AUTHORIZATION.includes(authorization)) return null;
1156
+ if (state === "WORKING_CANDIDATE") {
1157
+ if (candidateSha !== null) return null;
1158
+ if (authorization !== "NOT_AUTHORIZED") return null;
1159
+ } else if (candidateSha === null) return null;
1160
+ return {
1161
+ manifestId,
1162
+ state,
1163
+ candidateSha,
1164
+ baseSha,
1165
+ packageSha256,
1166
+ lockfileSha256,
1167
+ owner,
1168
+ releaseAuthorizationState: authorization
895
1169
  };
896
1170
  }
1171
+ /** Read and bind the manifest from a checkout, or null. */
1172
+ function readCandidateBinding(root, manifestPath = CANDIDATE_MANIFEST_PATH) {
1173
+ try {
1174
+ return parseCandidateBinding(JSON.parse(readFileSync(join(root, manifestPath), "utf8")));
1175
+ } catch {
1176
+ return null;
1177
+ }
1178
+ }
897
1179
  //#endregion
898
1180
  //#region src/engine/version.ts
899
1181
  /**
@@ -904,7 +1186,7 @@ function buildEvidenceGraph(parts) {
904
1186
  * scripts/sync-sarif-version.cjs and guarded by the version-consistency
905
1187
  * spec. cli.ts re-exports this as CLI_VERSION.
906
1188
  */
907
- const ENGINE_VERSION = "2.1.0";
1189
+ const ENGINE_VERSION = "4.0.0";
908
1190
  //#endregion
909
1191
  //#region src/engine/contract-versions.ts
910
1192
  /**
@@ -1065,7 +1347,9 @@ function basename$1(p) {
1065
1347
  * Internal files (cache, hashes) use `isRecord`; user-facing files
1066
1348
  * (config, plugins) use stricter checks that throw descriptive errors.
1067
1349
  */
1350
+ const MAX_JSON_BYTES = 16777216;
1068
1351
  function parseJsonFile(text, source, validate) {
1352
+ if (Buffer.byteLength(text, "utf8") > MAX_JSON_BYTES) throw new Error(`JSON source exceeds the ${MAX_JSON_BYTES}-byte limit: ${source}`);
1069
1353
  let parsed;
1070
1354
  try {
1071
1355
  parsed = JSON.parse(text);
@@ -1084,6 +1368,59 @@ function isRecord(v) {
1084
1368
  return typeof v === "object" && v !== null && !Array.isArray(v);
1085
1369
  }
1086
1370
  //#endregion
1371
+ //#region src/lib/fs-bounded.ts
1372
+ function closeQuiet(fd) {
1373
+ try {
1374
+ closeSync(fd);
1375
+ } catch {
1376
+ return;
1377
+ }
1378
+ }
1379
+ function readFileBounded(path, maxBytes) {
1380
+ let fd;
1381
+ try {
1382
+ const pathStat = lstatSync(path);
1383
+ if (pathStat.isSymbolicLink()) return {
1384
+ ok: false,
1385
+ reason: "symlink"
1386
+ };
1387
+ if (!pathStat.isFile()) return {
1388
+ ok: false,
1389
+ reason: "unreadable"
1390
+ };
1391
+ fd = openSync(path, 0);
1392
+ const fdStat = fstatSync(fd);
1393
+ if (!fdStat.isFile() || fdStat.size > maxBytes) return {
1394
+ ok: false,
1395
+ reason: fdStat.size > maxBytes ? "too-large" : "unreadable"
1396
+ };
1397
+ const chunks = [];
1398
+ let total = 0;
1399
+ const buffer = Buffer.allocUnsafe(Math.min(65536, maxBytes));
1400
+ while (true) {
1401
+ const read = readSync(fd, buffer, 0, buffer.length, null);
1402
+ if (read === 0) break;
1403
+ total += read;
1404
+ if (total > maxBytes) return {
1405
+ ok: false,
1406
+ reason: "too-large"
1407
+ };
1408
+ chunks.push(Buffer.from(buffer.subarray(0, read)));
1409
+ }
1410
+ return {
1411
+ ok: true,
1412
+ data: Buffer.concat(chunks)
1413
+ };
1414
+ } catch {
1415
+ return {
1416
+ ok: false,
1417
+ reason: "unreadable"
1418
+ };
1419
+ } finally {
1420
+ if (fd !== void 0) closeQuiet(fd);
1421
+ }
1422
+ }
1423
+ //#endregion
1087
1424
  //#region src/config/config-schema.ts
1088
1425
  const VALID_GATES = /* @__PURE__ */ new Set([
1089
1426
  "advisory",
@@ -1182,7 +1519,11 @@ const CONFIG_NAMES = ["mjolnir.config.json", ".mjolnir.json"];
1182
1519
  function findConfigPath(root) {
1183
1520
  for (const name of CONFIG_NAMES) {
1184
1521
  const p = join(root, name);
1185
- if (existsSync(p)) return p;
1522
+ try {
1523
+ if (existsSync(p) && !lstatSync(p).isSymbolicLink()) return p;
1524
+ } catch {
1525
+ continue;
1526
+ }
1186
1527
  }
1187
1528
  return null;
1188
1529
  }
@@ -1202,7 +1543,10 @@ function loadConfig(root, options = {}) {
1202
1543
  const p = join(root, name);
1203
1544
  if (!existsSync(p)) continue;
1204
1545
  try {
1205
- const parsed = parseJsonFile(readFileSync(p, "utf8"), p, (v) => isRecord(v));
1546
+ if (!existsSync(p) || lstatSync(p).isSymbolicLink()) continue;
1547
+ const read = readFileBounded(p, 1048576);
1548
+ if (!read.ok) throw new Error("config source could not be read safely");
1549
+ const parsed = parseJsonFile(read.data.toString("utf8"), p, (v) => isRecord(v));
1206
1550
  return {
1207
1551
  config: parsed,
1208
1552
  path: p,
@@ -1250,20 +1594,9 @@ function validate(cfg, knownRuleIds) {
1250
1594
  return warnings;
1251
1595
  }
1252
1596
  /**
1253
- * Bug-audit QA-2026-08-30 QA-6: the 90-day policy in the README was only
1254
- * applied at WRITE time by the `ignore` command — a hand-written entry
1255
- * without `expires` stayed active forever, silently bypassing the
1256
- * documented window.
1257
- *
1258
- * Audit S4 (remediation plan): the config-file MTIME is no longer an
1259
- * expiry anchor. Anchoring the 90-day default at mtime meant ANY edit to
1260
- * mjolnir.config.json — a reformat, an unrelated key, a `touch` — reset
1261
- * the 90-day window for EVERY hand-authored entry: suppressions could be
1262
- * extended indefinitely without touching their own fields. The expiry is
1263
- * now the entry's explicit `expires` date alone; an entry without one
1264
- * stays active and is honestly labeled "(no expiry set)" in the
1265
- * suppressions report. Hand-authored entries should declare `expires` at
1266
- * creation (README §Configuration documents the shape).
1597
+ * Expiry is evaluated only from an explicit ISO `expires` date. A missing
1598
+ * date remains active and is reported as having no expiry. Config-file mtime
1599
+ * is never an expiry anchor.
1267
1600
  */
1268
1601
  function isSuppressionActive(ign, now = /* @__PURE__ */ new Date()) {
1269
1602
  if (!ign.expires) return true;
@@ -1496,8 +1829,9 @@ function sharedWalk(options) {
1496
1829
  }
1497
1830
  const full = join(dir, entry.name);
1498
1831
  const rel = relative(options.root, full).replaceAll("\\", "/");
1832
+ if (entry.isDirectory() && options.skipDirs.includes(entry.name)) continue;
1499
1833
  if (options.ignoreMatcher.isIgnored(rel)) {
1500
- options.onIgnored?.();
1834
+ if (!rel.startsWith(".mjolnir/")) options.onIgnored?.(rel);
1501
1835
  continue;
1502
1836
  }
1503
1837
  if (entry.isSymbolicLink()) {
@@ -1505,7 +1839,6 @@ function sharedWalk(options) {
1505
1839
  continue;
1506
1840
  }
1507
1841
  if (entry.isDirectory()) {
1508
- if (options.skipDirs.includes(entry.name)) continue;
1509
1842
  if (rel.split("/").length > LIMITS$1.maxDepth) {
1510
1843
  options.onSkipped("max-depth");
1511
1844
  continue;
@@ -1523,7 +1856,7 @@ function sharedWalk(options) {
1523
1856
  } catch {
1524
1857
  options.onSkipped("stat-failed");
1525
1858
  }
1526
- else if (entry.isFile()) options.onUnrecognized?.();
1859
+ else if (entry.isFile()) options.onUnrecognized?.(rel);
1527
1860
  }
1528
1861
  };
1529
1862
  walk(options.root);
@@ -1541,7 +1874,33 @@ function sharedWalk(options) {
1541
1874
  *
1542
1875
  * When nothing is detectable we report `unknown` and the scanner analyzes
1543
1876
  * all test-looking files — stated honestly in output rather than guessed.
1877
+ *
1878
+ * ## One ID space (plan V5-024)
1879
+ *
1880
+ * This detector used to declare its own `TestFramework` union of three
1881
+ * literals while `src/frameworks/framework-inventory.ts` catalogued fourteen
1882
+ * framework ids. Two vocabularies for one concept is how a support matrix
1883
+ * starts disagreeing with what the tool actually detects: a framework can be
1884
+ * catalogued as OFFICIAL_PARTIAL and never once be emitted by the code that
1885
+ * claims to detect it.
1886
+ *
1887
+ * So the ids here are `FrameworkId`s — the inventory's own vocabulary — and
1888
+ * `DETECTABLE_TEST_FRAMEWORKS` is the subset this detector can actually
1889
+ * resolve from a checkout. That subset is the HONEST limit of detection, and
1890
+ * `detectableVsCatalogued()` reports the remainder rather than letting a
1891
+ * three-item list read as the whole catalog.
1892
+ */
1893
+ /**
1894
+ * The catalogued frameworks whose detection is implemented today.
1895
+ *
1896
+ * Every entry must exist in the inventory — enforced by the parity spec, not
1897
+ * by comment.
1544
1898
  */
1899
+ const DETECTABLE_TEST_FRAMEWORKS = [
1900
+ "jest",
1901
+ "vitest",
1902
+ "playwright"
1903
+ ];
1545
1904
  const CONFIG_FILES = {
1546
1905
  jest: [
1547
1906
  "jest.config.ts",
@@ -1560,7 +1919,7 @@ const CONFIG_FILES = {
1560
1919
  };
1561
1920
  function detectFrameworks(ws) {
1562
1921
  const found = /* @__PURE__ */ new Set();
1563
- for (const fw of Object.keys(CONFIG_FILES)) if (CONFIG_FILES[fw].some((f) => existsSync(join(ws.root, f)))) found.add(fw);
1922
+ for (const fw of DETECTABLE_TEST_FRAMEWORKS) if (CONFIG_FILES[fw].some((f) => existsSync(join(ws.root, f)))) found.add(fw);
1564
1923
  if (!found.has("jest") && ws.packageJson["jest"] !== void 0) found.add("jest");
1565
1924
  const deps = {
1566
1925
  ...ws.packageJson["dependencies"],
@@ -1582,11 +1941,7 @@ function detectFrameworks(ws) {
1582
1941
  };
1583
1942
  }
1584
1943
  return {
1585
- frameworks: [
1586
- "jest",
1587
- "vitest",
1588
- "playwright"
1589
- ].filter((f) => found.has(f)),
1944
+ frameworks: DETECTABLE_TEST_FRAMEWORKS.filter((f) => found.has(f)),
1590
1945
  unknown: false
1591
1946
  };
1592
1947
  }
@@ -1603,6 +1958,13 @@ function detectFrameworks(ws) {
1603
1958
  * byte-identical — a score shift is a regression, not an improvement.
1604
1959
  */
1605
1960
  let project = null;
1961
+ /**
1962
+ * The shared ts-morph project.
1963
+ *
1964
+ * Exported so an adapter's `dispose()` can evict the file it parsed: ts-morph
1965
+ * caches by file path, so without eviction a long scan holds every parsed
1966
+ * SourceFile until the process exits (plan V5-021).
1967
+ */
1606
1968
  function getProject() {
1607
1969
  if (!project) project = new Project({
1608
1970
  useInMemoryFileSystem: true,
@@ -2129,7 +2491,36 @@ function frameworkFilterApplies(rule, file) {
2129
2491
  * "cypress", `@jest/globals` → "jest", `vitest` → "vitest". Config
2130
2492
  * gating is rule-declared (`configFiles`), not hard-coded here.
2131
2493
  */
2132
- const TEST_FILE_RE = /\.(?:test|spec)\.(?:js|jsx|ts|tsx|mjs|cjs)$|\.cy\.(?:js|jsx|ts|tsx)$/;
2494
+ /**
2495
+ * `.test.` / `.spec.` filenames, plus the Cypress `.cy.` convention.
2496
+ *
2497
+ * `mts` and `cts` are TypeScript's Node-native ESM/CJS extensions. They were
2498
+ * missing here, and the omission was invisible in the worst way — not a wrong
2499
+ * answer, but an ABSENCE.
2500
+ *
2501
+ * Reproduced: a repo containing two byte-identical tests, one at
2502
+ * `tests/control.spec.ts` and one at `tests/slow.spec.mts`, each with a
2503
+ * `QA-TEST-004` hard sleep:
2504
+ *
2505
+ * discovered: 1, analyzed: 1, unrecognized: 1, scopeVerdict: "PARTIAL"
2506
+ *
2507
+ * The `.ts` test produced the finding. The `.mts` test produced nothing at
2508
+ * all — it was never scanned, so every rule that could have caught it was
2509
+ * silent on it, with no finding, no low-evidence note, and no indication that
2510
+ * a test file had been skipped. A test scanner that cannot see a whole file
2511
+ * extension has a false green that is invisible by construction: the reader
2512
+ * has nothing to distrust, because there is nothing there.
2513
+ *
2514
+ * The corroboration that this was an oversight rather than a decision sits
2515
+ * four lines below: PW_CONFIG_RE already accepted `cts`. The config regex knew
2516
+ * about the Node-native extensions; the test-file regex did not.
2517
+ *
2518
+ * `discovery/scan-adapters.ts:isUnrecognizedSourceCandidate` already counted
2519
+ * these paths as uncovered surface, so the scope accounting had been reporting
2520
+ * the hole the whole time — it was being read as a known limitation rather than
2521
+ * as a defect.
2522
+ */
2523
+ const TEST_FILE_RE = /\.(?:test|spec)\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$|\.cy\.(?:js|jsx|ts|tsx)$/;
2133
2524
  /**
2134
2525
  * Fallback config list for `configOnly` rules that do not declare
2135
2526
  * `configFiles` (the legacy playwright.config.* gating, preserved
@@ -2236,11 +2627,42 @@ const typescriptAdapter = {
2236
2627
  fixtureDirMemo: /* @__PURE__ */ new Map()
2237
2628
  });
2238
2629
  },
2630
+ /**
2631
+ * The ONE AST seam (plan V5-021).
2632
+ *
2633
+ * This adapter used to parse inside `runRules`, on the synchronous path,
2634
+ * while Java and C# exposed the async `parseAst` hook. Two seams meant two
2635
+ * sets of consequences, both of them bad:
2636
+ *
2637
+ * - the pipeline computes `wantsAst` as `adapter.parseAst !== undefined`,
2638
+ * so a TypeScript file never took the AST path through the pipeline at
2639
+ * all, and its parse failures were invisible to the pipeline's
2640
+ * fallback counters;
2641
+ * - nothing called `dispose()` for the ts-morph path, so a scan held
2642
+ * every parsed SourceFile for its whole lifetime.
2643
+ *
2644
+ * Parsing now happens here, once, through the same contract every other
2645
+ * adapter uses. `dispose()` drops this file's SourceFile from the shared
2646
+ * project; ts-morph caches per file path, so removing it is what keeps
2647
+ * memory proportional to one file rather than to the whole scan.
2648
+ */
2649
+ parseAst(file) {
2650
+ const sourceFile = parseTsFile(file);
2651
+ if (sourceFile === void 0) return void 0;
2652
+ return {
2653
+ ast: sourceFile,
2654
+ dispose: () => {
2655
+ try {
2656
+ getProject().removeSourceFile(sourceFile);
2657
+ } catch {}
2658
+ }
2659
+ };
2660
+ },
2239
2661
  runRules(rules, file, emit, onCrash, budget) {
2240
- const withAst = {
2662
+ const withAst = file.ast === void 0 ? {
2241
2663
  ...file,
2242
2664
  ast: parseTsFile(file)
2243
- };
2665
+ } : file;
2244
2666
  const withTags = {
2245
2667
  ...withAst,
2246
2668
  frameworkTags: frameworkTagsFromImports(withAst.text)
@@ -3625,7 +4047,7 @@ const SCAN_ADAPTERS = [
3625
4047
  * the adapter that claims it; per-adapter caps still apply.
3626
4048
  */
3627
4049
  function discoverAllTestFiles(ctx, languageAdapters, buckets, fixtureDirMemo) {
3628
- const walkSkips = languageAdapters.reduce((common, a) => common.filter((name) => a.dirSkips.includes(name)), languageAdapters[0]?.dirSkips ?? []);
4050
+ const walkSkips = [.../* @__PURE__ */ new Set([...languageAdapters.reduce((common, a) => common.filter((name) => a.dirSkips.includes(name)), languageAdapters[0]?.dirSkips ?? []), ".mjolnir"])];
3629
4051
  sharedWalk({
3630
4052
  root: ctx.workspace.root,
3631
4053
  deadline: ctx.deadline,
@@ -3651,10 +4073,63 @@ function discoverAllTestFiles(ctx, languageAdapters, buckets, fixtureDirMemo) {
3651
4073
  },
3652
4074
  isFull: () => languageAdapters.every((a) => (buckets.get(a.id)?.length ?? 0) >= ctx.maxFiles),
3653
4075
  fixtureDirMemo,
3654
- onIgnored: ctx.onIgnored,
3655
- onUnrecognized: ctx.onUnrecognized
4076
+ onIgnored: (path) => {
4077
+ if (isScopeRelevantIgnored(path)) ctx.onIgnored?.(path);
4078
+ },
4079
+ onUnrecognized: (path) => {
4080
+ if (typeof path === "string" && isUnrecognizedSourceCandidate(path)) ctx.onUnrecognized?.(path);
4081
+ }
3656
4082
  });
3657
4083
  }
4084
+ /**
4085
+ * Would ignoring this file have removed it from the scanned surface?
4086
+ *
4087
+ * The bug this fixes, reproduced: a repo containing a real test file plus
4088
+ * `vendor.min.js` and `pnpm-lock.yaml` reported
4089
+ *
4090
+ * discovered: 1, analyzed: 1, ignored: 2, scopeVerdict: "PARTIAL"
4091
+ *
4092
+ * Every discovered test file HAD been analyzed. The surface was complete. But
4093
+ * DEFAULT_IGNORES matches minified bundles and lockfiles, and the ignored
4094
+ * counter accepted any path with a source extension — so a minified bundle and
4095
+ * a lockfile downgraded a finished scan to "unverified".
4096
+ *
4097
+ * That is not a conservative bias, it is a broken signal. The terminal tells
4098
+ * the reader "some files were not analyzed, so the surface is unverified" —
4099
+ * which was false. And because minified bundles and lockfiles exist in
4100
+ * essentially every real repository, a PROVEN scan was unreachable in
4101
+ * practice, so the corpus regression guard failed 23 of 37 repositories on
4102
+ * exactly this. A gate that cannot pass teaches people to ignore it.
4103
+ *
4104
+ * The asymmetry is the tell. `isUnrecognizedSourceCandidate` below applies a
4105
+ * test-relevance test before counting a file; this one did not. Both count
4106
+ * against the same verdict, so both must ask the same question.
4107
+ *
4108
+ * An ignored file that IS a test file still downgrades the verdict. That is
4109
+ * the case the honesty law exists for, and this does not touch it.
4110
+ */
4111
+ function isScopeRelevantIgnored(path) {
4112
+ const name = path.replaceAll("\\", "/").split("/").pop() ?? path;
4113
+ if (!/\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml|min\.js)$/i.test(name)) return false;
4114
+ return isKnownTestFile(path);
4115
+ }
4116
+ function isUnrecognizedSourceCandidate(path) {
4117
+ const normalized = path.replaceAll("\\", "/");
4118
+ const name = normalized.split("/").pop() ?? normalized;
4119
+ if (normalized.includes(".github/workflows/") || name === "azure-pipelines.yml" || name === "Jenkinsfile") return false;
4120
+ const extensionIndex = name.lastIndexOf(".");
4121
+ const stem = extensionIndex > 0 ? name.slice(0, extensionIndex) : name;
4122
+ if (name === "mjolnir.config.json" || name === ".mjolnir.json" || name === "package.json" || name === "package-lock.json" || name === "pnpm-lock.yaml" || name === "yarn.lock" || [
4123
+ "playwright",
4124
+ "vitest",
4125
+ "vite",
4126
+ "eslint",
4127
+ "tsconfig",
4128
+ "jsconfig"
4129
+ ].some((prefix) => stem === prefix || stem.startsWith(`${prefix}.`))) return false;
4130
+ const testLike = /(?:^|\/)__tests__\//i.test(normalized) || /\.(?:spec|test)\.[cm]?[jt]sx?$/i.test(name);
4131
+ return /\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml)$/i.test(name) && testLike;
4132
+ }
3658
4133
  /** Whether ANY shipped adapter would discover this path as a test file. */
3659
4134
  function isKnownTestFile(path) {
3660
4135
  return SCAN_ADAPTERS.some((a) => a.isTestFile(path));
@@ -3681,17 +4156,14 @@ typescriptAdapter.testFileGlobs, pythonAdapter.testFileGlobs, javaAdapter.testFi
3681
4156
  * The fix: resolve git ONCE per process to an ABSOLUTE path by walking
3682
4157
  * the PATH directly (never consulting the CWD), verify the candidate is
3683
4158
  * an existing file, and pass that path to execFileSync. Resolution is
3684
- * memoized; a failure to find a real git anywhere on PATH degrades to
3685
- * the plain name (previous behavior) with the resolution error recorded
3686
- * — degraded git data already means full-file attribution, never a
3687
- * crash.
4159
+ * memoized; a failure to find a real git anywhere on PATH is recorded
4160
+ * and callers degrade without invoking a bare name.
3688
4161
  */
3689
4162
  let resolvedGit = void 0;
3690
4163
  /**
3691
4164
  * The absolute path of the git binary Mjölnir will invoke, or null when
3692
- * PATH carries no executable `git` at all (S1 degradation: callers fall
3693
- * back to the bare name and their own try/catch — same honest degrade
3694
- * as before, minus the CWD-hijack surface).
4165
+ * PATH carries no executable `git` at all. Callers must degrade without
4166
+ * invoking a bare name.
3695
4167
  */
3696
4168
  function resolveGitPath() {
3697
4169
  if (resolvedGit !== void 0) return resolvedGit;
@@ -3724,7 +4196,8 @@ function resolveGitPath() {
3724
4196
  * the previous inline `git()` helpers, with the hijack surface closed.
3725
4197
  */
3726
4198
  function runGit(root, args) {
3727
- const exe = resolveGitPath() ?? "git";
4199
+ const exe = resolveGitPath();
4200
+ if (!exe) return null;
3728
4201
  try {
3729
4202
  return execFileSync(exe, [
3730
4203
  "-C",
@@ -3860,7 +4333,7 @@ function computeChangedScope(root, baseBranch) {
3860
4333
  "--",
3861
4334
  ...chunk
3862
4335
  ]);
3863
- if (output === null) continue;
4336
+ if (output === null) return null;
3864
4337
  const sections = output.split("\ndiff --git ");
3865
4338
  for (const section of sections) {
3866
4339
  const body = section.startsWith("diff --git ") ? section : `diff --git ${section}`;
@@ -3888,6 +4361,11 @@ function computeChangedScope(root, baseBranch) {
3888
4361
  "--unified=0",
3889
4362
  "HEAD"
3890
4363
  ], changedFiles);
4364
+ if (committedDiffs === null || workingDiffs === null) return {
4365
+ changed: {},
4366
+ degraded: true,
4367
+ reason: "diff-failed"
4368
+ };
3891
4369
  for (const file of changedFiles) {
3892
4370
  const committedDiff = committedDiffs.get(file);
3893
4371
  const workingDiff = workingDiffs.get(file);
@@ -3913,8 +4391,11 @@ const LIMITS_MAX_LINES = 1e6;
3913
4391
  function allLinesOf(root, file) {
3914
4392
  const full = join(root, file);
3915
4393
  try {
3916
- if (statSync(full).size > LIMITS$1.maxFileBytes) return null;
3917
- const lineCount = readFileSync(full, "utf8").split("\n").length;
4394
+ const stat = lstatSync(full);
4395
+ if (stat.isSymbolicLink() || !stat.isFile()) return null;
4396
+ const read = readFileBounded(full, LIMITS$1.maxFileBytes);
4397
+ if (!read.ok) return null;
4398
+ const lineCount = read.data.toString("utf8").split("\n").length;
3918
4399
  return Array.from({ length: lineCount }, (_, i) => i + 1);
3919
4400
  } catch {
3920
4401
  return null;
@@ -7480,7 +7961,6 @@ const pyBareTruthinessAssert = defineRule({
7480
7961
  const re = /^[ \t]*assert\s+([A-Za-z_][\w.]*(?:\([^()]*\))?)[ \t]*$/gm;
7481
7962
  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*\()/;
7482
7963
  const isGuardFollowedByRealUse = (text, matchIndex, target) => {
7483
- const root = target.split(".")[0];
7484
7964
  const lineEnd = text.indexOf("\n", matchIndex);
7485
7965
  if (lineEnd === -1) return false;
7486
7966
  const lines = text.slice(lineEnd + 1).split("\n");
@@ -7490,8 +7970,11 @@ const pyBareTruthinessAssert = defineRule({
7490
7970
  if (/^\s*def\s/.test(l) && window.length > 0) break;
7491
7971
  window.push(l);
7492
7972
  }
7493
- const usesRoot = new RegExp(`\\b${root}\\b`);
7494
- return window.some((l) => usesRoot.test(l));
7973
+ const root = target.match(/^[a-z_]\w*/i)?.[0];
7974
+ if (!root) return false;
7975
+ return window.some((line) => {
7976
+ return (line.match(/[a-z_]\w*/gi) ?? []).some((token) => token === root);
7977
+ });
7495
7978
  };
7496
7979
  let m;
7497
7980
  while ((m = re.exec(text)) !== null) {
@@ -11453,6 +11936,22 @@ function normalizeOne(report, artifact, v) {
11453
11936
  function buildEvidenceRecords(report, artifact) {
11454
11937
  return report.verdicts.map((v) => normalizeOne(report, artifact, v)).sort(compareEvidenceRecords);
11455
11938
  }
11939
+ function countEvidence(records) {
11940
+ const counts = {
11941
+ total: records.length,
11942
+ failed: 0,
11943
+ flaky: 0,
11944
+ skipped: 0,
11945
+ timedOut: 0
11946
+ };
11947
+ for (const r of records) {
11948
+ if (r.status.failed) counts.failed++;
11949
+ if (r.status.passedOnRetry) counts.flaky++;
11950
+ if (r.status.skipped) counts.skipped++;
11951
+ if (r.status.timedOut) counts.timedOut++;
11952
+ }
11953
+ return counts;
11954
+ }
11456
11955
  //#endregion
11457
11956
  //#region src/engine/provenance.ts
11458
11957
  const GENERATED_HEADER_RE = /^\s*(?:\/\/|#|\/\*)\s*(?:auto[- ]?generated|generated by|do not edit)/i;
@@ -11740,6 +12239,11 @@ var DependencyGraph = class {
11740
12239
  get size() {
11741
12240
  return this.nodes.size;
11742
12241
  }
12242
+ /** Whether the graph knows this exact key. Reachability must not
12243
+ * treat "not in the graph" as "nothing to traverse" without saying so. */
12244
+ has(path) {
12245
+ return this.nodes.has(path);
12246
+ }
11743
12247
  };
11744
12248
  function parsePackageJson(filePath) {
11745
12249
  if (!existsSync(filePath)) return void 0;
@@ -11864,80 +12368,354 @@ function isIncrementalSafe(changedFiles) {
11864
12368
  };
11865
12369
  }
11866
12370
  //#endregion
11867
- //#region src/engine/monorepo-analysis.ts
11868
- const WORTHY_THRESHOLD = 80;
11869
- const NEEDS_WORK_THRESHOLD = 50;
11870
- function verdictOf(score) {
11871
- if (score === null) return "fail";
11872
- if (score >= WORTHY_THRESHOLD) return "pass";
11873
- if (score >= NEEDS_WORK_THRESHOLD) return "warn";
11874
- return "fail";
11875
- }
11876
- function hasBlocker(findings) {
11877
- return findings.some((f) => f.severity === "error");
11878
- }
12371
+ //#region src/brand/tokens.ts
11879
12372
  /**
11880
- * Aggregate per-package results into an overall verdict.
12373
+ * The single source of brand truth.
11881
12374
  *
11882
- * Worst-package propagation: if ANY package has a blocker (error-severity
11883
- * finding) or a failing score, the overall result fails regardless of
11884
- * strategy. This is the safety net — a single poisoned package must not
11885
- * hide behind averaging.
12375
+ * Every colour, typeface and motion constant Mjölnir shows a human —
12376
+ * terminal, README SVGs, demo video, website, docs, badges — resolves to
12377
+ * a value in this file. Nothing else may define one.
12378
+ *
12379
+ * WHY THIS EXISTS. Before it, the palette existed in six independent
12380
+ * copies: `site/.vitepress/theme/styles/vars.css`, `NORSE` in
12381
+ * `src/reporter/theme.ts`, `scripts/readme-svg.ts`,
12382
+ * `scripts/video/terminal-page.ts`, `scripts/generate-readme-architecture.ts`
12383
+ * and the table in `assets/brand/README.md`. Exactly one pair of those
12384
+ * was guarded (site-doctor Check 8, doc ↔ vars.css). The unguarded edges
12385
+ * are where the shipped surfaces drifted apart: the terminal and the site
12386
+ * disagreed on six semantic roles, the architecture diagram invented its
12387
+ * own neutral ramp, and the README badges still carried a palette retired
12388
+ * two releases earlier. `scripts/brand-doctor.mjs` now checks every edge
12389
+ * against this file.
12390
+ *
12391
+ * PURITY. Pure data. No I/O, no rendering, no environment access, no
12392
+ * imports, no logic. Consumers convert (hex → ANSI triplet, hex → CSS)
12393
+ * themselves. Same reason `presentation.ts` is pure: it makes the whole
12394
+ * thing golden-testable and safe to ship inside the npm package, where
12395
+ * it costs a few hundred bytes and replaces values the package already
12396
+ * carried anyway.
12397
+ *
12398
+ * DERIVATION. The palette is the one derived from the logo in PR #20
12399
+ * (brushed steel and forge gold under an aurora, over midnight iron).
12400
+ * Where the terminal disagreed with it, the terminal converges — see
12401
+ * `PENDING_TERMINAL` below. Full rationale: `assets/brand/README.md`.
12402
+ *
12403
+ * ACCESSIBILITY. Every foreground token in `BRAND` meets WCAG AA
12404
+ * (≥ 4.5:1) against every surface token it is allowed to sit on. That is
12405
+ * not a claim, it is `brand-doctor` rule 8, which computes the ratios.
12406
+ * The weakest legal pairing is `steelDim` on `ink800` at 5.00:1.
11886
12407
  */
11887
- function analyzeMonorepo(packages, config) {
11888
- const results = packages.map((p) => ({
11889
- ...p,
11890
- verdict: hasBlocker(p.findings) ? "fail" : verdictOf(p.score)
11891
- }));
11892
- if (results.length === 0) return {
11893
- packages: results,
11894
- overallScore: null,
11895
- overallVerdict: "fail",
11896
- strategy: config.weightingStrategy
11897
- };
11898
- const blockerPkg = results.find((r) => r.verdict === "fail" || hasBlocker(r.findings));
11899
- if (blockerPkg) return {
11900
- packages: results,
11901
- overallScore: blockerPkg.score,
11902
- overallVerdict: "fail",
11903
- strategy: config.weightingStrategy,
11904
- blockerPackage: blockerPkg.packageName
11905
- };
11906
- switch (config.weightingStrategy) {
11907
- case "worst-package": return worstPackageAggregation(results, config);
11908
- case "average": return averageAggregation(results, config);
11909
- case "configurable": return configurableAggregation(results, config);
11910
- }
11911
- }
11912
- function worstPackageAggregation(results, config) {
11913
- const firstResult = results[0];
11914
- if (firstResult === void 0) return {
11915
- packages: results,
11916
- overallScore: null,
11917
- overallVerdict: "fail",
11918
- strategy: config.weightingStrategy
11919
- };
11920
- let worst = firstResult;
11921
- for (const r of results) if ((r.score ?? 0) < (worst.score ?? 0)) worst = r;
11922
- return {
11923
- packages: results,
11924
- overallScore: worst.score,
11925
- overallVerdict: worst.verdict,
11926
- strategy: config.weightingStrategy,
11927
- ...worst.verdict === "fail" ? { blockerPackage: worst.packageName } : {}
11928
- };
11929
- }
11930
- function averageAggregation(results, config) {
11931
- const scored = results.filter((r) => r.score !== null);
11932
- if (scored.length === 0) return {
11933
- packages: results,
11934
- overallScore: null,
11935
- overallVerdict: "fail",
11936
- strategy: config.weightingStrategy
11937
- };
11938
- const avg = scored.reduce((sum, r) => sum + (r.score ?? 0), 0) / scored.length;
11939
- return {
11940
- packages: results,
12408
+ /**
12409
+ * The two brand hues plus the neutral they sit on.
12410
+ *
12411
+ * GOLD IS SCARCE. It means forged / certified / earned / decisive — the
12412
+ * primary mark, the FORGED state, one call to action. It is not a paint
12413
+ * bucket: gold as default text, default border or default heading is a
12414
+ * brand-doctor finding, not a style choice.
12415
+ *
12416
+ * AURORA is verification energy — the secondary, and the hue that marks
12417
+ * the runtime half of the trust ladder.
12418
+ */
12419
+ const BRAND = {
12420
+ gold: "#C19A34",
12421
+ goldBright: "#E6BD57",
12422
+ goldHot: "#F4DC9C",
12423
+ /** Pressed / deepest gold — the only step dark enough to carry white. */
12424
+ goldDeep: "#A5811C",
12425
+ aurora: "#37ABBD",
12426
+ auroraBright: "#45C1D4",
12427
+ auroraCyan: "#5CBDE0",
12428
+ /** The aurora's outer curtains: atmosphere and section identity only,
12429
+ * never a verdict or a status. */
12430
+ auroraGreen: "#5FD6A4",
12431
+ auroraViolet: "#9D8CF5",
12432
+ steel: "#C8CBCF",
12433
+ steelDim: "#8B939D"
12434
+ };
12435
+ /**
12436
+ * Midnight iron. One ramp, four steps, darkest first.
12437
+ *
12438
+ * `terminal` and `terminalBar` share one tone deliberately: the window's
12439
+ * only seam is a hairline ring and an inset shadow, never a second fill.
12440
+ * `chromeDot` is the three window dots — see the note on
12441
+ * `PENDING_TERMINAL.chromeDots` for why they are no longer red/amber/green.
12442
+ */
12443
+ const SURFACE = {
12444
+ ink950: "#0A1119",
12445
+ ink900: "#0C1420",
12446
+ ink850: "#111A29",
12447
+ ink800: "#18243A",
12448
+ /** Raised panel (cards, elevated surfaces). */
12449
+ panel: "#141F33",
12450
+ /** Soft fill (inline code, quiet chips). */
12451
+ soft: "#1A2740",
12452
+ /** Terminal body — the deepest tone, so a terminal reads as recessed. */
12453
+ terminal: "#0A1119",
12454
+ /** Terminal title bar — the same tone; the seam is shadow, not colour. */
12455
+ terminalBar: "#0A1119",
12456
+ /** The three window dots. One neutral, not a traffic light. */
12457
+ chromeDot: "#18243A"
12458
+ };
12459
+ const TEXT = {
12460
+ primary: "#EAEEF5",
12461
+ secondary: "#ABB6C6",
12462
+ muted: "#8B939D",
12463
+ /** Ink for text set ON gold (buttons, the FORGED chip). 7.17:1 on `gold`. */
12464
+ onGold: "#0A1119"
12465
+ };
12466
+ /**
12467
+ * Non-score status. `ok` is the one green in the system and it is NOT a
12468
+ * score colour — it survives only for contexts with no worthiness
12469
+ * meaning ("autofix applied", "analysis complete"). A green score would
12470
+ * say "your software is fine", which is the exact claim this product
12471
+ * refuses to make.
12472
+ */
12473
+ const STATUS = {
12474
+ ok: "#4FB477",
12475
+ info: "#5CC4E0",
12476
+ warning: "#E6BD57",
12477
+ error: "#EC6B66"
12478
+ };
12479
+ /**
12480
+ * The four ScoreState bands plus the unmeasured state. Band thresholds
12481
+ * and runes live in `src/reporter/presentation.ts`, which stays free of
12482
+ * colour — it emits a palette KEY and each surface resolves it here.
12483
+ *
12484
+ * `unmeasured` is steel-dim on purpose. UNKNOWN is a legitimate answer,
12485
+ * not a failure: colouring it red would make "we did not measure this"
12486
+ * look like "this is broken", which is precisely the dishonesty the
12487
+ * north-star law exists to prevent.
12488
+ */
12489
+ const SCORE = {
12490
+ critical: "#EC6B66",
12491
+ warning: "#E6BD57",
12492
+ trusted: "#5CC4E0",
12493
+ forged: "#F4DC9C",
12494
+ unmeasured: "#8B939D"
12495
+ };
12496
+ /**
12497
+ * E0 → E1 → E2 is a certainty ramp, and it is deliberately HUE-FREE.
12498
+ *
12499
+ * Evidence level says how sure we are, not whether the news is good. A
12500
+ * deterministic proof (E2) is a defect we are certain about — painting
12501
+ * it gold or green would read as an achievement. So certainty is carried
12502
+ * by brightness alone, and the *shape* does the real work:
12503
+ *
12504
+ * E0 open ring observation, no weight
12505
+ * E1 half-filled pattern evidence, half weight
12506
+ * E2 sealed deterministic proof, full weight
12507
+ *
12508
+ * Colour never carries this alone (R11): the geometry is the signal and
12509
+ * survives `--ascii`, `NO_COLOR` and monochrome print.
12510
+ */
12511
+ const EVIDENCE = {
12512
+ e0: "#8B939D",
12513
+ e1: "#ABB6C6",
12514
+ e2: "#EAEEF5"
12515
+ };
12516
+ /**
12517
+ * L0–L5, and the most important boundary in the product.
12518
+ *
12519
+ * L0–L2 are STATIC: the neutral steel ramp, brightening to the static
12520
+ * ceiling at L2. L3–L5 require a real run, and the hue changes to aurora
12521
+ * exactly there. The boundary is a hue break, not a gradient step,
12522
+ * because it is a change of kind and not of degree — a static-only
12523
+ * finding can never climb past L2, however confident it is.
12524
+ *
12525
+ * Every surface that draws the ladder must draw that break.
12526
+ */
12527
+ const TRUST = {
12528
+ l0: "#8B939D",
12529
+ l1: "#ABB6C6",
12530
+ l2: "#C8CBCF",
12531
+ l3: "#37ABBD",
12532
+ l4: "#45C1D4",
12533
+ l5: "#5CC4E0"
12534
+ };
12535
+ 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;
12536
+ 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;
12537
+ EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2;
12538
+ const RUNG_MEANINGS = [
12539
+ "observation only",
12540
+ "heuristic static",
12541
+ "deterministic static",
12542
+ "the finding's file executed",
12543
+ "the finding's test executed",
12544
+ "the run verdict corroborates"
12545
+ ];
12546
+ const RUNG_COLORS = [
12547
+ TRUST.l0,
12548
+ TRUST.l1,
12549
+ TRUST.l2,
12550
+ TRUST.l3,
12551
+ TRUST.l4,
12552
+ TRUST.l5
12553
+ ];
12554
+ RUNG_MEANINGS.map((meaning, i) => ({
12555
+ level: `L${i}`,
12556
+ meaning,
12557
+ runtime: i >= 3,
12558
+ color: RUNG_COLORS[i]
12559
+ }));
12560
+ const HEADLINES = {
12561
+ critical: "The hammer is cracked — {n} findings break its edge.",
12562
+ warning: "The hammer holds — but {n} findings weigh it down.",
12563
+ trusted: "Held in worthy hands — {n} findings remain.",
12564
+ forged: "Static score 100 — no findings on the analyzed surface.",
12565
+ unmeasured: "No tests found — the hammer cannot be weighed."
12566
+ };
12567
+ const RUNES = {
12568
+ critical: "ᚲ",
12569
+ warning: "ᚦ",
12570
+ trusted: "ᛏ",
12571
+ forged: "ᛟ",
12572
+ unmeasured: "ᛁ"
12573
+ };
12574
+ /**
12575
+ * The named form of the band boundaries. `report:honesty` /
12576
+ * `thresholds:parity` assert that no surface outside this module
12577
+ * re-invents a boundary; a consumer that genuinely needs one names it
12578
+ * here rather than typing the number.
12579
+ */
12580
+ const SCORE_THRESHOLDS = {
12581
+ /** Below this is `critical`. */
12582
+ criticalBelow: 50,
12583
+ /** At or above this is `trusted`. */
12584
+ trustedAtOrAbove: 80,
12585
+ /** The one score that is `forged`. */
12586
+ forgedAt: 100
12587
+ };
12588
+ /**
12589
+ * Band mapping — the one mapping, every consumer: <50 critical,
12590
+ * 50–79 warning, 80–99 trusted, 100 forged, null unmeasured.
12591
+ */
12592
+ function deriveScoreState(score) {
12593
+ if (score === null) return {
12594
+ score: null,
12595
+ band: "unmeasured",
12596
+ verdict: "UNWORTHY",
12597
+ color: "dim",
12598
+ powerLevel: 0,
12599
+ headline: HEADLINES.unmeasured,
12600
+ rune: RUNES.unmeasured
12601
+ };
12602
+ if (score >= SCORE_THRESHOLDS.forgedAt) return {
12603
+ score,
12604
+ band: "forged",
12605
+ verdict: "FORGED",
12606
+ color: "forged",
12607
+ powerLevel: score,
12608
+ headline: HEADLINES.forged,
12609
+ rune: RUNES.forged
12610
+ };
12611
+ if (score >= 80) return {
12612
+ score,
12613
+ band: "trusted",
12614
+ verdict: "WORTHY",
12615
+ color: "trusted",
12616
+ powerLevel: score,
12617
+ headline: HEADLINES.trusted,
12618
+ rune: RUNES.trusted
12619
+ };
12620
+ if (score >= 50) return {
12621
+ score,
12622
+ band: "warning",
12623
+ verdict: "NEEDS WORK",
12624
+ color: "warning",
12625
+ powerLevel: score,
12626
+ headline: HEADLINES.warning,
12627
+ rune: RUNES.warning
12628
+ };
12629
+ return {
12630
+ score,
12631
+ band: "critical",
12632
+ verdict: "UNWORTHY",
12633
+ color: "error",
12634
+ powerLevel: score,
12635
+ headline: HEADLINES.critical,
12636
+ rune: RUNES.critical
12637
+ };
12638
+ }
12639
+ //#endregion
12640
+ //#region src/engine/monorepo-analysis.ts
12641
+ /**
12642
+ * Package verdict, derived from the one ScoreState band model (BW-104).
12643
+ * These two constants used to be a private copy of the band boundaries;
12644
+ * the numeric vocabulary here is a different projection of the same bands
12645
+ * — "pass" is trusted or forged, "warn" is warning, "fail" is critical or
12646
+ * unmeasurable — so the boundaries are read, never retyped.
12647
+ */
12648
+ function verdictOf(score) {
12649
+ if (score === null) return "fail";
12650
+ const band = deriveScoreState(score).band;
12651
+ if (band === "forged" || band === "trusted") return "pass";
12652
+ return band === "warning" ? "warn" : "fail";
12653
+ }
12654
+ function hasBlocker(findings) {
12655
+ return findings.some((f) => f.severity === "error");
12656
+ }
12657
+ /**
12658
+ * Aggregate per-package results into an overall verdict.
12659
+ *
12660
+ * Worst-package propagation: if ANY package has a blocker (error-severity
12661
+ * finding) or a failing score, the overall result fails regardless of
12662
+ * strategy. This is the safety net — a single poisoned package must not
12663
+ * hide behind averaging.
12664
+ */
12665
+ function analyzeMonorepo(packages, config) {
12666
+ const results = packages.map((p) => ({
12667
+ ...p,
12668
+ verdict: hasBlocker(p.findings) ? "fail" : verdictOf(p.score)
12669
+ }));
12670
+ if (results.length === 0) return {
12671
+ packages: results,
12672
+ overallScore: null,
12673
+ overallVerdict: "fail",
12674
+ strategy: config.weightingStrategy
12675
+ };
12676
+ const blockerPkg = results.find((r) => r.verdict === "fail" || hasBlocker(r.findings));
12677
+ if (blockerPkg) return {
12678
+ packages: results,
12679
+ overallScore: blockerPkg.score,
12680
+ overallVerdict: "fail",
12681
+ strategy: config.weightingStrategy,
12682
+ blockerPackage: blockerPkg.packageName
12683
+ };
12684
+ switch (config.weightingStrategy) {
12685
+ case "worst-package": return worstPackageAggregation(results, config);
12686
+ case "average": return averageAggregation(results, config);
12687
+ case "configurable": return configurableAggregation(results, config);
12688
+ }
12689
+ }
12690
+ function worstPackageAggregation(results, config) {
12691
+ const firstResult = results[0];
12692
+ if (firstResult === void 0) return {
12693
+ packages: results,
12694
+ overallScore: null,
12695
+ overallVerdict: "fail",
12696
+ strategy: config.weightingStrategy
12697
+ };
12698
+ let worst = firstResult;
12699
+ for (const r of results) if ((r.score ?? 0) < (worst.score ?? 0)) worst = r;
12700
+ return {
12701
+ packages: results,
12702
+ overallScore: worst.score,
12703
+ overallVerdict: worst.verdict,
12704
+ strategy: config.weightingStrategy,
12705
+ ...worst.verdict === "fail" ? { blockerPackage: worst.packageName } : {}
12706
+ };
12707
+ }
12708
+ function averageAggregation(results, config) {
12709
+ const scored = results.filter((r) => r.score !== null);
12710
+ if (scored.length === 0) return {
12711
+ packages: results,
12712
+ overallScore: null,
12713
+ overallVerdict: "fail",
12714
+ strategy: config.weightingStrategy
12715
+ };
12716
+ const avg = scored.reduce((sum, r) => sum + (r.score ?? 0), 0) / scored.length;
12717
+ return {
12718
+ packages: results,
11941
12719
  overallScore: Math.round(avg),
11942
12720
  overallVerdict: verdictOf(Math.round(avg)),
11943
12721
  strategy: config.weightingStrategy
@@ -11967,6 +12745,90 @@ function configurableAggregation(results, config) {
11967
12745
  };
11968
12746
  }
11969
12747
  //#endregion
12748
+ //#region src/lib/fs-atomic.ts
12749
+ /**
12750
+ * Atomic file writes (audit S9).
12751
+ *
12752
+ * Every durability-critical write in Mjölnir (baseline, stats, badge,
12753
+ * TRIAGE.md, scaffolded rule files) used to hand-roll
12754
+ * `writeFileSync(path, data)` — a crash mid-write left a TRUNCATED file
12755
+ * at the real path, and a subsequent read (diff, badge endpoint) served
12756
+ * confident nonsense from it.
12757
+ *
12758
+ * `writeFileAtomic` writes to a temp sibling, then RENAMES. On the same
12759
+ * volume rename is atomic: readers see either the complete old file or
12760
+ * the complete new file, never a half-written one. The temp name is
12761
+ * created with `wx` (exclusive) so concurrent writers cannot interleave,
12762
+ * stale temps are cleaned up on failure, and on Windows the rename is
12763
+ * retried briefly because a concurrent reader can hold the destination
12764
+ * open (EBUSY/EPERM).
12765
+ */
12766
+ function atomicTempPath(path) {
12767
+ return `${path}.mjolnir-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}.tmp`;
12768
+ }
12769
+ /**
12770
+ * Atomically replace `path` with `data`.
12771
+ *
12772
+ * Accepts binary payloads as well as text: base-tree materialization in
12773
+ * `impact` writes git blobs, and a writer that only takes strings forces those
12774
+ * call sites back to the non-atomic path rather than keeping them contained.
12775
+ */
12776
+ function writeFileAtomic(path, data, opts = {}) {
12777
+ const dir = dirname(path);
12778
+ if (opts.mkdirs !== false && !existsSync(dir)) mkdirSync(dir, { recursive: true });
12779
+ const tmp = atomicTempPath(path);
12780
+ let fd;
12781
+ try {
12782
+ fd = openSync(tmp, "wx", opts.mode ?? 420);
12783
+ if (typeof data === "string") writeSync(fd, data, null, opts.encoding ?? "utf8");
12784
+ else writeSync(fd, data);
12785
+ } finally {
12786
+ if (fd !== void 0) closeSync(fd);
12787
+ }
12788
+ try {
12789
+ renameWithWindowsRetry(tmp, path);
12790
+ } catch (err) {
12791
+ try {
12792
+ if (existsSync(tmp)) unlinkSync(tmp);
12793
+ } catch {}
12794
+ throw err;
12795
+ }
12796
+ }
12797
+ /**
12798
+ * renameSync retry loop for Windows: a concurrent reader (another scan,
12799
+ * a badge endpoint, an editor) holding the destination open makes
12800
+ * rename fail with EBUSY/EPERM. A short bounded retry closes the race
12801
+ * without turning an atomic swap into a partial write.
12802
+ *
12803
+ * Internal contract test hook: the platform check keeps this loop off
12804
+ * the POSIX hot path; on win32 the EBUSY/EPERM arms are exercised by
12805
+ * the fs-atomic-retry spec (mocked renameSync).
12806
+ */
12807
+ const RENAME_RETRIES = 8;
12808
+ const RENAME_RETRY_DELAY_MS = 25;
12809
+ /** Synchronous sleep that does not spin the CPU. */
12810
+ function sleepSync(ms) {
12811
+ try {
12812
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
12813
+ } catch {
12814
+ const until = Date.now() + ms;
12815
+ while (Date.now() < until);
12816
+ }
12817
+ }
12818
+ function renameWithWindowsRetry(from, to) {
12819
+ for (let attempt = 0;; attempt++) try {
12820
+ renameSync(from, to);
12821
+ return;
12822
+ } catch (err) {
12823
+ const code = err?.code;
12824
+ if (process.platform === "win32" && (code === "EBUSY" || code === "EPERM") && attempt < RENAME_RETRIES) {
12825
+ sleepSync(RENAME_RETRY_DELAY_MS);
12826
+ continue;
12827
+ }
12828
+ throw err;
12829
+ }
12830
+ }
12831
+ //#endregion
11970
12832
  //#region src/engine/scan-cache.ts
11971
12833
  /**
11972
12834
  * Local incremental scan cache (Beta-to-Stable 1.0 plan, M5.2 / A-2).
@@ -12002,6 +12864,17 @@ const MAX_ENTRIES$1 = 4096;
12002
12864
  * serialized size; the newest entries win (real LRU-by-use).
12003
12865
  */
12004
12866
  const MAX_TOTAL_BYTES = 33554432;
12867
+ const MAX_FINDINGS_PER_ENTRY = 1e4;
12868
+ function isCachedFinding(value) {
12869
+ if (!isRecord(value)) return false;
12870
+ return typeof value["ruleId"] === "string" && typeof value["category"] === "string" && typeof value["severity"] === "string" && typeof value["confidence"] === "string" && typeof value["findingType"] === "string" && typeof value["file"] === "string" && Number.isSafeInteger(value["line"]) && Number.isSafeInteger(value["column"]) && typeof value["message"] === "string" && typeof value["why"] === "string" && typeof value["fix"] === "string";
12871
+ }
12872
+ function isCacheFile(value) {
12873
+ if (!isRecord(value) || value["version"] !== CACHE_VERSION) return false;
12874
+ const entries = value["entries"];
12875
+ if (!isRecord(entries) || Object.keys(entries).length > MAX_ENTRIES$1) return false;
12876
+ return Object.values(entries).every((entry) => isRecord(entry) && Array.isArray(entry["findings"]) && entry["findings"].length <= MAX_FINDINGS_PER_ENTRY && entry["findings"].every(isCachedFinding));
12877
+ }
12005
12878
  /** sha256 hex of a string — the only hash this module needs. */
12006
12879
  function sha256(text) {
12007
12880
  return createHash("sha256").update(text).digest("hex");
@@ -12051,282 +12924,120 @@ function hashDir(dir, hash, depth) {
12051
12924
  for (const entry of entries) {
12052
12925
  if (entry.name === "node_modules" || entry.name === ".git") continue;
12053
12926
  const full = join(dir, entry.name);
12054
- if (entry.isDirectory()) hashDir(full, hash, depth + 1);
12055
- else if (entry.isFile() && /\.tsx?$/.test(entry.name)) try {
12056
- hash.update(entry.name);
12057
- hash.update(readFileSync(full));
12058
- } catch {}
12059
- }
12060
- }
12061
- /**
12062
- * Content-addressed key for one file's rule-loop verdicts.
12063
- *
12064
- * Audit C1: the key MUST identify the verdict's producer, not just the
12065
- * bytes — two files with byte-identical text (a copied spec, a generated
12066
- * snapshot) previously shared one entry, and the first file's cached
12067
- * findings were re-emitted for the second with the wrong `file` stamp.
12068
- * The key therefore folds in the repo-relative path AND the adapter id,
12069
- * plus a parse-mode token (audit W9): a file whose analysis degraded to
12070
- * the regex fallback (or skipped the AST path) must not collide with a
12071
- * fully-AST-analyzed verdict for the same bytes — the fallback output
12072
- * belongs only to the fallback mode.
12073
- */
12074
- function fileCacheKey(rulesDigest, fileText, identity) {
12075
- const parseMode = identity.parseMode ?? "ast";
12076
- return sha256(`${CACHE_VERSION}\u0000${rulesDigest}\u0000${identity.relPath}\u0000${identity.adapterId}\u0000${parseMode}\u0000${fileText}`);
12077
- }
12078
- /** No-op cache used when --cache is absent: zero stats, zero I/O. */
12079
- const disabledScanCache = {
12080
- stats: {
12081
- hits: 0,
12082
- misses: 0,
12083
- file: ""
12084
- },
12085
- lookup: () => void 0,
12086
- store: () => {},
12087
- persist: () => {}
12088
- };
12089
- /**
12090
- * Opens (and lazily creates) `<root>/.mjolnir/cache/scan-v<CACHE_VERSION>.json`. A
12091
- * corrupt, hostile or future-versioned cache file degrades to a cold
12092
- * cache — never fails the scan.
12093
- */
12094
- function createScanCache(root) {
12095
- const dir = join(root, ".mjolnir", "cache");
12096
- const file = join(dir, `scan-v${CACHE_VERSION}.json`);
12097
- let entries = {};
12098
- let dirty = false;
12099
- const entryBytes = /* @__PURE__ */ new Map();
12100
- let totalBytes = 0;
12101
- try {
12102
- if (existsSync(file)) {
12103
- entries = parseJsonFile(readFileSync(file, "utf8"), file, (v) => isRecord(v) && v["version"] === CACHE_VERSION && isRecord(v["entries"])).entries;
12104
- for (const [k, v] of Object.entries(entries)) {
12105
- const size = JSON.stringify(v).length + k.length + 4;
12106
- entryBytes.set(k, size);
12107
- totalBytes += size;
12108
- }
12109
- }
12110
- } catch {
12111
- entries = {};
12112
- entryBytes.clear();
12113
- totalBytes = 0;
12114
- }
12115
- return {
12116
- stats: {
12117
- hits: 0,
12118
- misses: 0,
12119
- file
12120
- },
12121
- lookup(key) {
12122
- const entry = entries[key];
12123
- if (!entry) {
12124
- this.stats.misses++;
12125
- return;
12126
- }
12127
- this.stats.hits++;
12128
- delete entries[key];
12129
- entries[key] = entry;
12130
- return structuredClone(entry.findings);
12131
- },
12132
- store(key, findings, fileBudgetExceeded) {
12133
- if (fileBudgetExceeded) return;
12134
- const replacedBytes = entryBytes.get(key) ?? 0;
12135
- delete entries[key];
12136
- const entryJson = JSON.stringify(findings);
12137
- entries[key] = { findings: structuredClone(findings) };
12138
- const newBytes = entryJson.length + key.length + 4;
12139
- entryBytes.set(key, newBytes);
12140
- totalBytes = totalBytes - replacedBytes + newBytes;
12141
- let count = Object.keys(entries).length;
12142
- while ((count > MAX_ENTRIES$1 || totalBytes > MAX_TOTAL_BYTES) && count > 1) {
12143
- const oldest = Object.keys(entries)[0];
12144
- totalBytes -= entryBytes.get(oldest);
12145
- delete entries[oldest];
12146
- entryBytes.delete(oldest);
12147
- count--;
12148
- }
12149
- dirty = true;
12150
- },
12151
- persist() {
12152
- if (!dirty) return;
12153
- try {
12154
- mkdirSync(dir, { recursive: true });
12155
- writeFileSync(file, JSON.stringify({
12156
- version: CACHE_VERSION,
12157
- entries
12158
- }), "utf8");
12159
- } catch {}
12160
- }
12161
- };
12162
- }
12163
- //#endregion
12164
- //#region src/brand/tokens.ts
12165
- /**
12166
- * The single source of brand truth.
12167
- *
12168
- * Every colour, typeface and motion constant Mjölnir shows a human —
12169
- * terminal, README SVGs, demo video, website, docs, badges — resolves to
12170
- * a value in this file. Nothing else may define one.
12171
- *
12172
- * WHY THIS EXISTS. Before it, the palette existed in six independent
12173
- * copies: `site/.vitepress/theme/styles/vars.css`, `NORSE` in
12174
- * `src/reporter/theme.ts`, `scripts/readme-svg.ts`,
12175
- * `scripts/video/terminal-page.ts`, `scripts/generate-readme-architecture.ts`
12176
- * and the table in `assets/brand/README.md`. Exactly one pair of those
12177
- * was guarded (site-doctor Check 8, doc ↔ vars.css). The unguarded edges
12178
- * are where the shipped surfaces drifted apart: the terminal and the site
12179
- * disagreed on six semantic roles, the architecture diagram invented its
12180
- * own neutral ramp, and the README badges still carried a palette retired
12181
- * two releases earlier. `scripts/brand-doctor.mjs` now checks every edge
12182
- * against this file.
12183
- *
12184
- * PURITY. Pure data. No I/O, no rendering, no environment access, no
12185
- * imports, no logic. Consumers convert (hex → ANSI triplet, hex → CSS)
12186
- * themselves. Same reason `score-state.ts` is pure: it makes the whole
12187
- * thing golden-testable and safe to ship inside the npm package, where
12188
- * it costs a few hundred bytes and replaces values the package already
12189
- * carried anyway.
12190
- *
12191
- * DERIVATION. The palette is the one derived from the logo in PR #20
12192
- * (brushed steel and forge gold under an aurora, over midnight iron).
12193
- * Where the terminal disagreed with it, the terminal converges — see
12194
- * `PENDING_TERMINAL` below. Full rationale: `assets/brand/README.md`.
12195
- *
12196
- * ACCESSIBILITY. Every foreground token in `BRAND` meets WCAG AA
12197
- * (≥ 4.5:1) against every surface token it is allowed to sit on. That is
12198
- * not a claim, it is `brand-doctor` rule 8, which computes the ratios.
12199
- * The weakest legal pairing is `steelDim` on `ink800` at 5.00:1.
12200
- */
12201
- /**
12202
- * The two brand hues plus the neutral they sit on.
12203
- *
12204
- * GOLD IS SCARCE. It means forged / certified / earned / decisive — the
12205
- * primary mark, the FORGED state, one call to action. It is not a paint
12206
- * bucket: gold as default text, default border or default heading is a
12207
- * brand-doctor finding, not a style choice.
12208
- *
12209
- * AURORA is verification energy — the secondary, and the hue that marks
12210
- * the runtime half of the trust ladder.
12211
- */
12212
- const BRAND = {
12213
- gold: "#C19A34",
12214
- goldBright: "#E6BD57",
12215
- goldHot: "#F4DC9C",
12216
- /** Pressed / deepest gold — the only step dark enough to carry white. */
12217
- goldDeep: "#A5811C",
12218
- aurora: "#37ABBD",
12219
- auroraBright: "#45C1D4",
12220
- auroraCyan: "#5CBDE0",
12221
- /** The aurora's outer curtains: atmosphere and section identity only,
12222
- * never a verdict or a status. */
12223
- auroraGreen: "#5FD6A4",
12224
- auroraViolet: "#9D8CF5",
12225
- steel: "#C8CBCF",
12226
- steelDim: "#8B939D"
12227
- };
12228
- /**
12229
- * Midnight iron. One ramp, four steps, darkest first.
12230
- *
12231
- * `terminal` and `terminalBar` share one tone deliberately: the window's
12232
- * only seam is a hairline ring and an inset shadow, never a second fill.
12233
- * `chromeDot` is the three window dots — see the note on
12234
- * `PENDING_TERMINAL.chromeDots` for why they are no longer red/amber/green.
12235
- */
12236
- const SURFACE = {
12237
- ink950: "#0A1119",
12238
- ink900: "#0C1420",
12239
- ink850: "#111A29",
12240
- ink800: "#18243A",
12241
- /** Raised panel (cards, elevated surfaces). */
12242
- panel: "#141F33",
12243
- /** Soft fill (inline code, quiet chips). */
12244
- soft: "#1A2740",
12245
- /** Terminal body — the deepest tone, so a terminal reads as recessed. */
12246
- terminal: "#0A1119",
12247
- /** Terminal title bar — the same tone; the seam is shadow, not colour. */
12248
- terminalBar: "#0A1119",
12249
- /** The three window dots. One neutral, not a traffic light. */
12250
- chromeDot: "#18243A"
12251
- };
12252
- const TEXT = {
12253
- primary: "#EAEEF5",
12254
- secondary: "#ABB6C6",
12255
- muted: "#8B939D",
12256
- /** Ink for text set ON gold (buttons, the FORGED chip). 7.17:1 on `gold`. */
12257
- onGold: "#0A1119"
12258
- };
12259
- /**
12260
- * Non-score status. `ok` is the one green in the system and it is NOT a
12261
- * score colour — it survives only for contexts with no worthiness
12262
- * meaning ("autofix applied", "analysis complete"). A green score would
12263
- * say "your software is fine", which is the exact claim this product
12264
- * refuses to make.
12265
- */
12266
- const STATUS = {
12267
- ok: "#4FB477",
12268
- info: "#5CC4E0",
12269
- warning: "#E6BD57",
12270
- error: "#EC6B66"
12271
- };
12272
- /**
12273
- * The four ScoreState bands plus the unmeasured state. Band thresholds
12274
- * and runes live in `src/reporter/score-state.ts`, which stays free of
12275
- * colour — it emits a palette KEY and each surface resolves it here.
12276
- *
12277
- * `unmeasured` is steel-dim on purpose. UNKNOWN is a legitimate answer,
12278
- * not a failure: colouring it red would make "we did not measure this"
12279
- * look like "this is broken", which is precisely the dishonesty the
12280
- * north-star law exists to prevent.
12281
- */
12282
- const SCORE = {
12283
- critical: "#EC6B66",
12284
- warning: "#E6BD57",
12285
- trusted: "#5CC4E0",
12286
- forged: "#F4DC9C",
12287
- unmeasured: "#8B939D"
12288
- };
12289
- /**
12290
- * E0 → E1 → E2 is a certainty ramp, and it is deliberately HUE-FREE.
12291
- *
12292
- * Evidence level says how sure we are, not whether the news is good. A
12293
- * deterministic proof (E2) is a defect we are certain about — painting
12294
- * it gold or green would read as an achievement. So certainty is carried
12295
- * by brightness alone, and the *shape* does the real work:
12296
- *
12297
- * E0 open ring observation, no weight
12298
- * E1 half-filled pattern evidence, half weight
12299
- * E2 sealed deterministic proof, full weight
12927
+ if (entry.isDirectory()) hashDir(full, hash, depth + 1);
12928
+ else if (entry.isFile() && /\.tsx?$/.test(entry.name)) try {
12929
+ hash.update(entry.name);
12930
+ hash.update(readFileSync(full));
12931
+ } catch {}
12932
+ }
12933
+ }
12934
+ /**
12935
+ * Content-addressed key for one file's rule-loop verdicts.
12300
12936
  *
12301
- * Colour never carries this alone (R11): the geometry is the signal and
12302
- * survives `--ascii`, `NO_COLOR` and monochrome print.
12937
+ * Audit C1: the key MUST identify the verdict's producer, not just the
12938
+ * bytes — two files with byte-identical text (a copied spec, a generated
12939
+ * snapshot) previously shared one entry, and the first file's cached
12940
+ * findings were re-emitted for the second with the wrong `file` stamp.
12941
+ * The key therefore folds in the repo-relative path AND the adapter id,
12942
+ * plus a parse-mode token (audit W9): a file whose analysis degraded to
12943
+ * the regex fallback (or skipped the AST path) must not collide with a
12944
+ * fully-AST-analyzed verdict for the same bytes — the fallback output
12945
+ * belongs only to the fallback mode.
12303
12946
  */
12304
- const EVIDENCE = {
12305
- e0: "#8B939D",
12306
- e1: "#ABB6C6",
12307
- e2: "#EAEEF5"
12947
+ function fileCacheKey(rulesDigest, fileText, identity) {
12948
+ const parseMode = identity.parseMode ?? "ast";
12949
+ return sha256(`${CACHE_VERSION}\u0000${rulesDigest}\u0000${identity.relPath}\u0000${identity.adapterId}\u0000${parseMode}\u0000${fileText}`);
12950
+ }
12951
+ /** No-op cache used when --cache is absent: zero stats, zero I/O. */
12952
+ const disabledScanCache = {
12953
+ stats: {
12954
+ hits: 0,
12955
+ misses: 0,
12956
+ file: ""
12957
+ },
12958
+ lookup: () => void 0,
12959
+ store: () => {},
12960
+ persist: () => {}
12308
12961
  };
12309
12962
  /**
12310
- * L0–L5, and the most important boundary in the product.
12311
- *
12312
- * L0–L2 are STATIC: the neutral steel ramp, brightening to the static
12313
- * ceiling at L2. L3–L5 require a real run, and the hue changes to aurora
12314
- * exactly there. The boundary is a hue break, not a gradient step,
12315
- * because it is a change of kind and not of degree — a static-only
12316
- * finding can never climb past L2, however confident it is.
12317
- *
12318
- * Every surface that draws the ladder must draw that break.
12963
+ * Opens (and lazily creates) `<root>/.mjolnir/cache/scan-v<CACHE_VERSION>.json`. A
12964
+ * corrupt, hostile or future-versioned cache file degrades to a cold
12965
+ * cache — never fails the scan.
12319
12966
  */
12320
- const TRUST = {
12321
- l0: "#8B939D",
12322
- l1: "#ABB6C6",
12323
- l2: "#C8CBCF",
12324
- l3: "#37ABBD",
12325
- l4: "#45C1D4",
12326
- l5: "#5CC4E0"
12327
- };
12328
- 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;
12329
- 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;
12967
+ function createScanCache(root) {
12968
+ const dir = join(root, ".mjolnir", "cache");
12969
+ const file = join(dir, `scan-v${CACHE_VERSION}.json`);
12970
+ let entries = {};
12971
+ let dirty = false;
12972
+ const entryBytes = /* @__PURE__ */ new Map();
12973
+ let totalBytes = 0;
12974
+ try {
12975
+ if (existsSync(file)) {
12976
+ const stat = lstatSync(file);
12977
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_TOTAL_BYTES) throw new Error("invalid cache file");
12978
+ entries = parseJsonFile(readFileSync(file, "utf8"), file, isCacheFile).entries;
12979
+ for (const [k, v] of Object.entries(entries)) {
12980
+ const size = Buffer.byteLength(JSON.stringify(v), "utf8") + k.length + 4;
12981
+ entryBytes.set(k, size);
12982
+ totalBytes += size;
12983
+ }
12984
+ if (totalBytes > MAX_TOTAL_BYTES) throw new Error("cache byte budget exceeded");
12985
+ }
12986
+ } catch {
12987
+ entries = {};
12988
+ entryBytes.clear();
12989
+ totalBytes = 0;
12990
+ }
12991
+ return {
12992
+ stats: {
12993
+ hits: 0,
12994
+ misses: 0,
12995
+ file
12996
+ },
12997
+ lookup(key) {
12998
+ const entry = entries[key];
12999
+ if (!entry) {
13000
+ this.stats.misses++;
13001
+ return;
13002
+ }
13003
+ this.stats.hits++;
13004
+ delete entries[key];
13005
+ entries[key] = entry;
13006
+ return structuredClone(entry.findings);
13007
+ },
13008
+ store(key, findings, fileBudgetExceeded) {
13009
+ if (fileBudgetExceeded) return;
13010
+ if (findings.length > MAX_FINDINGS_PER_ENTRY) return;
13011
+ const entryJson = JSON.stringify(findings);
13012
+ const newBytes = Buffer.byteLength(entryJson, "utf8") + key.length + 4;
13013
+ if (newBytes > MAX_TOTAL_BYTES) return;
13014
+ const replacedBytes = entryBytes.get(key) ?? 0;
13015
+ delete entries[key];
13016
+ entries[key] = { findings: structuredClone(findings) };
13017
+ entryBytes.set(key, newBytes);
13018
+ totalBytes = totalBytes - replacedBytes + newBytes;
13019
+ let count = Object.keys(entries).length;
13020
+ while ((count > MAX_ENTRIES$1 || totalBytes > MAX_TOTAL_BYTES) && count > 1) {
13021
+ const oldest = Object.keys(entries)[0];
13022
+ totalBytes -= entryBytes.get(oldest);
13023
+ delete entries[oldest];
13024
+ entryBytes.delete(oldest);
13025
+ count--;
13026
+ }
13027
+ dirty = true;
13028
+ },
13029
+ persist() {
13030
+ if (!dirty) return;
13031
+ try {
13032
+ mkdirSync(dir, { recursive: true });
13033
+ writeFileAtomic(file, JSON.stringify({
13034
+ version: CACHE_VERSION,
13035
+ entries
13036
+ }), { encoding: "utf8" });
13037
+ } catch {}
13038
+ }
13039
+ };
13040
+ }
12330
13041
  //#endregion
12331
13042
  //#region src/reporter/theme.ts
12332
13043
  /**
@@ -12342,8 +13053,8 @@ SCORE.trusted, SCORE.forged, SCORE.warning, SCORE.critical, STATUS.info, TEXT.on
12342
13053
  *
12343
13054
  * Symbols always accompany color (color-blind safe, R11).
12344
13055
  *
12345
- * Score-state colors come from ScoreState (score-state.ts) — the single
12346
- * source of truth shared with the badge and (P2) the web.
13056
+ * Score-state colors come from ScoreState (presentation.ts) — the single
13057
+ * source of truth shared with the badge, the dashboard and the site.
12347
13058
  *
12348
13059
  * Terminal robustness (Master-Stabilization-Plan Sprint 5 Task 22):
12349
13060
  * box-drawing/gauge helpers accept an explicit width so callers can
@@ -13506,7 +14217,7 @@ function runForensics(target, options = {}) {
13506
14217
  const base = stat.isFile() ? dirname(target) : target;
13507
14218
  flakyMdPath = join(base, "FLAKY.md");
13508
14219
  try {
13509
- writeFileSync(flakyMdPath, renderFlakyMd(report));
14220
+ writeFileAtomic(flakyMdPath, renderFlakyMd(report));
13510
14221
  } catch {
13511
14222
  flakyMdPath = void 0;
13512
14223
  }
@@ -13798,6 +14509,31 @@ function loadPlugins(root, gateOpen = false) {
13798
14509
  * next catalog render (locked by tests/local-rules.spec.ts).
13799
14510
  */
13800
14511
  const LOCAL_RULES_DIR = "mjolnir-rules";
14512
+ const MAX_EXTERNAL_RULES = 1e3;
14513
+ const MAX_RULE_FILE_BYTES = 1048576;
14514
+ const MAX_PATTERNS_PER_RULE = 100;
14515
+ function hasNestedQuantifier(pattern) {
14516
+ for (let i = 0; i < pattern.length; i++) {
14517
+ if (pattern.charAt(i) !== "(") continue;
14518
+ let bodyHasQuantifier = false;
14519
+ let j = i + 1;
14520
+ for (; j < pattern.length && pattern.charAt(j) !== ")"; j++) {
14521
+ const bodyChar = pattern.charAt(j);
14522
+ if (bodyChar === "+" || bodyChar === "*" || bodyChar === "?") {
14523
+ bodyHasQuantifier = true;
14524
+ break;
14525
+ }
14526
+ }
14527
+ if (bodyHasQuantifier) {
14528
+ while (j < pattern.length && pattern.charAt(j) !== ")") j++;
14529
+ if (j + 1 < pattern.length) {
14530
+ const next = pattern.charAt(j + 1);
14531
+ if (next === "+" || next === "*" || next === "{") return true;
14532
+ }
14533
+ }
14534
+ }
14535
+ return false;
14536
+ }
13801
14537
  const ALLOWED_CATEGORIES = /* @__PURE__ */ new Set([
13802
14538
  "QA-TEST",
13803
14539
  "QA-TQUAL",
@@ -13827,7 +14563,7 @@ const ALLOWED_QA_IMPACTS = /* @__PURE__ */ new Set([
13827
14563
  * Missing directory → empty result (not an error — most workspaces
13828
14564
  * carry none).
13829
14565
  */
13830
- async function loadLocalRules(root, gateOpen = true) {
14566
+ async function loadLocalRules(root, gateOpen = false) {
13831
14567
  const result = {
13832
14568
  rules: [],
13833
14569
  errors: [],
@@ -13835,6 +14571,15 @@ async function loadLocalRules(root, gateOpen = true) {
13835
14571
  };
13836
14572
  const dir = join(root, LOCAL_RULES_DIR);
13837
14573
  if (!existsSync(dir)) return result;
14574
+ try {
14575
+ if (lstatSync(dir).isSymbolicLink()) {
14576
+ result.errors.push(`external rules directory "${LOCAL_RULES_DIR}/" is a symlink — skipped`);
14577
+ return result;
14578
+ }
14579
+ } catch (err) {
14580
+ result.errors.push(`external rules directory "${LOCAL_RULES_DIR}/" could not be inspected: ${err instanceof Error ? err.message : String(err)}`);
14581
+ return result;
14582
+ }
13838
14583
  let entries;
13839
14584
  try {
13840
14585
  entries = readdirSync(dir);
@@ -13843,7 +14588,20 @@ async function loadLocalRules(root, gateOpen = true) {
13843
14588
  return result;
13844
14589
  }
13845
14590
  for (const entry of entries.sort()) {
14591
+ if (result.rules.length >= MAX_EXTERNAL_RULES) {
14592
+ result.errors.push(`external rule budget exceeded (${MAX_EXTERNAL_RULES})`);
14593
+ break;
14594
+ }
13846
14595
  const path = join(dir, entry);
14596
+ try {
14597
+ if (lstatSync(path).isSymbolicLink()) {
14598
+ result.errors.push(`external rule "${LOCAL_RULES_DIR}/${entry}" is a symlink — skipped`);
14599
+ continue;
14600
+ }
14601
+ } catch (err) {
14602
+ result.errors.push(`external rule "${LOCAL_RULES_DIR}/${entry}" could not be inspected: ${err instanceof Error ? err.message : String(err)}`);
14603
+ continue;
14604
+ }
13847
14605
  if (entry.endsWith(".json")) loadJsonRule(path, result);
13848
14606
  else if (entry.endsWith(".mjs") || entry.endsWith(".js")) {
13849
14607
  if (!gateOpen) result.skipped.push(`${LOCAL_RULES_DIR}/${entry}`);
@@ -13856,6 +14614,10 @@ function loadJsonRule(path, result) {
13856
14614
  const name = `${LOCAL_RULES_DIR}/${path.split(/[\\/]/).pop()}`;
13857
14615
  let raw;
13858
14616
  try {
14617
+ if (lstatSync(path).size > MAX_RULE_FILE_BYTES) {
14618
+ result.errors.push(`external rule "${name}" exceeds the file size budget`);
14619
+ return;
14620
+ }
13859
14621
  raw = JSON.parse(readFileSync(path, "utf8"));
13860
14622
  } catch (err) {
13861
14623
  result.errors.push(`external rule "${name}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
@@ -13876,7 +14638,7 @@ function loadJsonRule(path, result) {
13876
14638
  return;
13877
14639
  }
13878
14640
  const patterns = decl["patterns"];
13879
- if (!Array.isArray(patterns) || patterns.length === 0 || !patterns.every((p) => typeof p === "string" && p.length > 0)) {
14641
+ if (!Array.isArray(patterns) || patterns.length === 0 || patterns.length > MAX_PATTERNS_PER_RULE || !patterns.every((p) => typeof p === "string" && p.length > 0)) {
13880
14642
  result.errors.push(`external rule ${id} must declare a non-empty "patterns" array of regex source strings.`);
13881
14643
  return;
13882
14644
  }
@@ -13892,6 +14654,10 @@ function loadJsonRule(path, result) {
13892
14654
  result.errors.push(`external rule ${id} has a pattern longer than 512 chars — likely a copy-paste error.`);
13893
14655
  return;
13894
14656
  }
14657
+ if (hasNestedQuantifier(p)) {
14658
+ result.errors.push(`external rule ${id} has a nested quantifier that may cause catastrophic backtracking`);
14659
+ return;
14660
+ }
13895
14661
  const quantifiers = p.match(/[+*?]|\{\d/g);
13896
14662
  if (quantifiers && quantifiers.length > 64) {
13897
14663
  result.errors.push(`external rule ${id} has a pattern with more than 64 quantifier/wildcard tokens — likely catastrophic backtracking.`);
@@ -14071,6 +14837,8 @@ function renderGateNotice(skipped) {
14071
14837
  * module moved. Re-exports in cli.ts keep the historical import surface.
14072
14838
  */
14073
14839
  const UNIVERSAL_RULES = RULES.map(asUniversal);
14840
+ const DEFAULT_MAX_DURATION_MS$1 = 6e5;
14841
+ const MAX_DURATION_MS$1 = 36e5;
14074
14842
  /** Registered rule IDs — used to warn on unknown severityOverrides keys (M4). */
14075
14843
  const KNOWN_RULE_IDS = new Set(RULES.map((r) => r.id));
14076
14844
  /**
@@ -14287,7 +15055,9 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14287
15055
  let testDeclarationCount = 0;
14288
15056
  let rulesPartial = false;
14289
15057
  let parseFailed = 0;
15058
+ let parseFallbacks = 0;
14290
15059
  let scanned = 0;
15060
+ let analyzed = 0;
14291
15061
  for (const path of testFiles) {
14292
15062
  if (Date.now() > deadline) {
14293
15063
  rulesPartial = true;
@@ -14301,13 +15071,16 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14301
15071
  if (!isCiAdapter) testFileCount++;
14302
15072
  let text;
14303
15073
  try {
14304
- text = readFileSync(path, "utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
15074
+ const readResult = readFileBounded(path, LIMITS$1.maxFileBytes);
15075
+ if (!readResult.ok) {
15076
+ skippedFiles++;
15077
+ continue;
15078
+ }
15079
+ text = readResult.data.toString("utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
14305
15080
  } catch {
14306
15081
  skippedFiles++;
14307
15082
  continue;
14308
15083
  }
14309
- let fileBudgetExceeded = false;
14310
- let fileRuleFailed = false;
14311
15084
  const relPath = relative(workspace.root, path).replaceAll("\\", "/");
14312
15085
  if (!isCiAdapter) {
14313
15086
  const lang = {
@@ -14333,74 +15106,106 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14333
15106
  });
14334
15107
  let cacheKey = fileCacheKey(rulesDigest, text, identity(wantsAst ? "ast" : "regex"));
14335
15108
  const cachedFindings = cache.lookup(cacheKey);
14336
- if (cachedFindings) {
14337
- for (const f of cachedFindings) findings.push(f);
14338
- continue;
14339
- }
14340
- hooks.onProgress?.({
14341
- phase: "parse",
14342
- done: scanned,
14343
- total: testFiles.length,
14344
- detail: relPath
14345
- });
14346
- const parsedFile = {
14347
- path: relPath,
14348
- text
14349
- };
14350
- let parsed;
14351
- const findingsStart = findings.length;
14352
- try {
14353
- if (adapter.parseAst && wantsAst) {
14354
- hooks.onProgress?.({
14355
- phase: "rules",
14356
- done: scanned,
14357
- total: testFiles.length,
14358
- detail: relPath
14359
- });
14360
- parsed = await adapter.parseAst(parsedFile);
14361
- }
14362
- const actualMode = parsed ? "ast" : "regex";
14363
- if (wantsAst && actualMode === "regex") {
14364
- cacheKey = fileCacheKey(rulesDigest, text, identity(actualMode));
14365
- const fallbackFindings = cache.lookup(cacheKey);
14366
- if (fallbackFindings) {
14367
- for (const f of fallbackFindings) findings.push(f);
14368
- continue;
15109
+ const executor = createExecutor("sequential", 1, async (job) => {
15110
+ if (job.cacheHit !== void 0) return {
15111
+ path: job.path,
15112
+ status: "CACHE_HIT",
15113
+ findings: [...job.cacheHit],
15114
+ parseFallback: false
15115
+ };
15116
+ let parseFallback = false;
15117
+ let fileRuleFailed = false;
15118
+ let fileBudgetExceeded = false;
15119
+ let parsedAst;
15120
+ try {
15121
+ if (adapter.parseAst && job.wantsAst) {
15122
+ hooks.onProgress?.({
15123
+ phase: "rules",
15124
+ done: scanned,
15125
+ total: testFiles.length,
15126
+ detail: job.path
15127
+ });
15128
+ parsedAst = await adapter.parseAst({
15129
+ path: job.path,
15130
+ text: job.text
15131
+ });
14369
15132
  }
14370
- }
14371
- const fileForRules = parsed ? {
14372
- ...parsedFile,
14373
- ast: parsed.ast
14374
- } : parsedFile;
14375
- adapter.runRules(activeRules, fileForRules, (f, ruleId, category) => {
14376
- if (!isValidFindingRecord(f)) {
14377
- fileRuleFailed = true;
14378
- onRuleCrash?.(ruleId, relPath, /* @__PURE__ */ new Error(`malformed finding record rejected (severity/line/message must be present, severity ∈ error|warning|info): ${JSON.stringify(f)}`));
14379
- return;
15133
+ const actualMode = parsedAst ? "ast" : "regex";
15134
+ if (job.wantsAst && actualMode === "regex") {
15135
+ parseFallback = true;
15136
+ parseFallbacks++;
15137
+ cacheKey = fileCacheKey(rulesDigest, job.text, identity(actualMode));
15138
+ const fallbackFindings = cache.lookup(cacheKey);
15139
+ if (fallbackFindings) return {
15140
+ path: job.path,
15141
+ status: "CACHE_HIT",
15142
+ findings: [...fallbackFindings],
15143
+ parseFallback: true
15144
+ };
14380
15145
  }
14381
- findings.push({
14382
- ...f,
14383
- ruleId,
14384
- category
15146
+ const fileForRules = parsedAst ? {
15147
+ path: job.path,
15148
+ text: job.text,
15149
+ ast: parsedAst.ast
15150
+ } : {
15151
+ path: job.path,
15152
+ text: job.text
15153
+ };
15154
+ const produced = [];
15155
+ adapter.runRules(activeRules, fileForRules, (f, ruleId, category) => {
15156
+ if (!isValidFindingRecord(f)) {
15157
+ fileRuleFailed = true;
15158
+ onRuleCrash?.(ruleId, job.path, /* @__PURE__ */ new Error(`malformed finding record rejected (severity/line/message must be present, severity ∈ error|warning|info): ${JSON.stringify(f)}`));
15159
+ return;
15160
+ }
15161
+ produced.push({
15162
+ ...f,
15163
+ ruleId,
15164
+ category
15165
+ });
15166
+ }, (ruleId, error) => {
15167
+ fileRuleFailed = true;
15168
+ onRuleCrash?.(ruleId, job.path, error);
15169
+ }, {
15170
+ deadline: Math.min(deadline, Date.now() + LIMITS$1.maxFileAnalysisMs),
15171
+ onExceeded: () => {
15172
+ rulesPartial = true;
15173
+ skippedFiles++;
15174
+ truncationReasons.add("file-budget");
15175
+ fileBudgetExceeded = true;
15176
+ }
14385
15177
  });
14386
- }, (ruleId, error) => {
14387
- fileRuleFailed = true;
14388
- onRuleCrash?.(ruleId, relPath, error);
14389
- }, {
14390
- deadline: Math.min(deadline, Date.now() + LIMITS$1.maxFileAnalysisMs),
14391
- onExceeded: () => {
14392
- rulesPartial = true;
14393
- skippedFiles++;
14394
- truncationReasons.add("file-budget");
14395
- fileBudgetExceeded = true;
14396
- }
14397
- });
14398
- if (!fileRuleFailed) cache.store(cacheKey, findings.slice(findingsStart), fileBudgetExceeded);
14399
- } catch {
14400
- skippedFiles++;
14401
- parseFailed++;
14402
- } finally {
14403
- parsed?.dispose();
15178
+ if (!fileRuleFailed && !fileBudgetExceeded) analyzed++;
15179
+ if (!fileRuleFailed) cache.store(cacheKey, produced, fileBudgetExceeded);
15180
+ return {
15181
+ path: job.path,
15182
+ status: "OK",
15183
+ findings: produced,
15184
+ parseFallback
15185
+ };
15186
+ } catch {
15187
+ if (job.wantsAst) parseFallbacks++;
15188
+ skippedFiles++;
15189
+ parseFailed++;
15190
+ return {
15191
+ path: job.path,
15192
+ status: "FAILED",
15193
+ findings: [],
15194
+ parseFallback
15195
+ };
15196
+ } finally {
15197
+ parsedAst?.dispose();
15198
+ }
15199
+ });
15200
+ const outcome = (await executeFiles([{
15201
+ path: relPath,
15202
+ text,
15203
+ wantsAst,
15204
+ ...cachedFindings !== void 0 ? { cacheHit: cachedFindings } : {}
15205
+ }], executor))[0];
15206
+ if (outcome?.status === "CACHE_HIT" || outcome?.status === "OK") {
15207
+ for (const f of outcome.findings) findings.push(f);
15208
+ if (cachedFindings !== void 0 && outcome.status === "CACHE_HIT") analyzed++;
14404
15209
  }
14405
15210
  }
14406
15211
  return {
@@ -14409,7 +15214,9 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14409
15214
  testDeclarationCount,
14410
15215
  rulesPartial,
14411
15216
  parseFailed,
14412
- scanned
15217
+ parseFallbacks,
15218
+ scanned,
15219
+ analyzed
14413
15220
  };
14414
15221
  }
14415
15222
  /**
@@ -14487,18 +15294,24 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
14487
15294
  for (const f of findings) f.fixGroupId = f.ruleId;
14488
15295
  const discoveredReport = discoverAndParseRuntimeReport(scanRoot.root);
14489
15296
  const runtimeReportPath = discoveredReport?.path;
15297
+ const runtimeIncomplete = discoveredReport !== void 0 && discoveredReport.report.analysisComplete !== true;
14490
15298
  let forensicVerdicts;
15299
+ let evidenceRecords = [];
14491
15300
  if (discoveredReport && discoveredReport.report.analysisComplete === true) try {
14492
- buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
15301
+ evidenceRecords = buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
14493
15302
  stampRuntimeCorroboration(findings, discoveredReport.report, workspace.root);
14494
15303
  forensicVerdicts = summarizeForensicVerdicts(discoveredReport.report);
14495
- } catch {}
15304
+ } catch {
15305
+ evidenceRecords = [];
15306
+ }
14496
15307
  return {
15308
+ evidenceRecords,
14497
15309
  testDeclarationCount,
14498
15310
  scopeInfo,
14499
15311
  suppressionCount,
14500
15312
  frameworks,
14501
15313
  runtimeReportPath,
15314
+ runtimeIncomplete,
14502
15315
  forensicVerdicts,
14503
15316
  config
14504
15317
  };
@@ -14567,10 +15380,13 @@ function assembleScanResult(o) {
14567
15380
  if (o.scopeIgnored > 0) scopeReasons.push(`ignored:${o.scopeIgnored}`);
14568
15381
  if (o.scopeUnrecognized > 0) scopeReasons.push(`unrecognized:${o.scopeUnrecognized}`);
14569
15382
  if (o.parseFailed > 0) scopeReasons.push(`parseFailed:${o.parseFailed}`);
15383
+ if (o.skippedFiles > 0) scopeReasons.push(`skipped:${o.skippedFiles}`);
15384
+ if (o.scopeInfo.degraded) scopeReasons.push(`degraded:${o.scopeInfo.degraded}`);
15385
+ if (o.runtimeIncomplete) scopeReasons.push("runtime-incomplete");
14570
15386
  for (const reason of o.truncationReasons) scopeReasons.push(`truncated:${reason}`);
14571
15387
  const scopeIntegrity = {
14572
15388
  discovered: o.testFiles.length,
14573
- analyzed: Math.max(0, o.scanned),
15389
+ analyzed: Math.max(0, o.analyzed ?? o.scanned),
14574
15390
  ignored: o.scopeIgnored,
14575
15391
  unrecognized: o.scopeUnrecognized,
14576
15392
  parseFailed: o.parseFailed,
@@ -14579,28 +15395,61 @@ function assembleScanResult(o) {
14579
15395
  ...scopeReasons.length > 0 ? { reasons: scopeReasons } : {}
14580
15396
  };
14581
15397
  const scopeAdjustedTotal = scopeReasons.length > 0 && total >= 100 ? 99 : total;
15398
+ let identityIncomplete = false;
14582
15399
  const inputSnapshot = o.testFiles.map((p) => {
14583
15400
  const relPath = relative(o.workspace.root, p).replaceAll("\\", "/");
14584
15401
  try {
14585
- const content = readFileSync(p);
14586
- const hash = createHash("sha256").update(content).digest("hex").slice(0, 16);
15402
+ const read = readFileBounded(resolve(o.workspace.root, p), LIMITS$1.maxFileBytes);
15403
+ if (!read.ok) {
15404
+ if (existsSync(o.workspace.root)) identityIncomplete = true;
15405
+ return {
15406
+ path: relPath,
15407
+ size: 0,
15408
+ hash: "UNAVAILABLE"
15409
+ };
15410
+ }
15411
+ const hash = createHash("sha256").update(read.data).digest("hex");
14587
15412
  return {
14588
15413
  path: relPath,
14589
- size: content.length,
15414
+ size: read.data.length,
14590
15415
  hash
14591
15416
  };
14592
15417
  } catch {
15418
+ if (existsSync(o.workspace.root)) identityIncomplete = true;
14593
15419
  return {
14594
15420
  path: relPath,
14595
- size: 0
15421
+ size: 0,
15422
+ hash: "UNAVAILABLE"
14596
15423
  };
14597
15424
  }
14598
15425
  });
14599
15426
  let reportDigest;
14600
15427
  if (o.runtimeReportPath) try {
14601
- const reportBytes = readFileSync(o.runtimeReportPath);
14602
- reportDigest = createHash("sha256").update(reportBytes).digest("hex").slice(0, 16);
14603
- } catch {}
15428
+ const reportPath = resolve(o.workspace.root, o.runtimeReportPath);
15429
+ if (lstatSync(reportPath).isFile()) {
15430
+ const read = readFileBounded(reportPath, LIMITS$1.maxFileBytes);
15431
+ if (!read.ok) identityIncomplete = true;
15432
+ else reportDigest = createHash("sha256").update(read.data).digest("hex");
15433
+ }
15434
+ } catch {
15435
+ identityIncomplete = true;
15436
+ }
15437
+ const completion = deriveCompletion({
15438
+ discoveryTruncated: o.discoveryTruncated,
15439
+ rulesPartial: o.rulesPartial,
15440
+ skippedFiles: o.skippedFiles,
15441
+ rulesCrashed: o.rulesCrashed,
15442
+ truncationReasons: o.truncationReasons,
15443
+ scopeIgnored: o.scopeIgnored,
15444
+ scopeUnrecognized: o.scopeUnrecognized,
15445
+ parseFailed: o.parseFailed,
15446
+ parseFallbacks: o.parseFallbacks ?? 0,
15447
+ ...o.scopeInfo.degraded !== void 0 ? { scopeDegraded: o.scopeInfo.degraded } : {},
15448
+ ...o.runtimeIncomplete !== void 0 ? { runtimeIncomplete: o.runtimeIncomplete } : {},
15449
+ identityIncomplete
15450
+ });
15451
+ const repository = bindRepository(o.scanRoot.root);
15452
+ const candidate = readCandidateBinding(o.scanRoot.root);
14604
15453
  const runIdentity = buildRunIdentity({
14605
15454
  files: inputSnapshot,
14606
15455
  rules: [...o.REVISION_BY_RULE_ID.entries()].map(([id, detectorRevision]) => ({
@@ -14613,18 +15462,32 @@ function assembleScanResult(o) {
14613
15462
  trustModelVersion: TRUST_MODEL_VERSION,
14614
15463
  scoringModelVersion: SCORING_MODEL_VERSION,
14615
15464
  frameworkSupportMatrixVersion: FRAMEWORK_SUPPORT_MATRIX_VERSION,
14616
- evidenceSchemaVersions: [1]
15465
+ evidenceSchemaVersions: [1],
15466
+ ...repository,
15467
+ ...candidate !== null ? { candidate } : {}
15468
+ });
15469
+ const evidenceGraph = buildEvidenceGraph({
15470
+ runId: runIdentity,
15471
+ ...candidate !== null ? { candidate: {
15472
+ manifestId: candidate.manifestId,
15473
+ candidateSha: candidate.candidateSha
15474
+ } } : {}
14617
15475
  });
14618
- const evidenceGraph = buildEvidenceGraph({ runId: runIdentity });
14619
15476
  const hasTests = o.testFileCount > 0 && o.testDeclarationCount > 0;
14620
15477
  const suiteInvalidatedBy = [...new Set(o.findings.filter((f) => SUITE_INVALIDATING_RULE_IDS.has(f.ruleId)).map((f) => f.ruleId))].sort();
15478
+ const finalScore = hasTests ? completion.partial && scopeAdjustedTotal >= 100 ? 99 : scopeAdjustedTotal : null;
14621
15479
  const result = {
14622
15480
  schemaVersion: 1,
14623
- partial: o.discoveryTruncated || o.rulesPartial || o.skippedFiles > 0 || o.rulesCrashed > 0 || o.scopeInfo.degraded !== void 0,
15481
+ partial: completion.partial,
14624
15482
  scopeIntegrity,
14625
15483
  runIdentity,
14626
15484
  evidenceGraph,
14627
- score: hasTests ? scopeAdjustedTotal : null,
15485
+ evidence: {
15486
+ records: o.evidenceRecords ?? [],
15487
+ counts: countEvidence(o.evidenceRecords ?? []),
15488
+ artifact: o.runtimeReportPath ?? null
15489
+ },
15490
+ score: finalScore,
14628
15491
  ...hasTests ? {} : { reason: "no-tests-found" },
14629
15492
  frameworks: o.frameworks.frameworks,
14630
15493
  frameworkDetectionUnknown: o.frameworks.unknown,
@@ -14650,12 +15513,8 @@ function assembleScanResult(o) {
14650
15513
  file: o.cache.stats.file
14651
15514
  } } : {},
14652
15515
  analysisStatus: {
14653
- discovery: o.discoveryTruncated ? "partial" : "complete",
14654
- rules: o.rulesPartial ? "partial" : "complete",
14655
- skippedFiles: o.skippedFiles,
14656
- durationMs: elapsed,
14657
- rulesCrashed: o.rulesCrashed,
14658
- ...o.truncationReasons.size > 0 ? { truncationReasons: [...o.truncationReasons].sort() } : {}
15516
+ ...completion.analysisStatus,
15517
+ durationMs: elapsed
14659
15518
  },
14660
15519
  scoringModelVersion: SCORING_MODEL_VERSION
14661
15520
  };
@@ -14703,7 +15562,8 @@ function assembleScanResult(o) {
14703
15562
  */
14704
15563
  async function runScan(args, hooks = {}) {
14705
15564
  const started = Date.now();
14706
- const deadline = started + args.maxDurationMs;
15565
+ const requestedDuration = Number.isFinite(args.maxDurationMs) ? args.maxDurationMs : DEFAULT_MAX_DURATION_MS$1;
15566
+ const deadline = started + Math.min(Math.max(1, requestedDuration), MAX_DURATION_MS$1);
14707
15567
  const discovered = discoverWorkspace(args.target);
14708
15568
  const targetAbs = resolve(args.target);
14709
15569
  const scanRoot = discovered && discovered.root !== targetAbs && targetAbs.startsWith(discovered.root + sep) ? {
@@ -14792,7 +15652,9 @@ async function runScan(args, hooks = {}) {
14792
15652
  testDeclarationCount = analysis.testDeclarationCount;
14793
15653
  rulesPartial = analysis.rulesPartial;
14794
15654
  parseFailed = analysis.parseFailed;
15655
+ const parseFallbacks = analysis.parseFallbacks;
14795
15656
  const scanned = analysis.scanned;
15657
+ const analyzed = analysis.analyzed;
14796
15658
  const postScan = applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, declarationsByFile, testDeclarationCount, tierByRuleId, REVISION_BY_RULE_ID);
14797
15659
  testDeclarationCount = postScan.testDeclarationCount;
14798
15660
  const result = assembleScanResult({
@@ -14808,7 +15670,9 @@ async function runScan(args, hooks = {}) {
14808
15670
  scopeIgnored,
14809
15671
  scopeUnrecognized,
14810
15672
  parseFailed,
15673
+ parseFallbacks,
14811
15674
  scanned,
15675
+ analyzed,
14812
15676
  testFiles,
14813
15677
  workspace,
14814
15678
  scanRoot,
@@ -14821,6 +15685,7 @@ async function runScan(args, hooks = {}) {
14821
15685
  suppressionCount: postScan.suppressionCount,
14822
15686
  frameworks: postScan.frameworks,
14823
15687
  runtimeReportPath: postScan.runtimeReportPath,
15688
+ runtimeIncomplete: postScan.runtimeIncomplete,
14824
15689
  forensicVerdicts: postScan.forensicVerdicts,
14825
15690
  config: postScan.config,
14826
15691
  fileProvenance,
@@ -14852,6 +15717,10 @@ function digestView(f) {
14852
15717
  trustLevel: f.trustLevel ?? null,
14853
15718
  confidence: f.confidence,
14854
15719
  findingType: f.findingType,
15720
+ qaImpact: f.qaImpact,
15721
+ findingId: f.findingId ?? null,
15722
+ rootCauseId: f.rootCauseId ?? null,
15723
+ deduplicationGroup: f.deduplicationGroup ?? null,
14855
15724
  fixGroupId: f.fixGroupId ?? null
14856
15725
  };
14857
15726
  }
@@ -14872,8 +15741,12 @@ function canonicalScanJson(result) {
14872
15741
  rules: result.analysisStatus.rules,
14873
15742
  skippedFiles: result.analysisStatus.skippedFiles,
14874
15743
  rulesCrashed: result.analysisStatus.rulesCrashed ?? 0,
14875
- truncationReasons: result.analysisStatus.truncationReasons ?? []
15744
+ parseFallbacks: result.analysisStatus.parseFallbacks ?? 0,
15745
+ truncationReasons: result.analysisStatus.truncationReasons ?? [],
15746
+ reasons: result.analysisStatus.reasons ?? []
14876
15747
  },
15748
+ scopeIntegrity: result.scopeIntegrity ?? null,
15749
+ runIdentity: result.runIdentity ?? null,
14877
15750
  findings: result.findings.map(digestView)
14878
15751
  });
14879
15752
  }
@@ -14930,9 +15803,13 @@ function buildMachineContract(result) {
14930
15803
  rules: result.analysisStatus.rules,
14931
15804
  skippedFiles: result.analysisStatus.skippedFiles,
14932
15805
  rulesCrashed: result.analysisStatus.rulesCrashed ?? 0,
15806
+ ...result.analysisStatus.parseFallbacks !== void 0 ? { parseFallbacks: result.analysisStatus.parseFallbacks } : {},
14933
15807
  truncationReasons: result.analysisStatus.truncationReasons ?? [],
15808
+ ...result.analysisStatus.reasons !== void 0 ? { reasons: result.analysisStatus.reasons } : {},
14934
15809
  frameworkDetectionUnknown: result.frameworkDetectionUnknown,
14935
- durationMs: result.analysisStatus.durationMs
15810
+ durationMs: result.analysisStatus.durationMs,
15811
+ ...result.scopeIntegrity !== void 0 ? { scopeIntegrity: result.scopeIntegrity } : {},
15812
+ ...result.runIdentity !== void 0 ? { runIdentity: result.runIdentity } : {}
14936
15813
  },
14937
15814
  ...result.trustSummary !== void 0 ? { trustSummary: result.trustSummary } : {},
14938
15815
  ...result.agenticProfile !== void 0 ? { provenance: result.agenticProfile } : {},
@@ -15293,12 +16170,15 @@ function trustVerdictFor(v) {
15293
16170
  * today the honest command set is repeat/compare/inspect, not
15294
16171
  * "collect a trace", because trace.zip is not yet ingested.
15295
16172
  */
16173
+ function safeTriageText(value) {
16174
+ return value.replace(/[;&|`$()\\\r\n]/g, " ").slice(0, 200);
16175
+ }
15296
16176
  function nextActionFor(v) {
15297
16177
  switch (classifyVerdict(v)) {
15298
- case "RETRY-DEPENDENT": return `repeat execution of this test (3+ runs) to confirm the flake rate, then quarantine + ticket: ${v.file}`;
15299
- case "TIMEOUT": return `re-run in isolation to separate slowness from a hang: npx playwright test ${v.file} --timeout 60000`;
15300
- case "FAILING": return `reproduce locally: npx playwright test ${v.file} -g "${v.title.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}" — then fix and re-run`;
15301
- case "SKIPPED": return `inspect the skip condition in ${v.file} — a skip after a failure usually hides an environment problem`;
16178
+ case "RETRY-DEPENDENT": return `repeat execution of this test (3+ runs) to confirm the flake rate, then quarantine + ticket: ${safeTriageText(v.file)}`;
16179
+ case "TIMEOUT": return `re-run this test in isolation with a 60-second timeout to separate slowness from a hang: ${safeTriageText(v.file)}`;
16180
+ case "FAILING": return `reproduce this test in isolation, then fix and re-run: ${safeTriageText(v.file)} — ${safeTriageText(v.title)}`;
16181
+ case "SKIPPED": return `inspect the skip condition in ${safeTriageText(v.file)} — a skip after a failure usually hides an environment problem`;
15302
16182
  }
15303
16183
  }
15304
16184
  /** Deterministic guided rows, worst first (same order law as triageRows). */
@@ -15393,7 +16273,16 @@ function renderPwRunSummary(s) {
15393
16273
  */
15394
16274
  /** Hard caps (§21 threat model: parameter size + resource bounds). */
15395
16275
  const MAX_PARAM_BYTES = 65536;
16276
+ const MAX_DURATION_MS = 36e5;
16277
+ const DEFAULT_MAX_DURATION_MS = 6e5;
15396
16278
  const PROTOCOL_VERSION = "2025-06-18";
16279
+ const CONFIGURED_ROOT = process.env.MJOLNIR_WORKSPACE_ROOT ? resolve(process.env.MJOLNIR_WORKSPACE_ROOT) : null;
16280
+ function isPathAllowed(path) {
16281
+ if (!CONFIGURED_ROOT) return true;
16282
+ const candidate = resolve(path);
16283
+ const rel = relative(CONFIGURED_ROOT, candidate);
16284
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
16285
+ }
15397
16286
  /** JSON-RPC error codes (subset used here). */
15398
16287
  const MCP_ERRORS = {
15399
16288
  PARSE: -32700,
@@ -15512,7 +16401,10 @@ function validateParams(name, args) {
15512
16401
  if (Buffer$1.byteLength(JSON.stringify(args), "utf8") > MAX_PARAM_BYTES) return `parameters exceed ${MAX_PARAM_BYTES} bytes (threat model §21)`;
15513
16402
  if (name === "scan") {
15514
16403
  if (typeof args["path"] !== "string" || args["path"].length === 0) return "scan requires a non-empty string `path`";
15515
- if (args["maxDurationMs"] !== void 0 && typeof args["maxDurationMs"] !== "number") return "`maxDurationMs` must be a number";
16404
+ if (args["maxDurationMs"] !== void 0) {
16405
+ const duration = args["maxDurationMs"];
16406
+ if (typeof duration !== "number" || !Number.isFinite(duration) || duration <= 0 || duration > MAX_DURATION_MS) return "`maxDurationMs` must be a finite positive number within the server budget";
16407
+ }
15516
16408
  }
15517
16409
  if (name === "explain") {
15518
16410
  if (typeof args["ruleId"] !== "string" || !/^QA-[A-Z]+-\d{3}$/.test(args["ruleId"])) return "explain requires `ruleId` matching /^QA-[A-Z]+-\\d{3}$/";
@@ -15557,6 +16449,24 @@ async function handleToolCall(call) {
15557
16449
  message: paramError
15558
16450
  }
15559
16451
  };
16452
+ const pathArgument = call.args["path"];
16453
+ if (typeof pathArgument === "string" && !isPathAllowed(pathArgument)) return {
16454
+ jsonrpc: "2.0",
16455
+ id: call.id,
16456
+ error: {
16457
+ code: MCP_ERRORS.INVALID_PARAMS,
16458
+ message: "path is outside the configured workspace root"
16459
+ }
16460
+ };
16461
+ const fixturesRoot = call.args["fixturesRoot"];
16462
+ if (typeof fixturesRoot === "string" && !isPathAllowed(fixturesRoot)) return {
16463
+ jsonrpc: "2.0",
16464
+ id: call.id,
16465
+ error: {
16466
+ code: MCP_ERRORS.INVALID_PARAMS,
16467
+ message: "fixturesRoot is outside the configured workspace root"
16468
+ }
16469
+ };
15560
16470
  try {
15561
16471
  if (call.name === "scan") {
15562
16472
  const target = resolve(call.args["path"]);
@@ -15568,7 +16478,7 @@ async function handleToolCall(call) {
15568
16478
  message: `scan target does not exist: ${target}`
15569
16479
  }
15570
16480
  };
15571
- const maxDurationMs = typeof call.args["maxDurationMs"] === "number" ? call.args["maxDurationMs"] : Number.POSITIVE_INFINITY;
16481
+ const maxDurationMs = typeof call.args["maxDurationMs"] === "number" ? call.args["maxDurationMs"] : DEFAULT_MAX_DURATION_MS;
15572
16482
  const result = await serializeScan(() => runScan({
15573
16483
  target,
15574
16484
  json: true,
@@ -15959,6 +16869,133 @@ async function handleStdioLine(line, write) {
15959
16869
  write(`${JSON.stringify(response)}\n`);
15960
16870
  }
15961
16871
  //#endregion
16872
+ //#region src/integrations/sentry.ts
16873
+ /**
16874
+ * Sentry crash reporting — opt-in, and inert unless a DSN is configured.
16875
+ *
16876
+ * Why opt-in: Mjölnir ships as an `npx` CLI over other people's private
16877
+ * repositories. A tool that reads a codebase must never phone home on its
16878
+ * own, so the ONLY switch is an explicit `SENTRY_DSN` in the environment.
16879
+ * Absent that, every function here returns immediately and `@sentry/node` is
16880
+ * never imported — zero network, zero added startup cost on a path whose
16881
+ * whole selling point is scan speed.
16882
+ *
16883
+ * Why an optional peer dependency: `@sentry/node` is ~1.5 MB unpacked and
16884
+ * nothing in a code scanner needs it. Making it a hard `dependency` would
16885
+ * tax every install for a feature most users never turn on, so it is an
16886
+ * optional peer: installs on request (`npm i @sentry/node`), absent by
16887
+ * default, and a missing module is handled as "reporting unavailable"
16888
+ * rather than as a crash.
16889
+ *
16890
+ * Capture points are deliberately the two EXISTING top-level catch blocks
16891
+ * (cli.ts entry, mcp/stdio.ts) rather than `process.on("uncaughtException")`
16892
+ * / `("unhandledRejection")` handlers. Registering those would silently
16893
+ * change the CLI's frozen exit-code contract (§24.1) and could keep a
16894
+ * process alive after a fatal error. One capture point per surface is
16895
+ * enough to answer "did Mjölnir itself break, and where".
16896
+ *
16897
+ * What is deliberately NOT sent: no user data (`sendDefaultPii: false`), no
16898
+ * traced request/span data (`tracesSampleRate: 0` — a batch CLI has no
16899
+ * request to trace), no console breadcrumbs, and no scan findings or file
16900
+ * contents. Events carry the error, its stack, the release, and the surface
16901
+ * that raised it.
16902
+ */
16903
+ /** The loaded SDK, or null while reporting is off/unavailable. */
16904
+ let sdk = null;
16905
+ /**
16906
+ * Flush budget for the process-exit paths. Long enough for one HTTPS
16907
+ * envelope on a slow link, short enough that a broken network cannot turn
16908
+ * a crashed CLI into a hung one. Sentry's own default is 2s.
16909
+ */
16910
+ const FLUSH_TIMEOUT_MS = 2e3;
16911
+ /** Writes a diagnostic without touching stdout. */
16912
+ function warn(message) {
16913
+ process.stderr.write(`mjolnir: sentry — ${message}\n`);
16914
+ }
16915
+ /**
16916
+ * The release name Sentry groups events under. Matches the npm package
16917
+ * name so a release created from a tag and a release reported by the SDK
16918
+ * are the same release, not two half-populated ones.
16919
+ */
16920
+ const SENTRY_RELEASE = `mjolnir-qa@${ENGINE_VERSION}`;
16921
+ /**
16922
+ * Resolve the environment tag. Unset means "a developer's machine" unless
16923
+ * CI says otherwise: a `npx` run is not a production event, and filing
16924
+ * those under `production` would poison the issue stream with noise from
16925
+ * end users triaging their own repos.
16926
+ */
16927
+ function resolveEnvironment() {
16928
+ const fromEnv = process.env.SENTRY_ENVIRONMENT?.trim();
16929
+ if (fromEnv) return fromEnv;
16930
+ return process.env.CI ? "ci" : "local";
16931
+ }
16932
+ /**
16933
+ * Turn reporting on if — and only if — a DSN is configured. Safe to call
16934
+ * from every entry point: the second call is a no-op that returns the
16935
+ * already-decided answer, so a process that reaches two surfaces cannot
16936
+ * initialize the SDK twice.
16937
+ *
16938
+ * Returns true when reporting is live. Never throws: a monitoring
16939
+ * integration that can take down the tool it monitors has failed at its
16940
+ * one job.
16941
+ */
16942
+ async function initSentry() {
16943
+ if (sdk) return true;
16944
+ const dsn = process.env.SENTRY_DSN?.trim();
16945
+ if (!dsn) return false;
16946
+ let loaded;
16947
+ try {
16948
+ loaded = await import("@sentry/node");
16949
+ } catch {
16950
+ warn("SENTRY_DSN is set but @sentry/node is not installed. Install it with: npm install @sentry/node");
16951
+ return false;
16952
+ }
16953
+ try {
16954
+ loaded.init({
16955
+ dsn,
16956
+ release: SENTRY_RELEASE,
16957
+ environment: resolveEnvironment(),
16958
+ tracesSampleRate: 0,
16959
+ sendDefaultPii: false,
16960
+ attachStacktrace: true
16961
+ });
16962
+ } catch (error) {
16963
+ warn(`SDK init failed (${error instanceof Error ? error.message : String(error)}). Crash reporting stays off.`);
16964
+ return false;
16965
+ }
16966
+ sdk = loaded;
16967
+ return true;
16968
+ }
16969
+ /**
16970
+ * Report one fatal error. `surface` names the entry point that raised it
16971
+ * ("cli" or "mcp") so the issue stream can be split by how Mjölnir was
16972
+ * being used when it broke. A non-Error throw is reported as-is rather
16973
+ * than dropped: a hostile throw value is exactly the case worth seeing.
16974
+ */
16975
+ function captureInternalError(error, surface) {
16976
+ if (!sdk) return;
16977
+ try {
16978
+ sdk.captureException(error, { tags: { surface } });
16979
+ } catch (captureError) {
16980
+ warn(`capture failed (${captureError instanceof Error ? captureError.message : String(captureError)}).`);
16981
+ }
16982
+ }
16983
+ /**
16984
+ * Deliver anything buffered before the process exits. Awaits the SDK's
16985
+ * own transport drain and returns whether the envelope was handed off;
16986
+ * the caller is on an exit path, so the answer is deliberately not
16987
+ * surfaced as a failure — a lost crash report must not change the exit
16988
+ * code a caller (or CI) is about to read.
16989
+ */
16990
+ async function flushSentry(timeoutMs = FLUSH_TIMEOUT_MS) {
16991
+ if (!sdk) return false;
16992
+ try {
16993
+ return await sdk.flush(timeoutMs);
16994
+ } catch {
16995
+ return false;
16996
+ }
16997
+ }
16998
+ //#endregion
15962
16999
  //#region src/mcp/stdio.ts
15963
17000
  /**
15964
17001
  * The standalone MCP stdio binary (blueprint §21), built to
@@ -15977,9 +17014,14 @@ async function handleStdioLine(line, write) {
15977
17014
  * subprocess coverage cannot merge into the parent istanbul report,
15978
17015
  * same class as dist/**), so it is excluded from the coverage ratchet.
15979
17016
  */
15980
- runStdioTransport(process.stdin, process.stdout).then(() => process.exit(0)).catch((err) => {
17017
+ initSentry();
17018
+ runStdioTransport(process.stdin, process.stdout).then(() => process.exit(0)).catch(async (err) => {
17019
+ captureInternalError(err, "mcp");
17020
+ await flushSentry();
15981
17021
  process.stderr.write(`mjolnir mcp fatal: ${err instanceof Error ? err.message : err}\n`);
15982
17022
  process.exitCode = 20;
15983
17023
  });
15984
17024
  //#endregion
15985
17025
  export {};
17026
+
17027
+ //# sourceMappingURL=stdio.mjs.map