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
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.mjs";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
4
4
|
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
-
import { createHash } from "node:crypto";
|
|
6
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
7
7
|
import * as ts$2 from "ts-morph";
|
|
8
8
|
import ts, { Project, SyntaxKind, ts as ts$1 } from "ts-morph";
|
|
9
9
|
import { Language, Parser } from "web-tree-sitter";
|
|
@@ -97,6 +97,38 @@ const DEDUCTIONS = {
|
|
|
97
97
|
info: 1
|
|
98
98
|
};
|
|
99
99
|
//#endregion
|
|
100
|
+
//#region src/engine/completion.ts
|
|
101
|
+
function deriveCompletion(input) {
|
|
102
|
+
const truncationReasons = [...new Set(input.truncationReasons)].sort();
|
|
103
|
+
const reasons = /* @__PURE__ */ new Set();
|
|
104
|
+
if (input.discoveryTruncated) reasons.add("discovery-truncated");
|
|
105
|
+
if (input.rulesPartial) reasons.add("rules-partial");
|
|
106
|
+
if (input.skippedFiles > 0) reasons.add(`skipped-files:${input.skippedFiles}`);
|
|
107
|
+
if (input.rulesCrashed > 0) reasons.add(`rules-crashed:${input.rulesCrashed}`);
|
|
108
|
+
if (input.scopeIgnored > 0) reasons.add(`scope-ignored:${input.scopeIgnored}`);
|
|
109
|
+
if (input.scopeUnrecognized > 0) reasons.add(`scope-unrecognized:${input.scopeUnrecognized}`);
|
|
110
|
+
if (input.parseFailed > 0) reasons.add(`parse-failed:${input.parseFailed}`);
|
|
111
|
+
if (input.parseFallbacks && input.parseFallbacks > 0) reasons.add(`parse-fallbacks:${input.parseFallbacks}`);
|
|
112
|
+
if (input.scopeDegraded) reasons.add(`scope-degraded:${input.scopeDegraded}`);
|
|
113
|
+
if (input.runtimeIncomplete) reasons.add("runtime-incomplete");
|
|
114
|
+
if (input.identityIncomplete) reasons.add("identity-incomplete");
|
|
115
|
+
for (const reason of truncationReasons) reasons.add(`truncated:${reason}`);
|
|
116
|
+
const discoveryPartial = input.discoveryTruncated || input.scopeIgnored > 0 || input.scopeUnrecognized > 0 || input.scopeDegraded !== void 0;
|
|
117
|
+
const rulesPartial = input.rulesPartial || input.rulesCrashed > 0 || input.parseFailed > 0;
|
|
118
|
+
return {
|
|
119
|
+
partial: discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0,
|
|
120
|
+
analysisStatus: {
|
|
121
|
+
discovery: discoveryPartial ? "partial" : "complete",
|
|
122
|
+
rules: rulesPartial ? "partial" : "complete",
|
|
123
|
+
skippedFiles: input.skippedFiles,
|
|
124
|
+
rulesCrashed: input.rulesCrashed,
|
|
125
|
+
parseFallbacks: input.parseFallbacks ?? 0,
|
|
126
|
+
...truncationReasons.length > 0 ? { truncationReasons } : {},
|
|
127
|
+
reasons: [...reasons].sort()
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
//#endregion
|
|
100
132
|
//#region src/engine/runtime-corroboration.ts
|
|
101
133
|
/**
|
|
102
134
|
* Stamp runtime corroboration + trust levels onto findings (mutates in
|
|
@@ -863,7 +895,10 @@ function sha256$1(text) {
|
|
|
863
895
|
return createHash("sha256").update(text).digest("hex");
|
|
864
896
|
}
|
|
865
897
|
function canonical(value) {
|
|
866
|
-
return JSON.stringify(value);
|
|
898
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
899
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
900
|
+
const record = value;
|
|
901
|
+
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`;
|
|
867
902
|
}
|
|
868
903
|
function buildRunIdentity(input) {
|
|
869
904
|
const inputFingerprint = sha256$1([...input.files].map((f) => f.hash ? `${f.path}:${f.hash}` : `${f.path}:${f.size}`).sort().join("\n"));
|
|
@@ -945,7 +980,7 @@ function buildEvidenceGraph(parts) {
|
|
|
945
980
|
* scripts/sync-sarif-version.cjs and guarded by the version-consistency
|
|
946
981
|
* spec. cli.ts re-exports this as CLI_VERSION.
|
|
947
982
|
*/
|
|
948
|
-
const ENGINE_VERSION = "
|
|
983
|
+
const ENGINE_VERSION = "3.0.0";
|
|
949
984
|
//#endregion
|
|
950
985
|
//#region src/engine/contract-versions.ts
|
|
951
986
|
/**
|
|
@@ -1106,7 +1141,9 @@ function basename$1(p) {
|
|
|
1106
1141
|
* Internal files (cache, hashes) use `isRecord`; user-facing files
|
|
1107
1142
|
* (config, plugins) use stricter checks that throw descriptive errors.
|
|
1108
1143
|
*/
|
|
1144
|
+
const MAX_JSON_BYTES = 16777216;
|
|
1109
1145
|
function parseJsonFile(text, source, validate) {
|
|
1146
|
+
if (Buffer.byteLength(text, "utf8") > MAX_JSON_BYTES) throw new Error(`JSON source exceeds the ${MAX_JSON_BYTES}-byte limit: ${source}`);
|
|
1110
1147
|
let parsed;
|
|
1111
1148
|
try {
|
|
1112
1149
|
parsed = JSON.parse(text);
|
|
@@ -1125,6 +1162,59 @@ function isRecord(v) {
|
|
|
1125
1162
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1126
1163
|
}
|
|
1127
1164
|
//#endregion
|
|
1165
|
+
//#region src/lib/fs-bounded.ts
|
|
1166
|
+
function closeQuiet(fd) {
|
|
1167
|
+
try {
|
|
1168
|
+
closeSync(fd);
|
|
1169
|
+
} catch {
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
function readFileBounded(path, maxBytes) {
|
|
1174
|
+
let fd;
|
|
1175
|
+
try {
|
|
1176
|
+
const pathStat = lstatSync(path);
|
|
1177
|
+
if (pathStat.isSymbolicLink()) return {
|
|
1178
|
+
ok: false,
|
|
1179
|
+
reason: "symlink"
|
|
1180
|
+
};
|
|
1181
|
+
if (!pathStat.isFile()) return {
|
|
1182
|
+
ok: false,
|
|
1183
|
+
reason: "unreadable"
|
|
1184
|
+
};
|
|
1185
|
+
fd = openSync(path, 0);
|
|
1186
|
+
const fdStat = fstatSync(fd);
|
|
1187
|
+
if (!fdStat.isFile() || fdStat.size > maxBytes) return {
|
|
1188
|
+
ok: false,
|
|
1189
|
+
reason: fdStat.size > maxBytes ? "too-large" : "unreadable"
|
|
1190
|
+
};
|
|
1191
|
+
const chunks = [];
|
|
1192
|
+
let total = 0;
|
|
1193
|
+
const buffer = Buffer.allocUnsafe(Math.min(65536, maxBytes));
|
|
1194
|
+
while (true) {
|
|
1195
|
+
const read = readSync(fd, buffer, 0, buffer.length, null);
|
|
1196
|
+
if (read === 0) break;
|
|
1197
|
+
total += read;
|
|
1198
|
+
if (total > maxBytes) return {
|
|
1199
|
+
ok: false,
|
|
1200
|
+
reason: "too-large"
|
|
1201
|
+
};
|
|
1202
|
+
chunks.push(Buffer.from(buffer.subarray(0, read)));
|
|
1203
|
+
}
|
|
1204
|
+
return {
|
|
1205
|
+
ok: true,
|
|
1206
|
+
data: Buffer.concat(chunks)
|
|
1207
|
+
};
|
|
1208
|
+
} catch {
|
|
1209
|
+
return {
|
|
1210
|
+
ok: false,
|
|
1211
|
+
reason: "unreadable"
|
|
1212
|
+
};
|
|
1213
|
+
} finally {
|
|
1214
|
+
if (fd !== void 0) closeQuiet(fd);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
//#endregion
|
|
1128
1218
|
//#region src/config/config-schema.ts
|
|
1129
1219
|
const VALID_GATES = /* @__PURE__ */ new Set([
|
|
1130
1220
|
"advisory",
|
|
@@ -1223,7 +1313,11 @@ const CONFIG_NAMES = ["mjolnir.config.json", ".mjolnir.json"];
|
|
|
1223
1313
|
function findConfigPath(root) {
|
|
1224
1314
|
for (const name of CONFIG_NAMES) {
|
|
1225
1315
|
const p = join(root, name);
|
|
1226
|
-
|
|
1316
|
+
try {
|
|
1317
|
+
if (existsSync(p) && !lstatSync(p).isSymbolicLink()) return p;
|
|
1318
|
+
} catch {
|
|
1319
|
+
continue;
|
|
1320
|
+
}
|
|
1227
1321
|
}
|
|
1228
1322
|
return null;
|
|
1229
1323
|
}
|
|
@@ -1243,7 +1337,10 @@ function loadConfig(root, options = {}) {
|
|
|
1243
1337
|
const p = join(root, name);
|
|
1244
1338
|
if (!existsSync(p)) continue;
|
|
1245
1339
|
try {
|
|
1246
|
-
|
|
1340
|
+
if (!existsSync(p) || lstatSync(p).isSymbolicLink()) continue;
|
|
1341
|
+
const read = readFileBounded(p, 1048576);
|
|
1342
|
+
if (!read.ok) throw new Error("config source could not be read safely");
|
|
1343
|
+
const parsed = parseJsonFile(read.data.toString("utf8"), p, (v) => isRecord(v));
|
|
1247
1344
|
return {
|
|
1248
1345
|
config: parsed,
|
|
1249
1346
|
path: p,
|
|
@@ -1489,6 +1586,10 @@ function globBody(glob) {
|
|
|
1489
1586
|
}
|
|
1490
1587
|
return re;
|
|
1491
1588
|
}
|
|
1589
|
+
/** Anchored full-path glob — the primitive the matcher builds on. */
|
|
1590
|
+
function globToRegExp(glob) {
|
|
1591
|
+
return new RegExp(`^${globBody(glob)}$`);
|
|
1592
|
+
}
|
|
1492
1593
|
/** Defaults-only matcher for callers that have no scan context. */
|
|
1493
1594
|
const DEFAULT_IGNORE_MATCHER = createMatcherFromPatterns([]);
|
|
1494
1595
|
/**
|
|
@@ -1538,8 +1639,9 @@ function sharedWalk(options) {
|
|
|
1538
1639
|
}
|
|
1539
1640
|
const full = join(dir, entry.name);
|
|
1540
1641
|
const rel = relative(options.root, full).replaceAll("\\", "/");
|
|
1642
|
+
if (entry.isDirectory() && options.skipDirs.includes(entry.name)) continue;
|
|
1541
1643
|
if (options.ignoreMatcher.isIgnored(rel)) {
|
|
1542
|
-
options.onIgnored?.();
|
|
1644
|
+
if (!rel.startsWith(".mjolnir/")) options.onIgnored?.(rel);
|
|
1543
1645
|
continue;
|
|
1544
1646
|
}
|
|
1545
1647
|
if (entry.isSymbolicLink()) {
|
|
@@ -1547,7 +1649,6 @@ function sharedWalk(options) {
|
|
|
1547
1649
|
continue;
|
|
1548
1650
|
}
|
|
1549
1651
|
if (entry.isDirectory()) {
|
|
1550
|
-
if (options.skipDirs.includes(entry.name)) continue;
|
|
1551
1652
|
if (rel.split("/").length > LIMITS$1.maxDepth) {
|
|
1552
1653
|
options.onSkipped("max-depth");
|
|
1553
1654
|
continue;
|
|
@@ -1565,7 +1666,7 @@ function sharedWalk(options) {
|
|
|
1565
1666
|
} catch {
|
|
1566
1667
|
options.onSkipped("stat-failed");
|
|
1567
1668
|
}
|
|
1568
|
-
else if (entry.isFile()) options.onUnrecognized?.();
|
|
1669
|
+
else if (entry.isFile()) options.onUnrecognized?.(rel);
|
|
1569
1670
|
}
|
|
1570
1671
|
};
|
|
1571
1672
|
walk(options.root);
|
|
@@ -3667,7 +3768,7 @@ const SCAN_ADAPTERS = [
|
|
|
3667
3768
|
* the adapter that claims it; per-adapter caps still apply.
|
|
3668
3769
|
*/
|
|
3669
3770
|
function discoverAllTestFiles(ctx, languageAdapters, buckets, fixtureDirMemo) {
|
|
3670
|
-
const walkSkips = languageAdapters.reduce((common, a) => common.filter((name) => a.dirSkips.includes(name)), languageAdapters[0]?.dirSkips ?? []);
|
|
3771
|
+
const walkSkips = [.../* @__PURE__ */ new Set([...languageAdapters.reduce((common, a) => common.filter((name) => a.dirSkips.includes(name)), languageAdapters[0]?.dirSkips ?? []), ".mjolnir"])];
|
|
3671
3772
|
sharedWalk({
|
|
3672
3773
|
root: ctx.workspace.root,
|
|
3673
3774
|
deadline: ctx.deadline,
|
|
@@ -3693,10 +3794,35 @@ function discoverAllTestFiles(ctx, languageAdapters, buckets, fixtureDirMemo) {
|
|
|
3693
3794
|
},
|
|
3694
3795
|
isFull: () => languageAdapters.every((a) => (buckets.get(a.id)?.length ?? 0) >= ctx.maxFiles),
|
|
3695
3796
|
fixtureDirMemo,
|
|
3696
|
-
onIgnored:
|
|
3697
|
-
|
|
3797
|
+
onIgnored: (path) => {
|
|
3798
|
+
if (isScopeRelevantIgnored(path)) ctx.onIgnored?.(path);
|
|
3799
|
+
},
|
|
3800
|
+
onUnrecognized: (path) => {
|
|
3801
|
+
if (typeof path === "string" && isUnrecognizedSourceCandidate(path)) ctx.onUnrecognized?.(path);
|
|
3802
|
+
}
|
|
3698
3803
|
});
|
|
3699
3804
|
}
|
|
3805
|
+
function isScopeRelevantIgnored(path) {
|
|
3806
|
+
const name = path.replaceAll("\\", "/").split("/").pop() ?? path;
|
|
3807
|
+
return /\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml|min\.js)$/i.test(name);
|
|
3808
|
+
}
|
|
3809
|
+
function isUnrecognizedSourceCandidate(path) {
|
|
3810
|
+
const normalized = path.replaceAll("\\", "/");
|
|
3811
|
+
const name = normalized.split("/").pop() ?? normalized;
|
|
3812
|
+
if (normalized.includes(".github/workflows/") || name === "azure-pipelines.yml" || name === "Jenkinsfile") return false;
|
|
3813
|
+
const extensionIndex = name.lastIndexOf(".");
|
|
3814
|
+
const stem = extensionIndex > 0 ? name.slice(0, extensionIndex) : name;
|
|
3815
|
+
if (name === "mjolnir.config.json" || name === ".mjolnir.json" || name === "package.json" || name === "package-lock.json" || name === "pnpm-lock.yaml" || name === "yarn.lock" || [
|
|
3816
|
+
"playwright",
|
|
3817
|
+
"vitest",
|
|
3818
|
+
"vite",
|
|
3819
|
+
"eslint",
|
|
3820
|
+
"tsconfig",
|
|
3821
|
+
"jsconfig"
|
|
3822
|
+
].some((prefix) => stem === prefix || stem.startsWith(`${prefix}.`))) return false;
|
|
3823
|
+
const testLike = /(?:^|\/)__tests__\//i.test(normalized) || /\.(?:spec|test)\.[cm]?[jt]sx?$/i.test(name);
|
|
3824
|
+
return /\.(?:[cm]?[jt]sx?|py|java|cs|ya?ml)$/i.test(name) && testLike;
|
|
3825
|
+
}
|
|
3700
3826
|
/** Whether ANY shipped adapter would discover this path as a test file. */
|
|
3701
3827
|
function isKnownTestFile(path) {
|
|
3702
3828
|
return SCAN_ADAPTERS.some((a) => a.isTestFile(path));
|
|
@@ -3760,17 +3886,14 @@ const SEARCHED_FOR = [
|
|
|
3760
3886
|
* The fix: resolve git ONCE per process to an ABSOLUTE path by walking
|
|
3761
3887
|
* the PATH directly (never consulting the CWD), verify the candidate is
|
|
3762
3888
|
* an existing file, and pass that path to execFileSync. Resolution is
|
|
3763
|
-
* memoized; a failure to find a real git anywhere on PATH
|
|
3764
|
-
*
|
|
3765
|
-
* — degraded git data already means full-file attribution, never a
|
|
3766
|
-
* crash.
|
|
3889
|
+
* memoized; a failure to find a real git anywhere on PATH is recorded
|
|
3890
|
+
* and callers degrade without invoking a bare name.
|
|
3767
3891
|
*/
|
|
3768
3892
|
let resolvedGit = void 0;
|
|
3769
3893
|
/**
|
|
3770
3894
|
* The absolute path of the git binary Mjölnir will invoke, or null when
|
|
3771
|
-
* PATH carries no executable `git` at all
|
|
3772
|
-
*
|
|
3773
|
-
* as before, minus the CWD-hijack surface).
|
|
3895
|
+
* PATH carries no executable `git` at all. Callers must degrade without
|
|
3896
|
+
* invoking a bare name.
|
|
3774
3897
|
*/
|
|
3775
3898
|
function resolveGitPath() {
|
|
3776
3899
|
if (resolvedGit !== void 0) return resolvedGit;
|
|
@@ -3803,7 +3926,8 @@ function resolveGitPath() {
|
|
|
3803
3926
|
* the previous inline `git()` helpers, with the hijack surface closed.
|
|
3804
3927
|
*/
|
|
3805
3928
|
function runGit(root, args) {
|
|
3806
|
-
const exe = resolveGitPath()
|
|
3929
|
+
const exe = resolveGitPath();
|
|
3930
|
+
if (!exe) return null;
|
|
3807
3931
|
try {
|
|
3808
3932
|
return execFileSync(exe, [
|
|
3809
3933
|
"-C",
|
|
@@ -3939,7 +4063,7 @@ function computeChangedScope(root, baseBranch) {
|
|
|
3939
4063
|
"--",
|
|
3940
4064
|
...chunk
|
|
3941
4065
|
]);
|
|
3942
|
-
if (output === null)
|
|
4066
|
+
if (output === null) return null;
|
|
3943
4067
|
const sections = output.split("\ndiff --git ");
|
|
3944
4068
|
for (const section of sections) {
|
|
3945
4069
|
const body = section.startsWith("diff --git ") ? section : `diff --git ${section}`;
|
|
@@ -3967,6 +4091,11 @@ function computeChangedScope(root, baseBranch) {
|
|
|
3967
4091
|
"--unified=0",
|
|
3968
4092
|
"HEAD"
|
|
3969
4093
|
], changedFiles);
|
|
4094
|
+
if (committedDiffs === null || workingDiffs === null) return {
|
|
4095
|
+
changed: {},
|
|
4096
|
+
degraded: true,
|
|
4097
|
+
reason: "diff-failed"
|
|
4098
|
+
};
|
|
3970
4099
|
for (const file of changedFiles) {
|
|
3971
4100
|
const committedDiff = committedDiffs.get(file);
|
|
3972
4101
|
const workingDiff = workingDiffs.get(file);
|
|
@@ -3992,8 +4121,11 @@ const LIMITS_MAX_LINES = 1e6;
|
|
|
3992
4121
|
function allLinesOf(root, file) {
|
|
3993
4122
|
const full = join(root, file);
|
|
3994
4123
|
try {
|
|
3995
|
-
|
|
3996
|
-
|
|
4124
|
+
const stat = lstatSync(full);
|
|
4125
|
+
if (stat.isSymbolicLink() || !stat.isFile()) return null;
|
|
4126
|
+
const read = readFileBounded(full, LIMITS$1.maxFileBytes);
|
|
4127
|
+
if (!read.ok) return null;
|
|
4128
|
+
const lineCount = read.data.toString("utf8").split("\n").length;
|
|
3997
4129
|
return Array.from({ length: lineCount }, (_, i) => i + 1);
|
|
3998
4130
|
} catch {
|
|
3999
4131
|
return null;
|
|
@@ -11946,6 +12078,22 @@ function parseManifest(filePath) {
|
|
|
11946
12078
|
if (filePath.endsWith("pyproject.toml")) return parsePyprojectToml(filePath);
|
|
11947
12079
|
if (filePath.endsWith("pom.xml")) return parsePomXml(filePath);
|
|
11948
12080
|
}
|
|
12081
|
+
/**
|
|
12082
|
+
* Given a set of starting files (e.g. test files), return all files
|
|
12083
|
+
* transitively reachable through the dependency graph.
|
|
12084
|
+
*/
|
|
12085
|
+
function getReachableFiles(fromFiles, graph) {
|
|
12086
|
+
const reachable = /* @__PURE__ */ new Set();
|
|
12087
|
+
const stack = [...fromFiles];
|
|
12088
|
+
while (stack.length > 0) {
|
|
12089
|
+
const current = stack.pop();
|
|
12090
|
+
if (current === void 0) continue;
|
|
12091
|
+
if (reachable.has(current)) continue;
|
|
12092
|
+
reachable.add(current);
|
|
12093
|
+
for (const dep of graph.getDependencies(current)) if (!reachable.has(dep)) stack.push(dep);
|
|
12094
|
+
}
|
|
12095
|
+
return [...reachable].sort();
|
|
12096
|
+
}
|
|
11949
12097
|
//#endregion
|
|
11950
12098
|
//#region src/engine/incremental-analysis.ts
|
|
11951
12099
|
/**
|
|
@@ -12087,6 +12235,122 @@ function configurableAggregation(results, config) {
|
|
|
12087
12235
|
};
|
|
12088
12236
|
}
|
|
12089
12237
|
//#endregion
|
|
12238
|
+
//#region src/lib/fs-atomic.ts
|
|
12239
|
+
/**
|
|
12240
|
+
* Atomic file writes (audit S9).
|
|
12241
|
+
*
|
|
12242
|
+
* Every durability-critical write in Mjölnir (baseline, stats, badge,
|
|
12243
|
+
* TRIAGE.md, scaffolded rule files) used to hand-roll
|
|
12244
|
+
* `writeFileSync(path, data)` — a crash mid-write left a TRUNCATED file
|
|
12245
|
+
* at the real path, and a subsequent read (diff, badge endpoint) served
|
|
12246
|
+
* confident nonsense from it.
|
|
12247
|
+
*
|
|
12248
|
+
* `writeFileAtomic` writes to a temp sibling, then RENAMES. On the same
|
|
12249
|
+
* volume rename is atomic: readers see either the complete old file or
|
|
12250
|
+
* the complete new file, never a half-written one. The temp name is
|
|
12251
|
+
* created with `wx` (exclusive) so concurrent writers cannot interleave,
|
|
12252
|
+
* stale temps are cleaned up on failure, and on Windows the rename is
|
|
12253
|
+
* retried briefly because a concurrent reader can hold the destination
|
|
12254
|
+
* open (EBUSY/EPERM).
|
|
12255
|
+
*/
|
|
12256
|
+
function atomicTempPath(path) {
|
|
12257
|
+
return `${path}.mjolnir-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}.tmp`;
|
|
12258
|
+
}
|
|
12259
|
+
/**
|
|
12260
|
+
* Atomically replace `path` with `data`.
|
|
12261
|
+
*/
|
|
12262
|
+
function writeFileAtomic(path, data, opts = {}) {
|
|
12263
|
+
const dir = dirname(path);
|
|
12264
|
+
if (opts.mkdirs !== false && !existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
12265
|
+
const tmp = atomicTempPath(path);
|
|
12266
|
+
let fd;
|
|
12267
|
+
try {
|
|
12268
|
+
fd = openSync(tmp, "wx", opts.mode ?? 420);
|
|
12269
|
+
writeSync(fd, data, null, opts.encoding ?? "utf8");
|
|
12270
|
+
} finally {
|
|
12271
|
+
if (fd !== void 0) closeSync(fd);
|
|
12272
|
+
}
|
|
12273
|
+
try {
|
|
12274
|
+
renameWithWindowsRetry(tmp, path);
|
|
12275
|
+
} catch (err) {
|
|
12276
|
+
try {
|
|
12277
|
+
if (existsSync(tmp)) unlinkSync(tmp);
|
|
12278
|
+
} catch {}
|
|
12279
|
+
throw err;
|
|
12280
|
+
}
|
|
12281
|
+
}
|
|
12282
|
+
/**
|
|
12283
|
+
* renameSync retry loop for Windows: a concurrent reader (another scan,
|
|
12284
|
+
* a badge endpoint, an editor) holding the destination open makes
|
|
12285
|
+
* rename fail with EBUSY/EPERM. A short bounded retry closes the race
|
|
12286
|
+
* without turning an atomic swap into a partial write.
|
|
12287
|
+
*
|
|
12288
|
+
* Internal contract test hook: the platform check keeps this loop off
|
|
12289
|
+
* the POSIX hot path; on win32 the EBUSY/EPERM arms are exercised by
|
|
12290
|
+
* the fs-atomic-retry spec (mocked renameSync).
|
|
12291
|
+
*/
|
|
12292
|
+
const RENAME_RETRIES = 8;
|
|
12293
|
+
const RENAME_RETRY_DELAY_MS = 25;
|
|
12294
|
+
/** Synchronous sleep that does not spin the CPU. */
|
|
12295
|
+
function sleepSync(ms) {
|
|
12296
|
+
try {
|
|
12297
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
12298
|
+
} catch {
|
|
12299
|
+
const until = Date.now() + ms;
|
|
12300
|
+
while (Date.now() < until);
|
|
12301
|
+
}
|
|
12302
|
+
}
|
|
12303
|
+
function renameWithWindowsRetry(from, to) {
|
|
12304
|
+
for (let attempt = 0;; attempt++) try {
|
|
12305
|
+
renameSync(from, to);
|
|
12306
|
+
return;
|
|
12307
|
+
} catch (err) {
|
|
12308
|
+
const code = err?.code;
|
|
12309
|
+
if (process.platform === "win32" && (code === "EBUSY" || code === "EPERM") && attempt < RENAME_RETRIES) {
|
|
12310
|
+
sleepSync(RENAME_RETRY_DELAY_MS);
|
|
12311
|
+
continue;
|
|
12312
|
+
}
|
|
12313
|
+
throw err;
|
|
12314
|
+
}
|
|
12315
|
+
}
|
|
12316
|
+
const STALE_TEMP_AGE_MS = 864e5;
|
|
12317
|
+
const TEMP_PID_RE = /^.+\.mjolnir-([1-9]\d{0,9})-(\d{13})-[0-9a-f]{8}\.tmp$/;
|
|
12318
|
+
function sweepStaleTempFiles(dir) {
|
|
12319
|
+
let swept = 0;
|
|
12320
|
+
let entries;
|
|
12321
|
+
try {
|
|
12322
|
+
entries = readdirSync(dir);
|
|
12323
|
+
} catch {
|
|
12324
|
+
return 0;
|
|
12325
|
+
}
|
|
12326
|
+
const cutoff = Date.now() - STALE_TEMP_AGE_MS;
|
|
12327
|
+
for (const entry of entries) {
|
|
12328
|
+
const match = TEMP_PID_RE.exec(entry);
|
|
12329
|
+
if (!match || Number(match[2]) > cutoff) continue;
|
|
12330
|
+
const path = join(dir, entry);
|
|
12331
|
+
try {
|
|
12332
|
+
const stat = lstatSync(path);
|
|
12333
|
+
if (!stat.isFile() || stat.mtimeMs > cutoff) continue;
|
|
12334
|
+
if (pidAlive(Number(match[1]))) continue;
|
|
12335
|
+
unlinkSync(path);
|
|
12336
|
+
swept++;
|
|
12337
|
+
} catch {
|
|
12338
|
+
continue;
|
|
12339
|
+
}
|
|
12340
|
+
}
|
|
12341
|
+
return swept;
|
|
12342
|
+
}
|
|
12343
|
+
function pidAlive(pid) {
|
|
12344
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 2147483647) return true;
|
|
12345
|
+
if (pid === process.pid) return true;
|
|
12346
|
+
try {
|
|
12347
|
+
process.kill(pid, 0);
|
|
12348
|
+
return true;
|
|
12349
|
+
} catch (err) {
|
|
12350
|
+
return err?.code !== "ESRCH";
|
|
12351
|
+
}
|
|
12352
|
+
}
|
|
12353
|
+
//#endregion
|
|
12090
12354
|
//#region src/engine/scan-cache.ts
|
|
12091
12355
|
/**
|
|
12092
12356
|
* Local incremental scan cache (Beta-to-Stable 1.0 plan, M5.2 / A-2).
|
|
@@ -12122,6 +12386,17 @@ const MAX_ENTRIES$1 = 4096;
|
|
|
12122
12386
|
* serialized size; the newest entries win (real LRU-by-use).
|
|
12123
12387
|
*/
|
|
12124
12388
|
const MAX_TOTAL_BYTES = 33554432;
|
|
12389
|
+
const MAX_FINDINGS_PER_ENTRY = 1e4;
|
|
12390
|
+
function isCachedFinding(value) {
|
|
12391
|
+
if (!isRecord(value)) return false;
|
|
12392
|
+
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";
|
|
12393
|
+
}
|
|
12394
|
+
function isCacheFile(value) {
|
|
12395
|
+
if (!isRecord(value) || value["version"] !== CACHE_VERSION) return false;
|
|
12396
|
+
const entries = value["entries"];
|
|
12397
|
+
if (!isRecord(entries) || Object.keys(entries).length > MAX_ENTRIES$1) return false;
|
|
12398
|
+
return Object.values(entries).every((entry) => isRecord(entry) && Array.isArray(entry["findings"]) && entry["findings"].length <= MAX_FINDINGS_PER_ENTRY && entry["findings"].every(isCachedFinding));
|
|
12399
|
+
}
|
|
12125
12400
|
/** sha256 hex of a string — the only hash this module needs. */
|
|
12126
12401
|
function sha256(text) {
|
|
12127
12402
|
return createHash("sha256").update(text).digest("hex");
|
|
@@ -12220,12 +12495,15 @@ function createScanCache(root) {
|
|
|
12220
12495
|
let totalBytes = 0;
|
|
12221
12496
|
try {
|
|
12222
12497
|
if (existsSync(file)) {
|
|
12223
|
-
|
|
12498
|
+
const stat = lstatSync(file);
|
|
12499
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_TOTAL_BYTES) throw new Error("invalid cache file");
|
|
12500
|
+
entries = parseJsonFile(readFileSync(file, "utf8"), file, isCacheFile).entries;
|
|
12224
12501
|
for (const [k, v] of Object.entries(entries)) {
|
|
12225
|
-
const size = JSON.stringify(v)
|
|
12502
|
+
const size = Buffer.byteLength(JSON.stringify(v), "utf8") + k.length + 4;
|
|
12226
12503
|
entryBytes.set(k, size);
|
|
12227
12504
|
totalBytes += size;
|
|
12228
12505
|
}
|
|
12506
|
+
if (totalBytes > MAX_TOTAL_BYTES) throw new Error("cache byte budget exceeded");
|
|
12229
12507
|
}
|
|
12230
12508
|
} catch {
|
|
12231
12509
|
entries = {};
|
|
@@ -12251,11 +12529,13 @@ function createScanCache(root) {
|
|
|
12251
12529
|
},
|
|
12252
12530
|
store(key, findings, fileBudgetExceeded) {
|
|
12253
12531
|
if (fileBudgetExceeded) return;
|
|
12532
|
+
if (findings.length > MAX_FINDINGS_PER_ENTRY) return;
|
|
12533
|
+
const entryJson = JSON.stringify(findings);
|
|
12534
|
+
const newBytes = Buffer.byteLength(entryJson, "utf8") + key.length + 4;
|
|
12535
|
+
if (newBytes > MAX_TOTAL_BYTES) return;
|
|
12254
12536
|
const replacedBytes = entryBytes.get(key) ?? 0;
|
|
12255
12537
|
delete entries[key];
|
|
12256
|
-
const entryJson = JSON.stringify(findings);
|
|
12257
12538
|
entries[key] = { findings: structuredClone(findings) };
|
|
12258
|
-
const newBytes = entryJson.length + key.length + 4;
|
|
12259
12539
|
entryBytes.set(key, newBytes);
|
|
12260
12540
|
totalBytes = totalBytes - replacedBytes + newBytes;
|
|
12261
12541
|
let count = Object.keys(entries).length;
|
|
@@ -12272,10 +12552,10 @@ function createScanCache(root) {
|
|
|
12272
12552
|
if (!dirty) return;
|
|
12273
12553
|
try {
|
|
12274
12554
|
mkdirSync(dir, { recursive: true });
|
|
12275
|
-
|
|
12555
|
+
writeFileAtomic(file, JSON.stringify({
|
|
12276
12556
|
version: CACHE_VERSION,
|
|
12277
12557
|
entries
|
|
12278
|
-
}), "utf8");
|
|
12558
|
+
}), { encoding: "utf8" });
|
|
12279
12559
|
} catch {}
|
|
12280
12560
|
}
|
|
12281
12561
|
};
|
|
@@ -12521,7 +12801,7 @@ const HEADLINES = {
|
|
|
12521
12801
|
critical: "The hammer is cracked — {n} findings break its edge.",
|
|
12522
12802
|
warning: "The hammer holds — but {n} findings weigh it down.",
|
|
12523
12803
|
trusted: "Held in worthy hands — {n} findings remain.",
|
|
12524
|
-
forged: "
|
|
12804
|
+
forged: "Static score 100 — no findings on the analyzed surface.",
|
|
12525
12805
|
unmeasured: "No tests found — the hammer cannot be weighed."
|
|
12526
12806
|
};
|
|
12527
12807
|
const RUNES = {
|
|
@@ -14062,12 +14342,12 @@ function loadSuppressions(root) {
|
|
|
14062
14342
|
};
|
|
14063
14343
|
}
|
|
14064
14344
|
function renderSuppressions(report) {
|
|
14065
|
-
if (report.total === 0) return "\nNo
|
|
14345
|
+
if (report.total === 0) return "\nNo configured suppression entries.\n";
|
|
14066
14346
|
const lines = [
|
|
14067
14347
|
"",
|
|
14068
14348
|
sectionHeader("QUALITY GOVERNANCE", ui),
|
|
14069
14349
|
"",
|
|
14070
|
-
`
|
|
14350
|
+
`Configured entries: ${report.total}`,
|
|
14071
14351
|
`Active: ${report.active}`,
|
|
14072
14352
|
`Expired: ${report.expired}`,
|
|
14073
14353
|
""
|
|
@@ -14256,6 +14536,31 @@ function loadPlugins(root, gateOpen = false) {
|
|
|
14256
14536
|
* next catalog render (locked by tests/local-rules.spec.ts).
|
|
14257
14537
|
*/
|
|
14258
14538
|
const LOCAL_RULES_DIR = "mjolnir-rules";
|
|
14539
|
+
const MAX_EXTERNAL_RULES = 1e3;
|
|
14540
|
+
const MAX_RULE_FILE_BYTES = 1048576;
|
|
14541
|
+
const MAX_PATTERNS_PER_RULE = 100;
|
|
14542
|
+
function hasNestedQuantifier(pattern) {
|
|
14543
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
14544
|
+
if (pattern.charAt(i) !== "(") continue;
|
|
14545
|
+
let bodyHasQuantifier = false;
|
|
14546
|
+
let j = i + 1;
|
|
14547
|
+
for (; j < pattern.length && pattern.charAt(j) !== ")"; j++) {
|
|
14548
|
+
const bodyChar = pattern.charAt(j);
|
|
14549
|
+
if (bodyChar === "+" || bodyChar === "*" || bodyChar === "?") {
|
|
14550
|
+
bodyHasQuantifier = true;
|
|
14551
|
+
break;
|
|
14552
|
+
}
|
|
14553
|
+
}
|
|
14554
|
+
if (bodyHasQuantifier) {
|
|
14555
|
+
while (j < pattern.length && pattern.charAt(j) !== ")") j++;
|
|
14556
|
+
if (j + 1 < pattern.length) {
|
|
14557
|
+
const next = pattern.charAt(j + 1);
|
|
14558
|
+
if (next === "+" || next === "*" || next === "{") return true;
|
|
14559
|
+
}
|
|
14560
|
+
}
|
|
14561
|
+
}
|
|
14562
|
+
return false;
|
|
14563
|
+
}
|
|
14259
14564
|
const ALLOWED_CATEGORIES = /* @__PURE__ */ new Set([
|
|
14260
14565
|
"QA-TEST",
|
|
14261
14566
|
"QA-TQUAL",
|
|
@@ -14285,7 +14590,7 @@ const ALLOWED_QA_IMPACTS = /* @__PURE__ */ new Set([
|
|
|
14285
14590
|
* Missing directory → empty result (not an error — most workspaces
|
|
14286
14591
|
* carry none).
|
|
14287
14592
|
*/
|
|
14288
|
-
async function loadLocalRules(root, gateOpen =
|
|
14593
|
+
async function loadLocalRules(root, gateOpen = false) {
|
|
14289
14594
|
const result = {
|
|
14290
14595
|
rules: [],
|
|
14291
14596
|
errors: [],
|
|
@@ -14293,6 +14598,15 @@ async function loadLocalRules(root, gateOpen = true) {
|
|
|
14293
14598
|
};
|
|
14294
14599
|
const dir = join(root, LOCAL_RULES_DIR);
|
|
14295
14600
|
if (!existsSync(dir)) return result;
|
|
14601
|
+
try {
|
|
14602
|
+
if (lstatSync(dir).isSymbolicLink()) {
|
|
14603
|
+
result.errors.push(`external rules directory "${LOCAL_RULES_DIR}/" is a symlink — skipped`);
|
|
14604
|
+
return result;
|
|
14605
|
+
}
|
|
14606
|
+
} catch (err) {
|
|
14607
|
+
result.errors.push(`external rules directory "${LOCAL_RULES_DIR}/" could not be inspected: ${err instanceof Error ? err.message : String(err)}`);
|
|
14608
|
+
return result;
|
|
14609
|
+
}
|
|
14296
14610
|
let entries;
|
|
14297
14611
|
try {
|
|
14298
14612
|
entries = readdirSync(dir);
|
|
@@ -14301,7 +14615,20 @@ async function loadLocalRules(root, gateOpen = true) {
|
|
|
14301
14615
|
return result;
|
|
14302
14616
|
}
|
|
14303
14617
|
for (const entry of entries.sort()) {
|
|
14618
|
+
if (result.rules.length >= MAX_EXTERNAL_RULES) {
|
|
14619
|
+
result.errors.push(`external rule budget exceeded (${MAX_EXTERNAL_RULES})`);
|
|
14620
|
+
break;
|
|
14621
|
+
}
|
|
14304
14622
|
const path = join(dir, entry);
|
|
14623
|
+
try {
|
|
14624
|
+
if (lstatSync(path).isSymbolicLink()) {
|
|
14625
|
+
result.errors.push(`external rule "${LOCAL_RULES_DIR}/${entry}" is a symlink — skipped`);
|
|
14626
|
+
continue;
|
|
14627
|
+
}
|
|
14628
|
+
} catch (err) {
|
|
14629
|
+
result.errors.push(`external rule "${LOCAL_RULES_DIR}/${entry}" could not be inspected: ${err instanceof Error ? err.message : String(err)}`);
|
|
14630
|
+
continue;
|
|
14631
|
+
}
|
|
14305
14632
|
if (entry.endsWith(".json")) loadJsonRule(path, result);
|
|
14306
14633
|
else if (entry.endsWith(".mjs") || entry.endsWith(".js")) {
|
|
14307
14634
|
if (!gateOpen) result.skipped.push(`${LOCAL_RULES_DIR}/${entry}`);
|
|
@@ -14314,6 +14641,10 @@ function loadJsonRule(path, result) {
|
|
|
14314
14641
|
const name = `${LOCAL_RULES_DIR}/${path.split(/[\\/]/).pop()}`;
|
|
14315
14642
|
let raw;
|
|
14316
14643
|
try {
|
|
14644
|
+
if (lstatSync(path).size > MAX_RULE_FILE_BYTES) {
|
|
14645
|
+
result.errors.push(`external rule "${name}" exceeds the file size budget`);
|
|
14646
|
+
return;
|
|
14647
|
+
}
|
|
14317
14648
|
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
14318
14649
|
} catch (err) {
|
|
14319
14650
|
result.errors.push(`external rule "${name}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -14334,7 +14665,7 @@ function loadJsonRule(path, result) {
|
|
|
14334
14665
|
return;
|
|
14335
14666
|
}
|
|
14336
14667
|
const patterns = decl["patterns"];
|
|
14337
|
-
if (!Array.isArray(patterns) || patterns.length === 0 || !patterns.every((p) => typeof p === "string" && p.length > 0)) {
|
|
14668
|
+
if (!Array.isArray(patterns) || patterns.length === 0 || patterns.length > MAX_PATTERNS_PER_RULE || !patterns.every((p) => typeof p === "string" && p.length > 0)) {
|
|
14338
14669
|
result.errors.push(`external rule ${id} must declare a non-empty "patterns" array of regex source strings.`);
|
|
14339
14670
|
return;
|
|
14340
14671
|
}
|
|
@@ -14350,6 +14681,10 @@ function loadJsonRule(path, result) {
|
|
|
14350
14681
|
result.errors.push(`external rule ${id} has a pattern longer than 512 chars — likely a copy-paste error.`);
|
|
14351
14682
|
return;
|
|
14352
14683
|
}
|
|
14684
|
+
if (hasNestedQuantifier(p)) {
|
|
14685
|
+
result.errors.push(`external rule ${id} has a nested quantifier that may cause catastrophic backtracking`);
|
|
14686
|
+
return;
|
|
14687
|
+
}
|
|
14353
14688
|
const quantifiers = p.match(/[+*?]|\{\d/g);
|
|
14354
14689
|
if (quantifiers && quantifiers.length > 64) {
|
|
14355
14690
|
result.errors.push(`external rule ${id} has a pattern with more than 64 quantifier/wildcard tokens — likely catastrophic backtracking.`);
|
|
@@ -14547,6 +14882,8 @@ var scan_pipeline_exports = /* @__PURE__ */ __exportAll({
|
|
|
14547
14882
|
summarizeForensicVerdicts: () => summarizeForensicVerdicts
|
|
14548
14883
|
});
|
|
14549
14884
|
const UNIVERSAL_RULES = RULES.map(asUniversal);
|
|
14885
|
+
const DEFAULT_MAX_DURATION_MS = 6e5;
|
|
14886
|
+
const MAX_DURATION_MS = 36e5;
|
|
14550
14887
|
/** Registered rule IDs — used to warn on unknown severityOverrides keys (M4). */
|
|
14551
14888
|
const KNOWN_RULE_IDS = new Set(RULES.map((r) => r.id));
|
|
14552
14889
|
/**
|
|
@@ -14763,7 +15100,9 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14763
15100
|
let testDeclarationCount = 0;
|
|
14764
15101
|
let rulesPartial = false;
|
|
14765
15102
|
let parseFailed = 0;
|
|
15103
|
+
let parseFallbacks = 0;
|
|
14766
15104
|
let scanned = 0;
|
|
15105
|
+
let analyzed = 0;
|
|
14767
15106
|
for (const path of testFiles) {
|
|
14768
15107
|
if (Date.now() > deadline) {
|
|
14769
15108
|
rulesPartial = true;
|
|
@@ -14777,7 +15116,12 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14777
15116
|
if (!isCiAdapter) testFileCount++;
|
|
14778
15117
|
let text;
|
|
14779
15118
|
try {
|
|
14780
|
-
|
|
15119
|
+
const readResult = readFileBounded(path, LIMITS$1.maxFileBytes);
|
|
15120
|
+
if (!readResult.ok) {
|
|
15121
|
+
skippedFiles++;
|
|
15122
|
+
continue;
|
|
15123
|
+
}
|
|
15124
|
+
text = readResult.data.toString("utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
|
|
14781
15125
|
} catch {
|
|
14782
15126
|
skippedFiles++;
|
|
14783
15127
|
continue;
|
|
@@ -14811,6 +15155,7 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14811
15155
|
const cachedFindings = cache.lookup(cacheKey);
|
|
14812
15156
|
if (cachedFindings) {
|
|
14813
15157
|
for (const f of cachedFindings) findings.push(f);
|
|
15158
|
+
analyzed++;
|
|
14814
15159
|
continue;
|
|
14815
15160
|
}
|
|
14816
15161
|
hooks.onProgress?.({
|
|
@@ -14837,10 +15182,12 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14837
15182
|
}
|
|
14838
15183
|
const actualMode = parsed ? "ast" : "regex";
|
|
14839
15184
|
if (wantsAst && actualMode === "regex") {
|
|
15185
|
+
parseFallbacks++;
|
|
14840
15186
|
cacheKey = fileCacheKey(rulesDigest, text, identity(actualMode));
|
|
14841
15187
|
const fallbackFindings = cache.lookup(cacheKey);
|
|
14842
15188
|
if (fallbackFindings) {
|
|
14843
15189
|
for (const f of fallbackFindings) findings.push(f);
|
|
15190
|
+
analyzed++;
|
|
14844
15191
|
continue;
|
|
14845
15192
|
}
|
|
14846
15193
|
}
|
|
@@ -14871,8 +15218,10 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14871
15218
|
fileBudgetExceeded = true;
|
|
14872
15219
|
}
|
|
14873
15220
|
});
|
|
15221
|
+
if (!fileRuleFailed && !fileBudgetExceeded) analyzed++;
|
|
14874
15222
|
if (!fileRuleFailed) cache.store(cacheKey, findings.slice(findingsStart), fileBudgetExceeded);
|
|
14875
15223
|
} catch {
|
|
15224
|
+
if (wantsAst) parseFallbacks++;
|
|
14876
15225
|
skippedFiles++;
|
|
14877
15226
|
parseFailed++;
|
|
14878
15227
|
} finally {
|
|
@@ -14885,7 +15234,9 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14885
15234
|
testDeclarationCount,
|
|
14886
15235
|
rulesPartial,
|
|
14887
15236
|
parseFailed,
|
|
14888
|
-
|
|
15237
|
+
parseFallbacks,
|
|
15238
|
+
scanned,
|
|
15239
|
+
analyzed
|
|
14889
15240
|
};
|
|
14890
15241
|
}
|
|
14891
15242
|
/**
|
|
@@ -14931,13 +15282,15 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
|
|
|
14931
15282
|
for (const w of warnings) hooks.onConfigWarning?.(w);
|
|
14932
15283
|
applySeverityOverrides(findings, config);
|
|
14933
15284
|
const active = loadSuppressions(workspace.root).entries.filter((e) => e.status === "active");
|
|
14934
|
-
|
|
15285
|
+
hooks.onPreSuppressionFindings?.([...findings]);
|
|
15286
|
+
let suppressionCount = 0;
|
|
14935
15287
|
if (active.length > 0) {
|
|
14936
15288
|
const ruleOnly = new Set(active.filter((e) => !e.files?.length).map((e) => e.ruleId));
|
|
14937
15289
|
const kept = findings.filter((f) => {
|
|
14938
15290
|
if (ruleOnly.has(f.ruleId)) return false;
|
|
14939
15291
|
return !active.some((e) => e.files?.length && e.ruleId === f.ruleId && e.files.some((g) => pathMatchesGlob(f.file, g)));
|
|
14940
15292
|
});
|
|
15293
|
+
suppressionCount = findings.length - kept.length;
|
|
14941
15294
|
findings.length = 0;
|
|
14942
15295
|
for (const f of kept) findings.push(f);
|
|
14943
15296
|
}
|
|
@@ -14961,6 +15314,7 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
|
|
|
14961
15314
|
for (const f of findings) f.fixGroupId = f.ruleId;
|
|
14962
15315
|
const discoveredReport = discoverAndParseRuntimeReport(scanRoot.root);
|
|
14963
15316
|
const runtimeReportPath = discoveredReport?.path;
|
|
15317
|
+
const runtimeIncomplete = discoveredReport !== void 0 && discoveredReport.report.analysisComplete !== true;
|
|
14964
15318
|
let forensicVerdicts;
|
|
14965
15319
|
if (discoveredReport && discoveredReport.report.analysisComplete === true) try {
|
|
14966
15320
|
buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
|
|
@@ -14973,6 +15327,7 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
|
|
|
14973
15327
|
suppressionCount,
|
|
14974
15328
|
frameworks,
|
|
14975
15329
|
runtimeReportPath,
|
|
15330
|
+
runtimeIncomplete,
|
|
14976
15331
|
forensicVerdicts,
|
|
14977
15332
|
config
|
|
14978
15333
|
};
|
|
@@ -15041,10 +15396,13 @@ function assembleScanResult(o) {
|
|
|
15041
15396
|
if (o.scopeIgnored > 0) scopeReasons.push(`ignored:${o.scopeIgnored}`);
|
|
15042
15397
|
if (o.scopeUnrecognized > 0) scopeReasons.push(`unrecognized:${o.scopeUnrecognized}`);
|
|
15043
15398
|
if (o.parseFailed > 0) scopeReasons.push(`parseFailed:${o.parseFailed}`);
|
|
15399
|
+
if (o.skippedFiles > 0) scopeReasons.push(`skipped:${o.skippedFiles}`);
|
|
15400
|
+
if (o.scopeInfo.degraded) scopeReasons.push(`degraded:${o.scopeInfo.degraded}`);
|
|
15401
|
+
if (o.runtimeIncomplete) scopeReasons.push("runtime-incomplete");
|
|
15044
15402
|
for (const reason of o.truncationReasons) scopeReasons.push(`truncated:${reason}`);
|
|
15045
15403
|
const scopeIntegrity = {
|
|
15046
15404
|
discovered: o.testFiles.length,
|
|
15047
|
-
analyzed: Math.max(0, o.scanned),
|
|
15405
|
+
analyzed: Math.max(0, o.analyzed ?? o.scanned),
|
|
15048
15406
|
ignored: o.scopeIgnored,
|
|
15049
15407
|
unrecognized: o.scopeUnrecognized,
|
|
15050
15408
|
parseFailed: o.parseFailed,
|
|
@@ -15053,28 +15411,59 @@ function assembleScanResult(o) {
|
|
|
15053
15411
|
...scopeReasons.length > 0 ? { reasons: scopeReasons } : {}
|
|
15054
15412
|
};
|
|
15055
15413
|
const scopeAdjustedTotal = scopeReasons.length > 0 && total >= 100 ? 99 : total;
|
|
15414
|
+
let identityIncomplete = false;
|
|
15056
15415
|
const inputSnapshot = o.testFiles.map((p) => {
|
|
15057
15416
|
const relPath = relative(o.workspace.root, p).replaceAll("\\", "/");
|
|
15058
15417
|
try {
|
|
15059
|
-
const
|
|
15060
|
-
|
|
15418
|
+
const read = readFileBounded(resolve(o.workspace.root, p), LIMITS$1.maxFileBytes);
|
|
15419
|
+
if (!read.ok) {
|
|
15420
|
+
if (existsSync(o.workspace.root)) identityIncomplete = true;
|
|
15421
|
+
return {
|
|
15422
|
+
path: relPath,
|
|
15423
|
+
size: 0,
|
|
15424
|
+
hash: "UNAVAILABLE"
|
|
15425
|
+
};
|
|
15426
|
+
}
|
|
15427
|
+
const hash = createHash("sha256").update(read.data).digest("hex");
|
|
15061
15428
|
return {
|
|
15062
15429
|
path: relPath,
|
|
15063
|
-
size:
|
|
15430
|
+
size: read.data.length,
|
|
15064
15431
|
hash
|
|
15065
15432
|
};
|
|
15066
15433
|
} catch {
|
|
15434
|
+
if (existsSync(o.workspace.root)) identityIncomplete = true;
|
|
15067
15435
|
return {
|
|
15068
15436
|
path: relPath,
|
|
15069
|
-
size: 0
|
|
15437
|
+
size: 0,
|
|
15438
|
+
hash: "UNAVAILABLE"
|
|
15070
15439
|
};
|
|
15071
15440
|
}
|
|
15072
15441
|
});
|
|
15073
15442
|
let reportDigest;
|
|
15074
15443
|
if (o.runtimeReportPath) try {
|
|
15075
|
-
const
|
|
15076
|
-
|
|
15077
|
-
|
|
15444
|
+
const reportPath = resolve(o.workspace.root, o.runtimeReportPath);
|
|
15445
|
+
if (lstatSync(reportPath).isFile()) {
|
|
15446
|
+
const read = readFileBounded(reportPath, LIMITS$1.maxFileBytes);
|
|
15447
|
+
if (!read.ok) identityIncomplete = true;
|
|
15448
|
+
else reportDigest = createHash("sha256").update(read.data).digest("hex");
|
|
15449
|
+
}
|
|
15450
|
+
} catch {
|
|
15451
|
+
identityIncomplete = true;
|
|
15452
|
+
}
|
|
15453
|
+
const completion = deriveCompletion({
|
|
15454
|
+
discoveryTruncated: o.discoveryTruncated,
|
|
15455
|
+
rulesPartial: o.rulesPartial,
|
|
15456
|
+
skippedFiles: o.skippedFiles,
|
|
15457
|
+
rulesCrashed: o.rulesCrashed,
|
|
15458
|
+
truncationReasons: o.truncationReasons,
|
|
15459
|
+
scopeIgnored: o.scopeIgnored,
|
|
15460
|
+
scopeUnrecognized: o.scopeUnrecognized,
|
|
15461
|
+
parseFailed: o.parseFailed,
|
|
15462
|
+
parseFallbacks: o.parseFallbacks ?? 0,
|
|
15463
|
+
...o.scopeInfo.degraded !== void 0 ? { scopeDegraded: o.scopeInfo.degraded } : {},
|
|
15464
|
+
...o.runtimeIncomplete !== void 0 ? { runtimeIncomplete: o.runtimeIncomplete } : {},
|
|
15465
|
+
identityIncomplete
|
|
15466
|
+
});
|
|
15078
15467
|
const runIdentity = buildRunIdentity({
|
|
15079
15468
|
files: inputSnapshot,
|
|
15080
15469
|
rules: [...o.REVISION_BY_RULE_ID.entries()].map(([id, detectorRevision]) => ({
|
|
@@ -15092,13 +15481,14 @@ function assembleScanResult(o) {
|
|
|
15092
15481
|
const evidenceGraph = buildEvidenceGraph({ runId: runIdentity });
|
|
15093
15482
|
const hasTests = o.testFileCount > 0 && o.testDeclarationCount > 0;
|
|
15094
15483
|
const suiteInvalidatedBy = [...new Set(o.findings.filter((f) => SUITE_INVALIDATING_RULE_IDS.has(f.ruleId)).map((f) => f.ruleId))].sort();
|
|
15484
|
+
const finalScore = hasTests ? completion.partial && scopeAdjustedTotal >= 100 ? 99 : scopeAdjustedTotal : null;
|
|
15095
15485
|
const result = {
|
|
15096
15486
|
schemaVersion: 1,
|
|
15097
|
-
partial:
|
|
15487
|
+
partial: completion.partial,
|
|
15098
15488
|
scopeIntegrity,
|
|
15099
15489
|
runIdentity,
|
|
15100
15490
|
evidenceGraph,
|
|
15101
|
-
score:
|
|
15491
|
+
score: finalScore,
|
|
15102
15492
|
...hasTests ? {} : { reason: "no-tests-found" },
|
|
15103
15493
|
frameworks: o.frameworks.frameworks,
|
|
15104
15494
|
frameworkDetectionUnknown: o.frameworks.unknown,
|
|
@@ -15124,12 +15514,8 @@ function assembleScanResult(o) {
|
|
|
15124
15514
|
file: o.cache.stats.file
|
|
15125
15515
|
} } : {},
|
|
15126
15516
|
analysisStatus: {
|
|
15127
|
-
|
|
15128
|
-
|
|
15129
|
-
skippedFiles: o.skippedFiles,
|
|
15130
|
-
durationMs: elapsed,
|
|
15131
|
-
rulesCrashed: o.rulesCrashed,
|
|
15132
|
-
...o.truncationReasons.size > 0 ? { truncationReasons: [...o.truncationReasons].sort() } : {}
|
|
15517
|
+
...completion.analysisStatus,
|
|
15518
|
+
durationMs: elapsed
|
|
15133
15519
|
},
|
|
15134
15520
|
scoringModelVersion: SCORING_MODEL_VERSION
|
|
15135
15521
|
};
|
|
@@ -15177,7 +15563,8 @@ function assembleScanResult(o) {
|
|
|
15177
15563
|
*/
|
|
15178
15564
|
async function runScan(args, hooks = {}) {
|
|
15179
15565
|
const started = Date.now();
|
|
15180
|
-
const
|
|
15566
|
+
const requestedDuration = Number.isFinite(args.maxDurationMs) ? args.maxDurationMs : DEFAULT_MAX_DURATION_MS;
|
|
15567
|
+
const deadline = started + Math.min(Math.max(1, requestedDuration), MAX_DURATION_MS);
|
|
15181
15568
|
const discovered = discoverWorkspace(args.target);
|
|
15182
15569
|
const targetAbs = resolve(args.target);
|
|
15183
15570
|
const scanRoot = discovered && discovered.root !== targetAbs && targetAbs.startsWith(discovered.root + sep) ? {
|
|
@@ -15256,6 +15643,7 @@ async function runScan(args, hooks = {}) {
|
|
|
15256
15643
|
scopeUnrecognized++;
|
|
15257
15644
|
}
|
|
15258
15645
|
});
|
|
15646
|
+
hooks.onTestFilesDiscovered?.(testFiles);
|
|
15259
15647
|
const analysis = await runFileAnalysisPhase(findings, testFiles, workspace, activeRules, hooks, cache, rulesDigest, deadline, truncationReasons, declarationsByFile, fileProvenance, (ruleId, file, error) => {
|
|
15260
15648
|
rulesCrashed++;
|
|
15261
15649
|
hooks.onRuleCrash?.(ruleId, file, error);
|
|
@@ -15265,7 +15653,9 @@ async function runScan(args, hooks = {}) {
|
|
|
15265
15653
|
testDeclarationCount = analysis.testDeclarationCount;
|
|
15266
15654
|
rulesPartial = analysis.rulesPartial;
|
|
15267
15655
|
parseFailed = analysis.parseFailed;
|
|
15656
|
+
const parseFallbacks = analysis.parseFallbacks;
|
|
15268
15657
|
const scanned = analysis.scanned;
|
|
15658
|
+
const analyzed = analysis.analyzed;
|
|
15269
15659
|
const postScan = applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, declarationsByFile, testDeclarationCount, tierByRuleId, REVISION_BY_RULE_ID);
|
|
15270
15660
|
testDeclarationCount = postScan.testDeclarationCount;
|
|
15271
15661
|
const result = assembleScanResult({
|
|
@@ -15281,7 +15671,9 @@ async function runScan(args, hooks = {}) {
|
|
|
15281
15671
|
scopeIgnored,
|
|
15282
15672
|
scopeUnrecognized,
|
|
15283
15673
|
parseFailed,
|
|
15674
|
+
parseFallbacks,
|
|
15284
15675
|
scanned,
|
|
15676
|
+
analyzed,
|
|
15285
15677
|
testFiles,
|
|
15286
15678
|
workspace,
|
|
15287
15679
|
scanRoot,
|
|
@@ -15294,6 +15686,7 @@ async function runScan(args, hooks = {}) {
|
|
|
15294
15686
|
suppressionCount: postScan.suppressionCount,
|
|
15295
15687
|
frameworks: postScan.frameworks,
|
|
15296
15688
|
runtimeReportPath: postScan.runtimeReportPath,
|
|
15689
|
+
runtimeIncomplete: postScan.runtimeIncomplete,
|
|
15297
15690
|
forensicVerdicts: postScan.forensicVerdicts,
|
|
15298
15691
|
config: postScan.config,
|
|
15299
15692
|
fileProvenance,
|
|
@@ -15306,4 +15699,4 @@ async function runScan(args, hooks = {}) {
|
|
|
15306
15699
|
return result;
|
|
15307
15700
|
}
|
|
15308
15701
|
//#endregion
|
|
15309
|
-
export {
|
|
15702
|
+
export { correlateFindings as $, plainContext as A, parseJsonFile as At, shouldUseAscii as B, isValidCategory as Bt, runForensics as C, globToRegExp as Ct, nextStep as D, loadConfig as Dt, buildFooter as E, isSuppressionActive as Et, padTo as F, QA_IMPACT_LABELS as Ft, EVIDENCE as G, deriveScoreState as H, palette as I, RULE_CATEGORIES as It, atomicTempPath as J, TINT as K, sanitizeData as L, SEVERITY_ORDER as Lt, severityIcon as M, buildEvidenceGraph as Mt, box as N, MEASURED_FP as Nt, okIcon as O, readFileBounded as Ot, measure as P, DEDUCTIONS as Pt, getReachableFiles as Q, scoreGauge as R, deriveEvidenceLevel as Rt, renderSuppressions as S, createIgnoreMatcher as St, FLAKE_GLYPH as T, ConfigValidationError as Tt, headlineFor as U, wrapText as V, BADGE_BAND as W, writeFileAtomic as X, sweepStaleTempFiles as Y, buildDependencyGraph as Z, summarizeForensicVerdicts as _, computeCodeText as _t, applyPostScanProcessing as a, massCeiling as at, loadLocalRules as b, DEFAULT_IGNORE_MATCHER as bt, discoverAndParseRuntimeReport as c, getRule as ct, isValidFindingRecord as d, SCAN_ADAPTERS as dt, classifyProvenance as et, pathMatchesGlob as f, SEARCHED_FOR as ft, selectAdapter as g, parseYamlGuarded as gt, scan_pipeline_exports as h, parseWorkflow as ht, SUITE_INVALIDATING_RULE_IDS as i, deductionFor as it, sectionHeader as j, ENGINE_VERSION as jt, panel as k, isRecord as kt, discoverTestFilesPhase as l, resolveGitPath as lt, runScan as m, parseAzurePipeline as mt, KNOWN_RULE_IDS as n, capForTier as nt, assembleScanResult as o, RETIRED_RULE_IDS as ot, runFileAnalysisPhase as p, isAzurePipelineFixture as pt, TRUST as q, OVERLAP_META_BY_RULE_ID as r, computeDimensions as rt, buildUniversalRules as s, RULES as st, EVIDENCE_OVERRIDES as t, computeAgenticProfile as tt, fallbackWorkspace as u, runGit as ut, pluginsGateOpen as v, parseTsFile as vt, sanitizeErrorText as w, isLintFixtureDir as wt, loadSuppressions as x, LIMITS$1 as xt, renderGateNotice as y, detectFrameworks as yt, shouldColorize as z, isAdvisoryFinding as zt };
|