Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions eslint-factory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Using an interpolated route bypasses Octokit's typed route dispatch, can silentl
**Flagged forms:**
- `` github.request(`GET /repos/${owner}/${repo}`, ...) `` — template literal with interpolations.
- `github.request("GET /repos/" + owner + "/" + repo, ...)` — string concatenation.
- `` github.request(`POST ${endpoint}`, ...) `` — opaque whole-route helper; thread a typed route from the caller instead of interpolating the entire path.
- `` context.github.request(`GET /repos/${owner}/${repo}`, ...) `` — `context.github` client.
- `` const gh = github; gh.request(`GET /repos/${owner}/${repo}`, ...) `` — aliased client.
- `` const client = getOctokit(token); client.request(`GET /repos/${owner}/${repo}`, ...) `` — `getOctokit` result alias.
Expand All @@ -47,6 +48,8 @@ Using an interpolated route bypasses Octokit's typed route dispatch, can silentl
github.request("GET /repos/{owner}/{repo}", { owner, repo });
```

For helpers that receive the entire route as a parameter, there is no mechanical `{owner}` / `{repo}` rewrite. Pass a typed route string from the caller instead of interpolating `POST ${endpoint}` or `"POST " + endpoint` at the helper call site.

### `no-json-stringify-error`

Disallow `JSON.stringify()` on caught error variables. `Error` properties (`message`, `stack`, etc.) are non-enumerable, so `JSON.stringify(err)` silently produces `{}`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,32 @@ describe("no-github-request-interpolated-route", () => {
});
});

it("invalid: opaque whole-route helpers are flagged with tailored guidance", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] New tests only exercise the github client — the existing interpolatedRoute tests cover all four known client names (github, octokit, githubClient, octokitClient), but the new opaqueWholeRoute block tests only github. If a client name is added or removed in future, this gap won't surface.

💡 Suggested additions

Add at least one entry per additional client name, mirroring the pattern in the interpolatedRoute test block:

{
  code: "function addReaction(endpoint) { octokit.request(`POST ${endpoint}`, {}); }",
  errors: [{ messageId: "opaqueWholeRoute", data: { kind: "template literal with interpolations", client: "octokit" } }],
},
{
  code: "function addReaction(endpoint) { githubClient.request(`POST ${endpoint}`, {}); }",
  errors: [{ messageId: "opaqueWholeRoute", data: { kind: "template literal with interpolations", client: "githubClient" } }],
},

This gives the test suite the same breadth as the existing interpolatedRoute coverage.

@copilot please address this.

cjsRuleTester.run("no-github-request-interpolated-route", noGithubRequestInterpolatedRouteRule, {
valid: [],
invalid: [
{
code: "function addReaction(endpoint) { github.request(`POST ${endpoint}`, { content: '+1' }); }",
errors: [
{
messageId: "opaqueWholeRoute",
data: { kind: "template literal with interpolations", client: "github" },
},
],
},
{
code: 'function addComment(endpoint) { github.request("POST " + endpoint, { body }); }',
errors: [
{
messageId: "opaqueWholeRoute",
data: { kind: "string concatenation expression", client: "github" },
},
],
},
],
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing regression test for the critical boundary between opaqueWholeRoute and interpolatedRoute: No test verifies that a multi-expression template like POST /repos/${owner}/${repo} (3 quasis, 2 expressions) falls through to interpolatedRoute instead of opaqueWholeRoute. The hasSingleExpression guard is the only protection and is untested.

💡 Suggested test
it("boundary: multi-expression template stays interpolatedRoute, not opaqueWholeRoute", () => {
  cjsRuleTester.run("no-github-request-interpolated-route", noGithubRequestInterpolatedRouteRule, {
    valid: [],
    invalid: [
      {
        code: "github.request(`POST /repos/${owner}/${repo}/reactions`, { content: '+1' });",
        errors: [{ messageId: "interpolatedRoute" }],
      },
    ],
  });
});

Without this, a future change to isOpaqueWholeRouteInterpolation could silently regress the most common path-interpolation case to a less actionable diagnostic.


it("valid: simple aliases bound to non-Octokit sources are not flagged", () => {
cjsRuleTester.run("no-github-request-interpolated-route", noGithubRequestInterpolatedRouteRule, {
valid: [
Expand Down
75 changes: 64 additions & 11 deletions eslint-factory/src/rules/no-github-request-interpolated-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const createRule = ESLintUtils.RuleCreator(name => `https://github.com/github/gh

const OCTOKIT_CLIENT_NAMES = new Set(["github", "octokit", "githubClient", "octokitClient"]);
const GET_OCTOKIT_MEMBER_OBJECT_NAMES = new Set(["github", "actions"]);
const HTTP_METHOD_PREFIXES = new Set(["GET ", "POST ", "PUT ", "PATCH ", "DELETE ", "HEAD ", "OPTIONS "]);

/**
* Returns true when the node is a template literal that contains at least one
Expand Down Expand Up @@ -39,6 +40,54 @@ function isStringConcatenation(node: TSESTree.Node): boolean {
return node.type === "BinaryExpression" && node.operator === "+" && !isStaticRouteExpression(node);
}

/**
* Returns true when `text` is an exact HTTP-method route prefix including the
* required trailing space, such as `GET ` or `POST `.
*/
function isValidHttpMethodPrefix(text: string | null | undefined): boolean {
return typeof text === "string" && HTTP_METHOD_PREFIXES.has(text);
}

/**
* Returns the human-readable route-expression kind string used in diagnostics,
* or null when `node` is not one of the interpolated route shapes this rule
* reports on.
*/
function getInterpolatedRouteKind(node: TSESTree.Node): string | null {
if (isInterpolatedTemplateLiteral(node)) return "template literal with interpolations";
if (isStringConcatenation(node)) return "string concatenation expression";
return null;
}

/**
* Returns true when the route expression is exactly an HTTP method plus a
* single opaque route expression, such as:
* - `POST ${endpoint}`
* - `"POST " + endpoint`
*
* This shape differs from value-into-path interpolation because there is no
* known static path at the call site to rewrite with `{placeholder}` segments.
*/
function isOpaqueWholeRouteInterpolation(node: TSESTree.Node): boolean {
if (node.type === "TemplateLiteral") {
const hasTwoQuasis = node.quasis.length === 2;
const hasSingleExpression = node.expressions.length === 1;
const hasMethodPrefix = hasTwoQuasis && isValidHttpMethodPrefix(node.quasis[0].value.cooked);
const hasEmptyTrailingQuasi = hasTwoQuasis && node.quasis[1].value.cooked === "";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] hasEmptyTrailingQuasi checks === "" but doesn't guard against nullquasis[1].value.cooked is null when the template contains an invalid escape sequence (e.g. POST ${endpoint}\u{FFFFFFFF}). A null cooked value would make this check pass, potentially mis-classifying the route as opaque.

💡 Suggested fix
const hasEmptyTrailingQuasi =
  hasTwoQuasis && node.quasis[1].value.cooked === "";

const hasEmptyTrailingQuasi =
  hasTwoQuasis && (node.quasis[1].value.cooked ?? null) === "";

Or more defensively:

const trailingCooked = node.quasis[1]?.value.cooked;
const hasEmptyTrailingQuasi = trailingCooked === "";

Also apply the same null-guard to the hasMethodPrefix check on quasis[0].value.cookedisValidHttpMethodPrefix already accepts null, so that path is safe, but documenting the intent is clearer.

@copilot please address this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unguarded null check may silently misclassify opaque templates: node.quasis[1].value.cooked can be null for template literals with invalid Unicode escape sequences, and null === "" evaluates to false, causing the node to fall through to the generic interpolatedRoute message instead of the intended opaqueWholeRoute message.

💡 Suggested fix
const hasEmptyTrailingQuasi = hasTwoQuasis && (node.quasis[1].value.cooked === "" || node.quasis[1].value.cooked == null);

Or extract a helper:

function isCookedEmpty(quasi: TSESTree.TemplateElement): boolean {
  return quasi.value.cooked === "" || quasi.value.cooked == null;
}

Template literals with invalid escapes (e.g., POST \u{notvalid}${endpoint} ) will set cooked to null on the quasi containing the invalid escape. If that quasi is the trailing one and happens to be otherwise empty, the classification silently degrades to a less specific diagnostic.

const isDynamicWholeRoute = hasSingleExpression && !isStaticRouteExpression(node.expressions[0]);
return hasMethodPrefix && hasEmptyTrailingQuasi && isDynamicWholeRoute;
}

if (node.type === "BinaryExpression" && node.operator === "+") {
if (node.left.type !== "Literal" || typeof node.left.value !== "string") return false;
const hasMethodPrefix = isValidHttpMethodPrefix(node.left.value);
const isRightDynamic = !isStaticRouteExpression(node.right);
return hasMethodPrefix && isRightDynamic;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BinaryExpression opaque-route check misclassifies right-associative concatenations: "POST " + (owner + "/" + repo) has left = "POST " (Literal) and right = (owner + "/" + repo) (dynamic BinaryExpression). This hits the hasMethodPrefix && isRightDynamic guard and fires opaqueWholeRoute, but the right-hand side is a multi-value path interpolation that should get the typed-placeholder interpolatedRoute guidance instead.

💡 Details and suggested fix

The distinction that makes isOpaqueWholeRouteInterpolation meaningful is whether the RHS is a single identifier (the whole route), not just any dynamic expression. Tighten the check:

if (node.type === "BinaryExpression" && node.operator === "+") {
  if (node.left.type !== "Literal" || typeof node.left.value !== "string") return false;
  const hasMethodPrefix = isValidHttpMethodPrefix(node.left.value);
  // Require the right side to be a single identifier (or member expression), not another concatenation
  const isRightSingleDynamic = node.right.type === "Identifier" || node.right.type === "MemberExpression";
  return hasMethodPrefix && isRightSingleDynamic;
}

Add a regression test:

{
  code: 'github.request("POST " + owner + "/" + repo, { });',
  errors: [{ messageId: "interpolatedRoute" }],
}

}

return false;
}

/**
* Returns true when `node` is the `context.github` member expression.
*/
Expand Down Expand Up @@ -88,10 +137,13 @@ export const noGithubRequestInterpolatedRouteRule = createRule({
"Disallow template literals with interpolations or string concatenation as the route argument of Octokit.request() calls. " +
"Octokit clients are detected by well-known names (github, octokit, githubClient, octokitClient), " +
"identifiers initialized from getOctokit(...) call results, context.github, and simple const aliases of any of these. " +
'Use the typed placeholder form instead: "GET /repos/{owner}/{repo}" with a separate params object.',
"Use the typed placeholder form for value interpolation, or thread a typed route string from the caller when the entire route is dynamic.",
},
schema: [],
messages: {
opaqueWholeRoute:
"Avoid using an opaque whole-route {{kind}} as the route argument of {{client}}.request(). " +
"When the entire route is dynamic, pass a typed route string from the caller instead of interpolating or concatenating a route variable.",
interpolatedRoute:
"Avoid using a {{kind}} as the route argument of {{client}}.request(). " +
'Use the typed placeholder form instead — e.g. github.request("GET /repos/{owner}/{repo}", { owner, repo }) — ' +
Expand Down Expand Up @@ -169,22 +221,23 @@ export const noGithubRequestInterpolatedRouteRule = createRule({
const firstArg = node.arguments[0];
if (!firstArg) return;

if (isInterpolatedTemplateLiteral(firstArg)) {
const routeKind = getInterpolatedRouteKind(firstArg);
if (!routeKind) return;

if (isOpaqueWholeRouteInterpolation(firstArg)) {
context.report({
node: firstArg,
messageId: "interpolatedRoute",
data: { kind: "template literal with interpolations", client: clientName },
messageId: "opaqueWholeRoute",
data: { kind: routeKind, client: clientName },
});
return;
}

if (isStringConcatenation(firstArg)) {
context.report({
node: firstArg,
messageId: "interpolatedRoute",
data: { kind: "string concatenation expression", client: clientName },
});
}
context.report({
node: firstArg,
messageId: "interpolatedRoute",
data: { kind: routeKind, client: clientName },
});
},
};
},
Expand Down
Loading