mjolnir-qa 2.0.2 → 3.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 +137 -0
- package/README.md +59 -47
- package/dist/cli.d.mts +49 -5
- package/dist/cli.mjs +4299 -542
- package/dist/mcp/stdio.mjs +440 -59
- package/dist/{scan-pipeline-D3Yk2cef.mjs → scan-pipeline-CAH9_Qgh.mjs} +448 -55
- package/dist/scan-pipeline-CRe7-MQn.mjs +2 -0
- package/package.json +20 -5
- package/dist/scan-pipeline-C0ka-RmX.mjs +0 -2
package/dist/mcp/stdio.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
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, writeFileSync } from "node:fs";
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
+
import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
6
6
|
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
7
|
import * as ts$2 from "ts-morph";
|
|
8
8
|
import ts, { Project, SyntaxKind, ts as ts$1 } from "ts-morph";
|
|
@@ -56,6 +56,38 @@ const DEDUCTIONS = {
|
|
|
56
56
|
info: 1
|
|
57
57
|
};
|
|
58
58
|
//#endregion
|
|
59
|
+
//#region src/engine/completion.ts
|
|
60
|
+
function deriveCompletion(input) {
|
|
61
|
+
const truncationReasons = [...new Set(input.truncationReasons)].sort();
|
|
62
|
+
const reasons = /* @__PURE__ */ new Set();
|
|
63
|
+
if (input.discoveryTruncated) reasons.add("discovery-truncated");
|
|
64
|
+
if (input.rulesPartial) reasons.add("rules-partial");
|
|
65
|
+
if (input.skippedFiles > 0) reasons.add(`skipped-files:${input.skippedFiles}`);
|
|
66
|
+
if (input.rulesCrashed > 0) reasons.add(`rules-crashed:${input.rulesCrashed}`);
|
|
67
|
+
if (input.scopeIgnored > 0) reasons.add(`scope-ignored:${input.scopeIgnored}`);
|
|
68
|
+
if (input.scopeUnrecognized > 0) reasons.add(`scope-unrecognized:${input.scopeUnrecognized}`);
|
|
69
|
+
if (input.parseFailed > 0) reasons.add(`parse-failed:${input.parseFailed}`);
|
|
70
|
+
if (input.parseFallbacks && input.parseFallbacks > 0) reasons.add(`parse-fallbacks:${input.parseFallbacks}`);
|
|
71
|
+
if (input.scopeDegraded) reasons.add(`scope-degraded:${input.scopeDegraded}`);
|
|
72
|
+
if (input.runtimeIncomplete) reasons.add("runtime-incomplete");
|
|
73
|
+
if (input.identityIncomplete) reasons.add("identity-incomplete");
|
|
74
|
+
for (const reason of truncationReasons) reasons.add(`truncated:${reason}`);
|
|
75
|
+
const discoveryPartial = input.discoveryTruncated || input.scopeIgnored > 0 || input.scopeUnrecognized > 0 || input.scopeDegraded !== void 0;
|
|
76
|
+
const rulesPartial = input.rulesPartial || input.rulesCrashed > 0 || input.parseFailed > 0;
|
|
77
|
+
return {
|
|
78
|
+
partial: discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0,
|
|
79
|
+
analysisStatus: {
|
|
80
|
+
discovery: discoveryPartial ? "partial" : "complete",
|
|
81
|
+
rules: rulesPartial ? "partial" : "complete",
|
|
82
|
+
skippedFiles: input.skippedFiles,
|
|
83
|
+
rulesCrashed: input.rulesCrashed,
|
|
84
|
+
parseFallbacks: input.parseFallbacks ?? 0,
|
|
85
|
+
...truncationReasons.length > 0 ? { truncationReasons } : {},
|
|
86
|
+
reasons: [...reasons].sort()
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
//#endregion
|
|
59
91
|
//#region src/engine/runtime-corroboration.ts
|
|
60
92
|
/**
|
|
61
93
|
* Stamp runtime corroboration + trust levels onto findings (mutates in
|
|
@@ -822,7 +854,10 @@ function sha256$1(text) {
|
|
|
822
854
|
return createHash("sha256").update(text).digest("hex");
|
|
823
855
|
}
|
|
824
856
|
function canonical(value) {
|
|
825
|
-
return JSON.stringify(value);
|
|
857
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
858
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
859
|
+
const record = value;
|
|
860
|
+
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`;
|
|
826
861
|
}
|
|
827
862
|
function buildRunIdentity(input) {
|
|
828
863
|
const inputFingerprint = sha256$1([...input.files].map((f) => f.hash ? `${f.path}:${f.hash}` : `${f.path}:${f.size}`).sort().join("\n"));
|
|
@@ -904,7 +939,7 @@ function buildEvidenceGraph(parts) {
|
|
|
904
939
|
* scripts/sync-sarif-version.cjs and guarded by the version-consistency
|
|
905
940
|
* spec. cli.ts re-exports this as CLI_VERSION.
|
|
906
941
|
*/
|
|
907
|
-
const ENGINE_VERSION = "
|
|
942
|
+
const ENGINE_VERSION = "3.0.0";
|
|
908
943
|
//#endregion
|
|
909
944
|
//#region src/engine/contract-versions.ts
|
|
910
945
|
/**
|
|
@@ -1065,7 +1100,9 @@ function basename$1(p) {
|
|
|
1065
1100
|
* Internal files (cache, hashes) use `isRecord`; user-facing files
|
|
1066
1101
|
* (config, plugins) use stricter checks that throw descriptive errors.
|
|
1067
1102
|
*/
|
|
1103
|
+
const MAX_JSON_BYTES = 16777216;
|
|
1068
1104
|
function parseJsonFile(text, source, validate) {
|
|
1105
|
+
if (Buffer.byteLength(text, "utf8") > MAX_JSON_BYTES) throw new Error(`JSON source exceeds the ${MAX_JSON_BYTES}-byte limit: ${source}`);
|
|
1069
1106
|
let parsed;
|
|
1070
1107
|
try {
|
|
1071
1108
|
parsed = JSON.parse(text);
|
|
@@ -1084,6 +1121,59 @@ function isRecord(v) {
|
|
|
1084
1121
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1085
1122
|
}
|
|
1086
1123
|
//#endregion
|
|
1124
|
+
//#region src/lib/fs-bounded.ts
|
|
1125
|
+
function closeQuiet(fd) {
|
|
1126
|
+
try {
|
|
1127
|
+
closeSync(fd);
|
|
1128
|
+
} catch {
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
function readFileBounded(path, maxBytes) {
|
|
1133
|
+
let fd;
|
|
1134
|
+
try {
|
|
1135
|
+
const pathStat = lstatSync(path);
|
|
1136
|
+
if (pathStat.isSymbolicLink()) return {
|
|
1137
|
+
ok: false,
|
|
1138
|
+
reason: "symlink"
|
|
1139
|
+
};
|
|
1140
|
+
if (!pathStat.isFile()) return {
|
|
1141
|
+
ok: false,
|
|
1142
|
+
reason: "unreadable"
|
|
1143
|
+
};
|
|
1144
|
+
fd = openSync(path, 0);
|
|
1145
|
+
const fdStat = fstatSync(fd);
|
|
1146
|
+
if (!fdStat.isFile() || fdStat.size > maxBytes) return {
|
|
1147
|
+
ok: false,
|
|
1148
|
+
reason: fdStat.size > maxBytes ? "too-large" : "unreadable"
|
|
1149
|
+
};
|
|
1150
|
+
const chunks = [];
|
|
1151
|
+
let total = 0;
|
|
1152
|
+
const buffer = Buffer.allocUnsafe(Math.min(65536, maxBytes));
|
|
1153
|
+
while (true) {
|
|
1154
|
+
const read = readSync(fd, buffer, 0, buffer.length, null);
|
|
1155
|
+
if (read === 0) break;
|
|
1156
|
+
total += read;
|
|
1157
|
+
if (total > maxBytes) return {
|
|
1158
|
+
ok: false,
|
|
1159
|
+
reason: "too-large"
|
|
1160
|
+
};
|
|
1161
|
+
chunks.push(Buffer.from(buffer.subarray(0, read)));
|
|
1162
|
+
}
|
|
1163
|
+
return {
|
|
1164
|
+
ok: true,
|
|
1165
|
+
data: Buffer.concat(chunks)
|
|
1166
|
+
};
|
|
1167
|
+
} catch {
|
|
1168
|
+
return {
|
|
1169
|
+
ok: false,
|
|
1170
|
+
reason: "unreadable"
|
|
1171
|
+
};
|
|
1172
|
+
} finally {
|
|
1173
|
+
if (fd !== void 0) closeQuiet(fd);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
//#endregion
|
|
1087
1177
|
//#region src/config/config-schema.ts
|
|
1088
1178
|
const VALID_GATES = /* @__PURE__ */ new Set([
|
|
1089
1179
|
"advisory",
|
|
@@ -1182,7 +1272,11 @@ const CONFIG_NAMES = ["mjolnir.config.json", ".mjolnir.json"];
|
|
|
1182
1272
|
function findConfigPath(root) {
|
|
1183
1273
|
for (const name of CONFIG_NAMES) {
|
|
1184
1274
|
const p = join(root, name);
|
|
1185
|
-
|
|
1275
|
+
try {
|
|
1276
|
+
if (existsSync(p) && !lstatSync(p).isSymbolicLink()) return p;
|
|
1277
|
+
} catch {
|
|
1278
|
+
continue;
|
|
1279
|
+
}
|
|
1186
1280
|
}
|
|
1187
1281
|
return null;
|
|
1188
1282
|
}
|
|
@@ -1202,7 +1296,10 @@ function loadConfig(root, options = {}) {
|
|
|
1202
1296
|
const p = join(root, name);
|
|
1203
1297
|
if (!existsSync(p)) continue;
|
|
1204
1298
|
try {
|
|
1205
|
-
|
|
1299
|
+
if (!existsSync(p) || lstatSync(p).isSymbolicLink()) continue;
|
|
1300
|
+
const read = readFileBounded(p, 1048576);
|
|
1301
|
+
if (!read.ok) throw new Error("config source could not be read safely");
|
|
1302
|
+
const parsed = parseJsonFile(read.data.toString("utf8"), p, (v) => isRecord(v));
|
|
1206
1303
|
return {
|
|
1207
1304
|
config: parsed,
|
|
1208
1305
|
path: p,
|
|
@@ -1496,8 +1593,9 @@ function sharedWalk(options) {
|
|
|
1496
1593
|
}
|
|
1497
1594
|
const full = join(dir, entry.name);
|
|
1498
1595
|
const rel = relative(options.root, full).replaceAll("\\", "/");
|
|
1596
|
+
if (entry.isDirectory() && options.skipDirs.includes(entry.name)) continue;
|
|
1499
1597
|
if (options.ignoreMatcher.isIgnored(rel)) {
|
|
1500
|
-
options.onIgnored?.();
|
|
1598
|
+
if (!rel.startsWith(".mjolnir/")) options.onIgnored?.(rel);
|
|
1501
1599
|
continue;
|
|
1502
1600
|
}
|
|
1503
1601
|
if (entry.isSymbolicLink()) {
|
|
@@ -1505,7 +1603,6 @@ function sharedWalk(options) {
|
|
|
1505
1603
|
continue;
|
|
1506
1604
|
}
|
|
1507
1605
|
if (entry.isDirectory()) {
|
|
1508
|
-
if (options.skipDirs.includes(entry.name)) continue;
|
|
1509
1606
|
if (rel.split("/").length > LIMITS$1.maxDepth) {
|
|
1510
1607
|
options.onSkipped("max-depth");
|
|
1511
1608
|
continue;
|
|
@@ -1523,7 +1620,7 @@ function sharedWalk(options) {
|
|
|
1523
1620
|
} catch {
|
|
1524
1621
|
options.onSkipped("stat-failed");
|
|
1525
1622
|
}
|
|
1526
|
-
else if (entry.isFile()) options.onUnrecognized?.();
|
|
1623
|
+
else if (entry.isFile()) options.onUnrecognized?.(rel);
|
|
1527
1624
|
}
|
|
1528
1625
|
};
|
|
1529
1626
|
walk(options.root);
|
|
@@ -3625,7 +3722,7 @@ const SCAN_ADAPTERS = [
|
|
|
3625
3722
|
* the adapter that claims it; per-adapter caps still apply.
|
|
3626
3723
|
*/
|
|
3627
3724
|
function discoverAllTestFiles(ctx, languageAdapters, buckets, fixtureDirMemo) {
|
|
3628
|
-
const walkSkips = languageAdapters.reduce((common, a) => common.filter((name) => a.dirSkips.includes(name)), languageAdapters[0]?.dirSkips ?? []);
|
|
3725
|
+
const walkSkips = [.../* @__PURE__ */ new Set([...languageAdapters.reduce((common, a) => common.filter((name) => a.dirSkips.includes(name)), languageAdapters[0]?.dirSkips ?? []), ".mjolnir"])];
|
|
3629
3726
|
sharedWalk({
|
|
3630
3727
|
root: ctx.workspace.root,
|
|
3631
3728
|
deadline: ctx.deadline,
|
|
@@ -3651,10 +3748,35 @@ function discoverAllTestFiles(ctx, languageAdapters, buckets, fixtureDirMemo) {
|
|
|
3651
3748
|
},
|
|
3652
3749
|
isFull: () => languageAdapters.every((a) => (buckets.get(a.id)?.length ?? 0) >= ctx.maxFiles),
|
|
3653
3750
|
fixtureDirMemo,
|
|
3654
|
-
onIgnored:
|
|
3655
|
-
|
|
3751
|
+
onIgnored: (path) => {
|
|
3752
|
+
if (isScopeRelevantIgnored(path)) ctx.onIgnored?.(path);
|
|
3753
|
+
},
|
|
3754
|
+
onUnrecognized: (path) => {
|
|
3755
|
+
if (typeof path === "string" && isUnrecognizedSourceCandidate(path)) ctx.onUnrecognized?.(path);
|
|
3756
|
+
}
|
|
3656
3757
|
});
|
|
3657
3758
|
}
|
|
3759
|
+
function isScopeRelevantIgnored(path) {
|
|
3760
|
+
const name = path.replaceAll("\\", "/").split("/").pop() ?? path;
|
|
3761
|
+
return /\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml|min\.js)$/i.test(name);
|
|
3762
|
+
}
|
|
3763
|
+
function isUnrecognizedSourceCandidate(path) {
|
|
3764
|
+
const normalized = path.replaceAll("\\", "/");
|
|
3765
|
+
const name = normalized.split("/").pop() ?? normalized;
|
|
3766
|
+
if (normalized.includes(".github/workflows/") || name === "azure-pipelines.yml" || name === "Jenkinsfile") return false;
|
|
3767
|
+
const extensionIndex = name.lastIndexOf(".");
|
|
3768
|
+
const stem = extensionIndex > 0 ? name.slice(0, extensionIndex) : name;
|
|
3769
|
+
if (name === "mjolnir.config.json" || name === ".mjolnir.json" || name === "package.json" || name === "package-lock.json" || name === "pnpm-lock.yaml" || name === "yarn.lock" || [
|
|
3770
|
+
"playwright",
|
|
3771
|
+
"vitest",
|
|
3772
|
+
"vite",
|
|
3773
|
+
"eslint",
|
|
3774
|
+
"tsconfig",
|
|
3775
|
+
"jsconfig"
|
|
3776
|
+
].some((prefix) => stem === prefix || stem.startsWith(`${prefix}.`))) return false;
|
|
3777
|
+
const testLike = /(?:^|\/)__tests__\//i.test(normalized) || /\.(?:spec|test)\.[cm]?[jt]sx?$/i.test(name);
|
|
3778
|
+
return /\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml)$/i.test(name) && testLike;
|
|
3779
|
+
}
|
|
3658
3780
|
/** Whether ANY shipped adapter would discover this path as a test file. */
|
|
3659
3781
|
function isKnownTestFile(path) {
|
|
3660
3782
|
return SCAN_ADAPTERS.some((a) => a.isTestFile(path));
|
|
@@ -3681,17 +3803,14 @@ typescriptAdapter.testFileGlobs, pythonAdapter.testFileGlobs, javaAdapter.testFi
|
|
|
3681
3803
|
* The fix: resolve git ONCE per process to an ABSOLUTE path by walking
|
|
3682
3804
|
* the PATH directly (never consulting the CWD), verify the candidate is
|
|
3683
3805
|
* 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.
|
|
3806
|
+
* memoized; a failure to find a real git anywhere on PATH is recorded
|
|
3807
|
+
* and callers degrade without invoking a bare name.
|
|
3688
3808
|
*/
|
|
3689
3809
|
let resolvedGit = void 0;
|
|
3690
3810
|
/**
|
|
3691
3811
|
* 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).
|
|
3812
|
+
* PATH carries no executable `git` at all. Callers must degrade without
|
|
3813
|
+
* invoking a bare name.
|
|
3695
3814
|
*/
|
|
3696
3815
|
function resolveGitPath() {
|
|
3697
3816
|
if (resolvedGit !== void 0) return resolvedGit;
|
|
@@ -3724,7 +3843,8 @@ function resolveGitPath() {
|
|
|
3724
3843
|
* the previous inline `git()` helpers, with the hijack surface closed.
|
|
3725
3844
|
*/
|
|
3726
3845
|
function runGit(root, args) {
|
|
3727
|
-
const exe = resolveGitPath()
|
|
3846
|
+
const exe = resolveGitPath();
|
|
3847
|
+
if (!exe) return null;
|
|
3728
3848
|
try {
|
|
3729
3849
|
return execFileSync(exe, [
|
|
3730
3850
|
"-C",
|
|
@@ -3860,7 +3980,7 @@ function computeChangedScope(root, baseBranch) {
|
|
|
3860
3980
|
"--",
|
|
3861
3981
|
...chunk
|
|
3862
3982
|
]);
|
|
3863
|
-
if (output === null)
|
|
3983
|
+
if (output === null) return null;
|
|
3864
3984
|
const sections = output.split("\ndiff --git ");
|
|
3865
3985
|
for (const section of sections) {
|
|
3866
3986
|
const body = section.startsWith("diff --git ") ? section : `diff --git ${section}`;
|
|
@@ -3888,6 +4008,11 @@ function computeChangedScope(root, baseBranch) {
|
|
|
3888
4008
|
"--unified=0",
|
|
3889
4009
|
"HEAD"
|
|
3890
4010
|
], changedFiles);
|
|
4011
|
+
if (committedDiffs === null || workingDiffs === null) return {
|
|
4012
|
+
changed: {},
|
|
4013
|
+
degraded: true,
|
|
4014
|
+
reason: "diff-failed"
|
|
4015
|
+
};
|
|
3891
4016
|
for (const file of changedFiles) {
|
|
3892
4017
|
const committedDiff = committedDiffs.get(file);
|
|
3893
4018
|
const workingDiff = workingDiffs.get(file);
|
|
@@ -3913,8 +4038,11 @@ const LIMITS_MAX_LINES = 1e6;
|
|
|
3913
4038
|
function allLinesOf(root, file) {
|
|
3914
4039
|
const full = join(root, file);
|
|
3915
4040
|
try {
|
|
3916
|
-
|
|
3917
|
-
|
|
4041
|
+
const stat = lstatSync(full);
|
|
4042
|
+
if (stat.isSymbolicLink() || !stat.isFile()) return null;
|
|
4043
|
+
const read = readFileBounded(full, LIMITS$1.maxFileBytes);
|
|
4044
|
+
if (!read.ok) return null;
|
|
4045
|
+
const lineCount = read.data.toString("utf8").split("\n").length;
|
|
3918
4046
|
return Array.from({ length: lineCount }, (_, i) => i + 1);
|
|
3919
4047
|
} catch {
|
|
3920
4048
|
return null;
|
|
@@ -11967,6 +12095,85 @@ function configurableAggregation(results, config) {
|
|
|
11967
12095
|
};
|
|
11968
12096
|
}
|
|
11969
12097
|
//#endregion
|
|
12098
|
+
//#region src/lib/fs-atomic.ts
|
|
12099
|
+
/**
|
|
12100
|
+
* Atomic file writes (audit S9).
|
|
12101
|
+
*
|
|
12102
|
+
* Every durability-critical write in Mjölnir (baseline, stats, badge,
|
|
12103
|
+
* TRIAGE.md, scaffolded rule files) used to hand-roll
|
|
12104
|
+
* `writeFileSync(path, data)` — a crash mid-write left a TRUNCATED file
|
|
12105
|
+
* at the real path, and a subsequent read (diff, badge endpoint) served
|
|
12106
|
+
* confident nonsense from it.
|
|
12107
|
+
*
|
|
12108
|
+
* `writeFileAtomic` writes to a temp sibling, then RENAMES. On the same
|
|
12109
|
+
* volume rename is atomic: readers see either the complete old file or
|
|
12110
|
+
* the complete new file, never a half-written one. The temp name is
|
|
12111
|
+
* created with `wx` (exclusive) so concurrent writers cannot interleave,
|
|
12112
|
+
* stale temps are cleaned up on failure, and on Windows the rename is
|
|
12113
|
+
* retried briefly because a concurrent reader can hold the destination
|
|
12114
|
+
* open (EBUSY/EPERM).
|
|
12115
|
+
*/
|
|
12116
|
+
function atomicTempPath(path) {
|
|
12117
|
+
return `${path}.mjolnir-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}.tmp`;
|
|
12118
|
+
}
|
|
12119
|
+
/**
|
|
12120
|
+
* Atomically replace `path` with `data`.
|
|
12121
|
+
*/
|
|
12122
|
+
function writeFileAtomic(path, data, opts = {}) {
|
|
12123
|
+
const dir = dirname(path);
|
|
12124
|
+
if (opts.mkdirs !== false && !existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
12125
|
+
const tmp = atomicTempPath(path);
|
|
12126
|
+
let fd;
|
|
12127
|
+
try {
|
|
12128
|
+
fd = openSync(tmp, "wx", opts.mode ?? 420);
|
|
12129
|
+
writeSync(fd, data, null, opts.encoding ?? "utf8");
|
|
12130
|
+
} finally {
|
|
12131
|
+
if (fd !== void 0) closeSync(fd);
|
|
12132
|
+
}
|
|
12133
|
+
try {
|
|
12134
|
+
renameWithWindowsRetry(tmp, path);
|
|
12135
|
+
} catch (err) {
|
|
12136
|
+
try {
|
|
12137
|
+
if (existsSync(tmp)) unlinkSync(tmp);
|
|
12138
|
+
} catch {}
|
|
12139
|
+
throw err;
|
|
12140
|
+
}
|
|
12141
|
+
}
|
|
12142
|
+
/**
|
|
12143
|
+
* renameSync retry loop for Windows: a concurrent reader (another scan,
|
|
12144
|
+
* a badge endpoint, an editor) holding the destination open makes
|
|
12145
|
+
* rename fail with EBUSY/EPERM. A short bounded retry closes the race
|
|
12146
|
+
* without turning an atomic swap into a partial write.
|
|
12147
|
+
*
|
|
12148
|
+
* Internal contract test hook: the platform check keeps this loop off
|
|
12149
|
+
* the POSIX hot path; on win32 the EBUSY/EPERM arms are exercised by
|
|
12150
|
+
* the fs-atomic-retry spec (mocked renameSync).
|
|
12151
|
+
*/
|
|
12152
|
+
const RENAME_RETRIES = 8;
|
|
12153
|
+
const RENAME_RETRY_DELAY_MS = 25;
|
|
12154
|
+
/** Synchronous sleep that does not spin the CPU. */
|
|
12155
|
+
function sleepSync(ms) {
|
|
12156
|
+
try {
|
|
12157
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
12158
|
+
} catch {
|
|
12159
|
+
const until = Date.now() + ms;
|
|
12160
|
+
while (Date.now() < until);
|
|
12161
|
+
}
|
|
12162
|
+
}
|
|
12163
|
+
function renameWithWindowsRetry(from, to) {
|
|
12164
|
+
for (let attempt = 0;; attempt++) try {
|
|
12165
|
+
renameSync(from, to);
|
|
12166
|
+
return;
|
|
12167
|
+
} catch (err) {
|
|
12168
|
+
const code = err?.code;
|
|
12169
|
+
if (process.platform === "win32" && (code === "EBUSY" || code === "EPERM") && attempt < RENAME_RETRIES) {
|
|
12170
|
+
sleepSync(RENAME_RETRY_DELAY_MS);
|
|
12171
|
+
continue;
|
|
12172
|
+
}
|
|
12173
|
+
throw err;
|
|
12174
|
+
}
|
|
12175
|
+
}
|
|
12176
|
+
//#endregion
|
|
11970
12177
|
//#region src/engine/scan-cache.ts
|
|
11971
12178
|
/**
|
|
11972
12179
|
* Local incremental scan cache (Beta-to-Stable 1.0 plan, M5.2 / A-2).
|
|
@@ -12002,6 +12209,17 @@ const MAX_ENTRIES$1 = 4096;
|
|
|
12002
12209
|
* serialized size; the newest entries win (real LRU-by-use).
|
|
12003
12210
|
*/
|
|
12004
12211
|
const MAX_TOTAL_BYTES = 33554432;
|
|
12212
|
+
const MAX_FINDINGS_PER_ENTRY = 1e4;
|
|
12213
|
+
function isCachedFinding(value) {
|
|
12214
|
+
if (!isRecord(value)) return false;
|
|
12215
|
+
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";
|
|
12216
|
+
}
|
|
12217
|
+
function isCacheFile(value) {
|
|
12218
|
+
if (!isRecord(value) || value["version"] !== CACHE_VERSION) return false;
|
|
12219
|
+
const entries = value["entries"];
|
|
12220
|
+
if (!isRecord(entries) || Object.keys(entries).length > MAX_ENTRIES$1) return false;
|
|
12221
|
+
return Object.values(entries).every((entry) => isRecord(entry) && Array.isArray(entry["findings"]) && entry["findings"].length <= MAX_FINDINGS_PER_ENTRY && entry["findings"].every(isCachedFinding));
|
|
12222
|
+
}
|
|
12005
12223
|
/** sha256 hex of a string — the only hash this module needs. */
|
|
12006
12224
|
function sha256(text) {
|
|
12007
12225
|
return createHash("sha256").update(text).digest("hex");
|
|
@@ -12100,12 +12318,15 @@ function createScanCache(root) {
|
|
|
12100
12318
|
let totalBytes = 0;
|
|
12101
12319
|
try {
|
|
12102
12320
|
if (existsSync(file)) {
|
|
12103
|
-
|
|
12321
|
+
const stat = lstatSync(file);
|
|
12322
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_TOTAL_BYTES) throw new Error("invalid cache file");
|
|
12323
|
+
entries = parseJsonFile(readFileSync(file, "utf8"), file, isCacheFile).entries;
|
|
12104
12324
|
for (const [k, v] of Object.entries(entries)) {
|
|
12105
|
-
const size = JSON.stringify(v)
|
|
12325
|
+
const size = Buffer.byteLength(JSON.stringify(v), "utf8") + k.length + 4;
|
|
12106
12326
|
entryBytes.set(k, size);
|
|
12107
12327
|
totalBytes += size;
|
|
12108
12328
|
}
|
|
12329
|
+
if (totalBytes > MAX_TOTAL_BYTES) throw new Error("cache byte budget exceeded");
|
|
12109
12330
|
}
|
|
12110
12331
|
} catch {
|
|
12111
12332
|
entries = {};
|
|
@@ -12131,11 +12352,13 @@ function createScanCache(root) {
|
|
|
12131
12352
|
},
|
|
12132
12353
|
store(key, findings, fileBudgetExceeded) {
|
|
12133
12354
|
if (fileBudgetExceeded) return;
|
|
12355
|
+
if (findings.length > MAX_FINDINGS_PER_ENTRY) return;
|
|
12356
|
+
const entryJson = JSON.stringify(findings);
|
|
12357
|
+
const newBytes = Buffer.byteLength(entryJson, "utf8") + key.length + 4;
|
|
12358
|
+
if (newBytes > MAX_TOTAL_BYTES) return;
|
|
12134
12359
|
const replacedBytes = entryBytes.get(key) ?? 0;
|
|
12135
12360
|
delete entries[key];
|
|
12136
|
-
const entryJson = JSON.stringify(findings);
|
|
12137
12361
|
entries[key] = { findings: structuredClone(findings) };
|
|
12138
|
-
const newBytes = entryJson.length + key.length + 4;
|
|
12139
12362
|
entryBytes.set(key, newBytes);
|
|
12140
12363
|
totalBytes = totalBytes - replacedBytes + newBytes;
|
|
12141
12364
|
let count = Object.keys(entries).length;
|
|
@@ -12152,10 +12375,10 @@ function createScanCache(root) {
|
|
|
12152
12375
|
if (!dirty) return;
|
|
12153
12376
|
try {
|
|
12154
12377
|
mkdirSync(dir, { recursive: true });
|
|
12155
|
-
|
|
12378
|
+
writeFileAtomic(file, JSON.stringify({
|
|
12156
12379
|
version: CACHE_VERSION,
|
|
12157
12380
|
entries
|
|
12158
|
-
}), "utf8");
|
|
12381
|
+
}), { encoding: "utf8" });
|
|
12159
12382
|
} catch {}
|
|
12160
12383
|
}
|
|
12161
12384
|
};
|
|
@@ -13798,6 +14021,31 @@ function loadPlugins(root, gateOpen = false) {
|
|
|
13798
14021
|
* next catalog render (locked by tests/local-rules.spec.ts).
|
|
13799
14022
|
*/
|
|
13800
14023
|
const LOCAL_RULES_DIR = "mjolnir-rules";
|
|
14024
|
+
const MAX_EXTERNAL_RULES = 1e3;
|
|
14025
|
+
const MAX_RULE_FILE_BYTES = 1048576;
|
|
14026
|
+
const MAX_PATTERNS_PER_RULE = 100;
|
|
14027
|
+
function hasNestedQuantifier(pattern) {
|
|
14028
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
14029
|
+
if (pattern.charAt(i) !== "(") continue;
|
|
14030
|
+
let bodyHasQuantifier = false;
|
|
14031
|
+
let j = i + 1;
|
|
14032
|
+
for (; j < pattern.length && pattern.charAt(j) !== ")"; j++) {
|
|
14033
|
+
const bodyChar = pattern.charAt(j);
|
|
14034
|
+
if (bodyChar === "+" || bodyChar === "*" || bodyChar === "?") {
|
|
14035
|
+
bodyHasQuantifier = true;
|
|
14036
|
+
break;
|
|
14037
|
+
}
|
|
14038
|
+
}
|
|
14039
|
+
if (bodyHasQuantifier) {
|
|
14040
|
+
while (j < pattern.length && pattern.charAt(j) !== ")") j++;
|
|
14041
|
+
if (j + 1 < pattern.length) {
|
|
14042
|
+
const next = pattern.charAt(j + 1);
|
|
14043
|
+
if (next === "+" || next === "*" || next === "{") return true;
|
|
14044
|
+
}
|
|
14045
|
+
}
|
|
14046
|
+
}
|
|
14047
|
+
return false;
|
|
14048
|
+
}
|
|
13801
14049
|
const ALLOWED_CATEGORIES = /* @__PURE__ */ new Set([
|
|
13802
14050
|
"QA-TEST",
|
|
13803
14051
|
"QA-TQUAL",
|
|
@@ -13827,7 +14075,7 @@ const ALLOWED_QA_IMPACTS = /* @__PURE__ */ new Set([
|
|
|
13827
14075
|
* Missing directory → empty result (not an error — most workspaces
|
|
13828
14076
|
* carry none).
|
|
13829
14077
|
*/
|
|
13830
|
-
async function loadLocalRules(root, gateOpen =
|
|
14078
|
+
async function loadLocalRules(root, gateOpen = false) {
|
|
13831
14079
|
const result = {
|
|
13832
14080
|
rules: [],
|
|
13833
14081
|
errors: [],
|
|
@@ -13835,6 +14083,15 @@ async function loadLocalRules(root, gateOpen = true) {
|
|
|
13835
14083
|
};
|
|
13836
14084
|
const dir = join(root, LOCAL_RULES_DIR);
|
|
13837
14085
|
if (!existsSync(dir)) return result;
|
|
14086
|
+
try {
|
|
14087
|
+
if (lstatSync(dir).isSymbolicLink()) {
|
|
14088
|
+
result.errors.push(`external rules directory "${LOCAL_RULES_DIR}/" is a symlink — skipped`);
|
|
14089
|
+
return result;
|
|
14090
|
+
}
|
|
14091
|
+
} catch (err) {
|
|
14092
|
+
result.errors.push(`external rules directory "${LOCAL_RULES_DIR}/" could not be inspected: ${err instanceof Error ? err.message : String(err)}`);
|
|
14093
|
+
return result;
|
|
14094
|
+
}
|
|
13838
14095
|
let entries;
|
|
13839
14096
|
try {
|
|
13840
14097
|
entries = readdirSync(dir);
|
|
@@ -13843,7 +14100,20 @@ async function loadLocalRules(root, gateOpen = true) {
|
|
|
13843
14100
|
return result;
|
|
13844
14101
|
}
|
|
13845
14102
|
for (const entry of entries.sort()) {
|
|
14103
|
+
if (result.rules.length >= MAX_EXTERNAL_RULES) {
|
|
14104
|
+
result.errors.push(`external rule budget exceeded (${MAX_EXTERNAL_RULES})`);
|
|
14105
|
+
break;
|
|
14106
|
+
}
|
|
13846
14107
|
const path = join(dir, entry);
|
|
14108
|
+
try {
|
|
14109
|
+
if (lstatSync(path).isSymbolicLink()) {
|
|
14110
|
+
result.errors.push(`external rule "${LOCAL_RULES_DIR}/${entry}" is a symlink — skipped`);
|
|
14111
|
+
continue;
|
|
14112
|
+
}
|
|
14113
|
+
} catch (err) {
|
|
14114
|
+
result.errors.push(`external rule "${LOCAL_RULES_DIR}/${entry}" could not be inspected: ${err instanceof Error ? err.message : String(err)}`);
|
|
14115
|
+
continue;
|
|
14116
|
+
}
|
|
13847
14117
|
if (entry.endsWith(".json")) loadJsonRule(path, result);
|
|
13848
14118
|
else if (entry.endsWith(".mjs") || entry.endsWith(".js")) {
|
|
13849
14119
|
if (!gateOpen) result.skipped.push(`${LOCAL_RULES_DIR}/${entry}`);
|
|
@@ -13856,6 +14126,10 @@ function loadJsonRule(path, result) {
|
|
|
13856
14126
|
const name = `${LOCAL_RULES_DIR}/${path.split(/[\\/]/).pop()}`;
|
|
13857
14127
|
let raw;
|
|
13858
14128
|
try {
|
|
14129
|
+
if (lstatSync(path).size > MAX_RULE_FILE_BYTES) {
|
|
14130
|
+
result.errors.push(`external rule "${name}" exceeds the file size budget`);
|
|
14131
|
+
return;
|
|
14132
|
+
}
|
|
13859
14133
|
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
13860
14134
|
} catch (err) {
|
|
13861
14135
|
result.errors.push(`external rule "${name}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -13876,7 +14150,7 @@ function loadJsonRule(path, result) {
|
|
|
13876
14150
|
return;
|
|
13877
14151
|
}
|
|
13878
14152
|
const patterns = decl["patterns"];
|
|
13879
|
-
if (!Array.isArray(patterns) || patterns.length === 0 || !patterns.every((p) => typeof p === "string" && p.length > 0)) {
|
|
14153
|
+
if (!Array.isArray(patterns) || patterns.length === 0 || patterns.length > MAX_PATTERNS_PER_RULE || !patterns.every((p) => typeof p === "string" && p.length > 0)) {
|
|
13880
14154
|
result.errors.push(`external rule ${id} must declare a non-empty "patterns" array of regex source strings.`);
|
|
13881
14155
|
return;
|
|
13882
14156
|
}
|
|
@@ -13892,6 +14166,10 @@ function loadJsonRule(path, result) {
|
|
|
13892
14166
|
result.errors.push(`external rule ${id} has a pattern longer than 512 chars — likely a copy-paste error.`);
|
|
13893
14167
|
return;
|
|
13894
14168
|
}
|
|
14169
|
+
if (hasNestedQuantifier(p)) {
|
|
14170
|
+
result.errors.push(`external rule ${id} has a nested quantifier that may cause catastrophic backtracking`);
|
|
14171
|
+
return;
|
|
14172
|
+
}
|
|
13895
14173
|
const quantifiers = p.match(/[+*?]|\{\d/g);
|
|
13896
14174
|
if (quantifiers && quantifiers.length > 64) {
|
|
13897
14175
|
result.errors.push(`external rule ${id} has a pattern with more than 64 quantifier/wildcard tokens — likely catastrophic backtracking.`);
|
|
@@ -14071,6 +14349,8 @@ function renderGateNotice(skipped) {
|
|
|
14071
14349
|
* module moved. Re-exports in cli.ts keep the historical import surface.
|
|
14072
14350
|
*/
|
|
14073
14351
|
const UNIVERSAL_RULES = RULES.map(asUniversal);
|
|
14352
|
+
const DEFAULT_MAX_DURATION_MS$1 = 6e5;
|
|
14353
|
+
const MAX_DURATION_MS$1 = 36e5;
|
|
14074
14354
|
/** Registered rule IDs — used to warn on unknown severityOverrides keys (M4). */
|
|
14075
14355
|
const KNOWN_RULE_IDS = new Set(RULES.map((r) => r.id));
|
|
14076
14356
|
/**
|
|
@@ -14287,7 +14567,9 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14287
14567
|
let testDeclarationCount = 0;
|
|
14288
14568
|
let rulesPartial = false;
|
|
14289
14569
|
let parseFailed = 0;
|
|
14570
|
+
let parseFallbacks = 0;
|
|
14290
14571
|
let scanned = 0;
|
|
14572
|
+
let analyzed = 0;
|
|
14291
14573
|
for (const path of testFiles) {
|
|
14292
14574
|
if (Date.now() > deadline) {
|
|
14293
14575
|
rulesPartial = true;
|
|
@@ -14301,7 +14583,12 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14301
14583
|
if (!isCiAdapter) testFileCount++;
|
|
14302
14584
|
let text;
|
|
14303
14585
|
try {
|
|
14304
|
-
|
|
14586
|
+
const readResult = readFileBounded(path, LIMITS$1.maxFileBytes);
|
|
14587
|
+
if (!readResult.ok) {
|
|
14588
|
+
skippedFiles++;
|
|
14589
|
+
continue;
|
|
14590
|
+
}
|
|
14591
|
+
text = readResult.data.toString("utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
|
|
14305
14592
|
} catch {
|
|
14306
14593
|
skippedFiles++;
|
|
14307
14594
|
continue;
|
|
@@ -14335,6 +14622,7 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14335
14622
|
const cachedFindings = cache.lookup(cacheKey);
|
|
14336
14623
|
if (cachedFindings) {
|
|
14337
14624
|
for (const f of cachedFindings) findings.push(f);
|
|
14625
|
+
analyzed++;
|
|
14338
14626
|
continue;
|
|
14339
14627
|
}
|
|
14340
14628
|
hooks.onProgress?.({
|
|
@@ -14361,10 +14649,12 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14361
14649
|
}
|
|
14362
14650
|
const actualMode = parsed ? "ast" : "regex";
|
|
14363
14651
|
if (wantsAst && actualMode === "regex") {
|
|
14652
|
+
parseFallbacks++;
|
|
14364
14653
|
cacheKey = fileCacheKey(rulesDigest, text, identity(actualMode));
|
|
14365
14654
|
const fallbackFindings = cache.lookup(cacheKey);
|
|
14366
14655
|
if (fallbackFindings) {
|
|
14367
14656
|
for (const f of fallbackFindings) findings.push(f);
|
|
14657
|
+
analyzed++;
|
|
14368
14658
|
continue;
|
|
14369
14659
|
}
|
|
14370
14660
|
}
|
|
@@ -14395,8 +14685,10 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14395
14685
|
fileBudgetExceeded = true;
|
|
14396
14686
|
}
|
|
14397
14687
|
});
|
|
14688
|
+
if (!fileRuleFailed && !fileBudgetExceeded) analyzed++;
|
|
14398
14689
|
if (!fileRuleFailed) cache.store(cacheKey, findings.slice(findingsStart), fileBudgetExceeded);
|
|
14399
14690
|
} catch {
|
|
14691
|
+
if (wantsAst) parseFallbacks++;
|
|
14400
14692
|
skippedFiles++;
|
|
14401
14693
|
parseFailed++;
|
|
14402
14694
|
} finally {
|
|
@@ -14409,7 +14701,9 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14409
14701
|
testDeclarationCount,
|
|
14410
14702
|
rulesPartial,
|
|
14411
14703
|
parseFailed,
|
|
14412
|
-
|
|
14704
|
+
parseFallbacks,
|
|
14705
|
+
scanned,
|
|
14706
|
+
analyzed
|
|
14413
14707
|
};
|
|
14414
14708
|
}
|
|
14415
14709
|
/**
|
|
@@ -14455,13 +14749,15 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
|
|
|
14455
14749
|
for (const w of warnings) hooks.onConfigWarning?.(w);
|
|
14456
14750
|
applySeverityOverrides(findings, config);
|
|
14457
14751
|
const active = loadSuppressions(workspace.root).entries.filter((e) => e.status === "active");
|
|
14458
|
-
|
|
14752
|
+
hooks.onPreSuppressionFindings?.([...findings]);
|
|
14753
|
+
let suppressionCount = 0;
|
|
14459
14754
|
if (active.length > 0) {
|
|
14460
14755
|
const ruleOnly = new Set(active.filter((e) => !e.files?.length).map((e) => e.ruleId));
|
|
14461
14756
|
const kept = findings.filter((f) => {
|
|
14462
14757
|
if (ruleOnly.has(f.ruleId)) return false;
|
|
14463
14758
|
return !active.some((e) => e.files?.length && e.ruleId === f.ruleId && e.files.some((g) => pathMatchesGlob(f.file, g)));
|
|
14464
14759
|
});
|
|
14760
|
+
suppressionCount = findings.length - kept.length;
|
|
14465
14761
|
findings.length = 0;
|
|
14466
14762
|
for (const f of kept) findings.push(f);
|
|
14467
14763
|
}
|
|
@@ -14485,6 +14781,7 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
|
|
|
14485
14781
|
for (const f of findings) f.fixGroupId = f.ruleId;
|
|
14486
14782
|
const discoveredReport = discoverAndParseRuntimeReport(scanRoot.root);
|
|
14487
14783
|
const runtimeReportPath = discoveredReport?.path;
|
|
14784
|
+
const runtimeIncomplete = discoveredReport !== void 0 && discoveredReport.report.analysisComplete !== true;
|
|
14488
14785
|
let forensicVerdicts;
|
|
14489
14786
|
if (discoveredReport && discoveredReport.report.analysisComplete === true) try {
|
|
14490
14787
|
buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
|
|
@@ -14497,6 +14794,7 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
|
|
|
14497
14794
|
suppressionCount,
|
|
14498
14795
|
frameworks,
|
|
14499
14796
|
runtimeReportPath,
|
|
14797
|
+
runtimeIncomplete,
|
|
14500
14798
|
forensicVerdicts,
|
|
14501
14799
|
config
|
|
14502
14800
|
};
|
|
@@ -14565,10 +14863,13 @@ function assembleScanResult(o) {
|
|
|
14565
14863
|
if (o.scopeIgnored > 0) scopeReasons.push(`ignored:${o.scopeIgnored}`);
|
|
14566
14864
|
if (o.scopeUnrecognized > 0) scopeReasons.push(`unrecognized:${o.scopeUnrecognized}`);
|
|
14567
14865
|
if (o.parseFailed > 0) scopeReasons.push(`parseFailed:${o.parseFailed}`);
|
|
14866
|
+
if (o.skippedFiles > 0) scopeReasons.push(`skipped:${o.skippedFiles}`);
|
|
14867
|
+
if (o.scopeInfo.degraded) scopeReasons.push(`degraded:${o.scopeInfo.degraded}`);
|
|
14868
|
+
if (o.runtimeIncomplete) scopeReasons.push("runtime-incomplete");
|
|
14568
14869
|
for (const reason of o.truncationReasons) scopeReasons.push(`truncated:${reason}`);
|
|
14569
14870
|
const scopeIntegrity = {
|
|
14570
14871
|
discovered: o.testFiles.length,
|
|
14571
|
-
analyzed: Math.max(0, o.scanned),
|
|
14872
|
+
analyzed: Math.max(0, o.analyzed ?? o.scanned),
|
|
14572
14873
|
ignored: o.scopeIgnored,
|
|
14573
14874
|
unrecognized: o.scopeUnrecognized,
|
|
14574
14875
|
parseFailed: o.parseFailed,
|
|
@@ -14577,28 +14878,59 @@ function assembleScanResult(o) {
|
|
|
14577
14878
|
...scopeReasons.length > 0 ? { reasons: scopeReasons } : {}
|
|
14578
14879
|
};
|
|
14579
14880
|
const scopeAdjustedTotal = scopeReasons.length > 0 && total >= 100 ? 99 : total;
|
|
14881
|
+
let identityIncomplete = false;
|
|
14580
14882
|
const inputSnapshot = o.testFiles.map((p) => {
|
|
14581
14883
|
const relPath = relative(o.workspace.root, p).replaceAll("\\", "/");
|
|
14582
14884
|
try {
|
|
14583
|
-
const
|
|
14584
|
-
|
|
14885
|
+
const read = readFileBounded(resolve(o.workspace.root, p), LIMITS$1.maxFileBytes);
|
|
14886
|
+
if (!read.ok) {
|
|
14887
|
+
if (existsSync(o.workspace.root)) identityIncomplete = true;
|
|
14888
|
+
return {
|
|
14889
|
+
path: relPath,
|
|
14890
|
+
size: 0,
|
|
14891
|
+
hash: "UNAVAILABLE"
|
|
14892
|
+
};
|
|
14893
|
+
}
|
|
14894
|
+
const hash = createHash("sha256").update(read.data).digest("hex");
|
|
14585
14895
|
return {
|
|
14586
14896
|
path: relPath,
|
|
14587
|
-
size:
|
|
14897
|
+
size: read.data.length,
|
|
14588
14898
|
hash
|
|
14589
14899
|
};
|
|
14590
14900
|
} catch {
|
|
14901
|
+
if (existsSync(o.workspace.root)) identityIncomplete = true;
|
|
14591
14902
|
return {
|
|
14592
14903
|
path: relPath,
|
|
14593
|
-
size: 0
|
|
14904
|
+
size: 0,
|
|
14905
|
+
hash: "UNAVAILABLE"
|
|
14594
14906
|
};
|
|
14595
14907
|
}
|
|
14596
14908
|
});
|
|
14597
14909
|
let reportDigest;
|
|
14598
14910
|
if (o.runtimeReportPath) try {
|
|
14599
|
-
const
|
|
14600
|
-
|
|
14601
|
-
|
|
14911
|
+
const reportPath = resolve(o.workspace.root, o.runtimeReportPath);
|
|
14912
|
+
if (lstatSync(reportPath).isFile()) {
|
|
14913
|
+
const read = readFileBounded(reportPath, LIMITS$1.maxFileBytes);
|
|
14914
|
+
if (!read.ok) identityIncomplete = true;
|
|
14915
|
+
else reportDigest = createHash("sha256").update(read.data).digest("hex");
|
|
14916
|
+
}
|
|
14917
|
+
} catch {
|
|
14918
|
+
identityIncomplete = true;
|
|
14919
|
+
}
|
|
14920
|
+
const completion = deriveCompletion({
|
|
14921
|
+
discoveryTruncated: o.discoveryTruncated,
|
|
14922
|
+
rulesPartial: o.rulesPartial,
|
|
14923
|
+
skippedFiles: o.skippedFiles,
|
|
14924
|
+
rulesCrashed: o.rulesCrashed,
|
|
14925
|
+
truncationReasons: o.truncationReasons,
|
|
14926
|
+
scopeIgnored: o.scopeIgnored,
|
|
14927
|
+
scopeUnrecognized: o.scopeUnrecognized,
|
|
14928
|
+
parseFailed: o.parseFailed,
|
|
14929
|
+
parseFallbacks: o.parseFallbacks ?? 0,
|
|
14930
|
+
...o.scopeInfo.degraded !== void 0 ? { scopeDegraded: o.scopeInfo.degraded } : {},
|
|
14931
|
+
...o.runtimeIncomplete !== void 0 ? { runtimeIncomplete: o.runtimeIncomplete } : {},
|
|
14932
|
+
identityIncomplete
|
|
14933
|
+
});
|
|
14602
14934
|
const runIdentity = buildRunIdentity({
|
|
14603
14935
|
files: inputSnapshot,
|
|
14604
14936
|
rules: [...o.REVISION_BY_RULE_ID.entries()].map(([id, detectorRevision]) => ({
|
|
@@ -14616,13 +14948,14 @@ function assembleScanResult(o) {
|
|
|
14616
14948
|
const evidenceGraph = buildEvidenceGraph({ runId: runIdentity });
|
|
14617
14949
|
const hasTests = o.testFileCount > 0 && o.testDeclarationCount > 0;
|
|
14618
14950
|
const suiteInvalidatedBy = [...new Set(o.findings.filter((f) => SUITE_INVALIDATING_RULE_IDS.has(f.ruleId)).map((f) => f.ruleId))].sort();
|
|
14951
|
+
const finalScore = hasTests ? completion.partial && scopeAdjustedTotal >= 100 ? 99 : scopeAdjustedTotal : null;
|
|
14619
14952
|
const result = {
|
|
14620
14953
|
schemaVersion: 1,
|
|
14621
|
-
partial:
|
|
14954
|
+
partial: completion.partial,
|
|
14622
14955
|
scopeIntegrity,
|
|
14623
14956
|
runIdentity,
|
|
14624
14957
|
evidenceGraph,
|
|
14625
|
-
score:
|
|
14958
|
+
score: finalScore,
|
|
14626
14959
|
...hasTests ? {} : { reason: "no-tests-found" },
|
|
14627
14960
|
frameworks: o.frameworks.frameworks,
|
|
14628
14961
|
frameworkDetectionUnknown: o.frameworks.unknown,
|
|
@@ -14648,12 +14981,8 @@ function assembleScanResult(o) {
|
|
|
14648
14981
|
file: o.cache.stats.file
|
|
14649
14982
|
} } : {},
|
|
14650
14983
|
analysisStatus: {
|
|
14651
|
-
|
|
14652
|
-
|
|
14653
|
-
skippedFiles: o.skippedFiles,
|
|
14654
|
-
durationMs: elapsed,
|
|
14655
|
-
rulesCrashed: o.rulesCrashed,
|
|
14656
|
-
...o.truncationReasons.size > 0 ? { truncationReasons: [...o.truncationReasons].sort() } : {}
|
|
14984
|
+
...completion.analysisStatus,
|
|
14985
|
+
durationMs: elapsed
|
|
14657
14986
|
},
|
|
14658
14987
|
scoringModelVersion: SCORING_MODEL_VERSION
|
|
14659
14988
|
};
|
|
@@ -14701,7 +15030,8 @@ function assembleScanResult(o) {
|
|
|
14701
15030
|
*/
|
|
14702
15031
|
async function runScan(args, hooks = {}) {
|
|
14703
15032
|
const started = Date.now();
|
|
14704
|
-
const
|
|
15033
|
+
const requestedDuration = Number.isFinite(args.maxDurationMs) ? args.maxDurationMs : DEFAULT_MAX_DURATION_MS$1;
|
|
15034
|
+
const deadline = started + Math.min(Math.max(1, requestedDuration), MAX_DURATION_MS$1);
|
|
14705
15035
|
const discovered = discoverWorkspace(args.target);
|
|
14706
15036
|
const targetAbs = resolve(args.target);
|
|
14707
15037
|
const scanRoot = discovered && discovered.root !== targetAbs && targetAbs.startsWith(discovered.root + sep) ? {
|
|
@@ -14780,6 +15110,7 @@ async function runScan(args, hooks = {}) {
|
|
|
14780
15110
|
scopeUnrecognized++;
|
|
14781
15111
|
}
|
|
14782
15112
|
});
|
|
15113
|
+
hooks.onTestFilesDiscovered?.(testFiles);
|
|
14783
15114
|
const analysis = await runFileAnalysisPhase(findings, testFiles, workspace, activeRules, hooks, cache, rulesDigest, deadline, truncationReasons, declarationsByFile, fileProvenance, (ruleId, file, error) => {
|
|
14784
15115
|
rulesCrashed++;
|
|
14785
15116
|
hooks.onRuleCrash?.(ruleId, file, error);
|
|
@@ -14789,7 +15120,9 @@ async function runScan(args, hooks = {}) {
|
|
|
14789
15120
|
testDeclarationCount = analysis.testDeclarationCount;
|
|
14790
15121
|
rulesPartial = analysis.rulesPartial;
|
|
14791
15122
|
parseFailed = analysis.parseFailed;
|
|
15123
|
+
const parseFallbacks = analysis.parseFallbacks;
|
|
14792
15124
|
const scanned = analysis.scanned;
|
|
15125
|
+
const analyzed = analysis.analyzed;
|
|
14793
15126
|
const postScan = applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, declarationsByFile, testDeclarationCount, tierByRuleId, REVISION_BY_RULE_ID);
|
|
14794
15127
|
testDeclarationCount = postScan.testDeclarationCount;
|
|
14795
15128
|
const result = assembleScanResult({
|
|
@@ -14805,7 +15138,9 @@ async function runScan(args, hooks = {}) {
|
|
|
14805
15138
|
scopeIgnored,
|
|
14806
15139
|
scopeUnrecognized,
|
|
14807
15140
|
parseFailed,
|
|
15141
|
+
parseFallbacks,
|
|
14808
15142
|
scanned,
|
|
15143
|
+
analyzed,
|
|
14809
15144
|
testFiles,
|
|
14810
15145
|
workspace,
|
|
14811
15146
|
scanRoot,
|
|
@@ -14818,6 +15153,7 @@ async function runScan(args, hooks = {}) {
|
|
|
14818
15153
|
suppressionCount: postScan.suppressionCount,
|
|
14819
15154
|
frameworks: postScan.frameworks,
|
|
14820
15155
|
runtimeReportPath: postScan.runtimeReportPath,
|
|
15156
|
+
runtimeIncomplete: postScan.runtimeIncomplete,
|
|
14821
15157
|
forensicVerdicts: postScan.forensicVerdicts,
|
|
14822
15158
|
config: postScan.config,
|
|
14823
15159
|
fileProvenance,
|
|
@@ -14849,6 +15185,10 @@ function digestView(f) {
|
|
|
14849
15185
|
trustLevel: f.trustLevel ?? null,
|
|
14850
15186
|
confidence: f.confidence,
|
|
14851
15187
|
findingType: f.findingType,
|
|
15188
|
+
qaImpact: f.qaImpact,
|
|
15189
|
+
findingId: f.findingId ?? null,
|
|
15190
|
+
rootCauseId: f.rootCauseId ?? null,
|
|
15191
|
+
deduplicationGroup: f.deduplicationGroup ?? null,
|
|
14852
15192
|
fixGroupId: f.fixGroupId ?? null
|
|
14853
15193
|
};
|
|
14854
15194
|
}
|
|
@@ -14869,8 +15209,12 @@ function canonicalScanJson(result) {
|
|
|
14869
15209
|
rules: result.analysisStatus.rules,
|
|
14870
15210
|
skippedFiles: result.analysisStatus.skippedFiles,
|
|
14871
15211
|
rulesCrashed: result.analysisStatus.rulesCrashed ?? 0,
|
|
14872
|
-
|
|
15212
|
+
parseFallbacks: result.analysisStatus.parseFallbacks ?? 0,
|
|
15213
|
+
truncationReasons: result.analysisStatus.truncationReasons ?? [],
|
|
15214
|
+
reasons: result.analysisStatus.reasons ?? []
|
|
14873
15215
|
},
|
|
15216
|
+
scopeIntegrity: result.scopeIntegrity ?? null,
|
|
15217
|
+
runIdentity: result.runIdentity ?? null,
|
|
14874
15218
|
findings: result.findings.map(digestView)
|
|
14875
15219
|
});
|
|
14876
15220
|
}
|
|
@@ -14927,9 +15271,13 @@ function buildMachineContract(result) {
|
|
|
14927
15271
|
rules: result.analysisStatus.rules,
|
|
14928
15272
|
skippedFiles: result.analysisStatus.skippedFiles,
|
|
14929
15273
|
rulesCrashed: result.analysisStatus.rulesCrashed ?? 0,
|
|
15274
|
+
...result.analysisStatus.parseFallbacks !== void 0 ? { parseFallbacks: result.analysisStatus.parseFallbacks } : {},
|
|
14930
15275
|
truncationReasons: result.analysisStatus.truncationReasons ?? [],
|
|
15276
|
+
...result.analysisStatus.reasons !== void 0 ? { reasons: result.analysisStatus.reasons } : {},
|
|
14931
15277
|
frameworkDetectionUnknown: result.frameworkDetectionUnknown,
|
|
14932
|
-
durationMs: result.analysisStatus.durationMs
|
|
15278
|
+
durationMs: result.analysisStatus.durationMs,
|
|
15279
|
+
...result.scopeIntegrity !== void 0 ? { scopeIntegrity: result.scopeIntegrity } : {},
|
|
15280
|
+
...result.runIdentity !== void 0 ? { runIdentity: result.runIdentity } : {}
|
|
14933
15281
|
},
|
|
14934
15282
|
...result.trustSummary !== void 0 ? { trustSummary: result.trustSummary } : {},
|
|
14935
15283
|
...result.agenticProfile !== void 0 ? { provenance: result.agenticProfile } : {},
|
|
@@ -15290,12 +15638,15 @@ function trustVerdictFor(v) {
|
|
|
15290
15638
|
* today the honest command set is repeat/compare/inspect, not
|
|
15291
15639
|
* "collect a trace", because trace.zip is not yet ingested.
|
|
15292
15640
|
*/
|
|
15641
|
+
function safeTriageText(value) {
|
|
15642
|
+
return value.replace(/[;&|`$()\\\r\n]/g, " ").slice(0, 200);
|
|
15643
|
+
}
|
|
15293
15644
|
function nextActionFor(v) {
|
|
15294
15645
|
switch (classifyVerdict(v)) {
|
|
15295
|
-
case "RETRY-DEPENDENT": return `repeat execution of this test (3+ runs) to confirm the flake rate, then quarantine + ticket: ${v.file}`;
|
|
15296
|
-
case "TIMEOUT": return `re-run in isolation to separate slowness from a hang:
|
|
15297
|
-
case "FAILING": return `reproduce
|
|
15298
|
-
case "SKIPPED": return `inspect the skip condition in ${v.file} — a skip after a failure usually hides an environment problem`;
|
|
15646
|
+
case "RETRY-DEPENDENT": return `repeat execution of this test (3+ runs) to confirm the flake rate, then quarantine + ticket: ${safeTriageText(v.file)}`;
|
|
15647
|
+
case "TIMEOUT": return `re-run this test in isolation with a 60-second timeout to separate slowness from a hang: ${safeTriageText(v.file)}`;
|
|
15648
|
+
case "FAILING": return `reproduce this test in isolation, then fix and re-run: ${safeTriageText(v.file)} — ${safeTriageText(v.title)}`;
|
|
15649
|
+
case "SKIPPED": return `inspect the skip condition in ${safeTriageText(v.file)} — a skip after a failure usually hides an environment problem`;
|
|
15299
15650
|
}
|
|
15300
15651
|
}
|
|
15301
15652
|
/** Deterministic guided rows, worst first (same order law as triageRows). */
|
|
@@ -15390,7 +15741,16 @@ function renderPwRunSummary(s) {
|
|
|
15390
15741
|
*/
|
|
15391
15742
|
/** Hard caps (§21 threat model: parameter size + resource bounds). */
|
|
15392
15743
|
const MAX_PARAM_BYTES = 65536;
|
|
15744
|
+
const MAX_DURATION_MS = 36e5;
|
|
15745
|
+
const DEFAULT_MAX_DURATION_MS = 6e5;
|
|
15393
15746
|
const PROTOCOL_VERSION = "2025-06-18";
|
|
15747
|
+
const CONFIGURED_ROOT = process.env.MJOLNIR_WORKSPACE_ROOT ? resolve(process.env.MJOLNIR_WORKSPACE_ROOT) : null;
|
|
15748
|
+
function isPathAllowed(path) {
|
|
15749
|
+
if (!CONFIGURED_ROOT) return true;
|
|
15750
|
+
const candidate = resolve(path);
|
|
15751
|
+
const rel = relative(CONFIGURED_ROOT, candidate);
|
|
15752
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
15753
|
+
}
|
|
15394
15754
|
/** JSON-RPC error codes (subset used here). */
|
|
15395
15755
|
const MCP_ERRORS = {
|
|
15396
15756
|
PARSE: -32700,
|
|
@@ -15509,7 +15869,10 @@ function validateParams(name, args) {
|
|
|
15509
15869
|
if (Buffer$1.byteLength(JSON.stringify(args), "utf8") > MAX_PARAM_BYTES) return `parameters exceed ${MAX_PARAM_BYTES} bytes (threat model §21)`;
|
|
15510
15870
|
if (name === "scan") {
|
|
15511
15871
|
if (typeof args["path"] !== "string" || args["path"].length === 0) return "scan requires a non-empty string `path`";
|
|
15512
|
-
if (args["maxDurationMs"] !== void 0
|
|
15872
|
+
if (args["maxDurationMs"] !== void 0) {
|
|
15873
|
+
const duration = args["maxDurationMs"];
|
|
15874
|
+
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";
|
|
15875
|
+
}
|
|
15513
15876
|
}
|
|
15514
15877
|
if (name === "explain") {
|
|
15515
15878
|
if (typeof args["ruleId"] !== "string" || !/^QA-[A-Z]+-\d{3}$/.test(args["ruleId"])) return "explain requires `ruleId` matching /^QA-[A-Z]+-\\d{3}$/";
|
|
@@ -15554,6 +15917,24 @@ async function handleToolCall(call) {
|
|
|
15554
15917
|
message: paramError
|
|
15555
15918
|
}
|
|
15556
15919
|
};
|
|
15920
|
+
const pathArgument = call.args["path"];
|
|
15921
|
+
if (typeof pathArgument === "string" && !isPathAllowed(pathArgument)) return {
|
|
15922
|
+
jsonrpc: "2.0",
|
|
15923
|
+
id: call.id,
|
|
15924
|
+
error: {
|
|
15925
|
+
code: MCP_ERRORS.INVALID_PARAMS,
|
|
15926
|
+
message: "path is outside the configured workspace root"
|
|
15927
|
+
}
|
|
15928
|
+
};
|
|
15929
|
+
const fixturesRoot = call.args["fixturesRoot"];
|
|
15930
|
+
if (typeof fixturesRoot === "string" && !isPathAllowed(fixturesRoot)) return {
|
|
15931
|
+
jsonrpc: "2.0",
|
|
15932
|
+
id: call.id,
|
|
15933
|
+
error: {
|
|
15934
|
+
code: MCP_ERRORS.INVALID_PARAMS,
|
|
15935
|
+
message: "fixturesRoot is outside the configured workspace root"
|
|
15936
|
+
}
|
|
15937
|
+
};
|
|
15557
15938
|
try {
|
|
15558
15939
|
if (call.name === "scan") {
|
|
15559
15940
|
const target = resolve(call.args["path"]);
|
|
@@ -15565,7 +15946,7 @@ async function handleToolCall(call) {
|
|
|
15565
15946
|
message: `scan target does not exist: ${target}`
|
|
15566
15947
|
}
|
|
15567
15948
|
};
|
|
15568
|
-
const maxDurationMs = typeof call.args["maxDurationMs"] === "number" ? call.args["maxDurationMs"] :
|
|
15949
|
+
const maxDurationMs = typeof call.args["maxDurationMs"] === "number" ? call.args["maxDurationMs"] : DEFAULT_MAX_DURATION_MS;
|
|
15569
15950
|
const result = await serializeScan(() => runScan({
|
|
15570
15951
|
target,
|
|
15571
15952
|
json: true,
|