tamperward 2.5.1 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +712 -175
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -586,12 +586,12 @@ function matcher(glob) {
|
|
|
586
586
|
}
|
|
587
587
|
return m;
|
|
588
588
|
}
|
|
589
|
-
function matchesAny(path,
|
|
590
|
-
return !!
|
|
589
|
+
function matchesAny(path, globs4) {
|
|
590
|
+
return !!globs4 && globs4.some((g) => matcher(g)(path));
|
|
591
591
|
}
|
|
592
592
|
function protectedCategory(path, policy) {
|
|
593
|
-
for (const [cat,
|
|
594
|
-
if (matchesAny(path,
|
|
593
|
+
for (const [cat, globs4] of Object.entries(policy.protected ?? {})) {
|
|
594
|
+
if (matchesAny(path, globs4)) return cat;
|
|
595
595
|
}
|
|
596
596
|
return null;
|
|
597
597
|
}
|
|
@@ -607,9 +607,9 @@ function isIgnored(path, policy) {
|
|
|
607
607
|
}
|
|
608
608
|
function mergeProtected(base, user) {
|
|
609
609
|
const out2 = { ...base };
|
|
610
|
-
for (const [cat,
|
|
610
|
+
for (const [cat, globs4] of Object.entries(user ?? {})) {
|
|
611
611
|
const have = new Set(out2[cat] ?? []);
|
|
612
|
-
out2[cat] = [...out2[cat] ?? [], ...(
|
|
612
|
+
out2[cat] = [...out2[cat] ?? [], ...(globs4 ?? []).filter((g) => !have.has(g))];
|
|
613
613
|
}
|
|
614
614
|
return out2;
|
|
615
615
|
}
|
|
@@ -654,6 +654,13 @@ function defaultPolicy(version = 1) {
|
|
|
654
654
|
config: [
|
|
655
655
|
"**/jest.config.*",
|
|
656
656
|
"**/vitest.config.*",
|
|
657
|
+
// A `vitest.workspace.*` file REPLACES the root config's selection with the
|
|
658
|
+
// union of its projects, and a `vite.config.*` carrying a `test:` key is the
|
|
659
|
+
// vitest config when no vitest.config exists — both are where a spec can be
|
|
660
|
+
// dropped from the suite without a line of it changing. ADDITIVE, like every
|
|
661
|
+
// baseline widening (the policy-diff reads it as a strengthening).
|
|
662
|
+
"**/vitest.workspace.*",
|
|
663
|
+
"**/vite.config.*",
|
|
657
664
|
"**/tsconfig*.json",
|
|
658
665
|
"**/.eslintrc*",
|
|
659
666
|
"**/eslint.config.*",
|
|
@@ -1141,6 +1148,25 @@ function trackedFiles(ctx) {
|
|
|
1141
1148
|
cache2.set(ctx.cwd, files);
|
|
1142
1149
|
return files;
|
|
1143
1150
|
}
|
|
1151
|
+
var branchCache = /* @__PURE__ */ new Map();
|
|
1152
|
+
function defaultBranch(ctx) {
|
|
1153
|
+
if (!ctx?.cwd) return null;
|
|
1154
|
+
const hit = branchCache.get(ctx.cwd);
|
|
1155
|
+
if (hit !== void 0) return hit;
|
|
1156
|
+
let name = null;
|
|
1157
|
+
try {
|
|
1158
|
+
const ref = execFileSync2("git", ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"], {
|
|
1159
|
+
cwd: ctx.cwd,
|
|
1160
|
+
encoding: "utf8",
|
|
1161
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1162
|
+
}).trim();
|
|
1163
|
+
name = ref.replace(/^origin\//, "") || null;
|
|
1164
|
+
} catch {
|
|
1165
|
+
name = null;
|
|
1166
|
+
}
|
|
1167
|
+
branchCache.set(ctx.cwd, name);
|
|
1168
|
+
return name;
|
|
1169
|
+
}
|
|
1144
1170
|
var dirOf = (t) => t.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
1145
1171
|
function containsProtected(token, policy, category, ctx) {
|
|
1146
1172
|
const dir = dirOf(token);
|
|
@@ -1199,6 +1225,13 @@ function underKey(node, key2) {
|
|
|
1199
1225
|
}
|
|
1200
1226
|
return false;
|
|
1201
1227
|
}
|
|
1228
|
+
function ownerKey(node) {
|
|
1229
|
+
const obj = node.parent;
|
|
1230
|
+
if (!obj || !ts2.isObjectLiteralExpression(obj)) return null;
|
|
1231
|
+
let q = obj.parent;
|
|
1232
|
+
while (q && (ts2.isParenthesizedExpression(q) || ts2.isAsExpression(q) || q.kind === ts2.SyntaxKind.SatisfiesExpression)) q = q.parent;
|
|
1233
|
+
return q && ts2.isPropertyAssignment(q) ? keyName(q.name) : null;
|
|
1234
|
+
}
|
|
1202
1235
|
function asExpression(src) {
|
|
1203
1236
|
const t = src.trim();
|
|
1204
1237
|
return t.startsWith("{") ? `(${t})` : src;
|
|
@@ -1276,18 +1309,20 @@ function literals(e) {
|
|
|
1276
1309
|
return { items, opaque };
|
|
1277
1310
|
}
|
|
1278
1311
|
function parseLists(src) {
|
|
1279
|
-
const res = { collectFrom: null, ignore: [], opaqueCollect: false, opaqueIgnore: false };
|
|
1312
|
+
const res = { collectFrom: null, collectKey: "collectCoverageFrom", ignore: [], opaqueCollect: false, opaqueIgnore: false };
|
|
1280
1313
|
try {
|
|
1281
1314
|
const sf = sourceOf(src);
|
|
1282
1315
|
const resolve5 = resolverFor(sf);
|
|
1283
1316
|
const visit = (node) => {
|
|
1284
1317
|
if (ts2.isPropertyAssignment(node)) {
|
|
1285
1318
|
const k = keyName(node.name);
|
|
1286
|
-
|
|
1319
|
+
const vitestCoverage = ownerKey(node) === "coverage";
|
|
1320
|
+
if (k === "collectCoverageFrom" || k === "include" && vitestCoverage) {
|
|
1287
1321
|
const { items, opaque } = literals(resolve5(node.initializer));
|
|
1288
1322
|
res.collectFrom = items;
|
|
1323
|
+
res.collectKey = k === "include" ? "coverage.include" : k;
|
|
1289
1324
|
if (opaque) res.opaqueCollect = true;
|
|
1290
|
-
} else if (k === "coveragePathIgnorePatterns" || k === "exclude" &&
|
|
1325
|
+
} else if (k === "coveragePathIgnorePatterns" || k === "exclude" && vitestCoverage) {
|
|
1291
1326
|
const { items, opaque } = literals(resolve5(node.initializer));
|
|
1292
1327
|
res.ignore.push(...items);
|
|
1293
1328
|
if (opaque) res.opaqueIgnore = true;
|
|
@@ -1300,6 +1335,43 @@ function parseLists(src) {
|
|
|
1300
1335
|
}
|
|
1301
1336
|
return res;
|
|
1302
1337
|
}
|
|
1338
|
+
function parseSwitches(src) {
|
|
1339
|
+
const res = {};
|
|
1340
|
+
try {
|
|
1341
|
+
const sf = sourceOf(src);
|
|
1342
|
+
const bool = (e) => e.kind === ts2.SyntaxKind.TrueKeyword ? true : e.kind === ts2.SyntaxKind.FalseKeyword ? false : void 0;
|
|
1343
|
+
const visit = (node) => {
|
|
1344
|
+
if (ts2.isPropertyAssignment(node)) {
|
|
1345
|
+
const k = keyName(node.name);
|
|
1346
|
+
const owner = ownerKey(node);
|
|
1347
|
+
if (k === "enabled" && owner === "coverage") res.enabled = bool(node.initializer);
|
|
1348
|
+
else if (k === "perFile" && owner === "thresholds" && underKey(node, "coverage")) res.perFile = bool(node.initializer);
|
|
1349
|
+
}
|
|
1350
|
+
ts2.forEachChild(node, visit);
|
|
1351
|
+
};
|
|
1352
|
+
visit(sf);
|
|
1353
|
+
} catch {
|
|
1354
|
+
}
|
|
1355
|
+
return res;
|
|
1356
|
+
}
|
|
1357
|
+
function sectionText(src, key2) {
|
|
1358
|
+
try {
|
|
1359
|
+
const sf = sourceOf(src);
|
|
1360
|
+
let out2 = "";
|
|
1361
|
+
const visit = (node) => {
|
|
1362
|
+
if (out2) return;
|
|
1363
|
+
if (ts2.isPropertyAssignment(node) && keyName(node.name) === key2 && ts2.isObjectLiteralExpression(node.initializer)) {
|
|
1364
|
+
out2 = node.initializer.getText(sf);
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
ts2.forEachChild(node, visit);
|
|
1368
|
+
};
|
|
1369
|
+
visit(sf);
|
|
1370
|
+
return out2;
|
|
1371
|
+
} catch {
|
|
1372
|
+
return "";
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1303
1375
|
var BENIGN_EXEMPTION = /node_modules|(?:^|[/!])(?:dist|build|out|coverage|tmp|temp|vendor|test|tests|__tests__|__mocks__|__fixtures__|fixtures?|mocks?|spec|specs|e2e|cypress|playwright|storybook|scripts?|examples?|docs?|bin|types?|typings)(?:\/|$|\*)|\.(?:test|spec|stories|d)\.|\.(?:config|setup)\.|\.git\b|\.cache|\.next|\.nuxt|\.output|\.idea|\.d\.ts|\.json$|\.md$|\.css$|\.s[ac]ss$/i;
|
|
1304
1376
|
function compareMetrics(scope, before, after, out2) {
|
|
1305
1377
|
if (!before) return;
|
|
@@ -1325,10 +1397,10 @@ function overrideTargetExists(key2, configPath, ctx) {
|
|
|
1325
1397
|
}
|
|
1326
1398
|
return files.some((f) => f.startsWith(root) && m(f.slice(root.length)));
|
|
1327
1399
|
}
|
|
1328
|
-
function weakenings(before, after, configPath, ctx) {
|
|
1400
|
+
function weakenings(before, after, configPath, movedElsewhere, ctx) {
|
|
1329
1401
|
const out2 = [];
|
|
1330
1402
|
if (before.present && !after.present) {
|
|
1331
|
-
out2.push("the coverage threshold gate was removed");
|
|
1403
|
+
if (!movedElsewhere) out2.push("the coverage threshold gate was removed");
|
|
1332
1404
|
return out2;
|
|
1333
1405
|
}
|
|
1334
1406
|
if (after.opaque && !after.global && after.paths.size === 0) return out2;
|
|
@@ -1355,16 +1427,45 @@ function weakenings(before, after, configPath, ctx) {
|
|
|
1355
1427
|
}
|
|
1356
1428
|
return out2;
|
|
1357
1429
|
}
|
|
1358
|
-
|
|
1430
|
+
var globs = (patterns) => {
|
|
1431
|
+
const ms = patterns.map((g) => {
|
|
1432
|
+
try {
|
|
1433
|
+
return picomatch2(norm(g), { dot: true });
|
|
1434
|
+
} catch {
|
|
1435
|
+
return () => false;
|
|
1436
|
+
}
|
|
1437
|
+
});
|
|
1438
|
+
return (p) => ms.some((m) => m(p));
|
|
1439
|
+
};
|
|
1440
|
+
var globList = (patterns) => {
|
|
1441
|
+
const pos = globs(patterns.filter((g) => !g.startsWith("!")));
|
|
1442
|
+
const neg = globs(patterns.filter((g) => g.startsWith("!")).map((g) => g.slice(1)));
|
|
1443
|
+
return (p) => pos(p) && !neg(p);
|
|
1444
|
+
};
|
|
1445
|
+
var CANONICAL_SOURCES = ["src/index.ts", "src/a.ts", "src/a.tsx", "src/lib/b.ts", "src/index.js", "src/a.jsx", "lib/a.js"];
|
|
1446
|
+
var NON_SOURCE = /(?:^|\/)(?:dist|build|out|coverage|node_modules|vendor|test|tests|__tests__|__mocks__|__fixtures__|fixtures|mocks|spec|specs|e2e|cypress|playwright|storybook|scripts|examples|docs|bin|types|typings)\/|\.(?:test|spec|stories|d|config|setup)\.|\.d\.ts$/;
|
|
1447
|
+
function sourceSamples(configPath, policy, ctx) {
|
|
1448
|
+
const files = trackedFiles(ctx);
|
|
1449
|
+
if (!files) return CANONICAL_SOURCES;
|
|
1450
|
+
const root = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/") + 1) : "";
|
|
1451
|
+
const own = files.filter((f) => f.startsWith(root) && langOf(f) === "js" && !NON_SOURCE.test(f) && !isProtected(f, policy, "tests") && !isProtected(f, policy, "config")).map((f) => f.slice(root.length));
|
|
1452
|
+
return own.length ? own : CANONICAL_SOURCES;
|
|
1453
|
+
}
|
|
1454
|
+
function narrowings(before, after, sources) {
|
|
1359
1455
|
const out2 = [];
|
|
1360
|
-
if (before.collectFrom && after.collectFrom && !after.opaqueCollect) {
|
|
1361
|
-
const
|
|
1362
|
-
|
|
1363
|
-
|
|
1456
|
+
if (before.collectFrom && after.collectFrom && !after.opaqueCollect && !before.opaqueCollect) {
|
|
1457
|
+
const pb = globList(before.collectFrom);
|
|
1458
|
+
const pa = globList(after.collectFrom);
|
|
1459
|
+
const dropped = sources.filter((s) => pb(s) && !pa(s));
|
|
1460
|
+
if (dropped.length) {
|
|
1461
|
+
const shown = dropped.slice(0, 3).map((d) => JSON.stringify(d)).join(", ");
|
|
1462
|
+
out2.push(`${after.collectKey} no longer measures ${shown}${dropped.length > 3 ? ` (+${dropped.length - 3} more)` : ""}`);
|
|
1364
1463
|
}
|
|
1365
1464
|
const had2 = new Set(before.collectFrom);
|
|
1366
1465
|
for (const g of after.collectFrom) {
|
|
1367
|
-
if (g.startsWith("!") && !had2.has(g) && !BENIGN_EXEMPTION.test(g)
|
|
1466
|
+
if (g.startsWith("!") && !had2.has(g) && !BENIGN_EXEMPTION.test(g) && !dropped.some((d) => globs([g.slice(1)])(d))) {
|
|
1467
|
+
out2.push(`${after.collectKey} now exempts ${JSON.stringify(g)}`);
|
|
1468
|
+
}
|
|
1368
1469
|
}
|
|
1369
1470
|
}
|
|
1370
1471
|
const had = new Set(before.ignore);
|
|
@@ -1373,75 +1474,153 @@ function narrowings(before, after) {
|
|
|
1373
1474
|
}
|
|
1374
1475
|
return out2;
|
|
1375
1476
|
}
|
|
1477
|
+
function switchWeakenings(before, after) {
|
|
1478
|
+
const out2 = [];
|
|
1479
|
+
if (before.enabled === true && after.enabled === false) out2.push("coverage.enabled switched off \u2014 the thresholds are never checked");
|
|
1480
|
+
if (before.perFile === true && after.perFile !== true) out2.push("thresholds.perFile removed \u2014 a single uncovered file now hides behind the aggregate");
|
|
1481
|
+
return out2;
|
|
1482
|
+
}
|
|
1483
|
+
var NYC_KEYS = METRICS.map((m) => ({
|
|
1484
|
+
key: new RegExp(`(?:^|[\\s{,])"?${m}"?\\s*[:=]\\s*"?(\\d+(?:\\.\\d+)?)`, "g"),
|
|
1485
|
+
label: m,
|
|
1486
|
+
weakens: "lower"
|
|
1487
|
+
}));
|
|
1488
|
+
var COVERAGE_PY = /(?:^|\/)(?:\.coveragerc|pyproject\.toml)$/;
|
|
1489
|
+
var CODECOV = /(?:^|\/)\.?codecov\.yml$/;
|
|
1376
1490
|
var SIMPLE_FILES = [
|
|
1377
1491
|
{
|
|
1378
1492
|
// coverage.py: `fail_under = 90` under [report] (.coveragerc, setup.cfg-style) or
|
|
1379
1493
|
// [tool.coverage.report] (pyproject.toml)
|
|
1380
|
-
file:
|
|
1494
|
+
file: COVERAGE_PY,
|
|
1381
1495
|
keys: [{ key: /^\s*fail[_-]under\s*=\s*"?(\d+(?:\.\d+)?)/, label: "fail_under", weakens: "lower" }],
|
|
1382
1496
|
removable: true
|
|
1383
1497
|
},
|
|
1384
1498
|
{
|
|
1385
1499
|
// nyc: .nycrc / .nycrc.json / .nycrc.yml / nyc.config.js — metric thresholds
|
|
1386
1500
|
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
|
-
})),
|
|
1501
|
+
keys: NYC_KEYS,
|
|
1392
1502
|
removable: false
|
|
1393
1503
|
},
|
|
1394
1504
|
{
|
|
1395
|
-
// codecov: `target: 90%` is the floor (lowering weakens
|
|
1505
|
+
// codecov: `target: 90%` is the floor (lowering weakens; `auto` is a floor that
|
|
1506
|
+
// follows the base commit, lower than any fixed number); `threshold: 2%` is the
|
|
1396
1507
|
// permitted drop (raising weakens)
|
|
1397
|
-
file:
|
|
1508
|
+
file: CODECOV,
|
|
1398
1509
|
keys: [
|
|
1399
|
-
{ key: /^\s*target:\s*"?(\d+(?:\.\d+)
|
|
1510
|
+
{ key: /^\s*target:\s*"?(\d+(?:\.\d+)?|auto)\b%?/, label: "target", weakens: "lower" },
|
|
1400
1511
|
{ key: /^\s*threshold:\s*"?(\d+(?:\.\d+)?)%?/, label: "threshold", weakens: "raise" }
|
|
1401
1512
|
],
|
|
1402
|
-
removable: false
|
|
1513
|
+
removable: false,
|
|
1514
|
+
scoped: true
|
|
1403
1515
|
}
|
|
1404
1516
|
];
|
|
1405
|
-
|
|
1517
|
+
var lessThan = (a, b) => a === "auto" ? b !== "auto" : b === "auto" ? false : a < b;
|
|
1518
|
+
var numOf = (s) => s === "auto" ? "auto" : Number(s);
|
|
1519
|
+
function numbersOf(src, key2, scoped = false) {
|
|
1406
1520
|
const out2 = [];
|
|
1521
|
+
const stack = [];
|
|
1407
1522
|
for (const line of src.split("\n")) {
|
|
1523
|
+
let scope = "";
|
|
1524
|
+
if (scoped) {
|
|
1525
|
+
const m = line.match(/^(\s*)([\w-]+):/);
|
|
1526
|
+
if (m) {
|
|
1527
|
+
const ind = m[1].length;
|
|
1528
|
+
while (stack.length && stack[stack.length - 1].ind >= ind) stack.pop();
|
|
1529
|
+
stack.push({ ind, key: m[2] });
|
|
1530
|
+
}
|
|
1531
|
+
scope = [...stack.slice(0, -1)].reverse().find((s) => /^(?:project|patch|changes)$/.test(s.key))?.key ?? "";
|
|
1532
|
+
}
|
|
1408
1533
|
if (key2.global) {
|
|
1409
|
-
for (const m of line.matchAll(key2)) out2.push(
|
|
1534
|
+
for (const m of line.matchAll(key2)) out2.push({ v: numOf(m[1]), scope });
|
|
1410
1535
|
} else {
|
|
1411
1536
|
const m = line.match(key2);
|
|
1412
|
-
if (m) out2.push(
|
|
1537
|
+
if (m) out2.push({ v: numOf(m[1]), scope });
|
|
1413
1538
|
}
|
|
1414
1539
|
}
|
|
1415
1540
|
return out2;
|
|
1416
1541
|
}
|
|
1417
|
-
function
|
|
1542
|
+
function simpleWeakeningsOf(spec, before, after) {
|
|
1418
1543
|
const out2 = [];
|
|
1419
|
-
const spec = SIMPLE_FILES.find((s) => s.file.test(path));
|
|
1420
|
-
if (!spec) return out2;
|
|
1421
1544
|
for (const k of spec.keys) {
|
|
1422
|
-
const
|
|
1423
|
-
const
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
if (
|
|
1433
|
-
|
|
1434
|
-
|
|
1545
|
+
const bAll = numbersOf(before, k.key, spec.scoped);
|
|
1546
|
+
const aAll = numbersOf(after, k.key, spec.scoped);
|
|
1547
|
+
const scopes = [...new Set(bAll.map((n) => n.scope))];
|
|
1548
|
+
for (const scope of scopes) {
|
|
1549
|
+
const label = scope ? `${scope} ${k.label}` : k.label;
|
|
1550
|
+
const desc = (x, y) => x === "auto" ? -1 : y === "auto" ? 1 : lessThan(x, y) ? 1 : lessThan(y, x) ? -1 : 0;
|
|
1551
|
+
const b = bAll.filter((n) => n.scope === scope).map((n) => n.v).sort(desc);
|
|
1552
|
+
const a = aAll.filter((n) => n.scope === scope).map((n) => n.v).sort(desc);
|
|
1553
|
+
if (b.length === 0) continue;
|
|
1554
|
+
if (a.length === 0) {
|
|
1555
|
+
if (spec.removable) out2.push(`${label} removed (was ${b.join(", ")})`);
|
|
1556
|
+
continue;
|
|
1557
|
+
}
|
|
1558
|
+
if (k.weakens === "lower") {
|
|
1559
|
+
for (let i = 0; i < b.length; i++) {
|
|
1560
|
+
const av = a[Math.min(i, a.length - 1)];
|
|
1561
|
+
if (lessThan(av, b[i])) {
|
|
1562
|
+
out2.push(`${label} lowered ${b[i]} \u2192 ${av}`);
|
|
1563
|
+
break;
|
|
1564
|
+
}
|
|
1435
1565
|
}
|
|
1566
|
+
} else {
|
|
1567
|
+
const bMax = b[0];
|
|
1568
|
+
const aMax = a[0];
|
|
1569
|
+
if (lessThan(bMax, aMax)) out2.push(`${label} raised ${bMax} \u2192 ${aMax} (a wider permitted drop)`);
|
|
1436
1570
|
}
|
|
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
1571
|
}
|
|
1442
1572
|
}
|
|
1443
1573
|
return out2;
|
|
1444
1574
|
}
|
|
1575
|
+
function listEntries(src, key2) {
|
|
1576
|
+
const out2 = [];
|
|
1577
|
+
const lines = src.split("\n");
|
|
1578
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1579
|
+
const m = lines[i].match(key2);
|
|
1580
|
+
if (!m) continue;
|
|
1581
|
+
let rest = m[1] ?? "";
|
|
1582
|
+
let j = i + 1;
|
|
1583
|
+
if (rest.trim().startsWith("[") && !rest.includes("]")) {
|
|
1584
|
+
while (j < lines.length && !lines[j].includes("]")) rest += "," + lines[j++];
|
|
1585
|
+
if (j < lines.length) rest += "," + lines[j];
|
|
1586
|
+
} else if (rest.trim() === "") {
|
|
1587
|
+
while (j < lines.length && /^\s+(?:-\s+)?\S/.test(lines[j])) rest += "," + lines[j++].replace(/^\s*-\s+/, "");
|
|
1588
|
+
}
|
|
1589
|
+
for (const e of rest.replace(/[[\]]/g, "").split(",")) {
|
|
1590
|
+
const v = e.trim().replace(/^(['"])(.*)\1$/, "$2");
|
|
1591
|
+
if (v) out2.push(v);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
return out2;
|
|
1595
|
+
}
|
|
1596
|
+
function simpleNarrowings(path, before, after) {
|
|
1597
|
+
const out2 = [];
|
|
1598
|
+
const spec = [
|
|
1599
|
+
[COVERAGE_PY, /^\s*omit\s*=\s*(.*)$/, "omit now exempts"],
|
|
1600
|
+
[CODECOV, /^ignore:\s*(.*)$/, "codecov now ignores"]
|
|
1601
|
+
];
|
|
1602
|
+
for (const [file, key2, what] of spec) {
|
|
1603
|
+
if (!file.test(path)) continue;
|
|
1604
|
+
const had = new Set(listEntries(before, key2));
|
|
1605
|
+
for (const e of listEntries(after, key2)) if (!had.has(e) && !BENIGN_EXEMPTION.test(e)) out2.push(`${what} ${JSON.stringify(e)}`);
|
|
1606
|
+
}
|
|
1607
|
+
if (CODECOV.test(path)) {
|
|
1608
|
+
const off = (s) => new Set(s.split("\n").map((l) => l.match(/^\s*(project|patch):\s*off\b/)?.[1]).filter((x) => !!x));
|
|
1609
|
+
const had = off(before);
|
|
1610
|
+
for (const s of off(after)) if (!had.has(s)) out2.push(`codecov status.${s} switched off`);
|
|
1611
|
+
}
|
|
1612
|
+
return out2;
|
|
1613
|
+
}
|
|
1614
|
+
function simpleWeakenings(path, before, after) {
|
|
1615
|
+
const spec = SIMPLE_FILES.find((s) => s.file.test(path));
|
|
1616
|
+
const out2 = spec ? simpleWeakeningsOf(spec, before, after) : [];
|
|
1617
|
+
if ((path.split("/").pop() ?? path) === "package.json") {
|
|
1618
|
+
const b = sectionText(before, "nyc");
|
|
1619
|
+
const a = sectionText(after, "nyc");
|
|
1620
|
+
if (b && a) out2.push(...simpleWeakeningsOf({ file: /./, keys: NYC_KEYS, removable: false }, b, a).map((r) => `nyc ${r}`));
|
|
1621
|
+
}
|
|
1622
|
+
return out2;
|
|
1623
|
+
}
|
|
1445
1624
|
var SWITCHES = [
|
|
1446
1625
|
{
|
|
1447
1626
|
re: /--passWithNoTests\b/,
|
|
@@ -1480,6 +1659,35 @@ var SWITCHES = [
|
|
|
1480
1659
|
}
|
|
1481
1660
|
];
|
|
1482
1661
|
var isJsConfig = (path) => /\.(?:[cm]?[jt]sx?|json)$/.test(path);
|
|
1662
|
+
function gateMovedElsewhere(c, changes, policy) {
|
|
1663
|
+
const before = c.before ?? "";
|
|
1664
|
+
const others = changes.filter((o) => o.kind === "file" && o !== c && o.after != null && isProtected(o.path, policy, "config"));
|
|
1665
|
+
if (isJsConfig(c.path)) {
|
|
1666
|
+
const was = parseThresholds(before);
|
|
1667
|
+
if (!was.present) return false;
|
|
1668
|
+
return others.some((o) => {
|
|
1669
|
+
if (!isJsConfig(o.path)) return false;
|
|
1670
|
+
const now = parseThresholds(o.after);
|
|
1671
|
+
if (!now.present) return false;
|
|
1672
|
+
if (now.opaque) return true;
|
|
1673
|
+
return METRICS.every((m) => {
|
|
1674
|
+
const bv = was.global?.values[m];
|
|
1675
|
+
const av = now.global?.values[m];
|
|
1676
|
+
return bv === void 0 || av !== void 0 && av >= bv;
|
|
1677
|
+
});
|
|
1678
|
+
});
|
|
1679
|
+
}
|
|
1680
|
+
const spec = SIMPLE_FILES.find((s) => s.file.test(c.path));
|
|
1681
|
+
if (!spec) return false;
|
|
1682
|
+
return spec.keys.every((k) => {
|
|
1683
|
+
const b = numbersOf(before, k.key, spec.scoped).map((n) => n.v);
|
|
1684
|
+
if (b.length === 0) return true;
|
|
1685
|
+
return others.some((o) => {
|
|
1686
|
+
const a = numbersOf(o.after, k.key, spec.scoped).map((n) => n.v);
|
|
1687
|
+
return a.length > 0 && b.every((bv) => a.some((av) => k.weakens === "lower" ? !lessThan(av, bv) : !lessThan(bv, av)));
|
|
1688
|
+
});
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1483
1691
|
var coverageLowering = {
|
|
1484
1692
|
id: RULE4,
|
|
1485
1693
|
surface: ["file"],
|
|
@@ -1492,10 +1700,15 @@ var coverageLowering = {
|
|
|
1492
1700
|
if (c.after != null && c.op !== "delete") {
|
|
1493
1701
|
const reasons = [];
|
|
1494
1702
|
if (isJsConfig(c.path)) {
|
|
1495
|
-
|
|
1496
|
-
|
|
1703
|
+
const moved = () => gateMovedElsewhere(c, changes, policy);
|
|
1704
|
+
const b = parseThresholds(c.before ?? "");
|
|
1705
|
+
const a = parseThresholds(c.after);
|
|
1706
|
+
reasons.push(...weakenings(b, a, c.path, b.present && !a.present && moved(), ctx));
|
|
1707
|
+
reasons.push(...narrowings(parseLists(c.before ?? ""), parseLists(c.after), sourceSamples(c.path, policy, ctx)));
|
|
1708
|
+
reasons.push(...switchWeakenings(parseSwitches(c.before ?? ""), parseSwitches(c.after)));
|
|
1497
1709
|
}
|
|
1498
1710
|
reasons.push(...simpleWeakenings(c.path, c.before ?? "", c.after));
|
|
1711
|
+
reasons.push(...simpleNarrowings(c.path, c.before ?? "", c.after));
|
|
1499
1712
|
for (const reason of reasons) {
|
|
1500
1713
|
out2.push(
|
|
1501
1714
|
makeFinding(RULE4, policy, {
|
|
@@ -1509,7 +1722,7 @@ var coverageLowering = {
|
|
|
1509
1722
|
} else if (c.op === "delete" || c.after === "" && (c.before ?? "").length > 0) {
|
|
1510
1723
|
const before = c.before ?? "";
|
|
1511
1724
|
const gated = isJsConfig(c.path) ? parseThresholds(before).present : simpleWeakenings(c.path, before, "").length > 0 || SIMPLE_FILES.some((s) => s.file.test(c.path) && s.keys.some((k) => numbersOf(before, k.key).length));
|
|
1512
|
-
if (gated) {
|
|
1725
|
+
if (gated && !gateMovedElsewhere(c, changes, policy)) {
|
|
1513
1726
|
out2.push(
|
|
1514
1727
|
makeFinding(RULE4, policy, {
|
|
1515
1728
|
file: c.path,
|
|
@@ -1606,8 +1819,33 @@ function canonical(core) {
|
|
|
1606
1819
|
return s.replace(/\s+--\s*$/, "").trim();
|
|
1607
1820
|
}
|
|
1608
1821
|
var usesRef = (core) => core.replace(/@.*$/, "");
|
|
1609
|
-
var
|
|
1610
|
-
|
|
1822
|
+
var SUITE_NARROWING_FLAGS = /--testPathIgnorePatterns\b|--testPathPattern\b|--testNamePattern\b|(?:^|\s)-t\s|--onlyChanged\b|--changed\b|--findRelatedTests\b|--exclude\b|--dir\b|--project\b/;
|
|
1823
|
+
var NEUTRALISING_SUFFIX = new RegExp(
|
|
1824
|
+
`\\|\\||;|(?<!&)&(?!&)|\\|(?!\\|)|--passWithNoTests\\b|--coverage=false\\b|--coverageThreshold\\b|${SUITE_NARROWING_FLAGS.source}`
|
|
1825
|
+
);
|
|
1826
|
+
var TIMEOUT_WRAP = /^timeout\s+(?:-{1,2}\S+\s+)*\d\S*\s+(.+)$/;
|
|
1827
|
+
var PATH_SHAPED = /\/|\.(?:test|spec)\./;
|
|
1828
|
+
function narrowedByPositional(args) {
|
|
1829
|
+
const toks = args.trim().split(/\s+/).filter(Boolean);
|
|
1830
|
+
for (let i = 0; i < toks.length; i++) {
|
|
1831
|
+
const t = toks[i];
|
|
1832
|
+
if (t === "--" || t.startsWith("-")) continue;
|
|
1833
|
+
const prev = toks[i - 1];
|
|
1834
|
+
if (prev && (prev.startsWith("-") && prev !== "--" && !prev.includes("=") || /[<>]/.test(prev))) continue;
|
|
1835
|
+
if (/[<>|;&]/.test(t)) continue;
|
|
1836
|
+
if (PATH_SHAPED.test(t)) return true;
|
|
1837
|
+
}
|
|
1838
|
+
return false;
|
|
1839
|
+
}
|
|
1840
|
+
function checkKind(core) {
|
|
1841
|
+
if (!invokesCheck(core)) return null;
|
|
1842
|
+
const s = canonical(core);
|
|
1843
|
+
if (/\btamperward\b/.test(s)) return "gate";
|
|
1844
|
+
if (/\b(?:lint|eslint)\b/i.test(s)) return "lint";
|
|
1845
|
+
if (/\b(?:typecheck|type-check|tsc)\b/i.test(s)) return "types";
|
|
1846
|
+
return "test";
|
|
1847
|
+
}
|
|
1848
|
+
function survives(removedCore, isUses, afterCores, addedCores) {
|
|
1611
1849
|
if (isUses) {
|
|
1612
1850
|
const ref = usesRef(removedCore);
|
|
1613
1851
|
return afterCores.some((a) => usesRef(a) === ref) ? { state: "kept" } : { state: "gone" };
|
|
@@ -1619,15 +1857,33 @@ function survives(removedCore, isUses, afterCores) {
|
|
|
1619
1857
|
if (a === r) return { state: "kept" };
|
|
1620
1858
|
if (a.startsWith(r + " ")) {
|
|
1621
1859
|
const rest = a.slice(r.length);
|
|
1622
|
-
if (NEUTRALISING_SUFFIX.test(rest)) neutralised
|
|
1860
|
+
if (NEUTRALISING_SUFFIX.test(rest) || narrowedByPositional(rest)) neutralised ??= raw;
|
|
1623
1861
|
else return { state: "kept" };
|
|
1624
1862
|
} else if (r.startsWith(a + " ") && invokesCheck(a)) {
|
|
1625
1863
|
return { state: "kept" };
|
|
1864
|
+
} else {
|
|
1865
|
+
const w = a.match(TIMEOUT_WRAP);
|
|
1866
|
+
if (w && (canonical(w[1]) === r || canonical(w[1]).startsWith(r + " "))) neutralised ??= raw;
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
const kind = checkKind(removedCore);
|
|
1870
|
+
let respelled = false;
|
|
1871
|
+
for (const raw of addedCores) {
|
|
1872
|
+
if (!kind || checkKind(raw) !== kind) continue;
|
|
1873
|
+
const a = canonical(raw);
|
|
1874
|
+
if (a.match(TIMEOUT_WRAP)) {
|
|
1875
|
+
neutralised ??= raw;
|
|
1876
|
+
continue;
|
|
1626
1877
|
}
|
|
1878
|
+
const args = a.slice(a.indexOf(" ") + 1 || a.length);
|
|
1879
|
+
if (a.includes(" ") && (NEUTRALISING_SUFFIX.test(" " + args) || narrowedByPositional(args))) neutralised ??= raw;
|
|
1880
|
+
else respelled = true;
|
|
1627
1881
|
}
|
|
1882
|
+
if (respelled) return { state: "kept" };
|
|
1628
1883
|
return neutralised ? { state: "neutralised", by: neutralised } : { state: "gone" };
|
|
1629
1884
|
}
|
|
1630
1885
|
var uncommented = (v) => v.replace(/\s+#.*$/, "").trim();
|
|
1886
|
+
var TRUTHY = Symbol("truthy");
|
|
1631
1887
|
function lex(src) {
|
|
1632
1888
|
const out2 = [];
|
|
1633
1889
|
let i = 0;
|
|
@@ -1658,13 +1914,13 @@ function lex(src) {
|
|
|
1658
1914
|
i += num[0].length;
|
|
1659
1915
|
continue;
|
|
1660
1916
|
}
|
|
1661
|
-
const op = src.slice(i).match(/^(?:==|!=|&&|\|\||<=|>=|[!()
|
|
1917
|
+
const op = src.slice(i).match(/^(?:==|!=|&&|\|\||<=|>=|[!()<>,[\]])/);
|
|
1662
1918
|
if (op) {
|
|
1663
1919
|
out2.push({ t: "op", v: op[0] });
|
|
1664
1920
|
i += op[0].length;
|
|
1665
1921
|
continue;
|
|
1666
1922
|
}
|
|
1667
|
-
const id = src.slice(i).match(/^[A-Za-z_][\w
|
|
1923
|
+
const id = src.slice(i).match(/^[A-Za-z_][\w.\-*]*/);
|
|
1668
1924
|
if (id) {
|
|
1669
1925
|
out2.push({ t: "id", v: id[0] });
|
|
1670
1926
|
i += id[0].length;
|
|
@@ -1674,15 +1930,16 @@ function lex(src) {
|
|
|
1674
1930
|
}
|
|
1675
1931
|
return out2;
|
|
1676
1932
|
}
|
|
1933
|
+
var truthy = (v) => v === TRUTHY ? true : typeof v === "string" ? v !== "" : typeof v === "number" ? v !== 0 : v === true;
|
|
1677
1934
|
function foldConst(src) {
|
|
1678
1935
|
const toks = lex(src);
|
|
1679
1936
|
if (!toks) return void 0;
|
|
1680
1937
|
let p = 0;
|
|
1681
1938
|
const peek = () => toks[p];
|
|
1682
1939
|
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);
|
|
1940
|
+
const num = (v2) => v2 === TRUTHY ? NaN : typeof v2 === "number" ? v2 : typeof v2 === "boolean" ? v2 ? 1 : 0 : v2 === null ? 0 : v2.trim() === "" ? 0 : Number(v2);
|
|
1685
1941
|
const eq = (a, b) => {
|
|
1942
|
+
if (a === TRUTHY || b === TRUTHY) return void 0;
|
|
1686
1943
|
if (typeof a === "string" && typeof b === "string") return a.toLowerCase() === b.toLowerCase();
|
|
1687
1944
|
if (a === null && b === null) return true;
|
|
1688
1945
|
const x = num(a);
|
|
@@ -1693,8 +1950,9 @@ function foldConst(src) {
|
|
|
1693
1950
|
let l = and();
|
|
1694
1951
|
while (eat("||")) {
|
|
1695
1952
|
const r = and();
|
|
1696
|
-
if (l
|
|
1697
|
-
l
|
|
1953
|
+
if (l !== void 0 && truthy(l)) continue;
|
|
1954
|
+
if (l !== void 0) l = r;
|
|
1955
|
+
else l = r !== void 0 && truthy(r) ? TRUTHY : void 0;
|
|
1698
1956
|
}
|
|
1699
1957
|
return l;
|
|
1700
1958
|
};
|
|
@@ -1702,8 +1960,9 @@ function foldConst(src) {
|
|
|
1702
1960
|
let l = cmp();
|
|
1703
1961
|
while (eat("&&")) {
|
|
1704
1962
|
const r = cmp();
|
|
1705
|
-
if (l
|
|
1706
|
-
|
|
1963
|
+
if (l !== void 0 && !truthy(l)) continue;
|
|
1964
|
+
if (l !== void 0) l = r;
|
|
1965
|
+
else l = r !== void 0 && !truthy(r) ? false : void 0;
|
|
1707
1966
|
}
|
|
1708
1967
|
return l;
|
|
1709
1968
|
};
|
|
@@ -1714,14 +1973,18 @@ function foldConst(src) {
|
|
|
1714
1973
|
if (!t || t.t !== "op" || !/^(?:==|!=|<|>|<=|>=)$/.test(t.v)) return l;
|
|
1715
1974
|
p++;
|
|
1716
1975
|
const r = unary();
|
|
1717
|
-
if (l === void 0 || r === void 0)
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1976
|
+
if (l === void 0 || r === void 0) {
|
|
1977
|
+
l = void 0;
|
|
1978
|
+
continue;
|
|
1979
|
+
}
|
|
1980
|
+
if (t.v === "==" || t.v === "!=") {
|
|
1981
|
+
const e = eq(l, r);
|
|
1982
|
+
l = e === void 0 ? void 0 : t.v === "==" ? e : !e;
|
|
1983
|
+
} else {
|
|
1721
1984
|
const x = num(l);
|
|
1722
1985
|
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;
|
|
1986
|
+
if (Number.isNaN(x) || Number.isNaN(y)) l = void 0;
|
|
1987
|
+
else l = t.v === "<" ? x < y : t.v === ">" ? x > y : t.v === "<=" ? x <= y : x >= y;
|
|
1725
1988
|
}
|
|
1726
1989
|
}
|
|
1727
1990
|
};
|
|
@@ -1746,6 +2009,19 @@ function foldConst(src) {
|
|
|
1746
2009
|
if (/^true$/i.test(t.v)) return true;
|
|
1747
2010
|
if (/^false$/i.test(t.v)) return false;
|
|
1748
2011
|
if (/^null$/i.test(t.v)) return null;
|
|
2012
|
+
if (eat("(")) {
|
|
2013
|
+
if (!eat(")")) {
|
|
2014
|
+
for (; ; ) {
|
|
2015
|
+
or();
|
|
2016
|
+
if (eat(")")) break;
|
|
2017
|
+
if (!eat(",")) return void 0;
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
while (eat("[")) {
|
|
2022
|
+
or();
|
|
2023
|
+
if (!eat("]")) return void 0;
|
|
2024
|
+
}
|
|
1749
2025
|
return void 0;
|
|
1750
2026
|
}
|
|
1751
2027
|
return void 0;
|
|
@@ -1762,7 +2038,7 @@ function isAlwaysFalse(raw) {
|
|
|
1762
2038
|
const e = expressionOf(raw);
|
|
1763
2039
|
if (e === "") return false;
|
|
1764
2040
|
const v = foldConst(e);
|
|
1765
|
-
return v !== void 0 && !(
|
|
2041
|
+
return v !== void 0 && !truthy(v);
|
|
1766
2042
|
}
|
|
1767
2043
|
function isTruthy(raw) {
|
|
1768
2044
|
const v = uncommented(raw).replace(/^(['"])(.*)\1$/, "$2").trim();
|
|
@@ -1770,7 +2046,7 @@ function isTruthy(raw) {
|
|
|
1770
2046
|
const m = v.match(/^\$\{\{\s*(.+?)\s*\}\}$/);
|
|
1771
2047
|
if (!m) return false;
|
|
1772
2048
|
const f = foldConst(m[1]);
|
|
1773
|
-
return f !== void 0 && (
|
|
2049
|
+
return f !== void 0 && truthy(f);
|
|
1774
2050
|
}
|
|
1775
2051
|
var indentOf = (l) => l.match(/^\s*/)[0].length;
|
|
1776
2052
|
var STEP_START = /^\s*-\s+(?:name|uses|run|if|id|with|env|shell|continue-on-error|working-directory|timeout-minutes):/;
|
|
@@ -1784,7 +2060,21 @@ function stepOf(lines, i) {
|
|
|
1784
2060
|
while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > stepInd)) e++;
|
|
1785
2061
|
return [s, e];
|
|
1786
2062
|
}
|
|
2063
|
+
function jobOf(lines, i) {
|
|
2064
|
+
const ind = indentOf(lines[i]);
|
|
2065
|
+
let h = i - 1;
|
|
2066
|
+
while (h >= 0 && (lines[h].trim() === "" || indentOf(lines[h]) >= ind)) h--;
|
|
2067
|
+
if (h < 0 || !/^\s+[\w-]+:\s*(?:#.*)?$/.test(lines[h])) return null;
|
|
2068
|
+
let t = h - 1;
|
|
2069
|
+
while (t >= 0 && (lines[t].trim() === "" || indentOf(lines[t]) > 0)) t--;
|
|
2070
|
+
if (t < 0 || !/^jobs:/.test(lines[t])) return null;
|
|
2071
|
+
const hi = indentOf(lines[h]);
|
|
2072
|
+
let e = i + 1;
|
|
2073
|
+
while (e < lines.length && (lines[e].trim() === "" || indentOf(lines[e]) > hi)) e++;
|
|
2074
|
+
return [h, e];
|
|
2075
|
+
}
|
|
1787
2076
|
var stepHasCheck = (lines, range) => range === null || lines.slice(range[0], range[1]).some((l) => invokesCheck(l) || USES.test(l) && CHECK.test(l));
|
|
2077
|
+
var jobHasCheck = (lines, range) => lines.slice(range[0], range[1]).some((l) => invokesCheck(l) || USES.test(l) && CHECK.test(l) || /^\s*uses:\s*\S/.test(l));
|
|
1788
2078
|
function runBlocks(lines) {
|
|
1789
2079
|
const out2 = [];
|
|
1790
2080
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -1802,13 +2092,28 @@ var NEUTRALISERS = [
|
|
|
1802
2092
|
{ re: /^\s*if\s+false\s*;?\s*then\b/, what: "if false; then \u2014 the check is never run" },
|
|
1803
2093
|
{ re: /^\s*:\s*<<\s*['"]?\w+/, what: "a heredoc fed to `:` \u2014 the check is commented out" }
|
|
1804
2094
|
];
|
|
2095
|
+
var LIST_KEYS = {
|
|
2096
|
+
branches: "branches",
|
|
2097
|
+
"branches-ignore": "branchesIgnore",
|
|
2098
|
+
"paths-ignore": "pathsIgnore",
|
|
2099
|
+
paths: "paths",
|
|
2100
|
+
types: "types"
|
|
2101
|
+
};
|
|
1805
2102
|
function flowList(v) {
|
|
1806
2103
|
const m = v.trim().match(/^\[(.*)\]$/);
|
|
1807
2104
|
if (!m) return null;
|
|
1808
2105
|
return m[1].split(",").map((s) => s.trim().replace(/^(['"])(.*)\1$/, "$2")).filter(Boolean);
|
|
1809
2106
|
}
|
|
1810
2107
|
function parseTriggers(src) {
|
|
1811
|
-
const t = {
|
|
2108
|
+
const t = {
|
|
2109
|
+
events: /* @__PURE__ */ new Set(),
|
|
2110
|
+
branches: /* @__PURE__ */ new Map(),
|
|
2111
|
+
branchesIgnore: /* @__PURE__ */ new Map(),
|
|
2112
|
+
pathsIgnore: /* @__PURE__ */ new Map(),
|
|
2113
|
+
paths: /* @__PURE__ */ new Map(),
|
|
2114
|
+
types: /* @__PURE__ */ new Map(),
|
|
2115
|
+
present: false
|
|
2116
|
+
};
|
|
1812
2117
|
const lines = src.split("\n");
|
|
1813
2118
|
const i = lines.findIndex((l) => /^(?:on|"on"|'on'|true):/.test(l));
|
|
1814
2119
|
if (i < 0) return t;
|
|
@@ -1844,8 +2149,9 @@ function parseTriggers(src) {
|
|
|
1844
2149
|
listKey = null;
|
|
1845
2150
|
continue;
|
|
1846
2151
|
}
|
|
1847
|
-
|
|
1848
|
-
|
|
2152
|
+
const key2 = LIST_KEYS[kv[1]];
|
|
2153
|
+
if (event && key2) {
|
|
2154
|
+
listKey = key2;
|
|
1849
2155
|
listInd = ind;
|
|
1850
2156
|
const list = flowList(kv[2]);
|
|
1851
2157
|
t[listKey].set(event, list ?? []);
|
|
@@ -1855,36 +2161,80 @@ function parseTriggers(src) {
|
|
|
1855
2161
|
}
|
|
1856
2162
|
var DEFAULT_BRANCH = /^(?:main|master)$/;
|
|
1857
2163
|
var ALL = (g) => /^\*\*?$|^\*\*\/\*$/.test(g);
|
|
1858
|
-
|
|
2164
|
+
var CODE_EVENTS = ["push", "pull_request", "pull_request_target"];
|
|
2165
|
+
var PR_CODE_TYPES = /^(?:opened|synchronize|reopened)$/;
|
|
2166
|
+
function ghGlob(g) {
|
|
2167
|
+
const re = g.split("**").map((part) => part.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*");
|
|
2168
|
+
try {
|
|
2169
|
+
const r = new RegExp(`^${re}$`);
|
|
2170
|
+
return (p) => r.test(p);
|
|
2171
|
+
} catch {
|
|
2172
|
+
return () => false;
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
function pathsMatchSource(globs4, sources) {
|
|
2176
|
+
const ms = globs4.map((g) => ({ neg: g.startsWith("!"), m: ghGlob(g.startsWith("!") ? g.slice(1) : g) }));
|
|
2177
|
+
return sources.some((s) => {
|
|
2178
|
+
let on = false;
|
|
2179
|
+
for (const { neg, m } of ms) if (m(s)) on = !neg;
|
|
2180
|
+
return on;
|
|
2181
|
+
});
|
|
2182
|
+
}
|
|
2183
|
+
var CODE_PROBES = ["src/index.ts", "src/index.js", "lib/index.js", "index.ts", "main.go", "src/main.py", "src/lib.rs", "packages/a/src/index.ts", "test/a.test.ts"];
|
|
2184
|
+
function sourceProbes(ctx) {
|
|
2185
|
+
const files = trackedFiles(ctx);
|
|
2186
|
+
if (!files) return CODE_PROBES;
|
|
2187
|
+
const code = files.filter((f) => langOf(f) !== null);
|
|
2188
|
+
return code.length ? code : CODE_PROBES;
|
|
2189
|
+
}
|
|
2190
|
+
function triggerNarrowings(before, after, opts) {
|
|
1859
2191
|
const out2 = [];
|
|
1860
2192
|
if (!before.present || !after.present) return out2;
|
|
1861
|
-
|
|
2193
|
+
const isDefault = (b) => opts.defaultBranch ? b === opts.defaultBranch : DEFAULT_BRANCH.test(b);
|
|
2194
|
+
const namesDefault = (l) => l.some(isDefault);
|
|
2195
|
+
for (const e of CODE_EVENTS) {
|
|
1862
2196
|
if (before.events.has(e) && !after.events.has(e)) out2.push(`the ${e} trigger was removed \u2014 the workflow no longer runs on it`);
|
|
1863
2197
|
}
|
|
1864
2198
|
for (const [e, list] of after.pathsIgnore) {
|
|
1865
2199
|
const had = before.pathsIgnore.get(e) ?? [];
|
|
1866
2200
|
if (list.some((g) => ALL(g) && !had.includes(g))) out2.push(`on.${e}.paths-ignore now ignores every path \u2014 the workflow never runs on ${e}`);
|
|
1867
2201
|
}
|
|
2202
|
+
for (const [e, list] of after.paths) {
|
|
2203
|
+
if (!CODE_EVENTS.includes(e) || !after.events.has(e)) continue;
|
|
2204
|
+
const had = before.paths.get(e);
|
|
2205
|
+
if (had && !pathsMatchSource(had, opts.sources)) continue;
|
|
2206
|
+
if (!pathsMatchSource(list, opts.sources)) out2.push(`on.${e}.paths matches no source file (now [${list.join(", ")}]) \u2014 the workflow never runs on code`);
|
|
2207
|
+
}
|
|
2208
|
+
for (const [e, list] of after.types) {
|
|
2209
|
+
if (!/^pull_request(?:_target)?$/.test(e) || !after.events.has(e)) continue;
|
|
2210
|
+
const had = before.types.get(e);
|
|
2211
|
+
if (had && !had.some((t) => PR_CODE_TYPES.test(t))) continue;
|
|
2212
|
+
if (!list.some((t) => PR_CODE_TYPES.test(t))) out2.push(`on.${e}.types no longer includes opened/synchronize (now [${list.join(", ")}]) \u2014 the workflow does not run on the pull request's code`);
|
|
2213
|
+
}
|
|
1868
2214
|
for (const [e, list] of after.branchesIgnore) {
|
|
1869
2215
|
const had = before.branchesIgnore.get(e) ?? [];
|
|
1870
|
-
if (list.some((g) => (ALL(g) ||
|
|
2216
|
+
if (list.some((g) => (ALL(g) || isDefault(g)) && !had.includes(g))) out2.push(`on.${e}.branches-ignore now covers the default branch`);
|
|
1871
2217
|
}
|
|
1872
2218
|
for (const [e, was] of before.branches) {
|
|
1873
2219
|
const now = after.branches.get(e);
|
|
1874
2220
|
if (!now || !after.events.has(e)) continue;
|
|
1875
2221
|
if (now.some(ALL)) continue;
|
|
1876
|
-
const lostDefault = was
|
|
1877
|
-
const replaced = was.length > 0 && !was.some((b) => now.includes(b));
|
|
2222
|
+
const lostDefault = namesDefault(was) && !namesDefault(now);
|
|
2223
|
+
const replaced = !namesDefault(was) && !namesDefault(now) && was.length > 0 && !was.some((b) => now.includes(b));
|
|
1878
2224
|
if (lostDefault || replaced) out2.push(`on.${e}.branches no longer names ${was.filter((b) => !now.includes(b)).join(", ")} (now [${now.join(", ")}])`);
|
|
1879
2225
|
}
|
|
1880
2226
|
return out2;
|
|
1881
2227
|
}
|
|
2228
|
+
var REUSED_WORKFLOW = /^\s*uses:\s*\.\/(\.github\/workflows\/[^\s@]+)/;
|
|
1882
2229
|
var ciTampering = {
|
|
1883
2230
|
id: RULE5,
|
|
1884
2231
|
surface: ["file"],
|
|
1885
2232
|
certainty: "mechanical",
|
|
1886
|
-
run(changes, policy) {
|
|
2233
|
+
run(changes, policy, _view, ctx) {
|
|
1887
2234
|
const out2 = [];
|
|
2235
|
+
const afterByPath = /* @__PURE__ */ new Map();
|
|
2236
|
+
for (const c of changes) if (c.kind === "file" && c.after != null) afterByPath.set(c.path, c.after);
|
|
2237
|
+
let triggerOpts = null;
|
|
1888
2238
|
for (const c of changes) {
|
|
1889
2239
|
if (c.kind !== "file") continue;
|
|
1890
2240
|
if (c.op === "rename" && c.oldPath && isActiveWorkflow(c.oldPath) && !isActiveWorkflow(c.path)) {
|
|
@@ -1907,6 +2257,10 @@ var ciTampering = {
|
|
|
1907
2257
|
if (!coe && !cond) continue;
|
|
1908
2258
|
const step = afterLines && l.newLine != null ? stepOf(afterLines, l.newLine - 1) : null;
|
|
1909
2259
|
if (afterLines && step && !stepHasCheck(afterLines, step)) continue;
|
|
2260
|
+
if (afterLines && !step && l.newLine != null) {
|
|
2261
|
+
const job = jobOf(afterLines, l.newLine - 1);
|
|
2262
|
+
if (job && !jobHasCheck(afterLines, job)) continue;
|
|
2263
|
+
}
|
|
1910
2264
|
if (coe && isTruthy(coe[1])) {
|
|
1911
2265
|
out2.push(
|
|
1912
2266
|
makeFinding(RULE5, policy, {
|
|
@@ -1964,25 +2318,35 @@ var ciTampering = {
|
|
|
1964
2318
|
);
|
|
1965
2319
|
}
|
|
1966
2320
|
if (c.before != null) {
|
|
1967
|
-
|
|
2321
|
+
triggerOpts ??= { defaultBranch: defaultBranch(ctx), sources: sourceProbes(ctx) };
|
|
2322
|
+
for (const reason of triggerNarrowings(parseTriggers(c.before), parseTriggers(c.after), triggerOpts)) {
|
|
1968
2323
|
out2.push(
|
|
1969
2324
|
makeFinding(RULE5, policy, {
|
|
1970
2325
|
file: c.path,
|
|
1971
2326
|
message: `The workflow's triggers were narrowed: ${reason}.`,
|
|
1972
2327
|
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."
|
|
2328
|
+
remediation: "Keep the workflow running on push and pull_request for the default branch and its source paths; a check that never runs is no check."
|
|
1974
2329
|
})
|
|
1975
2330
|
);
|
|
1976
2331
|
}
|
|
1977
2332
|
}
|
|
1978
2333
|
}
|
|
1979
2334
|
const afterCores = (afterLines ?? addedLines(c).map((l) => l.content)).map(commandCore);
|
|
2335
|
+
const addedCores = addedLines(c).map((l) => commandCore(l.content));
|
|
2336
|
+
for (const l of afterLines ?? []) {
|
|
2337
|
+
const reused = l.match(REUSED_WORKFLOW)?.[1];
|
|
2338
|
+
const content = reused ? afterByPath.get(reused) : void 0;
|
|
2339
|
+
if (content == null || reused === c.path) continue;
|
|
2340
|
+
const cores = content.split("\n").map(commandCore);
|
|
2341
|
+
afterCores.push(...cores);
|
|
2342
|
+
addedCores.push(...cores);
|
|
2343
|
+
}
|
|
1980
2344
|
for (const l of removedLines(c)) {
|
|
1981
2345
|
if (/^\s*#/.test(l.content)) continue;
|
|
1982
2346
|
const isUsesLine = USES.test(l.content) && CHECK.test(l.content);
|
|
1983
2347
|
const isCheckCommand = !isUsesLine && !/^\s*-?\s*uses:/.test(l.content) && (YAML_KEY.test(l.content) ? /^\s*-?\s*run:/.test(l.content) : true) && invokesCheck(l.content);
|
|
1984
2348
|
if (!isUsesLine && !isCheckCommand) continue;
|
|
1985
|
-
const s = survives(commandCore(l.content), isUsesLine, afterCores);
|
|
2349
|
+
const s = survives(commandCore(l.content), isUsesLine, afterCores, addedCores);
|
|
1986
2350
|
if (s.state === "kept") continue;
|
|
1987
2351
|
if (s.state === "neutralised") {
|
|
1988
2352
|
out2.push(
|
|
@@ -1990,7 +2354,7 @@ var ciTampering = {
|
|
|
1990
2354
|
file: c.path,
|
|
1991
2355
|
message: "A CI check command was neutralised in place: it still runs, but its result no longer decides (or its suite was narrowed).",
|
|
1992
2356
|
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."
|
|
2357
|
+
remediation: "Let the check decide the step; do not mask its status, cut it short, or narrow what it runs."
|
|
1994
2358
|
})
|
|
1995
2359
|
);
|
|
1996
2360
|
continue;
|
|
@@ -2015,7 +2379,7 @@ import { isAbsolute, join as join2, relative } from "node:path";
|
|
|
2015
2379
|
|
|
2016
2380
|
// src/detectors/policy-diff.ts
|
|
2017
2381
|
import { parse } from "yaml";
|
|
2018
|
-
var
|
|
2382
|
+
var globs2 = (list) => Array.isArray(list) ? list.filter((g) => typeof g === "string").map(normalizeGlob) : [];
|
|
2019
2383
|
function safeParse(src) {
|
|
2020
2384
|
try {
|
|
2021
2385
|
const v = parse(src);
|
|
@@ -2032,13 +2396,13 @@ function effective(raw) {
|
|
|
2032
2396
|
const rules = { ...base.rules };
|
|
2033
2397
|
for (const [name, cfg] of Object.entries(raw.rules ?? {})) {
|
|
2034
2398
|
const over = cfg && typeof cfg === "object" && !Array.isArray(cfg) ? cfg : {};
|
|
2035
|
-
rules[name] = { ...rules[name] ?? {}, ...over, ...over.exclude ? { exclude:
|
|
2399
|
+
rules[name] = { ...rules[name] ?? {}, ...over, ...over.exclude ? { exclude: globs2(over.exclude) } : {} };
|
|
2036
2400
|
}
|
|
2037
|
-
const userProtected = raw.protected && typeof raw.protected === "object" ? Object.fromEntries(Object.entries(raw.protected).map(([cat, list]) => [cat,
|
|
2401
|
+
const userProtected = raw.protected && typeof raw.protected === "object" ? Object.fromEntries(Object.entries(raw.protected).map(([cat, list]) => [cat, globs2(list)])) : void 0;
|
|
2038
2402
|
return {
|
|
2039
2403
|
version,
|
|
2040
2404
|
rules,
|
|
2041
|
-
ignore: raw.ignore ?
|
|
2405
|
+
ignore: raw.ignore ? globs2(raw.ignore) : base.ignore ?? [],
|
|
2042
2406
|
protected: mergeProtected(base.protected, userProtected),
|
|
2043
2407
|
requiredFor: raw.signoff?.required_for ?? raw.signoff?.requiredFor ?? base.signoff.requiredFor,
|
|
2044
2408
|
ledger: typeof raw.signoff?.ledger === "string" ? raw.signoff.ledger : base.signoff.ledger
|
|
@@ -2670,11 +3034,11 @@ function settingsWeakening(before, after, sibling) {
|
|
|
2670
3034
|
}
|
|
2671
3035
|
if (now !== "none" || !declares(after, event)) continue;
|
|
2672
3036
|
if (before !== null && declares(before, event)) continue;
|
|
2673
|
-
const
|
|
3037
|
+
const empty2 = (eventEntries(after, event) ?? []).length === 0;
|
|
2674
3038
|
const shadowed = sibling !== null && gateWiring(sibling, event).length > 0;
|
|
2675
|
-
if (
|
|
3039
|
+
if (empty2 || shadowed) {
|
|
2676
3040
|
reasons.push(
|
|
2677
|
-
`${event} hooks declared ${
|
|
3041
|
+
`${event} hooks declared ${empty2 ? "empty" : "without the tamperward entry"} in an override file` + (shadowed ? " \u2014 this shadows the gate the sibling settings file wires" : "")
|
|
2678
3042
|
);
|
|
2679
3043
|
}
|
|
2680
3044
|
}
|
|
@@ -2971,67 +3335,147 @@ function keyName2(name) {
|
|
|
2971
3335
|
if (ts3.isIdentifier(name) || ts3.isStringLiteral(name)) return name.text;
|
|
2972
3336
|
return null;
|
|
2973
3337
|
}
|
|
3338
|
+
var isStr = (e) => ts3.isStringLiteral(e) || ts3.isNoSubstitutionTemplateLiteral(e);
|
|
2974
3339
|
function literals2(e) {
|
|
2975
|
-
if (
|
|
3340
|
+
if (isStr(e)) return { items: [e.text], opaque: false };
|
|
2976
3341
|
if (!ts3.isArrayLiteralExpression(e)) return { items: [], opaque: true };
|
|
2977
3342
|
const items = [];
|
|
2978
3343
|
let opaque = false;
|
|
2979
3344
|
for (const el of e.elements) {
|
|
2980
|
-
if (
|
|
3345
|
+
if (isStr(el)) items.push(el.text);
|
|
2981
3346
|
else if (ts3.isSpreadElement(el) && /^(?:configDefaults\.|default)(?:exclude|include)$/i.test(el.expression.getText())) {
|
|
2982
3347
|
items.push(.../exclude$/i.test(el.expression.getText()) ? VITEST_DEFAULT_EXCLUDE : VITEST_DEFAULT_INCLUDE);
|
|
2983
3348
|
} else opaque = true;
|
|
2984
3349
|
}
|
|
2985
3350
|
return { items, opaque };
|
|
2986
3351
|
}
|
|
2987
|
-
function
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
3352
|
+
function ownerKey2(node) {
|
|
3353
|
+
const obj = node.parent;
|
|
3354
|
+
if (!obj || !ts3.isObjectLiteralExpression(obj)) return null;
|
|
3355
|
+
let q = obj.parent;
|
|
3356
|
+
while (q && (ts3.isParenthesizedExpression(q) || ts3.isAsExpression(q) || q.kind === ts3.SyntaxKind.SatisfiesExpression)) q = q.parent;
|
|
3357
|
+
return q && ts3.isPropertyAssignment(q) ? keyName2(q.name) : null;
|
|
2992
3358
|
}
|
|
2993
3359
|
function asExpression2(src) {
|
|
2994
3360
|
const t = src.trim();
|
|
2995
3361
|
return t.startsWith("{") ? `(${t})` : src;
|
|
2996
3362
|
}
|
|
2997
|
-
function runnerOf(path) {
|
|
3363
|
+
function runnerOf(path, content) {
|
|
2998
3364
|
const base = path.split("/").pop() ?? path;
|
|
2999
3365
|
if (/^jest\.config\./.test(base) || base === "package.json") return "jest";
|
|
3000
|
-
if (/^vitest\.config\./.test(base)) return "vitest";
|
|
3366
|
+
if (/^vitest\.(?:config|workspace)\./.test(base)) return "vitest";
|
|
3367
|
+
if (/^vite\.config\./.test(base) && content != null && /(?:^|[\s,{])["']?test["']?\s*:/m.test(content)) return "vitest";
|
|
3368
|
+
return null;
|
|
3369
|
+
}
|
|
3370
|
+
var isWorkspaceFile = (path) => /^vitest\.workspace\./.test(path.split("/").pop() ?? path);
|
|
3371
|
+
var empty = () => ({ include: null, regex: null, ignore: null, roots: null, base: null, projects: null, opaque: false, present: false });
|
|
3372
|
+
var stripRootDir = (g) => g.replace(/^<rootDir>\/?/, "");
|
|
3373
|
+
function dirValue(e) {
|
|
3374
|
+
if (!isStr(e)) return { dir: null, opaque: true };
|
|
3375
|
+
const d = stripRootDir(e.text).replace(/^\.\//, "").replace(/\/+$/, "");
|
|
3376
|
+
if (d === "." || d === "" || d.split("/").includes("..")) return { dir: null, opaque: false };
|
|
3377
|
+
return { dir: d, opaque: false };
|
|
3378
|
+
}
|
|
3379
|
+
function collect(root, runner) {
|
|
3380
|
+
const sel = empty();
|
|
3381
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3382
|
+
const take = (key2, e) => {
|
|
3383
|
+
const { items, opaque } = literals2(e);
|
|
3384
|
+
if (opaque || seen.has(key2)) sel.opaque = true;
|
|
3385
|
+
seen.add(key2);
|
|
3386
|
+
sel.present = true;
|
|
3387
|
+
return items.map(stripRootDir);
|
|
3388
|
+
};
|
|
3389
|
+
const ignores = (key2, e) => {
|
|
3390
|
+
const entries = take(key2, e).map((pattern) => ({ pattern, key: key2 }));
|
|
3391
|
+
sel.ignore = [...sel.ignore ?? [], ...entries];
|
|
3392
|
+
};
|
|
3393
|
+
const projects = (e) => {
|
|
3394
|
+
sel.present = true;
|
|
3395
|
+
if (!ts3.isArrayLiteralExpression(e)) {
|
|
3396
|
+
sel.opaque = true;
|
|
3397
|
+
return;
|
|
3398
|
+
}
|
|
3399
|
+
const list = [];
|
|
3400
|
+
for (const el of e.elements) {
|
|
3401
|
+
if (ts3.isObjectLiteralExpression(el)) list.push(collect(el, runner));
|
|
3402
|
+
else sel.opaque = true;
|
|
3403
|
+
}
|
|
3404
|
+
sel.projects = list;
|
|
3405
|
+
if (list.some((p) => p.opaque)) sel.opaque = true;
|
|
3406
|
+
};
|
|
3407
|
+
const visit = (node) => {
|
|
3408
|
+
if (ts3.isPropertyAssignment(node)) {
|
|
3409
|
+
const k = keyName2(node.name);
|
|
3410
|
+
if (runner === "jest") {
|
|
3411
|
+
if (k === "testMatch") sel.include = take(k, node.initializer);
|
|
3412
|
+
else if (k === "testRegex") sel.regex = take(k, node.initializer);
|
|
3413
|
+
else if (k === "testPathIgnorePatterns" || k === "modulePathIgnorePatterns") ignores(k, node.initializer);
|
|
3414
|
+
else if (k === "roots") sel.roots = take(k, node.initializer);
|
|
3415
|
+
else if (k === "rootDir") {
|
|
3416
|
+
const { dir, opaque } = dirValue(node.initializer);
|
|
3417
|
+
sel.present = true;
|
|
3418
|
+
if (opaque) sel.opaque = true;
|
|
3419
|
+
else sel.base = dir;
|
|
3420
|
+
} else if (k === "projects") {
|
|
3421
|
+
projects(node.initializer);
|
|
3422
|
+
return;
|
|
3423
|
+
}
|
|
3424
|
+
} else if (ownerKey2(node) === "test") {
|
|
3425
|
+
if (k === "include") sel.include = take(k, node.initializer);
|
|
3426
|
+
else if (k === "exclude") ignores(k, node.initializer);
|
|
3427
|
+
else if (k === "dir") {
|
|
3428
|
+
const { dir, opaque } = dirValue(node.initializer);
|
|
3429
|
+
sel.present = true;
|
|
3430
|
+
if (opaque) sel.opaque = true;
|
|
3431
|
+
else sel.base = dir;
|
|
3432
|
+
} else if (k === "projects") {
|
|
3433
|
+
projects(node.initializer);
|
|
3434
|
+
return;
|
|
3435
|
+
}
|
|
3436
|
+
}
|
|
3437
|
+
}
|
|
3438
|
+
ts3.forEachChild(node, visit);
|
|
3439
|
+
};
|
|
3440
|
+
visit(root);
|
|
3441
|
+
return sel;
|
|
3442
|
+
}
|
|
3443
|
+
function workspaceArray(sf) {
|
|
3444
|
+
for (const st of sf.statements) {
|
|
3445
|
+
let e;
|
|
3446
|
+
if (ts3.isExportAssignment(st)) e = st.expression;
|
|
3447
|
+
else if (ts3.isExpressionStatement(st) && ts3.isBinaryExpression(st.expression) && /module\.exports/.test(st.expression.left.getText())) e = st.expression.right;
|
|
3448
|
+
if (!e) continue;
|
|
3449
|
+
while (ts3.isParenthesizedExpression(e) || ts3.isAsExpression(e) || e.kind === ts3.SyntaxKind.SatisfiesExpression) {
|
|
3450
|
+
e = e.expression;
|
|
3451
|
+
}
|
|
3452
|
+
if (ts3.isCallExpression(e) && e.arguments.length > 0) e = e.arguments[0];
|
|
3453
|
+
return ts3.isArrayLiteralExpression(e) ? e : null;
|
|
3454
|
+
}
|
|
3001
3455
|
return null;
|
|
3002
3456
|
}
|
|
3003
|
-
function parseSelection(src, runner) {
|
|
3004
|
-
|
|
3457
|
+
function parseSelection(src, runner, path = "") {
|
|
3458
|
+
let sel = empty();
|
|
3005
3459
|
try {
|
|
3006
3460
|
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);
|
|
3461
|
+
if (runner === "vitest" && isWorkspaceFile(path)) {
|
|
3462
|
+
const arr = workspaceArray(sf);
|
|
3463
|
+
if (arr) {
|
|
3464
|
+
const list = [];
|
|
3465
|
+
for (const el of arr.elements) {
|
|
3466
|
+
if (ts3.isObjectLiteralExpression(el)) list.push(collect(el, runner));
|
|
3467
|
+
else sel.opaque = true;
|
|
3025
3468
|
}
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3469
|
+
sel.projects = list;
|
|
3470
|
+
sel.present = true;
|
|
3471
|
+
if (list.some((p) => p.opaque)) sel.opaque = true;
|
|
3472
|
+
} else if (src.trim()) sel.opaque = true;
|
|
3473
|
+
} else sel = collect(sf, runner);
|
|
3030
3474
|
} catch {
|
|
3031
3475
|
}
|
|
3032
3476
|
return sel;
|
|
3033
3477
|
}
|
|
3034
|
-
var
|
|
3478
|
+
var globs3 = (patterns) => {
|
|
3035
3479
|
const ms = patterns.map((g) => {
|
|
3036
3480
|
try {
|
|
3037
3481
|
return picomatch3(g, { dot: true });
|
|
@@ -3041,6 +3485,11 @@ var globs2 = (patterns) => {
|
|
|
3041
3485
|
});
|
|
3042
3486
|
return (path) => ms.some((m) => m(path));
|
|
3043
3487
|
};
|
|
3488
|
+
var globList2 = (patterns) => {
|
|
3489
|
+
const pos = globs3(patterns.filter((g) => !g.startsWith("!")));
|
|
3490
|
+
const neg = globs3(patterns.filter((g) => g.startsWith("!")).map((g) => g.slice(1)));
|
|
3491
|
+
return (path) => pos(path) && !neg(path);
|
|
3492
|
+
};
|
|
3044
3493
|
var regexes = (patterns) => {
|
|
3045
3494
|
const rs = patterns.flatMap((p) => {
|
|
3046
3495
|
try {
|
|
@@ -3051,29 +3500,44 @@ var regexes = (patterns) => {
|
|
|
3051
3500
|
});
|
|
3052
3501
|
return (path) => rs.some((r) => r.test("/" + path));
|
|
3053
3502
|
};
|
|
3503
|
+
var underDir = (path, dir) => dir === "" || path.startsWith(dir + "/");
|
|
3054
3504
|
function predicate(sel, runner) {
|
|
3505
|
+
if (sel.projects) {
|
|
3506
|
+
const ps = sel.projects.map((p) => predicate(p, runner));
|
|
3507
|
+
return {
|
|
3508
|
+
selects: (p) => ps.some((x) => x.selects(p)),
|
|
3509
|
+
why: (p) => ps.length ? `no project selects it (${ps[0].why(p)})` : "the projects list is empty"
|
|
3510
|
+
};
|
|
3511
|
+
}
|
|
3055
3512
|
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
|
|
3513
|
+
const ignoreList = sel.ignore ?? (runner === "jest" ? JEST_DEFAULT_IGNORE : VITEST_DEFAULT_EXCLUDE).map((pattern) => ({ pattern, key: runner === "jest" ? "testPathIgnorePatterns" : "test.exclude" }));
|
|
3514
|
+
const included = includeList ? globList2(includeList) : sel.regex ? regexes(sel.regex) : () => true;
|
|
3515
|
+
const ignoredBy = (p) => ignoreList.find((e) => (runner === "jest" ? regexes([e.pattern]) : globs3([e.pattern]))(p));
|
|
3516
|
+
const roots = sel.roots?.map((r) => r.replace(/^\.\//, "").replace(/\/+$/, "")).map((r) => r === "." ? "" : r) ?? null;
|
|
3517
|
+
const rebase = (p) => sel.base === null ? p : underDir(p, sel.base) ? p.slice(sel.base.length + 1) : null;
|
|
3518
|
+
const inRoots = (p) => roots === null || roots.some((r) => underDir(p, r));
|
|
3059
3519
|
return {
|
|
3060
|
-
selects: (p) =>
|
|
3520
|
+
selects: (p) => {
|
|
3521
|
+
const r = rebase(p);
|
|
3522
|
+
return r !== null && inRoots(r) && included(r) && !ignoredBy(r);
|
|
3523
|
+
},
|
|
3061
3524
|
why: (p) => {
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3525
|
+
const r = rebase(p);
|
|
3526
|
+
const baseKey = runner === "jest" ? "rootDir" : "test.dir";
|
|
3527
|
+
if (r === null) return `${baseKey} no longer covers it (${JSON.stringify(sel.base)})`;
|
|
3528
|
+
if (!inRoots(r)) return `roots no longer covers it (${JSON.stringify(sel.roots)})`;
|
|
3529
|
+
const hit = ignoredBy(r);
|
|
3530
|
+
if (hit) return `${hit.key === "exclude" ? "test.exclude" : hit.key} now matches it (${JSON.stringify(hit.pattern)})`;
|
|
3067
3531
|
const key2 = sel.include ? runner === "jest" ? "testMatch" : "test.include" : "testRegex";
|
|
3068
3532
|
return `${key2} no longer matches it (${JSON.stringify(sel.include ?? sel.regex ?? [])})`;
|
|
3069
3533
|
}
|
|
3070
3534
|
};
|
|
3071
3535
|
}
|
|
3072
3536
|
function suiteNarrowings(before, after, path, samples) {
|
|
3073
|
-
const runner = runnerOf(path);
|
|
3537
|
+
const runner = runnerOf(path, after) ?? runnerOf(path, before);
|
|
3074
3538
|
if (!runner) return [];
|
|
3075
|
-
const b = parseSelection(before ?? "", runner);
|
|
3076
|
-
const a = parseSelection(after, runner);
|
|
3539
|
+
const b = parseSelection(before ?? "", runner, path);
|
|
3540
|
+
const a = parseSelection(after, runner, path);
|
|
3077
3541
|
if (!a.present || a.opaque) return [];
|
|
3078
3542
|
const pb = predicate(b, runner);
|
|
3079
3543
|
const pa = predicate(a, runner);
|
|
@@ -3105,6 +3569,18 @@ function calleeName(expr) {
|
|
|
3105
3569
|
return null;
|
|
3106
3570
|
}
|
|
3107
3571
|
var isEachOf = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && (expr.expression.text === "it" || expr.expression.text === "test") && expr.name.text === "each";
|
|
3572
|
+
var isDescribeEach = (expr) => ts4.isPropertyAccessExpression(expr) && ts4.isIdentifier(expr.expression) && expr.expression.text === "describe" && expr.name.text === "each";
|
|
3573
|
+
function templateRows(node) {
|
|
3574
|
+
const rows = node.template.getText().split("\n").filter((l) => /\S/.test(l.replace(/[`]/g, ""))).length - 1;
|
|
3575
|
+
return Math.max(rows, 1);
|
|
3576
|
+
}
|
|
3577
|
+
function hasSubstantiveBody(call) {
|
|
3578
|
+
const fn = call.arguments.find((a) => ts4.isArrowFunction(a) || ts4.isFunctionExpression(a));
|
|
3579
|
+
if (!fn) return true;
|
|
3580
|
+
const body = fn.body;
|
|
3581
|
+
const text = ts4.isBlock(body) ? body.getText().slice(1, -1) : body.getText();
|
|
3582
|
+
return text.split("\n").some((l) => isSignificantLine(l.trim(), "js"));
|
|
3583
|
+
}
|
|
3108
3584
|
function eachRows(call) {
|
|
3109
3585
|
const arg = call.arguments[0];
|
|
3110
3586
|
if (!arg) return { n: 1, open: true };
|
|
@@ -3119,7 +3595,7 @@ function eachRows(call) {
|
|
|
3119
3595
|
}
|
|
3120
3596
|
return { n: 1, open: true };
|
|
3121
3597
|
}
|
|
3122
|
-
function countTests(src, path = "spec.ts") {
|
|
3598
|
+
function countTests(src, path = "spec.ts", substantiveOnly = false) {
|
|
3123
3599
|
const lang = langOf(path);
|
|
3124
3600
|
if (lang && lang !== "js") {
|
|
3125
3601
|
const re = TEST_DEFS[lang];
|
|
@@ -3130,23 +3606,33 @@ function countTests(src, path = "spec.ts") {
|
|
|
3130
3606
|
const sf = ts4.createSourceFile("spec.ts", src, ts4.ScriptTarget.Latest, true, ts4.ScriptKind.TS);
|
|
3131
3607
|
let n = 0;
|
|
3132
3608
|
let open = false;
|
|
3133
|
-
const visit = (node) => {
|
|
3609
|
+
const visit = (node, mult) => {
|
|
3134
3610
|
if (ts4.isCallExpression(node)) {
|
|
3611
|
+
const head = node.expression;
|
|
3612
|
+
if (ts4.isCallExpression(head) && isDescribeEach(head.expression)) {
|
|
3613
|
+
const r = eachRows(head);
|
|
3614
|
+
if (r.open) open = true;
|
|
3615
|
+
for (const a of node.arguments) visit(a, mult * r.n);
|
|
3616
|
+
return;
|
|
3617
|
+
}
|
|
3618
|
+
if (ts4.isTaggedTemplateExpression(head) && isDescribeEach(head.tag)) {
|
|
3619
|
+
for (const a of node.arguments) visit(a, mult * templateRows(head));
|
|
3620
|
+
return;
|
|
3621
|
+
}
|
|
3135
3622
|
if (isEachOf(node.expression)) {
|
|
3136
3623
|
const r = eachRows(node);
|
|
3137
|
-
n += r.n;
|
|
3624
|
+
n += r.n * mult;
|
|
3138
3625
|
if (r.open) open = true;
|
|
3139
3626
|
} else {
|
|
3140
3627
|
const name = calleeName(node.expression);
|
|
3141
|
-
if (name === "it" || name === "test") n
|
|
3628
|
+
if ((name === "it" || name === "test") && (!substantiveOnly || hasSubstantiveBody(node))) n += mult;
|
|
3142
3629
|
}
|
|
3143
3630
|
} else if (ts4.isTaggedTemplateExpression(node) && isEachOf(node.tag)) {
|
|
3144
|
-
|
|
3145
|
-
n += Math.max(rows, 1);
|
|
3631
|
+
n += templateRows(node) * mult;
|
|
3146
3632
|
}
|
|
3147
|
-
ts4.forEachChild(node, visit);
|
|
3633
|
+
ts4.forEachChild(node, (c) => visit(c, mult));
|
|
3148
3634
|
};
|
|
3149
|
-
visit(sf);
|
|
3635
|
+
visit(sf, 1);
|
|
3150
3636
|
return { min: n, open };
|
|
3151
3637
|
}
|
|
3152
3638
|
function countTestBlocks(src, path = "spec.ts") {
|
|
@@ -3161,7 +3647,7 @@ function significantLines(src, path) {
|
|
|
3161
3647
|
}
|
|
3162
3648
|
return out2;
|
|
3163
3649
|
}
|
|
3164
|
-
var OTHER_RUNNER_DIR = /(?:^|\/)(?:e2e|cypress|playwright)\//;
|
|
3650
|
+
var OTHER_RUNNER_DIR = /(?:^|\/)(?:e2e|cypress|playwright|fixtures|__fixtures__|__mocks__)\//;
|
|
3165
3651
|
function runnerSamples(configPath, policy, ctx) {
|
|
3166
3652
|
const files = trackedFiles(ctx);
|
|
3167
3653
|
if (!files) return CANONICAL_SAMPLES;
|
|
@@ -3169,6 +3655,34 @@ function runnerSamples(configPath, policy, ctx) {
|
|
|
3169
3655
|
const own = files.filter((f) => f.startsWith(root) && langOf(f) === "js" && isProtected(f, policy, "tests") && !OTHER_RUNNER_DIR.test(f)).map((f) => f.slice(root.length));
|
|
3170
3656
|
return own.length ? own : CANONICAL_SAMPLES;
|
|
3171
3657
|
}
|
|
3658
|
+
function scriptNarrowings(before, after, path) {
|
|
3659
|
+
if ((path.split("/").pop() ?? path) !== "package.json") return [];
|
|
3660
|
+
const scriptsOf = (src) => {
|
|
3661
|
+
try {
|
|
3662
|
+
const s = JSON.parse(src ?? "{}").scripts ?? {};
|
|
3663
|
+
return Object.fromEntries(Object.entries(s).filter((e) => typeof e[1] === "string"));
|
|
3664
|
+
} catch {
|
|
3665
|
+
return {};
|
|
3666
|
+
}
|
|
3667
|
+
};
|
|
3668
|
+
const was = scriptsOf(before);
|
|
3669
|
+
const now = scriptsOf(after);
|
|
3670
|
+
const out2 = [];
|
|
3671
|
+
for (const [name, cmd] of Object.entries(now)) {
|
|
3672
|
+
if (!/^test(?:$|[:.-])/.test(name) || !/\b(?:jest|vitest)\b/.test(cmd)) continue;
|
|
3673
|
+
const prev = was[name] ?? "";
|
|
3674
|
+
const flags = new RegExp(SUITE_NARROWING_FLAGS.source, "g");
|
|
3675
|
+
for (const m of cmd.matchAll(flags)) {
|
|
3676
|
+
const flag = m[0].trim();
|
|
3677
|
+
if (!flag) continue;
|
|
3678
|
+
const one = new RegExp(SUITE_NARROWING_FLAGS.source);
|
|
3679
|
+
if (one.test(prev) && prev.includes(flag)) continue;
|
|
3680
|
+
out2.push({ script: name, flag, before: prev, after: cmd });
|
|
3681
|
+
break;
|
|
3682
|
+
}
|
|
3683
|
+
}
|
|
3684
|
+
return out2;
|
|
3685
|
+
}
|
|
3172
3686
|
function joinRoot(root, to) {
|
|
3173
3687
|
const parts = root ? root.split("/") : [];
|
|
3174
3688
|
for (const seg of to.replace(/\/+$/, "").split("/")) {
|
|
@@ -3200,11 +3714,11 @@ var testDeletion = {
|
|
|
3200
3714
|
if (c.kind !== "file" || c.after == null || !isProtected(c.path, policy, "tests")) continue;
|
|
3201
3715
|
if (c.op === "add") {
|
|
3202
3716
|
for (const l of significantLines(c.after, c.path)) addedTestLines.add(l);
|
|
3203
|
-
addedTestBlocks +=
|
|
3717
|
+
addedTestBlocks += countTests(c.after, c.path, true).min;
|
|
3204
3718
|
} else if (c.before != null) {
|
|
3205
3719
|
const before = significantLines(c.before, c.path);
|
|
3206
3720
|
for (const l of significantLines(c.after, c.path)) if (!before.has(l)) addedTestLines.add(l);
|
|
3207
|
-
addedTestBlocks += Math.max(0,
|
|
3721
|
+
addedTestBlocks += Math.max(0, countTests(c.after, c.path, true).min - countTests(c.before, c.path, true).min);
|
|
3208
3722
|
}
|
|
3209
3723
|
}
|
|
3210
3724
|
const isRelocation = (removed, blocksRemoved) => {
|
|
@@ -3251,7 +3765,7 @@ var testDeletion = {
|
|
|
3251
3765
|
})
|
|
3252
3766
|
);
|
|
3253
3767
|
}
|
|
3254
|
-
} else if (c.op !== "delete" && c.after != null && !isTest && isProtected(c.path, policy, "config") && runnerOf(c.path)) {
|
|
3768
|
+
} else if (c.op !== "delete" && c.after != null && !isTest && isProtected(c.path, policy, "config") && runnerOf(c.path, c.after)) {
|
|
3255
3769
|
for (const n of suiteNarrowings(c.before, c.after, c.path, runnerSamples(c.path, policy, ctx))) {
|
|
3256
3770
|
out2.push(
|
|
3257
3771
|
makeFinding(RULE7, policy, {
|
|
@@ -3262,6 +3776,16 @@ var testDeletion = {
|
|
|
3262
3776
|
})
|
|
3263
3777
|
);
|
|
3264
3778
|
}
|
|
3779
|
+
for (const n of scriptNarrowings(c.before, c.after, c.path)) {
|
|
3780
|
+
out2.push(
|
|
3781
|
+
makeFinding(RULE7, policy, {
|
|
3782
|
+
file: c.path,
|
|
3783
|
+
message: `The test script now narrows the suite: ${n.flag} added to scripts.${n.script}.`,
|
|
3784
|
+
evidence: `"${n.script}": ${JSON.stringify(n.before)} \u2192 ${JSON.stringify(n.after)}`,
|
|
3785
|
+
remediation: "Keep the test script running the whole suite; a runner flag that selects fewer specs removes the rest from every run."
|
|
3786
|
+
})
|
|
3787
|
+
);
|
|
3788
|
+
}
|
|
3265
3789
|
}
|
|
3266
3790
|
} else {
|
|
3267
3791
|
let root = "";
|
|
@@ -3275,15 +3799,25 @@ var testDeletion = {
|
|
|
3275
3799
|
const inRepo = (t) => root ? `${root}/${t}` : t;
|
|
3276
3800
|
const listing = trackedFiles(ctx);
|
|
3277
3801
|
const testToks = toks.filter((t) => {
|
|
3278
|
-
|
|
3802
|
+
const resolved = root !== null && /(?:^|\/)\.{1,2}(?:\/|$)/.test(t) ? joinRoot(root, t) : null;
|
|
3803
|
+
const probe = resolved ?? t;
|
|
3804
|
+
if (!isProtected(probe, policy, "tests")) return false;
|
|
3279
3805
|
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);
|
|
3806
|
+
const looksLikeFile = /\.[A-Za-z0-9]+$/.test(probe.split("/").pop() ?? "");
|
|
3807
|
+
if (looksLikeFile || listing.includes(resolved ?? inRepo(t))) return true;
|
|
3808
|
+
return containsProtected(resolved ?? inRepo(t), policy, "tests", ctx);
|
|
3809
|
+
});
|
|
3810
|
+
const rootHasProtected = () => listing ? listing.some((f) => isProtected(f, policy, "tests")) : true;
|
|
3811
|
+
const dirToks = toks.filter((t) => {
|
|
3812
|
+
if (t.startsWith("-") || testToks.includes(t)) return false;
|
|
3813
|
+
const whole = /^(?:\.|\.\/|\*|\.\/\*)$/.test(t);
|
|
3814
|
+
if (!whole && /[*?{}[\]]/.test(t)) return false;
|
|
3815
|
+
if (root === null) return whole ? false : containsProtected(t, policy, "tests", ctx);
|
|
3816
|
+
const dir = whole ? root : joinRoot(root, t);
|
|
3817
|
+
if (dir === null) return false;
|
|
3818
|
+
if (dir === "") return rootHasProtected();
|
|
3819
|
+
return containsProtected(dir, policy, "tests", ctx);
|
|
3283
3820
|
});
|
|
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
3821
|
if (testToks.length === 0 && dirToks.length === 0) continue;
|
|
3288
3822
|
const named = [...testToks, ...dirToks];
|
|
3289
3823
|
const cmd = toks[0] ?? "";
|
|
@@ -3294,8 +3828,11 @@ var testDeletion = {
|
|
|
3294
3828
|
const findDeletes = cmd === "find" && /\s(?:-delete\b|-exec\s+(?:rm|unlink)\b|-execdir\s+(?:rm|unlink)\b)/.test(seg);
|
|
3295
3829
|
const gitRestoresOld = cmd === "git" && /^git\s+(?:checkout|restore)\b/.test(seg) && (() => {
|
|
3296
3830
|
const src = seg.match(/--source[= ]\s*(\S+)/)?.[1];
|
|
3297
|
-
const
|
|
3298
|
-
|
|
3831
|
+
const revIdx = toks.findIndex((t, i) => i >= 2 && !t.startsWith("-") && !named.includes(t) && t !== "--");
|
|
3832
|
+
const rev = src ?? (revIdx >= 0 ? toks[revIdx] : void 0);
|
|
3833
|
+
if (!rev || rev === "HEAD") return false;
|
|
3834
|
+
if (src || seg.includes(" -- ")) return true;
|
|
3835
|
+
return revIdx >= 0 && toks.some((t, i) => i > revIdx && named.includes(t));
|
|
3299
3836
|
})();
|
|
3300
3837
|
if (/\brm\b/.test(seg) && !/^(?:git|npm|yarn|pnpm|bun)\b/.test(seg)) {
|
|
3301
3838
|
why = "rm deletes a test file";
|
|
@@ -3350,7 +3887,7 @@ var MIN_REMOVED_LINES = 3;
|
|
|
3350
3887
|
var MIN_REMOVED_ROWS = 2;
|
|
3351
3888
|
var ws = (s) => s.replace(/\s+/g, "");
|
|
3352
3889
|
function eachTables(src, path) {
|
|
3353
|
-
const rows = /* @__PURE__ */ new
|
|
3890
|
+
const rows = /* @__PURE__ */ new Map();
|
|
3354
3891
|
let open = false;
|
|
3355
3892
|
if (langOf(path) !== "js" && langOf(path) !== null) return { rows, open };
|
|
3356
3893
|
try {
|
|
@@ -3364,10 +3901,9 @@ function eachTables(src, path) {
|
|
|
3364
3901
|
open = true;
|
|
3365
3902
|
continue;
|
|
3366
3903
|
}
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
}
|
|
3904
|
+
const text = el.getText(sf).replace(/\s*\n\s*/g, " ");
|
|
3905
|
+
const key2 = ws(text);
|
|
3906
|
+
if (key2.length >= 3) rows.set(key2, text);
|
|
3371
3907
|
}
|
|
3372
3908
|
} else open = true;
|
|
3373
3909
|
}
|
|
@@ -3422,12 +3958,13 @@ var testContentRemoval = {
|
|
|
3422
3958
|
gone.push(line);
|
|
3423
3959
|
}
|
|
3424
3960
|
const table = eachTables(c.before, c.path);
|
|
3425
|
-
const
|
|
3961
|
+
const tableAfter = eachTables(c.after, c.path);
|
|
3962
|
+
const goneRows = table.rows.size && tableAfter.open ? [...table.rows].filter(([k]) => !tableAfter.rows.has(k) && !addedPool.includes(k)).map(([, text]) => text) : [];
|
|
3426
3963
|
if (goneRows.length >= MIN_REMOVED_ROWS) {
|
|
3427
3964
|
out2.push(
|
|
3428
3965
|
makeFinding(RULE8, policy, {
|
|
3429
3966
|
file: c.path,
|
|
3430
|
-
message: `Rows removed from a test table: ${goneRows.length} case(s) gone from an each-table that
|
|
3967
|
+
message: `Rows removed from a test table: ${goneRows.length} case(s) gone from an each-table that spreads from elsewhere, and they reappear nowhere in the change.`,
|
|
3431
3968
|
evidence: goneRows.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
|
|
3432
3969
|
remediation: "Fix the code under test. A row of it.each is a test; moving the table out of the spec must carry every row with it."
|
|
3433
3970
|
})
|
|
@@ -3672,8 +4209,8 @@ function validate(r, where) {
|
|
|
3672
4209
|
if (r.ignore !== void 0 && !isStringList(r.ignore)) bad(`ignore must be a list of globs, got ${show(r.ignore)}`);
|
|
3673
4210
|
if (r.protected !== void 0) {
|
|
3674
4211
|
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(
|
|
4212
|
+
for (const [cat, globs4] of Object.entries(r.protected)) {
|
|
4213
|
+
if (!isStringList(globs4)) bad(`protected.${cat} must be a list of globs, got ${show(globs4)}`);
|
|
3677
4214
|
}
|
|
3678
4215
|
}
|
|
3679
4216
|
if (r.signoff !== void 0) {
|
|
@@ -3705,10 +4242,10 @@ function parsePolicy(raw, where = POLICY_FILE) {
|
|
|
3705
4242
|
const version = normalizeVersion(r.version, where);
|
|
3706
4243
|
validate(r, where);
|
|
3707
4244
|
const base = defaultPolicy(version);
|
|
3708
|
-
const
|
|
3709
|
-
const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list]) => [cat,
|
|
4245
|
+
const globs4 = (list) => list?.map(normalizeGlob);
|
|
4246
|
+
const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list]) => [cat, globs4(list) ?? []])) : void 0;
|
|
3710
4247
|
const userRules = r.rules ? Object.fromEntries(
|
|
3711
|
-
Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude:
|
|
4248
|
+
Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude: globs4(cfg.exclude) } : cfg])
|
|
3712
4249
|
) : void 0;
|
|
3713
4250
|
return {
|
|
3714
4251
|
version,
|
|
@@ -3718,7 +4255,7 @@ function parsePolicy(raw, where = POLICY_FILE) {
|
|
|
3718
4255
|
// an `exclude`-only override drop the rule's baseline severity (see mergeRules).
|
|
3719
4256
|
protected: mergeProtected(base.protected, userProtected),
|
|
3720
4257
|
rules: mergeRules(base.rules, userRules),
|
|
3721
|
-
ignore:
|
|
4258
|
+
ignore: globs4(r.ignore) ?? base.ignore,
|
|
3722
4259
|
signoff: {
|
|
3723
4260
|
requiredFor: r.signoff?.required_for ?? r.signoff?.requiredFor ?? base.signoff.requiredFor,
|
|
3724
4261
|
ledger: r.signoff?.ledger ?? base.signoff.ledger
|
|
@@ -5863,9 +6400,9 @@ function verifierInputs(cmd, atBase, policy) {
|
|
|
5863
6400
|
const tok = raw.replace(/^['"]|['"]$/g, "").replace(/^\.\//, "");
|
|
5864
6401
|
if (tok && baseFiles.has(tok)) picked.add(tok);
|
|
5865
6402
|
}
|
|
5866
|
-
const
|
|
5867
|
-
if (
|
|
5868
|
-
for (const rel of atBase) if (matchesAny(rel,
|
|
6403
|
+
const globs4 = policy.verify?.inputs;
|
|
6404
|
+
if (globs4 && globs4.length) {
|
|
6405
|
+
for (const rel of atBase) if (matchesAny(rel, globs4)) picked.add(rel);
|
|
5869
6406
|
}
|
|
5870
6407
|
return picked;
|
|
5871
6408
|
}
|