Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions eslint-factory/src/rules/require-async-entrypoint-catch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
});
Expand All @@ -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: [],
});
Expand Down Expand Up @@ -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; });`,
},
],
},
],
},
],
});
});
});
64 changes: 60 additions & 4 deletions eslint-factory/src/rules/require-async-entrypoint-catch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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;
Expand Down
Loading