* 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>
This commit is contained in:
@@ -423,6 +423,12 @@ An injectable time abstraction accepted as an optional parameter by production c
|
||||
### Process seam
|
||||
The single subprocess-spawning primitive test code uses (`tests/helpers/process-seam.cjs`, #3055): `runNode` / `runGit` / `runHook`, each returning one discriminated union `{ outcome, exitCode, stdout, stderr, timedOut, signal, killed, code }` where `outcome` is the frozen `OUTCOME` enum (`EXITED` / `KILLED` / `TIMED_OUT` / `BUFFER_OVERFLOW` / `SPAWN_FAILED`). Every call is timeout-bounded — there is no unbounded code path — and nothing throws for a child's exit code, kill, timeout, buffer overflow, or spawn failure; all five are data. KILLED is a child terminated by a signal the seam did not send (a genuine OOM kill): `spawnSync` reports no `error` for that case, so it must be distinguished from EXITED, and the `runGsdTools` adapter retries it exactly as the pre-seam `isKilled()` did. This is what makes `timedOut` and `signal` assertable, so a fail-open guard's degraded verdict can be tested instead of merely observing that the call did not throw. Discrimination order is forced by runtime behavior: a timeout and a maxBuffer overflow are identical on both `status` (`null`) and `signal` (`SIGTERM`) and differ only by `code` (`ETIMEDOUT` vs `ENOBUFS`), so overflow is classified first. Per-suite wrappers remain and bind fixtures (cwd, env, payload); only the spawn body delegates here. Deliberately **not** a fault-injection surface — it cannot distinguish an injected timeout from a genuine bench OOM and would retry it; injection is in-process via `deps` (#3056). `runGsdTools` is an adapter over it that preserves its own legacy `{ success, output, error, exitCode }` shape and retry-once-on-kill behavior.
|
||||
|
||||
### Git fixture wrapper
|
||||
The throw-preserving companion to the process seam (`tests/helpers/git-fixture.cjs`, #3143): `gitOrThrow(args, options)` runs `runGit` and returns `stdout` as a string on a clean exit, but throws on any other outcome. It exists because the seam **deliberately never throws** while `execSync` and `execFileSync` — the two forms 237 migrating call sites use — both throw on a non-zero exit. Migrating those mechanically onto `runGit` would convert a loud failure into a silent one: fixture setup that failed would return an empty string and surface as a baffling assertion failure further down. The thrown error carries `status` **and** `exitCode` as deliberate aliases (`status` is what the legacy `execSync` catch idiom reads, e.g. `tests/worktree-safety.test.cjs:1361`), plus `stdout`, `stderr`, `signal`, `timedOut` and `outcome`. Use `runGit` when every outcome is data you branch on; use `gitOrThrow` for fixture setup that must abort loudly. The seam module is **not** modified to add this — a throwing export would falsify the never-throws contract stated in its own header and in the `### Process seam` entry above.
|
||||
|
||||
### Unbounded-spawn guard
|
||||
The lint rule enforcing `DEFECT.UNBOUNDED-SUBPROCESS` across the test suite (`eslint-rules/no-unbounded-spawn.cjs`, #3143, wired into the `tests/**/*.cjs` block of `eslint.config.mjs`). Flags `spawnSync` / `execFileSync` / `execSync` whose options carry no usable `timeout`. It resolves renamed destructures (`const { execSync: exec } = require('node:child_process')`) and chained requires (`require('node:child_process').execSync(...)`) 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. Two values are rejected as *nominally* bounded: `timeout: 0` (Node reads zero as no timeout) and anything above the 600000 ms ceiling (effectively unbounded); a non-literal value is trusted, since the target shape is one named constant with a comment. `no-unbounded-spawn.allowlist.json` grandfathers pre-existing violations and ratchets **down only** — a listed file with zero violations reports its own entry as stale, so the list cannot go quiet while the class survives. Companion tests assert the list never grows, carries no dead entries, and that no `eslint-disable` for this rule exists anywhere under `tests/`.
|
||||
|
||||
### Deterministic scheduler
|
||||
Test-execution model in which all timing and concurrency outcomes are fully controlled by the test (via clock seam, explicit `await` ordering, or synchronous stepping) rather than by the OS thread scheduler. Opposed to real-race tests, which are non-deterministic on loaded CI runners.
|
||||
|
||||
|
||||
@@ -417,6 +417,57 @@ bench OOM. Inject faults in-process through a module's `deps` parameter instead.
|
||||
Per-suite wrappers are still expected and encouraged: bind your fixture (cwd, env, payload) in a
|
||||
local helper and delegate the spawn to the seam.
|
||||
|
||||
#### When you want git to *throw*: `gitOrThrow`
|
||||
|
||||
`runGit` never throws — that is the whole point of it. But `execSync` and `execFileSync` **do**
|
||||
throw on a non-zero exit, and a lot of fixture setup relies on that: `git commit` failing should
|
||||
stop the test right there, not hand back an empty string that produces a baffling assertion failure
|
||||
twenty lines later.
|
||||
|
||||
For that case use `tests/helpers/git-fixture.cjs`:
|
||||
|
||||
```javascript
|
||||
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
|
||||
|
||||
gitOrThrow(['init', '-b', 'main'], { cwd: dir });
|
||||
gitOrThrow(['commit', '-m', 'seed'], { cwd: dir }); // throws if git exits non-zero
|
||||
const branch = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir }).trim();
|
||||
```
|
||||
|
||||
It returns `stdout` as a **string** on success. On any non-`EXITED` outcome, or a non-zero exit, it
|
||||
throws an `Error` carrying `status`, `exitCode`, `stdout`, `stderr`, `signal`, `timedOut` and
|
||||
`outcome` as own properties. `status` and `exitCode` are deliberate aliases: `status` is what the
|
||||
legacy `execSync` idiom reads (`catch (err) { assert.equal(err.status, 1) }`), so a migrated call
|
||||
site keeps working.
|
||||
|
||||
| You want | Use |
|
||||
|---|---|
|
||||
| Every outcome as data; you branch on `outcome` | `runGit` |
|
||||
| Fixture setup that must abort loudly on failure | `gitOrThrow` |
|
||||
|
||||
`process-seam.cjs` itself is untouched by this — it still never throws.
|
||||
|
||||
#### The lint rule that enforces it
|
||||
|
||||
`local/no-unbounded-spawn` (`eslint-rules/no-unbounded-spawn.cjs`) fails any `spawnSync`,
|
||||
`execFileSync` or `execSync` under `tests/` that is not timeout-bounded. It resolves renamed
|
||||
destructures (`const { execSync: exec } = require('node:child_process')`) and chained requires
|
||||
(`require('node:child_process').execSync(...)`), so renaming your way around it does not work.
|
||||
|
||||
Two things it deliberately rejects, because both look bounded and are not:
|
||||
|
||||
- `timeout: 0` — Node reads zero as *no timeout*.
|
||||
- `timeout: 999999999` — anything above the 600000 ms ceiling is effectively unbounded. Size the
|
||||
number to what the command actually runs and say why in a comment.
|
||||
|
||||
A non-literal value (`timeout: GIT_TIMEOUT_MS`) is trusted — that is the shape you should be
|
||||
writing.
|
||||
|
||||
`eslint-rules/no-unbounded-spawn.allowlist.json` grandfathers files that predate the rule. It only
|
||||
ratchets **down**: once a file is clean, the rule reports its allowlist line as stale and you delete
|
||||
it. Never add an entry, and never reach for `eslint-disable` on this rule — a test asserts that no
|
||||
such comment exists.
|
||||
|
||||
### Test Structure
|
||||
|
||||
```javascript
|
||||
|
||||
141
eslint-rules/no-unbounded-spawn.allowlist.json
Normal file
141
eslint-rules/no-unbounded-spawn.allowlist.json
Normal file
@@ -0,0 +1,141 @@
|
||||
[
|
||||
"tests/adr-15-progress-converge.test.cjs",
|
||||
"tests/agent-fragments-emission.install.test.cjs",
|
||||
"tests/agent-install-validation.test.cjs",
|
||||
"tests/agent-skills.test.cjs",
|
||||
"tests/antigravity-upgrades.test.cjs",
|
||||
"tests/augment-upgrades.test.cjs",
|
||||
"tests/autonomous-converge.test.cjs",
|
||||
"tests/bugs-1656-1657.test.cjs",
|
||||
"tests/capability-cli.test.cjs",
|
||||
"tests/capability-consent.test.cjs",
|
||||
"tests/capability-ledger.test.cjs",
|
||||
"tests/capability-lifecycle.test.cjs",
|
||||
"tests/capability-loader.test.cjs",
|
||||
"tests/capability-writer.test.cjs",
|
||||
"tests/changeset-cli.test.cjs",
|
||||
"tests/changeset-github-release-notes.test.cjs",
|
||||
"tests/changeset-lint.test.cjs",
|
||||
"tests/check-tdd-review-checkpoint-e2e.test.cjs",
|
||||
"tests/check-ui-safety-gate.test.cjs",
|
||||
"tests/check-update-config-dir.test.cjs",
|
||||
"tests/ci-rebase-check.test.cjs",
|
||||
"tests/ci-test-scope.test.cjs",
|
||||
"tests/cli-exit.test.cjs",
|
||||
"tests/close-phase-todos-padded-resolves.test.cjs",
|
||||
"tests/code-review-pipeline-regression.test.cjs",
|
||||
"tests/codex-config.test.cjs",
|
||||
"tests/commands.test.cjs",
|
||||
"tests/commit-docs-bypass.test.cjs",
|
||||
"tests/commit-files-deletion.test.cjs",
|
||||
"tests/commit-files-pathspec.test.cjs",
|
||||
"tests/commonjs-marker.test.cjs",
|
||||
"tests/config-get-default.test.cjs",
|
||||
"tests/config-loader.test.cjs",
|
||||
"tests/config.test.cjs",
|
||||
"tests/configuration-migrate-config.test.cjs",
|
||||
"tests/copilot-install.test.cjs",
|
||||
"tests/declarative-reference-antigravity.test.cjs",
|
||||
"tests/declarative-reference-augment.test.cjs",
|
||||
"tests/declarative-reference-codebuddy.test.cjs",
|
||||
"tests/declarative-reference-copilot.test.cjs",
|
||||
"tests/declarative-reference-windsurf.test.cjs",
|
||||
"tests/declarative-reference-zcode.test.cjs",
|
||||
"tests/drift-detection.test.cjs",
|
||||
"tests/edge-probe.test.cjs",
|
||||
"tests/effort-sync-installed-runtime.test.cjs",
|
||||
"tests/emitted-caps-gate.test.cjs",
|
||||
"tests/emitted-sizes.test.cjs",
|
||||
"tests/ensure-runtime-build.test.cjs",
|
||||
"tests/execute-phase-worktree-guard.test.cjs",
|
||||
"tests/execute-wave-post-gate-pipeline-e2e.test.cjs",
|
||||
"tests/fix-2136-clock-local-today.test.cjs",
|
||||
"tests/fix-2590-workflow-script-contract.test.cjs",
|
||||
"tests/fix-2608-commit-staging-failure.test.cjs",
|
||||
"tests/fix-2650-plan-phase-stall-detection.test.cjs",
|
||||
"tests/fix-2657-untrack-compiled-artifacts.test.cjs",
|
||||
"tests/fix-3045-cursor-subagent-isolation.test.cjs",
|
||||
"tests/fix-3045-dispatch-isolation-resolver.test.cjs",
|
||||
"tests/fixture-builder.test.cjs",
|
||||
"tests/fixtures/index.cjs",
|
||||
"tests/fragment-single-edit-propagation.install.test.cjs",
|
||||
"tests/frontmatter-cli.test.cjs",
|
||||
"tests/gemini-runtime-removed.test.cjs",
|
||||
"tests/gen-registry.test.cjs",
|
||||
"tests/git-base-branch.test.cjs",
|
||||
"tests/golden-install-tree.test.cjs",
|
||||
"tests/graphify-auto-update.slow.test.cjs",
|
||||
"tests/graphify-visualization.test.cjs",
|
||||
"tests/gsd-agent-isolation-guard.test.cjs",
|
||||
"tests/gsd-statusline.test.cjs",
|
||||
"tests/gsd-write-guard.property.test.cjs",
|
||||
"tests/helpers.cjs",
|
||||
"tests/helpers/graphify.cjs",
|
||||
"tests/helpers/install-shared.cjs",
|
||||
"tests/hooks-opt-in.test.cjs",
|
||||
"tests/host-integration.test.cjs",
|
||||
"tests/ingest-docs.test.cjs",
|
||||
"tests/init.test.cjs",
|
||||
"tests/install-minimal-hooks.test.cjs",
|
||||
"tests/install-regressions.test.cjs",
|
||||
"tests/install-write-confinement.test.cjs",
|
||||
"tests/install.test.cjs",
|
||||
"tests/installer-migration-install.integration.test.cjs",
|
||||
"tests/installer-migrations.test.cjs",
|
||||
"tests/io.test.cjs",
|
||||
"tests/issue-2695-codex-hook-set.test.cjs",
|
||||
"tests/issue-2765-brace-expansion-lockfile.test.cjs",
|
||||
"tests/issue-498-update-context.test.cjs",
|
||||
"tests/issue-787-cline-hooks-agents.test.cjs",
|
||||
"tests/issue-844-manifest-version-sync.test.cjs",
|
||||
"tests/kilo-upgrades.test.cjs",
|
||||
"tests/kimi-agent-converter.test.cjs",
|
||||
"tests/kimi-upgrades.test.cjs",
|
||||
"tests/lint-docs-command-form.test.cjs",
|
||||
"tests/lint-legacy-dir-name.test.cjs",
|
||||
"tests/lint-pr-check-project-dir.test.cjs",
|
||||
"tests/lint-regression-test-names.test.cjs",
|
||||
"tests/lint-skill-deps.test.cjs",
|
||||
"tests/lint-test-file-count.test.cjs",
|
||||
"tests/loop-render-hooks.test.cjs",
|
||||
"tests/managed-hooks.test.cjs",
|
||||
"tests/mcp-catalog-parity.install.test.cjs",
|
||||
"tests/mutation-matrix-ratchet.test.cjs",
|
||||
"tests/new-milestone-clear-phases.test.cjs",
|
||||
"tests/opencode-command-dir-plural.test.cjs",
|
||||
"tests/opencode-plugin-adapter.test.cjs",
|
||||
"tests/pause-work-improvements.test.cjs",
|
||||
"tests/phase.test.cjs",
|
||||
"tests/precommit-alias-drift-hook.test.cjs",
|
||||
"tests/prepush-enterprise-email-hook.test.cjs",
|
||||
"tests/process-seam.test.cjs",
|
||||
"tests/prohibition-enforcement.test.cjs",
|
||||
"tests/project-instruction-file-parity.test.cjs",
|
||||
"tests/prune-orphaned-worktrees.test.cjs",
|
||||
"tests/quick-branching.test.cjs",
|
||||
"tests/reapply-verify-hunks.test.cjs",
|
||||
"tests/release-hotfix-empty-cherry-pick.test.cjs",
|
||||
"tests/repo-layout.test.cjs",
|
||||
"tests/reviewer-docs-parity.test.cjs",
|
||||
"tests/roadmap-upgrade.test.cjs",
|
||||
"tests/run-tests-harness.test.cjs",
|
||||
"tests/runtime-launcher-parity.test.cjs",
|
||||
"tests/skill-frontmatter-contract.test.cjs",
|
||||
"tests/slash-command-namespace.test.cjs",
|
||||
"tests/smart-entry.unit.test.cjs",
|
||||
"tests/spec-section.test.cjs",
|
||||
"tests/state-rebuild-cli.test.cjs",
|
||||
"tests/state.test.cjs",
|
||||
"tests/tsconfig-noemit.test.cjs",
|
||||
"tests/validate-registry.test.cjs",
|
||||
"tests/verification-status.test.cjs",
|
||||
"tests/verify.test.cjs",
|
||||
"tests/windsurf-hooks-bridge.test.cjs",
|
||||
"tests/workflow-fragments-emission.install.test.cjs",
|
||||
"tests/workflow-guard.test.cjs",
|
||||
"tests/workspace.test.cjs",
|
||||
"tests/worktree-baseref-install.test.cjs",
|
||||
"tests/worktree-cleanup.test.cjs",
|
||||
"tests/worktree-safety.test.cjs",
|
||||
"tests/worktree.test.cjs"
|
||||
]
|
||||
373
eslint-rules/no-unbounded-spawn.cjs
Normal file
373
eslint-rules/no-unbounded-spawn.cjs
Normal file
@@ -0,0 +1,373 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* no-unbounded-spawn
|
||||
*
|
||||
* Flag a synchronous child_process spawn (`spawnSync`, `execFileSync`,
|
||||
* `execSync`) in tests that is not timeout-bounded.
|
||||
*
|
||||
* ## What this enforces (DEFECT.UNBOUNDED-SUBPROCESS)
|
||||
*
|
||||
* An unbounded synchronous subprocess spawn can hang indefinitely. On
|
||||
* macOS CI this is how a stuck test silently stops reporting instead of
|
||||
* failing loudly — the runner just goes quiet. Every sync spawn in a test
|
||||
* must either:
|
||||
* - pass an explicit `timeout` (ms) in its options object, sized to a
|
||||
* sane ceiling, or
|
||||
* - be routed through `tests/helpers/process-seam.cjs` (`runNode`,
|
||||
* `runGit`, `runHook`), which is bounded by construction.
|
||||
*
|
||||
* ## Recognized call shapes
|
||||
*
|
||||
* - Bare identifier calls: `spawnSync(...)`, `execFileSync(...)`,
|
||||
* `execSync(...)` — whether from a plain destructure
|
||||
* (`const { execFileSync } = require('child_process')`) or an aliased
|
||||
* one (`const { execSync: exec } = require('child_process')`), or an
|
||||
* ES import (`import { execSync } from 'node:child_process'`, with or
|
||||
* without a local alias).
|
||||
* - Chained member calls: `require('node:child_process').execFileSync(...)`
|
||||
* — matched object-blind on the callee's `.property` name, the same
|
||||
* shape `no-bare-npm-exec.cjs` matches.
|
||||
*
|
||||
* ## Timeout resolution
|
||||
*
|
||||
* The options argument (last call argument, if any) is inspected for a
|
||||
* `timeout` property. An ObjectExpression is inspected directly; an
|
||||
* Identifier is resolved to its single-write, object-literal-initialized
|
||||
* variable in an enclosing scope. Anything else (spread-only, array,
|
||||
* literal, or an unresolvable identifier) is treated as unbounded — the
|
||||
* rule never assumes a call is safe just because it can't prove otherwise.
|
||||
*
|
||||
* A resolved timeout value is only "bounded" when it's a positive finite
|
||||
* number at or under `maxTimeoutMs` (default 600000ms / 10 minutes). A
|
||||
* non-literal timeout expression (variable, member access, call) is
|
||||
* trusted as bounded — this rule does not attempt general expression
|
||||
* evaluation.
|
||||
*
|
||||
* ## Allowlist
|
||||
*
|
||||
* `allowlist` (repo-relative POSIX paths) grandfathers pre-existing
|
||||
* violations. The allowlist only ratchets down: a listed file with zero
|
||||
* violations reports `staleAllowlistEntry` so the dead entry gets deleted.
|
||||
*/
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
const rule = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow an unbounded synchronous child_process spawn (spawnSync/execFileSync/execSync) in tests',
|
||||
category: 'Reliability',
|
||||
},
|
||||
schema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
maxTimeoutMs: { type: 'number' },
|
||||
allowlist: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
messages: {
|
||||
unboundedSpawn:
|
||||
'Unbounded synchronous subprocess spawn (DEFECT.UNBOUNDED-SUBPROCESS): an ' +
|
||||
'unbounded subprocess is an indefinite hang, and on macOS CI that is how a ' +
|
||||
'stuck run silently stops reporting instead of failing loudly. Pass an ' +
|
||||
'explicit `timeout` (ms), or route this call through ' +
|
||||
'tests/helpers/process-seam.cjs (`runNode`/`runGit`/`runHook`), which is ' +
|
||||
'bounded by construction.',
|
||||
timeoutTooLarge:
|
||||
'`timeout: {{value}}` exceeds the {{max}}ms ceiling — a timeout that large ' +
|
||||
'is effectively unbounded. Size it to what the command actually runs.',
|
||||
staleAllowlistEntry:
|
||||
'{{file}} no longer contains an unbounded spawn. Delete its line from ' +
|
||||
'eslint-rules/no-unbounded-spawn.allowlist.json — the allowlist only ratchets down.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const DEFAULT_MAX_TIMEOUT_MS = 600000; // 10 minutes
|
||||
const options = context.options[0] || {};
|
||||
const maxTimeoutMs =
|
||||
typeof options.maxTimeoutMs === 'number' ? options.maxTimeoutMs : DEFAULT_MAX_TIMEOUT_MS;
|
||||
const allowlist = Array.isArray(options.allowlist) ? options.allowlist : [];
|
||||
|
||||
const TARGET_FNS = new Set(['spawnSync', 'execFileSync', 'execSync']);
|
||||
const CP_SOURCES = new Set(['child_process', 'node:child_process']);
|
||||
|
||||
/** Map from local (in-scope) name -> canonical target function name. */
|
||||
const aliases = new Map();
|
||||
|
||||
const filename = context.filename || context.getFilename();
|
||||
const cwd = context.cwd || (context.getCwd ? context.getCwd() : process.cwd());
|
||||
const rel = path.relative(cwd, filename).split(path.sep).join('/');
|
||||
const allowlisted = allowlist.includes(rel);
|
||||
let violations = 0;
|
||||
|
||||
/**
|
||||
* Returns the string value of a Literal node, or null.
|
||||
*/
|
||||
function stringValue(node) {
|
||||
if (node && node.type === 'Literal' && typeof node.value === 'string') {
|
||||
return node.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the function name for a CallExpression callee (Identifier or
|
||||
* non-computed MemberExpression), or null. Object-blind on purpose for
|
||||
* MemberExpression — matches `require('node:child_process').execFileSync(...)`
|
||||
* regardless of the object expression.
|
||||
*/
|
||||
function getFnName(callee) {
|
||||
if (callee.type === 'Identifier') return callee.name;
|
||||
if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.property.type === 'Identifier'
|
||||
) {
|
||||
return callee.property.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `node` is a CallExpression matching
|
||||
* `require('child_process')` / `require('node:child_process')`.
|
||||
*/
|
||||
function isChildProcessRequire(node) {
|
||||
return (
|
||||
node &&
|
||||
node.type === 'CallExpression' &&
|
||||
node.callee.type === 'Identifier' &&
|
||||
node.callee.name === 'require' &&
|
||||
node.arguments.length === 1 &&
|
||||
CP_SOURCES.has(stringValue(node.arguments[0]))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers aliases from an ObjectPattern destructuring a
|
||||
* child_process require: `const { execFileSync } = require(...)` or
|
||||
* `const { execSync: exec } = require(...)`.
|
||||
*/
|
||||
function registerDestructureAliases(pattern) {
|
||||
for (const prop of pattern.properties) {
|
||||
if (prop.type !== 'Property' || prop.computed) continue;
|
||||
const keyName = prop.key.type === 'Identifier' ? prop.key.name : stringValue(prop.key);
|
||||
if (!keyName || !TARGET_FNS.has(keyName)) continue;
|
||||
if (prop.value.type !== 'Identifier') continue;
|
||||
aliases.set(prop.value.name, keyName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the options argument for a target call: the last call
|
||||
* argument, if any. Returns the ObjectExpression to inspect, or null
|
||||
* if it cannot be resolved to one.
|
||||
*/
|
||||
function resolveOptionsNode(node) {
|
||||
const args = node.arguments;
|
||||
if (!args || args.length === 0) return null;
|
||||
const last = args[args.length - 1];
|
||||
if (last.type === 'ObjectExpression') return last;
|
||||
if (last.type === 'Identifier') {
|
||||
const scope =
|
||||
context.sourceCode && typeof context.sourceCode.getScope === 'function'
|
||||
? context.sourceCode.getScope(node)
|
||||
: context.getScope();
|
||||
let cur = scope;
|
||||
while (cur) {
|
||||
const variable = cur.variables.find((v) => v.name === last.name);
|
||||
if (variable) {
|
||||
if (variable.defs.length !== 1) return null;
|
||||
const def = variable.defs[0];
|
||||
if (def.type !== 'Variable' || !def.node.init || def.node.init.type !== 'ObjectExpression') {
|
||||
return null;
|
||||
}
|
||||
const writeRefs = variable.references.filter((r) => r.isWrite());
|
||||
if (writeRefs.length > 1) return null;
|
||||
return def.node.init;
|
||||
}
|
||||
cur = cur.upper;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Recursion depth cap for evalNumeric — guards against a pathological
|
||||
* nested-expression chain blowing the stack. */
|
||||
const MAX_EVAL_DEPTH = 20;
|
||||
|
||||
/**
|
||||
* Recursively evaluates a numeric-ish AST node to a JS number, or
|
||||
* returns undefined if it's not one of the recognized numeric shapes.
|
||||
* Handles a numeric Literal, a unary +/- of a recursively-numeric
|
||||
* argument, and a BinaryExpression (*, +, -, /) where both sides are
|
||||
* recursively numeric — so a multi-term chain like `60 * 60 * 1000`
|
||||
* resolves instead of bailing out on the first nested BinaryExpression.
|
||||
*/
|
||||
function evalNumeric(node, depth = 0) {
|
||||
if (depth > MAX_EVAL_DEPTH) return undefined;
|
||||
if (node.type === 'Literal' && typeof node.value === 'number') {
|
||||
return node.value;
|
||||
}
|
||||
if (node.type === 'UnaryExpression' && (node.operator === '-' || node.operator === '+')) {
|
||||
const arg = evalNumeric(node.argument, depth + 1);
|
||||
if (arg === undefined) return undefined;
|
||||
return node.operator === '-' ? -arg : arg;
|
||||
}
|
||||
if (
|
||||
node.type === 'BinaryExpression' &&
|
||||
(node.operator === '*' || node.operator === '+' || node.operator === '-' || node.operator === '/')
|
||||
) {
|
||||
const left = evalNumeric(node.left, depth + 1);
|
||||
const right = evalNumeric(node.right, depth + 1);
|
||||
if (left === undefined || right === undefined) return undefined;
|
||||
switch (node.operator) {
|
||||
case '*':
|
||||
return left * right;
|
||||
case '+':
|
||||
return left + right;
|
||||
case '-':
|
||||
return left - right;
|
||||
case '/':
|
||||
return left / right;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the timeout verdict for an options ObjectExpression:
|
||||
* 'bounded' | 'unbounded' | { tooLarge: number }.
|
||||
*/
|
||||
function timeoutVerdict(objExpr) {
|
||||
let timeoutProp = null;
|
||||
for (const prop of objExpr.properties) {
|
||||
if (prop.type !== 'Property' || prop.computed) continue;
|
||||
const keyName = prop.key.type === 'Identifier' ? prop.key.name : stringValue(prop.key);
|
||||
if (keyName === 'timeout') timeoutProp = prop;
|
||||
}
|
||||
if (!timeoutProp) return 'unbounded';
|
||||
|
||||
const value = timeoutProp.value;
|
||||
let v;
|
||||
const numeric = evalNumeric(value);
|
||||
if (numeric !== undefined) {
|
||||
v = numeric;
|
||||
} else if (value.type === 'Literal' && value.value === null) {
|
||||
return 'unbounded';
|
||||
} else if (
|
||||
value.type === 'Identifier' &&
|
||||
(value.name === 'undefined' || value.name === 'NaN')
|
||||
) {
|
||||
return 'unbounded';
|
||||
} else {
|
||||
// A variable/member/call bound is trusted; this rule does not
|
||||
// attempt general expression evaluation.
|
||||
return 'bounded';
|
||||
}
|
||||
|
||||
if (!Number.isFinite(v) || v <= 0) return 'unbounded';
|
||||
if (v > maxTimeoutMs) return { tooLarge: v };
|
||||
return 'bounded';
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers alias imports from an ImportDeclaration: `import
|
||||
* { execSync } from 'node:child_process'` or an aliased
|
||||
* `import { execSync as exec } from ...`.
|
||||
*/
|
||||
function registerImportAliases(node) {
|
||||
if (typeof node.source.value !== 'string' || !CP_SOURCES.has(node.source.value)) return;
|
||||
for (const spec of node.specifiers) {
|
||||
if (spec.type !== 'ImportSpecifier') continue;
|
||||
const importedName =
|
||||
spec.imported.type === 'Identifier' ? spec.imported.name : stringValue(spec.imported);
|
||||
if (!importedName || !TARGET_FNS.has(importedName)) continue;
|
||||
aliases.set(spec.local.name, importedName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walks the whole AST from `root`, over own enumerable
|
||||
* object/array properties (skipping `parent` to avoid walking back up
|
||||
* and re-visiting already-visited nodes), invoking `visit` on every
|
||||
* node encountered. Used to build the alias map in a pre-pass so that
|
||||
* `CallExpression` — visited during the normal single top-down walk —
|
||||
* can resolve an alias regardless of where in the file it was declared
|
||||
* relative to the call site.
|
||||
*/
|
||||
function walk(node, visit) {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) walk(item, visit);
|
||||
return;
|
||||
}
|
||||
if (typeof node.type !== 'string') return;
|
||||
visit(node);
|
||||
for (const key of Object.keys(node)) {
|
||||
if (key === 'parent') continue;
|
||||
const value = node[key];
|
||||
if (value && typeof value === 'object') {
|
||||
walk(value, visit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
Program(node) {
|
||||
walk(node, (n) => {
|
||||
if (n.type === 'VariableDeclarator' && n.id.type === 'ObjectPattern' && isChildProcessRequire(n.init)) {
|
||||
registerDestructureAliases(n.id);
|
||||
} else if (n.type === 'ImportDeclaration') {
|
||||
registerImportAliases(n);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
CallExpression(node) {
|
||||
let name = getFnName(node.callee);
|
||||
if (node.callee.type === 'Identifier' && aliases.has(name)) {
|
||||
name = aliases.get(name);
|
||||
}
|
||||
if (!name || !TARGET_FNS.has(name)) return;
|
||||
|
||||
const optionsNode = resolveOptionsNode(node);
|
||||
const verdict = optionsNode ? timeoutVerdict(optionsNode) : 'unbounded';
|
||||
|
||||
if (verdict === 'bounded') return;
|
||||
|
||||
violations += 1;
|
||||
if (allowlisted) return;
|
||||
|
||||
if (typeof verdict === 'object' && verdict.tooLarge !== undefined) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'timeoutTooLarge',
|
||||
data: { value: String(verdict.tooLarge), max: String(maxTimeoutMs) },
|
||||
});
|
||||
} else {
|
||||
context.report({ node, messageId: 'unboundedSpawn' });
|
||||
}
|
||||
},
|
||||
|
||||
'Program:exit'(node) {
|
||||
if (allowlisted && violations === 0) {
|
||||
context.report({ node, messageId: 'staleAllowlistEntry', data: { file: rel } });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = rule;
|
||||
@@ -5,6 +5,7 @@ import pluginN from 'eslint-plugin-n';
|
||||
import noOnlyTests from 'eslint-plugin-no-only-tests';
|
||||
import { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -24,6 +25,10 @@ import noBareNpmExec from './eslint-rules/no-bare-npm-exec.cjs';
|
||||
import requireUserprofileWithHome from './eslint-rules/require-userprofile-with-home.cjs';
|
||||
import normalizePathInContent from './eslint-rules/normalize-path-in-content.cjs';
|
||||
import requireFsOpFallback from './eslint-rules/require-fs-op-fallback.cjs';
|
||||
import noUnboundedSpawn from './eslint-rules/no-unbounded-spawn.cjs';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const unboundedSpawnAllowlist = require('./eslint-rules/no-unbounded-spawn.allowlist.json');
|
||||
|
||||
const localPlugin = {
|
||||
rules: {
|
||||
@@ -42,6 +47,7 @@ const localPlugin = {
|
||||
'require-userprofile-with-home': requireUserprofileWithHome,
|
||||
'normalize-path-in-content': normalizePathInContent,
|
||||
'require-fs-op-fallback': requireFsOpFallback,
|
||||
'no-unbounded-spawn': noUnboundedSpawn,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -390,6 +396,8 @@ export default tseslint.config(
|
||||
'local/no-bare-npm-exec': 'error',
|
||||
// Require USERPROFILE alongside HOME assignments (ADR-1703 Phase 4)
|
||||
'local/require-userprofile-with-home': 'error',
|
||||
// Ban unbounded sync child_process spawns in tests (DEFECT.UNBOUNDED-SUBPROCESS)
|
||||
'local/no-unbounded-spawn': ['error', { allowlist: unboundedSpawnAllowlist }],
|
||||
// Ban raw setTimeout sync + elapsed/duration-style assertions via no-restricted-syntax
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
|
||||
213
tests/git-fixture.test.cjs
Normal file
213
tests/git-fixture.test.cjs
Normal file
@@ -0,0 +1,213 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* git-fixture.test.cjs
|
||||
*
|
||||
* Behavioral tests for tests/helpers/git-fixture.cjs's `gitOrThrow`, driving
|
||||
* the real seam against real `git` in a temp fixture repo. Covers matrix
|
||||
* section E of .gsd/phase/chore-3143-no-unbounded-spawn-guard/50-test-matrix.md.
|
||||
*
|
||||
* E10 needs to observe the exact `timeoutMs` value `gitOrThrow` forwards to
|
||||
* the seam without any wall-clock measurement (both the documented default
|
||||
* and the seam's own bare default would let a normal git command succeed,
|
||||
* so a black-box timing test cannot distinguish them). This file installs a
|
||||
* pass-through call-recording spy on `process-seam.cjs`'s `runGit` *before*
|
||||
* `helpers/git-fixture.cjs` is required for the first time in this process,
|
||||
* so `gitOrThrow`'s own `const { runGit } = require('./process-seam.cjs')`
|
||||
* destructures the spy. With no custom implementation, `mock.method()`
|
||||
* calls straight through to the real `runGit` — every test below still
|
||||
* exercises real git — while additionally recording each call's arguments.
|
||||
*/
|
||||
|
||||
const { describe, test, mock, after, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const processSeam = require('./helpers/process-seam.cjs');
|
||||
const { OUTCOME } = processSeam;
|
||||
|
||||
const runGitSpy = mock.method(processSeam, 'runGit');
|
||||
after(() => mock.restoreAll());
|
||||
|
||||
const { gitOrThrow, DEFAULT_GIT_TIMEOUT_MS } = require('./helpers/git-fixture.cjs');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
/**
|
||||
* Runs `fn`, returning the error it throws. Fails the calling test with a
|
||||
* clear assertion if `fn` does not throw. A standalone helper (not inline
|
||||
* in a test body) is the CONTRIBUTING.md-compliant place for a try/catch of
|
||||
* this shape — "try/finally is only permitted inside standalone utility or
|
||||
* helper functions".
|
||||
*/
|
||||
function captureThrown(fn) {
|
||||
let caught;
|
||||
try {
|
||||
fn();
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
assert.ok(caught, 'expected fn to throw');
|
||||
return caught;
|
||||
}
|
||||
|
||||
/** Initialize a fresh repo with a known branch name and one commit. */
|
||||
function initRepo(prefix = 'git-fixture-test-') {
|
||||
const dir = createTempDir(prefix);
|
||||
gitOrThrow(['init', '--quiet', '-b', 'mainline'], { cwd: dir });
|
||||
gitOrThrow(['config', 'user.email', 'git-fixture-test@example.com'], { cwd: dir });
|
||||
gitOrThrow(['config', 'user.name', 'git-fixture-test'], { cwd: dir });
|
||||
gitOrThrow(['commit', '--allow-empty', '-m', 'initial commit'], { cwd: dir });
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('git-fixture: E — gitOrThrow', () => {
|
||||
let dir;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = initRepo();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(dir);
|
||||
});
|
||||
|
||||
test('E1: returns stdout as a string on success', () => {
|
||||
const r = gitOrThrow(['--version'], { cwd: dir });
|
||||
assert.equal(typeof r, 'string');
|
||||
});
|
||||
|
||||
test('E2: returns real command output', () => {
|
||||
const r = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir });
|
||||
assert.equal(r.trim(), 'mainline');
|
||||
});
|
||||
|
||||
test('E3: throws on non-zero exit', () => {
|
||||
assert.throws(() => gitOrThrow(['rev-parse', '--verify', 'refs/heads/does-not-exist'], { cwd: dir }));
|
||||
});
|
||||
|
||||
test('E4: thrown error exposes .status (legacy execSync idiom)', () => {
|
||||
const raw = processSeam.runGit(['rev-parse', '--verify', 'refs/heads/does-not-exist'], { cwd: dir });
|
||||
assert.notEqual(raw.exitCode, 0);
|
||||
const caught = captureThrown(() =>
|
||||
gitOrThrow(['rev-parse', '--verify', 'refs/heads/does-not-exist'], { cwd: dir })
|
||||
);
|
||||
assert.equal(caught.status, raw.exitCode);
|
||||
});
|
||||
|
||||
test('E5: thrown error exposes .exitCode (seam idiom), aliasing .status', () => {
|
||||
const caught = captureThrown(() =>
|
||||
gitOrThrow(['rev-parse', '--verify', 'refs/heads/does-not-exist'], { cwd: dir })
|
||||
);
|
||||
assert.equal(caught.exitCode, caught.status);
|
||||
});
|
||||
|
||||
test('E6: thrown error carries both streams as strings', () => {
|
||||
const caught = captureThrown(() =>
|
||||
gitOrThrow(['rev-parse', '--verify', 'refs/heads/does-not-exist'], { cwd: dir })
|
||||
);
|
||||
assert.equal(typeof caught.stdout, 'string');
|
||||
assert.equal(typeof caught.stderr, 'string');
|
||||
assert.ok(caught.stderr.length > 0, 'expected git to write a fatal message to stderr');
|
||||
});
|
||||
|
||||
test('E7: non-zero exit is EXITED, not a failure outcome', () => {
|
||||
const caught = captureThrown(() =>
|
||||
gitOrThrow(['rev-parse', '--verify', 'refs/heads/does-not-exist'], { cwd: dir })
|
||||
);
|
||||
assert.equal(caught.outcome, OUTCOME.EXITED);
|
||||
assert.equal(caught.timedOut, false);
|
||||
});
|
||||
|
||||
test('E8: spawn failure throws with SPAWN_FAILED', () => {
|
||||
const caught = captureThrown(() =>
|
||||
gitOrThrow(['--version'], { cwd: path.join(dir, 'no-such-subdirectory') })
|
||||
);
|
||||
assert.equal(caught.outcome, OUTCOME.SPAWN_FAILED);
|
||||
});
|
||||
|
||||
test('E9: timeout throws and reports timedOut', () => {
|
||||
const caught = captureThrown(() => gitOrThrow(['rev-parse', 'HEAD'], { cwd: dir, timeoutMs: 1 }));
|
||||
assert.equal(caught.timedOut, true);
|
||||
assert.equal(caught.outcome, OUTCOME.TIMED_OUT);
|
||||
});
|
||||
|
||||
test('E10: omitted timeout uses the documented default, not silence', () => {
|
||||
runGitSpy.mock.resetCalls();
|
||||
gitOrThrow(['--version'], { cwd: dir });
|
||||
assert.equal(runGitSpy.mock.calls.length, 1);
|
||||
assert.equal(runGitSpy.mock.calls[0].arguments[1].timeoutMs, DEFAULT_GIT_TIMEOUT_MS);
|
||||
assert.equal(DEFAULT_GIT_TIMEOUT_MS, 15000);
|
||||
});
|
||||
|
||||
test('E11: explicit timeoutMs overrides the default', () => {
|
||||
runGitSpy.mock.resetCalls();
|
||||
gitOrThrow(['--version'], { cwd: dir, timeoutMs: 12345 });
|
||||
assert.equal(runGitSpy.mock.calls.length, 1);
|
||||
assert.equal(runGitSpy.mock.calls[0].arguments[1].timeoutMs, 12345);
|
||||
assert.notEqual(12345, DEFAULT_GIT_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
test('E12: shell-string args are rejected', () => {
|
||||
assert.throws(() => gitOrThrow('status', { cwd: dir }), TypeError);
|
||||
});
|
||||
|
||||
test('E13: argv is never shell-interpreted', () => {
|
||||
const marker = path.join(dir, 'PWNED_MARKER');
|
||||
// A ref name containing shell metacharacters. spawnSync never invokes a
|
||||
// shell, so this whole string reaches git as ONE literal argv element
|
||||
// (the candidate ref name) — never tokenized or command-substituted.
|
||||
const hostileRef = ';touch ' + marker + ';`id`;$(id)';
|
||||
|
||||
let threw = false;
|
||||
try {
|
||||
gitOrThrow(['rev-parse', '--verify', hostileRef], { cwd: dir });
|
||||
} catch (_e) {
|
||||
threw = true;
|
||||
}
|
||||
assert.ok(threw, 'expected the hostile string to fail resolution as a literal (bad) ref');
|
||||
assert.equal(fs.existsSync(marker), false, 'a shell-interpreted argv would have created this file');
|
||||
});
|
||||
|
||||
test('E14: string return is toString-compatible', () => {
|
||||
const r = gitOrThrow(['--version'], { cwd: dir });
|
||||
assert.equal(r.toString(), r);
|
||||
});
|
||||
|
||||
test('E15: string return is trim-compatible', () => {
|
||||
const r = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir });
|
||||
assert.equal(typeof r.trim(), 'string');
|
||||
assert.equal(r.trim(), 'mainline');
|
||||
});
|
||||
|
||||
test('E16: cwd is forwarded to the seam', () => {
|
||||
const otherDir = initRepo('git-fixture-test-other-');
|
||||
gitOrThrow(['checkout', '-b', 'other-branch'], { cwd: otherDir });
|
||||
|
||||
const branchInDir = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir }).trim();
|
||||
const branchInOtherDir = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: otherDir }).trim();
|
||||
|
||||
assert.equal(branchInDir, 'mainline');
|
||||
assert.equal(branchInOtherDir, 'other-branch');
|
||||
|
||||
cleanup(otherDir);
|
||||
});
|
||||
|
||||
test('E17: env is forwarded to the seam', () => {
|
||||
gitOrThrow(
|
||||
['commit', '--allow-empty', '-m', 'env-authored commit'],
|
||||
{
|
||||
cwd: dir,
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'Env Author',
|
||||
GIT_AUTHOR_EMAIL: 'env-author@example.com',
|
||||
GIT_COMMITTER_NAME: 'Env Author',
|
||||
GIT_COMMITTER_EMAIL: 'env-author@example.com',
|
||||
},
|
||||
}
|
||||
);
|
||||
const author = gitOrThrow(['log', '-1', '--format=%an'], { cwd: dir }).trim();
|
||||
assert.equal(author, 'Env Author');
|
||||
});
|
||||
});
|
||||
87
tests/helpers/git-fixture.cjs
Normal file
87
tests/helpers/git-fixture.cjs
Normal file
@@ -0,0 +1,87 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* git-fixture — a throw-preserving wrapper over the process seam's `runGit`.
|
||||
*
|
||||
* Why this exists: `execSync`/`execFileSync` throw on any non-zero exit,
|
||||
* and 237 sites in this repo's test suite are written against that throw —
|
||||
* they read `err.status`, `err.stdout`, `err.stderr`. `tests/helpers/
|
||||
* process-seam.cjs` deliberately never throws (see its own header): every
|
||||
* outcome, including a non-zero exit, a timeout, or a spawn failure, comes
|
||||
* back as data on a discriminated-union result. Migrating a throwing
|
||||
* `execSync`/`execFileSync` call site straight onto the seam without this
|
||||
* wrapper would silently turn a loud test failure (an uncaught throw) into
|
||||
* a quiet one (a result object nobody checked) — exactly the kind of
|
||||
* regression a migration must not introduce.
|
||||
*
|
||||
* `gitOrThrow` is that bridge: it calls the seam's `runGit` and re-throws in
|
||||
* the shape the old idiom produced, with the seam's typed fields attached
|
||||
* alongside it. `tests/helpers/process-seam.cjs` itself is NOT modified by
|
||||
* this module — its never-throws contract is intact; this is a layer on
|
||||
* top, not a change underneath.
|
||||
*/
|
||||
|
||||
const { runGit, OUTCOME } = require('./process-seam.cjs');
|
||||
|
||||
/**
|
||||
* Default timeout for `gitOrThrow` calls, in milliseconds.
|
||||
*
|
||||
* 15000ms: these are git plumbing operations (rev-parse, branch, log, ...)
|
||||
* against a small mkdtemp fixture repo — well over any observed local/CI
|
||||
* duration for that class of call, and far under the seam's own 60000ms
|
||||
* default so a hung git surfaces fast instead of riding out the seam's full
|
||||
* budget.
|
||||
*/
|
||||
const DEFAULT_GIT_TIMEOUT_MS = 15000;
|
||||
|
||||
/**
|
||||
* Run `git` via the process seam and throw on anything other than a clean
|
||||
* exit, preserving the legacy `execSync`/`execFileSync` throw-on-failure
|
||||
* idiom that existing test code is written against.
|
||||
*
|
||||
* @param {string[]} args - argv passed to git (never shell-interpreted).
|
||||
* @param {object} [options] - forwarded to `runGit`; see process-seam.cjs.
|
||||
* `options.timeoutMs`, if provided, overrides `DEFAULT_GIT_TIMEOUT_MS`.
|
||||
* @returns {string} `stdout` on a clean (exit 0) run.
|
||||
* @throws {Error} On any non-zero exit, timeout, kill, or spawn failure.
|
||||
* The thrown error carries, as own properties:
|
||||
* - `status` — the exit code (the legacy `execSync`/`execFileSync` name;
|
||||
* this repo's migrated catch blocks read `err.status`, e.g.
|
||||
* tests/worktree-safety.test.cjs:1361, tests/read-guard.test.cjs:160,
|
||||
* tests/security-scan.security.test.cjs:201).
|
||||
* - `exitCode` — the same value as `status` (the seam's own name; both
|
||||
* are aliases on purpose, not a rename).
|
||||
* - `stdout`, `stderr` — strings.
|
||||
* - `signal` — the seam's `signal` field.
|
||||
* - `timedOut` — the seam's `timedOut` field.
|
||||
* - `outcome` — the seam's `OUTCOME` discriminant.
|
||||
*/
|
||||
function gitOrThrow(args, options = {}) {
|
||||
// Destructure (not spread-after) so an explicit `timeoutMs: undefined` in
|
||||
// `options` still resolves to the default: a destructure default applies
|
||||
// on `undefined`, whereas `{ timeoutMs: DEFAULT, ...options }` would let
|
||||
// an own `undefined` key silently overwrite it and fall through to the
|
||||
// seam's much larger default timeout.
|
||||
const { timeoutMs = DEFAULT_GIT_TIMEOUT_MS, ...rest } = options;
|
||||
const r = runGit(args, { ...rest, timeoutMs });
|
||||
|
||||
if (r.outcome === OUTCOME.EXITED && r.exitCode === 0) {
|
||||
return r.stdout;
|
||||
}
|
||||
|
||||
const argvDisplay = ['git', ...args].join(' ');
|
||||
const err = new Error(
|
||||
`gitOrThrow: \`${argvDisplay}\` failed — outcome=${r.outcome} exitCode=${r.exitCode} ` +
|
||||
`stderr=${r.stderr.trim()}`
|
||||
);
|
||||
err.status = r.exitCode;
|
||||
err.exitCode = r.exitCode;
|
||||
err.stdout = r.stdout;
|
||||
err.stderr = r.stderr;
|
||||
err.signal = r.signal;
|
||||
err.timedOut = r.timedOut;
|
||||
err.outcome = r.outcome;
|
||||
throw err;
|
||||
}
|
||||
|
||||
module.exports = { gitOrThrow, DEFAULT_GIT_TIMEOUT_MS };
|
||||
166
tests/no-unbounded-spawn-allowlist.test.cjs
Normal file
166
tests/no-unbounded-spawn-allowlist.test.cjs
Normal file
@@ -0,0 +1,166 @@
|
||||
// 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']);
|
||||
});
|
||||
});
|
||||
457
tests/no-unbounded-spawn.test.cjs
Normal file
457
tests/no-unbounded-spawn.test.cjs
Normal file
@@ -0,0 +1,457 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* no-unbounded-spawn.test.cjs
|
||||
*
|
||||
* RuleTester unit tests for the `local/no-unbounded-spawn` ESLint rule
|
||||
* (eslint-rules/no-unbounded-spawn.cjs). Covers matrix sections A, B, C, D of
|
||||
* .gsd/phase/chore-3143-no-unbounded-spawn-guard/50-test-matrix.md.
|
||||
*
|
||||
* All spawn/exec call shapes below are TEST DATA (fixture code strings
|
||||
* handed to RuleTester) — none of them are real invocations, and they never
|
||||
* execute as real CallExpressions in THIS file's own AST (they live inside
|
||||
* string literals), so this file is not itself flaggable by the rule under
|
||||
* test.
|
||||
*
|
||||
* Discrepancy note (A12): 40-design.md / the test matrix state that a
|
||||
* locally-declared `function execSync(){}` followed by a bare `execSync(x)`
|
||||
* call is clean, because it isn't a `child_process` binding. The rule's
|
||||
* actual implementation does not check origin for a bare Identifier call —
|
||||
* `getFnName()` returns the raw callee name, and that name is looked up
|
||||
* directly against `TARGET_FNS` regardless of whether it was ever resolved
|
||||
* through the alias map. Verified empirically against the shipped rule
|
||||
* (RuleTester run, see PR discussion): a local `execSync` is FLAGGED, not
|
||||
* clean. This file asserts the actual (flagged) behavior rather than the
|
||||
* matrix's stated expectation — see A12 below.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { RuleTester, Linter } = require('eslint');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const rule = require('../eslint-rules/no-unbounded-spawn.cjs');
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'commonjs',
|
||||
},
|
||||
});
|
||||
|
||||
const FILE = 'tests/foo.test.cjs';
|
||||
|
||||
// ─── A. detection ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('no-unbounded-spawn: A — detection', () => {
|
||||
test('A1/A2/A3/A4: bounded literal timeout is clean; missing/empty options and single-arg are unbounded', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [
|
||||
{ code: `spawnSync(c, a, { timeout: 5000 });`, filename: FILE },
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: `spawnSync(c, a, {});`,
|
||||
filename: FILE,
|
||||
errors: [{ messageId: 'unboundedSpawn' }],
|
||||
},
|
||||
{
|
||||
code: `spawnSync(c, a);`,
|
||||
filename: FILE,
|
||||
errors: [{ messageId: 'unboundedSpawn' }],
|
||||
},
|
||||
{
|
||||
code: `spawnSync(c);`,
|
||||
filename: FILE,
|
||||
errors: [{ messageId: 'unboundedSpawn' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('A5: execSync without timeout is unbounded', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `execSync('git status', { cwd });`,
|
||||
filename: FILE,
|
||||
errors: [{ messageId: 'unboundedSpawn' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('A6: execFileSync without timeout is unbounded', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `execFileSync('git', ['status'], { cwd, encoding: 'utf8' });`,
|
||||
filename: FILE,
|
||||
errors: [{ messageId: 'unboundedSpawn' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('A7: member call on a namespace object is matched', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `cp.spawnSync(c, a, {});`,
|
||||
filename: FILE,
|
||||
errors: [{ messageId: 'unboundedSpawn' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('A8: member call on a require() CallExpression is matched (object-blind)', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `require('node:child_process').execFileSync('git',['--version'],{stdio:'ignore'});`,
|
||||
filename: FILE,
|
||||
errors: [{ messageId: 'unboundedSpawn' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('A9: destructured-and-renamed binding is matched', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code:
|
||||
`const { execSync: exec } = require('node:child_process');\n` +
|
||||
`exec('git branch main',{cwd,stdio:'pipe'});`,
|
||||
filename: FILE,
|
||||
errors: [{ messageId: 'unboundedSpawn' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('A10: renamed binding with a timeout is clean', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [
|
||||
{
|
||||
code:
|
||||
`const { execSync: exec } = require('node:child_process');\n` +
|
||||
`exec('x',{timeout:1000});`,
|
||||
filename: FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('A11: plain destructure, bounded, is clean', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [
|
||||
{
|
||||
code:
|
||||
`const { spawnSync } = require('child_process');\n` +
|
||||
`spawnSync('git', ['status'], { timeout: 5000 });`,
|
||||
filename: FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('A12: unrelated local named execSync IS matched (name-only matching) — diverges from matrix', () => {
|
||||
// See file header: the matrix states "clean"; the shipped rule
|
||||
// name-matches bare identifiers regardless of child_process origin, so
|
||||
// this actually reports 1 error. Asserting the real behavior here.
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `function execSync(x) { return x; }\nexecSync('foo');`,
|
||||
filename: FILE,
|
||||
errors: [{ messageId: 'unboundedSpawn' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('seam-routed calls are never flagged (runGit/runNode/runHook/gitOrThrow)', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [
|
||||
{ code: `runGit(args, { timeoutMs: 5000 });`, filename: FILE },
|
||||
{ code: `runNode([script], { timeoutMs: 5000 });`, filename: FILE },
|
||||
{ code: `runHook(HOOK, [], { input: payload, timeoutMs: 5000 });`, filename: FILE },
|
||||
{ code: `gitOrThrow(['status'], { cwd });`, filename: FILE },
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('A13: property-name match is deliberately object-blind (documented false positive)', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `db.execSync(q);`,
|
||||
filename: FILE,
|
||||
errors: [{ messageId: 'unboundedSpawn' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── B. the timeout value (Goodhart defenses) ──────────────────────────────
|
||||
|
||||
describe('no-unbounded-spawn: B — timeout value boundaries', () => {
|
||||
test('B1/B2/B3/B4/B5: zero/negative/null/undefined/NaN timeout are all unbounded', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{ code: `spawnSync(c, a, { timeout: 0 });`, filename: FILE, errors: [{ messageId: 'unboundedSpawn' }] },
|
||||
{ code: `spawnSync(c, a, { timeout: -1 });`, filename: FILE, errors: [{ messageId: 'unboundedSpawn' }] },
|
||||
{ code: `spawnSync(c, a, { timeout: null });`, filename: FILE, errors: [{ messageId: 'unboundedSpawn' }] },
|
||||
{ code: `spawnSync(c, a, { timeout: undefined });`, filename: FILE, errors: [{ messageId: 'unboundedSpawn' }] },
|
||||
{ code: `spawnSync(c, a, { timeout: NaN });`, filename: FILE, errors: [{ messageId: 'unboundedSpawn' }] },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('B6/B7/B8: 1ms, ceiling-1, and exactly the ceiling are all clean', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [
|
||||
{ code: `spawnSync(c, a, { timeout: 1 });`, filename: FILE },
|
||||
{ code: `spawnSync(c, a, { timeout: 599999 });`, filename: FILE },
|
||||
{ code: `spawnSync(c, a, { timeout: 600000 });`, filename: FILE },
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('B9/B10: over the ceiling and an absurd timeout are both unbounded', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `spawnSync(c, a, { timeout: 600001 });`,
|
||||
filename: FILE,
|
||||
errors: [{ messageId: 'timeoutTooLarge' }],
|
||||
},
|
||||
{
|
||||
code: `spawnSync(c, a, { timeout: 999999999 });`,
|
||||
filename: FILE,
|
||||
errors: [{ messageId: 'timeoutTooLarge' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('B11/B12/B13: non-literal timeout values are trusted as bounded', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [
|
||||
{ code: `spawnSync(c, a, { timeout: someVar });`, filename: FILE },
|
||||
{ code: `spawnSync(c, a, { timeout: opts.t });`, filename: FILE },
|
||||
{ code: `spawnSync(c, a, { timeout: 5 * 1000 });`, filename: FILE },
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('B14: computed key is not a resolvable timeout', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `spawnSync(c, a, { ['time'+'out']: 5000 });`,
|
||||
filename: FILE,
|
||||
errors: [{ messageId: 'unboundedSpawn' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('B15: string-literal key is a timeout', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [
|
||||
{ code: `spawnSync(c, a, { 'timeout': 5000 });`, filename: FILE },
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('B16: duplicate timeout keys resolve to the last', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `spawnSync(c, a, { timeout: 5000, timeout: 0 });`,
|
||||
filename: FILE,
|
||||
errors: [{ messageId: 'unboundedSpawn' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── C. options passed by reference (the seam's own shape) ────────────────
|
||||
|
||||
describe('no-unbounded-spawn: C — options by reference', () => {
|
||||
test('C1: options held in a const are resolved (the real process-seam.cjs shape)', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const o = { timeout: t }; spawnSync(c, a, o);`,
|
||||
filename: FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('C2: post-hoc property assignment is not resolved', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const o = {}; o.timeout = 5; spawnSync(c,a,o);`,
|
||||
filename: FILE,
|
||||
errors: [{ messageId: 'unboundedSpawn' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('C3: resolved options without a timeout are unbounded', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const o = { cwd }; spawnSync(c, a, o);`,
|
||||
filename: FILE,
|
||||
errors: [{ messageId: 'unboundedSpawn' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('C4: unresolvable options binding (function parameter) is unbounded', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `function f(o) { spawnSync(c, a, o); }`,
|
||||
filename: FILE,
|
||||
errors: [{ messageId: 'unboundedSpawn' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('C5: reassigned binding is not trusted', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `let o = {timeout:1}; o = {}; spawnSync(c,a,o);`,
|
||||
filename: FILE,
|
||||
errors: [{ messageId: 'unboundedSpawn' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('C6: spread-only options cannot prove a bound', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `spawnSync(c, a, { ...opts });`,
|
||||
filename: FILE,
|
||||
errors: [{ messageId: 'unboundedSpawn' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('C7: spread plus a literal timeout is bounded', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [
|
||||
{ code: `spawnSync(c, a, { ...opts, timeout: 5000 });`, filename: FILE },
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('C8: the process seam does not flag itself', () => {
|
||||
// Uses the real ESLint Linter (not RuleTester) against the actual file
|
||||
// contents, since this is checking the real implementation, not a
|
||||
// synthetic fixture.
|
||||
const linter = new Linter({ configType: 'flat' });
|
||||
const seamPath = path.join(__dirname, 'helpers', 'process-seam.cjs');
|
||||
const code = fs.readFileSync(seamPath, 'utf8');
|
||||
const messages = linter.verify(
|
||||
code,
|
||||
{
|
||||
languageOptions: { ecmaVersion: 2022, sourceType: 'commonjs' },
|
||||
plugins: { local: { rules: { 'no-unbounded-spawn': rule } } },
|
||||
rules: { 'local/no-unbounded-spawn': 'error' },
|
||||
},
|
||||
{ filename: 'tests/helpers/process-seam.cjs' }
|
||||
);
|
||||
assert.deepEqual(messages, []);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── D. allowlist + ratchet (D1-D3 here; D4-D8 in no-unbounded-spawn-allowlist.test.cjs) ─
|
||||
|
||||
describe('no-unbounded-spawn: D — allowlist behavior', () => {
|
||||
const ALLOWLISTED_ABS = path.join(process.cwd(), 'tests/allowlisted-fixture.test.cjs');
|
||||
const ALLOWLISTED_REL = 'tests/allowlisted-fixture.test.cjs';
|
||||
|
||||
test('D1: allowlisted file suppresses its violations', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `spawnSync('git', ['status'], {});`,
|
||||
filename: ALLOWLISTED_ABS,
|
||||
options: [{ allowlist: [ALLOWLISTED_REL] }],
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('D2: allowlisted file with zero violations reports exactly one stale-entry error', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `spawnSync('git', ['status'], { timeout: 5000 });`,
|
||||
filename: ALLOWLISTED_ABS,
|
||||
options: [{ allowlist: [ALLOWLISTED_REL] }],
|
||||
errors: [{ messageId: 'staleAllowlistEntry' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('D3: non-allowlisted file reports normally', () => {
|
||||
ruleTester.run('local/no-unbounded-spawn', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `spawnSync('git', ['status'], {});`,
|
||||
filename: FILE,
|
||||
options: [{ allowlist: [ALLOWLISTED_REL] }],
|
||||
errors: [{ messageId: 'unboundedSpawn' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user