@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.
Files changed (46) hide show
  1. package/README.md +60 -3
  2. package/dist/catch-hides-unexpected.js +47 -58
  3. package/dist/catch-misses-error.js +57 -67
  4. package/dist/finally-can-throw.js +45 -54
  5. package/dist/inference/format.d.ts +3 -3
  6. package/dist/inference/format.js +5 -5
  7. package/dist/inference/solve.js +1 -1
  8. package/dist/inference/summarize.d.ts +1 -1
  9. package/dist/inference/summarize.js +2 -2
  10. package/dist/must-handle-error.js +58 -66
  11. package/dist/narrow-try.d.ts +6 -1
  12. package/dist/narrow-try.js +137 -77
  13. package/dist/plugin.d.ts +0 -27
  14. package/dist/plugin.js +1 -3
  15. package/dist/react/boundaries.d.ts +3 -3
  16. package/dist/react/boundaries.js +5 -5
  17. package/dist/react/fallbacks.js +2 -2
  18. package/dist/refactors.d.ts +1 -1
  19. package/dist/refactors.js +1 -1
  20. package/dist/render-throw-without-boundary.js +64 -47
  21. package/dist/result-ignored.js +31 -37
  22. package/dist/result-unchecked.js +58 -61
  23. package/dist/rules.meta.d.ts +22 -47
  24. package/dist/rules.meta.js +19 -47
  25. package/dist/throws-doc-mismatch.js +35 -44
  26. package/package.json +2 -2
  27. package/dist/async-handler-unhandled.d.ts +0 -7
  28. package/dist/async-handler-unhandled.js +0 -342
  29. package/dist/catch-without-intent.d.ts +0 -10
  30. package/dist/catch-without-intent.js +0 -590
  31. package/dist/exhaustive-catch.d.ts +0 -6
  32. package/dist/exhaustive-catch.js +0 -190
  33. package/dist/exhaustive-fallback.d.ts +0 -6
  34. package/dist/exhaustive-fallback.js +0 -157
  35. package/dist/finally-may-throw.d.ts +0 -6
  36. package/dist/finally-may-throw.js +0 -184
  37. package/dist/no-swallow.d.ts +0 -8
  38. package/dist/no-swallow.js +0 -320
  39. package/dist/render-throws-without-boundary.d.ts +0 -6
  40. package/dist/render-throws-without-boundary.js +0 -151
  41. package/dist/rethrow-unknown.d.ts +0 -6
  42. package/dist/rethrow-unknown.js +0 -260
  43. package/dist/throw-in-hook.d.ts +0 -8
  44. package/dist/throw-in-hook.js +0 -510
  45. package/dist/throws-matches-doc.d.ts +0 -7
  46. 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. The rules, their options and how to fix what they find:
21
- [docs/packs/errors.md](https://github.com/khoing1111/ts-stateward/blob/main/docs/packs/errors.md).
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 handles some errors with instanceof also rethrows or handles all the others.
10
-
11
- ## Why
12
-
13
- A \`catch\` that handles \`NotFoundError\` and \`AuthError\` with \`instanceof\`, and has no last branch, silently drops 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.
14
-
15
- ## When to use it
16
-
17
- Use it in any code that catches by class. It is on as an error in every preset; keep it on. It needs no knowledge of what the \`try\` throws, so it works even where \`errors/catch-misses-error\` cannot see.
18
-
19
- ## What it catches
20
-
21
- A \`catch (e)\` with an \`if\` / \`else if\` chain or \`switch (true)\` that tests \`e instanceof X\`, and no branch for the other errors. It is fine when:
22
-
23
- - a last \`else\` or \`default\` does something: rethrows, wraps the error with \`{ cause: e }\`, or handles it;
24
- - any statement follows the chain in the \`catch\`, since it runs for every error.
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
- ### Good
30
+ End the chain with a branch for every other error, which rethrows it or handles it:
41
31
 
42
- \`\`\`ts
43
- try {
44
- await save(order);
45
- } catch (e) {
46
- if (e instanceof NotFoundError) {
47
- showMissing();
48
- } else if (e instanceof AuthError) {
49
- redirectToLogin();
50
- } else {
51
- throw e;
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
- ### Fix
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
- A quick fix adds \`else { throw e; }\` to an \`if\` chain. For a \`switch (true)\`, add \`default: throw e;\` yourself; the message shows it.
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\` (below). \`errors/catch-misses-error\` looks at what that \`else\` does with the error.
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) => `errors other than ${codeList(classes, cut)} are handled here without ever using \`${variable}\``);
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) => `errors other than ${codeList(classes, cut)} are silently swallowed here`);
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 for the rest:", [
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 for the rest:", [
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 handles some errors with instanceof also rethrows or handles all the others.",
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
- 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.
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
- ## Why
13
+ \`\`\`ts example-bad
14
+ class AuthError extends Error {}
15
+ class RateLimitError extends Error {}
14
16
 
15
- 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.
16
-
17
- ## When to use it
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
- \`\`\`ts
36
- // api.send can throw RateLimitError and AuthError.
37
- try {
38
- await api.send(message);
39
- } catch (e) {
40
- if (e instanceof AuthError) {
41
- redirectToLogin();
42
- } else {
43
- showGenericError();
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
- ### Good
35
+ Add a branch for each class the message names, and rethrow what you do not handle:
49
36
 
50
- \`\`\`ts
51
- try {
52
- await api.send(message);
53
- } catch (e) {
54
- if (e instanceof AuthError) {
55
- redirectToLogin();
56
- } else if (e instanceof RateLimitError) {
57
- retryLater();
58
- } else {
59
- throw e;
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
- ### Fix
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
- 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.
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
- - A \`catch\` with no \`instanceof\` branches: it treats every error alike on purpose.
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
- ## Error boundary fallbacks
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
- An error boundary's fallback is a catch too: 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 that branches with \`if\` / \`else if\`, \`switch (true)\` or \`? :\` on \`error instanceof X\` is checked the same way: each class the boundary can catch has a branch, or a branch for the rest does more than return \`null\` or nothing. The fallback is react-error-boundary's \`FallbackComponent\`, \`fallbackRender\` or \`onError\`, or a boundary class's \`getDerivedStateFromError\`.
78
+ ## Getting the best from it
80
79
 
81
- \`\`\`tsx
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\`, or whose tests are not only \`instanceof\`, is left alone; so is one passed as anything other than a function written in place or the name of a function of the project.
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" : `\`${cut(via)}\``;
107
- return `this catch does not handle ${codeList(names, cut)}, which ${source} can throw`;
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 stateward cannot list (${source.reason}); rethrow what you do not handle`,
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: "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.",
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
- A finally block does not call something that throws, so the error that led to it is not lost.
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
- ## Why
11
+ \`\`\`ts example-bad
12
+ declare function upload(file: string): Promise<void>;
13
+ declare function restore(state: unknown): void;
12
14
 
13
- 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.
14
-
15
- ## When to use it
16
-
17
- Use it in any code that cleans up in \`finally\`. It is on as an error in every preset; keep it on. Turn it off only if your cleanup's own errors are meant to win.
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
- ### Good
24
+ Give the cleanup its own \`try\` / \`catch\`, or move it out of the \`finally\`:
41
25
 
42
- \`\`\`ts
43
- try {
44
- await upload(file);
45
- } finally {
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
- restore(JSON.parse(saved));
48
- } catch (e) {
49
- report(e);
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
- ### Fix
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
- Move the call out of the \`finally\`, or wrap it in its own \`try\` / \`catch\`. There is no quick fix: what the cleanup should do when it fails is your call.
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
- - Functions from other files, and package calls: only the built-ins above and functions in the same file are read.
61
- - Other built-ins, such as \`fetch\` or \`BigInt\`.
62
- - A function that throws only inside its own \`try\` / \`catch\`.
63
- - Anything inside a nested \`try\` that has a \`catch\`.
64
- - A second throwing call in the same \`finally\`: only the first is reported.
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
- In \`stateward.config.ts\`:
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: () => "this `throw` runs", kind: "throw" };
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}(…)\` throws here`, kind: "builtin" };
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}(…)\` throws here`, kind: "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)}(…)\` throws here`, kind: "own-function" };
126
+ return { node, subject: (cut) => `\`${cut(callee)}(…)\` can throw`, kind: "own-function" };
135
127
  }
136
128
  function headline(cause) {
137
- return fitted((cut) => `if ${cause.subject(cut)}, the error that led to this finally is lost`);
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 of finally, or catch it inside:", [
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: "A finally block does not call something that throws, so the error that led to it is not lost.",
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 stateward cannot see (from a: why; b: why; and 3 more)`. */
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 stateward cannot see (from …)`, or "" for an empty set: a class
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 stateward can see` when both are empty.
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