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