tamperward 2.5.0 → 2.6.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 +862 -229
- 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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
+
return others.filter((rel) => !exclude?.has(rel) && (!keep || keep(rel))).map((rel) => addOf(rel, opts.cwd));
|
|
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);
|
|
348
368
|
}
|
|
349
|
-
function
|
|
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, globs4) {
|
|
590
|
+
return !!globs4 && globs4.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, globs4] of Object.entries(policy.protected ?? {})) {
|
|
594
|
+
if (matchesAny(path, globs4)) 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, globs4] of Object.entries(user ?? {})) {
|
|
585
611
|
const have = new Set(out2[cat] ?? []);
|
|
586
|
-
out2[cat] = [...out2[cat] ?? [], ...(
|
|
612
|
+
out2[cat] = [...out2[cat] ?? [], ...(globs4 ?? []).filter((g) => !have.has(g))];
|
|
587
613
|
}
|
|
588
614
|
return out2;
|
|
589
615
|
}
|
|
@@ -628,6 +654,13 @@ function defaultPolicy(version = 1) {
|
|
|
628
654
|
config: [
|
|
629
655
|
"**/jest.config.*",
|
|
630
656
|
"**/vitest.config.*",
|
|
657
|
+
// A `vitest.workspace.*` file REPLACES the root config's selection with the
|
|
658
|
+
// union of its projects, and a `vite.config.*` carrying a `test:` key is the
|
|
659
|
+
// vitest config when no vitest.config exists — both are where a spec can be
|
|
660
|
+
// dropped from the suite without a line of it changing. ADDITIVE, like every
|
|
661
|
+
// baseline widening (the policy-diff reads it as a strengthening).
|
|
662
|
+
"**/vitest.workspace.*",
|
|
663
|
+
"**/vite.config.*",
|
|
631
664
|
"**/tsconfig*.json",
|
|
632
665
|
"**/.eslintrc*",
|
|
633
666
|
"**/eslint.config.*",
|
|
@@ -1115,6 +1148,25 @@ function trackedFiles(ctx) {
|
|
|
1115
1148
|
cache2.set(ctx.cwd, files);
|
|
1116
1149
|
return files;
|
|
1117
1150
|
}
|
|
1151
|
+
var branchCache = /* @__PURE__ */ new Map();
|
|
1152
|
+
function defaultBranch(ctx) {
|
|
1153
|
+
if (!ctx?.cwd) return null;
|
|
1154
|
+
const hit = branchCache.get(ctx.cwd);
|
|
1155
|
+
if (hit !== void 0) return hit;
|
|
1156
|
+
let name = null;
|
|
1157
|
+
try {
|
|
1158
|
+
const ref = execFileSync2("git", ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"], {
|
|
1159
|
+
cwd: ctx.cwd,
|
|
1160
|
+
encoding: "utf8",
|
|
1161
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1162
|
+
}).trim();
|
|
1163
|
+
name = ref.replace(/^origin\//, "") || null;
|
|
1164
|
+
} catch {
|
|
1165
|
+
name = null;
|
|
1166
|
+
}
|
|
1167
|
+
branchCache.set(ctx.cwd, name);
|
|
1168
|
+
return name;
|
|
1169
|
+
}
|
|
1118
1170
|
var dirOf = (t) => t.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
1119
1171
|
function containsProtected(token, policy, category, ctx) {
|
|
1120
1172
|
const dir = dirOf(token);
|
|
@@ -1173,6 +1225,13 @@ function underKey(node, key2) {
|
|
|
1173
1225
|
}
|
|
1174
1226
|
return false;
|
|
1175
1227
|
}
|
|
1228
|
+
function ownerKey(node) {
|
|
1229
|
+
const obj = node.parent;
|
|
1230
|
+
if (!obj || !ts2.isObjectLiteralExpression(obj)) return null;
|
|
1231
|
+
let q = obj.parent;
|
|
1232
|
+
while (q && (ts2.isParenthesizedExpression(q) || ts2.isAsExpression(q) || q.kind === ts2.SyntaxKind.SatisfiesExpression)) q = q.parent;
|
|
1233
|
+
return q && ts2.isPropertyAssignment(q) ? keyName(q.name) : null;
|
|
1234
|
+
}
|
|
1176
1235
|
function asExpression(src) {
|
|
1177
1236
|
const t = src.trim();
|
|
1178
1237
|
return t.startsWith("{") ? `(${t})` : src;
|
|
@@ -1250,18 +1309,20 @@ function literals(e) {
|
|
|
1250
1309
|
return { items, opaque };
|
|
1251
1310
|
}
|
|
1252
1311
|
function parseLists(src) {
|
|
1253
|
-
const res = { collectFrom: null, ignore: [], opaqueCollect: false, opaqueIgnore: false };
|
|
1312
|
+
const res = { collectFrom: null, collectKey: "collectCoverageFrom", ignore: [], opaqueCollect: false, opaqueIgnore: false };
|
|
1254
1313
|
try {
|
|
1255
1314
|
const sf = sourceOf(src);
|
|
1256
1315
|
const resolve5 = resolverFor(sf);
|
|
1257
1316
|
const visit = (node) => {
|
|
1258
1317
|
if (ts2.isPropertyAssignment(node)) {
|
|
1259
1318
|
const k = keyName(node.name);
|
|
1260
|
-
|
|
1319
|
+
const vitestCoverage = ownerKey(node) === "coverage";
|
|
1320
|
+
if (k === "collectCoverageFrom" || k === "include" && vitestCoverage) {
|
|
1261
1321
|
const { items, opaque } = literals(resolve5(node.initializer));
|
|
1262
1322
|
res.collectFrom = items;
|
|
1323
|
+
res.collectKey = k === "include" ? "coverage.include" : k;
|
|
1263
1324
|
if (opaque) res.opaqueCollect = true;
|
|
1264
|
-
} else if (k === "coveragePathIgnorePatterns" || k === "exclude" &&
|
|
1325
|
+
} else if (k === "coveragePathIgnorePatterns" || k === "exclude" && vitestCoverage) {
|
|
1265
1326
|
const { items, opaque } = literals(resolve5(node.initializer));
|
|
1266
1327
|
res.ignore.push(...items);
|
|
1267
1328
|
if (opaque) res.opaqueIgnore = true;
|
|
@@ -1274,6 +1335,43 @@ function parseLists(src) {
|
|
|
1274
1335
|
}
|
|
1275
1336
|
return res;
|
|
1276
1337
|
}
|
|
1338
|
+
function parseSwitches(src) {
|
|
1339
|
+
const res = {};
|
|
1340
|
+
try {
|
|
1341
|
+
const sf = sourceOf(src);
|
|
1342
|
+
const bool = (e) => e.kind === ts2.SyntaxKind.TrueKeyword ? true : e.kind === ts2.SyntaxKind.FalseKeyword ? false : void 0;
|
|
1343
|
+
const visit = (node) => {
|
|
1344
|
+
if (ts2.isPropertyAssignment(node)) {
|
|
1345
|
+
const k = keyName(node.name);
|
|
1346
|
+
const owner = ownerKey(node);
|
|
1347
|
+
if (k === "enabled" && owner === "coverage") res.enabled = bool(node.initializer);
|
|
1348
|
+
else if (k === "perFile" && owner === "thresholds" && underKey(node, "coverage")) res.perFile = bool(node.initializer);
|
|
1349
|
+
}
|
|
1350
|
+
ts2.forEachChild(node, visit);
|
|
1351
|
+
};
|
|
1352
|
+
visit(sf);
|
|
1353
|
+
} catch {
|
|
1354
|
+
}
|
|
1355
|
+
return res;
|
|
1356
|
+
}
|
|
1357
|
+
function sectionText(src, key2) {
|
|
1358
|
+
try {
|
|
1359
|
+
const sf = sourceOf(src);
|
|
1360
|
+
let out2 = "";
|
|
1361
|
+
const visit = (node) => {
|
|
1362
|
+
if (out2) return;
|
|
1363
|
+
if (ts2.isPropertyAssignment(node) && keyName(node.name) === key2 && ts2.isObjectLiteralExpression(node.initializer)) {
|
|
1364
|
+
out2 = node.initializer.getText(sf);
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
ts2.forEachChild(node, visit);
|
|
1368
|
+
};
|
|
1369
|
+
visit(sf);
|
|
1370
|
+
return out2;
|
|
1371
|
+
} catch {
|
|
1372
|
+
return "";
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1277
1375
|
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;
|
|
1278
1376
|
function compareMetrics(scope, before, after, out2) {
|
|
1279
1377
|
if (!before) return;
|
|
@@ -1299,10 +1397,10 @@ function overrideTargetExists(key2, configPath, ctx) {
|
|
|
1299
1397
|
}
|
|
1300
1398
|
return files.some((f) => f.startsWith(root) && m(f.slice(root.length)));
|
|
1301
1399
|
}
|
|
1302
|
-
function weakenings(before, after, configPath, ctx) {
|
|
1400
|
+
function weakenings(before, after, configPath, movedElsewhere, ctx) {
|
|
1303
1401
|
const out2 = [];
|
|
1304
1402
|
if (before.present && !after.present) {
|
|
1305
|
-
out2.push("the coverage threshold gate was removed");
|
|
1403
|
+
if (!movedElsewhere) out2.push("the coverage threshold gate was removed");
|
|
1306
1404
|
return out2;
|
|
1307
1405
|
}
|
|
1308
1406
|
if (after.opaque && !after.global && after.paths.size === 0) return out2;
|
|
@@ -1329,16 +1427,45 @@ function weakenings(before, after, configPath, ctx) {
|
|
|
1329
1427
|
}
|
|
1330
1428
|
return out2;
|
|
1331
1429
|
}
|
|
1332
|
-
|
|
1430
|
+
var globs = (patterns) => {
|
|
1431
|
+
const ms = patterns.map((g) => {
|
|
1432
|
+
try {
|
|
1433
|
+
return picomatch2(norm(g), { dot: true });
|
|
1434
|
+
} catch {
|
|
1435
|
+
return () => false;
|
|
1436
|
+
}
|
|
1437
|
+
});
|
|
1438
|
+
return (p) => ms.some((m) => m(p));
|
|
1439
|
+
};
|
|
1440
|
+
var globList = (patterns) => {
|
|
1441
|
+
const pos = globs(patterns.filter((g) => !g.startsWith("!")));
|
|
1442
|
+
const neg = globs(patterns.filter((g) => g.startsWith("!")).map((g) => g.slice(1)));
|
|
1443
|
+
return (p) => pos(p) && !neg(p);
|
|
1444
|
+
};
|
|
1445
|
+
var CANONICAL_SOURCES = ["src/index.ts", "src/a.ts", "src/a.tsx", "src/lib/b.ts", "src/index.js", "src/a.jsx", "lib/a.js"];
|
|
1446
|
+
var NON_SOURCE = /(?:^|\/)(?:dist|build|out|coverage|node_modules|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)\.|\.d\.ts$/;
|
|
1447
|
+
function sourceSamples(configPath, policy, ctx) {
|
|
1448
|
+
const files = trackedFiles(ctx);
|
|
1449
|
+
if (!files) return CANONICAL_SOURCES;
|
|
1450
|
+
const root = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/") + 1) : "";
|
|
1451
|
+
const own = files.filter((f) => f.startsWith(root) && langOf(f) === "js" && !NON_SOURCE.test(f) && !isProtected(f, policy, "tests") && !isProtected(f, policy, "config")).map((f) => f.slice(root.length));
|
|
1452
|
+
return own.length ? own : CANONICAL_SOURCES;
|
|
1453
|
+
}
|
|
1454
|
+
function narrowings(before, after, sources) {
|
|
1333
1455
|
const out2 = [];
|
|
1334
|
-
if (before.collectFrom && after.collectFrom && !after.opaqueCollect) {
|
|
1335
|
-
const
|
|
1336
|
-
|
|
1337
|
-
|
|
1456
|
+
if (before.collectFrom && after.collectFrom && !after.opaqueCollect && !before.opaqueCollect) {
|
|
1457
|
+
const pb = globList(before.collectFrom);
|
|
1458
|
+
const pa = globList(after.collectFrom);
|
|
1459
|
+
const dropped = sources.filter((s) => pb(s) && !pa(s));
|
|
1460
|
+
if (dropped.length) {
|
|
1461
|
+
const shown = dropped.slice(0, 3).map((d) => JSON.stringify(d)).join(", ");
|
|
1462
|
+
out2.push(`${after.collectKey} no longer measures ${shown}${dropped.length > 3 ? ` (+${dropped.length - 3} more)` : ""}`);
|
|
1338
1463
|
}
|
|
1339
1464
|
const had2 = new Set(before.collectFrom);
|
|
1340
1465
|
for (const g of after.collectFrom) {
|
|
1341
|
-
if (g.startsWith("!") && !had2.has(g) && !BENIGN_EXEMPTION.test(g)
|
|
1466
|
+
if (g.startsWith("!") && !had2.has(g) && !BENIGN_EXEMPTION.test(g) && !dropped.some((d) => globs([g.slice(1)])(d))) {
|
|
1467
|
+
out2.push(`${after.collectKey} now exempts ${JSON.stringify(g)}`);
|
|
1468
|
+
}
|
|
1342
1469
|
}
|
|
1343
1470
|
}
|
|
1344
1471
|
const had = new Set(before.ignore);
|
|
@@ -1347,75 +1474,153 @@ function narrowings(before, after) {
|
|
|
1347
1474
|
}
|
|
1348
1475
|
return out2;
|
|
1349
1476
|
}
|
|
1477
|
+
function switchWeakenings(before, after) {
|
|
1478
|
+
const out2 = [];
|
|
1479
|
+
if (before.enabled === true && after.enabled === false) out2.push("coverage.enabled switched off \u2014 the thresholds are never checked");
|
|
1480
|
+
if (before.perFile === true && after.perFile !== true) out2.push("thresholds.perFile removed \u2014 a single uncovered file now hides behind the aggregate");
|
|
1481
|
+
return out2;
|
|
1482
|
+
}
|
|
1483
|
+
var NYC_KEYS = METRICS.map((m) => ({
|
|
1484
|
+
key: new RegExp(`(?:^|[\\s{,])"?${m}"?\\s*[:=]\\s*"?(\\d+(?:\\.\\d+)?)`, "g"),
|
|
1485
|
+
label: m,
|
|
1486
|
+
weakens: "lower"
|
|
1487
|
+
}));
|
|
1488
|
+
var COVERAGE_PY = /(?:^|\/)(?:\.coveragerc|pyproject\.toml)$/;
|
|
1489
|
+
var CODECOV = /(?:^|\/)\.?codecov\.yml$/;
|
|
1350
1490
|
var SIMPLE_FILES = [
|
|
1351
1491
|
{
|
|
1352
1492
|
// coverage.py: `fail_under = 90` under [report] (.coveragerc, setup.cfg-style) or
|
|
1353
1493
|
// [tool.coverage.report] (pyproject.toml)
|
|
1354
|
-
file:
|
|
1494
|
+
file: COVERAGE_PY,
|
|
1355
1495
|
keys: [{ key: /^\s*fail[_-]under\s*=\s*"?(\d+(?:\.\d+)?)/, label: "fail_under", weakens: "lower" }],
|
|
1356
1496
|
removable: true
|
|
1357
1497
|
},
|
|
1358
1498
|
{
|
|
1359
1499
|
// nyc: .nycrc / .nycrc.json / .nycrc.yml / nyc.config.js — metric thresholds
|
|
1360
1500
|
file: /(?:^|\/)(?:\.nycrc(?:\.\w+)?|nyc\.config\.\w+)$/,
|
|
1361
|
-
keys:
|
|
1362
|
-
key: new RegExp(`(?:^|[\\s{,])"?${m}"?\\s*[:=]\\s*"?(\\d+(?:\\.\\d+)?)`, "g"),
|
|
1363
|
-
label: m,
|
|
1364
|
-
weakens: "lower"
|
|
1365
|
-
})),
|
|
1501
|
+
keys: NYC_KEYS,
|
|
1366
1502
|
removable: false
|
|
1367
1503
|
},
|
|
1368
1504
|
{
|
|
1369
|
-
// codecov: `target: 90%` is the floor (lowering weakens
|
|
1505
|
+
// codecov: `target: 90%` is the floor (lowering weakens; `auto` is a floor that
|
|
1506
|
+
// follows the base commit, lower than any fixed number); `threshold: 2%` is the
|
|
1370
1507
|
// permitted drop (raising weakens)
|
|
1371
|
-
file:
|
|
1508
|
+
file: CODECOV,
|
|
1372
1509
|
keys: [
|
|
1373
|
-
{ key: /^\s*target:\s*"?(\d+(?:\.\d+)
|
|
1510
|
+
{ key: /^\s*target:\s*"?(\d+(?:\.\d+)?|auto)\b%?/, label: "target", weakens: "lower" },
|
|
1374
1511
|
{ key: /^\s*threshold:\s*"?(\d+(?:\.\d+)?)%?/, label: "threshold", weakens: "raise" }
|
|
1375
1512
|
],
|
|
1376
|
-
removable: false
|
|
1513
|
+
removable: false,
|
|
1514
|
+
scoped: true
|
|
1377
1515
|
}
|
|
1378
1516
|
];
|
|
1379
|
-
|
|
1517
|
+
var lessThan = (a, b) => a === "auto" ? b !== "auto" : b === "auto" ? false : a < b;
|
|
1518
|
+
var numOf = (s) => s === "auto" ? "auto" : Number(s);
|
|
1519
|
+
function numbersOf(src, key2, scoped = false) {
|
|
1380
1520
|
const out2 = [];
|
|
1521
|
+
const stack = [];
|
|
1381
1522
|
for (const line of src.split("\n")) {
|
|
1523
|
+
let scope = "";
|
|
1524
|
+
if (scoped) {
|
|
1525
|
+
const m = line.match(/^(\s*)([\w-]+):/);
|
|
1526
|
+
if (m) {
|
|
1527
|
+
const ind = m[1].length;
|
|
1528
|
+
while (stack.length && stack[stack.length - 1].ind >= ind) stack.pop();
|
|
1529
|
+
stack.push({ ind, key: m[2] });
|
|
1530
|
+
}
|
|
1531
|
+
scope = [...stack.slice(0, -1)].reverse().find((s) => /^(?:project|patch|changes)$/.test(s.key))?.key ?? "";
|
|
1532
|
+
}
|
|
1382
1533
|
if (key2.global) {
|
|
1383
|
-
for (const m of line.matchAll(key2)) out2.push(
|
|
1534
|
+
for (const m of line.matchAll(key2)) out2.push({ v: numOf(m[1]), scope });
|
|
1384
1535
|
} else {
|
|
1385
1536
|
const m = line.match(key2);
|
|
1386
|
-
if (m) out2.push(
|
|
1537
|
+
if (m) out2.push({ v: numOf(m[1]), scope });
|
|
1387
1538
|
}
|
|
1388
1539
|
}
|
|
1389
1540
|
return out2;
|
|
1390
1541
|
}
|
|
1391
|
-
function
|
|
1542
|
+
function simpleWeakeningsOf(spec, before, after) {
|
|
1392
1543
|
const out2 = [];
|
|
1393
|
-
const spec = SIMPLE_FILES.find((s) => s.file.test(path));
|
|
1394
|
-
if (!spec) return out2;
|
|
1395
1544
|
for (const k of spec.keys) {
|
|
1396
|
-
const
|
|
1397
|
-
const
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
if (
|
|
1407
|
-
|
|
1408
|
-
|
|
1545
|
+
const bAll = numbersOf(before, k.key, spec.scoped);
|
|
1546
|
+
const aAll = numbersOf(after, k.key, spec.scoped);
|
|
1547
|
+
const scopes = [...new Set(bAll.map((n) => n.scope))];
|
|
1548
|
+
for (const scope of scopes) {
|
|
1549
|
+
const label = scope ? `${scope} ${k.label}` : k.label;
|
|
1550
|
+
const desc = (x, y) => x === "auto" ? -1 : y === "auto" ? 1 : lessThan(x, y) ? 1 : lessThan(y, x) ? -1 : 0;
|
|
1551
|
+
const b = bAll.filter((n) => n.scope === scope).map((n) => n.v).sort(desc);
|
|
1552
|
+
const a = aAll.filter((n) => n.scope === scope).map((n) => n.v).sort(desc);
|
|
1553
|
+
if (b.length === 0) continue;
|
|
1554
|
+
if (a.length === 0) {
|
|
1555
|
+
if (spec.removable) out2.push(`${label} removed (was ${b.join(", ")})`);
|
|
1556
|
+
continue;
|
|
1557
|
+
}
|
|
1558
|
+
if (k.weakens === "lower") {
|
|
1559
|
+
for (let i = 0; i < b.length; i++) {
|
|
1560
|
+
const av = a[Math.min(i, a.length - 1)];
|
|
1561
|
+
if (lessThan(av, b[i])) {
|
|
1562
|
+
out2.push(`${label} lowered ${b[i]} \u2192 ${av}`);
|
|
1563
|
+
break;
|
|
1564
|
+
}
|
|
1409
1565
|
}
|
|
1566
|
+
} else {
|
|
1567
|
+
const bMax = b[0];
|
|
1568
|
+
const aMax = a[0];
|
|
1569
|
+
if (lessThan(bMax, aMax)) out2.push(`${label} raised ${bMax} \u2192 ${aMax} (a wider permitted drop)`);
|
|
1410
1570
|
}
|
|
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
1571
|
}
|
|
1416
1572
|
}
|
|
1417
1573
|
return out2;
|
|
1418
1574
|
}
|
|
1575
|
+
function listEntries(src, key2) {
|
|
1576
|
+
const out2 = [];
|
|
1577
|
+
const lines = src.split("\n");
|
|
1578
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1579
|
+
const m = lines[i].match(key2);
|
|
1580
|
+
if (!m) continue;
|
|
1581
|
+
let rest = m[1] ?? "";
|
|
1582
|
+
let j = i + 1;
|
|
1583
|
+
if (rest.trim().startsWith("[") && !rest.includes("]")) {
|
|
1584
|
+
while (j < lines.length && !lines[j].includes("]")) rest += "," + lines[j++];
|
|
1585
|
+
if (j < lines.length) rest += "," + lines[j];
|
|
1586
|
+
} else if (rest.trim() === "") {
|
|
1587
|
+
while (j < lines.length && /^\s+(?:-\s+)?\S/.test(lines[j])) rest += "," + lines[j++].replace(/^\s*-\s+/, "");
|
|
1588
|
+
}
|
|
1589
|
+
for (const e of rest.replace(/[[\]]/g, "").split(",")) {
|
|
1590
|
+
const v = e.trim().replace(/^(['"])(.*)\1$/, "$2");
|
|
1591
|
+
if (v) out2.push(v);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
return out2;
|
|
1595
|
+
}
|
|
1596
|
+
function simpleNarrowings(path, before, after) {
|
|
1597
|
+
const out2 = [];
|
|
1598
|
+
const spec = [
|
|
1599
|
+
[COVERAGE_PY, /^\s*omit\s*=\s*(.*)$/, "omit now exempts"],
|
|
1600
|
+
[CODECOV, /^ignore:\s*(.*)$/, "codecov now ignores"]
|
|
1601
|
+
];
|
|
1602
|
+
for (const [file, key2, what] of spec) {
|
|
1603
|
+
if (!file.test(path)) continue;
|
|
1604
|
+
const had = new Set(listEntries(before, key2));
|
|
1605
|
+
for (const e of listEntries(after, key2)) if (!had.has(e) && !BENIGN_EXEMPTION.test(e)) out2.push(`${what} ${JSON.stringify(e)}`);
|
|
1606
|
+
}
|
|
1607
|
+
if (CODECOV.test(path)) {
|
|
1608
|
+
const off = (s) => new Set(s.split("\n").map((l) => l.match(/^\s*(project|patch):\s*off\b/)?.[1]).filter((x) => !!x));
|
|
1609
|
+
const had = off(before);
|
|
1610
|
+
for (const s of off(after)) if (!had.has(s)) out2.push(`codecov status.${s} switched off`);
|
|
1611
|
+
}
|
|
1612
|
+
return out2;
|
|
1613
|
+
}
|
|
1614
|
+
function simpleWeakenings(path, before, after) {
|
|
1615
|
+
const spec = SIMPLE_FILES.find((s) => s.file.test(path));
|
|
1616
|
+
const out2 = spec ? simpleWeakeningsOf(spec, before, after) : [];
|
|
1617
|
+
if ((path.split("/").pop() ?? path) === "package.json") {
|
|
1618
|
+
const b = sectionText(before, "nyc");
|
|
1619
|
+
const a = sectionText(after, "nyc");
|
|
1620
|
+
if (b && a) out2.push(...simpleWeakeningsOf({ file: /./, keys: NYC_KEYS, removable: false }, b, a).map((r) => `nyc ${r}`));
|
|
1621
|
+
}
|
|
1622
|
+
return out2;
|
|
1623
|
+
}
|
|
1419
1624
|
var SWITCHES = [
|
|
1420
1625
|
{
|
|
1421
1626
|
re: /--passWithNoTests\b/,
|
|
@@ -1454,6 +1659,35 @@ var SWITCHES = [
|
|
|
1454
1659
|
}
|
|
1455
1660
|
];
|
|
1456
1661
|
var isJsConfig = (path) => /\.(?:[cm]?[jt]sx?|json)$/.test(path);
|
|
1662
|
+
function gateMovedElsewhere(c, changes, policy) {
|
|
1663
|
+
const before = c.before ?? "";
|
|
1664
|
+
const others = changes.filter((o) => o.kind === "file" && o !== c && o.after != null && isProtected(o.path, policy, "config"));
|
|
1665
|
+
if (isJsConfig(c.path)) {
|
|
1666
|
+
const was = parseThresholds(before);
|
|
1667
|
+
if (!was.present) return false;
|
|
1668
|
+
return others.some((o) => {
|
|
1669
|
+
if (!isJsConfig(o.path)) return false;
|
|
1670
|
+
const now = parseThresholds(o.after);
|
|
1671
|
+
if (!now.present) return false;
|
|
1672
|
+
if (now.opaque) return true;
|
|
1673
|
+
return METRICS.every((m) => {
|
|
1674
|
+
const bv = was.global?.values[m];
|
|
1675
|
+
const av = now.global?.values[m];
|
|
1676
|
+
return bv === void 0 || av !== void 0 && av >= bv;
|
|
1677
|
+
});
|
|
1678
|
+
});
|
|
1679
|
+
}
|
|
1680
|
+
const spec = SIMPLE_FILES.find((s) => s.file.test(c.path));
|
|
1681
|
+
if (!spec) return false;
|
|
1682
|
+
return spec.keys.every((k) => {
|
|
1683
|
+
const b = numbersOf(before, k.key, spec.scoped).map((n) => n.v);
|
|
1684
|
+
if (b.length === 0) return true;
|
|
1685
|
+
return others.some((o) => {
|
|
1686
|
+
const a = numbersOf(o.after, k.key, spec.scoped).map((n) => n.v);
|
|
1687
|
+
return a.length > 0 && b.every((bv) => a.some((av) => k.weakens === "lower" ? !lessThan(av, bv) : !lessThan(bv, av)));
|
|
1688
|
+
});
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1457
1691
|
var coverageLowering = {
|
|
1458
1692
|
id: RULE4,
|
|
1459
1693
|
surface: ["file"],
|
|
@@ -1466,10 +1700,15 @@ var coverageLowering = {
|
|
|
1466
1700
|
if (c.after != null && c.op !== "delete") {
|
|
1467
1701
|
const reasons = [];
|
|
1468
1702
|
if (isJsConfig(c.path)) {
|
|
1469
|
-
|
|
1470
|
-
|
|
1703
|
+
const moved = () => gateMovedElsewhere(c, changes, policy);
|
|
1704
|
+
const b = parseThresholds(c.before ?? "");
|
|
1705
|
+
const a = parseThresholds(c.after);
|
|
1706
|
+
reasons.push(...weakenings(b, a, c.path, b.present && !a.present && moved(), ctx));
|
|
1707
|
+
reasons.push(...narrowings(parseLists(c.before ?? ""), parseLists(c.after), sourceSamples(c.path, policy, ctx)));
|
|
1708
|
+
reasons.push(...switchWeakenings(parseSwitches(c.before ?? ""), parseSwitches(c.after)));
|
|
1471
1709
|
}
|
|
1472
1710
|
reasons.push(...simpleWeakenings(c.path, c.before ?? "", c.after));
|
|
1711
|
+
reasons.push(...simpleNarrowings(c.path, c.before ?? "", c.after));
|
|
1473
1712
|
for (const reason of reasons) {
|
|
1474
1713
|
out2.push(
|
|
1475
1714
|
makeFinding(RULE4, policy, {
|
|
@@ -1483,7 +1722,7 @@ var coverageLowering = {
|
|
|
1483
1722
|
} else if (c.op === "delete" || c.after === "" && (c.before ?? "").length > 0) {
|
|
1484
1723
|
const before = c.before ?? "";
|
|
1485
1724
|
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) {
|
|
1725
|
+
if (gated && !gateMovedElsewhere(c, changes, policy)) {
|
|
1487
1726
|
out2.push(
|
|
1488
1727
|
makeFinding(RULE4, policy, {
|
|
1489
1728
|
file: c.path,
|
|
@@ -1580,8 +1819,33 @@ function canonical(core) {
|
|
|
1580
1819
|
return s.replace(/\s+--\s*$/, "").trim();
|
|
1581
1820
|
}
|
|
1582
1821
|
var usesRef = (core) => core.replace(/@.*$/, "");
|
|
1583
|
-
var
|
|
1584
|
-
|
|
1822
|
+
var SUITE_NARROWING_FLAGS = /--testPathIgnorePatterns\b|--testPathPattern\b|--testNamePattern\b|(?:^|\s)-t\s|--onlyChanged\b|--changed\b|--findRelatedTests\b|--exclude\b|--dir\b|--project\b/;
|
|
1823
|
+
var NEUTRALISING_SUFFIX = new RegExp(
|
|
1824
|
+
`\\|\\||;|(?<!&)&(?!&)|\\|(?!\\|)|--passWithNoTests\\b|--coverage=false\\b|--coverageThreshold\\b|${SUITE_NARROWING_FLAGS.source}`
|
|
1825
|
+
);
|
|
1826
|
+
var TIMEOUT_WRAP = /^timeout\s+(?:-{1,2}\S+\s+)*\d\S*\s+(.+)$/;
|
|
1827
|
+
var PATH_SHAPED = /\/|\.(?:test|spec)\./;
|
|
1828
|
+
function narrowedByPositional(args) {
|
|
1829
|
+
const toks = args.trim().split(/\s+/).filter(Boolean);
|
|
1830
|
+
for (let i = 0; i < toks.length; i++) {
|
|
1831
|
+
const t = toks[i];
|
|
1832
|
+
if (t === "--" || t.startsWith("-")) continue;
|
|
1833
|
+
const prev = toks[i - 1];
|
|
1834
|
+
if (prev && (prev.startsWith("-") && prev !== "--" && !prev.includes("=") || /[<>]/.test(prev))) continue;
|
|
1835
|
+
if (/[<>|;&]/.test(t)) continue;
|
|
1836
|
+
if (PATH_SHAPED.test(t)) return true;
|
|
1837
|
+
}
|
|
1838
|
+
return false;
|
|
1839
|
+
}
|
|
1840
|
+
function checkKind(core) {
|
|
1841
|
+
if (!invokesCheck(core)) return null;
|
|
1842
|
+
const s = canonical(core);
|
|
1843
|
+
if (/\btamperward\b/.test(s)) return "gate";
|
|
1844
|
+
if (/\b(?:lint|eslint)\b/i.test(s)) return "lint";
|
|
1845
|
+
if (/\b(?:typecheck|type-check|tsc)\b/i.test(s)) return "types";
|
|
1846
|
+
return "test";
|
|
1847
|
+
}
|
|
1848
|
+
function survives(removedCore, isUses, afterCores, addedCores) {
|
|
1585
1849
|
if (isUses) {
|
|
1586
1850
|
const ref = usesRef(removedCore);
|
|
1587
1851
|
return afterCores.some((a) => usesRef(a) === ref) ? { state: "kept" } : { state: "gone" };
|
|
@@ -1593,15 +1857,33 @@ function survives(removedCore, isUses, afterCores) {
|
|
|
1593
1857
|
if (a === r) return { state: "kept" };
|
|
1594
1858
|
if (a.startsWith(r + " ")) {
|
|
1595
1859
|
const rest = a.slice(r.length);
|
|
1596
|
-
if (NEUTRALISING_SUFFIX.test(rest)) neutralised
|
|
1860
|
+
if (NEUTRALISING_SUFFIX.test(rest) || narrowedByPositional(rest)) neutralised ??= raw;
|
|
1597
1861
|
else return { state: "kept" };
|
|
1598
1862
|
} else if (r.startsWith(a + " ") && invokesCheck(a)) {
|
|
1599
1863
|
return { state: "kept" };
|
|
1864
|
+
} else {
|
|
1865
|
+
const w = a.match(TIMEOUT_WRAP);
|
|
1866
|
+
if (w && (canonical(w[1]) === r || canonical(w[1]).startsWith(r + " "))) neutralised ??= raw;
|
|
1600
1867
|
}
|
|
1601
1868
|
}
|
|
1869
|
+
const kind = checkKind(removedCore);
|
|
1870
|
+
let respelled = false;
|
|
1871
|
+
for (const raw of addedCores) {
|
|
1872
|
+
if (!kind || checkKind(raw) !== kind) continue;
|
|
1873
|
+
const a = canonical(raw);
|
|
1874
|
+
if (a.match(TIMEOUT_WRAP)) {
|
|
1875
|
+
neutralised ??= raw;
|
|
1876
|
+
continue;
|
|
1877
|
+
}
|
|
1878
|
+
const args = a.slice(a.indexOf(" ") + 1 || a.length);
|
|
1879
|
+
if (a.includes(" ") && (NEUTRALISING_SUFFIX.test(" " + args) || narrowedByPositional(args))) neutralised ??= raw;
|
|
1880
|
+
else respelled = true;
|
|
1881
|
+
}
|
|
1882
|
+
if (respelled) return { state: "kept" };
|
|
1602
1883
|
return neutralised ? { state: "neutralised", by: neutralised } : { state: "gone" };
|
|
1603
1884
|
}
|
|
1604
1885
|
var uncommented = (v) => v.replace(/\s+#.*$/, "").trim();
|
|
1886
|
+
var TRUTHY = Symbol("truthy");
|
|
1605
1887
|
function lex(src) {
|
|
1606
1888
|
const out2 = [];
|
|
1607
1889
|
let i = 0;
|
|
@@ -1632,13 +1914,13 @@ function lex(src) {
|
|
|
1632
1914
|
i += num[0].length;
|
|
1633
1915
|
continue;
|
|
1634
1916
|
}
|
|
1635
|
-
const op = src.slice(i).match(/^(?:==|!=|&&|\|\||<=|>=|[!()
|
|
1917
|
+
const op = src.slice(i).match(/^(?:==|!=|&&|\|\||<=|>=|[!()<>,[\]])/);
|
|
1636
1918
|
if (op) {
|
|
1637
1919
|
out2.push({ t: "op", v: op[0] });
|
|
1638
1920
|
i += op[0].length;
|
|
1639
1921
|
continue;
|
|
1640
1922
|
}
|
|
1641
|
-
const id = src.slice(i).match(/^[A-Za-z_][\w
|
|
1923
|
+
const id = src.slice(i).match(/^[A-Za-z_][\w.\-*]*/);
|
|
1642
1924
|
if (id) {
|
|
1643
1925
|
out2.push({ t: "id", v: id[0] });
|
|
1644
1926
|
i += id[0].length;
|
|
@@ -1648,15 +1930,16 @@ function lex(src) {
|
|
|
1648
1930
|
}
|
|
1649
1931
|
return out2;
|
|
1650
1932
|
}
|
|
1933
|
+
var truthy = (v) => v === TRUTHY ? true : typeof v === "string" ? v !== "" : typeof v === "number" ? v !== 0 : v === true;
|
|
1651
1934
|
function foldConst(src) {
|
|
1652
1935
|
const toks = lex(src);
|
|
1653
1936
|
if (!toks) return void 0;
|
|
1654
1937
|
let p = 0;
|
|
1655
1938
|
const peek = () => toks[p];
|
|
1656
1939
|
const eat = (v2) => toks[p]?.t === "op" && toks[p].v === v2 ? (p++, true) : false;
|
|
1657
|
-
const
|
|
1658
|
-
const num = (v2) => typeof v2 === "number" ? v2 : typeof v2 === "boolean" ? v2 ? 1 : 0 : v2 === null ? 0 : v2.trim() === "" ? 0 : Number(v2);
|
|
1940
|
+
const num = (v2) => v2 === TRUTHY ? NaN : typeof v2 === "number" ? v2 : typeof v2 === "boolean" ? v2 ? 1 : 0 : v2 === null ? 0 : v2.trim() === "" ? 0 : Number(v2);
|
|
1659
1941
|
const eq = (a, b) => {
|
|
1942
|
+
if (a === TRUTHY || b === TRUTHY) return void 0;
|
|
1660
1943
|
if (typeof a === "string" && typeof b === "string") return a.toLowerCase() === b.toLowerCase();
|
|
1661
1944
|
if (a === null && b === null) return true;
|
|
1662
1945
|
const x = num(a);
|
|
@@ -1667,8 +1950,9 @@ function foldConst(src) {
|
|
|
1667
1950
|
let l = and();
|
|
1668
1951
|
while (eat("||")) {
|
|
1669
1952
|
const r = and();
|
|
1670
|
-
if (l
|
|
1671
|
-
l
|
|
1953
|
+
if (l !== void 0 && truthy(l)) continue;
|
|
1954
|
+
if (l !== void 0) l = r;
|
|
1955
|
+
else l = r !== void 0 && truthy(r) ? TRUTHY : void 0;
|
|
1672
1956
|
}
|
|
1673
1957
|
return l;
|
|
1674
1958
|
};
|
|
@@ -1676,8 +1960,9 @@ function foldConst(src) {
|
|
|
1676
1960
|
let l = cmp();
|
|
1677
1961
|
while (eat("&&")) {
|
|
1678
1962
|
const r = cmp();
|
|
1679
|
-
if (l
|
|
1680
|
-
|
|
1963
|
+
if (l !== void 0 && !truthy(l)) continue;
|
|
1964
|
+
if (l !== void 0) l = r;
|
|
1965
|
+
else l = r !== void 0 && !truthy(r) ? false : void 0;
|
|
1681
1966
|
}
|
|
1682
1967
|
return l;
|
|
1683
1968
|
};
|
|
@@ -1688,14 +1973,18 @@ function foldConst(src) {
|
|
|
1688
1973
|
if (!t || t.t !== "op" || !/^(?:==|!=|<|>|<=|>=)$/.test(t.v)) return l;
|
|
1689
1974
|
p++;
|
|
1690
1975
|
const r = unary();
|
|
1691
|
-
if (l === void 0 || r === void 0)
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1976
|
+
if (l === void 0 || r === void 0) {
|
|
1977
|
+
l = void 0;
|
|
1978
|
+
continue;
|
|
1979
|
+
}
|
|
1980
|
+
if (t.v === "==" || t.v === "!=") {
|
|
1981
|
+
const e = eq(l, r);
|
|
1982
|
+
l = e === void 0 ? void 0 : t.v === "==" ? e : !e;
|
|
1983
|
+
} else {
|
|
1695
1984
|
const x = num(l);
|
|
1696
1985
|
const y = num(r);
|
|
1697
|
-
if (Number.isNaN(x) || Number.isNaN(y))
|
|
1698
|
-
l = t.v === "<" ? x < y : t.v === ">" ? x > y : t.v === "<=" ? x <= y : x >= y;
|
|
1986
|
+
if (Number.isNaN(x) || Number.isNaN(y)) l = void 0;
|
|
1987
|
+
else l = t.v === "<" ? x < y : t.v === ">" ? x > y : t.v === "<=" ? x <= y : x >= y;
|
|
1699
1988
|
}
|
|
1700
1989
|
}
|
|
1701
1990
|
};
|
|
@@ -1720,6 +2009,19 @@ function foldConst(src) {
|
|
|
1720
2009
|
if (/^true$/i.test(t.v)) return true;
|
|
1721
2010
|
if (/^false$/i.test(t.v)) return false;
|
|
1722
2011
|
if (/^null$/i.test(t.v)) return null;
|
|
2012
|
+
if (eat("(")) {
|
|
2013
|
+
if (!eat(")")) {
|
|
2014
|
+
for (; ; ) {
|
|
2015
|
+
or();
|
|
2016
|
+
if (eat(")")) break;
|
|
2017
|
+
if (!eat(",")) return void 0;
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
while (eat("[")) {
|
|
2022
|
+
or();
|
|
2023
|
+
if (!eat("]")) return void 0;
|
|
2024
|
+
}
|
|
1723
2025
|
return void 0;
|
|
1724
2026
|
}
|
|
1725
2027
|
return void 0;
|
|
@@ -1736,7 +2038,7 @@ function isAlwaysFalse(raw) {
|
|
|
1736
2038
|
const e = expressionOf(raw);
|
|
1737
2039
|
if (e === "") return false;
|
|
1738
2040
|
const v = foldConst(e);
|
|
1739
|
-
return v !== void 0 && !(
|
|
2041
|
+
return v !== void 0 && !truthy(v);
|
|
1740
2042
|
}
|
|
1741
2043
|
function isTruthy(raw) {
|
|
1742
2044
|
const v = uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim();
|
|
@@ -1744,7 +2046,7 @@ function isTruthy(raw) {
|
|
|
1744
2046
|
const m = v.match(/^\$\{\{\s*(.+?)\s*\}\}$/);
|
|
1745
2047
|
if (!m) return false;
|
|
1746
2048
|
const f = foldConst(m[1]);
|
|
1747
|
-
return f !== void 0 && (
|
|
2049
|
+
return f !== void 0 && truthy(f);
|
|
1748
2050
|
}
|
|
1749
2051
|
var indentOf = (l) => l.match(/^\s*/)[0].length;
|
|
1750
2052
|
var STEP_START = /^\s*-\s+(?:name|uses|run|if|id|with|env|shell|continue-on-error|working-directory|timeout-minutes):/;
|
|
@@ -1758,7 +2060,21 @@ function stepOf(lines, i) {
|
|
|
1758
2060
|
while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > stepInd)) e++;
|
|
1759
2061
|
return [s, e];
|
|
1760
2062
|
}
|
|
2063
|
+
function jobOf(lines, i) {
|
|
2064
|
+
const ind = indentOf(lines[i]);
|
|
2065
|
+
let h = i - 1;
|
|
2066
|
+
while (h >= 0 && (lines[h].trim() === "" || indentOf(lines[h]) >= ind)) h--;
|
|
2067
|
+
if (h < 0 || !/^\s+[\w-]+:\s*(?:#.*)?$/.test(lines[h])) return null;
|
|
2068
|
+
let t = h - 1;
|
|
2069
|
+
while (t >= 0 && (lines[t].trim() === "" || indentOf(lines[t]) > 0)) t--;
|
|
2070
|
+
if (t < 0 || !/^jobs:/.test(lines[t])) return null;
|
|
2071
|
+
const hi = indentOf(lines[h]);
|
|
2072
|
+
let e = i + 1;
|
|
2073
|
+
while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > hi)) e++;
|
|
2074
|
+
return [h, e];
|
|
2075
|
+
}
|
|
1761
2076
|
var stepHasCheck = (lines, range) => range === null || lines.slice(range[0], range[1]).some((l) => invokesCheck(l) || USES.test(l) && CHECK.test(l));
|
|
2077
|
+
var jobHasCheck = (lines, range) => lines.slice(range[0], range[1]).some((l) => invokesCheck(l) || USES.test(l) && CHECK.test(l) || /^\s*uses:\s*\S/.test(l));
|
|
1762
2078
|
function runBlocks(lines) {
|
|
1763
2079
|
const out2 = [];
|
|
1764
2080
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -1776,13 +2092,28 @@ var NEUTRALISERS = [
|
|
|
1776
2092
|
{ re: /^\s*if\s+false\s*;?\s*then\b/, what: "if false; then \u2014 the check is never run" },
|
|
1777
2093
|
{ re: /^\s*:\s*<<\s*['"]?\w+/, what: "a heredoc fed to `:` \u2014 the check is commented out" }
|
|
1778
2094
|
];
|
|
2095
|
+
var LIST_KEYS = {
|
|
2096
|
+
branches: "branches",
|
|
2097
|
+
"branches-ignore": "branchesIgnore",
|
|
2098
|
+
"paths-ignore": "pathsIgnore",
|
|
2099
|
+
paths: "paths",
|
|
2100
|
+
types: "types"
|
|
2101
|
+
};
|
|
1779
2102
|
function flowList(v) {
|
|
1780
2103
|
const m = v.trim().match(/^\[(.*)\]$/);
|
|
1781
2104
|
if (!m) return null;
|
|
1782
2105
|
return m[1].split(",").map((s) => s.trim().replace(/^(['"])(.*)\1$/, "$2")).filter(Boolean);
|
|
1783
2106
|
}
|
|
1784
2107
|
function parseTriggers(src) {
|
|
1785
|
-
const t = {
|
|
2108
|
+
const t = {
|
|
2109
|
+
events: /* @__PURE__ */ new Set(),
|
|
2110
|
+
branches: /* @__PURE__ */ new Map(),
|
|
2111
|
+
branchesIgnore: /* @__PURE__ */ new Map(),
|
|
2112
|
+
pathsIgnore: /* @__PURE__ */ new Map(),
|
|
2113
|
+
paths: /* @__PURE__ */ new Map(),
|
|
2114
|
+
types: /* @__PURE__ */ new Map(),
|
|
2115
|
+
present: false
|
|
2116
|
+
};
|
|
1786
2117
|
const lines = src.split("\n");
|
|
1787
2118
|
const i = lines.findIndex((l) => /^(?:on|"on"|'on'|true):/.test(l));
|
|
1788
2119
|
if (i < 0) return t;
|
|
@@ -1818,8 +2149,9 @@ function parseTriggers(src) {
|
|
|
1818
2149
|
listKey = null;
|
|
1819
2150
|
continue;
|
|
1820
2151
|
}
|
|
1821
|
-
|
|
1822
|
-
|
|
2152
|
+
const key2 = LIST_KEYS[kv[1]];
|
|
2153
|
+
if (event && key2) {
|
|
2154
|
+
listKey = key2;
|
|
1823
2155
|
listInd = ind;
|
|
1824
2156
|
const list = flowList(kv[2]);
|
|
1825
2157
|
t[listKey].set(event, list ?? []);
|
|
@@ -1829,36 +2161,80 @@ function parseTriggers(src) {
|
|
|
1829
2161
|
}
|
|
1830
2162
|
var DEFAULT_BRANCH = /^(?:main|master)$/;
|
|
1831
2163
|
var ALL = (g) => /^\*\*?$|^\*\*\/\*$/.test(g);
|
|
1832
|
-
|
|
2164
|
+
var CODE_EVENTS = ["push", "pull_request", "pull_request_target"];
|
|
2165
|
+
var PR_CODE_TYPES = /^(?:opened|synchronize|reopened)$/;
|
|
2166
|
+
function ghGlob(g) {
|
|
2167
|
+
const re = g.split("**").map((part) => part.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*");
|
|
2168
|
+
try {
|
|
2169
|
+
const r = new RegExp(`^${re}$`);
|
|
2170
|
+
return (p) => r.test(p);
|
|
2171
|
+
} catch {
|
|
2172
|
+
return () => false;
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
function pathsMatchSource(globs4, sources) {
|
|
2176
|
+
const ms = globs4.map((g) => ({ neg: g.startsWith("!"), m: ghGlob(g.startsWith("!") ? g.slice(1) : g) }));
|
|
2177
|
+
return sources.some((s) => {
|
|
2178
|
+
let on = false;
|
|
2179
|
+
for (const { neg, m } of ms) if (m(s)) on = !neg;
|
|
2180
|
+
return on;
|
|
2181
|
+
});
|
|
2182
|
+
}
|
|
2183
|
+
var CODE_PROBES = ["src/index.ts", "src/index.js", "lib/index.js", "index.ts", "main.go", "src/main.py", "src/lib.rs", "packages/a/src/index.ts", "test/a.test.ts"];
|
|
2184
|
+
function sourceProbes(ctx) {
|
|
2185
|
+
const files = trackedFiles(ctx);
|
|
2186
|
+
if (!files) return CODE_PROBES;
|
|
2187
|
+
const code = files.filter((f) => langOf(f) !== null);
|
|
2188
|
+
return code.length ? code : CODE_PROBES;
|
|
2189
|
+
}
|
|
2190
|
+
function triggerNarrowings(before, after, opts) {
|
|
1833
2191
|
const out2 = [];
|
|
1834
2192
|
if (!before.present || !after.present) return out2;
|
|
1835
|
-
|
|
2193
|
+
const isDefault = (b) => opts.defaultBranch ? b === opts.defaultBranch : DEFAULT_BRANCH.test(b);
|
|
2194
|
+
const namesDefault = (l) => l.some(isDefault);
|
|
2195
|
+
for (const e of CODE_EVENTS) {
|
|
1836
2196
|
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
2197
|
}
|
|
1838
2198
|
for (const [e, list] of after.pathsIgnore) {
|
|
1839
2199
|
const had = before.pathsIgnore.get(e) ?? [];
|
|
1840
2200
|
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
2201
|
}
|
|
2202
|
+
for (const [e, list] of after.paths) {
|
|
2203
|
+
if (!CODE_EVENTS.includes(e) || !after.events.has(e)) continue;
|
|
2204
|
+
const had = before.paths.get(e);
|
|
2205
|
+
if (had && !pathsMatchSource(had, opts.sources)) continue;
|
|
2206
|
+
if (!pathsMatchSource(list, opts.sources)) out2.push(`on.${e}.paths matches no source file (now [${list.join(", ")}]) \u2014 the workflow never runs on code`);
|
|
2207
|
+
}
|
|
2208
|
+
for (const [e, list] of after.types) {
|
|
2209
|
+
if (!/^pull_request(?:_target)?$/.test(e) || !after.events.has(e)) continue;
|
|
2210
|
+
const had = before.types.get(e);
|
|
2211
|
+
if (had && !had.some((t) => PR_CODE_TYPES.test(t))) continue;
|
|
2212
|
+
if (!list.some((t) => PR_CODE_TYPES.test(t))) out2.push(`on.${e}.types no longer includes opened/synchronize (now [${list.join(", ")}]) \u2014 the workflow does not run on the pull request's code`);
|
|
2213
|
+
}
|
|
1842
2214
|
for (const [e, list] of after.branchesIgnore) {
|
|
1843
2215
|
const had = before.branchesIgnore.get(e) ?? [];
|
|
1844
|
-
if (list.some((g) => (ALL(g) ||
|
|
2216
|
+
if (list.some((g) => (ALL(g) || isDefault(g)) && !had.includes(g))) out2.push(`on.${e}.branches-ignore now covers the default branch`);
|
|
1845
2217
|
}
|
|
1846
2218
|
for (const [e, was] of before.branches) {
|
|
1847
2219
|
const now = after.branches.get(e);
|
|
1848
2220
|
if (!now || !after.events.has(e)) continue;
|
|
1849
2221
|
if (now.some(ALL)) continue;
|
|
1850
|
-
const lostDefault = was
|
|
1851
|
-
const replaced = was.length > 0 && !was.some((b) => now.includes(b));
|
|
2222
|
+
const lostDefault = namesDefault(was) && !namesDefault(now);
|
|
2223
|
+
const replaced = !namesDefault(was) && !namesDefault(now) && was.length > 0 && !was.some((b) => now.includes(b));
|
|
1852
2224
|
if (lostDefault || replaced) out2.push(`on.${e}.branches no longer names ${was.filter((b) => !now.includes(b)).join(", ")} (now [${now.join(", ")}])`);
|
|
1853
2225
|
}
|
|
1854
2226
|
return out2;
|
|
1855
2227
|
}
|
|
2228
|
+
var REUSED_WORKFLOW = /^\s*uses:\s*\.\/(\.github\/workflows\/[^\s@]+)/;
|
|
1856
2229
|
var ciTampering = {
|
|
1857
2230
|
id: RULE5,
|
|
1858
2231
|
surface: ["file"],
|
|
1859
2232
|
certainty: "mechanical",
|
|
1860
|
-
run(changes, policy) {
|
|
2233
|
+
run(changes, policy, _view, ctx) {
|
|
1861
2234
|
const out2 = [];
|
|
2235
|
+
const afterByPath = /* @__PURE__ */ new Map();
|
|
2236
|
+
for (const c of changes) if (c.kind === "file" && c.after != null) afterByPath.set(c.path, c.after);
|
|
2237
|
+
let triggerOpts = null;
|
|
1862
2238
|
for (const c of changes) {
|
|
1863
2239
|
if (c.kind !== "file") continue;
|
|
1864
2240
|
if (c.op === "rename" && c.oldPath && isActiveWorkflow(c.oldPath) && !isActiveWorkflow(c.path)) {
|
|
@@ -1881,6 +2257,10 @@ var ciTampering = {
|
|
|
1881
2257
|
if (!coe && !cond) continue;
|
|
1882
2258
|
const step = afterLines && l.newLine != null ? stepOf(afterLines, l.newLine - 1) : null;
|
|
1883
2259
|
if (afterLines && step && !stepHasCheck(afterLines, step)) continue;
|
|
2260
|
+
if (afterLines && !step && l.newLine != null) {
|
|
2261
|
+
const job = jobOf(afterLines, l.newLine - 1);
|
|
2262
|
+
if (job && !jobHasCheck(afterLines, job)) continue;
|
|
2263
|
+
}
|
|
1884
2264
|
if (coe && isTruthy(coe[1])) {
|
|
1885
2265
|
out2.push(
|
|
1886
2266
|
makeFinding(RULE5, policy, {
|
|
@@ -1938,25 +2318,35 @@ var ciTampering = {
|
|
|
1938
2318
|
);
|
|
1939
2319
|
}
|
|
1940
2320
|
if (c.before != null) {
|
|
1941
|
-
|
|
2321
|
+
triggerOpts ??= { defaultBranch: defaultBranch(ctx), sources: sourceProbes(ctx) };
|
|
2322
|
+
for (const reason of triggerNarrowings(parseTriggers(c.before), parseTriggers(c.after), triggerOpts)) {
|
|
1942
2323
|
out2.push(
|
|
1943
2324
|
makeFinding(RULE5, policy, {
|
|
1944
2325
|
file: c.path,
|
|
1945
2326
|
message: `The workflow's triggers were narrowed: ${reason}.`,
|
|
1946
2327
|
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."
|
|
2328
|
+
remediation: "Keep the workflow running on push and pull_request for the default branch and its source paths; a check that never runs is no check."
|
|
1948
2329
|
})
|
|
1949
2330
|
);
|
|
1950
2331
|
}
|
|
1951
2332
|
}
|
|
1952
2333
|
}
|
|
1953
2334
|
const afterCores = (afterLines ?? addedLines(c).map((l) => l.content)).map(commandCore);
|
|
2335
|
+
const addedCores = addedLines(c).map((l) => commandCore(l.content));
|
|
2336
|
+
for (const l of afterLines ?? []) {
|
|
2337
|
+
const reused = l.match(REUSED_WORKFLOW)?.[1];
|
|
2338
|
+
const content = reused ? afterByPath.get(reused) : void 0;
|
|
2339
|
+
if (content == null || reused === c.path) continue;
|
|
2340
|
+
const cores = content.split("\n").map(commandCore);
|
|
2341
|
+
afterCores.push(...cores);
|
|
2342
|
+
addedCores.push(...cores);
|
|
2343
|
+
}
|
|
1954
2344
|
for (const l of removedLines(c)) {
|
|
1955
2345
|
if (/^\s*#/.test(l.content)) continue;
|
|
1956
2346
|
const isUsesLine = USES.test(l.content) && CHECK.test(l.content);
|
|
1957
2347
|
const isCheckCommand = !isUsesLine && !/^\s*-?\s*uses:/.test(l.content) && (YAML_KEY.test(l.content) ? /^\s*-?\s*run:/.test(l.content) : true) && invokesCheck(l.content);
|
|
1958
2348
|
if (!isUsesLine && !isCheckCommand) continue;
|
|
1959
|
-
const s = survives(commandCore(l.content), isUsesLine, afterCores);
|
|
2349
|
+
const s = survives(commandCore(l.content), isUsesLine, afterCores, addedCores);
|
|
1960
2350
|
if (s.state === "kept") continue;
|
|
1961
2351
|
if (s.state === "neutralised") {
|
|
1962
2352
|
out2.push(
|
|
@@ -1964,7 +2354,7 @@ var ciTampering = {
|
|
|
1964
2354
|
file: c.path,
|
|
1965
2355
|
message: "A CI check command was neutralised in place: it still runs, but its result no longer decides (or its suite was narrowed).",
|
|
1966
2356
|
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."
|
|
2357
|
+
remediation: "Let the check decide the step; do not mask its status, cut it short, or narrow what it runs."
|
|
1968
2358
|
})
|
|
1969
2359
|
);
|
|
1970
2360
|
continue;
|
|
@@ -1985,11 +2375,11 @@ var ciTampering = {
|
|
|
1985
2375
|
|
|
1986
2376
|
// src/detectors/hook-tampering.ts
|
|
1987
2377
|
import { existsSync, readFileSync as readFileSync2 } from "node:fs";
|
|
1988
|
-
import { join as join2 } from "node:path";
|
|
2378
|
+
import { isAbsolute, join as join2, relative } from "node:path";
|
|
1989
2379
|
|
|
1990
2380
|
// src/detectors/policy-diff.ts
|
|
1991
2381
|
import { parse } from "yaml";
|
|
1992
|
-
var
|
|
2382
|
+
var globs2 = (list) => Array.isArray(list) ? list.filter((g) => typeof g === "string").map(normalizeGlob) : [];
|
|
1993
2383
|
function safeParse(src) {
|
|
1994
2384
|
try {
|
|
1995
2385
|
const v = parse(src);
|
|
@@ -2006,13 +2396,13 @@ function effective(raw) {
|
|
|
2006
2396
|
const rules = { ...base.rules };
|
|
2007
2397
|
for (const [name, cfg] of Object.entries(raw.rules ?? {})) {
|
|
2008
2398
|
const over = cfg && typeof cfg === "object" && !Array.isArray(cfg) ? cfg : {};
|
|
2009
|
-
rules[name] = { ...rules[name] ?? {}, ...over, ...over.exclude ? { exclude:
|
|
2399
|
+
rules[name] = { ...rules[name] ?? {}, ...over, ...over.exclude ? { exclude: globs2(over.exclude) } : {} };
|
|
2010
2400
|
}
|
|
2011
|
-
const userProtected = raw.protected && typeof raw.protected === "object" ? Object.fromEntries(Object.entries(raw.protected).map(([cat, list]) => [cat,
|
|
2401
|
+
const userProtected = raw.protected && typeof raw.protected === "object" ? Object.fromEntries(Object.entries(raw.protected).map(([cat, list]) => [cat, globs2(list)])) : void 0;
|
|
2012
2402
|
return {
|
|
2013
2403
|
version,
|
|
2014
2404
|
rules,
|
|
2015
|
-
ignore: raw.ignore ?
|
|
2405
|
+
ignore: raw.ignore ? globs2(raw.ignore) : base.ignore ?? [],
|
|
2016
2406
|
protected: mergeProtected(base.protected, userProtected),
|
|
2017
2407
|
requiredFor: raw.signoff?.required_for ?? raw.signoff?.requiredFor ?? base.signoff.requiredFor,
|
|
2018
2408
|
ledger: typeof raw.signoff?.ledger === "string" ? raw.signoff.ledger : base.signoff.ledger
|
|
@@ -2495,9 +2885,22 @@ function chmodDropsExec(toks) {
|
|
|
2495
2885
|
return false;
|
|
2496
2886
|
}
|
|
2497
2887
|
var WRAPPERS = /^(?:sudo|command|exec|time|nice|env|[A-Za-z_][A-Za-z0-9_]*=\S*)$/;
|
|
2888
|
+
var REDIRECT_TARGET = /(?:^|\s)\d*>{1,2}\|?\s*(\S+)/g;
|
|
2889
|
+
var hookPath = (t) => t.replace(/^["']|["']$/g, "").replace(/^\.\//, "");
|
|
2890
|
+
function shellHookTarget(seg, toks, policy) {
|
|
2891
|
+
for (const m of seg.matchAll(REDIRECT_TARGET)) {
|
|
2892
|
+
const t = hookPath(m[1]);
|
|
2893
|
+
if (isProtected(t, policy, "hooks")) return t;
|
|
2894
|
+
}
|
|
2895
|
+
for (const tok of toks) {
|
|
2896
|
+
const t = hookPath(tok.replace(/^of=/, ""));
|
|
2897
|
+
if (isProtected(t, policy, "hooks")) return t;
|
|
2898
|
+
}
|
|
2899
|
+
return null;
|
|
2900
|
+
}
|
|
2498
2901
|
function shellWritesHook(seg, toks, policy) {
|
|
2499
2902
|
const hook = (t) => isProtected(t.replace(/^\.\//, ""), policy, "hooks");
|
|
2500
|
-
for (const m of seg.matchAll(
|
|
2903
|
+
for (const m of seg.matchAll(REDIRECT_TARGET)) {
|
|
2501
2904
|
if (hook(m[1].replace(/^["']|["']$/g, ""))) return "a redirect empties or rewrites a protected hook";
|
|
2502
2905
|
}
|
|
2503
2906
|
let at = 0;
|
|
@@ -2631,11 +3034,11 @@ function settingsWeakening(before, after, sibling) {
|
|
|
2631
3034
|
}
|
|
2632
3035
|
if (now !== "none" || !declares(after, event)) continue;
|
|
2633
3036
|
if (before !== null && declares(before, event)) continue;
|
|
2634
|
-
const
|
|
3037
|
+
const empty2 = (eventEntries(after, event) ?? []).length === 0;
|
|
2635
3038
|
const shadowed = sibling !== null && gateWiring(sibling, event).length > 0;
|
|
2636
|
-
if (
|
|
3039
|
+
if (empty2 || shadowed) {
|
|
2637
3040
|
reasons.push(
|
|
2638
|
-
`${event} hooks declared ${
|
|
3041
|
+
`${event} hooks declared ${empty2 ? "empty" : "without the tamperward entry"} in an override file` + (shadowed ? " \u2014 this shadows the gate the sibling settings file wires" : "")
|
|
2639
3042
|
);
|
|
2640
3043
|
}
|
|
2641
3044
|
}
|
|
@@ -2708,14 +3111,24 @@ function configFindings(c, policy) {
|
|
|
2708
3111
|
function updateIndexHiding(toks, policy) {
|
|
2709
3112
|
if (!toks.some((t) => t === "git" || t.endsWith("/git")) || !toks.includes("update-index")) return null;
|
|
2710
3113
|
const hide = toks.find((t) => /^--(?:skip-worktree|assume-unchanged)$/.test(t));
|
|
2711
|
-
|
|
2712
|
-
|
|
3114
|
+
const hidden = hide ? toks.find((t) => isProtected(t, policy)) : void 0;
|
|
3115
|
+
if (hide && hidden) {
|
|
3116
|
+
return {
|
|
3117
|
+
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`,
|
|
3118
|
+
file: hidden
|
|
3119
|
+
};
|
|
2713
3120
|
}
|
|
2714
|
-
|
|
2715
|
-
|
|
3121
|
+
const hook = toks.some((t) => /^--chmod=-x$/.test(t)) ? toks.find((t) => isProtected(t, policy, "hooks")) : void 0;
|
|
3122
|
+
if (hook) {
|
|
3123
|
+
return { reason: "git update-index --chmod=-x clears the execute bit on a protected hook through the index, which disables it", file: hook };
|
|
2716
3124
|
}
|
|
2717
3125
|
return null;
|
|
2718
3126
|
}
|
|
3127
|
+
function repoRelative(path, ctx) {
|
|
3128
|
+
if (!ctx?.cwd || !isAbsolute(path)) return path;
|
|
3129
|
+
const rel = relative(ctx.cwd, path);
|
|
3130
|
+
return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : path;
|
|
3131
|
+
}
|
|
2719
3132
|
var hookTampering = {
|
|
2720
3133
|
id: RULE6,
|
|
2721
3134
|
surface: ["file", "command"],
|
|
@@ -2865,7 +3278,8 @@ var hookTampering = {
|
|
|
2865
3278
|
if (hiding) {
|
|
2866
3279
|
out2.push(
|
|
2867
3280
|
makeFinding(RULE6, policy, {
|
|
2868
|
-
|
|
3281
|
+
file: repoRelative(hiding.file, ctx),
|
|
3282
|
+
message: `Hook tampering via shell: ${hiding.reason}.`,
|
|
2869
3283
|
evidence: seg,
|
|
2870
3284
|
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."
|
|
2871
3285
|
})
|
|
@@ -2874,8 +3288,10 @@ var hookTampering = {
|
|
|
2874
3288
|
}
|
|
2875
3289
|
const why = shellWritesHook(seg, toks, policy);
|
|
2876
3290
|
if (why) {
|
|
3291
|
+
const target = shellHookTarget(seg, toks, policy);
|
|
2877
3292
|
out2.push(
|
|
2878
3293
|
makeFinding(RULE6, policy, {
|
|
3294
|
+
...target ? { file: repoRelative(target, ctx) } : {},
|
|
2879
3295
|
message: `Hook tampering via shell: ${why}.`,
|
|
2880
3296
|
evidence: seg,
|
|
2881
3297
|
remediation: "Leave the hooks in place. Mutating them from the shell is still tampering."
|
|
@@ -2919,67 +3335,147 @@ function keyName2(name) {
|
|
|
2919
3335
|
if (ts3.isIdentifier(name) || ts3.isStringLiteral(name)) return name.text;
|
|
2920
3336
|
return null;
|
|
2921
3337
|
}
|
|
3338
|
+
var isStr = (e) => ts3.isStringLiteral(e) || ts3.isNoSubstitutionTemplateLiteral(e);
|
|
2922
3339
|
function literals2(e) {
|
|
2923
|
-
if (
|
|
3340
|
+
if (isStr(e)) return { items: [e.text], opaque: false };
|
|
2924
3341
|
if (!ts3.isArrayLiteralExpression(e)) return { items: [], opaque: true };
|
|
2925
3342
|
const items = [];
|
|
2926
3343
|
let opaque = false;
|
|
2927
3344
|
for (const el of e.elements) {
|
|
2928
|
-
if (
|
|
3345
|
+
if (isStr(el)) items.push(el.text);
|
|
2929
3346
|
else if (ts3.isSpreadElement(el) && /^(?:configDefaults\.|default)(?:exclude|include)$/i.test(el.expression.getText())) {
|
|
2930
3347
|
items.push(.../exclude$/i.test(el.expression.getText()) ? VITEST_DEFAULT_EXCLUDE : VITEST_DEFAULT_INCLUDE);
|
|
2931
3348
|
} else opaque = true;
|
|
2932
3349
|
}
|
|
2933
3350
|
return { items, opaque };
|
|
2934
3351
|
}
|
|
2935
|
-
function
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
3352
|
+
function ownerKey2(node) {
|
|
3353
|
+
const obj = node.parent;
|
|
3354
|
+
if (!obj || !ts3.isObjectLiteralExpression(obj)) return null;
|
|
3355
|
+
let q = obj.parent;
|
|
3356
|
+
while (q && (ts3.isParenthesizedExpression(q) || ts3.isAsExpression(q) || q.kind === ts3.SyntaxKind.SatisfiesExpression)) q = q.parent;
|
|
3357
|
+
return q && ts3.isPropertyAssignment(q) ? keyName2(q.name) : null;
|
|
2940
3358
|
}
|
|
2941
3359
|
function asExpression2(src) {
|
|
2942
3360
|
const t = src.trim();
|
|
2943
3361
|
return t.startsWith("{") ? `(${t})` : src;
|
|
2944
3362
|
}
|
|
2945
|
-
function runnerOf(path) {
|
|
3363
|
+
function runnerOf(path, content) {
|
|
2946
3364
|
const base = path.split("/").pop() ?? path;
|
|
2947
3365
|
if (/^jest\.config\./.test(base) || base === "package.json") return "jest";
|
|
2948
|
-
if (/^vitest\.config\./.test(base)) return "vitest";
|
|
3366
|
+
if (/^vitest\.(?:config|workspace)\./.test(base)) return "vitest";
|
|
3367
|
+
if (/^vite\.config\./.test(base) && content != null && /(?:^|[\s,{])["']?test["']?\s*:/m.test(content)) return "vitest";
|
|
2949
3368
|
return null;
|
|
2950
3369
|
}
|
|
2951
|
-
|
|
2952
|
-
|
|
3370
|
+
var isWorkspaceFile = (path) => /^vitest\.workspace\./.test(path.split("/").pop() ?? path);
|
|
3371
|
+
var empty = () => ({ include: null, regex: null, ignore: null, roots: null, base: null, projects: null, opaque: false, present: false });
|
|
3372
|
+
var stripRootDir = (g) => g.replace(/^<rootDir>\/?/, "");
|
|
3373
|
+
function dirValue(e) {
|
|
3374
|
+
if (!isStr(e)) return { dir: null, opaque: true };
|
|
3375
|
+
const d = stripRootDir(e.text).replace(/^\.\//, "").replace(/\/+$/, "");
|
|
3376
|
+
if (d === "." || d === "" || d.split("/").includes("..")) return { dir: null, opaque: false };
|
|
3377
|
+
return { dir: d, opaque: false };
|
|
3378
|
+
}
|
|
3379
|
+
function collect(root, runner) {
|
|
3380
|
+
const sel = empty();
|
|
3381
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3382
|
+
const take = (key2, e) => {
|
|
3383
|
+
const { items, opaque } = literals2(e);
|
|
3384
|
+
if (opaque || seen.has(key2)) sel.opaque = true;
|
|
3385
|
+
seen.add(key2);
|
|
3386
|
+
sel.present = true;
|
|
3387
|
+
return items.map(stripRootDir);
|
|
3388
|
+
};
|
|
3389
|
+
const ignores = (key2, e) => {
|
|
3390
|
+
const entries = take(key2, e).map((pattern) => ({ pattern, key: key2 }));
|
|
3391
|
+
sel.ignore = [...sel.ignore ?? [], ...entries];
|
|
3392
|
+
};
|
|
3393
|
+
const projects = (e) => {
|
|
3394
|
+
sel.present = true;
|
|
3395
|
+
if (!ts3.isArrayLiteralExpression(e)) {
|
|
3396
|
+
sel.opaque = true;
|
|
3397
|
+
return;
|
|
3398
|
+
}
|
|
3399
|
+
const list = [];
|
|
3400
|
+
for (const el of e.elements) {
|
|
3401
|
+
if (ts3.isObjectLiteralExpression(el)) list.push(collect(el, runner));
|
|
3402
|
+
else sel.opaque = true;
|
|
3403
|
+
}
|
|
3404
|
+
sel.projects = list;
|
|
3405
|
+
if (list.some((p) => p.opaque)) sel.opaque = true;
|
|
3406
|
+
};
|
|
3407
|
+
const visit = (node) => {
|
|
3408
|
+
if (ts3.isPropertyAssignment(node)) {
|
|
3409
|
+
const k = keyName2(node.name);
|
|
3410
|
+
if (runner === "jest") {
|
|
3411
|
+
if (k === "testMatch") sel.include = take(k, node.initializer);
|
|
3412
|
+
else if (k === "testRegex") sel.regex = take(k, node.initializer);
|
|
3413
|
+
else if (k === "testPathIgnorePatterns" || k === "modulePathIgnorePatterns") ignores(k, node.initializer);
|
|
3414
|
+
else if (k === "roots") sel.roots = take(k, node.initializer);
|
|
3415
|
+
else if (k === "rootDir") {
|
|
3416
|
+
const { dir, opaque } = dirValue(node.initializer);
|
|
3417
|
+
sel.present = true;
|
|
3418
|
+
if (opaque) sel.opaque = true;
|
|
3419
|
+
else sel.base = dir;
|
|
3420
|
+
} else if (k === "projects") {
|
|
3421
|
+
projects(node.initializer);
|
|
3422
|
+
return;
|
|
3423
|
+
}
|
|
3424
|
+
} else if (ownerKey2(node) === "test") {
|
|
3425
|
+
if (k === "include") sel.include = take(k, node.initializer);
|
|
3426
|
+
else if (k === "exclude") ignores(k, node.initializer);
|
|
3427
|
+
else if (k === "dir") {
|
|
3428
|
+
const { dir, opaque } = dirValue(node.initializer);
|
|
3429
|
+
sel.present = true;
|
|
3430
|
+
if (opaque) sel.opaque = true;
|
|
3431
|
+
else sel.base = dir;
|
|
3432
|
+
} else if (k === "projects") {
|
|
3433
|
+
projects(node.initializer);
|
|
3434
|
+
return;
|
|
3435
|
+
}
|
|
3436
|
+
}
|
|
3437
|
+
}
|
|
3438
|
+
ts3.forEachChild(node, visit);
|
|
3439
|
+
};
|
|
3440
|
+
visit(root);
|
|
3441
|
+
return sel;
|
|
3442
|
+
}
|
|
3443
|
+
function workspaceArray(sf) {
|
|
3444
|
+
for (const st of sf.statements) {
|
|
3445
|
+
let e;
|
|
3446
|
+
if (ts3.isExportAssignment(st)) e = st.expression;
|
|
3447
|
+
else if (ts3.isExpressionStatement(st) && ts3.isBinaryExpression(st.expression) && /module\.exports/.test(st.expression.left.getText())) e = st.expression.right;
|
|
3448
|
+
if (!e) continue;
|
|
3449
|
+
while (ts3.isParenthesizedExpression(e) || ts3.isAsExpression(e) || e.kind === ts3.SyntaxKind.SatisfiesExpression) {
|
|
3450
|
+
e = e.expression;
|
|
3451
|
+
}
|
|
3452
|
+
if (ts3.isCallExpression(e) && e.arguments.length > 0) e = e.arguments[0];
|
|
3453
|
+
return ts3.isArrayLiteralExpression(e) ? e : null;
|
|
3454
|
+
}
|
|
3455
|
+
return null;
|
|
3456
|
+
}
|
|
3457
|
+
function parseSelection(src, runner, path = "") {
|
|
3458
|
+
let sel = empty();
|
|
2953
3459
|
try {
|
|
2954
3460
|
const sf = ts3.createSourceFile("cfg.ts", asExpression2(src), ts3.ScriptTarget.Latest, true, ts3.ScriptKind.TS);
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
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);
|
|
3461
|
+
if (runner === "vitest" && isWorkspaceFile(path)) {
|
|
3462
|
+
const arr = workspaceArray(sf);
|
|
3463
|
+
if (arr) {
|
|
3464
|
+
const list = [];
|
|
3465
|
+
for (const el of arr.elements) {
|
|
3466
|
+
if (ts3.isObjectLiteralExpression(el)) list.push(collect(el, runner));
|
|
3467
|
+
else sel.opaque = true;
|
|
2973
3468
|
}
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
3469
|
+
sel.projects = list;
|
|
3470
|
+
sel.present = true;
|
|
3471
|
+
if (list.some((p) => p.opaque)) sel.opaque = true;
|
|
3472
|
+
} else if (src.trim()) sel.opaque = true;
|
|
3473
|
+
} else sel = collect(sf, runner);
|
|
2978
3474
|
} catch {
|
|
2979
3475
|
}
|
|
2980
3476
|
return sel;
|
|
2981
3477
|
}
|
|
2982
|
-
var
|
|
3478
|
+
var globs3 = (patterns) => {
|
|
2983
3479
|
const ms = patterns.map((g) => {
|
|
2984
3480
|
try {
|
|
2985
3481
|
return picomatch3(g, { dot: true });
|
|
@@ -2989,6 +3485,11 @@ var globs2 = (patterns) => {
|
|
|
2989
3485
|
});
|
|
2990
3486
|
return (path) => ms.some((m) => m(path));
|
|
2991
3487
|
};
|
|
3488
|
+
var globList2 = (patterns) => {
|
|
3489
|
+
const pos = globs3(patterns.filter((g) => !g.startsWith("!")));
|
|
3490
|
+
const neg = globs3(patterns.filter((g) => g.startsWith("!")).map((g) => g.slice(1)));
|
|
3491
|
+
return (path) => pos(path) && !neg(path);
|
|
3492
|
+
};
|
|
2992
3493
|
var regexes = (patterns) => {
|
|
2993
3494
|
const rs = patterns.flatMap((p) => {
|
|
2994
3495
|
try {
|
|
@@ -2999,29 +3500,44 @@ var regexes = (patterns) => {
|
|
|
2999
3500
|
});
|
|
3000
3501
|
return (path) => rs.some((r) => r.test("/" + path));
|
|
3001
3502
|
};
|
|
3503
|
+
var underDir = (path, dir) => dir === "" || path.startsWith(dir + "/");
|
|
3002
3504
|
function predicate(sel, runner) {
|
|
3505
|
+
if (sel.projects) {
|
|
3506
|
+
const ps = sel.projects.map((p) => predicate(p, runner));
|
|
3507
|
+
return {
|
|
3508
|
+
selects: (p) => ps.some((x) => x.selects(p)),
|
|
3509
|
+
why: (p) => ps.length ? `no project selects it (${ps[0].why(p)})` : "the projects list is empty"
|
|
3510
|
+
};
|
|
3511
|
+
}
|
|
3003
3512
|
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 ?
|
|
3006
|
-
const
|
|
3513
|
+
const ignoreList = sel.ignore ?? (runner === "jest" ? JEST_DEFAULT_IGNORE : VITEST_DEFAULT_EXCLUDE).map((pattern) => ({ pattern, key: runner === "jest" ? "testPathIgnorePatterns" : "test.exclude" }));
|
|
3514
|
+
const included = includeList ? globList2(includeList) : sel.regex ? regexes(sel.regex) : () => true;
|
|
3515
|
+
const ignoredBy = (p) => ignoreList.find((e) => (runner === "jest" ? regexes([e.pattern]) : globs3([e.pattern]))(p));
|
|
3516
|
+
const roots = sel.roots?.map((r) => r.replace(/^\.\//, "").replace(/\/+$/, "")).map((r) => r === "." ? "" : r) ?? null;
|
|
3517
|
+
const rebase = (p) => sel.base === null ? p : underDir(p, sel.base) ? p.slice(sel.base.length + 1) : null;
|
|
3518
|
+
const inRoots = (p) => roots === null || roots.some((r) => underDir(p, r));
|
|
3007
3519
|
return {
|
|
3008
|
-
selects: (p) =>
|
|
3520
|
+
selects: (p) => {
|
|
3521
|
+
const r = rebase(p);
|
|
3522
|
+
return r !== null && inRoots(r) && included(r) && !ignoredBy(r);
|
|
3523
|
+
},
|
|
3009
3524
|
why: (p) => {
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3525
|
+
const r = rebase(p);
|
|
3526
|
+
const baseKey = runner === "jest" ? "rootDir" : "test.dir";
|
|
3527
|
+
if (r === null) return `${baseKey} no longer covers it (${JSON.stringify(sel.base)})`;
|
|
3528
|
+
if (!inRoots(r)) return `roots no longer covers it (${JSON.stringify(sel.roots)})`;
|
|
3529
|
+
const hit = ignoredBy(r);
|
|
3530
|
+
if (hit) return `${hit.key === "exclude" ? "test.exclude" : hit.key} now matches it (${JSON.stringify(hit.pattern)})`;
|
|
3015
3531
|
const key2 = sel.include ? runner === "jest" ? "testMatch" : "test.include" : "testRegex";
|
|
3016
3532
|
return `${key2} no longer matches it (${JSON.stringify(sel.include ?? sel.regex ?? [])})`;
|
|
3017
3533
|
}
|
|
3018
3534
|
};
|
|
3019
3535
|
}
|
|
3020
3536
|
function suiteNarrowings(before, after, path, samples) {
|
|
3021
|
-
const runner = runnerOf(path);
|
|
3537
|
+
const runner = runnerOf(path, after) ?? runnerOf(path, before);
|
|
3022
3538
|
if (!runner) return [];
|
|
3023
|
-
const b = parseSelection(before ?? "", runner);
|
|
3024
|
-
const a = parseSelection(after, runner);
|
|
3539
|
+
const b = parseSelection(before ?? "", runner, path);
|
|
3540
|
+
const a = parseSelection(after, runner, path);
|
|
3025
3541
|
if (!a.present || a.opaque) return [];
|
|
3026
3542
|
const pb = predicate(b, runner);
|
|
3027
3543
|
const pa = predicate(a, runner);
|
|
@@ -3053,6 +3569,18 @@ function calleeName(expr) {
|
|
|
3053
3569
|
return null;
|
|
3054
3570
|
}
|
|
3055
3571
|
var isEachOf = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && (expr.expression.text === "it" || expr.expression.text === "test") && expr.name.text === "each";
|
|
3572
|
+
var isDescribeEach = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && expr.expression.text === "describe" && expr.name.text === "each";
|
|
3573
|
+
function templateRows(node) {
|
|
3574
|
+
const rows = node.template.getText().split("\n").filter((l) => /\S/.test(l.replace(/[`]/g, ""))).length - 1;
|
|
3575
|
+
return Math.max(rows, 1);
|
|
3576
|
+
}
|
|
3577
|
+
function hasSubstantiveBody(call) {
|
|
3578
|
+
const fn = call.arguments.find((a) => ts4.isArrowFunction(a) || ts4.isFunctionExpression(a));
|
|
3579
|
+
if (!fn) return true;
|
|
3580
|
+
const body = fn.body;
|
|
3581
|
+
const text = ts4.isBlock(body) ? body.getText().slice(1, -1) : body.getText();
|
|
3582
|
+
return text.split("\n").some((l) => isSignificantLine(l.trim(), "js"));
|
|
3583
|
+
}
|
|
3056
3584
|
function eachRows(call) {
|
|
3057
3585
|
const arg = call.arguments[0];
|
|
3058
3586
|
if (!arg) return { n: 1, open: true };
|
|
@@ -3067,7 +3595,7 @@ function eachRows(call) {
|
|
|
3067
3595
|
}
|
|
3068
3596
|
return { n: 1, open: true };
|
|
3069
3597
|
}
|
|
3070
|
-
function countTests(src, path = "spec.ts") {
|
|
3598
|
+
function countTests(src, path = "spec.ts", substantiveOnly = false) {
|
|
3071
3599
|
const lang = langOf(path);
|
|
3072
3600
|
if (lang && lang !== "js") {
|
|
3073
3601
|
const re = TEST_DEFS[lang];
|
|
@@ -3078,23 +3606,33 @@ function countTests(src, path = "spec.ts") {
|
|
|
3078
3606
|
const sf = ts4.createSourceFile("spec.ts", src, ts4.ScriptTarget.Latest, true, ts4.ScriptKind.TS);
|
|
3079
3607
|
let n = 0;
|
|
3080
3608
|
let open = false;
|
|
3081
|
-
const visit = (node) => {
|
|
3609
|
+
const visit = (node, mult) => {
|
|
3082
3610
|
if (ts4.isCallExpression(node)) {
|
|
3611
|
+
const head = node.expression;
|
|
3612
|
+
if (ts4.isCallExpression(head) && isDescribeEach(head.expression)) {
|
|
3613
|
+
const r = eachRows(head);
|
|
3614
|
+
if (r.open) open = true;
|
|
3615
|
+
for (const a of node.arguments) visit(a, mult * r.n);
|
|
3616
|
+
return;
|
|
3617
|
+
}
|
|
3618
|
+
if (ts4.isTaggedTemplateExpression(head) && isDescribeEach(head.tag)) {
|
|
3619
|
+
for (const a of node.arguments) visit(a, mult * templateRows(head));
|
|
3620
|
+
return;
|
|
3621
|
+
}
|
|
3083
3622
|
if (isEachOf(node.expression)) {
|
|
3084
3623
|
const r = eachRows(node);
|
|
3085
|
-
n += r.n;
|
|
3624
|
+
n += r.n * mult;
|
|
3086
3625
|
if (r.open) open = true;
|
|
3087
3626
|
} else {
|
|
3088
3627
|
const name = calleeName(node.expression);
|
|
3089
|
-
if (name === "it" || name === "test") n
|
|
3628
|
+
if ((name === "it" || name === "test") && (!substantiveOnly || hasSubstantiveBody(node))) n += mult;
|
|
3090
3629
|
}
|
|
3091
3630
|
} else if (ts4.isTaggedTemplateExpression(node) && isEachOf(node.tag)) {
|
|
3092
|
-
|
|
3093
|
-
n += Math.max(rows, 1);
|
|
3631
|
+
n += templateRows(node) * mult;
|
|
3094
3632
|
}
|
|
3095
|
-
ts4.forEachChild(node, visit);
|
|
3633
|
+
ts4.forEachChild(node, (c) => visit(c, mult));
|
|
3096
3634
|
};
|
|
3097
|
-
visit(sf);
|
|
3635
|
+
visit(sf, 1);
|
|
3098
3636
|
return { min: n, open };
|
|
3099
3637
|
}
|
|
3100
3638
|
function countTestBlocks(src, path = "spec.ts") {
|
|
@@ -3109,7 +3647,7 @@ function significantLines(src, path) {
|
|
|
3109
3647
|
}
|
|
3110
3648
|
return out2;
|
|
3111
3649
|
}
|
|
3112
|
-
var OTHER_RUNNER_DIR = /(?:^|\/)(?:e2e|cypress|playwright)\//;
|
|
3650
|
+
var OTHER_RUNNER_DIR = /(?:^|\/)(?:e2e|cypress|playwright|fixtures|__fixtures__|__mocks__)\//;
|
|
3113
3651
|
function runnerSamples(configPath, policy, ctx) {
|
|
3114
3652
|
const files = trackedFiles(ctx);
|
|
3115
3653
|
if (!files) return CANONICAL_SAMPLES;
|
|
@@ -3117,6 +3655,34 @@ function runnerSamples(configPath, policy, ctx) {
|
|
|
3117
3655
|
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
3656
|
return own.length ? own : CANONICAL_SAMPLES;
|
|
3119
3657
|
}
|
|
3658
|
+
function scriptNarrowings(before, after, path) {
|
|
3659
|
+
if ((path.split("/").pop() ?? path) !== "package.json") return [];
|
|
3660
|
+
const scriptsOf = (src) => {
|
|
3661
|
+
try {
|
|
3662
|
+
const s = JSON.parse(src ?? "{}").scripts ?? {};
|
|
3663
|
+
return Object.fromEntries(Object.entries(s).filter((e) => typeof e[1] === "string"));
|
|
3664
|
+
} catch {
|
|
3665
|
+
return {};
|
|
3666
|
+
}
|
|
3667
|
+
};
|
|
3668
|
+
const was = scriptsOf(before);
|
|
3669
|
+
const now = scriptsOf(after);
|
|
3670
|
+
const out2 = [];
|
|
3671
|
+
for (const [name, cmd] of Object.entries(now)) {
|
|
3672
|
+
if (!/^test(?:$|[:.-])/.test(name) || !/\b(?:jest|vitest)\b/.test(cmd)) continue;
|
|
3673
|
+
const prev = was[name] ?? "";
|
|
3674
|
+
const flags = new RegExp(SUITE_NARROWING_FLAGS.source, "g");
|
|
3675
|
+
for (const m of cmd.matchAll(flags)) {
|
|
3676
|
+
const flag = m[0].trim();
|
|
3677
|
+
if (!flag) continue;
|
|
3678
|
+
const one = new RegExp(SUITE_NARROWING_FLAGS.source);
|
|
3679
|
+
if (one.test(prev) && prev.includes(flag)) continue;
|
|
3680
|
+
out2.push({ script: name, flag, before: prev, after: cmd });
|
|
3681
|
+
break;
|
|
3682
|
+
}
|
|
3683
|
+
}
|
|
3684
|
+
return out2;
|
|
3685
|
+
}
|
|
3120
3686
|
function joinRoot(root, to) {
|
|
3121
3687
|
const parts = root ? root.split("/") : [];
|
|
3122
3688
|
for (const seg of to.replace(/\/+$/, "").split("/")) {
|
|
@@ -3148,11 +3714,11 @@ var testDeletion = {
|
|
|
3148
3714
|
if (c.kind !== "file" || c.after == null || !isProtected(c.path, policy, "tests")) continue;
|
|
3149
3715
|
if (c.op === "add") {
|
|
3150
3716
|
for (const l of significantLines(c.after, c.path)) addedTestLines.add(l);
|
|
3151
|
-
addedTestBlocks +=
|
|
3717
|
+
addedTestBlocks += countTests(c.after, c.path, true).min;
|
|
3152
3718
|
} else if (c.before != null) {
|
|
3153
3719
|
const before = significantLines(c.before, c.path);
|
|
3154
3720
|
for (const l of significantLines(c.after, c.path)) if (!before.has(l)) addedTestLines.add(l);
|
|
3155
|
-
addedTestBlocks += Math.max(0,
|
|
3721
|
+
addedTestBlocks += Math.max(0, countTests(c.after, c.path, true).min - countTests(c.before, c.path, true).min);
|
|
3156
3722
|
}
|
|
3157
3723
|
}
|
|
3158
3724
|
const isRelocation = (removed, blocksRemoved) => {
|
|
@@ -3199,7 +3765,7 @@ var testDeletion = {
|
|
|
3199
3765
|
})
|
|
3200
3766
|
);
|
|
3201
3767
|
}
|
|
3202
|
-
} else if (c.op !== "delete" && c.after != null && !isTest && isProtected(c.path, policy, "config") && runnerOf(c.path)) {
|
|
3768
|
+
} else if (c.op !== "delete" && c.after != null && !isTest && isProtected(c.path, policy, "config") && runnerOf(c.path, c.after)) {
|
|
3203
3769
|
for (const n of suiteNarrowings(c.before, c.after, c.path, runnerSamples(c.path, policy, ctx))) {
|
|
3204
3770
|
out2.push(
|
|
3205
3771
|
makeFinding(RULE7, policy, {
|
|
@@ -3210,6 +3776,16 @@ var testDeletion = {
|
|
|
3210
3776
|
})
|
|
3211
3777
|
);
|
|
3212
3778
|
}
|
|
3779
|
+
for (const n of scriptNarrowings(c.before, c.after, c.path)) {
|
|
3780
|
+
out2.push(
|
|
3781
|
+
makeFinding(RULE7, policy, {
|
|
3782
|
+
file: c.path,
|
|
3783
|
+
message: `The test script now narrows the suite: ${n.flag} added to scripts.${n.script}.`,
|
|
3784
|
+
evidence: `"${n.script}": ${JSON.stringify(n.before)} \u2192 ${JSON.stringify(n.after)}`,
|
|
3785
|
+
remediation: "Keep the test script running the whole suite; a runner flag that selects fewer specs removes the rest from every run."
|
|
3786
|
+
})
|
|
3787
|
+
);
|
|
3788
|
+
}
|
|
3213
3789
|
}
|
|
3214
3790
|
} else {
|
|
3215
3791
|
let root = "";
|
|
@@ -3223,15 +3799,25 @@ var testDeletion = {
|
|
|
3223
3799
|
const inRepo = (t) => root ? `${root}/${t}` : t;
|
|
3224
3800
|
const listing = trackedFiles(ctx);
|
|
3225
3801
|
const testToks = toks.filter((t) => {
|
|
3226
|
-
|
|
3802
|
+
const resolved = root !== null && /(?:^|\/)\.{1,2}(?:\/|$)/.test(t) ? joinRoot(root, t) : null;
|
|
3803
|
+
const probe = resolved ?? t;
|
|
3804
|
+
if (!isProtected(probe, policy, "tests")) return false;
|
|
3227
3805
|
if (!listing || root === null) return true;
|
|
3228
|
-
const looksLikeFile = /\.[A-Za-z0-9]+$/.test(
|
|
3229
|
-
if (looksLikeFile || listing.includes(inRepo(t))) return true;
|
|
3230
|
-
return containsProtected(inRepo(t), policy, "tests", ctx);
|
|
3806
|
+
const looksLikeFile = /\.[A-Za-z0-9]+$/.test(probe.split("/").pop() ?? "");
|
|
3807
|
+
if (looksLikeFile || listing.includes(resolved ?? inRepo(t))) return true;
|
|
3808
|
+
return containsProtected(resolved ?? inRepo(t), policy, "tests", ctx);
|
|
3809
|
+
});
|
|
3810
|
+
const rootHasProtected = () => listing ? listing.some((f) => isProtected(f, policy, "tests")) : true;
|
|
3811
|
+
const dirToks = toks.filter((t) => {
|
|
3812
|
+
if (t.startsWith("-") || testToks.includes(t)) return false;
|
|
3813
|
+
const whole = /^(?:\.|\.\/|\*|\.\/\*)$/.test(t);
|
|
3814
|
+
if (!whole && /[*?{}[\]]/.test(t)) return false;
|
|
3815
|
+
if (root === null) return whole ? false : containsProtected(t, policy, "tests", ctx);
|
|
3816
|
+
const dir = whole ? root : joinRoot(root, t);
|
|
3817
|
+
if (dir === null) return false;
|
|
3818
|
+
if (dir === "") return rootHasProtected();
|
|
3819
|
+
return containsProtected(dir, policy, "tests", ctx);
|
|
3231
3820
|
});
|
|
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
3821
|
if (testToks.length === 0 && dirToks.length === 0) continue;
|
|
3236
3822
|
const named = [...testToks, ...dirToks];
|
|
3237
3823
|
const cmd = toks[0] ?? "";
|
|
@@ -3242,8 +3828,11 @@ var testDeletion = {
|
|
|
3242
3828
|
const findDeletes = cmd === "find" && /\s(?:-delete\b|-exec\s+(?:rm|unlink)\b|-execdir\s+(?:rm|unlink)\b)/.test(seg);
|
|
3243
3829
|
const gitRestoresOld = cmd === "git" && /^git\s+(?:checkout|restore)\b/.test(seg) && (() => {
|
|
3244
3830
|
const src = seg.match(/--source[= ]\s*(\S+)/)?.[1];
|
|
3245
|
-
const
|
|
3246
|
-
|
|
3831
|
+
const revIdx = toks.findIndex((t, i) => i >= 2 && !t.startsWith("-") && !named.includes(t) && t !== "--");
|
|
3832
|
+
const rev = src ?? (revIdx >= 0 ? toks[revIdx] : void 0);
|
|
3833
|
+
if (!rev || rev === "HEAD") return false;
|
|
3834
|
+
if (src || seg.includes(" -- ")) return true;
|
|
3835
|
+
return revIdx >= 0 && toks.some((t, i) => i > revIdx && named.includes(t));
|
|
3247
3836
|
})();
|
|
3248
3837
|
if (/\brm\b/.test(seg) && !/^(?:git|npm|yarn|pnpm|bun)\b/.test(seg)) {
|
|
3249
3838
|
why = "rm deletes a test file";
|
|
@@ -3298,7 +3887,7 @@ var MIN_REMOVED_LINES = 3;
|
|
|
3298
3887
|
var MIN_REMOVED_ROWS = 2;
|
|
3299
3888
|
var ws = (s) => s.replace(/\s+/g, "");
|
|
3300
3889
|
function eachTables(src, path) {
|
|
3301
|
-
const rows = /* @__PURE__ */ new
|
|
3890
|
+
const rows = /* @__PURE__ */ new Map();
|
|
3302
3891
|
let open = false;
|
|
3303
3892
|
if (langOf(path) !== "js" && langOf(path) !== null) return { rows, open };
|
|
3304
3893
|
try {
|
|
@@ -3312,10 +3901,9 @@ function eachTables(src, path) {
|
|
|
3312
3901
|
open = true;
|
|
3313
3902
|
continue;
|
|
3314
3903
|
}
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
}
|
|
3904
|
+
const text = el.getText(sf).replace(/\s*\n\s*/g, " ");
|
|
3905
|
+
const key2 = ws(text);
|
|
3906
|
+
if (key2.length >= 3) rows.set(key2, text);
|
|
3319
3907
|
}
|
|
3320
3908
|
} else open = true;
|
|
3321
3909
|
}
|
|
@@ -3370,12 +3958,13 @@ var testContentRemoval = {
|
|
|
3370
3958
|
gone.push(line);
|
|
3371
3959
|
}
|
|
3372
3960
|
const table = eachTables(c.before, c.path);
|
|
3373
|
-
const
|
|
3961
|
+
const tableAfter = eachTables(c.after, c.path);
|
|
3962
|
+
const goneRows = table.rows.size && tableAfter.open ? [...table.rows].filter(([k]) => !tableAfter.rows.has(k) && !addedPool.includes(k)).map(([, text]) => text) : [];
|
|
3374
3963
|
if (goneRows.length >= MIN_REMOVED_ROWS) {
|
|
3375
3964
|
out2.push(
|
|
3376
3965
|
makeFinding(RULE8, policy, {
|
|
3377
3966
|
file: c.path,
|
|
3378
|
-
message: `Rows removed from a test table: ${goneRows.length} case(s) gone from an each-table that
|
|
3967
|
+
message: `Rows removed from a test table: ${goneRows.length} case(s) gone from an each-table that spreads from elsewhere, and they reappear nowhere in the change.`,
|
|
3379
3968
|
evidence: goneRows.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
|
|
3380
3969
|
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
3970
|
})
|
|
@@ -3572,7 +4161,7 @@ function hasBlocking(findings) {
|
|
|
3572
4161
|
|
|
3573
4162
|
// src/policy-load.ts
|
|
3574
4163
|
import { readFileSync as readFileSync3, existsSync as existsSync2 } from "node:fs";
|
|
3575
|
-
import { isAbsolute, join as join3, posix } from "node:path";
|
|
4164
|
+
import { isAbsolute as isAbsolute2, join as join3, posix } from "node:path";
|
|
3576
4165
|
import { parse as parse2 } from "yaml";
|
|
3577
4166
|
var PolicyError = class extends Error {
|
|
3578
4167
|
};
|
|
@@ -3589,7 +4178,7 @@ var isStringList = (v) => Array.isArray(v) && v.every((s) => typeof s === "strin
|
|
|
3589
4178
|
var isMapping = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3590
4179
|
var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "protected", "rules", "ignore", "signoff", "verify"]);
|
|
3591
4180
|
function ledgerInsideRepo(ledger) {
|
|
3592
|
-
if (!ledger.trim() ||
|
|
4181
|
+
if (!ledger.trim() || isAbsolute2(ledger) || /^[A-Za-z]:[\\/]/.test(ledger)) return false;
|
|
3593
4182
|
const norm2 = posix.normalize(ledger.replace(/\\/g, "/"));
|
|
3594
4183
|
return norm2 !== ".." && !norm2.startsWith("../") && !norm2.startsWith("/");
|
|
3595
4184
|
}
|
|
@@ -3620,8 +4209,8 @@ function validate(r, where) {
|
|
|
3620
4209
|
if (r.ignore !== void 0 && !isStringList(r.ignore)) bad(`ignore must be a list of globs, got ${show(r.ignore)}`);
|
|
3621
4210
|
if (r.protected !== void 0) {
|
|
3622
4211
|
if (!isMapping(r.protected)) bad(`protected must be a mapping of category to a list of globs, got ${show(r.protected)}`);
|
|
3623
|
-
for (const [cat,
|
|
3624
|
-
if (!isStringList(
|
|
4212
|
+
for (const [cat, globs4] of Object.entries(r.protected)) {
|
|
4213
|
+
if (!isStringList(globs4)) bad(`protected.${cat} must be a list of globs, got ${show(globs4)}`);
|
|
3625
4214
|
}
|
|
3626
4215
|
}
|
|
3627
4216
|
if (r.signoff !== void 0) {
|
|
@@ -3653,10 +4242,10 @@ function parsePolicy(raw, where = POLICY_FILE) {
|
|
|
3653
4242
|
const version = normalizeVersion(r.version, where);
|
|
3654
4243
|
validate(r, where);
|
|
3655
4244
|
const base = defaultPolicy(version);
|
|
3656
|
-
const
|
|
3657
|
-
const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list]) => [cat,
|
|
4245
|
+
const globs4 = (list) => list?.map(normalizeGlob);
|
|
4246
|
+
const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list]) => [cat, globs4(list) ?? []])) : void 0;
|
|
3658
4247
|
const userRules = r.rules ? Object.fromEntries(
|
|
3659
|
-
Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude:
|
|
4248
|
+
Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude: globs4(cfg.exclude) } : cfg])
|
|
3660
4249
|
) : void 0;
|
|
3661
4250
|
return {
|
|
3662
4251
|
version,
|
|
@@ -3666,7 +4255,7 @@ function parsePolicy(raw, where = POLICY_FILE) {
|
|
|
3666
4255
|
// an `exclude`-only override drop the rule's baseline severity (see mergeRules).
|
|
3667
4256
|
protected: mergeProtected(base.protected, userProtected),
|
|
3668
4257
|
rules: mergeRules(base.rules, userRules),
|
|
3669
|
-
ignore:
|
|
4258
|
+
ignore: globs4(r.ignore) ?? base.ignore,
|
|
3670
4259
|
signoff: {
|
|
3671
4260
|
requiredFor: r.signoff?.required_for ?? r.signoff?.requiredFor ?? base.signoff.requiredFor,
|
|
3672
4261
|
ledger: r.signoff?.ledger ?? base.signoff.ledger
|
|
@@ -4043,7 +4632,8 @@ function check(opts) {
|
|
|
4043
4632
|
layer = "local";
|
|
4044
4633
|
view = "staged";
|
|
4045
4634
|
} else if (opts.worktree) {
|
|
4046
|
-
|
|
4635
|
+
const p = policy;
|
|
4636
|
+
changes = opts.includeUntracked ? diffWorktreeWithUntracked({ cwd: opts.cwd }, (rel) => isProtected(rel, p)) : diffWorktree({ cwd: opts.cwd });
|
|
4047
4637
|
layer = opts.ciLayer ? "ci" : "local";
|
|
4048
4638
|
view = "worktree";
|
|
4049
4639
|
} else if (opts.diff) {
|
|
@@ -4102,7 +4692,7 @@ import { join as join9 } from "node:path";
|
|
|
4102
4692
|
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
4103
4693
|
import { readFileSync as readFileSync5, existsSync as existsSync4, writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
4104
4694
|
import { tmpdir } from "node:os";
|
|
4105
|
-
import { isAbsolute as
|
|
4695
|
+
import { isAbsolute as isAbsolute3, join as join5, relative as relative2, resolve } from "node:path";
|
|
4106
4696
|
function asStr(v) {
|
|
4107
4697
|
return typeof v === "string" ? v : "";
|
|
4108
4698
|
}
|
|
@@ -4117,8 +4707,8 @@ function abs(path, cwd) {
|
|
|
4117
4707
|
return resolve(cwd, path);
|
|
4118
4708
|
}
|
|
4119
4709
|
function relForDisplay(path, cwd) {
|
|
4120
|
-
const rel =
|
|
4121
|
-
return rel && !rel.startsWith("..") && !
|
|
4710
|
+
const rel = relative2(cwd, path);
|
|
4711
|
+
return rel && !rel.startsWith("..") && !isAbsolute3(rel) ? rel : path;
|
|
4122
4712
|
}
|
|
4123
4713
|
function applyEdit(content, oldStr, newStr) {
|
|
4124
4714
|
if (content === null) return newStr;
|
|
@@ -4261,11 +4851,11 @@ function advanceTurnBaseline(cwd, sessionId) {
|
|
|
4261
4851
|
|
|
4262
4852
|
// src/effect.ts
|
|
4263
4853
|
import { createHash as createHash2 } from "node:crypto";
|
|
4264
|
-
import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync7, statSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
4265
|
-
import { isAbsolute as
|
|
4854
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync7, renameSync, rmSync as rmSync2, statSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
4855
|
+
import { isAbsolute as isAbsolute4, join as join7 } from "node:path";
|
|
4266
4856
|
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
4267
4857
|
var UNSAFE2 = /[^A-Za-z0-9._-]/g;
|
|
4268
|
-
var
|
|
4858
|
+
var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
|
|
4269
4859
|
function gitDir2(cwd) {
|
|
4270
4860
|
try {
|
|
4271
4861
|
const d = execFileSync4("git", ["rev-parse", "--git-dir"], {
|
|
@@ -4274,7 +4864,7 @@ function gitDir2(cwd) {
|
|
|
4274
4864
|
stdio: ["ignore", "pipe", "pipe"]
|
|
4275
4865
|
}).trim();
|
|
4276
4866
|
if (!d) return null;
|
|
4277
|
-
return
|
|
4867
|
+
return isAbsolute4(d) ? d : join7(cwd, d);
|
|
4278
4868
|
} catch {
|
|
4279
4869
|
return null;
|
|
4280
4870
|
}
|
|
@@ -4307,7 +4897,7 @@ function snapshotProtected(cwd, policy, prev) {
|
|
|
4307
4897
|
return;
|
|
4308
4898
|
}
|
|
4309
4899
|
for (const name of names) {
|
|
4310
|
-
if (
|
|
4900
|
+
if (SKIP_DIRS2.has(name)) continue;
|
|
4311
4901
|
const r = rel ? `${rel}/${name}` : name;
|
|
4312
4902
|
let st;
|
|
4313
4903
|
try {
|
|
@@ -4333,12 +4923,16 @@ function snapshotProtected(cwd, policy, prev) {
|
|
|
4333
4923
|
function driftBetween(expected, current) {
|
|
4334
4924
|
const changed = [];
|
|
4335
4925
|
const deleted = [];
|
|
4926
|
+
const added = [];
|
|
4336
4927
|
for (const [path, e] of Object.entries(expected)) {
|
|
4337
4928
|
const c = current[path];
|
|
4338
4929
|
if (!c) deleted.push(path);
|
|
4339
4930
|
else if (c.hash !== e.hash || c.mode !== e.mode) changed.push(path);
|
|
4340
4931
|
}
|
|
4341
|
-
|
|
4932
|
+
for (const path of Object.keys(current)) {
|
|
4933
|
+
if (!expected[path]) added.push(path);
|
|
4934
|
+
}
|
|
4935
|
+
return { changed, deleted, added };
|
|
4342
4936
|
}
|
|
4343
4937
|
function loadTree(p) {
|
|
4344
4938
|
if (!p || !existsSync6(p)) return null;
|
|
@@ -4352,7 +4946,16 @@ function saveTree(p, tree) {
|
|
|
4352
4946
|
if (!p) return;
|
|
4353
4947
|
try {
|
|
4354
4948
|
mkdirSync3(join7(p, ".."), { recursive: true });
|
|
4355
|
-
|
|
4949
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
4950
|
+
writeFileSync3(tmp, JSON.stringify(tree));
|
|
4951
|
+
renameSync(tmp, p);
|
|
4952
|
+
} catch {
|
|
4953
|
+
}
|
|
4954
|
+
}
|
|
4955
|
+
function dropTree(p) {
|
|
4956
|
+
if (!p) return;
|
|
4957
|
+
try {
|
|
4958
|
+
rmSync2(p, { force: true });
|
|
4356
4959
|
} catch {
|
|
4357
4960
|
}
|
|
4358
4961
|
}
|
|
@@ -4368,6 +4971,9 @@ function loadTurnTree(cwd, sessionId) {
|
|
|
4368
4971
|
function saveTurnTree(cwd, sessionId, tree) {
|
|
4369
4972
|
saveTree(turnTreePath(cwd, sessionId), tree);
|
|
4370
4973
|
}
|
|
4974
|
+
function dropTurnTree(cwd, sessionId) {
|
|
4975
|
+
dropTree(turnTreePath(cwd, sessionId));
|
|
4976
|
+
}
|
|
4371
4977
|
|
|
4372
4978
|
// src/cli/watch.ts
|
|
4373
4979
|
import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync8, statSync as statSync2, watch } from "node:fs";
|
|
@@ -4612,17 +5218,26 @@ function turnView(cwd, sessionId, policy, base) {
|
|
|
4612
5218
|
if (c.oldPath) seen.add(c.oldPath);
|
|
4613
5219
|
}
|
|
4614
5220
|
const turnTree = loadTurnTree(cwd, sessionId);
|
|
4615
|
-
const
|
|
4616
|
-
|
|
4617
|
-
);
|
|
5221
|
+
const ptree = loadPtree(cwd, sessionId);
|
|
5222
|
+
const keep = (rel) => isProtected(rel, policy);
|
|
5223
|
+
const untouched = (rel, disk) => disk != null && turnTree?.[rel]?.hash === disk && ptree?.[rel]?.hash === disk;
|
|
5224
|
+
const untracked = untrackedAdds({ cwd }, keep, seen).filter((c) => c.kind !== "file" || !untouched(c.path, hashOnDisk(cwd, c.path)));
|
|
4618
5225
|
for (const c of fileChanges(untracked)) seen.add(c.path);
|
|
5226
|
+
const ignored = ignoredAdds({ cwd }, keep, seen).filter((c) => {
|
|
5227
|
+
if (c.kind !== "file") return false;
|
|
5228
|
+
const disk = hashOnDisk(cwd, c.path);
|
|
5229
|
+
if (untouched(c.path, disk)) return false;
|
|
5230
|
+
const sanctioned = ptree?.[c.path];
|
|
5231
|
+
return !(sanctioned && disk != null && sanctioned.hash !== disk);
|
|
5232
|
+
});
|
|
5233
|
+
for (const c of fileChanges(ignored)) seen.add(c.path);
|
|
4619
5234
|
const trusted = base ?? "HEAD";
|
|
4620
5235
|
const hidden = [];
|
|
4621
5236
|
for (const rel of hiddenTrackedPaths({ cwd })) {
|
|
4622
5237
|
if (seen.has(rel) || !isProtected(rel, policy)) continue;
|
|
4623
5238
|
hidden.push(...synthFileChange(rel, fileAt(trusted, rel, { cwd }), fileOnDisk(rel, { cwd })));
|
|
4624
5239
|
}
|
|
4625
|
-
return [...tracked, ...untracked, ...hidden];
|
|
5240
|
+
return [...tracked, ...untracked, ...ignored, ...hidden];
|
|
4626
5241
|
}
|
|
4627
5242
|
function uncoveredDrift(cwd, policy, expected, current, covered, base, turnTree) {
|
|
4628
5243
|
const d = driftBetween(expected, current);
|
|
@@ -4640,7 +5255,7 @@ function uncoveredDrift(cwd, policy, expected, current, covered, base, turnTree)
|
|
|
4640
5255
|
const after = fileOnDisk(rel, { cwd });
|
|
4641
5256
|
const trustedBlob = fileAt(trusted, rel, { cwd });
|
|
4642
5257
|
const atGitState = after != null && trustedBlob != null && contentHash(after) === contentHash(trustedBlob);
|
|
4643
|
-
const atTurnStart = current[rel] != null && turnTree?.[rel]?.hash === current[rel].hash;
|
|
5258
|
+
const atTurnStart = current[rel] != null && expected[rel]?.mtimeMs === -1 && turnTree?.[rel]?.hash === current[rel].hash;
|
|
4644
5259
|
if ((atGitState || atTurnStart) && current[rel] && expected[rel] && current[rel].mode === expected[rel].mode) {
|
|
4645
5260
|
continue;
|
|
4646
5261
|
}
|
|
@@ -4683,6 +5298,23 @@ function uncoveredDrift(cwd, policy, expected, current, covered, base, turnTree)
|
|
|
4683
5298
|
});
|
|
4684
5299
|
}
|
|
4685
5300
|
}
|
|
5301
|
+
for (const rel of d.added) {
|
|
5302
|
+
if (seen.has(rel)) continue;
|
|
5303
|
+
const after = fileOnDisk(rel, { cwd });
|
|
5304
|
+
if (after == null) {
|
|
5305
|
+
blocks.push({
|
|
5306
|
+
rule: "hidden-drift",
|
|
5307
|
+
severity: "block",
|
|
5308
|
+
file: rel,
|
|
5309
|
+
message: `Protected file ${rel} appeared outside git's view and cannot be read to judge it.`,
|
|
5310
|
+
evidence: rel,
|
|
5311
|
+
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.",
|
|
5312
|
+
signoff: { required: true, command: `tamperward allow hidden-drift --file ${rel} --reason "..."` }
|
|
5313
|
+
});
|
|
5314
|
+
continue;
|
|
5315
|
+
}
|
|
5316
|
+
changes.push(...synthFileChange(rel, null, after));
|
|
5317
|
+
}
|
|
4686
5318
|
return { changes, blocks };
|
|
4687
5319
|
}
|
|
4688
5320
|
function effectDriftBlocks(cwd, sessionId, policy) {
|
|
@@ -4691,17 +5323,18 @@ function effectDriftBlocks(cwd, sessionId, policy) {
|
|
|
4691
5323
|
const current = snapshotProtected(cwd, policy, expected ?? void 0);
|
|
4692
5324
|
if (!expected) {
|
|
4693
5325
|
savePtree(cwd, sessionId, current);
|
|
4694
|
-
if (
|
|
5326
|
+
if (loadTurnTree(cwd, sessionId)) dropTurnTree(cwd, sessionId);
|
|
5327
|
+
else saveTurnTree(cwd, sessionId, current);
|
|
4695
5328
|
return null;
|
|
4696
5329
|
}
|
|
4697
5330
|
const d = driftBetween(expected, current);
|
|
4698
|
-
if (d.changed.length === 0 && d.deleted.length === 0) {
|
|
5331
|
+
if (d.changed.length === 0 && d.deleted.length === 0 && d.added.length === 0) {
|
|
4699
5332
|
savePtree(cwd, sessionId, current);
|
|
4700
5333
|
return null;
|
|
4701
5334
|
}
|
|
4702
5335
|
const base = turnBaseline(cwd, sessionId);
|
|
4703
5336
|
const all = turnView(cwd, sessionId, policy, base);
|
|
4704
|
-
const drifted = /* @__PURE__ */ new Set([...d.changed, ...d.deleted]);
|
|
5337
|
+
const drifted = /* @__PURE__ */ new Set([...d.changed, ...d.deleted, ...d.added]);
|
|
4705
5338
|
const scoped = all.filter(
|
|
4706
5339
|
(c) => c.kind === "file" && (drifted.has(c.path) || c.oldPath != null && drifted.has(c.oldPath))
|
|
4707
5340
|
);
|
|
@@ -4916,7 +5549,7 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
|
|
|
4916
5549
|
import { execFileSync as execFileSync5 } from "node:child_process";
|
|
4917
5550
|
import { createHash as createHash5 } from "node:crypto";
|
|
4918
5551
|
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync11, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
|
|
4919
|
-
import { basename, dirname as dirname3, isAbsolute as
|
|
5552
|
+
import { basename, dirname as dirname3, isAbsolute as isAbsolute5, join as join10, relative as relative3, resolve as resolve2 } from "node:path";
|
|
4920
5553
|
import { fileURLToPath } from "node:url";
|
|
4921
5554
|
var errText2 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
|
|
4922
5555
|
var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
@@ -5234,8 +5867,8 @@ function hooksDir(cwd) {
|
|
|
5234
5867
|
return existsSync8(join10(cwd, ".git")) ? join10(cwd, ".git", "hooks") : null;
|
|
5235
5868
|
}
|
|
5236
5869
|
function display(cwd, path) {
|
|
5237
|
-
const rel =
|
|
5238
|
-
return rel && !rel.startsWith("..") && !
|
|
5870
|
+
const rel = relative3(cwd, path);
|
|
5871
|
+
return rel && !rel.startsWith("..") && !isAbsolute5(rel) ? rel : path;
|
|
5239
5872
|
}
|
|
5240
5873
|
function unconditionalExitAt(lines) {
|
|
5241
5874
|
let depth = 0;
|
|
@@ -5521,7 +6154,7 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
|
|
|
5521
6154
|
// src/cli/verify.ts
|
|
5522
6155
|
import { execFileSync as execFileSync8, spawnSync } from "node:child_process";
|
|
5523
6156
|
import { createHash as createHash8 } from "node:crypto";
|
|
5524
|
-
import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync14, readlinkSync as readlinkSync2, rmSync as
|
|
6157
|
+
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";
|
|
5525
6158
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
5526
6159
|
import { dirname as dirname4, join as join13, resolve as resolve3 } from "node:path";
|
|
5527
6160
|
|
|
@@ -5529,7 +6162,7 @@ import { dirname as dirname4, join as join13, resolve as resolve3 } from "node:p
|
|
|
5529
6162
|
import { execFileSync as execFileSync6 } from "node:child_process";
|
|
5530
6163
|
import { createHash as createHash6 } from "node:crypto";
|
|
5531
6164
|
import { existsSync as existsSync9, readFileSync as readFileSync12 } from "node:fs";
|
|
5532
|
-
import { isAbsolute as
|
|
6165
|
+
import { isAbsolute as isAbsolute6, join as join11 } from "node:path";
|
|
5533
6166
|
function trustedGitEnv() {
|
|
5534
6167
|
return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
|
|
5535
6168
|
}
|
|
@@ -5542,7 +6175,7 @@ function gitDirOf(cwd) {
|
|
|
5542
6175
|
env: trustedGitEnv()
|
|
5543
6176
|
}).trim();
|
|
5544
6177
|
if (!d) return null;
|
|
5545
|
-
return
|
|
6178
|
+
return isAbsolute6(d) ? d : join11(cwd, d);
|
|
5546
6179
|
} catch {
|
|
5547
6180
|
return null;
|
|
5548
6181
|
}
|
|
@@ -5756,7 +6389,7 @@ function materialize(cwd, dest) {
|
|
|
5756
6389
|
}
|
|
5757
6390
|
function dropSymlink(p) {
|
|
5758
6391
|
try {
|
|
5759
|
-
if (lstatSync(p).isSymbolicLink())
|
|
6392
|
+
if (lstatSync(p).isSymbolicLink()) rmSync3(p, { force: true });
|
|
5760
6393
|
} catch {
|
|
5761
6394
|
}
|
|
5762
6395
|
}
|
|
@@ -5767,9 +6400,9 @@ function verifierInputs(cmd, atBase, policy) {
|
|
|
5767
6400
|
const tok = raw.replace(/^['"]|['"]$/g, "").replace(/^\.\//, "");
|
|
5768
6401
|
if (tok && baseFiles.has(tok)) picked.add(tok);
|
|
5769
6402
|
}
|
|
5770
|
-
const
|
|
5771
|
-
if (
|
|
5772
|
-
for (const rel of atBase) if (matchesAny(rel,
|
|
6403
|
+
const globs4 = policy.verify?.inputs;
|
|
6404
|
+
if (globs4 && globs4.length) {
|
|
6405
|
+
for (const rel of atBase) if (matchesAny(rel, globs4)) picked.add(rel);
|
|
5773
6406
|
}
|
|
5774
6407
|
return picked;
|
|
5775
6408
|
}
|
|
@@ -5814,7 +6447,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
|
|
|
5814
6447
|
const content = execFileSync8("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
5815
6448
|
const out2 = join13(dest, e.path);
|
|
5816
6449
|
mkdirSync6(dirname4(out2), { recursive: true });
|
|
5817
|
-
|
|
6450
|
+
rmSync3(out2, { force: true });
|
|
5818
6451
|
if (e.mode === "120000") {
|
|
5819
6452
|
symlinkSync(content.toString("utf8"), out2);
|
|
5820
6453
|
} else {
|
|
@@ -5827,7 +6460,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
|
|
|
5827
6460
|
const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
|
|
5828
6461
|
for (const rel of inCopy) {
|
|
5829
6462
|
if (!isOverlay(rel) || baseProtected.has(rel)) continue;
|
|
5830
|
-
|
|
6463
|
+
rmSync3(join13(dest, rel), { force: true });
|
|
5831
6464
|
removed++;
|
|
5832
6465
|
}
|
|
5833
6466
|
return { restored, removed };
|
|
@@ -5902,7 +6535,7 @@ function runSuite(dir, cmd, budgetSecs) {
|
|
|
5902
6535
|
if (r.exit === null || r.exit === void 0) return { exit: null, secs };
|
|
5903
6536
|
return { exit: r.exit, secs };
|
|
5904
6537
|
} finally {
|
|
5905
|
-
|
|
6538
|
+
rmSync3(outDir, { recursive: true, force: true });
|
|
5906
6539
|
}
|
|
5907
6540
|
}
|
|
5908
6541
|
function runVerify(opts) {
|
|
@@ -5949,7 +6582,7 @@ function runVerify(opts) {
|
|
|
5949
6582
|
const visDir = join13(visRoot, "t");
|
|
5950
6583
|
const cleanup = (dirs) => {
|
|
5951
6584
|
if (opts.keep) return;
|
|
5952
|
-
for (const d of dirs)
|
|
6585
|
+
for (const d of dirs) rmSync3(d, { recursive: true, force: true });
|
|
5953
6586
|
};
|
|
5954
6587
|
const treeBefore = treeFingerprint(cwd);
|
|
5955
6588
|
const depsBefore = depsFingerprint(cwd);
|