Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
180 changes: 180 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 @@ -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");
Expand All @@ -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: [],
});
Expand All @@ -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: [],
});
Expand Down Expand Up @@ -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; });`,
},
],
},
],
},
],
});
});
});
88 changes: 75 additions & 13 deletions eslint-factory/src/rules/require-async-entrypoint-catch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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;
Expand Down
Loading