diff --git a/eslint-factory/src/rules/require-async-entrypoint-catch.test.ts b/eslint-factory/src/rules/require-async-entrypoint-catch.test.ts index 6990657dda4..cbc2829e867 100644 --- a/eslint-factory/src/rules/require-async-entrypoint-catch.test.ts +++ b/eslint-factory/src/rules/require-async-entrypoint-catch.test.ts @@ -9,6 +9,13 @@ const cjsRuleTester = new RuleTester({ }, }); +const esmRuleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + }, +}); + describe("require-async-entrypoint-catch", () => { it("uses the correct docs URL", () => { expect(requireAsyncEntrypointCatchRule.meta.docs.url).toBe("https://github.com/github/gh-aw/tree/main/eslint-factory#require-async-entrypoint-catch"); @@ -33,6 +40,26 @@ if (require.main === module) { main().catch(err => { console.error(err); process `async function main() { return 42; } main().catch(err => { process.exit(1); });`, + + // async arrow function with .catch() is valid + `const main = async () => { return 42; } +main().catch(err => { console.error(err); process.exitCode = 1; });`, + + // async function expression with .catch() is valid + `const run = async function() { return 42; } +run().catch(err => { process.exit(1); });`, + + // .then().catch() chain is valid + `const main = async () => {}; +main().then(() => process.exit(0)).catch(err => { console.error(err); process.exitCode = 1; });`, + + // .catch().finally() chain is valid + `const main = async () => {}; +main().catch(err => { console.error(err); process.exitCode = 1; }).finally(() => process.exit(0));`, + + // .then().catch().finally() chain is valid + `const main = async () => {}; +main().then(() => process.exit(0)).catch(err => { console.error(err); process.exitCode = 1; }).finally(() => process.exit(0));`, ], invalid: [], }); @@ -56,6 +83,25 @@ async function wrapper() { main().catch(err => { console.error(err); process.exitCode = 1; }); } }`, + + // async arrow awaited inside async context + `const main = async () => { return 42; } +(async () => { await main(); })();`, + ], + invalid: [], + }); + }); + + it("valid: sync arrow or non-async fn-expression call is not flagged", () => { + cjsRuleTester.run("require-async-entrypoint-catch", requireAsyncEntrypointCatchRule, { + valid: [ + // sync arrow — not async, so no unhandled rejection risk + `const main = () => { return 42; } +main();`, + + // sync function expression + `const run = function() { return 42; } +run();`, ], invalid: [], }); @@ -231,4 +277,138 @@ main().catch(err => { console.error(err); process.exitCode = 1; });`, ], }); }); + + it("invalid: bare call to async arrow function is flagged", () => { + cjsRuleTester.run("require-async-entrypoint-catch", requireAsyncEntrypointCatchRule, { + valid: [], + invalid: [ + { + code: `const main = async () => { return 42; } +main();`, + errors: [ + { + messageId: "requireCatch", + data: { name: "main" }, + suggestions: [ + { + messageId: "addCatch", + output: `const main = async () => { return 42; } +main().catch(err => { console.error(err); process.exitCode = 1; });`, + }, + ], + }, + ], + }, + { + code: `const main = async () => {}; +if (require.main === module) { main(); }`, + errors: [ + { + messageId: "requireCatch", + data: { name: "main" }, + suggestions: [ + { + messageId: "addCatch", + output: `const main = async () => {}; +if (require.main === module) { main().catch(err => { console.error(err); process.exitCode = 1; }); }`, + }, + ], + }, + ], + }, + ], + }); + }); + + it("invalid: bare call to async function expression is flagged", () => { + cjsRuleTester.run("require-async-entrypoint-catch", requireAsyncEntrypointCatchRule, { + valid: [], + invalid: [ + { + code: `const run = async function() { return 42; } +run();`, + errors: [ + { + messageId: "requireCatch", + data: { name: "run" }, + suggestions: [ + { + messageId: "addCatch", + output: `const run = async function() { return 42; } +run().catch(err => { console.error(err); process.exitCode = 1; });`, + }, + ], + }, + ], + }, + ], + }); + }); + + it("invalid: bare call to exported async arrow function is flagged", () => { + esmRuleTester.run("require-async-entrypoint-catch", requireAsyncEntrypointCatchRule, { + valid: [], + invalid: [ + { + code: `export const main = async () => { return 42; } +main();`, + errors: [ + { + messageId: "requireCatch", + data: { name: "main" }, + suggestions: [ + { + messageId: "addCatch", + output: `export const main = async () => { return 42; } +main().catch(err => { console.error(err); process.exitCode = 1; });`, + }, + ], + }, + ], + }, + ], + }); + }); + + it("invalid: .then() chain without .catch() on async function is flagged", () => { + cjsRuleTester.run("require-async-entrypoint-catch", requireAsyncEntrypointCatchRule, { + valid: [], + invalid: [ + { + code: `async function main() {} +main().then(() => process.exit(0));`, + errors: [ + { + messageId: "requireCatch", + data: { name: "main" }, + suggestions: [ + { + messageId: "addCatch", + output: `async function main() {} +main().then(() => process.exit(0)).catch(err => { console.error(err); process.exitCode = 1; });`, + }, + ], + }, + ], + }, + { + code: `const main = async () => {}; +main().then(() => process.exit(0));`, + errors: [ + { + messageId: "requireCatch", + data: { name: "main" }, + suggestions: [ + { + messageId: "addCatch", + output: `const main = async () => {}; +main().then(() => process.exit(0)).catch(err => { console.error(err); process.exitCode = 1; });`, + }, + ], + }, + ], + }, + ], + }); + }); }); diff --git a/eslint-factory/src/rules/require-async-entrypoint-catch.ts b/eslint-factory/src/rules/require-async-entrypoint-catch.ts index 41c8dfee09d..809f4d09ed8 100644 --- a/eslint-factory/src/rules/require-async-entrypoint-catch.ts +++ b/eslint-factory/src/rules/require-async-entrypoint-catch.ts @@ -8,15 +8,65 @@ type FunctionDeclarationDefinition = TSESLint.Scope.Definition & { type: "FunctionName"; node: TSESTree.FunctionDeclaration; }; +type VariableDefinition = TSESLint.Scope.Definition & { + type: "Variable"; + node: TSESTree.VariableDeclarator; +}; function isAsyncFuncNode(node: TSESTree.Node): node is AsyncFuncNode { return node.type === AST_NODE_TYPES.FunctionDeclaration || node.type === AST_NODE_TYPES.FunctionExpression || node.type === AST_NODE_TYPES.ArrowFunctionExpression; } +/** Returns true if any call in the chain is `.catch(...)`. */ +function chainHasCatch(node: TSESTree.CallExpression): boolean { + const callee = node.callee; + if (callee.type === AST_NODE_TYPES.MemberExpression) { + const prop = callee.property; + if (prop.type === AST_NODE_TYPES.Identifier && prop.name === "catch") { + return true; + } + const obj = callee.object; + if (obj.type === AST_NODE_TYPES.CallExpression) { + return chainHasCatch(obj); + } + } + return false; +} + +/** Walks a chained call expression to find the root identifier node. */ +function getRootCallIdentifier(node: TSESTree.CallExpression): TSESTree.Identifier | null { + const callee = node.callee; + if (callee.type === AST_NODE_TYPES.Identifier) { + return callee; + } + if (callee.type === AST_NODE_TYPES.MemberExpression) { + const obj = callee.object; + if (obj.type === AST_NODE_TYPES.CallExpression) { + return getRootCallIdentifier(obj); + } + } + return null; +} + function isFunctionDeclarationDefinition(definition: TSESLint.Scope.Definition): definition is FunctionDeclarationDefinition { return definition.type === "FunctionName" && definition.node.type === AST_NODE_TYPES.FunctionDeclaration; } +function isVariableDefinition(definition: TSESLint.Scope.Definition): definition is VariableDefinition { + return definition.type === "Variable" && definition.node.type === AST_NODE_TYPES.VariableDeclarator; +} + +function isModuleScopeVariableDeclaration(node: TSESTree.VariableDeclaration): boolean { + return node.parent.type === AST_NODE_TYPES.Program || (node.parent.type === AST_NODE_TYPES.ExportNamedDeclaration && node.parent.parent.type === AST_NODE_TYPES.Program); +} + +function isAsyncVariableEntrypoint(definition: VariableDefinition): boolean { + const declaration = definition.node.parent; + if (!declaration || declaration.type !== AST_NODE_TYPES.VariableDeclaration || !isModuleScopeVariableDeclaration(declaration)) return false; + const init = definition.node.init; + return (init?.type === AST_NODE_TYPES.FunctionExpression || init?.type === AST_NODE_TYPES.ArrowFunctionExpression) && init.async; +} + export const requireAsyncEntrypointCatchRule = createRule({ name: "require-async-entrypoint-catch", meta: { @@ -47,39 +97,51 @@ export const requireAsyncEntrypointCatchRule = createRule({ return false; } - /** Resolves an identifier callee to its bound FunctionDeclaration, if any. */ - function getResolvedFunctionDeclaration(identifier: TSESTree.Identifier): TSESTree.FunctionDeclaration | null { + /** Returns true when the identifier resolves to a module-scope async entrypoint. */ + function isAsyncModuleScopeEntrypoint(identifier: TSESTree.Identifier): boolean { let scope: SourceCodeScope | null = sourceCode.getScope(identifier); while (scope) { const variable = scope.set.get(identifier.name); - const definition = variable?.defs.find(isFunctionDeclarationDefinition); + const functionDeclarationDefinition = variable?.defs.find(isFunctionDeclarationDefinition); + if (functionDeclarationDefinition) { + return functionDeclarationDefinition.node.async && functionDeclarationDefinition.node.parent.type === AST_NODE_TYPES.Program; + } - if (definition) { - return definition.node; + const variableDefinition = variable?.defs.find(isVariableDefinition); + if (variableDefinition) { + return isAsyncVariableEntrypoint(variableDefinition); } if (variable && variable.defs.length > 0) { - return null; + return false; } scope = scope.upper; } - return null; + return false; } return { // Flag bare calls: ExpressionStatement whose expression is a direct CallExpression - // to a tracked module-scope async function, and that are not inside an async function body + // to a tracked module-scope async function/variable entrypoint, and that are not inside an async function body // (where `await` would be the right fix instead). "ExpressionStatement > CallExpression"(node: TSESTree.CallExpression) { const callee = node.callee; + let rootIdentifier: TSESTree.Identifier | null = null; + + if (callee.type === AST_NODE_TYPES.Identifier) { + rootIdentifier = callee; + } else if (callee.type === AST_NODE_TYPES.MemberExpression) { + // Chained call: main().then(...) etc. + // If the chain contains .catch(...), it's handled — skip. + if (chainHasCatch(node)) return; + rootIdentifier = getRootCallIdentifier(node); + } - // Only flag simple identifier calls: main(), run(), etc. - if (callee.type !== AST_NODE_TYPES.Identifier) return; - const resolvedDeclaration = getResolvedFunctionDeclaration(callee); - const name = callee.name; - if (!resolvedDeclaration?.async || resolvedDeclaration.parent.type !== AST_NODE_TYPES.Program) return; + if (!rootIdentifier) return; + const name = rootIdentifier.name; + if (!isAsyncModuleScopeEntrypoint(rootIdentifier)) return; // Inside an async context the caller can (and should) use `await fn()` instead. if (isInsideAsyncFunction(node)) return;