tamperward 2.19.0 → 2.20.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/README.md +8 -2
- package/dist/cli/index.js +659 -372
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -216,6 +216,10 @@ function defaultPolicy(version = 1) {
|
|
|
216
216
|
// broad any in annotation/generic position is common in legit code (measured ~84-100% FP as a
|
|
217
217
|
// block rule on real TS), so it WARNs until a semantic (error-silencing-aware) signal earns block.
|
|
218
218
|
"ts-any-launder": { severity: "warn" },
|
|
219
|
+
// net growth of ordinary `as T` / `<T>x` / `x!` assertions in non-test source (#383).
|
|
220
|
+
// Fires on a large share of legitimate TypeScript mainline diffs (measured in
|
|
221
|
+
// harness/fp-study/CAST-GROWTH-CORPUS.md), so it is a review prompt, not a gate.
|
|
222
|
+
"ts-cast-growth": { severity: "warn" },
|
|
219
223
|
"lint-suppression": { severity: "block" },
|
|
220
224
|
"coverage-lowering": { severity: "block" },
|
|
221
225
|
"ci-tampering": { severity: "block" },
|
|
@@ -244,6 +248,45 @@ function defaultPolicy(version = 1) {
|
|
|
244
248
|
return p;
|
|
245
249
|
}
|
|
246
250
|
|
|
251
|
+
// src/narrow.ts
|
|
252
|
+
function isRecord(v) {
|
|
253
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
254
|
+
}
|
|
255
|
+
function errnoCode(e) {
|
|
256
|
+
const code2 = isRecord(e) ? e.code : void 0;
|
|
257
|
+
return typeof code2 === "string" ? code2 : void 0;
|
|
258
|
+
}
|
|
259
|
+
function errorMessage(e) {
|
|
260
|
+
return e instanceof Error ? e.message : String(e);
|
|
261
|
+
}
|
|
262
|
+
function textOf(v) {
|
|
263
|
+
if (typeof v === "string") return v;
|
|
264
|
+
if (Buffer.isBuffer(v)) return v.toString("utf8");
|
|
265
|
+
return "";
|
|
266
|
+
}
|
|
267
|
+
function execFailure(e) {
|
|
268
|
+
const r = isRecord(e) ? e : {};
|
|
269
|
+
return {
|
|
270
|
+
stdout: textOf(r.stdout),
|
|
271
|
+
stderr: textOf(r.stderr),
|
|
272
|
+
status: typeof r.status === "number" ? r.status : null,
|
|
273
|
+
code: errnoCode(e),
|
|
274
|
+
message: errorMessage(e)
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
function finiteNumber(v) {
|
|
278
|
+
return typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
279
|
+
}
|
|
280
|
+
function stringOrUndefined(v) {
|
|
281
|
+
return typeof v === "string" ? v : void 0;
|
|
282
|
+
}
|
|
283
|
+
function nullableString(v) {
|
|
284
|
+
return v === null || typeof v === "string" ? v : void 0;
|
|
285
|
+
}
|
|
286
|
+
function nullableNumber(v) {
|
|
287
|
+
return v === null || typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
288
|
+
}
|
|
289
|
+
|
|
247
290
|
// src/disk.ts
|
|
248
291
|
var READ_CAP = 64 * 1024 * 1024;
|
|
249
292
|
var NO_META = { mode: 0, size: 0, mtimeMs: 0 };
|
|
@@ -251,7 +294,7 @@ function errText(e) {
|
|
|
251
294
|
return e instanceof Error ? e.message : String(e);
|
|
252
295
|
}
|
|
253
296
|
function errCode(e) {
|
|
254
|
-
return
|
|
297
|
+
return errnoCode(e) ?? "";
|
|
255
298
|
}
|
|
256
299
|
function irregularName(st) {
|
|
257
300
|
if (st.isFIFO()) return "fifo";
|
|
@@ -341,7 +384,7 @@ function inspectResolved(abs2) {
|
|
|
341
384
|
}
|
|
342
385
|
return inspectPath(real);
|
|
343
386
|
}
|
|
344
|
-
function
|
|
387
|
+
function textOf2(e) {
|
|
345
388
|
return e.content == null ? null : e.content.toString("utf8");
|
|
346
389
|
}
|
|
347
390
|
function describe(e, shown) {
|
|
@@ -675,9 +718,9 @@ function git(args, cwd) {
|
|
|
675
718
|
env: trustedGitEnv()
|
|
676
719
|
});
|
|
677
720
|
} catch (e) {
|
|
678
|
-
const
|
|
679
|
-
const detail =
|
|
680
|
-
throw new Error(detail ? `git ${args[0]}: ${detail}` :
|
|
721
|
+
const failure = execFailure(e);
|
|
722
|
+
const detail = failure.stderr.trim();
|
|
723
|
+
throw new Error(detail ? `git ${args[0]}: ${detail}` : failure.message || `git ${args[0]} failed`);
|
|
681
724
|
}
|
|
682
725
|
}
|
|
683
726
|
function blobAt(rev, path, cwd) {
|
|
@@ -688,7 +731,7 @@ function blobAt(rev, path, cwd) {
|
|
|
688
731
|
}
|
|
689
732
|
}
|
|
690
733
|
function fromDisk(path, cwd) {
|
|
691
|
-
return
|
|
734
|
+
return textOf2(inspectRel(cwd ?? process.cwd(), path));
|
|
692
735
|
}
|
|
693
736
|
function enrich(c, beforeReader, afterReader) {
|
|
694
737
|
if (c.kind !== "file") return c;
|
|
@@ -807,7 +850,7 @@ function ignoredAdds(opts = {}, keep, exclude) {
|
|
|
807
850
|
}
|
|
808
851
|
function diffWorktreeWithUntracked(opts = {}, keepIgnored) {
|
|
809
852
|
const tracked = diffWorktree(opts);
|
|
810
|
-
const seen = new Set(tracked.
|
|
853
|
+
const seen = new Set(tracked.flatMap((c) => c.kind === "file" ? [c.path] : []));
|
|
811
854
|
const untracked = untrackedAdds(opts, void 0, seen);
|
|
812
855
|
for (const c of untracked) if (c.kind === "file") seen.add(c.path);
|
|
813
856
|
const ignored = keepIgnored ? ignoredAdds(opts, keepIgnored, seen) : [];
|
|
@@ -832,7 +875,7 @@ function asStr(v) {
|
|
|
832
875
|
return typeof v === "string" ? v : "";
|
|
833
876
|
}
|
|
834
877
|
function readDisk(path) {
|
|
835
|
-
return
|
|
878
|
+
return textOf2(inspectResolved(path));
|
|
836
879
|
}
|
|
837
880
|
function abs(path, cwd) {
|
|
838
881
|
return resolve(cwd, path);
|
|
@@ -865,11 +908,11 @@ function synthFileChange(displayPath, before, after) {
|
|
|
865
908
|
maxBuffer: 256 * 1024 * 1024
|
|
866
909
|
});
|
|
867
910
|
} catch (e) {
|
|
868
|
-
const
|
|
869
|
-
if (
|
|
870
|
-
throw new Error(`cannot diff the incoming edit to ${displayPath}: ${
|
|
911
|
+
const failure = execFailure(e);
|
|
912
|
+
if (failure.status !== 1) {
|
|
913
|
+
throw new Error(`cannot diff the incoming edit to ${displayPath}: ${failure.code ?? failure.message}`);
|
|
871
914
|
}
|
|
872
|
-
raw =
|
|
915
|
+
raw = failure.stdout;
|
|
873
916
|
}
|
|
874
917
|
} finally {
|
|
875
918
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -905,8 +948,8 @@ function changesFromClaudeHook(input, cwd) {
|
|
|
905
948
|
let after = before;
|
|
906
949
|
const edits = Array.isArray(ti.edits) ? ti.edits : [];
|
|
907
950
|
for (const raw of edits) {
|
|
908
|
-
const ed = raw;
|
|
909
|
-
after = applyEdit(after, ed.old_string
|
|
951
|
+
const ed = isRecord(raw) ? raw : {};
|
|
952
|
+
after = applyEdit(after, asStr(ed.old_string), asStr(ed.new_string));
|
|
910
953
|
}
|
|
911
954
|
return synthFileChange(relForDisplay(abs(fp, cwd), cwd), before, after);
|
|
912
955
|
}
|
|
@@ -1110,7 +1153,7 @@ var noVerify = {
|
|
|
1110
1153
|
};
|
|
1111
1154
|
|
|
1112
1155
|
// src/detectors/ts-any-cast.ts
|
|
1113
|
-
import
|
|
1156
|
+
import ts2 from "typescript";
|
|
1114
1157
|
|
|
1115
1158
|
// src/diff/select.ts
|
|
1116
1159
|
function addedLines(c) {
|
|
@@ -1190,38 +1233,168 @@ function insideStringLiteral(line, idx, lang) {
|
|
|
1190
1233
|
return quote !== null;
|
|
1191
1234
|
}
|
|
1192
1235
|
|
|
1236
|
+
// src/detectors/ts-cast-growth.ts
|
|
1237
|
+
import ts from "typescript";
|
|
1238
|
+
var RULE2 = "ts-cast-growth";
|
|
1239
|
+
var DECLARATION = /\.d\.(?:ts|mts|cts)$/;
|
|
1240
|
+
var OUT_OF_BUDGET_PATH = /(?:^|\/)(?:node_modules|vendor|third_party|dist|build|generated|__generated__)\/|\.(?:generated|gen)\.[cm]?[jt]sx?$/;
|
|
1241
|
+
var GENERATED_HEADER = /@generated\b|\bAUTO-?GENERATED\b|\bDO NOT EDIT\b/i;
|
|
1242
|
+
var HEADER_LINES = 20;
|
|
1243
|
+
function scriptKind(path) {
|
|
1244
|
+
const lower = path.toLowerCase();
|
|
1245
|
+
if (lower.endsWith(".tsx")) return ts.ScriptKind.TSX;
|
|
1246
|
+
if (lower.endsWith(".ts") || lower.endsWith(".mts") || lower.endsWith(".cts")) return ts.ScriptKind.TS;
|
|
1247
|
+
if (lower.endsWith(".jsx")) return ts.ScriptKind.JSX;
|
|
1248
|
+
return ts.ScriptKind.JS;
|
|
1249
|
+
}
|
|
1250
|
+
function outOfBudget(path, before) {
|
|
1251
|
+
if (DECLARATION.test(path) || OUT_OF_BUDGET_PATH.test(path)) return true;
|
|
1252
|
+
return before != null && GENERATED_HEADER.test(before.split("\n", HEADER_LINES).join("\n"));
|
|
1253
|
+
}
|
|
1254
|
+
function unparenthesized(e) {
|
|
1255
|
+
let x = e;
|
|
1256
|
+
while (ts.isParenthesizedExpression(x)) x = x.expression;
|
|
1257
|
+
return x;
|
|
1258
|
+
}
|
|
1259
|
+
function assertedType(node) {
|
|
1260
|
+
let t = node.type;
|
|
1261
|
+
while (ts.isParenthesizedTypeNode(t)) t = t.type;
|
|
1262
|
+
return t;
|
|
1263
|
+
}
|
|
1264
|
+
function isDoubleCast(node) {
|
|
1265
|
+
const inner = unparenthesized(node.expression);
|
|
1266
|
+
return (ts.isAsExpression(inner) || ts.isTypeAssertionExpression(inner)) && assertedType(inner).kind === ts.SyntaxKind.UnknownKeyword;
|
|
1267
|
+
}
|
|
1268
|
+
function surfaceOf(path, src) {
|
|
1269
|
+
let sf;
|
|
1270
|
+
try {
|
|
1271
|
+
sf = ts.createSourceFile(path, src, ts.ScriptTarget.Latest, true, scriptKind(path));
|
|
1272
|
+
} catch {
|
|
1273
|
+
return null;
|
|
1274
|
+
}
|
|
1275
|
+
const diagnostics = "parseDiagnostics" in sf && Array.isArray(sf.parseDiagnostics) ? sf.parseDiagnostics : [];
|
|
1276
|
+
if (diagnostics.length > 0) return null;
|
|
1277
|
+
const surface = { type: 0, nonNull: 0, assertions: [] };
|
|
1278
|
+
const lines = src.split("\n");
|
|
1279
|
+
const record = (kind, node) => {
|
|
1280
|
+
const line = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
1281
|
+
surface.assertions.push({ kind, line, text: node.getText(sf).replace(/\s+/g, " "), lineText: (lines[line - 1] ?? "").trim() });
|
|
1282
|
+
if (kind === "type") surface.type++;
|
|
1283
|
+
else surface.nonNull++;
|
|
1284
|
+
};
|
|
1285
|
+
const isRow4OrHonest = (node) => {
|
|
1286
|
+
const type = assertedType(node);
|
|
1287
|
+
if (type.kind === ts.SyntaxKind.AnyKeyword || type.kind === ts.SyntaxKind.UnknownKeyword) return true;
|
|
1288
|
+
return ts.isTypeReferenceNode(type) && ts.isIdentifier(type.typeName) && type.typeName.text === "const";
|
|
1289
|
+
};
|
|
1290
|
+
const visit = (node) => {
|
|
1291
|
+
if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) {
|
|
1292
|
+
if (!isDoubleCast(node) && !isRow4OrHonest(node)) record("type", node);
|
|
1293
|
+
} else if (ts.isNonNullExpression(node)) {
|
|
1294
|
+
record("non-null", node);
|
|
1295
|
+
}
|
|
1296
|
+
ts.forEachChild(node, visit);
|
|
1297
|
+
};
|
|
1298
|
+
visit(sf);
|
|
1299
|
+
return surface;
|
|
1300
|
+
}
|
|
1301
|
+
function unmatchedAfter(before, after) {
|
|
1302
|
+
const n = before.length;
|
|
1303
|
+
const m = after.length;
|
|
1304
|
+
const same = (i2, j2) => before[i2].lineText === after[j2].lineText && before[i2].text === after[j2].text;
|
|
1305
|
+
const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
1306
|
+
for (let i2 = n - 1; i2 >= 0; i2--) {
|
|
1307
|
+
for (let j2 = m - 1; j2 >= 0; j2--) {
|
|
1308
|
+
lcs[i2][j2] = same(i2, j2) ? lcs[i2 + 1][j2 + 1] + 1 : Math.max(lcs[i2 + 1][j2], lcs[i2][j2 + 1]);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
const out2 = [];
|
|
1312
|
+
let i = 0;
|
|
1313
|
+
let j = 0;
|
|
1314
|
+
while (j < m) {
|
|
1315
|
+
if (i < n && same(i, j)) {
|
|
1316
|
+
i++;
|
|
1317
|
+
j++;
|
|
1318
|
+
} else if (i < n && lcs[i + 1][j] >= lcs[i][j + 1]) {
|
|
1319
|
+
i++;
|
|
1320
|
+
} else {
|
|
1321
|
+
out2.push(after[j]);
|
|
1322
|
+
j++;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
return out2;
|
|
1326
|
+
}
|
|
1327
|
+
var tsCastGrowth = {
|
|
1328
|
+
id: RULE2,
|
|
1329
|
+
surface: ["file"],
|
|
1330
|
+
certainty: "mechanical",
|
|
1331
|
+
run(changes, policy) {
|
|
1332
|
+
const out2 = [];
|
|
1333
|
+
for (const c of changes) {
|
|
1334
|
+
if (c.kind !== "file" || c.op === "delete" || c.after == null) continue;
|
|
1335
|
+
if (!isCodeFile(c.path) || outOfBudget(c.path, c.before)) continue;
|
|
1336
|
+
if (protectedCategory(c.path, policy) === "tests") continue;
|
|
1337
|
+
const after = surfaceOf(c.path, c.after);
|
|
1338
|
+
const before = c.before == null ? { type: 0, nonNull: 0, assertions: [] } : surfaceOf(c.path, c.before);
|
|
1339
|
+
if (!after || !before) continue;
|
|
1340
|
+
const dType = after.type - before.type;
|
|
1341
|
+
const dNonNull = after.nonNull - before.nonNull;
|
|
1342
|
+
if (dType <= 0 && dNonNull <= 0) continue;
|
|
1343
|
+
const grownKinds = /* @__PURE__ */ new Set([...dType > 0 ? ["type"] : [], ...dNonNull > 0 ? ["non-null"] : []]);
|
|
1344
|
+
const addedLineNumbers2 = new Set(addedLines(c).flatMap((l) => l.newLine == null ? [] : [l.newLine]));
|
|
1345
|
+
const onAddedLine = after.assertions.find((a) => grownKinds.has(a.kind) && addedLineNumbers2.has(a.line));
|
|
1346
|
+
const first = onAddedLine ?? unmatchedAfter(before.assertions, after.assertions).find((a) => grownKinds.has(a.kind)) ?? after.assertions.find((a) => grownKinds.has(a.kind));
|
|
1347
|
+
const parts = [];
|
|
1348
|
+
if (dType > 0) parts.push(`+${dType} type assertion${dType === 1 ? "" : "s"} (\`as T\` / \`<T>x\`)`);
|
|
1349
|
+
if (dNonNull > 0) parts.push(`+${dNonNull} non-null assertion${dNonNull === 1 ? "" : "s"} (\`x!\`)`);
|
|
1350
|
+
out2.push(
|
|
1351
|
+
makeFinding(RULE2, policy, {
|
|
1352
|
+
file: c.path,
|
|
1353
|
+
...first ? { line: first.line } : {},
|
|
1354
|
+
message: `The unsafe assertion surface grew: ${parts.join(", ")} net of any removed in this change (now ${after.type} type / ${after.nonNull} non-null in this file).`,
|
|
1355
|
+
evidence: first?.text ?? parts.join(", "),
|
|
1356
|
+
remediation: "Prove the value instead of asserting it: parse or validate at the boundary, use a type guard or discriminated union, or handle the nullable case. If the assertion is genuinely unavoidable, keep it at the narrowest boundary and say why in a comment.",
|
|
1357
|
+
defaultSeverity: "warn"
|
|
1358
|
+
})
|
|
1359
|
+
);
|
|
1360
|
+
}
|
|
1361
|
+
return out2;
|
|
1362
|
+
}
|
|
1363
|
+
};
|
|
1364
|
+
|
|
1193
1365
|
// src/detectors/ts-any-cast.ts
|
|
1194
1366
|
var BLOCK_RULE = "ts-any-cast";
|
|
1195
1367
|
var WARN_RULE = "ts-any-launder";
|
|
1196
|
-
var DOUBLE_CAST = /\bas\s
|
|
1368
|
+
var DOUBLE_CAST = /\bas\s+\(*\s*unknown\s*\)*\s+as\b/g;
|
|
1197
1369
|
var SUPPRESS = /@ts-(?:ignore|expect-error|nocheck)\b/g;
|
|
1198
1370
|
var JSDOC_ANY_CAST = /\/\*\*\s*@type\s*\{\s*any\s*\}\s*\*\/\s*\(/g;
|
|
1199
1371
|
var JS_FILE = /\.(?:js|jsx|mjs|cjs)$/;
|
|
1200
1372
|
var countMatches = (s, re) => (s.match(re) || []).length;
|
|
1201
1373
|
function countAny(src) {
|
|
1202
|
-
const r = { cast: 0, broad: 0 };
|
|
1374
|
+
const r = { cast: 0, broad: 0, double: 0 };
|
|
1203
1375
|
try {
|
|
1204
|
-
const sf =
|
|
1376
|
+
const sf = ts2.createSourceFile("f.ts", src, ts2.ScriptTarget.Latest, true, ts2.ScriptKind.TS);
|
|
1205
1377
|
const visit = (node) => {
|
|
1206
|
-
if (node
|
|
1378
|
+
if ((ts2.isAsExpression(node) || ts2.isTypeAssertionExpression(node)) && isDoubleCast(node)) r.double++;
|
|
1379
|
+
if (node.kind === ts2.SyntaxKind.AnyKeyword) {
|
|
1207
1380
|
let target = node;
|
|
1208
1381
|
let p = target.parent;
|
|
1209
|
-
while (p &&
|
|
1382
|
+
while (p && ts2.isParenthesizedTypeNode(p)) {
|
|
1210
1383
|
target = p;
|
|
1211
1384
|
p = p.parent;
|
|
1212
1385
|
}
|
|
1213
|
-
const isCast = p &&
|
|
1386
|
+
const isCast = p && ts2.isAsExpression(p) && p.type === target || p && ts2.isTypeAssertionExpression(p) && p.type === target;
|
|
1214
1387
|
if (isCast) r.cast++;
|
|
1215
1388
|
else r.broad++;
|
|
1216
1389
|
}
|
|
1217
|
-
|
|
1390
|
+
ts2.forEachChild(node, visit);
|
|
1218
1391
|
};
|
|
1219
1392
|
visit(sf);
|
|
1220
1393
|
} catch {
|
|
1221
1394
|
}
|
|
1222
1395
|
return r;
|
|
1223
1396
|
}
|
|
1224
|
-
var NARROW_LINE = /\bas\s+\(*\s*any\b|<\s*any\s*>|\bas\s
|
|
1397
|
+
var NARROW_LINE = /\bas\s+\(*\s*any\b|<\s*any\s*>|\bas\s+\(*\s*unknown\s*\)*\s+as\b|@ts-(?:ignore|expect-error|nocheck)\b/;
|
|
1225
1398
|
var BROAD_LINE = /:\s*any\b|<[^<>]*\bany\b[^<>]*>/;
|
|
1226
1399
|
var BLOCK_REMEDIATION = "Fix the underlying type instead of silencing the checker; do not cast to `any`.";
|
|
1227
1400
|
var WARN_REMEDIATION = "Prefer a precise type or `unknown` + a guard over `any` here \u2014 flagged for review.";
|
|
@@ -1240,7 +1413,7 @@ var tsAnyCast = {
|
|
|
1240
1413
|
const b = countAny(before);
|
|
1241
1414
|
const dCast = a.cast - b.cast;
|
|
1242
1415
|
const dBroad = a.broad - b.broad;
|
|
1243
|
-
const dDouble = countMatches(c.after, DOUBLE_CAST) - countMatches(before, DOUBLE_CAST);
|
|
1416
|
+
const dDouble = Math.max(a.double - b.double, countMatches(c.after, DOUBLE_CAST) - countMatches(before, DOUBLE_CAST));
|
|
1244
1417
|
const dSuppr = countMatches(c.after, SUPPRESS) - countMatches(before, SUPPRESS);
|
|
1245
1418
|
const dJsdoc = JS_FILE.test(c.path) ? countMatches(c.after, JSDOC_ANY_CAST) - countMatches(before, JSDOC_ANY_CAST) : 0;
|
|
1246
1419
|
const blockReasons = [];
|
|
@@ -1304,7 +1477,7 @@ var tsAnyCast = {
|
|
|
1304
1477
|
};
|
|
1305
1478
|
|
|
1306
1479
|
// src/detectors/lint-suppression.ts
|
|
1307
|
-
var
|
|
1480
|
+
var RULE3 = "lint-suppression";
|
|
1308
1481
|
var PATTERNS = {
|
|
1309
1482
|
js: [
|
|
1310
1483
|
{ re: /eslint-disable(?:-next-line|-line)?\b/, why: "eslint-disable suppresses lint rules" },
|
|
@@ -1352,7 +1525,7 @@ var PATTERNS = {
|
|
|
1352
1525
|
]
|
|
1353
1526
|
};
|
|
1354
1527
|
var lintSuppression = {
|
|
1355
|
-
id:
|
|
1528
|
+
id: RULE3,
|
|
1356
1529
|
surface: ["file"],
|
|
1357
1530
|
certainty: "mechanical",
|
|
1358
1531
|
run(changes, policy) {
|
|
@@ -1368,7 +1541,7 @@ var lintSuppression = {
|
|
|
1368
1541
|
const m = p.re.exec(l.content);
|
|
1369
1542
|
if (!m || insideStringLiteral(l.content, m.index, lang)) continue;
|
|
1370
1543
|
out2.push(
|
|
1371
|
-
makeFinding(
|
|
1544
|
+
makeFinding(RULE3, policy, {
|
|
1372
1545
|
file: c.path,
|
|
1373
1546
|
line: l.newLine ?? void 0,
|
|
1374
1547
|
message: `Lint suppression added: ${p.why}.`,
|
|
@@ -1385,8 +1558,8 @@ var lintSuppression = {
|
|
|
1385
1558
|
};
|
|
1386
1559
|
|
|
1387
1560
|
// src/detectors/test-skip.ts
|
|
1388
|
-
import
|
|
1389
|
-
var
|
|
1561
|
+
import ts3 from "typescript";
|
|
1562
|
+
var RULE4 = "test-skip";
|
|
1390
1563
|
var acc = (names) => `(?:\\s*\\.\\s*(?:${names})(?![\\w$])|\\s*\\[\\s*['"\`](?:${names})['"\`]\\s*\\])`;
|
|
1391
1564
|
var JS_RUNNER = "\\b(?:it|test|describe|suite)";
|
|
1392
1565
|
var JS_MOD = "concurrent|sequential|shuffle";
|
|
@@ -1489,11 +1662,11 @@ var JS_TEST_MODULES = /* @__PURE__ */ new Set([
|
|
|
1489
1662
|
"bun:test",
|
|
1490
1663
|
"@playwright/test"
|
|
1491
1664
|
]);
|
|
1492
|
-
function
|
|
1493
|
-
if (/\.tsx$/i.test(path)) return
|
|
1494
|
-
if (/\.jsx$/i.test(path)) return
|
|
1495
|
-
if (/\.(?:js|mjs|cjs)$/i.test(path)) return
|
|
1496
|
-
return
|
|
1665
|
+
function scriptKind2(path) {
|
|
1666
|
+
if (/\.tsx$/i.test(path)) return ts3.ScriptKind.TSX;
|
|
1667
|
+
if (/\.jsx$/i.test(path)) return ts3.ScriptKind.JSX;
|
|
1668
|
+
if (/\.(?:js|mjs|cjs)$/i.test(path)) return ts3.ScriptKind.JS;
|
|
1669
|
+
return ts3.ScriptKind.TS;
|
|
1497
1670
|
}
|
|
1498
1671
|
function addedLineNumbers(c) {
|
|
1499
1672
|
const out2 = /* @__PURE__ */ new Set();
|
|
@@ -1505,32 +1678,32 @@ function addedLineNumbers(c) {
|
|
|
1505
1678
|
return out2;
|
|
1506
1679
|
}
|
|
1507
1680
|
function moduleName(expr) {
|
|
1508
|
-
return
|
|
1681
|
+
return ts3.isStringLiteralLike(expr) ? expr.text : null;
|
|
1509
1682
|
}
|
|
1510
1683
|
function requiredModule(expr) {
|
|
1511
|
-
if (!
|
|
1684
|
+
if (!ts3.isCallExpression(expr) || !ts3.isIdentifier(expr.expression) || expr.expression.text !== "require" || expr.arguments.length !== 1) return null;
|
|
1512
1685
|
return moduleName(expr.arguments[0]);
|
|
1513
1686
|
}
|
|
1514
1687
|
function astContext(path, source) {
|
|
1515
1688
|
const rootName = path.startsWith("/") ? path : "/tamperward/" + path;
|
|
1516
|
-
const sf =
|
|
1689
|
+
const sf = ts3.createSourceFile(
|
|
1517
1690
|
rootName,
|
|
1518
1691
|
source,
|
|
1519
|
-
|
|
1692
|
+
ts3.ScriptTarget.Latest,
|
|
1520
1693
|
true,
|
|
1521
|
-
|
|
1694
|
+
scriptKind2(path)
|
|
1522
1695
|
);
|
|
1523
|
-
const diagnostics = sf.parseDiagnostics
|
|
1696
|
+
const diagnostics = "parseDiagnostics" in sf && Array.isArray(sf.parseDiagnostics) ? sf.parseDiagnostics : [];
|
|
1524
1697
|
if (diagnostics.length > 0) return null;
|
|
1525
1698
|
const options = {
|
|
1526
1699
|
allowJs: true,
|
|
1527
1700
|
checkJs: false,
|
|
1528
1701
|
noLib: true,
|
|
1529
1702
|
noResolve: true,
|
|
1530
|
-
target:
|
|
1531
|
-
module:
|
|
1703
|
+
target: ts3.ScriptTarget.Latest,
|
|
1704
|
+
module: ts3.ModuleKind.ESNext
|
|
1532
1705
|
};
|
|
1533
|
-
const host =
|
|
1706
|
+
const host = ts3.createCompilerHost(options, true);
|
|
1534
1707
|
host.getSourceFile = (fileName) => fileName === rootName ? sf : void 0;
|
|
1535
1708
|
host.fileExists = (fileName) => fileName === rootName;
|
|
1536
1709
|
host.readFile = (fileName) => fileName === rootName ? source : void 0;
|
|
@@ -1541,7 +1714,7 @@ function astContext(path, source) {
|
|
|
1541
1714
|
host.getCanonicalFileName = (fileName) => fileName;
|
|
1542
1715
|
host.useCaseSensitiveFileNames = () => true;
|
|
1543
1716
|
host.getNewLine = () => "\n";
|
|
1544
|
-
const program =
|
|
1717
|
+
const program = ts3.createProgram([rootName], options, host);
|
|
1545
1718
|
return { sf, checker: program.getTypeChecker() };
|
|
1546
1719
|
}
|
|
1547
1720
|
function symbolAt(ctx, node) {
|
|
@@ -1555,13 +1728,13 @@ function importAliases(ctx) {
|
|
|
1555
1728
|
if (sym) out2.set(sym, { causes });
|
|
1556
1729
|
};
|
|
1557
1730
|
for (const stmt of ctx.sf.statements) {
|
|
1558
|
-
if (
|
|
1731
|
+
if (ts3.isImportDeclaration(stmt) && ts3.isStringLiteralLike(stmt.moduleSpecifier)) {
|
|
1559
1732
|
if (!JS_TEST_MODULES.has(stmt.moduleSpecifier.text)) continue;
|
|
1560
1733
|
const clause = stmt.importClause;
|
|
1561
1734
|
if (!clause) continue;
|
|
1562
1735
|
if (clause.name && stmt.moduleSpecifier.text.startsWith("node:test")) add(clause.name);
|
|
1563
1736
|
const bindings = clause.namedBindings;
|
|
1564
|
-
if (bindings &&
|
|
1737
|
+
if (bindings && ts3.isNamedImports(bindings)) {
|
|
1565
1738
|
for (const el of bindings.elements) {
|
|
1566
1739
|
const imported = el.propertyName?.text ?? el.name.text;
|
|
1567
1740
|
if (JS_RUNNERS.has(imported)) add(el.name);
|
|
@@ -1569,17 +1742,17 @@ function importAliases(ctx) {
|
|
|
1569
1742
|
}
|
|
1570
1743
|
continue;
|
|
1571
1744
|
}
|
|
1572
|
-
if (!
|
|
1745
|
+
if (!ts3.isVariableStatement(stmt)) continue;
|
|
1573
1746
|
for (const decl of stmt.declarationList.declarations) {
|
|
1574
|
-
if (
|
|
1747
|
+
if (ts3.isObjectBindingPattern(decl.name) && decl.initializer) {
|
|
1575
1748
|
const mod = requiredModule(decl.initializer);
|
|
1576
1749
|
if (!mod || !JS_TEST_MODULES.has(mod)) continue;
|
|
1577
1750
|
for (const el of decl.name.elements) {
|
|
1578
|
-
if (!
|
|
1579
|
-
const imported = el.propertyName &&
|
|
1751
|
+
if (!ts3.isIdentifier(el.name)) continue;
|
|
1752
|
+
const imported = el.propertyName && ts3.isIdentifier(el.propertyName) ? el.propertyName.text : el.name.text;
|
|
1580
1753
|
if (JS_RUNNERS.has(imported)) add(el.name);
|
|
1581
1754
|
}
|
|
1582
|
-
} else if (
|
|
1755
|
+
} else if (ts3.isIdentifier(decl.name) && decl.initializer && ts3.isIdentifier(decl.initializer)) {
|
|
1583
1756
|
simpleAliases.push({ local: decl.name, source: decl.initializer });
|
|
1584
1757
|
}
|
|
1585
1758
|
}
|
|
@@ -1605,11 +1778,11 @@ function importAliases(ctx) {
|
|
|
1605
1778
|
function topLevelStaticStrings(ctx) {
|
|
1606
1779
|
const pending = /* @__PURE__ */ new Map();
|
|
1607
1780
|
for (const stmt of ctx.sf.statements) {
|
|
1608
|
-
if (!
|
|
1609
|
-
const isConst = (stmt.declarationList.flags &
|
|
1781
|
+
if (!ts3.isVariableStatement(stmt)) continue;
|
|
1782
|
+
const isConst = (stmt.declarationList.flags & ts3.NodeFlags.Const) !== 0;
|
|
1610
1783
|
if (!isConst) continue;
|
|
1611
1784
|
for (const decl of stmt.declarationList.declarations) {
|
|
1612
|
-
if (!
|
|
1785
|
+
if (!ts3.isIdentifier(decl.name) || !decl.initializer) continue;
|
|
1613
1786
|
const sym = symbolAt(ctx, decl.name);
|
|
1614
1787
|
if (sym) pending.set(sym, { decl: decl.name, expr: decl.initializer });
|
|
1615
1788
|
}
|
|
@@ -1617,15 +1790,15 @@ function topLevelStaticStrings(ctx) {
|
|
|
1617
1790
|
const resolved = /* @__PURE__ */ new Map();
|
|
1618
1791
|
const resolving = /* @__PURE__ */ new Set();
|
|
1619
1792
|
const valueOf = (expr) => {
|
|
1620
|
-
if (
|
|
1621
|
-
if (
|
|
1622
|
-
if (
|
|
1793
|
+
if (ts3.isStringLiteralLike(expr)) return { value: expr.text, causes: [] };
|
|
1794
|
+
if (ts3.isParenthesizedExpression(expr)) return valueOf(expr.expression);
|
|
1795
|
+
if (ts3.isBinaryExpression(expr) && expr.operatorToken.kind === ts3.SyntaxKind.PlusToken) {
|
|
1623
1796
|
const left = valueOf(expr.left);
|
|
1624
1797
|
const right = valueOf(expr.right);
|
|
1625
1798
|
if (!left || !right) return null;
|
|
1626
1799
|
return { value: left.value + right.value, causes: [...left.causes, ...right.causes] };
|
|
1627
1800
|
}
|
|
1628
|
-
if (
|
|
1801
|
+
if (ts3.isIdentifier(expr)) {
|
|
1629
1802
|
const sym = symbolAt(ctx, expr);
|
|
1630
1803
|
if (!sym) return null;
|
|
1631
1804
|
const cached = resolved.get(sym);
|
|
@@ -1652,15 +1825,15 @@ function topLevelStaticStrings(ctx) {
|
|
|
1652
1825
|
return resolved;
|
|
1653
1826
|
}
|
|
1654
1827
|
function staticPropertyName(expr, ctx, strings) {
|
|
1655
|
-
if (
|
|
1656
|
-
if (
|
|
1657
|
-
if (
|
|
1828
|
+
if (ts3.isStringLiteralLike(expr)) return { name: expr.text, causes: [] };
|
|
1829
|
+
if (ts3.isParenthesizedExpression(expr)) return staticPropertyName(expr.expression, ctx, strings);
|
|
1830
|
+
if (ts3.isBinaryExpression(expr) && expr.operatorToken.kind === ts3.SyntaxKind.PlusToken) {
|
|
1658
1831
|
const left = staticPropertyName(expr.left, ctx, strings);
|
|
1659
1832
|
const right = staticPropertyName(expr.right, ctx, strings);
|
|
1660
1833
|
if (!left || !right) return null;
|
|
1661
1834
|
return { name: left.name + right.name, causes: [...left.causes, ...right.causes] };
|
|
1662
1835
|
}
|
|
1663
|
-
if (
|
|
1836
|
+
if (ts3.isIdentifier(expr)) {
|
|
1664
1837
|
const sym = symbolAt(ctx, expr);
|
|
1665
1838
|
if (!sym) return null;
|
|
1666
1839
|
const value = strings.get(sym);
|
|
@@ -1673,8 +1846,8 @@ function runnerChain(expr, ctx, runners, strings) {
|
|
|
1673
1846
|
const causes = [];
|
|
1674
1847
|
let cur = expr;
|
|
1675
1848
|
let terminalNode = expr;
|
|
1676
|
-
while (
|
|
1677
|
-
if (
|
|
1849
|
+
while (ts3.isPropertyAccessExpression(cur) || ts3.isElementAccessExpression(cur)) {
|
|
1850
|
+
if (ts3.isPropertyAccessExpression(cur)) {
|
|
1678
1851
|
props.unshift(cur.name.text);
|
|
1679
1852
|
if (props.length === 1) terminalNode = cur.name;
|
|
1680
1853
|
cur = cur.expression;
|
|
@@ -1688,7 +1861,7 @@ function runnerChain(expr, ctx, runners, strings) {
|
|
|
1688
1861
|
if (props.length === 1) terminalNode = cur.argumentExpression;
|
|
1689
1862
|
cur = cur.expression;
|
|
1690
1863
|
}
|
|
1691
|
-
if (!
|
|
1864
|
+
if (!ts3.isIdentifier(cur)) return null;
|
|
1692
1865
|
const sym = symbolAt(ctx, cur);
|
|
1693
1866
|
if (sym) {
|
|
1694
1867
|
const binding = runners.get(sym);
|
|
@@ -1700,17 +1873,17 @@ function runnerChain(expr, ctx, runners, strings) {
|
|
|
1700
1873
|
return { root: cur.text, props, terminalNode, causes };
|
|
1701
1874
|
}
|
|
1702
1875
|
function optionDisables(value) {
|
|
1703
|
-
return !(value.kind ===
|
|
1876
|
+
return !(value.kind === ts3.SyntaxKind.FalseKeyword || ts3.isNumericLiteral(value) && Number(value.text) === 0);
|
|
1704
1877
|
}
|
|
1705
1878
|
function shorthandOptionDisables(prop, ctx) {
|
|
1706
1879
|
const sym = ctx.checker.getShorthandAssignmentValueSymbol(prop) ?? symbolAt(ctx, prop.name);
|
|
1707
|
-
const decl = sym?.valueDeclaration ?? sym?.declarations?.find((d) =>
|
|
1708
|
-
if (!decl || !
|
|
1880
|
+
const decl = sym?.valueDeclaration ?? sym?.declarations?.find((d) => ts3.isVariableDeclaration(d));
|
|
1881
|
+
if (!decl || !ts3.isVariableDeclaration(decl) || !decl.initializer) return true;
|
|
1709
1882
|
return optionDisables(decl.initializer);
|
|
1710
1883
|
}
|
|
1711
1884
|
function structuralNodeKey(node, sf) {
|
|
1712
1885
|
const children = node.getChildren(sf);
|
|
1713
|
-
const text = node.text;
|
|
1886
|
+
const text = "text" in node ? node.text : void 0;
|
|
1714
1887
|
if (children.length === 0) {
|
|
1715
1888
|
return typeof text === "string" ? `${node.kind}:${JSON.stringify(text)}` : String(node.kind);
|
|
1716
1889
|
}
|
|
@@ -1722,7 +1895,7 @@ function semanticSkipHits(ctx) {
|
|
|
1722
1895
|
const hits = [];
|
|
1723
1896
|
const callOrdinals = /* @__PURE__ */ new Map();
|
|
1724
1897
|
const visit = (node) => {
|
|
1725
|
-
if (
|
|
1898
|
+
if (ts3.isCallExpression(node)) {
|
|
1726
1899
|
const callText = node.getText(ctx.sf).trim();
|
|
1727
1900
|
const callIdentity = structuralNodeKey(node, ctx.sf);
|
|
1728
1901
|
const ordinal = (callOrdinals.get(callIdentity) ?? 0) + 1;
|
|
@@ -1747,19 +1920,19 @@ function semanticSkipHits(ctx) {
|
|
|
1747
1920
|
push(terminalNode, [], "a .skipIf()/.runIf() condition (the test runs only when the condition allows)");
|
|
1748
1921
|
} else if (terminal && ["fails", "failing"].includes(terminal) && props.length === 1) {
|
|
1749
1922
|
push(terminalNode, [], "a .fails/.failing marker (the test now passes by failing)");
|
|
1750
|
-
} else if (terminal === "each" && props.length === 1 && node.arguments.length > 0 &&
|
|
1923
|
+
} else if (terminal === "each" && props.length === 1 && node.arguments.length > 0 && ts3.isArrayLiteralExpression(node.arguments[0]) && node.arguments[0].elements.length === 0) {
|
|
1751
1924
|
push(terminalNode, [], "an empty .each table (no case ever runs)");
|
|
1752
1925
|
}
|
|
1753
1926
|
if (props.length === 0) {
|
|
1754
1927
|
for (const arg of node.arguments) {
|
|
1755
|
-
if (!
|
|
1928
|
+
if (!ts3.isObjectLiteralExpression(arg)) continue;
|
|
1756
1929
|
for (const prop of arg.properties) {
|
|
1757
|
-
if (
|
|
1930
|
+
if (ts3.isPropertyAssignment(prop)) {
|
|
1758
1931
|
let name = null;
|
|
1759
1932
|
let propertyCauses = [];
|
|
1760
|
-
if (
|
|
1933
|
+
if (ts3.isIdentifier(prop.name) || ts3.isStringLiteralLike(prop.name)) {
|
|
1761
1934
|
name = prop.name.text;
|
|
1762
|
-
} else if (
|
|
1935
|
+
} else if (ts3.isComputedPropertyName(prop.name)) {
|
|
1763
1936
|
const resolved = staticPropertyName(prop.name.expression, ctx, strings);
|
|
1764
1937
|
name = resolved?.name ?? null;
|
|
1765
1938
|
propertyCauses = resolved?.causes ?? [];
|
|
@@ -1768,7 +1941,7 @@ function semanticSkipHits(ctx) {
|
|
|
1768
1941
|
if (!optionDisables(prop.initializer)) continue;
|
|
1769
1942
|
const why = name === "only" ? "an { only: ... } option focusing the test" : name === "todo" ? "a { todo: ... } option (the test no longer fails the run)" : "a { skip: ... } option disabling the test";
|
|
1770
1943
|
push(prop.name, propertyCauses, why);
|
|
1771
|
-
} else if (
|
|
1944
|
+
} else if (ts3.isShorthandPropertyAssignment(prop) && ["skip", "todo", "only"].includes(prop.name.text)) {
|
|
1772
1945
|
if (!shorthandOptionDisables(prop, ctx)) continue;
|
|
1773
1946
|
push(prop.name, [], `a { ${prop.name.text} } option that conditionally narrows the test run`);
|
|
1774
1947
|
}
|
|
@@ -1777,7 +1950,7 @@ function semanticSkipHits(ctx) {
|
|
|
1777
1950
|
}
|
|
1778
1951
|
}
|
|
1779
1952
|
}
|
|
1780
|
-
|
|
1953
|
+
ts3.forEachChild(node, visit);
|
|
1781
1954
|
};
|
|
1782
1955
|
visit(ctx.sf);
|
|
1783
1956
|
return hits;
|
|
@@ -1816,7 +1989,7 @@ function astSkipHits(c) {
|
|
|
1816
1989
|
return { hits, authoritative: true };
|
|
1817
1990
|
}
|
|
1818
1991
|
var testSkip = {
|
|
1819
|
-
id:
|
|
1992
|
+
id: RULE4,
|
|
1820
1993
|
surface: ["file"],
|
|
1821
1994
|
certainty: "mechanical",
|
|
1822
1995
|
run(changes, policy) {
|
|
@@ -1834,7 +2007,7 @@ var testSkip = {
|
|
|
1834
2007
|
for (const hit of analysis.hits) {
|
|
1835
2008
|
astHitLines.add(hit.line);
|
|
1836
2009
|
out2.push(
|
|
1837
|
-
makeFinding(
|
|
2010
|
+
makeFinding(RULE4, policy, {
|
|
1838
2011
|
file: c.path,
|
|
1839
2012
|
line: hit.line,
|
|
1840
2013
|
message: `Test skipped or narrowed: ${hit.why}.`,
|
|
@@ -1852,7 +2025,7 @@ var testSkip = {
|
|
|
1852
2025
|
if (comment && !p.comment) continue;
|
|
1853
2026
|
if (matchesOutsideString(p, l.content, lang)) {
|
|
1854
2027
|
out2.push(
|
|
1855
|
-
makeFinding(
|
|
2028
|
+
makeFinding(RULE4, policy, {
|
|
1856
2029
|
file: c.path,
|
|
1857
2030
|
line: l.newLine ?? void 0,
|
|
1858
2031
|
message: `Test skipped or narrowed: ${p.why}.`,
|
|
@@ -1871,7 +2044,7 @@ var testSkip = {
|
|
|
1871
2044
|
|
|
1872
2045
|
// src/detectors/coverage-lowering.ts
|
|
1873
2046
|
import picomatch2 from "picomatch";
|
|
1874
|
-
import
|
|
2047
|
+
import ts4 from "typescript";
|
|
1875
2048
|
|
|
1876
2049
|
// src/detectors/repo.ts
|
|
1877
2050
|
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
@@ -1994,34 +2167,34 @@ function containsProtected(token, policy, category, ctx, except) {
|
|
|
1994
2167
|
}
|
|
1995
2168
|
|
|
1996
2169
|
// src/detectors/coverage-lowering.ts
|
|
1997
|
-
var
|
|
2170
|
+
var RULE5 = "coverage-lowering";
|
|
1998
2171
|
var METRICS = ["branches", "functions", "lines", "statements"];
|
|
1999
|
-
var isMetric = (k) => METRICS.
|
|
2172
|
+
var isMetric = (k) => METRICS.some((m) => m === k);
|
|
2000
2173
|
var norm = (p) => p.replace(/^\.\//, "");
|
|
2001
2174
|
function keyName(name) {
|
|
2002
|
-
if (
|
|
2175
|
+
if (ts4.isIdentifier(name) || ts4.isStringLiteral(name) || ts4.isNumericLiteral(name)) return name.text;
|
|
2003
2176
|
return null;
|
|
2004
2177
|
}
|
|
2005
2178
|
function numericOf(e) {
|
|
2006
|
-
if (
|
|
2007
|
-
if (
|
|
2008
|
-
if (
|
|
2179
|
+
if (ts4.isNumericLiteral(e)) return Number(e.text);
|
|
2180
|
+
if (ts4.isStringLiteral(e) && /^-?\d+(?:\.\d+)?$/.test(e.text.trim())) return Number(e.text);
|
|
2181
|
+
if (ts4.isPrefixUnaryExpression(e) && e.operator === ts4.SyntaxKind.MinusToken && ts4.isNumericLiteral(e.operand)) {
|
|
2009
2182
|
return -Number(e.operand.text);
|
|
2010
2183
|
}
|
|
2011
2184
|
return void 0;
|
|
2012
2185
|
}
|
|
2013
2186
|
function metricsOf(expr, resolve10) {
|
|
2014
2187
|
const obj = resolve10(expr);
|
|
2015
|
-
if (!
|
|
2188
|
+
if (!ts4.isObjectLiteralExpression(obj)) return void 0;
|
|
2016
2189
|
const out2 = { values: {}, opaque: false };
|
|
2017
2190
|
let any = false;
|
|
2018
2191
|
for (const p of obj.properties) {
|
|
2019
|
-
if (
|
|
2192
|
+
if (ts4.isSpreadAssignment(p)) {
|
|
2020
2193
|
out2.opaque = true;
|
|
2021
2194
|
any = true;
|
|
2022
2195
|
continue;
|
|
2023
2196
|
}
|
|
2024
|
-
if (!
|
|
2197
|
+
if (!ts4.isPropertyAssignment(p)) continue;
|
|
2025
2198
|
const k = keyName(p.name);
|
|
2026
2199
|
if (k === null || !isMetric(k)) continue;
|
|
2027
2200
|
any = true;
|
|
@@ -2033,34 +2206,34 @@ function metricsOf(expr, resolve10) {
|
|
|
2033
2206
|
}
|
|
2034
2207
|
function underKey(node, key2) {
|
|
2035
2208
|
for (let p = node.parent; p; p = p.parent) {
|
|
2036
|
-
if (
|
|
2209
|
+
if (ts4.isPropertyAssignment(p) && keyName(p.name) === key2) return true;
|
|
2037
2210
|
}
|
|
2038
2211
|
return false;
|
|
2039
2212
|
}
|
|
2040
2213
|
function ownerKey(node) {
|
|
2041
2214
|
const obj = node.parent;
|
|
2042
|
-
if (!obj || !
|
|
2215
|
+
if (!obj || !ts4.isObjectLiteralExpression(obj)) return null;
|
|
2043
2216
|
let q = obj.parent;
|
|
2044
|
-
while (q && (
|
|
2045
|
-
return q &&
|
|
2217
|
+
while (q && (ts4.isParenthesizedExpression(q) || ts4.isAsExpression(q) || q.kind === ts4.SyntaxKind.SatisfiesExpression)) q = q.parent;
|
|
2218
|
+
return q && ts4.isPropertyAssignment(q) ? keyName(q.name) : null;
|
|
2046
2219
|
}
|
|
2047
2220
|
function asExpression(src) {
|
|
2048
2221
|
const t = src.trim();
|
|
2049
2222
|
return t.startsWith("{") ? `(${t})` : src;
|
|
2050
2223
|
}
|
|
2051
2224
|
function sourceOf(src) {
|
|
2052
|
-
return
|
|
2225
|
+
return ts4.createSourceFile("cfg.ts", asExpression(src), ts4.ScriptTarget.Latest, true, ts4.ScriptKind.TS);
|
|
2053
2226
|
}
|
|
2054
2227
|
function resolverFor(sf) {
|
|
2055
2228
|
const consts = /* @__PURE__ */ new Map();
|
|
2056
2229
|
for (const st of sf.statements) {
|
|
2057
|
-
if (!
|
|
2230
|
+
if (!ts4.isVariableStatement(st)) continue;
|
|
2058
2231
|
for (const d of st.declarationList.declarations) {
|
|
2059
|
-
if (
|
|
2232
|
+
if (ts4.isIdentifier(d.name) && d.initializer) consts.set(d.name.text, d.initializer);
|
|
2060
2233
|
}
|
|
2061
2234
|
}
|
|
2062
2235
|
return (e) => {
|
|
2063
|
-
if (
|
|
2236
|
+
if (ts4.isIdentifier(e)) {
|
|
2064
2237
|
const bound = consts.get(e.text);
|
|
2065
2238
|
return bound ?? e;
|
|
2066
2239
|
}
|
|
@@ -2073,25 +2246,25 @@ function parseThresholds(src) {
|
|
|
2073
2246
|
const sf = sourceOf(src);
|
|
2074
2247
|
const resolve10 = resolverFor(sf);
|
|
2075
2248
|
const visit = (node) => {
|
|
2076
|
-
if (
|
|
2249
|
+
if (ts4.isPropertyAssignment(node)) {
|
|
2077
2250
|
const key2 = keyName(node.name);
|
|
2078
2251
|
if (key2 === "coverageThreshold" || key2 === "thresholds" && underKey(node, "coverage")) {
|
|
2079
2252
|
res.present = true;
|
|
2080
2253
|
const init = resolve10(node.initializer);
|
|
2081
|
-
if (!
|
|
2254
|
+
if (!ts4.isObjectLiteralExpression(init)) {
|
|
2082
2255
|
res.opaque = true;
|
|
2083
2256
|
} else {
|
|
2084
2257
|
const flat = metricsOf(init, resolve10);
|
|
2085
2258
|
if (flat) res.global = merge(res.global, flat);
|
|
2086
2259
|
for (const p of init.properties) {
|
|
2087
|
-
if (
|
|
2260
|
+
if (ts4.isSpreadAssignment(p)) {
|
|
2088
2261
|
res.opaque = true;
|
|
2089
2262
|
continue;
|
|
2090
2263
|
}
|
|
2091
|
-
if (!
|
|
2264
|
+
if (!ts4.isPropertyAssignment(p)) continue;
|
|
2092
2265
|
const k = keyName(p.name);
|
|
2093
2266
|
if (k === null || isMetric(k)) continue;
|
|
2094
|
-
if (key2 === "thresholds" && k === "100" && p.initializer.kind ===
|
|
2267
|
+
if (key2 === "thresholds" && k === "100" && p.initializer.kind === ts4.SyntaxKind.TrueKeyword) {
|
|
2095
2268
|
res.global = merge(res.global, { values: { branches: 100, functions: 100, lines: 100, statements: 100 }, opaque: false });
|
|
2096
2269
|
continue;
|
|
2097
2270
|
}
|
|
@@ -2103,7 +2276,7 @@ function parseThresholds(src) {
|
|
|
2103
2276
|
}
|
|
2104
2277
|
}
|
|
2105
2278
|
}
|
|
2106
|
-
|
|
2279
|
+
ts4.forEachChild(node, visit);
|
|
2107
2280
|
};
|
|
2108
2281
|
visit(sf);
|
|
2109
2282
|
} catch {
|
|
@@ -2114,12 +2287,12 @@ function merge(a, b) {
|
|
|
2114
2287
|
return { values: { ...a?.values ?? {}, ...b.values }, opaque: (a?.opaque ?? false) || b.opaque };
|
|
2115
2288
|
}
|
|
2116
2289
|
function literals(e) {
|
|
2117
|
-
if (
|
|
2118
|
-
if (!
|
|
2290
|
+
if (ts4.isStringLiteral(e)) return { items: [e.text], opaque: false };
|
|
2291
|
+
if (!ts4.isArrayLiteralExpression(e)) return { items: [], opaque: true };
|
|
2119
2292
|
const items = [];
|
|
2120
2293
|
let opaque = false;
|
|
2121
2294
|
for (const el of e.elements) {
|
|
2122
|
-
if (
|
|
2295
|
+
if (ts4.isStringLiteral(el) || ts4.isNoSubstitutionTemplateLiteral(el)) items.push(el.text);
|
|
2123
2296
|
else opaque = true;
|
|
2124
2297
|
}
|
|
2125
2298
|
return { items, opaque };
|
|
@@ -2130,7 +2303,7 @@ function parseLists(src) {
|
|
|
2130
2303
|
const sf = sourceOf(src);
|
|
2131
2304
|
const resolve10 = resolverFor(sf);
|
|
2132
2305
|
const visit = (node) => {
|
|
2133
|
-
if (
|
|
2306
|
+
if (ts4.isPropertyAssignment(node)) {
|
|
2134
2307
|
const k = keyName(node.name);
|
|
2135
2308
|
const vitestCoverage = ownerKey(node) === "coverage";
|
|
2136
2309
|
if (k === "collectCoverageFrom" || k === "include" && vitestCoverage) {
|
|
@@ -2144,7 +2317,7 @@ function parseLists(src) {
|
|
|
2144
2317
|
if (opaque) res.opaqueIgnore = true;
|
|
2145
2318
|
}
|
|
2146
2319
|
}
|
|
2147
|
-
|
|
2320
|
+
ts4.forEachChild(node, visit);
|
|
2148
2321
|
};
|
|
2149
2322
|
visit(sf);
|
|
2150
2323
|
} catch {
|
|
@@ -2155,15 +2328,15 @@ function parseSwitches(src) {
|
|
|
2155
2328
|
const res = {};
|
|
2156
2329
|
try {
|
|
2157
2330
|
const sf = sourceOf(src);
|
|
2158
|
-
const bool = (e) => e.kind ===
|
|
2331
|
+
const bool = (e) => e.kind === ts4.SyntaxKind.TrueKeyword ? true : e.kind === ts4.SyntaxKind.FalseKeyword ? false : void 0;
|
|
2159
2332
|
const visit = (node) => {
|
|
2160
|
-
if (
|
|
2333
|
+
if (ts4.isPropertyAssignment(node)) {
|
|
2161
2334
|
const k = keyName(node.name);
|
|
2162
2335
|
const owner = ownerKey(node);
|
|
2163
2336
|
if (k === "enabled" && owner === "coverage") res.enabled = bool(node.initializer);
|
|
2164
2337
|
else if (k === "perFile" && owner === "thresholds" && underKey(node, "coverage")) res.perFile = bool(node.initializer);
|
|
2165
2338
|
}
|
|
2166
|
-
|
|
2339
|
+
ts4.forEachChild(node, visit);
|
|
2167
2340
|
};
|
|
2168
2341
|
visit(sf);
|
|
2169
2342
|
} catch {
|
|
@@ -2176,11 +2349,11 @@ function sectionText(src, key2) {
|
|
|
2176
2349
|
let out2 = "";
|
|
2177
2350
|
const visit = (node) => {
|
|
2178
2351
|
if (out2) return;
|
|
2179
|
-
if (
|
|
2352
|
+
if (ts4.isPropertyAssignment(node) && keyName(node.name) === key2 && ts4.isObjectLiteralExpression(node.initializer)) {
|
|
2180
2353
|
out2 = node.initializer.getText(sf);
|
|
2181
2354
|
return;
|
|
2182
2355
|
}
|
|
2183
|
-
|
|
2356
|
+
ts4.forEachChild(node, visit);
|
|
2184
2357
|
};
|
|
2185
2358
|
visit(sf);
|
|
2186
2359
|
return out2;
|
|
@@ -2517,7 +2690,7 @@ function gateMovedElsewhere(c, changes, policy) {
|
|
|
2517
2690
|
});
|
|
2518
2691
|
}
|
|
2519
2692
|
var coverageLowering = {
|
|
2520
|
-
id:
|
|
2693
|
+
id: RULE5,
|
|
2521
2694
|
surface: ["file"],
|
|
2522
2695
|
certainty: "mechanical",
|
|
2523
2696
|
run(changes, policy, _view, ctx) {
|
|
@@ -2539,7 +2712,7 @@ var coverageLowering = {
|
|
|
2539
2712
|
reasons.push(...simpleNarrowings(c.path, c.before ?? "", c.after));
|
|
2540
2713
|
for (const reason of reasons) {
|
|
2541
2714
|
out2.push(
|
|
2542
|
-
makeFinding(
|
|
2715
|
+
makeFinding(RULE5, policy, {
|
|
2543
2716
|
file: c.path,
|
|
2544
2717
|
message: `Coverage gate weakened: ${reason}.`,
|
|
2545
2718
|
evidence: reason,
|
|
@@ -2552,7 +2725,7 @@ var coverageLowering = {
|
|
|
2552
2725
|
const gated = isJsConfig(c.path) ? parseThresholds(before).present : simpleWeakenings(c.path, before, "").length > 0 || SIMPLE_FILES.some((s) => s.file.test(c.path) && s.keys.some((k) => numbersOf(before, k.key).length));
|
|
2553
2726
|
if (gated && !gateMovedElsewhere(c, changes, policy)) {
|
|
2554
2727
|
out2.push(
|
|
2555
|
-
makeFinding(
|
|
2728
|
+
makeFinding(RULE5, policy, {
|
|
2556
2729
|
file: c.path,
|
|
2557
2730
|
message: "Coverage gate weakened: the coverage config was deleted.",
|
|
2558
2731
|
evidence: c.path,
|
|
@@ -2566,7 +2739,7 @@ var coverageLowering = {
|
|
|
2566
2739
|
const removedCoverage = removed.find((l) => /--coverage\b/.test(l.content));
|
|
2567
2740
|
if (removedCoverage && !added.some((l) => /--coverage\b/.test(l.content))) {
|
|
2568
2741
|
out2.push(
|
|
2569
|
-
makeFinding(
|
|
2742
|
+
makeFinding(RULE5, policy, {
|
|
2570
2743
|
file: c.path,
|
|
2571
2744
|
message: "The --coverage flag was removed from a test script.",
|
|
2572
2745
|
evidence: removedCoverage.content.trim(),
|
|
@@ -2580,7 +2753,7 @@ var coverageLowering = {
|
|
|
2580
2753
|
if (removed.some((r) => s.re.test(r.content))) continue;
|
|
2581
2754
|
if (/passWithNoTests/.test(s.re.source) && dirHasSpec(c.path, policy, ctx) === false) continue;
|
|
2582
2755
|
out2.push(
|
|
2583
|
-
makeFinding(
|
|
2756
|
+
makeFinding(RULE5, policy, {
|
|
2584
2757
|
file: c.path,
|
|
2585
2758
|
line: l.newLine ?? void 0,
|
|
2586
2759
|
message: s.message,
|
|
@@ -2603,7 +2776,7 @@ var coverageLowering = {
|
|
|
2603
2776
|
const ov = oldNums.get(m[1]);
|
|
2604
2777
|
if (ov !== void 0 && Number(m[2]) < ov) {
|
|
2605
2778
|
out2.push(
|
|
2606
|
-
makeFinding(
|
|
2779
|
+
makeFinding(RULE5, policy, {
|
|
2607
2780
|
file: c.path,
|
|
2608
2781
|
line: l.newLine ?? void 0,
|
|
2609
2782
|
message: `Coverage threshold for ${m[1]} lowered ${ov} \u2192 ${m[2]}.`,
|
|
@@ -2626,7 +2799,7 @@ var coverageLowering = {
|
|
|
2626
2799
|
};
|
|
2627
2800
|
|
|
2628
2801
|
// src/detectors/ci-tampering.ts
|
|
2629
|
-
var
|
|
2802
|
+
var RULE6 = "ci-tampering";
|
|
2630
2803
|
var USES = /^\s*-\s*uses:/;
|
|
2631
2804
|
var CHECK = /\b(test|tests|lint|typecheck|type-check|tsc|eslint|jest|vitest|playwright|coverage|tamperward)\b/i;
|
|
2632
2805
|
var YAML_KEY = /^\s*-?\s*[A-Za-z_][\w-]*:\s*(?:$|\S)/;
|
|
@@ -2890,7 +3063,7 @@ function isTruthy(raw) {
|
|
|
2890
3063
|
return f !== void 0 && truthy(f);
|
|
2891
3064
|
}
|
|
2892
3065
|
var SHELL_ERREXIT = /(?:^|\s)-[a-zA-Z]*e[a-zA-Z]*(?:\s|$)|-o\s+errexit\b/;
|
|
2893
|
-
var indentOf = (l) => l.
|
|
3066
|
+
var indentOf = (l) => l.length - l.trimStart().length;
|
|
2894
3067
|
var STEP_START = /^\s*-\s+(?:name|uses|run|if|id|with|env|shell|continue-on-error|working-directory|timeout-minutes):/;
|
|
2895
3068
|
function stepOf(lines, i) {
|
|
2896
3069
|
const ind = indentOf(lines[i]);
|
|
@@ -3072,7 +3245,7 @@ function triggerNarrowings(before, after, opts) {
|
|
|
3072
3245
|
}
|
|
3073
3246
|
var REUSED_WORKFLOW = /^\s*uses:\s*\.\/(\.github\/workflows\/[^\s@]+)/;
|
|
3074
3247
|
var ciTampering = {
|
|
3075
|
-
id:
|
|
3248
|
+
id: RULE6,
|
|
3076
3249
|
surface: ["file"],
|
|
3077
3250
|
certainty: "mechanical",
|
|
3078
3251
|
run(changes, policy, _view, ctx) {
|
|
@@ -3084,7 +3257,7 @@ var ciTampering = {
|
|
|
3084
3257
|
if (c.kind !== "file") continue;
|
|
3085
3258
|
if (c.op === "rename" && c.oldPath && isActiveWorkflow(c.oldPath) && !isActiveWorkflow(c.path)) {
|
|
3086
3259
|
out2.push(
|
|
3087
|
-
makeFinding(
|
|
3260
|
+
makeFinding(RULE6, policy, {
|
|
3088
3261
|
file: c.path,
|
|
3089
3262
|
message: `A CI workflow was renamed so it no longer runs (${c.oldPath} \u2192 ${c.path}).`,
|
|
3090
3263
|
evidence: `${c.oldPath} \u2192 ${c.path}`,
|
|
@@ -3108,7 +3281,7 @@ var ciTampering = {
|
|
|
3108
3281
|
}
|
|
3109
3282
|
if (coe && isTruthy(coe[1])) {
|
|
3110
3283
|
out2.push(
|
|
3111
|
-
makeFinding(
|
|
3284
|
+
makeFinding(RULE6, policy, {
|
|
3112
3285
|
file: c.path,
|
|
3113
3286
|
line: l.newLine ?? void 0,
|
|
3114
3287
|
message: "continue-on-error: true added \u2014 failures will no longer fail the job.",
|
|
@@ -3118,7 +3291,7 @@ var ciTampering = {
|
|
|
3118
3291
|
);
|
|
3119
3292
|
} else if (cond && isAlwaysFalse(cond[1])) {
|
|
3120
3293
|
out2.push(
|
|
3121
|
-
makeFinding(
|
|
3294
|
+
makeFinding(RULE6, policy, {
|
|
3122
3295
|
file: c.path,
|
|
3123
3296
|
line: l.newLine ?? void 0,
|
|
3124
3297
|
message: `if: ${uncommented(cond[1])} added \u2014 this step can never run.`,
|
|
@@ -3138,7 +3311,7 @@ var ciTampering = {
|
|
|
3138
3311
|
const n = NEUTRALISERS.find((x) => x.re.test(b.body[k]));
|
|
3139
3312
|
if (!n || propagates && /set\s\+e/.test(b.body[k])) continue;
|
|
3140
3313
|
out2.push(
|
|
3141
|
-
makeFinding(
|
|
3314
|
+
makeFinding(RULE6, policy, {
|
|
3142
3315
|
file: c.path,
|
|
3143
3316
|
line: idx + 1,
|
|
3144
3317
|
message: `A check's run block was neutralised: ${n.what}.`,
|
|
@@ -3154,7 +3327,7 @@ var ciTampering = {
|
|
|
3154
3327
|
const step = stepOf(afterLines, l.newLine - 1);
|
|
3155
3328
|
if (step && !stepHasCheck(afterLines, step)) continue;
|
|
3156
3329
|
out2.push(
|
|
3157
|
-
makeFinding(
|
|
3330
|
+
makeFinding(RULE6, policy, {
|
|
3158
3331
|
file: c.path,
|
|
3159
3332
|
line: l.newLine,
|
|
3160
3333
|
message: "shell: \u2026 {0} added on a check step \u2014 the shell no longer stops on the first failure.",
|
|
@@ -3163,11 +3336,11 @@ var ciTampering = {
|
|
|
3163
3336
|
})
|
|
3164
3337
|
);
|
|
3165
3338
|
}
|
|
3166
|
-
if (c.before != null) {
|
|
3339
|
+
if (c.before != null && c.after != null) {
|
|
3167
3340
|
triggerOpts ??= { defaultBranch: defaultBranch(ctx), sources: sourceProbes(ctx), hasBranch: (b) => branchExists(b, ctx) };
|
|
3168
3341
|
for (const reason of triggerNarrowings(parseTriggers(c.before), parseTriggers(c.after), triggerOpts)) {
|
|
3169
3342
|
out2.push(
|
|
3170
|
-
makeFinding(
|
|
3343
|
+
makeFinding(RULE6, policy, {
|
|
3171
3344
|
file: c.path,
|
|
3172
3345
|
message: `The workflow's triggers were narrowed: ${reason}.`,
|
|
3173
3346
|
evidence: reason,
|
|
@@ -3196,7 +3369,7 @@ var ciTampering = {
|
|
|
3196
3369
|
if (s.state === "kept") continue;
|
|
3197
3370
|
if (s.state === "neutralised") {
|
|
3198
3371
|
out2.push(
|
|
3199
|
-
makeFinding(
|
|
3372
|
+
makeFinding(RULE6, policy, {
|
|
3200
3373
|
file: c.path,
|
|
3201
3374
|
message: "A CI check command was neutralised in place: it still runs, but its result no longer decides (or its suite was narrowed).",
|
|
3202
3375
|
evidence: `${l.content.trim()} \u2192 ${s.by}`,
|
|
@@ -3206,7 +3379,7 @@ var ciTampering = {
|
|
|
3206
3379
|
continue;
|
|
3207
3380
|
}
|
|
3208
3381
|
out2.push(
|
|
3209
|
-
makeFinding(
|
|
3382
|
+
makeFinding(RULE6, policy, {
|
|
3210
3383
|
file: c.path,
|
|
3211
3384
|
message: isUsesLine ? "A CI check step (test/lint/typecheck) was removed." : "A CI check command was removed from a run block.",
|
|
3212
3385
|
evidence: l.content.trim(),
|
|
@@ -3235,7 +3408,7 @@ function shippedVersion() {
|
|
|
3235
3408
|
for (const rel of ["../package.json", "../../package.json", "../../../package.json"]) {
|
|
3236
3409
|
try {
|
|
3237
3410
|
const pkg = JSON.parse(readFileSync3(join5(here, rel), "utf8"));
|
|
3238
|
-
if (pkg.name === "tamperward" && typeof pkg.version === "string" && PLAIN_SEMVER.test(pkg.version)) {
|
|
3411
|
+
if (isRecord(pkg) && pkg.name === "tamperward" && typeof pkg.version === "string" && PLAIN_SEMVER.test(pkg.version)) {
|
|
3239
3412
|
return pkg.version;
|
|
3240
3413
|
}
|
|
3241
3414
|
} catch {
|
|
@@ -3319,11 +3492,25 @@ function resolvesToClaudeSettings(path, env = process.env) {
|
|
|
3319
3492
|
|
|
3320
3493
|
// src/detectors/policy-diff.ts
|
|
3321
3494
|
import { parse } from "yaml";
|
|
3495
|
+
function verifyBlock(v) {
|
|
3496
|
+
if (!isRecord(v)) return void 0;
|
|
3497
|
+
return {
|
|
3498
|
+
...typeof v.command === "string" ? { command: v.command } : {},
|
|
3499
|
+
...typeof v.budget === "number" ? { budget: v.budget } : {},
|
|
3500
|
+
...Array.isArray(v.inputs) ? { inputs: v.inputs.filter((g) => typeof g === "string") } : {},
|
|
3501
|
+
...typeof v.backend === "string" ? { backend: v.backend } : {},
|
|
3502
|
+
...typeof v.image === "string" ? { image: v.image } : {}
|
|
3503
|
+
};
|
|
3504
|
+
}
|
|
3505
|
+
function ruleOverrides(v) {
|
|
3506
|
+
if (!isRecord(v)) return {};
|
|
3507
|
+
return Object.fromEntries(Object.entries(v).map(([name, cfg]) => [name, isRecord(cfg) ? cfg : {}]));
|
|
3508
|
+
}
|
|
3322
3509
|
var globs2 = (list2) => Array.isArray(list2) ? list2.filter((g) => typeof g === "string").map(normalizeGlob) : [];
|
|
3323
3510
|
function safeParse(src) {
|
|
3324
3511
|
try {
|
|
3325
3512
|
const v = parse(src);
|
|
3326
|
-
if (
|
|
3513
|
+
if (isRecord(v)) return v;
|
|
3327
3514
|
return null;
|
|
3328
3515
|
} catch {
|
|
3329
3516
|
return null;
|
|
@@ -3334,18 +3521,24 @@ function effective(raw) {
|
|
|
3334
3521
|
const version = typeof v === "number" && Number.isInteger(v) && v >= 1 ? v : 1;
|
|
3335
3522
|
const base = defaultPolicy(version);
|
|
3336
3523
|
const rules = { ...base.rules };
|
|
3337
|
-
for (const [name,
|
|
3338
|
-
|
|
3339
|
-
|
|
3524
|
+
for (const [name, over] of Object.entries(ruleOverrides(raw.rules))) {
|
|
3525
|
+
rules[name] = {
|
|
3526
|
+
...rules[name] ?? {},
|
|
3527
|
+
...over.severity !== void 0 ? { severity: over.severity } : {},
|
|
3528
|
+
...over.enabled !== void 0 ? { enabled: over.enabled } : {},
|
|
3529
|
+
...over.exclude !== void 0 ? { exclude: globs2(over.exclude) } : {}
|
|
3530
|
+
};
|
|
3340
3531
|
}
|
|
3341
|
-
const userProtected = raw.protected
|
|
3532
|
+
const userProtected = isRecord(raw.protected) ? Object.fromEntries(Object.entries(raw.protected).map(([cat, list2]) => [cat, globs2(list2)])) : void 0;
|
|
3533
|
+
const signoff = isRecord(raw.signoff) ? raw.signoff : {};
|
|
3534
|
+
const requiredFor = [signoff.required_for, signoff.requiredFor].find(Array.isArray);
|
|
3342
3535
|
return {
|
|
3343
3536
|
version,
|
|
3344
3537
|
rules,
|
|
3345
3538
|
ignore: raw.ignore ? globs2(raw.ignore) : base.ignore ?? [],
|
|
3346
3539
|
protected: mergeProtected(base.protected, userProtected),
|
|
3347
|
-
requiredFor:
|
|
3348
|
-
ledger: typeof
|
|
3540
|
+
requiredFor: requiredFor ? requiredFor.filter((s) => typeof s === "string") : base.signoff.requiredFor,
|
|
3541
|
+
ledger: typeof signoff.ledger === "string" ? signoff.ledger : base.signoff.ledger
|
|
3349
3542
|
};
|
|
3350
3543
|
}
|
|
3351
3544
|
function policyWeakening(before, after) {
|
|
@@ -3400,8 +3593,8 @@ function policyWeakening(before, after) {
|
|
|
3400
3593
|
if (ae.ledger !== be.ledger) {
|
|
3401
3594
|
reasons.push(`sign-off ledger moved (${be.ledger} \u2192 ${ae.ledger}) \u2014 the local layer would trust a different file`);
|
|
3402
3595
|
}
|
|
3403
|
-
const bv = b.verify;
|
|
3404
|
-
const av = a.verify;
|
|
3596
|
+
const bv = verifyBlock(b.verify);
|
|
3597
|
+
const av = verifyBlock(a.verify);
|
|
3405
3598
|
if (bv?.command) {
|
|
3406
3599
|
if (!av?.command) {
|
|
3407
3600
|
reasons.push("verify block removed \u2014 pristine-suite re-execution disabled");
|
|
@@ -4159,7 +4352,7 @@ function scriptWeakening(before, after, opts = {}) {
|
|
|
4159
4352
|
const evidence = (trace?.line ?? before.find((l) => checkIdentity(stripComment(l).trim().replace(/^!\s+/, "").replace(PREFIX, "")) === id) ?? id).trim();
|
|
4160
4353
|
out2.push({ reason: `the check invocation \`${id}\` was ${how}`, evidence });
|
|
4161
4354
|
}
|
|
4162
|
-
const floor = beforeInv.
|
|
4355
|
+
const floor = beforeInv.flatMap((i) => i.state === "live" && i.pin && PLAIN_SEMVER.test(i.pin) ? [i.pin] : []).sort((a, b) => compareVersions(a, b) ?? 0)[0];
|
|
4163
4356
|
if (floor) {
|
|
4164
4357
|
for (const inv of afterInv) {
|
|
4165
4358
|
if (inv.state !== "live" || !inv.pin) continue;
|
|
@@ -4555,7 +4748,10 @@ function destinations(cmd, args, isDir) {
|
|
|
4555
4748
|
positional.push(a);
|
|
4556
4749
|
}
|
|
4557
4750
|
const base = (p) => p.replace(/\/+$/, "").split("/").pop() ?? p;
|
|
4558
|
-
if (target !== null)
|
|
4751
|
+
if (target !== null) {
|
|
4752
|
+
const dir = target.replace(/\/+$/, "");
|
|
4753
|
+
return positional.map((p) => `${dir}/${base(p)}`);
|
|
4754
|
+
}
|
|
4559
4755
|
if (positional.length < 2) return [];
|
|
4560
4756
|
const dest = positional[positional.length - 1];
|
|
4561
4757
|
const srcs = positional.slice(0, -1);
|
|
@@ -4569,16 +4765,18 @@ function expandShellPath(t, env = process.env) {
|
|
|
4569
4765
|
}
|
|
4570
4766
|
function hookTests(policy, ctx) {
|
|
4571
4767
|
const cwd = ctx?.cwd;
|
|
4572
|
-
const
|
|
4573
|
-
const
|
|
4768
|
+
const diskCwd = cwd !== void 0 && existsSync2(cwd) ? cwd : void 0;
|
|
4769
|
+
const onDisk = diskCwd !== void 0;
|
|
4770
|
+
const root = diskCwd !== void 0 ? canonicalPath(diskCwd) : cwd;
|
|
4574
4771
|
const memo = /* @__PURE__ */ new Map();
|
|
4575
4772
|
const rel = (t) => {
|
|
4576
4773
|
const hit = memo.get(t);
|
|
4577
4774
|
if (hit !== void 0) return hit;
|
|
4578
4775
|
let p = expandShellPath(t.replace(/^["']|["']$/g, ""));
|
|
4579
4776
|
const pathy = isAbsolute4(p) || p.includes("/") && !/^[-:]/.test(p);
|
|
4580
|
-
|
|
4581
|
-
|
|
4777
|
+
const abs2 = isAbsolute4(p) ? p : diskCwd !== void 0 ? resolve2(diskCwd, p) : null;
|
|
4778
|
+
if (pathy && abs2 !== null) {
|
|
4779
|
+
p = canonicalPath(abs2);
|
|
4582
4780
|
if (root !== void 0) {
|
|
4583
4781
|
if (p === root) p = ".";
|
|
4584
4782
|
else if (p.startsWith(root + "/")) p = p.slice(root.length + 1);
|
|
@@ -4743,7 +4941,7 @@ function xargsWritesHook(toks, fed, policy, ctx) {
|
|
|
4743
4941
|
}
|
|
4744
4942
|
|
|
4745
4943
|
// src/detectors/hook-tampering.ts
|
|
4746
|
-
var
|
|
4944
|
+
var RULE7 = "hook-tampering";
|
|
4747
4945
|
var ownerExec2 = (mode) => (parseInt(mode.slice(-3), 8) & 64) !== 0;
|
|
4748
4946
|
var EVENTS = ["PreToolUse", "Stop"];
|
|
4749
4947
|
var isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
@@ -4959,7 +5157,7 @@ function coverage(cs) {
|
|
|
4959
5157
|
const live = cs.filter((c) => c.runs);
|
|
4960
5158
|
if (live.length === 0) return "none";
|
|
4961
5159
|
if (live.some((c) => c.tools === null)) return null;
|
|
4962
|
-
return new Set(live.flatMap((c) => [...c.tools]));
|
|
5160
|
+
return new Set(live.flatMap((c) => c.tools ? [...c.tools] : []));
|
|
4963
5161
|
}
|
|
4964
5162
|
function duplicateJsonKeys(src) {
|
|
4965
5163
|
const dups = [];
|
|
@@ -5072,7 +5270,7 @@ function settingsWeakening(before, after, local) {
|
|
|
5072
5270
|
const schema = schemaProblem(e.group, e.h);
|
|
5073
5271
|
if (schema) {
|
|
5074
5272
|
reasons.push(`a ${e.event} hook entry carries a value the runtime's schema rejects (${schema}) \u2014 the runtime continues without the broken file, the gate included`);
|
|
5075
|
-
} else if (e.event === "PreToolUse" || e.event === "Stop") {
|
|
5273
|
+
} else if ((e.event === "PreToolUse" || e.event === "Stop") && isObj(e.h)) {
|
|
5076
5274
|
reasons.push(`a ${e.event} hook entry beside the gate ${siblingProblem(e.h)}`);
|
|
5077
5275
|
}
|
|
5078
5276
|
}
|
|
@@ -5090,7 +5288,7 @@ function claudeSettingsFindings(c, policy) {
|
|
|
5090
5288
|
if (c.after == null) return null;
|
|
5091
5289
|
const after = parseSettings(c.after);
|
|
5092
5290
|
const before = c.before != null ? parseSettings(c.before) : null;
|
|
5093
|
-
const finding = (reason) => makeFinding(
|
|
5291
|
+
const finding = (reason) => makeFinding(RULE7, policy, {
|
|
5094
5292
|
file: c.path,
|
|
5095
5293
|
message: `The Claude Code hook wiring was weakened: ${reason}.`,
|
|
5096
5294
|
evidence: reason,
|
|
@@ -5111,10 +5309,10 @@ function claudeSettingsFindings(c, policy) {
|
|
|
5111
5309
|
function policyFindings(c, policy) {
|
|
5112
5310
|
if (c.after == null) return null;
|
|
5113
5311
|
const added = c.before == null;
|
|
5114
|
-
const reasons =
|
|
5312
|
+
const reasons = c.before == null ? policyAddWeakening(c.after) : policyWeakening(c.before, c.after);
|
|
5115
5313
|
if (reasons === null) return null;
|
|
5116
5314
|
return reasons.map(
|
|
5117
|
-
(reason) => makeFinding(
|
|
5315
|
+
(reason) => makeFinding(RULE7, policy, {
|
|
5118
5316
|
file: c.path,
|
|
5119
5317
|
message: added ? `A policy file was added that weakens the baseline: ${reason}.` : `The policy was weakened: ${reason}.`,
|
|
5120
5318
|
evidence: reason,
|
|
@@ -5177,7 +5375,7 @@ function configFindings(c, changes, policy, ctx) {
|
|
|
5177
5375
|
reasons = after === null ? ["the file no longer parses as YAML \u2014 the tool that reads it runs nothing"] : isLefthook(c.path) ? lefthookWeakening(before, after) : preCommitWeakening(before, after);
|
|
5178
5376
|
}
|
|
5179
5377
|
return reasons.map(
|
|
5180
|
-
(reason) => makeFinding(
|
|
5378
|
+
(reason) => makeFinding(RULE7, policy, {
|
|
5181
5379
|
file: c.path,
|
|
5182
5380
|
message: `The hook configuration was weakened: ${reason}.`,
|
|
5183
5381
|
evidence: reason,
|
|
@@ -5260,7 +5458,7 @@ function huskyRuntimeWrite(path, content, ctx) {
|
|
|
5260
5458
|
const root = path.slice(0, path.length - m[0].length + (m[0].startsWith("/") ? 1 : 0));
|
|
5261
5459
|
const entry = inspectRel(join6(ctx.cwd, root), ".husky/pre-commit");
|
|
5262
5460
|
if (entry.kind !== "file") return false;
|
|
5263
|
-
const gate =
|
|
5461
|
+
const gate = textOf2(entry);
|
|
5264
5462
|
if (gate == null) return false;
|
|
5265
5463
|
return invocations(gate.split("\n"), { errexit: true }).some(
|
|
5266
5464
|
(i) => i.identity === "tamperward check --staged" && i.state === "live" && i.pin === TW_VERSION
|
|
@@ -5269,7 +5467,7 @@ function huskyRuntimeWrite(path, content, ctx) {
|
|
|
5269
5467
|
var isHuskyScript = (path) => /(?:^|\/)\.husky\/[^/]+$/.test(path);
|
|
5270
5468
|
var isHookNote = (path) => /\.(?:md|markdown|txt)$|(?:^|\/)\.git(?:ignore|attributes)$/i.test(path);
|
|
5271
5469
|
var hookTampering = {
|
|
5272
|
-
id:
|
|
5470
|
+
id: RULE7,
|
|
5273
5471
|
surface: ["file", "command"],
|
|
5274
5472
|
certainty: "mechanical",
|
|
5275
5473
|
run(changes, policy, _view, ctx) {
|
|
@@ -5282,7 +5480,7 @@ var hookTampering = {
|
|
|
5282
5480
|
if (isPackageJson(c.path) && c.before != null && c.after != null) {
|
|
5283
5481
|
for (const reason of packageJsonWeakening(c.before, c.after)) {
|
|
5284
5482
|
out2.push(
|
|
5285
|
-
makeFinding(
|
|
5483
|
+
makeFinding(RULE7, policy, {
|
|
5286
5484
|
file: c.path,
|
|
5287
5485
|
message: `The hook installation was weakened: ${reason}.`,
|
|
5288
5486
|
evidence: reason,
|
|
@@ -5295,7 +5493,7 @@ var hookTampering = {
|
|
|
5295
5493
|
}
|
|
5296
5494
|
if (c.op !== "delete" && c.oldMode && c.newMode && ownerExec2(c.oldMode) && !ownerExec2(c.newMode)) {
|
|
5297
5495
|
out2.push(
|
|
5298
|
-
makeFinding(
|
|
5496
|
+
makeFinding(RULE7, policy, {
|
|
5299
5497
|
file: c.path,
|
|
5300
5498
|
message: `Execute permission was removed from a protected hook (${c.oldMode} \u2192 ${c.newMode}) \u2014 git will skip it.`,
|
|
5301
5499
|
evidence: `${c.path}: mode ${c.oldMode} \u2192 ${c.newMode}`,
|
|
@@ -5305,7 +5503,7 @@ var hookTampering = {
|
|
|
5305
5503
|
}
|
|
5306
5504
|
if (c.op === "delete") {
|
|
5307
5505
|
out2.push(
|
|
5308
|
-
makeFinding(
|
|
5506
|
+
makeFinding(RULE7, policy, {
|
|
5309
5507
|
file: c.path,
|
|
5310
5508
|
message: "A protected hook/policy file was deleted.",
|
|
5311
5509
|
evidence: c.path,
|
|
@@ -5314,7 +5512,7 @@ var hookTampering = {
|
|
|
5314
5512
|
);
|
|
5315
5513
|
} else if (c.op === "rename" && !renameKept(c)) {
|
|
5316
5514
|
out2.push(
|
|
5317
|
-
makeFinding(
|
|
5515
|
+
makeFinding(RULE7, policy, {
|
|
5318
5516
|
file: c.path,
|
|
5319
5517
|
message: `A protected hook/policy file was renamed (${c.oldPath} \u2192 ${c.path}).`,
|
|
5320
5518
|
evidence: `${c.oldPath} \u2192 ${c.path}`,
|
|
@@ -5328,7 +5526,7 @@ var hookTampering = {
|
|
|
5328
5526
|
if (c.before != null && c.after != null) {
|
|
5329
5527
|
for (const reason of codeownersWeakening(c.before, c.after, [...repoGateFiles(ctx), c.path])) {
|
|
5330
5528
|
out2.push(
|
|
5331
|
-
makeFinding(
|
|
5529
|
+
makeFinding(RULE7, policy, {
|
|
5332
5530
|
file: c.path,
|
|
5333
5531
|
message: `CODEOWNERS was weakened: ${reason}.`,
|
|
5334
5532
|
evidence: reason,
|
|
@@ -5344,12 +5542,12 @@ var hookTampering = {
|
|
|
5344
5542
|
const husky = isHuskyScript(c.path);
|
|
5345
5543
|
const opts = husky ? { errexit: true } : {};
|
|
5346
5544
|
const beforePin = c.before != null && c.after != null ? initScriptPin(c.before) : null;
|
|
5347
|
-
if (beforePin !== null) {
|
|
5545
|
+
if (beforePin !== null && c.after != null) {
|
|
5348
5546
|
const afterPin = initScriptPin(c.after);
|
|
5349
5547
|
const kept = afterPin !== null && (beforePin === "" ? afterPin === "" || PLAIN_SEMVER.test(afterPin) : pinNotBelow(afterPin, beforePin));
|
|
5350
5548
|
if (!kept) {
|
|
5351
5549
|
out2.push(
|
|
5352
|
-
makeFinding(
|
|
5550
|
+
makeFinding(RULE7, policy, {
|
|
5353
5551
|
file: c.path,
|
|
5354
5552
|
message: "A protected hook script no longer matches the shape `tamperward init` writes.",
|
|
5355
5553
|
evidence: "the gate script no longer matches the shape init wrote; re-run init or sign off",
|
|
@@ -5372,7 +5570,7 @@ var hookTampering = {
|
|
|
5372
5570
|
if (shebang || !live) {
|
|
5373
5571
|
const why = shebang ?? (gate.length ? `its \`${gate[0].identity}\` is ${gate[0].state}${gate[0].why ? ` (${gate[0].why})` : ""}` : "it runs no `tamperward check`");
|
|
5374
5572
|
out2.push(
|
|
5375
|
-
makeFinding(
|
|
5573
|
+
makeFinding(RULE7, policy, {
|
|
5376
5574
|
file: c.path,
|
|
5377
5575
|
message: `A protected hook script was added that does not run the gate live: ${why}. A new gate script needs a sign-off unless the gate is live in it.`,
|
|
5378
5576
|
evidence: `a hook script was added; sign off with \`tamperward allow hook-tampering --file ${c.path} --reason "..."\` locally or the \`tamperward:allow:hook-tampering@<head-sha>\` label in CI, or make the gate live in it`,
|
|
@@ -5381,7 +5579,7 @@ var hookTampering = {
|
|
|
5381
5579
|
);
|
|
5382
5580
|
} else if (earlyExit) {
|
|
5383
5581
|
out2.push(
|
|
5384
|
-
makeFinding(
|
|
5582
|
+
makeFinding(RULE7, policy, {
|
|
5385
5583
|
file: c.path,
|
|
5386
5584
|
line: earlyExit.newLine ?? void 0,
|
|
5387
5585
|
message: "An early `exit 0` was inserted into a protected hook \u2014 it now passes everything.",
|
|
@@ -5403,7 +5601,7 @@ var hookTampering = {
|
|
|
5403
5601
|
deadGuard2 ? `a guard that can never be true was inserted (\`${deadGuard2.content.trim()}\`)` : null
|
|
5404
5602
|
].filter((d) => d !== null);
|
|
5405
5603
|
out2.push(
|
|
5406
|
-
makeFinding(
|
|
5604
|
+
makeFinding(RULE7, policy, {
|
|
5407
5605
|
file: c.path,
|
|
5408
5606
|
message: `A hand-written protected hook script was changed: ${detail.length ? detail.join("; ") : "an edit other than a pin raise"}. Every edit to a gate script other than raising its pin needs a sign-off.`,
|
|
5409
5607
|
evidence: `the gate script changed; sign off with \`tamperward allow hook-tampering --file ${c.path} --reason "..."\` locally or the \`tamperward:allow:hook-tampering@<head-sha>\` label in CI, or restore it`,
|
|
@@ -5415,7 +5613,7 @@ var hookTampering = {
|
|
|
5415
5613
|
}
|
|
5416
5614
|
if (shebangChanged && c.after != null) {
|
|
5417
5615
|
out2.push(
|
|
5418
|
-
makeFinding(
|
|
5616
|
+
makeFinding(RULE7, policy, {
|
|
5419
5617
|
file: c.path,
|
|
5420
5618
|
line: 1,
|
|
5421
5619
|
message: `A protected hook's interpreter was changed: ${shebang}.`,
|
|
@@ -5426,7 +5624,7 @@ var hookTampering = {
|
|
|
5426
5624
|
}
|
|
5427
5625
|
if (earlyExit) {
|
|
5428
5626
|
out2.push(
|
|
5429
|
-
makeFinding(
|
|
5627
|
+
makeFinding(RULE7, policy, {
|
|
5430
5628
|
file: c.path,
|
|
5431
5629
|
line: earlyExit.newLine ?? void 0,
|
|
5432
5630
|
message: "An early `exit 0` was inserted into a protected hook \u2014 it now passes everything.",
|
|
@@ -5438,7 +5636,7 @@ var hookTampering = {
|
|
|
5438
5636
|
const deadGuard = c.before == null || c.after == null ? added.find((l) => insertsDeadGuard(l.content)) : void 0;
|
|
5439
5637
|
if (deadGuard) {
|
|
5440
5638
|
out2.push(
|
|
5441
|
-
makeFinding(
|
|
5639
|
+
makeFinding(RULE7, policy, {
|
|
5442
5640
|
file: c.path,
|
|
5443
5641
|
line: deadGuard.newLine ?? void 0,
|
|
5444
5642
|
message: "A guard that can never be true was inserted into a protected hook \u2014 what it wraps no longer runs.",
|
|
@@ -5450,7 +5648,7 @@ var hookTampering = {
|
|
|
5450
5648
|
const lost = scriptWeakening(beforeLines, afterLines, opts);
|
|
5451
5649
|
if (lost.length) {
|
|
5452
5650
|
out2.push(
|
|
5453
|
-
makeFinding(
|
|
5651
|
+
makeFinding(RULE7, policy, {
|
|
5454
5652
|
file: c.path,
|
|
5455
5653
|
message: `A check invocation was removed from a protected hook: ${lost[0].reason}.`,
|
|
5456
5654
|
evidence: lost[0].evidence,
|
|
@@ -5469,7 +5667,7 @@ var hookTampering = {
|
|
|
5469
5667
|
const m = l.content.match(INLINE);
|
|
5470
5668
|
if (m && m[2] === "warn" && removedSeverity.get(m[1]) === "block") {
|
|
5471
5669
|
out2.push(
|
|
5472
|
-
makeFinding(
|
|
5670
|
+
makeFinding(RULE7, policy, {
|
|
5473
5671
|
file: c.path,
|
|
5474
5672
|
line: l.newLine ?? void 0,
|
|
5475
5673
|
message: `Rule "${m[1]}" was lowered from block to warn in the policy.`,
|
|
@@ -5479,7 +5677,7 @@ var hookTampering = {
|
|
|
5479
5677
|
);
|
|
5480
5678
|
} else if (/enabled:\s*false\b/.test(l.content)) {
|
|
5481
5679
|
out2.push(
|
|
5482
|
-
makeFinding(
|
|
5680
|
+
makeFinding(RULE7, policy, {
|
|
5483
5681
|
file: c.path,
|
|
5484
5682
|
line: l.newLine ?? void 0,
|
|
5485
5683
|
message: "A rule was disabled (enabled: false) in the policy.",
|
|
@@ -5497,7 +5695,7 @@ var hookTampering = {
|
|
|
5497
5695
|
const hiding = updateIndexHiding(toks, policy);
|
|
5498
5696
|
if (hiding) {
|
|
5499
5697
|
out2.push(
|
|
5500
|
-
makeFinding(
|
|
5698
|
+
makeFinding(RULE7, policy, {
|
|
5501
5699
|
file: repoRelative(hiding.file, ctx),
|
|
5502
5700
|
message: `Hook tampering via shell: ${hiding.reason}.`,
|
|
5503
5701
|
evidence: seg,
|
|
@@ -5513,7 +5711,7 @@ var hookTampering = {
|
|
|
5513
5711
|
const file = target ? repoRelative(target, ctx) : void 0;
|
|
5514
5712
|
const outside = file !== void 0 && (isAbsolute5(file) || /^[A-Za-z]:\//.test(file)) && isClaudeSettings(file) ? ` \u2014 ${file} is a Claude Code settings file the runtime reads hooks from` : "";
|
|
5515
5713
|
out2.push(
|
|
5516
|
-
makeFinding(
|
|
5714
|
+
makeFinding(RULE7, policy, {
|
|
5517
5715
|
...file !== void 0 ? { file } : {},
|
|
5518
5716
|
message: `Hook tampering via shell: ${why}${outside}.`,
|
|
5519
5717
|
evidence: seg,
|
|
@@ -5530,11 +5728,11 @@ var hookTampering = {
|
|
|
5530
5728
|
};
|
|
5531
5729
|
|
|
5532
5730
|
// src/detectors/test-deletion.ts
|
|
5533
|
-
import
|
|
5731
|
+
import ts6 from "typescript";
|
|
5534
5732
|
|
|
5535
5733
|
// src/detectors/suite-config.ts
|
|
5536
5734
|
import picomatch3 from "picomatch";
|
|
5537
|
-
import
|
|
5735
|
+
import ts5 from "typescript";
|
|
5538
5736
|
var JEST_DEFAULT_MATCH = ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)"];
|
|
5539
5737
|
var JEST_DEFAULT_IGNORE = ["/node_modules/"];
|
|
5540
5738
|
var VITEST_DEFAULT_INCLUDE = ["**/*.{test,spec}.?(c|m)[jt]s?(x)"];
|
|
@@ -5565,18 +5763,18 @@ var PYTEST_CANONICAL_SAMPLES = [
|
|
|
5565
5763
|
"tests/conftest.py"
|
|
5566
5764
|
];
|
|
5567
5765
|
function keyName2(name) {
|
|
5568
|
-
if (
|
|
5766
|
+
if (ts5.isIdentifier(name) || ts5.isStringLiteral(name)) return name.text;
|
|
5569
5767
|
return null;
|
|
5570
5768
|
}
|
|
5571
|
-
var isStr = (e) =>
|
|
5769
|
+
var isStr = (e) => ts5.isStringLiteral(e) || ts5.isNoSubstitutionTemplateLiteral(e);
|
|
5572
5770
|
function literals2(e) {
|
|
5573
5771
|
if (isStr(e)) return { items: [e.text], opaque: false };
|
|
5574
|
-
if (!
|
|
5772
|
+
if (!ts5.isArrayLiteralExpression(e)) return { items: [], opaque: true };
|
|
5575
5773
|
const items = [];
|
|
5576
5774
|
let opaque = false;
|
|
5577
5775
|
for (const el of e.elements) {
|
|
5578
5776
|
if (isStr(el)) items.push(el.text);
|
|
5579
|
-
else if (
|
|
5777
|
+
else if (ts5.isSpreadElement(el) && /^(?:configDefaults\.|default)(?:exclude|include)$/i.test(el.expression.getText())) {
|
|
5580
5778
|
items.push(.../exclude$/i.test(el.expression.getText()) ? VITEST_DEFAULT_EXCLUDE : VITEST_DEFAULT_INCLUDE);
|
|
5581
5779
|
} else opaque = true;
|
|
5582
5780
|
}
|
|
@@ -5584,10 +5782,10 @@ function literals2(e) {
|
|
|
5584
5782
|
}
|
|
5585
5783
|
function ownerKey2(node) {
|
|
5586
5784
|
const obj = node.parent;
|
|
5587
|
-
if (!obj || !
|
|
5785
|
+
if (!obj || !ts5.isObjectLiteralExpression(obj)) return null;
|
|
5588
5786
|
let q = obj.parent;
|
|
5589
|
-
while (q && (
|
|
5590
|
-
return q &&
|
|
5787
|
+
while (q && (ts5.isParenthesizedExpression(q) || ts5.isAsExpression(q) || q.kind === ts5.SyntaxKind.SatisfiesExpression)) q = q.parent;
|
|
5788
|
+
return q && ts5.isPropertyAssignment(q) ? keyName2(q.name) : null;
|
|
5591
5789
|
}
|
|
5592
5790
|
function asExpression2(src) {
|
|
5593
5791
|
const t = src.trim();
|
|
@@ -5622,13 +5820,12 @@ function effectivePytestFile(files) {
|
|
|
5622
5820
|
function effectivePytestConfig(path, ctx) {
|
|
5623
5821
|
if (path.includes("/")) return false;
|
|
5624
5822
|
const base = path;
|
|
5625
|
-
const
|
|
5626
|
-
const rank = ORDER.indexOf(base);
|
|
5823
|
+
const rank = PYTEST_INI_ORDER.findIndex((name) => name === base);
|
|
5627
5824
|
if (rank < 0) return false;
|
|
5628
5825
|
const files = ctx?.trackedFiles;
|
|
5629
5826
|
if (!files) return true;
|
|
5630
5827
|
const rootNames = new Set(files.filter((f) => !f.includes("/")));
|
|
5631
|
-
return !
|
|
5828
|
+
return !PYTEST_INI_ORDER.slice(0, rank).some((n) => rootNames.has(n));
|
|
5632
5829
|
}
|
|
5633
5830
|
function pytestBlock(src) {
|
|
5634
5831
|
const lines = src.split(/\r?\n/);
|
|
@@ -5728,15 +5925,15 @@ function collect(root, runner) {
|
|
|
5728
5925
|
projectList = e;
|
|
5729
5926
|
};
|
|
5730
5927
|
const visit = (node) => {
|
|
5731
|
-
if (
|
|
5928
|
+
if (ts5.isSpreadAssignment(node) && spreadsIntoConfig(node, runner)) {
|
|
5732
5929
|
sel.present = true;
|
|
5733
5930
|
sel.opaque = true;
|
|
5734
5931
|
}
|
|
5735
|
-
if (
|
|
5932
|
+
if (ts5.isCallExpression(node) && /^(?:\w+\.)?mergeConfig$/.test(node.expression.getText()) && node.arguments[0] && !ts5.isObjectLiteralExpression(unwrapConfigCall(node.arguments[0]))) {
|
|
5736
5933
|
sel.present = true;
|
|
5737
5934
|
sel.opaque = true;
|
|
5738
5935
|
}
|
|
5739
|
-
if (
|
|
5936
|
+
if (ts5.isPropertyAssignment(node)) {
|
|
5740
5937
|
const k = keyName2(node.name);
|
|
5741
5938
|
if (runner === "jest") {
|
|
5742
5939
|
if (k === "testMatch") sel.include = take(k, node.initializer);
|
|
@@ -5766,7 +5963,7 @@ function collect(root, runner) {
|
|
|
5766
5963
|
}
|
|
5767
5964
|
}
|
|
5768
5965
|
}
|
|
5769
|
-
|
|
5966
|
+
ts5.forEachChild(node, visit);
|
|
5770
5967
|
};
|
|
5771
5968
|
visit(root);
|
|
5772
5969
|
if (projectList) sel.projects = readProjects(projectList, sel, runner);
|
|
@@ -5775,30 +5972,30 @@ function collect(root, runner) {
|
|
|
5775
5972
|
}
|
|
5776
5973
|
function unwrapConfigCall(e) {
|
|
5777
5974
|
let x = e;
|
|
5778
|
-
while (
|
|
5975
|
+
while (ts5.isParenthesizedExpression(x) || ts5.isAsExpression(x) || ts5.isSatisfiesExpression(x)) {
|
|
5779
5976
|
x = x.expression;
|
|
5780
5977
|
}
|
|
5781
|
-
if (
|
|
5978
|
+
if (ts5.isCallExpression(x) && /^(?:\w+\.)?define(?:Config|Project)$/.test(x.expression.getText()) && x.arguments[0]) return unwrapConfigCall(x.arguments[0]);
|
|
5782
5979
|
return x;
|
|
5783
5980
|
}
|
|
5784
5981
|
var JEST_SELECTION_KEYS = /^(?:testMatch|testRegex|testPathIgnorePatterns|modulePathIgnorePatterns|roots|rootDir|projects)$/;
|
|
5785
5982
|
function spreadsIntoConfig(node, runner) {
|
|
5786
5983
|
const obj = node.parent;
|
|
5787
|
-
if (!
|
|
5788
|
-
const keys = obj.properties.map((p) =>
|
|
5984
|
+
if (!ts5.isObjectLiteralExpression(obj)) return false;
|
|
5985
|
+
const keys = obj.properties.map((p) => ts5.isPropertyAssignment(p) ? keyName2(p.name) : null);
|
|
5789
5986
|
if (runner === "vitest") {
|
|
5790
5987
|
if (keys.includes("test")) return true;
|
|
5791
5988
|
let q = obj.parent;
|
|
5792
|
-
while (q && (
|
|
5793
|
-
return !!q &&
|
|
5989
|
+
while (q && (ts5.isParenthesizedExpression(q) || ts5.isAsExpression(q) || q.kind === ts5.SyntaxKind.SatisfiesExpression)) q = q.parent;
|
|
5990
|
+
return !!q && ts5.isPropertyAssignment(q) && keyName2(q.name) === "test";
|
|
5794
5991
|
}
|
|
5795
5992
|
return keys.some((k) => k !== null && JEST_SELECTION_KEYS.test(k));
|
|
5796
5993
|
}
|
|
5797
5994
|
function extendsOf(el) {
|
|
5798
5995
|
for (const p of el.properties) {
|
|
5799
|
-
if (!
|
|
5800
|
-
if (p.initializer.kind ===
|
|
5801
|
-
if (p.initializer.kind ===
|
|
5996
|
+
if (!ts5.isPropertyAssignment(p) || keyName2(p.name) !== "extends") continue;
|
|
5997
|
+
if (p.initializer.kind === ts5.SyntaxKind.TrueKeyword) return true;
|
|
5998
|
+
if (p.initializer.kind === ts5.SyntaxKind.FalseKeyword) return null;
|
|
5802
5999
|
return "path";
|
|
5803
6000
|
}
|
|
5804
6001
|
return null;
|
|
@@ -5819,14 +6016,14 @@ function readProject(el, root, runner) {
|
|
|
5819
6016
|
return p;
|
|
5820
6017
|
}
|
|
5821
6018
|
function readProjects(e, root, runner) {
|
|
5822
|
-
if (!
|
|
6019
|
+
if (!ts5.isArrayLiteralExpression(e)) {
|
|
5823
6020
|
root.opaque = true;
|
|
5824
6021
|
return [];
|
|
5825
6022
|
}
|
|
5826
6023
|
const list2 = [];
|
|
5827
6024
|
for (const el of e.elements) {
|
|
5828
6025
|
const lit = unwrapConfigCall(el);
|
|
5829
|
-
if (
|
|
6026
|
+
if (ts5.isObjectLiteralExpression(lit)) list2.push(readProject(lit, root, runner));
|
|
5830
6027
|
else root.opaque = true;
|
|
5831
6028
|
}
|
|
5832
6029
|
return list2;
|
|
@@ -5834,14 +6031,14 @@ function readProjects(e, root, runner) {
|
|
|
5834
6031
|
function workspaceArray(sf) {
|
|
5835
6032
|
for (const st of sf.statements) {
|
|
5836
6033
|
let e;
|
|
5837
|
-
if (
|
|
5838
|
-
else if (
|
|
6034
|
+
if (ts5.isExportAssignment(st)) e = st.expression;
|
|
6035
|
+
else if (ts5.isExpressionStatement(st) && ts5.isBinaryExpression(st.expression) && /module\.exports/.test(st.expression.left.getText())) e = st.expression.right;
|
|
5839
6036
|
if (!e) continue;
|
|
5840
|
-
while (
|
|
6037
|
+
while (ts5.isParenthesizedExpression(e) || ts5.isAsExpression(e) || ts5.isSatisfiesExpression(e)) {
|
|
5841
6038
|
e = e.expression;
|
|
5842
6039
|
}
|
|
5843
|
-
if (
|
|
5844
|
-
return
|
|
6040
|
+
if (ts5.isCallExpression(e) && e.arguments.length > 0) e = e.arguments[0];
|
|
6041
|
+
return ts5.isArrayLiteralExpression(e) ? e : null;
|
|
5845
6042
|
}
|
|
5846
6043
|
return null;
|
|
5847
6044
|
}
|
|
@@ -5849,7 +6046,7 @@ function parseSelection(src, runner, path = "") {
|
|
|
5849
6046
|
let sel = empty();
|
|
5850
6047
|
if (runner === "pytest") return pytestSelection(src);
|
|
5851
6048
|
try {
|
|
5852
|
-
const sf =
|
|
6049
|
+
const sf = ts5.createSourceFile("cfg.ts", asExpression2(src), ts5.ScriptTarget.Latest, true, ts5.ScriptKind.TS);
|
|
5853
6050
|
if (runner === "vitest" && isWorkspaceFile(path)) {
|
|
5854
6051
|
const arr = workspaceArray(sf);
|
|
5855
6052
|
if (arr) {
|
|
@@ -5943,7 +6140,7 @@ function suiteNarrowings(before, after, path, samples) {
|
|
|
5943
6140
|
}
|
|
5944
6141
|
|
|
5945
6142
|
// src/detectors/test-deletion.ts
|
|
5946
|
-
var
|
|
6143
|
+
var RULE8 = "test-deletion";
|
|
5947
6144
|
var JVM_TEST = /^\s*(?:@\w+(?:\([^)]*\))?\s+)*@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/;
|
|
5948
6145
|
var TEST_DEFS = {
|
|
5949
6146
|
py: /^\s*(?:async\s+)?def\s+test\w*\s*\(/,
|
|
@@ -5956,21 +6153,21 @@ var TEST_DEFS = {
|
|
|
5956
6153
|
cs: /^\s*\[(?:Fact|Theory|Test|TestMethod|TestCase|DataTestMethod)\b/
|
|
5957
6154
|
};
|
|
5958
6155
|
function calleeName(expr) {
|
|
5959
|
-
if (
|
|
5960
|
-
if (
|
|
6156
|
+
if (ts6.isIdentifier(expr)) return expr.text;
|
|
6157
|
+
if (ts6.isPropertyAccessExpression(expr) && ts6.isIdentifier(expr.expression)) {
|
|
5961
6158
|
return expr.expression.text;
|
|
5962
6159
|
}
|
|
5963
6160
|
return null;
|
|
5964
6161
|
}
|
|
5965
6162
|
var TABLE_METHOD = /^(?:each|for)$/;
|
|
5966
|
-
var isEachOf = (expr) =>
|
|
5967
|
-
var isDescribeEach = (expr) =>
|
|
6163
|
+
var isEachOf = (expr) => ts6.isPropertyAccessExpression(expr) && ts6.isIdentifier(expr.expression) && (expr.expression.text === "it" || expr.expression.text === "test") && TABLE_METHOD.test(expr.name.text);
|
|
6164
|
+
var isDescribeEach = (expr) => ts6.isPropertyAccessExpression(expr) && ts6.isIdentifier(expr.expression) && expr.expression.text === "describe" && TABLE_METHOD.test(expr.name.text);
|
|
5968
6165
|
function loopRows(iterable) {
|
|
5969
|
-
if (
|
|
6166
|
+
if (ts6.isArrayLiteralExpression(iterable)) {
|
|
5970
6167
|
let n = 0;
|
|
5971
6168
|
let open = false;
|
|
5972
6169
|
for (const el of iterable.elements) {
|
|
5973
|
-
if (
|
|
6170
|
+
if (ts6.isSpreadElement(el)) open = true;
|
|
5974
6171
|
else n++;
|
|
5975
6172
|
}
|
|
5976
6173
|
return { n: Math.max(n, open ? 0 : 1), open };
|
|
@@ -5982,20 +6179,20 @@ function templateRows(node) {
|
|
|
5982
6179
|
return Math.max(rows, 1);
|
|
5983
6180
|
}
|
|
5984
6181
|
function hasSubstantiveBody(call) {
|
|
5985
|
-
const fn = call.arguments.find((a) =>
|
|
6182
|
+
const fn = call.arguments.find((a) => ts6.isArrowFunction(a) || ts6.isFunctionExpression(a));
|
|
5986
6183
|
if (!fn) return true;
|
|
5987
6184
|
const body = fn.body;
|
|
5988
|
-
const text =
|
|
6185
|
+
const text = ts6.isBlock(body) ? body.getText().slice(1, -1) : body.getText();
|
|
5989
6186
|
return text.split("\n").some((l) => isSignificantLine(l.trim(), "js"));
|
|
5990
6187
|
}
|
|
5991
6188
|
function eachRows(call) {
|
|
5992
6189
|
const arg = call.arguments[0];
|
|
5993
6190
|
if (!arg) return { n: 1, open: true };
|
|
5994
|
-
if (
|
|
6191
|
+
if (ts6.isArrayLiteralExpression(arg)) {
|
|
5995
6192
|
let n = 0;
|
|
5996
6193
|
let open = false;
|
|
5997
6194
|
for (const el of arg.elements) {
|
|
5998
|
-
if (
|
|
6195
|
+
if (ts6.isSpreadElement(el)) open = true;
|
|
5999
6196
|
else n++;
|
|
6000
6197
|
}
|
|
6001
6198
|
return { n: Math.max(n, open ? 0 : 1), open };
|
|
@@ -6010,35 +6207,35 @@ function countTests(src, path = "spec.ts", substantiveOnly = false) {
|
|
|
6010
6207
|
for (const line of src.split("\n")) if (re.test(line)) n2++;
|
|
6011
6208
|
return { min: n2, open: false };
|
|
6012
6209
|
}
|
|
6013
|
-
const sf =
|
|
6210
|
+
const sf = ts6.createSourceFile("spec.ts", src, ts6.ScriptTarget.Latest, true, ts6.ScriptKind.TS);
|
|
6014
6211
|
let n = 0;
|
|
6015
6212
|
let open = false;
|
|
6016
6213
|
const visit = (node, mult) => {
|
|
6017
|
-
if (
|
|
6214
|
+
if (ts6.isForOfStatement(node)) {
|
|
6018
6215
|
const r = loopRows(node.expression);
|
|
6019
6216
|
if (r.open) open = true;
|
|
6020
6217
|
visit(node.statement, mult * r.n);
|
|
6021
6218
|
return;
|
|
6022
6219
|
}
|
|
6023
|
-
if (
|
|
6220
|
+
if (ts6.isForStatement(node) || ts6.isForInStatement(node) || ts6.isWhileStatement(node) || ts6.isDoStatement(node)) {
|
|
6024
6221
|
open = true;
|
|
6025
6222
|
}
|
|
6026
|
-
if (
|
|
6223
|
+
if (ts6.isCallExpression(node)) {
|
|
6027
6224
|
const callee = node.expression;
|
|
6028
|
-
if (
|
|
6225
|
+
if (ts6.isPropertyAccessExpression(callee) && callee.name.text === "forEach") {
|
|
6029
6226
|
const r = loopRows(callee.expression);
|
|
6030
6227
|
if (r.open) open = true;
|
|
6031
6228
|
for (const a of node.arguments) visit(a, mult * r.n);
|
|
6032
6229
|
return;
|
|
6033
6230
|
}
|
|
6034
6231
|
const head = node.expression;
|
|
6035
|
-
if (
|
|
6232
|
+
if (ts6.isCallExpression(head) && isDescribeEach(head.expression)) {
|
|
6036
6233
|
const r = eachRows(head);
|
|
6037
6234
|
if (r.open) open = true;
|
|
6038
6235
|
for (const a of node.arguments) visit(a, mult * r.n);
|
|
6039
6236
|
return;
|
|
6040
6237
|
}
|
|
6041
|
-
if (
|
|
6238
|
+
if (ts6.isTaggedTemplateExpression(head) && isDescribeEach(head.tag)) {
|
|
6042
6239
|
for (const a of node.arguments) visit(a, mult * templateRows(head));
|
|
6043
6240
|
return;
|
|
6044
6241
|
}
|
|
@@ -6050,10 +6247,10 @@ function countTests(src, path = "spec.ts", substantiveOnly = false) {
|
|
|
6050
6247
|
const name = calleeName(node.expression);
|
|
6051
6248
|
if ((name === "it" || name === "test") && (!substantiveOnly || hasSubstantiveBody(node))) n += mult;
|
|
6052
6249
|
}
|
|
6053
|
-
} else if (
|
|
6250
|
+
} else if (ts6.isTaggedTemplateExpression(node) && isEachOf(node.tag)) {
|
|
6054
6251
|
n += templateRows(node) * mult;
|
|
6055
6252
|
}
|
|
6056
|
-
|
|
6253
|
+
ts6.forEachChild(node, (c) => visit(c, mult));
|
|
6057
6254
|
};
|
|
6058
6255
|
visit(sf, 1);
|
|
6059
6256
|
return { min: n, open };
|
|
@@ -6090,7 +6287,8 @@ function scriptNarrowings(before, after, path) {
|
|
|
6090
6287
|
if ((path.split("/").pop() ?? path) !== "package.json") return [];
|
|
6091
6288
|
const scriptsOf = (src) => {
|
|
6092
6289
|
try {
|
|
6093
|
-
const
|
|
6290
|
+
const pkg = JSON.parse(src ?? "{}");
|
|
6291
|
+
const s = isRecord(pkg) && isRecord(pkg.scripts) ? pkg.scripts : {};
|
|
6094
6292
|
return Object.fromEntries(Object.entries(s).filter((e) => typeof e[1] === "string"));
|
|
6095
6293
|
} catch {
|
|
6096
6294
|
return {};
|
|
@@ -6135,7 +6333,7 @@ function leavesGlob(dir, dest, policy, ctx) {
|
|
|
6135
6333
|
}
|
|
6136
6334
|
var SED_INPLACE = /(?:^|\s)(?:-i\b|--in-place\b|-[a-zA-Z]*i[a-zA-Z]*\b)/;
|
|
6137
6335
|
var testDeletion = {
|
|
6138
|
-
id:
|
|
6336
|
+
id: RULE8,
|
|
6139
6337
|
surface: ["file", "command"],
|
|
6140
6338
|
certainty: "mechanical",
|
|
6141
6339
|
run(changes, policy, _view, ctx) {
|
|
@@ -6166,7 +6364,7 @@ var testDeletion = {
|
|
|
6166
6364
|
if (c.op === "delete" && isTest) {
|
|
6167
6365
|
if (c.before != null && isRelocation(significantLines(c.before, c.path), countTestBlocks(c.before, c.path))) continue;
|
|
6168
6366
|
out2.push(
|
|
6169
|
-
makeFinding(
|
|
6367
|
+
makeFinding(RULE8, policy, {
|
|
6170
6368
|
file: c.path,
|
|
6171
6369
|
message: "A test file was deleted.",
|
|
6172
6370
|
evidence: c.path,
|
|
@@ -6175,7 +6373,7 @@ var testDeletion = {
|
|
|
6175
6373
|
);
|
|
6176
6374
|
} else if (c.op === "rename" && c.oldPath && isSpec(c.oldPath) && !isTest) {
|
|
6177
6375
|
out2.push(
|
|
6178
|
-
makeFinding(
|
|
6376
|
+
makeFinding(RULE8, policy, {
|
|
6179
6377
|
file: c.path,
|
|
6180
6378
|
message: `A test file was renamed out of the test glob (${c.oldPath} \u2192 ${c.path}).`,
|
|
6181
6379
|
evidence: `${c.oldPath} \u2192 ${c.path}`,
|
|
@@ -6190,7 +6388,7 @@ var testDeletion = {
|
|
|
6190
6388
|
const removed = new Set([...significantLines(c.before, c.path)].filter((l) => !afterLines.has(l)));
|
|
6191
6389
|
if (isRelocation(removed, before.min - after.min)) continue;
|
|
6192
6390
|
out2.push(
|
|
6193
|
-
makeFinding(
|
|
6391
|
+
makeFinding(RULE8, policy, {
|
|
6194
6392
|
file: c.path,
|
|
6195
6393
|
message: `Test blocks removed: ${before.min} \u2192 ${after.min} it()/test() in this spec (each-table rows count as tests).`,
|
|
6196
6394
|
evidence: `${before.min - after.min} test block(s) removed from ${c.path}`,
|
|
@@ -6203,7 +6401,7 @@ var testDeletion = {
|
|
|
6203
6401
|
const readable = rn !== "pytest" || effectivePytestConfig(c.path, ctx);
|
|
6204
6402
|
for (const n of readable ? suiteNarrowings(c.before, c.after, c.path, runnerSamples(c.path, policy, ctx, rn)) : []) {
|
|
6205
6403
|
out2.push(
|
|
6206
|
-
makeFinding(
|
|
6404
|
+
makeFinding(RULE8, policy, {
|
|
6207
6405
|
file: c.path,
|
|
6208
6406
|
message: `The runner no longer selects ${n.path}: ${n.reason}.`,
|
|
6209
6407
|
evidence: `${n.path} dropped from the suite by ${c.path}`,
|
|
@@ -6213,7 +6411,7 @@ var testDeletion = {
|
|
|
6213
6411
|
}
|
|
6214
6412
|
for (const n of scriptNarrowings(c.before, c.after, c.path)) {
|
|
6215
6413
|
out2.push(
|
|
6216
|
-
makeFinding(
|
|
6414
|
+
makeFinding(RULE8, policy, {
|
|
6217
6415
|
file: c.path,
|
|
6218
6416
|
message: `The test script now narrows the suite: ${n.flag} added to scripts.${n.script}.`,
|
|
6219
6417
|
evidence: `"${n.script}": ${JSON.stringify(n.before)} \u2192 ${JSON.stringify(n.after)}`,
|
|
@@ -6301,7 +6499,7 @@ var testDeletion = {
|
|
|
6301
6499
|
}
|
|
6302
6500
|
if (why) {
|
|
6303
6501
|
out2.push(
|
|
6304
|
-
makeFinding(
|
|
6502
|
+
makeFinding(RULE8, policy, {
|
|
6305
6503
|
message: `Test removed via shell: ${why}.`,
|
|
6306
6504
|
evidence,
|
|
6307
6505
|
remediation: "Fix the code under test. Mutating a spec from the shell is still deleting the test \u2014 and is blocked the same way."
|
|
@@ -6316,11 +6514,10 @@ var testDeletion = {
|
|
|
6316
6514
|
}
|
|
6317
6515
|
};
|
|
6318
6516
|
function pytestPrecedenceFindings(changes, policy, ctx) {
|
|
6319
|
-
const order = PYTEST_INI_ORDER;
|
|
6320
6517
|
const touched = /* @__PURE__ */ new Map();
|
|
6321
6518
|
for (const c of changes) {
|
|
6322
6519
|
if (c.kind !== "file" || c.oldPath) continue;
|
|
6323
|
-
if (
|
|
6520
|
+
if (PYTEST_INI_ORDER.some((name) => name === c.path)) touched.set(c.path, c);
|
|
6324
6521
|
}
|
|
6325
6522
|
if (touched.size === 0) return [];
|
|
6326
6523
|
const listing = trackedFiles(ctx);
|
|
@@ -6328,7 +6525,7 @@ function pytestPrecedenceFindings(changes, policy, ctx) {
|
|
|
6328
6525
|
const rootNames = new Set(listing.filter((f) => !f.includes("/")));
|
|
6329
6526
|
const base = /* @__PURE__ */ new Map();
|
|
6330
6527
|
const head = /* @__PURE__ */ new Map();
|
|
6331
|
-
for (const name of
|
|
6528
|
+
for (const name of PYTEST_INI_ORDER) {
|
|
6332
6529
|
const c = touched.get(name);
|
|
6333
6530
|
if (c) {
|
|
6334
6531
|
base.set(name, c.before);
|
|
@@ -6345,7 +6542,7 @@ function pytestPrecedenceFindings(changes, policy, ctx) {
|
|
|
6345
6542
|
if (!was || !now || was.path === now.path) return [];
|
|
6346
6543
|
const samples = runnerSamples(now.path, policy, ctx, "pytest");
|
|
6347
6544
|
return suiteNarrowings(was.content, now.content, now.path, samples).map(
|
|
6348
|
-
(n) => makeFinding(
|
|
6545
|
+
(n) => makeFinding(RULE8, policy, {
|
|
6349
6546
|
file: now.path,
|
|
6350
6547
|
message: `The effective pytest config moved from ${was.path} to ${now.path}, and ${n.path} is no longer selected: ${n.reason}.`,
|
|
6351
6548
|
evidence: `inifile precedence ${was.path} \u2192 ${now.path} drops ${n.path} from the suite`,
|
|
@@ -6355,8 +6552,8 @@ function pytestPrecedenceFindings(changes, policy, ctx) {
|
|
|
6355
6552
|
}
|
|
6356
6553
|
|
|
6357
6554
|
// src/detectors/test-content-removal.ts
|
|
6358
|
-
import
|
|
6359
|
-
var
|
|
6555
|
+
import ts7 from "typescript";
|
|
6556
|
+
var RULE9 = "test-content-removal";
|
|
6360
6557
|
var MIN_REMOVED_LINES = 3;
|
|
6361
6558
|
var MIN_REMOVED_ROWS = 2;
|
|
6362
6559
|
var ws = (s) => s.replace(/\s+/g, "");
|
|
@@ -6365,13 +6562,13 @@ function eachTables(src, path) {
|
|
|
6365
6562
|
let open = false;
|
|
6366
6563
|
if (langOf(path) !== "js" && langOf(path) !== null) return { rows, open };
|
|
6367
6564
|
try {
|
|
6368
|
-
const sf =
|
|
6565
|
+
const sf = ts7.createSourceFile("spec.ts", src, ts7.ScriptTarget.Latest, true, ts7.ScriptKind.TS);
|
|
6369
6566
|
const visit = (node) => {
|
|
6370
|
-
if (
|
|
6567
|
+
if (ts7.isCallExpression(node) && ts7.isPropertyAccessExpression(node.expression) && /^(?:each|for)$/.test(node.expression.name.text) && ts7.isIdentifier(node.expression.expression) && /^(?:it|test|describe)$/.test(node.expression.expression.text)) {
|
|
6371
6568
|
const arg = node.arguments[0];
|
|
6372
|
-
if (arg &&
|
|
6569
|
+
if (arg && ts7.isArrayLiteralExpression(arg)) {
|
|
6373
6570
|
for (const el of arg.elements) {
|
|
6374
|
-
if (
|
|
6571
|
+
if (ts7.isSpreadElement(el)) {
|
|
6375
6572
|
open = true;
|
|
6376
6573
|
continue;
|
|
6377
6574
|
}
|
|
@@ -6381,7 +6578,7 @@ function eachTables(src, path) {
|
|
|
6381
6578
|
}
|
|
6382
6579
|
} else open = true;
|
|
6383
6580
|
}
|
|
6384
|
-
|
|
6581
|
+
ts7.forEachChild(node, visit);
|
|
6385
6582
|
};
|
|
6386
6583
|
visit(sf);
|
|
6387
6584
|
} catch {
|
|
@@ -6399,7 +6596,7 @@ function significantLinesOrdered(src, path) {
|
|
|
6399
6596
|
return out2;
|
|
6400
6597
|
}
|
|
6401
6598
|
var testContentRemoval = {
|
|
6402
|
-
id:
|
|
6599
|
+
id: RULE9,
|
|
6403
6600
|
surface: ["file"],
|
|
6404
6601
|
certainty: "mechanical",
|
|
6405
6602
|
run(changes, policy) {
|
|
@@ -6440,7 +6637,7 @@ var testContentRemoval = {
|
|
|
6440
6637
|
const goneRows = table.rows.size && tableAfter.open ? [...table.rows].filter(([k]) => !tableAfter.rows.has(k) && !addedPool.includes(k)).map(([, text]) => text) : [];
|
|
6441
6638
|
if (goneRows.length >= MIN_REMOVED_ROWS) {
|
|
6442
6639
|
out2.push(
|
|
6443
|
-
makeFinding(
|
|
6640
|
+
makeFinding(RULE9, policy, {
|
|
6444
6641
|
file: c.path,
|
|
6445
6642
|
message: `Rows removed from a test table: ${goneRows.length} case(s) gone from an each-table that spreads from elsewhere, and they reappear nowhere in the change.`,
|
|
6446
6643
|
evidence: goneRows.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
|
|
@@ -6451,7 +6648,7 @@ var testContentRemoval = {
|
|
|
6451
6648
|
}
|
|
6452
6649
|
if (gone.length >= MIN_REMOVED_LINES && netDrop >= MIN_REMOVED_LINES) {
|
|
6453
6650
|
out2.push(
|
|
6454
|
-
makeFinding(
|
|
6651
|
+
makeFinding(RULE9, policy, {
|
|
6455
6652
|
file: c.path,
|
|
6456
6653
|
message: `Test content removed from a surviving spec: ${gone.length} significant line(s) gone with no test block deleted.`,
|
|
6457
6654
|
evidence: gone.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
|
|
@@ -6465,7 +6662,7 @@ var testContentRemoval = {
|
|
|
6465
6662
|
};
|
|
6466
6663
|
|
|
6467
6664
|
// src/detectors/snapshot-rewrite.ts
|
|
6468
|
-
var
|
|
6665
|
+
var RULE10 = "snapshot-rewrite";
|
|
6469
6666
|
var RUNNER2 = /\b(?:jest|vitest|playwright|ava)\b/;
|
|
6470
6667
|
var PM_TEST_SCRIPT = /(?:^|\s)(?:npm|pnpm|yarn|bun)\s+(?:t|tst|test|run(?:-script)?\s+\S*test\S*)(?:\s|$)/;
|
|
6471
6668
|
var UPDATE_ANY = /--(?:update-?[sS]napshots?|test-update-snapshots|snapshot-update)\b/;
|
|
@@ -6482,12 +6679,12 @@ function namesProtectedSnapshot(seg, policy) {
|
|
|
6482
6679
|
return null;
|
|
6483
6680
|
}
|
|
6484
6681
|
var snapshotRewrite = {
|
|
6485
|
-
id:
|
|
6682
|
+
id: RULE10,
|
|
6486
6683
|
surface: ["command", "file"],
|
|
6487
6684
|
certainty: "mechanical",
|
|
6488
6685
|
run(changes, policy) {
|
|
6489
6686
|
const out2 = [];
|
|
6490
|
-
const warn = (input) => out2.push(makeFinding(
|
|
6687
|
+
const warn = (input) => out2.push(makeFinding(RULE10, policy, { ...input, defaultSeverity: "warn" }));
|
|
6491
6688
|
for (const c of changes) {
|
|
6492
6689
|
if (c.kind === "command") {
|
|
6493
6690
|
for (const seg of segments(c.raw)) {
|
|
@@ -6516,7 +6713,7 @@ var snapshotRewrite = {
|
|
|
6516
6713
|
if (c.op === "add" || !isSnap && !wasSnap) continue;
|
|
6517
6714
|
const what = c.op === "delete" ? "deleted" : c.op === "rename" && !isSnap ? `renamed out of the protected snapshot set (to ${c.path})` : "rewritten";
|
|
6518
6715
|
warn({
|
|
6519
|
-
file: c.op === "rename" && !isSnap ? c.oldPath : c.path,
|
|
6716
|
+
file: c.op === "rename" && !isSnap ? c.oldPath ?? c.path : c.path,
|
|
6520
6717
|
message: `A recorded expected output was ${what}.`,
|
|
6521
6718
|
evidence: c.op === "rename" ? `${c.oldPath} -> ${c.path}` : c.path,
|
|
6522
6719
|
remediation: "If the intended output genuinely changed, a human should confirm the new expectation against the requirement; if the test was failing, fix the code instead of re-recording the expectation from it."
|
|
@@ -6527,7 +6724,7 @@ var snapshotRewrite = {
|
|
|
6527
6724
|
};
|
|
6528
6725
|
|
|
6529
6726
|
// src/detectors/snapshot-only.ts
|
|
6530
|
-
var
|
|
6727
|
+
var RULE11 = "snapshot-only-rewrite";
|
|
6531
6728
|
var COMMIT_VIEWS = ["staged", "range"];
|
|
6532
6729
|
function isSnapshotChange(c, policy) {
|
|
6533
6730
|
if (c.kind !== "file") return false;
|
|
@@ -6536,7 +6733,7 @@ function isSnapshotChange(c, policy) {
|
|
|
6536
6733
|
return c.oldPath != null && isProtected(c.oldPath, policy, "snapshots");
|
|
6537
6734
|
}
|
|
6538
6735
|
var snapshotOnlyRewrite = {
|
|
6539
|
-
id:
|
|
6736
|
+
id: RULE11,
|
|
6540
6737
|
surface: ["file"],
|
|
6541
6738
|
certainty: "mechanical",
|
|
6542
6739
|
run(changes, policy, view) {
|
|
@@ -6546,9 +6743,10 @@ var snapshotOnlyRewrite = {
|
|
|
6546
6743
|
const snaps = files.filter((c) => isSnapshotChange(c, policy));
|
|
6547
6744
|
if (snaps.length === 0 || snaps.length !== files.length) return [];
|
|
6548
6745
|
const first = snaps[0];
|
|
6549
|
-
|
|
6746
|
+
if (!first) return [];
|
|
6747
|
+
const names = snaps.map((c) => c.path).slice(0, 3).join(", ");
|
|
6550
6748
|
return [
|
|
6551
|
-
makeFinding(
|
|
6749
|
+
makeFinding(RULE11, policy, {
|
|
6552
6750
|
file: first.path,
|
|
6553
6751
|
message: `Only recorded expectations changed in this diff (${snaps.length} snapshot file${snaps.length === 1 ? "" : "s"}, no code change to explain them).`,
|
|
6554
6752
|
evidence: names + (snaps.length > 3 ? ", \u2026" : ""),
|
|
@@ -6560,8 +6758,8 @@ var snapshotOnlyRewrite = {
|
|
|
6560
6758
|
};
|
|
6561
6759
|
|
|
6562
6760
|
// src/detectors/assertion-weakening.ts
|
|
6563
|
-
import
|
|
6564
|
-
var
|
|
6761
|
+
import ts8 from "typescript";
|
|
6762
|
+
var RULE12 = "assertion-weakening";
|
|
6565
6763
|
var TEST_CALLS = /* @__PURE__ */ new Set(["it", "test"]);
|
|
6566
6764
|
var SUITE_CALLS = /* @__PURE__ */ new Set(["describe", "suite", "context"]);
|
|
6567
6765
|
var EXACT_MATCHERS = /* @__PURE__ */ new Set(["toBe", "toEqual", "toStrictEqual", "toMatchObject"]);
|
|
@@ -6569,55 +6767,55 @@ var WEAK_MATCHERS = /* @__PURE__ */ new Set(["toBeTruthy", "toBeDefined"]);
|
|
|
6569
6767
|
var compact = (s) => s.replace(/\s+/g, "");
|
|
6570
6768
|
function literalTitle(node) {
|
|
6571
6769
|
if (!node) return null;
|
|
6572
|
-
if (
|
|
6770
|
+
if (ts8.isStringLiteral(node) || ts8.isNoSubstitutionTemplateLiteral(node)) return node.text;
|
|
6573
6771
|
return null;
|
|
6574
6772
|
}
|
|
6575
6773
|
function scriptKindForPath(path) {
|
|
6576
6774
|
const lower = path.toLowerCase();
|
|
6577
|
-
if (lower.endsWith(".tsx")) return
|
|
6775
|
+
if (lower.endsWith(".tsx")) return ts8.ScriptKind.TSX;
|
|
6578
6776
|
if (lower.endsWith(".ts") || lower.endsWith(".mts") || lower.endsWith(".cts")) {
|
|
6579
|
-
return
|
|
6777
|
+
return ts8.ScriptKind.TS;
|
|
6580
6778
|
}
|
|
6581
|
-
if (lower.endsWith(".jsx")) return
|
|
6582
|
-
return
|
|
6779
|
+
if (lower.endsWith(".jsx")) return ts8.ScriptKind.JSX;
|
|
6780
|
+
return ts8.ScriptKind.JS;
|
|
6583
6781
|
}
|
|
6584
6782
|
function staticFacts(node) {
|
|
6585
6783
|
if (!node) return null;
|
|
6586
|
-
if (node.kind ===
|
|
6587
|
-
if (node.kind ===
|
|
6588
|
-
if (node.kind ===
|
|
6589
|
-
if (
|
|
6784
|
+
if (node.kind === ts8.SyntaxKind.TrueKeyword) return { defined: true, truthy: true };
|
|
6785
|
+
if (node.kind === ts8.SyntaxKind.FalseKeyword) return { defined: true, truthy: false };
|
|
6786
|
+
if (node.kind === ts8.SyntaxKind.NullKeyword) return { defined: true, truthy: false };
|
|
6787
|
+
if (ts8.isIdentifier(node) && node.text === "undefined") {
|
|
6590
6788
|
return { defined: false, truthy: false };
|
|
6591
6789
|
}
|
|
6592
|
-
if (
|
|
6593
|
-
if (
|
|
6790
|
+
if (ts8.isVoidExpression(node)) return { defined: false, truthy: false };
|
|
6791
|
+
if (ts8.isStringLiteral(node) || ts8.isNoSubstitutionTemplateLiteral(node)) {
|
|
6594
6792
|
return { defined: true, truthy: node.text.length > 0 };
|
|
6595
6793
|
}
|
|
6596
|
-
if (
|
|
6794
|
+
if (ts8.isNumericLiteral(node)) {
|
|
6597
6795
|
return { defined: true, truthy: Number(node.text) !== 0 };
|
|
6598
6796
|
}
|
|
6599
|
-
if (
|
|
6797
|
+
if (ts8.isBigIntLiteral(node)) {
|
|
6600
6798
|
return { defined: true, truthy: node.text.replace(/n$/i, "") !== "0" };
|
|
6601
6799
|
}
|
|
6602
|
-
if (
|
|
6603
|
-
const value = Number(node.operand.text) * (node.operator ===
|
|
6800
|
+
if (ts8.isPrefixUnaryExpression(node) && (node.operator === ts8.SyntaxKind.MinusToken || node.operator === ts8.SyntaxKind.PlusToken) && ts8.isNumericLiteral(node.operand)) {
|
|
6801
|
+
const value = Number(node.operand.text) * (node.operator === ts8.SyntaxKind.MinusToken ? -1 : 1);
|
|
6604
6802
|
return { defined: true, truthy: value !== 0 };
|
|
6605
6803
|
}
|
|
6606
|
-
if (
|
|
6804
|
+
if (ts8.isObjectLiteralExpression(node) || ts8.isArrayLiteralExpression(node) || ts8.isRegularExpressionLiteral(node) || ts8.isArrowFunction(node) || ts8.isFunctionExpression(node) || ts8.isClassExpression(node) || ts8.isNewExpression(node)) {
|
|
6607
6805
|
return { defined: true, truthy: true };
|
|
6608
6806
|
}
|
|
6609
6807
|
return null;
|
|
6610
6808
|
}
|
|
6611
6809
|
function expectAssertion(node, sf) {
|
|
6612
|
-
if (!
|
|
6810
|
+
if (!ts8.isPropertyAccessExpression(node.expression)) return null;
|
|
6613
6811
|
const matcher = node.expression.name.text;
|
|
6614
6812
|
let receiver = node.expression.expression;
|
|
6615
6813
|
let polarity = "";
|
|
6616
|
-
if (
|
|
6814
|
+
if (ts8.isPropertyAccessExpression(receiver) && receiver.name.text === "not") {
|
|
6617
6815
|
polarity = "not";
|
|
6618
6816
|
receiver = receiver.expression;
|
|
6619
6817
|
}
|
|
6620
|
-
if (!
|
|
6818
|
+
if (!ts8.isCallExpression(receiver) || !ts8.isIdentifier(receiver.expression) || receiver.expression.text !== "expect" || receiver.arguments.length !== 1) {
|
|
6621
6819
|
return null;
|
|
6622
6820
|
}
|
|
6623
6821
|
const subject = compact(receiver.arguments[0].getText(sf));
|
|
@@ -6633,41 +6831,41 @@ function expectAssertion(node, sf) {
|
|
|
6633
6831
|
};
|
|
6634
6832
|
}
|
|
6635
6833
|
function simpleCallName(node) {
|
|
6636
|
-
return
|
|
6834
|
+
return ts8.isIdentifier(node.expression) ? node.expression.text : null;
|
|
6637
6835
|
}
|
|
6638
6836
|
function callbackFor(node) {
|
|
6639
6837
|
const cb = node.arguments[1];
|
|
6640
|
-
return cb && (
|
|
6838
|
+
return cb && (ts8.isArrowFunction(cb) || ts8.isFunctionExpression(cb)) ? cb : null;
|
|
6641
6839
|
}
|
|
6642
6840
|
function blocks(src, path) {
|
|
6643
6841
|
const out2 = /* @__PURE__ */ new Map();
|
|
6644
6842
|
if (langOf(path) !== "js") return out2;
|
|
6645
6843
|
try {
|
|
6646
|
-
const sf =
|
|
6844
|
+
const sf = ts8.createSourceFile(path, src, ts8.ScriptTarget.Latest, true, scriptKindForPath(path));
|
|
6647
6845
|
const visit = (node, suites) => {
|
|
6648
|
-
if (
|
|
6846
|
+
if (ts8.isCallExpression(node)) {
|
|
6649
6847
|
const call = simpleCallName(node);
|
|
6650
6848
|
const title = literalTitle(node.arguments[0]);
|
|
6651
6849
|
const cb = callbackFor(node);
|
|
6652
6850
|
if (call && SUITE_CALLS.has(call) && title !== null && cb) {
|
|
6653
6851
|
const nextSuites = [...suites, title];
|
|
6654
|
-
|
|
6852
|
+
ts8.forEachChild(cb, (child) => visit(child, nextSuites));
|
|
6655
6853
|
return;
|
|
6656
6854
|
}
|
|
6657
6855
|
if (call && TEST_CALLS.has(call) && title !== null && cb) {
|
|
6658
6856
|
const assertions = [];
|
|
6659
6857
|
const collect2 = (child) => {
|
|
6660
|
-
if (child !== cb &&
|
|
6858
|
+
if (child !== cb && ts8.isCallExpression(child)) {
|
|
6661
6859
|
const nestedCall = simpleCallName(child);
|
|
6662
6860
|
if (nestedCall && (TEST_CALLS.has(nestedCall) || SUITE_CALLS.has(nestedCall))) {
|
|
6663
6861
|
return;
|
|
6664
6862
|
}
|
|
6665
6863
|
}
|
|
6666
|
-
if (
|
|
6864
|
+
if (ts8.isCallExpression(child)) {
|
|
6667
6865
|
const a = expectAssertion(child, sf);
|
|
6668
6866
|
if (a) assertions.push(a);
|
|
6669
6867
|
}
|
|
6670
|
-
|
|
6868
|
+
ts8.forEachChild(child, collect2);
|
|
6671
6869
|
};
|
|
6672
6870
|
collect2(cb);
|
|
6673
6871
|
const identity2 = [...suites, title].join(" > ");
|
|
@@ -6677,7 +6875,7 @@ function blocks(src, path) {
|
|
|
6677
6875
|
return;
|
|
6678
6876
|
}
|
|
6679
6877
|
}
|
|
6680
|
-
|
|
6878
|
+
ts8.forEachChild(node, (child) => visit(child, suites));
|
|
6681
6879
|
};
|
|
6682
6880
|
visit(sf, []);
|
|
6683
6881
|
} catch {
|
|
@@ -6726,7 +6924,7 @@ function findingForBlock(path, before, after, policy) {
|
|
|
6726
6924
|
const weakened = directionalWeakening(before.assertions, after.assertions);
|
|
6727
6925
|
if (weakened) {
|
|
6728
6926
|
const message = weakened.kind === "throw-specificity" ? `Assertion weakened in test "${before.identity}": exception-message specificity was removed from toThrow().` : `Assertion weakened in test "${before.identity}": ${weakened.before.matcher} was replaced by weaker ${weakened.after.matcher} on the same subject.`;
|
|
6729
|
-
return makeFinding(
|
|
6927
|
+
return makeFinding(RULE12, policy, {
|
|
6730
6928
|
file: path,
|
|
6731
6929
|
message,
|
|
6732
6930
|
evidence: `${weakened.before.text} -> ${weakened.after.text}`,
|
|
@@ -6735,7 +6933,7 @@ function findingForBlock(path, before, after, policy) {
|
|
|
6735
6933
|
}
|
|
6736
6934
|
const removed = pureRemoval(before.assertions, after.assertions);
|
|
6737
6935
|
if (removed) {
|
|
6738
|
-
return makeFinding(
|
|
6936
|
+
return makeFinding(RULE12, policy, {
|
|
6739
6937
|
file: path,
|
|
6740
6938
|
message: `Assertion removed from kept test "${before.identity}" without a replacement assertion.`,
|
|
6741
6939
|
evidence: removed.text,
|
|
@@ -6745,7 +6943,7 @@ function findingForBlock(path, before, after, policy) {
|
|
|
6745
6943
|
return null;
|
|
6746
6944
|
}
|
|
6747
6945
|
var assertionWeakening = {
|
|
6748
|
-
id:
|
|
6946
|
+
id: RULE12,
|
|
6749
6947
|
surface: ["file"],
|
|
6750
6948
|
certainty: "heuristic",
|
|
6751
6949
|
run(changes, policy) {
|
|
@@ -6771,6 +6969,7 @@ var assertionWeakening = {
|
|
|
6771
6969
|
var allDetectors = [
|
|
6772
6970
|
noVerify,
|
|
6773
6971
|
tsAnyCast,
|
|
6972
|
+
tsCastGrowth,
|
|
6774
6973
|
lintSuppression,
|
|
6775
6974
|
testSkip,
|
|
6776
6975
|
coverageLowering,
|
|
@@ -6812,14 +7011,15 @@ function evaluate(changes, policy, detectors = allDetectors, view, ctx) {
|
|
|
6812
7011
|
process.stderr.write(`tamperward: detector "${d.id}" errored: ${String(e)}
|
|
6813
7012
|
`);
|
|
6814
7013
|
if (view === "staged" || view === "worktree" || view === "range") {
|
|
6815
|
-
|
|
7014
|
+
const failed = {
|
|
6816
7015
|
rule: "detector-error",
|
|
6817
7016
|
severity: "block",
|
|
6818
7017
|
message: `Detector "${d.id}" failed to run; the verdict is incomplete.`,
|
|
6819
7018
|
evidence: String(e).slice(0, 200),
|
|
6820
7019
|
remediation: "A rule that cannot run is not a rule that passed. Fix the input or the detector; do not read this as clean.",
|
|
6821
7020
|
signoff: { required: true, command: `tamperward allow detector-error --reason "..."` }
|
|
6822
|
-
}
|
|
7021
|
+
};
|
|
7022
|
+
out2.push(failed);
|
|
6823
7023
|
}
|
|
6824
7024
|
}
|
|
6825
7025
|
}
|
|
@@ -6860,7 +7060,7 @@ function normalizeVersion(v, where = POLICY_FILE) {
|
|
|
6860
7060
|
return v;
|
|
6861
7061
|
}
|
|
6862
7062
|
var SEVERITIES = ["block", "warn"];
|
|
6863
|
-
var isSeverity = (v) => SEVERITIES.
|
|
7063
|
+
var isSeverity = (v) => SEVERITIES.some((s) => s === v);
|
|
6864
7064
|
var isStringList = (v) => Array.isArray(v) && v.every((s) => typeof s === "string");
|
|
6865
7065
|
var isMapping = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
6866
7066
|
var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "protected", "rules", "ignore", "signoff", "verify"]);
|
|
@@ -6874,75 +7074,105 @@ function validate(r, where) {
|
|
|
6874
7074
|
throw new PolicyError(`${where}: ${msg}`);
|
|
6875
7075
|
}
|
|
6876
7076
|
const show = (v) => JSON.stringify(v) ?? String(v);
|
|
7077
|
+
const out2 = {};
|
|
7078
|
+
if (r.version !== void 0) out2.version = r.version;
|
|
6877
7079
|
const unknown = Object.keys(r).filter((k) => !TOP_LEVEL_KEYS.has(k));
|
|
6878
7080
|
if (unknown.length) {
|
|
6879
7081
|
bad(`unknown top-level key${unknown.length === 1 ? "" : "s"} ${unknown.map((k) => JSON.stringify(k)).join(", ")} (expected one of ${[...TOP_LEVEL_KEYS].join(", ")})`);
|
|
6880
7082
|
}
|
|
6881
7083
|
if (r.rules !== void 0) {
|
|
6882
7084
|
if (!isMapping(r.rules)) bad(`rules must be a mapping of rule name to { severity, enabled, exclude }, got ${show(r.rules)}`);
|
|
7085
|
+
const rules = {};
|
|
6883
7086
|
for (const [name, cfg] of Object.entries(r.rules)) {
|
|
6884
7087
|
if (!isMapping(cfg)) bad(`rules.${name} must be a mapping like { severity: block }, got ${show(cfg)}`);
|
|
6885
|
-
|
|
6886
|
-
|
|
7088
|
+
const { severity, enabled, exclude } = cfg;
|
|
7089
|
+
if (severity !== void 0 && !isSeverity(severity)) {
|
|
7090
|
+
bad(`rules.${name}.severity must be "block" or "warn", got ${show(severity)}`);
|
|
6887
7091
|
}
|
|
6888
|
-
if (
|
|
6889
|
-
bad(`rules.${name}.enabled must be true or false, got ${show(
|
|
7092
|
+
if (enabled !== void 0 && typeof enabled !== "boolean") {
|
|
7093
|
+
bad(`rules.${name}.enabled must be true or false, got ${show(enabled)}`);
|
|
6890
7094
|
}
|
|
6891
|
-
if (
|
|
6892
|
-
bad(`rules.${name}.exclude must be a list of globs, got ${show(
|
|
7095
|
+
if (exclude !== void 0 && !isStringList(exclude)) {
|
|
7096
|
+
bad(`rules.${name}.exclude must be a list of globs, got ${show(exclude)}`);
|
|
6893
7097
|
}
|
|
7098
|
+
rules[name] = {
|
|
7099
|
+
...severity !== void 0 ? { severity } : {},
|
|
7100
|
+
...enabled !== void 0 ? { enabled } : {},
|
|
7101
|
+
...exclude !== void 0 ? { exclude } : {}
|
|
7102
|
+
};
|
|
6894
7103
|
}
|
|
7104
|
+
out2.rules = rules;
|
|
7105
|
+
}
|
|
7106
|
+
if (r.ignore !== void 0) {
|
|
7107
|
+
if (!isStringList(r.ignore)) bad(`ignore must be a list of globs, got ${show(r.ignore)}`);
|
|
7108
|
+
out2.ignore = r.ignore;
|
|
6895
7109
|
}
|
|
6896
|
-
if (r.ignore !== void 0 && !isStringList(r.ignore)) bad(`ignore must be a list of globs, got ${show(r.ignore)}`);
|
|
6897
7110
|
if (r.protected !== void 0) {
|
|
6898
7111
|
if (!isMapping(r.protected)) bad(`protected must be a mapping of category to a list of globs, got ${show(r.protected)}`);
|
|
7112
|
+
const categories = {};
|
|
6899
7113
|
for (const [cat, globs4] of Object.entries(r.protected)) {
|
|
6900
7114
|
if (!isStringList(globs4)) bad(`protected.${cat} must be a list of globs, got ${show(globs4)}`);
|
|
7115
|
+
categories[cat] = globs4;
|
|
6901
7116
|
}
|
|
7117
|
+
out2.protected = categories;
|
|
6902
7118
|
}
|
|
6903
7119
|
if (r.signoff !== void 0) {
|
|
6904
7120
|
if (!isMapping(r.signoff)) bad(`signoff must be a mapping, got ${show(r.signoff)}`);
|
|
7121
|
+
const signoff = {};
|
|
6905
7122
|
for (const key2 of ["required_for", "requiredFor"]) {
|
|
6906
7123
|
const v = r.signoff[key2];
|
|
6907
|
-
if (v
|
|
7124
|
+
if (v === void 0) continue;
|
|
7125
|
+
if (!(Array.isArray(v) && v.every(isSeverity))) {
|
|
6908
7126
|
bad(`signoff.${key2} must be a list of "block" / "warn", got ${show(v)}`);
|
|
6909
7127
|
}
|
|
7128
|
+
signoff[key2] = v;
|
|
6910
7129
|
}
|
|
6911
7130
|
const ledger = r.signoff.ledger;
|
|
6912
7131
|
if (ledger !== void 0 && typeof ledger !== "string") bad(`signoff.ledger must be a path, got ${show(ledger)}`);
|
|
6913
|
-
if (typeof ledger === "string"
|
|
6914
|
-
bad(`signoff.ledger must be a relative path inside the repository, got ${show(ledger)}`);
|
|
7132
|
+
if (typeof ledger === "string") {
|
|
7133
|
+
if (!ledgerInsideRepo(ledger)) bad(`signoff.ledger must be a relative path inside the repository, got ${show(ledger)}`);
|
|
7134
|
+
signoff.ledger = ledger;
|
|
6915
7135
|
}
|
|
7136
|
+
out2.signoff = signoff;
|
|
6916
7137
|
}
|
|
6917
7138
|
if (r.verify !== void 0) {
|
|
6918
7139
|
if (!isMapping(r.verify)) bad(`verify must be a mapping like { command: "npm test" }, got ${show(r.verify)}`);
|
|
6919
7140
|
const v = r.verify;
|
|
6920
|
-
|
|
6921
|
-
if (
|
|
6922
|
-
|
|
7141
|
+
const { command, budget, inputs, backend, image } = v;
|
|
7142
|
+
if (command !== void 0 && typeof command !== "string") bad(`verify.command must be a string, got ${show(command)}`);
|
|
7143
|
+
if (budget !== void 0 && !(typeof budget === "number" && Number.isFinite(budget) && budget > 0)) {
|
|
7144
|
+
bad(`verify.budget must be a positive number of seconds, got ${show(budget)}`);
|
|
6923
7145
|
}
|
|
6924
|
-
if (
|
|
6925
|
-
if (
|
|
6926
|
-
bad(`verify.backend must be "local" or "container", got ${show(
|
|
7146
|
+
if (inputs !== void 0 && !isStringList(inputs)) bad(`verify.inputs must be a list of globs, got ${show(inputs)}`);
|
|
7147
|
+
if (backend !== void 0 && backend !== "local" && backend !== "container") {
|
|
7148
|
+
bad(`verify.backend must be "local" or "container", got ${show(backend)}`);
|
|
6927
7149
|
}
|
|
6928
|
-
if (
|
|
6929
|
-
bad(`verify.image must be an immutable container image reference, got ${show(
|
|
7150
|
+
if (image !== void 0 && typeof image !== "string") {
|
|
7151
|
+
bad(`verify.image must be an immutable container image reference, got ${show(image)}`);
|
|
6930
7152
|
}
|
|
6931
|
-
|
|
6932
|
-
|
|
6933
|
-
if (
|
|
6934
|
-
|
|
6935
|
-
bad(`verify.image must be pinned by sha256 digest (name@sha256:<64 hex>), got ${show(v.image)}`);
|
|
7153
|
+
if ((backend ?? "local") === "container") {
|
|
7154
|
+
if (typeof image !== "string") bad('verify.image is required when verify.backend is "container"');
|
|
7155
|
+
if (!/^(?!-)[^\s@]+@sha256:[0-9a-f]{64}$/i.test(image)) {
|
|
7156
|
+
bad(`verify.image must be pinned by sha256 digest (name@sha256:<64 hex>), got ${show(image)}`);
|
|
6936
7157
|
}
|
|
6937
|
-
} else if (
|
|
7158
|
+
} else if (image !== void 0) {
|
|
6938
7159
|
bad('verify.image is only valid when verify.backend is "container"');
|
|
6939
7160
|
}
|
|
7161
|
+
out2.verify = {
|
|
7162
|
+
...command !== void 0 ? { command } : {},
|
|
7163
|
+
...budget !== void 0 ? { budget } : {},
|
|
7164
|
+
...inputs !== void 0 ? { inputs } : {},
|
|
7165
|
+
...backend !== void 0 ? { backend } : {},
|
|
7166
|
+
...image !== void 0 ? { image } : {}
|
|
7167
|
+
};
|
|
6940
7168
|
}
|
|
7169
|
+
return out2;
|
|
6941
7170
|
}
|
|
6942
7171
|
function parsePolicy(raw, where = POLICY_FILE) {
|
|
6943
|
-
const
|
|
6944
|
-
|
|
6945
|
-
|
|
7172
|
+
const doc = raw ?? {};
|
|
7173
|
+
if (!isMapping(doc)) throw new PolicyError(`${where} is not a policy mapping`);
|
|
7174
|
+
const version = normalizeVersion(doc.version, where);
|
|
7175
|
+
const r = validate(doc, where);
|
|
6946
7176
|
const base = defaultPolicy(version);
|
|
6947
7177
|
const globs4 = (list2) => list2?.map(normalizeGlob);
|
|
6948
7178
|
const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list2]) => [cat, globs4(list2) ?? []])) : void 0;
|
|
@@ -6981,10 +7211,7 @@ function parseOrThrow(src, where) {
|
|
|
6981
7211
|
try {
|
|
6982
7212
|
raw = parse2(src);
|
|
6983
7213
|
} catch (e) {
|
|
6984
|
-
throw new PolicyError(`${where} is not valid YAML: ${e
|
|
6985
|
-
}
|
|
6986
|
-
if (raw !== null && raw !== void 0 && (typeof raw !== "object" || Array.isArray(raw))) {
|
|
6987
|
-
throw new PolicyError(`${where} is not a policy mapping`);
|
|
7214
|
+
throw new PolicyError(`${where} is not valid YAML: ${errorMessage(e)}`);
|
|
6988
7215
|
}
|
|
6989
7216
|
return parsePolicy(raw, where);
|
|
6990
7217
|
}
|
|
@@ -7004,6 +7231,14 @@ import { createHash as createHash2 } from "node:crypto";
|
|
|
7004
7231
|
import { appendFileSync, mkdirSync, readFileSync as readFileSync6, existsSync as existsSync5 } from "node:fs";
|
|
7005
7232
|
import { dirname as dirname2, join as join8 } from "node:path";
|
|
7006
7233
|
var DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
7234
|
+
function ledgerEntryFrom(value) {
|
|
7235
|
+
if (!isRecord(value)) return null;
|
|
7236
|
+
const { rule, file, fingerprint: fingerprint2, reason, recordedAt, expiresAt } = value;
|
|
7237
|
+
if (typeof rule !== "string" || typeof fingerprint2 !== "string" || typeof reason !== "string") return null;
|
|
7238
|
+
if (typeof recordedAt !== "number" || typeof expiresAt !== "number") return null;
|
|
7239
|
+
if (file !== void 0 && typeof file !== "string") return null;
|
|
7240
|
+
return { rule, ...file !== void 0 ? { file } : {}, fingerprint: fingerprint2, reason, recordedAt, expiresAt };
|
|
7241
|
+
}
|
|
7007
7242
|
function fingerprint(rule, file, evidence) {
|
|
7008
7243
|
return createHash2("sha256").update(`${rule}\0${file ?? ""}\0${evidence}`).digest("hex").slice(0, 16);
|
|
7009
7244
|
}
|
|
@@ -7022,8 +7257,8 @@ function readLedger(cwd, policy) {
|
|
|
7022
7257
|
for (const line of readFileSync6(p, "utf8").split("\n")) {
|
|
7023
7258
|
if (!line.trim()) continue;
|
|
7024
7259
|
try {
|
|
7025
|
-
const e = JSON.parse(line);
|
|
7026
|
-
if (e
|
|
7260
|
+
const e = ledgerEntryFrom(JSON.parse(line));
|
|
7261
|
+
if (e) out2.push(e);
|
|
7027
7262
|
} catch {
|
|
7028
7263
|
}
|
|
7029
7264
|
}
|
|
@@ -7059,9 +7294,10 @@ function oobToken(want, oob, head) {
|
|
|
7059
7294
|
}
|
|
7060
7295
|
const [rule, sha2] = [t.slice(0, at), t.slice(at + 1)];
|
|
7061
7296
|
if (rule !== want) continue;
|
|
7062
|
-
|
|
7297
|
+
if (!head) return t;
|
|
7298
|
+
const normalizedHead = head.trim().toLowerCase();
|
|
7063
7299
|
const normalizedSha = sha2.trim().toLowerCase();
|
|
7064
|
-
if (
|
|
7300
|
+
if (/^[0-9a-f]+$/.test(normalizedHead) && normalizedSha === normalizedHead) return t;
|
|
7065
7301
|
}
|
|
7066
7302
|
return null;
|
|
7067
7303
|
}
|
|
@@ -7293,7 +7529,7 @@ function isGitHubActions(env = process.env) {
|
|
|
7293
7529
|
// src/cli/report.ts
|
|
7294
7530
|
var FORMATS = ["auto", "text", "json", "github"];
|
|
7295
7531
|
function isFormat(s) {
|
|
7296
|
-
return FORMATS.
|
|
7532
|
+
return FORMATS.some((f) => f === s);
|
|
7297
7533
|
}
|
|
7298
7534
|
function resolveFormat(input, env = process.env) {
|
|
7299
7535
|
if (input.json) return "json";
|
|
@@ -7471,6 +7707,17 @@ import { createHash as createHash3 } from "node:crypto";
|
|
|
7471
7707
|
import { existsSync as existsSync7, lstatSync as lstatSync2, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync8, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
|
|
7472
7708
|
import { isAbsolute as isAbsolute7, join as join10 } from "node:path";
|
|
7473
7709
|
import { execFileSync as execFileSync5 } from "node:child_process";
|
|
7710
|
+
function ptreeFrom(value) {
|
|
7711
|
+
if (!isRecord(value)) return null;
|
|
7712
|
+
const out2 = {};
|
|
7713
|
+
for (const [path, entry] of Object.entries(value)) {
|
|
7714
|
+
if (!isRecord(entry)) return null;
|
|
7715
|
+
const { hash, mode, size, mtimeMs } = entry;
|
|
7716
|
+
if (typeof hash !== "string" || typeof mode !== "number" || typeof size !== "number" || typeof mtimeMs !== "number") return null;
|
|
7717
|
+
out2[path] = { hash, mode, size, mtimeMs };
|
|
7718
|
+
}
|
|
7719
|
+
return out2;
|
|
7720
|
+
}
|
|
7474
7721
|
var UNSAFE2 = /[^A-Za-z0-9._-]/g;
|
|
7475
7722
|
var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
|
|
7476
7723
|
function gitDir2(cwd) {
|
|
@@ -7562,7 +7809,7 @@ function driftBetween(expected, current) {
|
|
|
7562
7809
|
function loadTree(p) {
|
|
7563
7810
|
if (!p || !existsSync7(p)) return null;
|
|
7564
7811
|
try {
|
|
7565
|
-
return JSON.parse(readFileSync8(p, "utf8"));
|
|
7812
|
+
return ptreeFrom(JSON.parse(readFileSync8(p, "utf8")));
|
|
7566
7813
|
} catch {
|
|
7567
7814
|
return null;
|
|
7568
7815
|
}
|
|
@@ -7622,8 +7869,25 @@ function watcherHealthPath(log) {
|
|
|
7622
7869
|
function readWatcherHealth(log) {
|
|
7623
7870
|
try {
|
|
7624
7871
|
const value = JSON.parse(readFileSync9(watcherHealthPath(log), "utf8"));
|
|
7625
|
-
if (
|
|
7626
|
-
|
|
7872
|
+
if (!isRecord(value)) return null;
|
|
7873
|
+
const { state, backend, pid, started_at } = value;
|
|
7874
|
+
if (value.version !== 1 || typeof pid !== "number" || typeof started_at !== "string") return null;
|
|
7875
|
+
if (state !== "healthy" && state !== "degraded" && state !== "stopped") return null;
|
|
7876
|
+
return {
|
|
7877
|
+
version: 1,
|
|
7878
|
+
state,
|
|
7879
|
+
backend: backend === "initializing" || backend === "recursive" ? backend : "fallback",
|
|
7880
|
+
pid,
|
|
7881
|
+
started_at,
|
|
7882
|
+
stopped_at: nullableString(value.stopped_at) ?? null,
|
|
7883
|
+
watched_dirs: finiteNumber(value.watched_dirs) ?? 0,
|
|
7884
|
+
last_append_at: nullableString(value.last_append_at) ?? null,
|
|
7885
|
+
event_count: finiteNumber(value.event_count) ?? 0,
|
|
7886
|
+
dropped_events: finiteNumber(value.dropped_events) ?? 0,
|
|
7887
|
+
error_count: finiteNumber(value.error_count) ?? 0,
|
|
7888
|
+
last_error: nullableString(value.last_error) ?? null,
|
|
7889
|
+
log: stringOrUndefined(value.log) ?? log
|
|
7890
|
+
};
|
|
7627
7891
|
} catch {
|
|
7628
7892
|
return null;
|
|
7629
7893
|
}
|
|
@@ -7634,7 +7898,7 @@ function pidAlive(pid) {
|
|
|
7634
7898
|
process.kill(pid, 0);
|
|
7635
7899
|
return true;
|
|
7636
7900
|
} catch (e) {
|
|
7637
|
-
return e
|
|
7901
|
+
return errnoCode(e) === "EPERM";
|
|
7638
7902
|
}
|
|
7639
7903
|
}
|
|
7640
7904
|
function watcherTelemetry(log) {
|
|
@@ -7838,7 +8102,23 @@ function runWatch(args) {
|
|
|
7838
8102
|
|
|
7839
8103
|
// src/detectors/fs-events.ts
|
|
7840
8104
|
import { closeSync as closeSync2, openSync as openSync2, readSync as readSync2, statSync } from "node:fs";
|
|
7841
|
-
|
|
8105
|
+
function fsEventFrom(line) {
|
|
8106
|
+
let value;
|
|
8107
|
+
try {
|
|
8108
|
+
value = JSON.parse(line);
|
|
8109
|
+
} catch {
|
|
8110
|
+
return null;
|
|
8111
|
+
}
|
|
8112
|
+
if (!isRecord(value)) return null;
|
|
8113
|
+
const { ts: ts9, path, kind } = value;
|
|
8114
|
+
if (typeof ts9 !== "string" || typeof path !== "string" || kind !== "change" && kind !== "rename") return null;
|
|
8115
|
+
const mode = nullableNumber(value.mode);
|
|
8116
|
+
const size = nullableNumber(value.size);
|
|
8117
|
+
const hash = nullableString(value.hash);
|
|
8118
|
+
if (mode === void 0 || size === void 0 || hash === void 0) return null;
|
|
8119
|
+
return { ts: ts9, path, kind, mode, size, hash };
|
|
8120
|
+
}
|
|
8121
|
+
var RULE13 = "transient-protected-mutation";
|
|
7842
8122
|
var MAX_EVENT_READ_BYTES = 4 * 1024 * 1024;
|
|
7843
8123
|
var MAX_EVENT_SWEEP_BYTES = 16 * 1024 * 1024;
|
|
7844
8124
|
var DEFAULT_EVENT_READ_IO = {
|
|
@@ -7909,11 +8189,9 @@ function readEvents(log, offset, maxBytes = MAX_EVENT_READ_BYTES, io = DEFAULT_E
|
|
|
7909
8189
|
const complete = chunk.subarray(0, lastNewline + 1).toString("utf8");
|
|
7910
8190
|
for (const line of complete.split("\n")) {
|
|
7911
8191
|
if (!line.trim()) continue;
|
|
7912
|
-
|
|
7913
|
-
|
|
7914
|
-
|
|
7915
|
-
malformedLines++;
|
|
7916
|
-
}
|
|
8192
|
+
const event = fsEventFrom(line);
|
|
8193
|
+
if (event) events.push(event);
|
|
8194
|
+
else malformedLines++;
|
|
7917
8195
|
}
|
|
7918
8196
|
const newOffset = start + lastNewline + 1;
|
|
7919
8197
|
return {
|
|
@@ -8033,7 +8311,7 @@ function drainEvents(log, offset, maxTotalBytes = MAX_EVENT_SWEEP_BYTES, io = DE
|
|
|
8033
8311
|
};
|
|
8034
8312
|
}
|
|
8035
8313
|
function transientFindings(events, persistentPaths, policy, finalHash) {
|
|
8036
|
-
if (!isEnabled(
|
|
8314
|
+
if (!isEnabled(RULE13, policy)) return [];
|
|
8037
8315
|
const byPath = /* @__PURE__ */ new Map();
|
|
8038
8316
|
for (const e of events) {
|
|
8039
8317
|
if (!isProtected(e.path, policy)) continue;
|
|
@@ -8053,7 +8331,7 @@ function transientFindings(events, persistentPaths, policy, finalHash) {
|
|
|
8053
8331
|
const modeChurn = modes.size > 1;
|
|
8054
8332
|
if (!contentChurn && !modeChurn) continue;
|
|
8055
8333
|
const what = contentChurn ? `content was modified and restored within the turn (${hashes.size} distinct states observed)` : `file mode changed during the turn (${[...modes].map((m) => (m & 4095).toString(8)).join(" \u2192 ")})`;
|
|
8056
|
-
const f = makeFinding(
|
|
8334
|
+
const f = makeFinding(RULE13, policy, {
|
|
8057
8335
|
file: path,
|
|
8058
8336
|
message: `Transient protected mutation: ${what}, leaving no net diff.`,
|
|
8059
8337
|
evidence: evs.slice(0, 4).map((e) => `${e.ts} ${e.hash ?? "gone"}${e.mode != null ? `/${(e.mode & 4095).toString(8)}` : ""}`).join(" | "),
|
|
@@ -8087,10 +8365,16 @@ function parseInput(raw) {
|
|
|
8087
8365
|
} catch (e) {
|
|
8088
8366
|
throw new HookInputError(`the hook payload is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
8089
8367
|
}
|
|
8090
|
-
if (
|
|
8368
|
+
if (!isRecord(parsed)) {
|
|
8091
8369
|
throw new HookInputError(`the hook payload is not a JSON object (got ${Array.isArray(parsed) ? "an array" : typeof parsed})`);
|
|
8092
8370
|
}
|
|
8093
|
-
return
|
|
8371
|
+
return {
|
|
8372
|
+
...typeof parsed.tool_name === "string" ? { tool_name: parsed.tool_name } : {},
|
|
8373
|
+
...isRecord(parsed.tool_input) ? { tool_input: parsed.tool_input } : {},
|
|
8374
|
+
...typeof parsed.cwd === "string" ? { cwd: parsed.cwd } : {},
|
|
8375
|
+
...typeof parsed.stop_hook_active === "boolean" ? { stop_hook_active: parsed.stop_hook_active } : {},
|
|
8376
|
+
...typeof parsed.session_id === "string" ? { session_id: parsed.session_id } : {}
|
|
8377
|
+
};
|
|
8094
8378
|
}
|
|
8095
8379
|
function recordDenylog(blocks2) {
|
|
8096
8380
|
const log = process.env.TAMPERWARD_DENYLOG;
|
|
@@ -8733,6 +9017,9 @@ function missingTools(matcher) {
|
|
|
8733
9017
|
if (have === null) return [];
|
|
8734
9018
|
return PRE_MATCHER.split("|").filter((t) => !have.has(t));
|
|
8735
9019
|
}
|
|
9020
|
+
function isHooksShape(hooks) {
|
|
9021
|
+
return hooksShapeError(hooks) === null;
|
|
9022
|
+
}
|
|
8736
9023
|
function hooksShapeError(hooks) {
|
|
8737
9024
|
if (hooks === void 0 || hooks === null) return null;
|
|
8738
9025
|
if (!isPlainObject(hooks)) return "hooks is not an object";
|
|
@@ -8755,7 +9042,7 @@ function verifierCandidates(cwd) {
|
|
|
8755
9042
|
};
|
|
8756
9043
|
try {
|
|
8757
9044
|
const pkg = JSON.parse(readFileSync11(join12(cwd, "package.json"), "utf8"));
|
|
8758
|
-
const test = pkg.scripts
|
|
9045
|
+
const test = isRecord(pkg) && isRecord(pkg.scripts) ? pkg.scripts.test : void 0;
|
|
8759
9046
|
if (typeof test === "string" && test.trim() !== "" && !/error:\s*no test specified/i.test(test)) add("npm test");
|
|
8760
9047
|
} catch {
|
|
8761
9048
|
}
|
|
@@ -8854,14 +9141,14 @@ function planClaudeHooks(cwd) {
|
|
|
8854
9141
|
detail: "exists but is not valid JSON \u2014 fix it, then re-run init (refusing to overwrite)"
|
|
8855
9142
|
};
|
|
8856
9143
|
}
|
|
8857
|
-
if (
|
|
9144
|
+
if (!isRecord(parsed)) {
|
|
8858
9145
|
return { item: "agent", path: rel, status: "error", detail: "exists but is not a JSON object \u2014 refusing to overwrite" };
|
|
8859
9146
|
}
|
|
8860
|
-
|
|
8861
|
-
|
|
8862
|
-
|
|
8863
|
-
|
|
8864
|
-
|
|
9147
|
+
const shapeError = hooksShapeError(parsed.hooks);
|
|
9148
|
+
if (shapeError || !isHooksShape(parsed.hooks)) {
|
|
9149
|
+
return { item: "agent", path: rel, status: "error", detail: `${shapeError ?? "hooks is not the shape Claude Code reads"} \u2014 fix it, then re-run init (refusing to overwrite)` };
|
|
9150
|
+
}
|
|
9151
|
+
settings = { ...parsed, hooks: parsed.hooks ?? void 0 };
|
|
8865
9152
|
}
|
|
8866
9153
|
const hooks = settings.hooks ??= {};
|
|
8867
9154
|
const entriesWith = (arr, needle) => (arr ?? []).filter((m) => (m.hooks ?? []).some((h) => needle.test(String(h.command ?? ""))));
|
|
@@ -9085,7 +9372,7 @@ function planCodeowners(cwd) {
|
|
|
9085
9372
|
const path = join12(cwd, existingRel ?? rel);
|
|
9086
9373
|
const existing = existingRel ? readFileSync11(path, "utf8") : null;
|
|
9087
9374
|
const missing = CODEOWNERS_PATHS.filter((p) => existing === null || !coveredBy(existing, p));
|
|
9088
|
-
if (missing.length === 0) {
|
|
9375
|
+
if (existingRel !== void 0 && missing.length === 0) {
|
|
9089
9376
|
return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
|
|
9090
9377
|
}
|
|
9091
9378
|
const inferred = inferOwner(cwd);
|
|
@@ -9711,14 +9998,15 @@ function decodeUtf8Tail(tail) {
|
|
|
9711
9998
|
while (start < tail.length && (tail[start] & 192) === 128) start++;
|
|
9712
9999
|
return tail.subarray(start).toString("utf8");
|
|
9713
10000
|
}
|
|
9714
|
-
function streamFromRaw(
|
|
10001
|
+
function streamFromRaw(value) {
|
|
10002
|
+
const raw = isRecord(value) ? value : {};
|
|
9715
10003
|
let tail = Buffer.alloc(0);
|
|
9716
10004
|
try {
|
|
9717
|
-
tail = Buffer.from(raw
|
|
10005
|
+
tail = Buffer.from(typeof raw.tail_b64 === "string" ? raw.tail_b64 : "", "base64");
|
|
9718
10006
|
} catch {
|
|
9719
10007
|
tail = Buffer.alloc(0);
|
|
9720
10008
|
}
|
|
9721
|
-
const captured = Number.isFinite(raw
|
|
10009
|
+
const captured = typeof raw.captured_bytes === "number" && Number.isFinite(raw.captured_bytes) ? Math.max(0, raw.captured_bytes) : tail.length;
|
|
9722
10010
|
return {
|
|
9723
10011
|
captured_bytes: captured,
|
|
9724
10012
|
retained_bytes: tail.length,
|
|
@@ -9729,7 +10017,7 @@ function streamFromRaw(raw) {
|
|
|
9729
10017
|
function scrubDiagnosticText(input) {
|
|
9730
10018
|
let out2 = "";
|
|
9731
10019
|
for (const ch of input) {
|
|
9732
|
-
const cp = ch.codePointAt(0);
|
|
10020
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
9733
10021
|
if (ch === "\n" || ch === " ") {
|
|
9734
10022
|
out2 += ch;
|
|
9735
10023
|
} else if (cp === 13) {
|
|
@@ -9932,7 +10220,7 @@ function parseCapturedSupervisorResult(stdoutText) {
|
|
|
9932
10220
|
} catch {
|
|
9933
10221
|
return null;
|
|
9934
10222
|
}
|
|
9935
|
-
if (!
|
|
10223
|
+
if (!isRecord(raw)) return null;
|
|
9936
10224
|
const diagnostics = {
|
|
9937
10225
|
stdout: streamFromRaw(raw.stdout),
|
|
9938
10226
|
stderr: streamFromRaw(raw.stderr)
|
|
@@ -9983,7 +10271,7 @@ function runCapturedProcessSync(executable, args, opts) {
|
|
|
9983
10271
|
const parsed = parseCapturedSupervisorResult(supervisor.stdout ?? "");
|
|
9984
10272
|
const diagnostics = parsed?.diagnostics ?? emptySuiteDiagnostics();
|
|
9985
10273
|
if (!parsed) {
|
|
9986
|
-
const supervisorTimedOut = Boolean(supervisor.error) && supervisor.error
|
|
10274
|
+
const supervisorTimedOut = Boolean(supervisor.error) && errnoCode(supervisor.error) === "ETIMEDOUT";
|
|
9987
10275
|
return {
|
|
9988
10276
|
exit: null,
|
|
9989
10277
|
signal: supervisor.signal ? String(supervisor.signal) : null,
|
|
@@ -10078,16 +10366,16 @@ function dockerAvailable(enginePath, host) {
|
|
|
10078
10366
|
}
|
|
10079
10367
|
}
|
|
10080
10368
|
function declaredImageVolumePaths(metadata) {
|
|
10081
|
-
if (!
|
|
10369
|
+
if (!isRecord(metadata)) {
|
|
10082
10370
|
throw new Error("image metadata must be a Docker image-inspect object");
|
|
10083
10371
|
}
|
|
10084
10372
|
const config = metadata.Config;
|
|
10085
|
-
if (!
|
|
10373
|
+
if (!isRecord(config)) {
|
|
10086
10374
|
throw new Error("image metadata Config must be an object");
|
|
10087
10375
|
}
|
|
10088
10376
|
const volumes = config.Volumes;
|
|
10089
10377
|
if (volumes === void 0 || volumes === null) return [];
|
|
10090
|
-
if (
|
|
10378
|
+
if (!isRecord(volumes)) {
|
|
10091
10379
|
throw new Error("image metadata Config.Volumes must be a mapping or null");
|
|
10092
10380
|
}
|
|
10093
10381
|
return Object.keys(volumes).sort();
|
|
@@ -10591,8 +10879,7 @@ function validateSymlinkGraph(target, out2, root, dependencyRoot, label) {
|
|
|
10591
10879
|
const pending = linkParts(target);
|
|
10592
10880
|
const seen = /* @__PURE__ */ new Set();
|
|
10593
10881
|
let hops = 0;
|
|
10594
|
-
|
|
10595
|
-
const part = pending.shift();
|
|
10882
|
+
for (let part = pending.shift(); part !== void 0; part = pending.shift()) {
|
|
10596
10883
|
if (part === "..") {
|
|
10597
10884
|
if (!stack.length) throw linkEscape(label, target);
|
|
10598
10885
|
stack.pop();
|
|
@@ -10604,7 +10891,7 @@ function validateSymlinkGraph(target, out2, root, dependencyRoot, label) {
|
|
|
10604
10891
|
stack = [];
|
|
10605
10892
|
continue;
|
|
10606
10893
|
}
|
|
10607
|
-
const base = domain === "
|
|
10894
|
+
const base = domain === "deps" && dependencyRoot ? dependencyRoot : root;
|
|
10608
10895
|
const current = join16(base, ...stack);
|
|
10609
10896
|
let st;
|
|
10610
10897
|
try {
|
|
@@ -11623,7 +11910,7 @@ function runAgentSupervised(argv2, cwd, budgetSecs, linuxPythonCandidates, lifec
|
|
|
11623
11910
|
}
|
|
11624
11911
|
}
|
|
11625
11912
|
);
|
|
11626
|
-
const supervisorTimedOut = Boolean(supervisor.error) && supervisor.error
|
|
11913
|
+
const supervisorTimedOut = Boolean(supervisor.error) && errnoCode(supervisor.error) === "ETIMEDOUT";
|
|
11627
11914
|
const completedNormally = !supervisorTimedOut && !supervisor.error && supervisor.status === 0 && supervisor.signal == null;
|
|
11628
11915
|
if (!completedNormally) {
|
|
11629
11916
|
return {
|
|
@@ -11674,7 +11961,7 @@ function pidAlive2(pid) {
|
|
|
11674
11961
|
process.kill(pid, 0);
|
|
11675
11962
|
return true;
|
|
11676
11963
|
} catch (e) {
|
|
11677
|
-
return e
|
|
11964
|
+
return errnoCode(e) === "EPERM";
|
|
11678
11965
|
}
|
|
11679
11966
|
}
|
|
11680
11967
|
function supervisedObserverLog(cwd) {
|
|
@@ -11717,7 +12004,7 @@ function stopObserverProcess(observer) {
|
|
|
11717
12004
|
waitMs(100);
|
|
11718
12005
|
const beforeStop = watcherTelemetry(observer.log);
|
|
11719
12006
|
const pid = observer.pid;
|
|
11720
|
-
if (pidAlive2(pid)) {
|
|
12007
|
+
if (pid !== null && pidAlive2(pid)) {
|
|
11721
12008
|
try {
|
|
11722
12009
|
process.kill(pid, "SIGTERM");
|
|
11723
12010
|
} catch {
|
|
@@ -12191,7 +12478,7 @@ function policyFor(opts, cwd) {
|
|
|
12191
12478
|
return atBase ?? defaultPolicy();
|
|
12192
12479
|
}
|
|
12193
12480
|
function asMapping(value) {
|
|
12194
|
-
return
|
|
12481
|
+
return isRecord(value) ? value : null;
|
|
12195
12482
|
}
|
|
12196
12483
|
function wiringState(status, authority) {
|
|
12197
12484
|
if (status === "ok") return "OK";
|
|
@@ -12512,8 +12799,8 @@ function githubApi(cwd, endpoint) {
|
|
|
12512
12799
|
);
|
|
12513
12800
|
return JSON.parse(stdout);
|
|
12514
12801
|
} catch (e) {
|
|
12515
|
-
const
|
|
12516
|
-
const detail =
|
|
12802
|
+
const failure = execFailure(e);
|
|
12803
|
+
const detail = failure.stderr ? failure.stderr.replace(/\s+/g, " ").trim() : failure.message;
|
|
12517
12804
|
throw new Error(
|
|
12518
12805
|
"GitHub API " + endpoint + " failed: " + (detail || "unknown error")
|
|
12519
12806
|
);
|
|
@@ -12824,7 +13111,7 @@ function summarizeTraceRuns(opts) {
|
|
|
12824
13111
|
for (const item of run) {
|
|
12825
13112
|
const rel = normalRepoPath(opts.root, item.path);
|
|
12826
13113
|
const isTracked = rel !== null && opts.tracked.has(rel);
|
|
12827
|
-
const keyPath =
|
|
13114
|
+
const keyPath = rel !== null ? opts.tracked.has(rel) ? rel : null : resolve9(opts.root, item.path);
|
|
12828
13115
|
if (keyPath === null) continue;
|
|
12829
13116
|
const target = isTracked ? repo : external;
|
|
12830
13117
|
const key2 = `${isTracked ? "repo" : "external"}\0${keyPath}`;
|
|
@@ -12909,7 +13196,7 @@ function materializeBase(base, cwd, dest) {
|
|
|
12909
13196
|
}
|
|
12910
13197
|
function traceFiles(prefix) {
|
|
12911
13198
|
const dir = resolve9(prefix, "..");
|
|
12912
|
-
const base = prefix.split(sep3).at(-1);
|
|
13199
|
+
const base = prefix.split(sep3).at(-1) ?? prefix;
|
|
12913
13200
|
return readdirSync6(dir).filter((name) => name === base || name.startsWith(base + ".")).map((name) => join19(dir, name)).sort();
|
|
12914
13201
|
}
|
|
12915
13202
|
function rewriteTraceRoot(accesses, actualRoot) {
|