@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stateward/plugin-errors",
3
- "version": "1.0.1-rc.1",
3
+ "version": "1.1.0",
4
4
  "description": "stateward rules for errors: errors are handled where they can happen.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,6 +32,6 @@
32
32
  "!dist/**/*.tsbuildinfo"
33
33
  ],
34
34
  "dependencies": {
35
- "@stateward/core": "1.0.1-rc.1"
35
+ "@stateward/core": "1.1.0"
36
36
  }
37
37
  }
@@ -1,7 +0,0 @@
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
@@ -1,342 +0,0 @@
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
@@ -1,10 +0,0 @@
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