diff --git a/eslint-factory/src/rules/prefer-number-isnan.test.ts b/eslint-factory/src/rules/prefer-number-isnan.test.ts index 080aef770fe..f04b74c75d2 100644 --- a/eslint-factory/src/rules/prefer-number-isnan.test.ts +++ b/eslint-factory/src/rules/prefer-number-isnan.test.ts @@ -64,6 +64,21 @@ describe("prefer-number-isnan", () => { code: `isNaN(process.env.PORT);`, errors: [{ messageId: "preferNumberIsNaN", suggestions: [{ messageId: "replaceWithNumberIsNaN", output: `Number.isNaN(process.env.PORT);` }] }], }, + { + // Shadowed parseInt can return non-number values, so keep suggestion-only behavior. + code: `const parseInt = x => x; isNaN(parseInt(value, 10));`, + errors: [{ messageId: "preferNumberIsNaN", suggestions: [{ messageId: "replaceWithNumberIsNaN", output: `const parseInt = x => x; Number.isNaN(parseInt(value, 10));` }] }], + }, + { + // Shadowed parseFloat can return non-number values, so keep suggestion-only behavior. + code: `const parseFloat = x => x; isNaN(parseFloat(value));`, + errors: [{ messageId: "preferNumberIsNaN", suggestions: [{ messageId: "replaceWithNumberIsNaN", output: `const parseFloat = x => x; Number.isNaN(parseFloat(value));` }] }], + }, + { + // Shadowed Number can alter coercion semantics, so keep suggestion-only behavior. + code: `const Number = x => x; isNaN(Number(value));`, + errors: [{ messageId: "preferNumberIsNaN", suggestions: [{ messageId: "replaceWithNumberIsNaN", output: `const Number = x => x; Number.isNaN(Number(value));` }] }], + }, ], }); }); @@ -99,4 +114,45 @@ describe("prefer-number-isnan", () => { ], }); }); + + it("invalid: provably-numeric argument gets a real autofix (no caveat, --fix-able)", () => { + cjsRuleTester.run("prefer-number-isnan", preferNumberIsNanRule, { + valid: [], + invalid: [ + // parseInt / parseFloat / Number — the dominant idiom in actions/setup/js + { + code: `isNaN(parseInt(x, 10));`, + output: `Number.isNaN(parseInt(x, 10));`, + errors: [{ messageId: "preferNumberIsNaN" }], + }, + { + code: `isNaN(parseFloat(x));`, + output: `Number.isNaN(parseFloat(x));`, + errors: [{ messageId: "preferNumberIsNaN" }], + }, + { + code: `isNaN(Number(x));`, + output: `Number.isNaN(Number(x));`, + errors: [{ messageId: "preferNumberIsNaN" }], + }, + // Number.parseInt / Number.parseFloat + { + code: `isNaN(Number.parseInt(x, 10));`, + output: `Number.isNaN(Number.parseInt(x, 10));`, + errors: [{ messageId: "preferNumberIsNaN" }], + }, + { + code: `isNaN(Number.parseFloat(x));`, + output: `Number.isNaN(Number.parseFloat(x));`, + errors: [{ messageId: "preferNumberIsNaN" }], + }, + // Numeric literal + { + code: `isNaN(42);`, + output: `Number.isNaN(42);`, + errors: [{ messageId: "preferNumberIsNaN" }], + }, + ], + }); + }); }); diff --git a/eslint-factory/src/rules/prefer-number-isnan.ts b/eslint-factory/src/rules/prefer-number-isnan.ts index d6e55111379..85bfde34f60 100644 --- a/eslint-factory/src/rules/prefer-number-isnan.ts +++ b/eslint-factory/src/rules/prefer-number-isnan.ts @@ -2,11 +2,13 @@ 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"]); +const NUMERIC_CALL_NAMES = new Set(["parseInt", "parseFloat", "Number"]); export const preferNumberIsNanRule = createRule({ name: "prefer-number-isnan", meta: { type: "suggestion", + fixable: "code", hasSuggestions: true, docs: { description: "Prefer Number.isNaN() over global isNaN() to avoid coercion footguns when validating unknown inputs.", @@ -57,19 +59,65 @@ export const preferNumberIsNanRule = createRule({ return isDirectAccess || isComputedAccess; } + /** + * Returns true when the argument is provably already a number type, making + * isNaN(x) → Number.isNaN(x) a guaranteed semantics-preserving equivalence. + * + * Provably-numeric means: a numeric Literal, or a CallExpression to + * parseInt / parseFloat / Number / Number.parseInt / Number.parseFloat. + */ + function isProvablyNumeric(arg: TSESTree.Node): boolean { + if (arg.type === "Literal" && typeof arg.value === "number") { + return true; + } + if (arg.type === "CallExpression") { + const callee = arg.callee; + // parseInt(x), parseFloat(x), Number(x) + if (callee.type === "Identifier" && NUMERIC_CALL_NAMES.has(callee.name) && !hasLocalBinding(callee, callee.name)) { + return true; + } + // Number.parseInt(x), Number.parseFloat(x) + if ( + callee.type === "MemberExpression" && + !callee.computed && + callee.object.type === "Identifier" && + callee.object.name === "Number" && + !hasLocalBinding(callee.object, "Number") && + callee.property.type === "Identifier" && + (callee.property.name === "parseInt" || callee.property.name === "parseFloat") + ) { + return true; + } + } + return false; + } + 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"); - }, + const [arg] = node.arguments; + const provablyNumeric = arg !== undefined && arg.type !== "SpreadElement" && isProvablyNumeric(arg); + + if (provablyNumeric) { + context.report({ + node: node.callee, + messageId: "preferNumberIsNaN", + fix(fixer: TSESLint.RuleFixer) { + return fixer.replaceText(node.callee, "Number.isNaN"); }, - ], - }); + }); + } else { + context.report({ + node: node.callee, + messageId: "preferNumberIsNaN", + suggest: [ + { + messageId: "replaceWithNumberIsNaN", + fix(fixer: TSESLint.RuleFixer) { + return fixer.replaceText(node.callee, "Number.isNaN"); + }, + }, + ], + }); + } } return {