mjolnir-qa 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +360 -6
- package/README.ar.md +58 -29
- package/README.bn.md +58 -29
- package/README.br.md +58 -29
- package/README.bs.md +58 -29
- package/README.da.md +59 -30
- package/README.de.md +59 -30
- package/README.es.md +58 -29
- package/README.fr.md +59 -30
- package/README.gr.md +58 -29
- package/README.he.md +58 -29
- package/README.it.md +58 -29
- package/README.ja.md +58 -29
- package/README.ko.md +58 -29
- package/README.md +95 -56
- package/README.no.md +58 -29
- package/README.pl.md +58 -29
- package/README.ru.md +58 -29
- package/README.th.md +58 -29
- package/README.tr.md +58 -29
- package/README.uk.md +58 -29
- package/README.vi.md +58 -29
- package/README.zh.md +58 -29
- package/README.zht.md +58 -29
- package/dist/cli.d.mts +218 -14
- package/dist/cli.mjs +1410 -883
- package/dist/mcp/stdio.mjs +1072 -408
- package/dist/scan-pipeline-Bg-46cLS.mjs +2 -0
- package/dist/{scan-pipeline-CAH9_Qgh.mjs → scan-pipeline-CCJdEKNa.mjs} +1443 -809
- package/package.json +52 -8
- package/dist/scan-pipeline-CRe7-MQn.mjs +0 -2
package/dist/mcp/stdio.mjs
CHANGED
|
@@ -2,14 +2,14 @@ import { createRequire } from "node:module";
|
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
3
|
import { Buffer as Buffer$1 } from "node:buffer";
|
|
4
4
|
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
-
import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, unlinkSync,
|
|
5
|
+
import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
6
6
|
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
|
+
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 { fileURLToPath, pathToFileURL } from "node:url";
|
|
10
11
|
import { Language, Parser } from "web-tree-sitter";
|
|
11
12
|
import { parse } from "yaml";
|
|
12
|
-
import { execFileSync } from "node:child_process";
|
|
13
13
|
import { inflateRawSync } from "node:zlib";
|
|
14
14
|
//#region src/types.ts
|
|
15
15
|
/** Severity ladder. Order matters for sorting and gating. */
|
|
@@ -19,6 +19,32 @@ const SEVERITY_ORDER = [
|
|
|
19
19
|
"info"
|
|
20
20
|
];
|
|
21
21
|
/**
|
|
22
|
+
* Trust levels (Verification Trust Evolution Plan §16): the OVERALL
|
|
23
|
+
* trust a consumer can place in one finding, combining the static
|
|
24
|
+
* evidence ladder (E0–E2) with RUNTIME corroboration from a real run
|
|
25
|
+
* report. Exposed honestly, never overclaimed:
|
|
26
|
+
* L0 — observation only (E0, no runtime evidence).
|
|
27
|
+
* L1 — heuristic static evidence (E1), no runtime evidence.
|
|
28
|
+
* L2 — deterministic static evidence (E2), no runtime evidence.
|
|
29
|
+
* L3 — RUNTIME: the file containing this finding appeared in a real
|
|
30
|
+
* run report (tests in that file executed).
|
|
31
|
+
* L4 — RUNTIME: the specific test containing this finding was
|
|
32
|
+
* identified in the report and executed (its outcome is known).
|
|
33
|
+
* L5 — RUNTIME: the run verdict directly corroborates the DEFECT
|
|
34
|
+
* class (e.g. a flake-risk finding whose test actually flaked,
|
|
35
|
+
* retried, or timed out in the report).
|
|
36
|
+
* INVARIANT (structurally enforced): L3–L5 require runtime
|
|
37
|
+
* corroboration — a static-only finding can never claim L4/L5.
|
|
38
|
+
*/
|
|
39
|
+
const TRUST_ORDER = [
|
|
40
|
+
"L0",
|
|
41
|
+
"L1",
|
|
42
|
+
"L2",
|
|
43
|
+
"L3",
|
|
44
|
+
"L4",
|
|
45
|
+
"L5"
|
|
46
|
+
];
|
|
47
|
+
/**
|
|
22
48
|
* Honest default evidence level for a finding (Honesty Core Phase 1).
|
|
23
49
|
* Derivation is deterministic and conservative:
|
|
24
50
|
* observation → E0 (never proof)
|
|
@@ -701,21 +727,13 @@ const MEASURED_FP = {
|
|
|
701
727
|
* empty scan is an observation, not a proof).
|
|
702
728
|
*/
|
|
703
729
|
function summaryTrustLevel(findings) {
|
|
704
|
-
const order = [
|
|
705
|
-
"L0",
|
|
706
|
-
"L1",
|
|
707
|
-
"L2",
|
|
708
|
-
"L3",
|
|
709
|
-
"L4",
|
|
710
|
-
"L5"
|
|
711
|
-
];
|
|
712
730
|
let best = 0;
|
|
713
731
|
for (const f of findings) {
|
|
714
732
|
const t = f.trustLevel ?? "L2";
|
|
715
|
-
const idx =
|
|
733
|
+
const idx = TRUST_ORDER.indexOf(t);
|
|
716
734
|
if (idx > best) best = idx;
|
|
717
735
|
}
|
|
718
|
-
return
|
|
736
|
+
return TRUST_ORDER[best];
|
|
719
737
|
}
|
|
720
738
|
/**
|
|
721
739
|
* A finding's trust rung (0–5) on the canonical ladder. Corroborated
|
|
@@ -726,14 +744,7 @@ function summaryTrustLevel(findings) {
|
|
|
726
744
|
*/
|
|
727
745
|
function trustRung(f) {
|
|
728
746
|
const t = f.trustLevel ?? deriveTrustLevel(f);
|
|
729
|
-
return
|
|
730
|
-
"L0",
|
|
731
|
-
"L1",
|
|
732
|
-
"L2",
|
|
733
|
-
"L3",
|
|
734
|
-
"L4",
|
|
735
|
-
"L5"
|
|
736
|
-
].indexOf(t);
|
|
747
|
+
return TRUST_ORDER.indexOf(t);
|
|
737
748
|
}
|
|
738
749
|
/**
|
|
739
750
|
* Hard incompleteness ceilings (plan §6: partial/crash/truncation;
|
|
@@ -870,6 +881,17 @@ function buildRunIdentity(input) {
|
|
|
870
881
|
engineVersion: input.engineVersion
|
|
871
882
|
};
|
|
872
883
|
if (input.reportDigest) verdictInputs.reportDigest = input.reportDigest;
|
|
884
|
+
if (input.commit !== void 0) verdictInputs.commit = input.commit;
|
|
885
|
+
if (input.tree !== void 0) verdictInputs.tree = input.tree;
|
|
886
|
+
if (input.lockfile !== void 0) verdictInputs.lockfile = input.lockfile;
|
|
887
|
+
if (input.candidate !== void 0) verdictInputs.candidate = {
|
|
888
|
+
manifestId: input.candidate.manifestId,
|
|
889
|
+
state: input.candidate.state,
|
|
890
|
+
candidateSha: input.candidate.candidateSha,
|
|
891
|
+
baseSha: input.candidate.baseSha,
|
|
892
|
+
packageSha256: input.candidate.packageSha256,
|
|
893
|
+
lockfileSha256: input.candidate.lockfileSha256
|
|
894
|
+
};
|
|
873
895
|
if (input.trustModelVersion !== void 0) verdictInputs.trustModelVersion = input.trustModelVersion;
|
|
874
896
|
if (input.scoringModelVersion !== void 0) verdictInputs.scoringModelVersion = input.scoringModelVersion;
|
|
875
897
|
if (input.frameworkSupportMatrixVersion !== void 0) verdictInputs.frameworkSupportMatrixVersion = input.frameworkSupportMatrixVersion;
|
|
@@ -877,15 +899,31 @@ function buildRunIdentity(input) {
|
|
|
877
899
|
if (input.suppressionFingerprint !== void 0) verdictInputs.suppressionFingerprint = input.suppressionFingerprint;
|
|
878
900
|
if (input.policyFingerprint !== void 0) verdictInputs.policyFingerprint = input.policyFingerprint;
|
|
879
901
|
if (input.historicalEvidenceFingerprint !== void 0) verdictInputs.historicalEvidenceFingerprint = input.historicalEvidenceFingerprint;
|
|
902
|
+
const scanId = sha256$1(canonical(verdictInputs));
|
|
903
|
+
const boundLinks = [
|
|
904
|
+
"input",
|
|
905
|
+
"rules",
|
|
906
|
+
"config",
|
|
907
|
+
"engine"
|
|
908
|
+
];
|
|
909
|
+
if (input.commit !== void 0) boundLinks.push("commit");
|
|
910
|
+
if (input.tree !== void 0) boundLinks.push("tree");
|
|
911
|
+
if (input.lockfile !== void 0) boundLinks.push("lockfile");
|
|
912
|
+
if (input.candidate !== void 0) boundLinks.push("candidate");
|
|
880
913
|
return {
|
|
881
|
-
scanId
|
|
914
|
+
scanId,
|
|
882
915
|
inputFingerprint,
|
|
883
916
|
rulesDigest,
|
|
884
917
|
configFingerprint,
|
|
885
918
|
engineVersion: input.engineVersion,
|
|
919
|
+
boundLinks,
|
|
886
920
|
...input.trustModelVersion !== void 0 ? { trustModelVersion: input.trustModelVersion } : {},
|
|
887
921
|
...input.scoringModelVersion !== void 0 ? { scoringModelVersion: input.scoringModelVersion } : {},
|
|
888
|
-
...input.frameworkSupportMatrixVersion !== void 0 ? { frameworkSupportMatrixVersion: input.frameworkSupportMatrixVersion } : {}
|
|
922
|
+
...input.frameworkSupportMatrixVersion !== void 0 ? { frameworkSupportMatrixVersion: input.frameworkSupportMatrixVersion } : {},
|
|
923
|
+
...input.commit !== void 0 ? { commit: input.commit } : {},
|
|
924
|
+
...input.tree !== void 0 ? { tree: input.tree } : {},
|
|
925
|
+
...input.lockfile !== void 0 ? { lockfile: input.lockfile } : {},
|
|
926
|
+
...input.candidate !== void 0 ? { candidate: input.candidate } : {}
|
|
889
927
|
};
|
|
890
928
|
}
|
|
891
929
|
/**
|
|
@@ -926,9 +964,218 @@ function buildEvidenceGraph(parts) {
|
|
|
926
964
|
...parts.reproduction !== void 0 ? { ref: parts.reproduction } : {}
|
|
927
965
|
}
|
|
928
966
|
],
|
|
929
|
-
...parts.runId !== void 0 ? { runId: parts.runId } : {}
|
|
967
|
+
...parts.runId !== void 0 ? { runId: parts.runId } : {},
|
|
968
|
+
...parts.candidate !== void 0 ? { candidate: {
|
|
969
|
+
manifestId: parts.candidate.manifestId,
|
|
970
|
+
candidateSha: parts.candidate.candidateSha
|
|
971
|
+
} } : {}
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
//#endregion
|
|
975
|
+
//#region src/engine/file-executor.ts
|
|
976
|
+
function cacheHitOutcome(job) {
|
|
977
|
+
return {
|
|
978
|
+
path: job.path,
|
|
979
|
+
status: "CACHE_HIT",
|
|
980
|
+
findings: [...job.cacheHit ?? []],
|
|
981
|
+
parseFallback: false
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Drive a list of jobs through an executor and return outcomes in INPUT order.
|
|
986
|
+
*
|
|
987
|
+
* This is the function the pipeline calls. It is executor-agnostic on purpose:
|
|
988
|
+
* swapping in a pooled executor must not require touching the caller, and the
|
|
989
|
+
* ordering guarantee has to live here or it would live in each executor and
|
|
990
|
+
* drift between them.
|
|
991
|
+
*/
|
|
992
|
+
async function executeFiles(jobs, executor, context = {}) {
|
|
993
|
+
const outcomes = new Array(jobs.length);
|
|
994
|
+
if (executor.concurrency <= 1 || jobs.length <= 1) {
|
|
995
|
+
for (let i = 0; i < jobs.length; i++) {
|
|
996
|
+
const job = jobs[i];
|
|
997
|
+
context.onProgress?.({
|
|
998
|
+
done: i,
|
|
999
|
+
total: jobs.length,
|
|
1000
|
+
path: job.path
|
|
1001
|
+
});
|
|
1002
|
+
outcomes[i] = await runOne(job, executor, context);
|
|
1003
|
+
}
|
|
1004
|
+
context.onProgress?.({
|
|
1005
|
+
done: jobs.length,
|
|
1006
|
+
total: jobs.length,
|
|
1007
|
+
path: jobs[jobs.length - 1]?.path ?? ""
|
|
1008
|
+
});
|
|
1009
|
+
return outcomes;
|
|
1010
|
+
}
|
|
1011
|
+
let next = 0;
|
|
1012
|
+
let completed = 0;
|
|
1013
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
1014
|
+
const limit = Math.max(1, Math.floor(executor.concurrency));
|
|
1015
|
+
const worker = async () => {
|
|
1016
|
+
for (;;) {
|
|
1017
|
+
const index = next++;
|
|
1018
|
+
if (index >= jobs.length) return;
|
|
1019
|
+
const job = jobs[index];
|
|
1020
|
+
outcomes[index] = await runOne(job, executor, context);
|
|
1021
|
+
completed++;
|
|
1022
|
+
context.onProgress?.({
|
|
1023
|
+
done: completed,
|
|
1024
|
+
total: jobs.length,
|
|
1025
|
+
path: job.path
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
for (let i = 0; i < Math.min(limit, jobs.length); i++) {
|
|
1030
|
+
const promise = worker().then(() => void 0);
|
|
1031
|
+
inFlight.add(promise);
|
|
1032
|
+
}
|
|
1033
|
+
await Promise.all(inFlight);
|
|
1034
|
+
return outcomes;
|
|
1035
|
+
}
|
|
1036
|
+
async function runOne(job, executor, context) {
|
|
1037
|
+
if (job.cacheHit !== void 0) return cacheHitOutcome(job);
|
|
1038
|
+
try {
|
|
1039
|
+
return await executor.execute(job, context);
|
|
1040
|
+
} catch (error) {
|
|
1041
|
+
return {
|
|
1042
|
+
path: job.path,
|
|
1043
|
+
status: "FAILED",
|
|
1044
|
+
findings: [],
|
|
1045
|
+
parseFallback: false,
|
|
1046
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* The strategy the pipeline uses today.
|
|
1052
|
+
*
|
|
1053
|
+
* `runOne` is supplied by the pipeline because parsing and rule execution
|
|
1054
|
+
* need the adapter and the active rule set, which are pipeline concerns. The
|
|
1055
|
+
* executor's job is the BOUNDARY and the bookkeeping, not the analysis — so
|
|
1056
|
+
* this factory exists to bind those two without the executor importing the
|
|
1057
|
+
* pipeline (which would be a cycle).
|
|
1058
|
+
*/
|
|
1059
|
+
function createExecutor(name, concurrency, runOne) {
|
|
1060
|
+
return {
|
|
1061
|
+
name,
|
|
1062
|
+
concurrency,
|
|
1063
|
+
execute: runOne
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
//#endregion
|
|
1067
|
+
//#region src/engine/candidate-binding.ts
|
|
1068
|
+
/**
|
|
1069
|
+
* Candidate binding (plan V5-010).
|
|
1070
|
+
*
|
|
1071
|
+
* Reads a candidate trust manifest and produces the immutable
|
|
1072
|
+
* `CandidateBinding` that a run identity is bound to. It also computes the
|
|
1073
|
+
* repository facts (commit, tree, lockfile digest) that the identity was
|
|
1074
|
+
* missing until this module existed.
|
|
1075
|
+
*
|
|
1076
|
+
* The law here is ABSENCE over invention. A manifest that is missing,
|
|
1077
|
+
* unreadable, or internally contradictory yields NO binding — not a partial
|
|
1078
|
+
* one. A consumer that can see "not bound" can refuse to trust the run; a
|
|
1079
|
+
* consumer handed a half-filled binding cannot tell what is missing, and will
|
|
1080
|
+
* trust the parts that are there.
|
|
1081
|
+
*/
|
|
1082
|
+
const CANDIDATE_MANIFEST_PATH = "candidate-trust-manifest.json";
|
|
1083
|
+
const SHA40 = /^[a-f0-9]{40}$/;
|
|
1084
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
1085
|
+
const STATES = ["WORKING_CANDIDATE", "RELEASE_CANDIDATE"];
|
|
1086
|
+
const AUTHORIZATION = ["NOT_AUTHORIZED", "AUTHORIZED"];
|
|
1087
|
+
function isRecord$1(value) {
|
|
1088
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1089
|
+
}
|
|
1090
|
+
function sha256File(path) {
|
|
1091
|
+
try {
|
|
1092
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
1093
|
+
} catch {
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
function gitLine(root, args) {
|
|
1098
|
+
try {
|
|
1099
|
+
const trimmed = execFileSync("git", args, {
|
|
1100
|
+
cwd: root,
|
|
1101
|
+
encoding: "utf8",
|
|
1102
|
+
stdio: [
|
|
1103
|
+
"ignore",
|
|
1104
|
+
"pipe",
|
|
1105
|
+
"ignore"
|
|
1106
|
+
],
|
|
1107
|
+
windowsHide: true
|
|
1108
|
+
}).trim();
|
|
1109
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
1110
|
+
} catch {
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* The commit and tree the working tree is at. Both absent outside a
|
|
1116
|
+
* repository or with no commits — never substituted with a placeholder.
|
|
1117
|
+
*/
|
|
1118
|
+
function bindRepository(root) {
|
|
1119
|
+
const commit = gitLine(root, ["rev-parse", "HEAD"]);
|
|
1120
|
+
const tree = gitLine(root, ["rev-parse", "HEAD^{tree}"]);
|
|
1121
|
+
const lockfile = sha256File(join(root, "package-lock.json"));
|
|
1122
|
+
return {
|
|
1123
|
+
...commit !== void 0 ? { commit } : {},
|
|
1124
|
+
...tree !== void 0 ? { tree } : {},
|
|
1125
|
+
...lockfile !== void 0 ? { lockfile } : {}
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Parse a candidate manifest into a binding, or return null when it cannot
|
|
1130
|
+
* honestly be bound.
|
|
1131
|
+
*
|
|
1132
|
+
* Null is returned for: no manifest, unreadable JSON, a missing or malformed
|
|
1133
|
+
* required field, or a state/authorization combination that cannot legally
|
|
1134
|
+
* exist (a WORKING_CANDIDATE carrying a commit, for example — the same
|
|
1135
|
+
* contradiction the release decision evaluator treats as BLOCKED).
|
|
1136
|
+
*/
|
|
1137
|
+
function parseCandidateBinding(raw) {
|
|
1138
|
+
if (!isRecord$1(raw)) return null;
|
|
1139
|
+
const identity = raw.identity;
|
|
1140
|
+
if (!isRecord$1(identity)) return null;
|
|
1141
|
+
const state = identity.state;
|
|
1142
|
+
const candidateSha = identity.candidateSha;
|
|
1143
|
+
const baseSha = identity.baseSha;
|
|
1144
|
+
const packageSha256 = identity.packageSha256;
|
|
1145
|
+
const lockfileSha256 = identity.lockfileSha256;
|
|
1146
|
+
if (typeof state !== "string" || !STATES.includes(state)) return null;
|
|
1147
|
+
if (typeof baseSha !== "string" || !SHA40.test(baseSha)) return null;
|
|
1148
|
+
if (typeof packageSha256 !== "string" || !SHA256.test(packageSha256)) return null;
|
|
1149
|
+
if (typeof lockfileSha256 !== "string" || !SHA256.test(lockfileSha256)) return null;
|
|
1150
|
+
if (candidateSha !== null && (typeof candidateSha !== "string" || !SHA40.test(candidateSha))) return null;
|
|
1151
|
+
const manifestId = raw.manifestId;
|
|
1152
|
+
if (typeof manifestId !== "string" || manifestId.length === 0) return null;
|
|
1153
|
+
const owner = typeof raw.owner === "string" ? raw.owner : "UNASSIGNED";
|
|
1154
|
+
const authorization = raw.releaseAuthorizationState;
|
|
1155
|
+
if (typeof authorization !== "string" || !AUTHORIZATION.includes(authorization)) return null;
|
|
1156
|
+
if (state === "WORKING_CANDIDATE") {
|
|
1157
|
+
if (candidateSha !== null) return null;
|
|
1158
|
+
if (authorization !== "NOT_AUTHORIZED") return null;
|
|
1159
|
+
} else if (candidateSha === null) return null;
|
|
1160
|
+
return {
|
|
1161
|
+
manifestId,
|
|
1162
|
+
state,
|
|
1163
|
+
candidateSha,
|
|
1164
|
+
baseSha,
|
|
1165
|
+
packageSha256,
|
|
1166
|
+
lockfileSha256,
|
|
1167
|
+
owner,
|
|
1168
|
+
releaseAuthorizationState: authorization
|
|
930
1169
|
};
|
|
931
1170
|
}
|
|
1171
|
+
/** Read and bind the manifest from a checkout, or null. */
|
|
1172
|
+
function readCandidateBinding(root, manifestPath = CANDIDATE_MANIFEST_PATH) {
|
|
1173
|
+
try {
|
|
1174
|
+
return parseCandidateBinding(JSON.parse(readFileSync(join(root, manifestPath), "utf8")));
|
|
1175
|
+
} catch {
|
|
1176
|
+
return null;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
932
1179
|
//#endregion
|
|
933
1180
|
//#region src/engine/version.ts
|
|
934
1181
|
/**
|
|
@@ -939,7 +1186,7 @@ function buildEvidenceGraph(parts) {
|
|
|
939
1186
|
* scripts/sync-sarif-version.cjs and guarded by the version-consistency
|
|
940
1187
|
* spec. cli.ts re-exports this as CLI_VERSION.
|
|
941
1188
|
*/
|
|
942
|
-
const ENGINE_VERSION = "
|
|
1189
|
+
const ENGINE_VERSION = "4.0.0";
|
|
943
1190
|
//#endregion
|
|
944
1191
|
//#region src/engine/contract-versions.ts
|
|
945
1192
|
/**
|
|
@@ -1347,20 +1594,9 @@ function validate(cfg, knownRuleIds) {
|
|
|
1347
1594
|
return warnings;
|
|
1348
1595
|
}
|
|
1349
1596
|
/**
|
|
1350
|
-
*
|
|
1351
|
-
*
|
|
1352
|
-
*
|
|
1353
|
-
* documented window.
|
|
1354
|
-
*
|
|
1355
|
-
* Audit S4 (remediation plan): the config-file MTIME is no longer an
|
|
1356
|
-
* expiry anchor. Anchoring the 90-day default at mtime meant ANY edit to
|
|
1357
|
-
* mjolnir.config.json — a reformat, an unrelated key, a `touch` — reset
|
|
1358
|
-
* the 90-day window for EVERY hand-authored entry: suppressions could be
|
|
1359
|
-
* extended indefinitely without touching their own fields. The expiry is
|
|
1360
|
-
* now the entry's explicit `expires` date alone; an entry without one
|
|
1361
|
-
* stays active and is honestly labeled "(no expiry set)" in the
|
|
1362
|
-
* suppressions report. Hand-authored entries should declare `expires` at
|
|
1363
|
-
* creation (README §Configuration documents the shape).
|
|
1597
|
+
* Expiry is evaluated only from an explicit ISO `expires` date. A missing
|
|
1598
|
+
* date remains active and is reported as having no expiry. Config-file mtime
|
|
1599
|
+
* is never an expiry anchor.
|
|
1364
1600
|
*/
|
|
1365
1601
|
function isSuppressionActive(ign, now = /* @__PURE__ */ new Date()) {
|
|
1366
1602
|
if (!ign.expires) return true;
|
|
@@ -1638,7 +1874,33 @@ function sharedWalk(options) {
|
|
|
1638
1874
|
*
|
|
1639
1875
|
* When nothing is detectable we report `unknown` and the scanner analyzes
|
|
1640
1876
|
* all test-looking files — stated honestly in output rather than guessed.
|
|
1877
|
+
*
|
|
1878
|
+
* ## One ID space (plan V5-024)
|
|
1879
|
+
*
|
|
1880
|
+
* This detector used to declare its own `TestFramework` union of three
|
|
1881
|
+
* literals while `src/frameworks/framework-inventory.ts` catalogued fourteen
|
|
1882
|
+
* framework ids. Two vocabularies for one concept is how a support matrix
|
|
1883
|
+
* starts disagreeing with what the tool actually detects: a framework can be
|
|
1884
|
+
* catalogued as OFFICIAL_PARTIAL and never once be emitted by the code that
|
|
1885
|
+
* claims to detect it.
|
|
1886
|
+
*
|
|
1887
|
+
* So the ids here are `FrameworkId`s — the inventory's own vocabulary — and
|
|
1888
|
+
* `DETECTABLE_TEST_FRAMEWORKS` is the subset this detector can actually
|
|
1889
|
+
* resolve from a checkout. That subset is the HONEST limit of detection, and
|
|
1890
|
+
* `detectableVsCatalogued()` reports the remainder rather than letting a
|
|
1891
|
+
* three-item list read as the whole catalog.
|
|
1641
1892
|
*/
|
|
1893
|
+
/**
|
|
1894
|
+
* The catalogued frameworks whose detection is implemented today.
|
|
1895
|
+
*
|
|
1896
|
+
* Every entry must exist in the inventory — enforced by the parity spec, not
|
|
1897
|
+
* by comment.
|
|
1898
|
+
*/
|
|
1899
|
+
const DETECTABLE_TEST_FRAMEWORKS = [
|
|
1900
|
+
"jest",
|
|
1901
|
+
"vitest",
|
|
1902
|
+
"playwright"
|
|
1903
|
+
];
|
|
1642
1904
|
const CONFIG_FILES = {
|
|
1643
1905
|
jest: [
|
|
1644
1906
|
"jest.config.ts",
|
|
@@ -1657,7 +1919,7 @@ const CONFIG_FILES = {
|
|
|
1657
1919
|
};
|
|
1658
1920
|
function detectFrameworks(ws) {
|
|
1659
1921
|
const found = /* @__PURE__ */ new Set();
|
|
1660
|
-
for (const fw of
|
|
1922
|
+
for (const fw of DETECTABLE_TEST_FRAMEWORKS) if (CONFIG_FILES[fw].some((f) => existsSync(join(ws.root, f)))) found.add(fw);
|
|
1661
1923
|
if (!found.has("jest") && ws.packageJson["jest"] !== void 0) found.add("jest");
|
|
1662
1924
|
const deps = {
|
|
1663
1925
|
...ws.packageJson["dependencies"],
|
|
@@ -1679,11 +1941,7 @@ function detectFrameworks(ws) {
|
|
|
1679
1941
|
};
|
|
1680
1942
|
}
|
|
1681
1943
|
return {
|
|
1682
|
-
frameworks:
|
|
1683
|
-
"jest",
|
|
1684
|
-
"vitest",
|
|
1685
|
-
"playwright"
|
|
1686
|
-
].filter((f) => found.has(f)),
|
|
1944
|
+
frameworks: DETECTABLE_TEST_FRAMEWORKS.filter((f) => found.has(f)),
|
|
1687
1945
|
unknown: false
|
|
1688
1946
|
};
|
|
1689
1947
|
}
|
|
@@ -1700,6 +1958,13 @@ function detectFrameworks(ws) {
|
|
|
1700
1958
|
* byte-identical — a score shift is a regression, not an improvement.
|
|
1701
1959
|
*/
|
|
1702
1960
|
let project = null;
|
|
1961
|
+
/**
|
|
1962
|
+
* The shared ts-morph project.
|
|
1963
|
+
*
|
|
1964
|
+
* Exported so an adapter's `dispose()` can evict the file it parsed: ts-morph
|
|
1965
|
+
* caches by file path, so without eviction a long scan holds every parsed
|
|
1966
|
+
* SourceFile until the process exits (plan V5-021).
|
|
1967
|
+
*/
|
|
1703
1968
|
function getProject() {
|
|
1704
1969
|
if (!project) project = new Project({
|
|
1705
1970
|
useInMemoryFileSystem: true,
|
|
@@ -2226,7 +2491,36 @@ function frameworkFilterApplies(rule, file) {
|
|
|
2226
2491
|
* "cypress", `@jest/globals` → "jest", `vitest` → "vitest". Config
|
|
2227
2492
|
* gating is rule-declared (`configFiles`), not hard-coded here.
|
|
2228
2493
|
*/
|
|
2229
|
-
|
|
2494
|
+
/**
|
|
2495
|
+
* `.test.` / `.spec.` filenames, plus the Cypress `.cy.` convention.
|
|
2496
|
+
*
|
|
2497
|
+
* `mts` and `cts` are TypeScript's Node-native ESM/CJS extensions. They were
|
|
2498
|
+
* missing here, and the omission was invisible in the worst way — not a wrong
|
|
2499
|
+
* answer, but an ABSENCE.
|
|
2500
|
+
*
|
|
2501
|
+
* Reproduced: a repo containing two byte-identical tests, one at
|
|
2502
|
+
* `tests/control.spec.ts` and one at `tests/slow.spec.mts`, each with a
|
|
2503
|
+
* `QA-TEST-004` hard sleep:
|
|
2504
|
+
*
|
|
2505
|
+
* discovered: 1, analyzed: 1, unrecognized: 1, scopeVerdict: "PARTIAL"
|
|
2506
|
+
*
|
|
2507
|
+
* The `.ts` test produced the finding. The `.mts` test produced nothing at
|
|
2508
|
+
* all — it was never scanned, so every rule that could have caught it was
|
|
2509
|
+
* silent on it, with no finding, no low-evidence note, and no indication that
|
|
2510
|
+
* a test file had been skipped. A test scanner that cannot see a whole file
|
|
2511
|
+
* extension has a false green that is invisible by construction: the reader
|
|
2512
|
+
* has nothing to distrust, because there is nothing there.
|
|
2513
|
+
*
|
|
2514
|
+
* The corroboration that this was an oversight rather than a decision sits
|
|
2515
|
+
* four lines below: PW_CONFIG_RE already accepted `cts`. The config regex knew
|
|
2516
|
+
* about the Node-native extensions; the test-file regex did not.
|
|
2517
|
+
*
|
|
2518
|
+
* `discovery/scan-adapters.ts:isUnrecognizedSourceCandidate` already counted
|
|
2519
|
+
* these paths as uncovered surface, so the scope accounting had been reporting
|
|
2520
|
+
* the hole the whole time — it was being read as a known limitation rather than
|
|
2521
|
+
* as a defect.
|
|
2522
|
+
*/
|
|
2523
|
+
const TEST_FILE_RE = /\.(?:test|spec)\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$|\.cy\.(?:js|jsx|ts|tsx)$/;
|
|
2230
2524
|
/**
|
|
2231
2525
|
* Fallback config list for `configOnly` rules that do not declare
|
|
2232
2526
|
* `configFiles` (the legacy playwright.config.* gating, preserved
|
|
@@ -2333,11 +2627,42 @@ const typescriptAdapter = {
|
|
|
2333
2627
|
fixtureDirMemo: /* @__PURE__ */ new Map()
|
|
2334
2628
|
});
|
|
2335
2629
|
},
|
|
2630
|
+
/**
|
|
2631
|
+
* The ONE AST seam (plan V5-021).
|
|
2632
|
+
*
|
|
2633
|
+
* This adapter used to parse inside `runRules`, on the synchronous path,
|
|
2634
|
+
* while Java and C# exposed the async `parseAst` hook. Two seams meant two
|
|
2635
|
+
* sets of consequences, both of them bad:
|
|
2636
|
+
*
|
|
2637
|
+
* - the pipeline computes `wantsAst` as `adapter.parseAst !== undefined`,
|
|
2638
|
+
* so a TypeScript file never took the AST path through the pipeline at
|
|
2639
|
+
* all, and its parse failures were invisible to the pipeline's
|
|
2640
|
+
* fallback counters;
|
|
2641
|
+
* - nothing called `dispose()` for the ts-morph path, so a scan held
|
|
2642
|
+
* every parsed SourceFile for its whole lifetime.
|
|
2643
|
+
*
|
|
2644
|
+
* Parsing now happens here, once, through the same contract every other
|
|
2645
|
+
* adapter uses. `dispose()` drops this file's SourceFile from the shared
|
|
2646
|
+
* project; ts-morph caches per file path, so removing it is what keeps
|
|
2647
|
+
* memory proportional to one file rather than to the whole scan.
|
|
2648
|
+
*/
|
|
2649
|
+
parseAst(file) {
|
|
2650
|
+
const sourceFile = parseTsFile(file);
|
|
2651
|
+
if (sourceFile === void 0) return void 0;
|
|
2652
|
+
return {
|
|
2653
|
+
ast: sourceFile,
|
|
2654
|
+
dispose: () => {
|
|
2655
|
+
try {
|
|
2656
|
+
getProject().removeSourceFile(sourceFile);
|
|
2657
|
+
} catch {}
|
|
2658
|
+
}
|
|
2659
|
+
};
|
|
2660
|
+
},
|
|
2336
2661
|
runRules(rules, file, emit, onCrash, budget) {
|
|
2337
|
-
const withAst = {
|
|
2662
|
+
const withAst = file.ast === void 0 ? {
|
|
2338
2663
|
...file,
|
|
2339
2664
|
ast: parseTsFile(file)
|
|
2340
|
-
};
|
|
2665
|
+
} : file;
|
|
2341
2666
|
const withTags = {
|
|
2342
2667
|
...withAst,
|
|
2343
2668
|
frameworkTags: frameworkTagsFromImports(withAst.text)
|
|
@@ -3756,9 +4081,37 @@ function discoverAllTestFiles(ctx, languageAdapters, buckets, fixtureDirMemo) {
|
|
|
3756
4081
|
}
|
|
3757
4082
|
});
|
|
3758
4083
|
}
|
|
4084
|
+
/**
|
|
4085
|
+
* Would ignoring this file have removed it from the scanned surface?
|
|
4086
|
+
*
|
|
4087
|
+
* The bug this fixes, reproduced: a repo containing a real test file plus
|
|
4088
|
+
* `vendor.min.js` and `pnpm-lock.yaml` reported
|
|
4089
|
+
*
|
|
4090
|
+
* discovered: 1, analyzed: 1, ignored: 2, scopeVerdict: "PARTIAL"
|
|
4091
|
+
*
|
|
4092
|
+
* Every discovered test file HAD been analyzed. The surface was complete. But
|
|
4093
|
+
* DEFAULT_IGNORES matches minified bundles and lockfiles, and the ignored
|
|
4094
|
+
* counter accepted any path with a source extension — so a minified bundle and
|
|
4095
|
+
* a lockfile downgraded a finished scan to "unverified".
|
|
4096
|
+
*
|
|
4097
|
+
* That is not a conservative bias, it is a broken signal. The terminal tells
|
|
4098
|
+
* the reader "some files were not analyzed, so the surface is unverified" —
|
|
4099
|
+
* which was false. And because minified bundles and lockfiles exist in
|
|
4100
|
+
* essentially every real repository, a PROVEN scan was unreachable in
|
|
4101
|
+
* practice, so the corpus regression guard failed 23 of 37 repositories on
|
|
4102
|
+
* exactly this. A gate that cannot pass teaches people to ignore it.
|
|
4103
|
+
*
|
|
4104
|
+
* The asymmetry is the tell. `isUnrecognizedSourceCandidate` below applies a
|
|
4105
|
+
* test-relevance test before counting a file; this one did not. Both count
|
|
4106
|
+
* against the same verdict, so both must ask the same question.
|
|
4107
|
+
*
|
|
4108
|
+
* An ignored file that IS a test file still downgrades the verdict. That is
|
|
4109
|
+
* the case the honesty law exists for, and this does not touch it.
|
|
4110
|
+
*/
|
|
3759
4111
|
function isScopeRelevantIgnored(path) {
|
|
3760
4112
|
const name = path.replaceAll("\\", "/").split("/").pop() ?? path;
|
|
3761
|
-
|
|
4113
|
+
if (!/\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml|min\.js)$/i.test(name)) return false;
|
|
4114
|
+
return isKnownTestFile(path);
|
|
3762
4115
|
}
|
|
3763
4116
|
function isUnrecognizedSourceCandidate(path) {
|
|
3764
4117
|
const normalized = path.replaceAll("\\", "/");
|
|
@@ -7608,7 +7961,6 @@ const pyBareTruthinessAssert = defineRule({
|
|
|
7608
7961
|
const re = /^[ \t]*assert\s+([A-Za-z_][\w.]*(?:\([^()]*\))?)[ \t]*$/gm;
|
|
7609
7962
|
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*\()/;
|
|
7610
7963
|
const isGuardFollowedByRealUse = (text, matchIndex, target) => {
|
|
7611
|
-
const root = target.split(".")[0];
|
|
7612
7964
|
const lineEnd = text.indexOf("\n", matchIndex);
|
|
7613
7965
|
if (lineEnd === -1) return false;
|
|
7614
7966
|
const lines = text.slice(lineEnd + 1).split("\n");
|
|
@@ -7618,8 +7970,11 @@ const pyBareTruthinessAssert = defineRule({
|
|
|
7618
7970
|
if (/^\s*def\s/.test(l) && window.length > 0) break;
|
|
7619
7971
|
window.push(l);
|
|
7620
7972
|
}
|
|
7621
|
-
const
|
|
7622
|
-
|
|
7973
|
+
const root = target.match(/^[a-z_]\w*/i)?.[0];
|
|
7974
|
+
if (!root) return false;
|
|
7975
|
+
return window.some((line) => {
|
|
7976
|
+
return (line.match(/[a-z_]\w*/gi) ?? []).some((token) => token === root);
|
|
7977
|
+
});
|
|
7623
7978
|
};
|
|
7624
7979
|
let m;
|
|
7625
7980
|
while ((m = re.exec(text)) !== null) {
|
|
@@ -11581,6 +11936,22 @@ function normalizeOne(report, artifact, v) {
|
|
|
11581
11936
|
function buildEvidenceRecords(report, artifact) {
|
|
11582
11937
|
return report.verdicts.map((v) => normalizeOne(report, artifact, v)).sort(compareEvidenceRecords);
|
|
11583
11938
|
}
|
|
11939
|
+
function countEvidence(records) {
|
|
11940
|
+
const counts = {
|
|
11941
|
+
total: records.length,
|
|
11942
|
+
failed: 0,
|
|
11943
|
+
flaky: 0,
|
|
11944
|
+
skipped: 0,
|
|
11945
|
+
timedOut: 0
|
|
11946
|
+
};
|
|
11947
|
+
for (const r of records) {
|
|
11948
|
+
if (r.status.failed) counts.failed++;
|
|
11949
|
+
if (r.status.passedOnRetry) counts.flaky++;
|
|
11950
|
+
if (r.status.skipped) counts.skipped++;
|
|
11951
|
+
if (r.status.timedOut) counts.timedOut++;
|
|
11952
|
+
}
|
|
11953
|
+
return counts;
|
|
11954
|
+
}
|
|
11584
11955
|
//#endregion
|
|
11585
11956
|
//#region src/engine/provenance.ts
|
|
11586
11957
|
const GENERATED_HEADER_RE = /^\s*(?:\/\/|#|\/\*)\s*(?:auto[- ]?generated|generated by|do not edit)/i;
|
|
@@ -11868,6 +12239,11 @@ var DependencyGraph = class {
|
|
|
11868
12239
|
get size() {
|
|
11869
12240
|
return this.nodes.size;
|
|
11870
12241
|
}
|
|
12242
|
+
/** Whether the graph knows this exact key. Reachability must not
|
|
12243
|
+
* treat "not in the graph" as "nothing to traverse" without saying so. */
|
|
12244
|
+
has(path) {
|
|
12245
|
+
return this.nodes.has(path);
|
|
12246
|
+
}
|
|
11871
12247
|
};
|
|
11872
12248
|
function parsePackageJson(filePath) {
|
|
11873
12249
|
if (!existsSync(filePath)) return void 0;
|
|
@@ -11992,120 +12368,394 @@ function isIncrementalSafe(changedFiles) {
|
|
|
11992
12368
|
};
|
|
11993
12369
|
}
|
|
11994
12370
|
//#endregion
|
|
11995
|
-
//#region src/
|
|
11996
|
-
const WORTHY_THRESHOLD = 80;
|
|
11997
|
-
const NEEDS_WORK_THRESHOLD = 50;
|
|
11998
|
-
function verdictOf(score) {
|
|
11999
|
-
if (score === null) return "fail";
|
|
12000
|
-
if (score >= WORTHY_THRESHOLD) return "pass";
|
|
12001
|
-
if (score >= NEEDS_WORK_THRESHOLD) return "warn";
|
|
12002
|
-
return "fail";
|
|
12003
|
-
}
|
|
12004
|
-
function hasBlocker(findings) {
|
|
12005
|
-
return findings.some((f) => f.severity === "error");
|
|
12006
|
-
}
|
|
12371
|
+
//#region src/brand/tokens.ts
|
|
12007
12372
|
/**
|
|
12008
|
-
*
|
|
12373
|
+
* The single source of brand truth.
|
|
12009
12374
|
*
|
|
12010
|
-
*
|
|
12011
|
-
*
|
|
12012
|
-
*
|
|
12013
|
-
*
|
|
12375
|
+
* Every colour, typeface and motion constant Mjölnir shows a human —
|
|
12376
|
+
* terminal, README SVGs, demo video, website, docs, badges — resolves to
|
|
12377
|
+
* a value in this file. Nothing else may define one.
|
|
12378
|
+
*
|
|
12379
|
+
* WHY THIS EXISTS. Before it, the palette existed in six independent
|
|
12380
|
+
* copies: `site/.vitepress/theme/styles/vars.css`, `NORSE` in
|
|
12381
|
+
* `src/reporter/theme.ts`, `scripts/readme-svg.ts`,
|
|
12382
|
+
* `scripts/video/terminal-page.ts`, `scripts/generate-readme-architecture.ts`
|
|
12383
|
+
* and the table in `assets/brand/README.md`. Exactly one pair of those
|
|
12384
|
+
* was guarded (site-doctor Check 8, doc ↔ vars.css). The unguarded edges
|
|
12385
|
+
* are where the shipped surfaces drifted apart: the terminal and the site
|
|
12386
|
+
* disagreed on six semantic roles, the architecture diagram invented its
|
|
12387
|
+
* own neutral ramp, and the README badges still carried a palette retired
|
|
12388
|
+
* two releases earlier. `scripts/brand-doctor.mjs` now checks every edge
|
|
12389
|
+
* against this file.
|
|
12390
|
+
*
|
|
12391
|
+
* PURITY. Pure data. No I/O, no rendering, no environment access, no
|
|
12392
|
+
* imports, no logic. Consumers convert (hex → ANSI triplet, hex → CSS)
|
|
12393
|
+
* themselves. Same reason `presentation.ts` is pure: it makes the whole
|
|
12394
|
+
* thing golden-testable and safe to ship inside the npm package, where
|
|
12395
|
+
* it costs a few hundred bytes and replaces values the package already
|
|
12396
|
+
* carried anyway.
|
|
12397
|
+
*
|
|
12398
|
+
* DERIVATION. The palette is the one derived from the logo in PR #20
|
|
12399
|
+
* (brushed steel and forge gold under an aurora, over midnight iron).
|
|
12400
|
+
* Where the terminal disagreed with it, the terminal converges — see
|
|
12401
|
+
* `PENDING_TERMINAL` below. Full rationale: `assets/brand/README.md`.
|
|
12402
|
+
*
|
|
12403
|
+
* ACCESSIBILITY. Every foreground token in `BRAND` meets WCAG AA
|
|
12404
|
+
* (≥ 4.5:1) against every surface token it is allowed to sit on. That is
|
|
12405
|
+
* not a claim, it is `brand-doctor` rule 8, which computes the ratios.
|
|
12406
|
+
* The weakest legal pairing is `steelDim` on `ink800` at 5.00:1.
|
|
12014
12407
|
*/
|
|
12015
|
-
function analyzeMonorepo(packages, config) {
|
|
12016
|
-
const results = packages.map((p) => ({
|
|
12017
|
-
...p,
|
|
12018
|
-
verdict: hasBlocker(p.findings) ? "fail" : verdictOf(p.score)
|
|
12019
|
-
}));
|
|
12020
|
-
if (results.length === 0) return {
|
|
12021
|
-
packages: results,
|
|
12022
|
-
overallScore: null,
|
|
12023
|
-
overallVerdict: "fail",
|
|
12024
|
-
strategy: config.weightingStrategy
|
|
12025
|
-
};
|
|
12026
|
-
const blockerPkg = results.find((r) => r.verdict === "fail" || hasBlocker(r.findings));
|
|
12027
|
-
if (blockerPkg) return {
|
|
12028
|
-
packages: results,
|
|
12029
|
-
overallScore: blockerPkg.score,
|
|
12030
|
-
overallVerdict: "fail",
|
|
12031
|
-
strategy: config.weightingStrategy,
|
|
12032
|
-
blockerPackage: blockerPkg.packageName
|
|
12033
|
-
};
|
|
12034
|
-
switch (config.weightingStrategy) {
|
|
12035
|
-
case "worst-package": return worstPackageAggregation(results, config);
|
|
12036
|
-
case "average": return averageAggregation(results, config);
|
|
12037
|
-
case "configurable": return configurableAggregation(results, config);
|
|
12038
|
-
}
|
|
12039
|
-
}
|
|
12040
|
-
function worstPackageAggregation(results, config) {
|
|
12041
|
-
const firstResult = results[0];
|
|
12042
|
-
if (firstResult === void 0) return {
|
|
12043
|
-
packages: results,
|
|
12044
|
-
overallScore: null,
|
|
12045
|
-
overallVerdict: "fail",
|
|
12046
|
-
strategy: config.weightingStrategy
|
|
12047
|
-
};
|
|
12048
|
-
let worst = firstResult;
|
|
12049
|
-
for (const r of results) if ((r.score ?? 0) < (worst.score ?? 0)) worst = r;
|
|
12050
|
-
return {
|
|
12051
|
-
packages: results,
|
|
12052
|
-
overallScore: worst.score,
|
|
12053
|
-
overallVerdict: worst.verdict,
|
|
12054
|
-
strategy: config.weightingStrategy,
|
|
12055
|
-
...worst.verdict === "fail" ? { blockerPackage: worst.packageName } : {}
|
|
12056
|
-
};
|
|
12057
|
-
}
|
|
12058
|
-
function averageAggregation(results, config) {
|
|
12059
|
-
const scored = results.filter((r) => r.score !== null);
|
|
12060
|
-
if (scored.length === 0) return {
|
|
12061
|
-
packages: results,
|
|
12062
|
-
overallScore: null,
|
|
12063
|
-
overallVerdict: "fail",
|
|
12064
|
-
strategy: config.weightingStrategy
|
|
12065
|
-
};
|
|
12066
|
-
const avg = scored.reduce((sum, r) => sum + (r.score ?? 0), 0) / scored.length;
|
|
12067
|
-
return {
|
|
12068
|
-
packages: results,
|
|
12069
|
-
overallScore: Math.round(avg),
|
|
12070
|
-
overallVerdict: verdictOf(Math.round(avg)),
|
|
12071
|
-
strategy: config.weightingStrategy
|
|
12072
|
-
};
|
|
12073
|
-
}
|
|
12074
|
-
function configurableAggregation(results, config) {
|
|
12075
|
-
const weights = config.packageWeights ?? {};
|
|
12076
|
-
let totalWeight = 0;
|
|
12077
|
-
let weightedSum = 0;
|
|
12078
|
-
for (const r of results) {
|
|
12079
|
-
const w = weights[r.packageName] ?? 1;
|
|
12080
|
-
totalWeight += w;
|
|
12081
|
-
weightedSum += (r.score ?? 0) * w;
|
|
12082
|
-
}
|
|
12083
|
-
if (totalWeight === 0) return {
|
|
12084
|
-
packages: results,
|
|
12085
|
-
overallScore: null,
|
|
12086
|
-
overallVerdict: "fail",
|
|
12087
|
-
strategy: config.weightingStrategy
|
|
12088
|
-
};
|
|
12089
|
-
const score = Math.round(weightedSum / totalWeight);
|
|
12090
|
-
return {
|
|
12091
|
-
packages: results,
|
|
12092
|
-
overallScore: score,
|
|
12093
|
-
overallVerdict: verdictOf(score),
|
|
12094
|
-
strategy: config.weightingStrategy
|
|
12095
|
-
};
|
|
12096
|
-
}
|
|
12097
|
-
//#endregion
|
|
12098
|
-
//#region src/lib/fs-atomic.ts
|
|
12099
12408
|
/**
|
|
12100
|
-
*
|
|
12101
|
-
*
|
|
12102
|
-
* Every durability-critical write in Mjölnir (baseline, stats, badge,
|
|
12103
|
-
* TRIAGE.md, scaffolded rule files) used to hand-roll
|
|
12104
|
-
* `writeFileSync(path, data)` — a crash mid-write left a TRUNCATED file
|
|
12105
|
-
* at the real path, and a subsequent read (diff, badge endpoint) served
|
|
12106
|
-
* confident nonsense from it.
|
|
12409
|
+
* The two brand hues plus the neutral they sit on.
|
|
12107
12410
|
*
|
|
12108
|
-
*
|
|
12411
|
+
* GOLD IS SCARCE. It means forged / certified / earned / decisive — the
|
|
12412
|
+
* primary mark, the FORGED state, one call to action. It is not a paint
|
|
12413
|
+
* bucket: gold as default text, default border or default heading is a
|
|
12414
|
+
* brand-doctor finding, not a style choice.
|
|
12415
|
+
*
|
|
12416
|
+
* AURORA is verification energy — the secondary, and the hue that marks
|
|
12417
|
+
* the runtime half of the trust ladder.
|
|
12418
|
+
*/
|
|
12419
|
+
const BRAND = {
|
|
12420
|
+
gold: "#C19A34",
|
|
12421
|
+
goldBright: "#E6BD57",
|
|
12422
|
+
goldHot: "#F4DC9C",
|
|
12423
|
+
/** Pressed / deepest gold — the only step dark enough to carry white. */
|
|
12424
|
+
goldDeep: "#A5811C",
|
|
12425
|
+
aurora: "#37ABBD",
|
|
12426
|
+
auroraBright: "#45C1D4",
|
|
12427
|
+
auroraCyan: "#5CBDE0",
|
|
12428
|
+
/** The aurora's outer curtains: atmosphere and section identity only,
|
|
12429
|
+
* never a verdict or a status. */
|
|
12430
|
+
auroraGreen: "#5FD6A4",
|
|
12431
|
+
auroraViolet: "#9D8CF5",
|
|
12432
|
+
steel: "#C8CBCF",
|
|
12433
|
+
steelDim: "#8B939D"
|
|
12434
|
+
};
|
|
12435
|
+
/**
|
|
12436
|
+
* Midnight iron. One ramp, four steps, darkest first.
|
|
12437
|
+
*
|
|
12438
|
+
* `terminal` and `terminalBar` share one tone deliberately: the window's
|
|
12439
|
+
* only seam is a hairline ring and an inset shadow, never a second fill.
|
|
12440
|
+
* `chromeDot` is the three window dots — see the note on
|
|
12441
|
+
* `PENDING_TERMINAL.chromeDots` for why they are no longer red/amber/green.
|
|
12442
|
+
*/
|
|
12443
|
+
const SURFACE = {
|
|
12444
|
+
ink950: "#0A1119",
|
|
12445
|
+
ink900: "#0C1420",
|
|
12446
|
+
ink850: "#111A29",
|
|
12447
|
+
ink800: "#18243A",
|
|
12448
|
+
/** Raised panel (cards, elevated surfaces). */
|
|
12449
|
+
panel: "#141F33",
|
|
12450
|
+
/** Soft fill (inline code, quiet chips). */
|
|
12451
|
+
soft: "#1A2740",
|
|
12452
|
+
/** Terminal body — the deepest tone, so a terminal reads as recessed. */
|
|
12453
|
+
terminal: "#0A1119",
|
|
12454
|
+
/** Terminal title bar — the same tone; the seam is shadow, not colour. */
|
|
12455
|
+
terminalBar: "#0A1119",
|
|
12456
|
+
/** The three window dots. One neutral, not a traffic light. */
|
|
12457
|
+
chromeDot: "#18243A"
|
|
12458
|
+
};
|
|
12459
|
+
const TEXT = {
|
|
12460
|
+
primary: "#EAEEF5",
|
|
12461
|
+
secondary: "#ABB6C6",
|
|
12462
|
+
muted: "#8B939D",
|
|
12463
|
+
/** Ink for text set ON gold (buttons, the FORGED chip). 7.17:1 on `gold`. */
|
|
12464
|
+
onGold: "#0A1119"
|
|
12465
|
+
};
|
|
12466
|
+
/**
|
|
12467
|
+
* Non-score status. `ok` is the one green in the system and it is NOT a
|
|
12468
|
+
* score colour — it survives only for contexts with no worthiness
|
|
12469
|
+
* meaning ("autofix applied", "analysis complete"). A green score would
|
|
12470
|
+
* say "your software is fine", which is the exact claim this product
|
|
12471
|
+
* refuses to make.
|
|
12472
|
+
*/
|
|
12473
|
+
const STATUS = {
|
|
12474
|
+
ok: "#4FB477",
|
|
12475
|
+
info: "#5CC4E0",
|
|
12476
|
+
warning: "#E6BD57",
|
|
12477
|
+
error: "#EC6B66"
|
|
12478
|
+
};
|
|
12479
|
+
/**
|
|
12480
|
+
* The four ScoreState bands plus the unmeasured state. Band thresholds
|
|
12481
|
+
* and runes live in `src/reporter/presentation.ts`, which stays free of
|
|
12482
|
+
* colour — it emits a palette KEY and each surface resolves it here.
|
|
12483
|
+
*
|
|
12484
|
+
* `unmeasured` is steel-dim on purpose. UNKNOWN is a legitimate answer,
|
|
12485
|
+
* not a failure: colouring it red would make "we did not measure this"
|
|
12486
|
+
* look like "this is broken", which is precisely the dishonesty the
|
|
12487
|
+
* north-star law exists to prevent.
|
|
12488
|
+
*/
|
|
12489
|
+
const SCORE = {
|
|
12490
|
+
critical: "#EC6B66",
|
|
12491
|
+
warning: "#E6BD57",
|
|
12492
|
+
trusted: "#5CC4E0",
|
|
12493
|
+
forged: "#F4DC9C",
|
|
12494
|
+
unmeasured: "#8B939D"
|
|
12495
|
+
};
|
|
12496
|
+
/**
|
|
12497
|
+
* E0 → E1 → E2 is a certainty ramp, and it is deliberately HUE-FREE.
|
|
12498
|
+
*
|
|
12499
|
+
* Evidence level says how sure we are, not whether the news is good. A
|
|
12500
|
+
* deterministic proof (E2) is a defect we are certain about — painting
|
|
12501
|
+
* it gold or green would read as an achievement. So certainty is carried
|
|
12502
|
+
* by brightness alone, and the *shape* does the real work:
|
|
12503
|
+
*
|
|
12504
|
+
* E0 open ring observation, no weight
|
|
12505
|
+
* E1 half-filled pattern evidence, half weight
|
|
12506
|
+
* E2 sealed deterministic proof, full weight
|
|
12507
|
+
*
|
|
12508
|
+
* Colour never carries this alone (R11): the geometry is the signal and
|
|
12509
|
+
* survives `--ascii`, `NO_COLOR` and monochrome print.
|
|
12510
|
+
*/
|
|
12511
|
+
const EVIDENCE = {
|
|
12512
|
+
e0: "#8B939D",
|
|
12513
|
+
e1: "#ABB6C6",
|
|
12514
|
+
e2: "#EAEEF5"
|
|
12515
|
+
};
|
|
12516
|
+
/**
|
|
12517
|
+
* L0–L5, and the most important boundary in the product.
|
|
12518
|
+
*
|
|
12519
|
+
* L0–L2 are STATIC: the neutral steel ramp, brightening to the static
|
|
12520
|
+
* ceiling at L2. L3–L5 require a real run, and the hue changes to aurora
|
|
12521
|
+
* exactly there. The boundary is a hue break, not a gradient step,
|
|
12522
|
+
* because it is a change of kind and not of degree — a static-only
|
|
12523
|
+
* finding can never climb past L2, however confident it is.
|
|
12524
|
+
*
|
|
12525
|
+
* Every surface that draws the ladder must draw that break.
|
|
12526
|
+
*/
|
|
12527
|
+
const TRUST = {
|
|
12528
|
+
l0: "#8B939D",
|
|
12529
|
+
l1: "#ABB6C6",
|
|
12530
|
+
l2: "#C8CBCF",
|
|
12531
|
+
l3: "#37ABBD",
|
|
12532
|
+
l4: "#45C1D4",
|
|
12533
|
+
l5: "#5CC4E0"
|
|
12534
|
+
};
|
|
12535
|
+
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;
|
|
12536
|
+
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;
|
|
12537
|
+
EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2;
|
|
12538
|
+
const RUNG_MEANINGS = [
|
|
12539
|
+
"observation only",
|
|
12540
|
+
"heuristic static",
|
|
12541
|
+
"deterministic static",
|
|
12542
|
+
"the finding's file executed",
|
|
12543
|
+
"the finding's test executed",
|
|
12544
|
+
"the run verdict corroborates"
|
|
12545
|
+
];
|
|
12546
|
+
const RUNG_COLORS = [
|
|
12547
|
+
TRUST.l0,
|
|
12548
|
+
TRUST.l1,
|
|
12549
|
+
TRUST.l2,
|
|
12550
|
+
TRUST.l3,
|
|
12551
|
+
TRUST.l4,
|
|
12552
|
+
TRUST.l5
|
|
12553
|
+
];
|
|
12554
|
+
RUNG_MEANINGS.map((meaning, i) => ({
|
|
12555
|
+
level: `L${i}`,
|
|
12556
|
+
meaning,
|
|
12557
|
+
runtime: i >= 3,
|
|
12558
|
+
color: RUNG_COLORS[i]
|
|
12559
|
+
}));
|
|
12560
|
+
const HEADLINES = {
|
|
12561
|
+
critical: "The hammer is cracked — {n} findings break its edge.",
|
|
12562
|
+
warning: "The hammer holds — but {n} findings weigh it down.",
|
|
12563
|
+
trusted: "Held in worthy hands — {n} findings remain.",
|
|
12564
|
+
forged: "Static score 100 — no findings on the analyzed surface.",
|
|
12565
|
+
unmeasured: "No tests found — the hammer cannot be weighed."
|
|
12566
|
+
};
|
|
12567
|
+
const RUNES = {
|
|
12568
|
+
critical: "ᚲ",
|
|
12569
|
+
warning: "ᚦ",
|
|
12570
|
+
trusted: "ᛏ",
|
|
12571
|
+
forged: "ᛟ",
|
|
12572
|
+
unmeasured: "ᛁ"
|
|
12573
|
+
};
|
|
12574
|
+
/**
|
|
12575
|
+
* The named form of the band boundaries. `report:honesty` /
|
|
12576
|
+
* `thresholds:parity` assert that no surface outside this module
|
|
12577
|
+
* re-invents a boundary; a consumer that genuinely needs one names it
|
|
12578
|
+
* here rather than typing the number.
|
|
12579
|
+
*/
|
|
12580
|
+
const SCORE_THRESHOLDS = {
|
|
12581
|
+
/** Below this is `critical`. */
|
|
12582
|
+
criticalBelow: 50,
|
|
12583
|
+
/** At or above this is `trusted`. */
|
|
12584
|
+
trustedAtOrAbove: 80,
|
|
12585
|
+
/** The one score that is `forged`. */
|
|
12586
|
+
forgedAt: 100
|
|
12587
|
+
};
|
|
12588
|
+
/**
|
|
12589
|
+
* Band mapping — the one mapping, every consumer: <50 critical,
|
|
12590
|
+
* 50–79 warning, 80–99 trusted, 100 forged, null unmeasured.
|
|
12591
|
+
*/
|
|
12592
|
+
function deriveScoreState(score) {
|
|
12593
|
+
if (score === null) return {
|
|
12594
|
+
score: null,
|
|
12595
|
+
band: "unmeasured",
|
|
12596
|
+
verdict: "UNWORTHY",
|
|
12597
|
+
color: "dim",
|
|
12598
|
+
powerLevel: 0,
|
|
12599
|
+
headline: HEADLINES.unmeasured,
|
|
12600
|
+
rune: RUNES.unmeasured
|
|
12601
|
+
};
|
|
12602
|
+
if (score >= SCORE_THRESHOLDS.forgedAt) return {
|
|
12603
|
+
score,
|
|
12604
|
+
band: "forged",
|
|
12605
|
+
verdict: "FORGED",
|
|
12606
|
+
color: "forged",
|
|
12607
|
+
powerLevel: score,
|
|
12608
|
+
headline: HEADLINES.forged,
|
|
12609
|
+
rune: RUNES.forged
|
|
12610
|
+
};
|
|
12611
|
+
if (score >= 80) return {
|
|
12612
|
+
score,
|
|
12613
|
+
band: "trusted",
|
|
12614
|
+
verdict: "WORTHY",
|
|
12615
|
+
color: "trusted",
|
|
12616
|
+
powerLevel: score,
|
|
12617
|
+
headline: HEADLINES.trusted,
|
|
12618
|
+
rune: RUNES.trusted
|
|
12619
|
+
};
|
|
12620
|
+
if (score >= 50) return {
|
|
12621
|
+
score,
|
|
12622
|
+
band: "warning",
|
|
12623
|
+
verdict: "NEEDS WORK",
|
|
12624
|
+
color: "warning",
|
|
12625
|
+
powerLevel: score,
|
|
12626
|
+
headline: HEADLINES.warning,
|
|
12627
|
+
rune: RUNES.warning
|
|
12628
|
+
};
|
|
12629
|
+
return {
|
|
12630
|
+
score,
|
|
12631
|
+
band: "critical",
|
|
12632
|
+
verdict: "UNWORTHY",
|
|
12633
|
+
color: "error",
|
|
12634
|
+
powerLevel: score,
|
|
12635
|
+
headline: HEADLINES.critical,
|
|
12636
|
+
rune: RUNES.critical
|
|
12637
|
+
};
|
|
12638
|
+
}
|
|
12639
|
+
//#endregion
|
|
12640
|
+
//#region src/engine/monorepo-analysis.ts
|
|
12641
|
+
/**
|
|
12642
|
+
* Package verdict, derived from the one ScoreState band model (BW-104).
|
|
12643
|
+
* These two constants used to be a private copy of the band boundaries;
|
|
12644
|
+
* the numeric vocabulary here is a different projection of the same bands
|
|
12645
|
+
* — "pass" is trusted or forged, "warn" is warning, "fail" is critical or
|
|
12646
|
+
* unmeasurable — so the boundaries are read, never retyped.
|
|
12647
|
+
*/
|
|
12648
|
+
function verdictOf(score) {
|
|
12649
|
+
if (score === null) return "fail";
|
|
12650
|
+
const band = deriveScoreState(score).band;
|
|
12651
|
+
if (band === "forged" || band === "trusted") return "pass";
|
|
12652
|
+
return band === "warning" ? "warn" : "fail";
|
|
12653
|
+
}
|
|
12654
|
+
function hasBlocker(findings) {
|
|
12655
|
+
return findings.some((f) => f.severity === "error");
|
|
12656
|
+
}
|
|
12657
|
+
/**
|
|
12658
|
+
* Aggregate per-package results into an overall verdict.
|
|
12659
|
+
*
|
|
12660
|
+
* Worst-package propagation: if ANY package has a blocker (error-severity
|
|
12661
|
+
* finding) or a failing score, the overall result fails regardless of
|
|
12662
|
+
* strategy. This is the safety net — a single poisoned package must not
|
|
12663
|
+
* hide behind averaging.
|
|
12664
|
+
*/
|
|
12665
|
+
function analyzeMonorepo(packages, config) {
|
|
12666
|
+
const results = packages.map((p) => ({
|
|
12667
|
+
...p,
|
|
12668
|
+
verdict: hasBlocker(p.findings) ? "fail" : verdictOf(p.score)
|
|
12669
|
+
}));
|
|
12670
|
+
if (results.length === 0) return {
|
|
12671
|
+
packages: results,
|
|
12672
|
+
overallScore: null,
|
|
12673
|
+
overallVerdict: "fail",
|
|
12674
|
+
strategy: config.weightingStrategy
|
|
12675
|
+
};
|
|
12676
|
+
const blockerPkg = results.find((r) => r.verdict === "fail" || hasBlocker(r.findings));
|
|
12677
|
+
if (blockerPkg) return {
|
|
12678
|
+
packages: results,
|
|
12679
|
+
overallScore: blockerPkg.score,
|
|
12680
|
+
overallVerdict: "fail",
|
|
12681
|
+
strategy: config.weightingStrategy,
|
|
12682
|
+
blockerPackage: blockerPkg.packageName
|
|
12683
|
+
};
|
|
12684
|
+
switch (config.weightingStrategy) {
|
|
12685
|
+
case "worst-package": return worstPackageAggregation(results, config);
|
|
12686
|
+
case "average": return averageAggregation(results, config);
|
|
12687
|
+
case "configurable": return configurableAggregation(results, config);
|
|
12688
|
+
}
|
|
12689
|
+
}
|
|
12690
|
+
function worstPackageAggregation(results, config) {
|
|
12691
|
+
const firstResult = results[0];
|
|
12692
|
+
if (firstResult === void 0) return {
|
|
12693
|
+
packages: results,
|
|
12694
|
+
overallScore: null,
|
|
12695
|
+
overallVerdict: "fail",
|
|
12696
|
+
strategy: config.weightingStrategy
|
|
12697
|
+
};
|
|
12698
|
+
let worst = firstResult;
|
|
12699
|
+
for (const r of results) if ((r.score ?? 0) < (worst.score ?? 0)) worst = r;
|
|
12700
|
+
return {
|
|
12701
|
+
packages: results,
|
|
12702
|
+
overallScore: worst.score,
|
|
12703
|
+
overallVerdict: worst.verdict,
|
|
12704
|
+
strategy: config.weightingStrategy,
|
|
12705
|
+
...worst.verdict === "fail" ? { blockerPackage: worst.packageName } : {}
|
|
12706
|
+
};
|
|
12707
|
+
}
|
|
12708
|
+
function averageAggregation(results, config) {
|
|
12709
|
+
const scored = results.filter((r) => r.score !== null);
|
|
12710
|
+
if (scored.length === 0) return {
|
|
12711
|
+
packages: results,
|
|
12712
|
+
overallScore: null,
|
|
12713
|
+
overallVerdict: "fail",
|
|
12714
|
+
strategy: config.weightingStrategy
|
|
12715
|
+
};
|
|
12716
|
+
const avg = scored.reduce((sum, r) => sum + (r.score ?? 0), 0) / scored.length;
|
|
12717
|
+
return {
|
|
12718
|
+
packages: results,
|
|
12719
|
+
overallScore: Math.round(avg),
|
|
12720
|
+
overallVerdict: verdictOf(Math.round(avg)),
|
|
12721
|
+
strategy: config.weightingStrategy
|
|
12722
|
+
};
|
|
12723
|
+
}
|
|
12724
|
+
function configurableAggregation(results, config) {
|
|
12725
|
+
const weights = config.packageWeights ?? {};
|
|
12726
|
+
let totalWeight = 0;
|
|
12727
|
+
let weightedSum = 0;
|
|
12728
|
+
for (const r of results) {
|
|
12729
|
+
const w = weights[r.packageName] ?? 1;
|
|
12730
|
+
totalWeight += w;
|
|
12731
|
+
weightedSum += (r.score ?? 0) * w;
|
|
12732
|
+
}
|
|
12733
|
+
if (totalWeight === 0) return {
|
|
12734
|
+
packages: results,
|
|
12735
|
+
overallScore: null,
|
|
12736
|
+
overallVerdict: "fail",
|
|
12737
|
+
strategy: config.weightingStrategy
|
|
12738
|
+
};
|
|
12739
|
+
const score = Math.round(weightedSum / totalWeight);
|
|
12740
|
+
return {
|
|
12741
|
+
packages: results,
|
|
12742
|
+
overallScore: score,
|
|
12743
|
+
overallVerdict: verdictOf(score),
|
|
12744
|
+
strategy: config.weightingStrategy
|
|
12745
|
+
};
|
|
12746
|
+
}
|
|
12747
|
+
//#endregion
|
|
12748
|
+
//#region src/lib/fs-atomic.ts
|
|
12749
|
+
/**
|
|
12750
|
+
* Atomic file writes (audit S9).
|
|
12751
|
+
*
|
|
12752
|
+
* Every durability-critical write in Mjölnir (baseline, stats, badge,
|
|
12753
|
+
* TRIAGE.md, scaffolded rule files) used to hand-roll
|
|
12754
|
+
* `writeFileSync(path, data)` — a crash mid-write left a TRUNCATED file
|
|
12755
|
+
* at the real path, and a subsequent read (diff, badge endpoint) served
|
|
12756
|
+
* confident nonsense from it.
|
|
12757
|
+
*
|
|
12758
|
+
* `writeFileAtomic` writes to a temp sibling, then RENAMES. On the same
|
|
12109
12759
|
* volume rename is atomic: readers see either the complete old file or
|
|
12110
12760
|
* the complete new file, never a half-written one. The temp name is
|
|
12111
12761
|
* created with `wx` (exclusive) so concurrent writers cannot interleave,
|
|
@@ -12118,6 +12768,10 @@ function atomicTempPath(path) {
|
|
|
12118
12768
|
}
|
|
12119
12769
|
/**
|
|
12120
12770
|
* Atomically replace `path` with `data`.
|
|
12771
|
+
*
|
|
12772
|
+
* Accepts binary payloads as well as text: base-tree materialization in
|
|
12773
|
+
* `impact` writes git blobs, and a writer that only takes strings forces those
|
|
12774
|
+
* call sites back to the non-atomic path rather than keeping them contained.
|
|
12121
12775
|
*/
|
|
12122
12776
|
function writeFileAtomic(path, data, opts = {}) {
|
|
12123
12777
|
const dir = dirname(path);
|
|
@@ -12126,7 +12780,8 @@ function writeFileAtomic(path, data, opts = {}) {
|
|
|
12126
12780
|
let fd;
|
|
12127
12781
|
try {
|
|
12128
12782
|
fd = openSync(tmp, "wx", opts.mode ?? 420);
|
|
12129
|
-
writeSync(fd, data, null, opts.encoding ?? "utf8");
|
|
12783
|
+
if (typeof data === "string") writeSync(fd, data, null, opts.encoding ?? "utf8");
|
|
12784
|
+
else writeSync(fd, data);
|
|
12130
12785
|
} finally {
|
|
12131
12786
|
if (fd !== void 0) closeSync(fd);
|
|
12132
12787
|
}
|
|
@@ -12384,173 +13039,6 @@ function createScanCache(root) {
|
|
|
12384
13039
|
};
|
|
12385
13040
|
}
|
|
12386
13041
|
//#endregion
|
|
12387
|
-
//#region src/brand/tokens.ts
|
|
12388
|
-
/**
|
|
12389
|
-
* The single source of brand truth.
|
|
12390
|
-
*
|
|
12391
|
-
* Every colour, typeface and motion constant Mjölnir shows a human —
|
|
12392
|
-
* terminal, README SVGs, demo video, website, docs, badges — resolves to
|
|
12393
|
-
* a value in this file. Nothing else may define one.
|
|
12394
|
-
*
|
|
12395
|
-
* WHY THIS EXISTS. Before it, the palette existed in six independent
|
|
12396
|
-
* copies: `site/.vitepress/theme/styles/vars.css`, `NORSE` in
|
|
12397
|
-
* `src/reporter/theme.ts`, `scripts/readme-svg.ts`,
|
|
12398
|
-
* `scripts/video/terminal-page.ts`, `scripts/generate-readme-architecture.ts`
|
|
12399
|
-
* and the table in `assets/brand/README.md`. Exactly one pair of those
|
|
12400
|
-
* was guarded (site-doctor Check 8, doc ↔ vars.css). The unguarded edges
|
|
12401
|
-
* are where the shipped surfaces drifted apart: the terminal and the site
|
|
12402
|
-
* disagreed on six semantic roles, the architecture diagram invented its
|
|
12403
|
-
* own neutral ramp, and the README badges still carried a palette retired
|
|
12404
|
-
* two releases earlier. `scripts/brand-doctor.mjs` now checks every edge
|
|
12405
|
-
* against this file.
|
|
12406
|
-
*
|
|
12407
|
-
* PURITY. Pure data. No I/O, no rendering, no environment access, no
|
|
12408
|
-
* imports, no logic. Consumers convert (hex → ANSI triplet, hex → CSS)
|
|
12409
|
-
* themselves. Same reason `score-state.ts` is pure: it makes the whole
|
|
12410
|
-
* thing golden-testable and safe to ship inside the npm package, where
|
|
12411
|
-
* it costs a few hundred bytes and replaces values the package already
|
|
12412
|
-
* carried anyway.
|
|
12413
|
-
*
|
|
12414
|
-
* DERIVATION. The palette is the one derived from the logo in PR #20
|
|
12415
|
-
* (brushed steel and forge gold under an aurora, over midnight iron).
|
|
12416
|
-
* Where the terminal disagreed with it, the terminal converges — see
|
|
12417
|
-
* `PENDING_TERMINAL` below. Full rationale: `assets/brand/README.md`.
|
|
12418
|
-
*
|
|
12419
|
-
* ACCESSIBILITY. Every foreground token in `BRAND` meets WCAG AA
|
|
12420
|
-
* (≥ 4.5:1) against every surface token it is allowed to sit on. That is
|
|
12421
|
-
* not a claim, it is `brand-doctor` rule 8, which computes the ratios.
|
|
12422
|
-
* The weakest legal pairing is `steelDim` on `ink800` at 5.00:1.
|
|
12423
|
-
*/
|
|
12424
|
-
/**
|
|
12425
|
-
* The two brand hues plus the neutral they sit on.
|
|
12426
|
-
*
|
|
12427
|
-
* GOLD IS SCARCE. It means forged / certified / earned / decisive — the
|
|
12428
|
-
* primary mark, the FORGED state, one call to action. It is not a paint
|
|
12429
|
-
* bucket: gold as default text, default border or default heading is a
|
|
12430
|
-
* brand-doctor finding, not a style choice.
|
|
12431
|
-
*
|
|
12432
|
-
* AURORA is verification energy — the secondary, and the hue that marks
|
|
12433
|
-
* the runtime half of the trust ladder.
|
|
12434
|
-
*/
|
|
12435
|
-
const BRAND = {
|
|
12436
|
-
gold: "#C19A34",
|
|
12437
|
-
goldBright: "#E6BD57",
|
|
12438
|
-
goldHot: "#F4DC9C",
|
|
12439
|
-
/** Pressed / deepest gold — the only step dark enough to carry white. */
|
|
12440
|
-
goldDeep: "#A5811C",
|
|
12441
|
-
aurora: "#37ABBD",
|
|
12442
|
-
auroraBright: "#45C1D4",
|
|
12443
|
-
auroraCyan: "#5CBDE0",
|
|
12444
|
-
/** The aurora's outer curtains: atmosphere and section identity only,
|
|
12445
|
-
* never a verdict or a status. */
|
|
12446
|
-
auroraGreen: "#5FD6A4",
|
|
12447
|
-
auroraViolet: "#9D8CF5",
|
|
12448
|
-
steel: "#C8CBCF",
|
|
12449
|
-
steelDim: "#8B939D"
|
|
12450
|
-
};
|
|
12451
|
-
/**
|
|
12452
|
-
* Midnight iron. One ramp, four steps, darkest first.
|
|
12453
|
-
*
|
|
12454
|
-
* `terminal` and `terminalBar` share one tone deliberately: the window's
|
|
12455
|
-
* only seam is a hairline ring and an inset shadow, never a second fill.
|
|
12456
|
-
* `chromeDot` is the three window dots — see the note on
|
|
12457
|
-
* `PENDING_TERMINAL.chromeDots` for why they are no longer red/amber/green.
|
|
12458
|
-
*/
|
|
12459
|
-
const SURFACE = {
|
|
12460
|
-
ink950: "#0A1119",
|
|
12461
|
-
ink900: "#0C1420",
|
|
12462
|
-
ink850: "#111A29",
|
|
12463
|
-
ink800: "#18243A",
|
|
12464
|
-
/** Raised panel (cards, elevated surfaces). */
|
|
12465
|
-
panel: "#141F33",
|
|
12466
|
-
/** Soft fill (inline code, quiet chips). */
|
|
12467
|
-
soft: "#1A2740",
|
|
12468
|
-
/** Terminal body — the deepest tone, so a terminal reads as recessed. */
|
|
12469
|
-
terminal: "#0A1119",
|
|
12470
|
-
/** Terminal title bar — the same tone; the seam is shadow, not colour. */
|
|
12471
|
-
terminalBar: "#0A1119",
|
|
12472
|
-
/** The three window dots. One neutral, not a traffic light. */
|
|
12473
|
-
chromeDot: "#18243A"
|
|
12474
|
-
};
|
|
12475
|
-
const TEXT = {
|
|
12476
|
-
primary: "#EAEEF5",
|
|
12477
|
-
secondary: "#ABB6C6",
|
|
12478
|
-
muted: "#8B939D",
|
|
12479
|
-
/** Ink for text set ON gold (buttons, the FORGED chip). 7.17:1 on `gold`. */
|
|
12480
|
-
onGold: "#0A1119"
|
|
12481
|
-
};
|
|
12482
|
-
/**
|
|
12483
|
-
* Non-score status. `ok` is the one green in the system and it is NOT a
|
|
12484
|
-
* score colour — it survives only for contexts with no worthiness
|
|
12485
|
-
* meaning ("autofix applied", "analysis complete"). A green score would
|
|
12486
|
-
* say "your software is fine", which is the exact claim this product
|
|
12487
|
-
* refuses to make.
|
|
12488
|
-
*/
|
|
12489
|
-
const STATUS = {
|
|
12490
|
-
ok: "#4FB477",
|
|
12491
|
-
info: "#5CC4E0",
|
|
12492
|
-
warning: "#E6BD57",
|
|
12493
|
-
error: "#EC6B66"
|
|
12494
|
-
};
|
|
12495
|
-
/**
|
|
12496
|
-
* The four ScoreState bands plus the unmeasured state. Band thresholds
|
|
12497
|
-
* and runes live in `src/reporter/score-state.ts`, which stays free of
|
|
12498
|
-
* colour — it emits a palette KEY and each surface resolves it here.
|
|
12499
|
-
*
|
|
12500
|
-
* `unmeasured` is steel-dim on purpose. UNKNOWN is a legitimate answer,
|
|
12501
|
-
* not a failure: colouring it red would make "we did not measure this"
|
|
12502
|
-
* look like "this is broken", which is precisely the dishonesty the
|
|
12503
|
-
* north-star law exists to prevent.
|
|
12504
|
-
*/
|
|
12505
|
-
const SCORE = {
|
|
12506
|
-
critical: "#EC6B66",
|
|
12507
|
-
warning: "#E6BD57",
|
|
12508
|
-
trusted: "#5CC4E0",
|
|
12509
|
-
forged: "#F4DC9C",
|
|
12510
|
-
unmeasured: "#8B939D"
|
|
12511
|
-
};
|
|
12512
|
-
/**
|
|
12513
|
-
* E0 → E1 → E2 is a certainty ramp, and it is deliberately HUE-FREE.
|
|
12514
|
-
*
|
|
12515
|
-
* Evidence level says how sure we are, not whether the news is good. A
|
|
12516
|
-
* deterministic proof (E2) is a defect we are certain about — painting
|
|
12517
|
-
* it gold or green would read as an achievement. So certainty is carried
|
|
12518
|
-
* by brightness alone, and the *shape* does the real work:
|
|
12519
|
-
*
|
|
12520
|
-
* E0 open ring observation, no weight
|
|
12521
|
-
* E1 half-filled pattern evidence, half weight
|
|
12522
|
-
* E2 sealed deterministic proof, full weight
|
|
12523
|
-
*
|
|
12524
|
-
* Colour never carries this alone (R11): the geometry is the signal and
|
|
12525
|
-
* survives `--ascii`, `NO_COLOR` and monochrome print.
|
|
12526
|
-
*/
|
|
12527
|
-
const EVIDENCE = {
|
|
12528
|
-
e0: "#8B939D",
|
|
12529
|
-
e1: "#ABB6C6",
|
|
12530
|
-
e2: "#EAEEF5"
|
|
12531
|
-
};
|
|
12532
|
-
/**
|
|
12533
|
-
* L0–L5, and the most important boundary in the product.
|
|
12534
|
-
*
|
|
12535
|
-
* L0–L2 are STATIC: the neutral steel ramp, brightening to the static
|
|
12536
|
-
* ceiling at L2. L3–L5 require a real run, and the hue changes to aurora
|
|
12537
|
-
* exactly there. The boundary is a hue break, not a gradient step,
|
|
12538
|
-
* because it is a change of kind and not of degree — a static-only
|
|
12539
|
-
* finding can never climb past L2, however confident it is.
|
|
12540
|
-
*
|
|
12541
|
-
* Every surface that draws the ladder must draw that break.
|
|
12542
|
-
*/
|
|
12543
|
-
const TRUST = {
|
|
12544
|
-
l0: "#8B939D",
|
|
12545
|
-
l1: "#ABB6C6",
|
|
12546
|
-
l2: "#C8CBCF",
|
|
12547
|
-
l3: "#37ABBD",
|
|
12548
|
-
l4: "#45C1D4",
|
|
12549
|
-
l5: "#5CC4E0"
|
|
12550
|
-
};
|
|
12551
|
-
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;
|
|
12552
|
-
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;
|
|
12553
|
-
//#endregion
|
|
12554
13042
|
//#region src/reporter/theme.ts
|
|
12555
13043
|
/**
|
|
12556
13044
|
* Norse-forge theme system. Pure string-in → string-out helpers.
|
|
@@ -12565,8 +13053,8 @@ SCORE.trusted, SCORE.forged, SCORE.warning, SCORE.critical, STATUS.info, TEXT.on
|
|
|
12565
13053
|
*
|
|
12566
13054
|
* Symbols always accompany color (color-blind safe, R11).
|
|
12567
13055
|
*
|
|
12568
|
-
* Score-state colors come from ScoreState (
|
|
12569
|
-
* source of truth shared with the badge and
|
|
13056
|
+
* Score-state colors come from ScoreState (presentation.ts) — the single
|
|
13057
|
+
* source of truth shared with the badge, the dashboard and the site.
|
|
12570
13058
|
*
|
|
12571
13059
|
* Terminal robustness (Master-Stabilization-Plan Sprint 5 Task 22):
|
|
12572
13060
|
* box-drawing/gauge helpers accept an explicit width so callers can
|
|
@@ -13729,7 +14217,7 @@ function runForensics(target, options = {}) {
|
|
|
13729
14217
|
const base = stat.isFile() ? dirname(target) : target;
|
|
13730
14218
|
flakyMdPath = join(base, "FLAKY.md");
|
|
13731
14219
|
try {
|
|
13732
|
-
|
|
14220
|
+
writeFileAtomic(flakyMdPath, renderFlakyMd(report));
|
|
13733
14221
|
} catch {
|
|
13734
14222
|
flakyMdPath = void 0;
|
|
13735
14223
|
}
|
|
@@ -14593,8 +15081,6 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14593
15081
|
skippedFiles++;
|
|
14594
15082
|
continue;
|
|
14595
15083
|
}
|
|
14596
|
-
let fileBudgetExceeded = false;
|
|
14597
|
-
let fileRuleFailed = false;
|
|
14598
15084
|
const relPath = relative(workspace.root, path).replaceAll("\\", "/");
|
|
14599
15085
|
if (!isCiAdapter) {
|
|
14600
15086
|
const lang = {
|
|
@@ -14620,79 +15106,106 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14620
15106
|
});
|
|
14621
15107
|
let cacheKey = fileCacheKey(rulesDigest, text, identity(wantsAst ? "ast" : "regex"));
|
|
14622
15108
|
const cachedFindings = cache.lookup(cacheKey);
|
|
14623
|
-
|
|
14624
|
-
|
|
14625
|
-
|
|
14626
|
-
|
|
14627
|
-
|
|
14628
|
-
|
|
14629
|
-
|
|
14630
|
-
|
|
14631
|
-
|
|
14632
|
-
|
|
14633
|
-
|
|
14634
|
-
|
|
14635
|
-
|
|
14636
|
-
|
|
14637
|
-
|
|
14638
|
-
|
|
14639
|
-
|
|
14640
|
-
|
|
14641
|
-
|
|
14642
|
-
|
|
14643
|
-
|
|
14644
|
-
|
|
14645
|
-
|
|
14646
|
-
detail: relPath
|
|
14647
|
-
});
|
|
14648
|
-
parsed = await adapter.parseAst(parsedFile);
|
|
14649
|
-
}
|
|
14650
|
-
const actualMode = parsed ? "ast" : "regex";
|
|
14651
|
-
if (wantsAst && actualMode === "regex") {
|
|
14652
|
-
parseFallbacks++;
|
|
14653
|
-
cacheKey = fileCacheKey(rulesDigest, text, identity(actualMode));
|
|
14654
|
-
const fallbackFindings = cache.lookup(cacheKey);
|
|
14655
|
-
if (fallbackFindings) {
|
|
14656
|
-
for (const f of fallbackFindings) findings.push(f);
|
|
14657
|
-
analyzed++;
|
|
14658
|
-
continue;
|
|
15109
|
+
const executor = createExecutor("sequential", 1, async (job) => {
|
|
15110
|
+
if (job.cacheHit !== void 0) return {
|
|
15111
|
+
path: job.path,
|
|
15112
|
+
status: "CACHE_HIT",
|
|
15113
|
+
findings: [...job.cacheHit],
|
|
15114
|
+
parseFallback: false
|
|
15115
|
+
};
|
|
15116
|
+
let parseFallback = false;
|
|
15117
|
+
let fileRuleFailed = false;
|
|
15118
|
+
let fileBudgetExceeded = false;
|
|
15119
|
+
let parsedAst;
|
|
15120
|
+
try {
|
|
15121
|
+
if (adapter.parseAst && job.wantsAst) {
|
|
15122
|
+
hooks.onProgress?.({
|
|
15123
|
+
phase: "rules",
|
|
15124
|
+
done: scanned,
|
|
15125
|
+
total: testFiles.length,
|
|
15126
|
+
detail: job.path
|
|
15127
|
+
});
|
|
15128
|
+
parsedAst = await adapter.parseAst({
|
|
15129
|
+
path: job.path,
|
|
15130
|
+
text: job.text
|
|
15131
|
+
});
|
|
14659
15132
|
}
|
|
14660
|
-
|
|
14661
|
-
|
|
14662
|
-
|
|
14663
|
-
|
|
14664
|
-
|
|
14665
|
-
|
|
14666
|
-
|
|
14667
|
-
|
|
14668
|
-
|
|
14669
|
-
|
|
15133
|
+
const actualMode = parsedAst ? "ast" : "regex";
|
|
15134
|
+
if (job.wantsAst && actualMode === "regex") {
|
|
15135
|
+
parseFallback = true;
|
|
15136
|
+
parseFallbacks++;
|
|
15137
|
+
cacheKey = fileCacheKey(rulesDigest, job.text, identity(actualMode));
|
|
15138
|
+
const fallbackFindings = cache.lookup(cacheKey);
|
|
15139
|
+
if (fallbackFindings) return {
|
|
15140
|
+
path: job.path,
|
|
15141
|
+
status: "CACHE_HIT",
|
|
15142
|
+
findings: [...fallbackFindings],
|
|
15143
|
+
parseFallback: true
|
|
15144
|
+
};
|
|
14670
15145
|
}
|
|
14671
|
-
|
|
14672
|
-
|
|
14673
|
-
|
|
14674
|
-
|
|
15146
|
+
const fileForRules = parsedAst ? {
|
|
15147
|
+
path: job.path,
|
|
15148
|
+
text: job.text,
|
|
15149
|
+
ast: parsedAst.ast
|
|
15150
|
+
} : {
|
|
15151
|
+
path: job.path,
|
|
15152
|
+
text: job.text
|
|
15153
|
+
};
|
|
15154
|
+
const produced = [];
|
|
15155
|
+
adapter.runRules(activeRules, fileForRules, (f, ruleId, category) => {
|
|
15156
|
+
if (!isValidFindingRecord(f)) {
|
|
15157
|
+
fileRuleFailed = true;
|
|
15158
|
+
onRuleCrash?.(ruleId, job.path, /* @__PURE__ */ new Error(`malformed finding record rejected (severity/line/message must be present, severity ∈ error|warning|info): ${JSON.stringify(f)}`));
|
|
15159
|
+
return;
|
|
15160
|
+
}
|
|
15161
|
+
produced.push({
|
|
15162
|
+
...f,
|
|
15163
|
+
ruleId,
|
|
15164
|
+
category
|
|
15165
|
+
});
|
|
15166
|
+
}, (ruleId, error) => {
|
|
15167
|
+
fileRuleFailed = true;
|
|
15168
|
+
onRuleCrash?.(ruleId, job.path, error);
|
|
15169
|
+
}, {
|
|
15170
|
+
deadline: Math.min(deadline, Date.now() + LIMITS$1.maxFileAnalysisMs),
|
|
15171
|
+
onExceeded: () => {
|
|
15172
|
+
rulesPartial = true;
|
|
15173
|
+
skippedFiles++;
|
|
15174
|
+
truncationReasons.add("file-budget");
|
|
15175
|
+
fileBudgetExceeded = true;
|
|
15176
|
+
}
|
|
14675
15177
|
});
|
|
14676
|
-
|
|
14677
|
-
fileRuleFailed
|
|
14678
|
-
|
|
14679
|
-
|
|
14680
|
-
|
|
14681
|
-
|
|
14682
|
-
|
|
14683
|
-
|
|
14684
|
-
|
|
14685
|
-
|
|
14686
|
-
|
|
14687
|
-
|
|
14688
|
-
|
|
14689
|
-
|
|
14690
|
-
|
|
14691
|
-
|
|
14692
|
-
|
|
14693
|
-
|
|
14694
|
-
|
|
14695
|
-
|
|
15178
|
+
if (!fileRuleFailed && !fileBudgetExceeded) analyzed++;
|
|
15179
|
+
if (!fileRuleFailed) cache.store(cacheKey, produced, fileBudgetExceeded);
|
|
15180
|
+
return {
|
|
15181
|
+
path: job.path,
|
|
15182
|
+
status: "OK",
|
|
15183
|
+
findings: produced,
|
|
15184
|
+
parseFallback
|
|
15185
|
+
};
|
|
15186
|
+
} catch {
|
|
15187
|
+
if (job.wantsAst) parseFallbacks++;
|
|
15188
|
+
skippedFiles++;
|
|
15189
|
+
parseFailed++;
|
|
15190
|
+
return {
|
|
15191
|
+
path: job.path,
|
|
15192
|
+
status: "FAILED",
|
|
15193
|
+
findings: [],
|
|
15194
|
+
parseFallback
|
|
15195
|
+
};
|
|
15196
|
+
} finally {
|
|
15197
|
+
parsedAst?.dispose();
|
|
15198
|
+
}
|
|
15199
|
+
});
|
|
15200
|
+
const outcome = (await executeFiles([{
|
|
15201
|
+
path: relPath,
|
|
15202
|
+
text,
|
|
15203
|
+
wantsAst,
|
|
15204
|
+
...cachedFindings !== void 0 ? { cacheHit: cachedFindings } : {}
|
|
15205
|
+
}], executor))[0];
|
|
15206
|
+
if (outcome?.status === "CACHE_HIT" || outcome?.status === "OK") {
|
|
15207
|
+
for (const f of outcome.findings) findings.push(f);
|
|
15208
|
+
if (cachedFindings !== void 0 && outcome.status === "CACHE_HIT") analyzed++;
|
|
14696
15209
|
}
|
|
14697
15210
|
}
|
|
14698
15211
|
return {
|
|
@@ -14783,12 +15296,16 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
|
|
|
14783
15296
|
const runtimeReportPath = discoveredReport?.path;
|
|
14784
15297
|
const runtimeIncomplete = discoveredReport !== void 0 && discoveredReport.report.analysisComplete !== true;
|
|
14785
15298
|
let forensicVerdicts;
|
|
15299
|
+
let evidenceRecords = [];
|
|
14786
15300
|
if (discoveredReport && discoveredReport.report.analysisComplete === true) try {
|
|
14787
|
-
buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
|
|
15301
|
+
evidenceRecords = buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
|
|
14788
15302
|
stampRuntimeCorroboration(findings, discoveredReport.report, workspace.root);
|
|
14789
15303
|
forensicVerdicts = summarizeForensicVerdicts(discoveredReport.report);
|
|
14790
|
-
} catch {
|
|
15304
|
+
} catch {
|
|
15305
|
+
evidenceRecords = [];
|
|
15306
|
+
}
|
|
14791
15307
|
return {
|
|
15308
|
+
evidenceRecords,
|
|
14792
15309
|
testDeclarationCount,
|
|
14793
15310
|
scopeInfo,
|
|
14794
15311
|
suppressionCount,
|
|
@@ -14931,6 +15448,8 @@ function assembleScanResult(o) {
|
|
|
14931
15448
|
...o.runtimeIncomplete !== void 0 ? { runtimeIncomplete: o.runtimeIncomplete } : {},
|
|
14932
15449
|
identityIncomplete
|
|
14933
15450
|
});
|
|
15451
|
+
const repository = bindRepository(o.scanRoot.root);
|
|
15452
|
+
const candidate = readCandidateBinding(o.scanRoot.root);
|
|
14934
15453
|
const runIdentity = buildRunIdentity({
|
|
14935
15454
|
files: inputSnapshot,
|
|
14936
15455
|
rules: [...o.REVISION_BY_RULE_ID.entries()].map(([id, detectorRevision]) => ({
|
|
@@ -14943,9 +15462,17 @@ function assembleScanResult(o) {
|
|
|
14943
15462
|
trustModelVersion: TRUST_MODEL_VERSION,
|
|
14944
15463
|
scoringModelVersion: SCORING_MODEL_VERSION,
|
|
14945
15464
|
frameworkSupportMatrixVersion: FRAMEWORK_SUPPORT_MATRIX_VERSION,
|
|
14946
|
-
evidenceSchemaVersions: [1]
|
|
15465
|
+
evidenceSchemaVersions: [1],
|
|
15466
|
+
...repository,
|
|
15467
|
+
...candidate !== null ? { candidate } : {}
|
|
15468
|
+
});
|
|
15469
|
+
const evidenceGraph = buildEvidenceGraph({
|
|
15470
|
+
runId: runIdentity,
|
|
15471
|
+
...candidate !== null ? { candidate: {
|
|
15472
|
+
manifestId: candidate.manifestId,
|
|
15473
|
+
candidateSha: candidate.candidateSha
|
|
15474
|
+
} } : {}
|
|
14947
15475
|
});
|
|
14948
|
-
const evidenceGraph = buildEvidenceGraph({ runId: runIdentity });
|
|
14949
15476
|
const hasTests = o.testFileCount > 0 && o.testDeclarationCount > 0;
|
|
14950
15477
|
const suiteInvalidatedBy = [...new Set(o.findings.filter((f) => SUITE_INVALIDATING_RULE_IDS.has(f.ruleId)).map((f) => f.ruleId))].sort();
|
|
14951
15478
|
const finalScore = hasTests ? completion.partial && scopeAdjustedTotal >= 100 ? 99 : scopeAdjustedTotal : null;
|
|
@@ -14955,6 +15482,11 @@ function assembleScanResult(o) {
|
|
|
14955
15482
|
scopeIntegrity,
|
|
14956
15483
|
runIdentity,
|
|
14957
15484
|
evidenceGraph,
|
|
15485
|
+
evidence: {
|
|
15486
|
+
records: o.evidenceRecords ?? [],
|
|
15487
|
+
counts: countEvidence(o.evidenceRecords ?? []),
|
|
15488
|
+
artifact: o.runtimeReportPath ?? null
|
|
15489
|
+
},
|
|
14958
15490
|
score: finalScore,
|
|
14959
15491
|
...hasTests ? {} : { reason: "no-tests-found" },
|
|
14960
15492
|
frameworks: o.frameworks.frameworks,
|
|
@@ -16337,6 +16869,133 @@ async function handleStdioLine(line, write) {
|
|
|
16337
16869
|
write(`${JSON.stringify(response)}\n`);
|
|
16338
16870
|
}
|
|
16339
16871
|
//#endregion
|
|
16872
|
+
//#region src/integrations/sentry.ts
|
|
16873
|
+
/**
|
|
16874
|
+
* Sentry crash reporting — opt-in, and inert unless a DSN is configured.
|
|
16875
|
+
*
|
|
16876
|
+
* Why opt-in: Mjölnir ships as an `npx` CLI over other people's private
|
|
16877
|
+
* repositories. A tool that reads a codebase must never phone home on its
|
|
16878
|
+
* own, so the ONLY switch is an explicit `SENTRY_DSN` in the environment.
|
|
16879
|
+
* Absent that, every function here returns immediately and `@sentry/node` is
|
|
16880
|
+
* never imported — zero network, zero added startup cost on a path whose
|
|
16881
|
+
* whole selling point is scan speed.
|
|
16882
|
+
*
|
|
16883
|
+
* Why an optional peer dependency: `@sentry/node` is ~1.5 MB unpacked and
|
|
16884
|
+
* nothing in a code scanner needs it. Making it a hard `dependency` would
|
|
16885
|
+
* tax every install for a feature most users never turn on, so it is an
|
|
16886
|
+
* optional peer: installs on request (`npm i @sentry/node`), absent by
|
|
16887
|
+
* default, and a missing module is handled as "reporting unavailable"
|
|
16888
|
+
* rather than as a crash.
|
|
16889
|
+
*
|
|
16890
|
+
* Capture points are deliberately the two EXISTING top-level catch blocks
|
|
16891
|
+
* (cli.ts entry, mcp/stdio.ts) rather than `process.on("uncaughtException")`
|
|
16892
|
+
* / `("unhandledRejection")` handlers. Registering those would silently
|
|
16893
|
+
* change the CLI's frozen exit-code contract (§24.1) and could keep a
|
|
16894
|
+
* process alive after a fatal error. One capture point per surface is
|
|
16895
|
+
* enough to answer "did Mjölnir itself break, and where".
|
|
16896
|
+
*
|
|
16897
|
+
* What is deliberately NOT sent: no user data (`sendDefaultPii: false`), no
|
|
16898
|
+
* traced request/span data (`tracesSampleRate: 0` — a batch CLI has no
|
|
16899
|
+
* request to trace), no console breadcrumbs, and no scan findings or file
|
|
16900
|
+
* contents. Events carry the error, its stack, the release, and the surface
|
|
16901
|
+
* that raised it.
|
|
16902
|
+
*/
|
|
16903
|
+
/** The loaded SDK, or null while reporting is off/unavailable. */
|
|
16904
|
+
let sdk = null;
|
|
16905
|
+
/**
|
|
16906
|
+
* Flush budget for the process-exit paths. Long enough for one HTTPS
|
|
16907
|
+
* envelope on a slow link, short enough that a broken network cannot turn
|
|
16908
|
+
* a crashed CLI into a hung one. Sentry's own default is 2s.
|
|
16909
|
+
*/
|
|
16910
|
+
const FLUSH_TIMEOUT_MS = 2e3;
|
|
16911
|
+
/** Writes a diagnostic without touching stdout. */
|
|
16912
|
+
function warn(message) {
|
|
16913
|
+
process.stderr.write(`mjolnir: sentry — ${message}\n`);
|
|
16914
|
+
}
|
|
16915
|
+
/**
|
|
16916
|
+
* The release name Sentry groups events under. Matches the npm package
|
|
16917
|
+
* name so a release created from a tag and a release reported by the SDK
|
|
16918
|
+
* are the same release, not two half-populated ones.
|
|
16919
|
+
*/
|
|
16920
|
+
const SENTRY_RELEASE = `mjolnir-qa@${ENGINE_VERSION}`;
|
|
16921
|
+
/**
|
|
16922
|
+
* Resolve the environment tag. Unset means "a developer's machine" unless
|
|
16923
|
+
* CI says otherwise: a `npx` run is not a production event, and filing
|
|
16924
|
+
* those under `production` would poison the issue stream with noise from
|
|
16925
|
+
* end users triaging their own repos.
|
|
16926
|
+
*/
|
|
16927
|
+
function resolveEnvironment() {
|
|
16928
|
+
const fromEnv = process.env.SENTRY_ENVIRONMENT?.trim();
|
|
16929
|
+
if (fromEnv) return fromEnv;
|
|
16930
|
+
return process.env.CI ? "ci" : "local";
|
|
16931
|
+
}
|
|
16932
|
+
/**
|
|
16933
|
+
* Turn reporting on if — and only if — a DSN is configured. Safe to call
|
|
16934
|
+
* from every entry point: the second call is a no-op that returns the
|
|
16935
|
+
* already-decided answer, so a process that reaches two surfaces cannot
|
|
16936
|
+
* initialize the SDK twice.
|
|
16937
|
+
*
|
|
16938
|
+
* Returns true when reporting is live. Never throws: a monitoring
|
|
16939
|
+
* integration that can take down the tool it monitors has failed at its
|
|
16940
|
+
* one job.
|
|
16941
|
+
*/
|
|
16942
|
+
async function initSentry() {
|
|
16943
|
+
if (sdk) return true;
|
|
16944
|
+
const dsn = process.env.SENTRY_DSN?.trim();
|
|
16945
|
+
if (!dsn) return false;
|
|
16946
|
+
let loaded;
|
|
16947
|
+
try {
|
|
16948
|
+
loaded = await import("@sentry/node");
|
|
16949
|
+
} catch {
|
|
16950
|
+
warn("SENTRY_DSN is set but @sentry/node is not installed. Install it with: npm install @sentry/node");
|
|
16951
|
+
return false;
|
|
16952
|
+
}
|
|
16953
|
+
try {
|
|
16954
|
+
loaded.init({
|
|
16955
|
+
dsn,
|
|
16956
|
+
release: SENTRY_RELEASE,
|
|
16957
|
+
environment: resolveEnvironment(),
|
|
16958
|
+
tracesSampleRate: 0,
|
|
16959
|
+
sendDefaultPii: false,
|
|
16960
|
+
attachStacktrace: true
|
|
16961
|
+
});
|
|
16962
|
+
} catch (error) {
|
|
16963
|
+
warn(`SDK init failed (${error instanceof Error ? error.message : String(error)}). Crash reporting stays off.`);
|
|
16964
|
+
return false;
|
|
16965
|
+
}
|
|
16966
|
+
sdk = loaded;
|
|
16967
|
+
return true;
|
|
16968
|
+
}
|
|
16969
|
+
/**
|
|
16970
|
+
* Report one fatal error. `surface` names the entry point that raised it
|
|
16971
|
+
* ("cli" or "mcp") so the issue stream can be split by how Mjölnir was
|
|
16972
|
+
* being used when it broke. A non-Error throw is reported as-is rather
|
|
16973
|
+
* than dropped: a hostile throw value is exactly the case worth seeing.
|
|
16974
|
+
*/
|
|
16975
|
+
function captureInternalError(error, surface) {
|
|
16976
|
+
if (!sdk) return;
|
|
16977
|
+
try {
|
|
16978
|
+
sdk.captureException(error, { tags: { surface } });
|
|
16979
|
+
} catch (captureError) {
|
|
16980
|
+
warn(`capture failed (${captureError instanceof Error ? captureError.message : String(captureError)}).`);
|
|
16981
|
+
}
|
|
16982
|
+
}
|
|
16983
|
+
/**
|
|
16984
|
+
* Deliver anything buffered before the process exits. Awaits the SDK's
|
|
16985
|
+
* own transport drain and returns whether the envelope was handed off;
|
|
16986
|
+
* the caller is on an exit path, so the answer is deliberately not
|
|
16987
|
+
* surfaced as a failure — a lost crash report must not change the exit
|
|
16988
|
+
* code a caller (or CI) is about to read.
|
|
16989
|
+
*/
|
|
16990
|
+
async function flushSentry(timeoutMs = FLUSH_TIMEOUT_MS) {
|
|
16991
|
+
if (!sdk) return false;
|
|
16992
|
+
try {
|
|
16993
|
+
return await sdk.flush(timeoutMs);
|
|
16994
|
+
} catch {
|
|
16995
|
+
return false;
|
|
16996
|
+
}
|
|
16997
|
+
}
|
|
16998
|
+
//#endregion
|
|
16340
16999
|
//#region src/mcp/stdio.ts
|
|
16341
17000
|
/**
|
|
16342
17001
|
* The standalone MCP stdio binary (blueprint §21), built to
|
|
@@ -16355,9 +17014,14 @@ async function handleStdioLine(line, write) {
|
|
|
16355
17014
|
* subprocess coverage cannot merge into the parent istanbul report,
|
|
16356
17015
|
* same class as dist/**), so it is excluded from the coverage ratchet.
|
|
16357
17016
|
*/
|
|
16358
|
-
|
|
17017
|
+
initSentry();
|
|
17018
|
+
runStdioTransport(process.stdin, process.stdout).then(() => process.exit(0)).catch(async (err) => {
|
|
17019
|
+
captureInternalError(err, "mcp");
|
|
17020
|
+
await flushSentry();
|
|
16359
17021
|
process.stderr.write(`mjolnir mcp fatal: ${err instanceof Error ? err.message : err}\n`);
|
|
16360
17022
|
process.exitCode = 20;
|
|
16361
17023
|
});
|
|
16362
17024
|
//#endregion
|
|
16363
17025
|
export {};
|
|
17026
|
+
|
|
17027
|
+
//# sourceMappingURL=stdio.mjs.map
|