@stateward/plugin-errors 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -3
- package/package.json +2 -2
- package/dist/async-handler-unhandled.d.ts +0 -7
- package/dist/async-handler-unhandled.js +0 -342
- package/dist/exhaustive-catch.d.ts +0 -6
- package/dist/exhaustive-catch.js +0 -190
- package/dist/exhaustive-fallback.d.ts +0 -6
- package/dist/exhaustive-fallback.js +0 -157
- package/dist/finally-may-throw.d.ts +0 -6
- package/dist/finally-may-throw.js +0 -184
- package/dist/no-swallow.d.ts +0 -8
- package/dist/no-swallow.js +0 -320
- package/dist/render-throws-without-boundary.d.ts +0 -6
- package/dist/render-throws-without-boundary.js +0 -151
- package/dist/rethrow-unknown.d.ts +0 -6
- package/dist/rethrow-unknown.js +0 -260
- package/dist/throw-in-hook.d.ts +0 -8
- package/dist/throw-in-hook.js +0 -510
- package/dist/throws-matches-doc.d.ts +0 -7
- package/dist/throws-matches-doc.js +0 -188
package/README.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
The `errors` pack of [stateward](https://github.com/khoing1111/ts-stateward#readme). It finds a `catch` that misses an error class or hides the unexpected ones, an error lost in `finally`, a `Result` nobody checks, and a component that can throw with no error boundary above it. In VS Code, hints name the errors a function can throw.
|
|
4
4
|
|
|
5
|
+
## Contents
|
|
6
|
+
|
|
7
|
+
- [Turn it on](#turn-it-on)
|
|
8
|
+
- [Rules](#rules)
|
|
9
|
+
|
|
10
|
+
<!-- generated:setup -->
|
|
11
|
+
|
|
12
|
+
## Turn it on
|
|
13
|
+
|
|
5
14
|
```bash
|
|
6
15
|
npm install --save-dev stateward @stateward/plugin-errors
|
|
7
16
|
```
|
|
@@ -13,9 +22,58 @@ import { errors } from "@stateward/plugin-errors";
|
|
|
13
22
|
|
|
14
23
|
export default defineConfig({
|
|
15
24
|
plugins: [errors()],
|
|
16
|
-
preset: "recommended",
|
|
25
|
+
preset: "recommended", // or "strict"
|
|
17
26
|
});
|
|
18
27
|
```
|
|
19
28
|
|
|
20
|
-
`npx stateward init` writes this for you.
|
|
21
|
-
|
|
29
|
+
`npx stateward init` writes this for you. A preset turns on the core and coverage rules; without one
|
|
30
|
+
no rule is on.
|
|
31
|
+
|
|
32
|
+
One rule's severity (`error`, `warning`, `info` or `off`), and its options, go under `rules`:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
rules: {
|
|
36
|
+
"errors/must-handle-error": "warning",
|
|
37
|
+
// a severity and options; this option is at its default, the rule's page lists them all
|
|
38
|
+
"errors/result-ignored": ["error", { allowVoid: false }],
|
|
39
|
+
},
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Add-on rules stay off until you list their add-on:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
addons: ["errors/catch-intent", "errors/narrow-try"],
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- `errors/catch-intent`: `catch-without-intent`
|
|
49
|
+
- `errors/narrow-try`: `narrow-try`
|
|
50
|
+
|
|
51
|
+
Every key of the config: [config.md](https://github.com/khoing1111/ts-stateward/blob/main/docs/user/config.md).
|
|
52
|
+
|
|
53
|
+
<!-- /generated:setup -->
|
|
54
|
+
|
|
55
|
+
<!-- generated:rules -->
|
|
56
|
+
|
|
57
|
+
## Rules
|
|
58
|
+
|
|
59
|
+
| Rule | What it finds | Kind | `recommended` | `strict` |
|
|
60
|
+
| --- | --- | --- | --- | --- |
|
|
61
|
+
| [must-handle-error](https://github.com/khoing1111/ts-stateward/blob/main/docs/rules/errors/must-handle-error.md) | An error class marked @mustHandle is named in a catch or passed on with @throws by every call that can throw it. | Core | error | error |
|
|
62
|
+
| [result-ignored](https://github.com/khoing1111/ts-stateward/blob/main/docs/rules/errors/result-ignored.md) | The Result of a call is checked or passed on, not dropped as a statement, after void or after await. | Core | error | error |
|
|
63
|
+
| [result-unchecked](https://github.com/khoing1111/ts-stateward/blob/main/docs/rules/errors/result-unchecked.md) | A call that reports failure in its value, not by throwing, is checked before the code goes on as if it worked. (heuristic) | Core | warning | warning |
|
|
64
|
+
| [throws-doc-mismatch](https://github.com/khoing1111/ts-stateward/blob/main/docs/rules/errors/throws-doc-mismatch.md) | A function's @throws tag lists exactly the error classes it can throw, as far as stateward can see. | Core | error | error |
|
|
65
|
+
| [catch-hides-unexpected](https://github.com/khoing1111/ts-stateward/blob/main/docs/rules/errors/catch-hides-unexpected.md) | A catch that handles some errors with instanceof also rethrows or handles all the others. | Core | error | error |
|
|
66
|
+
| [catch-misses-error](https://github.com/khoing1111/ts-stateward/blob/main/docs/rules/errors/catch-misses-error.md) | A catch that branches with instanceof handles every error class its try can throw, or rethrows the rest; so does an error boundary's fallback. | Core | error | error |
|
|
67
|
+
| [finally-can-throw](https://github.com/khoing1111/ts-stateward/blob/main/docs/rules/errors/finally-can-throw.md) | A finally block does not call something that throws, so the error that led to it is not lost. | Core | error | error |
|
|
68
|
+
| [catch-without-intent](https://github.com/khoing1111/ts-stateward/blob/main/docs/rules/errors/catch-without-intent.md) | Experimental: on real projects it is mostly wrong; it may change or be removed. A catch that handles every error says how: it narrows, reports, or falls back; one that only logs, or calls something stateward cannot judge, and carries on is reported. (heuristic) | Add-on `catch-intent` | warning | warning |
|
|
69
|
+
| [narrow-try](https://github.com/khoing1111/ts-stateward/blob/main/docs/rules/errors/narrow-try.md) | Calls to your own code placed in a `try` after the call it is for: move them after the `try`. | Add-on `narrow-try` | warning | warning |
|
|
70
|
+
| [render-throw-without-boundary](https://github.com/khoing1111/ts-stateward/blob/main/docs/rules/errors/render-throw-without-boundary.md) | A component that can throw a known error while rendering has an error boundary above it. | Core | warning | warning |
|
|
71
|
+
|
|
72
|
+
Kind: **Core** rules are on in every preset. **Coverage** rules ask your types to say more; they
|
|
73
|
+
are info under `recommended` and errors under `strict`. **Add-on** rules stay off until you list
|
|
74
|
+
their add-on in `addons`; the table shows their severity once it is listed. A heuristic rule
|
|
75
|
+
guesses, so it is never an error unless you set it.
|
|
76
|
+
|
|
77
|
+
Each rule's page says why it matters, what it catches and misses, how to fix what it finds, and its options. The pack's page: [docs/packs/errors.md](https://github.com/khoing1111/ts-stateward/blob/main/docs/packs/errors.md). Every pack's rules: [the rules index](https://github.com/khoing1111/ts-stateward/blob/main/docs/rules/README.md).
|
|
78
|
+
|
|
79
|
+
<!-- /generated:rules -->
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stateward/plugin-errors",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "stateward rules for errors: errors are handled where they can happen.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -32,6 +32,6 @@
|
|
|
32
32
|
"!dist/**/*.tsbuildinfo"
|
|
33
33
|
],
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@stateward/core": "1.0.
|
|
35
|
+
"@stateward/core": "1.0.1"
|
|
36
36
|
}
|
|
37
37
|
}
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
/** The group the rule belongs to in the docs and in `stateward status`. */
|
|
2
|
-
export declare const GROUP = "async";
|
|
3
|
-
export declare const asyncHandlerUnhandled: import("@stateward/core").FileRule<{
|
|
4
|
-
timers: import("@stateward/core").StringsOption;
|
|
5
|
-
ignoreProps: import("@stateward/core").StringsOption;
|
|
6
|
-
}, "async">;
|
|
7
|
-
//# sourceMappingURL=async-handler-unhandled.d.ts.map
|
|
@@ -1,342 +0,0 @@
|
|
|
1
|
-
import ts from "@stateward/core/typescript";
|
|
2
|
-
import { defineRule, matchesName, option } from "@stateward/core";
|
|
3
|
-
import { hasModifier, isBuiltinGlobal, scanRunning, sameFileFunction, stripParentheses, } from "./syntax.js";
|
|
4
|
-
import { pathOf } from "./inference/packages.js";
|
|
5
|
-
import { HEADLINE_MAX, fitted, oneLine, shorten, withExample } from "./text.js";
|
|
6
|
-
/** The group the rule belongs to in the docs and in `stateward status`. */
|
|
7
|
-
export const GROUP = "async";
|
|
8
|
-
const EXPLAIN = `# errors/async-handler-unhandled
|
|
9
|
-
|
|
10
|
-
An async event handler, listener or timer callback catches its own failures, since nothing awaits it.
|
|
11
|
-
|
|
12
|
-
## Why
|
|
13
|
-
|
|
14
|
-
Nothing awaits an \`onClick\`, a listener or a timer callback. When an \`async\` one fails after an \`await\`, its promise rejects with no code waiting for it: the console shows an unhandled rejection, the user sees nothing, and whatever the handler was changing is left half done. A Save button whose \`await save()\` fails looks like it did nothing.
|
|
15
|
-
|
|
16
|
-
## When to use it
|
|
17
|
-
|
|
18
|
-
Use it in any app with async event handlers: React components, DOM listeners, timers. It is on as an error in every preset. Turn it off only if a global \`unhandledrejection\` handler is how you deal with these on purpose.
|
|
19
|
-
|
|
20
|
-
## What it catches
|
|
21
|
-
|
|
22
|
-
An \`async\` function written in place as:
|
|
23
|
-
|
|
24
|
-
- a JSX prop named \`on\` plus a capital letter (\`onClick\`, \`onSubmit\`);
|
|
25
|
-
- the listener of \`addEventListener\`;
|
|
26
|
-
- the callback of \`setTimeout\`, \`setInterval\` or \`queueMicrotask\` (the \`timers\` option adds more);
|
|
27
|
-
|
|
28
|
-
when it awaits outside a \`try\` with a \`catch\`, or returns a promise from \`fetch\`, \`Promise.all\` and similar, or an \`async\` function in the same file. An \`await\` in a \`catch\` or \`finally\` block is not protected by that \`try\`.
|
|
29
|
-
|
|
30
|
-
### Bad
|
|
31
|
-
|
|
32
|
-
\`\`\`tsx
|
|
33
|
-
<button onClick={async () => {
|
|
34
|
-
await save();
|
|
35
|
-
}}>Save</button>
|
|
36
|
-
\`\`\`
|
|
37
|
-
|
|
38
|
-
### Good
|
|
39
|
-
|
|
40
|
-
\`\`\`tsx
|
|
41
|
-
<button onClick={async () => {
|
|
42
|
-
try { await save(); } catch (e) { showError(e); }
|
|
43
|
-
}}>Save</button>
|
|
44
|
-
\`\`\`
|
|
45
|
-
|
|
46
|
-
### Fix
|
|
47
|
-
|
|
48
|
-
Wrap the body in \`try\` / \`catch\` and show, retry or record the failure. There is no quick fix: what to do with it is your call.
|
|
49
|
-
|
|
50
|
-
## What it does not catch
|
|
51
|
-
|
|
52
|
-
- A call that catches its own failure (\`await save().catch(showError)\`).
|
|
53
|
-
- A promise that reports failure in its value instead of rejecting: its type has an \`unwrap()\` method and it resolves to data or an \`error\` (RTK Query's mutation and lazy-query triggers, \`await addTodo(todo)\`). \`await addTodo(todo).unwrap()\` rejects, and is reported.
|
|
54
|
-
- A package function the config's \`facts\` say never fails: \`facts: { "my-client": { send: { rejects: [] } } }\`.
|
|
55
|
-
- A handler passed by name (\`onClick={save}\`): only functions written in place are read.
|
|
56
|
-
- A prop whose type returns a promise (\`onSave: () => Promise<void>\`): the component that declares it awaits it.
|
|
57
|
-
- Other callbacks nobody awaits: \`.then\` callbacks, \`setImmediate\`, observers, \`useEffect\` callbacks.
|
|
58
|
-
- A handler with no \`await\` and no promise the rule recognises.
|
|
59
|
-
|
|
60
|
-
## Turn it on or off
|
|
61
|
-
|
|
62
|
-
In \`stateward.config.ts\`:
|
|
63
|
-
|
|
64
|
-
\`\`\`ts
|
|
65
|
-
rules: { "errors/async-handler-unhandled": "off" } // or "warning"
|
|
66
|
-
groups: { "errors/async": "off" } // the whole group
|
|
67
|
-
rules: { "errors/async-handler-unhandled": ["error", { ignoreProps: ["onSubmit"] }] } // a prop your form library awaits
|
|
68
|
-
\`\`\`
|
|
69
|
-
`;
|
|
70
|
-
const OPTIONS = {
|
|
71
|
-
timers: option.strings({
|
|
72
|
-
default: ["setTimeout", "setInterval", "queueMicrotask"],
|
|
73
|
-
description: "Functions whose first argument is a callback nobody awaits (add requestAnimationFrame, setImmediate…).",
|
|
74
|
-
}),
|
|
75
|
-
ignoreProps: option.strings({
|
|
76
|
-
default: [],
|
|
77
|
-
description: "JSX props whose async handler the component awaits itself, like a form library's onSubmit (globs allowed).",
|
|
78
|
-
}),
|
|
79
|
-
};
|
|
80
|
-
/** Objects the timers are also called on: `window.setTimeout`. */
|
|
81
|
-
const TIMER_OWNERS = new Set(["window", "globalThis", "self"]);
|
|
82
|
-
/** `Promise` methods that return a promise that can reject. */
|
|
83
|
-
const REJECTING_PROMISES = new Set([
|
|
84
|
-
"all",
|
|
85
|
-
"allSettled",
|
|
86
|
-
"race",
|
|
87
|
-
"any",
|
|
88
|
-
"reject",
|
|
89
|
-
]);
|
|
90
|
-
function asyncFunction(expression) {
|
|
91
|
-
const node = stripParentheses(expression);
|
|
92
|
-
if (!ts.isArrowFunction(node) && !ts.isFunctionExpression(node))
|
|
93
|
-
return undefined;
|
|
94
|
-
if (!hasModifier(node, ts.SyntaxKind.AsyncKeyword))
|
|
95
|
-
return undefined;
|
|
96
|
-
return ts.isFunctionExpression(node) && node.asteriskToken !== undefined ? undefined : node;
|
|
97
|
-
}
|
|
98
|
-
function calleeName(call) {
|
|
99
|
-
const callee = stripParentheses(call.expression);
|
|
100
|
-
if (ts.isIdentifier(callee))
|
|
101
|
-
return callee.text;
|
|
102
|
-
if (!ts.isPropertyAccessExpression(callee))
|
|
103
|
-
return undefined;
|
|
104
|
-
if (callee.name.text === "addEventListener")
|
|
105
|
-
return callee.name.text;
|
|
106
|
-
const owner = stripParentheses(callee.expression);
|
|
107
|
-
return ts.isIdentifier(owner) && TIMER_OWNERS.has(owner.text) ? callee.name.text : undefined;
|
|
108
|
-
}
|
|
109
|
-
/** The members of a printed union at its top level: `(() => A) | undefined` is two. */
|
|
110
|
-
function unionMembers(text) {
|
|
111
|
-
const members = [];
|
|
112
|
-
let depth = 0;
|
|
113
|
-
let start = 0;
|
|
114
|
-
for (let i = 0; i < text.length; i += 1) {
|
|
115
|
-
const char = text[i];
|
|
116
|
-
if (char === "(" || char === "[" || char === "{" || char === "<")
|
|
117
|
-
depth += 1;
|
|
118
|
-
else if (char === ")" ||
|
|
119
|
-
char === "]" ||
|
|
120
|
-
char === "}" ||
|
|
121
|
-
(char === ">" && text[i - 1] !== "=")) {
|
|
122
|
-
depth -= 1;
|
|
123
|
-
}
|
|
124
|
-
else if (char === "|" && depth === 0) {
|
|
125
|
-
members.push(text.slice(start, i).trim());
|
|
126
|
-
start = i + 1;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
members.push(text.slice(start).trim());
|
|
130
|
-
return members;
|
|
131
|
-
}
|
|
132
|
-
/** `() => Promise<void>` or `(id: string) => PromiseLike<T>`, in parentheses or not. */
|
|
133
|
-
const RETURNS_PROMISE = /^\(*\(.*\) => (?:Promise|PromiseLike)<.*>\)*$/;
|
|
134
|
-
/**
|
|
135
|
-
* Whether the prop an attribute sets is declared as a function that returns a promise
|
|
136
|
-
* (`onSave: () => Promise<void>`, `onSave?(): Promise<void>`): the component asks for a promise
|
|
137
|
-
* so that it can await it, so a failure is its to handle.
|
|
138
|
-
*/
|
|
139
|
-
function awaitedByComponent(fn, ctx) {
|
|
140
|
-
const expected = ctx.types.contextualTypeTextOf(fn);
|
|
141
|
-
if (expected === undefined)
|
|
142
|
-
return false;
|
|
143
|
-
const present = unionMembers(expected).filter((member) => member !== "undefined" && member !== "null");
|
|
144
|
-
return present.length > 0 && present.every((member) => RETURNS_PROMISE.test(member));
|
|
145
|
-
}
|
|
146
|
-
/** The async function this node hands to something that never awaits it, if it does. */
|
|
147
|
-
function handlerOf(node, ctx) {
|
|
148
|
-
if (ts.isJsxAttribute(node)) {
|
|
149
|
-
const name = node.name;
|
|
150
|
-
if (!ts.isIdentifier(name) || !/^on[A-Z]/.test(name.text))
|
|
151
|
-
return undefined;
|
|
152
|
-
if (matchesName(name.text, ctx.options.ignoreProps))
|
|
153
|
-
return undefined;
|
|
154
|
-
const value = node.initializer;
|
|
155
|
-
if (value === undefined || !ts.isJsxExpression(value) || value.expression === undefined) {
|
|
156
|
-
return undefined;
|
|
157
|
-
}
|
|
158
|
-
const fn = asyncFunction(value.expression);
|
|
159
|
-
return fn === undefined || awaitedByComponent(fn, ctx)
|
|
160
|
-
? undefined
|
|
161
|
-
: { fn, name: name.text, role: "handler" };
|
|
162
|
-
}
|
|
163
|
-
if (!ts.isCallExpression(node))
|
|
164
|
-
return undefined;
|
|
165
|
-
const name = calleeName(node);
|
|
166
|
-
if (name === "addEventListener") {
|
|
167
|
-
const [, listener] = node.arguments;
|
|
168
|
-
const fn = listener === undefined ? undefined : asyncFunction(listener);
|
|
169
|
-
return fn === undefined ? undefined : { fn, name, role: "listener", call: node };
|
|
170
|
-
}
|
|
171
|
-
if (name !== undefined && ctx.options.timers.includes(name)) {
|
|
172
|
-
const [callback] = node.arguments;
|
|
173
|
-
const fn = callback === undefined ? undefined : asyncFunction(callback);
|
|
174
|
-
return fn === undefined ? undefined : { fn, name, role: "callback", call: node };
|
|
175
|
-
}
|
|
176
|
-
return undefined;
|
|
177
|
-
}
|
|
178
|
-
/** A call that returns a promise which can reject: `fetch`, `Promise.all`, a local async function. */
|
|
179
|
-
function returnsPromise(expression, ctx) {
|
|
180
|
-
const call = stripParentheses(expression);
|
|
181
|
-
if (!ts.isCallExpression(call))
|
|
182
|
-
return false;
|
|
183
|
-
const callee = stripParentheses(call.expression);
|
|
184
|
-
if (ts.isIdentifier(callee) && callee.text === "fetch" && isBuiltinGlobal(callee, ctx)) {
|
|
185
|
-
return true;
|
|
186
|
-
}
|
|
187
|
-
if (ts.isPropertyAccessExpression(callee)) {
|
|
188
|
-
const owner = stripParentheses(callee.expression);
|
|
189
|
-
if (ts.isIdentifier(owner) &&
|
|
190
|
-
owner.text === "Promise" &&
|
|
191
|
-
REJECTING_PROMISES.has(callee.name.text) &&
|
|
192
|
-
isBuiltinGlobal(owner, ctx)) {
|
|
193
|
-
return true;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
const declared = sameFileFunction(callee, ctx);
|
|
197
|
-
return declared !== undefined && hasModifier(declared, ts.SyntaxKind.AsyncKeyword);
|
|
198
|
-
}
|
|
199
|
-
/**
|
|
200
|
-
* Whether the config's facts say the package function called here never fails: `rejects: []`
|
|
201
|
-
* (and no `throws`). The function is named as the facts name it: its path in the package's
|
|
202
|
-
* types (`Client.send`), or its own name (`send`).
|
|
203
|
-
*/
|
|
204
|
-
function factsSayNeverRejects(call, ctx) {
|
|
205
|
-
const callee = stripParentheses(call.expression);
|
|
206
|
-
const name = ts.isPropertyAccessExpression(callee) ? callee.name : callee;
|
|
207
|
-
const symbol = ctx.types.symbolAt(name);
|
|
208
|
-
const declared = symbol && ctx.types.declarationOf(symbol);
|
|
209
|
-
if (declared === undefined || ctx.types.isOwnFile(declared.file))
|
|
210
|
-
return false;
|
|
211
|
-
const packageName = ctx.types.packageOf(declared.file);
|
|
212
|
-
const facts = packageName === undefined ? undefined : ctx.facts[packageName];
|
|
213
|
-
if (facts === undefined)
|
|
214
|
-
return false;
|
|
215
|
-
const names = pathOf(declared.node);
|
|
216
|
-
const entry = facts[names.join(".")] ?? facts[names.at(-1) ?? ""];
|
|
217
|
-
return entry?.rejects?.length === 0 && (entry.throws ?? []).length === 0;
|
|
218
|
-
}
|
|
219
|
-
/**
|
|
220
|
-
* A promise that reports failure in its value, not by rejecting: its type has an `unwrap()`
|
|
221
|
-
* method and resolves to data or an error (RTK Query's mutation and lazy-query triggers, item
|
|
222
|
-
* 19), or the config's facts say the function never rejects.
|
|
223
|
-
*/
|
|
224
|
-
function neverRejects(expression, ctx) {
|
|
225
|
-
const code = stripParentheses(expression);
|
|
226
|
-
if (ctx.types.settlesWithError(code))
|
|
227
|
-
return true;
|
|
228
|
-
return ts.isCallExpression(code) && factsSayNeverRejects(code, ctx);
|
|
229
|
-
}
|
|
230
|
-
/** `save().catch(showError)`: a call that handles its own failure. */
|
|
231
|
-
function catchesOwnFailure(expression) {
|
|
232
|
-
const call = stripParentheses(expression);
|
|
233
|
-
if (!ts.isCallExpression(call) || call.arguments.length === 0)
|
|
234
|
-
return false;
|
|
235
|
-
const callee = stripParentheses(call.expression);
|
|
236
|
-
return ts.isPropertyAccessExpression(callee) && callee.name.text === "catch";
|
|
237
|
-
}
|
|
238
|
-
/** The first place the handler can fail with nothing to catch it, or undefined. */
|
|
239
|
-
function unguardedFailure(fn, ctx) {
|
|
240
|
-
let found;
|
|
241
|
-
const look = (node, guarded) => {
|
|
242
|
-
if (found !== undefined || guarded)
|
|
243
|
-
return;
|
|
244
|
-
if (ts.isAwaitExpression(node) &&
|
|
245
|
-
!catchesOwnFailure(node.expression) &&
|
|
246
|
-
!neverRejects(node.expression, ctx)) {
|
|
247
|
-
found = {
|
|
248
|
-
node: node.getFirstToken(ctx.sourceFile) ?? node,
|
|
249
|
-
kind: "await",
|
|
250
|
-
code: node.expression,
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
else if (ts.isForOfStatement(node) && node.awaitModifier !== undefined) {
|
|
254
|
-
found = { node: node.awaitModifier, kind: "for-await", code: node.expression };
|
|
255
|
-
}
|
|
256
|
-
else if (ts.isReturnStatement(node) &&
|
|
257
|
-
node.expression !== undefined &&
|
|
258
|
-
returnsPromise(node.expression, ctx)) {
|
|
259
|
-
found = { node: node.expression, kind: "await", code: node.expression };
|
|
260
|
-
}
|
|
261
|
-
};
|
|
262
|
-
scanRunning(fn.body, look);
|
|
263
|
-
if (found === undefined && !ts.isBlock(fn.body) && returnsPromise(fn.body, ctx)) {
|
|
264
|
-
found = { node: fn.body, kind: "await", code: fn.body };
|
|
265
|
-
}
|
|
266
|
-
return found;
|
|
267
|
-
}
|
|
268
|
-
function headline(handler) {
|
|
269
|
-
return fitted(HEADLINE_MAX, (cut) => `a failure in this async \`${cut(handler.name)}\` ${handler.role} is an unhandled rejection`);
|
|
270
|
-
}
|
|
271
|
-
/** The developer's own code in an example is cut to this many characters. */
|
|
272
|
-
const CODE_MAX = 50;
|
|
273
|
-
function code(node, ctx) {
|
|
274
|
-
return shorten(oneLine(node.getText(ctx.sourceFile)), CODE_MAX);
|
|
275
|
-
}
|
|
276
|
-
/** The example's first and last line: the handler written where it was, with its own names. */
|
|
277
|
-
function frame(handler, ctx) {
|
|
278
|
-
switch (handler.role) {
|
|
279
|
-
case "handler":
|
|
280
|
-
return { first: `${oneLine(handler.name)}={async () => {`, last: "}}" };
|
|
281
|
-
case "listener":
|
|
282
|
-
case "callback": {
|
|
283
|
-
const [before, after] = aroundFunction(handler.call, handler.fn, ctx);
|
|
284
|
-
return {
|
|
285
|
-
first: `${code(handler.call.expression, ctx)}(${before}async () => {`,
|
|
286
|
-
last: `}${after});`,
|
|
287
|
-
};
|
|
288
|
-
}
|
|
289
|
-
default: {
|
|
290
|
-
const unreachable = handler;
|
|
291
|
-
return unreachable;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
/** The call's other arguments, before and after the function: `"click", ` and `, 100`. */
|
|
296
|
-
function aroundFunction(call, fn, ctx) {
|
|
297
|
-
const index = call.arguments.findIndex((argument) => stripParentheses(argument) === fn);
|
|
298
|
-
const texts = call.arguments.map((argument) => code(argument, ctx));
|
|
299
|
-
const before = texts.slice(0, index).map((text) => `${text}, `);
|
|
300
|
-
const after = texts.slice(index + 1).map((text) => `, ${text}`);
|
|
301
|
-
return [before.join(""), after.join("")];
|
|
302
|
-
}
|
|
303
|
-
function hint(handler, failure, ctx) {
|
|
304
|
-
const { first, last } = frame(handler, ctx);
|
|
305
|
-
const failing = failure.kind === "await"
|
|
306
|
-
? `await ${code(failure.code, ctx)};`
|
|
307
|
-
: `for await (const item of ${code(failure.code, ctx)}) { … }`;
|
|
308
|
-
return withExample(`nothing awaits the ${handler.role}, so catch inside it:`, [
|
|
309
|
-
first,
|
|
310
|
-
` try { ${failing} } catch (e) { showError(e); }`,
|
|
311
|
-
last,
|
|
312
|
-
]);
|
|
313
|
-
}
|
|
314
|
-
export const asyncHandlerUnhandled = defineRule({
|
|
315
|
-
kind: "file",
|
|
316
|
-
meta: {
|
|
317
|
-
description: "An async event handler, listener or timer callback catches its own failures, since nothing awaits it.",
|
|
318
|
-
defaultSeverity: "error",
|
|
319
|
-
tier: "core",
|
|
320
|
-
group: GROUP,
|
|
321
|
-
options: OPTIONS,
|
|
322
|
-
explain: EXPLAIN,
|
|
323
|
-
},
|
|
324
|
-
check(ctx) {
|
|
325
|
-
const visit = (node) => {
|
|
326
|
-
const handler = handlerOf(node, ctx);
|
|
327
|
-
if (handler !== undefined) {
|
|
328
|
-
const failure = unguardedFailure(handler.fn, ctx);
|
|
329
|
-
if (failure !== undefined) {
|
|
330
|
-
ctx.report({
|
|
331
|
-
node: failure.node,
|
|
332
|
-
message: headline(handler),
|
|
333
|
-
hint: hint(handler, failure, ctx),
|
|
334
|
-
});
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
ts.forEachChild(node, visit);
|
|
338
|
-
};
|
|
339
|
-
visit(ctx.sourceFile);
|
|
340
|
-
},
|
|
341
|
-
});
|
|
342
|
-
//# sourceMappingURL=async-handler-unhandled.js.map
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
/** The group the rule belongs to in the docs and in `stateward status`. */
|
|
2
|
-
export declare const GROUP = "catch";
|
|
3
|
-
export declare const exhaustiveCatch: import("@stateward/core").ProjectRule<import("./inference/summary.js").ThrowsSummary, {
|
|
4
|
-
ignoreClasses: import("@stateward/core").StringsOption;
|
|
5
|
-
}, "catch">;
|
|
6
|
-
//# sourceMappingURL=exhaustive-catch.d.ts.map
|
package/dist/exhaustive-catch.js
DELETED
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
import { defineRule, matchesName, option } from "@stateward/core";
|
|
2
|
-
import { catchName, className } from "./inference/format.js";
|
|
3
|
-
import { analyse, everyCatch } from "./inference/solve.js";
|
|
4
|
-
import { summarizeOnce } from "./inference/project.js";
|
|
5
|
-
import { HEADLINE_MAX, codeList, fitted, withExample } from "./text.js";
|
|
6
|
-
/** The group the rule belongs to in the docs and in `stateward status`. */
|
|
7
|
-
export const GROUP = "catch";
|
|
8
|
-
const EXPLAIN = `# errors/exhaustive-catch
|
|
9
|
-
|
|
10
|
-
A catch that branches with instanceof handles every error class its try can throw, or rethrows the rest.
|
|
11
|
-
|
|
12
|
-
## Why
|
|
13
|
-
|
|
14
|
-
A \`catch\` that checks \`e instanceof AuthError\` says which errors it expects. When \`api.send\` can also throw \`RateLimitError\`, a rate limit falls into the generic branch: the user sees a generic error page, and the retry that would have worked never happens. This rule lists, at the \`catch\`, the classes it leaves out.
|
|
15
|
-
|
|
16
|
-
## When to use it
|
|
17
|
-
|
|
18
|
-
Use it in code that throws its own error classes and catches them by class. It is on as an error in every preset; where a \`catch\` does not branch on \`instanceof\`, it says nothing. It works best when your functions throw classes and document them with \`@throws {T}\`.
|
|
19
|
-
|
|
20
|
-
## What it catches
|
|
21
|
-
|
|
22
|
-
A \`catch\` that branches with \`if\` / \`else if\` or \`switch (true)\` on \`instanceof\`, when its \`try\` can throw an error class that no branch names and nothing passes on.
|
|
23
|
-
|
|
24
|
-
What the \`try\` can throw is worked out from \`throw new X(…)\` in the functions it calls, in any file, their \`@throws\` tags, the built-ins stateward knows (\`JSON.parse\`, \`new URL\`, \`fetch\`, …), and the packages it calls. \`stateward deps\` shows how well each package's errors are known.
|
|
25
|
-
|
|
26
|
-
What counts as handled:
|
|
27
|
-
|
|
28
|
-
- \`e instanceof X\` handles \`X\` and every class that extends it (\`e instanceof Error\` handles them all);
|
|
29
|
-
- a last \`else\` or \`default\`, or a statement after the chain, that throws (\`throw e\`, or a new error with \`{ cause: e }\`) passes the rest on;
|
|
30
|
-
- a last \`else\` or \`default\`, or code after the chain, that uses the error (\`setError(e)\`, logging it, reading a property) handles the rest. An \`else\` that ignores the error (\`showGenericError()\`) does not.
|
|
31
|
-
|
|
32
|
-
### Bad
|
|
33
|
-
|
|
34
|
-
\`\`\`ts
|
|
35
|
-
// api.send can throw RateLimitError and AuthError.
|
|
36
|
-
try {
|
|
37
|
-
await api.send(message);
|
|
38
|
-
} catch (e) {
|
|
39
|
-
if (e instanceof AuthError) {
|
|
40
|
-
redirectToLogin();
|
|
41
|
-
} else {
|
|
42
|
-
showGenericError();
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
\`\`\`
|
|
46
|
-
|
|
47
|
-
### Good
|
|
48
|
-
|
|
49
|
-
\`\`\`ts
|
|
50
|
-
try {
|
|
51
|
-
await api.send(message);
|
|
52
|
-
} catch (e) {
|
|
53
|
-
if (e instanceof AuthError) {
|
|
54
|
-
redirectToLogin();
|
|
55
|
-
} else if (e instanceof RateLimitError) {
|
|
56
|
-
retryLater();
|
|
57
|
-
} else {
|
|
58
|
-
throw e;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
\`\`\`
|
|
62
|
-
|
|
63
|
-
### Fix
|
|
64
|
-
|
|
65
|
-
Add a branch for each missing class, or rethrow what you do not handle. The message shows the branch in the shape of your \`catch\`, and links to the call that throws each class. There is no quick fix.
|
|
66
|
-
|
|
67
|
-
## What it does not catch
|
|
68
|
-
|
|
69
|
-
- A \`catch\` with no \`instanceof\` branches: it treats every error alike on purpose.
|
|
70
|
-
- Errors stateward cannot see: from a package it cannot read, a callback, or a dynamic call. Only the classes it can see are checked, and the message notes each call it could not see into, with the reason. \`errors/rethrow-unknown\` makes sure the rest is not dropped. To fill a gap, declare a package's errors under \`facts\` in the config.
|
|
71
|
-
- A class whose \`extends\` cannot be followed, or a branch with a test that is not only \`instanceof\` (\`e instanceof A && e.code === 1\`, \`isAbort(e)\`): it does not guess what that branch handles.
|
|
72
|
-
- Errors from the JavaScript engine itself, such as reading a property of \`undefined\`.
|
|
73
|
-
- Built-ins other than \`JSON.parse\`, \`JSON.stringify\`, \`new URL\`, \`decodeURIComponent\`, \`decodeURI\`, \`structuredClone\`, \`atob\`, \`fetch\`, \`toFixed\`, \`Array(n)\` and \`BigInt\`: they are taken not to throw.
|
|
74
|
-
- Promises kept in a \`let\`, a parameter or a property; getters, setters and generators; tests other than \`instanceof\` (\`e.name === "AbortError"\`).
|
|
75
|
-
|
|
76
|
-
## Turn it on or off
|
|
77
|
-
|
|
78
|
-
In \`stateward.config.ts\`:
|
|
79
|
-
|
|
80
|
-
\`\`\`ts
|
|
81
|
-
rules: { "errors/exhaustive-catch": "off" } // or "warning"
|
|
82
|
-
groups: { "errors/catch": "off" } // the whole group
|
|
83
|
-
facts: { "config-lib": { load: { throws: ["ConfigError"] } } } // declare a package's errors
|
|
84
|
-
rules: { "errors/exhaustive-catch": ["error", { ignoreClasses: ["SyntaxError"] }] } // never require a branch for these
|
|
85
|
-
\`\`\`
|
|
86
|
-
`;
|
|
87
|
-
/** At most this many package calls with unknown errors are noted on a finding. */
|
|
88
|
-
const MAX_UNKNOWN_NOTES = 2;
|
|
89
|
-
function headline(names, via) {
|
|
90
|
-
return fitted(HEADLINE_MAX, (cut) => {
|
|
91
|
-
const source = via === undefined ? "its `try` block" : `\`${cut(via)}\``;
|
|
92
|
-
return `this catch does not handle ${codeList(names, cut)}, which ${source} can throw`;
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
/** The fix, in the shape of the catch: a `case`, an `else if`, or an `if` for a chain without `else`. */
|
|
96
|
-
function hint(count, variable, handler, shape) {
|
|
97
|
-
const branch = count === 1 ? "it" : "each";
|
|
98
|
-
switch (shape) {
|
|
99
|
-
case "switch":
|
|
100
|
-
return withExample(`add a branch for ${branch}, or rethrow what you do not handle:`, [
|
|
101
|
-
`case ${variable} instanceof ${handler}:`,
|
|
102
|
-
" …",
|
|
103
|
-
"default:",
|
|
104
|
-
` throw ${variable};`,
|
|
105
|
-
]);
|
|
106
|
-
case "else":
|
|
107
|
-
return withExample(`add a branch for ${branch}, or rethrow what you do not handle:`, [
|
|
108
|
-
`} else if (${variable} instanceof ${handler}) {`,
|
|
109
|
-
" …",
|
|
110
|
-
"} else {",
|
|
111
|
-
` throw ${variable};`,
|
|
112
|
-
]);
|
|
113
|
-
case "if":
|
|
114
|
-
return withExample(`add a branch for ${branch}, or rethrow what you do not handle after the chain:`, [`if (${variable} instanceof ${handler}) {`, " …", "}", `throw ${variable};`]);
|
|
115
|
-
default: {
|
|
116
|
-
const unreachable = shape;
|
|
117
|
-
return unreachable;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
function report(seen, classes, ignored, emit) {
|
|
122
|
-
const { handler } = seen;
|
|
123
|
-
if (handler.restThrows || handler.restUsed || handler.variable === null)
|
|
124
|
-
return;
|
|
125
|
-
const missing = [];
|
|
126
|
-
for (const [id, info] of seen.unhandled) {
|
|
127
|
-
if (info.certain && !matchesName(catchName(id, classes), ignored)) {
|
|
128
|
-
missing.push({ id, via: info.via });
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
const [first] = missing;
|
|
132
|
-
if (first === undefined)
|
|
133
|
-
return;
|
|
134
|
-
const vias = new Set(missing.map((one) => one.via?.text));
|
|
135
|
-
const via = vias.size === 1 ? first.via?.text : undefined;
|
|
136
|
-
const related = [];
|
|
137
|
-
const shown = new Set();
|
|
138
|
-
for (const { id, via: from } of missing) {
|
|
139
|
-
if (from === null || shown.has(`${String(from.start)}:${from.text}`) || related.length >= 3) {
|
|
140
|
-
continue;
|
|
141
|
-
}
|
|
142
|
-
shown.add(`${String(from.start)}:${from.text}`);
|
|
143
|
-
related.push({
|
|
144
|
-
location: { file: from.file, start: from.start, end: from.end },
|
|
145
|
-
message: `\`${from.text}\` can throw \`${className(id, classes)}\``,
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
// A package call whose errors are not all known: the catch passes them on (they reach the
|
|
149
|
-
// rethrow, or nothing is reported for them), and the note says which call and why.
|
|
150
|
-
const unknowns = [...seen.thrown.unknowns.values()].filter((one) => one.package !== undefined);
|
|
151
|
-
for (const source of unknowns.slice(0, MAX_UNKNOWN_NOTES)) {
|
|
152
|
-
related.push({
|
|
153
|
-
location: { file: source.file, start: source.start, end: source.end },
|
|
154
|
-
message: `\`${source.text}\` may also throw errors stateward cannot list (${source.reason}); rethrow what you do not handle`,
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
emit({
|
|
158
|
-
message: headline(missing.map((one) => className(one.id, classes)), via),
|
|
159
|
-
hint: hint(missing.length, handler.variable, catchName(first.id, classes), handler.dispatch === "switch" ? "switch" : handler.otherwise === null ? "if" : "else"),
|
|
160
|
-
location: { file: seen.file, start: handler.at[0], end: handler.at[1] },
|
|
161
|
-
related,
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
export const exhaustiveCatch = defineRule({
|
|
165
|
-
kind: "project",
|
|
166
|
-
meta: {
|
|
167
|
-
description: "A catch that branches with instanceof handles every error class its try can throw, or rethrows the rest.",
|
|
168
|
-
defaultSeverity: "error",
|
|
169
|
-
tier: "core",
|
|
170
|
-
group: GROUP,
|
|
171
|
-
needsJsDoc: true,
|
|
172
|
-
options: {
|
|
173
|
-
ignoreClasses: option.strings({
|
|
174
|
-
default: [],
|
|
175
|
-
description: "Error classes a catch never needs a branch for, as its instanceof test names them (SyntaxError, *AbortError).",
|
|
176
|
-
}),
|
|
177
|
-
},
|
|
178
|
-
explain: EXPLAIN,
|
|
179
|
-
},
|
|
180
|
-
summarize: (ctx) => summarizeOnce(ctx),
|
|
181
|
-
check(summaries, ctx) {
|
|
182
|
-
const analysis = analyse(summaries);
|
|
183
|
-
for (const seen of everyCatch(analysis)) {
|
|
184
|
-
report(seen, analysis.classes, ctx.options.ignoreClasses, (problem) => {
|
|
185
|
-
ctx.report(problem);
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
},
|
|
189
|
-
});
|
|
190
|
-
//# sourceMappingURL=exhaustive-catch.js.map
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
/** The group the rule belongs to in the docs and in `stateward status`. */
|
|
2
|
-
export declare const GROUP = "react";
|
|
3
|
-
export declare const exhaustiveFallback: import("@stateward/core").ProjectRule<import("./react/boundaries.js").ReactSummary, {
|
|
4
|
-
ignoreClasses: import("@stateward/core").StringsOption;
|
|
5
|
-
}, "react">;
|
|
6
|
-
//# sourceMappingURL=exhaustive-fallback.d.ts.map
|