diff --git a/eslint-factory/eslint.config.cjs b/eslint-factory/eslint.config.cjs index 6d300ca7e82..b61b4ff1b6f 100644 --- a/eslint-factory/eslint.config.cjs +++ b/eslint-factory/eslint.config.cjs @@ -33,6 +33,7 @@ module.exports = [ "gh-aw-custom/require-new-url-try-catch": "warn", "gh-aw-custom/prefer-core-logging": "warn", "gh-aw-custom/no-core-error-then-process-exit": "warn", + "gh-aw-custom/no-exec-interpolated-command": "warn", }, }, { diff --git a/eslint-factory/src/index.ts b/eslint-factory/src/index.ts index 9fee1a0ea63..8e574b0e61a 100644 --- a/eslint-factory/src/index.ts +++ b/eslint-factory/src/index.ts @@ -19,6 +19,7 @@ import { requireSpawnSyncErrorCheckRule } from "./rules/require-spawnsync-error- import { requireNewUrlTryCatchRule } from "./rules/require-new-url-try-catch"; import { preferCoreLoggingRule } from "./rules/prefer-core-logging"; import { noCoreErrorThenProcessExitRule } from "./rules/no-core-error-then-process-exit"; +import { noExecInterpolatedCommandRule } from "./rules/no-exec-interpolated-command"; const plugin = { meta: { @@ -47,6 +48,7 @@ const plugin = { "require-new-url-try-catch": requireNewUrlTryCatchRule, "prefer-core-logging": preferCoreLoggingRule, "no-core-error-then-process-exit": noCoreErrorThenProcessExitRule, + "no-exec-interpolated-command": noExecInterpolatedCommandRule, }, }; diff --git a/eslint-factory/src/rules/no-exec-interpolated-command.test.ts b/eslint-factory/src/rules/no-exec-interpolated-command.test.ts new file mode 100644 index 00000000000..8df34aa41fd --- /dev/null +++ b/eslint-factory/src/rules/no-exec-interpolated-command.test.ts @@ -0,0 +1,73 @@ +import { RuleTester } from "eslint"; +import { describe, it } from "vitest"; +import { noExecInterpolatedCommandRule } from "./no-exec-interpolated-command"; + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: "latest", + sourceType: "commonjs", + }, +}); + +describe("no-exec-interpolated-command", () => { + it("accepts static command forms and flags dynamic ones", () => { + ruleTester.run("no-exec-interpolated-command", noExecInterpolatedCommandRule, { + valid: [ + // Static string — no interpolation, safe + { code: `exec.exec("git", ["checkout", branch]);` }, + // Static template literal — no expressions, safe + { 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 + { code: `exec.exec(myCommand, [arg1]);` }, + // Single-word static template literal — no interpolation + { code: "exec.exec(`git`, [branch]);" }, + // Fully static concatenation built from literal leaves + { code: `exec.exec("tool --retries " + 3, [], opts);` }, + // Fully static string concatenation remains allowed + { code: `exec.exec("git" + " checkout", [branch]);` }, + // Not exec.exec — unrelated call + { code: `someOther.exec(\`git checkout \${branch}\`);` }, + // Alias object name is intentionally out of scope + { code: `execAlias.exec(\`git checkout \${branch}\`);` }, + // Bare exec() call — not a member expression + { code: `exec(\`git checkout \${branch}\`);` }, + // Spread first argument is intentionally out of scope + { code: `exec.exec(...args);` }, + ], + invalid: [ + // Template literal with interpolation as command + { + code: "exec.exec(`git checkout ${branch}`, [], opts);", + errors: [{ messageId: "interpolatedCommand" }], + }, + // Template literal with multiple interpolations + { + code: "exec.exec(`git checkout -B ${branchName} ${baseRef}`, [], opts);", + errors: [{ messageId: "interpolatedCommand" }], + }, + // Dynamic string concatenation + { + code: `exec.exec("git checkout " + branchName, [], opts);`, + errors: [{ messageId: "interpolatedCommand", data: { kind: "dynamic string concatenation", method: "exec" } }], + }, + // Multi-segment dynamic string concatenation + { + code: `exec.exec("git checkout " + branchName + " " + ref, [], opts);`, + errors: [{ messageId: "interpolatedCommand", data: { kind: "dynamic string concatenation", method: "exec" } }], + }, + // getExecOutput with interpolated command + { + code: "exec.getExecOutput(`git rev-parse --verify ${ref}`, [], opts);", + errors: [{ messageId: "interpolatedCommand", data: { kind: "interpolated template literal", method: "getExecOutput" } }], + }, + // Template with only a single interpolation (whole command dynamic) + { + code: "exec.exec(`git am --3way ${patchPath}`, [], opts);", + 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 new file mode 100644 index 00000000000..42fd04d4587 --- /dev/null +++ b/eslint-factory/src/rules/no-exec-interpolated-command.ts @@ -0,0 +1,97 @@ +import { AST_NODE_TYPES, ESLintUtils, 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"; + +/** + * Returns true when the node is a purely static expression (no runtime + * interpolation): a literal, a no-expression template literal, or a binary + * `+` of two static expressions. + */ +function isStaticExpression(node: TSESTree.Expression): boolean { + if (node.type === "Literal") return true; + if (node.type === "TemplateLiteral") return node.expressions.length === 0; + if (node.type === "BinaryExpression" && node.operator === "+") { + return isStaticExpression(node.left) && isStaticExpression(node.right); + } + return false; +} + +/** + * Returns true when the node is a dynamic string concatenation (binary `+` + * that is not entirely static). + */ +function isDynamicStringConcatenation(node: TSESTree.Expression): boolean { + return node.type === "BinaryExpression" && node.operator === "+" && !isStaticExpression(node); +} + +/** + * Returns the display kind string for the problematic first argument, or null + * when the argument is not one of the flagged shapes. + */ +function getDynamicCommandKind(node: TSESTree.Expression): string | null { + if (node.type === "TemplateLiteral" && node.expressions.length > 0) return "interpolated template literal"; + if (isDynamicStringConcatenation(node)) return "dynamic string concatenation"; + return null; +} + +/** + * Returns true when the call expression looks like `exec.exec(...)` or + * `exec.getExecOutput(...)` — the `exec` global injected by github-script. + * + * Recognized shapes: + * exec.exec(cmd, args?, opts?) + * exec.getExecOutput(cmd, args?, opts?) + * + * This rule intentionally matches only the `exec` global injected by + * github-script in CommonJS action scripts. + */ +function resolveExecMethod(node: TSESTree.CallExpression): ExecMethodName | null { + const callee = node.callee; + if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.computed) return null; + const obj = callee.object; + const prop = callee.property; + if (obj.type !== AST_NODE_TYPES.Identifier || obj.name !== "exec") return null; + if (prop.type !== AST_NODE_TYPES.Identifier) return null; + return prop.name === "exec" || prop.name === "getExecOutput" ? prop.name : null; +} + +export const noExecInterpolatedCommandRule = createRule({ + name: "no-exec-interpolated-command", + meta: { + type: "problem", + docs: { + description: + "Disallow interpolated template literals or dynamic string concatenation as the first (command) argument of github-script's injected exec.exec() or exec.getExecOutput() calls in CommonJS action scripts. " + + "The @actions/exec runner splits the command string by spaces internally; variables containing spaces silently break argument boundaries. " + + "Pass a static command string and put all arguments in the second array parameter instead: exec.exec('git', [arg1, arg2]).", + }, + schema: [], + messages: { + interpolatedCommand: + "Avoid passing a {{kind}} as the exec command — @actions/exec splits the command string by spaces, so values containing spaces silently break argument boundaries. " + + "Use a static command string and pass all arguments in the args array, preserving the current method: exec.{{method}}('git', ['checkout', branchName]).", + }, + }, + defaultOptions: [], + create(context) { + return { + CallExpression(node) { + const method = resolveExecMethod(node); + if (!method) return; + + const firstArg = node.arguments[0]; + if (!firstArg || firstArg.type === AST_NODE_TYPES.SpreadElement) return; + + const kind = getDynamicCommandKind(firstArg); + if (!kind) return; + + context.report({ + node: firstArg, + messageId: "interpolatedCommand", + data: { kind, method }, + }); + }, + }; + }, +}); diff --git a/pkg/linters/README.md b/pkg/linters/README.md index ccdd53b1080..99c350d0055 100644 --- a/pkg/linters/README.md +++ b/pkg/linters/README.md @@ -156,6 +156,7 @@ import ( "github.com/github/gh-aw/pkg/linters/sprintfint" "github.com/github/gh-aw/pkg/linters/ssljson" "github.com/github/gh-aw/pkg/linters/timesleepnocontext" + "github.com/github/gh-aw/pkg/linters/trimleftright" ) // Use with multichecker, singlechecker, or custom go/analysis driver. @@ -183,6 +184,7 @@ _ = sortslice.Analyzer _ = sprintfint.Analyzer _ = ssljson.Analyzer _ = timesleepnocontext.Analyzer +_ = trimleftright.Analyzer ``` ## Dependencies