mjolnir-qa 2.1.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 +410 -2
- 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 +232 -16
- package/dist/cli.mjs +2035 -1262
- package/dist/mcp/stdio.mjs +1559 -517
- package/dist/scan-pipeline-Bg-46cLS.mjs +2 -0
- package/dist/{scan-pipeline-MQGUSey3.mjs → scan-pipeline-CCJdEKNa.mjs} +1722 -718
- package/package.json +59 -8
- package/dist/scan-pipeline-C_Rs4u8N.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 { existsSync, mkdirSync, readFileSync, readdirSync, statSync,
|
|
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
|
-
import { createHash } from "node:crypto";
|
|
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)
|
|
@@ -97,6 +123,38 @@ const DEDUCTIONS = {
|
|
|
97
123
|
info: 1
|
|
98
124
|
};
|
|
99
125
|
//#endregion
|
|
126
|
+
//#region src/engine/completion.ts
|
|
127
|
+
function deriveCompletion(input) {
|
|
128
|
+
const truncationReasons = [...new Set(input.truncationReasons)].sort();
|
|
129
|
+
const reasons = /* @__PURE__ */ new Set();
|
|
130
|
+
if (input.discoveryTruncated) reasons.add("discovery-truncated");
|
|
131
|
+
if (input.rulesPartial) reasons.add("rules-partial");
|
|
132
|
+
if (input.skippedFiles > 0) reasons.add(`skipped-files:${input.skippedFiles}`);
|
|
133
|
+
if (input.rulesCrashed > 0) reasons.add(`rules-crashed:${input.rulesCrashed}`);
|
|
134
|
+
if (input.scopeIgnored > 0) reasons.add(`scope-ignored:${input.scopeIgnored}`);
|
|
135
|
+
if (input.scopeUnrecognized > 0) reasons.add(`scope-unrecognized:${input.scopeUnrecognized}`);
|
|
136
|
+
if (input.parseFailed > 0) reasons.add(`parse-failed:${input.parseFailed}`);
|
|
137
|
+
if (input.parseFallbacks && input.parseFallbacks > 0) reasons.add(`parse-fallbacks:${input.parseFallbacks}`);
|
|
138
|
+
if (input.scopeDegraded) reasons.add(`scope-degraded:${input.scopeDegraded}`);
|
|
139
|
+
if (input.runtimeIncomplete) reasons.add("runtime-incomplete");
|
|
140
|
+
if (input.identityIncomplete) reasons.add("identity-incomplete");
|
|
141
|
+
for (const reason of truncationReasons) reasons.add(`truncated:${reason}`);
|
|
142
|
+
const discoveryPartial = input.discoveryTruncated || input.scopeIgnored > 0 || input.scopeUnrecognized > 0 || input.scopeDegraded !== void 0;
|
|
143
|
+
const rulesPartial = input.rulesPartial || input.rulesCrashed > 0 || input.parseFailed > 0;
|
|
144
|
+
return {
|
|
145
|
+
partial: discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0,
|
|
146
|
+
analysisStatus: {
|
|
147
|
+
discovery: discoveryPartial ? "partial" : "complete",
|
|
148
|
+
rules: rulesPartial ? "partial" : "complete",
|
|
149
|
+
skippedFiles: input.skippedFiles,
|
|
150
|
+
rulesCrashed: input.rulesCrashed,
|
|
151
|
+
parseFallbacks: input.parseFallbacks ?? 0,
|
|
152
|
+
...truncationReasons.length > 0 ? { truncationReasons } : {},
|
|
153
|
+
reasons: [...reasons].sort()
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
//#endregion
|
|
100
158
|
//#region src/engine/runtime-corroboration.ts
|
|
101
159
|
/**
|
|
102
160
|
* Stamp runtime corroboration + trust levels onto findings (mutates in
|
|
@@ -710,21 +768,13 @@ const MEASURED_FP = {
|
|
|
710
768
|
* empty scan is an observation, not a proof).
|
|
711
769
|
*/
|
|
712
770
|
function summaryTrustLevel(findings) {
|
|
713
|
-
const order = [
|
|
714
|
-
"L0",
|
|
715
|
-
"L1",
|
|
716
|
-
"L2",
|
|
717
|
-
"L3",
|
|
718
|
-
"L4",
|
|
719
|
-
"L5"
|
|
720
|
-
];
|
|
721
771
|
let best = 0;
|
|
722
772
|
for (const f of findings) {
|
|
723
773
|
const t = f.trustLevel ?? "L2";
|
|
724
|
-
const idx =
|
|
774
|
+
const idx = TRUST_ORDER.indexOf(t);
|
|
725
775
|
if (idx > best) best = idx;
|
|
726
776
|
}
|
|
727
|
-
return
|
|
777
|
+
return TRUST_ORDER[best];
|
|
728
778
|
}
|
|
729
779
|
/**
|
|
730
780
|
* A finding's trust rung (0–5) on the canonical ladder. Corroborated
|
|
@@ -735,14 +785,7 @@ function summaryTrustLevel(findings) {
|
|
|
735
785
|
*/
|
|
736
786
|
function trustRung(f) {
|
|
737
787
|
const t = f.trustLevel ?? deriveTrustLevel(f);
|
|
738
|
-
return
|
|
739
|
-
"L0",
|
|
740
|
-
"L1",
|
|
741
|
-
"L2",
|
|
742
|
-
"L3",
|
|
743
|
-
"L4",
|
|
744
|
-
"L5"
|
|
745
|
-
].indexOf(t);
|
|
788
|
+
return TRUST_ORDER.indexOf(t);
|
|
746
789
|
}
|
|
747
790
|
/**
|
|
748
791
|
* Hard incompleteness ceilings (plan §6: partial/crash/truncation;
|
|
@@ -863,7 +906,10 @@ function sha256$1(text) {
|
|
|
863
906
|
return createHash("sha256").update(text).digest("hex");
|
|
864
907
|
}
|
|
865
908
|
function canonical(value) {
|
|
866
|
-
return JSON.stringify(value);
|
|
909
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
910
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
911
|
+
const record = value;
|
|
912
|
+
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`;
|
|
867
913
|
}
|
|
868
914
|
function buildRunIdentity(input) {
|
|
869
915
|
const inputFingerprint = sha256$1([...input.files].map((f) => f.hash ? `${f.path}:${f.hash}` : `${f.path}:${f.size}`).sort().join("\n"));
|
|
@@ -876,6 +922,17 @@ function buildRunIdentity(input) {
|
|
|
876
922
|
engineVersion: input.engineVersion
|
|
877
923
|
};
|
|
878
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
|
+
};
|
|
879
936
|
if (input.trustModelVersion !== void 0) verdictInputs.trustModelVersion = input.trustModelVersion;
|
|
880
937
|
if (input.scoringModelVersion !== void 0) verdictInputs.scoringModelVersion = input.scoringModelVersion;
|
|
881
938
|
if (input.frameworkSupportMatrixVersion !== void 0) verdictInputs.frameworkSupportMatrixVersion = input.frameworkSupportMatrixVersion;
|
|
@@ -883,15 +940,31 @@ function buildRunIdentity(input) {
|
|
|
883
940
|
if (input.suppressionFingerprint !== void 0) verdictInputs.suppressionFingerprint = input.suppressionFingerprint;
|
|
884
941
|
if (input.policyFingerprint !== void 0) verdictInputs.policyFingerprint = input.policyFingerprint;
|
|
885
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");
|
|
886
954
|
return {
|
|
887
|
-
scanId
|
|
955
|
+
scanId,
|
|
888
956
|
inputFingerprint,
|
|
889
957
|
rulesDigest,
|
|
890
958
|
configFingerprint,
|
|
891
959
|
engineVersion: input.engineVersion,
|
|
960
|
+
boundLinks,
|
|
892
961
|
...input.trustModelVersion !== void 0 ? { trustModelVersion: input.trustModelVersion } : {},
|
|
893
962
|
...input.scoringModelVersion !== void 0 ? { scoringModelVersion: input.scoringModelVersion } : {},
|
|
894
|
-
...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 } : {}
|
|
895
968
|
};
|
|
896
969
|
}
|
|
897
970
|
/**
|
|
@@ -932,9 +1005,218 @@ function buildEvidenceGraph(parts) {
|
|
|
932
1005
|
...parts.reproduction !== void 0 ? { ref: parts.reproduction } : {}
|
|
933
1006
|
}
|
|
934
1007
|
],
|
|
935
|
-
...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
|
|
936
1210
|
};
|
|
937
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
|
+
}
|
|
938
1220
|
//#endregion
|
|
939
1221
|
//#region src/engine/version.ts
|
|
940
1222
|
/**
|
|
@@ -945,7 +1227,7 @@ function buildEvidenceGraph(parts) {
|
|
|
945
1227
|
* scripts/sync-sarif-version.cjs and guarded by the version-consistency
|
|
946
1228
|
* spec. cli.ts re-exports this as CLI_VERSION.
|
|
947
1229
|
*/
|
|
948
|
-
const ENGINE_VERSION = "
|
|
1230
|
+
const ENGINE_VERSION = "4.0.0";
|
|
949
1231
|
//#endregion
|
|
950
1232
|
//#region src/engine/contract-versions.ts
|
|
951
1233
|
/**
|
|
@@ -1106,7 +1388,9 @@ function basename$1(p) {
|
|
|
1106
1388
|
* Internal files (cache, hashes) use `isRecord`; user-facing files
|
|
1107
1389
|
* (config, plugins) use stricter checks that throw descriptive errors.
|
|
1108
1390
|
*/
|
|
1391
|
+
const MAX_JSON_BYTES = 16777216;
|
|
1109
1392
|
function parseJsonFile(text, source, validate) {
|
|
1393
|
+
if (Buffer.byteLength(text, "utf8") > MAX_JSON_BYTES) throw new Error(`JSON source exceeds the ${MAX_JSON_BYTES}-byte limit: ${source}`);
|
|
1110
1394
|
let parsed;
|
|
1111
1395
|
try {
|
|
1112
1396
|
parsed = JSON.parse(text);
|
|
@@ -1125,6 +1409,59 @@ function isRecord(v) {
|
|
|
1125
1409
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1126
1410
|
}
|
|
1127
1411
|
//#endregion
|
|
1412
|
+
//#region src/lib/fs-bounded.ts
|
|
1413
|
+
function closeQuiet(fd) {
|
|
1414
|
+
try {
|
|
1415
|
+
closeSync(fd);
|
|
1416
|
+
} catch {
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
function readFileBounded(path, maxBytes) {
|
|
1421
|
+
let fd;
|
|
1422
|
+
try {
|
|
1423
|
+
const pathStat = lstatSync(path);
|
|
1424
|
+
if (pathStat.isSymbolicLink()) return {
|
|
1425
|
+
ok: false,
|
|
1426
|
+
reason: "symlink"
|
|
1427
|
+
};
|
|
1428
|
+
if (!pathStat.isFile()) return {
|
|
1429
|
+
ok: false,
|
|
1430
|
+
reason: "unreadable"
|
|
1431
|
+
};
|
|
1432
|
+
fd = openSync(path, 0);
|
|
1433
|
+
const fdStat = fstatSync(fd);
|
|
1434
|
+
if (!fdStat.isFile() || fdStat.size > maxBytes) return {
|
|
1435
|
+
ok: false,
|
|
1436
|
+
reason: fdStat.size > maxBytes ? "too-large" : "unreadable"
|
|
1437
|
+
};
|
|
1438
|
+
const chunks = [];
|
|
1439
|
+
let total = 0;
|
|
1440
|
+
const buffer = Buffer.allocUnsafe(Math.min(65536, maxBytes));
|
|
1441
|
+
while (true) {
|
|
1442
|
+
const read = readSync(fd, buffer, 0, buffer.length, null);
|
|
1443
|
+
if (read === 0) break;
|
|
1444
|
+
total += read;
|
|
1445
|
+
if (total > maxBytes) return {
|
|
1446
|
+
ok: false,
|
|
1447
|
+
reason: "too-large"
|
|
1448
|
+
};
|
|
1449
|
+
chunks.push(Buffer.from(buffer.subarray(0, read)));
|
|
1450
|
+
}
|
|
1451
|
+
return {
|
|
1452
|
+
ok: true,
|
|
1453
|
+
data: Buffer.concat(chunks)
|
|
1454
|
+
};
|
|
1455
|
+
} catch {
|
|
1456
|
+
return {
|
|
1457
|
+
ok: false,
|
|
1458
|
+
reason: "unreadable"
|
|
1459
|
+
};
|
|
1460
|
+
} finally {
|
|
1461
|
+
if (fd !== void 0) closeQuiet(fd);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
//#endregion
|
|
1128
1465
|
//#region src/config/config-schema.ts
|
|
1129
1466
|
const VALID_GATES = /* @__PURE__ */ new Set([
|
|
1130
1467
|
"advisory",
|
|
@@ -1223,7 +1560,11 @@ const CONFIG_NAMES = ["mjolnir.config.json", ".mjolnir.json"];
|
|
|
1223
1560
|
function findConfigPath(root) {
|
|
1224
1561
|
for (const name of CONFIG_NAMES) {
|
|
1225
1562
|
const p = join(root, name);
|
|
1226
|
-
|
|
1563
|
+
try {
|
|
1564
|
+
if (existsSync(p) && !lstatSync(p).isSymbolicLink()) return p;
|
|
1565
|
+
} catch {
|
|
1566
|
+
continue;
|
|
1567
|
+
}
|
|
1227
1568
|
}
|
|
1228
1569
|
return null;
|
|
1229
1570
|
}
|
|
@@ -1243,7 +1584,10 @@ function loadConfig(root, options = {}) {
|
|
|
1243
1584
|
const p = join(root, name);
|
|
1244
1585
|
if (!existsSync(p)) continue;
|
|
1245
1586
|
try {
|
|
1246
|
-
|
|
1587
|
+
if (!existsSync(p) || lstatSync(p).isSymbolicLink()) continue;
|
|
1588
|
+
const read = readFileBounded(p, 1048576);
|
|
1589
|
+
if (!read.ok) throw new Error("config source could not be read safely");
|
|
1590
|
+
const parsed = parseJsonFile(read.data.toString("utf8"), p, (v) => isRecord(v));
|
|
1247
1591
|
return {
|
|
1248
1592
|
config: parsed,
|
|
1249
1593
|
path: p,
|
|
@@ -1291,20 +1635,9 @@ function validate(cfg, knownRuleIds) {
|
|
|
1291
1635
|
return warnings;
|
|
1292
1636
|
}
|
|
1293
1637
|
/**
|
|
1294
|
-
*
|
|
1295
|
-
*
|
|
1296
|
-
*
|
|
1297
|
-
* documented window.
|
|
1298
|
-
*
|
|
1299
|
-
* Audit S4 (remediation plan): the config-file MTIME is no longer an
|
|
1300
|
-
* expiry anchor. Anchoring the 90-day default at mtime meant ANY edit to
|
|
1301
|
-
* mjolnir.config.json — a reformat, an unrelated key, a `touch` — reset
|
|
1302
|
-
* the 90-day window for EVERY hand-authored entry: suppressions could be
|
|
1303
|
-
* extended indefinitely without touching their own fields. The expiry is
|
|
1304
|
-
* now the entry's explicit `expires` date alone; an entry without one
|
|
1305
|
-
* stays active and is honestly labeled "(no expiry set)" in the
|
|
1306
|
-
* suppressions report. Hand-authored entries should declare `expires` at
|
|
1307
|
-
* 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.
|
|
1308
1641
|
*/
|
|
1309
1642
|
function isSuppressionActive(ign, now = /* @__PURE__ */ new Date()) {
|
|
1310
1643
|
if (!ign.expires) return true;
|
|
@@ -1542,8 +1875,9 @@ function sharedWalk(options) {
|
|
|
1542
1875
|
}
|
|
1543
1876
|
const full = join(dir, entry.name);
|
|
1544
1877
|
const rel = relative(options.root, full).replaceAll("\\", "/");
|
|
1878
|
+
if (entry.isDirectory() && options.skipDirs.includes(entry.name)) continue;
|
|
1545
1879
|
if (options.ignoreMatcher.isIgnored(rel)) {
|
|
1546
|
-
options.onIgnored?.();
|
|
1880
|
+
if (!rel.startsWith(".mjolnir/")) options.onIgnored?.(rel);
|
|
1547
1881
|
continue;
|
|
1548
1882
|
}
|
|
1549
1883
|
if (entry.isSymbolicLink()) {
|
|
@@ -1551,7 +1885,6 @@ function sharedWalk(options) {
|
|
|
1551
1885
|
continue;
|
|
1552
1886
|
}
|
|
1553
1887
|
if (entry.isDirectory()) {
|
|
1554
|
-
if (options.skipDirs.includes(entry.name)) continue;
|
|
1555
1888
|
if (rel.split("/").length > LIMITS$1.maxDepth) {
|
|
1556
1889
|
options.onSkipped("max-depth");
|
|
1557
1890
|
continue;
|
|
@@ -1569,7 +1902,7 @@ function sharedWalk(options) {
|
|
|
1569
1902
|
} catch {
|
|
1570
1903
|
options.onSkipped("stat-failed");
|
|
1571
1904
|
}
|
|
1572
|
-
else if (entry.isFile()) options.onUnrecognized?.();
|
|
1905
|
+
else if (entry.isFile()) options.onUnrecognized?.(rel);
|
|
1573
1906
|
}
|
|
1574
1907
|
};
|
|
1575
1908
|
walk(options.root);
|
|
@@ -1587,7 +1920,33 @@ function sharedWalk(options) {
|
|
|
1587
1920
|
*
|
|
1588
1921
|
* When nothing is detectable we report `unknown` and the scanner analyzes
|
|
1589
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.
|
|
1590
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.
|
|
1944
|
+
*/
|
|
1945
|
+
const DETECTABLE_TEST_FRAMEWORKS = [
|
|
1946
|
+
"jest",
|
|
1947
|
+
"vitest",
|
|
1948
|
+
"playwright"
|
|
1949
|
+
];
|
|
1591
1950
|
const CONFIG_FILES = {
|
|
1592
1951
|
jest: [
|
|
1593
1952
|
"jest.config.ts",
|
|
@@ -1606,7 +1965,7 @@ const CONFIG_FILES = {
|
|
|
1606
1965
|
};
|
|
1607
1966
|
function detectFrameworks(ws) {
|
|
1608
1967
|
const found = /* @__PURE__ */ new Set();
|
|
1609
|
-
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);
|
|
1610
1969
|
if (!found.has("jest") && ws.packageJson["jest"] !== void 0) found.add("jest");
|
|
1611
1970
|
const deps = {
|
|
1612
1971
|
...ws.packageJson["dependencies"],
|
|
@@ -1628,11 +1987,7 @@ function detectFrameworks(ws) {
|
|
|
1628
1987
|
};
|
|
1629
1988
|
}
|
|
1630
1989
|
return {
|
|
1631
|
-
frameworks:
|
|
1632
|
-
"jest",
|
|
1633
|
-
"vitest",
|
|
1634
|
-
"playwright"
|
|
1635
|
-
].filter((f) => found.has(f)),
|
|
1990
|
+
frameworks: DETECTABLE_TEST_FRAMEWORKS.filter((f) => found.has(f)),
|
|
1636
1991
|
unknown: false
|
|
1637
1992
|
};
|
|
1638
1993
|
}
|
|
@@ -1649,6 +2004,13 @@ function detectFrameworks(ws) {
|
|
|
1649
2004
|
* byte-identical — a score shift is a regression, not an improvement.
|
|
1650
2005
|
*/
|
|
1651
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
|
+
*/
|
|
1652
2014
|
function getProject() {
|
|
1653
2015
|
if (!project) project = new Project({
|
|
1654
2016
|
useInMemoryFileSystem: true,
|
|
@@ -2175,7 +2537,36 @@ function frameworkFilterApplies(rule, file) {
|
|
|
2175
2537
|
* "cypress", `@jest/globals` → "jest", `vitest` → "vitest". Config
|
|
2176
2538
|
* gating is rule-declared (`configFiles`), not hard-coded here.
|
|
2177
2539
|
*/
|
|
2178
|
-
|
|
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)$/;
|
|
2179
2570
|
/**
|
|
2180
2571
|
* Fallback config list for `configOnly` rules that do not declare
|
|
2181
2572
|
* `configFiles` (the legacy playwright.config.* gating, preserved
|
|
@@ -2282,11 +2673,42 @@ const typescriptAdapter = {
|
|
|
2282
2673
|
fixtureDirMemo: /* @__PURE__ */ new Map()
|
|
2283
2674
|
});
|
|
2284
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
|
+
},
|
|
2285
2707
|
runRules(rules, file, emit, onCrash, budget) {
|
|
2286
|
-
const withAst = {
|
|
2708
|
+
const withAst = file.ast === void 0 ? {
|
|
2287
2709
|
...file,
|
|
2288
2710
|
ast: parseTsFile(file)
|
|
2289
|
-
};
|
|
2711
|
+
} : file;
|
|
2290
2712
|
const withTags = {
|
|
2291
2713
|
...withAst,
|
|
2292
2714
|
frameworkTags: frameworkTagsFromImports(withAst.text)
|
|
@@ -3671,7 +4093,7 @@ const SCAN_ADAPTERS = [
|
|
|
3671
4093
|
* the adapter that claims it; per-adapter caps still apply.
|
|
3672
4094
|
*/
|
|
3673
4095
|
function discoverAllTestFiles(ctx, languageAdapters, buckets, fixtureDirMemo) {
|
|
3674
|
-
const walkSkips = languageAdapters.reduce((common, a) => common.filter((name) => a.dirSkips.includes(name)), languageAdapters[0]?.dirSkips ?? []);
|
|
4096
|
+
const walkSkips = [.../* @__PURE__ */ new Set([...languageAdapters.reduce((common, a) => common.filter((name) => a.dirSkips.includes(name)), languageAdapters[0]?.dirSkips ?? []), ".mjolnir"])];
|
|
3675
4097
|
sharedWalk({
|
|
3676
4098
|
root: ctx.workspace.root,
|
|
3677
4099
|
deadline: ctx.deadline,
|
|
@@ -3697,10 +4119,63 @@ function discoverAllTestFiles(ctx, languageAdapters, buckets, fixtureDirMemo) {
|
|
|
3697
4119
|
},
|
|
3698
4120
|
isFull: () => languageAdapters.every((a) => (buckets.get(a.id)?.length ?? 0) >= ctx.maxFiles),
|
|
3699
4121
|
fixtureDirMemo,
|
|
3700
|
-
onIgnored:
|
|
3701
|
-
|
|
4122
|
+
onIgnored: (path) => {
|
|
4123
|
+
if (isScopeRelevantIgnored(path)) ctx.onIgnored?.(path);
|
|
4124
|
+
},
|
|
4125
|
+
onUnrecognized: (path) => {
|
|
4126
|
+
if (typeof path === "string" && isUnrecognizedSourceCandidate(path)) ctx.onUnrecognized?.(path);
|
|
4127
|
+
}
|
|
3702
4128
|
});
|
|
3703
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
|
+
*/
|
|
4157
|
+
function isScopeRelevantIgnored(path) {
|
|
4158
|
+
const name = path.replaceAll("\\", "/").split("/").pop() ?? path;
|
|
4159
|
+
if (!/\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml|min\.js)$/i.test(name)) return false;
|
|
4160
|
+
return isKnownTestFile(path);
|
|
4161
|
+
}
|
|
4162
|
+
function isUnrecognizedSourceCandidate(path) {
|
|
4163
|
+
const normalized = path.replaceAll("\\", "/");
|
|
4164
|
+
const name = normalized.split("/").pop() ?? normalized;
|
|
4165
|
+
if (normalized.includes(".github/workflows/") || name === "azure-pipelines.yml" || name === "Jenkinsfile") return false;
|
|
4166
|
+
const extensionIndex = name.lastIndexOf(".");
|
|
4167
|
+
const stem = extensionIndex > 0 ? name.slice(0, extensionIndex) : name;
|
|
4168
|
+
if (name === "mjolnir.config.json" || name === ".mjolnir.json" || name === "package.json" || name === "package-lock.json" || name === "pnpm-lock.yaml" || name === "yarn.lock" || [
|
|
4169
|
+
"playwright",
|
|
4170
|
+
"vitest",
|
|
4171
|
+
"vite",
|
|
4172
|
+
"eslint",
|
|
4173
|
+
"tsconfig",
|
|
4174
|
+
"jsconfig"
|
|
4175
|
+
].some((prefix) => stem === prefix || stem.startsWith(`${prefix}.`))) return false;
|
|
4176
|
+
const testLike = /(?:^|\/)__tests__\//i.test(normalized) || /\.(?:spec|test)\.[cm]?[jt]sx?$/i.test(name);
|
|
4177
|
+
return /\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml)$/i.test(name) && testLike;
|
|
4178
|
+
}
|
|
3704
4179
|
/** Whether ANY shipped adapter would discover this path as a test file. */
|
|
3705
4180
|
function isKnownTestFile(path) {
|
|
3706
4181
|
return SCAN_ADAPTERS.some((a) => a.isTestFile(path));
|
|
@@ -3764,17 +4239,14 @@ const SEARCHED_FOR = [
|
|
|
3764
4239
|
* The fix: resolve git ONCE per process to an ABSOLUTE path by walking
|
|
3765
4240
|
* the PATH directly (never consulting the CWD), verify the candidate is
|
|
3766
4241
|
* an existing file, and pass that path to execFileSync. Resolution is
|
|
3767
|
-
* memoized; a failure to find a real git anywhere on PATH
|
|
3768
|
-
*
|
|
3769
|
-
* — degraded git data already means full-file attribution, never a
|
|
3770
|
-
* crash.
|
|
4242
|
+
* memoized; a failure to find a real git anywhere on PATH is recorded
|
|
4243
|
+
* and callers degrade without invoking a bare name.
|
|
3771
4244
|
*/
|
|
3772
4245
|
let resolvedGit = void 0;
|
|
3773
4246
|
/**
|
|
3774
4247
|
* The absolute path of the git binary Mjölnir will invoke, or null when
|
|
3775
|
-
* PATH carries no executable `git` at all
|
|
3776
|
-
*
|
|
3777
|
-
* as before, minus the CWD-hijack surface).
|
|
4248
|
+
* PATH carries no executable `git` at all. Callers must degrade without
|
|
4249
|
+
* invoking a bare name.
|
|
3778
4250
|
*/
|
|
3779
4251
|
function resolveGitPath() {
|
|
3780
4252
|
if (resolvedGit !== void 0) return resolvedGit;
|
|
@@ -3807,7 +4279,8 @@ function resolveGitPath() {
|
|
|
3807
4279
|
* the previous inline `git()` helpers, with the hijack surface closed.
|
|
3808
4280
|
*/
|
|
3809
4281
|
function runGit(root, args) {
|
|
3810
|
-
const exe = resolveGitPath()
|
|
4282
|
+
const exe = resolveGitPath();
|
|
4283
|
+
if (!exe) return null;
|
|
3811
4284
|
try {
|
|
3812
4285
|
return execFileSync(exe, [
|
|
3813
4286
|
"-C",
|
|
@@ -3943,7 +4416,7 @@ function computeChangedScope(root, baseBranch) {
|
|
|
3943
4416
|
"--",
|
|
3944
4417
|
...chunk
|
|
3945
4418
|
]);
|
|
3946
|
-
if (output === null)
|
|
4419
|
+
if (output === null) return null;
|
|
3947
4420
|
const sections = output.split("\ndiff --git ");
|
|
3948
4421
|
for (const section of sections) {
|
|
3949
4422
|
const body = section.startsWith("diff --git ") ? section : `diff --git ${section}`;
|
|
@@ -3971,6 +4444,11 @@ function computeChangedScope(root, baseBranch) {
|
|
|
3971
4444
|
"--unified=0",
|
|
3972
4445
|
"HEAD"
|
|
3973
4446
|
], changedFiles);
|
|
4447
|
+
if (committedDiffs === null || workingDiffs === null) return {
|
|
4448
|
+
changed: {},
|
|
4449
|
+
degraded: true,
|
|
4450
|
+
reason: "diff-failed"
|
|
4451
|
+
};
|
|
3974
4452
|
for (const file of changedFiles) {
|
|
3975
4453
|
const committedDiff = committedDiffs.get(file);
|
|
3976
4454
|
const workingDiff = workingDiffs.get(file);
|
|
@@ -3996,8 +4474,11 @@ const LIMITS_MAX_LINES = 1e6;
|
|
|
3996
4474
|
function allLinesOf(root, file) {
|
|
3997
4475
|
const full = join(root, file);
|
|
3998
4476
|
try {
|
|
3999
|
-
|
|
4000
|
-
|
|
4477
|
+
const stat = lstatSync(full);
|
|
4478
|
+
if (stat.isSymbolicLink() || !stat.isFile()) return null;
|
|
4479
|
+
const read = readFileBounded(full, LIMITS$1.maxFileBytes);
|
|
4480
|
+
if (!read.ok) return null;
|
|
4481
|
+
const lineCount = read.data.toString("utf8").split("\n").length;
|
|
4001
4482
|
return Array.from({ length: lineCount }, (_, i) => i + 1);
|
|
4002
4483
|
} catch {
|
|
4003
4484
|
return null;
|
|
@@ -7563,7 +8044,6 @@ const pyBareTruthinessAssert = defineRule({
|
|
|
7563
8044
|
const re = /^[ \t]*assert\s+([A-Za-z_][\w.]*(?:\([^()]*\))?)[ \t]*$/gm;
|
|
7564
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*\()/;
|
|
7565
8046
|
const isGuardFollowedByRealUse = (text, matchIndex, target) => {
|
|
7566
|
-
const root = target.split(".")[0];
|
|
7567
8047
|
const lineEnd = text.indexOf("\n", matchIndex);
|
|
7568
8048
|
if (lineEnd === -1) return false;
|
|
7569
8049
|
const lines = text.slice(lineEnd + 1).split("\n");
|
|
@@ -7573,8 +8053,11 @@ const pyBareTruthinessAssert = defineRule({
|
|
|
7573
8053
|
if (/^\s*def\s/.test(l) && window.length > 0) break;
|
|
7574
8054
|
window.push(l);
|
|
7575
8055
|
}
|
|
7576
|
-
const
|
|
7577
|
-
|
|
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
|
+
});
|
|
7578
8061
|
};
|
|
7579
8062
|
let m;
|
|
7580
8063
|
while ((m = re.exec(text)) !== null) {
|
|
@@ -11577,6 +12060,22 @@ function normalizeOne(report, artifact, v) {
|
|
|
11577
12060
|
function buildEvidenceRecords(report, artifact) {
|
|
11578
12061
|
return report.verdicts.map((v) => normalizeOne(report, artifact, v)).sort(compareEvidenceRecords);
|
|
11579
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
|
+
}
|
|
11580
12079
|
//#endregion
|
|
11581
12080
|
//#region src/engine/provenance.ts
|
|
11582
12081
|
const GENERATED_HEADER_RE = /^\s*(?:\/\/|#|\/\*)\s*(?:auto[- ]?generated|generated by|do not edit)/i;
|
|
@@ -11864,6 +12363,11 @@ var DependencyGraph = class {
|
|
|
11864
12363
|
get size() {
|
|
11865
12364
|
return this.nodes.size;
|
|
11866
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
|
+
}
|
|
11867
12371
|
};
|
|
11868
12372
|
function parsePackageJson(filePath) {
|
|
11869
12373
|
if (!existsSync(filePath)) return void 0;
|
|
@@ -11951,20 +12455,40 @@ function parseManifest(filePath) {
|
|
|
11951
12455
|
if (filePath.endsWith("pom.xml")) return parsePomXml(filePath);
|
|
11952
12456
|
}
|
|
11953
12457
|
/**
|
|
11954
|
-
* Given a set of starting files (e.g. test files), return
|
|
11955
|
-
* 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.
|
|
11956
12471
|
*/
|
|
11957
12472
|
function getReachableFiles(fromFiles, graph) {
|
|
11958
12473
|
const reachable = /* @__PURE__ */ new Set();
|
|
12474
|
+
const unresolvedStarts = [];
|
|
11959
12475
|
const stack = [...fromFiles];
|
|
11960
12476
|
while (stack.length > 0) {
|
|
11961
12477
|
const current = stack.pop();
|
|
11962
12478
|
if (current === void 0) continue;
|
|
11963
12479
|
if (reachable.has(current)) continue;
|
|
11964
|
-
reachable.add(current);
|
|
12480
|
+
if (graph.has(current)) reachable.add(current);
|
|
12481
|
+
else if (fromFiles.includes(current)) {
|
|
12482
|
+
unresolvedStarts.push(current);
|
|
12483
|
+
continue;
|
|
12484
|
+
}
|
|
11965
12485
|
for (const dep of graph.getDependencies(current)) if (!reachable.has(dep)) stack.push(dep);
|
|
11966
12486
|
}
|
|
11967
|
-
return
|
|
12487
|
+
return {
|
|
12488
|
+
reachable: [...reachable].sort(),
|
|
12489
|
+
unresolvedStarts: unresolvedStarts.sort(),
|
|
12490
|
+
resolvedAny: unresolvedStarts.length < fromFiles.length
|
|
12491
|
+
};
|
|
11968
12492
|
}
|
|
11969
12493
|
//#endregion
|
|
11970
12494
|
//#region src/engine/incremental-analysis.ts
|
|
@@ -12004,607 +12528,934 @@ function isIncrementalSafe(changedFiles) {
|
|
|
12004
12528
|
};
|
|
12005
12529
|
}
|
|
12006
12530
|
//#endregion
|
|
12007
|
-
//#region src/
|
|
12008
|
-
const WORTHY_THRESHOLD = 80;
|
|
12009
|
-
const NEEDS_WORK_THRESHOLD = 50;
|
|
12010
|
-
function verdictOf(score) {
|
|
12011
|
-
if (score === null) return "fail";
|
|
12012
|
-
if (score >= WORTHY_THRESHOLD) return "pass";
|
|
12013
|
-
if (score >= NEEDS_WORK_THRESHOLD) return "warn";
|
|
12014
|
-
return "fail";
|
|
12015
|
-
}
|
|
12016
|
-
function hasBlocker(findings) {
|
|
12017
|
-
return findings.some((f) => f.severity === "error");
|
|
12018
|
-
}
|
|
12531
|
+
//#region src/brand/tokens.ts
|
|
12019
12532
|
/**
|
|
12020
|
-
*
|
|
12533
|
+
* The single source of brand truth.
|
|
12021
12534
|
*
|
|
12022
|
-
*
|
|
12023
|
-
*
|
|
12024
|
-
*
|
|
12025
|
-
*
|
|
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.
|
|
12026
12567
|
*/
|
|
12027
|
-
|
|
12028
|
-
|
|
12029
|
-
|
|
12030
|
-
|
|
12031
|
-
|
|
12032
|
-
|
|
12033
|
-
|
|
12034
|
-
|
|
12035
|
-
|
|
12036
|
-
|
|
12037
|
-
|
|
12038
|
-
|
|
12039
|
-
|
|
12040
|
-
|
|
12041
|
-
|
|
12042
|
-
|
|
12043
|
-
|
|
12044
|
-
|
|
12045
|
-
|
|
12046
|
-
|
|
12047
|
-
|
|
12048
|
-
|
|
12049
|
-
|
|
12050
|
-
|
|
12051
|
-
|
|
12052
|
-
|
|
12053
|
-
|
|
12054
|
-
|
|
12055
|
-
|
|
12056
|
-
|
|
12057
|
-
|
|
12058
|
-
|
|
12059
|
-
|
|
12060
|
-
|
|
12061
|
-
|
|
12062
|
-
|
|
12063
|
-
|
|
12064
|
-
|
|
12065
|
-
|
|
12066
|
-
|
|
12067
|
-
|
|
12068
|
-
|
|
12069
|
-
|
|
12070
|
-
|
|
12071
|
-
|
|
12072
|
-
|
|
12073
|
-
|
|
12074
|
-
|
|
12075
|
-
|
|
12076
|
-
|
|
12077
|
-
|
|
12078
|
-
|
|
12079
|
-
|
|
12080
|
-
|
|
12081
|
-
|
|
12082
|
-
|
|
12083
|
-
|
|
12084
|
-
|
|
12085
|
-
|
|
12086
|
-
|
|
12087
|
-
|
|
12088
|
-
|
|
12089
|
-
|
|
12090
|
-
|
|
12091
|
-
|
|
12092
|
-
|
|
12093
|
-
|
|
12568
|
+
/**
|
|
12569
|
+
* The two brand hues plus the neutral they sit on.
|
|
12570
|
+
*
|
|
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.
|
|
12575
|
+
*
|
|
12576
|
+
* AURORA is verification energy — the secondary, and the hue that marks
|
|
12577
|
+
* the runtime half of the trust ladder.
|
|
12578
|
+
*/
|
|
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
|
+
};
|
|
12595
|
+
/**
|
|
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.
|
|
12602
|
+
*/
|
|
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
|
+
};
|
|
12626
|
+
/**
|
|
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.
|
|
12643
|
+
*
|
|
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.
|
|
12648
|
+
*/
|
|
12649
|
+
const SCORE = {
|
|
12650
|
+
critical: "#EC6B66",
|
|
12651
|
+
warning: "#E6BD57",
|
|
12652
|
+
trusted: "#5CC4E0",
|
|
12653
|
+
forged: "#F4DC9C",
|
|
12654
|
+
unmeasured: "#8B939D"
|
|
12655
|
+
};
|
|
12656
|
+
/**
|
|
12657
|
+
* E0 → E1 → E2 is a certainty ramp, and it is deliberately HUE-FREE.
|
|
12658
|
+
*
|
|
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:
|
|
12663
|
+
*
|
|
12664
|
+
* E0 open ring observation, no weight
|
|
12665
|
+
* E1 half-filled pattern evidence, half weight
|
|
12666
|
+
* E2 sealed deterministic proof, full weight
|
|
12667
|
+
*
|
|
12668
|
+
* Colour never carries this alone (R11): the geometry is the signal and
|
|
12669
|
+
* survives `--ascii`, `NO_COLOR` and monochrome print.
|
|
12670
|
+
*/
|
|
12671
|
+
const EVIDENCE = {
|
|
12672
|
+
e0: "#8B939D",
|
|
12673
|
+
e1: "#ABB6C6",
|
|
12674
|
+
e2: "#EAEEF5"
|
|
12675
|
+
};
|
|
12676
|
+
/**
|
|
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.
|
|
12686
|
+
*/
|
|
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"
|
|
12094
12721
|
}
|
|
12095
|
-
|
|
12096
|
-
|
|
12097
|
-
|
|
12098
|
-
|
|
12099
|
-
|
|
12100
|
-
|
|
12101
|
-
|
|
12102
|
-
|
|
12103
|
-
|
|
12104
|
-
|
|
12105
|
-
|
|
12106
|
-
|
|
12107
|
-
|
|
12108
|
-
|
|
12722
|
+
};
|
|
12723
|
+
/**
|
|
12724
|
+
* The score bands as the badge Mjölnir itself generates renders them.
|
|
12725
|
+
*
|
|
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.
|
|
12755
|
+
*/
|
|
12756
|
+
const BADGE_BAND = {
|
|
12757
|
+
critical: "A83A35",
|
|
12758
|
+
warning: "7A5F16",
|
|
12759
|
+
trusted: "1F6F7C",
|
|
12760
|
+
forged: "8A6D1E",
|
|
12761
|
+
unmeasured: "5C646E"
|
|
12762
|
+
};
|
|
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
|
+
}));
|
|
12109
12788
|
//#endregion
|
|
12110
|
-
//#region src/
|
|
12789
|
+
//#region src/reporter/presentation.ts
|
|
12111
12790
|
/**
|
|
12112
|
-
*
|
|
12791
|
+
* presentation.ts — the one place a presentation DECISION is made.
|
|
12113
12792
|
*
|
|
12114
|
-
*
|
|
12115
|
-
*
|
|
12116
|
-
* active rule set are unchanged, and invalidates everything else. The
|
|
12117
|
-
* key is `sha256(fileText) + rulesDigest` + the file's own identity
|
|
12118
|
-
* (repo-relative path + adapter id + parse mode — audit C1/W9), where
|
|
12119
|
-
* the rules digest folds
|
|
12120
|
-
* in every active rule's id + `detectorRevision ?? 1` (the existing
|
|
12121
|
-
* stale-measurement machinery — Verification Trust Evolution Plan §07 —
|
|
12122
|
-
* reused as the cache invalidation signal, per A-2) plus a source hash
|
|
12123
|
-
* of each external plugin/local rule's `run` function, so a plugin that
|
|
12124
|
-
* changes code without bumping its revision still misses.
|
|
12793
|
+
* BITTERSWEET `BW-030a`. Before this module the same decision was written
|
|
12794
|
+
* down four different times and every surface drifted:
|
|
12125
12795
|
*
|
|
12126
|
-
*
|
|
12127
|
-
*
|
|
12128
|
-
*
|
|
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.
|
|
12129
12805
|
*
|
|
12130
|
-
*
|
|
12131
|
-
*
|
|
12132
|
-
*
|
|
12133
|
-
*
|
|
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.
|
|
12811
|
+
*
|
|
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.
|
|
12814
|
+
*
|
|
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.
|
|
12134
12820
|
*/
|
|
12135
|
-
const CACHE_VERSION = 2;
|
|
12136
|
-
/** Entry cap: a monorepo-scale suite stays far below this; bounded file. */
|
|
12137
|
-
const MAX_ENTRIES$1 = 4096;
|
|
12138
12821
|
/**
|
|
12139
|
-
*
|
|
12140
|
-
*
|
|
12141
|
-
*
|
|
12142
|
-
*
|
|
12822
|
+
* What a surface prints when a number was never measured.
|
|
12823
|
+
*
|
|
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.
|
|
12143
12831
|
*/
|
|
12144
|
-
const
|
|
12145
|
-
/**
|
|
12146
|
-
|
|
12147
|
-
|
|
12832
|
+
const UNMEASURED = "unknown — not measured";
|
|
12833
|
+
/**
|
|
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}`;
|
|
12148
12841
|
}
|
|
12149
12842
|
/**
|
|
12150
|
-
*
|
|
12151
|
-
*
|
|
12152
|
-
*
|
|
12153
|
-
*
|
|
12154
|
-
* utilities — without needing to trace the import graph. This is the
|
|
12155
|
-
* only reliable way to detect helper function changes in a bundled
|
|
12156
|
-
* ESM codebase where String(fn) doesn't reveal transitive dependencies.
|
|
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.
|
|
12157
12847
|
*/
|
|
12158
|
-
function
|
|
12159
|
-
|
|
12160
|
-
const rulesDirHash = hashRulesSourceTree(rules);
|
|
12161
|
-
return sha256(parts.join("|") + "\0" + rulesDirHash);
|
|
12848
|
+
function countOrNull(value) {
|
|
12849
|
+
return value ?? null;
|
|
12162
12850
|
}
|
|
12163
12851
|
/**
|
|
12164
|
-
*
|
|
12165
|
-
*
|
|
12166
|
-
*
|
|
12852
|
+
* The "Tests analyzed" cell, shared by every human surface that shows it.
|
|
12853
|
+
*
|
|
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.
|
|
12167
12863
|
*/
|
|
12168
|
-
function
|
|
12169
|
-
|
|
12170
|
-
|
|
12171
|
-
|
|
12172
|
-
|
|
12173
|
-
const rulesDir = firstPath.replace(/\\/g, "/").slice(0, idx + 11);
|
|
12174
|
-
try {
|
|
12175
|
-
const hash = createHash("sha256");
|
|
12176
|
-
hashDir(rulesDir, hash, 0);
|
|
12177
|
-
return hash.digest("hex").slice(0, 16);
|
|
12178
|
-
} catch {
|
|
12179
|
-
return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
|
|
12180
|
-
}
|
|
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"}`;
|
|
12181
12869
|
}
|
|
12182
|
-
|
|
12183
|
-
|
|
12184
|
-
|
|
12185
|
-
|
|
12186
|
-
|
|
12187
|
-
|
|
12188
|
-
|
|
12189
|
-
|
|
12190
|
-
|
|
12191
|
-
|
|
12192
|
-
|
|
12193
|
-
|
|
12194
|
-
|
|
12195
|
-
|
|
12196
|
-
|
|
12197
|
-
|
|
12198
|
-
|
|
12199
|
-
|
|
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."
|
|
12876
|
+
};
|
|
12877
|
+
const RUNES = {
|
|
12878
|
+
critical: "ᚲ",
|
|
12879
|
+
warning: "ᚦ",
|
|
12880
|
+
trusted: "ᛏ",
|
|
12881
|
+
forged: "ᛟ",
|
|
12882
|
+
unmeasured: "ᛁ"
|
|
12883
|
+
};
|
|
12884
|
+
/**
|
|
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
|
|
12897
|
+
};
|
|
12898
|
+
/**
|
|
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));
|
|
12200
12953
|
}
|
|
12201
12954
|
/**
|
|
12202
|
-
*
|
|
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.
|
|
12203
12959
|
*
|
|
12204
|
-
*
|
|
12205
|
-
*
|
|
12206
|
-
*
|
|
12207
|
-
*
|
|
12208
|
-
*
|
|
12209
|
-
* plus a parse-mode token (audit W9): a file whose analysis degraded to
|
|
12210
|
-
* the regex fallback (or skipped the AST path) must not collide with a
|
|
12211
|
-
* fully-AST-analyzed verdict for the same bytes — the fallback output
|
|
12212
|
-
* belongs only to the fallback mode.
|
|
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.
|
|
12213
12965
|
*/
|
|
12214
|
-
function
|
|
12215
|
-
const
|
|
12216
|
-
return
|
|
12966
|
+
function verdictFor(score) {
|
|
12967
|
+
const verdict = deriveScoreState(score).verdict;
|
|
12968
|
+
return verdict === "FORGED" ? "WORTHY" : verdict;
|
|
12217
12969
|
}
|
|
12218
|
-
|
|
12219
|
-
|
|
12220
|
-
|
|
12221
|
-
|
|
12222
|
-
misses: 0,
|
|
12223
|
-
file: ""
|
|
12224
|
-
},
|
|
12225
|
-
lookup: () => void 0,
|
|
12226
|
-
store: () => {},
|
|
12227
|
-
persist: () => {}
|
|
12970
|
+
const EVIDENCE_KIND = {
|
|
12971
|
+
E0: "observation",
|
|
12972
|
+
E1: "heuristic",
|
|
12973
|
+
E2: "deterministic"
|
|
12228
12974
|
};
|
|
12229
12975
|
/**
|
|
12230
|
-
*
|
|
12231
|
-
*
|
|
12232
|
-
*
|
|
12976
|
+
* The effective evidence level of a finding.
|
|
12977
|
+
*
|
|
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.
|
|
12233
12983
|
*/
|
|
12234
|
-
function
|
|
12235
|
-
|
|
12236
|
-
|
|
12237
|
-
|
|
12238
|
-
|
|
12239
|
-
const
|
|
12240
|
-
|
|
12241
|
-
|
|
12242
|
-
|
|
12243
|
-
|
|
12244
|
-
for (const [k, v] of Object.entries(entries)) {
|
|
12245
|
-
const size = JSON.stringify(v).length + k.length + 4;
|
|
12246
|
-
entryBytes.set(k, size);
|
|
12247
|
-
totalBytes += size;
|
|
12248
|
-
}
|
|
12249
|
-
}
|
|
12250
|
-
} catch {
|
|
12251
|
-
entries = {};
|
|
12252
|
-
entryBytes.clear();
|
|
12253
|
-
totalBytes = 0;
|
|
12254
|
-
}
|
|
12255
|
-
return {
|
|
12256
|
-
stats: {
|
|
12257
|
-
hits: 0,
|
|
12258
|
-
misses: 0,
|
|
12259
|
-
file
|
|
12260
|
-
},
|
|
12261
|
-
lookup(key) {
|
|
12262
|
-
const entry = entries[key];
|
|
12263
|
-
if (!entry) {
|
|
12264
|
-
this.stats.misses++;
|
|
12265
|
-
return;
|
|
12266
|
-
}
|
|
12267
|
-
this.stats.hits++;
|
|
12268
|
-
delete entries[key];
|
|
12269
|
-
entries[key] = entry;
|
|
12270
|
-
return structuredClone(entry.findings);
|
|
12271
|
-
},
|
|
12272
|
-
store(key, findings, fileBudgetExceeded) {
|
|
12273
|
-
if (fileBudgetExceeded) return;
|
|
12274
|
-
const replacedBytes = entryBytes.get(key) ?? 0;
|
|
12275
|
-
delete entries[key];
|
|
12276
|
-
const entryJson = JSON.stringify(findings);
|
|
12277
|
-
entries[key] = { findings: structuredClone(findings) };
|
|
12278
|
-
const newBytes = entryJson.length + key.length + 4;
|
|
12279
|
-
entryBytes.set(key, newBytes);
|
|
12280
|
-
totalBytes = totalBytes - replacedBytes + newBytes;
|
|
12281
|
-
let count = Object.keys(entries).length;
|
|
12282
|
-
while ((count > MAX_ENTRIES$1 || totalBytes > MAX_TOTAL_BYTES) && count > 1) {
|
|
12283
|
-
const oldest = Object.keys(entries)[0];
|
|
12284
|
-
totalBytes -= entryBytes.get(oldest);
|
|
12285
|
-
delete entries[oldest];
|
|
12286
|
-
entryBytes.delete(oldest);
|
|
12287
|
-
count--;
|
|
12288
|
-
}
|
|
12289
|
-
dirty = true;
|
|
12290
|
-
},
|
|
12291
|
-
persist() {
|
|
12292
|
-
if (!dirty) return;
|
|
12293
|
-
try {
|
|
12294
|
-
mkdirSync(dir, { recursive: true });
|
|
12295
|
-
writeFileSync(file, JSON.stringify({
|
|
12296
|
-
version: CACHE_VERSION,
|
|
12297
|
-
entries
|
|
12298
|
-
}), "utf8");
|
|
12299
|
-
} catch {}
|
|
12300
|
-
}
|
|
12301
|
-
};
|
|
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";
|
|
12302
12994
|
}
|
|
12303
|
-
//#endregion
|
|
12304
|
-
//#region src/brand/tokens.ts
|
|
12305
12995
|
/**
|
|
12306
|
-
* The
|
|
12307
|
-
*
|
|
12308
|
-
* Every colour, typeface and motion constant Mjölnir shows a human —
|
|
12309
|
-
* terminal, README SVGs, demo video, website, docs, badges — resolves to
|
|
12310
|
-
* a value in this file. Nothing else may define one.
|
|
12311
|
-
*
|
|
12312
|
-
* WHY THIS EXISTS. Before it, the palette existed in six independent
|
|
12313
|
-
* copies: `site/.vitepress/theme/styles/vars.css`, `NORSE` in
|
|
12314
|
-
* `src/reporter/theme.ts`, `scripts/readme-svg.ts`,
|
|
12315
|
-
* `scripts/video/terminal-page.ts`, `scripts/generate-readme-architecture.ts`
|
|
12316
|
-
* and the table in `assets/brand/README.md`. Exactly one pair of those
|
|
12317
|
-
* was guarded (site-doctor Check 8, doc ↔ vars.css). The unguarded edges
|
|
12318
|
-
* are where the shipped surfaces drifted apart: the terminal and the site
|
|
12319
|
-
* disagreed on six semantic roles, the architecture diagram invented its
|
|
12320
|
-
* own neutral ramp, and the README badges still carried a palette retired
|
|
12321
|
-
* two releases earlier. `scripts/brand-doctor.mjs` now checks every edge
|
|
12322
|
-
* against this file.
|
|
12323
|
-
*
|
|
12324
|
-
* PURITY. Pure data. No I/O, no rendering, no environment access, no
|
|
12325
|
-
* imports, no logic. Consumers convert (hex → ANSI triplet, hex → CSS)
|
|
12326
|
-
* themselves. Same reason `score-state.ts` is pure: it makes the whole
|
|
12327
|
-
* thing golden-testable and safe to ship inside the npm package, where
|
|
12328
|
-
* it costs a few hundred bytes and replaces values the package already
|
|
12329
|
-
* carried anyway.
|
|
12996
|
+
* The canonical evidence descriptor — `[E2 · deterministic · measured FP
|
|
12997
|
+
* 8% · n=14 · trust L3 · runtime: file executed]`.
|
|
12330
12998
|
*
|
|
12331
|
-
*
|
|
12332
|
-
*
|
|
12333
|
-
*
|
|
12334
|
-
*
|
|
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.
|
|
12335
13004
|
*
|
|
12336
|
-
*
|
|
12337
|
-
*
|
|
12338
|
-
* not a claim, it is `brand-doctor` rule 8, which computes the ratios.
|
|
12339
|
-
* The weakest legal pairing is `steelDim` on `ink800` at 5.00:1.
|
|
13005
|
+
* Every clause is conditional on the finding actually carrying that
|
|
13006
|
+
* evidence. Nothing here is ever defaulted into existence.
|
|
12340
13007
|
*/
|
|
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
|
|
12341
13022
|
/**
|
|
12342
|
-
*
|
|
12343
|
-
*
|
|
12344
|
-
*
|
|
12345
|
-
*
|
|
12346
|
-
*
|
|
12347
|
-
|
|
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.
|
|
12348
13040
|
*
|
|
12349
|
-
*
|
|
12350
|
-
*
|
|
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.
|
|
12351
13045
|
*/
|
|
12352
|
-
|
|
12353
|
-
|
|
12354
|
-
|
|
12355
|
-
|
|
12356
|
-
|
|
12357
|
-
|
|
12358
|
-
|
|
12359
|
-
|
|
12360
|
-
|
|
12361
|
-
|
|
12362
|
-
|
|
12363
|
-
|
|
12364
|
-
|
|
12365
|
-
|
|
12366
|
-
|
|
12367
|
-
|
|
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
|
|
12368
13130
|
/**
|
|
12369
|
-
*
|
|
13131
|
+
* Atomic file writes (audit S9).
|
|
12370
13132
|
*
|
|
12371
|
-
*
|
|
12372
|
-
*
|
|
12373
|
-
* `
|
|
12374
|
-
*
|
|
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).
|
|
12375
13146
|
*/
|
|
12376
|
-
|
|
12377
|
-
|
|
12378
|
-
|
|
12379
|
-
ink850: "#111A29",
|
|
12380
|
-
ink800: "#18243A",
|
|
12381
|
-
/** Raised panel (cards, elevated surfaces). */
|
|
12382
|
-
panel: "#141F33",
|
|
12383
|
-
/** Soft fill (inline code, quiet chips). */
|
|
12384
|
-
soft: "#1A2740",
|
|
12385
|
-
/** Terminal body — the deepest tone, so a terminal reads as recessed. */
|
|
12386
|
-
terminal: "#0A1119",
|
|
12387
|
-
/** Terminal title bar — the same tone; the seam is shadow, not colour. */
|
|
12388
|
-
terminalBar: "#0A1119",
|
|
12389
|
-
/** The three window dots. One neutral, not a traffic light. */
|
|
12390
|
-
chromeDot: "#18243A"
|
|
12391
|
-
};
|
|
12392
|
-
const TEXT = {
|
|
12393
|
-
primary: "#EAEEF5",
|
|
12394
|
-
secondary: "#ABB6C6",
|
|
12395
|
-
muted: "#8B939D",
|
|
12396
|
-
/** Ink for text set ON gold (buttons, the FORGED chip). 7.17:1 on `gold`. */
|
|
12397
|
-
onGold: "#0A1119"
|
|
12398
|
-
};
|
|
13147
|
+
function atomicTempPath(path) {
|
|
13148
|
+
return `${path}.mjolnir-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}.tmp`;
|
|
13149
|
+
}
|
|
12399
13150
|
/**
|
|
12400
|
-
*
|
|
12401
|
-
*
|
|
12402
|
-
*
|
|
12403
|
-
*
|
|
12404
|
-
*
|
|
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.
|
|
12405
13156
|
*/
|
|
12406
|
-
|
|
12407
|
-
|
|
12408
|
-
|
|
12409
|
-
|
|
12410
|
-
|
|
12411
|
-
|
|
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
|
+
}
|
|
12412
13178
|
/**
|
|
12413
|
-
*
|
|
12414
|
-
*
|
|
12415
|
-
*
|
|
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.
|
|
12416
13183
|
*
|
|
12417
|
-
*
|
|
12418
|
-
*
|
|
12419
|
-
*
|
|
12420
|
-
* north-star law exists to prevent.
|
|
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).
|
|
12421
13187
|
*/
|
|
12422
|
-
const
|
|
12423
|
-
|
|
12424
|
-
|
|
12425
|
-
|
|
12426
|
-
|
|
12427
|
-
|
|
12428
|
-
}
|
|
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
|
|
12429
13251
|
/**
|
|
12430
|
-
*
|
|
13252
|
+
* Local incremental scan cache (Beta-to-Stable 1.0 plan, M5.2 / A-2).
|
|
12431
13253
|
*
|
|
12432
|
-
*
|
|
12433
|
-
*
|
|
12434
|
-
*
|
|
12435
|
-
*
|
|
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.
|
|
12436
13265
|
*
|
|
12437
|
-
*
|
|
12438
|
-
*
|
|
12439
|
-
*
|
|
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).
|
|
12440
13269
|
*
|
|
12441
|
-
*
|
|
12442
|
-
*
|
|
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.
|
|
12443
13274
|
*/
|
|
12444
|
-
const
|
|
12445
|
-
|
|
12446
|
-
|
|
12447
|
-
e2: "#EAEEF5"
|
|
12448
|
-
};
|
|
13275
|
+
const CACHE_VERSION = 2;
|
|
13276
|
+
/** Entry cap: a monorepo-scale suite stays far below this; bounded file. */
|
|
13277
|
+
const MAX_ENTRIES$1 = 4096;
|
|
12449
13278
|
/**
|
|
12450
|
-
*
|
|
12451
|
-
*
|
|
12452
|
-
*
|
|
12453
|
-
*
|
|
12454
|
-
|
|
12455
|
-
|
|
12456
|
-
|
|
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;
|
|
13340
|
+
}
|
|
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
|
+
}
|
|
13352
|
+
/**
|
|
13353
|
+
* Content-addressed key for one file's rule-loop verdicts.
|
|
12457
13354
|
*
|
|
12458
|
-
*
|
|
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.
|
|
12459
13364
|
*/
|
|
12460
|
-
|
|
12461
|
-
|
|
12462
|
-
|
|
12463
|
-
|
|
12464
|
-
|
|
12465
|
-
|
|
12466
|
-
|
|
12467
|
-
|
|
12468
|
-
|
|
12469
|
-
|
|
12470
|
-
fill: "#F6EBCC",
|
|
12471
|
-
stroke: "#7A5F16",
|
|
12472
|
-
text: "#4A3A0E"
|
|
12473
|
-
},
|
|
12474
|
-
aurora: {
|
|
12475
|
-
fill: "#D9F0F4",
|
|
12476
|
-
stroke: "#1F6F7C",
|
|
12477
|
-
text: "#10353C"
|
|
12478
|
-
},
|
|
12479
|
-
error: {
|
|
12480
|
-
fill: "#FADEDD",
|
|
12481
|
-
stroke: "#A83A35",
|
|
12482
|
-
text: "#4E1B19"
|
|
12483
|
-
},
|
|
12484
|
-
/** The unmeasured / unknown state. Neutral, never the error tint. */
|
|
12485
|
-
neutral: {
|
|
12486
|
-
fill: "#E4E7EB",
|
|
12487
|
-
stroke: "#5C646E",
|
|
12488
|
-
text: "#262B31"
|
|
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: ""
|
|
12489
13375
|
},
|
|
12490
|
-
|
|
12491
|
-
|
|
12492
|
-
|
|
12493
|
-
text: "#163A26"
|
|
12494
|
-
}
|
|
13376
|
+
lookup: () => void 0,
|
|
13377
|
+
store: () => {},
|
|
13378
|
+
persist: () => {}
|
|
12495
13379
|
};
|
|
12496
13380
|
/**
|
|
12497
|
-
*
|
|
12498
|
-
*
|
|
12499
|
-
*
|
|
12500
|
-
* style preference: shields.io sets the message text in white and gives
|
|
12501
|
-
* you no say in it. `score.forged` (#F4DC9C) under white text measures
|
|
12502
|
-
* 1.35:1 — an unreadable badge, shipped to look on-brand. The brand's
|
|
12503
|
-
* own deep steps put every band between 4.9 and 6.3:1.
|
|
12504
|
-
*
|
|
12505
|
-
* What they replace was worse than off-brand, it was wrong:
|
|
12506
|
-
*
|
|
12507
|
-
* band was rendered as white-on now white-on
|
|
12508
|
-
* ────────────────────────────────────────────────────────────────────────────
|
|
12509
|
-
* 0-49 `red` #dd4343 4.24 #A83A35 6.32
|
|
12510
|
-
* 50-79 `yellow` #d8b800 1.95 #7A5F16 6.04
|
|
12511
|
-
* 80-99 `important` #ea7233 ORANGE 3.02 #1F6F7C 5.80
|
|
12512
|
-
* 100 `success` #44bb00 GREEN 2.51 #8A6D1E 4.90
|
|
12513
|
-
* unmeasured `lightgrey` #939393 3.07 #5C646E 5.99
|
|
12514
|
-
*
|
|
12515
|
-
* Two of those were defects, not preferences. `success` is green, and
|
|
12516
|
-
* green is not a score colour here — a 100 badge said "your software is
|
|
12517
|
-
* fine", which is the one claim this product refuses to make. And
|
|
12518
|
-
* `important` is ORANGE, not the blue-family colour the code's own
|
|
12519
|
-
* comment claimed for eight releases: every WORTHY badge ever rendered
|
|
12520
|
-
* showed the trusted band in a warning colour. Nobody had resolved a
|
|
12521
|
-
* shields name to a value and looked.
|
|
12522
|
-
*
|
|
12523
|
-
* Values are hex without `#`, the form shields.io's endpoint takes.
|
|
12524
|
-
* `A83A35`, `7A5F16`, `1F6F7C` and `5C646E` are the `TINT` strokes —
|
|
12525
|
-
* the same deep steps the mermaid diagrams use, for the same reason.
|
|
12526
|
-
* `8A6D1E` is the gold the brand document already named as the light
|
|
12527
|
-
* FORGED gradient's start.
|
|
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.
|
|
12528
13384
|
*/
|
|
12529
|
-
|
|
12530
|
-
|
|
12531
|
-
|
|
12532
|
-
|
|
12533
|
-
|
|
12534
|
-
|
|
12535
|
-
|
|
12536
|
-
|
|
12537
|
-
|
|
12538
|
-
|
|
12539
|
-
|
|
12540
|
-
|
|
12541
|
-
|
|
12542
|
-
|
|
12543
|
-
|
|
12544
|
-
|
|
12545
|
-
|
|
12546
|
-
|
|
12547
|
-
|
|
12548
|
-
|
|
12549
|
-
|
|
12550
|
-
|
|
12551
|
-
|
|
12552
|
-
|
|
12553
|
-
};
|
|
12554
|
-
/** Band mapping (the one mapping, three consumers): <50 critical, 50–79
|
|
12555
|
-
* warning, 80–99 trusted, 100 forged, null unmeasured. */
|
|
12556
|
-
function deriveScoreState(score) {
|
|
12557
|
-
if (score === null) return {
|
|
12558
|
-
score: null,
|
|
12559
|
-
band: "unmeasured",
|
|
12560
|
-
verdict: "UNWORTHY",
|
|
12561
|
-
color: "dim",
|
|
12562
|
-
powerLevel: 0,
|
|
12563
|
-
headline: HEADLINES.unmeasured,
|
|
12564
|
-
rune: RUNES.unmeasured
|
|
12565
|
-
};
|
|
12566
|
-
if (score >= 100) return {
|
|
12567
|
-
score,
|
|
12568
|
-
band: "forged",
|
|
12569
|
-
verdict: "FORGED",
|
|
12570
|
-
color: "forged",
|
|
12571
|
-
powerLevel: score,
|
|
12572
|
-
headline: HEADLINES.forged,
|
|
12573
|
-
rune: RUNES.forged
|
|
12574
|
-
};
|
|
12575
|
-
if (score >= 80) return {
|
|
12576
|
-
score,
|
|
12577
|
-
band: "trusted",
|
|
12578
|
-
verdict: "WORTHY",
|
|
12579
|
-
color: "trusted",
|
|
12580
|
-
powerLevel: score,
|
|
12581
|
-
headline: HEADLINES.trusted,
|
|
12582
|
-
rune: RUNES.trusted
|
|
12583
|
-
};
|
|
12584
|
-
if (score >= 50) return {
|
|
12585
|
-
score,
|
|
12586
|
-
band: "warning",
|
|
12587
|
-
verdict: "NEEDS WORK",
|
|
12588
|
-
color: "warning",
|
|
12589
|
-
powerLevel: score,
|
|
12590
|
-
headline: HEADLINES.warning,
|
|
12591
|
-
rune: RUNES.warning
|
|
12592
|
-
};
|
|
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
|
+
}
|
|
12593
13409
|
return {
|
|
12594
|
-
|
|
12595
|
-
|
|
12596
|
-
|
|
12597
|
-
|
|
12598
|
-
|
|
12599
|
-
|
|
12600
|
-
|
|
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
|
+
}
|
|
12601
13457
|
};
|
|
12602
13458
|
}
|
|
12603
|
-
/** Substitute a findings count into a headline template. Pure text
|
|
12604
|
-
* helper so every surface formats identically. */
|
|
12605
|
-
function headlineFor(state, findings) {
|
|
12606
|
-
return state.headline.replace("{n}", String(findings));
|
|
12607
|
-
}
|
|
12608
13459
|
//#endregion
|
|
12609
13460
|
//#region src/reporter/theme.ts
|
|
12610
13461
|
/**
|
|
@@ -12620,8 +13471,8 @@ function headlineFor(state, findings) {
|
|
|
12620
13471
|
*
|
|
12621
13472
|
* Symbols always accompany color (color-blind safe, R11).
|
|
12622
13473
|
*
|
|
12623
|
-
* Score-state colors come from ScoreState (
|
|
12624
|
-
* 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.
|
|
12625
13476
|
*
|
|
12626
13477
|
* Terminal robustness (Master-Stabilization-Plan Sprint 5 Task 22):
|
|
12627
13478
|
* box-drawing/gauge helpers accept an explicit width so callers can
|
|
@@ -12798,14 +13649,11 @@ function padTo(s, width) {
|
|
|
12798
13649
|
*/
|
|
12799
13650
|
function scoreGauge(score, p, width = 30, ascii = false) {
|
|
12800
13651
|
const filled = Math.round(score / 100 * width);
|
|
12801
|
-
const color =
|
|
13652
|
+
const color = gaugeColorForBand(deriveScoreState(score).band, p);
|
|
12802
13653
|
if (ascii) return color("#".repeat(filled)) + ".".repeat(Math.max(0, width - filled));
|
|
12803
13654
|
const head = filled > 0 && filled < width ? "▓" : "";
|
|
12804
13655
|
return color("█".repeat(Math.max(0, filled - (head ? 1 : 0)))) + color(head) + "░".repeat(width - filled);
|
|
12805
13656
|
}
|
|
12806
|
-
function gaugeColor(score, p) {
|
|
12807
|
-
return gaugeColorForBand(deriveScoreState(score).band, p);
|
|
12808
|
-
}
|
|
12809
13657
|
/**
|
|
12810
13658
|
* The single band → palette-key mapping, delegated from ScoreState so
|
|
12811
13659
|
* gauge, verdict and badge colors can never disagree again.
|
|
@@ -13960,7 +14808,7 @@ function runForensics(target, options = {}) {
|
|
|
13960
14808
|
const base = stat.isFile() ? dirname(target) : target;
|
|
13961
14809
|
flakyMdPath = join(base, "FLAKY.md");
|
|
13962
14810
|
try {
|
|
13963
|
-
|
|
14811
|
+
writeFileAtomic(flakyMdPath, renderFlakyMd(report));
|
|
13964
14812
|
} catch {
|
|
13965
14813
|
flakyMdPath = void 0;
|
|
13966
14814
|
}
|
|
@@ -14276,6 +15124,31 @@ function loadPlugins(root, gateOpen = false) {
|
|
|
14276
15124
|
* next catalog render (locked by tests/local-rules.spec.ts).
|
|
14277
15125
|
*/
|
|
14278
15126
|
const LOCAL_RULES_DIR = "mjolnir-rules";
|
|
15127
|
+
const MAX_EXTERNAL_RULES = 1e3;
|
|
15128
|
+
const MAX_RULE_FILE_BYTES = 1048576;
|
|
15129
|
+
const MAX_PATTERNS_PER_RULE = 100;
|
|
15130
|
+
function hasNestedQuantifier(pattern) {
|
|
15131
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
15132
|
+
if (pattern.charAt(i) !== "(") continue;
|
|
15133
|
+
let bodyHasQuantifier = false;
|
|
15134
|
+
let j = i + 1;
|
|
15135
|
+
for (; j < pattern.length && pattern.charAt(j) !== ")"; j++) {
|
|
15136
|
+
const bodyChar = pattern.charAt(j);
|
|
15137
|
+
if (bodyChar === "+" || bodyChar === "*" || bodyChar === "?") {
|
|
15138
|
+
bodyHasQuantifier = true;
|
|
15139
|
+
break;
|
|
15140
|
+
}
|
|
15141
|
+
}
|
|
15142
|
+
if (bodyHasQuantifier) {
|
|
15143
|
+
while (j < pattern.length && pattern.charAt(j) !== ")") j++;
|
|
15144
|
+
if (j + 1 < pattern.length) {
|
|
15145
|
+
const next = pattern.charAt(j + 1);
|
|
15146
|
+
if (next === "+" || next === "*" || next === "{") return true;
|
|
15147
|
+
}
|
|
15148
|
+
}
|
|
15149
|
+
}
|
|
15150
|
+
return false;
|
|
15151
|
+
}
|
|
14279
15152
|
const ALLOWED_CATEGORIES = /* @__PURE__ */ new Set([
|
|
14280
15153
|
"QA-TEST",
|
|
14281
15154
|
"QA-TQUAL",
|
|
@@ -14305,7 +15178,7 @@ const ALLOWED_QA_IMPACTS = /* @__PURE__ */ new Set([
|
|
|
14305
15178
|
* Missing directory → empty result (not an error — most workspaces
|
|
14306
15179
|
* carry none).
|
|
14307
15180
|
*/
|
|
14308
|
-
async function loadLocalRules(root, gateOpen =
|
|
15181
|
+
async function loadLocalRules(root, gateOpen = false) {
|
|
14309
15182
|
const result = {
|
|
14310
15183
|
rules: [],
|
|
14311
15184
|
errors: [],
|
|
@@ -14313,6 +15186,15 @@ async function loadLocalRules(root, gateOpen = true) {
|
|
|
14313
15186
|
};
|
|
14314
15187
|
const dir = join(root, LOCAL_RULES_DIR);
|
|
14315
15188
|
if (!existsSync(dir)) return result;
|
|
15189
|
+
try {
|
|
15190
|
+
if (lstatSync(dir).isSymbolicLink()) {
|
|
15191
|
+
result.errors.push(`external rules directory "${LOCAL_RULES_DIR}/" is a symlink — skipped`);
|
|
15192
|
+
return result;
|
|
15193
|
+
}
|
|
15194
|
+
} catch (err) {
|
|
15195
|
+
result.errors.push(`external rules directory "${LOCAL_RULES_DIR}/" could not be inspected: ${err instanceof Error ? err.message : String(err)}`);
|
|
15196
|
+
return result;
|
|
15197
|
+
}
|
|
14316
15198
|
let entries;
|
|
14317
15199
|
try {
|
|
14318
15200
|
entries = readdirSync(dir);
|
|
@@ -14321,7 +15203,20 @@ async function loadLocalRules(root, gateOpen = true) {
|
|
|
14321
15203
|
return result;
|
|
14322
15204
|
}
|
|
14323
15205
|
for (const entry of entries.sort()) {
|
|
15206
|
+
if (result.rules.length >= MAX_EXTERNAL_RULES) {
|
|
15207
|
+
result.errors.push(`external rule budget exceeded (${MAX_EXTERNAL_RULES})`);
|
|
15208
|
+
break;
|
|
15209
|
+
}
|
|
14324
15210
|
const path = join(dir, entry);
|
|
15211
|
+
try {
|
|
15212
|
+
if (lstatSync(path).isSymbolicLink()) {
|
|
15213
|
+
result.errors.push(`external rule "${LOCAL_RULES_DIR}/${entry}" is a symlink — skipped`);
|
|
15214
|
+
continue;
|
|
15215
|
+
}
|
|
15216
|
+
} catch (err) {
|
|
15217
|
+
result.errors.push(`external rule "${LOCAL_RULES_DIR}/${entry}" could not be inspected: ${err instanceof Error ? err.message : String(err)}`);
|
|
15218
|
+
continue;
|
|
15219
|
+
}
|
|
14325
15220
|
if (entry.endsWith(".json")) loadJsonRule(path, result);
|
|
14326
15221
|
else if (entry.endsWith(".mjs") || entry.endsWith(".js")) {
|
|
14327
15222
|
if (!gateOpen) result.skipped.push(`${LOCAL_RULES_DIR}/${entry}`);
|
|
@@ -14334,6 +15229,10 @@ function loadJsonRule(path, result) {
|
|
|
14334
15229
|
const name = `${LOCAL_RULES_DIR}/${path.split(/[\\/]/).pop()}`;
|
|
14335
15230
|
let raw;
|
|
14336
15231
|
try {
|
|
15232
|
+
if (lstatSync(path).size > MAX_RULE_FILE_BYTES) {
|
|
15233
|
+
result.errors.push(`external rule "${name}" exceeds the file size budget`);
|
|
15234
|
+
return;
|
|
15235
|
+
}
|
|
14337
15236
|
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
14338
15237
|
} catch (err) {
|
|
14339
15238
|
result.errors.push(`external rule "${name}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -14354,7 +15253,7 @@ function loadJsonRule(path, result) {
|
|
|
14354
15253
|
return;
|
|
14355
15254
|
}
|
|
14356
15255
|
const patterns = decl["patterns"];
|
|
14357
|
-
if (!Array.isArray(patterns) || patterns.length === 0 || !patterns.every((p) => typeof p === "string" && p.length > 0)) {
|
|
15256
|
+
if (!Array.isArray(patterns) || patterns.length === 0 || patterns.length > MAX_PATTERNS_PER_RULE || !patterns.every((p) => typeof p === "string" && p.length > 0)) {
|
|
14358
15257
|
result.errors.push(`external rule ${id} must declare a non-empty "patterns" array of regex source strings.`);
|
|
14359
15258
|
return;
|
|
14360
15259
|
}
|
|
@@ -14370,6 +15269,10 @@ function loadJsonRule(path, result) {
|
|
|
14370
15269
|
result.errors.push(`external rule ${id} has a pattern longer than 512 chars — likely a copy-paste error.`);
|
|
14371
15270
|
return;
|
|
14372
15271
|
}
|
|
15272
|
+
if (hasNestedQuantifier(p)) {
|
|
15273
|
+
result.errors.push(`external rule ${id} has a nested quantifier that may cause catastrophic backtracking`);
|
|
15274
|
+
return;
|
|
15275
|
+
}
|
|
14373
15276
|
const quantifiers = p.match(/[+*?]|\{\d/g);
|
|
14374
15277
|
if (quantifiers && quantifiers.length > 64) {
|
|
14375
15278
|
result.errors.push(`external rule ${id} has a pattern with more than 64 quantifier/wildcard tokens — likely catastrophic backtracking.`);
|
|
@@ -14567,6 +15470,8 @@ var scan_pipeline_exports = /* @__PURE__ */ __exportAll({
|
|
|
14567
15470
|
summarizeForensicVerdicts: () => summarizeForensicVerdicts
|
|
14568
15471
|
});
|
|
14569
15472
|
const UNIVERSAL_RULES = RULES.map(asUniversal);
|
|
15473
|
+
const DEFAULT_MAX_DURATION_MS = 6e5;
|
|
15474
|
+
const MAX_DURATION_MS = 36e5;
|
|
14570
15475
|
/** Registered rule IDs — used to warn on unknown severityOverrides keys (M4). */
|
|
14571
15476
|
const KNOWN_RULE_IDS = new Set(RULES.map((r) => r.id));
|
|
14572
15477
|
/**
|
|
@@ -14783,7 +15688,9 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14783
15688
|
let testDeclarationCount = 0;
|
|
14784
15689
|
let rulesPartial = false;
|
|
14785
15690
|
let parseFailed = 0;
|
|
15691
|
+
let parseFallbacks = 0;
|
|
14786
15692
|
let scanned = 0;
|
|
15693
|
+
let analyzed = 0;
|
|
14787
15694
|
for (const path of testFiles) {
|
|
14788
15695
|
if (Date.now() > deadline) {
|
|
14789
15696
|
rulesPartial = true;
|
|
@@ -14797,13 +15704,16 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14797
15704
|
if (!isCiAdapter) testFileCount++;
|
|
14798
15705
|
let text;
|
|
14799
15706
|
try {
|
|
14800
|
-
|
|
15707
|
+
const readResult = readFileBounded(path, LIMITS$1.maxFileBytes);
|
|
15708
|
+
if (!readResult.ok) {
|
|
15709
|
+
skippedFiles++;
|
|
15710
|
+
continue;
|
|
15711
|
+
}
|
|
15712
|
+
text = readResult.data.toString("utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
|
|
14801
15713
|
} catch {
|
|
14802
15714
|
skippedFiles++;
|
|
14803
15715
|
continue;
|
|
14804
15716
|
}
|
|
14805
|
-
let fileBudgetExceeded = false;
|
|
14806
|
-
let fileRuleFailed = false;
|
|
14807
15717
|
const relPath = relative(workspace.root, path).replaceAll("\\", "/");
|
|
14808
15718
|
if (!isCiAdapter) {
|
|
14809
15719
|
const lang = {
|
|
@@ -14829,74 +15739,106 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14829
15739
|
});
|
|
14830
15740
|
let cacheKey = fileCacheKey(rulesDigest, text, identity(wantsAst ? "ast" : "regex"));
|
|
14831
15741
|
const cachedFindings = cache.lookup(cacheKey);
|
|
14832
|
-
|
|
14833
|
-
|
|
14834
|
-
|
|
14835
|
-
|
|
14836
|
-
|
|
14837
|
-
|
|
14838
|
-
|
|
14839
|
-
|
|
14840
|
-
|
|
14841
|
-
|
|
14842
|
-
|
|
14843
|
-
|
|
14844
|
-
|
|
14845
|
-
|
|
14846
|
-
|
|
14847
|
-
|
|
14848
|
-
|
|
14849
|
-
|
|
14850
|
-
|
|
14851
|
-
|
|
14852
|
-
|
|
14853
|
-
|
|
14854
|
-
|
|
14855
|
-
});
|
|
14856
|
-
parsed = await adapter.parseAst(parsedFile);
|
|
14857
|
-
}
|
|
14858
|
-
const actualMode = parsed ? "ast" : "regex";
|
|
14859
|
-
if (wantsAst && actualMode === "regex") {
|
|
14860
|
-
cacheKey = fileCacheKey(rulesDigest, text, identity(actualMode));
|
|
14861
|
-
const fallbackFindings = cache.lookup(cacheKey);
|
|
14862
|
-
if (fallbackFindings) {
|
|
14863
|
-
for (const f of fallbackFindings) findings.push(f);
|
|
14864
|
-
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
|
+
});
|
|
14865
15765
|
}
|
|
14866
|
-
|
|
14867
|
-
|
|
14868
|
-
|
|
14869
|
-
|
|
14870
|
-
|
|
14871
|
-
|
|
14872
|
-
|
|
14873
|
-
|
|
14874
|
-
|
|
14875
|
-
|
|
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
|
+
};
|
|
14876
15778
|
}
|
|
14877
|
-
|
|
14878
|
-
|
|
14879
|
-
|
|
14880
|
-
|
|
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
|
+
}
|
|
14881
15810
|
});
|
|
14882
|
-
|
|
14883
|
-
fileRuleFailed
|
|
14884
|
-
|
|
14885
|
-
|
|
14886
|
-
|
|
14887
|
-
|
|
14888
|
-
|
|
14889
|
-
|
|
14890
|
-
|
|
14891
|
-
|
|
14892
|
-
|
|
14893
|
-
|
|
14894
|
-
|
|
14895
|
-
|
|
14896
|
-
|
|
14897
|
-
|
|
14898
|
-
|
|
14899
|
-
|
|
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++;
|
|
14900
15842
|
}
|
|
14901
15843
|
}
|
|
14902
15844
|
return {
|
|
@@ -14905,7 +15847,9 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14905
15847
|
testDeclarationCount,
|
|
14906
15848
|
rulesPartial,
|
|
14907
15849
|
parseFailed,
|
|
14908
|
-
|
|
15850
|
+
parseFallbacks,
|
|
15851
|
+
scanned,
|
|
15852
|
+
analyzed
|
|
14909
15853
|
};
|
|
14910
15854
|
}
|
|
14911
15855
|
/**
|
|
@@ -14983,18 +15927,24 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
|
|
|
14983
15927
|
for (const f of findings) f.fixGroupId = f.ruleId;
|
|
14984
15928
|
const discoveredReport = discoverAndParseRuntimeReport(scanRoot.root);
|
|
14985
15929
|
const runtimeReportPath = discoveredReport?.path;
|
|
15930
|
+
const runtimeIncomplete = discoveredReport !== void 0 && discoveredReport.report.analysisComplete !== true;
|
|
14986
15931
|
let forensicVerdicts;
|
|
15932
|
+
let evidenceRecords = [];
|
|
14987
15933
|
if (discoveredReport && discoveredReport.report.analysisComplete === true) try {
|
|
14988
|
-
buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
|
|
15934
|
+
evidenceRecords = buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
|
|
14989
15935
|
stampRuntimeCorroboration(findings, discoveredReport.report, workspace.root);
|
|
14990
15936
|
forensicVerdicts = summarizeForensicVerdicts(discoveredReport.report);
|
|
14991
|
-
} catch {
|
|
15937
|
+
} catch {
|
|
15938
|
+
evidenceRecords = [];
|
|
15939
|
+
}
|
|
14992
15940
|
return {
|
|
15941
|
+
evidenceRecords,
|
|
14993
15942
|
testDeclarationCount,
|
|
14994
15943
|
scopeInfo,
|
|
14995
15944
|
suppressionCount,
|
|
14996
15945
|
frameworks,
|
|
14997
15946
|
runtimeReportPath,
|
|
15947
|
+
runtimeIncomplete,
|
|
14998
15948
|
forensicVerdicts,
|
|
14999
15949
|
config
|
|
15000
15950
|
};
|
|
@@ -15063,10 +16013,13 @@ function assembleScanResult(o) {
|
|
|
15063
16013
|
if (o.scopeIgnored > 0) scopeReasons.push(`ignored:${o.scopeIgnored}`);
|
|
15064
16014
|
if (o.scopeUnrecognized > 0) scopeReasons.push(`unrecognized:${o.scopeUnrecognized}`);
|
|
15065
16015
|
if (o.parseFailed > 0) scopeReasons.push(`parseFailed:${o.parseFailed}`);
|
|
16016
|
+
if (o.skippedFiles > 0) scopeReasons.push(`skipped:${o.skippedFiles}`);
|
|
16017
|
+
if (o.scopeInfo.degraded) scopeReasons.push(`degraded:${o.scopeInfo.degraded}`);
|
|
16018
|
+
if (o.runtimeIncomplete) scopeReasons.push("runtime-incomplete");
|
|
15066
16019
|
for (const reason of o.truncationReasons) scopeReasons.push(`truncated:${reason}`);
|
|
15067
16020
|
const scopeIntegrity = {
|
|
15068
16021
|
discovered: o.testFiles.length,
|
|
15069
|
-
analyzed: Math.max(0, o.scanned),
|
|
16022
|
+
analyzed: Math.max(0, o.analyzed ?? o.scanned),
|
|
15070
16023
|
ignored: o.scopeIgnored,
|
|
15071
16024
|
unrecognized: o.scopeUnrecognized,
|
|
15072
16025
|
parseFailed: o.parseFailed,
|
|
@@ -15075,28 +16028,61 @@ function assembleScanResult(o) {
|
|
|
15075
16028
|
...scopeReasons.length > 0 ? { reasons: scopeReasons } : {}
|
|
15076
16029
|
};
|
|
15077
16030
|
const scopeAdjustedTotal = scopeReasons.length > 0 && total >= 100 ? 99 : total;
|
|
16031
|
+
let identityIncomplete = false;
|
|
15078
16032
|
const inputSnapshot = o.testFiles.map((p) => {
|
|
15079
16033
|
const relPath = relative(o.workspace.root, p).replaceAll("\\", "/");
|
|
15080
16034
|
try {
|
|
15081
|
-
const
|
|
15082
|
-
|
|
16035
|
+
const read = readFileBounded(resolve(o.workspace.root, p), LIMITS$1.maxFileBytes);
|
|
16036
|
+
if (!read.ok) {
|
|
16037
|
+
if (existsSync(o.workspace.root)) identityIncomplete = true;
|
|
16038
|
+
return {
|
|
16039
|
+
path: relPath,
|
|
16040
|
+
size: 0,
|
|
16041
|
+
hash: "UNAVAILABLE"
|
|
16042
|
+
};
|
|
16043
|
+
}
|
|
16044
|
+
const hash = createHash("sha256").update(read.data).digest("hex");
|
|
15083
16045
|
return {
|
|
15084
16046
|
path: relPath,
|
|
15085
|
-
size:
|
|
16047
|
+
size: read.data.length,
|
|
15086
16048
|
hash
|
|
15087
16049
|
};
|
|
15088
16050
|
} catch {
|
|
16051
|
+
if (existsSync(o.workspace.root)) identityIncomplete = true;
|
|
15089
16052
|
return {
|
|
15090
16053
|
path: relPath,
|
|
15091
|
-
size: 0
|
|
16054
|
+
size: 0,
|
|
16055
|
+
hash: "UNAVAILABLE"
|
|
15092
16056
|
};
|
|
15093
16057
|
}
|
|
15094
16058
|
});
|
|
15095
16059
|
let reportDigest;
|
|
15096
16060
|
if (o.runtimeReportPath) try {
|
|
15097
|
-
const
|
|
15098
|
-
|
|
15099
|
-
|
|
16061
|
+
const reportPath = resolve(o.workspace.root, o.runtimeReportPath);
|
|
16062
|
+
if (lstatSync(reportPath).isFile()) {
|
|
16063
|
+
const read = readFileBounded(reportPath, LIMITS$1.maxFileBytes);
|
|
16064
|
+
if (!read.ok) identityIncomplete = true;
|
|
16065
|
+
else reportDigest = createHash("sha256").update(read.data).digest("hex");
|
|
16066
|
+
}
|
|
16067
|
+
} catch {
|
|
16068
|
+
identityIncomplete = true;
|
|
16069
|
+
}
|
|
16070
|
+
const completion = deriveCompletion({
|
|
16071
|
+
discoveryTruncated: o.discoveryTruncated,
|
|
16072
|
+
rulesPartial: o.rulesPartial,
|
|
16073
|
+
skippedFiles: o.skippedFiles,
|
|
16074
|
+
rulesCrashed: o.rulesCrashed,
|
|
16075
|
+
truncationReasons: o.truncationReasons,
|
|
16076
|
+
scopeIgnored: o.scopeIgnored,
|
|
16077
|
+
scopeUnrecognized: o.scopeUnrecognized,
|
|
16078
|
+
parseFailed: o.parseFailed,
|
|
16079
|
+
parseFallbacks: o.parseFallbacks ?? 0,
|
|
16080
|
+
...o.scopeInfo.degraded !== void 0 ? { scopeDegraded: o.scopeInfo.degraded } : {},
|
|
16081
|
+
...o.runtimeIncomplete !== void 0 ? { runtimeIncomplete: o.runtimeIncomplete } : {},
|
|
16082
|
+
identityIncomplete
|
|
16083
|
+
});
|
|
16084
|
+
const repository = bindRepository(o.scanRoot.root);
|
|
16085
|
+
const candidate = readCandidateBinding(o.scanRoot.root);
|
|
15100
16086
|
const runIdentity = buildRunIdentity({
|
|
15101
16087
|
files: inputSnapshot,
|
|
15102
16088
|
rules: [...o.REVISION_BY_RULE_ID.entries()].map(([id, detectorRevision]) => ({
|
|
@@ -15109,18 +16095,32 @@ function assembleScanResult(o) {
|
|
|
15109
16095
|
trustModelVersion: TRUST_MODEL_VERSION,
|
|
15110
16096
|
scoringModelVersion: SCORING_MODEL_VERSION,
|
|
15111
16097
|
frameworkSupportMatrixVersion: FRAMEWORK_SUPPORT_MATRIX_VERSION,
|
|
15112
|
-
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
|
+
} } : {}
|
|
15113
16108
|
});
|
|
15114
|
-
const evidenceGraph = buildEvidenceGraph({ runId: runIdentity });
|
|
15115
16109
|
const hasTests = o.testFileCount > 0 && o.testDeclarationCount > 0;
|
|
15116
16110
|
const suiteInvalidatedBy = [...new Set(o.findings.filter((f) => SUITE_INVALIDATING_RULE_IDS.has(f.ruleId)).map((f) => f.ruleId))].sort();
|
|
16111
|
+
const finalScore = hasTests ? completion.partial && scopeAdjustedTotal >= 100 ? 99 : scopeAdjustedTotal : null;
|
|
15117
16112
|
const result = {
|
|
15118
16113
|
schemaVersion: 1,
|
|
15119
|
-
partial:
|
|
16114
|
+
partial: completion.partial,
|
|
15120
16115
|
scopeIntegrity,
|
|
15121
16116
|
runIdentity,
|
|
15122
16117
|
evidenceGraph,
|
|
15123
|
-
|
|
16118
|
+
evidence: {
|
|
16119
|
+
records: o.evidenceRecords ?? [],
|
|
16120
|
+
counts: countEvidence(o.evidenceRecords ?? []),
|
|
16121
|
+
artifact: o.runtimeReportPath ?? null
|
|
16122
|
+
},
|
|
16123
|
+
score: finalScore,
|
|
15124
16124
|
...hasTests ? {} : { reason: "no-tests-found" },
|
|
15125
16125
|
frameworks: o.frameworks.frameworks,
|
|
15126
16126
|
frameworkDetectionUnknown: o.frameworks.unknown,
|
|
@@ -15146,12 +16146,8 @@ function assembleScanResult(o) {
|
|
|
15146
16146
|
file: o.cache.stats.file
|
|
15147
16147
|
} } : {},
|
|
15148
16148
|
analysisStatus: {
|
|
15149
|
-
|
|
15150
|
-
|
|
15151
|
-
skippedFiles: o.skippedFiles,
|
|
15152
|
-
durationMs: elapsed,
|
|
15153
|
-
rulesCrashed: o.rulesCrashed,
|
|
15154
|
-
...o.truncationReasons.size > 0 ? { truncationReasons: [...o.truncationReasons].sort() } : {}
|
|
16149
|
+
...completion.analysisStatus,
|
|
16150
|
+
durationMs: elapsed
|
|
15155
16151
|
},
|
|
15156
16152
|
scoringModelVersion: SCORING_MODEL_VERSION
|
|
15157
16153
|
};
|
|
@@ -15199,7 +16195,8 @@ function assembleScanResult(o) {
|
|
|
15199
16195
|
*/
|
|
15200
16196
|
async function runScan(args, hooks = {}) {
|
|
15201
16197
|
const started = Date.now();
|
|
15202
|
-
const
|
|
16198
|
+
const requestedDuration = Number.isFinite(args.maxDurationMs) ? args.maxDurationMs : DEFAULT_MAX_DURATION_MS;
|
|
16199
|
+
const deadline = started + Math.min(Math.max(1, requestedDuration), MAX_DURATION_MS);
|
|
15203
16200
|
const discovered = discoverWorkspace(args.target);
|
|
15204
16201
|
const targetAbs = resolve(args.target);
|
|
15205
16202
|
const scanRoot = discovered && discovered.root !== targetAbs && targetAbs.startsWith(discovered.root + sep) ? {
|
|
@@ -15288,7 +16285,9 @@ async function runScan(args, hooks = {}) {
|
|
|
15288
16285
|
testDeclarationCount = analysis.testDeclarationCount;
|
|
15289
16286
|
rulesPartial = analysis.rulesPartial;
|
|
15290
16287
|
parseFailed = analysis.parseFailed;
|
|
16288
|
+
const parseFallbacks = analysis.parseFallbacks;
|
|
15291
16289
|
const scanned = analysis.scanned;
|
|
16290
|
+
const analyzed = analysis.analyzed;
|
|
15292
16291
|
const postScan = applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, declarationsByFile, testDeclarationCount, tierByRuleId, REVISION_BY_RULE_ID);
|
|
15293
16292
|
testDeclarationCount = postScan.testDeclarationCount;
|
|
15294
16293
|
const result = assembleScanResult({
|
|
@@ -15304,7 +16303,9 @@ async function runScan(args, hooks = {}) {
|
|
|
15304
16303
|
scopeIgnored,
|
|
15305
16304
|
scopeUnrecognized,
|
|
15306
16305
|
parseFailed,
|
|
16306
|
+
parseFallbacks,
|
|
15307
16307
|
scanned,
|
|
16308
|
+
analyzed,
|
|
15308
16309
|
testFiles,
|
|
15309
16310
|
workspace,
|
|
15310
16311
|
scanRoot,
|
|
@@ -15317,6 +16318,7 @@ async function runScan(args, hooks = {}) {
|
|
|
15317
16318
|
suppressionCount: postScan.suppressionCount,
|
|
15318
16319
|
frameworks: postScan.frameworks,
|
|
15319
16320
|
runtimeReportPath: postScan.runtimeReportPath,
|
|
16321
|
+
runtimeIncomplete: postScan.runtimeIncomplete,
|
|
15320
16322
|
forensicVerdicts: postScan.forensicVerdicts,
|
|
15321
16323
|
config: postScan.config,
|
|
15322
16324
|
fileProvenance,
|
|
@@ -15329,4 +16331,6 @@ async function runScan(args, hooks = {}) {
|
|
|
15329
16331
|
return result;
|
|
15330
16332
|
}
|
|
15331
16333
|
//#endregion
|
|
15332
|
-
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
|