From a8e9dc6506dff6b738c13c6e23d17b90e06ec777 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 09:17:05 +0000 Subject: [PATCH 1/3] Initial plan From c87c5494d73c9fbab22ba107d59b8778225152a7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 09:33:30 +0000 Subject: [PATCH 2/3] feat(eslint): prefer-number-isnan autofix for provably-numeric args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add isProvablyNumeric() to detect parseInt/parseFloat/Number/ Number.parseInt/Number.parseFloat calls, getTime/getTimezoneOffset/ valueOf method calls, and numeric literals as provably-numeric args - Add fixable: "code" to rule meta so --fix can apply the replacement - Branch report(): provably-numeric → real fix (no misleading caveat); unknown arg → unchanged suggest-only + "review Number(...)" caveat - Add 9 test cases asserting autofix output for all provably-numeric patterns; existing suggestion-only tests unchanged Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../src/rules/prefer-number-isnan.test.ts | 57 ++++++++++++++ .../src/rules/prefer-number-isnan.ts | 75 ++++++++++++++++--- 2 files changed, 121 insertions(+), 11 deletions(-) diff --git a/eslint-factory/src/rules/prefer-number-isnan.test.ts b/eslint-factory/src/rules/prefer-number-isnan.test.ts index 080aef770fe..8d5da272f79 100644 --- a/eslint-factory/src/rules/prefer-number-isnan.test.ts +++ b/eslint-factory/src/rules/prefer-number-isnan.test.ts @@ -99,4 +99,61 @@ 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" }], + }, + // Date method results + { + code: `isNaN(d.getTime());`, + output: `Number.isNaN(d.getTime());`, + errors: [{ messageId: "preferNumberIsNaN" }], + }, + { + code: `isNaN(d.getTimezoneOffset());`, + output: `Number.isNaN(d.getTimezoneOffset());`, + errors: [{ messageId: "preferNumberIsNaN" }], + }, + { + code: `isNaN(x.valueOf());`, + output: `Number.isNaN(x.valueOf());`, + 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..f5717e19fe3 100644 --- a/eslint-factory/src/rules/prefer-number-isnan.ts +++ b/eslint-factory/src/rules/prefer-number-isnan.ts @@ -2,11 +2,14 @@ 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"]); +const NUMERIC_METHOD_NAMES = new Set(["getTime", "getTimezoneOffset", "valueOf"]); 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 +60,69 @@ 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, or a + * CallExpression whose callee property is getTime / getTimezoneOffset / valueOf. + */ + 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)) { + return true; + } + // Number.parseInt(x), Number.parseFloat(x) + if ( + callee.type === "MemberExpression" && + !callee.computed && + callee.object.type === "Identifier" && + callee.object.name === "Number" && + callee.property.type === "Identifier" && + (callee.property.name === "parseInt" || callee.property.name === "parseFloat") + ) { + return true; + } + // x.getTime(), x.getTimezoneOffset(), x.valueOf() + if (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" && NUMERIC_METHOD_NAMES.has(callee.property.name)) { + 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 { From 308d3b1ca2d9778dd4efaffdc693e797f151d449 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:15:25 +0000 Subject: [PATCH 3/3] fix(eslint): tighten prefer-number-isnan autofix safety Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- .../src/rules/prefer-number-isnan.test.ts | 31 +++++++++---------- .../src/rules/prefer-number-isnan.ts | 11 ++----- 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/eslint-factory/src/rules/prefer-number-isnan.test.ts b/eslint-factory/src/rules/prefer-number-isnan.test.ts index 8d5da272f79..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));` }] }], + }, ], }); }); @@ -131,22 +146,6 @@ describe("prefer-number-isnan", () => { output: `Number.isNaN(Number.parseFloat(x));`, errors: [{ messageId: "preferNumberIsNaN" }], }, - // Date method results - { - code: `isNaN(d.getTime());`, - output: `Number.isNaN(d.getTime());`, - errors: [{ messageId: "preferNumberIsNaN" }], - }, - { - code: `isNaN(d.getTimezoneOffset());`, - output: `Number.isNaN(d.getTimezoneOffset());`, - errors: [{ messageId: "preferNumberIsNaN" }], - }, - { - code: `isNaN(x.valueOf());`, - output: `Number.isNaN(x.valueOf());`, - errors: [{ messageId: "preferNumberIsNaN" }], - }, // Numeric literal { code: `isNaN(42);`, diff --git a/eslint-factory/src/rules/prefer-number-isnan.ts b/eslint-factory/src/rules/prefer-number-isnan.ts index f5717e19fe3..85bfde34f60 100644 --- a/eslint-factory/src/rules/prefer-number-isnan.ts +++ b/eslint-factory/src/rules/prefer-number-isnan.ts @@ -3,7 +3,6 @@ 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"]); -const NUMERIC_METHOD_NAMES = new Set(["getTime", "getTimezoneOffset", "valueOf"]); export const preferNumberIsNanRule = createRule({ name: "prefer-number-isnan", @@ -65,8 +64,7 @@ export const preferNumberIsNanRule = createRule({ * 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, or a - * CallExpression whose callee property is getTime / getTimezoneOffset / valueOf. + * parseInt / parseFloat / Number / Number.parseInt / Number.parseFloat. */ function isProvablyNumeric(arg: TSESTree.Node): boolean { if (arg.type === "Literal" && typeof arg.value === "number") { @@ -75,7 +73,7 @@ export const preferNumberIsNanRule = createRule({ if (arg.type === "CallExpression") { const callee = arg.callee; // parseInt(x), parseFloat(x), Number(x) - if (callee.type === "Identifier" && NUMERIC_CALL_NAMES.has(callee.name)) { + if (callee.type === "Identifier" && NUMERIC_CALL_NAMES.has(callee.name) && !hasLocalBinding(callee, callee.name)) { return true; } // Number.parseInt(x), Number.parseFloat(x) @@ -84,15 +82,12 @@ export const preferNumberIsNanRule = createRule({ !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; } - // x.getTime(), x.getTimezoneOffset(), x.valueOf() - if (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" && NUMERIC_METHOD_NAMES.has(callee.property.name)) { - return true; - } } return false; }