mjolnir-qa 4.0.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +269 -1
- package/README.br.md +10 -10
- package/README.md +12 -12
- package/dist/cli.d.mts +181 -4
- package/dist/cli.mjs +1481 -120
- package/dist/mcp/stdio.mjs +390 -193
- package/dist/scan-pipeline-DNYbnWjE.mjs +2 -0
- package/dist/{scan-pipeline-CCJdEKNa.mjs → scan-pipeline-DT8DWAMl.mjs} +344 -179
- package/package.json +25 -8
- package/dist/scan-pipeline-Bg-46cLS.mjs +0 -2
package/dist/mcp/stdio.mjs
CHANGED
|
@@ -85,6 +85,7 @@ const DEDUCTIONS = {
|
|
|
85
85
|
//#region src/engine/completion.ts
|
|
86
86
|
function deriveCompletion(input) {
|
|
87
87
|
const truncationReasons = [...new Set(input.truncationReasons)].sort();
|
|
88
|
+
const degradations = [...input.degradations ?? []].sort((a, b) => a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0);
|
|
88
89
|
const reasons = /* @__PURE__ */ new Set();
|
|
89
90
|
if (input.discoveryTruncated) reasons.add("discovery-truncated");
|
|
90
91
|
if (input.rulesPartial) reasons.add("rules-partial");
|
|
@@ -94,14 +95,16 @@ function deriveCompletion(input) {
|
|
|
94
95
|
if (input.scopeUnrecognized > 0) reasons.add(`scope-unrecognized:${input.scopeUnrecognized}`);
|
|
95
96
|
if (input.parseFailed > 0) reasons.add(`parse-failed:${input.parseFailed}`);
|
|
96
97
|
if (input.parseFallbacks && input.parseFallbacks > 0) reasons.add(`parse-fallbacks:${input.parseFallbacks}`);
|
|
98
|
+
if (input.astFallbackFiles && input.astFallbackFiles > 0) reasons.add(`ast-budget-fallback-files:${input.astFallbackFiles}`);
|
|
97
99
|
if (input.scopeDegraded) reasons.add(`scope-degraded:${input.scopeDegraded}`);
|
|
98
100
|
if (input.runtimeIncomplete) reasons.add("runtime-incomplete");
|
|
99
101
|
if (input.identityIncomplete) reasons.add("identity-incomplete");
|
|
100
102
|
for (const reason of truncationReasons) reasons.add(`truncated:${reason}`);
|
|
103
|
+
for (const entry of degradations) reasons.add(`degraded:${entry.reason}:${entry.count}`);
|
|
101
104
|
const discoveryPartial = input.discoveryTruncated || input.scopeIgnored > 0 || input.scopeUnrecognized > 0 || input.scopeDegraded !== void 0;
|
|
102
105
|
const rulesPartial = input.rulesPartial || input.rulesCrashed > 0 || input.parseFailed > 0;
|
|
103
106
|
return {
|
|
104
|
-
partial: discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0,
|
|
107
|
+
partial: discoveryPartial || rulesPartial || input.skippedFiles > 0 || input.runtimeIncomplete === true || input.identityIncomplete === true || truncationReasons.length > 0 || degradations.length > 0,
|
|
105
108
|
analysisStatus: {
|
|
106
109
|
discovery: discoveryPartial ? "partial" : "complete",
|
|
107
110
|
rules: rulesPartial ? "partial" : "complete",
|
|
@@ -109,11 +112,49 @@ function deriveCompletion(input) {
|
|
|
109
112
|
rulesCrashed: input.rulesCrashed,
|
|
110
113
|
parseFallbacks: input.parseFallbacks ?? 0,
|
|
111
114
|
...truncationReasons.length > 0 ? { truncationReasons } : {},
|
|
115
|
+
...degradations.length > 0 ? { degradations } : {},
|
|
112
116
|
reasons: [...reasons].sort()
|
|
113
117
|
}
|
|
114
118
|
};
|
|
115
119
|
}
|
|
116
120
|
//#endregion
|
|
121
|
+
//#region src/engine/degradation-ledger.ts
|
|
122
|
+
/**
|
|
123
|
+
* Append-only. Records are never edited and never removed by a recording
|
|
124
|
+
* site; a reader takes a window, and the window is defined by index, so
|
|
125
|
+
* "delete my entry" is not an operation this module offers.
|
|
126
|
+
*/
|
|
127
|
+
const records = [];
|
|
128
|
+
/** Record one capability loss. `count` is for a site that fails N at once. */
|
|
129
|
+
function recordDegradation(reason, count = 1) {
|
|
130
|
+
if (!Number.isInteger(count) || count < 1) return;
|
|
131
|
+
for (let index = 0; index < count; index++) records.push({ reason });
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Mark where this reader's window opens. Everything appended from here on is
|
|
135
|
+
* what the reader will see; see the file header for what a concurrent scan
|
|
136
|
+
* does to that boundary.
|
|
137
|
+
*/
|
|
138
|
+
function beginDegradationWindow() {
|
|
139
|
+
return records.length;
|
|
140
|
+
}
|
|
141
|
+
/** The records appended since `marker`, without consuming them. */
|
|
142
|
+
function degradationsSince(marker) {
|
|
143
|
+
return records.slice(marker);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Per-reason counts for a window, sorted by reason so the output is
|
|
147
|
+
* byte-deterministic for the same set of degradations.
|
|
148
|
+
*/
|
|
149
|
+
function summarizeDegradations(window) {
|
|
150
|
+
const counts = /* @__PURE__ */ new Map();
|
|
151
|
+
for (const record of window) counts.set(record.reason, (counts.get(record.reason) ?? 0) + 1);
|
|
152
|
+
return [...counts.entries()].map(([reason, count]) => ({
|
|
153
|
+
reason,
|
|
154
|
+
count
|
|
155
|
+
})).sort((a, b) => a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0);
|
|
156
|
+
}
|
|
157
|
+
//#endregion
|
|
117
158
|
//#region src/engine/runtime-corroboration.ts
|
|
118
159
|
/**
|
|
119
160
|
* Stamp runtime corroboration + trust levels onto findings (mutates in
|
|
@@ -1064,6 +1105,120 @@ function createExecutor(name, concurrency, runOne) {
|
|
|
1064
1105
|
};
|
|
1065
1106
|
}
|
|
1066
1107
|
//#endregion
|
|
1108
|
+
//#region src/scope/git-resolve.ts
|
|
1109
|
+
/**
|
|
1110
|
+
* Absolute-path git resolution (audit S1).
|
|
1111
|
+
*
|
|
1112
|
+
* `execFileSync("git", …)` resolves through PATH — including the CWD on
|
|
1113
|
+
* Windows (CreateProcess searches the current directory before PATH for
|
|
1114
|
+
* extension-less names depending on NoDefaultCurrentDirectoryInExePath).
|
|
1115
|
+
* Scanning an UNTRUSTED repo therefore let a checked-in `git.exe` (or
|
|
1116
|
+
* `git.bat`/`git.cmd`) hijack Mjölnir's own git invocations: the attacker
|
|
1117
|
+
* controls the diff output, the merge-base decision, and
|
|
1118
|
+
* `--scope changed`'s "what is new" answer — the exact data CI gates on.
|
|
1119
|
+
*
|
|
1120
|
+
* The fix: resolve git ONCE per process to an ABSOLUTE path by walking
|
|
1121
|
+
* the PATH directly (never consulting the CWD), verify the candidate is
|
|
1122
|
+
* an existing file, and pass that path to execFileSync. Resolution is
|
|
1123
|
+
* memoized; a failure to find a real git anywhere on PATH is recorded
|
|
1124
|
+
* and callers degrade without invoking a bare name.
|
|
1125
|
+
*/
|
|
1126
|
+
let resolvedGit = void 0;
|
|
1127
|
+
/**
|
|
1128
|
+
* The absolute path of the git binary Mjölnir will invoke, or null when
|
|
1129
|
+
* PATH carries no executable `git` at all. Callers must degrade without
|
|
1130
|
+
* invoking a bare name.
|
|
1131
|
+
*/
|
|
1132
|
+
function resolveGitPath() {
|
|
1133
|
+
if (resolvedGit !== void 0) return resolvedGit;
|
|
1134
|
+
const candidates = [process.platform === "win32" ? "git.exe" : "git"];
|
|
1135
|
+
if (process.platform === "win32") {
|
|
1136
|
+
const pathExt = process.env["PATHEXT"]?.split(";").filter(Boolean) ?? [];
|
|
1137
|
+
for (const ext of pathExt) candidates.push(`git${ext.toLowerCase()}`);
|
|
1138
|
+
}
|
|
1139
|
+
const pathValue = process.env["PATH"] ?? "";
|
|
1140
|
+
for (const dir of pathValue.split(delimiter)) {
|
|
1141
|
+
if (dir === "") continue;
|
|
1142
|
+
if (!isAbsolute(dir)) continue;
|
|
1143
|
+
for (const candidate of candidates) {
|
|
1144
|
+
const full = join(dir, candidate);
|
|
1145
|
+
try {
|
|
1146
|
+
if (existsSync(full)) {
|
|
1147
|
+
resolvedGit = full;
|
|
1148
|
+
return resolvedGit;
|
|
1149
|
+
}
|
|
1150
|
+
} catch {}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
`${pathValue.length}`;
|
|
1154
|
+
resolvedGit = null;
|
|
1155
|
+
return resolvedGit;
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Run git with the S1-resolved absolute binary path. Returns stdout, or
|
|
1159
|
+
* null when git is missing or the command fails — the same contract as
|
|
1160
|
+
* the previous inline `git()` helpers, with the hijack surface closed.
|
|
1161
|
+
*/
|
|
1162
|
+
function runGit(root, args) {
|
|
1163
|
+
const exe = resolveGitPath();
|
|
1164
|
+
if (!exe) return null;
|
|
1165
|
+
try {
|
|
1166
|
+
return execFileSync(exe, [
|
|
1167
|
+
"-C",
|
|
1168
|
+
root,
|
|
1169
|
+
...args
|
|
1170
|
+
], {
|
|
1171
|
+
encoding: "utf8",
|
|
1172
|
+
stdio: [
|
|
1173
|
+
"ignore",
|
|
1174
|
+
"pipe",
|
|
1175
|
+
"ignore"
|
|
1176
|
+
],
|
|
1177
|
+
timeout: 15e3
|
|
1178
|
+
});
|
|
1179
|
+
} catch {
|
|
1180
|
+
return null;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
//#endregion
|
|
1184
|
+
//#region src/lib/safe-json.ts
|
|
1185
|
+
/**
|
|
1186
|
+
* Reusable safe-JSON parse-and-validate helper.
|
|
1187
|
+
*
|
|
1188
|
+
* Replaces the `JSON.parse(text) as T` pattern with a two-step process:
|
|
1189
|
+
* 1. Try/catch around JSON.parse (descriptive error on bad JSON)
|
|
1190
|
+
* 2. Runtime validation of the parsed shape (caller-supplied predicate)
|
|
1191
|
+
*
|
|
1192
|
+
* The `validate` predicate is intentionally lightweight — it checks the
|
|
1193
|
+
* expected shape exists rather than exhaustively type-guarding every field.
|
|
1194
|
+
* Internal files (cache, hashes) use `isRecord`; user-facing files
|
|
1195
|
+
* (config, plugins) use stricter checks that throw descriptive errors.
|
|
1196
|
+
*/
|
|
1197
|
+
const MAX_JSON_BYTES = 16777216;
|
|
1198
|
+
function parseJsonFile(text, source, validate) {
|
|
1199
|
+
if (Buffer.byteLength(text, "utf8") > MAX_JSON_BYTES) throw new Error(`JSON source exceeds the ${MAX_JSON_BYTES}-byte limit: ${source}`);
|
|
1200
|
+
let parsed;
|
|
1201
|
+
try {
|
|
1202
|
+
parsed = JSON.parse(text);
|
|
1203
|
+
} catch (err) {
|
|
1204
|
+
throw new Error(`invalid JSON in ${source}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
1205
|
+
}
|
|
1206
|
+
if (!validate(parsed)) throw new Error(`unexpected shape in ${source}`, { cause: parsed });
|
|
1207
|
+
return parsed;
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Minimal validator: value is a plain object (not null, not array).
|
|
1211
|
+
* Sufficient for internal files (cache, hashes) where downstream
|
|
1212
|
+
* code already checks individual fields.
|
|
1213
|
+
*/
|
|
1214
|
+
function isRecord(v) {
|
|
1215
|
+
try {
|
|
1216
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1217
|
+
} catch {
|
|
1218
|
+
return false;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
//#endregion
|
|
1067
1222
|
//#region src/engine/candidate-binding.ts
|
|
1068
1223
|
/**
|
|
1069
1224
|
* Candidate binding (plan V5-010).
|
|
@@ -1084,9 +1239,6 @@ const SHA40 = /^[a-f0-9]{40}$/;
|
|
|
1084
1239
|
const SHA256 = /^[a-f0-9]{64}$/;
|
|
1085
1240
|
const STATES = ["WORKING_CANDIDATE", "RELEASE_CANDIDATE"];
|
|
1086
1241
|
const AUTHORIZATION = ["NOT_AUTHORIZED", "AUTHORIZED"];
|
|
1087
|
-
function isRecord$1(value) {
|
|
1088
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1089
|
-
}
|
|
1090
1242
|
function sha256File(path) {
|
|
1091
1243
|
try {
|
|
1092
1244
|
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
@@ -1094,17 +1246,41 @@ function sha256File(path) {
|
|
|
1094
1246
|
return;
|
|
1095
1247
|
}
|
|
1096
1248
|
}
|
|
1249
|
+
/**
|
|
1250
|
+
* One git line, or nothing.
|
|
1251
|
+
*
|
|
1252
|
+
* Audit S1: the binary is the ABSOLUTE path `resolveGitPath()` found by
|
|
1253
|
+
* walking PATH itself, never the bare name. `bindRepository()` runs on every
|
|
1254
|
+
* scan (`scan-pipeline.ts`), and a bare `execFileSync("git", …)` lets a
|
|
1255
|
+
* `git.exe`/`git.bat`/`git.cmd` CHECKED INTO AN UNTRUSTED REPO hijack the
|
|
1256
|
+
* call on Windows, where CreateProcess searches the current directory before
|
|
1257
|
+
* PATH. The attacker then controls the `commit` and `tree` that land in
|
|
1258
|
+
* `runIdentity` and the evidence graph — the provenance a release decision
|
|
1259
|
+
* reads. This was the last bare-name exec left in `src/`; the invariant that
|
|
1260
|
+
* keeps it that way is `tests/contract/no-bare-process-exec.spec.ts`.
|
|
1261
|
+
*
|
|
1262
|
+
* The `-C <root>` shape (not `cwd`) matches every other hardened call site.
|
|
1263
|
+
* With no git on PATH at all, the answer is nothing — a scan of an unpacked
|
|
1264
|
+
* tarball has no commit, and its identity must say so rather than carrying a
|
|
1265
|
+
* placeholder that looks like provenance.
|
|
1266
|
+
*/
|
|
1097
1267
|
function gitLine(root, args) {
|
|
1268
|
+
const exe = resolveGitPath();
|
|
1269
|
+
if (!exe) return void 0;
|
|
1098
1270
|
try {
|
|
1099
|
-
const trimmed = execFileSync(
|
|
1100
|
-
|
|
1271
|
+
const trimmed = execFileSync(exe, [
|
|
1272
|
+
"-C",
|
|
1273
|
+
root,
|
|
1274
|
+
...args
|
|
1275
|
+
], {
|
|
1101
1276
|
encoding: "utf8",
|
|
1102
1277
|
stdio: [
|
|
1103
1278
|
"ignore",
|
|
1104
1279
|
"pipe",
|
|
1105
1280
|
"ignore"
|
|
1106
1281
|
],
|
|
1107
|
-
windowsHide: true
|
|
1282
|
+
windowsHide: true,
|
|
1283
|
+
timeout: 15e3
|
|
1108
1284
|
}).trim();
|
|
1109
1285
|
return trimmed.length > 0 ? trimmed : void 0;
|
|
1110
1286
|
} catch {
|
|
@@ -1135,9 +1311,9 @@ function bindRepository(root) {
|
|
|
1135
1311
|
* contradiction the release decision evaluator treats as BLOCKED).
|
|
1136
1312
|
*/
|
|
1137
1313
|
function parseCandidateBinding(raw) {
|
|
1138
|
-
if (!isRecord
|
|
1314
|
+
if (!isRecord(raw)) return null;
|
|
1139
1315
|
const identity = raw.identity;
|
|
1140
|
-
if (!isRecord
|
|
1316
|
+
if (!isRecord(identity)) return null;
|
|
1141
1317
|
const state = identity.state;
|
|
1142
1318
|
const candidateSha = identity.candidateSha;
|
|
1143
1319
|
const baseSha = identity.baseSha;
|
|
@@ -1186,7 +1362,7 @@ function readCandidateBinding(root, manifestPath = CANDIDATE_MANIFEST_PATH) {
|
|
|
1186
1362
|
* scripts/sync-sarif-version.cjs and guarded by the version-consistency
|
|
1187
1363
|
* spec. cli.ts re-exports this as CLI_VERSION.
|
|
1188
1364
|
*/
|
|
1189
|
-
const ENGINE_VERSION = "
|
|
1365
|
+
const ENGINE_VERSION = "5.0.0";
|
|
1190
1366
|
//#endregion
|
|
1191
1367
|
//#region src/engine/contract-versions.ts
|
|
1192
1368
|
/**
|
|
@@ -1315,9 +1491,12 @@ function discoverWorkspace(rootDir) {
|
|
|
1315
1491
|
const root = findProjectRoot(rootDir);
|
|
1316
1492
|
if (!root) return null;
|
|
1317
1493
|
let pkg = {};
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1494
|
+
const manifestPath = join(root, "package.json");
|
|
1495
|
+
if (existsSync(manifestPath)) try {
|
|
1496
|
+
pkg = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
1497
|
+
} catch {
|
|
1498
|
+
recordDegradation("workspace-manifest-unreadable");
|
|
1499
|
+
}
|
|
1321
1500
|
const rawWorkspaces = pkg["workspaces"];
|
|
1322
1501
|
let globs = [];
|
|
1323
1502
|
if (Array.isArray(rawWorkspaces)) globs = rawWorkspaces.filter((w) => typeof w === "string");
|
|
@@ -1334,40 +1513,6 @@ function basename$1(p) {
|
|
|
1334
1513
|
return parts[parts.length - 1];
|
|
1335
1514
|
}
|
|
1336
1515
|
//#endregion
|
|
1337
|
-
//#region src/lib/safe-json.ts
|
|
1338
|
-
/**
|
|
1339
|
-
* Reusable safe-JSON parse-and-validate helper.
|
|
1340
|
-
*
|
|
1341
|
-
* Replaces the `JSON.parse(text) as T` pattern with a two-step process:
|
|
1342
|
-
* 1. Try/catch around JSON.parse (descriptive error on bad JSON)
|
|
1343
|
-
* 2. Runtime validation of the parsed shape (caller-supplied predicate)
|
|
1344
|
-
*
|
|
1345
|
-
* The `validate` predicate is intentionally lightweight — it checks the
|
|
1346
|
-
* expected shape exists rather than exhaustively type-guarding every field.
|
|
1347
|
-
* Internal files (cache, hashes) use `isRecord`; user-facing files
|
|
1348
|
-
* (config, plugins) use stricter checks that throw descriptive errors.
|
|
1349
|
-
*/
|
|
1350
|
-
const MAX_JSON_BYTES = 16777216;
|
|
1351
|
-
function parseJsonFile(text, source, validate) {
|
|
1352
|
-
if (Buffer.byteLength(text, "utf8") > MAX_JSON_BYTES) throw new Error(`JSON source exceeds the ${MAX_JSON_BYTES}-byte limit: ${source}`);
|
|
1353
|
-
let parsed;
|
|
1354
|
-
try {
|
|
1355
|
-
parsed = JSON.parse(text);
|
|
1356
|
-
} catch (err) {
|
|
1357
|
-
throw new Error(`invalid JSON in ${source}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
1358
|
-
}
|
|
1359
|
-
if (!validate(parsed)) throw new Error(`unexpected shape in ${source}`, { cause: parsed });
|
|
1360
|
-
return parsed;
|
|
1361
|
-
}
|
|
1362
|
-
/**
|
|
1363
|
-
* Minimal validator: value is a plain object (not null, not array).
|
|
1364
|
-
* Sufficient for internal files (cache, hashes) where downstream
|
|
1365
|
-
* code already checks individual fields.
|
|
1366
|
-
*/
|
|
1367
|
-
function isRecord(v) {
|
|
1368
|
-
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1369
|
-
}
|
|
1370
|
-
//#endregion
|
|
1371
1516
|
//#region src/lib/fs-bounded.ts
|
|
1372
1517
|
function closeQuiet(fd) {
|
|
1373
1518
|
try {
|
|
@@ -1597,6 +1742,12 @@ function validate(cfg, knownRuleIds) {
|
|
|
1597
1742
|
* Expiry is evaluated only from an explicit ISO `expires` date. A missing
|
|
1598
1743
|
* date remains active and is reported as having no expiry. Config-file mtime
|
|
1599
1744
|
* is never an expiry anchor.
|
|
1745
|
+
*
|
|
1746
|
+
* `now` defaults to the current instant so existing callers stay correct, but
|
|
1747
|
+
* a verdict function that reads the clock invisibly is a verdict nobody can
|
|
1748
|
+
* test at a boundary. `tests/contract/deterministic-clock.spec.ts` audits
|
|
1749
|
+
* `src/config/**` for a call site that supplies its own `new Date()` instead
|
|
1750
|
+
* of threading one through.
|
|
1600
1751
|
*/
|
|
1601
1752
|
function isSuppressionActive(ign, now = /* @__PURE__ */ new Date()) {
|
|
1602
1753
|
if (!ign.expires) return true;
|
|
@@ -1992,6 +2143,7 @@ function parseTsFile(file) {
|
|
|
1992
2143
|
}
|
|
1993
2144
|
return p.createSourceFile(file.path, file.text);
|
|
1994
2145
|
} catch {
|
|
2146
|
+
recordDegradation("ast-parse-failed");
|
|
1995
2147
|
return;
|
|
1996
2148
|
}
|
|
1997
2149
|
}
|
|
@@ -2047,6 +2199,7 @@ function commentAndStringRanges(ctx) {
|
|
|
2047
2199
|
}
|
|
2048
2200
|
return ranges;
|
|
2049
2201
|
} catch {
|
|
2202
|
+
recordDegradation("ast-range-scan-failed");
|
|
2050
2203
|
return [];
|
|
2051
2204
|
}
|
|
2052
2205
|
}
|
|
@@ -2094,6 +2247,7 @@ function getCodeOnlyText(file) {
|
|
|
2094
2247
|
for (const r of ranges) for (let i = r.start; i < r.end && i < chars.length; i++) if (chars[i] !== "\n" && chars[i] !== "\r") chars[i] = " ";
|
|
2095
2248
|
return chars.join("");
|
|
2096
2249
|
} catch {
|
|
2250
|
+
recordDegradation("ast-mask-unavailable");
|
|
2097
2251
|
return file.text;
|
|
2098
2252
|
}
|
|
2099
2253
|
}
|
|
@@ -2478,6 +2632,44 @@ function frameworkFilterApplies(rule, file) {
|
|
|
2478
2632
|
return rule.frameworks.some((f) => tags.includes(f));
|
|
2479
2633
|
}
|
|
2480
2634
|
//#endregion
|
|
2635
|
+
//#region src/engine/shared-run-rules.ts
|
|
2636
|
+
/**
|
|
2637
|
+
* Run ONE rule with crash isolation, and return what it produced.
|
|
2638
|
+
*
|
|
2639
|
+
* This is the block that was copy-pasted into all seven language adapters:
|
|
2640
|
+
*
|
|
2641
|
+
* try { for (const f of rule.run(x)) emit(f, rule.id, rule.category); }
|
|
2642
|
+
* catch (error) { onCrash?.(rule.id, error); }
|
|
2643
|
+
*
|
|
2644
|
+
* Extracting it is worth doing for one reason beyond line count. Crash
|
|
2645
|
+
* isolation is the mechanism that lets a third-party or experimental
|
|
2646
|
+
* detector fail without ending a scan, and it is the mechanism the whole
|
|
2647
|
+
* `rulesCrashed` count depends on. Seven copies means seven chances to
|
|
2648
|
+
* write `catch {}` and lose the count, and `tests/contract/
|
|
2649
|
+
* no-uncounted-degradation.spec.ts` can only audit what it can see.
|
|
2650
|
+
*
|
|
2651
|
+
* `null` means the rule CRASHED, which is not the same as an empty array:
|
|
2652
|
+
* an empty array is the rule abstaining, and the adapters skip emission for
|
|
2653
|
+
* both but the distinction is why the caller must not conflate them.
|
|
2654
|
+
*
|
|
2655
|
+
* Deliberately NOT extracted: the per-adapter filter chain and the budget
|
|
2656
|
+
* check. Those genuinely differ — the TypeScript adapter injects an AST,
|
|
2657
|
+
* framework tags, a lazy `codeText` getter, `appliesTo` scoping, config-only
|
|
2658
|
+
* gating and `configGateMatches`, and it ABORTS the whole file on budget
|
|
2659
|
+
* exhaustion where `runRulesShared` only `continue`s to the next rule.
|
|
2660
|
+
* Routing every adapter through `runRulesShared` as written would silently
|
|
2661
|
+
* change which rules run and when, which is precisely the unmeasured claim
|
|
2662
|
+
* this program exists to delete. This helper takes the part they share.
|
|
2663
|
+
*/
|
|
2664
|
+
function runRuleIsolated(rule, file, onCrash) {
|
|
2665
|
+
try {
|
|
2666
|
+
return rule.run(file);
|
|
2667
|
+
} catch (error) {
|
|
2668
|
+
onCrash?.(rule.id, error);
|
|
2669
|
+
return null;
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
//#endregion
|
|
2481
2673
|
//#region src/adapters/typescript.ts
|
|
2482
2674
|
/**
|
|
2483
2675
|
* TypeScript/JavaScript adapter (R1).
|
|
@@ -2687,11 +2879,9 @@ const typescriptAdapter = {
|
|
|
2687
2879
|
budget.onExceeded();
|
|
2688
2880
|
return;
|
|
2689
2881
|
}
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
onCrash?.(rule.id, error);
|
|
2694
|
-
}
|
|
2882
|
+
const produced = runRuleIsolated(rule, enriched, onCrash);
|
|
2883
|
+
if (produced === null) continue;
|
|
2884
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
2695
2885
|
}
|
|
2696
2886
|
}
|
|
2697
2887
|
};
|
|
@@ -2719,6 +2909,7 @@ function loadWorkspaceShim(root) {
|
|
|
2719
2909
|
workspaceGlobs: []
|
|
2720
2910
|
};
|
|
2721
2911
|
} catch {
|
|
2912
|
+
recordDegradation("workspace-manifest-unreadable");
|
|
2722
2913
|
return null;
|
|
2723
2914
|
}
|
|
2724
2915
|
}
|
|
@@ -3073,11 +3264,9 @@ const pythonAdapter = {
|
|
|
3073
3264
|
budget.onExceeded();
|
|
3074
3265
|
return;
|
|
3075
3266
|
}
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
onCrash?.(rule.id, error);
|
|
3080
|
-
}
|
|
3267
|
+
const produced = runRuleIsolated(rule, enriched, onCrash);
|
|
3268
|
+
if (produced === null) continue;
|
|
3269
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
3081
3270
|
}
|
|
3082
3271
|
},
|
|
3083
3272
|
async parseAst(file) {
|
|
@@ -3230,11 +3419,9 @@ const javaAdapter = {
|
|
|
3230
3419
|
budget.onExceeded();
|
|
3231
3420
|
return;
|
|
3232
3421
|
}
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
onCrash?.(rule.id, error);
|
|
3237
|
-
}
|
|
3422
|
+
const produced = runRuleIsolated(rule, enriched, onCrash);
|
|
3423
|
+
if (produced === null) continue;
|
|
3424
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
3238
3425
|
}
|
|
3239
3426
|
}
|
|
3240
3427
|
};
|
|
@@ -3388,11 +3575,9 @@ const csharpAdapter = {
|
|
|
3388
3575
|
budget.onExceeded();
|
|
3389
3576
|
return;
|
|
3390
3577
|
}
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
onCrash?.(rule.id, error);
|
|
3395
|
-
}
|
|
3578
|
+
const produced = runRuleIsolated(rule, enriched, onCrash);
|
|
3579
|
+
if (produced === null) continue;
|
|
3580
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
3396
3581
|
}
|
|
3397
3582
|
}
|
|
3398
3583
|
};
|
|
@@ -3570,6 +3755,7 @@ const githubActionsAdapter = {
|
|
|
3570
3755
|
try {
|
|
3571
3756
|
entries = readdirSync(wfDir);
|
|
3572
3757
|
} catch {
|
|
3758
|
+
recordDegradation("ci-workflow-listing-unreadable");
|
|
3573
3759
|
return;
|
|
3574
3760
|
}
|
|
3575
3761
|
for (const name of entries) {
|
|
@@ -3610,15 +3796,13 @@ const githubActionsAdapter = {
|
|
|
3610
3796
|
budget.onExceeded();
|
|
3611
3797
|
return;
|
|
3612
3798
|
}
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
onCrash?.(rule.id, error);
|
|
3621
|
-
}
|
|
3799
|
+
const produced = runRuleIsolated(rule, {
|
|
3800
|
+
path: file.path,
|
|
3801
|
+
text: file.text,
|
|
3802
|
+
ast: doc
|
|
3803
|
+
}, onCrash);
|
|
3804
|
+
if (produced === null) continue;
|
|
3805
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
3622
3806
|
}
|
|
3623
3807
|
}
|
|
3624
3808
|
};
|
|
@@ -3928,15 +4112,13 @@ const azurePipelinesAdapter = {
|
|
|
3928
4112
|
budget.onExceeded();
|
|
3929
4113
|
return;
|
|
3930
4114
|
}
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
onCrash?.(rule.id, error);
|
|
3939
|
-
}
|
|
4115
|
+
const produced = runRuleIsolated(rule, {
|
|
4116
|
+
path: file.path,
|
|
4117
|
+
text: file.text,
|
|
4118
|
+
ast: doc
|
|
4119
|
+
}, onCrash);
|
|
4120
|
+
if (produced === null) continue;
|
|
4121
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
3940
4122
|
}
|
|
3941
4123
|
}
|
|
3942
4124
|
};
|
|
@@ -4007,14 +4189,12 @@ const jenkinsAdapter = {
|
|
|
4007
4189
|
budget.onExceeded();
|
|
4008
4190
|
return;
|
|
4009
4191
|
}
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
onCrash?.(rule.id, error);
|
|
4017
|
-
}
|
|
4192
|
+
const produced = runRuleIsolated(rule, {
|
|
4193
|
+
path: file.path,
|
|
4194
|
+
text: file.text
|
|
4195
|
+
}, onCrash);
|
|
4196
|
+
if (produced === null) continue;
|
|
4197
|
+
for (const f of produced) emit(f, rule.id, rule.category);
|
|
4018
4198
|
}
|
|
4019
4199
|
}
|
|
4020
4200
|
};
|
|
@@ -4141,82 +4321,6 @@ function isInsideSkippedDir(root, absPath, skipNames) {
|
|
|
4141
4321
|
}
|
|
4142
4322
|
typescriptAdapter.testFileGlobs, pythonAdapter.testFileGlobs, javaAdapter.testFileGlobs, csharpAdapter.testFileGlobs, githubActionsAdapter.testFileGlobs, azurePipelinesAdapter.testFileGlobs, jenkinsAdapter.testFileGlobs;
|
|
4143
4323
|
//#endregion
|
|
4144
|
-
//#region src/scope/git-resolve.ts
|
|
4145
|
-
/**
|
|
4146
|
-
* Absolute-path git resolution (audit S1).
|
|
4147
|
-
*
|
|
4148
|
-
* `execFileSync("git", …)` resolves through PATH — including the CWD on
|
|
4149
|
-
* Windows (CreateProcess searches the current directory before PATH for
|
|
4150
|
-
* extension-less names depending on NoDefaultCurrentDirectoryInExePath).
|
|
4151
|
-
* Scanning an UNTRUSTED repo therefore let a checked-in `git.exe` (or
|
|
4152
|
-
* `git.bat`/`git.cmd`) hijack Mjölnir's own git invocations: the attacker
|
|
4153
|
-
* controls the diff output, the merge-base decision, and
|
|
4154
|
-
* `--scope changed`'s "what is new" answer — the exact data CI gates on.
|
|
4155
|
-
*
|
|
4156
|
-
* The fix: resolve git ONCE per process to an ABSOLUTE path by walking
|
|
4157
|
-
* the PATH directly (never consulting the CWD), verify the candidate is
|
|
4158
|
-
* an existing file, and pass that path to execFileSync. Resolution is
|
|
4159
|
-
* memoized; a failure to find a real git anywhere on PATH is recorded
|
|
4160
|
-
* and callers degrade without invoking a bare name.
|
|
4161
|
-
*/
|
|
4162
|
-
let resolvedGit = void 0;
|
|
4163
|
-
/**
|
|
4164
|
-
* The absolute path of the git binary Mjölnir will invoke, or null when
|
|
4165
|
-
* PATH carries no executable `git` at all. Callers must degrade without
|
|
4166
|
-
* invoking a bare name.
|
|
4167
|
-
*/
|
|
4168
|
-
function resolveGitPath() {
|
|
4169
|
-
if (resolvedGit !== void 0) return resolvedGit;
|
|
4170
|
-
const candidates = [process.platform === "win32" ? "git.exe" : "git"];
|
|
4171
|
-
if (process.platform === "win32") {
|
|
4172
|
-
const pathExt = process.env["PATHEXT"]?.split(";").filter(Boolean) ?? [];
|
|
4173
|
-
for (const ext of pathExt) candidates.push(`git${ext.toLowerCase()}`);
|
|
4174
|
-
}
|
|
4175
|
-
const pathValue = process.env["PATH"] ?? "";
|
|
4176
|
-
for (const dir of pathValue.split(delimiter)) {
|
|
4177
|
-
if (dir === "") continue;
|
|
4178
|
-
if (!isAbsolute(dir)) continue;
|
|
4179
|
-
for (const candidate of candidates) {
|
|
4180
|
-
const full = join(dir, candidate);
|
|
4181
|
-
try {
|
|
4182
|
-
if (existsSync(full)) {
|
|
4183
|
-
resolvedGit = full;
|
|
4184
|
-
return resolvedGit;
|
|
4185
|
-
}
|
|
4186
|
-
} catch {}
|
|
4187
|
-
}
|
|
4188
|
-
}
|
|
4189
|
-
`${pathValue.length}`;
|
|
4190
|
-
resolvedGit = null;
|
|
4191
|
-
return resolvedGit;
|
|
4192
|
-
}
|
|
4193
|
-
/**
|
|
4194
|
-
* Run git with the S1-resolved absolute binary path. Returns stdout, or
|
|
4195
|
-
* null when git is missing or the command fails — the same contract as
|
|
4196
|
-
* the previous inline `git()` helpers, with the hijack surface closed.
|
|
4197
|
-
*/
|
|
4198
|
-
function runGit(root, args) {
|
|
4199
|
-
const exe = resolveGitPath();
|
|
4200
|
-
if (!exe) return null;
|
|
4201
|
-
try {
|
|
4202
|
-
return execFileSync(exe, [
|
|
4203
|
-
"-C",
|
|
4204
|
-
root,
|
|
4205
|
-
...args
|
|
4206
|
-
], {
|
|
4207
|
-
encoding: "utf8",
|
|
4208
|
-
stdio: [
|
|
4209
|
-
"ignore",
|
|
4210
|
-
"pipe",
|
|
4211
|
-
"ignore"
|
|
4212
|
-
],
|
|
4213
|
-
timeout: 15e3
|
|
4214
|
-
});
|
|
4215
|
-
} catch {
|
|
4216
|
-
return null;
|
|
4217
|
-
}
|
|
4218
|
-
}
|
|
4219
|
-
//#endregion
|
|
4220
4324
|
//#region src/scope/changed.ts
|
|
4221
4325
|
/**
|
|
4222
4326
|
* Changed-scope engine (Sprint-Plan W6, Product-MVP §9 `--scope changed`).
|
|
@@ -11657,6 +11761,53 @@ function getRule(id) {
|
|
|
11657
11761
|
return RULES.find((r) => r.id === id);
|
|
11658
11762
|
}
|
|
11659
11763
|
//#endregion
|
|
11764
|
+
//#region src/lib/compare.ts
|
|
11765
|
+
/**
|
|
11766
|
+
* Order that does not depend on who is asking.
|
|
11767
|
+
*
|
|
11768
|
+
* `String.prototype.localeCompare` with no locale argument resolves against
|
|
11769
|
+
* the PROCESS's default locale — the ICU collation chosen by `LANG`,
|
|
11770
|
+
* `LC_ALL` or the OS. So a sort written as
|
|
11771
|
+
* `[...xs].sort((a, b) => a.localeCompare(b))` produces a different order on
|
|
11772
|
+
* a developer's `de_DE` machine than on an en-US CI runner. For a display
|
|
11773
|
+
* list that is a nuisance; for anything that reaches a hash, a cache key, a
|
|
11774
|
+
* digest or a machine contract it is a correctness bug, because the same
|
|
11775
|
+
* repository then produces two different fingerprints.
|
|
11776
|
+
*
|
|
11777
|
+
* Two comparators, one per kind of site:
|
|
11778
|
+
*
|
|
11779
|
+
* compareCodePoints — pure UTF-16 code-unit order. Identical everywhere,
|
|
11780
|
+
* independent of ICU version, and already what `Array.prototype.sort`
|
|
11781
|
+
* defaults to. Use for every value that reaches a fingerprint, a digest,
|
|
11782
|
+
* a cache key, or a contract, and for any ordering with no human-facing
|
|
11783
|
+
* reason to be pretty.
|
|
11784
|
+
*
|
|
11785
|
+
* compareLocalized(locale) — the same collator, PINNED to a named locale
|
|
11786
|
+
* instead of inheriting the environment, for a surface that genuinely
|
|
11787
|
+
* wants readable order (a table of rule titles, a Mermaid legend). Pinned
|
|
11788
|
+
* rather than removed because a reader-facing list of "Test / Logic /
|
|
11789
|
+
* Mock" should not be ordered by code unit.
|
|
11790
|
+
*
|
|
11791
|
+
* A bare `localeCompare` with no second argument in `src/` is a defect and
|
|
11792
|
+
* `tests/contract/deterministic-ordering.spec.ts` fails the build on it.
|
|
11793
|
+
*/
|
|
11794
|
+
/** Code-unit order. Locale-free, ICU-free, and the Array.sort default. */
|
|
11795
|
+
function compareCodePoints(a, b) {
|
|
11796
|
+
if (a === b) return 0;
|
|
11797
|
+
return a < b ? -1 : 1;
|
|
11798
|
+
}
|
|
11799
|
+
/**
|
|
11800
|
+
* A readable-order comparator pinned to `locale`.
|
|
11801
|
+
*
|
|
11802
|
+
* The pin is the point: `a.localeCompare(b)` reads the environment, and the
|
|
11803
|
+
* environment is a machine setting, not a property of the code. Callers that
|
|
11804
|
+
* want stable output ask for a named locale and get the same answer on every
|
|
11805
|
+
* machine that ships the same ICU data.
|
|
11806
|
+
*/
|
|
11807
|
+
function compareLocalized(locale) {
|
|
11808
|
+
return (a, b) => a.localeCompare(b, locale);
|
|
11809
|
+
}
|
|
11810
|
+
//#endregion
|
|
11660
11811
|
//#region src/scorer/scorer.ts
|
|
11661
11812
|
/**
|
|
11662
11813
|
* Transparent scorer (Product-MVP §8).
|
|
@@ -11713,7 +11864,7 @@ function computeDimensions(findings) {
|
|
|
11713
11864
|
deductions.set(f.category, deductions.get(f.category) + deductionFor(f));
|
|
11714
11865
|
}
|
|
11715
11866
|
for (const dim of byCategory.values()) dim.score = Math.max(0, 100 - deductions.get(dim.category));
|
|
11716
|
-
return [...byCategory.values()].sort((a, b) => a.category
|
|
11867
|
+
return [...byCategory.values()].sort((a, b) => compareCodePoints(a.category, b.category));
|
|
11717
11868
|
}
|
|
11718
11869
|
/**
|
|
11719
11870
|
* Deduction-mass ceilings (product-gap-remediation master plan P2,
|
|
@@ -12155,7 +12306,7 @@ function groupByRootCause(findings) {
|
|
|
12155
12306
|
function correlateFindings(findings) {
|
|
12156
12307
|
const conclusions = [];
|
|
12157
12308
|
const rootCauseGroups = groupByRootCause(findings);
|
|
12158
|
-
for (const [rootCause, group] of Array.from(rootCauseGroups.entries()).sort((a, b) => a[0]
|
|
12309
|
+
for (const [rootCause, group] of Array.from(rootCauseGroups.entries()).sort((a, b) => compareLocalized("en")(a[0], b[0]))) {
|
|
12159
12310
|
if (group.length < 2) continue;
|
|
12160
12311
|
const findingIds = group.map((f) => f.findingId ?? f.ruleId);
|
|
12161
12312
|
conclusions.push({
|
|
@@ -12167,7 +12318,7 @@ function correlateFindings(findings) {
|
|
|
12167
12318
|
});
|
|
12168
12319
|
}
|
|
12169
12320
|
const fileGroups = groupByFile(findings);
|
|
12170
|
-
for (const [file, group] of Array.from(fileGroups.entries()).sort((a, b) => a[0]
|
|
12321
|
+
for (const [file, group] of Array.from(fileGroups.entries()).sort((a, b) => compareCodePoints(a[0], b[0]))) {
|
|
12171
12322
|
if (group.length < 2) continue;
|
|
12172
12323
|
const alreadyConvergent = /* @__PURE__ */ new Set();
|
|
12173
12324
|
for (const c of conclusions) if (c.conclusionType === "CONVERGENT") for (const id of c.findingIds) alreadyConvergent.add(id);
|
|
@@ -12918,9 +13069,10 @@ function hashDir(dir, hash, depth) {
|
|
|
12918
13069
|
try {
|
|
12919
13070
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
12920
13071
|
} catch {
|
|
13072
|
+
recordDegradation("rules-tree-listing-unreadable");
|
|
12921
13073
|
return;
|
|
12922
13074
|
}
|
|
12923
|
-
entries.sort((a, b) => a.name
|
|
13075
|
+
entries.sort((a, b) => compareCodePoints(a.name, b.name));
|
|
12924
13076
|
for (const entry of entries) {
|
|
12925
13077
|
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
|
12926
13078
|
const full = join(dir, entry.name);
|
|
@@ -14319,11 +14471,11 @@ function listFiles(dir) {
|
|
|
14319
14471
|
return out.map((p) => p);
|
|
14320
14472
|
}
|
|
14321
14473
|
plainContext();
|
|
14322
|
-
function loadSuppressions(root) {
|
|
14474
|
+
function loadSuppressions(root, now = /* @__PURE__ */ new Date()) {
|
|
14323
14475
|
const { config } = loadConfig(root);
|
|
14324
14476
|
const entries = (config.ignore ?? []).map((ign) => ({
|
|
14325
14477
|
...ign,
|
|
14326
|
-
status: isSuppressionActive(ign,
|
|
14478
|
+
status: isSuppressionActive(ign, now) ? "active" : "expired"
|
|
14327
14479
|
}));
|
|
14328
14480
|
return {
|
|
14329
14481
|
total: entries.length,
|
|
@@ -15056,6 +15208,7 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
15056
15208
|
let rulesPartial = false;
|
|
15057
15209
|
let parseFailed = 0;
|
|
15058
15210
|
let parseFallbacks = 0;
|
|
15211
|
+
let astFallbackFiles = 0;
|
|
15059
15212
|
let scanned = 0;
|
|
15060
15213
|
let analyzed = 0;
|
|
15061
15214
|
for (const path of testFiles) {
|
|
@@ -15099,6 +15252,10 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
15099
15252
|
});
|
|
15100
15253
|
}
|
|
15101
15254
|
const wantsAst = adapter.parseAst !== void 0 && Date.now() <= deadline;
|
|
15255
|
+
if (adapter.parseAst !== void 0 && !wantsAst) {
|
|
15256
|
+
truncationReasons.add("ast-budget-fallback");
|
|
15257
|
+
astFallbackFiles++;
|
|
15258
|
+
}
|
|
15102
15259
|
const identity = (mode) => ({
|
|
15103
15260
|
relPath,
|
|
15104
15261
|
adapterId: adapter.id,
|
|
@@ -15216,7 +15373,8 @@ async function runFileAnalysisPhase(findings, testFiles, workspace, activeRules,
|
|
|
15216
15373
|
parseFailed,
|
|
15217
15374
|
parseFallbacks,
|
|
15218
15375
|
scanned,
|
|
15219
|
-
analyzed
|
|
15376
|
+
analyzed,
|
|
15377
|
+
astFallbackFiles
|
|
15220
15378
|
};
|
|
15221
15379
|
}
|
|
15222
15380
|
/**
|
|
@@ -15444,6 +15602,8 @@ function assembleScanResult(o) {
|
|
|
15444
15602
|
scopeUnrecognized: o.scopeUnrecognized,
|
|
15445
15603
|
parseFailed: o.parseFailed,
|
|
15446
15604
|
parseFallbacks: o.parseFallbacks ?? 0,
|
|
15605
|
+
...o.astFallbackFiles !== void 0 ? { astFallbackFiles: o.astFallbackFiles } : {},
|
|
15606
|
+
...o.degradations !== void 0 ? { degradations: o.degradations } : {},
|
|
15447
15607
|
...o.scopeInfo.degraded !== void 0 ? { scopeDegraded: o.scopeInfo.degraded } : {},
|
|
15448
15608
|
...o.runtimeIncomplete !== void 0 ? { runtimeIncomplete: o.runtimeIncomplete } : {},
|
|
15449
15609
|
identityIncomplete
|
|
@@ -15562,13 +15722,15 @@ function assembleScanResult(o) {
|
|
|
15562
15722
|
*/
|
|
15563
15723
|
async function runScan(args, hooks = {}) {
|
|
15564
15724
|
const started = Date.now();
|
|
15725
|
+
const degradationWindow = beginDegradationWindow();
|
|
15565
15726
|
const requestedDuration = Number.isFinite(args.maxDurationMs) ? args.maxDurationMs : DEFAULT_MAX_DURATION_MS$1;
|
|
15566
15727
|
const deadline = started + Math.min(Math.max(1, requestedDuration), MAX_DURATION_MS$1);
|
|
15567
15728
|
const discovered = discoverWorkspace(args.target);
|
|
15568
15729
|
const targetAbs = resolve(args.target);
|
|
15569
15730
|
const scanRoot = discovered && discovered.root !== targetAbs && targetAbs.startsWith(discovered.root + sep) ? {
|
|
15570
15731
|
...discovered,
|
|
15571
|
-
root: targetAbs
|
|
15732
|
+
root: targetAbs,
|
|
15733
|
+
packageJson: {}
|
|
15572
15734
|
} : discovered ?? fallbackWorkspace(targetAbs);
|
|
15573
15735
|
const workspace = scanRoot;
|
|
15574
15736
|
if (args.verbose) hooks.onConfigWarning?.(`verbose: scan root (config/ignore anchor): ${workspace.root}`);
|
|
@@ -15653,6 +15815,7 @@ async function runScan(args, hooks = {}) {
|
|
|
15653
15815
|
rulesPartial = analysis.rulesPartial;
|
|
15654
15816
|
parseFailed = analysis.parseFailed;
|
|
15655
15817
|
const parseFallbacks = analysis.parseFallbacks;
|
|
15818
|
+
const astFallbackFiles = analysis.astFallbackFiles;
|
|
15656
15819
|
const scanned = analysis.scanned;
|
|
15657
15820
|
const analyzed = analysis.analyzed;
|
|
15658
15821
|
const postScan = applyPostScanProcessing(findings, workspace, args, hooks, scanRoot, declarationsByFile, testDeclarationCount, tierByRuleId, REVISION_BY_RULE_ID);
|
|
@@ -15671,6 +15834,8 @@ async function runScan(args, hooks = {}) {
|
|
|
15671
15834
|
scopeUnrecognized,
|
|
15672
15835
|
parseFailed,
|
|
15673
15836
|
parseFallbacks,
|
|
15837
|
+
astFallbackFiles,
|
|
15838
|
+
degradations: summarizeDegradations(degradationsSince(degradationWindow)),
|
|
15674
15839
|
scanned,
|
|
15675
15840
|
analyzed,
|
|
15676
15841
|
testFiles,
|
|
@@ -15697,6 +15862,39 @@ async function runScan(args, hooks = {}) {
|
|
|
15697
15862
|
resetTsMorphProject();
|
|
15698
15863
|
return result;
|
|
15699
15864
|
}
|
|
15865
|
+
//#endregion
|
|
15866
|
+
//#region src/cli-io.ts
|
|
15867
|
+
/**
|
|
15868
|
+
* The ONE error-to-message derivation in `src/`.
|
|
15869
|
+
*
|
|
15870
|
+
* This tree carried four near-copies: two `errorMessage` (byte-identical) and
|
|
15871
|
+
* two `errorText`. The two `errorText` copies were not identical, which is the
|
|
15872
|
+
* part that mattered — `String(someObject)` renders `[object Object]`, so the
|
|
15873
|
+
* copy that lost the payload turned a hostile non-Error throw into a message
|
|
15874
|
+
* that names nothing. The richer arms are kept here, so every call site gets
|
|
15875
|
+
* the informative one and the difference stops being a per-file accident.
|
|
15876
|
+
*
|
|
15877
|
+
* The arms, in order:
|
|
15878
|
+
* Error -> `.message`. The 99% case, and the only one that
|
|
15879
|
+
* carries a stack the caller may still want.
|
|
15880
|
+
* string -> itself. A thrown string is already the message.
|
|
15881
|
+
* plain object -> `JSON.stringify`. A non-Error throw of `{ code:
|
|
15882
|
+
* "EISDIR" }` is a REAL failure shape in Node, and
|
|
15883
|
+
* `[object Object]` hides the only field that says
|
|
15884
|
+
* what went wrong.
|
|
15885
|
+
* anything else -> `String(err)`. Symbols and undefined included, so
|
|
15886
|
+
* nothing vanishes.
|
|
15887
|
+
*
|
|
15888
|
+
* The call this replaces was `err instanceof Error ? err.message : String(err)`
|
|
15889
|
+
* in `src/mcp/server.ts` and `src/commands/doctor.ts`; `tests/cli/summary.spec.ts`
|
|
15890
|
+
* already pins the object arm.
|
|
15891
|
+
*/
|
|
15892
|
+
function errorMessage(err) {
|
|
15893
|
+
if (err instanceof Error) return err.message;
|
|
15894
|
+
if (typeof err === "string") return err;
|
|
15895
|
+
if (typeof err === "object" && err !== null) return JSON.stringify(err);
|
|
15896
|
+
return String(err);
|
|
15897
|
+
}
|
|
15700
15898
|
/**
|
|
15701
15899
|
* Fields that participate in the verification digest: identity (ruleId,
|
|
15702
15900
|
* detectorRevision, file, line, column) + evidence (severity,
|
|
@@ -15849,6 +16047,11 @@ function explainRule(ruleId, fixturesRoot) {
|
|
|
15849
16047
|
ok: false,
|
|
15850
16048
|
error: `Unknown rule ID "${ruleId}". Run \`mjolnir rules\` for the full catalog.`
|
|
15851
16049
|
};
|
|
16050
|
+
const window = beginDegradationWindow();
|
|
16051
|
+
const degraded = () => {
|
|
16052
|
+
const counts = summarizeDegradations(degradationsSince(window));
|
|
16053
|
+
return counts.length > 0 ? { exampleDegraded: counts } : {};
|
|
16054
|
+
};
|
|
15852
16055
|
const fixturePath = firstFixtureFile(join(fixturesRoot, ruleId, "must-fire"));
|
|
15853
16056
|
if (!fixturePath) return {
|
|
15854
16057
|
ok: true,
|
|
@@ -15858,9 +16061,11 @@ function explainRule(ruleId, fixturesRoot) {
|
|
|
15858
16061
|
try {
|
|
15859
16062
|
text = readFileSync(fixturePath, "utf8").replace(/\r\n/g, "\n");
|
|
15860
16063
|
} catch {
|
|
16064
|
+
recordDegradation("explain-fixture-unreadable");
|
|
15861
16065
|
return {
|
|
15862
16066
|
ok: true,
|
|
15863
|
-
rule
|
|
16067
|
+
rule,
|
|
16068
|
+
...degraded()
|
|
15864
16069
|
};
|
|
15865
16070
|
}
|
|
15866
16071
|
const normalizedPath = fixturePath.replaceAll("\\", "/");
|
|
@@ -15870,9 +16075,11 @@ function explainRule(ruleId, fixturesRoot) {
|
|
|
15870
16075
|
else try {
|
|
15871
16076
|
ast = isAzurePipelineFixture(normalizedPath) ? parseAzurePipeline(text) : parseWorkflow(text);
|
|
15872
16077
|
} catch {
|
|
16078
|
+
recordDegradation("explain-workflow-parse-failed");
|
|
15873
16079
|
return {
|
|
15874
16080
|
ok: true,
|
|
15875
|
-
rule
|
|
16081
|
+
rule,
|
|
16082
|
+
...degraded()
|
|
15876
16083
|
};
|
|
15877
16084
|
}
|
|
15878
16085
|
}
|
|
@@ -15889,9 +16096,11 @@ function explainRule(ruleId, fixturesRoot) {
|
|
|
15889
16096
|
codeText
|
|
15890
16097
|
});
|
|
15891
16098
|
} catch {
|
|
16099
|
+
recordDegradation("explain-rule-crash");
|
|
15892
16100
|
return {
|
|
15893
16101
|
ok: true,
|
|
15894
|
-
rule
|
|
16102
|
+
rule,
|
|
16103
|
+
...degraded()
|
|
15895
16104
|
};
|
|
15896
16105
|
}
|
|
15897
16106
|
const example = findings[0];
|
|
@@ -16652,15 +16861,11 @@ async function handleToolCall(call) {
|
|
|
16652
16861
|
id: call.id,
|
|
16653
16862
|
error: {
|
|
16654
16863
|
code: MCP_ERRORS.INTERNAL,
|
|
16655
|
-
message: errorMessage
|
|
16864
|
+
message: errorMessage(err)
|
|
16656
16865
|
}
|
|
16657
16866
|
};
|
|
16658
16867
|
}
|
|
16659
16868
|
}
|
|
16660
|
-
/** Normalize any thrown value to a string (unit-tested both arms). */
|
|
16661
|
-
function errorMessage$1(err) {
|
|
16662
|
-
return err instanceof Error ? err.message : String(err);
|
|
16663
|
-
}
|
|
16664
16869
|
/** JSON-RPC request shape validation (strict — §21). */
|
|
16665
16870
|
function validateRequest(msg) {
|
|
16666
16871
|
if (typeof msg !== "object" || msg === null) return {
|
|
@@ -16763,14 +16968,6 @@ async function handleMcpMessage(msg) {
|
|
|
16763
16968
|
};
|
|
16764
16969
|
}
|
|
16765
16970
|
//#endregion
|
|
16766
|
-
//#region src/cli-io.ts
|
|
16767
|
-
/**
|
|
16768
|
-
* The single error-to-message derivation shared by every catch site:
|
|
16769
|
-
* Error instances render their message; anything else is stringified
|
|
16770
|
-
* honestly (a hostile non-Error throw must surface, not vanish).
|
|
16771
|
-
*/
|
|
16772
|
-
const errorMessage = (err) => err instanceof Error ? err.message : String(err);
|
|
16773
|
-
//#endregion
|
|
16774
16971
|
//#region src/mcp/transport.ts
|
|
16775
16972
|
/**
|
|
16776
16973
|
* MCP stdio transport loop (blueprint §21 — the framing around the
|