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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@

Just as [Java’s throws keyword](https://dev.java/learn/exceptions/throwing/) does, enforcing the use of [JSDoc’s `@throws` tag](https://jsdoc.app/tags-throws) to explicitly specify which exceptions a function can throw to solve unpredictable propagation of exceptions happening which also known as a [JavaScript's "hidden exceptions"](https://www.youtube.com/watch?v=3iWoNJbGO2U).

See [examples](./examples) for more.

## Usage

Install dependencies
```sh
# https://typescript-eslint.io/getting-started/#step-1-installation
npm install --save-dev eslint @eslint/js typescript typescript-eslint
```

Install plugin
```
npm install --save-dev eslint-plugin-explicit-exceptions
```

Expand Down
10 changes: 10 additions & 0 deletions examples/fixed.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,13 @@ function factory() {
}
}
factory();

/**
* @throws {Promise<Error>}
*/
function promised() {
return new Promise((_, reject) => {
reject(new Error());
});
}
await promised();
7 changes: 7 additions & 0 deletions examples/unsafe.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,10 @@ function factory() {
}
}
factory();

function promised() {
return new Promise((_, reject) => {
reject(new Error());
});
}
await promised();
8 changes: 4 additions & 4 deletions src/rules/no-implicit-propagation.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,13 @@ module.exports = createRule({
const services = ESLintUtils.getParserServices(context);
const checker = services.program.getTypeChecker();

const warnedNodes = new Set();
const visitedNodes = new Set();

/** @param {import('@typescript-eslint/utils').TSESTree.ExpressionStatement} node */
const visitExpressionStatement = (node) => {
if (visitedNodes.has(node.range[0])) return;
visitedNodes.add(node.range[0]);

if (isInHandledContext(node)) return;

const callerDeclaration = findClosestFunctionNode(node);
Expand Down Expand Up @@ -134,9 +137,6 @@ module.exports = createRule({
return;
}

if (warnedNodes.has(node.range[0])) return;
warnedNodes.add(node.range[0]);

const calleeDeclaration = getCalleeDeclaration(services, node);
if (!calleeDeclaration) return;

Expand Down
104 changes: 104 additions & 0 deletions src/rules/no-implicit-propagation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,50 @@ ruleTester.run(
};
`,
},
{
code: `
/**
* @throws {Promise<Error>}
*/
async function foo() {
throw new Error();
}
async function bar() {
try {
await foo();
} catch {}
}
`,
},
{
code: `
/**
* @throws {Promise<Error>}
*/
async function foo() {
throw new Error();
}
/**
* @throws {Promise<Error>}
*/
async function bar() {
await foo();
}
`,
},
{
code: `
/**
* @throws {Promise<Error>}
*/
async function foo() {
throw new Error();
}
async function bar() {
await foo().catch(() => {});
}
`,
},
],
invalid: [
{
Expand Down Expand Up @@ -796,6 +840,66 @@ ruleTester.run(
{ messageId: 'implicitPropagation' },
],
},
{
code: `
/**
* @throws {Promise<Error>}
*/
async function foo() {
throw new Error();
}
async function bar() {
await foo();
}
`,
output: `
/**
* @throws {Promise<Error>}
*/
async function foo() {
throw new Error();
}
/**
* @throws {Promise<Error>}
*/
async function bar() {
await foo();
}
`,
errors: [
{ messageId: 'implicitPropagation' },
],
},
{
code: `
/**
* @throws {Promise<Error>}
*/
function foo() {
return new Promise((_, r) => r(new Error()));
}
async function bar() {
await foo();
}
`,
output: `
/**
* @throws {Promise<Error>}
*/
function foo() {
return new Promise((_, r) => r(new Error()));
}
/**
* @throws {Promise<Error>}
*/
async function bar() {
await foo();
}
`,
errors: [
{ messageId: 'implicitPropagation' },
],
},
],
},
);
125 changes: 122 additions & 3 deletions src/rules/no-undocumented-throws.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const { ESLintUtils, AST_NODE_TYPES } = require('@typescript-eslint/utils');
const ts = require('typescript');
const {
createRule,
findParent,
hasThrowsTag,
typesToUnionString,
isInHandledContext,
Expand All @@ -12,6 +13,7 @@ const {
isTypesAssignableTo,
findClosestFunctionNode,
findNodeToComment,
findIdentifierDeclaration,
} = require('../utils');


Expand Down Expand Up @@ -52,14 +54,19 @@ module.exports = createRule({

const options = getOptionsFromContext(context);

const visitedNodes = new Set();

/**
* Group throw statements in functions
* @type {Map<number, import('@typescript-eslint/utils').TSESTree.ThrowStatement[]>}
*/
const throwStatements = new Map();

/** @param {import('@typescript-eslint/utils').TSESTree.Node} node */
/** @param {import('@typescript-eslint/utils').TSESTree.FunctionLike} node */
const visitOnExit = (node) => {
if (visitedNodes.has(node.range[0])) return;
visitedNodes.add(node.range[0]);

const nodeToComment = findNodeToComment(node);
if (!nodeToComment) return;

Expand Down Expand Up @@ -98,7 +105,9 @@ module.exports = createRule({

if (!throwsTagTypeNodes.length) return;

const throwsTagTypes = getJSDocThrowsTagTypes(checker, functionDeclarationTSNode);
const throwsTagTypes = getJSDocThrowsTagTypes(checker, functionDeclarationTSNode)
.map(t => node.async ? checker.getAwaitedType(t) : t)
.filter(t => !!t);

if (isTypesAssignableTo(checker, throwTypes, throwsTagTypes)) return;

Expand All @@ -124,11 +133,16 @@ module.exports = createRule({
const lines = sourceCode.getLines();
const currentLine = lines[nodeToComment.loc.start.line - 1];
const indent = currentLine.match(/^\s*/)?.[0] ?? '';

const throwsTypeString = node.async
? `Promise<${typesToUnionString(checker, throwTypes)}>`
: typesToUnionString(checker, throwTypes);

return fixer
.insertTextBefore(
nodeToComment,
`/**\n` +
`${indent} * @throws {${typesToUnionString(checker, throwTypes)}}\n` +
`${indent} * @throws {${throwsTypeString}}\n` +
`${indent} */\n` +
`${indent}`
);
Expand Down Expand Up @@ -168,6 +182,111 @@ module.exports = createRule({
'PropertyDefinition > FunctionExpression:exit': visitOnExit,
'MethodDefinition > FunctionExpression:exit': visitOnExit,
'ReturnStatement > FunctionExpression:exit': visitOnExit,

/**
* Visitor for checking `new Promise()` calls
* @param {import('@typescript-eslint/utils').TSESTree.NewExpression} node
*/
'NewExpression[callee.type="Identifier"][callee.name="Promise"]'(node) {
const functionDeclaration = findClosestFunctionNode(node);
if (!functionDeclaration) return;

const nodeToComment = findNodeToComment(functionDeclaration);
if (!nodeToComment) return;

const comments = sourceCode.getCommentsBefore(nodeToComment);
const isCommented =
comments.length &&
comments
.map(({ value }) => value)
.some(hasThrowsTag);

if (isCommented) return;

if (!node.arguments.length) return;

/** @type {import('@typescript-eslint/utils').TSESTree.FunctionLike | null} */
let callbackNode = null;
switch (node.arguments[0].type) {
case AST_NODE_TYPES.ArrowFunctionExpression:
case AST_NODE_TYPES.FunctionExpression:
callbackNode = node.arguments[0];
break;
case AST_NODE_TYPES.Identifier: {
const declaration =
findIdentifierDeclaration(sourceCode, node.arguments[0]);

if (!declaration) return;

callbackNode =
/** @type {import('@typescript-eslint/utils').TSESTree.FunctionLike | null} */
(declaration);
}
default:
break;
}
if (!callbackNode || callbackNode.params.length < 2) return;

const rejectCallbackNode = callbackNode.params[1];
if (rejectCallbackNode.type !== AST_NODE_TYPES.Identifier) return;

const callbackScope = sourceCode.getScope(callbackNode)
if (!callbackScope) return;

const rejectCallbackRefs = callbackScope.set.get(rejectCallbackNode.name)?.references;
if (!rejectCallbackRefs) return;

const callRefs = rejectCallbackRefs
.filter(ref =>
ref.identifier.parent.type === AST_NODE_TYPES.CallExpression)
.map(ref =>
/** @type {import('@typescript-eslint/utils').TSESTree.CallExpression} */
(ref.identifier.parent)
);

if (!callRefs.length) return;

const rejectTypes = callRefs
.map(ref => services.getTypeAtLocation(ref.arguments[0]));

if (!rejectTypes.length) return;

const references = sourceCode.getScope(node).references;
if (!references.length) return;

const rejectHandled = references
.some(ref =>
findParent(ref.identifier, (node) =>
node.type === AST_NODE_TYPES.MemberExpression &&
node.property.type === AST_NODE_TYPES.Identifier &&
node.property.name === 'catch'
)
);

if (rejectHandled) return;

context.report({
node,
messageId: 'missingThrowsTag',
fix(fixer) {
const lines = sourceCode.getLines();
const currentLine = lines[nodeToComment.loc.start.line - 1];
const indent = currentLine.match(/^\s*/)?.[0] ?? '';

const throwsTypeString =
`Promise<${typesToUnionString(checker, rejectTypes)}>`;

return fixer
.insertTextBefore(
nodeToComment,
`/**\n` +
`${indent} * @throws {${throwsTypeString}}\n` +
`${indent} */\n` +
`${indent}`
);
},
});
},
};
},
});
Loading