mjolnir-qa 5.0.0 → 6.0.1
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 +3292 -15
- package/README.md +258 -652
- package/dist/cli.d.mts +434 -741
- package/dist/cli.mjs +31552 -15384
- package/dist/mcp/stdio.mjs +1146 -570
- package/package.json +78 -61
- 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/rolldown-runtime-8H4AJuhK.mjs +0 -14
- package/dist/scan-pipeline-DNYbnWjE.mjs +0 -2
- package/dist/scan-pipeline-DT8DWAMl.mjs +0 -16501
package/dist/mcp/stdio.mjs
CHANGED
|
@@ -2,16 +2,17 @@ import { createRequire } from "node:module";
|
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
3
|
import { Buffer as Buffer$1 } from "node:buffer";
|
|
4
4
|
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
-
import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
5
|
+
import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, 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";
|
|
11
12
|
import { Language, Parser } from "web-tree-sitter";
|
|
12
13
|
import { parse } from "yaml";
|
|
13
14
|
import { inflateRawSync } from "node:zlib";
|
|
14
|
-
|
|
15
|
+
// src/types.ts
|
|
15
16
|
/** Severity ladder. Order matters for sorting and gating. */
|
|
16
17
|
const SEVERITY_ORDER = [
|
|
17
18
|
"error",
|
|
@@ -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",
|
|
@@ -81,8 +99,7 @@ const DEDUCTIONS = {
|
|
|
81
99
|
warning: 3,
|
|
82
100
|
info: 1
|
|
83
101
|
};
|
|
84
|
-
|
|
85
|
-
//#region src/engine/completion.ts
|
|
102
|
+
// src/engine/completion.ts
|
|
86
103
|
function deriveCompletion(input) {
|
|
87
104
|
const truncationReasons = [...new Set(input.truncationReasons)].sort();
|
|
88
105
|
const degradations = [...input.degradations ?? []].sort((a, b) => a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0);
|
|
@@ -100,25 +117,31 @@ function deriveCompletion(input) {
|
|
|
100
117
|
if (input.runtimeIncomplete) reasons.add("runtime-incomplete");
|
|
101
118
|
if (input.identityIncomplete) reasons.add("identity-incomplete");
|
|
102
119
|
for (const reason of truncationReasons) reasons.add(`truncated:${reason}`);
|
|
120
|
+
if ((input.rulesWithheld ?? 0) > 0) reasons.add(`coverage:quarantine:${input.rulesWithheld}`);
|
|
103
121
|
for (const entry of degradations) reasons.add(`degraded:${entry.reason}:${entry.count}`);
|
|
104
122
|
const discoveryPartial = input.discoveryTruncated || input.scopeIgnored > 0 || input.scopeUnrecognized > 0 || input.scopeDegraded !== void 0;
|
|
105
123
|
const rulesPartial = input.rulesPartial || input.rulesCrashed > 0 || input.parseFailed > 0;
|
|
124
|
+
const partial = discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0 || degradations.length > 0;
|
|
125
|
+
const rulesWithheld = input.rulesWithheld ?? 0;
|
|
106
126
|
return {
|
|
107
|
-
partial
|
|
127
|
+
partial,
|
|
128
|
+
coverageState: rulesWithheld > 0 ? "PARTIAL" : "COMPLETE",
|
|
108
129
|
analysisStatus: {
|
|
109
130
|
discovery: discoveryPartial ? "partial" : "complete",
|
|
110
131
|
rules: rulesPartial ? "partial" : "complete",
|
|
111
132
|
skippedFiles: input.skippedFiles,
|
|
112
133
|
rulesCrashed: input.rulesCrashed,
|
|
113
134
|
parseFallbacks: input.parseFallbacks ?? 0,
|
|
135
|
+
rulesApplied: input.rulesApplied ?? 0,
|
|
136
|
+
rulesWithheld,
|
|
137
|
+
...input.withheldRuleIds !== void 0 && input.withheldRuleIds.length > 0 ? { withheldRuleIds: [...input.withheldRuleIds].sort() } : {},
|
|
114
138
|
...truncationReasons.length > 0 ? { truncationReasons } : {},
|
|
115
139
|
...degradations.length > 0 ? { degradations } : {},
|
|
116
140
|
reasons: [...reasons].sort()
|
|
117
141
|
}
|
|
118
142
|
};
|
|
119
143
|
}
|
|
120
|
-
|
|
121
|
-
//#region src/engine/degradation-ledger.ts
|
|
144
|
+
// src/engine/degradation-ledger.ts
|
|
122
145
|
/**
|
|
123
146
|
* Append-only. Records are never edited and never removed by a recording
|
|
124
147
|
* site; a reader takes a window, and the window is defined by index, so
|
|
@@ -154,8 +177,7 @@ function summarizeDegradations(window) {
|
|
|
154
177
|
count
|
|
155
178
|
})).sort((a, b) => a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0);
|
|
156
179
|
}
|
|
157
|
-
|
|
158
|
-
//#region src/engine/runtime-corroboration.ts
|
|
180
|
+
// src/engine/runtime-corroboration.ts
|
|
159
181
|
/**
|
|
160
182
|
* Stamp runtime corroboration + trust levels onto findings (mutates in
|
|
161
183
|
* place, the same contract as stampEvidenceLevels). Returns the number
|
|
@@ -237,8 +259,7 @@ function deriveTrustLevel(finding, corroboration) {
|
|
|
237
259
|
if (corroboration.level === "test") return "L4";
|
|
238
260
|
return "L3";
|
|
239
261
|
}
|
|
240
|
-
|
|
241
|
-
//#region src/rules/measured-fp.generated.ts
|
|
262
|
+
// src/rules/measured-fp.generated.ts
|
|
242
263
|
const MEASURED_FP = {
|
|
243
264
|
"QA-CI-001": {
|
|
244
265
|
fpRate: .105,
|
|
@@ -375,10 +396,10 @@ const MEASURED_FP = {
|
|
|
375
396
|
},
|
|
376
397
|
"QA-JV-101": {
|
|
377
398
|
fpRate: 0,
|
|
378
|
-
n:
|
|
399
|
+
n: 35,
|
|
379
400
|
detectorRevision: 1,
|
|
380
401
|
ciLow: 0,
|
|
381
|
-
ciHigh: .
|
|
402
|
+
ciHigh: .0989
|
|
382
403
|
},
|
|
383
404
|
"QA-JV-102": {
|
|
384
405
|
fpRate: .261,
|
|
@@ -494,10 +515,10 @@ const MEASURED_FP = {
|
|
|
494
515
|
},
|
|
495
516
|
"QA-PW-117": {
|
|
496
517
|
fpRate: 0,
|
|
497
|
-
n:
|
|
518
|
+
n: 35,
|
|
498
519
|
detectorRevision: 1,
|
|
499
520
|
ciLow: 0,
|
|
500
|
-
ciHigh: .
|
|
521
|
+
ciHigh: .0989
|
|
501
522
|
},
|
|
502
523
|
"QA-PW-121": {
|
|
503
524
|
fpRate: 0,
|
|
@@ -584,25 +605,18 @@ const MEASURED_FP = {
|
|
|
584
605
|
ciHigh: .2425
|
|
585
606
|
},
|
|
586
607
|
"QA-PY-002": {
|
|
587
|
-
fpRate: .
|
|
588
|
-
n:
|
|
608
|
+
fpRate: .12,
|
|
609
|
+
n: 25,
|
|
589
610
|
detectorRevision: 1,
|
|
590
|
-
ciLow: .
|
|
591
|
-
ciHigh: .
|
|
611
|
+
ciLow: .0417,
|
|
612
|
+
ciHigh: .2996
|
|
592
613
|
},
|
|
593
614
|
"QA-PY-003": {
|
|
594
|
-
fpRate: .
|
|
595
|
-
n:
|
|
615
|
+
fpRate: .515,
|
|
616
|
+
n: 33,
|
|
596
617
|
detectorRevision: 4,
|
|
597
|
-
ciLow: .
|
|
598
|
-
ciHigh: .
|
|
599
|
-
},
|
|
600
|
-
"QA-PY-004": {
|
|
601
|
-
fpRate: .533,
|
|
602
|
-
n: 30,
|
|
603
|
-
detectorRevision: 3,
|
|
604
|
-
ciLow: .3614,
|
|
605
|
-
ciHigh: .6977
|
|
618
|
+
ciLow: .3522,
|
|
619
|
+
ciHigh: .675
|
|
606
620
|
},
|
|
607
621
|
"QA-PY-005": {
|
|
608
622
|
fpRate: .13,
|
|
@@ -759,8 +773,7 @@ const MEASURED_FP = {
|
|
|
759
773
|
ciHigh: .4343
|
|
760
774
|
}
|
|
761
775
|
};
|
|
762
|
-
|
|
763
|
-
//#region src/engine/trust-summary.ts
|
|
776
|
+
// src/engine/trust-summary.ts
|
|
764
777
|
/**
|
|
765
778
|
* Trust level of the summary — the BEST trust level any finding in the
|
|
766
779
|
* scan reached. Static-only findings settle at L2 (deriveTrustLevel's
|
|
@@ -884,8 +897,58 @@ function buildTrustSummary(result, declarationsByFile) {
|
|
|
884
897
|
ceilingReasons: reasons
|
|
885
898
|
};
|
|
886
899
|
}
|
|
887
|
-
|
|
888
|
-
|
|
900
|
+
// src/governance/run-mode.ts
|
|
901
|
+
/**
|
|
902
|
+
* Which mode a run is in, and how the other one is reached.
|
|
903
|
+
*
|
|
904
|
+
* ADR 0012: `local-only` is the default and hosted mode is a declared state.
|
|
905
|
+
* This module is the enforcement point, and its shape is the decision:
|
|
906
|
+
*
|
|
907
|
+
* - `resolveRunMode` takes its inputs as an ARGUMENT, never from
|
|
908
|
+
* `process.env`. Nothing here can read the environment, so a stray
|
|
909
|
+
* `MJOLNIR_MODE` in a shell, in CI, or in a parent's environment cannot
|
|
910
|
+
* change what a run does. The first version of the test passed an `env`
|
|
911
|
+
* bag and asserted it was ignored — and that only means something because
|
|
912
|
+
* this function is structurally unable to consult one.
|
|
913
|
+
* - `isHostedModeReachable` is the single predicate every entry point has to
|
|
914
|
+
* pass through, and it answers the same question separately from
|
|
915
|
+
* `resolveRunMode`. Separating them is what stops a caller satisfying the
|
|
916
|
+
* easy one: the first says whether the mode MAY be entered, the second
|
|
917
|
+
* says which mode this run IS.
|
|
918
|
+
* - an unrecognised declaration THROWS. A typo in a build flag that fell back
|
|
919
|
+
* to `local-only` would mean a deployment believed it was hosted and was
|
|
920
|
+
* not — the same class of failure as a gate that reports success for
|
|
921
|
+
* something it did not run.
|
|
922
|
+
*
|
|
923
|
+
* Hosted mode is not implemented. This is the contract it will have to
|
|
924
|
+
* satisfy, and it is deliberately the smallest thing that can be wrong in only
|
|
925
|
+
* one direction.
|
|
926
|
+
*/
|
|
927
|
+
/**
|
|
928
|
+
* The build-time declaration that opts a build into hosted mode.
|
|
929
|
+
*
|
|
930
|
+
* A build flag rather than an environment variable, because a build flag is
|
|
931
|
+
* something a release engineer sets deliberately and reviews in a diff, and
|
|
932
|
+
* because an environment variable is set by whatever CI last happened to run
|
|
933
|
+
* with.
|
|
934
|
+
*/
|
|
935
|
+
const HOSTED_MODE_FLAG = "mjolnir-mode";
|
|
936
|
+
/**
|
|
937
|
+
* The mode this run is in.
|
|
938
|
+
*
|
|
939
|
+
* Throws on a declaration it does not recognise. A silent fallback here is the
|
|
940
|
+
* failure ADR 0012 exists to prevent: a build that asked for hosted mode,
|
|
941
|
+
* mistyped the flag, and ran local-only would report local-only artefacts into
|
|
942
|
+
* a pipeline that believed otherwise.
|
|
943
|
+
*/
|
|
944
|
+
function resolveRunMode(input) {
|
|
945
|
+
const declared = input.flags[HOSTED_MODE_FLAG];
|
|
946
|
+
if (declared === void 0 || declared === false) return "local-only";
|
|
947
|
+
if (declared === true) throw new Error(`${HOSTED_MODE_FLAG} was given without a value. Use "${HOSTED_MODE_FLAG}=hosted" or omit it.`);
|
|
948
|
+
if (declared === "local-only" || declared === "hosted") return declared;
|
|
949
|
+
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.`);
|
|
950
|
+
}
|
|
951
|
+
// src/engine/run-identity.ts
|
|
889
952
|
/**
|
|
890
953
|
* Run Identity (product-gap master plan §7, plan 1789009691197 R4c) —
|
|
891
954
|
* the deterministic binding that anchors every trust claim to its
|
|
@@ -925,6 +988,7 @@ function buildRunIdentity(input) {
|
|
|
925
988
|
if (input.commit !== void 0) verdictInputs.commit = input.commit;
|
|
926
989
|
if (input.tree !== void 0) verdictInputs.tree = input.tree;
|
|
927
990
|
if (input.lockfile !== void 0) verdictInputs.lockfile = input.lockfile;
|
|
991
|
+
if (input.runMode !== void 0) verdictInputs.runMode = input.runMode;
|
|
928
992
|
if (input.candidate !== void 0) verdictInputs.candidate = {
|
|
929
993
|
manifestId: input.candidate.manifestId,
|
|
930
994
|
state: input.candidate.state,
|
|
@@ -957,6 +1021,7 @@ function buildRunIdentity(input) {
|
|
|
957
1021
|
rulesDigest,
|
|
958
1022
|
configFingerprint,
|
|
959
1023
|
engineVersion: input.engineVersion,
|
|
1024
|
+
...input.runMode !== void 0 ? { runMode: input.runMode } : {},
|
|
960
1025
|
boundLinks,
|
|
961
1026
|
...input.trustModelVersion !== void 0 ? { trustModelVersion: input.trustModelVersion } : {},
|
|
962
1027
|
...input.scoringModelVersion !== void 0 ? { scoringModelVersion: input.scoringModelVersion } : {},
|
|
@@ -1012,8 +1077,7 @@ function buildEvidenceGraph(parts) {
|
|
|
1012
1077
|
} } : {}
|
|
1013
1078
|
};
|
|
1014
1079
|
}
|
|
1015
|
-
|
|
1016
|
-
//#region src/engine/file-executor.ts
|
|
1080
|
+
// src/engine/file-executor.ts
|
|
1017
1081
|
function cacheHitOutcome(job) {
|
|
1018
1082
|
return {
|
|
1019
1083
|
path: job.path,
|
|
@@ -1104,8 +1168,7 @@ function createExecutor(name, concurrency, runOne) {
|
|
|
1104
1168
|
execute: runOne
|
|
1105
1169
|
};
|
|
1106
1170
|
}
|
|
1107
|
-
|
|
1108
|
-
//#region src/scope/git-resolve.ts
|
|
1171
|
+
// src/scope/git-resolve.ts
|
|
1109
1172
|
/**
|
|
1110
1173
|
* Absolute-path git resolution (audit S1).
|
|
1111
1174
|
*
|
|
@@ -1180,8 +1243,7 @@ function runGit(root, args) {
|
|
|
1180
1243
|
return null;
|
|
1181
1244
|
}
|
|
1182
1245
|
}
|
|
1183
|
-
|
|
1184
|
-
//#region src/lib/safe-json.ts
|
|
1246
|
+
// src/lib/safe-json.ts
|
|
1185
1247
|
/**
|
|
1186
1248
|
* Reusable safe-JSON parse-and-validate helper.
|
|
1187
1249
|
*
|
|
@@ -1218,8 +1280,7 @@ function isRecord(v) {
|
|
|
1218
1280
|
return false;
|
|
1219
1281
|
}
|
|
1220
1282
|
}
|
|
1221
|
-
|
|
1222
|
-
//#region src/engine/candidate-binding.ts
|
|
1283
|
+
// src/engine/candidate-binding.ts
|
|
1223
1284
|
/**
|
|
1224
1285
|
* Candidate binding (plan V5-010).
|
|
1225
1286
|
*
|
|
@@ -1352,32 +1413,69 @@ function readCandidateBinding(root, manifestPath = CANDIDATE_MANIFEST_PATH) {
|
|
|
1352
1413
|
return null;
|
|
1353
1414
|
}
|
|
1354
1415
|
}
|
|
1355
|
-
|
|
1356
|
-
//#region src/engine/version.ts
|
|
1416
|
+
// src/engine/version.ts
|
|
1357
1417
|
/**
|
|
1358
1418
|
* Engine version, as a leaf module: `runIdentity` (R4c) needs the engine
|
|
1359
1419
|
* version inside scan-pipeline WITHOUT importing cli.ts (a cycle — cli
|
|
1360
1420
|
* imports the pipeline). The literal follows the same discipline as
|
|
1361
1421
|
* CLI_VERSION and SARIF's driver.version: kept in sync on release by
|
|
1362
|
-
*
|
|
1422
|
+
* `npm run version:surface:sync` (this file is an identity surface) and
|
|
1423
|
+
* guarded by the version-consistency
|
|
1363
1424
|
* spec. cli.ts re-exports this as CLI_VERSION.
|
|
1364
1425
|
*/
|
|
1365
|
-
const ENGINE_VERSION = "
|
|
1366
|
-
|
|
1367
|
-
//#region src/engine/contract-versions.ts
|
|
1426
|
+
const ENGINE_VERSION = "6.0.1";
|
|
1427
|
+
resolveBuildId();
|
|
1368
1428
|
/**
|
|
1369
|
-
*
|
|
1429
|
+
* The decision, with the process boundary already behind `runGit`.
|
|
1370
1430
|
*
|
|
1371
|
-
*
|
|
1372
|
-
*
|
|
1373
|
-
*
|
|
1374
|
-
|
|
1375
|
-
|
|
1431
|
+
* Separated so every branch is reachable from a test: a runner that throws and
|
|
1432
|
+
* a runner that answers are both things that happen, and both are what this
|
|
1433
|
+
* function exists to survive. What it must never do is invent an answer.
|
|
1434
|
+
*/
|
|
1435
|
+
function buildIdFrom(runGit) {
|
|
1436
|
+
const sha = runGit([
|
|
1437
|
+
"rev-parse",
|
|
1438
|
+
"--short=12",
|
|
1439
|
+
"HEAD"
|
|
1440
|
+
]);
|
|
1441
|
+
if (!sha) return void 0;
|
|
1442
|
+
const status = runGit(["status", "--porcelain"]);
|
|
1443
|
+
if (status === void 0) return `${sha}-dirty`;
|
|
1444
|
+
return status ? `${sha}-dirty` : sha;
|
|
1445
|
+
}
|
|
1446
|
+
function resolveBuildId() {
|
|
1447
|
+
const git = resolveGitPath();
|
|
1448
|
+
if (!git) return void 0;
|
|
1449
|
+
const runGit = (args) => {
|
|
1450
|
+
try {
|
|
1451
|
+
return execFileSync(git, args, {
|
|
1452
|
+
encoding: "utf8",
|
|
1453
|
+
timeout: 2e3,
|
|
1454
|
+
stdio: [
|
|
1455
|
+
"ignore",
|
|
1456
|
+
"pipe",
|
|
1457
|
+
"ignore"
|
|
1458
|
+
]
|
|
1459
|
+
}).trim();
|
|
1460
|
+
} catch {
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
};
|
|
1464
|
+
return buildIdFrom(runGit);
|
|
1465
|
+
}
|
|
1466
|
+
// src/engine/contract-versions.ts
|
|
1376
1467
|
const TRUST_MODEL_VERSION = "1.0.0";
|
|
1377
|
-
|
|
1468
|
+
/**
|
|
1469
|
+
* 2.0.0 (ADR 0014): the core tier carries an evidence FLOOR. A core rule's
|
|
1470
|
+
* finding is now stamped to at least E1, which changes `deductionFor` for
|
|
1471
|
+
* every core finding from 0 (E0) to `floor(base/2)`. The E0→E1→E2 ladder
|
|
1472
|
+
* itself is unchanged; what changed is which rung a core finding is allowed
|
|
1473
|
+
* to sit on, and therefore what it costs. Major, per the `semver + ADR
|
|
1474
|
+
* required` policy on the entry below.
|
|
1475
|
+
*/
|
|
1476
|
+
const SCORING_MODEL_VERSION = "2.0.0";
|
|
1378
1477
|
const FRAMEWORK_SUPPORT_MATRIX_VERSION = "1.0.0";
|
|
1379
|
-
|
|
1380
|
-
//#region src/discovery/evidence-discovery.ts
|
|
1478
|
+
// src/discovery/evidence-discovery.ts
|
|
1381
1479
|
/**
|
|
1382
1480
|
* Zero-config evidence discovery (Mega MVP Master Plan v3.1 §26 WI-11).
|
|
1383
1481
|
*
|
|
@@ -1421,6 +1519,11 @@ const EVIDENCE_CONVENTIONS = [
|
|
|
1421
1519
|
convention: "junit-file",
|
|
1422
1520
|
kind: "file",
|
|
1423
1521
|
names: ["junit-report.xml", "junit.xml"]
|
|
1522
|
+
},
|
|
1523
|
+
{
|
|
1524
|
+
convention: "dotnet-trx",
|
|
1525
|
+
kind: "dir",
|
|
1526
|
+
names: ["TestResults"]
|
|
1424
1527
|
}
|
|
1425
1528
|
];
|
|
1426
1529
|
const SEARCH_DEPTH = 2;
|
|
@@ -1464,8 +1567,7 @@ function discoverEvidenceCandidates(scanRoot) {
|
|
|
1464
1567
|
}
|
|
1465
1568
|
return out.sort((a, b) => a.depth - b.depth || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
1466
1569
|
}
|
|
1467
|
-
|
|
1468
|
-
//#region src/discovery/workspace.ts
|
|
1570
|
+
// src/discovery/workspace.ts
|
|
1469
1571
|
/**
|
|
1470
1572
|
* Repository discovery (Sprint-Plan W1-03).
|
|
1471
1573
|
* Finds project root, parses package.json, detects npm/yarn/pnpm workspaces.
|
|
@@ -1512,8 +1614,7 @@ function basename$1(p) {
|
|
|
1512
1614
|
const parts = p.split(/[\\/]/);
|
|
1513
1615
|
return parts[parts.length - 1];
|
|
1514
1616
|
}
|
|
1515
|
-
|
|
1516
|
-
//#region src/lib/fs-bounded.ts
|
|
1617
|
+
// src/lib/fs-bounded.ts
|
|
1517
1618
|
function closeQuiet(fd) {
|
|
1518
1619
|
try {
|
|
1519
1620
|
closeSync(fd);
|
|
@@ -1565,8 +1666,7 @@ function readFileBounded(path, maxBytes) {
|
|
|
1565
1666
|
if (fd !== void 0) closeQuiet(fd);
|
|
1566
1667
|
}
|
|
1567
1668
|
}
|
|
1568
|
-
|
|
1569
|
-
//#region src/config/config-schema.ts
|
|
1669
|
+
// src/config/config-schema.ts
|
|
1570
1670
|
const VALID_GATES = /* @__PURE__ */ new Set([
|
|
1571
1671
|
"advisory",
|
|
1572
1672
|
"error",
|
|
@@ -1636,8 +1736,7 @@ function validateConfigSchema(config) {
|
|
|
1636
1736
|
errors
|
|
1637
1737
|
};
|
|
1638
1738
|
}
|
|
1639
|
-
|
|
1640
|
-
//#region src/config/config.ts
|
|
1739
|
+
// src/config/config.ts
|
|
1641
1740
|
/**
|
|
1642
1741
|
* Config file support (Sprint-Plan W7, Product-MVP §27 + GAP-F).
|
|
1643
1742
|
* Zero-config preserved: config presence never changes detection
|
|
@@ -1771,8 +1870,107 @@ function applySeverityOverrides(findings, cfg) {
|
|
|
1771
1870
|
if (override && SEVERITY_ORDER.includes(override)) f.severity = override;
|
|
1772
1871
|
}
|
|
1773
1872
|
}
|
|
1774
|
-
|
|
1775
|
-
|
|
1873
|
+
// src/lib/compare.ts
|
|
1874
|
+
/**
|
|
1875
|
+
* Order that does not depend on who is asking.
|
|
1876
|
+
*
|
|
1877
|
+
* `String.prototype.localeCompare` with no locale argument resolves against
|
|
1878
|
+
* the PROCESS's default locale — the ICU collation chosen by `LANG`,
|
|
1879
|
+
* `LC_ALL` or the OS. So a sort written as
|
|
1880
|
+
* `[...xs].sort((a, b) => a.localeCompare(b))` produces a different order on
|
|
1881
|
+
* a developer's `de_DE` machine than on an en-US CI runner. For a display
|
|
1882
|
+
* list that is a nuisance; for anything that reaches a hash, a cache key, a
|
|
1883
|
+
* digest or a machine contract it is a correctness bug, because the same
|
|
1884
|
+
* repository then produces two different fingerprints.
|
|
1885
|
+
*
|
|
1886
|
+
* Two comparators, one per kind of site:
|
|
1887
|
+
*
|
|
1888
|
+
* compareCodePoints — pure UTF-16 code-unit order. Identical everywhere,
|
|
1889
|
+
* independent of ICU version, and already what `Array.prototype.sort`
|
|
1890
|
+
* defaults to. Use for every value that reaches a fingerprint, a digest,
|
|
1891
|
+
* a cache key, or a contract, and for any ordering with no human-facing
|
|
1892
|
+
* reason to be pretty.
|
|
1893
|
+
*
|
|
1894
|
+
* compareLocalized(locale) — the same collator, PINNED to a named locale
|
|
1895
|
+
* instead of inheriting the environment, for a surface that genuinely
|
|
1896
|
+
* wants readable order (a table of rule titles, a Mermaid legend). Pinned
|
|
1897
|
+
* rather than removed because a reader-facing list of "Test / Logic /
|
|
1898
|
+
* Mock" should not be ordered by code unit.
|
|
1899
|
+
*
|
|
1900
|
+
* A bare `localeCompare` with no second argument in `src/` is a defect and
|
|
1901
|
+
* `tests/contract/deterministic-ordering.spec.ts` fails the build on it.
|
|
1902
|
+
*/
|
|
1903
|
+
/** Code-unit order. Locale-free, ICU-free, and the Array.sort default. */
|
|
1904
|
+
function compareCodePoints(a, b) {
|
|
1905
|
+
if (a === b) return 0;
|
|
1906
|
+
return a < b ? -1 : 1;
|
|
1907
|
+
}
|
|
1908
|
+
/**
|
|
1909
|
+
* A readable-order comparator pinned to `locale`.
|
|
1910
|
+
*
|
|
1911
|
+
* The pin is the point: `a.localeCompare(b)` reads the environment, and the
|
|
1912
|
+
* environment is a machine setting, not a property of the code. Callers that
|
|
1913
|
+
* want stable output ask for a named locale and get the same answer on every
|
|
1914
|
+
* machine that ships the same ICU data.
|
|
1915
|
+
*/
|
|
1916
|
+
function compareLocalized(locale) {
|
|
1917
|
+
return (a, b) => a.localeCompare(b, locale);
|
|
1918
|
+
}
|
|
1919
|
+
// src/lib/glob.ts
|
|
1920
|
+
/**
|
|
1921
|
+
* The shared segment compiler: a glob to a regex BODY, with no anchors.
|
|
1922
|
+
*
|
|
1923
|
+
* Module-private because the anchoring is a caller decision and the DIALECT is
|
|
1924
|
+
* not: the repository needs a full-path match and a bare-name-at-any-depth
|
|
1925
|
+
* match, and duplicating the segment walk to get the second one is exactly how
|
|
1926
|
+
* two compilers came to disagree. Callers pick an anchoring; the dialect is
|
|
1927
|
+
* decided here and nowhere else.
|
|
1928
|
+
*
|
|
1929
|
+
* `?`, `[`, `]`, `!` and every other regex metacharacter in the literal parts
|
|
1930
|
+
* are escaped, so nothing a user writes in a config or ledger can reach the
|
|
1931
|
+
* regex engine as syntax. `*` is the only metacharacter.
|
|
1932
|
+
*/
|
|
1933
|
+
function globBody(glob) {
|
|
1934
|
+
const segments = glob.replaceAll("\\", "/").split("/");
|
|
1935
|
+
let re = "";
|
|
1936
|
+
for (const [i, segment] of segments.entries()) {
|
|
1937
|
+
const last = i === segments.length - 1;
|
|
1938
|
+
if (segment === "**") {
|
|
1939
|
+
re += last ? "(?:[^/]+/)*[^/]+" : "(?:[^/]+/)*";
|
|
1940
|
+
continue;
|
|
1941
|
+
}
|
|
1942
|
+
re += segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", "[^/]*");
|
|
1943
|
+
if (!last) re += "/";
|
|
1944
|
+
}
|
|
1945
|
+
return re;
|
|
1946
|
+
}
|
|
1947
|
+
/** A glob as a regex anchored to the WHOLE path. */
|
|
1948
|
+
function anchoredGlobRegExp(glob) {
|
|
1949
|
+
return new RegExp(`^${globBody(glob)}$`);
|
|
1950
|
+
}
|
|
1951
|
+
/**
|
|
1952
|
+
* A glob as a regex matching at ANY DEPTH — a bare name.
|
|
1953
|
+
*
|
|
1954
|
+
* `node_modules` becomes "a file or directory of this name anywhere", which is
|
|
1955
|
+
* gitignore semantics: every path inside the directory contains the segment, so
|
|
1956
|
+
* a directory match carries to its contents.
|
|
1957
|
+
*/
|
|
1958
|
+
function anyDepthGlobRegExp(glob) {
|
|
1959
|
+
return new RegExp(`(?:^|/)${globBody(glob)}(?:/|$)`);
|
|
1960
|
+
}
|
|
1961
|
+
/**
|
|
1962
|
+
* Whether `path` matches `glob`, in the dialect documented above.
|
|
1963
|
+
*
|
|
1964
|
+
* Both sides are normalized to forward slashes first. Finding paths are already
|
|
1965
|
+
* normalized by the walker, but a suppression `files` pattern written on
|
|
1966
|
+
* Windows ("e2e\\x.spec.ts") compiled to a literal-backslash regex that could
|
|
1967
|
+
* never match any finding — the suppression silently never applied, which is
|
|
1968
|
+
* the defect that made the whole dialect worth unifying.
|
|
1969
|
+
*/
|
|
1970
|
+
function pathMatchesGlob(path, glob) {
|
|
1971
|
+
return anchoredGlobRegExp(glob).test(path.replaceAll("\\", "/"));
|
|
1972
|
+
}
|
|
1973
|
+
// src/discovery/ignores.ts
|
|
1776
1974
|
/**
|
|
1777
1975
|
* Ignore resolution (Product-MVP.txt R1 — Tier-1 launch blocker).
|
|
1778
1976
|
*
|
|
@@ -1906,32 +2104,12 @@ function compilePattern(raw) {
|
|
|
1906
2104
|
if (pattern.trim() === "") return null;
|
|
1907
2105
|
if (pattern.length > LIMITS$1.maxPatternLength) return null;
|
|
1908
2106
|
if ((pattern.match(/[*?]/g) ?? []).length > LIMITS$1.maxPatternWildcards) return null;
|
|
1909
|
-
const re = pattern.includes("/") ?
|
|
2107
|
+
const re = pattern.includes("/") ? anchoredGlobRegExp(pattern) : anyDepthGlobRegExp(pattern);
|
|
1910
2108
|
return {
|
|
1911
2109
|
negated,
|
|
1912
2110
|
re
|
|
1913
2111
|
};
|
|
1914
2112
|
}
|
|
1915
|
-
/** The minimal glob dialect as a regex body without anchors. */
|
|
1916
|
-
function globBody(glob) {
|
|
1917
|
-
let re = "";
|
|
1918
|
-
for (let i = 0; i < glob.length; i++) {
|
|
1919
|
-
const c = glob[i];
|
|
1920
|
-
if (c === "*") {
|
|
1921
|
-
if (glob[i + 1] === "*") {
|
|
1922
|
-
if (glob[i + 2] === "/") {
|
|
1923
|
-
re += "(?:[^/]*/)*";
|
|
1924
|
-
i += 2;
|
|
1925
|
-
} else {
|
|
1926
|
-
re += ".*";
|
|
1927
|
-
i += 1;
|
|
1928
|
-
}
|
|
1929
|
-
} else re += "[^/]*";
|
|
1930
|
-
} else if (c === "?") re += "[^/]";
|
|
1931
|
-
else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
1932
|
-
}
|
|
1933
|
-
return re;
|
|
1934
|
-
}
|
|
1935
2113
|
createMatcherFromPatterns([]);
|
|
1936
2114
|
/**
|
|
1937
2115
|
* Check if a directory is a lint-fixture shape (Phase 2 auto-detection).
|
|
@@ -1948,8 +2126,7 @@ function isLintFixtureDir(absoluteDir) {
|
|
|
1948
2126
|
return false;
|
|
1949
2127
|
}
|
|
1950
2128
|
}
|
|
1951
|
-
|
|
1952
|
-
//#region src/discovery/shared-walk.ts
|
|
2129
|
+
// src/discovery/shared-walk.ts
|
|
1953
2130
|
/**
|
|
1954
2131
|
* Shared directory walker (audit P-2).
|
|
1955
2132
|
*
|
|
@@ -2012,8 +2189,7 @@ function sharedWalk(options) {
|
|
|
2012
2189
|
};
|
|
2013
2190
|
walk(options.root);
|
|
2014
2191
|
}
|
|
2015
|
-
|
|
2016
|
-
//#region src/discovery/frameworks.ts
|
|
2192
|
+
// src/discovery/frameworks.ts
|
|
2017
2193
|
/**
|
|
2018
2194
|
* Framework detection via config resolution (Sprint-Plan W3-01, R8).
|
|
2019
2195
|
*
|
|
@@ -2096,8 +2272,7 @@ function detectFrameworks(ws) {
|
|
|
2096
2272
|
unknown: false
|
|
2097
2273
|
};
|
|
2098
2274
|
}
|
|
2099
|
-
|
|
2100
|
-
//#region src/engine/ts-ast.ts
|
|
2275
|
+
// src/engine/ts-ast.ts
|
|
2101
2276
|
/**
|
|
2102
2277
|
* AST precision layer (Upgrade-Plan-v3 Phase 3).
|
|
2103
2278
|
*
|
|
@@ -2108,16 +2283,29 @@ function detectFrameworks(ws) {
|
|
|
2108
2283
|
* Migration discipline (per the plan): rule-by-rule, golden lock must stay
|
|
2109
2284
|
* byte-identical — a score shift is a regression, not an improvement.
|
|
2110
2285
|
*/
|
|
2111
|
-
let project = null;
|
|
2112
2286
|
/**
|
|
2113
|
-
*
|
|
2287
|
+
* One shared Project PER SCAN keeps memory bounded while reusing the compiler's
|
|
2288
|
+
* program across files.
|
|
2114
2289
|
*
|
|
2115
|
-
*
|
|
2116
|
-
*
|
|
2117
|
-
*
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2290
|
+
* "Per scan" has to mean per scan. The variable lived at module scope, so it
|
|
2291
|
+
* was one Project per PROCESS, and two scans running concurrently shared it —
|
|
2292
|
+
* `parseTsFile` calls `replaceWithText` on a cached SourceFile, so scan B
|
|
2293
|
+
* could overwrite the text scan A was mid-rule on. ts-morph's in-memory file
|
|
2294
|
+
* system and language service are not reentrant, and the observable result was
|
|
2295
|
+
* three of four concurrent scans reporting `rulesCrashed: 4` with
|
|
2296
|
+
* `rules: "partial"` — different machine-contract digests from the same tree,
|
|
2297
|
+
* which is the one thing this product claims cannot happen.
|
|
2298
|
+
*
|
|
2299
|
+
* An AsyncLocalStorage scope is the smallest change that makes the comment
|
|
2300
|
+
* true: each scan's async context carries its own Project, memory stays bounded
|
|
2301
|
+
* per scan exactly as intended, and the context is collected when the scan's
|
|
2302
|
+
* promise chain is. The loose project below remains for callers outside a scan
|
|
2303
|
+
* (tests, one-off parses), where there is nothing to collide with.
|
|
2304
|
+
*/
|
|
2305
|
+
const scanProjects = new AsyncLocalStorage();
|
|
2306
|
+
let looseProject = null;
|
|
2307
|
+
function createProject() {
|
|
2308
|
+
return new Project({
|
|
2121
2309
|
useInMemoryFileSystem: true,
|
|
2122
2310
|
skipAddingFilesFromTsConfig: true,
|
|
2123
2311
|
compilerOptions: {
|
|
@@ -2126,7 +2314,28 @@ function getProject() {
|
|
|
2126
2314
|
noEmit: true
|
|
2127
2315
|
}
|
|
2128
2316
|
});
|
|
2129
|
-
|
|
2317
|
+
}
|
|
2318
|
+
/**
|
|
2319
|
+
* Run `fn` with its own ts-morph Project.
|
|
2320
|
+
*
|
|
2321
|
+
* Async context propagates across `await`, so the Project stays the scan's own
|
|
2322
|
+
* for the whole scan without threading an argument through every adapter.
|
|
2323
|
+
*/
|
|
2324
|
+
function runWithScanProject(fn) {
|
|
2325
|
+
return scanProjects.run(createProject(), fn);
|
|
2326
|
+
}
|
|
2327
|
+
/**
|
|
2328
|
+
* The ts-morph Project for the current scan.
|
|
2329
|
+
*
|
|
2330
|
+
* Exported so an adapter's `dispose()` can evict the file it parsed: ts-morph
|
|
2331
|
+
* caches by file path, so without eviction a long scan holds every parsed
|
|
2332
|
+
* SourceFile until the process exits (plan V5-021).
|
|
2333
|
+
*/
|
|
2334
|
+
function getProject() {
|
|
2335
|
+
const scoped = scanProjects.getStore();
|
|
2336
|
+
if (scoped !== void 0) return scoped;
|
|
2337
|
+
looseProject ??= createProject();
|
|
2338
|
+
return looseProject;
|
|
2130
2339
|
}
|
|
2131
2340
|
/**
|
|
2132
2341
|
* Parse (or fetch from cache) the ts-morph SourceFile for a scanned file.
|
|
@@ -2251,12 +2460,18 @@ function getCodeOnlyText(file) {
|
|
|
2251
2460
|
return file.text;
|
|
2252
2461
|
}
|
|
2253
2462
|
}
|
|
2254
|
-
/**
|
|
2463
|
+
/**
|
|
2464
|
+
* Release the loose ts-morph Project used outside a scan.
|
|
2465
|
+
*
|
|
2466
|
+
* Deliberately does NOT call `scanProjects.disable()`. Scan-scoped Projects are
|
|
2467
|
+
* released by garbage collection when the scan's async context ends, and
|
|
2468
|
+
* `disable()` would tear the store out from under a scan still running — the
|
|
2469
|
+
* exact corruption this scoping exists to remove.
|
|
2470
|
+
*/
|
|
2255
2471
|
function resetTsMorphProject() {
|
|
2256
|
-
|
|
2472
|
+
looseProject = null;
|
|
2257
2473
|
}
|
|
2258
|
-
|
|
2259
|
-
//#region src/engine/code-text.ts
|
|
2474
|
+
// src/engine/code-text.ts
|
|
2260
2475
|
/**
|
|
2261
2476
|
* Code-only text masking (Phase 1 — Tempering Plan).
|
|
2262
2477
|
*
|
|
@@ -2616,8 +2831,7 @@ function getCodeTextForCounting(text, language) {
|
|
|
2616
2831
|
function blankRange(chars, start, end) {
|
|
2617
2832
|
for (let i = start; i < end && i < chars.length; i++) if (chars[i] !== "\n" && chars[i] !== "\r") chars[i] = " ";
|
|
2618
2833
|
}
|
|
2619
|
-
|
|
2620
|
-
//#region src/engine/adapter.ts
|
|
2834
|
+
// src/engine/adapter.ts
|
|
2621
2835
|
/**
|
|
2622
2836
|
* The §15.1 framework filter, shared by every language adapter: a rule
|
|
2623
2837
|
* that declares `frameworks` runs on a file only when the file carries
|
|
@@ -2631,8 +2845,7 @@ function frameworkFilterApplies(rule, file) {
|
|
|
2631
2845
|
if (tags === void 0 || tags.length === 0) return true;
|
|
2632
2846
|
return rule.frameworks.some((f) => tags.includes(f));
|
|
2633
2847
|
}
|
|
2634
|
-
|
|
2635
|
-
//#region src/engine/shared-run-rules.ts
|
|
2848
|
+
// src/engine/shared-run-rules.ts
|
|
2636
2849
|
/**
|
|
2637
2850
|
* Run ONE rule with crash isolation, and return what it produced.
|
|
2638
2851
|
*
|
|
@@ -2669,8 +2882,7 @@ function runRuleIsolated(rule, file, onCrash) {
|
|
|
2669
2882
|
return null;
|
|
2670
2883
|
}
|
|
2671
2884
|
}
|
|
2672
|
-
|
|
2673
|
-
//#region src/adapters/typescript.ts
|
|
2885
|
+
// src/adapters/typescript.ts
|
|
2674
2886
|
/**
|
|
2675
2887
|
* TypeScript/JavaScript adapter (R1).
|
|
2676
2888
|
* Wraps the proven regex-based engine behind the LanguageAdapter
|
|
@@ -2707,10 +2919,18 @@ function runRuleIsolated(rule, file, onCrash) {
|
|
|
2707
2919
|
* four lines below: PW_CONFIG_RE already accepted `cts`. The config regex knew
|
|
2708
2920
|
* about the Node-native extensions; the test-file regex did not.
|
|
2709
2921
|
*
|
|
2710
|
-
* `discovery/scan-adapters.ts:isUnrecognizedSourceCandidate`
|
|
2711
|
-
*
|
|
2712
|
-
*
|
|
2713
|
-
*
|
|
2922
|
+
* `discovery/scan-adapters.ts:isUnrecognizedSourceCandidate` counts these
|
|
2923
|
+
* paths as uncovered surface, so the scope accounting reports a hole if one
|
|
2924
|
+
* exists.
|
|
2925
|
+
*
|
|
2926
|
+
* CORRECTION: the comment this replaced said the scope accounting "had been
|
|
2927
|
+
* reporting the hole the whole time". It had not — that checker's regex
|
|
2928
|
+
* already matched `mts`/`cts`, and only `extensions` was unread. What WAS
|
|
2929
|
+
* true, and is what this entry now fixes, is that a field every adapter
|
|
2930
|
+
* declares and no code consulted is a claim the interface makes to anyone who
|
|
2931
|
+
* reads it. `isUnrecognizedSourceCandidate` now builds its extension set from
|
|
2932
|
+
* this list rather than from a second hard-coded one, which is what makes the
|
|
2933
|
+
* two halves of the same fact stop being able to drift.
|
|
2714
2934
|
*/
|
|
2715
2935
|
const TEST_FILE_RE = /\.(?:test|spec)\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$|\.cy\.(?:js|jsx|ts|tsx)$/;
|
|
2716
2936
|
/**
|
|
@@ -2782,7 +3002,9 @@ const typescriptAdapter = {
|
|
|
2782
3002
|
".js",
|
|
2783
3003
|
".jsx",
|
|
2784
3004
|
".mjs",
|
|
2785
|
-
".cjs"
|
|
3005
|
+
".cjs",
|
|
3006
|
+
".mts",
|
|
3007
|
+
".cts"
|
|
2786
3008
|
],
|
|
2787
3009
|
testFileGlobs: [
|
|
2788
3010
|
"*.test.{js,jsx,ts,tsx,mjs,cjs}",
|
|
@@ -2913,8 +3135,7 @@ function loadWorkspaceShim(root) {
|
|
|
2913
3135
|
return null;
|
|
2914
3136
|
}
|
|
2915
3137
|
}
|
|
2916
|
-
|
|
2917
|
-
//#region src/engine/tree-sitter-ast.ts
|
|
3138
|
+
// src/engine/tree-sitter-ast.ts
|
|
2918
3139
|
/**
|
|
2919
3140
|
* Tree-sitter WASM AST layer for Java and C# — Sprint 8 Task 36
|
|
2920
3141
|
* (Master-Stabilization-Plan.md).
|
|
@@ -2993,7 +3214,11 @@ async function withParseSlot(fn) {
|
|
|
2993
3214
|
}
|
|
2994
3215
|
function grammarPath(fileName) {
|
|
2995
3216
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
2996
|
-
const candidates = [
|
|
3217
|
+
const candidates = [
|
|
3218
|
+
join(here, "..", "..", "node_modules", "tree-sitter-wasms", "out", fileName),
|
|
3219
|
+
join(here, "..", "node_modules", "tree-sitter-wasms", "out", fileName),
|
|
3220
|
+
join(here, "..", "..", "..", "node_modules", "tree-sitter-wasms", "out", fileName)
|
|
3221
|
+
];
|
|
2997
3222
|
return candidates.find((p) => existsSync(p)) ?? candidates[0];
|
|
2998
3223
|
}
|
|
2999
3224
|
async function ensureParserInitialized() {
|
|
@@ -3073,6 +3298,7 @@ async function parseJavaAst(text) {
|
|
|
3073
3298
|
return (await getJavaParser()).parse(text) ?? void 0;
|
|
3074
3299
|
});
|
|
3075
3300
|
} catch {
|
|
3301
|
+
recordDegradation("ast-grammar-unavailable");
|
|
3076
3302
|
return;
|
|
3077
3303
|
}
|
|
3078
3304
|
}
|
|
@@ -3086,6 +3312,7 @@ async function parseCSharpAst(text) {
|
|
|
3086
3312
|
return (await getCSharpParser()).parse(text) ?? void 0;
|
|
3087
3313
|
});
|
|
3088
3314
|
} catch {
|
|
3315
|
+
recordDegradation("ast-grammar-unavailable");
|
|
3089
3316
|
return;
|
|
3090
3317
|
}
|
|
3091
3318
|
}
|
|
@@ -3101,6 +3328,7 @@ async function parsePythonAst(text) {
|
|
|
3101
3328
|
return (await getPythonParser()).parse(text) ?? void 0;
|
|
3102
3329
|
});
|
|
3103
3330
|
} catch {
|
|
3331
|
+
recordDegradation("ast-grammar-unavailable");
|
|
3104
3332
|
return;
|
|
3105
3333
|
}
|
|
3106
3334
|
}
|
|
@@ -3126,8 +3354,7 @@ async function releaseTreeSitterResources() {
|
|
|
3126
3354
|
} catch {}
|
|
3127
3355
|
}
|
|
3128
3356
|
}
|
|
3129
|
-
|
|
3130
|
-
//#region src/adapters/python.ts
|
|
3357
|
+
// src/adapters/python.ts
|
|
3131
3358
|
/**
|
|
3132
3359
|
* Python/pytest adapter (Upgrade-Plan-v2 R2).
|
|
3133
3360
|
*
|
|
@@ -3199,7 +3426,9 @@ const pythonAdapter = {
|
|
|
3199
3426
|
if (existsSync(pyproject)) try {
|
|
3200
3427
|
const text = readText$2(pyproject);
|
|
3201
3428
|
if (/\[tool\.pytest/i.test(text)) frameworks.add("pytest");
|
|
3202
|
-
} catch {
|
|
3429
|
+
} catch {
|
|
3430
|
+
recordDegradation("python-manifest-unreadable");
|
|
3431
|
+
}
|
|
3203
3432
|
for (const reqFile of [
|
|
3204
3433
|
"requirements.txt",
|
|
3205
3434
|
"requirements-dev.txt",
|
|
@@ -3211,7 +3440,9 @@ const pythonAdapter = {
|
|
|
3211
3440
|
const text = readText$2(p);
|
|
3212
3441
|
if (/^selenium\b/im.test(text)) frameworks.add("selenium");
|
|
3213
3442
|
if (/^playwright\b/im.test(text)) frameworks.add("playwright");
|
|
3214
|
-
} catch {
|
|
3443
|
+
} catch {
|
|
3444
|
+
recordDegradation("python-manifest-unreadable");
|
|
3445
|
+
}
|
|
3215
3446
|
}
|
|
3216
3447
|
if (frameworks.size === 0) return {
|
|
3217
3448
|
frameworks: [],
|
|
@@ -3281,8 +3512,7 @@ const pythonAdapter = {
|
|
|
3281
3512
|
function readText$2(p) {
|
|
3282
3513
|
return readFileSync(p, "utf8");
|
|
3283
3514
|
}
|
|
3284
|
-
|
|
3285
|
-
//#region src/adapters/java.ts
|
|
3515
|
+
// src/adapters/java.ts
|
|
3286
3516
|
/**
|
|
3287
3517
|
* Java/JUnit adapter (Upgrade-Plan-v3 Phase 4).
|
|
3288
3518
|
*
|
|
@@ -3448,8 +3678,7 @@ function javaFileTags(file) {
|
|
|
3448
3678
|
function readText$1(path) {
|
|
3449
3679
|
return readFileSync(path, "utf8");
|
|
3450
3680
|
}
|
|
3451
|
-
|
|
3452
|
-
//#region src/adapters/csharp.ts
|
|
3681
|
+
// src/adapters/csharp.ts
|
|
3453
3682
|
/**
|
|
3454
3683
|
* C#/.NET adapter (Upgrade-Plan-v3 Phase 5).
|
|
3455
3684
|
*
|
|
@@ -3605,8 +3834,7 @@ function csharpFileTags(file) {
|
|
|
3605
3834
|
function readText(path) {
|
|
3606
3835
|
return readFileSync(path, "utf8");
|
|
3607
3836
|
}
|
|
3608
|
-
|
|
3609
|
-
//#region src/discovery/yaml-guards.ts
|
|
3837
|
+
// src/discovery/yaml-guards.ts
|
|
3610
3838
|
/**
|
|
3611
3839
|
* Shared hostile-YAML guards (product-gap master plan P3 common law —
|
|
3612
3840
|
* `azure-pipelines.yml` detection rides the same YAML machinery as the
|
|
@@ -3675,8 +3903,7 @@ function enforceDepthCap(value, max, depthMessage = GENERIC_LABELS.depthMessage)
|
|
|
3675
3903
|
});
|
|
3676
3904
|
}
|
|
3677
3905
|
}
|
|
3678
|
-
|
|
3679
|
-
//#region src/discovery/workflow-parser.ts
|
|
3906
|
+
// src/discovery/workflow-parser.ts
|
|
3680
3907
|
/**
|
|
3681
3908
|
* Safe GitHub Actions workflow parser (Phase 0.3, W4-03 debt).
|
|
3682
3909
|
*
|
|
@@ -3722,8 +3949,7 @@ function parseWorkflow(text) {
|
|
|
3722
3949
|
}
|
|
3723
3950
|
return { jobs };
|
|
3724
3951
|
}
|
|
3725
|
-
|
|
3726
|
-
//#region src/adapters/github-actions.ts
|
|
3952
|
+
// src/adapters/github-actions.ts
|
|
3727
3953
|
/**
|
|
3728
3954
|
* GitHub Actions adapter (R1).
|
|
3729
3955
|
* Owns workflow discovery and safe-YAML parsing; CI rules consume the
|
|
@@ -3808,8 +4034,7 @@ const githubActionsAdapter = {
|
|
|
3808
4034
|
};
|
|
3809
4035
|
var WorkflowParseSkipped = class extends Error {};
|
|
3810
4036
|
const LIMITS_MAX_BYTES$2 = 1048576;
|
|
3811
|
-
|
|
3812
|
-
//#region src/discovery/azure-pipeline-parser.ts
|
|
4037
|
+
// src/discovery/azure-pipeline-parser.ts
|
|
3813
4038
|
/**
|
|
3814
4039
|
* Safe Azure DevOps pipeline parser (product-gap master plan P3b).
|
|
3815
4040
|
*
|
|
@@ -4043,8 +4268,7 @@ function isAzurePipelineFixture(normalizedPath) {
|
|
|
4043
4268
|
const base = normalizedPath.replaceAll("\\", "/").split("/").pop() ?? "";
|
|
4044
4269
|
return AZURE_PIPELINE_FILENAMES_SET.has(base);
|
|
4045
4270
|
}
|
|
4046
|
-
|
|
4047
|
-
//#region src/adapters/azure-pipelines.ts
|
|
4271
|
+
// src/adapters/azure-pipelines.ts
|
|
4048
4272
|
/**
|
|
4049
4273
|
* Azure DevOps pipeline adapter (product-gap master plan P3b).
|
|
4050
4274
|
* Owns `azure-pipelines.yml` discovery (repo root, the platform's
|
|
@@ -4124,8 +4348,7 @@ const azurePipelinesAdapter = {
|
|
|
4124
4348
|
};
|
|
4125
4349
|
var AzureParseSkipped = class extends Error {};
|
|
4126
4350
|
const LIMITS_MAX_BYTES$1 = 1048576;
|
|
4127
|
-
|
|
4128
|
-
//#region src/adapters/jenkins.ts
|
|
4351
|
+
// src/adapters/jenkins.ts
|
|
4129
4352
|
/**
|
|
4130
4353
|
* Jenkins adapter (product-gap master plan P3c).
|
|
4131
4354
|
* The root `Jenkinsfile` is a TEXT-target file kind: declarative and
|
|
@@ -4199,8 +4422,7 @@ const jenkinsAdapter = {
|
|
|
4199
4422
|
}
|
|
4200
4423
|
};
|
|
4201
4424
|
const LIMITS_MAX_BYTES = 1048576;
|
|
4202
|
-
|
|
4203
|
-
//#region src/discovery/scan-adapters.ts
|
|
4425
|
+
// src/discovery/scan-adapters.ts
|
|
4204
4426
|
/**
|
|
4205
4427
|
* The canonical adapter registry (audits H-6/H-9).
|
|
4206
4428
|
*
|
|
@@ -4293,6 +4515,22 @@ function isScopeRelevantIgnored(path) {
|
|
|
4293
4515
|
if (!/\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml|min\.js)$/i.test(name)) return false;
|
|
4294
4516
|
return isKnownTestFile(path);
|
|
4295
4517
|
}
|
|
4518
|
+
/**
|
|
4519
|
+
* Every source extension any shipped adapter claims.
|
|
4520
|
+
*
|
|
4521
|
+
* This is where `LanguageAdapter.extensions` is READ. The field was declared by
|
|
4522
|
+
* every adapter, listed a set of extensions, and consulted by nothing in
|
|
4523
|
+
* `src/` — so a support claim that no code checked was still a claim the
|
|
4524
|
+
* interface made to every reader.
|
|
4525
|
+
*
|
|
4526
|
+
* Derived rather than hand-listed so the two halves cannot drift. A test
|
|
4527
|
+
* already compared the old hand-written regex against this set and found them
|
|
4528
|
+
* equivalent, which is the honest result: the refactor removes a second
|
|
4529
|
+
* source of truth rather than fixing a wrong answer. The `.mts`/`.cts` gap
|
|
4530
|
+
* was real but lived in `extensions`, not here, and is now closed there.
|
|
4531
|
+
*/
|
|
4532
|
+
const ADAPTER_CLAIMED_EXTENSIONS = new Set(SCAN_ADAPTERS.flatMap((adapter) => adapter.extensions).map((ext) => ext.replace(/^\./, "").toLowerCase()));
|
|
4533
|
+
/** Whether ignoring this file would have removed it from the scanned surface. */
|
|
4296
4534
|
function isUnrecognizedSourceCandidate(path) {
|
|
4297
4535
|
const normalized = path.replaceAll("\\", "/");
|
|
4298
4536
|
const name = normalized.split("/").pop() ?? normalized;
|
|
@@ -4307,8 +4545,9 @@ function isUnrecognizedSourceCandidate(path) {
|
|
|
4307
4545
|
"tsconfig",
|
|
4308
4546
|
"jsconfig"
|
|
4309
4547
|
].some((prefix) => stem === prefix || stem.startsWith(`${prefix}.`))) return false;
|
|
4310
|
-
|
|
4311
|
-
|
|
4548
|
+
if (!(/(?:^|\/)__tests__\//i.test(normalized) || /\.(?:spec|test)\.[cm]?[jt]sx?$/i.test(name))) return false;
|
|
4549
|
+
const extension = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
|
|
4550
|
+
return ADAPTER_CLAIMED_EXTENSIONS.has(extension);
|
|
4312
4551
|
}
|
|
4313
4552
|
/** Whether ANY shipped adapter would discover this path as a test file. */
|
|
4314
4553
|
function isKnownTestFile(path) {
|
|
@@ -4320,8 +4559,7 @@ function isInsideSkippedDir(root, absPath, skipNames) {
|
|
|
4320
4559
|
return relative(root, absPath).replaceAll("\\", "/").split("/").slice(0, -1).some((seg) => skipNames.includes(seg));
|
|
4321
4560
|
}
|
|
4322
4561
|
typescriptAdapter.testFileGlobs, pythonAdapter.testFileGlobs, javaAdapter.testFileGlobs, csharpAdapter.testFileGlobs, githubActionsAdapter.testFileGlobs, azurePipelinesAdapter.testFileGlobs, jenkinsAdapter.testFileGlobs;
|
|
4323
|
-
|
|
4324
|
-
//#region src/scope/changed.ts
|
|
4562
|
+
// src/scope/changed.ts
|
|
4325
4563
|
/**
|
|
4326
4564
|
* Changed-scope engine (Sprint-Plan W6, Product-MVP §9 `--scope changed`).
|
|
4327
4565
|
*
|
|
@@ -4564,8 +4802,7 @@ function computeStagedFiles(root) {
|
|
|
4564
4802
|
if (raw === null) return null;
|
|
4565
4803
|
return raw.split("\0").filter((s) => s.length > 0);
|
|
4566
4804
|
}
|
|
4567
|
-
|
|
4568
|
-
//#region src/rules/rule.ts
|
|
4805
|
+
// src/rules/rule.ts
|
|
4569
4806
|
/**
|
|
4570
4807
|
* Invoke an `astQuery` hook with the mandatory-fallback contract: no
|
|
4571
4808
|
* hook or no AST on the context resolves to `undefined` (→ regex
|
|
@@ -4578,8 +4815,7 @@ function tryAstQuery(hook, ctx) {
|
|
|
4578
4815
|
function defineRule(rule) {
|
|
4579
4816
|
return rule;
|
|
4580
4817
|
}
|
|
4581
|
-
|
|
4582
|
-
//#region src/rules/shared/positions.ts
|
|
4818
|
+
// src/rules/shared/positions.ts
|
|
4583
4819
|
/**
|
|
4584
4820
|
* Shared position helpers (Phase 6 — Tempering Plan).
|
|
4585
4821
|
* One lineAt, one colAt, one matchBrace. The other 79 copies are dead.
|
|
@@ -4624,8 +4860,7 @@ function lineAt(text, index) {
|
|
|
4624
4860
|
function colAt(text, index) {
|
|
4625
4861
|
return index - (index <= 0 ? -1 : text.lastIndexOf("\n", index - 1));
|
|
4626
4862
|
}
|
|
4627
|
-
|
|
4628
|
-
//#region src/rules/test/qa-test-001-focused-test.ts
|
|
4863
|
+
// src/rules/test/qa-test-001-focused-test.ts
|
|
4629
4864
|
/**
|
|
4630
4865
|
* QA-TEST-001 — Focused test committed (`fit`/`fdescribe`/`.only`).
|
|
4631
4866
|
* Severity: error · Confidence: high · deterministic-defect
|
|
@@ -4691,8 +4926,7 @@ const focusedTestCommitted = defineRule({
|
|
|
4691
4926
|
return findings;
|
|
4692
4927
|
}
|
|
4693
4928
|
});
|
|
4694
|
-
|
|
4695
|
-
//#region src/rules/test/qa-test-002-skipped-test.ts
|
|
4929
|
+
// src/rules/test/qa-test-002-skipped-test.ts
|
|
4696
4930
|
/**
|
|
4697
4931
|
* QA-TEST-002 — Skipped test (`it.skip`, `xit`, `test.skip`, `describe.skip`).
|
|
4698
4932
|
* Severity: warning · Confidence: high · deterministic-defect
|
|
@@ -4748,8 +4982,7 @@ const skippedTest = defineRule({
|
|
|
4748
4982
|
return findings;
|
|
4749
4983
|
}
|
|
4750
4984
|
});
|
|
4751
|
-
|
|
4752
|
-
//#region src/rules/shared/masking.ts
|
|
4985
|
+
// src/rules/shared/masking.ts
|
|
4753
4986
|
/** True when the offset was blanked in codeText (inside a string or comment). */
|
|
4754
4987
|
function isMasked(ctx, index) {
|
|
4755
4988
|
const mask = ctx.codeText;
|
|
@@ -4810,8 +5043,7 @@ function isInsideEmbeddedCode(ctx, index) {
|
|
|
4810
5043
|
const hasCallSyntax = /\w\s*\(/.test(inner);
|
|
4811
5044
|
return hasNestedQuote && hasCallSyntax;
|
|
4812
5045
|
}
|
|
4813
|
-
|
|
4814
|
-
//#region src/rules/test/qa-test-003-no-assertions.ts
|
|
5046
|
+
// src/rules/test/qa-test-003-no-assertions.ts
|
|
4815
5047
|
/**
|
|
4816
5048
|
* QA-TEST-003 — Test with no assertions.
|
|
4817
5049
|
* Severity: error · Confidence: high · deterministic-defect
|
|
@@ -4889,8 +5121,7 @@ function matchBrace$2(text, open) {
|
|
|
4889
5121
|
}
|
|
4890
5122
|
return -1;
|
|
4891
5123
|
}
|
|
4892
|
-
|
|
4893
|
-
//#region src/rules/test/qa-test-004-hard-sleep.ts
|
|
5124
|
+
// src/rules/test/qa-test-004-hard-sleep.ts
|
|
4894
5125
|
/**
|
|
4895
5126
|
* QA-TEST-004 — Hard sleep (`sleep()`, `waitForTimeout()`, `setTimeout` as wait).
|
|
4896
5127
|
* Severity: warning · Confidence: high · deterministic-defect
|
|
@@ -4955,8 +5186,7 @@ const hardSleep = defineRule({
|
|
|
4955
5186
|
return findings;
|
|
4956
5187
|
}
|
|
4957
5188
|
});
|
|
4958
|
-
|
|
4959
|
-
//#region src/rules/test/qa-test-006-retry-abuse.ts
|
|
5189
|
+
// src/rules/test/qa-test-006-retry-abuse.ts
|
|
4960
5190
|
/**
|
|
4961
5191
|
* QA-TEST-006 — Retry abuse in test config.
|
|
4962
5192
|
* Severity: warning · Confidence: high · deterministic-defect
|
|
@@ -5022,8 +5252,7 @@ const retryAbuse = defineRule({
|
|
|
5022
5252
|
return findings;
|
|
5023
5253
|
}
|
|
5024
5254
|
});
|
|
5025
|
-
|
|
5026
|
-
//#region src/rules/test/qa-test-010-empty-body.ts
|
|
5255
|
+
// src/rules/test/qa-test-010-empty-body.ts
|
|
5027
5256
|
/**
|
|
5028
5257
|
* QA-TEST-010 — Empty test body.
|
|
5029
5258
|
* Severity: error · Confidence: high · deterministic-defect
|
|
@@ -5078,8 +5307,7 @@ const emptyTestBody = defineRule({
|
|
|
5078
5307
|
return findings;
|
|
5079
5308
|
}
|
|
5080
5309
|
});
|
|
5081
|
-
|
|
5082
|
-
//#region src/rules/quality/qa-tqual-002-tautological.ts
|
|
5310
|
+
// src/rules/quality/qa-tqual-002-tautological.ts
|
|
5083
5311
|
/**
|
|
5084
5312
|
* QA-TQUAL-002 — Tautological assertion.
|
|
5085
5313
|
* Severity: error · Confidence: high · deterministic-defect
|
|
@@ -5156,8 +5384,7 @@ const tautologicalAssertion = defineRule({
|
|
|
5156
5384
|
return findings;
|
|
5157
5385
|
}
|
|
5158
5386
|
});
|
|
5159
|
-
|
|
5160
|
-
//#region src/rules/quality/qa-tqual-009-promise-assertion.ts
|
|
5387
|
+
// src/rules/quality/qa-tqual-009-promise-assertion.ts
|
|
5161
5388
|
/**
|
|
5162
5389
|
* QA-TQUAL-009 — Unawaited assertion inside a promise chain.
|
|
5163
5390
|
* Severity: error · Confidence: high · deterministic-defect
|
|
@@ -5327,8 +5554,7 @@ function matchParen$2(text, open) {
|
|
|
5327
5554
|
}
|
|
5328
5555
|
return -1;
|
|
5329
5556
|
}
|
|
5330
|
-
|
|
5331
|
-
//#region src/rules/quality/qa-tqual-011-commented-out.ts
|
|
5557
|
+
// src/rules/quality/qa-tqual-011-commented-out.ts
|
|
5332
5558
|
/**
|
|
5333
5559
|
* QA-TQUAL-011 — Commented-out tests.
|
|
5334
5560
|
* Severity: warning · Confidence: high · deterministic-defect
|
|
@@ -5441,8 +5667,7 @@ function commentRanges(ctx) {
|
|
|
5441
5667
|
}
|
|
5442
5668
|
return ranges;
|
|
5443
5669
|
}
|
|
5444
|
-
|
|
5445
|
-
//#region src/rules/playwright/qa-pw-002-unawaited-assertion.ts
|
|
5670
|
+
// src/rules/playwright/qa-pw-002-unawaited-assertion.ts
|
|
5446
5671
|
/**
|
|
5447
5672
|
* QA-PW-002 — Missing `await` on a Playwright locator assertion.
|
|
5448
5673
|
* Severity: error · Confidence: high · deterministic-defect
|
|
@@ -5498,7 +5723,7 @@ const ASYNC_PW_MATCHERS = /* @__PURE__ */ new Set([
|
|
|
5498
5723
|
]);
|
|
5499
5724
|
const unawaitedLocatorAssertion = defineRule({
|
|
5500
5725
|
id: "QA-PW-002",
|
|
5501
|
-
tier: "
|
|
5726
|
+
tier: "extended",
|
|
5502
5727
|
category: "QA-PW",
|
|
5503
5728
|
title: "Unawaited Playwright assertion",
|
|
5504
5729
|
severity: "error",
|
|
@@ -5587,8 +5812,7 @@ function runRegexFallback(text, path) {
|
|
|
5587
5812
|
});
|
|
5588
5813
|
return findings;
|
|
5589
5814
|
}
|
|
5590
|
-
|
|
5591
|
-
//#region src/rules/playwright/qa-pw-003-debug-artifacts.ts
|
|
5815
|
+
// src/rules/playwright/qa-pw-003-debug-artifacts.ts
|
|
5592
5816
|
/**
|
|
5593
5817
|
* QA-PW-003 — Committed `page.pause()` or `test.only()` in e2e specs.
|
|
5594
5818
|
* Severity: error · Confidence: high · deterministic-defect
|
|
@@ -5612,7 +5836,7 @@ const committedDebugArtifacts = defineRule({
|
|
|
5612
5836
|
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"
|
|
5613
5837
|
},
|
|
5614
5838
|
introduced: "0.1.0",
|
|
5615
|
-
tier: "
|
|
5839
|
+
tier: "extended",
|
|
5616
5840
|
run(ctx) {
|
|
5617
5841
|
const text = ctx.codeText ?? ctx.text;
|
|
5618
5842
|
const findings = [];
|
|
@@ -5640,8 +5864,7 @@ const committedDebugArtifacts = defineRule({
|
|
|
5640
5864
|
return findings;
|
|
5641
5865
|
}
|
|
5642
5866
|
});
|
|
5643
|
-
|
|
5644
|
-
//#region src/rules/playwright/qa-pw-004-brittle-selectors.ts
|
|
5867
|
+
// src/rules/playwright/qa-pw-004-brittle-selectors.ts
|
|
5645
5868
|
/**
|
|
5646
5869
|
* QA-PW-004 — Brittle CSS/XPath selectors vs role-based locators.
|
|
5647
5870
|
* Severity: warning · Confidence: medium · heuristic-risk
|
|
@@ -5733,8 +5956,7 @@ function stripComments(ctx) {
|
|
|
5733
5956
|
} catch {}
|
|
5734
5957
|
return ctx.text.replace(/\/\/[^\n]*/g, (cm) => " ".repeat(cm.length));
|
|
5735
5958
|
}
|
|
5736
|
-
|
|
5737
|
-
//#region src/rules/ci/verification-gate.ts
|
|
5959
|
+
// src/rules/ci/verification-gate.ts
|
|
5738
5960
|
/**
|
|
5739
5961
|
* Shared "is this shell command a verification gate?" matcher for the QA-CI
|
|
5740
5962
|
* rules. A gate is a command whose failure MUST fail the job — test runners,
|
|
@@ -5774,8 +5996,7 @@ const VERIFICATION_GATE_RE = new RegExp([
|
|
|
5774
5996
|
function looksLikeVerificationGate(cmd) {
|
|
5775
5997
|
return VERIFICATION_GATE_RE.test(cmd);
|
|
5776
5998
|
}
|
|
5777
|
-
|
|
5778
|
-
//#region src/rules/ci/azure-gates.ts
|
|
5999
|
+
// src/rules/ci/azure-gates.ts
|
|
5779
6000
|
/** Discriminator: the Azure parser is the only one emitting `platform`. */
|
|
5780
6001
|
function isAzurePipelineDoc(ast) {
|
|
5781
6002
|
return typeof ast === "object" && ast !== null && ast.platform === "azure-pipelines";
|
|
@@ -5859,8 +6080,7 @@ function locateAzureJobKey(text, job, keyName) {
|
|
|
5859
6080
|
if (m) return lineAt(text, m.index);
|
|
5860
6081
|
return 1;
|
|
5861
6082
|
}
|
|
5862
|
-
|
|
5863
|
-
//#region src/rules/ci/qa-ci-001-continue-on-error.ts
|
|
6083
|
+
// src/rules/ci/qa-ci-001-continue-on-error.ts
|
|
5864
6084
|
/**
|
|
5865
6085
|
* QA-CI-001 — `continue-on-error: true` masks a failing job.
|
|
5866
6086
|
* Severity: error · Confidence: high · deterministic-defect
|
|
@@ -6088,8 +6308,7 @@ function describeAzureStep(step) {
|
|
|
6088
6308
|
if (step.task) return step.task;
|
|
6089
6309
|
return "step";
|
|
6090
6310
|
}
|
|
6091
|
-
|
|
6092
|
-
//#region src/rules/ci/qa-ci-002-swallowed-exit.ts
|
|
6311
|
+
// src/rules/ci/qa-ci-002-swallowed-exit.ts
|
|
6093
6312
|
/**
|
|
6094
6313
|
* QA-CI-002 — `|| true` swallows a command's exit code.
|
|
6095
6314
|
* Severity: error · Confidence: high · deterministic-defect
|
|
@@ -6144,8 +6363,7 @@ const swallowedExitCode = defineRule({
|
|
|
6144
6363
|
return findings;
|
|
6145
6364
|
}
|
|
6146
6365
|
});
|
|
6147
|
-
|
|
6148
|
-
//#region src/rules/ci/qa-ci-007-retry-masking.ts
|
|
6366
|
+
// src/rules/ci/qa-ci-007-retry-masking.ts
|
|
6149
6367
|
/**
|
|
6150
6368
|
* QA-CI-007 — Retry masking test failures.
|
|
6151
6369
|
* Severity: warning · Confidence: high · deterministic-defect
|
|
@@ -6296,8 +6514,7 @@ function azureArm$1(ctx, doc, findings) {
|
|
|
6296
6514
|
});
|
|
6297
6515
|
}
|
|
6298
6516
|
}
|
|
6299
|
-
|
|
6300
|
-
//#region src/rules/ci/qa-ci-005-report-never-generated.ts
|
|
6517
|
+
// src/rules/ci/qa-ci-005-report-never-generated.ts
|
|
6301
6518
|
/**
|
|
6302
6519
|
* QA-CI-005 — Report consumed but never generated.
|
|
6303
6520
|
* Severity: error · Confidence: high · deterministic-defect
|
|
@@ -6455,8 +6672,7 @@ function findJobConsumerLine(text, jobName, consumerRe) {
|
|
|
6455
6672
|
const anywhere = new RegExp(consumerRe.source, "i").exec(text);
|
|
6456
6673
|
return anywhere ? lineAt(text, anywhere.index) : 1;
|
|
6457
6674
|
}
|
|
6458
|
-
|
|
6459
|
-
//#region src/rules/ci/jenkins-gates.ts
|
|
6675
|
+
// src/rules/ci/jenkins-gates.ts
|
|
6460
6676
|
/**
|
|
6461
6677
|
* Jenkinsfile block scanner for the QA-CI Jenkins arms (product-gap
|
|
6462
6678
|
* master plan P3c). Jenkinsfiles are Groovy text — "no new language
|
|
@@ -6702,8 +6918,7 @@ function textIsVerificationGate(text) {
|
|
|
6702
6918
|
function lineOfOffset(text, offset) {
|
|
6703
6919
|
return lineAt(text, offset);
|
|
6704
6920
|
}
|
|
6705
|
-
|
|
6706
|
-
//#region src/rules/ci/qa-ci-008-always-success.ts
|
|
6921
|
+
// src/rules/ci/qa-ci-008-always-success.ts
|
|
6707
6922
|
/**
|
|
6708
6923
|
* QA-CI-008 — Always-success step at the end of a job.
|
|
6709
6924
|
* Severity: error · Confidence: high · deterministic-defect
|
|
@@ -6877,8 +7092,7 @@ function jenkinsArm$1(ctx, findings) {
|
|
|
6877
7092
|
});
|
|
6878
7093
|
}
|
|
6879
7094
|
}
|
|
6880
|
-
|
|
6881
|
-
//#region src/rules/ci/qa-ci-009-exit-code.ts
|
|
7095
|
+
// src/rules/ci/qa-ci-009-exit-code.ts
|
|
6882
7096
|
/**
|
|
6883
7097
|
* QA-CI-009 — Test command does not propagate exit code.
|
|
6884
7098
|
* Severity: error · Confidence: high · deterministic-defect
|
|
@@ -7013,8 +7227,7 @@ function jenkinsArm(ctx, findings) {
|
|
|
7013
7227
|
});
|
|
7014
7228
|
}
|
|
7015
7229
|
}
|
|
7016
|
-
|
|
7017
|
-
//#region src/rules/ci/qa-ci-010-non-blocking.ts
|
|
7230
|
+
// src/rules/ci/qa-ci-010-non-blocking.ts
|
|
7018
7231
|
/**
|
|
7019
7232
|
* QA-CI-010 — Required test job is non-blocking for branch protection.
|
|
7020
7233
|
* Severity: error · Confidence: medium · deterministic-defect
|
|
@@ -7097,8 +7310,7 @@ function findLine(text, re) {
|
|
|
7097
7310
|
function escapeRe(s) {
|
|
7098
7311
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
7099
7312
|
}
|
|
7100
|
-
|
|
7101
|
-
//#region src/rules/ci/qa-ci-013-can-never-fail.ts
|
|
7313
|
+
// src/rules/ci/qa-ci-013-can-never-fail.ts
|
|
7102
7314
|
/**
|
|
7103
7315
|
* QA-CI-013 — Verification stage/job conditioned so it can never fail the
|
|
7104
7316
|
* pipeline (product-gap master plan P3b).
|
|
@@ -7202,8 +7414,7 @@ const canNeverFailGate = defineRule({
|
|
|
7202
7414
|
function jobHasGate(job) {
|
|
7203
7415
|
return (job.steps ?? []).some((s) => s !== void 0 && stepIsVerificationGate$1(s));
|
|
7204
7416
|
}
|
|
7205
|
-
|
|
7206
|
-
//#region src/rules/ci/qa-ci-014-swallowed-verification.ts
|
|
7417
|
+
// src/rules/ci/qa-ci-014-swallowed-verification.ts
|
|
7207
7418
|
/**
|
|
7208
7419
|
* QA-CI-014 — try/catch swallowing a verification-stage failure without
|
|
7209
7420
|
* rethrow or failure-marking (product-gap master plan P3c).
|
|
@@ -7260,8 +7471,7 @@ const swallowedVerificationFailure = defineRule({
|
|
|
7260
7471
|
return findings;
|
|
7261
7472
|
}
|
|
7262
7473
|
});
|
|
7263
|
-
|
|
7264
|
-
//#region src/rules/python/qa-py-003-no-assertions.ts
|
|
7474
|
+
// src/rules/python/qa-py-003-no-assertions.ts
|
|
7265
7475
|
/**
|
|
7266
7476
|
* QA-PY-003 — Test function with no assertions.
|
|
7267
7477
|
* Severity: error · Confidence: high · deterministic-defect
|
|
@@ -7382,8 +7592,7 @@ function extractBlock$1(text, afterColon) {
|
|
|
7382
7592
|
const lineEnd = rest.indexOf("\n", firstIdx);
|
|
7383
7593
|
return lineEnd === -1 ? rest : rest.slice(0, lineEnd);
|
|
7384
7594
|
}
|
|
7385
|
-
|
|
7386
|
-
//#region src/rules/python/qa-py-005-hard-sleep.ts
|
|
7595
|
+
// src/rules/python/qa-py-005-hard-sleep.ts
|
|
7387
7596
|
/**
|
|
7388
7597
|
* QA-PY-005 — time.sleep() in tests.
|
|
7389
7598
|
* Severity: warning · Confidence: high · deterministic-defect
|
|
@@ -7430,15 +7639,14 @@ const pyHardSleep = defineRule({
|
|
|
7430
7639
|
return findings;
|
|
7431
7640
|
}
|
|
7432
7641
|
});
|
|
7433
|
-
|
|
7434
|
-
//#region src/rules/python/qa-py-002-skipped-test.ts
|
|
7642
|
+
// src/rules/python/qa-py-002-skipped-test.ts
|
|
7435
7643
|
/**
|
|
7436
7644
|
* QA-PY-002 — Skipped test (`@pytest.mark.skip`, `xfail` non-strict).
|
|
7437
7645
|
* Severity: warning · Confidence: high · deterministic-defect
|
|
7438
7646
|
*/
|
|
7439
7647
|
const pySkippedTest = defineRule({
|
|
7440
7648
|
id: "QA-PY-002",
|
|
7441
|
-
tier: "
|
|
7649
|
+
tier: "extended",
|
|
7442
7650
|
category: "QA-TEST",
|
|
7443
7651
|
title: "Skipped test",
|
|
7444
7652
|
severity: "warning",
|
|
@@ -7491,8 +7699,7 @@ const pySkippedTest = defineRule({
|
|
|
7491
7699
|
return findings;
|
|
7492
7700
|
}
|
|
7493
7701
|
});
|
|
7494
|
-
|
|
7495
|
-
//#region src/rules/python/qa-py-012-tautological.ts
|
|
7702
|
+
// src/rules/python/qa-py-012-tautological.ts
|
|
7496
7703
|
/**
|
|
7497
7704
|
* QA-PY-012 — Tautological assertion (`assert True`, `assert x == x`).
|
|
7498
7705
|
* Severity: error · Confidence: high · deterministic-defect
|
|
@@ -7547,8 +7754,7 @@ const pyTautological = defineRule({
|
|
|
7547
7754
|
return findings;
|
|
7548
7755
|
}
|
|
7549
7756
|
});
|
|
7550
|
-
|
|
7551
|
-
//#region src/rules/python/qa-py-001-focused-test.ts
|
|
7757
|
+
// src/rules/python/qa-py-001-focused-test.ts
|
|
7552
7758
|
/**
|
|
7553
7759
|
* QA-PY-001 — Focused test committed.
|
|
7554
7760
|
* Severity: error · Confidence: high · deterministic-defect
|
|
@@ -7574,7 +7780,7 @@ const pyFocusedTest = defineRule({
|
|
|
7574
7780
|
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"
|
|
7575
7781
|
},
|
|
7576
7782
|
introduced: "0.3.0",
|
|
7577
|
-
tier: "
|
|
7783
|
+
tier: "extended",
|
|
7578
7784
|
suiteInvalidating: true,
|
|
7579
7785
|
run(ctx) {
|
|
7580
7786
|
const text = ctx.text;
|
|
@@ -7602,8 +7808,7 @@ const pyFocusedTest = defineRule({
|
|
|
7602
7808
|
return findings;
|
|
7603
7809
|
}
|
|
7604
7810
|
});
|
|
7605
|
-
|
|
7606
|
-
//#region src/engine/jv-cs-ast.ts
|
|
7811
|
+
// src/engine/jv-cs-ast.ts
|
|
7607
7812
|
/** Narrow the loose `ast` seam back to a tree-sitter Tree. */
|
|
7608
7813
|
function getTreeSitterTree(ast) {
|
|
7609
7814
|
if (!(ast instanceof Object) || !("rootNode" in ast)) return void 0;
|
|
@@ -7807,8 +8012,7 @@ function isHelperIdiom(name) {
|
|
|
7807
8012
|
}
|
|
7808
8013
|
return false;
|
|
7809
8014
|
}
|
|
7810
|
-
|
|
7811
|
-
//#region src/engine/python-ast.ts
|
|
8015
|
+
// src/engine/python-ast.ts
|
|
7812
8016
|
function getPythonTree(ast) {
|
|
7813
8017
|
return getTreeSitterTree(ast);
|
|
7814
8018
|
}
|
|
@@ -7818,26 +8022,65 @@ function walk(node, visit) {
|
|
|
7818
8022
|
for (const child of node.children) walk(child, visit);
|
|
7819
8023
|
}
|
|
7820
8024
|
/**
|
|
8025
|
+
* The `pytest.raises` call that is a DIRECT context manager of `withNode`.
|
|
8026
|
+
*
|
|
8027
|
+
* Scopes the search to the with's own item list — `with_clause` for the
|
|
8028
|
+
* plain form, and `parenthesized_with_items` for `with (A, B):` — rather
|
|
8029
|
+
* than to the whole subtree. A call found deeper down belongs to an inner
|
|
8030
|
+
* `with_statement`, which the walk reaches in its own right and which owns
|
|
8031
|
+
* the block that actually gates it.
|
|
8032
|
+
*/
|
|
8033
|
+
function directRaisesItem(withNode) {
|
|
8034
|
+
let found;
|
|
8035
|
+
const scan = (n) => {
|
|
8036
|
+
if (found) return;
|
|
8037
|
+
if (n.type === "call") {
|
|
8038
|
+
const fn = n.childForFieldName("function");
|
|
8039
|
+
if (fn && /(?:^|\.)raises$/.test(fn.text)) {
|
|
8040
|
+
found = n;
|
|
8041
|
+
return;
|
|
8042
|
+
}
|
|
8043
|
+
}
|
|
8044
|
+
for (const child of n.children) scan(child);
|
|
8045
|
+
};
|
|
8046
|
+
for (const child of withNode.children) if (child.type === "with_clause" || child.type === "parenthesized_with_items") scan(child);
|
|
8047
|
+
return found;
|
|
8048
|
+
}
|
|
8049
|
+
/**
|
|
7821
8050
|
* Every `with pytest.raises(...)` (optionally `... as exc`) statement in
|
|
7822
8051
|
* the tree, with the structural facts the rework needs. Tree-sitter-
|
|
7823
8052
|
* python grammar: a `with_statement` wraps `with_item`s and a `block`;
|
|
7824
8053
|
* the raises call is a `call` whose function is an attribute chain
|
|
7825
8054
|
* ending in `.raises`.
|
|
8055
|
+
*
|
|
8056
|
+
* 6.0. The raises call is now required to be a DIRECT context manager of the
|
|
8057
|
+
* `with_statement` being measured. It used to be found by walking the whole
|
|
8058
|
+
* subtree, so a `with pytest.raises(...)` nested inside another `with`
|
|
8059
|
+
* handed back the OUTER block's statement count — and `QA-PY-007`'s
|
|
8060
|
+
* single-statement gate, which exists precisely to suppress that shape,
|
|
8061
|
+
* could never fire for nested code.
|
|
8062
|
+
*
|
|
8063
|
+
* That is not a rare shape. In the pinned `pytest-dev/pytest` corpus, seven
|
|
8064
|
+
* of the eleven adjudicated findings were single-statement raises nested
|
|
8065
|
+
* inside `with saved_fd(1):`, and every one of them was counted as
|
|
8066
|
+
* multi-statement because the surrounding block has a dozen statements. The
|
|
8067
|
+
* rule measured the wrong block, which is why it measured 75% false
|
|
8068
|
+
* positives while its own unit fixtures passed.
|
|
8069
|
+
*
|
|
8070
|
+
* Requiring a direct context manager also fixes the shape by construction:
|
|
8071
|
+
* the outer `with` is examined, has no direct raises item, and is skipped;
|
|
8072
|
+
* the inner one is examined with its own block.
|
|
7826
8073
|
*/
|
|
7827
8074
|
function pythonWithRaisesBlocks(tree) {
|
|
7828
8075
|
const out = [];
|
|
7829
8076
|
const root = tree.rootNode;
|
|
7830
8077
|
walk(root, (node) => {
|
|
7831
8078
|
if (node.type !== "with_statement") return;
|
|
7832
|
-
let raisesCall;
|
|
7833
8079
|
let blockNode;
|
|
7834
8080
|
for (const child of node.children) if (child.type === "block") blockNode = child;
|
|
7835
|
-
|
|
7836
|
-
|
|
7837
|
-
|
|
7838
|
-
if (fn && /(?:^|\.)raises$/.test(fn.text)) raisesCall = n;
|
|
7839
|
-
});
|
|
7840
|
-
if (!raisesCall || !blockNode) return;
|
|
8081
|
+
if (!blockNode) return;
|
|
8082
|
+
const raisesCall = directRaisesItem(node);
|
|
8083
|
+
if (!raisesCall) return;
|
|
7841
8084
|
const stmts = blockNode.namedChildren.filter((c) => c.type !== "comment");
|
|
7842
8085
|
const exceptionText = (raisesCall.childForFieldName("arguments")?.namedChildren[0])?.text ?? "";
|
|
7843
8086
|
const broadException = BROAD_EXC_RE.test(exceptionText);
|
|
@@ -7854,8 +8097,7 @@ function pythonWithRaisesBlocks(tree) {
|
|
|
7854
8097
|
});
|
|
7855
8098
|
return out;
|
|
7856
8099
|
}
|
|
7857
|
-
|
|
7858
|
-
//#region src/rules/python/qa-py-007-raises-without-match.ts
|
|
8100
|
+
// src/rules/python/qa-py-007-raises-without-match.ts
|
|
7859
8101
|
/**
|
|
7860
8102
|
* QA-PY-007 — pytest.raises without match.
|
|
7861
8103
|
* Severity: warning · Confidence: medium · heuristic-risk
|
|
@@ -7883,7 +8125,7 @@ const pyRaisesWithoutMatch = defineRule({
|
|
|
7883
8125
|
},
|
|
7884
8126
|
introduced: "0.3.0",
|
|
7885
8127
|
tier: "quarantine",
|
|
7886
|
-
detectorRevision:
|
|
8128
|
+
detectorRevision: 5,
|
|
7887
8129
|
run(ctx) {
|
|
7888
8130
|
const findings = [];
|
|
7889
8131
|
if (!ctx.path.endsWith(".py")) return findings;
|
|
@@ -7942,16 +8184,17 @@ function matchParen$1(text, open) {
|
|
|
7942
8184
|
return -1;
|
|
7943
8185
|
}
|
|
7944
8186
|
/**
|
|
7945
|
-
* AST arm (detectorRevision
|
|
7946
|
-
* parsed `with pytest.raises(...)` statements
|
|
7947
|
-
*
|
|
7948
|
-
*
|
|
7949
|
-
*
|
|
7950
|
-
*
|
|
7951
|
-
*
|
|
7952
|
-
*
|
|
7953
|
-
*
|
|
7954
|
-
*
|
|
8187
|
+
* AST arm (detectorRevision 5 — P6 rework, widened on adjudicated evidence):
|
|
8188
|
+
* candidates come from the parsed `with pytest.raises(...)` statements, and
|
|
8189
|
+
* the raises call must be a DIRECT context manager of the with whose block
|
|
8190
|
+
* is measured (see `pythonWithRaisesBlocks` for why).
|
|
8191
|
+
*
|
|
8192
|
+
* The gate: a block with fewer than two statements is exempt. That covers
|
|
8193
|
+
* the adjudicated FP core — pytest-dev/pytest nests its capture tests inside
|
|
8194
|
+
* `with saved_fd(1):`, and every one of those was scored as multi-statement
|
|
8195
|
+
* until the block resolution was fixed. A broad root type (Exception /
|
|
8196
|
+
* BaseException / …Group) still fires on a MULTI-statement block, where an
|
|
8197
|
+
* earlier line can raise it before the intended one.
|
|
7955
8198
|
*/
|
|
7956
8199
|
function astArm(ctx, tree) {
|
|
7957
8200
|
const raw = ctx.text;
|
|
@@ -7965,7 +8208,7 @@ function astArm(ctx, tree) {
|
|
|
7965
8208
|
const tail = raw.slice(block.callEnd, block.callEnd + 2400);
|
|
7966
8209
|
if (new RegExp(`\\b${name}\\b`).test(tail)) continue;
|
|
7967
8210
|
}
|
|
7968
|
-
if (block.blockStatements < 2
|
|
8211
|
+
if (block.blockStatements < 2) continue;
|
|
7969
8212
|
findings.push({
|
|
7970
8213
|
severity: "warning",
|
|
7971
8214
|
confidence: "medium",
|
|
@@ -7981,8 +8224,7 @@ function astArm(ctx, tree) {
|
|
|
7981
8224
|
}
|
|
7982
8225
|
return findings;
|
|
7983
8226
|
}
|
|
7984
|
-
|
|
7985
|
-
//#region src/rules/python/qa-py-009-commented-out.ts
|
|
8227
|
+
// src/rules/python/qa-py-009-commented-out.ts
|
|
7986
8228
|
/**
|
|
7987
8229
|
* QA-PY-009 — Commented-out tests.
|
|
7988
8230
|
* Severity: warning · Confidence: high · deterministic-defect
|
|
@@ -8008,7 +8250,7 @@ const pyCommentedOutTest = defineRule({
|
|
|
8008
8250
|
},
|
|
8009
8251
|
detectionNotes: "regex heuristic",
|
|
8010
8252
|
introduced: "0.3.0",
|
|
8011
|
-
tier: "
|
|
8253
|
+
tier: "extended",
|
|
8012
8254
|
run(ctx) {
|
|
8013
8255
|
const findings = [];
|
|
8014
8256
|
if (!ctx.path.endsWith(".py")) return findings;
|
|
@@ -8029,8 +8271,7 @@ const pyCommentedOutTest = defineRule({
|
|
|
8029
8271
|
return findings;
|
|
8030
8272
|
}
|
|
8031
8273
|
});
|
|
8032
|
-
|
|
8033
|
-
//#region src/rules/python/qa-py-004-bare-truthiness.ts
|
|
8274
|
+
// src/rules/python/qa-py-004-bare-truthiness.ts
|
|
8034
8275
|
/**
|
|
8035
8276
|
* QA-PY-004 — Bare truthiness assert on complex object.
|
|
8036
8277
|
* Severity: warning · Confidence: medium · heuristic-risk
|
|
@@ -8057,13 +8298,18 @@ const pyBareTruthinessAssert = defineRule({
|
|
|
8057
8298
|
},
|
|
8058
8299
|
introduced: "0.3.0",
|
|
8059
8300
|
tier: "quarantine",
|
|
8060
|
-
detectorRevision:
|
|
8301
|
+
detectorRevision: 4,
|
|
8061
8302
|
run(ctx) {
|
|
8062
8303
|
const text = ctx.codeText ?? ctx.text;
|
|
8063
8304
|
const findings = [];
|
|
8064
8305
|
if (!ctx.path.endsWith(".py")) return findings;
|
|
8065
8306
|
const re = /^[ \t]*assert\s+([A-Za-z_][\w.]*(?:\([^()]*\))?)[ \t]*$/gm;
|
|
8066
|
-
|
|
8307
|
+
/**
|
|
8308
|
+
* Constants. `assert False` / `assert None` are deliberate mechanisms —
|
|
8309
|
+
* a test that manufactures an exception to assert on its shape — not a
|
|
8310
|
+
* truthiness check whose wrong value would slip through.
|
|
8311
|
+
*/
|
|
8312
|
+
const isConstant = (target) => /^(?:True|False|None|NotImplemented|Ellipsis)$/.test(target) || /^["'\d]/.test(target);
|
|
8067
8313
|
const isGuardFollowedByRealUse = (text, matchIndex, target) => {
|
|
8068
8314
|
const lineEnd = text.indexOf("\n", matchIndex);
|
|
8069
8315
|
if (lineEnd === -1) return false;
|
|
@@ -8083,8 +8329,9 @@ const pyBareTruthinessAssert = defineRule({
|
|
|
8083
8329
|
let m;
|
|
8084
8330
|
while ((m = re.exec(text)) !== null) {
|
|
8085
8331
|
const target = m[1];
|
|
8332
|
+
if (target.includes("(")) continue;
|
|
8333
|
+
if (isConstant(target)) continue;
|
|
8086
8334
|
if (/^(?:is|has|can|should|was|were)_/.test(target)) continue;
|
|
8087
|
-
if (predicateRe.test(target)) continue;
|
|
8088
8335
|
if (/\.called$/.test(target)) continue;
|
|
8089
8336
|
if (isGuardFollowedByRealUse(text, m.index, target)) continue;
|
|
8090
8337
|
findings.push({
|
|
@@ -8103,8 +8350,7 @@ const pyBareTruthinessAssert = defineRule({
|
|
|
8103
8350
|
return findings;
|
|
8104
8351
|
}
|
|
8105
8352
|
});
|
|
8106
|
-
|
|
8107
|
-
//#region src/rules/python/qa-py-011-mutable-fixture.ts
|
|
8353
|
+
// src/rules/python/qa-py-011-mutable-fixture.ts
|
|
8108
8354
|
/**
|
|
8109
8355
|
* QA-PY-011 — Mutable fixture shared across tests.
|
|
8110
8356
|
* Severity: warning · Confidence: medium · heuristic-risk
|
|
@@ -8130,7 +8376,7 @@ const pyMutableFixture = defineRule({
|
|
|
8130
8376
|
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"
|
|
8131
8377
|
},
|
|
8132
8378
|
introduced: "0.3.0",
|
|
8133
|
-
tier: "
|
|
8379
|
+
tier: "extended",
|
|
8134
8380
|
run(ctx) {
|
|
8135
8381
|
const text = ctx.text;
|
|
8136
8382
|
const findings = [];
|
|
@@ -8155,8 +8401,7 @@ const pyMutableFixture = defineRule({
|
|
|
8155
8401
|
return findings;
|
|
8156
8402
|
}
|
|
8157
8403
|
});
|
|
8158
|
-
|
|
8159
|
-
//#region src/rules/playwright/qa-pw-101-wait-for-timeout.ts
|
|
8404
|
+
// src/rules/playwright/qa-pw-101-wait-for-timeout.ts
|
|
8160
8405
|
/**
|
|
8161
8406
|
* QA-PW-101 — waitForTimeout() anywhere.
|
|
8162
8407
|
* Severity: error · Confidence: high · deterministic-defect
|
|
@@ -8164,7 +8409,7 @@ const pyMutableFixture = defineRule({
|
|
|
8164
8409
|
*/
|
|
8165
8410
|
const pwWaitForTimeout = defineRule({
|
|
8166
8411
|
id: "QA-PW-101",
|
|
8167
|
-
tier: "
|
|
8412
|
+
tier: "extended",
|
|
8168
8413
|
category: "QA-PW",
|
|
8169
8414
|
title: "Hard sleep via waitForTimeout",
|
|
8170
8415
|
severity: "error",
|
|
@@ -8203,8 +8448,7 @@ const pwWaitForTimeout = defineRule({
|
|
|
8203
8448
|
return findings;
|
|
8204
8449
|
}
|
|
8205
8450
|
});
|
|
8206
|
-
|
|
8207
|
-
//#region src/rules/playwright/qa-pw-102-wait-load.ts
|
|
8451
|
+
// src/rules/playwright/qa-pw-102-wait-load.ts
|
|
8208
8452
|
/**
|
|
8209
8453
|
* QA-PW-102 — waitForEvent('load') / waitForLoadState('load') instead of
|
|
8210
8454
|
* web-first assertions.
|
|
@@ -8258,8 +8502,7 @@ const pwWaitForLoadEvent = defineRule({
|
|
|
8258
8502
|
return findings;
|
|
8259
8503
|
}
|
|
8260
8504
|
});
|
|
8261
|
-
|
|
8262
|
-
//#region src/rules/playwright/qa-pw-113-deep-frames.ts
|
|
8505
|
+
// src/rules/playwright/qa-pw-113-deep-frames.ts
|
|
8263
8506
|
/**
|
|
8264
8507
|
* QA-PW-113 — frameLocator chains > 2 deep.
|
|
8265
8508
|
* Severity: warning · Confidence: medium · heuristic-risk
|
|
@@ -8285,7 +8528,7 @@ const pwDeepFrameLocator = defineRule({
|
|
|
8285
8528
|
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"
|
|
8286
8529
|
},
|
|
8287
8530
|
introduced: "0.3.0",
|
|
8288
|
-
tier: "
|
|
8531
|
+
tier: "extended",
|
|
8289
8532
|
run(ctx) {
|
|
8290
8533
|
const text = ctx.codeText ?? ctx.text;
|
|
8291
8534
|
const findings = [];
|
|
@@ -8309,8 +8552,7 @@ const pwDeepFrameLocator = defineRule({
|
|
|
8309
8552
|
function count(fragment) {
|
|
8310
8553
|
return fragment.match(/frameLocator\s*\(/g).length;
|
|
8311
8554
|
}
|
|
8312
|
-
|
|
8313
|
-
//#region src/rules/playwright/qa-pw-117-serial.ts
|
|
8555
|
+
// src/rules/playwright/qa-pw-117-serial.ts
|
|
8314
8556
|
/**
|
|
8315
8557
|
* QA-PW-117 — test.describe.serial without justification comment.
|
|
8316
8558
|
* Severity: warning · Confidence: high · deterministic-defect
|
|
@@ -8364,8 +8606,7 @@ const pwSerialNoJustification = defineRule({
|
|
|
8364
8606
|
return findings;
|
|
8365
8607
|
}
|
|
8366
8608
|
});
|
|
8367
|
-
|
|
8368
|
-
//#region src/rules/playwright/qa-pw-121-config-retries.ts
|
|
8609
|
+
// src/rules/playwright/qa-pw-121-config-retries.ts
|
|
8369
8610
|
/**
|
|
8370
8611
|
* QA-PW-121 — playwright.config: retries >= 3 or workers = 0.
|
|
8371
8612
|
* Severity: warning · Confidence: high · deterministic-defect
|
|
@@ -8394,7 +8635,7 @@ const pwConfigRetryAbuse = defineRule({
|
|
|
8394
8635
|
},
|
|
8395
8636
|
detectionNotes: "regex heuristic",
|
|
8396
8637
|
introduced: "0.3.0",
|
|
8397
|
-
tier: "
|
|
8638
|
+
tier: "extended",
|
|
8398
8639
|
run(ctx) {
|
|
8399
8640
|
const text = ctx.codeText ?? ctx.text;
|
|
8400
8641
|
const findings = [];
|
|
@@ -8412,7 +8653,7 @@ const pwConfigRetryAbuse = defineRule({
|
|
|
8412
8653
|
column: colAt(text, m.index),
|
|
8413
8654
|
message: `retries: ${m[1]} — a flaky test gets ${m[1]} chances to pass by luck.`,
|
|
8414
8655
|
why: "High retry counts convert real bugs into intermittent passes and inflate CI minutes; the suite reports green while hiding instability.",
|
|
8415
|
-
fix: "Keep retries <= 2 and route repeat offenders into
|
|
8656
|
+
fix: "Keep retries <= 2 and route repeat offenders into the evidence path (`explain --evidence`)."
|
|
8416
8657
|
});
|
|
8417
8658
|
const workersRe = /workers\s*:\s*0\b/g;
|
|
8418
8659
|
while ((m = workersRe.exec(text)) !== null) findings.push({
|
|
@@ -8430,8 +8671,7 @@ const pwConfigRetryAbuse = defineRule({
|
|
|
8430
8671
|
return findings;
|
|
8431
8672
|
}
|
|
8432
8673
|
});
|
|
8433
|
-
|
|
8434
|
-
//#region src/rules/playwright/qa-pw-122-no-trace.ts
|
|
8674
|
+
// src/rules/playwright/qa-pw-122-no-trace.ts
|
|
8435
8675
|
/**
|
|
8436
8676
|
* QA-PW-122 — No trace capture on retry in playwright.config.
|
|
8437
8677
|
* Severity: warning · Confidence: high · deterministic-defect
|
|
@@ -8480,8 +8720,7 @@ const pwNoTraceOnRetry = defineRule({
|
|
|
8480
8720
|
return findings;
|
|
8481
8721
|
}
|
|
8482
8722
|
});
|
|
8483
|
-
|
|
8484
|
-
//#region src/rules/playwright/qa-pw-124-project-split.ts
|
|
8723
|
+
// src/rules/playwright/qa-pw-124-project-split.ts
|
|
8485
8724
|
/**
|
|
8486
8725
|
* QA-PW-124 — Projects defined but no smoke/regression split.
|
|
8487
8726
|
* Severity: info · Confidence: high · deterministic-defect
|
|
@@ -8543,8 +8782,7 @@ const pwNoProjectSplit = defineRule({
|
|
|
8543
8782
|
return findings;
|
|
8544
8783
|
}
|
|
8545
8784
|
});
|
|
8546
|
-
|
|
8547
|
-
//#region src/rules/playwright/qa-pw-104-trial-click.ts
|
|
8785
|
+
// src/rules/playwright/qa-pw-104-trial-click.ts
|
|
8548
8786
|
/**
|
|
8549
8787
|
* QA-PW-104 — `{ trial: true }` misuse — check-then-act race.
|
|
8550
8788
|
* Severity: warning · Confidence: medium · heuristic-risk
|
|
@@ -8569,7 +8807,7 @@ const pwTrialMisuse = defineRule({
|
|
|
8569
8807
|
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"
|
|
8570
8808
|
},
|
|
8571
8809
|
introduced: "0.3.0",
|
|
8572
|
-
tier: "
|
|
8810
|
+
tier: "extended",
|
|
8573
8811
|
run(ctx) {
|
|
8574
8812
|
const text = ctx.codeText ?? ctx.text;
|
|
8575
8813
|
const findings = [];
|
|
@@ -8590,8 +8828,7 @@ const pwTrialMisuse = defineRule({
|
|
|
8590
8828
|
return findings;
|
|
8591
8829
|
}
|
|
8592
8830
|
});
|
|
8593
|
-
|
|
8594
|
-
//#region src/rules/playwright/qa-pw-116-storage-state.ts
|
|
8831
|
+
// src/rules/playwright/qa-pw-116-storage-state.ts
|
|
8595
8832
|
/**
|
|
8596
8833
|
* QA-PW-116 — storageState reuse without expiry strategy.
|
|
8597
8834
|
* Severity: warning · Confidence: medium · heuristic-risk
|
|
@@ -8637,8 +8874,7 @@ const pwStorageStateNoExpiry = defineRule({
|
|
|
8637
8874
|
return findings;
|
|
8638
8875
|
}
|
|
8639
8876
|
});
|
|
8640
|
-
|
|
8641
|
-
//#region src/rules/playwright/qa-pw-125-global-setup.ts
|
|
8877
|
+
// src/rules/playwright/qa-pw-125-global-setup.ts
|
|
8642
8878
|
/**
|
|
8643
8879
|
* QA-PW-125 — globalSetup mutating shared external state.
|
|
8644
8880
|
* Severity: warning · Confidence: medium · heuristic-risk
|
|
@@ -8696,8 +8932,7 @@ const pwGlobalSetupSharedState = defineRule({
|
|
|
8696
8932
|
return findings;
|
|
8697
8933
|
}
|
|
8698
8934
|
});
|
|
8699
|
-
|
|
8700
|
-
//#region src/rules/playwright/qa-pw-115-shared-page.ts
|
|
8935
|
+
// src/rules/playwright/qa-pw-115-shared-page.ts
|
|
8701
8936
|
/**
|
|
8702
8937
|
* QA-PW-115 — Shared `page` across parallel tests without isolation.
|
|
8703
8938
|
* Severity: warning · Confidence: medium · heuristic-risk
|
|
@@ -8745,8 +8980,7 @@ const pwSharedPage = defineRule({
|
|
|
8745
8980
|
return findings;
|
|
8746
8981
|
}
|
|
8747
8982
|
});
|
|
8748
|
-
|
|
8749
|
-
//#region src/rules/playwright/qa-pw-140.ts
|
|
8983
|
+
// src/rules/playwright/qa-pw-140.ts
|
|
8750
8984
|
/**
|
|
8751
8985
|
* QA-PW-140 — Screenshot without maxDiffPixelRatio.
|
|
8752
8986
|
*
|
|
@@ -8774,7 +9008,7 @@ const qaPw140 = defineRule({
|
|
|
8774
9008
|
detail: "the detector matches a closed, exact runner API token on the code-only text; the token identifies the defect uniquely"
|
|
8775
9009
|
},
|
|
8776
9010
|
introduced: "0.3.0",
|
|
8777
|
-
tier: "
|
|
9011
|
+
tier: "extended",
|
|
8778
9012
|
run(ctx) {
|
|
8779
9013
|
const text = ctx.codeText ?? ctx.text;
|
|
8780
9014
|
const findings = [];
|
|
@@ -8820,8 +9054,7 @@ function matchParen(text, open) {
|
|
|
8820
9054
|
}
|
|
8821
9055
|
return -1;
|
|
8822
9056
|
}
|
|
8823
|
-
|
|
8824
|
-
//#region src/rules/playwright/qa-pw-123-hardcoded-url.ts
|
|
9057
|
+
// src/rules/playwright/qa-pw-123-hardcoded-url.ts
|
|
8825
9058
|
/**
|
|
8826
9059
|
* QA-PW-123 — Hardcoded baseURL in specs.
|
|
8827
9060
|
* Severity: warning · Confidence: high · deterministic-defect
|
|
@@ -8869,8 +9102,7 @@ const hardcodedBaseUrl = defineRule({
|
|
|
8869
9102
|
return findings;
|
|
8870
9103
|
}
|
|
8871
9104
|
});
|
|
8872
|
-
|
|
8873
|
-
//#region src/rules/quality/qa-env-001-env-coupling.ts
|
|
9105
|
+
// src/rules/quality/qa-env-001-env-coupling.ts
|
|
8874
9106
|
/**
|
|
8875
9107
|
* QA-ENV-001 — "Works On My Machine" detector core (Tier 5 #24).
|
|
8876
9108
|
* Environment coupling in test files: fixed ports, OS paths,
|
|
@@ -8937,8 +9169,7 @@ const envCoupling = defineRule({
|
|
|
8937
9169
|
return findings;
|
|
8938
9170
|
}
|
|
8939
9171
|
});
|
|
8940
|
-
|
|
8941
|
-
//#region src/rules/playwright/qa-pw-141-retry-no-triage.ts
|
|
9172
|
+
// src/rules/playwright/qa-pw-141-retry-no-triage.ts
|
|
8942
9173
|
/**
|
|
8943
9174
|
* QA-PW-141 — playwright.config retries set high enough to mask flakes.
|
|
8944
9175
|
* Severity: warning · Confidence: high · deterministic-defect
|
|
@@ -8989,14 +9220,13 @@ const pwRetryMaskingNoForensics = defineRule({
|
|
|
8989
9220
|
column: colAt(text, m.index),
|
|
8990
9221
|
message: `retries: ${m[1]} with no visible flake-triage loop.`,
|
|
8991
9222
|
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.",
|
|
8992
|
-
fix: "Keep retries <= 2 and feed retry outcomes into `
|
|
9223
|
+
fix: "Keep retries <= 2 and feed retry outcomes into the evidence path (`explain --evidence`), or add a reporter so flaky passes are reviewed."
|
|
8993
9224
|
});
|
|
8994
9225
|
}
|
|
8995
9226
|
return findings;
|
|
8996
9227
|
}
|
|
8997
9228
|
});
|
|
8998
|
-
|
|
8999
|
-
//#region src/rules/playwright/qa-pw-142-blanket-route.ts
|
|
9229
|
+
// src/rules/playwright/qa-pw-142-blanket-route.ts
|
|
9000
9230
|
/**
|
|
9001
9231
|
* QA-PW-142 — page.route() blanket-mocking instead of scoped intercepts.
|
|
9002
9232
|
* Severity: warning · Confidence: medium · heuristic-risk
|
|
@@ -9051,8 +9281,7 @@ const pwBlanketRouteMock = defineRule({
|
|
|
9051
9281
|
return findings;
|
|
9052
9282
|
}
|
|
9053
9283
|
});
|
|
9054
|
-
|
|
9055
|
-
//#region src/rules/playwright/qa-pw-143-no-artifacts.ts
|
|
9284
|
+
// src/rules/playwright/qa-pw-143-no-artifacts.ts
|
|
9056
9285
|
/**
|
|
9057
9286
|
* QA-PW-143 — No screenshot/video artifact capture on failure.
|
|
9058
9287
|
* Severity: info · Confidence: high · deterministic-defect
|
|
@@ -9105,8 +9334,7 @@ const pwNoFailureArtifacts = defineRule({
|
|
|
9105
9334
|
return findings;
|
|
9106
9335
|
}
|
|
9107
9336
|
});
|
|
9108
|
-
|
|
9109
|
-
//#region src/rules/playwright/qa-pw-144-single-browser.ts
|
|
9337
|
+
// src/rules/playwright/qa-pw-144-single-browser.ts
|
|
9110
9338
|
/**
|
|
9111
9339
|
* QA-PW-144 — Single-browser project matrix (no cross-browser coverage).
|
|
9112
9340
|
* Severity: info · Confidence: high · deterministic-defect
|
|
@@ -9168,8 +9396,7 @@ const pwSingleBrowserMatrix = defineRule({
|
|
|
9168
9396
|
return findings;
|
|
9169
9397
|
}
|
|
9170
9398
|
});
|
|
9171
|
-
|
|
9172
|
-
//#region src/rules/playwright/qa-pw-146-css-locator.ts
|
|
9399
|
+
// src/rules/playwright/qa-pw-146-css-locator.ts
|
|
9173
9400
|
/**
|
|
9174
9401
|
* QA-PW-146 — CSS/XPath string selector where Playwright's locator
|
|
9175
9402
|
* standard expects a normalized locator (plan §17.3).
|
|
@@ -9248,8 +9475,7 @@ const pwLocatorNormalize = defineRule({
|
|
|
9248
9475
|
return findings;
|
|
9249
9476
|
}
|
|
9250
9477
|
});
|
|
9251
|
-
|
|
9252
|
-
//#region src/rules/playwright/qa-pw-147-codegen-artifact.ts
|
|
9478
|
+
// src/rules/playwright/qa-pw-147-codegen-artifact.ts
|
|
9253
9479
|
/**
|
|
9254
9480
|
* QA-PW-147 — Playwright codegen artifact left unedited (plan §17.1).
|
|
9255
9481
|
* Severity: info · Confidence: medium · observation
|
|
@@ -9308,8 +9534,7 @@ const pwCodegenArtifact = defineRule({
|
|
|
9308
9534
|
return findings;
|
|
9309
9535
|
}
|
|
9310
9536
|
});
|
|
9311
|
-
|
|
9312
|
-
//#region src/rules/python/qa-py-101-sync-async-mix.ts
|
|
9537
|
+
// src/rules/python/qa-py-101-sync-async-mix.ts
|
|
9313
9538
|
/**
|
|
9314
9539
|
* QA-PY-101 — Playwright-Python: sync API in async test (or vice versa).
|
|
9315
9540
|
* Severity: warning · Confidence: high · deterministic-defect
|
|
@@ -9358,8 +9583,7 @@ const pyPwSyncAsyncMix = defineRule({
|
|
|
9358
9583
|
return findings;
|
|
9359
9584
|
}
|
|
9360
9585
|
});
|
|
9361
|
-
|
|
9362
|
-
//#region src/rules/python/qa-py-103-wait-for-timeout.ts
|
|
9586
|
+
// src/rules/python/qa-py-103-wait-for-timeout.ts
|
|
9363
9587
|
/**
|
|
9364
9588
|
* QA-PY-103 — Playwright-Python: wait_for_timeout() as synchronization.
|
|
9365
9589
|
* Severity: warning · Confidence: high · deterministic-defect
|
|
@@ -9384,7 +9608,7 @@ const pyPwWaitForTimeout = defineRule({
|
|
|
9384
9608
|
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"
|
|
9385
9609
|
},
|
|
9386
9610
|
introduced: "0.3.8",
|
|
9387
|
-
tier: "
|
|
9611
|
+
tier: "extended",
|
|
9388
9612
|
run(ctx) {
|
|
9389
9613
|
const text = ctx.codeText ?? ctx.text;
|
|
9390
9614
|
const findings = [];
|
|
@@ -9406,8 +9630,7 @@ const pyPwWaitForTimeout = defineRule({
|
|
|
9406
9630
|
return findings;
|
|
9407
9631
|
}
|
|
9408
9632
|
});
|
|
9409
|
-
|
|
9410
|
-
//#region src/rules/python/qa-py-105-pw-no-assertions.ts
|
|
9633
|
+
// src/rules/python/qa-py-105-pw-no-assertions.ts
|
|
9411
9634
|
/**
|
|
9412
9635
|
* QA-PY-105 — Playwright-Python: no assertion in Playwright test.
|
|
9413
9636
|
* Severity: error · Confidence: high · deterministic-defect
|
|
@@ -9487,8 +9710,7 @@ function extractBlock(text, afterColon) {
|
|
|
9487
9710
|
const lineEnd = rest.indexOf("\n", firstContent.index);
|
|
9488
9711
|
return lineEnd === -1 ? rest : rest.slice(0, lineEnd);
|
|
9489
9712
|
}
|
|
9490
|
-
|
|
9491
|
-
//#region src/rules/java/qa-jv-101-disabled-test.ts
|
|
9713
|
+
// src/rules/java/qa-jv-101-disabled-test.ts
|
|
9492
9714
|
/**
|
|
9493
9715
|
* QA-JV-101 — @Disabled test without justification.
|
|
9494
9716
|
* Severity: warning · Confidence: high · deterministic-defect
|
|
@@ -9542,8 +9764,7 @@ const jvDisabledTest = defineRule({
|
|
|
9542
9764
|
return findings;
|
|
9543
9765
|
}
|
|
9544
9766
|
});
|
|
9545
|
-
|
|
9546
|
-
//#region src/rules/java/qa-jv-103-no-assertions.ts
|
|
9767
|
+
// src/rules/java/qa-jv-103-no-assertions.ts
|
|
9547
9768
|
/**
|
|
9548
9769
|
* QA-JV-103 — Test method without assertions.
|
|
9549
9770
|
* Severity: error · Confidence: high · deterministic-defect
|
|
@@ -9700,8 +9921,7 @@ function matchBrace$1(text, open) {
|
|
|
9700
9921
|
}
|
|
9701
9922
|
return -1;
|
|
9702
9923
|
}
|
|
9703
|
-
|
|
9704
|
-
//#region src/engine/qa-model.ts
|
|
9924
|
+
// src/engine/qa-model.ts
|
|
9705
9925
|
/**
|
|
9706
9926
|
* Extract the semantic model for one parsed file. Resolves undefined
|
|
9707
9927
|
* when the language has no extractor or the parse stage produced no
|
|
@@ -10244,8 +10464,7 @@ function extractPythonModel(file) {
|
|
|
10244
10464
|
nodes
|
|
10245
10465
|
};
|
|
10246
10466
|
}
|
|
10247
|
-
|
|
10248
|
-
//#region src/rules/java/qa-jv-105-wait-for-timeout.ts
|
|
10467
|
+
// src/rules/java/qa-jv-105-wait-for-timeout.ts
|
|
10249
10468
|
/**
|
|
10250
10469
|
* QA-JV-105 — waitForTimeout / hardcoded navigation waits.
|
|
10251
10470
|
* Severity: warning · Confidence: high · deterministic-defect
|
|
@@ -10269,7 +10488,7 @@ function extractPythonModel(file) {
|
|
|
10269
10488
|
*/
|
|
10270
10489
|
const jvWaitForTimeout = defineRule({
|
|
10271
10490
|
id: "QA-JV-105",
|
|
10272
|
-
tier: "
|
|
10491
|
+
tier: "extended",
|
|
10273
10492
|
category: "QA-PW",
|
|
10274
10493
|
title: "waitForTimeout hard sleep",
|
|
10275
10494
|
severity: "warning",
|
|
@@ -10330,15 +10549,14 @@ const jvWaitForTimeout = defineRule({
|
|
|
10330
10549
|
return findings;
|
|
10331
10550
|
}
|
|
10332
10551
|
});
|
|
10333
|
-
|
|
10334
|
-
//#region src/rules/csharp/qa-cs-101-skipped-test.ts
|
|
10552
|
+
// src/rules/csharp/qa-cs-101-skipped-test.ts
|
|
10335
10553
|
/**
|
|
10336
10554
|
* QA-CS-101 — [Ignore]/[Skip] test without justification.
|
|
10337
10555
|
* Severity: warning · Confidence: high · deterministic-defect
|
|
10338
10556
|
*/
|
|
10339
10557
|
const csSkippedTest = defineRule({
|
|
10340
10558
|
id: "QA-CS-101",
|
|
10341
|
-
tier: "
|
|
10559
|
+
tier: "extended",
|
|
10342
10560
|
category: "QA-PW",
|
|
10343
10561
|
title: "Skipped test",
|
|
10344
10562
|
severity: "warning",
|
|
@@ -10395,8 +10613,7 @@ const csSkippedTest = defineRule({
|
|
|
10395
10613
|
return findings;
|
|
10396
10614
|
}
|
|
10397
10615
|
});
|
|
10398
|
-
|
|
10399
|
-
//#region src/rules/csharp/qa-cs-103-no-assertions.ts
|
|
10616
|
+
// src/rules/csharp/qa-cs-103-no-assertions.ts
|
|
10400
10617
|
/**
|
|
10401
10618
|
* QA-CS-103 — Test method without assertions.
|
|
10402
10619
|
* Severity: error · Confidence: high · deterministic-defect
|
|
@@ -10430,7 +10647,6 @@ const csSkippedTest = defineRule({
|
|
|
10430
10647
|
* Deep-diffing those requires symbol semantics Mjölnir deliberately does
|
|
10431
10648
|
* not promise for C# (plan §13.4).
|
|
10432
10649
|
*/
|
|
10433
|
-
/** Helper/verification idiom: verify/check/assert-prefixed names. */
|
|
10434
10650
|
const WHY$5 = "Without an assertion the test can only fail by throwing — it cannot detect behavioral regressions.";
|
|
10435
10651
|
const FIX$4 = "Add an assertion on the expected outcome, or remove the test.";
|
|
10436
10652
|
function makeFinding(path, position, name) {
|
|
@@ -10533,7 +10749,7 @@ const csNoAssertions = defineRule({
|
|
|
10533
10749
|
detectionStrategy: "AST",
|
|
10534
10750
|
detectionNotes: "L2 tree-sitter test-method scoping + structural assertion oracle (Shouldly/Verify/Assert receivers, throwing waits); regex fallback when no parse",
|
|
10535
10751
|
introduced: "0.3.8",
|
|
10536
|
-
tier: "
|
|
10752
|
+
tier: "extended",
|
|
10537
10753
|
detectorRevision: 2,
|
|
10538
10754
|
astQuery: cs103AstQuery,
|
|
10539
10755
|
run(ctx) {
|
|
@@ -10594,8 +10810,7 @@ function matchBrace(text, open) {
|
|
|
10594
10810
|
}
|
|
10595
10811
|
return -1;
|
|
10596
10812
|
}
|
|
10597
|
-
|
|
10598
|
-
//#region src/rules/csharp/qa-cs-105-wait-for-timeout.ts
|
|
10813
|
+
// src/rules/csharp/qa-cs-105-wait-for-timeout.ts
|
|
10599
10814
|
/**
|
|
10600
10815
|
* QA-CS-105 — Playwright-.NET: WaitForTimeoutAsync hard sleep.
|
|
10601
10816
|
* Severity: warning · Confidence: high · deterministic-defect
|
|
@@ -10683,8 +10898,7 @@ const csWaitForTimeout = defineRule({
|
|
|
10683
10898
|
return findings;
|
|
10684
10899
|
}
|
|
10685
10900
|
});
|
|
10686
|
-
|
|
10687
|
-
//#region src/rules/cypress/qa-cyp-001-cy-wait.ts
|
|
10901
|
+
// src/rules/cypress/qa-cyp-001-cy-wait.ts
|
|
10688
10902
|
/**
|
|
10689
10903
|
* QA-CYP-001 — Fixed `cy.wait(n)` (hard-coded wait).
|
|
10690
10904
|
* Severity: warning · Confidence: high · deterministic-defect
|
|
@@ -10763,8 +10977,7 @@ function isCypressFile(ctx) {
|
|
|
10763
10977
|
if (/\.cy\.[jt]sx?$/.test(ctx.path)) return true;
|
|
10764
10978
|
return /\bcy\s*\./.test(ctx.codeText ?? ctx.text);
|
|
10765
10979
|
}
|
|
10766
|
-
|
|
10767
|
-
//#region src/rules/cypress/qa-cyp-002-focused-test.ts
|
|
10980
|
+
// src/rules/cypress/qa-cyp-002-focused-test.ts
|
|
10768
10981
|
/**
|
|
10769
10982
|
* QA-CYP-002 — Focused test (`.only`).
|
|
10770
10983
|
* Severity: error · Confidence: high · deterministic-defect
|
|
@@ -10827,8 +11040,7 @@ const cypFocusedTest = defineRule({
|
|
|
10827
11040
|
return findings;
|
|
10828
11041
|
}
|
|
10829
11042
|
});
|
|
10830
|
-
|
|
10831
|
-
//#region src/rules/cypress/qa-cyp-003-config-security.ts
|
|
11043
|
+
// src/rules/cypress/qa-cyp-003-config-security.ts
|
|
10832
11044
|
/**
|
|
10833
11045
|
* QA-CYP-003 — Cypress config disables `chromeWebSecurity`.
|
|
10834
11046
|
* Severity: error · Confidence: high · deterministic-defect
|
|
@@ -10898,8 +11110,7 @@ const cypConfigSecurity = defineRule({
|
|
|
10898
11110
|
return findings;
|
|
10899
11111
|
}
|
|
10900
11112
|
});
|
|
10901
|
-
|
|
10902
|
-
//#region src/rules/selenium/qa-se-sleep-lookup.ts
|
|
11113
|
+
// src/rules/selenium/qa-se-sleep-lookup.ts
|
|
10903
11114
|
/**
|
|
10904
11115
|
* Selenium sleep-then-interact rules (Verification Trust Evolution Plan
|
|
10905
11116
|
* §15.3 — "Selenium implicit-wait abuse / missing WebDriverWait",
|
|
@@ -11050,8 +11261,7 @@ const sePythonSleepLookup = defineRule({
|
|
|
11050
11261
|
return sleepBeforeLookup(ctx, PY_SLEEP_RE, PY_LOOKUP_RE, "QA-SE-003");
|
|
11051
11262
|
}
|
|
11052
11263
|
});
|
|
11053
|
-
|
|
11054
|
-
//#region src/rules/shared/family.ts
|
|
11264
|
+
// src/rules/shared/family.ts
|
|
11055
11265
|
/**
|
|
11056
11266
|
* Cross-language pattern rule family generator (Phase 6 — Tempering Plan).
|
|
11057
11267
|
*
|
|
@@ -11117,8 +11327,7 @@ function definePatternFamily(opts) {
|
|
|
11117
11327
|
}
|
|
11118
11328
|
}));
|
|
11119
11329
|
}
|
|
11120
|
-
|
|
11121
|
-
//#region src/rules/families/hard-sleep.ts
|
|
11330
|
+
// src/rules/families/hard-sleep.ts
|
|
11122
11331
|
/**
|
|
11123
11332
|
* Hard-sleep family (Phase 6 — Tempering Plan).
|
|
11124
11333
|
* Java: Thread.sleep(), C#: Thread.Sleep() / Task.Delay()
|
|
@@ -11259,7 +11468,7 @@ const hardSleepFamily = definePatternFamily({
|
|
|
11259
11468
|
"mstest",
|
|
11260
11469
|
"playwright"
|
|
11261
11470
|
],
|
|
11262
|
-
tier: "
|
|
11471
|
+
tier: "extended",
|
|
11263
11472
|
detectionStrategy: "AST",
|
|
11264
11473
|
detectionNotes: "L2 tree-sitter invocation scoping (route/expose/server delegates, infinite blocks, WhenAny races, runner payload fixtures); regex fallback when no parse",
|
|
11265
11474
|
detectorRevision: 2,
|
|
@@ -11269,8 +11478,7 @@ const hardSleepFamily = definePatternFamily({
|
|
|
11269
11478
|
fix: "Use `await Assertions.Expect(locator).ToBeVisibleAsync()` or locator.WaitForAsync()."
|
|
11270
11479
|
}]
|
|
11271
11480
|
});
|
|
11272
|
-
|
|
11273
|
-
//#region src/rules/families/network-idle.ts
|
|
11481
|
+
// src/rules/families/network-idle.ts
|
|
11274
11482
|
/**
|
|
11275
11483
|
* Network-idle family (Phase 6 — Tempering Plan).
|
|
11276
11484
|
* All three language variants of waitForLoadState(networkidle).
|
|
@@ -11331,8 +11539,7 @@ const networkIdleFamily = definePatternFamily({
|
|
|
11331
11539
|
}
|
|
11332
11540
|
]
|
|
11333
11541
|
});
|
|
11334
|
-
|
|
11335
|
-
//#region src/rules/families/hardcoded-url.ts
|
|
11542
|
+
// src/rules/families/hardcoded-url.ts
|
|
11336
11543
|
/**
|
|
11337
11544
|
* Hardcoded-URL family (Phase 6 — Tempering Plan).
|
|
11338
11545
|
* Detects absolute URLs in navigation/request calls across languages.
|
|
@@ -11382,8 +11589,7 @@ const hardcodedUrlFamily = definePatternFamily({
|
|
|
11382
11589
|
fix: "Use `--base-url` / `base_url` fixture, or `os.environ[...]`."
|
|
11383
11590
|
}]
|
|
11384
11591
|
});
|
|
11385
|
-
|
|
11386
|
-
//#region src/rules/families/shared-page.ts
|
|
11592
|
+
// src/rules/families/shared-page.ts
|
|
11387
11593
|
/**
|
|
11388
11594
|
* Shared-page family (Phase 6 — Tempering Plan).
|
|
11389
11595
|
* Detects static/module-level Page/Browser instances shared across tests.
|
|
@@ -11444,8 +11650,7 @@ const sharedPageFamily = definePatternFamily({
|
|
|
11444
11650
|
}
|
|
11445
11651
|
]
|
|
11446
11652
|
});
|
|
11447
|
-
|
|
11448
|
-
//#region src/rules/families/brittle-selectors.ts
|
|
11653
|
+
// src/rules/families/brittle-selectors.ts
|
|
11449
11654
|
/**
|
|
11450
11655
|
* Brittle-selectors family (Phase 6 — Tempering Plan).
|
|
11451
11656
|
* Detects XPath/structural CSS selectors across Java, C#, Python.
|
|
@@ -11557,8 +11762,7 @@ const brittleSelectorsFamily = [
|
|
|
11557
11762
|
}
|
|
11558
11763
|
], "Prefer role-based locators (`get_by_role(...)`) or data-testid attributes.")
|
|
11559
11764
|
];
|
|
11560
|
-
|
|
11561
|
-
//#region src/rules/families/retry-masking.ts
|
|
11765
|
+
// src/rules/families/retry-masking.ts
|
|
11562
11766
|
/**
|
|
11563
11767
|
* Retry-masking family (Phase 6 — Tempering Plan).
|
|
11564
11768
|
* Detects retry annotations/attributes that mask flaky tests.
|
|
@@ -11582,7 +11786,7 @@ const retryMaskingFamily = [defineRule({
|
|
|
11582
11786
|
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"
|
|
11583
11787
|
},
|
|
11584
11788
|
introduced: "0.4.0",
|
|
11585
|
-
tier: "
|
|
11789
|
+
tier: "extended",
|
|
11586
11790
|
run(ctx) {
|
|
11587
11791
|
const text = ctx.codeText ?? ctx.text;
|
|
11588
11792
|
const findings = [];
|
|
@@ -11678,8 +11882,7 @@ const retryMaskingFamily = [defineRule({
|
|
|
11678
11882
|
return findings;
|
|
11679
11883
|
}
|
|
11680
11884
|
})];
|
|
11681
|
-
|
|
11682
|
-
//#region src/rules/index.ts
|
|
11885
|
+
// src/rules/index.ts
|
|
11683
11886
|
const RULES = [
|
|
11684
11887
|
focusedTestCommitted,
|
|
11685
11888
|
skippedTest,
|
|
@@ -11760,55 +11963,117 @@ const RULES = [
|
|
|
11760
11963
|
function getRule(id) {
|
|
11761
11964
|
return RULES.find((r) => r.id === id);
|
|
11762
11965
|
}
|
|
11763
|
-
|
|
11764
|
-
//#region src/lib/compare.ts
|
|
11966
|
+
// src/lib/wilson.ts
|
|
11765
11967
|
/**
|
|
11766
|
-
*
|
|
11968
|
+
* 95% Wilson score interval on a proportion (plan §20.2).
|
|
11767
11969
|
*
|
|
11768
|
-
* `
|
|
11769
|
-
*
|
|
11770
|
-
*
|
|
11771
|
-
*
|
|
11772
|
-
*
|
|
11773
|
-
*
|
|
11774
|
-
*
|
|
11775
|
-
* repository then produces two different fingerprints.
|
|
11970
|
+
* Lives in `src/` rather than `scripts/` because a measurement's interval is
|
|
11971
|
+
* now part of the ENGINE's tier decision (`src/rules/measurement.ts`), not
|
|
11972
|
+
* only a reporting nicety for the capability matrix. `scripts/lib/wilson.ts`
|
|
11973
|
+
* re-exports this module, so the number a gate prints and the number the
|
|
11974
|
+
* engine tiers on are the same number computed once. Two implementations
|
|
11975
|
+
* would be two sources of truth for the same figure, which is the failure
|
|
11976
|
+
* mode this whole release is about.
|
|
11776
11977
|
*
|
|
11777
|
-
*
|
|
11978
|
+
* The 4-dp rounding keeps generated artifacts byte-stable (see the capability
|
|
11979
|
+
* matrix generator's note on deterministic output).
|
|
11980
|
+
*/
|
|
11981
|
+
const Z95 = 1.959963984540054;
|
|
11982
|
+
/** 95% Wilson score interval on fp/n (fp successes of n trials), 4 dp. */
|
|
11983
|
+
function wilsonInterval(fp, n) {
|
|
11984
|
+
if (n <= 0) return {
|
|
11985
|
+
ciLow: 0,
|
|
11986
|
+
ciHigh: 1
|
|
11987
|
+
};
|
|
11988
|
+
const p = fp / n;
|
|
11989
|
+
const z2 = Z95 * Z95;
|
|
11990
|
+
const denom = 1 + z2 / n;
|
|
11991
|
+
const center = (p + z2 / (2 * n)) / denom;
|
|
11992
|
+
const half = Z95 / denom * Math.sqrt(p * (1 - p) / n + z2 / (4 * n * n));
|
|
11993
|
+
const round4 = (x) => Math.round(x * 1e4) / 1e4;
|
|
11994
|
+
return {
|
|
11995
|
+
ciLow: round4(Math.max(0, center - half)),
|
|
11996
|
+
ciHigh: round4(Math.min(1, center + half))
|
|
11997
|
+
};
|
|
11998
|
+
}
|
|
11999
|
+
/** The rule's declared detector implementation revision (§07). */
|
|
12000
|
+
function declaredDetectorRevision(rule) {
|
|
12001
|
+
return rule.detectorRevision ?? 1;
|
|
12002
|
+
}
|
|
12003
|
+
/**
|
|
12004
|
+
* A measurement exists AND was taken against the detector revision the
|
|
12005
|
+
* rule declares now. A revision mismatch (stale) does NOT count — §07:
|
|
12006
|
+
* stale → provisional → re-measure.
|
|
12007
|
+
*/
|
|
12008
|
+
function hasValidMeasurement(rule) {
|
|
12009
|
+
const m = MEASURED_FP[rule.id];
|
|
12010
|
+
return m !== void 0 && m.detectorRevision === declaredDetectorRevision(rule);
|
|
12011
|
+
}
|
|
12012
|
+
/**
|
|
12013
|
+
* The confidence interval of a rule's VALID measurement, or undefined when
|
|
12014
|
+
* it has none. A stale measurement returns undefined rather than its stale
|
|
12015
|
+
* interval: a number derived from an older detector is not evidence about
|
|
12016
|
+
* this one, and returning it would let the straddle test pass on stale data.
|
|
11778
12017
|
*
|
|
11779
|
-
*
|
|
11780
|
-
*
|
|
11781
|
-
*
|
|
11782
|
-
*
|
|
11783
|
-
*
|
|
12018
|
+
* The FP count is ROUNDED, and it is not an optimization. The shipped
|
|
12019
|
+
* measurement records a rate and a sample count, not an integer count of false
|
|
12020
|
+
* positives, so `fpRate * n` is generally fractional — 10.5% of 19 is 1.995.
|
|
12021
|
+
* `wilsonInterval` rounds internally, so passing the raw product gave this
|
|
12022
|
+
* function and `scripts/core-readiness.ts` two different `ciHigh` values for
|
|
12023
|
+
* the same rule at the same n: the ratchet decided one tier and the
|
|
12024
|
+
* readiness table printed another, both derived, neither reconciled. Every
|
|
12025
|
+
* caller now reconstructs the count the same way.
|
|
12026
|
+
*/
|
|
12027
|
+
function measurementInterval(rule) {
|
|
12028
|
+
if (!hasValidMeasurement(rule)) return void 0;
|
|
12029
|
+
const m = MEASURED_FP[rule.id];
|
|
12030
|
+
if (m === void 0) return void 0;
|
|
12031
|
+
return wilsonInterval(Math.round(m.fpRate * m.n), m.n);
|
|
12032
|
+
}
|
|
12033
|
+
/**
|
|
12034
|
+
* The tier the MEASUREMENT alone supports, ignoring any declared tier.
|
|
11784
12035
|
*
|
|
11785
|
-
*
|
|
11786
|
-
*
|
|
11787
|
-
*
|
|
11788
|
-
* rather than removed because a reader-facing list of "Test / Logic /
|
|
11789
|
-
* Mock" should not be ordered by code unit.
|
|
12036
|
+
* This is the interval floor that `effectiveTier` falls back to, extracted so
|
|
12037
|
+
* the tightening law can ask the question `effectiveTier` structurally cannot:
|
|
12038
|
+
* a declared tier may only ever hold a rule DOWN, never release it.
|
|
11790
12039
|
*
|
|
11791
|
-
*
|
|
11792
|
-
* `
|
|
12040
|
+
* `effectiveTier` cannot answer that, because it short-circuits on
|
|
12041
|
+
* `rule.tier` — asking it what a rule's tier "would" be returns the declared
|
|
12042
|
+
* value, and a comparison built on it compares the declaration with itself.
|
|
12043
|
+
* The first B0 dry-run made exactly that mistake and reported zero
|
|
12044
|
+
* mismatches for a comparison that had never run.
|
|
12045
|
+
*
|
|
12046
|
+
* READ THIS BEFORE USING IT AS A FLOOR. Measured over all 79 live rules, this
|
|
12047
|
+
* function returns `extended` for 78 of them and `quarantine` for none: on
|
|
12048
|
+
* this corpus the interval rule is very nearly an empty rule, because
|
|
12049
|
+
* `ciLow >= 50%` is not reached at n = 10..80. A "declared tier may only
|
|
12050
|
+
* tighten" law written against THIS function is decorative — it can only ever
|
|
12051
|
+
* catch a rule declaring `core`, which no rule declares. It was written that
|
|
12052
|
+
* way first and measured at zero teeth.
|
|
12053
|
+
*
|
|
12054
|
+
* The floor with teeth is `defensibleTier`, below.
|
|
11793
12055
|
*/
|
|
11794
|
-
|
|
11795
|
-
|
|
11796
|
-
if (
|
|
11797
|
-
|
|
12056
|
+
function measurementTier(rule) {
|
|
12057
|
+
const interval = measurementInterval(rule);
|
|
12058
|
+
if (interval === void 0) return "extended";
|
|
12059
|
+
if (interval.ciHigh <= .1) return "core";
|
|
12060
|
+
if (interval.ciLow >= .5) return "quarantine";
|
|
12061
|
+
return "extended";
|
|
11798
12062
|
}
|
|
11799
12063
|
/**
|
|
11800
|
-
*
|
|
12064
|
+
* The tier a rule effectively ships as: its declared tier, or — for the
|
|
12065
|
+
* omitted-tier case — what its measurement's interval supports (§11.2 Step 2).
|
|
11801
12066
|
*
|
|
11802
|
-
* The
|
|
11803
|
-
*
|
|
11804
|
-
*
|
|
11805
|
-
*
|
|
12067
|
+
* The declared tier is a floor that may only tighten, and the measurement is
|
|
12068
|
+
* what it is measured against. See `rule.ts`'s `tier` doc for the B0
|
|
12069
|
+
* measurement behind that, and `defensibleTier` for the floor it is measured
|
|
12070
|
+
* against.
|
|
11806
12071
|
*/
|
|
11807
|
-
function
|
|
11808
|
-
|
|
12072
|
+
function effectiveTier(rule) {
|
|
12073
|
+
if (rule.tier !== void 0) return rule.tier;
|
|
12074
|
+
return measurementTier(rule);
|
|
11809
12075
|
}
|
|
11810
|
-
|
|
11811
|
-
//#region src/scorer/scorer.ts
|
|
12076
|
+
// src/scorer/scorer.ts
|
|
11812
12077
|
/**
|
|
11813
12078
|
* Transparent scorer (Product-MVP §8).
|
|
11814
12079
|
* score = 100 − Σ(severity deductions), floor 0.
|
|
@@ -11960,9 +12225,9 @@ function countTestDeclarations(text, codeText) {
|
|
|
11960
12225
|
const source = codeText ?? text;
|
|
11961
12226
|
const patterns = [
|
|
11962
12227
|
/\b(?:it|test)(?:\.\w+)*\s*(?:\(|`)/g,
|
|
11963
|
-
|
|
11964
|
-
|
|
11965
|
-
|
|
12228
|
+
/^[ \t]*(?:async[ \t]+)?def[ \t]+test_\w*/gm,
|
|
12229
|
+
/^[ \t]*@Test\b/gm,
|
|
12230
|
+
/^[ \t]*\[(?:Test|Fact|Theory|TestMethod)\b/gm
|
|
11966
12231
|
];
|
|
11967
12232
|
let count = 0;
|
|
11968
12233
|
for (const re of patterns) {
|
|
@@ -11971,8 +12236,7 @@ function countTestDeclarations(text, codeText) {
|
|
|
11971
12236
|
}
|
|
11972
12237
|
return count;
|
|
11973
12238
|
}
|
|
11974
|
-
|
|
11975
|
-
//#region src/engine/rule-runner.ts
|
|
12239
|
+
// src/engine/rule-runner.ts
|
|
11976
12240
|
/**
|
|
11977
12241
|
* Legacy appliesTo → adapter id mapping.
|
|
11978
12242
|
*
|
|
@@ -12011,32 +12275,109 @@ function asUniversal(rule) {
|
|
|
12011
12275
|
}
|
|
12012
12276
|
};
|
|
12013
12277
|
}
|
|
12014
|
-
|
|
12015
|
-
|
|
12278
|
+
// src/engine/tier-policy.ts
|
|
12279
|
+
/**
|
|
12280
|
+
* Tier policy (audit H-1): the declared tier is authoritative over a
|
|
12281
|
+
* rule's severity and evidence claims.
|
|
12282
|
+
*
|
|
12283
|
+
* Quarantine-tier rules exist because their real-world false-positive
|
|
12284
|
+
* rate is unproven or proven bad (docs/FP-AUDIT.md records several at
|
|
12285
|
+
* 100% measured FP). A rule known to be wrong every single time it
|
|
12286
|
+
* fires must never gate CI or deduct score — regardless of the
|
|
12287
|
+
* severity/evidenceLevel its metadata declares. The pipeline therefore
|
|
12288
|
+
* caps every quarantine finding to severity=info, evidence=E0, which
|
|
12289
|
+
* makes it advisory by construction (isAdvisoryFinding excludes E0).
|
|
12290
|
+
*
|
|
12291
|
+
* The core tier carries the opposite bound (ADR 0014). A CAP says a tier
|
|
12292
|
+
* may not be stronger than this; a FLOOR says it may not be weaker. Core
|
|
12293
|
+
* is earned — it means the measurement cleared the false-positive
|
|
12294
|
+
* ceiling with enough samples to trust the interval — so a core rule
|
|
12295
|
+
* emitting a finding that costs nothing and blocks nothing is claiming a
|
|
12296
|
+
* promotion it has not cashed in. Before the floor existed, core and
|
|
12297
|
+
* extended were enforced identically, and reaching core changed only the
|
|
12298
|
+
* dedup tie-break, the M3 ceiling and the census readout: a consumer
|
|
12299
|
+
* could not tell them apart.
|
|
12300
|
+
*/
|
|
12016
12301
|
/** Cap applied to every finding of a quarantine-tier rule (H-1). */
|
|
12017
12302
|
const QUARANTINE_CAP = {
|
|
12018
12303
|
severity: "info",
|
|
12019
12304
|
evidenceLevel: "E0"
|
|
12020
12305
|
};
|
|
12021
|
-
/**
|
|
12022
|
-
|
|
12023
|
-
|
|
12024
|
-
|
|
12306
|
+
/**
|
|
12307
|
+
* Floor applied to every finding of a core-tier rule (ADR 0014).
|
|
12308
|
+
*
|
|
12309
|
+
* `E1`, not `E2`, and the reason is the difference between the two.
|
|
12310
|
+
* `E2` would be a claim that the finding is proven, which is a statement
|
|
12311
|
+
* about the code that no tier policy may make — corroboration does that,
|
|
12312
|
+
* at runtime, per finding. `E1` is the weaker and sufficient claim: a
|
|
12313
|
+
* core rule is measured, so its finding is pattern evidence rather than
|
|
12314
|
+
* an observation, and it is worth half the deduction.
|
|
12315
|
+
*
|
|
12316
|
+
* `E0` is what the floor exists to forbid. `isAdvisoryFinding` is
|
|
12317
|
+
* `level === "E0"`, so an E0 finding is simultaneously worth zero points
|
|
12318
|
+
* and unable to fail a gate. A rule whose false-positive rate is
|
|
12319
|
+
* measured under the 10% ceiling should never be able to produce one.
|
|
12320
|
+
*/
|
|
12321
|
+
const CORE_FLOOR = { evidenceLevel: "E1" };
|
|
12322
|
+
/**
|
|
12323
|
+
* The policy for a tier.
|
|
12324
|
+
*
|
|
12325
|
+
* `undefined` returns an EMPTY policy, and this is a decision, not a
|
|
12326
|
+
* convenience. `buildUniversalRules` in `scan-pipeline.ts` populates
|
|
12327
|
+
* `tierByRuleId` with `effectiveTier(rule)` for every registry rule — never
|
|
12328
|
+
* undefined — and adds a plugin or workspace-local rule only when that rule
|
|
12329
|
+
* DECLARES a tier. So an absent entry means "a third-party rule that claimed no
|
|
12330
|
+
* tier", and it is the one population that must not inherit a trust bound.
|
|
12331
|
+
*
|
|
12332
|
+
* It used to read "undefined tier = core" in this docstring, and that sentence
|
|
12333
|
+
* was load-bearing in the wrong direction. Before the floor, an absent entry
|
|
12334
|
+
* cost nothing, so the claim was inert. With a core floor, treating undefined as
|
|
12335
|
+
* core would raise every plugin's E0 finding to E1 and start charging
|
|
12336
|
+
* third-party detections for deductions — the exact trust escalation TI-013
|
|
12337
|
+
* exists to prevent, introduced by the ADR written to close a trust gap. A
|
|
12338
|
+
* plugin that wants the core floor has to declare `tier: "core"` and be
|
|
12339
|
+
* reviewed for it.
|
|
12340
|
+
*/
|
|
12341
|
+
function policyForTier(tier) {
|
|
12342
|
+
if (tier === "quarantine") return { cap: QUARANTINE_CAP };
|
|
12343
|
+
if (tier === "core") return { floor: CORE_FLOOR };
|
|
12344
|
+
return {};
|
|
12345
|
+
}
|
|
12346
|
+
/**
|
|
12347
|
+
* E-ladder order. "E2" > "E1" > "E0" is a claim about how much evidence a
|
|
12348
|
+
* finding carries, and the floor is only meaningful if "at least E1" can be
|
|
12349
|
+
* stated. Declared as a ladder rather than compared as strings because
|
|
12350
|
+
* `"E0" > "E1"` is true in JavaScript and would invert the floor.
|
|
12351
|
+
*/
|
|
12352
|
+
const EVIDENCE_RANK$1 = {
|
|
12353
|
+
E0: 0,
|
|
12354
|
+
E1: 1,
|
|
12355
|
+
E2: 2
|
|
12356
|
+
};
|
|
12025
12357
|
/**
|
|
12026
12358
|
* In-place enforcement over a scan's findings. Idempotent; runs AFTER
|
|
12027
|
-
* evidence stamping so it wins over rule-declared evidenceLevel
|
|
12359
|
+
* evidence stamping so it wins over rule-declared evidenceLevel — for the
|
|
12360
|
+
* cap and the floor alike. A rule's own `evidenceLevel` in `RuleMeta` is a
|
|
12361
|
+
* declaration about the rule; the tier is a decision about the rule, and
|
|
12362
|
+
* the decision outranks the declaration.
|
|
12363
|
+
*
|
|
12364
|
+
* Idempotence: cap-then-floor is a fixed point. A finding already at the
|
|
12365
|
+
* cap stays at the cap, and one already at or above the floor stays put,
|
|
12366
|
+
* so running this twice cannot walk a finding up the ladder.
|
|
12028
12367
|
*/
|
|
12029
12368
|
function enforceTierPolicy(findings, tierByRuleId) {
|
|
12030
12369
|
for (const f of findings) {
|
|
12031
|
-
const cap =
|
|
12370
|
+
const { cap, floor } = policyForTier(tierByRuleId.get(f.ruleId));
|
|
12032
12371
|
if (cap) {
|
|
12033
12372
|
f.severity = cap.severity;
|
|
12034
12373
|
f.evidenceLevel = cap.evidenceLevel;
|
|
12035
12374
|
}
|
|
12375
|
+
if (floor?.evidenceLevel === void 0) continue;
|
|
12376
|
+
const effective = f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence);
|
|
12377
|
+
if (EVIDENCE_RANK$1[effective] < EVIDENCE_RANK$1[floor.evidenceLevel]) f.evidenceLevel = floor.evidenceLevel;
|
|
12036
12378
|
}
|
|
12037
12379
|
}
|
|
12038
|
-
|
|
12039
|
-
//#region src/engine/evidence-core.ts
|
|
12380
|
+
// src/engine/evidence-core.ts
|
|
12040
12381
|
/**
|
|
12041
12382
|
* The core's own version — stamped into every record's provenance so a
|
|
12042
12383
|
* consumer can tell which normalization contract produced a record.
|
|
@@ -12103,8 +12444,7 @@ function countEvidence(records) {
|
|
|
12103
12444
|
}
|
|
12104
12445
|
return counts;
|
|
12105
12446
|
}
|
|
12106
|
-
|
|
12107
|
-
//#region src/engine/provenance.ts
|
|
12447
|
+
// src/engine/provenance.ts
|
|
12108
12448
|
const GENERATED_HEADER_RE = /^\s*(?:\/\/|#|\/\*)\s*(?:auto[- ]?generated|generated by|do not edit)/i;
|
|
12109
12449
|
/**
|
|
12110
12450
|
* Codegen fingerprint: the recorder names its first test exactly
|
|
@@ -12145,8 +12485,7 @@ function computeAgenticProfile(files, findings) {
|
|
|
12145
12485
|
note: "Static provenance markers only (generated-file headers, Playwright codegen default titles). Absence of a marker does not prove human authorship; healed and MCP-edited files without markers are indistinguishable from hand-written ones. Provenance never changes scoring or evidence — authorship is metadata, not trust."
|
|
12146
12486
|
};
|
|
12147
12487
|
}
|
|
12148
|
-
|
|
12149
|
-
//#region src/engine/overlap-dedup.ts
|
|
12488
|
+
// src/engine/overlap-dedup.ts
|
|
12150
12489
|
const TIER_RANK = {
|
|
12151
12490
|
core: 0,
|
|
12152
12491
|
extended: 1,
|
|
@@ -12221,8 +12560,8 @@ function isDeclaredByPresent(f, present, metaByRuleId) {
|
|
|
12221
12560
|
function ranksBefore(a, b, metaByRuleId) {
|
|
12222
12561
|
const ma = metaByRuleId.get(a.ruleId);
|
|
12223
12562
|
const mb = metaByRuleId.get(b.ruleId);
|
|
12224
|
-
const tierA = TIER_RANK[ma?.tier ?? "
|
|
12225
|
-
const tierB = TIER_RANK[mb?.tier ?? "
|
|
12563
|
+
const tierA = TIER_RANK[ma?.tier ?? "extended"];
|
|
12564
|
+
const tierB = TIER_RANK[mb?.tier ?? "extended"];
|
|
12226
12565
|
if (tierA !== tierB) return tierA < tierB;
|
|
12227
12566
|
const sevA = SEVERITY_RANK[a.severity];
|
|
12228
12567
|
const sevB = SEVERITY_RANK[b.severity];
|
|
@@ -12241,8 +12580,16 @@ function dropOrderBefore(a, b, metaByRuleId) {
|
|
|
12241
12580
|
if (ranksBefore(b, a, metaByRuleId)) return true;
|
|
12242
12581
|
return (a.column ?? Number.MAX_SAFE_INTEGER) < (b.column ?? Number.MAX_SAFE_INTEGER);
|
|
12243
12582
|
}
|
|
12244
|
-
|
|
12245
|
-
|
|
12583
|
+
// src/engine/finding-identity.ts
|
|
12584
|
+
/**
|
|
12585
|
+
* Correlation identity for before/after comparison: ruleId + file +
|
|
12586
|
+
* message, deliberately EXCLUDING line — a source edit that shifts a
|
|
12587
|
+
* finding still correlates. Shared by baseline, impact, and resolution.
|
|
12588
|
+
*/
|
|
12589
|
+
function findingFingerprint(f) {
|
|
12590
|
+
return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
|
|
12591
|
+
}
|
|
12592
|
+
// src/engine/correlation-engine.ts
|
|
12246
12593
|
const EVIDENCE_RANK = {
|
|
12247
12594
|
E0: 0,
|
|
12248
12595
|
E1: 1,
|
|
@@ -12286,13 +12633,31 @@ function groupByFile(findings) {
|
|
|
12286
12633
|
return groups;
|
|
12287
12634
|
}
|
|
12288
12635
|
/**
|
|
12289
|
-
* Group findings by
|
|
12290
|
-
*
|
|
12636
|
+
* Group findings by root cause, falling back to the FINDING FINGERPRINT.
|
|
12637
|
+
*
|
|
12638
|
+
* The fallback used to be `ruleId`, and that is the defect this fixes. Nothing
|
|
12639
|
+
* in the tree assigns `rootCauseId` — the machine contract reports it `null` on
|
|
12640
|
+
* every finding — so `ruleId` was the key for every group, and N independent
|
|
12641
|
+
* defects from one rule across N files were reported as
|
|
12642
|
+
*
|
|
12643
|
+
* CONVERGENT — N findings share root cause QA-PW-002
|
|
12644
|
+
*
|
|
12645
|
+
* which is the precise inverse of what convergence means. Convergence is the
|
|
12646
|
+
* claim that several findings are ONE thing; the key that produced it said
|
|
12647
|
+
* only "these are the same rule". A fingerprint is ruleId + file + message, so
|
|
12648
|
+
* two `QA-PW-002` findings in different files are two keys, two groups of one,
|
|
12649
|
+
* and neither is reported.
|
|
12650
|
+
*
|
|
12651
|
+
* This is also why `findingId`/`rootCauseId`/`deduplicationGroup` are not
|
|
12652
|
+
* wired instead: they are the same idea, and an unwired field plus a fallback
|
|
12653
|
+
* would be two mechanisms for one claim. When a rule declares a real root
|
|
12654
|
+
* cause, `rootCauseId` still wins — the field is honoured the moment anything
|
|
12655
|
+
* populates it.
|
|
12291
12656
|
*/
|
|
12292
12657
|
function groupByRootCause(findings) {
|
|
12293
12658
|
const groups = /* @__PURE__ */ new Map();
|
|
12294
12659
|
for (const f of findings) {
|
|
12295
|
-
const key = f.rootCauseId ?? f
|
|
12660
|
+
const key = f.rootCauseId ?? findingFingerprint(f);
|
|
12296
12661
|
const list = groups.get(key) ?? [];
|
|
12297
12662
|
list.push(f);
|
|
12298
12663
|
groups.set(key, list);
|
|
@@ -12300,6 +12665,17 @@ function groupByRootCause(findings) {
|
|
|
12300
12665
|
return groups;
|
|
12301
12666
|
}
|
|
12302
12667
|
/**
|
|
12668
|
+
* The identity a conclusion lists a finding under.
|
|
12669
|
+
*
|
|
12670
|
+
* `findingId` when something has populated it, else the fingerprint. Never
|
|
12671
|
+
* `ruleId`: the fallback used to collapse every finding from one rule onto one
|
|
12672
|
+
* id, so a three-finding CONVERGENT group listed the same id three times and
|
|
12673
|
+
* the `alreadyConvergent` set below could not tell the members apart.
|
|
12674
|
+
*/
|
|
12675
|
+
function conclusionId(f) {
|
|
12676
|
+
return f.findingId ?? findingFingerprint(f);
|
|
12677
|
+
}
|
|
12678
|
+
/**
|
|
12303
12679
|
* Correlate findings and produce conclusions WITHOUT modifying them.
|
|
12304
12680
|
* Deterministic — same input always produces same output.
|
|
12305
12681
|
*/
|
|
@@ -12308,7 +12684,7 @@ function correlateFindings(findings) {
|
|
|
12308
12684
|
const rootCauseGroups = groupByRootCause(findings);
|
|
12309
12685
|
for (const [rootCause, group] of Array.from(rootCauseGroups.entries()).sort((a, b) => compareLocalized("en")(a[0], b[0]))) {
|
|
12310
12686
|
if (group.length < 2) continue;
|
|
12311
|
-
const findingIds = group.map(
|
|
12687
|
+
const findingIds = group.map(conclusionId);
|
|
12312
12688
|
conclusions.push({
|
|
12313
12689
|
conclusionType: "CONVERGENT",
|
|
12314
12690
|
certainty: correlationStrength(group),
|
|
@@ -12322,9 +12698,9 @@ function correlateFindings(findings) {
|
|
|
12322
12698
|
if (group.length < 2) continue;
|
|
12323
12699
|
const alreadyConvergent = /* @__PURE__ */ new Set();
|
|
12324
12700
|
for (const c of conclusions) if (c.conclusionType === "CONVERGENT") for (const id of c.findingIds) alreadyConvergent.add(id);
|
|
12325
|
-
const novel = group.filter((f) => !alreadyConvergent.has(f
|
|
12701
|
+
const novel = group.filter((f) => !alreadyConvergent.has(conclusionId(f)));
|
|
12326
12702
|
if (novel.length < 2) continue;
|
|
12327
|
-
const findingIds = novel.map(
|
|
12703
|
+
const findingIds = novel.map(conclusionId);
|
|
12328
12704
|
conclusions.push({
|
|
12329
12705
|
conclusionType: "AMPLIFIED",
|
|
12330
12706
|
certainty: correlationStrength(novel),
|
|
@@ -12339,12 +12715,11 @@ function correlateFindings(findings) {
|
|
|
12339
12715
|
certainty: "STRONG",
|
|
12340
12716
|
corroboration: `${corroborated.length} finding(s) with runtime corroboration`,
|
|
12341
12717
|
sourceCount: corroborated.length,
|
|
12342
|
-
findingIds: corroborated.map(
|
|
12718
|
+
findingIds: corroborated.map(conclusionId)
|
|
12343
12719
|
});
|
|
12344
12720
|
return conclusions;
|
|
12345
12721
|
}
|
|
12346
|
-
|
|
12347
|
-
//#region src/engine/dependency-graph.ts
|
|
12722
|
+
// src/engine/dependency-graph.ts
|
|
12348
12723
|
/**
|
|
12349
12724
|
* Dependency Graph (ECO-005).
|
|
12350
12725
|
*
|
|
@@ -12481,8 +12856,7 @@ function parseManifest(filePath) {
|
|
|
12481
12856
|
if (filePath.endsWith("pyproject.toml")) return parsePyprojectToml(filePath);
|
|
12482
12857
|
if (filePath.endsWith("pom.xml")) return parsePomXml(filePath);
|
|
12483
12858
|
}
|
|
12484
|
-
|
|
12485
|
-
//#region src/engine/incremental-analysis.ts
|
|
12859
|
+
// src/engine/incremental-analysis.ts
|
|
12486
12860
|
/**
|
|
12487
12861
|
* Semantic inputs that affect the full scan beyond individual file content.
|
|
12488
12862
|
* If any of these changed, an incremental run is NOT safe — a full scan
|
|
@@ -12518,8 +12892,7 @@ function isIncrementalSafe(changedFiles) {
|
|
|
12518
12892
|
reasons
|
|
12519
12893
|
};
|
|
12520
12894
|
}
|
|
12521
|
-
|
|
12522
|
-
//#region src/brand/tokens.ts
|
|
12895
|
+
// src/brand/tokens.ts
|
|
12523
12896
|
/**
|
|
12524
12897
|
* The single source of brand truth.
|
|
12525
12898
|
*
|
|
@@ -12581,7 +12954,25 @@ const BRAND = {
|
|
|
12581
12954
|
auroraGreen: "#5FD6A4",
|
|
12582
12955
|
auroraViolet: "#9D8CF5",
|
|
12583
12956
|
steel: "#C8CBCF",
|
|
12584
|
-
steelDim: "#8B939D"
|
|
12957
|
+
steelDim: "#8B939D",
|
|
12958
|
+
/** The teal half of the accent rule, and the gradient's near stop. */
|
|
12959
|
+
readingAccent: "#73C4CA",
|
|
12960
|
+
/** The violet half of the accent rule, and the gradient's far stop. */
|
|
12961
|
+
readingAccentAlt: "#A596E5",
|
|
12962
|
+
/** The focus ring. A non-text affordance, held to the same AA floor. */
|
|
12963
|
+
readingFocus: "#8CE3ED",
|
|
12964
|
+
/** Hover state for the mark's monochrome glyph. */
|
|
12965
|
+
readingAccentBright: "#85DBED",
|
|
12966
|
+
/** Hover state for the wordmark's ink. */
|
|
12967
|
+
readingAccentInk: "#E5FCFF",
|
|
12968
|
+
/** UNWORTHY at reading contrast — a softer red than the terminal's. */
|
|
12969
|
+
readingUnworthy: "#FF9790",
|
|
12970
|
+
/** Muted body ink on the reading surfaces. */
|
|
12971
|
+
readingMuted: "#C6CFDB",
|
|
12972
|
+
/** Dimmed ink on the reading surfaces. */
|
|
12973
|
+
readingDim: "#ACB9CA",
|
|
12974
|
+
/** The hero lede: the brightest ink below `TEXT.primary`. */
|
|
12975
|
+
readingLede: "#E0E6EE"
|
|
12585
12976
|
};
|
|
12586
12977
|
/**
|
|
12587
12978
|
* Midnight iron. One ramp, four steps, darkest first.
|
|
@@ -12684,7 +13075,7 @@ const TRUST = {
|
|
|
12684
13075
|
l5: "#5CC4E0"
|
|
12685
13076
|
};
|
|
12686
13077
|
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;
|
|
12687
|
-
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;
|
|
13078
|
+
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;
|
|
12688
13079
|
EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2;
|
|
12689
13080
|
const RUNG_MEANINGS = [
|
|
12690
13081
|
"observation only",
|
|
@@ -12787,8 +13178,7 @@ function deriveScoreState(score) {
|
|
|
12787
13178
|
rune: RUNES.critical
|
|
12788
13179
|
};
|
|
12789
13180
|
}
|
|
12790
|
-
|
|
12791
|
-
//#region src/engine/monorepo-analysis.ts
|
|
13181
|
+
// src/engine/monorepo-analysis.ts
|
|
12792
13182
|
/**
|
|
12793
13183
|
* Package verdict, derived from the one ScoreState band model (BW-104).
|
|
12794
13184
|
* These two constants used to be a private copy of the band boundaries;
|
|
@@ -12895,8 +13285,7 @@ function configurableAggregation(results, config) {
|
|
|
12895
13285
|
strategy: config.weightingStrategy
|
|
12896
13286
|
};
|
|
12897
13287
|
}
|
|
12898
|
-
|
|
12899
|
-
//#region src/lib/fs-atomic.ts
|
|
13288
|
+
// src/lib/fs-atomic.ts
|
|
12900
13289
|
/**
|
|
12901
13290
|
* Atomic file writes (audit S9).
|
|
12902
13291
|
*
|
|
@@ -12979,8 +13368,7 @@ function renameWithWindowsRetry(from, to) {
|
|
|
12979
13368
|
throw err;
|
|
12980
13369
|
}
|
|
12981
13370
|
}
|
|
12982
|
-
|
|
12983
|
-
//#region src/engine/scan-cache.ts
|
|
13371
|
+
// src/engine/scan-cache.ts
|
|
12984
13372
|
/**
|
|
12985
13373
|
* Local incremental scan cache (Beta-to-Stable 1.0 plan, M5.2 / A-2).
|
|
12986
13374
|
*
|
|
@@ -13190,8 +13578,7 @@ function createScanCache(root) {
|
|
|
13190
13578
|
}
|
|
13191
13579
|
};
|
|
13192
13580
|
}
|
|
13193
|
-
|
|
13194
|
-
//#region src/reporter/theme.ts
|
|
13581
|
+
// src/reporter/theme.ts
|
|
13195
13582
|
/**
|
|
13196
13583
|
* Norse-forge theme system. Pure string-in → string-out helpers.
|
|
13197
13584
|
* Respects NO_COLOR and non-TTY via `palette(isTTY)` — every renderer
|
|
@@ -13291,8 +13678,7 @@ function rgb([r, g, b]) {
|
|
|
13291
13678
|
function palette(enabled) {
|
|
13292
13679
|
return enabled ? on : off;
|
|
13293
13680
|
}
|
|
13294
|
-
|
|
13295
|
-
//#region src/reporter/ui.ts
|
|
13681
|
+
// src/reporter/ui.ts
|
|
13296
13682
|
/**
|
|
13297
13683
|
* Neutral context for direct calls (tests, library use) and as the
|
|
13298
13684
|
* safe default when a command runner is invoked without wiring: no
|
|
@@ -13317,8 +13703,7 @@ function sectionHeader(title, ui) {
|
|
|
13317
13703
|
const glyph = ui.ascii ? "=" : "▍";
|
|
13318
13704
|
return ` ${ui.p.accent(`${glyph} ${title}`)}`;
|
|
13319
13705
|
}
|
|
13320
|
-
|
|
13321
|
-
//#region src/forensics/classify.ts
|
|
13706
|
+
// src/forensics/classify.ts
|
|
13322
13707
|
/**
|
|
13323
13708
|
* Strong-signal marker tables. Kept deliberately narrow: a marker here
|
|
13324
13709
|
* is a named family fact, not a vibe. Unknown text yields no signal —
|
|
@@ -13438,8 +13823,7 @@ function classifyForensicVerdict(signals) {
|
|
|
13438
13823
|
signals: signalCounts
|
|
13439
13824
|
};
|
|
13440
13825
|
}
|
|
13441
|
-
|
|
13442
|
-
//#region src/forensics/analyze.ts
|
|
13826
|
+
// src/forensics/analyze.ts
|
|
13443
13827
|
const ui$5 = plainContext();
|
|
13444
13828
|
const MAX_RECORDS = 1e5;
|
|
13445
13829
|
function analyze(records, source) {
|
|
@@ -13574,7 +13958,7 @@ function renderFlakyMd(report) {
|
|
|
13574
13958
|
const lines = [];
|
|
13575
13959
|
lines.push("# FLAKY.md");
|
|
13576
13960
|
lines.push("");
|
|
13577
|
-
lines.push(`> Generated by
|
|
13961
|
+
lines.push(`> Generated by Mjölnir from ${report.source} run data.`);
|
|
13578
13962
|
lines.push("> A test that passes only on retry is not a passing test — it is a lucky test.");
|
|
13579
13963
|
lines.push("");
|
|
13580
13964
|
lines.push(`**${report.totalTests}** tests analyzed · **${report.flakyTests}** true flakes · **${report.retriedTests}** retried · **${report.failed}** failing`);
|
|
@@ -13602,8 +13986,7 @@ function renderFlakyMd(report) {
|
|
|
13602
13986
|
lines.push("");
|
|
13603
13987
|
return lines.join("\n");
|
|
13604
13988
|
}
|
|
13605
|
-
|
|
13606
|
-
//#region src/forensics/evidence-hygiene.ts
|
|
13989
|
+
// src/forensics/evidence-hygiene.ts
|
|
13607
13990
|
/**
|
|
13608
13991
|
* Evidence hygiene — secret redaction + control-char sanitization
|
|
13609
13992
|
* (ENGINE-012).
|
|
@@ -13665,8 +14048,7 @@ function sanitizeErrorText(text, options) {
|
|
|
13665
14048
|
if (result.length > maxLength) result = result.slice(0, maxLength) + `[truncated at ${maxLength} chars]`;
|
|
13666
14049
|
return result;
|
|
13667
14050
|
}
|
|
13668
|
-
|
|
13669
|
-
//#region src/forensics/trace.ts
|
|
14051
|
+
// src/forensics/trace.ts
|
|
13670
14052
|
/**
|
|
13671
14053
|
* Playwright trace ingester (product-gap master plan §9 R5 / WI-17).
|
|
13672
14054
|
*
|
|
@@ -13859,8 +14241,7 @@ function parseTraceArtifact(bytes, artifactName) {
|
|
|
13859
14241
|
const { actions } = parseTraceNdjson(text);
|
|
13860
14242
|
return traceActionsToRecord(actions, artifactName);
|
|
13861
14243
|
}
|
|
13862
|
-
|
|
13863
|
-
//#region src/forensics/parse-jest-json.ts
|
|
14244
|
+
// src/forensics/parse-jest-json.ts
|
|
13864
14245
|
function toStatus$2(raw) {
|
|
13865
14246
|
switch (raw) {
|
|
13866
14247
|
case "passed": return "passed";
|
|
@@ -13920,21 +14301,20 @@ function parseJestJson(json) {
|
|
|
13920
14301
|
}
|
|
13921
14302
|
return out;
|
|
13922
14303
|
}
|
|
13923
|
-
|
|
13924
|
-
|
|
13925
|
-
|
|
13926
|
-
function decodeEntities(s) {
|
|
14304
|
+
// src/forensics/parse-junit.ts
|
|
14305
|
+
const MAX_INPUT$1 = 20971520;
|
|
14306
|
+
function decodeEntities$1(s) {
|
|
13927
14307
|
return s.replaceAll("<", "<").replaceAll(">", ">").replaceAll(""", "\"").replaceAll("'", "'").replaceAll("'", "'").replaceAll("&", "&");
|
|
13928
14308
|
}
|
|
13929
|
-
function attr(tag, name) {
|
|
14309
|
+
function attr$1(tag, name) {
|
|
13930
14310
|
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
13931
14311
|
const m = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*"([^"]*)"`, "i").exec(tag);
|
|
13932
|
-
if (m?.[1] !== void 0) return decodeEntities(m[1]);
|
|
14312
|
+
if (m?.[1] !== void 0) return decodeEntities$1(m[1]);
|
|
13933
14313
|
const m2 = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*'([^']*)'`, "i").exec(tag);
|
|
13934
|
-
return m2?.[1] !== void 0 ? decodeEntities(m2[1]) : void 0;
|
|
14314
|
+
return m2?.[1] !== void 0 ? decodeEntities$1(m2[1]) : void 0;
|
|
13935
14315
|
}
|
|
13936
14316
|
function parseJunitXml(xml) {
|
|
13937
|
-
if (xml.length > MAX_INPUT) return [];
|
|
14317
|
+
if (xml.length > MAX_INPUT$1) return [];
|
|
13938
14318
|
const out = [];
|
|
13939
14319
|
const startRe = /<testcase\b/gi;
|
|
13940
14320
|
const closeRe = /<\/testcase\s*>/gi;
|
|
@@ -13955,29 +14335,40 @@ function parseJunitXml(xml) {
|
|
|
13955
14335
|
inner = xml.slice(gt + 1, close.index);
|
|
13956
14336
|
startRe.lastIndex = close.index + close[0].length;
|
|
13957
14337
|
}
|
|
13958
|
-
const name = attr(attrs, "name") ?? "(unnamed)";
|
|
13959
|
-
const classname = attr(attrs, "classname") ?? "";
|
|
13960
|
-
const timeRaw = attr(attrs, "time");
|
|
14338
|
+
const name = attr$1(attrs, "name") ?? "(unnamed)";
|
|
14339
|
+
const classname = attr$1(attrs, "classname") ?? "";
|
|
14340
|
+
const timeRaw = attr$1(attrs, "time");
|
|
13961
14341
|
const timeSec = timeRaw !== void 0 && Number.isFinite(Number(timeRaw)) ? Number(timeRaw) : 0;
|
|
13962
14342
|
const failed = /<(?:failure|error)\b/i.test(inner);
|
|
13963
14343
|
const skipped = /<skipped\b/i.test(inner);
|
|
13964
14344
|
const errors = [];
|
|
13965
14345
|
if (failed) {
|
|
13966
|
-
const
|
|
13967
|
-
|
|
13968
|
-
|
|
13969
|
-
|
|
14346
|
+
const openRe = /<(?:failure|error)\b/gi;
|
|
14347
|
+
const endRe = /<\/(?:failure|error)\s*>/gi;
|
|
14348
|
+
let open;
|
|
14349
|
+
while ((open = openRe.exec(inner)) !== null) {
|
|
14350
|
+
const gt = inner.indexOf(">", open.index);
|
|
14351
|
+
if (gt === -1) break;
|
|
14352
|
+
const attrsStr = inner.slice(open.index, gt);
|
|
14353
|
+
if (attrsStr.endsWith("/")) {
|
|
14354
|
+
openRe.lastIndex = gt + 1;
|
|
14355
|
+
continue;
|
|
14356
|
+
}
|
|
14357
|
+
endRe.lastIndex = gt + 1;
|
|
14358
|
+
const end = endRe.exec(inner);
|
|
14359
|
+
if (end === null) break;
|
|
13970
14360
|
const msgAttr = /\smessage\s*=\s*"([^"]*)"/i.exec(attrsStr)?.[1];
|
|
13971
|
-
if (msgAttr) errors.push(sanitizeErrorText(decodeEntities(msgAttr)));
|
|
13972
|
-
const body =
|
|
13973
|
-
if (body && body !== msgAttr) errors.push(sanitizeErrorText(decodeEntities(body)));
|
|
14361
|
+
if (msgAttr) errors.push(sanitizeErrorText(decodeEntities$1(msgAttr)));
|
|
14362
|
+
const body = inner.slice(gt + 1, end.index).trim();
|
|
14363
|
+
if (body && body !== msgAttr) errors.push(sanitizeErrorText(decodeEntities$1(body)));
|
|
14364
|
+
openRe.lastIndex = end.index + end[0].length;
|
|
13974
14365
|
}
|
|
13975
14366
|
const selfCloseRe = /<(?:failure|error)\b([^>]*)\/>/gi;
|
|
13976
14367
|
let scMatch;
|
|
13977
14368
|
while ((scMatch = selfCloseRe.exec(inner)) !== null) {
|
|
13978
14369
|
const scAttrs = scMatch[1] ?? "";
|
|
13979
14370
|
const scMsgMatch = /\smessage\s*=\s*"([^"]*)"/i.exec(scAttrs);
|
|
13980
|
-
if (scMsgMatch?.[1]) errors.push(sanitizeErrorText(decodeEntities(scMsgMatch[1])));
|
|
14371
|
+
if (scMsgMatch?.[1]) errors.push(sanitizeErrorText(decodeEntities$1(scMsgMatch[1])));
|
|
13981
14372
|
}
|
|
13982
14373
|
}
|
|
13983
14374
|
const attempt = {
|
|
@@ -13994,8 +14385,156 @@ function parseJunitXml(xml) {
|
|
|
13994
14385
|
}
|
|
13995
14386
|
return out;
|
|
13996
14387
|
}
|
|
13997
|
-
|
|
13998
|
-
|
|
14388
|
+
// src/forensics/parse-trx.ts
|
|
14389
|
+
const MAX_INPUT = 20971520;
|
|
14390
|
+
function decodeEntities(s) {
|
|
14391
|
+
return s.replaceAll("<", "<").replaceAll(">", ">").replaceAll(""", "\"").replaceAll("'", "'").replaceAll("'", "'").replaceAll("&", "&");
|
|
14392
|
+
}
|
|
14393
|
+
function attr(tag, name) {
|
|
14394
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
14395
|
+
const m = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*"([^"]*)"`, "i").exec(tag);
|
|
14396
|
+
if (m?.[1] !== void 0) return decodeEntities(m[1]);
|
|
14397
|
+
const sq = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*'([^']*)'`, "i").exec(tag);
|
|
14398
|
+
if (sq?.[1] !== void 0) return decodeEntities(sq[1]);
|
|
14399
|
+
}
|
|
14400
|
+
/**
|
|
14401
|
+
* Does this document look like TRX at all?
|
|
14402
|
+
*
|
|
14403
|
+
* Shape-sniffed rather than trusted from its location, for the same reason the
|
|
14404
|
+
* JUnit path sniffs: a `TestResults/` directory is a convention, and a
|
|
14405
|
+
* convention can be wrong. A file that is not a `TestRun` must not be parsed as
|
|
14406
|
+
* one and produce confident nonsense.
|
|
14407
|
+
*/
|
|
14408
|
+
function looksLikeTrxXml(text) {
|
|
14409
|
+
return /<TestRun[\s>]/i.test(text.slice(0, 4096));
|
|
14410
|
+
}
|
|
14411
|
+
/**
|
|
14412
|
+
* TRX durations are `HH:MM:SS.fffffff`; absent or `-` means "not timed".
|
|
14413
|
+
*
|
|
14414
|
+
* Parsed by splitting rather than by one pattern. A single regex for this has
|
|
14415
|
+
* to be `^(\d+):(\d{2}):(\d{2})(?:\.(\d+))?$`, and the nested quantifiers there
|
|
14416
|
+
* are what `security/detect-unsafe-regex` flags — correctly in general, since
|
|
14417
|
+
* this shape is the usual ReDoS source. Splitting removes the question rather
|
|
14418
|
+
* than asserting the pattern is safe today.
|
|
14419
|
+
*/
|
|
14420
|
+
function parseDuration(raw) {
|
|
14421
|
+
if (raw === void 0) return 0;
|
|
14422
|
+
const text = raw.trim();
|
|
14423
|
+
const dot = text.indexOf(".");
|
|
14424
|
+
const clock = dot === -1 ? text : text.slice(0, dot);
|
|
14425
|
+
const fraction = dot === -1 ? "" : text.slice(dot + 1);
|
|
14426
|
+
const parts = clock.split(":");
|
|
14427
|
+
if (parts.length !== 3) return 0;
|
|
14428
|
+
const [h, m, s] = parts;
|
|
14429
|
+
if (h === void 0 || m === void 0 || s === void 0) return 0;
|
|
14430
|
+
if (!/^\d+$/.test(h) || !/^\d{1,2}$/.test(m) || !/^\d{1,2}$/.test(s)) return 0;
|
|
14431
|
+
const millis = fraction.length === 0 ? 0 : Number(fraction.slice(0, 3).padEnd(3, "0"));
|
|
14432
|
+
return Number(h) * 36e5 + Number(m) * 6e4 + Number(s) * 1e3 + millis;
|
|
14433
|
+
}
|
|
14434
|
+
/**
|
|
14435
|
+
* TRX `outcome` to a run status.
|
|
14436
|
+
*
|
|
14437
|
+
* The mapping is stated rather than inferred from a default, because the
|
|
14438
|
+
* dangerous direction here is a silent default: an outcome this engine has
|
|
14439
|
+
* never seen must not read as `passed`. Every unrecognised value maps to
|
|
14440
|
+
* `skipped`, which is the non-claiming direction — a skipped test contributes
|
|
14441
|
+
* nothing to "the suite passed" — rather than to a status that would let it.
|
|
14442
|
+
*
|
|
14443
|
+
* TRX also has outcomes that are not verdicts at all (`Warning`, `Pending`,
|
|
14444
|
+
* `Expired`). They are treated the same way, deliberately.
|
|
14445
|
+
*/
|
|
14446
|
+
function mapTrxOutcome(outcome) {
|
|
14447
|
+
switch ((outcome ?? "").toLowerCase()) {
|
|
14448
|
+
case "passed":
|
|
14449
|
+
case "completed": return "passed";
|
|
14450
|
+
case "failed":
|
|
14451
|
+
case "error": return "failed";
|
|
14452
|
+
case "timeout": return "timedOut";
|
|
14453
|
+
case "aborted":
|
|
14454
|
+
case "interrupted": return "interrupted";
|
|
14455
|
+
case "notexecuted":
|
|
14456
|
+
case "skipped":
|
|
14457
|
+
case "inconclusive":
|
|
14458
|
+
case "notrunnable":
|
|
14459
|
+
case "pending": return "skipped";
|
|
14460
|
+
default: return "skipped";
|
|
14461
|
+
}
|
|
14462
|
+
}
|
|
14463
|
+
function tagOf(xml, name) {
|
|
14464
|
+
const out = [];
|
|
14465
|
+
const re = new RegExp(`<${name}\\b[^>]*>`, "gi");
|
|
14466
|
+
let m;
|
|
14467
|
+
while ((m = re.exec(xml)) !== null) {
|
|
14468
|
+
if (m[0] !== void 0) out.push(m[0]);
|
|
14469
|
+
if (out.length > 2e5) break;
|
|
14470
|
+
}
|
|
14471
|
+
return out;
|
|
14472
|
+
}
|
|
14473
|
+
/** The text of a `<Message>` child, used as the failure's error text. */
|
|
14474
|
+
function messageOf(text, near) {
|
|
14475
|
+
const window = text.slice(near, near + 2e3);
|
|
14476
|
+
const m = /<Message[^>]*>([\s\S]{0,1500}?)<\/Message>/i.exec(window);
|
|
14477
|
+
if (m?.[1] === void 0) return void 0;
|
|
14478
|
+
const cleaned = sanitizeErrorText(decodeEntities(m[1].replace(/<[^>]*>/g, " ")).trim());
|
|
14479
|
+
return cleaned.length === 0 ? void 0 : cleaned;
|
|
14480
|
+
}
|
|
14481
|
+
/**
|
|
14482
|
+
* Parse a TRX document into test records.
|
|
14483
|
+
*
|
|
14484
|
+
* Returns an empty array for a document that is not TRX rather than throwing:
|
|
14485
|
+
* discovery sniffs before parsing, and a caller that hands us a non-TRX file
|
|
14486
|
+
* should get "no evidence here", not an exception that aborts a whole run
|
|
14487
|
+
* report.
|
|
14488
|
+
*/
|
|
14489
|
+
function parseTrxXml(xml) {
|
|
14490
|
+
if (xml.length > MAX_INPUT) return [];
|
|
14491
|
+
if (!looksLikeTrxXml(xml)) return [];
|
|
14492
|
+
const byTestId = /* @__PURE__ */ new Map();
|
|
14493
|
+
for (const tag of tagOf(xml, "UnitTest")) {
|
|
14494
|
+
const id = attr(tag, "id");
|
|
14495
|
+
const name = attr(tag, "name");
|
|
14496
|
+
if (id !== void 0 && name !== void 0) byTestId.set(id, name);
|
|
14497
|
+
}
|
|
14498
|
+
const records = [];
|
|
14499
|
+
const startRe = /<UnitTestResult\b/gi;
|
|
14500
|
+
const starts = [];
|
|
14501
|
+
let s;
|
|
14502
|
+
while ((s = startRe.exec(xml)) !== null) {
|
|
14503
|
+
starts.push(s.index);
|
|
14504
|
+
if (starts.length > 2e5) break;
|
|
14505
|
+
}
|
|
14506
|
+
const resultsEnd = xml.search(/<\/Results>/i);
|
|
14507
|
+
for (let i = 0; i < starts.length; i++) {
|
|
14508
|
+
const start = starts[i];
|
|
14509
|
+
const tagEnd = xml.indexOf(">", start);
|
|
14510
|
+
if (tagEnd === -1) break;
|
|
14511
|
+
const selfClosing = xml[tagEnd - 1] === "/";
|
|
14512
|
+
const attrs = xml.slice(start + 15, selfClosing ? tagEnd - 1 : tagEnd);
|
|
14513
|
+
const next = starts[i + 1] ?? -1;
|
|
14514
|
+
let bodyEnd = next === -1 ? xml.length : next;
|
|
14515
|
+
if (resultsEnd > tagEnd && resultsEnd < bodyEnd) bodyEnd = resultsEnd;
|
|
14516
|
+
const inner = selfClosing ? "" : xml.slice(tagEnd + 1, bodyEnd);
|
|
14517
|
+
const testName = attr(attrs, "testName") ?? attr(attrs, "testId");
|
|
14518
|
+
if (testName === void 0) continue;
|
|
14519
|
+
const attempt = {
|
|
14520
|
+
index: 1,
|
|
14521
|
+
status: mapTrxOutcome(attr(attrs, "outcome")),
|
|
14522
|
+
durationMs: parseDuration(attr(attrs, "duration"))
|
|
14523
|
+
};
|
|
14524
|
+
const messageAt = inner.search(/<Message\b/i);
|
|
14525
|
+
const error = messageAt === -1 ? void 0 : messageOf(inner, messageAt);
|
|
14526
|
+
records.push({
|
|
14527
|
+
file: "unknown",
|
|
14528
|
+
title: testName,
|
|
14529
|
+
attempts: [attempt],
|
|
14530
|
+
evidenceKind: "test",
|
|
14531
|
+
...error === void 0 ? {} : { errors: [error] }
|
|
14532
|
+
});
|
|
14533
|
+
if (records.length > 2e5) break;
|
|
14534
|
+
}
|
|
14535
|
+
return records;
|
|
14536
|
+
}
|
|
14537
|
+
// src/forensics/parse-playwright-json.ts
|
|
13999
14538
|
const MAX_DEPTH = 32;
|
|
14000
14539
|
function toStatus$1(raw) {
|
|
14001
14540
|
switch (raw) {
|
|
@@ -14060,8 +14599,7 @@ function parsePlaywrightJson(json) {
|
|
|
14060
14599
|
walk(root, 0);
|
|
14061
14600
|
return out;
|
|
14062
14601
|
}
|
|
14063
|
-
|
|
14064
|
-
//#region src/forensics/parse-vitest-json.ts
|
|
14602
|
+
// src/forensics/parse-vitest-json.ts
|
|
14065
14603
|
function toStatus(raw) {
|
|
14066
14604
|
switch (raw) {
|
|
14067
14605
|
case "passed": return "passed";
|
|
@@ -14115,8 +14653,7 @@ function parseVitestJson(json) {
|
|
|
14115
14653
|
}
|
|
14116
14654
|
return out;
|
|
14117
14655
|
}
|
|
14118
|
-
|
|
14119
|
-
//#region src/forensics/parse-har.ts
|
|
14656
|
+
// src/forensics/parse-har.ts
|
|
14120
14657
|
const MAX_ENTRIES = 2e4;
|
|
14121
14658
|
function sanitizeTitleUrl(url) {
|
|
14122
14659
|
try {
|
|
@@ -14201,10 +14738,11 @@ function parseHarJsonDetailed(json) {
|
|
|
14201
14738
|
truncated
|
|
14202
14739
|
};
|
|
14203
14740
|
}
|
|
14204
|
-
|
|
14205
|
-
//#region src/forensics/run.ts
|
|
14741
|
+
// src/forensics/run.ts
|
|
14206
14742
|
/**
|
|
14207
|
-
*
|
|
14743
|
+
* Runtime evidence entry point (R4) — the library behind `explain
|
|
14744
|
+
* --evidence`. The `forensics` verb is retired in the v6 carve; this module
|
|
14745
|
+
* is what reads the run data.
|
|
14208
14746
|
*
|
|
14209
14747
|
* Accepts either a single report file or a directory. In a directory it
|
|
14210
14748
|
* looks for, in priority order:
|
|
@@ -14404,6 +14942,13 @@ function traceArtifactName(fullPath) {
|
|
|
14404
14942
|
return base;
|
|
14405
14943
|
}
|
|
14406
14944
|
function parseFile(path, text) {
|
|
14945
|
+
if (/\.trx$/i.test(path) || looksLikeTrxXml(text)) {
|
|
14946
|
+
const records = parseTrxXml(text);
|
|
14947
|
+
if (records.length > 0) return {
|
|
14948
|
+
records,
|
|
14949
|
+
source: "dotnet-trx"
|
|
14950
|
+
};
|
|
14951
|
+
}
|
|
14407
14952
|
if (/\.xml$/i.test(path) || /^\s*<\?xml|<testsuite\b/i.test(text.slice(0, 200))) return {
|
|
14408
14953
|
records: parseJunitXml(text),
|
|
14409
14954
|
source: "junit-xml"
|
|
@@ -14484,8 +15029,7 @@ function loadSuppressions(root, now = /* @__PURE__ */ new Date()) {
|
|
|
14484
15029
|
entries
|
|
14485
15030
|
};
|
|
14486
15031
|
}
|
|
14487
|
-
|
|
14488
|
-
//#region src/plugins/reserved-prefixes.ts
|
|
15032
|
+
// src/plugins/reserved-prefixes.ts
|
|
14489
15033
|
/**
|
|
14490
15034
|
* Core-owned rule-ID prefixes that external rule sources (npm plugins,
|
|
14491
15035
|
* JS modules, JSON manifests) may never claim — one shared law.
|
|
@@ -14515,8 +15059,7 @@ function isReservedPrefix(ruleId) {
|
|
|
14515
15059
|
const upper = ruleId.toUpperCase();
|
|
14516
15060
|
return RESERVED_PREFIXES.some((p) => upper.startsWith(p));
|
|
14517
15061
|
}
|
|
14518
|
-
|
|
14519
|
-
//#region src/plugins/load.ts
|
|
15062
|
+
// src/plugins/load.ts
|
|
14520
15063
|
/**
|
|
14521
15064
|
* Plugin API (Upgrade-Plan-v3 Phase 6, tier-2 item #1).
|
|
14522
15065
|
*
|
|
@@ -14622,8 +15165,7 @@ function loadPlugins(root, gateOpen = false) {
|
|
|
14622
15165
|
}
|
|
14623
15166
|
return result;
|
|
14624
15167
|
}
|
|
14625
|
-
|
|
14626
|
-
//#region src/plugins/local-rules.ts
|
|
15168
|
+
// src/plugins/local-rules.ts
|
|
14627
15169
|
/**
|
|
14628
15170
|
* Local plugin/rule contract (Verification Trust Evolution Plan §18 —
|
|
14629
15171
|
* Local Extensibility).
|
|
@@ -14655,7 +15197,7 @@ function loadPlugins(root, gateOpen = false) {
|
|
|
14655
15197
|
* - Load failures degrade honestly: warning entries, never a crash;
|
|
14656
15198
|
* exit codes stay frozen.
|
|
14657
15199
|
*
|
|
14658
|
-
* Drift-check: the `mjolnir
|
|
15200
|
+
* Drift-check: the `mjolnir explain --list --md` catalog is generated from the
|
|
14659
15201
|
* LOADED rules (core + external), so the catalog can never drift from
|
|
14660
15202
|
* what actually ships — an edit to a local rule file changes the very
|
|
14661
15203
|
* next catalog render (locked by tests/local-rules.spec.ts).
|
|
@@ -14935,8 +15477,7 @@ async function loadModuleRules(path, result) {
|
|
|
14935
15477
|
result.rules.push(rule);
|
|
14936
15478
|
}
|
|
14937
15479
|
}
|
|
14938
|
-
|
|
14939
|
-
//#region src/plugins/trust-gate.ts
|
|
15480
|
+
// src/plugins/trust-gate.ts
|
|
14940
15481
|
/**
|
|
14941
15482
|
* Plugin trust gate (audit-remediation-master-plan.md, locked decision C2).
|
|
14942
15483
|
*
|
|
@@ -14972,8 +15513,7 @@ function renderGateNotice(skipped) {
|
|
|
14972
15513
|
lines.push("To run them, re-run with --enable-plugins (or set MJOLNIR_ENABLE_PLUGINS=1).", "Declarative JSON rule manifests (mjolnir-rules/*.json) are unaffected — they execute no code.");
|
|
14973
15514
|
return lines.join("\n");
|
|
14974
15515
|
}
|
|
14975
|
-
|
|
14976
|
-
//#region src/engine/scan-pipeline.ts
|
|
15516
|
+
// src/engine/scan-pipeline.ts
|
|
14977
15517
|
/**
|
|
14978
15518
|
* Canonical scan pipeline (M6 — blueprint §9.2).
|
|
14979
15519
|
*
|
|
@@ -15001,7 +15541,7 @@ const KNOWN_RULE_IDS = new Set(RULES.map((r) => r.id));
|
|
|
15001
15541
|
const OVERLAP_META_BY_RULE_ID = new Map(RULES.map((r, order) => {
|
|
15002
15542
|
const meta = {
|
|
15003
15543
|
...r.overlapWith ? { overlapWith: r.overlapWith } : {},
|
|
15004
|
-
|
|
15544
|
+
tier: effectiveTier(r),
|
|
15005
15545
|
order
|
|
15006
15546
|
};
|
|
15007
15547
|
return [r.id, meta];
|
|
@@ -15029,11 +15569,15 @@ async function buildUniversalRules(root, strict, opts = {}) {
|
|
|
15029
15569
|
rules: local.rules
|
|
15030
15570
|
}].flatMap((p) => p.rules.map(asUniversal));
|
|
15031
15571
|
const tierByRuleId = /* @__PURE__ */ new Map();
|
|
15032
|
-
for (const r of RULES)
|
|
15572
|
+
for (const r of RULES) tierByRuleId.set(r.id, effectiveTier(r));
|
|
15033
15573
|
for (const p of plugins) for (const r of p.rules) if (r.tier) tierByRuleId.set(r.id, r.tier);
|
|
15034
15574
|
for (const r of local.rules) if (r.tier) tierByRuleId.set(r.id, r.tier);
|
|
15035
15575
|
let rules = [...UNIVERSAL_RULES, ...externalRules];
|
|
15576
|
+
const quarantined = rules.filter((r) => tierByRuleId.get(r.id) === "quarantine").map((r) => r.id).sort();
|
|
15577
|
+
const withheldRuleIds = strict ? [] : quarantined;
|
|
15036
15578
|
if (!strict) rules = rules.filter((r) => tierByRuleId.get(r.id) !== "quarantine");
|
|
15579
|
+
const rulesWithheld = withheldRuleIds.length;
|
|
15580
|
+
const rulesApplied = rules.length;
|
|
15037
15581
|
const pluginMeta = [...plugins.map((p) => ({
|
|
15038
15582
|
name: p.name,
|
|
15039
15583
|
rules: p.rules.length
|
|
@@ -15046,7 +15590,10 @@ async function buildUniversalRules(root, strict, opts = {}) {
|
|
|
15046
15590
|
pluginErrors: allErrors,
|
|
15047
15591
|
tierByRuleId,
|
|
15048
15592
|
pluginMeta,
|
|
15049
|
-
externalRules: local.rules
|
|
15593
|
+
externalRules: local.rules,
|
|
15594
|
+
rulesApplied,
|
|
15595
|
+
rulesWithheld,
|
|
15596
|
+
withheldRuleIds
|
|
15050
15597
|
};
|
|
15051
15598
|
}
|
|
15052
15599
|
/** Rule-declared evidence-level overrides (Honesty Core). */
|
|
@@ -15071,35 +15618,6 @@ function fallbackWorkspace(targetAbs) {
|
|
|
15071
15618
|
};
|
|
15072
15619
|
}
|
|
15073
15620
|
/**
|
|
15074
|
-
* Minimal glob match for suppression `files` patterns, with gitignore
|
|
15075
|
-
* `**` semantics (bug-audit M5). Supports:
|
|
15076
|
-
* "tests/**" — everything inside tests/
|
|
15077
|
-
* "tests" + "/**\/*.spec.ts" — any depth UNDER tests/ (including none) ending in .spec.ts
|
|
15078
|
-
* "**" + "/*.spec.ts" — any depth including root-level files
|
|
15079
|
-
* "tests/foo.spec.ts" — exact path
|
|
15080
|
-
* "*" within a segment never crosses "/".
|
|
15081
|
-
*
|
|
15082
|
-
* Forward slashes only (findings always use normalized paths). `?`,
|
|
15083
|
-
* character classes and `!` negation are not metacharacters here — same
|
|
15084
|
-
* as before this rewrite.
|
|
15085
|
-
*/
|
|
15086
|
-
function pathMatchesGlob(path, glob) {
|
|
15087
|
-
const p = path.replaceAll("\\", "/");
|
|
15088
|
-
const segments = glob.replaceAll("\\", "/").split("/");
|
|
15089
|
-
let re = "^";
|
|
15090
|
-
for (const [i, segment] of segments.entries()) {
|
|
15091
|
-
const last = i === segments.length - 1;
|
|
15092
|
-
if (segment === "**") {
|
|
15093
|
-
if (last) re += "(?:[^/]+/)*[^/]+";
|
|
15094
|
-
else re += "(?:[^/]+/)*";
|
|
15095
|
-
continue;
|
|
15096
|
-
}
|
|
15097
|
-
re += segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", "[^/]*");
|
|
15098
|
-
if (!last) re += "/";
|
|
15099
|
-
}
|
|
15100
|
-
return new RegExp(`${re}$`).test(p);
|
|
15101
|
-
}
|
|
15102
|
-
/**
|
|
15103
15621
|
* Plan §16 + WI-11: locate a runtime run report next to the scan
|
|
15104
15622
|
* target, using the exact conventions the forensics ingestion already
|
|
15105
15623
|
* accepts. Zero-config search over conventional artifact names at
|
|
@@ -15455,12 +15973,28 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
|
|
|
15455
15973
|
const runtimeIncomplete = discoveredReport !== void 0 && discoveredReport.report.analysisComplete !== true;
|
|
15456
15974
|
let forensicVerdicts;
|
|
15457
15975
|
let evidenceRecords = [];
|
|
15458
|
-
if (discoveredReport && discoveredReport.report.analysisComplete === true)
|
|
15459
|
-
|
|
15460
|
-
|
|
15461
|
-
|
|
15462
|
-
|
|
15463
|
-
|
|
15976
|
+
if (discoveredReport && discoveredReport.report.analysisComplete === true) {
|
|
15977
|
+
const before = findings.map((f) => ({
|
|
15978
|
+
runtimeCorroboration: f.runtimeCorroboration,
|
|
15979
|
+
trustLevel: f.trustLevel
|
|
15980
|
+
}));
|
|
15981
|
+
try {
|
|
15982
|
+
const records = buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
|
|
15983
|
+
stampRuntimeCorroboration(findings, discoveredReport.report, workspace.root);
|
|
15984
|
+
forensicVerdicts = summarizeForensicVerdicts(discoveredReport.report);
|
|
15985
|
+
evidenceRecords = records;
|
|
15986
|
+
} catch {
|
|
15987
|
+
findings.forEach((f, i) => {
|
|
15988
|
+
const prev = before[i];
|
|
15989
|
+
if (prev?.runtimeCorroboration === void 0) delete f.runtimeCorroboration;
|
|
15990
|
+
else f.runtimeCorroboration = prev.runtimeCorroboration;
|
|
15991
|
+
if (prev?.trustLevel === void 0) delete f.trustLevel;
|
|
15992
|
+
else f.trustLevel = prev.trustLevel;
|
|
15993
|
+
});
|
|
15994
|
+
forensicVerdicts = void 0;
|
|
15995
|
+
evidenceRecords = [];
|
|
15996
|
+
recordDegradation("runtime-corroboration-failed");
|
|
15997
|
+
}
|
|
15464
15998
|
}
|
|
15465
15999
|
return {
|
|
15466
16000
|
evidenceRecords,
|
|
@@ -15604,6 +16138,9 @@ function assembleScanResult(o) {
|
|
|
15604
16138
|
parseFallbacks: o.parseFallbacks ?? 0,
|
|
15605
16139
|
...o.astFallbackFiles !== void 0 ? { astFallbackFiles: o.astFallbackFiles } : {},
|
|
15606
16140
|
...o.degradations !== void 0 ? { degradations: o.degradations } : {},
|
|
16141
|
+
...o.rulesWithheld !== void 0 ? { rulesWithheld: o.rulesWithheld } : {},
|
|
16142
|
+
...o.withheldRuleIds !== void 0 ? { withheldRuleIds: o.withheldRuleIds } : {},
|
|
16143
|
+
...o.rulesApplied !== void 0 ? { rulesApplied: o.rulesApplied } : {},
|
|
15607
16144
|
...o.scopeInfo.degraded !== void 0 ? { scopeDegraded: o.scopeInfo.degraded } : {},
|
|
15608
16145
|
...o.runtimeIncomplete !== void 0 ? { runtimeIncomplete: o.runtimeIncomplete } : {},
|
|
15609
16146
|
identityIncomplete
|
|
@@ -15618,11 +16155,15 @@ function assembleScanResult(o) {
|
|
|
15618
16155
|
})),
|
|
15619
16156
|
config: o.config ?? null,
|
|
15620
16157
|
engineVersion: ENGINE_VERSION,
|
|
16158
|
+
runMode: resolveRunMode({
|
|
16159
|
+
env: {},
|
|
16160
|
+
flags: {}
|
|
16161
|
+
}),
|
|
15621
16162
|
reportDigest,
|
|
15622
16163
|
trustModelVersion: TRUST_MODEL_VERSION,
|
|
15623
16164
|
scoringModelVersion: SCORING_MODEL_VERSION,
|
|
15624
16165
|
frameworkSupportMatrixVersion: FRAMEWORK_SUPPORT_MATRIX_VERSION,
|
|
15625
|
-
evidenceSchemaVersions: [
|
|
16166
|
+
evidenceSchemaVersions: [2],
|
|
15626
16167
|
...repository,
|
|
15627
16168
|
...candidate !== null ? { candidate } : {}
|
|
15628
16169
|
});
|
|
@@ -15635,7 +16176,10 @@ function assembleScanResult(o) {
|
|
|
15635
16176
|
});
|
|
15636
16177
|
const hasTests = o.testFileCount > 0 && o.testDeclarationCount > 0;
|
|
15637
16178
|
const suiteInvalidatedBy = [...new Set(o.findings.filter((f) => SUITE_INVALIDATING_RULE_IDS.has(f.ruleId)).map((f) => f.ruleId))].sort();
|
|
15638
|
-
const
|
|
16179
|
+
const scopeClamped = scopeReasons.length > 0 && total >= 100;
|
|
16180
|
+
const partialClamped = completion.partial && scopeAdjustedTotal >= 100;
|
|
16181
|
+
const scoreClampReason = scopeClamped ? "scope-degraded" : partialClamped ? "partial-scan" : null;
|
|
16182
|
+
const finalScore = hasTests ? partialClamped ? 99 : scopeAdjustedTotal : null;
|
|
15639
16183
|
const result = {
|
|
15640
16184
|
schemaVersion: 1,
|
|
15641
16185
|
partial: completion.partial,
|
|
@@ -15674,9 +16218,11 @@ function assembleScanResult(o) {
|
|
|
15674
16218
|
} } : {},
|
|
15675
16219
|
analysisStatus: {
|
|
15676
16220
|
...completion.analysisStatus,
|
|
16221
|
+
coverageState: completion.coverageState,
|
|
15677
16222
|
durationMs: elapsed
|
|
15678
16223
|
},
|
|
15679
|
-
scoringModelVersion: SCORING_MODEL_VERSION
|
|
16224
|
+
scoringModelVersion: SCORING_MODEL_VERSION,
|
|
16225
|
+
...scoreClampReason !== null ? { scoreClampReason } : {}
|
|
15680
16226
|
};
|
|
15681
16227
|
result.trustSummary = buildTrustSummary(result, o.declarationsByFile);
|
|
15682
16228
|
if (o.findings.length > 0) result.correlationConclusions = correlateFindings(o.findings);
|
|
@@ -15720,7 +16266,23 @@ function assembleScanResult(o) {
|
|
|
15720
16266
|
* inherently async); `runRules` and every rule stay synchronous and
|
|
15721
16267
|
* consume `ParsedFile.ast`. Callers await the returned promise.
|
|
15722
16268
|
*/
|
|
16269
|
+
/**
|
|
16270
|
+
* Run one scan inside its own ts-morph Project scope.
|
|
16271
|
+
*
|
|
16272
|
+
* The wrapper is the whole fix. ts-morph's Project caches SourceFiles by path
|
|
16273
|
+
* and `parseTsFile` calls `replaceWithText` on them, so a Project shared between
|
|
16274
|
+
* two concurrent scans lets one rewrite the file the other is mid-rule on —
|
|
16275
|
+
* which surfaced as three of four concurrent scans reporting
|
|
16276
|
+
* `rulesCrashed: 4` and `rules: "partial"`, and therefore as two different
|
|
16277
|
+
* machine-contract digests from one tree. See `src/engine/ts-ast.ts`.
|
|
16278
|
+
*
|
|
16279
|
+
* Async context propagates across `await`, so the scope covers the whole scan
|
|
16280
|
+
* without threading a Project through every adapter.
|
|
16281
|
+
*/
|
|
15723
16282
|
async function runScan(args, hooks = {}) {
|
|
16283
|
+
return runWithScanProject(() => runScanInProject(args, hooks));
|
|
16284
|
+
}
|
|
16285
|
+
async function runScanInProject(args, hooks = {}) {
|
|
15724
16286
|
const started = Date.now();
|
|
15725
16287
|
const degradationWindow = beginDegradationWindow();
|
|
15726
16288
|
const requestedDuration = Number.isFinite(args.maxDurationMs) ? args.maxDurationMs : DEFAULT_MAX_DURATION_MS$1;
|
|
@@ -15747,7 +16309,7 @@ async function runScan(args, hooks = {}) {
|
|
|
15747
16309
|
let scopeUnrecognized = 0;
|
|
15748
16310
|
let parseFailed = 0;
|
|
15749
16311
|
const fileProvenance = [];
|
|
15750
|
-
const { rules: activeRules, pluginErrors, tierByRuleId: tiers, pluginMeta } = await buildUniversalRules(workspace.root, args.strict, {
|
|
16312
|
+
const { rules: activeRules, pluginErrors, tierByRuleId: tiers, pluginMeta, rulesApplied, rulesWithheld, withheldRuleIds } = await buildUniversalRules(workspace.root, args.strict, {
|
|
15751
16313
|
...args.enablePlugins !== void 0 ? { enablePlugins: args.enablePlugins } : {},
|
|
15752
16314
|
onGateNotice: (notice) => hooks.onGateNotice ? hooks.onGateNotice(notice) : console.error(notice)
|
|
15753
16315
|
});
|
|
@@ -15836,6 +16398,9 @@ async function runScan(args, hooks = {}) {
|
|
|
15836
16398
|
parseFallbacks,
|
|
15837
16399
|
astFallbackFiles,
|
|
15838
16400
|
degradations: summarizeDegradations(degradationsSince(degradationWindow)),
|
|
16401
|
+
rulesApplied,
|
|
16402
|
+
rulesWithheld,
|
|
16403
|
+
...withheldRuleIds.length > 0 ? { withheldRuleIds } : {},
|
|
15839
16404
|
scanned,
|
|
15840
16405
|
analyzed,
|
|
15841
16406
|
testFiles,
|
|
@@ -15862,8 +16427,7 @@ async function runScan(args, hooks = {}) {
|
|
|
15862
16427
|
resetTsMorphProject();
|
|
15863
16428
|
return result;
|
|
15864
16429
|
}
|
|
15865
|
-
|
|
15866
|
-
//#region src/cli-io.ts
|
|
16430
|
+
// src/cli-io.ts
|
|
15867
16431
|
/**
|
|
15868
16432
|
* The ONE error-to-message derivation in `src/`.
|
|
15869
16433
|
*
|
|
@@ -15940,6 +16504,10 @@ function canonicalScanJson(result) {
|
|
|
15940
16504
|
skippedFiles: result.analysisStatus.skippedFiles,
|
|
15941
16505
|
rulesCrashed: result.analysisStatus.rulesCrashed ?? 0,
|
|
15942
16506
|
parseFallbacks: result.analysisStatus.parseFallbacks ?? 0,
|
|
16507
|
+
coverageState: result.analysisStatus.coverageState ?? "COMPLETE",
|
|
16508
|
+
rulesApplied: result.analysisStatus.rulesApplied ?? null,
|
|
16509
|
+
rulesWithheld: result.analysisStatus.rulesWithheld ?? 0,
|
|
16510
|
+
degradations: result.analysisStatus.degradations ?? [],
|
|
15943
16511
|
truncationReasons: result.analysisStatus.truncationReasons ?? [],
|
|
15944
16512
|
reasons: result.analysisStatus.reasons ?? []
|
|
15945
16513
|
},
|
|
@@ -15983,7 +16551,7 @@ function buildMachineContract(result) {
|
|
|
15983
16551
|
};
|
|
15984
16552
|
});
|
|
15985
16553
|
return {
|
|
15986
|
-
contractVersion:
|
|
16554
|
+
contractVersion: 2,
|
|
15987
16555
|
summary: {
|
|
15988
16556
|
digest: `sha256:${digest}`,
|
|
15989
16557
|
findings: result.findings.length,
|
|
@@ -16004,6 +16572,10 @@ function buildMachineContract(result) {
|
|
|
16004
16572
|
...result.analysisStatus.parseFallbacks !== void 0 ? { parseFallbacks: result.analysisStatus.parseFallbacks } : {},
|
|
16005
16573
|
truncationReasons: result.analysisStatus.truncationReasons ?? [],
|
|
16006
16574
|
...result.analysisStatus.reasons !== void 0 ? { reasons: result.analysisStatus.reasons } : {},
|
|
16575
|
+
...result.analysisStatus.degradations !== void 0 ? { degradations: result.analysisStatus.degradations } : {},
|
|
16576
|
+
...result.analysisStatus.coverageState !== void 0 ? { coverageState: result.analysisStatus.coverageState } : {},
|
|
16577
|
+
...result.analysisStatus.rulesApplied !== void 0 ? { rulesApplied: result.analysisStatus.rulesApplied } : {},
|
|
16578
|
+
...result.analysisStatus.rulesWithheld !== void 0 ? { rulesWithheld: result.analysisStatus.rulesWithheld } : {},
|
|
16007
16579
|
frameworkDetectionUnknown: result.frameworkDetectionUnknown,
|
|
16008
16580
|
durationMs: result.analysisStatus.durationMs,
|
|
16009
16581
|
...result.scopeIntegrity !== void 0 ? { scopeIntegrity: result.scopeIntegrity } : {},
|
|
@@ -16014,8 +16586,7 @@ function buildMachineContract(result) {
|
|
|
16014
16586
|
...result.forensicVerdicts !== void 0 ? { forensicVerdicts: result.forensicVerdicts } : {}
|
|
16015
16587
|
};
|
|
16016
16588
|
}
|
|
16017
|
-
|
|
16018
|
-
//#region src/commands/fixture-example.ts
|
|
16589
|
+
// src/commands/fixture-example.ts
|
|
16019
16590
|
/**
|
|
16020
16591
|
* Shared example-fixture selection for `mjolnir explain` (explain.ts)
|
|
16021
16592
|
* and the generated rule docs (rule-docs.ts) — one chooser so both
|
|
@@ -16045,7 +16616,7 @@ function explainRule(ruleId, fixturesRoot) {
|
|
|
16045
16616
|
const rule = getRule(ruleId);
|
|
16046
16617
|
if (!rule) return {
|
|
16047
16618
|
ok: false,
|
|
16048
|
-
error: `Unknown rule ID "${ruleId}". Run \`mjolnir
|
|
16619
|
+
error: `Unknown rule ID "${ruleId}". Run \`mjolnir explain --list\` for the full catalog.`
|
|
16049
16620
|
};
|
|
16050
16621
|
const window = beginDegradationWindow();
|
|
16051
16622
|
const degraded = () => {
|
|
@@ -16116,18 +16687,7 @@ function explainRule(ruleId, fixturesRoot) {
|
|
|
16116
16687
|
exampleFixtureRelPath: relative(fixturesRoot, fixturePath).replaceAll("\\", "/")
|
|
16117
16688
|
};
|
|
16118
16689
|
}
|
|
16119
|
-
|
|
16120
|
-
//#region src/engine/finding-identity.ts
|
|
16121
|
-
/**
|
|
16122
|
-
* Correlation identity for before/after comparison: ruleId + file +
|
|
16123
|
-
* message, deliberately EXCLUDING line — a source edit that shifts a
|
|
16124
|
-
* finding still correlates. Shared by baseline, impact, and resolution.
|
|
16125
|
-
*/
|
|
16126
|
-
function findingFingerprint(f) {
|
|
16127
|
-
return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
|
|
16128
|
-
}
|
|
16129
|
-
//#endregion
|
|
16130
|
-
//#region src/engine/resolution.ts
|
|
16690
|
+
// src/engine/resolution.ts
|
|
16131
16691
|
/** Correlation identity (v1-compatible): ruleId\0file\0message. */
|
|
16132
16692
|
const fingerprint$1 = findingFingerprint;
|
|
16133
16693
|
/**
|
|
@@ -16147,6 +16707,11 @@ function resolve$1(input) {
|
|
|
16147
16707
|
cause: "crash",
|
|
16148
16708
|
comparedAgainst
|
|
16149
16709
|
};
|
|
16710
|
+
if (current.analysisStatus.withheldRuleIds?.includes(entry.ruleId) === true) return {
|
|
16711
|
+
status: "INCONCLUSIVE",
|
|
16712
|
+
cause: "rule-withheld",
|
|
16713
|
+
comparedAgainst
|
|
16714
|
+
};
|
|
16150
16715
|
if (input.skippedFiles?.has(entry.file)) return {
|
|
16151
16716
|
status: "INCONCLUSIVE",
|
|
16152
16717
|
cause: "skipped",
|
|
@@ -16273,16 +16838,15 @@ function diffAgainstBaseline(result, baseline) {
|
|
|
16273
16838
|
unchangedCount
|
|
16274
16839
|
};
|
|
16275
16840
|
}
|
|
16276
|
-
|
|
16277
|
-
//#region src/commands/verify.ts
|
|
16841
|
+
// src/commands/verify.ts
|
|
16278
16842
|
/**
|
|
16279
|
-
* `mjolnir verify` — the agent-loop digest (product-gap-remediation
|
|
16843
|
+
* `mjolnir ci verify` — the agent-loop digest (product-gap-remediation
|
|
16280
16844
|
* master plan P7, plan 1788853205786 — flag agent, decision 6).
|
|
16281
16845
|
*
|
|
16282
|
-
* An agent fixes findings, then runs `mjolnir verify` against the
|
|
16283
|
-
* committed baseline (established once
|
|
16846
|
+
* An agent fixes findings, then runs `mjolnir ci verify` against the
|
|
16847
|
+
* committed baseline (established once from a `mjolnir scan --json` run). The
|
|
16284
16848
|
* digest answers exactly the loop's questions, derived from the SAME
|
|
16285
|
-
* §15 comparison machinery as `mjolnir
|
|
16849
|
+
* §15 comparison machinery as `mjolnir scan --scope changed` (no second truth):
|
|
16286
16850
|
*
|
|
16287
16851
|
* - RESOLVED — baseline findings no longer present, each with its
|
|
16288
16852
|
* lifecycle resolution (only VERIFIED-RESOLVED is a fix claim);
|
|
@@ -16421,8 +16985,7 @@ function renderTriageWorkflowJson(report) {
|
|
|
16421
16985
|
rows: workflowRows(report)
|
|
16422
16986
|
}, null, 2) + "\n";
|
|
16423
16987
|
}
|
|
16424
|
-
|
|
16425
|
-
//#region src/commands/pw-report.ts
|
|
16988
|
+
// src/commands/pw-report.ts
|
|
16426
16989
|
const ui = plainContext();
|
|
16427
16990
|
function summarizePwRun(report) {
|
|
16428
16991
|
const slowest = [...report.verdicts].sort((a, b) => b.totalDurationMs - a.totalDurationMs).slice(0, 5).map((v) => ({
|
|
@@ -16456,8 +17019,7 @@ function renderPwRunSummary(s) {
|
|
|
16456
17019
|
}
|
|
16457
17020
|
return lines.join("\n");
|
|
16458
17021
|
}
|
|
16459
|
-
|
|
16460
|
-
//#region src/mcp/server.ts
|
|
17022
|
+
// src/mcp/server.ts
|
|
16461
17023
|
/**
|
|
16462
17024
|
* MCP stdio transport (blueprint §21 — Phase 4, PURE TRANSPORT).
|
|
16463
17025
|
*
|
|
@@ -16475,7 +17037,10 @@ function renderPwRunSummary(s) {
|
|
|
16475
17037
|
* - parameter size caps; ONE scan in flight (serialized queue) reusing
|
|
16476
17038
|
* the pipeline's own budgets; zero network (no fetches anywhere); no
|
|
16477
17039
|
* shell execution beyond Mjölnir's own verbs.
|
|
16478
|
-
* - filesystem boundary
|
|
17040
|
+
* - filesystem boundary: the tools are read-only, and with no further
|
|
17041
|
+
* configuration they accept any path the parent process passes (the
|
|
17042
|
+
* parent is the trust boundary). `MJOLNIR_WORKSPACE_ROOT` confines every
|
|
17043
|
+
* path argument to that directory, symlinks resolved.
|
|
16479
17044
|
* - the plugin trust gate applies unchanged (--enable-plugins is NOT
|
|
16480
17045
|
* exposed over MCP: tools run with the gate CLOSED unless the server
|
|
16481
17046
|
* was started with MJOLNIR_ENABLE_PLUGINS=1 in its own environment).
|
|
@@ -16485,12 +17050,27 @@ const MAX_PARAM_BYTES = 65536;
|
|
|
16485
17050
|
const MAX_DURATION_MS = 36e5;
|
|
16486
17051
|
const DEFAULT_MAX_DURATION_MS = 6e5;
|
|
16487
17052
|
const PROTOCOL_VERSION = "2025-06-18";
|
|
16488
|
-
|
|
17053
|
+
/** The real path of `p`, or its lexical resolution when it does not exist. */
|
|
17054
|
+
function realOrResolved(p) {
|
|
17055
|
+
try {
|
|
17056
|
+
return realpathSync.native(resolve(p));
|
|
17057
|
+
} catch {
|
|
17058
|
+
return resolve(p);
|
|
17059
|
+
}
|
|
17060
|
+
}
|
|
17061
|
+
/**
|
|
17062
|
+
* The optional workspace root. When set, every path argument must resolve
|
|
17063
|
+
* INSIDE it after symlinks are followed.
|
|
17064
|
+
*
|
|
17065
|
+
* Unset is open by design and is documented that way in the header: the MCP
|
|
17066
|
+
* tools are read-only and the stdio parent is the trust boundary. Setting
|
|
17067
|
+
* `MJOLNIR_WORKSPACE_ROOT` is how a host confines them.
|
|
17068
|
+
*/
|
|
17069
|
+
const CONFIGURED_ROOT = process.env.MJOLNIR_WORKSPACE_ROOT ? realOrResolved(process.env.MJOLNIR_WORKSPACE_ROOT) : null;
|
|
16489
17070
|
function isPathAllowed(path) {
|
|
16490
17071
|
if (!CONFIGURED_ROOT) return true;
|
|
16491
|
-
const
|
|
16492
|
-
|
|
16493
|
-
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
17072
|
+
const rel = relative(CONFIGURED_ROOT, realOrResolved(path));
|
|
17073
|
+
return rel === "" || rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
16494
17074
|
}
|
|
16495
17075
|
/** JSON-RPC error codes (subset used here). */
|
|
16496
17076
|
const MCP_ERRORS = {
|
|
@@ -16558,7 +17138,7 @@ const MCP_TOOLS = [
|
|
|
16558
17138
|
},
|
|
16559
17139
|
{
|
|
16560
17140
|
name: "verify",
|
|
16561
|
-
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.",
|
|
17141
|
+
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.",
|
|
16562
17142
|
inputSchema: {
|
|
16563
17143
|
type: "object",
|
|
16564
17144
|
properties: { path: {
|
|
@@ -16570,7 +17150,7 @@ const MCP_TOOLS = [
|
|
|
16570
17150
|
},
|
|
16571
17151
|
{
|
|
16572
17152
|
name: "forensics",
|
|
16573
|
-
description: "Runtime-evidence report (1:1 with the forensics engine behind `mjolnir
|
|
17153
|
+
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.",
|
|
16574
17154
|
inputSchema: {
|
|
16575
17155
|
type: "object",
|
|
16576
17156
|
properties: { path: {
|
|
@@ -16594,7 +17174,7 @@ const MCP_TOOLS = [
|
|
|
16594
17174
|
},
|
|
16595
17175
|
{
|
|
16596
17176
|
name: "pw-report",
|
|
16597
|
-
description: "Playwright run summary (1:1 with `mjolnir
|
|
17177
|
+
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.",
|
|
16598
17178
|
inputSchema: {
|
|
16599
17179
|
type: "object",
|
|
16600
17180
|
properties: { path: {
|
|
@@ -16787,7 +17367,7 @@ async function handleToolCall(call) {
|
|
|
16787
17367
|
id: call.id,
|
|
16788
17368
|
result: {
|
|
16789
17369
|
hasBaseline: false,
|
|
16790
|
-
note: "no committed baseline at .mjolnir/baseline.json — establish the before-state with `mjolnir baseline`
|
|
17370
|
+
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"
|
|
16791
17371
|
}
|
|
16792
17372
|
};
|
|
16793
17373
|
const result = await serializeScan(() => runScan({
|
|
@@ -16967,8 +17547,7 @@ async function handleMcpMessage(msg) {
|
|
|
16967
17547
|
}
|
|
16968
17548
|
};
|
|
16969
17549
|
}
|
|
16970
|
-
|
|
16971
|
-
//#region src/mcp/transport.ts
|
|
17550
|
+
// src/mcp/transport.ts
|
|
16972
17551
|
/**
|
|
16973
17552
|
* MCP stdio transport loop (blueprint §21 — the framing around the
|
|
16974
17553
|
* pure-transport server).
|
|
@@ -17065,8 +17644,7 @@ async function handleStdioLine(line, write) {
|
|
|
17065
17644
|
}
|
|
17066
17645
|
write(`${JSON.stringify(response)}\n`);
|
|
17067
17646
|
}
|
|
17068
|
-
|
|
17069
|
-
//#region src/integrations/sentry.ts
|
|
17647
|
+
// src/integrations/sentry.ts
|
|
17070
17648
|
/**
|
|
17071
17649
|
* Sentry crash reporting — opt-in, and inert unless a DSN is configured.
|
|
17072
17650
|
*
|
|
@@ -17192,8 +17770,7 @@ async function flushSentry(timeoutMs = FLUSH_TIMEOUT_MS) {
|
|
|
17192
17770
|
return false;
|
|
17193
17771
|
}
|
|
17194
17772
|
}
|
|
17195
|
-
|
|
17196
|
-
//#region src/mcp/stdio.ts
|
|
17773
|
+
// src/mcp/stdio.ts
|
|
17197
17774
|
/**
|
|
17198
17775
|
* The standalone MCP stdio binary (blueprint §21), built to
|
|
17199
17776
|
* `dist/mcp/stdio.mjs` and run by `npm run mcp`.
|
|
@@ -17218,7 +17795,6 @@ runStdioTransport(process.stdin, process.stdout).then(() => process.exit(0)).cat
|
|
|
17218
17795
|
process.stderr.write(`mjolnir mcp fatal: ${err instanceof Error ? err.message : err}\n`);
|
|
17219
17796
|
process.exitCode = 20;
|
|
17220
17797
|
});
|
|
17221
|
-
//#endregion
|
|
17222
17798
|
export {};
|
|
17223
17799
|
|
|
17224
17800
|
//# sourceMappingURL=stdio.mjs.map
|