mjolnir-qa 5.0.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2909 -15
- package/README.md +81 -760
- package/dist/cli.d.mts +459 -290
- package/dist/cli.mjs +31329 -15271
- package/dist/mcp/stdio.mjs +916 -228
- package/package.json +59 -32
- package/README.ar.md +0 -647
- package/README.bn.md +0 -647
- package/README.br.md +0 -647
- package/README.bs.md +0 -647
- package/README.da.md +0 -647
- package/README.de.md +0 -647
- package/README.es.md +0 -647
- package/README.fr.md +0 -647
- package/README.gr.md +0 -647
- package/README.he.md +0 -647
- package/README.it.md +0 -647
- package/README.ja.md +0 -647
- package/README.ko.md +0 -647
- package/README.no.md +0 -647
- package/README.pl.md +0 -647
- package/README.ru.md +0 -647
- package/README.th.md +0 -647
- package/README.tr.md +0 -647
- package/README.uk.md +0 -647
- package/README.vi.md +0 -647
- package/README.zh.md +0 -647
- package/README.zht.md +0 -647
- package/dist/scan-pipeline-DNYbnWjE.mjs +0 -2
- package/dist/scan-pipeline-DT8DWAMl.mjs +0 -16501
package/dist/mcp/stdio.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
5
5
|
import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
6
6
|
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
7
|
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
8
9
|
import * as ts$2 from "ts-morph";
|
|
9
10
|
import ts, { Project, SyntaxKind, ts as ts$1 } from "ts-morph";
|
|
10
11
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -35,6 +36,23 @@ const SEVERITY_ORDER = [
|
|
|
35
36
|
* retried, or timed out in the report).
|
|
36
37
|
* INVARIANT (structurally enforced): L3–L5 require runtime
|
|
37
38
|
* corroboration — a static-only finding can never claim L4/L5.
|
|
39
|
+
*
|
|
40
|
+
* THREE LADDER VOCABULARIES now share this shape, and the letters are the
|
|
41
|
+
* only thing distinguishing them:
|
|
42
|
+
*
|
|
43
|
+
* `L0`–`L5` RUNTIME TRUST — how much a consumer can believe one finding.
|
|
44
|
+
* Here.
|
|
45
|
+
* `F0`–`F5` FRAMEWORK SUPPORT — a hand-declared target per framework.
|
|
46
|
+
* `FrameworkSupportLevel`, src/frameworks/framework-inventory.ts
|
|
47
|
+
* `M0`–`M5` CAPABILITY MATURITY — DERIVED from evidence, and the only
|
|
48
|
+
* one of the three that is a measurement.
|
|
49
|
+
* `Maturity`, src/v6/maturity.ts
|
|
50
|
+
*
|
|
51
|
+
* `tests/v6/maturity-vocabulary.spec.ts` holds the two vocabulary sets apart
|
|
52
|
+
* and fails if they ever share a member. `M` is the only DERIVED of the three,
|
|
53
|
+
* and that is the distinction worth remembering when reading a level out of a
|
|
54
|
+
* report: `F` and `L` are declarations about the world, `M` is a function of
|
|
55
|
+
* what this repository can demonstrate.
|
|
38
56
|
*/
|
|
39
57
|
const TRUST_ORDER = [
|
|
40
58
|
"L0",
|
|
@@ -100,17 +118,24 @@ function deriveCompletion(input) {
|
|
|
100
118
|
if (input.runtimeIncomplete) reasons.add("runtime-incomplete");
|
|
101
119
|
if (input.identityIncomplete) reasons.add("identity-incomplete");
|
|
102
120
|
for (const reason of truncationReasons) reasons.add(`truncated:${reason}`);
|
|
121
|
+
if ((input.rulesWithheld ?? 0) > 0) reasons.add(`coverage:quarantine:${input.rulesWithheld}`);
|
|
103
122
|
for (const entry of degradations) reasons.add(`degraded:${entry.reason}:${entry.count}`);
|
|
104
123
|
const discoveryPartial = input.discoveryTruncated || input.scopeIgnored > 0 || input.scopeUnrecognized > 0 || input.scopeDegraded !== void 0;
|
|
105
124
|
const rulesPartial = input.rulesPartial || input.rulesCrashed > 0 || input.parseFailed > 0;
|
|
125
|
+
const partial = discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0 || degradations.length > 0;
|
|
126
|
+
const rulesWithheld = input.rulesWithheld ?? 0;
|
|
106
127
|
return {
|
|
107
|
-
partial
|
|
128
|
+
partial,
|
|
129
|
+
coverageState: rulesWithheld > 0 ? "PARTIAL" : "COMPLETE",
|
|
108
130
|
analysisStatus: {
|
|
109
131
|
discovery: discoveryPartial ? "partial" : "complete",
|
|
110
132
|
rules: rulesPartial ? "partial" : "complete",
|
|
111
133
|
skippedFiles: input.skippedFiles,
|
|
112
134
|
rulesCrashed: input.rulesCrashed,
|
|
113
135
|
parseFallbacks: input.parseFallbacks ?? 0,
|
|
136
|
+
rulesApplied: input.rulesApplied ?? 0,
|
|
137
|
+
rulesWithheld,
|
|
138
|
+
...input.withheldRuleIds !== void 0 && input.withheldRuleIds.length > 0 ? { withheldRuleIds: [...input.withheldRuleIds].sort() } : {},
|
|
114
139
|
...truncationReasons.length > 0 ? { truncationReasons } : {},
|
|
115
140
|
...degradations.length > 0 ? { degradations } : {},
|
|
116
141
|
reasons: [...reasons].sort()
|
|
@@ -375,10 +400,10 @@ const MEASURED_FP = {
|
|
|
375
400
|
},
|
|
376
401
|
"QA-JV-101": {
|
|
377
402
|
fpRate: 0,
|
|
378
|
-
n:
|
|
403
|
+
n: 35,
|
|
379
404
|
detectorRevision: 1,
|
|
380
405
|
ciLow: 0,
|
|
381
|
-
ciHigh: .
|
|
406
|
+
ciHigh: .0989
|
|
382
407
|
},
|
|
383
408
|
"QA-JV-102": {
|
|
384
409
|
fpRate: .261,
|
|
@@ -494,10 +519,10 @@ const MEASURED_FP = {
|
|
|
494
519
|
},
|
|
495
520
|
"QA-PW-117": {
|
|
496
521
|
fpRate: 0,
|
|
497
|
-
n:
|
|
522
|
+
n: 35,
|
|
498
523
|
detectorRevision: 1,
|
|
499
524
|
ciLow: 0,
|
|
500
|
-
ciHigh: .
|
|
525
|
+
ciHigh: .0989
|
|
501
526
|
},
|
|
502
527
|
"QA-PW-121": {
|
|
503
528
|
fpRate: 0,
|
|
@@ -584,25 +609,18 @@ const MEASURED_FP = {
|
|
|
584
609
|
ciHigh: .2425
|
|
585
610
|
},
|
|
586
611
|
"QA-PY-002": {
|
|
587
|
-
fpRate: .
|
|
588
|
-
n:
|
|
612
|
+
fpRate: .12,
|
|
613
|
+
n: 25,
|
|
589
614
|
detectorRevision: 1,
|
|
590
|
-
ciLow: .
|
|
591
|
-
ciHigh: .
|
|
615
|
+
ciLow: .0417,
|
|
616
|
+
ciHigh: .2996
|
|
592
617
|
},
|
|
593
618
|
"QA-PY-003": {
|
|
594
|
-
fpRate: .
|
|
595
|
-
n:
|
|
619
|
+
fpRate: .515,
|
|
620
|
+
n: 33,
|
|
596
621
|
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
|
|
622
|
+
ciLow: .3522,
|
|
623
|
+
ciHigh: .675
|
|
606
624
|
},
|
|
607
625
|
"QA-PY-005": {
|
|
608
626
|
fpRate: .13,
|
|
@@ -885,6 +903,58 @@ function buildTrustSummary(result, declarationsByFile) {
|
|
|
885
903
|
};
|
|
886
904
|
}
|
|
887
905
|
//#endregion
|
|
906
|
+
//#region src/governance/run-mode.ts
|
|
907
|
+
/**
|
|
908
|
+
* Which mode a run is in, and how the other one is reached.
|
|
909
|
+
*
|
|
910
|
+
* ADR 0012: `local-only` is the default and hosted mode is a declared state.
|
|
911
|
+
* This module is the enforcement point, and its shape is the decision:
|
|
912
|
+
*
|
|
913
|
+
* - `resolveRunMode` takes its inputs as an ARGUMENT, never from
|
|
914
|
+
* `process.env`. Nothing here can read the environment, so a stray
|
|
915
|
+
* `MJOLNIR_MODE` in a shell, in CI, or in a parent's environment cannot
|
|
916
|
+
* change what a run does. The first version of the test passed an `env`
|
|
917
|
+
* bag and asserted it was ignored — and that only means something because
|
|
918
|
+
* this function is structurally unable to consult one.
|
|
919
|
+
* - `isHostedModeReachable` is the single predicate every entry point has to
|
|
920
|
+
* pass through, and it answers the same question separately from
|
|
921
|
+
* `resolveRunMode`. Separating them is what stops a caller satisfying the
|
|
922
|
+
* easy one: the first says whether the mode MAY be entered, the second
|
|
923
|
+
* says which mode this run IS.
|
|
924
|
+
* - an unrecognised declaration THROWS. A typo in a build flag that fell back
|
|
925
|
+
* to `local-only` would mean a deployment believed it was hosted and was
|
|
926
|
+
* not — the same class of failure as a gate that reports success for
|
|
927
|
+
* something it did not run.
|
|
928
|
+
*
|
|
929
|
+
* Hosted mode is not implemented. This is the contract it will have to
|
|
930
|
+
* satisfy, and it is deliberately the smallest thing that can be wrong in only
|
|
931
|
+
* one direction.
|
|
932
|
+
*/
|
|
933
|
+
/**
|
|
934
|
+
* The build-time declaration that opts a build into hosted mode.
|
|
935
|
+
*
|
|
936
|
+
* A build flag rather than an environment variable, because a build flag is
|
|
937
|
+
* something a release engineer sets deliberately and reviews in a diff, and
|
|
938
|
+
* because an environment variable is set by whatever CI last happened to run
|
|
939
|
+
* with.
|
|
940
|
+
*/
|
|
941
|
+
const HOSTED_MODE_FLAG = "mjolnir-mode";
|
|
942
|
+
/**
|
|
943
|
+
* The mode this run is in.
|
|
944
|
+
*
|
|
945
|
+
* Throws on a declaration it does not recognise. A silent fallback here is the
|
|
946
|
+
* failure ADR 0012 exists to prevent: a build that asked for hosted mode,
|
|
947
|
+
* mistyped the flag, and ran local-only would report local-only artefacts into
|
|
948
|
+
* a pipeline that believed otherwise.
|
|
949
|
+
*/
|
|
950
|
+
function resolveRunMode(input) {
|
|
951
|
+
const declared = input.flags[HOSTED_MODE_FLAG];
|
|
952
|
+
if (declared === void 0 || declared === false) return "local-only";
|
|
953
|
+
if (declared === true) throw new Error(`${HOSTED_MODE_FLAG} was given without a value. Use "${HOSTED_MODE_FLAG}=hosted" or omit it.`);
|
|
954
|
+
if (declared === "local-only" || declared === "hosted") return declared;
|
|
955
|
+
throw new Error(`${HOSTED_MODE_FLAG}=${String(declared)} is not a run mode. Expected "hosted" or "local-only". Falling back to a default here would let a mistyped flag look like a deliberate choice.`);
|
|
956
|
+
}
|
|
957
|
+
//#endregion
|
|
888
958
|
//#region src/engine/run-identity.ts
|
|
889
959
|
/**
|
|
890
960
|
* Run Identity (product-gap master plan §7, plan 1789009691197 R4c) —
|
|
@@ -925,6 +995,7 @@ function buildRunIdentity(input) {
|
|
|
925
995
|
if (input.commit !== void 0) verdictInputs.commit = input.commit;
|
|
926
996
|
if (input.tree !== void 0) verdictInputs.tree = input.tree;
|
|
927
997
|
if (input.lockfile !== void 0) verdictInputs.lockfile = input.lockfile;
|
|
998
|
+
if (input.runMode !== void 0) verdictInputs.runMode = input.runMode;
|
|
928
999
|
if (input.candidate !== void 0) verdictInputs.candidate = {
|
|
929
1000
|
manifestId: input.candidate.manifestId,
|
|
930
1001
|
state: input.candidate.state,
|
|
@@ -957,6 +1028,7 @@ function buildRunIdentity(input) {
|
|
|
957
1028
|
rulesDigest,
|
|
958
1029
|
configFingerprint,
|
|
959
1030
|
engineVersion: input.engineVersion,
|
|
1031
|
+
...input.runMode !== void 0 ? { runMode: input.runMode } : {},
|
|
960
1032
|
boundLinks,
|
|
961
1033
|
...input.trustModelVersion !== void 0 ? { trustModelVersion: input.trustModelVersion } : {},
|
|
962
1034
|
...input.scoringModelVersion !== void 0 ? { scoringModelVersion: input.scoringModelVersion } : {},
|
|
@@ -1359,10 +1431,50 @@ function readCandidateBinding(root, manifestPath = CANDIDATE_MANIFEST_PATH) {
|
|
|
1359
1431
|
* version inside scan-pipeline WITHOUT importing cli.ts (a cycle — cli
|
|
1360
1432
|
* imports the pipeline). The literal follows the same discipline as
|
|
1361
1433
|
* CLI_VERSION and SARIF's driver.version: kept in sync on release by
|
|
1362
|
-
* scripts/sync-sarif-version.cjs
|
|
1434
|
+
* scripts/archive/sync-sarif-version.cjs (archived; superseded by the release
|
|
1435
|
+
* workflow's own version stamping) and guarded by the version-consistency
|
|
1363
1436
|
* spec. cli.ts re-exports this as CLI_VERSION.
|
|
1364
1437
|
*/
|
|
1365
|
-
const ENGINE_VERSION = "
|
|
1438
|
+
const ENGINE_VERSION = "6.0.0";
|
|
1439
|
+
resolveBuildId();
|
|
1440
|
+
/**
|
|
1441
|
+
* The decision, with the process boundary already behind `runGit`.
|
|
1442
|
+
*
|
|
1443
|
+
* Separated so every branch is reachable from a test: a runner that throws and
|
|
1444
|
+
* a runner that answers are both things that happen, and both are what this
|
|
1445
|
+
* function exists to survive. What it must never do is invent an answer.
|
|
1446
|
+
*/
|
|
1447
|
+
function buildIdFrom(runGit) {
|
|
1448
|
+
const sha = runGit([
|
|
1449
|
+
"rev-parse",
|
|
1450
|
+
"--short=12",
|
|
1451
|
+
"HEAD"
|
|
1452
|
+
]);
|
|
1453
|
+
if (!sha) return void 0;
|
|
1454
|
+
const status = runGit(["status", "--porcelain"]);
|
|
1455
|
+
if (status === void 0) return `${sha}-dirty`;
|
|
1456
|
+
return status ? `${sha}-dirty` : sha;
|
|
1457
|
+
}
|
|
1458
|
+
function resolveBuildId() {
|
|
1459
|
+
const git = resolveGitPath();
|
|
1460
|
+
if (!git) return void 0;
|
|
1461
|
+
const runGit = (args) => {
|
|
1462
|
+
try {
|
|
1463
|
+
return execFileSync(git, args, {
|
|
1464
|
+
encoding: "utf8",
|
|
1465
|
+
timeout: 2e3,
|
|
1466
|
+
stdio: [
|
|
1467
|
+
"ignore",
|
|
1468
|
+
"pipe",
|
|
1469
|
+
"ignore"
|
|
1470
|
+
]
|
|
1471
|
+
}).trim();
|
|
1472
|
+
} catch {
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
};
|
|
1476
|
+
return buildIdFrom(runGit);
|
|
1477
|
+
}
|
|
1366
1478
|
//#endregion
|
|
1367
1479
|
//#region src/engine/contract-versions.ts
|
|
1368
1480
|
/**
|
|
@@ -1374,7 +1486,15 @@ const ENGINE_VERSION = "5.0.0";
|
|
|
1374
1486
|
* surface without widening.
|
|
1375
1487
|
*/
|
|
1376
1488
|
const TRUST_MODEL_VERSION = "1.0.0";
|
|
1377
|
-
|
|
1489
|
+
/**
|
|
1490
|
+
* 2.0.0 (ADR 0014): the core tier carries an evidence FLOOR. A core rule's
|
|
1491
|
+
* finding is now stamped to at least E1, which changes `deductionFor` for
|
|
1492
|
+
* every core finding from 0 (E0) to `floor(base/2)`. The E0→E1→E2 ladder
|
|
1493
|
+
* itself is unchanged; what changed is which rung a core finding is allowed
|
|
1494
|
+
* to sit on, and therefore what it costs. Major, per the `semver + ADR
|
|
1495
|
+
* required` policy on the entry below.
|
|
1496
|
+
*/
|
|
1497
|
+
const SCORING_MODEL_VERSION = "2.0.0";
|
|
1378
1498
|
const FRAMEWORK_SUPPORT_MATRIX_VERSION = "1.0.0";
|
|
1379
1499
|
//#endregion
|
|
1380
1500
|
//#region src/discovery/evidence-discovery.ts
|
|
@@ -1421,6 +1541,11 @@ const EVIDENCE_CONVENTIONS = [
|
|
|
1421
1541
|
convention: "junit-file",
|
|
1422
1542
|
kind: "file",
|
|
1423
1543
|
names: ["junit-report.xml", "junit.xml"]
|
|
1544
|
+
},
|
|
1545
|
+
{
|
|
1546
|
+
convention: "dotnet-trx",
|
|
1547
|
+
kind: "dir",
|
|
1548
|
+
names: ["TestResults"]
|
|
1424
1549
|
}
|
|
1425
1550
|
];
|
|
1426
1551
|
const SEARCH_DEPTH = 2;
|
|
@@ -1772,6 +1897,108 @@ function applySeverityOverrides(findings, cfg) {
|
|
|
1772
1897
|
}
|
|
1773
1898
|
}
|
|
1774
1899
|
//#endregion
|
|
1900
|
+
//#region src/lib/compare.ts
|
|
1901
|
+
/**
|
|
1902
|
+
* Order that does not depend on who is asking.
|
|
1903
|
+
*
|
|
1904
|
+
* `String.prototype.localeCompare` with no locale argument resolves against
|
|
1905
|
+
* the PROCESS's default locale — the ICU collation chosen by `LANG`,
|
|
1906
|
+
* `LC_ALL` or the OS. So a sort written as
|
|
1907
|
+
* `[...xs].sort((a, b) => a.localeCompare(b))` produces a different order on
|
|
1908
|
+
* a developer's `de_DE` machine than on an en-US CI runner. For a display
|
|
1909
|
+
* list that is a nuisance; for anything that reaches a hash, a cache key, a
|
|
1910
|
+
* digest or a machine contract it is a correctness bug, because the same
|
|
1911
|
+
* repository then produces two different fingerprints.
|
|
1912
|
+
*
|
|
1913
|
+
* Two comparators, one per kind of site:
|
|
1914
|
+
*
|
|
1915
|
+
* compareCodePoints — pure UTF-16 code-unit order. Identical everywhere,
|
|
1916
|
+
* independent of ICU version, and already what `Array.prototype.sort`
|
|
1917
|
+
* defaults to. Use for every value that reaches a fingerprint, a digest,
|
|
1918
|
+
* a cache key, or a contract, and for any ordering with no human-facing
|
|
1919
|
+
* reason to be pretty.
|
|
1920
|
+
*
|
|
1921
|
+
* compareLocalized(locale) — the same collator, PINNED to a named locale
|
|
1922
|
+
* instead of inheriting the environment, for a surface that genuinely
|
|
1923
|
+
* wants readable order (a table of rule titles, a Mermaid legend). Pinned
|
|
1924
|
+
* rather than removed because a reader-facing list of "Test / Logic /
|
|
1925
|
+
* Mock" should not be ordered by code unit.
|
|
1926
|
+
*
|
|
1927
|
+
* A bare `localeCompare` with no second argument in `src/` is a defect and
|
|
1928
|
+
* `tests/contract/deterministic-ordering.spec.ts` fails the build on it.
|
|
1929
|
+
*/
|
|
1930
|
+
/** Code-unit order. Locale-free, ICU-free, and the Array.sort default. */
|
|
1931
|
+
function compareCodePoints(a, b) {
|
|
1932
|
+
if (a === b) return 0;
|
|
1933
|
+
return a < b ? -1 : 1;
|
|
1934
|
+
}
|
|
1935
|
+
/**
|
|
1936
|
+
* A readable-order comparator pinned to `locale`.
|
|
1937
|
+
*
|
|
1938
|
+
* The pin is the point: `a.localeCompare(b)` reads the environment, and the
|
|
1939
|
+
* environment is a machine setting, not a property of the code. Callers that
|
|
1940
|
+
* want stable output ask for a named locale and get the same answer on every
|
|
1941
|
+
* machine that ships the same ICU data.
|
|
1942
|
+
*/
|
|
1943
|
+
function compareLocalized(locale) {
|
|
1944
|
+
return (a, b) => a.localeCompare(b, locale);
|
|
1945
|
+
}
|
|
1946
|
+
//#endregion
|
|
1947
|
+
//#region src/lib/glob.ts
|
|
1948
|
+
/**
|
|
1949
|
+
* The shared segment compiler: a glob to a regex BODY, with no anchors.
|
|
1950
|
+
*
|
|
1951
|
+
* Module-private because the anchoring is a caller decision and the DIALECT is
|
|
1952
|
+
* not: the repository needs a full-path match and a bare-name-at-any-depth
|
|
1953
|
+
* match, and duplicating the segment walk to get the second one is exactly how
|
|
1954
|
+
* two compilers came to disagree. Callers pick an anchoring; the dialect is
|
|
1955
|
+
* decided here and nowhere else.
|
|
1956
|
+
*
|
|
1957
|
+
* `?`, `[`, `]`, `!` and every other regex metacharacter in the literal parts
|
|
1958
|
+
* are escaped, so nothing a user writes in a config or ledger can reach the
|
|
1959
|
+
* regex engine as syntax. `*` is the only metacharacter.
|
|
1960
|
+
*/
|
|
1961
|
+
function globBody(glob) {
|
|
1962
|
+
const segments = glob.replaceAll("\\", "/").split("/");
|
|
1963
|
+
let re = "";
|
|
1964
|
+
for (const [i, segment] of segments.entries()) {
|
|
1965
|
+
const last = i === segments.length - 1;
|
|
1966
|
+
if (segment === "**") {
|
|
1967
|
+
re += last ? "(?:[^/]+/)*[^/]+" : "(?:[^/]+/)*";
|
|
1968
|
+
continue;
|
|
1969
|
+
}
|
|
1970
|
+
re += segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", "[^/]*");
|
|
1971
|
+
if (!last) re += "/";
|
|
1972
|
+
}
|
|
1973
|
+
return re;
|
|
1974
|
+
}
|
|
1975
|
+
/** A glob as a regex anchored to the WHOLE path. */
|
|
1976
|
+
function anchoredGlobRegExp(glob) {
|
|
1977
|
+
return new RegExp(`^${globBody(glob)}$`);
|
|
1978
|
+
}
|
|
1979
|
+
/**
|
|
1980
|
+
* A glob as a regex matching at ANY DEPTH — a bare name.
|
|
1981
|
+
*
|
|
1982
|
+
* `node_modules` becomes "a file or directory of this name anywhere", which is
|
|
1983
|
+
* gitignore semantics: every path inside the directory contains the segment, so
|
|
1984
|
+
* a directory match carries to its contents.
|
|
1985
|
+
*/
|
|
1986
|
+
function anyDepthGlobRegExp(glob) {
|
|
1987
|
+
return new RegExp(`(?:^|/)${globBody(glob)}(?:/|$)`);
|
|
1988
|
+
}
|
|
1989
|
+
/**
|
|
1990
|
+
* Whether `path` matches `glob`, in the dialect documented above.
|
|
1991
|
+
*
|
|
1992
|
+
* Both sides are normalized to forward slashes first. Finding paths are already
|
|
1993
|
+
* normalized by the walker, but a suppression `files` pattern written on
|
|
1994
|
+
* Windows ("e2e\\x.spec.ts") compiled to a literal-backslash regex that could
|
|
1995
|
+
* never match any finding — the suppression silently never applied, which is
|
|
1996
|
+
* the defect that made the whole dialect worth unifying.
|
|
1997
|
+
*/
|
|
1998
|
+
function pathMatchesGlob(path, glob) {
|
|
1999
|
+
return anchoredGlobRegExp(glob).test(path.replaceAll("\\", "/"));
|
|
2000
|
+
}
|
|
2001
|
+
//#endregion
|
|
1775
2002
|
//#region src/discovery/ignores.ts
|
|
1776
2003
|
/**
|
|
1777
2004
|
* Ignore resolution (Product-MVP.txt R1 — Tier-1 launch blocker).
|
|
@@ -1906,32 +2133,12 @@ function compilePattern(raw) {
|
|
|
1906
2133
|
if (pattern.trim() === "") return null;
|
|
1907
2134
|
if (pattern.length > LIMITS$1.maxPatternLength) return null;
|
|
1908
2135
|
if ((pattern.match(/[*?]/g) ?? []).length > LIMITS$1.maxPatternWildcards) return null;
|
|
1909
|
-
const re = pattern.includes("/") ?
|
|
2136
|
+
const re = pattern.includes("/") ? anchoredGlobRegExp(pattern) : anyDepthGlobRegExp(pattern);
|
|
1910
2137
|
return {
|
|
1911
2138
|
negated,
|
|
1912
2139
|
re
|
|
1913
2140
|
};
|
|
1914
2141
|
}
|
|
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
2142
|
createMatcherFromPatterns([]);
|
|
1936
2143
|
/**
|
|
1937
2144
|
* Check if a directory is a lint-fixture shape (Phase 2 auto-detection).
|
|
@@ -2108,16 +2315,29 @@ function detectFrameworks(ws) {
|
|
|
2108
2315
|
* Migration discipline (per the plan): rule-by-rule, golden lock must stay
|
|
2109
2316
|
* byte-identical — a score shift is a regression, not an improvement.
|
|
2110
2317
|
*/
|
|
2111
|
-
let project = null;
|
|
2112
2318
|
/**
|
|
2113
|
-
*
|
|
2319
|
+
* One shared Project PER SCAN keeps memory bounded while reusing the compiler's
|
|
2320
|
+
* program across files.
|
|
2114
2321
|
*
|
|
2115
|
-
*
|
|
2116
|
-
*
|
|
2117
|
-
*
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2322
|
+
* "Per scan" has to mean per scan. The variable lived at module scope, so it
|
|
2323
|
+
* was one Project per PROCESS, and two scans running concurrently shared it —
|
|
2324
|
+
* `parseTsFile` calls `replaceWithText` on a cached SourceFile, so scan B
|
|
2325
|
+
* could overwrite the text scan A was mid-rule on. ts-morph's in-memory file
|
|
2326
|
+
* system and language service are not reentrant, and the observable result was
|
|
2327
|
+
* three of four concurrent scans reporting `rulesCrashed: 4` with
|
|
2328
|
+
* `rules: "partial"` — different machine-contract digests from the same tree,
|
|
2329
|
+
* which is the one thing this product claims cannot happen.
|
|
2330
|
+
*
|
|
2331
|
+
* An AsyncLocalStorage scope is the smallest change that makes the comment
|
|
2332
|
+
* true: each scan's async context carries its own Project, memory stays bounded
|
|
2333
|
+
* per scan exactly as intended, and the context is collected when the scan's
|
|
2334
|
+
* promise chain is. The loose project below remains for callers outside a scan
|
|
2335
|
+
* (tests, one-off parses), where there is nothing to collide with.
|
|
2336
|
+
*/
|
|
2337
|
+
const scanProjects = new AsyncLocalStorage();
|
|
2338
|
+
let looseProject = null;
|
|
2339
|
+
function createProject() {
|
|
2340
|
+
return new Project({
|
|
2121
2341
|
useInMemoryFileSystem: true,
|
|
2122
2342
|
skipAddingFilesFromTsConfig: true,
|
|
2123
2343
|
compilerOptions: {
|
|
@@ -2126,7 +2346,28 @@ function getProject() {
|
|
|
2126
2346
|
noEmit: true
|
|
2127
2347
|
}
|
|
2128
2348
|
});
|
|
2129
|
-
|
|
2349
|
+
}
|
|
2350
|
+
/**
|
|
2351
|
+
* Run `fn` with its own ts-morph Project.
|
|
2352
|
+
*
|
|
2353
|
+
* Async context propagates across `await`, so the Project stays the scan's own
|
|
2354
|
+
* for the whole scan without threading an argument through every adapter.
|
|
2355
|
+
*/
|
|
2356
|
+
function runWithScanProject(fn) {
|
|
2357
|
+
return scanProjects.run(createProject(), fn);
|
|
2358
|
+
}
|
|
2359
|
+
/**
|
|
2360
|
+
* The ts-morph Project for the current scan.
|
|
2361
|
+
*
|
|
2362
|
+
* Exported so an adapter's `dispose()` can evict the file it parsed: ts-morph
|
|
2363
|
+
* caches by file path, so without eviction a long scan holds every parsed
|
|
2364
|
+
* SourceFile until the process exits (plan V5-021).
|
|
2365
|
+
*/
|
|
2366
|
+
function getProject() {
|
|
2367
|
+
const scoped = scanProjects.getStore();
|
|
2368
|
+
if (scoped !== void 0) return scoped;
|
|
2369
|
+
looseProject ??= createProject();
|
|
2370
|
+
return looseProject;
|
|
2130
2371
|
}
|
|
2131
2372
|
/**
|
|
2132
2373
|
* Parse (or fetch from cache) the ts-morph SourceFile for a scanned file.
|
|
@@ -2251,9 +2492,16 @@ function getCodeOnlyText(file) {
|
|
|
2251
2492
|
return file.text;
|
|
2252
2493
|
}
|
|
2253
2494
|
}
|
|
2254
|
-
/**
|
|
2495
|
+
/**
|
|
2496
|
+
* Release the loose ts-morph Project used outside a scan.
|
|
2497
|
+
*
|
|
2498
|
+
* Deliberately does NOT call `scanProjects.disable()`. Scan-scoped Projects are
|
|
2499
|
+
* released by garbage collection when the scan's async context ends, and
|
|
2500
|
+
* `disable()` would tear the store out from under a scan still running — the
|
|
2501
|
+
* exact corruption this scoping exists to remove.
|
|
2502
|
+
*/
|
|
2255
2503
|
function resetTsMorphProject() {
|
|
2256
|
-
|
|
2504
|
+
looseProject = null;
|
|
2257
2505
|
}
|
|
2258
2506
|
//#endregion
|
|
2259
2507
|
//#region src/engine/code-text.ts
|
|
@@ -2707,10 +2955,18 @@ function runRuleIsolated(rule, file, onCrash) {
|
|
|
2707
2955
|
* four lines below: PW_CONFIG_RE already accepted `cts`. The config regex knew
|
|
2708
2956
|
* about the Node-native extensions; the test-file regex did not.
|
|
2709
2957
|
*
|
|
2710
|
-
* `discovery/scan-adapters.ts:isUnrecognizedSourceCandidate`
|
|
2711
|
-
*
|
|
2712
|
-
*
|
|
2713
|
-
*
|
|
2958
|
+
* `discovery/scan-adapters.ts:isUnrecognizedSourceCandidate` counts these
|
|
2959
|
+
* paths as uncovered surface, so the scope accounting reports a hole if one
|
|
2960
|
+
* exists.
|
|
2961
|
+
*
|
|
2962
|
+
* CORRECTION: the comment this replaced said the scope accounting "had been
|
|
2963
|
+
* reporting the hole the whole time". It had not — that checker's regex
|
|
2964
|
+
* already matched `mts`/`cts`, and only `extensions` was unread. What WAS
|
|
2965
|
+
* true, and is what this entry now fixes, is that a field every adapter
|
|
2966
|
+
* declares and no code consulted is a claim the interface makes to anyone who
|
|
2967
|
+
* reads it. `isUnrecognizedSourceCandidate` now builds its extension set from
|
|
2968
|
+
* this list rather than from a second hard-coded one, which is what makes the
|
|
2969
|
+
* two halves of the same fact stop being able to drift.
|
|
2714
2970
|
*/
|
|
2715
2971
|
const TEST_FILE_RE = /\.(?:test|spec)\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$|\.cy\.(?:js|jsx|ts|tsx)$/;
|
|
2716
2972
|
/**
|
|
@@ -2782,7 +3038,9 @@ const typescriptAdapter = {
|
|
|
2782
3038
|
".js",
|
|
2783
3039
|
".jsx",
|
|
2784
3040
|
".mjs",
|
|
2785
|
-
".cjs"
|
|
3041
|
+
".cjs",
|
|
3042
|
+
".mts",
|
|
3043
|
+
".cts"
|
|
2786
3044
|
],
|
|
2787
3045
|
testFileGlobs: [
|
|
2788
3046
|
"*.test.{js,jsx,ts,tsx,mjs,cjs}",
|
|
@@ -2993,7 +3251,11 @@ async function withParseSlot(fn) {
|
|
|
2993
3251
|
}
|
|
2994
3252
|
function grammarPath(fileName) {
|
|
2995
3253
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
2996
|
-
const candidates = [
|
|
3254
|
+
const candidates = [
|
|
3255
|
+
join(here, "..", "..", "node_modules", "tree-sitter-wasms", "out", fileName),
|
|
3256
|
+
join(here, "..", "node_modules", "tree-sitter-wasms", "out", fileName),
|
|
3257
|
+
join(here, "..", "..", "..", "node_modules", "tree-sitter-wasms", "out", fileName)
|
|
3258
|
+
];
|
|
2997
3259
|
return candidates.find((p) => existsSync(p)) ?? candidates[0];
|
|
2998
3260
|
}
|
|
2999
3261
|
async function ensureParserInitialized() {
|
|
@@ -3073,6 +3335,7 @@ async function parseJavaAst(text) {
|
|
|
3073
3335
|
return (await getJavaParser()).parse(text) ?? void 0;
|
|
3074
3336
|
});
|
|
3075
3337
|
} catch {
|
|
3338
|
+
recordDegradation("ast-grammar-unavailable");
|
|
3076
3339
|
return;
|
|
3077
3340
|
}
|
|
3078
3341
|
}
|
|
@@ -3086,6 +3349,7 @@ async function parseCSharpAst(text) {
|
|
|
3086
3349
|
return (await getCSharpParser()).parse(text) ?? void 0;
|
|
3087
3350
|
});
|
|
3088
3351
|
} catch {
|
|
3352
|
+
recordDegradation("ast-grammar-unavailable");
|
|
3089
3353
|
return;
|
|
3090
3354
|
}
|
|
3091
3355
|
}
|
|
@@ -3101,6 +3365,7 @@ async function parsePythonAst(text) {
|
|
|
3101
3365
|
return (await getPythonParser()).parse(text) ?? void 0;
|
|
3102
3366
|
});
|
|
3103
3367
|
} catch {
|
|
3368
|
+
recordDegradation("ast-grammar-unavailable");
|
|
3104
3369
|
return;
|
|
3105
3370
|
}
|
|
3106
3371
|
}
|
|
@@ -3199,7 +3464,9 @@ const pythonAdapter = {
|
|
|
3199
3464
|
if (existsSync(pyproject)) try {
|
|
3200
3465
|
const text = readText$2(pyproject);
|
|
3201
3466
|
if (/\[tool\.pytest/i.test(text)) frameworks.add("pytest");
|
|
3202
|
-
} catch {
|
|
3467
|
+
} catch {
|
|
3468
|
+
recordDegradation("python-manifest-unreadable");
|
|
3469
|
+
}
|
|
3203
3470
|
for (const reqFile of [
|
|
3204
3471
|
"requirements.txt",
|
|
3205
3472
|
"requirements-dev.txt",
|
|
@@ -3211,7 +3478,9 @@ const pythonAdapter = {
|
|
|
3211
3478
|
const text = readText$2(p);
|
|
3212
3479
|
if (/^selenium\b/im.test(text)) frameworks.add("selenium");
|
|
3213
3480
|
if (/^playwright\b/im.test(text)) frameworks.add("playwright");
|
|
3214
|
-
} catch {
|
|
3481
|
+
} catch {
|
|
3482
|
+
recordDegradation("python-manifest-unreadable");
|
|
3483
|
+
}
|
|
3215
3484
|
}
|
|
3216
3485
|
if (frameworks.size === 0) return {
|
|
3217
3486
|
frameworks: [],
|
|
@@ -4293,6 +4562,22 @@ function isScopeRelevantIgnored(path) {
|
|
|
4293
4562
|
if (!/\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml|min\.js)$/i.test(name)) return false;
|
|
4294
4563
|
return isKnownTestFile(path);
|
|
4295
4564
|
}
|
|
4565
|
+
/**
|
|
4566
|
+
* Every source extension any shipped adapter claims.
|
|
4567
|
+
*
|
|
4568
|
+
* This is where `LanguageAdapter.extensions` is READ. The field was declared by
|
|
4569
|
+
* every adapter, listed a set of extensions, and consulted by nothing in
|
|
4570
|
+
* `src/` — so a support claim that no code checked was still a claim the
|
|
4571
|
+
* interface made to every reader.
|
|
4572
|
+
*
|
|
4573
|
+
* Derived rather than hand-listed so the two halves cannot drift. A test
|
|
4574
|
+
* already compared the old hand-written regex against this set and found them
|
|
4575
|
+
* equivalent, which is the honest result: the refactor removes a second
|
|
4576
|
+
* source of truth rather than fixing a wrong answer. The `.mts`/`.cts` gap
|
|
4577
|
+
* was real but lived in `extensions`, not here, and is now closed there.
|
|
4578
|
+
*/
|
|
4579
|
+
const ADAPTER_CLAIMED_EXTENSIONS = new Set(SCAN_ADAPTERS.flatMap((adapter) => adapter.extensions).map((ext) => ext.replace(/^\./, "").toLowerCase()));
|
|
4580
|
+
/** Whether ignoring this file would have removed it from the scanned surface. */
|
|
4296
4581
|
function isUnrecognizedSourceCandidate(path) {
|
|
4297
4582
|
const normalized = path.replaceAll("\\", "/");
|
|
4298
4583
|
const name = normalized.split("/").pop() ?? normalized;
|
|
@@ -4307,8 +4592,9 @@ function isUnrecognizedSourceCandidate(path) {
|
|
|
4307
4592
|
"tsconfig",
|
|
4308
4593
|
"jsconfig"
|
|
4309
4594
|
].some((prefix) => stem === prefix || stem.startsWith(`${prefix}.`))) return false;
|
|
4310
|
-
|
|
4311
|
-
|
|
4595
|
+
if (!(/(?:^|\/)__tests__\//i.test(normalized) || /\.(?:spec|test)\.[cm]?[jt]sx?$/i.test(name))) return false;
|
|
4596
|
+
const extension = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
|
|
4597
|
+
return ADAPTER_CLAIMED_EXTENSIONS.has(extension);
|
|
4312
4598
|
}
|
|
4313
4599
|
/** Whether ANY shipped adapter would discover this path as a test file. */
|
|
4314
4600
|
function isKnownTestFile(path) {
|
|
@@ -5498,7 +5784,7 @@ const ASYNC_PW_MATCHERS = /* @__PURE__ */ new Set([
|
|
|
5498
5784
|
]);
|
|
5499
5785
|
const unawaitedLocatorAssertion = defineRule({
|
|
5500
5786
|
id: "QA-PW-002",
|
|
5501
|
-
tier: "
|
|
5787
|
+
tier: "extended",
|
|
5502
5788
|
category: "QA-PW",
|
|
5503
5789
|
title: "Unawaited Playwright assertion",
|
|
5504
5790
|
severity: "error",
|
|
@@ -5612,7 +5898,7 @@ const committedDebugArtifacts = defineRule({
|
|
|
5612
5898
|
detail: "page.pause() and test.only() are exact Playwright runner tokens; the detector matches the member-call identifiers on the code-only text — closed token set, unique to the defect"
|
|
5613
5899
|
},
|
|
5614
5900
|
introduced: "0.1.0",
|
|
5615
|
-
tier: "
|
|
5901
|
+
tier: "extended",
|
|
5616
5902
|
run(ctx) {
|
|
5617
5903
|
const text = ctx.codeText ?? ctx.text;
|
|
5618
5904
|
const findings = [];
|
|
@@ -7438,7 +7724,7 @@ const pyHardSleep = defineRule({
|
|
|
7438
7724
|
*/
|
|
7439
7725
|
const pySkippedTest = defineRule({
|
|
7440
7726
|
id: "QA-PY-002",
|
|
7441
|
-
tier: "
|
|
7727
|
+
tier: "extended",
|
|
7442
7728
|
category: "QA-TEST",
|
|
7443
7729
|
title: "Skipped test",
|
|
7444
7730
|
severity: "warning",
|
|
@@ -7574,7 +7860,7 @@ const pyFocusedTest = defineRule({
|
|
|
7574
7860
|
detail: "pytest.skip/xfail/parametrize marks are runner decorators and module-level calls; the detector matches those exact tokens on the code-only text — the semantics are runner skip state"
|
|
7575
7861
|
},
|
|
7576
7862
|
introduced: "0.3.0",
|
|
7577
|
-
tier: "
|
|
7863
|
+
tier: "extended",
|
|
7578
7864
|
suiteInvalidating: true,
|
|
7579
7865
|
run(ctx) {
|
|
7580
7866
|
const text = ctx.text;
|
|
@@ -7818,26 +8104,65 @@ function walk(node, visit) {
|
|
|
7818
8104
|
for (const child of node.children) walk(child, visit);
|
|
7819
8105
|
}
|
|
7820
8106
|
/**
|
|
8107
|
+
* The `pytest.raises` call that is a DIRECT context manager of `withNode`.
|
|
8108
|
+
*
|
|
8109
|
+
* Scopes the search to the with's own item list — `with_clause` for the
|
|
8110
|
+
* plain form, and `parenthesized_with_items` for `with (A, B):` — rather
|
|
8111
|
+
* than to the whole subtree. A call found deeper down belongs to an inner
|
|
8112
|
+
* `with_statement`, which the walk reaches in its own right and which owns
|
|
8113
|
+
* the block that actually gates it.
|
|
8114
|
+
*/
|
|
8115
|
+
function directRaisesItem(withNode) {
|
|
8116
|
+
let found;
|
|
8117
|
+
const scan = (n) => {
|
|
8118
|
+
if (found) return;
|
|
8119
|
+
if (n.type === "call") {
|
|
8120
|
+
const fn = n.childForFieldName("function");
|
|
8121
|
+
if (fn && /(?:^|\.)raises$/.test(fn.text)) {
|
|
8122
|
+
found = n;
|
|
8123
|
+
return;
|
|
8124
|
+
}
|
|
8125
|
+
}
|
|
8126
|
+
for (const child of n.children) scan(child);
|
|
8127
|
+
};
|
|
8128
|
+
for (const child of withNode.children) if (child.type === "with_clause" || child.type === "parenthesized_with_items") scan(child);
|
|
8129
|
+
return found;
|
|
8130
|
+
}
|
|
8131
|
+
/**
|
|
7821
8132
|
* Every `with pytest.raises(...)` (optionally `... as exc`) statement in
|
|
7822
8133
|
* the tree, with the structural facts the rework needs. Tree-sitter-
|
|
7823
8134
|
* python grammar: a `with_statement` wraps `with_item`s and a `block`;
|
|
7824
8135
|
* the raises call is a `call` whose function is an attribute chain
|
|
7825
8136
|
* ending in `.raises`.
|
|
8137
|
+
*
|
|
8138
|
+
* 6.0. The raises call is now required to be a DIRECT context manager of the
|
|
8139
|
+
* `with_statement` being measured. It used to be found by walking the whole
|
|
8140
|
+
* subtree, so a `with pytest.raises(...)` nested inside another `with`
|
|
8141
|
+
* handed back the OUTER block's statement count — and `QA-PY-007`'s
|
|
8142
|
+
* single-statement gate, which exists precisely to suppress that shape,
|
|
8143
|
+
* could never fire for nested code.
|
|
8144
|
+
*
|
|
8145
|
+
* That is not a rare shape. In the pinned `pytest-dev/pytest` corpus, seven
|
|
8146
|
+
* of the eleven adjudicated findings were single-statement raises nested
|
|
8147
|
+
* inside `with saved_fd(1):`, and every one of them was counted as
|
|
8148
|
+
* multi-statement because the surrounding block has a dozen statements. The
|
|
8149
|
+
* rule measured the wrong block, which is why it measured 75% false
|
|
8150
|
+
* positives while its own unit fixtures passed.
|
|
8151
|
+
*
|
|
8152
|
+
* Requiring a direct context manager also fixes the shape by construction:
|
|
8153
|
+
* the outer `with` is examined, has no direct raises item, and is skipped;
|
|
8154
|
+
* the inner one is examined with its own block.
|
|
7826
8155
|
*/
|
|
7827
8156
|
function pythonWithRaisesBlocks(tree) {
|
|
7828
8157
|
const out = [];
|
|
7829
8158
|
const root = tree.rootNode;
|
|
7830
8159
|
walk(root, (node) => {
|
|
7831
8160
|
if (node.type !== "with_statement") return;
|
|
7832
|
-
let raisesCall;
|
|
7833
8161
|
let blockNode;
|
|
7834
8162
|
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;
|
|
8163
|
+
if (!blockNode) return;
|
|
8164
|
+
const raisesCall = directRaisesItem(node);
|
|
8165
|
+
if (!raisesCall) return;
|
|
7841
8166
|
const stmts = blockNode.namedChildren.filter((c) => c.type !== "comment");
|
|
7842
8167
|
const exceptionText = (raisesCall.childForFieldName("arguments")?.namedChildren[0])?.text ?? "";
|
|
7843
8168
|
const broadException = BROAD_EXC_RE.test(exceptionText);
|
|
@@ -7883,7 +8208,7 @@ const pyRaisesWithoutMatch = defineRule({
|
|
|
7883
8208
|
},
|
|
7884
8209
|
introduced: "0.3.0",
|
|
7885
8210
|
tier: "quarantine",
|
|
7886
|
-
detectorRevision:
|
|
8211
|
+
detectorRevision: 5,
|
|
7887
8212
|
run(ctx) {
|
|
7888
8213
|
const findings = [];
|
|
7889
8214
|
if (!ctx.path.endsWith(".py")) return findings;
|
|
@@ -7942,16 +8267,17 @@ function matchParen$1(text, open) {
|
|
|
7942
8267
|
return -1;
|
|
7943
8268
|
}
|
|
7944
8269
|
/**
|
|
7945
|
-
* AST arm (detectorRevision
|
|
7946
|
-
* parsed `with pytest.raises(...)` statements
|
|
7947
|
-
*
|
|
7948
|
-
*
|
|
7949
|
-
*
|
|
7950
|
-
*
|
|
7951
|
-
*
|
|
7952
|
-
*
|
|
7953
|
-
*
|
|
7954
|
-
*
|
|
8270
|
+
* AST arm (detectorRevision 5 — P6 rework, widened on adjudicated evidence):
|
|
8271
|
+
* candidates come from the parsed `with pytest.raises(...)` statements, and
|
|
8272
|
+
* the raises call must be a DIRECT context manager of the with whose block
|
|
8273
|
+
* is measured (see `pythonWithRaisesBlocks` for why).
|
|
8274
|
+
*
|
|
8275
|
+
* The gate: a block with fewer than two statements is exempt. That covers
|
|
8276
|
+
* the adjudicated FP core — pytest-dev/pytest nests its capture tests inside
|
|
8277
|
+
* `with saved_fd(1):`, and every one of those was scored as multi-statement
|
|
8278
|
+
* until the block resolution was fixed. A broad root type (Exception /
|
|
8279
|
+
* BaseException / …Group) still fires on a MULTI-statement block, where an
|
|
8280
|
+
* earlier line can raise it before the intended one.
|
|
7955
8281
|
*/
|
|
7956
8282
|
function astArm(ctx, tree) {
|
|
7957
8283
|
const raw = ctx.text;
|
|
@@ -7965,7 +8291,7 @@ function astArm(ctx, tree) {
|
|
|
7965
8291
|
const tail = raw.slice(block.callEnd, block.callEnd + 2400);
|
|
7966
8292
|
if (new RegExp(`\\b${name}\\b`).test(tail)) continue;
|
|
7967
8293
|
}
|
|
7968
|
-
if (block.blockStatements < 2
|
|
8294
|
+
if (block.blockStatements < 2) continue;
|
|
7969
8295
|
findings.push({
|
|
7970
8296
|
severity: "warning",
|
|
7971
8297
|
confidence: "medium",
|
|
@@ -8008,7 +8334,7 @@ const pyCommentedOutTest = defineRule({
|
|
|
8008
8334
|
},
|
|
8009
8335
|
detectionNotes: "regex heuristic",
|
|
8010
8336
|
introduced: "0.3.0",
|
|
8011
|
-
tier: "
|
|
8337
|
+
tier: "extended",
|
|
8012
8338
|
run(ctx) {
|
|
8013
8339
|
const findings = [];
|
|
8014
8340
|
if (!ctx.path.endsWith(".py")) return findings;
|
|
@@ -8057,13 +8383,18 @@ const pyBareTruthinessAssert = defineRule({
|
|
|
8057
8383
|
},
|
|
8058
8384
|
introduced: "0.3.0",
|
|
8059
8385
|
tier: "quarantine",
|
|
8060
|
-
detectorRevision:
|
|
8386
|
+
detectorRevision: 4,
|
|
8061
8387
|
run(ctx) {
|
|
8062
8388
|
const text = ctx.codeText ?? ctx.text;
|
|
8063
8389
|
const findings = [];
|
|
8064
8390
|
if (!ctx.path.endsWith(".py")) return findings;
|
|
8065
8391
|
const re = /^[ \t]*assert\s+([A-Za-z_][\w.]*(?:\([^()]*\))?)[ \t]*$/gm;
|
|
8066
|
-
|
|
8392
|
+
/**
|
|
8393
|
+
* Constants. `assert False` / `assert None` are deliberate mechanisms —
|
|
8394
|
+
* a test that manufactures an exception to assert on its shape — not a
|
|
8395
|
+
* truthiness check whose wrong value would slip through.
|
|
8396
|
+
*/
|
|
8397
|
+
const isConstant = (target) => /^(?:True|False|None|NotImplemented|Ellipsis)$/.test(target) || /^["'\d]/.test(target);
|
|
8067
8398
|
const isGuardFollowedByRealUse = (text, matchIndex, target) => {
|
|
8068
8399
|
const lineEnd = text.indexOf("\n", matchIndex);
|
|
8069
8400
|
if (lineEnd === -1) return false;
|
|
@@ -8083,8 +8414,9 @@ const pyBareTruthinessAssert = defineRule({
|
|
|
8083
8414
|
let m;
|
|
8084
8415
|
while ((m = re.exec(text)) !== null) {
|
|
8085
8416
|
const target = m[1];
|
|
8417
|
+
if (target.includes("(")) continue;
|
|
8418
|
+
if (isConstant(target)) continue;
|
|
8086
8419
|
if (/^(?:is|has|can|should|was|were)_/.test(target)) continue;
|
|
8087
|
-
if (predicateRe.test(target)) continue;
|
|
8088
8420
|
if (/\.called$/.test(target)) continue;
|
|
8089
8421
|
if (isGuardFollowedByRealUse(text, m.index, target)) continue;
|
|
8090
8422
|
findings.push({
|
|
@@ -8130,7 +8462,7 @@ const pyMutableFixture = defineRule({
|
|
|
8130
8462
|
detail: "pytest fixture mutation is fixture-lifecycle semantics (autouse/scope keys plus mutation calls); the detector matches the runner's fixture decorator tokens plus the mutation shapes"
|
|
8131
8463
|
},
|
|
8132
8464
|
introduced: "0.3.0",
|
|
8133
|
-
tier: "
|
|
8465
|
+
tier: "extended",
|
|
8134
8466
|
run(ctx) {
|
|
8135
8467
|
const text = ctx.text;
|
|
8136
8468
|
const findings = [];
|
|
@@ -8164,7 +8496,7 @@ const pyMutableFixture = defineRule({
|
|
|
8164
8496
|
*/
|
|
8165
8497
|
const pwWaitForTimeout = defineRule({
|
|
8166
8498
|
id: "QA-PW-101",
|
|
8167
|
-
tier: "
|
|
8499
|
+
tier: "extended",
|
|
8168
8500
|
category: "QA-PW",
|
|
8169
8501
|
title: "Hard sleep via waitForTimeout",
|
|
8170
8502
|
severity: "error",
|
|
@@ -8285,7 +8617,7 @@ const pwDeepFrameLocator = defineRule({
|
|
|
8285
8617
|
detail: "frameLocator chaining depth is an exact Playwright token sequence; the detector matches the frameLocator call chains — the token sequence is closed and unique to the defect"
|
|
8286
8618
|
},
|
|
8287
8619
|
introduced: "0.3.0",
|
|
8288
|
-
tier: "
|
|
8620
|
+
tier: "extended",
|
|
8289
8621
|
run(ctx) {
|
|
8290
8622
|
const text = ctx.codeText ?? ctx.text;
|
|
8291
8623
|
const findings = [];
|
|
@@ -8394,7 +8726,7 @@ const pwConfigRetryAbuse = defineRule({
|
|
|
8394
8726
|
},
|
|
8395
8727
|
detectionNotes: "regex heuristic",
|
|
8396
8728
|
introduced: "0.3.0",
|
|
8397
|
-
tier: "
|
|
8729
|
+
tier: "extended",
|
|
8398
8730
|
run(ctx) {
|
|
8399
8731
|
const text = ctx.codeText ?? ctx.text;
|
|
8400
8732
|
const findings = [];
|
|
@@ -8412,7 +8744,7 @@ const pwConfigRetryAbuse = defineRule({
|
|
|
8412
8744
|
column: colAt(text, m.index),
|
|
8413
8745
|
message: `retries: ${m[1]} — a flaky test gets ${m[1]} chances to pass by luck.`,
|
|
8414
8746
|
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
|
|
8747
|
+
fix: "Keep retries <= 2 and route repeat offenders into the evidence path (`explain --evidence`)."
|
|
8416
8748
|
});
|
|
8417
8749
|
const workersRe = /workers\s*:\s*0\b/g;
|
|
8418
8750
|
while ((m = workersRe.exec(text)) !== null) findings.push({
|
|
@@ -8569,7 +8901,7 @@ const pwTrialMisuse = defineRule({
|
|
|
8569
8901
|
detail: "the trial-click shape is an exact Playwright API token pair; the detector matches the call identifier on the code-only text — the token is closed and unique to the defect"
|
|
8570
8902
|
},
|
|
8571
8903
|
introduced: "0.3.0",
|
|
8572
|
-
tier: "
|
|
8904
|
+
tier: "extended",
|
|
8573
8905
|
run(ctx) {
|
|
8574
8906
|
const text = ctx.codeText ?? ctx.text;
|
|
8575
8907
|
const findings = [];
|
|
@@ -8774,7 +9106,7 @@ const qaPw140 = defineRule({
|
|
|
8774
9106
|
detail: "the detector matches a closed, exact runner API token on the code-only text; the token identifies the defect uniquely"
|
|
8775
9107
|
},
|
|
8776
9108
|
introduced: "0.3.0",
|
|
8777
|
-
tier: "
|
|
9109
|
+
tier: "extended",
|
|
8778
9110
|
run(ctx) {
|
|
8779
9111
|
const text = ctx.codeText ?? ctx.text;
|
|
8780
9112
|
const findings = [];
|
|
@@ -8989,7 +9321,7 @@ const pwRetryMaskingNoForensics = defineRule({
|
|
|
8989
9321
|
column: colAt(text, m.index),
|
|
8990
9322
|
message: `retries: ${m[1]} with no visible flake-triage loop.`,
|
|
8991
9323
|
why: "Retries convert intermittent failures into silent passes. Without a forensics/triage step consuming retry data, flaky tests pass forever and real regressions hide behind lucky reruns.",
|
|
8992
|
-
fix: "Keep retries <= 2 and feed retry outcomes into `
|
|
9324
|
+
fix: "Keep retries <= 2 and feed retry outcomes into the evidence path (`explain --evidence`), or add a reporter so flaky passes are reviewed."
|
|
8993
9325
|
});
|
|
8994
9326
|
}
|
|
8995
9327
|
return findings;
|
|
@@ -9384,7 +9716,7 @@ const pyPwWaitForTimeout = defineRule({
|
|
|
9384
9716
|
detail: "page.waitForTimeout is an exact Playwright token in Python tests; the detector matches the call identifier — closed token, same predicate the AST would encode"
|
|
9385
9717
|
},
|
|
9386
9718
|
introduced: "0.3.8",
|
|
9387
|
-
tier: "
|
|
9719
|
+
tier: "extended",
|
|
9388
9720
|
run(ctx) {
|
|
9389
9721
|
const text = ctx.codeText ?? ctx.text;
|
|
9390
9722
|
const findings = [];
|
|
@@ -10269,7 +10601,7 @@ function extractPythonModel(file) {
|
|
|
10269
10601
|
*/
|
|
10270
10602
|
const jvWaitForTimeout = defineRule({
|
|
10271
10603
|
id: "QA-JV-105",
|
|
10272
|
-
tier: "
|
|
10604
|
+
tier: "extended",
|
|
10273
10605
|
category: "QA-PW",
|
|
10274
10606
|
title: "waitForTimeout hard sleep",
|
|
10275
10607
|
severity: "warning",
|
|
@@ -10338,7 +10670,7 @@ const jvWaitForTimeout = defineRule({
|
|
|
10338
10670
|
*/
|
|
10339
10671
|
const csSkippedTest = defineRule({
|
|
10340
10672
|
id: "QA-CS-101",
|
|
10341
|
-
tier: "
|
|
10673
|
+
tier: "extended",
|
|
10342
10674
|
category: "QA-PW",
|
|
10343
10675
|
title: "Skipped test",
|
|
10344
10676
|
severity: "warning",
|
|
@@ -10533,7 +10865,7 @@ const csNoAssertions = defineRule({
|
|
|
10533
10865
|
detectionStrategy: "AST",
|
|
10534
10866
|
detectionNotes: "L2 tree-sitter test-method scoping + structural assertion oracle (Shouldly/Verify/Assert receivers, throwing waits); regex fallback when no parse",
|
|
10535
10867
|
introduced: "0.3.8",
|
|
10536
|
-
tier: "
|
|
10868
|
+
tier: "extended",
|
|
10537
10869
|
detectorRevision: 2,
|
|
10538
10870
|
astQuery: cs103AstQuery,
|
|
10539
10871
|
run(ctx) {
|
|
@@ -11259,7 +11591,7 @@ const hardSleepFamily = definePatternFamily({
|
|
|
11259
11591
|
"mstest",
|
|
11260
11592
|
"playwright"
|
|
11261
11593
|
],
|
|
11262
|
-
tier: "
|
|
11594
|
+
tier: "extended",
|
|
11263
11595
|
detectionStrategy: "AST",
|
|
11264
11596
|
detectionNotes: "L2 tree-sitter invocation scoping (route/expose/server delegates, infinite blocks, WhenAny races, runner payload fixtures); regex fallback when no parse",
|
|
11265
11597
|
detectorRevision: 2,
|
|
@@ -11582,7 +11914,7 @@ const retryMaskingFamily = [defineRule({
|
|
|
11582
11914
|
detail: "the retry-masking family's variants (QA-JV-109/QA-CS-109): retry masking is the runner's retry contract; the detector matches the runner's retry tokens on the code-only text"
|
|
11583
11915
|
},
|
|
11584
11916
|
introduced: "0.4.0",
|
|
11585
|
-
tier: "
|
|
11917
|
+
tier: "extended",
|
|
11586
11918
|
run(ctx) {
|
|
11587
11919
|
const text = ctx.codeText ?? ctx.text;
|
|
11588
11920
|
const findings = [];
|
|
@@ -11761,51 +12093,115 @@ function getRule(id) {
|
|
|
11761
12093
|
return RULES.find((r) => r.id === id);
|
|
11762
12094
|
}
|
|
11763
12095
|
//#endregion
|
|
11764
|
-
//#region src/lib/
|
|
12096
|
+
//#region src/lib/wilson.ts
|
|
11765
12097
|
/**
|
|
11766
|
-
*
|
|
12098
|
+
* 95% Wilson score interval on a proportion (plan §20.2).
|
|
11767
12099
|
*
|
|
11768
|
-
* `
|
|
11769
|
-
*
|
|
11770
|
-
*
|
|
11771
|
-
*
|
|
11772
|
-
*
|
|
11773
|
-
*
|
|
11774
|
-
*
|
|
11775
|
-
* repository then produces two different fingerprints.
|
|
12100
|
+
* Lives in `src/` rather than `scripts/` because a measurement's interval is
|
|
12101
|
+
* now part of the ENGINE's tier decision (`src/rules/measurement.ts`), not
|
|
12102
|
+
* only a reporting nicety for the capability matrix. `scripts/lib/wilson.ts`
|
|
12103
|
+
* re-exports this module, so the number a gate prints and the number the
|
|
12104
|
+
* engine tiers on are the same number computed once. Two implementations
|
|
12105
|
+
* would be two sources of truth for the same figure, which is the failure
|
|
12106
|
+
* mode this whole release is about.
|
|
11776
12107
|
*
|
|
11777
|
-
*
|
|
12108
|
+
* The 4-dp rounding keeps generated artifacts byte-stable (see the capability
|
|
12109
|
+
* matrix generator's note on deterministic output).
|
|
12110
|
+
*/
|
|
12111
|
+
const Z95 = 1.959963984540054;
|
|
12112
|
+
/** 95% Wilson score interval on fp/n (fp successes of n trials), 4 dp. */
|
|
12113
|
+
function wilsonInterval(fp, n) {
|
|
12114
|
+
if (n <= 0) return {
|
|
12115
|
+
ciLow: 0,
|
|
12116
|
+
ciHigh: 1
|
|
12117
|
+
};
|
|
12118
|
+
const p = fp / n;
|
|
12119
|
+
const z2 = Z95 * Z95;
|
|
12120
|
+
const denom = 1 + z2 / n;
|
|
12121
|
+
const center = (p + z2 / (2 * n)) / denom;
|
|
12122
|
+
const half = Z95 / denom * Math.sqrt(p * (1 - p) / n + z2 / (4 * n * n));
|
|
12123
|
+
const round4 = (x) => Math.round(x * 1e4) / 1e4;
|
|
12124
|
+
return {
|
|
12125
|
+
ciLow: round4(Math.max(0, center - half)),
|
|
12126
|
+
ciHigh: round4(Math.min(1, center + half))
|
|
12127
|
+
};
|
|
12128
|
+
}
|
|
12129
|
+
/** The rule's declared detector implementation revision (§07). */
|
|
12130
|
+
function declaredDetectorRevision(rule) {
|
|
12131
|
+
return rule.detectorRevision ?? 1;
|
|
12132
|
+
}
|
|
12133
|
+
/**
|
|
12134
|
+
* A measurement exists AND was taken against the detector revision the
|
|
12135
|
+
* rule declares now. A revision mismatch (stale) does NOT count — §07:
|
|
12136
|
+
* stale → provisional → re-measure.
|
|
12137
|
+
*/
|
|
12138
|
+
function hasValidMeasurement(rule) {
|
|
12139
|
+
const m = MEASURED_FP[rule.id];
|
|
12140
|
+
return m !== void 0 && m.detectorRevision === declaredDetectorRevision(rule);
|
|
12141
|
+
}
|
|
12142
|
+
/**
|
|
12143
|
+
* The confidence interval of a rule's VALID measurement, or undefined when
|
|
12144
|
+
* it has none. A stale measurement returns undefined rather than its stale
|
|
12145
|
+
* interval: a number derived from an older detector is not evidence about
|
|
12146
|
+
* this one, and returning it would let the straddle test pass on stale data.
|
|
11778
12147
|
*
|
|
11779
|
-
*
|
|
11780
|
-
*
|
|
11781
|
-
*
|
|
11782
|
-
*
|
|
11783
|
-
*
|
|
12148
|
+
* The FP count is ROUNDED, and it is not an optimization. The shipped
|
|
12149
|
+
* measurement records a rate and a sample count, not an integer count of false
|
|
12150
|
+
* positives, so `fpRate * n` is generally fractional — 10.5% of 19 is 1.995.
|
|
12151
|
+
* `wilsonInterval` rounds internally, so passing the raw product gave this
|
|
12152
|
+
* function and `scripts/core-readiness.ts` two different `ciHigh` values for
|
|
12153
|
+
* the same rule at the same n: the ratchet decided one tier and the
|
|
12154
|
+
* readiness table printed another, both derived, neither reconciled. Every
|
|
12155
|
+
* caller now reconstructs the count the same way.
|
|
12156
|
+
*/
|
|
12157
|
+
function measurementInterval(rule) {
|
|
12158
|
+
if (!hasValidMeasurement(rule)) return void 0;
|
|
12159
|
+
const m = MEASURED_FP[rule.id];
|
|
12160
|
+
if (m === void 0) return void 0;
|
|
12161
|
+
return wilsonInterval(Math.round(m.fpRate * m.n), m.n);
|
|
12162
|
+
}
|
|
12163
|
+
/**
|
|
12164
|
+
* The tier the MEASUREMENT alone supports, ignoring any declared tier.
|
|
11784
12165
|
*
|
|
11785
|
-
*
|
|
11786
|
-
*
|
|
11787
|
-
*
|
|
11788
|
-
* rather than removed because a reader-facing list of "Test / Logic /
|
|
11789
|
-
* Mock" should not be ordered by code unit.
|
|
12166
|
+
* This is the interval floor that `effectiveTier` falls back to, extracted so
|
|
12167
|
+
* the tightening law can ask the question `effectiveTier` structurally cannot:
|
|
12168
|
+
* a declared tier may only ever hold a rule DOWN, never release it.
|
|
11790
12169
|
*
|
|
11791
|
-
*
|
|
11792
|
-
* `
|
|
12170
|
+
* `effectiveTier` cannot answer that, because it short-circuits on
|
|
12171
|
+
* `rule.tier` — asking it what a rule's tier "would" be returns the declared
|
|
12172
|
+
* value, and a comparison built on it compares the declaration with itself.
|
|
12173
|
+
* The first B0 dry-run made exactly that mistake and reported zero
|
|
12174
|
+
* mismatches for a comparison that had never run.
|
|
12175
|
+
*
|
|
12176
|
+
* READ THIS BEFORE USING IT AS A FLOOR. Measured over all 79 live rules, this
|
|
12177
|
+
* function returns `extended` for 78 of them and `quarantine` for none: on
|
|
12178
|
+
* this corpus the interval rule is very nearly an empty rule, because
|
|
12179
|
+
* `ciLow >= 50%` is not reached at n = 10..80. A "declared tier may only
|
|
12180
|
+
* tighten" law written against THIS function is decorative — it can only ever
|
|
12181
|
+
* catch a rule declaring `core`, which no rule declares. It was written that
|
|
12182
|
+
* way first and measured at zero teeth.
|
|
12183
|
+
*
|
|
12184
|
+
* The floor with teeth is `defensibleTier`, below.
|
|
11793
12185
|
*/
|
|
11794
|
-
|
|
11795
|
-
|
|
11796
|
-
if (
|
|
11797
|
-
|
|
12186
|
+
function measurementTier(rule) {
|
|
12187
|
+
const interval = measurementInterval(rule);
|
|
12188
|
+
if (interval === void 0) return "extended";
|
|
12189
|
+
if (interval.ciHigh <= .1) return "core";
|
|
12190
|
+
if (interval.ciLow >= .5) return "quarantine";
|
|
12191
|
+
return "extended";
|
|
11798
12192
|
}
|
|
11799
12193
|
/**
|
|
11800
|
-
*
|
|
12194
|
+
* The tier a rule effectively ships as: its declared tier, or — for the
|
|
12195
|
+
* omitted-tier case — what its measurement's interval supports (§11.2 Step 2).
|
|
11801
12196
|
*
|
|
11802
|
-
* The
|
|
11803
|
-
*
|
|
11804
|
-
*
|
|
11805
|
-
*
|
|
12197
|
+
* The declared tier is a floor that may only tighten, and the measurement is
|
|
12198
|
+
* what it is measured against. See `rule.ts`'s `tier` doc for the B0
|
|
12199
|
+
* measurement behind that, and `defensibleTier` for the floor it is measured
|
|
12200
|
+
* against.
|
|
11806
12201
|
*/
|
|
11807
|
-
function
|
|
11808
|
-
|
|
12202
|
+
function effectiveTier(rule) {
|
|
12203
|
+
if (rule.tier !== void 0) return rule.tier;
|
|
12204
|
+
return measurementTier(rule);
|
|
11809
12205
|
}
|
|
11810
12206
|
//#endregion
|
|
11811
12207
|
//#region src/scorer/scorer.ts
|
|
@@ -12013,26 +12409,105 @@ function asUniversal(rule) {
|
|
|
12013
12409
|
}
|
|
12014
12410
|
//#endregion
|
|
12015
12411
|
//#region src/engine/tier-policy.ts
|
|
12412
|
+
/**
|
|
12413
|
+
* Tier policy (audit H-1): the declared tier is authoritative over a
|
|
12414
|
+
* rule's severity and evidence claims.
|
|
12415
|
+
*
|
|
12416
|
+
* Quarantine-tier rules exist because their real-world false-positive
|
|
12417
|
+
* rate is unproven or proven bad (docs/FP-AUDIT.md records several at
|
|
12418
|
+
* 100% measured FP). A rule known to be wrong every single time it
|
|
12419
|
+
* fires must never gate CI or deduct score — regardless of the
|
|
12420
|
+
* severity/evidenceLevel its metadata declares. The pipeline therefore
|
|
12421
|
+
* caps every quarantine finding to severity=info, evidence=E0, which
|
|
12422
|
+
* makes it advisory by construction (isAdvisoryFinding excludes E0).
|
|
12423
|
+
*
|
|
12424
|
+
* The core tier carries the opposite bound (ADR 0014). A CAP says a tier
|
|
12425
|
+
* may not be stronger than this; a FLOOR says it may not be weaker. Core
|
|
12426
|
+
* is earned — it means the measurement cleared the false-positive
|
|
12427
|
+
* ceiling with enough samples to trust the interval — so a core rule
|
|
12428
|
+
* emitting a finding that costs nothing and blocks nothing is claiming a
|
|
12429
|
+
* promotion it has not cashed in. Before the floor existed, core and
|
|
12430
|
+
* extended were enforced identically, and reaching core changed only the
|
|
12431
|
+
* dedup tie-break, the M3 ceiling and the census readout: a consumer
|
|
12432
|
+
* could not tell them apart.
|
|
12433
|
+
*/
|
|
12016
12434
|
/** Cap applied to every finding of a quarantine-tier rule (H-1). */
|
|
12017
12435
|
const QUARANTINE_CAP = {
|
|
12018
12436
|
severity: "info",
|
|
12019
12437
|
evidenceLevel: "E0"
|
|
12020
12438
|
};
|
|
12021
|
-
/**
|
|
12022
|
-
|
|
12023
|
-
|
|
12024
|
-
|
|
12439
|
+
/**
|
|
12440
|
+
* Floor applied to every finding of a core-tier rule (ADR 0014).
|
|
12441
|
+
*
|
|
12442
|
+
* `E1`, not `E2`, and the reason is the difference between the two.
|
|
12443
|
+
* `E2` would be a claim that the finding is proven, which is a statement
|
|
12444
|
+
* about the code that no tier policy may make — corroboration does that,
|
|
12445
|
+
* at runtime, per finding. `E1` is the weaker and sufficient claim: a
|
|
12446
|
+
* core rule is measured, so its finding is pattern evidence rather than
|
|
12447
|
+
* an observation, and it is worth half the deduction.
|
|
12448
|
+
*
|
|
12449
|
+
* `E0` is what the floor exists to forbid. `isAdvisoryFinding` is
|
|
12450
|
+
* `level === "E0"`, so an E0 finding is simultaneously worth zero points
|
|
12451
|
+
* and unable to fail a gate. A rule whose false-positive rate is
|
|
12452
|
+
* measured under the 10% ceiling should never be able to produce one.
|
|
12453
|
+
*/
|
|
12454
|
+
const CORE_FLOOR = { evidenceLevel: "E1" };
|
|
12455
|
+
/**
|
|
12456
|
+
* The policy for a tier.
|
|
12457
|
+
*
|
|
12458
|
+
* `undefined` returns an EMPTY policy, and this is a decision, not a
|
|
12459
|
+
* convenience. `buildUniversalRules` in `scan-pipeline.ts` populates
|
|
12460
|
+
* `tierByRuleId` with `effectiveTier(rule)` for every registry rule — never
|
|
12461
|
+
* undefined — and adds a plugin or workspace-local rule only when that rule
|
|
12462
|
+
* DECLARES a tier. So an absent entry means "a third-party rule that claimed no
|
|
12463
|
+
* tier", and it is the one population that must not inherit a trust bound.
|
|
12464
|
+
*
|
|
12465
|
+
* It used to read "undefined tier = core" in this docstring, and that sentence
|
|
12466
|
+
* was load-bearing in the wrong direction. Before the floor, an absent entry
|
|
12467
|
+
* cost nothing, so the claim was inert. With a core floor, treating undefined as
|
|
12468
|
+
* core would raise every plugin's E0 finding to E1 and start charging
|
|
12469
|
+
* third-party detections for deductions — the exact trust escalation TI-013
|
|
12470
|
+
* exists to prevent, introduced by the ADR written to close a trust gap. A
|
|
12471
|
+
* plugin that wants the core floor has to declare `tier: "core"` and be
|
|
12472
|
+
* reviewed for it.
|
|
12473
|
+
*/
|
|
12474
|
+
function policyForTier(tier) {
|
|
12475
|
+
if (tier === "quarantine") return { cap: QUARANTINE_CAP };
|
|
12476
|
+
if (tier === "core") return { floor: CORE_FLOOR };
|
|
12477
|
+
return {};
|
|
12478
|
+
}
|
|
12479
|
+
/**
|
|
12480
|
+
* E-ladder order. "E2" > "E1" > "E0" is a claim about how much evidence a
|
|
12481
|
+
* finding carries, and the floor is only meaningful if "at least E1" can be
|
|
12482
|
+
* stated. Declared as a ladder rather than compared as strings because
|
|
12483
|
+
* `"E0" > "E1"` is true in JavaScript and would invert the floor.
|
|
12484
|
+
*/
|
|
12485
|
+
const EVIDENCE_RANK$1 = {
|
|
12486
|
+
E0: 0,
|
|
12487
|
+
E1: 1,
|
|
12488
|
+
E2: 2
|
|
12489
|
+
};
|
|
12025
12490
|
/**
|
|
12026
12491
|
* In-place enforcement over a scan's findings. Idempotent; runs AFTER
|
|
12027
|
-
* evidence stamping so it wins over rule-declared evidenceLevel
|
|
12492
|
+
* evidence stamping so it wins over rule-declared evidenceLevel — for the
|
|
12493
|
+
* cap and the floor alike. A rule's own `evidenceLevel` in `RuleMeta` is a
|
|
12494
|
+
* declaration about the rule; the tier is a decision about the rule, and
|
|
12495
|
+
* the decision outranks the declaration.
|
|
12496
|
+
*
|
|
12497
|
+
* Idempotence: cap-then-floor is a fixed point. A finding already at the
|
|
12498
|
+
* cap stays at the cap, and one already at or above the floor stays put,
|
|
12499
|
+
* so running this twice cannot walk a finding up the ladder.
|
|
12028
12500
|
*/
|
|
12029
12501
|
function enforceTierPolicy(findings, tierByRuleId) {
|
|
12030
12502
|
for (const f of findings) {
|
|
12031
|
-
const cap =
|
|
12503
|
+
const { cap, floor } = policyForTier(tierByRuleId.get(f.ruleId));
|
|
12032
12504
|
if (cap) {
|
|
12033
12505
|
f.severity = cap.severity;
|
|
12034
12506
|
f.evidenceLevel = cap.evidenceLevel;
|
|
12035
12507
|
}
|
|
12508
|
+
if (floor?.evidenceLevel === void 0) continue;
|
|
12509
|
+
const effective = f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence);
|
|
12510
|
+
if (EVIDENCE_RANK$1[effective] < EVIDENCE_RANK$1[floor.evidenceLevel]) f.evidenceLevel = floor.evidenceLevel;
|
|
12036
12511
|
}
|
|
12037
12512
|
}
|
|
12038
12513
|
//#endregion
|
|
@@ -12221,8 +12696,8 @@ function isDeclaredByPresent(f, present, metaByRuleId) {
|
|
|
12221
12696
|
function ranksBefore(a, b, metaByRuleId) {
|
|
12222
12697
|
const ma = metaByRuleId.get(a.ruleId);
|
|
12223
12698
|
const mb = metaByRuleId.get(b.ruleId);
|
|
12224
|
-
const tierA = TIER_RANK[ma?.tier ?? "
|
|
12225
|
-
const tierB = TIER_RANK[mb?.tier ?? "
|
|
12699
|
+
const tierA = TIER_RANK[ma?.tier ?? "extended"];
|
|
12700
|
+
const tierB = TIER_RANK[mb?.tier ?? "extended"];
|
|
12226
12701
|
if (tierA !== tierB) return tierA < tierB;
|
|
12227
12702
|
const sevA = SEVERITY_RANK[a.severity];
|
|
12228
12703
|
const sevB = SEVERITY_RANK[b.severity];
|
|
@@ -12242,6 +12717,16 @@ function dropOrderBefore(a, b, metaByRuleId) {
|
|
|
12242
12717
|
return (a.column ?? Number.MAX_SAFE_INTEGER) < (b.column ?? Number.MAX_SAFE_INTEGER);
|
|
12243
12718
|
}
|
|
12244
12719
|
//#endregion
|
|
12720
|
+
//#region src/engine/finding-identity.ts
|
|
12721
|
+
/**
|
|
12722
|
+
* Correlation identity for before/after comparison: ruleId + file +
|
|
12723
|
+
* message, deliberately EXCLUDING line — a source edit that shifts a
|
|
12724
|
+
* finding still correlates. Shared by baseline, impact, and resolution.
|
|
12725
|
+
*/
|
|
12726
|
+
function findingFingerprint(f) {
|
|
12727
|
+
return `${f.ruleId}\u0000${f.file}\u0000${f.message}`;
|
|
12728
|
+
}
|
|
12729
|
+
//#endregion
|
|
12245
12730
|
//#region src/engine/correlation-engine.ts
|
|
12246
12731
|
const EVIDENCE_RANK = {
|
|
12247
12732
|
E0: 0,
|
|
@@ -12286,13 +12771,31 @@ function groupByFile(findings) {
|
|
|
12286
12771
|
return groups;
|
|
12287
12772
|
}
|
|
12288
12773
|
/**
|
|
12289
|
-
* Group findings by
|
|
12290
|
-
*
|
|
12774
|
+
* Group findings by root cause, falling back to the FINDING FINGERPRINT.
|
|
12775
|
+
*
|
|
12776
|
+
* The fallback used to be `ruleId`, and that is the defect this fixes. Nothing
|
|
12777
|
+
* in the tree assigns `rootCauseId` — the machine contract reports it `null` on
|
|
12778
|
+
* every finding — so `ruleId` was the key for every group, and N independent
|
|
12779
|
+
* defects from one rule across N files were reported as
|
|
12780
|
+
*
|
|
12781
|
+
* CONVERGENT — N findings share root cause QA-PW-002
|
|
12782
|
+
*
|
|
12783
|
+
* which is the precise inverse of what convergence means. Convergence is the
|
|
12784
|
+
* claim that several findings are ONE thing; the key that produced it said
|
|
12785
|
+
* only "these are the same rule". A fingerprint is ruleId + file + message, so
|
|
12786
|
+
* two `QA-PW-002` findings in different files are two keys, two groups of one,
|
|
12787
|
+
* and neither is reported.
|
|
12788
|
+
*
|
|
12789
|
+
* This is also why `findingId`/`rootCauseId`/`deduplicationGroup` are not
|
|
12790
|
+
* wired instead: they are the same idea, and an unwired field plus a fallback
|
|
12791
|
+
* would be two mechanisms for one claim. When a rule declares a real root
|
|
12792
|
+
* cause, `rootCauseId` still wins — the field is honoured the moment anything
|
|
12793
|
+
* populates it.
|
|
12291
12794
|
*/
|
|
12292
12795
|
function groupByRootCause(findings) {
|
|
12293
12796
|
const groups = /* @__PURE__ */ new Map();
|
|
12294
12797
|
for (const f of findings) {
|
|
12295
|
-
const key = f.rootCauseId ?? f
|
|
12798
|
+
const key = f.rootCauseId ?? findingFingerprint(f);
|
|
12296
12799
|
const list = groups.get(key) ?? [];
|
|
12297
12800
|
list.push(f);
|
|
12298
12801
|
groups.set(key, list);
|
|
@@ -12300,6 +12803,17 @@ function groupByRootCause(findings) {
|
|
|
12300
12803
|
return groups;
|
|
12301
12804
|
}
|
|
12302
12805
|
/**
|
|
12806
|
+
* The identity a conclusion lists a finding under.
|
|
12807
|
+
*
|
|
12808
|
+
* `findingId` when something has populated it, else the fingerprint. Never
|
|
12809
|
+
* `ruleId`: the fallback used to collapse every finding from one rule onto one
|
|
12810
|
+
* id, so a three-finding CONVERGENT group listed the same id three times and
|
|
12811
|
+
* the `alreadyConvergent` set below could not tell the members apart.
|
|
12812
|
+
*/
|
|
12813
|
+
function conclusionId(f) {
|
|
12814
|
+
return f.findingId ?? findingFingerprint(f);
|
|
12815
|
+
}
|
|
12816
|
+
/**
|
|
12303
12817
|
* Correlate findings and produce conclusions WITHOUT modifying them.
|
|
12304
12818
|
* Deterministic — same input always produces same output.
|
|
12305
12819
|
*/
|
|
@@ -12308,7 +12822,7 @@ function correlateFindings(findings) {
|
|
|
12308
12822
|
const rootCauseGroups = groupByRootCause(findings);
|
|
12309
12823
|
for (const [rootCause, group] of Array.from(rootCauseGroups.entries()).sort((a, b) => compareLocalized("en")(a[0], b[0]))) {
|
|
12310
12824
|
if (group.length < 2) continue;
|
|
12311
|
-
const findingIds = group.map(
|
|
12825
|
+
const findingIds = group.map(conclusionId);
|
|
12312
12826
|
conclusions.push({
|
|
12313
12827
|
conclusionType: "CONVERGENT",
|
|
12314
12828
|
certainty: correlationStrength(group),
|
|
@@ -12322,9 +12836,9 @@ function correlateFindings(findings) {
|
|
|
12322
12836
|
if (group.length < 2) continue;
|
|
12323
12837
|
const alreadyConvergent = /* @__PURE__ */ new Set();
|
|
12324
12838
|
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
|
|
12839
|
+
const novel = group.filter((f) => !alreadyConvergent.has(conclusionId(f)));
|
|
12326
12840
|
if (novel.length < 2) continue;
|
|
12327
|
-
const findingIds = novel.map(
|
|
12841
|
+
const findingIds = novel.map(conclusionId);
|
|
12328
12842
|
conclusions.push({
|
|
12329
12843
|
conclusionType: "AMPLIFIED",
|
|
12330
12844
|
certainty: correlationStrength(novel),
|
|
@@ -12339,7 +12853,7 @@ function correlateFindings(findings) {
|
|
|
12339
12853
|
certainty: "STRONG",
|
|
12340
12854
|
corroboration: `${corroborated.length} finding(s) with runtime corroboration`,
|
|
12341
12855
|
sourceCount: corroborated.length,
|
|
12342
|
-
findingIds: corroborated.map(
|
|
12856
|
+
findingIds: corroborated.map(conclusionId)
|
|
12343
12857
|
});
|
|
12344
12858
|
return conclusions;
|
|
12345
12859
|
}
|
|
@@ -12581,7 +13095,25 @@ const BRAND = {
|
|
|
12581
13095
|
auroraGreen: "#5FD6A4",
|
|
12582
13096
|
auroraViolet: "#9D8CF5",
|
|
12583
13097
|
steel: "#C8CBCF",
|
|
12584
|
-
steelDim: "#8B939D"
|
|
13098
|
+
steelDim: "#8B939D",
|
|
13099
|
+
/** The teal half of the accent rule, and the gradient's near stop. */
|
|
13100
|
+
readingAccent: "#73C4CA",
|
|
13101
|
+
/** The violet half of the accent rule, and the gradient's far stop. */
|
|
13102
|
+
readingAccentAlt: "#A596E5",
|
|
13103
|
+
/** The focus ring. A non-text affordance, held to the same AA floor. */
|
|
13104
|
+
readingFocus: "#8CE3ED",
|
|
13105
|
+
/** Hover state for the mark's monochrome glyph. */
|
|
13106
|
+
readingAccentBright: "#85DBED",
|
|
13107
|
+
/** Hover state for the wordmark's ink. */
|
|
13108
|
+
readingAccentInk: "#E5FCFF",
|
|
13109
|
+
/** UNWORTHY at reading contrast — a softer red than the terminal's. */
|
|
13110
|
+
readingUnworthy: "#FF9790",
|
|
13111
|
+
/** Muted body ink on the reading surfaces. */
|
|
13112
|
+
readingMuted: "#C6CFDB",
|
|
13113
|
+
/** Dimmed ink on the reading surfaces. */
|
|
13114
|
+
readingDim: "#ACB9CA",
|
|
13115
|
+
/** The hero lede: the brightest ink below `TEXT.primary`. */
|
|
13116
|
+
readingLede: "#E0E6EE"
|
|
12585
13117
|
};
|
|
12586
13118
|
/**
|
|
12587
13119
|
* Midnight iron. One ramp, four steps, darkest first.
|
|
@@ -12684,7 +13216,7 @@ const TRUST = {
|
|
|
12684
13216
|
l5: "#5CC4E0"
|
|
12685
13217
|
};
|
|
12686
13218
|
SURFACE.ink950, SURFACE.ink900, SURFACE.ink850, SURFACE.ink800, BRAND.steel, BRAND.steelDim, BRAND.gold, BRAND.goldBright, BRAND.goldHot, BRAND.aurora, BRAND.auroraBright, BRAND.auroraCyan, BRAND.auroraGreen, BRAND.auroraViolet;
|
|
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;
|
|
13219
|
+
SCORE.trusted, SCORE.forged, SCORE.warning, SCORE.critical, STATUS.info, TEXT.onGold, STATUS.ok, EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2, TRUST.l0, TRUST.l1, TRUST.l2, TRUST.l3, TRUST.l4, TRUST.l5, BRAND.readingAccent, BRAND.readingAccentAlt, BRAND.readingFocus, BRAND.readingAccentBright, BRAND.readingAccentInk, BRAND.readingUnworthy, BRAND.readingMuted, BRAND.readingDim, BRAND.readingLede;
|
|
12688
13220
|
EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2;
|
|
12689
13221
|
const RUNG_MEANINGS = [
|
|
12690
13222
|
"observation only",
|
|
@@ -13574,7 +14106,7 @@ function renderFlakyMd(report) {
|
|
|
13574
14106
|
const lines = [];
|
|
13575
14107
|
lines.push("# FLAKY.md");
|
|
13576
14108
|
lines.push("");
|
|
13577
|
-
lines.push(`> Generated by
|
|
14109
|
+
lines.push(`> Generated by Mjölnir from ${report.source} run data.`);
|
|
13578
14110
|
lines.push("> A test that passes only on retry is not a passing test — it is a lucky test.");
|
|
13579
14111
|
lines.push("");
|
|
13580
14112
|
lines.push(`**${report.totalTests}** tests analyzed · **${report.flakyTests}** true flakes · **${report.retriedTests}** retried · **${report.failed}** failing`);
|
|
@@ -13922,19 +14454,19 @@ function parseJestJson(json) {
|
|
|
13922
14454
|
}
|
|
13923
14455
|
//#endregion
|
|
13924
14456
|
//#region src/forensics/parse-junit.ts
|
|
13925
|
-
const MAX_INPUT = 20971520;
|
|
13926
|
-
function decodeEntities(s) {
|
|
14457
|
+
const MAX_INPUT$1 = 20971520;
|
|
14458
|
+
function decodeEntities$1(s) {
|
|
13927
14459
|
return s.replaceAll("<", "<").replaceAll(">", ">").replaceAll(""", "\"").replaceAll("'", "'").replaceAll("'", "'").replaceAll("&", "&");
|
|
13928
14460
|
}
|
|
13929
|
-
function attr(tag, name) {
|
|
14461
|
+
function attr$1(tag, name) {
|
|
13930
14462
|
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
13931
14463
|
const m = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*"([^"]*)"`, "i").exec(tag);
|
|
13932
|
-
if (m?.[1] !== void 0) return decodeEntities(m[1]);
|
|
14464
|
+
if (m?.[1] !== void 0) return decodeEntities$1(m[1]);
|
|
13933
14465
|
const m2 = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*'([^']*)'`, "i").exec(tag);
|
|
13934
|
-
return m2?.[1] !== void 0 ? decodeEntities(m2[1]) : void 0;
|
|
14466
|
+
return m2?.[1] !== void 0 ? decodeEntities$1(m2[1]) : void 0;
|
|
13935
14467
|
}
|
|
13936
14468
|
function parseJunitXml(xml) {
|
|
13937
|
-
if (xml.length > MAX_INPUT) return [];
|
|
14469
|
+
if (xml.length > MAX_INPUT$1) return [];
|
|
13938
14470
|
const out = [];
|
|
13939
14471
|
const startRe = /<testcase\b/gi;
|
|
13940
14472
|
const closeRe = /<\/testcase\s*>/gi;
|
|
@@ -13955,9 +14487,9 @@ function parseJunitXml(xml) {
|
|
|
13955
14487
|
inner = xml.slice(gt + 1, close.index);
|
|
13956
14488
|
startRe.lastIndex = close.index + close[0].length;
|
|
13957
14489
|
}
|
|
13958
|
-
const name = attr(attrs, "name") ?? "(unnamed)";
|
|
13959
|
-
const classname = attr(attrs, "classname") ?? "";
|
|
13960
|
-
const timeRaw = attr(attrs, "time");
|
|
14490
|
+
const name = attr$1(attrs, "name") ?? "(unnamed)";
|
|
14491
|
+
const classname = attr$1(attrs, "classname") ?? "";
|
|
14492
|
+
const timeRaw = attr$1(attrs, "time");
|
|
13961
14493
|
const timeSec = timeRaw !== void 0 && Number.isFinite(Number(timeRaw)) ? Number(timeRaw) : 0;
|
|
13962
14494
|
const failed = /<(?:failure|error)\b/i.test(inner);
|
|
13963
14495
|
const skipped = /<skipped\b/i.test(inner);
|
|
@@ -13968,16 +14500,16 @@ function parseJunitXml(xml) {
|
|
|
13968
14500
|
while ((failMatch = failRe.exec(inner)) !== null) {
|
|
13969
14501
|
const attrsStr = failMatch[1] ?? "";
|
|
13970
14502
|
const msgAttr = /\smessage\s*=\s*"([^"]*)"/i.exec(attrsStr)?.[1];
|
|
13971
|
-
if (msgAttr) errors.push(sanitizeErrorText(decodeEntities(msgAttr)));
|
|
14503
|
+
if (msgAttr) errors.push(sanitizeErrorText(decodeEntities$1(msgAttr)));
|
|
13972
14504
|
const body = failMatch[2]?.trim();
|
|
13973
|
-
if (body && body !== msgAttr) errors.push(sanitizeErrorText(decodeEntities(body)));
|
|
14505
|
+
if (body && body !== msgAttr) errors.push(sanitizeErrorText(decodeEntities$1(body)));
|
|
13974
14506
|
}
|
|
13975
14507
|
const selfCloseRe = /<(?:failure|error)\b([^>]*)\/>/gi;
|
|
13976
14508
|
let scMatch;
|
|
13977
14509
|
while ((scMatch = selfCloseRe.exec(inner)) !== null) {
|
|
13978
14510
|
const scAttrs = scMatch[1] ?? "";
|
|
13979
14511
|
const scMsgMatch = /\smessage\s*=\s*"([^"]*)"/i.exec(scAttrs);
|
|
13980
|
-
if (scMsgMatch?.[1]) errors.push(sanitizeErrorText(decodeEntities(scMsgMatch[1])));
|
|
14512
|
+
if (scMsgMatch?.[1]) errors.push(sanitizeErrorText(decodeEntities$1(scMsgMatch[1])));
|
|
13981
14513
|
}
|
|
13982
14514
|
}
|
|
13983
14515
|
const attempt = {
|
|
@@ -13995,6 +14527,142 @@ function parseJunitXml(xml) {
|
|
|
13995
14527
|
return out;
|
|
13996
14528
|
}
|
|
13997
14529
|
//#endregion
|
|
14530
|
+
//#region src/forensics/parse-trx.ts
|
|
14531
|
+
const MAX_INPUT = 20971520;
|
|
14532
|
+
function decodeEntities(s) {
|
|
14533
|
+
return s.replaceAll("<", "<").replaceAll(">", ">").replaceAll(""", "\"").replaceAll("'", "'").replaceAll("'", "'").replaceAll("&", "&");
|
|
14534
|
+
}
|
|
14535
|
+
function attr(tag, name) {
|
|
14536
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
14537
|
+
const m = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*"([^"]*)"`, "i").exec(tag);
|
|
14538
|
+
if (m?.[1] !== void 0) return decodeEntities(m[1]);
|
|
14539
|
+
const sq = new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*'([^']*)'`, "i").exec(tag);
|
|
14540
|
+
if (sq?.[1] !== void 0) return decodeEntities(sq[1]);
|
|
14541
|
+
}
|
|
14542
|
+
/**
|
|
14543
|
+
* Does this document look like TRX at all?
|
|
14544
|
+
*
|
|
14545
|
+
* Shape-sniffed rather than trusted from its location, for the same reason the
|
|
14546
|
+
* JUnit path sniffs: a `TestResults/` directory is a convention, and a
|
|
14547
|
+
* convention can be wrong. A file that is not a `TestRun` must not be parsed as
|
|
14548
|
+
* one and produce confident nonsense.
|
|
14549
|
+
*/
|
|
14550
|
+
function looksLikeTrxXml(text) {
|
|
14551
|
+
return /<TestRun[\s>]/i.test(text.slice(0, 4096));
|
|
14552
|
+
}
|
|
14553
|
+
/**
|
|
14554
|
+
* TRX durations are `HH:MM:SS.fffffff`; absent or `-` means "not timed".
|
|
14555
|
+
*
|
|
14556
|
+
* Parsed by splitting rather than by one pattern. A single regex for this has
|
|
14557
|
+
* to be `^(\d+):(\d{2}):(\d{2})(?:\.(\d+))?$`, and the nested quantifiers there
|
|
14558
|
+
* are what `security/detect-unsafe-regex` flags — correctly in general, since
|
|
14559
|
+
* this shape is the usual ReDoS source. Splitting removes the question rather
|
|
14560
|
+
* than asserting the pattern is safe today.
|
|
14561
|
+
*/
|
|
14562
|
+
function parseDuration(raw) {
|
|
14563
|
+
if (raw === void 0) return 0;
|
|
14564
|
+
const text = raw.trim();
|
|
14565
|
+
const dot = text.indexOf(".");
|
|
14566
|
+
const clock = dot === -1 ? text : text.slice(0, dot);
|
|
14567
|
+
const fraction = dot === -1 ? "" : text.slice(dot + 1);
|
|
14568
|
+
const parts = clock.split(":");
|
|
14569
|
+
if (parts.length !== 3) return 0;
|
|
14570
|
+
const [h, m, s] = parts;
|
|
14571
|
+
if (h === void 0 || m === void 0 || s === void 0) return 0;
|
|
14572
|
+
if (!/^\d+$/.test(h) || !/^\d{1,2}$/.test(m) || !/^\d{1,2}$/.test(s)) return 0;
|
|
14573
|
+
const millis = fraction.length === 0 ? 0 : Number(fraction.slice(0, 3).padEnd(3, "0"));
|
|
14574
|
+
return Number(h) * 36e5 + Number(m) * 6e4 + Number(s) * 1e3 + millis;
|
|
14575
|
+
}
|
|
14576
|
+
/**
|
|
14577
|
+
* TRX `outcome` to a run status.
|
|
14578
|
+
*
|
|
14579
|
+
* The mapping is stated rather than inferred from a default, because the
|
|
14580
|
+
* dangerous direction here is a silent default: an outcome this engine has
|
|
14581
|
+
* never seen must not read as `passed`. Every unrecognised value maps to
|
|
14582
|
+
* `skipped`, which is the non-claiming direction — a skipped test contributes
|
|
14583
|
+
* nothing to "the suite passed" — rather than to a status that would let it.
|
|
14584
|
+
*
|
|
14585
|
+
* TRX also has outcomes that are not verdicts at all (`Warning`, `Pending`,
|
|
14586
|
+
* `Expired`). They are treated the same way, deliberately.
|
|
14587
|
+
*/
|
|
14588
|
+
function mapTrxOutcome(outcome) {
|
|
14589
|
+
switch ((outcome ?? "").toLowerCase()) {
|
|
14590
|
+
case "passed":
|
|
14591
|
+
case "completed": return "passed";
|
|
14592
|
+
case "failed":
|
|
14593
|
+
case "error": return "failed";
|
|
14594
|
+
case "timeout": return "timedOut";
|
|
14595
|
+
case "aborted":
|
|
14596
|
+
case "interrupted": return "interrupted";
|
|
14597
|
+
case "notexecuted":
|
|
14598
|
+
case "skipped":
|
|
14599
|
+
case "inconclusive":
|
|
14600
|
+
case "notrunnable":
|
|
14601
|
+
case "pending": return "skipped";
|
|
14602
|
+
default: return "skipped";
|
|
14603
|
+
}
|
|
14604
|
+
}
|
|
14605
|
+
function tagOf(xml, name) {
|
|
14606
|
+
const out = [];
|
|
14607
|
+
const re = new RegExp(`<${name}\\b[^>]*>`, "gi");
|
|
14608
|
+
let m;
|
|
14609
|
+
while ((m = re.exec(xml)) !== null) {
|
|
14610
|
+
if (m[0] !== void 0) out.push(m[0]);
|
|
14611
|
+
if (out.length > 2e5) break;
|
|
14612
|
+
}
|
|
14613
|
+
return out;
|
|
14614
|
+
}
|
|
14615
|
+
/** The text of a `<Message>` child, used as the failure's error text. */
|
|
14616
|
+
function messageOf(text, near) {
|
|
14617
|
+
const window = text.slice(near, near + 2e3);
|
|
14618
|
+
const m = /<Message[^>]*>([\s\S]{0,1500}?)<\/Message>/i.exec(window);
|
|
14619
|
+
if (m?.[1] === void 0) return void 0;
|
|
14620
|
+
const cleaned = sanitizeErrorText(decodeEntities(m[1].replace(/<[^>]*>/g, " ")).trim());
|
|
14621
|
+
return cleaned.length === 0 ? void 0 : cleaned;
|
|
14622
|
+
}
|
|
14623
|
+
/**
|
|
14624
|
+
* Parse a TRX document into test records.
|
|
14625
|
+
*
|
|
14626
|
+
* Returns an empty array for a document that is not TRX rather than throwing:
|
|
14627
|
+
* discovery sniffs before parsing, and a caller that hands us a non-TRX file
|
|
14628
|
+
* should get "no evidence here", not an exception that aborts a whole run
|
|
14629
|
+
* report.
|
|
14630
|
+
*/
|
|
14631
|
+
function parseTrxXml(xml) {
|
|
14632
|
+
if (xml.length > MAX_INPUT) return [];
|
|
14633
|
+
if (!looksLikeTrxXml(xml)) return [];
|
|
14634
|
+
const byTestId = /* @__PURE__ */ new Map();
|
|
14635
|
+
for (const tag of tagOf(xml, "UnitTest")) {
|
|
14636
|
+
const id = attr(tag, "id");
|
|
14637
|
+
const name = attr(tag, "name");
|
|
14638
|
+
if (id !== void 0 && name !== void 0) byTestId.set(id, name);
|
|
14639
|
+
}
|
|
14640
|
+
const records = [];
|
|
14641
|
+
const resultRe = /<UnitTestResult\b([^>]*)>([\s\S]{0,4000}?)(?=<UnitTestResult\b|<\/Results>)/gi;
|
|
14642
|
+
let m;
|
|
14643
|
+
while ((m = resultRe.exec(xml)) !== null) {
|
|
14644
|
+
const attrs = m[1] ?? "";
|
|
14645
|
+
const inner = m[2] ?? "";
|
|
14646
|
+
const testName = attr(attrs, "testName") ?? attr(attrs, "testId");
|
|
14647
|
+
if (testName === void 0) continue;
|
|
14648
|
+
const attempt = {
|
|
14649
|
+
index: 1,
|
|
14650
|
+
status: mapTrxOutcome(attr(attrs, "outcome")),
|
|
14651
|
+
durationMs: parseDuration(attr(attrs, "duration"))
|
|
14652
|
+
};
|
|
14653
|
+
const error = messageOf(inner, 0);
|
|
14654
|
+
records.push({
|
|
14655
|
+
file: "unknown",
|
|
14656
|
+
title: testName,
|
|
14657
|
+
attempts: [attempt],
|
|
14658
|
+
evidenceKind: "test",
|
|
14659
|
+
...error === void 0 ? {} : { errors: [error] }
|
|
14660
|
+
});
|
|
14661
|
+
if (records.length > 2e5) break;
|
|
14662
|
+
}
|
|
14663
|
+
return records;
|
|
14664
|
+
}
|
|
14665
|
+
//#endregion
|
|
13998
14666
|
//#region src/forensics/parse-playwright-json.ts
|
|
13999
14667
|
const MAX_DEPTH = 32;
|
|
14000
14668
|
function toStatus$1(raw) {
|
|
@@ -14204,7 +14872,9 @@ function parseHarJsonDetailed(json) {
|
|
|
14204
14872
|
//#endregion
|
|
14205
14873
|
//#region src/forensics/run.ts
|
|
14206
14874
|
/**
|
|
14207
|
-
*
|
|
14875
|
+
* Runtime evidence entry point (R4) — the library behind `explain
|
|
14876
|
+
* --evidence`. The `forensics` verb is retired in the v6 carve; this module
|
|
14877
|
+
* is what reads the run data.
|
|
14208
14878
|
*
|
|
14209
14879
|
* Accepts either a single report file or a directory. In a directory it
|
|
14210
14880
|
* looks for, in priority order:
|
|
@@ -14404,6 +15074,13 @@ function traceArtifactName(fullPath) {
|
|
|
14404
15074
|
return base;
|
|
14405
15075
|
}
|
|
14406
15076
|
function parseFile(path, text) {
|
|
15077
|
+
if (/\.trx$/i.test(path) || looksLikeTrxXml(text)) {
|
|
15078
|
+
const records = parseTrxXml(text);
|
|
15079
|
+
if (records.length > 0) return {
|
|
15080
|
+
records,
|
|
15081
|
+
source: "dotnet-trx"
|
|
15082
|
+
};
|
|
15083
|
+
}
|
|
14407
15084
|
if (/\.xml$/i.test(path) || /^\s*<\?xml|<testsuite\b/i.test(text.slice(0, 200))) return {
|
|
14408
15085
|
records: parseJunitXml(text),
|
|
14409
15086
|
source: "junit-xml"
|
|
@@ -14655,7 +15332,7 @@ function loadPlugins(root, gateOpen = false) {
|
|
|
14655
15332
|
* - Load failures degrade honestly: warning entries, never a crash;
|
|
14656
15333
|
* exit codes stay frozen.
|
|
14657
15334
|
*
|
|
14658
|
-
* Drift-check: the `mjolnir
|
|
15335
|
+
* Drift-check: the `mjolnir explain --list --md` catalog is generated from the
|
|
14659
15336
|
* LOADED rules (core + external), so the catalog can never drift from
|
|
14660
15337
|
* what actually ships — an edit to a local rule file changes the very
|
|
14661
15338
|
* next catalog render (locked by tests/local-rules.spec.ts).
|
|
@@ -15001,7 +15678,7 @@ const KNOWN_RULE_IDS = new Set(RULES.map((r) => r.id));
|
|
|
15001
15678
|
const OVERLAP_META_BY_RULE_ID = new Map(RULES.map((r, order) => {
|
|
15002
15679
|
const meta = {
|
|
15003
15680
|
...r.overlapWith ? { overlapWith: r.overlapWith } : {},
|
|
15004
|
-
|
|
15681
|
+
tier: effectiveTier(r),
|
|
15005
15682
|
order
|
|
15006
15683
|
};
|
|
15007
15684
|
return [r.id, meta];
|
|
@@ -15029,11 +15706,14 @@ async function buildUniversalRules(root, strict, opts = {}) {
|
|
|
15029
15706
|
rules: local.rules
|
|
15030
15707
|
}].flatMap((p) => p.rules.map(asUniversal));
|
|
15031
15708
|
const tierByRuleId = /* @__PURE__ */ new Map();
|
|
15032
|
-
for (const r of RULES)
|
|
15709
|
+
for (const r of RULES) tierByRuleId.set(r.id, effectiveTier(r));
|
|
15033
15710
|
for (const p of plugins) for (const r of p.rules) if (r.tier) tierByRuleId.set(r.id, r.tier);
|
|
15034
15711
|
for (const r of local.rules) if (r.tier) tierByRuleId.set(r.id, r.tier);
|
|
15035
15712
|
let rules = [...UNIVERSAL_RULES, ...externalRules];
|
|
15713
|
+
const withheldRuleIds = rules.filter((r) => tierByRuleId.get(r.id) === "quarantine").map((r) => r.id).sort();
|
|
15036
15714
|
if (!strict) rules = rules.filter((r) => tierByRuleId.get(r.id) !== "quarantine");
|
|
15715
|
+
const rulesWithheld = strict ? 0 : withheldRuleIds.length;
|
|
15716
|
+
const rulesApplied = rules.length;
|
|
15037
15717
|
const pluginMeta = [...plugins.map((p) => ({
|
|
15038
15718
|
name: p.name,
|
|
15039
15719
|
rules: p.rules.length
|
|
@@ -15046,7 +15726,10 @@ async function buildUniversalRules(root, strict, opts = {}) {
|
|
|
15046
15726
|
pluginErrors: allErrors,
|
|
15047
15727
|
tierByRuleId,
|
|
15048
15728
|
pluginMeta,
|
|
15049
|
-
externalRules: local.rules
|
|
15729
|
+
externalRules: local.rules,
|
|
15730
|
+
rulesApplied,
|
|
15731
|
+
rulesWithheld,
|
|
15732
|
+
withheldRuleIds
|
|
15050
15733
|
};
|
|
15051
15734
|
}
|
|
15052
15735
|
/** Rule-declared evidence-level overrides (Honesty Core). */
|
|
@@ -15071,35 +15754,6 @@ function fallbackWorkspace(targetAbs) {
|
|
|
15071
15754
|
};
|
|
15072
15755
|
}
|
|
15073
15756
|
/**
|
|
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
15757
|
* Plan §16 + WI-11: locate a runtime run report next to the scan
|
|
15104
15758
|
* target, using the exact conventions the forensics ingestion already
|
|
15105
15759
|
* accepts. Zero-config search over conventional artifact names at
|
|
@@ -15604,6 +16258,9 @@ function assembleScanResult(o) {
|
|
|
15604
16258
|
parseFallbacks: o.parseFallbacks ?? 0,
|
|
15605
16259
|
...o.astFallbackFiles !== void 0 ? { astFallbackFiles: o.astFallbackFiles } : {},
|
|
15606
16260
|
...o.degradations !== void 0 ? { degradations: o.degradations } : {},
|
|
16261
|
+
...o.rulesWithheld !== void 0 ? { rulesWithheld: o.rulesWithheld } : {},
|
|
16262
|
+
...o.withheldRuleIds !== void 0 ? { withheldRuleIds: o.withheldRuleIds } : {},
|
|
16263
|
+
...o.rulesApplied !== void 0 ? { rulesApplied: o.rulesApplied } : {},
|
|
15607
16264
|
...o.scopeInfo.degraded !== void 0 ? { scopeDegraded: o.scopeInfo.degraded } : {},
|
|
15608
16265
|
...o.runtimeIncomplete !== void 0 ? { runtimeIncomplete: o.runtimeIncomplete } : {},
|
|
15609
16266
|
identityIncomplete
|
|
@@ -15618,11 +16275,15 @@ function assembleScanResult(o) {
|
|
|
15618
16275
|
})),
|
|
15619
16276
|
config: o.config ?? null,
|
|
15620
16277
|
engineVersion: ENGINE_VERSION,
|
|
16278
|
+
runMode: resolveRunMode({
|
|
16279
|
+
env: {},
|
|
16280
|
+
flags: {}
|
|
16281
|
+
}),
|
|
15621
16282
|
reportDigest,
|
|
15622
16283
|
trustModelVersion: TRUST_MODEL_VERSION,
|
|
15623
16284
|
scoringModelVersion: SCORING_MODEL_VERSION,
|
|
15624
16285
|
frameworkSupportMatrixVersion: FRAMEWORK_SUPPORT_MATRIX_VERSION,
|
|
15625
|
-
evidenceSchemaVersions: [
|
|
16286
|
+
evidenceSchemaVersions: [2],
|
|
15626
16287
|
...repository,
|
|
15627
16288
|
...candidate !== null ? { candidate } : {}
|
|
15628
16289
|
});
|
|
@@ -15635,7 +16296,10 @@ function assembleScanResult(o) {
|
|
|
15635
16296
|
});
|
|
15636
16297
|
const hasTests = o.testFileCount > 0 && o.testDeclarationCount > 0;
|
|
15637
16298
|
const suiteInvalidatedBy = [...new Set(o.findings.filter((f) => SUITE_INVALIDATING_RULE_IDS.has(f.ruleId)).map((f) => f.ruleId))].sort();
|
|
15638
|
-
const
|
|
16299
|
+
const scopeClamped = scopeReasons.length > 0 && total >= 100;
|
|
16300
|
+
const partialClamped = completion.partial && scopeAdjustedTotal >= 100;
|
|
16301
|
+
const scoreClampReason = scopeClamped ? "scope-degraded" : partialClamped ? "partial-scan" : null;
|
|
16302
|
+
const finalScore = hasTests ? partialClamped ? 99 : scopeAdjustedTotal : null;
|
|
15639
16303
|
const result = {
|
|
15640
16304
|
schemaVersion: 1,
|
|
15641
16305
|
partial: completion.partial,
|
|
@@ -15674,9 +16338,11 @@ function assembleScanResult(o) {
|
|
|
15674
16338
|
} } : {},
|
|
15675
16339
|
analysisStatus: {
|
|
15676
16340
|
...completion.analysisStatus,
|
|
16341
|
+
coverageState: completion.coverageState,
|
|
15677
16342
|
durationMs: elapsed
|
|
15678
16343
|
},
|
|
15679
|
-
scoringModelVersion: SCORING_MODEL_VERSION
|
|
16344
|
+
scoringModelVersion: SCORING_MODEL_VERSION,
|
|
16345
|
+
...scoreClampReason !== null ? { scoreClampReason } : {}
|
|
15680
16346
|
};
|
|
15681
16347
|
result.trustSummary = buildTrustSummary(result, o.declarationsByFile);
|
|
15682
16348
|
if (o.findings.length > 0) result.correlationConclusions = correlateFindings(o.findings);
|
|
@@ -15720,7 +16386,23 @@ function assembleScanResult(o) {
|
|
|
15720
16386
|
* inherently async); `runRules` and every rule stay synchronous and
|
|
15721
16387
|
* consume `ParsedFile.ast`. Callers await the returned promise.
|
|
15722
16388
|
*/
|
|
16389
|
+
/**
|
|
16390
|
+
* Run one scan inside its own ts-morph Project scope.
|
|
16391
|
+
*
|
|
16392
|
+
* The wrapper is the whole fix. ts-morph's Project caches SourceFiles by path
|
|
16393
|
+
* and `parseTsFile` calls `replaceWithText` on them, so a Project shared between
|
|
16394
|
+
* two concurrent scans lets one rewrite the file the other is mid-rule on —
|
|
16395
|
+
* which surfaced as three of four concurrent scans reporting
|
|
16396
|
+
* `rulesCrashed: 4` and `rules: "partial"`, and therefore as two different
|
|
16397
|
+
* machine-contract digests from one tree. See `src/engine/ts-ast.ts`.
|
|
16398
|
+
*
|
|
16399
|
+
* Async context propagates across `await`, so the scope covers the whole scan
|
|
16400
|
+
* without threading a Project through every adapter.
|
|
16401
|
+
*/
|
|
15723
16402
|
async function runScan(args, hooks = {}) {
|
|
16403
|
+
return runWithScanProject(() => runScanInProject(args, hooks));
|
|
16404
|
+
}
|
|
16405
|
+
async function runScanInProject(args, hooks = {}) {
|
|
15724
16406
|
const started = Date.now();
|
|
15725
16407
|
const degradationWindow = beginDegradationWindow();
|
|
15726
16408
|
const requestedDuration = Number.isFinite(args.maxDurationMs) ? args.maxDurationMs : DEFAULT_MAX_DURATION_MS$1;
|
|
@@ -15747,7 +16429,7 @@ async function runScan(args, hooks = {}) {
|
|
|
15747
16429
|
let scopeUnrecognized = 0;
|
|
15748
16430
|
let parseFailed = 0;
|
|
15749
16431
|
const fileProvenance = [];
|
|
15750
|
-
const { rules: activeRules, pluginErrors, tierByRuleId: tiers, pluginMeta } = await buildUniversalRules(workspace.root, args.strict, {
|
|
16432
|
+
const { rules: activeRules, pluginErrors, tierByRuleId: tiers, pluginMeta, rulesApplied, rulesWithheld, withheldRuleIds } = await buildUniversalRules(workspace.root, args.strict, {
|
|
15751
16433
|
...args.enablePlugins !== void 0 ? { enablePlugins: args.enablePlugins } : {},
|
|
15752
16434
|
onGateNotice: (notice) => hooks.onGateNotice ? hooks.onGateNotice(notice) : console.error(notice)
|
|
15753
16435
|
});
|
|
@@ -15836,6 +16518,9 @@ async function runScan(args, hooks = {}) {
|
|
|
15836
16518
|
parseFallbacks,
|
|
15837
16519
|
astFallbackFiles,
|
|
15838
16520
|
degradations: summarizeDegradations(degradationsSince(degradationWindow)),
|
|
16521
|
+
rulesApplied,
|
|
16522
|
+
rulesWithheld,
|
|
16523
|
+
...withheldRuleIds.length > 0 ? { withheldRuleIds } : {},
|
|
15839
16524
|
scanned,
|
|
15840
16525
|
analyzed,
|
|
15841
16526
|
testFiles,
|
|
@@ -15940,6 +16625,10 @@ function canonicalScanJson(result) {
|
|
|
15940
16625
|
skippedFiles: result.analysisStatus.skippedFiles,
|
|
15941
16626
|
rulesCrashed: result.analysisStatus.rulesCrashed ?? 0,
|
|
15942
16627
|
parseFallbacks: result.analysisStatus.parseFallbacks ?? 0,
|
|
16628
|
+
coverageState: result.analysisStatus.coverageState ?? "COMPLETE",
|
|
16629
|
+
rulesApplied: result.analysisStatus.rulesApplied ?? null,
|
|
16630
|
+
rulesWithheld: result.analysisStatus.rulesWithheld ?? 0,
|
|
16631
|
+
degradations: result.analysisStatus.degradations ?? [],
|
|
15943
16632
|
truncationReasons: result.analysisStatus.truncationReasons ?? [],
|
|
15944
16633
|
reasons: result.analysisStatus.reasons ?? []
|
|
15945
16634
|
},
|
|
@@ -15983,7 +16672,7 @@ function buildMachineContract(result) {
|
|
|
15983
16672
|
};
|
|
15984
16673
|
});
|
|
15985
16674
|
return {
|
|
15986
|
-
contractVersion:
|
|
16675
|
+
contractVersion: 2,
|
|
15987
16676
|
summary: {
|
|
15988
16677
|
digest: `sha256:${digest}`,
|
|
15989
16678
|
findings: result.findings.length,
|
|
@@ -16004,6 +16693,10 @@ function buildMachineContract(result) {
|
|
|
16004
16693
|
...result.analysisStatus.parseFallbacks !== void 0 ? { parseFallbacks: result.analysisStatus.parseFallbacks } : {},
|
|
16005
16694
|
truncationReasons: result.analysisStatus.truncationReasons ?? [],
|
|
16006
16695
|
...result.analysisStatus.reasons !== void 0 ? { reasons: result.analysisStatus.reasons } : {},
|
|
16696
|
+
...result.analysisStatus.degradations !== void 0 ? { degradations: result.analysisStatus.degradations } : {},
|
|
16697
|
+
...result.analysisStatus.coverageState !== void 0 ? { coverageState: result.analysisStatus.coverageState } : {},
|
|
16698
|
+
...result.analysisStatus.rulesApplied !== void 0 ? { rulesApplied: result.analysisStatus.rulesApplied } : {},
|
|
16699
|
+
...result.analysisStatus.rulesWithheld !== void 0 ? { rulesWithheld: result.analysisStatus.rulesWithheld } : {},
|
|
16007
16700
|
frameworkDetectionUnknown: result.frameworkDetectionUnknown,
|
|
16008
16701
|
durationMs: result.analysisStatus.durationMs,
|
|
16009
16702
|
...result.scopeIntegrity !== void 0 ? { scopeIntegrity: result.scopeIntegrity } : {},
|
|
@@ -16045,7 +16738,7 @@ function explainRule(ruleId, fixturesRoot) {
|
|
|
16045
16738
|
const rule = getRule(ruleId);
|
|
16046
16739
|
if (!rule) return {
|
|
16047
16740
|
ok: false,
|
|
16048
|
-
error: `Unknown rule ID "${ruleId}". Run \`mjolnir
|
|
16741
|
+
error: `Unknown rule ID "${ruleId}". Run \`mjolnir explain --list\` for the full catalog.`
|
|
16049
16742
|
};
|
|
16050
16743
|
const window = beginDegradationWindow();
|
|
16051
16744
|
const degraded = () => {
|
|
@@ -16117,16 +16810,6 @@ function explainRule(ruleId, fixturesRoot) {
|
|
|
16117
16810
|
};
|
|
16118
16811
|
}
|
|
16119
16812
|
//#endregion
|
|
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
16813
|
//#region src/engine/resolution.ts
|
|
16131
16814
|
/** Correlation identity (v1-compatible): ruleId\0file\0message. */
|
|
16132
16815
|
const fingerprint$1 = findingFingerprint;
|
|
@@ -16147,6 +16830,11 @@ function resolve$1(input) {
|
|
|
16147
16830
|
cause: "crash",
|
|
16148
16831
|
comparedAgainst
|
|
16149
16832
|
};
|
|
16833
|
+
if (current.analysisStatus.withheldRuleIds?.includes(entry.ruleId) === true) return {
|
|
16834
|
+
status: "INCONCLUSIVE",
|
|
16835
|
+
cause: "rule-withheld",
|
|
16836
|
+
comparedAgainst
|
|
16837
|
+
};
|
|
16150
16838
|
if (input.skippedFiles?.has(entry.file)) return {
|
|
16151
16839
|
status: "INCONCLUSIVE",
|
|
16152
16840
|
cause: "skipped",
|
|
@@ -16276,13 +16964,13 @@ function diffAgainstBaseline(result, baseline) {
|
|
|
16276
16964
|
//#endregion
|
|
16277
16965
|
//#region src/commands/verify.ts
|
|
16278
16966
|
/**
|
|
16279
|
-
* `mjolnir verify` — the agent-loop digest (product-gap-remediation
|
|
16967
|
+
* `mjolnir ci verify` — the agent-loop digest (product-gap-remediation
|
|
16280
16968
|
* master plan P7, plan 1788853205786 — flag agent, decision 6).
|
|
16281
16969
|
*
|
|
16282
|
-
* An agent fixes findings, then runs `mjolnir verify` against the
|
|
16283
|
-
* committed baseline (established once
|
|
16970
|
+
* An agent fixes findings, then runs `mjolnir ci verify` against the
|
|
16971
|
+
* committed baseline (established once from a `mjolnir scan --json` run). The
|
|
16284
16972
|
* digest answers exactly the loop's questions, derived from the SAME
|
|
16285
|
-
* §15 comparison machinery as `mjolnir
|
|
16973
|
+
* §15 comparison machinery as `mjolnir scan --scope changed` (no second truth):
|
|
16286
16974
|
*
|
|
16287
16975
|
* - RESOLVED — baseline findings no longer present, each with its
|
|
16288
16976
|
* lifecycle resolution (only VERIFIED-RESOLVED is a fix claim);
|
|
@@ -16558,7 +17246,7 @@ const MCP_TOOLS = [
|
|
|
16558
17246
|
},
|
|
16559
17247
|
{
|
|
16560
17248
|
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.",
|
|
17249
|
+
description: "Agent-loop digest (1:1 with `mjolnir ci verify`): scan the target and diff against the committed baseline. Returns resolved (per §15 lifecycle), new, unchanged (grouped by ruleId + location), and the score delta. Read-only; the same transport guardrails as every other tool.",
|
|
16562
17250
|
inputSchema: {
|
|
16563
17251
|
type: "object",
|
|
16564
17252
|
properties: { path: {
|
|
@@ -16570,7 +17258,7 @@ const MCP_TOOLS = [
|
|
|
16570
17258
|
},
|
|
16571
17259
|
{
|
|
16572
17260
|
name: "forensics",
|
|
16573
|
-
description: "Runtime-evidence report (1:1 with the forensics engine behind `mjolnir
|
|
17261
|
+
description: "Runtime-evidence report (1:1 with the forensics engine behind `mjolnir explain --playwright`): ingest a run report (Playwright JSON, JUnit XML, Jest/Vitest JSON, or a Playwright trace artifact) and return the ForensicsReport — per-test verdicts, retries, TRUE-FLAKE, durations. Hostile reports degrade to zero records (never a fabricated clean run). Read-only.",
|
|
16574
17262
|
inputSchema: {
|
|
16575
17263
|
type: "object",
|
|
16576
17264
|
properties: { path: {
|
|
@@ -16594,7 +17282,7 @@ const MCP_TOOLS = [
|
|
|
16594
17282
|
},
|
|
16595
17283
|
{
|
|
16596
17284
|
name: "pw-report",
|
|
16597
|
-
description: "Playwright run summary (1:1 with `mjolnir
|
|
17285
|
+
description: "Playwright run summary (1:1 with `mjolnir explain --playwright`): ingest a run report and return both the ForensicsReport and the rendered run summary. Read-only.",
|
|
16598
17286
|
inputSchema: {
|
|
16599
17287
|
type: "object",
|
|
16600
17288
|
properties: { path: {
|
|
@@ -16787,7 +17475,7 @@ async function handleToolCall(call) {
|
|
|
16787
17475
|
id: call.id,
|
|
16788
17476
|
result: {
|
|
16789
17477
|
hasBaseline: false,
|
|
16790
|
-
note: "no committed baseline at .mjolnir/baseline.json — establish the before-state with `mjolnir baseline`
|
|
17478
|
+
note: "no committed baseline at .mjolnir/baseline.json — establish the before-state with `mjolnir <target> --save-baseline`, then re-run this tool (or `mjolnir ci verify`) after the fix"
|
|
16791
17479
|
}
|
|
16792
17480
|
};
|
|
16793
17481
|
const result = await serializeScan(() => runScan({
|