@stateward/plugin-errors 1.0.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.
- package/README.md +20 -21
- package/dist/catch-hides-unexpected.js +47 -58
- package/dist/catch-misses-error.js +57 -67
- package/dist/finally-can-throw.js +45 -54
- package/dist/inference/format.d.ts +3 -3
- package/dist/inference/format.js +5 -5
- package/dist/inference/solve.js +1 -1
- package/dist/inference/summarize.d.ts +1 -1
- package/dist/inference/summarize.js +2 -2
- package/dist/must-handle-error.js +58 -66
- package/dist/narrow-try.d.ts +6 -1
- package/dist/narrow-try.js +137 -77
- package/dist/plugin.d.ts +0 -27
- package/dist/plugin.js +1 -3
- package/dist/react/boundaries.d.ts +3 -3
- package/dist/react/boundaries.js +5 -5
- package/dist/react/fallbacks.js +2 -2
- package/dist/refactors.d.ts +1 -1
- package/dist/refactors.js +1 -1
- package/dist/render-throw-without-boundary.js +64 -47
- package/dist/result-ignored.js +31 -37
- package/dist/result-unchecked.js +58 -61
- package/dist/rules.meta.d.ts +22 -47
- package/dist/rules.meta.js +19 -47
- package/dist/throws-doc-mismatch.js +35 -44
- package/package.json +2 -2
- package/dist/catch-without-intent.d.ts +0 -10
- package/dist/catch-without-intent.js +0 -590
package/dist/result-unchecked.js
CHANGED
|
@@ -3,79 +3,75 @@ import { defineRule } from "@stateward/core";
|
|
|
3
3
|
import { example, resultShapeOf, shorten } from "./result-type.js";
|
|
4
4
|
const EXPLAIN = `# errors/result-unchecked
|
|
5
5
|
|
|
6
|
-
A call that
|
|
6
|
+
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.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
\`\`\`ts example-bad
|
|
9
|
+
type Result<T> = { ok: true; value: T } | { ok: false; error: Error };
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
declare function saveSettings(values: string): Promise<Result<void>>;
|
|
12
|
+
declare function toast(text: string): void;
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
export async function submit(values: string): Promise<void> {
|
|
15
|
+
const result = await saveSettings(values);
|
|
16
|
+
if (!result.ok) toast("Could not save");
|
|
17
|
+
toast("Saved");
|
|
18
|
+
}
|
|
19
|
+
\`\`\`
|
|
17
20
|
|
|
18
|
-
|
|
21
|
+
When it failed, stop there: \`return\` (or \`throw\`) in the check:
|
|
19
22
|
|
|
20
|
-
-
|
|
21
|
-
|
|
22
|
-
- a trigger awaited as a statement outside a \`try\`, with more lines after it (\`await sendReply(x); setText("")\`);
|
|
23
|
-
- stored, then its data read before it is checked;
|
|
24
|
-
- stored, its error read in an \`if\` that does not \`return\` or \`throw\` (\`"error" in result\`, \`result.error\`, or an \`error\` or \`isError\` taken out of it), with more lines after that \`if\`.
|
|
23
|
+
\`\`\`ts example-good
|
|
24
|
+
type Result<T> = { ok: true; value: T } | { ok: false; error: Error };
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
declare function saveSettings(values: string): Promise<Result<void>>;
|
|
27
|
+
declare function toast(text: string): void;
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
export async function submit(values: string): Promise<void> {
|
|
30
|
+
const result = await saveSettings(values);
|
|
31
|
+
if (!result.ok) {
|
|
32
|
+
toast("Could not save");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
toast("Saved");
|
|
34
36
|
}
|
|
35
|
-
|
|
36
|
-
const result = await saveSettings(values);
|
|
37
|
-
if ("error" in result) toast.error("Could not save");
|
|
38
|
-
toast.success("Saved");
|
|
39
37
|
\`\`\`
|
|
40
38
|
|
|
41
|
-
|
|
39
|
+
For an RTK Query trigger, add \`.unwrap()\` so a failure throws: \`await deletePlan(id).unwrap()\`. Where the result is dropped, a quick fix adds it.
|
|
42
40
|
|
|
43
|
-
|
|
44
|
-
try {
|
|
45
|
-
await deletePlan(id).unwrap();
|
|
46
|
-
toast.success("Deleted");
|
|
47
|
-
} catch {
|
|
48
|
-
toast.error("Could not delete");
|
|
49
|
-
}
|
|
41
|
+
## Why it matters
|
|
50
42
|
|
|
51
|
-
|
|
52
|
-
if ("error" in result) {
|
|
53
|
-
toast.error("Could not save");
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
toast.success("Saved");
|
|
57
|
-
\`\`\`
|
|
43
|
+
Some calls do not throw when they fail: they resolve to data or an error. An RTK Query trigger without \`.unwrap()\` is the common one. Wrap \`await deletePlan(id)\` in a \`try\` and the \`catch\` never runs: a failed delete still shows "Deleted" and closes the dialog. Store the result and show its error without stopping, and the lines after still run: "Saved" appears after a save that failed.
|
|
58
44
|
|
|
59
|
-
|
|
45
|
+
## When it says “not checked”
|
|
60
46
|
|
|
61
|
-
|
|
47
|
+
An \`if\` that tests the result in a way stateward cannot read (\`if (result.error && isApiError(result.error))\`) is listed as a place not checked. Test it in one of the ways below, then narrow further inside.
|
|
62
48
|
|
|
63
49
|
## What it does not catch
|
|
64
50
|
|
|
65
|
-
|
|
66
|
-
- Lines after the call that only set back a pending state set before it (\`setSaving(true)\` … \`setSaving(false)\`): they run either way on purpose.
|
|
67
|
-
- A \`try\` with another \`await\` or a \`throw\` in it: the \`catch\` may be there for that.
|
|
68
|
-
- A result passed on, returned or stored elsewhere, or checked in a callback.
|
|
69
|
-
- A promise that rejects on failure (react-query's \`mutateAsync\`, \`fetch\`): it has no \`unwrap()\` that resolves to data or an error.
|
|
70
|
-
- An \`if\` that tests the result in a way stateward cannot read (\`if (result.error && isApiError(result.error))\`): it is listed as a place not checked.
|
|
51
|
+
It looks at a call whose value is data or an error: a promise with an \`unwrap()\` method that resolves to a union with an \`error\` member (RTK Query's triggers, found by the type, not by a name), or a Result as \`errors/result-ignored\` recognises it. It reports such a call when:
|
|
71
52
|
|
|
72
|
-
|
|
53
|
+
- it is awaited as a statement inside a \`try\` whose \`catch\` nothing else can reach (no call before it, no other \`await\` or \`throw\` in the \`try\`): the \`catch\` never runs;
|
|
54
|
+
- it is awaited as a statement inside a \`try\` after another call (an optimistic \`setFolders(…)\`), with more lines after it: when it fails, those lines run and the \`catch\` (the rollback) does not;
|
|
55
|
+
- a trigger is awaited as a statement outside a \`try\`, with more lines after it (\`await sendReply(x); setText("")\`);
|
|
56
|
+
- it is stored, then its data is read before it is checked;
|
|
57
|
+
- it is stored, and its error is read in an \`if\` that does not \`return\` or \`throw\` (\`"error" in result\`, \`result.error\`, \`!result.ok\`, or an \`error\` or \`isError\` taken out of it), with more lines after that \`if\`.
|
|
58
|
+
|
|
59
|
+
It is silent on:
|
|
73
60
|
|
|
74
|
-
|
|
61
|
+
- a Result dropped as a statement: that is \`errors/result-ignored\`;
|
|
62
|
+
- lines after the call that only set back a pending state set before it (\`setSaving(true)\` … \`setSaving(false)\`): they run either way on purpose;
|
|
63
|
+
- a \`try\` with another \`await\` or a \`throw\` in it: the \`catch\` may be there for that;
|
|
64
|
+
- a result passed on, returned, stored elsewhere, or checked in a callback;
|
|
65
|
+
- a promise that rejects on failure (react-query's \`mutateAsync\`, \`fetch\`): it has no \`unwrap()\` that resolves to data or an error.
|
|
66
|
+
|
|
67
|
+
## Getting the best from it
|
|
68
|
+
|
|
69
|
+
Code that says what it means is left alone: \`void trigger(…)\`, \`.unwrap()\`, a check (\`"error" in result\`, \`if (result.error) return\`, \`isOk(result)\`, \`const { data, isError } = await dispatch(…); if (isError) throw …\`) before the success path, an \`if\` with an \`else\` for the success path, and a component that reads the trigger's own \`isError\` or \`error\` from its hook (\`const [save, { isError }] = useSaveMutation()\`).
|
|
70
|
+
|
|
71
|
+
## Turn it on or off
|
|
75
72
|
|
|
76
73
|
\`\`\`ts
|
|
77
74
|
rules: { "errors/result-unchecked": "off" } // or "error"
|
|
78
|
-
groups: { "errors/callers": "off" } // the whole group
|
|
79
75
|
\`\`\`
|
|
80
76
|
`;
|
|
81
77
|
/** The longest call an example line shows before its arguments become `…`. */
|
|
@@ -428,7 +424,7 @@ function checkTry(statement, ctx) {
|
|
|
428
424
|
continue;
|
|
429
425
|
ctx.report({
|
|
430
426
|
node: inner,
|
|
431
|
-
message: `
|
|
427
|
+
message: `code after the call runs even when \`${calleeText(call, ctx)}(…)\` failed, and the \`catch\` does not`,
|
|
432
428
|
hint: [
|
|
433
429
|
'add `.unwrap()` so a failure reaches the `catch` (quick fix), or check `"error" in result` and return:',
|
|
434
430
|
example([
|
|
@@ -445,9 +441,9 @@ function checkTry(statement, ctx) {
|
|
|
445
441
|
ctx.report({
|
|
446
442
|
// The statement, as `errors/result-ignored` reports it, so that rule can yield to this one.
|
|
447
443
|
node: inner,
|
|
448
|
-
message:
|
|
444
|
+
message: `\`catch\` never runs for \`${calleeText(call, ctx)}(…)\`: it does not throw when it fails`,
|
|
449
445
|
hint: [
|
|
450
|
-
'
|
|
446
|
+
'add `.unwrap()` (quick fix), or check `"error" in result` and return, so the code after it does not run on failure:',
|
|
451
447
|
example([`const result = await ${callText(call, ctx)};`, `if ("error" in result) return;`]),
|
|
452
448
|
].join("\n"),
|
|
453
449
|
fixes: [unwrapFix(call, "Add `.unwrap()` so a failure reaches the `catch`", ctx)],
|
|
@@ -531,12 +527,12 @@ function checkDropped(statement, ctx) {
|
|
|
531
527
|
return;
|
|
532
528
|
ctx.report({
|
|
533
529
|
node: statement,
|
|
534
|
-
message: `
|
|
530
|
+
message: `code after the call runs even when \`${calleeText(call, ctx)}(…)\` failed`,
|
|
535
531
|
hint: [
|
|
536
532
|
'add `.unwrap()` so a failure throws (quick fix), or check `"error" in result` and return:',
|
|
537
533
|
example([`const result = await ${callText(call, ctx)};`, `if ("error" in result) return;`]),
|
|
538
534
|
].join("\n"),
|
|
539
|
-
fixes: [unwrapFix(call, "Add `.unwrap()` so a failure stops
|
|
535
|
+
fixes: [unwrapFix(call, "Add `.unwrap()` so a failure stops the function", ctx)],
|
|
540
536
|
});
|
|
541
537
|
}
|
|
542
538
|
/** What a stored result is reached by: `const r = …` or `const { data, error } = …`. */
|
|
@@ -561,8 +557,8 @@ function trackedOf(declaration, call, kind, ctx) {
|
|
|
561
557
|
return aliases.size === 0 ? undefined : { whole: undefined, aliases, callee, kind };
|
|
562
558
|
}
|
|
563
559
|
function goesOnHeadline(tracked, after) {
|
|
564
|
-
const what = after === "if" ? "
|
|
565
|
-
return `
|
|
560
|
+
const what = after === "if" ? "the `if`" : "the call";
|
|
561
|
+
return `code after ${what} runs even when \`${tracked.callee}(…)\` failed`;
|
|
566
562
|
}
|
|
567
563
|
/**
|
|
568
564
|
* The statements that run after a check that is the last of its list: those after the enclosing
|
|
@@ -644,8 +640,8 @@ function followStored(statement, before, rest, tracked, call, ctx) {
|
|
|
644
640
|
case "undecided":
|
|
645
641
|
ctx.unknown({
|
|
646
642
|
node: outcome.test,
|
|
647
|
-
message: `
|
|
648
|
-
hint: `test the result itself
|
|
643
|
+
message: `not checked: whether the \`if\` tests that \`${tracked.callee}(…)\` failed: it does not test the result itself`,
|
|
644
|
+
hint: `test the result itself, as in \`${checkLine(tracked)}\`, so the check can be followed`,
|
|
649
645
|
});
|
|
650
646
|
return;
|
|
651
647
|
case "goes-on": {
|
|
@@ -723,7 +719,8 @@ function checkDeclaration(statement, ctx) {
|
|
|
723
719
|
export const resultUnchecked = defineRule({
|
|
724
720
|
kind: "file",
|
|
725
721
|
meta: {
|
|
726
|
-
description: "A call that
|
|
722
|
+
description: "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.",
|
|
723
|
+
summary: "a call that fails in its value (an RTK Query trigger, a Result) used as if it had worked",
|
|
727
724
|
defaultSeverity: "warning",
|
|
728
725
|
tier: "core",
|
|
729
726
|
group: "callers",
|
package/dist/rules.meta.d.ts
CHANGED
|
@@ -2,7 +2,8 @@ export declare const RULES: {
|
|
|
2
2
|
readonly "catch-hides-unexpected": {
|
|
3
3
|
readonly kind: "file";
|
|
4
4
|
readonly meta: {
|
|
5
|
-
readonly description: "A catch that
|
|
5
|
+
readonly 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.";
|
|
6
|
+
readonly summary: "a `catch` that handles some error classes with `instanceof` and ignores the rest";
|
|
6
7
|
readonly defaultSeverity: "error";
|
|
7
8
|
readonly tier: "core";
|
|
8
9
|
readonly group: "catch";
|
|
@@ -15,48 +16,11 @@ export declare const RULES: {
|
|
|
15
16
|
};
|
|
16
17
|
};
|
|
17
18
|
};
|
|
18
|
-
readonly "catch-without-intent": {
|
|
19
|
-
readonly kind: "file";
|
|
20
|
-
readonly meta: {
|
|
21
|
-
readonly description: "Experimental: on real projects it is mostly wrong; it may change or be removed. A catch that handles every error says how: it narrows, reports, or falls back; one that only logs, or calls something stateward cannot judge, and carries on is reported.";
|
|
22
|
-
readonly defaultSeverity: "warning";
|
|
23
|
-
readonly tier: "addon";
|
|
24
|
-
readonly addon: "catch-intent";
|
|
25
|
-
readonly group: "catch";
|
|
26
|
-
readonly heuristic: true;
|
|
27
|
-
readonly options: {
|
|
28
|
-
readonly loggers: {
|
|
29
|
-
readonly kind: "strings";
|
|
30
|
-
readonly default: readonly ["console", "logger", "log"];
|
|
31
|
-
readonly description: "Objects whose every method only logs (globs like *Logger): a catch that only calls them swallows the error.";
|
|
32
|
-
};
|
|
33
|
-
readonly logMethods: {
|
|
34
|
-
readonly kind: "strings";
|
|
35
|
-
readonly default: readonly ["error", "warn", "info", "debug"];
|
|
36
|
-
readonly description: "Method names that only log, on any object (`Sentry.captureException` is not one).";
|
|
37
|
-
};
|
|
38
|
-
readonly logFunctions: {
|
|
39
|
-
readonly kind: "strings";
|
|
40
|
-
readonly default: readonly [];
|
|
41
|
-
readonly description: "Plain functions of your own that only log, like logError (globs allowed).";
|
|
42
|
-
};
|
|
43
|
-
readonly edgeHelpers: {
|
|
44
|
-
readonly kind: "strings";
|
|
45
|
-
readonly default: readonly [];
|
|
46
|
-
readonly description: "Your functions that report an error where it ends (to a service, to the user): a catch that calls one is handled, like reportAndShow (globs allowed). Sentry, Bugsnag and Datadog are known without it.";
|
|
47
|
-
};
|
|
48
|
-
readonly swallowHelpers: {
|
|
49
|
-
readonly kind: "strings";
|
|
50
|
-
readonly default: readonly [];
|
|
51
|
-
readonly description: "Functions that mark an error as ignored on purpose, like ignoreError (globs allowed): a catch that calls one is not reported.";
|
|
52
|
-
};
|
|
53
|
-
};
|
|
54
|
-
};
|
|
55
|
-
};
|
|
56
19
|
readonly "finally-can-throw": {
|
|
57
20
|
readonly kind: "file";
|
|
58
21
|
readonly meta: {
|
|
59
|
-
readonly description: "
|
|
22
|
+
readonly description: "An error thrown in `finally` replaces the error that was already thrown, so the report names the cleanup instead of the original cause.";
|
|
23
|
+
readonly summary: "a `finally` that calls something that can throw and replace the original error";
|
|
60
24
|
readonly defaultSeverity: "error";
|
|
61
25
|
readonly tier: "core";
|
|
62
26
|
readonly group: "catch";
|
|
@@ -77,7 +41,8 @@ export declare const RULES: {
|
|
|
77
41
|
readonly "catch-misses-error": {
|
|
78
42
|
readonly kind: "project";
|
|
79
43
|
readonly meta: {
|
|
80
|
-
readonly description: "
|
|
44
|
+
readonly 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.";
|
|
45
|
+
readonly summary: "a `catch` that branches on error classes but misses one its `try` can throw";
|
|
81
46
|
readonly defaultSeverity: "error";
|
|
82
47
|
readonly tier: "core";
|
|
83
48
|
readonly group: "catch";
|
|
@@ -94,7 +59,8 @@ export declare const RULES: {
|
|
|
94
59
|
readonly "throws-doc-mismatch": {
|
|
95
60
|
readonly kind: "project";
|
|
96
61
|
readonly meta: {
|
|
97
|
-
readonly description: "
|
|
62
|
+
readonly description: "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.";
|
|
63
|
+
readonly summary: "a `@throws` tag that lists an error the function cannot throw, or misses one it can";
|
|
98
64
|
readonly defaultSeverity: "error";
|
|
99
65
|
readonly tier: "core";
|
|
100
66
|
readonly group: "callers";
|
|
@@ -120,11 +86,16 @@ export declare const RULES: {
|
|
|
120
86
|
readonly "render-throw-without-boundary": {
|
|
121
87
|
readonly kind: "project";
|
|
122
88
|
readonly meta: {
|
|
123
|
-
readonly description: "
|
|
89
|
+
readonly description: "An error thrown while rendering, with no error boundary above the component, unmounts the whole React tree and leaves a blank page.";
|
|
90
|
+
readonly summary: "a component that can throw a known error while rendering, with no error boundary above it";
|
|
124
91
|
readonly defaultSeverity: "warning";
|
|
125
92
|
readonly tier: "core";
|
|
126
93
|
readonly group: "render";
|
|
127
94
|
readonly needsJsDoc: true;
|
|
95
|
+
readonly needsTypes: {
|
|
96
|
+
readonly library: "React";
|
|
97
|
+
readonly modules: readonly ["react"];
|
|
98
|
+
};
|
|
128
99
|
readonly options: {
|
|
129
100
|
readonly skipContextGuards: {
|
|
130
101
|
readonly kind: "boolean";
|
|
@@ -154,7 +125,8 @@ export declare const RULES: {
|
|
|
154
125
|
readonly "must-handle-error": {
|
|
155
126
|
readonly kind: "project";
|
|
156
127
|
readonly meta: {
|
|
157
|
-
readonly description: "An error
|
|
128
|
+
readonly description: "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.";
|
|
129
|
+
readonly summary: "a call that can throw an error marked `@mustHandle`, with no code that handles or passes it on";
|
|
158
130
|
readonly defaultSeverity: "error";
|
|
159
131
|
readonly tier: "core";
|
|
160
132
|
readonly group: "callers";
|
|
@@ -165,7 +137,8 @@ export declare const RULES: {
|
|
|
165
137
|
readonly "narrow-try": {
|
|
166
138
|
readonly kind: "project";
|
|
167
139
|
readonly meta: {
|
|
168
|
-
readonly description: "
|
|
140
|
+
readonly description: "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.";
|
|
141
|
+
readonly summary: "calls to your own code inside a `try` after the call it is for, so their bugs look like its failure";
|
|
169
142
|
readonly defaultSeverity: "warning";
|
|
170
143
|
readonly tier: "addon";
|
|
171
144
|
readonly addon: "narrow-try";
|
|
@@ -176,7 +149,8 @@ export declare const RULES: {
|
|
|
176
149
|
readonly "result-ignored": {
|
|
177
150
|
readonly kind: "file";
|
|
178
151
|
readonly meta: {
|
|
179
|
-
readonly description: "
|
|
152
|
+
readonly description: "A `Result` dropped as a statement discards its error, so a failed call looks the same as a successful one.";
|
|
153
|
+
readonly summary: "a `Result` dropped as a statement, after `void` or after `await`";
|
|
180
154
|
readonly defaultSeverity: "error";
|
|
181
155
|
readonly tier: "core";
|
|
182
156
|
readonly group: "callers";
|
|
@@ -203,7 +177,8 @@ export declare const RULES: {
|
|
|
203
177
|
readonly "result-unchecked": {
|
|
204
178
|
readonly kind: "file";
|
|
205
179
|
readonly meta: {
|
|
206
|
-
readonly description: "A call that
|
|
180
|
+
readonly description: "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.";
|
|
181
|
+
readonly summary: "a call that fails in its value (an RTK Query trigger, a Result) used as if it had worked";
|
|
207
182
|
readonly defaultSeverity: "warning";
|
|
208
183
|
readonly tier: "core";
|
|
209
184
|
readonly group: "callers";
|
package/dist/rules.meta.js
CHANGED
|
@@ -2,7 +2,8 @@ export const RULES = {
|
|
|
2
2
|
"catch-hides-unexpected": {
|
|
3
3
|
kind: "file",
|
|
4
4
|
meta: {
|
|
5
|
-
description: "A catch that
|
|
5
|
+
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.",
|
|
6
|
+
summary: "a `catch` that handles some error classes with `instanceof` and ignores the rest",
|
|
6
7
|
defaultSeverity: "error",
|
|
7
8
|
tier: "core",
|
|
8
9
|
group: "catch",
|
|
@@ -15,48 +16,11 @@ export const RULES = {
|
|
|
15
16
|
},
|
|
16
17
|
},
|
|
17
18
|
},
|
|
18
|
-
"catch-without-intent": {
|
|
19
|
-
kind: "file",
|
|
20
|
-
meta: {
|
|
21
|
-
description: "Experimental: on real projects it is mostly wrong; it may change or be removed. A catch that handles every error says how: it narrows, reports, or falls back; one that only logs, or calls something stateward cannot judge, and carries on is reported.",
|
|
22
|
-
defaultSeverity: "warning",
|
|
23
|
-
tier: "addon",
|
|
24
|
-
addon: "catch-intent",
|
|
25
|
-
group: "catch",
|
|
26
|
-
heuristic: true,
|
|
27
|
-
options: {
|
|
28
|
-
loggers: {
|
|
29
|
-
kind: "strings",
|
|
30
|
-
default: ["console", "logger", "log"],
|
|
31
|
-
description: "Objects whose every method only logs (globs like *Logger): a catch that only calls them swallows the error.",
|
|
32
|
-
},
|
|
33
|
-
logMethods: {
|
|
34
|
-
kind: "strings",
|
|
35
|
-
default: ["error", "warn", "info", "debug"],
|
|
36
|
-
description: "Method names that only log, on any object (`Sentry.captureException` is not one).",
|
|
37
|
-
},
|
|
38
|
-
logFunctions: {
|
|
39
|
-
kind: "strings",
|
|
40
|
-
default: [],
|
|
41
|
-
description: "Plain functions of your own that only log, like logError (globs allowed).",
|
|
42
|
-
},
|
|
43
|
-
edgeHelpers: {
|
|
44
|
-
kind: "strings",
|
|
45
|
-
default: [],
|
|
46
|
-
description: "Your functions that report an error where it ends (to a service, to the user): a catch that calls one is handled, like reportAndShow (globs allowed). Sentry, Bugsnag and Datadog are known without it.",
|
|
47
|
-
},
|
|
48
|
-
swallowHelpers: {
|
|
49
|
-
kind: "strings",
|
|
50
|
-
default: [],
|
|
51
|
-
description: "Functions that mark an error as ignored on purpose, like ignoreError (globs allowed): a catch that calls one is not reported.",
|
|
52
|
-
},
|
|
53
|
-
},
|
|
54
|
-
},
|
|
55
|
-
},
|
|
56
19
|
"finally-can-throw": {
|
|
57
20
|
kind: "file",
|
|
58
21
|
meta: {
|
|
59
|
-
description: "
|
|
22
|
+
description: "An error thrown in `finally` replaces the error that was already thrown, so the report names the cleanup instead of the original cause.",
|
|
23
|
+
summary: "a `finally` that calls something that can throw and replace the original error",
|
|
60
24
|
defaultSeverity: "error",
|
|
61
25
|
tier: "core",
|
|
62
26
|
group: "catch",
|
|
@@ -77,7 +41,8 @@ export const RULES = {
|
|
|
77
41
|
"catch-misses-error": {
|
|
78
42
|
kind: "project",
|
|
79
43
|
meta: {
|
|
80
|
-
description: "
|
|
44
|
+
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.",
|
|
45
|
+
summary: "a `catch` that branches on error classes but misses one its `try` can throw",
|
|
81
46
|
defaultSeverity: "error",
|
|
82
47
|
tier: "core",
|
|
83
48
|
group: "catch",
|
|
@@ -94,7 +59,8 @@ export const RULES = {
|
|
|
94
59
|
"throws-doc-mismatch": {
|
|
95
60
|
kind: "project",
|
|
96
61
|
meta: {
|
|
97
|
-
description: "
|
|
62
|
+
description: "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.",
|
|
63
|
+
summary: "a `@throws` tag that lists an error the function cannot throw, or misses one it can",
|
|
98
64
|
defaultSeverity: "error",
|
|
99
65
|
tier: "core",
|
|
100
66
|
group: "callers",
|
|
@@ -120,11 +86,13 @@ export const RULES = {
|
|
|
120
86
|
"render-throw-without-boundary": {
|
|
121
87
|
kind: "project",
|
|
122
88
|
meta: {
|
|
123
|
-
description: "
|
|
89
|
+
description: "An error thrown while rendering, with no error boundary above the component, unmounts the whole React tree and leaves a blank page.",
|
|
90
|
+
summary: "a component that can throw a known error while rendering, with no error boundary above it",
|
|
124
91
|
defaultSeverity: "warning",
|
|
125
92
|
tier: "core",
|
|
126
93
|
group: "render",
|
|
127
94
|
needsJsDoc: true,
|
|
95
|
+
needsTypes: { library: "React", modules: ["react"] },
|
|
128
96
|
options: {
|
|
129
97
|
skipContextGuards: {
|
|
130
98
|
kind: "boolean",
|
|
@@ -152,7 +120,8 @@ export const RULES = {
|
|
|
152
120
|
"must-handle-error": {
|
|
153
121
|
kind: "project",
|
|
154
122
|
meta: {
|
|
155
|
-
description: "An error
|
|
123
|
+
description: "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.",
|
|
124
|
+
summary: "a call that can throw an error marked `@mustHandle`, with no code that handles or passes it on",
|
|
156
125
|
defaultSeverity: "error",
|
|
157
126
|
tier: "core",
|
|
158
127
|
group: "callers",
|
|
@@ -163,7 +132,8 @@ export const RULES = {
|
|
|
163
132
|
"narrow-try": {
|
|
164
133
|
kind: "project",
|
|
165
134
|
meta: {
|
|
166
|
-
description: "
|
|
135
|
+
description: "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.",
|
|
136
|
+
summary: "calls to your own code inside a `try` after the call it is for, so their bugs look like its failure",
|
|
167
137
|
defaultSeverity: "warning",
|
|
168
138
|
tier: "addon",
|
|
169
139
|
addon: "narrow-try",
|
|
@@ -174,7 +144,8 @@ export const RULES = {
|
|
|
174
144
|
"result-ignored": {
|
|
175
145
|
kind: "file",
|
|
176
146
|
meta: {
|
|
177
|
-
description: "
|
|
147
|
+
description: "A `Result` dropped as a statement discards its error, so a failed call looks the same as a successful one.",
|
|
148
|
+
summary: "a `Result` dropped as a statement, after `void` or after `await`",
|
|
178
149
|
defaultSeverity: "error",
|
|
179
150
|
tier: "core",
|
|
180
151
|
group: "callers",
|
|
@@ -201,7 +172,8 @@ export const RULES = {
|
|
|
201
172
|
"result-unchecked": {
|
|
202
173
|
kind: "file",
|
|
203
174
|
meta: {
|
|
204
|
-
description: "A call that
|
|
175
|
+
description: "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.",
|
|
176
|
+
summary: "a call that fails in its value (an RTK Query trigger, a Result) used as if it had worked",
|
|
205
177
|
defaultSeverity: "warning",
|
|
206
178
|
tier: "core",
|
|
207
179
|
group: "callers",
|
|
@@ -7,65 +7,55 @@ import { codeList, fitted } from "./text.js";
|
|
|
7
7
|
export const GROUP = "callers";
|
|
8
8
|
const EXPLAIN = `# errors/throws-doc-mismatch
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
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.
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
\`\`\`ts example-bad
|
|
13
|
+
export class NotFoundError extends Error {}
|
|
13
14
|
|
|
14
|
-
\`@throws {T}\` tells callers which errors to expect, and stateward checks every caller's \`catch\` against it. A tag that has drifted from the code misleads both. If \`loadUser\` says \`@throws {NotFoundError}\` but also calls \`JSON.parse\`, a \`SyntaxError\` slips past a \`catch\` written for \`NotFoundError\`. A tag that lists an error the function can no longer throw sends callers to handle something that never comes.
|
|
15
|
-
|
|
16
|
-
## When to use it
|
|
17
|
-
|
|
18
|
-
Use it if you write \`@throws\` tags, typically on public functions and module boundaries. It is on as an error in every preset, and does nothing for functions without the tag, so it costs nothing until you start writing them.
|
|
19
|
-
|
|
20
|
-
## What it catches
|
|
21
|
-
|
|
22
|
-
It compares the tag with what the function can throw (its own \`throw new X(…)\`, the functions it calls, and the built-ins stateward knows), and reports:
|
|
23
|
-
|
|
24
|
-
- a class in the tag that the function cannot throw, neither it nor a subclass;
|
|
25
|
-
- a class the function can throw that the tag does not cover, by itself or by a class it extends.
|
|
26
|
-
|
|
27
|
-
On a function that returns a promise, \`@throws\` means what the promise rejects with: what callers see when they \`await\` it or use \`.catch\`.
|
|
28
|
-
|
|
29
|
-
### Bad
|
|
30
|
-
|
|
31
|
-
\`\`\`ts
|
|
32
15
|
/** @throws {NotFoundError} */
|
|
33
|
-
export
|
|
34
|
-
|
|
35
|
-
return JSON.parse(text)
|
|
16
|
+
export function loadUser(text: string): { name: string } {
|
|
17
|
+
if (text === "") throw new NotFoundError("no such user");
|
|
18
|
+
return JSON.parse(text) as { name: string };
|
|
36
19
|
}
|
|
37
20
|
\`\`\`
|
|
38
21
|
|
|
39
|
-
|
|
22
|
+
Update the tag to what the function can throw; the message gives the one to write:
|
|
23
|
+
|
|
24
|
+
\`\`\`ts example-good
|
|
25
|
+
export class NotFoundError extends Error {}
|
|
40
26
|
|
|
41
|
-
\`\`\`ts
|
|
42
27
|
/** @throws {NotFoundError | SyntaxError} */
|
|
43
|
-
export
|
|
44
|
-
|
|
45
|
-
return JSON.parse(text);
|
|
28
|
+
export function loadUser(text: string): { name: string } {
|
|
29
|
+
if (text === "") throw new NotFoundError("no such user");
|
|
30
|
+
return JSON.parse(text) as { name: string };
|
|
46
31
|
}
|
|
47
32
|
\`\`\`
|
|
48
33
|
|
|
49
|
-
|
|
34
|
+
The message links to the call that throws each missing class. There is no quick fix.
|
|
35
|
+
|
|
36
|
+
## Why it matters
|
|
50
37
|
|
|
51
|
-
|
|
38
|
+
\`@throws {T}\` tells callers which errors to expect, and stateward checks every caller's \`catch\` against it. A tag that has drifted from the code misleads both: here a \`SyntaxError\` from \`JSON.parse\` slips past a \`catch\` written for \`NotFoundError\`. A tag that lists an error the function can no longer throw sends callers to handle something that never comes.
|
|
39
|
+
|
|
40
|
+
## When it says “not checked”
|
|
41
|
+
|
|
42
|
+
- A listed class that comes from a call stateward cannot see into (a package, a callback, a dynamic call): it names the call. Remove the class from the tag if the function cannot throw it, or declare the package's errors under \`facts\` in the config (\`facts: { "config-lib": { load: { throws: ["ConfigError"] } } }\`).
|
|
43
|
+
- A name in the tag that is not a class in scope: import the class, or use a name in scope.
|
|
52
44
|
|
|
53
45
|
## What it does not catch
|
|
54
46
|
|
|
55
|
-
-
|
|
56
|
-
|
|
57
|
-
-
|
|
58
|
-
-
|
|
59
|
-
-
|
|
60
|
-
- Errors from the JavaScript engine itself, such as reading a property of \`undefined\`.
|
|
47
|
+
It compares the tag with what the function can throw (its own \`throw new X(…)\`, the functions it calls, and the built-ins stateward knows) and reports a class in the tag that the function cannot throw (neither it nor a subclass), and a class it can throw that the tag does not cover. On a function that returns a promise, \`@throws\` means what the promise rejects with. It is silent on:
|
|
48
|
+
|
|
49
|
+
- a function without \`@throws\`, or a tag without a type (\`@throws when the id is empty\`): that only says the function throws;
|
|
50
|
+
- a class that a surrounding \`try\` may catch: only classes that surely escape are reported as missing;
|
|
51
|
+
- errors from the JavaScript engine itself, such as reading a property of \`undefined\`.
|
|
61
52
|
|
|
62
53
|
## Turn it on or off
|
|
63
54
|
|
|
64
|
-
|
|
55
|
+
It does nothing for functions without the tag, so it costs nothing until you start writing them.
|
|
65
56
|
|
|
66
57
|
\`\`\`ts
|
|
67
58
|
rules: { "errors/throws-doc-mismatch": "off" } // or "warning"
|
|
68
|
-
groups: { "errors/inference": "off" } // the whole group
|
|
69
59
|
rules: { "errors/throws-doc-mismatch": ["error", { severities: { extra: "warning" } }] } // a stale tag only warns
|
|
70
60
|
\`\`\`
|
|
71
61
|
`;
|
|
@@ -109,7 +99,7 @@ function check(entry, analysis, ctx) {
|
|
|
109
99
|
const location = locationOf(entry.file, item.at);
|
|
110
100
|
if (item.id === null) {
|
|
111
101
|
ctx.unknown({
|
|
112
|
-
message: fitted((cut) => `
|
|
102
|
+
message: fitted((cut) => `not checked: \`@throws\` names \`${cut(item.name)}\`: no class of that name is in scope`),
|
|
113
103
|
hint: "name an error class that is in scope in this file, or import it",
|
|
114
104
|
location,
|
|
115
105
|
});
|
|
@@ -122,14 +112,14 @@ function check(entry, analysis, ctx) {
|
|
|
122
112
|
const listed = className(id, classes);
|
|
123
113
|
if (firstUnknown !== undefined) {
|
|
124
114
|
ctx.unknown({
|
|
125
|
-
message: fitted((cut) => `
|
|
126
|
-
hint: `
|
|
115
|
+
message: fitted((cut) => `not checked: whether \`${cut(name)}\` throws \`${cut(listed)}\`: what \`${cut(firstUnknown.text)}\` throws is not known (${firstUnknown.reason})`),
|
|
116
|
+
hint: `remove \`${listed}\` from the tag if \`${name}\` cannot throw it; otherwise add \`@throws\` where it comes from`,
|
|
127
117
|
location,
|
|
128
118
|
});
|
|
129
119
|
continue;
|
|
130
120
|
}
|
|
131
121
|
ctx.report({
|
|
132
|
-
message: fitted((cut) => `\`@throws\`
|
|
122
|
+
message: fitted((cut) => `\`@throws\` of \`${cut(name)}\` lists \`${cut(listed)}\`, which it cannot throw`),
|
|
133
123
|
hint: `remove \`${listed}\` from the tag, or throw it where it can happen`,
|
|
134
124
|
location,
|
|
135
125
|
findingKind: "extra",
|
|
@@ -160,7 +150,7 @@ function check(entry, analysis, ctx) {
|
|
|
160
150
|
const names = missing.map((one) => className(one.id, classes)).sort();
|
|
161
151
|
const tagNames = [...new Set(missing.map((one) => catchName(one.id, classes)))].sort();
|
|
162
152
|
ctx.report({
|
|
163
|
-
message: fitted((cut) =>
|
|
153
|
+
message: fitted((cut) => `\`@throws\` of \`${cut(name)}\` does not list ${codeList(names, cut)}`),
|
|
164
154
|
hint: `add ${missing.length === 1 ? "it" : "them"} to the tag: \`@throws {${tagNames.join(" | ")}}\``,
|
|
165
155
|
location: locationOf(entry.file, entry.fact.at),
|
|
166
156
|
related: missing.flatMap((one) => (one.related === undefined ? [] : [one.related])).slice(0, 3),
|
|
@@ -170,7 +160,8 @@ function check(entry, analysis, ctx) {
|
|
|
170
160
|
export const throwsDocMismatch = defineRule({
|
|
171
161
|
kind: "project",
|
|
172
162
|
meta: {
|
|
173
|
-
description: "
|
|
163
|
+
description: "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.",
|
|
164
|
+
summary: "a `@throws` tag that lists an error the function cannot throw, or misses one it can",
|
|
174
165
|
defaultSeverity: "error",
|
|
175
166
|
tier: "core",
|
|
176
167
|
group: GROUP,
|