mjolnir-qa 3.0.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +628 -6
- package/README.ar.md +58 -29
- package/README.bn.md +58 -29
- package/README.br.md +58 -29
- package/README.bs.md +58 -29
- package/README.da.md +59 -30
- package/README.de.md +59 -30
- package/README.es.md +58 -29
- package/README.fr.md +59 -30
- package/README.gr.md +58 -29
- package/README.he.md +58 -29
- package/README.it.md +58 -29
- package/README.ja.md +58 -29
- package/README.ko.md +58 -29
- package/README.md +95 -56
- package/README.no.md +58 -29
- package/README.pl.md +58 -29
- package/README.ru.md +58 -29
- package/README.th.md +58 -29
- package/README.tr.md +58 -29
- package/README.uk.md +58 -29
- package/README.vi.md +58 -29
- package/README.zh.md +58 -29
- package/README.zht.md +58 -29
- package/dist/cli.d.mts +398 -17
- package/dist/cli.mjs +2893 -1005
- package/dist/mcp/stdio.mjs +1576 -715
- package/dist/scan-pipeline-DNYbnWjE.mjs +2 -0
- package/dist/{scan-pipeline-CAH9_Qgh.mjs → scan-pipeline-DT8DWAMl.mjs} +1777 -978
- package/package.json +70 -9
- package/dist/scan-pipeline-CRe7-MQn.mjs +0 -2
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)
|
|
@@ -59,6 +85,7 @@ const DEDUCTIONS = {
|
|
|
59
85
|
//#region src/engine/completion.ts
|
|
60
86
|
function deriveCompletion(input) {
|
|
61
87
|
const truncationReasons = [...new Set(input.truncationReasons)].sort();
|
|
88
|
+
const degradations = [...input.degradations ?? []].sort((a, b) => a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0);
|
|
62
89
|
const reasons = /* @__PURE__ */ new Set();
|
|
63
90
|
if (input.discoveryTruncated) reasons.add("discovery-truncated");
|
|
64
91
|
if (input.rulesPartial) reasons.add("rules-partial");
|
|
@@ -68,14 +95,16 @@ function deriveCompletion(input) {
|
|
|
68
95
|
if (input.scopeUnrecognized > 0) reasons.add(`scope-unrecognized:${input.scopeUnrecognized}`);
|
|
69
96
|
if (input.parseFailed > 0) reasons.add(`parse-failed:${input.parseFailed}`);
|
|
70
97
|
if (input.parseFallbacks && input.parseFallbacks > 0) reasons.add(`parse-fallbacks:${input.parseFallbacks}`);
|
|
98
|
+
if (input.astFallbackFiles && input.astFallbackFiles > 0) reasons.add(`ast-budget-fallback-files:${input.astFallbackFiles}`);
|
|
71
99
|
if (input.scopeDegraded) reasons.add(`scope-degraded:${input.scopeDegraded}`);
|
|
72
100
|
if (input.runtimeIncomplete) reasons.add("runtime-incomplete");
|
|
73
101
|
if (input.identityIncomplete) reasons.add("identity-incomplete");
|
|
74
102
|
for (const reason of truncationReasons) reasons.add(`truncated:${reason}`);
|
|
103
|
+
for (const entry of degradations) reasons.add(`degraded:${entry.reason}:${entry.count}`);
|
|
75
104
|
const discoveryPartial = input.discoveryTruncated || input.scopeIgnored > 0 || input.scopeUnrecognized > 0 || input.scopeDegraded !== void 0;
|
|
76
105
|
const rulesPartial = input.rulesPartial || input.rulesCrashed > 0 || input.parseFailed > 0;
|
|
77
106
|
return {
|
|
78
|
-
partial: discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0,
|
|
107
|
+
partial: discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0 || degradations.length > 0,
|
|
79
108
|
analysisStatus: {
|
|
80
109
|
discovery: discoveryPartial ? "partial" : "complete",
|
|
81
110
|
rules: rulesPartial ? "partial" : "complete",
|
|
@@ -83,11 +112,49 @@ function deriveCompletion(input) {
|
|
|
83
112
|
rulesCrashed: input.rulesCrashed,
|
|
84
113
|
parseFallbacks: input.parseFallbacks ?? 0,
|
|
85
114
|
...truncationReasons.length > 0 ? { truncationReasons } : {},
|
|
115
|
+
...degradations.length > 0 ? { degradations } : {},
|
|
86
116
|
reasons: [...reasons].sort()
|
|
87
117
|
}
|
|
88
118
|
};
|
|
89
119
|
}
|
|
90
120
|
//#endregion
|
|
121
|
+
//#region src/engine/degradation-ledger.ts
|
|
122
|
+
/**
|
|
123
|
+
* Append-only. Records are never edited and never removed by a recording
|
|
124
|
+
* site; a reader takes a window, and the window is defined by index, so
|
|
125
|
+
* "delete my entry" is not an operation this module offers.
|
|
126
|
+
*/
|
|
127
|
+
const records = [];
|
|
128
|
+
/** Record one capability loss. `count` is for a site that fails N at once. */
|
|
129
|
+
function recordDegradation(reason, count = 1) {
|
|
130
|
+
if (!Number.isInteger(count) || count < 1) return;
|
|
131
|
+
for (let index = 0; index < count; index++) records.push({ reason });
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Mark where this reader's window opens. Everything appended from here on is
|
|
135
|
+
* what the reader will see; see the file header for what a concurrent scan
|
|
136
|
+
* does to that boundary.
|
|
137
|
+
*/
|
|
138
|
+
function beginDegradationWindow() {
|
|
139
|
+
return records.length;
|
|
140
|
+
}
|
|
141
|
+
/** The records appended since `marker`, without consuming them. */
|
|
142
|
+
function degradationsSince(marker) {
|
|
143
|
+
return records.slice(marker);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Per-reason counts for a window, sorted by reason so the output is
|
|
147
|
+
* byte-deterministic for the same set of degradations.
|
|
148
|
+
*/
|
|
149
|
+
function summarizeDegradations(window) {
|
|
150
|
+
const counts = /* @__PURE__ */ new Map();
|
|
151
|
+
for (const record of window) counts.set(record.reason, (counts.get(record.reason) ?? 0) + 1);
|
|
152
|
+
return [...counts.entries()].map(([reason, count]) => ({
|
|
153
|
+
reason,
|
|
154
|
+
count
|
|
155
|
+
})).sort((a, b) => a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0);
|
|
156
|
+
}
|
|
157
|
+
//#endregion
|
|
91
158
|
//#region src/engine/runtime-corroboration.ts
|
|
92
159
|
/**
|
|
93
160
|
* Stamp runtime corroboration + trust levels onto findings (mutates in
|
|
@@ -701,21 +768,13 @@ const MEASURED_FP = {
|
|
|
701
768
|
* empty scan is an observation, not a proof).
|
|
702
769
|
*/
|
|
703
770
|
function summaryTrustLevel(findings) {
|
|
704
|
-
const order = [
|
|
705
|
-
"L0",
|
|
706
|
-
"L1",
|
|
707
|
-
"L2",
|
|
708
|
-
"L3",
|
|
709
|
-
"L4",
|
|
710
|
-
"L5"
|
|
711
|
-
];
|
|
712
771
|
let best = 0;
|
|
713
772
|
for (const f of findings) {
|
|
714
773
|
const t = f.trustLevel ?? "L2";
|
|
715
|
-
const idx =
|
|
774
|
+
const idx = TRUST_ORDER.indexOf(t);
|
|
716
775
|
if (idx > best) best = idx;
|
|
717
776
|
}
|
|
718
|
-
return
|
|
777
|
+
return TRUST_ORDER[best];
|
|
719
778
|
}
|
|
720
779
|
/**
|
|
721
780
|
* A finding's trust rung (0–5) on the canonical ladder. Corroborated
|
|
@@ -726,14 +785,7 @@ function summaryTrustLevel(findings) {
|
|
|
726
785
|
*/
|
|
727
786
|
function trustRung(f) {
|
|
728
787
|
const t = f.trustLevel ?? deriveTrustLevel(f);
|
|
729
|
-
return
|
|
730
|
-
"L0",
|
|
731
|
-
"L1",
|
|
732
|
-
"L2",
|
|
733
|
-
"L3",
|
|
734
|
-
"L4",
|
|
735
|
-
"L5"
|
|
736
|
-
].indexOf(t);
|
|
788
|
+
return TRUST_ORDER.indexOf(t);
|
|
737
789
|
}
|
|
738
790
|
/**
|
|
739
791
|
* Hard incompleteness ceilings (plan §6: partial/crash/truncation;
|
|
@@ -870,6 +922,17 @@ function buildRunIdentity(input) {
|
|
|
870
922
|
engineVersion: input.engineVersion
|
|
871
923
|
};
|
|
872
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
|
+
};
|
|
873
936
|
if (input.trustModelVersion !== void 0) verdictInputs.trustModelVersion = input.trustModelVersion;
|
|
874
937
|
if (input.scoringModelVersion !== void 0) verdictInputs.scoringModelVersion = input.scoringModelVersion;
|
|
875
938
|
if (input.frameworkSupportMatrixVersion !== void 0) verdictInputs.frameworkSupportMatrixVersion = input.frameworkSupportMatrixVersion;
|
|
@@ -877,15 +940,31 @@ function buildRunIdentity(input) {
|
|
|
877
940
|
if (input.suppressionFingerprint !== void 0) verdictInputs.suppressionFingerprint = input.suppressionFingerprint;
|
|
878
941
|
if (input.policyFingerprint !== void 0) verdictInputs.policyFingerprint = input.policyFingerprint;
|
|
879
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");
|
|
880
954
|
return {
|
|
881
|
-
scanId
|
|
955
|
+
scanId,
|
|
882
956
|
inputFingerprint,
|
|
883
957
|
rulesDigest,
|
|
884
958
|
configFingerprint,
|
|
885
959
|
engineVersion: input.engineVersion,
|
|
960
|
+
boundLinks,
|
|
886
961
|
...input.trustModelVersion !== void 0 ? { trustModelVersion: input.trustModelVersion } : {},
|
|
887
962
|
...input.scoringModelVersion !== void 0 ? { scoringModelVersion: input.scoringModelVersion } : {},
|
|
888
|
-
...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 } : {}
|
|
889
968
|
};
|
|
890
969
|
}
|
|
891
970
|
/**
|
|
@@ -926,9 +1005,353 @@ function buildEvidenceGraph(parts) {
|
|
|
926
1005
|
...parts.reproduction !== void 0 ? { ref: parts.reproduction } : {}
|
|
927
1006
|
}
|
|
928
1007
|
],
|
|
929
|
-
...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/scope/git-resolve.ts
|
|
1109
|
+
/**
|
|
1110
|
+
* Absolute-path git resolution (audit S1).
|
|
1111
|
+
*
|
|
1112
|
+
* `execFileSync("git", …)` resolves through PATH — including the CWD on
|
|
1113
|
+
* Windows (CreateProcess searches the current directory before PATH for
|
|
1114
|
+
* extension-less names depending on NoDefaultCurrentDirectoryInExePath).
|
|
1115
|
+
* Scanning an UNTRUSTED repo therefore let a checked-in `git.exe` (or
|
|
1116
|
+
* `git.bat`/`git.cmd`) hijack Mjölnir's own git invocations: the attacker
|
|
1117
|
+
* controls the diff output, the merge-base decision, and
|
|
1118
|
+
* `--scope changed`'s "what is new" answer — the exact data CI gates on.
|
|
1119
|
+
*
|
|
1120
|
+
* The fix: resolve git ONCE per process to an ABSOLUTE path by walking
|
|
1121
|
+
* the PATH directly (never consulting the CWD), verify the candidate is
|
|
1122
|
+
* an existing file, and pass that path to execFileSync. Resolution is
|
|
1123
|
+
* memoized; a failure to find a real git anywhere on PATH is recorded
|
|
1124
|
+
* and callers degrade without invoking a bare name.
|
|
1125
|
+
*/
|
|
1126
|
+
let resolvedGit = void 0;
|
|
1127
|
+
/**
|
|
1128
|
+
* The absolute path of the git binary Mjölnir will invoke, or null when
|
|
1129
|
+
* PATH carries no executable `git` at all. Callers must degrade without
|
|
1130
|
+
* invoking a bare name.
|
|
1131
|
+
*/
|
|
1132
|
+
function resolveGitPath() {
|
|
1133
|
+
if (resolvedGit !== void 0) return resolvedGit;
|
|
1134
|
+
const candidates = [process.platform === "win32" ? "git.exe" : "git"];
|
|
1135
|
+
if (process.platform === "win32") {
|
|
1136
|
+
const pathExt = process.env["PATHEXT"]?.split(";").filter(Boolean) ?? [];
|
|
1137
|
+
for (const ext of pathExt) candidates.push(`git${ext.toLowerCase()}`);
|
|
1138
|
+
}
|
|
1139
|
+
const pathValue = process.env["PATH"] ?? "";
|
|
1140
|
+
for (const dir of pathValue.split(delimiter)) {
|
|
1141
|
+
if (dir === "") continue;
|
|
1142
|
+
if (!isAbsolute(dir)) continue;
|
|
1143
|
+
for (const candidate of candidates) {
|
|
1144
|
+
const full = join(dir, candidate);
|
|
1145
|
+
try {
|
|
1146
|
+
if (existsSync(full)) {
|
|
1147
|
+
resolvedGit = full;
|
|
1148
|
+
return resolvedGit;
|
|
1149
|
+
}
|
|
1150
|
+
} catch {}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
`${pathValue.length}`;
|
|
1154
|
+
resolvedGit = null;
|
|
1155
|
+
return resolvedGit;
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Run git with the S1-resolved absolute binary path. Returns stdout, or
|
|
1159
|
+
* null when git is missing or the command fails — the same contract as
|
|
1160
|
+
* the previous inline `git()` helpers, with the hijack surface closed.
|
|
1161
|
+
*/
|
|
1162
|
+
function runGit(root, args) {
|
|
1163
|
+
const exe = resolveGitPath();
|
|
1164
|
+
if (!exe) return null;
|
|
1165
|
+
try {
|
|
1166
|
+
return execFileSync(exe, [
|
|
1167
|
+
"-C",
|
|
1168
|
+
root,
|
|
1169
|
+
...args
|
|
1170
|
+
], {
|
|
1171
|
+
encoding: "utf8",
|
|
1172
|
+
stdio: [
|
|
1173
|
+
"ignore",
|
|
1174
|
+
"pipe",
|
|
1175
|
+
"ignore"
|
|
1176
|
+
],
|
|
1177
|
+
timeout: 15e3
|
|
1178
|
+
});
|
|
1179
|
+
} catch {
|
|
1180
|
+
return null;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
//#endregion
|
|
1184
|
+
//#region src/lib/safe-json.ts
|
|
1185
|
+
/**
|
|
1186
|
+
* Reusable safe-JSON parse-and-validate helper.
|
|
1187
|
+
*
|
|
1188
|
+
* Replaces the `JSON.parse(text) as T` pattern with a two-step process:
|
|
1189
|
+
* 1. Try/catch around JSON.parse (descriptive error on bad JSON)
|
|
1190
|
+
* 2. Runtime validation of the parsed shape (caller-supplied predicate)
|
|
1191
|
+
*
|
|
1192
|
+
* The `validate` predicate is intentionally lightweight — it checks the
|
|
1193
|
+
* expected shape exists rather than exhaustively type-guarding every field.
|
|
1194
|
+
* Internal files (cache, hashes) use `isRecord`; user-facing files
|
|
1195
|
+
* (config, plugins) use stricter checks that throw descriptive errors.
|
|
1196
|
+
*/
|
|
1197
|
+
const MAX_JSON_BYTES = 16777216;
|
|
1198
|
+
function parseJsonFile(text, source, validate) {
|
|
1199
|
+
if (Buffer.byteLength(text, "utf8") > MAX_JSON_BYTES) throw new Error(`JSON source exceeds the ${MAX_JSON_BYTES}-byte limit: ${source}`);
|
|
1200
|
+
let parsed;
|
|
1201
|
+
try {
|
|
1202
|
+
parsed = JSON.parse(text);
|
|
1203
|
+
} catch (err) {
|
|
1204
|
+
throw new Error(`invalid JSON in ${source}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
1205
|
+
}
|
|
1206
|
+
if (!validate(parsed)) throw new Error(`unexpected shape in ${source}`, { cause: parsed });
|
|
1207
|
+
return parsed;
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Minimal validator: value is a plain object (not null, not array).
|
|
1211
|
+
* Sufficient for internal files (cache, hashes) where downstream
|
|
1212
|
+
* code already checks individual fields.
|
|
1213
|
+
*/
|
|
1214
|
+
function isRecord(v) {
|
|
1215
|
+
try {
|
|
1216
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1217
|
+
} catch {
|
|
1218
|
+
return false;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
//#endregion
|
|
1222
|
+
//#region src/engine/candidate-binding.ts
|
|
1223
|
+
/**
|
|
1224
|
+
* Candidate binding (plan V5-010).
|
|
1225
|
+
*
|
|
1226
|
+
* Reads a candidate trust manifest and produces the immutable
|
|
1227
|
+
* `CandidateBinding` that a run identity is bound to. It also computes the
|
|
1228
|
+
* repository facts (commit, tree, lockfile digest) that the identity was
|
|
1229
|
+
* missing until this module existed.
|
|
1230
|
+
*
|
|
1231
|
+
* The law here is ABSENCE over invention. A manifest that is missing,
|
|
1232
|
+
* unreadable, or internally contradictory yields NO binding — not a partial
|
|
1233
|
+
* one. A consumer that can see "not bound" can refuse to trust the run; a
|
|
1234
|
+
* consumer handed a half-filled binding cannot tell what is missing, and will
|
|
1235
|
+
* trust the parts that are there.
|
|
1236
|
+
*/
|
|
1237
|
+
const CANDIDATE_MANIFEST_PATH = "candidate-trust-manifest.json";
|
|
1238
|
+
const SHA40 = /^[a-f0-9]{40}$/;
|
|
1239
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
1240
|
+
const STATES = ["WORKING_CANDIDATE", "RELEASE_CANDIDATE"];
|
|
1241
|
+
const AUTHORIZATION = ["NOT_AUTHORIZED", "AUTHORIZED"];
|
|
1242
|
+
function sha256File(path) {
|
|
1243
|
+
try {
|
|
1244
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
1245
|
+
} catch {
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* One git line, or nothing.
|
|
1251
|
+
*
|
|
1252
|
+
* Audit S1: the binary is the ABSOLUTE path `resolveGitPath()` found by
|
|
1253
|
+
* walking PATH itself, never the bare name. `bindRepository()` runs on every
|
|
1254
|
+
* scan (`scan-pipeline.ts`), and a bare `execFileSync("git", …)` lets a
|
|
1255
|
+
* `git.exe`/`git.bat`/`git.cmd` CHECKED INTO AN UNTRUSTED REPO hijack the
|
|
1256
|
+
* call on Windows, where CreateProcess searches the current directory before
|
|
1257
|
+
* PATH. The attacker then controls the `commit` and `tree` that land in
|
|
1258
|
+
* `runIdentity` and the evidence graph — the provenance a release decision
|
|
1259
|
+
* reads. This was the last bare-name exec left in `src/`; the invariant that
|
|
1260
|
+
* keeps it that way is `tests/contract/no-bare-process-exec.spec.ts`.
|
|
1261
|
+
*
|
|
1262
|
+
* The `-C <root>` shape (not `cwd`) matches every other hardened call site.
|
|
1263
|
+
* With no git on PATH at all, the answer is nothing — a scan of an unpacked
|
|
1264
|
+
* tarball has no commit, and its identity must say so rather than carrying a
|
|
1265
|
+
* placeholder that looks like provenance.
|
|
1266
|
+
*/
|
|
1267
|
+
function gitLine(root, args) {
|
|
1268
|
+
const exe = resolveGitPath();
|
|
1269
|
+
if (!exe) return void 0;
|
|
1270
|
+
try {
|
|
1271
|
+
const trimmed = execFileSync(exe, [
|
|
1272
|
+
"-C",
|
|
1273
|
+
root,
|
|
1274
|
+
...args
|
|
1275
|
+
], {
|
|
1276
|
+
encoding: "utf8",
|
|
1277
|
+
stdio: [
|
|
1278
|
+
"ignore",
|
|
1279
|
+
"pipe",
|
|
1280
|
+
"ignore"
|
|
1281
|
+
],
|
|
1282
|
+
windowsHide: true,
|
|
1283
|
+
timeout: 15e3
|
|
1284
|
+
}).trim();
|
|
1285
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
1286
|
+
} catch {
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* The commit and tree the working tree is at. Both absent outside a
|
|
1292
|
+
* repository or with no commits — never substituted with a placeholder.
|
|
1293
|
+
*/
|
|
1294
|
+
function bindRepository(root) {
|
|
1295
|
+
const commit = gitLine(root, ["rev-parse", "HEAD"]);
|
|
1296
|
+
const tree = gitLine(root, ["rev-parse", "HEAD^{tree}"]);
|
|
1297
|
+
const lockfile = sha256File(join(root, "package-lock.json"));
|
|
1298
|
+
return {
|
|
1299
|
+
...commit !== void 0 ? { commit } : {},
|
|
1300
|
+
...tree !== void 0 ? { tree } : {},
|
|
1301
|
+
...lockfile !== void 0 ? { lockfile } : {}
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
/**
|
|
1305
|
+
* Parse a candidate manifest into a binding, or return null when it cannot
|
|
1306
|
+
* honestly be bound.
|
|
1307
|
+
*
|
|
1308
|
+
* Null is returned for: no manifest, unreadable JSON, a missing or malformed
|
|
1309
|
+
* required field, or a state/authorization combination that cannot legally
|
|
1310
|
+
* exist (a WORKING_CANDIDATE carrying a commit, for example — the same
|
|
1311
|
+
* contradiction the release decision evaluator treats as BLOCKED).
|
|
1312
|
+
*/
|
|
1313
|
+
function parseCandidateBinding(raw) {
|
|
1314
|
+
if (!isRecord(raw)) return null;
|
|
1315
|
+
const identity = raw.identity;
|
|
1316
|
+
if (!isRecord(identity)) return null;
|
|
1317
|
+
const state = identity.state;
|
|
1318
|
+
const candidateSha = identity.candidateSha;
|
|
1319
|
+
const baseSha = identity.baseSha;
|
|
1320
|
+
const packageSha256 = identity.packageSha256;
|
|
1321
|
+
const lockfileSha256 = identity.lockfileSha256;
|
|
1322
|
+
if (typeof state !== "string" || !STATES.includes(state)) return null;
|
|
1323
|
+
if (typeof baseSha !== "string" || !SHA40.test(baseSha)) return null;
|
|
1324
|
+
if (typeof packageSha256 !== "string" || !SHA256.test(packageSha256)) return null;
|
|
1325
|
+
if (typeof lockfileSha256 !== "string" || !SHA256.test(lockfileSha256)) return null;
|
|
1326
|
+
if (candidateSha !== null && (typeof candidateSha !== "string" || !SHA40.test(candidateSha))) return null;
|
|
1327
|
+
const manifestId = raw.manifestId;
|
|
1328
|
+
if (typeof manifestId !== "string" || manifestId.length === 0) return null;
|
|
1329
|
+
const owner = typeof raw.owner === "string" ? raw.owner : "UNASSIGNED";
|
|
1330
|
+
const authorization = raw.releaseAuthorizationState;
|
|
1331
|
+
if (typeof authorization !== "string" || !AUTHORIZATION.includes(authorization)) return null;
|
|
1332
|
+
if (state === "WORKING_CANDIDATE") {
|
|
1333
|
+
if (candidateSha !== null) return null;
|
|
1334
|
+
if (authorization !== "NOT_AUTHORIZED") return null;
|
|
1335
|
+
} else if (candidateSha === null) return null;
|
|
1336
|
+
return {
|
|
1337
|
+
manifestId,
|
|
1338
|
+
state,
|
|
1339
|
+
candidateSha,
|
|
1340
|
+
baseSha,
|
|
1341
|
+
packageSha256,
|
|
1342
|
+
lockfileSha256,
|
|
1343
|
+
owner,
|
|
1344
|
+
releaseAuthorizationState: authorization
|
|
930
1345
|
};
|
|
931
1346
|
}
|
|
1347
|
+
/** Read and bind the manifest from a checkout, or null. */
|
|
1348
|
+
function readCandidateBinding(root, manifestPath = CANDIDATE_MANIFEST_PATH) {
|
|
1349
|
+
try {
|
|
1350
|
+
return parseCandidateBinding(JSON.parse(readFileSync(join(root, manifestPath), "utf8")));
|
|
1351
|
+
} catch {
|
|
1352
|
+
return null;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
932
1355
|
//#endregion
|
|
933
1356
|
//#region src/engine/version.ts
|
|
934
1357
|
/**
|
|
@@ -939,7 +1362,7 @@ function buildEvidenceGraph(parts) {
|
|
|
939
1362
|
* scripts/sync-sarif-version.cjs and guarded by the version-consistency
|
|
940
1363
|
* spec. cli.ts re-exports this as CLI_VERSION.
|
|
941
1364
|
*/
|
|
942
|
-
const ENGINE_VERSION = "
|
|
1365
|
+
const ENGINE_VERSION = "5.0.0";
|
|
943
1366
|
//#endregion
|
|
944
1367
|
//#region src/engine/contract-versions.ts
|
|
945
1368
|
/**
|
|
@@ -1068,9 +1491,12 @@ function discoverWorkspace(rootDir) {
|
|
|
1068
1491
|
const root = findProjectRoot(rootDir);
|
|
1069
1492
|
if (!root) return null;
|
|
1070
1493
|
let pkg = {};
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1494
|
+
const manifestPath = join(root, "package.json");
|
|
1495
|
+
if (existsSync(manifestPath)) try {
|
|
1496
|
+
pkg = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
1497
|
+
} catch {
|
|
1498
|
+
recordDegradation("workspace-manifest-unreadable");
|
|
1499
|
+
}
|
|
1074
1500
|
const rawWorkspaces = pkg["workspaces"];
|
|
1075
1501
|
let globs = [];
|
|
1076
1502
|
if (Array.isArray(rawWorkspaces)) globs = rawWorkspaces.filter((w) => typeof w === "string");
|
|
@@ -1087,40 +1513,6 @@ function basename$1(p) {
|
|
|
1087
1513
|
return parts[parts.length - 1];
|
|
1088
1514
|
}
|
|
1089
1515
|
//#endregion
|
|
1090
|
-
//#region src/lib/safe-json.ts
|
|
1091
|
-
/**
|
|
1092
|
-
* Reusable safe-JSON parse-and-validate helper.
|
|
1093
|
-
*
|
|
1094
|
-
* Replaces the `JSON.parse(text) as T` pattern with a two-step process:
|
|
1095
|
-
* 1. Try/catch around JSON.parse (descriptive error on bad JSON)
|
|
1096
|
-
* 2. Runtime validation of the parsed shape (caller-supplied predicate)
|
|
1097
|
-
*
|
|
1098
|
-
* The `validate` predicate is intentionally lightweight — it checks the
|
|
1099
|
-
* expected shape exists rather than exhaustively type-guarding every field.
|
|
1100
|
-
* Internal files (cache, hashes) use `isRecord`; user-facing files
|
|
1101
|
-
* (config, plugins) use stricter checks that throw descriptive errors.
|
|
1102
|
-
*/
|
|
1103
|
-
const MAX_JSON_BYTES = 16777216;
|
|
1104
|
-
function parseJsonFile(text, source, validate) {
|
|
1105
|
-
if (Buffer.byteLength(text, "utf8") > MAX_JSON_BYTES) throw new Error(`JSON source exceeds the ${MAX_JSON_BYTES}-byte limit: ${source}`);
|
|
1106
|
-
let parsed;
|
|
1107
|
-
try {
|
|
1108
|
-
parsed = JSON.parse(text);
|
|
1109
|
-
} catch (err) {
|
|
1110
|
-
throw new Error(`invalid JSON in ${source}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
1111
|
-
}
|
|
1112
|
-
if (!validate(parsed)) throw new Error(`unexpected shape in ${source}`, { cause: parsed });
|
|
1113
|
-
return parsed;
|
|
1114
|
-
}
|
|
1115
|
-
/**
|
|
1116
|
-
* Minimal validator: value is a plain object (not null, not array).
|
|
1117
|
-
* Sufficient for internal files (cache, hashes) where downstream
|
|
1118
|
-
* code already checks individual fields.
|
|
1119
|
-
*/
|
|
1120
|
-
function isRecord(v) {
|
|
1121
|
-
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1122
|
-
}
|
|
1123
|
-
//#endregion
|
|
1124
1516
|
//#region src/lib/fs-bounded.ts
|
|
1125
1517
|
function closeQuiet(fd) {
|
|
1126
1518
|
try {
|
|
@@ -1347,20 +1739,15 @@ function validate(cfg, knownRuleIds) {
|
|
|
1347
1739
|
return warnings;
|
|
1348
1740
|
}
|
|
1349
1741
|
/**
|
|
1350
|
-
*
|
|
1351
|
-
*
|
|
1352
|
-
*
|
|
1353
|
-
* documented window.
|
|
1742
|
+
* Expiry is evaluated only from an explicit ISO `expires` date. A missing
|
|
1743
|
+
* date remains active and is reported as having no expiry. Config-file mtime
|
|
1744
|
+
* is never an expiry anchor.
|
|
1354
1745
|
*
|
|
1355
|
-
*
|
|
1356
|
-
*
|
|
1357
|
-
*
|
|
1358
|
-
*
|
|
1359
|
-
*
|
|
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).
|
|
1746
|
+
* `now` defaults to the current instant so existing callers stay correct, but
|
|
1747
|
+
* a verdict function that reads the clock invisibly is a verdict nobody can
|
|
1748
|
+
* test at a boundary. `tests/contract/deterministic-clock.spec.ts` audits
|
|
1749
|
+
* `src/config/**` for a call site that supplies its own `new Date()` instead
|
|
1750
|
+
* of threading one through.
|
|
1364
1751
|
*/
|
|
1365
1752
|
function isSuppressionActive(ign, now = /* @__PURE__ */ new Date()) {
|
|
1366
1753
|
if (!ign.expires) return true;
|
|
@@ -1638,7 +2025,33 @@ function sharedWalk(options) {
|
|
|
1638
2025
|
*
|
|
1639
2026
|
* When nothing is detectable we report `unknown` and the scanner analyzes
|
|
1640
2027
|
* all test-looking files — stated honestly in output rather than guessed.
|
|
2028
|
+
*
|
|
2029
|
+
* ## One ID space (plan V5-024)
|
|
2030
|
+
*
|
|
2031
|
+
* This detector used to declare its own `TestFramework` union of three
|
|
2032
|
+
* literals while `src/frameworks/framework-inventory.ts` catalogued fourteen
|
|
2033
|
+
* framework ids. Two vocabularies for one concept is how a support matrix
|
|
2034
|
+
* starts disagreeing with what the tool actually detects: a framework can be
|
|
2035
|
+
* catalogued as OFFICIAL_PARTIAL and never once be emitted by the code that
|
|
2036
|
+
* claims to detect it.
|
|
2037
|
+
*
|
|
2038
|
+
* So the ids here are `FrameworkId`s — the inventory's own vocabulary — and
|
|
2039
|
+
* `DETECTABLE_TEST_FRAMEWORKS` is the subset this detector can actually
|
|
2040
|
+
* resolve from a checkout. That subset is the HONEST limit of detection, and
|
|
2041
|
+
* `detectableVsCatalogued()` reports the remainder rather than letting a
|
|
2042
|
+
* three-item list read as the whole catalog.
|
|
2043
|
+
*/
|
|
2044
|
+
/**
|
|
2045
|
+
* The catalogued frameworks whose detection is implemented today.
|
|
2046
|
+
*
|
|
2047
|
+
* Every entry must exist in the inventory — enforced by the parity spec, not
|
|
2048
|
+
* by comment.
|
|
1641
2049
|
*/
|
|
2050
|
+
const DETECTABLE_TEST_FRAMEWORKS = [
|
|
2051
|
+
"jest",
|
|
2052
|
+
"vitest",
|
|
2053
|
+
"playwright"
|
|
2054
|
+
];
|
|
1642
2055
|
const CONFIG_FILES = {
|
|
1643
2056
|
jest: [
|
|
1644
2057
|
"jest.config.ts",
|
|
@@ -1657,7 +2070,7 @@ const CONFIG_FILES = {
|
|
|
1657
2070
|
};
|
|
1658
2071
|
function detectFrameworks(ws) {
|
|
1659
2072
|
const found = /* @__PURE__ */ new Set();
|
|
1660
|
-
for (const fw of
|
|
2073
|
+
for (const fw of DETECTABLE_TEST_FRAMEWORKS) if (CONFIG_FILES[fw].some((f) => existsSync(join(ws.root, f)))) found.add(fw);
|
|
1661
2074
|
if (!found.has("jest") && ws.packageJson["jest"] !== void 0) found.add("jest");
|
|
1662
2075
|
const deps = {
|
|
1663
2076
|
...ws.packageJson["dependencies"],
|
|
@@ -1679,11 +2092,7 @@ function detectFrameworks(ws) {
|
|
|
1679
2092
|
};
|
|
1680
2093
|
}
|
|
1681
2094
|
return {
|
|
1682
|
-
frameworks:
|
|
1683
|
-
"jest",
|
|
1684
|
-
"vitest",
|
|
1685
|
-
"playwright"
|
|
1686
|
-
].filter((f) => found.has(f)),
|
|
2095
|
+
frameworks: DETECTABLE_TEST_FRAMEWORKS.filter((f) => found.has(f)),
|
|
1687
2096
|
unknown: false
|
|
1688
2097
|
};
|
|
1689
2098
|
}
|
|
@@ -1700,6 +2109,13 @@ function detectFrameworks(ws) {
|
|
|
1700
2109
|
* byte-identical — a score shift is a regression, not an improvement.
|
|
1701
2110
|
*/
|
|
1702
2111
|
let project = null;
|
|
2112
|
+
/**
|
|
2113
|
+
* The shared ts-morph project.
|
|
2114
|
+
*
|
|
2115
|
+
* Exported so an adapter's `dispose()` can evict the file it parsed: ts-morph
|
|
2116
|
+
* caches by file path, so without eviction a long scan holds every parsed
|
|
2117
|
+
* SourceFile until the process exits (plan V5-021).
|
|
2118
|
+
*/
|
|
1703
2119
|
function getProject() {
|
|
1704
2120
|
if (!project) project = new Project({
|
|
1705
2121
|
useInMemoryFileSystem: true,
|
|
@@ -1727,6 +2143,7 @@ function parseTsFile(file) {
|
|
|
1727
2143
|
}
|
|
1728
2144
|
return p.createSourceFile(file.path, file.text);
|
|
1729
2145
|
} catch {
|
|
2146
|
+
recordDegradation("ast-parse-failed");
|
|
1730
2147
|
return;
|
|
1731
2148
|
}
|
|
1732
2149
|
}
|
|
@@ -1782,6 +2199,7 @@ function commentAndStringRanges(ctx) {
|
|
|
1782
2199
|
}
|
|
1783
2200
|
return ranges;
|
|
1784
2201
|
} catch {
|
|
2202
|
+
recordDegradation("ast-range-scan-failed");
|
|
1785
2203
|
return [];
|
|
1786
2204
|
}
|
|
1787
2205
|
}
|
|
@@ -1829,6 +2247,7 @@ function getCodeOnlyText(file) {
|
|
|
1829
2247
|
for (const r of ranges) for (let i = r.start; i < r.end && i < chars.length; i++) if (chars[i] !== "\n" && chars[i] !== "\r") chars[i] = " ";
|
|
1830
2248
|
return chars.join("");
|
|
1831
2249
|
} catch {
|
|
2250
|
+
recordDegradation("ast-mask-unavailable");
|
|
1832
2251
|
return file.text;
|
|
1833
2252
|
}
|
|
1834
2253
|
}
|
|
@@ -2213,7 +2632,45 @@ function frameworkFilterApplies(rule, file) {
|
|
|
2213
2632
|
return rule.frameworks.some((f) => tags.includes(f));
|
|
2214
2633
|
}
|
|
2215
2634
|
//#endregion
|
|
2216
|
-
//#region src/
|
|
2635
|
+
//#region src/engine/shared-run-rules.ts
|
|
2636
|
+
/**
|
|
2637
|
+
* Run ONE rule with crash isolation, and return what it produced.
|
|
2638
|
+
*
|
|
2639
|
+
* This is the block that was copy-pasted into all seven language adapters:
|
|
2640
|
+
*
|
|
2641
|
+
* try { for (const f of rule.run(x)) emit(f, rule.id, rule.category); }
|
|
2642
|
+
* catch (error) { onCrash?.(rule.id, error); }
|
|
2643
|
+
*
|
|
2644
|
+
* Extracting it is worth doing for one reason beyond line count. Crash
|
|
2645
|
+
* isolation is the mechanism that lets a third-party or experimental
|
|
2646
|
+
* detector fail without ending a scan, and it is the mechanism the whole
|
|
2647
|
+
* `rulesCrashed` count depends on. Seven copies means seven chances to
|
|
2648
|
+
* write `catch {}` and lose the count, and `tests/contract/
|
|
2649
|
+
* no-uncounted-degradation.spec.ts` can only audit what it can see.
|
|
2650
|
+
*
|
|
2651
|
+
* `null` means the rule CRASHED, which is not the same as an empty array:
|
|
2652
|
+
* an empty array is the rule abstaining, and the adapters skip emission for
|
|
2653
|
+
* both but the distinction is why the caller must not conflate them.
|
|
2654
|
+
*
|
|
2655
|
+
* Deliberately NOT extracted: the per-adapter filter chain and the budget
|
|
2656
|
+
* check. Those genuinely differ — the TypeScript adapter injects an AST,
|
|
2657
|
+
* framework tags, a lazy `codeText` getter, `appliesTo` scoping, config-only
|
|
2658
|
+
* gating and `configGateMatches`, and it ABORTS the whole file on budget
|
|
2659
|
+
* exhaustion where `runRulesShared` only `continue`s to the next rule.
|
|
2660
|
+
* Routing every adapter through `runRulesShared` as written would silently
|
|
2661
|
+
* change which rules run and when, which is precisely the unmeasured claim
|
|
2662
|
+
* this program exists to delete. This helper takes the part they share.
|
|
2663
|
+
*/
|
|
2664
|
+
function runRuleIsolated(rule, file, onCrash) {
|
|
2665
|
+
try {
|
|
2666
|
+
return rule.run(file);
|
|
2667
|
+
} catch (error) {
|
|
2668
|
+
onCrash?.(rule.id, error);
|
|
2669
|
+
return null;
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
//#endregion
|
|
2673
|
+
//#region src/adapters/typescript.ts
|
|
2217
2674
|
/**
|
|
2218
2675
|
* TypeScript/JavaScript adapter (R1).
|
|
2219
2676
|
* Wraps the proven regex-based engine behind the LanguageAdapter
|
|
@@ -2226,7 +2683,36 @@ function frameworkFilterApplies(rule, file) {
|
|
|
2226
2683
|
* "cypress", `@jest/globals` → "jest", `vitest` → "vitest". Config
|
|
2227
2684
|
* gating is rule-declared (`configFiles`), not hard-coded here.
|
|
2228
2685
|
*/
|
|
2229
|
-
|
|
2686
|
+
/**
|
|
2687
|
+
* `.test.` / `.spec.` filenames, plus the Cypress `.cy.` convention.
|
|
2688
|
+
*
|
|
2689
|
+
* `mts` and `cts` are TypeScript's Node-native ESM/CJS extensions. They were
|
|
2690
|
+
* missing here, and the omission was invisible in the worst way — not a wrong
|
|
2691
|
+
* answer, but an ABSENCE.
|
|
2692
|
+
*
|
|
2693
|
+
* Reproduced: a repo containing two byte-identical tests, one at
|
|
2694
|
+
* `tests/control.spec.ts` and one at `tests/slow.spec.mts`, each with a
|
|
2695
|
+
* `QA-TEST-004` hard sleep:
|
|
2696
|
+
*
|
|
2697
|
+
* discovered: 1, analyzed: 1, unrecognized: 1, scopeVerdict: "PARTIAL"
|
|
2698
|
+
*
|
|
2699
|
+
* The `.ts` test produced the finding. The `.mts` test produced nothing at
|
|
2700
|
+
* all — it was never scanned, so every rule that could have caught it was
|
|
2701
|
+
* silent on it, with no finding, no low-evidence note, and no indication that
|
|
2702
|
+
* a test file had been skipped. A test scanner that cannot see a whole file
|
|
2703
|
+
* extension has a false green that is invisible by construction: the reader
|
|
2704
|
+
* has nothing to distrust, because there is nothing there.
|
|
2705
|
+
*
|
|
2706
|
+
* The corroboration that this was an oversight rather than a decision sits
|
|
2707
|
+
* four lines below: PW_CONFIG_RE already accepted `cts`. The config regex knew
|
|
2708
|
+
* about the Node-native extensions; the test-file regex did not.
|
|
2709
|
+
*
|
|
2710
|
+
* `discovery/scan-adapters.ts:isUnrecognizedSourceCandidate` already counted
|
|
2711
|
+
* these paths as uncovered surface, so the scope accounting had been reporting
|
|
2712
|
+
* the hole the whole time — it was being read as a known limitation rather than
|
|
2713
|
+
* as a defect.
|
|
2714
|
+
*/
|
|
2715
|
+
const TEST_FILE_RE = /\.(?:test|spec)\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$|\.cy\.(?:js|jsx|ts|tsx)$/;
|
|
2230
2716
|
/**
|
|
2231
2717
|
* Fallback config list for `configOnly` rules that do not declare
|
|
2232
2718
|
* `configFiles` (the legacy playwright.config.* gating, preserved
|
|
@@ -2333,11 +2819,42 @@ const typescriptAdapter = {
|
|
|
2333
2819
|
fixtureDirMemo: /* @__PURE__ */ new Map()
|
|
2334
2820
|
});
|
|
2335
2821
|
},
|
|
2822
|
+
/**
|
|
2823
|
+
* The ONE AST seam (plan V5-021).
|
|
2824
|
+
*
|
|
2825
|
+
* This adapter used to parse inside `runRules`, on the synchronous path,
|
|
2826
|
+
* while Java and C# exposed the async `parseAst` hook. Two seams meant two
|
|
2827
|
+
* sets of consequences, both of them bad:
|
|
2828
|
+
*
|
|
2829
|
+
* - the pipeline computes `wantsAst` as `adapter.parseAst !== undefined`,
|
|
2830
|
+
* so a TypeScript file never took the AST path through the pipeline at
|
|
2831
|
+
* all, and its parse failures were invisible to the pipeline's
|
|
2832
|
+
* fallback counters;
|
|
2833
|
+
* - nothing called `dispose()` for the ts-morph path, so a scan held
|
|
2834
|
+
* every parsed SourceFile for its whole lifetime.
|
|
2835
|
+
*
|
|
2836
|
+
* Parsing now happens here, once, through the same contract every other
|
|
2837
|
+
* adapter uses. `dispose()` drops this file's SourceFile from the shared
|
|
2838
|
+
* project; ts-morph caches per file path, so removing it is what keeps
|
|
2839
|
+
* memory proportional to one file rather than to the whole scan.
|
|
2840
|
+
*/
|
|
2841
|
+
parseAst(file) {
|
|
2842
|
+
const sourceFile = parseTsFile(file);
|
|
2843
|
+
if (sourceFile === void 0) return void 0;
|
|
2844
|
+
return {
|
|
2845
|
+
ast: sourceFile,
|
|
2846
|
+
dispose: () => {
|
|
2847
|
+
try {
|
|
2848
|
+
getProject().removeSourceFile(sourceFile);
|
|
2849
|
+
} catch {}
|
|
2850
|
+
}
|
|
2851
|
+
};
|
|
2852
|
+
},
|
|
2336
2853
|
runRules(rules, file, emit, onCrash, budget) {
|
|
2337
|
-
const withAst = {
|
|
2854
|
+
const withAst = file.ast === void 0 ? {
|
|
2338
2855
|
...file,
|
|
2339
2856
|
ast: parseTsFile(file)
|
|
2340
|
-
};
|
|
2857
|
+
} : file;
|
|
2341
2858
|
const withTags = {
|
|
2342
2859
|
...withAst,
|
|
2343
2860
|
frameworkTags: frameworkTagsFromImports(withAst.text)
|
|
@@ -2362,11 +2879,9 @@ const typescriptAdapter = {
|
|
|
2362
2879
|
budget.onExceeded();
|
|
2363
2880
|
return;
|
|
2364
2881
|
}
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
onCrash?.(rule.id, error);
|
|
2369
|
-
}
|
|
2882
|
+
const produced = runRuleIsolated(rule, enriched, onCrash);
|
|
2883
|
+
if (produced === null) continue;
|
|
2884
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
2370
2885
|
}
|
|
2371
2886
|
}
|
|
2372
2887
|
};
|
|
@@ -2394,6 +2909,7 @@ function loadWorkspaceShim(root) {
|
|
|
2394
2909
|
workspaceGlobs: []
|
|
2395
2910
|
};
|
|
2396
2911
|
} catch {
|
|
2912
|
+
recordDegradation("workspace-manifest-unreadable");
|
|
2397
2913
|
return null;
|
|
2398
2914
|
}
|
|
2399
2915
|
}
|
|
@@ -2748,11 +3264,9 @@ const pythonAdapter = {
|
|
|
2748
3264
|
budget.onExceeded();
|
|
2749
3265
|
return;
|
|
2750
3266
|
}
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
onCrash?.(rule.id, error);
|
|
2755
|
-
}
|
|
3267
|
+
const produced = runRuleIsolated(rule, enriched, onCrash);
|
|
3268
|
+
if (produced === null) continue;
|
|
3269
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
2756
3270
|
}
|
|
2757
3271
|
},
|
|
2758
3272
|
async parseAst(file) {
|
|
@@ -2905,11 +3419,9 @@ const javaAdapter = {
|
|
|
2905
3419
|
budget.onExceeded();
|
|
2906
3420
|
return;
|
|
2907
3421
|
}
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
onCrash?.(rule.id, error);
|
|
2912
|
-
}
|
|
3422
|
+
const produced = runRuleIsolated(rule, enriched, onCrash);
|
|
3423
|
+
if (produced === null) continue;
|
|
3424
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
2913
3425
|
}
|
|
2914
3426
|
}
|
|
2915
3427
|
};
|
|
@@ -3063,11 +3575,9 @@ const csharpAdapter = {
|
|
|
3063
3575
|
budget.onExceeded();
|
|
3064
3576
|
return;
|
|
3065
3577
|
}
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
onCrash?.(rule.id, error);
|
|
3070
|
-
}
|
|
3578
|
+
const produced = runRuleIsolated(rule, enriched, onCrash);
|
|
3579
|
+
if (produced === null) continue;
|
|
3580
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
3071
3581
|
}
|
|
3072
3582
|
}
|
|
3073
3583
|
};
|
|
@@ -3245,6 +3755,7 @@ const githubActionsAdapter = {
|
|
|
3245
3755
|
try {
|
|
3246
3756
|
entries = readdirSync(wfDir);
|
|
3247
3757
|
} catch {
|
|
3758
|
+
recordDegradation("ci-workflow-listing-unreadable");
|
|
3248
3759
|
return;
|
|
3249
3760
|
}
|
|
3250
3761
|
for (const name of entries) {
|
|
@@ -3285,15 +3796,13 @@ const githubActionsAdapter = {
|
|
|
3285
3796
|
budget.onExceeded();
|
|
3286
3797
|
return;
|
|
3287
3798
|
}
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
onCrash?.(rule.id, error);
|
|
3296
|
-
}
|
|
3799
|
+
const produced = runRuleIsolated(rule, {
|
|
3800
|
+
path: file.path,
|
|
3801
|
+
text: file.text,
|
|
3802
|
+
ast: doc
|
|
3803
|
+
}, onCrash);
|
|
3804
|
+
if (produced === null) continue;
|
|
3805
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
3297
3806
|
}
|
|
3298
3807
|
}
|
|
3299
3808
|
};
|
|
@@ -3603,15 +4112,13 @@ const azurePipelinesAdapter = {
|
|
|
3603
4112
|
budget.onExceeded();
|
|
3604
4113
|
return;
|
|
3605
4114
|
}
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
onCrash?.(rule.id, error);
|
|
3614
|
-
}
|
|
4115
|
+
const produced = runRuleIsolated(rule, {
|
|
4116
|
+
path: file.path,
|
|
4117
|
+
text: file.text,
|
|
4118
|
+
ast: doc
|
|
4119
|
+
}, onCrash);
|
|
4120
|
+
if (produced === null) continue;
|
|
4121
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
3615
4122
|
}
|
|
3616
4123
|
}
|
|
3617
4124
|
};
|
|
@@ -3682,14 +4189,12 @@ const jenkinsAdapter = {
|
|
|
3682
4189
|
budget.onExceeded();
|
|
3683
4190
|
return;
|
|
3684
4191
|
}
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
onCrash?.(rule.id, error);
|
|
3692
|
-
}
|
|
4192
|
+
const produced = runRuleIsolated(rule, {
|
|
4193
|
+
path: file.path,
|
|
4194
|
+
text: file.text
|
|
4195
|
+
}, onCrash);
|
|
4196
|
+
if (produced === null) continue;
|
|
4197
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
3693
4198
|
}
|
|
3694
4199
|
}
|
|
3695
4200
|
};
|
|
@@ -3756,9 +4261,37 @@ function discoverAllTestFiles(ctx, languageAdapters, buckets, fixtureDirMemo) {
|
|
|
3756
4261
|
}
|
|
3757
4262
|
});
|
|
3758
4263
|
}
|
|
4264
|
+
/**
|
|
4265
|
+
* Would ignoring this file have removed it from the scanned surface?
|
|
4266
|
+
*
|
|
4267
|
+
* The bug this fixes, reproduced: a repo containing a real test file plus
|
|
4268
|
+
* `vendor.min.js` and `pnpm-lock.yaml` reported
|
|
4269
|
+
*
|
|
4270
|
+
* discovered: 1, analyzed: 1, ignored: 2, scopeVerdict: "PARTIAL"
|
|
4271
|
+
*
|
|
4272
|
+
* Every discovered test file HAD been analyzed. The surface was complete. But
|
|
4273
|
+
* DEFAULT_IGNORES matches minified bundles and lockfiles, and the ignored
|
|
4274
|
+
* counter accepted any path with a source extension — so a minified bundle and
|
|
4275
|
+
* a lockfile downgraded a finished scan to "unverified".
|
|
4276
|
+
*
|
|
4277
|
+
* That is not a conservative bias, it is a broken signal. The terminal tells
|
|
4278
|
+
* the reader "some files were not analyzed, so the surface is unverified" —
|
|
4279
|
+
* which was false. And because minified bundles and lockfiles exist in
|
|
4280
|
+
* essentially every real repository, a PROVEN scan was unreachable in
|
|
4281
|
+
* practice, so the corpus regression guard failed 23 of 37 repositories on
|
|
4282
|
+
* exactly this. A gate that cannot pass teaches people to ignore it.
|
|
4283
|
+
*
|
|
4284
|
+
* The asymmetry is the tell. `isUnrecognizedSourceCandidate` below applies a
|
|
4285
|
+
* test-relevance test before counting a file; this one did not. Both count
|
|
4286
|
+
* against the same verdict, so both must ask the same question.
|
|
4287
|
+
*
|
|
4288
|
+
* An ignored file that IS a test file still downgrades the verdict. That is
|
|
4289
|
+
* the case the honesty law exists for, and this does not touch it.
|
|
4290
|
+
*/
|
|
3759
4291
|
function isScopeRelevantIgnored(path) {
|
|
3760
4292
|
const name = path.replaceAll("\\", "/").split("/").pop() ?? path;
|
|
3761
|
-
|
|
4293
|
+
if (!/\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml|min\.js)$/i.test(name)) return false;
|
|
4294
|
+
return isKnownTestFile(path);
|
|
3762
4295
|
}
|
|
3763
4296
|
function isUnrecognizedSourceCandidate(path) {
|
|
3764
4297
|
const normalized = path.replaceAll("\\", "/");
|
|
@@ -3788,82 +4321,6 @@ function isInsideSkippedDir(root, absPath, skipNames) {
|
|
|
3788
4321
|
}
|
|
3789
4322
|
typescriptAdapter.testFileGlobs, pythonAdapter.testFileGlobs, javaAdapter.testFileGlobs, csharpAdapter.testFileGlobs, githubActionsAdapter.testFileGlobs, azurePipelinesAdapter.testFileGlobs, jenkinsAdapter.testFileGlobs;
|
|
3790
4323
|
//#endregion
|
|
3791
|
-
//#region src/scope/git-resolve.ts
|
|
3792
|
-
/**
|
|
3793
|
-
* Absolute-path git resolution (audit S1).
|
|
3794
|
-
*
|
|
3795
|
-
* `execFileSync("git", …)` resolves through PATH — including the CWD on
|
|
3796
|
-
* Windows (CreateProcess searches the current directory before PATH for
|
|
3797
|
-
* extension-less names depending on NoDefaultCurrentDirectoryInExePath).
|
|
3798
|
-
* Scanning an UNTRUSTED repo therefore let a checked-in `git.exe` (or
|
|
3799
|
-
* `git.bat`/`git.cmd`) hijack Mjölnir's own git invocations: the attacker
|
|
3800
|
-
* controls the diff output, the merge-base decision, and
|
|
3801
|
-
* `--scope changed`'s "what is new" answer — the exact data CI gates on.
|
|
3802
|
-
*
|
|
3803
|
-
* The fix: resolve git ONCE per process to an ABSOLUTE path by walking
|
|
3804
|
-
* the PATH directly (never consulting the CWD), verify the candidate is
|
|
3805
|
-
* an existing file, and pass that path to execFileSync. Resolution is
|
|
3806
|
-
* memoized; a failure to find a real git anywhere on PATH is recorded
|
|
3807
|
-
* and callers degrade without invoking a bare name.
|
|
3808
|
-
*/
|
|
3809
|
-
let resolvedGit = void 0;
|
|
3810
|
-
/**
|
|
3811
|
-
* The absolute path of the git binary Mjölnir will invoke, or null when
|
|
3812
|
-
* PATH carries no executable `git` at all. Callers must degrade without
|
|
3813
|
-
* invoking a bare name.
|
|
3814
|
-
*/
|
|
3815
|
-
function resolveGitPath() {
|
|
3816
|
-
if (resolvedGit !== void 0) return resolvedGit;
|
|
3817
|
-
const candidates = [process.platform === "win32" ? "git.exe" : "git"];
|
|
3818
|
-
if (process.platform === "win32") {
|
|
3819
|
-
const pathExt = process.env["PATHEXT"]?.split(";").filter(Boolean) ?? [];
|
|
3820
|
-
for (const ext of pathExt) candidates.push(`git${ext.toLowerCase()}`);
|
|
3821
|
-
}
|
|
3822
|
-
const pathValue = process.env["PATH"] ?? "";
|
|
3823
|
-
for (const dir of pathValue.split(delimiter)) {
|
|
3824
|
-
if (dir === "") continue;
|
|
3825
|
-
if (!isAbsolute(dir)) continue;
|
|
3826
|
-
for (const candidate of candidates) {
|
|
3827
|
-
const full = join(dir, candidate);
|
|
3828
|
-
try {
|
|
3829
|
-
if (existsSync(full)) {
|
|
3830
|
-
resolvedGit = full;
|
|
3831
|
-
return resolvedGit;
|
|
3832
|
-
}
|
|
3833
|
-
} catch {}
|
|
3834
|
-
}
|
|
3835
|
-
}
|
|
3836
|
-
`${pathValue.length}`;
|
|
3837
|
-
resolvedGit = null;
|
|
3838
|
-
return resolvedGit;
|
|
3839
|
-
}
|
|
3840
|
-
/**
|
|
3841
|
-
* Run git with the S1-resolved absolute binary path. Returns stdout, or
|
|
3842
|
-
* null when git is missing or the command fails — the same contract as
|
|
3843
|
-
* the previous inline `git()` helpers, with the hijack surface closed.
|
|
3844
|
-
*/
|
|
3845
|
-
function runGit(root, args) {
|
|
3846
|
-
const exe = resolveGitPath();
|
|
3847
|
-
if (!exe) return null;
|
|
3848
|
-
try {
|
|
3849
|
-
return execFileSync(exe, [
|
|
3850
|
-
"-C",
|
|
3851
|
-
root,
|
|
3852
|
-
...args
|
|
3853
|
-
], {
|
|
3854
|
-
encoding: "utf8",
|
|
3855
|
-
stdio: [
|
|
3856
|
-
"ignore",
|
|
3857
|
-
"pipe",
|
|
3858
|
-
"ignore"
|
|
3859
|
-
],
|
|
3860
|
-
timeout: 15e3
|
|
3861
|
-
});
|
|
3862
|
-
} catch {
|
|
3863
|
-
return null;
|
|
3864
|
-
}
|
|
3865
|
-
}
|
|
3866
|
-
//#endregion
|
|
3867
4324
|
//#region src/scope/changed.ts
|
|
3868
4325
|
/**
|
|
3869
4326
|
* Changed-scope engine (Sprint-Plan W6, Product-MVP §9 `--scope changed`).
|
|
@@ -7608,7 +8065,6 @@ const pyBareTruthinessAssert = defineRule({
|
|
|
7608
8065
|
const re = /^[ \t]*assert\s+([A-Za-z_][\w.]*(?:\([^()]*\))?)[ \t]*$/gm;
|
|
7609
8066
|
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
8067
|
const isGuardFollowedByRealUse = (text, matchIndex, target) => {
|
|
7611
|
-
const root = target.split(".")[0];
|
|
7612
8068
|
const lineEnd = text.indexOf("\n", matchIndex);
|
|
7613
8069
|
if (lineEnd === -1) return false;
|
|
7614
8070
|
const lines = text.slice(lineEnd + 1).split("\n");
|
|
@@ -7618,8 +8074,11 @@ const pyBareTruthinessAssert = defineRule({
|
|
|
7618
8074
|
if (/^\s*def\s/.test(l) && window.length > 0) break;
|
|
7619
8075
|
window.push(l);
|
|
7620
8076
|
}
|
|
7621
|
-
const
|
|
7622
|
-
|
|
8077
|
+
const root = target.match(/^[a-z_]\w*/i)?.[0];
|
|
8078
|
+
if (!root) return false;
|
|
8079
|
+
return window.some((line) => {
|
|
8080
|
+
return (line.match(/[a-z_]\w*/gi) ?? []).some((token) => token === root);
|
|
8081
|
+
});
|
|
7623
8082
|
};
|
|
7624
8083
|
let m;
|
|
7625
8084
|
while ((m = re.exec(text)) !== null) {
|
|
@@ -11302,6 +11761,53 @@ function getRule(id) {
|
|
|
11302
11761
|
return RULES.find((r) => r.id === id);
|
|
11303
11762
|
}
|
|
11304
11763
|
//#endregion
|
|
11764
|
+
//#region src/lib/compare.ts
|
|
11765
|
+
/**
|
|
11766
|
+
* Order that does not depend on who is asking.
|
|
11767
|
+
*
|
|
11768
|
+
* `String.prototype.localeCompare` with no locale argument resolves against
|
|
11769
|
+
* the PROCESS's default locale — the ICU collation chosen by `LANG`,
|
|
11770
|
+
* `LC_ALL` or the OS. So a sort written as
|
|
11771
|
+
* `[...xs].sort((a, b) => a.localeCompare(b))` produces a different order on
|
|
11772
|
+
* a developer's `de_DE` machine than on an en-US CI runner. For a display
|
|
11773
|
+
* list that is a nuisance; for anything that reaches a hash, a cache key, a
|
|
11774
|
+
* digest or a machine contract it is a correctness bug, because the same
|
|
11775
|
+
* repository then produces two different fingerprints.
|
|
11776
|
+
*
|
|
11777
|
+
* Two comparators, one per kind of site:
|
|
11778
|
+
*
|
|
11779
|
+
* compareCodePoints — pure UTF-16 code-unit order. Identical everywhere,
|
|
11780
|
+
* independent of ICU version, and already what `Array.prototype.sort`
|
|
11781
|
+
* defaults to. Use for every value that reaches a fingerprint, a digest,
|
|
11782
|
+
* a cache key, or a contract, and for any ordering with no human-facing
|
|
11783
|
+
* reason to be pretty.
|
|
11784
|
+
*
|
|
11785
|
+
* compareLocalized(locale) — the same collator, PINNED to a named locale
|
|
11786
|
+
* instead of inheriting the environment, for a surface that genuinely
|
|
11787
|
+
* wants readable order (a table of rule titles, a Mermaid legend). Pinned
|
|
11788
|
+
* rather than removed because a reader-facing list of "Test / Logic /
|
|
11789
|
+
* Mock" should not be ordered by code unit.
|
|
11790
|
+
*
|
|
11791
|
+
* A bare `localeCompare` with no second argument in `src/` is a defect and
|
|
11792
|
+
* `tests/contract/deterministic-ordering.spec.ts` fails the build on it.
|
|
11793
|
+
*/
|
|
11794
|
+
/** Code-unit order. Locale-free, ICU-free, and the Array.sort default. */
|
|
11795
|
+
function compareCodePoints(a, b) {
|
|
11796
|
+
if (a === b) return 0;
|
|
11797
|
+
return a < b ? -1 : 1;
|
|
11798
|
+
}
|
|
11799
|
+
/**
|
|
11800
|
+
* A readable-order comparator pinned to `locale`.
|
|
11801
|
+
*
|
|
11802
|
+
* The pin is the point: `a.localeCompare(b)` reads the environment, and the
|
|
11803
|
+
* environment is a machine setting, not a property of the code. Callers that
|
|
11804
|
+
* want stable output ask for a named locale and get the same answer on every
|
|
11805
|
+
* machine that ships the same ICU data.
|
|
11806
|
+
*/
|
|
11807
|
+
function compareLocalized(locale) {
|
|
11808
|
+
return (a, b) => a.localeCompare(b, locale);
|
|
11809
|
+
}
|
|
11810
|
+
//#endregion
|
|
11305
11811
|
//#region src/scorer/scorer.ts
|
|
11306
11812
|
/**
|
|
11307
11813
|
* Transparent scorer (Product-MVP §8).
|
|
@@ -11358,7 +11864,7 @@ function computeDimensions(findings) {
|
|
|
11358
11864
|
deductions.set(f.category, deductions.get(f.category) + deductionFor(f));
|
|
11359
11865
|
}
|
|
11360
11866
|
for (const dim of byCategory.values()) dim.score = Math.max(0, 100 - deductions.get(dim.category));
|
|
11361
|
-
return [...byCategory.values()].sort((a, b) => a.category
|
|
11867
|
+
return [...byCategory.values()].sort((a, b) => compareCodePoints(a.category, b.category));
|
|
11362
11868
|
}
|
|
11363
11869
|
/**
|
|
11364
11870
|
* Deduction-mass ceilings (product-gap-remediation master plan P2,
|
|
@@ -11581,6 +12087,22 @@ function normalizeOne(report, artifact, v) {
|
|
|
11581
12087
|
function buildEvidenceRecords(report, artifact) {
|
|
11582
12088
|
return report.verdicts.map((v) => normalizeOne(report, artifact, v)).sort(compareEvidenceRecords);
|
|
11583
12089
|
}
|
|
12090
|
+
function countEvidence(records) {
|
|
12091
|
+
const counts = {
|
|
12092
|
+
total: records.length,
|
|
12093
|
+
failed: 0,
|
|
12094
|
+
flaky: 0,
|
|
12095
|
+
skipped: 0,
|
|
12096
|
+
timedOut: 0
|
|
12097
|
+
};
|
|
12098
|
+
for (const r of records) {
|
|
12099
|
+
if (r.status.failed) counts.failed++;
|
|
12100
|
+
if (r.status.passedOnRetry) counts.flaky++;
|
|
12101
|
+
if (r.status.skipped) counts.skipped++;
|
|
12102
|
+
if (r.status.timedOut) counts.timedOut++;
|
|
12103
|
+
}
|
|
12104
|
+
return counts;
|
|
12105
|
+
}
|
|
11584
12106
|
//#endregion
|
|
11585
12107
|
//#region src/engine/provenance.ts
|
|
11586
12108
|
const GENERATED_HEADER_RE = /^\s*(?:\/\/|#|\/\*)\s*(?:auto[- ]?generated|generated by|do not edit)/i;
|
|
@@ -11784,7 +12306,7 @@ function groupByRootCause(findings) {
|
|
|
11784
12306
|
function correlateFindings(findings) {
|
|
11785
12307
|
const conclusions = [];
|
|
11786
12308
|
const rootCauseGroups = groupByRootCause(findings);
|
|
11787
|
-
for (const [rootCause, group] of Array.from(rootCauseGroups.entries()).sort((a, b) => a[0]
|
|
12309
|
+
for (const [rootCause, group] of Array.from(rootCauseGroups.entries()).sort((a, b) => compareLocalized("en")(a[0], b[0]))) {
|
|
11788
12310
|
if (group.length < 2) continue;
|
|
11789
12311
|
const findingIds = group.map((f) => f.findingId ?? f.ruleId);
|
|
11790
12312
|
conclusions.push({
|
|
@@ -11796,7 +12318,7 @@ function correlateFindings(findings) {
|
|
|
11796
12318
|
});
|
|
11797
12319
|
}
|
|
11798
12320
|
const fileGroups = groupByFile(findings);
|
|
11799
|
-
for (const [file, group] of Array.from(fileGroups.entries()).sort((a, b) => a[0]
|
|
12321
|
+
for (const [file, group] of Array.from(fileGroups.entries()).sort((a, b) => compareCodePoints(a[0], b[0]))) {
|
|
11800
12322
|
if (group.length < 2) continue;
|
|
11801
12323
|
const alreadyConvergent = /* @__PURE__ */ new Set();
|
|
11802
12324
|
for (const c of conclusions) if (c.conclusionType === "CONVERGENT") for (const id of c.findingIds) alreadyConvergent.add(id);
|
|
@@ -11868,6 +12390,11 @@ var DependencyGraph = class {
|
|
|
11868
12390
|
get size() {
|
|
11869
12391
|
return this.nodes.size;
|
|
11870
12392
|
}
|
|
12393
|
+
/** Whether the graph knows this exact key. Reachability must not
|
|
12394
|
+
* treat "not in the graph" as "nothing to traverse" without saying so. */
|
|
12395
|
+
has(path) {
|
|
12396
|
+
return this.nodes.has(path);
|
|
12397
|
+
}
|
|
11871
12398
|
};
|
|
11872
12399
|
function parsePackageJson(filePath) {
|
|
11873
12400
|
if (!existsSync(filePath)) return void 0;
|
|
@@ -11992,117 +12519,391 @@ function isIncrementalSafe(changedFiles) {
|
|
|
11992
12519
|
};
|
|
11993
12520
|
}
|
|
11994
12521
|
//#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
|
-
}
|
|
12522
|
+
//#region src/brand/tokens.ts
|
|
12007
12523
|
/**
|
|
12008
|
-
*
|
|
12524
|
+
* The single source of brand truth.
|
|
12009
12525
|
*
|
|
12010
|
-
*
|
|
12011
|
-
*
|
|
12012
|
-
*
|
|
12013
|
-
* hide behind averaging.
|
|
12014
|
-
*/
|
|
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
|
-
/**
|
|
12100
|
-
* Atomic file writes (audit S9).
|
|
12526
|
+
* Every colour, typeface and motion constant Mjölnir shows a human —
|
|
12527
|
+
* terminal, README SVGs, demo video, website, docs, badges — resolves to
|
|
12528
|
+
* a value in this file. Nothing else may define one.
|
|
12101
12529
|
*
|
|
12102
|
-
*
|
|
12103
|
-
*
|
|
12104
|
-
* `
|
|
12105
|
-
*
|
|
12530
|
+
* WHY THIS EXISTS. Before it, the palette existed in six independent
|
|
12531
|
+
* copies: `site/.vitepress/theme/styles/vars.css`, `NORSE` in
|
|
12532
|
+
* `src/reporter/theme.ts`, `scripts/readme-svg.ts`,
|
|
12533
|
+
* `scripts/video/terminal-page.ts`, `scripts/generate-readme-architecture.ts`
|
|
12534
|
+
* and the table in `assets/brand/README.md`. Exactly one pair of those
|
|
12535
|
+
* was guarded (site-doctor Check 8, doc ↔ vars.css). The unguarded edges
|
|
12536
|
+
* are where the shipped surfaces drifted apart: the terminal and the site
|
|
12537
|
+
* disagreed on six semantic roles, the architecture diagram invented its
|
|
12538
|
+
* own neutral ramp, and the README badges still carried a palette retired
|
|
12539
|
+
* two releases earlier. `scripts/brand-doctor.mjs` now checks every edge
|
|
12540
|
+
* against this file.
|
|
12541
|
+
*
|
|
12542
|
+
* PURITY. Pure data. No I/O, no rendering, no environment access, no
|
|
12543
|
+
* imports, no logic. Consumers convert (hex → ANSI triplet, hex → CSS)
|
|
12544
|
+
* themselves. Same reason `presentation.ts` is pure: it makes the whole
|
|
12545
|
+
* thing golden-testable and safe to ship inside the npm package, where
|
|
12546
|
+
* it costs a few hundred bytes and replaces values the package already
|
|
12547
|
+
* carried anyway.
|
|
12548
|
+
*
|
|
12549
|
+
* DERIVATION. The palette is the one derived from the logo in PR #20
|
|
12550
|
+
* (brushed steel and forge gold under an aurora, over midnight iron).
|
|
12551
|
+
* Where the terminal disagreed with it, the terminal converges — see
|
|
12552
|
+
* `PENDING_TERMINAL` below. Full rationale: `assets/brand/README.md`.
|
|
12553
|
+
*
|
|
12554
|
+
* ACCESSIBILITY. Every foreground token in `BRAND` meets WCAG AA
|
|
12555
|
+
* (≥ 4.5:1) against every surface token it is allowed to sit on. That is
|
|
12556
|
+
* not a claim, it is `brand-doctor` rule 8, which computes the ratios.
|
|
12557
|
+
* The weakest legal pairing is `steelDim` on `ink800` at 5.00:1.
|
|
12558
|
+
*/
|
|
12559
|
+
/**
|
|
12560
|
+
* The two brand hues plus the neutral they sit on.
|
|
12561
|
+
*
|
|
12562
|
+
* GOLD IS SCARCE. It means forged / certified / earned / decisive — the
|
|
12563
|
+
* primary mark, the FORGED state, one call to action. It is not a paint
|
|
12564
|
+
* bucket: gold as default text, default border or default heading is a
|
|
12565
|
+
* brand-doctor finding, not a style choice.
|
|
12566
|
+
*
|
|
12567
|
+
* AURORA is verification energy — the secondary, and the hue that marks
|
|
12568
|
+
* the runtime half of the trust ladder.
|
|
12569
|
+
*/
|
|
12570
|
+
const BRAND = {
|
|
12571
|
+
gold: "#C19A34",
|
|
12572
|
+
goldBright: "#E6BD57",
|
|
12573
|
+
goldHot: "#F4DC9C",
|
|
12574
|
+
/** Pressed / deepest gold — the only step dark enough to carry white. */
|
|
12575
|
+
goldDeep: "#A5811C",
|
|
12576
|
+
aurora: "#37ABBD",
|
|
12577
|
+
auroraBright: "#45C1D4",
|
|
12578
|
+
auroraCyan: "#5CBDE0",
|
|
12579
|
+
/** The aurora's outer curtains: atmosphere and section identity only,
|
|
12580
|
+
* never a verdict or a status. */
|
|
12581
|
+
auroraGreen: "#5FD6A4",
|
|
12582
|
+
auroraViolet: "#9D8CF5",
|
|
12583
|
+
steel: "#C8CBCF",
|
|
12584
|
+
steelDim: "#8B939D"
|
|
12585
|
+
};
|
|
12586
|
+
/**
|
|
12587
|
+
* Midnight iron. One ramp, four steps, darkest first.
|
|
12588
|
+
*
|
|
12589
|
+
* `terminal` and `terminalBar` share one tone deliberately: the window's
|
|
12590
|
+
* only seam is a hairline ring and an inset shadow, never a second fill.
|
|
12591
|
+
* `chromeDot` is the three window dots — see the note on
|
|
12592
|
+
* `PENDING_TERMINAL.chromeDots` for why they are no longer red/amber/green.
|
|
12593
|
+
*/
|
|
12594
|
+
const SURFACE = {
|
|
12595
|
+
ink950: "#0A1119",
|
|
12596
|
+
ink900: "#0C1420",
|
|
12597
|
+
ink850: "#111A29",
|
|
12598
|
+
ink800: "#18243A",
|
|
12599
|
+
/** Raised panel (cards, elevated surfaces). */
|
|
12600
|
+
panel: "#141F33",
|
|
12601
|
+
/** Soft fill (inline code, quiet chips). */
|
|
12602
|
+
soft: "#1A2740",
|
|
12603
|
+
/** Terminal body — the deepest tone, so a terminal reads as recessed. */
|
|
12604
|
+
terminal: "#0A1119",
|
|
12605
|
+
/** Terminal title bar — the same tone; the seam is shadow, not colour. */
|
|
12606
|
+
terminalBar: "#0A1119",
|
|
12607
|
+
/** The three window dots. One neutral, not a traffic light. */
|
|
12608
|
+
chromeDot: "#18243A"
|
|
12609
|
+
};
|
|
12610
|
+
const TEXT = {
|
|
12611
|
+
primary: "#EAEEF5",
|
|
12612
|
+
secondary: "#ABB6C6",
|
|
12613
|
+
muted: "#8B939D",
|
|
12614
|
+
/** Ink for text set ON gold (buttons, the FORGED chip). 7.17:1 on `gold`. */
|
|
12615
|
+
onGold: "#0A1119"
|
|
12616
|
+
};
|
|
12617
|
+
/**
|
|
12618
|
+
* Non-score status. `ok` is the one green in the system and it is NOT a
|
|
12619
|
+
* score colour — it survives only for contexts with no worthiness
|
|
12620
|
+
* meaning ("autofix applied", "analysis complete"). A green score would
|
|
12621
|
+
* say "your software is fine", which is the exact claim this product
|
|
12622
|
+
* refuses to make.
|
|
12623
|
+
*/
|
|
12624
|
+
const STATUS = {
|
|
12625
|
+
ok: "#4FB477",
|
|
12626
|
+
info: "#5CC4E0",
|
|
12627
|
+
warning: "#E6BD57",
|
|
12628
|
+
error: "#EC6B66"
|
|
12629
|
+
};
|
|
12630
|
+
/**
|
|
12631
|
+
* The four ScoreState bands plus the unmeasured state. Band thresholds
|
|
12632
|
+
* and runes live in `src/reporter/presentation.ts`, which stays free of
|
|
12633
|
+
* colour — it emits a palette KEY and each surface resolves it here.
|
|
12634
|
+
*
|
|
12635
|
+
* `unmeasured` is steel-dim on purpose. UNKNOWN is a legitimate answer,
|
|
12636
|
+
* not a failure: colouring it red would make "we did not measure this"
|
|
12637
|
+
* look like "this is broken", which is precisely the dishonesty the
|
|
12638
|
+
* north-star law exists to prevent.
|
|
12639
|
+
*/
|
|
12640
|
+
const SCORE = {
|
|
12641
|
+
critical: "#EC6B66",
|
|
12642
|
+
warning: "#E6BD57",
|
|
12643
|
+
trusted: "#5CC4E0",
|
|
12644
|
+
forged: "#F4DC9C",
|
|
12645
|
+
unmeasured: "#8B939D"
|
|
12646
|
+
};
|
|
12647
|
+
/**
|
|
12648
|
+
* E0 → E1 → E2 is a certainty ramp, and it is deliberately HUE-FREE.
|
|
12649
|
+
*
|
|
12650
|
+
* Evidence level says how sure we are, not whether the news is good. A
|
|
12651
|
+
* deterministic proof (E2) is a defect we are certain about — painting
|
|
12652
|
+
* it gold or green would read as an achievement. So certainty is carried
|
|
12653
|
+
* by brightness alone, and the *shape* does the real work:
|
|
12654
|
+
*
|
|
12655
|
+
* E0 open ring observation, no weight
|
|
12656
|
+
* E1 half-filled pattern evidence, half weight
|
|
12657
|
+
* E2 sealed deterministic proof, full weight
|
|
12658
|
+
*
|
|
12659
|
+
* Colour never carries this alone (R11): the geometry is the signal and
|
|
12660
|
+
* survives `--ascii`, `NO_COLOR` and monochrome print.
|
|
12661
|
+
*/
|
|
12662
|
+
const EVIDENCE = {
|
|
12663
|
+
e0: "#8B939D",
|
|
12664
|
+
e1: "#ABB6C6",
|
|
12665
|
+
e2: "#EAEEF5"
|
|
12666
|
+
};
|
|
12667
|
+
/**
|
|
12668
|
+
* L0–L5, and the most important boundary in the product.
|
|
12669
|
+
*
|
|
12670
|
+
* L0–L2 are STATIC: the neutral steel ramp, brightening to the static
|
|
12671
|
+
* ceiling at L2. L3–L5 require a real run, and the hue changes to aurora
|
|
12672
|
+
* exactly there. The boundary is a hue break, not a gradient step,
|
|
12673
|
+
* because it is a change of kind and not of degree — a static-only
|
|
12674
|
+
* finding can never climb past L2, however confident it is.
|
|
12675
|
+
*
|
|
12676
|
+
* Every surface that draws the ladder must draw that break.
|
|
12677
|
+
*/
|
|
12678
|
+
const TRUST = {
|
|
12679
|
+
l0: "#8B939D",
|
|
12680
|
+
l1: "#ABB6C6",
|
|
12681
|
+
l2: "#C8CBCF",
|
|
12682
|
+
l3: "#37ABBD",
|
|
12683
|
+
l4: "#45C1D4",
|
|
12684
|
+
l5: "#5CC4E0"
|
|
12685
|
+
};
|
|
12686
|
+
SURFACE.ink950, SURFACE.ink900, SURFACE.ink850, SURFACE.ink800, BRAND.steel, BRAND.steelDim, BRAND.gold, BRAND.goldBright, BRAND.goldHot, BRAND.aurora, BRAND.auroraBright, BRAND.auroraCyan, BRAND.auroraGreen, BRAND.auroraViolet;
|
|
12687
|
+
SCORE.trusted, SCORE.forged, SCORE.warning, SCORE.critical, STATUS.info, TEXT.onGold, STATUS.ok, EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2, TRUST.l0, TRUST.l1, TRUST.l2, TRUST.l3, TRUST.l4, TRUST.l5;
|
|
12688
|
+
EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2;
|
|
12689
|
+
const RUNG_MEANINGS = [
|
|
12690
|
+
"observation only",
|
|
12691
|
+
"heuristic static",
|
|
12692
|
+
"deterministic static",
|
|
12693
|
+
"the finding's file executed",
|
|
12694
|
+
"the finding's test executed",
|
|
12695
|
+
"the run verdict corroborates"
|
|
12696
|
+
];
|
|
12697
|
+
const RUNG_COLORS = [
|
|
12698
|
+
TRUST.l0,
|
|
12699
|
+
TRUST.l1,
|
|
12700
|
+
TRUST.l2,
|
|
12701
|
+
TRUST.l3,
|
|
12702
|
+
TRUST.l4,
|
|
12703
|
+
TRUST.l5
|
|
12704
|
+
];
|
|
12705
|
+
RUNG_MEANINGS.map((meaning, i) => ({
|
|
12706
|
+
level: `L${i}`,
|
|
12707
|
+
meaning,
|
|
12708
|
+
runtime: i >= 3,
|
|
12709
|
+
color: RUNG_COLORS[i]
|
|
12710
|
+
}));
|
|
12711
|
+
const HEADLINES = {
|
|
12712
|
+
critical: "The hammer is cracked — {n} findings break its edge.",
|
|
12713
|
+
warning: "The hammer holds — but {n} findings weigh it down.",
|
|
12714
|
+
trusted: "Held in worthy hands — {n} findings remain.",
|
|
12715
|
+
forged: "Static score 100 — no findings on the analyzed surface.",
|
|
12716
|
+
unmeasured: "No tests found — the hammer cannot be weighed."
|
|
12717
|
+
};
|
|
12718
|
+
const RUNES = {
|
|
12719
|
+
critical: "ᚲ",
|
|
12720
|
+
warning: "ᚦ",
|
|
12721
|
+
trusted: "ᛏ",
|
|
12722
|
+
forged: "ᛟ",
|
|
12723
|
+
unmeasured: "ᛁ"
|
|
12724
|
+
};
|
|
12725
|
+
/**
|
|
12726
|
+
* The named form of the band boundaries. `report:honesty` /
|
|
12727
|
+
* `thresholds:parity` assert that no surface outside this module
|
|
12728
|
+
* re-invents a boundary; a consumer that genuinely needs one names it
|
|
12729
|
+
* here rather than typing the number.
|
|
12730
|
+
*/
|
|
12731
|
+
const SCORE_THRESHOLDS = {
|
|
12732
|
+
/** Below this is `critical`. */
|
|
12733
|
+
criticalBelow: 50,
|
|
12734
|
+
/** At or above this is `trusted`. */
|
|
12735
|
+
trustedAtOrAbove: 80,
|
|
12736
|
+
/** The one score that is `forged`. */
|
|
12737
|
+
forgedAt: 100
|
|
12738
|
+
};
|
|
12739
|
+
/**
|
|
12740
|
+
* Band mapping — the one mapping, every consumer: <50 critical,
|
|
12741
|
+
* 50–79 warning, 80–99 trusted, 100 forged, null unmeasured.
|
|
12742
|
+
*/
|
|
12743
|
+
function deriveScoreState(score) {
|
|
12744
|
+
if (score === null) return {
|
|
12745
|
+
score: null,
|
|
12746
|
+
band: "unmeasured",
|
|
12747
|
+
verdict: "UNWORTHY",
|
|
12748
|
+
color: "dim",
|
|
12749
|
+
powerLevel: 0,
|
|
12750
|
+
headline: HEADLINES.unmeasured,
|
|
12751
|
+
rune: RUNES.unmeasured
|
|
12752
|
+
};
|
|
12753
|
+
if (score >= SCORE_THRESHOLDS.forgedAt) return {
|
|
12754
|
+
score,
|
|
12755
|
+
band: "forged",
|
|
12756
|
+
verdict: "FORGED",
|
|
12757
|
+
color: "forged",
|
|
12758
|
+
powerLevel: score,
|
|
12759
|
+
headline: HEADLINES.forged,
|
|
12760
|
+
rune: RUNES.forged
|
|
12761
|
+
};
|
|
12762
|
+
if (score >= 80) return {
|
|
12763
|
+
score,
|
|
12764
|
+
band: "trusted",
|
|
12765
|
+
verdict: "WORTHY",
|
|
12766
|
+
color: "trusted",
|
|
12767
|
+
powerLevel: score,
|
|
12768
|
+
headline: HEADLINES.trusted,
|
|
12769
|
+
rune: RUNES.trusted
|
|
12770
|
+
};
|
|
12771
|
+
if (score >= 50) return {
|
|
12772
|
+
score,
|
|
12773
|
+
band: "warning",
|
|
12774
|
+
verdict: "NEEDS WORK",
|
|
12775
|
+
color: "warning",
|
|
12776
|
+
powerLevel: score,
|
|
12777
|
+
headline: HEADLINES.warning,
|
|
12778
|
+
rune: RUNES.warning
|
|
12779
|
+
};
|
|
12780
|
+
return {
|
|
12781
|
+
score,
|
|
12782
|
+
band: "critical",
|
|
12783
|
+
verdict: "UNWORTHY",
|
|
12784
|
+
color: "error",
|
|
12785
|
+
powerLevel: score,
|
|
12786
|
+
headline: HEADLINES.critical,
|
|
12787
|
+
rune: RUNES.critical
|
|
12788
|
+
};
|
|
12789
|
+
}
|
|
12790
|
+
//#endregion
|
|
12791
|
+
//#region src/engine/monorepo-analysis.ts
|
|
12792
|
+
/**
|
|
12793
|
+
* Package verdict, derived from the one ScoreState band model (BW-104).
|
|
12794
|
+
* These two constants used to be a private copy of the band boundaries;
|
|
12795
|
+
* the numeric vocabulary here is a different projection of the same bands
|
|
12796
|
+
* — "pass" is trusted or forged, "warn" is warning, "fail" is critical or
|
|
12797
|
+
* unmeasurable — so the boundaries are read, never retyped.
|
|
12798
|
+
*/
|
|
12799
|
+
function verdictOf(score) {
|
|
12800
|
+
if (score === null) return "fail";
|
|
12801
|
+
const band = deriveScoreState(score).band;
|
|
12802
|
+
if (band === "forged" || band === "trusted") return "pass";
|
|
12803
|
+
return band === "warning" ? "warn" : "fail";
|
|
12804
|
+
}
|
|
12805
|
+
function hasBlocker(findings) {
|
|
12806
|
+
return findings.some((f) => f.severity === "error");
|
|
12807
|
+
}
|
|
12808
|
+
/**
|
|
12809
|
+
* Aggregate per-package results into an overall verdict.
|
|
12810
|
+
*
|
|
12811
|
+
* Worst-package propagation: if ANY package has a blocker (error-severity
|
|
12812
|
+
* finding) or a failing score, the overall result fails regardless of
|
|
12813
|
+
* strategy. This is the safety net — a single poisoned package must not
|
|
12814
|
+
* hide behind averaging.
|
|
12815
|
+
*/
|
|
12816
|
+
function analyzeMonorepo(packages, config) {
|
|
12817
|
+
const results = packages.map((p) => ({
|
|
12818
|
+
...p,
|
|
12819
|
+
verdict: hasBlocker(p.findings) ? "fail" : verdictOf(p.score)
|
|
12820
|
+
}));
|
|
12821
|
+
if (results.length === 0) return {
|
|
12822
|
+
packages: results,
|
|
12823
|
+
overallScore: null,
|
|
12824
|
+
overallVerdict: "fail",
|
|
12825
|
+
strategy: config.weightingStrategy
|
|
12826
|
+
};
|
|
12827
|
+
const blockerPkg = results.find((r) => r.verdict === "fail" || hasBlocker(r.findings));
|
|
12828
|
+
if (blockerPkg) return {
|
|
12829
|
+
packages: results,
|
|
12830
|
+
overallScore: blockerPkg.score,
|
|
12831
|
+
overallVerdict: "fail",
|
|
12832
|
+
strategy: config.weightingStrategy,
|
|
12833
|
+
blockerPackage: blockerPkg.packageName
|
|
12834
|
+
};
|
|
12835
|
+
switch (config.weightingStrategy) {
|
|
12836
|
+
case "worst-package": return worstPackageAggregation(results, config);
|
|
12837
|
+
case "average": return averageAggregation(results, config);
|
|
12838
|
+
case "configurable": return configurableAggregation(results, config);
|
|
12839
|
+
}
|
|
12840
|
+
}
|
|
12841
|
+
function worstPackageAggregation(results, config) {
|
|
12842
|
+
const firstResult = results[0];
|
|
12843
|
+
if (firstResult === void 0) return {
|
|
12844
|
+
packages: results,
|
|
12845
|
+
overallScore: null,
|
|
12846
|
+
overallVerdict: "fail",
|
|
12847
|
+
strategy: config.weightingStrategy
|
|
12848
|
+
};
|
|
12849
|
+
let worst = firstResult;
|
|
12850
|
+
for (const r of results) if ((r.score ?? 0) < (worst.score ?? 0)) worst = r;
|
|
12851
|
+
return {
|
|
12852
|
+
packages: results,
|
|
12853
|
+
overallScore: worst.score,
|
|
12854
|
+
overallVerdict: worst.verdict,
|
|
12855
|
+
strategy: config.weightingStrategy,
|
|
12856
|
+
...worst.verdict === "fail" ? { blockerPackage: worst.packageName } : {}
|
|
12857
|
+
};
|
|
12858
|
+
}
|
|
12859
|
+
function averageAggregation(results, config) {
|
|
12860
|
+
const scored = results.filter((r) => r.score !== null);
|
|
12861
|
+
if (scored.length === 0) return {
|
|
12862
|
+
packages: results,
|
|
12863
|
+
overallScore: null,
|
|
12864
|
+
overallVerdict: "fail",
|
|
12865
|
+
strategy: config.weightingStrategy
|
|
12866
|
+
};
|
|
12867
|
+
const avg = scored.reduce((sum, r) => sum + (r.score ?? 0), 0) / scored.length;
|
|
12868
|
+
return {
|
|
12869
|
+
packages: results,
|
|
12870
|
+
overallScore: Math.round(avg),
|
|
12871
|
+
overallVerdict: verdictOf(Math.round(avg)),
|
|
12872
|
+
strategy: config.weightingStrategy
|
|
12873
|
+
};
|
|
12874
|
+
}
|
|
12875
|
+
function configurableAggregation(results, config) {
|
|
12876
|
+
const weights = config.packageWeights ?? {};
|
|
12877
|
+
let totalWeight = 0;
|
|
12878
|
+
let weightedSum = 0;
|
|
12879
|
+
for (const r of results) {
|
|
12880
|
+
const w = weights[r.packageName] ?? 1;
|
|
12881
|
+
totalWeight += w;
|
|
12882
|
+
weightedSum += (r.score ?? 0) * w;
|
|
12883
|
+
}
|
|
12884
|
+
if (totalWeight === 0) return {
|
|
12885
|
+
packages: results,
|
|
12886
|
+
overallScore: null,
|
|
12887
|
+
overallVerdict: "fail",
|
|
12888
|
+
strategy: config.weightingStrategy
|
|
12889
|
+
};
|
|
12890
|
+
const score = Math.round(weightedSum / totalWeight);
|
|
12891
|
+
return {
|
|
12892
|
+
packages: results,
|
|
12893
|
+
overallScore: score,
|
|
12894
|
+
overallVerdict: verdictOf(score),
|
|
12895
|
+
strategy: config.weightingStrategy
|
|
12896
|
+
};
|
|
12897
|
+
}
|
|
12898
|
+
//#endregion
|
|
12899
|
+
//#region src/lib/fs-atomic.ts
|
|
12900
|
+
/**
|
|
12901
|
+
* Atomic file writes (audit S9).
|
|
12902
|
+
*
|
|
12903
|
+
* Every durability-critical write in Mjölnir (baseline, stats, badge,
|
|
12904
|
+
* TRIAGE.md, scaffolded rule files) used to hand-roll
|
|
12905
|
+
* `writeFileSync(path, data)` — a crash mid-write left a TRUNCATED file
|
|
12906
|
+
* at the real path, and a subsequent read (diff, badge endpoint) served
|
|
12106
12907
|
* confident nonsense from it.
|
|
12107
12908
|
*
|
|
12108
12909
|
* `writeFileAtomic` writes to a temp sibling, then RENAMES. On the same
|
|
@@ -12118,6 +12919,10 @@ function atomicTempPath(path) {
|
|
|
12118
12919
|
}
|
|
12119
12920
|
/**
|
|
12120
12921
|
* Atomically replace `path` with `data`.
|
|
12922
|
+
*
|
|
12923
|
+
* Accepts binary payloads as well as text: base-tree materialization in
|
|
12924
|
+
* `impact` writes git blobs, and a writer that only takes strings forces those
|
|
12925
|
+
* call sites back to the non-atomic path rather than keeping them contained.
|
|
12121
12926
|
*/
|
|
12122
12927
|
function writeFileAtomic(path, data, opts = {}) {
|
|
12123
12928
|
const dir = dirname(path);
|
|
@@ -12126,7 +12931,8 @@ function writeFileAtomic(path, data, opts = {}) {
|
|
|
12126
12931
|
let fd;
|
|
12127
12932
|
try {
|
|
12128
12933
|
fd = openSync(tmp, "wx", opts.mode ?? 420);
|
|
12129
|
-
writeSync(fd, data, null, opts.encoding ?? "utf8");
|
|
12934
|
+
if (typeof data === "string") writeSync(fd, data, null, opts.encoding ?? "utf8");
|
|
12935
|
+
else writeSync(fd, data);
|
|
12130
12936
|
} finally {
|
|
12131
12937
|
if (fd !== void 0) closeSync(fd);
|
|
12132
12938
|
}
|
|
@@ -12252,304 +13058,138 @@ function hashRulesSourceTree(rules) {
|
|
|
12252
13058
|
try {
|
|
12253
13059
|
const hash = createHash("sha256");
|
|
12254
13060
|
hashDir(rulesDir, hash, 0);
|
|
12255
|
-
return hash.digest("hex").slice(0, 16);
|
|
12256
|
-
} catch {
|
|
12257
|
-
return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
|
|
12258
|
-
}
|
|
12259
|
-
}
|
|
12260
|
-
function hashDir(dir, hash, depth) {
|
|
12261
|
-
if (depth > 8) return;
|
|
12262
|
-
let entries;
|
|
12263
|
-
try {
|
|
12264
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
12265
|
-
} catch {
|
|
12266
|
-
|
|
12267
|
-
|
|
12268
|
-
|
|
12269
|
-
|
|
12270
|
-
|
|
12271
|
-
|
|
12272
|
-
|
|
12273
|
-
|
|
12274
|
-
|
|
12275
|
-
hash.update(
|
|
12276
|
-
|
|
12277
|
-
|
|
12278
|
-
}
|
|
12279
|
-
|
|
12280
|
-
* Content-addressed key for one file's rule-loop verdicts.
|
|
12281
|
-
*
|
|
12282
|
-
* Audit C1: the key MUST identify the verdict's producer, not just the
|
|
12283
|
-
* bytes — two files with byte-identical text (a copied spec, a generated
|
|
12284
|
-
* snapshot) previously shared one entry, and the first file's cached
|
|
12285
|
-
* findings were re-emitted for the second with the wrong `file` stamp.
|
|
12286
|
-
* The key therefore folds in the repo-relative path AND the adapter id,
|
|
12287
|
-
* plus a parse-mode token (audit W9): a file whose analysis degraded to
|
|
12288
|
-
* the regex fallback (or skipped the AST path) must not collide with a
|
|
12289
|
-
* fully-AST-analyzed verdict for the same bytes — the fallback output
|
|
12290
|
-
* belongs only to the fallback mode.
|
|
12291
|
-
*/
|
|
12292
|
-
function fileCacheKey(rulesDigest, fileText, identity) {
|
|
12293
|
-
const parseMode = identity.parseMode ?? "ast";
|
|
12294
|
-
return sha256(`${CACHE_VERSION}\u0000${rulesDigest}\u0000${identity.relPath}\u0000${identity.adapterId}\u0000${parseMode}\u0000${fileText}`);
|
|
12295
|
-
}
|
|
12296
|
-
/** No-op cache used when --cache is absent: zero stats, zero I/O. */
|
|
12297
|
-
const disabledScanCache = {
|
|
12298
|
-
stats: {
|
|
12299
|
-
hits: 0,
|
|
12300
|
-
misses: 0,
|
|
12301
|
-
file: ""
|
|
12302
|
-
},
|
|
12303
|
-
lookup: () => void 0,
|
|
12304
|
-
store: () => {},
|
|
12305
|
-
persist: () => {}
|
|
12306
|
-
};
|
|
12307
|
-
/**
|
|
12308
|
-
* Opens (and lazily creates) `<root>/.mjolnir/cache/scan-v<CACHE_VERSION>.json`. A
|
|
12309
|
-
* corrupt, hostile or future-versioned cache file degrades to a cold
|
|
12310
|
-
* cache — never fails the scan.
|
|
12311
|
-
*/
|
|
12312
|
-
function createScanCache(root) {
|
|
12313
|
-
const dir = join(root, ".mjolnir", "cache");
|
|
12314
|
-
const file = join(dir, `scan-v${CACHE_VERSION}.json`);
|
|
12315
|
-
let entries = {};
|
|
12316
|
-
let dirty = false;
|
|
12317
|
-
const entryBytes = /* @__PURE__ */ new Map();
|
|
12318
|
-
let totalBytes = 0;
|
|
12319
|
-
try {
|
|
12320
|
-
if (existsSync(file)) {
|
|
12321
|
-
const stat = lstatSync(file);
|
|
12322
|
-
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_TOTAL_BYTES) throw new Error("invalid cache file");
|
|
12323
|
-
entries = parseJsonFile(readFileSync(file, "utf8"), file, isCacheFile).entries;
|
|
12324
|
-
for (const [k, v] of Object.entries(entries)) {
|
|
12325
|
-
const size = Buffer.byteLength(JSON.stringify(v), "utf8") + k.length + 4;
|
|
12326
|
-
entryBytes.set(k, size);
|
|
12327
|
-
totalBytes += size;
|
|
12328
|
-
}
|
|
12329
|
-
if (totalBytes > MAX_TOTAL_BYTES) throw new Error("cache byte budget exceeded");
|
|
12330
|
-
}
|
|
12331
|
-
} catch {
|
|
12332
|
-
entries = {};
|
|
12333
|
-
entryBytes.clear();
|
|
12334
|
-
totalBytes = 0;
|
|
12335
|
-
}
|
|
12336
|
-
return {
|
|
12337
|
-
stats: {
|
|
12338
|
-
hits: 0,
|
|
12339
|
-
misses: 0,
|
|
12340
|
-
file
|
|
12341
|
-
},
|
|
12342
|
-
lookup(key) {
|
|
12343
|
-
const entry = entries[key];
|
|
12344
|
-
if (!entry) {
|
|
12345
|
-
this.stats.misses++;
|
|
12346
|
-
return;
|
|
12347
|
-
}
|
|
12348
|
-
this.stats.hits++;
|
|
12349
|
-
delete entries[key];
|
|
12350
|
-
entries[key] = entry;
|
|
12351
|
-
return structuredClone(entry.findings);
|
|
12352
|
-
},
|
|
12353
|
-
store(key, findings, fileBudgetExceeded) {
|
|
12354
|
-
if (fileBudgetExceeded) return;
|
|
12355
|
-
if (findings.length > MAX_FINDINGS_PER_ENTRY) return;
|
|
12356
|
-
const entryJson = JSON.stringify(findings);
|
|
12357
|
-
const newBytes = Buffer.byteLength(entryJson, "utf8") + key.length + 4;
|
|
12358
|
-
if (newBytes > MAX_TOTAL_BYTES) return;
|
|
12359
|
-
const replacedBytes = entryBytes.get(key) ?? 0;
|
|
12360
|
-
delete entries[key];
|
|
12361
|
-
entries[key] = { findings: structuredClone(findings) };
|
|
12362
|
-
entryBytes.set(key, newBytes);
|
|
12363
|
-
totalBytes = totalBytes - replacedBytes + newBytes;
|
|
12364
|
-
let count = Object.keys(entries).length;
|
|
12365
|
-
while ((count > MAX_ENTRIES$1 || totalBytes > MAX_TOTAL_BYTES) && count > 1) {
|
|
12366
|
-
const oldest = Object.keys(entries)[0];
|
|
12367
|
-
totalBytes -= entryBytes.get(oldest);
|
|
12368
|
-
delete entries[oldest];
|
|
12369
|
-
entryBytes.delete(oldest);
|
|
12370
|
-
count--;
|
|
12371
|
-
}
|
|
12372
|
-
dirty = true;
|
|
12373
|
-
},
|
|
12374
|
-
persist() {
|
|
12375
|
-
if (!dirty) return;
|
|
12376
|
-
try {
|
|
12377
|
-
mkdirSync(dir, { recursive: true });
|
|
12378
|
-
writeFileAtomic(file, JSON.stringify({
|
|
12379
|
-
version: CACHE_VERSION,
|
|
12380
|
-
entries
|
|
12381
|
-
}), { encoding: "utf8" });
|
|
12382
|
-
} catch {}
|
|
12383
|
-
}
|
|
12384
|
-
};
|
|
12385
|
-
}
|
|
12386
|
-
//#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
|
-
};
|
|
13061
|
+
return hash.digest("hex").slice(0, 16);
|
|
13062
|
+
} catch {
|
|
13063
|
+
return sha256(rules.map((r) => String(r.run)).join("\0")).slice(0, 16);
|
|
13064
|
+
}
|
|
13065
|
+
}
|
|
13066
|
+
function hashDir(dir, hash, depth) {
|
|
13067
|
+
if (depth > 8) return;
|
|
13068
|
+
let entries;
|
|
13069
|
+
try {
|
|
13070
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
13071
|
+
} catch {
|
|
13072
|
+
recordDegradation("rules-tree-listing-unreadable");
|
|
13073
|
+
return;
|
|
13074
|
+
}
|
|
13075
|
+
entries.sort((a, b) => compareCodePoints(a.name, b.name));
|
|
13076
|
+
for (const entry of entries) {
|
|
13077
|
+
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
|
13078
|
+
const full = join(dir, entry.name);
|
|
13079
|
+
if (entry.isDirectory()) hashDir(full, hash, depth + 1);
|
|
13080
|
+
else if (entry.isFile() && /\.tsx?$/.test(entry.name)) try {
|
|
13081
|
+
hash.update(entry.name);
|
|
13082
|
+
hash.update(readFileSync(full));
|
|
13083
|
+
} catch {}
|
|
13084
|
+
}
|
|
13085
|
+
}
|
|
12512
13086
|
/**
|
|
12513
|
-
*
|
|
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
|
|
13087
|
+
* Content-addressed key for one file's rule-loop verdicts.
|
|
12523
13088
|
*
|
|
12524
|
-
*
|
|
12525
|
-
*
|
|
13089
|
+
* Audit C1: the key MUST identify the verdict's producer, not just the
|
|
13090
|
+
* bytes — two files with byte-identical text (a copied spec, a generated
|
|
13091
|
+
* snapshot) previously shared one entry, and the first file's cached
|
|
13092
|
+
* findings were re-emitted for the second with the wrong `file` stamp.
|
|
13093
|
+
* The key therefore folds in the repo-relative path AND the adapter id,
|
|
13094
|
+
* plus a parse-mode token (audit W9): a file whose analysis degraded to
|
|
13095
|
+
* the regex fallback (or skipped the AST path) must not collide with a
|
|
13096
|
+
* fully-AST-analyzed verdict for the same bytes — the fallback output
|
|
13097
|
+
* belongs only to the fallback mode.
|
|
12526
13098
|
*/
|
|
12527
|
-
|
|
12528
|
-
|
|
12529
|
-
|
|
12530
|
-
|
|
13099
|
+
function fileCacheKey(rulesDigest, fileText, identity) {
|
|
13100
|
+
const parseMode = identity.parseMode ?? "ast";
|
|
13101
|
+
return sha256(`${CACHE_VERSION}\u0000${rulesDigest}\u0000${identity.relPath}\u0000${identity.adapterId}\u0000${parseMode}\u0000${fileText}`);
|
|
13102
|
+
}
|
|
13103
|
+
/** No-op cache used when --cache is absent: zero stats, zero I/O. */
|
|
13104
|
+
const disabledScanCache = {
|
|
13105
|
+
stats: {
|
|
13106
|
+
hits: 0,
|
|
13107
|
+
misses: 0,
|
|
13108
|
+
file: ""
|
|
13109
|
+
},
|
|
13110
|
+
lookup: () => void 0,
|
|
13111
|
+
store: () => {},
|
|
13112
|
+
persist: () => {}
|
|
12531
13113
|
};
|
|
12532
13114
|
/**
|
|
12533
|
-
*
|
|
12534
|
-
*
|
|
12535
|
-
*
|
|
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.
|
|
13115
|
+
* Opens (and lazily creates) `<root>/.mjolnir/cache/scan-v<CACHE_VERSION>.json`. A
|
|
13116
|
+
* corrupt, hostile or future-versioned cache file degrades to a cold
|
|
13117
|
+
* cache — never fails the scan.
|
|
12542
13118
|
*/
|
|
12543
|
-
|
|
12544
|
-
|
|
12545
|
-
|
|
12546
|
-
|
|
12547
|
-
|
|
12548
|
-
|
|
12549
|
-
|
|
12550
|
-
|
|
12551
|
-
|
|
12552
|
-
|
|
13119
|
+
function createScanCache(root) {
|
|
13120
|
+
const dir = join(root, ".mjolnir", "cache");
|
|
13121
|
+
const file = join(dir, `scan-v${CACHE_VERSION}.json`);
|
|
13122
|
+
let entries = {};
|
|
13123
|
+
let dirty = false;
|
|
13124
|
+
const entryBytes = /* @__PURE__ */ new Map();
|
|
13125
|
+
let totalBytes = 0;
|
|
13126
|
+
try {
|
|
13127
|
+
if (existsSync(file)) {
|
|
13128
|
+
const stat = lstatSync(file);
|
|
13129
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_TOTAL_BYTES) throw new Error("invalid cache file");
|
|
13130
|
+
entries = parseJsonFile(readFileSync(file, "utf8"), file, isCacheFile).entries;
|
|
13131
|
+
for (const [k, v] of Object.entries(entries)) {
|
|
13132
|
+
const size = Buffer.byteLength(JSON.stringify(v), "utf8") + k.length + 4;
|
|
13133
|
+
entryBytes.set(k, size);
|
|
13134
|
+
totalBytes += size;
|
|
13135
|
+
}
|
|
13136
|
+
if (totalBytes > MAX_TOTAL_BYTES) throw new Error("cache byte budget exceeded");
|
|
13137
|
+
}
|
|
13138
|
+
} catch {
|
|
13139
|
+
entries = {};
|
|
13140
|
+
entryBytes.clear();
|
|
13141
|
+
totalBytes = 0;
|
|
13142
|
+
}
|
|
13143
|
+
return {
|
|
13144
|
+
stats: {
|
|
13145
|
+
hits: 0,
|
|
13146
|
+
misses: 0,
|
|
13147
|
+
file
|
|
13148
|
+
},
|
|
13149
|
+
lookup(key) {
|
|
13150
|
+
const entry = entries[key];
|
|
13151
|
+
if (!entry) {
|
|
13152
|
+
this.stats.misses++;
|
|
13153
|
+
return;
|
|
13154
|
+
}
|
|
13155
|
+
this.stats.hits++;
|
|
13156
|
+
delete entries[key];
|
|
13157
|
+
entries[key] = entry;
|
|
13158
|
+
return structuredClone(entry.findings);
|
|
13159
|
+
},
|
|
13160
|
+
store(key, findings, fileBudgetExceeded) {
|
|
13161
|
+
if (fileBudgetExceeded) return;
|
|
13162
|
+
if (findings.length > MAX_FINDINGS_PER_ENTRY) return;
|
|
13163
|
+
const entryJson = JSON.stringify(findings);
|
|
13164
|
+
const newBytes = Buffer.byteLength(entryJson, "utf8") + key.length + 4;
|
|
13165
|
+
if (newBytes > MAX_TOTAL_BYTES) return;
|
|
13166
|
+
const replacedBytes = entryBytes.get(key) ?? 0;
|
|
13167
|
+
delete entries[key];
|
|
13168
|
+
entries[key] = { findings: structuredClone(findings) };
|
|
13169
|
+
entryBytes.set(key, newBytes);
|
|
13170
|
+
totalBytes = totalBytes - replacedBytes + newBytes;
|
|
13171
|
+
let count = Object.keys(entries).length;
|
|
13172
|
+
while ((count > MAX_ENTRIES$1 || totalBytes > MAX_TOTAL_BYTES) && count > 1) {
|
|
13173
|
+
const oldest = Object.keys(entries)[0];
|
|
13174
|
+
totalBytes -= entryBytes.get(oldest);
|
|
13175
|
+
delete entries[oldest];
|
|
13176
|
+
entryBytes.delete(oldest);
|
|
13177
|
+
count--;
|
|
13178
|
+
}
|
|
13179
|
+
dirty = true;
|
|
13180
|
+
},
|
|
13181
|
+
persist() {
|
|
13182
|
+
if (!dirty) return;
|
|
13183
|
+
try {
|
|
13184
|
+
mkdirSync(dir, { recursive: true });
|
|
13185
|
+
writeFileAtomic(file, JSON.stringify({
|
|
13186
|
+
version: CACHE_VERSION,
|
|
13187
|
+
entries
|
|
13188
|
+
}), { encoding: "utf8" });
|
|
13189
|
+
} catch {}
|
|
13190
|
+
}
|
|
13191
|
+
};
|
|
13192
|
+
}
|
|
12553
13193
|
//#endregion
|
|
12554
13194
|
//#region src/reporter/theme.ts
|
|
12555
13195
|
/**
|
|
@@ -12565,8 +13205,8 @@ SCORE.trusted, SCORE.forged, SCORE.warning, SCORE.critical, STATUS.info, TEXT.on
|
|
|
12565
13205
|
*
|
|
12566
13206
|
* Symbols always accompany color (color-blind safe, R11).
|
|
12567
13207
|
*
|
|
12568
|
-
* Score-state colors come from ScoreState (
|
|
12569
|
-
* source of truth shared with the badge and
|
|
13208
|
+
* Score-state colors come from ScoreState (presentation.ts) — the single
|
|
13209
|
+
* source of truth shared with the badge, the dashboard and the site.
|
|
12570
13210
|
*
|
|
12571
13211
|
* Terminal robustness (Master-Stabilization-Plan Sprint 5 Task 22):
|
|
12572
13212
|
* box-drawing/gauge helpers accept an explicit width so callers can
|
|
@@ -13729,7 +14369,7 @@ function runForensics(target, options = {}) {
|
|
|
13729
14369
|
const base = stat.isFile() ? dirname(target) : target;
|
|
13730
14370
|
flakyMdPath = join(base, "FLAKY.md");
|
|
13731
14371
|
try {
|
|
13732
|
-
|
|
14372
|
+
writeFileAtomic(flakyMdPath, renderFlakyMd(report));
|
|
13733
14373
|
} catch {
|
|
13734
14374
|
flakyMdPath = void 0;
|
|
13735
14375
|
}
|
|
@@ -13831,11 +14471,11 @@ function listFiles(dir) {
|
|
|
13831
14471
|
return out.map((p) => p);
|
|
13832
14472
|
}
|
|
13833
14473
|
plainContext();
|
|
13834
|
-
function loadSuppressions(root) {
|
|
14474
|
+
function loadSuppressions(root, now = /* @__PURE__ */ new Date()) {
|
|
13835
14475
|
const { config } = loadConfig(root);
|
|
13836
14476
|
const entries = (config.ignore ?? []).map((ign) => ({
|
|
13837
14477
|
...ign,
|
|
13838
|
-
status: isSuppressionActive(ign,
|
|
14478
|
+
status: isSuppressionActive(ign, now) ? "active" : "expired"
|
|
13839
14479
|
}));
|
|
13840
14480
|
return {
|
|
13841
14481
|
total: entries.length,
|
|
@@ -14568,6 +15208,7 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14568
15208
|
let rulesPartial = false;
|
|
14569
15209
|
let parseFailed = 0;
|
|
14570
15210
|
let parseFallbacks = 0;
|
|
15211
|
+
let astFallbackFiles = 0;
|
|
14571
15212
|
let scanned = 0;
|
|
14572
15213
|
let analyzed = 0;
|
|
14573
15214
|
for (const path of testFiles) {
|
|
@@ -14593,8 +15234,6 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14593
15234
|
skippedFiles++;
|
|
14594
15235
|
continue;
|
|
14595
15236
|
}
|
|
14596
|
-
let fileBudgetExceeded = false;
|
|
14597
|
-
let fileRuleFailed = false;
|
|
14598
15237
|
const relPath = relative(workspace.root, path).replaceAll("\\", "/");
|
|
14599
15238
|
if (!isCiAdapter) {
|
|
14600
15239
|
const lang = {
|
|
@@ -14613,6 +15252,10 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14613
15252
|
});
|
|
14614
15253
|
}
|
|
14615
15254
|
const wantsAst = adapter.parseAst !== void 0 && Date.now() <= deadline;
|
|
15255
|
+
if (adapter.parseAst !== void 0 && !wantsAst) {
|
|
15256
|
+
truncationReasons.add("ast-budget-fallback");
|
|
15257
|
+
astFallbackFiles++;
|
|
15258
|
+
}
|
|
14616
15259
|
const identity = (mode) => ({
|
|
14617
15260
|
relPath,
|
|
14618
15261
|
adapterId: adapter.id,
|
|
@@ -14620,79 +15263,106 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14620
15263
|
});
|
|
14621
15264
|
let cacheKey = fileCacheKey(rulesDigest, text, identity(wantsAst ? "ast" : "regex"));
|
|
14622
15265
|
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;
|
|
15266
|
+
const executor = createExecutor("sequential", 1, async (job) => {
|
|
15267
|
+
if (job.cacheHit !== void 0) return {
|
|
15268
|
+
path: job.path,
|
|
15269
|
+
status: "CACHE_HIT",
|
|
15270
|
+
findings: [...job.cacheHit],
|
|
15271
|
+
parseFallback: false
|
|
15272
|
+
};
|
|
15273
|
+
let parseFallback = false;
|
|
15274
|
+
let fileRuleFailed = false;
|
|
15275
|
+
let fileBudgetExceeded = false;
|
|
15276
|
+
let parsedAst;
|
|
15277
|
+
try {
|
|
15278
|
+
if (adapter.parseAst && job.wantsAst) {
|
|
15279
|
+
hooks.onProgress?.({
|
|
15280
|
+
phase: "rules",
|
|
15281
|
+
done: scanned,
|
|
15282
|
+
total: testFiles.length,
|
|
15283
|
+
detail: job.path
|
|
15284
|
+
});
|
|
15285
|
+
parsedAst = await adapter.parseAst({
|
|
15286
|
+
path: job.path,
|
|
15287
|
+
text: job.text
|
|
15288
|
+
});
|
|
14659
15289
|
}
|
|
14660
|
-
|
|
14661
|
-
|
|
14662
|
-
|
|
14663
|
-
|
|
14664
|
-
|
|
14665
|
-
|
|
14666
|
-
|
|
14667
|
-
|
|
14668
|
-
|
|
14669
|
-
|
|
15290
|
+
const actualMode = parsedAst ? "ast" : "regex";
|
|
15291
|
+
if (job.wantsAst && actualMode === "regex") {
|
|
15292
|
+
parseFallback = true;
|
|
15293
|
+
parseFallbacks++;
|
|
15294
|
+
cacheKey = fileCacheKey(rulesDigest, job.text, identity(actualMode));
|
|
15295
|
+
const fallbackFindings = cache.lookup(cacheKey);
|
|
15296
|
+
if (fallbackFindings) return {
|
|
15297
|
+
path: job.path,
|
|
15298
|
+
status: "CACHE_HIT",
|
|
15299
|
+
findings: [...fallbackFindings],
|
|
15300
|
+
parseFallback: true
|
|
15301
|
+
};
|
|
14670
15302
|
}
|
|
14671
|
-
|
|
14672
|
-
|
|
14673
|
-
|
|
14674
|
-
|
|
15303
|
+
const fileForRules = parsedAst ? {
|
|
15304
|
+
path: job.path,
|
|
15305
|
+
text: job.text,
|
|
15306
|
+
ast: parsedAst.ast
|
|
15307
|
+
} : {
|
|
15308
|
+
path: job.path,
|
|
15309
|
+
text: job.text
|
|
15310
|
+
};
|
|
15311
|
+
const produced = [];
|
|
15312
|
+
adapter.runRules(activeRules, fileForRules, (f, ruleId, category) => {
|
|
15313
|
+
if (!isValidFindingRecord(f)) {
|
|
15314
|
+
fileRuleFailed = true;
|
|
15315
|
+
onRuleCrash?.(ruleId, job.path, /* @__PURE__ */ new Error(`malformed finding record rejected (severity/line/message must be present, severity ∈ error|warning|info): ${JSON.stringify(f)}`));
|
|
15316
|
+
return;
|
|
15317
|
+
}
|
|
15318
|
+
produced.push({
|
|
15319
|
+
...f,
|
|
15320
|
+
ruleId,
|
|
15321
|
+
category
|
|
15322
|
+
});
|
|
15323
|
+
}, (ruleId, error) => {
|
|
15324
|
+
fileRuleFailed = true;
|
|
15325
|
+
onRuleCrash?.(ruleId, job.path, error);
|
|
15326
|
+
}, {
|
|
15327
|
+
deadline: Math.min(deadline, Date.now() + LIMITS$1.maxFileAnalysisMs),
|
|
15328
|
+
onExceeded: () => {
|
|
15329
|
+
rulesPartial = true;
|
|
15330
|
+
skippedFiles++;
|
|
15331
|
+
truncationReasons.add("file-budget");
|
|
15332
|
+
fileBudgetExceeded = true;
|
|
15333
|
+
}
|
|
14675
15334
|
});
|
|
14676
|
-
|
|
14677
|
-
fileRuleFailed
|
|
14678
|
-
|
|
14679
|
-
|
|
14680
|
-
|
|
14681
|
-
|
|
14682
|
-
|
|
14683
|
-
|
|
14684
|
-
|
|
14685
|
-
|
|
14686
|
-
|
|
14687
|
-
|
|
14688
|
-
|
|
14689
|
-
|
|
14690
|
-
|
|
14691
|
-
|
|
14692
|
-
|
|
14693
|
-
|
|
14694
|
-
|
|
14695
|
-
|
|
15335
|
+
if (!fileRuleFailed && !fileBudgetExceeded) analyzed++;
|
|
15336
|
+
if (!fileRuleFailed) cache.store(cacheKey, produced, fileBudgetExceeded);
|
|
15337
|
+
return {
|
|
15338
|
+
path: job.path,
|
|
15339
|
+
status: "OK",
|
|
15340
|
+
findings: produced,
|
|
15341
|
+
parseFallback
|
|
15342
|
+
};
|
|
15343
|
+
} catch {
|
|
15344
|
+
if (job.wantsAst) parseFallbacks++;
|
|
15345
|
+
skippedFiles++;
|
|
15346
|
+
parseFailed++;
|
|
15347
|
+
return {
|
|
15348
|
+
path: job.path,
|
|
15349
|
+
status: "FAILED",
|
|
15350
|
+
findings: [],
|
|
15351
|
+
parseFallback
|
|
15352
|
+
};
|
|
15353
|
+
} finally {
|
|
15354
|
+
parsedAst?.dispose();
|
|
15355
|
+
}
|
|
15356
|
+
});
|
|
15357
|
+
const outcome = (await executeFiles([{
|
|
15358
|
+
path: relPath,
|
|
15359
|
+
text,
|
|
15360
|
+
wantsAst,
|
|
15361
|
+
...cachedFindings !== void 0 ? { cacheHit: cachedFindings } : {}
|
|
15362
|
+
}], executor))[0];
|
|
15363
|
+
if (outcome?.status === "CACHE_HIT" || outcome?.status === "OK") {
|
|
15364
|
+
for (const f of outcome.findings) findings.push(f);
|
|
15365
|
+
if (cachedFindings !== void 0 && outcome.status === "CACHE_HIT") analyzed++;
|
|
14696
15366
|
}
|
|
14697
15367
|
}
|
|
14698
15368
|
return {
|
|
@@ -14703,7 +15373,8 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14703
15373
|
parseFailed,
|
|
14704
15374
|
parseFallbacks,
|
|
14705
15375
|
scanned,
|
|
14706
|
-
analyzed
|
|
15376
|
+
analyzed,
|
|
15377
|
+
astFallbackFiles
|
|
14707
15378
|
};
|
|
14708
15379
|
}
|
|
14709
15380
|
/**
|
|
@@ -14783,12 +15454,16 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
|
|
|
14783
15454
|
const runtimeReportPath = discoveredReport?.path;
|
|
14784
15455
|
const runtimeIncomplete = discoveredReport !== void 0 && discoveredReport.report.analysisComplete !== true;
|
|
14785
15456
|
let forensicVerdicts;
|
|
15457
|
+
let evidenceRecords = [];
|
|
14786
15458
|
if (discoveredReport && discoveredReport.report.analysisComplete === true) try {
|
|
14787
|
-
buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
|
|
15459
|
+
evidenceRecords = buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
|
|
14788
15460
|
stampRuntimeCorroboration(findings, discoveredReport.report, workspace.root);
|
|
14789
15461
|
forensicVerdicts = summarizeForensicVerdicts(discoveredReport.report);
|
|
14790
|
-
} catch {
|
|
15462
|
+
} catch {
|
|
15463
|
+
evidenceRecords = [];
|
|
15464
|
+
}
|
|
14791
15465
|
return {
|
|
15466
|
+
evidenceRecords,
|
|
14792
15467
|
testDeclarationCount,
|
|
14793
15468
|
scopeInfo,
|
|
14794
15469
|
suppressionCount,
|
|
@@ -14927,10 +15602,14 @@ function assembleScanResult(o) {
|
|
|
14927
15602
|
scopeUnrecognized: o.scopeUnrecognized,
|
|
14928
15603
|
parseFailed: o.parseFailed,
|
|
14929
15604
|
parseFallbacks: o.parseFallbacks ?? 0,
|
|
15605
|
+
...o.astFallbackFiles !== void 0 ? { astFallbackFiles: o.astFallbackFiles } : {},
|
|
15606
|
+
...o.degradations !== void 0 ? { degradations: o.degradations } : {},
|
|
14930
15607
|
...o.scopeInfo.degraded !== void 0 ? { scopeDegraded: o.scopeInfo.degraded } : {},
|
|
14931
15608
|
...o.runtimeIncomplete !== void 0 ? { runtimeIncomplete: o.runtimeIncomplete } : {},
|
|
14932
15609
|
identityIncomplete
|
|
14933
15610
|
});
|
|
15611
|
+
const repository = bindRepository(o.scanRoot.root);
|
|
15612
|
+
const candidate = readCandidateBinding(o.scanRoot.root);
|
|
14934
15613
|
const runIdentity = buildRunIdentity({
|
|
14935
15614
|
files: inputSnapshot,
|
|
14936
15615
|
rules: [...o.REVISION_BY_RULE_ID.entries()].map(([id, detectorRevision]) => ({
|
|
@@ -14943,9 +15622,17 @@ function assembleScanResult(o) {
|
|
|
14943
15622
|
trustModelVersion: TRUST_MODEL_VERSION,
|
|
14944
15623
|
scoringModelVersion: SCORING_MODEL_VERSION,
|
|
14945
15624
|
frameworkSupportMatrixVersion: FRAMEWORK_SUPPORT_MATRIX_VERSION,
|
|
14946
|
-
evidenceSchemaVersions: [1]
|
|
15625
|
+
evidenceSchemaVersions: [1],
|
|
15626
|
+
...repository,
|
|
15627
|
+
...candidate !== null ? { candidate } : {}
|
|
15628
|
+
});
|
|
15629
|
+
const evidenceGraph = buildEvidenceGraph({
|
|
15630
|
+
runId: runIdentity,
|
|
15631
|
+
...candidate !== null ? { candidate: {
|
|
15632
|
+
manifestId: candidate.manifestId,
|
|
15633
|
+
candidateSha: candidate.candidateSha
|
|
15634
|
+
} } : {}
|
|
14947
15635
|
});
|
|
14948
|
-
const evidenceGraph = buildEvidenceGraph({ runId: runIdentity });
|
|
14949
15636
|
const hasTests = o.testFileCount > 0 && o.testDeclarationCount > 0;
|
|
14950
15637
|
const suiteInvalidatedBy = [...new Set(o.findings.filter((f) => SUITE_INVALIDATING_RULE_IDS.has(f.ruleId)).map((f) => f.ruleId))].sort();
|
|
14951
15638
|
const finalScore = hasTests ? completion.partial && scopeAdjustedTotal >= 100 ? 99 : scopeAdjustedTotal : null;
|
|
@@ -14955,6 +15642,11 @@ function assembleScanResult(o) {
|
|
|
14955
15642
|
scopeIntegrity,
|
|
14956
15643
|
runIdentity,
|
|
14957
15644
|
evidenceGraph,
|
|
15645
|
+
evidence: {
|
|
15646
|
+
records: o.evidenceRecords ?? [],
|
|
15647
|
+
counts: countEvidence(o.evidenceRecords ?? []),
|
|
15648
|
+
artifact: o.runtimeReportPath ?? null
|
|
15649
|
+
},
|
|
14958
15650
|
score: finalScore,
|
|
14959
15651
|
...hasTests ? {} : { reason: "no-tests-found" },
|
|
14960
15652
|
frameworks: o.frameworks.frameworks,
|
|
@@ -15030,13 +15722,15 @@ function assembleScanResult(o) {
|
|
|
15030
15722
|
*/
|
|
15031
15723
|
async function runScan(args, hooks = {}) {
|
|
15032
15724
|
const started = Date.now();
|
|
15725
|
+
const degradationWindow = beginDegradationWindow();
|
|
15033
15726
|
const requestedDuration = Number.isFinite(args.maxDurationMs) ? args.maxDurationMs : DEFAULT_MAX_DURATION_MS$1;
|
|
15034
15727
|
const deadline = started + Math.min(Math.max(1, requestedDuration), MAX_DURATION_MS$1);
|
|
15035
15728
|
const discovered = discoverWorkspace(args.target);
|
|
15036
15729
|
const targetAbs = resolve(args.target);
|
|
15037
15730
|
const scanRoot = discovered && discovered.root !== targetAbs && targetAbs.startsWith(discovered.root + sep) ? {
|
|
15038
15731
|
...discovered,
|
|
15039
|
-
root: targetAbs
|
|
15732
|
+
root: targetAbs,
|
|
15733
|
+
packageJson: {}
|
|
15040
15734
|
} : discovered ?? fallbackWorkspace(targetAbs);
|
|
15041
15735
|
const workspace = scanRoot;
|
|
15042
15736
|
if (args.verbose) hooks.onConfigWarning?.(`verbose: scan root (config/ignore anchor): ${workspace.root}`);
|
|
@@ -15121,6 +15815,7 @@ async function runScan(args, hooks = {}) {
|
|
|
15121
15815
|
rulesPartial = analysis.rulesPartial;
|
|
15122
15816
|
parseFailed = analysis.parseFailed;
|
|
15123
15817
|
const parseFallbacks = analysis.parseFallbacks;
|
|
15818
|
+
const astFallbackFiles = analysis.astFallbackFiles;
|
|
15124
15819
|
const scanned = analysis.scanned;
|
|
15125
15820
|
const analyzed = analysis.analyzed;
|
|
15126
15821
|
const postScan = applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, declarationsByFile, testDeclarationCount, tierByRuleId, REVISION_BY_RULE_ID);
|
|
@@ -15139,6 +15834,8 @@ async function runScan(args, hooks = {}) {
|
|
|
15139
15834
|
scopeUnrecognized,
|
|
15140
15835
|
parseFailed,
|
|
15141
15836
|
parseFallbacks,
|
|
15837
|
+
astFallbackFiles,
|
|
15838
|
+
degradations: summarizeDegradations(degradationsSince(degradationWindow)),
|
|
15142
15839
|
scanned,
|
|
15143
15840
|
analyzed,
|
|
15144
15841
|
testFiles,
|
|
@@ -15165,6 +15862,39 @@ async function runScan(args, hooks = {}) {
|
|
|
15165
15862
|
resetTsMorphProject();
|
|
15166
15863
|
return result;
|
|
15167
15864
|
}
|
|
15865
|
+
//#endregion
|
|
15866
|
+
//#region src/cli-io.ts
|
|
15867
|
+
/**
|
|
15868
|
+
* The ONE error-to-message derivation in `src/`.
|
|
15869
|
+
*
|
|
15870
|
+
* This tree carried four near-copies: two `errorMessage` (byte-identical) and
|
|
15871
|
+
* two `errorText`. The two `errorText` copies were not identical, which is the
|
|
15872
|
+
* part that mattered — `String(someObject)` renders `[object Object]`, so the
|
|
15873
|
+
* copy that lost the payload turned a hostile non-Error throw into a message
|
|
15874
|
+
* that names nothing. The richer arms are kept here, so every call site gets
|
|
15875
|
+
* the informative one and the difference stops being a per-file accident.
|
|
15876
|
+
*
|
|
15877
|
+
* The arms, in order:
|
|
15878
|
+
* Error -> `.message`. The 99% case, and the only one that
|
|
15879
|
+
* carries a stack the caller may still want.
|
|
15880
|
+
* string -> itself. A thrown string is already the message.
|
|
15881
|
+
* plain object -> `JSON.stringify`. A non-Error throw of `{ code:
|
|
15882
|
+
* "EISDIR" }` is a REAL failure shape in Node, and
|
|
15883
|
+
* `[object Object]` hides the only field that says
|
|
15884
|
+
* what went wrong.
|
|
15885
|
+
* anything else -> `String(err)`. Symbols and undefined included, so
|
|
15886
|
+
* nothing vanishes.
|
|
15887
|
+
*
|
|
15888
|
+
* The call this replaces was `err instanceof Error ? err.message : String(err)`
|
|
15889
|
+
* in `src/mcp/server.ts` and `src/commands/doctor.ts`; `tests/cli/summary.spec.ts`
|
|
15890
|
+
* already pins the object arm.
|
|
15891
|
+
*/
|
|
15892
|
+
function errorMessage(err) {
|
|
15893
|
+
if (err instanceof Error) return err.message;
|
|
15894
|
+
if (typeof err === "string") return err;
|
|
15895
|
+
if (typeof err === "object" && err !== null) return JSON.stringify(err);
|
|
15896
|
+
return String(err);
|
|
15897
|
+
}
|
|
15168
15898
|
/**
|
|
15169
15899
|
* Fields that participate in the verification digest: identity (ruleId,
|
|
15170
15900
|
* detectorRevision, file, line, column) + evidence (severity,
|
|
@@ -15317,6 +16047,11 @@ function explainRule(ruleId, fixturesRoot) {
|
|
|
15317
16047
|
ok: false,
|
|
15318
16048
|
error: `Unknown rule ID "${ruleId}". Run \`mjolnir rules\` for the full catalog.`
|
|
15319
16049
|
};
|
|
16050
|
+
const window = beginDegradationWindow();
|
|
16051
|
+
const degraded = () => {
|
|
16052
|
+
const counts = summarizeDegradations(degradationsSince(window));
|
|
16053
|
+
return counts.length > 0 ? { exampleDegraded: counts } : {};
|
|
16054
|
+
};
|
|
15320
16055
|
const fixturePath = firstFixtureFile(join(fixturesRoot, ruleId, "must-fire"));
|
|
15321
16056
|
if (!fixturePath) return {
|
|
15322
16057
|
ok: true,
|
|
@@ -15326,9 +16061,11 @@ function explainRule(ruleId, fixturesRoot) {
|
|
|
15326
16061
|
try {
|
|
15327
16062
|
text = readFileSync(fixturePath, "utf8").replace(/\r\n/g, "\n");
|
|
15328
16063
|
} catch {
|
|
16064
|
+
recordDegradation("explain-fixture-unreadable");
|
|
15329
16065
|
return {
|
|
15330
16066
|
ok: true,
|
|
15331
|
-
rule
|
|
16067
|
+
rule,
|
|
16068
|
+
...degraded()
|
|
15332
16069
|
};
|
|
15333
16070
|
}
|
|
15334
16071
|
const normalizedPath = fixturePath.replaceAll("\\", "/");
|
|
@@ -15338,9 +16075,11 @@ function explainRule(ruleId, fixturesRoot) {
|
|
|
15338
16075
|
else try {
|
|
15339
16076
|
ast = isAzurePipelineFixture(normalizedPath) ? parseAzurePipeline(text) : parseWorkflow(text);
|
|
15340
16077
|
} catch {
|
|
16078
|
+
recordDegradation("explain-workflow-parse-failed");
|
|
15341
16079
|
return {
|
|
15342
16080
|
ok: true,
|
|
15343
|
-
rule
|
|
16081
|
+
rule,
|
|
16082
|
+
...degraded()
|
|
15344
16083
|
};
|
|
15345
16084
|
}
|
|
15346
16085
|
}
|
|
@@ -15357,9 +16096,11 @@ function explainRule(ruleId, fixturesRoot) {
|
|
|
15357
16096
|
codeText
|
|
15358
16097
|
});
|
|
15359
16098
|
} catch {
|
|
16099
|
+
recordDegradation("explain-rule-crash");
|
|
15360
16100
|
return {
|
|
15361
16101
|
ok: true,
|
|
15362
|
-
rule
|
|
16102
|
+
rule,
|
|
16103
|
+
...degraded()
|
|
15363
16104
|
};
|
|
15364
16105
|
}
|
|
15365
16106
|
const example = findings[0];
|
|
@@ -16120,15 +16861,11 @@ async function handleToolCall(call) {
|
|
|
16120
16861
|
id: call.id,
|
|
16121
16862
|
error: {
|
|
16122
16863
|
code: MCP_ERRORS.INTERNAL,
|
|
16123
|
-
message: errorMessage
|
|
16864
|
+
message: errorMessage(err)
|
|
16124
16865
|
}
|
|
16125
16866
|
};
|
|
16126
16867
|
}
|
|
16127
16868
|
}
|
|
16128
|
-
/** Normalize any thrown value to a string (unit-tested both arms). */
|
|
16129
|
-
function errorMessage$1(err) {
|
|
16130
|
-
return err instanceof Error ? err.message : String(err);
|
|
16131
|
-
}
|
|
16132
16869
|
/** JSON-RPC request shape validation (strict — §21). */
|
|
16133
16870
|
function validateRequest(msg) {
|
|
16134
16871
|
if (typeof msg !== "object" || msg === null) return {
|
|
@@ -16231,14 +16968,6 @@ async function handleMcpMessage(msg) {
|
|
|
16231
16968
|
};
|
|
16232
16969
|
}
|
|
16233
16970
|
//#endregion
|
|
16234
|
-
//#region src/cli-io.ts
|
|
16235
|
-
/**
|
|
16236
|
-
* The single error-to-message derivation shared by every catch site:
|
|
16237
|
-
* Error instances render their message; anything else is stringified
|
|
16238
|
-
* honestly (a hostile non-Error throw must surface, not vanish).
|
|
16239
|
-
*/
|
|
16240
|
-
const errorMessage = (err) => err instanceof Error ? err.message : String(err);
|
|
16241
|
-
//#endregion
|
|
16242
16971
|
//#region src/mcp/transport.ts
|
|
16243
16972
|
/**
|
|
16244
16973
|
* MCP stdio transport loop (blueprint §21 — the framing around the
|
|
@@ -16337,6 +17066,133 @@ async function handleStdioLine(line, write) {
|
|
|
16337
17066
|
write(`${JSON.stringify(response)}\n`);
|
|
16338
17067
|
}
|
|
16339
17068
|
//#endregion
|
|
17069
|
+
//#region src/integrations/sentry.ts
|
|
17070
|
+
/**
|
|
17071
|
+
* Sentry crash reporting — opt-in, and inert unless a DSN is configured.
|
|
17072
|
+
*
|
|
17073
|
+
* Why opt-in: Mjölnir ships as an `npx` CLI over other people's private
|
|
17074
|
+
* repositories. A tool that reads a codebase must never phone home on its
|
|
17075
|
+
* own, so the ONLY switch is an explicit `SENTRY_DSN` in the environment.
|
|
17076
|
+
* Absent that, every function here returns immediately and `@sentry/node` is
|
|
17077
|
+
* never imported — zero network, zero added startup cost on a path whose
|
|
17078
|
+
* whole selling point is scan speed.
|
|
17079
|
+
*
|
|
17080
|
+
* Why an optional peer dependency: `@sentry/node` is ~1.5 MB unpacked and
|
|
17081
|
+
* nothing in a code scanner needs it. Making it a hard `dependency` would
|
|
17082
|
+
* tax every install for a feature most users never turn on, so it is an
|
|
17083
|
+
* optional peer: installs on request (`npm i @sentry/node`), absent by
|
|
17084
|
+
* default, and a missing module is handled as "reporting unavailable"
|
|
17085
|
+
* rather than as a crash.
|
|
17086
|
+
*
|
|
17087
|
+
* Capture points are deliberately the two EXISTING top-level catch blocks
|
|
17088
|
+
* (cli.ts entry, mcp/stdio.ts) rather than `process.on("uncaughtException")`
|
|
17089
|
+
* / `("unhandledRejection")` handlers. Registering those would silently
|
|
17090
|
+
* change the CLI's frozen exit-code contract (§24.1) and could keep a
|
|
17091
|
+
* process alive after a fatal error. One capture point per surface is
|
|
17092
|
+
* enough to answer "did Mjölnir itself break, and where".
|
|
17093
|
+
*
|
|
17094
|
+
* What is deliberately NOT sent: no user data (`sendDefaultPii: false`), no
|
|
17095
|
+
* traced request/span data (`tracesSampleRate: 0` — a batch CLI has no
|
|
17096
|
+
* request to trace), no console breadcrumbs, and no scan findings or file
|
|
17097
|
+
* contents. Events carry the error, its stack, the release, and the surface
|
|
17098
|
+
* that raised it.
|
|
17099
|
+
*/
|
|
17100
|
+
/** The loaded SDK, or null while reporting is off/unavailable. */
|
|
17101
|
+
let sdk = null;
|
|
17102
|
+
/**
|
|
17103
|
+
* Flush budget for the process-exit paths. Long enough for one HTTPS
|
|
17104
|
+
* envelope on a slow link, short enough that a broken network cannot turn
|
|
17105
|
+
* a crashed CLI into a hung one. Sentry's own default is 2s.
|
|
17106
|
+
*/
|
|
17107
|
+
const FLUSH_TIMEOUT_MS = 2e3;
|
|
17108
|
+
/** Writes a diagnostic without touching stdout. */
|
|
17109
|
+
function warn(message) {
|
|
17110
|
+
process.stderr.write(`mjolnir: sentry — ${message}\n`);
|
|
17111
|
+
}
|
|
17112
|
+
/**
|
|
17113
|
+
* The release name Sentry groups events under. Matches the npm package
|
|
17114
|
+
* name so a release created from a tag and a release reported by the SDK
|
|
17115
|
+
* are the same release, not two half-populated ones.
|
|
17116
|
+
*/
|
|
17117
|
+
const SENTRY_RELEASE = `mjolnir-qa@${ENGINE_VERSION}`;
|
|
17118
|
+
/**
|
|
17119
|
+
* Resolve the environment tag. Unset means "a developer's machine" unless
|
|
17120
|
+
* CI says otherwise: a `npx` run is not a production event, and filing
|
|
17121
|
+
* those under `production` would poison the issue stream with noise from
|
|
17122
|
+
* end users triaging their own repos.
|
|
17123
|
+
*/
|
|
17124
|
+
function resolveEnvironment() {
|
|
17125
|
+
const fromEnv = process.env.SENTRY_ENVIRONMENT?.trim();
|
|
17126
|
+
if (fromEnv) return fromEnv;
|
|
17127
|
+
return process.env.CI ? "ci" : "local";
|
|
17128
|
+
}
|
|
17129
|
+
/**
|
|
17130
|
+
* Turn reporting on if — and only if — a DSN is configured. Safe to call
|
|
17131
|
+
* from every entry point: the second call is a no-op that returns the
|
|
17132
|
+
* already-decided answer, so a process that reaches two surfaces cannot
|
|
17133
|
+
* initialize the SDK twice.
|
|
17134
|
+
*
|
|
17135
|
+
* Returns true when reporting is live. Never throws: a monitoring
|
|
17136
|
+
* integration that can take down the tool it monitors has failed at its
|
|
17137
|
+
* one job.
|
|
17138
|
+
*/
|
|
17139
|
+
async function initSentry() {
|
|
17140
|
+
if (sdk) return true;
|
|
17141
|
+
const dsn = process.env.SENTRY_DSN?.trim();
|
|
17142
|
+
if (!dsn) return false;
|
|
17143
|
+
let loaded;
|
|
17144
|
+
try {
|
|
17145
|
+
loaded = await import("@sentry/node");
|
|
17146
|
+
} catch {
|
|
17147
|
+
warn("SENTRY_DSN is set but @sentry/node is not installed. Install it with: npm install @sentry/node");
|
|
17148
|
+
return false;
|
|
17149
|
+
}
|
|
17150
|
+
try {
|
|
17151
|
+
loaded.init({
|
|
17152
|
+
dsn,
|
|
17153
|
+
release: SENTRY_RELEASE,
|
|
17154
|
+
environment: resolveEnvironment(),
|
|
17155
|
+
tracesSampleRate: 0,
|
|
17156
|
+
sendDefaultPii: false,
|
|
17157
|
+
attachStacktrace: true
|
|
17158
|
+
});
|
|
17159
|
+
} catch (error) {
|
|
17160
|
+
warn(`SDK init failed (${error instanceof Error ? error.message : String(error)}). Crash reporting stays off.`);
|
|
17161
|
+
return false;
|
|
17162
|
+
}
|
|
17163
|
+
sdk = loaded;
|
|
17164
|
+
return true;
|
|
17165
|
+
}
|
|
17166
|
+
/**
|
|
17167
|
+
* Report one fatal error. `surface` names the entry point that raised it
|
|
17168
|
+
* ("cli" or "mcp") so the issue stream can be split by how Mjölnir was
|
|
17169
|
+
* being used when it broke. A non-Error throw is reported as-is rather
|
|
17170
|
+
* than dropped: a hostile throw value is exactly the case worth seeing.
|
|
17171
|
+
*/
|
|
17172
|
+
function captureInternalError(error, surface) {
|
|
17173
|
+
if (!sdk) return;
|
|
17174
|
+
try {
|
|
17175
|
+
sdk.captureException(error, { tags: { surface } });
|
|
17176
|
+
} catch (captureError) {
|
|
17177
|
+
warn(`capture failed (${captureError instanceof Error ? captureError.message : String(captureError)}).`);
|
|
17178
|
+
}
|
|
17179
|
+
}
|
|
17180
|
+
/**
|
|
17181
|
+
* Deliver anything buffered before the process exits. Awaits the SDK's
|
|
17182
|
+
* own transport drain and returns whether the envelope was handed off;
|
|
17183
|
+
* the caller is on an exit path, so the answer is deliberately not
|
|
17184
|
+
* surfaced as a failure — a lost crash report must not change the exit
|
|
17185
|
+
* code a caller (or CI) is about to read.
|
|
17186
|
+
*/
|
|
17187
|
+
async function flushSentry(timeoutMs = FLUSH_TIMEOUT_MS) {
|
|
17188
|
+
if (!sdk) return false;
|
|
17189
|
+
try {
|
|
17190
|
+
return await sdk.flush(timeoutMs);
|
|
17191
|
+
} catch {
|
|
17192
|
+
return false;
|
|
17193
|
+
}
|
|
17194
|
+
}
|
|
17195
|
+
//#endregion
|
|
16340
17196
|
//#region src/mcp/stdio.ts
|
|
16341
17197
|
/**
|
|
16342
17198
|
* The standalone MCP stdio binary (blueprint §21), built to
|
|
@@ -16355,9 +17211,14 @@ async function handleStdioLine(line, write) {
|
|
|
16355
17211
|
* subprocess coverage cannot merge into the parent istanbul report,
|
|
16356
17212
|
* same class as dist/**), so it is excluded from the coverage ratchet.
|
|
16357
17213
|
*/
|
|
16358
|
-
|
|
17214
|
+
initSentry();
|
|
17215
|
+
runStdioTransport(process.stdin, process.stdout).then(() => process.exit(0)).catch(async (err) => {
|
|
17216
|
+
captureInternalError(err, "mcp");
|
|
17217
|
+
await flushSentry();
|
|
16359
17218
|
process.stderr.write(`mjolnir mcp fatal: ${err instanceof Error ? err.message : err}\n`);
|
|
16360
17219
|
process.exitCode = 20;
|
|
16361
17220
|
});
|
|
16362
17221
|
//#endregion
|
|
16363
17222
|
export {};
|
|
17223
|
+
|
|
17224
|
+
//# sourceMappingURL=stdio.mjs.map
|