Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions eslint-factory/src/rules/require-sync-exec-timeout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,13 @@ describe("require-sync-exec-timeout", () => {
});
});

it("valid: options passed via identifier or spread are not statically inspectable", () => {
it("valid: non-literal timeout values, options identifiers, and spreads are not statically inspectable", () => {
cjsRuleTester.run("require-sync-exec-timeout", requireSyncExecTimeoutRule, {
valid: [`const { execSync } = require("child_process"); const opts = { timeout: 5000 }; execSync("git status", opts);`, `const { execSync } = require("child_process"); const base = {}; execSync("git status", { ...base });`],
valid: [
`const { execSync } = require("child_process"); execSync("git status", { timeout: userConfig.timeout });`,
`const { execSync } = require("child_process"); const opts = { timeout: 5000 }; execSync("git status", opts);`,
`const { execSync } = require("child_process"); const base = {}; execSync("git status", { ...base });`,
],
invalid: [],
});
});
Expand Down Expand Up @@ -68,6 +72,18 @@ describe("require-sync-exec-timeout", () => {
code: `const { execSync } = require("child_process"); execSync("git status", { timeout: undefined });`,
errors: [{ messageId: "requireTimeout" }],
},
{
code: `const { execSync } = require("child_process"); execSync("git status", { timeout: 0 });`,
errors: [{ messageId: "requireTimeout" }],
},
{
code: `const { execSync } = require("child_process"); execSync("git status", { timeout: -1 });`,
errors: [{ messageId: "requireTimeout" }],
},
{
code: `const { execSync } = require("child_process"); execSync("git status", { timeout: -0 });`,
errors: [{ messageId: "requireTimeout" }],
},
],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The new zero/negative timeout test cases only cover execSync. execFileSync and spawnSync share the same hasTimeoutOption path but have no parallel coverage for timeout: 0 or timeout: -1.

💡 Suggested addition

Add at least one case per method to the existing "invalid: execFileSync and spawnSync without timeout option" describe block:

{ code: `const { execFileSync } = require("child_process"); execFileSync("git", ["status"], { timeout: 0 });`, errors: [{ messageId: "requireTimeout" }] },
{ code: `const { spawnSync } = require("child_process"); spawnSync("git", ["status"], { timeout: 0 });`, errors: [{ messageId: "requireTimeout" }] },

@copilot please address this.

});
});
Expand Down
12 changes: 8 additions & 4 deletions eslint-factory/src/rules/require-sync-exec-timeout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ function resolveSyncExecMethod(node: TSESTree.CallExpression, sourceCode: TSESLi
return null;
}

/** Returns true when the options-object argument for the call statically carries a non-nullish `timeout` property. */
/** Returns true when the options-object argument for the call statically carries a positive `timeout` property. */
function hasTimeoutOption(node: TSESTree.CallExpression, method: SyncExecMethod): boolean {
const optionsArg = getOptionsArgument(node, method);
if (!optionsArg) return false;
Expand All @@ -127,8 +127,11 @@ function hasTimeoutOption(node: TSESTree.CallExpression, method: SyncExecMethod)
if (!isTimeoutProp) continue;

const value = prop.value;
const isNullish = (value.type === AST_NODE_TYPES.Literal && value.value == null) || (value.type === AST_NODE_TYPES.Identifier && value.name === "undefined");
if (!isNullish) return true;
const isMissingTimeout =
(value.type === AST_NODE_TYPES.Literal && (value.value == null || (typeof value.value === "number" && value.value <= 0))) ||
(value.type === AST_NODE_TYPES.UnaryExpression && value.operator === "-" && value.argument.type === AST_NODE_TYPES.Literal && typeof value.argument.value === "number") ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The UnaryExpression branch catches timeout: -1 and timeout: -0, but timeout: +0 slips through — unary + applied to 0 evaluates to 0 at runtime, which Node treats as "no timeout".

💡 Suggested fix

Evaluate the unary expression numerically instead of only checking operator === "-":

(value.type === AST_NODE_TYPES.UnaryExpression &&
  (value.operator === "-" || value.operator === "+") &&
  value.argument.type === AST_NODE_TYPES.Literal &&
  typeof value.argument.value === "number" &&
  (value.operator === "-" ? -value.argument.value : +value.argument.value) <= 0)

And add a regression test case:

{ code: `const { execSync } = require("child_process"); execSync("git status", { timeout: +0 });`, errors: [{ messageId: "requireTimeout" }] },

@copilot please address this.

(value.type === AST_NODE_TYPES.Identifier && value.name === "undefined");
if (!isMissingTimeout) return true;
Comment on lines +130 to +134
}

return false;
Expand All @@ -147,7 +150,8 @@ export const requireSyncExecTimeoutRule = createRule({
},
schema: [],
messages: {
requireTimeout: "{{method}}({{arg}}) has no `timeout` option. Pass `{ timeout: <ms>, ...otherOptions }` so a hung or runaway child process cannot block the job indefinitely.",
requireTimeout:
"{{method}}({{arg}}) has no positive `timeout` option. `timeout: 0` disables the timeout; pass `{ timeout: <positive milliseconds>, ...otherOptions }` so a hung or runaway child process cannot block the job indefinitely.",
},
},
defaultOptions: [],
Expand Down
Loading