mjolnir-qa 2.1.0 → 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 +54 -0
- package/dist/cli.d.mts +15 -3
- package/dist/cli.mjs +636 -390
- package/dist/mcp/stdio.mjs +436 -58
- package/dist/{scan-pipeline-MQGUSey3.mjs → scan-pipeline-CAH9_Qgh.mjs} +421 -51
- package/dist/scan-pipeline-CRe7-MQn.mjs +2 -0
- package/package.json +12 -5
- package/dist/scan-pipeline-C_Rs4u8N.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,
|
|
@@ -1542,8 +1639,9 @@ function sharedWalk(options) {
|
|
|
1542
1639
|
}
|
|
1543
1640
|
const full = join(dir, entry.name);
|
|
1544
1641
|
const rel = relative(options.root, full).replaceAll("\\", "/");
|
|
1642
|
+
if (entry.isDirectory() && options.skipDirs.includes(entry.name)) continue;
|
|
1545
1643
|
if (options.ignoreMatcher.isIgnored(rel)) {
|
|
1546
|
-
options.onIgnored?.();
|
|
1644
|
+
if (!rel.startsWith(".mjolnir/")) options.onIgnored?.(rel);
|
|
1547
1645
|
continue;
|
|
1548
1646
|
}
|
|
1549
1647
|
if (entry.isSymbolicLink()) {
|
|
@@ -1551,7 +1649,6 @@ function sharedWalk(options) {
|
|
|
1551
1649
|
continue;
|
|
1552
1650
|
}
|
|
1553
1651
|
if (entry.isDirectory()) {
|
|
1554
|
-
if (options.skipDirs.includes(entry.name)) continue;
|
|
1555
1652
|
if (rel.split("/").length > LIMITS$1.maxDepth) {
|
|
1556
1653
|
options.onSkipped("max-depth");
|
|
1557
1654
|
continue;
|
|
@@ -1569,7 +1666,7 @@ function sharedWalk(options) {
|
|
|
1569
1666
|
} catch {
|
|
1570
1667
|
options.onSkipped("stat-failed");
|
|
1571
1668
|
}
|
|
1572
|
-
else if (entry.isFile()) options.onUnrecognized?.();
|
|
1669
|
+
else if (entry.isFile()) options.onUnrecognized?.(rel);
|
|
1573
1670
|
}
|
|
1574
1671
|
};
|
|
1575
1672
|
walk(options.root);
|
|
@@ -3671,7 +3768,7 @@ const SCAN_ADAPTERS = [
|
|
|
3671
3768
|
* the adapter that claims it; per-adapter caps still apply.
|
|
3672
3769
|
*/
|
|
3673
3770
|
function discoverAllTestFiles(ctx, languageAdapters, buckets, fixtureDirMemo) {
|
|
3674
|
-
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"])];
|
|
3675
3772
|
sharedWalk({
|
|
3676
3773
|
root: ctx.workspace.root,
|
|
3677
3774
|
deadline: ctx.deadline,
|
|
@@ -3697,10 +3794,35 @@ function discoverAllTestFiles(ctx, languageAdapters, buckets, fixtureDirMemo) {
|
|
|
3697
3794
|
},
|
|
3698
3795
|
isFull: () => languageAdapters.every((a) => (buckets.get(a.id)?.length ?? 0) >= ctx.maxFiles),
|
|
3699
3796
|
fixtureDirMemo,
|
|
3700
|
-
onIgnored:
|
|
3701
|
-
|
|
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
|
+
}
|
|
3702
3803
|
});
|
|
3703
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
|
+
}
|
|
3704
3826
|
/** Whether ANY shipped adapter would discover this path as a test file. */
|
|
3705
3827
|
function isKnownTestFile(path) {
|
|
3706
3828
|
return SCAN_ADAPTERS.some((a) => a.isTestFile(path));
|
|
@@ -3764,17 +3886,14 @@ const SEARCHED_FOR = [
|
|
|
3764
3886
|
* The fix: resolve git ONCE per process to an ABSOLUTE path by walking
|
|
3765
3887
|
* the PATH directly (never consulting the CWD), verify the candidate is
|
|
3766
3888
|
* an existing file, and pass that path to execFileSync. Resolution is
|
|
3767
|
-
* memoized; a failure to find a real git anywhere on PATH
|
|
3768
|
-
*
|
|
3769
|
-
* — degraded git data already means full-file attribution, never a
|
|
3770
|
-
* crash.
|
|
3889
|
+
* memoized; a failure to find a real git anywhere on PATH is recorded
|
|
3890
|
+
* and callers degrade without invoking a bare name.
|
|
3771
3891
|
*/
|
|
3772
3892
|
let resolvedGit = void 0;
|
|
3773
3893
|
/**
|
|
3774
3894
|
* The absolute path of the git binary Mjölnir will invoke, or null when
|
|
3775
|
-
* PATH carries no executable `git` at all
|
|
3776
|
-
*
|
|
3777
|
-
* as before, minus the CWD-hijack surface).
|
|
3895
|
+
* PATH carries no executable `git` at all. Callers must degrade without
|
|
3896
|
+
* invoking a bare name.
|
|
3778
3897
|
*/
|
|
3779
3898
|
function resolveGitPath() {
|
|
3780
3899
|
if (resolvedGit !== void 0) return resolvedGit;
|
|
@@ -3807,7 +3926,8 @@ function resolveGitPath() {
|
|
|
3807
3926
|
* the previous inline `git()` helpers, with the hijack surface closed.
|
|
3808
3927
|
*/
|
|
3809
3928
|
function runGit(root, args) {
|
|
3810
|
-
const exe = resolveGitPath()
|
|
3929
|
+
const exe = resolveGitPath();
|
|
3930
|
+
if (!exe) return null;
|
|
3811
3931
|
try {
|
|
3812
3932
|
return execFileSync(exe, [
|
|
3813
3933
|
"-C",
|
|
@@ -3943,7 +4063,7 @@ function computeChangedScope(root, baseBranch) {
|
|
|
3943
4063
|
"--",
|
|
3944
4064
|
...chunk
|
|
3945
4065
|
]);
|
|
3946
|
-
if (output === null)
|
|
4066
|
+
if (output === null) return null;
|
|
3947
4067
|
const sections = output.split("\ndiff --git ");
|
|
3948
4068
|
for (const section of sections) {
|
|
3949
4069
|
const body = section.startsWith("diff --git ") ? section : `diff --git ${section}`;
|
|
@@ -3971,6 +4091,11 @@ function computeChangedScope(root, baseBranch) {
|
|
|
3971
4091
|
"--unified=0",
|
|
3972
4092
|
"HEAD"
|
|
3973
4093
|
], changedFiles);
|
|
4094
|
+
if (committedDiffs === null || workingDiffs === null) return {
|
|
4095
|
+
changed: {},
|
|
4096
|
+
degraded: true,
|
|
4097
|
+
reason: "diff-failed"
|
|
4098
|
+
};
|
|
3974
4099
|
for (const file of changedFiles) {
|
|
3975
4100
|
const committedDiff = committedDiffs.get(file);
|
|
3976
4101
|
const workingDiff = workingDiffs.get(file);
|
|
@@ -3996,8 +4121,11 @@ const LIMITS_MAX_LINES = 1e6;
|
|
|
3996
4121
|
function allLinesOf(root, file) {
|
|
3997
4122
|
const full = join(root, file);
|
|
3998
4123
|
try {
|
|
3999
|
-
|
|
4000
|
-
|
|
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;
|
|
4001
4129
|
return Array.from({ length: lineCount }, (_, i) => i + 1);
|
|
4002
4130
|
} catch {
|
|
4003
4131
|
return null;
|
|
@@ -12107,6 +12235,122 @@ function configurableAggregation(results, config) {
|
|
|
12107
12235
|
};
|
|
12108
12236
|
}
|
|
12109
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
|
|
12110
12354
|
//#region src/engine/scan-cache.ts
|
|
12111
12355
|
/**
|
|
12112
12356
|
* Local incremental scan cache (Beta-to-Stable 1.0 plan, M5.2 / A-2).
|
|
@@ -12142,6 +12386,17 @@ const MAX_ENTRIES$1 = 4096;
|
|
|
12142
12386
|
* serialized size; the newest entries win (real LRU-by-use).
|
|
12143
12387
|
*/
|
|
12144
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
|
+
}
|
|
12145
12400
|
/** sha256 hex of a string — the only hash this module needs. */
|
|
12146
12401
|
function sha256(text) {
|
|
12147
12402
|
return createHash("sha256").update(text).digest("hex");
|
|
@@ -12240,12 +12495,15 @@ function createScanCache(root) {
|
|
|
12240
12495
|
let totalBytes = 0;
|
|
12241
12496
|
try {
|
|
12242
12497
|
if (existsSync(file)) {
|
|
12243
|
-
|
|
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;
|
|
12244
12501
|
for (const [k, v] of Object.entries(entries)) {
|
|
12245
|
-
const size = JSON.stringify(v)
|
|
12502
|
+
const size = Buffer.byteLength(JSON.stringify(v), "utf8") + k.length + 4;
|
|
12246
12503
|
entryBytes.set(k, size);
|
|
12247
12504
|
totalBytes += size;
|
|
12248
12505
|
}
|
|
12506
|
+
if (totalBytes > MAX_TOTAL_BYTES) throw new Error("cache byte budget exceeded");
|
|
12249
12507
|
}
|
|
12250
12508
|
} catch {
|
|
12251
12509
|
entries = {};
|
|
@@ -12271,11 +12529,13 @@ function createScanCache(root) {
|
|
|
12271
12529
|
},
|
|
12272
12530
|
store(key, findings, fileBudgetExceeded) {
|
|
12273
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;
|
|
12274
12536
|
const replacedBytes = entryBytes.get(key) ?? 0;
|
|
12275
12537
|
delete entries[key];
|
|
12276
|
-
const entryJson = JSON.stringify(findings);
|
|
12277
12538
|
entries[key] = { findings: structuredClone(findings) };
|
|
12278
|
-
const newBytes = entryJson.length + key.length + 4;
|
|
12279
12539
|
entryBytes.set(key, newBytes);
|
|
12280
12540
|
totalBytes = totalBytes - replacedBytes + newBytes;
|
|
12281
12541
|
let count = Object.keys(entries).length;
|
|
@@ -12292,10 +12552,10 @@ function createScanCache(root) {
|
|
|
12292
12552
|
if (!dirty) return;
|
|
12293
12553
|
try {
|
|
12294
12554
|
mkdirSync(dir, { recursive: true });
|
|
12295
|
-
|
|
12555
|
+
writeFileAtomic(file, JSON.stringify({
|
|
12296
12556
|
version: CACHE_VERSION,
|
|
12297
12557
|
entries
|
|
12298
|
-
}), "utf8");
|
|
12558
|
+
}), { encoding: "utf8" });
|
|
12299
12559
|
} catch {}
|
|
12300
12560
|
}
|
|
12301
12561
|
};
|
|
@@ -14276,6 +14536,31 @@ function loadPlugins(root, gateOpen = false) {
|
|
|
14276
14536
|
* next catalog render (locked by tests/local-rules.spec.ts).
|
|
14277
14537
|
*/
|
|
14278
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
|
+
}
|
|
14279
14564
|
const ALLOWED_CATEGORIES = /* @__PURE__ */ new Set([
|
|
14280
14565
|
"QA-TEST",
|
|
14281
14566
|
"QA-TQUAL",
|
|
@@ -14305,7 +14590,7 @@ const ALLOWED_QA_IMPACTS = /* @__PURE__ */ new Set([
|
|
|
14305
14590
|
* Missing directory → empty result (not an error — most workspaces
|
|
14306
14591
|
* carry none).
|
|
14307
14592
|
*/
|
|
14308
|
-
async function loadLocalRules(root, gateOpen =
|
|
14593
|
+
async function loadLocalRules(root, gateOpen = false) {
|
|
14309
14594
|
const result = {
|
|
14310
14595
|
rules: [],
|
|
14311
14596
|
errors: [],
|
|
@@ -14313,6 +14598,15 @@ async function loadLocalRules(root, gateOpen = true) {
|
|
|
14313
14598
|
};
|
|
14314
14599
|
const dir = join(root, LOCAL_RULES_DIR);
|
|
14315
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
|
+
}
|
|
14316
14610
|
let entries;
|
|
14317
14611
|
try {
|
|
14318
14612
|
entries = readdirSync(dir);
|
|
@@ -14321,7 +14615,20 @@ async function loadLocalRules(root, gateOpen = true) {
|
|
|
14321
14615
|
return result;
|
|
14322
14616
|
}
|
|
14323
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
|
+
}
|
|
14324
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
|
+
}
|
|
14325
14632
|
if (entry.endsWith(".json")) loadJsonRule(path, result);
|
|
14326
14633
|
else if (entry.endsWith(".mjs") || entry.endsWith(".js")) {
|
|
14327
14634
|
if (!gateOpen) result.skipped.push(`${LOCAL_RULES_DIR}/${entry}`);
|
|
@@ -14334,6 +14641,10 @@ function loadJsonRule(path, result) {
|
|
|
14334
14641
|
const name = `${LOCAL_RULES_DIR}/${path.split(/[\\/]/).pop()}`;
|
|
14335
14642
|
let raw;
|
|
14336
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
|
+
}
|
|
14337
14648
|
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
14338
14649
|
} catch (err) {
|
|
14339
14650
|
result.errors.push(`external rule "${name}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -14354,7 +14665,7 @@ function loadJsonRule(path, result) {
|
|
|
14354
14665
|
return;
|
|
14355
14666
|
}
|
|
14356
14667
|
const patterns = decl["patterns"];
|
|
14357
|
-
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)) {
|
|
14358
14669
|
result.errors.push(`external rule ${id} must declare a non-empty "patterns" array of regex source strings.`);
|
|
14359
14670
|
return;
|
|
14360
14671
|
}
|
|
@@ -14370,6 +14681,10 @@ function loadJsonRule(path, result) {
|
|
|
14370
14681
|
result.errors.push(`external rule ${id} has a pattern longer than 512 chars — likely a copy-paste error.`);
|
|
14371
14682
|
return;
|
|
14372
14683
|
}
|
|
14684
|
+
if (hasNestedQuantifier(p)) {
|
|
14685
|
+
result.errors.push(`external rule ${id} has a nested quantifier that may cause catastrophic backtracking`);
|
|
14686
|
+
return;
|
|
14687
|
+
}
|
|
14373
14688
|
const quantifiers = p.match(/[+*?]|\{\d/g);
|
|
14374
14689
|
if (quantifiers && quantifiers.length > 64) {
|
|
14375
14690
|
result.errors.push(`external rule ${id} has a pattern with more than 64 quantifier/wildcard tokens — likely catastrophic backtracking.`);
|
|
@@ -14567,6 +14882,8 @@ var scan_pipeline_exports = /* @__PURE__ */ __exportAll({
|
|
|
14567
14882
|
summarizeForensicVerdicts: () => summarizeForensicVerdicts
|
|
14568
14883
|
});
|
|
14569
14884
|
const UNIVERSAL_RULES = RULES.map(asUniversal);
|
|
14885
|
+
const DEFAULT_MAX_DURATION_MS = 6e5;
|
|
14886
|
+
const MAX_DURATION_MS = 36e5;
|
|
14570
14887
|
/** Registered rule IDs — used to warn on unknown severityOverrides keys (M4). */
|
|
14571
14888
|
const KNOWN_RULE_IDS = new Set(RULES.map((r) => r.id));
|
|
14572
14889
|
/**
|
|
@@ -14783,7 +15100,9 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14783
15100
|
let testDeclarationCount = 0;
|
|
14784
15101
|
let rulesPartial = false;
|
|
14785
15102
|
let parseFailed = 0;
|
|
15103
|
+
let parseFallbacks = 0;
|
|
14786
15104
|
let scanned = 0;
|
|
15105
|
+
let analyzed = 0;
|
|
14787
15106
|
for (const path of testFiles) {
|
|
14788
15107
|
if (Date.now() > deadline) {
|
|
14789
15108
|
rulesPartial = true;
|
|
@@ -14797,7 +15116,12 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14797
15116
|
if (!isCiAdapter) testFileCount++;
|
|
14798
15117
|
let text;
|
|
14799
15118
|
try {
|
|
14800
|
-
|
|
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");
|
|
14801
15125
|
} catch {
|
|
14802
15126
|
skippedFiles++;
|
|
14803
15127
|
continue;
|
|
@@ -14831,6 +15155,7 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14831
15155
|
const cachedFindings = cache.lookup(cacheKey);
|
|
14832
15156
|
if (cachedFindings) {
|
|
14833
15157
|
for (const f of cachedFindings) findings.push(f);
|
|
15158
|
+
analyzed++;
|
|
14834
15159
|
continue;
|
|
14835
15160
|
}
|
|
14836
15161
|
hooks.onProgress?.({
|
|
@@ -14857,10 +15182,12 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14857
15182
|
}
|
|
14858
15183
|
const actualMode = parsed ? "ast" : "regex";
|
|
14859
15184
|
if (wantsAst && actualMode === "regex") {
|
|
15185
|
+
parseFallbacks++;
|
|
14860
15186
|
cacheKey = fileCacheKey(rulesDigest, text, identity(actualMode));
|
|
14861
15187
|
const fallbackFindings = cache.lookup(cacheKey);
|
|
14862
15188
|
if (fallbackFindings) {
|
|
14863
15189
|
for (const f of fallbackFindings) findings.push(f);
|
|
15190
|
+
analyzed++;
|
|
14864
15191
|
continue;
|
|
14865
15192
|
}
|
|
14866
15193
|
}
|
|
@@ -14891,8 +15218,10 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14891
15218
|
fileBudgetExceeded = true;
|
|
14892
15219
|
}
|
|
14893
15220
|
});
|
|
15221
|
+
if (!fileRuleFailed && !fileBudgetExceeded) analyzed++;
|
|
14894
15222
|
if (!fileRuleFailed) cache.store(cacheKey, findings.slice(findingsStart), fileBudgetExceeded);
|
|
14895
15223
|
} catch {
|
|
15224
|
+
if (wantsAst) parseFallbacks++;
|
|
14896
15225
|
skippedFiles++;
|
|
14897
15226
|
parseFailed++;
|
|
14898
15227
|
} finally {
|
|
@@ -14905,7 +15234,9 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
14905
15234
|
testDeclarationCount,
|
|
14906
15235
|
rulesPartial,
|
|
14907
15236
|
parseFailed,
|
|
14908
|
-
|
|
15237
|
+
parseFallbacks,
|
|
15238
|
+
scanned,
|
|
15239
|
+
analyzed
|
|
14909
15240
|
};
|
|
14910
15241
|
}
|
|
14911
15242
|
/**
|
|
@@ -14983,6 +15314,7 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
|
|
|
14983
15314
|
for (const f of findings) f.fixGroupId = f.ruleId;
|
|
14984
15315
|
const discoveredReport = discoverAndParseRuntimeReport(scanRoot.root);
|
|
14985
15316
|
const runtimeReportPath = discoveredReport?.path;
|
|
15317
|
+
const runtimeIncomplete = discoveredReport !== void 0 && discoveredReport.report.analysisComplete !== true;
|
|
14986
15318
|
let forensicVerdicts;
|
|
14987
15319
|
if (discoveredReport && discoveredReport.report.analysisComplete === true) try {
|
|
14988
15320
|
buildEvidenceRecords(discoveredReport.report, discoveredReport.path);
|
|
@@ -14995,6 +15327,7 @@ function applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, dec
|
|
|
14995
15327
|
suppressionCount,
|
|
14996
15328
|
frameworks,
|
|
14997
15329
|
runtimeReportPath,
|
|
15330
|
+
runtimeIncomplete,
|
|
14998
15331
|
forensicVerdicts,
|
|
14999
15332
|
config
|
|
15000
15333
|
};
|
|
@@ -15063,10 +15396,13 @@ function assembleScanResult(o) {
|
|
|
15063
15396
|
if (o.scopeIgnored > 0) scopeReasons.push(`ignored:${o.scopeIgnored}`);
|
|
15064
15397
|
if (o.scopeUnrecognized > 0) scopeReasons.push(`unrecognized:${o.scopeUnrecognized}`);
|
|
15065
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");
|
|
15066
15402
|
for (const reason of o.truncationReasons) scopeReasons.push(`truncated:${reason}`);
|
|
15067
15403
|
const scopeIntegrity = {
|
|
15068
15404
|
discovered: o.testFiles.length,
|
|
15069
|
-
analyzed: Math.max(0, o.scanned),
|
|
15405
|
+
analyzed: Math.max(0, o.analyzed ?? o.scanned),
|
|
15070
15406
|
ignored: o.scopeIgnored,
|
|
15071
15407
|
unrecognized: o.scopeUnrecognized,
|
|
15072
15408
|
parseFailed: o.parseFailed,
|
|
@@ -15075,28 +15411,59 @@ function assembleScanResult(o) {
|
|
|
15075
15411
|
...scopeReasons.length > 0 ? { reasons: scopeReasons } : {}
|
|
15076
15412
|
};
|
|
15077
15413
|
const scopeAdjustedTotal = scopeReasons.length > 0 && total >= 100 ? 99 : total;
|
|
15414
|
+
let identityIncomplete = false;
|
|
15078
15415
|
const inputSnapshot = o.testFiles.map((p) => {
|
|
15079
15416
|
const relPath = relative(o.workspace.root, p).replaceAll("\\", "/");
|
|
15080
15417
|
try {
|
|
15081
|
-
const
|
|
15082
|
-
|
|
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");
|
|
15083
15428
|
return {
|
|
15084
15429
|
path: relPath,
|
|
15085
|
-
size:
|
|
15430
|
+
size: read.data.length,
|
|
15086
15431
|
hash
|
|
15087
15432
|
};
|
|
15088
15433
|
} catch {
|
|
15434
|
+
if (existsSync(o.workspace.root)) identityIncomplete = true;
|
|
15089
15435
|
return {
|
|
15090
15436
|
path: relPath,
|
|
15091
|
-
size: 0
|
|
15437
|
+
size: 0,
|
|
15438
|
+
hash: "UNAVAILABLE"
|
|
15092
15439
|
};
|
|
15093
15440
|
}
|
|
15094
15441
|
});
|
|
15095
15442
|
let reportDigest;
|
|
15096
15443
|
if (o.runtimeReportPath) try {
|
|
15097
|
-
const
|
|
15098
|
-
|
|
15099
|
-
|
|
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
|
+
});
|
|
15100
15467
|
const runIdentity = buildRunIdentity({
|
|
15101
15468
|
files: inputSnapshot,
|
|
15102
15469
|
rules: [...o.REVISION_BY_RULE_ID.entries()].map(([id, detectorRevision]) => ({
|
|
@@ -15114,13 +15481,14 @@ function assembleScanResult(o) {
|
|
|
15114
15481
|
const evidenceGraph = buildEvidenceGraph({ runId: runIdentity });
|
|
15115
15482
|
const hasTests = o.testFileCount > 0 && o.testDeclarationCount > 0;
|
|
15116
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;
|
|
15117
15485
|
const result = {
|
|
15118
15486
|
schemaVersion: 1,
|
|
15119
|
-
partial:
|
|
15487
|
+
partial: completion.partial,
|
|
15120
15488
|
scopeIntegrity,
|
|
15121
15489
|
runIdentity,
|
|
15122
15490
|
evidenceGraph,
|
|
15123
|
-
score:
|
|
15491
|
+
score: finalScore,
|
|
15124
15492
|
...hasTests ? {} : { reason: "no-tests-found" },
|
|
15125
15493
|
frameworks: o.frameworks.frameworks,
|
|
15126
15494
|
frameworkDetectionUnknown: o.frameworks.unknown,
|
|
@@ -15146,12 +15514,8 @@ function assembleScanResult(o) {
|
|
|
15146
15514
|
file: o.cache.stats.file
|
|
15147
15515
|
} } : {},
|
|
15148
15516
|
analysisStatus: {
|
|
15149
|
-
|
|
15150
|
-
|
|
15151
|
-
skippedFiles: o.skippedFiles,
|
|
15152
|
-
durationMs: elapsed,
|
|
15153
|
-
rulesCrashed: o.rulesCrashed,
|
|
15154
|
-
...o.truncationReasons.size > 0 ? { truncationReasons: [...o.truncationReasons].sort() } : {}
|
|
15517
|
+
...completion.analysisStatus,
|
|
15518
|
+
durationMs: elapsed
|
|
15155
15519
|
},
|
|
15156
15520
|
scoringModelVersion: SCORING_MODEL_VERSION
|
|
15157
15521
|
};
|
|
@@ -15199,7 +15563,8 @@ function assembleScanResult(o) {
|
|
|
15199
15563
|
*/
|
|
15200
15564
|
async function runScan(args, hooks = {}) {
|
|
15201
15565
|
const started = Date.now();
|
|
15202
|
-
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);
|
|
15203
15568
|
const discovered = discoverWorkspace(args.target);
|
|
15204
15569
|
const targetAbs = resolve(args.target);
|
|
15205
15570
|
const scanRoot = discovered && discovered.root !== targetAbs && targetAbs.startsWith(discovered.root + sep) ? {
|
|
@@ -15288,7 +15653,9 @@ async function runScan(args, hooks = {}) {
|
|
|
15288
15653
|
testDeclarationCount = analysis.testDeclarationCount;
|
|
15289
15654
|
rulesPartial = analysis.rulesPartial;
|
|
15290
15655
|
parseFailed = analysis.parseFailed;
|
|
15656
|
+
const parseFallbacks = analysis.parseFallbacks;
|
|
15291
15657
|
const scanned = analysis.scanned;
|
|
15658
|
+
const analyzed = analysis.analyzed;
|
|
15292
15659
|
const postScan = applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, declarationsByFile, testDeclarationCount, tierByRuleId, REVISION_BY_RULE_ID);
|
|
15293
15660
|
testDeclarationCount = postScan.testDeclarationCount;
|
|
15294
15661
|
const result = assembleScanResult({
|
|
@@ -15304,7 +15671,9 @@ async function runScan(args, hooks = {}) {
|
|
|
15304
15671
|
scopeIgnored,
|
|
15305
15672
|
scopeUnrecognized,
|
|
15306
15673
|
parseFailed,
|
|
15674
|
+
parseFallbacks,
|
|
15307
15675
|
scanned,
|
|
15676
|
+
analyzed,
|
|
15308
15677
|
testFiles,
|
|
15309
15678
|
workspace,
|
|
15310
15679
|
scanRoot,
|
|
@@ -15317,6 +15686,7 @@ async function runScan(args, hooks = {}) {
|
|
|
15317
15686
|
suppressionCount: postScan.suppressionCount,
|
|
15318
15687
|
frameworks: postScan.frameworks,
|
|
15319
15688
|
runtimeReportPath: postScan.runtimeReportPath,
|
|
15689
|
+
runtimeIncomplete: postScan.runtimeIncomplete,
|
|
15320
15690
|
forensicVerdicts: postScan.forensicVerdicts,
|
|
15321
15691
|
config: postScan.config,
|
|
15322
15692
|
fileProvenance,
|
|
@@ -15329,4 +15699,4 @@ async function runScan(args, hooks = {}) {
|
|
|
15329
15699
|
return result;
|
|
15330
15700
|
}
|
|
15331
15701
|
//#endregion
|
|
15332
|
-
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 };
|