-
Notifications
You must be signed in to change notification settings - Fork 444
eslint-factory: add gh-aw-custom/prefer-number-isnan rule for unsafe global isNaN usage
#43365
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
Changes from all commits
ff269f3
218d682
4e24eb4
d461e89
adc126e
1fbbe13
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 |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| import { RuleTester } from "eslint"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import { preferNumberIsNanRule } from "./prefer-number-isnan"; | ||
|
|
||
| const cjsRuleTester = new RuleTester({ | ||
| languageOptions: { | ||
| ecmaVersion: 2022, | ||
| sourceType: "commonjs", | ||
| }, | ||
| }); | ||
|
|
||
| const esmRuleTester = new RuleTester({ | ||
| languageOptions: { | ||
| ecmaVersion: 2022, | ||
| sourceType: "module", | ||
| }, | ||
| }); | ||
|
|
||
| describe("prefer-number-isnan", () => { | ||
| it("uses the correct docs URL", () => { | ||
| expect(preferNumberIsNanRule.meta.docs.url).toBe("https://github.com/github/gh-aw/tree/main/eslint-factory#prefer-number-isnan"); | ||
| }); | ||
|
|
||
| it("valid: Number.isNaN and non-global forms are accepted", () => { | ||
| // CJS-only: actions/setup/js targets CommonJS; ESM counterparts tested in the shadowed-bindings block below | ||
| cjsRuleTester.run("prefer-number-isnan", preferNumberIsNanRule, { | ||
| valid: [`Number.isNaN(value);`, `Number["isNaN"](value);`, `foo.isNaN(value);`], | ||
| invalid: [], | ||
| }); | ||
|
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. [/tdd] The valid-cases test for 💡 Either add an ESM counterpart or add an intent comment// valid cases under ESM — mirrors CJS coverage above
esmRuleTester.run("prefer-number-isnan", preferNumberIsNanRule, {
valid: [`Number.isNaN(value);`, `foo.isNaN(value);`],
invalid: [],
});Or add a brief comment: @copilot please address this. |
||
| }); | ||
|
|
||
| it("valid: locally shadowed bindings are intentionally excluded", () => { | ||
| esmRuleTester.run("prefer-number-isnan", preferNumberIsNanRule, { | ||
| valid: [ | ||
| `function isNaN(value) { return false; } isNaN(value);`, | ||
| `const isNaN = Number.isNaN; isNaN(value);`, | ||
| `const globalThis = { isNaN(value) { return value; } }; globalThis.isNaN(value);`, | ||
| `const window = { isNaN(value) { return value; } }; window["isNaN"](value);`, | ||
| `const global = { isNaN(value) { return value; } }; global.isNaN(value);`, | ||
| // Dynamic computed access — identifier property reference, not string literal "isNaN" | ||
| `globalThis[isNaN](value);`, | ||
| ], | ||
| invalid: [], | ||
| }); | ||
| }); | ||
|
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. Missing valid test for dynamic computed property access: 💡 Suggested additionAdd to the valid tests: esmRuleTester.run('prefer-number-isnan', preferNumberIsNanRule, {
valid: [
// Dynamic computed access — identifier property, not string literal 'isNaN'
`globalThis[isNaN](value);`,
],
invalid: [],
});The |
||
|
|
||
| it("valid: isNaN used as a callback reference is not a CallExpression and is not flagged", () => { | ||
| cjsRuleTester.run("prefer-number-isnan", preferNumberIsNanRule, { | ||
| valid: [`values.some(isNaN);`], | ||
| invalid: [], | ||
| }); | ||
| }); | ||
|
|
||
| it("invalid: global isNaN() is flagged with a replacement suggestion", () => { | ||
| cjsRuleTester.run("prefer-number-isnan", preferNumberIsNanRule, { | ||
| valid: [], | ||
| invalid: [ | ||
| { | ||
| code: `isNaN(value);`, | ||
| errors: [{ messageId: "preferNumberIsNaN", suggestions: [{ messageId: "replaceWithNumberIsNaN", output: `Number.isNaN(value);` }] }], | ||
| }, | ||
| { | ||
| // Raw string argument (e.g. env var) — suggestion preserves argument so callers must review whether to wrap with Number(...) | ||
| code: `isNaN(process.env.PORT);`, | ||
| errors: [{ messageId: "preferNumberIsNaN", suggestions: [{ messageId: "replaceWithNumberIsNaN", output: `Number.isNaN(process.env.PORT);` }] }], | ||
| }, | ||
| ], | ||
| }); | ||
| }); | ||
|
|
||
| it("invalid: global object isNaN() access is flagged for direct and computed forms", () => { | ||
| cjsRuleTester.run("prefer-number-isnan", preferNumberIsNanRule, { | ||
| valid: [], | ||
| invalid: [ | ||
| { | ||
| code: `globalThis.isNaN(value);`, | ||
| errors: [{ messageId: "preferNumberIsNaN", suggestions: [{ messageId: "replaceWithNumberIsNaN", output: `Number.isNaN(value);` }] }], | ||
| }, | ||
| { | ||
| code: `globalThis["isNaN"](value);`, | ||
| errors: [{ messageId: "preferNumberIsNaN", suggestions: [{ messageId: "replaceWithNumberIsNaN", output: `Number.isNaN(value);` }] }], | ||
| }, | ||
| { | ||
| code: `window.isNaN(value);`, | ||
| errors: [{ messageId: "preferNumberIsNaN", suggestions: [{ messageId: "replaceWithNumberIsNaN", output: `Number.isNaN(value);` }] }], | ||
| }, | ||
| { | ||
| code: `window["isNaN"](value);`, | ||
| errors: [{ messageId: "preferNumberIsNaN", suggestions: [{ messageId: "replaceWithNumberIsNaN", output: `Number.isNaN(value);` }] }], | ||
| }, | ||
| { | ||
| code: `global.isNaN(value);`, | ||
| errors: [{ messageId: "preferNumberIsNaN", suggestions: [{ messageId: "replaceWithNumberIsNaN", output: `Number.isNaN(value);` }] }], | ||
| }, | ||
| { | ||
| code: `global["isNaN"](value);`, | ||
| errors: [{ messageId: "preferNumberIsNaN", suggestions: [{ messageId: "replaceWithNumberIsNaN", output: `Number.isNaN(value);` }] }], | ||
| }, | ||
| ], | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import { ESLintUtils, TSESLint, TSESTree } from "@typescript-eslint/utils"; | ||
|
|
||
| const createRule = ESLintUtils.RuleCreator(name => `https://github.com/github/gh-aw/tree/main/eslint-factory#${name}`); | ||
| const GLOBAL_IS_NAN_OBJECTS = new Set(["globalThis", "window", "global"]); | ||
|
|
||
| export const preferNumberIsNanRule = createRule({ | ||
| name: "prefer-number-isnan", | ||
| meta: { | ||
| type: "suggestion", | ||
| hasSuggestions: true, | ||
| docs: { | ||
| description: "Prefer Number.isNaN() over global isNaN() to avoid coercion footguns when validating unknown inputs.", | ||
| }, | ||
| schema: [], | ||
| messages: { | ||
| preferNumberIsNaN: "Prefer Number.isNaN(...) over global isNaN(...). Global isNaN() coerces non-number inputs and can hide invalid raw values.", | ||
| replaceWithNumberIsNaN: "Replace callee with Number.isNaN — review whether the argument should be wrapped with Number(...).", | ||
| }, | ||
| }, | ||
| defaultOptions: [], | ||
| create(context) { | ||
| const sourceCode = context.sourceCode; | ||
| type SourceCodeScope = ReturnType<typeof sourceCode.getScope>; | ||
|
|
||
| /** | ||
| * Checks whether a given identifier name is locally bound in the current scope chain. | ||
| * @param node AST node to start the scope search from. | ||
| * @param name Identifier name to search for. | ||
| * @returns true if the name has a local binding, false otherwise. | ||
| */ | ||
| function hasLocalBinding(node: TSESTree.Node, name: string): boolean { | ||
| let scope: SourceCodeScope | null = sourceCode.getScope(node); | ||
|
|
||
| while (scope) { | ||
|
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. [/codebase-design] The 💡 Scope the "local binding" check to user-defined variables onlyESLint's scope if (variable?.defs.some(d => d.type === "Variable" || d.type === "Parameter")) {
return true;
}Or verify this can't happen in practice by adding a test case like: // This should still be flagged — isNaN is imported, not a real local shadow
import { isNaN } from "./compat";
isNaN(value);@copilot please address this. |
||
| const variable = scope.set.get(name); | ||
|
|
||
| if (variable?.defs.some(d => d.type !== "ImportBinding")) { | ||
| return true; | ||
| } | ||
|
|
||
| scope = scope.upper; | ||
| } | ||
|
|
||
| return false; | ||
| } | ||
|
|
||
| /** | ||
| * Checks whether a MemberExpression property is isNaN, either direct or computed. | ||
| * @param node MemberExpression node to inspect. | ||
| * @returns true if the property is isNaN. | ||
| */ | ||
| function isIsNaNProperty(node: TSESTree.MemberExpression): boolean { | ||
| const property = node.property; | ||
| const isDirectAccess = !node.computed && property.type === "Identifier" && property.name === "isNaN"; | ||
| const isComputedAccess = property.type === "Literal" && property.value === "isNaN"; | ||
|
|
||
| return isDirectAccess || isComputedAccess; | ||
| } | ||
|
|
||
| function report(node: TSESTree.CallExpression): void { | ||
| context.report({ | ||
| node: node.callee, | ||
| messageId: "preferNumberIsNaN", | ||
| suggest: [ | ||
| { | ||
| messageId: "replaceWithNumberIsNaN", | ||
| fix(fixer: TSESLint.RuleFixer) { | ||
| return fixer.replaceText(node.callee, "Number.isNaN"); | ||
| }, | ||
| }, | ||
| ], | ||
| }); | ||
| } | ||
|
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. [/tdd] Missing edge case: the suggestion replaces only the callee ( 💡 Consider a suggestion message that surfaces the riskThe current suggestion message says "review whether the argument should be wrapped with // add to the invalid cases section
{
code: `isNaN(process.env.PORT);`,
errors: [{ messageId: "preferNumberIsNaN",
suggestions: [{ messageId: "replaceWithNumberIsNaN",
output: `Number.isNaN(process.env.PORT);` }] }],
},This makes the "review the argument" note testable and visible in the test suite as documentation. @copilot please address this. |
||
|
|
||
| return { | ||
| CallExpression(node) { | ||
| if (node.callee.type === "Identifier" && node.callee.name === "isNaN" && !hasLocalBinding(node, "isNaN")) { | ||
| report(node); | ||
| return; | ||
| } | ||
|
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. [/tdd] The 💡 Add a test to document the intended behaviour// Should this be flagged? Document the decision with a test.
it("valid/invalid: isNaN used as a callback reference", () => {
cjsRuleTester.run("prefer-number-isnan", preferNumberIsNanRule, {
valid: [`values.some(isNaN);`], // intentionally not a CallExpression
invalid: [],
});
});If the intent is to also catch reference-style usage, a separate @copilot please address this. |
||
|
|
||
| if (node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && GLOBAL_IS_NAN_OBJECTS.has(node.callee.object.name) && !hasLocalBinding(node, node.callee.object.name) && isIsNaNProperty(node.callee)) { | ||
| report(node); | ||
| } | ||
| }, | ||
| }; | ||
| }, | ||
| }); | ||
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.
[/grill-with-docs] The docs URL points to a README anchor (
#prefer-number-isnan) that doesn't exist yet — clicking this link in an IDE will land on a 404 fragment.💡 Add the anchor to README.md
Add a rules table or section to
eslint-factory/README.mdso the URL resolves. For example:This makes every rule's docs URL a meaningful destination for developers who click through from their IDE lint warning.
@copilot please address this.