tamperward 2.4.1 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +1047 -119
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -560,12 +560,12 @@ function matcher(glob) {
|
|
|
560
560
|
}
|
|
561
561
|
return m;
|
|
562
562
|
}
|
|
563
|
-
function matchesAny(path,
|
|
564
|
-
return !!
|
|
563
|
+
function matchesAny(path, globs3) {
|
|
564
|
+
return !!globs3 && globs3.some((g) => matcher(g)(path));
|
|
565
565
|
}
|
|
566
566
|
function protectedCategory(path, policy) {
|
|
567
|
-
for (const [cat,
|
|
568
|
-
if (matchesAny(path,
|
|
567
|
+
for (const [cat, globs3] of Object.entries(policy.protected ?? {})) {
|
|
568
|
+
if (matchesAny(path, globs3)) return cat;
|
|
569
569
|
}
|
|
570
570
|
return null;
|
|
571
571
|
}
|
|
@@ -581,9 +581,9 @@ function isIgnored(path, policy) {
|
|
|
581
581
|
}
|
|
582
582
|
function mergeProtected(base, user) {
|
|
583
583
|
const out2 = { ...base };
|
|
584
|
-
for (const [cat,
|
|
584
|
+
for (const [cat, globs3] of Object.entries(user ?? {})) {
|
|
585
585
|
const have = new Set(out2[cat] ?? []);
|
|
586
|
-
out2[cat] = [...out2[cat] ?? [], ...(
|
|
586
|
+
out2[cat] = [...out2[cat] ?? [], ...(globs3 ?? []).filter((g) => !have.has(g))];
|
|
587
587
|
}
|
|
588
588
|
return out2;
|
|
589
589
|
}
|
|
@@ -631,7 +631,19 @@ function defaultPolicy(version = 1) {
|
|
|
631
631
|
"**/tsconfig*.json",
|
|
632
632
|
"**/.eslintrc*",
|
|
633
633
|
"**/eslint.config.*",
|
|
634
|
-
"**/package.json"
|
|
634
|
+
"**/package.json",
|
|
635
|
+
// Coverage gates outside the Jest/Vitest shapes. coverage-lowering could only
|
|
636
|
+
// see a threshold it was shown: a Python `fail_under`, an nyc `lines`, a
|
|
637
|
+
// codecov `target` lived in files no glob protected, so lowering them fired
|
|
638
|
+
// nothing. ADDITIVE, like every baseline widening (the policy-diff reads it
|
|
639
|
+
// as a strengthening). pyproject.toml carries [tool.coverage.report].
|
|
640
|
+
"**/.coveragerc",
|
|
641
|
+
"**/pyproject.toml",
|
|
642
|
+
"**/.nycrc",
|
|
643
|
+
"**/.nycrc.*",
|
|
644
|
+
"**/nyc.config.*",
|
|
645
|
+
"**/codecov.yml",
|
|
646
|
+
"**/.codecov.yml"
|
|
635
647
|
],
|
|
636
648
|
ci: [".github/workflows/**"],
|
|
637
649
|
// Recorded expected outputs. An assertion stored as data is still an assertion;
|
|
@@ -735,9 +747,11 @@ var IMPORTS = {
|
|
|
735
747
|
cs: /^(?:using\b|namespace\b)/
|
|
736
748
|
};
|
|
737
749
|
var HASH_COMMENT = /* @__PURE__ */ new Set(["py", "rb"]);
|
|
750
|
+
var DEBUG_ONLY = /^(?:console\.\w+\(.*\)\s*;?|debugger\s*;?)$/;
|
|
738
751
|
function isSignificantLine(trimmed, lang) {
|
|
739
752
|
if (trimmed.length < 10) return false;
|
|
740
753
|
if (COMMON.test(trimmed)) return false;
|
|
754
|
+
if (DEBUG_ONLY.test(trimmed)) return false;
|
|
741
755
|
const l = lang ?? "js";
|
|
742
756
|
if (IMPORTS[l].test(trimmed)) return false;
|
|
743
757
|
if (HASH_COMMENT.has(l) && trimmed.startsWith("#")) return false;
|
|
@@ -1075,7 +1089,47 @@ var testSkip = {
|
|
|
1075
1089
|
};
|
|
1076
1090
|
|
|
1077
1091
|
// src/detectors/coverage-lowering.ts
|
|
1092
|
+
import picomatch2 from "picomatch";
|
|
1078
1093
|
import ts2 from "typescript";
|
|
1094
|
+
|
|
1095
|
+
// src/detectors/repo.ts
|
|
1096
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
1097
|
+
var cache2 = /* @__PURE__ */ new Map();
|
|
1098
|
+
function trackedFiles(ctx) {
|
|
1099
|
+
if (!ctx) return null;
|
|
1100
|
+
if (ctx.trackedFiles) return ctx.trackedFiles;
|
|
1101
|
+
if (!ctx.cwd) return null;
|
|
1102
|
+
const hit = cache2.get(ctx.cwd);
|
|
1103
|
+
if (hit !== void 0) return hit;
|
|
1104
|
+
let files = null;
|
|
1105
|
+
try {
|
|
1106
|
+
files = execFileSync2("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
1107
|
+
cwd: ctx.cwd,
|
|
1108
|
+
encoding: "utf8",
|
|
1109
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
1110
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1111
|
+
}).split("\0").filter(Boolean);
|
|
1112
|
+
} catch {
|
|
1113
|
+
files = null;
|
|
1114
|
+
}
|
|
1115
|
+
cache2.set(ctx.cwd, files);
|
|
1116
|
+
return files;
|
|
1117
|
+
}
|
|
1118
|
+
var dirOf = (t) => t.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
1119
|
+
function containsProtected(token, policy, category, ctx) {
|
|
1120
|
+
const dir = dirOf(token);
|
|
1121
|
+
if (!dir || dir === "." || dir === "/" || dir.startsWith("-")) return false;
|
|
1122
|
+
const files = trackedFiles(ctx);
|
|
1123
|
+
if (files) {
|
|
1124
|
+
const prefix = dir + "/";
|
|
1125
|
+
return files.some((f) => f.startsWith(prefix) && isProtected(f, policy, category));
|
|
1126
|
+
}
|
|
1127
|
+
if (isProtected(dir + "/probe", policy, category)) return true;
|
|
1128
|
+
const base = dir.split("/").pop() ?? "";
|
|
1129
|
+
return category === "tests" && /^(?:test|tests|__tests__|spec|specs)$/i.test(base);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// src/detectors/coverage-lowering.ts
|
|
1079
1133
|
var RULE4 = "coverage-lowering";
|
|
1080
1134
|
var METRICS = ["branches", "functions", "lines", "statements"];
|
|
1081
1135
|
var isMetric = (k) => METRICS.includes(k);
|
|
@@ -1086,29 +1140,36 @@ function keyName(name) {
|
|
|
1086
1140
|
}
|
|
1087
1141
|
function numericOf(e) {
|
|
1088
1142
|
if (ts2.isNumericLiteral(e)) return Number(e.text);
|
|
1143
|
+
if (ts2.isStringLiteral(e) && /^-?\d+(?:\.\d+)?$/.test(e.text.trim())) return Number(e.text);
|
|
1089
1144
|
if (ts2.isPrefixUnaryExpression(e) && e.operator === ts2.SyntaxKind.MinusToken && ts2.isNumericLiteral(e.operand)) {
|
|
1090
1145
|
return -Number(e.operand.text);
|
|
1091
1146
|
}
|
|
1092
1147
|
return void 0;
|
|
1093
1148
|
}
|
|
1094
|
-
function metricsOf(
|
|
1149
|
+
function metricsOf(expr, resolve5) {
|
|
1150
|
+
const obj = resolve5(expr);
|
|
1095
1151
|
if (!ts2.isObjectLiteralExpression(obj)) return void 0;
|
|
1096
|
-
const out2 = {};
|
|
1152
|
+
const out2 = { values: {}, opaque: false };
|
|
1097
1153
|
let any = false;
|
|
1098
1154
|
for (const p of obj.properties) {
|
|
1155
|
+
if (ts2.isSpreadAssignment(p)) {
|
|
1156
|
+
out2.opaque = true;
|
|
1157
|
+
any = true;
|
|
1158
|
+
continue;
|
|
1159
|
+
}
|
|
1099
1160
|
if (!ts2.isPropertyAssignment(p)) continue;
|
|
1100
1161
|
const k = keyName(p.name);
|
|
1101
1162
|
if (k === null || !isMetric(k)) continue;
|
|
1102
|
-
const n = numericOf(p.initializer);
|
|
1103
|
-
if (n === void 0) continue;
|
|
1104
|
-
out2[k] = n;
|
|
1105
1163
|
any = true;
|
|
1164
|
+
const n = numericOf(resolve5(p.initializer));
|
|
1165
|
+
if (n === void 0) out2.opaque = true;
|
|
1166
|
+
else out2.values[k] = n;
|
|
1106
1167
|
}
|
|
1107
1168
|
return any ? out2 : void 0;
|
|
1108
1169
|
}
|
|
1109
|
-
function
|
|
1170
|
+
function underKey(node, key2) {
|
|
1110
1171
|
for (let p = node.parent; p; p = p.parent) {
|
|
1111
|
-
if (ts2.isPropertyAssignment(p) && keyName(p.name) ===
|
|
1172
|
+
if (ts2.isPropertyAssignment(p) && keyName(p.name) === key2) return true;
|
|
1112
1173
|
}
|
|
1113
1174
|
return false;
|
|
1114
1175
|
}
|
|
@@ -1116,25 +1177,54 @@ function asExpression(src) {
|
|
|
1116
1177
|
const t = src.trim();
|
|
1117
1178
|
return t.startsWith("{") ? `(${t})` : src;
|
|
1118
1179
|
}
|
|
1180
|
+
function sourceOf(src) {
|
|
1181
|
+
return ts2.createSourceFile("cfg.ts", asExpression(src), ts2.ScriptTarget.Latest, true, ts2.ScriptKind.TS);
|
|
1182
|
+
}
|
|
1183
|
+
function resolverFor(sf) {
|
|
1184
|
+
const consts = /* @__PURE__ */ new Map();
|
|
1185
|
+
for (const st of sf.statements) {
|
|
1186
|
+
if (!ts2.isVariableStatement(st)) continue;
|
|
1187
|
+
for (const d of st.declarationList.declarations) {
|
|
1188
|
+
if (ts2.isIdentifier(d.name) && d.initializer) consts.set(d.name.text, d.initializer);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
return (e) => {
|
|
1192
|
+
if (ts2.isIdentifier(e)) {
|
|
1193
|
+
const bound = consts.get(e.text);
|
|
1194
|
+
return bound ?? e;
|
|
1195
|
+
}
|
|
1196
|
+
return e;
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1119
1199
|
function parseThresholds(src) {
|
|
1120
|
-
const res = { paths: /* @__PURE__ */ new Map(), present: false };
|
|
1200
|
+
const res = { paths: /* @__PURE__ */ new Map(), present: false, opaque: false };
|
|
1121
1201
|
try {
|
|
1122
|
-
const sf =
|
|
1202
|
+
const sf = sourceOf(src);
|
|
1203
|
+
const resolve5 = resolverFor(sf);
|
|
1123
1204
|
const visit = (node) => {
|
|
1124
|
-
if (ts2.isPropertyAssignment(node)
|
|
1205
|
+
if (ts2.isPropertyAssignment(node)) {
|
|
1125
1206
|
const key2 = keyName(node.name);
|
|
1126
|
-
if (key2 === "coverageThreshold" || key2 === "thresholds" &&
|
|
1207
|
+
if (key2 === "coverageThreshold" || key2 === "thresholds" && underKey(node, "coverage")) {
|
|
1127
1208
|
res.present = true;
|
|
1128
|
-
const
|
|
1129
|
-
if (
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
const
|
|
1133
|
-
if (
|
|
1134
|
-
const
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1209
|
+
const init = resolve5(node.initializer);
|
|
1210
|
+
if (!ts2.isObjectLiteralExpression(init)) {
|
|
1211
|
+
res.opaque = true;
|
|
1212
|
+
} else {
|
|
1213
|
+
const flat = metricsOf(init, resolve5);
|
|
1214
|
+
if (flat) res.global = merge(res.global, flat);
|
|
1215
|
+
for (const p of init.properties) {
|
|
1216
|
+
if (ts2.isSpreadAssignment(p)) {
|
|
1217
|
+
res.opaque = true;
|
|
1218
|
+
continue;
|
|
1219
|
+
}
|
|
1220
|
+
if (!ts2.isPropertyAssignment(p)) continue;
|
|
1221
|
+
const k = keyName(p.name);
|
|
1222
|
+
if (k === null || isMetric(k)) continue;
|
|
1223
|
+
const m = metricsOf(p.initializer, resolve5);
|
|
1224
|
+
if (!m) continue;
|
|
1225
|
+
if (k === "global") res.global = merge(res.global, m);
|
|
1226
|
+
else res.paths.set(norm(k), m);
|
|
1227
|
+
}
|
|
1138
1228
|
}
|
|
1139
1229
|
}
|
|
1140
1230
|
}
|
|
@@ -1145,41 +1235,242 @@ function parseThresholds(src) {
|
|
|
1145
1235
|
}
|
|
1146
1236
|
return res;
|
|
1147
1237
|
}
|
|
1148
|
-
|
|
1238
|
+
function merge(a, b) {
|
|
1239
|
+
return { values: { ...a?.values ?? {}, ...b.values }, opaque: (a?.opaque ?? false) || b.opaque };
|
|
1240
|
+
}
|
|
1241
|
+
function literals(e) {
|
|
1242
|
+
if (ts2.isStringLiteral(e)) return { items: [e.text], opaque: false };
|
|
1243
|
+
if (!ts2.isArrayLiteralExpression(e)) return { items: [], opaque: true };
|
|
1244
|
+
const items = [];
|
|
1245
|
+
let opaque = false;
|
|
1246
|
+
for (const el of e.elements) {
|
|
1247
|
+
if (ts2.isStringLiteral(el) || ts2.isNoSubstitutionTemplateLiteral(el)) items.push(el.text);
|
|
1248
|
+
else opaque = true;
|
|
1249
|
+
}
|
|
1250
|
+
return { items, opaque };
|
|
1251
|
+
}
|
|
1252
|
+
function parseLists(src) {
|
|
1253
|
+
const res = { collectFrom: null, ignore: [], opaqueCollect: false, opaqueIgnore: false };
|
|
1254
|
+
try {
|
|
1255
|
+
const sf = sourceOf(src);
|
|
1256
|
+
const resolve5 = resolverFor(sf);
|
|
1257
|
+
const visit = (node) => {
|
|
1258
|
+
if (ts2.isPropertyAssignment(node)) {
|
|
1259
|
+
const k = keyName(node.name);
|
|
1260
|
+
if (k === "collectCoverageFrom") {
|
|
1261
|
+
const { items, opaque } = literals(resolve5(node.initializer));
|
|
1262
|
+
res.collectFrom = items;
|
|
1263
|
+
if (opaque) res.opaqueCollect = true;
|
|
1264
|
+
} else if (k === "coveragePathIgnorePatterns" || k === "exclude" && underKey(node, "coverage")) {
|
|
1265
|
+
const { items, opaque } = literals(resolve5(node.initializer));
|
|
1266
|
+
res.ignore.push(...items);
|
|
1267
|
+
if (opaque) res.opaqueIgnore = true;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
ts2.forEachChild(node, visit);
|
|
1271
|
+
};
|
|
1272
|
+
visit(sf);
|
|
1273
|
+
} catch {
|
|
1274
|
+
}
|
|
1275
|
+
return res;
|
|
1276
|
+
}
|
|
1277
|
+
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
1278
|
function compareMetrics(scope, before, after, out2) {
|
|
1150
1279
|
if (!before) return;
|
|
1151
1280
|
for (const m of METRICS) {
|
|
1152
|
-
const bv = before[m];
|
|
1281
|
+
const bv = before.values[m];
|
|
1153
1282
|
if (bv === void 0) continue;
|
|
1154
|
-
const av = after?.[m];
|
|
1155
|
-
if (av === void 0)
|
|
1156
|
-
|
|
1283
|
+
const av = after?.values[m];
|
|
1284
|
+
if (av === void 0) {
|
|
1285
|
+
if (!after?.opaque) out2.push(`${scope} ${m} threshold removed (was ${bv})`);
|
|
1286
|
+
} else if (av < bv) out2.push(`${scope} ${m} threshold lowered ${bv} \u2192 ${av}`);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
function overrideTargetExists(key2, configPath, ctx) {
|
|
1290
|
+
const files = trackedFiles(ctx);
|
|
1291
|
+
if (!files) return null;
|
|
1292
|
+
const root = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/") + 1) : "";
|
|
1293
|
+
const rel = norm(key2);
|
|
1294
|
+
let m;
|
|
1295
|
+
try {
|
|
1296
|
+
m = /[*?{}[\]]/.test(rel) ? picomatch2(rel, { dot: true }) : (p) => p === rel;
|
|
1297
|
+
} catch {
|
|
1298
|
+
return null;
|
|
1157
1299
|
}
|
|
1300
|
+
return files.some((f) => f.startsWith(root) && m(f.slice(root.length)));
|
|
1158
1301
|
}
|
|
1159
|
-
function weakenings(before, after) {
|
|
1302
|
+
function weakenings(before, after, configPath, ctx) {
|
|
1160
1303
|
const out2 = [];
|
|
1161
1304
|
if (before.present && !after.present) {
|
|
1162
1305
|
out2.push("the coverage threshold gate was removed");
|
|
1163
1306
|
return out2;
|
|
1164
1307
|
}
|
|
1308
|
+
if (after.opaque && !after.global && after.paths.size === 0) return out2;
|
|
1165
1309
|
compareMetrics("global", before.global, after.global, out2);
|
|
1166
1310
|
const paths = /* @__PURE__ */ new Set([...before.paths.keys(), ...after.paths.keys()]);
|
|
1167
1311
|
for (const p of paths) {
|
|
1168
|
-
|
|
1312
|
+
const scope = `required coverage for ${p}:`;
|
|
1313
|
+
const b = before.paths.get(p);
|
|
1314
|
+
const a = after.paths.get(p);
|
|
1315
|
+
if (b && a) {
|
|
1316
|
+
compareMetrics(scope, b, a, out2);
|
|
1317
|
+
} else if (a) {
|
|
1318
|
+
const g = before.global;
|
|
1319
|
+
if (!g) continue;
|
|
1320
|
+
for (const m of METRICS) {
|
|
1321
|
+
const gv = g.values[m];
|
|
1322
|
+
const av = a.values[m];
|
|
1323
|
+
if (gv !== void 0 && av !== void 0 && av < gv) out2.push(`${scope} ${m} threshold lowered ${gv} \u2192 ${av}`);
|
|
1324
|
+
}
|
|
1325
|
+
} else if (b) {
|
|
1326
|
+
if (overrideTargetExists(p, configPath, ctx) === false) continue;
|
|
1327
|
+
compareMetrics(scope, b, after.global, out2);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
return out2;
|
|
1331
|
+
}
|
|
1332
|
+
function narrowings(before, after) {
|
|
1333
|
+
const out2 = [];
|
|
1334
|
+
if (before.collectFrom && after.collectFrom && !after.opaqueCollect) {
|
|
1335
|
+
const kept = new Set(after.collectFrom);
|
|
1336
|
+
for (const g of before.collectFrom) {
|
|
1337
|
+
if (!g.startsWith("!") && !kept.has(g)) out2.push(`collectCoverageFrom no longer measures ${JSON.stringify(g)}`);
|
|
1338
|
+
}
|
|
1339
|
+
const had2 = new Set(before.collectFrom);
|
|
1340
|
+
for (const g of after.collectFrom) {
|
|
1341
|
+
if (g.startsWith("!") && !had2.has(g) && !BENIGN_EXEMPTION.test(g)) out2.push(`collectCoverageFrom now exempts ${JSON.stringify(g)}`);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
const had = new Set(before.ignore);
|
|
1345
|
+
for (const g of after.ignore) {
|
|
1346
|
+
if (!had.has(g) && !BENIGN_EXEMPTION.test(g)) out2.push(`coverage now exempts ${JSON.stringify(g)} (coveragePathIgnorePatterns / coverage.exclude)`);
|
|
1347
|
+
}
|
|
1348
|
+
return out2;
|
|
1349
|
+
}
|
|
1350
|
+
var SIMPLE_FILES = [
|
|
1351
|
+
{
|
|
1352
|
+
// coverage.py: `fail_under = 90` under [report] (.coveragerc, setup.cfg-style) or
|
|
1353
|
+
// [tool.coverage.report] (pyproject.toml)
|
|
1354
|
+
file: /(?:^|\/)(?:\.coveragerc|pyproject\.toml)$/,
|
|
1355
|
+
keys: [{ key: /^\s*fail[_-]under\s*=\s*"?(\d+(?:\.\d+)?)/, label: "fail_under", weakens: "lower" }],
|
|
1356
|
+
removable: true
|
|
1357
|
+
},
|
|
1358
|
+
{
|
|
1359
|
+
// nyc: .nycrc / .nycrc.json / .nycrc.yml / nyc.config.js — metric thresholds
|
|
1360
|
+
file: /(?:^|\/)(?:\.nycrc(?:\.\w+)?|nyc\.config\.\w+)$/,
|
|
1361
|
+
keys: METRICS.map((m) => ({
|
|
1362
|
+
key: new RegExp(`(?:^|[\\s{,])"?${m}"?\\s*[:=]\\s*"?(\\d+(?:\\.\\d+)?)`, "g"),
|
|
1363
|
+
label: m,
|
|
1364
|
+
weakens: "lower"
|
|
1365
|
+
})),
|
|
1366
|
+
removable: false
|
|
1367
|
+
},
|
|
1368
|
+
{
|
|
1369
|
+
// codecov: `target: 90%` is the floor (lowering weakens); `threshold: 2%` is the
|
|
1370
|
+
// permitted drop (raising weakens)
|
|
1371
|
+
file: /(?:^|\/)\.?codecov\.yml$/,
|
|
1372
|
+
keys: [
|
|
1373
|
+
{ key: /^\s*target:\s*"?(\d+(?:\.\d+)?)%?/, label: "target", weakens: "lower" },
|
|
1374
|
+
{ key: /^\s*threshold:\s*"?(\d+(?:\.\d+)?)%?/, label: "threshold", weakens: "raise" }
|
|
1375
|
+
],
|
|
1376
|
+
removable: false
|
|
1377
|
+
}
|
|
1378
|
+
];
|
|
1379
|
+
function numbersOf(src, key2) {
|
|
1380
|
+
const out2 = [];
|
|
1381
|
+
for (const line of src.split("\n")) {
|
|
1382
|
+
if (key2.global) {
|
|
1383
|
+
for (const m of line.matchAll(key2)) out2.push(Number(m[1]));
|
|
1384
|
+
} else {
|
|
1385
|
+
const m = line.match(key2);
|
|
1386
|
+
if (m) out2.push(Number(m[1]));
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
return out2;
|
|
1390
|
+
}
|
|
1391
|
+
function simpleWeakenings(path, before, after) {
|
|
1392
|
+
const out2 = [];
|
|
1393
|
+
const spec = SIMPLE_FILES.find((s) => s.file.test(path));
|
|
1394
|
+
if (!spec) return out2;
|
|
1395
|
+
for (const k of spec.keys) {
|
|
1396
|
+
const b = numbersOf(before, k.key).sort((x, y) => y - x);
|
|
1397
|
+
const a = numbersOf(after, k.key).sort((x, y) => y - x);
|
|
1398
|
+
if (b.length === 0) continue;
|
|
1399
|
+
if (a.length === 0) {
|
|
1400
|
+
if (spec.removable) out2.push(`${k.label} removed (was ${b.join(", ")})`);
|
|
1401
|
+
continue;
|
|
1402
|
+
}
|
|
1403
|
+
if (k.weakens === "lower") {
|
|
1404
|
+
for (let i = 0; i < b.length; i++) {
|
|
1405
|
+
const av = a[Math.min(i, a.length - 1)];
|
|
1406
|
+
if (av < b[i]) {
|
|
1407
|
+
out2.push(`${k.label} lowered ${b[i]} \u2192 ${av}`);
|
|
1408
|
+
break;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
} else {
|
|
1412
|
+
const bMax = b[0];
|
|
1413
|
+
const aMax = a[0];
|
|
1414
|
+
if (aMax > bMax) out2.push(`${k.label} raised ${bMax} \u2192 ${aMax} (a wider permitted drop)`);
|
|
1415
|
+
}
|
|
1169
1416
|
}
|
|
1170
1417
|
return out2;
|
|
1171
1418
|
}
|
|
1419
|
+
var SWITCHES = [
|
|
1420
|
+
{
|
|
1421
|
+
re: /--passWithNoTests\b/,
|
|
1422
|
+
message: "--passWithNoTests added \u2014 the suite can now pass with zero tests.",
|
|
1423
|
+
remediation: "Remove --passWithNoTests; an empty suite must not be a green check."
|
|
1424
|
+
},
|
|
1425
|
+
{
|
|
1426
|
+
re: /\bpassWithNoTests"?\s*:\s*true\b/,
|
|
1427
|
+
message: "passWithNoTests: true added to the runner config \u2014 the suite can now pass with zero tests.",
|
|
1428
|
+
remediation: "Remove passWithNoTests; an empty suite must not be a green check."
|
|
1429
|
+
},
|
|
1430
|
+
{
|
|
1431
|
+
re: /--coverage=false\b|--no-coverage\b|--coverage\.enabled=false\b/,
|
|
1432
|
+
message: "coverage switched off on the command line (--coverage=false).",
|
|
1433
|
+
remediation: "Keep coverage reporting on; do not switch it off to dodge the gate."
|
|
1434
|
+
},
|
|
1435
|
+
{
|
|
1436
|
+
re: /--coverageThreshold[= ]/,
|
|
1437
|
+
message: "the coverage threshold is overridden on the command line (--coverageThreshold), which replaces the configured gate.",
|
|
1438
|
+
remediation: "Remove the override; the configured threshold is the gate."
|
|
1439
|
+
},
|
|
1440
|
+
{
|
|
1441
|
+
re: /\bautoUpdate"?\s*:\s*true\b/,
|
|
1442
|
+
message: "thresholds.autoUpdate: true added \u2014 the gate now follows the measured number.",
|
|
1443
|
+
remediation: "Remove autoUpdate; a threshold that tracks the result gates nothing."
|
|
1444
|
+
},
|
|
1445
|
+
{
|
|
1446
|
+
re: /"?check-coverage"?\s*[:=]\s*false\b/,
|
|
1447
|
+
message: "check-coverage: false \u2014 nyc will no longer fail on the thresholds.",
|
|
1448
|
+
remediation: "Keep check-coverage on; raise real coverage instead."
|
|
1449
|
+
},
|
|
1450
|
+
{
|
|
1451
|
+
re: /^\s*informational:\s*true\b/,
|
|
1452
|
+
message: "informational: true added \u2014 the codecov status can no longer fail.",
|
|
1453
|
+
remediation: "Remove informational; a status that cannot fail is not a gate."
|
|
1454
|
+
}
|
|
1455
|
+
];
|
|
1456
|
+
var isJsConfig = (path) => /\.(?:[cm]?[jt]sx?|json)$/.test(path);
|
|
1172
1457
|
var coverageLowering = {
|
|
1173
1458
|
id: RULE4,
|
|
1174
1459
|
surface: ["file"],
|
|
1175
1460
|
certainty: "mechanical",
|
|
1176
|
-
run(changes, policy) {
|
|
1461
|
+
run(changes, policy, _view, ctx) {
|
|
1177
1462
|
const out2 = [];
|
|
1178
1463
|
for (const c of changes) {
|
|
1179
1464
|
if (c.kind !== "file") continue;
|
|
1180
1465
|
if (!isProtected(c.path, policy, "config")) continue;
|
|
1181
1466
|
if (c.after != null && c.op !== "delete") {
|
|
1182
|
-
|
|
1467
|
+
const reasons = [];
|
|
1468
|
+
if (isJsConfig(c.path)) {
|
|
1469
|
+
reasons.push(...weakenings(parseThresholds(c.before ?? ""), parseThresholds(c.after), c.path, ctx));
|
|
1470
|
+
reasons.push(...narrowings(parseLists(c.before ?? ""), parseLists(c.after)));
|
|
1471
|
+
}
|
|
1472
|
+
reasons.push(...simpleWeakenings(c.path, c.before ?? "", c.after));
|
|
1473
|
+
for (const reason of reasons) {
|
|
1183
1474
|
out2.push(
|
|
1184
1475
|
makeFinding(RULE4, policy, {
|
|
1185
1476
|
file: c.path,
|
|
@@ -1190,7 +1481,9 @@ var coverageLowering = {
|
|
|
1190
1481
|
);
|
|
1191
1482
|
}
|
|
1192
1483
|
} else if (c.op === "delete" || c.after === "" && (c.before ?? "").length > 0) {
|
|
1193
|
-
|
|
1484
|
+
const before = c.before ?? "";
|
|
1485
|
+
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));
|
|
1486
|
+
if (gated) {
|
|
1194
1487
|
out2.push(
|
|
1195
1488
|
makeFinding(RULE4, policy, {
|
|
1196
1489
|
file: c.path,
|
|
@@ -1215,20 +1508,22 @@ var coverageLowering = {
|
|
|
1215
1508
|
);
|
|
1216
1509
|
}
|
|
1217
1510
|
for (const l of added) {
|
|
1218
|
-
|
|
1511
|
+
for (const s of SWITCHES) {
|
|
1512
|
+
if (!s.re.test(l.content)) continue;
|
|
1513
|
+
if (removed.some((r) => s.re.test(r.content))) continue;
|
|
1219
1514
|
out2.push(
|
|
1220
1515
|
makeFinding(RULE4, policy, {
|
|
1221
1516
|
file: c.path,
|
|
1222
1517
|
line: l.newLine ?? void 0,
|
|
1223
|
-
message:
|
|
1518
|
+
message: s.message,
|
|
1224
1519
|
evidence: l.content.trim(),
|
|
1225
|
-
remediation:
|
|
1520
|
+
remediation: s.remediation
|
|
1226
1521
|
})
|
|
1227
1522
|
);
|
|
1228
1523
|
}
|
|
1229
1524
|
}
|
|
1230
1525
|
if (c.after == null) {
|
|
1231
|
-
const KEY = /\b(branches|functions|lines|statements)\b\s
|
|
1526
|
+
const KEY = /\b(branches|functions|lines|statements|fail[_-]under|target)\b"?\s*[:=]\s*"?(\d+(?:\.\d+)?)/;
|
|
1232
1527
|
const oldNums = /* @__PURE__ */ new Map();
|
|
1233
1528
|
for (const l of removed) {
|
|
1234
1529
|
const m = l.content.match(KEY);
|
|
@@ -1254,7 +1549,7 @@ var coverageLowering = {
|
|
|
1254
1549
|
}
|
|
1255
1550
|
const seen = /* @__PURE__ */ new Set();
|
|
1256
1551
|
return out2.filter((f) => {
|
|
1257
|
-
const k = `${f.rule}|${f.message}`;
|
|
1552
|
+
const k = `${f.rule}|${f.file ?? ""}|${f.message}`;
|
|
1258
1553
|
if (seen.has(k)) return false;
|
|
1259
1554
|
seen.add(k);
|
|
1260
1555
|
return true;
|
|
@@ -1276,23 +1571,287 @@ var invokesCheck = (line) => INVOKES_TOOL.test(line) || INVOKES_SCRIPT.test(line
|
|
|
1276
1571
|
function commandCore(line) {
|
|
1277
1572
|
return line.trim().replace(/^-\s*/, "").replace(/^(?:run|uses):\s*/, "").replace(/\s+/g, " ").trim();
|
|
1278
1573
|
}
|
|
1574
|
+
function canonical(core) {
|
|
1575
|
+
let s = core.replace(/^(['"])(.*)\1$/, "$2").trim();
|
|
1576
|
+
s = s.replace(/^(?:npx|pnpm\s+(?:exec|dlx)|yarn\s+(?:exec|dlx)|bunx|bun\s+x)\s+/, "exec ");
|
|
1577
|
+
s = s.replace(/^(?:npm|pnpm|yarn|bun)\s+(?:run(?:-script)?\s+)?/, "exec ");
|
|
1578
|
+
s = s.replace(/^exec\s+(?:t|tst)(?=\s|$)/, "exec test");
|
|
1579
|
+
s = s.replace(/^exec\s+/, "");
|
|
1580
|
+
return s.replace(/\s+--\s*$/, "").trim();
|
|
1581
|
+
}
|
|
1582
|
+
var usesRef = (core) => core.replace(/@.*$/, "");
|
|
1583
|
+
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/;
|
|
1584
|
+
function survives(removedCore, isUses, afterCores) {
|
|
1585
|
+
if (isUses) {
|
|
1586
|
+
const ref = usesRef(removedCore);
|
|
1587
|
+
return afterCores.some((a) => usesRef(a) === ref) ? { state: "kept" } : { state: "gone" };
|
|
1588
|
+
}
|
|
1589
|
+
const r = canonical(removedCore);
|
|
1590
|
+
let neutralised;
|
|
1591
|
+
for (const raw of afterCores) {
|
|
1592
|
+
const a = canonical(raw);
|
|
1593
|
+
if (a === r) return { state: "kept" };
|
|
1594
|
+
if (a.startsWith(r + " ")) {
|
|
1595
|
+
const rest = a.slice(r.length);
|
|
1596
|
+
if (NEUTRALISING_SUFFIX.test(rest)) neutralised = raw;
|
|
1597
|
+
else return { state: "kept" };
|
|
1598
|
+
} else if (r.startsWith(a + " ") && invokesCheck(a)) {
|
|
1599
|
+
return { state: "kept" };
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
return neutralised ? { state: "neutralised", by: neutralised } : { state: "gone" };
|
|
1603
|
+
}
|
|
1279
1604
|
var uncommented = (v) => v.replace(/\s+#.*$/, "").trim();
|
|
1280
|
-
function
|
|
1281
|
-
const
|
|
1282
|
-
|
|
1605
|
+
function lex(src) {
|
|
1606
|
+
const out2 = [];
|
|
1607
|
+
let i = 0;
|
|
1608
|
+
while (i < src.length) {
|
|
1609
|
+
const ch = src[i];
|
|
1610
|
+
if (/\s/.test(ch)) {
|
|
1611
|
+
i++;
|
|
1612
|
+
continue;
|
|
1613
|
+
}
|
|
1614
|
+
if (ch === "'") {
|
|
1615
|
+
let j = i + 1;
|
|
1616
|
+
let s = "";
|
|
1617
|
+
while (j < src.length) {
|
|
1618
|
+
if (src[j] === "'" && src[j + 1] === "'") {
|
|
1619
|
+
s += "'";
|
|
1620
|
+
j += 2;
|
|
1621
|
+
} else if (src[j] === "'") break;
|
|
1622
|
+
else s += src[j++];
|
|
1623
|
+
}
|
|
1624
|
+
if (j >= src.length) return null;
|
|
1625
|
+
out2.push({ t: "str", v: s });
|
|
1626
|
+
i = j + 1;
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
const num = src.slice(i).match(/^-?\d+(?:\.\d+)?/);
|
|
1630
|
+
if (num) {
|
|
1631
|
+
out2.push({ t: "num", v: num[0] });
|
|
1632
|
+
i += num[0].length;
|
|
1633
|
+
continue;
|
|
1634
|
+
}
|
|
1635
|
+
const op = src.slice(i).match(/^(?:==|!=|&&|\|\||<=|>=|[!()<>])/);
|
|
1636
|
+
if (op) {
|
|
1637
|
+
out2.push({ t: "op", v: op[0] });
|
|
1638
|
+
i += op[0].length;
|
|
1639
|
+
continue;
|
|
1640
|
+
}
|
|
1641
|
+
const id = src.slice(i).match(/^[A-Za-z_][\w.\-[\]*]*/);
|
|
1642
|
+
if (id) {
|
|
1643
|
+
out2.push({ t: "id", v: id[0] });
|
|
1644
|
+
i += id[0].length;
|
|
1645
|
+
continue;
|
|
1646
|
+
}
|
|
1647
|
+
return null;
|
|
1648
|
+
}
|
|
1649
|
+
return out2;
|
|
1650
|
+
}
|
|
1651
|
+
function foldConst(src) {
|
|
1652
|
+
const toks = lex(src);
|
|
1653
|
+
if (!toks) return void 0;
|
|
1654
|
+
let p = 0;
|
|
1655
|
+
const peek = () => toks[p];
|
|
1656
|
+
const eat = (v2) => toks[p]?.t === "op" && toks[p].v === v2 ? (p++, true) : false;
|
|
1657
|
+
const truthy = (v2) => typeof v2 === "string" ? v2 !== "" : typeof v2 === "number" ? v2 !== 0 : v2 === true;
|
|
1658
|
+
const num = (v2) => typeof v2 === "number" ? v2 : typeof v2 === "boolean" ? v2 ? 1 : 0 : v2 === null ? 0 : v2.trim() === "" ? 0 : Number(v2);
|
|
1659
|
+
const eq = (a, b) => {
|
|
1660
|
+
if (typeof a === "string" && typeof b === "string") return a.toLowerCase() === b.toLowerCase();
|
|
1661
|
+
if (a === null && b === null) return true;
|
|
1662
|
+
const x = num(a);
|
|
1663
|
+
const y = num(b);
|
|
1664
|
+
return !Number.isNaN(x) && x === y;
|
|
1665
|
+
};
|
|
1666
|
+
const or = () => {
|
|
1667
|
+
let l = and();
|
|
1668
|
+
while (eat("||")) {
|
|
1669
|
+
const r = and();
|
|
1670
|
+
if (l === void 0 || r === void 0) return void 0;
|
|
1671
|
+
l = truthy(l) ? l : r;
|
|
1672
|
+
}
|
|
1673
|
+
return l;
|
|
1674
|
+
};
|
|
1675
|
+
const and = () => {
|
|
1676
|
+
let l = cmp();
|
|
1677
|
+
while (eat("&&")) {
|
|
1678
|
+
const r = cmp();
|
|
1679
|
+
if (l === void 0 || r === void 0) return void 0;
|
|
1680
|
+
l = truthy(l) ? r : l;
|
|
1681
|
+
}
|
|
1682
|
+
return l;
|
|
1683
|
+
};
|
|
1684
|
+
const cmp = () => {
|
|
1685
|
+
let l = unary();
|
|
1686
|
+
for (; ; ) {
|
|
1687
|
+
const t = peek();
|
|
1688
|
+
if (!t || t.t !== "op" || !/^(?:==|!=|<|>|<=|>=)$/.test(t.v)) return l;
|
|
1689
|
+
p++;
|
|
1690
|
+
const r = unary();
|
|
1691
|
+
if (l === void 0 || r === void 0) return void 0;
|
|
1692
|
+
if (t.v === "==") l = eq(l, r);
|
|
1693
|
+
else if (t.v === "!=") l = !eq(l, r);
|
|
1694
|
+
else {
|
|
1695
|
+
const x = num(l);
|
|
1696
|
+
const y = num(r);
|
|
1697
|
+
if (Number.isNaN(x) || Number.isNaN(y)) return void 0;
|
|
1698
|
+
l = t.v === "<" ? x < y : t.v === ">" ? x > y : t.v === "<=" ? x <= y : x >= y;
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
};
|
|
1702
|
+
const unary = () => {
|
|
1703
|
+
if (eat("!")) {
|
|
1704
|
+
const v2 = unary();
|
|
1705
|
+
return v2 === void 0 ? void 0 : !truthy(v2);
|
|
1706
|
+
}
|
|
1707
|
+
return primary();
|
|
1708
|
+
};
|
|
1709
|
+
const primary = () => {
|
|
1710
|
+
const t = peek();
|
|
1711
|
+
if (!t) return void 0;
|
|
1712
|
+
if (eat("(")) {
|
|
1713
|
+
const v2 = or();
|
|
1714
|
+
return eat(")") ? v2 : void 0;
|
|
1715
|
+
}
|
|
1716
|
+
p++;
|
|
1717
|
+
if (t.t === "str") return t.v;
|
|
1718
|
+
if (t.t === "num") return Number(t.v);
|
|
1719
|
+
if (t.t === "id") {
|
|
1720
|
+
if (/^true$/i.test(t.v)) return true;
|
|
1721
|
+
if (/^false$/i.test(t.v)) return false;
|
|
1722
|
+
if (/^null$/i.test(t.v)) return null;
|
|
1723
|
+
return void 0;
|
|
1724
|
+
}
|
|
1725
|
+
return void 0;
|
|
1726
|
+
};
|
|
1727
|
+
const v = or();
|
|
1728
|
+
return p === toks.length ? v : void 0;
|
|
1729
|
+
}
|
|
1730
|
+
function expressionOf(raw) {
|
|
1731
|
+
const v = uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim();
|
|
1283
1732
|
const m = v.match(/^\$\{\{\s*(.+?)\s*\}\}$/);
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
const
|
|
1288
|
-
if (
|
|
1289
|
-
|
|
1733
|
+
return m ? m[1] : v;
|
|
1734
|
+
}
|
|
1735
|
+
function isAlwaysFalse(raw) {
|
|
1736
|
+
const e = expressionOf(raw);
|
|
1737
|
+
if (e === "") return false;
|
|
1738
|
+
const v = foldConst(e);
|
|
1739
|
+
return v !== void 0 && !(typeof v === "string" ? v !== "" : typeof v === "number" ? v !== 0 : v === true);
|
|
1290
1740
|
}
|
|
1291
1741
|
function isTruthy(raw) {
|
|
1292
|
-
const v = uncommented(raw);
|
|
1293
|
-
if (/^true$/i.test(v)) return true;
|
|
1742
|
+
const v = uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim();
|
|
1743
|
+
if (/^(?:true|yes|on|1)$/i.test(v)) return true;
|
|
1294
1744
|
const m = v.match(/^\$\{\{\s*(.+?)\s*\}\}$/);
|
|
1295
|
-
|
|
1745
|
+
if (!m) return false;
|
|
1746
|
+
const f = foldConst(m[1]);
|
|
1747
|
+
return f !== void 0 && (typeof f === "string" ? f !== "" : typeof f === "number" ? f !== 0 : f === true);
|
|
1748
|
+
}
|
|
1749
|
+
var indentOf = (l) => l.match(/^\s*/)[0].length;
|
|
1750
|
+
var STEP_START = /^\s*-\s+(?:name|uses|run|if|id|with|env|shell|continue-on-error|working-directory|timeout-minutes):/;
|
|
1751
|
+
function stepOf(lines, i) {
|
|
1752
|
+
const ind = indentOf(lines[i]);
|
|
1753
|
+
let s = i - 1;
|
|
1754
|
+
while (s >= 0 && (lines[s].trim() === "" || indentOf(lines[s]) >= ind)) s--;
|
|
1755
|
+
if (s < 0 || !STEP_START.test(lines[s])) return null;
|
|
1756
|
+
const stepInd = indentOf(lines[s]);
|
|
1757
|
+
let e = i + 1;
|
|
1758
|
+
while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > stepInd)) e++;
|
|
1759
|
+
return [s, e];
|
|
1760
|
+
}
|
|
1761
|
+
var stepHasCheck = (lines, range) => range === null || lines.slice(range[0], range[1]).some((l) => invokesCheck(l) || USES.test(l) && CHECK.test(l));
|
|
1762
|
+
function runBlocks(lines) {
|
|
1763
|
+
const out2 = [];
|
|
1764
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1765
|
+
if (!/^\s*-?\s*run:\s*[|>][-+]?\s*$/.test(lines[i])) continue;
|
|
1766
|
+
const ind = indentOf(lines[i].replace(/^(\s*)-\s+/, "$1 "));
|
|
1767
|
+
let e = i + 1;
|
|
1768
|
+
while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > ind)) e++;
|
|
1769
|
+
out2.push({ start: i, end: e, body: lines.slice(i + 1, e) });
|
|
1770
|
+
}
|
|
1771
|
+
return out2;
|
|
1772
|
+
}
|
|
1773
|
+
var NEUTRALISERS = [
|
|
1774
|
+
{ re: /^\s*set\s+\+e\b/, what: "set +e \u2014 a failing check no longer fails the step" },
|
|
1775
|
+
{ re: /^\s*exit\s+0\s*(?:$|[;#])/, what: "exit 0 \u2014 the step ends green whatever the check did" },
|
|
1776
|
+
{ re: /^\s*if\s+false\s*;?\s*then\b/, what: "if false; then \u2014 the check is never run" },
|
|
1777
|
+
{ re: /^\s*:\s*<<\s*['"]?\w+/, what: "a heredoc fed to `:` \u2014 the check is commented out" }
|
|
1778
|
+
];
|
|
1779
|
+
function flowList(v) {
|
|
1780
|
+
const m = v.trim().match(/^\[(.*)\]$/);
|
|
1781
|
+
if (!m) return null;
|
|
1782
|
+
return m[1].split(",").map((s) => s.trim().replace(/^(['"])(.*)\1$/, "$2")).filter(Boolean);
|
|
1783
|
+
}
|
|
1784
|
+
function parseTriggers(src) {
|
|
1785
|
+
const t = { events: /* @__PURE__ */ new Set(), branches: /* @__PURE__ */ new Map(), branchesIgnore: /* @__PURE__ */ new Map(), pathsIgnore: /* @__PURE__ */ new Map(), present: false };
|
|
1786
|
+
const lines = src.split("\n");
|
|
1787
|
+
const i = lines.findIndex((l) => /^(?:on|"on"|'on'|true):/.test(l));
|
|
1788
|
+
if (i < 0) return t;
|
|
1789
|
+
t.present = true;
|
|
1790
|
+
const head = lines[i].replace(/^(?:on|"on"|'on'|true):\s*/, "");
|
|
1791
|
+
const inline = uncommented(head);
|
|
1792
|
+
if (inline) {
|
|
1793
|
+
const list = flowList(inline);
|
|
1794
|
+
for (const e of list ?? [inline]) t.events.add(e);
|
|
1795
|
+
return t;
|
|
1796
|
+
}
|
|
1797
|
+
let event = null;
|
|
1798
|
+
let listKey = null;
|
|
1799
|
+
let listInd = -1;
|
|
1800
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
1801
|
+
const l = lines[j];
|
|
1802
|
+
if (l.trim() === "" || /^\s*#/.test(l)) continue;
|
|
1803
|
+
const ind = indentOf(l);
|
|
1804
|
+
if (ind === 0) break;
|
|
1805
|
+
const body = uncommented(l);
|
|
1806
|
+
const item = body.match(/^\s*-\s+(.+)$/);
|
|
1807
|
+
if (item && listKey && event && ind > listInd) {
|
|
1808
|
+
const target = t[listKey].get(event) ?? [];
|
|
1809
|
+
target.push(item[1].replace(/^(['"])(.*)\1$/, "$2"));
|
|
1810
|
+
t[listKey].set(event, target);
|
|
1811
|
+
continue;
|
|
1812
|
+
}
|
|
1813
|
+
const kv = body.match(/^\s*([\w-]+):\s*(.*)$/);
|
|
1814
|
+
if (!kv) continue;
|
|
1815
|
+
if (ind === 2 || event === null && ind < 4) {
|
|
1816
|
+
event = kv[1];
|
|
1817
|
+
t.events.add(event);
|
|
1818
|
+
listKey = null;
|
|
1819
|
+
continue;
|
|
1820
|
+
}
|
|
1821
|
+
if (event && /^(?:branches|branches-ignore|paths-ignore)$/.test(kv[1])) {
|
|
1822
|
+
listKey = kv[1] === "branches" ? "branches" : kv[1] === "branches-ignore" ? "branchesIgnore" : "pathsIgnore";
|
|
1823
|
+
listInd = ind;
|
|
1824
|
+
const list = flowList(kv[2]);
|
|
1825
|
+
t[listKey].set(event, list ?? []);
|
|
1826
|
+
} else listKey = null;
|
|
1827
|
+
}
|
|
1828
|
+
return t;
|
|
1829
|
+
}
|
|
1830
|
+
var DEFAULT_BRANCH = /^(?:main|master)$/;
|
|
1831
|
+
var ALL = (g) => /^\*\*?$|^\*\*\/\*$/.test(g);
|
|
1832
|
+
function triggerNarrowings(before, after) {
|
|
1833
|
+
const out2 = [];
|
|
1834
|
+
if (!before.present || !after.present) return out2;
|
|
1835
|
+
for (const e of ["push", "pull_request", "pull_request_target"]) {
|
|
1836
|
+
if (before.events.has(e) && !after.events.has(e)) out2.push(`the ${e} trigger was removed \u2014 the workflow no longer runs on it`);
|
|
1837
|
+
}
|
|
1838
|
+
for (const [e, list] of after.pathsIgnore) {
|
|
1839
|
+
const had = before.pathsIgnore.get(e) ?? [];
|
|
1840
|
+
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}`);
|
|
1841
|
+
}
|
|
1842
|
+
for (const [e, list] of after.branchesIgnore) {
|
|
1843
|
+
const had = before.branchesIgnore.get(e) ?? [];
|
|
1844
|
+
if (list.some((g) => (ALL(g) || DEFAULT_BRANCH.test(g)) && !had.includes(g))) out2.push(`on.${e}.branches-ignore now covers the default branch`);
|
|
1845
|
+
}
|
|
1846
|
+
for (const [e, was] of before.branches) {
|
|
1847
|
+
const now = after.branches.get(e);
|
|
1848
|
+
if (!now || !after.events.has(e)) continue;
|
|
1849
|
+
if (now.some(ALL)) continue;
|
|
1850
|
+
const lostDefault = was.some((b) => DEFAULT_BRANCH.test(b) && !now.includes(b));
|
|
1851
|
+
const replaced = was.length > 0 && !was.some((b) => now.includes(b));
|
|
1852
|
+
if (lostDefault || replaced) out2.push(`on.${e}.branches no longer names ${was.filter((b) => !now.includes(b)).join(", ")} (now [${now.join(", ")}])`);
|
|
1853
|
+
}
|
|
1854
|
+
return out2;
|
|
1296
1855
|
}
|
|
1297
1856
|
var ciTampering = {
|
|
1298
1857
|
id: RULE5,
|
|
@@ -1314,9 +1873,14 @@ var ciTampering = {
|
|
|
1314
1873
|
continue;
|
|
1315
1874
|
}
|
|
1316
1875
|
if (!isProtected(c.path, policy, "ci")) continue;
|
|
1876
|
+
const afterLines = c.after != null ? c.after.split("\n") : null;
|
|
1877
|
+
const addedAt = new Set(addedLines(c).map((l) => l.newLine).filter((n) => n != null));
|
|
1317
1878
|
for (const l of addedLines(c)) {
|
|
1318
1879
|
const coe = l.content.match(/^\s*-?\s*continue-on-error:\s*(.+?)\s*$/);
|
|
1319
1880
|
const cond = l.content.match(/^\s*-?\s*if:\s*(.+?)\s*$/);
|
|
1881
|
+
if (!coe && !cond) continue;
|
|
1882
|
+
const step = afterLines && l.newLine != null ? stepOf(afterLines, l.newLine - 1) : null;
|
|
1883
|
+
if (afterLines && step && !stepHasCheck(afterLines, step)) continue;
|
|
1320
1884
|
if (coe && isTruthy(coe[1])) {
|
|
1321
1885
|
out2.push(
|
|
1322
1886
|
makeFinding(RULE5, policy, {
|
|
@@ -1339,15 +1903,72 @@ var ciTampering = {
|
|
|
1339
1903
|
);
|
|
1340
1904
|
}
|
|
1341
1905
|
}
|
|
1342
|
-
|
|
1343
|
-
(
|
|
1344
|
-
|
|
1906
|
+
if (afterLines) {
|
|
1907
|
+
for (const b of runBlocks(afterLines)) {
|
|
1908
|
+
if (!b.body.some(invokesCheck)) continue;
|
|
1909
|
+
const propagates = b.body.some((l) => /\bexit\s+"?\$/.test(l));
|
|
1910
|
+
for (let k = 0; k < b.body.length; k++) {
|
|
1911
|
+
const idx = b.start + 1 + k;
|
|
1912
|
+
if (!addedAt.has(idx + 1)) continue;
|
|
1913
|
+
const n = NEUTRALISERS.find((x) => x.re.test(b.body[k]));
|
|
1914
|
+
if (!n || propagates && /set\s\+e/.test(b.body[k])) continue;
|
|
1915
|
+
out2.push(
|
|
1916
|
+
makeFinding(RULE5, policy, {
|
|
1917
|
+
file: c.path,
|
|
1918
|
+
line: idx + 1,
|
|
1919
|
+
message: `A check's run block was neutralised: ${n.what}.`,
|
|
1920
|
+
evidence: b.body[k].trim(),
|
|
1921
|
+
remediation: "Let the check decide the step. Masking its exit status is the same tamper as removing it."
|
|
1922
|
+
})
|
|
1923
|
+
);
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
for (const l of addedLines(c)) {
|
|
1927
|
+
if (!/^\s*-?\s*shell:\s*.*\{0\}/.test(l.content) || l.newLine == null) continue;
|
|
1928
|
+
const step = stepOf(afterLines, l.newLine - 1);
|
|
1929
|
+
if (step && !stepHasCheck(afterLines, step)) continue;
|
|
1930
|
+
out2.push(
|
|
1931
|
+
makeFinding(RULE5, policy, {
|
|
1932
|
+
file: c.path,
|
|
1933
|
+
line: l.newLine,
|
|
1934
|
+
message: "shell: \u2026 {0} added on a check step \u2014 the shell no longer stops on the first failure.",
|
|
1935
|
+
evidence: l.content.trim(),
|
|
1936
|
+
remediation: "Keep the default shell (fail-fast) on steps that run checks."
|
|
1937
|
+
})
|
|
1938
|
+
);
|
|
1939
|
+
}
|
|
1940
|
+
if (c.before != null) {
|
|
1941
|
+
for (const reason of triggerNarrowings(parseTriggers(c.before), parseTriggers(c.after))) {
|
|
1942
|
+
out2.push(
|
|
1943
|
+
makeFinding(RULE5, policy, {
|
|
1944
|
+
file: c.path,
|
|
1945
|
+
message: `The workflow's triggers were narrowed: ${reason}.`,
|
|
1946
|
+
evidence: reason,
|
|
1947
|
+
remediation: "Keep the workflow running on push and pull_request for the default branch; a check that never runs is no check."
|
|
1948
|
+
})
|
|
1949
|
+
);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
const afterCores = (afterLines ?? addedLines(c).map((l) => l.content)).map(commandCore);
|
|
1345
1954
|
for (const l of removedLines(c)) {
|
|
1346
1955
|
if (/^\s*#/.test(l.content)) continue;
|
|
1347
1956
|
const isUsesLine = USES.test(l.content) && CHECK.test(l.content);
|
|
1348
1957
|
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
1958
|
if (!isUsesLine && !isCheckCommand) continue;
|
|
1350
|
-
|
|
1959
|
+
const s = survives(commandCore(l.content), isUsesLine, afterCores);
|
|
1960
|
+
if (s.state === "kept") continue;
|
|
1961
|
+
if (s.state === "neutralised") {
|
|
1962
|
+
out2.push(
|
|
1963
|
+
makeFinding(RULE5, policy, {
|
|
1964
|
+
file: c.path,
|
|
1965
|
+
message: "A CI check command was neutralised in place: it still runs, but its result no longer decides (or its suite was narrowed).",
|
|
1966
|
+
evidence: `${l.content.trim()} \u2192 ${s.by}`,
|
|
1967
|
+
remediation: "Let the check decide the step; do not mask its status or narrow what it runs."
|
|
1968
|
+
})
|
|
1969
|
+
);
|
|
1970
|
+
continue;
|
|
1971
|
+
}
|
|
1351
1972
|
out2.push(
|
|
1352
1973
|
makeFinding(RULE5, policy, {
|
|
1353
1974
|
file: c.path,
|
|
@@ -2269,44 +2890,215 @@ var hookTampering = {
|
|
|
2269
2890
|
};
|
|
2270
2891
|
|
|
2271
2892
|
// src/detectors/test-deletion.ts
|
|
2893
|
+
import ts4 from "typescript";
|
|
2894
|
+
|
|
2895
|
+
// src/detectors/suite-config.ts
|
|
2896
|
+
import picomatch3 from "picomatch";
|
|
2272
2897
|
import ts3 from "typescript";
|
|
2898
|
+
var JEST_DEFAULT_MATCH = ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)"];
|
|
2899
|
+
var JEST_DEFAULT_IGNORE = ["/node_modules/"];
|
|
2900
|
+
var VITEST_DEFAULT_INCLUDE = ["**/*.{test,spec}.?(c|m)[jt]s?(x)"];
|
|
2901
|
+
var VITEST_DEFAULT_EXCLUDE = [
|
|
2902
|
+
"**/node_modules/**",
|
|
2903
|
+
"**/dist/**",
|
|
2904
|
+
"**/cypress/**",
|
|
2905
|
+
"**/.{idea,git,cache,output,temp}/**",
|
|
2906
|
+
"**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*"
|
|
2907
|
+
];
|
|
2908
|
+
var CANONICAL_SAMPLES = [
|
|
2909
|
+
"src/a.test.ts",
|
|
2910
|
+
"src/a.spec.ts",
|
|
2911
|
+
"test/a.test.ts",
|
|
2912
|
+
"tests/a.test.js",
|
|
2913
|
+
"src/__tests__/a.ts",
|
|
2914
|
+
"a.test.tsx",
|
|
2915
|
+
"a.spec.js",
|
|
2916
|
+
"test/a.test.mjs"
|
|
2917
|
+
];
|
|
2918
|
+
function keyName2(name) {
|
|
2919
|
+
if (ts3.isIdentifier(name) || ts3.isStringLiteral(name)) return name.text;
|
|
2920
|
+
return null;
|
|
2921
|
+
}
|
|
2922
|
+
function literals2(e) {
|
|
2923
|
+
if (ts3.isStringLiteral(e) || ts3.isNoSubstitutionTemplateLiteral(e)) return { items: [e.text], opaque: false };
|
|
2924
|
+
if (!ts3.isArrayLiteralExpression(e)) return { items: [], opaque: true };
|
|
2925
|
+
const items = [];
|
|
2926
|
+
let opaque = false;
|
|
2927
|
+
for (const el of e.elements) {
|
|
2928
|
+
if (ts3.isStringLiteral(el) || ts3.isNoSubstitutionTemplateLiteral(el)) items.push(el.text);
|
|
2929
|
+
else if (ts3.isSpreadElement(el) && /^(?:configDefaults\.|default)(?:exclude|include)$/i.test(el.expression.getText())) {
|
|
2930
|
+
items.push(.../exclude$/i.test(el.expression.getText()) ? VITEST_DEFAULT_EXCLUDE : VITEST_DEFAULT_INCLUDE);
|
|
2931
|
+
} else opaque = true;
|
|
2932
|
+
}
|
|
2933
|
+
return { items, opaque };
|
|
2934
|
+
}
|
|
2935
|
+
function underKey2(node, key2) {
|
|
2936
|
+
for (let p = node.parent; p; p = p.parent) {
|
|
2937
|
+
if (ts3.isPropertyAssignment(p) && keyName2(p.name) === key2) return true;
|
|
2938
|
+
}
|
|
2939
|
+
return false;
|
|
2940
|
+
}
|
|
2941
|
+
function asExpression2(src) {
|
|
2942
|
+
const t = src.trim();
|
|
2943
|
+
return t.startsWith("{") ? `(${t})` : src;
|
|
2944
|
+
}
|
|
2945
|
+
function runnerOf(path) {
|
|
2946
|
+
const base = path.split("/").pop() ?? path;
|
|
2947
|
+
if (/^jest\.config\./.test(base) || base === "package.json") return "jest";
|
|
2948
|
+
if (/^vitest\.config\./.test(base)) return "vitest";
|
|
2949
|
+
return null;
|
|
2950
|
+
}
|
|
2951
|
+
function parseSelection(src, runner) {
|
|
2952
|
+
const sel = { include: null, regex: null, ignore: null, opaque: false, present: false };
|
|
2953
|
+
try {
|
|
2954
|
+
const sf = ts3.createSourceFile("cfg.ts", asExpression2(src), ts3.ScriptTarget.Latest, true, ts3.ScriptKind.TS);
|
|
2955
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2956
|
+
const take = (key2, e) => {
|
|
2957
|
+
const { items, opaque } = literals2(e);
|
|
2958
|
+
if (opaque || seen.has(key2)) sel.opaque = true;
|
|
2959
|
+
seen.add(key2);
|
|
2960
|
+
sel.present = true;
|
|
2961
|
+
return items.map((g) => g.replace(/^<rootDir>\//, ""));
|
|
2962
|
+
};
|
|
2963
|
+
const visit = (node) => {
|
|
2964
|
+
if (ts3.isPropertyAssignment(node)) {
|
|
2965
|
+
const k = keyName2(node.name);
|
|
2966
|
+
if (runner === "jest") {
|
|
2967
|
+
if (k === "testMatch") sel.include = take(k, node.initializer);
|
|
2968
|
+
else if (k === "testRegex") sel.regex = take(k, node.initializer);
|
|
2969
|
+
else if (k === "testPathIgnorePatterns") sel.ignore = take(k, node.initializer);
|
|
2970
|
+
} else if (underKey2(node, "test") && !underKey2(node, "coverage")) {
|
|
2971
|
+
if (k === "include") sel.include = take(k, node.initializer);
|
|
2972
|
+
else if (k === "exclude") sel.ignore = take(k, node.initializer);
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
ts3.forEachChild(node, visit);
|
|
2976
|
+
};
|
|
2977
|
+
visit(sf);
|
|
2978
|
+
} catch {
|
|
2979
|
+
}
|
|
2980
|
+
return sel;
|
|
2981
|
+
}
|
|
2982
|
+
var globs2 = (patterns) => {
|
|
2983
|
+
const ms = patterns.map((g) => {
|
|
2984
|
+
try {
|
|
2985
|
+
return picomatch3(g, { dot: true });
|
|
2986
|
+
} catch {
|
|
2987
|
+
return () => false;
|
|
2988
|
+
}
|
|
2989
|
+
});
|
|
2990
|
+
return (path) => ms.some((m) => m(path));
|
|
2991
|
+
};
|
|
2992
|
+
var regexes = (patterns) => {
|
|
2993
|
+
const rs = patterns.flatMap((p) => {
|
|
2994
|
+
try {
|
|
2995
|
+
return [new RegExp(p.replace(/<rootDir>/g, ""))];
|
|
2996
|
+
} catch {
|
|
2997
|
+
return [];
|
|
2998
|
+
}
|
|
2999
|
+
});
|
|
3000
|
+
return (path) => rs.some((r) => r.test("/" + path));
|
|
3001
|
+
};
|
|
3002
|
+
function predicate(sel, runner) {
|
|
3003
|
+
const includeList = sel.include ?? (runner === "jest" ? sel.regex ? null : JEST_DEFAULT_MATCH : VITEST_DEFAULT_INCLUDE);
|
|
3004
|
+
const ignoreList = sel.ignore ?? (runner === "jest" ? JEST_DEFAULT_IGNORE : VITEST_DEFAULT_EXCLUDE);
|
|
3005
|
+
const included = includeList ? globs2(includeList) : sel.regex ? regexes(sel.regex) : () => true;
|
|
3006
|
+
const ignored = runner === "jest" ? regexes(ignoreList) : globs2(ignoreList);
|
|
3007
|
+
return {
|
|
3008
|
+
selects: (p) => included(p) && !ignored(p),
|
|
3009
|
+
why: (p) => {
|
|
3010
|
+
if (ignored(p)) {
|
|
3011
|
+
const key3 = runner === "jest" ? "testPathIgnorePatterns" : "test.exclude";
|
|
3012
|
+
const hit = ignoreList.find((g) => (runner === "jest" ? regexes([g]) : globs2([g]))(p));
|
|
3013
|
+
return `${key3} now matches it (${JSON.stringify(hit ?? "")})`;
|
|
3014
|
+
}
|
|
3015
|
+
const key2 = sel.include ? runner === "jest" ? "testMatch" : "test.include" : "testRegex";
|
|
3016
|
+
return `${key2} no longer matches it (${JSON.stringify(sel.include ?? sel.regex ?? [])})`;
|
|
3017
|
+
}
|
|
3018
|
+
};
|
|
3019
|
+
}
|
|
3020
|
+
function suiteNarrowings(before, after, path, samples) {
|
|
3021
|
+
const runner = runnerOf(path);
|
|
3022
|
+
if (!runner) return [];
|
|
3023
|
+
const b = parseSelection(before ?? "", runner);
|
|
3024
|
+
const a = parseSelection(after, runner);
|
|
3025
|
+
if (!a.present || a.opaque) return [];
|
|
3026
|
+
const pb = predicate(b, runner);
|
|
3027
|
+
const pa = predicate(a, runner);
|
|
3028
|
+
const out2 = [];
|
|
3029
|
+
for (const s of samples) {
|
|
3030
|
+
if (pb.selects(s) && !pa.selects(s)) out2.push({ path: s, reason: pa.why(s) });
|
|
3031
|
+
}
|
|
3032
|
+
return out2;
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
// src/detectors/test-deletion.ts
|
|
2273
3036
|
var RULE7 = "test-deletion";
|
|
3037
|
+
var JVM_TEST = /^\s*(?:@\w+(?:\([^)]*\))?\s+)*@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/;
|
|
2274
3038
|
var TEST_DEFS = {
|
|
2275
3039
|
py: /^\s*(?:async\s+)?def\s+test\w*\s*\(/,
|
|
2276
3040
|
go: /^func\s+(?:Test|Example|Fuzz)\w*\s*\(/,
|
|
2277
3041
|
rs: /^\s*#\[[\w:]*test[\w:]*(?:\(|\])/,
|
|
2278
3042
|
rb: /^\s*(?:(?:it|specify|test|scenario|example)\s*(?:\(\s*)?['"]|def\s+test_\w+)/,
|
|
2279
|
-
java:
|
|
2280
|
-
kt:
|
|
3043
|
+
java: JVM_TEST,
|
|
3044
|
+
kt: JVM_TEST,
|
|
2281
3045
|
php: /^\s*(?:(?:public\s+)?function\s+test\w*\s*\(|#\[Test\]|\*\s*@test\b)/,
|
|
2282
3046
|
cs: /^\s*\[(?:Fact|Theory|Test|TestMethod|TestCase|DataTestMethod)\b/
|
|
2283
3047
|
};
|
|
2284
3048
|
function calleeName(expr) {
|
|
2285
|
-
if (
|
|
2286
|
-
if (
|
|
3049
|
+
if (ts4.isIdentifier(expr)) return expr.text;
|
|
3050
|
+
if (ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression)) {
|
|
2287
3051
|
return expr.expression.text;
|
|
2288
3052
|
}
|
|
2289
3053
|
return null;
|
|
2290
3054
|
}
|
|
2291
|
-
|
|
3055
|
+
var isEachOf = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && (expr.expression.text === "it" || expr.expression.text === "test") && expr.name.text === "each";
|
|
3056
|
+
function eachRows(call) {
|
|
3057
|
+
const arg = call.arguments[0];
|
|
3058
|
+
if (!arg) return { n: 1, open: true };
|
|
3059
|
+
if (ts4.isArrayLiteralExpression(arg)) {
|
|
3060
|
+
let n = 0;
|
|
3061
|
+
let open = false;
|
|
3062
|
+
for (const el of arg.elements) {
|
|
3063
|
+
if (ts4.isSpreadElement(el)) open = true;
|
|
3064
|
+
else n++;
|
|
3065
|
+
}
|
|
3066
|
+
return { n: Math.max(n, open ? 0 : 1), open };
|
|
3067
|
+
}
|
|
3068
|
+
return { n: 1, open: true };
|
|
3069
|
+
}
|
|
3070
|
+
function countTests(src, path = "spec.ts") {
|
|
2292
3071
|
const lang = langOf(path);
|
|
2293
3072
|
if (lang && lang !== "js") {
|
|
2294
3073
|
const re = TEST_DEFS[lang];
|
|
2295
3074
|
let n2 = 0;
|
|
2296
3075
|
for (const line of src.split("\n")) if (re.test(line)) n2++;
|
|
2297
|
-
return n2;
|
|
3076
|
+
return { min: n2, open: false };
|
|
2298
3077
|
}
|
|
2299
|
-
const sf =
|
|
3078
|
+
const sf = ts4.createSourceFile("spec.ts", src, ts4.ScriptTarget.Latest, true, ts4.ScriptKind.TS);
|
|
2300
3079
|
let n = 0;
|
|
3080
|
+
let open = false;
|
|
2301
3081
|
const visit = (node) => {
|
|
2302
|
-
if (
|
|
2303
|
-
|
|
2304
|
-
|
|
3082
|
+
if (ts4.isCallExpression(node)) {
|
|
3083
|
+
if (isEachOf(node.expression)) {
|
|
3084
|
+
const r = eachRows(node);
|
|
3085
|
+
n += r.n;
|
|
3086
|
+
if (r.open) open = true;
|
|
3087
|
+
} else {
|
|
3088
|
+
const name = calleeName(node.expression);
|
|
3089
|
+
if (name === "it" || name === "test") n++;
|
|
3090
|
+
}
|
|
3091
|
+
} else if (ts4.isTaggedTemplateExpression(node) && isEachOf(node.tag)) {
|
|
3092
|
+
const rows = node.template.getText().split("\n").filter((l) => /\S/.test(l.replace(/[`]/g, ""))).length - 1;
|
|
3093
|
+
n += Math.max(rows, 1);
|
|
2305
3094
|
}
|
|
2306
|
-
|
|
3095
|
+
ts4.forEachChild(node, visit);
|
|
2307
3096
|
};
|
|
2308
3097
|
visit(sf);
|
|
2309
|
-
return n;
|
|
3098
|
+
return { min: n, open };
|
|
3099
|
+
}
|
|
3100
|
+
function countTestBlocks(src, path = "spec.ts") {
|
|
3101
|
+
return countTests(src, path).min;
|
|
2310
3102
|
}
|
|
2311
3103
|
function significantLines(src, path) {
|
|
2312
3104
|
const out2 = /* @__PURE__ */ new Set();
|
|
@@ -2317,32 +3109,63 @@ function significantLines(src, path) {
|
|
|
2317
3109
|
}
|
|
2318
3110
|
return out2;
|
|
2319
3111
|
}
|
|
3112
|
+
var OTHER_RUNNER_DIR = /(?:^|\/)(?:e2e|cypress|playwright)\//;
|
|
3113
|
+
function runnerSamples(configPath, policy, ctx) {
|
|
3114
|
+
const files = trackedFiles(ctx);
|
|
3115
|
+
if (!files) return CANONICAL_SAMPLES;
|
|
3116
|
+
const root = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/") + 1) : "";
|
|
3117
|
+
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));
|
|
3118
|
+
return own.length ? own : CANONICAL_SAMPLES;
|
|
3119
|
+
}
|
|
3120
|
+
function joinRoot(root, to) {
|
|
3121
|
+
const parts = root ? root.split("/") : [];
|
|
3122
|
+
for (const seg of to.replace(/\/+$/, "").split("/")) {
|
|
3123
|
+
if (seg === "" || seg === ".") continue;
|
|
3124
|
+
if (seg === "..") {
|
|
3125
|
+
if (parts.length === 0) return null;
|
|
3126
|
+
parts.pop();
|
|
3127
|
+
} else parts.push(seg);
|
|
3128
|
+
}
|
|
3129
|
+
return parts.join("/");
|
|
3130
|
+
}
|
|
3131
|
+
function leavesGlob(dir, dest, policy, ctx) {
|
|
3132
|
+
const files = trackedFiles(ctx);
|
|
3133
|
+
if (!files || !dir) return false;
|
|
3134
|
+
const src = dir.replace(/^\.\//, "").replace(/\/+$/, "") + "/";
|
|
3135
|
+
const to = dest.replace(/^\.\//, "").replace(/\/+$/, "") + "/";
|
|
3136
|
+
return files.some((f) => f.startsWith(src) && isProtected(f, policy, "tests") && !isProtected(to + f.slice(src.length), policy, "tests"));
|
|
3137
|
+
}
|
|
3138
|
+
var SED_INPLACE = /(?:^|\s)(?:-i\b|--in-place\b|-[a-zA-Z]*i[a-zA-Z]*\b)/;
|
|
2320
3139
|
var testDeletion = {
|
|
2321
3140
|
id: RULE7,
|
|
2322
3141
|
surface: ["file", "command"],
|
|
2323
3142
|
certainty: "mechanical",
|
|
2324
|
-
run(changes, policy) {
|
|
3143
|
+
run(changes, policy, _view, ctx) {
|
|
2325
3144
|
const out2 = [];
|
|
2326
3145
|
const addedTestLines = /* @__PURE__ */ new Set();
|
|
2327
3146
|
let addedTestBlocks = 0;
|
|
2328
3147
|
for (const c of changes) {
|
|
2329
|
-
if (c.kind
|
|
3148
|
+
if (c.kind !== "file" || c.after == null || !isProtected(c.path, policy, "tests")) continue;
|
|
3149
|
+
if (c.op === "add") {
|
|
2330
3150
|
for (const l of significantLines(c.after, c.path)) addedTestLines.add(l);
|
|
2331
3151
|
addedTestBlocks += countTestBlocks(c.after, c.path);
|
|
3152
|
+
} else if (c.before != null) {
|
|
3153
|
+
const before = significantLines(c.before, c.path);
|
|
3154
|
+
for (const l of significantLines(c.after, c.path)) if (!before.has(l)) addedTestLines.add(l);
|
|
3155
|
+
addedTestBlocks += Math.max(0, countTestBlocks(c.after, c.path) - countTestBlocks(c.before, c.path));
|
|
2332
3156
|
}
|
|
2333
3157
|
}
|
|
2334
|
-
const isRelocation = (
|
|
2335
|
-
|
|
2336
|
-
if (sig.size === 0 || addedTestLines.size === 0) return false;
|
|
3158
|
+
const isRelocation = (removed, blocksRemoved) => {
|
|
3159
|
+
if (removed.size === 0 || addedTestLines.size === 0) return false;
|
|
2337
3160
|
let hit = 0;
|
|
2338
|
-
for (const l of
|
|
2339
|
-
return hit /
|
|
3161
|
+
for (const l of removed) if (addedTestLines.has(l)) hit++;
|
|
3162
|
+
return hit / removed.size >= 0.6 && addedTestBlocks >= blocksRemoved;
|
|
2340
3163
|
};
|
|
2341
3164
|
for (const c of changes) {
|
|
2342
3165
|
if (c.kind === "file") {
|
|
2343
3166
|
const isTest = isProtected(c.path, policy, "tests");
|
|
2344
3167
|
if (c.op === "delete" && isTest) {
|
|
2345
|
-
if (c.before != null && isRelocation(c.before, c.path)) continue;
|
|
3168
|
+
if (c.before != null && isRelocation(significantLines(c.before, c.path), countTestBlocks(c.before, c.path))) continue;
|
|
2346
3169
|
out2.push(
|
|
2347
3170
|
makeFinding(RULE7, policy, {
|
|
2348
3171
|
file: c.path,
|
|
@@ -2361,41 +3184,95 @@ var testDeletion = {
|
|
|
2361
3184
|
})
|
|
2362
3185
|
);
|
|
2363
3186
|
} 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) {
|
|
3187
|
+
const before = countTests(c.before, c.path);
|
|
3188
|
+
const after = countTests(c.after, c.path);
|
|
3189
|
+
if (after.min < before.min && !after.open) {
|
|
3190
|
+
const afterLines = significantLines(c.after, c.path);
|
|
3191
|
+
const removed = new Set([...significantLines(c.before, c.path)].filter((l) => !afterLines.has(l)));
|
|
3192
|
+
if (isRelocation(removed, before.min - after.min)) continue;
|
|
2367
3193
|
out2.push(
|
|
2368
3194
|
makeFinding(RULE7, policy, {
|
|
2369
3195
|
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}`,
|
|
3196
|
+
message: `Test blocks removed: ${before.min} \u2192 ${after.min} it()/test() in this spec (each-table rows count as tests).`,
|
|
3197
|
+
evidence: `${before.min - after.min} test block(s) removed from ${c.path}`,
|
|
2372
3198
|
remediation: "Keep the assertions and fix the code. Removing test blocks to go green is the tamper."
|
|
2373
3199
|
})
|
|
2374
3200
|
);
|
|
2375
3201
|
}
|
|
3202
|
+
} else if (c.op !== "delete" && c.after != null && !isTest && isProtected(c.path, policy, "config") && runnerOf(c.path)) {
|
|
3203
|
+
for (const n of suiteNarrowings(c.before, c.after, c.path, runnerSamples(c.path, policy, ctx))) {
|
|
3204
|
+
out2.push(
|
|
3205
|
+
makeFinding(RULE7, policy, {
|
|
3206
|
+
file: c.path,
|
|
3207
|
+
message: `The runner no longer selects ${n.path}: ${n.reason}.`,
|
|
3208
|
+
evidence: `${n.path} dropped from the suite by ${c.path}`,
|
|
3209
|
+
remediation: "Keep the runner pointed at the whole suite. Excluding a spec from the runner removes its tests without touching the file."
|
|
3210
|
+
})
|
|
3211
|
+
);
|
|
3212
|
+
}
|
|
2376
3213
|
}
|
|
2377
3214
|
} else {
|
|
3215
|
+
let root = "";
|
|
2378
3216
|
for (const seg of segments(c.raw)) {
|
|
2379
3217
|
const toks = tokens(seg).map(unquote);
|
|
2380
|
-
const
|
|
2381
|
-
if (
|
|
3218
|
+
const cdTo = toks[0] === "cd" ? toks[1] : null;
|
|
3219
|
+
if (cdTo !== null) {
|
|
3220
|
+
root = cdTo === void 0 || /^[/~$]/.test(cdTo) || root === null ? null : joinRoot(root, cdTo);
|
|
3221
|
+
continue;
|
|
3222
|
+
}
|
|
3223
|
+
const inRepo = (t) => root ? `${root}/${t}` : t;
|
|
3224
|
+
const listing = trackedFiles(ctx);
|
|
3225
|
+
const testToks = toks.filter((t) => {
|
|
3226
|
+
if (!isProtected(t, policy, "tests")) return false;
|
|
3227
|
+
if (!listing || root === null) return true;
|
|
3228
|
+
const looksLikeFile = /\.[A-Za-z0-9]+$/.test(t.split("/").pop() ?? "");
|
|
3229
|
+
if (looksLikeFile || listing.includes(inRepo(t))) return true;
|
|
3230
|
+
return containsProtected(inRepo(t), policy, "tests", ctx);
|
|
3231
|
+
});
|
|
3232
|
+
const dirToks = toks.filter(
|
|
3233
|
+
(t) => !t.startsWith("-") && !testToks.includes(t) && !/[*?{}[\]]/.test(t) && (root === null ? containsProtected(t, policy, "tests", ctx) : containsProtected(inRepo(t), policy, "tests", ctx))
|
|
3234
|
+
);
|
|
3235
|
+
if (testToks.length === 0 && dirToks.length === 0) continue;
|
|
3236
|
+
const named = [...testToks, ...dirToks];
|
|
3237
|
+
const cmd = toks[0] ?? "";
|
|
2382
3238
|
let why = null;
|
|
2383
3239
|
let evidence = seg;
|
|
2384
3240
|
const redirectTarget = seg.match(/>\s*(\S+)/)?.[1];
|
|
2385
3241
|
const redirectsOntoTest = redirectTarget ? isProtected(unquote(redirectTarget), policy, "tests") : false;
|
|
2386
|
-
|
|
3242
|
+
const findDeletes = cmd === "find" && /\s(?:-delete\b|-exec\s+(?:rm|unlink)\b|-execdir\s+(?:rm|unlink)\b)/.test(seg);
|
|
3243
|
+
const gitRestoresOld = cmd === "git" && /^git\s+(?:checkout|restore)\b/.test(seg) && (() => {
|
|
3244
|
+
const src = seg.match(/--source[= ]\s*(\S+)/)?.[1];
|
|
3245
|
+
const rev = src ?? toks.slice(2).find((t) => !t.startsWith("-") && !named.includes(t) && t !== "--");
|
|
3246
|
+
return !!rev && rev !== "HEAD" && (!!src || seg.includes(" -- "));
|
|
3247
|
+
})();
|
|
3248
|
+
if (/\brm\b/.test(seg) && !/^(?:git|npm|yarn|pnpm|bun)\b/.test(seg)) {
|
|
2387
3249
|
why = "rm deletes a test file";
|
|
2388
|
-
} else if (
|
|
3250
|
+
} else if (cmd === "git" && /^git\s+rm\b/.test(seg)) {
|
|
3251
|
+
why = "git rm deletes a test file";
|
|
3252
|
+
} else if (cmd === "sed" && SED_INPLACE.test(seg)) {
|
|
2389
3253
|
why = "sed -i rewrites a test file in place";
|
|
3254
|
+
} else if (cmd === "perl" && /(?:^|\s)-[a-zA-Z]*i\b|\s-i\S*/.test(seg)) {
|
|
3255
|
+
why = "perl -i rewrites a test file in place";
|
|
2390
3256
|
} else if (/\btruncate\b/.test(seg)) {
|
|
2391
3257
|
why = "truncate empties a test file";
|
|
2392
3258
|
} else if (redirectsOntoTest) {
|
|
2393
3259
|
why = "a redirect overwrites/empties a test file";
|
|
3260
|
+
} else if (cmd === "cp" && testToks.length > 0 && isProtected(toks[toks.length - 1] ?? "", policy, "tests") && !isProtected(toks[toks.length - 2] ?? "", policy, "tests")) {
|
|
3261
|
+
why = "cp overwrites a test file with something else";
|
|
3262
|
+
} else if (cmd === "tee" && !toks.includes("-a") && !toks.includes("--append")) {
|
|
3263
|
+
why = "tee overwrites a test file";
|
|
3264
|
+
} else if (findDeletes) {
|
|
3265
|
+
why = "find -delete removes test files";
|
|
3266
|
+
} else if (gitRestoresOld) {
|
|
3267
|
+
why = "git checkout <rev> replaces a test file with an older version";
|
|
2394
3268
|
} else if (/\bmv\b/.test(seg)) {
|
|
2395
3269
|
const dest = toks[toks.length - 1];
|
|
2396
|
-
if (dest && !isProtected(dest, policy, "tests")) {
|
|
3270
|
+
if (dest && testToks.length > 0 && !isProtected(dest, policy, "tests")) {
|
|
2397
3271
|
why = "mv renames a test file out of the test glob";
|
|
2398
3272
|
evidence = `${testToks[0]} \u2192 ${dest}`;
|
|
3273
|
+
} else if (dest && testToks.length === 0 && dirToks[0] !== dest && leavesGlob(dirToks[0] ?? "", dest, policy, ctx)) {
|
|
3274
|
+
why = "mv renames a test directory so its specs leave the test glob";
|
|
3275
|
+
evidence = `${dirToks[0]} \u2192 ${dest}`;
|
|
2399
3276
|
}
|
|
2400
3277
|
}
|
|
2401
3278
|
if (why) {
|
|
@@ -2415,9 +3292,40 @@ var testDeletion = {
|
|
|
2415
3292
|
};
|
|
2416
3293
|
|
|
2417
3294
|
// src/detectors/test-content-removal.ts
|
|
3295
|
+
import ts5 from "typescript";
|
|
2418
3296
|
var RULE8 = "test-content-removal";
|
|
2419
3297
|
var MIN_REMOVED_LINES = 3;
|
|
3298
|
+
var MIN_REMOVED_ROWS = 2;
|
|
2420
3299
|
var ws = (s) => s.replace(/\s+/g, "");
|
|
3300
|
+
function eachTables(src, path) {
|
|
3301
|
+
const rows = /* @__PURE__ */ new Set();
|
|
3302
|
+
let open = false;
|
|
3303
|
+
if (langOf(path) !== "js" && langOf(path) !== null) return { rows, open };
|
|
3304
|
+
try {
|
|
3305
|
+
const sf = ts5.createSourceFile("spec.ts", src, ts5.ScriptTarget.Latest, true, ts5.ScriptKind.TS);
|
|
3306
|
+
const visit = (node) => {
|
|
3307
|
+
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)) {
|
|
3308
|
+
const arg = node.arguments[0];
|
|
3309
|
+
if (arg && ts5.isArrayLiteralExpression(arg)) {
|
|
3310
|
+
for (const el of arg.elements) {
|
|
3311
|
+
if (ts5.isSpreadElement(el)) {
|
|
3312
|
+
open = true;
|
|
3313
|
+
continue;
|
|
3314
|
+
}
|
|
3315
|
+
for (const raw of el.getText(sf).split("\n")) {
|
|
3316
|
+
const l = raw.trim().replace(/,$/, "");
|
|
3317
|
+
if (isSignificantLine(l, "js")) rows.add(l);
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
} else open = true;
|
|
3321
|
+
}
|
|
3322
|
+
ts5.forEachChild(node, visit);
|
|
3323
|
+
};
|
|
3324
|
+
visit(sf);
|
|
3325
|
+
} catch {
|
|
3326
|
+
}
|
|
3327
|
+
return { rows, open };
|
|
3328
|
+
}
|
|
2421
3329
|
function significantLinesOrdered(src, path) {
|
|
2422
3330
|
const out2 = [];
|
|
2423
3331
|
const lang = langOf(path);
|
|
@@ -2434,16 +3342,23 @@ var testContentRemoval = {
|
|
|
2434
3342
|
run(changes, policy) {
|
|
2435
3343
|
const out2 = [];
|
|
2436
3344
|
let keptPool = "";
|
|
3345
|
+
let addedPool = "";
|
|
2437
3346
|
for (const c of changes) {
|
|
2438
|
-
if (c.kind
|
|
2439
|
-
|
|
3347
|
+
if (c.kind !== "file" || c.after == null) continue;
|
|
3348
|
+
if (isProtected(c.path, policy, "tests")) keptPool += ws(significantLinesOrdered(c.after, c.path).join("\n"));
|
|
3349
|
+
if (c.op === "add") addedPool += ws(c.after);
|
|
3350
|
+
else if (c.before != null) {
|
|
3351
|
+
const had = new Set(c.before.split("\n").map((l) => l.trim()));
|
|
3352
|
+
addedPool += ws(c.after.split("\n").filter((l) => !had.has(l.trim())).join("\n"));
|
|
2440
3353
|
}
|
|
2441
3354
|
}
|
|
2442
3355
|
for (const c of changes) {
|
|
2443
3356
|
if (c.kind !== "file" || c.op !== "modify" && c.op !== "rename" || c.before == null || c.after == null) continue;
|
|
2444
3357
|
if (!isProtected(c.path, policy, "tests")) continue;
|
|
2445
3358
|
if (isProtected(c.path, policy, "snapshots")) continue;
|
|
2446
|
-
|
|
3359
|
+
const blocksBefore = countTests(c.before, c.path);
|
|
3360
|
+
const blocksAfter = countTests(c.after, c.path);
|
|
3361
|
+
if (blocksAfter.min < blocksBefore.min && !blocksAfter.open) continue;
|
|
2447
3362
|
const beforeSig = significantLinesOrdered(c.before, c.path);
|
|
2448
3363
|
const afterSig = significantLinesOrdered(c.after, c.path);
|
|
2449
3364
|
const netDrop = beforeSig.length - afterSig.length;
|
|
@@ -2454,6 +3369,19 @@ var testContentRemoval = {
|
|
|
2454
3369
|
if (keptPool.includes(ws(line))) continue;
|
|
2455
3370
|
gone.push(line);
|
|
2456
3371
|
}
|
|
3372
|
+
const table = eachTables(c.before, c.path);
|
|
3373
|
+
const goneRows = table.rows.size && eachTables(c.after, c.path).open ? gone.filter((l) => table.rows.has(l.replace(/,$/, "")) && !addedPool.includes(ws(l))) : [];
|
|
3374
|
+
if (goneRows.length >= MIN_REMOVED_ROWS) {
|
|
3375
|
+
out2.push(
|
|
3376
|
+
makeFinding(RULE8, policy, {
|
|
3377
|
+
file: c.path,
|
|
3378
|
+
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.`,
|
|
3379
|
+
evidence: goneRows.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
|
|
3380
|
+
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."
|
|
3381
|
+
})
|
|
3382
|
+
);
|
|
3383
|
+
continue;
|
|
3384
|
+
}
|
|
2457
3385
|
if (gone.length >= MIN_REMOVED_LINES && netDrop >= MIN_REMOVED_LINES) {
|
|
2458
3386
|
out2.push(
|
|
2459
3387
|
makeFinding(RULE8, policy, {
|
|
@@ -2692,8 +3620,8 @@ function validate(r, where) {
|
|
|
2692
3620
|
if (r.ignore !== void 0 && !isStringList(r.ignore)) bad(`ignore must be a list of globs, got ${show(r.ignore)}`);
|
|
2693
3621
|
if (r.protected !== void 0) {
|
|
2694
3622
|
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(
|
|
3623
|
+
for (const [cat, globs3] of Object.entries(r.protected)) {
|
|
3624
|
+
if (!isStringList(globs3)) bad(`protected.${cat} must be a list of globs, got ${show(globs3)}`);
|
|
2697
3625
|
}
|
|
2698
3626
|
}
|
|
2699
3627
|
if (r.signoff !== void 0) {
|
|
@@ -2725,10 +3653,10 @@ function parsePolicy(raw, where = POLICY_FILE) {
|
|
|
2725
3653
|
const version = normalizeVersion(r.version, where);
|
|
2726
3654
|
validate(r, where);
|
|
2727
3655
|
const base = defaultPolicy(version);
|
|
2728
|
-
const
|
|
2729
|
-
const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list]) => [cat,
|
|
3656
|
+
const globs3 = (list) => list?.map(normalizeGlob);
|
|
3657
|
+
const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list]) => [cat, globs3(list) ?? []])) : void 0;
|
|
2730
3658
|
const userRules = r.rules ? Object.fromEntries(
|
|
2731
|
-
Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude:
|
|
3659
|
+
Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude: globs3(cfg.exclude) } : cfg])
|
|
2732
3660
|
) : void 0;
|
|
2733
3661
|
return {
|
|
2734
3662
|
version,
|
|
@@ -2738,7 +3666,7 @@ function parsePolicy(raw, where = POLICY_FILE) {
|
|
|
2738
3666
|
// an `exclude`-only override drop the rule's baseline severity (see mergeRules).
|
|
2739
3667
|
protected: mergeProtected(base.protected, userProtected),
|
|
2740
3668
|
rules: mergeRules(base.rules, userRules),
|
|
2741
|
-
ignore:
|
|
3669
|
+
ignore: globs3(r.ignore) ?? base.ignore,
|
|
2742
3670
|
signoff: {
|
|
2743
3671
|
requiredFor: r.signoff?.required_for ?? r.signoff?.requiredFor ?? base.signoff.requiredFor,
|
|
2744
3672
|
ledger: r.signoff?.ledger ?? base.signoff.ledger
|
|
@@ -3171,7 +4099,7 @@ import { createHash as createHash4 } from "node:crypto";
|
|
|
3171
4099
|
import { join as join9 } from "node:path";
|
|
3172
4100
|
|
|
3173
4101
|
// src/adapters/claude/changes.ts
|
|
3174
|
-
import { execFileSync as
|
|
4102
|
+
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
3175
4103
|
import { readFileSync as readFileSync5, existsSync as existsSync4, writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
3176
4104
|
import { tmpdir } from "node:os";
|
|
3177
4105
|
import { isAbsolute as isAbsolute2, join as join5, relative, resolve } from "node:path";
|
|
@@ -3211,7 +4139,7 @@ function synthFileChange(displayPath, before, after) {
|
|
|
3211
4139
|
writeFileSync(a, before ?? "");
|
|
3212
4140
|
writeFileSync(b, after ?? "");
|
|
3213
4141
|
try {
|
|
3214
|
-
raw =
|
|
4142
|
+
raw = execFileSync3("git", ["diff", "--no-index", "--no-color", "--text", a, b], {
|
|
3215
4143
|
encoding: "utf8",
|
|
3216
4144
|
maxBuffer: 256 * 1024 * 1024
|
|
3217
4145
|
});
|
|
@@ -3335,12 +4263,12 @@ function advanceTurnBaseline(cwd, sessionId) {
|
|
|
3335
4263
|
import { createHash as createHash2 } from "node:crypto";
|
|
3336
4264
|
import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync7, statSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
3337
4265
|
import { isAbsolute as isAbsolute3, join as join7 } from "node:path";
|
|
3338
|
-
import { execFileSync as
|
|
4266
|
+
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
3339
4267
|
var UNSAFE2 = /[^A-Za-z0-9._-]/g;
|
|
3340
4268
|
var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
|
|
3341
4269
|
function gitDir2(cwd) {
|
|
3342
4270
|
try {
|
|
3343
|
-
const d =
|
|
4271
|
+
const d = execFileSync4("git", ["rev-parse", "--git-dir"], {
|
|
3344
4272
|
cwd,
|
|
3345
4273
|
encoding: "utf8",
|
|
3346
4274
|
stdio: ["ignore", "pipe", "pipe"]
|
|
@@ -3985,7 +4913,7 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
|
|
|
3985
4913
|
}
|
|
3986
4914
|
|
|
3987
4915
|
// src/cli/init.ts
|
|
3988
|
-
import { execFileSync as
|
|
4916
|
+
import { execFileSync as execFileSync5 } from "node:child_process";
|
|
3989
4917
|
import { createHash as createHash5 } from "node:crypto";
|
|
3990
4918
|
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync11, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
|
|
3991
4919
|
import { basename, dirname as dirname3, isAbsolute as isAbsolute4, join as join10, relative as relative2, resolve as resolve2 } from "node:path";
|
|
@@ -3994,7 +4922,7 @@ var errText2 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/
|
|
|
3994
4922
|
var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3995
4923
|
function gitConfig(cwd, key2) {
|
|
3996
4924
|
try {
|
|
3997
|
-
return
|
|
4925
|
+
return execFileSync5("git", ["config", "--get", key2], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
|
|
3998
4926
|
} catch {
|
|
3999
4927
|
return null;
|
|
4000
4928
|
}
|
|
@@ -4299,7 +5227,7 @@ var MANAGED_HOOKS = [
|
|
|
4299
5227
|
];
|
|
4300
5228
|
function hooksDir(cwd) {
|
|
4301
5229
|
try {
|
|
4302
|
-
const out2 =
|
|
5230
|
+
const out2 = execFileSync5("git", ["rev-parse", "--git-path", "hooks"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
4303
5231
|
if (out2) return resolve2(cwd, out2);
|
|
4304
5232
|
} catch {
|
|
4305
5233
|
}
|
|
@@ -4591,14 +5519,14 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
|
|
|
4591
5519
|
}
|
|
4592
5520
|
|
|
4593
5521
|
// src/cli/verify.ts
|
|
4594
|
-
import { execFileSync as
|
|
5522
|
+
import { execFileSync as execFileSync8, spawnSync } from "node:child_process";
|
|
4595
5523
|
import { createHash as createHash8 } from "node:crypto";
|
|
4596
5524
|
import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync14, readlinkSync as readlinkSync2, rmSync as rmSync2, statSync as statSync7, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync10 } from "node:fs";
|
|
4597
5525
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
4598
5526
|
import { dirname as dirname4, join as join13, resolve as resolve3 } from "node:path";
|
|
4599
5527
|
|
|
4600
5528
|
// src/git/trusted.ts
|
|
4601
|
-
import { execFileSync as
|
|
5529
|
+
import { execFileSync as execFileSync6 } from "node:child_process";
|
|
4602
5530
|
import { createHash as createHash6 } from "node:crypto";
|
|
4603
5531
|
import { existsSync as existsSync9, readFileSync as readFileSync12 } from "node:fs";
|
|
4604
5532
|
import { isAbsolute as isAbsolute5, join as join11 } from "node:path";
|
|
@@ -4607,7 +5535,7 @@ function trustedGitEnv() {
|
|
|
4607
5535
|
}
|
|
4608
5536
|
function gitDirOf(cwd) {
|
|
4609
5537
|
try {
|
|
4610
|
-
const d =
|
|
5538
|
+
const d = execFileSync6("git", ["rev-parse", "--git-dir"], {
|
|
4611
5539
|
cwd,
|
|
4612
5540
|
encoding: "utf8",
|
|
4613
5541
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -4623,7 +5551,7 @@ function objectRewriteState(cwd) {
|
|
|
4623
5551
|
const parts = [];
|
|
4624
5552
|
try {
|
|
4625
5553
|
parts.push(
|
|
4626
|
-
"replace:" +
|
|
5554
|
+
"replace:" + execFileSync6("git", ["for-each-ref", "--format=%(refname) %(objectname)", "refs/replace/"], {
|
|
4627
5555
|
cwd,
|
|
4628
5556
|
encoding: "utf8",
|
|
4629
5557
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -4650,12 +5578,12 @@ function objectRewriteState(cwd) {
|
|
|
4650
5578
|
}
|
|
4651
5579
|
|
|
4652
5580
|
// src/fingerprint.ts
|
|
4653
|
-
import { execFileSync as
|
|
5581
|
+
import { execFileSync as execFileSync7 } from "node:child_process";
|
|
4654
5582
|
import { createHash as createHash7 } from "node:crypto";
|
|
4655
5583
|
import { readdirSync as readdirSync3, readFileSync as readFileSync13, readlinkSync, statSync as statSync6 } from "node:fs";
|
|
4656
5584
|
import { join as join12 } from "node:path";
|
|
4657
5585
|
function treeFingerprint(cwd) {
|
|
4658
|
-
const files =
|
|
5586
|
+
const files = execFileSync7("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
4659
5587
|
cwd,
|
|
4660
5588
|
encoding: "utf8",
|
|
4661
5589
|
maxBuffer: 1 << 28,
|
|
@@ -4787,7 +5715,7 @@ var VERIFICATION_SURFACE = [
|
|
|
4787
5715
|
"**/*.runsettings"
|
|
4788
5716
|
];
|
|
4789
5717
|
function git2(args, cwd) {
|
|
4790
|
-
return
|
|
5718
|
+
return execFileSync8("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
4791
5719
|
}
|
|
4792
5720
|
function resolveBase(base, cwd) {
|
|
4793
5721
|
assertRev(base);
|
|
@@ -4839,9 +5767,9 @@ function verifierInputs(cmd, atBase, policy) {
|
|
|
4839
5767
|
const tok = raw.replace(/^['"]|['"]$/g, "").replace(/^\.\//, "");
|
|
4840
5768
|
if (tok && baseFiles.has(tok)) picked.add(tok);
|
|
4841
5769
|
}
|
|
4842
|
-
const
|
|
4843
|
-
if (
|
|
4844
|
-
for (const rel of atBase) if (matchesAny(rel,
|
|
5770
|
+
const globs3 = policy.verify?.inputs;
|
|
5771
|
+
if (globs3 && globs3.length) {
|
|
5772
|
+
for (const rel of atBase) if (matchesAny(rel, globs3)) picked.add(rel);
|
|
4845
5773
|
}
|
|
4846
5774
|
return picked;
|
|
4847
5775
|
}
|
|
@@ -4883,7 +5811,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
|
|
|
4883
5811
|
if (e.type === "commit") {
|
|
4884
5812
|
throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
|
|
4885
5813
|
}
|
|
4886
|
-
const content =
|
|
5814
|
+
const content = execFileSync8("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
4887
5815
|
const out2 = join13(dest, e.path);
|
|
4888
5816
|
mkdirSync6(dirname4(out2), { recursive: true });
|
|
4889
5817
|
rmSync2(out2, { force: true });
|
|
@@ -5158,13 +6086,13 @@ function parseVerify(args) {
|
|
|
5158
6086
|
}
|
|
5159
6087
|
|
|
5160
6088
|
// src/cli/run.ts
|
|
5161
|
-
import { execFileSync as
|
|
6089
|
+
import { execFileSync as execFileSync9, spawnSync as spawnSync2 } from "node:child_process";
|
|
5162
6090
|
import { readdirSync as readdirSync4, readFileSync as readFileSync15, realpathSync } from "node:fs";
|
|
5163
6091
|
import { resolve as resolve4 } from "node:path";
|
|
5164
6092
|
var out = (s) => process.stdout.write(s + "\n");
|
|
5165
6093
|
var err = (s) => process.stderr.write(s + "\n");
|
|
5166
6094
|
function git3(args, cwd) {
|
|
5167
|
-
return
|
|
6095
|
+
return execFileSync9("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
5168
6096
|
}
|
|
5169
6097
|
function nowTicks() {
|
|
5170
6098
|
try {
|
|
@@ -5289,7 +6217,7 @@ tamperward run \u2014 agent exit ${agentExit}; HISTORY_REWRITE \u2192 ENFORCEMEN
|
|
|
5289
6217
|
const verifyCode = runVerify({ cwd, base, cmd: frozenCmd, budget: frozenBudget, policyOverride: frozenPolicy });
|
|
5290
6218
|
if (opts.settle && opts.settle > 0) {
|
|
5291
6219
|
try {
|
|
5292
|
-
|
|
6220
|
+
execFileSync9("sleep", [String(opts.settle)]);
|
|
5293
6221
|
} catch {
|
|
5294
6222
|
}
|
|
5295
6223
|
}
|