mjolnir-qa 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,14 +2,14 @@ import { createRequire } from "node:module";
2
2
  import { createInterface } from "node:readline";
3
3
  import { Buffer as Buffer$1 } from "node:buffer";
4
4
  import { createHash, randomBytes } from "node:crypto";
5
- import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
5
+ import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
6
6
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
+ import { execFileSync } from "node:child_process";
7
8
  import * as ts$2 from "ts-morph";
8
9
  import ts, { Project, SyntaxKind, ts as ts$1 } from "ts-morph";
9
10
  import { fileURLToPath, pathToFileURL } from "node:url";
10
11
  import { Language, Parser } from "web-tree-sitter";
11
12
  import { parse } from "yaml";
12
- import { execFileSync } from "node:child_process";
13
13
  import { inflateRawSync } from "node:zlib";
14
14
  //#region src/types.ts
15
15
  /** Severity ladder. Order matters for sorting and gating. */
@@ -19,6 +19,32 @@ const SEVERITY_ORDER = [
19
19
  "info"
20
20
  ];
21
21
  /**
22
+ * Trust levels (Verification Trust Evolution Plan §16): the OVERALL
23
+ * trust a consumer can place in one finding, combining the static
24
+ * evidence ladder (E0–E2) with RUNTIME corroboration from a real run
25
+ * report. Exposed honestly, never overclaimed:
26
+ * L0 — observation only (E0, no runtime evidence).
27
+ * L1 — heuristic static evidence (E1), no runtime evidence.
28
+ * L2 — deterministic static evidence (E2), no runtime evidence.
29
+ * L3 — RUNTIME: the file containing this finding appeared in a real
30
+ * run report (tests in that file executed).
31
+ * L4 — RUNTIME: the specific test containing this finding was
32
+ * identified in the report and executed (its outcome is known).
33
+ * L5 — RUNTIME: the run verdict directly corroborates the DEFECT
34
+ * class (e.g. a flake-risk finding whose test actually flaked,
35
+ * retried, or timed out in the report).
36
+ * INVARIANT (structurally enforced): L3–L5 require runtime
37
+ * corroboration — a static-only finding can never claim L4/L5.
38
+ */
39
+ const TRUST_ORDER = [
40
+ "L0",
41
+ "L1",
42
+ "L2",
43
+ "L3",
44
+ "L4",
45
+ "L5"
46
+ ];
47
+ /**
22
48
  * Honest default evidence level for a finding (Honesty Core Phase 1).
23
49
  * Derivation is deterministic and conservative:
24
50
  * observation → E0 (never proof)
@@ -701,21 +727,13 @@ const MEASURED_FP = {
701
727
  * empty scan is an observation, not a proof).
702
728
  */
703
729
  function summaryTrustLevel(findings) {
704
- const order = [
705
- "L0",
706
- "L1",
707
- "L2",
708
- "L3",
709
- "L4",
710
- "L5"
711
- ];
712
730
  let best = 0;
713
731
  for (const f of findings) {
714
732
  const t = f.trustLevel ?? "L2";
715
- const idx = order.indexOf(t);
733
+ const idx = TRUST_ORDER.indexOf(t);
716
734
  if (idx > best) best = idx;
717
735
  }
718
- return order[best];
736
+ return TRUST_ORDER[best];
719
737
  }
720
738
  /**
721
739
  * A finding's trust rung (0–5) on the canonical ladder. Corroborated
@@ -726,14 +744,7 @@ function summaryTrustLevel(findings) {
726
744
  */
727
745
  function trustRung(f) {
728
746
  const t = f.trustLevel ?? deriveTrustLevel(f);
729
- return [
730
- "L0",
731
- "L1",
732
- "L2",
733
- "L3",
734
- "L4",
735
- "L5"
736
- ].indexOf(t);
747
+ return TRUST_ORDER.indexOf(t);
737
748
  }
738
749
  /**
739
750
  * Hard incompleteness ceilings (plan §6: partial/crash/truncation;
@@ -870,6 +881,17 @@ function buildRunIdentity(input) {
870
881
  engineVersion: input.engineVersion
871
882
  };
872
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
+ };
873
895
  if (input.trustModelVersion !== void 0) verdictInputs.trustModelVersion = input.trustModelVersion;
874
896
  if (input.scoringModelVersion !== void 0) verdictInputs.scoringModelVersion = input.scoringModelVersion;
875
897
  if (input.frameworkSupportMatrixVersion !== void 0) verdictInputs.frameworkSupportMatrixVersion = input.frameworkSupportMatrixVersion;
@@ -877,15 +899,31 @@ function buildRunIdentity(input) {
877
899
  if (input.suppressionFingerprint !== void 0) verdictInputs.suppressionFingerprint = input.suppressionFingerprint;
878
900
  if (input.policyFingerprint !== void 0) verdictInputs.policyFingerprint = input.policyFingerprint;
879
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");
880
913
  return {
881
- scanId: sha256$1(canonical(verdictInputs)),
914
+ scanId,
882
915
  inputFingerprint,
883
916
  rulesDigest,
884
917
  configFingerprint,
885
918
  engineVersion: input.engineVersion,
919
+ boundLinks,
886
920
  ...input.trustModelVersion !== void 0 ? { trustModelVersion: input.trustModelVersion } : {},
887
921
  ...input.scoringModelVersion !== void 0 ? { scoringModelVersion: input.scoringModelVersion } : {},
888
- ...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 } : {}
889
927
  };
890
928
  }
891
929
  /**
@@ -926,9 +964,218 @@ function buildEvidenceGraph(parts) {
926
964
  ...parts.reproduction !== void 0 ? { ref: parts.reproduction } : {}
927
965
  }
928
966
  ],
929
- ...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
930
1169
  };
931
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
+ }
932
1179
  //#endregion
933
1180
  //#region src/engine/version.ts
934
1181
  /**
@@ -939,7 +1186,7 @@ function buildEvidenceGraph(parts) {
939
1186
  * scripts/sync-sarif-version.cjs and guarded by the version-consistency
940
1187
  * spec. cli.ts re-exports this as CLI_VERSION.
941
1188
  */
942
- const ENGINE_VERSION = "3.0.0";
1189
+ const ENGINE_VERSION = "4.0.0";
943
1190
  //#endregion
944
1191
  //#region src/engine/contract-versions.ts
945
1192
  /**
@@ -1347,20 +1594,9 @@ function validate(cfg, knownRuleIds) {
1347
1594
  return warnings;
1348
1595
  }
1349
1596
  /**
1350
- * Bug-audit QA-2026-08-30 QA-6: the 90-day policy in the README was only
1351
- * applied at WRITE time by the `ignore` command — a hand-written entry
1352
- * without `expires` stayed active forever, silently bypassing the
1353
- * documented window.
1354
- *
1355
- * Audit S4 (remediation plan): the config-file MTIME is no longer an
1356
- * expiry anchor. Anchoring the 90-day default at mtime meant ANY edit to
1357
- * mjolnir.config.json — a reformat, an unrelated key, a `touch` — reset
1358
- * the 90-day window for EVERY hand-authored entry: suppressions could be
1359
- * extended indefinitely without touching their own fields. The expiry is
1360
- * now the entry's explicit `expires` date alone; an entry without one
1361
- * stays active and is honestly labeled "(no expiry set)" in the
1362
- * suppressions report. Hand-authored entries should declare `expires` at
1363
- * creation (README §Configuration documents the shape).
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.
1364
1600
  */
1365
1601
  function isSuppressionActive(ign, now = /* @__PURE__ */ new Date()) {
1366
1602
  if (!ign.expires) return true;
@@ -1638,7 +1874,33 @@ function sharedWalk(options) {
1638
1874
  *
1639
1875
  * When nothing is detectable we report `unknown` and the scanner analyzes
1640
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.
1641
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.
1898
+ */
1899
+ const DETECTABLE_TEST_FRAMEWORKS = [
1900
+ "jest",
1901
+ "vitest",
1902
+ "playwright"
1903
+ ];
1642
1904
  const CONFIG_FILES = {
1643
1905
  jest: [
1644
1906
  "jest.config.ts",
@@ -1657,7 +1919,7 @@ const CONFIG_FILES = {
1657
1919
  };
1658
1920
  function detectFrameworks(ws) {
1659
1921
  const found = /* @__PURE__ */ new Set();
1660
- for (const fw of Object.keys(CONFIG_FILES)) if (CONFIG_FILES[fw].some((f) => existsSync(join(ws.root, f)))) found.add(fw);
1922
+ for (const fw of DETECTABLE_TEST_FRAMEWORKS) if (CONFIG_FILES[fw].some((f) => existsSync(join(ws.root, f)))) found.add(fw);
1661
1923
  if (!found.has("jest") && ws.packageJson["jest"] !== void 0) found.add("jest");
1662
1924
  const deps = {
1663
1925
  ...ws.packageJson["dependencies"],
@@ -1679,11 +1941,7 @@ function detectFrameworks(ws) {
1679
1941
  };
1680
1942
  }
1681
1943
  return {
1682
- frameworks: [
1683
- "jest",
1684
- "vitest",
1685
- "playwright"
1686
- ].filter((f) => found.has(f)),
1944
+ frameworks: DETECTABLE_TEST_FRAMEWORKS.filter((f) => found.has(f)),
1687
1945
  unknown: false
1688
1946
  };
1689
1947
  }
@@ -1700,6 +1958,13 @@ function detectFrameworks(ws) {
1700
1958
  * byte-identical — a score shift is a regression, not an improvement.
1701
1959
  */
1702
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
+ */
1703
1968
  function getProject() {
1704
1969
  if (!project) project = new Project({
1705
1970
  useInMemoryFileSystem: true,
@@ -2226,7 +2491,36 @@ function frameworkFilterApplies(rule, file) {
2226
2491
  * "cypress", `@jest/globals` → "jest", `vitest` → "vitest". Config
2227
2492
  * gating is rule-declared (`configFiles`), not hard-coded here.
2228
2493
  */
2229
- 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)$/;
2230
2524
  /**
2231
2525
  * Fallback config list for `configOnly` rules that do not declare
2232
2526
  * `configFiles` (the legacy playwright.config.* gating, preserved
@@ -2333,11 +2627,42 @@ const typescriptAdapter = {
2333
2627
  fixtureDirMemo: /* @__PURE__ */ new Map()
2334
2628
  });
2335
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
+ },
2336
2661
  runRules(rules, file, emit, onCrash, budget) {
2337
- const withAst = {
2662
+ const withAst = file.ast === void 0 ? {
2338
2663
  ...file,
2339
2664
  ast: parseTsFile(file)
2340
- };
2665
+ } : file;
2341
2666
  const withTags = {
2342
2667
  ...withAst,
2343
2668
  frameworkTags: frameworkTagsFromImports(withAst.text)
@@ -3756,9 +4081,37 @@ function discoverAllTestFiles(ctx, languageAdapters, buckets, fixtureDirMemo) {
3756
4081
  }
3757
4082
  });
3758
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
+ */
3759
4111
  function isScopeRelevantIgnored(path) {
3760
4112
  const name = path.replaceAll("\\", "/").split("/").pop() ?? path;
3761
- return /\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml|min\.js)$/i.test(name);
4113
+ if (!/\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml|min\.js)$/i.test(name)) return false;
4114
+ return isKnownTestFile(path);
3762
4115
  }
3763
4116
  function isUnrecognizedSourceCandidate(path) {
3764
4117
  const normalized = path.replaceAll("\\", "/");
@@ -7608,7 +7961,6 @@ const pyBareTruthinessAssert = defineRule({
7608
7961
  const re = /^[ \t]*assert\s+([A-Za-z_][\w.]*(?:\([^()]*\))?)[ \t]*$/gm;
7609
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*\()/;
7610
7963
  const isGuardFollowedByRealUse = (text, matchIndex, target) => {
7611
- const root = target.split(".")[0];
7612
7964
  const lineEnd = text.indexOf("\n", matchIndex);
7613
7965
  if (lineEnd === -1) return false;
7614
7966
  const lines = text.slice(lineEnd + 1).split("\n");
@@ -7618,8 +7970,11 @@ const pyBareTruthinessAssert = defineRule({
7618
7970
  if (/^\s*def\s/.test(l) && window.length > 0) break;
7619
7971
  window.push(l);
7620
7972
  }
7621
- const usesRoot = new RegExp(`\\b${root}\\b`);
7622
- 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
+ });
7623
7978
  };
7624
7979
  let m;
7625
7980
  while ((m = re.exec(text)) !== null) {
@@ -11581,6 +11936,22 @@ function normalizeOne(report, artifact, v) {
11581
11936
  function buildEvidenceRecords(report, artifact) {
11582
11937
  return report.verdicts.map((v) => normalizeOne(report, artifact, v)).sort(compareEvidenceRecords);
11583
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
+ }
11584
11955
  //#endregion
11585
11956
  //#region src/engine/provenance.ts
11586
11957
  const GENERATED_HEADER_RE = /^\s*(?:\/\/|#|\/\*)\s*(?:auto[- ]?generated|generated by|do not edit)/i;
@@ -11868,6 +12239,11 @@ var DependencyGraph = class {
11868
12239
  get size() {
11869
12240
  return this.nodes.size;
11870
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
+ }
11871
12247
  };
11872
12248
  function parsePackageJson(filePath) {
11873
12249
  if (!existsSync(filePath)) return void 0;
@@ -11992,120 +12368,394 @@ function isIncrementalSafe(changedFiles) {
11992
12368
  };
11993
12369
  }
11994
12370
  //#endregion
11995
- //#region src/engine/monorepo-analysis.ts
11996
- const WORTHY_THRESHOLD = 80;
11997
- const NEEDS_WORK_THRESHOLD = 50;
11998
- function verdictOf(score) {
11999
- if (score === null) return "fail";
12000
- if (score >= WORTHY_THRESHOLD) return "pass";
12001
- if (score >= NEEDS_WORK_THRESHOLD) return "warn";
12002
- return "fail";
12003
- }
12004
- function hasBlocker(findings) {
12005
- return findings.some((f) => f.severity === "error");
12006
- }
12371
+ //#region src/brand/tokens.ts
12007
12372
  /**
12008
- * Aggregate per-package results into an overall verdict.
12373
+ * The single source of brand truth.
12009
12374
  *
12010
- * Worst-package propagation: if ANY package has a blocker (error-severity
12011
- * finding) or a failing score, the overall result fails regardless of
12012
- * strategy. This is the safety net — a single poisoned package must not
12013
- * hide behind averaging.
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.
12014
12407
  */
12015
- function analyzeMonorepo(packages, config) {
12016
- const results = packages.map((p) => ({
12017
- ...p,
12018
- verdict: hasBlocker(p.findings) ? "fail" : verdictOf(p.score)
12019
- }));
12020
- if (results.length === 0) return {
12021
- packages: results,
12022
- overallScore: null,
12023
- overallVerdict: "fail",
12024
- strategy: config.weightingStrategy
12025
- };
12026
- const blockerPkg = results.find((r) => r.verdict === "fail" || hasBlocker(r.findings));
12027
- if (blockerPkg) return {
12028
- packages: results,
12029
- overallScore: blockerPkg.score,
12030
- overallVerdict: "fail",
12031
- strategy: config.weightingStrategy,
12032
- blockerPackage: blockerPkg.packageName
12033
- };
12034
- switch (config.weightingStrategy) {
12035
- case "worst-package": return worstPackageAggregation(results, config);
12036
- case "average": return averageAggregation(results, config);
12037
- case "configurable": return configurableAggregation(results, config);
12038
- }
12039
- }
12040
- function worstPackageAggregation(results, config) {
12041
- const firstResult = results[0];
12042
- if (firstResult === void 0) return {
12043
- packages: results,
12044
- overallScore: null,
12045
- overallVerdict: "fail",
12046
- strategy: config.weightingStrategy
12047
- };
12048
- let worst = firstResult;
12049
- for (const r of results) if ((r.score ?? 0) < (worst.score ?? 0)) worst = r;
12050
- return {
12051
- packages: results,
12052
- overallScore: worst.score,
12053
- overallVerdict: worst.verdict,
12054
- strategy: config.weightingStrategy,
12055
- ...worst.verdict === "fail" ? { blockerPackage: worst.packageName } : {}
12056
- };
12057
- }
12058
- function averageAggregation(results, config) {
12059
- const scored = results.filter((r) => r.score !== null);
12060
- if (scored.length === 0) return {
12061
- packages: results,
12062
- overallScore: null,
12063
- overallVerdict: "fail",
12064
- strategy: config.weightingStrategy
12065
- };
12066
- const avg = scored.reduce((sum, r) => sum + (r.score ?? 0), 0) / scored.length;
12067
- return {
12068
- packages: results,
12069
- overallScore: Math.round(avg),
12070
- overallVerdict: verdictOf(Math.round(avg)),
12071
- strategy: config.weightingStrategy
12072
- };
12073
- }
12074
- function configurableAggregation(results, config) {
12075
- const weights = config.packageWeights ?? {};
12076
- let totalWeight = 0;
12077
- let weightedSum = 0;
12078
- for (const r of results) {
12079
- const w = weights[r.packageName] ?? 1;
12080
- totalWeight += w;
12081
- weightedSum += (r.score ?? 0) * w;
12082
- }
12083
- if (totalWeight === 0) return {
12084
- packages: results,
12085
- overallScore: null,
12086
- overallVerdict: "fail",
12087
- strategy: config.weightingStrategy
12088
- };
12089
- const score = Math.round(weightedSum / totalWeight);
12090
- return {
12091
- packages: results,
12092
- overallScore: score,
12093
- overallVerdict: verdictOf(score),
12094
- strategy: config.weightingStrategy
12095
- };
12096
- }
12097
- //#endregion
12098
- //#region src/lib/fs-atomic.ts
12099
12408
  /**
12100
- * Atomic file writes (audit S9).
12101
- *
12102
- * Every durability-critical write in Mjölnir (baseline, stats, badge,
12103
- * TRIAGE.md, scaffolded rule files) used to hand-roll
12104
- * `writeFileSync(path, data)` — a crash mid-write left a TRUNCATED file
12105
- * at the real path, and a subsequent read (diff, badge endpoint) served
12106
- * confident nonsense from it.
12409
+ * The two brand hues plus the neutral they sit on.
12107
12410
  *
12108
- * `writeFileAtomic` writes to a temp sibling, then RENAMES. On the same
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,
12719
+ overallScore: Math.round(avg),
12720
+ overallVerdict: verdictOf(Math.round(avg)),
12721
+ strategy: config.weightingStrategy
12722
+ };
12723
+ }
12724
+ function configurableAggregation(results, config) {
12725
+ const weights = config.packageWeights ?? {};
12726
+ let totalWeight = 0;
12727
+ let weightedSum = 0;
12728
+ for (const r of results) {
12729
+ const w = weights[r.packageName] ?? 1;
12730
+ totalWeight += w;
12731
+ weightedSum += (r.score ?? 0) * w;
12732
+ }
12733
+ if (totalWeight === 0) return {
12734
+ packages: results,
12735
+ overallScore: null,
12736
+ overallVerdict: "fail",
12737
+ strategy: config.weightingStrategy
12738
+ };
12739
+ const score = Math.round(weightedSum / totalWeight);
12740
+ return {
12741
+ packages: results,
12742
+ overallScore: score,
12743
+ overallVerdict: verdictOf(score),
12744
+ strategy: config.weightingStrategy
12745
+ };
12746
+ }
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
12109
12759
  * volume rename is atomic: readers see either the complete old file or
12110
12760
  * the complete new file, never a half-written one. The temp name is
12111
12761
  * created with `wx` (exclusive) so concurrent writers cannot interleave,
@@ -12118,6 +12768,10 @@ function atomicTempPath(path) {
12118
12768
  }
12119
12769
  /**
12120
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.
12121
12775
  */
12122
12776
  function writeFileAtomic(path, data, opts = {}) {
12123
12777
  const dir = dirname(path);
@@ -12126,7 +12780,8 @@ function writeFileAtomic(path, data, opts = {}) {
12126
12780
  let fd;
12127
12781
  try {
12128
12782
  fd = openSync(tmp, "wx", opts.mode ?? 420);
12129
- writeSync(fd, data, null, opts.encoding ?? "utf8");
12783
+ if (typeof data === "string") writeSync(fd, data, null, opts.encoding ?? "utf8");
12784
+ else writeSync(fd, data);
12130
12785
  } finally {
12131
12786
  if (fd !== void 0) closeSync(fd);
12132
12787
  }
@@ -12384,173 +13039,6 @@ function createScanCache(root) {
12384
13039
  };
12385
13040
  }
12386
13041
  //#endregion
12387
- //#region src/brand/tokens.ts
12388
- /**
12389
- * The single source of brand truth.
12390
- *
12391
- * Every colour, typeface and motion constant Mjölnir shows a human —
12392
- * terminal, README SVGs, demo video, website, docs, badges — resolves to
12393
- * a value in this file. Nothing else may define one.
12394
- *
12395
- * WHY THIS EXISTS. Before it, the palette existed in six independent
12396
- * copies: `site/.vitepress/theme/styles/vars.css`, `NORSE` in
12397
- * `src/reporter/theme.ts`, `scripts/readme-svg.ts`,
12398
- * `scripts/video/terminal-page.ts`, `scripts/generate-readme-architecture.ts`
12399
- * and the table in `assets/brand/README.md`. Exactly one pair of those
12400
- * was guarded (site-doctor Check 8, doc ↔ vars.css). The unguarded edges
12401
- * are where the shipped surfaces drifted apart: the terminal and the site
12402
- * disagreed on six semantic roles, the architecture diagram invented its
12403
- * own neutral ramp, and the README badges still carried a palette retired
12404
- * two releases earlier. `scripts/brand-doctor.mjs` now checks every edge
12405
- * against this file.
12406
- *
12407
- * PURITY. Pure data. No I/O, no rendering, no environment access, no
12408
- * imports, no logic. Consumers convert (hex → ANSI triplet, hex → CSS)
12409
- * themselves. Same reason `score-state.ts` is pure: it makes the whole
12410
- * thing golden-testable and safe to ship inside the npm package, where
12411
- * it costs a few hundred bytes and replaces values the package already
12412
- * carried anyway.
12413
- *
12414
- * DERIVATION. The palette is the one derived from the logo in PR #20
12415
- * (brushed steel and forge gold under an aurora, over midnight iron).
12416
- * Where the terminal disagreed with it, the terminal converges — see
12417
- * `PENDING_TERMINAL` below. Full rationale: `assets/brand/README.md`.
12418
- *
12419
- * ACCESSIBILITY. Every foreground token in `BRAND` meets WCAG AA
12420
- * (≥ 4.5:1) against every surface token it is allowed to sit on. That is
12421
- * not a claim, it is `brand-doctor` rule 8, which computes the ratios.
12422
- * The weakest legal pairing is `steelDim` on `ink800` at 5.00:1.
12423
- */
12424
- /**
12425
- * The two brand hues plus the neutral they sit on.
12426
- *
12427
- * GOLD IS SCARCE. It means forged / certified / earned / decisive — the
12428
- * primary mark, the FORGED state, one call to action. It is not a paint
12429
- * bucket: gold as default text, default border or default heading is a
12430
- * brand-doctor finding, not a style choice.
12431
- *
12432
- * AURORA is verification energy — the secondary, and the hue that marks
12433
- * the runtime half of the trust ladder.
12434
- */
12435
- const BRAND = {
12436
- gold: "#C19A34",
12437
- goldBright: "#E6BD57",
12438
- goldHot: "#F4DC9C",
12439
- /** Pressed / deepest gold — the only step dark enough to carry white. */
12440
- goldDeep: "#A5811C",
12441
- aurora: "#37ABBD",
12442
- auroraBright: "#45C1D4",
12443
- auroraCyan: "#5CBDE0",
12444
- /** The aurora's outer curtains: atmosphere and section identity only,
12445
- * never a verdict or a status. */
12446
- auroraGreen: "#5FD6A4",
12447
- auroraViolet: "#9D8CF5",
12448
- steel: "#C8CBCF",
12449
- steelDim: "#8B939D"
12450
- };
12451
- /**
12452
- * Midnight iron. One ramp, four steps, darkest first.
12453
- *
12454
- * `terminal` and `terminalBar` share one tone deliberately: the window's
12455
- * only seam is a hairline ring and an inset shadow, never a second fill.
12456
- * `chromeDot` is the three window dots — see the note on
12457
- * `PENDING_TERMINAL.chromeDots` for why they are no longer red/amber/green.
12458
- */
12459
- const SURFACE = {
12460
- ink950: "#0A1119",
12461
- ink900: "#0C1420",
12462
- ink850: "#111A29",
12463
- ink800: "#18243A",
12464
- /** Raised panel (cards, elevated surfaces). */
12465
- panel: "#141F33",
12466
- /** Soft fill (inline code, quiet chips). */
12467
- soft: "#1A2740",
12468
- /** Terminal body — the deepest tone, so a terminal reads as recessed. */
12469
- terminal: "#0A1119",
12470
- /** Terminal title bar — the same tone; the seam is shadow, not colour. */
12471
- terminalBar: "#0A1119",
12472
- /** The three window dots. One neutral, not a traffic light. */
12473
- chromeDot: "#18243A"
12474
- };
12475
- const TEXT = {
12476
- primary: "#EAEEF5",
12477
- secondary: "#ABB6C6",
12478
- muted: "#8B939D",
12479
- /** Ink for text set ON gold (buttons, the FORGED chip). 7.17:1 on `gold`. */
12480
- onGold: "#0A1119"
12481
- };
12482
- /**
12483
- * Non-score status. `ok` is the one green in the system and it is NOT a
12484
- * score colour — it survives only for contexts with no worthiness
12485
- * meaning ("autofix applied", "analysis complete"). A green score would
12486
- * say "your software is fine", which is the exact claim this product
12487
- * refuses to make.
12488
- */
12489
- const STATUS = {
12490
- ok: "#4FB477",
12491
- info: "#5CC4E0",
12492
- warning: "#E6BD57",
12493
- error: "#EC6B66"
12494
- };
12495
- /**
12496
- * The four ScoreState bands plus the unmeasured state. Band thresholds
12497
- * and runes live in `src/reporter/score-state.ts`, which stays free of
12498
- * colour — it emits a palette KEY and each surface resolves it here.
12499
- *
12500
- * `unmeasured` is steel-dim on purpose. UNKNOWN is a legitimate answer,
12501
- * not a failure: colouring it red would make "we did not measure this"
12502
- * look like "this is broken", which is precisely the dishonesty the
12503
- * north-star law exists to prevent.
12504
- */
12505
- const SCORE = {
12506
- critical: "#EC6B66",
12507
- warning: "#E6BD57",
12508
- trusted: "#5CC4E0",
12509
- forged: "#F4DC9C",
12510
- unmeasured: "#8B939D"
12511
- };
12512
- /**
12513
- * E0 → E1 → E2 is a certainty ramp, and it is deliberately HUE-FREE.
12514
- *
12515
- * Evidence level says how sure we are, not whether the news is good. A
12516
- * deterministic proof (E2) is a defect we are certain about — painting
12517
- * it gold or green would read as an achievement. So certainty is carried
12518
- * by brightness alone, and the *shape* does the real work:
12519
- *
12520
- * E0 open ring observation, no weight
12521
- * E1 half-filled pattern evidence, half weight
12522
- * E2 sealed deterministic proof, full weight
12523
- *
12524
- * Colour never carries this alone (R11): the geometry is the signal and
12525
- * survives `--ascii`, `NO_COLOR` and monochrome print.
12526
- */
12527
- const EVIDENCE = {
12528
- e0: "#8B939D",
12529
- e1: "#ABB6C6",
12530
- e2: "#EAEEF5"
12531
- };
12532
- /**
12533
- * L0–L5, and the most important boundary in the product.
12534
- *
12535
- * L0–L2 are STATIC: the neutral steel ramp, brightening to the static
12536
- * ceiling at L2. L3–L5 require a real run, and the hue changes to aurora
12537
- * exactly there. The boundary is a hue break, not a gradient step,
12538
- * because it is a change of kind and not of degree — a static-only
12539
- * finding can never climb past L2, however confident it is.
12540
- *
12541
- * Every surface that draws the ladder must draw that break.
12542
- */
12543
- const TRUST = {
12544
- l0: "#8B939D",
12545
- l1: "#ABB6C6",
12546
- l2: "#C8CBCF",
12547
- l3: "#37ABBD",
12548
- l4: "#45C1D4",
12549
- l5: "#5CC4E0"
12550
- };
12551
- SURFACE.ink950, SURFACE.ink900, SURFACE.ink850, SURFACE.ink800, BRAND.steel, BRAND.steelDim, BRAND.gold, BRAND.goldBright, BRAND.goldHot, BRAND.aurora, BRAND.auroraBright, BRAND.auroraCyan, BRAND.auroraGreen, BRAND.auroraViolet;
12552
- SCORE.trusted, SCORE.forged, SCORE.warning, SCORE.critical, STATUS.info, TEXT.onGold, STATUS.ok, EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2, TRUST.l0, TRUST.l1, TRUST.l2, TRUST.l3, TRUST.l4, TRUST.l5;
12553
- //#endregion
12554
13042
  //#region src/reporter/theme.ts
12555
13043
  /**
12556
13044
  * Norse-forge theme system. Pure string-in → string-out helpers.
@@ -12565,8 +13053,8 @@ SCORE.trusted, SCORE.forged, SCORE.warning, SCORE.critical, STATUS.info, TEXT.on
12565
13053
  *
12566
13054
  * Symbols always accompany color (color-blind safe, R11).
12567
13055
  *
12568
- * Score-state colors come from ScoreState (score-state.ts) — the single
12569
- * 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.
12570
13058
  *
12571
13059
  * Terminal robustness (Master-Stabilization-Plan Sprint 5 Task 22):
12572
13060
  * box-drawing/gauge helpers accept an explicit width so callers can
@@ -13729,7 +14217,7 @@ function runForensics(target, options = {}) {
13729
14217
  const base = stat.isFile() ? dirname(target) : target;
13730
14218
  flakyMdPath = join(base, "FLAKY.md");
13731
14219
  try {
13732
- writeFileSync(flakyMdPath, renderFlakyMd(report));
14220
+ writeFileAtomic(flakyMdPath, renderFlakyMd(report));
13733
14221
  } catch {
13734
14222
  flakyMdPath = void 0;
13735
14223
  }
@@ -14593,8 +15081,6 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14593
15081
  skippedFiles++;
14594
15082
  continue;
14595
15083
  }
14596
- let fileBudgetExceeded = false;
14597
- let fileRuleFailed = false;
14598
15084
  const relPath = relative(workspace.root, path).replaceAll("\\", "/");
14599
15085
  if (!isCiAdapter) {
14600
15086
  const lang = {
@@ -14620,79 +15106,106 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
14620
15106
  });
14621
15107
  let cacheKey = fileCacheKey(rulesDigest, text, identity(wantsAst ? "ast" : "regex"));
14622
15108
  const cachedFindings = cache.lookup(cacheKey);
14623
- if (cachedFindings) {
14624
- for (const f of cachedFindings) findings.push(f);
14625
- analyzed++;
14626
- continue;
14627
- }
14628
- hooks.onProgress?.({
14629
- phase: "parse",
14630
- done: scanned,
14631
- total: testFiles.length,
14632
- detail: relPath
14633
- });
14634
- const parsedFile = {
14635
- path: relPath,
14636
- text
14637
- };
14638
- let parsed;
14639
- const findingsStart = findings.length;
14640
- try {
14641
- if (adapter.parseAst && wantsAst) {
14642
- hooks.onProgress?.({
14643
- phase: "rules",
14644
- done: scanned,
14645
- total: testFiles.length,
14646
- detail: relPath
14647
- });
14648
- parsed = await adapter.parseAst(parsedFile);
14649
- }
14650
- const actualMode = parsed ? "ast" : "regex";
14651
- if (wantsAst && actualMode === "regex") {
14652
- parseFallbacks++;
14653
- cacheKey = fileCacheKey(rulesDigest, text, identity(actualMode));
14654
- const fallbackFindings = cache.lookup(cacheKey);
14655
- if (fallbackFindings) {
14656
- for (const f of fallbackFindings) findings.push(f);
14657
- analyzed++;
14658
- continue;
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
+ });
14659
15132
  }
14660
- }
14661
- const fileForRules = parsed ? {
14662
- ...parsedFile,
14663
- ast: parsed.ast
14664
- } : parsedFile;
14665
- adapter.runRules(activeRules, fileForRules, (f, ruleId, category) => {
14666
- if (!isValidFindingRecord(f)) {
14667
- fileRuleFailed = true;
14668
- onRuleCrash?.(ruleId, relPath, /* @__PURE__ */ new Error(`malformed finding record rejected (severity/line/message must be present, severity ∈ error|warning|info): ${JSON.stringify(f)}`));
14669
- return;
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
+ };
14670
15145
  }
14671
- findings.push({
14672
- ...f,
14673
- ruleId,
14674
- 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
+ }
14675
15177
  });
14676
- }, (ruleId, error) => {
14677
- fileRuleFailed = true;
14678
- onRuleCrash?.(ruleId, relPath, error);
14679
- }, {
14680
- deadline: Math.min(deadline, Date.now() + LIMITS$1.maxFileAnalysisMs),
14681
- onExceeded: () => {
14682
- rulesPartial = true;
14683
- skippedFiles++;
14684
- truncationReasons.add("file-budget");
14685
- fileBudgetExceeded = true;
14686
- }
14687
- });
14688
- if (!fileRuleFailed && !fileBudgetExceeded) analyzed++;
14689
- if (!fileRuleFailed) cache.store(cacheKey, findings.slice(findingsStart), fileBudgetExceeded);
14690
- } catch {
14691
- if (wantsAst) parseFallbacks++;
14692
- skippedFiles++;
14693
- parseFailed++;
14694
- } finally {
14695
- parsed?.dispose();
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++;
14696
15209
  }
14697
15210
  }
14698
15211
  return {
@@ -14783,12 +15296,16 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
14783
15296
  const runtimeReportPath = discoveredReport?.path;
14784
15297
  const runtimeIncomplete = discoveredReport !== void 0 && discoveredReport.report.analysisComplete !== true;
14785
15298
  let forensicVerdicts;
15299
+ let evidenceRecords = [];
14786
15300
  if (discoveredReport && discoveredReport.report.analysisComplete === true) try {
14787
- buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
15301
+ evidenceRecords = buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
14788
15302
  stampRuntimeCorroboration(findings, discoveredReport.report, workspace.root);
14789
15303
  forensicVerdicts = summarizeForensicVerdicts(discoveredReport.report);
14790
- } catch {}
15304
+ } catch {
15305
+ evidenceRecords = [];
15306
+ }
14791
15307
  return {
15308
+ evidenceRecords,
14792
15309
  testDeclarationCount,
14793
15310
  scopeInfo,
14794
15311
  suppressionCount,
@@ -14931,6 +15448,8 @@ function assembleScanResult(o) {
14931
15448
  ...o.runtimeIncomplete !== void 0 ? { runtimeIncomplete: o.runtimeIncomplete } : {},
14932
15449
  identityIncomplete
14933
15450
  });
15451
+ const repository = bindRepository(o.scanRoot.root);
15452
+ const candidate = readCandidateBinding(o.scanRoot.root);
14934
15453
  const runIdentity = buildRunIdentity({
14935
15454
  files: inputSnapshot,
14936
15455
  rules: [...o.REVISION_BY_RULE_ID.entries()].map(([id, detectorRevision]) => ({
@@ -14943,9 +15462,17 @@ function assembleScanResult(o) {
14943
15462
  trustModelVersion: TRUST_MODEL_VERSION,
14944
15463
  scoringModelVersion: SCORING_MODEL_VERSION,
14945
15464
  frameworkSupportMatrixVersion: FRAMEWORK_SUPPORT_MATRIX_VERSION,
14946
- 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
+ } } : {}
14947
15475
  });
14948
- const evidenceGraph = buildEvidenceGraph({ runId: runIdentity });
14949
15476
  const hasTests = o.testFileCount > 0 && o.testDeclarationCount > 0;
14950
15477
  const suiteInvalidatedBy = [...new Set(o.findings.filter((f) => SUITE_INVALIDATING_RULE_IDS.has(f.ruleId)).map((f) => f.ruleId))].sort();
14951
15478
  const finalScore = hasTests ? completion.partial && scopeAdjustedTotal >= 100 ? 99 : scopeAdjustedTotal : null;
@@ -14955,6 +15482,11 @@ function assembleScanResult(o) {
14955
15482
  scopeIntegrity,
14956
15483
  runIdentity,
14957
15484
  evidenceGraph,
15485
+ evidence: {
15486
+ records: o.evidenceRecords ?? [],
15487
+ counts: countEvidence(o.evidenceRecords ?? []),
15488
+ artifact: o.runtimeReportPath ?? null
15489
+ },
14958
15490
  score: finalScore,
14959
15491
  ...hasTests ? {} : { reason: "no-tests-found" },
14960
15492
  frameworks: o.frameworks.frameworks,
@@ -16337,6 +16869,133 @@ async function handleStdioLine(line, write) {
16337
16869
  write(`${JSON.stringify(response)}\n`);
16338
16870
  }
16339
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
16340
16999
  //#region src/mcp/stdio.ts
16341
17000
  /**
16342
17001
  * The standalone MCP stdio binary (blueprint §21), built to
@@ -16355,9 +17014,14 @@ async function handleStdioLine(line, write) {
16355
17014
  * subprocess coverage cannot merge into the parent istanbul report,
16356
17015
  * same class as dist/**), so it is excluded from the coverage ratchet.
16357
17016
  */
16358
- 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();
16359
17021
  process.stderr.write(`mjolnir mcp fatal: ${err instanceof Error ? err.message : err}\n`);
16360
17022
  process.exitCode = 20;
16361
17023
  });
16362
17024
  //#endregion
16363
17025
  export {};
17026
+
17027
+ //# sourceMappingURL=stdio.mjs.map