@stateward/plugin-errors 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +20 -2
  3. package/dist/async-handler-unhandled.d.ts +7 -0
  4. package/dist/async-handler-unhandled.js +342 -0
  5. package/dist/catch-hides-unexpected.d.ts +6 -0
  6. package/dist/catch-hides-unexpected.js +260 -0
  7. package/dist/catch-misses-error.d.ts +6 -0
  8. package/dist/catch-misses-error.js +208 -0
  9. package/dist/catch-without-intent.d.ts +10 -0
  10. package/dist/catch-without-intent.js +590 -0
  11. package/dist/exhaustive-catch.d.ts +6 -0
  12. package/dist/exhaustive-catch.js +190 -0
  13. package/dist/exhaustive-fallback.d.ts +6 -0
  14. package/dist/exhaustive-fallback.js +157 -0
  15. package/dist/finally-can-throw.d.ts +6 -0
  16. package/dist/finally-can-throw.js +184 -0
  17. package/dist/finally-may-throw.d.ts +6 -0
  18. package/dist/finally-may-throw.js +184 -0
  19. package/dist/hover.d.ts +11 -0
  20. package/dist/hover.js +62 -0
  21. package/dist/index.d.ts +7 -0
  22. package/dist/index.js +2 -0
  23. package/dist/inference/builtins.d.ts +52 -0
  24. package/dist/inference/builtins.js +179 -0
  25. package/dist/inference/catches.d.ts +9 -0
  26. package/dist/inference/catches.js +33 -0
  27. package/dist/inference/deps.d.ts +12 -0
  28. package/dist/inference/deps.js +79 -0
  29. package/dist/inference/format.d.ts +25 -0
  30. package/dist/inference/format.js +72 -0
  31. package/dist/inference/packages.d.ts +40 -0
  32. package/dist/inference/packages.js +723 -0
  33. package/dist/inference/project.d.ts +8 -0
  34. package/dist/inference/project.js +22 -0
  35. package/dist/inference/solve.d.ts +128 -0
  36. package/dist/inference/solve.js +575 -0
  37. package/dist/inference/summarize.d.ts +50 -0
  38. package/dist/inference/summarize.js +1324 -0
  39. package/dist/inference/summary.d.ts +191 -0
  40. package/dist/inference/summary.js +237 -0
  41. package/dist/inlay.d.ts +19 -0
  42. package/dist/inlay.js +168 -0
  43. package/dist/must-handle-error.d.ts +55 -0
  44. package/dist/must-handle-error.js +794 -0
  45. package/dist/narrow-try.d.ts +52 -0
  46. package/dist/narrow-try.js +641 -0
  47. package/dist/no-swallow.d.ts +8 -0
  48. package/dist/no-swallow.js +320 -0
  49. package/dist/plugin.d.ts +116 -0
  50. package/dist/plugin.js +39 -0
  51. package/dist/react/boundaries.d.ts +83 -0
  52. package/dist/react/boundaries.js +371 -0
  53. package/dist/react/fallbacks.d.ts +12 -0
  54. package/dist/react/fallbacks.js +83 -0
  55. package/dist/react/render-guards.d.ts +24 -0
  56. package/dist/react/render-guards.js +271 -0
  57. package/dist/react/tree.d.ts +98 -0
  58. package/dist/react/tree.js +863 -0
  59. package/dist/refactors.d.ts +15 -0
  60. package/dist/refactors.js +69 -0
  61. package/dist/render-throw-without-boundary.d.ts +11 -0
  62. package/dist/render-throw-without-boundary.js +223 -0
  63. package/dist/render-throws-without-boundary.d.ts +6 -0
  64. package/dist/render-throws-without-boundary.js +151 -0
  65. package/dist/result-ignored.d.ts +6 -0
  66. package/dist/result-ignored.js +167 -0
  67. package/dist/result-type.d.ts +18 -0
  68. package/dist/result-type.js +55 -0
  69. package/dist/result-unchecked.d.ts +2 -0
  70. package/dist/result-unchecked.js +746 -0
  71. package/dist/rethrow-unknown.d.ts +6 -0
  72. package/dist/rethrow-unknown.js +260 -0
  73. package/dist/rules.meta.d.ts +214 -0
  74. package/dist/rules.meta.js +212 -0
  75. package/dist/syntax.d.ts +41 -0
  76. package/dist/syntax.js +171 -0
  77. package/dist/text.d.ts +22 -0
  78. package/dist/text.js +43 -0
  79. package/dist/throw-in-hook.d.ts +8 -0
  80. package/dist/throw-in-hook.js +510 -0
  81. package/dist/throws-doc-mismatch.d.ts +7 -0
  82. package/dist/throws-doc-mismatch.js +188 -0
  83. package/dist/throws-matches-doc.d.ts +7 -0
  84. package/dist/throws-matches-doc.js +188 -0
  85. package/package.json +34 -3
@@ -0,0 +1,15 @@
1
+ import type { EditorContributions } from "@stateward/core";
2
+ type CodeActions = NonNullable<EditorContributions["codeActions"]>;
3
+ /** The title of the refactoring that wraps a catch-all for the errors its `try` can throw. */
4
+ export declare const HANDLE_KNOWN_TITLE = "Handle the known errors here, rethrow the rest";
5
+ /**
6
+ * On a `catch (e)` with no `instanceof` branches, whose `try` can throw at least one error class
7
+ * stateward can name and whose body does not already throw: wraps the body as
8
+ * `if (e instanceof A || e instanceof B) { <old body> } else { throw e; }`. Two insertions, so the
9
+ * old body stays as written. It keeps what the catch does for the known errors and stops hiding
10
+ * the others, which is why it is a refactoring and not a "safe" fix. Core offers it only when the
11
+ * result compiles (a class the file does not import gets no action).
12
+ */
13
+ export declare const codeActions: CodeActions;
14
+ export {};
15
+ //# sourceMappingURL=refactors.d.ts.map
@@ -0,0 +1,69 @@
1
+ import ts from "@stateward/core/typescript";
2
+ import { catchesOf } from "./inference/catches.js";
3
+ import { catchName } from "./inference/format.js";
4
+ import { analyse } from "./inference/solve.js";
5
+ import { factSummaries } from "./inlay.js";
6
+ /** The title of the refactoring that wraps a catch-all for the errors its `try` can throw. */
7
+ export const HANDLE_KNOWN_TITLE = "Handle the known errors here, rethrow the rest";
8
+ /**
9
+ * On a `catch (e)` with no `instanceof` branches, whose `try` can throw at least one error class
10
+ * stateward can name and whose body does not already throw: wraps the body as
11
+ * `if (e instanceof A || e instanceof B) { <old body> } else { throw e; }`. Two insertions, so the
12
+ * old body stays as written. It keeps what the catch does for the known errors and stops hiding
13
+ * the others, which is why it is a refactoring and not a "safe" fix. Core offers it only when the
14
+ * result compiles (a class the file does not import gets no action).
15
+ */
16
+ export const codeActions = (input) => {
17
+ const summaries = factSummaries(input);
18
+ if (summaries === undefined)
19
+ return [];
20
+ const { sourceFile } = input;
21
+ const clauses = [];
22
+ const visit = (node) => {
23
+ if (ts.isCatchClause(node)) {
24
+ // The head: `catch (e) {`.
25
+ const head = [node.getStart(sourceFile), node.block.getStart(sourceFile) + 1];
26
+ if (head[0] <= input.end && input.start <= head[1])
27
+ clauses.push(node);
28
+ }
29
+ ts.forEachChild(node, visit);
30
+ };
31
+ visit(sourceFile);
32
+ if (clauses.length === 0)
33
+ return [];
34
+ const analysis = analyse(summaries);
35
+ const catches = new Map(catchesOf(analysis, sourceFile.fileName).map((seen) => [seen.handler.at[0], seen]));
36
+ const actions = [];
37
+ for (const clause of clauses) {
38
+ const seen = catches.get(clause.getStart(sourceFile));
39
+ const variable = seen?.handler.variable;
40
+ if (seen === undefined ||
41
+ variable === null ||
42
+ variable === undefined ||
43
+ seen.handler.branches.length > 0 ||
44
+ seen.handler.restThrows ||
45
+ seen.handler.block[0] !== clause.block.getStart(sourceFile)) {
46
+ continue;
47
+ }
48
+ const names = [
49
+ ...new Set([...seen.thrown.classes.keys()].map((id) => catchName(id, analysis.classes))),
50
+ ].sort();
51
+ if (names.length === 0)
52
+ continue;
53
+ const tests = names.map((name) => `${variable} instanceof ${name}`).join(" || ");
54
+ const [open, close] = seen.handler.block;
55
+ const at = (offset, newText) => ({
56
+ location: { file: input.file, start: offset, end: offset },
57
+ newText,
58
+ });
59
+ actions.push({
60
+ title: HANDLE_KNOWN_TITLE,
61
+ edits: [
62
+ at(open + 1, `\nif (${tests}) {`),
63
+ at(close - 1, `\n} else {\nthrow ${variable};\n}\n`),
64
+ ],
65
+ });
66
+ }
67
+ return actions;
68
+ };
69
+ //# sourceMappingURL=refactors.js.map
@@ -0,0 +1,11 @@
1
+ /** The group the rule belongs to in the docs and in `stateward status`. */
2
+ export declare const GROUP = "render";
3
+ /** The reason `plan` gives when a root boundary makes the rule silent. */
4
+ export declare const ROOT_BOUNDARY_SILENT = "your app's root has an error boundary";
5
+ export declare const renderThrowWithoutBoundary: import("@stateward/core").ProjectRule<import("./react/boundaries.js").ReactSummary, {
6
+ skipContextGuards: import("@stateward/core").BooleanOption;
7
+ skipInputGuards: import("@stateward/core").BooleanOption;
8
+ rootBoundaryCounts: import("@stateward/core").BooleanOption;
9
+ boundaries: import("@stateward/core").RecordOption;
10
+ }, "render">;
11
+ //# sourceMappingURL=render-throw-without-boundary.d.ts.map
@@ -0,0 +1,223 @@
1
+ import { defineRule, highestUncovered, option, shortestPath } from "@stateward/core";
2
+ import { className } from "./inference/format.js";
3
+ import { modelOf, summarizeReact } from "./react/boundaries.js";
4
+ import { LIBRARY_BOUNDARIES } from "./react/tree.js";
5
+ import { codeList, fitted, withExample } from "./text.js";
6
+ /** The group the rule belongs to in the docs and in `stateward status`. */
7
+ export const GROUP = "render";
8
+ const EXPLAIN = `# errors/render-throw-without-boundary
9
+
10
+ A component that can throw a known error while rendering has an error boundary above it.
11
+
12
+ ## Why
13
+
14
+ When a component throws while React renders it, or in one of its effects, React unmounts everything up to the nearest error boundary. With no boundary above, the whole app goes blank. A \`<UserPage>\` whose \`useUser\` throws \`NotFoundError\` for a wrong id takes the menu, the header and every other route down with it, where a boundary could have shown "this user does not exist".
15
+
16
+ ## When to use it
17
+
18
+ Use it in React apps whose code throws its own error classes. It is a core rule, a warning in every preset: whether a page should handle an error itself or let a boundary do it is your call. If every throw in render is a bug you want to see crash in development, turn it off.
19
+
20
+ ## What it catches
21
+
22
+ A place that renders a component which can throw a known error while rendering, with no error boundary above it on the way from your entry point (\`createRoot(…).render(…)\` or \`hydrateRoot\`). The component counts what its body and its hooks throw, what its \`useEffect\` and \`useLayoutEffect\` callbacks throw, and what the components it renders outside a boundary throw. A boundary is react-error-boundary's \`<ErrorBoundary>\` (the \`boundaries\` option adds other packages' boundaries), or a class of yours with \`getDerivedStateFromError\` or \`componentDidCatch\`.
23
+
24
+ It is reported once, at the highest place where the boundary is missing, with the path down to the call that throws.
25
+
26
+ A boundary around the whole app (the one that wraps the tree at \`createRoot(…).render(…)\`) does not count: it catches the error, but the whole app is replaced by its fallback, which is rarely what you want for one page. The finding is then at the highest place below it that renders the component that throws. If an app-wide fallback is what you want, set \`rootBoundaryCounts\` to \`true\`: the rule then says nothing for an app with a boundary at its root, and \`stateward plan\` says so (\`silent: your app's root has an error boundary\`).
27
+
28
+ Two kinds of throw are left out, since they name a programming mistake of the caller rather than a failure to show: the usual check in a context hook (\`if (!ctx) throw new Error("useAuth must be used within AuthProvider")\` on a value from \`useContext\`, or a comparison with a "missing" constant declared at the top of the file), and a check on what the caller passed when its type already rules the value out (\`props.title === undefined\` with \`title: string\`, a missing value from a context hook). A check on an optional prop or a parameter that can be \`undefined\` (\`title?: string\`, \`useLabel(label: string | undefined)\`) counts: any caller can leave it out, so it is a failure to show. A throw on a truthy value (\`if (error) throw error\`) or on a comparison (\`id === ""\`) still counts. Set \`skipContextGuards\` or \`skipInputGuards\` to \`false\` to count those too.
29
+
30
+ ### Bad
31
+
32
+ \`\`\`tsx
33
+ // useUser throws NotFoundError for an id it does not know.
34
+ createRoot(root).render(<App />); // App renders <UserPage /> for /users/:id
35
+ \`\`\`
36
+
37
+ ### Good
38
+
39
+ \`\`\`tsx
40
+ createRoot(root).render(
41
+ <ErrorBoundary FallbackComponent={ErrorPage}>
42
+ <App />
43
+ </ErrorBoundary>,
44
+ );
45
+ \`\`\`
46
+
47
+ ### Fix
48
+
49
+ Put an error boundary above the place the message names, or nearer to the component that throws (around one route). There is no quick fix: where the boundary goes, and what it shows, is your call.
50
+
51
+ ## What it does not catch
52
+
53
+ - Errors in event handlers, timers and async code: a boundary never catches them, so they are not counted. \`async/handler-rejection-lost\` checks async handlers.
54
+ - Errors stateward cannot see (a package it cannot read, a callback, a dynamic call): it is about known error classes, so these never make a finding. The hover on a boundary lists them.
55
+ - An app with no \`createRoot(…).render(…)\` or \`hydrateRoot\` it can find: nothing is checked.
56
+ - Routes in \`createBrowserRouter([...])\` route objects: React Router catches their errors itself (\`errorElement\`). \`memo\`, \`forwardRef\` and \`lazy\` components, and boundaries from other packages you have not listed in \`boundaries\`, are not followed.
57
+ - An element passed to a function or stored in an object or array (\`showToast(<UserPage />)\`): where it renders cannot be followed, so it never makes a finding. An element held in a local \`const\` or \`let\` is checked where the variable is rendered.
58
+ - A class that a \`try\` in the component may catch (with a test other than \`instanceof\`): it is not counted.
59
+ - A throw that is not of a known error class (\`throw "no cart"\`), and a thrown promise (Suspense).
60
+
61
+ ## Turn it on or off
62
+
63
+ In \`stateward.config.ts\`:
64
+
65
+ \`\`\`ts
66
+ rules: { "errors/render-throw-without-boundary": "off" } // or "error"
67
+ groups: { "errors/react": "off" } // the whole group
68
+ rules: {
69
+ "errors/render-throw-without-boundary": [
70
+ "warning",
71
+ { boundaries: { "react-error-boundary": ["ErrorBoundary"], "@sentry/react": ["ErrorBoundary"] } },
72
+ ],
73
+ } // Sentry's boundary counts too
74
+ rules: {
75
+ "errors/render-throw-without-boundary": ["warning", { rootBoundaryCounts: true }],
76
+ } // a boundary around the whole app is enough
77
+ \`\`\`
78
+ `;
79
+ /** At most this many notes; a longer path keeps its first steps and its last. */
80
+ const MAX_NOTES = 5;
81
+ function headline(component, names) {
82
+ return fitted((cut) => `\`<${cut(component)}>\` can throw ${codeList(names, cut)} while rendering, with no error boundary above it`);
83
+ }
84
+ /** A throw that only the boundary around the whole app catches (`rootBoundaryCounts` is off). */
85
+ function rootHeadline(component, names) {
86
+ return fitted((cut) => `\`<${cut(component)}>\` can throw ${codeList(names, cut)} while rendering; only the app-wide boundary catches it`);
87
+ }
88
+ function rootHint(component) {
89
+ return withExample("the whole app is replaced; add a boundary around it, or set `rootBoundaryCounts` if that is what you want", ["<ErrorBoundary FallbackComponent={ErrorPage}>", ` <${component} />`, "</ErrorBoundary>"]);
90
+ }
91
+ /** The reason `plan` gives when a root boundary makes the rule silent. */
92
+ export const ROOT_BOUNDARY_SILENT = "your app's root has an error boundary";
93
+ function hint(component) {
94
+ return withExample("wrap it in an error boundary, here or nearer to what throws; for example with react-error-boundary:", ["<ErrorBoundary FallbackComponent={ErrorPage}>", ` <${component} />`, "</ErrorBoundary>"]);
95
+ }
96
+ /** The renders down from the site to the component that throws `cls`, then the call that throws it. */
97
+ function pathNotes(model, site, cls) {
98
+ const classes = model.analysis.classes;
99
+ const path = shortestPath(site.to, (id) => model.edges(id), (edge) => model.nearest(edge) === undefined && model.needs(edge.to).has(cls), (id) => model.own(id).classes.get(cls)?.certain === true && !model.own(id).guarded.has(cls));
100
+ if (path === undefined)
101
+ return [];
102
+ const notes = path.map((edge) => ({
103
+ location: { file: edge.file, start: edge.render.at[0], end: edge.render.at[1] },
104
+ message: `\`<${edge.render.name}>\` is rendered here`,
105
+ }));
106
+ const thrower = path.at(-1)?.to ?? site.to;
107
+ const brought = model.own(thrower).classes.get(cls);
108
+ if (brought !== undefined) {
109
+ notes.push({
110
+ location: brought.location,
111
+ message: `${brought.what} \`${className(cls, classes)}\``,
112
+ });
113
+ }
114
+ if (notes.length <= MAX_NOTES)
115
+ return notes;
116
+ const skipped = notes.length - (MAX_NOTES - 1);
117
+ const last = notes.at(-1);
118
+ const head = notes.slice(0, MAX_NOTES - 2);
119
+ const first = notes[MAX_NOTES - 2];
120
+ return first === undefined || last === undefined
121
+ ? head
122
+ : [...head, { location: first.location, message: `… ${String(skipped)} more steps …` }, last];
123
+ }
124
+ /** Whether an error boundary of the project wraps the entry render: the app-wide boundary. */
125
+ function rootBoundaryOf(model, entry) {
126
+ const key = model.nearest(entry);
127
+ return key !== undefined && model.boundaries.has(key);
128
+ }
129
+ /**
130
+ * Where the walk starts below an entry with a root boundary: the renders in its component, or the
131
+ * entry itself when the component throws on its own (nothing nearer can be named then).
132
+ */
133
+ function belowRoot(model, entry) {
134
+ const own = model.own(entry.to);
135
+ const throwsItself = [...own.classes].some(([cls, brought]) => brought.certain && !own.guarded.has(cls));
136
+ return throwsItself ? [entry] : model.edges(entry.to);
137
+ }
138
+ export const renderThrowWithoutBoundary = defineRule({
139
+ kind: "project",
140
+ meta: {
141
+ description: "A component that can throw a known error while rendering has an error boundary above it.",
142
+ defaultSeverity: "warning",
143
+ tier: "core",
144
+ group: GROUP,
145
+ needsJsDoc: true,
146
+ options: {
147
+ skipContextGuards: option.boolean({
148
+ default: true,
149
+ description: 'Leave out the usual check in a context hook: `if (!ctx) throw new Error("useAuth must be used within AuthProvider")`.',
150
+ }),
151
+ skipInputGuards: option.boolean({
152
+ default: true,
153
+ description: "Leave out a throw when a prop or a parameter whose type rules it out, or a context hook's value, is missing (a mistake of the caller).",
154
+ }),
155
+ rootBoundaryCounts: option.boolean({
156
+ default: false,
157
+ description: "Count the error boundary around the whole app as handling the error. Off by default: the whole app is then replaced by its fallback, so the rule asks for a boundary nearer the page.",
158
+ }),
159
+ boundaries: option.record({
160
+ default: LIBRARY_BOUNDARIES,
161
+ description: 'Error boundary components from packages, by package: add Sentry\'s with `{ "@sentry/react": ["ErrorBoundary"] }` (keep react-error-boundary\'s to keep it).',
162
+ }),
163
+ },
164
+ explain: EXPLAIN,
165
+ },
166
+ summarize: (ctx) => summarizeReact(ctx, ctx.options.boundaries, {
167
+ contextGuards: ctx.options.skipContextGuards,
168
+ inputGuards: ctx.options.skipInputGuards,
169
+ }),
170
+ silentReason(summaries, ctx) {
171
+ if (!ctx.options.rootBoundaryCounts)
172
+ return undefined;
173
+ const model = modelOf(summaries);
174
+ return model.entries.length > 0 && model.entries.every((entry) => rootBoundaryOf(model, entry))
175
+ ? ROOT_BOUNDARY_SILENT
176
+ : undefined;
177
+ },
178
+ check(summaries, ctx) {
179
+ const model = modelOf(summaries);
180
+ const classes = model.analysis.classes;
181
+ const walk = (roots, open = []) => highestUncovered({
182
+ roots,
183
+ edges: (id) => model.edges(id),
184
+ covered: (edge) => !open.includes(edge) && model.nearest(edge) !== undefined,
185
+ needs: (id) => model.needs(id),
186
+ key: (edge) => `${edge.file}:${String(edge.render.at[0])}`,
187
+ });
188
+ // An entry with a boundary around it yields nothing in the plain walk. Unless the root
189
+ // boundary counts, the walk starts again below it, and what it finds is for the root message.
190
+ const rooted = ctx.options.rootBoundaryCounts
191
+ ? []
192
+ : model.entries.filter((entry) => rootBoundaryOf(model, entry));
193
+ const reported = new Set();
194
+ const places = [
195
+ ...walk(model.entries).map((found) => ({ ...found, root: false })),
196
+ ...walk(rooted.flatMap((entry) => belowRoot(model, entry)), rooted).map((found) => ({
197
+ ...found,
198
+ root: true,
199
+ })),
200
+ ];
201
+ for (const { edge, needs, root } of places) {
202
+ const key = `${edge.file}:${String(edge.render.at[0])}`;
203
+ if (reported.has(key))
204
+ continue;
205
+ reported.add(key);
206
+ const named = needs
207
+ .map((id) => ({ id, name: className(id, classes) }))
208
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
209
+ const [first] = named;
210
+ if (first === undefined)
211
+ continue;
212
+ const component = model.functions.get(edge.to)?.fn.name ?? edge.render.name;
213
+ const names = named.map((one) => one.name);
214
+ ctx.report({
215
+ message: root ? rootHeadline(component, names) : headline(component, names),
216
+ hint: root ? rootHint(edge.render.name) : hint(edge.render.name),
217
+ location: { file: edge.file, start: edge.render.at[0], end: edge.render.at[1] },
218
+ related: pathNotes(model, edge, first.id),
219
+ });
220
+ }
221
+ },
222
+ });
223
+ //# sourceMappingURL=render-throw-without-boundary.js.map
@@ -0,0 +1,6 @@
1
+ /** The group the rule belongs to in the docs and in `stateward status`. */
2
+ export declare const GROUP = "react";
3
+ export declare const renderThrowsWithoutBoundary: import("@stateward/core").ProjectRule<import("./react/boundaries.js").ReactSummary, {
4
+ boundaries: import("@stateward/core").RecordOption;
5
+ }, "react">;
6
+ //# sourceMappingURL=render-throws-without-boundary.d.ts.map
@@ -0,0 +1,151 @@
1
+ import { defineRule, highestUncovered, option, shortestPath } from "@stateward/core";
2
+ import { className } from "./inference/format.js";
3
+ import { modelOf, summarizeReact } from "./react/boundaries.js";
4
+ import { LIBRARY_BOUNDARIES } from "./react/tree.js";
5
+ import { HEADLINE_MAX, codeList, fitted, withExample } from "./text.js";
6
+ /** The group the rule belongs to in the docs and in `stateward status`. */
7
+ export const GROUP = "react";
8
+ const EXPLAIN = `# errors/render-throws-without-boundary
9
+
10
+ A component that can throw a known error while rendering has an error boundary above it.
11
+
12
+ ## Why
13
+
14
+ When a component throws while React renders it, or in one of its effects, React unmounts everything up to the nearest error boundary. With no boundary above, the whole app goes blank. A \`<UserPage>\` whose \`useUser\` throws \`NotFoundError\` for a wrong id takes the menu, the header and every other route down with it, where a boundary could have shown "this user does not exist".
15
+
16
+ ## When to use it
17
+
18
+ Use it in React apps whose code throws its own error classes. It is a core rule, a warning in every preset: whether a page should handle an error itself or let a boundary do it is your call. If every throw in render is a bug you want to see crash in development, turn it off.
19
+
20
+ ## What it catches
21
+
22
+ A place that renders a component which can throw a known error while rendering, with no error boundary above it on the way from your entry point (\`createRoot(…).render(…)\` or \`hydrateRoot\`). The component counts what its body and its hooks throw, what its \`useEffect\` and \`useLayoutEffect\` callbacks throw, and what the components it renders outside a boundary throw. A boundary is react-error-boundary's \`<ErrorBoundary>\` (the \`boundaries\` option adds other packages' boundaries), or a class of yours with \`getDerivedStateFromError\` or \`componentDidCatch\`.
23
+
24
+ It is reported once, at the highest place where the boundary is missing, with the path down to the call that throws. With a boundary around the whole app, it says nothing.
25
+
26
+ ### Bad
27
+
28
+ \`\`\`tsx
29
+ // useUser throws NotFoundError for an id it does not know.
30
+ createRoot(root).render(<App />); // App renders <UserPage /> for /users/:id
31
+ \`\`\`
32
+
33
+ ### Good
34
+
35
+ \`\`\`tsx
36
+ createRoot(root).render(
37
+ <ErrorBoundary FallbackComponent={ErrorPage}>
38
+ <App />
39
+ </ErrorBoundary>,
40
+ );
41
+ \`\`\`
42
+
43
+ ### Fix
44
+
45
+ Put an error boundary above the place the message names, or nearer to the component that throws (around one route). There is no quick fix: where the boundary goes, and what it shows, is your call.
46
+
47
+ ## What it does not catch
48
+
49
+ - Errors in event handlers, timers and async code: a boundary never catches them, so they are not counted. \`errors/async-handler-unhandled\` checks async handlers.
50
+ - Errors stateward cannot see (a package it cannot read, a callback, a dynamic call): it is about known error classes, so these never make a finding. The hover on a boundary lists them.
51
+ - An app with no \`createRoot(…).render(…)\` or \`hydrateRoot\` it can find: nothing is checked.
52
+ - Routes in \`createBrowserRouter([...])\` route objects: React Router catches their errors itself (\`errorElement\`). \`memo\`, \`forwardRef\` and \`lazy\` components, and boundaries from other packages you have not listed in \`boundaries\`, are not followed.
53
+ - An element passed to a function or stored in an object or array (\`showToast(<UserPage />)\`): where it renders cannot be followed, so it never makes a finding. An element held in a local \`const\` or \`let\` is checked where the variable is rendered.
54
+ - A class that a \`try\` in the component may catch (with a test other than \`instanceof\`): it is not counted.
55
+
56
+ ## Turn it on or off
57
+
58
+ In \`stateward.config.ts\`:
59
+
60
+ \`\`\`ts
61
+ rules: { "errors/render-throws-without-boundary": "off" } // or "error"
62
+ groups: { "errors/react": "off" } // the whole group
63
+ rules: {
64
+ "errors/render-throws-without-boundary": [
65
+ "warning",
66
+ { boundaries: { "react-error-boundary": ["ErrorBoundary"], "@sentry/react": ["ErrorBoundary"] } },
67
+ ],
68
+ } // Sentry's boundary counts too
69
+ \`\`\`
70
+ `;
71
+ /** At most this many notes; a longer path keeps its first steps and its last. */
72
+ const MAX_NOTES = 5;
73
+ function headline(component, names) {
74
+ return fitted(HEADLINE_MAX, (cut) => `\`<${cut(component)}>\` can throw ${codeList(names, cut)} while rendering, with no error boundary above it`);
75
+ }
76
+ function hint(component) {
77
+ return withExample("wrap it in an error boundary, here or nearer to what throws; for example with react-error-boundary:", ["<ErrorBoundary FallbackComponent={ErrorPage}>", ` <${component} />`, "</ErrorBoundary>"]);
78
+ }
79
+ /** The renders down from the site to the component that throws `cls`, then the call that throws it. */
80
+ function pathNotes(model, site, cls) {
81
+ const classes = model.analysis.classes;
82
+ const path = shortestPath(site.to, (id) => model.edges(id), (edge) => model.nearest(edge) === undefined && model.needs(edge.to).has(cls), (id) => model.own(id).classes.get(cls)?.certain === true);
83
+ if (path === undefined)
84
+ return [];
85
+ const notes = path.map((edge) => ({
86
+ location: { file: edge.file, start: edge.render.at[0], end: edge.render.at[1] },
87
+ message: `\`<${edge.render.name}>\` is rendered here`,
88
+ }));
89
+ const thrower = path.at(-1)?.to ?? site.to;
90
+ const brought = model.own(thrower).classes.get(cls);
91
+ if (brought !== undefined) {
92
+ notes.push({
93
+ location: brought.location,
94
+ message: `${brought.what} \`${className(cls, classes)}\``,
95
+ });
96
+ }
97
+ if (notes.length <= MAX_NOTES)
98
+ return notes;
99
+ const skipped = notes.length - (MAX_NOTES - 1);
100
+ const last = notes.at(-1);
101
+ const head = notes.slice(0, MAX_NOTES - 2);
102
+ const first = notes[MAX_NOTES - 2];
103
+ return first === undefined || last === undefined
104
+ ? head
105
+ : [...head, { location: first.location, message: `… ${String(skipped)} more steps …` }, last];
106
+ }
107
+ export const renderThrowsWithoutBoundary = defineRule({
108
+ kind: "project",
109
+ meta: {
110
+ description: "A component that can throw a known error while rendering has an error boundary above it.",
111
+ defaultSeverity: "warning",
112
+ tier: "core",
113
+ group: GROUP,
114
+ needsJsDoc: true,
115
+ options: {
116
+ boundaries: option.record({
117
+ default: LIBRARY_BOUNDARIES,
118
+ description: 'Error boundary components from packages, by package: add Sentry\'s with `{ "@sentry/react": ["ErrorBoundary"] }` (keep react-error-boundary\'s to keep it).',
119
+ }),
120
+ },
121
+ explain: EXPLAIN,
122
+ },
123
+ summarize: (ctx) => summarizeReact(ctx, ctx.options.boundaries),
124
+ check(summaries, ctx) {
125
+ const model = modelOf(summaries);
126
+ const classes = model.analysis.classes;
127
+ const found = highestUncovered({
128
+ roots: model.entries,
129
+ edges: (id) => model.edges(id),
130
+ covered: (edge) => model.nearest(edge) !== undefined,
131
+ needs: (id) => model.needs(id),
132
+ key: (edge) => `${edge.file}:${String(edge.render.at[0])}`,
133
+ });
134
+ for (const { edge, needs } of found) {
135
+ const named = needs
136
+ .map((id) => ({ id, name: className(id, classes) }))
137
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
138
+ const [first] = named;
139
+ if (first === undefined)
140
+ continue;
141
+ const component = model.functions.get(edge.to)?.fn.name ?? edge.render.name;
142
+ ctx.report({
143
+ message: headline(component, named.map((one) => one.name)),
144
+ hint: hint(edge.render.name),
145
+ location: { file: edge.file, start: edge.render.at[0], end: edge.render.at[1] },
146
+ related: pathNotes(model, edge, first.id),
147
+ });
148
+ }
149
+ },
150
+ });
151
+ //# sourceMappingURL=render-throws-without-boundary.js.map
@@ -0,0 +1,6 @@
1
+ export declare const resultIgnored: import("@stateward/core").FileRule<{
2
+ allowVoid: import("@stateward/core").BooleanOption;
3
+ resultTypes: import("@stateward/core").StringsOption;
4
+ ignore: import("@stateward/core").StringsOption;
5
+ }, "callers">;
6
+ //# sourceMappingURL=result-ignored.d.ts.map
@@ -0,0 +1,167 @@
1
+ import ts from "@stateward/core/typescript";
2
+ import { defineRule, matchesName, option } from "@stateward/core";
3
+ import { example, resultShapeOf, shorten } from "./result-type.js";
4
+ const EXPLAIN = `# errors/result-ignored
5
+
6
+ The Result of a call is checked or passed on, not dropped as a statement, after void or after await.
7
+
8
+ ## Why
9
+
10
+ A \`Result\` is how a function says "this can fail". Call \`saveUser(user)\` as a statement and the answer is thrown away: nobody sees the error, and the code goes on as if the user was saved. The failure shows up later, as missing data.
11
+
12
+ ## When to use it
13
+
14
+ Use it if your functions return a \`Result\` instead of throwing: your own, neverthrow's, or a \`safeParse\` result. It is on as an error in every preset. To drop a result on purpose, list the function in \`ignore\`, or set \`allowVoid\` and write \`void save()\`.
15
+
16
+ ## What it catches
17
+
18
+ A call whose value is a Result (or a \`Promise\` of one) that is dropped: written as a statement, after \`void\`, or after \`await\` on its own. A Result is recognised by its shape, so no library is needed:
19
+
20
+ - a union told apart by \`ok: true\` and \`ok: false\` (\`{ ok: true; value: T } | { ok: false; error: E }\`);
21
+ - a union told apart by \`success: true\` and \`success: false\` (zod's \`safeParse\`);
22
+ - a type with \`isOk()\` and \`isErr()\` methods (neverthrow);
23
+ - a type the \`resultTypes\` option names (\`["Result", "Either"]\`), whatever its shape.
24
+
25
+ ### Bad
26
+
27
+ \`\`\`ts
28
+ type Result<T> = { ok: true; value: T } | { ok: false; error: Error };
29
+ declare function saveUser(user: User): Promise<Result<void>>;
30
+
31
+ await saveUser(user);
32
+ \`\`\`
33
+
34
+ ### Good
35
+
36
+ \`\`\`ts
37
+ const saved = await saveUser(user);
38
+ if (!saved.ok) return saved;
39
+ \`\`\`
40
+
41
+ ### Fix
42
+
43
+ Check the result, or return it to your caller; the message shows how with your names and your Result's shape. There is no quick fix.
44
+
45
+ ## What it does not catch
46
+
47
+ - A result that is assigned, returned, passed on, used in a condition or spread.
48
+ - A Result of another shape (an \`Either\` with \`_tag\`, a tuple \`[error, value]\`), unless \`resultTypes\` names it.
49
+ - A result dropped in a callback that returns nothing (\`items.forEach((item) => save(item))\`), or after \`&&\` or \`?.\`.
50
+ - A result stored and never looked at (\`const saved = saveUser(user);\` with \`saved\` unused).
51
+ - Reading \`.value\` without checking \`.ok\`.
52
+
53
+ ## Turn it on or off
54
+
55
+ In \`stateward.config.ts\`:
56
+
57
+ \`\`\`ts
58
+ rules: { "errors/result-ignored": "off" } // or "warning"
59
+ groups: { "values/results": "off" } // the whole group
60
+ rules: { "errors/result-ignored": ["error", { allowVoid: true }] } // \`void save()\` drops it on purpose
61
+ rules: { "errors/result-ignored": ["error", { resultTypes: ["Either"] }] } // your own name for a Result
62
+ \`\`\`
63
+ `;
64
+ /** The longest callee a headline shows before it is cut, and the longest call an example line shows. */
65
+ const CALLEE_MAX = 30;
66
+ const CALL_MAX = 60;
67
+ const OPTIONS = {
68
+ allowVoid: option.boolean({
69
+ default: false,
70
+ description: "Take `void save()` as dropping the Result on purpose, and stay quiet.",
71
+ }),
72
+ resultTypes: option.strings({
73
+ default: [],
74
+ description: "Your names for a Result type whose shape stateward does not recognise (`Either`, `Outcome*`; globs allowed).",
75
+ }),
76
+ ignore: option.strings({
77
+ default: [],
78
+ description: "Functions whose Result may be dropped, by the name they are called with (`logEvent`, `*.track`; globs allowed).",
79
+ }),
80
+ };
81
+ function unwrap(expression) {
82
+ if (ts.isParenthesizedExpression(expression) ||
83
+ ts.isVoidExpression(expression) ||
84
+ ts.isAwaitExpression(expression)) {
85
+ return unwrap(expression.expression);
86
+ }
87
+ return expression;
88
+ }
89
+ /** The text of the callee, keeping its end when it is long: `…users.save`. */
90
+ function calleeText(call, ctx) {
91
+ const text = call.expression.getText(ctx.sourceFile).replace(/\s+/g, "");
92
+ return text.length > CALLEE_MAX ? `…${text.slice(-(CALLEE_MAX - 1))}` : text;
93
+ }
94
+ /** `void x`, also inside parentheses or after `await`. */
95
+ function isVoided(expression) {
96
+ if (ts.isVoidExpression(expression))
97
+ return true;
98
+ return ((ts.isParenthesizedExpression(expression) || ts.isAwaitExpression(expression)) &&
99
+ isVoided(expression.expression));
100
+ }
101
+ /** The line of the example that checks the result, in the Result's own shape. */
102
+ function checkLine(shape) {
103
+ switch (shape) {
104
+ case "ok":
105
+ return "if (!saved.ok) return saved;";
106
+ case "success":
107
+ return "if (!saved.success) return saved;";
108
+ case "isErr":
109
+ return "if (saved.isErr()) return saved;";
110
+ case "named":
111
+ return "// handle its failure here, or return it";
112
+ default: {
113
+ const unreachable = shape;
114
+ return unreachable;
115
+ }
116
+ }
117
+ }
118
+ function checkStatement(statement, ctx) {
119
+ if (ctx.options.allowVoid && isVoided(statement.expression))
120
+ return;
121
+ const call = unwrap(statement.expression);
122
+ if (!ts.isCallExpression(call))
123
+ return;
124
+ const callee = call.expression.getText(ctx.sourceFile).replace(/\s+/g, "");
125
+ if (matchesName(callee, ctx.options.ignore))
126
+ return;
127
+ const shape = resultShapeOf(call, ctx.types, ctx.options.resultTypes);
128
+ if (shape === undefined)
129
+ return;
130
+ const promised = ctx.types.isPromiseLike(call);
131
+ const whole = shorten(call.getText(ctx.sourceFile), Number.MAX_SAFE_INTEGER);
132
+ // A long call keeps its callee and drops its arguments, so the example stays valid code.
133
+ const callText = whole.length > CALL_MAX
134
+ ? `${shorten(call.expression.getText(ctx.sourceFile), CALL_MAX)}(…)`
135
+ : whole;
136
+ ctx.report({
137
+ node: statement,
138
+ about: statement,
139
+ message: `the result of \`${calleeText(call, ctx)}(…)\` is dropped, so its error is never handled`,
140
+ hint: [
141
+ "check it, or pass it on:",
142
+ example([`const saved = ${promised ? "await " : ""}${callText};`, checkLine(shape)]),
143
+ ].join("\n"),
144
+ });
145
+ }
146
+ export const resultIgnored = defineRule({
147
+ kind: "file",
148
+ meta: {
149
+ description: "The Result of a call is checked or passed on, not dropped as a statement, after void or after await.",
150
+ defaultSeverity: "error",
151
+ tier: "core",
152
+ group: "callers",
153
+ // A trigger dropped where its failure goes unseen is said better there (one cause, one message).
154
+ yieldsTo: ["result-unchecked"],
155
+ options: OPTIONS,
156
+ explain: EXPLAIN,
157
+ },
158
+ check(ctx) {
159
+ const visit = (node) => {
160
+ if (ts.isExpressionStatement(node))
161
+ checkStatement(node, ctx);
162
+ ts.forEachChild(node, visit);
163
+ };
164
+ visit(ctx.sourceFile);
165
+ },
166
+ });
167
+ //# sourceMappingURL=result-ignored.js.map