From 40454f197046ee4619a4360b90836fec8e24fb97 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:06:02 +0000 Subject: [PATCH] Add require-finite-check-before-date-toisostring ESLint rule Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eslint-factory/eslint.config.cjs | 1 + eslint-factory/src/index.ts | 2 + ...nite-check-before-date-toisostring.test.ts | 33 ++++ ...re-finite-check-before-date-toisostring.ts | 142 ++++++++++++++++++ 4 files changed, 178 insertions(+) create mode 100644 eslint-factory/src/rules/require-finite-check-before-date-toisostring.test.ts create mode 100644 eslint-factory/src/rules/require-finite-check-before-date-toisostring.ts diff --git a/eslint-factory/eslint.config.cjs b/eslint-factory/eslint.config.cjs index a4a7c16e3ed..720d1063307 100644 --- a/eslint-factory/eslint.config.cjs +++ b/eslint-factory/eslint.config.cjs @@ -77,6 +77,7 @@ module.exports = [ "gh-aw-custom/require-getexecoutput-exitcode-check": "warn", "gh-aw-custom/prefer-actions-exec-over-child-process": "warn", "gh-aw-custom/no-misplaced-error-code-definition": "warn", + "gh-aw-custom/require-finite-check-before-date-toisostring": "warn", }, }, { diff --git a/eslint-factory/src/index.ts b/eslint-factory/src/index.ts index d8093a1f9b1..7d67018da6a 100644 --- a/eslint-factory/src/index.ts +++ b/eslint-factory/src/index.ts @@ -64,6 +64,7 @@ import { requireFsChmodTryCatchRule } from "./rules/require-fs-chmod-try-catch"; import { noSingleCharStringReplaceRule } from "./rules/no-single-char-string-replace"; import { noAsyncForEachCallbackRule } from "./rules/no-async-foreach-callback"; import { noUnguardedAsyncTimerCallbackRule } from "./rules/no-unguarded-async-timer-callback"; +import { requireFiniteCheckBeforeDateToIsoStringRule } from "./rules/require-finite-check-before-date-toisostring"; const plugin = { meta: { @@ -136,6 +137,7 @@ const plugin = { "no-single-char-string-replace": noSingleCharStringReplaceRule, "no-unguarded-async-timer-callback": noUnguardedAsyncTimerCallbackRule, "no-async-foreach-callback": noAsyncForEachCallbackRule, + "require-finite-check-before-date-toisostring": requireFiniteCheckBeforeDateToIsoStringRule, }, }; diff --git a/eslint-factory/src/rules/require-finite-check-before-date-toisostring.test.ts b/eslint-factory/src/rules/require-finite-check-before-date-toisostring.test.ts new file mode 100644 index 00000000000..d0df8236803 --- /dev/null +++ b/eslint-factory/src/rules/require-finite-check-before-date-toisostring.test.ts @@ -0,0 +1,33 @@ +import { RuleTester } from "eslint"; +import { describe, expect, it } from "vitest"; +import { requireFiniteCheckBeforeDateToIsoStringRule } from "./require-finite-check-before-date-toisostring"; + +const ruleTester = new RuleTester({ languageOptions: { ecmaVersion: 2022, sourceType: "commonjs" } }); + +describe("require-finite-check-before-date-toisostring", () => { + it("uses the correct docs URL", () => { + expect(requireFiniteCheckBeforeDateToIsoStringRule.meta.docs.url).toBe("https://github.com/github/gh-aw/tree/main/eslint-factory#require-finite-check-before-date-toisostring"); + }); + + it("valid and invalid cases", () => { + ruleTester.run("require-finite-check-before-date-toisostring", requireFiniteCheckBeforeDateToIsoStringRule, { + valid: [ + `new Date().toISOString();`, + `new Date(Date.now()).toISOString();`, + `new Date(Date.now() - WINDOW_MS).toISOString();`, + `new Date(0).toISOString();`, + `function f(ms) { if (!Number.isFinite(ms)) return ""; return new Date(ms).toISOString(); }`, + `function f(data) { if (Number.isNaN(data.reset)) return null; return new Date(data.reset * 1000).toISOString(); }`, + `function f(ms) { try { return new Date(ms).toISOString(); } catch { return ""; } }`, + `const d = new Date(ms); d.toISOString();`, + `new Date(ms).getTime();`, + ], + invalid: [ + { code: `function f(ms) { return new Date(ms).toISOString(); }`, errors: [{ messageId: "requireFiniteCheck" }] }, + { code: `const s = new Date(data.reset * 1000).toISOString();`, errors: [{ messageId: "requireFiniteCheck" }] }, + { code: `function f(ms) { try { x(); } catch { return new Date(ms).toJSON(); } }`, errors: [{ messageId: "requireFiniteCheck" }] }, + { code: `function f(a, b) { if (Number.isFinite(b)) {} return new Date(a).toISOString(); }`, errors: [{ messageId: "requireFiniteCheck" }] }, + ], + }); + }); +}); diff --git a/eslint-factory/src/rules/require-finite-check-before-date-toisostring.ts b/eslint-factory/src/rules/require-finite-check-before-date-toisostring.ts new file mode 100644 index 00000000000..6309e199428 --- /dev/null +++ b/eslint-factory/src/rules/require-finite-check-before-date-toisostring.ts @@ -0,0 +1,142 @@ +import { AST_NODE_TYPES, ESLintUtils, TSESTree } from "@typescript-eslint/utils"; + +const createRule = ESLintUtils.RuleCreator(name => `https://github.com/github/gh-aw/tree/main/eslint-factory#${name}`); + +const SERIALIZERS = new Set(["toISOString", "toJSON"]); +const GUARD_NAMES = new Set(["isNaN", "isFinite"]); + +function isDateNowCall(node: TSESTree.Node): boolean { + return ( + node.type === AST_NODE_TYPES.CallExpression && + node.callee.type === AST_NODE_TYPES.MemberExpression && + !node.callee.computed && + node.callee.object.type === AST_NODE_TYPES.Identifier && + node.callee.object.name === "Date" && + node.callee.property.type === AST_NODE_TYPES.Identifier && + node.callee.property.name === "now" + ); +} + +/** True for expressions that cannot yield an invalid time value: literals, Date.now(), and arithmetic over those and UPPER_CASE constants. */ +function isTriviallyValidTime(node: TSESTree.Node): boolean { + if (node.type === AST_NODE_TYPES.Literal) return typeof node.value === "number"; + if (isDateNowCall(node)) return true; + if (node.type === AST_NODE_TYPES.Identifier) return /^[A-Z][A-Z0-9_]*$/.test(node.name); + if (node.type === AST_NODE_TYPES.BinaryExpression && ["+", "-", "*", "/"].includes(node.operator)) { + return isTriviallyValidTime(node.left) && isTriviallyValidTime(node.right); + } + return false; +} + +function collectIdentifierNames(node: TSESTree.Node, names: Set): void { + if (node.type === AST_NODE_TYPES.Identifier) names.add(node.name); + else if (node.type === AST_NODE_TYPES.MemberExpression) { + collectIdentifierNames(node.object, names); + } else if (node.type === AST_NODE_TYPES.BinaryExpression || node.type === AST_NODE_TYPES.LogicalExpression) { + collectIdentifierNames(node.left, names); + collectIdentifierNames(node.right, names); + } else if (node.type === AST_NODE_TYPES.CallExpression) { + node.arguments.forEach(arg => collectIdentifierNames(arg, names)); + } else if (node.type === AST_NODE_TYPES.ChainExpression) { + collectIdentifierNames(node.expression, names); + } +} + +function isGuardCall(node: TSESTree.CallExpression): boolean { + const { callee } = node; + if (callee.type === AST_NODE_TYPES.Identifier) return GUARD_NAMES.has(callee.name); + return ( + callee.type === AST_NODE_TYPES.MemberExpression && + !callee.computed && + callee.object.type === AST_NODE_TYPES.Identifier && + callee.object.name === "Number" && + callee.property.type === AST_NODE_TYPES.Identifier && + GUARD_NAMES.has(callee.property.name) + ); +} + +export const requireFiniteCheckBeforeDateToIsoStringRule = createRule({ + name: "require-finite-check-before-date-toisostring", + meta: { + type: "problem", + docs: { + description: "Require dynamic values passed to new Date(...) to be validated before calling toISOString()/toJSON(), which throw RangeError for invalid dates.", + }, + schema: [], + messages: { + requireFiniteCheck: "new Date({{arg}}).{{method}}() throws RangeError when the value is NaN, out of range, or an unparseable string. Validate the input with Number.isFinite()/Number.isNaN() or wrap the call in try/catch.", + }, + }, + defaultOptions: [], + create(context) { + const sourceCode = context.sourceCode; + + function isInsideTryBlock(node: TSESTree.Node): boolean { + const ancestors = sourceCode.getAncestors(node); + for (let i = ancestors.length - 1; i >= 0; i--) { + const a = ancestors[i]; + if (a.type === AST_NODE_TYPES.FunctionDeclaration || a.type === AST_NODE_TYPES.FunctionExpression || a.type === AST_NODE_TYPES.ArrowFunctionExpression) return false; + if (a.type === AST_NODE_TYPES.TryStatement && a.handler != null) { + const child = ancestors[i + 1] ?? node; + if (child === a.block) return true; + } + } + return false; + } + + function enclosingScopeNode(node: TSESTree.Node): TSESTree.Node { + const ancestors = sourceCode.getAncestors(node); + for (let i = ancestors.length - 1; i >= 0; i--) { + const a = ancestors[i]; + if (a.type === AST_NODE_TYPES.FunctionDeclaration || a.type === AST_NODE_TYPES.FunctionExpression || a.type === AST_NODE_TYPES.ArrowFunctionExpression) return a; + } + return ancestors[0]; + } + + /** True when the enclosing function contains a finite/NaN check involving any identifier used in the date argument. */ + function hasGuard(node: TSESTree.Node, arg: TSESTree.Node): boolean { + const argNames = new Set(); + collectIdentifierNames(arg, argNames); + if (argNames.size === 0) return false; + let found = false; + const visit = (n: TSESTree.Node): void => { + if (found) return; + if (n.type === AST_NODE_TYPES.CallExpression && isGuardCall(n)) { + const names = new Set(); + n.arguments.forEach(a => collectIdentifierNames(a, names)); + for (const name of names) { + if (argNames.has(name)) { + found = true; + return; + } + } + } + for (const key of Object.keys(n) as Array) { + if (key === "parent") continue; + const value = n[key] as unknown; + if (Array.isArray(value)) value.forEach(v => v && typeof (v as TSESTree.Node).type === "string" && visit(v as TSESTree.Node)); + else if (value && typeof (value as TSESTree.Node).type === "string") visit(value as TSESTree.Node); + } + }; + visit(enclosingScopeNode(node)); + return found; + } + + return { + CallExpression(node: TSESTree.CallExpression) { + const callee = node.callee; + if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.computed || callee.property.type !== AST_NODE_TYPES.Identifier || !SERIALIZERS.has(callee.property.name)) return; + const receiver = callee.object; + if (receiver.type !== AST_NODE_TYPES.NewExpression || receiver.callee.type !== AST_NODE_TYPES.Identifier || receiver.callee.name !== "Date") return; + const arg = receiver.arguments[0]; + if (!arg || arg.type === AST_NODE_TYPES.SpreadElement || isTriviallyValidTime(arg)) return; + if (isInsideTryBlock(node) || hasGuard(node, arg)) return; + context.report({ + node: receiver, + messageId: "requireFiniteCheck", + data: { arg: sourceCode.getText(arg), method: callee.property.name }, + }); + }, + }; + }, +});