From 986c3c73bbe49c07ced981192b321b9f12d54f2c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:30:12 +0000 Subject: [PATCH 1/3] Initial plan From adca02b1f70ef2368955bbb615a50d0594efc6dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:42:53 +0000 Subject: [PATCH 2/3] fix(eslint-factory): track async arrow/fn-expr entrypoints and flag .then() without .catch() Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../require-async-entrypoint-catch.test.ts | 140 ++++++++++++++++++ .../rules/require-async-entrypoint-catch.ts | 64 +++++++- 2 files changed, 200 insertions(+), 4 deletions(-) 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 180527cb177..3ddd618c2e9 100644 --- a/eslint-factory/src/rules/require-async-entrypoint-catch.test.ts +++ b/eslint-factory/src/rules/require-async-entrypoint-catch.test.ts @@ -33,6 +33,18 @@ 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; });`, ], invalid: [], }); @@ -56,6 +68,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: [], }); @@ -194,4 +225,113 @@ 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: .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 b2a5b340087..b3551e084d1 100644 --- a/eslint-factory/src/rules/require-async-entrypoint-catch.ts +++ b/eslint-factory/src/rules/require-async-entrypoint-catch.ts @@ -8,6 +8,35 @@ 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 the outermost call in the chain ends with `.catch(...)`. */ +function chainEndsWithCatch(node: TSESTree.CallExpression): boolean { + const callee = node.callee; + if (callee.type === AST_NODE_TYPES.MemberExpression) { + const prop = callee.property; + return prop.type === AST_NODE_TYPES.Identifier && prop.name === "catch"; + } + return false; +} + +/** + * Walks a chained call expression to find the root identifier name. + * e.g. for `main().then(cb)`, returns "main". + * Returns null if the root call is not a simple Identifier call. + */ +function getRootCallName(node: TSESTree.CallExpression): string | null { + const callee = node.callee; + if (callee.type === AST_NODE_TYPES.Identifier) { + return callee.name; + } + if (callee.type === AST_NODE_TYPES.MemberExpression) { + const obj = callee.object; + if (obj.type === AST_NODE_TYPES.CallExpression) { + return getRootCallName(obj); + } + } + return null; +} + export const requireAsyncEntrypointCatchRule = createRule({ name: "require-async-entrypoint-catch", meta: { @@ -49,16 +78,43 @@ export const requireAsyncEntrypointCatchRule = createRule({ } }, + // Collect module-scope async function expressions and arrow functions: + // const/let/var X = async function() {} or X = async () => {} + VariableDeclaration(node) { + if (node.parent.type !== AST_NODE_TYPES.Program) return; + for (const declarator of node.declarations) { + if ( + declarator.id.type === AST_NODE_TYPES.Identifier && + declarator.init !== null && + declarator.init !== undefined && + (declarator.init.type === AST_NODE_TYPES.FunctionExpression || declarator.init.type === AST_NODE_TYPES.ArrowFunctionExpression) && + declarator.init.async + ) { + asyncFunctionNames.add(declarator.id.name); + } + } + }, + // Flag bare calls: ExpressionStatement whose expression is a direct CallExpression // to a tracked async function, 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; - // Only flag simple identifier calls: main(), run(), etc. - if (callee.type !== AST_NODE_TYPES.Identifier) return; - const name = callee.name; - if (!asyncFunctionNames.has(name)) return; + let name: string | null = null; + + if (callee.type === AST_NODE_TYPES.Identifier) { + // Bare call: main() + name = callee.name; + } else if (callee.type === AST_NODE_TYPES.MemberExpression) { + // Chained call: main().then(...) etc. + // If the chain ends with .catch(...), it's handled — skip. + if (chainEndsWithCatch(node)) return; + // Otherwise find the root call name. + name = getRootCallName(node); + } + + if (!name || !asyncFunctionNames.has(name)) return; // Inside an async context the caller can (and should) use `await fn()` instead. if (isInsideAsyncFunction(node)) return; From 4416ef9145e8392e58264d9ddc6c8aca6270eb35 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:02:11 +0000 Subject: [PATCH 3/3] fix(eslint-factory): handle exported async entrypoints and catch-finally chains Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- .../require-async-entrypoint-catch.test.ts | 40 +++++++++++++++++++ .../rules/require-async-entrypoint-catch.ts | 19 ++++++--- 2 files changed, 53 insertions(+), 6 deletions(-) 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 3ddd618c2e9..8f07372e2f1 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"); @@ -45,6 +52,14 @@ 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: [], }); @@ -293,6 +308,31 @@ 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: [], diff --git a/eslint-factory/src/rules/require-async-entrypoint-catch.ts b/eslint-factory/src/rules/require-async-entrypoint-catch.ts index b3551e084d1..6d7f9986b50 100644 --- a/eslint-factory/src/rules/require-async-entrypoint-catch.ts +++ b/eslint-factory/src/rules/require-async-entrypoint-catch.ts @@ -8,12 +8,18 @@ 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 the outermost call in the chain ends with `.catch(...)`. */ -function chainEndsWithCatch(node: TSESTree.CallExpression): boolean { +/** 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; - return prop.type === AST_NODE_TYPES.Identifier && prop.name === "catch"; + 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; } @@ -81,7 +87,8 @@ export const requireAsyncEntrypointCatchRule = createRule({ // Collect module-scope async function expressions and arrow functions: // const/let/var X = async function() {} or X = async () => {} VariableDeclaration(node) { - if (node.parent.type !== AST_NODE_TYPES.Program) return; + const isModuleScope = node.parent.type === AST_NODE_TYPES.Program || (node.parent.type === AST_NODE_TYPES.ExportNamedDeclaration && node.parent.parent.type === AST_NODE_TYPES.Program); + if (!isModuleScope) return; for (const declarator of node.declarations) { if ( declarator.id.type === AST_NODE_TYPES.Identifier && @@ -108,8 +115,8 @@ export const requireAsyncEntrypointCatchRule = createRule({ name = callee.name; } else if (callee.type === AST_NODE_TYPES.MemberExpression) { // Chained call: main().then(...) etc. - // If the chain ends with .catch(...), it's handled — skip. - if (chainEndsWithCatch(node)) return; + // If the chain contains .catch(...), it's handled — skip. + if (chainHasCatch(node)) return; // Otherwise find the root call name. name = getRootCallName(node); }