tamperward 2.5.1 → 2.7.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 +2057 -490
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -415,7 +415,41 @@ function makeFinding(rule, policy, input) {
|
|
|
415
415
|
|
|
416
416
|
// src/detectors/command.ts
|
|
417
417
|
function segments(raw) {
|
|
418
|
-
|
|
418
|
+
const out2 = [];
|
|
419
|
+
let single = false;
|
|
420
|
+
let double = false;
|
|
421
|
+
let last = 0;
|
|
422
|
+
for (let i = 0; i < raw.length; i++) {
|
|
423
|
+
const ch = raw[i];
|
|
424
|
+
if (ch === "\\" && !single) {
|
|
425
|
+
i++;
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (ch === "'" && !double) {
|
|
429
|
+
single = !single;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (ch === '"' && !single) {
|
|
433
|
+
double = !double;
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
if (single || double) continue;
|
|
437
|
+
const prev = raw[i - 1];
|
|
438
|
+
const next = raw[i + 1];
|
|
439
|
+
let len = 0;
|
|
440
|
+
if (ch === "&" && next === "&") len = 2;
|
|
441
|
+
else if (ch === "|" && next === "|") len = 2;
|
|
442
|
+
else if (ch === "|" && next === "&") len = 2;
|
|
443
|
+
else if (ch === ";" || ch === "\n") len = 1;
|
|
444
|
+
else if (ch === "|" && prev !== ">") len = 1;
|
|
445
|
+
else if (ch === "&" && prev !== ">" && next !== ">") len = 1;
|
|
446
|
+
if (!len) continue;
|
|
447
|
+
out2.push(raw.slice(last, i));
|
|
448
|
+
i += len - 1;
|
|
449
|
+
last = i + 1;
|
|
450
|
+
}
|
|
451
|
+
out2.push(raw.slice(last));
|
|
452
|
+
return out2.map((s) => s.trim()).filter(Boolean);
|
|
419
453
|
}
|
|
420
454
|
function tokens(seg) {
|
|
421
455
|
return seg.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
|
|
@@ -586,12 +620,12 @@ function matcher(glob) {
|
|
|
586
620
|
}
|
|
587
621
|
return m;
|
|
588
622
|
}
|
|
589
|
-
function matchesAny(path,
|
|
590
|
-
return !!
|
|
623
|
+
function matchesAny(path, globs4) {
|
|
624
|
+
return !!globs4 && globs4.some((g) => matcher(g)(path));
|
|
591
625
|
}
|
|
592
626
|
function protectedCategory(path, policy) {
|
|
593
|
-
for (const [cat,
|
|
594
|
-
if (matchesAny(path,
|
|
627
|
+
for (const [cat, globs4] of Object.entries(policy.protected ?? {})) {
|
|
628
|
+
if (matchesAny(path, globs4)) return cat;
|
|
595
629
|
}
|
|
596
630
|
return null;
|
|
597
631
|
}
|
|
@@ -607,9 +641,9 @@ function isIgnored(path, policy) {
|
|
|
607
641
|
}
|
|
608
642
|
function mergeProtected(base, user) {
|
|
609
643
|
const out2 = { ...base };
|
|
610
|
-
for (const [cat,
|
|
644
|
+
for (const [cat, globs4] of Object.entries(user ?? {})) {
|
|
611
645
|
const have = new Set(out2[cat] ?? []);
|
|
612
|
-
out2[cat] = [...out2[cat] ?? [], ...(
|
|
646
|
+
out2[cat] = [...out2[cat] ?? [], ...(globs4 ?? []).filter((g) => !have.has(g))];
|
|
613
647
|
}
|
|
614
648
|
return out2;
|
|
615
649
|
}
|
|
@@ -654,6 +688,13 @@ function defaultPolicy(version = 1) {
|
|
|
654
688
|
config: [
|
|
655
689
|
"**/jest.config.*",
|
|
656
690
|
"**/vitest.config.*",
|
|
691
|
+
// A `vitest.workspace.*` file REPLACES the root config's selection with the
|
|
692
|
+
// union of its projects, and a `vite.config.*` carrying a `test:` key is the
|
|
693
|
+
// vitest config when no vitest.config exists — both are where a spec can be
|
|
694
|
+
// dropped from the suite without a line of it changing. ADDITIVE, like every
|
|
695
|
+
// baseline widening (the policy-diff reads it as a strengthening).
|
|
696
|
+
"**/vitest.workspace.*",
|
|
697
|
+
"**/vite.config.*",
|
|
657
698
|
"**/tsconfig*.json",
|
|
658
699
|
"**/.eslintrc*",
|
|
659
700
|
"**/eslint.config.*",
|
|
@@ -686,6 +727,9 @@ function defaultPolicy(version = 1) {
|
|
|
686
727
|
hooks: [
|
|
687
728
|
".husky/**",
|
|
688
729
|
"**/lefthook.*",
|
|
730
|
+
// lefthook merges `lefthook-local.yml` OVER `lefthook.yml`: a `skip: true`
|
|
731
|
+
// there switches the gate off without touching the file init pointed at.
|
|
732
|
+
"**/lefthook-local.*",
|
|
689
733
|
".tamperward.yml",
|
|
690
734
|
"**/.tamperward.yml",
|
|
691
735
|
".claude/settings.json",
|
|
@@ -1141,6 +1185,25 @@ function trackedFiles(ctx) {
|
|
|
1141
1185
|
cache2.set(ctx.cwd, files);
|
|
1142
1186
|
return files;
|
|
1143
1187
|
}
|
|
1188
|
+
var branchCache = /* @__PURE__ */ new Map();
|
|
1189
|
+
function defaultBranch(ctx) {
|
|
1190
|
+
if (!ctx?.cwd) return null;
|
|
1191
|
+
const hit = branchCache.get(ctx.cwd);
|
|
1192
|
+
if (hit !== void 0) return hit;
|
|
1193
|
+
let name = null;
|
|
1194
|
+
try {
|
|
1195
|
+
const ref = execFileSync2("git", ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"], {
|
|
1196
|
+
cwd: ctx.cwd,
|
|
1197
|
+
encoding: "utf8",
|
|
1198
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1199
|
+
}).trim();
|
|
1200
|
+
name = ref.replace(/^origin\//, "") || null;
|
|
1201
|
+
} catch {
|
|
1202
|
+
name = null;
|
|
1203
|
+
}
|
|
1204
|
+
branchCache.set(ctx.cwd, name);
|
|
1205
|
+
return name;
|
|
1206
|
+
}
|
|
1144
1207
|
var dirOf = (t) => t.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
1145
1208
|
function containsProtected(token, policy, category, ctx) {
|
|
1146
1209
|
const dir = dirOf(token);
|
|
@@ -1199,6 +1262,13 @@ function underKey(node, key2) {
|
|
|
1199
1262
|
}
|
|
1200
1263
|
return false;
|
|
1201
1264
|
}
|
|
1265
|
+
function ownerKey(node) {
|
|
1266
|
+
const obj = node.parent;
|
|
1267
|
+
if (!obj || !ts2.isObjectLiteralExpression(obj)) return null;
|
|
1268
|
+
let q = obj.parent;
|
|
1269
|
+
while (q && (ts2.isParenthesizedExpression(q) || ts2.isAsExpression(q) || q.kind === ts2.SyntaxKind.SatisfiesExpression)) q = q.parent;
|
|
1270
|
+
return q && ts2.isPropertyAssignment(q) ? keyName(q.name) : null;
|
|
1271
|
+
}
|
|
1202
1272
|
function asExpression(src) {
|
|
1203
1273
|
const t = src.trim();
|
|
1204
1274
|
return t.startsWith("{") ? `(${t})` : src;
|
|
@@ -1276,18 +1346,20 @@ function literals(e) {
|
|
|
1276
1346
|
return { items, opaque };
|
|
1277
1347
|
}
|
|
1278
1348
|
function parseLists(src) {
|
|
1279
|
-
const res = { collectFrom: null, ignore: [], opaqueCollect: false, opaqueIgnore: false };
|
|
1349
|
+
const res = { collectFrom: null, collectKey: "collectCoverageFrom", ignore: [], opaqueCollect: false, opaqueIgnore: false };
|
|
1280
1350
|
try {
|
|
1281
1351
|
const sf = sourceOf(src);
|
|
1282
1352
|
const resolve5 = resolverFor(sf);
|
|
1283
1353
|
const visit = (node) => {
|
|
1284
1354
|
if (ts2.isPropertyAssignment(node)) {
|
|
1285
1355
|
const k = keyName(node.name);
|
|
1286
|
-
|
|
1356
|
+
const vitestCoverage = ownerKey(node) === "coverage";
|
|
1357
|
+
if (k === "collectCoverageFrom" || k === "include" && vitestCoverage) {
|
|
1287
1358
|
const { items, opaque } = literals(resolve5(node.initializer));
|
|
1288
1359
|
res.collectFrom = items;
|
|
1360
|
+
res.collectKey = k === "include" ? "coverage.include" : k;
|
|
1289
1361
|
if (opaque) res.opaqueCollect = true;
|
|
1290
|
-
} else if (k === "coveragePathIgnorePatterns" || k === "exclude" &&
|
|
1362
|
+
} else if (k === "coveragePathIgnorePatterns" || k === "exclude" && vitestCoverage) {
|
|
1291
1363
|
const { items, opaque } = literals(resolve5(node.initializer));
|
|
1292
1364
|
res.ignore.push(...items);
|
|
1293
1365
|
if (opaque) res.opaqueIgnore = true;
|
|
@@ -1300,6 +1372,43 @@ function parseLists(src) {
|
|
|
1300
1372
|
}
|
|
1301
1373
|
return res;
|
|
1302
1374
|
}
|
|
1375
|
+
function parseSwitches(src) {
|
|
1376
|
+
const res = {};
|
|
1377
|
+
try {
|
|
1378
|
+
const sf = sourceOf(src);
|
|
1379
|
+
const bool = (e) => e.kind === ts2.SyntaxKind.TrueKeyword ? true : e.kind === ts2.SyntaxKind.FalseKeyword ? false : void 0;
|
|
1380
|
+
const visit = (node) => {
|
|
1381
|
+
if (ts2.isPropertyAssignment(node)) {
|
|
1382
|
+
const k = keyName(node.name);
|
|
1383
|
+
const owner = ownerKey(node);
|
|
1384
|
+
if (k === "enabled" && owner === "coverage") res.enabled = bool(node.initializer);
|
|
1385
|
+
else if (k === "perFile" && owner === "thresholds" && underKey(node, "coverage")) res.perFile = bool(node.initializer);
|
|
1386
|
+
}
|
|
1387
|
+
ts2.forEachChild(node, visit);
|
|
1388
|
+
};
|
|
1389
|
+
visit(sf);
|
|
1390
|
+
} catch {
|
|
1391
|
+
}
|
|
1392
|
+
return res;
|
|
1393
|
+
}
|
|
1394
|
+
function sectionText(src, key2) {
|
|
1395
|
+
try {
|
|
1396
|
+
const sf = sourceOf(src);
|
|
1397
|
+
let out2 = "";
|
|
1398
|
+
const visit = (node) => {
|
|
1399
|
+
if (out2) return;
|
|
1400
|
+
if (ts2.isPropertyAssignment(node) && keyName(node.name) === key2 && ts2.isObjectLiteralExpression(node.initializer)) {
|
|
1401
|
+
out2 = node.initializer.getText(sf);
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
ts2.forEachChild(node, visit);
|
|
1405
|
+
};
|
|
1406
|
+
visit(sf);
|
|
1407
|
+
return out2;
|
|
1408
|
+
} catch {
|
|
1409
|
+
return "";
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1303
1412
|
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;
|
|
1304
1413
|
function compareMetrics(scope, before, after, out2) {
|
|
1305
1414
|
if (!before) return;
|
|
@@ -1325,10 +1434,10 @@ function overrideTargetExists(key2, configPath, ctx) {
|
|
|
1325
1434
|
}
|
|
1326
1435
|
return files.some((f) => f.startsWith(root) && m(f.slice(root.length)));
|
|
1327
1436
|
}
|
|
1328
|
-
function weakenings(before, after, configPath, ctx) {
|
|
1437
|
+
function weakenings(before, after, configPath, movedElsewhere, ctx) {
|
|
1329
1438
|
const out2 = [];
|
|
1330
1439
|
if (before.present && !after.present) {
|
|
1331
|
-
out2.push("the coverage threshold gate was removed");
|
|
1440
|
+
if (!movedElsewhere) out2.push("the coverage threshold gate was removed");
|
|
1332
1441
|
return out2;
|
|
1333
1442
|
}
|
|
1334
1443
|
if (after.opaque && !after.global && after.paths.size === 0) return out2;
|
|
@@ -1355,16 +1464,45 @@ function weakenings(before, after, configPath, ctx) {
|
|
|
1355
1464
|
}
|
|
1356
1465
|
return out2;
|
|
1357
1466
|
}
|
|
1358
|
-
|
|
1467
|
+
var globs = (patterns) => {
|
|
1468
|
+
const ms = patterns.map((g) => {
|
|
1469
|
+
try {
|
|
1470
|
+
return picomatch2(norm(g), { dot: true });
|
|
1471
|
+
} catch {
|
|
1472
|
+
return () => false;
|
|
1473
|
+
}
|
|
1474
|
+
});
|
|
1475
|
+
return (p) => ms.some((m) => m(p));
|
|
1476
|
+
};
|
|
1477
|
+
var globList = (patterns) => {
|
|
1478
|
+
const pos = globs(patterns.filter((g) => !g.startsWith("!")));
|
|
1479
|
+
const neg = globs(patterns.filter((g) => g.startsWith("!")).map((g) => g.slice(1)));
|
|
1480
|
+
return (p) => pos(p) && !neg(p);
|
|
1481
|
+
};
|
|
1482
|
+
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"];
|
|
1483
|
+
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$/;
|
|
1484
|
+
function sourceSamples(configPath, policy, ctx) {
|
|
1485
|
+
const files = trackedFiles(ctx);
|
|
1486
|
+
if (!files) return CANONICAL_SOURCES;
|
|
1487
|
+
const root = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/") + 1) : "";
|
|
1488
|
+
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));
|
|
1489
|
+
return own.length ? own : CANONICAL_SOURCES;
|
|
1490
|
+
}
|
|
1491
|
+
function narrowings(before, after, sources) {
|
|
1359
1492
|
const out2 = [];
|
|
1360
|
-
if (before.collectFrom && after.collectFrom && !after.opaqueCollect) {
|
|
1361
|
-
const
|
|
1362
|
-
|
|
1363
|
-
|
|
1493
|
+
if (before.collectFrom && after.collectFrom && !after.opaqueCollect && !before.opaqueCollect) {
|
|
1494
|
+
const pb = globList(before.collectFrom);
|
|
1495
|
+
const pa = globList(after.collectFrom);
|
|
1496
|
+
const dropped = sources.filter((s) => pb(s) && !pa(s));
|
|
1497
|
+
if (dropped.length) {
|
|
1498
|
+
const shown = dropped.slice(0, 3).map((d) => JSON.stringify(d)).join(", ");
|
|
1499
|
+
out2.push(`${after.collectKey} no longer measures ${shown}${dropped.length > 3 ? ` (+${dropped.length - 3} more)` : ""}`);
|
|
1364
1500
|
}
|
|
1365
1501
|
const had2 = new Set(before.collectFrom);
|
|
1366
1502
|
for (const g of after.collectFrom) {
|
|
1367
|
-
if (g.startsWith("!") && !had2.has(g) && !BENIGN_EXEMPTION.test(g)
|
|
1503
|
+
if (g.startsWith("!") && !had2.has(g) && !BENIGN_EXEMPTION.test(g) && !dropped.some((d) => globs([g.slice(1)])(d))) {
|
|
1504
|
+
out2.push(`${after.collectKey} now exempts ${JSON.stringify(g)}`);
|
|
1505
|
+
}
|
|
1368
1506
|
}
|
|
1369
1507
|
}
|
|
1370
1508
|
const had = new Set(before.ignore);
|
|
@@ -1373,75 +1511,153 @@ function narrowings(before, after) {
|
|
|
1373
1511
|
}
|
|
1374
1512
|
return out2;
|
|
1375
1513
|
}
|
|
1514
|
+
function switchWeakenings(before, after) {
|
|
1515
|
+
const out2 = [];
|
|
1516
|
+
if (before.enabled === true && after.enabled === false) out2.push("coverage.enabled switched off \u2014 the thresholds are never checked");
|
|
1517
|
+
if (before.perFile === true && after.perFile !== true) out2.push("thresholds.perFile removed \u2014 a single uncovered file now hides behind the aggregate");
|
|
1518
|
+
return out2;
|
|
1519
|
+
}
|
|
1520
|
+
var NYC_KEYS = METRICS.map((m) => ({
|
|
1521
|
+
key: new RegExp(`(?:^|[\\s{,])"?${m}"?\\s*[:=]\\s*"?(\\d+(?:\\.\\d+)?)`, "g"),
|
|
1522
|
+
label: m,
|
|
1523
|
+
weakens: "lower"
|
|
1524
|
+
}));
|
|
1525
|
+
var COVERAGE_PY = /(?:^|\/)(?:\.coveragerc|pyproject\.toml)$/;
|
|
1526
|
+
var CODECOV = /(?:^|\/)\.?codecov\.yml$/;
|
|
1376
1527
|
var SIMPLE_FILES = [
|
|
1377
1528
|
{
|
|
1378
1529
|
// coverage.py: `fail_under = 90` under [report] (.coveragerc, setup.cfg-style) or
|
|
1379
1530
|
// [tool.coverage.report] (pyproject.toml)
|
|
1380
|
-
file:
|
|
1531
|
+
file: COVERAGE_PY,
|
|
1381
1532
|
keys: [{ key: /^\s*fail[_-]under\s*=\s*"?(\d+(?:\.\d+)?)/, label: "fail_under", weakens: "lower" }],
|
|
1382
1533
|
removable: true
|
|
1383
1534
|
},
|
|
1384
1535
|
{
|
|
1385
1536
|
// nyc: .nycrc / .nycrc.json / .nycrc.yml / nyc.config.js — metric thresholds
|
|
1386
1537
|
file: /(?:^|\/)(?:\.nycrc(?:\.\w+)?|nyc\.config\.\w+)$/,
|
|
1387
|
-
keys:
|
|
1388
|
-
key: new RegExp(`(?:^|[\\s{,])"?${m}"?\\s*[:=]\\s*"?(\\d+(?:\\.\\d+)?)`, "g"),
|
|
1389
|
-
label: m,
|
|
1390
|
-
weakens: "lower"
|
|
1391
|
-
})),
|
|
1538
|
+
keys: NYC_KEYS,
|
|
1392
1539
|
removable: false
|
|
1393
1540
|
},
|
|
1394
1541
|
{
|
|
1395
|
-
// codecov: `target: 90%` is the floor (lowering weakens
|
|
1542
|
+
// codecov: `target: 90%` is the floor (lowering weakens; `auto` is a floor that
|
|
1543
|
+
// follows the base commit, lower than any fixed number); `threshold: 2%` is the
|
|
1396
1544
|
// permitted drop (raising weakens)
|
|
1397
|
-
file:
|
|
1545
|
+
file: CODECOV,
|
|
1398
1546
|
keys: [
|
|
1399
|
-
{ key: /^\s*target:\s*"?(\d+(?:\.\d+)
|
|
1547
|
+
{ key: /^\s*target:\s*"?(\d+(?:\.\d+)?|auto)\b%?/, label: "target", weakens: "lower" },
|
|
1400
1548
|
{ key: /^\s*threshold:\s*"?(\d+(?:\.\d+)?)%?/, label: "threshold", weakens: "raise" }
|
|
1401
1549
|
],
|
|
1402
|
-
removable: false
|
|
1550
|
+
removable: false,
|
|
1551
|
+
scoped: true
|
|
1403
1552
|
}
|
|
1404
1553
|
];
|
|
1405
|
-
|
|
1554
|
+
var lessThan = (a, b) => a === "auto" ? b !== "auto" : b === "auto" ? false : a < b;
|
|
1555
|
+
var numOf = (s) => s === "auto" ? "auto" : Number(s);
|
|
1556
|
+
function numbersOf(src, key2, scoped = false) {
|
|
1406
1557
|
const out2 = [];
|
|
1558
|
+
const stack = [];
|
|
1407
1559
|
for (const line of src.split("\n")) {
|
|
1560
|
+
let scope = "";
|
|
1561
|
+
if (scoped) {
|
|
1562
|
+
const m = line.match(/^(\s*)([\w-]+):/);
|
|
1563
|
+
if (m) {
|
|
1564
|
+
const ind = m[1].length;
|
|
1565
|
+
while (stack.length && stack[stack.length - 1].ind >= ind) stack.pop();
|
|
1566
|
+
stack.push({ ind, key: m[2] });
|
|
1567
|
+
}
|
|
1568
|
+
scope = [...stack.slice(0, -1)].reverse().find((s) => /^(?:project|patch|changes)$/.test(s.key))?.key ?? "";
|
|
1569
|
+
}
|
|
1408
1570
|
if (key2.global) {
|
|
1409
|
-
for (const m of line.matchAll(key2)) out2.push(
|
|
1571
|
+
for (const m of line.matchAll(key2)) out2.push({ v: numOf(m[1]), scope });
|
|
1410
1572
|
} else {
|
|
1411
1573
|
const m = line.match(key2);
|
|
1412
|
-
if (m) out2.push(
|
|
1574
|
+
if (m) out2.push({ v: numOf(m[1]), scope });
|
|
1413
1575
|
}
|
|
1414
1576
|
}
|
|
1415
1577
|
return out2;
|
|
1416
1578
|
}
|
|
1417
|
-
function
|
|
1579
|
+
function simpleWeakeningsOf(spec, before, after) {
|
|
1418
1580
|
const out2 = [];
|
|
1419
|
-
const spec = SIMPLE_FILES.find((s) => s.file.test(path));
|
|
1420
|
-
if (!spec) return out2;
|
|
1421
1581
|
for (const k of spec.keys) {
|
|
1422
|
-
const
|
|
1423
|
-
const
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
if (
|
|
1433
|
-
|
|
1434
|
-
|
|
1582
|
+
const bAll = numbersOf(before, k.key, spec.scoped);
|
|
1583
|
+
const aAll = numbersOf(after, k.key, spec.scoped);
|
|
1584
|
+
const scopes = [...new Set(bAll.map((n) => n.scope))];
|
|
1585
|
+
for (const scope of scopes) {
|
|
1586
|
+
const label = scope ? `${scope} ${k.label}` : k.label;
|
|
1587
|
+
const desc = (x, y) => x === "auto" ? -1 : y === "auto" ? 1 : lessThan(x, y) ? 1 : lessThan(y, x) ? -1 : 0;
|
|
1588
|
+
const b = bAll.filter((n) => n.scope === scope).map((n) => n.v).sort(desc);
|
|
1589
|
+
const a = aAll.filter((n) => n.scope === scope).map((n) => n.v).sort(desc);
|
|
1590
|
+
if (b.length === 0) continue;
|
|
1591
|
+
if (a.length === 0) {
|
|
1592
|
+
if (spec.removable) out2.push(`${label} removed (was ${b.join(", ")})`);
|
|
1593
|
+
continue;
|
|
1594
|
+
}
|
|
1595
|
+
if (k.weakens === "lower") {
|
|
1596
|
+
for (let i = 0; i < b.length; i++) {
|
|
1597
|
+
const av = a[Math.min(i, a.length - 1)];
|
|
1598
|
+
if (lessThan(av, b[i])) {
|
|
1599
|
+
out2.push(`${label} lowered ${b[i]} \u2192 ${av}`);
|
|
1600
|
+
break;
|
|
1601
|
+
}
|
|
1435
1602
|
}
|
|
1603
|
+
} else {
|
|
1604
|
+
const bMax = b[0];
|
|
1605
|
+
const aMax = a[0];
|
|
1606
|
+
if (lessThan(bMax, aMax)) out2.push(`${label} raised ${bMax} \u2192 ${aMax} (a wider permitted drop)`);
|
|
1436
1607
|
}
|
|
1437
|
-
} else {
|
|
1438
|
-
const bMax = b[0];
|
|
1439
|
-
const aMax = a[0];
|
|
1440
|
-
if (aMax > bMax) out2.push(`${k.label} raised ${bMax} \u2192 ${aMax} (a wider permitted drop)`);
|
|
1441
1608
|
}
|
|
1442
1609
|
}
|
|
1443
1610
|
return out2;
|
|
1444
1611
|
}
|
|
1612
|
+
function listEntries(src, key2) {
|
|
1613
|
+
const out2 = [];
|
|
1614
|
+
const lines = src.split("\n");
|
|
1615
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1616
|
+
const m = lines[i].match(key2);
|
|
1617
|
+
if (!m) continue;
|
|
1618
|
+
let rest = m[1] ?? "";
|
|
1619
|
+
let j = i + 1;
|
|
1620
|
+
if (rest.trim().startsWith("[") && !rest.includes("]")) {
|
|
1621
|
+
while (j < lines.length && !lines[j].includes("]")) rest += "," + lines[j++];
|
|
1622
|
+
if (j < lines.length) rest += "," + lines[j];
|
|
1623
|
+
} else if (rest.trim() === "") {
|
|
1624
|
+
while (j < lines.length && /^\s+(?:-\s+)?\S/.test(lines[j])) rest += "," + lines[j++].replace(/^\s*-\s+/, "");
|
|
1625
|
+
}
|
|
1626
|
+
for (const e of rest.replace(/[[\]]/g, "").split(",")) {
|
|
1627
|
+
const v = e.trim().replace(/^(['"])(.*)\1$/, "$2");
|
|
1628
|
+
if (v) out2.push(v);
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
return out2;
|
|
1632
|
+
}
|
|
1633
|
+
function simpleNarrowings(path, before, after) {
|
|
1634
|
+
const out2 = [];
|
|
1635
|
+
const spec = [
|
|
1636
|
+
[COVERAGE_PY, /^\s*omit\s*=\s*(.*)$/, "omit now exempts"],
|
|
1637
|
+
[CODECOV, /^ignore:\s*(.*)$/, "codecov now ignores"]
|
|
1638
|
+
];
|
|
1639
|
+
for (const [file, key2, what] of spec) {
|
|
1640
|
+
if (!file.test(path)) continue;
|
|
1641
|
+
const had = new Set(listEntries(before, key2));
|
|
1642
|
+
for (const e of listEntries(after, key2)) if (!had.has(e) && !BENIGN_EXEMPTION.test(e)) out2.push(`${what} ${JSON.stringify(e)}`);
|
|
1643
|
+
}
|
|
1644
|
+
if (CODECOV.test(path)) {
|
|
1645
|
+
const off2 = (s) => new Set(s.split("\n").map((l) => l.match(/^\s*(project|patch):\s*off\b/)?.[1]).filter((x) => !!x));
|
|
1646
|
+
const had = off2(before);
|
|
1647
|
+
for (const s of off2(after)) if (!had.has(s)) out2.push(`codecov status.${s} switched off`);
|
|
1648
|
+
}
|
|
1649
|
+
return out2;
|
|
1650
|
+
}
|
|
1651
|
+
function simpleWeakenings(path, before, after) {
|
|
1652
|
+
const spec = SIMPLE_FILES.find((s) => s.file.test(path));
|
|
1653
|
+
const out2 = spec ? simpleWeakeningsOf(spec, before, after) : [];
|
|
1654
|
+
if ((path.split("/").pop() ?? path) === "package.json") {
|
|
1655
|
+
const b = sectionText(before, "nyc");
|
|
1656
|
+
const a = sectionText(after, "nyc");
|
|
1657
|
+
if (b && a) out2.push(...simpleWeakeningsOf({ file: /./, keys: NYC_KEYS, removable: false }, b, a).map((r) => `nyc ${r}`));
|
|
1658
|
+
}
|
|
1659
|
+
return out2;
|
|
1660
|
+
}
|
|
1445
1661
|
var SWITCHES = [
|
|
1446
1662
|
{
|
|
1447
1663
|
re: /--passWithNoTests\b/,
|
|
@@ -1480,6 +1696,35 @@ var SWITCHES = [
|
|
|
1480
1696
|
}
|
|
1481
1697
|
];
|
|
1482
1698
|
var isJsConfig = (path) => /\.(?:[cm]?[jt]sx?|json)$/.test(path);
|
|
1699
|
+
function gateMovedElsewhere(c, changes, policy) {
|
|
1700
|
+
const before = c.before ?? "";
|
|
1701
|
+
const others = changes.filter((o) => o.kind === "file" && o !== c && o.after != null && isProtected(o.path, policy, "config"));
|
|
1702
|
+
if (isJsConfig(c.path)) {
|
|
1703
|
+
const was = parseThresholds(before);
|
|
1704
|
+
if (!was.present) return false;
|
|
1705
|
+
return others.some((o) => {
|
|
1706
|
+
if (!isJsConfig(o.path)) return false;
|
|
1707
|
+
const now = parseThresholds(o.after);
|
|
1708
|
+
if (!now.present) return false;
|
|
1709
|
+
if (now.opaque) return true;
|
|
1710
|
+
return METRICS.every((m) => {
|
|
1711
|
+
const bv = was.global?.values[m];
|
|
1712
|
+
const av = now.global?.values[m];
|
|
1713
|
+
return bv === void 0 || av !== void 0 && av >= bv;
|
|
1714
|
+
});
|
|
1715
|
+
});
|
|
1716
|
+
}
|
|
1717
|
+
const spec = SIMPLE_FILES.find((s) => s.file.test(c.path));
|
|
1718
|
+
if (!spec) return false;
|
|
1719
|
+
return spec.keys.every((k) => {
|
|
1720
|
+
const b = numbersOf(before, k.key, spec.scoped).map((n) => n.v);
|
|
1721
|
+
if (b.length === 0) return true;
|
|
1722
|
+
return others.some((o) => {
|
|
1723
|
+
const a = numbersOf(o.after, k.key, spec.scoped).map((n) => n.v);
|
|
1724
|
+
return a.length > 0 && b.every((bv) => a.some((av) => k.weakens === "lower" ? !lessThan(av, bv) : !lessThan(bv, av)));
|
|
1725
|
+
});
|
|
1726
|
+
});
|
|
1727
|
+
}
|
|
1483
1728
|
var coverageLowering = {
|
|
1484
1729
|
id: RULE4,
|
|
1485
1730
|
surface: ["file"],
|
|
@@ -1492,10 +1737,15 @@ var coverageLowering = {
|
|
|
1492
1737
|
if (c.after != null && c.op !== "delete") {
|
|
1493
1738
|
const reasons = [];
|
|
1494
1739
|
if (isJsConfig(c.path)) {
|
|
1495
|
-
|
|
1496
|
-
|
|
1740
|
+
const moved = () => gateMovedElsewhere(c, changes, policy);
|
|
1741
|
+
const b = parseThresholds(c.before ?? "");
|
|
1742
|
+
const a = parseThresholds(c.after);
|
|
1743
|
+
reasons.push(...weakenings(b, a, c.path, b.present && !a.present && moved(), ctx));
|
|
1744
|
+
reasons.push(...narrowings(parseLists(c.before ?? ""), parseLists(c.after), sourceSamples(c.path, policy, ctx)));
|
|
1745
|
+
reasons.push(...switchWeakenings(parseSwitches(c.before ?? ""), parseSwitches(c.after)));
|
|
1497
1746
|
}
|
|
1498
1747
|
reasons.push(...simpleWeakenings(c.path, c.before ?? "", c.after));
|
|
1748
|
+
reasons.push(...simpleNarrowings(c.path, c.before ?? "", c.after));
|
|
1499
1749
|
for (const reason of reasons) {
|
|
1500
1750
|
out2.push(
|
|
1501
1751
|
makeFinding(RULE4, policy, {
|
|
@@ -1509,7 +1759,7 @@ var coverageLowering = {
|
|
|
1509
1759
|
} else if (c.op === "delete" || c.after === "" && (c.before ?? "").length > 0) {
|
|
1510
1760
|
const before = c.before ?? "";
|
|
1511
1761
|
const gated = isJsConfig(c.path) ? parseThresholds(before).present : simpleWeakenings(c.path, before, "").length > 0 || SIMPLE_FILES.some((s) => s.file.test(c.path) && s.keys.some((k) => numbersOf(before, k.key).length));
|
|
1512
|
-
if (gated) {
|
|
1762
|
+
if (gated && !gateMovedElsewhere(c, changes, policy)) {
|
|
1513
1763
|
out2.push(
|
|
1514
1764
|
makeFinding(RULE4, policy, {
|
|
1515
1765
|
file: c.path,
|
|
@@ -1606,8 +1856,33 @@ function canonical(core) {
|
|
|
1606
1856
|
return s.replace(/\s+--\s*$/, "").trim();
|
|
1607
1857
|
}
|
|
1608
1858
|
var usesRef = (core) => core.replace(/@.*$/, "");
|
|
1609
|
-
var
|
|
1610
|
-
|
|
1859
|
+
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/;
|
|
1860
|
+
var NEUTRALISING_SUFFIX = new RegExp(
|
|
1861
|
+
`\\|\\||;|(?<!&)&(?!&)|\\|(?!\\|)|--passWithNoTests\\b|--coverage=false\\b|--coverageThreshold\\b|${SUITE_NARROWING_FLAGS.source}`
|
|
1862
|
+
);
|
|
1863
|
+
var TIMEOUT_WRAP = /^timeout\s+(?:-{1,2}\S+\s+)*\d\S*\s+(.+)$/;
|
|
1864
|
+
var PATH_SHAPED = /\/|\.(?:test|spec)\./;
|
|
1865
|
+
function narrowedByPositional(args) {
|
|
1866
|
+
const toks = args.trim().split(/\s+/).filter(Boolean);
|
|
1867
|
+
for (let i = 0; i < toks.length; i++) {
|
|
1868
|
+
const t = toks[i];
|
|
1869
|
+
if (t === "--" || t.startsWith("-")) continue;
|
|
1870
|
+
const prev = toks[i - 1];
|
|
1871
|
+
if (prev && (prev.startsWith("-") && prev !== "--" && !prev.includes("=") || /[<>]/.test(prev))) continue;
|
|
1872
|
+
if (/[<>|;&]/.test(t)) continue;
|
|
1873
|
+
if (PATH_SHAPED.test(t)) return true;
|
|
1874
|
+
}
|
|
1875
|
+
return false;
|
|
1876
|
+
}
|
|
1877
|
+
function checkKind(core) {
|
|
1878
|
+
if (!invokesCheck(core)) return null;
|
|
1879
|
+
const s = canonical(core);
|
|
1880
|
+
if (/\btamperward\b/.test(s)) return "gate";
|
|
1881
|
+
if (/\b(?:lint|eslint)\b/i.test(s)) return "lint";
|
|
1882
|
+
if (/\b(?:typecheck|type-check|tsc)\b/i.test(s)) return "types";
|
|
1883
|
+
return "test";
|
|
1884
|
+
}
|
|
1885
|
+
function survives(removedCore, isUses, afterCores, addedCores) {
|
|
1611
1886
|
if (isUses) {
|
|
1612
1887
|
const ref = usesRef(removedCore);
|
|
1613
1888
|
return afterCores.some((a) => usesRef(a) === ref) ? { state: "kept" } : { state: "gone" };
|
|
@@ -1619,15 +1894,33 @@ function survives(removedCore, isUses, afterCores) {
|
|
|
1619
1894
|
if (a === r) return { state: "kept" };
|
|
1620
1895
|
if (a.startsWith(r + " ")) {
|
|
1621
1896
|
const rest = a.slice(r.length);
|
|
1622
|
-
if (NEUTRALISING_SUFFIX.test(rest)) neutralised
|
|
1897
|
+
if (NEUTRALISING_SUFFIX.test(rest) || narrowedByPositional(rest)) neutralised ??= raw;
|
|
1623
1898
|
else return { state: "kept" };
|
|
1624
1899
|
} else if (r.startsWith(a + " ") && invokesCheck(a)) {
|
|
1625
1900
|
return { state: "kept" };
|
|
1901
|
+
} else {
|
|
1902
|
+
const w = a.match(TIMEOUT_WRAP);
|
|
1903
|
+
if (w && (canonical(w[1]) === r || canonical(w[1]).startsWith(r + " "))) neutralised ??= raw;
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
const kind = checkKind(removedCore);
|
|
1907
|
+
let respelled = false;
|
|
1908
|
+
for (const raw of addedCores) {
|
|
1909
|
+
if (!kind || checkKind(raw) !== kind) continue;
|
|
1910
|
+
const a = canonical(raw);
|
|
1911
|
+
if (a.match(TIMEOUT_WRAP)) {
|
|
1912
|
+
neutralised ??= raw;
|
|
1913
|
+
continue;
|
|
1626
1914
|
}
|
|
1915
|
+
const args = a.slice(a.indexOf(" ") + 1 || a.length);
|
|
1916
|
+
if (a.includes(" ") && (NEUTRALISING_SUFFIX.test(" " + args) || narrowedByPositional(args))) neutralised ??= raw;
|
|
1917
|
+
else respelled = true;
|
|
1627
1918
|
}
|
|
1919
|
+
if (respelled) return { state: "kept" };
|
|
1628
1920
|
return neutralised ? { state: "neutralised", by: neutralised } : { state: "gone" };
|
|
1629
1921
|
}
|
|
1630
1922
|
var uncommented = (v) => v.replace(/\s+#.*$/, "").trim();
|
|
1923
|
+
var TRUTHY = Symbol("truthy");
|
|
1631
1924
|
function lex(src) {
|
|
1632
1925
|
const out2 = [];
|
|
1633
1926
|
let i = 0;
|
|
@@ -1658,13 +1951,13 @@ function lex(src) {
|
|
|
1658
1951
|
i += num[0].length;
|
|
1659
1952
|
continue;
|
|
1660
1953
|
}
|
|
1661
|
-
const op = src.slice(i).match(/^(?:==|!=|&&|\|\||<=|>=|[!()
|
|
1954
|
+
const op = src.slice(i).match(/^(?:==|!=|&&|\|\||<=|>=|[!()<>,[\]])/);
|
|
1662
1955
|
if (op) {
|
|
1663
1956
|
out2.push({ t: "op", v: op[0] });
|
|
1664
1957
|
i += op[0].length;
|
|
1665
1958
|
continue;
|
|
1666
1959
|
}
|
|
1667
|
-
const id = src.slice(i).match(/^[A-Za-z_][\w
|
|
1960
|
+
const id = src.slice(i).match(/^[A-Za-z_][\w.\-*]*/);
|
|
1668
1961
|
if (id) {
|
|
1669
1962
|
out2.push({ t: "id", v: id[0] });
|
|
1670
1963
|
i += id[0].length;
|
|
@@ -1674,15 +1967,16 @@ function lex(src) {
|
|
|
1674
1967
|
}
|
|
1675
1968
|
return out2;
|
|
1676
1969
|
}
|
|
1970
|
+
var truthy = (v) => v === TRUTHY ? true : typeof v === "string" ? v !== "" : typeof v === "number" ? v !== 0 : v === true;
|
|
1677
1971
|
function foldConst(src) {
|
|
1678
1972
|
const toks = lex(src);
|
|
1679
1973
|
if (!toks) return void 0;
|
|
1680
1974
|
let p = 0;
|
|
1681
1975
|
const peek = () => toks[p];
|
|
1682
1976
|
const eat = (v2) => toks[p]?.t === "op" && toks[p].v === v2 ? (p++, true) : false;
|
|
1683
|
-
const
|
|
1684
|
-
const num = (v2) => typeof v2 === "number" ? v2 : typeof v2 === "boolean" ? v2 ? 1 : 0 : v2 === null ? 0 : v2.trim() === "" ? 0 : Number(v2);
|
|
1977
|
+
const num = (v2) => v2 === TRUTHY ? NaN : typeof v2 === "number" ? v2 : typeof v2 === "boolean" ? v2 ? 1 : 0 : v2 === null ? 0 : v2.trim() === "" ? 0 : Number(v2);
|
|
1685
1978
|
const eq = (a, b) => {
|
|
1979
|
+
if (a === TRUTHY || b === TRUTHY) return void 0;
|
|
1686
1980
|
if (typeof a === "string" && typeof b === "string") return a.toLowerCase() === b.toLowerCase();
|
|
1687
1981
|
if (a === null && b === null) return true;
|
|
1688
1982
|
const x = num(a);
|
|
@@ -1693,8 +1987,9 @@ function foldConst(src) {
|
|
|
1693
1987
|
let l = and();
|
|
1694
1988
|
while (eat("||")) {
|
|
1695
1989
|
const r = and();
|
|
1696
|
-
if (l
|
|
1697
|
-
l
|
|
1990
|
+
if (l !== void 0 && truthy(l)) continue;
|
|
1991
|
+
if (l !== void 0) l = r;
|
|
1992
|
+
else l = r !== void 0 && truthy(r) ? TRUTHY : void 0;
|
|
1698
1993
|
}
|
|
1699
1994
|
return l;
|
|
1700
1995
|
};
|
|
@@ -1702,8 +1997,9 @@ function foldConst(src) {
|
|
|
1702
1997
|
let l = cmp();
|
|
1703
1998
|
while (eat("&&")) {
|
|
1704
1999
|
const r = cmp();
|
|
1705
|
-
if (l
|
|
1706
|
-
|
|
2000
|
+
if (l !== void 0 && !truthy(l)) continue;
|
|
2001
|
+
if (l !== void 0) l = r;
|
|
2002
|
+
else l = r !== void 0 && !truthy(r) ? false : void 0;
|
|
1707
2003
|
}
|
|
1708
2004
|
return l;
|
|
1709
2005
|
};
|
|
@@ -1714,14 +2010,18 @@ function foldConst(src) {
|
|
|
1714
2010
|
if (!t || t.t !== "op" || !/^(?:==|!=|<|>|<=|>=)$/.test(t.v)) return l;
|
|
1715
2011
|
p++;
|
|
1716
2012
|
const r = unary();
|
|
1717
|
-
if (l === void 0 || r === void 0)
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
2013
|
+
if (l === void 0 || r === void 0) {
|
|
2014
|
+
l = void 0;
|
|
2015
|
+
continue;
|
|
2016
|
+
}
|
|
2017
|
+
if (t.v === "==" || t.v === "!=") {
|
|
2018
|
+
const e = eq(l, r);
|
|
2019
|
+
l = e === void 0 ? void 0 : t.v === "==" ? e : !e;
|
|
2020
|
+
} else {
|
|
1721
2021
|
const x = num(l);
|
|
1722
2022
|
const y = num(r);
|
|
1723
|
-
if (Number.isNaN(x) || Number.isNaN(y))
|
|
1724
|
-
l = t.v === "<" ? x < y : t.v === ">" ? x > y : t.v === "<=" ? x <= y : x >= y;
|
|
2023
|
+
if (Number.isNaN(x) || Number.isNaN(y)) l = void 0;
|
|
2024
|
+
else l = t.v === "<" ? x < y : t.v === ">" ? x > y : t.v === "<=" ? x <= y : x >= y;
|
|
1725
2025
|
}
|
|
1726
2026
|
}
|
|
1727
2027
|
};
|
|
@@ -1746,6 +2046,19 @@ function foldConst(src) {
|
|
|
1746
2046
|
if (/^true$/i.test(t.v)) return true;
|
|
1747
2047
|
if (/^false$/i.test(t.v)) return false;
|
|
1748
2048
|
if (/^null$/i.test(t.v)) return null;
|
|
2049
|
+
if (eat("(")) {
|
|
2050
|
+
if (!eat(")")) {
|
|
2051
|
+
for (; ; ) {
|
|
2052
|
+
or();
|
|
2053
|
+
if (eat(")")) break;
|
|
2054
|
+
if (!eat(",")) return void 0;
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
while (eat("[")) {
|
|
2059
|
+
or();
|
|
2060
|
+
if (!eat("]")) return void 0;
|
|
2061
|
+
}
|
|
1749
2062
|
return void 0;
|
|
1750
2063
|
}
|
|
1751
2064
|
return void 0;
|
|
@@ -1762,7 +2075,7 @@ function isAlwaysFalse(raw) {
|
|
|
1762
2075
|
const e = expressionOf(raw);
|
|
1763
2076
|
if (e === "") return false;
|
|
1764
2077
|
const v = foldConst(e);
|
|
1765
|
-
return v !== void 0 && !(
|
|
2078
|
+
return v !== void 0 && !truthy(v);
|
|
1766
2079
|
}
|
|
1767
2080
|
function isTruthy(raw) {
|
|
1768
2081
|
const v = uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim();
|
|
@@ -1770,7 +2083,7 @@ function isTruthy(raw) {
|
|
|
1770
2083
|
const m = v.match(/^\$\{\{\s*(.+?)\s*\}\}$/);
|
|
1771
2084
|
if (!m) return false;
|
|
1772
2085
|
const f = foldConst(m[1]);
|
|
1773
|
-
return f !== void 0 && (
|
|
2086
|
+
return f !== void 0 && truthy(f);
|
|
1774
2087
|
}
|
|
1775
2088
|
var indentOf = (l) => l.match(/^\s*/)[0].length;
|
|
1776
2089
|
var STEP_START = /^\s*-\s+(?:name|uses|run|if|id|with|env|shell|continue-on-error|working-directory|timeout-minutes):/;
|
|
@@ -1784,7 +2097,21 @@ function stepOf(lines, i) {
|
|
|
1784
2097
|
while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > stepInd)) e++;
|
|
1785
2098
|
return [s, e];
|
|
1786
2099
|
}
|
|
2100
|
+
function jobOf(lines, i) {
|
|
2101
|
+
const ind = indentOf(lines[i]);
|
|
2102
|
+
let h = i - 1;
|
|
2103
|
+
while (h >= 0 && (lines[h].trim() === "" || indentOf(lines[h]) >= ind)) h--;
|
|
2104
|
+
if (h < 0 || !/^\s+[\w-]+:\s*(?:#.*)?$/.test(lines[h])) return null;
|
|
2105
|
+
let t = h - 1;
|
|
2106
|
+
while (t >= 0 && (lines[t].trim() === "" || indentOf(lines[t]) > 0)) t--;
|
|
2107
|
+
if (t < 0 || !/^jobs:/.test(lines[t])) return null;
|
|
2108
|
+
const hi = indentOf(lines[h]);
|
|
2109
|
+
let e = i + 1;
|
|
2110
|
+
while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > hi)) e++;
|
|
2111
|
+
return [h, e];
|
|
2112
|
+
}
|
|
1787
2113
|
var stepHasCheck = (lines, range) => range === null || lines.slice(range[0], range[1]).some((l) => invokesCheck(l) || USES.test(l) && CHECK.test(l));
|
|
2114
|
+
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));
|
|
1788
2115
|
function runBlocks(lines) {
|
|
1789
2116
|
const out2 = [];
|
|
1790
2117
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -1802,13 +2129,28 @@ var NEUTRALISERS = [
|
|
|
1802
2129
|
{ re: /^\s*if\s+false\s*;?\s*then\b/, what: "if false; then \u2014 the check is never run" },
|
|
1803
2130
|
{ re: /^\s*:\s*<<\s*['"]?\w+/, what: "a heredoc fed to `:` \u2014 the check is commented out" }
|
|
1804
2131
|
];
|
|
2132
|
+
var LIST_KEYS = {
|
|
2133
|
+
branches: "branches",
|
|
2134
|
+
"branches-ignore": "branchesIgnore",
|
|
2135
|
+
"paths-ignore": "pathsIgnore",
|
|
2136
|
+
paths: "paths",
|
|
2137
|
+
types: "types"
|
|
2138
|
+
};
|
|
1805
2139
|
function flowList(v) {
|
|
1806
2140
|
const m = v.trim().match(/^\[(.*)\]$/);
|
|
1807
2141
|
if (!m) return null;
|
|
1808
2142
|
return m[1].split(",").map((s) => s.trim().replace(/^(['"])(.*)\1$/, "$2")).filter(Boolean);
|
|
1809
2143
|
}
|
|
1810
2144
|
function parseTriggers(src) {
|
|
1811
|
-
const t = {
|
|
2145
|
+
const t = {
|
|
2146
|
+
events: /* @__PURE__ */ new Set(),
|
|
2147
|
+
branches: /* @__PURE__ */ new Map(),
|
|
2148
|
+
branchesIgnore: /* @__PURE__ */ new Map(),
|
|
2149
|
+
pathsIgnore: /* @__PURE__ */ new Map(),
|
|
2150
|
+
paths: /* @__PURE__ */ new Map(),
|
|
2151
|
+
types: /* @__PURE__ */ new Map(),
|
|
2152
|
+
present: false
|
|
2153
|
+
};
|
|
1812
2154
|
const lines = src.split("\n");
|
|
1813
2155
|
const i = lines.findIndex((l) => /^(?:on|"on"|'on'|true):/.test(l));
|
|
1814
2156
|
if (i < 0) return t;
|
|
@@ -1816,8 +2158,8 @@ function parseTriggers(src) {
|
|
|
1816
2158
|
const head = lines[i].replace(/^(?:on|"on"|'on'|true):\s*/, "");
|
|
1817
2159
|
const inline = uncommented(head);
|
|
1818
2160
|
if (inline) {
|
|
1819
|
-
const
|
|
1820
|
-
for (const e of
|
|
2161
|
+
const list2 = flowList(inline);
|
|
2162
|
+
for (const e of list2 ?? [inline]) t.events.add(e);
|
|
1821
2163
|
return t;
|
|
1822
2164
|
}
|
|
1823
2165
|
let event = null;
|
|
@@ -1844,47 +2186,92 @@ function parseTriggers(src) {
|
|
|
1844
2186
|
listKey = null;
|
|
1845
2187
|
continue;
|
|
1846
2188
|
}
|
|
1847
|
-
|
|
1848
|
-
|
|
2189
|
+
const key2 = LIST_KEYS[kv[1]];
|
|
2190
|
+
if (event && key2) {
|
|
2191
|
+
listKey = key2;
|
|
1849
2192
|
listInd = ind;
|
|
1850
|
-
const
|
|
1851
|
-
t[listKey].set(event,
|
|
2193
|
+
const list2 = flowList(kv[2]);
|
|
2194
|
+
t[listKey].set(event, list2 ?? []);
|
|
1852
2195
|
} else listKey = null;
|
|
1853
2196
|
}
|
|
1854
2197
|
return t;
|
|
1855
2198
|
}
|
|
1856
2199
|
var DEFAULT_BRANCH = /^(?:main|master)$/;
|
|
1857
2200
|
var ALL = (g) => /^\*\*?$|^\*\*\/\*$/.test(g);
|
|
1858
|
-
|
|
2201
|
+
var CODE_EVENTS = ["push", "pull_request", "pull_request_target"];
|
|
2202
|
+
var PR_CODE_TYPES = /^(?:opened|synchronize|reopened)$/;
|
|
2203
|
+
function ghGlob(g) {
|
|
2204
|
+
const re = g.split("**").map((part) => part.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*");
|
|
2205
|
+
try {
|
|
2206
|
+
const r = new RegExp(`^${re}$`);
|
|
2207
|
+
return (p) => r.test(p);
|
|
2208
|
+
} catch {
|
|
2209
|
+
return () => false;
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
function pathsMatchSource(globs4, sources) {
|
|
2213
|
+
const ms = globs4.map((g) => ({ neg: g.startsWith("!"), m: ghGlob(g.startsWith("!") ? g.slice(1) : g) }));
|
|
2214
|
+
return sources.some((s) => {
|
|
2215
|
+
let on = false;
|
|
2216
|
+
for (const { neg, m } of ms) if (m(s)) on = !neg;
|
|
2217
|
+
return on;
|
|
2218
|
+
});
|
|
2219
|
+
}
|
|
2220
|
+
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"];
|
|
2221
|
+
function sourceProbes(ctx) {
|
|
2222
|
+
const files = trackedFiles(ctx);
|
|
2223
|
+
if (!files) return CODE_PROBES;
|
|
2224
|
+
const code = files.filter((f) => langOf(f) !== null);
|
|
2225
|
+
return code.length ? code : CODE_PROBES;
|
|
2226
|
+
}
|
|
2227
|
+
function triggerNarrowings(before, after, opts) {
|
|
1859
2228
|
const out2 = [];
|
|
1860
2229
|
if (!before.present || !after.present) return out2;
|
|
1861
|
-
|
|
2230
|
+
const isDefault = (b) => opts.defaultBranch ? b === opts.defaultBranch : DEFAULT_BRANCH.test(b);
|
|
2231
|
+
const namesDefault = (l) => l.some(isDefault);
|
|
2232
|
+
for (const e of CODE_EVENTS) {
|
|
1862
2233
|
if (before.events.has(e) && !after.events.has(e)) out2.push(`the ${e} trigger was removed \u2014 the workflow no longer runs on it`);
|
|
1863
2234
|
}
|
|
1864
|
-
for (const [e,
|
|
2235
|
+
for (const [e, list2] of after.pathsIgnore) {
|
|
1865
2236
|
const had = before.pathsIgnore.get(e) ?? [];
|
|
1866
|
-
if (
|
|
2237
|
+
if (list2.some((g) => ALL(g) && !had.includes(g))) out2.push(`on.${e}.paths-ignore now ignores every path \u2014 the workflow never runs on ${e}`);
|
|
2238
|
+
}
|
|
2239
|
+
for (const [e, list2] of after.paths) {
|
|
2240
|
+
if (!CODE_EVENTS.includes(e) || !after.events.has(e)) continue;
|
|
2241
|
+
const had = before.paths.get(e);
|
|
2242
|
+
if (had && !pathsMatchSource(had, opts.sources)) continue;
|
|
2243
|
+
if (!pathsMatchSource(list2, opts.sources)) out2.push(`on.${e}.paths matches no source file (now [${list2.join(", ")}]) \u2014 the workflow never runs on code`);
|
|
2244
|
+
}
|
|
2245
|
+
for (const [e, list2] of after.types) {
|
|
2246
|
+
if (!/^pull_request(?:_target)?$/.test(e) || !after.events.has(e)) continue;
|
|
2247
|
+
const had = before.types.get(e);
|
|
2248
|
+
if (had && !had.some((t) => PR_CODE_TYPES.test(t))) continue;
|
|
2249
|
+
if (!list2.some((t) => PR_CODE_TYPES.test(t))) out2.push(`on.${e}.types no longer includes opened/synchronize (now [${list2.join(", ")}]) \u2014 the workflow does not run on the pull request's code`);
|
|
1867
2250
|
}
|
|
1868
|
-
for (const [e,
|
|
2251
|
+
for (const [e, list2] of after.branchesIgnore) {
|
|
1869
2252
|
const had = before.branchesIgnore.get(e) ?? [];
|
|
1870
|
-
if (
|
|
2253
|
+
if (list2.some((g) => (ALL(g) || isDefault(g)) && !had.includes(g))) out2.push(`on.${e}.branches-ignore now covers the default branch`);
|
|
1871
2254
|
}
|
|
1872
2255
|
for (const [e, was] of before.branches) {
|
|
1873
2256
|
const now = after.branches.get(e);
|
|
1874
2257
|
if (!now || !after.events.has(e)) continue;
|
|
1875
2258
|
if (now.some(ALL)) continue;
|
|
1876
|
-
const lostDefault = was
|
|
1877
|
-
const replaced = was.length > 0 && !was.some((b) => now.includes(b));
|
|
2259
|
+
const lostDefault = namesDefault(was) && !namesDefault(now);
|
|
2260
|
+
const replaced = !namesDefault(was) && !namesDefault(now) && was.length > 0 && !was.some((b) => now.includes(b));
|
|
1878
2261
|
if (lostDefault || replaced) out2.push(`on.${e}.branches no longer names ${was.filter((b) => !now.includes(b)).join(", ")} (now [${now.join(", ")}])`);
|
|
1879
2262
|
}
|
|
1880
2263
|
return out2;
|
|
1881
2264
|
}
|
|
2265
|
+
var REUSED_WORKFLOW = /^\s*uses:\s*\.\/(\.github\/workflows\/[^\s@]+)/;
|
|
1882
2266
|
var ciTampering = {
|
|
1883
2267
|
id: RULE5,
|
|
1884
2268
|
surface: ["file"],
|
|
1885
2269
|
certainty: "mechanical",
|
|
1886
|
-
run(changes, policy) {
|
|
2270
|
+
run(changes, policy, _view, ctx) {
|
|
1887
2271
|
const out2 = [];
|
|
2272
|
+
const afterByPath = /* @__PURE__ */ new Map();
|
|
2273
|
+
for (const c of changes) if (c.kind === "file" && c.after != null) afterByPath.set(c.path, c.after);
|
|
2274
|
+
let triggerOpts = null;
|
|
1888
2275
|
for (const c of changes) {
|
|
1889
2276
|
if (c.kind !== "file") continue;
|
|
1890
2277
|
if (c.op === "rename" && c.oldPath && isActiveWorkflow(c.oldPath) && !isActiveWorkflow(c.path)) {
|
|
@@ -1907,6 +2294,10 @@ var ciTampering = {
|
|
|
1907
2294
|
if (!coe && !cond) continue;
|
|
1908
2295
|
const step = afterLines && l.newLine != null ? stepOf(afterLines, l.newLine - 1) : null;
|
|
1909
2296
|
if (afterLines && step && !stepHasCheck(afterLines, step)) continue;
|
|
2297
|
+
if (afterLines && !step && l.newLine != null) {
|
|
2298
|
+
const job = jobOf(afterLines, l.newLine - 1);
|
|
2299
|
+
if (job && !jobHasCheck(afterLines, job)) continue;
|
|
2300
|
+
}
|
|
1910
2301
|
if (coe && isTruthy(coe[1])) {
|
|
1911
2302
|
out2.push(
|
|
1912
2303
|
makeFinding(RULE5, policy, {
|
|
@@ -1964,25 +2355,35 @@ var ciTampering = {
|
|
|
1964
2355
|
);
|
|
1965
2356
|
}
|
|
1966
2357
|
if (c.before != null) {
|
|
1967
|
-
|
|
2358
|
+
triggerOpts ??= { defaultBranch: defaultBranch(ctx), sources: sourceProbes(ctx) };
|
|
2359
|
+
for (const reason of triggerNarrowings(parseTriggers(c.before), parseTriggers(c.after), triggerOpts)) {
|
|
1968
2360
|
out2.push(
|
|
1969
2361
|
makeFinding(RULE5, policy, {
|
|
1970
2362
|
file: c.path,
|
|
1971
2363
|
message: `The workflow's triggers were narrowed: ${reason}.`,
|
|
1972
2364
|
evidence: reason,
|
|
1973
|
-
remediation: "Keep the workflow running on push and pull_request for the default branch; a check that never runs is no check."
|
|
2365
|
+
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."
|
|
1974
2366
|
})
|
|
1975
2367
|
);
|
|
1976
2368
|
}
|
|
1977
2369
|
}
|
|
1978
2370
|
}
|
|
1979
2371
|
const afterCores = (afterLines ?? addedLines(c).map((l) => l.content)).map(commandCore);
|
|
2372
|
+
const addedCores = addedLines(c).map((l) => commandCore(l.content));
|
|
2373
|
+
for (const l of afterLines ?? []) {
|
|
2374
|
+
const reused = l.match(REUSED_WORKFLOW)?.[1];
|
|
2375
|
+
const content = reused ? afterByPath.get(reused) : void 0;
|
|
2376
|
+
if (content == null || reused === c.path) continue;
|
|
2377
|
+
const cores = content.split("\n").map(commandCore);
|
|
2378
|
+
afterCores.push(...cores);
|
|
2379
|
+
addedCores.push(...cores);
|
|
2380
|
+
}
|
|
1980
2381
|
for (const l of removedLines(c)) {
|
|
1981
2382
|
if (/^\s*#/.test(l.content)) continue;
|
|
1982
2383
|
const isUsesLine = USES.test(l.content) && CHECK.test(l.content);
|
|
1983
2384
|
const isCheckCommand = !isUsesLine && !/^\s*-?\s*uses:/.test(l.content) && (YAML_KEY.test(l.content) ? /^\s*-?\s*run:/.test(l.content) : true) && invokesCheck(l.content);
|
|
1984
2385
|
if (!isUsesLine && !isCheckCommand) continue;
|
|
1985
|
-
const s = survives(commandCore(l.content), isUsesLine, afterCores);
|
|
2386
|
+
const s = survives(commandCore(l.content), isUsesLine, afterCores, addedCores);
|
|
1986
2387
|
if (s.state === "kept") continue;
|
|
1987
2388
|
if (s.state === "neutralised") {
|
|
1988
2389
|
out2.push(
|
|
@@ -1990,7 +2391,7 @@ var ciTampering = {
|
|
|
1990
2391
|
file: c.path,
|
|
1991
2392
|
message: "A CI check command was neutralised in place: it still runs, but its result no longer decides (or its suite was narrowed).",
|
|
1992
2393
|
evidence: `${l.content.trim()} \u2192 ${s.by}`,
|
|
1993
|
-
remediation: "Let the check decide the step; do not mask its status or narrow what it runs."
|
|
2394
|
+
remediation: "Let the check decide the step; do not mask its status, cut it short, or narrow what it runs."
|
|
1994
2395
|
})
|
|
1995
2396
|
);
|
|
1996
2397
|
continue;
|
|
@@ -2010,12 +2411,59 @@ var ciTampering = {
|
|
|
2010
2411
|
};
|
|
2011
2412
|
|
|
2012
2413
|
// src/detectors/hook-tampering.ts
|
|
2013
|
-
import { existsSync, readFileSync as
|
|
2014
|
-
import { isAbsolute, join as
|
|
2414
|
+
import { existsSync, readFileSync as readFileSync3 } from "node:fs";
|
|
2415
|
+
import { isAbsolute, join as join3, relative } from "node:path";
|
|
2416
|
+
|
|
2417
|
+
// src/wiring.ts
|
|
2418
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
2419
|
+
import { dirname, join as join2 } from "node:path";
|
|
2420
|
+
import { fileURLToPath } from "node:url";
|
|
2421
|
+
function shippedVersion() {
|
|
2422
|
+
try {
|
|
2423
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
2424
|
+
for (const rel of ["../package.json", "../../package.json", "../../../package.json"]) {
|
|
2425
|
+
try {
|
|
2426
|
+
const pkg = JSON.parse(readFileSync2(join2(here, rel), "utf8"));
|
|
2427
|
+
if (pkg.name === "tamperward" && pkg.version) return pkg.version;
|
|
2428
|
+
} catch {
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
} catch {
|
|
2432
|
+
}
|
|
2433
|
+
return "latest";
|
|
2434
|
+
}
|
|
2435
|
+
var TW_VERSION = shippedVersion();
|
|
2436
|
+
var PRE_TOOLS = ["Bash", "Edit", "Write", "MultiEdit", "NotebookEdit"];
|
|
2437
|
+
var PRE_MATCHER = PRE_TOOLS.join("|");
|
|
2438
|
+
var MARKER = "# tamperward: block agent shortcuts before they land";
|
|
2439
|
+
var HOOK_CMD = `npx --yes tamperward@${TW_VERSION} hook claude`;
|
|
2440
|
+
var SWEEP_CMD = `npx --yes tamperward@${TW_VERSION} sweep claude`;
|
|
2441
|
+
var PRECOMMIT_CMD = `npx --yes tamperward@${TW_VERSION} check --staged`;
|
|
2442
|
+
var OURS = /^\s*npx\s+(?:--yes|-y)\s+tamperward(?:@(\S+))?\s+(hook claude|sweep claude|check --staged)\s*$/;
|
|
2443
|
+
var PLAIN_SEMVER = /^\d+\.\d+\.\d+$/;
|
|
2444
|
+
function compareVersions(a, b) {
|
|
2445
|
+
if (!PLAIN_SEMVER.test(a) || !PLAIN_SEMVER.test(b)) return null;
|
|
2446
|
+
const pa = a.split(".").map(Number);
|
|
2447
|
+
const pb = b.split(".").map(Number);
|
|
2448
|
+
for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
|
|
2449
|
+
return 0;
|
|
2450
|
+
}
|
|
2451
|
+
function pinNotBelow(now, floor) {
|
|
2452
|
+
if (!now) return false;
|
|
2453
|
+
const c = compareVersions(now, floor);
|
|
2454
|
+
return c !== null && c >= 0;
|
|
2455
|
+
}
|
|
2456
|
+
var INIT_SCRIPT = /^#!\/bin\/sh\n# tamperward: block agent shortcuts before they land\nnpx --yes tamperward(?:@(\S+))? check --staged\n?$/;
|
|
2457
|
+
function initScriptPin(src) {
|
|
2458
|
+
const m = src.match(INIT_SCRIPT);
|
|
2459
|
+
return m ? m[1] ?? "" : null;
|
|
2460
|
+
}
|
|
2461
|
+
var CLAUDE_SETTINGS = /(?:^|\/)(?:\.claude\/settings(?:\.local)?\.json|managed-settings\.json)$/;
|
|
2462
|
+
var isClaudeSettings = (path) => CLAUDE_SETTINGS.test(path);
|
|
2015
2463
|
|
|
2016
2464
|
// src/detectors/policy-diff.ts
|
|
2017
2465
|
import { parse } from "yaml";
|
|
2018
|
-
var
|
|
2466
|
+
var globs2 = (list2) => Array.isArray(list2) ? list2.filter((g) => typeof g === "string").map(normalizeGlob) : [];
|
|
2019
2467
|
function safeParse(src) {
|
|
2020
2468
|
try {
|
|
2021
2469
|
const v = parse(src);
|
|
@@ -2032,13 +2480,13 @@ function effective(raw) {
|
|
|
2032
2480
|
const rules = { ...base.rules };
|
|
2033
2481
|
for (const [name, cfg] of Object.entries(raw.rules ?? {})) {
|
|
2034
2482
|
const over = cfg && typeof cfg === "object" && !Array.isArray(cfg) ? cfg : {};
|
|
2035
|
-
rules[name] = { ...rules[name] ?? {}, ...over, ...over.exclude ? { exclude:
|
|
2483
|
+
rules[name] = { ...rules[name] ?? {}, ...over, ...over.exclude ? { exclude: globs2(over.exclude) } : {} };
|
|
2036
2484
|
}
|
|
2037
|
-
const userProtected = raw.protected && typeof raw.protected === "object" ? Object.fromEntries(Object.entries(raw.protected).map(([cat,
|
|
2485
|
+
const userProtected = raw.protected && typeof raw.protected === "object" ? Object.fromEntries(Object.entries(raw.protected).map(([cat, list2]) => [cat, globs2(list2)])) : void 0;
|
|
2038
2486
|
return {
|
|
2039
2487
|
version,
|
|
2040
2488
|
rules,
|
|
2041
|
-
ignore: raw.ignore ?
|
|
2489
|
+
ignore: raw.ignore ? globs2(raw.ignore) : base.ignore ?? [],
|
|
2042
2490
|
protected: mergeProtected(base.protected, userProtected),
|
|
2043
2491
|
requiredFor: raw.signoff?.required_for ?? raw.signoff?.requiredFor ?? base.signoff.requiredFor,
|
|
2044
2492
|
ledger: typeof raw.signoff?.ledger === "string" ? raw.signoff.ledger : base.signoff.ledger
|
|
@@ -2155,7 +2603,7 @@ var RUNNER = /^(?:npx|pnpm|yarn|bunx|bun|npm)\s+(?:(?:exec|dlx|x|run)\s+)?(?:(?:
|
|
|
2155
2603
|
var TOOL = /^(tamperward|jest|vitest|eslint|tsc|lint-staged|lefthook|husky|pytest|make|cargo|go|pre-commit|mypy|ruff|rubocop|prettier|gradlew?)\b/;
|
|
2156
2604
|
var SCRIPT = /^(test|tests|lint|typecheck|type-check|coverage|check)\b/;
|
|
2157
2605
|
var SUBCOMMAND_TOOLS = /* @__PURE__ */ new Set(["tamperward", "make", "cargo", "go", "lefthook", "husky", "pre-commit"]);
|
|
2158
|
-
var PREFIX = /^(?:(?:then|do|else|\{|\(
|
|
2606
|
+
var PREFIX = /^(?:(?:then|do|else|\{|\(|exec|command|time|nice|env|sudo|[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"(?=\s|$)|'[^']*'(?=\s|$)|[^\s"']\S*|(?=\s)))\s+)*/;
|
|
2159
2607
|
function checkIdentity(segment) {
|
|
2160
2608
|
const core = segment.trim().replace(PREFIX, "").replace(/\s+/g, " ");
|
|
2161
2609
|
const viaRunner = RUNNER.test(core);
|
|
@@ -2166,20 +2614,49 @@ function checkIdentity(segment) {
|
|
|
2166
2614
|
if (tool === "tamperward") {
|
|
2167
2615
|
const sub = toks[1];
|
|
2168
2616
|
if (!sub || sub.startsWith("-")) return null;
|
|
2169
|
-
|
|
2617
|
+
const cwd = rest.match(/\s--cwd(?:=|\s+)(\S+)/)?.[1];
|
|
2618
|
+
return cwd ? `tamperward ${sub} --cwd ${cwd}` : `tamperward ${sub}`;
|
|
2170
2619
|
}
|
|
2171
2620
|
if (SUBCOMMAND_TOOLS.has(tool) && toks[1] && !toks[1].startsWith("-")) return `${tool} ${toks[1]}`;
|
|
2172
2621
|
return tool;
|
|
2173
2622
|
}
|
|
2623
|
+
function pinOfSegment(segment) {
|
|
2624
|
+
const m = segment.trim().replace(PREFIX, "").replace(RUNNER, "").match(/^tamperward(?:@(\S+))?\b/);
|
|
2625
|
+
return m ? m[1] ?? "" : null;
|
|
2626
|
+
}
|
|
2174
2627
|
function split(stmt) {
|
|
2175
2628
|
const segs = [];
|
|
2176
2629
|
const ops = [];
|
|
2177
|
-
|
|
2630
|
+
let depth = 0;
|
|
2631
|
+
let single = false;
|
|
2632
|
+
let double = false;
|
|
2178
2633
|
let last = 0;
|
|
2179
|
-
for (
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2634
|
+
for (let i = 0; i < stmt.length; i++) {
|
|
2635
|
+
const ch = stmt[i];
|
|
2636
|
+
if (ch === "'" && !double) {
|
|
2637
|
+
single = !single;
|
|
2638
|
+
continue;
|
|
2639
|
+
}
|
|
2640
|
+
if (ch === '"' && !single) {
|
|
2641
|
+
double = !double;
|
|
2642
|
+
continue;
|
|
2643
|
+
}
|
|
2644
|
+
if (single || double) continue;
|
|
2645
|
+
if (ch === "(" || ch === "{" && /(?:^|\s)$/.test(stmt.slice(0, i)) && /^(?:\s|$)/.test(stmt.slice(i + 1))) {
|
|
2646
|
+
depth++;
|
|
2647
|
+
continue;
|
|
2648
|
+
}
|
|
2649
|
+
if (ch === ")" || ch === "}" && depth > 0 && /(?:^|[\s;])$/.test(stmt.slice(0, i))) {
|
|
2650
|
+
depth = Math.max(0, depth - 1);
|
|
2651
|
+
continue;
|
|
2652
|
+
}
|
|
2653
|
+
if (depth > 0) continue;
|
|
2654
|
+
const op = stmt.startsWith("&&", i) ? "&&" : stmt.startsWith("||", i) ? "||" : ch === ";" ? ";" : ch === "|" ? "|" : null;
|
|
2655
|
+
if (!op) continue;
|
|
2656
|
+
segs.push(stmt.slice(last, i));
|
|
2657
|
+
ops.push(op);
|
|
2658
|
+
i += op.length - 1;
|
|
2659
|
+
last = i + 1;
|
|
2183
2660
|
}
|
|
2184
2661
|
segs.push(stmt.slice(last));
|
|
2185
2662
|
return { segs, ops };
|
|
@@ -2228,106 +2705,602 @@ function alwaysFalse(cond) {
|
|
|
2228
2705
|
if (/^(?:""|'')$/.test(inner)) return true;
|
|
2229
2706
|
return false;
|
|
2230
2707
|
}
|
|
2231
|
-
var alwaysTrue = (cond) =>
|
|
2708
|
+
var alwaysTrue = (cond) => {
|
|
2709
|
+
const c = cond.trim().replace(/;$/, "").trim();
|
|
2710
|
+
if (/^(?:true|:|!\s*false|\(\s*exit\s+0*\s*\))$/.test(c)) return true;
|
|
2711
|
+
const m = c.match(/^(?:\[\[\s+(.+?)\s+\]\]|\[\s+(.+?)\s+\]|test\s+(.+))$/);
|
|
2712
|
+
if (!m) return false;
|
|
2713
|
+
const inner = (m[1] ?? m[2] ?? m[3]).trim();
|
|
2714
|
+
if (inner.includes("$")) return false;
|
|
2715
|
+
const parts = inner.match(/^(\S+)\s+(-eq|-ne|-lt|-le|-gt|-ge|=|==|!=)\s+(\S+)$/);
|
|
2716
|
+
if (parts) {
|
|
2717
|
+
const ints = /^-?\d+$/.test(unq(parts[1])) && /^-?\d+$/.test(unq(parts[3]));
|
|
2718
|
+
if (parts[2].startsWith("-") && !ints) return false;
|
|
2719
|
+
return !alwaysFalse(c);
|
|
2720
|
+
}
|
|
2721
|
+
if (/^(?:-n|-z)\s+\S+$/.test(inner)) return !alwaysFalse(c);
|
|
2722
|
+
if (/^\S+$/.test(inner)) return unq(inner) !== "";
|
|
2723
|
+
return false;
|
|
2724
|
+
};
|
|
2232
2725
|
var EXIT_ZERO = /^(?:exit|return)(?:\s+0+)?$/;
|
|
2233
2726
|
var EXIT_ANY = /^(?:exit|return)(?:\s+\S+)?$/;
|
|
2234
|
-
var FAILS_LOUDLY = /\b(?:exit|return)\s+(?:[1-9]\d*|"?\$\?"?|"?\$\{?[A-Za-z_]\w*\}?"?)(?=[\s;})]|$)|\bfalse\b|\bkill\b|\bexit\s*$|\breturn\s*$/;
|
|
2235
2727
|
function insertsPassingExit(line) {
|
|
2236
2728
|
if (isComment(line)) return false;
|
|
2237
|
-
return split(stripComment(line)).segs.some((s) => EXIT_ZERO.test(s.trim().replace(PREFIX, "")));
|
|
2729
|
+
return split(stripComment(line)).segs.some((s) => EXIT_ZERO.test(s.trim().replace(/^!\s+/, "").replace(PREFIX, "")));
|
|
2238
2730
|
}
|
|
2239
2731
|
function insertsDeadGuard(line) {
|
|
2240
2732
|
if (isComment(line)) return false;
|
|
2241
2733
|
const m = stripComment(line).trim().match(/^(?:if|elif)\s+(.+?)\s*(?:;\s*then)?\s*$/);
|
|
2242
2734
|
return !!m && alwaysFalse(m[1]);
|
|
2243
2735
|
}
|
|
2244
|
-
function
|
|
2736
|
+
function joinContinued(lines) {
|
|
2245
2737
|
const out2 = [];
|
|
2246
|
-
|
|
2247
|
-
const
|
|
2738
|
+
let cur = null;
|
|
2739
|
+
for (const l of lines) {
|
|
2740
|
+
cur = cur === null ? l : cur + " " + l.trimStart();
|
|
2741
|
+
const m = cur.match(/(\\+)$/);
|
|
2742
|
+
if (m && m[1].length % 2 === 1 && !isComment(cur)) {
|
|
2743
|
+
cur = cur.slice(0, -1);
|
|
2744
|
+
continue;
|
|
2745
|
+
}
|
|
2746
|
+
out2.push(cur);
|
|
2747
|
+
cur = null;
|
|
2748
|
+
}
|
|
2749
|
+
if (cur !== null) out2.push(cur);
|
|
2750
|
+
return out2;
|
|
2751
|
+
}
|
|
2752
|
+
function stripHeredocs(lines) {
|
|
2753
|
+
const out2 = [];
|
|
2754
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2755
|
+
const l = lines[i];
|
|
2756
|
+
out2.push(l);
|
|
2757
|
+
if (isComment(l)) continue;
|
|
2758
|
+
const m = stripComment(l).match(/<<(-?)\s*(?:'([^']+)'|"([^"]+)"|\\?([A-Za-z_]\w*))/);
|
|
2759
|
+
if (!m) continue;
|
|
2760
|
+
const word = m[2] ?? m[3] ?? m[4];
|
|
2761
|
+
const dash = m[1] === "-";
|
|
2762
|
+
for (i++; i < lines.length; i++) {
|
|
2763
|
+
const t = dash ? lines[i].replace(/^\t+/, "") : lines[i];
|
|
2764
|
+
if (t === word) break;
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
return out2;
|
|
2768
|
+
}
|
|
2769
|
+
function explodeKeywords(line) {
|
|
2770
|
+
if (isComment(line)) return [line];
|
|
2771
|
+
let s = stripComment(line);
|
|
2772
|
+
s = s.replace(/;;/g, "\n;;");
|
|
2773
|
+
s = s.replace(/;\s*(then|do|fi|done|esac|else|elif)\b/g, "\n$1");
|
|
2774
|
+
s = s.replace(/^(\s*case\s+\S+\s+in)\s+(?=\S)/, "$1\n");
|
|
2775
|
+
s = s.replace(/^(\s*)(then|do|else)\s+(?=\S)/, "$1$2\n");
|
|
2776
|
+
s = s.replace(/\n(then|do|else)\s+(?=\S)/g, "\n$1\n");
|
|
2777
|
+
return s.split("\n");
|
|
2778
|
+
}
|
|
2779
|
+
function closingBrace(line, depth) {
|
|
2780
|
+
let single = false;
|
|
2781
|
+
let double = false;
|
|
2782
|
+
let expansion = 0;
|
|
2783
|
+
for (let i = 0; i < line.length; i++) {
|
|
2784
|
+
const ch = line[i];
|
|
2785
|
+
if (ch === "'" && !double) single = !single;
|
|
2786
|
+
else if (ch === '"' && !single) double = !double;
|
|
2787
|
+
else if (single) continue;
|
|
2788
|
+
else if (ch === "$" && line[i + 1] === "{") {
|
|
2789
|
+
expansion++;
|
|
2790
|
+
i++;
|
|
2791
|
+
} else if (ch === "}" && expansion > 0) expansion--;
|
|
2792
|
+
else if (ch === "{" && !double) depth++;
|
|
2793
|
+
else if (ch === "}" && !double && --depth === 0) return i;
|
|
2794
|
+
}
|
|
2795
|
+
return -1;
|
|
2796
|
+
}
|
|
2797
|
+
function braceDelta(line) {
|
|
2798
|
+
let d = 0;
|
|
2799
|
+
let single = false;
|
|
2800
|
+
let double = false;
|
|
2801
|
+
let expansion = 0;
|
|
2802
|
+
for (let i = 0; i < line.length; i++) {
|
|
2803
|
+
const ch = line[i];
|
|
2804
|
+
if (ch === "'" && !double) single = !single;
|
|
2805
|
+
else if (ch === '"' && !single) double = !double;
|
|
2806
|
+
else if (single) continue;
|
|
2807
|
+
else if (ch === "$" && line[i + 1] === "{") {
|
|
2808
|
+
expansion++;
|
|
2809
|
+
i++;
|
|
2810
|
+
} else if (ch === "}" && expansion > 0) expansion--;
|
|
2811
|
+
else if (ch === "{" && !double) d++;
|
|
2812
|
+
else if (ch === "}" && !double) d--;
|
|
2813
|
+
}
|
|
2814
|
+
return d;
|
|
2815
|
+
}
|
|
2816
|
+
var FN_DEF = /^\s*(?:function\s+)?([A-Za-z_][\w-]*)\s*\(\s*\)\s*(.*)$|^\s*function\s+([A-Za-z_][\w-]*)\s*(\{.*)?$/;
|
|
2817
|
+
function prepare(lines, opts) {
|
|
2818
|
+
const flat = stripHeredocs(joinContinued(lines)).flatMap(explodeKeywords);
|
|
2819
|
+
const top = [];
|
|
2820
|
+
const fns = /* @__PURE__ */ new Map();
|
|
2821
|
+
const shadowed = /* @__PURE__ */ new Set();
|
|
2822
|
+
let errexit = opts.errexit ?? false;
|
|
2823
|
+
let pipefail = false;
|
|
2824
|
+
if (flat[0]?.startsWith("#!")) {
|
|
2825
|
+
const sb = flat[0];
|
|
2826
|
+
if (/\s-[a-zA-Z]*e[a-zA-Z]*(?:\s|$)/.test(sb)) errexit = true;
|
|
2827
|
+
if (/-[a-zA-Z]*o\s+pipefail\b/.test(sb)) pipefail = true;
|
|
2828
|
+
}
|
|
2829
|
+
for (let i = 0; i < flat.length; i++) {
|
|
2830
|
+
const raw = flat[i];
|
|
2831
|
+
if (isComment(raw)) {
|
|
2832
|
+
top.push(raw);
|
|
2833
|
+
continue;
|
|
2834
|
+
}
|
|
2835
|
+
const stmt = stripComment(raw).trim();
|
|
2836
|
+
const al = stmt.match(/^alias\s+([A-Za-z_][\w-]*)=/);
|
|
2837
|
+
if (al) shadowed.add(al[1]);
|
|
2838
|
+
const def = stmt.match(FN_DEF);
|
|
2839
|
+
if (!def) {
|
|
2840
|
+
top.push(raw);
|
|
2841
|
+
continue;
|
|
2842
|
+
}
|
|
2843
|
+
const name = def[1] ?? def[3];
|
|
2844
|
+
shadowed.add(name);
|
|
2845
|
+
let rest = (def[2] ?? def[4] ?? "").trim();
|
|
2846
|
+
if (rest === "" && /^\s*\{/.test(flat[i + 1] ?? "")) {
|
|
2847
|
+
i++;
|
|
2848
|
+
rest = flat[i].trim();
|
|
2849
|
+
}
|
|
2850
|
+
const body = [];
|
|
2851
|
+
if (!rest.startsWith("{")) {
|
|
2852
|
+
fns.set(name, body);
|
|
2853
|
+
continue;
|
|
2854
|
+
}
|
|
2855
|
+
rest = rest.slice(1);
|
|
2856
|
+
const after = (line, close2) => {
|
|
2857
|
+
const t = line.slice(0, close2);
|
|
2858
|
+
if (t.trim()) body.push(t);
|
|
2859
|
+
const remainder = line.slice(close2 + 1).replace(/^\s*;?\s*/, "");
|
|
2860
|
+
if (remainder.trim()) top.push(remainder);
|
|
2861
|
+
};
|
|
2862
|
+
let close = closingBrace(rest, 1);
|
|
2863
|
+
if (close !== -1) {
|
|
2864
|
+
after(rest, close);
|
|
2865
|
+
} else {
|
|
2866
|
+
if (rest.trim()) body.push(rest);
|
|
2867
|
+
let depth = 1 + braceDelta(rest);
|
|
2868
|
+
for (i++; i < flat.length; i++) {
|
|
2869
|
+
const line = isComment(flat[i]) ? "" : stripComment(flat[i]);
|
|
2870
|
+
close = closingBrace(line, depth);
|
|
2871
|
+
if (close !== -1) {
|
|
2872
|
+
after(line, close);
|
|
2873
|
+
break;
|
|
2874
|
+
}
|
|
2875
|
+
depth += braceDelta(line);
|
|
2876
|
+
body.push(flat[i]);
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
fns.set(name, body);
|
|
2880
|
+
}
|
|
2881
|
+
return { top, fns, shadowed, pipefail, errexit };
|
|
2882
|
+
}
|
|
2883
|
+
var TRANSPARENT = /^(?:fi|done|esac|\}|\)|;;|then|do|else|exit\s+"?\$\??"?|exit\s+"?\$\{\?\}"?)\s*;?$/;
|
|
2884
|
+
function statusAfterFailure(seg, vars) {
|
|
2885
|
+
const inner = seg.trim().replace(/^[{(]\s*/, "").replace(/\s*[})]\s*;?\s*$/, "");
|
|
2886
|
+
let status = "fail";
|
|
2887
|
+
for (const piece of inner.split(/;|&&|\|\||\|/)) {
|
|
2888
|
+
const w = piece.trim().replace(PREFIX, "").split(/\s+/);
|
|
2889
|
+
switch (w[0]) {
|
|
2890
|
+
case "":
|
|
2891
|
+
break;
|
|
2892
|
+
case "true":
|
|
2893
|
+
case ":":
|
|
2894
|
+
status = "ok";
|
|
2895
|
+
break;
|
|
2896
|
+
case "false":
|
|
2897
|
+
status = "fail";
|
|
2898
|
+
break;
|
|
2899
|
+
case "kill":
|
|
2900
|
+
status = w[1] === "-0" ? "ok" : "exit";
|
|
2901
|
+
break;
|
|
2902
|
+
case "exit":
|
|
2903
|
+
case "return": {
|
|
2904
|
+
const arg = unq(w[1] ?? "");
|
|
2905
|
+
if (arg === "" || arg === "$?" || arg === "${?}") return status === "ok" ? "ok" : "exit";
|
|
2906
|
+
if (/^0+$/.test(arg)) return "ok";
|
|
2907
|
+
if (/^[1-9]\d*$/.test(arg)) return "exit";
|
|
2908
|
+
const v = arg.match(/^\$\{?([A-Za-z_]\w*)\}?$/);
|
|
2909
|
+
const known = v ? vars.get(v[1]) : void 0;
|
|
2910
|
+
return known !== void 0 && /^0+$/.test(known) ? "ok" : "exit";
|
|
2911
|
+
}
|
|
2912
|
+
default:
|
|
2913
|
+
status = "ok";
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
return status;
|
|
2917
|
+
}
|
|
2918
|
+
function constStatus(seg) {
|
|
2919
|
+
const s = seg.trim().replace(/^!\s+/, "");
|
|
2920
|
+
if (alwaysTrue(s)) return "ok";
|
|
2921
|
+
if (alwaysFalse(s)) return "fail";
|
|
2922
|
+
return "unknown";
|
|
2923
|
+
}
|
|
2924
|
+
function reaches(segs, ops, i) {
|
|
2925
|
+
let status = "ok";
|
|
2926
|
+
let certain = true;
|
|
2927
|
+
for (let j = 0; j < i; j++) {
|
|
2928
|
+
const runs = j === 0 ? true : ops[j - 1] === "&&" ? status !== "fail" : ops[j - 1] === "||" ? status !== "ok" : true;
|
|
2929
|
+
if (!runs) continue;
|
|
2930
|
+
const s = constStatus(segs[j]);
|
|
2931
|
+
if (s === "unknown" && (ops[j] === "&&" || ops[j] === "||")) certain = false;
|
|
2932
|
+
if (ops[j] === ";" || ops[j] === "|") {
|
|
2933
|
+
status = "ok";
|
|
2934
|
+
certain = certain && true;
|
|
2935
|
+
continue;
|
|
2936
|
+
}
|
|
2937
|
+
status = s;
|
|
2938
|
+
}
|
|
2939
|
+
const gate = i === 0 ? true : ops[i - 1] === "&&" ? status !== "fail" : ops[i - 1] === "||" ? status !== "ok" : true;
|
|
2940
|
+
if (!gate) return "no";
|
|
2941
|
+
if (i === 0 || ops[i - 1] === ";" || ops[i - 1] === "|") return "yes";
|
|
2942
|
+
if (!certain || status === "unknown") return "maybe";
|
|
2943
|
+
return "yes";
|
|
2944
|
+
}
|
|
2945
|
+
function literal(arg, vars) {
|
|
2946
|
+
const a = unq(arg.trim());
|
|
2947
|
+
if (/[`]|\$\(/.test(a)) return null;
|
|
2948
|
+
let unknown = false;
|
|
2949
|
+
const r = a.replace(/\$\{?([A-Za-z_]\w*)\}?/g, (_, n) => {
|
|
2950
|
+
const v = vars.get(n);
|
|
2951
|
+
if (v === void 0) unknown = true;
|
|
2952
|
+
return v ?? "";
|
|
2953
|
+
});
|
|
2954
|
+
return unknown ? null : r;
|
|
2955
|
+
}
|
|
2956
|
+
function cdStaysHome(arg, vars) {
|
|
2957
|
+
const a = arg.trim();
|
|
2958
|
+
if (/rev-parse\s+--show-toplevel/.test(a)) return true;
|
|
2959
|
+
const lit = literal(a, vars);
|
|
2960
|
+
if (lit === null) return null;
|
|
2961
|
+
return lit === "" || lit === "." || lit === "./";
|
|
2962
|
+
}
|
|
2963
|
+
function walk(lines, st, topIdx, depth) {
|
|
2964
|
+
const frames = [{ dead: false, exhausted: false, kind: "base" }];
|
|
2248
2965
|
const dead = () => frames.some((f) => f.dead);
|
|
2249
|
-
|
|
2966
|
+
const conditional = () => frames.length > 1;
|
|
2967
|
+
let exits = false;
|
|
2968
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2969
|
+
const raw = lines[i];
|
|
2970
|
+
const idx = topIdx(i);
|
|
2250
2971
|
if (isComment(raw)) {
|
|
2251
2972
|
const id = checkIdentity(raw.replace(/^\s*#+\s*/, ""));
|
|
2252
|
-
if (id)
|
|
2973
|
+
if (id) st.out.push({ identity: id, line: raw, state: "comment", idx, errexit: st.errexit, passthrough: false });
|
|
2253
2974
|
continue;
|
|
2254
2975
|
}
|
|
2255
|
-
|
|
2976
|
+
let stmt = stripComment(raw).trim();
|
|
2256
2977
|
if (!stmt) continue;
|
|
2257
|
-
const ifm = stmt.match(/^(if|elif)\s+(
|
|
2978
|
+
const ifm = stmt.match(/^(if|elif)\s+(.+)$/);
|
|
2258
2979
|
if (ifm) {
|
|
2259
|
-
|
|
2260
|
-
|
|
2980
|
+
const cond = ifm[2].replace(/;\s*$/, "");
|
|
2981
|
+
const cid = checkIdentity(cond.replace(/^!\s+/, ""));
|
|
2982
|
+
if (cid) st.out.push({ identity: cid, line: raw, state: dead() ? "unreachable" : "neutered", why: "used as an `if` condition \u2014 its failure selects a branch instead of failing the hook", idx, errexit: st.errexit, passthrough: false });
|
|
2983
|
+
if (ifm[1] === "if") frames.push({ dead: alwaysFalse(cond), exhausted: alwaysTrue(cond), kind: "if" });
|
|
2984
|
+
else if (frames.length > 1) {
|
|
2985
|
+
const f = frames[frames.length - 1];
|
|
2986
|
+
f.dead = f.exhausted || alwaysFalse(cond);
|
|
2987
|
+
f.exhausted = f.exhausted || alwaysTrue(cond);
|
|
2988
|
+
}
|
|
2261
2989
|
continue;
|
|
2262
2990
|
}
|
|
2263
2991
|
if (/^else\b/.test(stmt)) {
|
|
2264
|
-
if (frames.length > 1)
|
|
2992
|
+
if (frames.length > 1) {
|
|
2993
|
+
const f = frames[frames.length - 1];
|
|
2994
|
+
f.dead = f.exhausted;
|
|
2995
|
+
}
|
|
2265
2996
|
continue;
|
|
2266
2997
|
}
|
|
2267
|
-
if (/^fi\b/.test(stmt)) {
|
|
2998
|
+
if (/^(?:fi|done|esac)\b/.test(stmt)) {
|
|
2268
2999
|
if (frames.length > 1) frames.pop();
|
|
2269
3000
|
continue;
|
|
2270
3001
|
}
|
|
3002
|
+
if (/^(?:then|do)\b/.test(stmt)) continue;
|
|
3003
|
+
const loop = stmt.match(/^(while|until)\s+(.+)$/);
|
|
3004
|
+
if (loop) {
|
|
3005
|
+
const cond = loop[2].replace(/;\s*$/, "");
|
|
3006
|
+
frames.push({ dead: loop[1] === "while" ? alwaysFalse(cond) : alwaysTrue(cond), exhausted: false, kind: "loop" });
|
|
3007
|
+
continue;
|
|
3008
|
+
}
|
|
3009
|
+
const forl = stmt.match(/^for\s+[A-Za-z_]\w*(?:\s+in\b(.*))?$/);
|
|
3010
|
+
if (forl) {
|
|
3011
|
+
const list2 = (forl[1] ?? "$@").replace(/;\s*$/, "").trim();
|
|
3012
|
+
frames.push({ dead: forl[1] !== void 0 && (list2 === "" || /^(?:""|'')$/.test(list2)), exhausted: false, kind: "loop" });
|
|
3013
|
+
continue;
|
|
3014
|
+
}
|
|
3015
|
+
const casem = stmt.match(/^case\s+(\S+)\s+in\b/);
|
|
3016
|
+
if (casem) {
|
|
3017
|
+
frames.push({ dead: false, exhausted: false, kind: "case", word: unq(casem[1]) });
|
|
3018
|
+
continue;
|
|
3019
|
+
}
|
|
3020
|
+
const pat = frames[frames.length - 1].kind === "case" ? stmt.match(/^\(?([^()]+)\)\s*(.*)$/) : null;
|
|
3021
|
+
if (pat) {
|
|
3022
|
+
const f = frames[frames.length - 1];
|
|
3023
|
+
const word = f.word ?? "";
|
|
3024
|
+
const patterns = pat[1].split("|").map((p) => unq(p.trim()));
|
|
3025
|
+
const may = /[$`]/.test(word) || patterns.some((p) => p === "*" || /[$`*?[]/.test(p) || p === word);
|
|
3026
|
+
f.dead = !may;
|
|
3027
|
+
stmt = pat[2].trim();
|
|
3028
|
+
if (!stmt) continue;
|
|
3029
|
+
}
|
|
3030
|
+
if (/^;;$/.test(stmt)) continue;
|
|
3031
|
+
if (/^[{}()]$/.test(stmt)) continue;
|
|
3032
|
+
const bg = /(?<!&)&\s*$/.test(stmt) && !/&&\s*$/.test(stmt);
|
|
3033
|
+
if (bg) stmt = stmt.replace(/\s*&\s*$/, "");
|
|
2271
3034
|
const { segs, ops } = split(stmt);
|
|
2272
|
-
for (let
|
|
2273
|
-
|
|
3035
|
+
for (let s = 0; s < segs.length; s++) {
|
|
3036
|
+
let seg = segs[s].trim();
|
|
3037
|
+
const inverted = /^!\s+/.test(seg);
|
|
3038
|
+
seg = seg.replace(/^!\s+/, "");
|
|
3039
|
+
const execs = /^exec\s+/.test(seg) && !/^exec\s+(?:\d*[<>]|&>)/.test(seg);
|
|
3040
|
+
seg = seg.replace(PREFIX, "");
|
|
3041
|
+
const reach = dead() ? "no" : reaches(segs, ops, s);
|
|
3042
|
+
const first = seg.split(/\s+/)[0] ?? "";
|
|
3043
|
+
if (reach !== "no") {
|
|
3044
|
+
const certain = reach === "yes" && !conditional();
|
|
3045
|
+
const set = seg.match(/^set\s+(.+)$/);
|
|
3046
|
+
if (set) {
|
|
3047
|
+
const toks = set[1].split(/\s+/);
|
|
3048
|
+
for (let t = 0; t < toks.length; t++) {
|
|
3049
|
+
const m = toks[t].match(/^([-+])([a-zA-Z]+)$/);
|
|
3050
|
+
if (!m) continue;
|
|
3051
|
+
const on = m[1] === "-";
|
|
3052
|
+
const flip = (cur) => on ? certain ? true : cur : false;
|
|
3053
|
+
if (m[2].includes("e")) st.errexit = flip(st.errexit);
|
|
3054
|
+
if (m[2].includes("o")) {
|
|
3055
|
+
const name = toks[++t];
|
|
3056
|
+
if (name === "errexit") st.errexit = flip(st.errexit);
|
|
3057
|
+
if (name === "pipefail") st.pipefail = flip(st.pipefail);
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
const trap = seg.match(/^trap\s+(?:'([^']*)'|"([^"]*)"|(\S+))\s+(.+)$/);
|
|
3062
|
+
if (trap) {
|
|
3063
|
+
const action = trap[1] ?? trap[2] ?? trap[3];
|
|
3064
|
+
const sigs = trap[4].split(/\s+/);
|
|
3065
|
+
if (sigs.some((x) => /^(?:EXIT|ERR|0|SIGERR)$/i.test(x)) && /(?:^|[;\s])(?:exit(?:\s+0+)?|true|:)\s*(?:;|$)/.test(action)) st.traps.push(idx);
|
|
3066
|
+
}
|
|
3067
|
+
const asg = seg.match(/^(?:export\s+|readonly\s+)?([A-Za-z_]\w*)=(.*)$/);
|
|
3068
|
+
if (asg) {
|
|
3069
|
+
const v = certain ? literal(asg[2], st.vars) : null;
|
|
3070
|
+
if (v === null) st.vars.delete(asg[1]);
|
|
3071
|
+
else st.vars.set(asg[1], v);
|
|
3072
|
+
}
|
|
3073
|
+
if (first === "cd") {
|
|
3074
|
+
const home = cdStaysHome(seg.slice(2), st.vars);
|
|
3075
|
+
if (home === false) st.cdAway = true;
|
|
3076
|
+
else if (home === true && certain) st.cdAway = false;
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
2274
3079
|
const id = checkIdentity(seg);
|
|
2275
3080
|
if (!id) {
|
|
2276
|
-
if (
|
|
3081
|
+
if (reach === "yes") {
|
|
3082
|
+
if (EXIT_ANY.test(seg)) {
|
|
3083
|
+
if (!conditional()) {
|
|
3084
|
+
frames[0].dead = true;
|
|
3085
|
+
if (/^exit\b/.test(seg)) exits = true;
|
|
3086
|
+
} else if (/^exit\b/.test(seg) && depth > 0) {
|
|
3087
|
+
}
|
|
3088
|
+
} else if (execs && !conditional()) {
|
|
3089
|
+
frames[0].dead = true;
|
|
3090
|
+
} else if (st.fns.has(first) && reach === "yes") {
|
|
3091
|
+
const start = st.out.length;
|
|
3092
|
+
const body = st.fns.get(first) ?? [];
|
|
3093
|
+
const sub = { ...st, out: st.out };
|
|
3094
|
+
const fnExits = depth < 6 && walk(body, sub, () => idx, depth + 1);
|
|
3095
|
+
st.errexit = sub.errexit;
|
|
3096
|
+
st.pipefail = sub.pipefail;
|
|
3097
|
+
st.cdAway = sub.cdAway;
|
|
3098
|
+
const verdict2 = chainVerdict(segs, ops, s, st, inverted, bg);
|
|
3099
|
+
for (let k = start; k < st.out.length; k++) {
|
|
3100
|
+
const inv = st.out[k];
|
|
3101
|
+
if (inv.state === "live" && verdict2.state !== "live") {
|
|
3102
|
+
inv.state = verdict2.state;
|
|
3103
|
+
inv.why = verdict2.why;
|
|
3104
|
+
}
|
|
3105
|
+
if (inv.state === "live") inv.passthrough = inv.passthrough && verdict2.passthrough;
|
|
3106
|
+
}
|
|
3107
|
+
if (fnExits && !conditional()) {
|
|
3108
|
+
frames[0].dead = true;
|
|
3109
|
+
exits = true;
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
} else if (reach === "maybe" && st.fns.has(first)) {
|
|
3113
|
+
const start = st.out.length;
|
|
3114
|
+
const sub = { ...st, out: st.out };
|
|
3115
|
+
if (depth < 6) walk(st.fns.get(first) ?? [], sub, () => idx, depth + 1);
|
|
3116
|
+
const verdict2 = chainVerdict(segs, ops, s, st, inverted, bg);
|
|
3117
|
+
for (let k = start; k < st.out.length; k++) {
|
|
3118
|
+
const inv = st.out[k];
|
|
3119
|
+
if (inv.state === "live" && verdict2.state !== "live") {
|
|
3120
|
+
inv.state = verdict2.state;
|
|
3121
|
+
inv.why = verdict2.why;
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
2277
3125
|
continue;
|
|
2278
3126
|
}
|
|
2279
|
-
let state =
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
3127
|
+
let state = "live";
|
|
3128
|
+
let why;
|
|
3129
|
+
let passthrough = false;
|
|
3130
|
+
if (reach === "no") {
|
|
3131
|
+
state = "unreachable";
|
|
3132
|
+
why = dead() ? "behind an always-false guard, an early exit, or a function body that is never called" : "behind a constant condition";
|
|
3133
|
+
} else {
|
|
3134
|
+
const v = chainVerdict(segs, ops, s, st, inverted, bg);
|
|
3135
|
+
state = v.state;
|
|
3136
|
+
why = v.why;
|
|
3137
|
+
passthrough = v.passthrough;
|
|
3138
|
+
if (state === "live" && /^tamperward\b/.test(id) && st.cdAway) {
|
|
3139
|
+
state = "neutered";
|
|
3140
|
+
why = "runs from another directory after a `cd` away from the repository root";
|
|
3141
|
+
}
|
|
3142
|
+
if (state === "live") {
|
|
3143
|
+
const words2 = [first, "tamperward"].filter((w) => w && st.shadowed.has(w));
|
|
3144
|
+
if (words2.length) {
|
|
3145
|
+
state = "neutered";
|
|
3146
|
+
why = `\`${words2[0]}\` is redefined by a function or alias in this script`;
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
2289
3149
|
}
|
|
2290
|
-
|
|
3150
|
+
const pin = pinOfSegment(seg);
|
|
3151
|
+
st.out.push({ identity: id, line: raw, state, why, idx, errexit: st.errexit, passthrough, ...pin !== null ? { pin } : {} });
|
|
2291
3152
|
}
|
|
2292
3153
|
}
|
|
2293
|
-
return
|
|
3154
|
+
return exits;
|
|
3155
|
+
}
|
|
3156
|
+
function chainVerdict(segs, ops, i, st, inverted, bg) {
|
|
3157
|
+
if (inverted) return { state: "neutered", why: "its status is inverted by `!`", passthrough: false };
|
|
3158
|
+
if (bg) return { state: "neutered", why: "run in the background \u2014 the hook does not wait for its status", passthrough: false };
|
|
3159
|
+
let cur = "fail";
|
|
3160
|
+
for (let j = i; j < ops.length; j++) {
|
|
3161
|
+
const op = ops[j];
|
|
3162
|
+
const next = segs[j + 1];
|
|
3163
|
+
if (op === "|") {
|
|
3164
|
+
if (!st.pipefail) return { state: "neutered", why: "piped into another command without `pipefail`", passthrough: false };
|
|
3165
|
+
continue;
|
|
3166
|
+
}
|
|
3167
|
+
if (cur === "exit") break;
|
|
3168
|
+
if (op === "||") {
|
|
3169
|
+
if (cur === "fail") cur = statusAfterFailure(next, st.vars);
|
|
3170
|
+
} else if (op === "&&") {
|
|
3171
|
+
if (cur === "ok") cur = statusAfterFailure(next, st.vars);
|
|
3172
|
+
} else if (op === ";") {
|
|
3173
|
+
const rest = segs.slice(j + 1).map((x) => x.trim());
|
|
3174
|
+
if (st.errexit && cur === "fail") {
|
|
3175
|
+
if (rest.some((x) => /^(?:true|:)$/.test(x) || EXIT_ZERO.test(x))) return { state: "neutered", why: "followed by a passing statement in the same line", passthrough: false };
|
|
3176
|
+
return { state: "live", passthrough: false };
|
|
3177
|
+
}
|
|
3178
|
+
if (rest.every((x) => TRANSPARENT.test(x))) return { state: cur === "fail" ? "live" : "neutered", why: cur === "fail" ? void 0 : "the `||` chain after it ends in success", passthrough: cur === "fail" };
|
|
3179
|
+
return { state: "neutered", why: "followed by other statements whose status replaces it (no `set -e`)", passthrough: false };
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
if (cur === "ok") return { state: "neutered", why: "the `||` chain after it ends in success", passthrough: false };
|
|
3183
|
+
if (cur === "exit") return { state: "live", passthrough: false };
|
|
3184
|
+
return { state: "live", passthrough: i === ops.length || ops.slice(i).every((o) => o === "&&" || o === "||") };
|
|
3185
|
+
}
|
|
3186
|
+
function invocations(lines, opts = {}) {
|
|
3187
|
+
const prep = prepare(lines, opts);
|
|
3188
|
+
const st = {
|
|
3189
|
+
errexit: prep.errexit,
|
|
3190
|
+
pipefail: prep.pipefail,
|
|
3191
|
+
cdAway: false,
|
|
3192
|
+
vars: /* @__PURE__ */ new Map(),
|
|
3193
|
+
fns: prep.fns,
|
|
3194
|
+
shadowed: prep.shadowed,
|
|
3195
|
+
traps: [],
|
|
3196
|
+
out: [],
|
|
3197
|
+
lastReal: -1
|
|
3198
|
+
};
|
|
3199
|
+
for (let i = 0; i < prep.top.length; i++) {
|
|
3200
|
+
const t = stripComment(prep.top[i]).trim();
|
|
3201
|
+
if (t && !isComment(prep.top[i]) && !TRANSPARENT.test(t)) st.lastReal = i;
|
|
3202
|
+
}
|
|
3203
|
+
walk(prep.top, st, (i) => i, 0);
|
|
3204
|
+
const seen = new Set(st.out.map((o) => o.line));
|
|
3205
|
+
for (const [, body] of prep.fns) {
|
|
3206
|
+
const sub = { ...st, out: [], traps: [] };
|
|
3207
|
+
walk(body, sub, () => -1, 1);
|
|
3208
|
+
for (const inv of sub.out) if (!seen.has(inv.line) && inv.state !== "comment") st.out.push({ ...inv, state: "unreachable", why: "defined in a function that is never called" });
|
|
3209
|
+
}
|
|
3210
|
+
return st.out.map((inv) => {
|
|
3211
|
+
if (inv.state !== "live") return strip(inv);
|
|
3212
|
+
if (st.traps.some((t) => t < inv.idx) || !inv.errexit && st.traps.some((t) => t > inv.idx)) {
|
|
3213
|
+
return strip({ ...inv, state: "neutered", why: "a `trap` on EXIT/ERR exits 0 whatever the gate returned" });
|
|
3214
|
+
}
|
|
3215
|
+
if (inv.passthrough && !inv.errexit && inv.idx !== -1 && inv.idx < st.lastReal) {
|
|
3216
|
+
return strip({ ...inv, state: "neutered", why: "without `set -e` its failure does not fail the hook \u2014 other statements run after it" });
|
|
3217
|
+
}
|
|
3218
|
+
return strip(inv);
|
|
3219
|
+
});
|
|
2294
3220
|
}
|
|
2295
|
-
|
|
3221
|
+
var strip = (inv) => {
|
|
3222
|
+
const { idx: _i, errexit: _e, passthrough: _p, ...rest } = inv;
|
|
3223
|
+
return rest;
|
|
3224
|
+
};
|
|
3225
|
+
function scriptWeakening(before, after, opts = {}) {
|
|
3226
|
+
const beforeInv = invocations(before, opts);
|
|
3227
|
+
const afterInv = invocations(after, opts);
|
|
2296
3228
|
const live = (inv) => new Set(inv.filter((i) => i.state === "live").map((i) => i.identity));
|
|
2297
|
-
const bl = live(
|
|
2298
|
-
const afterInv = invocations(after);
|
|
3229
|
+
const bl = live(beforeInv);
|
|
2299
3230
|
const al = live(afterInv);
|
|
2300
3231
|
const out2 = [];
|
|
2301
3232
|
for (const id of bl) {
|
|
2302
3233
|
if (al.has(id)) continue;
|
|
2303
|
-
const trace = afterInv.find((i) => i.identity === id);
|
|
2304
|
-
const how = trace?.state === "neutered" ?
|
|
2305
|
-
const evidence = (trace?.line ?? before.find((l) => checkIdentity(stripComment(l).trim().replace(PREFIX, "")) === id) ?? id).trim();
|
|
3234
|
+
const trace = afterInv.find((i) => i.identity === id) ?? afterInv.find((i) => i.identity.split(" --cwd ")[0] === id.split(" --cwd ")[0]);
|
|
3235
|
+
const how = trace?.state === "neutered" ? `neutralised in place (${trace.why ?? "its failure can no longer fail the hook"})` : trace?.state === "unreachable" ? `made unreachable (${trace.why ?? "behind an always-false guard or an early exit"})` : trace?.state === "comment" ? "commented out" : trace && trace.identity !== id ? `replaced by \`${trace.identity}\`` : "removed";
|
|
3236
|
+
const evidence = (trace?.line ?? before.find((l) => checkIdentity(stripComment(l).trim().replace(/^!\s+/, "").replace(PREFIX, "")) === id) ?? id).trim();
|
|
2306
3237
|
out2.push({ reason: `the check invocation \`${id}\` was ${how}`, evidence });
|
|
2307
3238
|
}
|
|
3239
|
+
const floor = beforeInv.filter((i) => i.state === "live" && i.pin && PLAIN_SEMVER.test(i.pin)).map((i) => i.pin).sort((a, b) => compareVersions(a, b) ?? 0)[0];
|
|
3240
|
+
if (floor) {
|
|
3241
|
+
for (const inv of afterInv) {
|
|
3242
|
+
if (inv.state !== "live" || !inv.pin || !PLAIN_SEMVER.test(inv.pin)) continue;
|
|
3243
|
+
if ((compareVersions(inv.pin, floor) ?? 0) < 0) out2.push({ reason: `the gate's pin was lowered from ${floor} to ${inv.pin}`, evidence: inv.line.trim() });
|
|
3244
|
+
}
|
|
3245
|
+
}
|
|
2308
3246
|
return out2;
|
|
2309
3247
|
}
|
|
3248
|
+
function shebangProblem(src) {
|
|
3249
|
+
const first = src.split("\n")[0] ?? "";
|
|
3250
|
+
if (!first.startsWith("#!")) return null;
|
|
3251
|
+
const body = first.slice(2).trim();
|
|
3252
|
+
const toks = body.split(/\s+/);
|
|
3253
|
+
let i = 0;
|
|
3254
|
+
if (/(?:^|\/)env$/.test(toks[0] ?? "")) {
|
|
3255
|
+
i = 1;
|
|
3256
|
+
while (i < toks.length && toks[i].startsWith("-")) i++;
|
|
3257
|
+
}
|
|
3258
|
+
const interp = (toks[i] ?? "").replace(/^.*\//, "");
|
|
3259
|
+
if (!/^(?:sh|bash|dash|zsh|ksh|ash|busybox)$/.test(interp)) return `the shebang runs \`${body}\`, not a shell`;
|
|
3260
|
+
if (toks.slice(i + 1).some((t) => /^-[a-zA-Z]*c/.test(t))) return `the shebang passes \`-c\` \u2014 the interpreter runs the argument, not the script`;
|
|
3261
|
+
return null;
|
|
3262
|
+
}
|
|
2310
3263
|
var isCodeowners = (path) => /(?:^|\/)CODEOWNERS$/.test(path);
|
|
2311
3264
|
var GATE_CRITICAL = [
|
|
2312
|
-
"/.github/workflows/",
|
|
3265
|
+
"/.github/workflows/tamperward.yml",
|
|
2313
3266
|
"/.tamperward.yml",
|
|
2314
3267
|
"/.github/CODEOWNERS",
|
|
2315
3268
|
"/CODEOWNERS",
|
|
2316
3269
|
"/docs/CODEOWNERS",
|
|
2317
|
-
"/.husky/",
|
|
3270
|
+
"/.husky/pre-commit",
|
|
2318
3271
|
"/.claude/settings.json",
|
|
2319
3272
|
"/.claude/settings.local.json",
|
|
2320
3273
|
"/.pre-commit-config.yaml",
|
|
2321
3274
|
"/lefthook.yml",
|
|
2322
3275
|
"/lefthook.yaml"
|
|
2323
3276
|
];
|
|
2324
|
-
function
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
if (p
|
|
2329
|
-
|
|
2330
|
-
|
|
3277
|
+
function codeownersMatcher(pattern) {
|
|
3278
|
+
let p = pattern.trim();
|
|
3279
|
+
if (p === "*" || p === "**") return () => true;
|
|
3280
|
+
let anchored = p.startsWith("/");
|
|
3281
|
+
if (anchored) p = p.slice(1);
|
|
3282
|
+
const dir = p.endsWith("/");
|
|
3283
|
+
p = p.replace(/\/+$/, "");
|
|
3284
|
+
if (p.includes("/")) anchored = true;
|
|
3285
|
+
if (p.startsWith("**/")) {
|
|
3286
|
+
anchored = false;
|
|
3287
|
+
p = p.slice(3);
|
|
3288
|
+
}
|
|
3289
|
+
let re = "";
|
|
3290
|
+
for (let i = 0; i < p.length; i++) {
|
|
3291
|
+
const ch = p[i];
|
|
3292
|
+
if (ch === "*") {
|
|
3293
|
+
if (p[i + 1] === "*") {
|
|
3294
|
+
re += ".*";
|
|
3295
|
+
i++;
|
|
3296
|
+
} else re += "[^/]*";
|
|
3297
|
+
} else if (ch === "?") re += "[^/]";
|
|
3298
|
+
else re += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
3299
|
+
}
|
|
3300
|
+
const body = (anchored ? "^/" : "(?:^|/)") + re;
|
|
3301
|
+
const exact = new RegExp(body + "$");
|
|
3302
|
+
const under = new RegExp(body + "/");
|
|
3303
|
+
return (file) => !dir && exact.test(file) || under.test(file);
|
|
2331
3304
|
}
|
|
2332
3305
|
function ownersOf(content, critical) {
|
|
2333
3306
|
let owners = null;
|
|
@@ -2335,13 +3308,20 @@ function ownersOf(content, critical) {
|
|
|
2335
3308
|
const t = line.trim();
|
|
2336
3309
|
if (!t || t.startsWith("#")) continue;
|
|
2337
3310
|
const [pattern, ...rest] = t.split(/\s+/);
|
|
2338
|
-
|
|
3311
|
+
let matches;
|
|
3312
|
+
try {
|
|
3313
|
+
matches = codeownersMatcher(pattern)(critical);
|
|
3314
|
+
} catch {
|
|
3315
|
+
matches = false;
|
|
3316
|
+
}
|
|
3317
|
+
if (matches) owners = rest.filter((o) => !o.startsWith("#"));
|
|
2339
3318
|
}
|
|
2340
3319
|
return owners;
|
|
2341
3320
|
}
|
|
2342
|
-
function codeownersWeakening(before, after) {
|
|
3321
|
+
function codeownersWeakening(before, after, extra = []) {
|
|
2343
3322
|
const out2 = [];
|
|
2344
|
-
|
|
3323
|
+
const files = [.../* @__PURE__ */ new Set([...GATE_CRITICAL, ...extra.map((f) => f.startsWith("/") ? f : "/" + f)])];
|
|
3324
|
+
for (const critical of files) {
|
|
2345
3325
|
const was = ownersOf(before, critical);
|
|
2346
3326
|
if (!was || was.length === 0) continue;
|
|
2347
3327
|
const now = ownersOf(after, critical);
|
|
@@ -2360,25 +3340,47 @@ function parseDoc(src) {
|
|
|
2360
3340
|
}
|
|
2361
3341
|
}
|
|
2362
3342
|
var isLefthook = (path) => /(?:^|\/)\.?lefthook(?:-local)?\.(?:ya?ml|toml|json)$/.test(path);
|
|
3343
|
+
var isLefthookLocal = (path) => /(?:^|\/)\.?lefthook-local\.(?:ya?ml|toml|json)$/.test(path);
|
|
2363
3344
|
var isPreCommitConfig = (path) => /(?:^|\/)\.pre-commit-config\.ya?ml$/.test(path);
|
|
2364
3345
|
var isPackageJson = (path) => /(?:^|\/)package\.json$/.test(path);
|
|
2365
3346
|
var GATE = /\btamperward\b/;
|
|
2366
3347
|
var str = (v) => typeof v === "string" ? v : Array.isArray(v) ? v.map(String).join(" ") : v == null ? "" : JSON.stringify(v);
|
|
3348
|
+
var list = (v) => Array.isArray(v) ? v.map(String) : typeof v === "string" ? v.split(/[\s,]+/).filter(Boolean) : [];
|
|
3349
|
+
function runsGate(command) {
|
|
3350
|
+
return invocations([command], { errexit: true }).some((i) => i.state === "live" && /^tamperward check\b/.test(i.identity));
|
|
3351
|
+
}
|
|
3352
|
+
function mergeDocs(base, over) {
|
|
3353
|
+
const out2 = { ...base };
|
|
3354
|
+
for (const [k, v] of Object.entries(over)) {
|
|
3355
|
+
const b = out2[k];
|
|
3356
|
+
out2[k] = isDoc(b) && isDoc(v) ? mergeDocs(b, v) : v;
|
|
3357
|
+
}
|
|
3358
|
+
return out2;
|
|
3359
|
+
}
|
|
3360
|
+
var off = (v) => v === void 0 || v === null || v === false;
|
|
2367
3361
|
function lefthookEntries(doc) {
|
|
2368
3362
|
const out2 = /* @__PURE__ */ new Map();
|
|
2369
3363
|
for (const [section, v] of Object.entries(doc)) {
|
|
2370
3364
|
if (!isDoc(v)) continue;
|
|
3365
|
+
const sectionExclude = list(v.exclude_tags);
|
|
2371
3366
|
for (const group of ["commands", "scripts"]) {
|
|
2372
3367
|
const g = v[group];
|
|
2373
3368
|
if (!isDoc(g)) continue;
|
|
2374
3369
|
for (const [name, cfg] of Object.entries(g)) {
|
|
2375
|
-
if (!isDoc(cfg)
|
|
3370
|
+
if (!isDoc(cfg)) continue;
|
|
3371
|
+
const run = cfg.run ?? cfg.runner;
|
|
3372
|
+
const named = GATE.test(name);
|
|
3373
|
+
if (!GATE.test(str(run)) && !named) continue;
|
|
3374
|
+
const tags = list(cfg.tags);
|
|
2376
3375
|
out2.set(`${section}.${group}.${name}`, {
|
|
3376
|
+
live: run === void 0 ? named : runsGate(str(run)),
|
|
2377
3377
|
skip: cfg.skip,
|
|
3378
|
+
only: cfg.only,
|
|
2378
3379
|
sectionSkip: v.skip,
|
|
3380
|
+
sectionOnly: v.only,
|
|
2379
3381
|
glob: str(cfg.glob),
|
|
2380
3382
|
exclude: str(cfg.exclude),
|
|
2381
|
-
|
|
3383
|
+
excludedByTag: tags.filter((t) => sectionExclude.includes(t))
|
|
2382
3384
|
});
|
|
2383
3385
|
}
|
|
2384
3386
|
}
|
|
@@ -2390,33 +3392,46 @@ function lefthookWeakening(before, after) {
|
|
|
2390
3392
|
const b = lefthookEntries(before);
|
|
2391
3393
|
const a = lefthookEntries(after);
|
|
2392
3394
|
for (const [key2, was] of b) {
|
|
3395
|
+
if (!was.live) continue;
|
|
2393
3396
|
const now = a.get(key2);
|
|
2394
3397
|
if (!now) {
|
|
2395
3398
|
out2.push(`lefthook entry ${key2} (the gate) was removed`);
|
|
2396
3399
|
continue;
|
|
2397
3400
|
}
|
|
2398
|
-
if (now.
|
|
2399
|
-
if (now.
|
|
3401
|
+
if (!now.live) out2.push(`lefthook entry ${key2} no longer runs \`tamperward check\` live`);
|
|
3402
|
+
if (!off(now.skip) && off(was.skip)) out2.push(`lefthook entry ${key2} (the gate) is now \`skip: ${str(now.skip)}\``);
|
|
3403
|
+
if (!off(now.sectionSkip) && off(was.sectionSkip)) out2.push(`the lefthook hook carrying ${key2} (the gate) is now \`skip: ${str(now.sectionSkip)}\``);
|
|
3404
|
+
if (!off(now.only) && str(now.only) !== str(was.only)) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`only\` (${str(now.only)})`);
|
|
3405
|
+
if (!off(now.sectionOnly) && str(now.sectionOnly) !== str(was.sectionOnly)) out2.push(`the lefthook hook carrying ${key2} (the gate) gained or changed \`only\` (${str(now.sectionOnly)})`);
|
|
2400
3406
|
if (now.glob !== was.glob && now.glob) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`glob\` (${now.glob}) \u2014 it no longer runs on every commit`);
|
|
2401
3407
|
if (now.exclude !== was.exclude && now.exclude) out2.push(`lefthook entry ${key2} (the gate) gained or changed \`exclude\` (${now.exclude})`);
|
|
2402
|
-
if (now.
|
|
3408
|
+
if (now.excludedByTag.length && !was.excludedByTag.length) out2.push(`lefthook entry ${key2} (the gate) is tagged ${now.excludedByTag.join(", ")}, which the hook's \`exclude_tags\` switches off`);
|
|
2403
3409
|
}
|
|
2404
3410
|
return out2;
|
|
2405
3411
|
}
|
|
2406
|
-
var
|
|
3412
|
+
var STAGE_ALIAS = { commit: "pre-commit", push: "pre-push", merge_commit: "pre-merge-commit" };
|
|
3413
|
+
var GATE_STAGES = /* @__PURE__ */ new Set(["pre-commit", "pre-push"]);
|
|
3414
|
+
var ALL_STAGES = ["pre-commit", "pre-merge-commit", "pre-push", "prepare-commit-msg", "commit-msg", "post-checkout", "post-commit", "post-merge", "post-rewrite", "manual"];
|
|
3415
|
+
var normStage = (s) => STAGE_ALIAS[s] ?? s;
|
|
2407
3416
|
function preCommitEntries(doc) {
|
|
2408
3417
|
const out2 = /* @__PURE__ */ new Map();
|
|
2409
3418
|
const repos = Array.isArray(doc.repos) ? doc.repos : [];
|
|
3419
|
+
const defaults = Array.isArray(doc.default_stages) ? doc.default_stages.map(String).map(normStage) : ALL_STAGES;
|
|
2410
3420
|
for (const repo of repos) {
|
|
2411
3421
|
if (!isDoc(repo)) continue;
|
|
2412
3422
|
const hooks = Array.isArray(repo.hooks) ? repo.hooks : [];
|
|
2413
3423
|
for (const h of hooks) {
|
|
2414
3424
|
if (!isDoc(h)) continue;
|
|
2415
3425
|
if (!GATE.test(`${str(repo.repo)} ${str(h.id)} ${str(h.entry)} ${str(h.name)}`)) continue;
|
|
3426
|
+
const live = h.entry !== void 0 ? runsGate(str(h.entry)) : GATE.test(`${str(repo.repo)} ${str(h.id)}`);
|
|
2416
3427
|
out2.set(`${str(repo.repo)}:${str(h.id)}`, {
|
|
2417
|
-
|
|
3428
|
+
live,
|
|
3429
|
+
stages: Array.isArray(h.stages) ? h.stages.map(String).map(normStage) : defaults,
|
|
3430
|
+
explicitStages: Array.isArray(h.stages),
|
|
2418
3431
|
exclude: str(h.exclude),
|
|
2419
|
-
files: str(h.files)
|
|
3432
|
+
files: str(h.files),
|
|
3433
|
+
types: [str(h.types), str(h.types_or), str(h.exclude_types)].join(" ").trim(),
|
|
3434
|
+
alwaysRun: h.always_run === true
|
|
2420
3435
|
});
|
|
2421
3436
|
}
|
|
2422
3437
|
}
|
|
@@ -2427,28 +3442,45 @@ function preCommitWeakening(before, after) {
|
|
|
2427
3442
|
const b = preCommitEntries(before);
|
|
2428
3443
|
const a = preCommitEntries(after);
|
|
2429
3444
|
for (const [key2, was] of b) {
|
|
3445
|
+
if (!was.live) continue;
|
|
2430
3446
|
const now = a.get(key2);
|
|
2431
3447
|
if (!now) {
|
|
2432
3448
|
out2.push(`pre-commit hook ${key2} (the gate) was removed`);
|
|
2433
3449
|
continue;
|
|
2434
3450
|
}
|
|
2435
|
-
|
|
2436
|
-
|
|
3451
|
+
if (!now.live) out2.push(`pre-commit hook ${key2} no longer runs \`tamperward check\` live`);
|
|
3452
|
+
const lost = was.stages.filter((s) => GATE_STAGES.has(s) && !now.stages.includes(s));
|
|
3453
|
+
if (lost.length && !now.stages.some((s) => GATE_STAGES.has(s))) {
|
|
2437
3454
|
out2.push(`pre-commit hook ${key2} (the gate) now runs only at stages [${now.stages.join(", ")}] \u2014 no commit or push triggers it`);
|
|
2438
|
-
} else if (
|
|
2439
|
-
|
|
2440
|
-
if (lost.length) out2.push(`pre-commit hook ${key2} (the gate) no longer runs at stage(s) ${lost.join(", ")}`);
|
|
3455
|
+
} else if (lost.length) {
|
|
3456
|
+
out2.push(`pre-commit hook ${key2} (the gate) no longer runs at stage(s) ${lost.join(", ")}${now.explicitStages ? "" : " (default_stages changed)"}`);
|
|
2441
3457
|
}
|
|
2442
3458
|
if (now.exclude !== was.exclude && now.exclude) out2.push(`pre-commit hook ${key2} (the gate) gained or changed \`exclude\` (${now.exclude})`);
|
|
2443
3459
|
if (now.files !== was.files && now.files) out2.push(`pre-commit hook ${key2} (the gate) gained or changed \`files\` (${now.files})`);
|
|
3460
|
+
if (now.types !== was.types && now.types && !now.alwaysRun) out2.push(`pre-commit hook ${key2} (the gate) is scoped by file type (${now.types}) without \`always_run\``);
|
|
2444
3461
|
}
|
|
2445
3462
|
const topB = str(before.exclude);
|
|
2446
3463
|
const topA = str(after.exclude);
|
|
2447
3464
|
if (topA && topA !== topB && b.size > 0) out2.push(`a top-level pre-commit \`exclude\` was added or changed (${topA}) \u2014 it scopes every hook, the gate included`);
|
|
2448
3465
|
return out2;
|
|
2449
3466
|
}
|
|
2450
|
-
var INSTALLER = /\b(?:husky|lefthook|simple-git-hooks|pre-commit)\b/;
|
|
2451
3467
|
var INSTALL_SCRIPTS = ["prepare", "postinstall", "install", "preinstall"];
|
|
3468
|
+
function installerOf(script) {
|
|
3469
|
+
for (const seg of script.split(/&&|\|\||;|\n/)) {
|
|
3470
|
+
const raw = seg.trim();
|
|
3471
|
+
if (!raw) continue;
|
|
3472
|
+
if (/^(?:HUSKY|LEFTHOOK)=0\b/.test(raw)) continue;
|
|
3473
|
+
const core = raw.replace(PREFIX, "").replace(RUNNER, "").replace(/\s+/g, " ");
|
|
3474
|
+
const m = core.match(/^(?:node\s+)?(?:\S*\/)?(husky|lefthook|simple-git-hooks|pre-commit)(?:\.js)?(?:\s+(\S+))?/);
|
|
3475
|
+
if (!m) continue;
|
|
3476
|
+
const [, tool, sub] = m;
|
|
3477
|
+
if (tool === "husky" && (sub === void 0 || sub === "install" || sub.startsWith("-"))) return sub === "install" ? "husky install" : "husky";
|
|
3478
|
+
if (tool === "lefthook" && sub === "install") return "lefthook install";
|
|
3479
|
+
if (tool === "pre-commit" && sub === "install") return "pre-commit install";
|
|
3480
|
+
if (tool === "simple-git-hooks" && (sub === void 0 || sub.startsWith("-"))) return "simple-git-hooks";
|
|
3481
|
+
}
|
|
3482
|
+
return null;
|
|
3483
|
+
}
|
|
2452
3484
|
function packageJsonWeakening(before, after) {
|
|
2453
3485
|
let b;
|
|
2454
3486
|
let a;
|
|
@@ -2459,13 +3491,14 @@ function packageJsonWeakening(before, after) {
|
|
|
2459
3491
|
return [];
|
|
2460
3492
|
}
|
|
2461
3493
|
if (!isDoc(b) || !isDoc(a)) return [];
|
|
2462
|
-
const
|
|
3494
|
+
const installers = (d) => {
|
|
2463
3495
|
const sc = d.scripts;
|
|
2464
|
-
|
|
3496
|
+
if (!isDoc(sc)) return [];
|
|
3497
|
+
return INSTALL_SCRIPTS.map((k) => installerOf(str(sc[k]))).filter((x) => x !== null);
|
|
2465
3498
|
};
|
|
2466
|
-
const was =
|
|
3499
|
+
const was = installers(b)[0];
|
|
2467
3500
|
if (!was) return [];
|
|
2468
|
-
if (
|
|
3501
|
+
if (installers(a).length) return [];
|
|
2469
3502
|
return [`the install script that wires the git hooks (\`${was}\`) was removed from package.json scripts \u2014 a fresh checkout installs no hooks`];
|
|
2470
3503
|
}
|
|
2471
3504
|
var GIT_HOOKS = /* @__PURE__ */ new Set([
|
|
@@ -2492,7 +3525,7 @@ var GIT_HOOKS = /* @__PURE__ */ new Set([
|
|
|
2492
3525
|
]);
|
|
2493
3526
|
function hookIdentity(path) {
|
|
2494
3527
|
const base = path.split("/").pop() ?? "";
|
|
2495
|
-
if (isLefthook(path)) return "lefthook";
|
|
3528
|
+
if (isLefthook(path)) return isLefthookLocal(path) ? "lefthook-local" : "lefthook";
|
|
2496
3529
|
if (isPreCommitConfig(path)) return "pre-commit-config";
|
|
2497
3530
|
if (base === "CODEOWNERS") return "codeowners";
|
|
2498
3531
|
const husky = path.match(/(?:^|\/)\.husky\/([^/]+)$/);
|
|
@@ -2500,7 +3533,7 @@ function hookIdentity(path) {
|
|
|
2500
3533
|
return null;
|
|
2501
3534
|
}
|
|
2502
3535
|
var ownerExec = (mode) => (parseInt(mode.slice(-3).padStart(3, "0"), 8) & 64) !== 0;
|
|
2503
|
-
var SYMBOLIC = /^[ugoa]*([-+=]
|
|
3536
|
+
var SYMBOLIC = /^([ugoa]*)((?:[-+=][rwxXstugo]*)+)$/;
|
|
2504
3537
|
function chmodDropsExec(toks) {
|
|
2505
3538
|
const at = toks.findIndex((t) => t === "chmod" || t.endsWith("/chmod"));
|
|
2506
3539
|
if (at === -1) return false;
|
|
@@ -2513,31 +3546,113 @@ function chmodDropsExec(toks) {
|
|
|
2513
3546
|
for (const clause of tok.split(",")) {
|
|
2514
3547
|
const m = clause.match(SYMBOLIC);
|
|
2515
3548
|
if (!m) continue;
|
|
2516
|
-
const [,
|
|
2517
|
-
if (
|
|
2518
|
-
|
|
3549
|
+
const [, who, opsText] = m;
|
|
3550
|
+
if (who && !/[ua]/.test(who)) continue;
|
|
3551
|
+
let exec = null;
|
|
3552
|
+
for (const g of opsText.match(/[-+=][rwxXstugo]*/g) ?? []) {
|
|
3553
|
+
const op = g[0];
|
|
3554
|
+
const perms = g.slice(1);
|
|
3555
|
+
if (/[ugo]/.test(perms)) {
|
|
3556
|
+
exec = false;
|
|
3557
|
+
continue;
|
|
3558
|
+
}
|
|
3559
|
+
if (op === "-" && /[xX]/.test(perms)) exec = false;
|
|
3560
|
+
else if (op === "+" && /[xX]/.test(perms)) exec = true;
|
|
3561
|
+
else if (op === "=") exec = /[xX]/.test(perms);
|
|
3562
|
+
}
|
|
3563
|
+
if (exec === false) return true;
|
|
2519
3564
|
}
|
|
2520
3565
|
}
|
|
2521
3566
|
return false;
|
|
2522
3567
|
}
|
|
3568
|
+
function setfaclDropsExec(toks) {
|
|
3569
|
+
if (!toks.some((t) => /^-[a-zA-Z]*m|^--modify/.test(t))) return false;
|
|
3570
|
+
return toks.some((t) => t.split(",").some((e) => {
|
|
3571
|
+
const m = e.match(/^(?:u|user)::([rwxX-]*)$/);
|
|
3572
|
+
return !!m && !/[xX]/.test(m[1]);
|
|
3573
|
+
}));
|
|
3574
|
+
}
|
|
2523
3575
|
var WRAPPERS = /^(?:sudo|command|exec|time|nice|env|[A-Za-z_][A-Za-z0-9_]*=\S*)$/;
|
|
2524
|
-
var
|
|
2525
|
-
var
|
|
2526
|
-
|
|
3576
|
+
var INTERPRETERS = /* @__PURE__ */ new Set(["python", "python2", "python3", "node", "perl", "ruby", "php", "deno", "bun"]);
|
|
3577
|
+
var INLINE_FLAG = /^-(?:c|e|r|E|p|pe|pi|i|ne|ni|le)$|^--eval$|^--print$|^--exec$/;
|
|
3578
|
+
var EDITORS = /* @__PURE__ */ new Set(["ex", "ed", "vim", "vi", "nvim"]);
|
|
3579
|
+
var HOOK_BASENAMES = ["pre-commit", "pre-push", "commit-msg", "settings.json", "settings.local.json", ".tamperward.yml", "lefthook.yml", "lefthook.yaml", "lefthook-local.yml", ".pre-commit-config.yaml", "CODEOWNERS"];
|
|
3580
|
+
function nameGlob(pattern) {
|
|
3581
|
+
const re = new RegExp("^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$");
|
|
3582
|
+
return (n) => re.test(n);
|
|
3583
|
+
}
|
|
3584
|
+
function destinations(cmd, args, isDir) {
|
|
3585
|
+
const positional = [];
|
|
3586
|
+
let target = null;
|
|
3587
|
+
for (let i = 0; i < args.length; i++) {
|
|
3588
|
+
const a = args[i];
|
|
3589
|
+
if (a === "--") {
|
|
3590
|
+
positional.push(...args.slice(i + 1));
|
|
3591
|
+
break;
|
|
3592
|
+
}
|
|
3593
|
+
if (a === "-t" || a === "--target-directory") {
|
|
3594
|
+
target = args[++i] ?? null;
|
|
3595
|
+
continue;
|
|
3596
|
+
}
|
|
3597
|
+
const eq = a.match(/^--target-directory=(.*)$/);
|
|
3598
|
+
if (eq) {
|
|
3599
|
+
target = eq[1];
|
|
3600
|
+
continue;
|
|
3601
|
+
}
|
|
3602
|
+
if (cmd === "install" && /^-[mogS]$/.test(a) || cmd === "install" && /^--(?:mode|owner|group|suffix)$/.test(a) || cmd === "rsync" && /^(?:-e|--rsh|--exclude|--include|--files-from)$/.test(a)) {
|
|
3603
|
+
i++;
|
|
3604
|
+
continue;
|
|
3605
|
+
}
|
|
3606
|
+
if (a.startsWith("-")) continue;
|
|
3607
|
+
positional.push(a);
|
|
3608
|
+
}
|
|
3609
|
+
const base = (p) => p.replace(/\/+$/, "").split("/").pop() ?? p;
|
|
3610
|
+
if (target !== null) return positional.map((p) => `${target.replace(/\/+$/, "")}/${base(p)}`);
|
|
3611
|
+
if (positional.length < 2) return [];
|
|
3612
|
+
const dest = positional[positional.length - 1];
|
|
3613
|
+
const srcs = positional.slice(0, -1);
|
|
3614
|
+
if (dest.endsWith("/") || srcs.length > 1 || isDir(dest)) return [dest, ...srcs.map((s) => `${dest.replace(/\/+$/, "")}/${base(s)}`)];
|
|
3615
|
+
return [dest];
|
|
3616
|
+
}
|
|
3617
|
+
var REDIRECT_TARGET = /(?:^|\s)(?:\d*>{1,2}\|?|&>{1,2})\s*(\S+)/g;
|
|
3618
|
+
function hookTests(policy, ctx) {
|
|
3619
|
+
const rel = (t) => {
|
|
3620
|
+
let p = t.replace(/^["']|["']$/g, "");
|
|
3621
|
+
if (ctx?.cwd && p.startsWith(ctx.cwd + "/")) p = p.slice(ctx.cwd.length + 1);
|
|
3622
|
+
return p.replace(/^\.\/(?=.)/, "");
|
|
3623
|
+
};
|
|
3624
|
+
const hook = (t) => isProtected(rel(t), policy, "hooks");
|
|
3625
|
+
const whole = (t) => /^(?:\.|\.\/|:\/|\*)$/.test(rel(t)) || ctx?.cwd !== void 0 && rel(t) === ctx.cwd;
|
|
3626
|
+
const holds = (t) => !t.startsWith("-") && (whole(t) || containsProtected(rel(t), policy, "hooks", ctx));
|
|
3627
|
+
const hookish = (t) => hook(t) || holds(t);
|
|
3628
|
+
return { rel, hook, whole, holds, hookish };
|
|
3629
|
+
}
|
|
3630
|
+
function shellHookTarget(seg, toks, policy, ctx) {
|
|
3631
|
+
const { rel, hook, whole, holds } = hookTests(policy, ctx);
|
|
2527
3632
|
for (const m of seg.matchAll(REDIRECT_TARGET)) {
|
|
2528
|
-
|
|
2529
|
-
if (isProtected(t, policy, "hooks")) return t;
|
|
3633
|
+
if (hook(m[1])) return rel(m[1]);
|
|
2530
3634
|
}
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
3635
|
+
const args = toks.map((t) => t.replace(/^(?:of=|--(?:include|exclude|directory)=)/, ""));
|
|
3636
|
+
const fileLike = (p) => {
|
|
3637
|
+
const b = p.replace(/\/+$/, "").split("/").pop() ?? "";
|
|
3638
|
+
return HOOK_BASENAMES.includes(b) || /\.[A-Za-z0-9]+$/.test(b.replace(/^\.+/, "")) || (trackedFiles(ctx)?.includes(p) ?? false);
|
|
3639
|
+
};
|
|
3640
|
+
for (const t of args) if (hook(t) && fileLike(rel(t))) return rel(t);
|
|
3641
|
+
for (const t of args) {
|
|
3642
|
+
if (t.startsWith("-") || !holds(t)) continue;
|
|
3643
|
+
const dir = rel(t).replace(/\/+$/, "");
|
|
3644
|
+
const under = whole(t) ? "" : dir + "/";
|
|
3645
|
+
const listed = trackedFiles(ctx)?.find((f) => f.startsWith(under) && isProtected(f, policy, "hooks"));
|
|
3646
|
+
if (listed) return listed;
|
|
3647
|
+
const guess = under + "pre-commit";
|
|
3648
|
+
return isProtected(guess, policy, "hooks") ? guess : whole(t) ? ".tamperward.yml" : dir;
|
|
2534
3649
|
}
|
|
2535
3650
|
return null;
|
|
2536
3651
|
}
|
|
2537
|
-
function shellWritesHook(seg, toks, policy) {
|
|
2538
|
-
const hook
|
|
3652
|
+
function shellWritesHook(seg, toks, policy, ctx) {
|
|
3653
|
+
const { rel, hook, whole, holds, hookish } = hookTests(policy, ctx);
|
|
2539
3654
|
for (const m of seg.matchAll(REDIRECT_TARGET)) {
|
|
2540
|
-
if (hook(m[1]
|
|
3655
|
+
if (hook(m[1])) return "a redirect empties or rewrites a protected hook";
|
|
2541
3656
|
}
|
|
2542
3657
|
let at = 0;
|
|
2543
3658
|
while (at < toks.length && WRAPPERS.test(toks[at])) at++;
|
|
@@ -2547,21 +3662,28 @@ function shellWritesHook(seg, toks, policy) {
|
|
|
2547
3662
|
const last = positional[positional.length - 1] ?? "";
|
|
2548
3663
|
const anyHook = args.some(hook);
|
|
2549
3664
|
const inPlace = args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a));
|
|
3665
|
+
const scripts = args.filter((_, i) => i > 0 && INLINE_FLAG.test(args[i - 1]));
|
|
3666
|
+
const namesHookInText = () => scripts.some((a) => (a.match(/[\w.~/-]+/g) ?? []).some(hook));
|
|
2550
3667
|
switch (cmd) {
|
|
2551
3668
|
case "rm":
|
|
2552
3669
|
case "unlink":
|
|
2553
3670
|
case "shred":
|
|
2554
|
-
return anyHook ? "rm deletes a protected hook" : null;
|
|
3671
|
+
return anyHook || positional.some(holds) ? "rm deletes a protected hook" : null;
|
|
2555
3672
|
case "truncate":
|
|
2556
3673
|
return anyHook ? "truncate empties a protected hook" : null;
|
|
2557
3674
|
case "chmod":
|
|
2558
|
-
return anyHook && chmodDropsExec(toks) ? "chmod removes execute permission from a hook \u2014 git will skip it" : null;
|
|
3675
|
+
return (anyHook || positional.some(holds)) && chmodDropsExec(toks) ? "chmod removes execute permission from a hook \u2014 git will skip it" : null;
|
|
3676
|
+
case "setfacl":
|
|
3677
|
+
return anyHook && setfaclDropsExec(args) ? "setfacl removes the owner execute permission from a hook \u2014 git will skip it" : null;
|
|
2559
3678
|
case "mv":
|
|
2560
|
-
return anyHook ? "mv moves a protected hook out of place or overwrites it" : null;
|
|
3679
|
+
return anyHook || positional.some(holds) ? "mv moves a protected hook out of place or overwrites it" : null;
|
|
2561
3680
|
case "cp":
|
|
2562
3681
|
case "install":
|
|
2563
3682
|
case "ln":
|
|
2564
|
-
|
|
3683
|
+
case "rsync": {
|
|
3684
|
+
const dests = destinations(cmd, args, holds);
|
|
3685
|
+
return dests.some(hook) ? `${cmd} replaces a protected hook in place` : null;
|
|
3686
|
+
}
|
|
2565
3687
|
case "tee":
|
|
2566
3688
|
case "sponge":
|
|
2567
3689
|
return anyHook ? `${cmd} rewrites a protected hook in place` : null;
|
|
@@ -2569,20 +3691,45 @@ function shellWritesHook(seg, toks, policy) {
|
|
|
2569
3691
|
return args.some((a) => a.startsWith("of=") && hook(a.slice(3))) ? "dd rewrites a protected hook in place" : null;
|
|
2570
3692
|
case "sed":
|
|
2571
3693
|
case "perl":
|
|
2572
|
-
|
|
3694
|
+
if (anyHook && inPlace) return `an in-place ${cmd} rewrites a protected hook`;
|
|
3695
|
+
if (cmd === "perl" && args.some((a) => INLINE_FLAG.test(a)) && namesHookInText()) return "an inline perl script names a protected hook";
|
|
3696
|
+
return null;
|
|
2573
3697
|
case "awk":
|
|
2574
3698
|
case "gawk":
|
|
2575
3699
|
return anyHook && args.some((a, i) => a === "-i" && args[i + 1] === "inplace" || a === "--in-place" || a === "-i=inplace") ? "an in-place awk rewrites a protected hook" : null;
|
|
3700
|
+
case "patch":
|
|
3701
|
+
return anyHook ? "patch rewrites a protected hook" : null;
|
|
3702
|
+
case "xargs":
|
|
3703
|
+
return null;
|
|
3704
|
+
// judged by the caller, which sees the segments feeding it
|
|
3705
|
+
case "find": {
|
|
3706
|
+
const mutates = /\s(?:-delete\b|-exec(?:dir)?\s+(?:rm|unlink|chmod|truncate|mv|sed|tee|shred)\b)/.test(seg);
|
|
3707
|
+
if (!mutates) return null;
|
|
3708
|
+
if (/-exec(?:dir)?\s+chmod\b/.test(seg) && !chmodDropsExec(toks)) return null;
|
|
3709
|
+
const dirs = positional.filter((p) => !/^-/.test(p) && !["{}", ";", "+", "rm", "chmod", "unlink", "truncate", "mv", "sed", "tee", "shred", "inplace"].includes(p));
|
|
3710
|
+
const names = args.filter((a, i) => /^-i?(?:name|path|regex)$/.test(args[i - 1] ?? ""));
|
|
3711
|
+
const nameHits = names.length === 0 || names.some((n) => {
|
|
3712
|
+
try {
|
|
3713
|
+
const g = nameGlob(n.replace(/^.*\//, ""));
|
|
3714
|
+
return HOOK_BASENAMES.some(g);
|
|
3715
|
+
} catch {
|
|
3716
|
+
return true;
|
|
3717
|
+
}
|
|
3718
|
+
});
|
|
3719
|
+
const reachesHook = dirs.some((d) => hook(d) || holds(d) || whole(d) && nameHits) || dirs.length === 0 && nameHits;
|
|
3720
|
+
return reachesHook && nameHits ? "find rewrites or removes protected hooks" : null;
|
|
3721
|
+
}
|
|
2576
3722
|
case "git": {
|
|
2577
3723
|
let i = 0;
|
|
2578
3724
|
while (i < args.length && args[i].startsWith("-")) i += args[i] === "-C" || args[i] === "-c" ? 2 : 1;
|
|
2579
3725
|
const sub = args[i];
|
|
2580
3726
|
const rest = args.slice(i + 1);
|
|
2581
|
-
if (
|
|
3727
|
+
if (sub === "apply" || sub === "am") return rest.some((a) => hook(a.replace(/^--(?:include|exclude|directory)=/, ""))) ? `git ${sub} applies a patch that names a protected hook` : null;
|
|
3728
|
+
if (!rest.some(hookish)) return null;
|
|
2582
3729
|
if (sub === "checkout") {
|
|
2583
3730
|
const dd = rest.indexOf("--");
|
|
2584
3731
|
const before = dd === -1 ? rest : rest.slice(0, dd);
|
|
2585
|
-
const revs = before.filter((p) => !p.startsWith("-") && !
|
|
3732
|
+
const revs = before.filter((p) => !p.startsWith("-") && !hookish(p));
|
|
2586
3733
|
return revs.length > 0 ? "git checkout <rev> -- <hook> puts an older version of a protected hook back" : null;
|
|
2587
3734
|
}
|
|
2588
3735
|
if (sub === "restore" && rest.some((a) => /^--source(?:=|$)/.test(a) || a === "-s")) {
|
|
@@ -2591,108 +3738,290 @@ function shellWritesHook(seg, toks, policy) {
|
|
|
2591
3738
|
return null;
|
|
2592
3739
|
}
|
|
2593
3740
|
default:
|
|
3741
|
+
if (INTERPRETERS.has(cmd) && inPlace && anyHook) return `an in-place ${cmd} rewrites a protected hook`;
|
|
3742
|
+
if (INTERPRETERS.has(cmd) && args.some((a) => INLINE_FLAG.test(a)) && namesHookInText()) return `an inline ${cmd} script names a protected hook`;
|
|
3743
|
+
if (EDITORS.has(cmd) && (anyHook || args.some((a) => /^-[a-zA-Z]*[cs]/.test(a) || a === "--cmd") && namesHookInText())) return `${cmd} rewrites a protected hook`;
|
|
2594
3744
|
return null;
|
|
2595
3745
|
}
|
|
2596
3746
|
}
|
|
3747
|
+
function xargsWritesHook(toks, fed, policy, ctx) {
|
|
3748
|
+
const at = toks.findIndex((t) => t === "xargs" || t.endsWith("/xargs"));
|
|
3749
|
+
if (at === -1) return null;
|
|
3750
|
+
const rel = (t) => (ctx?.cwd && t.startsWith(ctx.cwd + "/") ? t.slice(ctx.cwd.length + 1) : t).replace(/^\.\//, "");
|
|
3751
|
+
const fedHook = fed.some((t) => isProtected(rel(t), policy, "hooks") || !t.startsWith("-") && containsProtected(rel(t), policy, "hooks", ctx));
|
|
3752
|
+
if (!fedHook) return null;
|
|
3753
|
+
let i = at + 1;
|
|
3754
|
+
while (i < toks.length && toks[i].startsWith("-")) i += /^-(?:I|n|L|P|d|a|s)$/.test(toks[i]) ? 2 : 1;
|
|
3755
|
+
const cmd = (toks[i] ?? "").replace(/^.*\//, "");
|
|
3756
|
+
const args = toks.slice(i + 1);
|
|
3757
|
+
if (["rm", "unlink", "shred", "truncate", "mv", "tee", "sponge"].includes(cmd)) return `xargs ${cmd} rewrites or removes a protected hook named earlier in the line`;
|
|
3758
|
+
if (cmd === "chmod" && chmodDropsExec(toks.slice(i))) return "xargs chmod removes execute permission from a hook named earlier in the line";
|
|
3759
|
+
if ((cmd === "sed" || cmd === "perl") && args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a))) return `xargs ${cmd} -i rewrites a protected hook named earlier in the line`;
|
|
3760
|
+
return null;
|
|
3761
|
+
}
|
|
2597
3762
|
|
|
2598
3763
|
// src/detectors/hook-tampering.ts
|
|
2599
3764
|
var RULE6 = "hook-tampering";
|
|
2600
3765
|
var ownerExec2 = (mode) => (parseInt(mode.slice(-3), 8) & 64) !== 0;
|
|
2601
|
-
var CLAUDE_SETTINGS = /(?:^|\/)\.claude\/settings(?:\.local)?\.json$/;
|
|
2602
|
-
var isClaudeSettings = (p) => CLAUDE_SETTINGS.test(p);
|
|
2603
|
-
function siblingSettings(p) {
|
|
2604
|
-
return p.endsWith("settings.local.json") ? p.replace(/settings\.local\.json$/, "settings.json") : p.replace(/settings\.json$/, "settings.local.json");
|
|
2605
|
-
}
|
|
2606
|
-
var TW_INVOCATION = /\btamperward(?:@\S+)?\s+(?:hook|sweep)\s+claude\b/;
|
|
2607
3766
|
var EVENTS = ["PreToolUse", "Stop"];
|
|
3767
|
+
var isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
2608
3768
|
function parseSettings(src) {
|
|
2609
3769
|
try {
|
|
2610
3770
|
const v = JSON.parse(src);
|
|
2611
|
-
return
|
|
3771
|
+
return isObj(v) ? v : null;
|
|
2612
3772
|
} catch {
|
|
2613
3773
|
return null;
|
|
2614
3774
|
}
|
|
2615
3775
|
}
|
|
2616
|
-
function toolSet(matcher2) {
|
|
2617
|
-
const m = typeof matcher2 === "string" ? matcher2.trim() : "";
|
|
2618
|
-
if (m === "" || m === "*") return null;
|
|
2619
|
-
return new Set(m.split("|").map((t) => t.trim()).filter(Boolean));
|
|
2620
|
-
}
|
|
2621
3776
|
function eventEntries(s, event) {
|
|
2622
3777
|
const hooks = s.hooks;
|
|
2623
|
-
if (
|
|
3778
|
+
if (!isObj(hooks)) return null;
|
|
2624
3779
|
const arr = hooks[event];
|
|
2625
3780
|
return Array.isArray(arr) ? arr : null;
|
|
2626
3781
|
}
|
|
2627
3782
|
var declares = (s, event) => eventEntries(s, event) !== null;
|
|
2628
|
-
|
|
3783
|
+
var GATE_KEYS = /* @__PURE__ */ new Set(["type", "command", "timeout"]);
|
|
3784
|
+
var CANONICAL_CMD = /^npx --yes tamperward@(\S+) (hook|sweep) claude$/;
|
|
3785
|
+
var MENTIONS_GATE = /\btamperward\b.*\b(?:hook|sweep)\b/;
|
|
3786
|
+
var FORGERY = /hookSpecificOutput|permissionDecision|updatedInput|"decision"|"continue"/;
|
|
3787
|
+
var SENSITIVE_ENV = /^(?:PATH|NODE_OPTIONS|NODE_PATH|HOME|npm_config_.*|NPM_CONFIG_.*)$/;
|
|
3788
|
+
function analyseGateCommand(cmd, event) {
|
|
3789
|
+
const want = event === "PreToolUse" ? "hook" : "sweep";
|
|
3790
|
+
const problems = [];
|
|
3791
|
+
let runs = true;
|
|
3792
|
+
const canonical2 = cmd.match(CANONICAL_CMD);
|
|
3793
|
+
if (canonical2) {
|
|
3794
|
+
if (canonical2[2] !== want) {
|
|
3795
|
+
problems.push(`runs \`${canonical2[2]} claude\` under ${event}`);
|
|
3796
|
+
runs = false;
|
|
3797
|
+
}
|
|
3798
|
+
if (!PLAIN_SEMVER.test(canonical2[1])) {
|
|
3799
|
+
problems.push(`the pin \`${canonical2[1]}\` is not a plain version`);
|
|
3800
|
+
runs = false;
|
|
3801
|
+
}
|
|
3802
|
+
return { runs, problems, pin: canonical2[1] };
|
|
3803
|
+
}
|
|
3804
|
+
const inv = cmd.match(/\btamperward(?:@(\S+))?\s+(hook|sweep)\s+(\S+)/);
|
|
3805
|
+
if (!inv) return { runs: false, problems: ["the command does not invoke the gate"], pin: null };
|
|
3806
|
+
if (inv[2] !== want || inv[3] !== "claude") {
|
|
3807
|
+
problems.push(`runs \`${inv[2]} ${inv[3]}\` under ${event}`);
|
|
3808
|
+
runs = false;
|
|
3809
|
+
}
|
|
3810
|
+
if (inv[1] !== void 0 && !PLAIN_SEMVER.test(inv[1])) {
|
|
3811
|
+
problems.push(`the pin \`${inv[1]}\` is not a plain version`);
|
|
3812
|
+
runs = false;
|
|
3813
|
+
}
|
|
3814
|
+
if (/[|;&<>`]|\$\(/.test(cmd)) {
|
|
3815
|
+
problems.push("text around the invocation (a redirect, pipe or chain)");
|
|
3816
|
+
runs = false;
|
|
3817
|
+
}
|
|
3818
|
+
if (/^\s*[A-Za-z_]\w*=/.test(cmd)) {
|
|
3819
|
+
problems.push("an environment assignment in front of the gate");
|
|
3820
|
+
runs = false;
|
|
3821
|
+
}
|
|
3822
|
+
if (/\b(?:sh|bash|zsh|dash)\s+-[a-zA-Z]*c\b/.test(cmd)) {
|
|
3823
|
+
problems.push("wrapped in `sh -c`");
|
|
3824
|
+
runs = false;
|
|
3825
|
+
}
|
|
3826
|
+
if (/(?:^|\s)(?:-p|--package)(?:=|\s)/.test(cmd)) {
|
|
3827
|
+
problems.push("`-p`/`--package` points npx at another package");
|
|
3828
|
+
runs = false;
|
|
3829
|
+
}
|
|
3830
|
+
const at = cmd.indexOf(inv[0]);
|
|
3831
|
+
const trailing = cmd.slice(at + inv[0].length).trim();
|
|
3832
|
+
if (trailing) {
|
|
3833
|
+
problems.push(`extra text after the invocation (\`${trailing}\`)`);
|
|
3834
|
+
runs = false;
|
|
3835
|
+
}
|
|
3836
|
+
const head = cmd.slice(0, at).trim();
|
|
3837
|
+
if (head !== "npx --yes") problems.push(`the launcher \`${head || "(none)"}\` is not \`npx --yes\``);
|
|
3838
|
+
return { runs, problems, pin: inv[1] ?? "" };
|
|
3839
|
+
}
|
|
3840
|
+
function matcherSelection(m) {
|
|
3841
|
+
if (m === void 0) return { tools: null, problem: null };
|
|
3842
|
+
if (typeof m !== "string") return { tools: /* @__PURE__ */ new Set(), problem: `the matcher ${JSON.stringify(m)} is not a string` };
|
|
3843
|
+
if (m === "" || m === "*") return { tools: null, problem: null };
|
|
3844
|
+
let problem = null;
|
|
3845
|
+
if (/[^\x00-\x7f]/.test(m)) problem = "the matcher contains non-ASCII characters that look like tool names but are not";
|
|
3846
|
+
if (/^[A-Za-z0-9_ |-]+$/.test(m)) return { tools: new Set(m.split("|").map((t) => t.trim()).filter(Boolean)), problem };
|
|
3847
|
+
let re;
|
|
3848
|
+
try {
|
|
3849
|
+
re = new RegExp(m);
|
|
3850
|
+
} catch {
|
|
3851
|
+
return { tools: /* @__PURE__ */ new Set(), problem: `the matcher \`${m}\` is not a valid pattern \u2014 it selects nothing` };
|
|
3852
|
+
}
|
|
3853
|
+
return { tools: new Set(PRE_TOOLS.filter((t) => re.test(t))), problem };
|
|
3854
|
+
}
|
|
3855
|
+
function gateCandidates(s, event) {
|
|
2629
3856
|
const out2 = [];
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
if (
|
|
2638
|
-
|
|
2639
|
-
|
|
3857
|
+
const mentions = (v) => /\btamperward\b/.test(JSON.stringify(v) ?? "");
|
|
3858
|
+
for (const group of eventEntries(s, event) ?? []) {
|
|
3859
|
+
if (!isObj(group)) {
|
|
3860
|
+
if (mentions(group)) out2.push({ key: JSON.stringify(group), runs: false, problems: ["the matcher entry is not an object"], pin: null, tools: null });
|
|
3861
|
+
continue;
|
|
3862
|
+
}
|
|
3863
|
+
if (!Array.isArray(group.hooks)) {
|
|
3864
|
+
if (mentions(group)) out2.push({ key: JSON.stringify(group), runs: false, problems: ["`hooks` is not a list"], pin: null, tools: null });
|
|
3865
|
+
continue;
|
|
3866
|
+
}
|
|
3867
|
+
for (const h of group.hooks) {
|
|
3868
|
+
if (!isObj(h)) {
|
|
3869
|
+
if (mentions(h)) out2.push({ key: JSON.stringify(h), runs: false, problems: ["the hook entry is not an object"], pin: null, tools: null });
|
|
3870
|
+
continue;
|
|
3871
|
+
}
|
|
3872
|
+
const cmd = typeof h.command === "string" ? h.command : null;
|
|
3873
|
+
if (cmd === null) {
|
|
3874
|
+
if (mentions(h)) out2.push({ key: JSON.stringify(h), runs: false, problems: ["the gate is not in a `command` string"], pin: null, tools: null });
|
|
3875
|
+
continue;
|
|
3876
|
+
}
|
|
3877
|
+
if (!MENTIONS_GATE.test(cmd)) continue;
|
|
3878
|
+
const a = analyseGateCommand(cmd, event);
|
|
3879
|
+
const problems = [...a.problems];
|
|
3880
|
+
let runs = a.runs;
|
|
3881
|
+
if (h.type !== "command") {
|
|
3882
|
+
problems.push(h.type === void 0 ? "no `type`" : `\`type\` is ${JSON.stringify(h.type)}`);
|
|
3883
|
+
if (h.type !== void 0) runs = false;
|
|
3884
|
+
}
|
|
3885
|
+
for (const k of Object.keys(h)) {
|
|
3886
|
+
if (GATE_KEYS.has(k)) continue;
|
|
3887
|
+
problems.push(`carries \`${k}\``);
|
|
3888
|
+
runs = false;
|
|
3889
|
+
}
|
|
3890
|
+
if ("timeout" in h) {
|
|
3891
|
+
const t = h.timeout;
|
|
3892
|
+
if (typeof t !== "number" || !Number.isInteger(t) || t < 120) {
|
|
3893
|
+
problems.push(`\`timeout\` ${JSON.stringify(t)} \u2014 the gate is cut off before it answers`);
|
|
3894
|
+
runs = false;
|
|
3895
|
+
}
|
|
3896
|
+
}
|
|
3897
|
+
let tools = null;
|
|
3898
|
+
if (event === "PreToolUse") {
|
|
3899
|
+
const sel = matcherSelection(group.matcher);
|
|
3900
|
+
tools = sel.tools;
|
|
3901
|
+
if (sel.problem) {
|
|
3902
|
+
problems.push(sel.problem);
|
|
3903
|
+
}
|
|
3904
|
+
} else if (group.matcher !== void 0 && !(typeof group.matcher === "string" && (group.matcher === "" || group.matcher === "*"))) {
|
|
3905
|
+
problems.push(`a \`matcher\` (${JSON.stringify(group.matcher)}) on the Stop entry`);
|
|
3906
|
+
runs = false;
|
|
3907
|
+
}
|
|
3908
|
+
out2.push({ key: JSON.stringify({ m: group.matcher, h }), runs, problems, pin: a.pin, tools });
|
|
2640
3909
|
}
|
|
2641
3910
|
}
|
|
2642
3911
|
return out2;
|
|
2643
3912
|
}
|
|
2644
|
-
function
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
3913
|
+
function otherCommands(s, event) {
|
|
3914
|
+
const out2 = [];
|
|
3915
|
+
for (const group of eventEntries(s, event) ?? []) {
|
|
3916
|
+
if (!isObj(group) || !Array.isArray(group.hooks)) continue;
|
|
3917
|
+
for (const h of group.hooks) {
|
|
3918
|
+
if (!isObj(h) || typeof h.command !== "string" || MENTIONS_GATE.test(h.command)) continue;
|
|
3919
|
+
out2.push(h.command);
|
|
3920
|
+
}
|
|
3921
|
+
}
|
|
3922
|
+
return out2;
|
|
3923
|
+
}
|
|
3924
|
+
function coverage(cs) {
|
|
3925
|
+
const live = cs.filter((c) => c.runs);
|
|
3926
|
+
if (live.length === 0) return "none";
|
|
3927
|
+
if (live.some((c) => c.tools === null)) return null;
|
|
3928
|
+
return new Set(live.flatMap((c) => [...c.tools]));
|
|
3929
|
+
}
|
|
3930
|
+
function duplicateJsonKeys(src) {
|
|
3931
|
+
const dups = [];
|
|
3932
|
+
const stack = [];
|
|
3933
|
+
let expectKey = false;
|
|
3934
|
+
for (let i = 0; i < src.length; i++) {
|
|
3935
|
+
const ch = src[i];
|
|
3936
|
+
if (ch === '"') {
|
|
3937
|
+
let j = i + 1;
|
|
3938
|
+
let s = "";
|
|
3939
|
+
while (j < src.length && src[j] !== '"') {
|
|
3940
|
+
if (src[j] === "\\") {
|
|
3941
|
+
s += src[j + 1] ?? "";
|
|
3942
|
+
j += 2;
|
|
3943
|
+
} else {
|
|
3944
|
+
s += src[j];
|
|
3945
|
+
j++;
|
|
3946
|
+
}
|
|
3947
|
+
}
|
|
3948
|
+
if (expectKey) {
|
|
3949
|
+
const set = stack[stack.length - 1];
|
|
3950
|
+
if (set) {
|
|
3951
|
+
if (set.has(s)) dups.push(s);
|
|
3952
|
+
set.add(s);
|
|
3953
|
+
}
|
|
3954
|
+
expectKey = false;
|
|
3955
|
+
}
|
|
3956
|
+
i = j;
|
|
3957
|
+
continue;
|
|
3958
|
+
}
|
|
3959
|
+
if (ch === "{") {
|
|
3960
|
+
stack.push(/* @__PURE__ */ new Set());
|
|
3961
|
+
expectKey = true;
|
|
3962
|
+
} else if (ch === "[") {
|
|
3963
|
+
stack.push(null);
|
|
3964
|
+
expectKey = false;
|
|
3965
|
+
} else if (ch === "}" || ch === "]") {
|
|
3966
|
+
stack.pop();
|
|
3967
|
+
expectKey = false;
|
|
3968
|
+
} else if (ch === ",") expectKey = stack[stack.length - 1] !== null;
|
|
3969
|
+
}
|
|
3970
|
+
return [...new Set(dups)];
|
|
2648
3971
|
}
|
|
2649
|
-
|
|
2650
|
-
function settingsWeakening(before, after, sibling) {
|
|
3972
|
+
function settingsWeakening(before, after, local) {
|
|
2651
3973
|
const reasons = [];
|
|
2652
|
-
|
|
2653
|
-
|
|
3974
|
+
const dis = after.disableAllHooks;
|
|
3975
|
+
if (dis !== void 0 && dis !== false && !(before !== null && before.disableAllHooks === dis)) {
|
|
3976
|
+
reasons.push(`\`disableAllHooks: ${JSON.stringify(dis)}\` switches every hook off, the gate included`);
|
|
3977
|
+
}
|
|
3978
|
+
if (isObj(after.env)) {
|
|
3979
|
+
const was = before !== null && isObj(before.env) ? before.env : {};
|
|
3980
|
+
for (const [k, v] of Object.entries(after.env)) {
|
|
3981
|
+
if (SENSITIVE_ENV.test(k) && JSON.stringify(was[k]) !== JSON.stringify(v)) {
|
|
3982
|
+
reasons.push(`a top-level \`env\` sets ${k} \u2014 the gate command resolves through it`);
|
|
3983
|
+
}
|
|
3984
|
+
}
|
|
2654
3985
|
}
|
|
2655
3986
|
for (const event of EVENTS) {
|
|
2656
|
-
const
|
|
2657
|
-
const
|
|
3987
|
+
const bc = before !== null ? gateCandidates(before, event) : [];
|
|
3988
|
+
const ac = gateCandidates(after, event);
|
|
3989
|
+
const was = coverage(bc);
|
|
3990
|
+
const now = coverage(ac);
|
|
2658
3991
|
if (was !== "none") {
|
|
2659
3992
|
if (now === "none") {
|
|
2660
3993
|
reasons.push(`the tamperward ${event} hook entry was removed`);
|
|
2661
3994
|
} else if (event === "PreToolUse") {
|
|
2662
3995
|
if (was === null && now !== null) {
|
|
2663
|
-
reasons.push(`the PreToolUse matcher for the gate was narrowed from every tool to ${[...now].join("|")}`);
|
|
3996
|
+
reasons.push(`the PreToolUse matcher for the gate was narrowed from every tool to ${[...now].join("|") || "(nothing)"}`);
|
|
2664
3997
|
} else if (was !== null && now !== null) {
|
|
2665
3998
|
const lost = [...was].filter((t) => !now.has(t));
|
|
2666
3999
|
if (lost.length) reasons.push(`the PreToolUse matcher for the gate no longer covers ${lost.join(", ")}`);
|
|
2667
4000
|
}
|
|
2668
4001
|
}
|
|
2669
|
-
|
|
4002
|
+
} else if (now === "none" && declares(after, event) && !(before !== null && declares(before, event)) && (eventEntries(after, event) ?? []).length === 0) {
|
|
4003
|
+
reasons.push(`${event} hooks declared empty in ${local ? "an override file" : "the settings file"}`);
|
|
4004
|
+
}
|
|
4005
|
+
const had = new Set(bc.map((c) => c.key));
|
|
4006
|
+
const fresh = ac.filter((c) => !had.has(c.key));
|
|
4007
|
+
for (const c of fresh) {
|
|
4008
|
+
if (c.problems.length) reasons.push(`the ${event} gate entry does not match the shape \`tamperward init\` writes: ${c.problems.join("; ")}`);
|
|
4009
|
+
}
|
|
4010
|
+
const pins = bc.map((c) => c.pin).filter((p) => !!p && PLAIN_SEMVER.test(p)).sort((a, b) => compareVersions(b, a) ?? 0);
|
|
4011
|
+
const floor = pins[0] ?? (before === null && PLAIN_SEMVER.test(TW_VERSION) ? TW_VERSION : null);
|
|
4012
|
+
if (floor) {
|
|
4013
|
+
for (const c of fresh) {
|
|
4014
|
+
if (c.pin && PLAIN_SEMVER.test(c.pin) && !pinNotBelow(c.pin, floor)) reasons.push(`the ${event} gate is pinned to tamperward@${c.pin}, below ${floor}`);
|
|
4015
|
+
}
|
|
2670
4016
|
}
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
const shadowed = sibling !== null && gateWiring(sibling, event).length > 0;
|
|
2675
|
-
if (empty || shadowed) {
|
|
2676
|
-
reasons.push(
|
|
2677
|
-
`${event} hooks declared ${empty ? "empty" : "without the tamperward entry"} in an override file` + (shadowed ? " \u2014 this shadows the gate the sibling settings file wires" : "")
|
|
2678
|
-
);
|
|
4017
|
+
const hadOther = new Set(before !== null ? otherCommands(before, event) : []);
|
|
4018
|
+
for (const cmd of otherCommands(after, event)) {
|
|
4019
|
+
if (!hadOther.has(cmd) && FORGERY.test(cmd)) reasons.push(`a ${event} hook entry beside the gate emits a hook decision of its own (\`${cmd.slice(0, 60)}\`)`);
|
|
2679
4020
|
}
|
|
2680
4021
|
}
|
|
2681
4022
|
return reasons;
|
|
2682
4023
|
}
|
|
2683
|
-
function
|
|
2684
|
-
const path = siblingSettings(c.path);
|
|
2685
|
-
const inSet = changes.find((o) => o.kind === "file" && o.path === path);
|
|
2686
|
-
if (inSet) return inSet.after != null ? parseSettings(inSet.after) : null;
|
|
2687
|
-
if (!ctx?.cwd) return null;
|
|
2688
|
-
try {
|
|
2689
|
-
const p = join2(ctx.cwd, path);
|
|
2690
|
-
return existsSync(p) ? parseSettings(readFileSync2(p, "utf8")) : null;
|
|
2691
|
-
} catch {
|
|
2692
|
-
return null;
|
|
2693
|
-
}
|
|
2694
|
-
}
|
|
2695
|
-
function claudeSettingsFindings(c, changes, policy, ctx) {
|
|
4024
|
+
function claudeSettingsFindings(c, policy) {
|
|
2696
4025
|
if (c.after == null) return null;
|
|
2697
4026
|
const after = parseSettings(c.after);
|
|
2698
4027
|
const before = c.before != null ? parseSettings(c.before) : null;
|
|
@@ -2700,14 +4029,19 @@ function claudeSettingsFindings(c, changes, policy, ctx) {
|
|
|
2700
4029
|
file: c.path,
|
|
2701
4030
|
message: `The Claude Code hook wiring was weakened: ${reason}.`,
|
|
2702
4031
|
evidence: reason,
|
|
2703
|
-
remediation: "Leave the PreToolUse and Stop entries that run tamperward, and the matcher that covers every editing tool, exactly as `tamperward init` wrote them. Editing the wiring is editing the gate."
|
|
4032
|
+
remediation: "Leave the PreToolUse and Stop entries that run tamperward, and the matcher that covers every editing tool, exactly as `tamperward init` wrote them. Editing the wiring is editing the gate: re-run `tamperward init` to restore it, or sign off."
|
|
2704
4033
|
});
|
|
2705
4034
|
if (c.before != null && before === null) return null;
|
|
2706
4035
|
if (after === null) {
|
|
2707
4036
|
if (c.before == null) return [];
|
|
2708
4037
|
return [finding("the settings file no longer parses as JSON \u2014 the runtime ignores it, hooks and all")];
|
|
2709
4038
|
}
|
|
2710
|
-
|
|
4039
|
+
const reasons = settingsWeakening(before, after, /settings\.local\.json$/.test(c.path));
|
|
4040
|
+
const had = new Set(c.before != null ? duplicateJsonKeys(c.before) : []);
|
|
4041
|
+
for (const k of duplicateJsonKeys(c.after)) {
|
|
4042
|
+
if (!had.has(k)) reasons.push(`the key "${k}" appears twice \u2014 which reading the runtime keeps is not something the gate can vouch for`);
|
|
4043
|
+
}
|
|
4044
|
+
return reasons.map(finding);
|
|
2711
4045
|
}
|
|
2712
4046
|
function policyFindings(c, policy) {
|
|
2713
4047
|
if (c.after == null) return null;
|
|
@@ -2729,12 +4063,54 @@ function renameKept(c) {
|
|
|
2729
4063
|
if (id === null || hookIdentity(c.path) !== id) return false;
|
|
2730
4064
|
return c.before != null && c.after != null ? c.before === c.after : c.hunks.length === 0;
|
|
2731
4065
|
}
|
|
2732
|
-
function
|
|
2733
|
-
|
|
2734
|
-
const
|
|
2735
|
-
const
|
|
2736
|
-
if (
|
|
2737
|
-
|
|
4066
|
+
function lefthookBase(c, changes, ctx) {
|
|
4067
|
+
const dir = c.path.includes("/") ? c.path.slice(0, c.path.lastIndexOf("/") + 1) : "";
|
|
4068
|
+
const names = ["lefthook.yml", "lefthook.yaml", ".lefthook.yml", ".lefthook.yaml"].map((n) => dir + n);
|
|
4069
|
+
const inSet = changes.find((o) => o.kind === "file" && names.includes(o.path));
|
|
4070
|
+
if (inSet) return inSet.after != null ? parseDoc(inSet.after) : null;
|
|
4071
|
+
if (!ctx?.cwd) return null;
|
|
4072
|
+
for (const n of names) {
|
|
4073
|
+
try {
|
|
4074
|
+
const p = join3(ctx.cwd, n);
|
|
4075
|
+
if (existsSync(p)) return parseDoc(readFileSync3(p, "utf8"));
|
|
4076
|
+
} catch {
|
|
4077
|
+
}
|
|
4078
|
+
}
|
|
4079
|
+
return null;
|
|
4080
|
+
}
|
|
4081
|
+
function syntheticBase(over) {
|
|
4082
|
+
const base = {};
|
|
4083
|
+
for (const [section, v] of Object.entries(over)) {
|
|
4084
|
+
if (!isObj(v)) continue;
|
|
4085
|
+
for (const group of ["commands", "scripts"]) {
|
|
4086
|
+
const g = v[group];
|
|
4087
|
+
if (!isObj(g)) continue;
|
|
4088
|
+
for (const name of Object.keys(g)) {
|
|
4089
|
+
if (!/tamperward/.test(name)) continue;
|
|
4090
|
+
const sec = base[section] ??= {};
|
|
4091
|
+
const grp = sec[group] ??= {};
|
|
4092
|
+
grp[name] = { run: "npx tamperward check --staged" };
|
|
4093
|
+
}
|
|
4094
|
+
}
|
|
4095
|
+
}
|
|
4096
|
+
return base;
|
|
4097
|
+
}
|
|
4098
|
+
function configFindings(c, changes, policy, ctx) {
|
|
4099
|
+
if (c.after == null) return null;
|
|
4100
|
+
let reasons;
|
|
4101
|
+
if (isLefthookLocal(c.path)) {
|
|
4102
|
+
const overBefore = c.before != null ? parseDoc(c.before) : {};
|
|
4103
|
+
const overAfter = parseDoc(c.after);
|
|
4104
|
+
if (overBefore === null) return null;
|
|
4105
|
+
const base = lefthookBase(c, changes, ctx) ?? syntheticBase(overAfter ?? {});
|
|
4106
|
+
reasons = overAfter === null ? ["the file no longer parses as YAML \u2014 the tool that reads it runs nothing"] : lefthookWeakening(mergeDocs(base, overBefore), mergeDocs(base, overAfter));
|
|
4107
|
+
} else {
|
|
4108
|
+
if (c.before == null) return null;
|
|
4109
|
+
const before = parseDoc(c.before);
|
|
4110
|
+
const after = parseDoc(c.after);
|
|
4111
|
+
if (before === null) return null;
|
|
4112
|
+
reasons = after === null ? ["the file no longer parses as YAML \u2014 the tool that reads it runs nothing"] : isLefthook(c.path) ? lefthookWeakening(before, after) : preCommitWeakening(before, after);
|
|
4113
|
+
}
|
|
2738
4114
|
return reasons.map(
|
|
2739
4115
|
(reason) => makeFinding(RULE6, policy, {
|
|
2740
4116
|
file: c.path,
|
|
@@ -2744,6 +4120,11 @@ function configFindings(c, policy) {
|
|
|
2744
4120
|
})
|
|
2745
4121
|
);
|
|
2746
4122
|
}
|
|
4123
|
+
function repoGateFiles(ctx) {
|
|
4124
|
+
const files = trackedFiles(ctx);
|
|
4125
|
+
if (!files) return [];
|
|
4126
|
+
return files.filter((f) => /^\.github\/workflows\/[^/]+\.ya?ml$/.test(f) || /^\.husky\/[^/_][^/]*$/.test(f));
|
|
4127
|
+
}
|
|
2747
4128
|
function updateIndexHiding(toks, policy) {
|
|
2748
4129
|
if (!toks.some((t) => t === "git" || t.endsWith("/git")) || !toks.includes("update-index")) return null;
|
|
2749
4130
|
const hide = toks.find((t) => /^--(?:skip-worktree|assume-unchanged)$/.test(t));
|
|
@@ -2765,6 +4146,7 @@ function repoRelative(path, ctx) {
|
|
|
2765
4146
|
const rel = relative(ctx.cwd, path);
|
|
2766
4147
|
return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : path;
|
|
2767
4148
|
}
|
|
4149
|
+
var isHuskyScript = (path) => /(?:^|\/)\.husky\/[^/]+$/.test(path);
|
|
2768
4150
|
var hookTampering = {
|
|
2769
4151
|
id: RULE6,
|
|
2770
4152
|
surface: ["file", "command"],
|
|
@@ -2774,7 +4156,7 @@ var hookTampering = {
|
|
|
2774
4156
|
for (const c of changes) {
|
|
2775
4157
|
let semantic;
|
|
2776
4158
|
if (c.kind === "file") {
|
|
2777
|
-
const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false);
|
|
4159
|
+
const targetsHook = isProtected(c.path, policy, "hooks") || (c.oldPath ? isProtected(c.oldPath, policy, "hooks") : false) || isClaudeSettings(c.path);
|
|
2778
4160
|
if (!targetsHook) {
|
|
2779
4161
|
if (isPackageJson(c.path) && c.before != null && c.after != null) {
|
|
2780
4162
|
for (const reason of packageJsonWeakening(c.before, c.after)) {
|
|
@@ -2818,12 +4200,12 @@ var hookTampering = {
|
|
|
2818
4200
|
remediation: "Renaming a hook out of place disables it. Restore the original path."
|
|
2819
4201
|
})
|
|
2820
4202
|
);
|
|
2821
|
-
} else if ((c.path.endsWith(POLICY_FILE) || isClaudeSettings(c.path)) && (semantic = c.path.endsWith(POLICY_FILE) ? policyFindings(c, policy) : claudeSettingsFindings(c,
|
|
4203
|
+
} else if ((c.path.endsWith(POLICY_FILE) || isClaudeSettings(c.path)) && (semantic = c.path.endsWith(POLICY_FILE) ? policyFindings(c, policy) : claudeSettingsFindings(c, policy)) !== null) {
|
|
2822
4204
|
out2.push(...semantic);
|
|
2823
4205
|
} else if (c.op === "rename") {
|
|
2824
4206
|
} else if (isCodeowners(c.path)) {
|
|
2825
4207
|
if (c.before != null && c.after != null) {
|
|
2826
|
-
for (const reason of codeownersWeakening(c.before, c.after)) {
|
|
4208
|
+
for (const reason of codeownersWeakening(c.before, c.after, [...repoGateFiles(ctx), c.path])) {
|
|
2827
4209
|
out2.push(
|
|
2828
4210
|
makeFinding(RULE6, policy, {
|
|
2829
4211
|
file: c.path,
|
|
@@ -2834,9 +4216,40 @@ var hookTampering = {
|
|
|
2834
4216
|
);
|
|
2835
4217
|
}
|
|
2836
4218
|
}
|
|
2837
|
-
} else if ((isLefthook(c.path) || isPreCommitConfig(c.path)) && (semantic = configFindings(c, policy)) !== null) {
|
|
4219
|
+
} else if ((isLefthook(c.path) || isPreCommitConfig(c.path)) && (semantic = configFindings(c, changes, policy, ctx)) !== null) {
|
|
2838
4220
|
out2.push(...semantic);
|
|
2839
4221
|
} else if (!c.path.endsWith(POLICY_FILE)) {
|
|
4222
|
+
const husky = isHuskyScript(c.path);
|
|
4223
|
+
const beforePin = c.before != null && c.after != null ? initScriptPin(c.before) : null;
|
|
4224
|
+
if (beforePin !== null) {
|
|
4225
|
+
const afterPin = initScriptPin(c.after);
|
|
4226
|
+
const kept = afterPin !== null && (beforePin === "" ? afterPin === "" || PLAIN_SEMVER.test(afterPin) : pinNotBelow(afterPin, beforePin));
|
|
4227
|
+
if (!kept) {
|
|
4228
|
+
out2.push(
|
|
4229
|
+
makeFinding(RULE6, policy, {
|
|
4230
|
+
file: c.path,
|
|
4231
|
+
message: "A protected hook script no longer matches the shape `tamperward init` writes.",
|
|
4232
|
+
evidence: "the gate script no longer matches the shape init wrote; re-run init or sign off",
|
|
4233
|
+
remediation: "The pre-commit script init writes is the marker line and the pinned staged check, nothing else. Re-run `tamperward init` to restore it, or sign off on the customised script."
|
|
4234
|
+
})
|
|
4235
|
+
);
|
|
4236
|
+
}
|
|
4237
|
+
continue;
|
|
4238
|
+
}
|
|
4239
|
+
if (!husky && c.after != null) {
|
|
4240
|
+
const problem = shebangProblem(c.after);
|
|
4241
|
+
if (problem && (c.before == null || problem !== shebangProblem(c.before))) {
|
|
4242
|
+
out2.push(
|
|
4243
|
+
makeFinding(RULE6, policy, {
|
|
4244
|
+
file: c.path,
|
|
4245
|
+
line: 1,
|
|
4246
|
+
message: `A protected hook's interpreter was changed: ${problem}.`,
|
|
4247
|
+
evidence: c.after.split("\n")[0],
|
|
4248
|
+
remediation: "Keep a shell in shebang position. git execs the hook directly, and an interpreter that never reads the script is a disabled hook."
|
|
4249
|
+
})
|
|
4250
|
+
);
|
|
4251
|
+
}
|
|
4252
|
+
}
|
|
2840
4253
|
const added = addedLines(c);
|
|
2841
4254
|
const earlyExit = added.find((l) => insertsPassingExit(l.content));
|
|
2842
4255
|
if (earlyExit) {
|
|
@@ -2864,7 +4277,7 @@ var hookTampering = {
|
|
|
2864
4277
|
}
|
|
2865
4278
|
const beforeLines = c.before != null ? c.before.split("\n") : removedLines(c).map((l) => l.content);
|
|
2866
4279
|
const afterLines = c.after != null ? c.after.split("\n") : added.map((l) => l.content);
|
|
2867
|
-
const lost = scriptWeakening(beforeLines, afterLines);
|
|
4280
|
+
const lost = scriptWeakening(beforeLines, afterLines, husky ? { errexit: true } : {});
|
|
2868
4281
|
if (lost.length) {
|
|
2869
4282
|
out2.push(
|
|
2870
4283
|
makeFinding(RULE6, policy, {
|
|
@@ -2908,6 +4321,7 @@ var hookTampering = {
|
|
|
2908
4321
|
}
|
|
2909
4322
|
}
|
|
2910
4323
|
} else {
|
|
4324
|
+
const fed = [];
|
|
2911
4325
|
for (const seg of segments(c.raw)) {
|
|
2912
4326
|
const toks = tokens(seg).map(unquote);
|
|
2913
4327
|
const hiding = updateIndexHiding(toks, policy);
|
|
@@ -2920,11 +4334,12 @@ var hookTampering = {
|
|
|
2920
4334
|
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."
|
|
2921
4335
|
})
|
|
2922
4336
|
);
|
|
4337
|
+
fed.push(...toks);
|
|
2923
4338
|
continue;
|
|
2924
4339
|
}
|
|
2925
|
-
const why = shellWritesHook(seg, toks, policy);
|
|
4340
|
+
const why = shellWritesHook(seg, toks, policy, ctx) ?? xargsWritesHook(toks, fed, policy, ctx);
|
|
2926
4341
|
if (why) {
|
|
2927
|
-
const target = shellHookTarget(seg, toks, policy);
|
|
4342
|
+
const target = shellHookTarget(seg, toks, policy, ctx) ?? shellHookTarget("", fed, policy, ctx);
|
|
2928
4343
|
out2.push(
|
|
2929
4344
|
makeFinding(RULE6, policy, {
|
|
2930
4345
|
...target ? { file: repoRelative(target, ctx) } : {},
|
|
@@ -2934,6 +4349,7 @@ var hookTampering = {
|
|
|
2934
4349
|
})
|
|
2935
4350
|
);
|
|
2936
4351
|
}
|
|
4352
|
+
fed.push(...toks);
|
|
2937
4353
|
}
|
|
2938
4354
|
}
|
|
2939
4355
|
}
|
|
@@ -2971,67 +4387,147 @@ function keyName2(name) {
|
|
|
2971
4387
|
if (ts3.isIdentifier(name) || ts3.isStringLiteral(name)) return name.text;
|
|
2972
4388
|
return null;
|
|
2973
4389
|
}
|
|
4390
|
+
var isStr = (e) => ts3.isStringLiteral(e) || ts3.isNoSubstitutionTemplateLiteral(e);
|
|
2974
4391
|
function literals2(e) {
|
|
2975
|
-
if (
|
|
4392
|
+
if (isStr(e)) return { items: [e.text], opaque: false };
|
|
2976
4393
|
if (!ts3.isArrayLiteralExpression(e)) return { items: [], opaque: true };
|
|
2977
4394
|
const items = [];
|
|
2978
4395
|
let opaque = false;
|
|
2979
4396
|
for (const el of e.elements) {
|
|
2980
|
-
if (
|
|
4397
|
+
if (isStr(el)) items.push(el.text);
|
|
2981
4398
|
else if (ts3.isSpreadElement(el) && /^(?:configDefaults\.|default)(?:exclude|include)$/i.test(el.expression.getText())) {
|
|
2982
4399
|
items.push(.../exclude$/i.test(el.expression.getText()) ? VITEST_DEFAULT_EXCLUDE : VITEST_DEFAULT_INCLUDE);
|
|
2983
4400
|
} else opaque = true;
|
|
2984
4401
|
}
|
|
2985
4402
|
return { items, opaque };
|
|
2986
4403
|
}
|
|
2987
|
-
function
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
4404
|
+
function ownerKey2(node) {
|
|
4405
|
+
const obj = node.parent;
|
|
4406
|
+
if (!obj || !ts3.isObjectLiteralExpression(obj)) return null;
|
|
4407
|
+
let q = obj.parent;
|
|
4408
|
+
while (q && (ts3.isParenthesizedExpression(q) || ts3.isAsExpression(q) || q.kind === ts3.SyntaxKind.SatisfiesExpression)) q = q.parent;
|
|
4409
|
+
return q && ts3.isPropertyAssignment(q) ? keyName2(q.name) : null;
|
|
2992
4410
|
}
|
|
2993
4411
|
function asExpression2(src) {
|
|
2994
4412
|
const t = src.trim();
|
|
2995
4413
|
return t.startsWith("{") ? `(${t})` : src;
|
|
2996
4414
|
}
|
|
2997
|
-
function runnerOf(path) {
|
|
4415
|
+
function runnerOf(path, content) {
|
|
2998
4416
|
const base = path.split("/").pop() ?? path;
|
|
2999
4417
|
if (/^jest\.config\./.test(base) || base === "package.json") return "jest";
|
|
3000
|
-
if (/^vitest\.config\./.test(base)) return "vitest";
|
|
4418
|
+
if (/^vitest\.(?:config|workspace)\./.test(base)) return "vitest";
|
|
4419
|
+
if (/^vite\.config\./.test(base) && content != null && /(?:^|[\s,{])["']?test["']?\s*:/m.test(content)) return "vitest";
|
|
3001
4420
|
return null;
|
|
3002
4421
|
}
|
|
3003
|
-
|
|
3004
|
-
|
|
4422
|
+
var isWorkspaceFile = (path) => /^vitest\.workspace\./.test(path.split("/").pop() ?? path);
|
|
4423
|
+
var empty = () => ({ include: null, regex: null, ignore: null, roots: null, base: null, projects: null, opaque: false, present: false });
|
|
4424
|
+
var stripRootDir = (g) => g.replace(/^<rootDir>\/?/, "");
|
|
4425
|
+
function dirValue(e) {
|
|
4426
|
+
if (!isStr(e)) return { dir: null, opaque: true };
|
|
4427
|
+
const d = stripRootDir(e.text).replace(/^\.\//, "").replace(/\/+$/, "");
|
|
4428
|
+
if (d === "." || d === "" || d.split("/").includes("..")) return { dir: null, opaque: false };
|
|
4429
|
+
return { dir: d, opaque: false };
|
|
4430
|
+
}
|
|
4431
|
+
function collect(root, runner) {
|
|
4432
|
+
const sel = empty();
|
|
4433
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4434
|
+
const take = (key2, e) => {
|
|
4435
|
+
const { items, opaque } = literals2(e);
|
|
4436
|
+
if (opaque || seen.has(key2)) sel.opaque = true;
|
|
4437
|
+
seen.add(key2);
|
|
4438
|
+
sel.present = true;
|
|
4439
|
+
return items.map(stripRootDir);
|
|
4440
|
+
};
|
|
4441
|
+
const ignores = (key2, e) => {
|
|
4442
|
+
const entries = take(key2, e).map((pattern) => ({ pattern, key: key2 }));
|
|
4443
|
+
sel.ignore = [...sel.ignore ?? [], ...entries];
|
|
4444
|
+
};
|
|
4445
|
+
const projects = (e) => {
|
|
4446
|
+
sel.present = true;
|
|
4447
|
+
if (!ts3.isArrayLiteralExpression(e)) {
|
|
4448
|
+
sel.opaque = true;
|
|
4449
|
+
return;
|
|
4450
|
+
}
|
|
4451
|
+
const list2 = [];
|
|
4452
|
+
for (const el of e.elements) {
|
|
4453
|
+
if (ts3.isObjectLiteralExpression(el)) list2.push(collect(el, runner));
|
|
4454
|
+
else sel.opaque = true;
|
|
4455
|
+
}
|
|
4456
|
+
sel.projects = list2;
|
|
4457
|
+
if (list2.some((p) => p.opaque)) sel.opaque = true;
|
|
4458
|
+
};
|
|
4459
|
+
const visit = (node) => {
|
|
4460
|
+
if (ts3.isPropertyAssignment(node)) {
|
|
4461
|
+
const k = keyName2(node.name);
|
|
4462
|
+
if (runner === "jest") {
|
|
4463
|
+
if (k === "testMatch") sel.include = take(k, node.initializer);
|
|
4464
|
+
else if (k === "testRegex") sel.regex = take(k, node.initializer);
|
|
4465
|
+
else if (k === "testPathIgnorePatterns" || k === "modulePathIgnorePatterns") ignores(k, node.initializer);
|
|
4466
|
+
else if (k === "roots") sel.roots = take(k, node.initializer);
|
|
4467
|
+
else if (k === "rootDir") {
|
|
4468
|
+
const { dir, opaque } = dirValue(node.initializer);
|
|
4469
|
+
sel.present = true;
|
|
4470
|
+
if (opaque) sel.opaque = true;
|
|
4471
|
+
else sel.base = dir;
|
|
4472
|
+
} else if (k === "projects") {
|
|
4473
|
+
projects(node.initializer);
|
|
4474
|
+
return;
|
|
4475
|
+
}
|
|
4476
|
+
} else if (ownerKey2(node) === "test") {
|
|
4477
|
+
if (k === "include") sel.include = take(k, node.initializer);
|
|
4478
|
+
else if (k === "exclude") ignores(k, node.initializer);
|
|
4479
|
+
else if (k === "dir") {
|
|
4480
|
+
const { dir, opaque } = dirValue(node.initializer);
|
|
4481
|
+
sel.present = true;
|
|
4482
|
+
if (opaque) sel.opaque = true;
|
|
4483
|
+
else sel.base = dir;
|
|
4484
|
+
} else if (k === "projects") {
|
|
4485
|
+
projects(node.initializer);
|
|
4486
|
+
return;
|
|
4487
|
+
}
|
|
4488
|
+
}
|
|
4489
|
+
}
|
|
4490
|
+
ts3.forEachChild(node, visit);
|
|
4491
|
+
};
|
|
4492
|
+
visit(root);
|
|
4493
|
+
return sel;
|
|
4494
|
+
}
|
|
4495
|
+
function workspaceArray(sf) {
|
|
4496
|
+
for (const st of sf.statements) {
|
|
4497
|
+
let e;
|
|
4498
|
+
if (ts3.isExportAssignment(st)) e = st.expression;
|
|
4499
|
+
else if (ts3.isExpressionStatement(st) && ts3.isBinaryExpression(st.expression) && /module\.exports/.test(st.expression.left.getText())) e = st.expression.right;
|
|
4500
|
+
if (!e) continue;
|
|
4501
|
+
while (ts3.isParenthesizedExpression(e) || ts3.isAsExpression(e) || e.kind === ts3.SyntaxKind.SatisfiesExpression) {
|
|
4502
|
+
e = e.expression;
|
|
4503
|
+
}
|
|
4504
|
+
if (ts3.isCallExpression(e) && e.arguments.length > 0) e = e.arguments[0];
|
|
4505
|
+
return ts3.isArrayLiteralExpression(e) ? e : null;
|
|
4506
|
+
}
|
|
4507
|
+
return null;
|
|
4508
|
+
}
|
|
4509
|
+
function parseSelection(src, runner, path = "") {
|
|
4510
|
+
let sel = empty();
|
|
3005
4511
|
try {
|
|
3006
4512
|
const sf = ts3.createSourceFile("cfg.ts", asExpression2(src), ts3.ScriptTarget.Latest, true, ts3.ScriptKind.TS);
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
};
|
|
3015
|
-
const visit = (node) => {
|
|
3016
|
-
if (ts3.isPropertyAssignment(node)) {
|
|
3017
|
-
const k = keyName2(node.name);
|
|
3018
|
-
if (runner === "jest") {
|
|
3019
|
-
if (k === "testMatch") sel.include = take(k, node.initializer);
|
|
3020
|
-
else if (k === "testRegex") sel.regex = take(k, node.initializer);
|
|
3021
|
-
else if (k === "testPathIgnorePatterns") sel.ignore = take(k, node.initializer);
|
|
3022
|
-
} else if (underKey2(node, "test") && !underKey2(node, "coverage")) {
|
|
3023
|
-
if (k === "include") sel.include = take(k, node.initializer);
|
|
3024
|
-
else if (k === "exclude") sel.ignore = take(k, node.initializer);
|
|
4513
|
+
if (runner === "vitest" && isWorkspaceFile(path)) {
|
|
4514
|
+
const arr = workspaceArray(sf);
|
|
4515
|
+
if (arr) {
|
|
4516
|
+
const list2 = [];
|
|
4517
|
+
for (const el of arr.elements) {
|
|
4518
|
+
if (ts3.isObjectLiteralExpression(el)) list2.push(collect(el, runner));
|
|
4519
|
+
else sel.opaque = true;
|
|
3025
4520
|
}
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
4521
|
+
sel.projects = list2;
|
|
4522
|
+
sel.present = true;
|
|
4523
|
+
if (list2.some((p) => p.opaque)) sel.opaque = true;
|
|
4524
|
+
} else if (src.trim()) sel.opaque = true;
|
|
4525
|
+
} else sel = collect(sf, runner);
|
|
3030
4526
|
} catch {
|
|
3031
4527
|
}
|
|
3032
4528
|
return sel;
|
|
3033
4529
|
}
|
|
3034
|
-
var
|
|
4530
|
+
var globs3 = (patterns) => {
|
|
3035
4531
|
const ms = patterns.map((g) => {
|
|
3036
4532
|
try {
|
|
3037
4533
|
return picomatch3(g, { dot: true });
|
|
@@ -3041,6 +4537,11 @@ var globs2 = (patterns) => {
|
|
|
3041
4537
|
});
|
|
3042
4538
|
return (path) => ms.some((m) => m(path));
|
|
3043
4539
|
};
|
|
4540
|
+
var globList2 = (patterns) => {
|
|
4541
|
+
const pos = globs3(patterns.filter((g) => !g.startsWith("!")));
|
|
4542
|
+
const neg = globs3(patterns.filter((g) => g.startsWith("!")).map((g) => g.slice(1)));
|
|
4543
|
+
return (path) => pos(path) && !neg(path);
|
|
4544
|
+
};
|
|
3044
4545
|
var regexes = (patterns) => {
|
|
3045
4546
|
const rs = patterns.flatMap((p) => {
|
|
3046
4547
|
try {
|
|
@@ -3051,29 +4552,44 @@ var regexes = (patterns) => {
|
|
|
3051
4552
|
});
|
|
3052
4553
|
return (path) => rs.some((r) => r.test("/" + path));
|
|
3053
4554
|
};
|
|
4555
|
+
var underDir = (path, dir) => dir === "" || path.startsWith(dir + "/");
|
|
3054
4556
|
function predicate(sel, runner) {
|
|
4557
|
+
if (sel.projects) {
|
|
4558
|
+
const ps = sel.projects.map((p) => predicate(p, runner));
|
|
4559
|
+
return {
|
|
4560
|
+
selects: (p) => ps.some((x) => x.selects(p)),
|
|
4561
|
+
why: (p) => ps.length ? `no project selects it (${ps[0].why(p)})` : "the projects list is empty"
|
|
4562
|
+
};
|
|
4563
|
+
}
|
|
3055
4564
|
const includeList = sel.include ?? (runner === "jest" ? sel.regex ? null : JEST_DEFAULT_MATCH : VITEST_DEFAULT_INCLUDE);
|
|
3056
|
-
const ignoreList = sel.ignore ?? (runner === "jest" ? JEST_DEFAULT_IGNORE : VITEST_DEFAULT_EXCLUDE);
|
|
3057
|
-
const included = includeList ?
|
|
3058
|
-
const
|
|
4565
|
+
const ignoreList = sel.ignore ?? (runner === "jest" ? JEST_DEFAULT_IGNORE : VITEST_DEFAULT_EXCLUDE).map((pattern) => ({ pattern, key: runner === "jest" ? "testPathIgnorePatterns" : "test.exclude" }));
|
|
4566
|
+
const included = includeList ? globList2(includeList) : sel.regex ? regexes(sel.regex) : () => true;
|
|
4567
|
+
const ignoredBy = (p) => ignoreList.find((e) => (runner === "jest" ? regexes([e.pattern]) : globs3([e.pattern]))(p));
|
|
4568
|
+
const roots = sel.roots?.map((r) => r.replace(/^\.\//, "").replace(/\/+$/, "")).map((r) => r === "." ? "" : r) ?? null;
|
|
4569
|
+
const rebase = (p) => sel.base === null ? p : underDir(p, sel.base) ? p.slice(sel.base.length + 1) : null;
|
|
4570
|
+
const inRoots = (p) => roots === null || roots.some((r) => underDir(p, r));
|
|
3059
4571
|
return {
|
|
3060
|
-
selects: (p) =>
|
|
4572
|
+
selects: (p) => {
|
|
4573
|
+
const r = rebase(p);
|
|
4574
|
+
return r !== null && inRoots(r) && included(r) && !ignoredBy(r);
|
|
4575
|
+
},
|
|
3061
4576
|
why: (p) => {
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
4577
|
+
const r = rebase(p);
|
|
4578
|
+
const baseKey = runner === "jest" ? "rootDir" : "test.dir";
|
|
4579
|
+
if (r === null) return `${baseKey} no longer covers it (${JSON.stringify(sel.base)})`;
|
|
4580
|
+
if (!inRoots(r)) return `roots no longer covers it (${JSON.stringify(sel.roots)})`;
|
|
4581
|
+
const hit = ignoredBy(r);
|
|
4582
|
+
if (hit) return `${hit.key === "exclude" ? "test.exclude" : hit.key} now matches it (${JSON.stringify(hit.pattern)})`;
|
|
3067
4583
|
const key2 = sel.include ? runner === "jest" ? "testMatch" : "test.include" : "testRegex";
|
|
3068
4584
|
return `${key2} no longer matches it (${JSON.stringify(sel.include ?? sel.regex ?? [])})`;
|
|
3069
4585
|
}
|
|
3070
4586
|
};
|
|
3071
4587
|
}
|
|
3072
4588
|
function suiteNarrowings(before, after, path, samples) {
|
|
3073
|
-
const runner = runnerOf(path);
|
|
4589
|
+
const runner = runnerOf(path, after) ?? runnerOf(path, before);
|
|
3074
4590
|
if (!runner) return [];
|
|
3075
|
-
const b = parseSelection(before ?? "", runner);
|
|
3076
|
-
const a = parseSelection(after, runner);
|
|
4591
|
+
const b = parseSelection(before ?? "", runner, path);
|
|
4592
|
+
const a = parseSelection(after, runner, path);
|
|
3077
4593
|
if (!a.present || a.opaque) return [];
|
|
3078
4594
|
const pb = predicate(b, runner);
|
|
3079
4595
|
const pa = predicate(a, runner);
|
|
@@ -3105,6 +4621,18 @@ function calleeName(expr) {
|
|
|
3105
4621
|
return null;
|
|
3106
4622
|
}
|
|
3107
4623
|
var isEachOf = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && (expr.expression.text === "it" || expr.expression.text === "test") && expr.name.text === "each";
|
|
4624
|
+
var isDescribeEach = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && expr.expression.text === "describe" && expr.name.text === "each";
|
|
4625
|
+
function templateRows(node) {
|
|
4626
|
+
const rows = node.template.getText().split("\n").filter((l) => /\S/.test(l.replace(/[`]/g, ""))).length - 1;
|
|
4627
|
+
return Math.max(rows, 1);
|
|
4628
|
+
}
|
|
4629
|
+
function hasSubstantiveBody(call) {
|
|
4630
|
+
const fn = call.arguments.find((a) => ts4.isArrowFunction(a) || ts4.isFunctionExpression(a));
|
|
4631
|
+
if (!fn) return true;
|
|
4632
|
+
const body = fn.body;
|
|
4633
|
+
const text = ts4.isBlock(body) ? body.getText().slice(1, -1) : body.getText();
|
|
4634
|
+
return text.split("\n").some((l) => isSignificantLine(l.trim(), "js"));
|
|
4635
|
+
}
|
|
3108
4636
|
function eachRows(call) {
|
|
3109
4637
|
const arg = call.arguments[0];
|
|
3110
4638
|
if (!arg) return { n: 1, open: true };
|
|
@@ -3119,7 +4647,7 @@ function eachRows(call) {
|
|
|
3119
4647
|
}
|
|
3120
4648
|
return { n: 1, open: true };
|
|
3121
4649
|
}
|
|
3122
|
-
function countTests(src, path = "spec.ts") {
|
|
4650
|
+
function countTests(src, path = "spec.ts", substantiveOnly = false) {
|
|
3123
4651
|
const lang = langOf(path);
|
|
3124
4652
|
if (lang && lang !== "js") {
|
|
3125
4653
|
const re = TEST_DEFS[lang];
|
|
@@ -3130,23 +4658,33 @@ function countTests(src, path = "spec.ts") {
|
|
|
3130
4658
|
const sf = ts4.createSourceFile("spec.ts", src, ts4.ScriptTarget.Latest, true, ts4.ScriptKind.TS);
|
|
3131
4659
|
let n = 0;
|
|
3132
4660
|
let open = false;
|
|
3133
|
-
const visit = (node) => {
|
|
4661
|
+
const visit = (node, mult) => {
|
|
3134
4662
|
if (ts4.isCallExpression(node)) {
|
|
4663
|
+
const head = node.expression;
|
|
4664
|
+
if (ts4.isCallExpression(head) && isDescribeEach(head.expression)) {
|
|
4665
|
+
const r = eachRows(head);
|
|
4666
|
+
if (r.open) open = true;
|
|
4667
|
+
for (const a of node.arguments) visit(a, mult * r.n);
|
|
4668
|
+
return;
|
|
4669
|
+
}
|
|
4670
|
+
if (ts4.isTaggedTemplateExpression(head) && isDescribeEach(head.tag)) {
|
|
4671
|
+
for (const a of node.arguments) visit(a, mult * templateRows(head));
|
|
4672
|
+
return;
|
|
4673
|
+
}
|
|
3135
4674
|
if (isEachOf(node.expression)) {
|
|
3136
4675
|
const r = eachRows(node);
|
|
3137
|
-
n += r.n;
|
|
4676
|
+
n += r.n * mult;
|
|
3138
4677
|
if (r.open) open = true;
|
|
3139
4678
|
} else {
|
|
3140
4679
|
const name = calleeName(node.expression);
|
|
3141
|
-
if (name === "it" || name === "test") n
|
|
4680
|
+
if ((name === "it" || name === "test") && (!substantiveOnly || hasSubstantiveBody(node))) n += mult;
|
|
3142
4681
|
}
|
|
3143
4682
|
} else if (ts4.isTaggedTemplateExpression(node) && isEachOf(node.tag)) {
|
|
3144
|
-
|
|
3145
|
-
n += Math.max(rows, 1);
|
|
4683
|
+
n += templateRows(node) * mult;
|
|
3146
4684
|
}
|
|
3147
|
-
ts4.forEachChild(node, visit);
|
|
4685
|
+
ts4.forEachChild(node, (c) => visit(c, mult));
|
|
3148
4686
|
};
|
|
3149
|
-
visit(sf);
|
|
4687
|
+
visit(sf, 1);
|
|
3150
4688
|
return { min: n, open };
|
|
3151
4689
|
}
|
|
3152
4690
|
function countTestBlocks(src, path = "spec.ts") {
|
|
@@ -3161,7 +4699,7 @@ function significantLines(src, path) {
|
|
|
3161
4699
|
}
|
|
3162
4700
|
return out2;
|
|
3163
4701
|
}
|
|
3164
|
-
var OTHER_RUNNER_DIR = /(?:^|\/)(?:e2e|cypress|playwright)\//;
|
|
4702
|
+
var OTHER_RUNNER_DIR = /(?:^|\/)(?:e2e|cypress|playwright|fixtures|__fixtures__|__mocks__)\//;
|
|
3165
4703
|
function runnerSamples(configPath, policy, ctx) {
|
|
3166
4704
|
const files = trackedFiles(ctx);
|
|
3167
4705
|
if (!files) return CANONICAL_SAMPLES;
|
|
@@ -3169,6 +4707,34 @@ function runnerSamples(configPath, policy, ctx) {
|
|
|
3169
4707
|
const own = files.filter((f) => f.startsWith(root) && langOf(f) === "js" && isProtected(f, policy, "tests") && !OTHER_RUNNER_DIR.test(f)).map((f) => f.slice(root.length));
|
|
3170
4708
|
return own.length ? own : CANONICAL_SAMPLES;
|
|
3171
4709
|
}
|
|
4710
|
+
function scriptNarrowings(before, after, path) {
|
|
4711
|
+
if ((path.split("/").pop() ?? path) !== "package.json") return [];
|
|
4712
|
+
const scriptsOf = (src) => {
|
|
4713
|
+
try {
|
|
4714
|
+
const s = JSON.parse(src ?? "{}").scripts ?? {};
|
|
4715
|
+
return Object.fromEntries(Object.entries(s).filter((e) => typeof e[1] === "string"));
|
|
4716
|
+
} catch {
|
|
4717
|
+
return {};
|
|
4718
|
+
}
|
|
4719
|
+
};
|
|
4720
|
+
const was = scriptsOf(before);
|
|
4721
|
+
const now = scriptsOf(after);
|
|
4722
|
+
const out2 = [];
|
|
4723
|
+
for (const [name, cmd] of Object.entries(now)) {
|
|
4724
|
+
if (!/^test(?:$|[:.-])/.test(name) || !/\b(?:jest|vitest)\b/.test(cmd)) continue;
|
|
4725
|
+
const prev = was[name] ?? "";
|
|
4726
|
+
const flags = new RegExp(SUITE_NARROWING_FLAGS.source, "g");
|
|
4727
|
+
for (const m of cmd.matchAll(flags)) {
|
|
4728
|
+
const flag = m[0].trim();
|
|
4729
|
+
if (!flag) continue;
|
|
4730
|
+
const one = new RegExp(SUITE_NARROWING_FLAGS.source);
|
|
4731
|
+
if (one.test(prev) && prev.includes(flag)) continue;
|
|
4732
|
+
out2.push({ script: name, flag, before: prev, after: cmd });
|
|
4733
|
+
break;
|
|
4734
|
+
}
|
|
4735
|
+
}
|
|
4736
|
+
return out2;
|
|
4737
|
+
}
|
|
3172
4738
|
function joinRoot(root, to) {
|
|
3173
4739
|
const parts = root ? root.split("/") : [];
|
|
3174
4740
|
for (const seg of to.replace(/\/+$/, "").split("/")) {
|
|
@@ -3200,11 +4766,11 @@ var testDeletion = {
|
|
|
3200
4766
|
if (c.kind !== "file" || c.after == null || !isProtected(c.path, policy, "tests")) continue;
|
|
3201
4767
|
if (c.op === "add") {
|
|
3202
4768
|
for (const l of significantLines(c.after, c.path)) addedTestLines.add(l);
|
|
3203
|
-
addedTestBlocks +=
|
|
4769
|
+
addedTestBlocks += countTests(c.after, c.path, true).min;
|
|
3204
4770
|
} else if (c.before != null) {
|
|
3205
4771
|
const before = significantLines(c.before, c.path);
|
|
3206
4772
|
for (const l of significantLines(c.after, c.path)) if (!before.has(l)) addedTestLines.add(l);
|
|
3207
|
-
addedTestBlocks += Math.max(0,
|
|
4773
|
+
addedTestBlocks += Math.max(0, countTests(c.after, c.path, true).min - countTests(c.before, c.path, true).min);
|
|
3208
4774
|
}
|
|
3209
4775
|
}
|
|
3210
4776
|
const isRelocation = (removed, blocksRemoved) => {
|
|
@@ -3251,7 +4817,7 @@ var testDeletion = {
|
|
|
3251
4817
|
})
|
|
3252
4818
|
);
|
|
3253
4819
|
}
|
|
3254
|
-
} else if (c.op !== "delete" && c.after != null && !isTest && isProtected(c.path, policy, "config") && runnerOf(c.path)) {
|
|
4820
|
+
} else if (c.op !== "delete" && c.after != null && !isTest && isProtected(c.path, policy, "config") && runnerOf(c.path, c.after)) {
|
|
3255
4821
|
for (const n of suiteNarrowings(c.before, c.after, c.path, runnerSamples(c.path, policy, ctx))) {
|
|
3256
4822
|
out2.push(
|
|
3257
4823
|
makeFinding(RULE7, policy, {
|
|
@@ -3262,6 +4828,16 @@ var testDeletion = {
|
|
|
3262
4828
|
})
|
|
3263
4829
|
);
|
|
3264
4830
|
}
|
|
4831
|
+
for (const n of scriptNarrowings(c.before, c.after, c.path)) {
|
|
4832
|
+
out2.push(
|
|
4833
|
+
makeFinding(RULE7, policy, {
|
|
4834
|
+
file: c.path,
|
|
4835
|
+
message: `The test script now narrows the suite: ${n.flag} added to scripts.${n.script}.`,
|
|
4836
|
+
evidence: `"${n.script}": ${JSON.stringify(n.before)} \u2192 ${JSON.stringify(n.after)}`,
|
|
4837
|
+
remediation: "Keep the test script running the whole suite; a runner flag that selects fewer specs removes the rest from every run."
|
|
4838
|
+
})
|
|
4839
|
+
);
|
|
4840
|
+
}
|
|
3265
4841
|
}
|
|
3266
4842
|
} else {
|
|
3267
4843
|
let root = "";
|
|
@@ -3275,15 +4851,25 @@ var testDeletion = {
|
|
|
3275
4851
|
const inRepo = (t) => root ? `${root}/${t}` : t;
|
|
3276
4852
|
const listing = trackedFiles(ctx);
|
|
3277
4853
|
const testToks = toks.filter((t) => {
|
|
3278
|
-
|
|
4854
|
+
const resolved = root !== null && /(?:^|\/)\.{1,2}(?:\/|$)/.test(t) ? joinRoot(root, t) : null;
|
|
4855
|
+
const probe = resolved ?? t;
|
|
4856
|
+
if (!isProtected(probe, policy, "tests")) return false;
|
|
3279
4857
|
if (!listing || root === null) return true;
|
|
3280
|
-
const looksLikeFile = /\.[A-Za-z0-9]+$/.test(
|
|
3281
|
-
if (looksLikeFile || listing.includes(inRepo(t))) return true;
|
|
3282
|
-
return containsProtected(inRepo(t), policy, "tests", ctx);
|
|
4858
|
+
const looksLikeFile = /\.[A-Za-z0-9]+$/.test(probe.split("/").pop() ?? "");
|
|
4859
|
+
if (looksLikeFile || listing.includes(resolved ?? inRepo(t))) return true;
|
|
4860
|
+
return containsProtected(resolved ?? inRepo(t), policy, "tests", ctx);
|
|
4861
|
+
});
|
|
4862
|
+
const rootHasProtected = () => listing ? listing.some((f) => isProtected(f, policy, "tests")) : true;
|
|
4863
|
+
const dirToks = toks.filter((t) => {
|
|
4864
|
+
if (t.startsWith("-") || testToks.includes(t)) return false;
|
|
4865
|
+
const whole = /^(?:\.|\.\/|\*|\.\/\*)$/.test(t);
|
|
4866
|
+
if (!whole && /[*?{}[\]]/.test(t)) return false;
|
|
4867
|
+
if (root === null) return whole ? false : containsProtected(t, policy, "tests", ctx);
|
|
4868
|
+
const dir = whole ? root : joinRoot(root, t);
|
|
4869
|
+
if (dir === null) return false;
|
|
4870
|
+
if (dir === "") return rootHasProtected();
|
|
4871
|
+
return containsProtected(dir, policy, "tests", ctx);
|
|
3283
4872
|
});
|
|
3284
|
-
const dirToks = toks.filter(
|
|
3285
|
-
(t) => !t.startsWith("-") && !testToks.includes(t) && !/[*?{}[\]]/.test(t) && (root === null ? containsProtected(t, policy, "tests", ctx) : containsProtected(inRepo(t), policy, "tests", ctx))
|
|
3286
|
-
);
|
|
3287
4873
|
if (testToks.length === 0 && dirToks.length === 0) continue;
|
|
3288
4874
|
const named = [...testToks, ...dirToks];
|
|
3289
4875
|
const cmd = toks[0] ?? "";
|
|
@@ -3294,8 +4880,11 @@ var testDeletion = {
|
|
|
3294
4880
|
const findDeletes = cmd === "find" && /\s(?:-delete\b|-exec\s+(?:rm|unlink)\b|-execdir\s+(?:rm|unlink)\b)/.test(seg);
|
|
3295
4881
|
const gitRestoresOld = cmd === "git" && /^git\s+(?:checkout|restore)\b/.test(seg) && (() => {
|
|
3296
4882
|
const src = seg.match(/--source[= ]\s*(\S+)/)?.[1];
|
|
3297
|
-
const
|
|
3298
|
-
|
|
4883
|
+
const revIdx = toks.findIndex((t, i) => i >= 2 && !t.startsWith("-") && !named.includes(t) && t !== "--");
|
|
4884
|
+
const rev = src ?? (revIdx >= 0 ? toks[revIdx] : void 0);
|
|
4885
|
+
if (!rev || rev === "HEAD") return false;
|
|
4886
|
+
if (src || seg.includes(" -- ")) return true;
|
|
4887
|
+
return revIdx >= 0 && toks.some((t, i) => i > revIdx && named.includes(t));
|
|
3299
4888
|
})();
|
|
3300
4889
|
if (/\brm\b/.test(seg) && !/^(?:git|npm|yarn|pnpm|bun)\b/.test(seg)) {
|
|
3301
4890
|
why = "rm deletes a test file";
|
|
@@ -3350,7 +4939,7 @@ var MIN_REMOVED_LINES = 3;
|
|
|
3350
4939
|
var MIN_REMOVED_ROWS = 2;
|
|
3351
4940
|
var ws = (s) => s.replace(/\s+/g, "");
|
|
3352
4941
|
function eachTables(src, path) {
|
|
3353
|
-
const rows = /* @__PURE__ */ new
|
|
4942
|
+
const rows = /* @__PURE__ */ new Map();
|
|
3354
4943
|
let open = false;
|
|
3355
4944
|
if (langOf(path) !== "js" && langOf(path) !== null) return { rows, open };
|
|
3356
4945
|
try {
|
|
@@ -3364,10 +4953,9 @@ function eachTables(src, path) {
|
|
|
3364
4953
|
open = true;
|
|
3365
4954
|
continue;
|
|
3366
4955
|
}
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
}
|
|
4956
|
+
const text = el.getText(sf).replace(/\s*\n\s*/g, " ");
|
|
4957
|
+
const key2 = ws(text);
|
|
4958
|
+
if (key2.length >= 3) rows.set(key2, text);
|
|
3371
4959
|
}
|
|
3372
4960
|
} else open = true;
|
|
3373
4961
|
}
|
|
@@ -3422,12 +5010,13 @@ var testContentRemoval = {
|
|
|
3422
5010
|
gone.push(line);
|
|
3423
5011
|
}
|
|
3424
5012
|
const table = eachTables(c.before, c.path);
|
|
3425
|
-
const
|
|
5013
|
+
const tableAfter = eachTables(c.after, c.path);
|
|
5014
|
+
const goneRows = table.rows.size && tableAfter.open ? [...table.rows].filter(([k]) => !tableAfter.rows.has(k) && !addedPool.includes(k)).map(([, text]) => text) : [];
|
|
3426
5015
|
if (goneRows.length >= MIN_REMOVED_ROWS) {
|
|
3427
5016
|
out2.push(
|
|
3428
5017
|
makeFinding(RULE8, policy, {
|
|
3429
5018
|
file: c.path,
|
|
3430
|
-
message: `Rows removed from a test table: ${goneRows.length} case(s) gone from an each-table that
|
|
5019
|
+
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.`,
|
|
3431
5020
|
evidence: goneRows.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
|
|
3432
5021
|
remediation: "Fix the code under test. A row of it.each is a test; moving the table out of the spec must carry every row with it."
|
|
3433
5022
|
})
|
|
@@ -3564,7 +5153,7 @@ var GUARDED_RULE = "hook-tampering";
|
|
|
3564
5153
|
var BASELINE_HOOKS = defaultPolicy().protected.hooks;
|
|
3565
5154
|
function isGuardedFinding(f) {
|
|
3566
5155
|
if (f.rule !== GUARDED_RULE || !f.file) return false;
|
|
3567
|
-
return isPolicyFile(f.file) || matchesAny(f.file, BASELINE_HOOKS);
|
|
5156
|
+
return isPolicyFile(f.file) || matchesAny(f.file, BASELINE_HOOKS) || isClaudeSettings(f.file);
|
|
3568
5157
|
}
|
|
3569
5158
|
function key(f) {
|
|
3570
5159
|
return `${f.rule}|${f.file ?? ""}|${f.line ?? ""}|${f.evidence}`;
|
|
@@ -3623,8 +5212,8 @@ function hasBlocking(findings) {
|
|
|
3623
5212
|
}
|
|
3624
5213
|
|
|
3625
5214
|
// src/policy-load.ts
|
|
3626
|
-
import { readFileSync as
|
|
3627
|
-
import { isAbsolute as isAbsolute2, join as
|
|
5215
|
+
import { readFileSync as readFileSync4, existsSync as existsSync2 } from "node:fs";
|
|
5216
|
+
import { isAbsolute as isAbsolute2, join as join4, posix } from "node:path";
|
|
3628
5217
|
import { parse as parse2 } from "yaml";
|
|
3629
5218
|
var PolicyError = class extends Error {
|
|
3630
5219
|
};
|
|
@@ -3672,8 +5261,8 @@ function validate(r, where) {
|
|
|
3672
5261
|
if (r.ignore !== void 0 && !isStringList(r.ignore)) bad(`ignore must be a list of globs, got ${show(r.ignore)}`);
|
|
3673
5262
|
if (r.protected !== void 0) {
|
|
3674
5263
|
if (!isMapping(r.protected)) bad(`protected must be a mapping of category to a list of globs, got ${show(r.protected)}`);
|
|
3675
|
-
for (const [cat,
|
|
3676
|
-
if (!isStringList(
|
|
5264
|
+
for (const [cat, globs4] of Object.entries(r.protected)) {
|
|
5265
|
+
if (!isStringList(globs4)) bad(`protected.${cat} must be a list of globs, got ${show(globs4)}`);
|
|
3677
5266
|
}
|
|
3678
5267
|
}
|
|
3679
5268
|
if (r.signoff !== void 0) {
|
|
@@ -3705,10 +5294,10 @@ function parsePolicy(raw, where = POLICY_FILE) {
|
|
|
3705
5294
|
const version = normalizeVersion(r.version, where);
|
|
3706
5295
|
validate(r, where);
|
|
3707
5296
|
const base = defaultPolicy(version);
|
|
3708
|
-
const
|
|
3709
|
-
const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat,
|
|
5297
|
+
const globs4 = (list2) => list2?.map(normalizeGlob);
|
|
5298
|
+
const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list2]) => [cat, globs4(list2) ?? []])) : void 0;
|
|
3710
5299
|
const userRules = r.rules ? Object.fromEntries(
|
|
3711
|
-
Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude:
|
|
5300
|
+
Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude: globs4(cfg.exclude) } : cfg])
|
|
3712
5301
|
) : void 0;
|
|
3713
5302
|
return {
|
|
3714
5303
|
version,
|
|
@@ -3718,7 +5307,7 @@ function parsePolicy(raw, where = POLICY_FILE) {
|
|
|
3718
5307
|
// an `exclude`-only override drop the rule's baseline severity (see mergeRules).
|
|
3719
5308
|
protected: mergeProtected(base.protected, userProtected),
|
|
3720
5309
|
rules: mergeRules(base.rules, userRules),
|
|
3721
|
-
ignore:
|
|
5310
|
+
ignore: globs4(r.ignore) ?? base.ignore,
|
|
3722
5311
|
signoff: {
|
|
3723
5312
|
requiredFor: r.signoff?.required_for ?? r.signoff?.requiredFor ?? base.signoff.requiredFor,
|
|
3724
5313
|
ledger: r.signoff?.ledger ?? base.signoff.ledger
|
|
@@ -3748,9 +5337,9 @@ function parseOrThrow(src, where) {
|
|
|
3748
5337
|
return parsePolicy(raw, where);
|
|
3749
5338
|
}
|
|
3750
5339
|
function loadPolicy(cwd = process.cwd()) {
|
|
3751
|
-
const path =
|
|
5340
|
+
const path = join4(cwd, POLICY_FILE);
|
|
3752
5341
|
if (!existsSync2(path)) return defaultPolicy();
|
|
3753
|
-
return parseOrThrow(
|
|
5342
|
+
return parseOrThrow(readFileSync4(path, "utf8"), POLICY_FILE);
|
|
3754
5343
|
}
|
|
3755
5344
|
function loadPolicyAt(rev, cwd) {
|
|
3756
5345
|
const src = fileAt(rev, POLICY_FILE, { cwd });
|
|
@@ -3760,8 +5349,8 @@ function loadPolicyAt(rev, cwd) {
|
|
|
3760
5349
|
|
|
3761
5350
|
// src/signoff.ts
|
|
3762
5351
|
import { createHash } from "node:crypto";
|
|
3763
|
-
import { appendFileSync, mkdirSync, readFileSync as
|
|
3764
|
-
import { dirname, join as
|
|
5352
|
+
import { appendFileSync, mkdirSync, readFileSync as readFileSync5, existsSync as existsSync3 } from "node:fs";
|
|
5353
|
+
import { dirname as dirname2, join as join5 } from "node:path";
|
|
3765
5354
|
var DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
3766
5355
|
function fingerprint(rule, file, evidence) {
|
|
3767
5356
|
return createHash("sha256").update(`${rule}\0${file ?? ""}\0${evidence}`).digest("hex").slice(0, 16);
|
|
@@ -3772,13 +5361,13 @@ function ledgerPath(cwd, policy) {
|
|
|
3772
5361
|
if (!ledgerInsideRepo(rel)) {
|
|
3773
5362
|
throw new PolicyError(`signoff.ledger must be a relative path inside the repository, got ${JSON.stringify(rel)}`);
|
|
3774
5363
|
}
|
|
3775
|
-
return
|
|
5364
|
+
return join5(cwd, rel);
|
|
3776
5365
|
}
|
|
3777
5366
|
function readLedger(cwd, policy) {
|
|
3778
5367
|
const p = ledgerPath(cwd, policy);
|
|
3779
5368
|
if (!existsSync3(p)) return [];
|
|
3780
5369
|
const out2 = [];
|
|
3781
|
-
for (const line of
|
|
5370
|
+
for (const line of readFileSync5(p, "utf8").split("\n")) {
|
|
3782
5371
|
if (!line.trim()) continue;
|
|
3783
5372
|
try {
|
|
3784
5373
|
const e = JSON.parse(line);
|
|
@@ -3790,7 +5379,7 @@ function readLedger(cwd, policy) {
|
|
|
3790
5379
|
}
|
|
3791
5380
|
function appendEntry(cwd, policy, e) {
|
|
3792
5381
|
const p = ledgerPath(cwd, policy);
|
|
3793
|
-
mkdirSync(
|
|
5382
|
+
mkdirSync(dirname2(p), { recursive: true });
|
|
3794
5383
|
appendFileSync(p, JSON.stringify(e) + "\n");
|
|
3795
5384
|
}
|
|
3796
5385
|
function makeEntry(f, reason, now, ttlMs = DEFAULT_TTL_MS) {
|
|
@@ -4147,21 +5736,21 @@ function runCheck(opts) {
|
|
|
4147
5736
|
}
|
|
4148
5737
|
|
|
4149
5738
|
// src/cli/hook.ts
|
|
4150
|
-
import { readFileSync as
|
|
5739
|
+
import { readFileSync as readFileSync11, appendFileSync as appendFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync7, statSync as statSync4 } from "node:fs";
|
|
4151
5740
|
import { createHash as createHash4 } from "node:crypto";
|
|
4152
|
-
import { join as
|
|
5741
|
+
import { join as join10 } from "node:path";
|
|
4153
5742
|
|
|
4154
5743
|
// src/adapters/claude/changes.ts
|
|
4155
5744
|
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
4156
|
-
import { readFileSync as
|
|
5745
|
+
import { readFileSync as readFileSync6, existsSync as existsSync4, writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
4157
5746
|
import { tmpdir } from "node:os";
|
|
4158
|
-
import { isAbsolute as isAbsolute3, join as
|
|
5747
|
+
import { isAbsolute as isAbsolute3, join as join6, relative as relative2, resolve } from "node:path";
|
|
4159
5748
|
function asStr(v) {
|
|
4160
5749
|
return typeof v === "string" ? v : "";
|
|
4161
5750
|
}
|
|
4162
5751
|
function readDisk(path) {
|
|
4163
5752
|
try {
|
|
4164
|
-
return existsSync4(path) ?
|
|
5753
|
+
return existsSync4(path) ? readFileSync6(path, "utf8") : null;
|
|
4165
5754
|
} catch {
|
|
4166
5755
|
return null;
|
|
4167
5756
|
}
|
|
@@ -4184,11 +5773,11 @@ function synthFileChange(displayPath, before, after) {
|
|
|
4184
5773
|
if (before === null) op = "add";
|
|
4185
5774
|
else if (after === null) op = "delete";
|
|
4186
5775
|
else op = "modify";
|
|
4187
|
-
const dir = mkdtempSync(
|
|
5776
|
+
const dir = mkdtempSync(join6(tmpdir(), "hf-"));
|
|
4188
5777
|
let raw = "";
|
|
4189
5778
|
try {
|
|
4190
|
-
const a =
|
|
4191
|
-
const b =
|
|
5779
|
+
const a = join6(dir, "a");
|
|
5780
|
+
const b = join6(dir, "b");
|
|
4192
5781
|
writeFileSync(a, before ?? "");
|
|
4193
5782
|
writeFileSync(b, after ?? "");
|
|
4194
5783
|
try {
|
|
@@ -4273,14 +5862,14 @@ function formatDenial(blocks) {
|
|
|
4273
5862
|
}
|
|
4274
5863
|
|
|
4275
5864
|
// src/session.ts
|
|
4276
|
-
import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as
|
|
4277
|
-
import { dirname as
|
|
5865
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "node:fs";
|
|
5866
|
+
import { dirname as dirname3, join as join7 } from "node:path";
|
|
4278
5867
|
var UNSAFE = /[^A-Za-z0-9_-]/g;
|
|
4279
5868
|
var SHA = /^[0-9a-f]{7,40}$/;
|
|
4280
5869
|
function baselinePath(cwd, sessionId) {
|
|
4281
5870
|
const gd = gitDir(cwd);
|
|
4282
5871
|
if (!gd) return null;
|
|
4283
|
-
return
|
|
5872
|
+
return join7(gd, "tamperward", `session-${sessionId.replace(UNSAFE, "")}`);
|
|
4284
5873
|
}
|
|
4285
5874
|
function turnBaseline(cwd, sessionId) {
|
|
4286
5875
|
if (!sessionId) return null;
|
|
@@ -4288,12 +5877,12 @@ function turnBaseline(cwd, sessionId) {
|
|
|
4288
5877
|
const p = baselinePath(cwd, sessionId);
|
|
4289
5878
|
if (!p) return null;
|
|
4290
5879
|
if (existsSync5(p)) {
|
|
4291
|
-
const v =
|
|
5880
|
+
const v = readFileSync7(p, "utf8").trim();
|
|
4292
5881
|
if (SHA.test(v)) return v;
|
|
4293
5882
|
}
|
|
4294
5883
|
const head = headSha(cwd);
|
|
4295
5884
|
if (!head) return null;
|
|
4296
|
-
mkdirSync2(
|
|
5885
|
+
mkdirSync2(dirname3(p), { recursive: true });
|
|
4297
5886
|
writeFileSync2(p, head);
|
|
4298
5887
|
return head;
|
|
4299
5888
|
} catch {
|
|
@@ -4306,7 +5895,7 @@ function advanceTurnBaseline(cwd, sessionId) {
|
|
|
4306
5895
|
const p = baselinePath(cwd, sessionId);
|
|
4307
5896
|
const head = headSha(cwd);
|
|
4308
5897
|
if (!p || !head) return;
|
|
4309
|
-
mkdirSync2(
|
|
5898
|
+
mkdirSync2(dirname3(p), { recursive: true });
|
|
4310
5899
|
writeFileSync2(p, head);
|
|
4311
5900
|
} catch {
|
|
4312
5901
|
}
|
|
@@ -4314,8 +5903,8 @@ function advanceTurnBaseline(cwd, sessionId) {
|
|
|
4314
5903
|
|
|
4315
5904
|
// src/effect.ts
|
|
4316
5905
|
import { createHash as createHash2 } from "node:crypto";
|
|
4317
|
-
import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as
|
|
4318
|
-
import { isAbsolute as isAbsolute4, join as
|
|
5906
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync8, renameSync, rmSync as rmSync2, statSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
5907
|
+
import { isAbsolute as isAbsolute4, join as join8 } from "node:path";
|
|
4319
5908
|
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
4320
5909
|
var UNSAFE2 = /[^A-Za-z0-9._-]/g;
|
|
4321
5910
|
var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
|
|
@@ -4327,7 +5916,7 @@ function gitDir2(cwd) {
|
|
|
4327
5916
|
stdio: ["ignore", "pipe", "pipe"]
|
|
4328
5917
|
}).trim();
|
|
4329
5918
|
if (!d) return null;
|
|
4330
|
-
return isAbsolute4(d) ? d :
|
|
5919
|
+
return isAbsolute4(d) ? d : join8(cwd, d);
|
|
4331
5920
|
} catch {
|
|
4332
5921
|
return null;
|
|
4333
5922
|
}
|
|
@@ -4336,8 +5925,8 @@ function statePath(cwd, sessionId, kind) {
|
|
|
4336
5925
|
if (!sessionId) return null;
|
|
4337
5926
|
const gd = gitDir2(cwd);
|
|
4338
5927
|
if (!gd) return null;
|
|
4339
|
-
const dir =
|
|
4340
|
-
return
|
|
5928
|
+
const dir = join8(gd, "tamperward");
|
|
5929
|
+
return join8(dir, `${kind}-${sessionId.replace(UNSAFE2, "")}.json`);
|
|
4341
5930
|
}
|
|
4342
5931
|
function ptreePath(cwd, sessionId) {
|
|
4343
5932
|
return statePath(cwd, sessionId, "ptree");
|
|
@@ -4351,8 +5940,8 @@ function contentHash(content) {
|
|
|
4351
5940
|
}
|
|
4352
5941
|
function snapshotProtected(cwd, policy, prev) {
|
|
4353
5942
|
const out2 = {};
|
|
4354
|
-
const
|
|
4355
|
-
const abs2 = rel ?
|
|
5943
|
+
const walk2 = (rel) => {
|
|
5944
|
+
const abs2 = rel ? join8(cwd, rel) : cwd;
|
|
4356
5945
|
let names;
|
|
4357
5946
|
try {
|
|
4358
5947
|
names = readdirSync(abs2);
|
|
@@ -4364,15 +5953,15 @@ function snapshotProtected(cwd, policy, prev) {
|
|
|
4364
5953
|
const r = rel ? `${rel}/${name}` : name;
|
|
4365
5954
|
let st;
|
|
4366
5955
|
try {
|
|
4367
|
-
st = statSync(
|
|
5956
|
+
st = statSync(join8(cwd, r));
|
|
4368
5957
|
} catch {
|
|
4369
5958
|
continue;
|
|
4370
5959
|
}
|
|
4371
|
-
if (st.isDirectory())
|
|
5960
|
+
if (st.isDirectory()) walk2(r);
|
|
4372
5961
|
else if (st.isFile() && isProtected(r, policy)) {
|
|
4373
5962
|
let hash = "";
|
|
4374
5963
|
try {
|
|
4375
|
-
hash = sha(
|
|
5964
|
+
hash = sha(readFileSync8(join8(cwd, r)));
|
|
4376
5965
|
} catch {
|
|
4377
5966
|
hash = "unreadable";
|
|
4378
5967
|
}
|
|
@@ -4380,7 +5969,7 @@ function snapshotProtected(cwd, policy, prev) {
|
|
|
4380
5969
|
}
|
|
4381
5970
|
}
|
|
4382
5971
|
};
|
|
4383
|
-
|
|
5972
|
+
walk2("");
|
|
4384
5973
|
return out2;
|
|
4385
5974
|
}
|
|
4386
5975
|
function driftBetween(expected, current) {
|
|
@@ -4400,7 +5989,7 @@ function driftBetween(expected, current) {
|
|
|
4400
5989
|
function loadTree(p) {
|
|
4401
5990
|
if (!p || !existsSync6(p)) return null;
|
|
4402
5991
|
try {
|
|
4403
|
-
return JSON.parse(
|
|
5992
|
+
return JSON.parse(readFileSync8(p, "utf8"));
|
|
4404
5993
|
} catch {
|
|
4405
5994
|
return null;
|
|
4406
5995
|
}
|
|
@@ -4408,7 +5997,7 @@ function loadTree(p) {
|
|
|
4408
5997
|
function saveTree(p, tree) {
|
|
4409
5998
|
if (!p) return;
|
|
4410
5999
|
try {
|
|
4411
|
-
mkdirSync3(
|
|
6000
|
+
mkdirSync3(join8(p, ".."), { recursive: true });
|
|
4412
6001
|
const tmp = `${p}.${process.pid}.tmp`;
|
|
4413
6002
|
writeFileSync3(tmp, JSON.stringify(tree));
|
|
4414
6003
|
renameSync(tmp, p);
|
|
@@ -4439,15 +6028,15 @@ function dropTurnTree(cwd, sessionId) {
|
|
|
4439
6028
|
}
|
|
4440
6029
|
|
|
4441
6030
|
// src/cli/watch.ts
|
|
4442
|
-
import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as
|
|
4443
|
-
import { join as
|
|
6031
|
+
import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync9, statSync as statSync2, watch } from "node:fs";
|
|
6032
|
+
import { join as join9 } from "node:path";
|
|
4444
6033
|
import { createHash as createHash3 } from "node:crypto";
|
|
4445
6034
|
var SKIP2 = /(^|\/)(\.git|node_modules)(\/|$)/;
|
|
4446
6035
|
function snap(abs2) {
|
|
4447
6036
|
try {
|
|
4448
6037
|
const st = statSync2(abs2);
|
|
4449
6038
|
if (!st.isFile()) return { mode: st.mode, size: null, hash: null };
|
|
4450
|
-
const hash = createHash3("sha256").update(
|
|
6039
|
+
const hash = createHash3("sha256").update(readFileSync9(abs2)).digest("hex").slice(0, 16);
|
|
4451
6040
|
return { mode: st.mode, size: st.size, hash };
|
|
4452
6041
|
} catch {
|
|
4453
6042
|
return { mode: null, size: null, hash: null };
|
|
@@ -4455,7 +6044,7 @@ function snap(abs2) {
|
|
|
4455
6044
|
}
|
|
4456
6045
|
function defaultEventLog(cwd) {
|
|
4457
6046
|
if (process.env.TAMPERWARD_FSEVENTS) return process.env.TAMPERWARD_FSEVENTS;
|
|
4458
|
-
return
|
|
6047
|
+
return join9(gitDir(cwd) ?? join9(cwd, ".git"), "tamperward", "fsevents.jsonl");
|
|
4459
6048
|
}
|
|
4460
6049
|
function watchTree(dir, cb) {
|
|
4461
6050
|
if (process.env.TAMPERWARD_WATCH_NO_RECURSIVE !== "1") {
|
|
@@ -4472,12 +6061,12 @@ function watchTree(dir, cb) {
|
|
|
4472
6061
|
if (watchers.has(rel) || SKIP2.test(rel + "/")) return;
|
|
4473
6062
|
let w;
|
|
4474
6063
|
try {
|
|
4475
|
-
w = watch(rel ?
|
|
6064
|
+
w = watch(rel ? join9(dir, rel) : dir, (kind, fname) => {
|
|
4476
6065
|
if (!fname) return;
|
|
4477
6066
|
const child = rel ? `${rel}/${String(fname)}` : String(fname);
|
|
4478
6067
|
cb(kind, child);
|
|
4479
6068
|
try {
|
|
4480
|
-
if (statSync2(
|
|
6069
|
+
if (statSync2(join9(dir, child)).isDirectory()) addDir(child);
|
|
4481
6070
|
} catch {
|
|
4482
6071
|
}
|
|
4483
6072
|
});
|
|
@@ -4486,11 +6075,11 @@ function watchTree(dir, cb) {
|
|
|
4486
6075
|
}
|
|
4487
6076
|
watchers.set(rel, w);
|
|
4488
6077
|
};
|
|
4489
|
-
const
|
|
6078
|
+
const walk2 = (rel) => {
|
|
4490
6079
|
addDir(rel);
|
|
4491
6080
|
let names = [];
|
|
4492
6081
|
try {
|
|
4493
|
-
names = readdirSync2(rel ?
|
|
6082
|
+
names = readdirSync2(rel ? join9(dir, rel) : dir);
|
|
4494
6083
|
} catch {
|
|
4495
6084
|
return;
|
|
4496
6085
|
}
|
|
@@ -4498,25 +6087,25 @@ function watchTree(dir, cb) {
|
|
|
4498
6087
|
const child = rel ? `${rel}/${name}` : name;
|
|
4499
6088
|
if (SKIP2.test(child + "/")) continue;
|
|
4500
6089
|
try {
|
|
4501
|
-
if (statSync2(
|
|
6090
|
+
if (statSync2(join9(dir, child)).isDirectory()) walk2(child);
|
|
4502
6091
|
} catch {
|
|
4503
6092
|
}
|
|
4504
6093
|
}
|
|
4505
6094
|
};
|
|
4506
|
-
|
|
6095
|
+
walk2("");
|
|
4507
6096
|
return { close: () => {
|
|
4508
6097
|
for (const w of watchers.values()) w.close();
|
|
4509
6098
|
} };
|
|
4510
6099
|
}
|
|
4511
6100
|
function startWatcher(dir, log, policy) {
|
|
4512
6101
|
try {
|
|
4513
|
-
mkdirSync4(
|
|
6102
|
+
mkdirSync4(join9(log, ".."), { recursive: true });
|
|
4514
6103
|
} catch {
|
|
4515
6104
|
}
|
|
4516
6105
|
const last = /* @__PURE__ */ new Map();
|
|
4517
6106
|
return watchTree(dir, (kind, rel) => {
|
|
4518
6107
|
if (SKIP2.test(rel) || !isProtected(rel, policy)) return;
|
|
4519
|
-
const s = snap(
|
|
6108
|
+
const s = snap(join9(dir, rel));
|
|
4520
6109
|
const key2 = `${s.hash}:${s.mode}`;
|
|
4521
6110
|
if (last.get(rel) === key2) return;
|
|
4522
6111
|
last.set(rel, key2);
|
|
@@ -4543,7 +6132,7 @@ function runWatch(args) {
|
|
|
4543
6132
|
}
|
|
4544
6133
|
|
|
4545
6134
|
// src/detectors/fs-events.ts
|
|
4546
|
-
import { readFileSync as
|
|
6135
|
+
import { readFileSync as readFileSync10, statSync as statSync3 } from "node:fs";
|
|
4547
6136
|
var RULE11 = "transient-protected-mutation";
|
|
4548
6137
|
function readEvents(log, offset) {
|
|
4549
6138
|
let size = 0;
|
|
@@ -4553,7 +6142,7 @@ function readEvents(log, offset) {
|
|
|
4553
6142
|
return { events: [], newOffset: offset };
|
|
4554
6143
|
}
|
|
4555
6144
|
if (size <= offset) return { events: [], newOffset: offset >= 0 ? Math.min(offset, size) : 0 };
|
|
4556
|
-
const raw =
|
|
6145
|
+
const raw = readFileSync10(log, "utf8").slice(offset);
|
|
4557
6146
|
const events = [];
|
|
4558
6147
|
for (const line of raw.split("\n")) {
|
|
4559
6148
|
if (!line.trim()) continue;
|
|
@@ -4606,7 +6195,7 @@ var HookInputError = class extends Error {
|
|
|
4606
6195
|
};
|
|
4607
6196
|
function readStdin() {
|
|
4608
6197
|
try {
|
|
4609
|
-
return
|
|
6198
|
+
return readFileSync11(0, "utf8");
|
|
4610
6199
|
} catch (e) {
|
|
4611
6200
|
throw new HookInputError(`cannot read the hook payload on stdin: ${e instanceof Error ? e.message : String(e)}`);
|
|
4612
6201
|
}
|
|
@@ -4665,7 +6254,7 @@ function fileChanges(changes) {
|
|
|
4665
6254
|
}
|
|
4666
6255
|
function hashOnDisk(cwd, rel) {
|
|
4667
6256
|
try {
|
|
4668
|
-
return contentHash(
|
|
6257
|
+
return contentHash(readFileSync11(join10(cwd, rel)));
|
|
4669
6258
|
} catch {
|
|
4670
6259
|
return null;
|
|
4671
6260
|
}
|
|
@@ -4854,7 +6443,7 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
|
|
|
4854
6443
|
if (!existsSync7(log)) return none;
|
|
4855
6444
|
let offset = 0;
|
|
4856
6445
|
if (cp && existsSync7(cp)) {
|
|
4857
|
-
const n = Number(
|
|
6446
|
+
const n = Number(readFileSync11(cp, "utf8"));
|
|
4858
6447
|
offset = Number.isFinite(n) ? n : 0;
|
|
4859
6448
|
}
|
|
4860
6449
|
const { events, newOffset } = readEvents(log, offset);
|
|
@@ -4868,7 +6457,7 @@ function turnTransientBlocks(cwd, sessionId, policy, changes) {
|
|
|
4868
6457
|
const finalHash = (path) => {
|
|
4869
6458
|
try {
|
|
4870
6459
|
if (!statSync4(`${cwd}/${path}`).isFile()) return null;
|
|
4871
|
-
return createHash4("sha256").update(
|
|
6460
|
+
return createHash4("sha256").update(readFileSync11(`${cwd}/${path}`)).digest("hex").slice(0, 16);
|
|
4872
6461
|
} catch {
|
|
4873
6462
|
return null;
|
|
4874
6463
|
}
|
|
@@ -5011,9 +6600,8 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
|
|
|
5011
6600
|
// src/cli/init.ts
|
|
5012
6601
|
import { execFileSync as execFileSync5 } from "node:child_process";
|
|
5013
6602
|
import { createHash as createHash5 } from "node:crypto";
|
|
5014
|
-
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as
|
|
5015
|
-
import { basename, dirname as
|
|
5016
|
-
import { fileURLToPath } from "node:url";
|
|
6603
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync12, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
|
|
6604
|
+
import { basename, dirname as dirname4, isAbsolute as isAbsolute5, join as join11, relative as relative3, resolve as resolve2 } from "node:path";
|
|
5017
6605
|
var errText2 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
|
|
5018
6606
|
var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
5019
6607
|
function gitConfig(cwd, key2) {
|
|
@@ -5023,12 +6611,9 @@ function gitConfig(cwd, key2) {
|
|
|
5023
6611
|
return null;
|
|
5024
6612
|
}
|
|
5025
6613
|
}
|
|
5026
|
-
var PRE_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit";
|
|
5027
|
-
var MARKER = "# tamperward: block agent shortcuts before they land";
|
|
5028
6614
|
var HOOK_RE = /\btamperward(?:@\S+)?\s+hook\s+claude\b/;
|
|
5029
6615
|
var SWEEP_RE = /\btamperward(?:@\S+)?\s+sweep\s+claude\b/;
|
|
5030
6616
|
var PRECOMMIT_RE = /\btamperward(?:@\S+)?\s+check\s+--staged\b/;
|
|
5031
|
-
var OURS = /^\s*npx\s+(?:--yes|-y)\s+tamperward(?:@(\S+))?\s+(hook claude|sweep claude|check --staged)\s*$/;
|
|
5032
6617
|
var CODEOWNERS_MARK = "# tamperward: the gate cannot guard the file that defines it";
|
|
5033
6618
|
var CODEOWNERS_PATHS = ["/.github/workflows/", "/.tamperward.yml", "/.github/CODEOWNERS"];
|
|
5034
6619
|
function coveredBy(existing, critical) {
|
|
@@ -5072,24 +6657,6 @@ version: 1
|
|
|
5072
6657
|
# snapshot-rewrite: { severity: block }
|
|
5073
6658
|
# ignore: [] # visible blind spots \u2014 the count is always reported
|
|
5074
6659
|
`;
|
|
5075
|
-
function shippedVersion() {
|
|
5076
|
-
try {
|
|
5077
|
-
const here = dirname3(fileURLToPath(import.meta.url));
|
|
5078
|
-
for (const rel of ["../package.json", "../../package.json", "../../../package.json"]) {
|
|
5079
|
-
try {
|
|
5080
|
-
const pkg = JSON.parse(readFileSync11(join10(here, rel), "utf8"));
|
|
5081
|
-
if (pkg.name === "tamperward" && pkg.version) return pkg.version;
|
|
5082
|
-
} catch {
|
|
5083
|
-
}
|
|
5084
|
-
}
|
|
5085
|
-
} catch {
|
|
5086
|
-
}
|
|
5087
|
-
return "latest";
|
|
5088
|
-
}
|
|
5089
|
-
var TW_VERSION = shippedVersion();
|
|
5090
|
-
var HOOK_CMD = `npx --yes tamperward@${TW_VERSION} hook claude`;
|
|
5091
|
-
var SWEEP_CMD = `npx --yes tamperward@${TW_VERSION} sweep claude`;
|
|
5092
|
-
var PRECOMMIT_CMD = `npx --yes tamperward@${TW_VERSION} check --staged`;
|
|
5093
6660
|
function pinOf(command) {
|
|
5094
6661
|
const m = command.match(OURS);
|
|
5095
6662
|
return m ? m[1] ?? "" : null;
|
|
@@ -5184,13 +6751,13 @@ function readWorkflowMark(src) {
|
|
|
5184
6751
|
if (!m) return null;
|
|
5185
6752
|
return { version: m[1], hash: m[2], body: src.replace(WORKFLOW_MARK_RE, "") };
|
|
5186
6753
|
}
|
|
5187
|
-
function
|
|
6754
|
+
function toolSet(matcher2) {
|
|
5188
6755
|
const m = String(matcher2 ?? "").trim();
|
|
5189
6756
|
if (m === "" || m === "*") return null;
|
|
5190
6757
|
return new Set(m.split("|").map((t) => t.trim()).filter(Boolean));
|
|
5191
6758
|
}
|
|
5192
6759
|
function missingTools(matcher2) {
|
|
5193
|
-
const have =
|
|
6760
|
+
const have = toolSet(matcher2);
|
|
5194
6761
|
if (have === null) return [];
|
|
5195
6762
|
return PRE_MATCHER.split("|").filter((t) => !have.has(t));
|
|
5196
6763
|
}
|
|
@@ -5210,7 +6777,7 @@ function hooksShapeError(hooks) {
|
|
|
5210
6777
|
return null;
|
|
5211
6778
|
}
|
|
5212
6779
|
function planPolicy(cwd) {
|
|
5213
|
-
const path =
|
|
6780
|
+
const path = join11(cwd, POLICY_FILE);
|
|
5214
6781
|
if (existsSync8(path)) {
|
|
5215
6782
|
try {
|
|
5216
6783
|
loadPolicy(cwd);
|
|
@@ -5229,12 +6796,12 @@ function planPolicy(cwd) {
|
|
|
5229
6796
|
}
|
|
5230
6797
|
function planClaudeHooks(cwd) {
|
|
5231
6798
|
const rel = ".claude/settings.json";
|
|
5232
|
-
const path =
|
|
6799
|
+
const path = join11(cwd, rel);
|
|
5233
6800
|
let settings = {};
|
|
5234
6801
|
if (existsSync8(path)) {
|
|
5235
6802
|
let parsed;
|
|
5236
6803
|
try {
|
|
5237
|
-
parsed = JSON.parse(
|
|
6804
|
+
parsed = JSON.parse(readFileSync12(path, "utf8"));
|
|
5238
6805
|
} catch {
|
|
5239
6806
|
return {
|
|
5240
6807
|
item: "agent",
|
|
@@ -5287,12 +6854,12 @@ function planClaudeHooks(cwd) {
|
|
|
5287
6854
|
});
|
|
5288
6855
|
}
|
|
5289
6856
|
for (const m of stale) {
|
|
5290
|
-
m.matcher = [...
|
|
6857
|
+
m.matcher = [...toolSet(m.matcher) ?? /* @__PURE__ */ new Set(), ...missingTools(m.matcher)].join("|");
|
|
5291
6858
|
}
|
|
5292
6859
|
if (needStop) {
|
|
5293
6860
|
(hooks.Stop ??= []).push({ hooks: [{ type: "command", command: SWEEP_CMD }] });
|
|
5294
6861
|
}
|
|
5295
|
-
mkdirSync5(
|
|
6862
|
+
mkdirSync5(dirname4(path), { recursive: true });
|
|
5296
6863
|
writeFileSync5(path, JSON.stringify(settings, null, 2) + "\n");
|
|
5297
6864
|
}
|
|
5298
6865
|
};
|
|
@@ -5327,7 +6894,7 @@ function hooksDir(cwd) {
|
|
|
5327
6894
|
if (out2) return resolve2(cwd, out2);
|
|
5328
6895
|
} catch {
|
|
5329
6896
|
}
|
|
5330
|
-
return existsSync8(
|
|
6897
|
+
return existsSync8(join11(cwd, ".git")) ? join11(cwd, ".git", "hooks") : null;
|
|
5331
6898
|
}
|
|
5332
6899
|
function display(cwd, path) {
|
|
5333
6900
|
const rel = relative3(cwd, path);
|
|
@@ -5354,30 +6921,30 @@ function planPreCommit(cwd) {
|
|
|
5354
6921
|
const skip = (detail) => ({ item: "pre-commit", path: "(none)", status: "skip", detail });
|
|
5355
6922
|
let path;
|
|
5356
6923
|
let note;
|
|
5357
|
-
if (existsSync8(
|
|
5358
|
-
path =
|
|
6924
|
+
if (existsSync8(join11(cwd, ".husky"))) {
|
|
6925
|
+
path = join11(cwd, ".husky", "pre-commit");
|
|
5359
6926
|
note = "husky";
|
|
5360
6927
|
} else {
|
|
5361
|
-
const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync8(
|
|
6928
|
+
const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync8(join11(cwd, c))));
|
|
5362
6929
|
if (managed) {
|
|
5363
6930
|
return skip(`${managed.name} manages this repository's hooks and regenerates them on install, so a line in the hooks directory would not last \u2014 ${managed.hint}`);
|
|
5364
6931
|
}
|
|
5365
6932
|
const dir = hooksDir(cwd);
|
|
5366
6933
|
if (!dir) return skip("not a git repo and no .husky/ \u2014 nothing to wire");
|
|
5367
|
-
if (basename(dir) === "_" && basename(
|
|
5368
|
-
path =
|
|
6934
|
+
if (basename(dir) === "_" && basename(dirname4(dir)) === ".husky") {
|
|
6935
|
+
path = join11(dirname4(dir), "pre-commit");
|
|
5369
6936
|
note = "husky";
|
|
5370
6937
|
} else {
|
|
5371
|
-
path =
|
|
6938
|
+
path = join11(dir, "pre-commit");
|
|
5372
6939
|
const rel2 = display(cwd, dir);
|
|
5373
|
-
note = rel2 ===
|
|
6940
|
+
note = rel2 === join11(".git", "hooks") ? "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" : `git hooks directory ${rel2} (core.hooksPath)`;
|
|
5374
6941
|
}
|
|
5375
6942
|
}
|
|
5376
6943
|
const rel = display(cwd, path);
|
|
5377
6944
|
if (existsSync8(path) && !statSync5(path).isFile()) {
|
|
5378
6945
|
return { item: "pre-commit", path: rel, status: "error", detail: "exists but is not a regular file \u2014 refusing to write" };
|
|
5379
6946
|
}
|
|
5380
|
-
const existing = existsSync8(path) ?
|
|
6947
|
+
const existing = existsSync8(path) ? readFileSync12(path, "utf8") : null;
|
|
5381
6948
|
if (existing === null) {
|
|
5382
6949
|
return {
|
|
5383
6950
|
item: "pre-commit",
|
|
@@ -5385,7 +6952,7 @@ function planPreCommit(cwd) {
|
|
|
5385
6952
|
status: "create",
|
|
5386
6953
|
detail: `create via ${note}`,
|
|
5387
6954
|
apply: () => {
|
|
5388
|
-
mkdirSync5(
|
|
6955
|
+
mkdirSync5(dirname4(path), { recursive: true });
|
|
5389
6956
|
writeFileSync5(path, `#!/bin/sh
|
|
5390
6957
|
${block.join("\n")}
|
|
5391
6958
|
`);
|
|
@@ -5466,10 +7033,10 @@ ${block.join("\n")}
|
|
|
5466
7033
|
function planCodeowners(cwd) {
|
|
5467
7034
|
const rel = ".github/CODEOWNERS";
|
|
5468
7035
|
const existingRel = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].find(
|
|
5469
|
-
(r) => existsSync8(
|
|
7036
|
+
(r) => existsSync8(join11(cwd, r))
|
|
5470
7037
|
);
|
|
5471
|
-
const path =
|
|
5472
|
-
const existing = existingRel ?
|
|
7038
|
+
const path = join11(cwd, existingRel ?? rel);
|
|
7039
|
+
const existing = existingRel ? readFileSync12(path, "utf8") : null;
|
|
5473
7040
|
const missing = CODEOWNERS_PATHS.filter((p) => existing === null || !coveredBy(existing, p));
|
|
5474
7041
|
if (missing.length === 0) {
|
|
5475
7042
|
return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
|
|
@@ -5495,16 +7062,16 @@ ${CODEOWNERS_MARK}
|
|
|
5495
7062
|
warning: `${inferred.owner} was inferred from the origin remote and is not confirmed to be a user. If "${inferred.owner.slice(1)}" is an organisation, replace it with ${inferred.owner}/<team> in CODEOWNERS: GitHub rejects a bare organisation as an owner, and a rule with an invalid owner protects nothing.`
|
|
5496
7063
|
} : {},
|
|
5497
7064
|
apply: () => {
|
|
5498
|
-
mkdirSync5(
|
|
7065
|
+
mkdirSync5(dirname4(path), { recursive: true });
|
|
5499
7066
|
writeFileSync5(path, existing === null ? block.replace(/^\n/, "") : existing.replace(/\n?$/, "\n") + block);
|
|
5500
7067
|
}
|
|
5501
7068
|
};
|
|
5502
7069
|
}
|
|
5503
7070
|
function planWorkflow(cwd, force) {
|
|
5504
7071
|
const rel = ".github/workflows/tamperward.yml";
|
|
5505
|
-
const path =
|
|
7072
|
+
const path = join11(cwd, rel);
|
|
5506
7073
|
const write = () => {
|
|
5507
|
-
mkdirSync5(
|
|
7074
|
+
mkdirSync5(dirname4(path), { recursive: true });
|
|
5508
7075
|
writeFileSync5(path, workflowFile(WORKFLOW_CONTENT));
|
|
5509
7076
|
};
|
|
5510
7077
|
if (!existsSync8(path)) {
|
|
@@ -5518,7 +7085,7 @@ function planWorkflow(cwd, force) {
|
|
|
5518
7085
|
}
|
|
5519
7086
|
let src;
|
|
5520
7087
|
try {
|
|
5521
|
-
src =
|
|
7088
|
+
src = readFileSync12(path, "utf8");
|
|
5522
7089
|
} catch {
|
|
5523
7090
|
return { item: "ci", path: rel, status: "error", detail: "exists but cannot be read \u2014 refusing to overwrite" };
|
|
5524
7091
|
}
|
|
@@ -5617,15 +7184,15 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
|
|
|
5617
7184
|
// src/cli/verify.ts
|
|
5618
7185
|
import { execFileSync as execFileSync8, spawnSync } from "node:child_process";
|
|
5619
7186
|
import { createHash as createHash8 } from "node:crypto";
|
|
5620
|
-
import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as
|
|
7187
|
+
import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync15, readlinkSync as readlinkSync2, rmSync as rmSync3, statSync as statSync7, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync10 } from "node:fs";
|
|
5621
7188
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
5622
|
-
import { dirname as
|
|
7189
|
+
import { dirname as dirname5, join as join14, resolve as resolve3 } from "node:path";
|
|
5623
7190
|
|
|
5624
7191
|
// src/git/trusted.ts
|
|
5625
7192
|
import { execFileSync as execFileSync6 } from "node:child_process";
|
|
5626
7193
|
import { createHash as createHash6 } from "node:crypto";
|
|
5627
|
-
import { existsSync as existsSync9, readFileSync as
|
|
5628
|
-
import { isAbsolute as isAbsolute6, join as
|
|
7194
|
+
import { existsSync as existsSync9, readFileSync as readFileSync13 } from "node:fs";
|
|
7195
|
+
import { isAbsolute as isAbsolute6, join as join12 } from "node:path";
|
|
5629
7196
|
function trustedGitEnv() {
|
|
5630
7197
|
return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
|
|
5631
7198
|
}
|
|
@@ -5638,7 +7205,7 @@ function gitDirOf(cwd) {
|
|
|
5638
7205
|
env: trustedGitEnv()
|
|
5639
7206
|
}).trim();
|
|
5640
7207
|
if (!d) return null;
|
|
5641
|
-
return isAbsolute6(d) ? d :
|
|
7208
|
+
return isAbsolute6(d) ? d : join12(cwd, d);
|
|
5642
7209
|
} catch {
|
|
5643
7210
|
return null;
|
|
5644
7211
|
}
|
|
@@ -5663,9 +7230,9 @@ function objectRewriteState(cwd) {
|
|
|
5663
7230
|
parts.push(`${rel}:<no-git-dir>`);
|
|
5664
7231
|
continue;
|
|
5665
7232
|
}
|
|
5666
|
-
const p =
|
|
7233
|
+
const p = join12(dir, rel);
|
|
5667
7234
|
try {
|
|
5668
|
-
parts.push(`${rel}:` + (existsSync9(p) ?
|
|
7235
|
+
parts.push(`${rel}:` + (existsSync9(p) ? readFileSync13(p, "utf8").trim() : ""));
|
|
5669
7236
|
} catch {
|
|
5670
7237
|
parts.push(`${rel}:<unreadable>`);
|
|
5671
7238
|
}
|
|
@@ -5676,8 +7243,8 @@ function objectRewriteState(cwd) {
|
|
|
5676
7243
|
// src/fingerprint.ts
|
|
5677
7244
|
import { execFileSync as execFileSync7 } from "node:child_process";
|
|
5678
7245
|
import { createHash as createHash7 } from "node:crypto";
|
|
5679
|
-
import { readdirSync as readdirSync3, readFileSync as
|
|
5680
|
-
import { join as
|
|
7246
|
+
import { readdirSync as readdirSync3, readFileSync as readFileSync14, readlinkSync, statSync as statSync6 } from "node:fs";
|
|
7247
|
+
import { join as join13 } from "node:path";
|
|
5681
7248
|
function treeFingerprint(cwd) {
|
|
5682
7249
|
const files = execFileSync7("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
5683
7250
|
cwd,
|
|
@@ -5690,7 +7257,7 @@ function treeFingerprint(cwd) {
|
|
|
5690
7257
|
h.update(rel);
|
|
5691
7258
|
h.update("\0");
|
|
5692
7259
|
try {
|
|
5693
|
-
h.update(
|
|
7260
|
+
h.update(readFileSync14(join13(cwd, rel)));
|
|
5694
7261
|
} catch {
|
|
5695
7262
|
h.update("<unreadable>");
|
|
5696
7263
|
}
|
|
@@ -5699,9 +7266,9 @@ function treeFingerprint(cwd) {
|
|
|
5699
7266
|
return h.digest("hex");
|
|
5700
7267
|
}
|
|
5701
7268
|
function depsFingerprint(cwd) {
|
|
5702
|
-
const root =
|
|
7269
|
+
const root = join13(cwd, "node_modules");
|
|
5703
7270
|
const h = createHash7("sha256");
|
|
5704
|
-
const
|
|
7271
|
+
const walk2 = (dir) => {
|
|
5705
7272
|
let entries;
|
|
5706
7273
|
try {
|
|
5707
7274
|
entries = readdirSync3(dir, { withFileTypes: true });
|
|
@@ -5709,12 +7276,12 @@ function depsFingerprint(cwd) {
|
|
|
5709
7276
|
return;
|
|
5710
7277
|
}
|
|
5711
7278
|
for (const e of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
5712
|
-
const p =
|
|
5713
|
-
if (e.isDirectory())
|
|
7279
|
+
const p = join13(dir, e.name);
|
|
7280
|
+
if (e.isDirectory()) walk2(p);
|
|
5714
7281
|
else if (e.isFile()) {
|
|
5715
7282
|
h.update(p);
|
|
5716
7283
|
try {
|
|
5717
|
-
h.update(
|
|
7284
|
+
h.update(readFileSync14(p));
|
|
5718
7285
|
h.update(String(statSync6(p).mode));
|
|
5719
7286
|
} catch {
|
|
5720
7287
|
h.update("<unreadable>");
|
|
@@ -5734,7 +7301,7 @@ function depsFingerprint(cwd) {
|
|
|
5734
7301
|
} catch {
|
|
5735
7302
|
return "none";
|
|
5736
7303
|
}
|
|
5737
|
-
|
|
7304
|
+
walk2(root);
|
|
5738
7305
|
return h.digest("hex");
|
|
5739
7306
|
}
|
|
5740
7307
|
|
|
@@ -5829,7 +7396,7 @@ function baseIsAncestorOfHead(base, cwd) {
|
|
|
5829
7396
|
function materialize(cwd, dest) {
|
|
5830
7397
|
const listed = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
|
|
5831
7398
|
for (const rel of listed) {
|
|
5832
|
-
const src =
|
|
7399
|
+
const src = join14(cwd, rel);
|
|
5833
7400
|
let st;
|
|
5834
7401
|
try {
|
|
5835
7402
|
st = statSync7(src);
|
|
@@ -5837,18 +7404,18 @@ function materialize(cwd, dest) {
|
|
|
5837
7404
|
continue;
|
|
5838
7405
|
}
|
|
5839
7406
|
if (!st.isFile()) continue;
|
|
5840
|
-
const out2 =
|
|
5841
|
-
mkdirSync6(
|
|
7407
|
+
const out2 = join14(dest, rel);
|
|
7408
|
+
mkdirSync6(dirname5(out2), { recursive: true });
|
|
5842
7409
|
dropSymlink(out2);
|
|
5843
7410
|
if (lstatSync(src).isSymbolicLink()) {
|
|
5844
|
-
writeFileSync6(out2,
|
|
7411
|
+
writeFileSync6(out2, readFileSync15(src));
|
|
5845
7412
|
} else {
|
|
5846
7413
|
cpSync(src, out2, { dereference: true });
|
|
5847
7414
|
}
|
|
5848
7415
|
chmodSync2(out2, st.mode);
|
|
5849
7416
|
}
|
|
5850
7417
|
const nm = resolve3(cwd, "node_modules");
|
|
5851
|
-
if (existsSync10(nm)) symlinkSync(nm,
|
|
7418
|
+
if (existsSync10(nm)) symlinkSync(nm, join14(dest, "node_modules"), "dir");
|
|
5852
7419
|
}
|
|
5853
7420
|
function dropSymlink(p) {
|
|
5854
7421
|
try {
|
|
@@ -5863,9 +7430,9 @@ function verifierInputs(cmd, atBase, policy) {
|
|
|
5863
7430
|
const tok = raw.replace(/^['"]|['"]$/g, "").replace(/^\.\//, "");
|
|
5864
7431
|
if (tok && baseFiles.has(tok)) picked.add(tok);
|
|
5865
7432
|
}
|
|
5866
|
-
const
|
|
5867
|
-
if (
|
|
5868
|
-
for (const rel of atBase) if (matchesAny(rel,
|
|
7433
|
+
const globs4 = policy.verify?.inputs;
|
|
7434
|
+
if (globs4 && globs4.length) {
|
|
7435
|
+
for (const rel of atBase) if (matchesAny(rel, globs4)) picked.add(rel);
|
|
5869
7436
|
}
|
|
5870
7437
|
return picked;
|
|
5871
7438
|
}
|
|
@@ -5881,11 +7448,11 @@ function overlayDigest(dest, paths) {
|
|
|
5881
7448
|
for (const rel of [...paths].sort()) {
|
|
5882
7449
|
h.update(rel);
|
|
5883
7450
|
h.update("\0");
|
|
5884
|
-
const p =
|
|
7451
|
+
const p = join14(dest, rel);
|
|
5885
7452
|
try {
|
|
5886
7453
|
const st = lstatSync(p);
|
|
5887
7454
|
h.update(String(st.mode));
|
|
5888
|
-
h.update(st.isSymbolicLink() ? readlinkSync2(p) :
|
|
7455
|
+
h.update(st.isSymbolicLink() ? readlinkSync2(p) : readFileSync15(p));
|
|
5889
7456
|
} catch {
|
|
5890
7457
|
h.update("<gone>");
|
|
5891
7458
|
}
|
|
@@ -5908,8 +7475,8 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
|
|
|
5908
7475
|
throw new Error(`the base's ${e.path} is a submodule; the pristine copy cannot reproduce it`);
|
|
5909
7476
|
}
|
|
5910
7477
|
const content = execFileSync8("git", ["show", `${base}:${e.path}`], { cwd, maxBuffer: 1 << 28, env: trustedGitEnv() });
|
|
5911
|
-
const out2 =
|
|
5912
|
-
mkdirSync6(
|
|
7478
|
+
const out2 = join14(dest, e.path);
|
|
7479
|
+
mkdirSync6(dirname5(out2), { recursive: true });
|
|
5913
7480
|
rmSync3(out2, { force: true });
|
|
5914
7481
|
if (e.mode === "120000") {
|
|
5915
7482
|
symlinkSync(content.toString("utf8"), out2);
|
|
@@ -5923,7 +7490,7 @@ function overlayPristine(cwd, base, dest, policy, cmd) {
|
|
|
5923
7490
|
const inCopy = git2(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd).split("\0").filter(Boolean);
|
|
5924
7491
|
for (const rel of inCopy) {
|
|
5925
7492
|
if (!isOverlay(rel) || baseProtected.has(rel)) continue;
|
|
5926
|
-
rmSync3(
|
|
7493
|
+
rmSync3(join14(dest, rel), { force: true });
|
|
5927
7494
|
removed++;
|
|
5928
7495
|
}
|
|
5929
7496
|
return { restored, removed };
|
|
@@ -5965,8 +7532,8 @@ function suiteEnv(scratch) {
|
|
|
5965
7532
|
if (DROPPED_ENV.has(k) || PINNED_NPM.test(k)) continue;
|
|
5966
7533
|
env[k] = v;
|
|
5967
7534
|
}
|
|
5968
|
-
const userRc =
|
|
5969
|
-
const globalRc =
|
|
7535
|
+
const userRc = join14(scratch, "npmrc-user");
|
|
7536
|
+
const globalRc = join14(scratch, "npmrc-global");
|
|
5970
7537
|
for (const f of [userRc, globalRc]) writeFileSync6(f, "", { mode: 292 });
|
|
5971
7538
|
env.npm_config_userconfig = userRc;
|
|
5972
7539
|
env.npm_config_globalconfig = globalRc;
|
|
@@ -5976,8 +7543,8 @@ function suiteEnv(scratch) {
|
|
|
5976
7543
|
}
|
|
5977
7544
|
function runSuite(dir, cmd, budgetSecs) {
|
|
5978
7545
|
const t0 = Date.now();
|
|
5979
|
-
const outDir = mkdtempSync2(
|
|
5980
|
-
const outFile =
|
|
7546
|
+
const outDir = mkdtempSync2(join14(tmpdir2(), "tw-verify-run-"));
|
|
7547
|
+
const outFile = join14(outDir, "result.json");
|
|
5981
7548
|
try {
|
|
5982
7549
|
spawnSync(process.execPath, ["-e", SUPERVISOR, cmd, String(budgetSecs * 1e3), outFile], {
|
|
5983
7550
|
cwd: dir,
|
|
@@ -5990,7 +7557,7 @@ function runSuite(dir, cmd, budgetSecs) {
|
|
|
5990
7557
|
const secs = Math.round((Date.now() - t0) / 1e3);
|
|
5991
7558
|
let r;
|
|
5992
7559
|
try {
|
|
5993
|
-
r = JSON.parse(
|
|
7560
|
+
r = JSON.parse(readFileSync15(outFile, "utf8"));
|
|
5994
7561
|
} catch {
|
|
5995
7562
|
return { exit: null, secs };
|
|
5996
7563
|
}
|
|
@@ -6041,8 +7608,8 @@ function runVerify(opts) {
|
|
|
6041
7608
|
out2(`verify: cannot resolve base rev "${opts.base ?? "HEAD"}" \u2014 failing closed`);
|
|
6042
7609
|
return 2;
|
|
6043
7610
|
}
|
|
6044
|
-
const visRoot = mkdtempSync2(
|
|
6045
|
-
const visDir =
|
|
7611
|
+
const visRoot = mkdtempSync2(join14(tmpdir2(), "tw-verify-vis-"));
|
|
7612
|
+
const visDir = join14(visRoot, "t");
|
|
6046
7613
|
const cleanup = (dirs) => {
|
|
6047
7614
|
if (opts.keep) return;
|
|
6048
7615
|
for (const d of dirs) rmSync3(d, { recursive: true, force: true });
|
|
@@ -6070,8 +7637,8 @@ function runVerify(opts) {
|
|
|
6070
7637
|
out2("pristine run executes through it too. Failing closed, not open.");
|
|
6071
7638
|
return 2;
|
|
6072
7639
|
}
|
|
6073
|
-
const priRoot = mkdtempSync2(
|
|
6074
|
-
const priDir =
|
|
7640
|
+
const priRoot = mkdtempSync2(join14(tmpdir2(), "tw-verify-pri-"));
|
|
7641
|
+
const priDir = join14(priRoot, "t");
|
|
6075
7642
|
let restored = [];
|
|
6076
7643
|
let removedAdded = 0;
|
|
6077
7644
|
try {
|
|
@@ -6183,7 +7750,7 @@ function parseVerify(args) {
|
|
|
6183
7750
|
|
|
6184
7751
|
// src/cli/run.ts
|
|
6185
7752
|
import { execFileSync as execFileSync9, spawnSync as spawnSync2 } from "node:child_process";
|
|
6186
|
-
import { readdirSync as readdirSync4, readFileSync as
|
|
7753
|
+
import { readdirSync as readdirSync4, readFileSync as readFileSync16, realpathSync } from "node:fs";
|
|
6187
7754
|
import { resolve as resolve4 } from "node:path";
|
|
6188
7755
|
var out = (s) => process.stdout.write(s + "\n");
|
|
6189
7756
|
var err = (s) => process.stderr.write(s + "\n");
|
|
@@ -6192,7 +7759,7 @@ function git3(args, cwd) {
|
|
|
6192
7759
|
}
|
|
6193
7760
|
function nowTicks() {
|
|
6194
7761
|
try {
|
|
6195
|
-
return Math.floor(parseFloat(
|
|
7762
|
+
return Math.floor(parseFloat(readFileSync16("/proc/uptime", "utf8").split(" ")[0]) * 100);
|
|
6196
7763
|
} catch {
|
|
6197
7764
|
return Number.POSITIVE_INFINITY;
|
|
6198
7765
|
}
|
|
@@ -6222,7 +7789,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
|
|
|
6222
7789
|
}
|
|
6223
7790
|
if (held !== real && !held.startsWith(real + "/")) continue;
|
|
6224
7791
|
try {
|
|
6225
|
-
const stat =
|
|
7792
|
+
const stat = readFileSync16(`/proc/${pid}/stat`, "utf8");
|
|
6226
7793
|
const startTicks = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]);
|
|
6227
7794
|
if (Number.isFinite(startTicks) && startTicks >= spawnedAfterTicks) out2.push(n);
|
|
6228
7795
|
} catch {
|