mjolnir-qa 4.0.0 → 6.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 +3178 -16
- package/README.md +81 -760
- package/dist/cli.d.mts +552 -206
- package/dist/cli.mjs +31409 -13990
- package/dist/mcp/stdio.mjs +1290 -405
- package/package.json +76 -32
- package/README.ar.md +0 -647
- package/README.bn.md +0 -647
- package/README.br.md +0 -647
- package/README.bs.md +0 -647
- package/README.da.md +0 -647
- package/README.de.md +0 -647
- package/README.es.md +0 -647
- package/README.fr.md +0 -647
- package/README.gr.md +0 -647
- package/README.he.md +0 -647
- package/README.it.md +0 -647
- package/README.ja.md +0 -647
- package/README.ko.md +0 -647
- package/README.no.md +0 -647
- package/README.pl.md +0 -647
- package/README.ru.md +0 -647
- package/README.th.md +0 -647
- package/README.tr.md +0 -647
- package/README.uk.md +0 -647
- package/README.vi.md +0 -647
- package/README.zh.md +0 -647
- package/README.zht.md +0 -647
- package/dist/scan-pipeline-Bg-46cLS.mjs +0 -2
- package/dist/scan-pipeline-CCJdEKNa.mjs +0 -16336
package/dist/mcp/stdio.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
5
5
|
import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
6
6
|
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
7
|
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
8
9
|
import * as ts$2 from "ts-morph";
|
|
9
10
|
import ts, { Project, SyntaxKind, ts as ts$1 } from "ts-morph";
|
|
10
11
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -35,6 +36,23 @@ const SEVERITY_ORDER = [
|
|
|
35
36
|
* retried, or timed out in the report).
|
|
36
37
|
* INVARIANT (structurally enforced): L3–L5 require runtime
|
|
37
38
|
* corroboration — a static-only finding can never claim L4/L5.
|
|
39
|
+
*
|
|
40
|
+
* THREE LADDER VOCABULARIES now share this shape, and the letters are the
|
|
41
|
+
* only thing distinguishing them:
|
|
42
|
+
*
|
|
43
|
+
* `L0`–`L5` RUNTIME TRUST — how much a consumer can believe one finding.
|
|
44
|
+
* Here.
|
|
45
|
+
* `F0`–`F5` FRAMEWORK SUPPORT — a hand-declared target per framework.
|
|
46
|
+
* `FrameworkSupportLevel`, src/frameworks/framework-inventory.ts
|
|
47
|
+
* `M0`–`M5` CAPABILITY MATURITY — DERIVED from evidence, and the only
|
|
48
|
+
* one of the three that is a measurement.
|
|
49
|
+
* `Maturity`, src/v6/maturity.ts
|
|
50
|
+
*
|
|
51
|
+
* `tests/v6/maturity-vocabulary.spec.ts` holds the two vocabulary sets apart
|
|
52
|
+
* and fails if they ever share a member. `M` is the only DERIVED of the three,
|
|
53
|
+
* and that is the distinction worth remembering when reading a level out of a
|
|
54
|
+
* report: `F` and `L` are declarations about the world, `M` is a function of
|
|
55
|
+
* what this repository can demonstrate.
|
|
38
56
|
*/
|
|
39
57
|
const TRUST_ORDER = [
|
|
40
58
|
"L0",
|
|
@@ -85,6 +103,7 @@ const DEDUCTIONS = {
|
|
|
85
103
|
//#region src/engine/completion.ts
|
|
86
104
|
function deriveCompletion(input) {
|
|
87
105
|
const truncationReasons = [...new Set(input.truncationReasons)].sort();
|
|
106
|
+
const degradations = [...input.degradations ?? []].sort((a, b) => a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0);
|
|
88
107
|
const reasons = /* @__PURE__ */ new Set();
|
|
89
108
|
if (input.discoveryTruncated) reasons.add("discovery-truncated");
|
|
90
109
|
if (input.rulesPartial) reasons.add("rules-partial");
|
|
@@ -94,26 +113,73 @@ function deriveCompletion(input) {
|
|
|
94
113
|
if (input.scopeUnrecognized > 0) reasons.add(`scope-unrecognized:${input.scopeUnrecognized}`);
|
|
95
114
|
if (input.parseFailed > 0) reasons.add(`parse-failed:${input.parseFailed}`);
|
|
96
115
|
if (input.parseFallbacks && input.parseFallbacks > 0) reasons.add(`parse-fallbacks:${input.parseFallbacks}`);
|
|
116
|
+
if (input.astFallbackFiles && input.astFallbackFiles > 0) reasons.add(`ast-budget-fallback-files:${input.astFallbackFiles}`);
|
|
97
117
|
if (input.scopeDegraded) reasons.add(`scope-degraded:${input.scopeDegraded}`);
|
|
98
118
|
if (input.runtimeIncomplete) reasons.add("runtime-incomplete");
|
|
99
119
|
if (input.identityIncomplete) reasons.add("identity-incomplete");
|
|
100
120
|
for (const reason of truncationReasons) reasons.add(`truncated:${reason}`);
|
|
121
|
+
if ((input.rulesWithheld ?? 0) > 0) reasons.add(`coverage:quarantine:${input.rulesWithheld}`);
|
|
122
|
+
for (const entry of degradations) reasons.add(`degraded:${entry.reason}:${entry.count}`);
|
|
101
123
|
const discoveryPartial = input.discoveryTruncated || input.scopeIgnored > 0 || input.scopeUnrecognized > 0 || input.scopeDegraded !== void 0;
|
|
102
124
|
const rulesPartial = input.rulesPartial || input.rulesCrashed > 0 || input.parseFailed > 0;
|
|
125
|
+
const partial = discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0 || degradations.length > 0;
|
|
126
|
+
const rulesWithheld = input.rulesWithheld ?? 0;
|
|
103
127
|
return {
|
|
104
|
-
partial
|
|
128
|
+
partial,
|
|
129
|
+
coverageState: rulesWithheld > 0 ? "PARTIAL" : "COMPLETE",
|
|
105
130
|
analysisStatus: {
|
|
106
131
|
discovery: discoveryPartial ? "partial" : "complete",
|
|
107
132
|
rules: rulesPartial ? "partial" : "complete",
|
|
108
133
|
skippedFiles: input.skippedFiles,
|
|
109
134
|
rulesCrashed: input.rulesCrashed,
|
|
110
135
|
parseFallbacks: input.parseFallbacks ?? 0,
|
|
136
|
+
rulesApplied: input.rulesApplied ?? 0,
|
|
137
|
+
rulesWithheld,
|
|
138
|
+
...input.withheldRuleIds !== void 0 && input.withheldRuleIds.length > 0 ? { withheldRuleIds: [...input.withheldRuleIds].sort() } : {},
|
|
111
139
|
...truncationReasons.length > 0 ? { truncationReasons } : {},
|
|
140
|
+
...degradations.length > 0 ? { degradations } : {},
|
|
112
141
|
reasons: [...reasons].sort()
|
|
113
142
|
}
|
|
114
143
|
};
|
|
115
144
|
}
|
|
116
145
|
//#endregion
|
|
146
|
+
//#region src/engine/degradation-ledger.ts
|
|
147
|
+
/**
|
|
148
|
+
* Append-only. Records are never edited and never removed by a recording
|
|
149
|
+
* site; a reader takes a window, and the window is defined by index, so
|
|
150
|
+
* "delete my entry" is not an operation this module offers.
|
|
151
|
+
*/
|
|
152
|
+
const records = [];
|
|
153
|
+
/** Record one capability loss. `count` is for a site that fails N at once. */
|
|
154
|
+
function recordDegradation(reason, count = 1) {
|
|
155
|
+
if (!Number.isInteger(count) || count < 1) return;
|
|
156
|
+
for (let index = 0; index < count; index++) records.push({ reason });
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Mark where this reader's window opens. Everything appended from here on is
|
|
160
|
+
* what the reader will see; see the file header for what a concurrent scan
|
|
161
|
+
* does to that boundary.
|
|
162
|
+
*/
|
|
163
|
+
function beginDegradationWindow() {
|
|
164
|
+
return records.length;
|
|
165
|
+
}
|
|
166
|
+
/** The records appended since `marker`, without consuming them. */
|
|
167
|
+
function degradationsSince(marker) {
|
|
168
|
+
return records.slice(marker);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Per-reason counts for a window, sorted by reason so the output is
|
|
172
|
+
* byte-deterministic for the same set of degradations.
|
|
173
|
+
*/
|
|
174
|
+
function summarizeDegradations(window) {
|
|
175
|
+
const counts = /* @__PURE__ */ new Map();
|
|
176
|
+
for (const record of window) counts.set(record.reason, (counts.get(record.reason) ?? 0) + 1);
|
|
177
|
+
return [...counts.entries()].map(([reason, count]) => ({
|
|
178
|
+
reason,
|
|
179
|
+
count
|
|
180
|
+
})).sort((a, b) => a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0);
|
|
181
|
+
}
|
|
182
|
+
//#endregion
|
|
117
183
|
//#region src/engine/runtime-corroboration.ts
|
|
118
184
|
/**
|
|
119
185
|
* Stamp runtime corroboration + trust levels onto findings (mutates in
|
|
@@ -334,10 +400,10 @@ const MEASURED_FP = {
|
|
|
334
400
|
},
|
|
335
401
|
"QA-JV-101": {
|
|
336
402
|
fpRate: 0,
|
|
337
|
-
n:
|
|
403
|
+
n: 35,
|
|
338
404
|
detectorRevision: 1,
|
|
339
405
|
ciLow: 0,
|
|
340
|
-
ciHigh: .
|
|
406
|
+
ciHigh: .0989
|
|
341
407
|
},
|
|
342
408
|
"QA-JV-102": {
|
|
343
409
|
fpRate: .261,
|
|
@@ -453,10 +519,10 @@ const MEASURED_FP = {
|
|
|
453
519
|
},
|
|
454
520
|
"QA-PW-117": {
|
|
455
521
|
fpRate: 0,
|
|
456
|
-
n:
|
|
522
|
+
n: 35,
|
|
457
523
|
detectorRevision: 1,
|
|
458
524
|
ciLow: 0,
|
|
459
|
-
ciHigh: .
|
|
525
|
+
ciHigh: .0989
|
|
460
526
|
},
|
|
461
527
|
"QA-PW-121": {
|
|
462
528
|
fpRate: 0,
|
|
@@ -543,25 +609,18 @@ const MEASURED_FP = {
|
|
|
543
609
|
ciHigh: .2425
|
|
544
610
|
},
|
|
545
611
|
"QA-PY-002": {
|
|
546
|
-
fpRate: .
|
|
547
|
-
n:
|
|
612
|
+
fpRate: .12,
|
|
613
|
+
n: 25,
|
|
548
614
|
detectorRevision: 1,
|
|
549
|
-
ciLow: .
|
|
550
|
-
ciHigh: .
|
|
615
|
+
ciLow: .0417,
|
|
616
|
+
ciHigh: .2996
|
|
551
617
|
},
|
|
552
618
|
"QA-PY-003": {
|
|
553
|
-
fpRate: .
|
|
554
|
-
n:
|
|
619
|
+
fpRate: .515,
|
|
620
|
+
n: 33,
|
|
555
621
|
detectorRevision: 4,
|
|
556
|
-
ciLow: .
|
|
557
|
-
ciHigh: .
|
|
558
|
-
},
|
|
559
|
-
"QA-PY-004": {
|
|
560
|
-
fpRate: .533,
|
|
561
|
-
n: 30,
|
|
562
|
-
detectorRevision: 3,
|
|
563
|
-
ciLow: .3614,
|
|
564
|
-
ciHigh: .6977
|
|
622
|
+
ciLow: .3522,
|
|
623
|
+
ciHigh: .675
|
|
565
624
|
},
|
|
566
625
|
"QA-PY-005": {
|
|
567
626
|
fpRate: .13,
|
|
@@ -844,6 +903,58 @@ function buildTrustSummary(result, declarationsByFile) {
|
|
|
844
903
|
};
|
|
845
904
|
}
|
|
846
905
|
//#endregion
|
|
906
|
+
//#region src/governance/run-mode.ts
|
|
907
|
+
/**
|
|
908
|
+
* Which mode a run is in, and how the other one is reached.
|
|
909
|
+
*
|
|
910
|
+
* ADR 0012: `local-only` is the default and hosted mode is a declared state.
|
|
911
|
+
* This module is the enforcement point, and its shape is the decision:
|
|
912
|
+
*
|
|
913
|
+
* - `resolveRunMode` takes its inputs as an ARGUMENT, never from
|
|
914
|
+
* `process.env`. Nothing here can read the environment, so a stray
|
|
915
|
+
* `MJOLNIR_MODE` in a shell, in CI, or in a parent's environment cannot
|
|
916
|
+
* change what a run does. The first version of the test passed an `env`
|
|
917
|
+
* bag and asserted it was ignored — and that only means something because
|
|
918
|
+
* this function is structurally unable to consult one.
|
|
919
|
+
* - `isHostedModeReachable` is the single predicate every entry point has to
|
|
920
|
+
* pass through, and it answers the same question separately from
|
|
921
|
+
* `resolveRunMode`. Separating them is what stops a caller satisfying the
|
|
922
|
+
* easy one: the first says whether the mode MAY be entered, the second
|
|
923
|
+
* says which mode this run IS.
|
|
924
|
+
* - an unrecognised declaration THROWS. A typo in a build flag that fell back
|
|
925
|
+
* to `local-only` would mean a deployment believed it was hosted and was
|
|
926
|
+
* not — the same class of failure as a gate that reports success for
|
|
927
|
+
* something it did not run.
|
|
928
|
+
*
|
|
929
|
+
* Hosted mode is not implemented. This is the contract it will have to
|
|
930
|
+
* satisfy, and it is deliberately the smallest thing that can be wrong in only
|
|
931
|
+
* one direction.
|
|
932
|
+
*/
|
|
933
|
+
/**
|
|
934
|
+
* The build-time declaration that opts a build into hosted mode.
|
|
935
|
+
*
|
|
936
|
+
* A build flag rather than an environment variable, because a build flag is
|
|
937
|
+
* something a release engineer sets deliberately and reviews in a diff, and
|
|
938
|
+
* because an environment variable is set by whatever CI last happened to run
|
|
939
|
+
* with.
|
|
940
|
+
*/
|
|
941
|
+
const HOSTED_MODE_FLAG = "mjolnir-mode";
|
|
942
|
+
/**
|
|
943
|
+
* The mode this run is in.
|
|
944
|
+
*
|
|
945
|
+
* Throws on a declaration it does not recognise. A silent fallback here is the
|
|
946
|
+
* failure ADR 0012 exists to prevent: a build that asked for hosted mode,
|
|
947
|
+
* mistyped the flag, and ran local-only would report local-only artefacts into
|
|
948
|
+
* a pipeline that believed otherwise.
|
|
949
|
+
*/
|
|
950
|
+
function resolveRunMode(input) {
|
|
951
|
+
const declared = input.flags[HOSTED_MODE_FLAG];
|
|
952
|
+
if (declared === void 0 || declared === false) return "local-only";
|
|
953
|
+
if (declared === true) throw new Error(`${HOSTED_MODE_FLAG} was given without a value. Use "${HOSTED_MODE_FLAG}=hosted" or omit it.`);
|
|
954
|
+
if (declared === "local-only" || declared === "hosted") return declared;
|
|
955
|
+
throw new Error(`${HOSTED_MODE_FLAG}=${String(declared)} is not a run mode. Expected "hosted" or "local-only". Falling back to a default here would let a mistyped flag look like a deliberate choice.`);
|
|
956
|
+
}
|
|
957
|
+
//#endregion
|
|
847
958
|
//#region src/engine/run-identity.ts
|
|
848
959
|
/**
|
|
849
960
|
* Run Identity (product-gap master plan §7, plan 1789009691197 R4c) —
|
|
@@ -884,6 +995,7 @@ function buildRunIdentity(input) {
|
|
|
884
995
|
if (input.commit !== void 0) verdictInputs.commit = input.commit;
|
|
885
996
|
if (input.tree !== void 0) verdictInputs.tree = input.tree;
|
|
886
997
|
if (input.lockfile !== void 0) verdictInputs.lockfile = input.lockfile;
|
|
998
|
+
if (input.runMode !== void 0) verdictInputs.runMode = input.runMode;
|
|
887
999
|
if (input.candidate !== void 0) verdictInputs.candidate = {
|
|
888
1000
|
manifestId: input.candidate.manifestId,
|
|
889
1001
|
state: input.candidate.state,
|
|
@@ -916,6 +1028,7 @@ function buildRunIdentity(input) {
|
|
|
916
1028
|
rulesDigest,
|
|
917
1029
|
configFingerprint,
|
|
918
1030
|
engineVersion: input.engineVersion,
|
|
1031
|
+
...input.runMode !== void 0 ? { runMode: input.runMode } : {},
|
|
919
1032
|
boundLinks,
|
|
920
1033
|
...input.trustModelVersion !== void 0 ? { trustModelVersion: input.trustModelVersion } : {},
|
|
921
1034
|
...input.scoringModelVersion !== void 0 ? { scoringModelVersion: input.scoringModelVersion } : {},
|
|
@@ -1064,6 +1177,120 @@ function createExecutor(name, concurrency, runOne) {
|
|
|
1064
1177
|
};
|
|
1065
1178
|
}
|
|
1066
1179
|
//#endregion
|
|
1180
|
+
//#region src/scope/git-resolve.ts
|
|
1181
|
+
/**
|
|
1182
|
+
* Absolute-path git resolution (audit S1).
|
|
1183
|
+
*
|
|
1184
|
+
* `execFileSync("git", …)` resolves through PATH — including the CWD on
|
|
1185
|
+
* Windows (CreateProcess searches the current directory before PATH for
|
|
1186
|
+
* extension-less names depending on NoDefaultCurrentDirectoryInExePath).
|
|
1187
|
+
* Scanning an UNTRUSTED repo therefore let a checked-in `git.exe` (or
|
|
1188
|
+
* `git.bat`/`git.cmd`) hijack Mjölnir's own git invocations: the attacker
|
|
1189
|
+
* controls the diff output, the merge-base decision, and
|
|
1190
|
+
* `--scope changed`'s "what is new" answer — the exact data CI gates on.
|
|
1191
|
+
*
|
|
1192
|
+
* The fix: resolve git ONCE per process to an ABSOLUTE path by walking
|
|
1193
|
+
* the PATH directly (never consulting the CWD), verify the candidate is
|
|
1194
|
+
* an existing file, and pass that path to execFileSync. Resolution is
|
|
1195
|
+
* memoized; a failure to find a real git anywhere on PATH is recorded
|
|
1196
|
+
* and callers degrade without invoking a bare name.
|
|
1197
|
+
*/
|
|
1198
|
+
let resolvedGit = void 0;
|
|
1199
|
+
/**
|
|
1200
|
+
* The absolute path of the git binary Mjölnir will invoke, or null when
|
|
1201
|
+
* PATH carries no executable `git` at all. Callers must degrade without
|
|
1202
|
+
* invoking a bare name.
|
|
1203
|
+
*/
|
|
1204
|
+
function resolveGitPath() {
|
|
1205
|
+
if (resolvedGit !== void 0) return resolvedGit;
|
|
1206
|
+
const candidates = [process.platform === "win32" ? "git.exe" : "git"];
|
|
1207
|
+
if (process.platform === "win32") {
|
|
1208
|
+
const pathExt = process.env["PATHEXT"]?.split(";").filter(Boolean) ?? [];
|
|
1209
|
+
for (const ext of pathExt) candidates.push(`git${ext.toLowerCase()}`);
|
|
1210
|
+
}
|
|
1211
|
+
const pathValue = process.env["PATH"] ?? "";
|
|
1212
|
+
for (const dir of pathValue.split(delimiter)) {
|
|
1213
|
+
if (dir === "") continue;
|
|
1214
|
+
if (!isAbsolute(dir)) continue;
|
|
1215
|
+
for (const candidate of candidates) {
|
|
1216
|
+
const full = join(dir, candidate);
|
|
1217
|
+
try {
|
|
1218
|
+
if (existsSync(full)) {
|
|
1219
|
+
resolvedGit = full;
|
|
1220
|
+
return resolvedGit;
|
|
1221
|
+
}
|
|
1222
|
+
} catch {}
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
`${pathValue.length}`;
|
|
1226
|
+
resolvedGit = null;
|
|
1227
|
+
return resolvedGit;
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Run git with the S1-resolved absolute binary path. Returns stdout, or
|
|
1231
|
+
* null when git is missing or the command fails — the same contract as
|
|
1232
|
+
* the previous inline `git()` helpers, with the hijack surface closed.
|
|
1233
|
+
*/
|
|
1234
|
+
function runGit(root, args) {
|
|
1235
|
+
const exe = resolveGitPath();
|
|
1236
|
+
if (!exe) return null;
|
|
1237
|
+
try {
|
|
1238
|
+
return execFileSync(exe, [
|
|
1239
|
+
"-C",
|
|
1240
|
+
root,
|
|
1241
|
+
...args
|
|
1242
|
+
], {
|
|
1243
|
+
encoding: "utf8",
|
|
1244
|
+
stdio: [
|
|
1245
|
+
"ignore",
|
|
1246
|
+
"pipe",
|
|
1247
|
+
"ignore"
|
|
1248
|
+
],
|
|
1249
|
+
timeout: 15e3
|
|
1250
|
+
});
|
|
1251
|
+
} catch {
|
|
1252
|
+
return null;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
//#endregion
|
|
1256
|
+
//#region src/lib/safe-json.ts
|
|
1257
|
+
/**
|
|
1258
|
+
* Reusable safe-JSON parse-and-validate helper.
|
|
1259
|
+
*
|
|
1260
|
+
* Replaces the `JSON.parse(text) as T` pattern with a two-step process:
|
|
1261
|
+
* 1. Try/catch around JSON.parse (descriptive error on bad JSON)
|
|
1262
|
+
* 2. Runtime validation of the parsed shape (caller-supplied predicate)
|
|
1263
|
+
*
|
|
1264
|
+
* The `validate` predicate is intentionally lightweight — it checks the
|
|
1265
|
+
* expected shape exists rather than exhaustively type-guarding every field.
|
|
1266
|
+
* Internal files (cache, hashes) use `isRecord`; user-facing files
|
|
1267
|
+
* (config, plugins) use stricter checks that throw descriptive errors.
|
|
1268
|
+
*/
|
|
1269
|
+
const MAX_JSON_BYTES = 16777216;
|
|
1270
|
+
function parseJsonFile(text, source, validate) {
|
|
1271
|
+
if (Buffer.byteLength(text, "utf8") > MAX_JSON_BYTES) throw new Error(`JSON source exceeds the ${MAX_JSON_BYTES}-byte limit: ${source}`);
|
|
1272
|
+
let parsed;
|
|
1273
|
+
try {
|
|
1274
|
+
parsed = JSON.parse(text);
|
|
1275
|
+
} catch (err) {
|
|
1276
|
+
throw new Error(`invalid JSON in ${source}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
1277
|
+
}
|
|
1278
|
+
if (!validate(parsed)) throw new Error(`unexpected shape in ${source}`, { cause: parsed });
|
|
1279
|
+
return parsed;
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Minimal validator: value is a plain object (not null, not array).
|
|
1283
|
+
* Sufficient for internal files (cache, hashes) where downstream
|
|
1284
|
+
* code already checks individual fields.
|
|
1285
|
+
*/
|
|
1286
|
+
function isRecord(v) {
|
|
1287
|
+
try {
|
|
1288
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1289
|
+
} catch {
|
|
1290
|
+
return false;
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
//#endregion
|
|
1067
1294
|
//#region src/engine/candidate-binding.ts
|
|
1068
1295
|
/**
|
|
1069
1296
|
* Candidate binding (plan V5-010).
|
|
@@ -1084,9 +1311,6 @@ const SHA40 = /^[a-f0-9]{40}$/;
|
|
|
1084
1311
|
const SHA256 = /^[a-f0-9]{64}$/;
|
|
1085
1312
|
const STATES = ["WORKING_CANDIDATE", "RELEASE_CANDIDATE"];
|
|
1086
1313
|
const AUTHORIZATION = ["NOT_AUTHORIZED", "AUTHORIZED"];
|
|
1087
|
-
function isRecord$1(value) {
|
|
1088
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1089
|
-
}
|
|
1090
1314
|
function sha256File(path) {
|
|
1091
1315
|
try {
|
|
1092
1316
|
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
@@ -1094,17 +1318,41 @@ function sha256File(path) {
|
|
|
1094
1318
|
return;
|
|
1095
1319
|
}
|
|
1096
1320
|
}
|
|
1321
|
+
/**
|
|
1322
|
+
* One git line, or nothing.
|
|
1323
|
+
*
|
|
1324
|
+
* Audit S1: the binary is the ABSOLUTE path `resolveGitPath()` found by
|
|
1325
|
+
* walking PATH itself, never the bare name. `bindRepository()` runs on every
|
|
1326
|
+
* scan (`scan-pipeline.ts`), and a bare `execFileSync("git", …)` lets a
|
|
1327
|
+
* `git.exe`/`git.bat`/`git.cmd` CHECKED INTO AN UNTRUSTED REPO hijack the
|
|
1328
|
+
* call on Windows, where CreateProcess searches the current directory before
|
|
1329
|
+
* PATH. The attacker then controls the `commit` and `tree` that land in
|
|
1330
|
+
* `runIdentity` and the evidence graph — the provenance a release decision
|
|
1331
|
+
* reads. This was the last bare-name exec left in `src/`; the invariant that
|
|
1332
|
+
* keeps it that way is `tests/contract/no-bare-process-exec.spec.ts`.
|
|
1333
|
+
*
|
|
1334
|
+
* The `-C <root>` shape (not `cwd`) matches every other hardened call site.
|
|
1335
|
+
* With no git on PATH at all, the answer is nothing — a scan of an unpacked
|
|
1336
|
+
* tarball has no commit, and its identity must say so rather than carrying a
|
|
1337
|
+
* placeholder that looks like provenance.
|
|
1338
|
+
*/
|
|
1097
1339
|
function gitLine(root, args) {
|
|
1340
|
+
const exe = resolveGitPath();
|
|
1341
|
+
if (!exe) return void 0;
|
|
1098
1342
|
try {
|
|
1099
|
-
const trimmed = execFileSync(
|
|
1100
|
-
|
|
1343
|
+
const trimmed = execFileSync(exe, [
|
|
1344
|
+
"-C",
|
|
1345
|
+
root,
|
|
1346
|
+
...args
|
|
1347
|
+
], {
|
|
1101
1348
|
encoding: "utf8",
|
|
1102
1349
|
stdio: [
|
|
1103
1350
|
"ignore",
|
|
1104
1351
|
"pipe",
|
|
1105
1352
|
"ignore"
|
|
1106
1353
|
],
|
|
1107
|
-
windowsHide: true
|
|
1354
|
+
windowsHide: true,
|
|
1355
|
+
timeout: 15e3
|
|
1108
1356
|
}).trim();
|
|
1109
1357
|
return trimmed.length > 0 ? trimmed : void 0;
|
|
1110
1358
|
} catch {
|
|
@@ -1135,9 +1383,9 @@ function bindRepository(root) {
|
|
|
1135
1383
|
* contradiction the release decision evaluator treats as BLOCKED).
|
|
1136
1384
|
*/
|
|
1137
1385
|
function parseCandidateBinding(raw) {
|
|
1138
|
-
if (!isRecord
|
|
1386
|
+
if (!isRecord(raw)) return null;
|
|
1139
1387
|
const identity = raw.identity;
|
|
1140
|
-
if (!isRecord
|
|
1388
|
+
if (!isRecord(identity)) return null;
|
|
1141
1389
|
const state = identity.state;
|
|
1142
1390
|
const candidateSha = identity.candidateSha;
|
|
1143
1391
|
const baseSha = identity.baseSha;
|
|
@@ -1183,10 +1431,50 @@ function readCandidateBinding(root, manifestPath = CANDIDATE_MANIFEST_PATH) {
|
|
|
1183
1431
|
* version inside scan-pipeline WITHOUT importing cli.ts (a cycle — cli
|
|
1184
1432
|
* imports the pipeline). The literal follows the same discipline as
|
|
1185
1433
|
* CLI_VERSION and SARIF's driver.version: kept in sync on release by
|
|
1186
|
-
* scripts/sync-sarif-version.cjs
|
|
1434
|
+
* scripts/archive/sync-sarif-version.cjs (archived; superseded by the release
|
|
1435
|
+
* workflow's own version stamping) and guarded by the version-consistency
|
|
1187
1436
|
* spec. cli.ts re-exports this as CLI_VERSION.
|
|
1188
1437
|
*/
|
|
1189
|
-
const ENGINE_VERSION = "
|
|
1438
|
+
const ENGINE_VERSION = "6.0.0";
|
|
1439
|
+
resolveBuildId();
|
|
1440
|
+
/**
|
|
1441
|
+
* The decision, with the process boundary already behind `runGit`.
|
|
1442
|
+
*
|
|
1443
|
+
* Separated so every branch is reachable from a test: a runner that throws and
|
|
1444
|
+
* a runner that answers are both things that happen, and both are what this
|
|
1445
|
+
* function exists to survive. What it must never do is invent an answer.
|
|
1446
|
+
*/
|
|
1447
|
+
function buildIdFrom(runGit) {
|
|
1448
|
+
const sha = runGit([
|
|
1449
|
+
"rev-parse",
|
|
1450
|
+
"--short=12",
|
|
1451
|
+
"HEAD"
|
|
1452
|
+
]);
|
|
1453
|
+
if (!sha) return void 0;
|
|
1454
|
+
const status = runGit(["status", "--porcelain"]);
|
|
1455
|
+
if (status === void 0) return `${sha}-dirty`;
|
|
1456
|
+
return status ? `${sha}-dirty` : sha;
|
|
1457
|
+
}
|
|
1458
|
+
function resolveBuildId() {
|
|
1459
|
+
const git = resolveGitPath();
|
|
1460
|
+
if (!git) return void 0;
|
|
1461
|
+
const runGit = (args) => {
|
|
1462
|
+
try {
|
|
1463
|
+
return execFileSync(git, args, {
|
|
1464
|
+
encoding: "utf8",
|
|
1465
|
+
timeout: 2e3,
|
|
1466
|
+
stdio: [
|
|
1467
|
+
"ignore",
|
|
1468
|
+
"pipe",
|
|
1469
|
+
"ignore"
|
|
1470
|
+
]
|
|
1471
|
+
}).trim();
|
|
1472
|
+
} catch {
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
};
|
|
1476
|
+
return buildIdFrom(runGit);
|
|
1477
|
+
}
|
|
1190
1478
|
//#endregion
|
|
1191
1479
|
//#region src/engine/contract-versions.ts
|
|
1192
1480
|
/**
|
|
@@ -1198,7 +1486,15 @@ const ENGINE_VERSION = "4.0.0";
|
|
|
1198
1486
|
* surface without widening.
|
|
1199
1487
|
*/
|
|
1200
1488
|
const TRUST_MODEL_VERSION = "1.0.0";
|
|
1201
|
-
|
|
1489
|
+
/**
|
|
1490
|
+
* 2.0.0 (ADR 0014): the core tier carries an evidence FLOOR. A core rule's
|
|
1491
|
+
* finding is now stamped to at least E1, which changes `deductionFor` for
|
|
1492
|
+
* every core finding from 0 (E0) to `floor(base/2)`. The E0→E1→E2 ladder
|
|
1493
|
+
* itself is unchanged; what changed is which rung a core finding is allowed
|
|
1494
|
+
* to sit on, and therefore what it costs. Major, per the `semver + ADR
|
|
1495
|
+
* required` policy on the entry below.
|
|
1496
|
+
*/
|
|
1497
|
+
const SCORING_MODEL_VERSION = "2.0.0";
|
|
1202
1498
|
const FRAMEWORK_SUPPORT_MATRIX_VERSION = "1.0.0";
|
|
1203
1499
|
//#endregion
|
|
1204
1500
|
//#region src/discovery/evidence-discovery.ts
|
|
@@ -1245,6 +1541,11 @@ const EVIDENCE_CONVENTIONS = [
|
|
|
1245
1541
|
convention: "junit-file",
|
|
1246
1542
|
kind: "file",
|
|
1247
1543
|
names: ["junit-report.xml", "junit.xml"]
|
|
1544
|
+
},
|
|
1545
|
+
{
|
|
1546
|
+
convention: "dotnet-trx",
|
|
1547
|
+
kind: "dir",
|
|
1548
|
+
names: ["TestResults"]
|
|
1248
1549
|
}
|
|
1249
1550
|
];
|
|
1250
1551
|
const SEARCH_DEPTH = 2;
|
|
@@ -1315,9 +1616,12 @@ function discoverWorkspace(rootDir) {
|
|
|
1315
1616
|
const root = findProjectRoot(rootDir);
|
|
1316
1617
|
if (!root) return null;
|
|
1317
1618
|
let pkg = {};
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1619
|
+
const manifestPath = join(root, "package.json");
|
|
1620
|
+
if (existsSync(manifestPath)) try {
|
|
1621
|
+
pkg = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
1622
|
+
} catch {
|
|
1623
|
+
recordDegradation("workspace-manifest-unreadable");
|
|
1624
|
+
}
|
|
1321
1625
|
const rawWorkspaces = pkg["workspaces"];
|
|
1322
1626
|
let globs = [];
|
|
1323
1627
|
if (Array.isArray(rawWorkspaces)) globs = rawWorkspaces.filter((w) => typeof w === "string");
|
|
@@ -1334,40 +1638,6 @@ function basename$1(p) {
|
|
|
1334
1638
|
return parts[parts.length - 1];
|
|
1335
1639
|
}
|
|
1336
1640
|
//#endregion
|
|
1337
|
-
//#region src/lib/safe-json.ts
|
|
1338
|
-
/**
|
|
1339
|
-
* Reusable safe-JSON parse-and-validate helper.
|
|
1340
|
-
*
|
|
1341
|
-
* Replaces the `JSON.parse(text) as T` pattern with a two-step process:
|
|
1342
|
-
* 1. Try/catch around JSON.parse (descriptive error on bad JSON)
|
|
1343
|
-
* 2. Runtime validation of the parsed shape (caller-supplied predicate)
|
|
1344
|
-
*
|
|
1345
|
-
* The `validate` predicate is intentionally lightweight — it checks the
|
|
1346
|
-
* expected shape exists rather than exhaustively type-guarding every field.
|
|
1347
|
-
* Internal files (cache, hashes) use `isRecord`; user-facing files
|
|
1348
|
-
* (config, plugins) use stricter checks that throw descriptive errors.
|
|
1349
|
-
*/
|
|
1350
|
-
const MAX_JSON_BYTES = 16777216;
|
|
1351
|
-
function parseJsonFile(text, source, validate) {
|
|
1352
|
-
if (Buffer.byteLength(text, "utf8") > MAX_JSON_BYTES) throw new Error(`JSON source exceeds the ${MAX_JSON_BYTES}-byte limit: ${source}`);
|
|
1353
|
-
let parsed;
|
|
1354
|
-
try {
|
|
1355
|
-
parsed = JSON.parse(text);
|
|
1356
|
-
} catch (err) {
|
|
1357
|
-
throw new Error(`invalid JSON in ${source}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
1358
|
-
}
|
|
1359
|
-
if (!validate(parsed)) throw new Error(`unexpected shape in ${source}`, { cause: parsed });
|
|
1360
|
-
return parsed;
|
|
1361
|
-
}
|
|
1362
|
-
/**
|
|
1363
|
-
* Minimal validator: value is a plain object (not null, not array).
|
|
1364
|
-
* Sufficient for internal files (cache, hashes) where downstream
|
|
1365
|
-
* code already checks individual fields.
|
|
1366
|
-
*/
|
|
1367
|
-
function isRecord(v) {
|
|
1368
|
-
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1369
|
-
}
|
|
1370
|
-
//#endregion
|
|
1371
1641
|
//#region src/lib/fs-bounded.ts
|
|
1372
1642
|
function closeQuiet(fd) {
|
|
1373
1643
|
try {
|
|
@@ -1597,6 +1867,12 @@ function validate(cfg, knownRuleIds) {
|
|
|
1597
1867
|
* Expiry is evaluated only from an explicit ISO `expires` date. A missing
|
|
1598
1868
|
* date remains active and is reported as having no expiry. Config-file mtime
|
|
1599
1869
|
* is never an expiry anchor.
|
|
1870
|
+
*
|
|
1871
|
+
* `now` defaults to the current instant so existing callers stay correct, but
|
|
1872
|
+
* a verdict function that reads the clock invisibly is a verdict nobody can
|
|
1873
|
+
* test at a boundary. `tests/contract/deterministic-clock.spec.ts` audits
|
|
1874
|
+
* `src/config/**` for a call site that supplies its own `new Date()` instead
|
|
1875
|
+
* of threading one through.
|
|
1600
1876
|
*/
|
|
1601
1877
|
function isSuppressionActive(ign, now = /* @__PURE__ */ new Date()) {
|
|
1602
1878
|
if (!ign.expires) return true;
|
|
@@ -1621,6 +1897,108 @@ function applySeverityOverrides(findings, cfg) {
|
|
|
1621
1897
|
}
|
|
1622
1898
|
}
|
|
1623
1899
|
//#endregion
|
|
1900
|
+
//#region src/lib/compare.ts
|
|
1901
|
+
/**
|
|
1902
|
+
* Order that does not depend on who is asking.
|
|
1903
|
+
*
|
|
1904
|
+
* `String.prototype.localeCompare` with no locale argument resolves against
|
|
1905
|
+
* the PROCESS's default locale — the ICU collation chosen by `LANG`,
|
|
1906
|
+
* `LC_ALL` or the OS. So a sort written as
|
|
1907
|
+
* `[...xs].sort((a, b) => a.localeCompare(b))` produces a different order on
|
|
1908
|
+
* a developer's `de_DE` machine than on an en-US CI runner. For a display
|
|
1909
|
+
* list that is a nuisance; for anything that reaches a hash, a cache key, a
|
|
1910
|
+
* digest or a machine contract it is a correctness bug, because the same
|
|
1911
|
+
* repository then produces two different fingerprints.
|
|
1912
|
+
*
|
|
1913
|
+
* Two comparators, one per kind of site:
|
|
1914
|
+
*
|
|
1915
|
+
* compareCodePoints — pure UTF-16 code-unit order. Identical everywhere,
|
|
1916
|
+
* independent of ICU version, and already what `Array.prototype.sort`
|
|
1917
|
+
* defaults to. Use for every value that reaches a fingerprint, a digest,
|
|
1918
|
+
* a cache key, or a contract, and for any ordering with no human-facing
|
|
1919
|
+
* reason to be pretty.
|
|
1920
|
+
*
|
|
1921
|
+
* compareLocalized(locale) — the same collator, PINNED to a named locale
|
|
1922
|
+
* instead of inheriting the environment, for a surface that genuinely
|
|
1923
|
+
* wants readable order (a table of rule titles, a Mermaid legend). Pinned
|
|
1924
|
+
* rather than removed because a reader-facing list of "Test / Logic /
|
|
1925
|
+
* Mock" should not be ordered by code unit.
|
|
1926
|
+
*
|
|
1927
|
+
* A bare `localeCompare` with no second argument in `src/` is a defect and
|
|
1928
|
+
* `tests/contract/deterministic-ordering.spec.ts` fails the build on it.
|
|
1929
|
+
*/
|
|
1930
|
+
/** Code-unit order. Locale-free, ICU-free, and the Array.sort default. */
|
|
1931
|
+
function compareCodePoints(a, b) {
|
|
1932
|
+
if (a === b) return 0;
|
|
1933
|
+
return a < b ? -1 : 1;
|
|
1934
|
+
}
|
|
1935
|
+
/**
|
|
1936
|
+
* A readable-order comparator pinned to `locale`.
|
|
1937
|
+
*
|
|
1938
|
+
* The pin is the point: `a.localeCompare(b)` reads the environment, and the
|
|
1939
|
+
* environment is a machine setting, not a property of the code. Callers that
|
|
1940
|
+
* want stable output ask for a named locale and get the same answer on every
|
|
1941
|
+
* machine that ships the same ICU data.
|
|
1942
|
+
*/
|
|
1943
|
+
function compareLocalized(locale) {
|
|
1944
|
+
return (a, b) => a.localeCompare(b, locale);
|
|
1945
|
+
}
|
|
1946
|
+
//#endregion
|
|
1947
|
+
//#region src/lib/glob.ts
|
|
1948
|
+
/**
|
|
1949
|
+
* The shared segment compiler: a glob to a regex BODY, with no anchors.
|
|
1950
|
+
*
|
|
1951
|
+
* Module-private because the anchoring is a caller decision and the DIALECT is
|
|
1952
|
+
* not: the repository needs a full-path match and a bare-name-at-any-depth
|
|
1953
|
+
* match, and duplicating the segment walk to get the second one is exactly how
|
|
1954
|
+
* two compilers came to disagree. Callers pick an anchoring; the dialect is
|
|
1955
|
+
* decided here and nowhere else.
|
|
1956
|
+
*
|
|
1957
|
+
* `?`, `[`, `]`, `!` and every other regex metacharacter in the literal parts
|
|
1958
|
+
* are escaped, so nothing a user writes in a config or ledger can reach the
|
|
1959
|
+
* regex engine as syntax. `*` is the only metacharacter.
|
|
1960
|
+
*/
|
|
1961
|
+
function globBody(glob) {
|
|
1962
|
+
const segments = glob.replaceAll("\\", "/").split("/");
|
|
1963
|
+
let re = "";
|
|
1964
|
+
for (const [i, segment] of segments.entries()) {
|
|
1965
|
+
const last = i === segments.length - 1;
|
|
1966
|
+
if (segment === "**") {
|
|
1967
|
+
re += last ? "(?:[^/]+/)*[^/]+" : "(?:[^/]+/)*";
|
|
1968
|
+
continue;
|
|
1969
|
+
}
|
|
1970
|
+
re += segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", "[^/]*");
|
|
1971
|
+
if (!last) re += "/";
|
|
1972
|
+
}
|
|
1973
|
+
return re;
|
|
1974
|
+
}
|
|
1975
|
+
/** A glob as a regex anchored to the WHOLE path. */
|
|
1976
|
+
function anchoredGlobRegExp(glob) {
|
|
1977
|
+
return new RegExp(`^${globBody(glob)}$`);
|
|
1978
|
+
}
|
|
1979
|
+
/**
|
|
1980
|
+
* A glob as a regex matching at ANY DEPTH — a bare name.
|
|
1981
|
+
*
|
|
1982
|
+
* `node_modules` becomes "a file or directory of this name anywhere", which is
|
|
1983
|
+
* gitignore semantics: every path inside the directory contains the segment, so
|
|
1984
|
+
* a directory match carries to its contents.
|
|
1985
|
+
*/
|
|
1986
|
+
function anyDepthGlobRegExp(glob) {
|
|
1987
|
+
return new RegExp(`(?:^|/)${globBody(glob)}(?:/|$)`);
|
|
1988
|
+
}
|
|
1989
|
+
/**
|
|
1990
|
+
* Whether `path` matches `glob`, in the dialect documented above.
|
|
1991
|
+
*
|
|
1992
|
+
* Both sides are normalized to forward slashes first. Finding paths are already
|
|
1993
|
+
* normalized by the walker, but a suppression `files` pattern written on
|
|
1994
|
+
* Windows ("e2e\\x.spec.ts") compiled to a literal-backslash regex that could
|
|
1995
|
+
* never match any finding — the suppression silently never applied, which is
|
|
1996
|
+
* the defect that made the whole dialect worth unifying.
|
|
1997
|
+
*/
|
|
1998
|
+
function pathMatchesGlob(path, glob) {
|
|
1999
|
+
return anchoredGlobRegExp(glob).test(path.replaceAll("\\", "/"));
|
|
2000
|
+
}
|
|
2001
|
+
//#endregion
|
|
1624
2002
|
//#region src/discovery/ignores.ts
|
|
1625
2003
|
/**
|
|
1626
2004
|
* Ignore resolution (Product-MVP.txt R1 — Tier-1 launch blocker).
|
|
@@ -1755,32 +2133,12 @@ function compilePattern(raw) {
|
|
|
1755
2133
|
if (pattern.trim() === "") return null;
|
|
1756
2134
|
if (pattern.length > LIMITS$1.maxPatternLength) return null;
|
|
1757
2135
|
if ((pattern.match(/[*?]/g) ?? []).length > LIMITS$1.maxPatternWildcards) return null;
|
|
1758
|
-
const re = pattern.includes("/") ?
|
|
2136
|
+
const re = pattern.includes("/") ? anchoredGlobRegExp(pattern) : anyDepthGlobRegExp(pattern);
|
|
1759
2137
|
return {
|
|
1760
2138
|
negated,
|
|
1761
2139
|
re
|
|
1762
2140
|
};
|
|
1763
2141
|
}
|
|
1764
|
-
/** The minimal glob dialect as a regex body without anchors. */
|
|
1765
|
-
function globBody(glob) {
|
|
1766
|
-
let re = "";
|
|
1767
|
-
for (let i = 0; i < glob.length; i++) {
|
|
1768
|
-
const c = glob[i];
|
|
1769
|
-
if (c === "*") {
|
|
1770
|
-
if (glob[i + 1] === "*") {
|
|
1771
|
-
if (glob[i + 2] === "/") {
|
|
1772
|
-
re += "(?:[^/]*/)*";
|
|
1773
|
-
i += 2;
|
|
1774
|
-
} else {
|
|
1775
|
-
re += ".*";
|
|
1776
|
-
i += 1;
|
|
1777
|
-
}
|
|
1778
|
-
} else re += "[^/]*";
|
|
1779
|
-
} else if (c === "?") re += "[^/]";
|
|
1780
|
-
else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
1781
|
-
}
|
|
1782
|
-
return re;
|
|
1783
|
-
}
|
|
1784
2142
|
createMatcherFromPatterns([]);
|
|
1785
2143
|
/**
|
|
1786
2144
|
* Check if a directory is a lint-fixture shape (Phase 2 auto-detection).
|
|
@@ -1957,16 +2315,29 @@ function detectFrameworks(ws) {
|
|
|
1957
2315
|
* Migration discipline (per the plan): rule-by-rule, golden lock must stay
|
|
1958
2316
|
* byte-identical — a score shift is a regression, not an improvement.
|
|
1959
2317
|
*/
|
|
1960
|
-
let project = null;
|
|
1961
2318
|
/**
|
|
1962
|
-
*
|
|
2319
|
+
* One shared Project PER SCAN keeps memory bounded while reusing the compiler's
|
|
2320
|
+
* program across files.
|
|
1963
2321
|
*
|
|
1964
|
-
*
|
|
1965
|
-
*
|
|
1966
|
-
*
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
2322
|
+
* "Per scan" has to mean per scan. The variable lived at module scope, so it
|
|
2323
|
+
* was one Project per PROCESS, and two scans running concurrently shared it —
|
|
2324
|
+
* `parseTsFile` calls `replaceWithText` on a cached SourceFile, so scan B
|
|
2325
|
+
* could overwrite the text scan A was mid-rule on. ts-morph's in-memory file
|
|
2326
|
+
* system and language service are not reentrant, and the observable result was
|
|
2327
|
+
* three of four concurrent scans reporting `rulesCrashed: 4` with
|
|
2328
|
+
* `rules: "partial"` — different machine-contract digests from the same tree,
|
|
2329
|
+
* which is the one thing this product claims cannot happen.
|
|
2330
|
+
*
|
|
2331
|
+
* An AsyncLocalStorage scope is the smallest change that makes the comment
|
|
2332
|
+
* true: each scan's async context carries its own Project, memory stays bounded
|
|
2333
|
+
* per scan exactly as intended, and the context is collected when the scan's
|
|
2334
|
+
* promise chain is. The loose project below remains for callers outside a scan
|
|
2335
|
+
* (tests, one-off parses), where there is nothing to collide with.
|
|
2336
|
+
*/
|
|
2337
|
+
const scanProjects = new AsyncLocalStorage();
|
|
2338
|
+
let looseProject = null;
|
|
2339
|
+
function createProject() {
|
|
2340
|
+
return new Project({
|
|
1970
2341
|
useInMemoryFileSystem: true,
|
|
1971
2342
|
skipAddingFilesFromTsConfig: true,
|
|
1972
2343
|
compilerOptions: {
|
|
@@ -1975,7 +2346,28 @@ function getProject() {
|
|
|
1975
2346
|
noEmit: true
|
|
1976
2347
|
}
|
|
1977
2348
|
});
|
|
1978
|
-
|
|
2349
|
+
}
|
|
2350
|
+
/**
|
|
2351
|
+
* Run `fn` with its own ts-morph Project.
|
|
2352
|
+
*
|
|
2353
|
+
* Async context propagates across `await`, so the Project stays the scan's own
|
|
2354
|
+
* for the whole scan without threading an argument through every adapter.
|
|
2355
|
+
*/
|
|
2356
|
+
function runWithScanProject(fn) {
|
|
2357
|
+
return scanProjects.run(createProject(), fn);
|
|
2358
|
+
}
|
|
2359
|
+
/**
|
|
2360
|
+
* The ts-morph Project for the current scan.
|
|
2361
|
+
*
|
|
2362
|
+
* Exported so an adapter's `dispose()` can evict the file it parsed: ts-morph
|
|
2363
|
+
* caches by file path, so without eviction a long scan holds every parsed
|
|
2364
|
+
* SourceFile until the process exits (plan V5-021).
|
|
2365
|
+
*/
|
|
2366
|
+
function getProject() {
|
|
2367
|
+
const scoped = scanProjects.getStore();
|
|
2368
|
+
if (scoped !== void 0) return scoped;
|
|
2369
|
+
looseProject ??= createProject();
|
|
2370
|
+
return looseProject;
|
|
1979
2371
|
}
|
|
1980
2372
|
/**
|
|
1981
2373
|
* Parse (or fetch from cache) the ts-morph SourceFile for a scanned file.
|
|
@@ -1992,6 +2384,7 @@ function parseTsFile(file) {
|
|
|
1992
2384
|
}
|
|
1993
2385
|
return p.createSourceFile(file.path, file.text);
|
|
1994
2386
|
} catch {
|
|
2387
|
+
recordDegradation("ast-parse-failed");
|
|
1995
2388
|
return;
|
|
1996
2389
|
}
|
|
1997
2390
|
}
|
|
@@ -2047,6 +2440,7 @@ function commentAndStringRanges(ctx) {
|
|
|
2047
2440
|
}
|
|
2048
2441
|
return ranges;
|
|
2049
2442
|
} catch {
|
|
2443
|
+
recordDegradation("ast-range-scan-failed");
|
|
2050
2444
|
return [];
|
|
2051
2445
|
}
|
|
2052
2446
|
}
|
|
@@ -2094,12 +2488,20 @@ function getCodeOnlyText(file) {
|
|
|
2094
2488
|
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] = " ";
|
|
2095
2489
|
return chars.join("");
|
|
2096
2490
|
} catch {
|
|
2491
|
+
recordDegradation("ast-mask-unavailable");
|
|
2097
2492
|
return file.text;
|
|
2098
2493
|
}
|
|
2099
2494
|
}
|
|
2100
|
-
/**
|
|
2495
|
+
/**
|
|
2496
|
+
* Release the loose ts-morph Project used outside a scan.
|
|
2497
|
+
*
|
|
2498
|
+
* Deliberately does NOT call `scanProjects.disable()`. Scan-scoped Projects are
|
|
2499
|
+
* released by garbage collection when the scan's async context ends, and
|
|
2500
|
+
* `disable()` would tear the store out from under a scan still running — the
|
|
2501
|
+
* exact corruption this scoping exists to remove.
|
|
2502
|
+
*/
|
|
2101
2503
|
function resetTsMorphProject() {
|
|
2102
|
-
|
|
2504
|
+
looseProject = null;
|
|
2103
2505
|
}
|
|
2104
2506
|
//#endregion
|
|
2105
2507
|
//#region src/engine/code-text.ts
|
|
@@ -2478,6 +2880,44 @@ function frameworkFilterApplies(rule, file) {
|
|
|
2478
2880
|
return rule.frameworks.some((f) => tags.includes(f));
|
|
2479
2881
|
}
|
|
2480
2882
|
//#endregion
|
|
2883
|
+
//#region src/engine/shared-run-rules.ts
|
|
2884
|
+
/**
|
|
2885
|
+
* Run ONE rule with crash isolation, and return what it produced.
|
|
2886
|
+
*
|
|
2887
|
+
* This is the block that was copy-pasted into all seven language adapters:
|
|
2888
|
+
*
|
|
2889
|
+
* try { for (const f of rule.run(x)) emit(f, rule.id, rule.category); }
|
|
2890
|
+
* catch (error) { onCrash?.(rule.id, error); }
|
|
2891
|
+
*
|
|
2892
|
+
* Extracting it is worth doing for one reason beyond line count. Crash
|
|
2893
|
+
* isolation is the mechanism that lets a third-party or experimental
|
|
2894
|
+
* detector fail without ending a scan, and it is the mechanism the whole
|
|
2895
|
+
* `rulesCrashed` count depends on. Seven copies means seven chances to
|
|
2896
|
+
* write `catch {}` and lose the count, and `tests/contract/
|
|
2897
|
+
* no-uncounted-degradation.spec.ts` can only audit what it can see.
|
|
2898
|
+
*
|
|
2899
|
+
* `null` means the rule CRASHED, which is not the same as an empty array:
|
|
2900
|
+
* an empty array is the rule abstaining, and the adapters skip emission for
|
|
2901
|
+
* both but the distinction is why the caller must not conflate them.
|
|
2902
|
+
*
|
|
2903
|
+
* Deliberately NOT extracted: the per-adapter filter chain and the budget
|
|
2904
|
+
* check. Those genuinely differ — the TypeScript adapter injects an AST,
|
|
2905
|
+
* framework tags, a lazy `codeText` getter, `appliesTo` scoping, config-only
|
|
2906
|
+
* gating and `configGateMatches`, and it ABORTS the whole file on budget
|
|
2907
|
+
* exhaustion where `runRulesShared` only `continue`s to the next rule.
|
|
2908
|
+
* Routing every adapter through `runRulesShared` as written would silently
|
|
2909
|
+
* change which rules run and when, which is precisely the unmeasured claim
|
|
2910
|
+
* this program exists to delete. This helper takes the part they share.
|
|
2911
|
+
*/
|
|
2912
|
+
function runRuleIsolated(rule, file, onCrash) {
|
|
2913
|
+
try {
|
|
2914
|
+
return rule.run(file);
|
|
2915
|
+
} catch (error) {
|
|
2916
|
+
onCrash?.(rule.id, error);
|
|
2917
|
+
return null;
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
//#endregion
|
|
2481
2921
|
//#region src/adapters/typescript.ts
|
|
2482
2922
|
/**
|
|
2483
2923
|
* TypeScript/JavaScript adapter (R1).
|
|
@@ -2515,10 +2955,18 @@ function frameworkFilterApplies(rule, file) {
|
|
|
2515
2955
|
* four lines below: PW_CONFIG_RE already accepted `cts`. The config regex knew
|
|
2516
2956
|
* about the Node-native extensions; the test-file regex did not.
|
|
2517
2957
|
*
|
|
2518
|
-
* `discovery/scan-adapters.ts:isUnrecognizedSourceCandidate`
|
|
2519
|
-
*
|
|
2520
|
-
*
|
|
2521
|
-
*
|
|
2958
|
+
* `discovery/scan-adapters.ts:isUnrecognizedSourceCandidate` counts these
|
|
2959
|
+
* paths as uncovered surface, so the scope accounting reports a hole if one
|
|
2960
|
+
* exists.
|
|
2961
|
+
*
|
|
2962
|
+
* CORRECTION: the comment this replaced said the scope accounting "had been
|
|
2963
|
+
* reporting the hole the whole time". It had not — that checker's regex
|
|
2964
|
+
* already matched `mts`/`cts`, and only `extensions` was unread. What WAS
|
|
2965
|
+
* true, and is what this entry now fixes, is that a field every adapter
|
|
2966
|
+
* declares and no code consulted is a claim the interface makes to anyone who
|
|
2967
|
+
* reads it. `isUnrecognizedSourceCandidate` now builds its extension set from
|
|
2968
|
+
* this list rather than from a second hard-coded one, which is what makes the
|
|
2969
|
+
* two halves of the same fact stop being able to drift.
|
|
2522
2970
|
*/
|
|
2523
2971
|
const TEST_FILE_RE = /\.(?:test|spec)\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$|\.cy\.(?:js|jsx|ts|tsx)$/;
|
|
2524
2972
|
/**
|
|
@@ -2590,7 +3038,9 @@ const typescriptAdapter = {
|
|
|
2590
3038
|
".js",
|
|
2591
3039
|
".jsx",
|
|
2592
3040
|
".mjs",
|
|
2593
|
-
".cjs"
|
|
3041
|
+
".cjs",
|
|
3042
|
+
".mts",
|
|
3043
|
+
".cts"
|
|
2594
3044
|
],
|
|
2595
3045
|
testFileGlobs: [
|
|
2596
3046
|
"*.test.{js,jsx,ts,tsx,mjs,cjs}",
|
|
@@ -2687,11 +3137,9 @@ const typescriptAdapter = {
|
|
|
2687
3137
|
budget.onExceeded();
|
|
2688
3138
|
return;
|
|
2689
3139
|
}
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
onCrash?.(rule.id, error);
|
|
2694
|
-
}
|
|
3140
|
+
const produced = runRuleIsolated(rule, enriched, onCrash);
|
|
3141
|
+
if (produced === null) continue;
|
|
3142
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
2695
3143
|
}
|
|
2696
3144
|
}
|
|
2697
3145
|
};
|
|
@@ -2719,6 +3167,7 @@ function loadWorkspaceShim(root) {
|
|
|
2719
3167
|
workspaceGlobs: []
|
|
2720
3168
|
};
|
|
2721
3169
|
} catch {
|
|
3170
|
+
recordDegradation("workspace-manifest-unreadable");
|
|
2722
3171
|
return null;
|
|
2723
3172
|
}
|
|
2724
3173
|
}
|
|
@@ -2802,7 +3251,11 @@ async function withParseSlot(fn) {
|
|
|
2802
3251
|
}
|
|
2803
3252
|
function grammarPath(fileName) {
|
|
2804
3253
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
2805
|
-
const candidates = [
|
|
3254
|
+
const candidates = [
|
|
3255
|
+
join(here, "..", "..", "node_modules", "tree-sitter-wasms", "out", fileName),
|
|
3256
|
+
join(here, "..", "node_modules", "tree-sitter-wasms", "out", fileName),
|
|
3257
|
+
join(here, "..", "..", "..", "node_modules", "tree-sitter-wasms", "out", fileName)
|
|
3258
|
+
];
|
|
2806
3259
|
return candidates.find((p) => existsSync(p)) ?? candidates[0];
|
|
2807
3260
|
}
|
|
2808
3261
|
async function ensureParserInitialized() {
|
|
@@ -2882,6 +3335,7 @@ async function parseJavaAst(text) {
|
|
|
2882
3335
|
return (await getJavaParser()).parse(text) ?? void 0;
|
|
2883
3336
|
});
|
|
2884
3337
|
} catch {
|
|
3338
|
+
recordDegradation("ast-grammar-unavailable");
|
|
2885
3339
|
return;
|
|
2886
3340
|
}
|
|
2887
3341
|
}
|
|
@@ -2895,6 +3349,7 @@ async function parseCSharpAst(text) {
|
|
|
2895
3349
|
return (await getCSharpParser()).parse(text) ?? void 0;
|
|
2896
3350
|
});
|
|
2897
3351
|
} catch {
|
|
3352
|
+
recordDegradation("ast-grammar-unavailable");
|
|
2898
3353
|
return;
|
|
2899
3354
|
}
|
|
2900
3355
|
}
|
|
@@ -2910,6 +3365,7 @@ async function parsePythonAst(text) {
|
|
|
2910
3365
|
return (await getPythonParser()).parse(text) ?? void 0;
|
|
2911
3366
|
});
|
|
2912
3367
|
} catch {
|
|
3368
|
+
recordDegradation("ast-grammar-unavailable");
|
|
2913
3369
|
return;
|
|
2914
3370
|
}
|
|
2915
3371
|
}
|
|
@@ -3008,7 +3464,9 @@ const pythonAdapter = {
|
|
|
3008
3464
|
if (existsSync(pyproject)) try {
|
|
3009
3465
|
const text = readText$2(pyproject);
|
|
3010
3466
|
if (/\[tool\.pytest/i.test(text)) frameworks.add("pytest");
|
|
3011
|
-
} catch {
|
|
3467
|
+
} catch {
|
|
3468
|
+
recordDegradation("python-manifest-unreadable");
|
|
3469
|
+
}
|
|
3012
3470
|
for (const reqFile of [
|
|
3013
3471
|
"requirements.txt",
|
|
3014
3472
|
"requirements-dev.txt",
|
|
@@ -3020,7 +3478,9 @@ const pythonAdapter = {
|
|
|
3020
3478
|
const text = readText$2(p);
|
|
3021
3479
|
if (/^selenium\b/im.test(text)) frameworks.add("selenium");
|
|
3022
3480
|
if (/^playwright\b/im.test(text)) frameworks.add("playwright");
|
|
3023
|
-
} catch {
|
|
3481
|
+
} catch {
|
|
3482
|
+
recordDegradation("python-manifest-unreadable");
|
|
3483
|
+
}
|
|
3024
3484
|
}
|
|
3025
3485
|
if (frameworks.size === 0) return {
|
|
3026
3486
|
frameworks: [],
|
|
@@ -3073,11 +3533,9 @@ const pythonAdapter = {
|
|
|
3073
3533
|
budget.onExceeded();
|
|
3074
3534
|
return;
|
|
3075
3535
|
}
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
onCrash?.(rule.id, error);
|
|
3080
|
-
}
|
|
3536
|
+
const produced = runRuleIsolated(rule, enriched, onCrash);
|
|
3537
|
+
if (produced === null) continue;
|
|
3538
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
3081
3539
|
}
|
|
3082
3540
|
},
|
|
3083
3541
|
async parseAst(file) {
|
|
@@ -3230,11 +3688,9 @@ const javaAdapter = {
|
|
|
3230
3688
|
budget.onExceeded();
|
|
3231
3689
|
return;
|
|
3232
3690
|
}
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
onCrash?.(rule.id, error);
|
|
3237
|
-
}
|
|
3691
|
+
const produced = runRuleIsolated(rule, enriched, onCrash);
|
|
3692
|
+
if (produced === null) continue;
|
|
3693
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
3238
3694
|
}
|
|
3239
3695
|
}
|
|
3240
3696
|
};
|
|
@@ -3388,11 +3844,9 @@ const csharpAdapter = {
|
|
|
3388
3844
|
budget.onExceeded();
|
|
3389
3845
|
return;
|
|
3390
3846
|
}
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
onCrash?.(rule.id, error);
|
|
3395
|
-
}
|
|
3847
|
+
const produced = runRuleIsolated(rule, enriched, onCrash);
|
|
3848
|
+
if (produced === null) continue;
|
|
3849
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
3396
3850
|
}
|
|
3397
3851
|
}
|
|
3398
3852
|
};
|
|
@@ -3570,6 +4024,7 @@ const githubActionsAdapter = {
|
|
|
3570
4024
|
try {
|
|
3571
4025
|
entries = readdirSync(wfDir);
|
|
3572
4026
|
} catch {
|
|
4027
|
+
recordDegradation("ci-workflow-listing-unreadable");
|
|
3573
4028
|
return;
|
|
3574
4029
|
}
|
|
3575
4030
|
for (const name of entries) {
|
|
@@ -3610,15 +4065,13 @@ const githubActionsAdapter = {
|
|
|
3610
4065
|
budget.onExceeded();
|
|
3611
4066
|
return;
|
|
3612
4067
|
}
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
onCrash?.(rule.id, error);
|
|
3621
|
-
}
|
|
4068
|
+
const produced = runRuleIsolated(rule, {
|
|
4069
|
+
path: file.path,
|
|
4070
|
+
text: file.text,
|
|
4071
|
+
ast: doc
|
|
4072
|
+
}, onCrash);
|
|
4073
|
+
if (produced === null) continue;
|
|
4074
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
3622
4075
|
}
|
|
3623
4076
|
}
|
|
3624
4077
|
};
|
|
@@ -3928,15 +4381,13 @@ const azurePipelinesAdapter = {
|
|
|
3928
4381
|
budget.onExceeded();
|
|
3929
4382
|
return;
|
|
3930
4383
|
}
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
onCrash?.(rule.id, error);
|
|
3939
|
-
}
|
|
4384
|
+
const produced = runRuleIsolated(rule, {
|
|
4385
|
+
path: file.path,
|
|
4386
|
+
text: file.text,
|
|
4387
|
+
ast: doc
|
|
4388
|
+
}, onCrash);
|
|
4389
|
+
if (produced === null) continue;
|
|
4390
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
3940
4391
|
}
|
|
3941
4392
|
}
|
|
3942
4393
|
};
|
|
@@ -4007,14 +4458,12 @@ const jenkinsAdapter = {
|
|
|
4007
4458
|
budget.onExceeded();
|
|
4008
4459
|
return;
|
|
4009
4460
|
}
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
onCrash?.(rule.id, error);
|
|
4017
|
-
}
|
|
4461
|
+
const produced = runRuleIsolated(rule, {
|
|
4462
|
+
path: file.path,
|
|
4463
|
+
text: file.text
|
|
4464
|
+
}, onCrash);
|
|
4465
|
+
if (produced === null) continue;
|
|
4466
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
4018
4467
|
}
|
|
4019
4468
|
}
|
|
4020
4469
|
};
|
|
@@ -4113,109 +4562,50 @@ function isScopeRelevantIgnored(path) {
|
|
|
4113
4562
|
if (!/\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml|min\.js)$/i.test(name)) return false;
|
|
4114
4563
|
return isKnownTestFile(path);
|
|
4115
4564
|
}
|
|
4116
|
-
function isUnrecognizedSourceCandidate(path) {
|
|
4117
|
-
const normalized = path.replaceAll("\\", "/");
|
|
4118
|
-
const name = normalized.split("/").pop() ?? normalized;
|
|
4119
|
-
if (normalized.includes(".github/workflows/") || name === "azure-pipelines.yml" || name === "Jenkinsfile") return false;
|
|
4120
|
-
const extensionIndex = name.lastIndexOf(".");
|
|
4121
|
-
const stem = extensionIndex > 0 ? name.slice(0, extensionIndex) : name;
|
|
4122
|
-
if (name === "mjolnir.config.json" || name === ".mjolnir.json" || name === "package.json" || name === "package-lock.json" || name === "pnpm-lock.yaml" || name === "yarn.lock" || [
|
|
4123
|
-
"playwright",
|
|
4124
|
-
"vitest",
|
|
4125
|
-
"vite",
|
|
4126
|
-
"eslint",
|
|
4127
|
-
"tsconfig",
|
|
4128
|
-
"jsconfig"
|
|
4129
|
-
].some((prefix) => stem === prefix || stem.startsWith(`${prefix}.`))) return false;
|
|
4130
|
-
const testLike = /(?:^|\/)__tests__\//i.test(normalized) || /\.(?:spec|test)\.[cm]?[jt]sx?$/i.test(name);
|
|
4131
|
-
return /\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml)$/i.test(name) && testLike;
|
|
4132
|
-
}
|
|
4133
|
-
/** Whether ANY shipped adapter would discover this path as a test file. */
|
|
4134
|
-
function isKnownTestFile(path) {
|
|
4135
|
-
return SCAN_ADAPTERS.some((a) => a.isTestFile(path));
|
|
4136
|
-
}
|
|
4137
|
-
/** True when any path segment (relative to the scan root) is a skip name. */
|
|
4138
|
-
function isInsideSkippedDir(root, absPath, skipNames) {
|
|
4139
|
-
if (skipNames.length === 0) return false;
|
|
4140
|
-
return relative(root, absPath).replaceAll("\\", "/").split("/").slice(0, -1).some((seg) => skipNames.includes(seg));
|
|
4141
|
-
}
|
|
4142
|
-
typescriptAdapter.testFileGlobs, pythonAdapter.testFileGlobs, javaAdapter.testFileGlobs, csharpAdapter.testFileGlobs, githubActionsAdapter.testFileGlobs, azurePipelinesAdapter.testFileGlobs, jenkinsAdapter.testFileGlobs;
|
|
4143
|
-
//#endregion
|
|
4144
|
-
//#region src/scope/git-resolve.ts
|
|
4145
4565
|
/**
|
|
4146
|
-
*
|
|
4566
|
+
* Every source extension any shipped adapter claims.
|
|
4147
4567
|
*
|
|
4148
|
-
*
|
|
4149
|
-
*
|
|
4150
|
-
*
|
|
4151
|
-
*
|
|
4152
|
-
* `git.bat`/`git.cmd`) hijack Mjölnir's own git invocations: the attacker
|
|
4153
|
-
* controls the diff output, the merge-base decision, and
|
|
4154
|
-
* `--scope changed`'s "what is new" answer — the exact data CI gates on.
|
|
4568
|
+
* This is where `LanguageAdapter.extensions` is READ. The field was declared by
|
|
4569
|
+
* every adapter, listed a set of extensions, and consulted by nothing in
|
|
4570
|
+
* `src/` — so a support claim that no code checked was still a claim the
|
|
4571
|
+
* interface made to every reader.
|
|
4155
4572
|
*
|
|
4156
|
-
*
|
|
4157
|
-
* the
|
|
4158
|
-
*
|
|
4159
|
-
*
|
|
4160
|
-
*
|
|
4161
|
-
*/
|
|
4162
|
-
|
|
4163
|
-
/**
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
try {
|
|
4182
|
-
if (existsSync(full)) {
|
|
4183
|
-
resolvedGit = full;
|
|
4184
|
-
return resolvedGit;
|
|
4185
|
-
}
|
|
4186
|
-
} catch {}
|
|
4187
|
-
}
|
|
4188
|
-
}
|
|
4189
|
-
`${pathValue.length}`;
|
|
4190
|
-
resolvedGit = null;
|
|
4191
|
-
return resolvedGit;
|
|
4573
|
+
* Derived rather than hand-listed so the two halves cannot drift. A test
|
|
4574
|
+
* already compared the old hand-written regex against this set and found them
|
|
4575
|
+
* equivalent, which is the honest result: the refactor removes a second
|
|
4576
|
+
* source of truth rather than fixing a wrong answer. The `.mts`/`.cts` gap
|
|
4577
|
+
* was real but lived in `extensions`, not here, and is now closed there.
|
|
4578
|
+
*/
|
|
4579
|
+
const ADAPTER_CLAIMED_EXTENSIONS = new Set(SCAN_ADAPTERS.flatMap((adapter) => adapter.extensions).map((ext) => ext.replace(/^\./, "").toLowerCase()));
|
|
4580
|
+
/** Whether ignoring this file would have removed it from the scanned surface. */
|
|
4581
|
+
function isUnrecognizedSourceCandidate(path) {
|
|
4582
|
+
const normalized = path.replaceAll("\\", "/");
|
|
4583
|
+
const name = normalized.split("/").pop() ?? normalized;
|
|
4584
|
+
if (normalized.includes(".github/workflows/") || name === "azure-pipelines.yml" || name === "Jenkinsfile") return false;
|
|
4585
|
+
const extensionIndex = name.lastIndexOf(".");
|
|
4586
|
+
const stem = extensionIndex > 0 ? name.slice(0, extensionIndex) : name;
|
|
4587
|
+
if (name === "mjolnir.config.json" || name === ".mjolnir.json" || name === "package.json" || name === "package-lock.json" || name === "pnpm-lock.yaml" || name === "yarn.lock" || [
|
|
4588
|
+
"playwright",
|
|
4589
|
+
"vitest",
|
|
4590
|
+
"vite",
|
|
4591
|
+
"eslint",
|
|
4592
|
+
"tsconfig",
|
|
4593
|
+
"jsconfig"
|
|
4594
|
+
].some((prefix) => stem === prefix || stem.startsWith(`${prefix}.`))) return false;
|
|
4595
|
+
if (!(/(?:^|\/)__tests__\//i.test(normalized) || /\.(?:spec|test)\.[cm]?[jt]sx?$/i.test(name))) return false;
|
|
4596
|
+
const extension = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
|
|
4597
|
+
return ADAPTER_CLAIMED_EXTENSIONS.has(extension);
|
|
4192
4598
|
}
|
|
4193
|
-
/**
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
*/
|
|
4198
|
-
function
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
try {
|
|
4202
|
-
return execFileSync(exe, [
|
|
4203
|
-
"-C",
|
|
4204
|
-
root,
|
|
4205
|
-
...args
|
|
4206
|
-
], {
|
|
4207
|
-
encoding: "utf8",
|
|
4208
|
-
stdio: [
|
|
4209
|
-
"ignore",
|
|
4210
|
-
"pipe",
|
|
4211
|
-
"ignore"
|
|
4212
|
-
],
|
|
4213
|
-
timeout: 15e3
|
|
4214
|
-
});
|
|
4215
|
-
} catch {
|
|
4216
|
-
return null;
|
|
4217
|
-
}
|
|
4599
|
+
/** Whether ANY shipped adapter would discover this path as a test file. */
|
|
4600
|
+
function isKnownTestFile(path) {
|
|
4601
|
+
return SCAN_ADAPTERS.some((a) => a.isTestFile(path));
|
|
4602
|
+
}
|
|
4603
|
+
/** True when any path segment (relative to the scan root) is a skip name. */
|
|
4604
|
+
function isInsideSkippedDir(root, absPath, skipNames) {
|
|
4605
|
+
if (skipNames.length === 0) return false;
|
|
4606
|
+
return relative(root, absPath).replaceAll("\\", "/").split("/").slice(0, -1).some((seg) => skipNames.includes(seg));
|
|
4218
4607
|
}
|
|
4608
|
+
typescriptAdapter.testFileGlobs, pythonAdapter.testFileGlobs, javaAdapter.testFileGlobs, csharpAdapter.testFileGlobs, githubActionsAdapter.testFileGlobs, azurePipelinesAdapter.testFileGlobs, jenkinsAdapter.testFileGlobs;
|
|
4219
4609
|
//#endregion
|
|
4220
4610
|
//#region src/scope/changed.ts
|
|
4221
4611
|
/**
|
|
@@ -5394,7 +5784,7 @@ const ASYNC_PW_MATCHERS = /* @__PURE__ */ new Set([
|
|
|
5394
5784
|
]);
|
|
5395
5785
|
const unawaitedLocatorAssertion = defineRule({
|
|
5396
5786
|
id: "QA-PW-002",
|
|
5397
|
-
tier: "
|
|
5787
|
+
tier: "extended",
|
|
5398
5788
|
category: "QA-PW",
|
|
5399
5789
|
title: "Unawaited Playwright assertion",
|
|
5400
5790
|
severity: "error",
|
|
@@ -5508,7 +5898,7 @@ const committedDebugArtifacts = defineRule({
|
|
|
5508
5898
|
detail: "page.pause() and test.only() are exact Playwright runner tokens; the detector matches the member-call identifiers on the code-only text — closed token set, unique to the defect"
|
|
5509
5899
|
},
|
|
5510
5900
|
introduced: "0.1.0",
|
|
5511
|
-
tier: "
|
|
5901
|
+
tier: "extended",
|
|
5512
5902
|
run(ctx) {
|
|
5513
5903
|
const text = ctx.codeText ?? ctx.text;
|
|
5514
5904
|
const findings = [];
|
|
@@ -7334,7 +7724,7 @@ const pyHardSleep = defineRule({
|
|
|
7334
7724
|
*/
|
|
7335
7725
|
const pySkippedTest = defineRule({
|
|
7336
7726
|
id: "QA-PY-002",
|
|
7337
|
-
tier: "
|
|
7727
|
+
tier: "extended",
|
|
7338
7728
|
category: "QA-TEST",
|
|
7339
7729
|
title: "Skipped test",
|
|
7340
7730
|
severity: "warning",
|
|
@@ -7470,7 +7860,7 @@ const pyFocusedTest = defineRule({
|
|
|
7470
7860
|
detail: "pytest.skip/xfail/parametrize marks are runner decorators and module-level calls; the detector matches those exact tokens on the code-only text — the semantics are runner skip state"
|
|
7471
7861
|
},
|
|
7472
7862
|
introduced: "0.3.0",
|
|
7473
|
-
tier: "
|
|
7863
|
+
tier: "extended",
|
|
7474
7864
|
suiteInvalidating: true,
|
|
7475
7865
|
run(ctx) {
|
|
7476
7866
|
const text = ctx.text;
|
|
@@ -7714,26 +8104,65 @@ function walk(node, visit) {
|
|
|
7714
8104
|
for (const child of node.children) walk(child, visit);
|
|
7715
8105
|
}
|
|
7716
8106
|
/**
|
|
8107
|
+
* The `pytest.raises` call that is a DIRECT context manager of `withNode`.
|
|
8108
|
+
*
|
|
8109
|
+
* Scopes the search to the with's own item list — `with_clause` for the
|
|
8110
|
+
* plain form, and `parenthesized_with_items` for `with (A, B):` — rather
|
|
8111
|
+
* than to the whole subtree. A call found deeper down belongs to an inner
|
|
8112
|
+
* `with_statement`, which the walk reaches in its own right and which owns
|
|
8113
|
+
* the block that actually gates it.
|
|
8114
|
+
*/
|
|
8115
|
+
function directRaisesItem(withNode) {
|
|
8116
|
+
let found;
|
|
8117
|
+
const scan = (n) => {
|
|
8118
|
+
if (found) return;
|
|
8119
|
+
if (n.type === "call") {
|
|
8120
|
+
const fn = n.childForFieldName("function");
|
|
8121
|
+
if (fn && /(?:^|\.)raises$/.test(fn.text)) {
|
|
8122
|
+
found = n;
|
|
8123
|
+
return;
|
|
8124
|
+
}
|
|
8125
|
+
}
|
|
8126
|
+
for (const child of n.children) scan(child);
|
|
8127
|
+
};
|
|
8128
|
+
for (const child of withNode.children) if (child.type === "with_clause" || child.type === "parenthesized_with_items") scan(child);
|
|
8129
|
+
return found;
|
|
8130
|
+
}
|
|
8131
|
+
/**
|
|
7717
8132
|
* Every `with pytest.raises(...)` (optionally `... as exc`) statement in
|
|
7718
8133
|
* the tree, with the structural facts the rework needs. Tree-sitter-
|
|
7719
8134
|
* python grammar: a `with_statement` wraps `with_item`s and a `block`;
|
|
7720
8135
|
* the raises call is a `call` whose function is an attribute chain
|
|
7721
8136
|
* ending in `.raises`.
|
|
8137
|
+
*
|
|
8138
|
+
* 6.0. The raises call is now required to be a DIRECT context manager of the
|
|
8139
|
+
* `with_statement` being measured. It used to be found by walking the whole
|
|
8140
|
+
* subtree, so a `with pytest.raises(...)` nested inside another `with`
|
|
8141
|
+
* handed back the OUTER block's statement count — and `QA-PY-007`'s
|
|
8142
|
+
* single-statement gate, which exists precisely to suppress that shape,
|
|
8143
|
+
* could never fire for nested code.
|
|
8144
|
+
*
|
|
8145
|
+
* That is not a rare shape. In the pinned `pytest-dev/pytest` corpus, seven
|
|
8146
|
+
* of the eleven adjudicated findings were single-statement raises nested
|
|
8147
|
+
* inside `with saved_fd(1):`, and every one of them was counted as
|
|
8148
|
+
* multi-statement because the surrounding block has a dozen statements. The
|
|
8149
|
+
* rule measured the wrong block, which is why it measured 75% false
|
|
8150
|
+
* positives while its own unit fixtures passed.
|
|
8151
|
+
*
|
|
8152
|
+
* Requiring a direct context manager also fixes the shape by construction:
|
|
8153
|
+
* the outer `with` is examined, has no direct raises item, and is skipped;
|
|
8154
|
+
* the inner one is examined with its own block.
|
|
7722
8155
|
*/
|
|
7723
8156
|
function pythonWithRaisesBlocks(tree) {
|
|
7724
8157
|
const out = [];
|
|
7725
8158
|
const root = tree.rootNode;
|
|
7726
8159
|
walk(root, (node) => {
|
|
7727
8160
|
if (node.type !== "with_statement") return;
|
|
7728
|
-
let raisesCall;
|
|
7729
8161
|
let blockNode;
|
|
7730
8162
|
for (const child of node.children) if (child.type === "block") blockNode = child;
|
|
7731
|
-
|
|
7732
|
-
|
|
7733
|
-
|
|
7734
|
-
if (fn && /(?:^|\.)raises$/.test(fn.text)) raisesCall = n;
|
|
7735
|
-
});
|
|
7736
|
-
if (!raisesCall || !blockNode) return;
|
|
8163
|
+
if (!blockNode) return;
|
|
8164
|
+
const raisesCall = directRaisesItem(node);
|
|
8165
|
+
if (!raisesCall) return;
|
|
7737
8166
|
const stmts = blockNode.namedChildren.filter((c) => c.type !== "comment");
|
|
7738
8167
|
const exceptionText = (raisesCall.childForFieldName("arguments")?.namedChildren[0])?.text ?? "";
|
|
7739
8168
|
const broadException = BROAD_EXC_RE.test(exceptionText);
|
|
@@ -7779,7 +8208,7 @@ const pyRaisesWithoutMatch = defineRule({
|
|
|
7779
8208
|
},
|
|
7780
8209
|
introduced: "0.3.0",
|
|
7781
8210
|
tier: "quarantine",
|
|
7782
|
-
detectorRevision:
|
|
8211
|
+
detectorRevision: 5,
|
|
7783
8212
|
run(ctx) {
|
|
7784
8213
|
const findings = [];
|
|
7785
8214
|
if (!ctx.path.endsWith(".py")) return findings;
|
|
@@ -7838,16 +8267,17 @@ function matchParen$1(text, open) {
|
|
|
7838
8267
|
return -1;
|
|
7839
8268
|
}
|
|
7840
8269
|
/**
|
|
7841
|
-
* AST arm (detectorRevision
|
|
7842
|
-
* parsed `with pytest.raises(...)` statements
|
|
7843
|
-
*
|
|
7844
|
-
*
|
|
7845
|
-
*
|
|
7846
|
-
*
|
|
7847
|
-
*
|
|
7848
|
-
*
|
|
7849
|
-
*
|
|
7850
|
-
*
|
|
8270
|
+
* AST arm (detectorRevision 5 — P6 rework, widened on adjudicated evidence):
|
|
8271
|
+
* candidates come from the parsed `with pytest.raises(...)` statements, and
|
|
8272
|
+
* the raises call must be a DIRECT context manager of the with whose block
|
|
8273
|
+
* is measured (see `pythonWithRaisesBlocks` for why).
|
|
8274
|
+
*
|
|
8275
|
+
* The gate: a block with fewer than two statements is exempt. That covers
|
|
8276
|
+
* the adjudicated FP core — pytest-dev/pytest nests its capture tests inside
|
|
8277
|
+
* `with saved_fd(1):`, and every one of those was scored as multi-statement
|
|
8278
|
+
* until the block resolution was fixed. A broad root type (Exception /
|
|
8279
|
+
* BaseException / …Group) still fires on a MULTI-statement block, where an
|
|
8280
|
+
* earlier line can raise it before the intended one.
|
|
7851
8281
|
*/
|
|
7852
8282
|
function astArm(ctx, tree) {
|
|
7853
8283
|
const raw = ctx.text;
|
|
@@ -7861,7 +8291,7 @@ function astArm(ctx, tree) {
|
|
|
7861
8291
|
const tail = raw.slice(block.callEnd, block.callEnd + 2400);
|
|
7862
8292
|
if (new RegExp(`\\b${name}\\b`).test(tail)) continue;
|
|
7863
8293
|
}
|
|
7864
|
-
if (block.blockStatements < 2
|
|
8294
|
+
if (block.blockStatements < 2) continue;
|
|
7865
8295
|
findings.push({
|
|
7866
8296
|
severity: "warning",
|
|
7867
8297
|
confidence: "medium",
|
|
@@ -7904,7 +8334,7 @@ const pyCommentedOutTest = defineRule({
|
|
|
7904
8334
|
},
|
|
7905
8335
|
detectionNotes: "regex heuristic",
|
|
7906
8336
|
introduced: "0.3.0",
|
|
7907
|
-
tier: "
|
|
8337
|
+
tier: "extended",
|
|
7908
8338
|
run(ctx) {
|
|
7909
8339
|
const findings = [];
|
|
7910
8340
|
if (!ctx.path.endsWith(".py")) return findings;
|
|
@@ -7953,13 +8383,18 @@ const pyBareTruthinessAssert = defineRule({
|
|
|
7953
8383
|
},
|
|
7954
8384
|
introduced: "0.3.0",
|
|
7955
8385
|
tier: "quarantine",
|
|
7956
|
-
detectorRevision:
|
|
8386
|
+
detectorRevision: 4,
|
|
7957
8387
|
run(ctx) {
|
|
7958
8388
|
const text = ctx.codeText ?? ctx.text;
|
|
7959
8389
|
const findings = [];
|
|
7960
8390
|
if (!ctx.path.endsWith(".py")) return findings;
|
|
7961
8391
|
const re = /^[ \t]*assert\s+([A-Za-z_][\w.]*(?:\([^()]*\))?)[ \t]*$/gm;
|
|
7962
|
-
|
|
8392
|
+
/**
|
|
8393
|
+
* Constants. `assert False` / `assert None` are deliberate mechanisms —
|
|
8394
|
+
* a test that manufactures an exception to assert on its shape — not a
|
|
8395
|
+
* truthiness check whose wrong value would slip through.
|
|
8396
|
+
*/
|
|
8397
|
+
const isConstant = (target) => /^(?:True|False|None|NotImplemented|Ellipsis)$/.test(target) || /^["'\d]/.test(target);
|
|
7963
8398
|
const isGuardFollowedByRealUse = (text, matchIndex, target) => {
|
|
7964
8399
|
const lineEnd = text.indexOf("\n", matchIndex);
|
|
7965
8400
|
if (lineEnd === -1) return false;
|
|
@@ -7979,8 +8414,9 @@ const pyBareTruthinessAssert = defineRule({
|
|
|
7979
8414
|
let m;
|
|
7980
8415
|
while ((m = re.exec(text)) !== null) {
|
|
7981
8416
|
const target = m[1];
|
|
8417
|
+
if (target.includes("(")) continue;
|
|
8418
|
+
if (isConstant(target)) continue;
|
|
7982
8419
|
if (/^(?:is|has|can|should|was|were)_/.test(target)) continue;
|
|
7983
|
-
if (predicateRe.test(target)) continue;
|
|
7984
8420
|
if (/\.called$/.test(target)) continue;
|
|
7985
8421
|
if (isGuardFollowedByRealUse(text, m.index, target)) continue;
|
|
7986
8422
|
findings.push({
|
|
@@ -8026,7 +8462,7 @@ const pyMutableFixture = defineRule({
|
|
|
8026
8462
|
detail: "pytest fixture mutation is fixture-lifecycle semantics (autouse/scope keys plus mutation calls); the detector matches the runner's fixture decorator tokens plus the mutation shapes"
|
|
8027
8463
|
},
|
|
8028
8464
|
introduced: "0.3.0",
|
|
8029
|
-
tier: "
|
|
8465
|
+
tier: "extended",
|
|
8030
8466
|
run(ctx) {
|
|
8031
8467
|
const text = ctx.text;
|
|
8032
8468
|
const findings = [];
|
|
@@ -8060,7 +8496,7 @@ const pyMutableFixture = defineRule({
|
|
|
8060
8496
|
*/
|
|
8061
8497
|
const pwWaitForTimeout = defineRule({
|
|
8062
8498
|
id: "QA-PW-101",
|
|
8063
|
-
tier: "
|
|
8499
|
+
tier: "extended",
|
|
8064
8500
|
category: "QA-PW",
|
|
8065
8501
|
title: "Hard sleep via waitForTimeout",
|
|
8066
8502
|
severity: "error",
|
|
@@ -8181,7 +8617,7 @@ const pwDeepFrameLocator = defineRule({
|
|
|
8181
8617
|
detail: "frameLocator chaining depth is an exact Playwright token sequence; the detector matches the frameLocator call chains — the token sequence is closed and unique to the defect"
|
|
8182
8618
|
},
|
|
8183
8619
|
introduced: "0.3.0",
|
|
8184
|
-
tier: "
|
|
8620
|
+
tier: "extended",
|
|
8185
8621
|
run(ctx) {
|
|
8186
8622
|
const text = ctx.codeText ?? ctx.text;
|
|
8187
8623
|
const findings = [];
|
|
@@ -8290,7 +8726,7 @@ const pwConfigRetryAbuse = defineRule({
|
|
|
8290
8726
|
},
|
|
8291
8727
|
detectionNotes: "regex heuristic",
|
|
8292
8728
|
introduced: "0.3.0",
|
|
8293
|
-
tier: "
|
|
8729
|
+
tier: "extended",
|
|
8294
8730
|
run(ctx) {
|
|
8295
8731
|
const text = ctx.codeText ?? ctx.text;
|
|
8296
8732
|
const findings = [];
|
|
@@ -8308,7 +8744,7 @@ const pwConfigRetryAbuse = defineRule({
|
|
|
8308
8744
|
column: colAt(text, m.index),
|
|
8309
8745
|
message: `retries: ${m[1]} — a flaky test gets ${m[1]} chances to pass by luck.`,
|
|
8310
8746
|
why: "High retry counts convert real bugs into intermittent passes and inflate CI minutes; the suite reports green while hiding instability.",
|
|
8311
|
-
fix: "Keep retries <= 2 and route repeat offenders into
|
|
8747
|
+
fix: "Keep retries <= 2 and route repeat offenders into the evidence path (`explain --evidence`)."
|
|
8312
8748
|
});
|
|
8313
8749
|
const workersRe = /workers\s*:\s*0\b/g;
|
|
8314
8750
|
while ((m = workersRe.exec(text)) !== null) findings.push({
|
|
@@ -8465,7 +8901,7 @@ const pwTrialMisuse = defineRule({
|
|
|
8465
8901
|
detail: "the trial-click shape is an exact Playwright API token pair; the detector matches the call identifier on the code-only text — the token is closed and unique to the defect"
|
|
8466
8902
|
},
|
|
8467
8903
|
introduced: "0.3.0",
|
|
8468
|
-
tier: "
|
|
8904
|
+
tier: "extended",
|
|
8469
8905
|
run(ctx) {
|
|
8470
8906
|
const text = ctx.codeText ?? ctx.text;
|
|
8471
8907
|
const findings = [];
|
|
@@ -8670,7 +9106,7 @@ const qaPw140 = defineRule({
|
|
|
8670
9106
|
detail: "the detector matches a closed, exact runner API token on the code-only text; the token identifies the defect uniquely"
|
|
8671
9107
|
},
|
|
8672
9108
|
introduced: "0.3.0",
|
|
8673
|
-
tier: "
|
|
9109
|
+
tier: "extended",
|
|
8674
9110
|
run(ctx) {
|
|
8675
9111
|
const text = ctx.codeText ?? ctx.text;
|
|
8676
9112
|
const findings = [];
|
|
@@ -8885,7 +9321,7 @@ const pwRetryMaskingNoForensics = defineRule({
|
|
|
8885
9321
|
column: colAt(text, m.index),
|
|
8886
9322
|
message: `retries: ${m[1]} with no visible flake-triage loop.`,
|
|
8887
9323
|
why: "Retries convert intermittent failures into silent passes. Without a forensics/triage step consuming retry data, flaky tests pass forever and real regressions hide behind lucky reruns.",
|
|
8888
|
-
fix: "Keep retries <= 2 and feed retry outcomes into `
|
|
9324
|
+
fix: "Keep retries <= 2 and feed retry outcomes into the evidence path (`explain --evidence`), or add a reporter so flaky passes are reviewed."
|
|
8889
9325
|
});
|
|
8890
9326
|
}
|
|
8891
9327
|
return findings;
|
|
@@ -9280,7 +9716,7 @@ const pyPwWaitForTimeout = defineRule({
|
|
|
9280
9716
|
detail: "page.waitForTimeout is an exact Playwright token in Python tests; the detector matches the call identifier — closed token, same predicate the AST would encode"
|
|
9281
9717
|
},
|
|
9282
9718
|
introduced: "0.3.8",
|
|
9283
|
-
tier: "
|
|
9719
|
+
tier: "extended",
|
|
9284
9720
|
run(ctx) {
|
|
9285
9721
|
const text = ctx.codeText ?? ctx.text;
|
|
9286
9722
|
const findings = [];
|
|
@@ -10165,7 +10601,7 @@ function extractPythonModel(file) {
|
|
|
10165
10601
|
*/
|
|
10166
10602
|
const jvWaitForTimeout = defineRule({
|
|
10167
10603
|
id: "QA-JV-105",
|
|
10168
|
-
tier: "
|
|
10604
|
+
tier: "extended",
|
|
10169
10605
|
category: "QA-PW",
|
|
10170
10606
|
title: "waitForTimeout hard sleep",
|
|
10171
10607
|
severity: "warning",
|
|
@@ -10234,7 +10670,7 @@ const jvWaitForTimeout = defineRule({
|
|
|
10234
10670
|
*/
|
|
10235
10671
|
const csSkippedTest = defineRule({
|
|
10236
10672
|
id: "QA-CS-101",
|
|
10237
|
-
tier: "
|
|
10673
|
+
tier: "extended",
|
|
10238
10674
|
category: "QA-PW",
|
|
10239
10675
|
title: "Skipped test",
|
|
10240
10676
|
severity: "warning",
|
|
@@ -10429,7 +10865,7 @@ const csNoAssertions = defineRule({
|
|
|
10429
10865
|
detectionStrategy: "AST",
|
|
10430
10866
|
detectionNotes: "L2 tree-sitter test-method scoping + structural assertion oracle (Shouldly/Verify/Assert receivers, throwing waits); regex fallback when no parse",
|
|
10431
10867
|
introduced: "0.3.8",
|
|
10432
|
-
tier: "
|
|
10868
|
+
tier: "extended",
|
|
10433
10869
|
detectorRevision: 2,
|
|
10434
10870
|
astQuery: cs103AstQuery,
|
|
10435
10871
|
run(ctx) {
|
|
@@ -11155,7 +11591,7 @@ const hardSleepFamily = definePatternFamily({
|
|
|
11155
11591
|
"mstest",
|
|
11156
11592
|
"playwright"
|
|
11157
11593
|
],
|
|
11158
|
-
tier: "
|
|
11594
|
+
tier: "extended",
|
|
11159
11595
|
detectionStrategy: "AST",
|
|
11160
11596
|
detectionNotes: "L2 tree-sitter invocation scoping (route/expose/server delegates, infinite blocks, WhenAny races, runner payload fixtures); regex fallback when no parse",
|
|
11161
11597
|
detectorRevision: 2,
|
|
@@ -11478,7 +11914,7 @@ const retryMaskingFamily = [defineRule({
|
|
|
11478
11914
|
detail: "the retry-masking family's variants (QA-JV-109/QA-CS-109): retry masking is the runner's retry contract; the detector matches the runner's retry tokens on the code-only text"
|
|
11479
11915
|
},
|
|
11480
11916
|
introduced: "0.4.0",
|
|
11481
|
-
tier: "
|
|
11917
|
+
tier: "extended",
|
|
11482
11918
|
run(ctx) {
|
|
11483
11919
|
const text = ctx.codeText ?? ctx.text;
|
|
11484
11920
|
const findings = [];
|
|
@@ -11657,6 +12093,117 @@ function getRule(id) {
|
|
|
11657
12093
|
return RULES.find((r) => r.id === id);
|
|
11658
12094
|
}
|
|
11659
12095
|
//#endregion
|
|
12096
|
+
//#region src/lib/wilson.ts
|
|
12097
|
+
/**
|
|
12098
|
+
* 95% Wilson score interval on a proportion (plan §20.2).
|
|
12099
|
+
*
|
|
12100
|
+
* Lives in `src/` rather than `scripts/` because a measurement's interval is
|
|
12101
|
+
* now part of the ENGINE's tier decision (`src/rules/measurement.ts`), not
|
|
12102
|
+
* only a reporting nicety for the capability matrix. `scripts/lib/wilson.ts`
|
|
12103
|
+
* re-exports this module, so the number a gate prints and the number the
|
|
12104
|
+
* engine tiers on are the same number computed once. Two implementations
|
|
12105
|
+
* would be two sources of truth for the same figure, which is the failure
|
|
12106
|
+
* mode this whole release is about.
|
|
12107
|
+
*
|
|
12108
|
+
* The 4-dp rounding keeps generated artifacts byte-stable (see the capability
|
|
12109
|
+
* matrix generator's note on deterministic output).
|
|
12110
|
+
*/
|
|
12111
|
+
const Z95 = 1.959963984540054;
|
|
12112
|
+
/** 95% Wilson score interval on fp/n (fp successes of n trials), 4 dp. */
|
|
12113
|
+
function wilsonInterval(fp, n) {
|
|
12114
|
+
if (n <= 0) return {
|
|
12115
|
+
ciLow: 0,
|
|
12116
|
+
ciHigh: 1
|
|
12117
|
+
};
|
|
12118
|
+
const p = fp / n;
|
|
12119
|
+
const z2 = Z95 * Z95;
|
|
12120
|
+
const denom = 1 + z2 / n;
|
|
12121
|
+
const center = (p + z2 / (2 * n)) / denom;
|
|
12122
|
+
const half = Z95 / denom * Math.sqrt(p * (1 - p) / n + z2 / (4 * n * n));
|
|
12123
|
+
const round4 = (x) => Math.round(x * 1e4) / 1e4;
|
|
12124
|
+
return {
|
|
12125
|
+
ciLow: round4(Math.max(0, center - half)),
|
|
12126
|
+
ciHigh: round4(Math.min(1, center + half))
|
|
12127
|
+
};
|
|
12128
|
+
}
|
|
12129
|
+
/** The rule's declared detector implementation revision (§07). */
|
|
12130
|
+
function declaredDetectorRevision(rule) {
|
|
12131
|
+
return rule.detectorRevision ?? 1;
|
|
12132
|
+
}
|
|
12133
|
+
/**
|
|
12134
|
+
* A measurement exists AND was taken against the detector revision the
|
|
12135
|
+
* rule declares now. A revision mismatch (stale) does NOT count — §07:
|
|
12136
|
+
* stale → provisional → re-measure.
|
|
12137
|
+
*/
|
|
12138
|
+
function hasValidMeasurement(rule) {
|
|
12139
|
+
const m = MEASURED_FP[rule.id];
|
|
12140
|
+
return m !== void 0 && m.detectorRevision === declaredDetectorRevision(rule);
|
|
12141
|
+
}
|
|
12142
|
+
/**
|
|
12143
|
+
* The confidence interval of a rule's VALID measurement, or undefined when
|
|
12144
|
+
* it has none. A stale measurement returns undefined rather than its stale
|
|
12145
|
+
* interval: a number derived from an older detector is not evidence about
|
|
12146
|
+
* this one, and returning it would let the straddle test pass on stale data.
|
|
12147
|
+
*
|
|
12148
|
+
* The FP count is ROUNDED, and it is not an optimization. The shipped
|
|
12149
|
+
* measurement records a rate and a sample count, not an integer count of false
|
|
12150
|
+
* positives, so `fpRate * n` is generally fractional — 10.5% of 19 is 1.995.
|
|
12151
|
+
* `wilsonInterval` rounds internally, so passing the raw product gave this
|
|
12152
|
+
* function and `scripts/core-readiness.ts` two different `ciHigh` values for
|
|
12153
|
+
* the same rule at the same n: the ratchet decided one tier and the
|
|
12154
|
+
* readiness table printed another, both derived, neither reconciled. Every
|
|
12155
|
+
* caller now reconstructs the count the same way.
|
|
12156
|
+
*/
|
|
12157
|
+
function measurementInterval(rule) {
|
|
12158
|
+
if (!hasValidMeasurement(rule)) return void 0;
|
|
12159
|
+
const m = MEASURED_FP[rule.id];
|
|
12160
|
+
if (m === void 0) return void 0;
|
|
12161
|
+
return wilsonInterval(Math.round(m.fpRate * m.n), m.n);
|
|
12162
|
+
}
|
|
12163
|
+
/**
|
|
12164
|
+
* The tier the MEASUREMENT alone supports, ignoring any declared tier.
|
|
12165
|
+
*
|
|
12166
|
+
* This is the interval floor that `effectiveTier` falls back to, extracted so
|
|
12167
|
+
* the tightening law can ask the question `effectiveTier` structurally cannot:
|
|
12168
|
+
* a declared tier may only ever hold a rule DOWN, never release it.
|
|
12169
|
+
*
|
|
12170
|
+
* `effectiveTier` cannot answer that, because it short-circuits on
|
|
12171
|
+
* `rule.tier` — asking it what a rule's tier "would" be returns the declared
|
|
12172
|
+
* value, and a comparison built on it compares the declaration with itself.
|
|
12173
|
+
* The first B0 dry-run made exactly that mistake and reported zero
|
|
12174
|
+
* mismatches for a comparison that had never run.
|
|
12175
|
+
*
|
|
12176
|
+
* READ THIS BEFORE USING IT AS A FLOOR. Measured over all 79 live rules, this
|
|
12177
|
+
* function returns `extended` for 78 of them and `quarantine` for none: on
|
|
12178
|
+
* this corpus the interval rule is very nearly an empty rule, because
|
|
12179
|
+
* `ciLow >= 50%` is not reached at n = 10..80. A "declared tier may only
|
|
12180
|
+
* tighten" law written against THIS function is decorative — it can only ever
|
|
12181
|
+
* catch a rule declaring `core`, which no rule declares. It was written that
|
|
12182
|
+
* way first and measured at zero teeth.
|
|
12183
|
+
*
|
|
12184
|
+
* The floor with teeth is `defensibleTier`, below.
|
|
12185
|
+
*/
|
|
12186
|
+
function measurementTier(rule) {
|
|
12187
|
+
const interval = measurementInterval(rule);
|
|
12188
|
+
if (interval === void 0) return "extended";
|
|
12189
|
+
if (interval.ciHigh <= .1) return "core";
|
|
12190
|
+
if (interval.ciLow >= .5) return "quarantine";
|
|
12191
|
+
return "extended";
|
|
12192
|
+
}
|
|
12193
|
+
/**
|
|
12194
|
+
* The tier a rule effectively ships as: its declared tier, or — for the
|
|
12195
|
+
* omitted-tier case — what its measurement's interval supports (§11.2 Step 2).
|
|
12196
|
+
*
|
|
12197
|
+
* The declared tier is a floor that may only tighten, and the measurement is
|
|
12198
|
+
* what it is measured against. See `rule.ts`'s `tier` doc for the B0
|
|
12199
|
+
* measurement behind that, and `defensibleTier` for the floor it is measured
|
|
12200
|
+
* against.
|
|
12201
|
+
*/
|
|
12202
|
+
function effectiveTier(rule) {
|
|
12203
|
+
if (rule.tier !== void 0) return rule.tier;
|
|
12204
|
+
return measurementTier(rule);
|
|
12205
|
+
}
|
|
12206
|
+
//#endregion
|
|
11660
12207
|
//#region src/scorer/scorer.ts
|
|
11661
12208
|
/**
|
|
11662
12209
|
* Transparent scorer (Product-MVP §8).
|
|
@@ -11713,7 +12260,7 @@ function computeDimensions(findings) {
|
|
|
11713
12260
|
deductions.set(f.category, deductions.get(f.category) + deductionFor(f));
|
|
11714
12261
|
}
|
|
11715
12262
|
for (const dim of byCategory.values()) dim.score = Math.max(0, 100 - deductions.get(dim.category));
|
|
11716
|
-
return [...byCategory.values()].sort((a, b) => a.category
|
|
12263
|
+
return [...byCategory.values()].sort((a, b) => compareCodePoints(a.category, b.category));
|
|
11717
12264
|
}
|
|
11718
12265
|
/**
|
|
11719
12266
|
* Deduction-mass ceilings (product-gap-remediation master plan P2,
|
|
@@ -11862,26 +12409,105 @@ function asUniversal(rule) {
|
|
|
11862
12409
|
}
|
|
11863
12410
|
//#endregion
|
|
11864
12411
|
//#region src/engine/tier-policy.ts
|
|
12412
|
+
/**
|
|
12413
|
+
* Tier policy (audit H-1): the declared tier is authoritative over a
|
|
12414
|
+
* rule's severity and evidence claims.
|
|
12415
|
+
*
|
|
12416
|
+
* Quarantine-tier rules exist because their real-world false-positive
|
|
12417
|
+
* rate is unproven or proven bad (docs/FP-AUDIT.md records several at
|
|
12418
|
+
* 100% measured FP). A rule known to be wrong every single time it
|
|
12419
|
+
* fires must never gate CI or deduct score — regardless of the
|
|
12420
|
+
* severity/evidenceLevel its metadata declares. The pipeline therefore
|
|
12421
|
+
* caps every quarantine finding to severity=info, evidence=E0, which
|
|
12422
|
+
* makes it advisory by construction (isAdvisoryFinding excludes E0).
|
|
12423
|
+
*
|
|
12424
|
+
* The core tier carries the opposite bound (ADR 0014). A CAP says a tier
|
|
12425
|
+
* may not be stronger than this; a FLOOR says it may not be weaker. Core
|
|
12426
|
+
* is earned — it means the measurement cleared the false-positive
|
|
12427
|
+
* ceiling with enough samples to trust the interval — so a core rule
|
|
12428
|
+
* emitting a finding that costs nothing and blocks nothing is claiming a
|
|
12429
|
+
* promotion it has not cashed in. Before the floor existed, core and
|
|
12430
|
+
* extended were enforced identically, and reaching core changed only the
|
|
12431
|
+
* dedup tie-break, the M3 ceiling and the census readout: a consumer
|
|
12432
|
+
* could not tell them apart.
|
|
12433
|
+
*/
|
|
11865
12434
|
/** Cap applied to every finding of a quarantine-tier rule (H-1). */
|
|
11866
12435
|
const QUARANTINE_CAP = {
|
|
11867
12436
|
severity: "info",
|
|
11868
12437
|
evidenceLevel: "E0"
|
|
11869
12438
|
};
|
|
11870
|
-
/**
|
|
11871
|
-
|
|
11872
|
-
|
|
11873
|
-
|
|
12439
|
+
/**
|
|
12440
|
+
* Floor applied to every finding of a core-tier rule (ADR 0014).
|
|
12441
|
+
*
|
|
12442
|
+
* `E1`, not `E2`, and the reason is the difference between the two.
|
|
12443
|
+
* `E2` would be a claim that the finding is proven, which is a statement
|
|
12444
|
+
* about the code that no tier policy may make — corroboration does that,
|
|
12445
|
+
* at runtime, per finding. `E1` is the weaker and sufficient claim: a
|
|
12446
|
+
* core rule is measured, so its finding is pattern evidence rather than
|
|
12447
|
+
* an observation, and it is worth half the deduction.
|
|
12448
|
+
*
|
|
12449
|
+
* `E0` is what the floor exists to forbid. `isAdvisoryFinding` is
|
|
12450
|
+
* `level === "E0"`, so an E0 finding is simultaneously worth zero points
|
|
12451
|
+
* and unable to fail a gate. A rule whose false-positive rate is
|
|
12452
|
+
* measured under the 10% ceiling should never be able to produce one.
|
|
12453
|
+
*/
|
|
12454
|
+
const CORE_FLOOR = { evidenceLevel: "E1" };
|
|
12455
|
+
/**
|
|
12456
|
+
* The policy for a tier.
|
|
12457
|
+
*
|
|
12458
|
+
* `undefined` returns an EMPTY policy, and this is a decision, not a
|
|
12459
|
+
* convenience. `buildUniversalRules` in `scan-pipeline.ts` populates
|
|
12460
|
+
* `tierByRuleId` with `effectiveTier(rule)` for every registry rule — never
|
|
12461
|
+
* undefined — and adds a plugin or workspace-local rule only when that rule
|
|
12462
|
+
* DECLARES a tier. So an absent entry means "a third-party rule that claimed no
|
|
12463
|
+
* tier", and it is the one population that must not inherit a trust bound.
|
|
12464
|
+
*
|
|
12465
|
+
* It used to read "undefined tier = core" in this docstring, and that sentence
|
|
12466
|
+
* was load-bearing in the wrong direction. Before the floor, an absent entry
|
|
12467
|
+
* cost nothing, so the claim was inert. With a core floor, treating undefined as
|
|
12468
|
+
* core would raise every plugin's E0 finding to E1 and start charging
|
|
12469
|
+
* third-party detections for deductions — the exact trust escalation TI-013
|
|
12470
|
+
* exists to prevent, introduced by the ADR written to close a trust gap. A
|
|
12471
|
+
* plugin that wants the core floor has to declare `tier: "core"` and be
|
|
12472
|
+
* reviewed for it.
|
|
12473
|
+
*/
|
|
12474
|
+
function policyForTier(tier) {
|
|
12475
|
+
if (tier === "quarantine") return { cap: QUARANTINE_CAP };
|
|
12476
|
+
if (tier === "core") return { floor: CORE_FLOOR };
|
|
12477
|
+
return {};
|
|
12478
|
+
}
|
|
12479
|
+
/**
|
|
12480
|
+
* E-ladder order. "E2" > "E1" > "E0" is a claim about how much evidence a
|
|
12481
|
+
* finding carries, and the floor is only meaningful if "at least E1" can be
|
|
12482
|
+
* stated. Declared as a ladder rather than compared as strings because
|
|
12483
|
+
* `"E0" > "E1"` is true in JavaScript and would invert the floor.
|
|
12484
|
+
*/
|
|
12485
|
+
const EVIDENCE_RANK$1 = {
|
|
12486
|
+
E0: 0,
|
|
12487
|
+
E1: 1,
|
|
12488
|
+
E2: 2
|
|
12489
|
+
};
|
|
11874
12490
|
/**
|
|
11875
12491
|
* In-place enforcement over a scan's findings. Idempotent; runs AFTER
|
|
11876
|
-
* evidence stamping so it wins over rule-declared evidenceLevel
|
|
12492
|
+
* evidence stamping so it wins over rule-declared evidenceLevel — for the
|
|
12493
|
+
* cap and the floor alike. A rule's own `evidenceLevel` in `RuleMeta` is a
|
|
12494
|
+
* declaration about the rule; the tier is a decision about the rule, and
|
|
12495
|
+
* the decision outranks the declaration.
|
|
12496
|
+
*
|
|
12497
|
+
* Idempotence: cap-then-floor is a fixed point. A finding already at the
|
|
12498
|
+
* cap stays at the cap, and one already at or above the floor stays put,
|
|
12499
|
+
* so running this twice cannot walk a finding up the ladder.
|
|
11877
12500
|
*/
|
|
11878
12501
|
function enforceTierPolicy(findings, tierByRuleId) {
|
|
11879
12502
|
for (const f of findings) {
|
|
11880
|
-
const cap =
|
|
12503
|
+
const { cap, floor } = policyForTier(tierByRuleId.get(f.ruleId));
|
|
11881
12504
|
if (cap) {
|
|
11882
12505
|
f.severity = cap.severity;
|
|
11883
12506
|
f.evidenceLevel = cap.evidenceLevel;
|
|
11884
12507
|
}
|
|
12508
|
+
if (floor?.evidenceLevel === void 0) continue;
|
|
12509
|
+
const effective = f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence);
|
|
12510
|
+
if (EVIDENCE_RANK$1[effective] < EVIDENCE_RANK$1[floor.evidenceLevel]) f.evidenceLevel = floor.evidenceLevel;
|
|
11885
12511
|
}
|
|
11886
12512
|
}
|
|
11887
12513
|
//#endregion
|
|
@@ -12070,8 +12696,8 @@ function isDeclaredByPresent(f, present, metaByRuleId) {
|
|
|
12070
12696
|
function ranksBefore(a, b, metaByRuleId) {
|
|
12071
12697
|
const ma = metaByRuleId.get(a.ruleId);
|
|
12072
12698
|
const mb = metaByRuleId.get(b.ruleId);
|
|
12073
|
-
const tierA = TIER_RANK[ma?.tier ?? "
|
|
12074
|
-
const tierB = TIER_RANK[mb?.tier ?? "
|
|
12699
|
+
const tierA = TIER_RANK[ma?.tier ?? "extended"];
|
|
12700
|
+
const tierB = TIER_RANK[mb?.tier ?? "extended"];
|
|
12075
12701
|
if (tierA !== tierB) return tierA < tierB;
|
|
12076
12702
|
const sevA = SEVERITY_RANK[a.severity];
|
|
12077
12703
|
const sevB = SEVERITY_RANK[b.severity];
|
|
@@ -12091,6 +12717,16 @@ function dropOrderBefore(a, b, metaByRuleId) {
|
|
|
12091
12717
|
return (a.column ?? Number.MAX_SAFE_INTEGER) < (b.column ?? Number.MAX_SAFE_INTEGER);
|
|
12092
12718
|
}
|
|
12093
12719
|
//#endregion
|
|
12720
|
+
//#region src/engine/finding-identity.ts
|
|
12721
|
+
/**
|
|
12722
|
+
* Correlation identity for before/after comparison: ruleId + file +
|
|
12723
|
+
* message, deliberately EXCLUDING line — a source edit that shifts a
|
|
12724
|
+
* finding still correlates. Shared by baseline, impact, and resolution.
|
|
12725
|
+
*/
|
|
12726
|
+
function findingFingerprint(f) {
|
|
12727
|
+
return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
|
|
12728
|
+
}
|
|
12729
|
+
//#endregion
|
|
12094
12730
|
//#region src/engine/correlation-engine.ts
|
|
12095
12731
|
const EVIDENCE_RANK = {
|
|
12096
12732
|
E0: 0,
|
|
@@ -12135,13 +12771,31 @@ function groupByFile(findings) {
|
|
|
12135
12771
|
return groups;
|
|
12136
12772
|
}
|
|
12137
12773
|
/**
|
|
12138
|
-
* Group findings by
|
|
12139
|
-
*
|
|
12774
|
+
* Group findings by root cause, falling back to the FINDING FINGERPRINT.
|
|
12775
|
+
*
|
|
12776
|
+
* The fallback used to be `ruleId`, and that is the defect this fixes. Nothing
|
|
12777
|
+
* in the tree assigns `rootCauseId` — the machine contract reports it `null` on
|
|
12778
|
+
* every finding — so `ruleId` was the key for every group, and N independent
|
|
12779
|
+
* defects from one rule across N files were reported as
|
|
12780
|
+
*
|
|
12781
|
+
* CONVERGENT — N findings share root cause QA-PW-002
|
|
12782
|
+
*
|
|
12783
|
+
* which is the precise inverse of what convergence means. Convergence is the
|
|
12784
|
+
* claim that several findings are ONE thing; the key that produced it said
|
|
12785
|
+
* only "these are the same rule". A fingerprint is ruleId + file + message, so
|
|
12786
|
+
* two `QA-PW-002` findings in different files are two keys, two groups of one,
|
|
12787
|
+
* and neither is reported.
|
|
12788
|
+
*
|
|
12789
|
+
* This is also why `findingId`/`rootCauseId`/`deduplicationGroup` are not
|
|
12790
|
+
* wired instead: they are the same idea, and an unwired field plus a fallback
|
|
12791
|
+
* would be two mechanisms for one claim. When a rule declares a real root
|
|
12792
|
+
* cause, `rootCauseId` still wins — the field is honoured the moment anything
|
|
12793
|
+
* populates it.
|
|
12140
12794
|
*/
|
|
12141
12795
|
function groupByRootCause(findings) {
|
|
12142
12796
|
const groups = /* @__PURE__ */ new Map();
|
|
12143
12797
|
for (const f of findings) {
|
|
12144
|
-
const key = f.rootCauseId ?? f
|
|
12798
|
+
const key = f.rootCauseId ?? findingFingerprint(f);
|
|
12145
12799
|
const list = groups.get(key) ?? [];
|
|
12146
12800
|
list.push(f);
|
|
12147
12801
|
groups.set(key, list);
|
|
@@ -12149,15 +12803,26 @@ function groupByRootCause(findings) {
|
|
|
12149
12803
|
return groups;
|
|
12150
12804
|
}
|
|
12151
12805
|
/**
|
|
12806
|
+
* The identity a conclusion lists a finding under.
|
|
12807
|
+
*
|
|
12808
|
+
* `findingId` when something has populated it, else the fingerprint. Never
|
|
12809
|
+
* `ruleId`: the fallback used to collapse every finding from one rule onto one
|
|
12810
|
+
* id, so a three-finding CONVERGENT group listed the same id three times and
|
|
12811
|
+
* the `alreadyConvergent` set below could not tell the members apart.
|
|
12812
|
+
*/
|
|
12813
|
+
function conclusionId(f) {
|
|
12814
|
+
return f.findingId ?? findingFingerprint(f);
|
|
12815
|
+
}
|
|
12816
|
+
/**
|
|
12152
12817
|
* Correlate findings and produce conclusions WITHOUT modifying them.
|
|
12153
12818
|
* Deterministic — same input always produces same output.
|
|
12154
12819
|
*/
|
|
12155
12820
|
function correlateFindings(findings) {
|
|
12156
12821
|
const conclusions = [];
|
|
12157
12822
|
const rootCauseGroups = groupByRootCause(findings);
|
|
12158
|
-
for (const [rootCause, group] of Array.from(rootCauseGroups.entries()).sort((a, b) => a[0]
|
|
12823
|
+
for (const [rootCause, group] of Array.from(rootCauseGroups.entries()).sort((a, b) => compareLocalized("en")(a[0], b[0]))) {
|
|
12159
12824
|
if (group.length < 2) continue;
|
|
12160
|
-
const findingIds = group.map(
|
|
12825
|
+
const findingIds = group.map(conclusionId);
|
|
12161
12826
|
conclusions.push({
|
|
12162
12827
|
conclusionType: "CONVERGENT",
|
|
12163
12828
|
certainty: correlationStrength(group),
|
|
@@ -12167,13 +12832,13 @@ function correlateFindings(findings) {
|
|
|
12167
12832
|
});
|
|
12168
12833
|
}
|
|
12169
12834
|
const fileGroups = groupByFile(findings);
|
|
12170
|
-
for (const [file, group] of Array.from(fileGroups.entries()).sort((a, b) => a[0]
|
|
12835
|
+
for (const [file, group] of Array.from(fileGroups.entries()).sort((a, b) => compareCodePoints(a[0], b[0]))) {
|
|
12171
12836
|
if (group.length < 2) continue;
|
|
12172
12837
|
const alreadyConvergent = /* @__PURE__ */ new Set();
|
|
12173
12838
|
for (const c of conclusions) if (c.conclusionType === "CONVERGENT") for (const id of c.findingIds) alreadyConvergent.add(id);
|
|
12174
|
-
const novel = group.filter((f) => !alreadyConvergent.has(f
|
|
12839
|
+
const novel = group.filter((f) => !alreadyConvergent.has(conclusionId(f)));
|
|
12175
12840
|
if (novel.length < 2) continue;
|
|
12176
|
-
const findingIds = novel.map(
|
|
12841
|
+
const findingIds = novel.map(conclusionId);
|
|
12177
12842
|
conclusions.push({
|
|
12178
12843
|
conclusionType: "AMPLIFIED",
|
|
12179
12844
|
certainty: correlationStrength(novel),
|
|
@@ -12188,7 +12853,7 @@ function correlateFindings(findings) {
|
|
|
12188
12853
|
certainty: "STRONG",
|
|
12189
12854
|
corroboration: `${corroborated.length} finding(s) with runtime corroboration`,
|
|
12190
12855
|
sourceCount: corroborated.length,
|
|
12191
|
-
findingIds: corroborated.map(
|
|
12856
|
+
findingIds: corroborated.map(conclusionId)
|
|
12192
12857
|
});
|
|
12193
12858
|
return conclusions;
|
|
12194
12859
|
}
|
|
@@ -12430,7 +13095,25 @@ const BRAND = {
|
|
|
12430
13095
|
auroraGreen: "#5FD6A4",
|
|
12431
13096
|
auroraViolet: "#9D8CF5",
|
|
12432
13097
|
steel: "#C8CBCF",
|
|
12433
|
-
steelDim: "#8B939D"
|
|
13098
|
+
steelDim: "#8B939D",
|
|
13099
|
+
/** The teal half of the accent rule, and the gradient's near stop. */
|
|
13100
|
+
readingAccent: "#73C4CA",
|
|
13101
|
+
/** The violet half of the accent rule, and the gradient's far stop. */
|
|
13102
|
+
readingAccentAlt: "#A596E5",
|
|
13103
|
+
/** The focus ring. A non-text affordance, held to the same AA floor. */
|
|
13104
|
+
readingFocus: "#8CE3ED",
|
|
13105
|
+
/** Hover state for the mark's monochrome glyph. */
|
|
13106
|
+
readingAccentBright: "#85DBED",
|
|
13107
|
+
/** Hover state for the wordmark's ink. */
|
|
13108
|
+
readingAccentInk: "#E5FCFF",
|
|
13109
|
+
/** UNWORTHY at reading contrast — a softer red than the terminal's. */
|
|
13110
|
+
readingUnworthy: "#FF9790",
|
|
13111
|
+
/** Muted body ink on the reading surfaces. */
|
|
13112
|
+
readingMuted: "#C6CFDB",
|
|
13113
|
+
/** Dimmed ink on the reading surfaces. */
|
|
13114
|
+
readingDim: "#ACB9CA",
|
|
13115
|
+
/** The hero lede: the brightest ink below `TEXT.primary`. */
|
|
13116
|
+
readingLede: "#E0E6EE"
|
|
12434
13117
|
};
|
|
12435
13118
|
/**
|
|
12436
13119
|
* Midnight iron. One ramp, four steps, darkest first.
|
|
@@ -12533,7 +13216,7 @@ const TRUST = {
|
|
|
12533
13216
|
l5: "#5CC4E0"
|
|
12534
13217
|
};
|
|
12535
13218
|
SURFACE.ink950, SURFACE.ink900, SURFACE.ink850, SURFACE.ink800, BRAND.steel, BRAND.steelDim, BRAND.gold, BRAND.goldBright, BRAND.goldHot, BRAND.aurora, BRAND.auroraBright, BRAND.auroraCyan, BRAND.auroraGreen, BRAND.auroraViolet;
|
|
12536
|
-
SCORE.trusted, SCORE.forged, SCORE.warning, SCORE.critical, STATUS.info, TEXT.onGold, STATUS.ok, EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2, TRUST.l0, TRUST.l1, TRUST.l2, TRUST.l3, TRUST.l4, TRUST.l5;
|
|
13219
|
+
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, BRAND.readingAccent, BRAND.readingAccentAlt, BRAND.readingFocus, BRAND.readingAccentBright, BRAND.readingAccentInk, BRAND.readingUnworthy, BRAND.readingMuted, BRAND.readingDim, BRAND.readingLede;
|
|
12537
13220
|
EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2;
|
|
12538
13221
|
const RUNG_MEANINGS = [
|
|
12539
13222
|
"observation only",
|
|
@@ -12918,9 +13601,10 @@ function hashDir(dir, hash, depth) {
|
|
|
12918
13601
|
try {
|
|
12919
13602
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
12920
13603
|
} catch {
|
|
13604
|
+
recordDegradation("rules-tree-listing-unreadable");
|
|
12921
13605
|
return;
|
|
12922
13606
|
}
|
|
12923
|
-
entries.sort((a, b) => a.name
|
|
13607
|
+
entries.sort((a, b) => compareCodePoints(a.name, b.name));
|
|
12924
13608
|
for (const entry of entries) {
|
|
12925
13609
|
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
|
12926
13610
|
const full = join(dir, entry.name);
|
|
@@ -13422,7 +14106,7 @@ function renderFlakyMd(report) {
|
|
|
13422
14106
|
const lines = [];
|
|
13423
14107
|
lines.push("# FLAKY.md");
|
|
13424
14108
|
lines.push("");
|
|
13425
|
-
lines.push(`> Generated by
|
|
14109
|
+
lines.push(`> Generated by Mjölnir from ${report.source} run data.`);
|
|
13426
14110
|
lines.push("> A test that passes only on retry is not a passing test — it is a lucky test.");
|
|
13427
14111
|
lines.push("");
|
|
13428
14112
|
lines.push(`**${report.totalTests}** tests analyzed · **${report.flakyTests}** true flakes · **${report.retriedTests}** retried · **${report.failed}** failing`);
|
|
@@ -13770,19 +14454,19 @@ function parseJestJson(json) {
|
|
|
13770
14454
|
}
|
|
13771
14455
|
//#endregion
|
|
13772
14456
|
//#region src/forensics/parse-junit.ts
|
|
13773
|
-
const MAX_INPUT = 20971520;
|
|
13774
|
-
function decodeEntities(s) {
|
|
14457
|
+
const MAX_INPUT$1 = 20971520;
|
|
14458
|
+
function decodeEntities$1(s) {
|
|
13775
14459
|
return s.replaceAll("<", "<").replaceAll(">", ">").replaceAll(""", "\"").replaceAll("'", "'").replaceAll("'", "'").replaceAll("&", "&");
|
|
13776
14460
|
}
|
|
13777
|
-
function attr(tag, name) {
|
|
14461
|
+
function attr$1(tag, name) {
|
|
13778
14462
|
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
13779
14463
|
const m = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*"([^"]*)"`, "i").exec(tag);
|
|
13780
|
-
if (m?.[1] !== void 0) return decodeEntities(m[1]);
|
|
14464
|
+
if (m?.[1] !== void 0) return decodeEntities$1(m[1]);
|
|
13781
14465
|
const m2 = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*'([^']*)'`, "i").exec(tag);
|
|
13782
|
-
return m2?.[1] !== void 0 ? decodeEntities(m2[1]) : void 0;
|
|
14466
|
+
return m2?.[1] !== void 0 ? decodeEntities$1(m2[1]) : void 0;
|
|
13783
14467
|
}
|
|
13784
14468
|
function parseJunitXml(xml) {
|
|
13785
|
-
if (xml.length > MAX_INPUT) return [];
|
|
14469
|
+
if (xml.length > MAX_INPUT$1) return [];
|
|
13786
14470
|
const out = [];
|
|
13787
14471
|
const startRe = /<testcase\b/gi;
|
|
13788
14472
|
const closeRe = /<\/testcase\s*>/gi;
|
|
@@ -13803,9 +14487,9 @@ function parseJunitXml(xml) {
|
|
|
13803
14487
|
inner = xml.slice(gt + 1, close.index);
|
|
13804
14488
|
startRe.lastIndex = close.index + close[0].length;
|
|
13805
14489
|
}
|
|
13806
|
-
const name = attr(attrs, "name") ?? "(unnamed)";
|
|
13807
|
-
const classname = attr(attrs, "classname") ?? "";
|
|
13808
|
-
const timeRaw = attr(attrs, "time");
|
|
14490
|
+
const name = attr$1(attrs, "name") ?? "(unnamed)";
|
|
14491
|
+
const classname = attr$1(attrs, "classname") ?? "";
|
|
14492
|
+
const timeRaw = attr$1(attrs, "time");
|
|
13809
14493
|
const timeSec = timeRaw !== void 0 && Number.isFinite(Number(timeRaw)) ? Number(timeRaw) : 0;
|
|
13810
14494
|
const failed = /<(?:failure|error)\b/i.test(inner);
|
|
13811
14495
|
const skipped = /<skipped\b/i.test(inner);
|
|
@@ -13816,16 +14500,16 @@ function parseJunitXml(xml) {
|
|
|
13816
14500
|
while ((failMatch = failRe.exec(inner)) !== null) {
|
|
13817
14501
|
const attrsStr = failMatch[1] ?? "";
|
|
13818
14502
|
const msgAttr = /\smessage\s*=\s*"([^"]*)"/i.exec(attrsStr)?.[1];
|
|
13819
|
-
if (msgAttr) errors.push(sanitizeErrorText(decodeEntities(msgAttr)));
|
|
14503
|
+
if (msgAttr) errors.push(sanitizeErrorText(decodeEntities$1(msgAttr)));
|
|
13820
14504
|
const body = failMatch[2]?.trim();
|
|
13821
|
-
if (body && body !== msgAttr) errors.push(sanitizeErrorText(decodeEntities(body)));
|
|
14505
|
+
if (body && body !== msgAttr) errors.push(sanitizeErrorText(decodeEntities$1(body)));
|
|
13822
14506
|
}
|
|
13823
14507
|
const selfCloseRe = /<(?:failure|error)\b([^>]*)\/>/gi;
|
|
13824
14508
|
let scMatch;
|
|
13825
14509
|
while ((scMatch = selfCloseRe.exec(inner)) !== null) {
|
|
13826
14510
|
const scAttrs = scMatch[1] ?? "";
|
|
13827
14511
|
const scMsgMatch = /\smessage\s*=\s*"([^"]*)"/i.exec(scAttrs);
|
|
13828
|
-
if (scMsgMatch?.[1]) errors.push(sanitizeErrorText(decodeEntities(scMsgMatch[1])));
|
|
14512
|
+
if (scMsgMatch?.[1]) errors.push(sanitizeErrorText(decodeEntities$1(scMsgMatch[1])));
|
|
13829
14513
|
}
|
|
13830
14514
|
}
|
|
13831
14515
|
const attempt = {
|
|
@@ -13843,6 +14527,142 @@ function parseJunitXml(xml) {
|
|
|
13843
14527
|
return out;
|
|
13844
14528
|
}
|
|
13845
14529
|
//#endregion
|
|
14530
|
+
//#region src/forensics/parse-trx.ts
|
|
14531
|
+
const MAX_INPUT = 20971520;
|
|
14532
|
+
function decodeEntities(s) {
|
|
14533
|
+
return s.replaceAll("<", "<").replaceAll(">", ">").replaceAll(""", "\"").replaceAll("'", "'").replaceAll("'", "'").replaceAll("&", "&");
|
|
14534
|
+
}
|
|
14535
|
+
function attr(tag, name) {
|
|
14536
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
14537
|
+
const m = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*"([^"]*)"`, "i").exec(tag);
|
|
14538
|
+
if (m?.[1] !== void 0) return decodeEntities(m[1]);
|
|
14539
|
+
const sq = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*'([^']*)'`, "i").exec(tag);
|
|
14540
|
+
if (sq?.[1] !== void 0) return decodeEntities(sq[1]);
|
|
14541
|
+
}
|
|
14542
|
+
/**
|
|
14543
|
+
* Does this document look like TRX at all?
|
|
14544
|
+
*
|
|
14545
|
+
* Shape-sniffed rather than trusted from its location, for the same reason the
|
|
14546
|
+
* JUnit path sniffs: a `TestResults/` directory is a convention, and a
|
|
14547
|
+
* convention can be wrong. A file that is not a `TestRun` must not be parsed as
|
|
14548
|
+
* one and produce confident nonsense.
|
|
14549
|
+
*/
|
|
14550
|
+
function looksLikeTrxXml(text) {
|
|
14551
|
+
return /<TestRun[\s>]/i.test(text.slice(0, 4096));
|
|
14552
|
+
}
|
|
14553
|
+
/**
|
|
14554
|
+
* TRX durations are `HH:MM:SS.fffffff`; absent or `-` means "not timed".
|
|
14555
|
+
*
|
|
14556
|
+
* Parsed by splitting rather than by one pattern. A single regex for this has
|
|
14557
|
+
* to be `^(\d+):(\d{2}):(\d{2})(?:\.(\d+))?$`, and the nested quantifiers there
|
|
14558
|
+
* are what `security/detect-unsafe-regex` flags — correctly in general, since
|
|
14559
|
+
* this shape is the usual ReDoS source. Splitting removes the question rather
|
|
14560
|
+
* than asserting the pattern is safe today.
|
|
14561
|
+
*/
|
|
14562
|
+
function parseDuration(raw) {
|
|
14563
|
+
if (raw === void 0) return 0;
|
|
14564
|
+
const text = raw.trim();
|
|
14565
|
+
const dot = text.indexOf(".");
|
|
14566
|
+
const clock = dot === -1 ? text : text.slice(0, dot);
|
|
14567
|
+
const fraction = dot === -1 ? "" : text.slice(dot + 1);
|
|
14568
|
+
const parts = clock.split(":");
|
|
14569
|
+
if (parts.length !== 3) return 0;
|
|
14570
|
+
const [h, m, s] = parts;
|
|
14571
|
+
if (h === void 0 || m === void 0 || s === void 0) return 0;
|
|
14572
|
+
if (!/^\d+$/.test(h) || !/^\d{1,2}$/.test(m) || !/^\d{1,2}$/.test(s)) return 0;
|
|
14573
|
+
const millis = fraction.length === 0 ? 0 : Number(fraction.slice(0, 3).padEnd(3, "0"));
|
|
14574
|
+
return Number(h) * 36e5 + Number(m) * 6e4 + Number(s) * 1e3 + millis;
|
|
14575
|
+
}
|
|
14576
|
+
/**
|
|
14577
|
+
* TRX `outcome` to a run status.
|
|
14578
|
+
*
|
|
14579
|
+
* The mapping is stated rather than inferred from a default, because the
|
|
14580
|
+
* dangerous direction here is a silent default: an outcome this engine has
|
|
14581
|
+
* never seen must not read as `passed`. Every unrecognised value maps to
|
|
14582
|
+
* `skipped`, which is the non-claiming direction — a skipped test contributes
|
|
14583
|
+
* nothing to "the suite passed" — rather than to a status that would let it.
|
|
14584
|
+
*
|
|
14585
|
+
* TRX also has outcomes that are not verdicts at all (`Warning`, `Pending`,
|
|
14586
|
+
* `Expired`). They are treated the same way, deliberately.
|
|
14587
|
+
*/
|
|
14588
|
+
function mapTrxOutcome(outcome) {
|
|
14589
|
+
switch ((outcome ?? "").toLowerCase()) {
|
|
14590
|
+
case "passed":
|
|
14591
|
+
case "completed": return "passed";
|
|
14592
|
+
case "failed":
|
|
14593
|
+
case "error": return "failed";
|
|
14594
|
+
case "timeout": return "timedOut";
|
|
14595
|
+
case "aborted":
|
|
14596
|
+
case "interrupted": return "interrupted";
|
|
14597
|
+
case "notexecuted":
|
|
14598
|
+
case "skipped":
|
|
14599
|
+
case "inconclusive":
|
|
14600
|
+
case "notrunnable":
|
|
14601
|
+
case "pending": return "skipped";
|
|
14602
|
+
default: return "skipped";
|
|
14603
|
+
}
|
|
14604
|
+
}
|
|
14605
|
+
function tagOf(xml, name) {
|
|
14606
|
+
const out = [];
|
|
14607
|
+
const re = new RegExp(`<${name}\\b[^>]*>`, "gi");
|
|
14608
|
+
let m;
|
|
14609
|
+
while ((m = re.exec(xml)) !== null) {
|
|
14610
|
+
if (m[0] !== void 0) out.push(m[0]);
|
|
14611
|
+
if (out.length > 2e5) break;
|
|
14612
|
+
}
|
|
14613
|
+
return out;
|
|
14614
|
+
}
|
|
14615
|
+
/** The text of a `<Message>` child, used as the failure's error text. */
|
|
14616
|
+
function messageOf(text, near) {
|
|
14617
|
+
const window = text.slice(near, near + 2e3);
|
|
14618
|
+
const m = /<Message[^>]*>([\s\S]{0,1500}?)<\/Message>/i.exec(window);
|
|
14619
|
+
if (m?.[1] === void 0) return void 0;
|
|
14620
|
+
const cleaned = sanitizeErrorText(decodeEntities(m[1].replace(/<[^>]*>/g, " ")).trim());
|
|
14621
|
+
return cleaned.length === 0 ? void 0 : cleaned;
|
|
14622
|
+
}
|
|
14623
|
+
/**
|
|
14624
|
+
* Parse a TRX document into test records.
|
|
14625
|
+
*
|
|
14626
|
+
* Returns an empty array for a document that is not TRX rather than throwing:
|
|
14627
|
+
* discovery sniffs before parsing, and a caller that hands us a non-TRX file
|
|
14628
|
+
* should get "no evidence here", not an exception that aborts a whole run
|
|
14629
|
+
* report.
|
|
14630
|
+
*/
|
|
14631
|
+
function parseTrxXml(xml) {
|
|
14632
|
+
if (xml.length > MAX_INPUT) return [];
|
|
14633
|
+
if (!looksLikeTrxXml(xml)) return [];
|
|
14634
|
+
const byTestId = /* @__PURE__ */ new Map();
|
|
14635
|
+
for (const tag of tagOf(xml, "UnitTest")) {
|
|
14636
|
+
const id = attr(tag, "id");
|
|
14637
|
+
const name = attr(tag, "name");
|
|
14638
|
+
if (id !== void 0 && name !== void 0) byTestId.set(id, name);
|
|
14639
|
+
}
|
|
14640
|
+
const records = [];
|
|
14641
|
+
const resultRe = /<UnitTestResult\b([^>]*)>([\s\S]{0,4000}?)(?=<UnitTestResult\b|<\/Results>)/gi;
|
|
14642
|
+
let m;
|
|
14643
|
+
while ((m = resultRe.exec(xml)) !== null) {
|
|
14644
|
+
const attrs = m[1] ?? "";
|
|
14645
|
+
const inner = m[2] ?? "";
|
|
14646
|
+
const testName = attr(attrs, "testName") ?? attr(attrs, "testId");
|
|
14647
|
+
if (testName === void 0) continue;
|
|
14648
|
+
const attempt = {
|
|
14649
|
+
index: 1,
|
|
14650
|
+
status: mapTrxOutcome(attr(attrs, "outcome")),
|
|
14651
|
+
durationMs: parseDuration(attr(attrs, "duration"))
|
|
14652
|
+
};
|
|
14653
|
+
const error = messageOf(inner, 0);
|
|
14654
|
+
records.push({
|
|
14655
|
+
file: "unknown",
|
|
14656
|
+
title: testName,
|
|
14657
|
+
attempts: [attempt],
|
|
14658
|
+
evidenceKind: "test",
|
|
14659
|
+
...error === void 0 ? {} : { errors: [error] }
|
|
14660
|
+
});
|
|
14661
|
+
if (records.length > 2e5) break;
|
|
14662
|
+
}
|
|
14663
|
+
return records;
|
|
14664
|
+
}
|
|
14665
|
+
//#endregion
|
|
13846
14666
|
//#region src/forensics/parse-playwright-json.ts
|
|
13847
14667
|
const MAX_DEPTH = 32;
|
|
13848
14668
|
function toStatus$1(raw) {
|
|
@@ -14052,7 +14872,9 @@ function parseHarJsonDetailed(json) {
|
|
|
14052
14872
|
//#endregion
|
|
14053
14873
|
//#region src/forensics/run.ts
|
|
14054
14874
|
/**
|
|
14055
|
-
*
|
|
14875
|
+
* Runtime evidence entry point (R4) — the library behind `explain
|
|
14876
|
+
* --evidence`. The `forensics` verb is retired in the v6 carve; this module
|
|
14877
|
+
* is what reads the run data.
|
|
14056
14878
|
*
|
|
14057
14879
|
* Accepts either a single report file or a directory. In a directory it
|
|
14058
14880
|
* looks for, in priority order:
|
|
@@ -14252,6 +15074,13 @@ function traceArtifactName(fullPath) {
|
|
|
14252
15074
|
return base;
|
|
14253
15075
|
}
|
|
14254
15076
|
function parseFile(path, text) {
|
|
15077
|
+
if (/\.trx$/i.test(path) || looksLikeTrxXml(text)) {
|
|
15078
|
+
const records = parseTrxXml(text);
|
|
15079
|
+
if (records.length > 0) return {
|
|
15080
|
+
records,
|
|
15081
|
+
source: "dotnet-trx"
|
|
15082
|
+
};
|
|
15083
|
+
}
|
|
14255
15084
|
if (/\.xml$/i.test(path) || /^\s*<\?xml|<testsuite\b/i.test(text.slice(0, 200))) return {
|
|
14256
15085
|
records: parseJunitXml(text),
|
|
14257
15086
|
source: "junit-xml"
|
|
@@ -14319,11 +15148,11 @@ function listFiles(dir) {
|
|
|
14319
15148
|
return out.map((p) => p);
|
|
14320
15149
|
}
|
|
14321
15150
|
plainContext();
|
|
14322
|
-
function loadSuppressions(root) {
|
|
15151
|
+
function loadSuppressions(root, now = /* @__PURE__ */ new Date()) {
|
|
14323
15152
|
const { config } = loadConfig(root);
|
|
14324
15153
|
const entries = (config.ignore ?? []).map((ign) => ({
|
|
14325
15154
|
...ign,
|
|
14326
|
-
status: isSuppressionActive(ign,
|
|
15155
|
+
status: isSuppressionActive(ign, now) ? "active" : "expired"
|
|
14327
15156
|
}));
|
|
14328
15157
|
return {
|
|
14329
15158
|
total: entries.length,
|
|
@@ -14503,7 +15332,7 @@ function loadPlugins(root, gateOpen = false) {
|
|
|
14503
15332
|
* - Load failures degrade honestly: warning entries, never a crash;
|
|
14504
15333
|
* exit codes stay frozen.
|
|
14505
15334
|
*
|
|
14506
|
-
* Drift-check: the `mjolnir
|
|
15335
|
+
* Drift-check: the `mjolnir explain --list --md` catalog is generated from the
|
|
14507
15336
|
* LOADED rules (core + external), so the catalog can never drift from
|
|
14508
15337
|
* what actually ships — an edit to a local rule file changes the very
|
|
14509
15338
|
* next catalog render (locked by tests/local-rules.spec.ts).
|
|
@@ -14849,7 +15678,7 @@ const KNOWN_RULE_IDS = new Set(RULES.map((r) => r.id));
|
|
|
14849
15678
|
const OVERLAP_META_BY_RULE_ID = new Map(RULES.map((r, order) => {
|
|
14850
15679
|
const meta = {
|
|
14851
15680
|
...r.overlapWith ? { overlapWith: r.overlapWith } : {},
|
|
14852
|
-
|
|
15681
|
+
tier: effectiveTier(r),
|
|
14853
15682
|
order
|
|
14854
15683
|
};
|
|
14855
15684
|
return [r.id, meta];
|
|
@@ -14877,11 +15706,14 @@ async function buildUniversalRules(root, strict, opts = {}) {
|
|
|
14877
15706
|
rules: local.rules
|
|
14878
15707
|
}].flatMap((p) => p.rules.map(asUniversal));
|
|
14879
15708
|
const tierByRuleId = /* @__PURE__ */ new Map();
|
|
14880
|
-
for (const r of RULES)
|
|
15709
|
+
for (const r of RULES) tierByRuleId.set(r.id, effectiveTier(r));
|
|
14881
15710
|
for (const p of plugins) for (const r of p.rules) if (r.tier) tierByRuleId.set(r.id, r.tier);
|
|
14882
15711
|
for (const r of local.rules) if (r.tier) tierByRuleId.set(r.id, r.tier);
|
|
14883
15712
|
let rules = [...UNIVERSAL_RULES, ...externalRules];
|
|
15713
|
+
const withheldRuleIds = rules.filter((r) => tierByRuleId.get(r.id) === "quarantine").map((r) => r.id).sort();
|
|
14884
15714
|
if (!strict) rules = rules.filter((r) => tierByRuleId.get(r.id) !== "quarantine");
|
|
15715
|
+
const rulesWithheld = strict ? 0 : withheldRuleIds.length;
|
|
15716
|
+
const rulesApplied = rules.length;
|
|
14885
15717
|
const pluginMeta = [...plugins.map((p) => ({
|
|
14886
15718
|
name: p.name,
|
|
14887
15719
|
rules: p.rules.length
|
|
@@ -14894,7 +15726,10 @@ async function buildUniversalRules(root, strict, opts = {}) {
|
|
|
14894
15726
|
pluginErrors: allErrors,
|
|
14895
15727
|
tierByRuleId,
|
|
14896
15728
|
pluginMeta,
|
|
14897
|
-
externalRules: local.rules
|
|
15729
|
+
externalRules: local.rules,
|
|
15730
|
+
rulesApplied,
|
|
15731
|
+
rulesWithheld,
|
|
15732
|
+
withheldRuleIds
|
|
14898
15733
|
};
|
|
14899
15734
|
}
|
|
14900
15735
|
/** Rule-declared evidence-level overrides (Honesty Core). */
|
|
@@ -14919,35 +15754,6 @@ function fallbackWorkspace(targetAbs) {
|
|
|
14919
15754
|
};
|
|
14920
15755
|
}
|
|
14921
15756
|
/**
|
|
14922
|
-
* Minimal glob match for suppression `files` patterns, with gitignore
|
|
14923
|
-
* `**` semantics (bug-audit M5). Supports:
|
|
14924
|
-
* "tests/**" — everything inside tests/
|
|
14925
|
-
* "tests" + "/**\/*.spec.ts" — any depth UNDER tests/ (including none) ending in .spec.ts
|
|
14926
|
-
* "**" + "/*.spec.ts" — any depth including root-level files
|
|
14927
|
-
* "tests/foo.spec.ts" — exact path
|
|
14928
|
-
* "*" within a segment never crosses "/".
|
|
14929
|
-
*
|
|
14930
|
-
* Forward slashes only (findings always use normalized paths). `?`,
|
|
14931
|
-
* character classes and `!` negation are not metacharacters here — same
|
|
14932
|
-
* as before this rewrite.
|
|
14933
|
-
*/
|
|
14934
|
-
function pathMatchesGlob(path, glob) {
|
|
14935
|
-
const p = path.replaceAll("\\", "/");
|
|
14936
|
-
const segments = glob.replaceAll("\\", "/").split("/");
|
|
14937
|
-
let re = "^";
|
|
14938
|
-
for (const [i, segment] of segments.entries()) {
|
|
14939
|
-
const last = i === segments.length - 1;
|
|
14940
|
-
if (segment === "**") {
|
|
14941
|
-
if (last) re += "(?:[^/]+/)*[^/]+";
|
|
14942
|
-
else re += "(?:[^/]+/)*";
|
|
14943
|
-
continue;
|
|
14944
|
-
}
|
|
14945
|
-
re += segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", "[^/]*");
|
|
14946
|
-
if (!last) re += "/";
|
|
14947
|
-
}
|
|
14948
|
-
return new RegExp(`${re}$`).test(p);
|
|
14949
|
-
}
|
|
14950
|
-
/**
|
|
14951
15757
|
* Plan §16 + WI-11: locate a runtime run report next to the scan
|
|
14952
15758
|
* target, using the exact conventions the forensics ingestion already
|
|
14953
15759
|
* accepts. Zero-config search over conventional artifact names at
|
|
@@ -15056,6 +15862,7 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
15056
15862
|
let rulesPartial = false;
|
|
15057
15863
|
let parseFailed = 0;
|
|
15058
15864
|
let parseFallbacks = 0;
|
|
15865
|
+
let astFallbackFiles = 0;
|
|
15059
15866
|
let scanned = 0;
|
|
15060
15867
|
let analyzed = 0;
|
|
15061
15868
|
for (const path of testFiles) {
|
|
@@ -15099,6 +15906,10 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
15099
15906
|
});
|
|
15100
15907
|
}
|
|
15101
15908
|
const wantsAst = adapter.parseAst !== void 0 && Date.now() <= deadline;
|
|
15909
|
+
if (adapter.parseAst !== void 0 && !wantsAst) {
|
|
15910
|
+
truncationReasons.add("ast-budget-fallback");
|
|
15911
|
+
astFallbackFiles++;
|
|
15912
|
+
}
|
|
15102
15913
|
const identity = (mode) => ({
|
|
15103
15914
|
relPath,
|
|
15104
15915
|
adapterId: adapter.id,
|
|
@@ -15216,7 +16027,8 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
15216
16027
|
parseFailed,
|
|
15217
16028
|
parseFallbacks,
|
|
15218
16029
|
scanned,
|
|
15219
|
-
analyzed
|
|
16030
|
+
analyzed,
|
|
16031
|
+
astFallbackFiles
|
|
15220
16032
|
};
|
|
15221
16033
|
}
|
|
15222
16034
|
/**
|
|
@@ -15444,6 +16256,11 @@ function assembleScanResult(o) {
|
|
|
15444
16256
|
scopeUnrecognized: o.scopeUnrecognized,
|
|
15445
16257
|
parseFailed: o.parseFailed,
|
|
15446
16258
|
parseFallbacks: o.parseFallbacks ?? 0,
|
|
16259
|
+
...o.astFallbackFiles !== void 0 ? { astFallbackFiles: o.astFallbackFiles } : {},
|
|
16260
|
+
...o.degradations !== void 0 ? { degradations: o.degradations } : {},
|
|
16261
|
+
...o.rulesWithheld !== void 0 ? { rulesWithheld: o.rulesWithheld } : {},
|
|
16262
|
+
...o.withheldRuleIds !== void 0 ? { withheldRuleIds: o.withheldRuleIds } : {},
|
|
16263
|
+
...o.rulesApplied !== void 0 ? { rulesApplied: o.rulesApplied } : {},
|
|
15447
16264
|
...o.scopeInfo.degraded !== void 0 ? { scopeDegraded: o.scopeInfo.degraded } : {},
|
|
15448
16265
|
...o.runtimeIncomplete !== void 0 ? { runtimeIncomplete: o.runtimeIncomplete } : {},
|
|
15449
16266
|
identityIncomplete
|
|
@@ -15458,11 +16275,15 @@ function assembleScanResult(o) {
|
|
|
15458
16275
|
})),
|
|
15459
16276
|
config: o.config ?? null,
|
|
15460
16277
|
engineVersion: ENGINE_VERSION,
|
|
16278
|
+
runMode: resolveRunMode({
|
|
16279
|
+
env: {},
|
|
16280
|
+
flags: {}
|
|
16281
|
+
}),
|
|
15461
16282
|
reportDigest,
|
|
15462
16283
|
trustModelVersion: TRUST_MODEL_VERSION,
|
|
15463
16284
|
scoringModelVersion: SCORING_MODEL_VERSION,
|
|
15464
16285
|
frameworkSupportMatrixVersion: FRAMEWORK_SUPPORT_MATRIX_VERSION,
|
|
15465
|
-
evidenceSchemaVersions: [
|
|
16286
|
+
evidenceSchemaVersions: [2],
|
|
15466
16287
|
...repository,
|
|
15467
16288
|
...candidate !== null ? { candidate } : {}
|
|
15468
16289
|
});
|
|
@@ -15475,7 +16296,10 @@ function assembleScanResult(o) {
|
|
|
15475
16296
|
});
|
|
15476
16297
|
const hasTests = o.testFileCount > 0 && o.testDeclarationCount > 0;
|
|
15477
16298
|
const suiteInvalidatedBy = [...new Set(o.findings.filter((f) => SUITE_INVALIDATING_RULE_IDS.has(f.ruleId)).map((f) => f.ruleId))].sort();
|
|
15478
|
-
const
|
|
16299
|
+
const scopeClamped = scopeReasons.length > 0 && total >= 100;
|
|
16300
|
+
const partialClamped = completion.partial && scopeAdjustedTotal >= 100;
|
|
16301
|
+
const scoreClampReason = scopeClamped ? "scope-degraded" : partialClamped ? "partial-scan" : null;
|
|
16302
|
+
const finalScore = hasTests ? partialClamped ? 99 : scopeAdjustedTotal : null;
|
|
15479
16303
|
const result = {
|
|
15480
16304
|
schemaVersion: 1,
|
|
15481
16305
|
partial: completion.partial,
|
|
@@ -15514,9 +16338,11 @@ function assembleScanResult(o) {
|
|
|
15514
16338
|
} } : {},
|
|
15515
16339
|
analysisStatus: {
|
|
15516
16340
|
...completion.analysisStatus,
|
|
16341
|
+
coverageState: completion.coverageState,
|
|
15517
16342
|
durationMs: elapsed
|
|
15518
16343
|
},
|
|
15519
|
-
scoringModelVersion: SCORING_MODEL_VERSION
|
|
16344
|
+
scoringModelVersion: SCORING_MODEL_VERSION,
|
|
16345
|
+
...scoreClampReason !== null ? { scoreClampReason } : {}
|
|
15520
16346
|
};
|
|
15521
16347
|
result.trustSummary = buildTrustSummary(result, o.declarationsByFile);
|
|
15522
16348
|
if (o.findings.length > 0) result.correlationConclusions = correlateFindings(o.findings);
|
|
@@ -15560,15 +16386,33 @@ function assembleScanResult(o) {
|
|
|
15560
16386
|
* inherently async); `runRules` and every rule stay synchronous and
|
|
15561
16387
|
* consume `ParsedFile.ast`. Callers await the returned promise.
|
|
15562
16388
|
*/
|
|
16389
|
+
/**
|
|
16390
|
+
* Run one scan inside its own ts-morph Project scope.
|
|
16391
|
+
*
|
|
16392
|
+
* The wrapper is the whole fix. ts-morph's Project caches SourceFiles by path
|
|
16393
|
+
* and `parseTsFile` calls `replaceWithText` on them, so a Project shared between
|
|
16394
|
+
* two concurrent scans lets one rewrite the file the other is mid-rule on —
|
|
16395
|
+
* which surfaced as three of four concurrent scans reporting
|
|
16396
|
+
* `rulesCrashed: 4` and `rules: "partial"`, and therefore as two different
|
|
16397
|
+
* machine-contract digests from one tree. See `src/engine/ts-ast.ts`.
|
|
16398
|
+
*
|
|
16399
|
+
* Async context propagates across `await`, so the scope covers the whole scan
|
|
16400
|
+
* without threading a Project through every adapter.
|
|
16401
|
+
*/
|
|
15563
16402
|
async function runScan(args, hooks = {}) {
|
|
16403
|
+
return runWithScanProject(() => runScanInProject(args, hooks));
|
|
16404
|
+
}
|
|
16405
|
+
async function runScanInProject(args, hooks = {}) {
|
|
15564
16406
|
const started = Date.now();
|
|
16407
|
+
const degradationWindow = beginDegradationWindow();
|
|
15565
16408
|
const requestedDuration = Number.isFinite(args.maxDurationMs) ? args.maxDurationMs : DEFAULT_MAX_DURATION_MS$1;
|
|
15566
16409
|
const deadline = started + Math.min(Math.max(1, requestedDuration), MAX_DURATION_MS$1);
|
|
15567
16410
|
const discovered = discoverWorkspace(args.target);
|
|
15568
16411
|
const targetAbs = resolve(args.target);
|
|
15569
16412
|
const scanRoot = discovered && discovered.root !== targetAbs && targetAbs.startsWith(discovered.root + sep) ? {
|
|
15570
16413
|
...discovered,
|
|
15571
|
-
root: targetAbs
|
|
16414
|
+
root: targetAbs,
|
|
16415
|
+
packageJson: {}
|
|
15572
16416
|
} : discovered ?? fallbackWorkspace(targetAbs);
|
|
15573
16417
|
const workspace = scanRoot;
|
|
15574
16418
|
if (args.verbose) hooks.onConfigWarning?.(`verbose: scan root (config/ignore anchor): ${workspace.root}`);
|
|
@@ -15585,7 +16429,7 @@ async function runScan(args, hooks = {}) {
|
|
|
15585
16429
|
let scopeUnrecognized = 0;
|
|
15586
16430
|
let parseFailed = 0;
|
|
15587
16431
|
const fileProvenance = [];
|
|
15588
|
-
const { rules: activeRules, pluginErrors, tierByRuleId: tiers, pluginMeta } = await buildUniversalRules(workspace.root, args.strict, {
|
|
16432
|
+
const { rules: activeRules, pluginErrors, tierByRuleId: tiers, pluginMeta, rulesApplied, rulesWithheld, withheldRuleIds } = await buildUniversalRules(workspace.root, args.strict, {
|
|
15589
16433
|
...args.enablePlugins !== void 0 ? { enablePlugins: args.enablePlugins } : {},
|
|
15590
16434
|
onGateNotice: (notice) => hooks.onGateNotice ? hooks.onGateNotice(notice) : console.error(notice)
|
|
15591
16435
|
});
|
|
@@ -15653,6 +16497,7 @@ async function runScan(args, hooks = {}) {
|
|
|
15653
16497
|
rulesPartial = analysis.rulesPartial;
|
|
15654
16498
|
parseFailed = analysis.parseFailed;
|
|
15655
16499
|
const parseFallbacks = analysis.parseFallbacks;
|
|
16500
|
+
const astFallbackFiles = analysis.astFallbackFiles;
|
|
15656
16501
|
const scanned = analysis.scanned;
|
|
15657
16502
|
const analyzed = analysis.analyzed;
|
|
15658
16503
|
const postScan = applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, declarationsByFile, testDeclarationCount, tierByRuleId, REVISION_BY_RULE_ID);
|
|
@@ -15671,6 +16516,11 @@ async function runScan(args, hooks = {}) {
|
|
|
15671
16516
|
scopeUnrecognized,
|
|
15672
16517
|
parseFailed,
|
|
15673
16518
|
parseFallbacks,
|
|
16519
|
+
astFallbackFiles,
|
|
16520
|
+
degradations: summarizeDegradations(degradationsSince(degradationWindow)),
|
|
16521
|
+
rulesApplied,
|
|
16522
|
+
rulesWithheld,
|
|
16523
|
+
...withheldRuleIds.length > 0 ? { withheldRuleIds } : {},
|
|
15674
16524
|
scanned,
|
|
15675
16525
|
analyzed,
|
|
15676
16526
|
testFiles,
|
|
@@ -15697,6 +16547,39 @@ async function runScan(args, hooks = {}) {
|
|
|
15697
16547
|
resetTsMorphProject();
|
|
15698
16548
|
return result;
|
|
15699
16549
|
}
|
|
16550
|
+
//#endregion
|
|
16551
|
+
//#region src/cli-io.ts
|
|
16552
|
+
/**
|
|
16553
|
+
* The ONE error-to-message derivation in `src/`.
|
|
16554
|
+
*
|
|
16555
|
+
* This tree carried four near-copies: two `errorMessage` (byte-identical) and
|
|
16556
|
+
* two `errorText`. The two `errorText` copies were not identical, which is the
|
|
16557
|
+
* part that mattered — `String(someObject)` renders `[object Object]`, so the
|
|
16558
|
+
* copy that lost the payload turned a hostile non-Error throw into a message
|
|
16559
|
+
* that names nothing. The richer arms are kept here, so every call site gets
|
|
16560
|
+
* the informative one and the difference stops being a per-file accident.
|
|
16561
|
+
*
|
|
16562
|
+
* The arms, in order:
|
|
16563
|
+
* Error -> `.message`. The 99% case, and the only one that
|
|
16564
|
+
* carries a stack the caller may still want.
|
|
16565
|
+
* string -> itself. A thrown string is already the message.
|
|
16566
|
+
* plain object -> `JSON.stringify`. A non-Error throw of `{ code:
|
|
16567
|
+
* "EISDIR" }` is a REAL failure shape in Node, and
|
|
16568
|
+
* `[object Object]` hides the only field that says
|
|
16569
|
+
* what went wrong.
|
|
16570
|
+
* anything else -> `String(err)`. Symbols and undefined included, so
|
|
16571
|
+
* nothing vanishes.
|
|
16572
|
+
*
|
|
16573
|
+
* The call this replaces was `err instanceof Error ? err.message : String(err)`
|
|
16574
|
+
* in `src/mcp/server.ts` and `src/commands/doctor.ts`; `tests/cli/summary.spec.ts`
|
|
16575
|
+
* already pins the object arm.
|
|
16576
|
+
*/
|
|
16577
|
+
function errorMessage(err) {
|
|
16578
|
+
if (err instanceof Error) return err.message;
|
|
16579
|
+
if (typeof err === "string") return err;
|
|
16580
|
+
if (typeof err === "object" && err !== null) return JSON.stringify(err);
|
|
16581
|
+
return String(err);
|
|
16582
|
+
}
|
|
15700
16583
|
/**
|
|
15701
16584
|
* Fields that participate in the verification digest: identity (ruleId,
|
|
15702
16585
|
* detectorRevision, file, line, column) + evidence (severity,
|
|
@@ -15742,6 +16625,10 @@ function canonicalScanJson(result) {
|
|
|
15742
16625
|
skippedFiles: result.analysisStatus.skippedFiles,
|
|
15743
16626
|
rulesCrashed: result.analysisStatus.rulesCrashed ?? 0,
|
|
15744
16627
|
parseFallbacks: result.analysisStatus.parseFallbacks ?? 0,
|
|
16628
|
+
coverageState: result.analysisStatus.coverageState ?? "COMPLETE",
|
|
16629
|
+
rulesApplied: result.analysisStatus.rulesApplied ?? null,
|
|
16630
|
+
rulesWithheld: result.analysisStatus.rulesWithheld ?? 0,
|
|
16631
|
+
degradations: result.analysisStatus.degradations ?? [],
|
|
15745
16632
|
truncationReasons: result.analysisStatus.truncationReasons ?? [],
|
|
15746
16633
|
reasons: result.analysisStatus.reasons ?? []
|
|
15747
16634
|
},
|
|
@@ -15785,7 +16672,7 @@ function buildMachineContract(result) {
|
|
|
15785
16672
|
};
|
|
15786
16673
|
});
|
|
15787
16674
|
return {
|
|
15788
|
-
contractVersion:
|
|
16675
|
+
contractVersion: 2,
|
|
15789
16676
|
summary: {
|
|
15790
16677
|
digest: `sha256:${digest}`,
|
|
15791
16678
|
findings: result.findings.length,
|
|
@@ -15806,6 +16693,10 @@ function buildMachineContract(result) {
|
|
|
15806
16693
|
...result.analysisStatus.parseFallbacks !== void 0 ? { parseFallbacks: result.analysisStatus.parseFallbacks } : {},
|
|
15807
16694
|
truncationReasons: result.analysisStatus.truncationReasons ?? [],
|
|
15808
16695
|
...result.analysisStatus.reasons !== void 0 ? { reasons: result.analysisStatus.reasons } : {},
|
|
16696
|
+
...result.analysisStatus.degradations !== void 0 ? { degradations: result.analysisStatus.degradations } : {},
|
|
16697
|
+
...result.analysisStatus.coverageState !== void 0 ? { coverageState: result.analysisStatus.coverageState } : {},
|
|
16698
|
+
...result.analysisStatus.rulesApplied !== void 0 ? { rulesApplied: result.analysisStatus.rulesApplied } : {},
|
|
16699
|
+
...result.analysisStatus.rulesWithheld !== void 0 ? { rulesWithheld: result.analysisStatus.rulesWithheld } : {},
|
|
15809
16700
|
frameworkDetectionUnknown: result.frameworkDetectionUnknown,
|
|
15810
16701
|
durationMs: result.analysisStatus.durationMs,
|
|
15811
16702
|
...result.scopeIntegrity !== void 0 ? { scopeIntegrity: result.scopeIntegrity } : {},
|
|
@@ -15847,7 +16738,12 @@ function explainRule(ruleId, fixturesRoot) {
|
|
|
15847
16738
|
const rule = getRule(ruleId);
|
|
15848
16739
|
if (!rule) return {
|
|
15849
16740
|
ok: false,
|
|
15850
|
-
error: `Unknown rule ID "${ruleId}". Run \`mjolnir
|
|
16741
|
+
error: `Unknown rule ID "${ruleId}". Run \`mjolnir explain --list\` for the full catalog.`
|
|
16742
|
+
};
|
|
16743
|
+
const window = beginDegradationWindow();
|
|
16744
|
+
const degraded = () => {
|
|
16745
|
+
const counts = summarizeDegradations(degradationsSince(window));
|
|
16746
|
+
return counts.length > 0 ? { exampleDegraded: counts } : {};
|
|
15851
16747
|
};
|
|
15852
16748
|
const fixturePath = firstFixtureFile(join(fixturesRoot, ruleId, "must-fire"));
|
|
15853
16749
|
if (!fixturePath) return {
|
|
@@ -15858,9 +16754,11 @@ function explainRule(ruleId, fixturesRoot) {
|
|
|
15858
16754
|
try {
|
|
15859
16755
|
text = readFileSync(fixturePath, "utf8").replace(/\r\n/g, "\n");
|
|
15860
16756
|
} catch {
|
|
16757
|
+
recordDegradation("explain-fixture-unreadable");
|
|
15861
16758
|
return {
|
|
15862
16759
|
ok: true,
|
|
15863
|
-
rule
|
|
16760
|
+
rule,
|
|
16761
|
+
...degraded()
|
|
15864
16762
|
};
|
|
15865
16763
|
}
|
|
15866
16764
|
const normalizedPath = fixturePath.replaceAll("\\", "/");
|
|
@@ -15870,9 +16768,11 @@ function explainRule(ruleId, fixturesRoot) {
|
|
|
15870
16768
|
else try {
|
|
15871
16769
|
ast = isAzurePipelineFixture(normalizedPath) ? parseAzurePipeline(text) : parseWorkflow(text);
|
|
15872
16770
|
} catch {
|
|
16771
|
+
recordDegradation("explain-workflow-parse-failed");
|
|
15873
16772
|
return {
|
|
15874
16773
|
ok: true,
|
|
15875
|
-
rule
|
|
16774
|
+
rule,
|
|
16775
|
+
...degraded()
|
|
15876
16776
|
};
|
|
15877
16777
|
}
|
|
15878
16778
|
}
|
|
@@ -15889,9 +16789,11 @@ function explainRule(ruleId, fixturesRoot) {
|
|
|
15889
16789
|
codeText
|
|
15890
16790
|
});
|
|
15891
16791
|
} catch {
|
|
16792
|
+
recordDegradation("explain-rule-crash");
|
|
15892
16793
|
return {
|
|
15893
16794
|
ok: true,
|
|
15894
|
-
rule
|
|
16795
|
+
rule,
|
|
16796
|
+
...degraded()
|
|
15895
16797
|
};
|
|
15896
16798
|
}
|
|
15897
16799
|
const example = findings[0];
|
|
@@ -15908,16 +16810,6 @@ function explainRule(ruleId, fixturesRoot) {
|
|
|
15908
16810
|
};
|
|
15909
16811
|
}
|
|
15910
16812
|
//#endregion
|
|
15911
|
-
//#region src/engine/finding-identity.ts
|
|
15912
|
-
/**
|
|
15913
|
-
* Correlation identity for before/after comparison: ruleId + file +
|
|
15914
|
-
* message, deliberately EXCLUDING line — a source edit that shifts a
|
|
15915
|
-
* finding still correlates. Shared by baseline, impact, and resolution.
|
|
15916
|
-
*/
|
|
15917
|
-
function findingFingerprint(f) {
|
|
15918
|
-
return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
|
|
15919
|
-
}
|
|
15920
|
-
//#endregion
|
|
15921
16813
|
//#region src/engine/resolution.ts
|
|
15922
16814
|
/** Correlation identity (v1-compatible): ruleId\0file\0message. */
|
|
15923
16815
|
const fingerprint$1 = findingFingerprint;
|
|
@@ -15938,6 +16830,11 @@ function resolve$1(input) {
|
|
|
15938
16830
|
cause: "crash",
|
|
15939
16831
|
comparedAgainst
|
|
15940
16832
|
};
|
|
16833
|
+
if (current.analysisStatus.withheldRuleIds?.includes(entry.ruleId) === true) return {
|
|
16834
|
+
status: "INCONCLUSIVE",
|
|
16835
|
+
cause: "rule-withheld",
|
|
16836
|
+
comparedAgainst
|
|
16837
|
+
};
|
|
15941
16838
|
if (input.skippedFiles?.has(entry.file)) return {
|
|
15942
16839
|
status: "INCONCLUSIVE",
|
|
15943
16840
|
cause: "skipped",
|
|
@@ -16067,13 +16964,13 @@ function diffAgainstBaseline(result, baseline) {
|
|
|
16067
16964
|
//#endregion
|
|
16068
16965
|
//#region src/commands/verify.ts
|
|
16069
16966
|
/**
|
|
16070
|
-
* `mjolnir verify` — the agent-loop digest (product-gap-remediation
|
|
16967
|
+
* `mjolnir ci verify` — the agent-loop digest (product-gap-remediation
|
|
16071
16968
|
* master plan P7, plan 1788853205786 — flag agent, decision 6).
|
|
16072
16969
|
*
|
|
16073
|
-
* An agent fixes findings, then runs `mjolnir verify` against the
|
|
16074
|
-
* committed baseline (established once
|
|
16970
|
+
* An agent fixes findings, then runs `mjolnir ci verify` against the
|
|
16971
|
+
* committed baseline (established once from a `mjolnir scan --json` run). The
|
|
16075
16972
|
* digest answers exactly the loop's questions, derived from the SAME
|
|
16076
|
-
* §15 comparison machinery as `mjolnir
|
|
16973
|
+
* §15 comparison machinery as `mjolnir scan --scope changed` (no second truth):
|
|
16077
16974
|
*
|
|
16078
16975
|
* - RESOLVED — baseline findings no longer present, each with its
|
|
16079
16976
|
* lifecycle resolution (only VERIFIED-RESOLVED is a fix claim);
|
|
@@ -16349,7 +17246,7 @@ const MCP_TOOLS = [
|
|
|
16349
17246
|
},
|
|
16350
17247
|
{
|
|
16351
17248
|
name: "verify",
|
|
16352
|
-
description: "Agent-loop digest (1:1 with `mjolnir verify`): scan the target and diff against the committed baseline. Returns resolved (per §15 lifecycle), new, unchanged (grouped by ruleId + location), and the score delta. Read-only; the same transport guardrails as every other tool.",
|
|
17249
|
+
description: "Agent-loop digest (1:1 with `mjolnir ci verify`): scan the target and diff against the committed baseline. Returns resolved (per §15 lifecycle), new, unchanged (grouped by ruleId + location), and the score delta. Read-only; the same transport guardrails as every other tool.",
|
|
16353
17250
|
inputSchema: {
|
|
16354
17251
|
type: "object",
|
|
16355
17252
|
properties: { path: {
|
|
@@ -16361,7 +17258,7 @@ const MCP_TOOLS = [
|
|
|
16361
17258
|
},
|
|
16362
17259
|
{
|
|
16363
17260
|
name: "forensics",
|
|
16364
|
-
description: "Runtime-evidence report (1:1 with the forensics engine behind `mjolnir
|
|
17261
|
+
description: "Runtime-evidence report (1:1 with the forensics engine behind `mjolnir explain --playwright`): ingest a run report (Playwright JSON, JUnit XML, Jest/Vitest JSON, or a Playwright trace artifact) and return the ForensicsReport — per-test verdicts, retries, TRUE-FLAKE, durations. Hostile reports degrade to zero records (never a fabricated clean run). Read-only.",
|
|
16365
17262
|
inputSchema: {
|
|
16366
17263
|
type: "object",
|
|
16367
17264
|
properties: { path: {
|
|
@@ -16385,7 +17282,7 @@ const MCP_TOOLS = [
|
|
|
16385
17282
|
},
|
|
16386
17283
|
{
|
|
16387
17284
|
name: "pw-report",
|
|
16388
|
-
description: "Playwright run summary (1:1 with `mjolnir
|
|
17285
|
+
description: "Playwright run summary (1:1 with `mjolnir explain --playwright`): ingest a run report and return both the ForensicsReport and the rendered run summary. Read-only.",
|
|
16389
17286
|
inputSchema: {
|
|
16390
17287
|
type: "object",
|
|
16391
17288
|
properties: { path: {
|
|
@@ -16578,7 +17475,7 @@ async function handleToolCall(call) {
|
|
|
16578
17475
|
id: call.id,
|
|
16579
17476
|
result: {
|
|
16580
17477
|
hasBaseline: false,
|
|
16581
|
-
note: "no committed baseline at .mjolnir/baseline.json — establish the before-state with `mjolnir baseline`
|
|
17478
|
+
note: "no committed baseline at .mjolnir/baseline.json — establish the before-state with `mjolnir <target> --save-baseline`, then re-run this tool (or `mjolnir ci verify`) after the fix"
|
|
16582
17479
|
}
|
|
16583
17480
|
};
|
|
16584
17481
|
const result = await serializeScan(() => runScan({
|
|
@@ -16652,15 +17549,11 @@ async function handleToolCall(call) {
|
|
|
16652
17549
|
id: call.id,
|
|
16653
17550
|
error: {
|
|
16654
17551
|
code: MCP_ERRORS.INTERNAL,
|
|
16655
|
-
message: errorMessage
|
|
17552
|
+
message: errorMessage(err)
|
|
16656
17553
|
}
|
|
16657
17554
|
};
|
|
16658
17555
|
}
|
|
16659
17556
|
}
|
|
16660
|
-
/** Normalize any thrown value to a string (unit-tested both arms). */
|
|
16661
|
-
function errorMessage$1(err) {
|
|
16662
|
-
return err instanceof Error ? err.message : String(err);
|
|
16663
|
-
}
|
|
16664
17557
|
/** JSON-RPC request shape validation (strict — §21). */
|
|
16665
17558
|
function validateRequest(msg) {
|
|
16666
17559
|
if (typeof msg !== "object" || msg === null) return {
|
|
@@ -16763,14 +17656,6 @@ async function handleMcpMessage(msg) {
|
|
|
16763
17656
|
};
|
|
16764
17657
|
}
|
|
16765
17658
|
//#endregion
|
|
16766
|
-
//#region src/cli-io.ts
|
|
16767
|
-
/**
|
|
16768
|
-
* The single error-to-message derivation shared by every catch site:
|
|
16769
|
-
* Error instances render their message; anything else is stringified
|
|
16770
|
-
* honestly (a hostile non-Error throw must surface, not vanish).
|
|
16771
|
-
*/
|
|
16772
|
-
const errorMessage = (err) => err instanceof Error ? err.message : String(err);
|
|
16773
|
-
//#endregion
|
|
16774
17659
|
//#region src/mcp/transport.ts
|
|
16775
17660
|
/**
|
|
16776
17661
|
* MCP stdio transport loop (blueprint §21 — the framing around the
|