* test(#3144): bound the git/worktree cluster onto the process seam Migrates 180 unbounded sync spawn sites across 19 files. Every previously unbounded call now carries an explicit timeout with a comment giving the number and why. The migration is not a callee swap. execSync and execFileSync throw on a non-zero exit and the seam never does, so each site was classified first: sites that rely on the throw route to gitOrThrow, and sites that already read .status to detect an EXPECTED non-zero -- an intended cherry-pick conflict, a rev-parse outside a repo driving a skip -- route to the never-throwing runGit instead, which would otherwise throw on exactly the exit being probed for. Two same-named git() helpers in worktree-cleanup.test.cjs have different return contracts, one trimmed and one raw; both are preserved rather than unified. Collapses five hand-rolled throw wrappers onto one throwIfFailed in git-fixture.cjs, which gitOrThrow now also uses so the shape cannot drift. Allowlist drops 139 to 120; BASELINE lowered to match. * test(#3144): fix pre-PR review findings Documents throwIfFailed in the CONTEXT.md glossary and CONTRIBUTING.md -- it became the shared throw mechanism without either doc naming it. Routes the sixth and seventh hand-rolled copies of the throw shape through throwIfFailed (worktree-baseref-install, worktree-safety-reap); the first consolidation missed both. Converts ci-rebase-check's 8 fixture-setup calls from unchecked runGit to gitOrThrow so a failed setup step aborts where it fails rather than surfacing later as a confusing failure against the wrong subject. Adds 12 direct unit tests for throwIfFailed, which until now was only exercised transitively. Splits verify.test.cjs's non-git grep/sed bound off GIT_TIMEOUT_MS. --------- Co-authored-by: sim <sim@local>
168 lines
6.5 KiB
JavaScript
168 lines
6.5 KiB
JavaScript
// allow-test-rule: structural-regression-guard [#3143]
|
|
/**
|
|
* no-unbounded-spawn-allowlist.test.cjs
|
|
*
|
|
* Structural guards on `eslint-rules/no-unbounded-spawn.allowlist.json` —
|
|
* matrix D4-D8 of
|
|
* .gsd/phase/chore-3143-no-unbounded-spawn-guard/50-test-matrix.md.
|
|
*
|
|
* D7 needs to inspect test-file *contents* for an inline directive that
|
|
* disables this rule by name — the absence of that pattern is the contract
|
|
* this guard protects (a contributor cannot silence the check by disabling
|
|
* it inline instead of fixing the timeout). That is a `readFileSync` +
|
|
* text-search on `.cjs` files, which is exactly what `local/no-source-grep`
|
|
* exists to catch — hence the
|
|
* `// allow-test-rule: structural-regression-guard [#3143]` annotation on
|
|
* its own line above, per CONTRIBUTING.md's documented exemption.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const ALLOWLIST_PATH = path.join(__dirname, '..', 'eslint-rules', 'no-unbounded-spawn.allowlist.json');
|
|
const TESTS_DIR = path.join(__dirname);
|
|
const REPO_ROOT = path.join(__dirname, '..');
|
|
|
|
// The allowlist only ratchets DOWN. This baseline is the length observed at
|
|
// the time this guard was written (139 entries) — each future migration
|
|
// wave lowers it as files are moved off the allowlist by adding real
|
|
// timeouts; it must never grow back up. Lowered to 120 by the #3144 Wave-1
|
|
// process-seam migration (19 files' unbounded spawns bounded).
|
|
const BASELINE = 120;
|
|
|
|
function readAllowlist() {
|
|
const raw = fs.readFileSync(ALLOWLIST_PATH, 'utf8');
|
|
return JSON.parse(raw);
|
|
}
|
|
|
|
function listTestFiles() {
|
|
const out = [];
|
|
for (const entry of fs.readdirSync(TESTS_DIR, { withFileTypes: true })) {
|
|
if (entry.isFile() && entry.name.endsWith('.cjs')) {
|
|
out.push(path.join(TESTS_DIR, entry.name));
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Pure detection helpers, extracted so each is unit-testable against a
|
|
// synthetic fixture — proving the check itself can fail, not only that
|
|
// today's real data happens to pass it (a check that never runs against an
|
|
// injected violation is a vacuous-truth risk).
|
|
|
|
function findDeadEntries(list, root) {
|
|
return list.filter((entry) => !fs.existsSync(path.join(root, entry)));
|
|
}
|
|
|
|
function findBackslashEntries(list) {
|
|
return list.filter((entry) => entry.includes('\\'));
|
|
}
|
|
|
|
function isSorted(list) {
|
|
return JSON.stringify(list) === JSON.stringify([...list].sort());
|
|
}
|
|
|
|
function findDuplicates(list) {
|
|
const seen = new Set();
|
|
const dupes = new Set();
|
|
for (const entry of list) {
|
|
if (seen.has(entry)) dupes.add(entry);
|
|
seen.add(entry);
|
|
}
|
|
return [...dupes];
|
|
}
|
|
|
|
// Built via concatenation, not a string literal, so this file does not
|
|
// itself contain the literal directive text (`local/no-unbounded-spawn`)
|
|
// that D7 below scans every test file for — a literal here would make this
|
|
// guard flag itself.
|
|
const GUARDED_RULE = 'local' + '/' + 'no-unbounded-spawn';
|
|
|
|
function containsDisableDirective(contents, ruleName) {
|
|
return new RegExp(`eslint-disable[^\\n]*${ruleName}`).test(contents);
|
|
}
|
|
|
|
describe('no-unbounded-spawn allowlist: D4 — no dead entries', () => {
|
|
test('every allowlist entry resolves to a file that exists on disk', () => {
|
|
const list = readAllowlist();
|
|
const dead = findDeadEntries(list, REPO_ROOT);
|
|
assert.deepEqual(dead, [], `dead allowlist entries (file does not exist): ${JSON.stringify(dead)}`);
|
|
});
|
|
|
|
test('detection logic actually flags a synthetic dead entry', () => {
|
|
const synthetic = ['tests/does-not-exist-xyz.test.cjs', 'tests/no-unbounded-spawn.test.cjs'];
|
|
const dead = findDeadEntries(synthetic, REPO_ROOT);
|
|
assert.deepEqual(dead, ['tests/does-not-exist-xyz.test.cjs']);
|
|
});
|
|
});
|
|
|
|
describe('no-unbounded-spawn allowlist: D5 — never grows', () => {
|
|
test('allowlist length is at or under the recorded baseline', () => {
|
|
const list = readAllowlist();
|
|
assert.ok(
|
|
list.length <= BASELINE,
|
|
`allowlist grew to ${list.length} entries, exceeding the BASELINE of ${BASELINE}. ` +
|
|
`The allowlist only ratchets down — if this is a legitimate new violation, ` +
|
|
`lower BASELINE only after confirming it, never raise it to paper over growth.`
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('no-unbounded-spawn allowlist: D6 — separator normalization', () => {
|
|
test('every entry uses / separators, never \\', () => {
|
|
const list = readAllowlist();
|
|
const withBackslash = findBackslashEntries(list);
|
|
assert.deepEqual(withBackslash, [], `entries with a backslash separator: ${JSON.stringify(withBackslash)}`);
|
|
});
|
|
|
|
test('detection logic actually flags a synthetic backslash entry', () => {
|
|
const synthetic = ['tests\\foo.test.cjs', 'tests/bar.test.cjs'];
|
|
assert.deepEqual(findBackslashEntries(synthetic), ['tests\\foo.test.cjs']);
|
|
});
|
|
});
|
|
|
|
describe('no-unbounded-spawn allowlist: D7 — no inline disable of this rule', () => {
|
|
test('no test file inline-disables the unbounded-spawn guard', () => {
|
|
const offenders = [];
|
|
for (const filePath of listTestFiles()) {
|
|
const contents = fs.readFileSync(filePath, 'utf8');
|
|
if (containsDisableDirective(contents, GUARDED_RULE)) {
|
|
offenders.push(path.relative(REPO_ROOT, filePath));
|
|
}
|
|
}
|
|
assert.deepEqual(
|
|
offenders,
|
|
[],
|
|
`test files inline-disabling the unbounded-spawn guard (forbidden — fix the timeout instead): ${JSON.stringify(offenders)}`
|
|
);
|
|
});
|
|
|
|
test('detection logic actually flags a synthetic inline-disable directive', () => {
|
|
const syntheticContents = [
|
|
"'use strict';",
|
|
'// eslint-disable-next-line ' + GUARDED_RULE,
|
|
"spawnSync('git', ['status'], {});",
|
|
].join('\n');
|
|
assert.equal(containsDisableDirective(syntheticContents, GUARDED_RULE), true);
|
|
assert.equal(containsDisableDirective("'use strict';\nspawnSync('git', ['status'], {});", GUARDED_RULE), false);
|
|
});
|
|
});
|
|
|
|
describe('no-unbounded-spawn allowlist: D8 — canonical form', () => {
|
|
test('allowlist is valid JSON, sorted, with no duplicates', () => {
|
|
const list = readAllowlist();
|
|
assert.ok(Array.isArray(list), 'allowlist.json must parse to an array');
|
|
assert.ok(isSorted(list), 'allowlist entries must be sorted');
|
|
assert.deepEqual(findDuplicates(list), [], 'allowlist entries must be unique');
|
|
});
|
|
|
|
test('detection logic actually flags a synthetic unsorted/duplicate list', () => {
|
|
assert.equal(isSorted(['b.test.cjs', 'a.test.cjs']), false);
|
|
assert.deepEqual(findDuplicates(['a.test.cjs', 'b.test.cjs', 'a.test.cjs']), ['a.test.cjs']);
|
|
});
|
|
});
|