mjolnir-qa 1.0.9 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,7 +109,7 @@ 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.everFailed || matched.finalStatus === "timedOut")) corroboration.level = "defect";
176
113
  f.runtimeCorroboration = corroboration;
177
114
  f.trustLevel = deriveTrustLevel(f, corroboration);
178
115
  corroborated++;
@@ -180,12 +117,24 @@ function stampRuntimeCorroboration(findings, report, evidence) {
180
117
  return corroborated;
181
118
  }
182
119
  /**
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.)
120
+ * The test whose declaration span contains `line`. Playwright JSON
121
+ * verdicts carry the spec's declaration line: the containing test is
122
+ * the one with the greatest declaration line ≤ the finding's line in
123
+ * the same file (specs are flat within a file). When the report cannot
124
+ * place lines (JUnit, or some verdicts lack them) the only HONEST
125
+ * claim is file-level corroboration — plus the unambiguous
126
+ * single-test-file case. Claiming a specific test without range
127
+ * knowledge would fabricate precision the report does not carry.
188
128
  */
129
+ function findContainingTest(verdicts, line) {
130
+ if (verdicts.length === 1) return verdicts[0];
131
+ if (verdicts.some((v) => v.line === void 0)) return void 0;
132
+ const sorted = [...verdicts].sort((a, b) => (a.line ?? 0) - (b.line ?? 0));
133
+ let match;
134
+ for (const v of sorted) if ((v.line ?? 0) <= line) match = v;
135
+ else break;
136
+ return match;
137
+ }
189
138
  /**
190
139
  * The deterministic L0–L5 derivation (see TRUST_ORDER). Static base
191
140
  * from the E-ladder; runtime evidence upgrades, never downgrades.
@@ -193,17 +142,8 @@ function stampRuntimeCorroboration(findings, report, evidence) {
193
142
  * produce L4/L5 for a static-only finding.
194
143
  */
195
144
  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
- }
145
+ const level = finding.evidenceLevel ?? (finding.findingType === "observation" ? "E0" : finding.findingType === "heuristic-risk" ? "E1" : finding.confidence === "low" ? "E1" : "E2");
146
+ if (!corroboration) return level === "E0" ? "L0" : level === "E1" ? "L1" : "L2";
207
147
  if (corroboration.level === "defect") return "L5";
208
148
  if (corroboration.level === "test") return "L4";
209
149
  return "L3";
@@ -417,7 +357,7 @@ const MEASURED_FP = {
417
357
  "QA-PW-004": {
418
358
  fpRate: .429,
419
359
  n: 14,
420
- detectorRevision: 1,
360
+ detectorRevision: 2,
421
361
  ciLow: .2138,
422
362
  ciHigh: .6741
423
363
  },
@@ -564,7 +504,7 @@ const MEASURED_FP = {
564
504
  "QA-PY-003": {
565
505
  fpRate: .467,
566
506
  n: 30,
567
- detectorRevision: 3,
507
+ detectorRevision: 4,
568
508
  ciLow: .3023,
569
509
  ciHigh: .6386
570
510
  },
@@ -895,21 +835,33 @@ function canonical(value) {
895
835
  return JSON.stringify(value);
896
836
  }
897
837
  function buildRunIdentity(input) {
898
- const inputFingerprint = sha256$1([...input.files].map((f) => `${f.path}:${f.size}`).sort().join("\n"));
838
+ const inputFingerprint = sha256$1([...input.files].map((f) => f.hash ? `${f.path}:${f.hash}` : `${f.path}:${f.size}`).sort().join("\n"));
899
839
  const rulesDigest = sha256$1([...input.rules].map((r) => `${r.id}:${r.detectorRevision}`).sort().join("\n"));
900
840
  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
- })),
841
+ const verdictInputs = {
908
842
  inputFingerprint,
909
843
  rulesDigest,
910
844
  configFingerprint,
911
845
  engineVersion: input.engineVersion
912
846
  };
847
+ if (input.reportDigest) verdictInputs.reportDigest = input.reportDigest;
848
+ if (input.trustModelVersion !== void 0) verdictInputs.trustModelVersion = input.trustModelVersion;
849
+ if (input.scoringModelVersion !== void 0) verdictInputs.scoringModelVersion = input.scoringModelVersion;
850
+ if (input.frameworkSupportMatrixVersion !== void 0) verdictInputs.frameworkSupportMatrixVersion = input.frameworkSupportMatrixVersion;
851
+ if (input.evidenceSchemaVersions !== void 0) verdictInputs.evidenceSchemaVersions = [...input.evidenceSchemaVersions].sort((a, b) => a - b);
852
+ if (input.suppressionFingerprint !== void 0) verdictInputs.suppressionFingerprint = input.suppressionFingerprint;
853
+ if (input.policyFingerprint !== void 0) verdictInputs.policyFingerprint = input.policyFingerprint;
854
+ if (input.historicalEvidenceFingerprint !== void 0) verdictInputs.historicalEvidenceFingerprint = input.historicalEvidenceFingerprint;
855
+ return {
856
+ scanId: sha256$1(canonical(verdictInputs)),
857
+ inputFingerprint,
858
+ rulesDigest,
859
+ configFingerprint,
860
+ engineVersion: input.engineVersion,
861
+ ...input.trustModelVersion !== void 0 ? { trustModelVersion: input.trustModelVersion } : {},
862
+ ...input.scoringModelVersion !== void 0 ? { scoringModelVersion: input.scoringModelVersion } : {},
863
+ ...input.frameworkSupportMatrixVersion !== void 0 ? { frameworkSupportMatrixVersion: input.frameworkSupportMatrixVersion } : {}
864
+ };
913
865
  }
914
866
  /**
915
867
  * Builds the Evidence Graph for a run. No fabrication: a link's `ref`
@@ -962,7 +914,20 @@ function buildEvidenceGraph(parts) {
962
914
  * scripts/sync-sarif-version.cjs and guarded by the version-consistency
963
915
  * spec. cli.ts re-exports this as CLI_VERSION.
964
916
  */
965
- const ENGINE_VERSION = "1.0.9";
917
+ const ENGINE_VERSION = "2.0.0";
918
+ //#endregion
919
+ //#region src/engine/contract-versions.ts
920
+ /**
921
+ * Versioned contract registry (VERSION-001).
922
+ *
923
+ * Single source of truth for every version constant the engine exposes
924
+ * to consumers (run identity, evidence graph, machine contract).
925
+ * Constants are `as const` — literal types flow into the identity
926
+ * surface without widening.
927
+ */
928
+ const TRUST_MODEL_VERSION = "1.0.0";
929
+ const SCORING_MODEL_VERSION = "1.0.0";
930
+ const FRAMEWORK_SUPPORT_MATRIX_VERSION = "1.0.0";
966
931
  //#endregion
967
932
  //#region src/discovery/evidence-discovery.ts
968
933
  /**
@@ -1020,7 +985,7 @@ const SKIP_DIRS = /* @__PURE__ */ new Set([
1020
985
  ]);
1021
986
  function listDirs(dir) {
1022
987
  try {
1023
- return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !SKIP_DIRS.has(e.name)).map((e) => join(dir, e.name));
988
+ return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !SKIP_DIRS.has(e.name)).map((e) => join(dir, e.name)).sort();
1024
989
  } catch {
1025
990
  return [];
1026
991
  }
@@ -1041,14 +1006,15 @@ function discoverEvidenceCandidates(scanRoot) {
1041
1006
  const st = statSync(p);
1042
1007
  if (conv.kind === "dir" ? st.isDirectory() : st.isFile()) out.push({
1043
1008
  path: p,
1044
- convention: conv.convention
1009
+ convention: conv.convention,
1010
+ depth
1045
1011
  });
1046
1012
  }
1047
1013
  if (depth < SEARCH_DEPTH) next.push(...listDirs(dir));
1048
1014
  }
1049
1015
  levels.push(next);
1050
1016
  }
1051
- return out.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
1017
+ return out.sort((a, b) => a.depth - b.depth || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
1052
1018
  }
1053
1019
  //#endregion
1054
1020
  //#region src/discovery/workspace.ts
@@ -1057,10 +1023,17 @@ function discoverEvidenceCandidates(scanRoot) {
1057
1023
  * Finds project root, parses package.json, detects npm/yarn/pnpm workspaces.
1058
1024
  * Monorepo depth beyond workspaces is a documented launch cut (§29.1).
1059
1025
  */
1026
+ const PROJECT_MARKERS = [
1027
+ "package.json",
1028
+ "pyproject.toml",
1029
+ "pom.xml",
1030
+ "build.gradle",
1031
+ "build.gradle.kts"
1032
+ ];
1060
1033
  function findProjectRoot(startDir) {
1061
1034
  let dir = resolve(startDir);
1062
1035
  while (true) {
1063
- if (existsSync(join(dir, "package.json"))) return dir;
1036
+ for (const marker of PROJECT_MARKERS) if (existsSync(join(dir, marker))) return dir;
1064
1037
  const parent = dirname(dir);
1065
1038
  if (parent === dir) return null;
1066
1039
  dir = parent;
@@ -1089,6 +1062,242 @@ function basename(p) {
1089
1062
  return parts[parts.length - 1];
1090
1063
  }
1091
1064
  //#endregion
1065
+ //#region src/lib/safe-json.ts
1066
+ /**
1067
+ * Reusable safe-JSON parse-and-validate helper.
1068
+ *
1069
+ * Replaces the `JSON.parse(text) as T` pattern with a two-step process:
1070
+ * 1. Try/catch around JSON.parse (descriptive error on bad JSON)
1071
+ * 2. Runtime validation of the parsed shape (caller-supplied predicate)
1072
+ *
1073
+ * The `validate` predicate is intentionally lightweight — it checks the
1074
+ * expected shape exists rather than exhaustively type-guarding every field.
1075
+ * Internal files (cache, hashes) use `isRecord`; user-facing files
1076
+ * (config, plugins) use stricter checks that throw descriptive errors.
1077
+ */
1078
+ function parseJsonFile(text, source, validate) {
1079
+ let parsed;
1080
+ try {
1081
+ parsed = JSON.parse(text);
1082
+ } catch (err) {
1083
+ throw new Error(`invalid JSON in ${source}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
1084
+ }
1085
+ if (!validate(parsed)) throw new Error(`unexpected shape in ${source}`, { cause: parsed });
1086
+ return parsed;
1087
+ }
1088
+ /**
1089
+ * Minimal validator: value is a plain object (not null, not array).
1090
+ * Sufficient for internal files (cache, hashes) where downstream
1091
+ * code already checks individual fields.
1092
+ */
1093
+ function isRecord(v) {
1094
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1095
+ }
1096
+ //#endregion
1097
+ //#region src/config/config-schema.ts
1098
+ const VALID_GATES = /* @__PURE__ */ new Set([
1099
+ "advisory",
1100
+ "error",
1101
+ "warning"
1102
+ ]);
1103
+ const VALID_SEVERITIES = /* @__PURE__ */ new Set([
1104
+ "error",
1105
+ "warning",
1106
+ "info"
1107
+ ]);
1108
+ /**
1109
+ * Validates a config object against the structural schema.
1110
+ * Returns { valid: true } when the config is structurally sound,
1111
+ * or { valid: false, errors } listing each violation.
1112
+ */
1113
+ function validateConfigSchema(config) {
1114
+ const errors = [];
1115
+ if (config === null || typeof config !== "object" || Array.isArray(config)) return {
1116
+ valid: false,
1117
+ errors: ["config must be a non-null object"]
1118
+ };
1119
+ const cfg = config;
1120
+ if (cfg["gate"] !== void 0) {
1121
+ if (typeof cfg["gate"] !== "string") errors.push(`gate must be a string, got ${typeof cfg["gate"]}`);
1122
+ else if (!VALID_GATES.has(cfg["gate"])) errors.push(`gate must be one of advisory|error|warning, got "${cfg["gate"]}"`);
1123
+ }
1124
+ if (cfg["exclude"] !== void 0) {
1125
+ if (!Array.isArray(cfg["exclude"])) errors.push(`exclude must be an array, got ${typeof cfg["exclude"]}`);
1126
+ 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]}`);
1127
+ }
1128
+ if (cfg["severityOverrides"] !== void 0) {
1129
+ if (typeof cfg["severityOverrides"] !== "object" || cfg["severityOverrides"] === null || Array.isArray(cfg["severityOverrides"])) errors.push("severityOverrides must be an object");
1130
+ 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}`);
1131
+ else if (!VALID_SEVERITIES.has(value)) errors.push(`severityOverrides["${key}"] must be one of error|warning|info, got "${value}"`);
1132
+ }
1133
+ if (cfg["ignore"] !== void 0) {
1134
+ if (!Array.isArray(cfg["ignore"])) errors.push(`ignore must be an array, got ${typeof cfg["ignore"]}`);
1135
+ else for (let i = 0; i < cfg["ignore"].length; i++) {
1136
+ const entry = cfg["ignore"][i];
1137
+ const prefix = `ignore[${i}]`;
1138
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
1139
+ errors.push(`${prefix} must be an object`);
1140
+ continue;
1141
+ }
1142
+ const ign = entry;
1143
+ if (typeof ign["ruleId"] !== "string" || ign["ruleId"] === "") errors.push(`${prefix}.ruleId must be a non-empty string`);
1144
+ if (typeof ign["reason"] !== "string" || ign["reason"] === "") errors.push(`${prefix}.reason must be a non-empty string`);
1145
+ if (ign["files"] !== void 0) {
1146
+ if (!Array.isArray(ign["files"])) errors.push(`${prefix}.files must be an array`);
1147
+ 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]}`);
1148
+ }
1149
+ if (ign["expires"] !== void 0 && typeof ign["expires"] !== "string") errors.push(`${prefix}.expires must be a string, got ${typeof ign["expires"]}`);
1150
+ const allowedKeys = /* @__PURE__ */ new Set([
1151
+ "ruleId",
1152
+ "reason",
1153
+ "files",
1154
+ "expires"
1155
+ ]);
1156
+ for (const key of Object.keys(ign)) if (!allowedKeys.has(key)) errors.push(`${prefix} has unknown property "${key}"`);
1157
+ }
1158
+ }
1159
+ if (cfg["plugins"] !== void 0) {
1160
+ if (cfg["plugins"] === null || typeof cfg["plugins"] !== "object") errors.push("plugins must be an object or array");
1161
+ }
1162
+ return {
1163
+ valid: errors.length === 0,
1164
+ errors
1165
+ };
1166
+ }
1167
+ //#endregion
1168
+ //#region src/config/config.ts
1169
+ /**
1170
+ * Config file support (Sprint-Plan W7, Product-MVP §27 + GAP-F).
1171
+ * Zero-config preserved: config presence never changes detection
1172
+ * semantics — only severity, scope, and gating.
1173
+ */
1174
+ /**
1175
+ * Audit S7: every config key the engine consumes. A top-level key
1176
+ * OUTSIDE this set is a likely typo ("severities", "ingore") — unknown
1177
+ * keys emit a warning (fail-noisy, not fail-silent) and are ignored.
1178
+ */
1179
+ const KNOWN_CONFIG_KEYS = /* @__PURE__ */ new Set([
1180
+ "gate",
1181
+ "exclude",
1182
+ "severityOverrides",
1183
+ "ignore",
1184
+ "plugins"
1185
+ ]);
1186
+ const CONFIG_NAMES = ["mjolnir.config.json", ".mjolnir.json"];
1187
+ /**
1188
+ * Returns the absolute path of the first config file found, or null.
1189
+ * Shared by config loader, ignores, and plugin loading so all three
1190
+ * respect the same config file name priority.
1191
+ */
1192
+ function findConfigPath(root) {
1193
+ for (const name of CONFIG_NAMES) {
1194
+ const p = join(root, name);
1195
+ if (existsSync(p)) return p;
1196
+ }
1197
+ return null;
1198
+ }
1199
+ /**
1200
+ * Distinguished from other load failures so the CLI can exit 10 (usage)
1201
+ * instead of 20 (internal): a typo in the user's config is a user error
1202
+ * with a fixable message, not a tool malfunction (bug-audit M4).
1203
+ */
1204
+ var ConfigValidationError = class extends Error {
1205
+ constructor(message, options) {
1206
+ super(message, options);
1207
+ this.name = "ConfigValidationError";
1208
+ }
1209
+ };
1210
+ function loadConfig(root, options = {}) {
1211
+ for (const name of CONFIG_NAMES) {
1212
+ const p = join(root, name);
1213
+ if (!existsSync(p)) continue;
1214
+ try {
1215
+ const parsed = parseJsonFile(readFileSync(p, "utf8"), p, (v) => isRecord(v));
1216
+ return {
1217
+ config: parsed,
1218
+ path: p,
1219
+ warnings: validate(parsed, options.knownRuleIds)
1220
+ };
1221
+ } catch (err) {
1222
+ const msg = err.message;
1223
+ throw new ConfigValidationError(`Invalid mjolnir config at ${p}: ${msg}`, { cause: err });
1224
+ }
1225
+ }
1226
+ return {
1227
+ config: {},
1228
+ path: null,
1229
+ warnings: []
1230
+ };
1231
+ }
1232
+ function validate(cfg, knownRuleIds) {
1233
+ const warnings = [];
1234
+ const schemaResult = validateConfigSchema(cfg);
1235
+ if (!schemaResult.valid) throw new Error(schemaResult.errors.join("; "));
1236
+ if (cfg.gate && ![
1237
+ "advisory",
1238
+ "error",
1239
+ "warning"
1240
+ ].includes(cfg.gate)) throw new Error(`gate must be advisory|error|warning, got "${cfg.gate}"`);
1241
+ for (const [ruleId, severity] of Object.entries(cfg.severityOverrides ?? {})) {
1242
+ if (!SEVERITY_ORDER.includes(severity)) throw new Error(`severityOverrides["${ruleId}"] must be one of ${SEVERITY_ORDER.join("|")}, got "${String(severity)}"`);
1243
+ 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.`);
1244
+ }
1245
+ for (const ign of cfg.ignore ?? []) {
1246
+ if (!ign.ruleId) throw new Error("ignore entries require ruleId");
1247
+ if (!ign.reason) throw new Error(`ignore for ${ign.ruleId} requires a "reason" (§27)`);
1248
+ if (ign.files !== void 0) {
1249
+ if (!Array.isArray(ign.files)) throw new Error(`ignore for ${ign.ruleId}: "files" must be an array of glob strings, got ${typeof ign.files}`);
1250
+ 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)})`);
1251
+ }
1252
+ 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}"`);
1253
+ 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.`);
1254
+ }
1255
+ 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).`);
1256
+ if (cfg.exclude !== void 0) {
1257
+ if (!Array.isArray(cfg.exclude)) throw new Error(`exclude must be an array of strings, got ${typeof cfg.exclude}`);
1258
+ for (const p of cfg.exclude) if (typeof p !== "string") throw new Error(`exclude entries must be strings, got ${typeof p} (${JSON.stringify(p)})`);
1259
+ }
1260
+ return warnings;
1261
+ }
1262
+ /**
1263
+ * Bug-audit QA-2026-08-30 QA-6: the 90-day policy in the README was only
1264
+ * applied at WRITE time by the `ignore` command — a hand-written entry
1265
+ * without `expires` stayed active forever, silently bypassing the
1266
+ * documented window.
1267
+ *
1268
+ * Audit S4 (remediation plan): the config-file MTIME is no longer an
1269
+ * expiry anchor. Anchoring the 90-day default at mtime meant ANY edit to
1270
+ * mjolnir.config.json — a reformat, an unrelated key, a `touch` — reset
1271
+ * the 90-day window for EVERY hand-authored entry: suppressions could be
1272
+ * extended indefinitely without touching their own fields. The expiry is
1273
+ * now the entry's explicit `expires` date alone; an entry without one
1274
+ * stays active and is honestly labeled "(no expiry set)" in the
1275
+ * suppressions report. Hand-authored entries should declare `expires` at
1276
+ * creation (README §Configuration documents the shape).
1277
+ */
1278
+ function isSuppressionActive(ign, now = /* @__PURE__ */ new Date()) {
1279
+ if (!ign.expires) return true;
1280
+ return utcMidnight(ign.expires) > utcMidnightOf(now);
1281
+ }
1282
+ /** UTC-midnight timestamp of an ISO date string (YYYY-MM-DD). */
1283
+ function utcMidnight(isoDate) {
1284
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(isoDate)) return NaN;
1285
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
1286
+ if (!m) return NaN;
1287
+ return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
1288
+ }
1289
+ /** UTC-midnight timestamp of a Date (calendar day, timezone-independent). */
1290
+ function utcMidnightOf(d) {
1291
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
1292
+ }
1293
+ function applySeverityOverrides(findings, cfg) {
1294
+ const overrides = cfg.severityOverrides ?? {};
1295
+ for (const f of findings) {
1296
+ const override = overrides[f.ruleId];
1297
+ if (override && SEVERITY_ORDER.includes(override)) f.severity = override;
1298
+ }
1299
+ }
1300
+ //#endregion
1092
1301
  //#region src/discovery/ignores.ts
1093
1302
  /**
1094
1303
  * Ignore resolution (Product-MVP.txt R1 — Tier-1 launch blocker).
@@ -1118,6 +1327,7 @@ const DEFAULT_IGNORES = [
1118
1327
  "node_modules",
1119
1328
  ".git",
1120
1329
  ".mjolnir",
1330
+ ".claude",
1121
1331
  "dist",
1122
1332
  "build",
1123
1333
  "out",
@@ -1203,8 +1413,8 @@ function loadExtraPatterns(root) {
1203
1413
  const patterns = readFileSync(ignorePath, "utf8").split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
1204
1414
  extra.push(...patterns);
1205
1415
  } catch {}
1206
- const configPath = join(root, "mjolnir.config.json");
1207
- if (existsSync(configPath)) try {
1416
+ const configPath = findConfigPath(root);
1417
+ if (configPath) try {
1208
1418
  const cfg = JSON.parse(readFileSync(configPath, "utf8"));
1209
1419
  if (Array.isArray(cfg.exclude)) extra.push(...cfg.exclude.filter((p) => typeof p === "string"));
1210
1420
  } catch {}
@@ -1551,6 +1761,10 @@ function getCodeOnlyText(file) {
1551
1761
  return file.text;
1552
1762
  }
1553
1763
  }
1764
+ /** Release the shared ts-morph Project between scans to free memory. */
1765
+ function resetTsMorphProject() {
1766
+ project = null;
1767
+ }
1554
1768
  //#endregion
1555
1769
  //#region src/engine/code-text.ts
1556
1770
  /**
@@ -1577,6 +1791,7 @@ function computeCodeText(file, language) {
1577
1791
  case "python": return maskPython(file.text);
1578
1792
  case "java": return maskJava(file.text);
1579
1793
  case "csharp": return maskCSharp(file.text);
1794
+ default: return file.text;
1580
1795
  }
1581
1796
  }
1582
1797
  /**
@@ -1802,18 +2017,36 @@ function maskCSharp(text) {
1802
2017
  i = prefixStart + 1;
1803
2018
  continue;
1804
2019
  }
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;
2020
+ if (chars[i] === "\"") {
2021
+ let quoteCount = 0;
2022
+ let j = i;
2023
+ while (j < len && chars[j] === "\"") {
2024
+ quoteCount++;
2025
+ j++;
2026
+ }
2027
+ if (quoteCount >= 3) {
2028
+ const start = i;
2029
+ i = j;
2030
+ while (i < len) {
2031
+ let closeCount = 0;
2032
+ let k = i;
2033
+ while (k < len && chars[k] === "\"") {
2034
+ closeCount++;
2035
+ k++;
2036
+ }
2037
+ if (closeCount === quoteCount) {
2038
+ i = k;
2039
+ break;
2040
+ }
2041
+ if (closeCount > 0) {
2042
+ i = k;
2043
+ continue;
2044
+ }
2045
+ i++;
1812
2046
  }
1813
- i++;
2047
+ blankRange(chars, start, i);
2048
+ continue;
1814
2049
  }
1815
- blankRange(chars, start, i);
1816
- continue;
1817
2050
  }
1818
2051
  if (chars[i] === "\"") {
1819
2052
  const start = i;
@@ -1843,8 +2076,52 @@ function maskCSharp(text) {
1843
2076
  return chars.join("");
1844
2077
  }
1845
2078
  /**
1846
- * Blanks a range of characters to spaces, preserving newlines so
1847
- * line/column computations remain valid.
2079
+ * Blanks TypeScript/JavaScript string literals and comments to spaces
2080
+ * using the TypeScript compiler scanner with `reScanTemplateToken`
2081
+ * tracking for template interpolation boundaries. No AST required.
2082
+ *
2083
+ * This reuses the same pattern as `detector-hash.ts` (walkTokens) which
2084
+ * already solves the exact same template-interpolation problem for hash
2085
+ * computation. The phantom-comment edge case only occurs when
2086
+ * `reScanTemplateToken` is NOT called — calling it closes the gap.
2087
+ */
2088
+ function maskTypeScriptBasic(text) {
2089
+ const chars = text.split("");
2090
+ const scanner = ts$1.createScanner(ts$1.ScriptTarget.ES2022, false, ts$1.LanguageVariant.Standard, text);
2091
+ let templateDepth = 0;
2092
+ let tok = scanner.scan();
2093
+ while (tok !== ts$1.SyntaxKind.EndOfFileToken) {
2094
+ if (tok === ts$1.SyntaxKind.CloseBraceToken && templateDepth > 0) tok = scanner.reScanTemplateToken(false);
2095
+ const start = scanner.getTokenPos();
2096
+ const end = scanner.getTextPos();
2097
+ 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) {
2098
+ for (let i = start; i < end && i < chars.length; i++) if (chars[i] !== "\n" && chars[i] !== "\r") chars[i] = " ";
2099
+ }
2100
+ if (tok === ts$1.SyntaxKind.TemplateHead) templateDepth++;
2101
+ else if (tok === ts$1.SyntaxKind.LastTemplateToken) templateDepth = Math.max(0, templateDepth - 1);
2102
+ tok = scanner.scan();
2103
+ }
2104
+ return chars.join("");
2105
+ }
2106
+ /**
2107
+ * Compute the code-only text for counting test declarations. Dispatches
2108
+ * to the appropriate masker per language. Never falls back to raw text —
2109
+ * always produces a masked view with comments/strings blanked.
2110
+ *
2111
+ * Used when `parsedFile.codeText` is not available (e.g., AST parse failed).
2112
+ */
2113
+ function getCodeTextForCounting(text, language) {
2114
+ switch (language) {
2115
+ case "typescript": return maskTypeScriptBasic(text);
2116
+ case "python": return maskPython(text);
2117
+ case "java": return maskJava(text);
2118
+ case "csharp": return maskCSharp(text);
2119
+ default: return text;
2120
+ }
2121
+ }
2122
+ /**
2123
+ * Blanks a range of characters to spaces, preserving newlines so
2124
+ * line/column computations remain valid.
1848
2125
  */
1849
2126
  function blankRange(chars, start, end) {
1850
2127
  for (let i = start; i < end && i < chars.length; i++) if (chars[i] !== "\n" && chars[i] !== "\r") chars[i] = " ";
@@ -3203,7 +3480,9 @@ const azurePipelinesAdapter = {
3203
3480
  dirSkips: [],
3204
3481
  isTestFile(path) {
3205
3482
  const normalized = path.replaceAll("\\", "/");
3206
- return AZURE_PIPELINE_NAMES.includes(normalized);
3483
+ const basename = normalized.split("/").pop() ?? normalized;
3484
+ if (!AZURE_PIPELINE_NAMES.includes(basename)) return false;
3485
+ return normalized === basename || normalized.startsWith("/") || /^[A-Z]:/i.test(normalized);
3207
3486
  },
3208
3487
  detectFrameworks() {
3209
3488
  return {
@@ -3285,7 +3564,10 @@ const jenkinsAdapter = {
3285
3564
  testFileGlobs: ["Jenkinsfile"],
3286
3565
  dirSkips: [],
3287
3566
  isTestFile(path) {
3288
- return JENKINS_FILENAMES.includes(path.replaceAll("\\", "/"));
3567
+ const normalized = path.replaceAll("\\", "/");
3568
+ const basename = normalized.split("/").pop() ?? normalized;
3569
+ if (!JENKINS_FILENAMES.includes(basename)) return false;
3570
+ return normalized === basename || normalized.startsWith("/") || /^[A-Z]:/i.test(normalized);
3289
3571
  },
3290
3572
  detectFrameworks() {
3291
3573
  return {
@@ -3538,18 +3820,19 @@ function collectNameStatus(output) {
3538
3820
  return files;
3539
3821
  }
3540
3822
  function computeChangedScope(root, baseBranch) {
3541
- if (!existsSync(join(root, ".git"))) return {
3823
+ const gitRoot = runGit(root, ["rev-parse", "--show-toplevel"])?.trim() ?? root;
3824
+ if (!existsSync(join(gitRoot, ".git"))) return {
3542
3825
  changed: {},
3543
3826
  degraded: true,
3544
3827
  reason: "not-a-git-repo"
3545
3828
  };
3546
- const mergeBase = resolveMergeBase(root, baseBranch);
3829
+ const mergeBase = resolveMergeBase(gitRoot, baseBranch);
3547
3830
  if (!mergeBase) return {
3548
3831
  changed: {},
3549
3832
  degraded: true,
3550
3833
  reason: "no-merge-base"
3551
3834
  };
3552
- const committed = runGit(root, [
3835
+ const committed = runGit(gitRoot, [
3553
3836
  "diff",
3554
3837
  "--name-status",
3555
3838
  "-z",
@@ -3562,7 +3845,7 @@ function computeChangedScope(root, baseBranch) {
3562
3845
  degraded: true,
3563
3846
  reason: "diff-failed"
3564
3847
  };
3565
- const workingTree = runGit(root, [
3848
+ const workingTree = runGit(gitRoot, [
3566
3849
  "diff",
3567
3850
  "--name-status",
3568
3851
  "-z",
@@ -3574,7 +3857,7 @@ function computeChangedScope(root, baseBranch) {
3574
3857
  degraded: true,
3575
3858
  reason: "diff-failed"
3576
3859
  };
3577
- const untracked = runGit(root, [
3860
+ const untracked = runGit(gitRoot, [
3578
3861
  "ls-files",
3579
3862
  "-z",
3580
3863
  "--others",
@@ -3598,7 +3881,7 @@ function computeChangedScope(root, baseBranch) {
3598
3881
  const CHUNK = 200;
3599
3882
  for (let start = 0; start < files.length; start += CHUNK) {
3600
3883
  const chunk = files.slice(start, start + CHUNK);
3601
- const output = runGit(root, [
3884
+ const output = runGit(gitRoot, [
3602
3885
  ...args,
3603
3886
  "--",
3604
3887
  ...chunk
@@ -3607,7 +3890,14 @@ function computeChangedScope(root, baseBranch) {
3607
3890
  const sections = output.split("\ndiff --git ");
3608
3891
  for (const section of sections) {
3609
3892
  const body = section.startsWith("diff --git ") ? section : `diff --git ${section}`;
3610
- const file = /^diff --git a\/.+ b\/(.+)\n/.exec(`${body}\n`)?.[1];
3893
+ const header = body.split("\n")[0] ?? "";
3894
+ let file;
3895
+ const canonM = /^diff --git "a\/.+?" "b\/(.+?)"/.exec(header);
3896
+ if (canonM) file = canonM[1];
3897
+ else {
3898
+ const simpleM = /^diff --git a\/\S+\s+b\/(\S+)/.exec(header);
3899
+ if (simpleM) file = simpleM[1];
3900
+ }
3611
3901
  if (file !== void 0) perFile.set(file, body);
3612
3902
  }
3613
3903
  }
@@ -3704,8 +3994,9 @@ function filterToChanged(findings, diff) {
3704
3994
  * an honest stderr note). Empty list = genuinely nothing staged.
3705
3995
  */
3706
3996
  function computeStagedFiles(root) {
3707
- if (!existsSync(join(root, ".git"))) return null;
3708
- const raw = runGit(root, [
3997
+ const gitRoot = runGit(root, ["rev-parse", "--show-toplevel"])?.trim() ?? root;
3998
+ if (!existsSync(join(gitRoot, ".git"))) return null;
3999
+ const raw = runGit(gitRoot, [
3709
4000
  "diff",
3710
4001
  "--cached",
3711
4002
  "--name-only",
@@ -4345,12 +4636,12 @@ const unawaitedPromiseAssertion = defineRule({
4345
4636
  detectorRevision: 2,
4346
4637
  run(ctx) {
4347
4638
  const sourceFile = getTsSourceFile(ctx.ast);
4348
- if (sourceFile) return astArm$2(ctx, sourceFile);
4349
- return regexArm$1(ctx);
4639
+ if (sourceFile) return astArm$1(ctx, sourceFile);
4640
+ return regexArm(ctx);
4350
4641
  }
4351
4642
  });
4352
4643
  /** The rev-1 line-walk detector — the parse-or-fallback path (§10.1). */
4353
- function regexArm$1(ctx) {
4644
+ function regexArm(ctx) {
4354
4645
  const text = ctx.codeText ?? ctx.text;
4355
4646
  const findings = [];
4356
4647
  const thenRe = /\.then\s*\(/g;
@@ -4410,7 +4701,7 @@ function regexArm$1(ctx) {
4410
4701
  * - `void`-wrapped chains: a deliberate fire-and-forget marker.
4411
4702
  * Everything else the rev-1 diagnosis covers still fires, one per file.
4412
4703
  */
4413
- function astArm$2(ctx, sourceFile) {
4704
+ function astArm$1(ctx, sourceFile) {
4414
4705
  const findings = [];
4415
4706
  const thenCalls = sourceFile.getDescendantsOfKind(ts.SyntaxKind.PropertyAccessExpression).filter((p) => p.getName() === "then" && p.getParent()?.getKind() === ts.SyntaxKind.CallExpression);
4416
4707
  for (const thenAccess of thenCalls) {
@@ -4552,7 +4843,13 @@ const COMMENTED_TEST_RE = /^(?:await\s+)?(?:it|test)(?:\.\w+)?\s*\(/;
4552
4843
  * continuation of a block comment — those cannot be a commented-out test.
4553
4844
  */
4554
4845
  function stripCommentMarkers(line) {
4555
- const consumed = /^\s*(?:\/{2,}|\/\*\*?|\*)?\s*/.exec(line)[0].length;
4846
+ const m = /^\s*(?:\/{2,}|\/\*\*?|\*)?\s*/.exec(line);
4847
+ if (!m) return {
4848
+ text: line,
4849
+ consumed: 0,
4850
+ matched: false
4851
+ };
4852
+ const consumed = m[0].length;
4556
4853
  return {
4557
4854
  text: line.slice(consumed),
4558
4855
  consumed,
@@ -4813,6 +5110,7 @@ const brittleSelectors = defineRule({
4813
5110
  },
4814
5111
  detectionNotes: "regex pattern + inside-string oracle",
4815
5112
  introduced: "0.1.0",
5113
+ detectorRevision: 2,
4816
5114
  tier: "quarantine",
4817
5115
  run(ctx) {
4818
5116
  const findings = [];
@@ -4869,7 +5167,7 @@ function stripComments(ctx) {
4869
5167
  const slice = ctx.text.slice(r.start, r.end);
4870
5168
  return slice.startsWith("//") || slice.startsWith("/*");
4871
5169
  });
4872
- const chars = [...ctx.text];
5170
+ const chars = ctx.text.split("");
4873
5171
  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
5172
  return chars.join("");
4875
5173
  }
@@ -4980,11 +5278,11 @@ function locateAzureStepKey(text, step, keyName) {
4980
5278
  if (anchorAt !== -1) {
4981
5279
  keyRe.lastIndex = anchorAt;
4982
5280
  const m = keyRe.exec(text);
4983
- if (m) return lineOf$1(text, m.index);
4984
- return lineOf$1(text, anchorAt);
5281
+ if (m) return lineAt(text, m.index);
5282
+ return lineAt(text, anchorAt);
4985
5283
  }
4986
5284
  const m = keyRe.exec(text);
4987
- if (m) return lineOf$1(text, m.index);
5285
+ if (m) return lineAt(text, m.index);
4988
5286
  return 1;
4989
5287
  }
4990
5288
  /** Line of a job-level key: anchored at the job's own kind key or name. */
@@ -4995,18 +5293,13 @@ function locateAzureJobKey(text, job, keyName) {
4995
5293
  if (anchorAt !== -1) {
4996
5294
  keyRe.lastIndex = anchorAt;
4997
5295
  const m = keyRe.exec(text);
4998
- if (m) return lineOf$1(text, m.index);
4999
- return lineOf$1(text, anchorAt);
5296
+ if (m) return lineAt(text, m.index);
5297
+ return lineAt(text, anchorAt);
5000
5298
  }
5001
5299
  const m = keyRe.exec(text);
5002
- if (m) return lineOf$1(text, m.index);
5300
+ if (m) return lineAt(text, m.index);
5003
5301
  return 1;
5004
5302
  }
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
5303
  //#endregion
5011
5304
  //#region src/rules/ci/qa-ci-001-continue-on-error.ts
5012
5305
  /**
@@ -5165,19 +5458,14 @@ function locateStepContinueOnError(text, step) {
5165
5458
  const re = /continue-on-error:\s*true/g;
5166
5459
  re.lastIndex = searchFrom;
5167
5460
  const m = re.exec(text);
5168
- if (m) return lineOf(text, m.index);
5169
- if (anchorAt !== -1) return lineOf(text, anchorAt);
5461
+ if (m) return lineAt(text, m.index);
5462
+ if (anchorAt !== -1) return lineAt(text, anchorAt);
5170
5463
  return 1;
5171
5464
  }
5172
5465
  function findLine$2(text, re) {
5173
5466
  const m = re.exec(text);
5174
5467
  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;
5468
+ return lineAt(text, m.index);
5181
5469
  }
5182
5470
  function escapeRe$1(s) {
5183
5471
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -5604,14 +5892,9 @@ function findJobConsumerLine(text, jobName, consumerRe) {
5604
5892
  const re = new RegExp(consumerRe.source, "gi");
5605
5893
  re.lastIndex = declMatch ? declMatch.index : 0;
5606
5894
  const anchored = re.exec(text);
5607
- if (anchored) return lineOfIndex(text, anchored.index);
5895
+ if (anchored) return lineAt(text, anchored.index);
5608
5896
  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;
5897
+ return anywhere ? lineAt(text, anywhere.index) : 1;
5615
5898
  }
5616
5899
  //#endregion
5617
5900
  //#region src/rules/ci/jenkins-gates.ts
@@ -5858,9 +6141,7 @@ function textIsVerificationGate(text) {
5858
6141
  }
5859
6142
  /** Line (1-based) of an offset — for finding anchors. */
5860
6143
  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;
6144
+ return lineAt(text, offset);
5864
6145
  }
5865
6146
  //#endregion
5866
6147
  //#region src/rules/ci/qa-ci-008-always-success.ts
@@ -6085,6 +6366,7 @@ const exitCodeNotPropagated = defineRule({
6085
6366
  for (const line of lines) {
6086
6367
  if (!TEST_CMD$1.test(line)) continue;
6087
6368
  const m = TEST_CMD$1.exec(line);
6369
+ if (!m) continue;
6088
6370
  const afterCmd = line.slice(m.index + m[0].length);
6089
6371
  if (/^\s*\|(?!\|)/.exec(afterCmd) && !/\|\|\s|&&\s/.test(afterCmd)) findings.push({
6090
6372
  severity: "error",
@@ -6248,6 +6530,7 @@ const nonBlockingTestJob = defineRule({
6248
6530
  });
6249
6531
  function findLine(text, re) {
6250
6532
  const m = re.exec(text);
6533
+ if (!m) return 1;
6251
6534
  let line = 1;
6252
6535
  for (let i = 0; i < m.index; i++) if (text[i] === "\n") line++;
6253
6536
  return line;
@@ -6394,7 +6677,7 @@ const swallowedVerificationFailure = defineRule({
6394
6677
  autofix: false,
6395
6678
  detectionStrategy: "FRAMEWORK",
6396
6679
  detectionNotes: "Groovy try/catch block scan (string-aware brace matching) over the Jenkinsfile text",
6397
- introduced: "1.1.1",
6680
+ introduced: "2.0.0",
6398
6681
  tier: "quarantine",
6399
6682
  run(ctx) {
6400
6683
  const findings = [];
@@ -6445,7 +6728,7 @@ const pyNoAssertions = defineRule({
6445
6728
  },
6446
6729
  introduced: "0.3.0",
6447
6730
  tier: "quarantine",
6448
- detectorRevision: 3,
6731
+ detectorRevision: 4,
6449
6732
  run(ctx) {
6450
6733
  const text = ctx.codeText ?? ctx.text;
6451
6734
  const findings = [];
@@ -6453,7 +6736,8 @@ const pyNoAssertions = defineRule({
6453
6736
  const fnRe = /^([ \t]*)def\s+(test_\w+)\s*\([^)]*\)\s*:/gm;
6454
6737
  let m;
6455
6738
  while ((m = fnRe.exec(text)) !== null) {
6456
- if (m[1].length > 0) continue;
6739
+ const indent = m[1];
6740
+ if (indent.length > 0 && !isCollectedTestMethod(text, m.index, indent)) continue;
6457
6741
  const body = extractBlock$1(text, m.index + m[0].length);
6458
6742
  if (body === null) continue;
6459
6743
  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 +6766,35 @@ const pyNoAssertions = defineRule({
6482
6766
  return findings;
6483
6767
  }
6484
6768
  });
6769
+ /**
6770
+ * detectorRevision 4: true when the def at `defAt` (indent `indent`) is
6771
+ * a `test_*` METHOD of a pytest-collectable class — every header between
6772
+ * the def and the file top at a SMALLER indent must be either another
6773
+ * def (the method is nested in a function → not collected) or a
6774
+ * `class Test*` (collected). A class with a non-Test name, or any
6775
+ * non-header line at a smaller indent before a class header, means the
6776
+ * def is data → not collected.
6777
+ */
6778
+ function isCollectedTestMethod(text, defAt, indent) {
6779
+ const lines = text.slice(0, defAt).split("\n");
6780
+ for (let i = lines.length - 1; i >= 0; i--) {
6781
+ const line = lines[i];
6782
+ if (line.trim() === "") continue;
6783
+ const indentMatch = /^[ \t]*/.exec(line);
6784
+ if (!indentMatch) continue;
6785
+ const lineIndent = indentMatch[0];
6786
+ if (lineIndent.length >= indent.length) continue;
6787
+ const classM = /^[ \t]*class\s+(\w+)/.exec(line);
6788
+ if (classM) {
6789
+ if (!/^Test/.test(classM[1])) return false;
6790
+ const classBody = lines.slice(i + 1).join("\n");
6791
+ if (new RegExp(`^${lineIndent}(?: |\t)\\s*def\\s+__init__\\s*\\(`, "m").test(classBody)) return false;
6792
+ return true;
6793
+ }
6794
+ return false;
6795
+ }
6796
+ return false;
6797
+ }
6485
6798
  /** Extract an indented block starting after a `:` line; returns null if empty. */
6486
6799
  function extractBlock$1(text, afterColon) {
6487
6800
  const rest = text.slice(afterColon).replace(/\r\n/g, "\n");
@@ -6490,7 +6803,11 @@ function extractBlock$1(text, afterColon) {
6490
6803
  const firstIdx = firstContent.index;
6491
6804
  if (rest.slice(0, firstIdx).includes("\n")) {
6492
6805
  const lines = rest.split("\n").slice(1);
6493
- const indent = /^[ \t]*/.exec(lines.find((l) => l.trim() !== ""))[0];
6806
+ const nonBlank = lines.find((l) => l.trim() !== "");
6807
+ if (!nonBlank) return null;
6808
+ const indentMatch = /^[ \t]*/.exec(nonBlank);
6809
+ if (!indentMatch) return null;
6810
+ const indent = indentMatch[0];
6494
6811
  if (!indent) return null;
6495
6812
  const collected = [];
6496
6813
  for (const line of lines) {
@@ -6744,12 +7061,9 @@ function getTreeSitterTree(ast) {
6744
7061
  * source. The helper exists so this finding is pinned in one place.
6745
7062
  */
6746
7063
  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
7064
  return {
6751
- line,
6752
- column: charIndex - lastBreak
7065
+ line: lineAt(text, charIndex),
7066
+ column: colAt(text, charIndex)
6753
7067
  };
6754
7068
  }
6755
7069
  function lastIdentifierText(node) {
@@ -6802,8 +7116,7 @@ function isCsTestAttribute(node) {
6802
7116
  if (node.type !== "attribute") return false;
6803
7117
  const nameNode = node.childForFieldName("name");
6804
7118
  if (!nameNode) return false;
6805
- const last = lastIdentifierText(nameNode);
6806
- return last !== void 0 && CS_TEST_ATTRIBUTES.has(last);
7119
+ return CS_TEST_ATTRIBUTES.has(lastIdentifierText(nameNode) ?? "");
6807
7120
  }
6808
7121
  /**
6809
7122
  * Every method declaration carrying a `[Test]`/`[Fact]`/`[TestMethod]`
@@ -6855,8 +7168,7 @@ function isInvocation(node) {
6855
7168
  function callName(node) {
6856
7169
  if (node.type === "method_invocation") {
6857
7170
  const nameNode = node.childForFieldName("name");
6858
- if (!nameNode) return void 0;
6859
- return lastIdentifierText(nameNode);
7171
+ return nameNode ? lastIdentifierText(nameNode) : void 0;
6860
7172
  }
6861
7173
  if (node.type === "invocation_expression") {
6862
7174
  const fn = node.childForFieldName("function");
@@ -6890,7 +7202,7 @@ function invocationsWithin(node) {
6890
7202
  const out = [];
6891
7203
  const visit = (n) => {
6892
7204
  if (isInvocation(n)) out.push(n);
6893
- for (const child of n.namedChildren) visit(child);
7205
+ for (const child of n.namedChildren) if (child) visit(child);
6894
7206
  };
6895
7207
  visit(node);
6896
7208
  return out;
@@ -7017,7 +7329,7 @@ const pyRaisesWithoutMatch = defineRule({
7017
7329
  const findings = [];
7018
7330
  if (!ctx.path.endsWith(".py")) return findings;
7019
7331
  const tree = getPythonTree(ctx.ast);
7020
- if (tree) return astArm$1(ctx, tree);
7332
+ if (tree) return astArm(ctx, tree);
7021
7333
  const text = ctx.codeText ?? ctx.text;
7022
7334
  const re = /pytest\.raises\s*\(/g;
7023
7335
  let m;
@@ -7082,7 +7394,7 @@ function matchParen$1(text, open) {
7082
7394
  * Broad root types (Exception/BaseException/…Group) always fire — the
7083
7395
  * type alone pins nothing.
7084
7396
  */
7085
- function astArm$1(ctx, tree) {
7397
+ function astArm(ctx, tree) {
7086
7398
  const raw = ctx.text;
7087
7399
  const findings = [];
7088
7400
  for (const block of pythonWithRaisesBlocks(tree)) {
@@ -8343,8 +8655,8 @@ const pwLocatorNormalize = defineRule({
8343
8655
  autofix: false,
8344
8656
  detectionStrategy: "LEXICAL",
8345
8657
  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"
8658
+ reasonCode: "lexical-artifact",
8659
+ detail: "CSS/XPath string selectors in locator()/waitForSelector() calls and raw page.$/page.$$ handles detected via regex on the raw text view"
8348
8660
  },
8349
8661
  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
8662
  introduced: "0.6.0",
@@ -8407,75 +8719,32 @@ const pwCodegenArtifact = defineRule({
8407
8719
  detectionStrategy: "LEXICAL",
8408
8720
  strategyJustification: {
8409
8721
  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"
8722
+ 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
8723
  },
8412
8724
  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
8725
  introduced: "0.6.0",
8414
8726
  tier: "quarantine",
8415
8727
  detectorRevision: 2,
8416
8728
  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({
8729
+ const text = ctx.text;
8730
+ const findings = [];
8731
+ const re = new RegExp(CODEGEN_DEFAULT_TITLE_RE$1.source, "g");
8732
+ let m;
8733
+ while ((m = re.exec(text)) !== null) findings.push({
8465
8734
  severity: "info",
8466
8735
  confidence: "medium",
8467
8736
  findingType: "observation",
8468
8737
  qaImpact: "HYGIENE",
8469
8738
  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.`,
8739
+ line: lineAt(text, m.index),
8740
+ column: colAt(text, m.index),
8741
+ message: `Codegen default test title (${m[0].replace(/\s+/g, " ").trim()}) — an unreviewed recording artifact.`,
8473
8742
  why: WHY$7,
8474
8743
  fix: FIX$6
8475
8744
  });
8745
+ return findings;
8476
8746
  }
8477
- return findings;
8478
- }
8747
+ });
8479
8748
  //#endregion
8480
8749
  //#region src/rules/python/qa-py-101-sync-async-mix.ts
8481
8750
  /**
@@ -8849,7 +9118,17 @@ function matchBrace$1(text, open) {
8849
9118
  else if (ch === inStr) inStr = null;
8850
9119
  continue;
8851
9120
  }
8852
- if (ch === "\"") inStr = ch;
9121
+ if (ch === "/" && text[i + 1] === "/") {
9122
+ const nl = text.indexOf("\n", i);
9123
+ i = nl === -1 ? text.length - 1 : nl;
9124
+ continue;
9125
+ }
9126
+ if (ch === "/" && text[i + 1] === "*") {
9127
+ const end = text.indexOf("*/", i + 2);
9128
+ i = end === -1 ? text.length - 1 : end + 1;
9129
+ continue;
9130
+ }
9131
+ if (ch === "\"" || ch === "'") inStr = ch;
8853
9132
  else if (ch === "{") depth++;
8854
9133
  else if (ch === "}") {
8855
9134
  depth--;
@@ -8870,7 +9149,11 @@ function extractQaModel(file) {
8870
9149
  if (file.path.endsWith(".java")) return extractJavaModel(file);
8871
9150
  if (file.path.endsWith(".cs")) return extractCSharpModel(file);
8872
9151
  if (file.path.endsWith(".py")) return extractPythonModel(file);
8873
- if (/\.[cm]?[jt]sx?$/.test(file.path)) return extractTsModel(file, parseTsFile(file));
9152
+ if (/\.[cm]?[jt]sx?$/.test(file.path)) {
9153
+ const sf = parseTsFile(file);
9154
+ if (!sf) return void 0;
9155
+ return extractTsModel(file, sf);
9156
+ }
8874
9157
  }
8875
9158
  function pos(text, index) {
8876
9159
  return {
@@ -8921,18 +9204,16 @@ function extractJavaModel(file) {
8921
9204
  truncated: test.body.hasError
8922
9205
  });
8923
9206
  for (const decl of tree.rootNode.descendantsOfType("method_declaration")) {
9207
+ if (!decl) continue;
8924
9208
  const modifiers = decl.childForFieldName("modifiers") ?? decl.children[0];
8925
9209
  if (!modifiers || modifiers.type !== "modifiers") continue;
8926
- const body = decl.childForFieldName("body");
8927
- if (!body) continue;
8928
9210
  const nameNode = decl.childForFieldName("name");
8929
- if (!nameNode) continue;
9211
+ const body = decl.childForFieldName("body");
9212
+ if (!nameNode || !body) continue;
8930
9213
  for (const annotation of javaAnnotationNames(modifiers)) {
8931
9214
  if (JAVA_HOOK_ANNOTATIONS.has(annotation)) {
8932
- let concept = "setup";
8933
- if (annotation.startsWith("After")) concept = "teardown";
8934
9215
  nodes.push({
8935
- concept,
9216
+ concept: annotation.startsWith("After") ? "teardown" : "setup",
8936
9217
  name: nameNode.text,
8937
9218
  start: pos(text, decl.startIndex),
8938
9219
  end: pos(text, body.endIndex),
@@ -8992,10 +9273,10 @@ function extractCSharpModel(file) {
8992
9273
  truncated: test.body.hasError
8993
9274
  });
8994
9275
  for (const decl of tree.rootNode.descendantsOfType("method_declaration")) {
8995
- const body = decl.childForFieldName("body");
8996
- if (!body) continue;
9276
+ if (!decl) continue;
8997
9277
  const nameNode = decl.childForFieldName("name");
8998
- if (!nameNode) continue;
9278
+ const body = decl.childForFieldName("body");
9279
+ if (!nameNode || !body) continue;
8999
9280
  let attr;
9000
9281
  for (const child of decl.children) {
9001
9282
  if (child?.type !== "attribute_list") continue;
@@ -9014,12 +9295,8 @@ function extractCSharpModel(file) {
9014
9295
  if (attr) break;
9015
9296
  }
9016
9297
  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
9298
  nodes.push({
9022
- concept,
9299
+ concept: attr.includes("TearDown") || attr.includes("Cleanup") ? "teardown" : CS_RETRY_ATTRIBUTES.has(attr) ? "retry" : "setup",
9023
9300
  name: nameNode.text,
9024
9301
  start: pos(text, decl.startIndex),
9025
9302
  end: pos(text, body.endIndex),
@@ -9028,12 +9305,12 @@ function extractCSharpModel(file) {
9028
9305
  }
9029
9306
  nodes.push(...javaCSharpCallNodes(tree, text, CS_CALLEE_CONCEPTS));
9030
9307
  for (const throwStmt of tree.rootNode.descendantsOfType("throw_statement")) {
9308
+ if (!throwStmt) continue;
9031
9309
  const creation = throwStmt.namedChildren.find((c) => c?.type === "object_creation_expression");
9032
9310
  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({
9311
+ const typeName = creation.childForFieldName("type")?.text ?? "";
9312
+ if (!/assert/i.test(typeName) || !/exception/i.test(typeName)) continue;
9313
+ nodes.push({
9037
9314
  concept: "assertion",
9038
9315
  name: typeName,
9039
9316
  callee: typeName,
@@ -9104,7 +9381,6 @@ const JAVA_CALLEE_CONCEPTS = {
9104
9381
  */
9105
9382
  const CS_CALLEE_CONCEPTS = {
9106
9383
  exact: {
9107
- expect: "assertion",
9108
9384
  Expect: "assertion",
9109
9385
  GotoAsync: "navigation",
9110
9386
  GoToAsync: "navigation",
@@ -9180,17 +9456,16 @@ function javaCSharpCallNodes(tree, text, table) {
9180
9456
  }
9181
9457
  }
9182
9458
  if (concept === void 0) continue;
9183
- const node = {
9459
+ nodes.push({
9184
9460
  concept,
9185
9461
  name: callee,
9186
9462
  callee,
9463
+ ...receiver !== void 0 ? { receiver } : {},
9187
9464
  start: pos(text, call.startIndex),
9188
9465
  end: pos(text, call.endIndex),
9189
9466
  text: call.text,
9190
9467
  ancestors: ancestorCallNames(call)
9191
- };
9192
- if (receiver !== void 0) node.receiver = receiver;
9193
- nodes.push(node);
9468
+ });
9194
9469
  }
9195
9470
  return nodes;
9196
9471
  }
@@ -9280,8 +9555,7 @@ function extractTsModel(file, sf) {
9280
9555
  const nodes = [];
9281
9556
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
9282
9557
  const chain = calleeChainText(call.getExpression());
9283
- const parts = chain.split(".");
9284
- const base = parts[parts.length - 1];
9558
+ const base = chain.split(".").pop() ?? chain;
9285
9559
  const start = pos(text, call.getStart());
9286
9560
  const end = pos(text, call.getEnd());
9287
9561
  if (TS_TEST_CALLEE_RE.test(chain)) {
@@ -9299,13 +9573,12 @@ function extractTsModel(file, sf) {
9299
9573
  const hook = TS_HOOK_CONCEPTS[base];
9300
9574
  if (hook && /(?:^|\.)(?:beforeEach|beforeAll|afterEach|afterAll)$/.test(chain)) {
9301
9575
  const callback = call.getArguments().find((a) => a.getKindName().includes("Function"));
9302
- const node = {
9576
+ nodes.push({
9303
9577
  concept: hook,
9304
9578
  name: chain,
9305
- start
9306
- };
9307
- if (callback) node.end = pos(text, callback.getEnd());
9308
- nodes.push(node);
9579
+ start,
9580
+ ...callback ? { end: pos(text, callback.getEnd()) } : {}
9581
+ });
9309
9582
  continue;
9310
9583
  }
9311
9584
  if (/^describe(?:\.\w+)*\.serial$/.test(chain) || /\.describe\.serial/.test(chain)) {
@@ -9318,9 +9591,8 @@ function extractTsModel(file, sf) {
9318
9591
  continue;
9319
9592
  }
9320
9593
  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(".");
9594
+ const name = receiverParts.at(-1) ?? chain;
9595
+ const receiver = receiverParts.length > 1 ? receiverParts.slice(0, -1).join(".") : void 0;
9324
9596
  let concept = TS_CALLEE_CONCEPTS.exact[name];
9325
9597
  if (concept === void 0) {
9326
9598
  for (const p of TS_CALLEE_CONCEPTS.prefixes) if (p.test(name)) {
@@ -9328,23 +9600,21 @@ function extractTsModel(file, sf) {
9328
9600
  break;
9329
9601
  }
9330
9602
  }
9603
+ 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
9604
  if (concept === void 0) continue;
9332
- const node = {
9605
+ nodes.push({
9333
9606
  concept,
9334
9607
  name,
9335
9608
  callee: name,
9609
+ ...receiver !== void 0 ? { receiver } : {},
9336
9610
  start,
9337
9611
  end,
9338
9612
  text: call.getText(),
9339
9613
  awaited: isAwaitedTsCall(call)
9340
- };
9341
- if (receiver !== void 0) node.receiver = receiver;
9342
- nodes.push(node);
9614
+ });
9343
9615
  }
9344
- let language = "javascript";
9345
- if (/\.[cm]?ts$/.test(file.path) || /\.tsx$/.test(file.path)) language = "typescript";
9346
9616
  return {
9347
- language,
9617
+ language: /(?:^|\.)tsx?$/.test(file.path) ? "typescript" : "javascript",
9348
9618
  nodes
9349
9619
  };
9350
9620
  }
@@ -9379,8 +9649,9 @@ function extractPythonModel(file) {
9379
9649
  const text = file.text;
9380
9650
  const nodes = [];
9381
9651
  const push = (concept, m, name) => {
9382
- const node = {
9652
+ nodes.push({
9383
9653
  concept,
9654
+ ...name !== void 0 ? { name } : {},
9384
9655
  start: {
9385
9656
  index: m.index,
9386
9657
  line: lineAt(text, m.index),
@@ -9392,9 +9663,7 @@ function extractPythonModel(file) {
9392
9663
  column: colAt(text, m.index + m[0].length)
9393
9664
  },
9394
9665
  text: m[0]
9395
- };
9396
- if (name !== void 0) node.name = name;
9397
- nodes.push(node);
9666
+ });
9398
9667
  };
9399
9668
  for (const re of [
9400
9669
  PY_TEST_RE,
@@ -9405,13 +9674,7 @@ function extractPythonModel(file) {
9405
9674
  ]) {
9406
9675
  re.lastIndex = 0;
9407
9676
  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
- }
9677
+ 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
9678
  }
9416
9679
  return {
9417
9680
  language: "python",
@@ -9749,7 +10012,17 @@ function matchBrace(text, open) {
9749
10012
  else if (ch === inStr) inStr = null;
9750
10013
  continue;
9751
10014
  }
9752
- if (ch === "\"") inStr = ch;
10015
+ if (ch === "/" && text[i + 1] === "/") {
10016
+ const nl = text.indexOf("\n", i);
10017
+ i = nl === -1 ? text.length - 1 : nl;
10018
+ continue;
10019
+ }
10020
+ if (ch === "/" && text[i + 1] === "*") {
10021
+ const end = text.indexOf("*/", i + 2);
10022
+ i = end === -1 ? text.length - 1 : end + 1;
10023
+ continue;
10024
+ }
10025
+ if (ch === "\"" || ch === "'") inStr = ch;
9753
10026
  else if (ch === "{") depth++;
9754
10027
  else if (ch === "}") {
9755
10028
  depth--;
@@ -9887,8 +10160,8 @@ const cypCyWait = defineRule({
9887
10160
  autofix: false,
9888
10161
  detectionStrategy: "LEXICAL",
9889
10162
  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"
10163
+ reasonCode: "lexical-artifact",
10164
+ detail: "cy.wait with a numeric-literal argument only — alias waits (cy.wait('@…')) are the legitimate routed-request form and never fire"
9892
10165
  },
9893
10166
  detectionNotes: "cy.wait with a numeric-literal argument only — alias waits (cy.wait('@…')) are the legitimate routed-request form and never fire",
9894
10167
  introduced: "0.6.0",
@@ -9896,7 +10169,7 @@ const cypCyWait = defineRule({
9896
10169
  detectorRevision: 1,
9897
10170
  run(ctx) {
9898
10171
  if (!isCypressFile(ctx)) return [];
9899
- const text = ctx.codeText !== void 0 ? ctx.codeText : ctx.text;
10172
+ const text = ctx.codeText ?? ctx.text;
9900
10173
  const findings = [];
9901
10174
  const re = new RegExp(CY_WAIT_NUMBER_RE.source, "g");
9902
10175
  let m;
@@ -9963,8 +10236,8 @@ const cypFocusedTest = defineRule({
9963
10236
  autofix: false,
9964
10237
  detectionStrategy: "LEXICAL",
9965
10238
  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"
10239
+ reasonCode: "lexical-artifact",
10240
+ detail: "Cypress .only() is a lexical modifier — the regex matches the literal `.only(` call target in source text"
9968
10241
  },
9969
10242
  detectionNotes: "it/describe/context .only member-call shape on the code-only text view",
9970
10243
  introduced: "0.6.0",
@@ -9972,7 +10245,7 @@ const cypFocusedTest = defineRule({
9972
10245
  detectorRevision: 1,
9973
10246
  run(ctx) {
9974
10247
  if (!isCypressFile(ctx)) return [];
9975
- const text = ctx.codeText !== void 0 ? ctx.codeText : ctx.text;
10248
+ const text = ctx.codeText ?? ctx.text;
9976
10249
  const findings = [];
9977
10250
  const re = new RegExp(FOCUS_RE.source, "g");
9978
10251
  let m;
@@ -10033,15 +10306,15 @@ const cypConfigSecurity = defineRule({
10033
10306
  autofix: false,
10034
10307
  detectionStrategy: "LEXICAL",
10035
10308
  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"
10309
+ reasonCode: "string-content-defect",
10310
+ detail: "chromeWebSecurity:false in cypress.config is a string-content match in the config file — the regex detects the boolean literal"
10038
10311
  },
10039
10312
  detectionNotes: "positive match on chromeWebSecurity:false inside cypress.config.* (the config is the artifact — no heuristic)",
10040
10313
  introduced: "0.6.0",
10041
10314
  tier: "quarantine",
10042
10315
  detectorRevision: 1,
10043
10316
  run(ctx) {
10044
- const base = ctx.path.replace(/\\/g, "/").split("/").pop();
10317
+ const base = ctx.path.replace(/\\/g, "/").split("/").pop() ?? "";
10045
10318
  if (!CYPRESS_CONFIG_RE.test(base)) return [];
10046
10319
  const text = ctx.text;
10047
10320
  const findings = [];
@@ -10128,7 +10401,7 @@ function sleepBeforeLookup(ctx, sleepRe, lookupRe, id) {
10128
10401
  return findings;
10129
10402
  }
10130
10403
  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*\(/;
10404
+ const JAVA_LOOKUP_RE = /\bdriver\.findElement|\bdriver\.findElements|\.\s*sendKeys\s*\(|\.\s*click\s*\(|\.\s*clear\s*\(|findElement\s*\(/;
10132
10405
  const seJavaSleepLookup = defineRule({
10133
10406
  id: "QA-SE-001",
10134
10407
  category: "QA-PW",
@@ -10144,8 +10417,8 @@ const seJavaSleepLookup = defineRule({
10144
10417
  autofix: false,
10145
10418
  detectionStrategy: "LEXICAL",
10146
10419
  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"
10420
+ reasonCode: "lexical-artifact",
10421
+ 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
10422
  },
10150
10423
  detectionNotes: "sequence shape: Thread.sleep followed by a findElement/interaction call within 3 lines (code-only view)",
10151
10424
  introduced: "0.6.0",
@@ -10157,7 +10430,7 @@ const seJavaSleepLookup = defineRule({
10157
10430
  }
10158
10431
  });
10159
10432
  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*\(/;
10433
+ const CS_LOOKUP_RE = /\bdriver\.FindElement|\bdriver\.FindElements|\.\s*SendKeys\s*\(|\.\s*Click\s*\(|\.\s*Clear\s*\(|FindElement\s*\(/;
10161
10434
  const seCSharpSleepLookup = defineRule({
10162
10435
  id: "QA-SE-002",
10163
10436
  category: "QA-PW",
@@ -10173,8 +10446,8 @@ const seCSharpSleepLookup = defineRule({
10173
10446
  autofix: false,
10174
10447
  detectionStrategy: "LEXICAL",
10175
10448
  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"
10449
+ reasonCode: "lexical-artifact",
10450
+ 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
10451
  },
10179
10452
  detectionNotes: "sequence shape: Thread.Sleep/Task.Delay followed by a FindElement/interaction call within 3 lines (code-only view)",
10180
10453
  introduced: "0.6.0",
@@ -10186,7 +10459,7 @@ const seCSharpSleepLookup = defineRule({
10186
10459
  }
10187
10460
  });
10188
10461
  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*\(/;
10462
+ const PY_LOOKUP_RE = /\bdriver\.find_element|\bdriver\.find_elements|\.find_element\s*\(|\.find_elements\s*\(|\.send_keys\s*\(|\.click\s*\(|\.clear\s*\(/;
10190
10463
  const sePythonSleepLookup = defineRule({
10191
10464
  id: "QA-SE-003",
10192
10465
  category: "QA-PW",
@@ -10202,8 +10475,8 @@ const sePythonSleepLookup = defineRule({
10202
10475
  autofix: false,
10203
10476
  detectionStrategy: "LEXICAL",
10204
10477
  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"
10478
+ reasonCode: "lexical-artifact",
10479
+ 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
10480
  },
10208
10481
  detectionNotes: "sequence shape: time.sleep followed by a find_element/interaction call within 3 lines (code-only view)",
10209
10482
  introduced: "0.6.0",
@@ -11149,6 +11422,58 @@ function enforceTierPolicy(findings, tierByRuleId) {
11149
11422
  }
11150
11423
  }
11151
11424
  //#endregion
11425
+ //#region src/engine/evidence-core.ts
11426
+ /**
11427
+ * The core's own version — stamped into every record's provenance so a
11428
+ * consumer can tell which normalization contract produced a record.
11429
+ * Bump ONLY on a semantic change to the normalization itself.
11430
+ */
11431
+ const EVIDENCE_CORE_VERSION = "evidence-core@1";
11432
+ /** Deterministic canonical order: file → line (absent last) → title → source. */
11433
+ function compareEvidenceRecords(a, b) {
11434
+ if (a.file !== b.file) return a.file < b.file ? -1 : 1;
11435
+ const la = a.line ?? Number.POSITIVE_INFINITY;
11436
+ const lb = b.line ?? Number.POSITIVE_INFINITY;
11437
+ if (la !== lb) return la - lb;
11438
+ if (a.title !== b.title) return a.title < b.title ? -1 : 1;
11439
+ if (a.source !== b.source) return a.source < b.source ? -1 : 1;
11440
+ return 0;
11441
+ }
11442
+ function normalizeOne(report, artifact, v) {
11443
+ const last = v.attempts > 0 ? v.finalStatus : "skipped";
11444
+ return {
11445
+ source: report.source,
11446
+ artifact,
11447
+ file: v.file,
11448
+ title: v.title,
11449
+ ...v.line !== void 0 ? { line: v.line } : {},
11450
+ status: {
11451
+ final: last,
11452
+ failed: v.everFailed,
11453
+ retried: v.attempts > 1,
11454
+ passedOnRetry: v.passedOnRetry,
11455
+ skipped: v.skipped,
11456
+ timedOut: v.finalStatus === "timedOut"
11457
+ },
11458
+ attempts: v.attempts,
11459
+ durationMs: v.totalDurationMs,
11460
+ errors: [],
11461
+ attachments: [],
11462
+ provenance: {
11463
+ core: EVIDENCE_CORE_VERSION,
11464
+ ingest: "mjolnir.forensics"
11465
+ }
11466
+ };
11467
+ }
11468
+ /**
11469
+ * Normalize a forensics report into deterministically-ordered evidence
11470
+ * records. The same report + artifact identity ALWAYS produces the same
11471
+ * record list — report verdict order never leaks into the output.
11472
+ */
11473
+ function buildEvidenceRecords(report, artifact) {
11474
+ return report.verdicts.map((v) => normalizeOne(report, artifact, v)).sort(compareEvidenceRecords);
11475
+ }
11476
+ //#endregion
11152
11477
  //#region src/engine/provenance.ts
11153
11478
  const GENERATED_HEADER_RE = /^\s*(?:\/\/|#|\/\*)\s*(?:auto[- ]?generated|generated by|do not edit)/i;
11154
11479
  /**
@@ -11250,6 +11575,7 @@ function isDeclaredByPresent(f, present, metaByRuleId) {
11250
11575
  for (const g of present) {
11251
11576
  if (g === f || g.ruleId === f.ruleId) continue;
11252
11577
  if (!metaByRuleId.get(g.ruleId)?.overlapWith?.includes(f.ruleId)) continue;
11578
+ if (f.column === void 0 || g.column === void 0) continue;
11253
11579
  if (Math.abs(g.column - f.column) > MAX_SAME_ROOT_COLUMN_DELTA) continue;
11254
11580
  return true;
11255
11581
  }
@@ -11283,79 +11609,496 @@ function ranksBefore(a, b, metaByRuleId) {
11283
11609
  function dropOrderBefore(a, b, metaByRuleId) {
11284
11610
  if (ranksBefore(a, b, metaByRuleId)) return false;
11285
11611
  if (ranksBefore(b, a, metaByRuleId)) return true;
11286
- return a.column < b.column;
11612
+ return (a.column ?? Number.MAX_SAFE_INTEGER) < (b.column ?? Number.MAX_SAFE_INTEGER);
11287
11613
  }
11288
11614
  //#endregion
11289
- //#region src/engine/scan-cache.ts
11615
+ //#region src/engine/correlation-engine.ts
11616
+ const EVIDENCE_RANK = {
11617
+ E0: 0,
11618
+ E1: 1,
11619
+ E2: 2
11620
+ };
11621
+ function bestEvidence(findings) {
11622
+ let best = "E0";
11623
+ for (const f of findings) {
11624
+ const level = f.evidenceLevel ?? "E0";
11625
+ if (EVIDENCE_RANK[level] > EVIDENCE_RANK[best]) best = level;
11626
+ }
11627
+ return best;
11628
+ }
11629
+ function hasRuntimeCorroboration(findings) {
11630
+ return findings.some((f) => f.runtimeCorroboration !== void 0);
11631
+ }
11290
11632
  /**
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).
11633
+ * Determine the correlation strength for a group of findings.
11308
11634
  *
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.
11635
+ * TI-009: E1+E1+E1 ≠ E2. Multiple weak findings SUPPORT but never
11636
+ * STRONG. Only E2 or runtime corroboration yields STRONG.
11313
11637
  */
11314
- const CACHE_VERSION = 2;
11315
- /** Entry cap: a monorepo-scale suite stays far below this; bounded file. */
11316
- const MAX_ENTRIES = 4096;
11638
+ function correlationStrength(findings) {
11639
+ const best = bestEvidence(findings);
11640
+ if (hasRuntimeCorroboration(findings)) return "STRONG";
11641
+ if (best === "E2") return "STRONG";
11642
+ if (best === "E1" && findings.length > 1) return "SUPPORTING";
11643
+ if (best === "E1") return "SUPPORTING";
11644
+ return "NONE";
11645
+ }
11317
11646
  /**
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).
11647
+ * Group findings by file to detect file-level convergence.
11322
11648
  */
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");
11649
+ function groupByFile(findings) {
11650
+ const groups = /* @__PURE__ */ new Map();
11651
+ for (const f of findings) {
11652
+ const list = groups.get(f.file) ?? [];
11653
+ list.push(f);
11654
+ groups.set(f.file, list);
11655
+ }
11656
+ return groups;
11327
11657
  }
11328
11658
  /**
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.
11659
+ * Group findings by rootCauseId (or ruleId when rootCauseId is absent)
11660
+ * to detect convergent findings pointing at the same root cause.
11334
11661
  */
11335
- function computeRulesDigest(rules) {
11336
- return sha256(rules.map((r) => `${r.id}:${r.detectorRevision ?? 1}:${sha256(String(r.run)).slice(0, 12)}`).sort().join("|"));
11662
+ function groupByRootCause(findings) {
11663
+ const groups = /* @__PURE__ */ new Map();
11664
+ for (const f of findings) {
11665
+ const key = f.rootCauseId ?? f.ruleId;
11666
+ const list = groups.get(key) ?? [];
11667
+ list.push(f);
11668
+ groups.set(key, list);
11669
+ }
11670
+ return groups;
11337
11671
  }
11338
11672
  /**
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
11349
- * belongs only to the fallback mode.
11673
+ * Correlate findings and produce conclusions WITHOUT modifying them.
11674
+ * Deterministic — same input always produces same output.
11350
11675
  */
11351
- function fileCacheKey(rulesDigest, fileText, identity) {
11352
- const parseMode = identity.parseMode ?? "ast";
11353
- return sha256(`${CACHE_VERSION}\u0000${rulesDigest}\u0000${identity.relPath}\u0000${identity.adapterId}\u0000${parseMode}\u0000${fileText}`);
11676
+ function correlateFindings(findings) {
11677
+ const conclusions = [];
11678
+ const rootCauseGroups = groupByRootCause(findings);
11679
+ for (const [rootCause, group] of Array.from(rootCauseGroups.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
11680
+ if (group.length < 2) continue;
11681
+ const findingIds = group.map((f) => f.findingId ?? f.ruleId);
11682
+ conclusions.push({
11683
+ conclusionType: "CONVERGENT",
11684
+ certainty: correlationStrength(group),
11685
+ corroboration: `${group.length} findings share root cause ${rootCause}`,
11686
+ sourceCount: group.length,
11687
+ findingIds
11688
+ });
11689
+ }
11690
+ const fileGroups = groupByFile(findings);
11691
+ for (const [file, group] of Array.from(fileGroups.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
11692
+ if (group.length < 2) continue;
11693
+ const alreadyConvergent = /* @__PURE__ */ new Set();
11694
+ for (const c of conclusions) if (c.conclusionType === "CONVERGENT") for (const id of c.findingIds) alreadyConvergent.add(id);
11695
+ const novel = group.filter((f) => !alreadyConvergent.has(f.findingId ?? f.ruleId));
11696
+ if (novel.length < 2) continue;
11697
+ const findingIds = novel.map((f) => f.findingId ?? f.ruleId);
11698
+ conclusions.push({
11699
+ conclusionType: "AMPLIFIED",
11700
+ certainty: correlationStrength(novel),
11701
+ corroboration: `${novel.length} findings in ${file}`,
11702
+ sourceCount: novel.length,
11703
+ findingIds
11704
+ });
11705
+ }
11706
+ const corroborated = findings.filter((f) => f.runtimeCorroboration !== void 0);
11707
+ if (corroborated.length > 0) conclusions.push({
11708
+ conclusionType: "CORROBORATED",
11709
+ certainty: "STRONG",
11710
+ corroboration: `${corroborated.length} finding(s) with runtime corroboration`,
11711
+ sourceCount: corroborated.length,
11712
+ findingIds: corroborated.map((f) => f.findingId ?? f.ruleId)
11713
+ });
11714
+ return conclusions;
11354
11715
  }
11355
- /** No-op cache used when --cache is absent: zero stats, zero I/O. */
11356
- const disabledScanCache = {
11357
- stats: {
11358
- hits: 0,
11716
+ //#endregion
11717
+ //#region src/engine/dependency-graph.ts
11718
+ /**
11719
+ * Dependency Graph (ECO-005).
11720
+ *
11721
+ * Parses package manifests (package.json, pyproject.toml, pom.xml) to
11722
+ * build a dependency graph. Supports transitive dependency resolution,
11723
+ * dependent lookup, and reachable-file computation from test files.
11724
+ */
11725
+ var DependencyGraph = class {
11726
+ nodes = /* @__PURE__ */ new Map();
11727
+ addNode(node) {
11728
+ this.nodes.set(node.path, {
11729
+ path: node.path,
11730
+ dependencies: [...node.dependencies]
11731
+ });
11732
+ }
11733
+ getDependencies(path) {
11734
+ return this.nodes.get(path)?.dependencies ?? [];
11735
+ }
11736
+ getTransitiveDependencies(path) {
11737
+ const visited = /* @__PURE__ */ new Set();
11738
+ const stack = [path];
11739
+ while (stack.length > 0) {
11740
+ const current = stack.pop();
11741
+ if (current === void 0) continue;
11742
+ if (visited.has(current)) continue;
11743
+ visited.add(current);
11744
+ const node = this.nodes.get(current);
11745
+ if (node) {
11746
+ for (const dep of node.dependencies) if (!visited.has(dep)) stack.push(dep);
11747
+ }
11748
+ }
11749
+ visited.delete(path);
11750
+ return [...visited].sort();
11751
+ }
11752
+ getDependents(path) {
11753
+ const dependents = [];
11754
+ for (const [key, node] of this.nodes) if (node.dependencies.includes(path)) dependents.push(key);
11755
+ return dependents.sort();
11756
+ }
11757
+ get allPaths() {
11758
+ return [...this.nodes.keys()].sort();
11759
+ }
11760
+ get size() {
11761
+ return this.nodes.size;
11762
+ }
11763
+ };
11764
+ function parsePackageJson(filePath) {
11765
+ if (!existsSync(filePath)) return void 0;
11766
+ try {
11767
+ const raw = readFileSync(filePath, "utf8");
11768
+ const pkg = JSON.parse(raw);
11769
+ return {
11770
+ path: filePath,
11771
+ dependencies: [...Object.keys(pkg["dependencies"] ?? {}), ...Object.keys(pkg["devDependencies"] ?? {})]
11772
+ };
11773
+ } catch {
11774
+ return;
11775
+ }
11776
+ }
11777
+ function parsePyprojectToml(filePath) {
11778
+ if (!existsSync(filePath)) return void 0;
11779
+ try {
11780
+ const raw = readFileSync(filePath, "utf8");
11781
+ const deps = [];
11782
+ const depSection = raw.match(/\[project\][\s\S]*?dependencies\s*=\s*\[([\s\S]*?)\]/);
11783
+ if (depSection?.[1]) {
11784
+ const lines = depSection[1].split("\n");
11785
+ for (const line of lines) {
11786
+ const match = line.match(/^\s*"([\w-]+)/);
11787
+ if (match?.[1]) deps.push(match[1]);
11788
+ }
11789
+ }
11790
+ return {
11791
+ path: filePath,
11792
+ dependencies: deps
11793
+ };
11794
+ } catch {
11795
+ return;
11796
+ }
11797
+ }
11798
+ function parsePomXml(filePath) {
11799
+ if (!existsSync(filePath)) return void 0;
11800
+ try {
11801
+ const raw = readFileSync(filePath, "utf8");
11802
+ const deps = [];
11803
+ const re = /<artifactId>([^<]+)<\/artifactId>/g;
11804
+ let m;
11805
+ while ((m = re.exec(raw)) !== null) if (m[1]) deps.push(m[1]);
11806
+ return {
11807
+ path: filePath,
11808
+ dependencies: deps
11809
+ };
11810
+ } catch {
11811
+ return;
11812
+ }
11813
+ }
11814
+ /**
11815
+ * Build a dependency graph from project manifests. Scans for package.json,
11816
+ * pyproject.toml, and pom.xml at the project root and one level deep.
11817
+ */
11818
+ function buildDependencyGraph(projectRoot) {
11819
+ const graph = new DependencyGraph();
11820
+ const manifestPatterns = [
11821
+ "package.json",
11822
+ "pyproject.toml",
11823
+ "pom.xml",
11824
+ join("packages", "*", "package.json")
11825
+ ];
11826
+ for (const pattern of manifestPatterns) if (pattern.includes("*")) {
11827
+ const [base, , file] = pattern.split(/[\\/]/);
11828
+ if (base === void 0 || file === void 0) continue;
11829
+ const baseDir = resolve(projectRoot, base);
11830
+ if (!existsSync(baseDir)) continue;
11831
+ try {
11832
+ const entries = readdirSync(baseDir, { withFileTypes: true });
11833
+ for (const entry of entries) if (entry.isDirectory()) {
11834
+ const node = parseManifest(resolve(baseDir, entry.name, file));
11835
+ if (node) graph.addNode(node);
11836
+ }
11837
+ } catch {}
11838
+ } else {
11839
+ const node = parseManifest(resolve(projectRoot, pattern));
11840
+ if (node) graph.addNode(node);
11841
+ }
11842
+ return graph;
11843
+ }
11844
+ function parseManifest(filePath) {
11845
+ if (filePath.endsWith("package.json")) return parsePackageJson(filePath);
11846
+ if (filePath.endsWith("pyproject.toml")) return parsePyprojectToml(filePath);
11847
+ if (filePath.endsWith("pom.xml")) return parsePomXml(filePath);
11848
+ }
11849
+ //#endregion
11850
+ //#region src/engine/incremental-analysis.ts
11851
+ /**
11852
+ * Semantic inputs that affect the full scan beyond individual file content.
11853
+ * If any of these changed, an incremental run is NOT safe — a full scan
11854
+ * is required for equivalence (TI-005).
11855
+ */
11856
+ const SEMANTIC_INPUT_PATTERNS = [
11857
+ "mjolnir.config",
11858
+ ".mjolnirignore",
11859
+ "package.json",
11860
+ "tsconfig",
11861
+ "vitest.config",
11862
+ "jest.config",
11863
+ "playwright.config"
11864
+ ];
11865
+ /**
11866
+ * Verify whether an incremental scan is safe. Returns true when ONLY
11867
+ * regular source/test files changed and no semantic config inputs were
11868
+ * touched. When false, the caller must fall back to a full scan.
11869
+ *
11870
+ * TI-005 contract: incremental safety is a prerequisite for equivalence.
11871
+ * A safe incremental run re-scans only changed files and merges their
11872
+ * results with cached verdicts for unchanged files — the aggregate must
11873
+ * match a full scan byte-for-byte.
11874
+ */
11875
+ function isIncrementalSafe(changedFiles) {
11876
+ const reasons = [];
11877
+ for (const f of changedFiles) {
11878
+ const lower = f.path.toLowerCase().replace(/\\/g, "/");
11879
+ for (const pattern of SEMANTIC_INPUT_PATTERNS) if (lower.includes(pattern)) reasons.push(`semantic input changed: ${f.path} matches "${pattern}" pattern`);
11880
+ }
11881
+ return {
11882
+ safe: reasons.length === 0,
11883
+ reasons
11884
+ };
11885
+ }
11886
+ //#endregion
11887
+ //#region src/engine/monorepo-analysis.ts
11888
+ const WORTHY_THRESHOLD = 80;
11889
+ const NEEDS_WORK_THRESHOLD = 50;
11890
+ function verdictOf(score) {
11891
+ if (score === null) return "fail";
11892
+ if (score >= WORTHY_THRESHOLD) return "pass";
11893
+ if (score >= NEEDS_WORK_THRESHOLD) return "warn";
11894
+ return "fail";
11895
+ }
11896
+ function hasBlocker(findings) {
11897
+ return findings.some((f) => f.severity === "error");
11898
+ }
11899
+ /**
11900
+ * Aggregate per-package results into an overall verdict.
11901
+ *
11902
+ * Worst-package propagation: if ANY package has a blocker (error-severity
11903
+ * finding) or a failing score, the overall result fails regardless of
11904
+ * strategy. This is the safety net — a single poisoned package must not
11905
+ * hide behind averaging.
11906
+ */
11907
+ function analyzeMonorepo(packages, config) {
11908
+ const results = packages.map((p) => ({
11909
+ ...p,
11910
+ verdict: verdictOf(p.score)
11911
+ }));
11912
+ if (results.length === 0) return {
11913
+ packages: results,
11914
+ overallScore: null,
11915
+ overallVerdict: "fail",
11916
+ strategy: config.weightingStrategy
11917
+ };
11918
+ const blockerPkg = results.find((r) => r.verdict === "fail" || hasBlocker(r.findings));
11919
+ if (blockerPkg) return {
11920
+ packages: results,
11921
+ overallScore: blockerPkg.score,
11922
+ overallVerdict: "fail",
11923
+ strategy: config.weightingStrategy,
11924
+ blockerPackage: blockerPkg.packageName
11925
+ };
11926
+ switch (config.weightingStrategy) {
11927
+ case "worst-package": return worstPackageAggregation(results, config);
11928
+ case "average": return averageAggregation(results, config);
11929
+ case "configurable": return configurableAggregation(results, config);
11930
+ }
11931
+ }
11932
+ function worstPackageAggregation(results, config) {
11933
+ const firstResult = results[0];
11934
+ if (firstResult === void 0) return {
11935
+ packages: results,
11936
+ overallScore: null,
11937
+ overallVerdict: "fail",
11938
+ strategy: config.weightingStrategy
11939
+ };
11940
+ let worst = firstResult;
11941
+ for (const r of results) if ((r.score ?? 0) < (worst.score ?? 0)) worst = r;
11942
+ return {
11943
+ packages: results,
11944
+ overallScore: worst.score,
11945
+ overallVerdict: worst.verdict,
11946
+ strategy: config.weightingStrategy,
11947
+ ...worst.verdict === "fail" ? { blockerPackage: worst.packageName } : {}
11948
+ };
11949
+ }
11950
+ function averageAggregation(results, config) {
11951
+ const scored = results.filter((r) => r.score !== null);
11952
+ if (scored.length === 0) return {
11953
+ packages: results,
11954
+ overallScore: null,
11955
+ overallVerdict: "fail",
11956
+ strategy: config.weightingStrategy
11957
+ };
11958
+ const avg = scored.reduce((sum, r) => sum + (r.score ?? 0), 0) / scored.length;
11959
+ return {
11960
+ packages: results,
11961
+ overallScore: Math.round(avg),
11962
+ overallVerdict: verdictOf(Math.round(avg)),
11963
+ strategy: config.weightingStrategy
11964
+ };
11965
+ }
11966
+ function configurableAggregation(results, config) {
11967
+ const weights = config.packageWeights ?? {};
11968
+ let totalWeight = 0;
11969
+ let weightedSum = 0;
11970
+ for (const r of results) {
11971
+ const w = weights[r.packageName] ?? 1;
11972
+ totalWeight += w;
11973
+ weightedSum += (r.score ?? 0) * w;
11974
+ }
11975
+ if (totalWeight === 0) return {
11976
+ packages: results,
11977
+ overallScore: null,
11978
+ overallVerdict: "fail",
11979
+ strategy: config.weightingStrategy
11980
+ };
11981
+ const score = Math.round(weightedSum / totalWeight);
11982
+ return {
11983
+ packages: results,
11984
+ overallScore: score,
11985
+ overallVerdict: verdictOf(score),
11986
+ strategy: config.weightingStrategy
11987
+ };
11988
+ }
11989
+ //#endregion
11990
+ //#region src/engine/scan-cache.ts
11991
+ /**
11992
+ * Local incremental scan cache (Beta-to-Stable 1.0 plan, M5.2 / A-2).
11993
+ *
11994
+ * Content-addressed, local-only verdict cache: `--cache` reuses the
11995
+ * per-file rule outputs of a previous scan when the file's bytes AND the
11996
+ * active rule set are unchanged, and invalidates everything else. The
11997
+ * key is `sha256(fileText) + rulesDigest` + the file's own identity
11998
+ * (repo-relative path + adapter id + parse mode — audit C1/W9), where
11999
+ * the rules digest folds
12000
+ * in every active rule's id + `detectorRevision ?? 1` (the existing
12001
+ * stale-measurement machinery — Verification Trust Evolution Plan §07 —
12002
+ * reused as the cache invalidation signal, per A-2) plus a source hash
12003
+ * of each external plugin/local rule's `run` function, so a plugin that
12004
+ * changes code without bumping its revision still misses.
12005
+ *
12006
+ * Privacy posture: the cache lives under `<repo>/.mjolnir/cache/`, is
12007
+ * gitignored, never leaves the machine, and this module performs zero
12008
+ * network I/O — fs and crypto only (asserted by the privacy spec).
12009
+ *
12010
+ * Only raw rule-loop outputs are cached. Everything after the loop
12011
+ * (severity overrides, suppressions, overlap dedup, evidence stamping,
12012
+ * tier policy, runtime corroboration, scoring) re-runs on every scan,
12013
+ * so a cached scan is byte-equivalent to a fresh one by construction.
12014
+ */
12015
+ const CACHE_VERSION = 2;
12016
+ /** Entry cap: a monorepo-scale suite stays far below this; bounded file. */
12017
+ const MAX_ENTRIES = 4096;
12018
+ /**
12019
+ * Audit M5: total byte budget. The old cap counted only ENTRIES, so
12020
+ * 4096 files × ~100KB of findings each could still produce a
12021
+ * multi-hundred-MB writeFileSync on persist. The budget bounds the
12022
+ * serialized size; the newest entries win (real LRU-by-use).
12023
+ */
12024
+ const MAX_TOTAL_BYTES = 33554432;
12025
+ /** sha256 hex of a string — the only hash this module needs. */
12026
+ function sha256(text) {
12027
+ return createHash("sha256").update(text).digest("hex");
12028
+ }
12029
+ /**
12030
+ * Digest of the active rule set: id + effective detectorRevision for
12031
+ * every rule (sorted for determinism), plus a content hash of all rule
12032
+ * source files. The directory-tree hash catches changes to ANY file
12033
+ * under src/rules/ — same-file helpers, cross-module imports, shared
12034
+ * utilities — without needing to trace the import graph. This is the
12035
+ * only reliable way to detect helper function changes in a bundled
12036
+ * ESM codebase where String(fn) doesn't reveal transitive dependencies.
12037
+ */
12038
+ function computeRulesDigest(rules) {
12039
+ const parts = rules.map((r) => `${r.id}:${r.detectorRevision ?? 1}`).sort();
12040
+ const rulesDirHash = hashRulesSourceTree(rules);
12041
+ return sha256(parts.join("|") + "\0" + rulesDirHash);
12042
+ }
12043
+ /**
12044
+ * Hash all .ts files under src/rules/ (when the first rule's modulePath
12045
+ * reveals the project root). Falls back to per-rule function source
12046
+ * hashing when the directory cannot be resolved.
12047
+ */
12048
+ function hashRulesSourceTree(rules) {
12049
+ const firstPath = rules.find((r) => r.modulePath)?.modulePath;
12050
+ if (!firstPath) return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
12051
+ const idx = firstPath.replace(/\\/g, "/").indexOf("/src/rules/");
12052
+ if (idx < 0) return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
12053
+ const rulesDir = firstPath.replace(/\\/g, "/").slice(0, idx + 11);
12054
+ try {
12055
+ const hash = createHash("sha256");
12056
+ hashDir(rulesDir, hash, 0);
12057
+ return hash.digest("hex").slice(0, 16);
12058
+ } catch {
12059
+ return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
12060
+ }
12061
+ }
12062
+ function hashDir(dir, hash, depth) {
12063
+ if (depth > 8) return;
12064
+ let entries;
12065
+ try {
12066
+ entries = readdirSync(dir, { withFileTypes: true });
12067
+ } catch {
12068
+ return;
12069
+ }
12070
+ entries.sort((a, b) => a.name.localeCompare(b.name));
12071
+ for (const entry of entries) {
12072
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
12073
+ const full = join(dir, entry.name);
12074
+ if (entry.isDirectory()) hashDir(full, hash, depth + 1);
12075
+ else if (entry.isFile() && /\.tsx?$/.test(entry.name)) try {
12076
+ hash.update(entry.name);
12077
+ hash.update(readFileSync(full));
12078
+ } catch {}
12079
+ }
12080
+ }
12081
+ /**
12082
+ * Content-addressed key for one file's rule-loop verdicts.
12083
+ *
12084
+ * Audit C1: the key MUST identify the verdict's producer, not just the
12085
+ * bytes — two files with byte-identical text (a copied spec, a generated
12086
+ * snapshot) previously shared one entry, and the first file's cached
12087
+ * findings were re-emitted for the second with the wrong `file` stamp.
12088
+ * The key therefore folds in the repo-relative path AND the adapter id,
12089
+ * plus a parse-mode token (audit W9): a file whose analysis degraded to
12090
+ * the regex fallback (or skipped the AST path) must not collide with a
12091
+ * fully-AST-analyzed verdict for the same bytes — the fallback output
12092
+ * belongs only to the fallback mode.
12093
+ */
12094
+ function fileCacheKey(rulesDigest, fileText, identity) {
12095
+ const parseMode = identity.parseMode ?? "ast";
12096
+ return sha256(`${CACHE_VERSION}\u0000${rulesDigest}\u0000${identity.relPath}\u0000${identity.adapterId}\u0000${parseMode}\u0000${fileText}`);
12097
+ }
12098
+ /** No-op cache used when --cache is absent: zero stats, zero I/O. */
12099
+ const disabledScanCache = {
12100
+ stats: {
12101
+ hits: 0,
11359
12102
  misses: 0,
11360
12103
  file: ""
11361
12104
  },
@@ -11377,14 +12120,11 @@ function createScanCache(root) {
11377
12120
  let totalBytes = 0;
11378
12121
  try {
11379
12122
  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
- }
12123
+ entries = parseJsonFile(readFileSync(file, "utf8"), file, (v) => isRecord(v) && v["version"] === CACHE_VERSION && isRecord(v["entries"])).entries;
12124
+ for (const [k, v] of Object.entries(entries)) {
12125
+ const size = JSON.stringify(v).length + k.length + 4;
12126
+ entryBytes.set(k, size);
12127
+ totalBytes += size;
11388
12128
  }
11389
12129
  }
11390
12130
  } catch {
@@ -11498,6 +12238,10 @@ const BRAND = {
11498
12238
  aurora: "#37ABBD",
11499
12239
  auroraBright: "#45C1D4",
11500
12240
  auroraCyan: "#5CBDE0",
12241
+ /** The aurora's outer curtains: atmosphere and section identity only,
12242
+ * never a verdict or a status. */
12243
+ auroraGreen: "#5FD6A4",
12244
+ auroraViolet: "#9D8CF5",
11501
12245
  steel: "#C8CBCF",
11502
12246
  steelDim: "#8B939D"
11503
12247
  };
@@ -11601,7 +12345,7 @@ const TRUST = {
11601
12345
  l4: "#45C1D4",
11602
12346
  l5: "#5CC4E0"
11603
12347
  };
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;
12348
+ 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
12349
  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
12350
  //#endregion
11607
12351
  //#region src/reporter/theme.ts
@@ -11720,9 +12464,14 @@ function plainContext(width = 80) {
11720
12464
  };
11721
12465
  }
11722
12466
  const FLAKE_GLYPH = "🔥";
11723
- /** Section header: `▚ TITLE` (ascii `= TITLE`). The one header style. */
12467
+ /**
12468
+ * Section header: `▍ TITLE` (ascii `= TITLE`). The one header style.
12469
+ * A thin accent bar, the terminal's version of the rule that opens each
12470
+ * chapter on the website; it replaced a checkerboard quadrant (▚) that
12471
+ * read as a rendering glitch rather than a mark.
12472
+ */
11724
12473
  function sectionHeader(title, ui) {
11725
- const glyph = ui.ascii ? "=" : "▚";
12474
+ const glyph = ui.ascii ? "=" : "▍";
11726
12475
  return ` ${ui.p.accent(`${glyph} ${title}`)}`;
11727
12476
  }
11728
12477
  //#endregion
@@ -11892,6 +12641,7 @@ function analyze(records, source) {
11892
12641
  });
11893
12642
  }
11894
12643
  return {
12644
+ forensicsSchemaVersion: 1,
11895
12645
  source,
11896
12646
  totalTests: verdicts.length,
11897
12647
  failed,
@@ -11899,7 +12649,10 @@ function analyze(records, source) {
11899
12649
  retriedTests: retried,
11900
12650
  flakyTests: flaky,
11901
12651
  totalDurationMs: totalDuration,
11902
- verdicts
12652
+ verdicts,
12653
+ analysisComplete: true,
12654
+ skippedReports: 0,
12655
+ incompleteReasons: []
11903
12656
  };
11904
12657
  }
11905
12658
  /** Flakiness Leaderboard: flakiest first, then slowest. */
@@ -11923,6 +12676,7 @@ function renderLeaderboard(report) {
11923
12676
  const top = leaderboard(report);
11924
12677
  if (top.length === 0) {
11925
12678
  lines.push("No failures or retries found — nothing suspicious this run.");
12679
+ if (!report.analysisComplete) lines.push(`⚠ Analysis is partial — ${report.skippedReports} report(s) skipped (${report.incompleteReasons.join(", ")}).`);
11926
12680
  return lines.join("\n");
11927
12681
  }
11928
12682
  const maxMs = Math.max(...top.map((v) => v.totalDurationMs), 1);
@@ -11930,6 +12684,10 @@ function renderLeaderboard(report) {
11930
12684
  const flag = v.passedOnRetry ? "TRUE-FLAKE" : "FAILING";
11931
12685
  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
12686
  }
12687
+ if (!report.analysisComplete) {
12688
+ lines.push("");
12689
+ lines.push(`⚠ Analysis is partial — ${report.skippedReports} report(s) skipped (${report.incompleteReasons.join(", ")}).`);
12690
+ }
11933
12691
  return lines.join("\n");
11934
12692
  }
11935
12693
  /** FLAKY.md — the committed artifact. */
@@ -11945,6 +12703,10 @@ function renderFlakyMd(report) {
11945
12703
  const top = leaderboard(report);
11946
12704
  if (top.length === 0) {
11947
12705
  lines.push("_No flaky or failing tests detected in this run._");
12706
+ if (!report.analysisComplete) {
12707
+ lines.push("");
12708
+ lines.push(`> ⚠ Analysis is partial — ${report.skippedReports} report(s) skipped (${report.incompleteReasons.join(", ")}).`);
12709
+ }
11948
12710
  return lines.join("\n");
11949
12711
  }
11950
12712
  lines.push("| Status | Test | File | Attempts | Duration |");
@@ -11953,10 +12715,77 @@ function renderFlakyMd(report) {
11953
12715
  const status = v.passedOnRetry ? `${FLAKE_GLYPH} TRUE-FLAKE` : "❌ failing";
11954
12716
  lines.push(`| ${status} | \`${v.title}\` | \`${v.file}\` | ${v.attempts} | ${(v.totalDurationMs / 1e3).toFixed(1)}s |`);
11955
12717
  }
12718
+ if (!report.analysisComplete) {
12719
+ lines.push("");
12720
+ lines.push(`> ⚠ Analysis is partial — ${report.skippedReports} report(s) skipped (${report.incompleteReasons.join(", ")}).`);
12721
+ }
11956
12722
  lines.push("");
11957
12723
  return lines.join("\n");
11958
12724
  }
11959
12725
  //#endregion
12726
+ //#region src/forensics/evidence-hygiene.ts
12727
+ /**
12728
+ * Evidence hygiene — secret redaction + control-char sanitization
12729
+ * (ENGINE-012).
12730
+ *
12731
+ * Pure, deterministic, bounded. Every error text entering the forensics
12732
+ * pipeline passes through `sanitizeErrorText` so that secrets, control
12733
+ * characters, and oversized payloads never survive into reports or
12734
+ * committed artifacts.
12735
+ */
12736
+ const MAX_LENGTH_DEFAULT = 10240;
12737
+ const REDACTION_PATTERNS = [
12738
+ {
12739
+ name: "aws-key",
12740
+ regex: /AKIA[0-9A-Z]{16}/g
12741
+ },
12742
+ {
12743
+ name: "github-token",
12744
+ regex: /gh[pousr]_\w{36,}/g
12745
+ },
12746
+ {
12747
+ name: "jwt",
12748
+ regex: /eyJ[\w-]+\.eyJ[\w-]+\.[\w-]+/g
12749
+ },
12750
+ {
12751
+ name: "bearer-token",
12752
+ regex: /[Bb]earer\s+[\w.+-]+/g
12753
+ },
12754
+ {
12755
+ name: "api-key-assignment",
12756
+ regex: /apikey\s*[:=]\s*['"]?[\w.]{8,}['"]?|api[_-]?key\s*[:=]\s*['"]?[\w.]{8,}['"]?/gi
12757
+ },
12758
+ {
12759
+ name: "password-assignment",
12760
+ regex: /(?:password|passwd|pwd)\s*[:=]\s*['"]?[^\s'"]{4,}['"]?/gi
12761
+ },
12762
+ {
12763
+ name: "private-key",
12764
+ regex: /-----BEGIN[A\s][^-]*KEY-----[\s\S]*?-----END[A\s][^-]*KEY-----/g
12765
+ },
12766
+ {
12767
+ name: "hex-secret",
12768
+ regex: /\b[0-9a-f]{33,}\b/gi
12769
+ },
12770
+ {
12771
+ name: "base64-secret",
12772
+ regex: /\b[\w+/]{100,}={0,2}\b/g
12773
+ }
12774
+ ];
12775
+ const CONTROL_CHAR_PATTERN = /[\0\x07\x08\v\f\x0e\x0f\x1b\x7f\x80-\x9f]/g;
12776
+ /**
12777
+ * Sanitize error text: redact secrets, strip control characters, cap length.
12778
+ * Pure and deterministic — same input always yields the same output.
12779
+ */
12780
+ function sanitizeErrorText(text, options) {
12781
+ const maxLength = options?.maxLength ?? MAX_LENGTH_DEFAULT;
12782
+ let result = text;
12783
+ for (const { name, regex } of REDACTION_PATTERNS) result = result.replace(regex, `[REDACTED:${name}]`);
12784
+ result = result.replace(CONTROL_CHAR_PATTERN, "");
12785
+ if (result.length > maxLength) result = result.slice(0, maxLength) + `[truncated at ${maxLength} chars]`;
12786
+ return result;
12787
+ }
12788
+ //#endregion
11960
12789
  //#region src/forensics/trace.ts
11961
12790
  /**
11962
12791
  * Playwright trace ingester (product-gap master plan §9 R5 / WI-17).
@@ -12053,7 +12882,7 @@ function parseTraceNdjson(text) {
12053
12882
  if (start !== void 0 && end !== void 0 && end >= start) action.durationMs = Math.round((end - start) * 1e3);
12054
12883
  if (ev.error !== null && typeof ev.error === "object" && ev.error !== void 0) {
12055
12884
  const msg = ev.error.message;
12056
- action.error = typeof msg === "string" ? { message: msg } : { message: "action failed" };
12885
+ action.error = typeof msg === "string" ? { message: sanitizeErrorText(msg) } : { message: "action failed" };
12057
12886
  }
12058
12887
  actions.set(ev.callId, action);
12059
12888
  }
@@ -12073,7 +12902,7 @@ function traceActionsToRecord(actions, artifact) {
12073
12902
  totalMs += a.durationMs;
12074
12903
  if (a.error !== void 0) {
12075
12904
  failed = true;
12076
- const msg = a.error.message ?? "action failed";
12905
+ const msg = sanitizeErrorText(a.error.message ?? "action failed");
12077
12906
  errors.push(msg);
12078
12907
  if (/timed\s*out|timeout\s+of/i.test(msg)) timedOut = true;
12079
12908
  }
@@ -12194,11 +13023,18 @@ function parseJestJson(json) {
12194
13023
  };
12195
13024
  const line = typeof assertion.location?.line === "number" && assertion.location.line > 0 ? assertion.location.line : void 0;
12196
13025
  const title = assertion.title ?? "(unnamed)";
13026
+ const errors = [];
13027
+ for (const msgs of [assertion.failureMessages, assertion.failureDetails]) {
13028
+ if (!Array.isArray(msgs)) continue;
13029
+ for (const m of msgs) if (typeof m === "string" && m.length > 0) errors.push(sanitizeErrorText(m));
13030
+ else if (m !== null && typeof m === "object" && "message" in m && typeof m.message === "string") errors.push(sanitizeErrorText(m.message));
13031
+ }
12197
13032
  out.push({
12198
13033
  file,
12199
13034
  title,
12200
13035
  attempts: [attempt],
12201
- ...line !== void 0 ? { line } : {}
13036
+ ...line !== void 0 ? { line } : {},
13037
+ ...errors.length > 0 ? { errors } : {}
12202
13038
  });
12203
13039
  }
12204
13040
  }
@@ -12244,15 +13080,36 @@ function parseJunitXml(xml) {
12244
13080
  const timeRaw = attr(attrs, "time");
12245
13081
  const timeSec = timeRaw !== void 0 && Number.isFinite(Number(timeRaw)) ? Number(timeRaw) : 0;
12246
13082
  const failed = /<(?:failure|error)\b/i.test(inner);
13083
+ const skipped = /<skipped\b/i.test(inner);
13084
+ const errors = [];
13085
+ if (failed) {
13086
+ const failRe = /<(?:failure|error)\b([^>]*)>([\s\S]*?)<\/(?:failure|error)\s*>/gi;
13087
+ let failMatch;
13088
+ while ((failMatch = failRe.exec(inner)) !== null) {
13089
+ const attrsStr = failMatch[1] ?? "";
13090
+ const msgAttr = /\smessage\s*=\s*"([^"]*)"/i.exec(attrsStr)?.[1];
13091
+ if (msgAttr) errors.push(sanitizeErrorText(decodeEntities(msgAttr)));
13092
+ const body = failMatch[2]?.trim();
13093
+ if (body && body !== msgAttr) errors.push(sanitizeErrorText(decodeEntities(body)));
13094
+ }
13095
+ const selfCloseRe = /<(?:failure|error)\b([^>]*)\/>/gi;
13096
+ let scMatch;
13097
+ while ((scMatch = selfCloseRe.exec(inner)) !== null) {
13098
+ const scAttrs = scMatch[1] ?? "";
13099
+ const scMsgMatch = /\smessage\s*=\s*"([^"]*)"/i.exec(scAttrs);
13100
+ if (scMsgMatch?.[1]) errors.push(sanitizeErrorText(decodeEntities(scMsgMatch[1])));
13101
+ }
13102
+ }
12247
13103
  const attempt = {
12248
13104
  index: 1,
12249
- status: /<skipped\b/i.test(inner) ? "skipped" : failed ? "failed" : "passed",
13105
+ status: skipped ? "skipped" : failed ? "failed" : "passed",
12250
13106
  durationMs: Math.round(timeSec * 1e3)
12251
13107
  };
12252
13108
  out.push({
12253
13109
  file: classname || "unknown",
12254
13110
  title: name,
12255
- attempts: [attempt]
13111
+ attempts: [attempt],
13112
+ ...errors.length > 0 ? { errors } : {}
12256
13113
  });
12257
13114
  }
12258
13115
  return out;
@@ -12289,6 +13146,7 @@ function parsePlaywrightJson(json) {
12289
13146
  const attempts = [];
12290
13147
  let i = 0;
12291
13148
  const results = Array.isArray(test.results) ? test.results : [];
13149
+ const collectedErrors = [];
12292
13150
  for (const r of results) {
12293
13151
  if (!r || typeof r !== "object") continue;
12294
13152
  attempts.push({
@@ -12296,6 +13154,16 @@ function parsePlaywrightJson(json) {
12296
13154
  status: toStatus$1(r.status),
12297
13155
  durationMs: Number.isFinite(r.duration) ? Math.max(0, r.duration) : 0
12298
13156
  });
13157
+ if (r.error !== void 0 && r.error !== null) {
13158
+ const errObj = r.error;
13159
+ if (typeof errObj.message === "string" && errObj.message.length > 0) collectedErrors.push(sanitizeErrorText(errObj.message));
13160
+ }
13161
+ if (Array.isArray(r.errors)) {
13162
+ for (const e of r.errors) if (e !== null && typeof e === "object") {
13163
+ const eObj = e;
13164
+ if (typeof eObj.message === "string" && eObj.message.length > 0) collectedErrors.push(sanitizeErrorText(eObj.message));
13165
+ } else if (typeof e === "string" && e.length > 0) collectedErrors.push(sanitizeErrorText(e));
13166
+ }
12299
13167
  }
12300
13168
  if (attempts.length === 0) continue;
12301
13169
  const specLine = typeof spec.location?.line === "number" && spec.location.line >= 0 ? spec.location.line + 1 : void 0;
@@ -12303,7 +13171,8 @@ function parsePlaywrightJson(json) {
12303
13171
  file,
12304
13172
  title: spec.title ?? "(unnamed)",
12305
13173
  attempts,
12306
- ...specLine !== void 0 ? { line: specLine } : {}
13174
+ ...specLine !== void 0 ? { line: specLine } : {},
13175
+ ...collectedErrors.length > 0 ? { errors: collectedErrors } : {}
12307
13176
  });
12308
13177
  }
12309
13178
  }
@@ -12349,11 +13218,18 @@ function parseVitestJson(json) {
12349
13218
  durationMs
12350
13219
  };
12351
13220
  const line = typeof assertion.location?.line === "number" && assertion.location.line > 0 ? assertion.location.line : void 0;
13221
+ const errors = [];
13222
+ for (const msgs of [assertion.failureMessages, assertion.failureDetails]) {
13223
+ if (!Array.isArray(msgs)) continue;
13224
+ for (const m of msgs) if (typeof m === "string" && m.length > 0) errors.push(sanitizeErrorText(m));
13225
+ else if (m !== null && typeof m === "object" && "message" in m && typeof m.message === "string") errors.push(sanitizeErrorText(m.message));
13226
+ }
12352
13227
  out.push({
12353
13228
  file,
12354
13229
  title: assertion.title ?? "(unnamed)",
12355
13230
  attempts: [attempt],
12356
- ...line !== void 0 ? { line } : {}
13231
+ ...line !== void 0 ? { line } : {},
13232
+ ...errors.length > 0 ? { errors } : {}
12357
13233
  });
12358
13234
  }
12359
13235
  }
@@ -12374,9 +13250,13 @@ function parseVitestJson(json) {
12374
13250
  * Writes FLAKY.md next to the scan target unless --no-flaky-md.
12375
13251
  */
12376
13252
  const MAX_FILES = 500;
13253
+ const MAX_REPORT_FILE_BYTES = 1048576;
13254
+ const MAX_CUMULATIVE_BYTES = 52428800;
12377
13255
  function runForensics(target, options = {}) {
12378
13256
  const records = [];
12379
13257
  let source = "playwright-json";
13258
+ let skippedReports = 0;
13259
+ const incompleteReasons = [];
12380
13260
  if (!existsSync(target)) {
12381
13261
  const report = analyze(records, source);
12382
13262
  return {
@@ -12390,6 +13270,22 @@ function runForensics(target, options = {}) {
12390
13270
  }
12391
13271
  const stat = statSync(target);
12392
13272
  if (stat.isFile()) {
13273
+ if (stat.size > MAX_REPORT_FILE_BYTES) {
13274
+ skippedReports = 1;
13275
+ incompleteReasons.push("size-limit");
13276
+ const report = analyze([], source);
13277
+ report.analysisComplete = false;
13278
+ report.skippedReports = skippedReports;
13279
+ report.incompleteReasons = incompleteReasons;
13280
+ return {
13281
+ report,
13282
+ output: [
13283
+ renderLeaderboard(report),
13284
+ "",
13285
+ renderFlakyMdNotWritten(options)
13286
+ ].join("\n")
13287
+ };
13288
+ }
12393
13289
  if (/\.(?:zip|trace|ndjson)$/i.test(target)) try {
12394
13290
  const record = parseTraceArtifact(readFileSync(target), traceArtifactName(target));
12395
13291
  if (record === void 0) {
@@ -12414,6 +13310,9 @@ function runForensics(target, options = {}) {
12414
13310
  };
12415
13311
  } catch {
12416
13312
  const report = analyze([], "playwright-trace");
13313
+ report.analysisComplete = false;
13314
+ report.skippedReports = 1;
13315
+ report.incompleteReasons = ["parse-failure"];
12417
13316
  return {
12418
13317
  report,
12419
13318
  output: [
@@ -12427,12 +13326,34 @@ function runForensics(target, options = {}) {
12427
13326
  const parsed = parseFile(target, readFileSync(target, "utf8"));
12428
13327
  records.push(...parsed.records);
12429
13328
  source = parsed.source;
12430
- } catch {}
13329
+ } catch {
13330
+ skippedReports = 1;
13331
+ incompleteReasons.push("parse-failure");
13332
+ }
12431
13333
  } else {
12432
13334
  let count = 0;
12433
- for (const full of listFiles(target)) {
12434
- if (++count > MAX_FILES) break;
13335
+ let cumulativeBytes = 0;
13336
+ let hitFileLimit = false;
13337
+ let hitCumulativeLimit = false;
13338
+ const allFiles = listFiles(target);
13339
+ for (const full of allFiles) {
13340
+ if (++count > MAX_FILES) {
13341
+ hitFileLimit = true;
13342
+ break;
13343
+ }
12435
13344
  try {
13345
+ const fileStat = statSync(full);
13346
+ if (fileStat.size > MAX_REPORT_FILE_BYTES) {
13347
+ skippedReports++;
13348
+ if (!incompleteReasons.includes("size-limit")) incompleteReasons.push("size-limit");
13349
+ continue;
13350
+ }
13351
+ cumulativeBytes += fileStat.size;
13352
+ if (cumulativeBytes > MAX_CUMULATIVE_BYTES) {
13353
+ hitCumulativeLimit = true;
13354
+ if (!incompleteReasons.includes("cumulative-size-limit")) incompleteReasons.push("cumulative-size-limit");
13355
+ break;
13356
+ }
12436
13357
  if (/\.(?:zip|trace|ndjson)$/i.test(full)) {
12437
13358
  const record = parseTraceArtifact(readFileSync(full), traceArtifactName(full));
12438
13359
  if (record === void 0) continue;
@@ -12444,10 +13365,26 @@ function runForensics(target, options = {}) {
12444
13365
  if (parsed.records.length === 0) continue;
12445
13366
  if (records.length === 0) source = parsed.source;
12446
13367
  records.push(...parsed.records);
12447
- } catch {}
13368
+ } catch {
13369
+ skippedReports++;
13370
+ if (!incompleteReasons.includes("parse-failure")) incompleteReasons.push("parse-failure");
13371
+ }
13372
+ }
13373
+ if (hitFileLimit) {
13374
+ if (!incompleteReasons.includes("file-count-limit")) incompleteReasons.push("file-count-limit");
13375
+ skippedReports += allFiles.length - count + 1;
13376
+ }
13377
+ if (hitCumulativeLimit) {
13378
+ if (!incompleteReasons.includes("cumulative-size-limit")) incompleteReasons.push("cumulative-size-limit");
13379
+ skippedReports += allFiles.length - count + 1;
12448
13380
  }
12449
13381
  }
12450
13382
  const report = analyze(records, source);
13383
+ if (skippedReports > 0) {
13384
+ report.analysisComplete = false;
13385
+ report.skippedReports = skippedReports;
13386
+ report.incompleteReasons = incompleteReasons;
13387
+ }
12451
13388
  let flakyMdPath;
12452
13389
  if ((options.writeFlakyMd ?? true) && report.totalTests > 0) {
12453
13390
  const base = stat.isFile() ? dirname(target) : target;
@@ -12535,113 +13472,6 @@ function listFiles(dir) {
12535
13472
  walk(dir, 0);
12536
13473
  return out.map((p) => p);
12537
13474
  }
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
13475
  plainContext();
12646
13476
  function loadSuppressions(root) {
12647
13477
  const { config } = loadConfig(root);
@@ -12737,11 +13567,11 @@ function loadPlugins(root, gateOpen = false) {
12737
13567
  errors: [],
12738
13568
  skipped: []
12739
13569
  };
12740
- const cfgPath = join(root, "mjolnir.config.json");
12741
- if (!existsSync(cfgPath)) return result;
13570
+ const cfgPath = findConfigPath(root);
13571
+ if (!cfgPath) return result;
12742
13572
  let raw;
12743
13573
  try {
12744
- raw = JSON.parse(readFileSync(cfgPath, "utf8"));
13574
+ raw = parseJsonFile(readFileSync(cfgPath, "utf8"), cfgPath, isRecord);
12745
13575
  } catch {
12746
13576
  return result;
12747
13577
  }
@@ -12857,17 +13687,12 @@ const ALLOWED_QA_IMPACTS = /* @__PURE__ */ new Set([
12857
13687
  "FALSE-GREEN",
12858
13688
  "HYGIENE"
12859
13689
  ]);
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
13690
  /**
12866
13691
  * Discover + load external rules from `<root>/mjolnir-rules/`.
12867
13692
  * Missing directory → empty result (not an error — most workspaces
12868
13693
  * carry none).
12869
13694
  */
12870
- async function loadLocalRules(root, gateOpen = false) {
13695
+ async function loadLocalRules(root, gateOpen = true) {
12871
13696
  const result = {
12872
13697
  rules: [],
12873
13698
  errors: [],
@@ -12879,18 +13704,15 @@ async function loadLocalRules(root, gateOpen = false) {
12879
13704
  try {
12880
13705
  entries = readdirSync(dir);
12881
13706
  } catch (err) {
12882
- result.errors.push(`external rules directory "${LOCAL_RULES_DIR}/" could not be read: ${errorMessage$1(err)}`);
13707
+ result.errors.push(`external rules directory "${LOCAL_RULES_DIR}/" could not be read: ${err instanceof Error ? err.message : String(err)}`);
12883
13708
  return result;
12884
13709
  }
12885
13710
  for (const entry of entries.sort()) {
12886
13711
  const path = join(dir, entry);
12887
13712
  if (entry.endsWith(".json")) loadJsonRule(path, result);
12888
13713
  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);
13714
+ if (!gateOpen) result.skipped.push(`${LOCAL_RULES_DIR}/${entry}`);
13715
+ else await loadModuleRules(path, result);
12894
13716
  }
12895
13717
  }
12896
13718
  return result;
@@ -12901,7 +13723,7 @@ function loadJsonRule(path, result) {
12901
13723
  try {
12902
13724
  raw = JSON.parse(readFileSync(path, "utf8"));
12903
13725
  } catch (err) {
12904
- result.errors.push(`external rule "${name}" is not valid JSON: ${errorMessage$1(err)}`);
13726
+ result.errors.push(`external rule "${name}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
12905
13727
  return;
12906
13728
  }
12907
13729
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
@@ -12923,23 +13745,24 @@ function loadJsonRule(path, result) {
12923
13745
  result.errors.push(`external rule ${id} must declare a non-empty "patterns" array of regex source strings.`);
12924
13746
  return;
12925
13747
  }
13748
+ let regexes;
13749
+ try {
13750
+ regexes = patterns.map((p) => new RegExp(p, "g"));
13751
+ } catch (err) {
13752
+ result.errors.push(`external rule ${id} declares an invalid regex: ${err instanceof Error ? err.message : String(err)}`);
13753
+ return;
13754
+ }
12926
13755
  for (const p of patterns) {
12927
13756
  if (p.length > 512) {
12928
- result.errors.push(`external rule ${id} declares a pattern longer than 512 chars — rejected (bound on sync regex cost).`);
13757
+ result.errors.push(`external rule ${id} has a pattern longer than 512 chars — likely a copy-paste error.`);
12929
13758
  return;
12930
13759
  }
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).`);
13760
+ const quantifiers = p.match(/[+*?]|\{\d/g);
13761
+ if (quantifiers && quantifiers.length > 64) {
13762
+ result.errors.push(`external rule ${id} has a pattern with more than 64 quantifier/wildcard tokens — likely catastrophic backtracking.`);
12933
13763
  return;
12934
13764
  }
12935
13765
  }
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
13766
  const severity = decl["severity"];
12944
13767
  const category = decl["category"];
12945
13768
  const appliesTo = decl["appliesTo"];
@@ -12960,20 +13783,14 @@ function loadJsonRule(path, result) {
12960
13783
  result.errors.push(`external rule ${id} declares an invalid "qaImpact".`);
12961
13784
  return;
12962
13785
  }
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"];
13786
+ const title = typeof decl["title"] === "string" ? decl["title"] : id;
13787
+ const message = typeof decl["message"] === "string" ? decl["message"] : title;
12967
13788
  const why = typeof decl["why"] === "string" ? decl["why"] : "Declared by a workspace-local external rule.";
12968
13789
  const fix = typeof decl["fix"] === "string" ? decl["fix"] : "Review the matched code against the rule's intent.";
12969
13790
  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 = {
13791
+ const languages = Array.isArray(decl["languages"]) && decl["languages"].every((l) => typeof l === "string") ? decl["languages"] : void 0;
13792
+ const frameworks = Array.isArray(decl["frameworks"]) && decl["frameworks"].every((l) => typeof l === "string") ? decl["frameworks"] : void 0;
13793
+ result.rules.push({
12977
13794
  id,
12978
13795
  category,
12979
13796
  title,
@@ -12982,6 +13799,8 @@ function loadJsonRule(path, result) {
12982
13799
  findingType: "heuristic-risk",
12983
13800
  qaImpact,
12984
13801
  appliesTo,
13802
+ ...languages !== void 0 ? { languages } : {},
13803
+ ...frameworks !== void 0 ? { frameworks } : {},
12985
13804
  falsePositiveRisk: decl["falsePositiveRisk"] === "low" || decl["falsePositiveRisk"] === "high" ? decl["falsePositiveRisk"] : "medium",
12986
13805
  autofix: false,
12987
13806
  detectionStrategy: "LEXICAL",
@@ -12990,18 +13809,6 @@ function loadJsonRule(path, result) {
12990
13809
  detectorRevision: 1,
12991
13810
  run(ctx) {
12992
13811
  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
13812
  const findings = [];
13006
13813
  for (const re of regexes) {
13007
13814
  const run = new RegExp(re.source, "g");
@@ -13012,7 +13819,7 @@ function loadJsonRule(path, result) {
13012
13819
  findingType: "heuristic-risk",
13013
13820
  qaImpact,
13014
13821
  file: ctx.path,
13015
- line: lineOf(m.index),
13822
+ line: 1 + view.slice(0, m.index).split("\n").length - 1,
13016
13823
  column: m.index - (view.lastIndexOf("\n", m.index - 1) + 1) + 1,
13017
13824
  message,
13018
13825
  why,
@@ -13021,10 +13828,7 @@ function loadJsonRule(path, result) {
13021
13828
  }
13022
13829
  return findings;
13023
13830
  }
13024
- };
13025
- if (languages !== void 0) rule.languages = languages;
13026
- if (frameworks !== void 0) rule.frameworks = frameworks;
13027
- result.rules.push(rule);
13831
+ });
13028
13832
  }
13029
13833
  async function loadModuleRules(path, result) {
13030
13834
  const name = `${LOCAL_RULES_DIR}/${path.split(/[\\/]/).pop()}`;
@@ -13032,7 +13836,7 @@ async function loadModuleRules(path, result) {
13032
13836
  try {
13033
13837
  mod = await import(pathToFileURL(path).href);
13034
13838
  } catch (err) {
13035
- result.errors.push(`external rule module "${name}" failed to load: ${errorMessage$1(err)}`);
13839
+ result.errors.push(`external rule module "${name}" failed to load: ${err instanceof Error ? err.message : String(err)}`);
13036
13840
  return;
13037
13841
  }
13038
13842
  const rules = mod?.rules;
@@ -13051,25 +13855,25 @@ async function loadModuleRules(path, result) {
13051
13855
  result.errors.push(`external rule module "${name}" rule ${ruleId} uses a reserved core prefix — rejected. Use your own family.`);
13052
13856
  continue;
13053
13857
  }
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;
13858
+ if (r.tier === "core") {
13859
+ 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).`);
13860
+ rule.tier = "extended";
13057
13861
  }
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.`);
13862
+ if (typeof r.severity === "string" && !ALLOWED_SEVERITIES.has(r.severity)) {
13863
+ result.errors.push(`external rule ${r.id} declares an invalid "severity" (need error|warning|info).`);
13060
13864
  continue;
13061
13865
  }
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.`);
13866
+ if (typeof r.category === "string" && !ALLOWED_CATEGORIES.has(r.category)) {
13867
+ result.errors.push(`external rule ${r.id} declares an invalid "category" (need ${[...ALLOWED_CATEGORIES].join("|")}).`);
13064
13868
  continue;
13065
13869
  }
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.`);
13870
+ if (typeof r.appliesTo === "string" && !ALLOWED_APPLIES_TO.has(r.appliesTo)) {
13871
+ result.errors.push(`external rule ${r.id} declares an invalid "appliesTo" (need ${[...ALLOWED_APPLIES_TO].join("|")}).`);
13068
13872
  continue;
13069
13873
  }
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";
13874
+ if (typeof r.qaImpact === "string" && !ALLOWED_QA_IMPACTS.has(r.qaImpact)) {
13875
+ result.errors.push(`external rule ${r.id} declares an invalid "qaImpact" (need ${[...ALLOWED_QA_IMPACTS].join("|")}).`);
13876
+ continue;
13073
13877
  }
13074
13878
  result.rules.push(rule);
13075
13879
  }
@@ -13221,138 +14025,73 @@ function fallbackWorkspace(targetAbs) {
13221
14025
  * as before this rewrite.
13222
14026
  */
13223
14027
  function pathMatchesGlob(path, glob) {
13224
- const p = path.replaceAll("\\", "/");
13225
- const segments = glob.replaceAll("\\", "/").split("/");
13226
- let re = "^";
13227
- for (const [i, segment] of segments.entries()) {
13228
- const last = i === segments.length - 1;
13229
- if (segment === "**") {
13230
- 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++;
14028
+ const p = path.replaceAll("\\", "/");
14029
+ const segments = glob.replaceAll("\\", "/").split("/");
14030
+ let re = "^";
14031
+ for (const [i, segment] of segments.entries()) {
14032
+ const last = i === segments.length - 1;
14033
+ if (segment === "**") {
14034
+ if (last) re += "(?:[^/]+/)*[^/]+";
14035
+ else re += "(?:[^/]+/)*";
14036
+ continue;
13354
14037
  }
13355
- };
14038
+ re += segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", "[^/]*");
14039
+ if (!last) re += "/";
14040
+ }
14041
+ return new RegExp(`${re}$`).test(p);
14042
+ }
14043
+ /**
14044
+ * Plan §16 + WI-11: locate a runtime run report next to the scan
14045
+ * target, using the exact conventions the forensics ingestion already
14046
+ * accepts. Zero-config search over conventional artifact names at
14047
+ * depth ≤ 2 (src/discovery/evidence-discovery.ts); the FIRST parsable
14048
+ * candidate wins (priority: mjolnir-report > playwright-json >
14049
+ * test-results-dir > junit-file). Validates each candidate by attempting
14050
+ * to parse it and checking that it produces at least one test. Returns
14051
+ * the parsed report alongside the path to avoid double-parsing.
14052
+ */
14053
+ function discoverAndParseRuntimeReport(scanRoot) {
14054
+ for (const c of discoverEvidenceCandidates(scanRoot)) try {
14055
+ const fr = runForensics(c.path, { writeFlakyMd: false });
14056
+ if (fr.report.totalTests > 0) return {
14057
+ path: c.path,
14058
+ report: fr.report
14059
+ };
14060
+ } catch {}
14061
+ }
14062
+ /**
14063
+ * Audit W10 — runtime shape validation at the rule→Finding boundary.
14064
+ * A finding record coming out of a rule (plugin/JSON-manifest rules are
14065
+ * external data, not trusted internal code) must carry the fields the
14066
+ * whole downstream pipeline indexes on: severity within the enum,
14067
+ * integer line ≥ 1, non-empty message, and a file path. Malformed
14068
+ * records are rejected (routed to the crash/plugin-error channel by the
14069
+ * caller) — never silently scored.
14070
+ */
14071
+ function isValidFindingRecord(f) {
14072
+ if (typeof f !== "object" || f === null) return false;
14073
+ const rec = f;
14074
+ if (rec["severity"] !== "error" && rec["severity"] !== "warning" && rec["severity"] !== "info") return false;
14075
+ if (typeof rec["line"] !== "number" || !Number.isInteger(rec["line"]) || rec["line"] < 1) return false;
14076
+ if (typeof rec["message"] !== "string" || rec["message"] === "") return false;
14077
+ if (typeof rec["file"] !== "string" || rec["file"] === "") return false;
14078
+ return true;
14079
+ }
14080
+ /**
14081
+ * Replace 7-way nested ternary with a linear probe.
14082
+ * Returns the adapter whose `isTestFile` matches the path, falling
14083
+ * back to the TypeScript adapter (the default language).
14084
+ */
14085
+ function selectAdapter(path) {
14086
+ if (githubActionsAdapter.isTestFile(path)) return githubActionsAdapter;
14087
+ if (azurePipelinesAdapter.isTestFile(path)) return azurePipelinesAdapter;
14088
+ if (jenkinsAdapter.isTestFile(path)) return jenkinsAdapter;
14089
+ if (pythonAdapter.isTestFile(path)) return pythonAdapter;
14090
+ if (javaAdapter.isTestFile(path)) return javaAdapter;
14091
+ if (csharpAdapter.isTestFile(path)) return csharpAdapter;
14092
+ return typescriptAdapter;
14093
+ }
14094
+ function discoverTestFilesPhase(workspace, args, hooks, scanRoot, ctx) {
13356
14095
  const languageAdapters = SCAN_ADAPTERS.filter((a) => a.id !== "github-actions" && a.id !== "azure-pipelines" && a.id !== "jenkins");
13357
14096
  const buckets = new Map(languageAdapters.map((a) => [a.id, []]));
13358
14097
  discoverAllTestFiles(ctx, languageAdapters, buckets, /* @__PURE__ */ new Map());
@@ -13391,22 +14130,29 @@ async function runScan(args, hooks = {}) {
13391
14130
  done: ctx.testFiles.length,
13392
14131
  total: ctx.testFiles.length
13393
14132
  });
14133
+ return {
14134
+ testFiles: ctx.testFiles,
14135
+ stagedSurface
14136
+ };
14137
+ }
14138
+ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules, hooks, cache, rulesDigest, deadline, truncationReasons, declarationsByFile, fileProvenance, onRuleCrash) {
14139
+ let skippedFiles = 0;
14140
+ let testFileCount = 0;
14141
+ let testDeclarationCount = 0;
14142
+ let rulesPartial = false;
14143
+ let parseFailed = 0;
13394
14144
  let scanned = 0;
13395
- for (const path of ctx.testFiles) {
14145
+ for (const path of testFiles) {
13396
14146
  if (Date.now() > deadline) {
13397
14147
  rulesPartial = true;
13398
- skippedFiles += ctx.testFiles.length - scanned;
14148
+ skippedFiles += testFiles.length - scanned;
13399
14149
  truncationReasons.add("rule-loop-deadline");
13400
14150
  break;
13401
14151
  }
13402
14152
  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++;
14153
+ const adapter = selectAdapter(path);
14154
+ const isCiAdapter = adapter.id === "github-actions" || adapter.id === "azure-pipelines" || adapter.id === "jenkins";
14155
+ if (!isCiAdapter) testFileCount++;
13410
14156
  let text;
13411
14157
  try {
13412
14158
  text = readFileSync(path, "utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
@@ -13416,8 +14162,15 @@ async function runScan(args, hooks = {}) {
13416
14162
  }
13417
14163
  let fileBudgetExceeded = false;
13418
14164
  const relPath = relative(workspace.root, path).replaceAll("\\", "/");
13419
- if (!isWorkflow && !isAzurePipeline && !isJenkinsfile) {
13420
- const decls = countTestDeclarations(text);
14165
+ if (!isCiAdapter) {
14166
+ const lang = {
14167
+ typescript: "typescript",
14168
+ python: "python",
14169
+ java: "java",
14170
+ csharp: "csharp"
14171
+ }[adapter.id] ?? "typescript";
14172
+ const codeView = getCodeTextForCounting(text, lang);
14173
+ const decls = countTestDeclarations(text, codeView);
13421
14174
  testDeclarationCount += decls;
13422
14175
  declarationsByFile.set(relPath, decls);
13423
14176
  fileProvenance.push({
@@ -13425,7 +14178,6 @@ async function runScan(args, hooks = {}) {
13425
14178
  provenance: classifyProvenance({ text })
13426
14179
  });
13427
14180
  }
13428
- const adapter = isWorkflow ? githubActionsAdapter : isAzurePipeline ? azurePipelinesAdapter : isJenkinsfile ? jenkinsAdapter : isPython ? pythonAdapter : isJava ? javaAdapter : isCs ? csharpAdapter : typescriptAdapter;
13429
14181
  const wantsAst = adapter.parseAst !== void 0 && Date.now() <= deadline;
13430
14182
  const identity = (mode) => ({
13431
14183
  relPath,
@@ -13441,7 +14193,7 @@ async function runScan(args, hooks = {}) {
13441
14193
  hooks.onProgress?.({
13442
14194
  phase: "parse",
13443
14195
  done: scanned,
13444
- total: ctx.testFiles.length,
14196
+ total: testFiles.length,
13445
14197
  detail: relPath
13446
14198
  });
13447
14199
  const parsedFile = {
@@ -13455,7 +14207,7 @@ async function runScan(args, hooks = {}) {
13455
14207
  hooks.onProgress?.({
13456
14208
  phase: "rules",
13457
14209
  done: scanned,
13458
- total: ctx.testFiles.length,
14210
+ total: testFiles.length,
13459
14211
  detail: relPath
13460
14212
  });
13461
14213
  parsed = await adapter.parseAst(parsedFile);
@@ -13475,7 +14227,7 @@ async function runScan(args, hooks = {}) {
13475
14227
  } : parsedFile;
13476
14228
  adapter.runRules(activeRules, fileForRules, (f, ruleId, category) => {
13477
14229
  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)}`));
14230
+ onRuleCrash?.(ruleId, relPath, /* @__PURE__ */ new Error(`malformed finding record rejected (severity/line/message must be present, severity ∈ error|warning|info): ${JSON.stringify(f)}`));
13479
14231
  return;
13480
14232
  }
13481
14233
  findings.push({
@@ -13484,7 +14236,7 @@ async function runScan(args, hooks = {}) {
13484
14236
  category
13485
14237
  });
13486
14238
  }, (ruleId, error) => {
13487
- ctx.onRuleCrash?.(ruleId, relPath, error);
14239
+ onRuleCrash?.(ruleId, relPath, error);
13488
14240
  }, {
13489
14241
  deadline: Math.min(deadline, Date.now() + LIMITS$1.maxFileAnalysisMs),
13490
14242
  onExceeded: () => {
@@ -13502,6 +14254,16 @@ async function runScan(args, hooks = {}) {
13502
14254
  parsed?.dispose();
13503
14255
  }
13504
14256
  }
14257
+ return {
14258
+ skippedFiles,
14259
+ testFileCount,
14260
+ testDeclarationCount,
14261
+ rulesPartial,
14262
+ parseFailed,
14263
+ scanned
14264
+ };
14265
+ }
14266
+ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, declarationsByFile, testDeclarationCount, tierByRuleId, REVISION_BY_RULE_ID) {
13505
14267
  let scopeInfo = { scope: "all" };
13506
14268
  if (args.scopeChanged) {
13507
14269
  const diff = computeChangedScope(workspace.root, args.base);
@@ -13547,104 +14309,342 @@ async function runScan(args, hooks = {}) {
13547
14309
  }
13548
14310
  enforceTierPolicy(findings, tierByRuleId);
13549
14311
  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);
14312
+ const discoveredReport = discoverAndParseRuntimeReport(scanRoot.root);
14313
+ const runtimeReportPath = discoveredReport?.path;
14314
+ if (discoveredReport) try {
14315
+ buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
14316
+ stampRuntimeCorroboration(findings, discoveredReport.report, workspace.root);
13555
14317
  } catch {}
13556
- hooks.onProgress?.({
14318
+ return {
14319
+ testDeclarationCount,
14320
+ scopeInfo,
14321
+ suppressionCount,
14322
+ frameworks,
14323
+ runtimeReportPath,
14324
+ config
14325
+ };
14326
+ }
14327
+ /**
14328
+ * ECO-003: Partition findings by package for monorepo analysis.
14329
+ * Uses the dependency graph to assign findings to their nearest package
14330
+ * manifest, falling back to the workspace root.
14331
+ */
14332
+ function partitionFindingsByPackage(findings, depGraph, workspaceRoot) {
14333
+ const packagePaths = depGraph.allPaths;
14334
+ if (packagePaths.length === 0) return [];
14335
+ const normalizedRoot = workspaceRoot.replaceAll("\\", "/");
14336
+ const packageMap = /* @__PURE__ */ new Map();
14337
+ for (const manifestPath of packagePaths) {
14338
+ const relPath = manifestPath.replaceAll("\\", "/").replace(normalizedRoot, "").replace(/^\//, "");
14339
+ const pathSegments = relPath.split("/");
14340
+ const packageName = pathSegments.length > 1 ? pathSegments[1] ?? "root" : "root";
14341
+ packageMap.set(manifestPath, {
14342
+ packageName,
14343
+ path: relPath.replace("/package.json", "").replace("/pyproject.toml", "").replace("/pom.xml", ""),
14344
+ findings: []
14345
+ });
14346
+ }
14347
+ for (const f of findings) {
14348
+ const normalizedFile = f.file.replaceAll("\\", "/");
14349
+ let bestMatch;
14350
+ let bestLen = 0;
14351
+ for (const manifestPath of packagePaths) {
14352
+ const relToRoot = manifestPath.replaceAll("\\", "/").replace(/[/\\][^/\\]+$/, "").replace(normalizedRoot, "").replace(/^\//, "");
14353
+ if (relToRoot && normalizedFile.startsWith(relToRoot + "/")) {
14354
+ if (relToRoot.length > bestLen) {
14355
+ bestLen = relToRoot.length;
14356
+ bestMatch = manifestPath;
14357
+ }
14358
+ }
14359
+ }
14360
+ const target = bestMatch ? packageMap.get(bestMatch) : packageMap.values().next().value;
14361
+ if (target) target.findings.push(f);
14362
+ }
14363
+ return [...packageMap.values()].map((p) => ({
14364
+ packageName: p.packageName,
14365
+ path: p.path,
14366
+ findings: p.findings,
14367
+ score: p.findings.length === 0 ? 100 : null
14368
+ }));
14369
+ }
14370
+ function assembleScanResult(o) {
14371
+ o.hooks.onProgress?.({
13557
14372
  phase: "score",
13558
- done: findings.length
14373
+ done: o.findings.length
13559
14374
  });
13560
- const dimensions = computeDimensions(findings);
13561
- const rawDeductions = findings.reduce((sum, f) => sum + deductionFor(f), 0);
14375
+ const dimensions = computeDimensions(o.findings);
14376
+ const rawDeductions = o.findings.reduce((sum, f) => sum + deductionFor(f), 0);
13562
14377
  const effectiveDeductions = rawDeductions;
13563
- const total = computeTotal(dimensions, findings, {
13564
- testDeclarations: testDeclarationCount,
13565
- testFileCount,
14378
+ const total = computeTotal(dimensions, o.findings, {
14379
+ testDeclarations: o.testDeclarationCount,
14380
+ testFileCount: o.testFileCount,
13566
14381
  suiteInvalidatingRuleIds: SUITE_INVALIDATING_RULE_IDS
13567
14382
  });
13568
- const elapsed = Date.now() - started;
14383
+ const elapsed = Date.now() - o.started;
13569
14384
  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}`);
14385
+ if (o.scopeIgnored > 0) scopeReasons.push(`ignored:${o.scopeIgnored}`);
14386
+ if (o.scopeUnrecognized > 0) scopeReasons.push(`unrecognized:${o.scopeUnrecognized}`);
14387
+ if (o.parseFailed > 0) scopeReasons.push(`parseFailed:${o.parseFailed}`);
14388
+ for (const reason of o.truncationReasons) scopeReasons.push(`truncated:${reason}`);
13574
14389
  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,
14390
+ discovered: o.testFiles.length,
14391
+ analyzed: Math.max(0, o.scanned),
14392
+ ignored: o.scopeIgnored,
14393
+ unrecognized: o.scopeUnrecognized,
14394
+ parseFailed: o.parseFailed,
14395
+ truncated: o.truncationReasons.size,
13581
14396
  scopeVerdict: scopeReasons.length === 0 ? "PROVEN" : "PARTIAL",
13582
14397
  ...scopeReasons.length > 0 ? { reasons: scopeReasons } : {}
13583
14398
  };
14399
+ const scopeAdjustedTotal = scopeReasons.length > 0 && total >= 100 ? 99 : total;
14400
+ const inputSnapshot = o.testFiles.map((p) => {
14401
+ const relPath = relative(o.workspace.root, p).replaceAll("\\", "/");
14402
+ try {
14403
+ const content = readFileSync(p);
14404
+ const hash = createHash("sha256").update(content).digest("hex").slice(0, 16);
14405
+ return {
14406
+ path: relPath,
14407
+ size: content.length,
14408
+ hash
14409
+ };
14410
+ } catch {
14411
+ return {
14412
+ path: relPath,
14413
+ size: 0
14414
+ };
14415
+ }
14416
+ });
14417
+ let reportDigest;
14418
+ if (o.runtimeReportPath) try {
14419
+ const reportBytes = readFileSync(o.runtimeReportPath);
14420
+ reportDigest = createHash("sha256").update(reportBytes).digest("hex").slice(0, 16);
14421
+ } catch {}
13584
14422
  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]) => ({
14423
+ files: inputSnapshot,
14424
+ rules: [...o.REVISION_BY_RULE_ID.entries()].map(([id, detectorRevision]) => ({
13599
14425
  id,
13600
14426
  detectorRevision
13601
14427
  })),
13602
- config: null,
13603
- engineVersion: ENGINE_VERSION
14428
+ config: o.config ?? null,
14429
+ engineVersion: ENGINE_VERSION,
14430
+ reportDigest,
14431
+ trustModelVersion: TRUST_MODEL_VERSION,
14432
+ scoringModelVersion: SCORING_MODEL_VERSION,
14433
+ frameworkSupportMatrixVersion: FRAMEWORK_SUPPORT_MATRIX_VERSION,
14434
+ evidenceSchemaVersions: [1]
13604
14435
  });
13605
14436
  const evidenceGraph = buildEvidenceGraph({ runId: runIdentity });
13606
- const hasTests = testFileCount > 0;
14437
+ const hasTests = o.testFileCount > 0 && o.testDeclarationCount > 0;
14438
+ const suiteInvalidatedBy = [...new Set(o.findings.filter((f) => SUITE_INVALIDATING_RULE_IDS.has(f.ruleId)).map((f) => f.ruleId))].sort();
13607
14439
  const result = {
13608
14440
  schemaVersion: 1,
13609
- partial: discoveryTruncated || rulesPartial || skippedFiles > 0,
14441
+ partial: o.discoveryTruncated || o.rulesPartial || o.skippedFiles > 0 || o.rulesCrashed > 0 || o.scopeInfo.degraded !== void 0,
13610
14442
  scopeIntegrity,
13611
14443
  runIdentity,
13612
14444
  evidenceGraph,
13613
- score: hasTests ? total : null,
14445
+ score: hasTests ? scopeAdjustedTotal : null,
13614
14446
  ...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 } : {}
14447
+ frameworks: o.frameworks.frameworks,
14448
+ frameworkDetectionUnknown: o.frameworks.unknown,
14449
+ ...o.args.scopeChanged ? {
14450
+ scope: o.scopeInfo.scope,
14451
+ ...o.scopeInfo.degraded ? { scopeDegraded: o.scopeInfo.degraded } : {}
13620
14452
  } : {},
13621
- ...stagedSurface ? { staged: { files: testFileCount } } : {},
14453
+ ...o.stagedSurface ? { staged: { files: o.testFileCount } } : {},
13622
14454
  dimensions,
13623
- findings,
13624
- testFileCount,
13625
- testDeclarationCount,
14455
+ findings: o.findings,
14456
+ testFileCount: o.testFileCount,
14457
+ testDeclarationCount: o.testDeclarationCount,
13626
14458
  rawDeductions,
13627
14459
  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
14460
+ suppressionCount: o.suppressionCount,
14461
+ ...o.pluginsLoaded.length > 0 ? { plugins: o.pluginsLoaded } : {},
14462
+ ...suiteInvalidatedBy.length > 0 ? { suiteInvalidatedBy } : {},
14463
+ agenticProfile: computeAgenticProfile(o.fileProvenance, o.findings),
14464
+ ...o.args.cache ? { cache: {
14465
+ hits: o.cache.stats.hits,
14466
+ misses: o.cache.stats.misses,
14467
+ file: o.cache.stats.file
13635
14468
  } } : {},
13636
14469
  analysisStatus: {
13637
- discovery: discoveryTruncated ? "partial" : "complete",
13638
- rules: rulesPartial ? "partial" : "complete",
13639
- skippedFiles,
14470
+ discovery: o.discoveryTruncated ? "partial" : "complete",
14471
+ rules: o.rulesPartial ? "partial" : "complete",
14472
+ skippedFiles: o.skippedFiles,
13640
14473
  durationMs: elapsed,
13641
- rulesCrashed,
13642
- ...truncationReasons.size > 0 ? { truncationReasons: [...truncationReasons].sort() } : {}
13643
- }
14474
+ rulesCrashed: o.rulesCrashed,
14475
+ ...o.truncationReasons.size > 0 ? { truncationReasons: [...o.truncationReasons].sort() } : {}
14476
+ },
14477
+ scoringModelVersion: SCORING_MODEL_VERSION
13644
14478
  };
13645
- result.trustSummary = buildTrustSummary(result, declarationsByFile);
13646
- cache.persist();
14479
+ result.trustSummary = buildTrustSummary(result, o.declarationsByFile);
14480
+ if (o.findings.length > 0) result.correlationConclusions = correlateFindings(o.findings);
14481
+ if (o.dependencyGraph && o.dependencyGraph.size > 0) {
14482
+ let edges = 0;
14483
+ for (const p of o.dependencyGraph.allPaths) edges += o.dependencyGraph.getDependencies(p).length;
14484
+ result.dependencyGraph = {
14485
+ nodes: o.dependencyGraph.size,
14486
+ edges
14487
+ };
14488
+ }
14489
+ if (o.args.monorepo && o.dependencyGraph && o.dependencyGraph.size > 1) {
14490
+ const packages = partitionFindingsByPackage(o.findings, o.dependencyGraph, o.workspace.root);
14491
+ if (packages.length > 1) {
14492
+ const monorepoResult = analyzeMonorepo(packages, { weightingStrategy: "worst-package" });
14493
+ result.monorepoAnalysis = {
14494
+ packages: monorepoResult.packages.map((p) => ({
14495
+ packageName: p.packageName,
14496
+ path: p.path,
14497
+ findings: p.findings.length,
14498
+ score: p.score,
14499
+ verdict: p.verdict
14500
+ })),
14501
+ overallScore: monorepoResult.overallScore,
14502
+ overallVerdict: monorepoResult.overallVerdict,
14503
+ strategy: monorepoResult.strategy,
14504
+ ...monorepoResult.blockerPackage ? { blockerPackage: monorepoResult.blockerPackage } : {}
14505
+ };
14506
+ }
14507
+ }
14508
+ o.cache.persist();
14509
+ return result;
14510
+ }
14511
+ /**
14512
+ * Testable default scan path core. `hooks` lets callers observe
14513
+ * normally-invisible events (swallowed rule crashes) without changing
14514
+ * the ScanResult contract beyond the rulesCrashed counter.
14515
+ *
14516
+ * Async since the Verification Trust Evolution Plan Phase 0.5 (§10): the
14517
+ * per-file loop awaits the adapter parse stage (WASM grammar load is
14518
+ * inherently async); `runRules` and every rule stay synchronous and
14519
+ * consume `ParsedFile.ast`. Callers await the returned promise.
14520
+ */
14521
+ async function runScan(args, hooks = {}) {
14522
+ const started = Date.now();
14523
+ const deadline = started + args.maxDurationMs;
14524
+ const discovered = discoverWorkspace(args.target);
14525
+ const targetAbs = resolve(args.target);
14526
+ const scanRoot = discovered && discovered.root !== targetAbs && targetAbs.startsWith(discovered.root + sep) ? {
14527
+ ...discovered,
14528
+ root: targetAbs
14529
+ } : discovered ?? fallbackWorkspace(targetAbs);
14530
+ const workspace = scanRoot;
14531
+ if (args.verbose) hooks.onConfigWarning?.(`verbose: scan root (config/ignore anchor): ${workspace.root}`);
14532
+ const findings = [];
14533
+ let skippedFiles = 0;
14534
+ let testFileCount = 0;
14535
+ let testDeclarationCount = 0;
14536
+ const declarationsByFile = /* @__PURE__ */ new Map();
14537
+ let rulesCrashed = 0;
14538
+ const truncationReasons = /* @__PURE__ */ new Set();
14539
+ let discoveryTruncated = false;
14540
+ let rulesPartial = false;
14541
+ let scopeIgnored = 0;
14542
+ let scopeUnrecognized = 0;
14543
+ let parseFailed = 0;
14544
+ const fileProvenance = [];
14545
+ const { rules: activeRules, pluginErrors, tierByRuleId: tiers, pluginMeta } = await buildUniversalRules(workspace.root, args.strict, {
14546
+ ...args.enablePlugins !== void 0 ? { enablePlugins: args.enablePlugins } : {},
14547
+ onGateNotice: (notice) => hooks.onGateNotice ? hooks.onGateNotice(notice) : console.error(notice)
14548
+ });
14549
+ const tierByRuleId = tiers;
14550
+ const pluginsLoaded = pluginMeta;
14551
+ const REVISION_BY_RULE_ID = new Map(activeRules.map((r) => [r.id, r.detectorRevision ?? MEASURED_FP[r.id]?.detectorRevision ?? 1]));
14552
+ const cache = args.cache ? createScanCache(workspace.root) : disabledScanCache;
14553
+ const depGraph = buildDependencyGraph(workspace.root);
14554
+ const rulesDigest = computeRulesDigest(activeRules);
14555
+ if (args.cache && args.verbose) {
14556
+ const safety = isIncrementalSafe([]);
14557
+ if (!safety.safe) hooks.onConfigWarning?.(`incremental: ${safety.reasons.join("; ")}`);
14558
+ }
14559
+ for (const perr of pluginErrors) findings.push({
14560
+ ruleId: "QA-PLUGIN-000",
14561
+ category: "QA-PW",
14562
+ severity: "warning",
14563
+ confidence: "high",
14564
+ findingType: "deterministic-defect",
14565
+ qaImpact: "HYGIENE",
14566
+ evidenceLevel: "E2",
14567
+ file: "mjolnir.config.json",
14568
+ line: 1,
14569
+ column: 1,
14570
+ message: `Plugin problem: ${perr}`,
14571
+ why: "A configured plugin could not be loaded or declared invalid rules — its checks are silently missing from this scan.",
14572
+ fix: "Fix or remove the plugin entry in mjolnir.config.json."
14573
+ });
14574
+ const { testFiles, stagedSurface } = discoverTestFilesPhase(workspace, args, hooks, scanRoot, {
14575
+ workspace,
14576
+ testFiles: [],
14577
+ deadline,
14578
+ maxFiles: LIMITS$1.maxFilesPerAdapter,
14579
+ ignoreMatcher: createIgnoreMatcher(workspace.root),
14580
+ onSkippedFile: (reason) => {
14581
+ skippedFiles++;
14582
+ truncationReasons.add(reason);
14583
+ },
14584
+ onDiscoveryTruncated: (reason) => {
14585
+ discoveryTruncated = true;
14586
+ if (!truncationReasons.has(reason)) {
14587
+ truncationReasons.add(reason);
14588
+ skippedFiles++;
14589
+ }
14590
+ },
14591
+ onRuleCrash: (ruleId, file, error) => {
14592
+ rulesCrashed++;
14593
+ hooks.onRuleCrash?.(ruleId, file, error);
14594
+ },
14595
+ onIgnored: () => {
14596
+ scopeIgnored++;
14597
+ },
14598
+ onUnrecognized: () => {
14599
+ scopeUnrecognized++;
14600
+ }
14601
+ });
14602
+ const analysis = await runFileAnalysisPhase(findings, testFiles, workspace, activeRules, hooks, cache, rulesDigest, deadline, truncationReasons, declarationsByFile, fileProvenance, (ruleId, file, error) => {
14603
+ rulesCrashed++;
14604
+ hooks.onRuleCrash?.(ruleId, file, error);
14605
+ });
14606
+ skippedFiles += analysis.skippedFiles;
14607
+ testFileCount = analysis.testFileCount;
14608
+ testDeclarationCount = analysis.testDeclarationCount;
14609
+ rulesPartial = analysis.rulesPartial;
14610
+ parseFailed = analysis.parseFailed;
14611
+ const scanned = analysis.scanned;
14612
+ const postScan = applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, declarationsByFile, testDeclarationCount, tierByRuleId, REVISION_BY_RULE_ID);
14613
+ testDeclarationCount = postScan.testDeclarationCount;
14614
+ const result = assembleScanResult({
14615
+ findings,
14616
+ testFileCount,
14617
+ testDeclarationCount,
14618
+ declarationsByFile,
14619
+ skippedFiles,
14620
+ rulesCrashed,
14621
+ truncationReasons,
14622
+ discoveryTruncated,
14623
+ rulesPartial,
14624
+ scopeIgnored,
14625
+ scopeUnrecognized,
14626
+ parseFailed,
14627
+ scanned,
14628
+ testFiles,
14629
+ workspace,
14630
+ scanRoot,
14631
+ args,
14632
+ hooks,
14633
+ cache,
14634
+ REVISION_BY_RULE_ID,
14635
+ pluginsLoaded,
14636
+ scopeInfo: postScan.scopeInfo,
14637
+ suppressionCount: postScan.suppressionCount,
14638
+ frameworks: postScan.frameworks,
14639
+ runtimeReportPath: postScan.runtimeReportPath,
14640
+ config: postScan.config,
14641
+ fileProvenance,
14642
+ started,
14643
+ stagedSurface,
14644
+ dependencyGraph: depGraph
14645
+ });
13647
14646
  await releaseTreeSitterResources();
14647
+ resetTsMorphProject();
13648
14648
  return result;
13649
14649
  }
13650
14650
  /**
@@ -13838,11 +14838,19 @@ function explainRule(ruleId, fixturesRoot) {
13838
14838
  };
13839
14839
  }
13840
14840
  //#endregion
14841
+ //#region src/engine/finding-identity.ts
14842
+ /**
14843
+ * Correlation identity for before/after comparison: ruleId + file +
14844
+ * message, deliberately EXCLUDING line — a source edit that shifts a
14845
+ * finding still correlates. Shared by baseline, impact, and resolution.
14846
+ */
14847
+ function findingFingerprint(f) {
14848
+ return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
14849
+ }
14850
+ //#endregion
13841
14851
  //#region src/engine/resolution.ts
13842
14852
  /** Correlation identity (v1-compatible): ruleId\0file\0message. */
13843
- function fingerprint$1(entry) {
13844
- return `${entry.ruleId}\u0000${entry.file}\u0000${entry.message}`;
13845
- }
14853
+ const fingerprint$1 = findingFingerprint;
13846
14854
  /**
13847
14855
  * The §15 ordered algorithm. Pure; first match wins.
13848
14856
  */
@@ -13917,11 +14925,9 @@ const REGISTRY_REVISIONS = new Map(RULES.map((r) => [r.id, r.detectorRevision ??
13917
14925
  * occurrence location, not a durable identity; message rewording,
13918
14926
  * file renames and rule-id changes correlate as resolved+new
13919
14927
  * (documented limitation). Exported for the handoff verification
13920
- * contract — do not duplicate this algorithm.
14928
+ * contract — re-exported from engine/finding-identity.js (ENGINE-002).
13921
14929
  */
13922
- function fingerprint(f) {
13923
- return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
13924
- }
14930
+ const fingerprint = findingFingerprint;
13925
14931
  function loadBaseline(path, onWarning) {
13926
14932
  if (!existsSync(path)) return null;
13927
14933
  try {
@@ -14098,7 +15104,7 @@ function nextActionFor(v) {
14098
15104
  switch (classifyVerdict(v)) {
14099
15105
  case "RETRY-DEPENDENT": return `repeat execution of this test (3+ runs) to confirm the flake rate, then quarantine + ticket: ${v.file}`;
14100
15106
  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`;
15107
+ case "FAILING": return `reproduce locally: npx playwright test ${v.file} -g "${v.title.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}" — then fix and re-run`;
14102
15108
  case "SKIPPED": return `inspect the skip condition in ${v.file} — a skip after a failure usually hides an environment problem`;
14103
15109
  }
14104
15110
  }
@@ -14127,6 +15133,9 @@ function renderTriageWorkflowJson(report) {
14127
15133
  artifact: "mjolnir-triage-workflow",
14128
15134
  source: report.source,
14129
15135
  totalTests: report.totalTests,
15136
+ analysisComplete: report.analysisComplete,
15137
+ skippedReports: report.skippedReports,
15138
+ incompleteReasons: report.incompleteReasons,
14130
15139
  rows: workflowRows(report)
14131
15140
  }, null, 2) + "\n";
14132
15141
  }
@@ -14225,10 +15234,16 @@ const MCP_TOOLS = [
14225
15234
  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
15235
  inputSchema: {
14227
15236
  type: "object",
14228
- properties: { ruleId: {
14229
- type: "string",
14230
- description: "e.g. QA-PW-101"
14231
- } },
15237
+ properties: {
15238
+ ruleId: {
15239
+ type: "string",
15240
+ description: "e.g. QA-PW-101"
15241
+ },
15242
+ fixturesRoot: {
15243
+ type: "string",
15244
+ description: "Root directory for fixture lookups (defaults to cwd)."
15245
+ }
15246
+ },
14232
15247
  required: ["ruleId"]
14233
15248
  }
14234
15249
  },
@@ -14380,7 +15395,16 @@ async function handleToolCall(call) {
14380
15395
  }
14381
15396
  if (call.name === "explain") {
14382
15397
  const ruleId = call.args["ruleId"];
14383
- const explanation = explainRule(ruleId, process.cwd());
15398
+ const rawFixturesRoot = call.args["fixturesRoot"];
15399
+ if (rawFixturesRoot !== void 0 && typeof rawFixturesRoot !== "string") return {
15400
+ jsonrpc: "2.0",
15401
+ id: call.id,
15402
+ error: {
15403
+ code: MCP_ERRORS.INVALID_PARAMS,
15404
+ message: "fixturesRoot must be a string"
15405
+ }
15406
+ };
15407
+ const explanation = explainRule(ruleId, (typeof rawFixturesRoot === "string" ? rawFixturesRoot : void 0) ?? process.cwd());
14384
15408
  if (!explanation.ok) return {
14385
15409
  jsonrpc: "2.0",
14386
15410
  id: call.id,
@@ -14525,13 +15549,13 @@ async function handleToolCall(call) {
14525
15549
  id: call.id,
14526
15550
  error: {
14527
15551
  code: MCP_ERRORS.INTERNAL,
14528
- message: errorMessage(err)
15552
+ message: errorMessage$1(err)
14529
15553
  }
14530
15554
  };
14531
15555
  }
14532
15556
  }
14533
15557
  /** Normalize any thrown value to a string (unit-tested both arms). */
14534
- function errorMessage(err) {
15558
+ function errorMessage$1(err) {
14535
15559
  return err instanceof Error ? err.message : String(err);
14536
15560
  }
14537
15561
  /** JSON-RPC request shape validation (strict — §21). */
@@ -14636,6 +15660,14 @@ async function handleMcpMessage(msg) {
14636
15660
  };
14637
15661
  }
14638
15662
  //#endregion
15663
+ //#region src/cli-io.ts
15664
+ /**
15665
+ * The single error-to-message derivation shared by every catch site:
15666
+ * Error instances render their message; anything else is stringified
15667
+ * honestly (a hostile non-Error throw must surface, not vanish).
15668
+ */
15669
+ const errorMessage = (err) => err instanceof Error ? err.message : String(err);
15670
+ //#endregion
14639
15671
  //#region src/mcp/transport.ts
14640
15672
  /**
14641
15673
  * MCP stdio transport loop (blueprint §21 — the framing around the
@@ -14648,12 +15680,52 @@ async function handleMcpMessage(msg) {
14648
15680
  * (stdio.ts) exits 0 after it.
14649
15681
  */
14650
15682
  /**
15683
+ * Extract and validate a JSON-RPC request ID. Returns null if absent or
15684
+ * invalid (JSON-RPC ID must be a string, number, or null — not an object).
15685
+ */
15686
+ function normalizeJsonRpcId(msg) {
15687
+ if (typeof msg !== "object" || msg === null) return null;
15688
+ const id = msg["id"];
15689
+ if (typeof id === "string" || typeof id === "number") return id;
15690
+ return null;
15691
+ }
15692
+ /**
14651
15693
  * Drive the transport over arbitrary streams (real stdio in the binary,
14652
15694
  * PassThrough in tests). Resolves on input EOF.
14653
15695
  */
14654
15696
  async function runStdioTransport(input, output) {
14655
15697
  const rl = createInterface({ input });
14656
- for await (const line of rl) await handleStdioLine(line, (s) => output.write(s));
15698
+ let outputFailed = false;
15699
+ const onOutputError = () => {
15700
+ outputFailed = true;
15701
+ rl.close();
15702
+ };
15703
+ output.once("error", onOutputError);
15704
+ try {
15705
+ for await (const line of rl) {
15706
+ if (outputFailed) break;
15707
+ try {
15708
+ await handleStdioLine(line, (s) => {
15709
+ if (!outputFailed) output.write(s);
15710
+ });
15711
+ } catch {
15712
+ try {
15713
+ output.write(JSON.stringify({
15714
+ jsonrpc: "2.0",
15715
+ id: null,
15716
+ error: {
15717
+ code: -32603,
15718
+ message: "internal transport error"
15719
+ }
15720
+ }) + "\n");
15721
+ } catch {
15722
+ break;
15723
+ }
15724
+ }
15725
+ }
15726
+ } finally {
15727
+ output.removeListener("error", onOutputError);
15728
+ }
14657
15729
  }
14658
15730
  /**
14659
15731
  * Process one newline-delimited JSON-RPC frame; write the response to
@@ -14677,7 +15749,20 @@ async function handleStdioLine(line, write) {
14677
15749
  })}\n`);
14678
15750
  return;
14679
15751
  }
14680
- const response = await handleMcpMessage(parsed);
15752
+ let response;
15753
+ try {
15754
+ response = await handleMcpMessage(parsed);
15755
+ if (response) response.id = normalizeJsonRpcId(parsed);
15756
+ } catch (err) {
15757
+ response = {
15758
+ jsonrpc: "2.0",
15759
+ id: normalizeJsonRpcId(parsed),
15760
+ error: {
15761
+ code: -32603,
15762
+ message: errorMessage(err)
15763
+ }
15764
+ };
15765
+ }
14681
15766
  write(`${JSON.stringify(response)}\n`);
14682
15767
  }
14683
15768
  //#endregion
@@ -14699,6 +15784,9 @@ async function handleStdioLine(line, write) {
14699
15784
  * subprocess coverage cannot merge into the parent istanbul report,
14700
15785
  * same class as dist/**), so it is excluded from the coverage ratchet.
14701
15786
  */
14702
- runStdioTransport(process.stdin, process.stdout).then(() => process.exit(0));
15787
+ runStdioTransport(process.stdin, process.stdout).then(() => process.exit(0)).catch((err) => {
15788
+ process.stderr.write(`mjolnir mcp fatal: ${err instanceof Error ? err.message : err}\n`);
15789
+ process.exitCode = 20;
15790
+ });
14703
15791
  //#endregion
14704
15792
  export {};