@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 the stateward authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @stateward/plugin-errors
|
|
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
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install --save-dev stateward @stateward/plugin-errors
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
// stateward.config.ts
|
|
11
|
+
import { defineConfig } from "stateward";
|
|
12
|
+
import { errors } from "@stateward/plugin-errors";
|
|
13
|
+
|
|
14
|
+
export default defineConfig({
|
|
15
|
+
plugins: [errors()],
|
|
16
|
+
preset: "recommended",
|
|
17
|
+
});
|
|
18
|
+
```
|
|
19
|
+
|
|
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).
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** The group the rule belongs to in the docs and in `stateward status`. */
|
|
2
|
+
export declare const GROUP = "async";
|
|
3
|
+
export declare const asyncHandlerUnhandled: import("@stateward/core").FileRule<{
|
|
4
|
+
timers: import("@stateward/core").StringsOption;
|
|
5
|
+
ignoreProps: import("@stateward/core").StringsOption;
|
|
6
|
+
}, "async">;
|
|
7
|
+
//# sourceMappingURL=async-handler-unhandled.d.ts.map
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import ts from "@stateward/core/typescript";
|
|
2
|
+
import { defineRule, matchesName, option } from "@stateward/core";
|
|
3
|
+
import { hasModifier, isBuiltinGlobal, scanRunning, sameFileFunction, stripParentheses, } from "./syntax.js";
|
|
4
|
+
import { pathOf } from "./inference/packages.js";
|
|
5
|
+
import { HEADLINE_MAX, fitted, oneLine, shorten, withExample } from "./text.js";
|
|
6
|
+
/** The group the rule belongs to in the docs and in `stateward status`. */
|
|
7
|
+
export const GROUP = "async";
|
|
8
|
+
const EXPLAIN = `# errors/async-handler-unhandled
|
|
9
|
+
|
|
10
|
+
An async event handler, listener or timer callback catches its own failures, since nothing awaits it.
|
|
11
|
+
|
|
12
|
+
## Why
|
|
13
|
+
|
|
14
|
+
Nothing awaits an \`onClick\`, a listener or a timer callback. When an \`async\` one fails after an \`await\`, its promise rejects with no code waiting for it: the console shows an unhandled rejection, the user sees nothing, and whatever the handler was changing is left half done. A Save button whose \`await save()\` fails looks like it did nothing.
|
|
15
|
+
|
|
16
|
+
## When to use it
|
|
17
|
+
|
|
18
|
+
Use it in any app with async event handlers: React components, DOM listeners, timers. It is on as an error in every preset. Turn it off only if a global \`unhandledrejection\` handler is how you deal with these on purpose.
|
|
19
|
+
|
|
20
|
+
## What it catches
|
|
21
|
+
|
|
22
|
+
An \`async\` function written in place as:
|
|
23
|
+
|
|
24
|
+
- a JSX prop named \`on\` plus a capital letter (\`onClick\`, \`onSubmit\`);
|
|
25
|
+
- the listener of \`addEventListener\`;
|
|
26
|
+
- the callback of \`setTimeout\`, \`setInterval\` or \`queueMicrotask\` (the \`timers\` option adds more);
|
|
27
|
+
|
|
28
|
+
when it awaits outside a \`try\` with a \`catch\`, or returns a promise from \`fetch\`, \`Promise.all\` and similar, or an \`async\` function in the same file. An \`await\` in a \`catch\` or \`finally\` block is not protected by that \`try\`.
|
|
29
|
+
|
|
30
|
+
### Bad
|
|
31
|
+
|
|
32
|
+
\`\`\`tsx
|
|
33
|
+
<button onClick={async () => {
|
|
34
|
+
await save();
|
|
35
|
+
}}>Save</button>
|
|
36
|
+
\`\`\`
|
|
37
|
+
|
|
38
|
+
### Good
|
|
39
|
+
|
|
40
|
+
\`\`\`tsx
|
|
41
|
+
<button onClick={async () => {
|
|
42
|
+
try { await save(); } catch (e) { showError(e); }
|
|
43
|
+
}}>Save</button>
|
|
44
|
+
\`\`\`
|
|
45
|
+
|
|
46
|
+
### Fix
|
|
47
|
+
|
|
48
|
+
Wrap the body in \`try\` / \`catch\` and show, retry or record the failure. There is no quick fix: what to do with it is your call.
|
|
49
|
+
|
|
50
|
+
## What it does not catch
|
|
51
|
+
|
|
52
|
+
- A call that catches its own failure (\`await save().catch(showError)\`).
|
|
53
|
+
- A promise that reports failure in its value instead of rejecting: its type has an \`unwrap()\` method and it resolves to data or an \`error\` (RTK Query's mutation and lazy-query triggers, \`await addTodo(todo)\`). \`await addTodo(todo).unwrap()\` rejects, and is reported.
|
|
54
|
+
- A package function the config's \`facts\` say never fails: \`facts: { "my-client": { send: { rejects: [] } } }\`.
|
|
55
|
+
- A handler passed by name (\`onClick={save}\`): only functions written in place are read.
|
|
56
|
+
- A prop whose type returns a promise (\`onSave: () => Promise<void>\`): the component that declares it awaits it.
|
|
57
|
+
- Other callbacks nobody awaits: \`.then\` callbacks, \`setImmediate\`, observers, \`useEffect\` callbacks.
|
|
58
|
+
- A handler with no \`await\` and no promise the rule recognises.
|
|
59
|
+
|
|
60
|
+
## Turn it on or off
|
|
61
|
+
|
|
62
|
+
In \`stateward.config.ts\`:
|
|
63
|
+
|
|
64
|
+
\`\`\`ts
|
|
65
|
+
rules: { "errors/async-handler-unhandled": "off" } // or "warning"
|
|
66
|
+
groups: { "errors/async": "off" } // the whole group
|
|
67
|
+
rules: { "errors/async-handler-unhandled": ["error", { ignoreProps: ["onSubmit"] }] } // a prop your form library awaits
|
|
68
|
+
\`\`\`
|
|
69
|
+
`;
|
|
70
|
+
const OPTIONS = {
|
|
71
|
+
timers: option.strings({
|
|
72
|
+
default: ["setTimeout", "setInterval", "queueMicrotask"],
|
|
73
|
+
description: "Functions whose first argument is a callback nobody awaits (add requestAnimationFrame, setImmediate…).",
|
|
74
|
+
}),
|
|
75
|
+
ignoreProps: option.strings({
|
|
76
|
+
default: [],
|
|
77
|
+
description: "JSX props whose async handler the component awaits itself, like a form library's onSubmit (globs allowed).",
|
|
78
|
+
}),
|
|
79
|
+
};
|
|
80
|
+
/** Objects the timers are also called on: `window.setTimeout`. */
|
|
81
|
+
const TIMER_OWNERS = new Set(["window", "globalThis", "self"]);
|
|
82
|
+
/** `Promise` methods that return a promise that can reject. */
|
|
83
|
+
const REJECTING_PROMISES = new Set([
|
|
84
|
+
"all",
|
|
85
|
+
"allSettled",
|
|
86
|
+
"race",
|
|
87
|
+
"any",
|
|
88
|
+
"reject",
|
|
89
|
+
]);
|
|
90
|
+
function asyncFunction(expression) {
|
|
91
|
+
const node = stripParentheses(expression);
|
|
92
|
+
if (!ts.isArrowFunction(node) && !ts.isFunctionExpression(node))
|
|
93
|
+
return undefined;
|
|
94
|
+
if (!hasModifier(node, ts.SyntaxKind.AsyncKeyword))
|
|
95
|
+
return undefined;
|
|
96
|
+
return ts.isFunctionExpression(node) && node.asteriskToken !== undefined ? undefined : node;
|
|
97
|
+
}
|
|
98
|
+
function calleeName(call) {
|
|
99
|
+
const callee = stripParentheses(call.expression);
|
|
100
|
+
if (ts.isIdentifier(callee))
|
|
101
|
+
return callee.text;
|
|
102
|
+
if (!ts.isPropertyAccessExpression(callee))
|
|
103
|
+
return undefined;
|
|
104
|
+
if (callee.name.text === "addEventListener")
|
|
105
|
+
return callee.name.text;
|
|
106
|
+
const owner = stripParentheses(callee.expression);
|
|
107
|
+
return ts.isIdentifier(owner) && TIMER_OWNERS.has(owner.text) ? callee.name.text : undefined;
|
|
108
|
+
}
|
|
109
|
+
/** The members of a printed union at its top level: `(() => A) | undefined` is two. */
|
|
110
|
+
function unionMembers(text) {
|
|
111
|
+
const members = [];
|
|
112
|
+
let depth = 0;
|
|
113
|
+
let start = 0;
|
|
114
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
115
|
+
const char = text[i];
|
|
116
|
+
if (char === "(" || char === "[" || char === "{" || char === "<")
|
|
117
|
+
depth += 1;
|
|
118
|
+
else if (char === ")" ||
|
|
119
|
+
char === "]" ||
|
|
120
|
+
char === "}" ||
|
|
121
|
+
(char === ">" && text[i - 1] !== "=")) {
|
|
122
|
+
depth -= 1;
|
|
123
|
+
}
|
|
124
|
+
else if (char === "|" && depth === 0) {
|
|
125
|
+
members.push(text.slice(start, i).trim());
|
|
126
|
+
start = i + 1;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
members.push(text.slice(start).trim());
|
|
130
|
+
return members;
|
|
131
|
+
}
|
|
132
|
+
/** `() => Promise<void>` or `(id: string) => PromiseLike<T>`, in parentheses or not. */
|
|
133
|
+
const RETURNS_PROMISE = /^\(*\(.*\) => (?:Promise|PromiseLike)<.*>\)*$/;
|
|
134
|
+
/**
|
|
135
|
+
* Whether the prop an attribute sets is declared as a function that returns a promise
|
|
136
|
+
* (`onSave: () => Promise<void>`, `onSave?(): Promise<void>`): the component asks for a promise
|
|
137
|
+
* so that it can await it, so a failure is its to handle.
|
|
138
|
+
*/
|
|
139
|
+
function awaitedByComponent(fn, ctx) {
|
|
140
|
+
const expected = ctx.types.contextualTypeTextOf(fn);
|
|
141
|
+
if (expected === undefined)
|
|
142
|
+
return false;
|
|
143
|
+
const present = unionMembers(expected).filter((member) => member !== "undefined" && member !== "null");
|
|
144
|
+
return present.length > 0 && present.every((member) => RETURNS_PROMISE.test(member));
|
|
145
|
+
}
|
|
146
|
+
/** The async function this node hands to something that never awaits it, if it does. */
|
|
147
|
+
function handlerOf(node, ctx) {
|
|
148
|
+
if (ts.isJsxAttribute(node)) {
|
|
149
|
+
const name = node.name;
|
|
150
|
+
if (!ts.isIdentifier(name) || !/^on[A-Z]/.test(name.text))
|
|
151
|
+
return undefined;
|
|
152
|
+
if (matchesName(name.text, ctx.options.ignoreProps))
|
|
153
|
+
return undefined;
|
|
154
|
+
const value = node.initializer;
|
|
155
|
+
if (value === undefined || !ts.isJsxExpression(value) || value.expression === undefined) {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
const fn = asyncFunction(value.expression);
|
|
159
|
+
return fn === undefined || awaitedByComponent(fn, ctx)
|
|
160
|
+
? undefined
|
|
161
|
+
: { fn, name: name.text, role: "handler" };
|
|
162
|
+
}
|
|
163
|
+
if (!ts.isCallExpression(node))
|
|
164
|
+
return undefined;
|
|
165
|
+
const name = calleeName(node);
|
|
166
|
+
if (name === "addEventListener") {
|
|
167
|
+
const [, listener] = node.arguments;
|
|
168
|
+
const fn = listener === undefined ? undefined : asyncFunction(listener);
|
|
169
|
+
return fn === undefined ? undefined : { fn, name, role: "listener", call: node };
|
|
170
|
+
}
|
|
171
|
+
if (name !== undefined && ctx.options.timers.includes(name)) {
|
|
172
|
+
const [callback] = node.arguments;
|
|
173
|
+
const fn = callback === undefined ? undefined : asyncFunction(callback);
|
|
174
|
+
return fn === undefined ? undefined : { fn, name, role: "callback", call: node };
|
|
175
|
+
}
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
/** A call that returns a promise which can reject: `fetch`, `Promise.all`, a local async function. */
|
|
179
|
+
function returnsPromise(expression, ctx) {
|
|
180
|
+
const call = stripParentheses(expression);
|
|
181
|
+
if (!ts.isCallExpression(call))
|
|
182
|
+
return false;
|
|
183
|
+
const callee = stripParentheses(call.expression);
|
|
184
|
+
if (ts.isIdentifier(callee) && callee.text === "fetch" && isBuiltinGlobal(callee, ctx)) {
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
if (ts.isPropertyAccessExpression(callee)) {
|
|
188
|
+
const owner = stripParentheses(callee.expression);
|
|
189
|
+
if (ts.isIdentifier(owner) &&
|
|
190
|
+
owner.text === "Promise" &&
|
|
191
|
+
REJECTING_PROMISES.has(callee.name.text) &&
|
|
192
|
+
isBuiltinGlobal(owner, ctx)) {
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const declared = sameFileFunction(callee, ctx);
|
|
197
|
+
return declared !== undefined && hasModifier(declared, ts.SyntaxKind.AsyncKeyword);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Whether the config's facts say the package function called here never fails: `rejects: []`
|
|
201
|
+
* (and no `throws`). The function is named as the facts name it: its path in the package's
|
|
202
|
+
* types (`Client.send`), or its own name (`send`).
|
|
203
|
+
*/
|
|
204
|
+
function factsSayNeverRejects(call, ctx) {
|
|
205
|
+
const callee = stripParentheses(call.expression);
|
|
206
|
+
const name = ts.isPropertyAccessExpression(callee) ? callee.name : callee;
|
|
207
|
+
const symbol = ctx.types.symbolAt(name);
|
|
208
|
+
const declared = symbol && ctx.types.declarationOf(symbol);
|
|
209
|
+
if (declared === undefined || ctx.types.isOwnFile(declared.file))
|
|
210
|
+
return false;
|
|
211
|
+
const packageName = ctx.types.packageOf(declared.file);
|
|
212
|
+
const facts = packageName === undefined ? undefined : ctx.facts[packageName];
|
|
213
|
+
if (facts === undefined)
|
|
214
|
+
return false;
|
|
215
|
+
const names = pathOf(declared.node);
|
|
216
|
+
const entry = facts[names.join(".")] ?? facts[names.at(-1) ?? ""];
|
|
217
|
+
return entry?.rejects?.length === 0 && (entry.throws ?? []).length === 0;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* A promise that reports failure in its value, not by rejecting: its type has an `unwrap()`
|
|
221
|
+
* method and resolves to data or an error (RTK Query's mutation and lazy-query triggers, item
|
|
222
|
+
* 19), or the config's facts say the function never rejects.
|
|
223
|
+
*/
|
|
224
|
+
function neverRejects(expression, ctx) {
|
|
225
|
+
const code = stripParentheses(expression);
|
|
226
|
+
if (ctx.types.settlesWithError(code))
|
|
227
|
+
return true;
|
|
228
|
+
return ts.isCallExpression(code) && factsSayNeverRejects(code, ctx);
|
|
229
|
+
}
|
|
230
|
+
/** `save().catch(showError)`: a call that handles its own failure. */
|
|
231
|
+
function catchesOwnFailure(expression) {
|
|
232
|
+
const call = stripParentheses(expression);
|
|
233
|
+
if (!ts.isCallExpression(call) || call.arguments.length === 0)
|
|
234
|
+
return false;
|
|
235
|
+
const callee = stripParentheses(call.expression);
|
|
236
|
+
return ts.isPropertyAccessExpression(callee) && callee.name.text === "catch";
|
|
237
|
+
}
|
|
238
|
+
/** The first place the handler can fail with nothing to catch it, or undefined. */
|
|
239
|
+
function unguardedFailure(fn, ctx) {
|
|
240
|
+
let found;
|
|
241
|
+
const look = (node, guarded) => {
|
|
242
|
+
if (found !== undefined || guarded)
|
|
243
|
+
return;
|
|
244
|
+
if (ts.isAwaitExpression(node) &&
|
|
245
|
+
!catchesOwnFailure(node.expression) &&
|
|
246
|
+
!neverRejects(node.expression, ctx)) {
|
|
247
|
+
found = {
|
|
248
|
+
node: node.getFirstToken(ctx.sourceFile) ?? node,
|
|
249
|
+
kind: "await",
|
|
250
|
+
code: node.expression,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
else if (ts.isForOfStatement(node) && node.awaitModifier !== undefined) {
|
|
254
|
+
found = { node: node.awaitModifier, kind: "for-await", code: node.expression };
|
|
255
|
+
}
|
|
256
|
+
else if (ts.isReturnStatement(node) &&
|
|
257
|
+
node.expression !== undefined &&
|
|
258
|
+
returnsPromise(node.expression, ctx)) {
|
|
259
|
+
found = { node: node.expression, kind: "await", code: node.expression };
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
scanRunning(fn.body, look);
|
|
263
|
+
if (found === undefined && !ts.isBlock(fn.body) && returnsPromise(fn.body, ctx)) {
|
|
264
|
+
found = { node: fn.body, kind: "await", code: fn.body };
|
|
265
|
+
}
|
|
266
|
+
return found;
|
|
267
|
+
}
|
|
268
|
+
function headline(handler) {
|
|
269
|
+
return fitted(HEADLINE_MAX, (cut) => `a failure in this async \`${cut(handler.name)}\` ${handler.role} is an unhandled rejection`);
|
|
270
|
+
}
|
|
271
|
+
/** The developer's own code in an example is cut to this many characters. */
|
|
272
|
+
const CODE_MAX = 50;
|
|
273
|
+
function code(node, ctx) {
|
|
274
|
+
return shorten(oneLine(node.getText(ctx.sourceFile)), CODE_MAX);
|
|
275
|
+
}
|
|
276
|
+
/** The example's first and last line: the handler written where it was, with its own names. */
|
|
277
|
+
function frame(handler, ctx) {
|
|
278
|
+
switch (handler.role) {
|
|
279
|
+
case "handler":
|
|
280
|
+
return { first: `${oneLine(handler.name)}={async () => {`, last: "}}" };
|
|
281
|
+
case "listener":
|
|
282
|
+
case "callback": {
|
|
283
|
+
const [before, after] = aroundFunction(handler.call, handler.fn, ctx);
|
|
284
|
+
return {
|
|
285
|
+
first: `${code(handler.call.expression, ctx)}(${before}async () => {`,
|
|
286
|
+
last: `}${after});`,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
default: {
|
|
290
|
+
const unreachable = handler;
|
|
291
|
+
return unreachable;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/** The call's other arguments, before and after the function: `"click", ` and `, 100`. */
|
|
296
|
+
function aroundFunction(call, fn, ctx) {
|
|
297
|
+
const index = call.arguments.findIndex((argument) => stripParentheses(argument) === fn);
|
|
298
|
+
const texts = call.arguments.map((argument) => code(argument, ctx));
|
|
299
|
+
const before = texts.slice(0, index).map((text) => `${text}, `);
|
|
300
|
+
const after = texts.slice(index + 1).map((text) => `, ${text}`);
|
|
301
|
+
return [before.join(""), after.join("")];
|
|
302
|
+
}
|
|
303
|
+
function hint(handler, failure, ctx) {
|
|
304
|
+
const { first, last } = frame(handler, ctx);
|
|
305
|
+
const failing = failure.kind === "await"
|
|
306
|
+
? `await ${code(failure.code, ctx)};`
|
|
307
|
+
: `for await (const item of ${code(failure.code, ctx)}) { … }`;
|
|
308
|
+
return withExample(`nothing awaits the ${handler.role}, so catch inside it:`, [
|
|
309
|
+
first,
|
|
310
|
+
` try { ${failing} } catch (e) { showError(e); }`,
|
|
311
|
+
last,
|
|
312
|
+
]);
|
|
313
|
+
}
|
|
314
|
+
export const asyncHandlerUnhandled = defineRule({
|
|
315
|
+
kind: "file",
|
|
316
|
+
meta: {
|
|
317
|
+
description: "An async event handler, listener or timer callback catches its own failures, since nothing awaits it.",
|
|
318
|
+
defaultSeverity: "error",
|
|
319
|
+
tier: "core",
|
|
320
|
+
group: GROUP,
|
|
321
|
+
options: OPTIONS,
|
|
322
|
+
explain: EXPLAIN,
|
|
323
|
+
},
|
|
324
|
+
check(ctx) {
|
|
325
|
+
const visit = (node) => {
|
|
326
|
+
const handler = handlerOf(node, ctx);
|
|
327
|
+
if (handler !== undefined) {
|
|
328
|
+
const failure = unguardedFailure(handler.fn, ctx);
|
|
329
|
+
if (failure !== undefined) {
|
|
330
|
+
ctx.report({
|
|
331
|
+
node: failure.node,
|
|
332
|
+
message: headline(handler),
|
|
333
|
+
hint: hint(handler, failure, ctx),
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
ts.forEachChild(node, visit);
|
|
338
|
+
};
|
|
339
|
+
visit(ctx.sourceFile);
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
//# sourceMappingURL=async-handler-unhandled.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** The group the rule belongs to in the docs and in `stateward status`. */
|
|
2
|
+
export declare const GROUP = "catch";
|
|
3
|
+
export declare const catchHidesUnexpected: import("@stateward/core").FileRule<{
|
|
4
|
+
restUsesError: import("@stateward/core").BooleanOption;
|
|
5
|
+
}, "catch">;
|
|
6
|
+
//# sourceMappingURL=catch-hides-unexpected.d.ts.map
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import ts from "@stateward/core/typescript";
|
|
2
|
+
import { defineRule, option } from "@stateward/core";
|
|
3
|
+
import { stripParentheses } from "./syntax.js";
|
|
4
|
+
import { codeList, fitted, oneLine, withExample } from "./text.js";
|
|
5
|
+
/** The group the rule belongs to in the docs and in `stateward status`. */
|
|
6
|
+
export const GROUP = "catch";
|
|
7
|
+
const EXPLAIN = `# errors/catch-hides-unexpected
|
|
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();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
\`\`\`
|
|
39
|
+
|
|
40
|
+
### Good
|
|
41
|
+
|
|
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;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
\`\`\`
|
|
55
|
+
|
|
56
|
+
### Fix
|
|
57
|
+
|
|
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.
|
|
59
|
+
|
|
60
|
+
## What it does not catch
|
|
61
|
+
|
|
62
|
+
- A chain that tests another value, or tests other than \`instanceof\`.
|
|
63
|
+
- 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\`.
|
|
66
|
+
|
|
67
|
+
## Turn it on or off
|
|
68
|
+
|
|
69
|
+
In \`stateward.config.ts\`:
|
|
70
|
+
|
|
71
|
+
\`\`\`ts
|
|
72
|
+
rules: { "errors/catch-hides-unexpected": "off" } // or "warning"
|
|
73
|
+
groups: { "errors/catch": "off" } // the whole group
|
|
74
|
+
rules: { "errors/catch-hides-unexpected": ["error", { restUsesError: true }] } // the last branch must use the error
|
|
75
|
+
\`\`\`
|
|
76
|
+
`;
|
|
77
|
+
/** The statements of a branch or block, without empty statements; a comment is not a statement. */
|
|
78
|
+
const OPTIONS = {
|
|
79
|
+
restUsesError: option.boolean({
|
|
80
|
+
default: false,
|
|
81
|
+
description: "The branch for the other errors must use the error (rethrow it, wrap it, report it); a generic message alone does not count.",
|
|
82
|
+
}),
|
|
83
|
+
};
|
|
84
|
+
function realStatements(node) {
|
|
85
|
+
if (node === undefined)
|
|
86
|
+
return [];
|
|
87
|
+
const statements = ts.isBlock(node) ? node.statements : [node];
|
|
88
|
+
return statements.filter((statement) => !ts.isEmptyStatement(statement));
|
|
89
|
+
}
|
|
90
|
+
/** The classes of the `instanceof` tests on `variable` in a condition, in source order. */
|
|
91
|
+
function instanceOfClasses(condition, variable, sourceFile) {
|
|
92
|
+
const node = stripParentheses(condition);
|
|
93
|
+
if (!ts.isBinaryExpression(node))
|
|
94
|
+
return [];
|
|
95
|
+
const operator = node.operatorToken.kind;
|
|
96
|
+
if (operator === ts.SyntaxKind.InstanceOfKeyword) {
|
|
97
|
+
const subject = stripParentheses(node.left);
|
|
98
|
+
return ts.isIdentifier(subject) && subject.text === variable
|
|
99
|
+
? [oneLine(stripParentheses(node.right).getText(sourceFile))]
|
|
100
|
+
: [];
|
|
101
|
+
}
|
|
102
|
+
if (operator === ts.SyntaxKind.BarBarToken ||
|
|
103
|
+
operator === ts.SyntaxKind.AmpersandAmpersandToken) {
|
|
104
|
+
return [
|
|
105
|
+
...instanceOfClasses(node.left, variable, sourceFile),
|
|
106
|
+
...instanceOfClasses(node.right, variable, sourceFile),
|
|
107
|
+
];
|
|
108
|
+
}
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
/** An `if` / `else if` chain, from its first `if` to its last link. */
|
|
112
|
+
function linksOf(start) {
|
|
113
|
+
const links = [start];
|
|
114
|
+
for (let last = start; last.elseStatement !== undefined && ts.isIfStatement(last.elseStatement);) {
|
|
115
|
+
last = last.elseStatement;
|
|
116
|
+
links.push(last);
|
|
117
|
+
}
|
|
118
|
+
return links;
|
|
119
|
+
}
|
|
120
|
+
function dispatchOf(statement, variable, sourceFile) {
|
|
121
|
+
if (ts.isIfStatement(statement)) {
|
|
122
|
+
const links = linksOf(statement);
|
|
123
|
+
const classes = links.flatMap((link) => instanceOfClasses(link.expression, variable, sourceFile));
|
|
124
|
+
const end = links[links.length - 1];
|
|
125
|
+
if (classes.length === 0 || end === undefined)
|
|
126
|
+
return undefined;
|
|
127
|
+
return {
|
|
128
|
+
start: statement,
|
|
129
|
+
classes,
|
|
130
|
+
rest: realStatements(end.elseStatement),
|
|
131
|
+
openChainEnd: end.elseStatement === undefined ? end : undefined,
|
|
132
|
+
kind: "if",
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (ts.isSwitchStatement(statement)) {
|
|
136
|
+
if (stripParentheses(statement.expression).kind !== ts.SyntaxKind.TrueKeyword)
|
|
137
|
+
return undefined;
|
|
138
|
+
const classes = statement.caseBlock.clauses.flatMap((clause) => ts.isCaseClause(clause) ? instanceOfClasses(clause.expression, variable, sourceFile) : []);
|
|
139
|
+
if (classes.length === 0)
|
|
140
|
+
return undefined;
|
|
141
|
+
const fallback = statement.caseBlock.clauses.find((clause) => ts.isDefaultClause(clause));
|
|
142
|
+
return {
|
|
143
|
+
start: statement,
|
|
144
|
+
classes,
|
|
145
|
+
rest: (fallback?.statements ?? []).filter((inner) => !ts.isEmptyStatement(inner)),
|
|
146
|
+
openChainEnd: undefined,
|
|
147
|
+
kind: "switch",
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
/** Whether any of `nodes` names `variable` (a nested function's own `e` counts too: rare, and safe). */
|
|
153
|
+
function mentions(nodes, variable) {
|
|
154
|
+
const visit = (node) => (ts.isIdentifier(node) && node.text === variable) || ts.forEachChild(node, visit) === true;
|
|
155
|
+
return nodes.some(visit);
|
|
156
|
+
}
|
|
157
|
+
function ignoringHeadline(classes, variable) {
|
|
158
|
+
return fitted((cut) => `errors other than ${codeList(classes, cut)} are handled here without ever using \`${variable}\``);
|
|
159
|
+
}
|
|
160
|
+
function headline(classes) {
|
|
161
|
+
return fitted((cut) => `errors other than ${codeList(classes, cut)} are silently swallowed here`);
|
|
162
|
+
}
|
|
163
|
+
function hint(dispatch, variable) {
|
|
164
|
+
if (dispatch.kind === "switch") {
|
|
165
|
+
return withExample("end the switch with a case for the rest:", [
|
|
166
|
+
"default:",
|
|
167
|
+
` throw ${variable};`,
|
|
168
|
+
]);
|
|
169
|
+
}
|
|
170
|
+
return withExample("end the chain with a branch for the rest:", [
|
|
171
|
+
"} else {",
|
|
172
|
+
` throw ${variable};`,
|
|
173
|
+
"}",
|
|
174
|
+
]);
|
|
175
|
+
}
|
|
176
|
+
/** The whitespace at the start of the line that holds `position`. */
|
|
177
|
+
function indentationAt(position, text) {
|
|
178
|
+
const lineStart = text.lastIndexOf("\n", position - 1) + 1;
|
|
179
|
+
return /^[ \t]*/.exec(text.slice(lineStart, position))?.[0] ?? "";
|
|
180
|
+
}
|
|
181
|
+
/** `else { throw e; }` after the last branch, at the indentation of the chain. */
|
|
182
|
+
function rethrowFix(start, end, variable, sourceFile) {
|
|
183
|
+
const indent = indentationAt(start.getStart(sourceFile), sourceFile.text);
|
|
184
|
+
const step = indent.includes("\t") ? "\t" : " ";
|
|
185
|
+
const at = end.thenStatement.getEnd();
|
|
186
|
+
return {
|
|
187
|
+
title: `Rethrow the other errors with \`else { throw ${variable}; }\``,
|
|
188
|
+
edits: [
|
|
189
|
+
{
|
|
190
|
+
location: { file: sourceFile.fileName, start: at, end: at },
|
|
191
|
+
newText: ` else {\n${indent}${step}throw ${variable};\n${indent}}`,
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function checkCatch(clause, ctx) {
|
|
197
|
+
const declaration = clause.variableDeclaration;
|
|
198
|
+
if (declaration === undefined || !ts.isIdentifier(declaration.name))
|
|
199
|
+
return;
|
|
200
|
+
const variable = declaration.name.text;
|
|
201
|
+
const statements = clause.block.statements;
|
|
202
|
+
let last;
|
|
203
|
+
const classes = [];
|
|
204
|
+
for (const [index, statement] of statements.entries()) {
|
|
205
|
+
const dispatch = dispatchOf(statement, variable, ctx.sourceFile);
|
|
206
|
+
if (dispatch === undefined)
|
|
207
|
+
continue;
|
|
208
|
+
last = { dispatch, index };
|
|
209
|
+
for (const name of dispatch.classes)
|
|
210
|
+
if (!classes.includes(name))
|
|
211
|
+
classes.push(name);
|
|
212
|
+
}
|
|
213
|
+
if (last === undefined)
|
|
214
|
+
return;
|
|
215
|
+
const { dispatch, index } = last;
|
|
216
|
+
// A branch for the rest, or any statement that runs for every error after the chain.
|
|
217
|
+
const after = statements.slice(index + 1).filter((statement) => !ts.isEmptyStatement(statement));
|
|
218
|
+
const rest = [...dispatch.rest, ...after];
|
|
219
|
+
if (rest.length > 0) {
|
|
220
|
+
if (!ctx.options.restUsesError || mentions(rest, variable))
|
|
221
|
+
return;
|
|
222
|
+
ctx.report({
|
|
223
|
+
node: dispatch.start.getFirstToken(ctx.sourceFile) ?? dispatch.start,
|
|
224
|
+
message: ignoringHeadline(classes, variable),
|
|
225
|
+
hint: withExample("rethrow the rest, or pass the error on where you handle it:", [
|
|
226
|
+
`throw ${variable};`,
|
|
227
|
+
]),
|
|
228
|
+
});
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const fixes = dispatch.openChainEnd === undefined
|
|
232
|
+
? []
|
|
233
|
+
: [rethrowFix(dispatch.start, dispatch.openChainEnd, variable, ctx.sourceFile)];
|
|
234
|
+
ctx.report({
|
|
235
|
+
node: dispatch.start.getFirstToken(ctx.sourceFile) ?? dispatch.start,
|
|
236
|
+
message: headline(classes),
|
|
237
|
+
hint: hint(dispatch, variable),
|
|
238
|
+
fixes,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
export const catchHidesUnexpected = defineRule({
|
|
242
|
+
kind: "file",
|
|
243
|
+
meta: {
|
|
244
|
+
description: "A catch that handles some errors with instanceof also rethrows or handles all the others.",
|
|
245
|
+
defaultSeverity: "error",
|
|
246
|
+
tier: "core",
|
|
247
|
+
group: GROUP,
|
|
248
|
+
options: OPTIONS,
|
|
249
|
+
explain: EXPLAIN,
|
|
250
|
+
},
|
|
251
|
+
check(ctx) {
|
|
252
|
+
const visit = (node) => {
|
|
253
|
+
if (ts.isCatchClause(node))
|
|
254
|
+
checkCatch(node, ctx);
|
|
255
|
+
ts.forEachChild(node, visit);
|
|
256
|
+
};
|
|
257
|
+
visit(ctx.sourceFile);
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
//# sourceMappingURL=catch-hides-unexpected.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** The group the rule belongs to in the docs and in `stateward status`. */
|
|
2
|
+
export declare const GROUP = "catch";
|
|
3
|
+
export declare const catchMissesError: import("@stateward/core").ProjectRule<import("./react/boundaries.js").ReactSummary, {
|
|
4
|
+
ignoreClasses: import("@stateward/core").StringsOption;
|
|
5
|
+
}, "catch">;
|
|
6
|
+
//# sourceMappingURL=catch-misses-error.d.ts.map
|