@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.
- package/LICENSE +21 -0
- package/README.md +20 -2
- package/dist/async-handler-unhandled.d.ts +7 -0
- package/dist/async-handler-unhandled.js +342 -0
- package/dist/catch-hides-unexpected.d.ts +6 -0
- package/dist/catch-hides-unexpected.js +260 -0
- package/dist/catch-misses-error.d.ts +6 -0
- package/dist/catch-misses-error.js +208 -0
- package/dist/catch-without-intent.d.ts +10 -0
- package/dist/catch-without-intent.js +590 -0
- package/dist/exhaustive-catch.d.ts +6 -0
- package/dist/exhaustive-catch.js +190 -0
- package/dist/exhaustive-fallback.d.ts +6 -0
- package/dist/exhaustive-fallback.js +157 -0
- package/dist/finally-can-throw.d.ts +6 -0
- package/dist/finally-can-throw.js +184 -0
- package/dist/finally-may-throw.d.ts +6 -0
- package/dist/finally-may-throw.js +184 -0
- package/dist/hover.d.ts +11 -0
- package/dist/hover.js +62 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +2 -0
- package/dist/inference/builtins.d.ts +52 -0
- package/dist/inference/builtins.js +179 -0
- package/dist/inference/catches.d.ts +9 -0
- package/dist/inference/catches.js +33 -0
- package/dist/inference/deps.d.ts +12 -0
- package/dist/inference/deps.js +79 -0
- package/dist/inference/format.d.ts +25 -0
- package/dist/inference/format.js +72 -0
- package/dist/inference/packages.d.ts +40 -0
- package/dist/inference/packages.js +723 -0
- package/dist/inference/project.d.ts +8 -0
- package/dist/inference/project.js +22 -0
- package/dist/inference/solve.d.ts +128 -0
- package/dist/inference/solve.js +575 -0
- package/dist/inference/summarize.d.ts +50 -0
- package/dist/inference/summarize.js +1324 -0
- package/dist/inference/summary.d.ts +191 -0
- package/dist/inference/summary.js +237 -0
- package/dist/inlay.d.ts +19 -0
- package/dist/inlay.js +168 -0
- package/dist/must-handle-error.d.ts +55 -0
- package/dist/must-handle-error.js +794 -0
- package/dist/narrow-try.d.ts +52 -0
- package/dist/narrow-try.js +641 -0
- package/dist/no-swallow.d.ts +8 -0
- package/dist/no-swallow.js +320 -0
- package/dist/plugin.d.ts +116 -0
- package/dist/plugin.js +39 -0
- package/dist/react/boundaries.d.ts +83 -0
- package/dist/react/boundaries.js +371 -0
- package/dist/react/fallbacks.d.ts +12 -0
- package/dist/react/fallbacks.js +83 -0
- package/dist/react/render-guards.d.ts +24 -0
- package/dist/react/render-guards.js +271 -0
- package/dist/react/tree.d.ts +98 -0
- package/dist/react/tree.js +863 -0
- package/dist/refactors.d.ts +15 -0
- package/dist/refactors.js +69 -0
- package/dist/render-throw-without-boundary.d.ts +11 -0
- package/dist/render-throw-without-boundary.js +223 -0
- package/dist/render-throws-without-boundary.d.ts +6 -0
- package/dist/render-throws-without-boundary.js +151 -0
- package/dist/result-ignored.d.ts +6 -0
- package/dist/result-ignored.js +167 -0
- package/dist/result-type.d.ts +18 -0
- package/dist/result-type.js +55 -0
- package/dist/result-unchecked.d.ts +2 -0
- package/dist/result-unchecked.js +746 -0
- package/dist/rethrow-unknown.d.ts +6 -0
- package/dist/rethrow-unknown.js +260 -0
- package/dist/rules.meta.d.ts +214 -0
- package/dist/rules.meta.js +212 -0
- package/dist/syntax.d.ts +41 -0
- package/dist/syntax.js +171 -0
- package/dist/text.d.ts +22 -0
- package/dist/text.js +43 -0
- package/dist/throw-in-hook.d.ts +8 -0
- package/dist/throw-in-hook.js +510 -0
- package/dist/throws-doc-mismatch.d.ts +7 -0
- package/dist/throws-doc-mismatch.js +188 -0
- package/dist/throws-matches-doc.d.ts +7 -0
- package/dist/throws-matches-doc.js +188 -0
- 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
|