From 581785c6fd868889e9d67d97b79b6f2ffb844258 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:32:20 +0000 Subject: [PATCH 1/3] Initial plan From 0c729174df858aab84090d629c2c73f987aa785a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:43:32 +0000 Subject: [PATCH 2/3] fix(eslint-factory): autofix prefer-number-isnan for provably numeric args Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../src/rules/prefer-number-isnan.test.ts | 44 +++++++++---- .../src/rules/prefer-number-isnan.ts | 64 +++++++++++++++++-- 2 files changed, 91 insertions(+), 17 deletions(-) diff --git a/eslint-factory/src/rules/prefer-number-isnan.test.ts b/eslint-factory/src/rules/prefer-number-isnan.test.ts index e27bbb0fbde..20b62a9f3ff 100644 --- a/eslint-factory/src/rules/prefer-number-isnan.test.ts +++ b/eslint-factory/src/rules/prefer-number-isnan.test.ts @@ -55,18 +55,36 @@ describe("prefer-number-isnan", () => { }); }); - it("invalid: global isNaN() is flagged with a replacement suggestion", () => { + it("invalid: provably numeric arguments are autofixed", () => { cjsRuleTester.run("prefer-number-isnan", preferNumberIsNanRule, { valid: [], invalid: [ { - code: `isNaN(value);`, - errors: [{ messageId: "preferNumberIsNaN", suggestions: [{ messageId: "replaceWithNumberIsNaN", output: `Number.isNaN(value);` }] }], + code: `isNaN(parseInt(value, 10));`, + output: `Number.isNaN(parseInt(value, 10));`, + errors: [{ messageId: "preferNumberIsNaN" }], + }, + { + code: `isNaN(parseFloat(value));`, + output: `Number.isNaN(parseFloat(value));`, + errors: [{ messageId: "preferNumberIsNaN" }], }, { - // Raw string argument (e.g. env var) — suggestion preserves argument so callers must review whether to wrap with Number(...) + code: `isNaN(d.getTime());`, + output: `Number.isNaN(d.getTime());`, + errors: [{ messageId: "preferNumberIsNaN" }], + }, + ], + }); + }); + + it("invalid: unknown/raw arguments remain suggestion-only with coercion caveat", () => { + cjsRuleTester.run("prefer-number-isnan", preferNumberIsNanRule, { + valid: [], + invalid: [ + { code: `isNaN(process.env.PORT);`, - errors: [{ messageId: "preferNumberIsNaN", suggestions: [{ messageId: "replaceWithNumberIsNaN", output: `Number.isNaN(process.env.PORT);` }] }], + errors: [{ messageId: "preferNumberIsNaNWithCoercionCaveat", suggestions: [{ messageId: "replaceWithNumberIsNaNWithNumberWrapReview", output: `Number.isNaN(process.env.PORT);` }] }], }, ], }); @@ -78,27 +96,27 @@ describe("prefer-number-isnan", () => { invalid: [ { code: `globalThis.isNaN(value);`, - errors: [{ messageId: "preferNumberIsNaN", suggestions: [{ messageId: "replaceWithNumberIsNaN", output: `Number.isNaN(value);` }] }], + errors: [{ messageId: "preferNumberIsNaNWithCoercionCaveat", suggestions: [{ messageId: "replaceWithNumberIsNaNWithNumberWrapReview", output: `Number.isNaN(value);` }] }], }, { code: `globalThis["isNaN"](value);`, - errors: [{ messageId: "preferNumberIsNaN", suggestions: [{ messageId: "replaceWithNumberIsNaN", output: `Number.isNaN(value);` }] }], + errors: [{ messageId: "preferNumberIsNaNWithCoercionCaveat", suggestions: [{ messageId: "replaceWithNumberIsNaNWithNumberWrapReview", output: `Number.isNaN(value);` }] }], }, { code: `window.isNaN(value);`, - errors: [{ messageId: "preferNumberIsNaN", suggestions: [{ messageId: "replaceWithNumberIsNaN", output: `Number.isNaN(value);` }] }], + errors: [{ messageId: "preferNumberIsNaNWithCoercionCaveat", suggestions: [{ messageId: "replaceWithNumberIsNaNWithNumberWrapReview", output: `Number.isNaN(value);` }] }], }, { code: `window["isNaN"](value);`, - errors: [{ messageId: "preferNumberIsNaN", suggestions: [{ messageId: "replaceWithNumberIsNaN", output: `Number.isNaN(value);` }] }], + errors: [{ messageId: "preferNumberIsNaNWithCoercionCaveat", suggestions: [{ messageId: "replaceWithNumberIsNaNWithNumberWrapReview", output: `Number.isNaN(value);` }] }], }, { code: `global.isNaN(value);`, - errors: [{ messageId: "preferNumberIsNaN", suggestions: [{ messageId: "replaceWithNumberIsNaN", output: `Number.isNaN(value);` }] }], + errors: [{ messageId: "preferNumberIsNaNWithCoercionCaveat", suggestions: [{ messageId: "replaceWithNumberIsNaNWithNumberWrapReview", output: `Number.isNaN(value);` }] }], }, { code: `global["isNaN"](value);`, - errors: [{ messageId: "preferNumberIsNaN", suggestions: [{ messageId: "replaceWithNumberIsNaN", output: `Number.isNaN(value);` }] }], + errors: [{ messageId: "preferNumberIsNaNWithCoercionCaveat", suggestions: [{ messageId: "replaceWithNumberIsNaNWithNumberWrapReview", output: `Number.isNaN(value);` }] }], }, ], }); @@ -110,11 +128,11 @@ describe("prefer-number-isnan", () => { invalid: [ { code: `isNaN(value);`, - errors: [{ messageId: "preferNumberIsNaN", suggestions: [{ messageId: "replaceWithNumberIsNaN", output: `Number.isNaN(value);` }] }], + errors: [{ messageId: "preferNumberIsNaNWithCoercionCaveat", suggestions: [{ messageId: "replaceWithNumberIsNaNWithNumberWrapReview", output: `Number.isNaN(value);` }] }], }, { code: `window.isNaN(value);`, - errors: [{ messageId: "preferNumberIsNaN", suggestions: [{ messageId: "replaceWithNumberIsNaN", output: `Number.isNaN(value);` }] }], + errors: [{ messageId: "preferNumberIsNaNWithCoercionCaveat", suggestions: [{ messageId: "replaceWithNumberIsNaNWithNumberWrapReview", output: `Number.isNaN(value);` }] }], }, ], }); diff --git a/eslint-factory/src/rules/prefer-number-isnan.ts b/eslint-factory/src/rules/prefer-number-isnan.ts index 0589239b1d1..7ac0d95ec64 100644 --- a/eslint-factory/src/rules/prefer-number-isnan.ts +++ b/eslint-factory/src/rules/prefer-number-isnan.ts @@ -7,14 +7,17 @@ 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.", }, 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(...).", + preferNumberIsNaN: "Prefer Number.isNaN(...) over global isNaN(...).", + preferNumberIsNaNWithCoercionCaveat: "Prefer Number.isNaN(...) over global isNaN(...). Global isNaN() coerces non-number inputs and can hide invalid raw values.", + replaceWithNumberIsNaN: "Replace callee with Number.isNaN.", + replaceWithNumberIsNaNWithNumberWrapReview: "Replace callee with Number.isNaN — review whether the argument should be wrapped with Number(...).", }, }, defaultOptions: [], @@ -58,13 +61,66 @@ export const preferNumberIsNanRule = createRule({ return isDirectAccess || isComputedAccess; } + function isNumberParseMethod(node: TSESTree.MemberExpression): boolean { + if (node.object.type !== "Identifier" || node.object.name !== "Number") { + return false; + } + + const property = node.property; + const isDirectAccess = !node.computed && property.type === "Identifier" && (property.name === "parseInt" || property.name === "parseFloat"); + const isComputedAccess = node.computed && property.type === "Literal" && (property.value === "parseInt" || property.value === "parseFloat"); + + return isDirectAccess || isComputedAccess; + } + + function isNumericMethod(node: TSESTree.MemberExpression): boolean { + const property = node.property; + const isDirectAccess = !node.computed && property.type === "Identifier" && (property.name === "getTime" || property.name === "getTimezoneOffset" || property.name === "valueOf"); + const isComputedAccess = node.computed && property.type === "Literal" && (property.value === "getTime" || property.value === "getTimezoneOffset" || property.value === "valueOf"); + + return isDirectAccess || isComputedAccess; + } + + function isProvablyNumericArgument(node: TSESTree.CallExpressionArgument): boolean { + if (node.type === "Literal") { + return typeof node.value === "number"; + } + + if (node.type !== "CallExpression") { + return false; + } + + if (node.callee.type === "Identifier" && (node.callee.name === "parseInt" || node.callee.name === "parseFloat" || node.callee.name === "Number")) { + return true; + } + + if (node.callee.type === "MemberExpression" && (isNumberParseMethod(node.callee) || isNumericMethod(node.callee))) { + return true; + } + + return false; + } + function report(node: TSESTree.CallExpression): void { + const hasSingleProvablyNumericArgument = node.arguments.length === 1 && isProvablyNumericArgument(node.arguments[0]); + + if (hasSingleProvablyNumericArgument) { + context.report({ + node: node.callee, + messageId: "preferNumberIsNaN", + fix(fixer: TSESLint.RuleFixer) { + return fixer.replaceText(node.callee, "Number.isNaN"); + }, + }); + return; + } + context.report({ node: node.callee, - messageId: "preferNumberIsNaN", + messageId: "preferNumberIsNaNWithCoercionCaveat", suggest: [ { - messageId: "replaceWithNumberIsNaN", + messageId: "replaceWithNumberIsNaNWithNumberWrapReview", fix(fixer: TSESLint.RuleFixer) { return fixer.replaceText(node.callee, "Number.isNaN"); }, From 37862f63f701157e09093ceac4e70b4ce1828783 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:28:40 +0000 Subject: [PATCH 3/3] fix(eslint-factory): scope-check shadowed names and remove method-call autofix in prefer-number-isnan Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- .../src/rules/prefer-number-isnan.test.ts | 47 ++++++++++++++++++- .../src/rules/prefer-number-isnan.ts | 23 +++++---- 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/eslint-factory/src/rules/prefer-number-isnan.test.ts b/eslint-factory/src/rules/prefer-number-isnan.test.ts index 20b62a9f3ff..8c49339042a 100644 --- a/eslint-factory/src/rules/prefer-number-isnan.test.ts +++ b/eslint-factory/src/rules/prefer-number-isnan.test.ts @@ -70,10 +70,53 @@ describe("prefer-number-isnan", () => { errors: [{ messageId: "preferNumberIsNaN" }], }, { - code: `isNaN(d.getTime());`, - output: `Number.isNaN(d.getTime());`, + code: `isNaN(Number(value));`, + output: `Number.isNaN(Number(value));`, + errors: [{ messageId: "preferNumberIsNaN" }], + }, + { + code: `isNaN(Number.parseInt(value, 10));`, + output: `Number.isNaN(Number.parseInt(value, 10));`, errors: [{ messageId: "preferNumberIsNaN" }], }, + { + code: `isNaN(42);`, + output: `Number.isNaN(42);`, + errors: [{ messageId: "preferNumberIsNaN" }], + }, + ], + }); + }); + + it("valid: method calls on arbitrary receivers are not treated as provably numeric", () => { + cjsRuleTester.run("prefer-number-isnan", preferNumberIsNanRule, { + valid: [], + invalid: [ + // getTime/valueOf can be defined on any object; must remain suggestion-only + { + code: `isNaN(d.getTime());`, + errors: [{ messageId: "preferNumberIsNaNWithCoercionCaveat", suggestions: [{ messageId: "replaceWithNumberIsNaNWithNumberWrapReview", output: `Number.isNaN(d.getTime());` }] }], + }, + { + code: `isNaN(x.valueOf());`, + errors: [{ messageId: "preferNumberIsNaNWithCoercionCaveat", suggestions: [{ messageId: "replaceWithNumberIsNaNWithNumberWrapReview", output: `Number.isNaN(x.valueOf());` }] }], + }, + ], + }); + }); + + it("invalid: shadowed parseInt/parseFloat/Number fall back to suggestion-only", () => { + esmRuleTester.run("prefer-number-isnan", preferNumberIsNanRule, { + valid: [], + invalid: [ + { + code: `function parseInt() { return "x"; } isNaN(parseInt(v));`, + errors: [{ messageId: "preferNumberIsNaNWithCoercionCaveat", suggestions: [{ messageId: "replaceWithNumberIsNaNWithNumberWrapReview", output: `function parseInt() { return "x"; } Number.isNaN(parseInt(v));` }] }], + }, + { + code: `const Number = {}; isNaN(0);`, + errors: [{ messageId: "preferNumberIsNaNWithCoercionCaveat", suggestions: [{ messageId: "replaceWithNumberIsNaNWithNumberWrapReview", output: `const Number = {}; Number.isNaN(0);` }] }], + }, ], }); }); diff --git a/eslint-factory/src/rules/prefer-number-isnan.ts b/eslint-factory/src/rules/prefer-number-isnan.ts index 7ac0d95ec64..12e4bc710be 100644 --- a/eslint-factory/src/rules/prefer-number-isnan.ts +++ b/eslint-factory/src/rules/prefer-number-isnan.ts @@ -73,14 +73,6 @@ export const preferNumberIsNanRule = createRule({ return isDirectAccess || isComputedAccess; } - function isNumericMethod(node: TSESTree.MemberExpression): boolean { - const property = node.property; - const isDirectAccess = !node.computed && property.type === "Identifier" && (property.name === "getTime" || property.name === "getTimezoneOffset" || property.name === "valueOf"); - const isComputedAccess = node.computed && property.type === "Literal" && (property.value === "getTime" || property.value === "getTimezoneOffset" || property.value === "valueOf"); - - return isDirectAccess || isComputedAccess; - } - function isProvablyNumericArgument(node: TSESTree.CallExpressionArgument): boolean { if (node.type === "Literal") { return typeof node.value === "number"; @@ -90,21 +82,28 @@ export const preferNumberIsNanRule = createRule({ return false; } - if (node.callee.type === "Identifier" && (node.callee.name === "parseInt" || node.callee.name === "parseFloat" || node.callee.name === "Number")) { - return true; + // Unqualified global functions are only provably numeric when unshadowed. + if (node.callee.type === "Identifier") { + const name = node.callee.name; + + return (name === "parseInt" || name === "parseFloat" || name === "Number") && !hasLocalBinding(node, name); } - if (node.callee.type === "MemberExpression" && (isNumberParseMethod(node.callee) || isNumericMethod(node.callee))) { + // Number.parseInt / Number.parseFloat are only provably numeric when Number is unshadowed. + if (node.callee.type === "MemberExpression" && isNumberParseMethod(node.callee) && !hasLocalBinding(node, "Number")) { return true; } + // Arbitrary method calls (getTime, valueOf, etc.) are not provably numeric — + // the receiver type is unknown and the method can be overridden to return anything. return false; } function report(node: TSESTree.CallExpression): void { + const numberUnshadowed = !hasLocalBinding(node, "Number"); const hasSingleProvablyNumericArgument = node.arguments.length === 1 && isProvablyNumericArgument(node.arguments[0]); - if (hasSingleProvablyNumericArgument) { + if (hasSingleProvablyNumericArgument && numberUnshadowed) { context.report({ node: node.callee, messageId: "preferNumberIsNaN",