Summary
require-sync-exec-timeout (eslint-factory/src/rules/require-sync-exec-timeout.ts) treats any SpreadElement inside the options object as "caller may already have included a timeout" and bails out (hasTimeoutOption returns true the moment it sees prop.type === SpreadElement). This is a reasonable default to avoid false positives when the spread source is genuinely unknown/external, but it also silently clears calls where the spread source is a same-file wrapper parameter that is provably never given a timeout, leaving a real hang risk unflagged.
Grounded false negative
actions/setup/js/build_checkout_manifest.cjs:
42: function resolveDefaultBranch(repository, checkoutPath, options = {}) {
...
46: ((args, execOptions = {}) => {
47: try {
48: return execFileSync("git", args, { encoding: "utf8", ...execOptions });
...
55: ((args, execOptions = {}) => {
56: try {
57: return execFileSync("gh", args, {
58: encoding: "utf8",
59: env: { ...process.env, ...(execOptions.env || {}) },
60: ...execOptions,
61: });
...
71: const output = runGit(["-C", repoPath, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"], {
72: stdio: ["ignore", "pipe", "pipe"],
73: });
...
84: const ghExecOptions = { stdio: ["ignore", "pipe", "pipe"] };
88: if (checkoutToken !== "") ghExecOptions.env = { GH_TOKEN: checkoutToken };
90: defaultBranch = runGH(["api", `repos/${repository}`, "--jq", ".default_branch"], ghExecOptions).trim();
Neither the runGit call (line 71, options { stdio: [...] }) nor the runGH call (line 90, ghExecOptions = { stdio: [...] } or { stdio: [...], env }) ever supplies a timeout. Both flow into execFileSync("git"/"gh", args, { encoding: "utf8", ...execOptions }), so the final options object genuinely has no timeout key at either call site -- unlike every other execSync/execFileSync/spawnSync call in actions/setup/js (git_helpers.cjs, apply_samples.cjs, merge_remote_agent_github_folder.cjs, memory_custom_validation.cjs, artifact_client.cjs, safeoutputs_cli.cjs, start_mcp_gateway.cjs, evaluate_outcomes.cjs), which all hardcode a positive default timeout. A stalled git symbolic-ref (lock contention) or gh api repos/... call (network stall) here can hang indefinitely with no actionable diagnostic -- exactly the failure mode the rule's own description targets.
The rule never reaches this because execFileSync("git"/"gh", args, { encoding: "utf8", ...execOptions }) contains a SpreadElement (...execOptions), so hasTimeoutOption returns true unconditionally without looking at what execOptions actually is at any call site.
Acceptance criteria
- When the options object's only non-timeout content is a single
SpreadElement whose argument is an Identifier resolving to a function parameter with a literal default value (= {} or an ObjectExpression default with no timeout key), and that parameter is never reassigned before the spread, do not treat the spread as sufficient by itself -- instead check the resolved call-site arguments passed for that parameter across the file (or, at minimum, stop treating a bare {}-defaulted parameter spread as a free pass) so build_checkout_manifest.cjs:48/:57 (and their callers at :71/:90) are flagged.
- Keep the existing conservative behavior for spreads whose source cannot be statically characterized (e.g. spreads of an untyped external parameter with no default, or of
...process.env-shaped values) to avoid new false positives.
- Add a
RuleTester invalid case shaped like the wrapper-closure pattern above: a helper (args, execOptions = {}) => execFileSync("git", args, { encoding: "utf8", ...execOptions }) called with an options object containing no timeout.
- Re-verify existing valid cases using spreads from genuinely-unresolvable sources still pass (no new false positives).
- Once the rule catches this, add
timeout: <positive ms> (matching the child_process_timeouts.cjs/GIT_COMMAND_TIMEOUT_MS-style convention used elsewhere) to the two call sites in build_checkout_manifest.cjs as a follow-up fix.
Scope
eslint-factory/src/rules/require-sync-exec-timeout.ts (+ its .test.ts). Rule target file: actions/setup/js/build_checkout_manifest.cjs.
Generated by 🤖 ESLint Refiner · agent · 197.6 AIC · ⌖ 16 AIC · ⊞ 5.8K · ◷
Summary
require-sync-exec-timeout(eslint-factory/src/rules/require-sync-exec-timeout.ts) treats anySpreadElementinside the options object as "caller may already have included a timeout" and bails out (hasTimeoutOptionreturnstruethe moment it seesprop.type === SpreadElement). This is a reasonable default to avoid false positives when the spread source is genuinely unknown/external, but it also silently clears calls where the spread source is a same-file wrapper parameter that is provably never given atimeout, leaving a real hang risk unflagged.Grounded false negative
actions/setup/js/build_checkout_manifest.cjs:Neither the
runGitcall (line 71, options{ stdio: [...] }) nor therunGHcall (line 90,ghExecOptions={ stdio: [...] }or{ stdio: [...], env }) ever supplies atimeout. Both flow intoexecFileSync("git"/"gh", args, { encoding: "utf8", ...execOptions }), so the final options object genuinely has notimeoutkey at either call site -- unlike every otherexecSync/execFileSync/spawnSynccall inactions/setup/js(git_helpers.cjs,apply_samples.cjs,merge_remote_agent_github_folder.cjs,memory_custom_validation.cjs,artifact_client.cjs,safeoutputs_cli.cjs,start_mcp_gateway.cjs,evaluate_outcomes.cjs), which all hardcode a positive default timeout. A stalledgit symbolic-ref(lock contention) orgh api repos/...call (network stall) here can hang indefinitely with no actionable diagnostic -- exactly the failure mode the rule's own description targets.The rule never reaches this because
execFileSync("git"/"gh", args, { encoding: "utf8", ...execOptions })contains aSpreadElement(...execOptions), sohasTimeoutOptionreturnstrueunconditionally without looking at whatexecOptionsactually is at any call site.Acceptance criteria
SpreadElementwhose argument is anIdentifierresolving to a function parameter with a literal default value (= {}or anObjectExpressiondefault with notimeoutkey), and that parameter is never reassigned before the spread, do not treat the spread as sufficient by itself -- instead check the resolved call-site arguments passed for that parameter across the file (or, at minimum, stop treating a bare{}-defaulted parameter spread as a free pass) sobuild_checkout_manifest.cjs:48/:57(and their callers at:71/:90) are flagged....process.env-shaped values) to avoid new false positives.RuleTesterinvalid case shaped like the wrapper-closure pattern above: a helper(args, execOptions = {}) => execFileSync("git", args, { encoding: "utf8", ...execOptions })called with an options object containing notimeout.timeout: <positive ms>(matching thechild_process_timeouts.cjs/GIT_COMMAND_TIMEOUT_MS-style convention used elsewhere) to the two call sites inbuild_checkout_manifest.cjsas a follow-up fix.Scope
eslint-factory/src/rules/require-sync-exec-timeout.ts(+ its.test.ts). Rule target file:actions/setup/js/build_checkout_manifest.cjs.