From 6223c4cee6509611d59220cd7d371fa5c7764680 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 22 Aug 2026 06:18:23 +0000 Subject: [PATCH 1/3] Initial plan From 672a006e1be9b799050159c58be01ac2fbec2efb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 22 Aug 2026 06:27:40 +0000 Subject: [PATCH 2/3] Fix timeout detection for defaulted option spreads Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/build_checkout_manifest.cjs | 6 + .../setup/js/build_checkout_manifest.test.cjs | 23 +++ .../rules/require-sync-exec-timeout.test.ts | 6 + .../src/rules/require-sync-exec-timeout.ts | 182 ++++++++++++++++-- 4 files changed, 205 insertions(+), 12 deletions(-) diff --git a/actions/setup/js/build_checkout_manifest.cjs b/actions/setup/js/build_checkout_manifest.cjs index 53913543904..c44a98959f6 100644 --- a/actions/setup/js/build_checkout_manifest.cjs +++ b/actions/setup/js/build_checkout_manifest.cjs @@ -7,8 +7,12 @@ const fs = require("fs"); const path = require("path"); const { execFileSync } = require("child_process"); +const { getSetupTimeoutMs } = require("./child_process_timeouts.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); +const GIT_COMMAND_TIMEOUT_MS = getSetupTimeoutMs("gitBranch"); +const GH_COMMAND_TIMEOUT_MS = getSetupTimeoutMs("outcomeGh"); + function parseManifestEntries(entriesJSON = process.env.GH_AW_CHECKOUT_MANIFEST_ENTRIES || "[]") { let parsed; try { @@ -70,6 +74,7 @@ function resolveDefaultBranch(repository, checkoutPath, options = {}) { try { const output = runGit(["-C", repoPath, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { stdio: ["ignore", "pipe", "pipe"], + timeout: GIT_COMMAND_TIMEOUT_MS, }); defaultBranch = output.trim().replace(/^origin\//, ""); core.debug(`build_checkout_manifest: git resolved default branch for ${repository}: ${defaultBranch}`); @@ -83,6 +88,7 @@ function resolveDefaultBranch(repository, checkoutPath, options = {}) { const checkoutToken = options.checkoutToken || ""; const ghExecOptions = { stdio: ["ignore", "pipe", "pipe"], + timeout: GH_COMMAND_TIMEOUT_MS, }; if (checkoutToken !== "") { ghExecOptions.env = { GH_TOKEN: checkoutToken }; diff --git a/actions/setup/js/build_checkout_manifest.test.cjs b/actions/setup/js/build_checkout_manifest.test.cjs index 83ce6f58c59..d5e3b8b2d98 100644 --- a/actions/setup/js/build_checkout_manifest.test.cjs +++ b/actions/setup/js/build_checkout_manifest.test.cjs @@ -4,6 +4,7 @@ import os from "os"; import path from "path"; import { spawnSync } from "child_process"; +import { getSetupTimeoutMs } from "./child_process_timeouts.cjs"; import { buildCheckoutManifest, readManifestEntriesFromEnv, resolveDefaultBranch } from "./build_checkout_manifest.cjs"; function execGit(args, options = {}) { @@ -86,6 +87,27 @@ describe("build_checkout_manifest.cjs", () => { expect(ghCalls).toHaveLength(0); }); + it("passes a timeout to local git default branch lookup", () => { + const workspace = createTempDir("checkout-manifest-workspace-"); + tempDirs.push(workspace); + const checkoutPath = "target"; + const repoDir = path.join(workspace, checkoutPath); + fs.mkdirSync(path.join(repoDir, ".git"), { recursive: true }); + /** @type {any} */ + let gitOptions = null; + + const defaultBranch = resolveDefaultBranch("owner/repo", checkoutPath, { + workspace, + runGit: (_args, options) => { + gitOptions = options; + return "origin/main\n"; + }, + }); + + expect(defaultBranch).toBe("main"); + expect(gitOptions?.timeout).toBe(getSetupTimeoutMs("gitBranch")); + }); + it("falls back to gh api when local git default branch is unavailable", () => { const workspace = createTempDir("checkout-manifest-workspace-"); tempDirs.push(workspace); @@ -103,6 +125,7 @@ describe("build_checkout_manifest.cjs", () => { expect(defaultBranch).toBe("trunk"); expect(ghOptions?.env?.GH_TOKEN).toBe("${{ secrets.CROSS_REPO_PAT }}"); + expect(ghOptions?.timeout).toBe(getSetupTimeoutMs("outcomeGh")); }); it("writes manifest with lowercase keys", () => { diff --git a/eslint-factory/src/rules/require-sync-exec-timeout.test.ts b/eslint-factory/src/rules/require-sync-exec-timeout.test.ts index 2b885bf2aa4..eb80530dad3 100644 --- a/eslint-factory/src/rules/require-sync-exec-timeout.test.ts +++ b/eslint-factory/src/rules/require-sync-exec-timeout.test.ts @@ -44,6 +44,8 @@ describe("require-sync-exec-timeout", () => { `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 });`, + `const { execFileSync } = require("child_process"); const runGit = (args, execOptions) => execFileSync("git", args, { encoding: "utf8", ...execOptions }); runGit(["status"], { stdio: "pipe" });`, + `const { execFileSync } = require("child_process"); const runGit = (args, execOptions = {}) => execFileSync("git", args, { encoding: "utf8", ...execOptions }); runGit(["status"], { stdio: "pipe", timeout: 5000 });`, ], invalid: [], }); @@ -112,6 +114,10 @@ describe("require-sync-exec-timeout", () => { code: `const { execFileSync } = require("child_process"); execFileSync("git", { encoding: "utf8" });`, errors: [{ messageId: "requireTimeout" }], }, + { + code: `const { execFileSync } = require("child_process"); const runGit = (args, execOptions = {}) => execFileSync("git", args, { encoding: "utf8", ...execOptions }); runGit(["status"], { stdio: "pipe" });`, + errors: [{ messageId: "requireTimeout" }], + }, ], }); }); diff --git a/eslint-factory/src/rules/require-sync-exec-timeout.ts b/eslint-factory/src/rules/require-sync-exec-timeout.ts index 1a7d202ae8e..9ac1b923a9f 100644 --- a/eslint-factory/src/rules/require-sync-exec-timeout.ts +++ b/eslint-factory/src/rules/require-sync-exec-timeout.ts @@ -15,6 +15,8 @@ const SYNC_EXEC_METHODS: ReadonlySet = new Set(["execSync", "exe // Index of the options-object argument for each method when all parameters are supplied: // execSync(cmd, opts), execFileSync(cmd, args?, opts), spawnSync(cmd, args?, opts). const OPTIONS_ARG_INDEX: Record = { execSync: 1, execFileSync: 2, spawnSync: 2 }; +type FunctionWithParams = TSESTree.ArrowFunctionExpression | TSESTree.FunctionDeclaration | TSESTree.FunctionExpression; +type NodeWithParent = TSESTree.Node & { parent?: TSESTree.Node }; function getOptionsArgument(node: TSESTree.CallExpression, method: SyncExecMethod): TSESTree.CallExpressionArgument | undefined { if (method === "execSync") return node.arguments[OPTIONS_ARG_INDEX.execSync]; @@ -81,6 +83,166 @@ function resolveSyncExecBinding(identifierName: string, scopeNode: TSESTree.Node return null; } +function resolveIdentifierVariable(identifierName: string, scopeNode: TSESTree.Node, sourceCode: TSESLint.SourceCode): TSESLint.Scope.Variable | null { + let scope: SourceCodeScope | null = sourceCode.getScope(scopeNode); + while (scope) { + const variable = scope.set.get(identifierName); + if (variable) return variable; + scope = scope.upper; + } + return null; +} + +function getParent(node: TSESTree.Node): TSESTree.Node | undefined { + return (node as NodeWithParent).parent; +} + +function isFunctionWithParams(node: TSESTree.Node | undefined): node is FunctionWithParams { + return node?.type === AST_NODE_TYPES.ArrowFunctionExpression || node?.type === AST_NODE_TYPES.FunctionDeclaration || node?.type === AST_NODE_TYPES.FunctionExpression; +} + +function getContainingFunction(node: TSESTree.Node): FunctionWithParams | null { + let current: TSESTree.Node | undefined = getParent(node); + while (current) { + if (isFunctionWithParams(current)) return current; + current = getParent(current); + } + return null; +} + +function isPositiveTimeoutProperty(prop: TSESTree.Property): boolean { + const isTimeoutProp = (!prop.computed && prop.key.type === AST_NODE_TYPES.Identifier && prop.key.name === "timeout") || (!prop.computed && prop.key.type === AST_NODE_TYPES.Literal && prop.key.value === "timeout"); + if (!isTimeoutProp) return false; + + const value = prop.value; + 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") || + (value.type === AST_NODE_TYPES.Identifier && value.name === "undefined"); + return !isMissingTimeout; +} + +function hasPositiveTimeoutProperty(objectExpression: TSESTree.ObjectExpression): boolean { + return objectExpression.properties.some(prop => prop.type === AST_NODE_TYPES.Property && isPositiveTimeoutProperty(prop)); +} + +function objectArgumentSuppliesTimeout(objectExpression: TSESTree.ObjectExpression): boolean { + if (hasPositiveTimeoutProperty(objectExpression)) return true; + + // Call-site spread sources can be shared or externally-computed options. Keep + // the rule conservative unless the local object is plainly missing timeout. + return objectExpression.properties.some(prop => prop.type === AST_NODE_TYPES.SpreadElement); +} + +function isReassignedBefore(variable: TSESLint.Scope.Variable, node: TSESTree.Node): boolean { + const nodeStart = node.range?.[0]; + if (nodeStart == null) return variable.references.some(ref => ref.isWrite() && !ref.init); + + return variable.references.some(ref => { + if (!ref.isWrite() || ref.init) return false; + const refStart = ref.identifier.range?.[0]; + return refStart == null || refStart < nodeStart; + }); +} + +function resolveStaticObjectInitializer(identifier: TSESTree.Identifier, sourceCode: TSESLint.SourceCode): { init: TSESTree.ObjectExpression; variable: TSESLint.Scope.Variable } | null { + const variable = resolveIdentifierVariable(identifier.name, identifier, sourceCode); + if (!variable || variable.defs.length !== 1) return null; + + const def = variable.defs[0]; + if (def.type !== "Variable") return null; + + const declarator = def.node as TSESTree.VariableDeclarator; + if (declarator.init?.type !== AST_NODE_TYPES.ObjectExpression) return null; + if (isReassignedBefore(variable, identifier)) return null; + + return { init: declarator.init, variable }; +} + +function callSiteArgumentSuppliesTimeout(argument: TSESTree.CallExpressionArgument | undefined, sourceCode: TSESLint.SourceCode): boolean { + if (!argument) return false; + if (argument.type === AST_NODE_TYPES.ObjectExpression) return objectArgumentSuppliesTimeout(argument); + + if (argument.type === AST_NODE_TYPES.Identifier) { + const staticObject = resolveStaticObjectInitializer(argument, sourceCode); + if (!staticObject) return true; + return objectArgumentSuppliesTimeout(staticObject.init); + } + + // Non-object expressions are not statically inspectable; preserve the prior + // conservative behavior. + return true; +} + +function getParamDefaultObject(functionNode: FunctionWithParams, paramName: string): { objectExpression: TSESTree.ObjectExpression; paramIndex: number } | null { + for (let index = 0; index < functionNode.params.length; index += 1) { + const param = functionNode.params[index]; + if (param.type !== AST_NODE_TYPES.AssignmentPattern) continue; + if (param.left.type !== AST_NODE_TYPES.Identifier || param.left.name !== paramName) continue; + if (param.right.type !== AST_NODE_TYPES.ObjectExpression) continue; + return { objectExpression: param.right, paramIndex: index }; + } + + return null; +} + +function getFunctionBindingVariable(functionNode: FunctionWithParams, sourceCode: TSESLint.SourceCode): TSESLint.Scope.Variable | null { + if (functionNode.type === AST_NODE_TYPES.FunctionDeclaration && functionNode.id) { + return resolveIdentifierVariable(functionNode.id.name, functionNode.id, sourceCode); + } + + let current: TSESTree.Node = functionNode; + let parent = getParent(current); + while (parent) { + if (parent.type === AST_NODE_TYPES.VariableDeclarator && parent.id.type === AST_NODE_TYPES.Identifier) { + return resolveIdentifierVariable(parent.id.name, parent.id, sourceCode); + } + if (parent.type === AST_NODE_TYPES.AssignmentExpression && parent.left.type === AST_NODE_TYPES.Identifier) { + return resolveIdentifierVariable(parent.left.name, parent.left, sourceCode); + } + current = parent; + parent = getParent(current); + } + + return null; +} + +function getDirectCalls(binding: TSESLint.Scope.Variable): TSESTree.CallExpression[] { + const calls: TSESTree.CallExpression[] = []; + for (const reference of binding.references) { + if (reference.isWrite()) continue; + const identifier = reference.identifier; + const parent = getParent(identifier); + if (parent?.type === AST_NODE_TYPES.CallExpression && parent.callee === identifier) { + calls.push(parent); + } + } + return calls; +} + +function characterizedParameterSpreadSuppliesTimeout(spread: TSESTree.SpreadElement, sourceCode: TSESLint.SourceCode): boolean | null { + if (spread.argument.type !== AST_NODE_TYPES.Identifier) return null; + + const parameterVariable = resolveIdentifierVariable(spread.argument.name, spread.argument, sourceCode); + if (!parameterVariable || !parameterVariable.defs.some(def => def.type === "Parameter")) return null; + + const containingFunction = getContainingFunction(spread); + if (!containingFunction) return null; + + const defaultObject = getParamDefaultObject(containingFunction, spread.argument.name); + if (!defaultObject) return null; + if (hasPositiveTimeoutProperty(defaultObject.objectExpression)) return true; + if (isReassignedBefore(parameterVariable, spread.argument)) return null; + + const binding = getFunctionBindingVariable(containingFunction, sourceCode); + if (!binding) return false; + + const directCalls = getDirectCalls(binding); + if (directCalls.length === 0) return false; + + return directCalls.every(call => callSiteArgumentSuppliesTimeout(call.arguments[defaultObject.paramIndex], sourceCode)); +} + /** * Returns the resolved sync-exec method name for a CallExpression, or null if it * doesn't resolve to one of execSync/execFileSync/spawnSync from `child_process`. @@ -110,7 +272,7 @@ function resolveSyncExecMethod(node: TSESTree.CallExpression, sourceCode: TSESLi } /** Returns true when the options-object argument for the call statically carries a positive `timeout` property. */ -function hasTimeoutOption(node: TSESTree.CallExpression, method: SyncExecMethod): boolean { +function hasTimeoutOption(node: TSESTree.CallExpression, method: SyncExecMethod, sourceCode: TSESLint.SourceCode): boolean { const optionsArg = getOptionsArgument(node, method); if (!optionsArg) return false; @@ -120,18 +282,14 @@ function hasTimeoutOption(node: TSESTree.CallExpression, method: SyncExecMethod) if (optionsArg.type !== AST_NODE_TYPES.ObjectExpression) return true; for (const prop of optionsArg.properties) { - if (prop.type === AST_NODE_TYPES.SpreadElement) return true; + if (prop.type === AST_NODE_TYPES.SpreadElement) { + const spreadSuppliesTimeout = characterizedParameterSpreadSuppliesTimeout(prop, sourceCode); + if (spreadSuppliesTimeout !== false) return true; + continue; + } if (prop.type !== AST_NODE_TYPES.Property) continue; - const isTimeoutProp = (!prop.computed && prop.key.type === AST_NODE_TYPES.Identifier && prop.key.name === "timeout") || (!prop.computed && prop.key.type === AST_NODE_TYPES.Literal && prop.key.value === "timeout"); - if (!isTimeoutProp) continue; - - const value = prop.value; - 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") || - (value.type === AST_NODE_TYPES.Identifier && value.name === "undefined"); - if (!isMissingTimeout) return true; + if (isPositiveTimeoutProperty(prop)) return true; } return false; @@ -162,7 +320,7 @@ export const requireSyncExecTimeoutRule = createRule({ CallExpression(node) { const method = resolveSyncExecMethod(node, sourceCode); if (!method) return; - if (hasTimeoutOption(node, method)) return; + if (hasTimeoutOption(node, method, sourceCode)) return; const argText = node.arguments.length > 0 ? sourceCode.getText(node.arguments[0]) : ""; From 91f5cbb4fd3cb013310bdfeebd4ad5ab6e9f4a66 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 22 Aug 2026 06:29:43 +0000 Subject: [PATCH 3/3] Keep unresolved timeout wrapper spreads conservative Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- eslint-factory/src/rules/require-sync-exec-timeout.test.ts | 1 + eslint-factory/src/rules/require-sync-exec-timeout.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/eslint-factory/src/rules/require-sync-exec-timeout.test.ts b/eslint-factory/src/rules/require-sync-exec-timeout.test.ts index eb80530dad3..89db7ec5a71 100644 --- a/eslint-factory/src/rules/require-sync-exec-timeout.test.ts +++ b/eslint-factory/src/rules/require-sync-exec-timeout.test.ts @@ -46,6 +46,7 @@ describe("require-sync-exec-timeout", () => { `const { execSync } = require("child_process"); const base = {}; execSync("git status", { ...base });`, `const { execFileSync } = require("child_process"); const runGit = (args, execOptions) => execFileSync("git", args, { encoding: "utf8", ...execOptions }); runGit(["status"], { stdio: "pipe" });`, `const { execFileSync } = require("child_process"); const runGit = (args, execOptions = {}) => execFileSync("git", args, { encoding: "utf8", ...execOptions }); runGit(["status"], { stdio: "pipe", timeout: 5000 });`, + `const { execFileSync } = require("child_process"); module.exports.runGit = (args, execOptions = {}) => execFileSync("git", args, { encoding: "utf8", ...execOptions });`, ], invalid: [], }); diff --git a/eslint-factory/src/rules/require-sync-exec-timeout.ts b/eslint-factory/src/rules/require-sync-exec-timeout.ts index 9ac1b923a9f..1edabf75a36 100644 --- a/eslint-factory/src/rules/require-sync-exec-timeout.ts +++ b/eslint-factory/src/rules/require-sync-exec-timeout.ts @@ -235,10 +235,10 @@ function characterizedParameterSpreadSuppliesTimeout(spread: TSESTree.SpreadElem if (isReassignedBefore(parameterVariable, spread.argument)) return null; const binding = getFunctionBindingVariable(containingFunction, sourceCode); - if (!binding) return false; + if (!binding) return null; const directCalls = getDirectCalls(binding); - if (directCalls.length === 0) return false; + if (directCalls.length === 0) return null; return directCalls.every(call => callSiteArgumentSuppliesTimeout(call.arguments[defaultObject.paramIndex], sourceCode)); }