mjolnir-qa 3.0.0 → 4.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 +360 -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 +218 -14
- package/dist/cli.mjs +1410 -883
- package/dist/mcp/stdio.mjs +1072 -408
- package/dist/scan-pipeline-Bg-46cLS.mjs +2 -0
- package/dist/{scan-pipeline-CAH9_Qgh.mjs → scan-pipeline-CCJdEKNa.mjs} +1443 -809
- package/package.json +52 -8
- 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)
|
|
@@ -742,21 +768,13 @@ const MEASURED_FP = {
|
|
|
742
768
|
* empty scan is an observation, not a proof).
|
|
743
769
|
*/
|
|
744
770
|
function summaryTrustLevel(findings) {
|
|
745
|
-
const order = [
|
|
746
|
-
"L0",
|
|
747
|
-
"L1",
|
|
748
|
-
"L2",
|
|
749
|
-
"L3",
|
|
750
|
-
"L4",
|
|
751
|
-
"L5"
|
|
752
|
-
];
|
|
753
771
|
let best = 0;
|
|
754
772
|
for (const f of findings) {
|
|
755
773
|
const t = f.trustLevel ?? "L2";
|
|
756
|
-
const idx =
|
|
774
|
+
const idx = TRUST_ORDER.indexOf(t);
|
|
757
775
|
if (idx > best) best = idx;
|
|
758
776
|
}
|
|
759
|
-
return
|
|
777
|
+
return TRUST_ORDER[best];
|
|
760
778
|
}
|
|
761
779
|
/**
|
|
762
780
|
* A finding's trust rung (0–5) on the canonical ladder. Corroborated
|
|
@@ -767,14 +785,7 @@ function summaryTrustLevel(findings) {
|
|
|
767
785
|
*/
|
|
768
786
|
function trustRung(f) {
|
|
769
787
|
const t = f.trustLevel ?? deriveTrustLevel(f);
|
|
770
|
-
return
|
|
771
|
-
"L0",
|
|
772
|
-
"L1",
|
|
773
|
-
"L2",
|
|
774
|
-
"L3",
|
|
775
|
-
"L4",
|
|
776
|
-
"L5"
|
|
777
|
-
].indexOf(t);
|
|
788
|
+
return TRUST_ORDER.indexOf(t);
|
|
778
789
|
}
|
|
779
790
|
/**
|
|
780
791
|
* Hard incompleteness ceilings (plan §6: partial/crash/truncation;
|
|
@@ -911,6 +922,17 @@ function buildRunIdentity(input) {
|
|
|
911
922
|
engineVersion: input.engineVersion
|
|
912
923
|
};
|
|
913
924
|
if (input.reportDigest) verdictInputs.reportDigest = input.reportDigest;
|
|
925
|
+
if (input.commit !== void 0) verdictInputs.commit = input.commit;
|
|
926
|
+
if (input.tree !== void 0) verdictInputs.tree = input.tree;
|
|
927
|
+
if (input.lockfile !== void 0) verdictInputs.lockfile = input.lockfile;
|
|
928
|
+
if (input.candidate !== void 0) verdictInputs.candidate = {
|
|
929
|
+
manifestId: input.candidate.manifestId,
|
|
930
|
+
state: input.candidate.state,
|
|
931
|
+
candidateSha: input.candidate.candidateSha,
|
|
932
|
+
baseSha: input.candidate.baseSha,
|
|
933
|
+
packageSha256: input.candidate.packageSha256,
|
|
934
|
+
lockfileSha256: input.candidate.lockfileSha256
|
|
935
|
+
};
|
|
914
936
|
if (input.trustModelVersion !== void 0) verdictInputs.trustModelVersion = input.trustModelVersion;
|
|
915
937
|
if (input.scoringModelVersion !== void 0) verdictInputs.scoringModelVersion = input.scoringModelVersion;
|
|
916
938
|
if (input.frameworkSupportMatrixVersion !== void 0) verdictInputs.frameworkSupportMatrixVersion = input.frameworkSupportMatrixVersion;
|
|
@@ -918,15 +940,31 @@ function buildRunIdentity(input) {
|
|
|
918
940
|
if (input.suppressionFingerprint !== void 0) verdictInputs.suppressionFingerprint = input.suppressionFingerprint;
|
|
919
941
|
if (input.policyFingerprint !== void 0) verdictInputs.policyFingerprint = input.policyFingerprint;
|
|
920
942
|
if (input.historicalEvidenceFingerprint !== void 0) verdictInputs.historicalEvidenceFingerprint = input.historicalEvidenceFingerprint;
|
|
943
|
+
const scanId = sha256$1(canonical(verdictInputs));
|
|
944
|
+
const boundLinks = [
|
|
945
|
+
"input",
|
|
946
|
+
"rules",
|
|
947
|
+
"config",
|
|
948
|
+
"engine"
|
|
949
|
+
];
|
|
950
|
+
if (input.commit !== void 0) boundLinks.push("commit");
|
|
951
|
+
if (input.tree !== void 0) boundLinks.push("tree");
|
|
952
|
+
if (input.lockfile !== void 0) boundLinks.push("lockfile");
|
|
953
|
+
if (input.candidate !== void 0) boundLinks.push("candidate");
|
|
921
954
|
return {
|
|
922
|
-
scanId
|
|
955
|
+
scanId,
|
|
923
956
|
inputFingerprint,
|
|
924
957
|
rulesDigest,
|
|
925
958
|
configFingerprint,
|
|
926
959
|
engineVersion: input.engineVersion,
|
|
960
|
+
boundLinks,
|
|
927
961
|
...input.trustModelVersion !== void 0 ? { trustModelVersion: input.trustModelVersion } : {},
|
|
928
962
|
...input.scoringModelVersion !== void 0 ? { scoringModelVersion: input.scoringModelVersion } : {},
|
|
929
|
-
...input.frameworkSupportMatrixVersion !== void 0 ? { frameworkSupportMatrixVersion: input.frameworkSupportMatrixVersion } : {}
|
|
963
|
+
...input.frameworkSupportMatrixVersion !== void 0 ? { frameworkSupportMatrixVersion: input.frameworkSupportMatrixVersion } : {},
|
|
964
|
+
...input.commit !== void 0 ? { commit: input.commit } : {},
|
|
965
|
+
...input.tree !== void 0 ? { tree: input.tree } : {},
|
|
966
|
+
...input.lockfile !== void 0 ? { lockfile: input.lockfile } : {},
|
|
967
|
+
...input.candidate !== void 0 ? { candidate: input.candidate } : {}
|
|
930
968
|
};
|
|
931
969
|
}
|
|
932
970
|
/**
|
|
@@ -967,9 +1005,218 @@ function buildEvidenceGraph(parts) {
|
|
|
967
1005
|
...parts.reproduction !== void 0 ? { ref: parts.reproduction } : {}
|
|
968
1006
|
}
|
|
969
1007
|
],
|
|
970
|
-
...parts.runId !== void 0 ? { runId: parts.runId } : {}
|
|
1008
|
+
...parts.runId !== void 0 ? { runId: parts.runId } : {},
|
|
1009
|
+
...parts.candidate !== void 0 ? { candidate: {
|
|
1010
|
+
manifestId: parts.candidate.manifestId,
|
|
1011
|
+
candidateSha: parts.candidate.candidateSha
|
|
1012
|
+
} } : {}
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
//#endregion
|
|
1016
|
+
//#region src/engine/file-executor.ts
|
|
1017
|
+
function cacheHitOutcome(job) {
|
|
1018
|
+
return {
|
|
1019
|
+
path: job.path,
|
|
1020
|
+
status: "CACHE_HIT",
|
|
1021
|
+
findings: [...job.cacheHit ?? []],
|
|
1022
|
+
parseFallback: false
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Drive a list of jobs through an executor and return outcomes in INPUT order.
|
|
1027
|
+
*
|
|
1028
|
+
* This is the function the pipeline calls. It is executor-agnostic on purpose:
|
|
1029
|
+
* swapping in a pooled executor must not require touching the caller, and the
|
|
1030
|
+
* ordering guarantee has to live here or it would live in each executor and
|
|
1031
|
+
* drift between them.
|
|
1032
|
+
*/
|
|
1033
|
+
async function executeFiles(jobs, executor, context = {}) {
|
|
1034
|
+
const outcomes = new Array(jobs.length);
|
|
1035
|
+
if (executor.concurrency <= 1 || jobs.length <= 1) {
|
|
1036
|
+
for (let i = 0; i < jobs.length; i++) {
|
|
1037
|
+
const job = jobs[i];
|
|
1038
|
+
context.onProgress?.({
|
|
1039
|
+
done: i,
|
|
1040
|
+
total: jobs.length,
|
|
1041
|
+
path: job.path
|
|
1042
|
+
});
|
|
1043
|
+
outcomes[i] = await runOne(job, executor, context);
|
|
1044
|
+
}
|
|
1045
|
+
context.onProgress?.({
|
|
1046
|
+
done: jobs.length,
|
|
1047
|
+
total: jobs.length,
|
|
1048
|
+
path: jobs[jobs.length - 1]?.path ?? ""
|
|
1049
|
+
});
|
|
1050
|
+
return outcomes;
|
|
1051
|
+
}
|
|
1052
|
+
let next = 0;
|
|
1053
|
+
let completed = 0;
|
|
1054
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
1055
|
+
const limit = Math.max(1, Math.floor(executor.concurrency));
|
|
1056
|
+
const worker = async () => {
|
|
1057
|
+
for (;;) {
|
|
1058
|
+
const index = next++;
|
|
1059
|
+
if (index >= jobs.length) return;
|
|
1060
|
+
const job = jobs[index];
|
|
1061
|
+
outcomes[index] = await runOne(job, executor, context);
|
|
1062
|
+
completed++;
|
|
1063
|
+
context.onProgress?.({
|
|
1064
|
+
done: completed,
|
|
1065
|
+
total: jobs.length,
|
|
1066
|
+
path: job.path
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
};
|
|
1070
|
+
for (let i = 0; i < Math.min(limit, jobs.length); i++) {
|
|
1071
|
+
const promise = worker().then(() => void 0);
|
|
1072
|
+
inFlight.add(promise);
|
|
1073
|
+
}
|
|
1074
|
+
await Promise.all(inFlight);
|
|
1075
|
+
return outcomes;
|
|
1076
|
+
}
|
|
1077
|
+
async function runOne(job, executor, context) {
|
|
1078
|
+
if (job.cacheHit !== void 0) return cacheHitOutcome(job);
|
|
1079
|
+
try {
|
|
1080
|
+
return await executor.execute(job, context);
|
|
1081
|
+
} catch (error) {
|
|
1082
|
+
return {
|
|
1083
|
+
path: job.path,
|
|
1084
|
+
status: "FAILED",
|
|
1085
|
+
findings: [],
|
|
1086
|
+
parseFallback: false,
|
|
1087
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* The strategy the pipeline uses today.
|
|
1093
|
+
*
|
|
1094
|
+
* `runOne` is supplied by the pipeline because parsing and rule execution
|
|
1095
|
+
* need the adapter and the active rule set, which are pipeline concerns. The
|
|
1096
|
+
* executor's job is the BOUNDARY and the bookkeeping, not the analysis — so
|
|
1097
|
+
* this factory exists to bind those two without the executor importing the
|
|
1098
|
+
* pipeline (which would be a cycle).
|
|
1099
|
+
*/
|
|
1100
|
+
function createExecutor(name, concurrency, runOne) {
|
|
1101
|
+
return {
|
|
1102
|
+
name,
|
|
1103
|
+
concurrency,
|
|
1104
|
+
execute: runOne
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
//#endregion
|
|
1108
|
+
//#region src/engine/candidate-binding.ts
|
|
1109
|
+
/**
|
|
1110
|
+
* Candidate binding (plan V5-010).
|
|
1111
|
+
*
|
|
1112
|
+
* Reads a candidate trust manifest and produces the immutable
|
|
1113
|
+
* `CandidateBinding` that a run identity is bound to. It also computes the
|
|
1114
|
+
* repository facts (commit, tree, lockfile digest) that the identity was
|
|
1115
|
+
* missing until this module existed.
|
|
1116
|
+
*
|
|
1117
|
+
* The law here is ABSENCE over invention. A manifest that is missing,
|
|
1118
|
+
* unreadable, or internally contradictory yields NO binding — not a partial
|
|
1119
|
+
* one. A consumer that can see "not bound" can refuse to trust the run; a
|
|
1120
|
+
* consumer handed a half-filled binding cannot tell what is missing, and will
|
|
1121
|
+
* trust the parts that are there.
|
|
1122
|
+
*/
|
|
1123
|
+
const CANDIDATE_MANIFEST_PATH = "candidate-trust-manifest.json";
|
|
1124
|
+
const SHA40 = /^[a-f0-9]{40}$/;
|
|
1125
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
1126
|
+
const STATES = ["WORKING_CANDIDATE", "RELEASE_CANDIDATE"];
|
|
1127
|
+
const AUTHORIZATION = ["NOT_AUTHORIZED", "AUTHORIZED"];
|
|
1128
|
+
function isRecord$1(value) {
|
|
1129
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1130
|
+
}
|
|
1131
|
+
function sha256File(path) {
|
|
1132
|
+
try {
|
|
1133
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
1134
|
+
} catch {
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
function gitLine(root, args) {
|
|
1139
|
+
try {
|
|
1140
|
+
const trimmed = execFileSync("git", args, {
|
|
1141
|
+
cwd: root,
|
|
1142
|
+
encoding: "utf8",
|
|
1143
|
+
stdio: [
|
|
1144
|
+
"ignore",
|
|
1145
|
+
"pipe",
|
|
1146
|
+
"ignore"
|
|
1147
|
+
],
|
|
1148
|
+
windowsHide: true
|
|
1149
|
+
}).trim();
|
|
1150
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
1151
|
+
} catch {
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* The commit and tree the working tree is at. Both absent outside a
|
|
1157
|
+
* repository or with no commits — never substituted with a placeholder.
|
|
1158
|
+
*/
|
|
1159
|
+
function bindRepository(root) {
|
|
1160
|
+
const commit = gitLine(root, ["rev-parse", "HEAD"]);
|
|
1161
|
+
const tree = gitLine(root, ["rev-parse", "HEAD^{tree}"]);
|
|
1162
|
+
const lockfile = sha256File(join(root, "package-lock.json"));
|
|
1163
|
+
return {
|
|
1164
|
+
...commit !== void 0 ? { commit } : {},
|
|
1165
|
+
...tree !== void 0 ? { tree } : {},
|
|
1166
|
+
...lockfile !== void 0 ? { lockfile } : {}
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Parse a candidate manifest into a binding, or return null when it cannot
|
|
1171
|
+
* honestly be bound.
|
|
1172
|
+
*
|
|
1173
|
+
* Null is returned for: no manifest, unreadable JSON, a missing or malformed
|
|
1174
|
+
* required field, or a state/authorization combination that cannot legally
|
|
1175
|
+
* exist (a WORKING_CANDIDATE carrying a commit, for example — the same
|
|
1176
|
+
* contradiction the release decision evaluator treats as BLOCKED).
|
|
1177
|
+
*/
|
|
1178
|
+
function parseCandidateBinding(raw) {
|
|
1179
|
+
if (!isRecord$1(raw)) return null;
|
|
1180
|
+
const identity = raw.identity;
|
|
1181
|
+
if (!isRecord$1(identity)) return null;
|
|
1182
|
+
const state = identity.state;
|
|
1183
|
+
const candidateSha = identity.candidateSha;
|
|
1184
|
+
const baseSha = identity.baseSha;
|
|
1185
|
+
const packageSha256 = identity.packageSha256;
|
|
1186
|
+
const lockfileSha256 = identity.lockfileSha256;
|
|
1187
|
+
if (typeof state !== "string" || !STATES.includes(state)) return null;
|
|
1188
|
+
if (typeof baseSha !== "string" || !SHA40.test(baseSha)) return null;
|
|
1189
|
+
if (typeof packageSha256 !== "string" || !SHA256.test(packageSha256)) return null;
|
|
1190
|
+
if (typeof lockfileSha256 !== "string" || !SHA256.test(lockfileSha256)) return null;
|
|
1191
|
+
if (candidateSha !== null && (typeof candidateSha !== "string" || !SHA40.test(candidateSha))) return null;
|
|
1192
|
+
const manifestId = raw.manifestId;
|
|
1193
|
+
if (typeof manifestId !== "string" || manifestId.length === 0) return null;
|
|
1194
|
+
const owner = typeof raw.owner === "string" ? raw.owner : "UNASSIGNED";
|
|
1195
|
+
const authorization = raw.releaseAuthorizationState;
|
|
1196
|
+
if (typeof authorization !== "string" || !AUTHORIZATION.includes(authorization)) return null;
|
|
1197
|
+
if (state === "WORKING_CANDIDATE") {
|
|
1198
|
+
if (candidateSha !== null) return null;
|
|
1199
|
+
if (authorization !== "NOT_AUTHORIZED") return null;
|
|
1200
|
+
} else if (candidateSha === null) return null;
|
|
1201
|
+
return {
|
|
1202
|
+
manifestId,
|
|
1203
|
+
state,
|
|
1204
|
+
candidateSha,
|
|
1205
|
+
baseSha,
|
|
1206
|
+
packageSha256,
|
|
1207
|
+
lockfileSha256,
|
|
1208
|
+
owner,
|
|
1209
|
+
releaseAuthorizationState: authorization
|
|
971
1210
|
};
|
|
972
1211
|
}
|
|
1212
|
+
/** Read and bind the manifest from a checkout, or null. */
|
|
1213
|
+
function readCandidateBinding(root, manifestPath = CANDIDATE_MANIFEST_PATH) {
|
|
1214
|
+
try {
|
|
1215
|
+
return parseCandidateBinding(JSON.parse(readFileSync(join(root, manifestPath), "utf8")));
|
|
1216
|
+
} catch {
|
|
1217
|
+
return null;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
973
1220
|
//#endregion
|
|
974
1221
|
//#region src/engine/version.ts
|
|
975
1222
|
/**
|
|
@@ -980,7 +1227,7 @@ function buildEvidenceGraph(parts) {
|
|
|
980
1227
|
* scripts/sync-sarif-version.cjs and guarded by the version-consistency
|
|
981
1228
|
* spec. cli.ts re-exports this as CLI_VERSION.
|
|
982
1229
|
*/
|
|
983
|
-
const ENGINE_VERSION = "
|
|
1230
|
+
const ENGINE_VERSION = "4.0.0";
|
|
984
1231
|
//#endregion
|
|
985
1232
|
//#region src/engine/contract-versions.ts
|
|
986
1233
|
/**
|
|
@@ -1388,20 +1635,9 @@ function validate(cfg, knownRuleIds) {
|
|
|
1388
1635
|
return warnings;
|
|
1389
1636
|
}
|
|
1390
1637
|
/**
|
|
1391
|
-
*
|
|
1392
|
-
*
|
|
1393
|
-
*
|
|
1394
|
-
* documented window.
|
|
1395
|
-
*
|
|
1396
|
-
* Audit S4 (remediation plan): the config-file MTIME is no longer an
|
|
1397
|
-
* expiry anchor. Anchoring the 90-day default at mtime meant ANY edit to
|
|
1398
|
-
* mjolnir.config.json — a reformat, an unrelated key, a `touch` — reset
|
|
1399
|
-
* the 90-day window for EVERY hand-authored entry: suppressions could be
|
|
1400
|
-
* extended indefinitely without touching their own fields. The expiry is
|
|
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).
|
|
1638
|
+
* Expiry is evaluated only from an explicit ISO `expires` date. A missing
|
|
1639
|
+
* date remains active and is reported as having no expiry. Config-file mtime
|
|
1640
|
+
* is never an expiry anchor.
|
|
1405
1641
|
*/
|
|
1406
1642
|
function isSuppressionActive(ign, now = /* @__PURE__ */ new Date()) {
|
|
1407
1643
|
if (!ign.expires) return true;
|
|
@@ -1684,7 +1920,33 @@ function sharedWalk(options) {
|
|
|
1684
1920
|
*
|
|
1685
1921
|
* When nothing is detectable we report `unknown` and the scanner analyzes
|
|
1686
1922
|
* all test-looking files — stated honestly in output rather than guessed.
|
|
1923
|
+
*
|
|
1924
|
+
* ## One ID space (plan V5-024)
|
|
1925
|
+
*
|
|
1926
|
+
* This detector used to declare its own `TestFramework` union of three
|
|
1927
|
+
* literals while `src/frameworks/framework-inventory.ts` catalogued fourteen
|
|
1928
|
+
* framework ids. Two vocabularies for one concept is how a support matrix
|
|
1929
|
+
* starts disagreeing with what the tool actually detects: a framework can be
|
|
1930
|
+
* catalogued as OFFICIAL_PARTIAL and never once be emitted by the code that
|
|
1931
|
+
* claims to detect it.
|
|
1932
|
+
*
|
|
1933
|
+
* So the ids here are `FrameworkId`s — the inventory's own vocabulary — and
|
|
1934
|
+
* `DETECTABLE_TEST_FRAMEWORKS` is the subset this detector can actually
|
|
1935
|
+
* resolve from a checkout. That subset is the HONEST limit of detection, and
|
|
1936
|
+
* `detectableVsCatalogued()` reports the remainder rather than letting a
|
|
1937
|
+
* three-item list read as the whole catalog.
|
|
1938
|
+
*/
|
|
1939
|
+
/**
|
|
1940
|
+
* The catalogued frameworks whose detection is implemented today.
|
|
1941
|
+
*
|
|
1942
|
+
* Every entry must exist in the inventory — enforced by the parity spec, not
|
|
1943
|
+
* by comment.
|
|
1687
1944
|
*/
|
|
1945
|
+
const DETECTABLE_TEST_FRAMEWORKS = [
|
|
1946
|
+
"jest",
|
|
1947
|
+
"vitest",
|
|
1948
|
+
"playwright"
|
|
1949
|
+
];
|
|
1688
1950
|
const CONFIG_FILES = {
|
|
1689
1951
|
jest: [
|
|
1690
1952
|
"jest.config.ts",
|
|
@@ -1703,7 +1965,7 @@ const CONFIG_FILES = {
|
|
|
1703
1965
|
};
|
|
1704
1966
|
function detectFrameworks(ws) {
|
|
1705
1967
|
const found = /* @__PURE__ */ new Set();
|
|
1706
|
-
for (const fw of
|
|
1968
|
+
for (const fw of DETECTABLE_TEST_FRAMEWORKS) if (CONFIG_FILES[fw].some((f) => existsSync(join(ws.root, f)))) found.add(fw);
|
|
1707
1969
|
if (!found.has("jest") && ws.packageJson["jest"] !== void 0) found.add("jest");
|
|
1708
1970
|
const deps = {
|
|
1709
1971
|
...ws.packageJson["dependencies"],
|
|
@@ -1725,11 +1987,7 @@ function detectFrameworks(ws) {
|
|
|
1725
1987
|
};
|
|
1726
1988
|
}
|
|
1727
1989
|
return {
|
|
1728
|
-
frameworks:
|
|
1729
|
-
"jest",
|
|
1730
|
-
"vitest",
|
|
1731
|
-
"playwright"
|
|
1732
|
-
].filter((f) => found.has(f)),
|
|
1990
|
+
frameworks: DETECTABLE_TEST_FRAMEWORKS.filter((f) => found.has(f)),
|
|
1733
1991
|
unknown: false
|
|
1734
1992
|
};
|
|
1735
1993
|
}
|
|
@@ -1746,6 +2004,13 @@ function detectFrameworks(ws) {
|
|
|
1746
2004
|
* byte-identical — a score shift is a regression, not an improvement.
|
|
1747
2005
|
*/
|
|
1748
2006
|
let project = null;
|
|
2007
|
+
/**
|
|
2008
|
+
* The shared ts-morph project.
|
|
2009
|
+
*
|
|
2010
|
+
* Exported so an adapter's `dispose()` can evict the file it parsed: ts-morph
|
|
2011
|
+
* caches by file path, so without eviction a long scan holds every parsed
|
|
2012
|
+
* SourceFile until the process exits (plan V5-021).
|
|
2013
|
+
*/
|
|
1749
2014
|
function getProject() {
|
|
1750
2015
|
if (!project) project = new Project({
|
|
1751
2016
|
useInMemoryFileSystem: true,
|
|
@@ -2272,7 +2537,36 @@ function frameworkFilterApplies(rule, file) {
|
|
|
2272
2537
|
* "cypress", `@jest/globals` → "jest", `vitest` → "vitest". Config
|
|
2273
2538
|
* gating is rule-declared (`configFiles`), not hard-coded here.
|
|
2274
2539
|
*/
|
|
2275
|
-
|
|
2540
|
+
/**
|
|
2541
|
+
* `.test.` / `.spec.` filenames, plus the Cypress `.cy.` convention.
|
|
2542
|
+
*
|
|
2543
|
+
* `mts` and `cts` are TypeScript's Node-native ESM/CJS extensions. They were
|
|
2544
|
+
* missing here, and the omission was invisible in the worst way — not a wrong
|
|
2545
|
+
* answer, but an ABSENCE.
|
|
2546
|
+
*
|
|
2547
|
+
* Reproduced: a repo containing two byte-identical tests, one at
|
|
2548
|
+
* `tests/control.spec.ts` and one at `tests/slow.spec.mts`, each with a
|
|
2549
|
+
* `QA-TEST-004` hard sleep:
|
|
2550
|
+
*
|
|
2551
|
+
* discovered: 1, analyzed: 1, unrecognized: 1, scopeVerdict: "PARTIAL"
|
|
2552
|
+
*
|
|
2553
|
+
* The `.ts` test produced the finding. The `.mts` test produced nothing at
|
|
2554
|
+
* all — it was never scanned, so every rule that could have caught it was
|
|
2555
|
+
* silent on it, with no finding, no low-evidence note, and no indication that
|
|
2556
|
+
* a test file had been skipped. A test scanner that cannot see a whole file
|
|
2557
|
+
* extension has a false green that is invisible by construction: the reader
|
|
2558
|
+
* has nothing to distrust, because there is nothing there.
|
|
2559
|
+
*
|
|
2560
|
+
* The corroboration that this was an oversight rather than a decision sits
|
|
2561
|
+
* four lines below: PW_CONFIG_RE already accepted `cts`. The config regex knew
|
|
2562
|
+
* about the Node-native extensions; the test-file regex did not.
|
|
2563
|
+
*
|
|
2564
|
+
* `discovery/scan-adapters.ts:isUnrecognizedSourceCandidate` already counted
|
|
2565
|
+
* these paths as uncovered surface, so the scope accounting had been reporting
|
|
2566
|
+
* the hole the whole time — it was being read as a known limitation rather than
|
|
2567
|
+
* as a defect.
|
|
2568
|
+
*/
|
|
2569
|
+
const TEST_FILE_RE = /\.(?:test|spec)\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$|\.cy\.(?:js|jsx|ts|tsx)$/;
|
|
2276
2570
|
/**
|
|
2277
2571
|
* Fallback config list for `configOnly` rules that do not declare
|
|
2278
2572
|
* `configFiles` (the legacy playwright.config.* gating, preserved
|
|
@@ -2379,11 +2673,42 @@ const typescriptAdapter = {
|
|
|
2379
2673
|
fixtureDirMemo: /* @__PURE__ */ new Map()
|
|
2380
2674
|
});
|
|
2381
2675
|
},
|
|
2676
|
+
/**
|
|
2677
|
+
* The ONE AST seam (plan V5-021).
|
|
2678
|
+
*
|
|
2679
|
+
* This adapter used to parse inside `runRules`, on the synchronous path,
|
|
2680
|
+
* while Java and C# exposed the async `parseAst` hook. Two seams meant two
|
|
2681
|
+
* sets of consequences, both of them bad:
|
|
2682
|
+
*
|
|
2683
|
+
* - the pipeline computes `wantsAst` as `adapter.parseAst !== undefined`,
|
|
2684
|
+
* so a TypeScript file never took the AST path through the pipeline at
|
|
2685
|
+
* all, and its parse failures were invisible to the pipeline's
|
|
2686
|
+
* fallback counters;
|
|
2687
|
+
* - nothing called `dispose()` for the ts-morph path, so a scan held
|
|
2688
|
+
* every parsed SourceFile for its whole lifetime.
|
|
2689
|
+
*
|
|
2690
|
+
* Parsing now happens here, once, through the same contract every other
|
|
2691
|
+
* adapter uses. `dispose()` drops this file's SourceFile from the shared
|
|
2692
|
+
* project; ts-morph caches per file path, so removing it is what keeps
|
|
2693
|
+
* memory proportional to one file rather than to the whole scan.
|
|
2694
|
+
*/
|
|
2695
|
+
parseAst(file) {
|
|
2696
|
+
const sourceFile = parseTsFile(file);
|
|
2697
|
+
if (sourceFile === void 0) return void 0;
|
|
2698
|
+
return {
|
|
2699
|
+
ast: sourceFile,
|
|
2700
|
+
dispose: () => {
|
|
2701
|
+
try {
|
|
2702
|
+
getProject().removeSourceFile(sourceFile);
|
|
2703
|
+
} catch {}
|
|
2704
|
+
}
|
|
2705
|
+
};
|
|
2706
|
+
},
|
|
2382
2707
|
runRules(rules, file, emit, onCrash, budget) {
|
|
2383
|
-
const withAst = {
|
|
2708
|
+
const withAst = file.ast === void 0 ? {
|
|
2384
2709
|
...file,
|
|
2385
2710
|
ast: parseTsFile(file)
|
|
2386
|
-
};
|
|
2711
|
+
} : file;
|
|
2387
2712
|
const withTags = {
|
|
2388
2713
|
...withAst,
|
|
2389
2714
|
frameworkTags: frameworkTagsFromImports(withAst.text)
|
|
@@ -3802,9 +4127,37 @@ function discoverAllTestFiles(ctx, languageAdapters, buckets, fixtureDirMemo) {
|
|
|
3802
4127
|
}
|
|
3803
4128
|
});
|
|
3804
4129
|
}
|
|
4130
|
+
/**
|
|
4131
|
+
* Would ignoring this file have removed it from the scanned surface?
|
|
4132
|
+
*
|
|
4133
|
+
* The bug this fixes, reproduced: a repo containing a real test file plus
|
|
4134
|
+
* `vendor.min.js` and `pnpm-lock.yaml` reported
|
|
4135
|
+
*
|
|
4136
|
+
* discovered: 1, analyzed: 1, ignored: 2, scopeVerdict: "PARTIAL"
|
|
4137
|
+
*
|
|
4138
|
+
* Every discovered test file HAD been analyzed. The surface was complete. But
|
|
4139
|
+
* DEFAULT_IGNORES matches minified bundles and lockfiles, and the ignored
|
|
4140
|
+
* counter accepted any path with a source extension — so a minified bundle and
|
|
4141
|
+
* a lockfile downgraded a finished scan to "unverified".
|
|
4142
|
+
*
|
|
4143
|
+
* That is not a conservative bias, it is a broken signal. The terminal tells
|
|
4144
|
+
* the reader "some files were not analyzed, so the surface is unverified" —
|
|
4145
|
+
* which was false. And because minified bundles and lockfiles exist in
|
|
4146
|
+
* essentially every real repository, a PROVEN scan was unreachable in
|
|
4147
|
+
* practice, so the corpus regression guard failed 23 of 37 repositories on
|
|
4148
|
+
* exactly this. A gate that cannot pass teaches people to ignore it.
|
|
4149
|
+
*
|
|
4150
|
+
* The asymmetry is the tell. `isUnrecognizedSourceCandidate` below applies a
|
|
4151
|
+
* test-relevance test before counting a file; this one did not. Both count
|
|
4152
|
+
* against the same verdict, so both must ask the same question.
|
|
4153
|
+
*
|
|
4154
|
+
* An ignored file that IS a test file still downgrades the verdict. That is
|
|
4155
|
+
* the case the honesty law exists for, and this does not touch it.
|
|
4156
|
+
*/
|
|
3805
4157
|
function isScopeRelevantIgnored(path) {
|
|
3806
4158
|
const name = path.replaceAll("\\", "/").split("/").pop() ?? path;
|
|
3807
|
-
|
|
4159
|
+
if (!/\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml|min\.js)$/i.test(name)) return false;
|
|
4160
|
+
return isKnownTestFile(path);
|
|
3808
4161
|
}
|
|
3809
4162
|
function isUnrecognizedSourceCandidate(path) {
|
|
3810
4163
|
const normalized = path.replaceAll("\\", "/");
|
|
@@ -7691,7 +8044,6 @@ const pyBareTruthinessAssert = defineRule({
|
|
|
7691
8044
|
const re = /^[ \t]*assert\s+([A-Za-z_][\w.]*(?:\([^()]*\))?)[ \t]*$/gm;
|
|
7692
8045
|
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
8046
|
const isGuardFollowedByRealUse = (text, matchIndex, target) => {
|
|
7694
|
-
const root = target.split(".")[0];
|
|
7695
8047
|
const lineEnd = text.indexOf("\n", matchIndex);
|
|
7696
8048
|
if (lineEnd === -1) return false;
|
|
7697
8049
|
const lines = text.slice(lineEnd + 1).split("\n");
|
|
@@ -7701,8 +8053,11 @@ const pyBareTruthinessAssert = defineRule({
|
|
|
7701
8053
|
if (/^\s*def\s/.test(l) && window.length > 0) break;
|
|
7702
8054
|
window.push(l);
|
|
7703
8055
|
}
|
|
7704
|
-
const
|
|
7705
|
-
|
|
8056
|
+
const root = target.match(/^[a-z_]\w*/i)?.[0];
|
|
8057
|
+
if (!root) return false;
|
|
8058
|
+
return window.some((line) => {
|
|
8059
|
+
return (line.match(/[a-z_]\w*/gi) ?? []).some((token) => token === root);
|
|
8060
|
+
});
|
|
7706
8061
|
};
|
|
7707
8062
|
let m;
|
|
7708
8063
|
while ((m = re.exec(text)) !== null) {
|
|
@@ -11705,6 +12060,22 @@ function normalizeOne(report, artifact, v) {
|
|
|
11705
12060
|
function buildEvidenceRecords(report, artifact) {
|
|
11706
12061
|
return report.verdicts.map((v) => normalizeOne(report, artifact, v)).sort(compareEvidenceRecords);
|
|
11707
12062
|
}
|
|
12063
|
+
function countEvidence(records) {
|
|
12064
|
+
const counts = {
|
|
12065
|
+
total: records.length,
|
|
12066
|
+
failed: 0,
|
|
12067
|
+
flaky: 0,
|
|
12068
|
+
skipped: 0,
|
|
12069
|
+
timedOut: 0
|
|
12070
|
+
};
|
|
12071
|
+
for (const r of records) {
|
|
12072
|
+
if (r.status.failed) counts.failed++;
|
|
12073
|
+
if (r.status.passedOnRetry) counts.flaky++;
|
|
12074
|
+
if (r.status.skipped) counts.skipped++;
|
|
12075
|
+
if (r.status.timedOut) counts.timedOut++;
|
|
12076
|
+
}
|
|
12077
|
+
return counts;
|
|
12078
|
+
}
|
|
11708
12079
|
//#endregion
|
|
11709
12080
|
//#region src/engine/provenance.ts
|
|
11710
12081
|
const GENERATED_HEADER_RE = /^\s*(?:\/\/|#|\/\*)\s*(?:auto[- ]?generated|generated by|do not edit)/i;
|
|
@@ -11992,6 +12363,11 @@ var DependencyGraph = class {
|
|
|
11992
12363
|
get size() {
|
|
11993
12364
|
return this.nodes.size;
|
|
11994
12365
|
}
|
|
12366
|
+
/** Whether the graph knows this exact key. Reachability must not
|
|
12367
|
+
* treat "not in the graph" as "nothing to traverse" without saying so. */
|
|
12368
|
+
has(path) {
|
|
12369
|
+
return this.nodes.has(path);
|
|
12370
|
+
}
|
|
11995
12371
|
};
|
|
11996
12372
|
function parsePackageJson(filePath) {
|
|
11997
12373
|
if (!existsSync(filePath)) return void 0;
|
|
@@ -12079,20 +12455,40 @@ function parseManifest(filePath) {
|
|
|
12079
12455
|
if (filePath.endsWith("pom.xml")) return parsePomXml(filePath);
|
|
12080
12456
|
}
|
|
12081
12457
|
/**
|
|
12082
|
-
* Given a set of starting files (e.g. test files), return
|
|
12083
|
-
* transitively reachable through the dependency graph
|
|
12458
|
+
* Given a set of starting files (e.g. test files), return the files
|
|
12459
|
+
* transitively reachable through the dependency graph, plus an honest
|
|
12460
|
+
* account of what could not be resolved.
|
|
12461
|
+
*
|
|
12462
|
+
* KNOWN LIMITATION (BW-022, still open): the graph is keyed by manifest path
|
|
12463
|
+
* and this query is given source paths, so today `unresolvedStarts` is
|
|
12464
|
+
* normally every input. The traversal itself is correct for a graph whose
|
|
12465
|
+
* keys are the paths being queried — which is what the unit tests build, and
|
|
12466
|
+
* which is why they passed while production did nothing. Fixing the seam
|
|
12467
|
+
* means giving a file a resolvable owning manifest and deciding impact's
|
|
12468
|
+
* direction (dependents, not dependencies), which is a design change, not a
|
|
12469
|
+
* patch. Until then this function reports that it resolved nothing instead
|
|
12470
|
+
* of implying otherwise.
|
|
12084
12471
|
*/
|
|
12085
12472
|
function getReachableFiles(fromFiles, graph) {
|
|
12086
12473
|
const reachable = /* @__PURE__ */ new Set();
|
|
12474
|
+
const unresolvedStarts = [];
|
|
12087
12475
|
const stack = [...fromFiles];
|
|
12088
12476
|
while (stack.length > 0) {
|
|
12089
12477
|
const current = stack.pop();
|
|
12090
12478
|
if (current === void 0) continue;
|
|
12091
12479
|
if (reachable.has(current)) continue;
|
|
12092
|
-
reachable.add(current);
|
|
12480
|
+
if (graph.has(current)) reachable.add(current);
|
|
12481
|
+
else if (fromFiles.includes(current)) {
|
|
12482
|
+
unresolvedStarts.push(current);
|
|
12483
|
+
continue;
|
|
12484
|
+
}
|
|
12093
12485
|
for (const dep of graph.getDependencies(current)) if (!reachable.has(dep)) stack.push(dep);
|
|
12094
12486
|
}
|
|
12095
|
-
return
|
|
12487
|
+
return {
|
|
12488
|
+
reachable: [...reachable].sort(),
|
|
12489
|
+
unresolvedStarts: unresolvedStarts.sort(),
|
|
12490
|
+
resolvedAny: unresolvedStarts.length < fromFiles.length
|
|
12491
|
+
};
|
|
12096
12492
|
}
|
|
12097
12493
|
//#endregion
|
|
12098
12494
|
//#region src/engine/incremental-analysis.ts
|
|
@@ -12132,739 +12528,934 @@ function isIncrementalSafe(changedFiles) {
|
|
|
12132
12528
|
};
|
|
12133
12529
|
}
|
|
12134
12530
|
//#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
|
-
}
|
|
12531
|
+
//#region src/brand/tokens.ts
|
|
12147
12532
|
/**
|
|
12148
|
-
*
|
|
12533
|
+
* The single source of brand truth.
|
|
12149
12534
|
*
|
|
12150
|
-
*
|
|
12151
|
-
*
|
|
12152
|
-
*
|
|
12153
|
-
*
|
|
12535
|
+
* Every colour, typeface and motion constant Mjölnir shows a human —
|
|
12536
|
+
* terminal, README SVGs, demo video, website, docs, badges — resolves to
|
|
12537
|
+
* a value in this file. Nothing else may define one.
|
|
12538
|
+
*
|
|
12539
|
+
* WHY THIS EXISTS. Before it, the palette existed in six independent
|
|
12540
|
+
* copies: `site/.vitepress/theme/styles/vars.css`, `NORSE` in
|
|
12541
|
+
* `src/reporter/theme.ts`, `scripts/readme-svg.ts`,
|
|
12542
|
+
* `scripts/video/terminal-page.ts`, `scripts/generate-readme-architecture.ts`
|
|
12543
|
+
* and the table in `assets/brand/README.md`. Exactly one pair of those
|
|
12544
|
+
* was guarded (site-doctor Check 8, doc ↔ vars.css). The unguarded edges
|
|
12545
|
+
* are where the shipped surfaces drifted apart: the terminal and the site
|
|
12546
|
+
* disagreed on six semantic roles, the architecture diagram invented its
|
|
12547
|
+
* own neutral ramp, and the README badges still carried a palette retired
|
|
12548
|
+
* two releases earlier. `scripts/brand-doctor.mjs` now checks every edge
|
|
12549
|
+
* against this file.
|
|
12550
|
+
*
|
|
12551
|
+
* PURITY. Pure data. No I/O, no rendering, no environment access, no
|
|
12552
|
+
* imports, no logic. Consumers convert (hex → ANSI triplet, hex → CSS)
|
|
12553
|
+
* themselves. Same reason `presentation.ts` is pure: it makes the whole
|
|
12554
|
+
* thing golden-testable and safe to ship inside the npm package, where
|
|
12555
|
+
* it costs a few hundred bytes and replaces values the package already
|
|
12556
|
+
* carried anyway.
|
|
12557
|
+
*
|
|
12558
|
+
* DERIVATION. The palette is the one derived from the logo in PR #20
|
|
12559
|
+
* (brushed steel and forge gold under an aurora, over midnight iron).
|
|
12560
|
+
* Where the terminal disagreed with it, the terminal converges — see
|
|
12561
|
+
* `PENDING_TERMINAL` below. Full rationale: `assets/brand/README.md`.
|
|
12562
|
+
*
|
|
12563
|
+
* ACCESSIBILITY. Every foreground token in `BRAND` meets WCAG AA
|
|
12564
|
+
* (≥ 4.5:1) against every surface token it is allowed to sit on. That is
|
|
12565
|
+
* not a claim, it is `brand-doctor` rule 8, which computes the ratios.
|
|
12566
|
+
* The weakest legal pairing is `steelDim` on `ink800` at 5.00:1.
|
|
12154
12567
|
*/
|
|
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
12568
|
/**
|
|
12240
|
-
*
|
|
12569
|
+
* The two brand hues plus the neutral they sit on.
|
|
12241
12570
|
*
|
|
12242
|
-
*
|
|
12243
|
-
*
|
|
12244
|
-
*
|
|
12245
|
-
*
|
|
12246
|
-
* confident nonsense from it.
|
|
12571
|
+
* GOLD IS SCARCE. It means forged / certified / earned / decisive — the
|
|
12572
|
+
* primary mark, the FORGED state, one call to action. It is not a paint
|
|
12573
|
+
* bucket: gold as default text, default border or default heading is a
|
|
12574
|
+
* brand-doctor finding, not a style choice.
|
|
12247
12575
|
*
|
|
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).
|
|
12576
|
+
* AURORA is verification energy — the secondary, and the hue that marks
|
|
12577
|
+
* the runtime half of the trust ladder.
|
|
12255
12578
|
*/
|
|
12256
|
-
|
|
12257
|
-
|
|
12258
|
-
|
|
12579
|
+
const BRAND = {
|
|
12580
|
+
gold: "#C19A34",
|
|
12581
|
+
goldBright: "#E6BD57",
|
|
12582
|
+
goldHot: "#F4DC9C",
|
|
12583
|
+
/** Pressed / deepest gold — the only step dark enough to carry white. */
|
|
12584
|
+
goldDeep: "#A5811C",
|
|
12585
|
+
aurora: "#37ABBD",
|
|
12586
|
+
auroraBright: "#45C1D4",
|
|
12587
|
+
auroraCyan: "#5CBDE0",
|
|
12588
|
+
/** The aurora's outer curtains: atmosphere and section identity only,
|
|
12589
|
+
* never a verdict or a status. */
|
|
12590
|
+
auroraGreen: "#5FD6A4",
|
|
12591
|
+
auroraViolet: "#9D8CF5",
|
|
12592
|
+
steel: "#C8CBCF",
|
|
12593
|
+
steelDim: "#8B939D"
|
|
12594
|
+
};
|
|
12259
12595
|
/**
|
|
12260
|
-
*
|
|
12596
|
+
* Midnight iron. One ramp, four steps, darkest first.
|
|
12597
|
+
*
|
|
12598
|
+
* `terminal` and `terminalBar` share one tone deliberately: the window's
|
|
12599
|
+
* only seam is a hairline ring and an inset shadow, never a second fill.
|
|
12600
|
+
* `chromeDot` is the three window dots — see the note on
|
|
12601
|
+
* `PENDING_TERMINAL.chromeDots` for why they are no longer red/amber/green.
|
|
12261
12602
|
*/
|
|
12262
|
-
|
|
12263
|
-
|
|
12264
|
-
|
|
12265
|
-
|
|
12266
|
-
|
|
12267
|
-
|
|
12268
|
-
|
|
12269
|
-
|
|
12270
|
-
|
|
12271
|
-
|
|
12272
|
-
|
|
12273
|
-
|
|
12274
|
-
|
|
12275
|
-
|
|
12276
|
-
|
|
12277
|
-
|
|
12278
|
-
|
|
12279
|
-
|
|
12280
|
-
|
|
12281
|
-
|
|
12603
|
+
const SURFACE = {
|
|
12604
|
+
ink950: "#0A1119",
|
|
12605
|
+
ink900: "#0C1420",
|
|
12606
|
+
ink850: "#111A29",
|
|
12607
|
+
ink800: "#18243A",
|
|
12608
|
+
/** Raised panel (cards, elevated surfaces). */
|
|
12609
|
+
panel: "#141F33",
|
|
12610
|
+
/** Soft fill (inline code, quiet chips). */
|
|
12611
|
+
soft: "#1A2740",
|
|
12612
|
+
/** Terminal body — the deepest tone, so a terminal reads as recessed. */
|
|
12613
|
+
terminal: "#0A1119",
|
|
12614
|
+
/** Terminal title bar — the same tone; the seam is shadow, not colour. */
|
|
12615
|
+
terminalBar: "#0A1119",
|
|
12616
|
+
/** The three window dots. One neutral, not a traffic light. */
|
|
12617
|
+
chromeDot: "#18243A"
|
|
12618
|
+
};
|
|
12619
|
+
const TEXT = {
|
|
12620
|
+
primary: "#EAEEF5",
|
|
12621
|
+
secondary: "#ABB6C6",
|
|
12622
|
+
muted: "#8B939D",
|
|
12623
|
+
/** Ink for text set ON gold (buttons, the FORGED chip). 7.17:1 on `gold`. */
|
|
12624
|
+
onGold: "#0A1119"
|
|
12625
|
+
};
|
|
12282
12626
|
/**
|
|
12283
|
-
*
|
|
12284
|
-
*
|
|
12285
|
-
*
|
|
12286
|
-
*
|
|
12627
|
+
* Non-score status. `ok` is the one green in the system and it is NOT a
|
|
12628
|
+
* score colour — it survives only for contexts with no worthiness
|
|
12629
|
+
* meaning ("autofix applied", "analysis complete"). A green score would
|
|
12630
|
+
* say "your software is fine", which is the exact claim this product
|
|
12631
|
+
* refuses to make.
|
|
12632
|
+
*/
|
|
12633
|
+
const STATUS = {
|
|
12634
|
+
ok: "#4FB477",
|
|
12635
|
+
info: "#5CC4E0",
|
|
12636
|
+
warning: "#E6BD57",
|
|
12637
|
+
error: "#EC6B66"
|
|
12638
|
+
};
|
|
12639
|
+
/**
|
|
12640
|
+
* The four ScoreState bands plus the unmeasured state. Band thresholds
|
|
12641
|
+
* and runes live in `src/reporter/presentation.ts`, which stays free of
|
|
12642
|
+
* colour — it emits a palette KEY and each surface resolves it here.
|
|
12287
12643
|
*
|
|
12288
|
-
*
|
|
12289
|
-
*
|
|
12290
|
-
*
|
|
12644
|
+
* `unmeasured` is steel-dim on purpose. UNKNOWN is a legitimate answer,
|
|
12645
|
+
* not a failure: colouring it red would make "we did not measure this"
|
|
12646
|
+
* look like "this is broken", which is precisely the dishonesty the
|
|
12647
|
+
* north-star law exists to prevent.
|
|
12291
12648
|
*/
|
|
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
|
|
12649
|
+
const SCORE = {
|
|
12650
|
+
critical: "#EC6B66",
|
|
12651
|
+
warning: "#E6BD57",
|
|
12652
|
+
trusted: "#5CC4E0",
|
|
12653
|
+
forged: "#F4DC9C",
|
|
12654
|
+
unmeasured: "#8B939D"
|
|
12655
|
+
};
|
|
12355
12656
|
/**
|
|
12356
|
-
*
|
|
12657
|
+
* E0 → E1 → E2 is a certainty ramp, and it is deliberately HUE-FREE.
|
|
12357
12658
|
*
|
|
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.
|
|
12659
|
+
* Evidence level says how sure we are, not whether the news is good. A
|
|
12660
|
+
* deterministic proof (E2) is a defect we are certain about — painting
|
|
12661
|
+
* it gold or green would read as an achievement. So certainty is carried
|
|
12662
|
+
* by brightness alone, and the *shape* does the real work:
|
|
12369
12663
|
*
|
|
12370
|
-
*
|
|
12371
|
-
*
|
|
12372
|
-
*
|
|
12664
|
+
* E0 open ring observation, no weight
|
|
12665
|
+
* E1 half-filled pattern evidence, half weight
|
|
12666
|
+
* E2 sealed deterministic proof, full weight
|
|
12373
12667
|
*
|
|
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.
|
|
12668
|
+
* Colour never carries this alone (R11): the geometry is the signal and
|
|
12669
|
+
* survives `--ascii`, `NO_COLOR` and monochrome print.
|
|
12378
12670
|
*/
|
|
12379
|
-
const
|
|
12380
|
-
|
|
12381
|
-
|
|
12671
|
+
const EVIDENCE = {
|
|
12672
|
+
e0: "#8B939D",
|
|
12673
|
+
e1: "#ABB6C6",
|
|
12674
|
+
e2: "#EAEEF5"
|
|
12675
|
+
};
|
|
12382
12676
|
/**
|
|
12383
|
-
*
|
|
12384
|
-
*
|
|
12385
|
-
*
|
|
12386
|
-
*
|
|
12677
|
+
* L0–L5, and the most important boundary in the product.
|
|
12678
|
+
*
|
|
12679
|
+
* L0–L2 are STATIC: the neutral steel ramp, brightening to the static
|
|
12680
|
+
* ceiling at L2. L3–L5 require a real run, and the hue changes to aurora
|
|
12681
|
+
* exactly there. The boundary is a hue break, not a gradient step,
|
|
12682
|
+
* because it is a change of kind and not of degree — a static-only
|
|
12683
|
+
* finding can never climb past L2, however confident it is.
|
|
12684
|
+
*
|
|
12685
|
+
* Every surface that draws the ladder must draw that break.
|
|
12387
12686
|
*/
|
|
12388
|
-
const
|
|
12389
|
-
|
|
12390
|
-
|
|
12391
|
-
|
|
12392
|
-
|
|
12393
|
-
|
|
12394
|
-
|
|
12395
|
-
|
|
12396
|
-
|
|
12397
|
-
|
|
12398
|
-
|
|
12399
|
-
|
|
12400
|
-
|
|
12401
|
-
|
|
12402
|
-
|
|
12403
|
-
|
|
12404
|
-
|
|
12405
|
-
|
|
12406
|
-
|
|
12407
|
-
|
|
12408
|
-
|
|
12409
|
-
|
|
12410
|
-
|
|
12411
|
-
|
|
12412
|
-
*/
|
|
12413
|
-
|
|
12414
|
-
|
|
12415
|
-
|
|
12416
|
-
|
|
12417
|
-
}
|
|
12418
|
-
|
|
12419
|
-
|
|
12420
|
-
|
|
12421
|
-
|
|
12422
|
-
*/
|
|
12423
|
-
function hashRulesSourceTree(rules) {
|
|
12424
|
-
const firstPath = rules.find((r) => r.modulePath)?.modulePath;
|
|
12425
|
-
if (!firstPath) return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
|
|
12426
|
-
const idx = firstPath.replace(/\\/g, "/").indexOf("/src/rules/");
|
|
12427
|
-
if (idx < 0) return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
|
|
12428
|
-
const rulesDir = firstPath.replace(/\\/g, "/").slice(0, idx + 11);
|
|
12429
|
-
try {
|
|
12430
|
-
const hash = createHash("sha256");
|
|
12431
|
-
hashDir(rulesDir, hash, 0);
|
|
12432
|
-
return hash.digest("hex").slice(0, 16);
|
|
12433
|
-
} catch {
|
|
12434
|
-
return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
|
|
12435
|
-
}
|
|
12436
|
-
}
|
|
12437
|
-
function hashDir(dir, hash, depth) {
|
|
12438
|
-
if (depth > 8) return;
|
|
12439
|
-
let entries;
|
|
12440
|
-
try {
|
|
12441
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
12442
|
-
} catch {
|
|
12443
|
-
return;
|
|
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));
|
|
12453
|
-
} catch {}
|
|
12687
|
+
const TRUST = {
|
|
12688
|
+
l0: "#8B939D",
|
|
12689
|
+
l1: "#ABB6C6",
|
|
12690
|
+
l2: "#C8CBCF",
|
|
12691
|
+
l3: "#37ABBD",
|
|
12692
|
+
l4: "#45C1D4",
|
|
12693
|
+
l5: "#5CC4E0"
|
|
12694
|
+
};
|
|
12695
|
+
const TINT = {
|
|
12696
|
+
gold: {
|
|
12697
|
+
fill: "#F6EBCC",
|
|
12698
|
+
stroke: "#7A5F16",
|
|
12699
|
+
text: "#4A3A0E"
|
|
12700
|
+
},
|
|
12701
|
+
aurora: {
|
|
12702
|
+
fill: "#D9F0F4",
|
|
12703
|
+
stroke: "#1F6F7C",
|
|
12704
|
+
text: "#10353C"
|
|
12705
|
+
},
|
|
12706
|
+
error: {
|
|
12707
|
+
fill: "#FADEDD",
|
|
12708
|
+
stroke: "#A83A35",
|
|
12709
|
+
text: "#4E1B19"
|
|
12710
|
+
},
|
|
12711
|
+
/** The unmeasured / unknown state. Neutral, never the error tint. */
|
|
12712
|
+
neutral: {
|
|
12713
|
+
fill: "#E4E7EB",
|
|
12714
|
+
stroke: "#5C646E",
|
|
12715
|
+
text: "#262B31"
|
|
12716
|
+
},
|
|
12717
|
+
ok: {
|
|
12718
|
+
fill: "#DCF0E4",
|
|
12719
|
+
stroke: "#276B45",
|
|
12720
|
+
text: "#163A26"
|
|
12454
12721
|
}
|
|
12455
|
-
}
|
|
12722
|
+
};
|
|
12456
12723
|
/**
|
|
12457
|
-
*
|
|
12724
|
+
* The score bands as the badge Mjölnir itself generates renders them.
|
|
12458
12725
|
*
|
|
12459
|
-
*
|
|
12460
|
-
*
|
|
12461
|
-
*
|
|
12462
|
-
*
|
|
12463
|
-
*
|
|
12464
|
-
*
|
|
12465
|
-
*
|
|
12466
|
-
*
|
|
12467
|
-
*
|
|
12726
|
+
* These are DEEPER than the score tokens on purpose, and it is not a
|
|
12727
|
+
* style preference: shields.io sets the message text in white and gives
|
|
12728
|
+
* you no say in it. `score.forged` (#F4DC9C) under white text measures
|
|
12729
|
+
* 1.35:1 — an unreadable badge, shipped to look on-brand. The brand's
|
|
12730
|
+
* own deep steps put every band between 4.9 and 6.3:1.
|
|
12731
|
+
*
|
|
12732
|
+
* What they replace was worse than off-brand, it was wrong:
|
|
12733
|
+
*
|
|
12734
|
+
* band was rendered as white-on now white-on
|
|
12735
|
+
* ────────────────────────────────────────────────────────────────────────────
|
|
12736
|
+
* 0-49 `red` #dd4343 4.24 #A83A35 6.32
|
|
12737
|
+
* 50-79 `yellow` #d8b800 1.95 #7A5F16 6.04
|
|
12738
|
+
* 80-99 `important` #ea7233 ORANGE 3.02 #1F6F7C 5.80
|
|
12739
|
+
* 100 `success` #44bb00 GREEN 2.51 #8A6D1E 4.90
|
|
12740
|
+
* unmeasured `lightgrey` #939393 3.07 #5C646E 5.99
|
|
12741
|
+
*
|
|
12742
|
+
* Two of those were defects, not preferences. `success` is green, and
|
|
12743
|
+
* green is not a score colour here — a 100 badge said "your software is
|
|
12744
|
+
* fine", which is the one claim this product refuses to make. And
|
|
12745
|
+
* `important` is ORANGE, not the blue-family colour the code's own
|
|
12746
|
+
* comment claimed for eight releases: every WORTHY badge ever rendered
|
|
12747
|
+
* showed the trusted band in a warning colour. Nobody had resolved a
|
|
12748
|
+
* shields name to a value and looked.
|
|
12749
|
+
*
|
|
12750
|
+
* Values are hex without `#`, the form shields.io's endpoint takes.
|
|
12751
|
+
* `A83A35`, `7A5F16`, `1F6F7C` and `5C646E` are the `TINT` strokes —
|
|
12752
|
+
* the same deep steps the mermaid diagrams use, for the same reason.
|
|
12753
|
+
* `8A6D1E` is the gold the brand document already named as the light
|
|
12754
|
+
* FORGED gradient's start.
|
|
12468
12755
|
*/
|
|
12469
|
-
|
|
12470
|
-
|
|
12471
|
-
|
|
12472
|
-
|
|
12473
|
-
|
|
12474
|
-
|
|
12475
|
-
stats: {
|
|
12476
|
-
hits: 0,
|
|
12477
|
-
misses: 0,
|
|
12478
|
-
file: ""
|
|
12479
|
-
},
|
|
12480
|
-
lookup: () => void 0,
|
|
12481
|
-
store: () => {},
|
|
12482
|
-
persist: () => {}
|
|
12756
|
+
const BADGE_BAND = {
|
|
12757
|
+
critical: "A83A35",
|
|
12758
|
+
warning: "7A5F16",
|
|
12759
|
+
trusted: "1F6F7C",
|
|
12760
|
+
forged: "8A6D1E",
|
|
12761
|
+
unmeasured: "5C646E"
|
|
12483
12762
|
};
|
|
12484
|
-
|
|
12485
|
-
|
|
12486
|
-
|
|
12487
|
-
|
|
12488
|
-
|
|
12489
|
-
|
|
12490
|
-
|
|
12491
|
-
|
|
12492
|
-
|
|
12493
|
-
|
|
12494
|
-
|
|
12495
|
-
|
|
12496
|
-
|
|
12497
|
-
|
|
12498
|
-
|
|
12499
|
-
|
|
12500
|
-
|
|
12501
|
-
|
|
12502
|
-
|
|
12503
|
-
|
|
12504
|
-
|
|
12505
|
-
|
|
12506
|
-
|
|
12507
|
-
|
|
12508
|
-
|
|
12509
|
-
entries = {};
|
|
12510
|
-
entryBytes.clear();
|
|
12511
|
-
totalBytes = 0;
|
|
12512
|
-
}
|
|
12513
|
-
return {
|
|
12514
|
-
stats: {
|
|
12515
|
-
hits: 0,
|
|
12516
|
-
misses: 0,
|
|
12517
|
-
file
|
|
12518
|
-
},
|
|
12519
|
-
lookup(key) {
|
|
12520
|
-
const entry = entries[key];
|
|
12521
|
-
if (!entry) {
|
|
12522
|
-
this.stats.misses++;
|
|
12523
|
-
return;
|
|
12524
|
-
}
|
|
12525
|
-
this.stats.hits++;
|
|
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 {}
|
|
12560
|
-
}
|
|
12561
|
-
};
|
|
12562
|
-
}
|
|
12763
|
+
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;
|
|
12764
|
+
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;
|
|
12765
|
+
EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2;
|
|
12766
|
+
const RUNG_MEANINGS = [
|
|
12767
|
+
"observation only",
|
|
12768
|
+
"heuristic static",
|
|
12769
|
+
"deterministic static",
|
|
12770
|
+
"the finding's file executed",
|
|
12771
|
+
"the finding's test executed",
|
|
12772
|
+
"the run verdict corroborates"
|
|
12773
|
+
];
|
|
12774
|
+
const RUNG_COLORS = [
|
|
12775
|
+
TRUST.l0,
|
|
12776
|
+
TRUST.l1,
|
|
12777
|
+
TRUST.l2,
|
|
12778
|
+
TRUST.l3,
|
|
12779
|
+
TRUST.l4,
|
|
12780
|
+
TRUST.l5
|
|
12781
|
+
];
|
|
12782
|
+
const TRUST_RUNGS = RUNG_MEANINGS.map((meaning, i) => ({
|
|
12783
|
+
level: `L${i}`,
|
|
12784
|
+
meaning,
|
|
12785
|
+
runtime: i >= 3,
|
|
12786
|
+
color: RUNG_COLORS[i]
|
|
12787
|
+
}));
|
|
12563
12788
|
//#endregion
|
|
12564
|
-
//#region src/
|
|
12789
|
+
//#region src/reporter/presentation.ts
|
|
12565
12790
|
/**
|
|
12566
|
-
*
|
|
12791
|
+
* presentation.ts — the one place a presentation DECISION is made.
|
|
12567
12792
|
*
|
|
12568
|
-
*
|
|
12569
|
-
*
|
|
12570
|
-
* a value in this file. Nothing else may define one.
|
|
12793
|
+
* BITTERSWEET `BW-030a`. Before this module the same decision was written
|
|
12794
|
+
* down four different times and every surface drifted:
|
|
12571
12795
|
*
|
|
12572
|
-
*
|
|
12573
|
-
*
|
|
12574
|
-
*
|
|
12575
|
-
* `
|
|
12576
|
-
*
|
|
12577
|
-
*
|
|
12578
|
-
*
|
|
12579
|
-
*
|
|
12580
|
-
*
|
|
12581
|
-
* two releases earlier. `scripts/brand-doctor.mjs` now checks every edge
|
|
12582
|
-
* against this file.
|
|
12796
|
+
* - score bands: `score-state.ts` (50/80), `dashboard.ts` (50/60),
|
|
12797
|
+
* `handover.ts` (50/90), `mermaid.ts` (50/80), `monorepo-analysis.ts`
|
|
12798
|
+
* (50/80);
|
|
12799
|
+
* - the evidence descriptor: `terminal.ts` returned the full
|
|
12800
|
+
* `[E2 · deterministic · measured FP 8% · n=14 · trust L3]` while
|
|
12801
|
+
* `evidence-tag.ts` — which called itself the "single definition
|
|
12802
|
+
* site" — returned four bare words and defaulted a missing level to
|
|
12803
|
+
* `E2`, the STRONGEST one;
|
|
12804
|
+
* - the score bar: `theme.ts:scoreGauge` plus four private reimplementations.
|
|
12583
12805
|
*
|
|
12584
|
-
*
|
|
12585
|
-
*
|
|
12586
|
-
*
|
|
12587
|
-
*
|
|
12588
|
-
*
|
|
12589
|
-
* carried anyway.
|
|
12806
|
+
* THE ONE RULE OF THIS MODULE: it owns decisions, never data and never
|
|
12807
|
+
* I/O. It reads a `Finding` / a score number / a `Palette` and returns
|
|
12808
|
+
* the word, band, threshold or descriptor to print. It opens no file,
|
|
12809
|
+
* touches no clock, spawns no process and formats no HTML document. If a
|
|
12810
|
+
* second responsibility appears here, split it in the same change.
|
|
12590
12811
|
*
|
|
12591
|
-
*
|
|
12592
|
-
*
|
|
12593
|
-
* Where the terminal disagreed with it, the terminal converges — see
|
|
12594
|
-
* `PENDING_TERMINAL` below. Full rationale: `assets/brand/README.md`.
|
|
12812
|
+
* Pure: the same input always yields the same output, so every decision
|
|
12813
|
+
* is golden-testable and two runs of the same scan cannot disagree.
|
|
12595
12814
|
*
|
|
12596
|
-
*
|
|
12597
|
-
*
|
|
12598
|
-
*
|
|
12599
|
-
*
|
|
12815
|
+
* Layout note — the threshold LITERALS stay physically here rather than in
|
|
12816
|
+
* a registry object so that `site/scripts/gen-report.mjs`, which parses
|
|
12817
|
+
* `score >= N` branches out of this file to build the site's band ruler,
|
|
12818
|
+
* keeps parsing one canonical file. `SCORE_THRESHOLDS` is derived from
|
|
12819
|
+
* those literals; it is the nameable form, not a second copy of them.
|
|
12600
12820
|
*/
|
|
12601
12821
|
/**
|
|
12602
|
-
*
|
|
12822
|
+
* What a surface prints when a number was never measured.
|
|
12603
12823
|
*
|
|
12604
|
-
*
|
|
12605
|
-
*
|
|
12606
|
-
*
|
|
12607
|
-
*
|
|
12608
|
-
*
|
|
12609
|
-
*
|
|
12610
|
-
*
|
|
12824
|
+
* The bug this replaces: `testDeclarationCount ?? 0` rendered
|
|
12825
|
+
* "0 tests analyzed in 0 files" for a producer that simply did not
|
|
12826
|
+
* measure. A zero is a CLAIM — it says the count was taken and found
|
|
12827
|
+
* nothing. Absent means nobody looked. `dashboard.ts` already modelled
|
|
12828
|
+
* this correctly for frameworks (`frameworkCount: null` → "unknown");
|
|
12829
|
+
* its sibling `trust-report.ts` did not, so the same repo produced two
|
|
12830
|
+
* artifacts that disagreed about whether a measurement existed.
|
|
12611
12831
|
*/
|
|
12612
|
-
const
|
|
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
|
-
};
|
|
12832
|
+
const UNMEASURED = "unknown — not measured";
|
|
12628
12833
|
/**
|
|
12629
|
-
*
|
|
12834
|
+
* Render a count for a human surface: the number when it was measured,
|
|
12835
|
+
* `unknown — not measured` when it was not. Never `0` for an absent
|
|
12836
|
+
* measurement.
|
|
12837
|
+
*/
|
|
12838
|
+
function countOrUnknown(value, unit) {
|
|
12839
|
+
if (value === void 0) return UNMEASURED;
|
|
12840
|
+
return unit === void 0 ? String(value) : `${value} ${unit}`;
|
|
12841
|
+
}
|
|
12842
|
+
/**
|
|
12843
|
+
* The machine-surface counterpart: an unmeasured count is `null`, which
|
|
12844
|
+
* JSON has a word for, rather than `0`, which does not. Used by the
|
|
12845
|
+
* trust-report JSON artifact; the scan machine contract
|
|
12846
|
+
* (`engine/machine-contract.ts`) is drift-locked and untouched.
|
|
12847
|
+
*/
|
|
12848
|
+
function countOrNull(value) {
|
|
12849
|
+
return value ?? null;
|
|
12850
|
+
}
|
|
12851
|
+
/**
|
|
12852
|
+
* The "Tests analyzed" cell, shared by every human surface that shows it.
|
|
12630
12853
|
*
|
|
12631
|
-
*
|
|
12632
|
-
*
|
|
12633
|
-
*
|
|
12634
|
-
*
|
|
12854
|
+
* Three renderers had this line written out separately, identically, and
|
|
12855
|
+
* all three rendered `testDeclarationCount ?? 0` — "0 tests analyzed in 0
|
|
12856
|
+
* files" — for a producer that never measured a declaration at all. Two
|
|
12857
|
+
* of them are the *sibling* artifacts for the same scan (the PR comment
|
|
12858
|
+
* and the trust report), so the same run told a reviewer both "0 tests
|
|
12859
|
+
* analyzed" and "no tests found".
|
|
12860
|
+
*
|
|
12861
|
+
* Declared-but-file-count-absent (and the reverse) are reported honestly
|
|
12862
|
+
* rather than filled in from each other.
|
|
12635
12863
|
*/
|
|
12636
|
-
|
|
12637
|
-
|
|
12638
|
-
|
|
12639
|
-
|
|
12640
|
-
|
|
12641
|
-
|
|
12642
|
-
|
|
12643
|
-
|
|
12644
|
-
|
|
12645
|
-
|
|
12646
|
-
|
|
12647
|
-
|
|
12648
|
-
terminalBar: "#0A1119",
|
|
12649
|
-
/** The three window dots. One neutral, not a traffic light. */
|
|
12650
|
-
chromeDot: "#18243A"
|
|
12864
|
+
function testsAnalyzedCell(declarations, files) {
|
|
12865
|
+
if (declarations === void 0 && files === void 0) return UNMEASURED;
|
|
12866
|
+
const declText = countOrUnknown(declarations);
|
|
12867
|
+
if (files === void 0) return `${declText} in unknown files`;
|
|
12868
|
+
return `${declText} in ${files} ${files === 1 ? "file" : "files"}`;
|
|
12869
|
+
}
|
|
12870
|
+
const HEADLINES = {
|
|
12871
|
+
critical: "The hammer is cracked — {n} findings break its edge.",
|
|
12872
|
+
warning: "The hammer holds — but {n} findings weigh it down.",
|
|
12873
|
+
trusted: "Held in worthy hands — {n} findings remain.",
|
|
12874
|
+
forged: "Static score 100 — no findings on the analyzed surface.",
|
|
12875
|
+
unmeasured: "No tests found — the hammer cannot be weighed."
|
|
12651
12876
|
};
|
|
12652
|
-
const
|
|
12653
|
-
|
|
12654
|
-
|
|
12655
|
-
|
|
12656
|
-
|
|
12657
|
-
|
|
12877
|
+
const RUNES = {
|
|
12878
|
+
critical: "ᚲ",
|
|
12879
|
+
warning: "ᚦ",
|
|
12880
|
+
trusted: "ᛏ",
|
|
12881
|
+
forged: "ᛟ",
|
|
12882
|
+
unmeasured: "ᛁ"
|
|
12658
12883
|
};
|
|
12659
12884
|
/**
|
|
12660
|
-
*
|
|
12661
|
-
*
|
|
12662
|
-
*
|
|
12663
|
-
*
|
|
12664
|
-
|
|
12665
|
-
|
|
12666
|
-
|
|
12667
|
-
|
|
12668
|
-
|
|
12669
|
-
|
|
12670
|
-
|
|
12885
|
+
* The named form of the band boundaries. `report:honesty` /
|
|
12886
|
+
* `thresholds:parity` assert that no surface outside this module
|
|
12887
|
+
* re-invents a boundary; a consumer that genuinely needs one names it
|
|
12888
|
+
* here rather than typing the number.
|
|
12889
|
+
*/
|
|
12890
|
+
const SCORE_THRESHOLDS = {
|
|
12891
|
+
/** Below this is `critical`. */
|
|
12892
|
+
criticalBelow: 50,
|
|
12893
|
+
/** At or above this is `trusted`. */
|
|
12894
|
+
trustedAtOrAbove: 80,
|
|
12895
|
+
/** The one score that is `forged`. */
|
|
12896
|
+
forgedAt: 100
|
|
12671
12897
|
};
|
|
12672
12898
|
/**
|
|
12673
|
-
*
|
|
12674
|
-
*
|
|
12675
|
-
|
|
12899
|
+
* Band mapping — the one mapping, every consumer: <50 critical,
|
|
12900
|
+
* 50–79 warning, 80–99 trusted, 100 forged, null unmeasured.
|
|
12901
|
+
*/
|
|
12902
|
+
function deriveScoreState(score) {
|
|
12903
|
+
if (score === null) return {
|
|
12904
|
+
score: null,
|
|
12905
|
+
band: "unmeasured",
|
|
12906
|
+
verdict: "UNWORTHY",
|
|
12907
|
+
color: "dim",
|
|
12908
|
+
powerLevel: 0,
|
|
12909
|
+
headline: HEADLINES.unmeasured,
|
|
12910
|
+
rune: RUNES.unmeasured
|
|
12911
|
+
};
|
|
12912
|
+
if (score >= SCORE_THRESHOLDS.forgedAt) return {
|
|
12913
|
+
score,
|
|
12914
|
+
band: "forged",
|
|
12915
|
+
verdict: "FORGED",
|
|
12916
|
+
color: "forged",
|
|
12917
|
+
powerLevel: score,
|
|
12918
|
+
headline: HEADLINES.forged,
|
|
12919
|
+
rune: RUNES.forged
|
|
12920
|
+
};
|
|
12921
|
+
if (score >= 80) return {
|
|
12922
|
+
score,
|
|
12923
|
+
band: "trusted",
|
|
12924
|
+
verdict: "WORTHY",
|
|
12925
|
+
color: "trusted",
|
|
12926
|
+
powerLevel: score,
|
|
12927
|
+
headline: HEADLINES.trusted,
|
|
12928
|
+
rune: RUNES.trusted
|
|
12929
|
+
};
|
|
12930
|
+
if (score >= 50) return {
|
|
12931
|
+
score,
|
|
12932
|
+
band: "warning",
|
|
12933
|
+
verdict: "NEEDS WORK",
|
|
12934
|
+
color: "warning",
|
|
12935
|
+
powerLevel: score,
|
|
12936
|
+
headline: HEADLINES.warning,
|
|
12937
|
+
rune: RUNES.warning
|
|
12938
|
+
};
|
|
12939
|
+
return {
|
|
12940
|
+
score,
|
|
12941
|
+
band: "critical",
|
|
12942
|
+
verdict: "UNWORTHY",
|
|
12943
|
+
color: "error",
|
|
12944
|
+
powerLevel: score,
|
|
12945
|
+
headline: HEADLINES.critical,
|
|
12946
|
+
rune: RUNES.critical
|
|
12947
|
+
};
|
|
12948
|
+
}
|
|
12949
|
+
/** Substitute a findings count into a headline template. Pure text
|
|
12950
|
+
* helper so every surface formats identically. */
|
|
12951
|
+
function headlineFor(state, findings) {
|
|
12952
|
+
return state.headline.replace("{n}", String(findings));
|
|
12953
|
+
}
|
|
12954
|
+
/**
|
|
12955
|
+
* Contract-stable three-band verdict (property-locked in
|
|
12956
|
+
* tests/scoring-precision.spec.ts). Delegates to the ScoreState model —
|
|
12957
|
+
* 100 keeps returning WORTHY here; the FORGED premium treatment lives
|
|
12958
|
+
* in the dedicated block, not in this public mapping.
|
|
12676
12959
|
*
|
|
12677
|
-
* `
|
|
12678
|
-
*
|
|
12679
|
-
*
|
|
12680
|
-
*
|
|
12960
|
+
* The `FORGED → WORTHY` collapse is a DOCUMENTED PROJECTION that
|
|
12961
|
+
* preserves the three-band public contract, not a divergence to be
|
|
12962
|
+
* "fixed": a consumer reading the verdict word must not be able to tell
|
|
12963
|
+
* a 100 from a 99, because the 100 carries no runtime evidence and the
|
|
12964
|
+
* score alone never earned it.
|
|
12681
12965
|
*/
|
|
12682
|
-
|
|
12683
|
-
|
|
12684
|
-
|
|
12685
|
-
|
|
12686
|
-
|
|
12687
|
-
|
|
12966
|
+
function verdictFor(score) {
|
|
12967
|
+
const verdict = deriveScoreState(score).verdict;
|
|
12968
|
+
return verdict === "FORGED" ? "WORTHY" : verdict;
|
|
12969
|
+
}
|
|
12970
|
+
const EVIDENCE_KIND = {
|
|
12971
|
+
E0: "observation",
|
|
12972
|
+
E1: "heuristic",
|
|
12973
|
+
E2: "deterministic"
|
|
12688
12974
|
};
|
|
12689
12975
|
/**
|
|
12690
|
-
*
|
|
12976
|
+
* The effective evidence level of a finding.
|
|
12691
12977
|
*
|
|
12692
|
-
*
|
|
12693
|
-
*
|
|
12694
|
-
*
|
|
12695
|
-
*
|
|
12978
|
+
* The `?? deriveEvidenceLevel(...)` fallback IS the fix for BW-101. The
|
|
12979
|
+
* deleted `evidence-tag.ts` defaulted a missing level to `E2` — the
|
|
12980
|
+
* strongest claim the product can make — which is the exact inverse of
|
|
12981
|
+
* its own principle: absent evidence is not proof, so it is derived
|
|
12982
|
+
* conservatively from the finding's own type and confidence.
|
|
12983
|
+
*/
|
|
12984
|
+
function evidenceLevelOf(f) {
|
|
12985
|
+
return f.evidenceLevel ?? deriveEvidenceLevel(f.findingType, f.confidence);
|
|
12986
|
+
}
|
|
12987
|
+
/** What the runtime report actually vouched for, in the product's words. */
|
|
12988
|
+
function runtimeLabel(f) {
|
|
12989
|
+
const c = f.runtimeCorroboration;
|
|
12990
|
+
if (c === void 0) return null;
|
|
12991
|
+
if (c.level === "defect") return "defect corroborated";
|
|
12992
|
+
if (c.level === "test") return "test executed";
|
|
12993
|
+
return "file executed";
|
|
12994
|
+
}
|
|
12995
|
+
/**
|
|
12996
|
+
* The canonical evidence descriptor — `[E2 · deterministic · measured FP
|
|
12997
|
+
* 8% · n=14 · trust L3 · runtime: file executed]`.
|
|
12696
12998
|
*
|
|
12697
|
-
*
|
|
12698
|
-
*
|
|
12699
|
-
*
|
|
12999
|
+
* This is the version `terminal.ts` already shipped, promoted to the one
|
|
13000
|
+
* definition site. The HTML and markdown surfaces now gain the measured
|
|
13001
|
+
* false-positive rate, the sample size, the trust rung and what runtime
|
|
13002
|
+
* corroborated — they used to show four bare words that a reader could
|
|
13003
|
+
* mistake for the whole evidence story.
|
|
12700
13004
|
*
|
|
12701
|
-
*
|
|
12702
|
-
*
|
|
13005
|
+
* Every clause is conditional on the finding actually carrying that
|
|
13006
|
+
* evidence. Nothing here is ever defaulted into existence.
|
|
12703
13007
|
*/
|
|
12704
|
-
|
|
12705
|
-
|
|
12706
|
-
|
|
12707
|
-
|
|
12708
|
-
}
|
|
13008
|
+
function evidenceTag(f) {
|
|
13009
|
+
const level = evidenceLevelOf(f);
|
|
13010
|
+
let tag = `${level} · ${EVIDENCE_KIND[level]}`;
|
|
13011
|
+
if (f.measuredFpRate !== void 0) {
|
|
13012
|
+
tag += ` · measured FP ${Math.round(f.measuredFpRate * 100)}%`;
|
|
13013
|
+
if (f.measuredFpN !== void 0) tag += ` · n=${f.measuredFpN}`;
|
|
13014
|
+
}
|
|
13015
|
+
if (f.trustLevel !== void 0) tag += ` · trust ${f.trustLevel}`;
|
|
13016
|
+
const runtime = runtimeLabel(f);
|
|
13017
|
+
if (runtime !== null) tag += ` · runtime: ${runtime}`;
|
|
13018
|
+
return `[${tag}]`;
|
|
13019
|
+
}
|
|
13020
|
+
//#endregion
|
|
13021
|
+
//#region src/engine/monorepo-analysis.ts
|
|
12709
13022
|
/**
|
|
12710
|
-
*
|
|
13023
|
+
* Package verdict, derived from the one ScoreState band model (BW-104).
|
|
13024
|
+
* These two constants used to be a private copy of the band boundaries;
|
|
13025
|
+
* the numeric vocabulary here is a different projection of the same bands
|
|
13026
|
+
* — "pass" is trusted or forged, "warn" is warning, "fail" is critical or
|
|
13027
|
+
* unmeasurable — so the boundaries are read, never retyped.
|
|
13028
|
+
*/
|
|
13029
|
+
function verdictOf(score) {
|
|
13030
|
+
if (score === null) return "fail";
|
|
13031
|
+
const band = deriveScoreState(score).band;
|
|
13032
|
+
if (band === "forged" || band === "trusted") return "pass";
|
|
13033
|
+
return band === "warning" ? "warn" : "fail";
|
|
13034
|
+
}
|
|
13035
|
+
function hasBlocker(findings) {
|
|
13036
|
+
return findings.some((f) => f.severity === "error");
|
|
13037
|
+
}
|
|
13038
|
+
/**
|
|
13039
|
+
* Aggregate per-package results into an overall verdict.
|
|
12711
13040
|
*
|
|
12712
|
-
*
|
|
12713
|
-
*
|
|
12714
|
-
*
|
|
12715
|
-
*
|
|
12716
|
-
|
|
13041
|
+
* Worst-package propagation: if ANY package has a blocker (error-severity
|
|
13042
|
+
* finding) or a failing score, the overall result fails regardless of
|
|
13043
|
+
* strategy. This is the safety net — a single poisoned package must not
|
|
13044
|
+
* hide behind averaging.
|
|
13045
|
+
*/
|
|
13046
|
+
function analyzeMonorepo(packages, config) {
|
|
13047
|
+
const results = packages.map((p) => ({
|
|
13048
|
+
...p,
|
|
13049
|
+
verdict: hasBlocker(p.findings) ? "fail" : verdictOf(p.score)
|
|
13050
|
+
}));
|
|
13051
|
+
if (results.length === 0) return {
|
|
13052
|
+
packages: results,
|
|
13053
|
+
overallScore: null,
|
|
13054
|
+
overallVerdict: "fail",
|
|
13055
|
+
strategy: config.weightingStrategy
|
|
13056
|
+
};
|
|
13057
|
+
const blockerPkg = results.find((r) => r.verdict === "fail" || hasBlocker(r.findings));
|
|
13058
|
+
if (blockerPkg) return {
|
|
13059
|
+
packages: results,
|
|
13060
|
+
overallScore: blockerPkg.score,
|
|
13061
|
+
overallVerdict: "fail",
|
|
13062
|
+
strategy: config.weightingStrategy,
|
|
13063
|
+
blockerPackage: blockerPkg.packageName
|
|
13064
|
+
};
|
|
13065
|
+
switch (config.weightingStrategy) {
|
|
13066
|
+
case "worst-package": return worstPackageAggregation(results, config);
|
|
13067
|
+
case "average": return averageAggregation(results, config);
|
|
13068
|
+
case "configurable": return configurableAggregation(results, config);
|
|
13069
|
+
}
|
|
13070
|
+
}
|
|
13071
|
+
function worstPackageAggregation(results, config) {
|
|
13072
|
+
const firstResult = results[0];
|
|
13073
|
+
if (firstResult === void 0) return {
|
|
13074
|
+
packages: results,
|
|
13075
|
+
overallScore: null,
|
|
13076
|
+
overallVerdict: "fail",
|
|
13077
|
+
strategy: config.weightingStrategy
|
|
13078
|
+
};
|
|
13079
|
+
let worst = firstResult;
|
|
13080
|
+
for (const r of results) if ((r.score ?? 0) < (worst.score ?? 0)) worst = r;
|
|
13081
|
+
return {
|
|
13082
|
+
packages: results,
|
|
13083
|
+
overallScore: worst.score,
|
|
13084
|
+
overallVerdict: worst.verdict,
|
|
13085
|
+
strategy: config.weightingStrategy,
|
|
13086
|
+
...worst.verdict === "fail" ? { blockerPackage: worst.packageName } : {}
|
|
13087
|
+
};
|
|
13088
|
+
}
|
|
13089
|
+
function averageAggregation(results, config) {
|
|
13090
|
+
const scored = results.filter((r) => r.score !== null);
|
|
13091
|
+
if (scored.length === 0) return {
|
|
13092
|
+
packages: results,
|
|
13093
|
+
overallScore: null,
|
|
13094
|
+
overallVerdict: "fail",
|
|
13095
|
+
strategy: config.weightingStrategy
|
|
13096
|
+
};
|
|
13097
|
+
const avg = scored.reduce((sum, r) => sum + (r.score ?? 0), 0) / scored.length;
|
|
13098
|
+
return {
|
|
13099
|
+
packages: results,
|
|
13100
|
+
overallScore: Math.round(avg),
|
|
13101
|
+
overallVerdict: verdictOf(Math.round(avg)),
|
|
13102
|
+
strategy: config.weightingStrategy
|
|
13103
|
+
};
|
|
13104
|
+
}
|
|
13105
|
+
function configurableAggregation(results, config) {
|
|
13106
|
+
const weights = config.packageWeights ?? {};
|
|
13107
|
+
let totalWeight = 0;
|
|
13108
|
+
let weightedSum = 0;
|
|
13109
|
+
for (const r of results) {
|
|
13110
|
+
const w = weights[r.packageName] ?? 1;
|
|
13111
|
+
totalWeight += w;
|
|
13112
|
+
weightedSum += (r.score ?? 0) * w;
|
|
13113
|
+
}
|
|
13114
|
+
if (totalWeight === 0) return {
|
|
13115
|
+
packages: results,
|
|
13116
|
+
overallScore: null,
|
|
13117
|
+
overallVerdict: "fail",
|
|
13118
|
+
strategy: config.weightingStrategy
|
|
13119
|
+
};
|
|
13120
|
+
const score = Math.round(weightedSum / totalWeight);
|
|
13121
|
+
return {
|
|
13122
|
+
packages: results,
|
|
13123
|
+
overallScore: score,
|
|
13124
|
+
overallVerdict: verdictOf(score),
|
|
13125
|
+
strategy: config.weightingStrategy
|
|
13126
|
+
};
|
|
13127
|
+
}
|
|
13128
|
+
//#endregion
|
|
13129
|
+
//#region src/lib/fs-atomic.ts
|
|
13130
|
+
/**
|
|
13131
|
+
* Atomic file writes (audit S9).
|
|
12717
13132
|
*
|
|
12718
|
-
* Every
|
|
13133
|
+
* Every durability-critical write in Mjölnir (baseline, stats, badge,
|
|
13134
|
+
* TRIAGE.md, scaffolded rule files) used to hand-roll
|
|
13135
|
+
* `writeFileSync(path, data)` — a crash mid-write left a TRUNCATED file
|
|
13136
|
+
* at the real path, and a subsequent read (diff, badge endpoint) served
|
|
13137
|
+
* confident nonsense from it.
|
|
13138
|
+
*
|
|
13139
|
+
* `writeFileAtomic` writes to a temp sibling, then RENAMES. On the same
|
|
13140
|
+
* volume rename is atomic: readers see either the complete old file or
|
|
13141
|
+
* the complete new file, never a half-written one. The temp name is
|
|
13142
|
+
* created with `wx` (exclusive) so concurrent writers cannot interleave,
|
|
13143
|
+
* stale temps are cleaned up on failure, and on Windows the rename is
|
|
13144
|
+
* retried briefly because a concurrent reader can hold the destination
|
|
13145
|
+
* open (EBUSY/EPERM).
|
|
12719
13146
|
*/
|
|
12720
|
-
|
|
12721
|
-
|
|
12722
|
-
|
|
12723
|
-
|
|
12724
|
-
|
|
12725
|
-
|
|
12726
|
-
|
|
12727
|
-
|
|
12728
|
-
|
|
12729
|
-
|
|
12730
|
-
|
|
12731
|
-
|
|
12732
|
-
|
|
12733
|
-
|
|
12734
|
-
|
|
12735
|
-
|
|
12736
|
-
|
|
12737
|
-
|
|
12738
|
-
|
|
12739
|
-
|
|
12740
|
-
|
|
12741
|
-
|
|
12742
|
-
|
|
12743
|
-
|
|
12744
|
-
|
|
12745
|
-
|
|
12746
|
-
|
|
12747
|
-
|
|
12748
|
-
|
|
12749
|
-
}
|
|
12750
|
-
|
|
12751
|
-
|
|
12752
|
-
|
|
12753
|
-
|
|
13147
|
+
function atomicTempPath(path) {
|
|
13148
|
+
return `${path}.mjolnir-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}.tmp`;
|
|
13149
|
+
}
|
|
13150
|
+
/**
|
|
13151
|
+
* Atomically replace `path` with `data`.
|
|
13152
|
+
*
|
|
13153
|
+
* Accepts binary payloads as well as text: base-tree materialization in
|
|
13154
|
+
* `impact` writes git blobs, and a writer that only takes strings forces those
|
|
13155
|
+
* call sites back to the non-atomic path rather than keeping them contained.
|
|
13156
|
+
*/
|
|
13157
|
+
function writeFileAtomic(path, data, opts = {}) {
|
|
13158
|
+
const dir = dirname(path);
|
|
13159
|
+
if (opts.mkdirs !== false && !existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
13160
|
+
const tmp = atomicTempPath(path);
|
|
13161
|
+
let fd;
|
|
13162
|
+
try {
|
|
13163
|
+
fd = openSync(tmp, "wx", opts.mode ?? 420);
|
|
13164
|
+
if (typeof data === "string") writeSync(fd, data, null, opts.encoding ?? "utf8");
|
|
13165
|
+
else writeSync(fd, data);
|
|
13166
|
+
} finally {
|
|
13167
|
+
if (fd !== void 0) closeSync(fd);
|
|
13168
|
+
}
|
|
13169
|
+
try {
|
|
13170
|
+
renameWithWindowsRetry(tmp, path);
|
|
13171
|
+
} catch (err) {
|
|
13172
|
+
try {
|
|
13173
|
+
if (existsSync(tmp)) unlinkSync(tmp);
|
|
13174
|
+
} catch {}
|
|
13175
|
+
throw err;
|
|
13176
|
+
}
|
|
13177
|
+
}
|
|
13178
|
+
/**
|
|
13179
|
+
* renameSync retry loop for Windows: a concurrent reader (another scan,
|
|
13180
|
+
* a badge endpoint, an editor) holding the destination open makes
|
|
13181
|
+
* rename fail with EBUSY/EPERM. A short bounded retry closes the race
|
|
13182
|
+
* without turning an atomic swap into a partial write.
|
|
13183
|
+
*
|
|
13184
|
+
* Internal contract test hook: the platform check keeps this loop off
|
|
13185
|
+
* the POSIX hot path; on win32 the EBUSY/EPERM arms are exercised by
|
|
13186
|
+
* the fs-atomic-retry spec (mocked renameSync).
|
|
13187
|
+
*/
|
|
13188
|
+
const RENAME_RETRIES = 8;
|
|
13189
|
+
const RENAME_RETRY_DELAY_MS = 25;
|
|
13190
|
+
/** Synchronous sleep that does not spin the CPU. */
|
|
13191
|
+
function sleepSync(ms) {
|
|
13192
|
+
try {
|
|
13193
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
13194
|
+
} catch {
|
|
13195
|
+
const until = Date.now() + ms;
|
|
13196
|
+
while (Date.now() < until);
|
|
13197
|
+
}
|
|
13198
|
+
}
|
|
13199
|
+
function renameWithWindowsRetry(from, to) {
|
|
13200
|
+
for (let attempt = 0;; attempt++) try {
|
|
13201
|
+
renameSync(from, to);
|
|
13202
|
+
return;
|
|
13203
|
+
} catch (err) {
|
|
13204
|
+
const code = err?.code;
|
|
13205
|
+
if (process.platform === "win32" && (code === "EBUSY" || code === "EPERM") && attempt < RENAME_RETRIES) {
|
|
13206
|
+
sleepSync(RENAME_RETRY_DELAY_MS);
|
|
13207
|
+
continue;
|
|
13208
|
+
}
|
|
13209
|
+
throw err;
|
|
13210
|
+
}
|
|
13211
|
+
}
|
|
13212
|
+
const STALE_TEMP_AGE_MS = 864e5;
|
|
13213
|
+
const TEMP_PID_RE = /^.+\.mjolnir-([1-9]\d{0,9})-(\d{13})-[0-9a-f]{8}\.tmp$/;
|
|
13214
|
+
function sweepStaleTempFiles(dir) {
|
|
13215
|
+
let swept = 0;
|
|
13216
|
+
let entries;
|
|
13217
|
+
try {
|
|
13218
|
+
entries = readdirSync(dir);
|
|
13219
|
+
} catch {
|
|
13220
|
+
return 0;
|
|
13221
|
+
}
|
|
13222
|
+
const cutoff = Date.now() - STALE_TEMP_AGE_MS;
|
|
13223
|
+
for (const entry of entries) {
|
|
13224
|
+
const match = TEMP_PID_RE.exec(entry);
|
|
13225
|
+
if (!match || Number(match[2]) > cutoff) continue;
|
|
13226
|
+
const path = join(dir, entry);
|
|
13227
|
+
try {
|
|
13228
|
+
const stat = lstatSync(path);
|
|
13229
|
+
if (!stat.isFile() || stat.mtimeMs > cutoff) continue;
|
|
13230
|
+
if (pidAlive(Number(match[1]))) continue;
|
|
13231
|
+
unlinkSync(path);
|
|
13232
|
+
swept++;
|
|
13233
|
+
} catch {
|
|
13234
|
+
continue;
|
|
13235
|
+
}
|
|
13236
|
+
}
|
|
13237
|
+
return swept;
|
|
13238
|
+
}
|
|
13239
|
+
function pidAlive(pid) {
|
|
13240
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 2147483647) return true;
|
|
13241
|
+
if (pid === process.pid) return true;
|
|
13242
|
+
try {
|
|
13243
|
+
process.kill(pid, 0);
|
|
13244
|
+
return true;
|
|
13245
|
+
} catch (err) {
|
|
13246
|
+
return err?.code !== "ESRCH";
|
|
13247
|
+
}
|
|
13248
|
+
}
|
|
13249
|
+
//#endregion
|
|
13250
|
+
//#region src/engine/scan-cache.ts
|
|
13251
|
+
/**
|
|
13252
|
+
* Local incremental scan cache (Beta-to-Stable 1.0 plan, M5.2 / A-2).
|
|
13253
|
+
*
|
|
13254
|
+
* Content-addressed, local-only verdict cache: `--cache` reuses the
|
|
13255
|
+
* per-file rule outputs of a previous scan when the file's bytes AND the
|
|
13256
|
+
* active rule set are unchanged, and invalidates everything else. The
|
|
13257
|
+
* key is `sha256(fileText) + rulesDigest` + the file's own identity
|
|
13258
|
+
* (repo-relative path + adapter id + parse mode — audit C1/W9), where
|
|
13259
|
+
* the rules digest folds
|
|
13260
|
+
* in every active rule's id + `detectorRevision ?? 1` (the existing
|
|
13261
|
+
* stale-measurement machinery — Verification Trust Evolution Plan §07 —
|
|
13262
|
+
* reused as the cache invalidation signal, per A-2) plus a source hash
|
|
13263
|
+
* of each external plugin/local rule's `run` function, so a plugin that
|
|
13264
|
+
* changes code without bumping its revision still misses.
|
|
13265
|
+
*
|
|
13266
|
+
* Privacy posture: the cache lives under `<repo>/.mjolnir/cache/`, is
|
|
13267
|
+
* gitignored, never leaves the machine, and this module performs zero
|
|
13268
|
+
* network I/O — fs and crypto only (asserted by the privacy spec).
|
|
13269
|
+
*
|
|
13270
|
+
* Only raw rule-loop outputs are cached. Everything after the loop
|
|
13271
|
+
* (severity overrides, suppressions, overlap dedup, evidence stamping,
|
|
13272
|
+
* tier policy, runtime corroboration, scoring) re-runs on every scan,
|
|
13273
|
+
* so a cached scan is byte-equivalent to a fresh one by construction.
|
|
13274
|
+
*/
|
|
13275
|
+
const CACHE_VERSION = 2;
|
|
13276
|
+
/** Entry cap: a monorepo-scale suite stays far below this; bounded file. */
|
|
13277
|
+
const MAX_ENTRIES$1 = 4096;
|
|
13278
|
+
/**
|
|
13279
|
+
* Audit M5: total byte budget. The old cap counted only ENTRIES, so
|
|
13280
|
+
* 4096 files × ~100KB of findings each could still produce a
|
|
13281
|
+
* multi-hundred-MB writeFileSync on persist. The budget bounds the
|
|
13282
|
+
* serialized size; the newest entries win (real LRU-by-use).
|
|
13283
|
+
*/
|
|
13284
|
+
const MAX_TOTAL_BYTES = 33554432;
|
|
13285
|
+
const MAX_FINDINGS_PER_ENTRY = 1e4;
|
|
13286
|
+
function isCachedFinding(value) {
|
|
13287
|
+
if (!isRecord(value)) return false;
|
|
13288
|
+
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";
|
|
13289
|
+
}
|
|
13290
|
+
function isCacheFile(value) {
|
|
13291
|
+
if (!isRecord(value) || value["version"] !== CACHE_VERSION) return false;
|
|
13292
|
+
const entries = value["entries"];
|
|
13293
|
+
if (!isRecord(entries) || Object.keys(entries).length > MAX_ENTRIES$1) return false;
|
|
13294
|
+
return Object.values(entries).every((entry) => isRecord(entry) && Array.isArray(entry["findings"]) && entry["findings"].length <= MAX_FINDINGS_PER_ENTRY && entry["findings"].every(isCachedFinding));
|
|
13295
|
+
}
|
|
13296
|
+
/** sha256 hex of a string — the only hash this module needs. */
|
|
13297
|
+
function sha256(text) {
|
|
13298
|
+
return createHash("sha256").update(text).digest("hex");
|
|
13299
|
+
}
|
|
13300
|
+
/**
|
|
13301
|
+
* Digest of the active rule set: id + effective detectorRevision for
|
|
13302
|
+
* every rule (sorted for determinism), plus a content hash of all rule
|
|
13303
|
+
* source files. The directory-tree hash catches changes to ANY file
|
|
13304
|
+
* under src/rules/ — same-file helpers, cross-module imports, shared
|
|
13305
|
+
* utilities — without needing to trace the import graph. This is the
|
|
13306
|
+
* only reliable way to detect helper function changes in a bundled
|
|
13307
|
+
* ESM codebase where String(fn) doesn't reveal transitive dependencies.
|
|
13308
|
+
*/
|
|
13309
|
+
function computeRulesDigest(rules) {
|
|
13310
|
+
const parts = rules.map((r) => `${r.id}:${r.detectorRevision ?? 1}`).sort();
|
|
13311
|
+
const rulesDirHash = hashRulesSourceTree(rules);
|
|
13312
|
+
return sha256(parts.join("|") + "\0" + rulesDirHash);
|
|
13313
|
+
}
|
|
13314
|
+
/**
|
|
13315
|
+
* Hash all .ts files under src/rules/ (when the first rule's modulePath
|
|
13316
|
+
* reveals the project root). Falls back to per-rule function source
|
|
13317
|
+
* hashing when the directory cannot be resolved.
|
|
13318
|
+
*/
|
|
13319
|
+
function hashRulesSourceTree(rules) {
|
|
13320
|
+
const firstPath = rules.find((r) => r.modulePath)?.modulePath;
|
|
13321
|
+
if (!firstPath) return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
|
|
13322
|
+
const idx = firstPath.replace(/\\/g, "/").indexOf("/src/rules/");
|
|
13323
|
+
if (idx < 0) return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
|
|
13324
|
+
const rulesDir = firstPath.replace(/\\/g, "/").slice(0, idx + 11);
|
|
13325
|
+
try {
|
|
13326
|
+
const hash = createHash("sha256");
|
|
13327
|
+
hashDir(rulesDir, hash, 0);
|
|
13328
|
+
return hash.digest("hex").slice(0, 16);
|
|
13329
|
+
} catch {
|
|
13330
|
+
return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
|
|
13331
|
+
}
|
|
13332
|
+
}
|
|
13333
|
+
function hashDir(dir, hash, depth) {
|
|
13334
|
+
if (depth > 8) return;
|
|
13335
|
+
let entries;
|
|
13336
|
+
try {
|
|
13337
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
13338
|
+
} catch {
|
|
13339
|
+
return;
|
|
12754
13340
|
}
|
|
12755
|
-
|
|
13341
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
13342
|
+
for (const entry of entries) {
|
|
13343
|
+
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
|
13344
|
+
const full = join(dir, entry.name);
|
|
13345
|
+
if (entry.isDirectory()) hashDir(full, hash, depth + 1);
|
|
13346
|
+
else if (entry.isFile() && /\.tsx?$/.test(entry.name)) try {
|
|
13347
|
+
hash.update(entry.name);
|
|
13348
|
+
hash.update(readFileSync(full));
|
|
13349
|
+
} catch {}
|
|
13350
|
+
}
|
|
13351
|
+
}
|
|
12756
13352
|
/**
|
|
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.
|
|
13353
|
+
* Content-addressed key for one file's rule-loop verdicts.
|
|
12782
13354
|
*
|
|
12783
|
-
*
|
|
12784
|
-
*
|
|
12785
|
-
*
|
|
12786
|
-
*
|
|
12787
|
-
*
|
|
13355
|
+
* Audit C1: the key MUST identify the verdict's producer, not just the
|
|
13356
|
+
* bytes — two files with byte-identical text (a copied spec, a generated
|
|
13357
|
+
* snapshot) previously shared one entry, and the first file's cached
|
|
13358
|
+
* findings were re-emitted for the second with the wrong `file` stamp.
|
|
13359
|
+
* The key therefore folds in the repo-relative path AND the adapter id,
|
|
13360
|
+
* plus a parse-mode token (audit W9): a file whose analysis degraded to
|
|
13361
|
+
* the regex fallback (or skipped the AST path) must not collide with a
|
|
13362
|
+
* fully-AST-analyzed verdict for the same bytes — the fallback output
|
|
13363
|
+
* belongs only to the fallback mode.
|
|
12788
13364
|
*/
|
|
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: "ᛁ"
|
|
13365
|
+
function fileCacheKey(rulesDigest, fileText, identity) {
|
|
13366
|
+
const parseMode = identity.parseMode ?? "ast";
|
|
13367
|
+
return sha256(`${CACHE_VERSION}\u0000${rulesDigest}\u0000${identity.relPath}\u0000${identity.adapterId}\u0000${parseMode}\u0000${fileText}`);
|
|
13368
|
+
}
|
|
13369
|
+
/** No-op cache used when --cache is absent: zero stats, zero I/O. */
|
|
13370
|
+
const disabledScanCache = {
|
|
13371
|
+
stats: {
|
|
13372
|
+
hits: 0,
|
|
13373
|
+
misses: 0,
|
|
13374
|
+
file: ""
|
|
13375
|
+
},
|
|
13376
|
+
lookup: () => void 0,
|
|
13377
|
+
store: () => {},
|
|
13378
|
+
persist: () => {}
|
|
12813
13379
|
};
|
|
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
|
-
};
|
|
13380
|
+
/**
|
|
13381
|
+
* Opens (and lazily creates) `<root>/.mjolnir/cache/scan-v<CACHE_VERSION>.json`. A
|
|
13382
|
+
* corrupt, hostile or future-versioned cache file degrades to a cold
|
|
13383
|
+
* cache — never fails the scan.
|
|
13384
|
+
*/
|
|
13385
|
+
function createScanCache(root) {
|
|
13386
|
+
const dir = join(root, ".mjolnir", "cache");
|
|
13387
|
+
const file = join(dir, `scan-v${CACHE_VERSION}.json`);
|
|
13388
|
+
let entries = {};
|
|
13389
|
+
let dirty = false;
|
|
13390
|
+
const entryBytes = /* @__PURE__ */ new Map();
|
|
13391
|
+
let totalBytes = 0;
|
|
13392
|
+
try {
|
|
13393
|
+
if (existsSync(file)) {
|
|
13394
|
+
const stat = lstatSync(file);
|
|
13395
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_TOTAL_BYTES) throw new Error("invalid cache file");
|
|
13396
|
+
entries = parseJsonFile(readFileSync(file, "utf8"), file, isCacheFile).entries;
|
|
13397
|
+
for (const [k, v] of Object.entries(entries)) {
|
|
13398
|
+
const size = Buffer.byteLength(JSON.stringify(v), "utf8") + k.length + 4;
|
|
13399
|
+
entryBytes.set(k, size);
|
|
13400
|
+
totalBytes += size;
|
|
13401
|
+
}
|
|
13402
|
+
if (totalBytes > MAX_TOTAL_BYTES) throw new Error("cache byte budget exceeded");
|
|
13403
|
+
}
|
|
13404
|
+
} catch {
|
|
13405
|
+
entries = {};
|
|
13406
|
+
entryBytes.clear();
|
|
13407
|
+
totalBytes = 0;
|
|
13408
|
+
}
|
|
12853
13409
|
return {
|
|
12854
|
-
|
|
12855
|
-
|
|
12856
|
-
|
|
12857
|
-
|
|
12858
|
-
|
|
12859
|
-
|
|
12860
|
-
|
|
13410
|
+
stats: {
|
|
13411
|
+
hits: 0,
|
|
13412
|
+
misses: 0,
|
|
13413
|
+
file
|
|
13414
|
+
},
|
|
13415
|
+
lookup(key) {
|
|
13416
|
+
const entry = entries[key];
|
|
13417
|
+
if (!entry) {
|
|
13418
|
+
this.stats.misses++;
|
|
13419
|
+
return;
|
|
13420
|
+
}
|
|
13421
|
+
this.stats.hits++;
|
|
13422
|
+
delete entries[key];
|
|
13423
|
+
entries[key] = entry;
|
|
13424
|
+
return structuredClone(entry.findings);
|
|
13425
|
+
},
|
|
13426
|
+
store(key, findings, fileBudgetExceeded) {
|
|
13427
|
+
if (fileBudgetExceeded) return;
|
|
13428
|
+
if (findings.length > MAX_FINDINGS_PER_ENTRY) return;
|
|
13429
|
+
const entryJson = JSON.stringify(findings);
|
|
13430
|
+
const newBytes = Buffer.byteLength(entryJson, "utf8") + key.length + 4;
|
|
13431
|
+
if (newBytes > MAX_TOTAL_BYTES) return;
|
|
13432
|
+
const replacedBytes = entryBytes.get(key) ?? 0;
|
|
13433
|
+
delete entries[key];
|
|
13434
|
+
entries[key] = { findings: structuredClone(findings) };
|
|
13435
|
+
entryBytes.set(key, newBytes);
|
|
13436
|
+
totalBytes = totalBytes - replacedBytes + newBytes;
|
|
13437
|
+
let count = Object.keys(entries).length;
|
|
13438
|
+
while ((count > MAX_ENTRIES$1 || totalBytes > MAX_TOTAL_BYTES) && count > 1) {
|
|
13439
|
+
const oldest = Object.keys(entries)[0];
|
|
13440
|
+
totalBytes -= entryBytes.get(oldest);
|
|
13441
|
+
delete entries[oldest];
|
|
13442
|
+
entryBytes.delete(oldest);
|
|
13443
|
+
count--;
|
|
13444
|
+
}
|
|
13445
|
+
dirty = true;
|
|
13446
|
+
},
|
|
13447
|
+
persist() {
|
|
13448
|
+
if (!dirty) return;
|
|
13449
|
+
try {
|
|
13450
|
+
mkdirSync(dir, { recursive: true });
|
|
13451
|
+
writeFileAtomic(file, JSON.stringify({
|
|
13452
|
+
version: CACHE_VERSION,
|
|
13453
|
+
entries
|
|
13454
|
+
}), { encoding: "utf8" });
|
|
13455
|
+
} catch {}
|
|
13456
|
+
}
|
|
12861
13457
|
};
|
|
12862
13458
|
}
|
|
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
13459
|
//#endregion
|
|
12869
13460
|
//#region src/reporter/theme.ts
|
|
12870
13461
|
/**
|
|
@@ -12880,8 +13471,8 @@ function headlineFor(state, findings) {
|
|
|
12880
13471
|
*
|
|
12881
13472
|
* Symbols always accompany color (color-blind safe, R11).
|
|
12882
13473
|
*
|
|
12883
|
-
* Score-state colors come from ScoreState (
|
|
12884
|
-
* source of truth shared with the badge and
|
|
13474
|
+
* Score-state colors come from ScoreState (presentation.ts) — the single
|
|
13475
|
+
* source of truth shared with the badge, the dashboard and the site.
|
|
12885
13476
|
*
|
|
12886
13477
|
* Terminal robustness (Master-Stabilization-Plan Sprint 5 Task 22):
|
|
12887
13478
|
* box-drawing/gauge helpers accept an explicit width so callers can
|
|
@@ -13058,14 +13649,11 @@ function padTo(s, width) {
|
|
|
13058
13649
|
*/
|
|
13059
13650
|
function scoreGauge(score, p, width = 30, ascii = false) {
|
|
13060
13651
|
const filled = Math.round(score / 100 * width);
|
|
13061
|
-
const color =
|
|
13652
|
+
const color = gaugeColorForBand(deriveScoreState(score).band, p);
|
|
13062
13653
|
if (ascii) return color("#".repeat(filled)) + ".".repeat(Math.max(0, width - filled));
|
|
13063
13654
|
const head = filled > 0 && filled < width ? "▓" : "";
|
|
13064
13655
|
return color("█".repeat(Math.max(0, filled - (head ? 1 : 0)))) + color(head) + "░".repeat(width - filled);
|
|
13065
13656
|
}
|
|
13066
|
-
function gaugeColor(score, p) {
|
|
13067
|
-
return gaugeColorForBand(deriveScoreState(score).band, p);
|
|
13068
|
-
}
|
|
13069
13657
|
/**
|
|
13070
13658
|
* The single band → palette-key mapping, delegated from ScoreState so
|
|
13071
13659
|
* gauge, verdict and badge colors can never disagree again.
|
|
@@ -14220,7 +14808,7 @@ function runForensics(target, options = {}) {
|
|
|
14220
14808
|
const base = stat.isFile() ? dirname(target) : target;
|
|
14221
14809
|
flakyMdPath = join(base, "FLAKY.md");
|
|
14222
14810
|
try {
|
|
14223
|
-
|
|
14811
|
+
writeFileAtomic(flakyMdPath, renderFlakyMd(report));
|
|
14224
14812
|
} catch {
|
|
14225
14813
|
flakyMdPath = void 0;
|
|
14226
14814
|
}
|
|
@@ -15126,8 +15714,6 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
15126
15714
|
skippedFiles++;
|
|
15127
15715
|
continue;
|
|
15128
15716
|
}
|
|
15129
|
-
let fileBudgetExceeded = false;
|
|
15130
|
-
let fileRuleFailed = false;
|
|
15131
15717
|
const relPath = relative(workspace.root, path).replaceAll("\\", "/");
|
|
15132
15718
|
if (!isCiAdapter) {
|
|
15133
15719
|
const lang = {
|
|
@@ -15153,79 +15739,106 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
15153
15739
|
});
|
|
15154
15740
|
let cacheKey = fileCacheKey(rulesDigest, text, identity(wantsAst ? "ast" : "regex"));
|
|
15155
15741
|
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;
|
|
15742
|
+
const executor = createExecutor("sequential", 1, async (job) => {
|
|
15743
|
+
if (job.cacheHit !== void 0) return {
|
|
15744
|
+
path: job.path,
|
|
15745
|
+
status: "CACHE_HIT",
|
|
15746
|
+
findings: [...job.cacheHit],
|
|
15747
|
+
parseFallback: false
|
|
15748
|
+
};
|
|
15749
|
+
let parseFallback = false;
|
|
15750
|
+
let fileRuleFailed = false;
|
|
15751
|
+
let fileBudgetExceeded = false;
|
|
15752
|
+
let parsedAst;
|
|
15753
|
+
try {
|
|
15754
|
+
if (adapter.parseAst && job.wantsAst) {
|
|
15755
|
+
hooks.onProgress?.({
|
|
15756
|
+
phase: "rules",
|
|
15757
|
+
done: scanned,
|
|
15758
|
+
total: testFiles.length,
|
|
15759
|
+
detail: job.path
|
|
15760
|
+
});
|
|
15761
|
+
parsedAst = await adapter.parseAst({
|
|
15762
|
+
path: job.path,
|
|
15763
|
+
text: job.text
|
|
15764
|
+
});
|
|
15192
15765
|
}
|
|
15193
|
-
|
|
15194
|
-
|
|
15195
|
-
|
|
15196
|
-
|
|
15197
|
-
|
|
15198
|
-
|
|
15199
|
-
|
|
15200
|
-
|
|
15201
|
-
|
|
15202
|
-
|
|
15766
|
+
const actualMode = parsedAst ? "ast" : "regex";
|
|
15767
|
+
if (job.wantsAst && actualMode === "regex") {
|
|
15768
|
+
parseFallback = true;
|
|
15769
|
+
parseFallbacks++;
|
|
15770
|
+
cacheKey = fileCacheKey(rulesDigest, job.text, identity(actualMode));
|
|
15771
|
+
const fallbackFindings = cache.lookup(cacheKey);
|
|
15772
|
+
if (fallbackFindings) return {
|
|
15773
|
+
path: job.path,
|
|
15774
|
+
status: "CACHE_HIT",
|
|
15775
|
+
findings: [...fallbackFindings],
|
|
15776
|
+
parseFallback: true
|
|
15777
|
+
};
|
|
15203
15778
|
}
|
|
15204
|
-
|
|
15205
|
-
|
|
15206
|
-
|
|
15207
|
-
|
|
15779
|
+
const fileForRules = parsedAst ? {
|
|
15780
|
+
path: job.path,
|
|
15781
|
+
text: job.text,
|
|
15782
|
+
ast: parsedAst.ast
|
|
15783
|
+
} : {
|
|
15784
|
+
path: job.path,
|
|
15785
|
+
text: job.text
|
|
15786
|
+
};
|
|
15787
|
+
const produced = [];
|
|
15788
|
+
adapter.runRules(activeRules, fileForRules, (f, ruleId, category) => {
|
|
15789
|
+
if (!isValidFindingRecord(f)) {
|
|
15790
|
+
fileRuleFailed = true;
|
|
15791
|
+
onRuleCrash?.(ruleId, job.path, /* @__PURE__ */ new Error(`malformed finding record rejected (severity/line/message must be present, severity ∈ error|warning|info): ${JSON.stringify(f)}`));
|
|
15792
|
+
return;
|
|
15793
|
+
}
|
|
15794
|
+
produced.push({
|
|
15795
|
+
...f,
|
|
15796
|
+
ruleId,
|
|
15797
|
+
category
|
|
15798
|
+
});
|
|
15799
|
+
}, (ruleId, error) => {
|
|
15800
|
+
fileRuleFailed = true;
|
|
15801
|
+
onRuleCrash?.(ruleId, job.path, error);
|
|
15802
|
+
}, {
|
|
15803
|
+
deadline: Math.min(deadline, Date.now() + LIMITS$1.maxFileAnalysisMs),
|
|
15804
|
+
onExceeded: () => {
|
|
15805
|
+
rulesPartial = true;
|
|
15806
|
+
skippedFiles++;
|
|
15807
|
+
truncationReasons.add("file-budget");
|
|
15808
|
+
fileBudgetExceeded = true;
|
|
15809
|
+
}
|
|
15208
15810
|
});
|
|
15209
|
-
|
|
15210
|
-
fileRuleFailed
|
|
15211
|
-
|
|
15212
|
-
|
|
15213
|
-
|
|
15214
|
-
|
|
15215
|
-
|
|
15216
|
-
|
|
15217
|
-
|
|
15218
|
-
|
|
15219
|
-
|
|
15220
|
-
|
|
15221
|
-
|
|
15222
|
-
|
|
15223
|
-
|
|
15224
|
-
|
|
15225
|
-
|
|
15226
|
-
|
|
15227
|
-
|
|
15228
|
-
|
|
15811
|
+
if (!fileRuleFailed && !fileBudgetExceeded) analyzed++;
|
|
15812
|
+
if (!fileRuleFailed) cache.store(cacheKey, produced, fileBudgetExceeded);
|
|
15813
|
+
return {
|
|
15814
|
+
path: job.path,
|
|
15815
|
+
status: "OK",
|
|
15816
|
+
findings: produced,
|
|
15817
|
+
parseFallback
|
|
15818
|
+
};
|
|
15819
|
+
} catch {
|
|
15820
|
+
if (job.wantsAst) parseFallbacks++;
|
|
15821
|
+
skippedFiles++;
|
|
15822
|
+
parseFailed++;
|
|
15823
|
+
return {
|
|
15824
|
+
path: job.path,
|
|
15825
|
+
status: "FAILED",
|
|
15826
|
+
findings: [],
|
|
15827
|
+
parseFallback
|
|
15828
|
+
};
|
|
15829
|
+
} finally {
|
|
15830
|
+
parsedAst?.dispose();
|
|
15831
|
+
}
|
|
15832
|
+
});
|
|
15833
|
+
const outcome = (await executeFiles([{
|
|
15834
|
+
path: relPath,
|
|
15835
|
+
text,
|
|
15836
|
+
wantsAst,
|
|
15837
|
+
...cachedFindings !== void 0 ? { cacheHit: cachedFindings } : {}
|
|
15838
|
+
}], executor))[0];
|
|
15839
|
+
if (outcome?.status === "CACHE_HIT" || outcome?.status === "OK") {
|
|
15840
|
+
for (const f of outcome.findings) findings.push(f);
|
|
15841
|
+
if (cachedFindings !== void 0 && outcome.status === "CACHE_HIT") analyzed++;
|
|
15229
15842
|
}
|
|
15230
15843
|
}
|
|
15231
15844
|
return {
|
|
@@ -15316,12 +15929,16 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
|
|
|
15316
15929
|
const runtimeReportPath = discoveredReport?.path;
|
|
15317
15930
|
const runtimeIncomplete = discoveredReport !== void 0 && discoveredReport.report.analysisComplete !== true;
|
|
15318
15931
|
let forensicVerdicts;
|
|
15932
|
+
let evidenceRecords = [];
|
|
15319
15933
|
if (discoveredReport && discoveredReport.report.analysisComplete === true) try {
|
|
15320
|
-
buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
|
|
15934
|
+
evidenceRecords = buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
|
|
15321
15935
|
stampRuntimeCorroboration(findings, discoveredReport.report, workspace.root);
|
|
15322
15936
|
forensicVerdicts = summarizeForensicVerdicts(discoveredReport.report);
|
|
15323
|
-
} catch {
|
|
15937
|
+
} catch {
|
|
15938
|
+
evidenceRecords = [];
|
|
15939
|
+
}
|
|
15324
15940
|
return {
|
|
15941
|
+
evidenceRecords,
|
|
15325
15942
|
testDeclarationCount,
|
|
15326
15943
|
scopeInfo,
|
|
15327
15944
|
suppressionCount,
|
|
@@ -15464,6 +16081,8 @@ function assembleScanResult(o) {
|
|
|
15464
16081
|
...o.runtimeIncomplete !== void 0 ? { runtimeIncomplete: o.runtimeIncomplete } : {},
|
|
15465
16082
|
identityIncomplete
|
|
15466
16083
|
});
|
|
16084
|
+
const repository = bindRepository(o.scanRoot.root);
|
|
16085
|
+
const candidate = readCandidateBinding(o.scanRoot.root);
|
|
15467
16086
|
const runIdentity = buildRunIdentity({
|
|
15468
16087
|
files: inputSnapshot,
|
|
15469
16088
|
rules: [...o.REVISION_BY_RULE_ID.entries()].map(([id, detectorRevision]) => ({
|
|
@@ -15476,9 +16095,17 @@ function assembleScanResult(o) {
|
|
|
15476
16095
|
trustModelVersion: TRUST_MODEL_VERSION,
|
|
15477
16096
|
scoringModelVersion: SCORING_MODEL_VERSION,
|
|
15478
16097
|
frameworkSupportMatrixVersion: FRAMEWORK_SUPPORT_MATRIX_VERSION,
|
|
15479
|
-
evidenceSchemaVersions: [1]
|
|
16098
|
+
evidenceSchemaVersions: [1],
|
|
16099
|
+
...repository,
|
|
16100
|
+
...candidate !== null ? { candidate } : {}
|
|
16101
|
+
});
|
|
16102
|
+
const evidenceGraph = buildEvidenceGraph({
|
|
16103
|
+
runId: runIdentity,
|
|
16104
|
+
...candidate !== null ? { candidate: {
|
|
16105
|
+
manifestId: candidate.manifestId,
|
|
16106
|
+
candidateSha: candidate.candidateSha
|
|
16107
|
+
} } : {}
|
|
15480
16108
|
});
|
|
15481
|
-
const evidenceGraph = buildEvidenceGraph({ runId: runIdentity });
|
|
15482
16109
|
const hasTests = o.testFileCount > 0 && o.testDeclarationCount > 0;
|
|
15483
16110
|
const suiteInvalidatedBy = [...new Set(o.findings.filter((f) => SUITE_INVALIDATING_RULE_IDS.has(f.ruleId)).map((f) => f.ruleId))].sort();
|
|
15484
16111
|
const finalScore = hasTests ? completion.partial && scopeAdjustedTotal >= 100 ? 99 : scopeAdjustedTotal : null;
|
|
@@ -15488,6 +16115,11 @@ function assembleScanResult(o) {
|
|
|
15488
16115
|
scopeIntegrity,
|
|
15489
16116
|
runIdentity,
|
|
15490
16117
|
evidenceGraph,
|
|
16118
|
+
evidence: {
|
|
16119
|
+
records: o.evidenceRecords ?? [],
|
|
16120
|
+
counts: countEvidence(o.evidenceRecords ?? []),
|
|
16121
|
+
artifact: o.runtimeReportPath ?? null
|
|
16122
|
+
},
|
|
15491
16123
|
score: finalScore,
|
|
15492
16124
|
...hasTests ? {} : { reason: "no-tests-found" },
|
|
15493
16125
|
frameworks: o.frameworks.frameworks,
|
|
@@ -15699,4 +16331,6 @@ async function runScan(args, hooks = {}) {
|
|
|
15699
16331
|
return result;
|
|
15700
16332
|
}
|
|
15701
16333
|
//#endregion
|
|
15702
|
-
export {
|
|
16334
|
+
export { BADGE_BAND as $, plainContext as A, isLintFixtureDir as At, shouldUseAscii as B, DEDUCTIONS as Bt, runForensics as C, computeCodeText as Ct, nextStep as D, LIMITS$1 as Dt, buildFooter as E, DEFAULT_IGNORE_MATCHER as Et, padTo as F, isRecord as Ft, countOrNull as G, deriveEvidenceLevel as Gt, atomicTempPath as H, RULE_CATEGORIES as Ht, palette as I, parseJsonFile as It, evidenceTag as J, deriveScoreState as K, isAdvisoryFinding as Kt, sanitizeData as L, ENGINE_VERSION as Lt, severityIcon as M, isSuppressionActive as Mt, box as N, loadConfig as Nt, okIcon as O, createIgnoreMatcher as Ot, measure as P, readFileBounded as Pt, TRUST_RUNGS as Q, scoreGauge as R, buildEvidenceGraph as Rt, renderSuppressions as S, parseYamlGuarded as St, FLAKE_GLYPH as T, detectFrameworks as Tt, sweepStaleTempFiles as U, SEVERITY_ORDER as Ut, wrapText as V, QA_IMPACT_LABELS as Vt, writeFileAtomic as W, TRUST_ORDER as Wt, testsAnalyzedCell as X, headlineFor as Y, verdictFor as Z, summarizeForensicVerdicts as _, SCAN_ADAPTERS as _t, applyPostScanProcessing as a, correlateFindings as at, loadLocalRules as b, parseAzurePipeline as bt, discoverAndParseRuntimeReport as c, capForTier as ct, isValidFindingRecord as d, massCeiling as dt, SCORE as et, pathMatchesGlob as f, RETIRED_RULE_IDS as ft, selectAdapter as g, runGit as gt, scan_pipeline_exports as h, resolveGitPath as ht, SUITE_INVALIDATING_RULE_IDS as i, getReachableFiles as it, sectionHeader as j, ConfigValidationError as jt, panel as k, globToRegExp as kt, discoverTestFilesPhase as l, computeDimensions as lt, runScan as m, getRule as mt, KNOWN_RULE_IDS as n, TINT as nt, assembleScanResult as o, classifyProvenance as ot, runFileAnalysisPhase as p, RULES as pt, evidenceLevelOf as q, isValidCategory 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, SEARCHED_FOR as vt, sanitizeErrorText as w, parseTsFile as wt, loadSuppressions as x, parseWorkflow as xt, renderGateNotice as y, isAzurePipelineFixture as yt, shouldColorize as z, MEASURED_FP as zt };
|
|
16335
|
+
|
|
16336
|
+
//# sourceMappingURL=scan-pipeline-CCJdEKNa.mjs.map
|