tamperward 2.4.1 → 2.5.1
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/dist/cli/index.js +1197 -173
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -330,26 +330,52 @@ function diffWorktree(opts = {}) {
|
|
|
330
330
|
(c) => enrich(c, (p) => blobAt("HEAD", p, opts.cwd), (p) => fromDisk(p, opts.cwd))
|
|
331
331
|
);
|
|
332
332
|
}
|
|
333
|
+
function addOf(rel, cwd) {
|
|
334
|
+
const after = fromDisk(rel, cwd);
|
|
335
|
+
return {
|
|
336
|
+
kind: "file",
|
|
337
|
+
path: rel,
|
|
338
|
+
oldPath: null,
|
|
339
|
+
op: "add",
|
|
340
|
+
before: null,
|
|
341
|
+
after,
|
|
342
|
+
binary: false,
|
|
343
|
+
hunks: after == null ? [] : addHunks(after)
|
|
344
|
+
};
|
|
345
|
+
}
|
|
333
346
|
function untrackedAdds(opts = {}, keep, exclude) {
|
|
334
347
|
const others = git(["ls-files", "--others", "--exclude-standard", "-z"], opts.cwd).split("\0").filter(Boolean);
|
|
335
|
-
return others.filter((rel) => !exclude?.has(rel) && (!keep || keep(rel))).map((rel) =>
|
|
336
|
-
const after = fromDisk(rel, opts.cwd);
|
|
337
|
-
return {
|
|
338
|
-
kind: "file",
|
|
339
|
-
path: rel,
|
|
340
|
-
oldPath: null,
|
|
341
|
-
op: "add",
|
|
342
|
-
before: null,
|
|
343
|
-
after,
|
|
344
|
-
binary: false,
|
|
345
|
-
hunks: after == null ? [] : addHunks(after)
|
|
346
|
-
};
|
|
347
|
-
});
|
|
348
|
+
return others.filter((rel) => !exclude?.has(rel) && (!keep || keep(rel))).map((rel) => addOf(rel, opts.cwd));
|
|
348
349
|
}
|
|
349
|
-
|
|
350
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
|
|
351
|
+
var IGNORED_LS = ["ls-files", "--others", "--ignored", "--exclude-standard", "-z"];
|
|
352
|
+
function underSkippedDir(rel) {
|
|
353
|
+
return rel.split("/").some((seg) => SKIP_DIRS.has(seg));
|
|
354
|
+
}
|
|
355
|
+
function ignoredPaths(opts = {}, keep) {
|
|
356
|
+
const files = [];
|
|
357
|
+
const dirs = [];
|
|
358
|
+
for (const entry of git([...IGNORED_LS, "--directory"], opts.cwd).split("\0").filter(Boolean)) {
|
|
359
|
+
if (underSkippedDir(entry)) continue;
|
|
360
|
+
if (entry.endsWith("/")) dirs.push(entry);
|
|
361
|
+
else files.push(entry);
|
|
362
|
+
}
|
|
363
|
+
if (dirs.length > 0) {
|
|
364
|
+
const expanded = git([...IGNORED_LS, "--", ...dirs.map((d) => `:(literal)${d}`)], opts.cwd).split("\0").filter(Boolean);
|
|
365
|
+
for (const rel of expanded) if (!underSkippedDir(rel)) files.push(rel);
|
|
366
|
+
}
|
|
367
|
+
return files.filter(keep);
|
|
368
|
+
}
|
|
369
|
+
function ignoredAdds(opts = {}, keep, exclude) {
|
|
370
|
+
return ignoredPaths(opts, keep).filter((rel) => !exclude?.has(rel)).map((rel) => addOf(rel, opts.cwd));
|
|
371
|
+
}
|
|
372
|
+
function diffWorktreeWithUntracked(opts = {}, keepIgnored) {
|
|
350
373
|
const tracked = diffWorktree(opts);
|
|
351
374
|
const seen = new Set(tracked.filter((c) => c.kind === "file").map((c) => c.path));
|
|
352
|
-
|
|
375
|
+
const untracked = untrackedAdds(opts, void 0, seen);
|
|
376
|
+
for (const c of untracked) if (c.kind === "file") seen.add(c.path);
|
|
377
|
+
const ignored = keepIgnored ? ignoredAdds(opts, keepIgnored, seen) : [];
|
|
378
|
+
return [...tracked, ...untracked, ...ignored];
|
|
353
379
|
}
|
|
354
380
|
function hiddenTrackedPaths(opts = {}) {
|
|
355
381
|
const out2 = [];
|
|
@@ -560,12 +586,12 @@ function matcher(glob) {
|
|
|
560
586
|
}
|
|
561
587
|
return m;
|
|
562
588
|
}
|
|
563
|
-
function matchesAny(path,
|
|
564
|
-
return !!
|
|
589
|
+
function matchesAny(path, globs3) {
|
|
590
|
+
return !!globs3 && globs3.some((g) => matcher(g)(path));
|
|
565
591
|
}
|
|
566
592
|
function protectedCategory(path, policy) {
|
|
567
|
-
for (const [cat,
|
|
568
|
-
if (matchesAny(path,
|
|
593
|
+
for (const [cat, globs3] of Object.entries(policy.protected ?? {})) {
|
|
594
|
+
if (matchesAny(path, globs3)) return cat;
|
|
569
595
|
}
|
|
570
596
|
return null;
|
|
571
597
|
}
|
|
@@ -581,9 +607,9 @@ function isIgnored(path, policy) {
|
|
|
581
607
|
}
|
|
582
608
|
function mergeProtected(base, user) {
|
|
583
609
|
const out2 = { ...base };
|
|
584
|
-
for (const [cat,
|
|
610
|
+
for (const [cat, globs3] of Object.entries(user ?? {})) {
|
|
585
611
|
const have = new Set(out2[cat] ?? []);
|
|
586
|
-
out2[cat] = [...out2[cat] ?? [], ...(
|
|
612
|
+
out2[cat] = [...out2[cat] ?? [], ...(globs3 ?? []).filter((g) => !have.has(g))];
|
|
587
613
|
}
|
|
588
614
|
return out2;
|
|
589
615
|
}
|
|
@@ -631,7 +657,19 @@ function defaultPolicy(version = 1) {
|
|
|
631
657
|
"**/tsconfig*.json",
|
|
632
658
|
"**/.eslintrc*",
|
|
633
659
|
"**/eslint.config.*",
|
|
634
|
-
"**/package.json"
|
|
660
|
+
"**/package.json",
|
|
661
|
+
// Coverage gates outside the Jest/Vitest shapes. coverage-lowering could only
|
|
662
|
+
// see a threshold it was shown: a Python `fail_under`, an nyc `lines`, a
|
|
663
|
+
// codecov `target` lived in files no glob protected, so lowering them fired
|
|
664
|
+
// nothing. ADDITIVE, like every baseline widening (the policy-diff reads it
|
|
665
|
+
// as a strengthening). pyproject.toml carries [tool.coverage.report].
|
|
666
|
+
"**/.coveragerc",
|
|
667
|
+
"**/pyproject.toml",
|
|
668
|
+
"**/.nycrc",
|
|
669
|
+
"**/.nycrc.*",
|
|
670
|
+
"**/nyc.config.*",
|
|
671
|
+
"**/codecov.yml",
|
|
672
|
+
"**/.codecov.yml"
|
|
635
673
|
],
|
|
636
674
|
ci: [".github/workflows/**"],
|
|
637
675
|
// Recorded expected outputs. An assertion stored as data is still an assertion;
|
|
@@ -735,9 +773,11 @@ var IMPORTS = {
|
|
|
735
773
|
cs: /^(?:using\b|namespace\b)/
|
|
736
774
|
};
|
|
737
775
|
var HASH_COMMENT = /* @__PURE__ */ new Set(["py", "rb"]);
|
|
776
|
+
var DEBUG_ONLY = /^(?:console\.\w+\(.*\)\s*;?|debugger\s*;?)$/;
|
|
738
777
|
function isSignificantLine(trimmed, lang) {
|
|
739
778
|
if (trimmed.length < 10) return false;
|
|
740
779
|
if (COMMON.test(trimmed)) return false;
|
|
780
|
+
if (DEBUG_ONLY.test(trimmed)) return false;
|
|
741
781
|
const l = lang ?? "js";
|
|
742
782
|
if (IMPORTS[l].test(trimmed)) return false;
|
|
743
783
|
if (HASH_COMMENT.has(l) && trimmed.startsWith("#")) return false;
|
|
@@ -1075,7 +1115,47 @@ var testSkip = {
|
|
|
1075
1115
|
};
|
|
1076
1116
|
|
|
1077
1117
|
// src/detectors/coverage-lowering.ts
|
|
1118
|
+
import picomatch2 from "picomatch";
|
|
1078
1119
|
import ts2 from "typescript";
|
|
1120
|
+
|
|
1121
|
+
// src/detectors/repo.ts
|
|
1122
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
1123
|
+
var cache2 = /* @__PURE__ */ new Map();
|
|
1124
|
+
function trackedFiles(ctx) {
|
|
1125
|
+
if (!ctx) return null;
|
|
1126
|
+
if (ctx.trackedFiles) return ctx.trackedFiles;
|
|
1127
|
+
if (!ctx.cwd) return null;
|
|
1128
|
+
const hit = cache2.get(ctx.cwd);
|
|
1129
|
+
if (hit !== void 0) return hit;
|
|
1130
|
+
let files = null;
|
|
1131
|
+
try {
|
|
1132
|
+
files = execFileSync2("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
1133
|
+
cwd: ctx.cwd,
|
|
1134
|
+
encoding: "utf8",
|
|
1135
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
1136
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1137
|
+
}).split("\0").filter(Boolean);
|
|
1138
|
+
} catch {
|
|
1139
|
+
files = null;
|
|
1140
|
+
}
|
|
1141
|
+
cache2.set(ctx.cwd, files);
|
|
1142
|
+
return files;
|
|
1143
|
+
}
|
|
1144
|
+
var dirOf = (t) => t.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
1145
|
+
function containsProtected(token, policy, category, ctx) {
|
|
1146
|
+
const dir = dirOf(token);
|
|
1147
|
+
if (!dir || dir === "." || dir === "/" || dir.startsWith("-")) return false;
|
|
1148
|
+
const files = trackedFiles(ctx);
|
|
1149
|
+
if (files) {
|
|
1150
|
+
const prefix = dir + "/";
|
|
1151
|
+
return files.some((f) => f.startsWith(prefix) && isProtected(f, policy, category));
|
|
1152
|
+
}
|
|
1153
|
+
if (isProtected(dir + "/probe", policy, category)) return true;
|
|
1154
|
+
const base = dir.split("/").pop() ?? "";
|
|
1155
|
+
return category === "tests" && /^(?:test|tests|__tests__|spec|specs)$/i.test(base);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// src/detectors/coverage-lowering.ts
|
|
1079
1159
|
var RULE4 = "coverage-lowering";
|
|
1080
1160
|
var METRICS = ["branches", "functions", "lines", "statements"];
|
|
1081
1161
|
var isMetric = (k) => METRICS.includes(k);
|
|
@@ -1086,29 +1166,36 @@ function keyName(name) {
|
|
|
1086
1166
|
}
|
|
1087
1167
|
function numericOf(e) {
|
|
1088
1168
|
if (ts2.isNumericLiteral(e)) return Number(e.text);
|
|
1169
|
+
if (ts2.isStringLiteral(e) && /^-?\d+(?:\.\d+)?$/.test(e.text.trim())) return Number(e.text);
|
|
1089
1170
|
if (ts2.isPrefixUnaryExpression(e) && e.operator === ts2.SyntaxKind.MinusToken && ts2.isNumericLiteral(e.operand)) {
|
|
1090
1171
|
return -Number(e.operand.text);
|
|
1091
1172
|
}
|
|
1092
1173
|
return void 0;
|
|
1093
1174
|
}
|
|
1094
|
-
function metricsOf(
|
|
1175
|
+
function metricsOf(expr, resolve5) {
|
|
1176
|
+
const obj = resolve5(expr);
|
|
1095
1177
|
if (!ts2.isObjectLiteralExpression(obj)) return void 0;
|
|
1096
|
-
const out2 = {};
|
|
1178
|
+
const out2 = { values: {}, opaque: false };
|
|
1097
1179
|
let any = false;
|
|
1098
1180
|
for (const p of obj.properties) {
|
|
1181
|
+
if (ts2.isSpreadAssignment(p)) {
|
|
1182
|
+
out2.opaque = true;
|
|
1183
|
+
any = true;
|
|
1184
|
+
continue;
|
|
1185
|
+
}
|
|
1099
1186
|
if (!ts2.isPropertyAssignment(p)) continue;
|
|
1100
1187
|
const k = keyName(p.name);
|
|
1101
1188
|
if (k === null || !isMetric(k)) continue;
|
|
1102
|
-
const n = numericOf(p.initializer);
|
|
1103
|
-
if (n === void 0) continue;
|
|
1104
|
-
out2[k] = n;
|
|
1105
1189
|
any = true;
|
|
1190
|
+
const n = numericOf(resolve5(p.initializer));
|
|
1191
|
+
if (n === void 0) out2.opaque = true;
|
|
1192
|
+
else out2.values[k] = n;
|
|
1106
1193
|
}
|
|
1107
1194
|
return any ? out2 : void 0;
|
|
1108
1195
|
}
|
|
1109
|
-
function
|
|
1196
|
+
function underKey(node, key2) {
|
|
1110
1197
|
for (let p = node.parent; p; p = p.parent) {
|
|
1111
|
-
if (ts2.isPropertyAssignment(p) && keyName(p.name) ===
|
|
1198
|
+
if (ts2.isPropertyAssignment(p) && keyName(p.name) === key2) return true;
|
|
1112
1199
|
}
|
|
1113
1200
|
return false;
|
|
1114
1201
|
}
|
|
@@ -1116,25 +1203,54 @@ function asExpression(src) {
|
|
|
1116
1203
|
const t = src.trim();
|
|
1117
1204
|
return t.startsWith("{") ? `(${t})` : src;
|
|
1118
1205
|
}
|
|
1206
|
+
function sourceOf(src) {
|
|
1207
|
+
return ts2.createSourceFile("cfg.ts", asExpression(src), ts2.ScriptTarget.Latest, true, ts2.ScriptKind.TS);
|
|
1208
|
+
}
|
|
1209
|
+
function resolverFor(sf) {
|
|
1210
|
+
const consts = /* @__PURE__ */ new Map();
|
|
1211
|
+
for (const st of sf.statements) {
|
|
1212
|
+
if (!ts2.isVariableStatement(st)) continue;
|
|
1213
|
+
for (const d of st.declarationList.declarations) {
|
|
1214
|
+
if (ts2.isIdentifier(d.name) && d.initializer) consts.set(d.name.text, d.initializer);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
return (e) => {
|
|
1218
|
+
if (ts2.isIdentifier(e)) {
|
|
1219
|
+
const bound = consts.get(e.text);
|
|
1220
|
+
return bound ?? e;
|
|
1221
|
+
}
|
|
1222
|
+
return e;
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1119
1225
|
function parseThresholds(src) {
|
|
1120
|
-
const res = { paths: /* @__PURE__ */ new Map(), present: false };
|
|
1226
|
+
const res = { paths: /* @__PURE__ */ new Map(), present: false, opaque: false };
|
|
1121
1227
|
try {
|
|
1122
|
-
const sf =
|
|
1228
|
+
const sf = sourceOf(src);
|
|
1229
|
+
const resolve5 = resolverFor(sf);
|
|
1123
1230
|
const visit = (node) => {
|
|
1124
|
-
if (ts2.isPropertyAssignment(node)
|
|
1231
|
+
if (ts2.isPropertyAssignment(node)) {
|
|
1125
1232
|
const key2 = keyName(node.name);
|
|
1126
|
-
if (key2 === "coverageThreshold" || key2 === "thresholds" &&
|
|
1233
|
+
if (key2 === "coverageThreshold" || key2 === "thresholds" && underKey(node, "coverage")) {
|
|
1127
1234
|
res.present = true;
|
|
1128
|
-
const
|
|
1129
|
-
if (
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
const
|
|
1133
|
-
if (
|
|
1134
|
-
const
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1235
|
+
const init = resolve5(node.initializer);
|
|
1236
|
+
if (!ts2.isObjectLiteralExpression(init)) {
|
|
1237
|
+
res.opaque = true;
|
|
1238
|
+
} else {
|
|
1239
|
+
const flat = metricsOf(init, resolve5);
|
|
1240
|
+
if (flat) res.global = merge(res.global, flat);
|
|
1241
|
+
for (const p of init.properties) {
|
|
1242
|
+
if (ts2.isSpreadAssignment(p)) {
|
|
1243
|
+
res.opaque = true;
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
if (!ts2.isPropertyAssignment(p)) continue;
|
|
1247
|
+
const k = keyName(p.name);
|
|
1248
|
+
if (k === null || isMetric(k)) continue;
|
|
1249
|
+
const m = metricsOf(p.initializer, resolve5);
|
|
1250
|
+
if (!m) continue;
|
|
1251
|
+
if (k === "global") res.global = merge(res.global, m);
|
|
1252
|
+
else res.paths.set(norm(k), m);
|
|
1253
|
+
}
|
|
1138
1254
|
}
|
|
1139
1255
|
}
|
|
1140
1256
|
}
|
|
@@ -1145,41 +1261,242 @@ function parseThresholds(src) {
|
|
|
1145
1261
|
}
|
|
1146
1262
|
return res;
|
|
1147
1263
|
}
|
|
1148
|
-
|
|
1264
|
+
function merge(a, b) {
|
|
1265
|
+
return { values: { ...a?.values ?? {}, ...b.values }, opaque: (a?.opaque ?? false) || b.opaque };
|
|
1266
|
+
}
|
|
1267
|
+
function literals(e) {
|
|
1268
|
+
if (ts2.isStringLiteral(e)) return { items: [e.text], opaque: false };
|
|
1269
|
+
if (!ts2.isArrayLiteralExpression(e)) return { items: [], opaque: true };
|
|
1270
|
+
const items = [];
|
|
1271
|
+
let opaque = false;
|
|
1272
|
+
for (const el of e.elements) {
|
|
1273
|
+
if (ts2.isStringLiteral(el) || ts2.isNoSubstitutionTemplateLiteral(el)) items.push(el.text);
|
|
1274
|
+
else opaque = true;
|
|
1275
|
+
}
|
|
1276
|
+
return { items, opaque };
|
|
1277
|
+
}
|
|
1278
|
+
function parseLists(src) {
|
|
1279
|
+
const res = { collectFrom: null, ignore: [], opaqueCollect: false, opaqueIgnore: false };
|
|
1280
|
+
try {
|
|
1281
|
+
const sf = sourceOf(src);
|
|
1282
|
+
const resolve5 = resolverFor(sf);
|
|
1283
|
+
const visit = (node) => {
|
|
1284
|
+
if (ts2.isPropertyAssignment(node)) {
|
|
1285
|
+
const k = keyName(node.name);
|
|
1286
|
+
if (k === "collectCoverageFrom") {
|
|
1287
|
+
const { items, opaque } = literals(resolve5(node.initializer));
|
|
1288
|
+
res.collectFrom = items;
|
|
1289
|
+
if (opaque) res.opaqueCollect = true;
|
|
1290
|
+
} else if (k === "coveragePathIgnorePatterns" || k === "exclude" && underKey(node, "coverage")) {
|
|
1291
|
+
const { items, opaque } = literals(resolve5(node.initializer));
|
|
1292
|
+
res.ignore.push(...items);
|
|
1293
|
+
if (opaque) res.opaqueIgnore = true;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
ts2.forEachChild(node, visit);
|
|
1297
|
+
};
|
|
1298
|
+
visit(sf);
|
|
1299
|
+
} catch {
|
|
1300
|
+
}
|
|
1301
|
+
return res;
|
|
1302
|
+
}
|
|
1303
|
+
var BENIGN_EXEMPTION = /node_modules|(?:^|[/!])(?:dist|build|out|coverage|tmp|temp|vendor|test|tests|__tests__|__mocks__|__fixtures__|fixtures?|mocks?|spec|specs|e2e|cypress|playwright|storybook|scripts?|examples?|docs?|bin|types?|typings)(?:\/|$|\*)|\.(?:test|spec|stories|d)\.|\.(?:config|setup)\.|\.git\b|\.cache|\.next|\.nuxt|\.output|\.idea|\.d\.ts|\.json$|\.md$|\.css$|\.s[ac]ss$/i;
|
|
1149
1304
|
function compareMetrics(scope, before, after, out2) {
|
|
1150
1305
|
if (!before) return;
|
|
1151
1306
|
for (const m of METRICS) {
|
|
1152
|
-
const bv = before[m];
|
|
1307
|
+
const bv = before.values[m];
|
|
1153
1308
|
if (bv === void 0) continue;
|
|
1154
|
-
const av = after?.[m];
|
|
1155
|
-
if (av === void 0)
|
|
1156
|
-
|
|
1309
|
+
const av = after?.values[m];
|
|
1310
|
+
if (av === void 0) {
|
|
1311
|
+
if (!after?.opaque) out2.push(`${scope} ${m} threshold removed (was ${bv})`);
|
|
1312
|
+
} else if (av < bv) out2.push(`${scope} ${m} threshold lowered ${bv} \u2192 ${av}`);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
function overrideTargetExists(key2, configPath, ctx) {
|
|
1316
|
+
const files = trackedFiles(ctx);
|
|
1317
|
+
if (!files) return null;
|
|
1318
|
+
const root = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/") + 1) : "";
|
|
1319
|
+
const rel = norm(key2);
|
|
1320
|
+
let m;
|
|
1321
|
+
try {
|
|
1322
|
+
m = /[*?{}[\]]/.test(rel) ? picomatch2(rel, { dot: true }) : (p) => p === rel;
|
|
1323
|
+
} catch {
|
|
1324
|
+
return null;
|
|
1157
1325
|
}
|
|
1326
|
+
return files.some((f) => f.startsWith(root) && m(f.slice(root.length)));
|
|
1158
1327
|
}
|
|
1159
|
-
function weakenings(before, after) {
|
|
1328
|
+
function weakenings(before, after, configPath, ctx) {
|
|
1160
1329
|
const out2 = [];
|
|
1161
1330
|
if (before.present && !after.present) {
|
|
1162
1331
|
out2.push("the coverage threshold gate was removed");
|
|
1163
1332
|
return out2;
|
|
1164
1333
|
}
|
|
1334
|
+
if (after.opaque && !after.global && after.paths.size === 0) return out2;
|
|
1165
1335
|
compareMetrics("global", before.global, after.global, out2);
|
|
1166
1336
|
const paths = /* @__PURE__ */ new Set([...before.paths.keys(), ...after.paths.keys()]);
|
|
1167
1337
|
for (const p of paths) {
|
|
1168
|
-
|
|
1338
|
+
const scope = `required coverage for ${p}:`;
|
|
1339
|
+
const b = before.paths.get(p);
|
|
1340
|
+
const a = after.paths.get(p);
|
|
1341
|
+
if (b && a) {
|
|
1342
|
+
compareMetrics(scope, b, a, out2);
|
|
1343
|
+
} else if (a) {
|
|
1344
|
+
const g = before.global;
|
|
1345
|
+
if (!g) continue;
|
|
1346
|
+
for (const m of METRICS) {
|
|
1347
|
+
const gv = g.values[m];
|
|
1348
|
+
const av = a.values[m];
|
|
1349
|
+
if (gv !== void 0 && av !== void 0 && av < gv) out2.push(`${scope} ${m} threshold lowered ${gv} \u2192 ${av}`);
|
|
1350
|
+
}
|
|
1351
|
+
} else if (b) {
|
|
1352
|
+
if (overrideTargetExists(p, configPath, ctx) === false) continue;
|
|
1353
|
+
compareMetrics(scope, b, after.global, out2);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
return out2;
|
|
1357
|
+
}
|
|
1358
|
+
function narrowings(before, after) {
|
|
1359
|
+
const out2 = [];
|
|
1360
|
+
if (before.collectFrom && after.collectFrom && !after.opaqueCollect) {
|
|
1361
|
+
const kept = new Set(after.collectFrom);
|
|
1362
|
+
for (const g of before.collectFrom) {
|
|
1363
|
+
if (!g.startsWith("!") && !kept.has(g)) out2.push(`collectCoverageFrom no longer measures ${JSON.stringify(g)}`);
|
|
1364
|
+
}
|
|
1365
|
+
const had2 = new Set(before.collectFrom);
|
|
1366
|
+
for (const g of after.collectFrom) {
|
|
1367
|
+
if (g.startsWith("!") && !had2.has(g) && !BENIGN_EXEMPTION.test(g)) out2.push(`collectCoverageFrom now exempts ${JSON.stringify(g)}`);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
const had = new Set(before.ignore);
|
|
1371
|
+
for (const g of after.ignore) {
|
|
1372
|
+
if (!had.has(g) && !BENIGN_EXEMPTION.test(g)) out2.push(`coverage now exempts ${JSON.stringify(g)} (coveragePathIgnorePatterns / coverage.exclude)`);
|
|
1373
|
+
}
|
|
1374
|
+
return out2;
|
|
1375
|
+
}
|
|
1376
|
+
var SIMPLE_FILES = [
|
|
1377
|
+
{
|
|
1378
|
+
// coverage.py: `fail_under = 90` under [report] (.coveragerc, setup.cfg-style) or
|
|
1379
|
+
// [tool.coverage.report] (pyproject.toml)
|
|
1380
|
+
file: /(?:^|\/)(?:\.coveragerc|pyproject\.toml)$/,
|
|
1381
|
+
keys: [{ key: /^\s*fail[_-]under\s*=\s*"?(\d+(?:\.\d+)?)/, label: "fail_under", weakens: "lower" }],
|
|
1382
|
+
removable: true
|
|
1383
|
+
},
|
|
1384
|
+
{
|
|
1385
|
+
// nyc: .nycrc / .nycrc.json / .nycrc.yml / nyc.config.js — metric thresholds
|
|
1386
|
+
file: /(?:^|\/)(?:\.nycrc(?:\.\w+)?|nyc\.config\.\w+)$/,
|
|
1387
|
+
keys: METRICS.map((m) => ({
|
|
1388
|
+
key: new RegExp(`(?:^|[\\s{,])"?${m}"?\\s*[:=]\\s*"?(\\d+(?:\\.\\d+)?)`, "g"),
|
|
1389
|
+
label: m,
|
|
1390
|
+
weakens: "lower"
|
|
1391
|
+
})),
|
|
1392
|
+
removable: false
|
|
1393
|
+
},
|
|
1394
|
+
{
|
|
1395
|
+
// codecov: `target: 90%` is the floor (lowering weakens); `threshold: 2%` is the
|
|
1396
|
+
// permitted drop (raising weakens)
|
|
1397
|
+
file: /(?:^|\/)\.?codecov\.yml$/,
|
|
1398
|
+
keys: [
|
|
1399
|
+
{ key: /^\s*target:\s*"?(\d+(?:\.\d+)?)%?/, label: "target", weakens: "lower" },
|
|
1400
|
+
{ key: /^\s*threshold:\s*"?(\d+(?:\.\d+)?)%?/, label: "threshold", weakens: "raise" }
|
|
1401
|
+
],
|
|
1402
|
+
removable: false
|
|
1403
|
+
}
|
|
1404
|
+
];
|
|
1405
|
+
function numbersOf(src, key2) {
|
|
1406
|
+
const out2 = [];
|
|
1407
|
+
for (const line of src.split("\n")) {
|
|
1408
|
+
if (key2.global) {
|
|
1409
|
+
for (const m of line.matchAll(key2)) out2.push(Number(m[1]));
|
|
1410
|
+
} else {
|
|
1411
|
+
const m = line.match(key2);
|
|
1412
|
+
if (m) out2.push(Number(m[1]));
|
|
1413
|
+
}
|
|
1169
1414
|
}
|
|
1170
1415
|
return out2;
|
|
1171
1416
|
}
|
|
1417
|
+
function simpleWeakenings(path, before, after) {
|
|
1418
|
+
const out2 = [];
|
|
1419
|
+
const spec = SIMPLE_FILES.find((s) => s.file.test(path));
|
|
1420
|
+
if (!spec) return out2;
|
|
1421
|
+
for (const k of spec.keys) {
|
|
1422
|
+
const b = numbersOf(before, k.key).sort((x, y) => y - x);
|
|
1423
|
+
const a = numbersOf(after, k.key).sort((x, y) => y - x);
|
|
1424
|
+
if (b.length === 0) continue;
|
|
1425
|
+
if (a.length === 0) {
|
|
1426
|
+
if (spec.removable) out2.push(`${k.label} removed (was ${b.join(", ")})`);
|
|
1427
|
+
continue;
|
|
1428
|
+
}
|
|
1429
|
+
if (k.weakens === "lower") {
|
|
1430
|
+
for (let i = 0; i < b.length; i++) {
|
|
1431
|
+
const av = a[Math.min(i, a.length - 1)];
|
|
1432
|
+
if (av < b[i]) {
|
|
1433
|
+
out2.push(`${k.label} lowered ${b[i]} \u2192 ${av}`);
|
|
1434
|
+
break;
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
} else {
|
|
1438
|
+
const bMax = b[0];
|
|
1439
|
+
const aMax = a[0];
|
|
1440
|
+
if (aMax > bMax) out2.push(`${k.label} raised ${bMax} \u2192 ${aMax} (a wider permitted drop)`);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
return out2;
|
|
1444
|
+
}
|
|
1445
|
+
var SWITCHES = [
|
|
1446
|
+
{
|
|
1447
|
+
re: /--passWithNoTests\b/,
|
|
1448
|
+
message: "--passWithNoTests added \u2014 the suite can now pass with zero tests.",
|
|
1449
|
+
remediation: "Remove --passWithNoTests; an empty suite must not be a green check."
|
|
1450
|
+
},
|
|
1451
|
+
{
|
|
1452
|
+
re: /\bpassWithNoTests"?\s*:\s*true\b/,
|
|
1453
|
+
message: "passWithNoTests: true added to the runner config \u2014 the suite can now pass with zero tests.",
|
|
1454
|
+
remediation: "Remove passWithNoTests; an empty suite must not be a green check."
|
|
1455
|
+
},
|
|
1456
|
+
{
|
|
1457
|
+
re: /--coverage=false\b|--no-coverage\b|--coverage\.enabled=false\b/,
|
|
1458
|
+
message: "coverage switched off on the command line (--coverage=false).",
|
|
1459
|
+
remediation: "Keep coverage reporting on; do not switch it off to dodge the gate."
|
|
1460
|
+
},
|
|
1461
|
+
{
|
|
1462
|
+
re: /--coverageThreshold[= ]/,
|
|
1463
|
+
message: "the coverage threshold is overridden on the command line (--coverageThreshold), which replaces the configured gate.",
|
|
1464
|
+
remediation: "Remove the override; the configured threshold is the gate."
|
|
1465
|
+
},
|
|
1466
|
+
{
|
|
1467
|
+
re: /\bautoUpdate"?\s*:\s*true\b/,
|
|
1468
|
+
message: "thresholds.autoUpdate: true added \u2014 the gate now follows the measured number.",
|
|
1469
|
+
remediation: "Remove autoUpdate; a threshold that tracks the result gates nothing."
|
|
1470
|
+
},
|
|
1471
|
+
{
|
|
1472
|
+
re: /"?check-coverage"?\s*[:=]\s*false\b/,
|
|
1473
|
+
message: "check-coverage: false \u2014 nyc will no longer fail on the thresholds.",
|
|
1474
|
+
remediation: "Keep check-coverage on; raise real coverage instead."
|
|
1475
|
+
},
|
|
1476
|
+
{
|
|
1477
|
+
re: /^\s*informational:\s*true\b/,
|
|
1478
|
+
message: "informational: true added \u2014 the codecov status can no longer fail.",
|
|
1479
|
+
remediation: "Remove informational; a status that cannot fail is not a gate."
|
|
1480
|
+
}
|
|
1481
|
+
];
|
|
1482
|
+
var isJsConfig = (path) => /\.(?:[cm]?[jt]sx?|json)$/.test(path);
|
|
1172
1483
|
var coverageLowering = {
|
|
1173
1484
|
id: RULE4,
|
|
1174
1485
|
surface: ["file"],
|
|
1175
1486
|
certainty: "mechanical",
|
|
1176
|
-
run(changes, policy) {
|
|
1487
|
+
run(changes, policy, _view, ctx) {
|
|
1177
1488
|
const out2 = [];
|
|
1178
1489
|
for (const c of changes) {
|
|
1179
1490
|
if (c.kind !== "file") continue;
|
|
1180
1491
|
if (!isProtected(c.path, policy, "config")) continue;
|
|
1181
1492
|
if (c.after != null && c.op !== "delete") {
|
|
1182
|
-
|
|
1493
|
+
const reasons = [];
|
|
1494
|
+
if (isJsConfig(c.path)) {
|
|
1495
|
+
reasons.push(...weakenings(parseThresholds(c.before ?? ""), parseThresholds(c.after), c.path, ctx));
|
|
1496
|
+
reasons.push(...narrowings(parseLists(c.before ?? ""), parseLists(c.after)));
|
|
1497
|
+
}
|
|
1498
|
+
reasons.push(...simpleWeakenings(c.path, c.before ?? "", c.after));
|
|
1499
|
+
for (const reason of reasons) {
|
|
1183
1500
|
out2.push(
|
|
1184
1501
|
makeFinding(RULE4, policy, {
|
|
1185
1502
|
file: c.path,
|
|
@@ -1190,7 +1507,9 @@ var coverageLowering = {
|
|
|
1190
1507
|
);
|
|
1191
1508
|
}
|
|
1192
1509
|
} else if (c.op === "delete" || c.after === "" && (c.before ?? "").length > 0) {
|
|
1193
|
-
|
|
1510
|
+
const before = c.before ?? "";
|
|
1511
|
+
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));
|
|
1512
|
+
if (gated) {
|
|
1194
1513
|
out2.push(
|
|
1195
1514
|
makeFinding(RULE4, policy, {
|
|
1196
1515
|
file: c.path,
|
|
@@ -1215,20 +1534,22 @@ var coverageLowering = {
|
|
|
1215
1534
|
);
|
|
1216
1535
|
}
|
|
1217
1536
|
for (const l of added) {
|
|
1218
|
-
|
|
1537
|
+
for (const s of SWITCHES) {
|
|
1538
|
+
if (!s.re.test(l.content)) continue;
|
|
1539
|
+
if (removed.some((r) => s.re.test(r.content))) continue;
|
|
1219
1540
|
out2.push(
|
|
1220
1541
|
makeFinding(RULE4, policy, {
|
|
1221
1542
|
file: c.path,
|
|
1222
1543
|
line: l.newLine ?? void 0,
|
|
1223
|
-
message:
|
|
1544
|
+
message: s.message,
|
|
1224
1545
|
evidence: l.content.trim(),
|
|
1225
|
-
remediation:
|
|
1546
|
+
remediation: s.remediation
|
|
1226
1547
|
})
|
|
1227
1548
|
);
|
|
1228
1549
|
}
|
|
1229
1550
|
}
|
|
1230
1551
|
if (c.after == null) {
|
|
1231
|
-
const KEY = /\b(branches|functions|lines|statements)\b\s
|
|
1552
|
+
const KEY = /\b(branches|functions|lines|statements|fail[_-]under|target)\b"?\s*[:=]\s*"?(\d+(?:\.\d+)?)/;
|
|
1232
1553
|
const oldNums = /* @__PURE__ */ new Map();
|
|
1233
1554
|
for (const l of removed) {
|
|
1234
1555
|
const m = l.content.match(KEY);
|
|
@@ -1254,7 +1575,7 @@ var coverageLowering = {
|
|
|
1254
1575
|
}
|
|
1255
1576
|
const seen = /* @__PURE__ */ new Set();
|
|
1256
1577
|
return out2.filter((f) => {
|
|
1257
|
-
const k = `${f.rule}|${f.message}`;
|
|
1578
|
+
const k = `${f.rule}|${f.file ?? ""}|${f.message}`;
|
|
1258
1579
|
if (seen.has(k)) return false;
|
|
1259
1580
|
seen.add(k);
|
|
1260
1581
|
return true;
|
|
@@ -1276,23 +1597,287 @@ var invokesCheck = (line) => INVOKES_TOOL.test(line) || INVOKES_SCRIPT.test(line
|
|
|
1276
1597
|
function commandCore(line) {
|
|
1277
1598
|
return line.trim().replace(/^-\s*/, "").replace(/^(?:run|uses):\s*/, "").replace(/\s+/g, " ").trim();
|
|
1278
1599
|
}
|
|
1600
|
+
function canonical(core) {
|
|
1601
|
+
let s = core.replace(/^(['"])(.*)\1$/, "$2").trim();
|
|
1602
|
+
s = s.replace(/^(?:npx|pnpm\s+(?:exec|dlx)|yarn\s+(?:exec|dlx)|bunx|bun\s+x)\s+/, "exec ");
|
|
1603
|
+
s = s.replace(/^(?:npm|pnpm|yarn|bun)\s+(?:run(?:-script)?\s+)?/, "exec ");
|
|
1604
|
+
s = s.replace(/^exec\s+(?:t|tst)(?=\s|$)/, "exec test");
|
|
1605
|
+
s = s.replace(/^exec\s+/, "");
|
|
1606
|
+
return s.replace(/\s+--\s*$/, "").trim();
|
|
1607
|
+
}
|
|
1608
|
+
var usesRef = (core) => core.replace(/@.*$/, "");
|
|
1609
|
+
var NEUTRALISING_SUFFIX = /\|\||;|(?<!&)&(?!&)|\|(?!\|)|--passWithNoTests\b|--coverage=false\b|--coverageThreshold\b|--testPathIgnorePatterns\b|--testPathPattern\b|--testNamePattern\b|(?:^|\s)-t\s|--onlyChanged\b|--changed\b|--findRelatedTests\b/;
|
|
1610
|
+
function survives(removedCore, isUses, afterCores) {
|
|
1611
|
+
if (isUses) {
|
|
1612
|
+
const ref = usesRef(removedCore);
|
|
1613
|
+
return afterCores.some((a) => usesRef(a) === ref) ? { state: "kept" } : { state: "gone" };
|
|
1614
|
+
}
|
|
1615
|
+
const r = canonical(removedCore);
|
|
1616
|
+
let neutralised;
|
|
1617
|
+
for (const raw of afterCores) {
|
|
1618
|
+
const a = canonical(raw);
|
|
1619
|
+
if (a === r) return { state: "kept" };
|
|
1620
|
+
if (a.startsWith(r + " ")) {
|
|
1621
|
+
const rest = a.slice(r.length);
|
|
1622
|
+
if (NEUTRALISING_SUFFIX.test(rest)) neutralised = raw;
|
|
1623
|
+
else return { state: "kept" };
|
|
1624
|
+
} else if (r.startsWith(a + " ") && invokesCheck(a)) {
|
|
1625
|
+
return { state: "kept" };
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
return neutralised ? { state: "neutralised", by: neutralised } : { state: "gone" };
|
|
1629
|
+
}
|
|
1279
1630
|
var uncommented = (v) => v.replace(/\s+#.*$/, "").trim();
|
|
1280
|
-
function
|
|
1281
|
-
const
|
|
1282
|
-
|
|
1631
|
+
function lex(src) {
|
|
1632
|
+
const out2 = [];
|
|
1633
|
+
let i = 0;
|
|
1634
|
+
while (i < src.length) {
|
|
1635
|
+
const ch = src[i];
|
|
1636
|
+
if (/\s/.test(ch)) {
|
|
1637
|
+
i++;
|
|
1638
|
+
continue;
|
|
1639
|
+
}
|
|
1640
|
+
if (ch === "'") {
|
|
1641
|
+
let j = i + 1;
|
|
1642
|
+
let s = "";
|
|
1643
|
+
while (j < src.length) {
|
|
1644
|
+
if (src[j] === "'" && src[j + 1] === "'") {
|
|
1645
|
+
s += "'";
|
|
1646
|
+
j += 2;
|
|
1647
|
+
} else if (src[j] === "'") break;
|
|
1648
|
+
else s += src[j++];
|
|
1649
|
+
}
|
|
1650
|
+
if (j >= src.length) return null;
|
|
1651
|
+
out2.push({ t: "str", v: s });
|
|
1652
|
+
i = j + 1;
|
|
1653
|
+
continue;
|
|
1654
|
+
}
|
|
1655
|
+
const num = src.slice(i).match(/^-?\d+(?:\.\d+)?/);
|
|
1656
|
+
if (num) {
|
|
1657
|
+
out2.push({ t: "num", v: num[0] });
|
|
1658
|
+
i += num[0].length;
|
|
1659
|
+
continue;
|
|
1660
|
+
}
|
|
1661
|
+
const op = src.slice(i).match(/^(?:==|!=|&&|\|\||<=|>=|[!()<>])/);
|
|
1662
|
+
if (op) {
|
|
1663
|
+
out2.push({ t: "op", v: op[0] });
|
|
1664
|
+
i += op[0].length;
|
|
1665
|
+
continue;
|
|
1666
|
+
}
|
|
1667
|
+
const id = src.slice(i).match(/^[A-Za-z_][\w.\-[\]*]*/);
|
|
1668
|
+
if (id) {
|
|
1669
|
+
out2.push({ t: "id", v: id[0] });
|
|
1670
|
+
i += id[0].length;
|
|
1671
|
+
continue;
|
|
1672
|
+
}
|
|
1673
|
+
return null;
|
|
1674
|
+
}
|
|
1675
|
+
return out2;
|
|
1676
|
+
}
|
|
1677
|
+
function foldConst(src) {
|
|
1678
|
+
const toks = lex(src);
|
|
1679
|
+
if (!toks) return void 0;
|
|
1680
|
+
let p = 0;
|
|
1681
|
+
const peek = () => toks[p];
|
|
1682
|
+
const eat = (v2) => toks[p]?.t === "op" && toks[p].v === v2 ? (p++, true) : false;
|
|
1683
|
+
const truthy = (v2) => typeof v2 === "string" ? v2 !== "" : typeof v2 === "number" ? v2 !== 0 : v2 === true;
|
|
1684
|
+
const num = (v2) => typeof v2 === "number" ? v2 : typeof v2 === "boolean" ? v2 ? 1 : 0 : v2 === null ? 0 : v2.trim() === "" ? 0 : Number(v2);
|
|
1685
|
+
const eq = (a, b) => {
|
|
1686
|
+
if (typeof a === "string" && typeof b === "string") return a.toLowerCase() === b.toLowerCase();
|
|
1687
|
+
if (a === null && b === null) return true;
|
|
1688
|
+
const x = num(a);
|
|
1689
|
+
const y = num(b);
|
|
1690
|
+
return !Number.isNaN(x) && x === y;
|
|
1691
|
+
};
|
|
1692
|
+
const or = () => {
|
|
1693
|
+
let l = and();
|
|
1694
|
+
while (eat("||")) {
|
|
1695
|
+
const r = and();
|
|
1696
|
+
if (l === void 0 || r === void 0) return void 0;
|
|
1697
|
+
l = truthy(l) ? l : r;
|
|
1698
|
+
}
|
|
1699
|
+
return l;
|
|
1700
|
+
};
|
|
1701
|
+
const and = () => {
|
|
1702
|
+
let l = cmp();
|
|
1703
|
+
while (eat("&&")) {
|
|
1704
|
+
const r = cmp();
|
|
1705
|
+
if (l === void 0 || r === void 0) return void 0;
|
|
1706
|
+
l = truthy(l) ? r : l;
|
|
1707
|
+
}
|
|
1708
|
+
return l;
|
|
1709
|
+
};
|
|
1710
|
+
const cmp = () => {
|
|
1711
|
+
let l = unary();
|
|
1712
|
+
for (; ; ) {
|
|
1713
|
+
const t = peek();
|
|
1714
|
+
if (!t || t.t !== "op" || !/^(?:==|!=|<|>|<=|>=)$/.test(t.v)) return l;
|
|
1715
|
+
p++;
|
|
1716
|
+
const r = unary();
|
|
1717
|
+
if (l === void 0 || r === void 0) return void 0;
|
|
1718
|
+
if (t.v === "==") l = eq(l, r);
|
|
1719
|
+
else if (t.v === "!=") l = !eq(l, r);
|
|
1720
|
+
else {
|
|
1721
|
+
const x = num(l);
|
|
1722
|
+
const y = num(r);
|
|
1723
|
+
if (Number.isNaN(x) || Number.isNaN(y)) return void 0;
|
|
1724
|
+
l = t.v === "<" ? x < y : t.v === ">" ? x > y : t.v === "<=" ? x <= y : x >= y;
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
};
|
|
1728
|
+
const unary = () => {
|
|
1729
|
+
if (eat("!")) {
|
|
1730
|
+
const v2 = unary();
|
|
1731
|
+
return v2 === void 0 ? void 0 : !truthy(v2);
|
|
1732
|
+
}
|
|
1733
|
+
return primary();
|
|
1734
|
+
};
|
|
1735
|
+
const primary = () => {
|
|
1736
|
+
const t = peek();
|
|
1737
|
+
if (!t) return void 0;
|
|
1738
|
+
if (eat("(")) {
|
|
1739
|
+
const v2 = or();
|
|
1740
|
+
return eat(")") ? v2 : void 0;
|
|
1741
|
+
}
|
|
1742
|
+
p++;
|
|
1743
|
+
if (t.t === "str") return t.v;
|
|
1744
|
+
if (t.t === "num") return Number(t.v);
|
|
1745
|
+
if (t.t === "id") {
|
|
1746
|
+
if (/^true$/i.test(t.v)) return true;
|
|
1747
|
+
if (/^false$/i.test(t.v)) return false;
|
|
1748
|
+
if (/^null$/i.test(t.v)) return null;
|
|
1749
|
+
return void 0;
|
|
1750
|
+
}
|
|
1751
|
+
return void 0;
|
|
1752
|
+
};
|
|
1753
|
+
const v = or();
|
|
1754
|
+
return p === toks.length ? v : void 0;
|
|
1755
|
+
}
|
|
1756
|
+
function expressionOf(raw) {
|
|
1757
|
+
const v = uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim();
|
|
1283
1758
|
const m = v.match(/^\$\{\{\s*(.+?)\s*\}\}$/);
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
const
|
|
1288
|
-
if (
|
|
1289
|
-
|
|
1759
|
+
return m ? m[1] : v;
|
|
1760
|
+
}
|
|
1761
|
+
function isAlwaysFalse(raw) {
|
|
1762
|
+
const e = expressionOf(raw);
|
|
1763
|
+
if (e === "") return false;
|
|
1764
|
+
const v = foldConst(e);
|
|
1765
|
+
return v !== void 0 && !(typeof v === "string" ? v !== "" : typeof v === "number" ? v !== 0 : v === true);
|
|
1290
1766
|
}
|
|
1291
1767
|
function isTruthy(raw) {
|
|
1292
|
-
const v = uncommented(raw);
|
|
1293
|
-
if (/^true$/i.test(v)) return true;
|
|
1768
|
+
const v = uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim();
|
|
1769
|
+
if (/^(?:true|yes|on|1)$/i.test(v)) return true;
|
|
1294
1770
|
const m = v.match(/^\$\{\{\s*(.+?)\s*\}\}$/);
|
|
1295
|
-
|
|
1771
|
+
if (!m) return false;
|
|
1772
|
+
const f = foldConst(m[1]);
|
|
1773
|
+
return f !== void 0 && (typeof f === "string" ? f !== "" : typeof f === "number" ? f !== 0 : f === true);
|
|
1774
|
+
}
|
|
1775
|
+
var indentOf = (l) => l.match(/^\s*/)[0].length;
|
|
1776
|
+
var STEP_START = /^\s*-\s+(?:name|uses|run|if|id|with|env|shell|continue-on-error|working-directory|timeout-minutes):/;
|
|
1777
|
+
function stepOf(lines, i) {
|
|
1778
|
+
const ind = indentOf(lines[i]);
|
|
1779
|
+
let s = i - 1;
|
|
1780
|
+
while (s >= 0 && (lines[s].trim() === "" || indentOf(lines[s]) >= ind)) s--;
|
|
1781
|
+
if (s < 0 || !STEP_START.test(lines[s])) return null;
|
|
1782
|
+
const stepInd = indentOf(lines[s]);
|
|
1783
|
+
let e = i + 1;
|
|
1784
|
+
while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > stepInd)) e++;
|
|
1785
|
+
return [s, e];
|
|
1786
|
+
}
|
|
1787
|
+
var stepHasCheck = (lines, range) => range === null || lines.slice(range[0], range[1]).some((l) => invokesCheck(l) || USES.test(l) && CHECK.test(l));
|
|
1788
|
+
function runBlocks(lines) {
|
|
1789
|
+
const out2 = [];
|
|
1790
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1791
|
+
if (!/^\s*-?\s*run:\s*[|>][-+]?\s*$/.test(lines[i])) continue;
|
|
1792
|
+
const ind = indentOf(lines[i].replace(/^(\s*)-\s+/, "$1 "));
|
|
1793
|
+
let e = i + 1;
|
|
1794
|
+
while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > ind)) e++;
|
|
1795
|
+
out2.push({ start: i, end: e, body: lines.slice(i + 1, e) });
|
|
1796
|
+
}
|
|
1797
|
+
return out2;
|
|
1798
|
+
}
|
|
1799
|
+
var NEUTRALISERS = [
|
|
1800
|
+
{ re: /^\s*set\s+\+e\b/, what: "set +e \u2014 a failing check no longer fails the step" },
|
|
1801
|
+
{ re: /^\s*exit\s+0\s*(?:$|[;#])/, what: "exit 0 \u2014 the step ends green whatever the check did" },
|
|
1802
|
+
{ re: /^\s*if\s+false\s*;?\s*then\b/, what: "if false; then \u2014 the check is never run" },
|
|
1803
|
+
{ re: /^\s*:\s*<<\s*['"]?\w+/, what: "a heredoc fed to `:` \u2014 the check is commented out" }
|
|
1804
|
+
];
|
|
1805
|
+
function flowList(v) {
|
|
1806
|
+
const m = v.trim().match(/^\[(.*)\]$/);
|
|
1807
|
+
if (!m) return null;
|
|
1808
|
+
return m[1].split(",").map((s) => s.trim().replace(/^(['"])(.*)\1$/, "$2")).filter(Boolean);
|
|
1809
|
+
}
|
|
1810
|
+
function parseTriggers(src) {
|
|
1811
|
+
const t = { events: /* @__PURE__ */ new Set(), branches: /* @__PURE__ */ new Map(), branchesIgnore: /* @__PURE__ */ new Map(), pathsIgnore: /* @__PURE__ */ new Map(), present: false };
|
|
1812
|
+
const lines = src.split("\n");
|
|
1813
|
+
const i = lines.findIndex((l) => /^(?:on|"on"|'on'|true):/.test(l));
|
|
1814
|
+
if (i < 0) return t;
|
|
1815
|
+
t.present = true;
|
|
1816
|
+
const head = lines[i].replace(/^(?:on|"on"|'on'|true):\s*/, "");
|
|
1817
|
+
const inline = uncommented(head);
|
|
1818
|
+
if (inline) {
|
|
1819
|
+
const list = flowList(inline);
|
|
1820
|
+
for (const e of list ?? [inline]) t.events.add(e);
|
|
1821
|
+
return t;
|
|
1822
|
+
}
|
|
1823
|
+
let event = null;
|
|
1824
|
+
let listKey = null;
|
|
1825
|
+
let listInd = -1;
|
|
1826
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
1827
|
+
const l = lines[j];
|
|
1828
|
+
if (l.trim() === "" || /^\s*#/.test(l)) continue;
|
|
1829
|
+
const ind = indentOf(l);
|
|
1830
|
+
if (ind === 0) break;
|
|
1831
|
+
const body = uncommented(l);
|
|
1832
|
+
const item = body.match(/^\s*-\s+(.+)$/);
|
|
1833
|
+
if (item && listKey && event && ind > listInd) {
|
|
1834
|
+
const target = t[listKey].get(event) ?? [];
|
|
1835
|
+
target.push(item[1].replace(/^(['"])(.*)\1$/, "$2"));
|
|
1836
|
+
t[listKey].set(event, target);
|
|
1837
|
+
continue;
|
|
1838
|
+
}
|
|
1839
|
+
const kv = body.match(/^\s*([\w-]+):\s*(.*)$/);
|
|
1840
|
+
if (!kv) continue;
|
|
1841
|
+
if (ind === 2 || event === null && ind < 4) {
|
|
1842
|
+
event = kv[1];
|
|
1843
|
+
t.events.add(event);
|
|
1844
|
+
listKey = null;
|
|
1845
|
+
continue;
|
|
1846
|
+
}
|
|
1847
|
+
if (event && /^(?:branches|branches-ignore|paths-ignore)$/.test(kv[1])) {
|
|
1848
|
+
listKey = kv[1] === "branches" ? "branches" : kv[1] === "branches-ignore" ? "branchesIgnore" : "pathsIgnore";
|
|
1849
|
+
listInd = ind;
|
|
1850
|
+
const list = flowList(kv[2]);
|
|
1851
|
+
t[listKey].set(event, list ?? []);
|
|
1852
|
+
} else listKey = null;
|
|
1853
|
+
}
|
|
1854
|
+
return t;
|
|
1855
|
+
}
|
|
1856
|
+
var DEFAULT_BRANCH = /^(?:main|master)$/;
|
|
1857
|
+
var ALL = (g) => /^\*\*?$|^\*\*\/\*$/.test(g);
|
|
1858
|
+
function triggerNarrowings(before, after) {
|
|
1859
|
+
const out2 = [];
|
|
1860
|
+
if (!before.present || !after.present) return out2;
|
|
1861
|
+
for (const e of ["push", "pull_request", "pull_request_target"]) {
|
|
1862
|
+
if (before.events.has(e) && !after.events.has(e)) out2.push(`the ${e} trigger was removed \u2014 the workflow no longer runs on it`);
|
|
1863
|
+
}
|
|
1864
|
+
for (const [e, list] of after.pathsIgnore) {
|
|
1865
|
+
const had = before.pathsIgnore.get(e) ?? [];
|
|
1866
|
+
if (list.some((g) => ALL(g) && !had.includes(g))) out2.push(`on.${e}.paths-ignore now ignores every path \u2014 the workflow never runs on ${e}`);
|
|
1867
|
+
}
|
|
1868
|
+
for (const [e, list] of after.branchesIgnore) {
|
|
1869
|
+
const had = before.branchesIgnore.get(e) ?? [];
|
|
1870
|
+
if (list.some((g) => (ALL(g) || DEFAULT_BRANCH.test(g)) && !had.includes(g))) out2.push(`on.${e}.branches-ignore now covers the default branch`);
|
|
1871
|
+
}
|
|
1872
|
+
for (const [e, was] of before.branches) {
|
|
1873
|
+
const now = after.branches.get(e);
|
|
1874
|
+
if (!now || !after.events.has(e)) continue;
|
|
1875
|
+
if (now.some(ALL)) continue;
|
|
1876
|
+
const lostDefault = was.some((b) => DEFAULT_BRANCH.test(b) && !now.includes(b));
|
|
1877
|
+
const replaced = was.length > 0 && !was.some((b) => now.includes(b));
|
|
1878
|
+
if (lostDefault || replaced) out2.push(`on.${e}.branches no longer names ${was.filter((b) => !now.includes(b)).join(", ")} (now [${now.join(", ")}])`);
|
|
1879
|
+
}
|
|
1880
|
+
return out2;
|
|
1296
1881
|
}
|
|
1297
1882
|
var ciTampering = {
|
|
1298
1883
|
id: RULE5,
|
|
@@ -1314,9 +1899,14 @@ var ciTampering = {
|
|
|
1314
1899
|
continue;
|
|
1315
1900
|
}
|
|
1316
1901
|
if (!isProtected(c.path, policy, "ci")) continue;
|
|
1902
|
+
const afterLines = c.after != null ? c.after.split("\n") : null;
|
|
1903
|
+
const addedAt = new Set(addedLines(c).map((l) => l.newLine).filter((n) => n != null));
|
|
1317
1904
|
for (const l of addedLines(c)) {
|
|
1318
1905
|
const coe = l.content.match(/^\s*-?\s*continue-on-error:\s*(.+?)\s*$/);
|
|
1319
1906
|
const cond = l.content.match(/^\s*-?\s*if:\s*(.+?)\s*$/);
|
|
1907
|
+
if (!coe && !cond) continue;
|
|
1908
|
+
const step = afterLines && l.newLine != null ? stepOf(afterLines, l.newLine - 1) : null;
|
|
1909
|
+
if (afterLines && step && !stepHasCheck(afterLines, step)) continue;
|
|
1320
1910
|
if (coe && isTruthy(coe[1])) {
|
|
1321
1911
|
out2.push(
|
|
1322
1912
|
makeFinding(RULE5, policy, {
|
|
@@ -1339,15 +1929,72 @@ var ciTampering = {
|
|
|
1339
1929
|
);
|
|
1340
1930
|
}
|
|
1341
1931
|
}
|
|
1342
|
-
|
|
1343
|
-
(
|
|
1344
|
-
|
|
1932
|
+
if (afterLines) {
|
|
1933
|
+
for (const b of runBlocks(afterLines)) {
|
|
1934
|
+
if (!b.body.some(invokesCheck)) continue;
|
|
1935
|
+
const propagates = b.body.some((l) => /\bexit\s+"?\$/.test(l));
|
|
1936
|
+
for (let k = 0; k < b.body.length; k++) {
|
|
1937
|
+
const idx = b.start + 1 + k;
|
|
1938
|
+
if (!addedAt.has(idx + 1)) continue;
|
|
1939
|
+
const n = NEUTRALISERS.find((x) => x.re.test(b.body[k]));
|
|
1940
|
+
if (!n || propagates && /set\s\+e/.test(b.body[k])) continue;
|
|
1941
|
+
out2.push(
|
|
1942
|
+
makeFinding(RULE5, policy, {
|
|
1943
|
+
file: c.path,
|
|
1944
|
+
line: idx + 1,
|
|
1945
|
+
message: `A check's run block was neutralised: ${n.what}.`,
|
|
1946
|
+
evidence: b.body[k].trim(),
|
|
1947
|
+
remediation: "Let the check decide the step. Masking its exit status is the same tamper as removing it."
|
|
1948
|
+
})
|
|
1949
|
+
);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
for (const l of addedLines(c)) {
|
|
1953
|
+
if (!/^\s*-?\s*shell:\s*.*\{0\}/.test(l.content) || l.newLine == null) continue;
|
|
1954
|
+
const step = stepOf(afterLines, l.newLine - 1);
|
|
1955
|
+
if (step && !stepHasCheck(afterLines, step)) continue;
|
|
1956
|
+
out2.push(
|
|
1957
|
+
makeFinding(RULE5, policy, {
|
|
1958
|
+
file: c.path,
|
|
1959
|
+
line: l.newLine,
|
|
1960
|
+
message: "shell: \u2026 {0} added on a check step \u2014 the shell no longer stops on the first failure.",
|
|
1961
|
+
evidence: l.content.trim(),
|
|
1962
|
+
remediation: "Keep the default shell (fail-fast) on steps that run checks."
|
|
1963
|
+
})
|
|
1964
|
+
);
|
|
1965
|
+
}
|
|
1966
|
+
if (c.before != null) {
|
|
1967
|
+
for (const reason of triggerNarrowings(parseTriggers(c.before), parseTriggers(c.after))) {
|
|
1968
|
+
out2.push(
|
|
1969
|
+
makeFinding(RULE5, policy, {
|
|
1970
|
+
file: c.path,
|
|
1971
|
+
message: `The workflow's triggers were narrowed: ${reason}.`,
|
|
1972
|
+
evidence: reason,
|
|
1973
|
+
remediation: "Keep the workflow running on push and pull_request for the default branch; a check that never runs is no check."
|
|
1974
|
+
})
|
|
1975
|
+
);
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
const afterCores = (afterLines ?? addedLines(c).map((l) => l.content)).map(commandCore);
|
|
1345
1980
|
for (const l of removedLines(c)) {
|
|
1346
1981
|
if (/^\s*#/.test(l.content)) continue;
|
|
1347
1982
|
const isUsesLine = USES.test(l.content) && CHECK.test(l.content);
|
|
1348
1983
|
const isCheckCommand = !isUsesLine && !/^\s*-?\s*uses:/.test(l.content) && (YAML_KEY.test(l.content) ? /^\s*-?\s*run:/.test(l.content) : true) && invokesCheck(l.content);
|
|
1349
1984
|
if (!isUsesLine && !isCheckCommand) continue;
|
|
1350
|
-
|
|
1985
|
+
const s = survives(commandCore(l.content), isUsesLine, afterCores);
|
|
1986
|
+
if (s.state === "kept") continue;
|
|
1987
|
+
if (s.state === "neutralised") {
|
|
1988
|
+
out2.push(
|
|
1989
|
+
makeFinding(RULE5, policy, {
|
|
1990
|
+
file: c.path,
|
|
1991
|
+
message: "A CI check command was neutralised in place: it still runs, but its result no longer decides (or its suite was narrowed).",
|
|
1992
|
+
evidence: `${l.content.trim()} \u2192 ${s.by}`,
|
|
1993
|
+
remediation: "Let the check decide the step; do not mask its status or narrow what it runs."
|
|
1994
|
+
})
|
|
1995
|
+
);
|
|
1996
|
+
continue;
|
|
1997
|
+
}
|
|
1351
1998
|
out2.push(
|
|
1352
1999
|
makeFinding(RULE5, policy, {
|
|
1353
2000
|
file: c.path,
|
|
@@ -1364,7 +2011,7 @@ var ciTampering = {
|
|
|
1364
2011
|
|
|
1365
2012
|
// src/detectors/hook-tampering.ts
|
|
1366
2013
|
import { existsSync, readFileSync as readFileSync2 } from "node:fs";
|
|
1367
|
-
import { join as join2 } from "node:path";
|
|
2014
|
+
import { isAbsolute, join as join2, relative } from "node:path";
|
|
1368
2015
|
|
|
1369
2016
|
// src/detectors/policy-diff.ts
|
|
1370
2017
|
import { parse } from "yaml";
|
|
@@ -1874,9 +2521,22 @@ function chmodDropsExec(toks) {
|
|
|
1874
2521
|
return false;
|
|
1875
2522
|
}
|
|
1876
2523
|
var WRAPPERS = /^(?:sudo|command|exec|time|nice|env|[A-Za-z_][A-Za-z0-9_]*=\S*)$/;
|
|
2524
|
+
var REDIRECT_TARGET = /(?:^|\s)\d*>{1,2}\|?\s*(\S+)/g;
|
|
2525
|
+
var hookPath = (t) => t.replace(/^["']|["']$/g, "").replace(/^\.\//, "");
|
|
2526
|
+
function shellHookTarget(seg, toks, policy) {
|
|
2527
|
+
for (const m of seg.matchAll(REDIRECT_TARGET)) {
|
|
2528
|
+
const t = hookPath(m[1]);
|
|
2529
|
+
if (isProtected(t, policy, "hooks")) return t;
|
|
2530
|
+
}
|
|
2531
|
+
for (const tok of toks) {
|
|
2532
|
+
const t = hookPath(tok.replace(/^of=/, ""));
|
|
2533
|
+
if (isProtected(t, policy, "hooks")) return t;
|
|
2534
|
+
}
|
|
2535
|
+
return null;
|
|
2536
|
+
}
|
|
1877
2537
|
function shellWritesHook(seg, toks, policy) {
|
|
1878
2538
|
const hook = (t) => isProtected(t.replace(/^\.\//, ""), policy, "hooks");
|
|
1879
|
-
for (const m of seg.matchAll(
|
|
2539
|
+
for (const m of seg.matchAll(REDIRECT_TARGET)) {
|
|
1880
2540
|
if (hook(m[1].replace(/^["']|["']$/g, ""))) return "a redirect empties or rewrites a protected hook";
|
|
1881
2541
|
}
|
|
1882
2542
|
let at = 0;
|
|
@@ -2087,14 +2747,24 @@ function configFindings(c, policy) {
|
|
|
2087
2747
|
function updateIndexHiding(toks, policy) {
|
|
2088
2748
|
if (!toks.some((t) => t === "git" || t.endsWith("/git")) || !toks.includes("update-index")) return null;
|
|
2089
2749
|
const hide = toks.find((t) => /^--(?:skip-worktree|assume-unchanged)$/.test(t));
|
|
2090
|
-
|
|
2091
|
-
|
|
2750
|
+
const hidden = hide ? toks.find((t) => isProtected(t, policy)) : void 0;
|
|
2751
|
+
if (hide && hidden) {
|
|
2752
|
+
return {
|
|
2753
|
+
reason: `git update-index ${hide} hides a protected file from every git diff \u2014 edits to it no longer reach the sweep or the pre-commit gate`,
|
|
2754
|
+
file: hidden
|
|
2755
|
+
};
|
|
2092
2756
|
}
|
|
2093
|
-
|
|
2094
|
-
|
|
2757
|
+
const hook = toks.some((t) => /^--chmod=-x$/.test(t)) ? toks.find((t) => isProtected(t, policy, "hooks")) : void 0;
|
|
2758
|
+
if (hook) {
|
|
2759
|
+
return { reason: "git update-index --chmod=-x clears the execute bit on a protected hook through the index, which disables it", file: hook };
|
|
2095
2760
|
}
|
|
2096
2761
|
return null;
|
|
2097
2762
|
}
|
|
2763
|
+
function repoRelative(path, ctx) {
|
|
2764
|
+
if (!ctx?.cwd || !isAbsolute(path)) return path;
|
|
2765
|
+
const rel = relative(ctx.cwd, path);
|
|
2766
|
+
return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : path;
|
|
2767
|
+
}
|
|
2098
2768
|
var hookTampering = {
|
|
2099
2769
|
id: RULE6,
|
|
2100
2770
|
surface: ["file", "command"],
|
|
@@ -2244,7 +2914,8 @@ var hookTampering = {
|
|
|
2244
2914
|
if (hiding) {
|
|
2245
2915
|
out2.push(
|
|
2246
2916
|
makeFinding(RULE6, policy, {
|
|
2247
|
-
|
|
2917
|
+
file: repoRelative(hiding.file, ctx),
|
|
2918
|
+
message: `Hook tampering via shell: ${hiding.reason}.`,
|
|
2248
2919
|
evidence: seg,
|
|
2249
2920
|
remediation: "Leave protected files visible to git. Hiding one from the diff hides it from the gate, which is the tamper \u2014 use `--no-skip-worktree` / `--no-assume-unchanged` to undo it."
|
|
2250
2921
|
})
|
|
@@ -2253,8 +2924,10 @@ var hookTampering = {
|
|
|
2253
2924
|
}
|
|
2254
2925
|
const why = shellWritesHook(seg, toks, policy);
|
|
2255
2926
|
if (why) {
|
|
2927
|
+
const target = shellHookTarget(seg, toks, policy);
|
|
2256
2928
|
out2.push(
|
|
2257
2929
|
makeFinding(RULE6, policy, {
|
|
2930
|
+
...target ? { file: repoRelative(target, ctx) } : {},
|
|
2258
2931
|
message: `Hook tampering via shell: ${why}.`,
|
|
2259
2932
|
evidence: seg,
|
|
2260
2933
|
remediation: "Leave the hooks in place. Mutating them from the shell is still tampering."
|
|
@@ -2269,44 +2942,215 @@ var hookTampering = {
|
|
|
2269
2942
|
};
|
|
2270
2943
|
|
|
2271
2944
|
// src/detectors/test-deletion.ts
|
|
2945
|
+
import ts4 from "typescript";
|
|
2946
|
+
|
|
2947
|
+
// src/detectors/suite-config.ts
|
|
2948
|
+
import picomatch3 from "picomatch";
|
|
2272
2949
|
import ts3 from "typescript";
|
|
2950
|
+
var JEST_DEFAULT_MATCH = ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)"];
|
|
2951
|
+
var JEST_DEFAULT_IGNORE = ["/node_modules/"];
|
|
2952
|
+
var VITEST_DEFAULT_INCLUDE = ["**/*.{test,spec}.?(c|m)[jt]s?(x)"];
|
|
2953
|
+
var VITEST_DEFAULT_EXCLUDE = [
|
|
2954
|
+
"**/node_modules/**",
|
|
2955
|
+
"**/dist/**",
|
|
2956
|
+
"**/cypress/**",
|
|
2957
|
+
"**/.{idea,git,cache,output,temp}/**",
|
|
2958
|
+
"**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*"
|
|
2959
|
+
];
|
|
2960
|
+
var CANONICAL_SAMPLES = [
|
|
2961
|
+
"src/a.test.ts",
|
|
2962
|
+
"src/a.spec.ts",
|
|
2963
|
+
"test/a.test.ts",
|
|
2964
|
+
"tests/a.test.js",
|
|
2965
|
+
"src/__tests__/a.ts",
|
|
2966
|
+
"a.test.tsx",
|
|
2967
|
+
"a.spec.js",
|
|
2968
|
+
"test/a.test.mjs"
|
|
2969
|
+
];
|
|
2970
|
+
function keyName2(name) {
|
|
2971
|
+
if (ts3.isIdentifier(name) || ts3.isStringLiteral(name)) return name.text;
|
|
2972
|
+
return null;
|
|
2973
|
+
}
|
|
2974
|
+
function literals2(e) {
|
|
2975
|
+
if (ts3.isStringLiteral(e) || ts3.isNoSubstitutionTemplateLiteral(e)) return { items: [e.text], opaque: false };
|
|
2976
|
+
if (!ts3.isArrayLiteralExpression(e)) return { items: [], opaque: true };
|
|
2977
|
+
const items = [];
|
|
2978
|
+
let opaque = false;
|
|
2979
|
+
for (const el of e.elements) {
|
|
2980
|
+
if (ts3.isStringLiteral(el) || ts3.isNoSubstitutionTemplateLiteral(el)) items.push(el.text);
|
|
2981
|
+
else if (ts3.isSpreadElement(el) && /^(?:configDefaults\.|default)(?:exclude|include)$/i.test(el.expression.getText())) {
|
|
2982
|
+
items.push(.../exclude$/i.test(el.expression.getText()) ? VITEST_DEFAULT_EXCLUDE : VITEST_DEFAULT_INCLUDE);
|
|
2983
|
+
} else opaque = true;
|
|
2984
|
+
}
|
|
2985
|
+
return { items, opaque };
|
|
2986
|
+
}
|
|
2987
|
+
function underKey2(node, key2) {
|
|
2988
|
+
for (let p = node.parent; p; p = p.parent) {
|
|
2989
|
+
if (ts3.isPropertyAssignment(p) && keyName2(p.name) === key2) return true;
|
|
2990
|
+
}
|
|
2991
|
+
return false;
|
|
2992
|
+
}
|
|
2993
|
+
function asExpression2(src) {
|
|
2994
|
+
const t = src.trim();
|
|
2995
|
+
return t.startsWith("{") ? `(${t})` : src;
|
|
2996
|
+
}
|
|
2997
|
+
function runnerOf(path) {
|
|
2998
|
+
const base = path.split("/").pop() ?? path;
|
|
2999
|
+
if (/^jest\.config\./.test(base) || base === "package.json") return "jest";
|
|
3000
|
+
if (/^vitest\.config\./.test(base)) return "vitest";
|
|
3001
|
+
return null;
|
|
3002
|
+
}
|
|
3003
|
+
function parseSelection(src, runner) {
|
|
3004
|
+
const sel = { include: null, regex: null, ignore: null, opaque: false, present: false };
|
|
3005
|
+
try {
|
|
3006
|
+
const sf = ts3.createSourceFile("cfg.ts", asExpression2(src), ts3.ScriptTarget.Latest, true, ts3.ScriptKind.TS);
|
|
3007
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3008
|
+
const take = (key2, e) => {
|
|
3009
|
+
const { items, opaque } = literals2(e);
|
|
3010
|
+
if (opaque || seen.has(key2)) sel.opaque = true;
|
|
3011
|
+
seen.add(key2);
|
|
3012
|
+
sel.present = true;
|
|
3013
|
+
return items.map((g) => g.replace(/^<rootDir>\//, ""));
|
|
3014
|
+
};
|
|
3015
|
+
const visit = (node) => {
|
|
3016
|
+
if (ts3.isPropertyAssignment(node)) {
|
|
3017
|
+
const k = keyName2(node.name);
|
|
3018
|
+
if (runner === "jest") {
|
|
3019
|
+
if (k === "testMatch") sel.include = take(k, node.initializer);
|
|
3020
|
+
else if (k === "testRegex") sel.regex = take(k, node.initializer);
|
|
3021
|
+
else if (k === "testPathIgnorePatterns") sel.ignore = take(k, node.initializer);
|
|
3022
|
+
} else if (underKey2(node, "test") && !underKey2(node, "coverage")) {
|
|
3023
|
+
if (k === "include") sel.include = take(k, node.initializer);
|
|
3024
|
+
else if (k === "exclude") sel.ignore = take(k, node.initializer);
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
ts3.forEachChild(node, visit);
|
|
3028
|
+
};
|
|
3029
|
+
visit(sf);
|
|
3030
|
+
} catch {
|
|
3031
|
+
}
|
|
3032
|
+
return sel;
|
|
3033
|
+
}
|
|
3034
|
+
var globs2 = (patterns) => {
|
|
3035
|
+
const ms = patterns.map((g) => {
|
|
3036
|
+
try {
|
|
3037
|
+
return picomatch3(g, { dot: true });
|
|
3038
|
+
} catch {
|
|
3039
|
+
return () => false;
|
|
3040
|
+
}
|
|
3041
|
+
});
|
|
3042
|
+
return (path) => ms.some((m) => m(path));
|
|
3043
|
+
};
|
|
3044
|
+
var regexes = (patterns) => {
|
|
3045
|
+
const rs = patterns.flatMap((p) => {
|
|
3046
|
+
try {
|
|
3047
|
+
return [new RegExp(p.replace(/<rootDir>/g, ""))];
|
|
3048
|
+
} catch {
|
|
3049
|
+
return [];
|
|
3050
|
+
}
|
|
3051
|
+
});
|
|
3052
|
+
return (path) => rs.some((r) => r.test("/" + path));
|
|
3053
|
+
};
|
|
3054
|
+
function predicate(sel, runner) {
|
|
3055
|
+
const includeList = sel.include ?? (runner === "jest" ? sel.regex ? null : JEST_DEFAULT_MATCH : VITEST_DEFAULT_INCLUDE);
|
|
3056
|
+
const ignoreList = sel.ignore ?? (runner === "jest" ? JEST_DEFAULT_IGNORE : VITEST_DEFAULT_EXCLUDE);
|
|
3057
|
+
const included = includeList ? globs2(includeList) : sel.regex ? regexes(sel.regex) : () => true;
|
|
3058
|
+
const ignored = runner === "jest" ? regexes(ignoreList) : globs2(ignoreList);
|
|
3059
|
+
return {
|
|
3060
|
+
selects: (p) => included(p) && !ignored(p),
|
|
3061
|
+
why: (p) => {
|
|
3062
|
+
if (ignored(p)) {
|
|
3063
|
+
const key3 = runner === "jest" ? "testPathIgnorePatterns" : "test.exclude";
|
|
3064
|
+
const hit = ignoreList.find((g) => (runner === "jest" ? regexes([g]) : globs2([g]))(p));
|
|
3065
|
+
return `${key3} now matches it (${JSON.stringify(hit ?? "")})`;
|
|
3066
|
+
}
|
|
3067
|
+
const key2 = sel.include ? runner === "jest" ? "testMatch" : "test.include" : "testRegex";
|
|
3068
|
+
return `${key2} no longer matches it (${JSON.stringify(sel.include ?? sel.regex ?? [])})`;
|
|
3069
|
+
}
|
|
3070
|
+
};
|
|
3071
|
+
}
|
|
3072
|
+
function suiteNarrowings(before, after, path, samples) {
|
|
3073
|
+
const runner = runnerOf(path);
|
|
3074
|
+
if (!runner) return [];
|
|
3075
|
+
const b = parseSelection(before ?? "", runner);
|
|
3076
|
+
const a = parseSelection(after, runner);
|
|
3077
|
+
if (!a.present || a.opaque) return [];
|
|
3078
|
+
const pb = predicate(b, runner);
|
|
3079
|
+
const pa = predicate(a, runner);
|
|
3080
|
+
const out2 = [];
|
|
3081
|
+
for (const s of samples) {
|
|
3082
|
+
if (pb.selects(s) && !pa.selects(s)) out2.push({ path: s, reason: pa.why(s) });
|
|
3083
|
+
}
|
|
3084
|
+
return out2;
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
// src/detectors/test-deletion.ts
|
|
2273
3088
|
var RULE7 = "test-deletion";
|
|
3089
|
+
var JVM_TEST = /^\s*(?:@\w+(?:\([^)]*\))?\s+)*@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/;
|
|
2274
3090
|
var TEST_DEFS = {
|
|
2275
3091
|
py: /^\s*(?:async\s+)?def\s+test\w*\s*\(/,
|
|
2276
3092
|
go: /^func\s+(?:Test|Example|Fuzz)\w*\s*\(/,
|
|
2277
3093
|
rs: /^\s*#\[[\w:]*test[\w:]*(?:\(|\])/,
|
|
2278
3094
|
rb: /^\s*(?:(?:it|specify|test|scenario|example)\s*(?:\(\s*)?['"]|def\s+test_\w+)/,
|
|
2279
|
-
java:
|
|
2280
|
-
kt:
|
|
3095
|
+
java: JVM_TEST,
|
|
3096
|
+
kt: JVM_TEST,
|
|
2281
3097
|
php: /^\s*(?:(?:public\s+)?function\s+test\w*\s*\(|#\[Test\]|\*\s*@test\b)/,
|
|
2282
3098
|
cs: /^\s*\[(?:Fact|Theory|Test|TestMethod|TestCase|DataTestMethod)\b/
|
|
2283
3099
|
};
|
|
2284
3100
|
function calleeName(expr) {
|
|
2285
|
-
if (
|
|
2286
|
-
if (
|
|
3101
|
+
if (ts4.isIdentifier(expr)) return expr.text;
|
|
3102
|
+
if (ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression)) {
|
|
2287
3103
|
return expr.expression.text;
|
|
2288
3104
|
}
|
|
2289
3105
|
return null;
|
|
2290
3106
|
}
|
|
2291
|
-
|
|
3107
|
+
var isEachOf = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && (expr.expression.text === "it" || expr.expression.text === "test") && expr.name.text === "each";
|
|
3108
|
+
function eachRows(call) {
|
|
3109
|
+
const arg = call.arguments[0];
|
|
3110
|
+
if (!arg) return { n: 1, open: true };
|
|
3111
|
+
if (ts4.isArrayLiteralExpression(arg)) {
|
|
3112
|
+
let n = 0;
|
|
3113
|
+
let open = false;
|
|
3114
|
+
for (const el of arg.elements) {
|
|
3115
|
+
if (ts4.isSpreadElement(el)) open = true;
|
|
3116
|
+
else n++;
|
|
3117
|
+
}
|
|
3118
|
+
return { n: Math.max(n, open ? 0 : 1), open };
|
|
3119
|
+
}
|
|
3120
|
+
return { n: 1, open: true };
|
|
3121
|
+
}
|
|
3122
|
+
function countTests(src, path = "spec.ts") {
|
|
2292
3123
|
const lang = langOf(path);
|
|
2293
3124
|
if (lang && lang !== "js") {
|
|
2294
3125
|
const re = TEST_DEFS[lang];
|
|
2295
3126
|
let n2 = 0;
|
|
2296
3127
|
for (const line of src.split("\n")) if (re.test(line)) n2++;
|
|
2297
|
-
return n2;
|
|
3128
|
+
return { min: n2, open: false };
|
|
2298
3129
|
}
|
|
2299
|
-
const sf =
|
|
3130
|
+
const sf = ts4.createSourceFile("spec.ts", src, ts4.ScriptTarget.Latest, true, ts4.ScriptKind.TS);
|
|
2300
3131
|
let n = 0;
|
|
3132
|
+
let open = false;
|
|
2301
3133
|
const visit = (node) => {
|
|
2302
|
-
if (
|
|
2303
|
-
|
|
2304
|
-
|
|
3134
|
+
if (ts4.isCallExpression(node)) {
|
|
3135
|
+
if (isEachOf(node.expression)) {
|
|
3136
|
+
const r = eachRows(node);
|
|
3137
|
+
n += r.n;
|
|
3138
|
+
if (r.open) open = true;
|
|
3139
|
+
} else {
|
|
3140
|
+
const name = calleeName(node.expression);
|
|
3141
|
+
if (name === "it" || name === "test") n++;
|
|
3142
|
+
}
|
|
3143
|
+
} else if (ts4.isTaggedTemplateExpression(node) && isEachOf(node.tag)) {
|
|
3144
|
+
const rows = node.template.getText().split("\n").filter((l) => /\S/.test(l.replace(/[`]/g, ""))).length - 1;
|
|
3145
|
+
n += Math.max(rows, 1);
|
|
2305
3146
|
}
|
|
2306
|
-
|
|
3147
|
+
ts4.forEachChild(node, visit);
|
|
2307
3148
|
};
|
|
2308
3149
|
visit(sf);
|
|
2309
|
-
return n;
|
|
3150
|
+
return { min: n, open };
|
|
3151
|
+
}
|
|
3152
|
+
function countTestBlocks(src, path = "spec.ts") {
|
|
3153
|
+
return countTests(src, path).min;
|
|
2310
3154
|
}
|
|
2311
3155
|
function significantLines(src, path) {
|
|
2312
3156
|
const out2 = /* @__PURE__ */ new Set();
|
|
@@ -2317,32 +3161,63 @@ function significantLines(src, path) {
|
|
|
2317
3161
|
}
|
|
2318
3162
|
return out2;
|
|
2319
3163
|
}
|
|
3164
|
+
var OTHER_RUNNER_DIR = /(?:^|\/)(?:e2e|cypress|playwright)\//;
|
|
3165
|
+
function runnerSamples(configPath, policy, ctx) {
|
|
3166
|
+
const files = trackedFiles(ctx);
|
|
3167
|
+
if (!files) return CANONICAL_SAMPLES;
|
|
3168
|
+
const root = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/") + 1) : "";
|
|
3169
|
+
const own = files.filter((f) => f.startsWith(root) && langOf(f) === "js" && isProtected(f, policy, "tests") && !OTHER_RUNNER_DIR.test(f)).map((f) => f.slice(root.length));
|
|
3170
|
+
return own.length ? own : CANONICAL_SAMPLES;
|
|
3171
|
+
}
|
|
3172
|
+
function joinRoot(root, to) {
|
|
3173
|
+
const parts = root ? root.split("/") : [];
|
|
3174
|
+
for (const seg of to.replace(/\/+$/, "").split("/")) {
|
|
3175
|
+
if (seg === "" || seg === ".") continue;
|
|
3176
|
+
if (seg === "..") {
|
|
3177
|
+
if (parts.length === 0) return null;
|
|
3178
|
+
parts.pop();
|
|
3179
|
+
} else parts.push(seg);
|
|
3180
|
+
}
|
|
3181
|
+
return parts.join("/");
|
|
3182
|
+
}
|
|
3183
|
+
function leavesGlob(dir, dest, policy, ctx) {
|
|
3184
|
+
const files = trackedFiles(ctx);
|
|
3185
|
+
if (!files || !dir) return false;
|
|
3186
|
+
const src = dir.replace(/^\.\//, "").replace(/\/+$/, "") + "/";
|
|
3187
|
+
const to = dest.replace(/^\.\//, "").replace(/\/+$/, "") + "/";
|
|
3188
|
+
return files.some((f) => f.startsWith(src) && isProtected(f, policy, "tests") && !isProtected(to + f.slice(src.length), policy, "tests"));
|
|
3189
|
+
}
|
|
3190
|
+
var SED_INPLACE = /(?:^|\s)(?:-i\b|--in-place\b|-[a-zA-Z]*i[a-zA-Z]*\b)/;
|
|
2320
3191
|
var testDeletion = {
|
|
2321
3192
|
id: RULE7,
|
|
2322
3193
|
surface: ["file", "command"],
|
|
2323
3194
|
certainty: "mechanical",
|
|
2324
|
-
run(changes, policy) {
|
|
3195
|
+
run(changes, policy, _view, ctx) {
|
|
2325
3196
|
const out2 = [];
|
|
2326
3197
|
const addedTestLines = /* @__PURE__ */ new Set();
|
|
2327
3198
|
let addedTestBlocks = 0;
|
|
2328
3199
|
for (const c of changes) {
|
|
2329
|
-
if (c.kind
|
|
3200
|
+
if (c.kind !== "file" || c.after == null || !isProtected(c.path, policy, "tests")) continue;
|
|
3201
|
+
if (c.op === "add") {
|
|
2330
3202
|
for (const l of significantLines(c.after, c.path)) addedTestLines.add(l);
|
|
2331
3203
|
addedTestBlocks += countTestBlocks(c.after, c.path);
|
|
3204
|
+
} else if (c.before != null) {
|
|
3205
|
+
const before = significantLines(c.before, c.path);
|
|
3206
|
+
for (const l of significantLines(c.after, c.path)) if (!before.has(l)) addedTestLines.add(l);
|
|
3207
|
+
addedTestBlocks += Math.max(0, countTestBlocks(c.after, c.path) - countTestBlocks(c.before, c.path));
|
|
2332
3208
|
}
|
|
2333
3209
|
}
|
|
2334
|
-
const isRelocation = (
|
|
2335
|
-
|
|
2336
|
-
if (sig.size === 0 || addedTestLines.size === 0) return false;
|
|
3210
|
+
const isRelocation = (removed, blocksRemoved) => {
|
|
3211
|
+
if (removed.size === 0 || addedTestLines.size === 0) return false;
|
|
2337
3212
|
let hit = 0;
|
|
2338
|
-
for (const l of
|
|
2339
|
-
return hit /
|
|
3213
|
+
for (const l of removed) if (addedTestLines.has(l)) hit++;
|
|
3214
|
+
return hit / removed.size >= 0.6 && addedTestBlocks >= blocksRemoved;
|
|
2340
3215
|
};
|
|
2341
3216
|
for (const c of changes) {
|
|
2342
3217
|
if (c.kind === "file") {
|
|
2343
3218
|
const isTest = isProtected(c.path, policy, "tests");
|
|
2344
3219
|
if (c.op === "delete" && isTest) {
|
|
2345
|
-
if (c.before != null && isRelocation(c.before, c.path)) continue;
|
|
3220
|
+
if (c.before != null && isRelocation(significantLines(c.before, c.path), countTestBlocks(c.before, c.path))) continue;
|
|
2346
3221
|
out2.push(
|
|
2347
3222
|
makeFinding(RULE7, policy, {
|
|
2348
3223
|
file: c.path,
|
|
@@ -2361,41 +3236,95 @@ var testDeletion = {
|
|
|
2361
3236
|
})
|
|
2362
3237
|
);
|
|
2363
3238
|
} else if ((c.op === "modify" || c.op === "rename") && isTest && c.before != null && c.after != null) {
|
|
2364
|
-
const before =
|
|
2365
|
-
const after =
|
|
2366
|
-
if (after < before) {
|
|
3239
|
+
const before = countTests(c.before, c.path);
|
|
3240
|
+
const after = countTests(c.after, c.path);
|
|
3241
|
+
if (after.min < before.min && !after.open) {
|
|
3242
|
+
const afterLines = significantLines(c.after, c.path);
|
|
3243
|
+
const removed = new Set([...significantLines(c.before, c.path)].filter((l) => !afterLines.has(l)));
|
|
3244
|
+
if (isRelocation(removed, before.min - after.min)) continue;
|
|
2367
3245
|
out2.push(
|
|
2368
3246
|
makeFinding(RULE7, policy, {
|
|
2369
3247
|
file: c.path,
|
|
2370
|
-
message: `Test blocks removed: ${before} \u2192 ${after} it()/test() in this spec.`,
|
|
2371
|
-
evidence: `${before - after} test block(s) removed from ${c.path}`,
|
|
3248
|
+
message: `Test blocks removed: ${before.min} \u2192 ${after.min} it()/test() in this spec (each-table rows count as tests).`,
|
|
3249
|
+
evidence: `${before.min - after.min} test block(s) removed from ${c.path}`,
|
|
2372
3250
|
remediation: "Keep the assertions and fix the code. Removing test blocks to go green is the tamper."
|
|
2373
3251
|
})
|
|
2374
3252
|
);
|
|
2375
3253
|
}
|
|
3254
|
+
} else if (c.op !== "delete" && c.after != null && !isTest && isProtected(c.path, policy, "config") && runnerOf(c.path)) {
|
|
3255
|
+
for (const n of suiteNarrowings(c.before, c.after, c.path, runnerSamples(c.path, policy, ctx))) {
|
|
3256
|
+
out2.push(
|
|
3257
|
+
makeFinding(RULE7, policy, {
|
|
3258
|
+
file: c.path,
|
|
3259
|
+
message: `The runner no longer selects ${n.path}: ${n.reason}.`,
|
|
3260
|
+
evidence: `${n.path} dropped from the suite by ${c.path}`,
|
|
3261
|
+
remediation: "Keep the runner pointed at the whole suite. Excluding a spec from the runner removes its tests without touching the file."
|
|
3262
|
+
})
|
|
3263
|
+
);
|
|
3264
|
+
}
|
|
2376
3265
|
}
|
|
2377
3266
|
} else {
|
|
3267
|
+
let root = "";
|
|
2378
3268
|
for (const seg of segments(c.raw)) {
|
|
2379
3269
|
const toks = tokens(seg).map(unquote);
|
|
2380
|
-
const
|
|
2381
|
-
if (
|
|
3270
|
+
const cdTo = toks[0] === "cd" ? toks[1] : null;
|
|
3271
|
+
if (cdTo !== null) {
|
|
3272
|
+
root = cdTo === void 0 || /^[/~$]/.test(cdTo) || root === null ? null : joinRoot(root, cdTo);
|
|
3273
|
+
continue;
|
|
3274
|
+
}
|
|
3275
|
+
const inRepo = (t) => root ? `${root}/${t}` : t;
|
|
3276
|
+
const listing = trackedFiles(ctx);
|
|
3277
|
+
const testToks = toks.filter((t) => {
|
|
3278
|
+
if (!isProtected(t, policy, "tests")) return false;
|
|
3279
|
+
if (!listing || root === null) return true;
|
|
3280
|
+
const looksLikeFile = /\.[A-Za-z0-9]+$/.test(t.split("/").pop() ?? "");
|
|
3281
|
+
if (looksLikeFile || listing.includes(inRepo(t))) return true;
|
|
3282
|
+
return containsProtected(inRepo(t), policy, "tests", ctx);
|
|
3283
|
+
});
|
|
3284
|
+
const dirToks = toks.filter(
|
|
3285
|
+
(t) => !t.startsWith("-") && !testToks.includes(t) && !/[*?{}[\]]/.test(t) && (root === null ? containsProtected(t, policy, "tests", ctx) : containsProtected(inRepo(t), policy, "tests", ctx))
|
|
3286
|
+
);
|
|
3287
|
+
if (testToks.length === 0 && dirToks.length === 0) continue;
|
|
3288
|
+
const named = [...testToks, ...dirToks];
|
|
3289
|
+
const cmd = toks[0] ?? "";
|
|
2382
3290
|
let why = null;
|
|
2383
3291
|
let evidence = seg;
|
|
2384
3292
|
const redirectTarget = seg.match(/>\s*(\S+)/)?.[1];
|
|
2385
3293
|
const redirectsOntoTest = redirectTarget ? isProtected(unquote(redirectTarget), policy, "tests") : false;
|
|
2386
|
-
|
|
3294
|
+
const findDeletes = cmd === "find" && /\s(?:-delete\b|-exec\s+(?:rm|unlink)\b|-execdir\s+(?:rm|unlink)\b)/.test(seg);
|
|
3295
|
+
const gitRestoresOld = cmd === "git" && /^git\s+(?:checkout|restore)\b/.test(seg) && (() => {
|
|
3296
|
+
const src = seg.match(/--source[= ]\s*(\S+)/)?.[1];
|
|
3297
|
+
const rev = src ?? toks.slice(2).find((t) => !t.startsWith("-") && !named.includes(t) && t !== "--");
|
|
3298
|
+
return !!rev && rev !== "HEAD" && (!!src || seg.includes(" -- "));
|
|
3299
|
+
})();
|
|
3300
|
+
if (/\brm\b/.test(seg) && !/^(?:git|npm|yarn|pnpm|bun)\b/.test(seg)) {
|
|
2387
3301
|
why = "rm deletes a test file";
|
|
2388
|
-
} else if (
|
|
3302
|
+
} else if (cmd === "git" && /^git\s+rm\b/.test(seg)) {
|
|
3303
|
+
why = "git rm deletes a test file";
|
|
3304
|
+
} else if (cmd === "sed" && SED_INPLACE.test(seg)) {
|
|
2389
3305
|
why = "sed -i rewrites a test file in place";
|
|
3306
|
+
} else if (cmd === "perl" && /(?:^|\s)-[a-zA-Z]*i\b|\s-i\S*/.test(seg)) {
|
|
3307
|
+
why = "perl -i rewrites a test file in place";
|
|
2390
3308
|
} else if (/\btruncate\b/.test(seg)) {
|
|
2391
3309
|
why = "truncate empties a test file";
|
|
2392
3310
|
} else if (redirectsOntoTest) {
|
|
2393
3311
|
why = "a redirect overwrites/empties a test file";
|
|
3312
|
+
} else if (cmd === "cp" && testToks.length > 0 && isProtected(toks[toks.length - 1] ?? "", policy, "tests") && !isProtected(toks[toks.length - 2] ?? "", policy, "tests")) {
|
|
3313
|
+
why = "cp overwrites a test file with something else";
|
|
3314
|
+
} else if (cmd === "tee" && !toks.includes("-a") && !toks.includes("--append")) {
|
|
3315
|
+
why = "tee overwrites a test file";
|
|
3316
|
+
} else if (findDeletes) {
|
|
3317
|
+
why = "find -delete removes test files";
|
|
3318
|
+
} else if (gitRestoresOld) {
|
|
3319
|
+
why = "git checkout <rev> replaces a test file with an older version";
|
|
2394
3320
|
} else if (/\bmv\b/.test(seg)) {
|
|
2395
3321
|
const dest = toks[toks.length - 1];
|
|
2396
|
-
if (dest && !isProtected(dest, policy, "tests")) {
|
|
3322
|
+
if (dest && testToks.length > 0 && !isProtected(dest, policy, "tests")) {
|
|
2397
3323
|
why = "mv renames a test file out of the test glob";
|
|
2398
3324
|
evidence = `${testToks[0]} \u2192 ${dest}`;
|
|
3325
|
+
} else if (dest && testToks.length === 0 && dirToks[0] !== dest && leavesGlob(dirToks[0] ?? "", dest, policy, ctx)) {
|
|
3326
|
+
why = "mv renames a test directory so its specs leave the test glob";
|
|
3327
|
+
evidence = `${dirToks[0]} \u2192 ${dest}`;
|
|
2399
3328
|
}
|
|
2400
3329
|
}
|
|
2401
3330
|
if (why) {
|
|
@@ -2415,9 +3344,40 @@ var testDeletion = {
|
|
|
2415
3344
|
};
|
|
2416
3345
|
|
|
2417
3346
|
// src/detectors/test-content-removal.ts
|
|
3347
|
+
import ts5 from "typescript";
|
|
2418
3348
|
var RULE8 = "test-content-removal";
|
|
2419
3349
|
var MIN_REMOVED_LINES = 3;
|
|
3350
|
+
var MIN_REMOVED_ROWS = 2;
|
|
2420
3351
|
var ws = (s) => s.replace(/\s+/g, "");
|
|
3352
|
+
function eachTables(src, path) {
|
|
3353
|
+
const rows = /* @__PURE__ */ new Set();
|
|
3354
|
+
let open = false;
|
|
3355
|
+
if (langOf(path) !== "js" && langOf(path) !== null) return { rows, open };
|
|
3356
|
+
try {
|
|
3357
|
+
const sf = ts5.createSourceFile("spec.ts", src, ts5.ScriptTarget.Latest, true, ts5.ScriptKind.TS);
|
|
3358
|
+
const visit = (node) => {
|
|
3359
|
+
if (ts5.isCallExpression(node) && ts5.isPropertyAccessExpression(node.expression) && node.expression.name.text === "each" && ts5.isIdentifier(node.expression.expression) && /^(?:it|test|describe)$/.test(node.expression.expression.text)) {
|
|
3360
|
+
const arg = node.arguments[0];
|
|
3361
|
+
if (arg && ts5.isArrayLiteralExpression(arg)) {
|
|
3362
|
+
for (const el of arg.elements) {
|
|
3363
|
+
if (ts5.isSpreadElement(el)) {
|
|
3364
|
+
open = true;
|
|
3365
|
+
continue;
|
|
3366
|
+
}
|
|
3367
|
+
for (const raw of el.getText(sf).split("\n")) {
|
|
3368
|
+
const l = raw.trim().replace(/,$/, "");
|
|
3369
|
+
if (isSignificantLine(l, "js")) rows.add(l);
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
} else open = true;
|
|
3373
|
+
}
|
|
3374
|
+
ts5.forEachChild(node, visit);
|
|
3375
|
+
};
|
|
3376
|
+
visit(sf);
|
|
3377
|
+
} catch {
|
|
3378
|
+
}
|
|
3379
|
+
return { rows, open };
|
|
3380
|
+
}
|
|
2421
3381
|
function significantLinesOrdered(src, path) {
|
|
2422
3382
|
const out2 = [];
|
|
2423
3383
|
const lang = langOf(path);
|
|
@@ -2434,16 +3394,23 @@ var testContentRemoval = {
|
|
|
2434
3394
|
run(changes, policy) {
|
|
2435
3395
|
const out2 = [];
|
|
2436
3396
|
let keptPool = "";
|
|
3397
|
+
let addedPool = "";
|
|
2437
3398
|
for (const c of changes) {
|
|
2438
|
-
if (c.kind
|
|
2439
|
-
|
|
3399
|
+
if (c.kind !== "file" || c.after == null) continue;
|
|
3400
|
+
if (isProtected(c.path, policy, "tests")) keptPool += ws(significantLinesOrdered(c.after, c.path).join("\n"));
|
|
3401
|
+
if (c.op === "add") addedPool += ws(c.after);
|
|
3402
|
+
else if (c.before != null) {
|
|
3403
|
+
const had = new Set(c.before.split("\n").map((l) => l.trim()));
|
|
3404
|
+
addedPool += ws(c.after.split("\n").filter((l) => !had.has(l.trim())).join("\n"));
|
|
2440
3405
|
}
|
|
2441
3406
|
}
|
|
2442
3407
|
for (const c of changes) {
|
|
2443
3408
|
if (c.kind !== "file" || c.op !== "modify" && c.op !== "rename" || c.before == null || c.after == null) continue;
|
|
2444
3409
|
if (!isProtected(c.path, policy, "tests")) continue;
|
|
2445
3410
|
if (isProtected(c.path, policy, "snapshots")) continue;
|
|
2446
|
-
|
|
3411
|
+
const blocksBefore = countTests(c.before, c.path);
|
|
3412
|
+
const blocksAfter = countTests(c.after, c.path);
|
|
3413
|
+
if (blocksAfter.min < blocksBefore.min && !blocksAfter.open) continue;
|
|
2447
3414
|
const beforeSig = significantLinesOrdered(c.before, c.path);
|
|
2448
3415
|
const afterSig = significantLinesOrdered(c.after, c.path);
|
|
2449
3416
|
const netDrop = beforeSig.length - afterSig.length;
|
|
@@ -2454,6 +3421,19 @@ var testContentRemoval = {
|
|
|
2454
3421
|
if (keptPool.includes(ws(line))) continue;
|
|
2455
3422
|
gone.push(line);
|
|
2456
3423
|
}
|
|
3424
|
+
const table = eachTables(c.before, c.path);
|
|
3425
|
+
const goneRows = table.rows.size && eachTables(c.after, c.path).open ? gone.filter((l) => table.rows.has(l.replace(/,$/, "")) && !addedPool.includes(ws(l))) : [];
|
|
3426
|
+
if (goneRows.length >= MIN_REMOVED_ROWS) {
|
|
3427
|
+
out2.push(
|
|
3428
|
+
makeFinding(RULE8, policy, {
|
|
3429
|
+
file: c.path,
|
|
3430
|
+
message: `Rows removed from a test table: ${goneRows.length} case(s) gone from an each-table that now spreads from elsewhere, and they reappear nowhere in the change.`,
|
|
3431
|
+
evidence: goneRows.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
|
|
3432
|
+
remediation: "Fix the code under test. A row of it.each is a test; moving the table out of the spec must carry every row with it."
|
|
3433
|
+
})
|
|
3434
|
+
);
|
|
3435
|
+
continue;
|
|
3436
|
+
}
|
|
2457
3437
|
if (gone.length >= MIN_REMOVED_LINES && netDrop >= MIN_REMOVED_LINES) {
|
|
2458
3438
|
out2.push(
|
|
2459
3439
|
makeFinding(RULE8, policy, {
|
|
@@ -2644,7 +3624,7 @@ function hasBlocking(findings) {
|
|
|
2644
3624
|
|
|
2645
3625
|
// src/policy-load.ts
|
|
2646
3626
|
import { readFileSync as readFileSync3, existsSync as existsSync2 } from "node:fs";
|
|
2647
|
-
import { isAbsolute, join as join3, posix } from "node:path";
|
|
3627
|
+
import { isAbsolute as isAbsolute2, join as join3, posix } from "node:path";
|
|
2648
3628
|
import { parse as parse2 } from "yaml";
|
|
2649
3629
|
var PolicyError = class extends Error {
|
|
2650
3630
|
};
|
|
@@ -2661,7 +3641,7 @@ var isStringList = (v) => Array.isArray(v) && v.every((s) => typeof s === "strin
|
|
|
2661
3641
|
var isMapping = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2662
3642
|
var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "protected", "rules", "ignore", "signoff", "verify"]);
|
|
2663
3643
|
function ledgerInsideRepo(ledger) {
|
|
2664
|
-
if (!ledger.trim() ||
|
|
3644
|
+
if (!ledger.trim() || isAbsolute2(ledger) || /^[A-Za-z]:[\\/]/.test(ledger)) return false;
|
|
2665
3645
|
const norm2 = posix.normalize(ledger.replace(/\\/g, "/"));
|
|
2666
3646
|
return norm2 !== ".." && !norm2.startsWith("../") && !norm2.startsWith("/");
|
|
2667
3647
|
}
|
|
@@ -2692,8 +3672,8 @@ function validate(r, where) {
|
|
|
2692
3672
|
if (r.ignore !== void 0 && !isStringList(r.ignore)) bad(`ignore must be a list of globs, got ${show(r.ignore)}`);
|
|
2693
3673
|
if (r.protected !== void 0) {
|
|
2694
3674
|
if (!isMapping(r.protected)) bad(`protected must be a mapping of category to a list of globs, got ${show(r.protected)}`);
|
|
2695
|
-
for (const [cat,
|
|
2696
|
-
if (!isStringList(
|
|
3675
|
+
for (const [cat, globs3] of Object.entries(r.protected)) {
|
|
3676
|
+
if (!isStringList(globs3)) bad(`protected.${cat} must be a list of globs, got ${show(globs3)}`);
|
|
2697
3677
|
}
|
|
2698
3678
|
}
|
|
2699
3679
|
if (r.signoff !== void 0) {
|
|
@@ -2725,10 +3705,10 @@ function parsePolicy(raw, where = POLICY_FILE) {
|
|
|
2725
3705
|
const version = normalizeVersion(r.version, where);
|
|
2726
3706
|
validate(r, where);
|
|
2727
3707
|
const base = defaultPolicy(version);
|
|
2728
|
-
const
|
|
2729
|
-
const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list]) => [cat,
|
|
3708
|
+
const globs3 = (list) => list?.map(normalizeGlob);
|
|
3709
|
+
const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list]) => [cat, globs3(list) ?? []])) : void 0;
|
|
2730
3710
|
const userRules = r.rules ? Object.fromEntries(
|
|
2731
|
-
Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude:
|
|
3711
|
+
Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude: globs3(cfg.exclude) } : cfg])
|
|
2732
3712
|
) : void 0;
|
|
2733
3713
|
return {
|
|
2734
3714
|
version,
|
|
@@ -2738,7 +3718,7 @@ function parsePolicy(raw, where = POLICY_FILE) {
|
|
|
2738
3718
|
// an `exclude`-only override drop the rule's baseline severity (see mergeRules).
|
|
2739
3719
|
protected: mergeProtected(base.protected, userProtected),
|
|
2740
3720
|
rules: mergeRules(base.rules, userRules),
|
|
2741
|
-
ignore:
|
|
3721
|
+
ignore: globs3(r.ignore) ?? base.ignore,
|
|
2742
3722
|
signoff: {
|
|
2743
3723
|
requiredFor: r.signoff?.required_for ?? r.signoff?.requiredFor ?? base.signoff.requiredFor,
|
|
2744
3724
|
ledger: r.signoff?.ledger ?? base.signoff.ledger
|
|
@@ -3115,7 +4095,8 @@ function check(opts) {
|
|
|
3115
4095
|
layer = "local";
|
|
3116
4096
|
view = "staged";
|
|
3117
4097
|
} else if (opts.worktree) {
|
|
3118
|
-
|
|
4098
|
+
const p = policy;
|
|
4099
|
+
changes = opts.includeUntracked ? diffWorktreeWithUntracked({ cwd: opts.cwd }, (rel) => isProtected(rel, p)) : diffWorktree({ cwd: opts.cwd });
|
|
3119
4100
|
layer = opts.ciLayer ? "ci" : "local";
|
|
3120
4101
|
view = "worktree";
|
|
3121
4102
|
} else if (opts.diff) {
|
|
@@ -3171,10 +4152,10 @@ import { createHash as createHash4 } from "node:crypto";
|
|
|
3171
4152
|
import { join as join9 } from "node:path";
|
|
3172
4153
|
|
|
3173
4154
|
// src/adapters/claude/changes.ts
|
|
3174
|
-
import { execFileSync as
|
|
4155
|
+
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
3175
4156
|
import { readFileSync as readFileSync5, existsSync as existsSync4, writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
3176
4157
|
import { tmpdir } from "node:os";
|
|
3177
|
-
import { isAbsolute as
|
|
4158
|
+
import { isAbsolute as isAbsolute3, join as join5, relative as relative2, resolve } from "node:path";
|
|
3178
4159
|
function asStr(v) {
|
|
3179
4160
|
return typeof v === "string" ? v : "";
|
|
3180
4161
|
}
|
|
@@ -3189,8 +4170,8 @@ function abs(path, cwd) {
|
|
|
3189
4170
|
return resolve(cwd, path);
|
|
3190
4171
|
}
|
|
3191
4172
|
function relForDisplay(path, cwd) {
|
|
3192
|
-
const rel =
|
|
3193
|
-
return rel && !rel.startsWith("..") && !
|
|
4173
|
+
const rel = relative2(cwd, path);
|
|
4174
|
+
return rel && !rel.startsWith("..") && !isAbsolute3(rel) ? rel : path;
|
|
3194
4175
|
}
|
|
3195
4176
|
function applyEdit(content, oldStr, newStr) {
|
|
3196
4177
|
if (content === null) return newStr;
|
|
@@ -3211,7 +4192,7 @@ function synthFileChange(displayPath, before, after) {
|
|
|
3211
4192
|
writeFileSync(a, before ?? "");
|
|
3212
4193
|
writeFileSync(b, after ?? "");
|
|
3213
4194
|
try {
|
|
3214
|
-
raw =
|
|
4195
|
+
raw = execFileSync3("git", ["diff", "--no-index", "--no-color", "--text", a, b], {
|
|
3215
4196
|
encoding: "utf8",
|
|
3216
4197
|
maxBuffer: 256 * 1024 * 1024
|
|
3217
4198
|
});
|
|
@@ -3333,20 +4314,20 @@ function advanceTurnBaseline(cwd, sessionId) {
|
|
|
3333
4314
|
|
|
3334
4315
|
// src/effect.ts
|
|
3335
4316
|
import { createHash as createHash2 } from "node:crypto";
|
|
3336
|
-
import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync7, statSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
3337
|
-
import { isAbsolute as
|
|
3338
|
-
import { execFileSync as
|
|
4317
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync7, renameSync, rmSync as rmSync2, statSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
4318
|
+
import { isAbsolute as isAbsolute4, join as join7 } from "node:path";
|
|
4319
|
+
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
3339
4320
|
var UNSAFE2 = /[^A-Za-z0-9._-]/g;
|
|
3340
|
-
var
|
|
4321
|
+
var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
|
|
3341
4322
|
function gitDir2(cwd) {
|
|
3342
4323
|
try {
|
|
3343
|
-
const d =
|
|
4324
|
+
const d = execFileSync4("git", ["rev-parse", "--git-dir"], {
|
|
3344
4325
|
cwd,
|
|
3345
4326
|
encoding: "utf8",
|
|
3346
4327
|
stdio: ["ignore", "pipe", "pipe"]
|
|
3347
4328
|
}).trim();
|
|
3348
4329
|
if (!d) return null;
|
|
3349
|
-
return
|
|
4330
|
+
return isAbsolute4(d) ? d : join7(cwd, d);
|
|
3350
4331
|
} catch {
|
|
3351
4332
|
return null;
|
|
3352
4333
|
}
|
|
@@ -3379,7 +4360,7 @@ function snapshotProtected(cwd, policy, prev) {
|
|
|
3379
4360
|
return;
|
|
3380
4361
|
}
|
|
3381
4362
|
for (const name of names) {
|
|
3382
|
-
if (
|
|
4363
|
+
if (SKIP_DIRS2.has(name)) continue;
|
|
3383
4364
|
const r = rel ? `${rel}/${name}` : name;
|
|
3384
4365
|
let st;
|
|
3385
4366
|
try {
|
|
@@ -3405,12 +4386,16 @@ function snapshotProtected(cwd, policy, prev) {
|
|
|
3405
4386
|
function driftBetween(expected, current) {
|
|
3406
4387
|
const changed = [];
|
|
3407
4388
|
const deleted = [];
|
|
4389
|
+
const added = [];
|
|
3408
4390
|
for (const [path, e] of Object.entries(expected)) {
|
|
3409
4391
|
const c = current[path];
|
|
3410
4392
|
if (!c) deleted.push(path);
|
|
3411
4393
|
else if (c.hash !== e.hash || c.mode !== e.mode) changed.push(path);
|
|
3412
4394
|
}
|
|
3413
|
-
|
|
4395
|
+
for (const path of Object.keys(current)) {
|
|
4396
|
+
if (!expected[path]) added.push(path);
|
|
4397
|
+
}
|
|
4398
|
+
return { changed, deleted, added };
|
|
3414
4399
|
}
|
|
3415
4400
|
function loadTree(p) {
|
|
3416
4401
|
if (!p || !existsSync6(p)) return null;
|
|
@@ -3424,7 +4409,16 @@ function saveTree(p, tree) {
|
|
|
3424
4409
|
if (!p) return;
|
|
3425
4410
|
try {
|
|
3426
4411
|
mkdirSync3(join7(p, ".."), { recursive: true });
|
|
3427
|
-
|
|
4412
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
4413
|
+
writeFileSync3(tmp, JSON.stringify(tree));
|
|
4414
|
+
renameSync(tmp, p);
|
|
4415
|
+
} catch {
|
|
4416
|
+
}
|
|
4417
|
+
}
|
|
4418
|
+
function dropTree(p) {
|
|
4419
|
+
if (!p) return;
|
|
4420
|
+
try {
|
|
4421
|
+
rmSync2(p, { force: true });
|
|
3428
4422
|
} catch {
|
|
3429
4423
|
}
|
|
3430
4424
|
}
|
|
@@ -3440,6 +4434,9 @@ function loadTurnTree(cwd, sessionId) {
|
|
|
3440
4434
|
function saveTurnTree(cwd, sessionId, tree) {
|
|
3441
4435
|
saveTree(turnTreePath(cwd, sessionId), tree);
|
|
3442
4436
|
}
|
|
4437
|
+
function dropTurnTree(cwd, sessionId) {
|
|
4438
|
+
dropTree(turnTreePath(cwd, sessionId));
|
|
4439
|
+
}
|
|
3443
4440
|
|
|
3444
4441
|
// src/cli/watch.ts
|
|
3445
4442
|
import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync8, statSync as statSync2, watch } from "node:fs";
|
|
@@ -3684,17 +4681,26 @@ function turnView(cwd, sessionId, policy, base) {
|
|
|
3684
4681
|
if (c.oldPath) seen.add(c.oldPath);
|
|
3685
4682
|
}
|
|
3686
4683
|
const turnTree = loadTurnTree(cwd, sessionId);
|
|
3687
|
-
const
|
|
3688
|
-
|
|
3689
|
-
);
|
|
4684
|
+
const ptree = loadPtree(cwd, sessionId);
|
|
4685
|
+
const keep = (rel) => isProtected(rel, policy);
|
|
4686
|
+
const untouched = (rel, disk) => disk != null && turnTree?.[rel]?.hash === disk && ptree?.[rel]?.hash === disk;
|
|
4687
|
+
const untracked = untrackedAdds({ cwd }, keep, seen).filter((c) => c.kind !== "file" || !untouched(c.path, hashOnDisk(cwd, c.path)));
|
|
3690
4688
|
for (const c of fileChanges(untracked)) seen.add(c.path);
|
|
4689
|
+
const ignored = ignoredAdds({ cwd }, keep, seen).filter((c) => {
|
|
4690
|
+
if (c.kind !== "file") return false;
|
|
4691
|
+
const disk = hashOnDisk(cwd, c.path);
|
|
4692
|
+
if (untouched(c.path, disk)) return false;
|
|
4693
|
+
const sanctioned = ptree?.[c.path];
|
|
4694
|
+
return !(sanctioned && disk != null && sanctioned.hash !== disk);
|
|
4695
|
+
});
|
|
4696
|
+
for (const c of fileChanges(ignored)) seen.add(c.path);
|
|
3691
4697
|
const trusted = base ?? "HEAD";
|
|
3692
4698
|
const hidden = [];
|
|
3693
4699
|
for (const rel of hiddenTrackedPaths({ cwd })) {
|
|
3694
4700
|
if (seen.has(rel) || !isProtected(rel, policy)) continue;
|
|
3695
4701
|
hidden.push(...synthFileChange(rel, fileAt(trusted, rel, { cwd }), fileOnDisk(rel, { cwd })));
|
|
3696
4702
|
}
|
|
3697
|
-
return [...tracked, ...untracked, ...hidden];
|
|
4703
|
+
return [...tracked, ...untracked, ...ignored, ...hidden];
|
|
3698
4704
|
}
|
|
3699
4705
|
function uncoveredDrift(cwd, policy, expected, current, covered, base, turnTree) {
|
|
3700
4706
|
const d = driftBetween(expected, current);
|
|
@@ -3712,7 +4718,7 @@ function uncoveredDrift(cwd, policy, expected, current, covered, base, turnTree)
|
|
|
3712
4718
|
const after = fileOnDisk(rel, { cwd });
|
|
3713
4719
|
const trustedBlob = fileAt(trusted, rel, { cwd });
|
|
3714
4720
|
const atGitState = after != null && trustedBlob != null && contentHash(after) === contentHash(trustedBlob);
|
|
3715
|
-
const atTurnStart = current[rel] != null && turnTree?.[rel]?.hash === current[rel].hash;
|
|
4721
|
+
const atTurnStart = current[rel] != null && expected[rel]?.mtimeMs === -1 && turnTree?.[rel]?.hash === current[rel].hash;
|
|
3716
4722
|
if ((atGitState || atTurnStart) && current[rel] && expected[rel] && current[rel].mode === expected[rel].mode) {
|
|
3717
4723
|
continue;
|
|
3718
4724
|
}
|
|
@@ -3755,6 +4761,23 @@ function uncoveredDrift(cwd, policy, expected, current, covered, base, turnTree)
|
|
|
3755
4761
|
});
|
|
3756
4762
|
}
|
|
3757
4763
|
}
|
|
4764
|
+
for (const rel of d.added) {
|
|
4765
|
+
if (seen.has(rel)) continue;
|
|
4766
|
+
const after = fileOnDisk(rel, { cwd });
|
|
4767
|
+
if (after == null) {
|
|
4768
|
+
blocks.push({
|
|
4769
|
+
rule: "hidden-drift",
|
|
4770
|
+
severity: "block",
|
|
4771
|
+
file: rel,
|
|
4772
|
+
message: `Protected file ${rel} appeared outside git's view and cannot be read to judge it.`,
|
|
4773
|
+
evidence: rel,
|
|
4774
|
+
remediation: "Remove the file, or make it readable and bring it into git's view; create protected files through a tool call the gate can see.",
|
|
4775
|
+
signoff: { required: true, command: `tamperward allow hidden-drift --file ${rel} --reason "..."` }
|
|
4776
|
+
});
|
|
4777
|
+
continue;
|
|
4778
|
+
}
|
|
4779
|
+
changes.push(...synthFileChange(rel, null, after));
|
|
4780
|
+
}
|
|
3758
4781
|
return { changes, blocks };
|
|
3759
4782
|
}
|
|
3760
4783
|
function effectDriftBlocks(cwd, sessionId, policy) {
|
|
@@ -3763,17 +4786,18 @@ function effectDriftBlocks(cwd, sessionId, policy) {
|
|
|
3763
4786
|
const current = snapshotProtected(cwd, policy, expected ?? void 0);
|
|
3764
4787
|
if (!expected) {
|
|
3765
4788
|
savePtree(cwd, sessionId, current);
|
|
3766
|
-
if (
|
|
4789
|
+
if (loadTurnTree(cwd, sessionId)) dropTurnTree(cwd, sessionId);
|
|
4790
|
+
else saveTurnTree(cwd, sessionId, current);
|
|
3767
4791
|
return null;
|
|
3768
4792
|
}
|
|
3769
4793
|
const d = driftBetween(expected, current);
|
|
3770
|
-
if (d.changed.length === 0 && d.deleted.length === 0) {
|
|
4794
|
+
if (d.changed.length === 0 && d.deleted.length === 0 && d.added.length === 0) {
|
|
3771
4795
|
savePtree(cwd, sessionId, current);
|
|
3772
4796
|
return null;
|
|
3773
4797
|
}
|
|
3774
4798
|
const base = turnBaseline(cwd, sessionId);
|
|
3775
4799
|
const all = turnView(cwd, sessionId, policy, base);
|
|
3776
|
-
const drifted = /* @__PURE__ */ new Set([...d.changed, ...d.deleted]);
|
|
4800
|
+
const drifted = /* @__PURE__ */ new Set([...d.changed, ...d.deleted, ...d.added]);
|
|
3777
4801
|
const scoped = all.filter(
|
|
3778
4802
|
(c) => c.kind === "file" && (drifted.has(c.path) || c.oldPath != null && drifted.has(c.oldPath))
|
|
3779
4803
|
);
|
|
@@ -3985,16 +5009,16 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
|
|
|
3985
5009
|
}
|
|
3986
5010
|
|
|
3987
5011
|
// src/cli/init.ts
|
|
3988
|
-
import { execFileSync as
|
|
5012
|
+
import { execFileSync as execFileSync5 } from "node:child_process";
|
|
3989
5013
|
import { createHash as createHash5 } from "node:crypto";
|
|
3990
5014
|
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync11, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
|
|
3991
|
-
import { basename, dirname as dirname3, isAbsolute as
|
|
5015
|
+
import { basename, dirname as dirname3, isAbsolute as isAbsolute5, join as join10, relative as relative3, resolve as resolve2 } from "node:path";
|
|
3992
5016
|
import { fileURLToPath } from "node:url";
|
|
3993
5017
|
var errText2 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
|
|
3994
5018
|
var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3995
5019
|
function gitConfig(cwd, key2) {
|
|
3996
5020
|
try {
|
|
3997
|
-
return
|
|
5021
|
+
return execFileSync5("git", ["config", "--get", key2], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
|
|
3998
5022
|
} catch {
|
|
3999
5023
|
return null;
|
|
4000
5024
|
}
|
|
@@ -4299,15 +5323,15 @@ var MANAGED_HOOKS = [
|
|
|
4299
5323
|
];
|
|
4300
5324
|
function hooksDir(cwd) {
|
|
4301
5325
|
try {
|
|
4302
|
-
const out2 =
|
|
5326
|
+
const out2 = execFileSync5("git", ["rev-parse", "--git-path", "hooks"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
4303
5327
|
if (out2) return resolve2(cwd, out2);
|
|
4304
5328
|
} catch {
|
|
4305
5329
|
}
|
|
4306
5330
|
return existsSync8(join10(cwd, ".git")) ? join10(cwd, ".git", "hooks") : null;
|
|
4307
5331
|
}
|
|
4308
5332
|
function display(cwd, path) {
|
|
4309
|
-
const rel =
|
|
4310
|
-
return rel && !rel.startsWith("..") && !
|
|
5333
|
+
const rel = relative3(cwd, path);
|
|
5334
|
+
return rel && !rel.startsWith("..") && !isAbsolute5(rel) ? rel : path;
|
|
4311
5335
|
}
|
|
4312
5336
|
function unconditionalExitAt(lines) {
|
|
4313
5337
|
let depth = 0;
|
|
@@ -4591,30 +5615,30 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
|
|
|
4591
5615
|
}
|
|
4592
5616
|
|
|
4593
5617
|
// src/cli/verify.ts
|
|
4594
|
-
import { execFileSync as
|
|
5618
|
+
import { execFileSync as execFileSync8, spawnSync } from "node:child_process";
|
|
4595
5619
|
import { createHash as createHash8 } from "node:crypto";
|
|
4596
|
-
import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync14, readlinkSync as readlinkSync2, rmSync as
|
|
5620
|
+
import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync14, readlinkSync as readlinkSync2, rmSync as rmSync3, statSync as statSync7, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync10 } from "node:fs";
|
|
4597
5621
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
4598
5622
|
import { dirname as dirname4, join as join13, resolve as resolve3 } from "node:path";
|
|
4599
5623
|
|
|
4600
5624
|
// src/git/trusted.ts
|
|
4601
|
-
import { execFileSync as
|
|
5625
|
+
import { execFileSync as execFileSync6 } from "node:child_process";
|
|
4602
5626
|
import { createHash as createHash6 } from "node:crypto";
|
|
4603
5627
|
import { existsSync as existsSync9, readFileSync as readFileSync12 } from "node:fs";
|
|
4604
|
-
import { isAbsolute as
|
|
5628
|
+
import { isAbsolute as isAbsolute6, join as join11 } from "node:path";
|
|
4605
5629
|
function trustedGitEnv() {
|
|
4606
5630
|
return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
|
|
4607
5631
|
}
|
|
4608
5632
|
function gitDirOf(cwd) {
|
|
4609
5633
|
try {
|
|
4610
|
-
const d =
|
|
5634
|
+
const d = execFileSync6("git", ["rev-parse", "--git-dir"], {
|
|
4611
5635
|
cwd,
|
|
4612
5636
|
encoding: "utf8",
|
|
4613
5637
|
stdio: ["ignore", "pipe", "pipe"],
|
|
4614
5638
|
env: trustedGitEnv()
|
|
4615
5639
|
}).trim();
|
|
4616
5640
|
if (!d) return null;
|
|
4617
|
-
return
|
|
5641
|
+
return isAbsolute6(d) ? d : join11(cwd, d);
|
|
4618
5642
|
} catch {
|
|
4619
5643
|
return null;
|
|
4620
5644
|
}
|
|
@@ -4623,7 +5647,7 @@ function objectRewriteState(cwd) {
|
|
|
4623
5647
|
const parts = [];
|
|
4624
5648
|
try {
|
|
4625
5649
|
parts.push(
|
|
4626
|
-
"replace:" +
|
|
5650
|
+
"replace:" + execFileSync6("git", ["for-each-ref", "--format=%(refname) %(objectname)", "refs/replace/"], {
|
|
4627
5651
|
cwd,
|
|
4628
5652
|
encoding: "utf8",
|
|
4629
5653
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -4650,12 +5674,12 @@ function objectRewriteState(cwd) {
|
|
|
4650
5674
|
}
|
|
4651
5675
|
|
|
4652
5676
|
// src/fingerprint.ts
|
|
4653
|
-
import { execFileSync as
|
|
5677
|
+
import { execFileSync as execFileSync7 } from "node:child_process";
|
|
4654
5678
|
import { createHash as createHash7 } from "node:crypto";
|
|
4655
5679
|
import { readdirSync as readdirSync3, readFileSync as readFileSync13, readlinkSync, statSync as statSync6 } from "node:fs";
|
|
4656
5680
|
import { join as join12 } from "node:path";
|
|
4657
5681
|
function treeFingerprint(cwd) {
|
|
4658
|
-
const files =
|
|
5682
|
+
const files = execFileSync7("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
4659
5683
|
cwd,
|
|
4660
5684
|
encoding: "utf8",
|
|
4661
5685
|
maxBuffer: 1 << 28,
|
|
@@ -4787,7 +5811,7 @@ var VERIFICATION_SURFACE = [
|
|
|
4787
5811
|
"**/*.runsettings"
|
|
4788
5812
|
];
|
|
4789
5813
|
function git2(args, cwd) {
|
|
4790
|
-
return
|
|
5814
|
+
return execFileSync8("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
4791
5815
|
}
|
|
4792
5816
|
function resolveBase(base, cwd) {
|
|
4793
5817
|
assertRev(base);
|
|
@@ -4828,7 +5852,7 @@ function materialize(cwd, dest) {
|
|
|
4828
5852
|
}
|
|
4829
5853
|
function dropSymlink(p) {
|
|
4830
5854
|
try {
|
|
4831
|
-
if (lstatSync(p).isSymbolicLink())
|
|
5855
|
+
if (lstatSync(p).isSymbolicLink()) rmSync3(p, { force: true });
|
|
4832
5856
|
} catch {
|
|
4833
5857
|
}
|
|
4834
5858
|
}
|
|
@@ -4839,9 +5863,9 @@ function verifierInputs(cmd, atBase, policy) {
|
|
|
4839
5863
|
const tok = raw.replace(/^['"]|['"]$/g, "").replace(/^\.\//, "");
|
|
4840
5864
|
if (tok && baseFiles.has(tok)) picked.add(tok);
|
|
4841
5865
|
}
|
|
4842
|
-
const
|
|
4843
|
-
if (
|
|
4844
|
-
for (const rel of atBase) if (matchesAny(rel,
|
|
5866
|
+
const globs3 = policy.verify?.inputs;
|
|
5867
|
+
if (globs3 && globs3.length) {
|
|
5868
|
+
for (const rel of atBase) if (matchesAny(rel, globs3)) picked.add(rel);
|
|
4845
5869
|
}
|
|
4846
5870
|
return picked;
|
|
4847
5871
|
}
|
|
@@ -4883,10 +5907,10 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
|
|
|
4883
5907
|
if (e.type === "commit") {
|
|
4884
5908
|
throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
|
|
4885
5909
|
}
|
|
4886
|
-
const content =
|
|
5910
|
+
const content = execFileSync8("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
4887
5911
|
const out2 = join13(dest, e.path);
|
|
4888
5912
|
mkdirSync6(dirname4(out2), { recursive: true });
|
|
4889
|
-
|
|
5913
|
+
rmSync3(out2, { force: true });
|
|
4890
5914
|
if (e.mode === "120000") {
|
|
4891
5915
|
symlinkSync(content.toString("utf8"), out2);
|
|
4892
5916
|
} else {
|
|
@@ -4899,7 +5923,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
|
|
|
4899
5923
|
const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
|
|
4900
5924
|
for (const rel of inCopy) {
|
|
4901
5925
|
if (!isOverlay(rel) || baseProtected.has(rel)) continue;
|
|
4902
|
-
|
|
5926
|
+
rmSync3(join13(dest, rel), { force: true });
|
|
4903
5927
|
removed++;
|
|
4904
5928
|
}
|
|
4905
5929
|
return { restored, removed };
|
|
@@ -4974,7 +5998,7 @@ function runSuite(dir, cmd, budgetSecs) {
|
|
|
4974
5998
|
if (r.exit === null || r.exit === void 0) return { exit: null, secs };
|
|
4975
5999
|
return { exit: r.exit, secs };
|
|
4976
6000
|
} finally {
|
|
4977
|
-
|
|
6001
|
+
rmSync3(outDir, { recursive: true, force: true });
|
|
4978
6002
|
}
|
|
4979
6003
|
}
|
|
4980
6004
|
function runVerify(opts) {
|
|
@@ -5021,7 +6045,7 @@ function runVerify(opts) {
|
|
|
5021
6045
|
const visDir = join13(visRoot, "t");
|
|
5022
6046
|
const cleanup = (dirs) => {
|
|
5023
6047
|
if (opts.keep) return;
|
|
5024
|
-
for (const d of dirs)
|
|
6048
|
+
for (const d of dirs) rmSync3(d, { recursive: true, force: true });
|
|
5025
6049
|
};
|
|
5026
6050
|
const treeBefore = treeFingerprint(cwd);
|
|
5027
6051
|
const depsBefore = depsFingerprint(cwd);
|
|
@@ -5158,13 +6182,13 @@ function parseVerify(args) {
|
|
|
5158
6182
|
}
|
|
5159
6183
|
|
|
5160
6184
|
// src/cli/run.ts
|
|
5161
|
-
import { execFileSync as
|
|
6185
|
+
import { execFileSync as execFileSync9, spawnSync as spawnSync2 } from "node:child_process";
|
|
5162
6186
|
import { readdirSync as readdirSync4, readFileSync as readFileSync15, realpathSync } from "node:fs";
|
|
5163
6187
|
import { resolve as resolve4 } from "node:path";
|
|
5164
6188
|
var out = (s) => process.stdout.write(s + "\n");
|
|
5165
6189
|
var err = (s) => process.stderr.write(s + "\n");
|
|
5166
6190
|
function git3(args, cwd) {
|
|
5167
|
-
return
|
|
6191
|
+
return execFileSync9("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
5168
6192
|
}
|
|
5169
6193
|
function nowTicks() {
|
|
5170
6194
|
try {
|
|
@@ -5289,7 +6313,7 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
|
|
|
5289
6313
|
const verifyCode = runVerify({ cwd, base, cmd: frozenCmd, budget: frozenBudget, policyOverride: frozenPolicy });
|
|
5290
6314
|
if (opts.settle && opts.settle > 0) {
|
|
5291
6315
|
try {
|
|
5292
|
-
|
|
6316
|
+
execFileSync9("sleep", [String(opts.settle)]);
|
|
5293
6317
|
} catch {
|
|
5294
6318
|
}
|
|
5295
6319
|
}
|