Skip to content
Closed
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
56 changes: 56 additions & 0 deletions eslint-factory/src/rules/prefer-number-isnan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));` }] }],
},
],
});
});
Expand Down Expand Up @@ -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" }],
},
],
});
});
});
70 changes: 59 additions & 11 deletions eslint-factory/src/rules/prefer-number-isnan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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 {
Expand Down