diff --git a/eslint-factory/src/rules/command-initializer-utils.ts b/eslint-factory/src/rules/command-initializer-utils.ts index 77c64da9015..90666fb4a7c 100644 --- a/eslint-factory/src/rules/command-initializer-utils.ts +++ b/eslint-factory/src/rules/command-initializer-utils.ts @@ -1,10 +1,95 @@ import { AST_NODE_TYPES, TSESLint, TSESTree } from "@typescript-eslint/utils"; +/** + * Resolves the element of an array literal bound to `name` by an array + * destructuring pattern (for example `const [cmd] = [command]`). Returns null + * when the binding cannot be resolved precisely — a non-literal right-hand + * side, a rest element before the binding, a spread element in the array + * literal, a hole, or a default value. + */ +function resolveArrayPatternElement(pattern: TSESTree.ArrayPattern, init: TSESTree.Expression, name: string): TSESTree.Expression | null { + if (init.type !== AST_NODE_TYPES.ArrayExpression) return null; + for (let index = 0; index < pattern.elements.length; index++) { + const element = pattern.elements[index]; + // A rest element consumes the remaining values, so positions after it no + // longer line up with the array literal. + if (element !== null && element.type === AST_NODE_TYPES.RestElement) return null; + if (element === null || element.type !== AST_NODE_TYPES.Identifier || element.name !== name) continue; + // A spread element at or before this position shifts the value positions. + if (init.elements.slice(0, index + 1).some(value => value !== null && value.type === AST_NODE_TYPES.SpreadElement)) return null; + const value = init.elements[index]; + if (value === null || value.type === AST_NODE_TYPES.SpreadElement) return null; + return value; + } + return null; +} + +/** + * Returns the static property name of a non-computed property key, or null + * when the key is not statically known. + */ +function getStaticPropertyName(key: TSESTree.Node): string | null { + if (key.type === AST_NODE_TYPES.Identifier) return key.name; + if (key.type === AST_NODE_TYPES.Literal && (typeof key.value === "string" || typeof key.value === "number")) return String(key.value); + return null; +} + +/** + * Narrows a property value to a plain expression, rejecting binding patterns + * and other non-expression property values. + */ +function asExpression(value: TSESTree.Property["value"]): TSESTree.Expression | null { + switch (value.type) { + case AST_NODE_TYPES.ArrayPattern: + case AST_NODE_TYPES.AssignmentPattern: + case AST_NODE_TYPES.ObjectPattern: + case AST_NODE_TYPES.TSEmptyBodyFunctionExpression: + return null; + default: + return value; + } +} + +/** + * Resolves the property value of an object literal bound to `name` by an + * object destructuring pattern (for example `const { cmd } = { cmd: command }`). + * Returns null when the binding cannot be resolved precisely — a non-literal + * right-hand side, a spread element, a computed or accessor property, or a + * default value. + */ +function resolveObjectPatternProperty(pattern: TSESTree.ObjectPattern, init: TSESTree.Expression, name: string): TSESTree.Expression | null { + if (init.type !== AST_NODE_TYPES.ObjectExpression) return null; + // A spread can override any property, so the literal is no longer authoritative. + if (init.properties.some(property => property.type === AST_NODE_TYPES.SpreadElement)) return null; + + let key: string | null = null; + for (const property of pattern.properties) { + if (property.type !== AST_NODE_TYPES.Property || property.computed) continue; + if (property.value.type !== AST_NODE_TYPES.Identifier || property.value.name !== name) continue; + key = getStaticPropertyName(property.key); + break; + } + if (key === null) return null; + + let resolved: TSESTree.Expression | null = null; + for (const property of init.properties) { + if (property.type !== AST_NODE_TYPES.Property || property.computed || property.kind !== "init") continue; + if (getStaticPropertyName(property.key) !== key) continue; + // Later properties win over earlier duplicates. + resolved = asExpression(property.value); + } + return resolved; +} + /** * 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. + * + * Destructured bindings (for example `const [cmd] = [command]`) resolve to the + * specific destructured value when it can be determined precisely, and to null + * otherwise — never to the whole right-hand side expression. */ function resolveInitializer(identifier: TSESTree.Identifier, sourceCode: TSESLint.SourceCode): TSESTree.Expression | null { const startScope = sourceCode.getScope(identifier); @@ -28,7 +113,11 @@ function resolveInitializer(identifier: TSESTree.Identifier, sourceCode: TSESLin // 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; + if (declarator.init === null || declarator.init === undefined) return null; + if (declarator.id.type === AST_NODE_TYPES.Identifier) return declarator.init; + if (declarator.id.type === AST_NODE_TYPES.ArrayPattern) return resolveArrayPatternElement(declarator.id, declarator.init, identifier.name); + if (declarator.id.type === AST_NODE_TYPES.ObjectPattern) return resolveObjectPatternProperty(declarator.id, declarator.init, identifier.name); + return null; } scope = scope.upper; } diff --git a/eslint-factory/src/rules/no-child-process-interpolated-command.test.ts b/eslint-factory/src/rules/no-child-process-interpolated-command.test.ts index d880a396fb6..33c669910ba 100644 --- a/eslint-factory/src/rules/no-child-process-interpolated-command.test.ts +++ b/eslint-factory/src/rules/no-child-process-interpolated-command.test.ts @@ -41,6 +41,10 @@ describe("no-child-process-interpolated-command", () => { { code: `const { execSync } = require("child_process"); execSync("git-status".replace("-", () => " "));` }, // Un-reassigned options object without shell — safe, must not over-flag { code: `const { spawnSync } = require("child_process"); const cmd = \`git checkout \${branch}\`; const opts = {}; spawnSync(cmd, [], opts);` }, + // Statically destructured command — safe, must not be flagged + { code: `const { execSync } = require("child_process"); const [cmd] = ["git status"]; execSync(cmd);` }, + // Destructured from an unresolvable right-hand side — must not be flagged + { code: `const { execSync } = require("child_process"); function run(parts) { const [cmd] = parts; execSync(cmd); }` }, ], invalid: [ { @@ -141,6 +145,16 @@ describe("no-child-process-interpolated-command", () => { code: `require("child_process").spawn(\`git checkout \${branch}\`, { shell: true });`, errors: [{ messageId: "interpolatedCommand", data: { kind: "interpolated template literal", method: "spawn" } }], }, + // Array-destructured dynamic command must resolve to the destructured element + { + code: `const { execSync } = require("child_process"); const [cmd] = [\`git checkout \${branch}\`]; execSync(cmd);`, + errors: [{ messageId: "interpolatedCommand", data: { kind: "interpolated template literal", method: "execSync" } }], + }, + // Object-destructured dynamic command must resolve to the destructured property + { + code: `const { execSync } = require("child_process"); const { cmd } = { cmd: \`git checkout \${branch}\` }; execSync(cmd);`, + errors: [{ messageId: "interpolatedCommand", data: { kind: "interpolated template literal", method: "execSync" } }], + }, ], }); }); 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 98654f63d98..cdf75492446 100644 --- a/eslint-factory/src/rules/no-exec-interpolated-command.test.ts +++ b/eslint-factory/src/rules/no-exec-interpolated-command.test.ts @@ -59,6 +59,10 @@ describe("no-exec-interpolated-command", () => { { code: `exec.exec("git-checkout".replace("-", () => " "), [branch]);` }, // A write-once interpolation resolving to a literal is safe { code: `function run() { const branch = "main"; const ref = branch; exec.exec(\`git checkout \${ref}\`, []); }` }, + // Statically destructured command — safe, must not be flagged + { code: `function run() { const [cmd] = ["git status"]; exec.exec(cmd, []); }` }, + // Destructured from an unresolvable right-hand side — must not be flagged + { code: `function run(parts) { const [cmd] = parts; exec.exec(cmd, []); }` }, // A digits-only sanitized interpolation is safe { code: `function run(port) { const safePort = String(port).replace(/[^0-9]/g, ""); exec.exec(\`netstat | grep :\${safePort}\`, []); }` }, ], @@ -153,6 +157,16 @@ describe("no-exec-interpolated-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" } }], }, + // Array-destructured dynamic command must resolve to the destructured element + { + code: "function run(branch) { const [cmd] = [`git checkout ${branch}`]; exec.exec(cmd, []); }", + errors: [{ messageId: "interpolatedCommand", data: { kind: "interpolated template literal", method: "exec" } }], + }, + // Object-destructured dynamic command must resolve to the destructured property + { + code: "function run(branch) { const { cmd } = { cmd: `git checkout ${branch}` }; exec.exec(cmd, []); }", + errors: [{ messageId: "interpolatedCommand", data: { kind: "interpolated template literal", method: "exec" } }], + }, // execApi parameter-alias with array-shaped args — flagged (matches git_helpers.cjs / create_pull_request.cjs convention) { code: "function run(execApi, branch) { execApi.exec(`git checkout ${branch}`, []); }", diff --git a/eslint-factory/src/rules/no-github-request-interpolated-route.test.ts b/eslint-factory/src/rules/no-github-request-interpolated-route.test.ts index d819b72d5b3..54090e1f3bb 100644 --- a/eslint-factory/src/rules/no-github-request-interpolated-route.test.ts +++ b/eslint-factory/src/rules/no-github-request-interpolated-route.test.ts @@ -389,4 +389,35 @@ describe("no-github-request-interpolated-route", () => { invalid: [], }); }); + + it("destructured route bindings resolve to the destructured value", () => { + cjsRuleTester.run("no-github-request-interpolated-route", noGithubRequestInterpolatedRouteRule, { + valid: [ + // Statically destructured route — no interpolation, must not be flagged + `function f() { const [route] = ["GET /repos/{owner}/{repo}"]; github.request(route, {}); }`, + // Destructured from a non-literal right-hand side — unresolvable, must not be flagged + "function f(routes) { const [route] = routes; github.request(route, {}); }", + ], + invalid: [ + { + code: "function f(owner, repo) { const [route] = [`GET /repos/${owner}/${repo}`]; github.request(route, {}); }", + errors: [ + { + messageId: "interpolatedRoute", + data: { kind: "template literal with interpolations", client: "github" }, + }, + ], + }, + { + code: "function f(owner, repo) { const { route } = { route: `GET /repos/${owner}/${repo}` }; github.request(route, {}); }", + errors: [ + { + messageId: "interpolatedRoute", + data: { kind: "template literal with interpolations", client: "github" }, + }, + ], + }, + ], + }); + }); });