@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.
@@ -21,15 +21,15 @@ function sourceKey(source) {
21
21
  function sourceText(source) {
22
22
  return `${source.text}: ${source.reason}`;
23
23
  }
24
- /** `others stateward cannot see (from a: why; b: why; and 3 more)`. */
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 stateward cannot see (from ${[...listed, ...tail].join("; ")})`;
29
+ return `others not known (from ${[...listed, ...tail].join("; ")})`;
30
30
  }
31
31
  /**
32
- * `ParseError | ZodError (zod 3.23.8: declared) | others stateward cannot see (from …)`, or "" for an empty set: a class
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 stateward can see` when both are empty.
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 stateward can see"] : lines;
70
+ return lines.length === 0 ? ["throws nothing known"] : lines;
71
71
  }
72
72
  //# sourceMappingURL=format.js.map
@@ -304,7 +304,7 @@ function declared(entry) {
304
304
  addSource(made, {
305
305
  ...locationOf(entry.file, item.at),
306
306
  text: item.name,
307
- reason: "`@throws` names no class stateward can find",
307
+ reason: "`@throws` names a class that is not found",
308
308
  });
309
309
  }
310
310
  }
@@ -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 stateward cannot follow";
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 stateward cannot follow",
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 no class stateward can find",
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 class marked \`@mustHandle\` is named in a \`catch\` or passed on with \`@throws\` by every call that can throw it.
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
- ## Why
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
- For the few errors that callers must act on: a declined payment, an expired session, a conflict the user must resolve. It is a core rule, an error in every preset, and reports nothing until you mark a class: you choose, error by error, what is checked. For new code, returning the failure as a value (a \`Result\` or a union) makes TypeScript itself check it; \`@mustHandle\` is for errors that are already thrown.
33
-
34
- ## What it catches
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
- - **handled on purpose:** a \`catch\` around the call 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 \`e instanceof PaymentDeclinedError\`, and a \`switch (true)\` case count the same;
39
- - **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) …\` (or a marked class it extends) and does not rethrow it there, like \`showPaymentError(e)\`; only that function is read, not the ones it calls;
40
- - **handled where it is called:** a helper declared inside a function and called only inside a handling \`try\` is judged at those calls, not in its own body;
41
- - **passed on on purpose:** the function's JSDoc has \`@throws {PaymentDeclinedError}\` (or a marked class it extends); then its own callers must handle it, and are checked the same way. An \`await\`ed promise counts as a call.
26
+ export function reorder(amount: number): string {
27
+ return pay(amount);
28
+ }
29
+ \`\`\`
42
30
 
43
- Everything else is reported at the call: no \`try\`, a \`catch\` that treats every error alike (no branch, or only \`instanceof Error\`), a \`catch\` that rethrows everything (\`report(e); throw e;\`) in a function without \`@throws\`. The message gives your sentence from \`@mustHandle\` and links to the tag.
31
+ Name the class in a \`catch\` and do what its sentence says:
44
32
 
45
- ### Bad
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
- \`\`\`ts
48
- export async function reorder(order: Order, card: Card): Promise<Receipt> {
49
- return await pay(order, card); // pay can throw PaymentDeclinedError
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
- ### Good
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: await pay(order, card) };
44
+ return { kind: "paid", receipt: pay(amount) };
59
45
  } catch (e) {
60
- if (e instanceof PaymentDeclinedError) return { kind: "declined", reason: e.reason };
46
+ if (e instanceof PaymentDeclinedError) return { kind: "declined" };
61
47
  throw e;
62
48
  }
63
49
  }
64
50
  \`\`\`
65
51
 
66
- or pass it on, so the caller of \`reorder\` decides:
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
- \`\`\`ts
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
- ### Fix
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
- Name the class in the \`catch\` and do what the sentence says, pass it on with \`@throws\`, or change the throwing function to return the failure as a value. The quick fix "Pass it on" adds \`@throws\` to the function's JSDoc (and creates the JSDoc when there is none); what to do in a \`catch\` branch is your call, so there is no fix that writes one.
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
- - Where stateward cannot follow the error, it says so, and these places are listed by \`stateward check\` (not only counted), 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.
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
- ## Turn it on or off
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
- In \`stateward.config.ts\`:
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 this \`catch\`, which treats every error alike:`
534
- : `name it in this \`catch\`, which treats every error alike, or rethrow it and add ${passOn} to \`${cut(fn)}\`:`;
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
- ? "this `catch` passes it on and nothing above it handles it: handle it in the `catch`:"
539
- : `this \`catch\` passes it on: add ${passOn} to \`${cut(fn)}\`, or handle it in the \`catch\`:`;
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
- ? `this \`catch\` does not name \`${name}\`: it handles every error alike`
648
- : `this \`catch\` rethrows \`${name}\``,
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) => `\`${cut(call)}\` can throw \`${cut(name)}\` out of a callback stateward cannot follow`),
665
- hint: fitted((cut) => `name \`${cut(tag)}\` in a \`catch\` here, or make sure the code that calls this callback does: it must be handled`),
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) => `stateward cannot tell whether the \`catch\` around \`${cut(call)}\` handles \`${cut(name)}\``),
672
- hint: fitted((cut) => `name it with \`instanceof ${cut(tag)}\`: stateward cannot follow what a class this \`catch\` tests extends`),
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) => `stateward cannot tell whether \`${cut(call)}\` can still throw \`${cut(name)}\`, which must be handled`),
679
- hint: fitted((cut) => `name it in a \`catch\`, or pass it on with \`@throws {${cut(tag)}}\`: a test inside \`${cut(met.leaf.text)}\` may handle it`),
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 class marked @mustHandle is named in a catch or passed on with @throws by every call that can throw it.",
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: "stateward does not check `@mustHandle` on a type yet, only on an error class",
734
- hint: "to have every call checked, throw an error class marked `@mustHandle`; plain-object errors come later",
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
  }
@@ -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. */
@@ -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
- Lines after the call a \`try\` is for, which call your own code, belong after the \`try\`.
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
- ## Why
16
+ \`\`\`ts example-bad
17
+ class RequestError extends Error {}
18
+ declare function showError(error: unknown): void;
17
19
 
18
- \`\`\`ts
19
- try {
20
- const plan = await getPlan(id);
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
- ## What it catches
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
- A \`try\` whose last statements come after the last call that can fail as expected, and can themselves fail only as bugs. A call can fail as expected when stateward knows an error class it throws (\`throw new RequestError(…)\` in your code, \`@throws\`, a built-in like \`JSON.parse\` or \`new URL\`), when it is awaited and calls a package whose errors are not all known, or when it is awaited and its rejection is not known to be nothing. The message names that call, the request the \`try\` is for. A call of a package that is not awaited (\`toast.success(…)\`) is not one, and with no such call the rule reports nothing.
36
+ Keep only the call the \`try\` is for inside it, and move the rest after it:
36
37
 
37
- Of the lines after it, the first that calls your own code is reported: a function of the project given data the \`try\` got (\`openEditor(plan)\`), or a callback the function was given (\`onClose()\`). Data is what an \`await\` gives, and what is read from it (unless declared \`unknown\`, which TypeScript makes you test first); what \`JSON.parse\` or \`await response.json()\` gives is not, since 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).
38
+ \`\`\`ts example-good
39
+ class RequestError extends Error {}
40
+ declare function showError(error: unknown): void;
38
41
 
39
- ### Bad
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
- \`\`\`ts
42
- try {
43
- await savePlan(plan);
44
- closeEditor();
45
- onSaved();
46
- } catch (e) {
47
- showError(e);
48
- return;
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
- ### Good
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
- \`\`\`ts
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
- ### Fix
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
- 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 the 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 (\`plan\` above), declare it before the \`try\`, assign it inside, and return from the \`catch\`:
64
+ ## What it does not catch
68
65
 
69
- \`\`\`ts
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
- ## What it does not catch
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
- - 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(…)\`: most of those are written inside the \`try\` on purpose, and reporting them was noise.
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
- A limit: an awaited result of your own function (\`result.success\` after \`await convert(…)\`) counts as data too; the rule cannot tell a typed result from a response.
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
- ESLint, typescript-eslint, eslint-plugin-unicorn and Biome have no rule about the width of a \`try\`: their catch rules are about empty catches (\`no-empty\`, \`noEmptyBlockStatements\`), catches that only rethrow (\`no-useless-catch\`, \`noUselessCatch\`) and the catch variable (\`prefer-optional-catch-binding\`, \`catch-error-name\`, \`use-unknown-in-catch-callback-variable\`).
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
- Turn it on in \`stateward.config.ts\`:
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
- awaits.push([inner.expression.getStart(sourceFile), inner.expression.getEnd()]);
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
- ? { at, text: f.text, raw: f.raw, reportable: canReport, data, awaits, awaited, declares, uses }
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) && (spot.parent === undefined || !within(spot.parent, 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 this line after the `try`" : "Move these lines after the `try`",
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) => `\`${asCode(cut(line))}\` is inside the \`try\` meant for \`${cut(expected)}\``);
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" : "these lines";
542
- const start = `a bug here would be handled like a failed \`${expected}\`; move ${lines} after the \`try\``;
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 `${start} (quick fix)`;
545
- return `${start} and return from the \`catch\` (declare what ${count === 1 ? "it uses" : "they use"} before the \`try\`)`;
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: "Calls to your own code placed in a `try` after the call it is for: move them after the `try`.",
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",