@stateward/plugin-errors 1.0.1-rc.1 → 1.1.0
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 +60 -3
- package/dist/catch-hides-unexpected.js +47 -58
- package/dist/catch-misses-error.js +57 -67
- package/dist/finally-can-throw.js +45 -54
- package/dist/inference/format.d.ts +3 -3
- package/dist/inference/format.js +5 -5
- package/dist/inference/solve.js +1 -1
- package/dist/inference/summarize.d.ts +1 -1
- package/dist/inference/summarize.js +2 -2
- package/dist/must-handle-error.js +58 -66
- package/dist/narrow-try.d.ts +6 -1
- package/dist/narrow-try.js +137 -77
- package/dist/plugin.d.ts +0 -27
- package/dist/plugin.js +1 -3
- package/dist/react/boundaries.d.ts +3 -3
- package/dist/react/boundaries.js +5 -5
- package/dist/react/fallbacks.js +2 -2
- package/dist/refactors.d.ts +1 -1
- package/dist/refactors.js +1 -1
- package/dist/render-throw-without-boundary.js +64 -47
- package/dist/result-ignored.js +31 -37
- package/dist/result-unchecked.js +58 -61
- package/dist/rules.meta.d.ts +22 -47
- package/dist/rules.meta.js +19 -47
- package/dist/throws-doc-mismatch.js +35 -44
- package/package.json +2 -2
- package/dist/async-handler-unhandled.d.ts +0 -7
- package/dist/async-handler-unhandled.js +0 -342
- package/dist/catch-without-intent.d.ts +0 -10
- package/dist/catch-without-intent.js +0 -590
- 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,57 @@ 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/narrow-try"],
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- `errors/narrow-try`: `narrow-try`
|
|
49
|
+
|
|
50
|
+
Every key of the config: [config.md](https://github.com/khoing1111/ts-stateward/blob/main/docs/user/config.md).
|
|
51
|
+
|
|
52
|
+
<!-- /generated:setup -->
|
|
53
|
+
|
|
54
|
+
<!-- generated:rules -->
|
|
55
|
+
|
|
56
|
+
## Rules
|
|
57
|
+
|
|
58
|
+
| Rule | What it finds | Why it matters | Kind | `recommended` | `strict` |
|
|
59
|
+
| --- | --- | --- | --- | --- | --- |
|
|
60
|
+
| [must-handle-error](https://github.com/khoing1111/ts-stateward/blob/main/docs/rules/errors/must-handle-error.md) | a call that can throw an error marked `@mustHandle`, with no code that handles or passes it on | An error marked `@mustHandle` needs a decision at each call, such as a message to the user; a call that neither catches it nor passes it on lets it reach the top. | Core | error | error |
|
|
61
|
+
| [result-ignored](https://github.com/khoing1111/ts-stateward/blob/main/docs/rules/errors/result-ignored.md) | a `Result` dropped as a statement, after `void` or after `await` | A `Result` dropped as a statement discards its error, so a failed call looks the same as a successful one. | Core | error | error |
|
|
62
|
+
| [result-unchecked](https://github.com/khoing1111/ts-stateward/blob/main/docs/rules/errors/result-unchecked.md) | a call that fails in its value (an RTK Query trigger, a Result) used as if it had worked (guess) | A call that returns its failure instead of throwing never reaches the `catch`; without a check, the code after it runs as if the call worked. | Core | warning | warning |
|
|
63
|
+
| [throws-doc-mismatch](https://github.com/khoing1111/ts-stateward/blob/main/docs/rules/errors/throws-doc-mismatch.md) | a `@throws` tag that lists an error the function cannot throw, or misses one it can | Callers decide what to catch from `@throws`: a class it omits reaches them unhandled, and a class listed in excess asks them to handle a case that cannot happen. | Core | error | error |
|
|
64
|
+
| [catch-hides-unexpected](https://github.com/khoing1111/ts-stateward/blob/main/docs/rules/errors/catch-hides-unexpected.md) | a `catch` that handles some error classes with `instanceof` and ignores the rest | A `catch` that tests some error classes and has no last branch ignores every other error, bugs included; rethrowing the rest keeps them visible. | Core | error | error |
|
|
65
|
+
| [catch-misses-error](https://github.com/khoing1111/ts-stateward/blob/main/docs/rules/errors/catch-misses-error.md) | a `catch` that branches on error classes but misses one its `try` can throw | An error class the `try` can throw and the `catch` does not test goes to the generic branch, or is ignored; a branch for it, or a rethrow, handles it explicitly. | Core | error | error |
|
|
66
|
+
| [finally-can-throw](https://github.com/khoing1111/ts-stateward/blob/main/docs/rules/errors/finally-can-throw.md) | a `finally` that calls something that can throw and replace the original error | An error thrown in `finally` replaces the error that was already thrown, so the report names the cleanup instead of the original cause. | Core | error | error |
|
|
67
|
+
| [narrow-try](https://github.com/khoing1111/ts-stateward/blob/main/docs/rules/errors/narrow-try.md) | calls to your own code inside a `try` after the call it is for, so their bugs look like its failure | A bug in code inside a `try` meant for another call is caught and reported as that call's failure; moving the code after the `try` keeps the two apart. | Add-on `narrow-try` | warning | warning |
|
|
68
|
+
| [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, with no error boundary above it | An error thrown while rendering, with no error boundary above the component, unmounts the whole React tree and leaves a blank page. | Core | warning | warning |
|
|
69
|
+
|
|
70
|
+
**Core** rules are on in every preset. **Coverage** rules point at code whose types do not say
|
|
71
|
+
enough for stateward to check it; they are info under `recommended` and errors under `strict`.
|
|
72
|
+
**Add-on** rules stay off until you list their add-on in `addons`; the table shows their severity
|
|
73
|
+
once it is listed. A rule marked (guess) judges from patterns in the code, not only from types, so
|
|
74
|
+
it can be wrong and is never an error unless you make it one.
|
|
75
|
+
|
|
76
|
+
Each rule's page shows an example with what stateward prints for it, the fix, why it matters, what it misses, 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).
|
|
77
|
+
|
|
78
|
+
<!-- /generated:rules -->
|
|
@@ -6,71 +6,66 @@ import { codeList, fitted, oneLine, withExample } from "./text.js";
|
|
|
6
6
|
export const GROUP = "catch";
|
|
7
7
|
const EXPLAIN = `# errors/catch-hides-unexpected
|
|
8
8
|
|
|
9
|
-
A catch that
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
### Bad
|
|
27
|
-
|
|
28
|
-
\`\`\`ts
|
|
29
|
-
try {
|
|
30
|
-
await save(order);
|
|
31
|
-
} catch (e) {
|
|
32
|
-
if (e instanceof NotFoundError) {
|
|
33
|
-
showMissing();
|
|
34
|
-
} else if (e instanceof AuthError) {
|
|
35
|
-
redirectToLogin();
|
|
9
|
+
A \`catch\` that tests some error classes and has no last branch ignores every other error, bugs included; rethrowing the rest keeps them visible.
|
|
10
|
+
|
|
11
|
+
\`\`\`ts example-bad
|
|
12
|
+
class NotFoundError extends Error {}
|
|
13
|
+
class AuthError extends Error {}
|
|
14
|
+
|
|
15
|
+
declare function save(order: string): Promise<void>;
|
|
16
|
+
|
|
17
|
+
export async function submit(order: string, show: (text: string) => void): Promise<void> {
|
|
18
|
+
try {
|
|
19
|
+
await save(order);
|
|
20
|
+
} catch (e) {
|
|
21
|
+
if (e instanceof NotFoundError) {
|
|
22
|
+
show("This order no longer exists.");
|
|
23
|
+
} else if (e instanceof AuthError) {
|
|
24
|
+
show("Please sign in again.");
|
|
25
|
+
}
|
|
36
26
|
}
|
|
37
27
|
}
|
|
38
28
|
\`\`\`
|
|
39
29
|
|
|
40
|
-
|
|
30
|
+
End the chain with a branch for every other error, which rethrows it or handles it:
|
|
41
31
|
|
|
42
|
-
\`\`\`ts
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
32
|
+
\`\`\`ts example-good
|
|
33
|
+
class NotFoundError extends Error {}
|
|
34
|
+
class AuthError extends Error {}
|
|
35
|
+
|
|
36
|
+
declare function save(order: string): Promise<void>;
|
|
37
|
+
|
|
38
|
+
export async function submit(order: string, show: (text: string) => void): Promise<void> {
|
|
39
|
+
try {
|
|
40
|
+
await save(order);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
if (e instanceof NotFoundError) {
|
|
43
|
+
show("This order no longer exists.");
|
|
44
|
+
} else if (e instanceof AuthError) {
|
|
45
|
+
show("Please sign in again.");
|
|
46
|
+
} else {
|
|
47
|
+
throw e;
|
|
48
|
+
}
|
|
52
49
|
}
|
|
53
50
|
}
|
|
54
51
|
\`\`\`
|
|
55
52
|
|
|
56
|
-
|
|
53
|
+
A quick fix adds \`else { throw e; }\` to an \`if\` chain. For a \`switch (true)\`, add \`default: throw e;\` yourself; the message shows it. Wrapping the error with \`{ cause: e }\`, or any statement after the chain, counts too, since it runs for every error.
|
|
57
54
|
|
|
58
|
-
|
|
55
|
+
## Why it matters
|
|
56
|
+
|
|
57
|
+
A \`catch\` that handles \`NotFoundError\` and \`AuthError\` and has no last branch ignores every other error: a bug, a network failure, a string someone threw. \`save(order)\` fails with a \`TypeError\`, neither branch matches, and the code carries on as if the order was saved. The rule needs no knowledge of what the \`try\` throws, so it works even where \`errors/catch-misses-error\` cannot see.
|
|
59
58
|
|
|
60
59
|
## What it does not catch
|
|
61
60
|
|
|
62
|
-
- A chain that tests another value, or tests other than \`instanceof\`.
|
|
61
|
+
- A chain that tests another value, or tests with something other than \`instanceof\`.
|
|
63
62
|
- A \`catch\` with no parameter, or a destructured one.
|
|
64
|
-
- A last \`else\` that does something but still ignores the error (\`else { showGenericError(); }\`): it counts as handling the rest, unless you set \`restUsesError
|
|
65
|
-
- A \`catch\` with no \`instanceof\` chain that ignores the error: that is \`errors/catch-without-intent\`.
|
|
63
|
+
- A last \`else\` that does something but still ignores the error (\`else { showGenericError(); }\`): it counts as handling the rest, unless you set \`restUsesError\`. \`errors/catch-misses-error\` looks at what that \`else\` does with the error.
|
|
66
64
|
|
|
67
65
|
## Turn it on or off
|
|
68
66
|
|
|
69
|
-
In \`stateward.config.ts\`:
|
|
70
|
-
|
|
71
67
|
\`\`\`ts
|
|
72
68
|
rules: { "errors/catch-hides-unexpected": "off" } // or "warning"
|
|
73
|
-
groups: { "errors/catch": "off" } // the whole group
|
|
74
69
|
rules: { "errors/catch-hides-unexpected": ["error", { restUsesError: true }] } // the last branch must use the error
|
|
75
70
|
\`\`\`
|
|
76
71
|
`;
|
|
@@ -155,23 +150,16 @@ function mentions(nodes, variable) {
|
|
|
155
150
|
return nodes.some(visit);
|
|
156
151
|
}
|
|
157
152
|
function ignoringHeadline(classes, variable) {
|
|
158
|
-
return fitted((cut) =>
|
|
153
|
+
return fitted((cut) => `\`catch\` handles errors other than ${codeList(classes, cut)} without looking at \`${variable}\``);
|
|
159
154
|
}
|
|
160
155
|
function headline(classes) {
|
|
161
|
-
return fitted((cut) =>
|
|
156
|
+
return fitted((cut) => `\`catch\` ignores errors other than ${codeList(classes, cut)}`);
|
|
162
157
|
}
|
|
163
158
|
function hint(dispatch, variable) {
|
|
164
159
|
if (dispatch.kind === "switch") {
|
|
165
|
-
return withExample("end the switch with a case
|
|
166
|
-
"default:",
|
|
167
|
-
` throw ${variable};`,
|
|
168
|
-
]);
|
|
160
|
+
return withExample("end the switch with a case that rethrows the rest, so they reach the caller:", ["default:", ` throw ${variable};`]);
|
|
169
161
|
}
|
|
170
|
-
return withExample("end the chain with a branch
|
|
171
|
-
"} else {",
|
|
172
|
-
` throw ${variable};`,
|
|
173
|
-
"}",
|
|
174
|
-
]);
|
|
162
|
+
return withExample("end the chain with a branch that rethrows the rest, so they reach the caller:", ["} else {", ` throw ${variable};`, "}"]);
|
|
175
163
|
}
|
|
176
164
|
/** The whitespace at the start of the line that holds `position`. */
|
|
177
165
|
function indentationAt(position, text) {
|
|
@@ -241,7 +229,8 @@ function checkCatch(clause, ctx) {
|
|
|
241
229
|
export const catchHidesUnexpected = defineRule({
|
|
242
230
|
kind: "file",
|
|
243
231
|
meta: {
|
|
244
|
-
description: "A catch that
|
|
232
|
+
description: "A `catch` that tests some error classes and has no last branch ignores every other error, bugs included; rethrowing the rest keeps them visible.",
|
|
233
|
+
summary: "a `catch` that handles some error classes with `instanceof` and ignores the rest",
|
|
245
234
|
defaultSeverity: "error",
|
|
246
235
|
tier: "core",
|
|
247
236
|
group: GROUP,
|
|
@@ -8,94 +8,83 @@ import { codeList, fitted, withExample } from "./text.js";
|
|
|
8
8
|
export const GROUP = "catch";
|
|
9
9
|
const EXPLAIN = `# errors/catch-misses-error
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
An error class the \`try\` can throw and the \`catch\` does not test goes to the generic branch, or is ignored; a branch for it, or a rethrow, handles it explicitly.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
\`\`\`ts example-bad
|
|
14
|
+
class AuthError extends Error {}
|
|
15
|
+
class RateLimitError extends Error {}
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
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}\`.
|
|
20
|
-
|
|
21
|
-
## What it catches
|
|
22
|
-
|
|
23
|
-
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.
|
|
24
|
-
|
|
25
|
-
What the \`try\` can throw is worked out from \`throw new X(…)\` in the functions it calls, in any file, their \`@throws\` tags (also on a function declared without a body, such as \`declare function\` or a \`.d.ts\` of your own), 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.
|
|
26
|
-
|
|
27
|
-
What counts as handled:
|
|
28
|
-
|
|
29
|
-
- \`e instanceof X\` handles \`X\` and every class that extends it (\`e instanceof Error\` handles them all);
|
|
30
|
-
- 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;
|
|
31
|
-
- 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.
|
|
32
|
-
|
|
33
|
-
### Bad
|
|
17
|
+
function send(text: string, remaining: number): void {
|
|
18
|
+
if (remaining === 0) throw new RateLimitError("slow down");
|
|
19
|
+
if (text === "") throw new AuthError("signed out");
|
|
20
|
+
}
|
|
34
21
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
22
|
+
export function post(text: string, remaining: number, show: (text: string) => void): void {
|
|
23
|
+
try {
|
|
24
|
+
send(text, remaining);
|
|
25
|
+
} catch (e) {
|
|
26
|
+
if (e instanceof AuthError) {
|
|
27
|
+
show("Please sign in again.");
|
|
28
|
+
} else {
|
|
29
|
+
show("Something went wrong.");
|
|
30
|
+
}
|
|
44
31
|
}
|
|
45
32
|
}
|
|
46
33
|
\`\`\`
|
|
47
34
|
|
|
48
|
-
|
|
35
|
+
Add a branch for each class the message names, and rethrow what you do not handle:
|
|
49
36
|
|
|
50
|
-
\`\`\`ts
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
37
|
+
\`\`\`ts example-good
|
|
38
|
+
class AuthError extends Error {}
|
|
39
|
+
class RateLimitError extends Error {}
|
|
40
|
+
|
|
41
|
+
function send(text: string, remaining: number): void {
|
|
42
|
+
if (remaining === 0) throw new RateLimitError("slow down");
|
|
43
|
+
if (text === "") throw new AuthError("signed out");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function post(text: string, remaining: number, show: (text: string) => void): void {
|
|
47
|
+
try {
|
|
48
|
+
send(text, remaining);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
if (e instanceof AuthError) {
|
|
51
|
+
show("Please sign in again.");
|
|
52
|
+
} else if (e instanceof RateLimitError) {
|
|
53
|
+
show("Too many messages; try again in a minute.");
|
|
54
|
+
} else {
|
|
55
|
+
throw e;
|
|
56
|
+
}
|
|
60
57
|
}
|
|
61
58
|
}
|
|
62
59
|
\`\`\`
|
|
63
60
|
|
|
64
|
-
|
|
61
|
+
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.
|
|
62
|
+
|
|
63
|
+
## Why it matters
|
|
65
64
|
|
|
66
|
-
|
|
65
|
+
A \`catch\` that checks \`e instanceof AuthError\` says which errors it expects. When \`send\` can also throw \`RateLimitError\`, a rate limit falls into the generic branch: the user sees "Something went wrong", and the wait that would have worked never happens. The rule lists, at the \`catch\`, the classes it leaves out, so a new error class reaches every place that has to decide about it.
|
|
67
66
|
|
|
68
67
|
## What it does not catch
|
|
69
68
|
|
|
70
|
-
|
|
71
|
-
- 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/catch-hides-unexpected\` makes sure the rest is not dropped. To fill a gap, declare a package's errors under \`facts\` in the config.
|
|
72
|
-
- 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.
|
|
73
|
-
- Errors from the JavaScript engine itself, such as reading a property of \`undefined\`.
|
|
74
|
-
- 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.
|
|
75
|
-
- Promises kept in a \`let\`, a parameter or a property; getters, setters and generators; tests other than \`instanceof\` (\`e.name === "AbortError"\`).
|
|
69
|
+
What counts as handled: \`e instanceof X\` handles \`X\` and every class that extends it (\`e instanceof Error\` handles them all); 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; and one that uses the error (\`setError(e)\`, logging it, reading a property) handles the rest. An \`else\` that ignores the error, like \`show("Something went wrong.")\` above, does not. The rule is silent on:
|
|
76
70
|
|
|
77
|
-
|
|
71
|
+
- a \`catch\` with no \`instanceof\` branches: it treats every error alike on purpose;
|
|
72
|
+
- errors stateward cannot see: from a package it cannot read, a callback, or a dynamic call. The message notes each call it could not see into, with the reason, and \`errors/catch-hides-unexpected\` makes sure the rest is not dropped;
|
|
73
|
+
- a class whose \`extends\` cannot be followed, or a branch whose test is not only \`instanceof\` (\`e instanceof A && e.code === 1\`, \`isAbort(e)\`, \`e.name === "AbortError"\`): it does not guess what that branch handles;
|
|
74
|
+
- errors from the JavaScript engine itself, such as reading a property of \`undefined\`;
|
|
75
|
+
- 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;
|
|
76
|
+
- promises kept in a \`let\`, a parameter or a property; getters, setters and generators.
|
|
78
77
|
|
|
79
|
-
|
|
78
|
+
## Getting the best from it
|
|
80
79
|
|
|
81
|
-
|
|
82
|
-
// SettingsPage, under this boundary, can throw ParseError.
|
|
83
|
-
function UserErrorPage({ error }: FallbackProps) {
|
|
84
|
-
if (error instanceof NotFoundError) return <p>This user does not exist.</p>;
|
|
85
|
-
return <p>Something went wrong: {String(error)}</p>; // a branch for the rest
|
|
86
|
-
}
|
|
87
|
-
\`\`\`
|
|
80
|
+
What the \`try\` can throw is worked out from \`throw new X(…)\` in the functions it calls, in any file; their \`@throws {X}\` tags (also on a function with no body, such as \`declare function\` or a \`.d.ts\` of your own); the built-ins above; and the packages it calls (\`stateward deps\` shows how well each package's errors are known). So throw classes, not strings, and document what a function throws with \`@throws\`. Declare a package's errors under \`facts\` in the config: \`facts: { "config-lib": { load: { throws: ["ConfigError"] } } }\`.
|
|
88
81
|
|
|
89
|
-
A fallback that does not branch on \`instanceof\`,
|
|
82
|
+
An error boundary's fallback is checked the same way: what the components below the boundary throw while rendering and in their effects, down to the next boundary, is its \`try\` (the hover on the boundary lists it). A fallback (react-error-boundary's \`FallbackComponent\`, \`fallbackRender\` or \`onError\`, or a boundary class's \`getDerivedStateFromError\`) that branches with \`if\` / \`else if\`, \`switch (true)\` or \`? :\` on \`error instanceof X\` needs a branch for each class the boundary can catch, or a branch for the rest that does more than return \`null\` or nothing. A fallback that does not branch on \`instanceof\`, whose tests are not only \`instanceof\`, or that is passed as anything other than a function written in place or the name of one of your functions, is left alone.
|
|
90
83
|
|
|
91
84
|
## Turn it on or off
|
|
92
85
|
|
|
93
|
-
In \`stateward.config.ts\`:
|
|
94
|
-
|
|
95
86
|
\`\`\`ts
|
|
96
87
|
rules: { "errors/catch-misses-error": "off" } // or "warning"
|
|
97
|
-
groups: { "errors/catch": "off" } // the whole group
|
|
98
|
-
facts: { "config-lib": { load: { throws: ["ConfigError"] } } } // declare a package's errors
|
|
99
88
|
rules: { "errors/catch-misses-error": ["error", { ignoreClasses: ["SyntaxError"] }] } // never require a branch for these
|
|
100
89
|
\`\`\`
|
|
101
90
|
`;
|
|
@@ -103,8 +92,8 @@ rules: { "errors/catch-misses-error": ["error", { ignoreClasses: ["SyntaxError"]
|
|
|
103
92
|
const MAX_UNKNOWN_NOTES = 2;
|
|
104
93
|
function headline(names, via) {
|
|
105
94
|
return fitted((cut) => {
|
|
106
|
-
const source = via === undefined ? "its `try` block" :
|
|
107
|
-
return
|
|
95
|
+
const source = via === undefined ? "thrown in its `try` block" : `from \`${cut(via)}\``;
|
|
96
|
+
return `\`catch\` does not handle ${codeList(names, cut)} ${source}`;
|
|
108
97
|
});
|
|
109
98
|
}
|
|
110
99
|
/** The fix, in the shape of the catch: a `case`, an `else if`, or an `if` for a chain without `else`. */
|
|
@@ -166,7 +155,7 @@ function report(seen, classes, ignored, emit) {
|
|
|
166
155
|
for (const source of unknowns.slice(0, MAX_UNKNOWN_NOTES)) {
|
|
167
156
|
related.push({
|
|
168
157
|
location: { file: source.file, start: source.start, end: source.end },
|
|
169
|
-
message: `\`${source.text}\` may also throw errors
|
|
158
|
+
message: `\`${source.text}\` may also throw errors that are not known (${source.reason}); rethrow what you do not handle`,
|
|
170
159
|
});
|
|
171
160
|
}
|
|
172
161
|
emit({
|
|
@@ -179,7 +168,8 @@ function report(seen, classes, ignored, emit) {
|
|
|
179
168
|
export const catchMissesError = defineRule({
|
|
180
169
|
kind: "project",
|
|
181
170
|
meta: {
|
|
182
|
-
description: "
|
|
171
|
+
description: "An error class the `try` can throw and the `catch` does not test goes to the generic branch, or is ignored; a branch for it, or a rethrow, handles it explicitly.",
|
|
172
|
+
summary: "a `catch` that branches on error classes but misses one its `try` can throw",
|
|
183
173
|
defaultSeverity: "error",
|
|
184
174
|
tier: "core",
|
|
185
175
|
group: GROUP,
|
|
@@ -6,70 +6,62 @@ import { fitted, oneLine, withExample } from "./text.js";
|
|
|
6
6
|
export const GROUP = "catch";
|
|
7
7
|
const EXPLAIN = `# errors/finally-can-throw
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
An error thrown in \`finally\` replaces the error that was already thrown, so the report names the cleanup instead of the original cause.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
\`\`\`ts example-bad
|
|
12
|
+
declare function upload(file: string): Promise<void>;
|
|
13
|
+
declare function restore(state: unknown): void;
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
## What it catches
|
|
20
|
-
|
|
21
|
-
In a \`finally\` block, outside a nested \`try\` that has a \`catch\`:
|
|
22
|
-
|
|
23
|
-
- a call to a built-in that throws: \`JSON.parse\`, \`JSON.stringify\` (on circular values), \`new URL\`, \`decodeURIComponent\`, \`decodeURI\`, \`structuredClone\`, \`atob\`;
|
|
24
|
-
- a \`throw\`;
|
|
25
|
-
- a call to a function in the same file that has a \`throw\` outside its own \`try\` / \`catch\` (an \`async\` one when the call is awaited).
|
|
26
|
-
|
|
27
|
-
One finding per \`finally\`, on the first of these.
|
|
28
|
-
|
|
29
|
-
### Bad
|
|
30
|
-
|
|
31
|
-
\`\`\`ts
|
|
32
|
-
try {
|
|
33
|
-
await upload(file);
|
|
34
|
-
} finally {
|
|
35
|
-
const state = JSON.parse(saved);
|
|
36
|
-
restore(state);
|
|
15
|
+
export async function send(file: string, saved: string): Promise<void> {
|
|
16
|
+
try {
|
|
17
|
+
await upload(file);
|
|
18
|
+
} finally {
|
|
19
|
+
restore(JSON.parse(saved));
|
|
20
|
+
}
|
|
37
21
|
}
|
|
38
22
|
\`\`\`
|
|
39
23
|
|
|
40
|
-
|
|
24
|
+
Give the cleanup its own \`try\` / \`catch\`, or move it out of the \`finally\`:
|
|
41
25
|
|
|
42
|
-
\`\`\`ts
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
26
|
+
\`\`\`ts example-good
|
|
27
|
+
declare function upload(file: string): Promise<void>;
|
|
28
|
+
declare function restore(state: unknown): void;
|
|
29
|
+
declare function report(error: unknown): void;
|
|
30
|
+
|
|
31
|
+
export async function send(file: string, saved: string): Promise<void> {
|
|
46
32
|
try {
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
33
|
+
await upload(file);
|
|
34
|
+
} finally {
|
|
35
|
+
try {
|
|
36
|
+
restore(JSON.parse(saved));
|
|
37
|
+
} catch (e) {
|
|
38
|
+
report(e);
|
|
39
|
+
}
|
|
50
40
|
}
|
|
51
41
|
}
|
|
52
42
|
\`\`\`
|
|
53
43
|
|
|
54
|
-
|
|
44
|
+
There is no quick fix: what the cleanup should do when it fails is your call.
|
|
45
|
+
|
|
46
|
+
## Why it matters
|
|
55
47
|
|
|
56
|
-
|
|
48
|
+
When code in a \`finally\` throws, its error replaces the one that sent the code there. An upload that fails with a network error then reports a \`SyntaxError\` from parsing the saved state: the real cause, with its message and stack, is gone, and the rest of the cleanup does not run.
|
|
57
49
|
|
|
58
50
|
## What it does not catch
|
|
59
51
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
-
|
|
63
|
-
-
|
|
64
|
-
-
|
|
52
|
+
It looks, in a \`finally\` and outside a nested \`try\` that has a \`catch\`, for a \`throw\`; a call to a built-in that throws (\`JSON.parse\`, \`JSON.stringify\` on circular values, \`new URL\`, \`decodeURIComponent\`, \`decodeURI\`, \`structuredClone\`, \`atob\`); or a call to a function in the same file that throws outside its own \`try\` / \`catch\` (an \`async\` one when the call is awaited). It reports the first of these in each \`finally\`. It does not see:
|
|
53
|
+
|
|
54
|
+
- functions from other files, and package calls;
|
|
55
|
+
- other built-ins, such as \`fetch\` or \`BigInt\`;
|
|
56
|
+
- a function that throws only inside its own \`try\` / \`catch\`;
|
|
57
|
+
- a second throwing call in the same \`finally\`.
|
|
65
58
|
|
|
66
59
|
## Turn it on or off
|
|
67
60
|
|
|
68
|
-
|
|
61
|
+
Turn it off only if your cleanup's own errors are meant to win:
|
|
69
62
|
|
|
70
63
|
\`\`\`ts
|
|
71
64
|
rules: { "errors/finally-can-throw": "off" } // or "warning"
|
|
72
|
-
groups: { "errors/finally": "off" } // the whole group
|
|
73
65
|
rules: { "errors/finally-can-throw": ["error", { severities: { builtin: "warning" } }] } // built-ins only warn
|
|
74
66
|
\`\`\`
|
|
75
67
|
`;
|
|
@@ -108,13 +100,13 @@ const KINDS = {
|
|
|
108
100
|
};
|
|
109
101
|
function causeOf(node, awaited, ctx) {
|
|
110
102
|
if (ts.isThrowStatement(node))
|
|
111
|
-
return { node, subject: () => "
|
|
103
|
+
return { node, subject: () => "", kind: "throw" };
|
|
112
104
|
if (ts.isNewExpression(node)) {
|
|
113
105
|
const callee = stripParentheses(node.expression);
|
|
114
106
|
if (ts.isIdentifier(callee) &&
|
|
115
107
|
THROWING_CONSTRUCTORS.has(callee.text) &&
|
|
116
108
|
isBuiltinGlobal(callee, ctx)) {
|
|
117
|
-
return { node, subject: () => `\`new ${callee.text}(…)\`
|
|
109
|
+
return { node, subject: () => `\`new ${callee.text}(…)\` can throw`, kind: "builtin" };
|
|
118
110
|
}
|
|
119
111
|
return undefined;
|
|
120
112
|
}
|
|
@@ -122,7 +114,7 @@ function causeOf(node, awaited, ctx) {
|
|
|
122
114
|
return undefined;
|
|
123
115
|
const builtin = builtinCall(node, ctx);
|
|
124
116
|
if (builtin !== undefined) {
|
|
125
|
-
return { node, subject: () => `\`${builtin}(…)\`
|
|
117
|
+
return { node, subject: () => `\`${builtin}(…)\` can throw`, kind: "builtin" };
|
|
126
118
|
}
|
|
127
119
|
const declared = sameFileFunction(node.expression, ctx);
|
|
128
120
|
if (declared === undefined || !throwsUncaught(declared))
|
|
@@ -131,16 +123,14 @@ function causeOf(node, awaited, ctx) {
|
|
|
131
123
|
if (hasModifier(declared, ts.SyntaxKind.AsyncKeyword) && !awaited)
|
|
132
124
|
return undefined;
|
|
133
125
|
const callee = oneLine(stripParentheses(node.expression).getText(ctx.sourceFile));
|
|
134
|
-
return { node, subject: (cut) => `\`${cut(callee)}(…)\`
|
|
126
|
+
return { node, subject: (cut) => `\`${cut(callee)}(…)\` can throw`, kind: "own-function" };
|
|
135
127
|
}
|
|
136
128
|
function headline(cause) {
|
|
137
|
-
|
|
129
|
+
if (cause.kind === "throw")
|
|
130
|
+
return "a `throw` in `finally` replaces the original error";
|
|
131
|
+
return fitted((cut) => `a throw in \`finally\` replaces the original error: ${cause.subject(cut)}`);
|
|
138
132
|
}
|
|
139
|
-
const HINT = withExample("move it out
|
|
140
|
-
"finally {",
|
|
141
|
-
" try { cleanup(); } catch (e) { report(e); }",
|
|
142
|
-
"}",
|
|
143
|
-
]);
|
|
133
|
+
const HINT = withExample("catch it inside the `finally`, or move it out, so the original error is kept:", ["finally {", " try { cleanup(); } catch (e) { report(e); }", "}"]);
|
|
144
134
|
/** `shown` holds what is already reported: a `finally` inside a `finally` is looked at twice. */
|
|
145
135
|
function checkFinally(block, shown, ctx) {
|
|
146
136
|
let found;
|
|
@@ -158,7 +148,8 @@ function checkFinally(block, shown, ctx) {
|
|
|
158
148
|
export const finallyCanThrow = defineRule({
|
|
159
149
|
kind: "file",
|
|
160
150
|
meta: {
|
|
161
|
-
description: "
|
|
151
|
+
description: "An error thrown in `finally` replaces the error that was already thrown, so the report names the cleanup instead of the original cause.",
|
|
152
|
+
summary: "a `finally` that calls something that can throw and replace the original error",
|
|
162
153
|
defaultSeverity: "error",
|
|
163
154
|
tier: "core",
|
|
164
155
|
group: GROUP,
|
|
@@ -4,10 +4,10 @@ import type { ClassFact } from "./summary.js";
|
|
|
4
4
|
export declare function className(id: string, classes: ReadonlyMap<string, ClassFact>): string;
|
|
5
5
|
/** The name an `instanceof` test or a `@throws` tag uses for the class. */
|
|
6
6
|
export declare function catchName(id: string, classes: ReadonlyMap<string, ClassFact>): string;
|
|
7
|
-
/** `others
|
|
7
|
+
/** `others not known (from a: why; b: why; and 3 more)`. */
|
|
8
8
|
export declare function unknownText(sources: readonly Source[], more: boolean): string;
|
|
9
9
|
/**
|
|
10
|
-
* `ParseError | ZodError (zod 3.23.8: declared) | others
|
|
10
|
+
* `ParseError | ZodError (zod 3.23.8: declared) | others not known (from …)`, or "" for an empty set: a class
|
|
11
11
|
* known from a package's facts names where they come from.
|
|
12
12
|
*/
|
|
13
13
|
export declare function thrownText(thrown: Thrown, classes: ReadonlyMap<string, ClassFact>): string;
|
|
@@ -19,7 +19,7 @@ export declare function thrownText(thrown: Thrown, classes: ReadonlyMap<string,
|
|
|
19
19
|
export declare function rejectsShown(result: Result): Thrown;
|
|
20
20
|
/**
|
|
21
21
|
* The hover's lines: `throws: …` for what a call throws, `rejects: …` for what its promise
|
|
22
|
-
* rejects with, `throws nothing
|
|
22
|
+
* rejects with, `throws nothing known` when both are empty.
|
|
23
23
|
*/
|
|
24
24
|
export declare function resultLines(result: Result, classes: ReadonlyMap<string, ClassFact>): string[];
|
|
25
25
|
//# sourceMappingURL=format.d.ts.map
|