* test(#3143): add no-unbounded-spawn guard and throw-preserving git fixture Adds the ESLint rule local/no-unbounded-spawn, wired into the tests/**/*.cjs block, plus an allowlist that only ratchets down: a listed file with zero violations reports its own entry as stale. The rule resolves renamed destructures and chained requires rather than matching literal callee names -- both forms exist in the suite today and a name-only matcher leaves them permanently invisible. It resolves an options object held in a single-write const, which is what keeps process-seam.cjs, the bounded reference implementation, from flagging itself. timeout: 0 and anything above the 600000ms ceiling are rejected as only nominally bounded. Adds tests/helpers/git-fixture.cjs so a migrated execSync call site keeps its throw-on-non-zero contract; process-seam.cjs is unchanged. * test(#3143): prove the allowlist guards can actually fail Extracts the D4/D6/D7/D8 checks into pure helpers and drives each against a synthetic fixture carrying an injected violation. Without this the suite only proved that today's clean data passes, which a deleted check would also satisfy. * fix(#3143): close two ceiling and alias escapes found in review Nested arithmetic bypassed the ceiling entirely: the numeric evaluator only resolved a flat literal, so `timeout: 60 * 60 * 1000` (3600000ms, six times the ceiling) fell through to trusted and reported nothing. The evaluator now recurses through arithmetic and unary signs with a depth cap. Alias resolution was traversal-order dependent, not scope dependent: a call textually above its own require destructure saw an empty alias map and reported clean. The map is now built in a Program pre-pass. Also: an explicit timeoutMs:undefined no longer overwrites the git fixture default via spread, adds the missing seam-routed rule test, and de-duplicates the repeated try/catch in the fixture tests. --------- Co-authored-by: sim <sim@local>
167 lines
6.4 KiB
JavaScript
167 lines
6.4 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.
|
|
const BASELINE = 139;
|
|
|
|
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']);
|
|
});
|
|
});
|