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