mjolnir-qa 2.1.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +410 -2
- package/README.ar.md +58 -29
- package/README.bn.md +58 -29
- package/README.br.md +58 -29
- package/README.bs.md +58 -29
- package/README.da.md +59 -30
- package/README.de.md +59 -30
- package/README.es.md +58 -29
- package/README.fr.md +59 -30
- package/README.gr.md +58 -29
- package/README.he.md +58 -29
- package/README.it.md +58 -29
- package/README.ja.md +58 -29
- package/README.ko.md +58 -29
- package/README.md +95 -56
- package/README.no.md +58 -29
- package/README.pl.md +58 -29
- package/README.ru.md +58 -29
- package/README.th.md +58 -29
- package/README.tr.md +58 -29
- package/README.uk.md +58 -29
- package/README.vi.md +58 -29
- package/README.zh.md +58 -29
- package/README.zht.md +58 -29
- package/dist/cli.d.mts +232 -16
- package/dist/cli.mjs +2035 -1262
- package/dist/mcp/stdio.mjs +1559 -517
- package/dist/scan-pipeline-Bg-46cLS.mjs +2 -0
- package/dist/{scan-pipeline-MQGUSey3.mjs → scan-pipeline-CCJdEKNa.mjs} +1722 -718
- package/package.json +59 -8
- package/dist/scan-pipeline-C_Rs4u8N.mjs +0 -2
package/dist/mcp/stdio.mjs
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
3
|
import { Buffer as Buffer$1 } from "node:buffer";
|
|
4
|
-
import { createHash } from "node:crypto";
|
|
5
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync,
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
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)
|
|
@@ -56,6 +82,38 @@ const DEDUCTIONS = {
|
|
|
56
82
|
info: 1
|
|
57
83
|
};
|
|
58
84
|
//#endregion
|
|
85
|
+
//#region src/engine/completion.ts
|
|
86
|
+
function deriveCompletion(input) {
|
|
87
|
+
const truncationReasons = [...new Set(input.truncationReasons)].sort();
|
|
88
|
+
const reasons = /* @__PURE__ */ new Set();
|
|
89
|
+
if (input.discoveryTruncated) reasons.add("discovery-truncated");
|
|
90
|
+
if (input.rulesPartial) reasons.add("rules-partial");
|
|
91
|
+
if (input.skippedFiles > 0) reasons.add(`skipped-files:${input.skippedFiles}`);
|
|
92
|
+
if (input.rulesCrashed > 0) reasons.add(`rules-crashed:${input.rulesCrashed}`);
|
|
93
|
+
if (input.scopeIgnored > 0) reasons.add(`scope-ignored:${input.scopeIgnored}`);
|
|
94
|
+
if (input.scopeUnrecognized > 0) reasons.add(`scope-unrecognized:${input.scopeUnrecognized}`);
|
|
95
|
+
if (input.parseFailed > 0) reasons.add(`parse-failed:${input.parseFailed}`);
|
|
96
|
+
if (input.parseFallbacks && input.parseFallbacks > 0) reasons.add(`parse-fallbacks:${input.parseFallbacks}`);
|
|
97
|
+
if (input.scopeDegraded) reasons.add(`scope-degraded:${input.scopeDegraded}`);
|
|
98
|
+
if (input.runtimeIncomplete) reasons.add("runtime-incomplete");
|
|
99
|
+
if (input.identityIncomplete) reasons.add("identity-incomplete");
|
|
100
|
+
for (const reason of truncationReasons) reasons.add(`truncated:${reason}`);
|
|
101
|
+
const discoveryPartial = input.discoveryTruncated || input.scopeIgnored > 0 || input.scopeUnrecognized > 0 || input.scopeDegraded !== void 0;
|
|
102
|
+
const rulesPartial = input.rulesPartial || input.rulesCrashed > 0 || input.parseFailed > 0;
|
|
103
|
+
return {
|
|
104
|
+
partial: discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0,
|
|
105
|
+
analysisStatus: {
|
|
106
|
+
discovery: discoveryPartial ? "partial" : "complete",
|
|
107
|
+
rules: rulesPartial ? "partial" : "complete",
|
|
108
|
+
skippedFiles: input.skippedFiles,
|
|
109
|
+
rulesCrashed: input.rulesCrashed,
|
|
110
|
+
parseFallbacks: input.parseFallbacks ?? 0,
|
|
111
|
+
...truncationReasons.length > 0 ? { truncationReasons } : {},
|
|
112
|
+
reasons: [...reasons].sort()
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
//#endregion
|
|
59
117
|
//#region src/engine/runtime-corroboration.ts
|
|
60
118
|
/**
|
|
61
119
|
* Stamp runtime corroboration + trust levels onto findings (mutates in
|
|
@@ -669,21 +727,13 @@ const MEASURED_FP = {
|
|
|
669
727
|
* empty scan is an observation, not a proof).
|
|
670
728
|
*/
|
|
671
729
|
function summaryTrustLevel(findings) {
|
|
672
|
-
const order = [
|
|
673
|
-
"L0",
|
|
674
|
-
"L1",
|
|
675
|
-
"L2",
|
|
676
|
-
"L3",
|
|
677
|
-
"L4",
|
|
678
|
-
"L5"
|
|
679
|
-
];
|
|
680
730
|
let best = 0;
|
|
681
731
|
for (const f of findings) {
|
|
682
732
|
const t = f.trustLevel ?? "L2";
|
|
683
|
-
const idx =
|
|
733
|
+
const idx = TRUST_ORDER.indexOf(t);
|
|
684
734
|
if (idx > best) best = idx;
|
|
685
735
|
}
|
|
686
|
-
return
|
|
736
|
+
return TRUST_ORDER[best];
|
|
687
737
|
}
|
|
688
738
|
/**
|
|
689
739
|
* A finding's trust rung (0–5) on the canonical ladder. Corroborated
|
|
@@ -694,14 +744,7 @@ function summaryTrustLevel(findings) {
|
|
|
694
744
|
*/
|
|
695
745
|
function trustRung(f) {
|
|
696
746
|
const t = f.trustLevel ?? deriveTrustLevel(f);
|
|
697
|
-
return
|
|
698
|
-
"L0",
|
|
699
|
-
"L1",
|
|
700
|
-
"L2",
|
|
701
|
-
"L3",
|
|
702
|
-
"L4",
|
|
703
|
-
"L5"
|
|
704
|
-
].indexOf(t);
|
|
747
|
+
return TRUST_ORDER.indexOf(t);
|
|
705
748
|
}
|
|
706
749
|
/**
|
|
707
750
|
* Hard incompleteness ceilings (plan §6: partial/crash/truncation;
|
|
@@ -822,7 +865,10 @@ function sha256$1(text) {
|
|
|
822
865
|
return createHash("sha256").update(text).digest("hex");
|
|
823
866
|
}
|
|
824
867
|
function canonical(value) {
|
|
825
|
-
return JSON.stringify(value);
|
|
868
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
869
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
870
|
+
const record = value;
|
|
871
|
+
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`;
|
|
826
872
|
}
|
|
827
873
|
function buildRunIdentity(input) {
|
|
828
874
|
const inputFingerprint = sha256$1([...input.files].map((f) => f.hash ? `${f.path}:${f.hash}` : `${f.path}:${f.size}`).sort().join("\n"));
|
|
@@ -835,6 +881,17 @@ function buildRunIdentity(input) {
|
|
|
835
881
|
engineVersion: input.engineVersion
|
|
836
882
|
};
|
|
837
883
|
if (input.reportDigest) verdictInputs.reportDigest = input.reportDigest;
|
|
884
|
+
if (input.commit !== void 0) verdictInputs.commit = input.commit;
|
|
885
|
+
if (input.tree !== void 0) verdictInputs.tree = input.tree;
|
|
886
|
+
if (input.lockfile !== void 0) verdictInputs.lockfile = input.lockfile;
|
|
887
|
+
if (input.candidate !== void 0) verdictInputs.candidate = {
|
|
888
|
+
manifestId: input.candidate.manifestId,
|
|
889
|
+
state: input.candidate.state,
|
|
890
|
+
candidateSha: input.candidate.candidateSha,
|
|
891
|
+
baseSha: input.candidate.baseSha,
|
|
892
|
+
packageSha256: input.candidate.packageSha256,
|
|
893
|
+
lockfileSha256: input.candidate.lockfileSha256
|
|
894
|
+
};
|
|
838
895
|
if (input.trustModelVersion !== void 0) verdictInputs.trustModelVersion = input.trustModelVersion;
|
|
839
896
|
if (input.scoringModelVersion !== void 0) verdictInputs.scoringModelVersion = input.scoringModelVersion;
|
|
840
897
|
if (input.frameworkSupportMatrixVersion !== void 0) verdictInputs.frameworkSupportMatrixVersion = input.frameworkSupportMatrixVersion;
|
|
@@ -842,15 +899,31 @@ function buildRunIdentity(input) {
|
|
|
842
899
|
if (input.suppressionFingerprint !== void 0) verdictInputs.suppressionFingerprint = input.suppressionFingerprint;
|
|
843
900
|
if (input.policyFingerprint !== void 0) verdictInputs.policyFingerprint = input.policyFingerprint;
|
|
844
901
|
if (input.historicalEvidenceFingerprint !== void 0) verdictInputs.historicalEvidenceFingerprint = input.historicalEvidenceFingerprint;
|
|
902
|
+
const scanId = sha256$1(canonical(verdictInputs));
|
|
903
|
+
const boundLinks = [
|
|
904
|
+
"input",
|
|
905
|
+
"rules",
|
|
906
|
+
"config",
|
|
907
|
+
"engine"
|
|
908
|
+
];
|
|
909
|
+
if (input.commit !== void 0) boundLinks.push("commit");
|
|
910
|
+
if (input.tree !== void 0) boundLinks.push("tree");
|
|
911
|
+
if (input.lockfile !== void 0) boundLinks.push("lockfile");
|
|
912
|
+
if (input.candidate !== void 0) boundLinks.push("candidate");
|
|
845
913
|
return {
|
|
846
|
-
scanId
|
|
914
|
+
scanId,
|
|
847
915
|
inputFingerprint,
|
|
848
916
|
rulesDigest,
|
|
849
917
|
configFingerprint,
|
|
850
918
|
engineVersion: input.engineVersion,
|
|
919
|
+
boundLinks,
|
|
851
920
|
...input.trustModelVersion !== void 0 ? { trustModelVersion: input.trustModelVersion } : {},
|
|
852
921
|
...input.scoringModelVersion !== void 0 ? { scoringModelVersion: input.scoringModelVersion } : {},
|
|
853
|
-
...input.frameworkSupportMatrixVersion !== void 0 ? { frameworkSupportMatrixVersion: input.frameworkSupportMatrixVersion } : {}
|
|
922
|
+
...input.frameworkSupportMatrixVersion !== void 0 ? { frameworkSupportMatrixVersion: input.frameworkSupportMatrixVersion } : {},
|
|
923
|
+
...input.commit !== void 0 ? { commit: input.commit } : {},
|
|
924
|
+
...input.tree !== void 0 ? { tree: input.tree } : {},
|
|
925
|
+
...input.lockfile !== void 0 ? { lockfile: input.lockfile } : {},
|
|
926
|
+
...input.candidate !== void 0 ? { candidate: input.candidate } : {}
|
|
854
927
|
};
|
|
855
928
|
}
|
|
856
929
|
/**
|
|
@@ -891,9 +964,218 @@ function buildEvidenceGraph(parts) {
|
|
|
891
964
|
...parts.reproduction !== void 0 ? { ref: parts.reproduction } : {}
|
|
892
965
|
}
|
|
893
966
|
],
|
|
894
|
-
...parts.runId !== void 0 ? { runId: parts.runId } : {}
|
|
967
|
+
...parts.runId !== void 0 ? { runId: parts.runId } : {},
|
|
968
|
+
...parts.candidate !== void 0 ? { candidate: {
|
|
969
|
+
manifestId: parts.candidate.manifestId,
|
|
970
|
+
candidateSha: parts.candidate.candidateSha
|
|
971
|
+
} } : {}
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
//#endregion
|
|
975
|
+
//#region src/engine/file-executor.ts
|
|
976
|
+
function cacheHitOutcome(job) {
|
|
977
|
+
return {
|
|
978
|
+
path: job.path,
|
|
979
|
+
status: "CACHE_HIT",
|
|
980
|
+
findings: [...job.cacheHit ?? []],
|
|
981
|
+
parseFallback: false
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Drive a list of jobs through an executor and return outcomes in INPUT order.
|
|
986
|
+
*
|
|
987
|
+
* This is the function the pipeline calls. It is executor-agnostic on purpose:
|
|
988
|
+
* swapping in a pooled executor must not require touching the caller, and the
|
|
989
|
+
* ordering guarantee has to live here or it would live in each executor and
|
|
990
|
+
* drift between them.
|
|
991
|
+
*/
|
|
992
|
+
async function executeFiles(jobs, executor, context = {}) {
|
|
993
|
+
const outcomes = new Array(jobs.length);
|
|
994
|
+
if (executor.concurrency <= 1 || jobs.length <= 1) {
|
|
995
|
+
for (let i = 0; i < jobs.length; i++) {
|
|
996
|
+
const job = jobs[i];
|
|
997
|
+
context.onProgress?.({
|
|
998
|
+
done: i,
|
|
999
|
+
total: jobs.length,
|
|
1000
|
+
path: job.path
|
|
1001
|
+
});
|
|
1002
|
+
outcomes[i] = await runOne(job, executor, context);
|
|
1003
|
+
}
|
|
1004
|
+
context.onProgress?.({
|
|
1005
|
+
done: jobs.length,
|
|
1006
|
+
total: jobs.length,
|
|
1007
|
+
path: jobs[jobs.length - 1]?.path ?? ""
|
|
1008
|
+
});
|
|
1009
|
+
return outcomes;
|
|
1010
|
+
}
|
|
1011
|
+
let next = 0;
|
|
1012
|
+
let completed = 0;
|
|
1013
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
1014
|
+
const limit = Math.max(1, Math.floor(executor.concurrency));
|
|
1015
|
+
const worker = async () => {
|
|
1016
|
+
for (;;) {
|
|
1017
|
+
const index = next++;
|
|
1018
|
+
if (index >= jobs.length) return;
|
|
1019
|
+
const job = jobs[index];
|
|
1020
|
+
outcomes[index] = await runOne(job, executor, context);
|
|
1021
|
+
completed++;
|
|
1022
|
+
context.onProgress?.({
|
|
1023
|
+
done: completed,
|
|
1024
|
+
total: jobs.length,
|
|
1025
|
+
path: job.path
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
for (let i = 0; i < Math.min(limit, jobs.length); i++) {
|
|
1030
|
+
const promise = worker().then(() => void 0);
|
|
1031
|
+
inFlight.add(promise);
|
|
1032
|
+
}
|
|
1033
|
+
await Promise.all(inFlight);
|
|
1034
|
+
return outcomes;
|
|
1035
|
+
}
|
|
1036
|
+
async function runOne(job, executor, context) {
|
|
1037
|
+
if (job.cacheHit !== void 0) return cacheHitOutcome(job);
|
|
1038
|
+
try {
|
|
1039
|
+
return await executor.execute(job, context);
|
|
1040
|
+
} catch (error) {
|
|
1041
|
+
return {
|
|
1042
|
+
path: job.path,
|
|
1043
|
+
status: "FAILED",
|
|
1044
|
+
findings: [],
|
|
1045
|
+
parseFallback: false,
|
|
1046
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* The strategy the pipeline uses today.
|
|
1052
|
+
*
|
|
1053
|
+
* `runOne` is supplied by the pipeline because parsing and rule execution
|
|
1054
|
+
* need the adapter and the active rule set, which are pipeline concerns. The
|
|
1055
|
+
* executor's job is the BOUNDARY and the bookkeeping, not the analysis — so
|
|
1056
|
+
* this factory exists to bind those two without the executor importing the
|
|
1057
|
+
* pipeline (which would be a cycle).
|
|
1058
|
+
*/
|
|
1059
|
+
function createExecutor(name, concurrency, runOne) {
|
|
1060
|
+
return {
|
|
1061
|
+
name,
|
|
1062
|
+
concurrency,
|
|
1063
|
+
execute: runOne
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
//#endregion
|
|
1067
|
+
//#region src/engine/candidate-binding.ts
|
|
1068
|
+
/**
|
|
1069
|
+
* Candidate binding (plan V5-010).
|
|
1070
|
+
*
|
|
1071
|
+
* Reads a candidate trust manifest and produces the immutable
|
|
1072
|
+
* `CandidateBinding` that a run identity is bound to. It also computes the
|
|
1073
|
+
* repository facts (commit, tree, lockfile digest) that the identity was
|
|
1074
|
+
* missing until this module existed.
|
|
1075
|
+
*
|
|
1076
|
+
* The law here is ABSENCE over invention. A manifest that is missing,
|
|
1077
|
+
* unreadable, or internally contradictory yields NO binding — not a partial
|
|
1078
|
+
* one. A consumer that can see "not bound" can refuse to trust the run; a
|
|
1079
|
+
* consumer handed a half-filled binding cannot tell what is missing, and will
|
|
1080
|
+
* trust the parts that are there.
|
|
1081
|
+
*/
|
|
1082
|
+
const CANDIDATE_MANIFEST_PATH = "candidate-trust-manifest.json";
|
|
1083
|
+
const SHA40 = /^[a-f0-9]{40}$/;
|
|
1084
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
1085
|
+
const STATES = ["WORKING_CANDIDATE", "RELEASE_CANDIDATE"];
|
|
1086
|
+
const AUTHORIZATION = ["NOT_AUTHORIZED", "AUTHORIZED"];
|
|
1087
|
+
function isRecord$1(value) {
|
|
1088
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1089
|
+
}
|
|
1090
|
+
function sha256File(path) {
|
|
1091
|
+
try {
|
|
1092
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
1093
|
+
} catch {
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
function gitLine(root, args) {
|
|
1098
|
+
try {
|
|
1099
|
+
const trimmed = execFileSync("git", args, {
|
|
1100
|
+
cwd: root,
|
|
1101
|
+
encoding: "utf8",
|
|
1102
|
+
stdio: [
|
|
1103
|
+
"ignore",
|
|
1104
|
+
"pipe",
|
|
1105
|
+
"ignore"
|
|
1106
|
+
],
|
|
1107
|
+
windowsHide: true
|
|
1108
|
+
}).trim();
|
|
1109
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
1110
|
+
} catch {
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* The commit and tree the working tree is at. Both absent outside a
|
|
1116
|
+
* repository or with no commits — never substituted with a placeholder.
|
|
1117
|
+
*/
|
|
1118
|
+
function bindRepository(root) {
|
|
1119
|
+
const commit = gitLine(root, ["rev-parse", "HEAD"]);
|
|
1120
|
+
const tree = gitLine(root, ["rev-parse", "HEAD^{tree}"]);
|
|
1121
|
+
const lockfile = sha256File(join(root, "package-lock.json"));
|
|
1122
|
+
return {
|
|
1123
|
+
...commit !== void 0 ? { commit } : {},
|
|
1124
|
+
...tree !== void 0 ? { tree } : {},
|
|
1125
|
+
...lockfile !== void 0 ? { lockfile } : {}
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Parse a candidate manifest into a binding, or return null when it cannot
|
|
1130
|
+
* honestly be bound.
|
|
1131
|
+
*
|
|
1132
|
+
* Null is returned for: no manifest, unreadable JSON, a missing or malformed
|
|
1133
|
+
* required field, or a state/authorization combination that cannot legally
|
|
1134
|
+
* exist (a WORKING_CANDIDATE carrying a commit, for example — the same
|
|
1135
|
+
* contradiction the release decision evaluator treats as BLOCKED).
|
|
1136
|
+
*/
|
|
1137
|
+
function parseCandidateBinding(raw) {
|
|
1138
|
+
if (!isRecord$1(raw)) return null;
|
|
1139
|
+
const identity = raw.identity;
|
|
1140
|
+
if (!isRecord$1(identity)) return null;
|
|
1141
|
+
const state = identity.state;
|
|
1142
|
+
const candidateSha = identity.candidateSha;
|
|
1143
|
+
const baseSha = identity.baseSha;
|
|
1144
|
+
const packageSha256 = identity.packageSha256;
|
|
1145
|
+
const lockfileSha256 = identity.lockfileSha256;
|
|
1146
|
+
if (typeof state !== "string" || !STATES.includes(state)) return null;
|
|
1147
|
+
if (typeof baseSha !== "string" || !SHA40.test(baseSha)) return null;
|
|
1148
|
+
if (typeof packageSha256 !== "string" || !SHA256.test(packageSha256)) return null;
|
|
1149
|
+
if (typeof lockfileSha256 !== "string" || !SHA256.test(lockfileSha256)) return null;
|
|
1150
|
+
if (candidateSha !== null && (typeof candidateSha !== "string" || !SHA40.test(candidateSha))) return null;
|
|
1151
|
+
const manifestId = raw.manifestId;
|
|
1152
|
+
if (typeof manifestId !== "string" || manifestId.length === 0) return null;
|
|
1153
|
+
const owner = typeof raw.owner === "string" ? raw.owner : "UNASSIGNED";
|
|
1154
|
+
const authorization = raw.releaseAuthorizationState;
|
|
1155
|
+
if (typeof authorization !== "string" || !AUTHORIZATION.includes(authorization)) return null;
|
|
1156
|
+
if (state === "WORKING_CANDIDATE") {
|
|
1157
|
+
if (candidateSha !== null) return null;
|
|
1158
|
+
if (authorization !== "NOT_AUTHORIZED") return null;
|
|
1159
|
+
} else if (candidateSha === null) return null;
|
|
1160
|
+
return {
|
|
1161
|
+
manifestId,
|
|
1162
|
+
state,
|
|
1163
|
+
candidateSha,
|
|
1164
|
+
baseSha,
|
|
1165
|
+
packageSha256,
|
|
1166
|
+
lockfileSha256,
|
|
1167
|
+
owner,
|
|
1168
|
+
releaseAuthorizationState: authorization
|
|
895
1169
|
};
|
|
896
1170
|
}
|
|
1171
|
+
/** Read and bind the manifest from a checkout, or null. */
|
|
1172
|
+
function readCandidateBinding(root, manifestPath = CANDIDATE_MANIFEST_PATH) {
|
|
1173
|
+
try {
|
|
1174
|
+
return parseCandidateBinding(JSON.parse(readFileSync(join(root, manifestPath), "utf8")));
|
|
1175
|
+
} catch {
|
|
1176
|
+
return null;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
897
1179
|
//#endregion
|
|
898
1180
|
//#region src/engine/version.ts
|
|
899
1181
|
/**
|
|
@@ -904,7 +1186,7 @@ function buildEvidenceGraph(parts) {
|
|
|
904
1186
|
* scripts/sync-sarif-version.cjs and guarded by the version-consistency
|
|
905
1187
|
* spec. cli.ts re-exports this as CLI_VERSION.
|
|
906
1188
|
*/
|
|
907
|
-
const ENGINE_VERSION = "
|
|
1189
|
+
const ENGINE_VERSION = "4.0.0";
|
|
908
1190
|
//#endregion
|
|
909
1191
|
//#region src/engine/contract-versions.ts
|
|
910
1192
|
/**
|
|
@@ -1065,7 +1347,9 @@ function basename$1(p) {
|
|
|
1065
1347
|
* Internal files (cache, hashes) use `isRecord`; user-facing files
|
|
1066
1348
|
* (config, plugins) use stricter checks that throw descriptive errors.
|
|
1067
1349
|
*/
|
|
1350
|
+
const MAX_JSON_BYTES = 16777216;
|
|
1068
1351
|
function parseJsonFile(text, source, validate) {
|
|
1352
|
+
if (Buffer.byteLength(text, "utf8") > MAX_JSON_BYTES) throw new Error(`JSON source exceeds the ${MAX_JSON_BYTES}-byte limit: ${source}`);
|
|
1069
1353
|
let parsed;
|
|
1070
1354
|
try {
|
|
1071
1355
|
parsed = JSON.parse(text);
|
|
@@ -1084,6 +1368,59 @@ function isRecord(v) {
|
|
|
1084
1368
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1085
1369
|
}
|
|
1086
1370
|
//#endregion
|
|
1371
|
+
//#region src/lib/fs-bounded.ts
|
|
1372
|
+
function closeQuiet(fd) {
|
|
1373
|
+
try {
|
|
1374
|
+
closeSync(fd);
|
|
1375
|
+
} catch {
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
function readFileBounded(path, maxBytes) {
|
|
1380
|
+
let fd;
|
|
1381
|
+
try {
|
|
1382
|
+
const pathStat = lstatSync(path);
|
|
1383
|
+
if (pathStat.isSymbolicLink()) return {
|
|
1384
|
+
ok: false,
|
|
1385
|
+
reason: "symlink"
|
|
1386
|
+
};
|
|
1387
|
+
if (!pathStat.isFile()) return {
|
|
1388
|
+
ok: false,
|
|
1389
|
+
reason: "unreadable"
|
|
1390
|
+
};
|
|
1391
|
+
fd = openSync(path, 0);
|
|
1392
|
+
const fdStat = fstatSync(fd);
|
|
1393
|
+
if (!fdStat.isFile() || fdStat.size > maxBytes) return {
|
|
1394
|
+
ok: false,
|
|
1395
|
+
reason: fdStat.size > maxBytes ? "too-large" : "unreadable"
|
|
1396
|
+
};
|
|
1397
|
+
const chunks = [];
|
|
1398
|
+
let total = 0;
|
|
1399
|
+
const buffer = Buffer.allocUnsafe(Math.min(65536, maxBytes));
|
|
1400
|
+
while (true) {
|
|
1401
|
+
const read = readSync(fd, buffer, 0, buffer.length, null);
|
|
1402
|
+
if (read === 0) break;
|
|
1403
|
+
total += read;
|
|
1404
|
+
if (total > maxBytes) return {
|
|
1405
|
+
ok: false,
|
|
1406
|
+
reason: "too-large"
|
|
1407
|
+
};
|
|
1408
|
+
chunks.push(Buffer.from(buffer.subarray(0, read)));
|
|
1409
|
+
}
|
|
1410
|
+
return {
|
|
1411
|
+
ok: true,
|
|
1412
|
+
data: Buffer.concat(chunks)
|
|
1413
|
+
};
|
|
1414
|
+
} catch {
|
|
1415
|
+
return {
|
|
1416
|
+
ok: false,
|
|
1417
|
+
reason: "unreadable"
|
|
1418
|
+
};
|
|
1419
|
+
} finally {
|
|
1420
|
+
if (fd !== void 0) closeQuiet(fd);
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
//#endregion
|
|
1087
1424
|
//#region src/config/config-schema.ts
|
|
1088
1425
|
const VALID_GATES = /* @__PURE__ */ new Set([
|
|
1089
1426
|
"advisory",
|
|
@@ -1182,7 +1519,11 @@ const CONFIG_NAMES = ["mjolnir.config.json", ".mjolnir.json"];
|
|
|
1182
1519
|
function findConfigPath(root) {
|
|
1183
1520
|
for (const name of CONFIG_NAMES) {
|
|
1184
1521
|
const p = join(root, name);
|
|
1185
|
-
|
|
1522
|
+
try {
|
|
1523
|
+
if (existsSync(p) && !lstatSync(p).isSymbolicLink()) return p;
|
|
1524
|
+
} catch {
|
|
1525
|
+
continue;
|
|
1526
|
+
}
|
|
1186
1527
|
}
|
|
1187
1528
|
return null;
|
|
1188
1529
|
}
|
|
@@ -1202,7 +1543,10 @@ function loadConfig(root, options = {}) {
|
|
|
1202
1543
|
const p = join(root, name);
|
|
1203
1544
|
if (!existsSync(p)) continue;
|
|
1204
1545
|
try {
|
|
1205
|
-
|
|
1546
|
+
if (!existsSync(p) || lstatSync(p).isSymbolicLink()) continue;
|
|
1547
|
+
const read = readFileBounded(p, 1048576);
|
|
1548
|
+
if (!read.ok) throw new Error("config source could not be read safely");
|
|
1549
|
+
const parsed = parseJsonFile(read.data.toString("utf8"), p, (v) => isRecord(v));
|
|
1206
1550
|
return {
|
|
1207
1551
|
config: parsed,
|
|
1208
1552
|
path: p,
|
|
@@ -1250,20 +1594,9 @@ function validate(cfg, knownRuleIds) {
|
|
|
1250
1594
|
return warnings;
|
|
1251
1595
|
}
|
|
1252
1596
|
/**
|
|
1253
|
-
*
|
|
1254
|
-
*
|
|
1255
|
-
*
|
|
1256
|
-
* documented window.
|
|
1257
|
-
*
|
|
1258
|
-
* Audit S4 (remediation plan): the config-file MTIME is no longer an
|
|
1259
|
-
* expiry anchor. Anchoring the 90-day default at mtime meant ANY edit to
|
|
1260
|
-
* mjolnir.config.json — a reformat, an unrelated key, a `touch` — reset
|
|
1261
|
-
* the 90-day window for EVERY hand-authored entry: suppressions could be
|
|
1262
|
-
* extended indefinitely without touching their own fields. The expiry is
|
|
1263
|
-
* now the entry's explicit `expires` date alone; an entry without one
|
|
1264
|
-
* stays active and is honestly labeled "(no expiry set)" in the
|
|
1265
|
-
* suppressions report. Hand-authored entries should declare `expires` at
|
|
1266
|
-
* creation (README §Configuration documents the shape).
|
|
1597
|
+
* Expiry is evaluated only from an explicit ISO `expires` date. A missing
|
|
1598
|
+
* date remains active and is reported as having no expiry. Config-file mtime
|
|
1599
|
+
* is never an expiry anchor.
|
|
1267
1600
|
*/
|
|
1268
1601
|
function isSuppressionActive(ign, now = /* @__PURE__ */ new Date()) {
|
|
1269
1602
|
if (!ign.expires) return true;
|
|
@@ -1496,8 +1829,9 @@ function sharedWalk(options) {
|
|
|
1496
1829
|
}
|
|
1497
1830
|
const full = join(dir, entry.name);
|
|
1498
1831
|
const rel = relative(options.root, full).replaceAll("\\", "/");
|
|
1832
|
+
if (entry.isDirectory() && options.skipDirs.includes(entry.name)) continue;
|
|
1499
1833
|
if (options.ignoreMatcher.isIgnored(rel)) {
|
|
1500
|
-
options.onIgnored?.();
|
|
1834
|
+
if (!rel.startsWith(".mjolnir/")) options.onIgnored?.(rel);
|
|
1501
1835
|
continue;
|
|
1502
1836
|
}
|
|
1503
1837
|
if (entry.isSymbolicLink()) {
|
|
@@ -1505,7 +1839,6 @@ function sharedWalk(options) {
|
|
|
1505
1839
|
continue;
|
|
1506
1840
|
}
|
|
1507
1841
|
if (entry.isDirectory()) {
|
|
1508
|
-
if (options.skipDirs.includes(entry.name)) continue;
|
|
1509
1842
|
if (rel.split("/").length > LIMITS$1.maxDepth) {
|
|
1510
1843
|
options.onSkipped("max-depth");
|
|
1511
1844
|
continue;
|
|
@@ -1523,7 +1856,7 @@ function sharedWalk(options) {
|
|
|
1523
1856
|
} catch {
|
|
1524
1857
|
options.onSkipped("stat-failed");
|
|
1525
1858
|
}
|
|
1526
|
-
else if (entry.isFile()) options.onUnrecognized?.();
|
|
1859
|
+
else if (entry.isFile()) options.onUnrecognized?.(rel);
|
|
1527
1860
|
}
|
|
1528
1861
|
};
|
|
1529
1862
|
walk(options.root);
|
|
@@ -1541,7 +1874,33 @@ function sharedWalk(options) {
|
|
|
1541
1874
|
*
|
|
1542
1875
|
* When nothing is detectable we report `unknown` and the scanner analyzes
|
|
1543
1876
|
* all test-looking files — stated honestly in output rather than guessed.
|
|
1877
|
+
*
|
|
1878
|
+
* ## One ID space (plan V5-024)
|
|
1879
|
+
*
|
|
1880
|
+
* This detector used to declare its own `TestFramework` union of three
|
|
1881
|
+
* literals while `src/frameworks/framework-inventory.ts` catalogued fourteen
|
|
1882
|
+
* framework ids. Two vocabularies for one concept is how a support matrix
|
|
1883
|
+
* starts disagreeing with what the tool actually detects: a framework can be
|
|
1884
|
+
* catalogued as OFFICIAL_PARTIAL and never once be emitted by the code that
|
|
1885
|
+
* claims to detect it.
|
|
1886
|
+
*
|
|
1887
|
+
* So the ids here are `FrameworkId`s — the inventory's own vocabulary — and
|
|
1888
|
+
* `DETECTABLE_TEST_FRAMEWORKS` is the subset this detector can actually
|
|
1889
|
+
* resolve from a checkout. That subset is the HONEST limit of detection, and
|
|
1890
|
+
* `detectableVsCatalogued()` reports the remainder rather than letting a
|
|
1891
|
+
* three-item list read as the whole catalog.
|
|
1892
|
+
*/
|
|
1893
|
+
/**
|
|
1894
|
+
* The catalogued frameworks whose detection is implemented today.
|
|
1895
|
+
*
|
|
1896
|
+
* Every entry must exist in the inventory — enforced by the parity spec, not
|
|
1897
|
+
* by comment.
|
|
1544
1898
|
*/
|
|
1899
|
+
const DETECTABLE_TEST_FRAMEWORKS = [
|
|
1900
|
+
"jest",
|
|
1901
|
+
"vitest",
|
|
1902
|
+
"playwright"
|
|
1903
|
+
];
|
|
1545
1904
|
const CONFIG_FILES = {
|
|
1546
1905
|
jest: [
|
|
1547
1906
|
"jest.config.ts",
|
|
@@ -1560,7 +1919,7 @@ const CONFIG_FILES = {
|
|
|
1560
1919
|
};
|
|
1561
1920
|
function detectFrameworks(ws) {
|
|
1562
1921
|
const found = /* @__PURE__ */ new Set();
|
|
1563
|
-
for (const fw of
|
|
1922
|
+
for (const fw of DETECTABLE_TEST_FRAMEWORKS) if (CONFIG_FILES[fw].some((f) => existsSync(join(ws.root, f)))) found.add(fw);
|
|
1564
1923
|
if (!found.has("jest") && ws.packageJson["jest"] !== void 0) found.add("jest");
|
|
1565
1924
|
const deps = {
|
|
1566
1925
|
...ws.packageJson["dependencies"],
|
|
@@ -1582,11 +1941,7 @@ function detectFrameworks(ws) {
|
|
|
1582
1941
|
};
|
|
1583
1942
|
}
|
|
1584
1943
|
return {
|
|
1585
|
-
frameworks:
|
|
1586
|
-
"jest",
|
|
1587
|
-
"vitest",
|
|
1588
|
-
"playwright"
|
|
1589
|
-
].filter((f) => found.has(f)),
|
|
1944
|
+
frameworks: DETECTABLE_TEST_FRAMEWORKS.filter((f) => found.has(f)),
|
|
1590
1945
|
unknown: false
|
|
1591
1946
|
};
|
|
1592
1947
|
}
|
|
@@ -1603,6 +1958,13 @@ function detectFrameworks(ws) {
|
|
|
1603
1958
|
* byte-identical — a score shift is a regression, not an improvement.
|
|
1604
1959
|
*/
|
|
1605
1960
|
let project = null;
|
|
1961
|
+
/**
|
|
1962
|
+
* The shared ts-morph project.
|
|
1963
|
+
*
|
|
1964
|
+
* Exported so an adapter's `dispose()` can evict the file it parsed: ts-morph
|
|
1965
|
+
* caches by file path, so without eviction a long scan holds every parsed
|
|
1966
|
+
* SourceFile until the process exits (plan V5-021).
|
|
1967
|
+
*/
|
|
1606
1968
|
function getProject() {
|
|
1607
1969
|
if (!project) project = new Project({
|
|
1608
1970
|
useInMemoryFileSystem: true,
|
|
@@ -2129,7 +2491,36 @@ function frameworkFilterApplies(rule, file) {
|
|
|
2129
2491
|
* "cypress", `@jest/globals` → "jest", `vitest` → "vitest". Config
|
|
2130
2492
|
* gating is rule-declared (`configFiles`), not hard-coded here.
|
|
2131
2493
|
*/
|
|
2132
|
-
|
|
2494
|
+
/**
|
|
2495
|
+
* `.test.` / `.spec.` filenames, plus the Cypress `.cy.` convention.
|
|
2496
|
+
*
|
|
2497
|
+
* `mts` and `cts` are TypeScript's Node-native ESM/CJS extensions. They were
|
|
2498
|
+
* missing here, and the omission was invisible in the worst way — not a wrong
|
|
2499
|
+
* answer, but an ABSENCE.
|
|
2500
|
+
*
|
|
2501
|
+
* Reproduced: a repo containing two byte-identical tests, one at
|
|
2502
|
+
* `tests/control.spec.ts` and one at `tests/slow.spec.mts`, each with a
|
|
2503
|
+
* `QA-TEST-004` hard sleep:
|
|
2504
|
+
*
|
|
2505
|
+
* discovered: 1, analyzed: 1, unrecognized: 1, scopeVerdict: "PARTIAL"
|
|
2506
|
+
*
|
|
2507
|
+
* The `.ts` test produced the finding. The `.mts` test produced nothing at
|
|
2508
|
+
* all — it was never scanned, so every rule that could have caught it was
|
|
2509
|
+
* silent on it, with no finding, no low-evidence note, and no indication that
|
|
2510
|
+
* a test file had been skipped. A test scanner that cannot see a whole file
|
|
2511
|
+
* extension has a false green that is invisible by construction: the reader
|
|
2512
|
+
* has nothing to distrust, because there is nothing there.
|
|
2513
|
+
*
|
|
2514
|
+
* The corroboration that this was an oversight rather than a decision sits
|
|
2515
|
+
* four lines below: PW_CONFIG_RE already accepted `cts`. The config regex knew
|
|
2516
|
+
* about the Node-native extensions; the test-file regex did not.
|
|
2517
|
+
*
|
|
2518
|
+
* `discovery/scan-adapters.ts:isUnrecognizedSourceCandidate` already counted
|
|
2519
|
+
* these paths as uncovered surface, so the scope accounting had been reporting
|
|
2520
|
+
* the hole the whole time — it was being read as a known limitation rather than
|
|
2521
|
+
* as a defect.
|
|
2522
|
+
*/
|
|
2523
|
+
const TEST_FILE_RE = /\.(?:test|spec)\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$|\.cy\.(?:js|jsx|ts|tsx)$/;
|
|
2133
2524
|
/**
|
|
2134
2525
|
* Fallback config list for `configOnly` rules that do not declare
|
|
2135
2526
|
* `configFiles` (the legacy playwright.config.* gating, preserved
|
|
@@ -2236,11 +2627,42 @@ const typescriptAdapter = {
|
|
|
2236
2627
|
fixtureDirMemo: /* @__PURE__ */ new Map()
|
|
2237
2628
|
});
|
|
2238
2629
|
},
|
|
2630
|
+
/**
|
|
2631
|
+
* The ONE AST seam (plan V5-021).
|
|
2632
|
+
*
|
|
2633
|
+
* This adapter used to parse inside `runRules`, on the synchronous path,
|
|
2634
|
+
* while Java and C# exposed the async `parseAst` hook. Two seams meant two
|
|
2635
|
+
* sets of consequences, both of them bad:
|
|
2636
|
+
*
|
|
2637
|
+
* - the pipeline computes `wantsAst` as `adapter.parseAst !== undefined`,
|
|
2638
|
+
* so a TypeScript file never took the AST path through the pipeline at
|
|
2639
|
+
* all, and its parse failures were invisible to the pipeline's
|
|
2640
|
+
* fallback counters;
|
|
2641
|
+
* - nothing called `dispose()` for the ts-morph path, so a scan held
|
|
2642
|
+
* every parsed SourceFile for its whole lifetime.
|
|
2643
|
+
*
|
|
2644
|
+
* Parsing now happens here, once, through the same contract every other
|
|
2645
|
+
* adapter uses. `dispose()` drops this file's SourceFile from the shared
|
|
2646
|
+
* project; ts-morph caches per file path, so removing it is what keeps
|
|
2647
|
+
* memory proportional to one file rather than to the whole scan.
|
|
2648
|
+
*/
|
|
2649
|
+
parseAst(file) {
|
|
2650
|
+
const sourceFile = parseTsFile(file);
|
|
2651
|
+
if (sourceFile === void 0) return void 0;
|
|
2652
|
+
return {
|
|
2653
|
+
ast: sourceFile,
|
|
2654
|
+
dispose: () => {
|
|
2655
|
+
try {
|
|
2656
|
+
getProject().removeSourceFile(sourceFile);
|
|
2657
|
+
} catch {}
|
|
2658
|
+
}
|
|
2659
|
+
};
|
|
2660
|
+
},
|
|
2239
2661
|
runRules(rules, file, emit, onCrash, budget) {
|
|
2240
|
-
const withAst = {
|
|
2662
|
+
const withAst = file.ast === void 0 ? {
|
|
2241
2663
|
...file,
|
|
2242
2664
|
ast: parseTsFile(file)
|
|
2243
|
-
};
|
|
2665
|
+
} : file;
|
|
2244
2666
|
const withTags = {
|
|
2245
2667
|
...withAst,
|
|
2246
2668
|
frameworkTags: frameworkTagsFromImports(withAst.text)
|
|
@@ -3625,7 +4047,7 @@ const SCAN_ADAPTERS = [
|
|
|
3625
4047
|
* the adapter that claims it; per-adapter caps still apply.
|
|
3626
4048
|
*/
|
|
3627
4049
|
function discoverAllTestFiles(ctx, languageAdapters, buckets, fixtureDirMemo) {
|
|
3628
|
-
const walkSkips = languageAdapters.reduce((common, a) => common.filter((name) => a.dirSkips.includes(name)), languageAdapters[0]?.dirSkips ?? []);
|
|
4050
|
+
const walkSkips = [.../* @__PURE__ */ new Set([...languageAdapters.reduce((common, a) => common.filter((name) => a.dirSkips.includes(name)), languageAdapters[0]?.dirSkips ?? []), ".mjolnir"])];
|
|
3629
4051
|
sharedWalk({
|
|
3630
4052
|
root: ctx.workspace.root,
|
|
3631
4053
|
deadline: ctx.deadline,
|
|
@@ -3651,10 +4073,63 @@ function discoverAllTestFiles(ctx, languageAdapters, buckets, fixtureDirMemo) {
|
|
|
3651
4073
|
},
|
|
3652
4074
|
isFull: () => languageAdapters.every((a) => (buckets.get(a.id)?.length ?? 0) >= ctx.maxFiles),
|
|
3653
4075
|
fixtureDirMemo,
|
|
3654
|
-
onIgnored:
|
|
3655
|
-
|
|
4076
|
+
onIgnored: (path) => {
|
|
4077
|
+
if (isScopeRelevantIgnored(path)) ctx.onIgnored?.(path);
|
|
4078
|
+
},
|
|
4079
|
+
onUnrecognized: (path) => {
|
|
4080
|
+
if (typeof path === "string" && isUnrecognizedSourceCandidate(path)) ctx.onUnrecognized?.(path);
|
|
4081
|
+
}
|
|
3656
4082
|
});
|
|
3657
4083
|
}
|
|
4084
|
+
/**
|
|
4085
|
+
* Would ignoring this file have removed it from the scanned surface?
|
|
4086
|
+
*
|
|
4087
|
+
* The bug this fixes, reproduced: a repo containing a real test file plus
|
|
4088
|
+
* `vendor.min.js` and `pnpm-lock.yaml` reported
|
|
4089
|
+
*
|
|
4090
|
+
* discovered: 1, analyzed: 1, ignored: 2, scopeVerdict: "PARTIAL"
|
|
4091
|
+
*
|
|
4092
|
+
* Every discovered test file HAD been analyzed. The surface was complete. But
|
|
4093
|
+
* DEFAULT_IGNORES matches minified bundles and lockfiles, and the ignored
|
|
4094
|
+
* counter accepted any path with a source extension — so a minified bundle and
|
|
4095
|
+
* a lockfile downgraded a finished scan to "unverified".
|
|
4096
|
+
*
|
|
4097
|
+
* That is not a conservative bias, it is a broken signal. The terminal tells
|
|
4098
|
+
* the reader "some files were not analyzed, so the surface is unverified" —
|
|
4099
|
+
* which was false. And because minified bundles and lockfiles exist in
|
|
4100
|
+
* essentially every real repository, a PROVEN scan was unreachable in
|
|
4101
|
+
* practice, so the corpus regression guard failed 23 of 37 repositories on
|
|
4102
|
+
* exactly this. A gate that cannot pass teaches people to ignore it.
|
|
4103
|
+
*
|
|
4104
|
+
* The asymmetry is the tell. `isUnrecognizedSourceCandidate` below applies a
|
|
4105
|
+
* test-relevance test before counting a file; this one did not. Both count
|
|
4106
|
+
* against the same verdict, so both must ask the same question.
|
|
4107
|
+
*
|
|
4108
|
+
* An ignored file that IS a test file still downgrades the verdict. That is
|
|
4109
|
+
* the case the honesty law exists for, and this does not touch it.
|
|
4110
|
+
*/
|
|
4111
|
+
function isScopeRelevantIgnored(path) {
|
|
4112
|
+
const name = path.replaceAll("\\", "/").split("/").pop() ?? path;
|
|
4113
|
+
if (!/\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml|min\.js)$/i.test(name)) return false;
|
|
4114
|
+
return isKnownTestFile(path);
|
|
4115
|
+
}
|
|
4116
|
+
function isUnrecognizedSourceCandidate(path) {
|
|
4117
|
+
const normalized = path.replaceAll("\\", "/");
|
|
4118
|
+
const name = normalized.split("/").pop() ?? normalized;
|
|
4119
|
+
if (normalized.includes(".github/workflows/") || name === "azure-pipelines.yml" || name === "Jenkinsfile") return false;
|
|
4120
|
+
const extensionIndex = name.lastIndexOf(".");
|
|
4121
|
+
const stem = extensionIndex > 0 ? name.slice(0, extensionIndex) : name;
|
|
4122
|
+
if (name === "mjolnir.config.json" || name === ".mjolnir.json" || name === "package.json" || name === "package-lock.json" || name === "pnpm-lock.yaml" || name === "yarn.lock" || [
|
|
4123
|
+
"playwright",
|
|
4124
|
+
"vitest",
|
|
4125
|
+
"vite",
|
|
4126
|
+
"eslint",
|
|
4127
|
+
"tsconfig",
|
|
4128
|
+
"jsconfig"
|
|
4129
|
+
].some((prefix) => stem === prefix || stem.startsWith(`${prefix}.`))) return false;
|
|
4130
|
+
const testLike = /(?:^|\/)__tests__\//i.test(normalized) || /\.(?:spec|test)\.[cm]?[jt]sx?$/i.test(name);
|
|
4131
|
+
return /\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml)$/i.test(name) && testLike;
|
|
4132
|
+
}
|
|
3658
4133
|
/** Whether ANY shipped adapter would discover this path as a test file. */
|
|
3659
4134
|
function isKnownTestFile(path) {
|
|
3660
4135
|
return SCAN_ADAPTERS.some((a) => a.isTestFile(path));
|
|
@@ -3681,17 +4156,14 @@ typescriptAdapter.testFileGlobs, pythonAdapter.testFileGlobs, javaAdapter.testFi
|
|
|
3681
4156
|
* The fix: resolve git ONCE per process to an ABSOLUTE path by walking
|
|
3682
4157
|
* the PATH directly (never consulting the CWD), verify the candidate is
|
|
3683
4158
|
* an existing file, and pass that path to execFileSync. Resolution is
|
|
3684
|
-
* memoized; a failure to find a real git anywhere on PATH
|
|
3685
|
-
*
|
|
3686
|
-
* — degraded git data already means full-file attribution, never a
|
|
3687
|
-
* crash.
|
|
4159
|
+
* memoized; a failure to find a real git anywhere on PATH is recorded
|
|
4160
|
+
* and callers degrade without invoking a bare name.
|
|
3688
4161
|
*/
|
|
3689
4162
|
let resolvedGit = void 0;
|
|
3690
4163
|
/**
|
|
3691
4164
|
* The absolute path of the git binary Mjölnir will invoke, or null when
|
|
3692
|
-
* PATH carries no executable `git` at all
|
|
3693
|
-
*
|
|
3694
|
-
* as before, minus the CWD-hijack surface).
|
|
4165
|
+
* PATH carries no executable `git` at all. Callers must degrade without
|
|
4166
|
+
* invoking a bare name.
|
|
3695
4167
|
*/
|
|
3696
4168
|
function resolveGitPath() {
|
|
3697
4169
|
if (resolvedGit !== void 0) return resolvedGit;
|
|
@@ -3724,7 +4196,8 @@ function resolveGitPath() {
|
|
|
3724
4196
|
* the previous inline `git()` helpers, with the hijack surface closed.
|
|
3725
4197
|
*/
|
|
3726
4198
|
function runGit(root, args) {
|
|
3727
|
-
const exe = resolveGitPath()
|
|
4199
|
+
const exe = resolveGitPath();
|
|
4200
|
+
if (!exe) return null;
|
|
3728
4201
|
try {
|
|
3729
4202
|
return execFileSync(exe, [
|
|
3730
4203
|
"-C",
|
|
@@ -3860,7 +4333,7 @@ function computeChangedScope(root, baseBranch) {
|
|
|
3860
4333
|
"--",
|
|
3861
4334
|
...chunk
|
|
3862
4335
|
]);
|
|
3863
|
-
if (output === null)
|
|
4336
|
+
if (output === null) return null;
|
|
3864
4337
|
const sections = output.split("\ndiff --git ");
|
|
3865
4338
|
for (const section of sections) {
|
|
3866
4339
|
const body = section.startsWith("diff --git ") ? section : `diff --git ${section}`;
|
|
@@ -3888,6 +4361,11 @@ function computeChangedScope(root, baseBranch) {
|
|
|
3888
4361
|
"--unified=0",
|
|
3889
4362
|
"HEAD"
|
|
3890
4363
|
], changedFiles);
|
|
4364
|
+
if (committedDiffs === null || workingDiffs === null) return {
|
|
4365
|
+
changed: {},
|
|
4366
|
+
degraded: true,
|
|
4367
|
+
reason: "diff-failed"
|
|
4368
|
+
};
|
|
3891
4369
|
for (const file of changedFiles) {
|
|
3892
4370
|
const committedDiff = committedDiffs.get(file);
|
|
3893
4371
|
const workingDiff = workingDiffs.get(file);
|
|
@@ -3913,8 +4391,11 @@ const LIMITS_MAX_LINES = 1e6;
|
|
|
3913
4391
|
function allLinesOf(root, file) {
|
|
3914
4392
|
const full = join(root, file);
|
|
3915
4393
|
try {
|
|
3916
|
-
|
|
3917
|
-
|
|
4394
|
+
const stat = lstatSync(full);
|
|
4395
|
+
if (stat.isSymbolicLink() || !stat.isFile()) return null;
|
|
4396
|
+
const read = readFileBounded(full, LIMITS$1.maxFileBytes);
|
|
4397
|
+
if (!read.ok) return null;
|
|
4398
|
+
const lineCount = read.data.toString("utf8").split("\n").length;
|
|
3918
4399
|
return Array.from({ length: lineCount }, (_, i) => i + 1);
|
|
3919
4400
|
} catch {
|
|
3920
4401
|
return null;
|
|
@@ -7480,7 +7961,6 @@ const pyBareTruthinessAssert = defineRule({
|
|
|
7480
7961
|
const re = /^[ \t]*assert\s+([A-Za-z_][\w.]*(?:\([^()]*\))?)[ \t]*$/gm;
|
|
7481
7962
|
const predicateRe = /^(?:(?:any|all|isinstance)\s*\(|[\w.]*\.(?:startswith|endswith|exists|isdir|isfile|islink|ismount|check|isdigit|isalpha|isalnum|isnumeric|isdecimal|isspace|islower|isupper|istitle|isidentifier|isprintable|isascii)\s*\(|re\.(?:match|search|fullmatch)\s*\()/;
|
|
7482
7963
|
const isGuardFollowedByRealUse = (text, matchIndex, target) => {
|
|
7483
|
-
const root = target.split(".")[0];
|
|
7484
7964
|
const lineEnd = text.indexOf("\n", matchIndex);
|
|
7485
7965
|
if (lineEnd === -1) return false;
|
|
7486
7966
|
const lines = text.slice(lineEnd + 1).split("\n");
|
|
@@ -7490,8 +7970,11 @@ const pyBareTruthinessAssert = defineRule({
|
|
|
7490
7970
|
if (/^\s*def\s/.test(l) && window.length > 0) break;
|
|
7491
7971
|
window.push(l);
|
|
7492
7972
|
}
|
|
7493
|
-
const
|
|
7494
|
-
|
|
7973
|
+
const root = target.match(/^[a-z_]\w*/i)?.[0];
|
|
7974
|
+
if (!root) return false;
|
|
7975
|
+
return window.some((line) => {
|
|
7976
|
+
return (line.match(/[a-z_]\w*/gi) ?? []).some((token) => token === root);
|
|
7977
|
+
});
|
|
7495
7978
|
};
|
|
7496
7979
|
let m;
|
|
7497
7980
|
while ((m = re.exec(text)) !== null) {
|
|
@@ -11453,6 +11936,22 @@ function normalizeOne(report, artifact, v) {
|
|
|
11453
11936
|
function buildEvidenceRecords(report, artifact) {
|
|
11454
11937
|
return report.verdicts.map((v) => normalizeOne(report, artifact, v)).sort(compareEvidenceRecords);
|
|
11455
11938
|
}
|
|
11939
|
+
function countEvidence(records) {
|
|
11940
|
+
const counts = {
|
|
11941
|
+
total: records.length,
|
|
11942
|
+
failed: 0,
|
|
11943
|
+
flaky: 0,
|
|
11944
|
+
skipped: 0,
|
|
11945
|
+
timedOut: 0
|
|
11946
|
+
};
|
|
11947
|
+
for (const r of records) {
|
|
11948
|
+
if (r.status.failed) counts.failed++;
|
|
11949
|
+
if (r.status.passedOnRetry) counts.flaky++;
|
|
11950
|
+
if (r.status.skipped) counts.skipped++;
|
|
11951
|
+
if (r.status.timedOut) counts.timedOut++;
|
|
11952
|
+
}
|
|
11953
|
+
return counts;
|
|
11954
|
+
}
|
|
11456
11955
|
//#endregion
|
|
11457
11956
|
//#region src/engine/provenance.ts
|
|
11458
11957
|
const GENERATED_HEADER_RE = /^\s*(?:\/\/|#|\/\*)\s*(?:auto[- ]?generated|generated by|do not edit)/i;
|
|
@@ -11740,6 +12239,11 @@ var DependencyGraph = class {
|
|
|
11740
12239
|
get size() {
|
|
11741
12240
|
return this.nodes.size;
|
|
11742
12241
|
}
|
|
12242
|
+
/** Whether the graph knows this exact key. Reachability must not
|
|
12243
|
+
* treat "not in the graph" as "nothing to traverse" without saying so. */
|
|
12244
|
+
has(path) {
|
|
12245
|
+
return this.nodes.has(path);
|
|
12246
|
+
}
|
|
11743
12247
|
};
|
|
11744
12248
|
function parsePackageJson(filePath) {
|
|
11745
12249
|
if (!existsSync(filePath)) return void 0;
|
|
@@ -11864,80 +12368,354 @@ function isIncrementalSafe(changedFiles) {
|
|
|
11864
12368
|
};
|
|
11865
12369
|
}
|
|
11866
12370
|
//#endregion
|
|
11867
|
-
//#region src/
|
|
11868
|
-
const WORTHY_THRESHOLD = 80;
|
|
11869
|
-
const NEEDS_WORK_THRESHOLD = 50;
|
|
11870
|
-
function verdictOf(score) {
|
|
11871
|
-
if (score === null) return "fail";
|
|
11872
|
-
if (score >= WORTHY_THRESHOLD) return "pass";
|
|
11873
|
-
if (score >= NEEDS_WORK_THRESHOLD) return "warn";
|
|
11874
|
-
return "fail";
|
|
11875
|
-
}
|
|
11876
|
-
function hasBlocker(findings) {
|
|
11877
|
-
return findings.some((f) => f.severity === "error");
|
|
11878
|
-
}
|
|
12371
|
+
//#region src/brand/tokens.ts
|
|
11879
12372
|
/**
|
|
11880
|
-
*
|
|
12373
|
+
* The single source of brand truth.
|
|
11881
12374
|
*
|
|
11882
|
-
*
|
|
11883
|
-
*
|
|
11884
|
-
*
|
|
11885
|
-
*
|
|
12375
|
+
* Every colour, typeface and motion constant Mjölnir shows a human —
|
|
12376
|
+
* terminal, README SVGs, demo video, website, docs, badges — resolves to
|
|
12377
|
+
* a value in this file. Nothing else may define one.
|
|
12378
|
+
*
|
|
12379
|
+
* WHY THIS EXISTS. Before it, the palette existed in six independent
|
|
12380
|
+
* copies: `site/.vitepress/theme/styles/vars.css`, `NORSE` in
|
|
12381
|
+
* `src/reporter/theme.ts`, `scripts/readme-svg.ts`,
|
|
12382
|
+
* `scripts/video/terminal-page.ts`, `scripts/generate-readme-architecture.ts`
|
|
12383
|
+
* and the table in `assets/brand/README.md`. Exactly one pair of those
|
|
12384
|
+
* was guarded (site-doctor Check 8, doc ↔ vars.css). The unguarded edges
|
|
12385
|
+
* are where the shipped surfaces drifted apart: the terminal and the site
|
|
12386
|
+
* disagreed on six semantic roles, the architecture diagram invented its
|
|
12387
|
+
* own neutral ramp, and the README badges still carried a palette retired
|
|
12388
|
+
* two releases earlier. `scripts/brand-doctor.mjs` now checks every edge
|
|
12389
|
+
* against this file.
|
|
12390
|
+
*
|
|
12391
|
+
* PURITY. Pure data. No I/O, no rendering, no environment access, no
|
|
12392
|
+
* imports, no logic. Consumers convert (hex → ANSI triplet, hex → CSS)
|
|
12393
|
+
* themselves. Same reason `presentation.ts` is pure: it makes the whole
|
|
12394
|
+
* thing golden-testable and safe to ship inside the npm package, where
|
|
12395
|
+
* it costs a few hundred bytes and replaces values the package already
|
|
12396
|
+
* carried anyway.
|
|
12397
|
+
*
|
|
12398
|
+
* DERIVATION. The palette is the one derived from the logo in PR #20
|
|
12399
|
+
* (brushed steel and forge gold under an aurora, over midnight iron).
|
|
12400
|
+
* Where the terminal disagreed with it, the terminal converges — see
|
|
12401
|
+
* `PENDING_TERMINAL` below. Full rationale: `assets/brand/README.md`.
|
|
12402
|
+
*
|
|
12403
|
+
* ACCESSIBILITY. Every foreground token in `BRAND` meets WCAG AA
|
|
12404
|
+
* (≥ 4.5:1) against every surface token it is allowed to sit on. That is
|
|
12405
|
+
* not a claim, it is `brand-doctor` rule 8, which computes the ratios.
|
|
12406
|
+
* The weakest legal pairing is `steelDim` on `ink800` at 5.00:1.
|
|
11886
12407
|
*/
|
|
11887
|
-
|
|
11888
|
-
|
|
11889
|
-
|
|
11890
|
-
|
|
11891
|
-
|
|
11892
|
-
|
|
11893
|
-
|
|
11894
|
-
|
|
11895
|
-
|
|
11896
|
-
|
|
11897
|
-
|
|
11898
|
-
|
|
11899
|
-
|
|
11900
|
-
|
|
11901
|
-
|
|
11902
|
-
|
|
11903
|
-
|
|
11904
|
-
|
|
11905
|
-
|
|
11906
|
-
|
|
11907
|
-
|
|
11908
|
-
|
|
11909
|
-
|
|
11910
|
-
|
|
11911
|
-
|
|
11912
|
-
|
|
11913
|
-
|
|
11914
|
-
|
|
11915
|
-
|
|
11916
|
-
|
|
11917
|
-
|
|
11918
|
-
|
|
11919
|
-
|
|
11920
|
-
|
|
11921
|
-
|
|
11922
|
-
|
|
11923
|
-
|
|
11924
|
-
|
|
11925
|
-
|
|
11926
|
-
|
|
11927
|
-
|
|
11928
|
-
|
|
11929
|
-
|
|
11930
|
-
|
|
11931
|
-
|
|
11932
|
-
|
|
11933
|
-
|
|
11934
|
-
|
|
11935
|
-
|
|
11936
|
-
|
|
11937
|
-
|
|
11938
|
-
|
|
11939
|
-
|
|
11940
|
-
|
|
12408
|
+
/**
|
|
12409
|
+
* The two brand hues plus the neutral they sit on.
|
|
12410
|
+
*
|
|
12411
|
+
* GOLD IS SCARCE. It means forged / certified / earned / decisive — the
|
|
12412
|
+
* primary mark, the FORGED state, one call to action. It is not a paint
|
|
12413
|
+
* bucket: gold as default text, default border or default heading is a
|
|
12414
|
+
* brand-doctor finding, not a style choice.
|
|
12415
|
+
*
|
|
12416
|
+
* AURORA is verification energy — the secondary, and the hue that marks
|
|
12417
|
+
* the runtime half of the trust ladder.
|
|
12418
|
+
*/
|
|
12419
|
+
const BRAND = {
|
|
12420
|
+
gold: "#C19A34",
|
|
12421
|
+
goldBright: "#E6BD57",
|
|
12422
|
+
goldHot: "#F4DC9C",
|
|
12423
|
+
/** Pressed / deepest gold — the only step dark enough to carry white. */
|
|
12424
|
+
goldDeep: "#A5811C",
|
|
12425
|
+
aurora: "#37ABBD",
|
|
12426
|
+
auroraBright: "#45C1D4",
|
|
12427
|
+
auroraCyan: "#5CBDE0",
|
|
12428
|
+
/** The aurora's outer curtains: atmosphere and section identity only,
|
|
12429
|
+
* never a verdict or a status. */
|
|
12430
|
+
auroraGreen: "#5FD6A4",
|
|
12431
|
+
auroraViolet: "#9D8CF5",
|
|
12432
|
+
steel: "#C8CBCF",
|
|
12433
|
+
steelDim: "#8B939D"
|
|
12434
|
+
};
|
|
12435
|
+
/**
|
|
12436
|
+
* Midnight iron. One ramp, four steps, darkest first.
|
|
12437
|
+
*
|
|
12438
|
+
* `terminal` and `terminalBar` share one tone deliberately: the window's
|
|
12439
|
+
* only seam is a hairline ring and an inset shadow, never a second fill.
|
|
12440
|
+
* `chromeDot` is the three window dots — see the note on
|
|
12441
|
+
* `PENDING_TERMINAL.chromeDots` for why they are no longer red/amber/green.
|
|
12442
|
+
*/
|
|
12443
|
+
const SURFACE = {
|
|
12444
|
+
ink950: "#0A1119",
|
|
12445
|
+
ink900: "#0C1420",
|
|
12446
|
+
ink850: "#111A29",
|
|
12447
|
+
ink800: "#18243A",
|
|
12448
|
+
/** Raised panel (cards, elevated surfaces). */
|
|
12449
|
+
panel: "#141F33",
|
|
12450
|
+
/** Soft fill (inline code, quiet chips). */
|
|
12451
|
+
soft: "#1A2740",
|
|
12452
|
+
/** Terminal body — the deepest tone, so a terminal reads as recessed. */
|
|
12453
|
+
terminal: "#0A1119",
|
|
12454
|
+
/** Terminal title bar — the same tone; the seam is shadow, not colour. */
|
|
12455
|
+
terminalBar: "#0A1119",
|
|
12456
|
+
/** The three window dots. One neutral, not a traffic light. */
|
|
12457
|
+
chromeDot: "#18243A"
|
|
12458
|
+
};
|
|
12459
|
+
const TEXT = {
|
|
12460
|
+
primary: "#EAEEF5",
|
|
12461
|
+
secondary: "#ABB6C6",
|
|
12462
|
+
muted: "#8B939D",
|
|
12463
|
+
/** Ink for text set ON gold (buttons, the FORGED chip). 7.17:1 on `gold`. */
|
|
12464
|
+
onGold: "#0A1119"
|
|
12465
|
+
};
|
|
12466
|
+
/**
|
|
12467
|
+
* Non-score status. `ok` is the one green in the system and it is NOT a
|
|
12468
|
+
* score colour — it survives only for contexts with no worthiness
|
|
12469
|
+
* meaning ("autofix applied", "analysis complete"). A green score would
|
|
12470
|
+
* say "your software is fine", which is the exact claim this product
|
|
12471
|
+
* refuses to make.
|
|
12472
|
+
*/
|
|
12473
|
+
const STATUS = {
|
|
12474
|
+
ok: "#4FB477",
|
|
12475
|
+
info: "#5CC4E0",
|
|
12476
|
+
warning: "#E6BD57",
|
|
12477
|
+
error: "#EC6B66"
|
|
12478
|
+
};
|
|
12479
|
+
/**
|
|
12480
|
+
* The four ScoreState bands plus the unmeasured state. Band thresholds
|
|
12481
|
+
* and runes live in `src/reporter/presentation.ts`, which stays free of
|
|
12482
|
+
* colour — it emits a palette KEY and each surface resolves it here.
|
|
12483
|
+
*
|
|
12484
|
+
* `unmeasured` is steel-dim on purpose. UNKNOWN is a legitimate answer,
|
|
12485
|
+
* not a failure: colouring it red would make "we did not measure this"
|
|
12486
|
+
* look like "this is broken", which is precisely the dishonesty the
|
|
12487
|
+
* north-star law exists to prevent.
|
|
12488
|
+
*/
|
|
12489
|
+
const SCORE = {
|
|
12490
|
+
critical: "#EC6B66",
|
|
12491
|
+
warning: "#E6BD57",
|
|
12492
|
+
trusted: "#5CC4E0",
|
|
12493
|
+
forged: "#F4DC9C",
|
|
12494
|
+
unmeasured: "#8B939D"
|
|
12495
|
+
};
|
|
12496
|
+
/**
|
|
12497
|
+
* E0 → E1 → E2 is a certainty ramp, and it is deliberately HUE-FREE.
|
|
12498
|
+
*
|
|
12499
|
+
* Evidence level says how sure we are, not whether the news is good. A
|
|
12500
|
+
* deterministic proof (E2) is a defect we are certain about — painting
|
|
12501
|
+
* it gold or green would read as an achievement. So certainty is carried
|
|
12502
|
+
* by brightness alone, and the *shape* does the real work:
|
|
12503
|
+
*
|
|
12504
|
+
* E0 open ring observation, no weight
|
|
12505
|
+
* E1 half-filled pattern evidence, half weight
|
|
12506
|
+
* E2 sealed deterministic proof, full weight
|
|
12507
|
+
*
|
|
12508
|
+
* Colour never carries this alone (R11): the geometry is the signal and
|
|
12509
|
+
* survives `--ascii`, `NO_COLOR` and monochrome print.
|
|
12510
|
+
*/
|
|
12511
|
+
const EVIDENCE = {
|
|
12512
|
+
e0: "#8B939D",
|
|
12513
|
+
e1: "#ABB6C6",
|
|
12514
|
+
e2: "#EAEEF5"
|
|
12515
|
+
};
|
|
12516
|
+
/**
|
|
12517
|
+
* L0–L5, and the most important boundary in the product.
|
|
12518
|
+
*
|
|
12519
|
+
* L0–L2 are STATIC: the neutral steel ramp, brightening to the static
|
|
12520
|
+
* ceiling at L2. L3–L5 require a real run, and the hue changes to aurora
|
|
12521
|
+
* exactly there. The boundary is a hue break, not a gradient step,
|
|
12522
|
+
* because it is a change of kind and not of degree — a static-only
|
|
12523
|
+
* finding can never climb past L2, however confident it is.
|
|
12524
|
+
*
|
|
12525
|
+
* Every surface that draws the ladder must draw that break.
|
|
12526
|
+
*/
|
|
12527
|
+
const TRUST = {
|
|
12528
|
+
l0: "#8B939D",
|
|
12529
|
+
l1: "#ABB6C6",
|
|
12530
|
+
l2: "#C8CBCF",
|
|
12531
|
+
l3: "#37ABBD",
|
|
12532
|
+
l4: "#45C1D4",
|
|
12533
|
+
l5: "#5CC4E0"
|
|
12534
|
+
};
|
|
12535
|
+
SURFACE.ink950, SURFACE.ink900, SURFACE.ink850, SURFACE.ink800, BRAND.steel, BRAND.steelDim, BRAND.gold, BRAND.goldBright, BRAND.goldHot, BRAND.aurora, BRAND.auroraBright, BRAND.auroraCyan, BRAND.auroraGreen, BRAND.auroraViolet;
|
|
12536
|
+
SCORE.trusted, SCORE.forged, SCORE.warning, SCORE.critical, STATUS.info, TEXT.onGold, STATUS.ok, EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2, TRUST.l0, TRUST.l1, TRUST.l2, TRUST.l3, TRUST.l4, TRUST.l5;
|
|
12537
|
+
EVIDENCE.e0, EVIDENCE.e1, EVIDENCE.e2;
|
|
12538
|
+
const RUNG_MEANINGS = [
|
|
12539
|
+
"observation only",
|
|
12540
|
+
"heuristic static",
|
|
12541
|
+
"deterministic static",
|
|
12542
|
+
"the finding's file executed",
|
|
12543
|
+
"the finding's test executed",
|
|
12544
|
+
"the run verdict corroborates"
|
|
12545
|
+
];
|
|
12546
|
+
const RUNG_COLORS = [
|
|
12547
|
+
TRUST.l0,
|
|
12548
|
+
TRUST.l1,
|
|
12549
|
+
TRUST.l2,
|
|
12550
|
+
TRUST.l3,
|
|
12551
|
+
TRUST.l4,
|
|
12552
|
+
TRUST.l5
|
|
12553
|
+
];
|
|
12554
|
+
RUNG_MEANINGS.map((meaning, i) => ({
|
|
12555
|
+
level: `L${i}`,
|
|
12556
|
+
meaning,
|
|
12557
|
+
runtime: i >= 3,
|
|
12558
|
+
color: RUNG_COLORS[i]
|
|
12559
|
+
}));
|
|
12560
|
+
const HEADLINES = {
|
|
12561
|
+
critical: "The hammer is cracked — {n} findings break its edge.",
|
|
12562
|
+
warning: "The hammer holds — but {n} findings weigh it down.",
|
|
12563
|
+
trusted: "Held in worthy hands — {n} findings remain.",
|
|
12564
|
+
forged: "Static score 100 — no findings on the analyzed surface.",
|
|
12565
|
+
unmeasured: "No tests found — the hammer cannot be weighed."
|
|
12566
|
+
};
|
|
12567
|
+
const RUNES = {
|
|
12568
|
+
critical: "ᚲ",
|
|
12569
|
+
warning: "ᚦ",
|
|
12570
|
+
trusted: "ᛏ",
|
|
12571
|
+
forged: "ᛟ",
|
|
12572
|
+
unmeasured: "ᛁ"
|
|
12573
|
+
};
|
|
12574
|
+
/**
|
|
12575
|
+
* The named form of the band boundaries. `report:honesty` /
|
|
12576
|
+
* `thresholds:parity` assert that no surface outside this module
|
|
12577
|
+
* re-invents a boundary; a consumer that genuinely needs one names it
|
|
12578
|
+
* here rather than typing the number.
|
|
12579
|
+
*/
|
|
12580
|
+
const SCORE_THRESHOLDS = {
|
|
12581
|
+
/** Below this is `critical`. */
|
|
12582
|
+
criticalBelow: 50,
|
|
12583
|
+
/** At or above this is `trusted`. */
|
|
12584
|
+
trustedAtOrAbove: 80,
|
|
12585
|
+
/** The one score that is `forged`. */
|
|
12586
|
+
forgedAt: 100
|
|
12587
|
+
};
|
|
12588
|
+
/**
|
|
12589
|
+
* Band mapping — the one mapping, every consumer: <50 critical,
|
|
12590
|
+
* 50–79 warning, 80–99 trusted, 100 forged, null unmeasured.
|
|
12591
|
+
*/
|
|
12592
|
+
function deriveScoreState(score) {
|
|
12593
|
+
if (score === null) return {
|
|
12594
|
+
score: null,
|
|
12595
|
+
band: "unmeasured",
|
|
12596
|
+
verdict: "UNWORTHY",
|
|
12597
|
+
color: "dim",
|
|
12598
|
+
powerLevel: 0,
|
|
12599
|
+
headline: HEADLINES.unmeasured,
|
|
12600
|
+
rune: RUNES.unmeasured
|
|
12601
|
+
};
|
|
12602
|
+
if (score >= SCORE_THRESHOLDS.forgedAt) return {
|
|
12603
|
+
score,
|
|
12604
|
+
band: "forged",
|
|
12605
|
+
verdict: "FORGED",
|
|
12606
|
+
color: "forged",
|
|
12607
|
+
powerLevel: score,
|
|
12608
|
+
headline: HEADLINES.forged,
|
|
12609
|
+
rune: RUNES.forged
|
|
12610
|
+
};
|
|
12611
|
+
if (score >= 80) return {
|
|
12612
|
+
score,
|
|
12613
|
+
band: "trusted",
|
|
12614
|
+
verdict: "WORTHY",
|
|
12615
|
+
color: "trusted",
|
|
12616
|
+
powerLevel: score,
|
|
12617
|
+
headline: HEADLINES.trusted,
|
|
12618
|
+
rune: RUNES.trusted
|
|
12619
|
+
};
|
|
12620
|
+
if (score >= 50) return {
|
|
12621
|
+
score,
|
|
12622
|
+
band: "warning",
|
|
12623
|
+
verdict: "NEEDS WORK",
|
|
12624
|
+
color: "warning",
|
|
12625
|
+
powerLevel: score,
|
|
12626
|
+
headline: HEADLINES.warning,
|
|
12627
|
+
rune: RUNES.warning
|
|
12628
|
+
};
|
|
12629
|
+
return {
|
|
12630
|
+
score,
|
|
12631
|
+
band: "critical",
|
|
12632
|
+
verdict: "UNWORTHY",
|
|
12633
|
+
color: "error",
|
|
12634
|
+
powerLevel: score,
|
|
12635
|
+
headline: HEADLINES.critical,
|
|
12636
|
+
rune: RUNES.critical
|
|
12637
|
+
};
|
|
12638
|
+
}
|
|
12639
|
+
//#endregion
|
|
12640
|
+
//#region src/engine/monorepo-analysis.ts
|
|
12641
|
+
/**
|
|
12642
|
+
* Package verdict, derived from the one ScoreState band model (BW-104).
|
|
12643
|
+
* These two constants used to be a private copy of the band boundaries;
|
|
12644
|
+
* the numeric vocabulary here is a different projection of the same bands
|
|
12645
|
+
* — "pass" is trusted or forged, "warn" is warning, "fail" is critical or
|
|
12646
|
+
* unmeasurable — so the boundaries are read, never retyped.
|
|
12647
|
+
*/
|
|
12648
|
+
function verdictOf(score) {
|
|
12649
|
+
if (score === null) return "fail";
|
|
12650
|
+
const band = deriveScoreState(score).band;
|
|
12651
|
+
if (band === "forged" || band === "trusted") return "pass";
|
|
12652
|
+
return band === "warning" ? "warn" : "fail";
|
|
12653
|
+
}
|
|
12654
|
+
function hasBlocker(findings) {
|
|
12655
|
+
return findings.some((f) => f.severity === "error");
|
|
12656
|
+
}
|
|
12657
|
+
/**
|
|
12658
|
+
* Aggregate per-package results into an overall verdict.
|
|
12659
|
+
*
|
|
12660
|
+
* Worst-package propagation: if ANY package has a blocker (error-severity
|
|
12661
|
+
* finding) or a failing score, the overall result fails regardless of
|
|
12662
|
+
* strategy. This is the safety net — a single poisoned package must not
|
|
12663
|
+
* hide behind averaging.
|
|
12664
|
+
*/
|
|
12665
|
+
function analyzeMonorepo(packages, config) {
|
|
12666
|
+
const results = packages.map((p) => ({
|
|
12667
|
+
...p,
|
|
12668
|
+
verdict: hasBlocker(p.findings) ? "fail" : verdictOf(p.score)
|
|
12669
|
+
}));
|
|
12670
|
+
if (results.length === 0) return {
|
|
12671
|
+
packages: results,
|
|
12672
|
+
overallScore: null,
|
|
12673
|
+
overallVerdict: "fail",
|
|
12674
|
+
strategy: config.weightingStrategy
|
|
12675
|
+
};
|
|
12676
|
+
const blockerPkg = results.find((r) => r.verdict === "fail" || hasBlocker(r.findings));
|
|
12677
|
+
if (blockerPkg) return {
|
|
12678
|
+
packages: results,
|
|
12679
|
+
overallScore: blockerPkg.score,
|
|
12680
|
+
overallVerdict: "fail",
|
|
12681
|
+
strategy: config.weightingStrategy,
|
|
12682
|
+
blockerPackage: blockerPkg.packageName
|
|
12683
|
+
};
|
|
12684
|
+
switch (config.weightingStrategy) {
|
|
12685
|
+
case "worst-package": return worstPackageAggregation(results, config);
|
|
12686
|
+
case "average": return averageAggregation(results, config);
|
|
12687
|
+
case "configurable": return configurableAggregation(results, config);
|
|
12688
|
+
}
|
|
12689
|
+
}
|
|
12690
|
+
function worstPackageAggregation(results, config) {
|
|
12691
|
+
const firstResult = results[0];
|
|
12692
|
+
if (firstResult === void 0) return {
|
|
12693
|
+
packages: results,
|
|
12694
|
+
overallScore: null,
|
|
12695
|
+
overallVerdict: "fail",
|
|
12696
|
+
strategy: config.weightingStrategy
|
|
12697
|
+
};
|
|
12698
|
+
let worst = firstResult;
|
|
12699
|
+
for (const r of results) if ((r.score ?? 0) < (worst.score ?? 0)) worst = r;
|
|
12700
|
+
return {
|
|
12701
|
+
packages: results,
|
|
12702
|
+
overallScore: worst.score,
|
|
12703
|
+
overallVerdict: worst.verdict,
|
|
12704
|
+
strategy: config.weightingStrategy,
|
|
12705
|
+
...worst.verdict === "fail" ? { blockerPackage: worst.packageName } : {}
|
|
12706
|
+
};
|
|
12707
|
+
}
|
|
12708
|
+
function averageAggregation(results, config) {
|
|
12709
|
+
const scored = results.filter((r) => r.score !== null);
|
|
12710
|
+
if (scored.length === 0) return {
|
|
12711
|
+
packages: results,
|
|
12712
|
+
overallScore: null,
|
|
12713
|
+
overallVerdict: "fail",
|
|
12714
|
+
strategy: config.weightingStrategy
|
|
12715
|
+
};
|
|
12716
|
+
const avg = scored.reduce((sum, r) => sum + (r.score ?? 0), 0) / scored.length;
|
|
12717
|
+
return {
|
|
12718
|
+
packages: results,
|
|
11941
12719
|
overallScore: Math.round(avg),
|
|
11942
12720
|
overallVerdict: verdictOf(Math.round(avg)),
|
|
11943
12721
|
strategy: config.weightingStrategy
|
|
@@ -11967,6 +12745,90 @@ function configurableAggregation(results, config) {
|
|
|
11967
12745
|
};
|
|
11968
12746
|
}
|
|
11969
12747
|
//#endregion
|
|
12748
|
+
//#region src/lib/fs-atomic.ts
|
|
12749
|
+
/**
|
|
12750
|
+
* Atomic file writes (audit S9).
|
|
12751
|
+
*
|
|
12752
|
+
* Every durability-critical write in Mjölnir (baseline, stats, badge,
|
|
12753
|
+
* TRIAGE.md, scaffolded rule files) used to hand-roll
|
|
12754
|
+
* `writeFileSync(path, data)` — a crash mid-write left a TRUNCATED file
|
|
12755
|
+
* at the real path, and a subsequent read (diff, badge endpoint) served
|
|
12756
|
+
* confident nonsense from it.
|
|
12757
|
+
*
|
|
12758
|
+
* `writeFileAtomic` writes to a temp sibling, then RENAMES. On the same
|
|
12759
|
+
* volume rename is atomic: readers see either the complete old file or
|
|
12760
|
+
* the complete new file, never a half-written one. The temp name is
|
|
12761
|
+
* created with `wx` (exclusive) so concurrent writers cannot interleave,
|
|
12762
|
+
* stale temps are cleaned up on failure, and on Windows the rename is
|
|
12763
|
+
* retried briefly because a concurrent reader can hold the destination
|
|
12764
|
+
* open (EBUSY/EPERM).
|
|
12765
|
+
*/
|
|
12766
|
+
function atomicTempPath(path) {
|
|
12767
|
+
return `${path}.mjolnir-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}.tmp`;
|
|
12768
|
+
}
|
|
12769
|
+
/**
|
|
12770
|
+
* Atomically replace `path` with `data`.
|
|
12771
|
+
*
|
|
12772
|
+
* Accepts binary payloads as well as text: base-tree materialization in
|
|
12773
|
+
* `impact` writes git blobs, and a writer that only takes strings forces those
|
|
12774
|
+
* call sites back to the non-atomic path rather than keeping them contained.
|
|
12775
|
+
*/
|
|
12776
|
+
function writeFileAtomic(path, data, opts = {}) {
|
|
12777
|
+
const dir = dirname(path);
|
|
12778
|
+
if (opts.mkdirs !== false && !existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
12779
|
+
const tmp = atomicTempPath(path);
|
|
12780
|
+
let fd;
|
|
12781
|
+
try {
|
|
12782
|
+
fd = openSync(tmp, "wx", opts.mode ?? 420);
|
|
12783
|
+
if (typeof data === "string") writeSync(fd, data, null, opts.encoding ?? "utf8");
|
|
12784
|
+
else writeSync(fd, data);
|
|
12785
|
+
} finally {
|
|
12786
|
+
if (fd !== void 0) closeSync(fd);
|
|
12787
|
+
}
|
|
12788
|
+
try {
|
|
12789
|
+
renameWithWindowsRetry(tmp, path);
|
|
12790
|
+
} catch (err) {
|
|
12791
|
+
try {
|
|
12792
|
+
if (existsSync(tmp)) unlinkSync(tmp);
|
|
12793
|
+
} catch {}
|
|
12794
|
+
throw err;
|
|
12795
|
+
}
|
|
12796
|
+
}
|
|
12797
|
+
/**
|
|
12798
|
+
* renameSync retry loop for Windows: a concurrent reader (another scan,
|
|
12799
|
+
* a badge endpoint, an editor) holding the destination open makes
|
|
12800
|
+
* rename fail with EBUSY/EPERM. A short bounded retry closes the race
|
|
12801
|
+
* without turning an atomic swap into a partial write.
|
|
12802
|
+
*
|
|
12803
|
+
* Internal contract test hook: the platform check keeps this loop off
|
|
12804
|
+
* the POSIX hot path; on win32 the EBUSY/EPERM arms are exercised by
|
|
12805
|
+
* the fs-atomic-retry spec (mocked renameSync).
|
|
12806
|
+
*/
|
|
12807
|
+
const RENAME_RETRIES = 8;
|
|
12808
|
+
const RENAME_RETRY_DELAY_MS = 25;
|
|
12809
|
+
/** Synchronous sleep that does not spin the CPU. */
|
|
12810
|
+
function sleepSync(ms) {
|
|
12811
|
+
try {
|
|
12812
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
12813
|
+
} catch {
|
|
12814
|
+
const until = Date.now() + ms;
|
|
12815
|
+
while (Date.now() < until);
|
|
12816
|
+
}
|
|
12817
|
+
}
|
|
12818
|
+
function renameWithWindowsRetry(from, to) {
|
|
12819
|
+
for (let attempt = 0;; attempt++) try {
|
|
12820
|
+
renameSync(from, to);
|
|
12821
|
+
return;
|
|
12822
|
+
} catch (err) {
|
|
12823
|
+
const code = err?.code;
|
|
12824
|
+
if (process.platform === "win32" && (code === "EBUSY" || code === "EPERM") && attempt < RENAME_RETRIES) {
|
|
12825
|
+
sleepSync(RENAME_RETRY_DELAY_MS);
|
|
12826
|
+
continue;
|
|
12827
|
+
}
|
|
12828
|
+
throw err;
|
|
12829
|
+
}
|
|
12830
|
+
}
|
|
12831
|
+
//#endregion
|
|
11970
12832
|
//#region src/engine/scan-cache.ts
|
|
11971
12833
|
/**
|
|
11972
12834
|
* Local incremental scan cache (Beta-to-Stable 1.0 plan, M5.2 / A-2).
|
|
@@ -12002,6 +12864,17 @@ const MAX_ENTRIES$1 = 4096;
|
|
|
12002
12864
|
* serialized size; the newest entries win (real LRU-by-use).
|
|
12003
12865
|
*/
|
|
12004
12866
|
const MAX_TOTAL_BYTES = 33554432;
|
|
12867
|
+
const MAX_FINDINGS_PER_ENTRY = 1e4;
|
|
12868
|
+
function isCachedFinding(value) {
|
|
12869
|
+
if (!isRecord(value)) return false;
|
|
12870
|
+
return typeof value["ruleId"] === "string" && typeof value["category"] === "string" && typeof value["severity"] === "string" && typeof value["confidence"] === "string" && typeof value["findingType"] === "string" && typeof value["file"] === "string" && Number.isSafeInteger(value["line"]) && Number.isSafeInteger(value["column"]) && typeof value["message"] === "string" && typeof value["why"] === "string" && typeof value["fix"] === "string";
|
|
12871
|
+
}
|
|
12872
|
+
function isCacheFile(value) {
|
|
12873
|
+
if (!isRecord(value) || value["version"] !== CACHE_VERSION) return false;
|
|
12874
|
+
const entries = value["entries"];
|
|
12875
|
+
if (!isRecord(entries) || Object.keys(entries).length > MAX_ENTRIES$1) return false;
|
|
12876
|
+
return Object.values(entries).every((entry) => isRecord(entry) && Array.isArray(entry["findings"]) && entry["findings"].length <= MAX_FINDINGS_PER_ENTRY && entry["findings"].every(isCachedFinding));
|
|
12877
|
+
}
|
|
12005
12878
|
/** sha256 hex of a string — the only hash this module needs. */
|
|
12006
12879
|
function sha256(text) {
|
|
12007
12880
|
return createHash("sha256").update(text).digest("hex");
|
|
@@ -12051,282 +12924,120 @@ function hashDir(dir, hash, depth) {
|
|
|
12051
12924
|
for (const entry of entries) {
|
|
12052
12925
|
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
|
12053
12926
|
const full = join(dir, entry.name);
|
|
12054
|
-
if (entry.isDirectory()) hashDir(full, hash, depth + 1);
|
|
12055
|
-
else if (entry.isFile() && /\.tsx?$/.test(entry.name)) try {
|
|
12056
|
-
hash.update(entry.name);
|
|
12057
|
-
hash.update(readFileSync(full));
|
|
12058
|
-
} catch {}
|
|
12059
|
-
}
|
|
12060
|
-
}
|
|
12061
|
-
/**
|
|
12062
|
-
* Content-addressed key for one file's rule-loop verdicts.
|
|
12063
|
-
*
|
|
12064
|
-
* Audit C1: the key MUST identify the verdict's producer, not just the
|
|
12065
|
-
* bytes — two files with byte-identical text (a copied spec, a generated
|
|
12066
|
-
* snapshot) previously shared one entry, and the first file's cached
|
|
12067
|
-
* findings were re-emitted for the second with the wrong `file` stamp.
|
|
12068
|
-
* The key therefore folds in the repo-relative path AND the adapter id,
|
|
12069
|
-
* plus a parse-mode token (audit W9): a file whose analysis degraded to
|
|
12070
|
-
* the regex fallback (or skipped the AST path) must not collide with a
|
|
12071
|
-
* fully-AST-analyzed verdict for the same bytes — the fallback output
|
|
12072
|
-
* belongs only to the fallback mode.
|
|
12073
|
-
*/
|
|
12074
|
-
function fileCacheKey(rulesDigest, fileText, identity) {
|
|
12075
|
-
const parseMode = identity.parseMode ?? "ast";
|
|
12076
|
-
return sha256(`${CACHE_VERSION}\u0000${rulesDigest}\u0000${identity.relPath}\u0000${identity.adapterId}\u0000${parseMode}\u0000${fileText}`);
|
|
12077
|
-
}
|
|
12078
|
-
/** No-op cache used when --cache is absent: zero stats, zero I/O. */
|
|
12079
|
-
const disabledScanCache = {
|
|
12080
|
-
stats: {
|
|
12081
|
-
hits: 0,
|
|
12082
|
-
misses: 0,
|
|
12083
|
-
file: ""
|
|
12084
|
-
},
|
|
12085
|
-
lookup: () => void 0,
|
|
12086
|
-
store: () => {},
|
|
12087
|
-
persist: () => {}
|
|
12088
|
-
};
|
|
12089
|
-
/**
|
|
12090
|
-
* Opens (and lazily creates) `<root>/.mjolnir/cache/scan-v<CACHE_VERSION>.json`. A
|
|
12091
|
-
* corrupt, hostile or future-versioned cache file degrades to a cold
|
|
12092
|
-
* cache — never fails the scan.
|
|
12093
|
-
*/
|
|
12094
|
-
function createScanCache(root) {
|
|
12095
|
-
const dir = join(root, ".mjolnir", "cache");
|
|
12096
|
-
const file = join(dir, `scan-v${CACHE_VERSION}.json`);
|
|
12097
|
-
let entries = {};
|
|
12098
|
-
let dirty = false;
|
|
12099
|
-
const entryBytes = /* @__PURE__ */ new Map();
|
|
12100
|
-
let totalBytes = 0;
|
|
12101
|
-
try {
|
|
12102
|
-
if (existsSync(file)) {
|
|
12103
|
-
entries = parseJsonFile(readFileSync(file, "utf8"), file, (v) => isRecord(v) && v["version"] === CACHE_VERSION && isRecord(v["entries"])).entries;
|
|
12104
|
-
for (const [k, v] of Object.entries(entries)) {
|
|
12105
|
-
const size = JSON.stringify(v).length + k.length + 4;
|
|
12106
|
-
entryBytes.set(k, size);
|
|
12107
|
-
totalBytes += size;
|
|
12108
|
-
}
|
|
12109
|
-
}
|
|
12110
|
-
} catch {
|
|
12111
|
-
entries = {};
|
|
12112
|
-
entryBytes.clear();
|
|
12113
|
-
totalBytes = 0;
|
|
12114
|
-
}
|
|
12115
|
-
return {
|
|
12116
|
-
stats: {
|
|
12117
|
-
hits: 0,
|
|
12118
|
-
misses: 0,
|
|
12119
|
-
file
|
|
12120
|
-
},
|
|
12121
|
-
lookup(key) {
|
|
12122
|
-
const entry = entries[key];
|
|
12123
|
-
if (!entry) {
|
|
12124
|
-
this.stats.misses++;
|
|
12125
|
-
return;
|
|
12126
|
-
}
|
|
12127
|
-
this.stats.hits++;
|
|
12128
|
-
delete entries[key];
|
|
12129
|
-
entries[key] = entry;
|
|
12130
|
-
return structuredClone(entry.findings);
|
|
12131
|
-
},
|
|
12132
|
-
store(key, findings, fileBudgetExceeded) {
|
|
12133
|
-
if (fileBudgetExceeded) return;
|
|
12134
|
-
const replacedBytes = entryBytes.get(key) ?? 0;
|
|
12135
|
-
delete entries[key];
|
|
12136
|
-
const entryJson = JSON.stringify(findings);
|
|
12137
|
-
entries[key] = { findings: structuredClone(findings) };
|
|
12138
|
-
const newBytes = entryJson.length + key.length + 4;
|
|
12139
|
-
entryBytes.set(key, newBytes);
|
|
12140
|
-
totalBytes = totalBytes - replacedBytes + newBytes;
|
|
12141
|
-
let count = Object.keys(entries).length;
|
|
12142
|
-
while ((count > MAX_ENTRIES$1 || totalBytes > MAX_TOTAL_BYTES) && count > 1) {
|
|
12143
|
-
const oldest = Object.keys(entries)[0];
|
|
12144
|
-
totalBytes -= entryBytes.get(oldest);
|
|
12145
|
-
delete entries[oldest];
|
|
12146
|
-
entryBytes.delete(oldest);
|
|
12147
|
-
count--;
|
|
12148
|
-
}
|
|
12149
|
-
dirty = true;
|
|
12150
|
-
},
|
|
12151
|
-
persist() {
|
|
12152
|
-
if (!dirty) return;
|
|
12153
|
-
try {
|
|
12154
|
-
mkdirSync(dir, { recursive: true });
|
|
12155
|
-
writeFileSync(file, JSON.stringify({
|
|
12156
|
-
version: CACHE_VERSION,
|
|
12157
|
-
entries
|
|
12158
|
-
}), "utf8");
|
|
12159
|
-
} catch {}
|
|
12160
|
-
}
|
|
12161
|
-
};
|
|
12162
|
-
}
|
|
12163
|
-
//#endregion
|
|
12164
|
-
//#region src/brand/tokens.ts
|
|
12165
|
-
/**
|
|
12166
|
-
* The single source of brand truth.
|
|
12167
|
-
*
|
|
12168
|
-
* Every colour, typeface and motion constant Mjölnir shows a human —
|
|
12169
|
-
* terminal, README SVGs, demo video, website, docs, badges — resolves to
|
|
12170
|
-
* a value in this file. Nothing else may define one.
|
|
12171
|
-
*
|
|
12172
|
-
* WHY THIS EXISTS. Before it, the palette existed in six independent
|
|
12173
|
-
* copies: `site/.vitepress/theme/styles/vars.css`, `NORSE` in
|
|
12174
|
-
* `src/reporter/theme.ts`, `scripts/readme-svg.ts`,
|
|
12175
|
-
* `scripts/video/terminal-page.ts`, `scripts/generate-readme-architecture.ts`
|
|
12176
|
-
* and the table in `assets/brand/README.md`. Exactly one pair of those
|
|
12177
|
-
* was guarded (site-doctor Check 8, doc ↔ vars.css). The unguarded edges
|
|
12178
|
-
* are where the shipped surfaces drifted apart: the terminal and the site
|
|
12179
|
-
* disagreed on six semantic roles, the architecture diagram invented its
|
|
12180
|
-
* own neutral ramp, and the README badges still carried a palette retired
|
|
12181
|
-
* two releases earlier. `scripts/brand-doctor.mjs` now checks every edge
|
|
12182
|
-
* against this file.
|
|
12183
|
-
*
|
|
12184
|
-
* PURITY. Pure data. No I/O, no rendering, no environment access, no
|
|
12185
|
-
* imports, no logic. Consumers convert (hex → ANSI triplet, hex → CSS)
|
|
12186
|
-
* themselves. Same reason `score-state.ts` is pure: it makes the whole
|
|
12187
|
-
* thing golden-testable and safe to ship inside the npm package, where
|
|
12188
|
-
* it costs a few hundred bytes and replaces values the package already
|
|
12189
|
-
* carried anyway.
|
|
12190
|
-
*
|
|
12191
|
-
* DERIVATION. The palette is the one derived from the logo in PR #20
|
|
12192
|
-
* (brushed steel and forge gold under an aurora, over midnight iron).
|
|
12193
|
-
* Where the terminal disagreed with it, the terminal converges — see
|
|
12194
|
-
* `PENDING_TERMINAL` below. Full rationale: `assets/brand/README.md`.
|
|
12195
|
-
*
|
|
12196
|
-
* ACCESSIBILITY. Every foreground token in `BRAND` meets WCAG AA
|
|
12197
|
-
* (≥ 4.5:1) against every surface token it is allowed to sit on. That is
|
|
12198
|
-
* not a claim, it is `brand-doctor` rule 8, which computes the ratios.
|
|
12199
|
-
* The weakest legal pairing is `steelDim` on `ink800` at 5.00:1.
|
|
12200
|
-
*/
|
|
12201
|
-
/**
|
|
12202
|
-
* The two brand hues plus the neutral they sit on.
|
|
12203
|
-
*
|
|
12204
|
-
* GOLD IS SCARCE. It means forged / certified / earned / decisive — the
|
|
12205
|
-
* primary mark, the FORGED state, one call to action. It is not a paint
|
|
12206
|
-
* bucket: gold as default text, default border or default heading is a
|
|
12207
|
-
* brand-doctor finding, not a style choice.
|
|
12208
|
-
*
|
|
12209
|
-
* AURORA is verification energy — the secondary, and the hue that marks
|
|
12210
|
-
* the runtime half of the trust ladder.
|
|
12211
|
-
*/
|
|
12212
|
-
const BRAND = {
|
|
12213
|
-
gold: "#C19A34",
|
|
12214
|
-
goldBright: "#E6BD57",
|
|
12215
|
-
goldHot: "#F4DC9C",
|
|
12216
|
-
/** Pressed / deepest gold — the only step dark enough to carry white. */
|
|
12217
|
-
goldDeep: "#A5811C",
|
|
12218
|
-
aurora: "#37ABBD",
|
|
12219
|
-
auroraBright: "#45C1D4",
|
|
12220
|
-
auroraCyan: "#5CBDE0",
|
|
12221
|
-
/** The aurora's outer curtains: atmosphere and section identity only,
|
|
12222
|
-
* never a verdict or a status. */
|
|
12223
|
-
auroraGreen: "#5FD6A4",
|
|
12224
|
-
auroraViolet: "#9D8CF5",
|
|
12225
|
-
steel: "#C8CBCF",
|
|
12226
|
-
steelDim: "#8B939D"
|
|
12227
|
-
};
|
|
12228
|
-
/**
|
|
12229
|
-
* Midnight iron. One ramp, four steps, darkest first.
|
|
12230
|
-
*
|
|
12231
|
-
* `terminal` and `terminalBar` share one tone deliberately: the window's
|
|
12232
|
-
* only seam is a hairline ring and an inset shadow, never a second fill.
|
|
12233
|
-
* `chromeDot` is the three window dots — see the note on
|
|
12234
|
-
* `PENDING_TERMINAL.chromeDots` for why they are no longer red/amber/green.
|
|
12235
|
-
*/
|
|
12236
|
-
const SURFACE = {
|
|
12237
|
-
ink950: "#0A1119",
|
|
12238
|
-
ink900: "#0C1420",
|
|
12239
|
-
ink850: "#111A29",
|
|
12240
|
-
ink800: "#18243A",
|
|
12241
|
-
/** Raised panel (cards, elevated surfaces). */
|
|
12242
|
-
panel: "#141F33",
|
|
12243
|
-
/** Soft fill (inline code, quiet chips). */
|
|
12244
|
-
soft: "#1A2740",
|
|
12245
|
-
/** Terminal body — the deepest tone, so a terminal reads as recessed. */
|
|
12246
|
-
terminal: "#0A1119",
|
|
12247
|
-
/** Terminal title bar — the same tone; the seam is shadow, not colour. */
|
|
12248
|
-
terminalBar: "#0A1119",
|
|
12249
|
-
/** The three window dots. One neutral, not a traffic light. */
|
|
12250
|
-
chromeDot: "#18243A"
|
|
12251
|
-
};
|
|
12252
|
-
const TEXT = {
|
|
12253
|
-
primary: "#EAEEF5",
|
|
12254
|
-
secondary: "#ABB6C6",
|
|
12255
|
-
muted: "#8B939D",
|
|
12256
|
-
/** Ink for text set ON gold (buttons, the FORGED chip). 7.17:1 on `gold`. */
|
|
12257
|
-
onGold: "#0A1119"
|
|
12258
|
-
};
|
|
12259
|
-
/**
|
|
12260
|
-
* Non-score status. `ok` is the one green in the system and it is NOT a
|
|
12261
|
-
* score colour — it survives only for contexts with no worthiness
|
|
12262
|
-
* meaning ("autofix applied", "analysis complete"). A green score would
|
|
12263
|
-
* say "your software is fine", which is the exact claim this product
|
|
12264
|
-
* refuses to make.
|
|
12265
|
-
*/
|
|
12266
|
-
const STATUS = {
|
|
12267
|
-
ok: "#4FB477",
|
|
12268
|
-
info: "#5CC4E0",
|
|
12269
|
-
warning: "#E6BD57",
|
|
12270
|
-
error: "#EC6B66"
|
|
12271
|
-
};
|
|
12272
|
-
/**
|
|
12273
|
-
* The four ScoreState bands plus the unmeasured state. Band thresholds
|
|
12274
|
-
* and runes live in `src/reporter/score-state.ts`, which stays free of
|
|
12275
|
-
* colour — it emits a palette KEY and each surface resolves it here.
|
|
12276
|
-
*
|
|
12277
|
-
* `unmeasured` is steel-dim on purpose. UNKNOWN is a legitimate answer,
|
|
12278
|
-
* not a failure: colouring it red would make "we did not measure this"
|
|
12279
|
-
* look like "this is broken", which is precisely the dishonesty the
|
|
12280
|
-
* north-star law exists to prevent.
|
|
12281
|
-
*/
|
|
12282
|
-
const SCORE = {
|
|
12283
|
-
critical: "#EC6B66",
|
|
12284
|
-
warning: "#E6BD57",
|
|
12285
|
-
trusted: "#5CC4E0",
|
|
12286
|
-
forged: "#F4DC9C",
|
|
12287
|
-
unmeasured: "#8B939D"
|
|
12288
|
-
};
|
|
12289
|
-
/**
|
|
12290
|
-
* E0 → E1 → E2 is a certainty ramp, and it is deliberately HUE-FREE.
|
|
12291
|
-
*
|
|
12292
|
-
* Evidence level says how sure we are, not whether the news is good. A
|
|
12293
|
-
* deterministic proof (E2) is a defect we are certain about — painting
|
|
12294
|
-
* it gold or green would read as an achievement. So certainty is carried
|
|
12295
|
-
* by brightness alone, and the *shape* does the real work:
|
|
12296
|
-
*
|
|
12297
|
-
* E0 open ring observation, no weight
|
|
12298
|
-
* E1 half-filled pattern evidence, half weight
|
|
12299
|
-
* E2 sealed deterministic proof, full weight
|
|
12927
|
+
if (entry.isDirectory()) hashDir(full, hash, depth + 1);
|
|
12928
|
+
else if (entry.isFile() && /\.tsx?$/.test(entry.name)) try {
|
|
12929
|
+
hash.update(entry.name);
|
|
12930
|
+
hash.update(readFileSync(full));
|
|
12931
|
+
} catch {}
|
|
12932
|
+
}
|
|
12933
|
+
}
|
|
12934
|
+
/**
|
|
12935
|
+
* Content-addressed key for one file's rule-loop verdicts.
|
|
12300
12936
|
*
|
|
12301
|
-
*
|
|
12302
|
-
*
|
|
12937
|
+
* Audit C1: the key MUST identify the verdict's producer, not just the
|
|
12938
|
+
* bytes — two files with byte-identical text (a copied spec, a generated
|
|
12939
|
+
* snapshot) previously shared one entry, and the first file's cached
|
|
12940
|
+
* findings were re-emitted for the second with the wrong `file` stamp.
|
|
12941
|
+
* The key therefore folds in the repo-relative path AND the adapter id,
|
|
12942
|
+
* plus a parse-mode token (audit W9): a file whose analysis degraded to
|
|
12943
|
+
* the regex fallback (or skipped the AST path) must not collide with a
|
|
12944
|
+
* fully-AST-analyzed verdict for the same bytes — the fallback output
|
|
12945
|
+
* belongs only to the fallback mode.
|
|
12303
12946
|
*/
|
|
12304
|
-
|
|
12305
|
-
|
|
12306
|
-
|
|
12307
|
-
|
|
12947
|
+
function fileCacheKey(rulesDigest, fileText, identity) {
|
|
12948
|
+
const parseMode = identity.parseMode ?? "ast";
|
|
12949
|
+
return sha256(`${CACHE_VERSION}\u0000${rulesDigest}\u0000${identity.relPath}\u0000${identity.adapterId}\u0000${parseMode}\u0000${fileText}`);
|
|
12950
|
+
}
|
|
12951
|
+
/** No-op cache used when --cache is absent: zero stats, zero I/O. */
|
|
12952
|
+
const disabledScanCache = {
|
|
12953
|
+
stats: {
|
|
12954
|
+
hits: 0,
|
|
12955
|
+
misses: 0,
|
|
12956
|
+
file: ""
|
|
12957
|
+
},
|
|
12958
|
+
lookup: () => void 0,
|
|
12959
|
+
store: () => {},
|
|
12960
|
+
persist: () => {}
|
|
12308
12961
|
};
|
|
12309
12962
|
/**
|
|
12310
|
-
*
|
|
12311
|
-
*
|
|
12312
|
-
*
|
|
12313
|
-
* ceiling at L2. L3–L5 require a real run, and the hue changes to aurora
|
|
12314
|
-
* exactly there. The boundary is a hue break, not a gradient step,
|
|
12315
|
-
* because it is a change of kind and not of degree — a static-only
|
|
12316
|
-
* finding can never climb past L2, however confident it is.
|
|
12317
|
-
*
|
|
12318
|
-
* Every surface that draws the ladder must draw that break.
|
|
12963
|
+
* Opens (and lazily creates) `<root>/.mjolnir/cache/scan-v<CACHE_VERSION>.json`. A
|
|
12964
|
+
* corrupt, hostile or future-versioned cache file degrades to a cold
|
|
12965
|
+
* cache — never fails the scan.
|
|
12319
12966
|
*/
|
|
12320
|
-
|
|
12321
|
-
|
|
12322
|
-
|
|
12323
|
-
|
|
12324
|
-
|
|
12325
|
-
|
|
12326
|
-
|
|
12327
|
-
|
|
12328
|
-
|
|
12329
|
-
|
|
12967
|
+
function createScanCache(root) {
|
|
12968
|
+
const dir = join(root, ".mjolnir", "cache");
|
|
12969
|
+
const file = join(dir, `scan-v${CACHE_VERSION}.json`);
|
|
12970
|
+
let entries = {};
|
|
12971
|
+
let dirty = false;
|
|
12972
|
+
const entryBytes = /* @__PURE__ */ new Map();
|
|
12973
|
+
let totalBytes = 0;
|
|
12974
|
+
try {
|
|
12975
|
+
if (existsSync(file)) {
|
|
12976
|
+
const stat = lstatSync(file);
|
|
12977
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_TOTAL_BYTES) throw new Error("invalid cache file");
|
|
12978
|
+
entries = parseJsonFile(readFileSync(file, "utf8"), file, isCacheFile).entries;
|
|
12979
|
+
for (const [k, v] of Object.entries(entries)) {
|
|
12980
|
+
const size = Buffer.byteLength(JSON.stringify(v), "utf8") + k.length + 4;
|
|
12981
|
+
entryBytes.set(k, size);
|
|
12982
|
+
totalBytes += size;
|
|
12983
|
+
}
|
|
12984
|
+
if (totalBytes > MAX_TOTAL_BYTES) throw new Error("cache byte budget exceeded");
|
|
12985
|
+
}
|
|
12986
|
+
} catch {
|
|
12987
|
+
entries = {};
|
|
12988
|
+
entryBytes.clear();
|
|
12989
|
+
totalBytes = 0;
|
|
12990
|
+
}
|
|
12991
|
+
return {
|
|
12992
|
+
stats: {
|
|
12993
|
+
hits: 0,
|
|
12994
|
+
misses: 0,
|
|
12995
|
+
file
|
|
12996
|
+
},
|
|
12997
|
+
lookup(key) {
|
|
12998
|
+
const entry = entries[key];
|
|
12999
|
+
if (!entry) {
|
|
13000
|
+
this.stats.misses++;
|
|
13001
|
+
return;
|
|
13002
|
+
}
|
|
13003
|
+
this.stats.hits++;
|
|
13004
|
+
delete entries[key];
|
|
13005
|
+
entries[key] = entry;
|
|
13006
|
+
return structuredClone(entry.findings);
|
|
13007
|
+
},
|
|
13008
|
+
store(key, findings, fileBudgetExceeded) {
|
|
13009
|
+
if (fileBudgetExceeded) return;
|
|
13010
|
+
if (findings.length > MAX_FINDINGS_PER_ENTRY) return;
|
|
13011
|
+
const entryJson = JSON.stringify(findings);
|
|
13012
|
+
const newBytes = Buffer.byteLength(entryJson, "utf8") + key.length + 4;
|
|
13013
|
+
if (newBytes > MAX_TOTAL_BYTES) return;
|
|
13014
|
+
const replacedBytes = entryBytes.get(key) ?? 0;
|
|
13015
|
+
delete entries[key];
|
|
13016
|
+
entries[key] = { findings: structuredClone(findings) };
|
|
13017
|
+
entryBytes.set(key, newBytes);
|
|
13018
|
+
totalBytes = totalBytes - replacedBytes + newBytes;
|
|
13019
|
+
let count = Object.keys(entries).length;
|
|
13020
|
+
while ((count > MAX_ENTRIES$1 || totalBytes > MAX_TOTAL_BYTES) && count > 1) {
|
|
13021
|
+
const oldest = Object.keys(entries)[0];
|
|
13022
|
+
totalBytes -= entryBytes.get(oldest);
|
|
13023
|
+
delete entries[oldest];
|
|
13024
|
+
entryBytes.delete(oldest);
|
|
13025
|
+
count--;
|
|
13026
|
+
}
|
|
13027
|
+
dirty = true;
|
|
13028
|
+
},
|
|
13029
|
+
persist() {
|
|
13030
|
+
if (!dirty) return;
|
|
13031
|
+
try {
|
|
13032
|
+
mkdirSync(dir, { recursive: true });
|
|
13033
|
+
writeFileAtomic(file, JSON.stringify({
|
|
13034
|
+
version: CACHE_VERSION,
|
|
13035
|
+
entries
|
|
13036
|
+
}), { encoding: "utf8" });
|
|
13037
|
+
} catch {}
|
|
13038
|
+
}
|
|
13039
|
+
};
|
|
13040
|
+
}
|
|
12330
13041
|
//#endregion
|
|
12331
13042
|
//#region src/reporter/theme.ts
|
|
12332
13043
|
/**
|
|
@@ -12342,8 +13053,8 @@ SCORE.trusted, SCORE.forged, SCORE.warning, SCORE.critical, STATUS.info, TEXT.on
|
|
|
12342
13053
|
*
|
|
12343
13054
|
* Symbols always accompany color (color-blind safe, R11).
|
|
12344
13055
|
*
|
|
12345
|
-
* Score-state colors come from ScoreState (
|
|
12346
|
-
* source of truth shared with the badge and
|
|
13056
|
+
* Score-state colors come from ScoreState (presentation.ts) — the single
|
|
13057
|
+
* source of truth shared with the badge, the dashboard and the site.
|
|
12347
13058
|
*
|
|
12348
13059
|
* Terminal robustness (Master-Stabilization-Plan Sprint 5 Task 22):
|
|
12349
13060
|
* box-drawing/gauge helpers accept an explicit width so callers can
|
|
@@ -13506,7 +14217,7 @@ function runForensics(target, options = {}) {
|
|
|
13506
14217
|
const base = stat.isFile() ? dirname(target) : target;
|
|
13507
14218
|
flakyMdPath = join(base, "FLAKY.md");
|
|
13508
14219
|
try {
|
|
13509
|
-
|
|
14220
|
+
writeFileAtomic(flakyMdPath, renderFlakyMd(report));
|
|
13510
14221
|
} catch {
|
|
13511
14222
|
flakyMdPath = void 0;
|
|
13512
14223
|
}
|
|
@@ -13798,6 +14509,31 @@ function loadPlugins(root, gateOpen = false) {
|
|
|
13798
14509
|
* next catalog render (locked by tests/local-rules.spec.ts).
|
|
13799
14510
|
*/
|
|
13800
14511
|
const LOCAL_RULES_DIR = "mjolnir-rules";
|
|
14512
|
+
const MAX_EXTERNAL_RULES = 1e3;
|
|
14513
|
+
const MAX_RULE_FILE_BYTES = 1048576;
|
|
14514
|
+
const MAX_PATTERNS_PER_RULE = 100;
|
|
14515
|
+
function hasNestedQuantifier(pattern) {
|
|
14516
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
14517
|
+
if (pattern.charAt(i) !== "(") continue;
|
|
14518
|
+
let bodyHasQuantifier = false;
|
|
14519
|
+
let j = i + 1;
|
|
14520
|
+
for (; j < pattern.length && pattern.charAt(j) !== ")"; j++) {
|
|
14521
|
+
const bodyChar = pattern.charAt(j);
|
|
14522
|
+
if (bodyChar === "+" || bodyChar === "*" || bodyChar === "?") {
|
|
14523
|
+
bodyHasQuantifier = true;
|
|
14524
|
+
break;
|
|
14525
|
+
}
|
|
14526
|
+
}
|
|
14527
|
+
if (bodyHasQuantifier) {
|
|
14528
|
+
while (j < pattern.length && pattern.charAt(j) !== ")") j++;
|
|
14529
|
+
if (j + 1 < pattern.length) {
|
|
14530
|
+
const next = pattern.charAt(j + 1);
|
|
14531
|
+
if (next === "+" || next === "*" || next === "{") return true;
|
|
14532
|
+
}
|
|
14533
|
+
}
|
|
14534
|
+
}
|
|
14535
|
+
return false;
|
|
14536
|
+
}
|
|
13801
14537
|
const ALLOWED_CATEGORIES = /* @__PURE__ */ new Set([
|
|
13802
14538
|
"QA-TEST",
|
|
13803
14539
|
"QA-TQUAL",
|
|
@@ -13827,7 +14563,7 @@ const ALLOWED_QA_IMPACTS = /* @__PURE__ */ new Set([
|
|
|
13827
14563
|
* Missing directory → empty result (not an error — most workspaces
|
|
13828
14564
|
* carry none).
|
|
13829
14565
|
*/
|
|
13830
|
-
async function loadLocalRules(root, gateOpen =
|
|
14566
|
+
async function loadLocalRules(root, gateOpen = false) {
|
|
13831
14567
|
const result = {
|
|
13832
14568
|
rules: [],
|
|
13833
14569
|
errors: [],
|
|
@@ -13835,6 +14571,15 @@ async function loadLocalRules(root, gateOpen = true) {
|
|
|
13835
14571
|
};
|
|
13836
14572
|
const dir = join(root, LOCAL_RULES_DIR);
|
|
13837
14573
|
if (!existsSync(dir)) return result;
|
|
14574
|
+
try {
|
|
14575
|
+
if (lstatSync(dir).isSymbolicLink()) {
|
|
14576
|
+
result.errors.push(`external rules directory "${LOCAL_RULES_DIR}/" is a symlink — skipped`);
|
|
14577
|
+
return result;
|
|
14578
|
+
}
|
|
14579
|
+
} catch (err) {
|
|
14580
|
+
result.errors.push(`external rules directory "${LOCAL_RULES_DIR}/" could not be inspected: ${err instanceof Error ? err.message : String(err)}`);
|
|
14581
|
+
return result;
|
|
14582
|
+
}
|
|
13838
14583
|
let entries;
|
|
13839
14584
|
try {
|
|
13840
14585
|
entries = readdirSync(dir);
|
|
@@ -13843,7 +14588,20 @@ async function loadLocalRules(root, gateOpen = true) {
|
|
|
13843
14588
|
return result;
|
|
13844
14589
|
}
|
|
13845
14590
|
for (const entry of entries.sort()) {
|
|
14591
|
+
if (result.rules.length >= MAX_EXTERNAL_RULES) {
|
|
14592
|
+
result.errors.push(`external rule budget exceeded (${MAX_EXTERNAL_RULES})`);
|
|
14593
|
+
break;
|
|
14594
|
+
}
|
|
13846
14595
|
const path = join(dir, entry);
|
|
14596
|
+
try {
|
|
14597
|
+
if (lstatSync(path).isSymbolicLink()) {
|
|
14598
|
+
result.errors.push(`external rule "${LOCAL_RULES_DIR}/${entry}" is a symlink — skipped`);
|
|
14599
|
+
continue;
|
|
14600
|
+
}
|
|
14601
|
+
} catch (err) {
|
|
14602
|
+
result.errors.push(`external rule "${LOCAL_RULES_DIR}/${entry}" could not be inspected: ${err instanceof Error ? err.message : String(err)}`);
|
|
14603
|
+
continue;
|
|
14604
|
+
}
|
|
13847
14605
|
if (entry.endsWith(".json")) loadJsonRule(path, result);
|
|
13848
14606
|
else if (entry.endsWith(".mjs") || entry.endsWith(".js")) {
|
|
13849
14607
|
if (!gateOpen) result.skipped.push(`${LOCAL_RULES_DIR}/${entry}`);
|
|
@@ -13856,6 +14614,10 @@ function loadJsonRule(path, result) {
|
|
|
13856
14614
|
const name = `${LOCAL_RULES_DIR}/${path.split(/[\\/]/).pop()}`;
|
|
13857
14615
|
let raw;
|
|
13858
14616
|
try {
|
|
14617
|
+
if (lstatSync(path).size > MAX_RULE_FILE_BYTES) {
|
|
14618
|
+
result.errors.push(`external rule "${name}" exceeds the file size budget`);
|
|
14619
|
+
return;
|
|
14620
|
+
}
|
|
13859
14621
|
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
13860
14622
|
} catch (err) {
|
|
13861
14623
|
result.errors.push(`external rule "${name}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -13876,7 +14638,7 @@ function loadJsonRule(path, result) {
|
|
|
13876
14638
|
return;
|
|
13877
14639
|
}
|
|
13878
14640
|
const patterns = decl["patterns"];
|
|
13879
|
-
if (!Array.isArray(patterns) || patterns.length === 0 || !patterns.every((p) => typeof p === "string" && p.length > 0)) {
|
|
14641
|
+
if (!Array.isArray(patterns) || patterns.length === 0 || patterns.length > MAX_PATTERNS_PER_RULE || !patterns.every((p) => typeof p === "string" && p.length > 0)) {
|
|
13880
14642
|
result.errors.push(`external rule ${id} must declare a non-empty "patterns" array of regex source strings.`);
|
|
13881
14643
|
return;
|
|
13882
14644
|
}
|
|
@@ -13892,6 +14654,10 @@ function loadJsonRule(path, result) {
|
|
|
13892
14654
|
result.errors.push(`external rule ${id} has a pattern longer than 512 chars — likely a copy-paste error.`);
|
|
13893
14655
|
return;
|
|
13894
14656
|
}
|
|
14657
|
+
if (hasNestedQuantifier(p)) {
|
|
14658
|
+
result.errors.push(`external rule ${id} has a nested quantifier that may cause catastrophic backtracking`);
|
|
14659
|
+
return;
|
|
14660
|
+
}
|
|
13895
14661
|
const quantifiers = p.match(/[+*?]|\{\d/g);
|
|
13896
14662
|
if (quantifiers && quantifiers.length > 64) {
|
|
13897
14663
|
result.errors.push(`external rule ${id} has a pattern with more than 64 quantifier/wildcard tokens — likely catastrophic backtracking.`);
|
|
@@ -14071,6 +14837,8 @@ function renderGateNotice(skipped) {
|
|
|
14071
14837
|
* module moved. Re-exports in cli.ts keep the historical import surface.
|
|
14072
14838
|
*/
|
|
14073
14839
|
const UNIVERSAL_RULES = RULES.map(asUniversal);
|
|
14840
|
+
const DEFAULT_MAX_DURATION_MS$1 = 6e5;
|
|
14841
|
+
const MAX_DURATION_MS$1 = 36e5;
|
|
14074
14842
|
/** Registered rule IDs — used to warn on unknown severityOverrides keys (M4). */
|
|
14075
14843
|
const KNOWN_RULE_IDS = new Set(RULES.map((r) => r.id));
|
|
14076
14844
|
/**
|
|
@@ -14287,7 +15055,9 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14287
15055
|
let testDeclarationCount = 0;
|
|
14288
15056
|
let rulesPartial = false;
|
|
14289
15057
|
let parseFailed = 0;
|
|
15058
|
+
let parseFallbacks = 0;
|
|
14290
15059
|
let scanned = 0;
|
|
15060
|
+
let analyzed = 0;
|
|
14291
15061
|
for (const path of testFiles) {
|
|
14292
15062
|
if (Date.now() > deadline) {
|
|
14293
15063
|
rulesPartial = true;
|
|
@@ -14301,13 +15071,16 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14301
15071
|
if (!isCiAdapter) testFileCount++;
|
|
14302
15072
|
let text;
|
|
14303
15073
|
try {
|
|
14304
|
-
|
|
15074
|
+
const readResult = readFileBounded(path, LIMITS$1.maxFileBytes);
|
|
15075
|
+
if (!readResult.ok) {
|
|
15076
|
+
skippedFiles++;
|
|
15077
|
+
continue;
|
|
15078
|
+
}
|
|
15079
|
+
text = readResult.data.toString("utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
|
|
14305
15080
|
} catch {
|
|
14306
15081
|
skippedFiles++;
|
|
14307
15082
|
continue;
|
|
14308
15083
|
}
|
|
14309
|
-
let fileBudgetExceeded = false;
|
|
14310
|
-
let fileRuleFailed = false;
|
|
14311
15084
|
const relPath = relative(workspace.root, path).replaceAll("\\", "/");
|
|
14312
15085
|
if (!isCiAdapter) {
|
|
14313
15086
|
const lang = {
|
|
@@ -14333,74 +15106,106 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14333
15106
|
});
|
|
14334
15107
|
let cacheKey = fileCacheKey(rulesDigest, text, identity(wantsAst ? "ast" : "regex"));
|
|
14335
15108
|
const cachedFindings = cache.lookup(cacheKey);
|
|
14336
|
-
|
|
14337
|
-
|
|
14338
|
-
|
|
14339
|
-
|
|
14340
|
-
|
|
14341
|
-
|
|
14342
|
-
|
|
14343
|
-
|
|
14344
|
-
|
|
14345
|
-
|
|
14346
|
-
|
|
14347
|
-
|
|
14348
|
-
|
|
14349
|
-
|
|
14350
|
-
|
|
14351
|
-
|
|
14352
|
-
|
|
14353
|
-
|
|
14354
|
-
|
|
14355
|
-
|
|
14356
|
-
|
|
14357
|
-
|
|
14358
|
-
|
|
14359
|
-
});
|
|
14360
|
-
parsed = await adapter.parseAst(parsedFile);
|
|
14361
|
-
}
|
|
14362
|
-
const actualMode = parsed ? "ast" : "regex";
|
|
14363
|
-
if (wantsAst && actualMode === "regex") {
|
|
14364
|
-
cacheKey = fileCacheKey(rulesDigest, text, identity(actualMode));
|
|
14365
|
-
const fallbackFindings = cache.lookup(cacheKey);
|
|
14366
|
-
if (fallbackFindings) {
|
|
14367
|
-
for (const f of fallbackFindings) findings.push(f);
|
|
14368
|
-
continue;
|
|
15109
|
+
const executor = createExecutor("sequential", 1, async (job) => {
|
|
15110
|
+
if (job.cacheHit !== void 0) return {
|
|
15111
|
+
path: job.path,
|
|
15112
|
+
status: "CACHE_HIT",
|
|
15113
|
+
findings: [...job.cacheHit],
|
|
15114
|
+
parseFallback: false
|
|
15115
|
+
};
|
|
15116
|
+
let parseFallback = false;
|
|
15117
|
+
let fileRuleFailed = false;
|
|
15118
|
+
let fileBudgetExceeded = false;
|
|
15119
|
+
let parsedAst;
|
|
15120
|
+
try {
|
|
15121
|
+
if (adapter.parseAst && job.wantsAst) {
|
|
15122
|
+
hooks.onProgress?.({
|
|
15123
|
+
phase: "rules",
|
|
15124
|
+
done: scanned,
|
|
15125
|
+
total: testFiles.length,
|
|
15126
|
+
detail: job.path
|
|
15127
|
+
});
|
|
15128
|
+
parsedAst = await adapter.parseAst({
|
|
15129
|
+
path: job.path,
|
|
15130
|
+
text: job.text
|
|
15131
|
+
});
|
|
14369
15132
|
}
|
|
14370
|
-
|
|
14371
|
-
|
|
14372
|
-
|
|
14373
|
-
|
|
14374
|
-
|
|
14375
|
-
|
|
14376
|
-
|
|
14377
|
-
|
|
14378
|
-
|
|
14379
|
-
|
|
15133
|
+
const actualMode = parsedAst ? "ast" : "regex";
|
|
15134
|
+
if (job.wantsAst && actualMode === "regex") {
|
|
15135
|
+
parseFallback = true;
|
|
15136
|
+
parseFallbacks++;
|
|
15137
|
+
cacheKey = fileCacheKey(rulesDigest, job.text, identity(actualMode));
|
|
15138
|
+
const fallbackFindings = cache.lookup(cacheKey);
|
|
15139
|
+
if (fallbackFindings) return {
|
|
15140
|
+
path: job.path,
|
|
15141
|
+
status: "CACHE_HIT",
|
|
15142
|
+
findings: [...fallbackFindings],
|
|
15143
|
+
parseFallback: true
|
|
15144
|
+
};
|
|
14380
15145
|
}
|
|
14381
|
-
|
|
14382
|
-
|
|
14383
|
-
|
|
14384
|
-
|
|
15146
|
+
const fileForRules = parsedAst ? {
|
|
15147
|
+
path: job.path,
|
|
15148
|
+
text: job.text,
|
|
15149
|
+
ast: parsedAst.ast
|
|
15150
|
+
} : {
|
|
15151
|
+
path: job.path,
|
|
15152
|
+
text: job.text
|
|
15153
|
+
};
|
|
15154
|
+
const produced = [];
|
|
15155
|
+
adapter.runRules(activeRules, fileForRules, (f, ruleId, category) => {
|
|
15156
|
+
if (!isValidFindingRecord(f)) {
|
|
15157
|
+
fileRuleFailed = true;
|
|
15158
|
+
onRuleCrash?.(ruleId, job.path, /* @__PURE__ */ new Error(`malformed finding record rejected (severity/line/message must be present, severity ∈ error|warning|info): ${JSON.stringify(f)}`));
|
|
15159
|
+
return;
|
|
15160
|
+
}
|
|
15161
|
+
produced.push({
|
|
15162
|
+
...f,
|
|
15163
|
+
ruleId,
|
|
15164
|
+
category
|
|
15165
|
+
});
|
|
15166
|
+
}, (ruleId, error) => {
|
|
15167
|
+
fileRuleFailed = true;
|
|
15168
|
+
onRuleCrash?.(ruleId, job.path, error);
|
|
15169
|
+
}, {
|
|
15170
|
+
deadline: Math.min(deadline, Date.now() + LIMITS$1.maxFileAnalysisMs),
|
|
15171
|
+
onExceeded: () => {
|
|
15172
|
+
rulesPartial = true;
|
|
15173
|
+
skippedFiles++;
|
|
15174
|
+
truncationReasons.add("file-budget");
|
|
15175
|
+
fileBudgetExceeded = true;
|
|
15176
|
+
}
|
|
14385
15177
|
});
|
|
14386
|
-
|
|
14387
|
-
fileRuleFailed
|
|
14388
|
-
|
|
14389
|
-
|
|
14390
|
-
|
|
14391
|
-
|
|
14392
|
-
|
|
14393
|
-
|
|
14394
|
-
|
|
14395
|
-
|
|
14396
|
-
|
|
14397
|
-
|
|
14398
|
-
|
|
14399
|
-
|
|
14400
|
-
|
|
14401
|
-
|
|
14402
|
-
|
|
14403
|
-
|
|
15178
|
+
if (!fileRuleFailed && !fileBudgetExceeded) analyzed++;
|
|
15179
|
+
if (!fileRuleFailed) cache.store(cacheKey, produced, fileBudgetExceeded);
|
|
15180
|
+
return {
|
|
15181
|
+
path: job.path,
|
|
15182
|
+
status: "OK",
|
|
15183
|
+
findings: produced,
|
|
15184
|
+
parseFallback
|
|
15185
|
+
};
|
|
15186
|
+
} catch {
|
|
15187
|
+
if (job.wantsAst) parseFallbacks++;
|
|
15188
|
+
skippedFiles++;
|
|
15189
|
+
parseFailed++;
|
|
15190
|
+
return {
|
|
15191
|
+
path: job.path,
|
|
15192
|
+
status: "FAILED",
|
|
15193
|
+
findings: [],
|
|
15194
|
+
parseFallback
|
|
15195
|
+
};
|
|
15196
|
+
} finally {
|
|
15197
|
+
parsedAst?.dispose();
|
|
15198
|
+
}
|
|
15199
|
+
});
|
|
15200
|
+
const outcome = (await executeFiles([{
|
|
15201
|
+
path: relPath,
|
|
15202
|
+
text,
|
|
15203
|
+
wantsAst,
|
|
15204
|
+
...cachedFindings !== void 0 ? { cacheHit: cachedFindings } : {}
|
|
15205
|
+
}], executor))[0];
|
|
15206
|
+
if (outcome?.status === "CACHE_HIT" || outcome?.status === "OK") {
|
|
15207
|
+
for (const f of outcome.findings) findings.push(f);
|
|
15208
|
+
if (cachedFindings !== void 0 && outcome.status === "CACHE_HIT") analyzed++;
|
|
14404
15209
|
}
|
|
14405
15210
|
}
|
|
14406
15211
|
return {
|
|
@@ -14409,7 +15214,9 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14409
15214
|
testDeclarationCount,
|
|
14410
15215
|
rulesPartial,
|
|
14411
15216
|
parseFailed,
|
|
14412
|
-
|
|
15217
|
+
parseFallbacks,
|
|
15218
|
+
scanned,
|
|
15219
|
+
analyzed
|
|
14413
15220
|
};
|
|
14414
15221
|
}
|
|
14415
15222
|
/**
|
|
@@ -14487,18 +15294,24 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
|
|
|
14487
15294
|
for (const f of findings) f.fixGroupId = f.ruleId;
|
|
14488
15295
|
const discoveredReport = discoverAndParseRuntimeReport(scanRoot.root);
|
|
14489
15296
|
const runtimeReportPath = discoveredReport?.path;
|
|
15297
|
+
const runtimeIncomplete = discoveredReport !== void 0 && discoveredReport.report.analysisComplete !== true;
|
|
14490
15298
|
let forensicVerdicts;
|
|
15299
|
+
let evidenceRecords = [];
|
|
14491
15300
|
if (discoveredReport && discoveredReport.report.analysisComplete === true) try {
|
|
14492
|
-
buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
|
|
15301
|
+
evidenceRecords = buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
|
|
14493
15302
|
stampRuntimeCorroboration(findings, discoveredReport.report, workspace.root);
|
|
14494
15303
|
forensicVerdicts = summarizeForensicVerdicts(discoveredReport.report);
|
|
14495
|
-
} catch {
|
|
15304
|
+
} catch {
|
|
15305
|
+
evidenceRecords = [];
|
|
15306
|
+
}
|
|
14496
15307
|
return {
|
|
15308
|
+
evidenceRecords,
|
|
14497
15309
|
testDeclarationCount,
|
|
14498
15310
|
scopeInfo,
|
|
14499
15311
|
suppressionCount,
|
|
14500
15312
|
frameworks,
|
|
14501
15313
|
runtimeReportPath,
|
|
15314
|
+
runtimeIncomplete,
|
|
14502
15315
|
forensicVerdicts,
|
|
14503
15316
|
config
|
|
14504
15317
|
};
|
|
@@ -14567,10 +15380,13 @@ function assembleScanResult(o) {
|
|
|
14567
15380
|
if (o.scopeIgnored > 0) scopeReasons.push(`ignored:${o.scopeIgnored}`);
|
|
14568
15381
|
if (o.scopeUnrecognized > 0) scopeReasons.push(`unrecognized:${o.scopeUnrecognized}`);
|
|
14569
15382
|
if (o.parseFailed > 0) scopeReasons.push(`parseFailed:${o.parseFailed}`);
|
|
15383
|
+
if (o.skippedFiles > 0) scopeReasons.push(`skipped:${o.skippedFiles}`);
|
|
15384
|
+
if (o.scopeInfo.degraded) scopeReasons.push(`degraded:${o.scopeInfo.degraded}`);
|
|
15385
|
+
if (o.runtimeIncomplete) scopeReasons.push("runtime-incomplete");
|
|
14570
15386
|
for (const reason of o.truncationReasons) scopeReasons.push(`truncated:${reason}`);
|
|
14571
15387
|
const scopeIntegrity = {
|
|
14572
15388
|
discovered: o.testFiles.length,
|
|
14573
|
-
analyzed: Math.max(0, o.scanned),
|
|
15389
|
+
analyzed: Math.max(0, o.analyzed ?? o.scanned),
|
|
14574
15390
|
ignored: o.scopeIgnored,
|
|
14575
15391
|
unrecognized: o.scopeUnrecognized,
|
|
14576
15392
|
parseFailed: o.parseFailed,
|
|
@@ -14579,28 +15395,61 @@ function assembleScanResult(o) {
|
|
|
14579
15395
|
...scopeReasons.length > 0 ? { reasons: scopeReasons } : {}
|
|
14580
15396
|
};
|
|
14581
15397
|
const scopeAdjustedTotal = scopeReasons.length > 0 && total >= 100 ? 99 : total;
|
|
15398
|
+
let identityIncomplete = false;
|
|
14582
15399
|
const inputSnapshot = o.testFiles.map((p) => {
|
|
14583
15400
|
const relPath = relative(o.workspace.root, p).replaceAll("\\", "/");
|
|
14584
15401
|
try {
|
|
14585
|
-
const
|
|
14586
|
-
|
|
15402
|
+
const read = readFileBounded(resolve(o.workspace.root, p), LIMITS$1.maxFileBytes);
|
|
15403
|
+
if (!read.ok) {
|
|
15404
|
+
if (existsSync(o.workspace.root)) identityIncomplete = true;
|
|
15405
|
+
return {
|
|
15406
|
+
path: relPath,
|
|
15407
|
+
size: 0,
|
|
15408
|
+
hash: "UNAVAILABLE"
|
|
15409
|
+
};
|
|
15410
|
+
}
|
|
15411
|
+
const hash = createHash("sha256").update(read.data).digest("hex");
|
|
14587
15412
|
return {
|
|
14588
15413
|
path: relPath,
|
|
14589
|
-
size:
|
|
15414
|
+
size: read.data.length,
|
|
14590
15415
|
hash
|
|
14591
15416
|
};
|
|
14592
15417
|
} catch {
|
|
15418
|
+
if (existsSync(o.workspace.root)) identityIncomplete = true;
|
|
14593
15419
|
return {
|
|
14594
15420
|
path: relPath,
|
|
14595
|
-
size: 0
|
|
15421
|
+
size: 0,
|
|
15422
|
+
hash: "UNAVAILABLE"
|
|
14596
15423
|
};
|
|
14597
15424
|
}
|
|
14598
15425
|
});
|
|
14599
15426
|
let reportDigest;
|
|
14600
15427
|
if (o.runtimeReportPath) try {
|
|
14601
|
-
const
|
|
14602
|
-
|
|
14603
|
-
|
|
15428
|
+
const reportPath = resolve(o.workspace.root, o.runtimeReportPath);
|
|
15429
|
+
if (lstatSync(reportPath).isFile()) {
|
|
15430
|
+
const read = readFileBounded(reportPath, LIMITS$1.maxFileBytes);
|
|
15431
|
+
if (!read.ok) identityIncomplete = true;
|
|
15432
|
+
else reportDigest = createHash("sha256").update(read.data).digest("hex");
|
|
15433
|
+
}
|
|
15434
|
+
} catch {
|
|
15435
|
+
identityIncomplete = true;
|
|
15436
|
+
}
|
|
15437
|
+
const completion = deriveCompletion({
|
|
15438
|
+
discoveryTruncated: o.discoveryTruncated,
|
|
15439
|
+
rulesPartial: o.rulesPartial,
|
|
15440
|
+
skippedFiles: o.skippedFiles,
|
|
15441
|
+
rulesCrashed: o.rulesCrashed,
|
|
15442
|
+
truncationReasons: o.truncationReasons,
|
|
15443
|
+
scopeIgnored: o.scopeIgnored,
|
|
15444
|
+
scopeUnrecognized: o.scopeUnrecognized,
|
|
15445
|
+
parseFailed: o.parseFailed,
|
|
15446
|
+
parseFallbacks: o.parseFallbacks ?? 0,
|
|
15447
|
+
...o.scopeInfo.degraded !== void 0 ? { scopeDegraded: o.scopeInfo.degraded } : {},
|
|
15448
|
+
...o.runtimeIncomplete !== void 0 ? { runtimeIncomplete: o.runtimeIncomplete } : {},
|
|
15449
|
+
identityIncomplete
|
|
15450
|
+
});
|
|
15451
|
+
const repository = bindRepository(o.scanRoot.root);
|
|
15452
|
+
const candidate = readCandidateBinding(o.scanRoot.root);
|
|
14604
15453
|
const runIdentity = buildRunIdentity({
|
|
14605
15454
|
files: inputSnapshot,
|
|
14606
15455
|
rules: [...o.REVISION_BY_RULE_ID.entries()].map(([id, detectorRevision]) => ({
|
|
@@ -14613,18 +15462,32 @@ function assembleScanResult(o) {
|
|
|
14613
15462
|
trustModelVersion: TRUST_MODEL_VERSION,
|
|
14614
15463
|
scoringModelVersion: SCORING_MODEL_VERSION,
|
|
14615
15464
|
frameworkSupportMatrixVersion: FRAMEWORK_SUPPORT_MATRIX_VERSION,
|
|
14616
|
-
evidenceSchemaVersions: [1]
|
|
15465
|
+
evidenceSchemaVersions: [1],
|
|
15466
|
+
...repository,
|
|
15467
|
+
...candidate !== null ? { candidate } : {}
|
|
15468
|
+
});
|
|
15469
|
+
const evidenceGraph = buildEvidenceGraph({
|
|
15470
|
+
runId: runIdentity,
|
|
15471
|
+
...candidate !== null ? { candidate: {
|
|
15472
|
+
manifestId: candidate.manifestId,
|
|
15473
|
+
candidateSha: candidate.candidateSha
|
|
15474
|
+
} } : {}
|
|
14617
15475
|
});
|
|
14618
|
-
const evidenceGraph = buildEvidenceGraph({ runId: runIdentity });
|
|
14619
15476
|
const hasTests = o.testFileCount > 0 && o.testDeclarationCount > 0;
|
|
14620
15477
|
const suiteInvalidatedBy = [...new Set(o.findings.filter((f) => SUITE_INVALIDATING_RULE_IDS.has(f.ruleId)).map((f) => f.ruleId))].sort();
|
|
15478
|
+
const finalScore = hasTests ? completion.partial && scopeAdjustedTotal >= 100 ? 99 : scopeAdjustedTotal : null;
|
|
14621
15479
|
const result = {
|
|
14622
15480
|
schemaVersion: 1,
|
|
14623
|
-
partial:
|
|
15481
|
+
partial: completion.partial,
|
|
14624
15482
|
scopeIntegrity,
|
|
14625
15483
|
runIdentity,
|
|
14626
15484
|
evidenceGraph,
|
|
14627
|
-
|
|
15485
|
+
evidence: {
|
|
15486
|
+
records: o.evidenceRecords ?? [],
|
|
15487
|
+
counts: countEvidence(o.evidenceRecords ?? []),
|
|
15488
|
+
artifact: o.runtimeReportPath ?? null
|
|
15489
|
+
},
|
|
15490
|
+
score: finalScore,
|
|
14628
15491
|
...hasTests ? {} : { reason: "no-tests-found" },
|
|
14629
15492
|
frameworks: o.frameworks.frameworks,
|
|
14630
15493
|
frameworkDetectionUnknown: o.frameworks.unknown,
|
|
@@ -14650,12 +15513,8 @@ function assembleScanResult(o) {
|
|
|
14650
15513
|
file: o.cache.stats.file
|
|
14651
15514
|
} } : {},
|
|
14652
15515
|
analysisStatus: {
|
|
14653
|
-
|
|
14654
|
-
|
|
14655
|
-
skippedFiles: o.skippedFiles,
|
|
14656
|
-
durationMs: elapsed,
|
|
14657
|
-
rulesCrashed: o.rulesCrashed,
|
|
14658
|
-
...o.truncationReasons.size > 0 ? { truncationReasons: [...o.truncationReasons].sort() } : {}
|
|
15516
|
+
...completion.analysisStatus,
|
|
15517
|
+
durationMs: elapsed
|
|
14659
15518
|
},
|
|
14660
15519
|
scoringModelVersion: SCORING_MODEL_VERSION
|
|
14661
15520
|
};
|
|
@@ -14703,7 +15562,8 @@ function assembleScanResult(o) {
|
|
|
14703
15562
|
*/
|
|
14704
15563
|
async function runScan(args, hooks = {}) {
|
|
14705
15564
|
const started = Date.now();
|
|
14706
|
-
const
|
|
15565
|
+
const requestedDuration = Number.isFinite(args.maxDurationMs) ? args.maxDurationMs : DEFAULT_MAX_DURATION_MS$1;
|
|
15566
|
+
const deadline = started + Math.min(Math.max(1, requestedDuration), MAX_DURATION_MS$1);
|
|
14707
15567
|
const discovered = discoverWorkspace(args.target);
|
|
14708
15568
|
const targetAbs = resolve(args.target);
|
|
14709
15569
|
const scanRoot = discovered && discovered.root !== targetAbs && targetAbs.startsWith(discovered.root + sep) ? {
|
|
@@ -14792,7 +15652,9 @@ async function runScan(args, hooks = {}) {
|
|
|
14792
15652
|
testDeclarationCount = analysis.testDeclarationCount;
|
|
14793
15653
|
rulesPartial = analysis.rulesPartial;
|
|
14794
15654
|
parseFailed = analysis.parseFailed;
|
|
15655
|
+
const parseFallbacks = analysis.parseFallbacks;
|
|
14795
15656
|
const scanned = analysis.scanned;
|
|
15657
|
+
const analyzed = analysis.analyzed;
|
|
14796
15658
|
const postScan = applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, declarationsByFile, testDeclarationCount, tierByRuleId, REVISION_BY_RULE_ID);
|
|
14797
15659
|
testDeclarationCount = postScan.testDeclarationCount;
|
|
14798
15660
|
const result = assembleScanResult({
|
|
@@ -14808,7 +15670,9 @@ async function runScan(args, hooks = {}) {
|
|
|
14808
15670
|
scopeIgnored,
|
|
14809
15671
|
scopeUnrecognized,
|
|
14810
15672
|
parseFailed,
|
|
15673
|
+
parseFallbacks,
|
|
14811
15674
|
scanned,
|
|
15675
|
+
analyzed,
|
|
14812
15676
|
testFiles,
|
|
14813
15677
|
workspace,
|
|
14814
15678
|
scanRoot,
|
|
@@ -14821,6 +15685,7 @@ async function runScan(args, hooks = {}) {
|
|
|
14821
15685
|
suppressionCount: postScan.suppressionCount,
|
|
14822
15686
|
frameworks: postScan.frameworks,
|
|
14823
15687
|
runtimeReportPath: postScan.runtimeReportPath,
|
|
15688
|
+
runtimeIncomplete: postScan.runtimeIncomplete,
|
|
14824
15689
|
forensicVerdicts: postScan.forensicVerdicts,
|
|
14825
15690
|
config: postScan.config,
|
|
14826
15691
|
fileProvenance,
|
|
@@ -14852,6 +15717,10 @@ function digestView(f) {
|
|
|
14852
15717
|
trustLevel: f.trustLevel ?? null,
|
|
14853
15718
|
confidence: f.confidence,
|
|
14854
15719
|
findingType: f.findingType,
|
|
15720
|
+
qaImpact: f.qaImpact,
|
|
15721
|
+
findingId: f.findingId ?? null,
|
|
15722
|
+
rootCauseId: f.rootCauseId ?? null,
|
|
15723
|
+
deduplicationGroup: f.deduplicationGroup ?? null,
|
|
14855
15724
|
fixGroupId: f.fixGroupId ?? null
|
|
14856
15725
|
};
|
|
14857
15726
|
}
|
|
@@ -14872,8 +15741,12 @@ function canonicalScanJson(result) {
|
|
|
14872
15741
|
rules: result.analysisStatus.rules,
|
|
14873
15742
|
skippedFiles: result.analysisStatus.skippedFiles,
|
|
14874
15743
|
rulesCrashed: result.analysisStatus.rulesCrashed ?? 0,
|
|
14875
|
-
|
|
15744
|
+
parseFallbacks: result.analysisStatus.parseFallbacks ?? 0,
|
|
15745
|
+
truncationReasons: result.analysisStatus.truncationReasons ?? [],
|
|
15746
|
+
reasons: result.analysisStatus.reasons ?? []
|
|
14876
15747
|
},
|
|
15748
|
+
scopeIntegrity: result.scopeIntegrity ?? null,
|
|
15749
|
+
runIdentity: result.runIdentity ?? null,
|
|
14877
15750
|
findings: result.findings.map(digestView)
|
|
14878
15751
|
});
|
|
14879
15752
|
}
|
|
@@ -14930,9 +15803,13 @@ function buildMachineContract(result) {
|
|
|
14930
15803
|
rules: result.analysisStatus.rules,
|
|
14931
15804
|
skippedFiles: result.analysisStatus.skippedFiles,
|
|
14932
15805
|
rulesCrashed: result.analysisStatus.rulesCrashed ?? 0,
|
|
15806
|
+
...result.analysisStatus.parseFallbacks !== void 0 ? { parseFallbacks: result.analysisStatus.parseFallbacks } : {},
|
|
14933
15807
|
truncationReasons: result.analysisStatus.truncationReasons ?? [],
|
|
15808
|
+
...result.analysisStatus.reasons !== void 0 ? { reasons: result.analysisStatus.reasons } : {},
|
|
14934
15809
|
frameworkDetectionUnknown: result.frameworkDetectionUnknown,
|
|
14935
|
-
durationMs: result.analysisStatus.durationMs
|
|
15810
|
+
durationMs: result.analysisStatus.durationMs,
|
|
15811
|
+
...result.scopeIntegrity !== void 0 ? { scopeIntegrity: result.scopeIntegrity } : {},
|
|
15812
|
+
...result.runIdentity !== void 0 ? { runIdentity: result.runIdentity } : {}
|
|
14936
15813
|
},
|
|
14937
15814
|
...result.trustSummary !== void 0 ? { trustSummary: result.trustSummary } : {},
|
|
14938
15815
|
...result.agenticProfile !== void 0 ? { provenance: result.agenticProfile } : {},
|
|
@@ -15293,12 +16170,15 @@ function trustVerdictFor(v) {
|
|
|
15293
16170
|
* today the honest command set is repeat/compare/inspect, not
|
|
15294
16171
|
* "collect a trace", because trace.zip is not yet ingested.
|
|
15295
16172
|
*/
|
|
16173
|
+
function safeTriageText(value) {
|
|
16174
|
+
return value.replace(/[;&|`$()\\\r\n]/g, " ").slice(0, 200);
|
|
16175
|
+
}
|
|
15296
16176
|
function nextActionFor(v) {
|
|
15297
16177
|
switch (classifyVerdict(v)) {
|
|
15298
|
-
case "RETRY-DEPENDENT": return `repeat execution of this test (3+ runs) to confirm the flake rate, then quarantine + ticket: ${v.file}`;
|
|
15299
|
-
case "TIMEOUT": return `re-run in isolation to separate slowness from a hang:
|
|
15300
|
-
case "FAILING": return `reproduce
|
|
15301
|
-
case "SKIPPED": return `inspect the skip condition in ${v.file} — a skip after a failure usually hides an environment problem`;
|
|
16178
|
+
case "RETRY-DEPENDENT": return `repeat execution of this test (3+ runs) to confirm the flake rate, then quarantine + ticket: ${safeTriageText(v.file)}`;
|
|
16179
|
+
case "TIMEOUT": return `re-run this test in isolation with a 60-second timeout to separate slowness from a hang: ${safeTriageText(v.file)}`;
|
|
16180
|
+
case "FAILING": return `reproduce this test in isolation, then fix and re-run: ${safeTriageText(v.file)} — ${safeTriageText(v.title)}`;
|
|
16181
|
+
case "SKIPPED": return `inspect the skip condition in ${safeTriageText(v.file)} — a skip after a failure usually hides an environment problem`;
|
|
15302
16182
|
}
|
|
15303
16183
|
}
|
|
15304
16184
|
/** Deterministic guided rows, worst first (same order law as triageRows). */
|
|
@@ -15393,7 +16273,16 @@ function renderPwRunSummary(s) {
|
|
|
15393
16273
|
*/
|
|
15394
16274
|
/** Hard caps (§21 threat model: parameter size + resource bounds). */
|
|
15395
16275
|
const MAX_PARAM_BYTES = 65536;
|
|
16276
|
+
const MAX_DURATION_MS = 36e5;
|
|
16277
|
+
const DEFAULT_MAX_DURATION_MS = 6e5;
|
|
15396
16278
|
const PROTOCOL_VERSION = "2025-06-18";
|
|
16279
|
+
const CONFIGURED_ROOT = process.env.MJOLNIR_WORKSPACE_ROOT ? resolve(process.env.MJOLNIR_WORKSPACE_ROOT) : null;
|
|
16280
|
+
function isPathAllowed(path) {
|
|
16281
|
+
if (!CONFIGURED_ROOT) return true;
|
|
16282
|
+
const candidate = resolve(path);
|
|
16283
|
+
const rel = relative(CONFIGURED_ROOT, candidate);
|
|
16284
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
16285
|
+
}
|
|
15397
16286
|
/** JSON-RPC error codes (subset used here). */
|
|
15398
16287
|
const MCP_ERRORS = {
|
|
15399
16288
|
PARSE: -32700,
|
|
@@ -15512,7 +16401,10 @@ function validateParams(name, args) {
|
|
|
15512
16401
|
if (Buffer$1.byteLength(JSON.stringify(args), "utf8") > MAX_PARAM_BYTES) return `parameters exceed ${MAX_PARAM_BYTES} bytes (threat model §21)`;
|
|
15513
16402
|
if (name === "scan") {
|
|
15514
16403
|
if (typeof args["path"] !== "string" || args["path"].length === 0) return "scan requires a non-empty string `path`";
|
|
15515
|
-
if (args["maxDurationMs"] !== void 0
|
|
16404
|
+
if (args["maxDurationMs"] !== void 0) {
|
|
16405
|
+
const duration = args["maxDurationMs"];
|
|
16406
|
+
if (typeof duration !== "number" || !Number.isFinite(duration) || duration <= 0 || duration > MAX_DURATION_MS) return "`maxDurationMs` must be a finite positive number within the server budget";
|
|
16407
|
+
}
|
|
15516
16408
|
}
|
|
15517
16409
|
if (name === "explain") {
|
|
15518
16410
|
if (typeof args["ruleId"] !== "string" || !/^QA-[A-Z]+-\d{3}$/.test(args["ruleId"])) return "explain requires `ruleId` matching /^QA-[A-Z]+-\\d{3}$/";
|
|
@@ -15557,6 +16449,24 @@ async function handleToolCall(call) {
|
|
|
15557
16449
|
message: paramError
|
|
15558
16450
|
}
|
|
15559
16451
|
};
|
|
16452
|
+
const pathArgument = call.args["path"];
|
|
16453
|
+
if (typeof pathArgument === "string" && !isPathAllowed(pathArgument)) return {
|
|
16454
|
+
jsonrpc: "2.0",
|
|
16455
|
+
id: call.id,
|
|
16456
|
+
error: {
|
|
16457
|
+
code: MCP_ERRORS.INVALID_PARAMS,
|
|
16458
|
+
message: "path is outside the configured workspace root"
|
|
16459
|
+
}
|
|
16460
|
+
};
|
|
16461
|
+
const fixturesRoot = call.args["fixturesRoot"];
|
|
16462
|
+
if (typeof fixturesRoot === "string" && !isPathAllowed(fixturesRoot)) return {
|
|
16463
|
+
jsonrpc: "2.0",
|
|
16464
|
+
id: call.id,
|
|
16465
|
+
error: {
|
|
16466
|
+
code: MCP_ERRORS.INVALID_PARAMS,
|
|
16467
|
+
message: "fixturesRoot is outside the configured workspace root"
|
|
16468
|
+
}
|
|
16469
|
+
};
|
|
15560
16470
|
try {
|
|
15561
16471
|
if (call.name === "scan") {
|
|
15562
16472
|
const target = resolve(call.args["path"]);
|
|
@@ -15568,7 +16478,7 @@ async function handleToolCall(call) {
|
|
|
15568
16478
|
message: `scan target does not exist: ${target}`
|
|
15569
16479
|
}
|
|
15570
16480
|
};
|
|
15571
|
-
const maxDurationMs = typeof call.args["maxDurationMs"] === "number" ? call.args["maxDurationMs"] :
|
|
16481
|
+
const maxDurationMs = typeof call.args["maxDurationMs"] === "number" ? call.args["maxDurationMs"] : DEFAULT_MAX_DURATION_MS;
|
|
15572
16482
|
const result = await serializeScan(() => runScan({
|
|
15573
16483
|
target,
|
|
15574
16484
|
json: true,
|
|
@@ -15959,6 +16869,133 @@ async function handleStdioLine(line, write) {
|
|
|
15959
16869
|
write(`${JSON.stringify(response)}\n`);
|
|
15960
16870
|
}
|
|
15961
16871
|
//#endregion
|
|
16872
|
+
//#region src/integrations/sentry.ts
|
|
16873
|
+
/**
|
|
16874
|
+
* Sentry crash reporting — opt-in, and inert unless a DSN is configured.
|
|
16875
|
+
*
|
|
16876
|
+
* Why opt-in: Mjölnir ships as an `npx` CLI over other people's private
|
|
16877
|
+
* repositories. A tool that reads a codebase must never phone home on its
|
|
16878
|
+
* own, so the ONLY switch is an explicit `SENTRY_DSN` in the environment.
|
|
16879
|
+
* Absent that, every function here returns immediately and `@sentry/node` is
|
|
16880
|
+
* never imported — zero network, zero added startup cost on a path whose
|
|
16881
|
+
* whole selling point is scan speed.
|
|
16882
|
+
*
|
|
16883
|
+
* Why an optional peer dependency: `@sentry/node` is ~1.5 MB unpacked and
|
|
16884
|
+
* nothing in a code scanner needs it. Making it a hard `dependency` would
|
|
16885
|
+
* tax every install for a feature most users never turn on, so it is an
|
|
16886
|
+
* optional peer: installs on request (`npm i @sentry/node`), absent by
|
|
16887
|
+
* default, and a missing module is handled as "reporting unavailable"
|
|
16888
|
+
* rather than as a crash.
|
|
16889
|
+
*
|
|
16890
|
+
* Capture points are deliberately the two EXISTING top-level catch blocks
|
|
16891
|
+
* (cli.ts entry, mcp/stdio.ts) rather than `process.on("uncaughtException")`
|
|
16892
|
+
* / `("unhandledRejection")` handlers. Registering those would silently
|
|
16893
|
+
* change the CLI's frozen exit-code contract (§24.1) and could keep a
|
|
16894
|
+
* process alive after a fatal error. One capture point per surface is
|
|
16895
|
+
* enough to answer "did Mjölnir itself break, and where".
|
|
16896
|
+
*
|
|
16897
|
+
* What is deliberately NOT sent: no user data (`sendDefaultPii: false`), no
|
|
16898
|
+
* traced request/span data (`tracesSampleRate: 0` — a batch CLI has no
|
|
16899
|
+
* request to trace), no console breadcrumbs, and no scan findings or file
|
|
16900
|
+
* contents. Events carry the error, its stack, the release, and the surface
|
|
16901
|
+
* that raised it.
|
|
16902
|
+
*/
|
|
16903
|
+
/** The loaded SDK, or null while reporting is off/unavailable. */
|
|
16904
|
+
let sdk = null;
|
|
16905
|
+
/**
|
|
16906
|
+
* Flush budget for the process-exit paths. Long enough for one HTTPS
|
|
16907
|
+
* envelope on a slow link, short enough that a broken network cannot turn
|
|
16908
|
+
* a crashed CLI into a hung one. Sentry's own default is 2s.
|
|
16909
|
+
*/
|
|
16910
|
+
const FLUSH_TIMEOUT_MS = 2e3;
|
|
16911
|
+
/** Writes a diagnostic without touching stdout. */
|
|
16912
|
+
function warn(message) {
|
|
16913
|
+
process.stderr.write(`mjolnir: sentry — ${message}\n`);
|
|
16914
|
+
}
|
|
16915
|
+
/**
|
|
16916
|
+
* The release name Sentry groups events under. Matches the npm package
|
|
16917
|
+
* name so a release created from a tag and a release reported by the SDK
|
|
16918
|
+
* are the same release, not two half-populated ones.
|
|
16919
|
+
*/
|
|
16920
|
+
const SENTRY_RELEASE = `mjolnir-qa@${ENGINE_VERSION}`;
|
|
16921
|
+
/**
|
|
16922
|
+
* Resolve the environment tag. Unset means "a developer's machine" unless
|
|
16923
|
+
* CI says otherwise: a `npx` run is not a production event, and filing
|
|
16924
|
+
* those under `production` would poison the issue stream with noise from
|
|
16925
|
+
* end users triaging their own repos.
|
|
16926
|
+
*/
|
|
16927
|
+
function resolveEnvironment() {
|
|
16928
|
+
const fromEnv = process.env.SENTRY_ENVIRONMENT?.trim();
|
|
16929
|
+
if (fromEnv) return fromEnv;
|
|
16930
|
+
return process.env.CI ? "ci" : "local";
|
|
16931
|
+
}
|
|
16932
|
+
/**
|
|
16933
|
+
* Turn reporting on if — and only if — a DSN is configured. Safe to call
|
|
16934
|
+
* from every entry point: the second call is a no-op that returns the
|
|
16935
|
+
* already-decided answer, so a process that reaches two surfaces cannot
|
|
16936
|
+
* initialize the SDK twice.
|
|
16937
|
+
*
|
|
16938
|
+
* Returns true when reporting is live. Never throws: a monitoring
|
|
16939
|
+
* integration that can take down the tool it monitors has failed at its
|
|
16940
|
+
* one job.
|
|
16941
|
+
*/
|
|
16942
|
+
async function initSentry() {
|
|
16943
|
+
if (sdk) return true;
|
|
16944
|
+
const dsn = process.env.SENTRY_DSN?.trim();
|
|
16945
|
+
if (!dsn) return false;
|
|
16946
|
+
let loaded;
|
|
16947
|
+
try {
|
|
16948
|
+
loaded = await import("@sentry/node");
|
|
16949
|
+
} catch {
|
|
16950
|
+
warn("SENTRY_DSN is set but @sentry/node is not installed. Install it with: npm install @sentry/node");
|
|
16951
|
+
return false;
|
|
16952
|
+
}
|
|
16953
|
+
try {
|
|
16954
|
+
loaded.init({
|
|
16955
|
+
dsn,
|
|
16956
|
+
release: SENTRY_RELEASE,
|
|
16957
|
+
environment: resolveEnvironment(),
|
|
16958
|
+
tracesSampleRate: 0,
|
|
16959
|
+
sendDefaultPii: false,
|
|
16960
|
+
attachStacktrace: true
|
|
16961
|
+
});
|
|
16962
|
+
} catch (error) {
|
|
16963
|
+
warn(`SDK init failed (${error instanceof Error ? error.message : String(error)}). Crash reporting stays off.`);
|
|
16964
|
+
return false;
|
|
16965
|
+
}
|
|
16966
|
+
sdk = loaded;
|
|
16967
|
+
return true;
|
|
16968
|
+
}
|
|
16969
|
+
/**
|
|
16970
|
+
* Report one fatal error. `surface` names the entry point that raised it
|
|
16971
|
+
* ("cli" or "mcp") so the issue stream can be split by how Mjölnir was
|
|
16972
|
+
* being used when it broke. A non-Error throw is reported as-is rather
|
|
16973
|
+
* than dropped: a hostile throw value is exactly the case worth seeing.
|
|
16974
|
+
*/
|
|
16975
|
+
function captureInternalError(error, surface) {
|
|
16976
|
+
if (!sdk) return;
|
|
16977
|
+
try {
|
|
16978
|
+
sdk.captureException(error, { tags: { surface } });
|
|
16979
|
+
} catch (captureError) {
|
|
16980
|
+
warn(`capture failed (${captureError instanceof Error ? captureError.message : String(captureError)}).`);
|
|
16981
|
+
}
|
|
16982
|
+
}
|
|
16983
|
+
/**
|
|
16984
|
+
* Deliver anything buffered before the process exits. Awaits the SDK's
|
|
16985
|
+
* own transport drain and returns whether the envelope was handed off;
|
|
16986
|
+
* the caller is on an exit path, so the answer is deliberately not
|
|
16987
|
+
* surfaced as a failure — a lost crash report must not change the exit
|
|
16988
|
+
* code a caller (or CI) is about to read.
|
|
16989
|
+
*/
|
|
16990
|
+
async function flushSentry(timeoutMs = FLUSH_TIMEOUT_MS) {
|
|
16991
|
+
if (!sdk) return false;
|
|
16992
|
+
try {
|
|
16993
|
+
return await sdk.flush(timeoutMs);
|
|
16994
|
+
} catch {
|
|
16995
|
+
return false;
|
|
16996
|
+
}
|
|
16997
|
+
}
|
|
16998
|
+
//#endregion
|
|
15962
16999
|
//#region src/mcp/stdio.ts
|
|
15963
17000
|
/**
|
|
15964
17001
|
* The standalone MCP stdio binary (blueprint §21), built to
|
|
@@ -15977,9 +17014,14 @@ async function handleStdioLine(line, write) {
|
|
|
15977
17014
|
* subprocess coverage cannot merge into the parent istanbul report,
|
|
15978
17015
|
* same class as dist/**), so it is excluded from the coverage ratchet.
|
|
15979
17016
|
*/
|
|
15980
|
-
|
|
17017
|
+
initSentry();
|
|
17018
|
+
runStdioTransport(process.stdin, process.stdout).then(() => process.exit(0)).catch(async (err) => {
|
|
17019
|
+
captureInternalError(err, "mcp");
|
|
17020
|
+
await flushSentry();
|
|
15981
17021
|
process.stderr.write(`mjolnir mcp fatal: ${err instanceof Error ? err.message : err}\n`);
|
|
15982
17022
|
process.exitCode = 20;
|
|
15983
17023
|
});
|
|
15984
17024
|
//#endregion
|
|
15985
17025
|
export {};
|
|
17026
|
+
|
|
17027
|
+
//# sourceMappingURL=stdio.mjs.map
|