mjolnir-qa 1.0.9 → 2.0.2

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.
@@ -3,7 +3,7 @@ import { createInterface } from "node:readline";
3
3
  import { Buffer as Buffer$1 } from "node:buffer";
4
4
  import { createHash } from "node:crypto";
5
5
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
6
- import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
7
  import * as ts$2 from "ts-morph";
8
8
  import ts, { Project, SyntaxKind, ts as ts$1 } from "ts-morph";
9
9
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -56,115 +56,52 @@ const DEDUCTIONS = {
56
56
  info: 1
57
57
  };
58
58
  //#endregion
59
- //#region src/engine/evidence-core.ts
60
- /**
61
- * The core's own version — stamped into every record's provenance so a
62
- * consumer can tell which normalization contract produced a record.
63
- * Bump ONLY on a semantic change to the normalization itself.
64
- */
65
- const EVIDENCE_CORE_VERSION = "evidence-core@1";
66
- /** Deterministic canonical order: file → line (absent last) → title → source. */
67
- function compareEvidenceRecords(a, b) {
68
- if (a.file !== b.file) return a.file < b.file ? -1 : 1;
69
- const la = a.line ?? Number.POSITIVE_INFINITY;
70
- const lb = b.line ?? Number.POSITIVE_INFINITY;
71
- if (la !== lb) return la - lb;
72
- if (a.title !== b.title) return a.title < b.title ? -1 : 1;
73
- if (a.source !== b.source) return a.source < b.source ? -1 : 1;
74
- return 0;
75
- }
76
- function normalizeOne(report, artifact, v) {
77
- const last = v.attempts > 0 ? v.finalStatus : "skipped";
78
- return {
79
- source: report.source,
80
- artifact,
81
- file: v.file,
82
- title: v.title,
83
- ...v.line !== void 0 ? { line: v.line } : {},
84
- status: {
85
- final: last,
86
- failed: v.everFailed,
87
- retried: v.attempts > 1,
88
- passedOnRetry: v.passedOnRetry,
89
- skipped: v.skipped,
90
- timedOut: v.finalStatus === "timedOut"
91
- },
92
- attempts: v.attempts,
93
- durationMs: v.totalDurationMs,
94
- errors: [],
95
- attachments: [],
96
- provenance: {
97
- core: EVIDENCE_CORE_VERSION,
98
- ingest: "mjolnir.forensics"
99
- }
100
- };
101
- }
102
- /**
103
- * Normalize a forensics report into deterministically-ordered evidence
104
- * records. The same report + artifact identity ALWAYS produces the same
105
- * record list — report verdict order never leaks into the output.
106
- */
107
- function buildEvidenceRecords(report, artifact) {
108
- return report.verdicts.map((v) => normalizeOne(report, artifact, v)).sort(compareEvidenceRecords);
109
- }
110
- /**
111
- * The test whose declaration span contains `line` — the exact matching
112
- * semantics runtime corroboration has always used, extracted verbatim
113
- * (Audit W8 honesty rule included: a verdict without a line can never
114
- * be a test-level match). Returns the record with the greatest
115
- * declaration line ≤ the finding's line in that file; ties resolve by
116
- * the canonical order (deterministic refinement over report order).
117
- */
118
- function findTestAt(records, file, line) {
119
- const inFile = records.filter((r) => r.file === file);
120
- if (inFile.length === 0) return void 0;
121
- if (inFile.length === 1) {
122
- const only = inFile[0];
123
- return only.line !== void 0 && only.line <= line ? only : void 0;
124
- }
125
- for (const r of inFile) if (r.line === void 0) return void 0;
126
- let match;
127
- for (const r of inFile) {
128
- const l = r.line;
129
- if (l <= line && (match === void 0 || match.line <= l)) match = r;
130
- }
131
- return match;
132
- }
133
- /** Per-file test count — the honest ceiling for file-level corroboration. */
134
- function countTestsIn(records, file) {
135
- return records.filter((r) => r.file === file).length;
136
- }
137
- //#endregion
138
59
  //#region src/engine/runtime-corroboration.ts
139
60
  /**
140
61
  * Stamp runtime corroboration + trust levels onto findings (mutates in
141
62
  * place, the same contract as stampEvidenceLevels). Returns the number
142
63
  * of findings that gained runtime corroboration.
143
64
  *
144
- * WI-2 (Canonical Evidence Core): matching is delegated to the core —
145
- * `evidence` is the pipeline's normalized record set; when omitted, the
146
- * records are built from the report internally. Either way the stamped
147
- * output is identical (preservation suite pins this).
65
+ * @param scanRoot - when provided, report file paths are normalized to
66
+ * repo-relative forward-slash form relative to this root, so platform
67
+ * absolute paths from runtime reports match the pipeline's
68
+ * normalized relative paths (src/foo.test.ts).
148
69
  */
149
- function stampRuntimeCorroboration(findings, report, evidence) {
150
- const records = evidence ?? buildEvidenceRecords(report, "runtime-report");
70
+ function stampRuntimeCorroboration(findings, report, scanRoot) {
71
+ const normalize = (p) => {
72
+ let n = p.replace(/\\/g, "/");
73
+ if (scanRoot) {
74
+ const root = scanRoot.replace(/\\/g, "/").replace(/\/?$/, "/");
75
+ if (n.startsWith(root)) n = n.slice(root.length);
76
+ }
77
+ return n.replace(/^\.\//, "").replace(/^\/+/, "");
78
+ };
79
+ const byFile = /* @__PURE__ */ new Map();
80
+ for (const v of report.verdicts) {
81
+ const key = normalize(v.file);
82
+ const list = byFile.get(key) ?? [];
83
+ list.push(v);
84
+ byFile.set(key, list);
85
+ }
151
86
  let corroborated = 0;
152
87
  for (const f of findings) {
153
- const testsExecuted = countTestsIn(records, f.file);
154
- if (testsExecuted === 0) continue;
155
- const matched = findTestAt(records, f.file, f.line);
88
+ const verdicts = byFile.get(f.file);
89
+ if (!verdicts || verdicts.length === 0) continue;
90
+ const testsExecuted = verdicts.filter((v) => !v.skipped).length;
91
+ const matched = findContainingTest(verdicts, f.line);
156
92
  let corroboration;
157
- if (matched) corroboration = {
93
+ if (testsExecuted === 0) continue;
94
+ if (matched && !matched.skipped) corroboration = {
158
95
  level: "test",
159
96
  source: report.source,
160
97
  testsExecuted,
161
98
  matchedTest: {
162
99
  title: matched.title,
163
- finalStatus: matched.status.final,
100
+ finalStatus: matched.finalStatus,
164
101
  attempts: matched.attempts,
165
- passedOnRetry: matched.status.passedOnRetry,
166
- everFailed: matched.status.failed,
167
- skipped: matched.status.skipped
102
+ passedOnRetry: matched.passedOnRetry,
103
+ everFailed: matched.everFailed,
104
+ skipped: false
168
105
  }
169
106
  };
170
107
  else corroboration = {
@@ -172,20 +109,22 @@ function stampRuntimeCorroboration(findings, report, evidence) {
172
109
  source: report.source,
173
110
  testsExecuted
174
111
  };
175
- if (f.qaImpact === "FLAKY-RISK" && matched !== void 0 && (matched.status.passedOnRetry || matched.status.failed || matched.status.final === "timedOut")) corroboration.level = "defect";
112
+ if (f.qaImpact === "FLAKY-RISK" && matched !== void 0 && !matched.skipped && (matched.passedOnRetry || matched.finalStatus === "timedOut") && deriveTrustLevel(f, {
113
+ ...corroboration,
114
+ level: "defect"
115
+ }) === "L5") corroboration.level = "defect";
176
116
  f.runtimeCorroboration = corroboration;
177
117
  f.trustLevel = deriveTrustLevel(f, corroboration);
178
118
  corroborated++;
179
119
  }
180
120
  return corroborated;
181
121
  }
182
- /**
183
- * (Matching semantics live in the evidence core — `findTestAt`. The
184
- * doc comments below were the pre-core contract and are preserved
185
- * there; Audit W8's honesty rule is enforced by `findTestAt` verbatim:
186
- * a record without a declaration line can never produce a test-level
187
- * match.)
188
- */
122
+ function findContainingTest(verdicts, line) {
123
+ if (!Number.isInteger(line) || line < 1) return void 0;
124
+ if (verdicts.some((v) => v.line === void 0)) return void 0;
125
+ const matches = verdicts.filter((v) => v.line === line);
126
+ return matches.length === 1 ? matches[0] : void 0;
127
+ }
189
128
  /**
190
129
  * The deterministic L0–L5 derivation (see TRUST_ORDER). Static base
191
130
  * from the E-ladder; runtime evidence upgrades, never downgrades.
@@ -193,18 +132,9 @@ function stampRuntimeCorroboration(findings, report, evidence) {
193
132
  * produce L4/L5 for a static-only finding.
194
133
  */
195
134
  function deriveTrustLevel(finding, corroboration) {
196
- let level;
197
- if (finding.evidenceLevel !== void 0) level = finding.evidenceLevel;
198
- else if (finding.findingType === "observation") level = "E0";
199
- else if (finding.findingType === "heuristic-risk") level = "E1";
200
- else if (finding.confidence === "low") level = "E1";
201
- else level = "E2";
202
- if (!corroboration) {
203
- if (level === "E0") return "L0";
204
- if (level === "E1") return "L1";
205
- return "L2";
206
- }
207
- if (corroboration.level === "defect") return "L5";
135
+ const level = finding.evidenceLevel ?? (finding.findingType === "observation" ? "E0" : finding.findingType === "heuristic-risk" ? "E1" : finding.confidence === "low" ? "E1" : "E2");
136
+ if (!corroboration) return level === "E0" ? "L0" : level === "E1" ? "L1" : "L2";
137
+ if (corroboration.level === "defect") return level === "E0" ? "L4" : "L5";
208
138
  if (corroboration.level === "test") return "L4";
209
139
  return "L3";
210
140
  }
@@ -417,7 +347,7 @@ const MEASURED_FP = {
417
347
  "QA-PW-004": {
418
348
  fpRate: .429,
419
349
  n: 14,
420
- detectorRevision: 1,
350
+ detectorRevision: 2,
421
351
  ciLow: .2138,
422
352
  ciHigh: .6741
423
353
  },
@@ -501,7 +431,7 @@ const MEASURED_FP = {
501
431
  "QA-PW-125": {
502
432
  fpRate: 0,
503
433
  n: 10,
504
- detectorRevision: 1,
434
+ detectorRevision: 2,
505
435
  ciLow: 0,
506
436
  ciHigh: .2775
507
437
  },
@@ -564,7 +494,7 @@ const MEASURED_FP = {
564
494
  "QA-PY-003": {
565
495
  fpRate: .467,
566
496
  n: 30,
567
- detectorRevision: 3,
497
+ detectorRevision: 4,
568
498
  ciLow: .3023,
569
499
  ciHigh: .6386
570
500
  },
@@ -895,21 +825,33 @@ function canonical(value) {
895
825
  return JSON.stringify(value);
896
826
  }
897
827
  function buildRunIdentity(input) {
898
- const inputFingerprint = sha256$1([...input.files].map((f) => `${f.path}:${f.size}`).sort().join("\n"));
828
+ const inputFingerprint = sha256$1([...input.files].map((f) => f.hash ? `${f.path}:${f.hash}` : `${f.path}:${f.size}`).sort().join("\n"));
899
829
  const rulesDigest = sha256$1([...input.rules].map((r) => `${r.id}:${r.detectorRevision}`).sort().join("\n"));
900
830
  const configFingerprint = sha256$1(canonical(input.config ?? null));
901
- return {
902
- scanId: sha256$1(canonical({
903
- inputFingerprint,
904
- rulesDigest,
905
- configFingerprint,
906
- engineVersion: input.engineVersion
907
- })),
831
+ const verdictInputs = {
908
832
  inputFingerprint,
909
833
  rulesDigest,
910
834
  configFingerprint,
911
835
  engineVersion: input.engineVersion
912
836
  };
837
+ if (input.reportDigest) verdictInputs.reportDigest = input.reportDigest;
838
+ if (input.trustModelVersion !== void 0) verdictInputs.trustModelVersion = input.trustModelVersion;
839
+ if (input.scoringModelVersion !== void 0) verdictInputs.scoringModelVersion = input.scoringModelVersion;
840
+ if (input.frameworkSupportMatrixVersion !== void 0) verdictInputs.frameworkSupportMatrixVersion = input.frameworkSupportMatrixVersion;
841
+ if (input.evidenceSchemaVersions !== void 0) verdictInputs.evidenceSchemaVersions = [...input.evidenceSchemaVersions].sort((a, b) => a - b);
842
+ if (input.suppressionFingerprint !== void 0) verdictInputs.suppressionFingerprint = input.suppressionFingerprint;
843
+ if (input.policyFingerprint !== void 0) verdictInputs.policyFingerprint = input.policyFingerprint;
844
+ if (input.historicalEvidenceFingerprint !== void 0) verdictInputs.historicalEvidenceFingerprint = input.historicalEvidenceFingerprint;
845
+ return {
846
+ scanId: sha256$1(canonical(verdictInputs)),
847
+ inputFingerprint,
848
+ rulesDigest,
849
+ configFingerprint,
850
+ engineVersion: input.engineVersion,
851
+ ...input.trustModelVersion !== void 0 ? { trustModelVersion: input.trustModelVersion } : {},
852
+ ...input.scoringModelVersion !== void 0 ? { scoringModelVersion: input.scoringModelVersion } : {},
853
+ ...input.frameworkSupportMatrixVersion !== void 0 ? { frameworkSupportMatrixVersion: input.frameworkSupportMatrixVersion } : {}
854
+ };
913
855
  }
914
856
  /**
915
857
  * Builds the Evidence Graph for a run. No fabrication: a link's `ref`
@@ -962,7 +904,20 @@ function buildEvidenceGraph(parts) {
962
904
  * scripts/sync-sarif-version.cjs and guarded by the version-consistency
963
905
  * spec. cli.ts re-exports this as CLI_VERSION.
964
906
  */
965
- const ENGINE_VERSION = "1.0.9";
907
+ const ENGINE_VERSION = "2.0.2";
908
+ //#endregion
909
+ //#region src/engine/contract-versions.ts
910
+ /**
911
+ * Versioned contract registry (VERSION-001).
912
+ *
913
+ * Single source of truth for every version constant the engine exposes
914
+ * to consumers (run identity, evidence graph, machine contract).
915
+ * Constants are `as const` — literal types flow into the identity
916
+ * surface without widening.
917
+ */
918
+ const TRUST_MODEL_VERSION = "1.0.0";
919
+ const SCORING_MODEL_VERSION = "1.0.0";
920
+ const FRAMEWORK_SUPPORT_MATRIX_VERSION = "1.0.0";
966
921
  //#endregion
967
922
  //#region src/discovery/evidence-discovery.ts
968
923
  /**
@@ -1020,7 +975,7 @@ const SKIP_DIRS = /* @__PURE__ */ new Set([
1020
975
  ]);
1021
976
  function listDirs(dir) {
1022
977
  try {
1023
- return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !SKIP_DIRS.has(e.name)).map((e) => join(dir, e.name));
978
+ return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !SKIP_DIRS.has(e.name)).map((e) => join(dir, e.name)).sort();
1024
979
  } catch {
1025
980
  return [];
1026
981
  }
@@ -1041,14 +996,15 @@ function discoverEvidenceCandidates(scanRoot) {
1041
996
  const st = statSync(p);
1042
997
  if (conv.kind === "dir" ? st.isDirectory() : st.isFile()) out.push({
1043
998
  path: p,
1044
- convention: conv.convention
999
+ convention: conv.convention,
1000
+ depth
1045
1001
  });
1046
1002
  }
1047
1003
  if (depth < SEARCH_DEPTH) next.push(...listDirs(dir));
1048
1004
  }
1049
1005
  levels.push(next);
1050
1006
  }
1051
- return out.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
1007
+ return out.sort((a, b) => a.depth - b.depth || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
1052
1008
  }
1053
1009
  //#endregion
1054
1010
  //#region src/discovery/workspace.ts
@@ -1057,10 +1013,17 @@ function discoverEvidenceCandidates(scanRoot) {
1057
1013
  * Finds project root, parses package.json, detects npm/yarn/pnpm workspaces.
1058
1014
  * Monorepo depth beyond workspaces is a documented launch cut (§29.1).
1059
1015
  */
1016
+ const PROJECT_MARKERS = [
1017
+ "package.json",
1018
+ "pyproject.toml",
1019
+ "pom.xml",
1020
+ "build.gradle",
1021
+ "build.gradle.kts"
1022
+ ];
1060
1023
  function findProjectRoot(startDir) {
1061
1024
  let dir = resolve(startDir);
1062
1025
  while (true) {
1063
- if (existsSync(join(dir, "package.json"))) return dir;
1026
+ for (const marker of PROJECT_MARKERS) if (existsSync(join(dir, marker))) return dir;
1064
1027
  const parent = dirname(dir);
1065
1028
  if (parent === dir) return null;
1066
1029
  dir = parent;
@@ -1079,16 +1042,252 @@ function discoverWorkspace(rootDir) {
1079
1042
  else if (rawWorkspaces && typeof rawWorkspaces === "object" && Array.isArray(rawWorkspaces.packages)) globs = rawWorkspaces.packages.filter((w) => typeof w === "string");
1080
1043
  return {
1081
1044
  root,
1082
- name: typeof pkg["name"] === "string" ? pkg["name"] : basename(root),
1045
+ name: typeof pkg["name"] === "string" ? pkg["name"] : basename$1(root),
1083
1046
  packageJson: pkg,
1084
1047
  workspaceGlobs: globs
1085
1048
  };
1086
1049
  }
1087
- function basename(p) {
1050
+ function basename$1(p) {
1088
1051
  const parts = p.split(/[\\/]/);
1089
1052
  return parts[parts.length - 1];
1090
1053
  }
1091
1054
  //#endregion
1055
+ //#region src/lib/safe-json.ts
1056
+ /**
1057
+ * Reusable safe-JSON parse-and-validate helper.
1058
+ *
1059
+ * Replaces the `JSON.parse(text) as T` pattern with a two-step process:
1060
+ * 1. Try/catch around JSON.parse (descriptive error on bad JSON)
1061
+ * 2. Runtime validation of the parsed shape (caller-supplied predicate)
1062
+ *
1063
+ * The `validate` predicate is intentionally lightweight — it checks the
1064
+ * expected shape exists rather than exhaustively type-guarding every field.
1065
+ * Internal files (cache, hashes) use `isRecord`; user-facing files
1066
+ * (config, plugins) use stricter checks that throw descriptive errors.
1067
+ */
1068
+ function parseJsonFile(text, source, validate) {
1069
+ let parsed;
1070
+ try {
1071
+ parsed = JSON.parse(text);
1072
+ } catch (err) {
1073
+ throw new Error(`invalid JSON in ${source}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
1074
+ }
1075
+ if (!validate(parsed)) throw new Error(`unexpected shape in ${source}`, { cause: parsed });
1076
+ return parsed;
1077
+ }
1078
+ /**
1079
+ * Minimal validator: value is a plain object (not null, not array).
1080
+ * Sufficient for internal files (cache, hashes) where downstream
1081
+ * code already checks individual fields.
1082
+ */
1083
+ function isRecord(v) {
1084
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1085
+ }
1086
+ //#endregion
1087
+ //#region src/config/config-schema.ts
1088
+ const VALID_GATES = /* @__PURE__ */ new Set([
1089
+ "advisory",
1090
+ "error",
1091
+ "warning"
1092
+ ]);
1093
+ const VALID_SEVERITIES = /* @__PURE__ */ new Set([
1094
+ "error",
1095
+ "warning",
1096
+ "info"
1097
+ ]);
1098
+ /**
1099
+ * Validates a config object against the structural schema.
1100
+ * Returns { valid: true } when the config is structurally sound,
1101
+ * or { valid: false, errors } listing each violation.
1102
+ */
1103
+ function validateConfigSchema(config) {
1104
+ const errors = [];
1105
+ if (config === null || typeof config !== "object" || Array.isArray(config)) return {
1106
+ valid: false,
1107
+ errors: ["config must be a non-null object"]
1108
+ };
1109
+ const cfg = config;
1110
+ if (cfg["gate"] !== void 0) {
1111
+ if (typeof cfg["gate"] !== "string") errors.push(`gate must be a string, got ${typeof cfg["gate"]}`);
1112
+ else if (!VALID_GATES.has(cfg["gate"])) errors.push(`gate must be one of advisory|error|warning, got "${cfg["gate"]}"`);
1113
+ }
1114
+ if (cfg["exclude"] !== void 0) {
1115
+ if (!Array.isArray(cfg["exclude"])) errors.push(`exclude must be an array, got ${typeof cfg["exclude"]}`);
1116
+ else for (let i = 0; i < cfg["exclude"].length; i++) if (typeof cfg["exclude"][i] !== "string") errors.push(`exclude[${i}] must be a string, got ${typeof cfg["exclude"][i]}`);
1117
+ }
1118
+ if (cfg["severityOverrides"] !== void 0) {
1119
+ if (typeof cfg["severityOverrides"] !== "object" || cfg["severityOverrides"] === null || Array.isArray(cfg["severityOverrides"])) errors.push("severityOverrides must be an object");
1120
+ else for (const [key, value] of Object.entries(cfg["severityOverrides"])) if (typeof value !== "string") errors.push(`severityOverrides["${key}"] must be a string, got ${typeof value}`);
1121
+ else if (!VALID_SEVERITIES.has(value)) errors.push(`severityOverrides["${key}"] must be one of error|warning|info, got "${value}"`);
1122
+ }
1123
+ if (cfg["ignore"] !== void 0) {
1124
+ if (!Array.isArray(cfg["ignore"])) errors.push(`ignore must be an array, got ${typeof cfg["ignore"]}`);
1125
+ else for (let i = 0; i < cfg["ignore"].length; i++) {
1126
+ const entry = cfg["ignore"][i];
1127
+ const prefix = `ignore[${i}]`;
1128
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
1129
+ errors.push(`${prefix} must be an object`);
1130
+ continue;
1131
+ }
1132
+ const ign = entry;
1133
+ if (typeof ign["ruleId"] !== "string" || ign["ruleId"] === "") errors.push(`${prefix}.ruleId must be a non-empty string`);
1134
+ if (typeof ign["reason"] !== "string" || ign["reason"] === "") errors.push(`${prefix}.reason must be a non-empty string`);
1135
+ if (ign["files"] !== void 0) {
1136
+ if (!Array.isArray(ign["files"])) errors.push(`${prefix}.files must be an array`);
1137
+ else for (let j = 0; j < ign["files"].length; j++) if (typeof ign["files"][j] !== "string") errors.push(`${prefix}.files[${j}] must be a string, got ${typeof ign["files"][j]}`);
1138
+ }
1139
+ if (ign["expires"] !== void 0 && typeof ign["expires"] !== "string") errors.push(`${prefix}.expires must be a string, got ${typeof ign["expires"]}`);
1140
+ const allowedKeys = /* @__PURE__ */ new Set([
1141
+ "ruleId",
1142
+ "reason",
1143
+ "files",
1144
+ "expires"
1145
+ ]);
1146
+ for (const key of Object.keys(ign)) if (!allowedKeys.has(key)) errors.push(`${prefix} has unknown property "${key}"`);
1147
+ }
1148
+ }
1149
+ if (cfg["plugins"] !== void 0) {
1150
+ if (cfg["plugins"] === null || typeof cfg["plugins"] !== "object") errors.push("plugins must be an object or array");
1151
+ }
1152
+ return {
1153
+ valid: errors.length === 0,
1154
+ errors
1155
+ };
1156
+ }
1157
+ //#endregion
1158
+ //#region src/config/config.ts
1159
+ /**
1160
+ * Config file support (Sprint-Plan W7, Product-MVP §27 + GAP-F).
1161
+ * Zero-config preserved: config presence never changes detection
1162
+ * semantics — only severity, scope, and gating.
1163
+ */
1164
+ /**
1165
+ * Audit S7: every config key the engine consumes. A top-level key
1166
+ * OUTSIDE this set is a likely typo ("severities", "ingore") — unknown
1167
+ * keys emit a warning (fail-noisy, not fail-silent) and are ignored.
1168
+ */
1169
+ const KNOWN_CONFIG_KEYS = /* @__PURE__ */ new Set([
1170
+ "gate",
1171
+ "exclude",
1172
+ "severityOverrides",
1173
+ "ignore",
1174
+ "plugins"
1175
+ ]);
1176
+ const CONFIG_NAMES = ["mjolnir.config.json", ".mjolnir.json"];
1177
+ /**
1178
+ * Returns the absolute path of the first config file found, or null.
1179
+ * Shared by config loader, ignores, and plugin loading so all three
1180
+ * respect the same config file name priority.
1181
+ */
1182
+ function findConfigPath(root) {
1183
+ for (const name of CONFIG_NAMES) {
1184
+ const p = join(root, name);
1185
+ if (existsSync(p)) return p;
1186
+ }
1187
+ return null;
1188
+ }
1189
+ /**
1190
+ * Distinguished from other load failures so the CLI can exit 10 (usage)
1191
+ * instead of 20 (internal): a typo in the user's config is a user error
1192
+ * with a fixable message, not a tool malfunction (bug-audit M4).
1193
+ */
1194
+ var ConfigValidationError = class extends Error {
1195
+ constructor(message, options) {
1196
+ super(message, options);
1197
+ this.name = "ConfigValidationError";
1198
+ }
1199
+ };
1200
+ function loadConfig(root, options = {}) {
1201
+ for (const name of CONFIG_NAMES) {
1202
+ const p = join(root, name);
1203
+ if (!existsSync(p)) continue;
1204
+ try {
1205
+ const parsed = parseJsonFile(readFileSync(p, "utf8"), p, (v) => isRecord(v));
1206
+ return {
1207
+ config: parsed,
1208
+ path: p,
1209
+ warnings: validate(parsed, options.knownRuleIds)
1210
+ };
1211
+ } catch (err) {
1212
+ const msg = err.message;
1213
+ throw new ConfigValidationError(`Invalid mjolnir config at ${p}: ${msg}`, { cause: err });
1214
+ }
1215
+ }
1216
+ return {
1217
+ config: {},
1218
+ path: null,
1219
+ warnings: []
1220
+ };
1221
+ }
1222
+ function validate(cfg, knownRuleIds) {
1223
+ const warnings = [];
1224
+ const schemaResult = validateConfigSchema(cfg);
1225
+ if (!schemaResult.valid) throw new Error(schemaResult.errors.join("; "));
1226
+ if (cfg.gate && ![
1227
+ "advisory",
1228
+ "error",
1229
+ "warning"
1230
+ ].includes(cfg.gate)) throw new Error(`gate must be advisory|error|warning, got "${cfg.gate}"`);
1231
+ for (const [ruleId, severity] of Object.entries(cfg.severityOverrides ?? {})) {
1232
+ if (!SEVERITY_ORDER.includes(severity)) throw new Error(`severityOverrides["${ruleId}"] must be one of ${SEVERITY_ORDER.join("|")}, got "${String(severity)}"`);
1233
+ if (knownRuleIds && !knownRuleIds.has(ruleId)) warnings.push(`warning: severityOverrides["${ruleId}"] names no registered rule (typo? plugin rule?) — the entry is kept but currently matches nothing.`);
1234
+ }
1235
+ for (const ign of cfg.ignore ?? []) {
1236
+ if (!ign.ruleId) throw new Error("ignore entries require ruleId");
1237
+ if (!ign.reason) throw new Error(`ignore for ${ign.ruleId} requires a "reason" (§27)`);
1238
+ if (ign.files !== void 0) {
1239
+ if (!Array.isArray(ign.files)) throw new Error(`ignore for ${ign.ruleId}: "files" must be an array of glob strings, got ${typeof ign.files}`);
1240
+ for (const g of ign.files) if (typeof g !== "string") throw new Error(`ignore for ${ign.ruleId}: "files" entries must be strings, got ${typeof g} (${JSON.stringify(g)})`);
1241
+ }
1242
+ if (ign.expires !== void 0 && Number.isNaN(new Date(ign.expires).getTime())) throw new Error(`ignore for ${ign.ruleId}: "expires" must be an ISO date, got "${ign.expires}"`);
1243
+ if (ign.expires !== void 0 && !Number.isNaN(new Date(ign.expires).getTime()) && new Date(ign.expires).getTime() < Date.now()) warnings.push(`warning: ignore for ${ign.ruleId} has expired (expires "${ign.expires}") — the entry is kept but currently inactive.`);
1244
+ }
1245
+ for (const key of Object.keys(cfg)) if (!KNOWN_CONFIG_KEYS.has(key)) warnings.push(`warning: mjolnir.config.json has an unknown top-level key "${key}" — ignored (typo? see README §Configuration).`);
1246
+ if (cfg.exclude !== void 0) {
1247
+ if (!Array.isArray(cfg.exclude)) throw new Error(`exclude must be an array of strings, got ${typeof cfg.exclude}`);
1248
+ for (const p of cfg.exclude) if (typeof p !== "string") throw new Error(`exclude entries must be strings, got ${typeof p} (${JSON.stringify(p)})`);
1249
+ }
1250
+ return warnings;
1251
+ }
1252
+ /**
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).
1267
+ */
1268
+ function isSuppressionActive(ign, now = /* @__PURE__ */ new Date()) {
1269
+ if (!ign.expires) return true;
1270
+ return utcMidnight(ign.expires) > utcMidnightOf(now);
1271
+ }
1272
+ /** UTC-midnight timestamp of an ISO date string (YYYY-MM-DD). */
1273
+ function utcMidnight(isoDate) {
1274
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(isoDate)) return NaN;
1275
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
1276
+ if (!m) return NaN;
1277
+ return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
1278
+ }
1279
+ /** UTC-midnight timestamp of a Date (calendar day, timezone-independent). */
1280
+ function utcMidnightOf(d) {
1281
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
1282
+ }
1283
+ function applySeverityOverrides(findings, cfg) {
1284
+ const overrides = cfg.severityOverrides ?? {};
1285
+ for (const f of findings) {
1286
+ const override = overrides[f.ruleId];
1287
+ if (override && SEVERITY_ORDER.includes(override)) f.severity = override;
1288
+ }
1289
+ }
1290
+ //#endregion
1092
1291
  //#region src/discovery/ignores.ts
1093
1292
  /**
1094
1293
  * Ignore resolution (Product-MVP.txt R1 — Tier-1 launch blocker).
@@ -1118,6 +1317,7 @@ const DEFAULT_IGNORES = [
1118
1317
  "node_modules",
1119
1318
  ".git",
1120
1319
  ".mjolnir",
1320
+ ".claude",
1121
1321
  "dist",
1122
1322
  "build",
1123
1323
  "out",
@@ -1203,8 +1403,8 @@ function loadExtraPatterns(root) {
1203
1403
  const patterns = readFileSync(ignorePath, "utf8").split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
1204
1404
  extra.push(...patterns);
1205
1405
  } catch {}
1206
- const configPath = join(root, "mjolnir.config.json");
1207
- if (existsSync(configPath)) try {
1406
+ const configPath = findConfigPath(root);
1407
+ if (configPath) try {
1208
1408
  const cfg = JSON.parse(readFileSync(configPath, "utf8"));
1209
1409
  if (Array.isArray(cfg.exclude)) extra.push(...cfg.exclude.filter((p) => typeof p === "string"));
1210
1410
  } catch {}
@@ -1503,41 +1703,25 @@ function getCodeOnlyText(file) {
1503
1703
  try {
1504
1704
  const text = file.text;
1505
1705
  const ranges = [];
1506
- for (const d of sf.getDescendantsOfKind(ts$1.SyntaxKind.StringLiteral)) {
1507
- const s = d.getStart();
1508
- ranges.push({
1509
- start: s,
1510
- end: s + d.getWidth()
1511
- });
1512
- }
1513
- for (const d of sf.getDescendantsOfKind(ts$1.SyntaxKind.NoSubstitutionTemplateLiteral)) {
1514
- const s = d.getStart();
1515
- ranges.push({
1516
- start: s,
1517
- end: s + d.getWidth()
1518
- });
1519
- }
1520
- for (const d of sf.getDescendantsOfKind(ts$1.SyntaxKind.TemplateHead)) {
1521
- const s = d.getStart();
1522
- ranges.push({
1523
- start: s,
1524
- end: s + d.getWidth()
1525
- });
1526
- }
1527
- for (const d of sf.getDescendantsOfKind(ts$1.SyntaxKind.TemplateMiddle)) {
1528
- const s = d.getStart();
1529
- ranges.push({
1530
- start: s,
1531
- end: s + d.getWidth()
1532
- });
1533
- }
1534
- for (const d of sf.getDescendantsOfKind(ts$1.SyntaxKind.TemplateTail)) {
1535
- const s = d.getStart();
1536
- ranges.push({
1537
- start: s,
1538
- end: s + d.getWidth()
1539
- });
1540
- }
1706
+ const compiler = sf.compilerNode;
1707
+ const stringAndTemplateKinds = {
1708
+ [ts$1.SyntaxKind.StringLiteral]: true,
1709
+ [ts$1.SyntaxKind.NoSubstitutionTemplateLiteral]: true,
1710
+ [ts$1.SyntaxKind.TemplateHead]: true,
1711
+ [ts$1.SyntaxKind.TemplateMiddle]: true,
1712
+ [ts$1.SyntaxKind.TemplateTail]: true
1713
+ };
1714
+ const collect = (node) => {
1715
+ if (stringAndTemplateKinds[node.kind]) {
1716
+ const s = node.getStart(compiler);
1717
+ ranges.push({
1718
+ start: s,
1719
+ end: s + node.getWidth()
1720
+ });
1721
+ }
1722
+ ts$1.forEachChild(node, collect);
1723
+ };
1724
+ ts$1.forEachChild(compiler, collect);
1541
1725
  for (const r of commentAndStringRanges({
1542
1726
  ...file,
1543
1727
  ast: sf
@@ -1551,6 +1735,10 @@ function getCodeOnlyText(file) {
1551
1735
  return file.text;
1552
1736
  }
1553
1737
  }
1738
+ /** Release the shared ts-morph Project between scans to free memory. */
1739
+ function resetTsMorphProject() {
1740
+ project = null;
1741
+ }
1554
1742
  //#endregion
1555
1743
  //#region src/engine/code-text.ts
1556
1744
  /**
@@ -1577,6 +1765,7 @@ function computeCodeText(file, language) {
1577
1765
  case "python": return maskPython(file.text);
1578
1766
  case "java": return maskJava(file.text);
1579
1767
  case "csharp": return maskCSharp(file.text);
1768
+ default: return file.text;
1580
1769
  }
1581
1770
  }
1582
1771
  /**
@@ -1802,18 +1991,36 @@ function maskCSharp(text) {
1802
1991
  i = prefixStart + 1;
1803
1992
  continue;
1804
1993
  }
1805
- if (chars[i] === "\"" && i + 2 < len && chars[i + 1] === "\"" && chars[i + 2] === "\"") {
1806
- const start = i;
1807
- i += 3;
1808
- while (i < len) {
1809
- if (chars[i] === "\"" && i + 1 < len && chars[i + 1] === "\"" && i + 2 < len && chars[i + 2] === "\"") {
1810
- i += 3;
1811
- break;
1994
+ if (chars[i] === "\"") {
1995
+ let quoteCount = 0;
1996
+ let j = i;
1997
+ while (j < len && chars[j] === "\"") {
1998
+ quoteCount++;
1999
+ j++;
2000
+ }
2001
+ if (quoteCount >= 3) {
2002
+ const start = i;
2003
+ i = j;
2004
+ while (i < len) {
2005
+ let closeCount = 0;
2006
+ let k = i;
2007
+ while (k < len && chars[k] === "\"") {
2008
+ closeCount++;
2009
+ k++;
2010
+ }
2011
+ if (closeCount === quoteCount) {
2012
+ i = k;
2013
+ break;
2014
+ }
2015
+ if (closeCount > 0) {
2016
+ i = k;
2017
+ continue;
2018
+ }
2019
+ i++;
1812
2020
  }
1813
- i++;
2021
+ blankRange(chars, start, i);
2022
+ continue;
1814
2023
  }
1815
- blankRange(chars, start, i);
1816
- continue;
1817
2024
  }
1818
2025
  if (chars[i] === "\"") {
1819
2026
  const start = i;
@@ -1843,6 +2050,50 @@ function maskCSharp(text) {
1843
2050
  return chars.join("");
1844
2051
  }
1845
2052
  /**
2053
+ * Blanks TypeScript/JavaScript string literals and comments to spaces
2054
+ * using the TypeScript compiler scanner with `reScanTemplateToken`
2055
+ * tracking for template interpolation boundaries. No AST required.
2056
+ *
2057
+ * This reuses the same pattern as `detector-hash.ts` (walkTokens) which
2058
+ * already solves the exact same template-interpolation problem for hash
2059
+ * computation. The phantom-comment edge case only occurs when
2060
+ * `reScanTemplateToken` is NOT called — calling it closes the gap.
2061
+ */
2062
+ function maskTypeScriptBasic(text) {
2063
+ const chars = text.split("");
2064
+ const scanner = ts$1.createScanner(ts$1.ScriptTarget.ES2022, false, ts$1.LanguageVariant.Standard, text);
2065
+ let templateDepth = 0;
2066
+ let tok = scanner.scan();
2067
+ while (tok !== ts$1.SyntaxKind.EndOfFileToken) {
2068
+ if (tok === ts$1.SyntaxKind.CloseBraceToken && templateDepth > 0) tok = scanner.reScanTemplateToken(false);
2069
+ const start = scanner.getTokenPos();
2070
+ const end = scanner.getTextPos();
2071
+ if (tok === ts$1.SyntaxKind.SingleLineCommentTrivia || tok === ts$1.SyntaxKind.MultiLineCommentTrivia || tok === ts$1.SyntaxKind.StringLiteral || tok === ts$1.SyntaxKind.NoSubstitutionTemplateLiteral || tok === ts$1.SyntaxKind.TemplateHead || tok === ts$1.SyntaxKind.TemplateMiddle || tok === ts$1.SyntaxKind.TemplateTail) {
2072
+ for (let i = start; i < end && i < chars.length; i++) if (chars[i] !== "\n" && chars[i] !== "\r") chars[i] = " ";
2073
+ }
2074
+ if (tok === ts$1.SyntaxKind.TemplateHead) templateDepth++;
2075
+ else if (tok === ts$1.SyntaxKind.LastTemplateToken) templateDepth = Math.max(0, templateDepth - 1);
2076
+ tok = scanner.scan();
2077
+ }
2078
+ return chars.join("");
2079
+ }
2080
+ /**
2081
+ * Compute the code-only text for counting test declarations. Dispatches
2082
+ * to the appropriate masker per language. Never falls back to raw text —
2083
+ * always produces a masked view with comments/strings blanked.
2084
+ *
2085
+ * Used when `parsedFile.codeText` is not available (e.g., AST parse failed).
2086
+ */
2087
+ function getCodeTextForCounting(text, language) {
2088
+ switch (language) {
2089
+ case "typescript": return maskTypeScriptBasic(text);
2090
+ case "python": return maskPython(text);
2091
+ case "java": return maskJava(text);
2092
+ case "csharp": return maskCSharp(text);
2093
+ default: return text;
2094
+ }
2095
+ }
2096
+ /**
1846
2097
  * Blanks a range of characters to spaces, preserving newlines so
1847
2098
  * line/column computations remain valid.
1848
2099
  */
@@ -3203,7 +3454,9 @@ const azurePipelinesAdapter = {
3203
3454
  dirSkips: [],
3204
3455
  isTestFile(path) {
3205
3456
  const normalized = path.replaceAll("\\", "/");
3206
- return AZURE_PIPELINE_NAMES.includes(normalized);
3457
+ const basename = normalized.split("/").pop() ?? normalized;
3458
+ if (!AZURE_PIPELINE_NAMES.includes(basename)) return false;
3459
+ return normalized === basename || normalized.startsWith("/") || /^[A-Z]:/i.test(normalized);
3207
3460
  },
3208
3461
  detectFrameworks() {
3209
3462
  return {
@@ -3285,7 +3538,10 @@ const jenkinsAdapter = {
3285
3538
  testFileGlobs: ["Jenkinsfile"],
3286
3539
  dirSkips: [],
3287
3540
  isTestFile(path) {
3288
- return JENKINS_FILENAMES.includes(path.replaceAll("\\", "/"));
3541
+ const normalized = path.replaceAll("\\", "/");
3542
+ const basename = normalized.split("/").pop() ?? normalized;
3543
+ if (!JENKINS_FILENAMES.includes(basename)) return false;
3544
+ return normalized === basename || normalized.startsWith("/") || /^[A-Z]:/i.test(normalized);
3289
3545
  },
3290
3546
  detectFrameworks() {
3291
3547
  return {
@@ -3538,18 +3794,19 @@ function collectNameStatus(output) {
3538
3794
  return files;
3539
3795
  }
3540
3796
  function computeChangedScope(root, baseBranch) {
3541
- if (!existsSync(join(root, ".git"))) return {
3797
+ const gitRoot = runGit(root, ["rev-parse", "--show-toplevel"])?.trim() ?? root;
3798
+ if (!existsSync(join(gitRoot, ".git"))) return {
3542
3799
  changed: {},
3543
3800
  degraded: true,
3544
3801
  reason: "not-a-git-repo"
3545
3802
  };
3546
- const mergeBase = resolveMergeBase(root, baseBranch);
3803
+ const mergeBase = resolveMergeBase(gitRoot, baseBranch);
3547
3804
  if (!mergeBase) return {
3548
3805
  changed: {},
3549
3806
  degraded: true,
3550
3807
  reason: "no-merge-base"
3551
3808
  };
3552
- const committed = runGit(root, [
3809
+ const committed = runGit(gitRoot, [
3553
3810
  "diff",
3554
3811
  "--name-status",
3555
3812
  "-z",
@@ -3562,7 +3819,7 @@ function computeChangedScope(root, baseBranch) {
3562
3819
  degraded: true,
3563
3820
  reason: "diff-failed"
3564
3821
  };
3565
- const workingTree = runGit(root, [
3822
+ const workingTree = runGit(gitRoot, [
3566
3823
  "diff",
3567
3824
  "--name-status",
3568
3825
  "-z",
@@ -3574,7 +3831,7 @@ function computeChangedScope(root, baseBranch) {
3574
3831
  degraded: true,
3575
3832
  reason: "diff-failed"
3576
3833
  };
3577
- const untracked = runGit(root, [
3834
+ const untracked = runGit(gitRoot, [
3578
3835
  "ls-files",
3579
3836
  "-z",
3580
3837
  "--others",
@@ -3598,7 +3855,7 @@ function computeChangedScope(root, baseBranch) {
3598
3855
  const CHUNK = 200;
3599
3856
  for (let start = 0; start < files.length; start += CHUNK) {
3600
3857
  const chunk = files.slice(start, start + CHUNK);
3601
- const output = runGit(root, [
3858
+ const output = runGit(gitRoot, [
3602
3859
  ...args,
3603
3860
  "--",
3604
3861
  ...chunk
@@ -3607,7 +3864,14 @@ function computeChangedScope(root, baseBranch) {
3607
3864
  const sections = output.split("\ndiff --git ");
3608
3865
  for (const section of sections) {
3609
3866
  const body = section.startsWith("diff --git ") ? section : `diff --git ${section}`;
3610
- const file = /^diff --git a\/.+ b\/(.+)\n/.exec(`${body}\n`)?.[1];
3867
+ const header = body.split("\n")[0] ?? "";
3868
+ let file;
3869
+ const canonM = /^diff --git "a\/.+?" "b\/(.+?)"/.exec(header);
3870
+ if (canonM) file = canonM[1];
3871
+ else {
3872
+ const simpleM = /^diff --git a\/\S+\s+b\/(\S+)/.exec(header);
3873
+ if (simpleM) file = simpleM[1];
3874
+ }
3611
3875
  if (file !== void 0) perFile.set(file, body);
3612
3876
  }
3613
3877
  }
@@ -3704,8 +3968,9 @@ function filterToChanged(findings, diff) {
3704
3968
  * an honest stderr note). Empty list = genuinely nothing staged.
3705
3969
  */
3706
3970
  function computeStagedFiles(root) {
3707
- if (!existsSync(join(root, ".git"))) return null;
3708
- const raw = runGit(root, [
3971
+ const gitRoot = runGit(root, ["rev-parse", "--show-toplevel"])?.trim() ?? root;
3972
+ if (!existsSync(join(gitRoot, ".git"))) return null;
3973
+ const raw = runGit(gitRoot, [
3709
3974
  "diff",
3710
3975
  "--cached",
3711
3976
  "--name-only",
@@ -4345,12 +4610,12 @@ const unawaitedPromiseAssertion = defineRule({
4345
4610
  detectorRevision: 2,
4346
4611
  run(ctx) {
4347
4612
  const sourceFile = getTsSourceFile(ctx.ast);
4348
- if (sourceFile) return astArm$2(ctx, sourceFile);
4349
- return regexArm$1(ctx);
4613
+ if (sourceFile) return astArm$1(ctx, sourceFile);
4614
+ return regexArm(ctx);
4350
4615
  }
4351
4616
  });
4352
4617
  /** The rev-1 line-walk detector — the parse-or-fallback path (§10.1). */
4353
- function regexArm$1(ctx) {
4618
+ function regexArm(ctx) {
4354
4619
  const text = ctx.codeText ?? ctx.text;
4355
4620
  const findings = [];
4356
4621
  const thenRe = /\.then\s*\(/g;
@@ -4410,7 +4675,7 @@ function regexArm$1(ctx) {
4410
4675
  * - `void`-wrapped chains: a deliberate fire-and-forget marker.
4411
4676
  * Everything else the rev-1 diagnosis covers still fires, one per file.
4412
4677
  */
4413
- function astArm$2(ctx, sourceFile) {
4678
+ function astArm$1(ctx, sourceFile) {
4414
4679
  const findings = [];
4415
4680
  const thenCalls = sourceFile.getDescendantsOfKind(ts.SyntaxKind.PropertyAccessExpression).filter((p) => p.getName() === "then" && p.getParent()?.getKind() === ts.SyntaxKind.CallExpression);
4416
4681
  for (const thenAccess of thenCalls) {
@@ -4552,7 +4817,13 @@ const COMMENTED_TEST_RE = /^(?:await\s+)?(?:it|test)(?:\.\w+)?\s*\(/;
4552
4817
  * continuation of a block comment — those cannot be a commented-out test.
4553
4818
  */
4554
4819
  function stripCommentMarkers(line) {
4555
- const consumed = /^\s*(?:\/{2,}|\/\*\*?|\*)?\s*/.exec(line)[0].length;
4820
+ const m = /^\s*(?:\/{2,}|\/\*\*?|\*)?\s*/.exec(line);
4821
+ if (!m) return {
4822
+ text: line,
4823
+ consumed: 0,
4824
+ matched: false
4825
+ };
4826
+ const consumed = m[0].length;
4556
4827
  return {
4557
4828
  text: line.slice(consumed),
4558
4829
  consumed,
@@ -4813,6 +5084,7 @@ const brittleSelectors = defineRule({
4813
5084
  },
4814
5085
  detectionNotes: "regex pattern + inside-string oracle",
4815
5086
  introduced: "0.1.0",
5087
+ detectorRevision: 2,
4816
5088
  tier: "quarantine",
4817
5089
  run(ctx) {
4818
5090
  const findings = [];
@@ -4869,7 +5141,7 @@ function stripComments(ctx) {
4869
5141
  const slice = ctx.text.slice(r.start, r.end);
4870
5142
  return slice.startsWith("//") || slice.startsWith("/*");
4871
5143
  });
4872
- const chars = [...ctx.text];
5144
+ const chars = ctx.text.split("");
4873
5145
  for (const r of ranges) for (let i = r.start; i < r.end && i < chars.length; i++) if (chars[i] !== "\n" && chars[i] !== "\r") chars[i] = " ";
4874
5146
  return chars.join("");
4875
5147
  }
@@ -4980,11 +5252,11 @@ function locateAzureStepKey(text, step, keyName) {
4980
5252
  if (anchorAt !== -1) {
4981
5253
  keyRe.lastIndex = anchorAt;
4982
5254
  const m = keyRe.exec(text);
4983
- if (m) return lineOf$1(text, m.index);
4984
- return lineOf$1(text, anchorAt);
5255
+ if (m) return lineAt(text, m.index);
5256
+ return lineAt(text, anchorAt);
4985
5257
  }
4986
5258
  const m = keyRe.exec(text);
4987
- if (m) return lineOf$1(text, m.index);
5259
+ if (m) return lineAt(text, m.index);
4988
5260
  return 1;
4989
5261
  }
4990
5262
  /** Line of a job-level key: anchored at the job's own kind key or name. */
@@ -4995,18 +5267,13 @@ function locateAzureJobKey(text, job, keyName) {
4995
5267
  if (anchorAt !== -1) {
4996
5268
  keyRe.lastIndex = anchorAt;
4997
5269
  const m = keyRe.exec(text);
4998
- if (m) return lineOf$1(text, m.index);
4999
- return lineOf$1(text, anchorAt);
5270
+ if (m) return lineAt(text, m.index);
5271
+ return lineAt(text, anchorAt);
5000
5272
  }
5001
5273
  const m = keyRe.exec(text);
5002
- if (m) return lineOf$1(text, m.index);
5274
+ if (m) return lineAt(text, m.index);
5003
5275
  return 1;
5004
5276
  }
5005
- function lineOf$1(text, index) {
5006
- let line = 1;
5007
- for (let i = 0; i < index; i++) if (text[i] === "\n") line++;
5008
- return line;
5009
- }
5010
5277
  //#endregion
5011
5278
  //#region src/rules/ci/qa-ci-001-continue-on-error.ts
5012
5279
  /**
@@ -5165,19 +5432,14 @@ function locateStepContinueOnError(text, step) {
5165
5432
  const re = /continue-on-error:\s*true/g;
5166
5433
  re.lastIndex = searchFrom;
5167
5434
  const m = re.exec(text);
5168
- if (m) return lineOf(text, m.index);
5169
- if (anchorAt !== -1) return lineOf(text, anchorAt);
5435
+ if (m) return lineAt(text, m.index);
5436
+ if (anchorAt !== -1) return lineAt(text, anchorAt);
5170
5437
  return 1;
5171
5438
  }
5172
5439
  function findLine$2(text, re) {
5173
5440
  const m = re.exec(text);
5174
5441
  if (!m) return 1;
5175
- return lineOf(text, m.index);
5176
- }
5177
- function lineOf(text, index) {
5178
- let line = 1;
5179
- for (let i = 0; i < index; i++) if (text[i] === "\n") line++;
5180
- return line;
5442
+ return lineAt(text, m.index);
5181
5443
  }
5182
5444
  function escapeRe$1(s) {
5183
5445
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -5604,14 +5866,9 @@ function findJobConsumerLine(text, jobName, consumerRe) {
5604
5866
  const re = new RegExp(consumerRe.source, "gi");
5605
5867
  re.lastIndex = declMatch ? declMatch.index : 0;
5606
5868
  const anchored = re.exec(text);
5607
- if (anchored) return lineOfIndex(text, anchored.index);
5869
+ if (anchored) return lineAt(text, anchored.index);
5608
5870
  const anywhere = new RegExp(consumerRe.source, "i").exec(text);
5609
- return anywhere ? lineOfIndex(text, anywhere.index) : 1;
5610
- }
5611
- function lineOfIndex(text, index) {
5612
- let line = 1;
5613
- for (let i = 0; i < index; i++) if (text[i] === "\n") line++;
5614
- return line;
5871
+ return anywhere ? lineAt(text, anywhere.index) : 1;
5615
5872
  }
5616
5873
  //#endregion
5617
5874
  //#region src/rules/ci/jenkins-gates.ts
@@ -5858,9 +6115,7 @@ function textIsVerificationGate(text) {
5858
6115
  }
5859
6116
  /** Line (1-based) of an offset — for finding anchors. */
5860
6117
  function lineOfOffset(text, offset) {
5861
- let line = 1;
5862
- for (let i = 0; i < offset && i < text.length; i++) if (text[i] === "\n") line++;
5863
- return line;
6118
+ return lineAt(text, offset);
5864
6119
  }
5865
6120
  //#endregion
5866
6121
  //#region src/rules/ci/qa-ci-008-always-success.ts
@@ -6085,6 +6340,7 @@ const exitCodeNotPropagated = defineRule({
6085
6340
  for (const line of lines) {
6086
6341
  if (!TEST_CMD$1.test(line)) continue;
6087
6342
  const m = TEST_CMD$1.exec(line);
6343
+ if (!m) continue;
6088
6344
  const afterCmd = line.slice(m.index + m[0].length);
6089
6345
  if (/^\s*\|(?!\|)/.exec(afterCmd) && !/\|\|\s|&&\s/.test(afterCmd)) findings.push({
6090
6346
  severity: "error",
@@ -6248,6 +6504,7 @@ const nonBlockingTestJob = defineRule({
6248
6504
  });
6249
6505
  function findLine(text, re) {
6250
6506
  const m = re.exec(text);
6507
+ if (!m) return 1;
6251
6508
  let line = 1;
6252
6509
  for (let i = 0; i < m.index; i++) if (text[i] === "\n") line++;
6253
6510
  return line;
@@ -6445,7 +6702,7 @@ const pyNoAssertions = defineRule({
6445
6702
  },
6446
6703
  introduced: "0.3.0",
6447
6704
  tier: "quarantine",
6448
- detectorRevision: 3,
6705
+ detectorRevision: 4,
6449
6706
  run(ctx) {
6450
6707
  const text = ctx.codeText ?? ctx.text;
6451
6708
  const findings = [];
@@ -6453,7 +6710,8 @@ const pyNoAssertions = defineRule({
6453
6710
  const fnRe = /^([ \t]*)def\s+(test_\w+)\s*\([^)]*\)\s*:/gm;
6454
6711
  let m;
6455
6712
  while ((m = fnRe.exec(text)) !== null) {
6456
- if (m[1].length > 0) continue;
6713
+ const indent = m[1];
6714
+ if (indent.length > 0 && !isCollectedTestMethod(text, m.index, indent)) continue;
6457
6715
  const body = extractBlock$1(text, m.index + m[0].length);
6458
6716
  if (body === null) continue;
6459
6717
  if (!(/^[ \t]*assert\b/m.test(body) || /pytest\.raises/.test(body) || /pytest\.(?:warns|deprecated_call|fail\s*\()/.test(body) || /self\.assert/.test(body) || /\bexpect\b/.test(body) || /\b_?(?:assert|expect|verify|check)[_A-Z]\w*\s*\(/.test(body) || /\b_?wait_for_\w+\s*\(/.test(body))) {
@@ -6482,6 +6740,35 @@ const pyNoAssertions = defineRule({
6482
6740
  return findings;
6483
6741
  }
6484
6742
  });
6743
+ /**
6744
+ * detectorRevision 4: true when the def at `defAt` (indent `indent`) is
6745
+ * a `test_*` METHOD of a pytest-collectable class — every header between
6746
+ * the def and the file top at a SMALLER indent must be either another
6747
+ * def (the method is nested in a function → not collected) or a
6748
+ * `class Test*` (collected). A class with a non-Test name, or any
6749
+ * non-header line at a smaller indent before a class header, means the
6750
+ * def is data → not collected.
6751
+ */
6752
+ function isCollectedTestMethod(text, defAt, indent) {
6753
+ const lines = text.slice(0, defAt).split("\n");
6754
+ for (let i = lines.length - 1; i >= 0; i--) {
6755
+ const line = lines[i];
6756
+ if (line.trim() === "") continue;
6757
+ const indentMatch = /^[ \t]*/.exec(line);
6758
+ if (!indentMatch) continue;
6759
+ const lineIndent = indentMatch[0];
6760
+ if (lineIndent.length >= indent.length) continue;
6761
+ const classM = /^[ \t]*class\s+(\w+)/.exec(line);
6762
+ if (classM) {
6763
+ if (!/^Test/.test(classM[1])) return false;
6764
+ const classBody = lines.slice(i + 1).join("\n");
6765
+ if (new RegExp(`^${lineIndent}(?: |\t)\\s*def\\s+__init__\\s*\\(`, "m").test(classBody)) return false;
6766
+ return true;
6767
+ }
6768
+ return false;
6769
+ }
6770
+ return false;
6771
+ }
6485
6772
  /** Extract an indented block starting after a `:` line; returns null if empty. */
6486
6773
  function extractBlock$1(text, afterColon) {
6487
6774
  const rest = text.slice(afterColon).replace(/\r\n/g, "\n");
@@ -6490,7 +6777,11 @@ function extractBlock$1(text, afterColon) {
6490
6777
  const firstIdx = firstContent.index;
6491
6778
  if (rest.slice(0, firstIdx).includes("\n")) {
6492
6779
  const lines = rest.split("\n").slice(1);
6493
- const indent = /^[ \t]*/.exec(lines.find((l) => l.trim() !== ""))[0];
6780
+ const nonBlank = lines.find((l) => l.trim() !== "");
6781
+ if (!nonBlank) return null;
6782
+ const indentMatch = /^[ \t]*/.exec(nonBlank);
6783
+ if (!indentMatch) return null;
6784
+ const indent = indentMatch[0];
6494
6785
  if (!indent) return null;
6495
6786
  const collected = [];
6496
6787
  for (const line of lines) {
@@ -6744,12 +7035,9 @@ function getTreeSitterTree(ast) {
6744
7035
  * source. The helper exists so this finding is pinned in one place.
6745
7036
  */
6746
7037
  function nodeLineCol(text, charIndex) {
6747
- let line = 1;
6748
- for (let i = 0; i < charIndex; i++) if (text.charCodeAt(i) === 10) line++;
6749
- const lastBreak = charIndex <= 0 ? -1 : text.lastIndexOf("\n", charIndex - 1);
6750
7038
  return {
6751
- line,
6752
- column: charIndex - lastBreak
7039
+ line: lineAt(text, charIndex),
7040
+ column: colAt(text, charIndex)
6753
7041
  };
6754
7042
  }
6755
7043
  function lastIdentifierText(node) {
@@ -6802,8 +7090,7 @@ function isCsTestAttribute(node) {
6802
7090
  if (node.type !== "attribute") return false;
6803
7091
  const nameNode = node.childForFieldName("name");
6804
7092
  if (!nameNode) return false;
6805
- const last = lastIdentifierText(nameNode);
6806
- return last !== void 0 && CS_TEST_ATTRIBUTES.has(last);
7093
+ return CS_TEST_ATTRIBUTES.has(lastIdentifierText(nameNode) ?? "");
6807
7094
  }
6808
7095
  /**
6809
7096
  * Every method declaration carrying a `[Test]`/`[Fact]`/`[TestMethod]`
@@ -6855,8 +7142,7 @@ function isInvocation(node) {
6855
7142
  function callName(node) {
6856
7143
  if (node.type === "method_invocation") {
6857
7144
  const nameNode = node.childForFieldName("name");
6858
- if (!nameNode) return void 0;
6859
- return lastIdentifierText(nameNode);
7145
+ return nameNode ? lastIdentifierText(nameNode) : void 0;
6860
7146
  }
6861
7147
  if (node.type === "invocation_expression") {
6862
7148
  const fn = node.childForFieldName("function");
@@ -6890,7 +7176,7 @@ function invocationsWithin(node) {
6890
7176
  const out = [];
6891
7177
  const visit = (n) => {
6892
7178
  if (isInvocation(n)) out.push(n);
6893
- for (const child of n.namedChildren) visit(child);
7179
+ for (const child of n.namedChildren) if (child) visit(child);
6894
7180
  };
6895
7181
  visit(node);
6896
7182
  return out;
@@ -7017,7 +7303,7 @@ const pyRaisesWithoutMatch = defineRule({
7017
7303
  const findings = [];
7018
7304
  if (!ctx.path.endsWith(".py")) return findings;
7019
7305
  const tree = getPythonTree(ctx.ast);
7020
- if (tree) return astArm$1(ctx, tree);
7306
+ if (tree) return astArm(ctx, tree);
7021
7307
  const text = ctx.codeText ?? ctx.text;
7022
7308
  const re = /pytest\.raises\s*\(/g;
7023
7309
  let m;
@@ -7082,7 +7368,7 @@ function matchParen$1(text, open) {
7082
7368
  * Broad root types (Exception/BaseException/…Group) always fire — the
7083
7369
  * type alone pins nothing.
7084
7370
  */
7085
- function astArm$1(ctx, tree) {
7371
+ function astArm(ctx, tree) {
7086
7372
  const raw = ctx.text;
7087
7373
  const findings = [];
7088
7374
  for (const block of pythonWithRaisesBlocks(tree)) {
@@ -7793,6 +8079,7 @@ const pwGlobalSetupSharedState = defineRule({
7793
8079
  },
7794
8080
  detectionNotes: "regex heuristic",
7795
8081
  introduced: "0.3.0",
8082
+ detectorRevision: 2,
7796
8083
  run(ctx) {
7797
8084
  const text = ctx.text;
7798
8085
  const findings = [];
@@ -7800,6 +8087,7 @@ const pwGlobalSetupSharedState = defineRule({
7800
8087
  const re = /(?:execSync|exec|spawn|query|request)\s*\(\s*[`'"][^`'"]*(?:migrate|migration|seed|TRUNCATE|DROP\s+(?:TABLE|DATABASE)|DELETE\s+FROM)[^`'"]*[`'"]/gi;
7801
8088
  let m;
7802
8089
  while ((m = re.exec(text)) !== null) {
8090
+ if (isMasked(ctx, m.index)) continue;
7803
8091
  const lineStart = text.lastIndexOf("\n", m.index) + 1;
7804
8092
  const lineEnd = text.indexOf("\n", m.index);
7805
8093
  const prevLineStart = text.lastIndexOf("\n", lineStart - 2) + 1;
@@ -8343,8 +8631,8 @@ const pwLocatorNormalize = defineRule({
8343
8631
  autofix: false,
8344
8632
  detectionStrategy: "LEXICAL",
8345
8633
  strategyJustification: {
8346
- reasonCode: "string-content-defect",
8347
- detail: "CSS/XPath string selectors are string-argument shapes (css=/xpath= engines, bare id/class/attr CSS, nth-child); the detector classifies the string shapes directly"
8634
+ reasonCode: "lexical-artifact",
8635
+ detail: "CSS/XPath string selectors in locator()/waitForSelector() calls and raw page.$/page.$$ handles detected via regex on the raw text view"
8348
8636
  },
8349
8637
  detectionNotes: "string-selector shapes (css=/xpath= engines, bare id/class/attr CSS, nth-child) inside .locator()/waitForSelector()/page.$ APIs, on the RAW text view (the selector text lives inside string literals, which the code-only view blanks)",
8350
8638
  introduced: "0.6.0",
@@ -8407,75 +8695,32 @@ const pwCodegenArtifact = defineRule({
8407
8695
  detectionStrategy: "LEXICAL",
8408
8696
  strategyJustification: {
8409
8697
  reasonCode: "lexical-artifact",
8410
- detail: "the codegen recorder's default title ('test', 'test 1', …) committed is a recording artifact — the default-title string is the finding; the detector matches the recorder's exact title shapes"
8698
+ detail: "codegen recorder's default test title ('test', 'test 1', …) committed verbatim — the regex matches the literal title string on the RAW text view"
8411
8699
  },
8412
8700
  detectionNotes: "the codegen recorder's default test title ('test', 'test 1', 'test 2', …) committed verbatim, on the RAW text view (the title is a string literal)",
8413
8701
  introduced: "0.6.0",
8414
8702
  tier: "quarantine",
8415
8703
  detectorRevision: 2,
8416
8704
  run(ctx) {
8417
- const sourceFile = getTsSourceFile(ctx.ast);
8418
- if (sourceFile) return astArm(ctx, sourceFile);
8419
- return regexArm(ctx);
8420
- }
8421
- });
8422
- /** The rev-1 raw-text detector — the parse-or-fallback path (§10.1). */
8423
- function regexArm(ctx) {
8424
- const text = ctx.text;
8425
- const findings = [];
8426
- const re = new RegExp(CODEGEN_DEFAULT_TITLE_RE$1.source, "g");
8427
- let m;
8428
- while ((m = re.exec(text)) !== null) findings.push({
8429
- severity: "info",
8430
- confidence: "medium",
8431
- findingType: "observation",
8432
- qaImpact: "HYGIENE",
8433
- file: ctx.path,
8434
- line: lineAt(text, m.index),
8435
- column: colAt(text, m.index),
8436
- message: `Codegen default test title (${m[0].replace(/\s+/g, " ").trim()}) — an unreviewed recording artifact.`,
8437
- why: WHY$7,
8438
- fix: FIX$6
8439
- });
8440
- return findings;
8441
- }
8442
- /**
8443
- * AST arm (detectorRevision 2 — P6 final attempt): only REAL test/it
8444
- * CallExpressions with a default-title StringLiteral fire. The
8445
- * adjudicated FP class — `test('test', …)` written as a synthetic code
8446
- * string inside unit tests of lint rules — is code-as-data: the parser
8447
- * sees it as a string VALUE, never as a CallExpression, so it cannot
8448
- * fire here.
8449
- */
8450
- function astArm(ctx, sourceFile) {
8451
- const findings = [];
8452
- const calls = sourceFile.getDescendantsOfKind(ts.SyntaxKind.CallExpression).filter((c) => {
8453
- const expr = c.getExpression();
8454
- if (expr.getKind() !== ts.SyntaxKind.Identifier) return false;
8455
- const name = expr.getText();
8456
- return name === "test" || name === "it";
8457
- });
8458
- for (const call of calls) {
8459
- const firstArg = call.getArguments()[0];
8460
- if (!firstArg || firstArg.getKind() !== ts.SyntaxKind.StringLiteral) continue;
8461
- const title = firstArg.getText().slice(1, -1);
8462
- if (!/^test(?:\s+\d+)?$/.test(title)) continue;
8463
- const pos = call.getStart();
8464
- findings.push({
8705
+ const text = ctx.text;
8706
+ const findings = [];
8707
+ const re = new RegExp(CODEGEN_DEFAULT_TITLE_RE$1.source, "g");
8708
+ let m;
8709
+ while ((m = re.exec(text)) !== null) findings.push({
8465
8710
  severity: "info",
8466
8711
  confidence: "medium",
8467
8712
  findingType: "observation",
8468
8713
  qaImpact: "HYGIENE",
8469
8714
  file: ctx.path,
8470
- line: sourceFile.getLineAndColumnAtPos(pos).line,
8471
- column: sourceFile.getLineAndColumnAtPos(pos).column,
8472
- message: `Codegen default test title (test declaration titled "${title}") — an unreviewed recording artifact.`,
8715
+ line: lineAt(text, m.index),
8716
+ column: colAt(text, m.index),
8717
+ message: `Codegen default test title (${m[0].replace(/\s+/g, " ").trim()}) — an unreviewed recording artifact.`,
8473
8718
  why: WHY$7,
8474
8719
  fix: FIX$6
8475
8720
  });
8721
+ return findings;
8476
8722
  }
8477
- return findings;
8478
- }
8723
+ });
8479
8724
  //#endregion
8480
8725
  //#region src/rules/python/qa-py-101-sync-async-mix.ts
8481
8726
  /**
@@ -8849,7 +9094,17 @@ function matchBrace$1(text, open) {
8849
9094
  else if (ch === inStr) inStr = null;
8850
9095
  continue;
8851
9096
  }
8852
- if (ch === "\"") inStr = ch;
9097
+ if (ch === "/" && text[i + 1] === "/") {
9098
+ const nl = text.indexOf("\n", i);
9099
+ i = nl === -1 ? text.length - 1 : nl;
9100
+ continue;
9101
+ }
9102
+ if (ch === "/" && text[i + 1] === "*") {
9103
+ const end = text.indexOf("*/", i + 2);
9104
+ i = end === -1 ? text.length - 1 : end + 1;
9105
+ continue;
9106
+ }
9107
+ if (ch === "\"" || ch === "'") inStr = ch;
8853
9108
  else if (ch === "{") depth++;
8854
9109
  else if (ch === "}") {
8855
9110
  depth--;
@@ -8870,7 +9125,11 @@ function extractQaModel(file) {
8870
9125
  if (file.path.endsWith(".java")) return extractJavaModel(file);
8871
9126
  if (file.path.endsWith(".cs")) return extractCSharpModel(file);
8872
9127
  if (file.path.endsWith(".py")) return extractPythonModel(file);
8873
- if (/\.[cm]?[jt]sx?$/.test(file.path)) return extractTsModel(file, parseTsFile(file));
9128
+ if (/\.[cm]?[jt]sx?$/.test(file.path)) {
9129
+ const sf = parseTsFile(file);
9130
+ if (!sf) return void 0;
9131
+ return extractTsModel(file, sf);
9132
+ }
8874
9133
  }
8875
9134
  function pos(text, index) {
8876
9135
  return {
@@ -8921,18 +9180,16 @@ function extractJavaModel(file) {
8921
9180
  truncated: test.body.hasError
8922
9181
  });
8923
9182
  for (const decl of tree.rootNode.descendantsOfType("method_declaration")) {
9183
+ if (!decl) continue;
8924
9184
  const modifiers = decl.childForFieldName("modifiers") ?? decl.children[0];
8925
9185
  if (!modifiers || modifiers.type !== "modifiers") continue;
8926
- const body = decl.childForFieldName("body");
8927
- if (!body) continue;
8928
9186
  const nameNode = decl.childForFieldName("name");
8929
- if (!nameNode) continue;
9187
+ const body = decl.childForFieldName("body");
9188
+ if (!nameNode || !body) continue;
8930
9189
  for (const annotation of javaAnnotationNames(modifiers)) {
8931
9190
  if (JAVA_HOOK_ANNOTATIONS.has(annotation)) {
8932
- let concept = "setup";
8933
- if (annotation.startsWith("After")) concept = "teardown";
8934
9191
  nodes.push({
8935
- concept,
9192
+ concept: annotation.startsWith("After") ? "teardown" : "setup",
8936
9193
  name: nameNode.text,
8937
9194
  start: pos(text, decl.startIndex),
8938
9195
  end: pos(text, body.endIndex),
@@ -8992,10 +9249,10 @@ function extractCSharpModel(file) {
8992
9249
  truncated: test.body.hasError
8993
9250
  });
8994
9251
  for (const decl of tree.rootNode.descendantsOfType("method_declaration")) {
8995
- const body = decl.childForFieldName("body");
8996
- if (!body) continue;
9252
+ if (!decl) continue;
8997
9253
  const nameNode = decl.childForFieldName("name");
8998
- if (!nameNode) continue;
9254
+ const body = decl.childForFieldName("body");
9255
+ if (!nameNode || !body) continue;
8999
9256
  let attr;
9000
9257
  for (const child of decl.children) {
9001
9258
  if (child?.type !== "attribute_list") continue;
@@ -9014,12 +9271,8 @@ function extractCSharpModel(file) {
9014
9271
  if (attr) break;
9015
9272
  }
9016
9273
  if (!attr) continue;
9017
- let concept;
9018
- if (attr.includes("TearDown") || attr.includes("Cleanup")) concept = "teardown";
9019
- else if (CS_RETRY_ATTRIBUTES.has(attr)) concept = "retry";
9020
- else concept = "setup";
9021
9274
  nodes.push({
9022
- concept,
9275
+ concept: attr.includes("TearDown") || attr.includes("Cleanup") ? "teardown" : CS_RETRY_ATTRIBUTES.has(attr) ? "retry" : "setup",
9023
9276
  name: nameNode.text,
9024
9277
  start: pos(text, decl.startIndex),
9025
9278
  end: pos(text, body.endIndex),
@@ -9028,12 +9281,12 @@ function extractCSharpModel(file) {
9028
9281
  }
9029
9282
  nodes.push(...javaCSharpCallNodes(tree, text, CS_CALLEE_CONCEPTS));
9030
9283
  for (const throwStmt of tree.rootNode.descendantsOfType("throw_statement")) {
9284
+ if (!throwStmt) continue;
9031
9285
  const creation = throwStmt.namedChildren.find((c) => c?.type === "object_creation_expression");
9032
9286
  if (!creation) continue;
9033
- const typeNode = creation.childForFieldName("type");
9034
- if (!typeNode) continue;
9035
- const typeName = typeNode.text;
9036
- if (/assert/i.test(typeName) && /exception/i.test(typeName)) nodes.push({
9287
+ const typeName = creation.childForFieldName("type")?.text ?? "";
9288
+ if (!/assert/i.test(typeName) || !/exception/i.test(typeName)) continue;
9289
+ nodes.push({
9037
9290
  concept: "assertion",
9038
9291
  name: typeName,
9039
9292
  callee: typeName,
@@ -9104,7 +9357,6 @@ const JAVA_CALLEE_CONCEPTS = {
9104
9357
  */
9105
9358
  const CS_CALLEE_CONCEPTS = {
9106
9359
  exact: {
9107
- expect: "assertion",
9108
9360
  Expect: "assertion",
9109
9361
  GotoAsync: "navigation",
9110
9362
  GoToAsync: "navigation",
@@ -9180,17 +9432,16 @@ function javaCSharpCallNodes(tree, text, table) {
9180
9432
  }
9181
9433
  }
9182
9434
  if (concept === void 0) continue;
9183
- const node = {
9435
+ nodes.push({
9184
9436
  concept,
9185
9437
  name: callee,
9186
9438
  callee,
9439
+ ...receiver !== void 0 ? { receiver } : {},
9187
9440
  start: pos(text, call.startIndex),
9188
9441
  end: pos(text, call.endIndex),
9189
9442
  text: call.text,
9190
9443
  ancestors: ancestorCallNames(call)
9191
- };
9192
- if (receiver !== void 0) node.receiver = receiver;
9193
- nodes.push(node);
9444
+ });
9194
9445
  }
9195
9446
  return nodes;
9196
9447
  }
@@ -9280,8 +9531,7 @@ function extractTsModel(file, sf) {
9280
9531
  const nodes = [];
9281
9532
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
9282
9533
  const chain = calleeChainText(call.getExpression());
9283
- const parts = chain.split(".");
9284
- const base = parts[parts.length - 1];
9534
+ const base = chain.split(".").pop() ?? chain;
9285
9535
  const start = pos(text, call.getStart());
9286
9536
  const end = pos(text, call.getEnd());
9287
9537
  if (TS_TEST_CALLEE_RE.test(chain)) {
@@ -9299,13 +9549,12 @@ function extractTsModel(file, sf) {
9299
9549
  const hook = TS_HOOK_CONCEPTS[base];
9300
9550
  if (hook && /(?:^|\.)(?:beforeEach|beforeAll|afterEach|afterAll)$/.test(chain)) {
9301
9551
  const callback = call.getArguments().find((a) => a.getKindName().includes("Function"));
9302
- const node = {
9552
+ nodes.push({
9303
9553
  concept: hook,
9304
9554
  name: chain,
9305
- start
9306
- };
9307
- if (callback) node.end = pos(text, callback.getEnd());
9308
- nodes.push(node);
9555
+ start,
9556
+ ...callback ? { end: pos(text, callback.getEnd()) } : {}
9557
+ });
9309
9558
  continue;
9310
9559
  }
9311
9560
  if (/^describe(?:\.\w+)*\.serial$/.test(chain) || /\.describe\.serial/.test(chain)) {
@@ -9318,9 +9567,8 @@ function extractTsModel(file, sf) {
9318
9567
  continue;
9319
9568
  }
9320
9569
  const receiverParts = chain.split(".");
9321
- const name = receiverParts[receiverParts.length - 1];
9322
- let receiver;
9323
- if (receiverParts.length > 1) receiver = receiverParts.slice(0, -1).join(".");
9570
+ const name = receiverParts.at(-1) ?? chain;
9571
+ const receiver = receiverParts.length > 1 ? receiverParts.slice(0, -1).join(".") : void 0;
9324
9572
  let concept = TS_CALLEE_CONCEPTS.exact[name];
9325
9573
  if (concept === void 0) {
9326
9574
  for (const p of TS_CALLEE_CONCEPTS.prefixes) if (p.test(name)) {
@@ -9328,23 +9576,21 @@ function extractTsModel(file, sf) {
9328
9576
  break;
9329
9577
  }
9330
9578
  }
9579
+ if (concept === void 0 && receiver !== void 0 && /^(?:jest|vi)$/.test(receiver) && (name === "mock" || name === "fn" || name === "spyOn" || name === "retryTimes")) concept = name === "retryTimes" ? "retry" : "mock";
9331
9580
  if (concept === void 0) continue;
9332
- const node = {
9581
+ nodes.push({
9333
9582
  concept,
9334
9583
  name,
9335
9584
  callee: name,
9585
+ ...receiver !== void 0 ? { receiver } : {},
9336
9586
  start,
9337
9587
  end,
9338
9588
  text: call.getText(),
9339
9589
  awaited: isAwaitedTsCall(call)
9340
- };
9341
- if (receiver !== void 0) node.receiver = receiver;
9342
- nodes.push(node);
9590
+ });
9343
9591
  }
9344
- let language = "javascript";
9345
- if (/\.[cm]?ts$/.test(file.path) || /\.tsx$/.test(file.path)) language = "typescript";
9346
9592
  return {
9347
- language,
9593
+ language: /(?:^|\.)tsx?$/.test(file.path) ? "typescript" : "javascript",
9348
9594
  nodes
9349
9595
  };
9350
9596
  }
@@ -9379,8 +9625,9 @@ function extractPythonModel(file) {
9379
9625
  const text = file.text;
9380
9626
  const nodes = [];
9381
9627
  const push = (concept, m, name) => {
9382
- const node = {
9628
+ nodes.push({
9383
9629
  concept,
9630
+ ...name !== void 0 ? { name } : {},
9384
9631
  start: {
9385
9632
  index: m.index,
9386
9633
  line: lineAt(text, m.index),
@@ -9392,9 +9639,7 @@ function extractPythonModel(file) {
9392
9639
  column: colAt(text, m.index + m[0].length)
9393
9640
  },
9394
9641
  text: m[0]
9395
- };
9396
- if (name !== void 0) node.name = name;
9397
- nodes.push(node);
9642
+ });
9398
9643
  };
9399
9644
  for (const re of [
9400
9645
  PY_TEST_RE,
@@ -9405,13 +9650,7 @@ function extractPythonModel(file) {
9405
9650
  ]) {
9406
9651
  re.lastIndex = 0;
9407
9652
  let m;
9408
- while ((m = re.exec(text)) !== null) {
9409
- let concept = "assertion";
9410
- if (re === PY_TEST_RE) concept = "test";
9411
- else if (re === PY_FIXTURE_RE) concept = "fixture";
9412
- else if (re === PY_WAIT_RE) concept = "wait";
9413
- push(concept, m, re === PY_TEST_RE ? m[2] : void 0);
9414
- }
9653
+ while ((m = re.exec(text)) !== null) push(re === PY_TEST_RE ? "test" : re === PY_FIXTURE_RE ? "fixture" : re === PY_WAIT_RE ? "wait" : "assertion", m, re === PY_TEST_RE ? m[2] : void 0);
9415
9654
  }
9416
9655
  return {
9417
9656
  language: "python",
@@ -9749,7 +9988,17 @@ function matchBrace(text, open) {
9749
9988
  else if (ch === inStr) inStr = null;
9750
9989
  continue;
9751
9990
  }
9752
- if (ch === "\"") inStr = ch;
9991
+ if (ch === "/" && text[i + 1] === "/") {
9992
+ const nl = text.indexOf("\n", i);
9993
+ i = nl === -1 ? text.length - 1 : nl;
9994
+ continue;
9995
+ }
9996
+ if (ch === "/" && text[i + 1] === "*") {
9997
+ const end = text.indexOf("*/", i + 2);
9998
+ i = end === -1 ? text.length - 1 : end + 1;
9999
+ continue;
10000
+ }
10001
+ if (ch === "\"" || ch === "'") inStr = ch;
9753
10002
  else if (ch === "{") depth++;
9754
10003
  else if (ch === "}") {
9755
10004
  depth--;
@@ -9887,8 +10136,8 @@ const cypCyWait = defineRule({
9887
10136
  autofix: false,
9888
10137
  detectionStrategy: "LEXICAL",
9889
10138
  strategyJustification: {
9890
- reasonCode: "runner-semantic",
9891
- detail: "cy.wait(numeric) is a Cypress runner wait contract; the detector matches the member-call token with a numeric-literal argument on the code-only text — alias waits (cy.wait('@…')) are structurally distinct and excluded by the argument shape"
10139
+ reasonCode: "lexical-artifact",
10140
+ detail: "cy.wait with a numeric-literal argument only — alias waits (cy.wait('@…')) are the legitimate routed-request form and never fire"
9892
10141
  },
9893
10142
  detectionNotes: "cy.wait with a numeric-literal argument only — alias waits (cy.wait('@…')) are the legitimate routed-request form and never fire",
9894
10143
  introduced: "0.6.0",
@@ -9896,7 +10145,7 @@ const cypCyWait = defineRule({
9896
10145
  detectorRevision: 1,
9897
10146
  run(ctx) {
9898
10147
  if (!isCypressFile(ctx)) return [];
9899
- const text = ctx.codeText !== void 0 ? ctx.codeText : ctx.text;
10148
+ const text = ctx.codeText ?? ctx.text;
9900
10149
  const findings = [];
9901
10150
  const re = new RegExp(CY_WAIT_NUMBER_RE.source, "g");
9902
10151
  let m;
@@ -9963,8 +10212,8 @@ const cypFocusedTest = defineRule({
9963
10212
  autofix: false,
9964
10213
  detectionStrategy: "LEXICAL",
9965
10214
  strategyJustification: {
9966
- reasonCode: "runner-semantic",
9967
- detail: "Cypress .only is the runner's focus token; the detector matches the it/describe/context .only member-call shape on the code-only text — exact-key precision"
10215
+ reasonCode: "lexical-artifact",
10216
+ detail: "Cypress .only() is a lexical modifier — the regex matches the literal `.only(` call target in source text"
9968
10217
  },
9969
10218
  detectionNotes: "it/describe/context .only member-call shape on the code-only text view",
9970
10219
  introduced: "0.6.0",
@@ -9972,7 +10221,7 @@ const cypFocusedTest = defineRule({
9972
10221
  detectorRevision: 1,
9973
10222
  run(ctx) {
9974
10223
  if (!isCypressFile(ctx)) return [];
9975
- const text = ctx.codeText !== void 0 ? ctx.codeText : ctx.text;
10224
+ const text = ctx.codeText ?? ctx.text;
9976
10225
  const findings = [];
9977
10226
  const re = new RegExp(FOCUS_RE.source, "g");
9978
10227
  let m;
@@ -10033,15 +10282,15 @@ const cypConfigSecurity = defineRule({
10033
10282
  autofix: false,
10034
10283
  detectionStrategy: "LEXICAL",
10035
10284
  strategyJustification: {
10036
- reasonCode: "exact-key-match",
10037
- detail: "chromeWebSecurity:false is an exact config key/value pair inside cypress.config.*; the detector matches the key and value literally — the config surface's statements are the finding"
10285
+ reasonCode: "string-content-defect",
10286
+ detail: "chromeWebSecurity:false in cypress.config is a string-content match in the config file — the regex detects the boolean literal"
10038
10287
  },
10039
10288
  detectionNotes: "positive match on chromeWebSecurity:false inside cypress.config.* (the config is the artifact — no heuristic)",
10040
10289
  introduced: "0.6.0",
10041
10290
  tier: "quarantine",
10042
10291
  detectorRevision: 1,
10043
10292
  run(ctx) {
10044
- const base = ctx.path.replace(/\\/g, "/").split("/").pop();
10293
+ const base = ctx.path.replace(/\\/g, "/").split("/").pop() ?? "";
10045
10294
  if (!CYPRESS_CONFIG_RE.test(base)) return [];
10046
10295
  const text = ctx.text;
10047
10296
  const findings = [];
@@ -10128,7 +10377,7 @@ function sleepBeforeLookup(ctx, sleepRe, lookupRe, id) {
10128
10377
  return findings;
10129
10378
  }
10130
10379
  const JAVA_SLEEP_RE = /\bThread\.sleep\s*\([^)]*\)/;
10131
- const JAVA_LOOKUP_RE = /\bdriver\.findElement|\.\s*sendKeys\s*\(|\.\s*click\s*\(|\.\s*clear\s*\(|findElement\s*\(/;
10380
+ const JAVA_LOOKUP_RE = /\bdriver\.findElement|\bdriver\.findElements|\.\s*sendKeys\s*\(|\.\s*click\s*\(|\.\s*clear\s*\(|findElement\s*\(/;
10132
10381
  const seJavaSleepLookup = defineRule({
10133
10382
  id: "QA-SE-001",
10134
10383
  category: "QA-PW",
@@ -10144,8 +10393,8 @@ const seJavaSleepLookup = defineRule({
10144
10393
  autofix: false,
10145
10394
  detectionStrategy: "LEXICAL",
10146
10395
  strategyJustification: {
10147
- reasonCode: "runner-semantic",
10148
- detail: "the Selenium family's variants (QA-SE-001/002/003): the defect is the SEQUENCE sleep-then-interact — runner timing semantics, not a single node; the detector matches the sleep token followed by a lookup within the recorded window"
10396
+ reasonCode: "lexical-artifact",
10397
+ detail: "sleep-then-lookup sequence: Thread.sleep/Thread.Sleep/time.sleep regex match followed by findElement/FindElement/find_element within 3 lines on code-only view"
10149
10398
  },
10150
10399
  detectionNotes: "sequence shape: Thread.sleep followed by a findElement/interaction call within 3 lines (code-only view)",
10151
10400
  introduced: "0.6.0",
@@ -10157,7 +10406,7 @@ const seJavaSleepLookup = defineRule({
10157
10406
  }
10158
10407
  });
10159
10408
  const CS_SLEEP_RE = /\b(?:Thread\.Sleep|Task\.Delay)\s*\([^)]*\)/;
10160
- const CS_LOOKUP_RE = /\bdriver\.FindElement|\.\s*SendKeys\s*\(|\.\s*Click\s*\(|\.\s*Clear\s*\(|FindElement\s*\(/;
10409
+ const CS_LOOKUP_RE = /\bdriver\.FindElement|\bdriver\.FindElements|\.\s*SendKeys\s*\(|\.\s*Click\s*\(|\.\s*Clear\s*\(|FindElement\s*\(/;
10161
10410
  const seCSharpSleepLookup = defineRule({
10162
10411
  id: "QA-SE-002",
10163
10412
  category: "QA-PW",
@@ -10173,8 +10422,8 @@ const seCSharpSleepLookup = defineRule({
10173
10422
  autofix: false,
10174
10423
  detectionStrategy: "LEXICAL",
10175
10424
  strategyJustification: {
10176
- reasonCode: "runner-semantic",
10177
- detail: "the Selenium family's variants (QA-SE-001/002/003): the defect is the SEQUENCE sleep-then-interact — runner timing semantics, not a single node; the detector matches the sleep token followed by a lookup within the recorded window"
10425
+ reasonCode: "lexical-artifact",
10426
+ detail: "sleep-then-lookup sequence: Thread.sleep/Thread.Sleep/time.sleep regex match followed by findElement/FindElement/find_element within 3 lines on code-only view"
10178
10427
  },
10179
10428
  detectionNotes: "sequence shape: Thread.Sleep/Task.Delay followed by a FindElement/interaction call within 3 lines (code-only view)",
10180
10429
  introduced: "0.6.0",
@@ -10186,7 +10435,7 @@ const seCSharpSleepLookup = defineRule({
10186
10435
  }
10187
10436
  });
10188
10437
  const PY_SLEEP_RE = /\btime\.sleep\s*\([^)]*\)/;
10189
- const PY_LOOKUP_RE = /\bdriver\.find_element|\.find_element\s*\(|\.find_elements\s*\(|\.send_keys\s*\(|\.click\s*\(|\.clear\s*\(/;
10438
+ const PY_LOOKUP_RE = /\bdriver\.find_element|\bdriver\.find_elements|\.find_element\s*\(|\.find_elements\s*\(|\.send_keys\s*\(|\.click\s*\(|\.clear\s*\(/;
10190
10439
  const sePythonSleepLookup = defineRule({
10191
10440
  id: "QA-SE-003",
10192
10441
  category: "QA-PW",
@@ -10202,8 +10451,8 @@ const sePythonSleepLookup = defineRule({
10202
10451
  autofix: false,
10203
10452
  detectionStrategy: "LEXICAL",
10204
10453
  strategyJustification: {
10205
- reasonCode: "runner-semantic",
10206
- detail: "the Selenium family's variants (QA-SE-001/002/003): the defect is the SEQUENCE sleep-then-interact — runner timing semantics, not a single node; the detector matches the sleep token followed by a lookup within the recorded window"
10454
+ reasonCode: "lexical-artifact",
10455
+ detail: "sleep-then-lookup sequence: Thread.sleep/Thread.Sleep/time.sleep regex match followed by findElement/FindElement/find_element within 3 lines on code-only view"
10207
10456
  },
10208
10457
  detectionNotes: "sequence shape: time.sleep followed by a find_element/interaction call within 3 lines (code-only view)",
10209
10458
  introduced: "0.6.0",
@@ -10917,6 +11166,10 @@ const RULES = [
10917
11166
  seCSharpSleepLookup,
10918
11167
  sePythonSleepLookup
10919
11168
  ];
11169
+ /**
11170
+ * Look up a rule by its ID in the active registry.
11171
+ * Returns undefined if the ID is not found (including retired IDs).
11172
+ */
10920
11173
  function getRule(id) {
10921
11174
  return RULES.find((r) => r.id === id);
10922
11175
  }
@@ -11149,6 +11402,58 @@ function enforceTierPolicy(findings, tierByRuleId) {
11149
11402
  }
11150
11403
  }
11151
11404
  //#endregion
11405
+ //#region src/engine/evidence-core.ts
11406
+ /**
11407
+ * The core's own version — stamped into every record's provenance so a
11408
+ * consumer can tell which normalization contract produced a record.
11409
+ * Bump ONLY on a semantic change to the normalization itself.
11410
+ */
11411
+ const EVIDENCE_CORE_VERSION = "evidence-core@1";
11412
+ /** Deterministic canonical order: file → line (absent last) → title → source. */
11413
+ function compareEvidenceRecords(a, b) {
11414
+ if (a.file !== b.file) return a.file < b.file ? -1 : 1;
11415
+ const la = a.line ?? Number.POSITIVE_INFINITY;
11416
+ const lb = b.line ?? Number.POSITIVE_INFINITY;
11417
+ if (la !== lb) return la - lb;
11418
+ if (a.title !== b.title) return a.title < b.title ? -1 : 1;
11419
+ if (a.source !== b.source) return a.source < b.source ? -1 : 1;
11420
+ return 0;
11421
+ }
11422
+ function normalizeOne(report, artifact, v) {
11423
+ const last = v.attempts > 0 ? v.finalStatus : "skipped";
11424
+ return {
11425
+ source: report.source,
11426
+ artifact,
11427
+ file: v.file,
11428
+ title: v.title,
11429
+ ...v.line !== void 0 ? { line: v.line } : {},
11430
+ status: {
11431
+ final: last,
11432
+ failed: v.everFailed,
11433
+ retried: v.attempts > 1,
11434
+ passedOnRetry: v.passedOnRetry,
11435
+ skipped: v.skipped,
11436
+ timedOut: v.finalStatus === "timedOut"
11437
+ },
11438
+ attempts: v.attempts,
11439
+ durationMs: v.totalDurationMs,
11440
+ errors: [],
11441
+ attachments: [],
11442
+ provenance: {
11443
+ core: EVIDENCE_CORE_VERSION,
11444
+ ingest: "mjolnir.forensics"
11445
+ }
11446
+ };
11447
+ }
11448
+ /**
11449
+ * Normalize a forensics report into deterministically-ordered evidence
11450
+ * records. The same report + artifact identity ALWAYS produces the same
11451
+ * record list — report verdict order never leaks into the output.
11452
+ */
11453
+ function buildEvidenceRecords(report, artifact) {
11454
+ return report.verdicts.map((v) => normalizeOne(report, artifact, v)).sort(compareEvidenceRecords);
11455
+ }
11456
+ //#endregion
11152
11457
  //#region src/engine/provenance.ts
11153
11458
  const GENERATED_HEADER_RE = /^\s*(?:\/\/|#|\/\*)\s*(?:auto[- ]?generated|generated by|do not edit)/i;
11154
11459
  /**
@@ -11250,6 +11555,7 @@ function isDeclaredByPresent(f, present, metaByRuleId) {
11250
11555
  for (const g of present) {
11251
11556
  if (g === f || g.ruleId === f.ruleId) continue;
11252
11557
  if (!metaByRuleId.get(g.ruleId)?.overlapWith?.includes(f.ruleId)) continue;
11558
+ if (f.column === void 0 || g.column === void 0) continue;
11253
11559
  if (Math.abs(g.column - f.column) > MAX_SAME_ROOT_COLUMN_DELTA) continue;
11254
11560
  return true;
11255
11561
  }
@@ -11283,69 +11589,486 @@ function ranksBefore(a, b, metaByRuleId) {
11283
11589
  function dropOrderBefore(a, b, metaByRuleId) {
11284
11590
  if (ranksBefore(a, b, metaByRuleId)) return false;
11285
11591
  if (ranksBefore(b, a, metaByRuleId)) return true;
11286
- return a.column < b.column;
11592
+ return (a.column ?? Number.MAX_SAFE_INTEGER) < (b.column ?? Number.MAX_SAFE_INTEGER);
11287
11593
  }
11288
11594
  //#endregion
11289
- //#region src/engine/scan-cache.ts
11595
+ //#region src/engine/correlation-engine.ts
11596
+ const EVIDENCE_RANK = {
11597
+ E0: 0,
11598
+ E1: 1,
11599
+ E2: 2
11600
+ };
11601
+ function bestEvidence(findings) {
11602
+ let best = "E0";
11603
+ for (const f of findings) {
11604
+ const level = f.evidenceLevel ?? "E0";
11605
+ if (EVIDENCE_RANK[level] > EVIDENCE_RANK[best]) best = level;
11606
+ }
11607
+ return best;
11608
+ }
11609
+ function hasRuntimeCorroboration(findings) {
11610
+ return findings.some((f) => f.runtimeCorroboration !== void 0);
11611
+ }
11290
11612
  /**
11291
- * Local incremental scan cache (Beta-to-Stable 1.0 plan, M5.2 / A-2).
11292
- *
11293
- * Content-addressed, local-only verdict cache: `--cache` reuses the
11294
- * per-file rule outputs of a previous scan when the file's bytes AND the
11295
- * active rule set are unchanged, and invalidates everything else. The
11296
- * key is `sha256(fileText) + rulesDigest` + the file's own identity
11297
- * (repo-relative path + adapter id + parse mode — audit C1/W9), where
11298
- * the rules digest folds
11299
- * in every active rule's id + `detectorRevision ?? 1` (the existing
11300
- * stale-measurement machinery — Verification Trust Evolution Plan §07 —
11301
- * reused as the cache invalidation signal, per A-2) plus a source hash
11302
- * of each external plugin/local rule's `run` function, so a plugin that
11303
- * changes code without bumping its revision still misses.
11304
- *
11305
- * Privacy posture: the cache lives under `<repo>/.mjolnir/cache/`, is
11306
- * gitignored, never leaves the machine, and this module performs zero
11307
- * network I/O — fs and crypto only (asserted by the privacy spec).
11613
+ * Determine the correlation strength for a group of findings.
11308
11614
  *
11309
- * Only raw rule-loop outputs are cached. Everything after the loop
11310
- * (severity overrides, suppressions, overlap dedup, evidence stamping,
11311
- * tier policy, runtime corroboration, scoring) re-runs on every scan,
11312
- * so a cached scan is byte-equivalent to a fresh one by construction.
11615
+ * TI-009: E1+E1+E1 ≠ E2. Multiple weak findings SUPPORT but never
11616
+ * STRONG. Only E2 or runtime corroboration yields STRONG.
11313
11617
  */
11314
- const CACHE_VERSION = 2;
11315
- /** Entry cap: a monorepo-scale suite stays far below this; bounded file. */
11316
- const MAX_ENTRIES = 4096;
11618
+ function correlationStrength(findings) {
11619
+ const best = bestEvidence(findings);
11620
+ if (hasRuntimeCorroboration(findings)) return "STRONG";
11621
+ if (best === "E2") return "STRONG";
11622
+ if (best === "E1" && findings.length > 1) return "SUPPORTING";
11623
+ if (best === "E1") return "SUPPORTING";
11624
+ return "NONE";
11625
+ }
11317
11626
  /**
11318
- * Audit M5: total byte budget. The old cap counted only ENTRIES, so
11319
- * 4096 files × ~100KB of findings each could still produce a
11320
- * multi-hundred-MB writeFileSync on persist. The budget bounds the
11321
- * serialized size; the newest entries win (real LRU-by-use).
11627
+ * Group findings by file to detect file-level convergence.
11322
11628
  */
11323
- const MAX_TOTAL_BYTES = 33554432;
11324
- /** sha256 hex of a string — the only hash this module needs. */
11325
- function sha256(text) {
11326
- return createHash("sha256").update(text).digest("hex");
11629
+ function groupByFile(findings) {
11630
+ const groups = /* @__PURE__ */ new Map();
11631
+ for (const f of findings) {
11632
+ const list = groups.get(f.file) ?? [];
11633
+ list.push(f);
11634
+ groups.set(f.file, list);
11635
+ }
11636
+ return groups;
11327
11637
  }
11328
11638
  /**
11329
- * Digest of the active rule set: id + effective detectorRevision for
11330
- * every rule (sorted for determinism), plus a source hash per rule's
11331
- * `run` function. Any detector change — revision bump OR code edit —
11332
- * produces a different digest, invalidating every cached entry. Takes a
11333
- * structural subset on purpose: only these three fields feed the key.
11639
+ * Group findings by rootCauseId (or ruleId when rootCauseId is absent)
11640
+ * to detect convergent findings pointing at the same root cause.
11334
11641
  */
11335
- function computeRulesDigest(rules) {
11336
- return sha256(rules.map((r) => `${r.id}:${r.detectorRevision ?? 1}:${sha256(String(r.run)).slice(0, 12)}`).sort().join("|"));
11642
+ function groupByRootCause(findings) {
11643
+ const groups = /* @__PURE__ */ new Map();
11644
+ for (const f of findings) {
11645
+ const key = f.rootCauseId ?? f.ruleId;
11646
+ const list = groups.get(key) ?? [];
11647
+ list.push(f);
11648
+ groups.set(key, list);
11649
+ }
11650
+ return groups;
11337
11651
  }
11338
11652
  /**
11339
- * Content-addressed key for one file's rule-loop verdicts.
11340
- *
11341
- * Audit C1: the key MUST identify the verdict's producer, not just the
11342
- * bytes — two files with byte-identical text (a copied spec, a generated
11343
- * snapshot) previously shared one entry, and the first file's cached
11344
- * findings were re-emitted for the second with the wrong `file` stamp.
11345
- * The key therefore folds in the repo-relative path AND the adapter id,
11346
- * plus a parse-mode token (audit W9): a file whose analysis degraded to
11347
- * the regex fallback (or skipped the AST path) must not collide with a
11348
- * fully-AST-analyzed verdict for the same bytes — the fallback output
11653
+ * Correlate findings and produce conclusions WITHOUT modifying them.
11654
+ * Deterministic — same input always produces same output.
11655
+ */
11656
+ function correlateFindings(findings) {
11657
+ const conclusions = [];
11658
+ const rootCauseGroups = groupByRootCause(findings);
11659
+ for (const [rootCause, group] of Array.from(rootCauseGroups.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
11660
+ if (group.length < 2) continue;
11661
+ const findingIds = group.map((f) => f.findingId ?? f.ruleId);
11662
+ conclusions.push({
11663
+ conclusionType: "CONVERGENT",
11664
+ certainty: correlationStrength(group),
11665
+ corroboration: `${group.length} findings share root cause ${rootCause}`,
11666
+ sourceCount: group.length,
11667
+ findingIds
11668
+ });
11669
+ }
11670
+ const fileGroups = groupByFile(findings);
11671
+ for (const [file, group] of Array.from(fileGroups.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
11672
+ if (group.length < 2) continue;
11673
+ const alreadyConvergent = /* @__PURE__ */ new Set();
11674
+ for (const c of conclusions) if (c.conclusionType === "CONVERGENT") for (const id of c.findingIds) alreadyConvergent.add(id);
11675
+ const novel = group.filter((f) => !alreadyConvergent.has(f.findingId ?? f.ruleId));
11676
+ if (novel.length < 2) continue;
11677
+ const findingIds = novel.map((f) => f.findingId ?? f.ruleId);
11678
+ conclusions.push({
11679
+ conclusionType: "AMPLIFIED",
11680
+ certainty: correlationStrength(novel),
11681
+ corroboration: `${novel.length} findings in ${file}`,
11682
+ sourceCount: novel.length,
11683
+ findingIds
11684
+ });
11685
+ }
11686
+ const corroborated = findings.filter((f) => f.runtimeCorroboration !== void 0);
11687
+ if (corroborated.length > 0) conclusions.push({
11688
+ conclusionType: "CORROBORATED",
11689
+ certainty: "STRONG",
11690
+ corroboration: `${corroborated.length} finding(s) with runtime corroboration`,
11691
+ sourceCount: corroborated.length,
11692
+ findingIds: corroborated.map((f) => f.findingId ?? f.ruleId)
11693
+ });
11694
+ return conclusions;
11695
+ }
11696
+ //#endregion
11697
+ //#region src/engine/dependency-graph.ts
11698
+ /**
11699
+ * Dependency Graph (ECO-005).
11700
+ *
11701
+ * Parses package manifests (package.json, pyproject.toml, pom.xml) to
11702
+ * build a dependency graph. Supports transitive dependency resolution,
11703
+ * dependent lookup, and reachable-file computation from test files.
11704
+ */
11705
+ var DependencyGraph = class {
11706
+ nodes = /* @__PURE__ */ new Map();
11707
+ addNode(node) {
11708
+ this.nodes.set(node.path, {
11709
+ path: node.path,
11710
+ dependencies: [...node.dependencies]
11711
+ });
11712
+ }
11713
+ getDependencies(path) {
11714
+ return this.nodes.get(path)?.dependencies ?? [];
11715
+ }
11716
+ getTransitiveDependencies(path) {
11717
+ const visited = /* @__PURE__ */ new Set();
11718
+ const stack = [path];
11719
+ while (stack.length > 0) {
11720
+ const current = stack.pop();
11721
+ if (current === void 0) continue;
11722
+ if (visited.has(current)) continue;
11723
+ visited.add(current);
11724
+ const node = this.nodes.get(current);
11725
+ if (node) {
11726
+ for (const dep of node.dependencies) if (!visited.has(dep)) stack.push(dep);
11727
+ }
11728
+ }
11729
+ visited.delete(path);
11730
+ return [...visited].sort();
11731
+ }
11732
+ getDependents(path) {
11733
+ const dependents = [];
11734
+ for (const [key, node] of this.nodes) if (node.dependencies.includes(path)) dependents.push(key);
11735
+ return dependents.sort();
11736
+ }
11737
+ get allPaths() {
11738
+ return [...this.nodes.keys()].sort();
11739
+ }
11740
+ get size() {
11741
+ return this.nodes.size;
11742
+ }
11743
+ };
11744
+ function parsePackageJson(filePath) {
11745
+ if (!existsSync(filePath)) return void 0;
11746
+ try {
11747
+ const raw = readFileSync(filePath, "utf8");
11748
+ const pkg = JSON.parse(raw);
11749
+ return {
11750
+ path: filePath,
11751
+ dependencies: [...Object.keys(pkg["dependencies"] ?? {}), ...Object.keys(pkg["devDependencies"] ?? {})]
11752
+ };
11753
+ } catch {
11754
+ return;
11755
+ }
11756
+ }
11757
+ function parsePyprojectToml(filePath) {
11758
+ if (!existsSync(filePath)) return void 0;
11759
+ try {
11760
+ const raw = readFileSync(filePath, "utf8");
11761
+ const deps = [];
11762
+ const depSection = raw.match(/\[project\][\s\S]*?dependencies\s*=\s*\[([\s\S]*?)\]/);
11763
+ if (depSection?.[1]) {
11764
+ const lines = depSection[1].split("\n");
11765
+ for (const line of lines) {
11766
+ const match = line.match(/^\s*"([\w-]+)/);
11767
+ if (match?.[1]) deps.push(match[1]);
11768
+ }
11769
+ }
11770
+ return {
11771
+ path: filePath,
11772
+ dependencies: deps
11773
+ };
11774
+ } catch {
11775
+ return;
11776
+ }
11777
+ }
11778
+ function parsePomXml(filePath) {
11779
+ if (!existsSync(filePath)) return void 0;
11780
+ try {
11781
+ const raw = readFileSync(filePath, "utf8");
11782
+ const deps = [];
11783
+ const re = /<artifactId>([^<]+)<\/artifactId>/g;
11784
+ let m;
11785
+ while ((m = re.exec(raw)) !== null) if (m[1]) deps.push(m[1]);
11786
+ return {
11787
+ path: filePath,
11788
+ dependencies: deps
11789
+ };
11790
+ } catch {
11791
+ return;
11792
+ }
11793
+ }
11794
+ /**
11795
+ * Build a dependency graph from project manifests. Scans for package.json,
11796
+ * pyproject.toml, and pom.xml at the project root and one level deep.
11797
+ */
11798
+ function buildDependencyGraph(projectRoot) {
11799
+ const graph = new DependencyGraph();
11800
+ const manifestPatterns = [
11801
+ "package.json",
11802
+ "pyproject.toml",
11803
+ "pom.xml",
11804
+ join("packages", "*", "package.json")
11805
+ ];
11806
+ for (const pattern of manifestPatterns) if (pattern.includes("*")) {
11807
+ const [base, , file] = pattern.split(/[\\/]/);
11808
+ if (base === void 0 || file === void 0) continue;
11809
+ const baseDir = resolve(projectRoot, base);
11810
+ if (!existsSync(baseDir)) continue;
11811
+ try {
11812
+ const entries = readdirSync(baseDir, { withFileTypes: true });
11813
+ for (const entry of entries) if (entry.isDirectory()) {
11814
+ const node = parseManifest(resolve(baseDir, entry.name, file));
11815
+ if (node) graph.addNode(node);
11816
+ }
11817
+ } catch {}
11818
+ } else {
11819
+ const node = parseManifest(resolve(projectRoot, pattern));
11820
+ if (node) graph.addNode(node);
11821
+ }
11822
+ return graph;
11823
+ }
11824
+ function parseManifest(filePath) {
11825
+ if (filePath.endsWith("package.json")) return parsePackageJson(filePath);
11826
+ if (filePath.endsWith("pyproject.toml")) return parsePyprojectToml(filePath);
11827
+ if (filePath.endsWith("pom.xml")) return parsePomXml(filePath);
11828
+ }
11829
+ //#endregion
11830
+ //#region src/engine/incremental-analysis.ts
11831
+ /**
11832
+ * Semantic inputs that affect the full scan beyond individual file content.
11833
+ * If any of these changed, an incremental run is NOT safe — a full scan
11834
+ * is required for equivalence (TI-005).
11835
+ */
11836
+ const SEMANTIC_INPUT_PATTERNS = [
11837
+ "mjolnir.config",
11838
+ ".mjolnirignore",
11839
+ "package.json",
11840
+ "tsconfig",
11841
+ "vitest.config",
11842
+ "jest.config",
11843
+ "playwright.config"
11844
+ ];
11845
+ /**
11846
+ * Verify whether an incremental scan is safe. Returns true when ONLY
11847
+ * regular source/test files changed and no semantic config inputs were
11848
+ * touched. When false, the caller must fall back to a full scan.
11849
+ *
11850
+ * TI-005 contract: incremental safety is a prerequisite for equivalence.
11851
+ * A safe incremental run re-scans only changed files and merges their
11852
+ * results with cached verdicts for unchanged files — the aggregate must
11853
+ * match a full scan byte-for-byte.
11854
+ */
11855
+ function isIncrementalSafe(changedFiles) {
11856
+ const reasons = [];
11857
+ for (const f of changedFiles) {
11858
+ const lower = f.path.toLowerCase().replace(/\\/g, "/");
11859
+ for (const pattern of SEMANTIC_INPUT_PATTERNS) if (lower.includes(pattern)) reasons.push(`semantic input changed: ${f.path} matches "${pattern}" pattern`);
11860
+ }
11861
+ return {
11862
+ safe: reasons.length === 0,
11863
+ reasons
11864
+ };
11865
+ }
11866
+ //#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
+ }
11879
+ /**
11880
+ * Aggregate per-package results into an overall verdict.
11881
+ *
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.
11886
+ */
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,
11941
+ overallScore: Math.round(avg),
11942
+ overallVerdict: verdictOf(Math.round(avg)),
11943
+ strategy: config.weightingStrategy
11944
+ };
11945
+ }
11946
+ function configurableAggregation(results, config) {
11947
+ const weights = config.packageWeights ?? {};
11948
+ let totalWeight = 0;
11949
+ let weightedSum = 0;
11950
+ for (const r of results) {
11951
+ const w = weights[r.packageName] ?? 1;
11952
+ totalWeight += w;
11953
+ weightedSum += (r.score ?? 0) * w;
11954
+ }
11955
+ if (totalWeight === 0) return {
11956
+ packages: results,
11957
+ overallScore: null,
11958
+ overallVerdict: "fail",
11959
+ strategy: config.weightingStrategy
11960
+ };
11961
+ const score = Math.round(weightedSum / totalWeight);
11962
+ return {
11963
+ packages: results,
11964
+ overallScore: score,
11965
+ overallVerdict: verdictOf(score),
11966
+ strategy: config.weightingStrategy
11967
+ };
11968
+ }
11969
+ //#endregion
11970
+ //#region src/engine/scan-cache.ts
11971
+ /**
11972
+ * Local incremental scan cache (Beta-to-Stable 1.0 plan, M5.2 / A-2).
11973
+ *
11974
+ * Content-addressed, local-only verdict cache: `--cache` reuses the
11975
+ * per-file rule outputs of a previous scan when the file's bytes AND the
11976
+ * active rule set are unchanged, and invalidates everything else. The
11977
+ * key is `sha256(fileText) + rulesDigest` + the file's own identity
11978
+ * (repo-relative path + adapter id + parse mode — audit C1/W9), where
11979
+ * the rules digest folds
11980
+ * in every active rule's id + `detectorRevision ?? 1` (the existing
11981
+ * stale-measurement machinery — Verification Trust Evolution Plan §07 —
11982
+ * reused as the cache invalidation signal, per A-2) plus a source hash
11983
+ * of each external plugin/local rule's `run` function, so a plugin that
11984
+ * changes code without bumping its revision still misses.
11985
+ *
11986
+ * Privacy posture: the cache lives under `<repo>/.mjolnir/cache/`, is
11987
+ * gitignored, never leaves the machine, and this module performs zero
11988
+ * network I/O — fs and crypto only (asserted by the privacy spec).
11989
+ *
11990
+ * Only raw rule-loop outputs are cached. Everything after the loop
11991
+ * (severity overrides, suppressions, overlap dedup, evidence stamping,
11992
+ * tier policy, runtime corroboration, scoring) re-runs on every scan,
11993
+ * so a cached scan is byte-equivalent to a fresh one by construction.
11994
+ */
11995
+ const CACHE_VERSION = 2;
11996
+ /** Entry cap: a monorepo-scale suite stays far below this; bounded file. */
11997
+ const MAX_ENTRIES$1 = 4096;
11998
+ /**
11999
+ * Audit M5: total byte budget. The old cap counted only ENTRIES, so
12000
+ * 4096 files × ~100KB of findings each could still produce a
12001
+ * multi-hundred-MB writeFileSync on persist. The budget bounds the
12002
+ * serialized size; the newest entries win (real LRU-by-use).
12003
+ */
12004
+ const MAX_TOTAL_BYTES = 33554432;
12005
+ /** sha256 hex of a string — the only hash this module needs. */
12006
+ function sha256(text) {
12007
+ return createHash("sha256").update(text).digest("hex");
12008
+ }
12009
+ /**
12010
+ * Digest of the active rule set: id + effective detectorRevision for
12011
+ * every rule (sorted for determinism), plus a content hash of all rule
12012
+ * source files. The directory-tree hash catches changes to ANY file
12013
+ * under src/rules/ — same-file helpers, cross-module imports, shared
12014
+ * utilities — without needing to trace the import graph. This is the
12015
+ * only reliable way to detect helper function changes in a bundled
12016
+ * ESM codebase where String(fn) doesn't reveal transitive dependencies.
12017
+ */
12018
+ function computeRulesDigest(rules) {
12019
+ const parts = rules.map((r) => `${r.id}:${r.detectorRevision ?? 1}`).sort();
12020
+ const rulesDirHash = hashRulesSourceTree(rules);
12021
+ return sha256(parts.join("|") + "\0" + rulesDirHash);
12022
+ }
12023
+ /**
12024
+ * Hash all .ts files under src/rules/ (when the first rule's modulePath
12025
+ * reveals the project root). Falls back to per-rule function source
12026
+ * hashing when the directory cannot be resolved.
12027
+ */
12028
+ function hashRulesSourceTree(rules) {
12029
+ const firstPath = rules.find((r) => r.modulePath)?.modulePath;
12030
+ if (!firstPath) return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
12031
+ const idx = firstPath.replace(/\\/g, "/").indexOf("/src/rules/");
12032
+ if (idx < 0) return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
12033
+ const rulesDir = firstPath.replace(/\\/g, "/").slice(0, idx + 11);
12034
+ try {
12035
+ const hash = createHash("sha256");
12036
+ hashDir(rulesDir, hash, 0);
12037
+ return hash.digest("hex").slice(0, 16);
12038
+ } catch {
12039
+ return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
12040
+ }
12041
+ }
12042
+ function hashDir(dir, hash, depth) {
12043
+ if (depth > 8) return;
12044
+ let entries;
12045
+ try {
12046
+ entries = readdirSync(dir, { withFileTypes: true });
12047
+ } catch {
12048
+ return;
12049
+ }
12050
+ entries.sort((a, b) => a.name.localeCompare(b.name));
12051
+ for (const entry of entries) {
12052
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
12053
+ 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
11349
12072
  * belongs only to the fallback mode.
11350
12073
  */
11351
12074
  function fileCacheKey(rulesDigest, fileText, identity) {
@@ -11377,14 +12100,11 @@ function createScanCache(root) {
11377
12100
  let totalBytes = 0;
11378
12101
  try {
11379
12102
  if (existsSync(file)) {
11380
- const parsed = JSON.parse(readFileSync(file, "utf8"));
11381
- if (parsed?.version === CACHE_VERSION && parsed.entries) {
11382
- entries = parsed.entries;
11383
- for (const [k, v] of Object.entries(entries)) {
11384
- const size = JSON.stringify(v).length + k.length + 4;
11385
- entryBytes.set(k, size);
11386
- totalBytes += size;
11387
- }
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;
11388
12108
  }
11389
12109
  }
11390
12110
  } catch {
@@ -11419,7 +12139,7 @@ function createScanCache(root) {
11419
12139
  entryBytes.set(key, newBytes);
11420
12140
  totalBytes = totalBytes - replacedBytes + newBytes;
11421
12141
  let count = Object.keys(entries).length;
11422
- while ((count > MAX_ENTRIES || totalBytes > MAX_TOTAL_BYTES) && count > 1) {
12142
+ while ((count > MAX_ENTRIES$1 || totalBytes > MAX_TOTAL_BYTES) && count > 1) {
11423
12143
  const oldest = Object.keys(entries)[0];
11424
12144
  totalBytes -= entryBytes.get(oldest);
11425
12145
  delete entries[oldest];
@@ -11498,6 +12218,10 @@ const BRAND = {
11498
12218
  aurora: "#37ABBD",
11499
12219
  auroraBright: "#45C1D4",
11500
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",
11501
12225
  steel: "#C8CBCF",
11502
12226
  steelDim: "#8B939D"
11503
12227
  };
@@ -11601,7 +12325,7 @@ const TRUST = {
11601
12325
  l4: "#45C1D4",
11602
12326
  l5: "#5CC4E0"
11603
12327
  };
11604
- SURFACE.ink950, SURFACE.ink900, SURFACE.ink850, SURFACE.ink800, BRAND.steel, BRAND.steelDim, BRAND.gold, BRAND.goldBright, BRAND.goldHot, BRAND.aurora, BRAND.auroraBright, BRAND.auroraCyan;
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;
11605
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;
11606
12330
  //#endregion
11607
12331
  //#region src/reporter/theme.ts
@@ -11720,9 +12444,14 @@ function plainContext(width = 80) {
11720
12444
  };
11721
12445
  }
11722
12446
  const FLAKE_GLYPH = "🔥";
11723
- /** Section header: `▚ TITLE` (ascii `= TITLE`). The one header style. */
12447
+ /**
12448
+ * Section header: `▍ TITLE` (ascii `= TITLE`). The one header style.
12449
+ * A thin accent bar, the terminal's version of the rule that opens each
12450
+ * chapter on the website; it replaced a checkerboard quadrant (▚) that
12451
+ * read as a rendering glitch rather than a mark.
12452
+ */
11724
12453
  function sectionHeader(title, ui) {
11725
- const glyph = ui.ascii ? "=" : "▚";
12454
+ const glyph = ui.ascii ? "=" : "▍";
11726
12455
  return ` ${ui.p.accent(`${glyph} ${title}`)}`;
11727
12456
  }
11728
12457
  //#endregion
@@ -11852,6 +12581,7 @@ const ui$5 = plainContext();
11852
12581
  const MAX_RECORDS = 1e5;
11853
12582
  function analyze(records, source) {
11854
12583
  const verdicts = [];
12584
+ const networkObservations = [];
11855
12585
  let failed = 0;
11856
12586
  let skipped = 0;
11857
12587
  let retried = 0;
@@ -11861,6 +12591,16 @@ function analyze(records, source) {
11861
12591
  const attempts = rec.attempts;
11862
12592
  const finalStatus = attempts[attempts.length - 1]?.status ?? "skipped";
11863
12593
  const totalDurationMs = attempts.reduce((s, a) => s + a.durationMs, 0);
12594
+ if (rec.evidenceKind === "network-observation") {
12595
+ networkObservations.push({
12596
+ file: rec.file,
12597
+ title: rec.title,
12598
+ outcome: finalStatus === "passed" ? "succeeded" : finalStatus === "skipped" ? "unknown" : "failed",
12599
+ durationMs: totalDurationMs,
12600
+ ...rec.errors !== void 0 ? { errors: rec.errors } : {}
12601
+ });
12602
+ continue;
12603
+ }
11864
12604
  totalDuration += totalDurationMs;
11865
12605
  const everFailed = attempts.some((a) => a.status === "failed" || a.status === "timedOut");
11866
12606
  const passedOnRetry = finalStatus === "passed" && attempts.length >= 2 && everFailed;
@@ -11892,6 +12632,7 @@ function analyze(records, source) {
11892
12632
  });
11893
12633
  }
11894
12634
  return {
12635
+ forensicsSchemaVersion: 1,
11895
12636
  source,
11896
12637
  totalTests: verdicts.length,
11897
12638
  failed,
@@ -11899,7 +12640,15 @@ function analyze(records, source) {
11899
12640
  retriedTests: retried,
11900
12641
  flakyTests: flaky,
11901
12642
  totalDurationMs: totalDuration,
11902
- verdicts
12643
+ verdicts,
12644
+ ...networkObservations.length > 0 ? {
12645
+ totalNetworkObservations: networkObservations.length,
12646
+ failedNetworkObservations: networkObservations.filter((observation) => observation.outcome === "failed").length,
12647
+ networkObservations
12648
+ } : {},
12649
+ analysisComplete: records.length <= MAX_RECORDS,
12650
+ skippedReports: 0,
12651
+ incompleteReasons: records.length > MAX_RECORDS ? ["record-count-limit"] : []
11903
12652
  };
11904
12653
  }
11905
12654
  /** Flakiness Leaderboard: flakiest first, then slowest. */
@@ -11914,15 +12663,36 @@ function bar(ms, maxMs, width = 20) {
11914
12663
  const filled = Math.round(ms / maxMs * width);
11915
12664
  return "█".repeat(Math.min(width, filled)) + "░".repeat(Math.max(0, width - filled));
11916
12665
  }
12666
+ function renderNetworkObservations(report) {
12667
+ if (!report.networkObservations?.length) return [];
12668
+ const failures = report.networkObservations.filter((observation) => observation.outcome === "failed");
12669
+ return [
12670
+ `${report.networkObservations.length} network observations · ${failures.length} network failures (not test outcomes)`,
12671
+ ...failures.slice(0, 25).map((observation) => `NETWORK FAILURE ${observation.title} · ${(observation.durationMs / 1e3).toFixed(1)}s`),
12672
+ ""
12673
+ ];
12674
+ }
12675
+ function partialAnalysisMessage(report) {
12676
+ const skippedReasons = report.incompleteReasons.filter((reason) => reason !== "record-count-limit");
12677
+ const partialReasons = report.incompleteReasons.filter((reason) => reason === "record-count-limit").map(() => "record count limit reached");
12678
+ if (report.skippedReports > 0) {
12679
+ const skippedReasonText = skippedReasons.length > 0 ? ` (${skippedReasons.join(", ")})` : "";
12680
+ const suffix = partialReasons.length > 0 ? `; ${partialReasons.join(", ")}.` : ".";
12681
+ return `Analysis is partial — ${report.skippedReports} report(s) skipped${skippedReasonText}${suffix}`;
12682
+ }
12683
+ return `${partialReasons.length > 0 ? partialReasons.join(", ") : report.incompleteReasons.join(", ")}; analysis is partial.`;
12684
+ }
11917
12685
  function renderLeaderboard(report) {
11918
12686
  const lines = [];
11919
12687
  lines.push(sectionHeader("FLAKINESS LEADERBOARD", ui$5));
11920
12688
  lines.push("");
11921
12689
  lines.push(`${report.totalTests} tests · ${report.failed} failed · ${report.flakyTests} flaky · ${report.retriedTests} retried`);
11922
12690
  lines.push("");
12691
+ lines.push(...renderNetworkObservations(report));
11923
12692
  const top = leaderboard(report);
11924
12693
  if (top.length === 0) {
11925
- lines.push("No failures or retries found — nothing suspicious this run.");
12694
+ lines.push(report.totalTests === 0 && (report.totalNetworkObservations ?? 0) > 0 ? "No test outcomes available from network observations." : (report.totalNetworkObservations ?? 0) > 0 ? "No flaky or failing tests detected; network evidence is listed separately." : "No failures or retries found — nothing suspicious this run.");
12695
+ if (!report.analysisComplete) lines.push(`⚠ ${partialAnalysisMessage(report)}`);
11926
12696
  return lines.join("\n");
11927
12697
  }
11928
12698
  const maxMs = Math.max(...top.map((v) => v.totalDurationMs), 1);
@@ -11930,6 +12700,10 @@ function renderLeaderboard(report) {
11930
12700
  const flag = v.passedOnRetry ? "TRUE-FLAKE" : "FAILING";
11931
12701
  lines.push(`${flag.padEnd(10)} ${v.title} (${v.file})`, ` ${bar(v.totalDurationMs, maxMs)} ${(v.totalDurationMs / 1e3).toFixed(1)}s · ${v.attempts} attempt${v.attempts === 1 ? "" : "s"}`);
11932
12702
  }
12703
+ if (!report.analysisComplete) {
12704
+ lines.push("");
12705
+ lines.push(`⚠ ${partialAnalysisMessage(report)}`);
12706
+ }
11933
12707
  return lines.join("\n");
11934
12708
  }
11935
12709
  /** FLAKY.md — the committed artifact. */
@@ -11942,9 +12716,14 @@ function renderFlakyMd(report) {
11942
12716
  lines.push("");
11943
12717
  lines.push(`**${report.totalTests}** tests analyzed · **${report.flakyTests}** true flakes · **${report.retriedTests}** retried · **${report.failed}** failing`);
11944
12718
  lines.push("");
12719
+ lines.push(...renderNetworkObservations(report));
11945
12720
  const top = leaderboard(report);
11946
12721
  if (top.length === 0) {
11947
- lines.push("_No flaky or failing tests detected in this run._");
12722
+ lines.push(report.totalTests === 0 && (report.totalNetworkObservations ?? 0) > 0 ? "_No test outcomes available from network observations._" : "_No flaky or failing tests detected in this run._");
12723
+ if (!report.analysisComplete) {
12724
+ lines.push("");
12725
+ lines.push(`> ⚠ ${partialAnalysisMessage(report)}`);
12726
+ }
11948
12727
  return lines.join("\n");
11949
12728
  }
11950
12729
  lines.push("| Status | Test | File | Attempts | Duration |");
@@ -11953,10 +12732,77 @@ function renderFlakyMd(report) {
11953
12732
  const status = v.passedOnRetry ? `${FLAKE_GLYPH} TRUE-FLAKE` : "❌ failing";
11954
12733
  lines.push(`| ${status} | \`${v.title}\` | \`${v.file}\` | ${v.attempts} | ${(v.totalDurationMs / 1e3).toFixed(1)}s |`);
11955
12734
  }
12735
+ if (!report.analysisComplete) {
12736
+ lines.push("");
12737
+ lines.push(`> ⚠ ${partialAnalysisMessage(report)}`);
12738
+ }
11956
12739
  lines.push("");
11957
12740
  return lines.join("\n");
11958
12741
  }
11959
12742
  //#endregion
12743
+ //#region src/forensics/evidence-hygiene.ts
12744
+ /**
12745
+ * Evidence hygiene — secret redaction + control-char sanitization
12746
+ * (ENGINE-012).
12747
+ *
12748
+ * Pure, deterministic, bounded. Every error text entering the forensics
12749
+ * pipeline passes through `sanitizeErrorText` so that secrets, control
12750
+ * characters, and oversized payloads never survive into reports or
12751
+ * committed artifacts.
12752
+ */
12753
+ const MAX_LENGTH_DEFAULT = 10240;
12754
+ const REDACTION_PATTERNS = [
12755
+ {
12756
+ name: "aws-key",
12757
+ regex: /AKIA[0-9A-Z]{16}/g
12758
+ },
12759
+ {
12760
+ name: "github-token",
12761
+ regex: /gh[pousr]_\w{36,}/g
12762
+ },
12763
+ {
12764
+ name: "jwt",
12765
+ regex: /eyJ[\w-]+\.eyJ[\w-]+\.[\w-]+/g
12766
+ },
12767
+ {
12768
+ name: "bearer-token",
12769
+ regex: /[Bb]earer\s+[\w.+-]+/g
12770
+ },
12771
+ {
12772
+ name: "api-key-assignment",
12773
+ regex: /apikey\s*[:=]\s*['"]?[\w.]{8,}['"]?|api[_-]?key\s*[:=]\s*['"]?[\w.]{8,}['"]?/gi
12774
+ },
12775
+ {
12776
+ name: "password-assignment",
12777
+ regex: /(?:password|passwd|pwd)\s*[:=]\s*['"]?[^\s'"]{4,}['"]?/gi
12778
+ },
12779
+ {
12780
+ name: "private-key",
12781
+ regex: /-----BEGIN[A\s][^-]*KEY-----[\s\S]*?-----END[A\s][^-]*KEY-----/g
12782
+ },
12783
+ {
12784
+ name: "hex-secret",
12785
+ regex: /\b[0-9a-f]{33,}\b/gi
12786
+ },
12787
+ {
12788
+ name: "base64-secret",
12789
+ regex: /\b[\w+/]{100,}={0,2}\b/g
12790
+ }
12791
+ ];
12792
+ const CONTROL_CHAR_PATTERN = /[\0\x07\x08\v\f\x0e\x0f\x1b\x7f\x80-\x9f]/g;
12793
+ /**
12794
+ * Sanitize error text: redact secrets, strip control characters, cap length.
12795
+ * Pure and deterministic — same input always yields the same output.
12796
+ */
12797
+ function sanitizeErrorText(text, options) {
12798
+ const maxLength = options?.maxLength ?? MAX_LENGTH_DEFAULT;
12799
+ let result = text;
12800
+ for (const { name, regex } of REDACTION_PATTERNS) result = result.replace(regex, `[REDACTED:${name}]`);
12801
+ result = result.replace(CONTROL_CHAR_PATTERN, "");
12802
+ if (result.length > maxLength) result = result.slice(0, maxLength) + `[truncated at ${maxLength} chars]`;
12803
+ return result;
12804
+ }
12805
+ //#endregion
11960
12806
  //#region src/forensics/trace.ts
11961
12807
  /**
11962
12808
  * Playwright trace ingester (product-gap master plan §9 R5 / WI-17).
@@ -12053,7 +12899,7 @@ function parseTraceNdjson(text) {
12053
12899
  if (start !== void 0 && end !== void 0 && end >= start) action.durationMs = Math.round((end - start) * 1e3);
12054
12900
  if (ev.error !== null && typeof ev.error === "object" && ev.error !== void 0) {
12055
12901
  const msg = ev.error.message;
12056
- action.error = typeof msg === "string" ? { message: msg } : { message: "action failed" };
12902
+ action.error = typeof msg === "string" ? { message: sanitizeErrorText(msg) } : { message: "action failed" };
12057
12903
  }
12058
12904
  actions.set(ev.callId, action);
12059
12905
  }
@@ -12073,7 +12919,7 @@ function traceActionsToRecord(actions, artifact) {
12073
12919
  totalMs += a.durationMs;
12074
12920
  if (a.error !== void 0) {
12075
12921
  failed = true;
12076
- const msg = a.error.message ?? "action failed";
12922
+ const msg = sanitizeErrorText(a.error.message ?? "action failed");
12077
12923
  errors.push(msg);
12078
12924
  if (/timed\s*out|timeout\s+of/i.test(msg)) timedOut = true;
12079
12925
  }
@@ -12194,11 +13040,18 @@ function parseJestJson(json) {
12194
13040
  };
12195
13041
  const line = typeof assertion.location?.line === "number" && assertion.location.line > 0 ? assertion.location.line : void 0;
12196
13042
  const title = assertion.title ?? "(unnamed)";
13043
+ const errors = [];
13044
+ for (const msgs of [assertion.failureMessages, assertion.failureDetails]) {
13045
+ if (!Array.isArray(msgs)) continue;
13046
+ for (const m of msgs) if (typeof m === "string" && m.length > 0) errors.push(sanitizeErrorText(m));
13047
+ else if (m !== null && typeof m === "object" && "message" in m && typeof m.message === "string") errors.push(sanitizeErrorText(m.message));
13048
+ }
12197
13049
  out.push({
12198
13050
  file,
12199
13051
  title,
12200
13052
  attempts: [attempt],
12201
- ...line !== void 0 ? { line } : {}
13053
+ ...line !== void 0 ? { line } : {},
13054
+ ...errors.length > 0 ? { errors } : {}
12202
13055
  });
12203
13056
  }
12204
13057
  }
@@ -12244,15 +13097,36 @@ function parseJunitXml(xml) {
12244
13097
  const timeRaw = attr(attrs, "time");
12245
13098
  const timeSec = timeRaw !== void 0 && Number.isFinite(Number(timeRaw)) ? Number(timeRaw) : 0;
12246
13099
  const failed = /<(?:failure|error)\b/i.test(inner);
13100
+ const skipped = /<skipped\b/i.test(inner);
13101
+ const errors = [];
13102
+ if (failed) {
13103
+ const failRe = /<(?:failure|error)\b([^>]*)>([\s\S]*?)<\/(?:failure|error)\s*>/gi;
13104
+ let failMatch;
13105
+ while ((failMatch = failRe.exec(inner)) !== null) {
13106
+ const attrsStr = failMatch[1] ?? "";
13107
+ const msgAttr = /\smessage\s*=\s*"([^"]*)"/i.exec(attrsStr)?.[1];
13108
+ if (msgAttr) errors.push(sanitizeErrorText(decodeEntities(msgAttr)));
13109
+ const body = failMatch[2]?.trim();
13110
+ if (body && body !== msgAttr) errors.push(sanitizeErrorText(decodeEntities(body)));
13111
+ }
13112
+ const selfCloseRe = /<(?:failure|error)\b([^>]*)\/>/gi;
13113
+ let scMatch;
13114
+ while ((scMatch = selfCloseRe.exec(inner)) !== null) {
13115
+ const scAttrs = scMatch[1] ?? "";
13116
+ const scMsgMatch = /\smessage\s*=\s*"([^"]*)"/i.exec(scAttrs);
13117
+ if (scMsgMatch?.[1]) errors.push(sanitizeErrorText(decodeEntities(scMsgMatch[1])));
13118
+ }
13119
+ }
12247
13120
  const attempt = {
12248
13121
  index: 1,
12249
- status: /<skipped\b/i.test(inner) ? "skipped" : failed ? "failed" : "passed",
13122
+ status: skipped ? "skipped" : failed ? "failed" : "passed",
12250
13123
  durationMs: Math.round(timeSec * 1e3)
12251
13124
  };
12252
13125
  out.push({
12253
13126
  file: classname || "unknown",
12254
13127
  title: name,
12255
- attempts: [attempt]
13128
+ attempts: [attempt],
13129
+ ...errors.length > 0 ? { errors } : {}
12256
13130
  });
12257
13131
  }
12258
13132
  return out;
@@ -12289,6 +13163,7 @@ function parsePlaywrightJson(json) {
12289
13163
  const attempts = [];
12290
13164
  let i = 0;
12291
13165
  const results = Array.isArray(test.results) ? test.results : [];
13166
+ const collectedErrors = [];
12292
13167
  for (const r of results) {
12293
13168
  if (!r || typeof r !== "object") continue;
12294
13169
  attempts.push({
@@ -12296,6 +13171,16 @@ function parsePlaywrightJson(json) {
12296
13171
  status: toStatus$1(r.status),
12297
13172
  durationMs: Number.isFinite(r.duration) ? Math.max(0, r.duration) : 0
12298
13173
  });
13174
+ if (r.error !== void 0 && r.error !== null) {
13175
+ const errObj = r.error;
13176
+ if (typeof errObj.message === "string" && errObj.message.length > 0) collectedErrors.push(sanitizeErrorText(errObj.message));
13177
+ }
13178
+ if (Array.isArray(r.errors)) {
13179
+ for (const e of r.errors) if (e !== null && typeof e === "object") {
13180
+ const eObj = e;
13181
+ if (typeof eObj.message === "string" && eObj.message.length > 0) collectedErrors.push(sanitizeErrorText(eObj.message));
13182
+ } else if (typeof e === "string" && e.length > 0) collectedErrors.push(sanitizeErrorText(e));
13183
+ }
12299
13184
  }
12300
13185
  if (attempts.length === 0) continue;
12301
13186
  const specLine = typeof spec.location?.line === "number" && spec.location.line >= 0 ? spec.location.line + 1 : void 0;
@@ -12303,7 +13188,8 @@ function parsePlaywrightJson(json) {
12303
13188
  file,
12304
13189
  title: spec.title ?? "(unnamed)",
12305
13190
  attempts,
12306
- ...specLine !== void 0 ? { line: specLine } : {}
13191
+ ...specLine !== void 0 ? { line: specLine } : {},
13192
+ ...collectedErrors.length > 0 ? { errors: collectedErrors } : {}
12307
13193
  });
12308
13194
  }
12309
13195
  }
@@ -12349,17 +13235,110 @@ function parseVitestJson(json) {
12349
13235
  durationMs
12350
13236
  };
12351
13237
  const line = typeof assertion.location?.line === "number" && assertion.location.line > 0 ? assertion.location.line : void 0;
13238
+ const errors = [];
13239
+ for (const msgs of [assertion.failureMessages, assertion.failureDetails]) {
13240
+ if (!Array.isArray(msgs)) continue;
13241
+ for (const m of msgs) if (typeof m === "string" && m.length > 0) errors.push(sanitizeErrorText(m));
13242
+ else if (m !== null && typeof m === "object" && "message" in m && typeof m.message === "string") errors.push(sanitizeErrorText(m.message));
13243
+ }
12352
13244
  out.push({
12353
13245
  file,
12354
13246
  title: assertion.title ?? "(unnamed)",
12355
13247
  attempts: [attempt],
12356
- ...line !== void 0 ? { line } : {}
13248
+ ...line !== void 0 ? { line } : {},
13249
+ ...errors.length > 0 ? { errors } : {}
12357
13250
  });
12358
13251
  }
12359
13252
  }
12360
13253
  return out;
12361
13254
  }
12362
13255
  //#endregion
13256
+ //#region src/forensics/parse-har.ts
13257
+ const MAX_ENTRIES = 2e4;
13258
+ function sanitizeTitleUrl(url) {
13259
+ try {
13260
+ const parsed = new URL(url);
13261
+ parsed.username = "";
13262
+ parsed.password = "";
13263
+ parsed.search = "";
13264
+ parsed.hash = "";
13265
+ return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
13266
+ } catch {
13267
+ return url.split(/[?#]/, 1)[0] ?? url;
13268
+ }
13269
+ }
13270
+ function serializeTransportError(value) {
13271
+ if (typeof value === "string") return value;
13272
+ try {
13273
+ const serialized = JSON.stringify(value);
13274
+ return serialized === void 0 ? String(value) : serialized;
13275
+ } catch {
13276
+ return "[unserializable transport error]";
13277
+ }
13278
+ }
13279
+ function isValidHttpStatus(status) {
13280
+ return typeof status === "number" && Number.isInteger(status) && status >= 100 && status <= 599;
13281
+ }
13282
+ /**
13283
+ * Sniff: a HAR root is `{ log: { entries: [...] } }` — distinct from
13284
+ * every other JSON shape the forensics layer accepts (Playwright/Jest/
13285
+ * Vitest all key on `testResults` or `suites`).
13286
+ */
13287
+ function looksLikeHarJson(json) {
13288
+ if (!json || typeof json !== "object") return false;
13289
+ const log = json.log;
13290
+ if (!log || typeof log !== "object") return false;
13291
+ return Array.isArray(log.entries);
13292
+ }
13293
+ function parseHarJsonDetailed(json) {
13294
+ if (!json || typeof json !== "object") return {
13295
+ records: [],
13296
+ truncated: false
13297
+ };
13298
+ const entries = json.log?.entries;
13299
+ if (!Array.isArray(entries)) return {
13300
+ records: [],
13301
+ truncated: false
13302
+ };
13303
+ const out = [];
13304
+ const truncated = entries.length > MAX_ENTRIES;
13305
+ for (const entry of entries.slice(0, MAX_ENTRIES)) {
13306
+ if (!entry || typeof entry !== "object") continue;
13307
+ const method = typeof entry.request?.method === "string" ? entry.request.method : "GET";
13308
+ const url = typeof entry.request?.url === "string" ? entry.request.url : void 0;
13309
+ if (url === void 0 || url.length === 0) continue;
13310
+ const status = typeof entry.response?.status === "number" ? entry.response.status : void 0;
13311
+ const transportError = entry.response?._error ?? entry.response?.error ?? void 0;
13312
+ const hasTransportError = transportError !== void 0 && transportError !== null;
13313
+ const hasValidStatus = isValidHttpStatus(status);
13314
+ if (!hasValidStatus && !hasTransportError) continue;
13315
+ const httpFailed = hasValidStatus && status >= 400;
13316
+ const errors = [];
13317
+ if (hasTransportError) errors.push(sanitizeErrorText(serializeTransportError(transportError)));
13318
+ if (httpFailed) {
13319
+ const text = typeof entry.response?.statusText === "string" && entry.response.statusText.length > 0 ? entry.response.statusText : `HTTP ${status}`;
13320
+ errors.push(sanitizeErrorText(`${status} ${text}`));
13321
+ }
13322
+ const durationMs = typeof entry.time === "number" && Number.isFinite(entry.time) ? Math.max(0, Math.round(entry.time)) : 0;
13323
+ const attempt = {
13324
+ index: 1,
13325
+ status: hasTransportError || httpFailed ? "failed" : "passed",
13326
+ durationMs
13327
+ };
13328
+ out.push({
13329
+ file: "har",
13330
+ evidenceKind: "network-observation",
13331
+ title: `${method} ${sanitizeTitleUrl(url)}`,
13332
+ attempts: [attempt],
13333
+ ...errors.length > 0 ? { errors } : {}
13334
+ });
13335
+ }
13336
+ return {
13337
+ records: out,
13338
+ truncated
13339
+ };
13340
+ }
13341
+ //#endregion
12363
13342
  //#region src/forensics/run.ts
12364
13343
  /**
12365
13344
  * `mjolnir forensics <dir-or-file>` — runtime evidence entry point (R4).
@@ -12374,9 +13353,23 @@ function parseVitestJson(json) {
12374
13353
  * Writes FLAKY.md next to the scan target unless --no-flaky-md.
12375
13354
  */
12376
13355
  const MAX_FILES = 500;
13356
+ const MAX_REPORT_FILE_BYTES = 1048576;
13357
+ const MAX_CUMULATIVE_BYTES = 52428800;
13358
+ /**
13359
+ * Run forensics analysis on test result artifacts.
13360
+ *
13361
+ * Parses test result files (Playwright JSON, JUnit XML, HAR, etc.),
13362
+ * analyzes them for flakiness, determinism, and trust signals, and
13363
+ * returns a structured report plus rendered output strings.
13364
+ *
13365
+ * @param target - path to the test result artifact or directory
13366
+ * @param options - writeFlakyMd (default true) controls FLAKY.md output
13367
+ */
12377
13368
  function runForensics(target, options = {}) {
12378
13369
  const records = [];
12379
13370
  let source = "playwright-json";
13371
+ let skippedReports = 0;
13372
+ const incompleteReasons = [];
12380
13373
  if (!existsSync(target)) {
12381
13374
  const report = analyze(records, source);
12382
13375
  return {
@@ -12384,12 +13377,28 @@ function runForensics(target, options = {}) {
12384
13377
  output: [
12385
13378
  renderLeaderboard(report),
12386
13379
  "",
12387
- renderFlakyMdHint()
13380
+ renderFlakyMdNotWritten(options)
12388
13381
  ].join("\n")
12389
13382
  };
12390
13383
  }
12391
13384
  const stat = statSync(target);
12392
13385
  if (stat.isFile()) {
13386
+ if (stat.size > MAX_REPORT_FILE_BYTES) {
13387
+ skippedReports = 1;
13388
+ incompleteReasons.push("size-limit");
13389
+ const report = analyze([], source);
13390
+ report.analysisComplete = false;
13391
+ report.skippedReports = skippedReports;
13392
+ report.incompleteReasons = incompleteReasons;
13393
+ return {
13394
+ report,
13395
+ output: [
13396
+ renderLeaderboard(report),
13397
+ "",
13398
+ renderFlakyMdNotWritten(options)
13399
+ ].join("\n")
13400
+ };
13401
+ }
12393
13402
  if (/\.(?:zip|trace|ndjson)$/i.test(target)) try {
12394
13403
  const record = parseTraceArtifact(readFileSync(target), traceArtifactName(target));
12395
13404
  if (record === void 0) {
@@ -12403,51 +13412,95 @@ function runForensics(target, options = {}) {
12403
13412
  ].join("\n")
12404
13413
  };
12405
13414
  }
12406
- const report = analyze([record], "playwright-trace");
13415
+ records.push(record);
13416
+ source = "playwright-trace";
13417
+ } catch {
13418
+ const report = analyze([], "playwright-trace");
13419
+ report.analysisComplete = false;
13420
+ report.skippedReports = 1;
13421
+ report.incompleteReasons = ["parse-failure"];
12407
13422
  return {
12408
13423
  report,
12409
13424
  output: [
12410
13425
  renderLeaderboard(report),
12411
13426
  "",
12412
- renderFlakyMdHint()
12413
- ].join("\n")
12414
- };
12415
- } catch {
12416
- const report = analyze([], "playwright-trace");
12417
- return {
12418
- report,
12419
- output: [
12420
- renderLeaderboard(report),
12421
- "",
12422
- renderFlakyMdNotWritten(options)
13427
+ renderFlakyMdNotWritten(options)
12423
13428
  ].join("\n")
12424
13429
  };
12425
13430
  }
12426
- try {
13431
+ else try {
12427
13432
  const parsed = parseFile(target, readFileSync(target, "utf8"));
12428
13433
  records.push(...parsed.records);
12429
13434
  source = parsed.source;
12430
- } catch {}
13435
+ if (parsed.parseFailed) {
13436
+ skippedReports = 1;
13437
+ incompleteReasons.push("parse-failure");
13438
+ }
13439
+ if (parsed.truncated && !incompleteReasons.includes("entry-count-limit")) incompleteReasons.push("entry-count-limit");
13440
+ } catch {
13441
+ skippedReports = 1;
13442
+ incompleteReasons.push("parse-failure");
13443
+ }
12431
13444
  } else {
12432
13445
  let count = 0;
12433
- for (const full of listFiles(target)) {
12434
- if (++count > MAX_FILES) break;
13446
+ let cumulativeBytes = 0;
13447
+ let hitFileLimit = false;
13448
+ let hitCumulativeLimit = false;
13449
+ const allFiles = listFiles(target);
13450
+ for (const full of allFiles) {
13451
+ if (++count > MAX_FILES) {
13452
+ hitFileLimit = true;
13453
+ break;
13454
+ }
12435
13455
  try {
13456
+ const fileStat = statSync(full);
13457
+ if (fileStat.size > MAX_REPORT_FILE_BYTES) {
13458
+ skippedReports++;
13459
+ if (!incompleteReasons.includes("size-limit")) incompleteReasons.push("size-limit");
13460
+ continue;
13461
+ }
13462
+ cumulativeBytes += fileStat.size;
13463
+ if (cumulativeBytes > MAX_CUMULATIVE_BYTES) {
13464
+ hitCumulativeLimit = true;
13465
+ if (!incompleteReasons.includes("cumulative-size-limit")) incompleteReasons.push("cumulative-size-limit");
13466
+ break;
13467
+ }
12436
13468
  if (/\.(?:zip|trace|ndjson)$/i.test(full)) {
12437
13469
  const record = parseTraceArtifact(readFileSync(full), traceArtifactName(full));
12438
13470
  if (record === void 0) continue;
12439
- if (records.length === 0) source = "playwright-trace";
13471
+ if (records.length === 0 || source === "har") source = "playwright-trace";
12440
13472
  records.push(record);
12441
13473
  continue;
12442
13474
  }
12443
13475
  const parsed = parseFile(full, readFileSync(full, "utf8"));
13476
+ if (parsed.parseFailed) {
13477
+ skippedReports++;
13478
+ if (!incompleteReasons.includes("parse-failure")) incompleteReasons.push("parse-failure");
13479
+ }
13480
+ if (parsed.truncated && !incompleteReasons.includes("entry-count-limit")) incompleteReasons.push("entry-count-limit");
12444
13481
  if (parsed.records.length === 0) continue;
12445
- if (records.length === 0) source = parsed.source;
13482
+ if (records.length === 0 || source === "har" && parsed.source !== "har") source = parsed.source;
12446
13483
  records.push(...parsed.records);
12447
- } catch {}
13484
+ } catch {
13485
+ skippedReports++;
13486
+ if (!incompleteReasons.includes("parse-failure")) incompleteReasons.push("parse-failure");
13487
+ }
13488
+ }
13489
+ if (hitFileLimit) {
13490
+ if (!incompleteReasons.includes("file-count-limit")) incompleteReasons.push("file-count-limit");
13491
+ skippedReports += allFiles.length - count + 1;
13492
+ }
13493
+ if (hitCumulativeLimit) {
13494
+ if (!incompleteReasons.includes("cumulative-size-limit")) incompleteReasons.push("cumulative-size-limit");
13495
+ skippedReports += allFiles.length - count + 1;
12448
13496
  }
12449
13497
  }
12450
13498
  const report = analyze(records, source);
13499
+ if (skippedReports > 0 || incompleteReasons.length > 0) {
13500
+ report.analysisComplete = false;
13501
+ report.skippedReports = skippedReports;
13502
+ report.incompleteReasons = [.../* @__PURE__ */ new Set([...report.incompleteReasons, ...incompleteReasons])];
13503
+ }
12451
13504
  let flakyMdPath;
12452
13505
  if ((options.writeFlakyMd ?? true) && report.totalTests > 0) {
12453
13506
  const base = stat.isFile() ? dirname(target) : target;
@@ -12463,7 +13516,7 @@ function runForensics(target, options = {}) {
12463
13516
  output: [
12464
13517
  renderLeaderboard(report),
12465
13518
  "",
12466
- flakyMdPath !== void 0 ? renderFlakyMdHint() : renderFlakyMdNotWritten(options)
13519
+ flakyMdPath !== void 0 ? renderFlakyMdHint() : report.totalTests === 0 && (report.totalNetworkObservations ?? 0) > 0 && options.writeFlakyMd !== false ? "FLAKY.md was not written — network observations do not establish test outcomes." : renderFlakyMdNotWritten(options)
12467
13520
  ].join("\n"),
12468
13521
  flakyMdPath
12469
13522
  };
@@ -12493,14 +13546,33 @@ function parseFile(path, text) {
12493
13546
  source: "junit-xml"
12494
13547
  };
12495
13548
  let json;
13549
+ const isHarFile = /\.har$/i.test(path);
12496
13550
  try {
12497
13551
  json = JSON.parse(text);
12498
13552
  } catch {
12499
13553
  return {
12500
13554
  records: [],
12501
- source: "playwright-json"
13555
+ source: isHarFile ? "har" : "playwright-json",
13556
+ parseFailed: true
13557
+ };
13558
+ }
13559
+ if (looksLikeHarJson(json)) {
13560
+ const parsed = parseHarJsonDetailed(json);
13561
+ return {
13562
+ records: parsed.records,
13563
+ source: "har",
13564
+ truncated: parsed.truncated
12502
13565
  };
12503
13566
  }
13567
+ if (isHarFile || json !== null && typeof json === "object" && [
13568
+ "suites",
13569
+ "specs",
13570
+ "testResults"
13571
+ ].some((key) => key in json && !Array.isArray(json[key]))) return {
13572
+ records: [],
13573
+ source: isHarFile ? "har" : "playwright-json",
13574
+ parseFailed: true
13575
+ };
12504
13576
  if (looksLikeJestJson(json)) return {
12505
13577
  records: parseJestJson(json),
12506
13578
  source: "jest-json"
@@ -12529,119 +13601,12 @@ function listFiles(dir) {
12529
13601
  if (e.isDirectory()) {
12530
13602
  if (["node_modules", ".git"].includes(e.name)) continue;
12531
13603
  walk(full, depth + 1);
12532
- } else if (e.isFile() && /\.(?:json|xml|zip|trace|ndjson)$/i.test(e.name)) out.push(full);
13604
+ } else if (e.isFile() && /\.(?:json|xml|zip|trace|ndjson|har)$/i.test(e.name)) out.push(full);
12533
13605
  }
12534
13606
  };
12535
13607
  walk(dir, 0);
12536
13608
  return out.map((p) => p);
12537
13609
  }
12538
- //#endregion
12539
- //#region src/config/config.ts
12540
- /**
12541
- * Config file support (Sprint-Plan W7, Product-MVP §27 + GAP-F).
12542
- * Zero-config preserved: config presence never changes detection
12543
- * semantics — only severity, scope, and gating.
12544
- */
12545
- /**
12546
- * Audit S7: every config key the engine consumes. A top-level key
12547
- * OUTSIDE this set is a likely typo ("severities", "ingore") — unknown
12548
- * keys emit a warning (fail-noisy, not fail-silent) and are ignored.
12549
- */
12550
- const KNOWN_CONFIG_KEYS = /* @__PURE__ */ new Set([
12551
- "gate",
12552
- "exclude",
12553
- "severityOverrides",
12554
- "ignore",
12555
- "plugins"
12556
- ]);
12557
- const CONFIG_NAMES = ["mjolnir.config.json", ".mjolnir.json"];
12558
- /**
12559
- * Distinguished from other load failures so the CLI can exit 10 (usage)
12560
- * instead of 20 (internal): a typo in the user's config is a user error
12561
- * with a fixable message, not a tool malfunction (bug-audit M4).
12562
- */
12563
- var ConfigValidationError = class extends Error {
12564
- constructor(message, options) {
12565
- super(message, options);
12566
- this.name = "ConfigValidationError";
12567
- }
12568
- };
12569
- function loadConfig(root, options = {}) {
12570
- for (const name of CONFIG_NAMES) {
12571
- const p = join(root, name);
12572
- if (!existsSync(p)) continue;
12573
- try {
12574
- const parsed = JSON.parse(readFileSync(p, "utf8"));
12575
- return {
12576
- config: parsed,
12577
- path: p,
12578
- warnings: validate(parsed, options.knownRuleIds)
12579
- };
12580
- } catch (err) {
12581
- const msg = err.message;
12582
- throw new ConfigValidationError(`Invalid mjolnir config at ${p}: ${msg}`, { cause: err });
12583
- }
12584
- }
12585
- return {
12586
- config: {},
12587
- path: null,
12588
- warnings: []
12589
- };
12590
- }
12591
- function validate(cfg, knownRuleIds) {
12592
- const warnings = [];
12593
- if (cfg.gate && ![
12594
- "advisory",
12595
- "error",
12596
- "warning"
12597
- ].includes(cfg.gate)) throw new Error(`gate must be advisory|error|warning, got "${cfg.gate}"`);
12598
- for (const [ruleId, severity] of Object.entries(cfg.severityOverrides ?? {})) {
12599
- if (!SEVERITY_ORDER.includes(severity)) throw new Error(`severityOverrides["${ruleId}"] must be one of ${SEVERITY_ORDER.join("|")}, got "${String(severity)}"`);
12600
- if (knownRuleIds && !knownRuleIds.has(ruleId)) warnings.push(`warning: severityOverrides["${ruleId}"] names no registered rule (typo? plugin rule?) — the entry is kept but currently matches nothing.`);
12601
- }
12602
- for (const ign of cfg.ignore ?? []) {
12603
- if (!ign.ruleId) throw new Error("ignore entries require ruleId");
12604
- if (!ign.reason) throw new Error(`ignore for ${ign.ruleId} requires a "reason" (§27)`);
12605
- if (ign.files !== void 0) {
12606
- if (!Array.isArray(ign.files)) throw new Error(`ignore for ${ign.ruleId}: "files" must be an array of glob strings, got ${typeof ign.files}`);
12607
- for (const g of ign.files) if (typeof g !== "string") throw new Error(`ignore for ${ign.ruleId}: "files" entries must be strings, got ${typeof g} (${JSON.stringify(g)})`);
12608
- }
12609
- if (ign.expires !== void 0 && Number.isNaN(new Date(ign.expires).getTime())) throw new Error(`ignore for ${ign.ruleId}: "expires" must be an ISO date, got "${ign.expires}"`);
12610
- }
12611
- for (const key of Object.keys(cfg)) if (!KNOWN_CONFIG_KEYS.has(key)) warnings.push(`warning: mjolnir.config.json has an unknown top-level key "${key}" — ignored (typo? see README §Configuration).`);
12612
- if (cfg.exclude !== void 0) {
12613
- if (!Array.isArray(cfg.exclude)) throw new Error(`exclude must be an array of strings, got ${typeof cfg.exclude}`);
12614
- for (const p of cfg.exclude) if (typeof p !== "string") throw new Error(`exclude entries must be strings, got ${typeof p} (${JSON.stringify(p)})`);
12615
- }
12616
- return warnings;
12617
- }
12618
- /**
12619
- * Bug-audit QA-2026-08-30 QA-6: the 90-day policy in the README was only
12620
- * applied at WRITE time by the `ignore` command — a hand-written entry
12621
- * without `expires` stayed active forever, silently bypassing the
12622
- * documented window.
12623
- *
12624
- * Audit S4 (remediation plan): the config-file MTIME is no longer an
12625
- * expiry anchor. Anchoring the 90-day default at mtime meant ANY edit to
12626
- * mjolnir.config.json — a reformat, an unrelated key, a `touch` — reset
12627
- * the 90-day window for EVERY hand-authored entry: suppressions could be
12628
- * extended indefinitely without touching their own fields. The expiry is
12629
- * now the entry's explicit `expires` date alone; an entry without one
12630
- * stays active and is honestly labeled "(no expiry set)" in the
12631
- * suppressions report. Hand-authored entries should declare `expires` at
12632
- * creation (README §Configuration documents the shape).
12633
- */
12634
- function isSuppressionActive(ign, now = /* @__PURE__ */ new Date()) {
12635
- if (!ign.expires) return true;
12636
- return new Date(ign.expires) > now;
12637
- }
12638
- function applySeverityOverrides(findings, cfg) {
12639
- const overrides = cfg.severityOverrides ?? {};
12640
- for (const f of findings) {
12641
- const override = overrides[f.ruleId];
12642
- if (override && SEVERITY_ORDER.includes(override)) f.severity = override;
12643
- }
12644
- }
12645
13610
  plainContext();
12646
13611
  function loadSuppressions(root) {
12647
13612
  const { config } = loadConfig(root);
@@ -12737,11 +13702,11 @@ function loadPlugins(root, gateOpen = false) {
12737
13702
  errors: [],
12738
13703
  skipped: []
12739
13704
  };
12740
- const cfgPath = join(root, "mjolnir.config.json");
12741
- if (!existsSync(cfgPath)) return result;
13705
+ const cfgPath = findConfigPath(root);
13706
+ if (!cfgPath) return result;
12742
13707
  let raw;
12743
13708
  try {
12744
- raw = JSON.parse(readFileSync(cfgPath, "utf8"));
13709
+ raw = parseJsonFile(readFileSync(cfgPath, "utf8"), cfgPath, isRecord);
12745
13710
  } catch {
12746
13711
  return result;
12747
13712
  }
@@ -12857,17 +13822,12 @@ const ALLOWED_QA_IMPACTS = /* @__PURE__ */ new Set([
12857
13822
  "FALSE-GREEN",
12858
13823
  "HYGIENE"
12859
13824
  ]);
12860
- /** Consistent error message extraction (v8 branch-friendly form). */
12861
- function errorMessage$1(err) {
12862
- if (err instanceof Error) return err.message;
12863
- return String(err);
12864
- }
12865
13825
  /**
12866
13826
  * Discover + load external rules from `<root>/mjolnir-rules/`.
12867
13827
  * Missing directory → empty result (not an error — most workspaces
12868
13828
  * carry none).
12869
13829
  */
12870
- async function loadLocalRules(root, gateOpen = false) {
13830
+ async function loadLocalRules(root, gateOpen = true) {
12871
13831
  const result = {
12872
13832
  rules: [],
12873
13833
  errors: [],
@@ -12879,18 +13839,15 @@ async function loadLocalRules(root, gateOpen = false) {
12879
13839
  try {
12880
13840
  entries = readdirSync(dir);
12881
13841
  } catch (err) {
12882
- result.errors.push(`external rules directory "${LOCAL_RULES_DIR}/" could not be read: ${errorMessage$1(err)}`);
13842
+ result.errors.push(`external rules directory "${LOCAL_RULES_DIR}/" could not be read: ${err instanceof Error ? err.message : String(err)}`);
12883
13843
  return result;
12884
13844
  }
12885
13845
  for (const entry of entries.sort()) {
12886
13846
  const path = join(dir, entry);
12887
13847
  if (entry.endsWith(".json")) loadJsonRule(path, result);
12888
13848
  else if (entry.endsWith(".mjs") || entry.endsWith(".js")) {
12889
- if (!gateOpen) {
12890
- result.skipped.push(`${LOCAL_RULES_DIR}/${entry}`);
12891
- continue;
12892
- }
12893
- await loadModuleRules(path, result);
13849
+ if (!gateOpen) result.skipped.push(`${LOCAL_RULES_DIR}/${entry}`);
13850
+ else await loadModuleRules(path, result);
12894
13851
  }
12895
13852
  }
12896
13853
  return result;
@@ -12901,7 +13858,7 @@ function loadJsonRule(path, result) {
12901
13858
  try {
12902
13859
  raw = JSON.parse(readFileSync(path, "utf8"));
12903
13860
  } catch (err) {
12904
- result.errors.push(`external rule "${name}" is not valid JSON: ${errorMessage$1(err)}`);
13861
+ result.errors.push(`external rule "${name}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
12905
13862
  return;
12906
13863
  }
12907
13864
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
@@ -12923,23 +13880,24 @@ function loadJsonRule(path, result) {
12923
13880
  result.errors.push(`external rule ${id} must declare a non-empty "patterns" array of regex source strings.`);
12924
13881
  return;
12925
13882
  }
13883
+ let regexes;
13884
+ try {
13885
+ regexes = patterns.map((p) => new RegExp(p, "g"));
13886
+ } catch (err) {
13887
+ result.errors.push(`external rule ${id} declares an invalid regex: ${err instanceof Error ? err.message : String(err)}`);
13888
+ return;
13889
+ }
12926
13890
  for (const p of patterns) {
12927
13891
  if (p.length > 512) {
12928
- result.errors.push(`external rule ${id} declares a pattern longer than 512 chars — rejected (bound on sync regex cost).`);
13892
+ result.errors.push(`external rule ${id} has a pattern longer than 512 chars — likely a copy-paste error.`);
12929
13893
  return;
12930
13894
  }
12931
- if ((p.match(/[*+?{]/g) ?? []).length > 64) {
12932
- result.errors.push(`external rule ${id} declares a pattern with more than 64 quantifier/wildcard tokens — rejected (bound on sync regex cost).`);
13895
+ const quantifiers = p.match(/[+*?]|\{\d/g);
13896
+ if (quantifiers && quantifiers.length > 64) {
13897
+ result.errors.push(`external rule ${id} has a pattern with more than 64 quantifier/wildcard tokens — likely catastrophic backtracking.`);
12933
13898
  return;
12934
13899
  }
12935
13900
  }
12936
- let regexes;
12937
- try {
12938
- regexes = patterns.map((p) => new RegExp(p, "g"));
12939
- } catch (err) {
12940
- result.errors.push(`external rule ${id} declares an invalid regex: ${errorMessage$1(err)}`);
12941
- return;
12942
- }
12943
13901
  const severity = decl["severity"];
12944
13902
  const category = decl["category"];
12945
13903
  const appliesTo = decl["appliesTo"];
@@ -12960,20 +13918,14 @@ function loadJsonRule(path, result) {
12960
13918
  result.errors.push(`external rule ${id} declares an invalid "qaImpact".`);
12961
13919
  return;
12962
13920
  }
12963
- let title = id;
12964
- if (typeof decl["title"] === "string") title = decl["title"];
12965
- let message = title;
12966
- if (typeof decl["message"] === "string") message = decl["message"];
13921
+ const title = typeof decl["title"] === "string" ? decl["title"] : id;
13922
+ const message = typeof decl["message"] === "string" ? decl["message"] : title;
12967
13923
  const why = typeof decl["why"] === "string" ? decl["why"] : "Declared by a workspace-local external rule.";
12968
13924
  const fix = typeof decl["fix"] === "string" ? decl["fix"] : "Review the matched code against the rule's intent.";
12969
13925
  const confidence = decl["confidence"] === "high" || decl["confidence"] === "low" ? decl["confidence"] : "medium";
12970
- let languages;
12971
- const rawLanguages = decl["languages"];
12972
- if (Array.isArray(rawLanguages) && rawLanguages.every((l) => typeof l === "string")) languages = rawLanguages;
12973
- let frameworks;
12974
- const rawFrameworks = decl["frameworks"];
12975
- if (Array.isArray(rawFrameworks) && rawFrameworks.every((l) => typeof l === "string")) frameworks = rawFrameworks;
12976
- const rule = {
13926
+ const languages = Array.isArray(decl["languages"]) && decl["languages"].every((l) => typeof l === "string") ? decl["languages"] : void 0;
13927
+ const frameworks = Array.isArray(decl["frameworks"]) && decl["frameworks"].every((l) => typeof l === "string") ? decl["frameworks"] : void 0;
13928
+ result.rules.push({
12977
13929
  id,
12978
13930
  category,
12979
13931
  title,
@@ -12982,6 +13934,8 @@ function loadJsonRule(path, result) {
12982
13934
  findingType: "heuristic-risk",
12983
13935
  qaImpact,
12984
13936
  appliesTo,
13937
+ ...languages !== void 0 ? { languages } : {},
13938
+ ...frameworks !== void 0 ? { frameworks } : {},
12985
13939
  falsePositiveRisk: decl["falsePositiveRisk"] === "low" || decl["falsePositiveRisk"] === "high" ? decl["falsePositiveRisk"] : "medium",
12986
13940
  autofix: false,
12987
13941
  detectionStrategy: "LEXICAL",
@@ -12990,41 +13944,30 @@ function loadJsonRule(path, result) {
12990
13944
  detectorRevision: 1,
12991
13945
  run(ctx) {
12992
13946
  const view = ctx.text;
12993
- const lineStarts = [0];
12994
- for (let i = 0; i < view.length; i++) if (view.charCodeAt(i) === 10) lineStarts.push(i + 1);
12995
- const lineOf = (index) => {
12996
- let lo = 0;
12997
- let hi = lineStarts.length - 1;
12998
- while (lo < hi) {
12999
- const mid = lo + hi + 1 >> 1;
13000
- if (lineStarts.at(mid) <= index) lo = mid;
13001
- else hi = mid - 1;
13002
- }
13003
- return lo + 1;
13004
- };
13005
13947
  const findings = [];
13006
13948
  for (const re of regexes) {
13007
13949
  const run = new RegExp(re.source, "g");
13008
13950
  let m;
13009
- while ((m = run.exec(view)) !== null) findings.push({
13010
- severity,
13011
- confidence,
13012
- findingType: "heuristic-risk",
13013
- qaImpact,
13014
- file: ctx.path,
13015
- line: lineOf(m.index),
13016
- column: m.index - (view.lastIndexOf("\n", m.index - 1) + 1) + 1,
13017
- message,
13018
- why,
13019
- fix
13020
- });
13951
+ while ((m = run.exec(view)) !== null) {
13952
+ if (findings.length >= 1e4) throw new Error(`External rule ${id} exceeded its match limit`);
13953
+ if (m[0].length === 0) run.lastIndex = m.index + 1;
13954
+ findings.push({
13955
+ severity,
13956
+ confidence,
13957
+ findingType: "heuristic-risk",
13958
+ qaImpact,
13959
+ file: ctx.path,
13960
+ line: 1 + view.slice(0, m.index).split("\n").length - 1,
13961
+ column: m.index - (view.lastIndexOf("\n", m.index - 1) + 1) + 1,
13962
+ message,
13963
+ why,
13964
+ fix
13965
+ });
13966
+ }
13021
13967
  }
13022
13968
  return findings;
13023
13969
  }
13024
- };
13025
- if (languages !== void 0) rule.languages = languages;
13026
- if (frameworks !== void 0) rule.frameworks = frameworks;
13027
- result.rules.push(rule);
13970
+ });
13028
13971
  }
13029
13972
  async function loadModuleRules(path, result) {
13030
13973
  const name = `${LOCAL_RULES_DIR}/${path.split(/[\\/]/).pop()}`;
@@ -13032,7 +13975,7 @@ async function loadModuleRules(path, result) {
13032
13975
  try {
13033
13976
  mod = await import(pathToFileURL(path).href);
13034
13977
  } catch (err) {
13035
- result.errors.push(`external rule module "${name}" failed to load: ${errorMessage$1(err)}`);
13978
+ result.errors.push(`external rule module "${name}" failed to load: ${err instanceof Error ? err.message : String(err)}`);
13036
13979
  return;
13037
13980
  }
13038
13981
  const rules = mod?.rules;
@@ -13051,25 +13994,25 @@ async function loadModuleRules(path, result) {
13051
13994
  result.errors.push(`external rule module "${name}" rule ${ruleId} uses a reserved core prefix — rejected. Use your own family.`);
13052
13995
  continue;
13053
13996
  }
13054
- if (typeof r.severity !== "string" || !ALLOWED_SEVERITIES.has(r.severity)) {
13055
- result.errors.push(`external rule module "${name}" rule ${ruleId} declares an invalid "severity" (need error|warning|info) — skipped.`);
13056
- continue;
13997
+ if (r.tier === "core") {
13998
+ result.errors.push(`external rule ${r.id} declares tier "core" — clamped to "extended" (core requires a measured FP rate from the corpus sidecar; external rules are outside that registry).`);
13999
+ rule.tier = "extended";
13057
14000
  }
13058
- if (typeof r.category !== "string" || !ALLOWED_CATEGORIES.has(r.category)) {
13059
- result.errors.push(`external rule module "${name}" rule ${ruleId} declares an invalid "category" (need one of ${[...ALLOWED_CATEGORIES].join("|")}) — skipped.`);
14001
+ if (typeof r.severity === "string" && !ALLOWED_SEVERITIES.has(r.severity)) {
14002
+ result.errors.push(`external rule ${r.id} declares an invalid "severity" (need error|warning|info).`);
13060
14003
  continue;
13061
14004
  }
13062
- if (typeof r.appliesTo !== "string" || !ALLOWED_APPLIES_TO.has(r.appliesTo)) {
13063
- result.errors.push(`external rule module "${name}" rule ${ruleId} declares an invalid "appliesTo" (need one of ${[...ALLOWED_APPLIES_TO].join("|")}) — skipped.`);
14005
+ if (typeof r.category === "string" && !ALLOWED_CATEGORIES.has(r.category)) {
14006
+ result.errors.push(`external rule ${r.id} declares an invalid "category" (need ${[...ALLOWED_CATEGORIES].join("|")}).`);
13064
14007
  continue;
13065
14008
  }
13066
- if (r.qaImpact !== void 0 && (typeof r.qaImpact !== "string" || !ALLOWED_QA_IMPACTS.has(r.qaImpact))) {
13067
- result.errors.push(`external rule module "${name}" rule ${ruleId} declares an invalid "qaImpact" — skipped.`);
14009
+ if (typeof r.appliesTo === "string" && !ALLOWED_APPLIES_TO.has(r.appliesTo)) {
14010
+ result.errors.push(`external rule ${r.id} declares an invalid "appliesTo" (need ${[...ALLOWED_APPLIES_TO].join("|")}).`);
13068
14011
  continue;
13069
14012
  }
13070
- if (r.tier === "core") {
13071
- result.errors.push(`external rule ${r.id} declares tier "core" — clamped to "extended" (core requires a measured FP rate from the corpus sidecar; external rules are outside that registry).`);
13072
- rule.tier = "extended";
14013
+ if (typeof r.qaImpact === "string" && !ALLOWED_QA_IMPACTS.has(r.qaImpact)) {
14014
+ result.errors.push(`external rule ${r.id} declares an invalid "qaImpact" (need ${[...ALLOWED_QA_IMPACTS].join("|")}).`);
14015
+ continue;
13073
14016
  }
13074
14017
  result.rules.push(rule);
13075
14018
  }
@@ -13228,131 +14171,73 @@ function pathMatchesGlob(path, glob) {
13228
14171
  const last = i === segments.length - 1;
13229
14172
  if (segment === "**") {
13230
14173
  if (last) re += "(?:[^/]+/)*[^/]+";
13231
- else re += "(?:[^/]+/)*";
13232
- continue;
13233
- }
13234
- re += segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", "[^/]*");
13235
- if (!last) re += "/";
13236
- }
13237
- return new RegExp(`${re}$`).test(p);
13238
- }
13239
- /**
13240
- * Plan §16 + WI-11: locate a runtime run report next to the scan
13241
- * target, using the exact conventions the forensics ingestion already
13242
- * accepts. Zero-config search over conventional artifact names at
13243
- * depth ≤ 2 (src/discovery/evidence-discovery.ts); the FIRST parsable
13244
- * candidate wins (priority: mjolnir-report > playwright-json >
13245
- * test-results-dir > junit-file). Returns the path for `runForensics`,
13246
- * or undefined when no convention is present ("no runtime evidence" —
13247
- * never guessed; the CLI surfaces the missing-evidence message).
13248
- */
13249
- function discoverRuntimeReport(scanRoot) {
13250
- for (const c of discoverEvidenceCandidates(scanRoot)) return c.path;
13251
- }
13252
- /**
13253
- * Audit W10 — runtime shape validation at the rule→Finding boundary.
13254
- * A finding record coming out of a rule (plugin/JSON-manifest rules are
13255
- * external data, not trusted internal code) must carry the fields the
13256
- * whole downstream pipeline indexes on: severity within the enum,
13257
- * integer line ≥ 1, non-empty message, and a file path. Malformed
13258
- * records are rejected (routed to the crash/plugin-error channel by the
13259
- * caller) — never silently scored.
13260
- */
13261
- function isValidFindingRecord(f) {
13262
- if (typeof f !== "object" || f === null) return false;
13263
- const rec = f;
13264
- if (rec["severity"] !== "error" && rec["severity"] !== "warning" && rec["severity"] !== "info") return false;
13265
- if (typeof rec["line"] !== "number" || !Number.isInteger(rec["line"]) || rec["line"] < 1) return false;
13266
- if (typeof rec["message"] !== "string" || rec["message"] === "") return false;
13267
- if (typeof rec["file"] !== "string" || rec["file"] === "") return false;
13268
- return true;
13269
- }
13270
- /**
13271
- * Testable default scan path core. `hooks` lets callers observe
13272
- * normally-invisible events (swallowed rule crashes) without changing
13273
- * the ScanResult contract beyond the rulesCrashed counter.
13274
- *
13275
- * Async since the Verification Trust Evolution Plan Phase 0.5 (§10): the
13276
- * per-file loop awaits the adapter parse stage (WASM grammar load is
13277
- * inherently async); `runRules` and every rule stay synchronous and
13278
- * consume `ParsedFile.ast`. Callers await the returned promise.
13279
- */
13280
- async function runScan(args, hooks = {}) {
13281
- const started = Date.now();
13282
- const deadline = started + args.maxDurationMs;
13283
- const discovered = discoverWorkspace(args.target);
13284
- const targetAbs = resolve(args.target);
13285
- const scanRoot = discovered && discovered.root !== targetAbs && targetAbs.startsWith(discovered.root + sep) ? {
13286
- ...discovered,
13287
- root: targetAbs
13288
- } : discovered ?? fallbackWorkspace(targetAbs);
13289
- const workspace = scanRoot;
13290
- if (args.verbose) hooks.onConfigWarning?.(`verbose: scan root (config/ignore anchor): ${workspace.root}`);
13291
- const findings = [];
13292
- let skippedFiles = 0;
13293
- let testFileCount = 0;
13294
- let testDeclarationCount = 0;
13295
- const declarationsByFile = /* @__PURE__ */ new Map();
13296
- let rulesCrashed = 0;
13297
- const truncationReasons = /* @__PURE__ */ new Set();
13298
- let discoveryTruncated = false;
13299
- let rulesPartial = false;
13300
- let scopeIgnored = 0;
13301
- let scopeUnrecognized = 0;
13302
- let parseFailed = 0;
13303
- const fileProvenance = [];
13304
- const { rules: activeRules, pluginErrors, tierByRuleId: tiers, pluginMeta } = await buildUniversalRules(workspace.root, args.strict, {
13305
- ...args.enablePlugins !== void 0 ? { enablePlugins: args.enablePlugins } : {},
13306
- onGateNotice: (notice) => hooks.onGateNotice ? hooks.onGateNotice(notice) : console.error(notice)
13307
- });
13308
- const tierByRuleId = tiers;
13309
- const pluginsLoaded = pluginMeta;
13310
- const REVISION_BY_RULE_ID = new Map(activeRules.map((r) => [r.id, r.detectorRevision ?? MEASURED_FP[r.id]?.detectorRevision ?? 1]));
13311
- const cache = args.cache ? createScanCache(workspace.root) : disabledScanCache;
13312
- const rulesDigest = computeRulesDigest(activeRules);
13313
- for (const perr of pluginErrors) findings.push({
13314
- ruleId: "QA-PLUGIN-000",
13315
- category: "QA-PW",
13316
- severity: "warning",
13317
- confidence: "high",
13318
- findingType: "deterministic-defect",
13319
- qaImpact: "HYGIENE",
13320
- evidenceLevel: "E2",
13321
- file: "mjolnir.config.json",
13322
- line: 1,
13323
- column: 1,
13324
- message: `Plugin problem: ${perr}`,
13325
- why: "A configured plugin could not be loaded or declared invalid rules — its checks are silently missing from this scan.",
13326
- fix: "Fix or remove the plugin entry in mjolnir.config.json."
13327
- });
13328
- const ctx = {
13329
- workspace,
13330
- testFiles: [],
13331
- deadline,
13332
- maxFiles: LIMITS$1.maxFilesPerAdapter,
13333
- ignoreMatcher: createIgnoreMatcher(workspace.root),
13334
- onSkippedFile: (reason) => {
13335
- skippedFiles++;
13336
- truncationReasons.add(reason);
13337
- },
13338
- onDiscoveryTruncated: (reason) => {
13339
- discoveryTruncated = true;
13340
- if (!truncationReasons.has(reason)) {
13341
- truncationReasons.add(reason);
13342
- skippedFiles++;
13343
- }
13344
- },
13345
- onRuleCrash: (ruleId, file, error) => {
13346
- rulesCrashed++;
13347
- hooks.onRuleCrash?.(ruleId, file, error);
13348
- },
13349
- onIgnored: () => {
13350
- scopeIgnored++;
13351
- },
13352
- onUnrecognized: () => {
13353
- scopeUnrecognized++;
14174
+ else re += "(?:[^/]+/)*";
14175
+ continue;
13354
14176
  }
13355
- };
14177
+ re += segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", "[^/]*");
14178
+ if (!last) re += "/";
14179
+ }
14180
+ return new RegExp(`${re}$`).test(p);
14181
+ }
14182
+ /**
14183
+ * Plan §16 + WI-11: locate a runtime run report next to the scan
14184
+ * target, using the exact conventions the forensics ingestion already
14185
+ * accepts. Zero-config search over conventional artifact names at
14186
+ * depth ≤ 2 (src/discovery/evidence-discovery.ts). The FIRST parsable
14187
+ * candidate with `totalTests > 0` is kept as a fallback; the loop
14188
+ * continues and returns a later candidate when one with
14189
+ * `analysisComplete === true` is found. Returns the parsed report
14190
+ * alongside the path to avoid double-parsing.
14191
+ */
14192
+ function discoverAndParseRuntimeReport(scanRoot) {
14193
+ let fallback;
14194
+ for (const c of discoverEvidenceCandidates(scanRoot)) try {
14195
+ const fr = runForensics(c.path, { writeFlakyMd: false });
14196
+ if (fr.report.totalTests <= 0) continue;
14197
+ if (!fallback) fallback = {
14198
+ path: c.path,
14199
+ report: fr.report
14200
+ };
14201
+ if (fr.report.analysisComplete === true) return {
14202
+ path: c.path,
14203
+ report: fr.report
14204
+ };
14205
+ } catch {}
14206
+ return fallback;
14207
+ }
14208
+ /**
14209
+ * Audit W10 — runtime shape validation at the rule→Finding boundary.
14210
+ * A finding record coming out of a rule (plugin/JSON-manifest rules are
14211
+ * external data, not trusted internal code) must carry the fields the
14212
+ * whole downstream pipeline indexes on: severity within the enum,
14213
+ * integer line ≥ 1, non-empty message, and a file path. Malformed
14214
+ * records are rejected (routed to the crash/plugin-error channel by the
14215
+ * caller) — never silently scored.
14216
+ */
14217
+ function isValidFindingRecord(f) {
14218
+ if (typeof f !== "object" || f === null) return false;
14219
+ const rec = f;
14220
+ if (rec["severity"] !== "error" && rec["severity"] !== "warning" && rec["severity"] !== "info") return false;
14221
+ if (typeof rec["line"] !== "number" || !Number.isInteger(rec["line"]) || rec["line"] < 1) return false;
14222
+ if (typeof rec["message"] !== "string" || rec["message"] === "") return false;
14223
+ if (typeof rec["file"] !== "string" || rec["file"] === "") return false;
14224
+ return true;
14225
+ }
14226
+ /**
14227
+ * Replace 7-way nested ternary with a linear probe.
14228
+ * Returns the adapter whose `isTestFile` matches the path, falling
14229
+ * back to the TypeScript adapter (the default language).
14230
+ */
14231
+ function selectAdapter(path) {
14232
+ if (githubActionsAdapter.isTestFile(path)) return githubActionsAdapter;
14233
+ if (azurePipelinesAdapter.isTestFile(path)) return azurePipelinesAdapter;
14234
+ if (jenkinsAdapter.isTestFile(path)) return jenkinsAdapter;
14235
+ if (pythonAdapter.isTestFile(path)) return pythonAdapter;
14236
+ if (javaAdapter.isTestFile(path)) return javaAdapter;
14237
+ if (csharpAdapter.isTestFile(path)) return csharpAdapter;
14238
+ return typescriptAdapter;
14239
+ }
14240
+ function discoverTestFilesPhase(workspace, args, hooks, scanRoot, ctx) {
13356
14241
  const languageAdapters = SCAN_ADAPTERS.filter((a) => a.id !== "github-actions" && a.id !== "azure-pipelines" && a.id !== "jenkins");
13357
14242
  const buckets = new Map(languageAdapters.map((a) => [a.id, []]));
13358
14243
  discoverAllTestFiles(ctx, languageAdapters, buckets, /* @__PURE__ */ new Map());
@@ -13391,22 +14276,29 @@ async function runScan(args, hooks = {}) {
13391
14276
  done: ctx.testFiles.length,
13392
14277
  total: ctx.testFiles.length
13393
14278
  });
14279
+ return {
14280
+ testFiles: ctx.testFiles,
14281
+ stagedSurface
14282
+ };
14283
+ }
14284
+ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules, hooks, cache, rulesDigest, deadline, truncationReasons, declarationsByFile, fileProvenance, onRuleCrash) {
14285
+ let skippedFiles = 0;
14286
+ let testFileCount = 0;
14287
+ let testDeclarationCount = 0;
14288
+ let rulesPartial = false;
14289
+ let parseFailed = 0;
13394
14290
  let scanned = 0;
13395
- for (const path of ctx.testFiles) {
14291
+ for (const path of testFiles) {
13396
14292
  if (Date.now() > deadline) {
13397
14293
  rulesPartial = true;
13398
- skippedFiles += ctx.testFiles.length - scanned;
14294
+ skippedFiles += testFiles.length - scanned;
13399
14295
  truncationReasons.add("rule-loop-deadline");
13400
14296
  break;
13401
14297
  }
13402
14298
  scanned++;
13403
- const isWorkflow = githubActionsAdapter.isTestFile(path);
13404
- const isAzurePipeline = azurePipelinesAdapter.isTestFile(path);
13405
- const isJenkinsfile = jenkinsAdapter.isTestFile(path);
13406
- const isPython = pythonAdapter.isTestFile(path);
13407
- const isJava = javaAdapter.isTestFile(path);
13408
- const isCs = csharpAdapter.isTestFile(path);
13409
- if (!isWorkflow && !isAzurePipeline && !isJenkinsfile) testFileCount++;
14299
+ const adapter = selectAdapter(path);
14300
+ const isCiAdapter = adapter.id === "github-actions" || adapter.id === "azure-pipelines" || adapter.id === "jenkins";
14301
+ if (!isCiAdapter) testFileCount++;
13410
14302
  let text;
13411
14303
  try {
13412
14304
  text = readFileSync(path, "utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
@@ -13415,9 +14307,17 @@ async function runScan(args, hooks = {}) {
13415
14307
  continue;
13416
14308
  }
13417
14309
  let fileBudgetExceeded = false;
14310
+ let fileRuleFailed = false;
13418
14311
  const relPath = relative(workspace.root, path).replaceAll("\\", "/");
13419
- if (!isWorkflow && !isAzurePipeline && !isJenkinsfile) {
13420
- const decls = countTestDeclarations(text);
14312
+ if (!isCiAdapter) {
14313
+ const lang = {
14314
+ typescript: "typescript",
14315
+ python: "python",
14316
+ java: "java",
14317
+ csharp: "csharp"
14318
+ }[adapter.id] ?? "typescript";
14319
+ const codeView = getCodeTextForCounting(text, lang);
14320
+ const decls = countTestDeclarations(text, codeView);
13421
14321
  testDeclarationCount += decls;
13422
14322
  declarationsByFile.set(relPath, decls);
13423
14323
  fileProvenance.push({
@@ -13425,7 +14325,6 @@ async function runScan(args, hooks = {}) {
13425
14325
  provenance: classifyProvenance({ text })
13426
14326
  });
13427
14327
  }
13428
- const adapter = isWorkflow ? githubActionsAdapter : isAzurePipeline ? azurePipelinesAdapter : isJenkinsfile ? jenkinsAdapter : isPython ? pythonAdapter : isJava ? javaAdapter : isCs ? csharpAdapter : typescriptAdapter;
13429
14328
  const wantsAst = adapter.parseAst !== void 0 && Date.now() <= deadline;
13430
14329
  const identity = (mode) => ({
13431
14330
  relPath,
@@ -13441,7 +14340,7 @@ async function runScan(args, hooks = {}) {
13441
14340
  hooks.onProgress?.({
13442
14341
  phase: "parse",
13443
14342
  done: scanned,
13444
- total: ctx.testFiles.length,
14343
+ total: testFiles.length,
13445
14344
  detail: relPath
13446
14345
  });
13447
14346
  const parsedFile = {
@@ -13455,7 +14354,7 @@ async function runScan(args, hooks = {}) {
13455
14354
  hooks.onProgress?.({
13456
14355
  phase: "rules",
13457
14356
  done: scanned,
13458
- total: ctx.testFiles.length,
14357
+ total: testFiles.length,
13459
14358
  detail: relPath
13460
14359
  });
13461
14360
  parsed = await adapter.parseAst(parsedFile);
@@ -13475,7 +14374,8 @@ async function runScan(args, hooks = {}) {
13475
14374
  } : parsedFile;
13476
14375
  adapter.runRules(activeRules, fileForRules, (f, ruleId, category) => {
13477
14376
  if (!isValidFindingRecord(f)) {
13478
- ctx.onRuleCrash?.(ruleId, relPath, /* @__PURE__ */ new Error(`malformed finding record rejected (severity/line/message must be present, severity ∈ error|warning|info): ${JSON.stringify(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)}`));
13479
14379
  return;
13480
14380
  }
13481
14381
  findings.push({
@@ -13484,7 +14384,8 @@ async function runScan(args, hooks = {}) {
13484
14384
  category
13485
14385
  });
13486
14386
  }, (ruleId, error) => {
13487
- ctx.onRuleCrash?.(ruleId, relPath, error);
14387
+ fileRuleFailed = true;
14388
+ onRuleCrash?.(ruleId, relPath, error);
13488
14389
  }, {
13489
14390
  deadline: Math.min(deadline, Date.now() + LIMITS$1.maxFileAnalysisMs),
13490
14391
  onExceeded: () => {
@@ -13494,7 +14395,7 @@ async function runScan(args, hooks = {}) {
13494
14395
  fileBudgetExceeded = true;
13495
14396
  }
13496
14397
  });
13497
- cache.store(cacheKey, findings.slice(findingsStart), fileBudgetExceeded);
14398
+ if (!fileRuleFailed) cache.store(cacheKey, findings.slice(findingsStart), fileBudgetExceeded);
13498
14399
  } catch {
13499
14400
  skippedFiles++;
13500
14401
  parseFailed++;
@@ -13502,6 +14403,41 @@ async function runScan(args, hooks = {}) {
13502
14403
  parsed?.dispose();
13503
14404
  }
13504
14405
  }
14406
+ return {
14407
+ skippedFiles,
14408
+ testFileCount,
14409
+ testDeclarationCount,
14410
+ rulesPartial,
14411
+ parseFailed,
14412
+ scanned
14413
+ };
14414
+ }
14415
+ /**
14416
+ * Summarize forensic classifications from an ingested runtime report
14417
+ * (plan §10.4, WAVE 5). Returns undefined when no verdict carries a
14418
+ * classification — the machine contract slot stays absent rather than
14419
+ * reporting all-zero counts.
14420
+ */
14421
+ function summarizeForensicVerdicts(report) {
14422
+ if (report.analysisComplete !== true) return void 0;
14423
+ const byVerdict = {};
14424
+ let classifications = 0;
14425
+ let inconclusive = 0;
14426
+ for (const v of report.verdicts) {
14427
+ if (!v.forensic) continue;
14428
+ classifications++;
14429
+ const label = v.forensic.verdict;
14430
+ byVerdict[label] = (byVerdict[label] ?? 0) + 1;
14431
+ if (label === "inconclusive") inconclusive++;
14432
+ }
14433
+ if (classifications === 0) return void 0;
14434
+ return {
14435
+ classifications,
14436
+ byVerdict,
14437
+ inconclusive
14438
+ };
14439
+ }
14440
+ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, declarationsByFile, testDeclarationCount, tierByRuleId, REVISION_BY_RULE_ID) {
13505
14441
  let scopeInfo = { scope: "all" };
13506
14442
  if (args.scopeChanged) {
13507
14443
  const diff = computeChangedScope(workspace.root, args.base);
@@ -13547,104 +14483,350 @@ async function runScan(args, hooks = {}) {
13547
14483
  }
13548
14484
  enforceTierPolicy(findings, tierByRuleId);
13549
14485
  for (const f of findings) f.fixGroupId = f.ruleId;
13550
- const runtimeReportPath = discoverRuntimeReport(scanRoot.root);
13551
- if (runtimeReportPath) try {
13552
- const fr = runForensics(runtimeReportPath, { writeFlakyMd: false });
13553
- const evidence = buildEvidenceRecords(fr.report, runtimeReportPath);
13554
- stampRuntimeCorroboration(findings, fr.report, evidence);
14486
+ const discoveredReport = discoverAndParseRuntimeReport(scanRoot.root);
14487
+ const runtimeReportPath = discoveredReport?.path;
14488
+ let forensicVerdicts;
14489
+ if (discoveredReport && discoveredReport.report.analysisComplete === true) try {
14490
+ buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
14491
+ stampRuntimeCorroboration(findings, discoveredReport.report, workspace.root);
14492
+ forensicVerdicts = summarizeForensicVerdicts(discoveredReport.report);
13555
14493
  } catch {}
13556
- hooks.onProgress?.({
14494
+ return {
14495
+ testDeclarationCount,
14496
+ scopeInfo,
14497
+ suppressionCount,
14498
+ frameworks,
14499
+ runtimeReportPath,
14500
+ forensicVerdicts,
14501
+ config
14502
+ };
14503
+ }
14504
+ /**
14505
+ * ECO-003: Partition findings by package for monorepo analysis.
14506
+ * Uses the dependency graph to assign findings to their nearest package
14507
+ * manifest, falling back to the workspace root.
14508
+ */
14509
+ function partitionFindingsByPackage(findings, depGraph, workspaceRoot, declarationsByFile, analysisComplete) {
14510
+ const packageMap = /* @__PURE__ */ new Map();
14511
+ const addPackage = (path) => {
14512
+ const entry = {
14513
+ packageName: path === "" ? "root" : basename(path),
14514
+ path: path || ".",
14515
+ findings: [],
14516
+ testDeclarations: 0,
14517
+ testFileCount: 0
14518
+ };
14519
+ packageMap.set(path, entry);
14520
+ return entry;
14521
+ };
14522
+ for (const manifestPath of depGraph.allPaths) {
14523
+ const path = relative(workspaceRoot, dirname(manifestPath)).replaceAll("\\", "/");
14524
+ if (path === ".." || path.startsWith("../")) continue;
14525
+ if (!packageMap.has(path)) addPackage(path);
14526
+ }
14527
+ const packageForFile = (file) => {
14528
+ const normalizedFile = file.replaceAll("\\", "/");
14529
+ let bestPath = "";
14530
+ for (const path of packageMap.keys()) if (path.length > bestPath.length && normalizedFile.startsWith(path + "/")) bestPath = path;
14531
+ return packageMap.get(bestPath) ?? addPackage("");
14532
+ };
14533
+ for (const f of findings) packageForFile(f.file).findings.push(f);
14534
+ for (const [file, declarations] of declarationsByFile) {
14535
+ const target = packageForFile(file);
14536
+ target.testDeclarations += declarations;
14537
+ target.testFileCount++;
14538
+ }
14539
+ return [...packageMap.values()].map((p) => ({
14540
+ packageName: p.packageName,
14541
+ path: p.path,
14542
+ findings: p.findings,
14543
+ score: analysisComplete && p.testDeclarations > 0 && p.testFileCount > 0 ? computeTotal(computeDimensions(p.findings), p.findings, {
14544
+ testDeclarations: p.testDeclarations,
14545
+ testFileCount: p.testFileCount,
14546
+ suiteInvalidatingRuleIds: SUITE_INVALIDATING_RULE_IDS
14547
+ }) : null
14548
+ }));
14549
+ }
14550
+ function assembleScanResult(o) {
14551
+ o.hooks.onProgress?.({
13557
14552
  phase: "score",
13558
- done: findings.length
14553
+ done: o.findings.length
13559
14554
  });
13560
- const dimensions = computeDimensions(findings);
13561
- const rawDeductions = findings.reduce((sum, f) => sum + deductionFor(f), 0);
14555
+ const dimensions = computeDimensions(o.findings);
14556
+ const rawDeductions = o.findings.reduce((sum, f) => sum + deductionFor(f), 0);
13562
14557
  const effectiveDeductions = rawDeductions;
13563
- const total = computeTotal(dimensions, findings, {
13564
- testDeclarations: testDeclarationCount,
13565
- testFileCount,
14558
+ const total = computeTotal(dimensions, o.findings, {
14559
+ testDeclarations: o.testDeclarationCount,
14560
+ testFileCount: o.testFileCount,
13566
14561
  suiteInvalidatingRuleIds: SUITE_INVALIDATING_RULE_IDS
13567
14562
  });
13568
- const elapsed = Date.now() - started;
14563
+ const elapsed = Date.now() - o.started;
13569
14564
  const scopeReasons = [];
13570
- if (scopeIgnored > 0) scopeReasons.push(`ignored:${scopeIgnored}`);
13571
- if (scopeUnrecognized > 0) scopeReasons.push(`unrecognized:${scopeUnrecognized}`);
13572
- if (parseFailed > 0) scopeReasons.push(`parseFailed:${parseFailed}`);
13573
- for (const reason of truncationReasons) scopeReasons.push(`truncated:${reason}`);
14565
+ if (o.scopeIgnored > 0) scopeReasons.push(`ignored:${o.scopeIgnored}`);
14566
+ if (o.scopeUnrecognized > 0) scopeReasons.push(`unrecognized:${o.scopeUnrecognized}`);
14567
+ if (o.parseFailed > 0) scopeReasons.push(`parseFailed:${o.parseFailed}`);
14568
+ for (const reason of o.truncationReasons) scopeReasons.push(`truncated:${reason}`);
13574
14569
  const scopeIntegrity = {
13575
- discovered: ctx.testFiles.length,
13576
- analyzed: Math.max(0, scanned),
13577
- ignored: scopeIgnored,
13578
- unrecognized: scopeUnrecognized,
13579
- parseFailed,
13580
- truncated: truncationReasons.size,
14570
+ discovered: o.testFiles.length,
14571
+ analyzed: Math.max(0, o.scanned),
14572
+ ignored: o.scopeIgnored,
14573
+ unrecognized: o.scopeUnrecognized,
14574
+ parseFailed: o.parseFailed,
14575
+ truncated: o.truncationReasons.size,
13581
14576
  scopeVerdict: scopeReasons.length === 0 ? "PROVEN" : "PARTIAL",
13582
14577
  ...scopeReasons.length > 0 ? { reasons: scopeReasons } : {}
13583
14578
  };
14579
+ const scopeAdjustedTotal = scopeReasons.length > 0 && total >= 100 ? 99 : total;
14580
+ const inputSnapshot = o.testFiles.map((p) => {
14581
+ const relPath = relative(o.workspace.root, p).replaceAll("\\", "/");
14582
+ try {
14583
+ const content = readFileSync(p);
14584
+ const hash = createHash("sha256").update(content).digest("hex").slice(0, 16);
14585
+ return {
14586
+ path: relPath,
14587
+ size: content.length,
14588
+ hash
14589
+ };
14590
+ } catch {
14591
+ return {
14592
+ path: relPath,
14593
+ size: 0
14594
+ };
14595
+ }
14596
+ });
14597
+ let reportDigest;
14598
+ if (o.runtimeReportPath) try {
14599
+ const reportBytes = readFileSync(o.runtimeReportPath);
14600
+ reportDigest = createHash("sha256").update(reportBytes).digest("hex").slice(0, 16);
14601
+ } catch {}
13584
14602
  const runIdentity = buildRunIdentity({
13585
- files: ctx.testFiles.map((p) => {
13586
- try {
13587
- return {
13588
- path: relative(workspace.root, p).replaceAll("\\", "/"),
13589
- size: statSync(p).size
13590
- };
13591
- } catch {
13592
- return {
13593
- path: relative(workspace.root, p).replaceAll("\\", "/"),
13594
- size: 0
13595
- };
13596
- }
13597
- }),
13598
- rules: [...REVISION_BY_RULE_ID.entries()].map(([id, detectorRevision]) => ({
14603
+ files: inputSnapshot,
14604
+ rules: [...o.REVISION_BY_RULE_ID.entries()].map(([id, detectorRevision]) => ({
13599
14605
  id,
13600
14606
  detectorRevision
13601
14607
  })),
13602
- config: null,
13603
- engineVersion: ENGINE_VERSION
14608
+ config: o.config ?? null,
14609
+ engineVersion: ENGINE_VERSION,
14610
+ reportDigest,
14611
+ trustModelVersion: TRUST_MODEL_VERSION,
14612
+ scoringModelVersion: SCORING_MODEL_VERSION,
14613
+ frameworkSupportMatrixVersion: FRAMEWORK_SUPPORT_MATRIX_VERSION,
14614
+ evidenceSchemaVersions: [1]
13604
14615
  });
13605
14616
  const evidenceGraph = buildEvidenceGraph({ runId: runIdentity });
13606
- const hasTests = testFileCount > 0;
14617
+ const hasTests = o.testFileCount > 0 && o.testDeclarationCount > 0;
14618
+ const suiteInvalidatedBy = [...new Set(o.findings.filter((f) => SUITE_INVALIDATING_RULE_IDS.has(f.ruleId)).map((f) => f.ruleId))].sort();
13607
14619
  const result = {
13608
14620
  schemaVersion: 1,
13609
- partial: discoveryTruncated || rulesPartial || skippedFiles > 0,
14621
+ partial: o.discoveryTruncated || o.rulesPartial || o.skippedFiles > 0 || o.rulesCrashed > 0 || o.scopeInfo.degraded !== void 0,
13610
14622
  scopeIntegrity,
13611
14623
  runIdentity,
13612
14624
  evidenceGraph,
13613
- score: hasTests ? total : null,
14625
+ score: hasTests ? scopeAdjustedTotal : null,
13614
14626
  ...hasTests ? {} : { reason: "no-tests-found" },
13615
- frameworks: frameworks.frameworks,
13616
- frameworkDetectionUnknown: frameworks.unknown,
13617
- ...args.scopeChanged ? {
13618
- scope: scopeInfo.scope,
13619
- ...scopeInfo.degraded ? { scopeDegraded: scopeInfo.degraded } : {}
14627
+ frameworks: o.frameworks.frameworks,
14628
+ frameworkDetectionUnknown: o.frameworks.unknown,
14629
+ ...o.args.scopeChanged ? {
14630
+ scope: o.scopeInfo.scope,
14631
+ ...o.scopeInfo.degraded ? { scopeDegraded: o.scopeInfo.degraded } : {}
13620
14632
  } : {},
13621
- ...stagedSurface ? { staged: { files: testFileCount } } : {},
14633
+ ...o.stagedSurface ? { staged: { files: o.testFileCount } } : {},
13622
14634
  dimensions,
13623
- findings,
13624
- testFileCount,
13625
- testDeclarationCount,
14635
+ findings: o.findings,
14636
+ testFileCount: o.testFileCount,
14637
+ testDeclarationCount: o.testDeclarationCount,
13626
14638
  rawDeductions,
13627
14639
  effectiveDeductions,
13628
- suppressionCount,
13629
- ...pluginsLoaded.length > 0 ? { plugins: pluginsLoaded } : {},
13630
- agenticProfile: computeAgenticProfile(fileProvenance, findings),
13631
- ...args.cache ? { cache: {
13632
- hits: cache.stats.hits,
13633
- misses: cache.stats.misses,
13634
- file: cache.stats.file
14640
+ suppressionCount: o.suppressionCount,
14641
+ ...o.pluginsLoaded.length > 0 ? { plugins: o.pluginsLoaded } : {},
14642
+ ...suiteInvalidatedBy.length > 0 ? { suiteInvalidatedBy } : {},
14643
+ agenticProfile: computeAgenticProfile(o.fileProvenance, o.findings),
14644
+ ...o.forensicVerdicts !== void 0 ? { forensicVerdicts: o.forensicVerdicts } : {},
14645
+ ...o.args.cache ? { cache: {
14646
+ hits: o.cache.stats.hits,
14647
+ misses: o.cache.stats.misses,
14648
+ file: o.cache.stats.file
13635
14649
  } } : {},
13636
14650
  analysisStatus: {
13637
- discovery: discoveryTruncated ? "partial" : "complete",
13638
- rules: rulesPartial ? "partial" : "complete",
13639
- skippedFiles,
14651
+ discovery: o.discoveryTruncated ? "partial" : "complete",
14652
+ rules: o.rulesPartial ? "partial" : "complete",
14653
+ skippedFiles: o.skippedFiles,
13640
14654
  durationMs: elapsed,
13641
- rulesCrashed,
13642
- ...truncationReasons.size > 0 ? { truncationReasons: [...truncationReasons].sort() } : {}
13643
- }
14655
+ rulesCrashed: o.rulesCrashed,
14656
+ ...o.truncationReasons.size > 0 ? { truncationReasons: [...o.truncationReasons].sort() } : {}
14657
+ },
14658
+ scoringModelVersion: SCORING_MODEL_VERSION
13644
14659
  };
13645
- result.trustSummary = buildTrustSummary(result, declarationsByFile);
13646
- cache.persist();
14660
+ result.trustSummary = buildTrustSummary(result, o.declarationsByFile);
14661
+ if (o.findings.length > 0) result.correlationConclusions = correlateFindings(o.findings);
14662
+ if (o.dependencyGraph && o.dependencyGraph.size > 0) {
14663
+ let edges = 0;
14664
+ for (const p of o.dependencyGraph.allPaths) edges += o.dependencyGraph.getDependencies(p).length;
14665
+ result.dependencyGraph = {
14666
+ nodes: o.dependencyGraph.size,
14667
+ edges
14668
+ };
14669
+ }
14670
+ if (o.args.monorepo && o.dependencyGraph && o.dependencyGraph.size > 1) {
14671
+ const packages = partitionFindingsByPackage(o.findings, o.dependencyGraph, o.workspace.root, o.declarationsByFile, !result.partial && scopeReasons.length === 0 && !o.args.scopeChanged);
14672
+ if (packages.length > 1) {
14673
+ const monorepoResult = analyzeMonorepo(packages, { weightingStrategy: "worst-package" });
14674
+ result.monorepoAnalysis = {
14675
+ packages: monorepoResult.packages.map((p) => ({
14676
+ packageName: p.packageName,
14677
+ path: p.path,
14678
+ findings: p.findings.length,
14679
+ score: p.score,
14680
+ verdict: p.verdict
14681
+ })),
14682
+ overallScore: monorepoResult.overallScore,
14683
+ overallVerdict: monorepoResult.overallVerdict,
14684
+ strategy: monorepoResult.strategy,
14685
+ ...monorepoResult.blockerPackage ? { blockerPackage: monorepoResult.blockerPackage } : {}
14686
+ };
14687
+ }
14688
+ }
14689
+ o.cache.persist();
14690
+ return result;
14691
+ }
14692
+ /**
14693
+ * Testable default scan path core. `hooks` lets callers observe
14694
+ * normally-invisible events (swallowed rule crashes) without changing
14695
+ * the ScanResult contract beyond the rulesCrashed counter.
14696
+ *
14697
+ * Async since the Verification Trust Evolution Plan Phase 0.5 (§10): the
14698
+ * per-file loop awaits the adapter parse stage (WASM grammar load is
14699
+ * inherently async); `runRules` and every rule stay synchronous and
14700
+ * consume `ParsedFile.ast`. Callers await the returned promise.
14701
+ */
14702
+ async function runScan(args, hooks = {}) {
14703
+ const started = Date.now();
14704
+ const deadline = started + args.maxDurationMs;
14705
+ const discovered = discoverWorkspace(args.target);
14706
+ const targetAbs = resolve(args.target);
14707
+ const scanRoot = discovered && discovered.root !== targetAbs && targetAbs.startsWith(discovered.root + sep) ? {
14708
+ ...discovered,
14709
+ root: targetAbs
14710
+ } : discovered ?? fallbackWorkspace(targetAbs);
14711
+ const workspace = scanRoot;
14712
+ if (args.verbose) hooks.onConfigWarning?.(`verbose: scan root (config/ignore anchor): ${workspace.root}`);
14713
+ const findings = [];
14714
+ let skippedFiles = 0;
14715
+ let testFileCount = 0;
14716
+ let testDeclarationCount = 0;
14717
+ const declarationsByFile = /* @__PURE__ */ new Map();
14718
+ let rulesCrashed = 0;
14719
+ const truncationReasons = /* @__PURE__ */ new Set();
14720
+ let discoveryTruncated = false;
14721
+ let rulesPartial = false;
14722
+ let scopeIgnored = 0;
14723
+ let scopeUnrecognized = 0;
14724
+ let parseFailed = 0;
14725
+ const fileProvenance = [];
14726
+ const { rules: activeRules, pluginErrors, tierByRuleId: tiers, pluginMeta } = await buildUniversalRules(workspace.root, args.strict, {
14727
+ ...args.enablePlugins !== void 0 ? { enablePlugins: args.enablePlugins } : {},
14728
+ onGateNotice: (notice) => hooks.onGateNotice ? hooks.onGateNotice(notice) : console.error(notice)
14729
+ });
14730
+ const tierByRuleId = tiers;
14731
+ const pluginsLoaded = pluginMeta;
14732
+ const REVISION_BY_RULE_ID = new Map(activeRules.map((r) => [r.id, r.detectorRevision ?? MEASURED_FP[r.id]?.detectorRevision ?? 1]));
14733
+ const cache = args.cache ? createScanCache(workspace.root) : disabledScanCache;
14734
+ const depGraph = buildDependencyGraph(workspace.root);
14735
+ const rulesDigest = `complete-file-v1:${computeRulesDigest(activeRules)}`;
14736
+ if (args.cache && args.verbose) {
14737
+ const safety = isIncrementalSafe([]);
14738
+ if (!safety.safe) hooks.onConfigWarning?.(`incremental: ${safety.reasons.join("; ")}`);
14739
+ }
14740
+ for (const perr of pluginErrors) findings.push({
14741
+ ruleId: "QA-PLUGIN-000",
14742
+ category: "QA-PW",
14743
+ severity: "warning",
14744
+ confidence: "high",
14745
+ findingType: "deterministic-defect",
14746
+ qaImpact: "HYGIENE",
14747
+ evidenceLevel: "E2",
14748
+ file: "mjolnir.config.json",
14749
+ line: 1,
14750
+ column: 1,
14751
+ message: `Plugin problem: ${perr}`,
14752
+ why: "A configured plugin could not be loaded or declared invalid rules — its checks are silently missing from this scan.",
14753
+ fix: "Fix or remove the plugin entry in mjolnir.config.json."
14754
+ });
14755
+ const { testFiles, stagedSurface } = discoverTestFilesPhase(workspace, args, hooks, scanRoot, {
14756
+ workspace,
14757
+ testFiles: [],
14758
+ deadline,
14759
+ maxFiles: LIMITS$1.maxFilesPerAdapter,
14760
+ ignoreMatcher: createIgnoreMatcher(workspace.root),
14761
+ onSkippedFile: (reason) => {
14762
+ skippedFiles++;
14763
+ truncationReasons.add(reason);
14764
+ },
14765
+ onDiscoveryTruncated: (reason) => {
14766
+ discoveryTruncated = true;
14767
+ if (!truncationReasons.has(reason)) {
14768
+ truncationReasons.add(reason);
14769
+ skippedFiles++;
14770
+ }
14771
+ },
14772
+ onRuleCrash: (ruleId, file, error) => {
14773
+ rulesCrashed++;
14774
+ hooks.onRuleCrash?.(ruleId, file, error);
14775
+ },
14776
+ onIgnored: () => {
14777
+ scopeIgnored++;
14778
+ },
14779
+ onUnrecognized: () => {
14780
+ scopeUnrecognized++;
14781
+ }
14782
+ });
14783
+ const analysis = await runFileAnalysisPhase(findings, testFiles, workspace, activeRules, hooks, cache, rulesDigest, deadline, truncationReasons, declarationsByFile, fileProvenance, (ruleId, file, error) => {
14784
+ rulesCrashed++;
14785
+ hooks.onRuleCrash?.(ruleId, file, error);
14786
+ });
14787
+ skippedFiles += analysis.skippedFiles;
14788
+ testFileCount = analysis.testFileCount;
14789
+ testDeclarationCount = analysis.testDeclarationCount;
14790
+ rulesPartial = analysis.rulesPartial;
14791
+ parseFailed = analysis.parseFailed;
14792
+ const scanned = analysis.scanned;
14793
+ const postScan = applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, declarationsByFile, testDeclarationCount, tierByRuleId, REVISION_BY_RULE_ID);
14794
+ testDeclarationCount = postScan.testDeclarationCount;
14795
+ const result = assembleScanResult({
14796
+ findings,
14797
+ testFileCount,
14798
+ testDeclarationCount,
14799
+ declarationsByFile,
14800
+ skippedFiles,
14801
+ rulesCrashed,
14802
+ truncationReasons,
14803
+ discoveryTruncated,
14804
+ rulesPartial,
14805
+ scopeIgnored,
14806
+ scopeUnrecognized,
14807
+ parseFailed,
14808
+ scanned,
14809
+ testFiles,
14810
+ workspace,
14811
+ scanRoot,
14812
+ args,
14813
+ hooks,
14814
+ cache,
14815
+ REVISION_BY_RULE_ID,
14816
+ pluginsLoaded,
14817
+ scopeInfo: postScan.scopeInfo,
14818
+ suppressionCount: postScan.suppressionCount,
14819
+ frameworks: postScan.frameworks,
14820
+ runtimeReportPath: postScan.runtimeReportPath,
14821
+ forensicVerdicts: postScan.forensicVerdicts,
14822
+ config: postScan.config,
14823
+ fileProvenance,
14824
+ started,
14825
+ stagedSurface,
14826
+ dependencyGraph: depGraph
14827
+ });
13647
14828
  await releaseTreeSitterResources();
14829
+ resetTsMorphProject();
13648
14830
  return result;
13649
14831
  }
13650
14832
  /**
@@ -13693,10 +14875,14 @@ function canonicalScanJson(result) {
13693
14875
  });
13694
14876
  }
13695
14877
  /** Advisory when the DERIVED evidence level is E0 (same rule as scoring). */
14878
+ function effectiveEvidenceLevel(f) {
14879
+ return f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence);
14880
+ }
13696
14881
  function isAdvisory(f) {
13697
- return (f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence)) === "E0";
14882
+ return effectiveEvidenceLevel(f) === "E0";
13698
14883
  }
13699
- function levelFor(f) {
14884
+ function levelFor(f, evidenceLevel = effectiveEvidenceLevel(f)) {
14885
+ if (evidenceLevel === "E0") return "notice";
13700
14886
  if (f.severity === "error") return "failure";
13701
14887
  if (f.severity === "warning") return "warning";
13702
14888
  return "notice";
@@ -13710,15 +14896,18 @@ function countBy(findings, predicate) {
13710
14896
  */
13711
14897
  function buildMachineContract(result) {
13712
14898
  const digest = createHash("sha256").update(canonicalScanJson(result)).digest("hex");
13713
- const annotations = result.findings.map((f) => ({
13714
- path: f.file,
13715
- start_line: f.line,
13716
- annotation_level: levelFor(f),
13717
- message: `${f.ruleId}: ${f.message}`,
13718
- ruleId: f.ruleId,
13719
- ...f.detectorRevision !== void 0 ? { detectorRevision: f.detectorRevision } : {},
13720
- advisory: isAdvisory(f)
13721
- }));
14899
+ const annotations = result.findings.map((f) => {
14900
+ const evidenceLevel = effectiveEvidenceLevel(f);
14901
+ return {
14902
+ path: f.file,
14903
+ start_line: f.line,
14904
+ annotation_level: levelFor(f, evidenceLevel),
14905
+ message: `${f.ruleId}: ${f.message}`,
14906
+ ruleId: f.ruleId,
14907
+ ...f.detectorRevision !== void 0 ? { detectorRevision: f.detectorRevision } : {},
14908
+ advisory: evidenceLevel === "E0"
14909
+ };
14910
+ });
13722
14911
  return {
13723
14912
  contractVersion: 1,
13724
14913
  summary: {
@@ -13743,7 +14932,8 @@ function buildMachineContract(result) {
13743
14932
  durationMs: result.analysisStatus.durationMs
13744
14933
  },
13745
14934
  ...result.trustSummary !== void 0 ? { trustSummary: result.trustSummary } : {},
13746
- ...result.agenticProfile !== void 0 ? { provenance: result.agenticProfile } : {}
14935
+ ...result.agenticProfile !== void 0 ? { provenance: result.agenticProfile } : {},
14936
+ ...result.forensicVerdicts !== void 0 ? { forensicVerdicts: result.forensicVerdicts } : {}
13747
14937
  };
13748
14938
  }
13749
14939
  //#endregion
@@ -13838,11 +15028,19 @@ function explainRule(ruleId, fixturesRoot) {
13838
15028
  };
13839
15029
  }
13840
15030
  //#endregion
15031
+ //#region src/engine/finding-identity.ts
15032
+ /**
15033
+ * Correlation identity for before/after comparison: ruleId + file +
15034
+ * message, deliberately EXCLUDING line — a source edit that shifts a
15035
+ * finding still correlates. Shared by baseline, impact, and resolution.
15036
+ */
15037
+ function findingFingerprint(f) {
15038
+ return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
15039
+ }
15040
+ //#endregion
13841
15041
  //#region src/engine/resolution.ts
13842
15042
  /** Correlation identity (v1-compatible): ruleId\0file\0message. */
13843
- function fingerprint$1(entry) {
13844
- return `${entry.ruleId}\u0000${entry.file}\u0000${entry.message}`;
13845
- }
15043
+ const fingerprint$1 = findingFingerprint;
13846
15044
  /**
13847
15045
  * The §15 ordered algorithm. Pure; first match wins.
13848
15046
  */
@@ -13917,11 +15115,9 @@ const REGISTRY_REVISIONS = new Map(RULES.map((r) => [r.id, r.detectorRevision ??
13917
15115
  * occurrence location, not a durable identity; message rewording,
13918
15116
  * file renames and rule-id changes correlate as resolved+new
13919
15117
  * (documented limitation). Exported for the handoff verification
13920
- * contract — do not duplicate this algorithm.
15118
+ * contract — re-exported from engine/finding-identity.js (ENGINE-002).
13921
15119
  */
13922
- function fingerprint(f) {
13923
- return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
13924
- }
15120
+ const fingerprint = findingFingerprint;
13925
15121
  function loadBaseline(path, onWarning) {
13926
15122
  if (!existsSync(path)) return null;
13927
15123
  try {
@@ -14098,7 +15294,7 @@ function nextActionFor(v) {
14098
15294
  switch (classifyVerdict(v)) {
14099
15295
  case "RETRY-DEPENDENT": return `repeat execution of this test (3+ runs) to confirm the flake rate, then quarantine + ticket: ${v.file}`;
14100
15296
  case "TIMEOUT": return `re-run in isolation to separate slowness from a hang: npx playwright test ${v.file} --timeout 60000`;
14101
- case "FAILING": return `reproduce locally: npx playwright test ${v.file} -g "${v.title.replace(/"/g, "\\\"")}" — then fix and re-run`;
15297
+ case "FAILING": return `reproduce locally: npx playwright test ${v.file} -g "${v.title.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}" — then fix and re-run`;
14102
15298
  case "SKIPPED": return `inspect the skip condition in ${v.file} — a skip after a failure usually hides an environment problem`;
14103
15299
  }
14104
15300
  }
@@ -14127,6 +15323,9 @@ function renderTriageWorkflowJson(report) {
14127
15323
  artifact: "mjolnir-triage-workflow",
14128
15324
  source: report.source,
14129
15325
  totalTests: report.totalTests,
15326
+ analysisComplete: report.analysisComplete,
15327
+ skippedReports: report.skippedReports,
15328
+ incompleteReasons: report.incompleteReasons,
14130
15329
  rows: workflowRows(report)
14131
15330
  }, null, 2) + "\n";
14132
15331
  }
@@ -14225,10 +15424,16 @@ const MCP_TOOLS = [
14225
15424
  description: "Explain one rule: what it detects, why it matters, how to fix it, plus its measured FP rate and trust metadata. Read-only; never runs a scan.",
14226
15425
  inputSchema: {
14227
15426
  type: "object",
14228
- properties: { ruleId: {
14229
- type: "string",
14230
- description: "e.g. QA-PW-101"
14231
- } },
15427
+ properties: {
15428
+ ruleId: {
15429
+ type: "string",
15430
+ description: "e.g. QA-PW-101"
15431
+ },
15432
+ fixturesRoot: {
15433
+ type: "string",
15434
+ description: "Root directory for fixture lookups (defaults to cwd)."
15435
+ }
15436
+ },
14232
15437
  required: ["ruleId"]
14233
15438
  }
14234
15439
  },
@@ -14380,7 +15585,16 @@ async function handleToolCall(call) {
14380
15585
  }
14381
15586
  if (call.name === "explain") {
14382
15587
  const ruleId = call.args["ruleId"];
14383
- const explanation = explainRule(ruleId, process.cwd());
15588
+ const rawFixturesRoot = call.args["fixturesRoot"];
15589
+ if (rawFixturesRoot !== void 0 && typeof rawFixturesRoot !== "string") return {
15590
+ jsonrpc: "2.0",
15591
+ id: call.id,
15592
+ error: {
15593
+ code: MCP_ERRORS.INVALID_PARAMS,
15594
+ message: "fixturesRoot must be a string"
15595
+ }
15596
+ };
15597
+ const explanation = explainRule(ruleId, (typeof rawFixturesRoot === "string" ? rawFixturesRoot : void 0) ?? process.cwd());
14384
15598
  if (!explanation.ok) return {
14385
15599
  jsonrpc: "2.0",
14386
15600
  id: call.id,
@@ -14525,13 +15739,13 @@ async function handleToolCall(call) {
14525
15739
  id: call.id,
14526
15740
  error: {
14527
15741
  code: MCP_ERRORS.INTERNAL,
14528
- message: errorMessage(err)
15742
+ message: errorMessage$1(err)
14529
15743
  }
14530
15744
  };
14531
15745
  }
14532
15746
  }
14533
15747
  /** Normalize any thrown value to a string (unit-tested both arms). */
14534
- function errorMessage(err) {
15748
+ function errorMessage$1(err) {
14535
15749
  return err instanceof Error ? err.message : String(err);
14536
15750
  }
14537
15751
  /** JSON-RPC request shape validation (strict — §21). */
@@ -14636,6 +15850,14 @@ async function handleMcpMessage(msg) {
14636
15850
  };
14637
15851
  }
14638
15852
  //#endregion
15853
+ //#region src/cli-io.ts
15854
+ /**
15855
+ * The single error-to-message derivation shared by every catch site:
15856
+ * Error instances render their message; anything else is stringified
15857
+ * honestly (a hostile non-Error throw must surface, not vanish).
15858
+ */
15859
+ const errorMessage = (err) => err instanceof Error ? err.message : String(err);
15860
+ //#endregion
14639
15861
  //#region src/mcp/transport.ts
14640
15862
  /**
14641
15863
  * MCP stdio transport loop (blueprint §21 — the framing around the
@@ -14648,12 +15870,52 @@ async function handleMcpMessage(msg) {
14648
15870
  * (stdio.ts) exits 0 after it.
14649
15871
  */
14650
15872
  /**
15873
+ * Extract and validate a JSON-RPC request ID. Returns null if absent or
15874
+ * invalid (JSON-RPC ID must be a string, number, or null — not an object).
15875
+ */
15876
+ function normalizeJsonRpcId(msg) {
15877
+ if (typeof msg !== "object" || msg === null) return null;
15878
+ const id = msg["id"];
15879
+ if (typeof id === "string" || typeof id === "number") return id;
15880
+ return null;
15881
+ }
15882
+ /**
14651
15883
  * Drive the transport over arbitrary streams (real stdio in the binary,
14652
15884
  * PassThrough in tests). Resolves on input EOF.
14653
15885
  */
14654
15886
  async function runStdioTransport(input, output) {
14655
15887
  const rl = createInterface({ input });
14656
- for await (const line of rl) await handleStdioLine(line, (s) => output.write(s));
15888
+ let outputFailed = false;
15889
+ const onOutputError = () => {
15890
+ outputFailed = true;
15891
+ rl.close();
15892
+ };
15893
+ output.once("error", onOutputError);
15894
+ try {
15895
+ for await (const line of rl) {
15896
+ if (outputFailed) break;
15897
+ try {
15898
+ await handleStdioLine(line, (s) => {
15899
+ if (!outputFailed) output.write(s);
15900
+ });
15901
+ } catch {
15902
+ try {
15903
+ output.write(JSON.stringify({
15904
+ jsonrpc: "2.0",
15905
+ id: null,
15906
+ error: {
15907
+ code: -32603,
15908
+ message: "internal transport error"
15909
+ }
15910
+ }) + "\n");
15911
+ } catch {
15912
+ break;
15913
+ }
15914
+ }
15915
+ }
15916
+ } finally {
15917
+ output.removeListener("error", onOutputError);
15918
+ }
14657
15919
  }
14658
15920
  /**
14659
15921
  * Process one newline-delimited JSON-RPC frame; write the response to
@@ -14677,7 +15939,20 @@ async function handleStdioLine(line, write) {
14677
15939
  })}\n`);
14678
15940
  return;
14679
15941
  }
14680
- const response = await handleMcpMessage(parsed);
15942
+ let response;
15943
+ try {
15944
+ response = await handleMcpMessage(parsed);
15945
+ if (response) response.id = normalizeJsonRpcId(parsed);
15946
+ } catch (err) {
15947
+ response = {
15948
+ jsonrpc: "2.0",
15949
+ id: normalizeJsonRpcId(parsed),
15950
+ error: {
15951
+ code: -32603,
15952
+ message: errorMessage(err)
15953
+ }
15954
+ };
15955
+ }
14681
15956
  write(`${JSON.stringify(response)}\n`);
14682
15957
  }
14683
15958
  //#endregion
@@ -14699,6 +15974,9 @@ async function handleStdioLine(line, write) {
14699
15974
  * subprocess coverage cannot merge into the parent istanbul report,
14700
15975
  * same class as dist/**), so it is excluded from the coverage ratchet.
14701
15976
  */
14702
- runStdioTransport(process.stdin, process.stdout).then(() => process.exit(0));
15977
+ runStdioTransport(process.stdin, process.stdout).then(() => process.exit(0)).catch((err) => {
15978
+ process.stderr.write(`mjolnir mcp fatal: ${err instanceof Error ? err.message : err}\n`);
15979
+ process.exitCode = 20;
15980
+ });
14703
15981
  //#endregion
14704
15982
  export {};