diff --git a/eslint-factory/eslint.config.cjs b/eslint-factory/eslint.config.cjs index 4cf8866b3ad..fc15687a417 100644 --- a/eslint-factory/eslint.config.cjs +++ b/eslint-factory/eslint.config.cjs @@ -56,6 +56,7 @@ module.exports = [ "gh-aw-custom/no-setfailed-then-exit-zero": "warn", "gh-aw-custom/no-err-stack-then-string-fallback": "warn", "gh-aw-custom/no-caught-error-interpolation": "warn", + "gh-aw-custom/no-map-parseint-callback": "warn", "gh-aw-custom/require-fetch-try-catch": "warn", "gh-aw-custom/no-core-error-then-setfailed": "warn", "gh-aw-custom/no-duplicate-constant-values": "warn", diff --git a/eslint-factory/src/index.ts b/eslint-factory/src/index.ts index 15214be24f0..2879a281de5 100644 --- a/eslint-factory/src/index.ts +++ b/eslint-factory/src/index.ts @@ -11,6 +11,7 @@ import { noUnsafePromiseCatchErrorPropertyRule } from "./rules/no-unsafe-promise import { preferGetErrorMessageRule } from "./rules/prefer-get-error-message"; import { preferGetErrorMessageOverStringRule } from "./rules/prefer-get-error-message-over-string"; import { preferNumberIsNanRule } from "./rules/prefer-number-isnan"; +import { noMapParseIntCallbackRule } from "./rules/no-map-parseint-callback"; import { requireAsyncEntrypointCatchRule } from "./rules/require-async-entrypoint-catch"; import { requireAwaitCoreSummaryWriteRule } from "./rules/require-await-core-summary-write"; import { requireFsCloseSyncRule } from "./rules/require-fs-close-sync"; @@ -86,6 +87,7 @@ const plugin = { "prefer-get-error-message": preferGetErrorMessageRule, "prefer-get-error-message-over-string": preferGetErrorMessageOverStringRule, "prefer-number-isnan": preferNumberIsNanRule, + "no-map-parseint-callback": noMapParseIntCallbackRule, "require-async-entrypoint-catch": requireAsyncEntrypointCatchRule, "require-await-core-summary-write": requireAwaitCoreSummaryWriteRule, "require-fs-close-sync": requireFsCloseSyncRule, diff --git a/eslint-factory/src/rules/no-map-parseint-callback.test.ts b/eslint-factory/src/rules/no-map-parseint-callback.test.ts new file mode 100644 index 00000000000..4c96c4d15c8 --- /dev/null +++ b/eslint-factory/src/rules/no-map-parseint-callback.test.ts @@ -0,0 +1,22 @@ +import { RuleTester } from "eslint"; +import { describe, expect, it } from "vitest"; +import { noMapParseIntCallbackRule } from "./no-map-parseint-callback"; + +const ruleTester = new RuleTester({ languageOptions: { ecmaVersion: 2022, sourceType: "commonjs" } }); + +describe("no-map-parseint-callback", () => { + it("uses the correct docs URL", () => { + expect(noMapParseIntCallbackRule.meta.docs.url).toBe("https://github.com/github/gh-aw/tree/main/eslint-factory#no-map-parseint-callback"); + }); + + it("validates map(parseInt) usage", () => { + ruleTester.run("no-map-parseint-callback", noMapParseIntCallbackRule, { + valid: [`parts.map(Number);`, `parts.map(v => parseInt(v, 10));`, `parts.map(parseFloat);`, `function f(parseInt) { return a.map(parseInt); }`, `a.forEach(parseInt);`], + invalid: [ + { code: `parts.map(parseInt);`, errors: [{ messageId: "noMapParseInt" }] }, + { code: `parts.map(Number.parseInt);`, errors: [{ messageId: "noMapParseInt" }] }, + { code: `s.split(".").map(parseInt, null);`, errors: [{ messageId: "noMapParseInt" }] }, + ], + }); + }); +}); diff --git a/eslint-factory/src/rules/no-map-parseint-callback.ts b/eslint-factory/src/rules/no-map-parseint-callback.ts new file mode 100644 index 00000000000..d239ac7636b --- /dev/null +++ b/eslint-factory/src/rules/no-map-parseint-callback.ts @@ -0,0 +1,53 @@ +import { ESLintUtils, TSESTree } from "@typescript-eslint/utils"; + +const createRule = ESLintUtils.RuleCreator(name => `https://github.com/github/gh-aw/tree/main/eslint-factory#${name}`); + +export const noMapParseIntCallbackRule = createRule({ + name: "no-map-parseint-callback", + meta: { + type: "problem", + docs: { + description: "Disallow passing parseInt directly as an array callback, because the element index is used as the radix.", + }, + schema: [], + messages: { + noMapParseInt: 'Do not pass parseInt directly to .map(); the index becomes the radix (["10","10","10"].map(parseInt) yields [10, NaN, 2]). Use .map(value => parseInt(value, 10)) or .map(Number).', + }, + }, + defaultOptions: [], + create(context) { + const sourceCode = context.sourceCode; + + function isShadowed(node: TSESTree.Node, name: string): boolean { + let scope: ReturnType | null = sourceCode.getScope(node); + while (scope) { + const variable = scope.set.get(name); + if (variable && variable.defs.length > 0) { + return true; + } + scope = scope.upper; + } + return false; + } + + function isParseInt(node: TSESTree.Node): boolean { + if (node.type === "Identifier") { + return node.name === "parseInt" && !isShadowed(node, "parseInt"); + } + return node.type === "MemberExpression" && !node.computed && node.object.type === "Identifier" && node.object.name === "Number" && !isShadowed(node, "Number") && node.property.type === "Identifier" && node.property.name === "parseInt"; + } + + return { + CallExpression(node) { + const callee = node.callee; + if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier" || callee.property.name !== "map") { + return; + } + const arg = node.arguments[0]; + if (arg && isParseInt(arg)) { + context.report({ node: arg, messageId: "noMapParseInt" }); + } + }, + }; + }, +});