Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions eslint-factory/eslint.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ module.exports = [
"gh-aw-custom/require-escaped-regexp-interpolation": "warn",
"gh-aw-custom/require-fetch-timeout": "warn",
"gh-aw-custom/require-nan-check-after-env-numeric-parse": "warn",
"gh-aw-custom/require-nan-check-after-split-index-parse": "warn",
"gh-aw-custom/prefer-structured-clone": "warn",
"gh-aw-custom/require-fetch-response-body-try-catch": "warn",
"gh-aw-custom/require-error-code-in-thrown-error": "warn",
Expand Down
2 changes: 2 additions & 0 deletions eslint-factory/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { noDuplicateConstantValuesRule } from "./rules/no-duplicate-constant-val
import { requireEscapedRegexpInterpolationRule } from "./rules/require-escaped-regexp-interpolation";
import { requireFetchTimeoutRule } from "./rules/require-fetch-timeout";
import { requireNanCheckAfterEnvNumericParseRule } from "./rules/require-nan-check-after-env-numeric-parse";
import { requireNanCheckAfterSplitIndexParseRule } from "./rules/require-nan-check-after-split-index-parse";
import { preferStructuredCloneRule } from "./rules/prefer-structured-clone";
import { requireFetchResponseBodyTryCatchRule } from "./rules/require-fetch-response-body-try-catch";
import { requireErrorCodeInThrownErrorRule } from "./rules/require-error-code-in-thrown-error";
Expand Down Expand Up @@ -91,6 +92,7 @@ const plugin = {
"require-escaped-regexp-interpolation": requireEscapedRegexpInterpolationRule,
"require-fetch-timeout": requireFetchTimeoutRule,
"require-nan-check-after-env-numeric-parse": requireNanCheckAfterEnvNumericParseRule,
"require-nan-check-after-split-index-parse": requireNanCheckAfterSplitIndexParseRule,
"prefer-structured-clone": preferStructuredCloneRule,
"require-fetch-response-body-try-catch": requireFetchResponseBodyTryCatchRule,
"require-error-code-in-thrown-error": requireErrorCodeInThrownErrorRule,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { RuleTester } from "eslint";
import { describe, expect, it } from "vitest";
import { requireNanCheckAfterSplitIndexParseRule } from "./require-nan-check-after-split-index-parse";

const cjsRuleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2022,
sourceType: "commonjs",
},
});

describe("require-nan-check-after-split-index-parse", () => {
it("uses the correct docs URL", () => {
expect(requireNanCheckAfterSplitIndexParseRule.meta.docs.url).toBe("https://github.com/github/gh-aw/tree/main/eslint-factory#require-nan-check-after-split-index-parse");
});

it("valid: parseInt from split(...)[index] validated with Number.isNaN", () => {
cjsRuleTester.run("require-nan-check-after-split-index-parse", requireNanCheckAfterSplitIndexParseRule, {
valid: [`const discussionNumber = parseInt(endpoint.split(":")[1], 10); if (Number.isNaN(discussionNumber)) throw new Error("invalid");`],
invalid: [],
});
});

it("valid: parseInt from split(...)[index] validated with global isNaN", () => {
cjsRuleTester.run("require-nan-check-after-split-index-parse", requireNanCheckAfterSplitIndexParseRule, {
valid: [`const discussionNumber = parseInt(endpoint.split(":")[1], 10); if (isNaN(discussionNumber)) throw new Error("invalid");`],
invalid: [],
});
});

it("valid: Number.parseInt from split(...)[index] validated with Number.isNaN", () => {
cjsRuleTester.run("require-nan-check-after-split-index-parse", requireNanCheckAfterSplitIndexParseRule, {
valid: [`const discussionNumber = Number.parseInt(endpoint.split(":")[1], 10); if (Number.isNaN(discussionNumber)) throw new Error("invalid");`],
invalid: [],
});
});

it("valid: truthiness guard on parsed value", () => {
cjsRuleTester.run("require-nan-check-after-split-index-parse", requireNanCheckAfterSplitIndexParseRule, {
valid: [`const discussionNumber = parseInt(endpoint.split(":")[1], 10); if (!discussionNumber) throw new Error("invalid");`],
invalid: [],
});
});

it("valid: parseInt not from split(...)[index] is not flagged", () => {
cjsRuleTester.run("require-nan-check-after-split-index-parse", requireNanCheckAfterSplitIndexParseRule, {
valid: [`const count = parseInt(rawValue, 10); doSomething(count);`],
invalid: [],
});
});

it("invalid: parseInt from split(...)[index] without NaN check", () => {
cjsRuleTester.run("require-nan-check-after-split-index-parse", requireNanCheckAfterSplitIndexParseRule, {
valid: [],
invalid: [
{
code: `const discussionNumber = parseInt(endpoint.split(":")[1], 10); getDiscussionNodeId(owner, repo, discussionNumber);`,
errors: [{ messageId: "requireNaNCheck" }],
},
],
});
});

it("invalid: Number.parseInt from split(...)[index] without NaN check", () => {
cjsRuleTester.run("require-nan-check-after-split-index-parse", requireNanCheckAfterSplitIndexParseRule, {
valid: [],
invalid: [
{
code: `const discussionNumber = Number.parseInt(endpoint.split(":")[1], 10); getDiscussionNodeId(owner, repo, discussionNumber);`,
errors: [{ messageId: "requireNaNCheck" }],
},
],
});
});

it("invalid: parseFloat from split(...)[index] without NaN check", () => {
cjsRuleTester.run("require-nan-check-after-split-index-parse", requireNanCheckAfterSplitIndexParseRule, {
valid: [],
invalid: [
{
code: `const version = parseFloat(tag.split("v")[1]); doSomething(version);`,
errors: [{ messageId: "requireNaNCheck" }],
},
],
});
});

it("invalid: same-named variable validated in one scope does not suppress an unvalidated occurrence in another scope", () => {
cjsRuleTester.run("require-nan-check-after-split-index-parse", requireNanCheckAfterSplitIndexParseRule, {
valid: [],
invalid: [
{
code: `
function a() {
const discussionNumber = parseInt(endpoint.split(":")[1], 10);
if (!discussionNumber) throw new Error("invalid");
return discussionNumber;
}
function b() {
const discussionNumber = parseInt(endpoint.split(":")[1], 10);
return getDiscussionNodeId(owner, repo, discussionNumber);
}
`.trim(),
errors: [{ messageId: "requireNaNCheck" }],
},
],
});
});

it("invalid: multiple unvalidated split-index parse declarations are each reported", () => {
cjsRuleTester.run("require-nan-check-after-split-index-parse", requireNanCheckAfterSplitIndexParseRule, {
valid: [],
invalid: [
{
code: `const a = parseInt(x.split(":")[1], 10); const b = parseInt(y.split(":")[1], 10); use(a, b);`,
errors: [{ messageId: "requireNaNCheck" }, { messageId: "requireNaNCheck" }],
},
],
});
});
});
167 changes: 167 additions & 0 deletions eslint-factory/src/rules/require-nan-check-after-split-index-parse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { ESLintUtils, TSESTree } from "@typescript-eslint/utils";

const createRule = ESLintUtils.RuleCreator(name => `https://github.com/github/gh-aw/tree/main/eslint-factory#${name}`);

export const requireNanCheckAfterSplitIndexParseRule = createRule({
name: "require-nan-check-after-split-index-parse",
meta: {
type: "problem",
docs: {
description: "Require NaN validation after parsing a numeric value out of a string.split(...)[index] expression, since malformed delimited strings silently produce NaN that can propagate into API calls.",
},
schema: [],
messages: {
requireNaNCheck:
"Numeric value '{{name}}' parsed from a 'split(...)[index]' expression is never validated with Number.isNaN(), isNaN(), Number.isFinite(), isFinite(), or a truthiness check. A malformed delimited string will silently produce NaN, which can then be passed to downstream API calls.",
},
},
defaultOptions: [],
create(context) {
// Set of VariableDeclarator nodes for split(...)[index]-derived parses, keyed by node identity
// so that same-named variables in different scopes are not conflated.
const unvalidated = new Set<TSESTree.VariableDeclarator>();
// Set of VariableDeclarator nodes confirmed to be validated (via isNaN / Number.isNaN or a truthiness guard)
const validated = new Set<TSESTree.VariableDeclarator>();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[/codebase-design] unvalidated and validated are module-level Sets accumulated across the entire program and only evaluated at Program:exit. This means the rule has no mechanism to handle let re-assignments — if n is reassigned after being validated, it stays in validated. While the current targets use const exclusively, making this assumption explicit (e.g., restricting to VariableDeclarator with const) would prevent subtle false-negatives if the rule is later applied to non-const code.

💡 Quick guard
// In VariableDeclarator handler, restrict to const only:
if (
  node.id.type === "Identifier" &&
  node.parent.type === "VariableDeclaration" &&
  node.parent.kind === "const" && // <-- guard
  node.init?.type === "CallExpression" &&
  isNumericParseCallFromSplitIndex(node.init)
) {
  unvalidated.add(node);
}

@copilot please address this.

/**
* Resolves an Identifier reference to the VariableDeclarator that declared it,
* using scope analysis so that same-named variables in different scopes are distinguished.
*/
function resolveDeclarator(identifier: TSESTree.Identifier): TSESTree.VariableDeclarator | null {
const sourceCode = context.sourceCode;
let scope: ReturnType<typeof sourceCode.getScope> | null = sourceCode.getScope(identifier);
while (scope) {
const variable = scope.variables.find(v => v.name === identifier.name);
if (variable) {
const def = variable.defs.find(d => d.node.type === "VariableDeclarator");
if (def && def.node.type === "VariableDeclarator") {
return def.node as TSESTree.VariableDeclarator;
}
return null;
}
scope = scope.upper;
}
return null;
}

/**
* Returns true when the given node is a `<expr>.split(...)[<index>]` member access,
* e.g. `endpoint.split(":")[1]`.
*/
function isSplitIndexAccess(node: TSESTree.Node): boolean {
if (node.type !== "MemberExpression" || !node.computed) return false;
const obj = node.object;
return obj.type === "CallExpression" && obj.callee.type === "MemberExpression" && !obj.callee.computed && obj.callee.property.type === "Identifier" && obj.callee.property.name === "split";
}

/**
* Returns true when the call expression is a numeric-parse function whose

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This matcher misses Number(split(...)[index]), even though that conversion has the exact same silent-NaN failure mode as parseInt/parseFloat. That leaves an easy escape hatch: callers can switch to Number(...) and bypass the rule without making the code any safer.

💡 Cover the full numeric-conversion surface consistently

Extend isNumericParseCallFromSplitIndex to include Number(...), the same way the existing env-based rule already does:

if (callee.type === "Identifier" && callee.name === "Number") {
  return isSplitIndexAccess(firstArg);
}

Then add both valid and invalid tests for Number(endpoint.split(":")[1]) so the new rule cannot be trivially sidestepped.

* first argument is a `split(...)[index]` access.
*/
function isNumericParseCallFromSplitIndex(node: TSESTree.CallExpression): boolean {
const { callee, arguments: args } = node;
Comment on lines +85 to +86

if (args.length === 0 || args[0].type === "SpreadElement") return false;

const firstArg = args[0] as TSESTree.Expression;

// Global parseInt(splitExpr, ...) or parseFloat(splitExpr)
if (callee.type === "Identifier" && (callee.name === "parseInt" || callee.name === "parseFloat")) {
return isSplitIndexAccess(firstArg);
}

// Number.parseInt(splitExpr, ...) or Number.parseFloat(splitExpr)
if (
callee.type === "MemberExpression" &&
callee.object.type === "Identifier" &&
callee.object.name === "Number" &&
!callee.computed &&
callee.property.type === "Identifier" &&
(callee.property.name === "parseInt" || callee.property.name === "parseFloat")
) {
return isSplitIndexAccess(firstArg);
}

return false;
}

/**
* Returns true when the call expression is a NaN-validating global:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The message and PR summary claim Number.isFinite() / isFinite() guards are supported, but this implementation only marks validation when those calls appear as standalone CallExpressions with the parsed identifier as the direct argument. Common patterns like if (!Number.isFinite(count) || count < 0) and const value = Number.isFinite(count) ? count : 0 never mark the variable as validated here, so the rule will warn on code that already follows the advertised contract.

💡 Fix the validator walk instead of relying on bare call sites

Inspect boolean test expressions recursively and mark the variable when an isNaN/isFinite check on that identifier appears anywhere inside the condition, not just as a top-level call expression. For example:

function markValidationInTest(node: TSESTree.Node): void {
  if (node.type === "CallExpression" && isIsNaNCall(node) && node.arguments[0]?.type === "Identifier") {
    const declarator = resolveDeclarator(node.arguments[0]);
    if (declarator) validated.add(declarator);
  }
  if (node.type === "LogicalExpression") {
    markValidationInTest(node.left);
    markValidationInTest(node.right);
  }
}

That keeps the implementation aligned with both the rule text and the existing require-nan-check-after-env-numeric-parse test patterns that already use Number.isFinite(...) inside compound conditions.

* isNaN(...), Number.isNaN(...), isFinite(...) or Number.isFinite(...).
*/
function isIsNaNCall(node: TSESTree.CallExpression): boolean {
const { callee } = node;

// Global isNaN(x) / isFinite(x)
if (callee.type === "Identifier" && (callee.name === "isNaN" || callee.name === "isFinite")) {
return true;
}

// Number.isNaN(x) / Number.isFinite(x)
if (
callee.type === "MemberExpression" &&
callee.object.type === "Identifier" &&
callee.object.name === "Number" &&
!callee.computed &&
callee.property.type === "Identifier" &&
(callee.property.name === "isNaN" || callee.property.name === "isFinite")
) {
return true;
}

return false;
}

/**
* Marks a bare identifier (or `!identifier`) used as a condition test as validated,
* since NaN is falsy and such a truthiness guard rejects it.
*/
function markTruthinessGuard(test: TSESTree.Node): void {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[/tdd] The markTruthinessGuard helper only unwraps ! (logical NOT), so patterns like if (n > 0) or if (n !== undefined) are not recognized as validation — callers who add such guards will get a false positive warning.

💡 Suggested test to document the boundary

Add a valid case (or explicitly mark it invalid) to pin the intended behaviour:

// Does this suppress the warning, or not?
const n = parseInt(s.split(":")[1], 10);
if (!(n > 0)) throw new Error("invalid");

If > 0 guards are intentionally unsupported, document them in the error message so consumers know which guard forms are accepted.

@copilot please address this.

let expr = test;
while (expr.type === "UnaryExpression" && expr.operator === "!") {
expr = expr.argument;
}
if (expr.type === "Identifier") {
const declarator = resolveDeclarator(expr);
if (declarator) validated.add(declarator);
}
}

return {
VariableDeclarator(node) {
if (node.id.type === "Identifier" && node.init?.type === "CallExpression" && isNumericParseCallFromSplitIndex(node.init as TSESTree.CallExpression)) {
unvalidated.add(node);
}
},

CallExpression(node) {
// Track any isNaN(x) / Number.isNaN(x) call where x is an identifier
if (isIsNaNCall(node) && node.arguments.length === 1 && node.arguments[0].type === "Identifier") {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[/tdd] The CallExpression handler only recognises isNaN(x) / Number.isNaN(x) when the argument is a bare Identifier. A common real-world guard like Number.isNaN(parseInt(...)) inline (i.e., no intermediate variable) is never tracked — but more importantly, expressions like isNaN(+n) or Number.isNaN(n | 0) will silently fall through. This is edge-case territory, but worth a test to document intent.

💡 Suggested test
// valid: variable passed with unary coercion — does the rule accept this?
const n = parseInt(s.split(":")[1], 10);
if (Number.isNaN(+n)) throw new Error("invalid");

@copilot please address this.

const declarator = resolveDeclarator(node.arguments[0] as TSESTree.Identifier);
if (declarator) validated.add(declarator);
}
},

IfStatement(node) {
markTruthinessGuard(node.test);
},

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The markTruthinessGuard helper strips ! chains and resolves bare identifiers, but does not handle BinaryExpression guards such as if (port > 0), if (version >= 1), or if (n < maxValue). All of those patterns correctly reject NaN at runtime (NaN > 0 is false), yet the rule will still report them as unvalidated.

Example false-positive:

const port = parseInt(addr.split(":")[1], 10);
if (port > 0 && port < 65536) usePort(port); // flagged by rule, but guard is correct

Consider adding handling for BinaryExpression comparisons in markTruthinessGuard, or add a test case that documents this as a known limitation.

@copilot please address this.

ConditionalExpression(node) {
markTruthinessGuard(node.test);
},

"Program:exit"() {
for (const declaratorNode of unvalidated) {
if (!validated.has(declaratorNode)) {
const name = declaratorNode.id.type === "Identifier" ? declaratorNode.id.name : "";
context.report({
node: declaratorNode,
messageId: "requireNaNCheck",
data: { name },
});
}
}
},
};
},
});
Loading