-
Notifications
You must be signed in to change notification settings - Fork 460
fix(eslint-factory): resolve single-level variable indirection in no-exec-interpolated-command #47544
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(eslint-factory): resolve single-level variable indirection in no-exec-interpolated-command #47544
Changes from all commits
9764c2e
6e80867
f3b49af
897ee36
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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,43 @@ 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Scope walk climbs past function boundaries — module-level variable initializers are not statically safe. 💡 Details and suggested fixThe The existing Fix: stop climbing at the enclosing function boundary so only local block-scoped bindings are resolved. // stop at function/module boundaries
if (scope.type === 'function' || scope.type === 'module') {
break;
}
scope = scope.upper; |
||
| * initializer. | ||
| */ | ||
| function resolveInitializer(identifier: TSESTree.Identifier): TSESTree.Expression | 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. | ||
| 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 +120,16 @@ export const noExecInterpolatedCommandRule = createRule({ | |
| const firstArg = node.arguments[0]; | ||
| if (!firstArg || firstArg.type === AST_NODE_TYPES.SpreadElement) return; | ||
|
|
||
| const kind = getDynamicCommandKind(firstArg); | ||
| let candidate: TSESTree.Expression = firstArg as TSESTree.Expression; | ||
| const seen = new Set<TSESTree.Identifier>(); | ||
| 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; | ||
|
|
||
| context.report({ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[/tdd] Missing boundary test: two-level indirection (e.g.
const a = \git ${x}`; const b = a; exec.exec(b, [])`) is intentionally out of scope, but there is no test documenting this limit.💡 Suggested valid test
Without a test, the intended boundary is invisible to reviewers of future follow-up PRs.
@copilot please address this.