Files
msd-core/tests/no-unbounded-spawn-allowlist.test.cjs
Tom Boucher 2afe17bbdb test(#3143): add the no-unbounded-spawn guard and throw-preserving git fixture (#3150)
* 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>
2026-08-07 09:43:36 -04:00

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']);
});
});