@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/inference/format.js
CHANGED
|
@@ -21,15 +21,15 @@ function sourceKey(source) {
|
|
|
21
21
|
function sourceText(source) {
|
|
22
22
|
return `${source.text}: ${source.reason}`;
|
|
23
23
|
}
|
|
24
|
-
/** `others
|
|
24
|
+
/** `others not known (from a: why; b: why; and 3 more)`. */
|
|
25
25
|
export function unknownText(sources, more) {
|
|
26
26
|
const listed = sources.slice(0, MAX_LISTED_SOURCES).map(sourceText);
|
|
27
27
|
const rest = sources.length - listed.length;
|
|
28
28
|
const tail = rest > 0 || more ? [`and ${String(rest)}${more ? "+" : ""} more`] : [];
|
|
29
|
-
return `others
|
|
29
|
+
return `others not known (from ${[...listed, ...tail].join("; ")})`;
|
|
30
30
|
}
|
|
31
31
|
/**
|
|
32
|
-
* `ParseError | ZodError (zod 3.23.8: declared) | others
|
|
32
|
+
* `ParseError | ZodError (zod 3.23.8: declared) | others not known (from …)`, or "" for an empty set: a class
|
|
33
33
|
* known from a package's facts names where they come from.
|
|
34
34
|
*/
|
|
35
35
|
export function thrownText(thrown, classes) {
|
|
@@ -58,7 +58,7 @@ export function rejectsShown(result) {
|
|
|
58
58
|
}
|
|
59
59
|
/**
|
|
60
60
|
* The hover's lines: `throws: …` for what a call throws, `rejects: …` for what its promise
|
|
61
|
-
* rejects with, `throws nothing
|
|
61
|
+
* rejects with, `throws nothing known` when both are empty.
|
|
62
62
|
*/
|
|
63
63
|
export function resultLines(result, classes) {
|
|
64
64
|
const throws = thrownText(result.throws, classes);
|
|
@@ -67,6 +67,6 @@ export function resultLines(result, classes) {
|
|
|
67
67
|
...(throws === "" ? [] : [`throws: ${throws}`]),
|
|
68
68
|
...(rejects === "" ? [] : [`rejects: ${rejects}`]),
|
|
69
69
|
];
|
|
70
|
-
return lines.length === 0 ? ["throws nothing
|
|
70
|
+
return lines.length === 0 ? ["throws nothing known"] : lines;
|
|
71
71
|
}
|
|
72
72
|
//# sourceMappingURL=format.js.map
|
package/dist/inference/solve.js
CHANGED
|
@@ -9,7 +9,7 @@ export declare const REASON: {
|
|
|
9
9
|
readonly dynamic: "dynamic call";
|
|
10
10
|
readonly thrownValue: "a thrown value of unknown class";
|
|
11
11
|
readonly madeError: "an error made by a call";
|
|
12
|
-
readonly promise: "a promise
|
|
12
|
+
readonly promise: "a promise that is not followed";
|
|
13
13
|
readonly heldPromise: "promise held in a variable";
|
|
14
14
|
};
|
|
15
15
|
/** Why a callback handed to a call of the platform that is not in the tables adds `unknown`. */
|
|
@@ -10,7 +10,7 @@ export const REASON = {
|
|
|
10
10
|
dynamic: "dynamic call",
|
|
11
11
|
thrownValue: "a thrown value of unknown class",
|
|
12
12
|
madeError: "an error made by a call",
|
|
13
|
-
promise: "a promise
|
|
13
|
+
promise: "a promise that is not followed",
|
|
14
14
|
heldPromise: "promise held in a variable",
|
|
15
15
|
};
|
|
16
16
|
/** Why a callback handed to a call of the platform that is not in the tables adds `unknown`. */
|
|
@@ -345,7 +345,7 @@ export function summarizeThrows(ctx, packages) {
|
|
|
345
345
|
...doc.entries.map((entry) => entry.id === null
|
|
346
346
|
? {
|
|
347
347
|
k: "unknown",
|
|
348
|
-
reason: "`@throws` names
|
|
348
|
+
reason: "`@throws` names a class that is not found",
|
|
349
349
|
text: entry.name,
|
|
350
350
|
at: span,
|
|
351
351
|
}
|
|
@@ -12,85 +12,76 @@ import { fitted, oneLine, shorten, withExample } from "./text.js";
|
|
|
12
12
|
export const GROUP = "callers";
|
|
13
13
|
const EXPLAIN = `# errors/must-handle-error
|
|
14
14
|
|
|
15
|
-
An error
|
|
15
|
+
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.
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
Some errors need a decision from the code that meets them. A declined card is not a crash: the user should be told and offered another card. If \`checkout\` lets \`PaymentDeclinedError\` fall into a generic \`catch\` that shows "Something went wrong", the user never learns why, and tries the same card again. TypeScript cannot say which errors a call can throw, so nothing reminds the author of \`checkout\` that this one needs handling.
|
|
20
|
-
|
|
21
|
-
Write the decision once, on the class:
|
|
22
|
-
|
|
23
|
-
\`\`\`ts
|
|
17
|
+
\`\`\`ts example-bad
|
|
24
18
|
/** @mustHandle Tell the user the card was declined and let them pick another card. */
|
|
25
19
|
export class PaymentDeclinedError extends Error {}
|
|
26
|
-
\`\`\`
|
|
27
|
-
|
|
28
|
-
and stateward checks every call that can throw it, in any file, and shows your sentence where it is missed.
|
|
29
|
-
|
|
30
|
-
## When to use it
|
|
31
20
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
A call that can throw or reject with a marked class (or a class that extends it), when nothing between the call and the function's callers handles it on purpose:
|
|
21
|
+
function pay(amount: number): string {
|
|
22
|
+
if (amount > 100) throw new PaymentDeclinedError("insufficient funds");
|
|
23
|
+
return "receipt-1";
|
|
24
|
+
}
|
|
37
25
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
26
|
+
export function reorder(amount: number): string {
|
|
27
|
+
return pay(amount);
|
|
28
|
+
}
|
|
29
|
+
\`\`\`
|
|
42
30
|
|
|
43
|
-
|
|
31
|
+
Name the class in a \`catch\` and do what its sentence says:
|
|
44
32
|
|
|
45
|
-
|
|
33
|
+
\`\`\`ts example-good
|
|
34
|
+
/** @mustHandle Tell the user the card was declined and let them pick another card. */
|
|
35
|
+
export class PaymentDeclinedError extends Error {}
|
|
46
36
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return
|
|
37
|
+
function pay(amount: number): string {
|
|
38
|
+
if (amount > 100) throw new PaymentDeclinedError("insufficient funds");
|
|
39
|
+
return "receipt-1";
|
|
50
40
|
}
|
|
51
|
-
\`\`\`
|
|
52
41
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
\`\`\`ts
|
|
56
|
-
export async function reorder(order: Order, card: Card): Promise<ReorderResult> {
|
|
42
|
+
export function reorder(amount: number): { kind: "paid"; receipt: string } | { kind: "declined" } {
|
|
57
43
|
try {
|
|
58
|
-
return { kind: "paid", receipt:
|
|
44
|
+
return { kind: "paid", receipt: pay(amount) };
|
|
59
45
|
} catch (e) {
|
|
60
|
-
if (e instanceof PaymentDeclinedError) return { kind: "declined"
|
|
46
|
+
if (e instanceof PaymentDeclinedError) return { kind: "declined" };
|
|
61
47
|
throw e;
|
|
62
48
|
}
|
|
63
49
|
}
|
|
64
50
|
\`\`\`
|
|
65
51
|
|
|
66
|
-
|
|
52
|
+
Or pass it on with \`/** @throws {PaymentDeclinedError} */\` on \`reorder\`, so its callers decide (and are checked the same way); the quick fix "Pass it on" adds that tag, creating the JSDoc when there is none. What to do in a \`catch\` branch is your call, so no fix writes one. Or change the throwing function to return the failure as a value.
|
|
67
53
|
|
|
68
|
-
|
|
69
|
-
/** @throws {PaymentDeclinedError} */
|
|
70
|
-
export async function reorder(order: Order, card: Card): Promise<Receipt> {
|
|
71
|
-
return await pay(order, card);
|
|
72
|
-
}
|
|
73
|
-
\`\`\`
|
|
54
|
+
## Why it matters
|
|
74
55
|
|
|
75
|
-
|
|
56
|
+
Some errors need a decision from the code that meets them. A declined card is not a crash: the user should be told and offered another card. If \`reorder\` lets \`PaymentDeclinedError\` fall into a generic \`catch\` that shows "Something went wrong", the user never learns why, and tries the same card again. TypeScript cannot say which errors a call can throw, so nothing reminds the author of \`reorder\` that this one needs handling. With \`@mustHandle\`, you write the decision once, on the class; stateward checks every call that can throw it, in any file, and shows your sentence where it is missed.
|
|
76
57
|
|
|
77
|
-
|
|
58
|
+
## When it says “not checked”
|
|
59
|
+
|
|
60
|
+
Where it cannot follow the error, it says so, and \`stateward check\` lists these places (not only counts them), since you asked for this error: a callback whose caller it cannot see (an event handler, a function passed to another), a \`catch\` that tests a class whose \`extends\` it cannot follow, a test inside the called function it cannot read. A \`@mustHandle\` on a type alias or an interface (plain-object errors such as \`{ kind: "declined" }\`) is not checked yet, and is reported the same way.
|
|
78
61
|
|
|
79
62
|
## What it does not catch
|
|
80
63
|
|
|
81
|
-
|
|
82
|
-
- \`promise.catch(handler)\`: the handler is taken to handle every rejection.
|
|
83
|
-
- A \`@mustHandle\` on a type alias or an interface (plain-object errors such as \`{ kind: "declined" }\`): not checked yet, and reported as a place not checked. That is the next step.
|
|
84
|
-
- Errors from packages: only classes of your project can be marked.
|
|
85
|
-
- A \`throw new PaymentDeclinedError()\` itself: the function that throws it is where it starts; its callers are checked.
|
|
64
|
+
A call that can throw or reject with a marked class (or one that extends it) is fine when it is:
|
|
86
65
|
|
|
87
|
-
|
|
66
|
+
- **handled on purpose**: a \`catch\` around it has a branch \`e instanceof PaymentDeclinedError\` (or a marked class it extends) that does not rethrow it. A guard \`if (!(e instanceof PaymentDeclinedError)) throw e;\` followed by the handling, a \`?:\` or \`&&\` that tests it, and a \`switch (true)\` case count the same;
|
|
67
|
+
- **handed to a helper that handles it**: the \`catch\` passes its error to a function of yours whose first statement is \`if (error instanceof PaymentDeclinedError) …\` and does not rethrow it there (only that function is read, not the ones it calls);
|
|
68
|
+
- **handled where it is called**: a helper declared inside a function and called only inside a handling \`try\` is judged at those calls;
|
|
69
|
+
- **passed on on purpose**: the function's JSDoc has \`@throws {PaymentDeclinedError}\`. An awaited promise counts as a call.
|
|
70
|
+
|
|
71
|
+
Everything else is reported at the call: no \`try\`, a \`catch\` that treats every error alike (no branch, or only \`instanceof Error\`), or one that rethrows everything (\`report(e); throw e;\`) in a function without \`@throws\`. The rule is silent on:
|
|
72
|
+
|
|
73
|
+
- \`promise.catch(handler)\`: the handler is taken to handle every rejection;
|
|
74
|
+
- errors from packages: only classes of your project can be marked;
|
|
75
|
+
- the \`throw new PaymentDeclinedError()\` itself: that is where it starts; its callers are checked.
|
|
88
76
|
|
|
89
|
-
|
|
77
|
+
## Getting the best from it
|
|
78
|
+
|
|
79
|
+
Mark only the few errors that callers must act on: a declined payment, an expired session, a conflict the user must resolve. The rule reports nothing until you mark a class, so you choose, error by error, what is checked. Write the sentence for the person who meets the error: it is what the finding shows. For new code, returning the failure as a value (a \`Result\` or a union) lets TypeScript itself check it; \`@mustHandle\` is for errors that are already thrown.
|
|
80
|
+
|
|
81
|
+
## Turn it on or off
|
|
90
82
|
|
|
91
83
|
\`\`\`ts
|
|
92
84
|
rules: { "errors/must-handle-error": "off" } // or "warning"
|
|
93
|
-
groups: { "errors/catch": "off" } // the whole group
|
|
94
85
|
\`\`\`
|
|
95
86
|
`;
|
|
96
87
|
const TAG = "mustHandle";
|
|
@@ -530,13 +521,13 @@ function hintLine(met, name, model) {
|
|
|
530
521
|
const passOn = fn === undefined ? undefined : `\`@throws {${cut(tag)}}\``;
|
|
531
522
|
if (by?.fate === "generic") {
|
|
532
523
|
return passOn === undefined || fn === undefined
|
|
533
|
-
? `name \`${cut(name)}\` in
|
|
534
|
-
: `name it in
|
|
524
|
+
? `name \`${cut(name)}\` in the \`catch\`, which treats every error alike:`
|
|
525
|
+
: `name it in the \`catch\`, which treats every error alike, or rethrow it and add ${passOn} to \`${cut(fn)}\`:`;
|
|
535
526
|
}
|
|
536
527
|
if (by?.fate === "rethrown") {
|
|
537
528
|
return passOn === undefined || fn === undefined
|
|
538
|
-
? "
|
|
539
|
-
: `
|
|
529
|
+
? "handle it in the `catch`: the `catch` passes it on, and nothing above handles it:"
|
|
530
|
+
: `add ${passOn} to \`${cut(fn)}\`, or handle it in the \`catch\`: the \`catch\` passes it on:`;
|
|
540
531
|
}
|
|
541
532
|
return passOn === undefined || fn === undefined
|
|
542
533
|
? "name it in a `catch` around the call, or return it as a failure instead of throwing:"
|
|
@@ -644,8 +635,8 @@ function report(met, model, out) {
|
|
|
644
635
|
related.push({
|
|
645
636
|
location: locationOf(met.file, outcome.by.handler.at),
|
|
646
637
|
message: outcome.by.fate === "generic"
|
|
647
|
-
?
|
|
648
|
-
:
|
|
638
|
+
? `\`catch\` does not name \`${name}\`: it handles every error alike`
|
|
639
|
+
: `\`catch\` rethrows \`${name}\``,
|
|
649
640
|
});
|
|
650
641
|
}
|
|
651
642
|
related.push(tagNote(met, where, model));
|
|
@@ -661,22 +652,22 @@ function report(met, model, out) {
|
|
|
661
652
|
}
|
|
662
653
|
case "callback":
|
|
663
654
|
out.unknown({
|
|
664
|
-
message: fitted((cut) =>
|
|
665
|
-
hint: fitted((cut) => `name \`${cut(tag)}\` in a \`catch\`
|
|
655
|
+
message: fitted((cut) => `not checked: whether \`${cut(name)}\` from \`${cut(call)}\` is handled: it leaves a callback whose caller is not known`),
|
|
656
|
+
hint: fitted((cut) => `name \`${cut(tag)}\` in a \`catch\` around the call, or in the code that calls the callback: it must be handled`),
|
|
666
657
|
location,
|
|
667
658
|
});
|
|
668
659
|
return;
|
|
669
660
|
case "undecided":
|
|
670
661
|
out.unknown({
|
|
671
|
-
message: fitted((cut) => `
|
|
672
|
-
hint: fitted((cut) => `name it with \`instanceof ${cut(tag)}
|
|
662
|
+
message: fitted((cut) => `not checked: whether the \`catch\` around \`${cut(call)}\` handles \`${cut(name)}\`: the class it tests is not known`),
|
|
663
|
+
hint: fitted((cut) => `name it with \`instanceof ${cut(tag)}\`, so the \`catch\` can be checked`),
|
|
673
664
|
location,
|
|
674
665
|
});
|
|
675
666
|
return;
|
|
676
667
|
case "maybe":
|
|
677
668
|
out.unknown({
|
|
678
|
-
message: fitted((cut) => `
|
|
679
|
-
hint: fitted((cut) => `name it in a \`catch\`, or pass it on with \`@throws {${cut(tag)}}
|
|
669
|
+
message: fitted((cut) => `not checked: whether \`${cut(call)}\` can still throw \`${cut(name)}\`: a test inside \`${cut(met.leaf.text)}\` may handle it`),
|
|
670
|
+
hint: fitted((cut) => `name it in a \`catch\`, or pass it on with \`@throws {${cut(tag)}}\`, so the call can be checked: it must be handled`),
|
|
680
671
|
location,
|
|
681
672
|
});
|
|
682
673
|
return;
|
|
@@ -714,7 +705,8 @@ function modelOf(summaries) {
|
|
|
714
705
|
export const mustHandleError = defineRule({
|
|
715
706
|
kind: "project",
|
|
716
707
|
meta: {
|
|
717
|
-
description: "An error
|
|
708
|
+
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.",
|
|
709
|
+
summary: "a call that can throw an error marked `@mustHandle`, with no code that handles or passes it on",
|
|
718
710
|
defaultSeverity: "error",
|
|
719
711
|
tier: "core",
|
|
720
712
|
group: GROUP,
|
|
@@ -730,8 +722,8 @@ export const mustHandleError = defineRule({
|
|
|
730
722
|
const { model, untracked } = modelOf(summaries);
|
|
731
723
|
for (const location of untracked) {
|
|
732
724
|
ctx.unknown({
|
|
733
|
-
message: "
|
|
734
|
-
hint: "
|
|
725
|
+
message: "not checked: the calls that meet `@mustHandle` on a type: only an error class is followed",
|
|
726
|
+
hint: "throw an error class marked `@mustHandle`, so every call is checked; plain-object errors come later",
|
|
735
727
|
location,
|
|
736
728
|
});
|
|
737
729
|
}
|
package/dist/narrow-try.d.ts
CHANGED
|
@@ -19,10 +19,15 @@ type Line = {
|
|
|
19
19
|
* `JSON.parse(text)`): a value that may not have the shape its type says.
|
|
20
20
|
*/
|
|
21
21
|
readonly data: readonly string[];
|
|
22
|
-
/** What its `await`s wait for, outside nested functions. */
|
|
22
|
+
/** What its `await`s wait for, outside nested functions: each call up to its arguments. */
|
|
23
23
|
readonly awaits: readonly Span[];
|
|
24
24
|
/** The first thing it awaits, as written (`promise`), or null. */
|
|
25
25
|
readonly awaited: string | null;
|
|
26
|
+
/**
|
|
27
|
+
* The bodies of the functions it only defines (`const save = async () => {…}`, a function
|
|
28
|
+
* declaration): nothing in them runs at this line, so what they can throw is not its.
|
|
29
|
+
*/
|
|
30
|
+
readonly defines: readonly Span[];
|
|
26
31
|
/** The names it declares in the block. */
|
|
27
32
|
readonly declares: readonly string[];
|
|
28
33
|
/** Every name it mentions. */
|
package/dist/narrow-try.js
CHANGED
|
@@ -11,97 +11,80 @@ import { fitted, oneLine, shorten } from "./text.js";
|
|
|
11
11
|
export const GROUP = "catch";
|
|
12
12
|
const EXPLAIN = `# errors/narrow-try
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
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.
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
\`\`\`ts example-bad
|
|
17
|
+
class RequestError extends Error {}
|
|
18
|
+
declare function showError(error: unknown): void;
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
openEditor(plan);
|
|
22
|
-
} catch (e) {
|
|
23
|
-
showError("Could not load the plan");
|
|
20
|
+
async function savePlan(plan: string): Promise<void> {
|
|
21
|
+
const response = await fetch("/api/plans", { method: "POST", body: plan });
|
|
22
|
+
if (!response.ok) throw new RequestError(String(response.status));
|
|
24
23
|
}
|
|
25
|
-
\`\`\`
|
|
26
|
-
|
|
27
|
-
The \`catch\` is for a failed \`getPlan\`. When \`openEditor\` has a bug (it reads \`plan.details.title\` and a plan comes back without \`details\`), the \`TypeError\` is handled like a failed request: the user sees "Could not load the plan", the console shows nothing useful, and the bug is never reported.
|
|
28
|
-
|
|
29
|
-
## When to use it
|
|
30
|
-
|
|
31
|
-
Use it to review how wide your \`try\` blocks are. It is an add-on, off until you list it: in some code styles most \`try\` blocks hold the whole success path on purpose, and then it is noise. It needs to know which call in the \`try\` can fail as expected, so it works best where your functions throw classes or document \`@throws\`, and with packages stateward knows (\`stateward deps\`).
|
|
32
24
|
|
|
33
|
-
|
|
25
|
+
export async function submit(plan: string, close: () => void): Promise<void> {
|
|
26
|
+
try {
|
|
27
|
+
await savePlan(plan);
|
|
28
|
+
close();
|
|
29
|
+
} catch (e) {
|
|
30
|
+
showError(e);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
\`\`\`
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
Keep only the call the \`try\` is for inside it, and move the rest after it:
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
\`\`\`ts example-good
|
|
39
|
+
class RequestError extends Error {}
|
|
40
|
+
declare function showError(error: unknown): void;
|
|
38
41
|
|
|
39
|
-
|
|
42
|
+
async function savePlan(plan: string): Promise<void> {
|
|
43
|
+
const response = await fetch("/api/plans", { method: "POST", body: plan });
|
|
44
|
+
if (!response.ok) throw new RequestError(String(response.status));
|
|
45
|
+
}
|
|
40
46
|
|
|
41
|
-
|
|
42
|
-
try {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
export async function submit(plan: string, close: () => void): Promise<void> {
|
|
48
|
+
try {
|
|
49
|
+
await savePlan(plan);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
showError(e);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
close();
|
|
49
55
|
}
|
|
50
56
|
\`\`\`
|
|
51
57
|
|
|
52
|
-
|
|
58
|
+
The quick fix moves the lines after the \`try\`. It is offered only when the move keeps what runs the same for every outcome but a bug: the \`catch\` ends with \`return\` or \`throw\`, there is no \`finally\`, and the moved lines use nothing declared in the \`try\`. Where they use the result, declare it before the \`try\` (\`let plan: Plan;\`), assign it inside, and return from the \`catch\`.
|
|
53
59
|
|
|
54
|
-
|
|
55
|
-
try {
|
|
56
|
-
await savePlan(plan);
|
|
57
|
-
} catch (e) {
|
|
58
|
-
showError(e);
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
closeEditor();
|
|
62
|
-
onSaved();
|
|
63
|
-
\`\`\`
|
|
60
|
+
## Why it matters
|
|
64
61
|
|
|
65
|
-
|
|
62
|
+
The \`catch\` is for a failed save. When \`close\` has a bug (it reads a property of \`undefined\`, say), the \`TypeError\` is handled like a failed request: the user sees "Could not save", the console shows nothing useful, and the bug is never reported. With only the request inside the \`try\`, a bug in the code after it surfaces as the bug it is.
|
|
66
63
|
|
|
67
|
-
|
|
64
|
+
## What it does not catch
|
|
68
65
|
|
|
69
|
-
|
|
70
|
-
let plan: Plan;
|
|
71
|
-
try {
|
|
72
|
-
plan = await getPlan(id);
|
|
73
|
-
} catch (e) {
|
|
74
|
-
showError("Could not load the plan");
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
openEditor(plan);
|
|
78
|
-
\`\`\`
|
|
66
|
+
It needs a call that can fail as expected: one that throws a known error class (\`throw new RequestError(…)\` in your code, \`@throws\`, a built-in like \`JSON.parse\` or \`new URL\`), an awaited call into a package whose errors are not all known, or an awaited call whose rejection is not known to be nothing. The message names it. A package call that is not awaited (\`toast.success(…)\`) is not one; with no such call the rule says nothing. After it, it reports the first line that calls your own code: a function of the project given data the \`try\` got (\`openEditor(plan)\`), or a callback the function was given (\`close()\`). It is silent on:
|
|
79
67
|
|
|
80
|
-
|
|
68
|
+
- lines that only read, test or default what the \`try\` got (\`setTitle(plan.title)\`, \`if ("items" in data)\`, \`data.items ?? []\`), \`break\` and \`continue\`, store dispatches (\`dispatch(addNotification(…))\`), and a promise's own \`resolve(…)\`: these are mostly inside the \`try\` on purpose;
|
|
69
|
+
- lines that only use built-ins (\`seen.set(id, host)\`) or cannot fail (\`return true;\`);
|
|
70
|
+
- a \`catch\` that tests the error (\`instanceof\`, \`e.name === …\`, a type guard), rethrows it, or hands it to \`reject(e)\` or another callback it was given: it already lets bugs through;
|
|
71
|
+
- what \`JSON.parse\` or \`await response.json()\` gives: a value of the wrong shape is the same failure as bad JSON, which the \`catch\` is for (a callback given it, \`onEvent(event)\`, is still reported). A value declared \`unknown\` is not data either, since TypeScript makes you test it first;
|
|
72
|
+
- code in a callback written inside the \`try\`: when what it throws is not known, the rule assumes the worst and reports nothing.
|
|
81
73
|
|
|
82
|
-
|
|
83
|
-
- A \`catch\` that tests the error (\`instanceof\`, \`e.name === …\`, a type guard) or rethrows it: it already lets bugs through.
|
|
84
|
-
- A \`try\` where nothing is known to fail as expected: every call in it unknown and not awaited.
|
|
85
|
-
- Lines after the expected call that only use built-ins (\`seen.set(id, host)\`, \`url.searchParams.get("t")\`), or cannot fail (\`return true;\`).
|
|
86
|
-
- A \`catch\` that hands the error to \`reject(e)\` or another callback it was given: the bug reaches the caller.
|
|
87
|
-
- Code in a callback written inside the \`try\` counts as the callback's call: when stateward cannot tell what it throws, it assumes the worst and stays quiet.
|
|
74
|
+
A limit: an awaited result of your own function (\`result.success\` after \`await convert(…)\`) counts as data too; the rule does not tell a typed result from a response.
|
|
88
75
|
|
|
89
|
-
|
|
76
|
+
ESLint, typescript-eslint, eslint-plugin-unicorn and Biome have no rule about how wide a \`try\` is: their catch rules are about empty catches, catches that only rethrow, and the catch variable.
|
|
90
77
|
|
|
91
|
-
|
|
78
|
+
## Getting the best from it
|
|
79
|
+
|
|
80
|
+
It works best where your functions throw classes or document \`@throws\`, and with packages stateward knows (\`stateward deps\`): it then knows which call in the \`try\` can fail as expected.
|
|
92
81
|
|
|
93
82
|
## Turn it on or off
|
|
94
83
|
|
|
95
|
-
|
|
84
|
+
It is an add-on: in some code styles most \`try\` blocks hold the whole success path on purpose, and then it is noise. Use it to review how wide your \`try\` blocks are:
|
|
96
85
|
|
|
97
86
|
\`\`\`ts
|
|
98
87
|
addons: ["errors/narrow-try"]
|
|
99
|
-
\`\`\`
|
|
100
|
-
|
|
101
|
-
Then change it, or turn it off in one folder:
|
|
102
|
-
|
|
103
|
-
\`\`\`ts
|
|
104
|
-
rules: { "errors/narrow-try": "off" } // or "error"
|
|
105
88
|
overrides: [{ files: ["src/scripts/**"], rules: { "errors/narrow-try": "off" } }] // one folder
|
|
106
89
|
\`\`\`
|
|
107
90
|
`;
|
|
@@ -281,6 +264,63 @@ function endsWithExit(block) {
|
|
|
281
264
|
const last = block.statements.at(-1);
|
|
282
265
|
return last !== undefined && (ts.isReturnStatement(last) || ts.isThrowStatement(last));
|
|
283
266
|
}
|
|
267
|
+
/** The calls whose rejection reaches `Promise.all([…])` and its kin. */
|
|
268
|
+
const COMBINED = new Set(["all", "race", "any"]);
|
|
269
|
+
/**
|
|
270
|
+
* Where what an `await` waits for is written: a call up to its arguments (`api.save` in
|
|
271
|
+
* `await api.save({ ttl: ttl.trim() })`, not `ttl.trim`, user trial 1.0.2), the promise a
|
|
272
|
+
* `.then`/`.catch`/`.finally` chain starts from, and each call listed in `Promise.all([…])`;
|
|
273
|
+
* nothing for `Promise.allSettled(…)`, which never rejects; any other value whole. A call inside the arguments runs before the request and is not it.
|
|
274
|
+
*/
|
|
275
|
+
function awaitedSpans(expression, sourceFile) {
|
|
276
|
+
let node = expression;
|
|
277
|
+
while (ts.isParenthesizedExpression(node) ||
|
|
278
|
+
ts.isNonNullExpression(node) ||
|
|
279
|
+
ts.isAsExpression(node) ||
|
|
280
|
+
ts.isSatisfiesExpression(node)) {
|
|
281
|
+
node = node.expression;
|
|
282
|
+
}
|
|
283
|
+
if (!ts.isCallExpression(node))
|
|
284
|
+
return [[node.getStart(sourceFile), node.getEnd()]];
|
|
285
|
+
const callee = node.expression;
|
|
286
|
+
const own = [node.getStart(sourceFile), callee.getEnd()];
|
|
287
|
+
if (!ts.isPropertyAccessExpression(callee))
|
|
288
|
+
return [own];
|
|
289
|
+
const method = callee.name.text;
|
|
290
|
+
if (method === "then" || method === "catch" || method === "finally") {
|
|
291
|
+
return [own, ...awaitedSpans(callee.expression, sourceFile)];
|
|
292
|
+
}
|
|
293
|
+
const [list] = node.arguments;
|
|
294
|
+
// `Promise.allSettled(…)` never rejects: an `await` of it waits for nothing that can fail.
|
|
295
|
+
if (ts.isIdentifier(callee.expression) &&
|
|
296
|
+
callee.expression.text === "Promise" &&
|
|
297
|
+
method === "allSettled") {
|
|
298
|
+
return [];
|
|
299
|
+
}
|
|
300
|
+
if (ts.isIdentifier(callee.expression) &&
|
|
301
|
+
callee.expression.text === "Promise" &&
|
|
302
|
+
COMBINED.has(method) &&
|
|
303
|
+
list !== undefined &&
|
|
304
|
+
ts.isArrayLiteralExpression(list)) {
|
|
305
|
+
return [own, ...list.elements.flatMap((element) => awaitedSpans(element, sourceFile))];
|
|
306
|
+
}
|
|
307
|
+
return [own];
|
|
308
|
+
}
|
|
309
|
+
/** The functions `statement` defines without running them, as spans (`Line.defines`). */
|
|
310
|
+
function definedFunctions(statement, sourceFile) {
|
|
311
|
+
if (ts.isFunctionDeclaration(statement))
|
|
312
|
+
return [[statement.getStart(sourceFile), statement.getEnd()]];
|
|
313
|
+
if (!ts.isVariableStatement(statement))
|
|
314
|
+
return [];
|
|
315
|
+
return statement.declarationList.declarations.flatMap((declaration) => {
|
|
316
|
+
let value = declaration.initializer;
|
|
317
|
+
while (value !== undefined && ts.isParenthesizedExpression(value))
|
|
318
|
+
value = value.expression;
|
|
319
|
+
return value !== undefined && (ts.isArrowFunction(value) || ts.isFunctionExpression(value))
|
|
320
|
+
? [[value.getStart(sourceFile), value.getEnd()]]
|
|
321
|
+
: [];
|
|
322
|
+
});
|
|
323
|
+
}
|
|
284
324
|
function shapeOf(node, sourceFile) {
|
|
285
325
|
const clause = node.catchClause;
|
|
286
326
|
if (clause === undefined)
|
|
@@ -294,7 +334,10 @@ function shapeOf(node, sourceFile) {
|
|
|
294
334
|
let awaited = null;
|
|
295
335
|
running(statement, (inner) => {
|
|
296
336
|
if (ts.isAwaitExpression(inner)) {
|
|
297
|
-
|
|
337
|
+
const spans = awaitedSpans(inner.expression, sourceFile);
|
|
338
|
+
if (spans.length === 0)
|
|
339
|
+
return;
|
|
340
|
+
awaits.push(...spans);
|
|
298
341
|
awaited ??= shorten(oneLine(inner.expression.getText(sourceFile)), LINE_MAX);
|
|
299
342
|
}
|
|
300
343
|
});
|
|
@@ -306,6 +349,7 @@ function shapeOf(node, sourceFile) {
|
|
|
306
349
|
data: dataOf(statement, data),
|
|
307
350
|
awaits,
|
|
308
351
|
awaited,
|
|
352
|
+
defines: definedFunctions(statement, sourceFile),
|
|
309
353
|
declares: declaredBy(statement),
|
|
310
354
|
uses: namesIn(statement),
|
|
311
355
|
});
|
|
@@ -344,10 +388,12 @@ function readLine(value) {
|
|
|
344
388
|
const f = fieldsOf(value);
|
|
345
389
|
const at = spanOf(f?.at);
|
|
346
390
|
const awaits = each(f?.awaits, spanOf);
|
|
391
|
+
const defines = each(f?.defines, spanOf);
|
|
347
392
|
const declares = readStrings(f?.declares);
|
|
348
393
|
const uses = readStrings(f?.uses);
|
|
349
|
-
if (f === undefined || at === undefined || awaits === undefined)
|
|
394
|
+
if (f === undefined || at === undefined || awaits === undefined || defines === undefined) {
|
|
350
395
|
return undefined;
|
|
396
|
+
}
|
|
351
397
|
const data = readStrings(f.data);
|
|
352
398
|
if (declares === undefined || uses === undefined || data === undefined)
|
|
353
399
|
return undefined;
|
|
@@ -357,7 +403,18 @@ function readLine(value) {
|
|
|
357
403
|
isString(f.raw) &&
|
|
358
404
|
awaited !== undefined &&
|
|
359
405
|
typeof canReport === "boolean"
|
|
360
|
-
? {
|
|
406
|
+
? {
|
|
407
|
+
at,
|
|
408
|
+
text: f.text,
|
|
409
|
+
raw: f.raw,
|
|
410
|
+
reportable: canReport,
|
|
411
|
+
data,
|
|
412
|
+
awaits,
|
|
413
|
+
awaited,
|
|
414
|
+
defines,
|
|
415
|
+
declares,
|
|
416
|
+
uses,
|
|
417
|
+
}
|
|
361
418
|
: undefined;
|
|
362
419
|
}
|
|
363
420
|
function readShape(value) {
|
|
@@ -457,7 +514,9 @@ function spotsOf(analysis, file, effects, out) {
|
|
|
457
514
|
const within = (inner, outer) => inner[0] >= outer[0] && inner[1] <= outer[1];
|
|
458
515
|
/** The spots of `line` that are its own: not inside a `try` that is itself inside the line. */
|
|
459
516
|
function spotsIn(line, spots) {
|
|
460
|
-
return spots.filter((spot) => within(spot.at, line.at) &&
|
|
517
|
+
return spots.filter((spot) => within(spot.at, line.at) &&
|
|
518
|
+
(spot.parent === undefined || !within(spot.parent, line.at)) &&
|
|
519
|
+
!line.defines.some((body) => within(spot.at, body)));
|
|
461
520
|
}
|
|
462
521
|
/**
|
|
463
522
|
* What can fail as expected in `line`: a spot with a known error, or one stateward cannot follow
|
|
@@ -507,7 +566,7 @@ function moveFix(shape, kept, moved, file) {
|
|
|
507
566
|
const raw = moved.map((line) => line.raw).join("");
|
|
508
567
|
const dedented = raw.split(`\n${shape.inner}`).join(`\n${shape.indent}`);
|
|
509
568
|
const fix = {
|
|
510
|
-
title: moved.length === 1 ? "Move
|
|
569
|
+
title: moved.length === 1 ? "Move the line after the `try`" : "Move the lines after the `try`",
|
|
511
570
|
edits: [
|
|
512
571
|
{ location: { file, start: lastKept.at[1], end: lastMoved.at[1] }, newText: "" },
|
|
513
572
|
{ location: { file, start: shape.at[1], end: shape.at[1] }, newText: dedented },
|
|
@@ -535,14 +594,14 @@ function nameOf(spot, line) {
|
|
|
535
594
|
return `throw ${open < 0 ? thrown : `${thrown.slice(0, open)}(…)`}`;
|
|
536
595
|
}
|
|
537
596
|
function headline(line, expected) {
|
|
538
|
-
return fitted((cut) =>
|
|
597
|
+
return fitted((cut) => `the \`try\` for \`${cut(expected)}\` also catches errors from \`${asCode(cut(line))}\``);
|
|
539
598
|
}
|
|
540
599
|
function hint(expected, fixable, count) {
|
|
541
|
-
const lines = count === 1 ? "it" : "
|
|
542
|
-
const
|
|
600
|
+
const [lines, them] = count === 1 ? ["the line", "it"] : ["the lines", "them"];
|
|
601
|
+
const why = `so a bug in ${them} is not handled like a failed \`${expected}\``;
|
|
543
602
|
if (fixable)
|
|
544
|
-
return
|
|
545
|
-
return
|
|
603
|
+
return `move ${lines} after the \`try\`, ${why} (quick fix)`;
|
|
604
|
+
return `move ${lines} after the \`try\` and return from the \`catch\` (declare what ${count === 1 ? "it uses" : "they use"} before the \`try\`), ${why}`;
|
|
546
605
|
}
|
|
547
606
|
/** A promise's own `resolve` and `reject` (excalidraw): they never throw. */
|
|
548
607
|
const SETTLES = new Set(["resolve(…)", "reject(…)"]);
|
|
@@ -586,7 +645,8 @@ function checkTry(shape, spots, file, report) {
|
|
|
586
645
|
export const narrowTry = defineRule({
|
|
587
646
|
kind: "project",
|
|
588
647
|
meta: {
|
|
589
|
-
description: "
|
|
648
|
+
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.",
|
|
649
|
+
summary: "calls to your own code inside a `try` after the call it is for, so their bugs look like its failure",
|
|
590
650
|
defaultSeverity: "warning",
|
|
591
651
|
tier: "addon",
|
|
592
652
|
addon: "narrow-try",
|