From 9764c2e3f83431b4ee661f1a19ac47c6b56f9f86 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:30:27 +0000 Subject: [PATCH 1/3] Initial plan From 6e8086750c4ffc963438c974291cad2e8db445a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:43:31 +0000 Subject: [PATCH 2/3] fix(eslint-factory): resolve single-level variable indirection in no-exec-interpolated-command Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../no-exec-interpolated-command.test.ts | 20 ++++++++++- .../src/rules/no-exec-interpolated-command.ts | 36 +++++++++++++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/eslint-factory/src/rules/no-exec-interpolated-command.test.ts b/eslint-factory/src/rules/no-exec-interpolated-command.test.ts index 8df34aa41fd..9244e54c41b 100644 --- a/eslint-factory/src/rules/no-exec-interpolated-command.test.ts +++ b/eslint-factory/src/rules/no-exec-interpolated-command.test.ts @@ -19,7 +19,7 @@ describe("no-exec-interpolated-command", () => { { code: "exec.exec(`git`, [`checkout`, branch]);" }, // getExecOutput with static command { code: `exec.getExecOutput("git", ["rev-parse", "--abbrev-ref", "HEAD"], opts);` }, - // Command variable (identifier) — not a string literal, out of scope + // Command variable (identifier) — no definition in scope, cannot resolve, not flagged { code: `exec.exec(myCommand, [arg1]);` }, // Single-word static template literal — no interpolation { code: "exec.exec(`git`, [branch]);" }, @@ -35,6 +35,14 @@ describe("no-exec-interpolated-command", () => { { code: `exec(\`git checkout \${branch}\`);` }, // Spread first argument is intentionally out of scope { code: `exec.exec(...args);` }, + // Variable holds a static string — safe, must not be flagged + { code: `const cmd = "git"; exec.exec(cmd, [branch]);` }, + // Variable holds a static template literal — safe + { code: "const cmd = `git`; exec.exec(cmd, [branch]);" }, + // Reassigned variable — skipped to avoid false positives + { code: `let cmd = "git"; cmd = "other"; exec.exec(cmd, [branch]);` }, + // Parameter as command — skipped (def.type === "Parameter") + { code: `(function(cmd) { exec.exec(cmd, []); })("git");` }, ], invalid: [ // Template literal with interpolation as command @@ -67,6 +75,16 @@ describe("no-exec-interpolated-command", () => { code: "exec.exec(`git am --3way ${patchPath}`, [], opts);", errors: [{ messageId: "interpolatedCommand", data: { kind: "interpolated template literal", method: "exec" } }], }, + // Variable holds an interpolated template literal — must be flagged (indirection) + { + code: "const cmd = `git checkout ${branch}`; exec.exec(cmd, []);", + errors: [{ messageId: "interpolatedCommand", data: { kind: "interpolated template literal", method: "exec" } }], + }, + // Variable holds dynamic string concatenation — must be flagged (indirection) + { + code: `const cmd = "git checkout " + branchName; exec.exec(cmd, []);`, + errors: [{ messageId: "interpolatedCommand", data: { kind: "dynamic string concatenation", method: "exec" } }], + }, ], }); }); diff --git a/eslint-factory/src/rules/no-exec-interpolated-command.ts b/eslint-factory/src/rules/no-exec-interpolated-command.ts index 42fd04d4587..a20f61ebad4 100644 --- a/eslint-factory/src/rules/no-exec-interpolated-command.ts +++ b/eslint-factory/src/rules/no-exec-interpolated-command.ts @@ -1,4 +1,4 @@ -import { AST_NODE_TYPES, ESLintUtils, TSESTree } from "@typescript-eslint/utils"; +import { AST_NODE_TYPES, ESLintUtils, TSESLint, TSESTree } from "@typescript-eslint/utils"; const createRule = ESLintUtils.RuleCreator(name => `https://github.com/github/gh-aw/tree/main/eslint-factory#${name}`); type ExecMethodName = "exec" | "getExecOutput"; @@ -75,6 +75,33 @@ export const noExecInterpolatedCommandRule = createRule({ }, defaultOptions: [], create(context) { + const sourceCode = context.sourceCode; + + /** + * When `identifier` is a write-once local variable binding, returns its + * initializer expression so the caller can apply further checks. Returns + * null for parameters, imports, multiply-assigned vars, and vars with no + * initializer. + */ + function resolveInitializer(identifier: TSESTree.Identifier): TSESTree.Expression | null { + let scope: TSESLint.Scope.Scope | null = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) { + // Only accept simple, single-definition Variable bindings. + if (variable.defs.length !== 1) return null; + const def = variable.defs[0]; + if (def.type !== "Variable") return null; + // Reject re-assigned bindings (write references that are not the initializer). + if (variable.references.some(ref => ref.isWrite() && !ref.init)) return null; + const declarator = def.node as TSESTree.VariableDeclarator; + return declarator.init ?? null; + } + scope = scope.upper; + } + return null; + } + return { CallExpression(node) { const method = resolveExecMethod(node); @@ -83,7 +110,12 @@ export const noExecInterpolatedCommandRule = createRule({ const firstArg = node.arguments[0]; if (!firstArg || firstArg.type === AST_NODE_TYPES.SpreadElement) return; - const kind = getDynamicCommandKind(firstArg); + // Resolve a single level of variable indirection so that + // const cmd = `git checkout ${branch}`; exec.exec(cmd, []); + // is flagged in the same way as the direct form. + const candidate = firstArg.type === AST_NODE_TYPES.Identifier ? (resolveInitializer(firstArg) ?? firstArg) : (firstArg as TSESTree.Expression); + + const kind = getDynamicCommandKind(candidate); if (!kind) return; context.report({ From 897ee3641eeb8fbab663358e3b1fa00090d382ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:33:02 +0000 Subject: [PATCH 3/3] fix(eslint-factory): handle in-function alias chains and scope boundaries Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- .../no-exec-interpolated-command.test.ts | 19 ++++++++++++++ .../src/rules/no-exec-interpolated-command.ts | 26 ++++++++++++++----- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/eslint-factory/src/rules/no-exec-interpolated-command.test.ts b/eslint-factory/src/rules/no-exec-interpolated-command.test.ts index 9244e54c41b..944eede606e 100644 --- a/eslint-factory/src/rules/no-exec-interpolated-command.test.ts +++ b/eslint-factory/src/rules/no-exec-interpolated-command.test.ts @@ -41,8 +41,12 @@ describe("no-exec-interpolated-command", () => { { code: "const cmd = `git`; exec.exec(cmd, [branch]);" }, // Reassigned variable — skipped to avoid false positives { code: `let cmd = "git"; cmd = "other"; exec.exec(cmd, [branch]);` }, + // Dynamic initializer with reassignment — must still be skipped + { code: "let cmd = `git checkout ${branch}`; cmd = 'git'; exec.exec(cmd, []);" }, // Parameter as command — skipped (def.type === "Parameter") { code: `(function(cmd) { exec.exec(cmd, []); })("git");` }, + // Cross-function binding is intentionally out of scope + { code: "function outer(branch) { const cmd = `git checkout ${branch}`; function inner() { exec.exec(cmd, []); } }" }, ], invalid: [ // Template literal with interpolation as command @@ -80,11 +84,26 @@ describe("no-exec-interpolated-command", () => { code: "const cmd = `git checkout ${branch}`; exec.exec(cmd, []);", errors: [{ messageId: "interpolatedCommand", data: { kind: "interpolated template literal", method: "exec" } }], }, + // Same-function indirection should still be flagged with function-boundary resolution + { + code: "function run(branch) { const cmd = `git checkout ${branch}`; exec.exec(cmd, []); }", + errors: [{ messageId: "interpolatedCommand", data: { kind: "interpolated template literal", method: "exec" } }], + }, + // Nested block in same function should still resolve and be flagged + { + code: "function run(branch) { const cmd = `git checkout ${branch}`; if (ok) { exec.exec(cmd, []); } }", + errors: [{ messageId: "interpolatedCommand", data: { kind: "interpolated template literal", method: "exec" } }], + }, // Variable holds dynamic string concatenation — must be flagged (indirection) { code: `const cmd = "git checkout " + branchName; exec.exec(cmd, []);`, errors: [{ messageId: "interpolatedCommand", data: { kind: "dynamic string concatenation", method: "exec" } }], }, + // Chained aliases are also flagged when they resolve to a dynamic command + { + code: "function run(branch) { const dynamic = `git checkout ${branch}`; const cmd = dynamic; exec.exec(cmd, []); }", + errors: [{ messageId: "interpolatedCommand", data: { kind: "interpolated template literal", method: "exec" } }], + }, ], }); }); diff --git a/eslint-factory/src/rules/no-exec-interpolated-command.ts b/eslint-factory/src/rules/no-exec-interpolated-command.ts index a20f61ebad4..4be73b9b2eb 100644 --- a/eslint-factory/src/rules/no-exec-interpolated-command.ts +++ b/eslint-factory/src/rules/no-exec-interpolated-command.ts @@ -84,8 +84,18 @@ export const noExecInterpolatedCommandRule = createRule({ * initializer. */ function resolveInitializer(identifier: TSESTree.Identifier): TSESTree.Expression | null { - let scope: TSESLint.Scope.Scope | null = sourceCode.getScope(identifier); - while (scope !== null) { + const startScope = sourceCode.getScope(identifier); + const functionScope = startScope.variableScope; + // Only resolve within a concrete function boundary (function declaration, + // function expression, or arrow function). Module/global scopes are + // intentionally skipped because those bindings are not a stable proxy for + // runtime values at call time. + if (functionScope.type !== "function") return null; + + let scope: TSESLint.Scope.Scope | null = startScope; + // Stay inside the same function's nested block scopes; do not cross to + // enclosing function/module scopes. + while (scope !== null && scope.variableScope === functionScope) { const variable = scope.set.get(identifier.name); if (variable !== undefined) { // Only accept simple, single-definition Variable bindings. @@ -110,10 +120,14 @@ export const noExecInterpolatedCommandRule = createRule({ const firstArg = node.arguments[0]; if (!firstArg || firstArg.type === AST_NODE_TYPES.SpreadElement) return; - // Resolve a single level of variable indirection so that - // const cmd = `git checkout ${branch}`; exec.exec(cmd, []); - // is flagged in the same way as the direct form. - const candidate = firstArg.type === AST_NODE_TYPES.Identifier ? (resolveInitializer(firstArg) ?? firstArg) : (firstArg as TSESTree.Expression); + let candidate: TSESTree.Expression = firstArg as TSESTree.Expression; + const seen = new Set(); + while (candidate.type === AST_NODE_TYPES.Identifier && !seen.has(candidate)) { + seen.add(candidate); + const resolved = resolveInitializer(candidate); + if (!resolved) break; + candidate = resolved; + } const kind = getDynamicCommandKind(candidate); if (!kind) return;