@stateward/plugin-errors 0.0.0-stage → 1.0.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 (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +20 -2
  3. package/dist/async-handler-unhandled.d.ts +7 -0
  4. package/dist/async-handler-unhandled.js +342 -0
  5. package/dist/catch-hides-unexpected.d.ts +6 -0
  6. package/dist/catch-hides-unexpected.js +260 -0
  7. package/dist/catch-misses-error.d.ts +6 -0
  8. package/dist/catch-misses-error.js +208 -0
  9. package/dist/catch-without-intent.d.ts +10 -0
  10. package/dist/catch-without-intent.js +590 -0
  11. package/dist/exhaustive-catch.d.ts +6 -0
  12. package/dist/exhaustive-catch.js +190 -0
  13. package/dist/exhaustive-fallback.d.ts +6 -0
  14. package/dist/exhaustive-fallback.js +157 -0
  15. package/dist/finally-can-throw.d.ts +6 -0
  16. package/dist/finally-can-throw.js +184 -0
  17. package/dist/finally-may-throw.d.ts +6 -0
  18. package/dist/finally-may-throw.js +184 -0
  19. package/dist/hover.d.ts +11 -0
  20. package/dist/hover.js +62 -0
  21. package/dist/index.d.ts +7 -0
  22. package/dist/index.js +2 -0
  23. package/dist/inference/builtins.d.ts +52 -0
  24. package/dist/inference/builtins.js +179 -0
  25. package/dist/inference/catches.d.ts +9 -0
  26. package/dist/inference/catches.js +33 -0
  27. package/dist/inference/deps.d.ts +12 -0
  28. package/dist/inference/deps.js +79 -0
  29. package/dist/inference/format.d.ts +25 -0
  30. package/dist/inference/format.js +72 -0
  31. package/dist/inference/packages.d.ts +40 -0
  32. package/dist/inference/packages.js +723 -0
  33. package/dist/inference/project.d.ts +8 -0
  34. package/dist/inference/project.js +22 -0
  35. package/dist/inference/solve.d.ts +128 -0
  36. package/dist/inference/solve.js +575 -0
  37. package/dist/inference/summarize.d.ts +50 -0
  38. package/dist/inference/summarize.js +1324 -0
  39. package/dist/inference/summary.d.ts +191 -0
  40. package/dist/inference/summary.js +237 -0
  41. package/dist/inlay.d.ts +19 -0
  42. package/dist/inlay.js +168 -0
  43. package/dist/must-handle-error.d.ts +55 -0
  44. package/dist/must-handle-error.js +794 -0
  45. package/dist/narrow-try.d.ts +52 -0
  46. package/dist/narrow-try.js +641 -0
  47. package/dist/no-swallow.d.ts +8 -0
  48. package/dist/no-swallow.js +320 -0
  49. package/dist/plugin.d.ts +116 -0
  50. package/dist/plugin.js +39 -0
  51. package/dist/react/boundaries.d.ts +83 -0
  52. package/dist/react/boundaries.js +371 -0
  53. package/dist/react/fallbacks.d.ts +12 -0
  54. package/dist/react/fallbacks.js +83 -0
  55. package/dist/react/render-guards.d.ts +24 -0
  56. package/dist/react/render-guards.js +271 -0
  57. package/dist/react/tree.d.ts +98 -0
  58. package/dist/react/tree.js +863 -0
  59. package/dist/refactors.d.ts +15 -0
  60. package/dist/refactors.js +69 -0
  61. package/dist/render-throw-without-boundary.d.ts +11 -0
  62. package/dist/render-throw-without-boundary.js +223 -0
  63. package/dist/render-throws-without-boundary.d.ts +6 -0
  64. package/dist/render-throws-without-boundary.js +151 -0
  65. package/dist/result-ignored.d.ts +6 -0
  66. package/dist/result-ignored.js +167 -0
  67. package/dist/result-type.d.ts +18 -0
  68. package/dist/result-type.js +55 -0
  69. package/dist/result-unchecked.d.ts +2 -0
  70. package/dist/result-unchecked.js +746 -0
  71. package/dist/rethrow-unknown.d.ts +6 -0
  72. package/dist/rethrow-unknown.js +260 -0
  73. package/dist/rules.meta.d.ts +214 -0
  74. package/dist/rules.meta.js +212 -0
  75. package/dist/syntax.d.ts +41 -0
  76. package/dist/syntax.js +171 -0
  77. package/dist/text.d.ts +22 -0
  78. package/dist/text.js +43 -0
  79. package/dist/throw-in-hook.d.ts +8 -0
  80. package/dist/throw-in-hook.js +510 -0
  81. package/dist/throws-doc-mismatch.d.ts +7 -0
  82. package/dist/throws-doc-mismatch.js +188 -0
  83. package/dist/throws-matches-doc.d.ts +7 -0
  84. package/dist/throws-matches-doc.js +188 -0
  85. package/package.json +34 -3
@@ -0,0 +1,208 @@
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 { modelOf, summarizeReact, throwsOf } from "./react/boundaries.js";
5
+ import { checkFallbacks } from "./react/fallbacks.js";
6
+ import { codeList, fitted, withExample } from "./text.js";
7
+ /** The group the rule belongs to in the docs and in `stateward status`. */
8
+ export const GROUP = "catch";
9
+ const EXPLAIN = `# errors/catch-misses-error
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.
12
+
13
+ ## Why
14
+
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
34
+
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();
44
+ }
45
+ }
46
+ \`\`\`
47
+
48
+ ### Good
49
+
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;
60
+ }
61
+ }
62
+ \`\`\`
63
+
64
+ ### Fix
65
+
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.
67
+
68
+ ## What it does not catch
69
+
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"\`).
76
+
77
+ ## Error boundary fallbacks
78
+
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\`.
80
+
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
+ \`\`\`
88
+
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.
90
+
91
+ ## Turn it on or off
92
+
93
+ In \`stateward.config.ts\`:
94
+
95
+ \`\`\`ts
96
+ 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
+ rules: { "errors/catch-misses-error": ["error", { ignoreClasses: ["SyntaxError"] }] } // never require a branch for these
100
+ \`\`\`
101
+ `;
102
+ /** At most this many package calls with unknown errors are noted on a finding. */
103
+ const MAX_UNKNOWN_NOTES = 2;
104
+ function headline(names, via) {
105
+ 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`;
108
+ });
109
+ }
110
+ /** The fix, in the shape of the catch: a `case`, an `else if`, or an `if` for a chain without `else`. */
111
+ function hint(count, variable, handler, shape) {
112
+ const branch = count === 1 ? "it" : "each";
113
+ switch (shape) {
114
+ case "switch":
115
+ return withExample(`add a branch for ${branch}, or rethrow what you do not handle:`, [
116
+ `case ${variable} instanceof ${handler}:`,
117
+ " …",
118
+ "default:",
119
+ ` throw ${variable};`,
120
+ ]);
121
+ case "else":
122
+ return withExample(`add a branch for ${branch}, or rethrow what you do not handle:`, [
123
+ `} else if (${variable} instanceof ${handler}) {`,
124
+ " …",
125
+ "} else {",
126
+ ` throw ${variable};`,
127
+ ]);
128
+ case "if":
129
+ return withExample(`add a branch for ${branch}, or rethrow what you do not handle after the chain:`, [`if (${variable} instanceof ${handler}) {`, " …", "}", `throw ${variable};`]);
130
+ default: {
131
+ const unreachable = shape;
132
+ return unreachable;
133
+ }
134
+ }
135
+ }
136
+ function report(seen, classes, ignored, emit) {
137
+ const { handler } = seen;
138
+ if (handler.restThrows || handler.restUsed || handler.variable === null)
139
+ return;
140
+ const missing = [];
141
+ for (const [id, info] of seen.unhandled) {
142
+ if (info.certain && !matchesName(catchName(id, classes), ignored)) {
143
+ missing.push({ id, via: info.via });
144
+ }
145
+ }
146
+ const [first] = missing;
147
+ if (first === undefined)
148
+ return;
149
+ const vias = new Set(missing.map((one) => one.via?.text));
150
+ const via = vias.size === 1 ? first.via?.text : undefined;
151
+ const related = [];
152
+ const shown = new Set();
153
+ for (const { id, via: from } of missing) {
154
+ if (from === null || shown.has(`${String(from.start)}:${from.text}`) || related.length >= 3) {
155
+ continue;
156
+ }
157
+ shown.add(`${String(from.start)}:${from.text}`);
158
+ related.push({
159
+ location: { file: from.file, start: from.start, end: from.end },
160
+ message: `\`${from.text}\` can throw \`${className(id, classes)}\``,
161
+ });
162
+ }
163
+ // A package call whose errors are not all known: the catch passes them on (they reach the
164
+ // rethrow, or nothing is reported for them), and the note says which call and why.
165
+ const unknowns = [...seen.thrown.unknowns.values()].filter((one) => one.package !== undefined);
166
+ for (const source of unknowns.slice(0, MAX_UNKNOWN_NOTES)) {
167
+ related.push({
168
+ 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`,
170
+ });
171
+ }
172
+ emit({
173
+ message: headline(missing.map((one) => className(one.id, classes)), via),
174
+ hint: hint(missing.length, handler.variable, catchName(first.id, classes), handler.dispatch === "switch" ? "switch" : handler.otherwise === null ? "if" : "else"),
175
+ location: { file: seen.file, start: handler.at[0], end: handler.at[1] },
176
+ related,
177
+ });
178
+ }
179
+ export const catchMissesError = defineRule({
180
+ kind: "project",
181
+ 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.",
183
+ defaultSeverity: "error",
184
+ tier: "core",
185
+ group: GROUP,
186
+ needsJsDoc: true,
187
+ options: {
188
+ ignoreClasses: option.strings({
189
+ default: [],
190
+ description: "Error classes a catch never needs a branch for, as its instanceof test names them (SyntaxError, *AbortError).",
191
+ }),
192
+ },
193
+ explain: EXPLAIN,
194
+ },
195
+ summarize: (ctx) => summarizeReact(ctx),
196
+ check(summaries, ctx) {
197
+ const analysis = analyse(throwsOf(summaries));
198
+ for (const seen of everyCatch(analysis)) {
199
+ report(seen, analysis.classes, ctx.options.ignoreClasses, (problem) => {
200
+ ctx.report(problem);
201
+ });
202
+ }
203
+ checkFallbacks(modelOf(summaries), ctx.options.ignoreClasses, (problem) => {
204
+ ctx.report(problem);
205
+ });
206
+ },
207
+ });
208
+ //# sourceMappingURL=catch-misses-error.js.map
@@ -0,0 +1,10 @@
1
+ /** The group the rule belongs to in the docs and in `stateward status`. */
2
+ export declare const GROUP = "catch";
3
+ export declare const catchWithoutIntent: import("@stateward/core").FileRule<{
4
+ loggers: import("@stateward/core").StringsOption;
5
+ logMethods: import("@stateward/core").StringsOption;
6
+ logFunctions: import("@stateward/core").StringsOption;
7
+ edgeHelpers: import("@stateward/core").StringsOption;
8
+ swallowHelpers: import("@stateward/core").StringsOption;
9
+ }, "catch">;
10
+ //# sourceMappingURL=catch-without-intent.d.ts.map