mjolnir-qa 3.0.0 → 5.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 +628 -6
- package/README.ar.md +58 -29
- package/README.bn.md +58 -29
- package/README.br.md +58 -29
- package/README.bs.md +58 -29
- package/README.da.md +59 -30
- package/README.de.md +59 -30
- package/README.es.md +58 -29
- package/README.fr.md +59 -30
- package/README.gr.md +58 -29
- package/README.he.md +58 -29
- package/README.it.md +58 -29
- package/README.ja.md +58 -29
- package/README.ko.md +58 -29
- package/README.md +95 -56
- package/README.no.md +58 -29
- package/README.pl.md +58 -29
- package/README.ru.md +58 -29
- package/README.th.md +58 -29
- package/README.tr.md +58 -29
- package/README.uk.md +58 -29
- package/README.vi.md +58 -29
- package/README.zh.md +58 -29
- package/README.zht.md +58 -29
- package/dist/cli.d.mts +398 -17
- package/dist/cli.mjs +2893 -1005
- package/dist/mcp/stdio.mjs +1576 -715
- package/dist/scan-pipeline-DNYbnWjE.mjs +2 -0
- package/dist/{scan-pipeline-CAH9_Qgh.mjs → scan-pipeline-DT8DWAMl.mjs} +1777 -978
- package/package.json +70 -9
- package/dist/scan-pipeline-CRe7-MQn.mjs +0 -2
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.mjs";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
-
import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, unlinkSync,
|
|
3
|
+
import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
4
4
|
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
6
|
import { createHash, randomBytes } from "node:crypto";
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
7
8
|
import * as ts$2 from "ts-morph";
|
|
8
9
|
import ts, { Project, SyntaxKind, ts as ts$1 } from "ts-morph";
|
|
9
10
|
import { Language, Parser } from "web-tree-sitter";
|
|
10
11
|
import { parse } from "yaml";
|
|
11
|
-
import { execFileSync } from "node:child_process";
|
|
12
12
|
import { inflateRawSync } from "node:zlib";
|
|
13
13
|
//#region src/types.ts
|
|
14
14
|
/** Severity ladder. Order matters for sorting and gating. */
|
|
@@ -60,6 +60,32 @@ function isValidCategory(value) {
|
|
|
60
60
|
return value !== void 0 && RULE_CATEGORIES.includes(value);
|
|
61
61
|
}
|
|
62
62
|
/**
|
|
63
|
+
* Trust levels (Verification Trust Evolution Plan §16): the OVERALL
|
|
64
|
+
* trust a consumer can place in one finding, combining the static
|
|
65
|
+
* evidence ladder (E0–E2) with RUNTIME corroboration from a real run
|
|
66
|
+
* report. Exposed honestly, never overclaimed:
|
|
67
|
+
* L0 — observation only (E0, no runtime evidence).
|
|
68
|
+
* L1 — heuristic static evidence (E1), no runtime evidence.
|
|
69
|
+
* L2 — deterministic static evidence (E2), no runtime evidence.
|
|
70
|
+
* L3 — RUNTIME: the file containing this finding appeared in a real
|
|
71
|
+
* run report (tests in that file executed).
|
|
72
|
+
* L4 — RUNTIME: the specific test containing this finding was
|
|
73
|
+
* identified in the report and executed (its outcome is known).
|
|
74
|
+
* L5 — RUNTIME: the run verdict directly corroborates the DEFECT
|
|
75
|
+
* class (e.g. a flake-risk finding whose test actually flaked,
|
|
76
|
+
* retried, or timed out in the report).
|
|
77
|
+
* INVARIANT (structurally enforced): L3–L5 require runtime
|
|
78
|
+
* corroboration — a static-only finding can never claim L4/L5.
|
|
79
|
+
*/
|
|
80
|
+
const TRUST_ORDER = [
|
|
81
|
+
"L0",
|
|
82
|
+
"L1",
|
|
83
|
+
"L2",
|
|
84
|
+
"L3",
|
|
85
|
+
"L4",
|
|
86
|
+
"L5"
|
|
87
|
+
];
|
|
88
|
+
/**
|
|
63
89
|
* Honest default evidence level for a finding (Honesty Core Phase 1).
|
|
64
90
|
* Derivation is deterministic and conservative:
|
|
65
91
|
* observation → E0 (never proof)
|
|
@@ -100,6 +126,7 @@ const DEDUCTIONS = {
|
|
|
100
126
|
//#region src/engine/completion.ts
|
|
101
127
|
function deriveCompletion(input) {
|
|
102
128
|
const truncationReasons = [...new Set(input.truncationReasons)].sort();
|
|
129
|
+
const degradations = [...input.degradations ?? []].sort((a, b) => a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0);
|
|
103
130
|
const reasons = /* @__PURE__ */ new Set();
|
|
104
131
|
if (input.discoveryTruncated) reasons.add("discovery-truncated");
|
|
105
132
|
if (input.rulesPartial) reasons.add("rules-partial");
|
|
@@ -109,14 +136,16 @@ function deriveCompletion(input) {
|
|
|
109
136
|
if (input.scopeUnrecognized > 0) reasons.add(`scope-unrecognized:${input.scopeUnrecognized}`);
|
|
110
137
|
if (input.parseFailed > 0) reasons.add(`parse-failed:${input.parseFailed}`);
|
|
111
138
|
if (input.parseFallbacks && input.parseFallbacks > 0) reasons.add(`parse-fallbacks:${input.parseFallbacks}`);
|
|
139
|
+
if (input.astFallbackFiles && input.astFallbackFiles > 0) reasons.add(`ast-budget-fallback-files:${input.astFallbackFiles}`);
|
|
112
140
|
if (input.scopeDegraded) reasons.add(`scope-degraded:${input.scopeDegraded}`);
|
|
113
141
|
if (input.runtimeIncomplete) reasons.add("runtime-incomplete");
|
|
114
142
|
if (input.identityIncomplete) reasons.add("identity-incomplete");
|
|
115
143
|
for (const reason of truncationReasons) reasons.add(`truncated:${reason}`);
|
|
144
|
+
for (const entry of degradations) reasons.add(`degraded:${entry.reason}:${entry.count}`);
|
|
116
145
|
const discoveryPartial = input.discoveryTruncated || input.scopeIgnored > 0 || input.scopeUnrecognized > 0 || input.scopeDegraded !== void 0;
|
|
117
146
|
const rulesPartial = input.rulesPartial || input.rulesCrashed > 0 || input.parseFailed > 0;
|
|
118
147
|
return {
|
|
119
|
-
partial: discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0,
|
|
148
|
+
partial: discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0 || degradations.length > 0,
|
|
120
149
|
analysisStatus: {
|
|
121
150
|
discovery: discoveryPartial ? "partial" : "complete",
|
|
122
151
|
rules: rulesPartial ? "partial" : "complete",
|
|
@@ -124,11 +153,49 @@ function deriveCompletion(input) {
|
|
|
124
153
|
rulesCrashed: input.rulesCrashed,
|
|
125
154
|
parseFallbacks: input.parseFallbacks ?? 0,
|
|
126
155
|
...truncationReasons.length > 0 ? { truncationReasons } : {},
|
|
156
|
+
...degradations.length > 0 ? { degradations } : {},
|
|
127
157
|
reasons: [...reasons].sort()
|
|
128
158
|
}
|
|
129
159
|
};
|
|
130
160
|
}
|
|
131
161
|
//#endregion
|
|
162
|
+
//#region src/engine/degradation-ledger.ts
|
|
163
|
+
/**
|
|
164
|
+
* Append-only. Records are never edited and never removed by a recording
|
|
165
|
+
* site; a reader takes a window, and the window is defined by index, so
|
|
166
|
+
* "delete my entry" is not an operation this module offers.
|
|
167
|
+
*/
|
|
168
|
+
const records = [];
|
|
169
|
+
/** Record one capability loss. `count` is for a site that fails N at once. */
|
|
170
|
+
function recordDegradation(reason, count = 1) {
|
|
171
|
+
if (!Number.isInteger(count) || count < 1) return;
|
|
172
|
+
for (let index = 0; index < count; index++) records.push({ reason });
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Mark where this reader's window opens. Everything appended from here on is
|
|
176
|
+
* what the reader will see; see the file header for what a concurrent scan
|
|
177
|
+
* does to that boundary.
|
|
178
|
+
*/
|
|
179
|
+
function beginDegradationWindow() {
|
|
180
|
+
return records.length;
|
|
181
|
+
}
|
|
182
|
+
/** The records appended since `marker`, without consuming them. */
|
|
183
|
+
function degradationsSince(marker) {
|
|
184
|
+
return records.slice(marker);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Per-reason counts for a window, sorted by reason so the output is
|
|
188
|
+
* byte-deterministic for the same set of degradations.
|
|
189
|
+
*/
|
|
190
|
+
function summarizeDegradations(window) {
|
|
191
|
+
const counts = /* @__PURE__ */ new Map();
|
|
192
|
+
for (const record of window) counts.set(record.reason, (counts.get(record.reason) ?? 0) + 1);
|
|
193
|
+
return [...counts.entries()].map(([reason, count]) => ({
|
|
194
|
+
reason,
|
|
195
|
+
count
|
|
196
|
+
})).sort((a, b) => a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0);
|
|
197
|
+
}
|
|
198
|
+
//#endregion
|
|
132
199
|
//#region src/engine/runtime-corroboration.ts
|
|
133
200
|
/**
|
|
134
201
|
* Stamp runtime corroboration + trust levels onto findings (mutates in
|
|
@@ -742,21 +809,13 @@ const MEASURED_FP = {
|
|
|
742
809
|
* empty scan is an observation, not a proof).
|
|
743
810
|
*/
|
|
744
811
|
function summaryTrustLevel(findings) {
|
|
745
|
-
const order = [
|
|
746
|
-
"L0",
|
|
747
|
-
"L1",
|
|
748
|
-
"L2",
|
|
749
|
-
"L3",
|
|
750
|
-
"L4",
|
|
751
|
-
"L5"
|
|
752
|
-
];
|
|
753
812
|
let best = 0;
|
|
754
813
|
for (const f of findings) {
|
|
755
814
|
const t = f.trustLevel ?? "L2";
|
|
756
|
-
const idx =
|
|
815
|
+
const idx = TRUST_ORDER.indexOf(t);
|
|
757
816
|
if (idx > best) best = idx;
|
|
758
817
|
}
|
|
759
|
-
return
|
|
818
|
+
return TRUST_ORDER[best];
|
|
760
819
|
}
|
|
761
820
|
/**
|
|
762
821
|
* A finding's trust rung (0–5) on the canonical ladder. Corroborated
|
|
@@ -767,14 +826,7 @@ function summaryTrustLevel(findings) {
|
|
|
767
826
|
*/
|
|
768
827
|
function trustRung(f) {
|
|
769
828
|
const t = f.trustLevel ?? deriveTrustLevel(f);
|
|
770
|
-
return
|
|
771
|
-
"L0",
|
|
772
|
-
"L1",
|
|
773
|
-
"L2",
|
|
774
|
-
"L3",
|
|
775
|
-
"L4",
|
|
776
|
-
"L5"
|
|
777
|
-
].indexOf(t);
|
|
829
|
+
return TRUST_ORDER.indexOf(t);
|
|
778
830
|
}
|
|
779
831
|
/**
|
|
780
832
|
* Hard incompleteness ceilings (plan §6: partial/crash/truncation;
|
|
@@ -911,6 +963,17 @@ function buildRunIdentity(input) {
|
|
|
911
963
|
engineVersion: input.engineVersion
|
|
912
964
|
};
|
|
913
965
|
if (input.reportDigest) verdictInputs.reportDigest = input.reportDigest;
|
|
966
|
+
if (input.commit !== void 0) verdictInputs.commit = input.commit;
|
|
967
|
+
if (input.tree !== void 0) verdictInputs.tree = input.tree;
|
|
968
|
+
if (input.lockfile !== void 0) verdictInputs.lockfile = input.lockfile;
|
|
969
|
+
if (input.candidate !== void 0) verdictInputs.candidate = {
|
|
970
|
+
manifestId: input.candidate.manifestId,
|
|
971
|
+
state: input.candidate.state,
|
|
972
|
+
candidateSha: input.candidate.candidateSha,
|
|
973
|
+
baseSha: input.candidate.baseSha,
|
|
974
|
+
packageSha256: input.candidate.packageSha256,
|
|
975
|
+
lockfileSha256: input.candidate.lockfileSha256
|
|
976
|
+
};
|
|
914
977
|
if (input.trustModelVersion !== void 0) verdictInputs.trustModelVersion = input.trustModelVersion;
|
|
915
978
|
if (input.scoringModelVersion !== void 0) verdictInputs.scoringModelVersion = input.scoringModelVersion;
|
|
916
979
|
if (input.frameworkSupportMatrixVersion !== void 0) verdictInputs.frameworkSupportMatrixVersion = input.frameworkSupportMatrixVersion;
|
|
@@ -918,15 +981,31 @@ function buildRunIdentity(input) {
|
|
|
918
981
|
if (input.suppressionFingerprint !== void 0) verdictInputs.suppressionFingerprint = input.suppressionFingerprint;
|
|
919
982
|
if (input.policyFingerprint !== void 0) verdictInputs.policyFingerprint = input.policyFingerprint;
|
|
920
983
|
if (input.historicalEvidenceFingerprint !== void 0) verdictInputs.historicalEvidenceFingerprint = input.historicalEvidenceFingerprint;
|
|
984
|
+
const scanId = sha256$1(canonical(verdictInputs));
|
|
985
|
+
const boundLinks = [
|
|
986
|
+
"input",
|
|
987
|
+
"rules",
|
|
988
|
+
"config",
|
|
989
|
+
"engine"
|
|
990
|
+
];
|
|
991
|
+
if (input.commit !== void 0) boundLinks.push("commit");
|
|
992
|
+
if (input.tree !== void 0) boundLinks.push("tree");
|
|
993
|
+
if (input.lockfile !== void 0) boundLinks.push("lockfile");
|
|
994
|
+
if (input.candidate !== void 0) boundLinks.push("candidate");
|
|
921
995
|
return {
|
|
922
|
-
scanId
|
|
996
|
+
scanId,
|
|
923
997
|
inputFingerprint,
|
|
924
998
|
rulesDigest,
|
|
925
999
|
configFingerprint,
|
|
926
1000
|
engineVersion: input.engineVersion,
|
|
1001
|
+
boundLinks,
|
|
927
1002
|
...input.trustModelVersion !== void 0 ? { trustModelVersion: input.trustModelVersion } : {},
|
|
928
1003
|
...input.scoringModelVersion !== void 0 ? { scoringModelVersion: input.scoringModelVersion } : {},
|
|
929
|
-
...input.frameworkSupportMatrixVersion !== void 0 ? { frameworkSupportMatrixVersion: input.frameworkSupportMatrixVersion } : {}
|
|
1004
|
+
...input.frameworkSupportMatrixVersion !== void 0 ? { frameworkSupportMatrixVersion: input.frameworkSupportMatrixVersion } : {},
|
|
1005
|
+
...input.commit !== void 0 ? { commit: input.commit } : {},
|
|
1006
|
+
...input.tree !== void 0 ? { tree: input.tree } : {},
|
|
1007
|
+
...input.lockfile !== void 0 ? { lockfile: input.lockfile } : {},
|
|
1008
|
+
...input.candidate !== void 0 ? { candidate: input.candidate } : {}
|
|
930
1009
|
};
|
|
931
1010
|
}
|
|
932
1011
|
/**
|
|
@@ -967,9 +1046,353 @@ function buildEvidenceGraph(parts) {
|
|
|
967
1046
|
...parts.reproduction !== void 0 ? { ref: parts.reproduction } : {}
|
|
968
1047
|
}
|
|
969
1048
|
],
|
|
970
|
-
...parts.runId !== void 0 ? { runId: parts.runId } : {}
|
|
1049
|
+
...parts.runId !== void 0 ? { runId: parts.runId } : {},
|
|
1050
|
+
...parts.candidate !== void 0 ? { candidate: {
|
|
1051
|
+
manifestId: parts.candidate.manifestId,
|
|
1052
|
+
candidateSha: parts.candidate.candidateSha
|
|
1053
|
+
} } : {}
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
//#endregion
|
|
1057
|
+
//#region src/engine/file-executor.ts
|
|
1058
|
+
function cacheHitOutcome(job) {
|
|
1059
|
+
return {
|
|
1060
|
+
path: job.path,
|
|
1061
|
+
status: "CACHE_HIT",
|
|
1062
|
+
findings: [...job.cacheHit ?? []],
|
|
1063
|
+
parseFallback: false
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* Drive a list of jobs through an executor and return outcomes in INPUT order.
|
|
1068
|
+
*
|
|
1069
|
+
* This is the function the pipeline calls. It is executor-agnostic on purpose:
|
|
1070
|
+
* swapping in a pooled executor must not require touching the caller, and the
|
|
1071
|
+
* ordering guarantee has to live here or it would live in each executor and
|
|
1072
|
+
* drift between them.
|
|
1073
|
+
*/
|
|
1074
|
+
async function executeFiles(jobs, executor, context = {}) {
|
|
1075
|
+
const outcomes = new Array(jobs.length);
|
|
1076
|
+
if (executor.concurrency <= 1 || jobs.length <= 1) {
|
|
1077
|
+
for (let i = 0; i < jobs.length; i++) {
|
|
1078
|
+
const job = jobs[i];
|
|
1079
|
+
context.onProgress?.({
|
|
1080
|
+
done: i,
|
|
1081
|
+
total: jobs.length,
|
|
1082
|
+
path: job.path
|
|
1083
|
+
});
|
|
1084
|
+
outcomes[i] = await runOne(job, executor, context);
|
|
1085
|
+
}
|
|
1086
|
+
context.onProgress?.({
|
|
1087
|
+
done: jobs.length,
|
|
1088
|
+
total: jobs.length,
|
|
1089
|
+
path: jobs[jobs.length - 1]?.path ?? ""
|
|
1090
|
+
});
|
|
1091
|
+
return outcomes;
|
|
1092
|
+
}
|
|
1093
|
+
let next = 0;
|
|
1094
|
+
let completed = 0;
|
|
1095
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
1096
|
+
const limit = Math.max(1, Math.floor(executor.concurrency));
|
|
1097
|
+
const worker = async () => {
|
|
1098
|
+
for (;;) {
|
|
1099
|
+
const index = next++;
|
|
1100
|
+
if (index >= jobs.length) return;
|
|
1101
|
+
const job = jobs[index];
|
|
1102
|
+
outcomes[index] = await runOne(job, executor, context);
|
|
1103
|
+
completed++;
|
|
1104
|
+
context.onProgress?.({
|
|
1105
|
+
done: completed,
|
|
1106
|
+
total: jobs.length,
|
|
1107
|
+
path: job.path
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
};
|
|
1111
|
+
for (let i = 0; i < Math.min(limit, jobs.length); i++) {
|
|
1112
|
+
const promise = worker().then(() => void 0);
|
|
1113
|
+
inFlight.add(promise);
|
|
1114
|
+
}
|
|
1115
|
+
await Promise.all(inFlight);
|
|
1116
|
+
return outcomes;
|
|
1117
|
+
}
|
|
1118
|
+
async function runOne(job, executor, context) {
|
|
1119
|
+
if (job.cacheHit !== void 0) return cacheHitOutcome(job);
|
|
1120
|
+
try {
|
|
1121
|
+
return await executor.execute(job, context);
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
return {
|
|
1124
|
+
path: job.path,
|
|
1125
|
+
status: "FAILED",
|
|
1126
|
+
findings: [],
|
|
1127
|
+
parseFallback: false,
|
|
1128
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* The strategy the pipeline uses today.
|
|
1134
|
+
*
|
|
1135
|
+
* `runOne` is supplied by the pipeline because parsing and rule execution
|
|
1136
|
+
* need the adapter and the active rule set, which are pipeline concerns. The
|
|
1137
|
+
* executor's job is the BOUNDARY and the bookkeeping, not the analysis — so
|
|
1138
|
+
* this factory exists to bind those two without the executor importing the
|
|
1139
|
+
* pipeline (which would be a cycle).
|
|
1140
|
+
*/
|
|
1141
|
+
function createExecutor(name, concurrency, runOne) {
|
|
1142
|
+
return {
|
|
1143
|
+
name,
|
|
1144
|
+
concurrency,
|
|
1145
|
+
execute: runOne
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
//#endregion
|
|
1149
|
+
//#region src/scope/git-resolve.ts
|
|
1150
|
+
/**
|
|
1151
|
+
* Absolute-path git resolution (audit S1).
|
|
1152
|
+
*
|
|
1153
|
+
* `execFileSync("git", …)` resolves through PATH — including the CWD on
|
|
1154
|
+
* Windows (CreateProcess searches the current directory before PATH for
|
|
1155
|
+
* extension-less names depending on NoDefaultCurrentDirectoryInExePath).
|
|
1156
|
+
* Scanning an UNTRUSTED repo therefore let a checked-in `git.exe` (or
|
|
1157
|
+
* `git.bat`/`git.cmd`) hijack Mjölnir's own git invocations: the attacker
|
|
1158
|
+
* controls the diff output, the merge-base decision, and
|
|
1159
|
+
* `--scope changed`'s "what is new" answer — the exact data CI gates on.
|
|
1160
|
+
*
|
|
1161
|
+
* The fix: resolve git ONCE per process to an ABSOLUTE path by walking
|
|
1162
|
+
* the PATH directly (never consulting the CWD), verify the candidate is
|
|
1163
|
+
* an existing file, and pass that path to execFileSync. Resolution is
|
|
1164
|
+
* memoized; a failure to find a real git anywhere on PATH is recorded
|
|
1165
|
+
* and callers degrade without invoking a bare name.
|
|
1166
|
+
*/
|
|
1167
|
+
let resolvedGit = void 0;
|
|
1168
|
+
/**
|
|
1169
|
+
* The absolute path of the git binary Mjölnir will invoke, or null when
|
|
1170
|
+
* PATH carries no executable `git` at all. Callers must degrade without
|
|
1171
|
+
* invoking a bare name.
|
|
1172
|
+
*/
|
|
1173
|
+
function resolveGitPath() {
|
|
1174
|
+
if (resolvedGit !== void 0) return resolvedGit;
|
|
1175
|
+
const candidates = [process.platform === "win32" ? "git.exe" : "git"];
|
|
1176
|
+
if (process.platform === "win32") {
|
|
1177
|
+
const pathExt = process.env["PATHEXT"]?.split(";").filter(Boolean) ?? [];
|
|
1178
|
+
for (const ext of pathExt) candidates.push(`git${ext.toLowerCase()}`);
|
|
1179
|
+
}
|
|
1180
|
+
const pathValue = process.env["PATH"] ?? "";
|
|
1181
|
+
for (const dir of pathValue.split(delimiter)) {
|
|
1182
|
+
if (dir === "") continue;
|
|
1183
|
+
if (!isAbsolute(dir)) continue;
|
|
1184
|
+
for (const candidate of candidates) {
|
|
1185
|
+
const full = join(dir, candidate);
|
|
1186
|
+
try {
|
|
1187
|
+
if (existsSync(full)) {
|
|
1188
|
+
resolvedGit = full;
|
|
1189
|
+
return resolvedGit;
|
|
1190
|
+
}
|
|
1191
|
+
} catch {}
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
`${pathValue.length}`;
|
|
1195
|
+
resolvedGit = null;
|
|
1196
|
+
return resolvedGit;
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* Run git with the S1-resolved absolute binary path. Returns stdout, or
|
|
1200
|
+
* null when git is missing or the command fails — the same contract as
|
|
1201
|
+
* the previous inline `git()` helpers, with the hijack surface closed.
|
|
1202
|
+
*/
|
|
1203
|
+
function runGit(root, args) {
|
|
1204
|
+
const exe = resolveGitPath();
|
|
1205
|
+
if (!exe) return null;
|
|
1206
|
+
try {
|
|
1207
|
+
return execFileSync(exe, [
|
|
1208
|
+
"-C",
|
|
1209
|
+
root,
|
|
1210
|
+
...args
|
|
1211
|
+
], {
|
|
1212
|
+
encoding: "utf8",
|
|
1213
|
+
stdio: [
|
|
1214
|
+
"ignore",
|
|
1215
|
+
"pipe",
|
|
1216
|
+
"ignore"
|
|
1217
|
+
],
|
|
1218
|
+
timeout: 15e3
|
|
1219
|
+
});
|
|
1220
|
+
} catch {
|
|
1221
|
+
return null;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
//#endregion
|
|
1225
|
+
//#region src/lib/safe-json.ts
|
|
1226
|
+
/**
|
|
1227
|
+
* Reusable safe-JSON parse-and-validate helper.
|
|
1228
|
+
*
|
|
1229
|
+
* Replaces the `JSON.parse(text) as T` pattern with a two-step process:
|
|
1230
|
+
* 1. Try/catch around JSON.parse (descriptive error on bad JSON)
|
|
1231
|
+
* 2. Runtime validation of the parsed shape (caller-supplied predicate)
|
|
1232
|
+
*
|
|
1233
|
+
* The `validate` predicate is intentionally lightweight — it checks the
|
|
1234
|
+
* expected shape exists rather than exhaustively type-guarding every field.
|
|
1235
|
+
* Internal files (cache, hashes) use `isRecord`; user-facing files
|
|
1236
|
+
* (config, plugins) use stricter checks that throw descriptive errors.
|
|
1237
|
+
*/
|
|
1238
|
+
const MAX_JSON_BYTES = 16777216;
|
|
1239
|
+
function parseJsonFile(text, source, validate) {
|
|
1240
|
+
if (Buffer.byteLength(text, "utf8") > MAX_JSON_BYTES) throw new Error(`JSON source exceeds the ${MAX_JSON_BYTES}-byte limit: ${source}`);
|
|
1241
|
+
let parsed;
|
|
1242
|
+
try {
|
|
1243
|
+
parsed = JSON.parse(text);
|
|
1244
|
+
} catch (err) {
|
|
1245
|
+
throw new Error(`invalid JSON in ${source}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
1246
|
+
}
|
|
1247
|
+
if (!validate(parsed)) throw new Error(`unexpected shape in ${source}`, { cause: parsed });
|
|
1248
|
+
return parsed;
|
|
1249
|
+
}
|
|
1250
|
+
/**
|
|
1251
|
+
* Minimal validator: value is a plain object (not null, not array).
|
|
1252
|
+
* Sufficient for internal files (cache, hashes) where downstream
|
|
1253
|
+
* code already checks individual fields.
|
|
1254
|
+
*/
|
|
1255
|
+
function isRecord(v) {
|
|
1256
|
+
try {
|
|
1257
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1258
|
+
} catch {
|
|
1259
|
+
return false;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
//#endregion
|
|
1263
|
+
//#region src/engine/candidate-binding.ts
|
|
1264
|
+
/**
|
|
1265
|
+
* Candidate binding (plan V5-010).
|
|
1266
|
+
*
|
|
1267
|
+
* Reads a candidate trust manifest and produces the immutable
|
|
1268
|
+
* `CandidateBinding` that a run identity is bound to. It also computes the
|
|
1269
|
+
* repository facts (commit, tree, lockfile digest) that the identity was
|
|
1270
|
+
* missing until this module existed.
|
|
1271
|
+
*
|
|
1272
|
+
* The law here is ABSENCE over invention. A manifest that is missing,
|
|
1273
|
+
* unreadable, or internally contradictory yields NO binding — not a partial
|
|
1274
|
+
* one. A consumer that can see "not bound" can refuse to trust the run; a
|
|
1275
|
+
* consumer handed a half-filled binding cannot tell what is missing, and will
|
|
1276
|
+
* trust the parts that are there.
|
|
1277
|
+
*/
|
|
1278
|
+
const CANDIDATE_MANIFEST_PATH = "candidate-trust-manifest.json";
|
|
1279
|
+
const SHA40 = /^[a-f0-9]{40}$/;
|
|
1280
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
1281
|
+
const STATES = ["WORKING_CANDIDATE", "RELEASE_CANDIDATE"];
|
|
1282
|
+
const AUTHORIZATION = ["NOT_AUTHORIZED", "AUTHORIZED"];
|
|
1283
|
+
function sha256File(path) {
|
|
1284
|
+
try {
|
|
1285
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
1286
|
+
} catch {
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* One git line, or nothing.
|
|
1292
|
+
*
|
|
1293
|
+
* Audit S1: the binary is the ABSOLUTE path `resolveGitPath()` found by
|
|
1294
|
+
* walking PATH itself, never the bare name. `bindRepository()` runs on every
|
|
1295
|
+
* scan (`scan-pipeline.ts`), and a bare `execFileSync("git", …)` lets a
|
|
1296
|
+
* `git.exe`/`git.bat`/`git.cmd` CHECKED INTO AN UNTRUSTED REPO hijack the
|
|
1297
|
+
* call on Windows, where CreateProcess searches the current directory before
|
|
1298
|
+
* PATH. The attacker then controls the `commit` and `tree` that land in
|
|
1299
|
+
* `runIdentity` and the evidence graph — the provenance a release decision
|
|
1300
|
+
* reads. This was the last bare-name exec left in `src/`; the invariant that
|
|
1301
|
+
* keeps it that way is `tests/contract/no-bare-process-exec.spec.ts`.
|
|
1302
|
+
*
|
|
1303
|
+
* The `-C <root>` shape (not `cwd`) matches every other hardened call site.
|
|
1304
|
+
* With no git on PATH at all, the answer is nothing — a scan of an unpacked
|
|
1305
|
+
* tarball has no commit, and its identity must say so rather than carrying a
|
|
1306
|
+
* placeholder that looks like provenance.
|
|
1307
|
+
*/
|
|
1308
|
+
function gitLine(root, args) {
|
|
1309
|
+
const exe = resolveGitPath();
|
|
1310
|
+
if (!exe) return void 0;
|
|
1311
|
+
try {
|
|
1312
|
+
const trimmed = execFileSync(exe, [
|
|
1313
|
+
"-C",
|
|
1314
|
+
root,
|
|
1315
|
+
...args
|
|
1316
|
+
], {
|
|
1317
|
+
encoding: "utf8",
|
|
1318
|
+
stdio: [
|
|
1319
|
+
"ignore",
|
|
1320
|
+
"pipe",
|
|
1321
|
+
"ignore"
|
|
1322
|
+
],
|
|
1323
|
+
windowsHide: true,
|
|
1324
|
+
timeout: 15e3
|
|
1325
|
+
}).trim();
|
|
1326
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
1327
|
+
} catch {
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
/**
|
|
1332
|
+
* The commit and tree the working tree is at. Both absent outside a
|
|
1333
|
+
* repository or with no commits — never substituted with a placeholder.
|
|
1334
|
+
*/
|
|
1335
|
+
function bindRepository(root) {
|
|
1336
|
+
const commit = gitLine(root, ["rev-parse", "HEAD"]);
|
|
1337
|
+
const tree = gitLine(root, ["rev-parse", "HEAD^{tree}"]);
|
|
1338
|
+
const lockfile = sha256File(join(root, "package-lock.json"));
|
|
1339
|
+
return {
|
|
1340
|
+
...commit !== void 0 ? { commit } : {},
|
|
1341
|
+
...tree !== void 0 ? { tree } : {},
|
|
1342
|
+
...lockfile !== void 0 ? { lockfile } : {}
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
/**
|
|
1346
|
+
* Parse a candidate manifest into a binding, or return null when it cannot
|
|
1347
|
+
* honestly be bound.
|
|
1348
|
+
*
|
|
1349
|
+
* Null is returned for: no manifest, unreadable JSON, a missing or malformed
|
|
1350
|
+
* required field, or a state/authorization combination that cannot legally
|
|
1351
|
+
* exist (a WORKING_CANDIDATE carrying a commit, for example — the same
|
|
1352
|
+
* contradiction the release decision evaluator treats as BLOCKED).
|
|
1353
|
+
*/
|
|
1354
|
+
function parseCandidateBinding(raw) {
|
|
1355
|
+
if (!isRecord(raw)) return null;
|
|
1356
|
+
const identity = raw.identity;
|
|
1357
|
+
if (!isRecord(identity)) return null;
|
|
1358
|
+
const state = identity.state;
|
|
1359
|
+
const candidateSha = identity.candidateSha;
|
|
1360
|
+
const baseSha = identity.baseSha;
|
|
1361
|
+
const packageSha256 = identity.packageSha256;
|
|
1362
|
+
const lockfileSha256 = identity.lockfileSha256;
|
|
1363
|
+
if (typeof state !== "string" || !STATES.includes(state)) return null;
|
|
1364
|
+
if (typeof baseSha !== "string" || !SHA40.test(baseSha)) return null;
|
|
1365
|
+
if (typeof packageSha256 !== "string" || !SHA256.test(packageSha256)) return null;
|
|
1366
|
+
if (typeof lockfileSha256 !== "string" || !SHA256.test(lockfileSha256)) return null;
|
|
1367
|
+
if (candidateSha !== null && (typeof candidateSha !== "string" || !SHA40.test(candidateSha))) return null;
|
|
1368
|
+
const manifestId = raw.manifestId;
|
|
1369
|
+
if (typeof manifestId !== "string" || manifestId.length === 0) return null;
|
|
1370
|
+
const owner = typeof raw.owner === "string" ? raw.owner : "UNASSIGNED";
|
|
1371
|
+
const authorization = raw.releaseAuthorizationState;
|
|
1372
|
+
if (typeof authorization !== "string" || !AUTHORIZATION.includes(authorization)) return null;
|
|
1373
|
+
if (state === "WORKING_CANDIDATE") {
|
|
1374
|
+
if (candidateSha !== null) return null;
|
|
1375
|
+
if (authorization !== "NOT_AUTHORIZED") return null;
|
|
1376
|
+
} else if (candidateSha === null) return null;
|
|
1377
|
+
return {
|
|
1378
|
+
manifestId,
|
|
1379
|
+
state,
|
|
1380
|
+
candidateSha,
|
|
1381
|
+
baseSha,
|
|
1382
|
+
packageSha256,
|
|
1383
|
+
lockfileSha256,
|
|
1384
|
+
owner,
|
|
1385
|
+
releaseAuthorizationState: authorization
|
|
971
1386
|
};
|
|
972
1387
|
}
|
|
1388
|
+
/** Read and bind the manifest from a checkout, or null. */
|
|
1389
|
+
function readCandidateBinding(root, manifestPath = CANDIDATE_MANIFEST_PATH) {
|
|
1390
|
+
try {
|
|
1391
|
+
return parseCandidateBinding(JSON.parse(readFileSync(join(root, manifestPath), "utf8")));
|
|
1392
|
+
} catch {
|
|
1393
|
+
return null;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
973
1396
|
//#endregion
|
|
974
1397
|
//#region src/engine/version.ts
|
|
975
1398
|
/**
|
|
@@ -980,7 +1403,7 @@ function buildEvidenceGraph(parts) {
|
|
|
980
1403
|
* scripts/sync-sarif-version.cjs and guarded by the version-consistency
|
|
981
1404
|
* spec. cli.ts re-exports this as CLI_VERSION.
|
|
982
1405
|
*/
|
|
983
|
-
const ENGINE_VERSION = "
|
|
1406
|
+
const ENGINE_VERSION = "5.0.0";
|
|
984
1407
|
//#endregion
|
|
985
1408
|
//#region src/engine/contract-versions.ts
|
|
986
1409
|
/**
|
|
@@ -1109,9 +1532,12 @@ function discoverWorkspace(rootDir) {
|
|
|
1109
1532
|
const root = findProjectRoot(rootDir);
|
|
1110
1533
|
if (!root) return null;
|
|
1111
1534
|
let pkg = {};
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1535
|
+
const manifestPath = join(root, "package.json");
|
|
1536
|
+
if (existsSync(manifestPath)) try {
|
|
1537
|
+
pkg = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
1538
|
+
} catch {
|
|
1539
|
+
recordDegradation("workspace-manifest-unreadable");
|
|
1540
|
+
}
|
|
1115
1541
|
const rawWorkspaces = pkg["workspaces"];
|
|
1116
1542
|
let globs = [];
|
|
1117
1543
|
if (Array.isArray(rawWorkspaces)) globs = rawWorkspaces.filter((w) => typeof w === "string");
|
|
@@ -1128,40 +1554,6 @@ function basename$1(p) {
|
|
|
1128
1554
|
return parts[parts.length - 1];
|
|
1129
1555
|
}
|
|
1130
1556
|
//#endregion
|
|
1131
|
-
//#region src/lib/safe-json.ts
|
|
1132
|
-
/**
|
|
1133
|
-
* Reusable safe-JSON parse-and-validate helper.
|
|
1134
|
-
*
|
|
1135
|
-
* Replaces the `JSON.parse(text) as T` pattern with a two-step process:
|
|
1136
|
-
* 1. Try/catch around JSON.parse (descriptive error on bad JSON)
|
|
1137
|
-
* 2. Runtime validation of the parsed shape (caller-supplied predicate)
|
|
1138
|
-
*
|
|
1139
|
-
* The `validate` predicate is intentionally lightweight — it checks the
|
|
1140
|
-
* expected shape exists rather than exhaustively type-guarding every field.
|
|
1141
|
-
* Internal files (cache, hashes) use `isRecord`; user-facing files
|
|
1142
|
-
* (config, plugins) use stricter checks that throw descriptive errors.
|
|
1143
|
-
*/
|
|
1144
|
-
const MAX_JSON_BYTES = 16777216;
|
|
1145
|
-
function parseJsonFile(text, source, validate) {
|
|
1146
|
-
if (Buffer.byteLength(text, "utf8") > MAX_JSON_BYTES) throw new Error(`JSON source exceeds the ${MAX_JSON_BYTES}-byte limit: ${source}`);
|
|
1147
|
-
let parsed;
|
|
1148
|
-
try {
|
|
1149
|
-
parsed = JSON.parse(text);
|
|
1150
|
-
} catch (err) {
|
|
1151
|
-
throw new Error(`invalid JSON in ${source}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
1152
|
-
}
|
|
1153
|
-
if (!validate(parsed)) throw new Error(`unexpected shape in ${source}`, { cause: parsed });
|
|
1154
|
-
return parsed;
|
|
1155
|
-
}
|
|
1156
|
-
/**
|
|
1157
|
-
* Minimal validator: value is a plain object (not null, not array).
|
|
1158
|
-
* Sufficient for internal files (cache, hashes) where downstream
|
|
1159
|
-
* code already checks individual fields.
|
|
1160
|
-
*/
|
|
1161
|
-
function isRecord(v) {
|
|
1162
|
-
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1163
|
-
}
|
|
1164
|
-
//#endregion
|
|
1165
1557
|
//#region src/lib/fs-bounded.ts
|
|
1166
1558
|
function closeQuiet(fd) {
|
|
1167
1559
|
try {
|
|
@@ -1388,20 +1780,15 @@ function validate(cfg, knownRuleIds) {
|
|
|
1388
1780
|
return warnings;
|
|
1389
1781
|
}
|
|
1390
1782
|
/**
|
|
1391
|
-
*
|
|
1392
|
-
*
|
|
1393
|
-
*
|
|
1394
|
-
* documented window.
|
|
1783
|
+
* Expiry is evaluated only from an explicit ISO `expires` date. A missing
|
|
1784
|
+
* date remains active and is reported as having no expiry. Config-file mtime
|
|
1785
|
+
* is never an expiry anchor.
|
|
1395
1786
|
*
|
|
1396
|
-
*
|
|
1397
|
-
*
|
|
1398
|
-
*
|
|
1399
|
-
*
|
|
1400
|
-
*
|
|
1401
|
-
* now the entry's explicit `expires` date alone; an entry without one
|
|
1402
|
-
* stays active and is honestly labeled "(no expiry set)" in the
|
|
1403
|
-
* suppressions report. Hand-authored entries should declare `expires` at
|
|
1404
|
-
* creation (README §Configuration documents the shape).
|
|
1787
|
+
* `now` defaults to the current instant so existing callers stay correct, but
|
|
1788
|
+
* a verdict function that reads the clock invisibly is a verdict nobody can
|
|
1789
|
+
* test at a boundary. `tests/contract/deterministic-clock.spec.ts` audits
|
|
1790
|
+
* `src/config/**` for a call site that supplies its own `new Date()` instead
|
|
1791
|
+
* of threading one through.
|
|
1405
1792
|
*/
|
|
1406
1793
|
function isSuppressionActive(ign, now = /* @__PURE__ */ new Date()) {
|
|
1407
1794
|
if (!ign.expires) return true;
|
|
@@ -1684,7 +2071,33 @@ function sharedWalk(options) {
|
|
|
1684
2071
|
*
|
|
1685
2072
|
* When nothing is detectable we report `unknown` and the scanner analyzes
|
|
1686
2073
|
* all test-looking files — stated honestly in output rather than guessed.
|
|
2074
|
+
*
|
|
2075
|
+
* ## One ID space (plan V5-024)
|
|
2076
|
+
*
|
|
2077
|
+
* This detector used to declare its own `TestFramework` union of three
|
|
2078
|
+
* literals while `src/frameworks/framework-inventory.ts` catalogued fourteen
|
|
2079
|
+
* framework ids. Two vocabularies for one concept is how a support matrix
|
|
2080
|
+
* starts disagreeing with what the tool actually detects: a framework can be
|
|
2081
|
+
* catalogued as OFFICIAL_PARTIAL and never once be emitted by the code that
|
|
2082
|
+
* claims to detect it.
|
|
2083
|
+
*
|
|
2084
|
+
* So the ids here are `FrameworkId`s — the inventory's own vocabulary — and
|
|
2085
|
+
* `DETECTABLE_TEST_FRAMEWORKS` is the subset this detector can actually
|
|
2086
|
+
* resolve from a checkout. That subset is the HONEST limit of detection, and
|
|
2087
|
+
* `detectableVsCatalogued()` reports the remainder rather than letting a
|
|
2088
|
+
* three-item list read as the whole catalog.
|
|
2089
|
+
*/
|
|
2090
|
+
/**
|
|
2091
|
+
* The catalogued frameworks whose detection is implemented today.
|
|
2092
|
+
*
|
|
2093
|
+
* Every entry must exist in the inventory — enforced by the parity spec, not
|
|
2094
|
+
* by comment.
|
|
1687
2095
|
*/
|
|
2096
|
+
const DETECTABLE_TEST_FRAMEWORKS = [
|
|
2097
|
+
"jest",
|
|
2098
|
+
"vitest",
|
|
2099
|
+
"playwright"
|
|
2100
|
+
];
|
|
1688
2101
|
const CONFIG_FILES = {
|
|
1689
2102
|
jest: [
|
|
1690
2103
|
"jest.config.ts",
|
|
@@ -1703,7 +2116,7 @@ const CONFIG_FILES = {
|
|
|
1703
2116
|
};
|
|
1704
2117
|
function detectFrameworks(ws) {
|
|
1705
2118
|
const found = /* @__PURE__ */ new Set();
|
|
1706
|
-
for (const fw of
|
|
2119
|
+
for (const fw of DETECTABLE_TEST_FRAMEWORKS) if (CONFIG_FILES[fw].some((f) => existsSync(join(ws.root, f)))) found.add(fw);
|
|
1707
2120
|
if (!found.has("jest") && ws.packageJson["jest"] !== void 0) found.add("jest");
|
|
1708
2121
|
const deps = {
|
|
1709
2122
|
...ws.packageJson["dependencies"],
|
|
@@ -1725,11 +2138,7 @@ function detectFrameworks(ws) {
|
|
|
1725
2138
|
};
|
|
1726
2139
|
}
|
|
1727
2140
|
return {
|
|
1728
|
-
frameworks:
|
|
1729
|
-
"jest",
|
|
1730
|
-
"vitest",
|
|
1731
|
-
"playwright"
|
|
1732
|
-
].filter((f) => found.has(f)),
|
|
2141
|
+
frameworks: DETECTABLE_TEST_FRAMEWORKS.filter((f) => found.has(f)),
|
|
1733
2142
|
unknown: false
|
|
1734
2143
|
};
|
|
1735
2144
|
}
|
|
@@ -1746,6 +2155,13 @@ function detectFrameworks(ws) {
|
|
|
1746
2155
|
* byte-identical — a score shift is a regression, not an improvement.
|
|
1747
2156
|
*/
|
|
1748
2157
|
let project = null;
|
|
2158
|
+
/**
|
|
2159
|
+
* The shared ts-morph project.
|
|
2160
|
+
*
|
|
2161
|
+
* Exported so an adapter's `dispose()` can evict the file it parsed: ts-morph
|
|
2162
|
+
* caches by file path, so without eviction a long scan holds every parsed
|
|
2163
|
+
* SourceFile until the process exits (plan V5-021).
|
|
2164
|
+
*/
|
|
1749
2165
|
function getProject() {
|
|
1750
2166
|
if (!project) project = new Project({
|
|
1751
2167
|
useInMemoryFileSystem: true,
|
|
@@ -1773,6 +2189,7 @@ function parseTsFile(file) {
|
|
|
1773
2189
|
}
|
|
1774
2190
|
return p.createSourceFile(file.path, file.text);
|
|
1775
2191
|
} catch {
|
|
2192
|
+
recordDegradation("ast-parse-failed");
|
|
1776
2193
|
return;
|
|
1777
2194
|
}
|
|
1778
2195
|
}
|
|
@@ -1828,6 +2245,7 @@ function commentAndStringRanges(ctx) {
|
|
|
1828
2245
|
}
|
|
1829
2246
|
return ranges;
|
|
1830
2247
|
} catch {
|
|
2248
|
+
recordDegradation("ast-range-scan-failed");
|
|
1831
2249
|
return [];
|
|
1832
2250
|
}
|
|
1833
2251
|
}
|
|
@@ -1875,6 +2293,7 @@ function getCodeOnlyText(file) {
|
|
|
1875
2293
|
for (const r of ranges) for (let i = r.start; i < r.end && i < chars.length; i++) if (chars[i] !== "\n" && chars[i] !== "\r") chars[i] = " ";
|
|
1876
2294
|
return chars.join("");
|
|
1877
2295
|
} catch {
|
|
2296
|
+
recordDegradation("ast-mask-unavailable");
|
|
1878
2297
|
return file.text;
|
|
1879
2298
|
}
|
|
1880
2299
|
}
|
|
@@ -2259,7 +2678,45 @@ function frameworkFilterApplies(rule, file) {
|
|
|
2259
2678
|
return rule.frameworks.some((f) => tags.includes(f));
|
|
2260
2679
|
}
|
|
2261
2680
|
//#endregion
|
|
2262
|
-
//#region src/
|
|
2681
|
+
//#region src/engine/shared-run-rules.ts
|
|
2682
|
+
/**
|
|
2683
|
+
* Run ONE rule with crash isolation, and return what it produced.
|
|
2684
|
+
*
|
|
2685
|
+
* This is the block that was copy-pasted into all seven language adapters:
|
|
2686
|
+
*
|
|
2687
|
+
* try { for (const f of rule.run(x)) emit(f, rule.id, rule.category); }
|
|
2688
|
+
* catch (error) { onCrash?.(rule.id, error); }
|
|
2689
|
+
*
|
|
2690
|
+
* Extracting it is worth doing for one reason beyond line count. Crash
|
|
2691
|
+
* isolation is the mechanism that lets a third-party or experimental
|
|
2692
|
+
* detector fail without ending a scan, and it is the mechanism the whole
|
|
2693
|
+
* `rulesCrashed` count depends on. Seven copies means seven chances to
|
|
2694
|
+
* write `catch {}` and lose the count, and `tests/contract/
|
|
2695
|
+
* no-uncounted-degradation.spec.ts` can only audit what it can see.
|
|
2696
|
+
*
|
|
2697
|
+
* `null` means the rule CRASHED, which is not the same as an empty array:
|
|
2698
|
+
* an empty array is the rule abstaining, and the adapters skip emission for
|
|
2699
|
+
* both but the distinction is why the caller must not conflate them.
|
|
2700
|
+
*
|
|
2701
|
+
* Deliberately NOT extracted: the per-adapter filter chain and the budget
|
|
2702
|
+
* check. Those genuinely differ — the TypeScript adapter injects an AST,
|
|
2703
|
+
* framework tags, a lazy `codeText` getter, `appliesTo` scoping, config-only
|
|
2704
|
+
* gating and `configGateMatches`, and it ABORTS the whole file on budget
|
|
2705
|
+
* exhaustion where `runRulesShared` only `continue`s to the next rule.
|
|
2706
|
+
* Routing every adapter through `runRulesShared` as written would silently
|
|
2707
|
+
* change which rules run and when, which is precisely the unmeasured claim
|
|
2708
|
+
* this program exists to delete. This helper takes the part they share.
|
|
2709
|
+
*/
|
|
2710
|
+
function runRuleIsolated(rule, file, onCrash) {
|
|
2711
|
+
try {
|
|
2712
|
+
return rule.run(file);
|
|
2713
|
+
} catch (error) {
|
|
2714
|
+
onCrash?.(rule.id, error);
|
|
2715
|
+
return null;
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
//#endregion
|
|
2719
|
+
//#region src/adapters/typescript.ts
|
|
2263
2720
|
/**
|
|
2264
2721
|
* TypeScript/JavaScript adapter (R1).
|
|
2265
2722
|
* Wraps the proven regex-based engine behind the LanguageAdapter
|
|
@@ -2272,7 +2729,36 @@ function frameworkFilterApplies(rule, file) {
|
|
|
2272
2729
|
* "cypress", `@jest/globals` → "jest", `vitest` → "vitest". Config
|
|
2273
2730
|
* gating is rule-declared (`configFiles`), not hard-coded here.
|
|
2274
2731
|
*/
|
|
2275
|
-
|
|
2732
|
+
/**
|
|
2733
|
+
* `.test.` / `.spec.` filenames, plus the Cypress `.cy.` convention.
|
|
2734
|
+
*
|
|
2735
|
+
* `mts` and `cts` are TypeScript's Node-native ESM/CJS extensions. They were
|
|
2736
|
+
* missing here, and the omission was invisible in the worst way — not a wrong
|
|
2737
|
+
* answer, but an ABSENCE.
|
|
2738
|
+
*
|
|
2739
|
+
* Reproduced: a repo containing two byte-identical tests, one at
|
|
2740
|
+
* `tests/control.spec.ts` and one at `tests/slow.spec.mts`, each with a
|
|
2741
|
+
* `QA-TEST-004` hard sleep:
|
|
2742
|
+
*
|
|
2743
|
+
* discovered: 1, analyzed: 1, unrecognized: 1, scopeVerdict: "PARTIAL"
|
|
2744
|
+
*
|
|
2745
|
+
* The `.ts` test produced the finding. The `.mts` test produced nothing at
|
|
2746
|
+
* all — it was never scanned, so every rule that could have caught it was
|
|
2747
|
+
* silent on it, with no finding, no low-evidence note, and no indication that
|
|
2748
|
+
* a test file had been skipped. A test scanner that cannot see a whole file
|
|
2749
|
+
* extension has a false green that is invisible by construction: the reader
|
|
2750
|
+
* has nothing to distrust, because there is nothing there.
|
|
2751
|
+
*
|
|
2752
|
+
* The corroboration that this was an oversight rather than a decision sits
|
|
2753
|
+
* four lines below: PW_CONFIG_RE already accepted `cts`. The config regex knew
|
|
2754
|
+
* about the Node-native extensions; the test-file regex did not.
|
|
2755
|
+
*
|
|
2756
|
+
* `discovery/scan-adapters.ts:isUnrecognizedSourceCandidate` already counted
|
|
2757
|
+
* these paths as uncovered surface, so the scope accounting had been reporting
|
|
2758
|
+
* the hole the whole time — it was being read as a known limitation rather than
|
|
2759
|
+
* as a defect.
|
|
2760
|
+
*/
|
|
2761
|
+
const TEST_FILE_RE = /\.(?:test|spec)\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$|\.cy\.(?:js|jsx|ts|tsx)$/;
|
|
2276
2762
|
/**
|
|
2277
2763
|
* Fallback config list for `configOnly` rules that do not declare
|
|
2278
2764
|
* `configFiles` (the legacy playwright.config.* gating, preserved
|
|
@@ -2379,11 +2865,42 @@ const typescriptAdapter = {
|
|
|
2379
2865
|
fixtureDirMemo: /* @__PURE__ */ new Map()
|
|
2380
2866
|
});
|
|
2381
2867
|
},
|
|
2868
|
+
/**
|
|
2869
|
+
* The ONE AST seam (plan V5-021).
|
|
2870
|
+
*
|
|
2871
|
+
* This adapter used to parse inside `runRules`, on the synchronous path,
|
|
2872
|
+
* while Java and C# exposed the async `parseAst` hook. Two seams meant two
|
|
2873
|
+
* sets of consequences, both of them bad:
|
|
2874
|
+
*
|
|
2875
|
+
* - the pipeline computes `wantsAst` as `adapter.parseAst !== undefined`,
|
|
2876
|
+
* so a TypeScript file never took the AST path through the pipeline at
|
|
2877
|
+
* all, and its parse failures were invisible to the pipeline's
|
|
2878
|
+
* fallback counters;
|
|
2879
|
+
* - nothing called `dispose()` for the ts-morph path, so a scan held
|
|
2880
|
+
* every parsed SourceFile for its whole lifetime.
|
|
2881
|
+
*
|
|
2882
|
+
* Parsing now happens here, once, through the same contract every other
|
|
2883
|
+
* adapter uses. `dispose()` drops this file's SourceFile from the shared
|
|
2884
|
+
* project; ts-morph caches per file path, so removing it is what keeps
|
|
2885
|
+
* memory proportional to one file rather than to the whole scan.
|
|
2886
|
+
*/
|
|
2887
|
+
parseAst(file) {
|
|
2888
|
+
const sourceFile = parseTsFile(file);
|
|
2889
|
+
if (sourceFile === void 0) return void 0;
|
|
2890
|
+
return {
|
|
2891
|
+
ast: sourceFile,
|
|
2892
|
+
dispose: () => {
|
|
2893
|
+
try {
|
|
2894
|
+
getProject().removeSourceFile(sourceFile);
|
|
2895
|
+
} catch {}
|
|
2896
|
+
}
|
|
2897
|
+
};
|
|
2898
|
+
},
|
|
2382
2899
|
runRules(rules, file, emit, onCrash, budget) {
|
|
2383
|
-
const withAst = {
|
|
2900
|
+
const withAst = file.ast === void 0 ? {
|
|
2384
2901
|
...file,
|
|
2385
2902
|
ast: parseTsFile(file)
|
|
2386
|
-
};
|
|
2903
|
+
} : file;
|
|
2387
2904
|
const withTags = {
|
|
2388
2905
|
...withAst,
|
|
2389
2906
|
frameworkTags: frameworkTagsFromImports(withAst.text)
|
|
@@ -2408,11 +2925,9 @@ const typescriptAdapter = {
|
|
|
2408
2925
|
budget.onExceeded();
|
|
2409
2926
|
return;
|
|
2410
2927
|
}
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
onCrash?.(rule.id, error);
|
|
2415
|
-
}
|
|
2928
|
+
const produced = runRuleIsolated(rule, enriched, onCrash);
|
|
2929
|
+
if (produced === null) continue;
|
|
2930
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
2416
2931
|
}
|
|
2417
2932
|
}
|
|
2418
2933
|
};
|
|
@@ -2440,6 +2955,7 @@ function loadWorkspaceShim(root) {
|
|
|
2440
2955
|
workspaceGlobs: []
|
|
2441
2956
|
};
|
|
2442
2957
|
} catch {
|
|
2958
|
+
recordDegradation("workspace-manifest-unreadable");
|
|
2443
2959
|
return null;
|
|
2444
2960
|
}
|
|
2445
2961
|
}
|
|
@@ -2794,11 +3310,9 @@ const pythonAdapter = {
|
|
|
2794
3310
|
budget.onExceeded();
|
|
2795
3311
|
return;
|
|
2796
3312
|
}
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
onCrash?.(rule.id, error);
|
|
2801
|
-
}
|
|
3313
|
+
const produced = runRuleIsolated(rule, enriched, onCrash);
|
|
3314
|
+
if (produced === null) continue;
|
|
3315
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
2802
3316
|
}
|
|
2803
3317
|
},
|
|
2804
3318
|
async parseAst(file) {
|
|
@@ -2951,11 +3465,9 @@ const javaAdapter = {
|
|
|
2951
3465
|
budget.onExceeded();
|
|
2952
3466
|
return;
|
|
2953
3467
|
}
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
onCrash?.(rule.id, error);
|
|
2958
|
-
}
|
|
3468
|
+
const produced = runRuleIsolated(rule, enriched, onCrash);
|
|
3469
|
+
if (produced === null) continue;
|
|
3470
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
2959
3471
|
}
|
|
2960
3472
|
}
|
|
2961
3473
|
};
|
|
@@ -3109,11 +3621,9 @@ const csharpAdapter = {
|
|
|
3109
3621
|
budget.onExceeded();
|
|
3110
3622
|
return;
|
|
3111
3623
|
}
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
onCrash?.(rule.id, error);
|
|
3116
|
-
}
|
|
3624
|
+
const produced = runRuleIsolated(rule, enriched, onCrash);
|
|
3625
|
+
if (produced === null) continue;
|
|
3626
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
3117
3627
|
}
|
|
3118
3628
|
}
|
|
3119
3629
|
};
|
|
@@ -3291,6 +3801,7 @@ const githubActionsAdapter = {
|
|
|
3291
3801
|
try {
|
|
3292
3802
|
entries = readdirSync(wfDir);
|
|
3293
3803
|
} catch {
|
|
3804
|
+
recordDegradation("ci-workflow-listing-unreadable");
|
|
3294
3805
|
return;
|
|
3295
3806
|
}
|
|
3296
3807
|
for (const name of entries) {
|
|
@@ -3331,15 +3842,13 @@ const githubActionsAdapter = {
|
|
|
3331
3842
|
budget.onExceeded();
|
|
3332
3843
|
return;
|
|
3333
3844
|
}
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
onCrash?.(rule.id, error);
|
|
3342
|
-
}
|
|
3845
|
+
const produced = runRuleIsolated(rule, {
|
|
3846
|
+
path: file.path,
|
|
3847
|
+
text: file.text,
|
|
3848
|
+
ast: doc
|
|
3849
|
+
}, onCrash);
|
|
3850
|
+
if (produced === null) continue;
|
|
3851
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
3343
3852
|
}
|
|
3344
3853
|
}
|
|
3345
3854
|
};
|
|
@@ -3649,15 +4158,13 @@ const azurePipelinesAdapter = {
|
|
|
3649
4158
|
budget.onExceeded();
|
|
3650
4159
|
return;
|
|
3651
4160
|
}
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
onCrash?.(rule.id, error);
|
|
3660
|
-
}
|
|
4161
|
+
const produced = runRuleIsolated(rule, {
|
|
4162
|
+
path: file.path,
|
|
4163
|
+
text: file.text,
|
|
4164
|
+
ast: doc
|
|
4165
|
+
}, onCrash);
|
|
4166
|
+
if (produced === null) continue;
|
|
4167
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
3661
4168
|
}
|
|
3662
4169
|
}
|
|
3663
4170
|
};
|
|
@@ -3728,14 +4235,12 @@ const jenkinsAdapter = {
|
|
|
3728
4235
|
budget.onExceeded();
|
|
3729
4236
|
return;
|
|
3730
4237
|
}
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
onCrash?.(rule.id, error);
|
|
3738
|
-
}
|
|
4238
|
+
const produced = runRuleIsolated(rule, {
|
|
4239
|
+
path: file.path,
|
|
4240
|
+
text: file.text
|
|
4241
|
+
}, onCrash);
|
|
4242
|
+
if (produced === null) continue;
|
|
4243
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
3739
4244
|
}
|
|
3740
4245
|
}
|
|
3741
4246
|
};
|
|
@@ -3802,9 +4307,37 @@ function discoverAllTestFiles(ctx, languageAdapters, buckets, fixtureDirMemo) {
|
|
|
3802
4307
|
}
|
|
3803
4308
|
});
|
|
3804
4309
|
}
|
|
4310
|
+
/**
|
|
4311
|
+
* Would ignoring this file have removed it from the scanned surface?
|
|
4312
|
+
*
|
|
4313
|
+
* The bug this fixes, reproduced: a repo containing a real test file plus
|
|
4314
|
+
* `vendor.min.js` and `pnpm-lock.yaml` reported
|
|
4315
|
+
*
|
|
4316
|
+
* discovered: 1, analyzed: 1, ignored: 2, scopeVerdict: "PARTIAL"
|
|
4317
|
+
*
|
|
4318
|
+
* Every discovered test file HAD been analyzed. The surface was complete. But
|
|
4319
|
+
* DEFAULT_IGNORES matches minified bundles and lockfiles, and the ignored
|
|
4320
|
+
* counter accepted any path with a source extension — so a minified bundle and
|
|
4321
|
+
* a lockfile downgraded a finished scan to "unverified".
|
|
4322
|
+
*
|
|
4323
|
+
* That is not a conservative bias, it is a broken signal. The terminal tells
|
|
4324
|
+
* the reader "some files were not analyzed, so the surface is unverified" —
|
|
4325
|
+
* which was false. And because minified bundles and lockfiles exist in
|
|
4326
|
+
* essentially every real repository, a PROVEN scan was unreachable in
|
|
4327
|
+
* practice, so the corpus regression guard failed 23 of 37 repositories on
|
|
4328
|
+
* exactly this. A gate that cannot pass teaches people to ignore it.
|
|
4329
|
+
*
|
|
4330
|
+
* The asymmetry is the tell. `isUnrecognizedSourceCandidate` below applies a
|
|
4331
|
+
* test-relevance test before counting a file; this one did not. Both count
|
|
4332
|
+
* against the same verdict, so both must ask the same question.
|
|
4333
|
+
*
|
|
4334
|
+
* An ignored file that IS a test file still downgrades the verdict. That is
|
|
4335
|
+
* the case the honesty law exists for, and this does not touch it.
|
|
4336
|
+
*/
|
|
3805
4337
|
function isScopeRelevantIgnored(path) {
|
|
3806
4338
|
const name = path.replaceAll("\\", "/").split("/").pop() ?? path;
|
|
3807
|
-
|
|
4339
|
+
if (!/\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml|min\.js)$/i.test(name)) return false;
|
|
4340
|
+
return isKnownTestFile(path);
|
|
3808
4341
|
}
|
|
3809
4342
|
function isUnrecognizedSourceCandidate(path) {
|
|
3810
4343
|
const normalized = path.replaceAll("\\", "/");
|
|
@@ -3871,82 +4404,6 @@ const SEARCHED_FOR = [
|
|
|
3871
4404
|
}
|
|
3872
4405
|
];
|
|
3873
4406
|
//#endregion
|
|
3874
|
-
//#region src/scope/git-resolve.ts
|
|
3875
|
-
/**
|
|
3876
|
-
* Absolute-path git resolution (audit S1).
|
|
3877
|
-
*
|
|
3878
|
-
* `execFileSync("git", …)` resolves through PATH — including the CWD on
|
|
3879
|
-
* Windows (CreateProcess searches the current directory before PATH for
|
|
3880
|
-
* extension-less names depending on NoDefaultCurrentDirectoryInExePath).
|
|
3881
|
-
* Scanning an UNTRUSTED repo therefore let a checked-in `git.exe` (or
|
|
3882
|
-
* `git.bat`/`git.cmd`) hijack Mjölnir's own git invocations: the attacker
|
|
3883
|
-
* controls the diff output, the merge-base decision, and
|
|
3884
|
-
* `--scope changed`'s "what is new" answer — the exact data CI gates on.
|
|
3885
|
-
*
|
|
3886
|
-
* The fix: resolve git ONCE per process to an ABSOLUTE path by walking
|
|
3887
|
-
* the PATH directly (never consulting the CWD), verify the candidate is
|
|
3888
|
-
* an existing file, and pass that path to execFileSync. Resolution is
|
|
3889
|
-
* memoized; a failure to find a real git anywhere on PATH is recorded
|
|
3890
|
-
* and callers degrade without invoking a bare name.
|
|
3891
|
-
*/
|
|
3892
|
-
let resolvedGit = void 0;
|
|
3893
|
-
/**
|
|
3894
|
-
* The absolute path of the git binary Mjölnir will invoke, or null when
|
|
3895
|
-
* PATH carries no executable `git` at all. Callers must degrade without
|
|
3896
|
-
* invoking a bare name.
|
|
3897
|
-
*/
|
|
3898
|
-
function resolveGitPath() {
|
|
3899
|
-
if (resolvedGit !== void 0) return resolvedGit;
|
|
3900
|
-
const candidates = [process.platform === "win32" ? "git.exe" : "git"];
|
|
3901
|
-
if (process.platform === "win32") {
|
|
3902
|
-
const pathExt = process.env["PATHEXT"]?.split(";").filter(Boolean) ?? [];
|
|
3903
|
-
for (const ext of pathExt) candidates.push(`git${ext.toLowerCase()}`);
|
|
3904
|
-
}
|
|
3905
|
-
const pathValue = process.env["PATH"] ?? "";
|
|
3906
|
-
for (const dir of pathValue.split(delimiter)) {
|
|
3907
|
-
if (dir === "") continue;
|
|
3908
|
-
if (!isAbsolute(dir)) continue;
|
|
3909
|
-
for (const candidate of candidates) {
|
|
3910
|
-
const full = join(dir, candidate);
|
|
3911
|
-
try {
|
|
3912
|
-
if (existsSync(full)) {
|
|
3913
|
-
resolvedGit = full;
|
|
3914
|
-
return resolvedGit;
|
|
3915
|
-
}
|
|
3916
|
-
} catch {}
|
|
3917
|
-
}
|
|
3918
|
-
}
|
|
3919
|
-
`${pathValue.length}`;
|
|
3920
|
-
resolvedGit = null;
|
|
3921
|
-
return resolvedGit;
|
|
3922
|
-
}
|
|
3923
|
-
/**
|
|
3924
|
-
* Run git with the S1-resolved absolute binary path. Returns stdout, or
|
|
3925
|
-
* null when git is missing or the command fails — the same contract as
|
|
3926
|
-
* the previous inline `git()` helpers, with the hijack surface closed.
|
|
3927
|
-
*/
|
|
3928
|
-
function runGit(root, args) {
|
|
3929
|
-
const exe = resolveGitPath();
|
|
3930
|
-
if (!exe) return null;
|
|
3931
|
-
try {
|
|
3932
|
-
return execFileSync(exe, [
|
|
3933
|
-
"-C",
|
|
3934
|
-
root,
|
|
3935
|
-
...args
|
|
3936
|
-
], {
|
|
3937
|
-
encoding: "utf8",
|
|
3938
|
-
stdio: [
|
|
3939
|
-
"ignore",
|
|
3940
|
-
"pipe",
|
|
3941
|
-
"ignore"
|
|
3942
|
-
],
|
|
3943
|
-
timeout: 15e3
|
|
3944
|
-
});
|
|
3945
|
-
} catch {
|
|
3946
|
-
return null;
|
|
3947
|
-
}
|
|
3948
|
-
}
|
|
3949
|
-
//#endregion
|
|
3950
4407
|
//#region src/scope/changed.ts
|
|
3951
4408
|
/**
|
|
3952
4409
|
* Changed-scope engine (Sprint-Plan W6, Product-MVP §9 `--scope changed`).
|
|
@@ -7691,7 +8148,6 @@ const pyBareTruthinessAssert = defineRule({
|
|
|
7691
8148
|
const re = /^[ \t]*assert\s+([A-Za-z_][\w.]*(?:\([^()]*\))?)[ \t]*$/gm;
|
|
7692
8149
|
const predicateRe = /^(?:(?:any|all|isinstance)\s*\(|[\w.]*\.(?:startswith|endswith|exists|isdir|isfile|islink|ismount|check|isdigit|isalpha|isalnum|isnumeric|isdecimal|isspace|islower|isupper|istitle|isidentifier|isprintable|isascii)\s*\(|re\.(?:match|search|fullmatch)\s*\()/;
|
|
7693
8150
|
const isGuardFollowedByRealUse = (text, matchIndex, target) => {
|
|
7694
|
-
const root = target.split(".")[0];
|
|
7695
8151
|
const lineEnd = text.indexOf("\n", matchIndex);
|
|
7696
8152
|
if (lineEnd === -1) return false;
|
|
7697
8153
|
const lines = text.slice(lineEnd + 1).split("\n");
|
|
@@ -7701,8 +8157,11 @@ const pyBareTruthinessAssert = defineRule({
|
|
|
7701
8157
|
if (/^\s*def\s/.test(l) && window.length > 0) break;
|
|
7702
8158
|
window.push(l);
|
|
7703
8159
|
}
|
|
7704
|
-
const
|
|
7705
|
-
|
|
8160
|
+
const root = target.match(/^[a-z_]\w*/i)?.[0];
|
|
8161
|
+
if (!root) return false;
|
|
8162
|
+
return window.some((line) => {
|
|
8163
|
+
return (line.match(/[a-z_]\w*/gi) ?? []).some((token) => token === root);
|
|
8164
|
+
});
|
|
7706
8165
|
};
|
|
7707
8166
|
let m;
|
|
7708
8167
|
while ((m = re.exec(text)) !== null) {
|
|
@@ -11426,6 +11885,53 @@ const RETIRED_RULE_IDS = [
|
|
|
11426
11885
|
"QA-CS-111"
|
|
11427
11886
|
];
|
|
11428
11887
|
//#endregion
|
|
11888
|
+
//#region src/lib/compare.ts
|
|
11889
|
+
/**
|
|
11890
|
+
* Order that does not depend on who is asking.
|
|
11891
|
+
*
|
|
11892
|
+
* `String.prototype.localeCompare` with no locale argument resolves against
|
|
11893
|
+
* the PROCESS's default locale — the ICU collation chosen by `LANG`,
|
|
11894
|
+
* `LC_ALL` or the OS. So a sort written as
|
|
11895
|
+
* `[...xs].sort((a, b) => a.localeCompare(b))` produces a different order on
|
|
11896
|
+
* a developer's `de_DE` machine than on an en-US CI runner. For a display
|
|
11897
|
+
* list that is a nuisance; for anything that reaches a hash, a cache key, a
|
|
11898
|
+
* digest or a machine contract it is a correctness bug, because the same
|
|
11899
|
+
* repository then produces two different fingerprints.
|
|
11900
|
+
*
|
|
11901
|
+
* Two comparators, one per kind of site:
|
|
11902
|
+
*
|
|
11903
|
+
* compareCodePoints — pure UTF-16 code-unit order. Identical everywhere,
|
|
11904
|
+
* independent of ICU version, and already what `Array.prototype.sort`
|
|
11905
|
+
* defaults to. Use for every value that reaches a fingerprint, a digest,
|
|
11906
|
+
* a cache key, or a contract, and for any ordering with no human-facing
|
|
11907
|
+
* reason to be pretty.
|
|
11908
|
+
*
|
|
11909
|
+
* compareLocalized(locale) — the same collator, PINNED to a named locale
|
|
11910
|
+
* instead of inheriting the environment, for a surface that genuinely
|
|
11911
|
+
* wants readable order (a table of rule titles, a Mermaid legend). Pinned
|
|
11912
|
+
* rather than removed because a reader-facing list of "Test / Logic /
|
|
11913
|
+
* Mock" should not be ordered by code unit.
|
|
11914
|
+
*
|
|
11915
|
+
* A bare `localeCompare` with no second argument in `src/` is a defect and
|
|
11916
|
+
* `tests/contract/deterministic-ordering.spec.ts` fails the build on it.
|
|
11917
|
+
*/
|
|
11918
|
+
/** Code-unit order. Locale-free, ICU-free, and the Array.sort default. */
|
|
11919
|
+
function compareCodePoints(a, b) {
|
|
11920
|
+
if (a === b) return 0;
|
|
11921
|
+
return a < b ? -1 : 1;
|
|
11922
|
+
}
|
|
11923
|
+
/**
|
|
11924
|
+
* A readable-order comparator pinned to `locale`.
|
|
11925
|
+
*
|
|
11926
|
+
* The pin is the point: `a.localeCompare(b)` reads the environment, and the
|
|
11927
|
+
* environment is a machine setting, not a property of the code. Callers that
|
|
11928
|
+
* want stable output ask for a named locale and get the same answer on every
|
|
11929
|
+
* machine that ships the same ICU data.
|
|
11930
|
+
*/
|
|
11931
|
+
function compareLocalized(locale) {
|
|
11932
|
+
return (a, b) => a.localeCompare(b, locale);
|
|
11933
|
+
}
|
|
11934
|
+
//#endregion
|
|
11429
11935
|
//#region src/scorer/scorer.ts
|
|
11430
11936
|
/**
|
|
11431
11937
|
* Transparent scorer (Product-MVP §8).
|
|
@@ -11482,7 +11988,7 @@ function computeDimensions(findings) {
|
|
|
11482
11988
|
deductions.set(f.category, deductions.get(f.category) + deductionFor(f));
|
|
11483
11989
|
}
|
|
11484
11990
|
for (const dim of byCategory.values()) dim.score = Math.max(0, 100 - deductions.get(dim.category));
|
|
11485
|
-
return [...byCategory.values()].sort((a, b) => a.category
|
|
11991
|
+
return [...byCategory.values()].sort((a, b) => compareCodePoints(a.category, b.category));
|
|
11486
11992
|
}
|
|
11487
11993
|
/**
|
|
11488
11994
|
* Deduction-mass ceilings (product-gap-remediation master plan P2,
|
|
@@ -11705,6 +12211,22 @@ function normalizeOne(report, artifact, v) {
|
|
|
11705
12211
|
function buildEvidenceRecords(report, artifact) {
|
|
11706
12212
|
return report.verdicts.map((v) => normalizeOne(report, artifact, v)).sort(compareEvidenceRecords);
|
|
11707
12213
|
}
|
|
12214
|
+
function countEvidence(records) {
|
|
12215
|
+
const counts = {
|
|
12216
|
+
total: records.length,
|
|
12217
|
+
failed: 0,
|
|
12218
|
+
flaky: 0,
|
|
12219
|
+
skipped: 0,
|
|
12220
|
+
timedOut: 0
|
|
12221
|
+
};
|
|
12222
|
+
for (const r of records) {
|
|
12223
|
+
if (r.status.failed) counts.failed++;
|
|
12224
|
+
if (r.status.passedOnRetry) counts.flaky++;
|
|
12225
|
+
if (r.status.skipped) counts.skipped++;
|
|
12226
|
+
if (r.status.timedOut) counts.timedOut++;
|
|
12227
|
+
}
|
|
12228
|
+
return counts;
|
|
12229
|
+
}
|
|
11708
12230
|
//#endregion
|
|
11709
12231
|
//#region src/engine/provenance.ts
|
|
11710
12232
|
const GENERATED_HEADER_RE = /^\s*(?:\/\/|#|\/\*)\s*(?:auto[- ]?generated|generated by|do not edit)/i;
|
|
@@ -11908,7 +12430,7 @@ function groupByRootCause(findings) {
|
|
|
11908
12430
|
function correlateFindings(findings) {
|
|
11909
12431
|
const conclusions = [];
|
|
11910
12432
|
const rootCauseGroups = groupByRootCause(findings);
|
|
11911
|
-
for (const [rootCause, group] of Array.from(rootCauseGroups.entries()).sort((a, b) => a[0]
|
|
12433
|
+
for (const [rootCause, group] of Array.from(rootCauseGroups.entries()).sort((a, b) => compareLocalized("en")(a[0], b[0]))) {
|
|
11912
12434
|
if (group.length < 2) continue;
|
|
11913
12435
|
const findingIds = group.map((f) => f.findingId ?? f.ruleId);
|
|
11914
12436
|
conclusions.push({
|
|
@@ -11920,7 +12442,7 @@ function correlateFindings(findings) {
|
|
|
11920
12442
|
});
|
|
11921
12443
|
}
|
|
11922
12444
|
const fileGroups = groupByFile(findings);
|
|
11923
|
-
for (const [file, group] of Array.from(fileGroups.entries()).sort((a, b) => a[0]
|
|
12445
|
+
for (const [file, group] of Array.from(fileGroups.entries()).sort((a, b) => compareCodePoints(a[0], b[0]))) {
|
|
11924
12446
|
if (group.length < 2) continue;
|
|
11925
12447
|
const alreadyConvergent = /* @__PURE__ */ new Set();
|
|
11926
12448
|
for (const c of conclusions) if (c.conclusionType === "CONVERGENT") for (const id of c.findingIds) alreadyConvergent.add(id);
|
|
@@ -11992,6 +12514,11 @@ var DependencyGraph = class {
|
|
|
11992
12514
|
get size() {
|
|
11993
12515
|
return this.nodes.size;
|
|
11994
12516
|
}
|
|
12517
|
+
/** Whether the graph knows this exact key. Reachability must not
|
|
12518
|
+
* treat "not in the graph" as "nothing to traverse" without saying so. */
|
|
12519
|
+
has(path) {
|
|
12520
|
+
return this.nodes.has(path);
|
|
12521
|
+
}
|
|
11995
12522
|
};
|
|
11996
12523
|
function parsePackageJson(filePath) {
|
|
11997
12524
|
if (!existsSync(filePath)) return void 0;
|
|
@@ -12079,20 +12606,40 @@ function parseManifest(filePath) {
|
|
|
12079
12606
|
if (filePath.endsWith("pom.xml")) return parsePomXml(filePath);
|
|
12080
12607
|
}
|
|
12081
12608
|
/**
|
|
12082
|
-
* Given a set of starting files (e.g. test files), return
|
|
12083
|
-
* transitively reachable through the dependency graph
|
|
12609
|
+
* Given a set of starting files (e.g. test files), return the files
|
|
12610
|
+
* transitively reachable through the dependency graph, plus an honest
|
|
12611
|
+
* account of what could not be resolved.
|
|
12612
|
+
*
|
|
12613
|
+
* KNOWN LIMITATION (BW-022, still open): the graph is keyed by manifest path
|
|
12614
|
+
* and this query is given source paths, so today `unresolvedStarts` is
|
|
12615
|
+
* normally every input. The traversal itself is correct for a graph whose
|
|
12616
|
+
* keys are the paths being queried — which is what the unit tests build, and
|
|
12617
|
+
* which is why they passed while production did nothing. Fixing the seam
|
|
12618
|
+
* means giving a file a resolvable owning manifest and deciding impact's
|
|
12619
|
+
* direction (dependents, not dependencies), which is a design change, not a
|
|
12620
|
+
* patch. Until then this function reports that it resolved nothing instead
|
|
12621
|
+
* of implying otherwise.
|
|
12084
12622
|
*/
|
|
12085
12623
|
function getReachableFiles(fromFiles, graph) {
|
|
12086
12624
|
const reachable = /* @__PURE__ */ new Set();
|
|
12625
|
+
const unresolvedStarts = [];
|
|
12087
12626
|
const stack = [...fromFiles];
|
|
12088
12627
|
while (stack.length > 0) {
|
|
12089
12628
|
const current = stack.pop();
|
|
12090
12629
|
if (current === void 0) continue;
|
|
12091
12630
|
if (reachable.has(current)) continue;
|
|
12092
|
-
reachable.add(current);
|
|
12631
|
+
if (graph.has(current)) reachable.add(current);
|
|
12632
|
+
else if (fromFiles.includes(current)) {
|
|
12633
|
+
unresolvedStarts.push(current);
|
|
12634
|
+
continue;
|
|
12635
|
+
}
|
|
12093
12636
|
for (const dep of graph.getDependencies(current)) if (!reachable.has(dep)) stack.push(dep);
|
|
12094
12637
|
}
|
|
12095
|
-
return
|
|
12638
|
+
return {
|
|
12639
|
+
reachable: [...reachable].sort(),
|
|
12640
|
+
unresolvedStarts: unresolvedStarts.sort(),
|
|
12641
|
+
resolvedAny: unresolvedStarts.length < fromFiles.length
|
|
12642
|
+
};
|
|
12096
12643
|
}
|
|
12097
12644
|
//#endregion
|
|
12098
12645
|
//#region src/engine/incremental-analysis.ts
|
|
@@ -12132,739 +12679,935 @@ function isIncrementalSafe(changedFiles) {
|
|
|
12132
12679
|
};
|
|
12133
12680
|
}
|
|
12134
12681
|
//#endregion
|
|
12135
|
-
//#region src/
|
|
12136
|
-
const WORTHY_THRESHOLD = 80;
|
|
12137
|
-
const NEEDS_WORK_THRESHOLD = 50;
|
|
12138
|
-
function verdictOf(score) {
|
|
12139
|
-
if (score === null) return "fail";
|
|
12140
|
-
if (score >= WORTHY_THRESHOLD) return "pass";
|
|
12141
|
-
if (score >= NEEDS_WORK_THRESHOLD) return "warn";
|
|
12142
|
-
return "fail";
|
|
12143
|
-
}
|
|
12144
|
-
function hasBlocker(findings) {
|
|
12145
|
-
return findings.some((f) => f.severity === "error");
|
|
12146
|
-
}
|
|
12682
|
+
//#region src/brand/tokens.ts
|
|
12147
12683
|
/**
|
|
12148
|
-
*
|
|
12684
|
+
* The single source of brand truth.
|
|
12149
12685
|
*
|
|
12150
|
-
*
|
|
12151
|
-
*
|
|
12152
|
-
*
|
|
12153
|
-
*
|
|
12686
|
+
* Every colour, typeface and motion constant Mjölnir shows a human —
|
|
12687
|
+
* terminal, README SVGs, demo video, website, docs, badges — resolves to
|
|
12688
|
+
* a value in this file. Nothing else may define one.
|
|
12689
|
+
*
|
|
12690
|
+
* WHY THIS EXISTS. Before it, the palette existed in six independent
|
|
12691
|
+
* copies: `site/.vitepress/theme/styles/vars.css`, `NORSE` in
|
|
12692
|
+
* `src/reporter/theme.ts`, `scripts/readme-svg.ts`,
|
|
12693
|
+
* `scripts/video/terminal-page.ts`, `scripts/generate-readme-architecture.ts`
|
|
12694
|
+
* and the table in `assets/brand/README.md`. Exactly one pair of those
|
|
12695
|
+
* was guarded (site-doctor Check 8, doc ↔ vars.css). The unguarded edges
|
|
12696
|
+
* are where the shipped surfaces drifted apart: the terminal and the site
|
|
12697
|
+
* disagreed on six semantic roles, the architecture diagram invented its
|
|
12698
|
+
* own neutral ramp, and the README badges still carried a palette retired
|
|
12699
|
+
* two releases earlier. `scripts/brand-doctor.mjs` now checks every edge
|
|
12700
|
+
* against this file.
|
|
12701
|
+
*
|
|
12702
|
+
* PURITY. Pure data. No I/O, no rendering, no environment access, no
|
|
12703
|
+
* imports, no logic. Consumers convert (hex → ANSI triplet, hex → CSS)
|
|
12704
|
+
* themselves. Same reason `presentation.ts` is pure: it makes the whole
|
|
12705
|
+
* thing golden-testable and safe to ship inside the npm package, where
|
|
12706
|
+
* it costs a few hundred bytes and replaces values the package already
|
|
12707
|
+
* carried anyway.
|
|
12708
|
+
*
|
|
12709
|
+
* DERIVATION. The palette is the one derived from the logo in PR #20
|
|
12710
|
+
* (brushed steel and forge gold under an aurora, over midnight iron).
|
|
12711
|
+
* Where the terminal disagreed with it, the terminal converges — see
|
|
12712
|
+
* `PENDING_TERMINAL` below. Full rationale: `assets/brand/README.md`.
|
|
12713
|
+
*
|
|
12714
|
+
* ACCESSIBILITY. Every foreground token in `BRAND` meets WCAG AA
|
|
12715
|
+
* (≥ 4.5:1) against every surface token it is allowed to sit on. That is
|
|
12716
|
+
* not a claim, it is `brand-doctor` rule 8, which computes the ratios.
|
|
12717
|
+
* The weakest legal pairing is `steelDim` on `ink800` at 5.00:1.
|
|
12154
12718
|
*/
|
|
12155
|
-
function analyzeMonorepo(packages, config) {
|
|
12156
|
-
const results = packages.map((p) => ({
|
|
12157
|
-
...p,
|
|
12158
|
-
verdict: hasBlocker(p.findings) ? "fail" : verdictOf(p.score)
|
|
12159
|
-
}));
|
|
12160
|
-
if (results.length === 0) return {
|
|
12161
|
-
packages: results,
|
|
12162
|
-
overallScore: null,
|
|
12163
|
-
overallVerdict: "fail",
|
|
12164
|
-
strategy: config.weightingStrategy
|
|
12165
|
-
};
|
|
12166
|
-
const blockerPkg = results.find((r) => r.verdict === "fail" || hasBlocker(r.findings));
|
|
12167
|
-
if (blockerPkg) return {
|
|
12168
|
-
packages: results,
|
|
12169
|
-
overallScore: blockerPkg.score,
|
|
12170
|
-
overallVerdict: "fail",
|
|
12171
|
-
strategy: config.weightingStrategy,
|
|
12172
|
-
blockerPackage: blockerPkg.packageName
|
|
12173
|
-
};
|
|
12174
|
-
switch (config.weightingStrategy) {
|
|
12175
|
-
case "worst-package": return worstPackageAggregation(results, config);
|
|
12176
|
-
case "average": return averageAggregation(results, config);
|
|
12177
|
-
case "configurable": return configurableAggregation(results, config);
|
|
12178
|
-
}
|
|
12179
|
-
}
|
|
12180
|
-
function worstPackageAggregation(results, config) {
|
|
12181
|
-
const firstResult = results[0];
|
|
12182
|
-
if (firstResult === void 0) return {
|
|
12183
|
-
packages: results,
|
|
12184
|
-
overallScore: null,
|
|
12185
|
-
overallVerdict: "fail",
|
|
12186
|
-
strategy: config.weightingStrategy
|
|
12187
|
-
};
|
|
12188
|
-
let worst = firstResult;
|
|
12189
|
-
for (const r of results) if ((r.score ?? 0) < (worst.score ?? 0)) worst = r;
|
|
12190
|
-
return {
|
|
12191
|
-
packages: results,
|
|
12192
|
-
overallScore: worst.score,
|
|
12193
|
-
overallVerdict: worst.verdict,
|
|
12194
|
-
strategy: config.weightingStrategy,
|
|
12195
|
-
...worst.verdict === "fail" ? { blockerPackage: worst.packageName } : {}
|
|
12196
|
-
};
|
|
12197
|
-
}
|
|
12198
|
-
function averageAggregation(results, config) {
|
|
12199
|
-
const scored = results.filter((r) => r.score !== null);
|
|
12200
|
-
if (scored.length === 0) return {
|
|
12201
|
-
packages: results,
|
|
12202
|
-
overallScore: null,
|
|
12203
|
-
overallVerdict: "fail",
|
|
12204
|
-
strategy: config.weightingStrategy
|
|
12205
|
-
};
|
|
12206
|
-
const avg = scored.reduce((sum, r) => sum + (r.score ?? 0), 0) / scored.length;
|
|
12207
|
-
return {
|
|
12208
|
-
packages: results,
|
|
12209
|
-
overallScore: Math.round(avg),
|
|
12210
|
-
overallVerdict: verdictOf(Math.round(avg)),
|
|
12211
|
-
strategy: config.weightingStrategy
|
|
12212
|
-
};
|
|
12213
|
-
}
|
|
12214
|
-
function configurableAggregation(results, config) {
|
|
12215
|
-
const weights = config.packageWeights ?? {};
|
|
12216
|
-
let totalWeight = 0;
|
|
12217
|
-
let weightedSum = 0;
|
|
12218
|
-
for (const r of results) {
|
|
12219
|
-
const w = weights[r.packageName] ?? 1;
|
|
12220
|
-
totalWeight += w;
|
|
12221
|
-
weightedSum += (r.score ?? 0) * w;
|
|
12222
|
-
}
|
|
12223
|
-
if (totalWeight === 0) return {
|
|
12224
|
-
packages: results,
|
|
12225
|
-
overallScore: null,
|
|
12226
|
-
overallVerdict: "fail",
|
|
12227
|
-
strategy: config.weightingStrategy
|
|
12228
|
-
};
|
|
12229
|
-
const score = Math.round(weightedSum / totalWeight);
|
|
12230
|
-
return {
|
|
12231
|
-
packages: results,
|
|
12232
|
-
overallScore: score,
|
|
12233
|
-
overallVerdict: verdictOf(score),
|
|
12234
|
-
strategy: config.weightingStrategy
|
|
12235
|
-
};
|
|
12236
|
-
}
|
|
12237
|
-
//#endregion
|
|
12238
|
-
//#region src/lib/fs-atomic.ts
|
|
12239
12719
|
/**
|
|
12240
|
-
*
|
|
12720
|
+
* The two brand hues plus the neutral they sit on.
|
|
12241
12721
|
*
|
|
12242
|
-
*
|
|
12243
|
-
*
|
|
12244
|
-
*
|
|
12245
|
-
*
|
|
12246
|
-
* confident nonsense from it.
|
|
12722
|
+
* GOLD IS SCARCE. It means forged / certified / earned / decisive — the
|
|
12723
|
+
* primary mark, the FORGED state, one call to action. It is not a paint
|
|
12724
|
+
* bucket: gold as default text, default border or default heading is a
|
|
12725
|
+
* brand-doctor finding, not a style choice.
|
|
12247
12726
|
*
|
|
12248
|
-
*
|
|
12249
|
-
*
|
|
12250
|
-
* the complete new file, never a half-written one. The temp name is
|
|
12251
|
-
* created with `wx` (exclusive) so concurrent writers cannot interleave,
|
|
12252
|
-
* stale temps are cleaned up on failure, and on Windows the rename is
|
|
12253
|
-
* retried briefly because a concurrent reader can hold the destination
|
|
12254
|
-
* open (EBUSY/EPERM).
|
|
12727
|
+
* AURORA is verification energy — the secondary, and the hue that marks
|
|
12728
|
+
* the runtime half of the trust ladder.
|
|
12255
12729
|
*/
|
|
12256
|
-
|
|
12257
|
-
|
|
12258
|
-
|
|
12730
|
+
const BRAND = {
|
|
12731
|
+
gold: "#C19A34",
|
|
12732
|
+
goldBright: "#E6BD57",
|
|
12733
|
+
goldHot: "#F4DC9C",
|
|
12734
|
+
/** Pressed / deepest gold — the only step dark enough to carry white. */
|
|
12735
|
+
goldDeep: "#A5811C",
|
|
12736
|
+
aurora: "#37ABBD",
|
|
12737
|
+
auroraBright: "#45C1D4",
|
|
12738
|
+
auroraCyan: "#5CBDE0",
|
|
12739
|
+
/** The aurora's outer curtains: atmosphere and section identity only,
|
|
12740
|
+
* never a verdict or a status. */
|
|
12741
|
+
auroraGreen: "#5FD6A4",
|
|
12742
|
+
auroraViolet: "#9D8CF5",
|
|
12743
|
+
steel: "#C8CBCF",
|
|
12744
|
+
steelDim: "#8B939D"
|
|
12745
|
+
};
|
|
12259
12746
|
/**
|
|
12260
|
-
*
|
|
12747
|
+
* Midnight iron. One ramp, four steps, darkest first.
|
|
12748
|
+
*
|
|
12749
|
+
* `terminal` and `terminalBar` share one tone deliberately: the window's
|
|
12750
|
+
* only seam is a hairline ring and an inset shadow, never a second fill.
|
|
12751
|
+
* `chromeDot` is the three window dots — see the note on
|
|
12752
|
+
* `PENDING_TERMINAL.chromeDots` for why they are no longer red/amber/green.
|
|
12261
12753
|
*/
|
|
12262
|
-
|
|
12263
|
-
|
|
12264
|
-
|
|
12265
|
-
|
|
12266
|
-
|
|
12267
|
-
|
|
12268
|
-
|
|
12269
|
-
|
|
12270
|
-
|
|
12271
|
-
|
|
12272
|
-
|
|
12273
|
-
|
|
12274
|
-
|
|
12275
|
-
|
|
12276
|
-
|
|
12277
|
-
|
|
12278
|
-
|
|
12279
|
-
|
|
12280
|
-
|
|
12281
|
-
|
|
12754
|
+
const SURFACE = {
|
|
12755
|
+
ink950: "#0A1119",
|
|
12756
|
+
ink900: "#0C1420",
|
|
12757
|
+
ink850: "#111A29",
|
|
12758
|
+
ink800: "#18243A",
|
|
12759
|
+
/** Raised panel (cards, elevated surfaces). */
|
|
12760
|
+
panel: "#141F33",
|
|
12761
|
+
/** Soft fill (inline code, quiet chips). */
|
|
12762
|
+
soft: "#1A2740",
|
|
12763
|
+
/** Terminal body — the deepest tone, so a terminal reads as recessed. */
|
|
12764
|
+
terminal: "#0A1119",
|
|
12765
|
+
/** Terminal title bar — the same tone; the seam is shadow, not colour. */
|
|
12766
|
+
terminalBar: "#0A1119",
|
|
12767
|
+
/** The three window dots. One neutral, not a traffic light. */
|
|
12768
|
+
chromeDot: "#18243A"
|
|
12769
|
+
};
|
|
12770
|
+
const TEXT = {
|
|
12771
|
+
primary: "#EAEEF5",
|
|
12772
|
+
secondary: "#ABB6C6",
|
|
12773
|
+
muted: "#8B939D",
|
|
12774
|
+
/** Ink for text set ON gold (buttons, the FORGED chip). 7.17:1 on `gold`. */
|
|
12775
|
+
onGold: "#0A1119"
|
|
12776
|
+
};
|
|
12282
12777
|
/**
|
|
12283
|
-
*
|
|
12284
|
-
*
|
|
12285
|
-
*
|
|
12286
|
-
*
|
|
12778
|
+
* Non-score status. `ok` is the one green in the system and it is NOT a
|
|
12779
|
+
* score colour — it survives only for contexts with no worthiness
|
|
12780
|
+
* meaning ("autofix applied", "analysis complete"). A green score would
|
|
12781
|
+
* say "your software is fine", which is the exact claim this product
|
|
12782
|
+
* refuses to make.
|
|
12783
|
+
*/
|
|
12784
|
+
const STATUS = {
|
|
12785
|
+
ok: "#4FB477",
|
|
12786
|
+
info: "#5CC4E0",
|
|
12787
|
+
warning: "#E6BD57",
|
|
12788
|
+
error: "#EC6B66"
|
|
12789
|
+
};
|
|
12790
|
+
/**
|
|
12791
|
+
* The four ScoreState bands plus the unmeasured state. Band thresholds
|
|
12792
|
+
* and runes live in `src/reporter/presentation.ts`, which stays free of
|
|
12793
|
+
* colour — it emits a palette KEY and each surface resolves it here.
|
|
12287
12794
|
*
|
|
12288
|
-
*
|
|
12289
|
-
*
|
|
12290
|
-
*
|
|
12795
|
+
* `unmeasured` is steel-dim on purpose. UNKNOWN is a legitimate answer,
|
|
12796
|
+
* not a failure: colouring it red would make "we did not measure this"
|
|
12797
|
+
* look like "this is broken", which is precisely the dishonesty the
|
|
12798
|
+
* north-star law exists to prevent.
|
|
12291
12799
|
*/
|
|
12292
|
-
const
|
|
12293
|
-
|
|
12294
|
-
|
|
12295
|
-
|
|
12296
|
-
|
|
12297
|
-
|
|
12298
|
-
|
|
12299
|
-
const until = Date.now() + ms;
|
|
12300
|
-
while (Date.now() < until);
|
|
12301
|
-
}
|
|
12302
|
-
}
|
|
12303
|
-
function renameWithWindowsRetry(from, to) {
|
|
12304
|
-
for (let attempt = 0;; attempt++) try {
|
|
12305
|
-
renameSync(from, to);
|
|
12306
|
-
return;
|
|
12307
|
-
} catch (err) {
|
|
12308
|
-
const code = err?.code;
|
|
12309
|
-
if (process.platform === "win32" && (code === "EBUSY" || code === "EPERM") && attempt < RENAME_RETRIES) {
|
|
12310
|
-
sleepSync(RENAME_RETRY_DELAY_MS);
|
|
12311
|
-
continue;
|
|
12312
|
-
}
|
|
12313
|
-
throw err;
|
|
12314
|
-
}
|
|
12315
|
-
}
|
|
12316
|
-
const STALE_TEMP_AGE_MS = 864e5;
|
|
12317
|
-
const TEMP_PID_RE = /^.+\.mjolnir-([1-9]\d{0,9})-(\d{13})-[0-9a-f]{8}\.tmp$/;
|
|
12318
|
-
function sweepStaleTempFiles(dir) {
|
|
12319
|
-
let swept = 0;
|
|
12320
|
-
let entries;
|
|
12321
|
-
try {
|
|
12322
|
-
entries = readdirSync(dir);
|
|
12323
|
-
} catch {
|
|
12324
|
-
return 0;
|
|
12325
|
-
}
|
|
12326
|
-
const cutoff = Date.now() - STALE_TEMP_AGE_MS;
|
|
12327
|
-
for (const entry of entries) {
|
|
12328
|
-
const match = TEMP_PID_RE.exec(entry);
|
|
12329
|
-
if (!match || Number(match[2]) > cutoff) continue;
|
|
12330
|
-
const path = join(dir, entry);
|
|
12331
|
-
try {
|
|
12332
|
-
const stat = lstatSync(path);
|
|
12333
|
-
if (!stat.isFile() || stat.mtimeMs > cutoff) continue;
|
|
12334
|
-
if (pidAlive(Number(match[1]))) continue;
|
|
12335
|
-
unlinkSync(path);
|
|
12336
|
-
swept++;
|
|
12337
|
-
} catch {
|
|
12338
|
-
continue;
|
|
12339
|
-
}
|
|
12340
|
-
}
|
|
12341
|
-
return swept;
|
|
12342
|
-
}
|
|
12343
|
-
function pidAlive(pid) {
|
|
12344
|
-
if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 2147483647) return true;
|
|
12345
|
-
if (pid === process.pid) return true;
|
|
12346
|
-
try {
|
|
12347
|
-
process.kill(pid, 0);
|
|
12348
|
-
return true;
|
|
12349
|
-
} catch (err) {
|
|
12350
|
-
return err?.code !== "ESRCH";
|
|
12351
|
-
}
|
|
12352
|
-
}
|
|
12353
|
-
//#endregion
|
|
12354
|
-
//#region src/engine/scan-cache.ts
|
|
12800
|
+
const SCORE = {
|
|
12801
|
+
critical: "#EC6B66",
|
|
12802
|
+
warning: "#E6BD57",
|
|
12803
|
+
trusted: "#5CC4E0",
|
|
12804
|
+
forged: "#F4DC9C",
|
|
12805
|
+
unmeasured: "#8B939D"
|
|
12806
|
+
};
|
|
12355
12807
|
/**
|
|
12356
|
-
*
|
|
12808
|
+
* E0 → E1 → E2 is a certainty ramp, and it is deliberately HUE-FREE.
|
|
12357
12809
|
*
|
|
12358
|
-
*
|
|
12359
|
-
*
|
|
12360
|
-
*
|
|
12361
|
-
*
|
|
12362
|
-
* (repo-relative path + adapter id + parse mode — audit C1/W9), where
|
|
12363
|
-
* the rules digest folds
|
|
12364
|
-
* in every active rule's id + `detectorRevision ?? 1` (the existing
|
|
12365
|
-
* stale-measurement machinery — Verification Trust Evolution Plan §07 —
|
|
12366
|
-
* reused as the cache invalidation signal, per A-2) plus a source hash
|
|
12367
|
-
* of each external plugin/local rule's `run` function, so a plugin that
|
|
12368
|
-
* changes code without bumping its revision still misses.
|
|
12810
|
+
* Evidence level says how sure we are, not whether the news is good. A
|
|
12811
|
+
* deterministic proof (E2) is a defect we are certain about — painting
|
|
12812
|
+
* it gold or green would read as an achievement. So certainty is carried
|
|
12813
|
+
* by brightness alone, and the *shape* does the real work:
|
|
12369
12814
|
*
|
|
12370
|
-
*
|
|
12371
|
-
*
|
|
12372
|
-
*
|
|
12815
|
+
* E0 open ring observation, no weight
|
|
12816
|
+
* E1 half-filled pattern evidence, half weight
|
|
12817
|
+
* E2 sealed deterministic proof, full weight
|
|
12373
12818
|
*
|
|
12374
|
-
*
|
|
12375
|
-
*
|
|
12376
|
-
* tier policy, runtime corroboration, scoring) re-runs on every scan,
|
|
12377
|
-
* so a cached scan is byte-equivalent to a fresh one by construction.
|
|
12819
|
+
* Colour never carries this alone (R11): the geometry is the signal and
|
|
12820
|
+
* survives `--ascii`, `NO_COLOR` and monochrome print.
|
|
12378
12821
|
*/
|
|
12379
|
-
const
|
|
12380
|
-
|
|
12381
|
-
|
|
12822
|
+
const EVIDENCE = {
|
|
12823
|
+
e0: "#8B939D",
|
|
12824
|
+
e1: "#ABB6C6",
|
|
12825
|
+
e2: "#EAEEF5"
|
|
12826
|
+
};
|
|
12382
12827
|
/**
|
|
12383
|
-
*
|
|
12384
|
-
*
|
|
12385
|
-
*
|
|
12386
|
-
*
|
|
12387
|
-
|
|
12388
|
-
|
|
12389
|
-
|
|
12390
|
-
|
|
12391
|
-
|
|
12392
|
-
|
|
12828
|
+
* L0–L5, and the most important boundary in the product.
|
|
12829
|
+
*
|
|
12830
|
+
* L0–L2 are STATIC: the neutral steel ramp, brightening to the static
|
|
12831
|
+
* ceiling at L2. L3–L5 require a real run, and the hue changes to aurora
|
|
12832
|
+
* exactly there. The boundary is a hue break, not a gradient step,
|
|
12833
|
+
* because it is a change of kind and not of degree — a static-only
|
|
12834
|
+
* finding can never climb past L2, however confident it is.
|
|
12835
|
+
*
|
|
12836
|
+
* Every surface that draws the ladder must draw that break.
|
|
12837
|
+
*/
|
|
12838
|
+
const TRUST = {
|
|
12839
|
+
l0: "#8B939D",
|
|
12840
|
+
l1: "#ABB6C6",
|
|
12841
|
+
l2: "#C8CBCF",
|
|
12842
|
+
l3: "#37ABBD",
|
|
12843
|
+
l4: "#45C1D4",
|
|
12844
|
+
l5: "#5CC4E0"
|
|
12845
|
+
};
|
|
12846
|
+
const TINT = {
|
|
12847
|
+
gold: {
|
|
12848
|
+
fill: "#F6EBCC",
|
|
12849
|
+
stroke: "#7A5F16",
|
|
12850
|
+
text: "#4A3A0E"
|
|
12851
|
+
},
|
|
12852
|
+
aurora: {
|
|
12853
|
+
fill: "#D9F0F4",
|
|
12854
|
+
stroke: "#1F6F7C",
|
|
12855
|
+
text: "#10353C"
|
|
12856
|
+
},
|
|
12857
|
+
error: {
|
|
12858
|
+
fill: "#FADEDD",
|
|
12859
|
+
stroke: "#A83A35",
|
|
12860
|
+
text: "#4E1B19"
|
|
12861
|
+
},
|
|
12862
|
+
/** The unmeasured / unknown state. Neutral, never the error tint. */
|
|
12863
|
+
neutral: {
|
|
12864
|
+
fill: "#E4E7EB",
|
|
12865
|
+
stroke: "#5C646E",
|
|
12866
|
+
text: "#262B31"
|
|
12867
|
+
},
|
|
12868
|
+
ok: {
|
|
12869
|
+
fill: "#DCF0E4",
|
|
12870
|
+
stroke: "#276B45",
|
|
12871
|
+
text: "#163A26"
|
|
12872
|
+
}
|
|
12873
|
+
};
|
|
12874
|
+
/**
|
|
12875
|
+
* The score bands as the badge Mjölnir itself generates renders them.
|
|
12876
|
+
*
|
|
12877
|
+
* These are DEEPER than the score tokens on purpose, and it is not a
|
|
12878
|
+
* style preference: shields.io sets the message text in white and gives
|
|
12879
|
+
* you no say in it. `score.forged` (#F4DC9C) under white text measures
|
|
12880
|
+
* 1.35:1 — an unreadable badge, shipped to look on-brand. The brand's
|
|
12881
|
+
* own deep steps put every band between 4.9 and 6.3:1.
|
|
12882
|
+
*
|
|
12883
|
+
* What they replace was worse than off-brand, it was wrong:
|
|
12884
|
+
*
|
|
12885
|
+
* band was rendered as white-on now white-on
|
|
12886
|
+
* ────────────────────────────────────────────────────────────────────────────
|
|
12887
|
+
* 0-49 `red` #dd4343 4.24 #A83A35 6.32
|
|
12888
|
+
* 50-79 `yellow` #d8b800 1.95 #7A5F16 6.04
|
|
12889
|
+
* 80-99 `important` #ea7233 ORANGE 3.02 #1F6F7C 5.80
|
|
12890
|
+
* 100 `success` #44bb00 GREEN 2.51 #8A6D1E 4.90
|
|
12891
|
+
* unmeasured `lightgrey` #939393 3.07 #5C646E 5.99
|
|
12892
|
+
*
|
|
12893
|
+
* Two of those were defects, not preferences. `success` is green, and
|
|
12894
|
+
* green is not a score colour here — a 100 badge said "your software is
|
|
12895
|
+
* fine", which is the one claim this product refuses to make. And
|
|
12896
|
+
* `important` is ORANGE, not the blue-family colour the code's own
|
|
12897
|
+
* comment claimed for eight releases: every WORTHY badge ever rendered
|
|
12898
|
+
* showed the trusted band in a warning colour. Nobody had resolved a
|
|
12899
|
+
* shields name to a value and looked.
|
|
12900
|
+
*
|
|
12901
|
+
* Values are hex without `#`, the form shields.io's endpoint takes.
|
|
12902
|
+
* `A83A35`, `7A5F16`, `1F6F7C` and `5C646E` are the `TINT` strokes —
|
|
12903
|
+
* the same deep steps the mermaid diagrams use, for the same reason.
|
|
12904
|
+
* `8A6D1E` is the gold the brand document already named as the light
|
|
12905
|
+
* FORGED gradient's start.
|
|
12906
|
+
*/
|
|
12907
|
+
const BADGE_BAND = {
|
|
12908
|
+
critical: "A83A35",
|
|
12909
|
+
warning: "7A5F16",
|
|
12910
|
+
trusted: "1F6F7C",
|
|
12911
|
+
forged: "8A6D1E",
|
|
12912
|
+
unmeasured: "5C646E"
|
|
12913
|
+
};
|
|
12914
|
+
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;
|
|
12915
|
+
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;
|
|
12916
|
+
EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2;
|
|
12917
|
+
const RUNG_MEANINGS = [
|
|
12918
|
+
"observation only",
|
|
12919
|
+
"heuristic static",
|
|
12920
|
+
"deterministic static",
|
|
12921
|
+
"the finding's file executed",
|
|
12922
|
+
"the finding's test executed",
|
|
12923
|
+
"the run verdict corroborates"
|
|
12924
|
+
];
|
|
12925
|
+
const RUNG_COLORS = [
|
|
12926
|
+
TRUST.l0,
|
|
12927
|
+
TRUST.l1,
|
|
12928
|
+
TRUST.l2,
|
|
12929
|
+
TRUST.l3,
|
|
12930
|
+
TRUST.l4,
|
|
12931
|
+
TRUST.l5
|
|
12932
|
+
];
|
|
12933
|
+
const TRUST_RUNGS = RUNG_MEANINGS.map((meaning, i) => ({
|
|
12934
|
+
level: `L${i}`,
|
|
12935
|
+
meaning,
|
|
12936
|
+
runtime: i >= 3,
|
|
12937
|
+
color: RUNG_COLORS[i]
|
|
12938
|
+
}));
|
|
12939
|
+
//#endregion
|
|
12940
|
+
//#region src/reporter/presentation.ts
|
|
12941
|
+
/**
|
|
12942
|
+
* presentation.ts — the one place a presentation DECISION is made.
|
|
12943
|
+
*
|
|
12944
|
+
* BITTERSWEET `BW-030a`. Before this module the same decision was written
|
|
12945
|
+
* down four different times and every surface drifted:
|
|
12946
|
+
*
|
|
12947
|
+
* - score bands: `score-state.ts` (50/80), `dashboard.ts` (50/60),
|
|
12948
|
+
* `handover.ts` (50/90), `mermaid.ts` (50/80), `monorepo-analysis.ts`
|
|
12949
|
+
* (50/80);
|
|
12950
|
+
* - the evidence descriptor: `terminal.ts` returned the full
|
|
12951
|
+
* `[E2 · deterministic · measured FP 8% · n=14 · trust L3]` while
|
|
12952
|
+
* `evidence-tag.ts` — which called itself the "single definition
|
|
12953
|
+
* site" — returned four bare words and defaulted a missing level to
|
|
12954
|
+
* `E2`, the STRONGEST one;
|
|
12955
|
+
* - the score bar: `theme.ts:scoreGauge` plus four private reimplementations.
|
|
12956
|
+
*
|
|
12957
|
+
* THE ONE RULE OF THIS MODULE: it owns decisions, never data and never
|
|
12958
|
+
* I/O. It reads a `Finding` / a score number / a `Palette` and returns
|
|
12959
|
+
* the word, band, threshold or descriptor to print. It opens no file,
|
|
12960
|
+
* touches no clock, spawns no process and formats no HTML document. If a
|
|
12961
|
+
* second responsibility appears here, split it in the same change.
|
|
12962
|
+
*
|
|
12963
|
+
* Pure: the same input always yields the same output, so every decision
|
|
12964
|
+
* is golden-testable and two runs of the same scan cannot disagree.
|
|
12965
|
+
*
|
|
12966
|
+
* Layout note — the threshold LITERALS stay physically here rather than in
|
|
12967
|
+
* a registry object so that `site/scripts/gen-report.mjs`, which parses
|
|
12968
|
+
* `score >= N` branches out of this file to build the site's band ruler,
|
|
12969
|
+
* keeps parsing one canonical file. `SCORE_THRESHOLDS` is derived from
|
|
12970
|
+
* those literals; it is the nameable form, not a second copy of them.
|
|
12971
|
+
*/
|
|
12972
|
+
/**
|
|
12973
|
+
* What a surface prints when a number was never measured.
|
|
12974
|
+
*
|
|
12975
|
+
* The bug this replaces: `testDeclarationCount ?? 0` rendered
|
|
12976
|
+
* "0 tests analyzed in 0 files" for a producer that simply did not
|
|
12977
|
+
* measure. A zero is a CLAIM — it says the count was taken and found
|
|
12978
|
+
* nothing. Absent means nobody looked. `dashboard.ts` already modelled
|
|
12979
|
+
* this correctly for frameworks (`frameworkCount: null` → "unknown");
|
|
12980
|
+
* its sibling `trust-report.ts` did not, so the same repo produced two
|
|
12981
|
+
* artifacts that disagreed about whether a measurement existed.
|
|
12982
|
+
*/
|
|
12983
|
+
const UNMEASURED = "unknown — not measured";
|
|
12984
|
+
/**
|
|
12985
|
+
* Render a count for a human surface: the number when it was measured,
|
|
12986
|
+
* `unknown — not measured` when it was not. Never `0` for an absent
|
|
12987
|
+
* measurement.
|
|
12988
|
+
*/
|
|
12989
|
+
function countOrUnknown(value, unit) {
|
|
12990
|
+
if (value === void 0) return UNMEASURED;
|
|
12991
|
+
return unit === void 0 ? String(value) : `${value} ${unit}`;
|
|
12393
12992
|
}
|
|
12394
|
-
|
|
12395
|
-
|
|
12396
|
-
|
|
12397
|
-
|
|
12398
|
-
|
|
12993
|
+
/**
|
|
12994
|
+
* The machine-surface counterpart: an unmeasured count is `null`, which
|
|
12995
|
+
* JSON has a word for, rather than `0`, which does not. Used by the
|
|
12996
|
+
* trust-report JSON artifact; the scan machine contract
|
|
12997
|
+
* (`engine/machine-contract.ts`) is drift-locked and untouched.
|
|
12998
|
+
*/
|
|
12999
|
+
function countOrNull(value) {
|
|
13000
|
+
return value ?? null;
|
|
12399
13001
|
}
|
|
12400
|
-
/**
|
|
12401
|
-
|
|
12402
|
-
|
|
13002
|
+
/**
|
|
13003
|
+
* The "Tests analyzed" cell, shared by every human surface that shows it.
|
|
13004
|
+
*
|
|
13005
|
+
* Three renderers had this line written out separately, identically, and
|
|
13006
|
+
* all three rendered `testDeclarationCount ?? 0` — "0 tests analyzed in 0
|
|
13007
|
+
* files" — for a producer that never measured a declaration at all. Two
|
|
13008
|
+
* of them are the *sibling* artifacts for the same scan (the PR comment
|
|
13009
|
+
* and the trust report), so the same run told a reviewer both "0 tests
|
|
13010
|
+
* analyzed" and "no tests found".
|
|
13011
|
+
*
|
|
13012
|
+
* Declared-but-file-count-absent (and the reverse) are reported honestly
|
|
13013
|
+
* rather than filled in from each other.
|
|
13014
|
+
*/
|
|
13015
|
+
function testsAnalyzedCell(declarations, files) {
|
|
13016
|
+
if (declarations === void 0 && files === void 0) return UNMEASURED;
|
|
13017
|
+
const declText = countOrUnknown(declarations);
|
|
13018
|
+
if (files === void 0) return `${declText} in unknown files`;
|
|
13019
|
+
return `${declText} in ${files} ${files === 1 ? "file" : "files"}`;
|
|
13020
|
+
}
|
|
13021
|
+
const HEADLINES = {
|
|
13022
|
+
critical: "The hammer is cracked — {n} findings break its edge.",
|
|
13023
|
+
warning: "The hammer holds — but {n} findings weigh it down.",
|
|
13024
|
+
trusted: "Held in worthy hands — {n} findings remain.",
|
|
13025
|
+
forged: "Static score 100 — no findings on the analyzed surface.",
|
|
13026
|
+
unmeasured: "No tests found — the hammer cannot be weighed."
|
|
13027
|
+
};
|
|
13028
|
+
const RUNES = {
|
|
13029
|
+
critical: "ᚲ",
|
|
13030
|
+
warning: "ᚦ",
|
|
13031
|
+
trusted: "ᛏ",
|
|
13032
|
+
forged: "ᛟ",
|
|
13033
|
+
unmeasured: "ᛁ"
|
|
13034
|
+
};
|
|
13035
|
+
/**
|
|
13036
|
+
* The named form of the band boundaries. `report:honesty` /
|
|
13037
|
+
* `thresholds:parity` assert that no surface outside this module
|
|
13038
|
+
* re-invents a boundary; a consumer that genuinely needs one names it
|
|
13039
|
+
* here rather than typing the number.
|
|
13040
|
+
*/
|
|
13041
|
+
const SCORE_THRESHOLDS = {
|
|
13042
|
+
/** Below this is `critical`. */
|
|
13043
|
+
criticalBelow: 50,
|
|
13044
|
+
/** At or above this is `trusted`. */
|
|
13045
|
+
trustedAtOrAbove: 80,
|
|
13046
|
+
/** The one score that is `forged`. */
|
|
13047
|
+
forgedAt: 100
|
|
13048
|
+
};
|
|
13049
|
+
/**
|
|
13050
|
+
* Band mapping — the one mapping, every consumer: <50 critical,
|
|
13051
|
+
* 50–79 warning, 80–99 trusted, 100 forged, null unmeasured.
|
|
13052
|
+
*/
|
|
13053
|
+
function deriveScoreState(score) {
|
|
13054
|
+
if (score === null) return {
|
|
13055
|
+
score: null,
|
|
13056
|
+
band: "unmeasured",
|
|
13057
|
+
verdict: "UNWORTHY",
|
|
13058
|
+
color: "dim",
|
|
13059
|
+
powerLevel: 0,
|
|
13060
|
+
headline: HEADLINES.unmeasured,
|
|
13061
|
+
rune: RUNES.unmeasured
|
|
13062
|
+
};
|
|
13063
|
+
if (score >= SCORE_THRESHOLDS.forgedAt) return {
|
|
13064
|
+
score,
|
|
13065
|
+
band: "forged",
|
|
13066
|
+
verdict: "FORGED",
|
|
13067
|
+
color: "forged",
|
|
13068
|
+
powerLevel: score,
|
|
13069
|
+
headline: HEADLINES.forged,
|
|
13070
|
+
rune: RUNES.forged
|
|
13071
|
+
};
|
|
13072
|
+
if (score >= 80) return {
|
|
13073
|
+
score,
|
|
13074
|
+
band: "trusted",
|
|
13075
|
+
verdict: "WORTHY",
|
|
13076
|
+
color: "trusted",
|
|
13077
|
+
powerLevel: score,
|
|
13078
|
+
headline: HEADLINES.trusted,
|
|
13079
|
+
rune: RUNES.trusted
|
|
13080
|
+
};
|
|
13081
|
+
if (score >= 50) return {
|
|
13082
|
+
score,
|
|
13083
|
+
band: "warning",
|
|
13084
|
+
verdict: "NEEDS WORK",
|
|
13085
|
+
color: "warning",
|
|
13086
|
+
powerLevel: score,
|
|
13087
|
+
headline: HEADLINES.warning,
|
|
13088
|
+
rune: RUNES.warning
|
|
13089
|
+
};
|
|
13090
|
+
return {
|
|
13091
|
+
score,
|
|
13092
|
+
band: "critical",
|
|
13093
|
+
verdict: "UNWORTHY",
|
|
13094
|
+
color: "error",
|
|
13095
|
+
powerLevel: score,
|
|
13096
|
+
headline: HEADLINES.critical,
|
|
13097
|
+
rune: RUNES.critical
|
|
13098
|
+
};
|
|
13099
|
+
}
|
|
13100
|
+
/** Substitute a findings count into a headline template. Pure text
|
|
13101
|
+
* helper so every surface formats identically. */
|
|
13102
|
+
function headlineFor(state, findings) {
|
|
13103
|
+
return state.headline.replace("{n}", String(findings));
|
|
13104
|
+
}
|
|
13105
|
+
/**
|
|
13106
|
+
* Contract-stable three-band verdict (property-locked in
|
|
13107
|
+
* tests/scoring-precision.spec.ts). Delegates to the ScoreState model —
|
|
13108
|
+
* 100 keeps returning WORTHY here; the FORGED premium treatment lives
|
|
13109
|
+
* in the dedicated block, not in this public mapping.
|
|
13110
|
+
*
|
|
13111
|
+
* The `FORGED → WORTHY` collapse is a DOCUMENTED PROJECTION that
|
|
13112
|
+
* preserves the three-band public contract, not a divergence to be
|
|
13113
|
+
* "fixed": a consumer reading the verdict word must not be able to tell
|
|
13114
|
+
* a 100 from a 99, because the 100 carries no runtime evidence and the
|
|
13115
|
+
* score alone never earned it.
|
|
13116
|
+
*/
|
|
13117
|
+
function verdictFor(score) {
|
|
13118
|
+
const verdict = deriveScoreState(score).verdict;
|
|
13119
|
+
return verdict === "FORGED" ? "WORTHY" : verdict;
|
|
13120
|
+
}
|
|
13121
|
+
const EVIDENCE_KIND = {
|
|
13122
|
+
E0: "observation",
|
|
13123
|
+
E1: "heuristic",
|
|
13124
|
+
E2: "deterministic"
|
|
13125
|
+
};
|
|
13126
|
+
/**
|
|
13127
|
+
* The effective evidence level of a finding.
|
|
13128
|
+
*
|
|
13129
|
+
* The `?? deriveEvidenceLevel(...)` fallback IS the fix for BW-101. The
|
|
13130
|
+
* deleted `evidence-tag.ts` defaulted a missing level to `E2` — the
|
|
13131
|
+
* strongest claim the product can make — which is the exact inverse of
|
|
13132
|
+
* its own principle: absent evidence is not proof, so it is derived
|
|
13133
|
+
* conservatively from the finding's own type and confidence.
|
|
13134
|
+
*/
|
|
13135
|
+
function evidenceLevelOf(f) {
|
|
13136
|
+
return f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence);
|
|
13137
|
+
}
|
|
13138
|
+
/** What the runtime report actually vouched for, in the product's words. */
|
|
13139
|
+
function runtimeLabel(f) {
|
|
13140
|
+
const c = f.runtimeCorroboration;
|
|
13141
|
+
if (c === void 0) return null;
|
|
13142
|
+
if (c.level === "defect") return "defect corroborated";
|
|
13143
|
+
if (c.level === "test") return "test executed";
|
|
13144
|
+
return "file executed";
|
|
13145
|
+
}
|
|
13146
|
+
/**
|
|
13147
|
+
* The canonical evidence descriptor — `[E2 · deterministic · measured FP
|
|
13148
|
+
* 8% · n=14 · trust L3 · runtime: file executed]`.
|
|
13149
|
+
*
|
|
13150
|
+
* This is the version `terminal.ts` already shipped, promoted to the one
|
|
13151
|
+
* definition site. The HTML and markdown surfaces now gain the measured
|
|
13152
|
+
* false-positive rate, the sample size, the trust rung and what runtime
|
|
13153
|
+
* corroborated — they used to show four bare words that a reader could
|
|
13154
|
+
* mistake for the whole evidence story.
|
|
13155
|
+
*
|
|
13156
|
+
* Every clause is conditional on the finding actually carrying that
|
|
13157
|
+
* evidence. Nothing here is ever defaulted into existence.
|
|
13158
|
+
*/
|
|
13159
|
+
function evidenceTag(f) {
|
|
13160
|
+
const level = evidenceLevelOf(f);
|
|
13161
|
+
let tag = `${level} · ${EVIDENCE_KIND[level]}`;
|
|
13162
|
+
if (f.measuredFpRate !== void 0) {
|
|
13163
|
+
tag += ` · measured FP ${Math.round(f.measuredFpRate * 100)}%`;
|
|
13164
|
+
if (f.measuredFpN !== void 0) tag += ` · n=${f.measuredFpN}`;
|
|
13165
|
+
}
|
|
13166
|
+
if (f.trustLevel !== void 0) tag += ` · trust ${f.trustLevel}`;
|
|
13167
|
+
const runtime = runtimeLabel(f);
|
|
13168
|
+
if (runtime !== null) tag += ` · runtime: ${runtime}`;
|
|
13169
|
+
return `[${tag}]`;
|
|
13170
|
+
}
|
|
13171
|
+
//#endregion
|
|
13172
|
+
//#region src/engine/monorepo-analysis.ts
|
|
13173
|
+
/**
|
|
13174
|
+
* Package verdict, derived from the one ScoreState band model (BW-104).
|
|
13175
|
+
* These two constants used to be a private copy of the band boundaries;
|
|
13176
|
+
* the numeric vocabulary here is a different projection of the same bands
|
|
13177
|
+
* — "pass" is trusted or forged, "warn" is warning, "fail" is critical or
|
|
13178
|
+
* unmeasurable — so the boundaries are read, never retyped.
|
|
13179
|
+
*/
|
|
13180
|
+
function verdictOf(score) {
|
|
13181
|
+
if (score === null) return "fail";
|
|
13182
|
+
const band = deriveScoreState(score).band;
|
|
13183
|
+
if (band === "forged" || band === "trusted") return "pass";
|
|
13184
|
+
return band === "warning" ? "warn" : "fail";
|
|
13185
|
+
}
|
|
13186
|
+
function hasBlocker(findings) {
|
|
13187
|
+
return findings.some((f) => f.severity === "error");
|
|
13188
|
+
}
|
|
13189
|
+
/**
|
|
13190
|
+
* Aggregate per-package results into an overall verdict.
|
|
13191
|
+
*
|
|
13192
|
+
* Worst-package propagation: if ANY package has a blocker (error-severity
|
|
13193
|
+
* finding) or a failing score, the overall result fails regardless of
|
|
13194
|
+
* strategy. This is the safety net — a single poisoned package must not
|
|
13195
|
+
* hide behind averaging.
|
|
13196
|
+
*/
|
|
13197
|
+
function analyzeMonorepo(packages, config) {
|
|
13198
|
+
const results = packages.map((p) => ({
|
|
13199
|
+
...p,
|
|
13200
|
+
verdict: hasBlocker(p.findings) ? "fail" : verdictOf(p.score)
|
|
13201
|
+
}));
|
|
13202
|
+
if (results.length === 0) return {
|
|
13203
|
+
packages: results,
|
|
13204
|
+
overallScore: null,
|
|
13205
|
+
overallVerdict: "fail",
|
|
13206
|
+
strategy: config.weightingStrategy
|
|
13207
|
+
};
|
|
13208
|
+
const blockerPkg = results.find((r) => r.verdict === "fail" || hasBlocker(r.findings));
|
|
13209
|
+
if (blockerPkg) return {
|
|
13210
|
+
packages: results,
|
|
13211
|
+
overallScore: blockerPkg.score,
|
|
13212
|
+
overallVerdict: "fail",
|
|
13213
|
+
strategy: config.weightingStrategy,
|
|
13214
|
+
blockerPackage: blockerPkg.packageName
|
|
13215
|
+
};
|
|
13216
|
+
switch (config.weightingStrategy) {
|
|
13217
|
+
case "worst-package": return worstPackageAggregation(results, config);
|
|
13218
|
+
case "average": return averageAggregation(results, config);
|
|
13219
|
+
case "configurable": return configurableAggregation(results, config);
|
|
13220
|
+
}
|
|
13221
|
+
}
|
|
13222
|
+
function worstPackageAggregation(results, config) {
|
|
13223
|
+
const firstResult = results[0];
|
|
13224
|
+
if (firstResult === void 0) return {
|
|
13225
|
+
packages: results,
|
|
13226
|
+
overallScore: null,
|
|
13227
|
+
overallVerdict: "fail",
|
|
13228
|
+
strategy: config.weightingStrategy
|
|
13229
|
+
};
|
|
13230
|
+
let worst = firstResult;
|
|
13231
|
+
for (const r of results) if ((r.score ?? 0) < (worst.score ?? 0)) worst = r;
|
|
13232
|
+
return {
|
|
13233
|
+
packages: results,
|
|
13234
|
+
overallScore: worst.score,
|
|
13235
|
+
overallVerdict: worst.verdict,
|
|
13236
|
+
strategy: config.weightingStrategy,
|
|
13237
|
+
...worst.verdict === "fail" ? { blockerPackage: worst.packageName } : {}
|
|
13238
|
+
};
|
|
13239
|
+
}
|
|
13240
|
+
function averageAggregation(results, config) {
|
|
13241
|
+
const scored = results.filter((r) => r.score !== null);
|
|
13242
|
+
if (scored.length === 0) return {
|
|
13243
|
+
packages: results,
|
|
13244
|
+
overallScore: null,
|
|
13245
|
+
overallVerdict: "fail",
|
|
13246
|
+
strategy: config.weightingStrategy
|
|
13247
|
+
};
|
|
13248
|
+
const avg = scored.reduce((sum, r) => sum + (r.score ?? 0), 0) / scored.length;
|
|
13249
|
+
return {
|
|
13250
|
+
packages: results,
|
|
13251
|
+
overallScore: Math.round(avg),
|
|
13252
|
+
overallVerdict: verdictOf(Math.round(avg)),
|
|
13253
|
+
strategy: config.weightingStrategy
|
|
13254
|
+
};
|
|
13255
|
+
}
|
|
13256
|
+
function configurableAggregation(results, config) {
|
|
13257
|
+
const weights = config.packageWeights ?? {};
|
|
13258
|
+
let totalWeight = 0;
|
|
13259
|
+
let weightedSum = 0;
|
|
13260
|
+
for (const r of results) {
|
|
13261
|
+
const w = weights[r.packageName] ?? 1;
|
|
13262
|
+
totalWeight += w;
|
|
13263
|
+
weightedSum += (r.score ?? 0) * w;
|
|
13264
|
+
}
|
|
13265
|
+
if (totalWeight === 0) return {
|
|
13266
|
+
packages: results,
|
|
13267
|
+
overallScore: null,
|
|
13268
|
+
overallVerdict: "fail",
|
|
13269
|
+
strategy: config.weightingStrategy
|
|
13270
|
+
};
|
|
13271
|
+
const score = Math.round(weightedSum / totalWeight);
|
|
13272
|
+
return {
|
|
13273
|
+
packages: results,
|
|
13274
|
+
overallScore: score,
|
|
13275
|
+
overallVerdict: verdictOf(score),
|
|
13276
|
+
strategy: config.weightingStrategy
|
|
13277
|
+
};
|
|
12403
13278
|
}
|
|
13279
|
+
//#endregion
|
|
13280
|
+
//#region src/lib/fs-atomic.ts
|
|
12404
13281
|
/**
|
|
12405
|
-
*
|
|
12406
|
-
*
|
|
12407
|
-
*
|
|
12408
|
-
*
|
|
12409
|
-
*
|
|
12410
|
-
*
|
|
12411
|
-
*
|
|
13282
|
+
* Atomic file writes (audit S9).
|
|
13283
|
+
*
|
|
13284
|
+
* Every durability-critical write in Mjölnir (baseline, stats, badge,
|
|
13285
|
+
* TRIAGE.md, scaffolded rule files) used to hand-roll
|
|
13286
|
+
* `writeFileSync(path, data)` — a crash mid-write left a TRUNCATED file
|
|
13287
|
+
* at the real path, and a subsequent read (diff, badge endpoint) served
|
|
13288
|
+
* confident nonsense from it.
|
|
13289
|
+
*
|
|
13290
|
+
* `writeFileAtomic` writes to a temp sibling, then RENAMES. On the same
|
|
13291
|
+
* volume rename is atomic: readers see either the complete old file or
|
|
13292
|
+
* the complete new file, never a half-written one. The temp name is
|
|
13293
|
+
* created with `wx` (exclusive) so concurrent writers cannot interleave,
|
|
13294
|
+
* stale temps are cleaned up on failure, and on Windows the rename is
|
|
13295
|
+
* retried briefly because a concurrent reader can hold the destination
|
|
13296
|
+
* open (EBUSY/EPERM).
|
|
12412
13297
|
*/
|
|
12413
|
-
function
|
|
12414
|
-
|
|
12415
|
-
const rulesDirHash = hashRulesSourceTree(rules);
|
|
12416
|
-
return sha256(parts.join("|") + "\0" + rulesDirHash);
|
|
13298
|
+
function atomicTempPath(path) {
|
|
13299
|
+
return `${path}.mjolnir-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}.tmp`;
|
|
12417
13300
|
}
|
|
12418
13301
|
/**
|
|
12419
|
-
*
|
|
12420
|
-
*
|
|
12421
|
-
*
|
|
13302
|
+
* Atomically replace `path` with `data`.
|
|
13303
|
+
*
|
|
13304
|
+
* Accepts binary payloads as well as text: base-tree materialization in
|
|
13305
|
+
* `impact` writes git blobs, and a writer that only takes strings forces those
|
|
13306
|
+
* call sites back to the non-atomic path rather than keeping them contained.
|
|
12422
13307
|
*/
|
|
12423
|
-
function
|
|
12424
|
-
const
|
|
12425
|
-
if (
|
|
12426
|
-
const
|
|
12427
|
-
|
|
12428
|
-
const rulesDir = firstPath.replace(/\\/g, "/").slice(0, idx + 11);
|
|
13308
|
+
function writeFileAtomic(path, data, opts = {}) {
|
|
13309
|
+
const dir = dirname(path);
|
|
13310
|
+
if (opts.mkdirs !== false && !existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
13311
|
+
const tmp = atomicTempPath(path);
|
|
13312
|
+
let fd;
|
|
12429
13313
|
try {
|
|
12430
|
-
|
|
12431
|
-
|
|
12432
|
-
|
|
12433
|
-
}
|
|
12434
|
-
|
|
13314
|
+
fd = openSync(tmp, "wx", opts.mode ?? 420);
|
|
13315
|
+
if (typeof data === "string") writeSync(fd, data, null, opts.encoding ?? "utf8");
|
|
13316
|
+
else writeSync(fd, data);
|
|
13317
|
+
} finally {
|
|
13318
|
+
if (fd !== void 0) closeSync(fd);
|
|
12435
13319
|
}
|
|
12436
|
-
}
|
|
12437
|
-
function hashDir(dir, hash, depth) {
|
|
12438
|
-
if (depth > 8) return;
|
|
12439
|
-
let entries;
|
|
12440
13320
|
try {
|
|
12441
|
-
|
|
12442
|
-
} catch {
|
|
12443
|
-
|
|
12444
|
-
|
|
12445
|
-
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
12446
|
-
for (const entry of entries) {
|
|
12447
|
-
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
|
12448
|
-
const full = join(dir, entry.name);
|
|
12449
|
-
if (entry.isDirectory()) hashDir(full, hash, depth + 1);
|
|
12450
|
-
else if (entry.isFile() && /\.tsx?$/.test(entry.name)) try {
|
|
12451
|
-
hash.update(entry.name);
|
|
12452
|
-
hash.update(readFileSync(full));
|
|
13321
|
+
renameWithWindowsRetry(tmp, path);
|
|
13322
|
+
} catch (err) {
|
|
13323
|
+
try {
|
|
13324
|
+
if (existsSync(tmp)) unlinkSync(tmp);
|
|
12453
13325
|
} catch {}
|
|
13326
|
+
throw err;
|
|
12454
13327
|
}
|
|
12455
13328
|
}
|
|
12456
13329
|
/**
|
|
12457
|
-
*
|
|
13330
|
+
* renameSync retry loop for Windows: a concurrent reader (another scan,
|
|
13331
|
+
* a badge endpoint, an editor) holding the destination open makes
|
|
13332
|
+
* rename fail with EBUSY/EPERM. A short bounded retry closes the race
|
|
13333
|
+
* without turning an atomic swap into a partial write.
|
|
12458
13334
|
*
|
|
12459
|
-
*
|
|
12460
|
-
*
|
|
12461
|
-
*
|
|
12462
|
-
* findings were re-emitted for the second with the wrong `file` stamp.
|
|
12463
|
-
* The key therefore folds in the repo-relative path AND the adapter id,
|
|
12464
|
-
* plus a parse-mode token (audit W9): a file whose analysis degraded to
|
|
12465
|
-
* the regex fallback (or skipped the AST path) must not collide with a
|
|
12466
|
-
* fully-AST-analyzed verdict for the same bytes — the fallback output
|
|
12467
|
-
* belongs only to the fallback mode.
|
|
12468
|
-
*/
|
|
12469
|
-
function fileCacheKey(rulesDigest, fileText, identity) {
|
|
12470
|
-
const parseMode = identity.parseMode ?? "ast";
|
|
12471
|
-
return sha256(`${CACHE_VERSION}\u0000${rulesDigest}\u0000${identity.relPath}\u0000${identity.adapterId}\u0000${parseMode}\u0000${fileText}`);
|
|
12472
|
-
}
|
|
12473
|
-
/** No-op cache used when --cache is absent: zero stats, zero I/O. */
|
|
12474
|
-
const disabledScanCache = {
|
|
12475
|
-
stats: {
|
|
12476
|
-
hits: 0,
|
|
12477
|
-
misses: 0,
|
|
12478
|
-
file: ""
|
|
12479
|
-
},
|
|
12480
|
-
lookup: () => void 0,
|
|
12481
|
-
store: () => {},
|
|
12482
|
-
persist: () => {}
|
|
12483
|
-
};
|
|
12484
|
-
/**
|
|
12485
|
-
* Opens (and lazily creates) `<root>/.mjolnir/cache/scan-v<CACHE_VERSION>.json`. A
|
|
12486
|
-
* corrupt, hostile or future-versioned cache file degrades to a cold
|
|
12487
|
-
* cache — never fails the scan.
|
|
13335
|
+
* Internal contract test hook: the platform check keeps this loop off
|
|
13336
|
+
* the POSIX hot path; on win32 the EBUSY/EPERM arms are exercised by
|
|
13337
|
+
* the fs-atomic-retry spec (mocked renameSync).
|
|
12488
13338
|
*/
|
|
12489
|
-
|
|
12490
|
-
|
|
12491
|
-
|
|
12492
|
-
|
|
12493
|
-
let dirty = false;
|
|
12494
|
-
const entryBytes = /* @__PURE__ */ new Map();
|
|
12495
|
-
let totalBytes = 0;
|
|
13339
|
+
const RENAME_RETRIES = 8;
|
|
13340
|
+
const RENAME_RETRY_DELAY_MS = 25;
|
|
13341
|
+
/** Synchronous sleep that does not spin the CPU. */
|
|
13342
|
+
function sleepSync(ms) {
|
|
12496
13343
|
try {
|
|
12497
|
-
|
|
12498
|
-
|
|
12499
|
-
|
|
12500
|
-
|
|
12501
|
-
|
|
12502
|
-
|
|
12503
|
-
|
|
12504
|
-
|
|
12505
|
-
|
|
12506
|
-
|
|
13344
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
13345
|
+
} catch {
|
|
13346
|
+
const until = Date.now() + ms;
|
|
13347
|
+
while (Date.now() < until);
|
|
13348
|
+
}
|
|
13349
|
+
}
|
|
13350
|
+
function renameWithWindowsRetry(from, to) {
|
|
13351
|
+
for (let attempt = 0;; attempt++) try {
|
|
13352
|
+
renameSync(from, to);
|
|
13353
|
+
return;
|
|
13354
|
+
} catch (err) {
|
|
13355
|
+
const code = err?.code;
|
|
13356
|
+
if (process.platform === "win32" && (code === "EBUSY" || code === "EPERM") && attempt < RENAME_RETRIES) {
|
|
13357
|
+
sleepSync(RENAME_RETRY_DELAY_MS);
|
|
13358
|
+
continue;
|
|
12507
13359
|
}
|
|
13360
|
+
throw err;
|
|
13361
|
+
}
|
|
13362
|
+
}
|
|
13363
|
+
const STALE_TEMP_AGE_MS = 864e5;
|
|
13364
|
+
const TEMP_PID_RE = /^.+\.mjolnir-([1-9]\d{0,9})-(\d{13})-[0-9a-f]{8}\.tmp$/;
|
|
13365
|
+
function sweepStaleTempFiles(dir) {
|
|
13366
|
+
let swept = 0;
|
|
13367
|
+
let entries;
|
|
13368
|
+
try {
|
|
13369
|
+
entries = readdirSync(dir);
|
|
12508
13370
|
} catch {
|
|
12509
|
-
|
|
12510
|
-
entryBytes.clear();
|
|
12511
|
-
totalBytes = 0;
|
|
13371
|
+
return 0;
|
|
12512
13372
|
}
|
|
12513
|
-
|
|
12514
|
-
|
|
12515
|
-
|
|
12516
|
-
|
|
12517
|
-
|
|
12518
|
-
|
|
12519
|
-
|
|
12520
|
-
|
|
12521
|
-
if (
|
|
12522
|
-
|
|
12523
|
-
|
|
12524
|
-
|
|
12525
|
-
|
|
12526
|
-
delete entries[key];
|
|
12527
|
-
entries[key] = entry;
|
|
12528
|
-
return structuredClone(entry.findings);
|
|
12529
|
-
},
|
|
12530
|
-
store(key, findings, fileBudgetExceeded) {
|
|
12531
|
-
if (fileBudgetExceeded) return;
|
|
12532
|
-
if (findings.length > MAX_FINDINGS_PER_ENTRY) return;
|
|
12533
|
-
const entryJson = JSON.stringify(findings);
|
|
12534
|
-
const newBytes = Buffer.byteLength(entryJson, "utf8") + key.length + 4;
|
|
12535
|
-
if (newBytes > MAX_TOTAL_BYTES) return;
|
|
12536
|
-
const replacedBytes = entryBytes.get(key) ?? 0;
|
|
12537
|
-
delete entries[key];
|
|
12538
|
-
entries[key] = { findings: structuredClone(findings) };
|
|
12539
|
-
entryBytes.set(key, newBytes);
|
|
12540
|
-
totalBytes = totalBytes - replacedBytes + newBytes;
|
|
12541
|
-
let count = Object.keys(entries).length;
|
|
12542
|
-
while ((count > MAX_ENTRIES$1 || totalBytes > MAX_TOTAL_BYTES) && count > 1) {
|
|
12543
|
-
const oldest = Object.keys(entries)[0];
|
|
12544
|
-
totalBytes -= entryBytes.get(oldest);
|
|
12545
|
-
delete entries[oldest];
|
|
12546
|
-
entryBytes.delete(oldest);
|
|
12547
|
-
count--;
|
|
12548
|
-
}
|
|
12549
|
-
dirty = true;
|
|
12550
|
-
},
|
|
12551
|
-
persist() {
|
|
12552
|
-
if (!dirty) return;
|
|
12553
|
-
try {
|
|
12554
|
-
mkdirSync(dir, { recursive: true });
|
|
12555
|
-
writeFileAtomic(file, JSON.stringify({
|
|
12556
|
-
version: CACHE_VERSION,
|
|
12557
|
-
entries
|
|
12558
|
-
}), { encoding: "utf8" });
|
|
12559
|
-
} catch {}
|
|
13373
|
+
const cutoff = Date.now() - STALE_TEMP_AGE_MS;
|
|
13374
|
+
for (const entry of entries) {
|
|
13375
|
+
const match = TEMP_PID_RE.exec(entry);
|
|
13376
|
+
if (!match || Number(match[2]) > cutoff) continue;
|
|
13377
|
+
const path = join(dir, entry);
|
|
13378
|
+
try {
|
|
13379
|
+
const stat = lstatSync(path);
|
|
13380
|
+
if (!stat.isFile() || stat.mtimeMs > cutoff) continue;
|
|
13381
|
+
if (pidAlive(Number(match[1]))) continue;
|
|
13382
|
+
unlinkSync(path);
|
|
13383
|
+
swept++;
|
|
13384
|
+
} catch {
|
|
13385
|
+
continue;
|
|
12560
13386
|
}
|
|
12561
|
-
}
|
|
13387
|
+
}
|
|
13388
|
+
return swept;
|
|
13389
|
+
}
|
|
13390
|
+
function pidAlive(pid) {
|
|
13391
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 2147483647) return true;
|
|
13392
|
+
if (pid === process.pid) return true;
|
|
13393
|
+
try {
|
|
13394
|
+
process.kill(pid, 0);
|
|
13395
|
+
return true;
|
|
13396
|
+
} catch (err) {
|
|
13397
|
+
return err?.code !== "ESRCH";
|
|
13398
|
+
}
|
|
12562
13399
|
}
|
|
12563
13400
|
//#endregion
|
|
12564
|
-
//#region src/
|
|
12565
|
-
/**
|
|
12566
|
-
* The single source of brand truth.
|
|
12567
|
-
*
|
|
12568
|
-
* Every colour, typeface and motion constant Mjölnir shows a human —
|
|
12569
|
-
* terminal, README SVGs, demo video, website, docs, badges — resolves to
|
|
12570
|
-
* a value in this file. Nothing else may define one.
|
|
12571
|
-
*
|
|
12572
|
-
* WHY THIS EXISTS. Before it, the palette existed in six independent
|
|
12573
|
-
* copies: `site/.vitepress/theme/styles/vars.css`, `NORSE` in
|
|
12574
|
-
* `src/reporter/theme.ts`, `scripts/readme-svg.ts`,
|
|
12575
|
-
* `scripts/video/terminal-page.ts`, `scripts/generate-readme-architecture.ts`
|
|
12576
|
-
* and the table in `assets/brand/README.md`. Exactly one pair of those
|
|
12577
|
-
* was guarded (site-doctor Check 8, doc ↔ vars.css). The unguarded edges
|
|
12578
|
-
* are where the shipped surfaces drifted apart: the terminal and the site
|
|
12579
|
-
* disagreed on six semantic roles, the architecture diagram invented its
|
|
12580
|
-
* own neutral ramp, and the README badges still carried a palette retired
|
|
12581
|
-
* two releases earlier. `scripts/brand-doctor.mjs` now checks every edge
|
|
12582
|
-
* against this file.
|
|
12583
|
-
*
|
|
12584
|
-
* PURITY. Pure data. No I/O, no rendering, no environment access, no
|
|
12585
|
-
* imports, no logic. Consumers convert (hex → ANSI triplet, hex → CSS)
|
|
12586
|
-
* themselves. Same reason `score-state.ts` is pure: it makes the whole
|
|
12587
|
-
* thing golden-testable and safe to ship inside the npm package, where
|
|
12588
|
-
* it costs a few hundred bytes and replaces values the package already
|
|
12589
|
-
* carried anyway.
|
|
12590
|
-
*
|
|
12591
|
-
* DERIVATION. The palette is the one derived from the logo in PR #20
|
|
12592
|
-
* (brushed steel and forge gold under an aurora, over midnight iron).
|
|
12593
|
-
* Where the terminal disagreed with it, the terminal converges — see
|
|
12594
|
-
* `PENDING_TERMINAL` below. Full rationale: `assets/brand/README.md`.
|
|
12595
|
-
*
|
|
12596
|
-
* ACCESSIBILITY. Every foreground token in `BRAND` meets WCAG AA
|
|
12597
|
-
* (≥ 4.5:1) against every surface token it is allowed to sit on. That is
|
|
12598
|
-
* not a claim, it is `brand-doctor` rule 8, which computes the ratios.
|
|
12599
|
-
* The weakest legal pairing is `steelDim` on `ink800` at 5.00:1.
|
|
12600
|
-
*/
|
|
13401
|
+
//#region src/engine/scan-cache.ts
|
|
12601
13402
|
/**
|
|
12602
|
-
*
|
|
13403
|
+
* Local incremental scan cache (Beta-to-Stable 1.0 plan, M5.2 / A-2).
|
|
12603
13404
|
*
|
|
12604
|
-
*
|
|
12605
|
-
*
|
|
12606
|
-
*
|
|
12607
|
-
*
|
|
13405
|
+
* Content-addressed, local-only verdict cache: `--cache` reuses the
|
|
13406
|
+
* per-file rule outputs of a previous scan when the file's bytes AND the
|
|
13407
|
+
* active rule set are unchanged, and invalidates everything else. The
|
|
13408
|
+
* key is `sha256(fileText) + rulesDigest` + the file's own identity
|
|
13409
|
+
* (repo-relative path + adapter id + parse mode — audit C1/W9), where
|
|
13410
|
+
* the rules digest folds
|
|
13411
|
+
* in every active rule's id + `detectorRevision ?? 1` (the existing
|
|
13412
|
+
* stale-measurement machinery — Verification Trust Evolution Plan §07 —
|
|
13413
|
+
* reused as the cache invalidation signal, per A-2) plus a source hash
|
|
13414
|
+
* of each external plugin/local rule's `run` function, so a plugin that
|
|
13415
|
+
* changes code without bumping its revision still misses.
|
|
12608
13416
|
*
|
|
12609
|
-
*
|
|
12610
|
-
* the
|
|
12611
|
-
|
|
12612
|
-
const BRAND = {
|
|
12613
|
-
gold: "#C19A34",
|
|
12614
|
-
goldBright: "#E6BD57",
|
|
12615
|
-
goldHot: "#F4DC9C",
|
|
12616
|
-
/** Pressed / deepest gold — the only step dark enough to carry white. */
|
|
12617
|
-
goldDeep: "#A5811C",
|
|
12618
|
-
aurora: "#37ABBD",
|
|
12619
|
-
auroraBright: "#45C1D4",
|
|
12620
|
-
auroraCyan: "#5CBDE0",
|
|
12621
|
-
/** The aurora's outer curtains: atmosphere and section identity only,
|
|
12622
|
-
* never a verdict or a status. */
|
|
12623
|
-
auroraGreen: "#5FD6A4",
|
|
12624
|
-
auroraViolet: "#9D8CF5",
|
|
12625
|
-
steel: "#C8CBCF",
|
|
12626
|
-
steelDim: "#8B939D"
|
|
12627
|
-
};
|
|
12628
|
-
/**
|
|
12629
|
-
* Midnight iron. One ramp, four steps, darkest first.
|
|
13417
|
+
* Privacy posture: the cache lives under `<repo>/.mjolnir/cache/`, is
|
|
13418
|
+
* gitignored, never leaves the machine, and this module performs zero
|
|
13419
|
+
* network I/O — fs and crypto only (asserted by the privacy spec).
|
|
12630
13420
|
*
|
|
12631
|
-
*
|
|
12632
|
-
*
|
|
12633
|
-
*
|
|
12634
|
-
*
|
|
12635
|
-
*/
|
|
12636
|
-
const SURFACE = {
|
|
12637
|
-
ink950: "#0A1119",
|
|
12638
|
-
ink900: "#0C1420",
|
|
12639
|
-
ink850: "#111A29",
|
|
12640
|
-
ink800: "#18243A",
|
|
12641
|
-
/** Raised panel (cards, elevated surfaces). */
|
|
12642
|
-
panel: "#141F33",
|
|
12643
|
-
/** Soft fill (inline code, quiet chips). */
|
|
12644
|
-
soft: "#1A2740",
|
|
12645
|
-
/** Terminal body — the deepest tone, so a terminal reads as recessed. */
|
|
12646
|
-
terminal: "#0A1119",
|
|
12647
|
-
/** Terminal title bar — the same tone; the seam is shadow, not colour. */
|
|
12648
|
-
terminalBar: "#0A1119",
|
|
12649
|
-
/** The three window dots. One neutral, not a traffic light. */
|
|
12650
|
-
chromeDot: "#18243A"
|
|
12651
|
-
};
|
|
12652
|
-
const TEXT = {
|
|
12653
|
-
primary: "#EAEEF5",
|
|
12654
|
-
secondary: "#ABB6C6",
|
|
12655
|
-
muted: "#8B939D",
|
|
12656
|
-
/** Ink for text set ON gold (buttons, the FORGED chip). 7.17:1 on `gold`. */
|
|
12657
|
-
onGold: "#0A1119"
|
|
12658
|
-
};
|
|
12659
|
-
/**
|
|
12660
|
-
* Non-score status. `ok` is the one green in the system and it is NOT a
|
|
12661
|
-
* score colour — it survives only for contexts with no worthiness
|
|
12662
|
-
* meaning ("autofix applied", "analysis complete"). A green score would
|
|
12663
|
-
* say "your software is fine", which is the exact claim this product
|
|
12664
|
-
* refuses to make.
|
|
13421
|
+
* Only raw rule-loop outputs are cached. Everything after the loop
|
|
13422
|
+
* (severity overrides, suppressions, overlap dedup, evidence stamping,
|
|
13423
|
+
* tier policy, runtime corroboration, scoring) re-runs on every scan,
|
|
13424
|
+
* so a cached scan is byte-equivalent to a fresh one by construction.
|
|
12665
13425
|
*/
|
|
12666
|
-
const
|
|
12667
|
-
|
|
12668
|
-
|
|
12669
|
-
warning: "#E6BD57",
|
|
12670
|
-
error: "#EC6B66"
|
|
12671
|
-
};
|
|
13426
|
+
const CACHE_VERSION = 2;
|
|
13427
|
+
/** Entry cap: a monorepo-scale suite stays far below this; bounded file. */
|
|
13428
|
+
const MAX_ENTRIES$1 = 4096;
|
|
12672
13429
|
/**
|
|
12673
|
-
*
|
|
12674
|
-
*
|
|
12675
|
-
*
|
|
12676
|
-
*
|
|
12677
|
-
* `unmeasured` is steel-dim on purpose. UNKNOWN is a legitimate answer,
|
|
12678
|
-
* not a failure: colouring it red would make "we did not measure this"
|
|
12679
|
-
* look like "this is broken", which is precisely the dishonesty the
|
|
12680
|
-
* north-star law exists to prevent.
|
|
13430
|
+
* Audit M5: total byte budget. The old cap counted only ENTRIES, so
|
|
13431
|
+
* 4096 files × ~100KB of findings each could still produce a
|
|
13432
|
+
* multi-hundred-MB writeFileSync on persist. The budget bounds the
|
|
13433
|
+
* serialized size; the newest entries win (real LRU-by-use).
|
|
12681
13434
|
*/
|
|
12682
|
-
const
|
|
12683
|
-
|
|
12684
|
-
|
|
12685
|
-
|
|
12686
|
-
|
|
12687
|
-
|
|
12688
|
-
|
|
13435
|
+
const MAX_TOTAL_BYTES = 33554432;
|
|
13436
|
+
const MAX_FINDINGS_PER_ENTRY = 1e4;
|
|
13437
|
+
function isCachedFinding(value) {
|
|
13438
|
+
if (!isRecord(value)) return false;
|
|
13439
|
+
return typeof value["ruleId"] === "string" && typeof value["category"] === "string" && typeof value["severity"] === "string" && typeof value["confidence"] === "string" && typeof value["findingType"] === "string" && typeof value["file"] === "string" && Number.isSafeInteger(value["line"]) && Number.isSafeInteger(value["column"]) && typeof value["message"] === "string" && typeof value["why"] === "string" && typeof value["fix"] === "string";
|
|
13440
|
+
}
|
|
13441
|
+
function isCacheFile(value) {
|
|
13442
|
+
if (!isRecord(value) || value["version"] !== CACHE_VERSION) return false;
|
|
13443
|
+
const entries = value["entries"];
|
|
13444
|
+
if (!isRecord(entries) || Object.keys(entries).length > MAX_ENTRIES$1) return false;
|
|
13445
|
+
return Object.values(entries).every((entry) => isRecord(entry) && Array.isArray(entry["findings"]) && entry["findings"].length <= MAX_FINDINGS_PER_ENTRY && entry["findings"].every(isCachedFinding));
|
|
13446
|
+
}
|
|
13447
|
+
/** sha256 hex of a string — the only hash this module needs. */
|
|
13448
|
+
function sha256(text) {
|
|
13449
|
+
return createHash("sha256").update(text).digest("hex");
|
|
13450
|
+
}
|
|
12689
13451
|
/**
|
|
12690
|
-
*
|
|
12691
|
-
*
|
|
12692
|
-
*
|
|
12693
|
-
*
|
|
12694
|
-
*
|
|
12695
|
-
*
|
|
12696
|
-
*
|
|
12697
|
-
* E0 open ring observation, no weight
|
|
12698
|
-
* E1 half-filled pattern evidence, half weight
|
|
12699
|
-
* E2 sealed deterministic proof, full weight
|
|
12700
|
-
*
|
|
12701
|
-
* Colour never carries this alone (R11): the geometry is the signal and
|
|
12702
|
-
* survives `--ascii`, `NO_COLOR` and monochrome print.
|
|
13452
|
+
* Digest of the active rule set: id + effective detectorRevision for
|
|
13453
|
+
* every rule (sorted for determinism), plus a content hash of all rule
|
|
13454
|
+
* source files. The directory-tree hash catches changes to ANY file
|
|
13455
|
+
* under src/rules/ — same-file helpers, cross-module imports, shared
|
|
13456
|
+
* utilities — without needing to trace the import graph. This is the
|
|
13457
|
+
* only reliable way to detect helper function changes in a bundled
|
|
13458
|
+
* ESM codebase where String(fn) doesn't reveal transitive dependencies.
|
|
12703
13459
|
*/
|
|
12704
|
-
|
|
12705
|
-
|
|
12706
|
-
|
|
12707
|
-
|
|
12708
|
-
}
|
|
13460
|
+
function computeRulesDigest(rules) {
|
|
13461
|
+
const parts = rules.map((r) => `${r.id}:${r.detectorRevision ?? 1}`).sort();
|
|
13462
|
+
const rulesDirHash = hashRulesSourceTree(rules);
|
|
13463
|
+
return sha256(parts.join("|") + "\0" + rulesDirHash);
|
|
13464
|
+
}
|
|
12709
13465
|
/**
|
|
12710
|
-
*
|
|
12711
|
-
*
|
|
12712
|
-
*
|
|
12713
|
-
* ceiling at L2. L3–L5 require a real run, and the hue changes to aurora
|
|
12714
|
-
* exactly there. The boundary is a hue break, not a gradient step,
|
|
12715
|
-
* because it is a change of kind and not of degree — a static-only
|
|
12716
|
-
* finding can never climb past L2, however confident it is.
|
|
12717
|
-
*
|
|
12718
|
-
* Every surface that draws the ladder must draw that break.
|
|
13466
|
+
* Hash all .ts files under src/rules/ (when the first rule's modulePath
|
|
13467
|
+
* reveals the project root). Falls back to per-rule function source
|
|
13468
|
+
* hashing when the directory cannot be resolved.
|
|
12719
13469
|
*/
|
|
12720
|
-
|
|
12721
|
-
|
|
12722
|
-
|
|
12723
|
-
|
|
12724
|
-
|
|
12725
|
-
|
|
12726
|
-
|
|
12727
|
-
|
|
12728
|
-
|
|
12729
|
-
|
|
12730
|
-
|
|
12731
|
-
|
|
12732
|
-
|
|
12733
|
-
|
|
12734
|
-
|
|
12735
|
-
|
|
12736
|
-
|
|
12737
|
-
|
|
12738
|
-
|
|
12739
|
-
|
|
12740
|
-
|
|
12741
|
-
|
|
12742
|
-
text: "#4E1B19"
|
|
12743
|
-
},
|
|
12744
|
-
/** The unmeasured / unknown state. Neutral, never the error tint. */
|
|
12745
|
-
neutral: {
|
|
12746
|
-
fill: "#E4E7EB",
|
|
12747
|
-
stroke: "#5C646E",
|
|
12748
|
-
text: "#262B31"
|
|
12749
|
-
},
|
|
12750
|
-
ok: {
|
|
12751
|
-
fill: "#DCF0E4",
|
|
12752
|
-
stroke: "#276B45",
|
|
12753
|
-
text: "#163A26"
|
|
13470
|
+
function hashRulesSourceTree(rules) {
|
|
13471
|
+
const firstPath = rules.find((r) => r.modulePath)?.modulePath;
|
|
13472
|
+
if (!firstPath) return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
|
|
13473
|
+
const idx = firstPath.replace(/\\/g, "/").indexOf("/src/rules/");
|
|
13474
|
+
if (idx < 0) return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
|
|
13475
|
+
const rulesDir = firstPath.replace(/\\/g, "/").slice(0, idx + 11);
|
|
13476
|
+
try {
|
|
13477
|
+
const hash = createHash("sha256");
|
|
13478
|
+
hashDir(rulesDir, hash, 0);
|
|
13479
|
+
return hash.digest("hex").slice(0, 16);
|
|
13480
|
+
} catch {
|
|
13481
|
+
return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
|
|
13482
|
+
}
|
|
13483
|
+
}
|
|
13484
|
+
function hashDir(dir, hash, depth) {
|
|
13485
|
+
if (depth > 8) return;
|
|
13486
|
+
let entries;
|
|
13487
|
+
try {
|
|
13488
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
13489
|
+
} catch {
|
|
13490
|
+
recordDegradation("rules-tree-listing-unreadable");
|
|
13491
|
+
return;
|
|
12754
13492
|
}
|
|
12755
|
-
|
|
13493
|
+
entries.sort((a, b) => compareCodePoints(a.name, b.name));
|
|
13494
|
+
for (const entry of entries) {
|
|
13495
|
+
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
|
13496
|
+
const full = join(dir, entry.name);
|
|
13497
|
+
if (entry.isDirectory()) hashDir(full, hash, depth + 1);
|
|
13498
|
+
else if (entry.isFile() && /\.tsx?$/.test(entry.name)) try {
|
|
13499
|
+
hash.update(entry.name);
|
|
13500
|
+
hash.update(readFileSync(full));
|
|
13501
|
+
} catch {}
|
|
13502
|
+
}
|
|
13503
|
+
}
|
|
12756
13504
|
/**
|
|
12757
|
-
*
|
|
12758
|
-
*
|
|
12759
|
-
* These are DEEPER than the score tokens on purpose, and it is not a
|
|
12760
|
-
* style preference: shields.io sets the message text in white and gives
|
|
12761
|
-
* you no say in it. `score.forged` (#F4DC9C) under white text measures
|
|
12762
|
-
* 1.35:1 — an unreadable badge, shipped to look on-brand. The brand's
|
|
12763
|
-
* own deep steps put every band between 4.9 and 6.3:1.
|
|
12764
|
-
*
|
|
12765
|
-
* What they replace was worse than off-brand, it was wrong:
|
|
12766
|
-
*
|
|
12767
|
-
* band was rendered as white-on now white-on
|
|
12768
|
-
* ────────────────────────────────────────────────────────────────────────────
|
|
12769
|
-
* 0-49 `red` #dd4343 4.24 #A83A35 6.32
|
|
12770
|
-
* 50-79 `yellow` #d8b800 1.95 #7A5F16 6.04
|
|
12771
|
-
* 80-99 `important` #ea7233 ORANGE 3.02 #1F6F7C 5.80
|
|
12772
|
-
* 100 `success` #44bb00 GREEN 2.51 #8A6D1E 4.90
|
|
12773
|
-
* unmeasured `lightgrey` #939393 3.07 #5C646E 5.99
|
|
12774
|
-
*
|
|
12775
|
-
* Two of those were defects, not preferences. `success` is green, and
|
|
12776
|
-
* green is not a score colour here — a 100 badge said "your software is
|
|
12777
|
-
* fine", which is the one claim this product refuses to make. And
|
|
12778
|
-
* `important` is ORANGE, not the blue-family colour the code's own
|
|
12779
|
-
* comment claimed for eight releases: every WORTHY badge ever rendered
|
|
12780
|
-
* showed the trusted band in a warning colour. Nobody had resolved a
|
|
12781
|
-
* shields name to a value and looked.
|
|
13505
|
+
* Content-addressed key for one file's rule-loop verdicts.
|
|
12782
13506
|
*
|
|
12783
|
-
*
|
|
12784
|
-
*
|
|
12785
|
-
*
|
|
12786
|
-
*
|
|
12787
|
-
*
|
|
13507
|
+
* Audit C1: the key MUST identify the verdict's producer, not just the
|
|
13508
|
+
* bytes — two files with byte-identical text (a copied spec, a generated
|
|
13509
|
+
* snapshot) previously shared one entry, and the first file's cached
|
|
13510
|
+
* findings were re-emitted for the second with the wrong `file` stamp.
|
|
13511
|
+
* The key therefore folds in the repo-relative path AND the adapter id,
|
|
13512
|
+
* plus a parse-mode token (audit W9): a file whose analysis degraded to
|
|
13513
|
+
* the regex fallback (or skipped the AST path) must not collide with a
|
|
13514
|
+
* fully-AST-analyzed verdict for the same bytes — the fallback output
|
|
13515
|
+
* belongs only to the fallback mode.
|
|
12788
13516
|
*/
|
|
12789
|
-
|
|
12790
|
-
|
|
12791
|
-
|
|
12792
|
-
|
|
12793
|
-
|
|
12794
|
-
|
|
12795
|
-
|
|
12796
|
-
|
|
12797
|
-
|
|
12798
|
-
|
|
12799
|
-
|
|
12800
|
-
|
|
12801
|
-
|
|
12802
|
-
|
|
12803
|
-
trusted: "Held in worthy hands — {n} findings remain.",
|
|
12804
|
-
forged: "Static score 100 — no findings on the analyzed surface.",
|
|
12805
|
-
unmeasured: "No tests found — the hammer cannot be weighed."
|
|
12806
|
-
};
|
|
12807
|
-
const RUNES = {
|
|
12808
|
-
critical: "ᚲ",
|
|
12809
|
-
warning: "ᚦ",
|
|
12810
|
-
trusted: "ᛏ",
|
|
12811
|
-
forged: "ᛟ",
|
|
12812
|
-
unmeasured: "ᛁ"
|
|
13517
|
+
function fileCacheKey(rulesDigest, fileText, identity) {
|
|
13518
|
+
const parseMode = identity.parseMode ?? "ast";
|
|
13519
|
+
return sha256(`${CACHE_VERSION}\u0000${rulesDigest}\u0000${identity.relPath}\u0000${identity.adapterId}\u0000${parseMode}\u0000${fileText}`);
|
|
13520
|
+
}
|
|
13521
|
+
/** No-op cache used when --cache is absent: zero stats, zero I/O. */
|
|
13522
|
+
const disabledScanCache = {
|
|
13523
|
+
stats: {
|
|
13524
|
+
hits: 0,
|
|
13525
|
+
misses: 0,
|
|
13526
|
+
file: ""
|
|
13527
|
+
},
|
|
13528
|
+
lookup: () => void 0,
|
|
13529
|
+
store: () => {},
|
|
13530
|
+
persist: () => {}
|
|
12813
13531
|
};
|
|
12814
|
-
/**
|
|
12815
|
-
*
|
|
12816
|
-
|
|
12817
|
-
|
|
12818
|
-
|
|
12819
|
-
|
|
12820
|
-
|
|
12821
|
-
|
|
12822
|
-
|
|
12823
|
-
|
|
12824
|
-
|
|
12825
|
-
|
|
12826
|
-
|
|
12827
|
-
|
|
12828
|
-
|
|
12829
|
-
|
|
12830
|
-
|
|
12831
|
-
|
|
12832
|
-
|
|
12833
|
-
|
|
12834
|
-
|
|
12835
|
-
|
|
12836
|
-
|
|
12837
|
-
|
|
12838
|
-
|
|
12839
|
-
|
|
12840
|
-
|
|
12841
|
-
|
|
12842
|
-
|
|
12843
|
-
};
|
|
12844
|
-
if (score >= 50) return {
|
|
12845
|
-
score,
|
|
12846
|
-
band: "warning",
|
|
12847
|
-
verdict: "NEEDS WORK",
|
|
12848
|
-
color: "warning",
|
|
12849
|
-
powerLevel: score,
|
|
12850
|
-
headline: HEADLINES.warning,
|
|
12851
|
-
rune: RUNES.warning
|
|
12852
|
-
};
|
|
13532
|
+
/**
|
|
13533
|
+
* Opens (and lazily creates) `<root>/.mjolnir/cache/scan-v<CACHE_VERSION>.json`. A
|
|
13534
|
+
* corrupt, hostile or future-versioned cache file degrades to a cold
|
|
13535
|
+
* cache — never fails the scan.
|
|
13536
|
+
*/
|
|
13537
|
+
function createScanCache(root) {
|
|
13538
|
+
const dir = join(root, ".mjolnir", "cache");
|
|
13539
|
+
const file = join(dir, `scan-v${CACHE_VERSION}.json`);
|
|
13540
|
+
let entries = {};
|
|
13541
|
+
let dirty = false;
|
|
13542
|
+
const entryBytes = /* @__PURE__ */ new Map();
|
|
13543
|
+
let totalBytes = 0;
|
|
13544
|
+
try {
|
|
13545
|
+
if (existsSync(file)) {
|
|
13546
|
+
const stat = lstatSync(file);
|
|
13547
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_TOTAL_BYTES) throw new Error("invalid cache file");
|
|
13548
|
+
entries = parseJsonFile(readFileSync(file, "utf8"), file, isCacheFile).entries;
|
|
13549
|
+
for (const [k, v] of Object.entries(entries)) {
|
|
13550
|
+
const size = Buffer.byteLength(JSON.stringify(v), "utf8") + k.length + 4;
|
|
13551
|
+
entryBytes.set(k, size);
|
|
13552
|
+
totalBytes += size;
|
|
13553
|
+
}
|
|
13554
|
+
if (totalBytes > MAX_TOTAL_BYTES) throw new Error("cache byte budget exceeded");
|
|
13555
|
+
}
|
|
13556
|
+
} catch {
|
|
13557
|
+
entries = {};
|
|
13558
|
+
entryBytes.clear();
|
|
13559
|
+
totalBytes = 0;
|
|
13560
|
+
}
|
|
12853
13561
|
return {
|
|
12854
|
-
|
|
12855
|
-
|
|
12856
|
-
|
|
12857
|
-
|
|
12858
|
-
|
|
12859
|
-
|
|
12860
|
-
|
|
13562
|
+
stats: {
|
|
13563
|
+
hits: 0,
|
|
13564
|
+
misses: 0,
|
|
13565
|
+
file
|
|
13566
|
+
},
|
|
13567
|
+
lookup(key) {
|
|
13568
|
+
const entry = entries[key];
|
|
13569
|
+
if (!entry) {
|
|
13570
|
+
this.stats.misses++;
|
|
13571
|
+
return;
|
|
13572
|
+
}
|
|
13573
|
+
this.stats.hits++;
|
|
13574
|
+
delete entries[key];
|
|
13575
|
+
entries[key] = entry;
|
|
13576
|
+
return structuredClone(entry.findings);
|
|
13577
|
+
},
|
|
13578
|
+
store(key, findings, fileBudgetExceeded) {
|
|
13579
|
+
if (fileBudgetExceeded) return;
|
|
13580
|
+
if (findings.length > MAX_FINDINGS_PER_ENTRY) return;
|
|
13581
|
+
const entryJson = JSON.stringify(findings);
|
|
13582
|
+
const newBytes = Buffer.byteLength(entryJson, "utf8") + key.length + 4;
|
|
13583
|
+
if (newBytes > MAX_TOTAL_BYTES) return;
|
|
13584
|
+
const replacedBytes = entryBytes.get(key) ?? 0;
|
|
13585
|
+
delete entries[key];
|
|
13586
|
+
entries[key] = { findings: structuredClone(findings) };
|
|
13587
|
+
entryBytes.set(key, newBytes);
|
|
13588
|
+
totalBytes = totalBytes - replacedBytes + newBytes;
|
|
13589
|
+
let count = Object.keys(entries).length;
|
|
13590
|
+
while ((count > MAX_ENTRIES$1 || totalBytes > MAX_TOTAL_BYTES) && count > 1) {
|
|
13591
|
+
const oldest = Object.keys(entries)[0];
|
|
13592
|
+
totalBytes -= entryBytes.get(oldest);
|
|
13593
|
+
delete entries[oldest];
|
|
13594
|
+
entryBytes.delete(oldest);
|
|
13595
|
+
count--;
|
|
13596
|
+
}
|
|
13597
|
+
dirty = true;
|
|
13598
|
+
},
|
|
13599
|
+
persist() {
|
|
13600
|
+
if (!dirty) return;
|
|
13601
|
+
try {
|
|
13602
|
+
mkdirSync(dir, { recursive: true });
|
|
13603
|
+
writeFileAtomic(file, JSON.stringify({
|
|
13604
|
+
version: CACHE_VERSION,
|
|
13605
|
+
entries
|
|
13606
|
+
}), { encoding: "utf8" });
|
|
13607
|
+
} catch {}
|
|
13608
|
+
}
|
|
12861
13609
|
};
|
|
12862
13610
|
}
|
|
12863
|
-
/** Substitute a findings count into a headline template. Pure text
|
|
12864
|
-
* helper so every surface formats identically. */
|
|
12865
|
-
function headlineFor(state, findings) {
|
|
12866
|
-
return state.headline.replace("{n}", String(findings));
|
|
12867
|
-
}
|
|
12868
13611
|
//#endregion
|
|
12869
13612
|
//#region src/reporter/theme.ts
|
|
12870
13613
|
/**
|
|
@@ -12880,8 +13623,8 @@ function headlineFor(state, findings) {
|
|
|
12880
13623
|
*
|
|
12881
13624
|
* Symbols always accompany color (color-blind safe, R11).
|
|
12882
13625
|
*
|
|
12883
|
-
* Score-state colors come from ScoreState (
|
|
12884
|
-
* source of truth shared with the badge and
|
|
13626
|
+
* Score-state colors come from ScoreState (presentation.ts) — the single
|
|
13627
|
+
* source of truth shared with the badge, the dashboard and the site.
|
|
12885
13628
|
*
|
|
12886
13629
|
* Terminal robustness (Master-Stabilization-Plan Sprint 5 Task 22):
|
|
12887
13630
|
* box-drawing/gauge helpers accept an explicit width so callers can
|
|
@@ -13058,14 +13801,11 @@ function padTo(s, width) {
|
|
|
13058
13801
|
*/
|
|
13059
13802
|
function scoreGauge(score, p, width = 30, ascii = false) {
|
|
13060
13803
|
const filled = Math.round(score / 100 * width);
|
|
13061
|
-
const color =
|
|
13804
|
+
const color = gaugeColorForBand(deriveScoreState(score).band, p);
|
|
13062
13805
|
if (ascii) return color("#".repeat(filled)) + ".".repeat(Math.max(0, width - filled));
|
|
13063
13806
|
const head = filled > 0 && filled < width ? "▓" : "";
|
|
13064
13807
|
return color("█".repeat(Math.max(0, filled - (head ? 1 : 0)))) + color(head) + "░".repeat(width - filled);
|
|
13065
13808
|
}
|
|
13066
|
-
function gaugeColor(score, p) {
|
|
13067
|
-
return gaugeColorForBand(deriveScoreState(score).band, p);
|
|
13068
|
-
}
|
|
13069
13809
|
/**
|
|
13070
13810
|
* The single band → palette-key mapping, delegated from ScoreState so
|
|
13071
13811
|
* gauge, verdict and badge colors can never disagree again.
|
|
@@ -14220,7 +14960,7 @@ function runForensics(target, options = {}) {
|
|
|
14220
14960
|
const base = stat.isFile() ? dirname(target) : target;
|
|
14221
14961
|
flakyMdPath = join(base, "FLAKY.md");
|
|
14222
14962
|
try {
|
|
14223
|
-
|
|
14963
|
+
writeFileAtomic(flakyMdPath, renderFlakyMd(report));
|
|
14224
14964
|
} catch {
|
|
14225
14965
|
flakyMdPath = void 0;
|
|
14226
14966
|
}
|
|
@@ -14328,11 +15068,11 @@ function listFiles(dir) {
|
|
|
14328
15068
|
* `mjolnir suppressions` — every suppression stays visible.
|
|
14329
15069
|
*/
|
|
14330
15070
|
const ui = plainContext();
|
|
14331
|
-
function loadSuppressions(root) {
|
|
15071
|
+
function loadSuppressions(root, now = /* @__PURE__ */ new Date()) {
|
|
14332
15072
|
const { config } = loadConfig(root);
|
|
14333
15073
|
const entries = (config.ignore ?? []).map((ign) => ({
|
|
14334
15074
|
...ign,
|
|
14335
|
-
status: isSuppressionActive(ign,
|
|
15075
|
+
status: isSuppressionActive(ign, now) ? "active" : "expired"
|
|
14336
15076
|
}));
|
|
14337
15077
|
return {
|
|
14338
15078
|
total: entries.length,
|
|
@@ -15101,6 +15841,7 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
15101
15841
|
let rulesPartial = false;
|
|
15102
15842
|
let parseFailed = 0;
|
|
15103
15843
|
let parseFallbacks = 0;
|
|
15844
|
+
let astFallbackFiles = 0;
|
|
15104
15845
|
let scanned = 0;
|
|
15105
15846
|
let analyzed = 0;
|
|
15106
15847
|
for (const path of testFiles) {
|
|
@@ -15126,8 +15867,6 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
15126
15867
|
skippedFiles++;
|
|
15127
15868
|
continue;
|
|
15128
15869
|
}
|
|
15129
|
-
let fileBudgetExceeded = false;
|
|
15130
|
-
let fileRuleFailed = false;
|
|
15131
15870
|
const relPath = relative(workspace.root, path).replaceAll("\\", "/");
|
|
15132
15871
|
if (!isCiAdapter) {
|
|
15133
15872
|
const lang = {
|
|
@@ -15146,6 +15885,10 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
15146
15885
|
});
|
|
15147
15886
|
}
|
|
15148
15887
|
const wantsAst = adapter.parseAst !== void 0 && Date.now() <= deadline;
|
|
15888
|
+
if (adapter.parseAst !== void 0 && !wantsAst) {
|
|
15889
|
+
truncationReasons.add("ast-budget-fallback");
|
|
15890
|
+
astFallbackFiles++;
|
|
15891
|
+
}
|
|
15149
15892
|
const identity = (mode) => ({
|
|
15150
15893
|
relPath,
|
|
15151
15894
|
adapterId: adapter.id,
|
|
@@ -15153,79 +15896,106 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
15153
15896
|
});
|
|
15154
15897
|
let cacheKey = fileCacheKey(rulesDigest, text, identity(wantsAst ? "ast" : "regex"));
|
|
15155
15898
|
const cachedFindings = cache.lookup(cacheKey);
|
|
15156
|
-
|
|
15157
|
-
|
|
15158
|
-
|
|
15159
|
-
|
|
15160
|
-
|
|
15161
|
-
|
|
15162
|
-
|
|
15163
|
-
|
|
15164
|
-
|
|
15165
|
-
|
|
15166
|
-
|
|
15167
|
-
|
|
15168
|
-
|
|
15169
|
-
|
|
15170
|
-
|
|
15171
|
-
|
|
15172
|
-
|
|
15173
|
-
|
|
15174
|
-
|
|
15175
|
-
|
|
15176
|
-
|
|
15177
|
-
|
|
15178
|
-
|
|
15179
|
-
detail: relPath
|
|
15180
|
-
});
|
|
15181
|
-
parsed = await adapter.parseAst(parsedFile);
|
|
15182
|
-
}
|
|
15183
|
-
const actualMode = parsed ? "ast" : "regex";
|
|
15184
|
-
if (wantsAst && actualMode === "regex") {
|
|
15185
|
-
parseFallbacks++;
|
|
15186
|
-
cacheKey = fileCacheKey(rulesDigest, text, identity(actualMode));
|
|
15187
|
-
const fallbackFindings = cache.lookup(cacheKey);
|
|
15188
|
-
if (fallbackFindings) {
|
|
15189
|
-
for (const f of fallbackFindings) findings.push(f);
|
|
15190
|
-
analyzed++;
|
|
15191
|
-
continue;
|
|
15899
|
+
const executor = createExecutor("sequential", 1, async (job) => {
|
|
15900
|
+
if (job.cacheHit !== void 0) return {
|
|
15901
|
+
path: job.path,
|
|
15902
|
+
status: "CACHE_HIT",
|
|
15903
|
+
findings: [...job.cacheHit],
|
|
15904
|
+
parseFallback: false
|
|
15905
|
+
};
|
|
15906
|
+
let parseFallback = false;
|
|
15907
|
+
let fileRuleFailed = false;
|
|
15908
|
+
let fileBudgetExceeded = false;
|
|
15909
|
+
let parsedAst;
|
|
15910
|
+
try {
|
|
15911
|
+
if (adapter.parseAst && job.wantsAst) {
|
|
15912
|
+
hooks.onProgress?.({
|
|
15913
|
+
phase: "rules",
|
|
15914
|
+
done: scanned,
|
|
15915
|
+
total: testFiles.length,
|
|
15916
|
+
detail: job.path
|
|
15917
|
+
});
|
|
15918
|
+
parsedAst = await adapter.parseAst({
|
|
15919
|
+
path: job.path,
|
|
15920
|
+
text: job.text
|
|
15921
|
+
});
|
|
15192
15922
|
}
|
|
15193
|
-
|
|
15194
|
-
|
|
15195
|
-
|
|
15196
|
-
|
|
15197
|
-
|
|
15198
|
-
|
|
15199
|
-
|
|
15200
|
-
|
|
15201
|
-
|
|
15202
|
-
|
|
15923
|
+
const actualMode = parsedAst ? "ast" : "regex";
|
|
15924
|
+
if (job.wantsAst && actualMode === "regex") {
|
|
15925
|
+
parseFallback = true;
|
|
15926
|
+
parseFallbacks++;
|
|
15927
|
+
cacheKey = fileCacheKey(rulesDigest, job.text, identity(actualMode));
|
|
15928
|
+
const fallbackFindings = cache.lookup(cacheKey);
|
|
15929
|
+
if (fallbackFindings) return {
|
|
15930
|
+
path: job.path,
|
|
15931
|
+
status: "CACHE_HIT",
|
|
15932
|
+
findings: [...fallbackFindings],
|
|
15933
|
+
parseFallback: true
|
|
15934
|
+
};
|
|
15203
15935
|
}
|
|
15204
|
-
|
|
15205
|
-
|
|
15206
|
-
|
|
15207
|
-
|
|
15936
|
+
const fileForRules = parsedAst ? {
|
|
15937
|
+
path: job.path,
|
|
15938
|
+
text: job.text,
|
|
15939
|
+
ast: parsedAst.ast
|
|
15940
|
+
} : {
|
|
15941
|
+
path: job.path,
|
|
15942
|
+
text: job.text
|
|
15943
|
+
};
|
|
15944
|
+
const produced = [];
|
|
15945
|
+
adapter.runRules(activeRules, fileForRules, (f, ruleId, category) => {
|
|
15946
|
+
if (!isValidFindingRecord(f)) {
|
|
15947
|
+
fileRuleFailed = true;
|
|
15948
|
+
onRuleCrash?.(ruleId, job.path, /* @__PURE__ */ new Error(`malformed finding record rejected (severity/line/message must be present, severity ∈ error|warning|info): ${JSON.stringify(f)}`));
|
|
15949
|
+
return;
|
|
15950
|
+
}
|
|
15951
|
+
produced.push({
|
|
15952
|
+
...f,
|
|
15953
|
+
ruleId,
|
|
15954
|
+
category
|
|
15955
|
+
});
|
|
15956
|
+
}, (ruleId, error) => {
|
|
15957
|
+
fileRuleFailed = true;
|
|
15958
|
+
onRuleCrash?.(ruleId, job.path, error);
|
|
15959
|
+
}, {
|
|
15960
|
+
deadline: Math.min(deadline, Date.now() + LIMITS$1.maxFileAnalysisMs),
|
|
15961
|
+
onExceeded: () => {
|
|
15962
|
+
rulesPartial = true;
|
|
15963
|
+
skippedFiles++;
|
|
15964
|
+
truncationReasons.add("file-budget");
|
|
15965
|
+
fileBudgetExceeded = true;
|
|
15966
|
+
}
|
|
15208
15967
|
});
|
|
15209
|
-
|
|
15210
|
-
fileRuleFailed
|
|
15211
|
-
|
|
15212
|
-
|
|
15213
|
-
|
|
15214
|
-
|
|
15215
|
-
|
|
15216
|
-
|
|
15217
|
-
|
|
15218
|
-
|
|
15219
|
-
|
|
15220
|
-
|
|
15221
|
-
|
|
15222
|
-
|
|
15223
|
-
|
|
15224
|
-
|
|
15225
|
-
|
|
15226
|
-
|
|
15227
|
-
|
|
15228
|
-
|
|
15968
|
+
if (!fileRuleFailed && !fileBudgetExceeded) analyzed++;
|
|
15969
|
+
if (!fileRuleFailed) cache.store(cacheKey, produced, fileBudgetExceeded);
|
|
15970
|
+
return {
|
|
15971
|
+
path: job.path,
|
|
15972
|
+
status: "OK",
|
|
15973
|
+
findings: produced,
|
|
15974
|
+
parseFallback
|
|
15975
|
+
};
|
|
15976
|
+
} catch {
|
|
15977
|
+
if (job.wantsAst) parseFallbacks++;
|
|
15978
|
+
skippedFiles++;
|
|
15979
|
+
parseFailed++;
|
|
15980
|
+
return {
|
|
15981
|
+
path: job.path,
|
|
15982
|
+
status: "FAILED",
|
|
15983
|
+
findings: [],
|
|
15984
|
+
parseFallback
|
|
15985
|
+
};
|
|
15986
|
+
} finally {
|
|
15987
|
+
parsedAst?.dispose();
|
|
15988
|
+
}
|
|
15989
|
+
});
|
|
15990
|
+
const outcome = (await executeFiles([{
|
|
15991
|
+
path: relPath,
|
|
15992
|
+
text,
|
|
15993
|
+
wantsAst,
|
|
15994
|
+
...cachedFindings !== void 0 ? { cacheHit: cachedFindings } : {}
|
|
15995
|
+
}], executor))[0];
|
|
15996
|
+
if (outcome?.status === "CACHE_HIT" || outcome?.status === "OK") {
|
|
15997
|
+
for (const f of outcome.findings) findings.push(f);
|
|
15998
|
+
if (cachedFindings !== void 0 && outcome.status === "CACHE_HIT") analyzed++;
|
|
15229
15999
|
}
|
|
15230
16000
|
}
|
|
15231
16001
|
return {
|
|
@@ -15236,7 +16006,8 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
15236
16006
|
parseFailed,
|
|
15237
16007
|
parseFallbacks,
|
|
15238
16008
|
scanned,
|
|
15239
|
-
analyzed
|
|
16009
|
+
analyzed,
|
|
16010
|
+
astFallbackFiles
|
|
15240
16011
|
};
|
|
15241
16012
|
}
|
|
15242
16013
|
/**
|
|
@@ -15316,12 +16087,16 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
|
|
|
15316
16087
|
const runtimeReportPath = discoveredReport?.path;
|
|
15317
16088
|
const runtimeIncomplete = discoveredReport !== void 0 && discoveredReport.report.analysisComplete !== true;
|
|
15318
16089
|
let forensicVerdicts;
|
|
16090
|
+
let evidenceRecords = [];
|
|
15319
16091
|
if (discoveredReport && discoveredReport.report.analysisComplete === true) try {
|
|
15320
|
-
buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
|
|
16092
|
+
evidenceRecords = buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
|
|
15321
16093
|
stampRuntimeCorroboration(findings, discoveredReport.report, workspace.root);
|
|
15322
16094
|
forensicVerdicts = summarizeForensicVerdicts(discoveredReport.report);
|
|
15323
|
-
} catch {
|
|
16095
|
+
} catch {
|
|
16096
|
+
evidenceRecords = [];
|
|
16097
|
+
}
|
|
15324
16098
|
return {
|
|
16099
|
+
evidenceRecords,
|
|
15325
16100
|
testDeclarationCount,
|
|
15326
16101
|
scopeInfo,
|
|
15327
16102
|
suppressionCount,
|
|
@@ -15460,10 +16235,14 @@ function assembleScanResult(o) {
|
|
|
15460
16235
|
scopeUnrecognized: o.scopeUnrecognized,
|
|
15461
16236
|
parseFailed: o.parseFailed,
|
|
15462
16237
|
parseFallbacks: o.parseFallbacks ?? 0,
|
|
16238
|
+
...o.astFallbackFiles !== void 0 ? { astFallbackFiles: o.astFallbackFiles } : {},
|
|
16239
|
+
...o.degradations !== void 0 ? { degradations: o.degradations } : {},
|
|
15463
16240
|
...o.scopeInfo.degraded !== void 0 ? { scopeDegraded: o.scopeInfo.degraded } : {},
|
|
15464
16241
|
...o.runtimeIncomplete !== void 0 ? { runtimeIncomplete: o.runtimeIncomplete } : {},
|
|
15465
16242
|
identityIncomplete
|
|
15466
16243
|
});
|
|
16244
|
+
const repository = bindRepository(o.scanRoot.root);
|
|
16245
|
+
const candidate = readCandidateBinding(o.scanRoot.root);
|
|
15467
16246
|
const runIdentity = buildRunIdentity({
|
|
15468
16247
|
files: inputSnapshot,
|
|
15469
16248
|
rules: [...o.REVISION_BY_RULE_ID.entries()].map(([id, detectorRevision]) => ({
|
|
@@ -15476,9 +16255,17 @@ function assembleScanResult(o) {
|
|
|
15476
16255
|
trustModelVersion: TRUST_MODEL_VERSION,
|
|
15477
16256
|
scoringModelVersion: SCORING_MODEL_VERSION,
|
|
15478
16257
|
frameworkSupportMatrixVersion: FRAMEWORK_SUPPORT_MATRIX_VERSION,
|
|
15479
|
-
evidenceSchemaVersions: [1]
|
|
16258
|
+
evidenceSchemaVersions: [1],
|
|
16259
|
+
...repository,
|
|
16260
|
+
...candidate !== null ? { candidate } : {}
|
|
16261
|
+
});
|
|
16262
|
+
const evidenceGraph = buildEvidenceGraph({
|
|
16263
|
+
runId: runIdentity,
|
|
16264
|
+
...candidate !== null ? { candidate: {
|
|
16265
|
+
manifestId: candidate.manifestId,
|
|
16266
|
+
candidateSha: candidate.candidateSha
|
|
16267
|
+
} } : {}
|
|
15480
16268
|
});
|
|
15481
|
-
const evidenceGraph = buildEvidenceGraph({ runId: runIdentity });
|
|
15482
16269
|
const hasTests = o.testFileCount > 0 && o.testDeclarationCount > 0;
|
|
15483
16270
|
const suiteInvalidatedBy = [...new Set(o.findings.filter((f) => SUITE_INVALIDATING_RULE_IDS.has(f.ruleId)).map((f) => f.ruleId))].sort();
|
|
15484
16271
|
const finalScore = hasTests ? completion.partial && scopeAdjustedTotal >= 100 ? 99 : scopeAdjustedTotal : null;
|
|
@@ -15488,6 +16275,11 @@ function assembleScanResult(o) {
|
|
|
15488
16275
|
scopeIntegrity,
|
|
15489
16276
|
runIdentity,
|
|
15490
16277
|
evidenceGraph,
|
|
16278
|
+
evidence: {
|
|
16279
|
+
records: o.evidenceRecords ?? [],
|
|
16280
|
+
counts: countEvidence(o.evidenceRecords ?? []),
|
|
16281
|
+
artifact: o.runtimeReportPath ?? null
|
|
16282
|
+
},
|
|
15491
16283
|
score: finalScore,
|
|
15492
16284
|
...hasTests ? {} : { reason: "no-tests-found" },
|
|
15493
16285
|
frameworks: o.frameworks.frameworks,
|
|
@@ -15563,13 +16355,15 @@ function assembleScanResult(o) {
|
|
|
15563
16355
|
*/
|
|
15564
16356
|
async function runScan(args, hooks = {}) {
|
|
15565
16357
|
const started = Date.now();
|
|
16358
|
+
const degradationWindow = beginDegradationWindow();
|
|
15566
16359
|
const requestedDuration = Number.isFinite(args.maxDurationMs) ? args.maxDurationMs : DEFAULT_MAX_DURATION_MS;
|
|
15567
16360
|
const deadline = started + Math.min(Math.max(1, requestedDuration), MAX_DURATION_MS);
|
|
15568
16361
|
const discovered = discoverWorkspace(args.target);
|
|
15569
16362
|
const targetAbs = resolve(args.target);
|
|
15570
16363
|
const scanRoot = discovered && discovered.root !== targetAbs && targetAbs.startsWith(discovered.root + sep) ? {
|
|
15571
16364
|
...discovered,
|
|
15572
|
-
root: targetAbs
|
|
16365
|
+
root: targetAbs,
|
|
16366
|
+
packageJson: {}
|
|
15573
16367
|
} : discovered ?? fallbackWorkspace(targetAbs);
|
|
15574
16368
|
const workspace = scanRoot;
|
|
15575
16369
|
if (args.verbose) hooks.onConfigWarning?.(`verbose: scan root (config/ignore anchor): ${workspace.root}`);
|
|
@@ -15654,6 +16448,7 @@ async function runScan(args, hooks = {}) {
|
|
|
15654
16448
|
rulesPartial = analysis.rulesPartial;
|
|
15655
16449
|
parseFailed = analysis.parseFailed;
|
|
15656
16450
|
const parseFallbacks = analysis.parseFallbacks;
|
|
16451
|
+
const astFallbackFiles = analysis.astFallbackFiles;
|
|
15657
16452
|
const scanned = analysis.scanned;
|
|
15658
16453
|
const analyzed = analysis.analyzed;
|
|
15659
16454
|
const postScan = applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, declarationsByFile, testDeclarationCount, tierByRuleId, REVISION_BY_RULE_ID);
|
|
@@ -15672,6 +16467,8 @@ async function runScan(args, hooks = {}) {
|
|
|
15672
16467
|
scopeUnrecognized,
|
|
15673
16468
|
parseFailed,
|
|
15674
16469
|
parseFallbacks,
|
|
16470
|
+
astFallbackFiles,
|
|
16471
|
+
degradations: summarizeDegradations(degradationsSince(degradationWindow)),
|
|
15675
16472
|
scanned,
|
|
15676
16473
|
analyzed,
|
|
15677
16474
|
testFiles,
|
|
@@ -15699,4 +16496,6 @@ async function runScan(args, hooks = {}) {
|
|
|
15699
16496
|
return result;
|
|
15700
16497
|
}
|
|
15701
16498
|
//#endregion
|
|
15702
|
-
export {
|
|
16499
|
+
export { BADGE_BAND as $, plainContext as A, ConfigValidationError as At, shouldUseAscii as B, MEASURED_FP as Bt, runForensics as C, parseTsFile as Ct, nextStep as D, createIgnoreMatcher as Dt, buildFooter as E, LIMITS$1 as Et, padTo as F, isRecord as Ft, countOrNull as G, DEDUCTIONS as Gt, atomicTempPath as H, degradationsSince as Ht, palette as I, parseJsonFile as It, evidenceTag as J, SEVERITY_ORDER as Jt, deriveScoreState as K, QA_IMPACT_LABELS as Kt, sanitizeData as L, resolveGitPath as Lt, severityIcon as M, loadConfig as Mt, box as N, readFileBounded as Nt, okIcon as O, globToRegExp as Ot, measure as P, ENGINE_VERSION as Pt, TRUST_RUNGS as Q, isValidCategory as Qt, scoreGauge as R, runGit as Rt, renderSuppressions as S, computeCodeText as St, FLAKE_GLYPH as T, DEFAULT_IGNORE_MATCHER as Tt, sweepStaleTempFiles as U, recordDegradation as Ut, wrapText as V, beginDegradationWindow as Vt, writeFileAtomic as W, summarizeDegradations as Wt, testsAnalyzedCell as X, deriveEvidenceLevel as Xt, headlineFor as Y, TRUST_ORDER as Yt, verdictFor as Z, isAdvisoryFinding as Zt, summarizeForensicVerdicts as _, SEARCHED_FOR as _t, applyPostScanProcessing as a, correlateFindings as at, loadLocalRules as b, parseWorkflow as bt, discoverAndParseRuntimeReport as c, capForTier as ct, isValidFindingRecord as d, massCeiling as dt, SCORE as et, pathMatchesGlob as f, compareCodePoints as ft, selectAdapter as g, SCAN_ADAPTERS as gt, scan_pipeline_exports as h, getRule as ht, SUITE_INVALIDATING_RULE_IDS as i, getReachableFiles as it, sectionHeader as j, isSuppressionActive as jt, panel as k, isLintFixtureDir as kt, discoverTestFilesPhase as l, computeDimensions as lt, runScan as m, RULES as mt, KNOWN_RULE_IDS as n, TINT as nt, assembleScanResult as o, classifyProvenance as ot, runFileAnalysisPhase as p, RETIRED_RULE_IDS as pt, evidenceLevelOf as q, RULE_CATEGORIES as qt, OVERLAP_META_BY_RULE_ID as r, buildDependencyGraph as rt, buildUniversalRules as s, computeAgenticProfile as st, EVIDENCE_OVERRIDES as t, STATUS as tt, fallbackWorkspace as u, deductionFor as ut, pluginsGateOpen as v, isAzurePipelineFixture as vt, sanitizeErrorText as w, detectFrameworks as wt, loadSuppressions as x, parseYamlGuarded as xt, renderGateNotice as y, parseAzurePipeline as yt, shouldColorize as z, buildEvidenceGraph as zt };
|
|
16500
|
+
|
|
16501
|
+
//# sourceMappingURL=scan-pipeline-DT8DWAMl.mjs.map
|