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 b97665b098a..d819b72d5b3 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 @@ -72,8 +72,6 @@ describe("no-github-request-interpolated-route", () => { valid: [ // `this.github` is not resolved — `this` is not an Identifier "this.github.request(`GET /repos/${owner}/${repo}`, { owner, repo });", - // Variable indirection for the route argument is not resolved - "const route = `GET /repos/${owner}/${repo}`; github.request(route, { owner, repo });", `github.request("GET /repos/".concat(owner, "/", repo), { owner, repo });`, `github.request("GET /repos" + "/{owner}/{repo}", { owner, repo });`, ], @@ -345,4 +343,50 @@ describe("no-github-request-interpolated-route", () => { ], }); }); + + it("invalid: identifier-bound interpolated route is resolved and flagged", () => { + cjsRuleTester.run("no-github-request-interpolated-route", noGithubRequestInterpolatedRouteRule, { + valid: [], + 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 = "GET /repos/" + owner + "/" + repo; github.request(route, {}); }`, + errors: [ + { + messageId: "interpolatedRoute", + data: { kind: "string concatenation expression", client: "github" }, + }, + ], + }, + ], + }); + }); + + it("valid: identifier-bound fully-static ternary route is not flagged", () => { + cjsRuleTester.run("no-github-request-interpolated-route", noGithubRequestInterpolatedRouteRule, { + valid: [ + // Fully static ternary — both branches are literals, so this is a true negative + `function f(cond) { const route = cond ? "GET /a" : "GET /b"; github.request(route, {}); }`, + ], + invalid: [], + }); + }); + + it("valid: reassigned identifier is not resolved (write-once semantics)", () => { + cjsRuleTester.run("no-github-request-interpolated-route", noGithubRequestInterpolatedRouteRule, { + valid: [ + // let binding reassigned after declaration — write-once chain does not apply + "function f(owner, repo, suffix) { let route = `GET /repos/${owner}/${repo}`; route = suffix; github.request(route, {}); }", + ], + invalid: [], + }); + }); }); diff --git a/eslint-factory/src/rules/no-github-request-interpolated-route.ts b/eslint-factory/src/rules/no-github-request-interpolated-route.ts index eff4c298f74..1a2496a7856 100644 --- a/eslint-factory/src/rules/no-github-request-interpolated-route.ts +++ b/eslint-factory/src/rules/no-github-request-interpolated-route.ts @@ -1,4 +1,5 @@ import { AST_NODE_TYPES, ESLintUtils, TSESTree } from "@typescript-eslint/utils"; +import { resolveWriteOnceInitializerChain } from "./command-initializer-utils"; const createRule = ESLintUtils.RuleCreator(name => `https://github.com/github/gh-aw/tree/main/eslint-factory#${name}`); @@ -219,12 +220,14 @@ export const noGithubRequestInterpolatedRouteRule = createRule({ if (!clientName) return; const firstArg = node.arguments[0]; - if (!firstArg) return; + if (!firstArg || firstArg.type === AST_NODE_TYPES.SpreadElement) return; - const routeKind = getInterpolatedRouteKind(firstArg); + const routeExpr = resolveWriteOnceInitializerChain(firstArg as TSESTree.Expression, context.sourceCode); + + const routeKind = getInterpolatedRouteKind(routeExpr); if (!routeKind) return; - if (isOpaqueWholeRouteInterpolation(firstArg)) { + if (isOpaqueWholeRouteInterpolation(routeExpr)) { context.report({ node: firstArg, messageId: "opaqueWholeRoute",