@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,794 @@
1
+ import ts from "@stateward/core/typescript";
2
+ import { defineRule } from "@stateward/core";
3
+ import { catchName, className } from "./inference/format.js";
4
+ import { summarizeOnce } from "./inference/project.js";
5
+ import { analyse, evaluated, relation } from "./inference/solve.js";
6
+ import { ANONYMOUS, classIdOf } from "./inference/summarize.js";
7
+ import { each, fieldsOf, isString, spanOf } from "./inference/summary.js";
8
+ import { throwsOf } from "./react/boundaries.js";
9
+ import { stripParentheses } from "./syntax.js";
10
+ import { fitted, oneLine, shorten, withExample } from "./text.js";
11
+ /** The group the rule belongs to in the docs and in `stateward status`. */
12
+ export const GROUP = "callers";
13
+ const EXPLAIN = `# errors/must-handle-error
14
+
15
+ An error class marked \`@mustHandle\` is named in a \`catch\` or passed on with \`@throws\` by every call that can throw it.
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
24
+ /** @mustHandle Tell the user the card was declined and let them pick another card. */
25
+ 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
+
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:
37
+
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.
42
+
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.
44
+
45
+ ### Bad
46
+
47
+ \`\`\`ts
48
+ export async function reorder(order: Order, card: Card): Promise<Receipt> {
49
+ return await pay(order, card); // pay can throw PaymentDeclinedError
50
+ }
51
+ \`\`\`
52
+
53
+ ### Good
54
+
55
+ \`\`\`ts
56
+ export async function reorder(order: Order, card: Card): Promise<ReorderResult> {
57
+ try {
58
+ return { kind: "paid", receipt: await pay(order, card) };
59
+ } catch (e) {
60
+ if (e instanceof PaymentDeclinedError) return { kind: "declined", reason: e.reason };
61
+ throw e;
62
+ }
63
+ }
64
+ \`\`\`
65
+
66
+ or pass it on, so the caller of \`reorder\` decides:
67
+
68
+ \`\`\`ts
69
+ /** @throws {PaymentDeclinedError} */
70
+ export async function reorder(order: Order, card: Card): Promise<Receipt> {
71
+ return await pay(order, card);
72
+ }
73
+ \`\`\`
74
+
75
+ ### Fix
76
+
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.
78
+
79
+ ## What it does not catch
80
+
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.
86
+
87
+ ## Turn it on or off
88
+
89
+ In \`stateward.config.ts\`:
90
+
91
+ \`\`\`ts
92
+ rules: { "errors/must-handle-error": "off" } // or "warning"
93
+ groups: { "errors/catch": "off" } // the whole group
94
+ \`\`\`
95
+ `;
96
+ const TAG = "mustHandle";
97
+ function mustHandleTag(node) {
98
+ return ts.getJSDocTags(node).find((tag) => tag.tagName.text === TAG);
99
+ }
100
+ function functionValue(node) {
101
+ if (node === undefined)
102
+ return false;
103
+ const value = stripParentheses(node);
104
+ return ts.isArrowFunction(value) || ts.isFunctionExpression(value);
105
+ }
106
+ /** The statement a function's JSDoc belongs to, and its name; undefined for any other node. */
107
+ function documented(node) {
108
+ if (ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) {
109
+ return node.name === undefined || node.body === undefined
110
+ ? undefined
111
+ : { host: node, name: node.name };
112
+ }
113
+ if (ts.isPropertyDeclaration(node) || ts.isPropertyAssignment(node)) {
114
+ return functionValue(node.initializer) ? { host: node, name: node.name } : undefined;
115
+ }
116
+ if (ts.isVariableDeclaration(node) &&
117
+ ts.isIdentifier(node.name) &&
118
+ functionValue(node.initializer) &&
119
+ ts.isVariableDeclarationList(node.parent) &&
120
+ node.parent.declarations.length === 1 &&
121
+ ts.isVariableStatement(node.parent.parent)) {
122
+ return { host: node.parent.parent, name: node.name };
123
+ }
124
+ return undefined;
125
+ }
126
+ /** The function a declaration names: a function or method, or a `const` holding an arrow or function. */
127
+ function namedFunction(node) {
128
+ if ((ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) && node.name !== undefined) {
129
+ return { name: node.name, fn: node };
130
+ }
131
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
132
+ const value = stripParentheses(node.initializer);
133
+ if (ts.isArrowFunction(value) || ts.isFunctionExpression(value)) {
134
+ return { name: node.name, fn: value };
135
+ }
136
+ }
137
+ return undefined;
138
+ }
139
+ /** The names `test` checks with `instanceof` on `variable`, joined by `||`; undefined for any other test. */
140
+ function instanceofNames(test, variable) {
141
+ const node = stripParentheses(test);
142
+ if (!ts.isBinaryExpression(node))
143
+ return undefined;
144
+ if (node.operatorToken.kind === ts.SyntaxKind.BarBarToken) {
145
+ const left = instanceofNames(node.left, variable);
146
+ const right = instanceofNames(node.right, variable);
147
+ return left === undefined || right === undefined ? undefined : [...left, ...right];
148
+ }
149
+ if (node.operatorToken.kind !== ts.SyntaxKind.InstanceOfKeyword)
150
+ return undefined;
151
+ const tested = stripParentheses(node.left);
152
+ return ts.isIdentifier(tested) && tested.text === variable ? [node.right] : undefined;
153
+ }
154
+ /** Whether `node` throws the value named `variable` (`throw error`), outside nested functions. */
155
+ function throwsVariable(node, variable) {
156
+ if (ts.isFunctionLike(node))
157
+ return false;
158
+ if (ts.isThrowStatement(node)) {
159
+ const thrown = stripParentheses(node.expression);
160
+ if (ts.isIdentifier(thrown) && thrown.text === variable)
161
+ return true;
162
+ }
163
+ return ts.forEachChild(node, (child) => throwsVariable(child, variable) || undefined) === true;
164
+ }
165
+ /**
166
+ * The helper a function is when its first statement is `if (p instanceof X) …` on one of its
167
+ * parameters and that branch does not rethrow `p`: whatever else it does, it handles `X`.
168
+ */
169
+ function errorHelperOf(fn, id, types) {
170
+ if (fn.body === undefined || !ts.isBlock(fn.body))
171
+ return undefined;
172
+ const [first] = fn.body.statements;
173
+ if (first === undefined || !ts.isIfStatement(first))
174
+ return undefined;
175
+ for (const [parameter, declared] of fn.parameters.entries()) {
176
+ if (!ts.isIdentifier(declared.name))
177
+ continue;
178
+ const variable = declared.name.text;
179
+ const named = instanceofNames(first.expression, variable);
180
+ if (named === undefined || throwsVariable(first.thenStatement, variable))
181
+ continue;
182
+ const classes = named.map((one) => classIdOf(types, one));
183
+ if (classes.some((one) => one === undefined))
184
+ return undefined;
185
+ return { id, parameter, classes: classes.filter((one) => one !== undefined) };
186
+ }
187
+ return undefined;
188
+ }
189
+ /** The calls in `clause` (not in nested functions) that pass its variable as an argument. */
190
+ function passesIn(clause, ctx) {
191
+ const declaration = clause.variableDeclaration;
192
+ if (declaration === undefined || !ts.isIdentifier(declaration.name))
193
+ return [];
194
+ const variable = declaration.name.text;
195
+ const at = clause.getStart(ctx.sourceFile);
196
+ const found = [];
197
+ const visit = (node) => {
198
+ if (ts.isFunctionLike(node))
199
+ return;
200
+ if (ts.isCallExpression(node)) {
201
+ const callee = stripParentheses(node.expression);
202
+ const name = ts.isPropertyAccessExpression(callee) ? callee.name : callee;
203
+ const symbol = ctx.types.symbolAt(name);
204
+ for (const [argument, value] of node.arguments.entries()) {
205
+ const passed = stripParentheses(value);
206
+ if (symbol !== undefined && ts.isIdentifier(passed) && passed.text === variable) {
207
+ found.push({ at, callee: symbol.id, argument });
208
+ }
209
+ }
210
+ }
211
+ ts.forEachChild(node, visit);
212
+ };
213
+ visit(clause.block);
214
+ return found;
215
+ }
216
+ function summarizeMustHandle(ctx) {
217
+ const { sourceFile, types } = ctx;
218
+ const text = sourceFile.text;
219
+ const tags = [];
220
+ const untracked = [];
221
+ const anchors = [];
222
+ const helpers = [];
223
+ const passes = [];
224
+ const spanOfNode = (node) => [node.getStart(sourceFile), node.getEnd()];
225
+ const visit = (node) => {
226
+ if (ts.isClassDeclaration(node) ||
227
+ ts.isTypeAliasDeclaration(node) ||
228
+ ts.isInterfaceDeclaration(node) ||
229
+ ts.isVariableStatement(node)) {
230
+ const tag = mustHandleTag(node);
231
+ const symbol = tag !== undefined && ts.isClassDeclaration(node) && node.name !== undefined
232
+ ? types.symbolAt(node.name)
233
+ : undefined;
234
+ if (tag !== undefined && symbol !== undefined) {
235
+ tags.push({
236
+ id: symbol.id,
237
+ name: symbol.name,
238
+ how: oneLine(ts.getTextOfJSDocComment(tag.comment) ?? ""),
239
+ at: spanOfNode(tag),
240
+ });
241
+ }
242
+ else if (tag !== undefined) {
243
+ untracked.push(spanOfNode(tag));
244
+ }
245
+ }
246
+ const named = namedFunction(node);
247
+ const symbol = named === undefined ? undefined : types.symbolAt(named.name);
248
+ const helper = named === undefined || symbol === undefined
249
+ ? undefined
250
+ : errorHelperOf(named.fn, symbol.id, types);
251
+ if (helper !== undefined)
252
+ helpers.push(helper);
253
+ if (ts.isCatchClause(node))
254
+ passes.push(...passesIn(node, ctx));
255
+ const fn = documented(node);
256
+ if (fn !== undefined) {
257
+ const start = fn.host.getStart(sourceFile);
258
+ const lineStart = text.lastIndexOf("\n", start - 1) + 1;
259
+ const before = text.slice(lineStart, start);
260
+ const doc = ts
261
+ .getJSDocCommentsAndTags(ts.isVariableStatement(fn.host) ? fn.name.parent : fn.host)
262
+ .filter((one) => ts.isJSDoc(one))
263
+ .at(-1);
264
+ const docStart = doc === undefined ? -1 : doc.getStart(sourceFile);
265
+ anchors.push({
266
+ name: fn.name.getStart(sourceFile),
267
+ start,
268
+ indent: /^\s*$/.test(before) ? before : "",
269
+ doc: doc === undefined
270
+ ? null
271
+ : { start: docStart, end: doc.getEnd(), text: text.slice(docStart, doc.getEnd()) },
272
+ });
273
+ }
274
+ ts.forEachChild(node, visit);
275
+ };
276
+ ts.forEachChild(sourceFile, visit);
277
+ return { tags, untracked, anchors, helpers, passes };
278
+ }
279
+ function readTag(value) {
280
+ const f = fieldsOf(value);
281
+ const at = spanOf(f?.at);
282
+ if (f === undefined || at === undefined)
283
+ return undefined;
284
+ return isString(f.id) && isString(f.name) && isString(f.how)
285
+ ? { id: f.id, name: f.name, how: f.how, at }
286
+ : undefined;
287
+ }
288
+ function readAnchor(value) {
289
+ const f = fieldsOf(value);
290
+ if (f === undefined || typeof f.name !== "number" || typeof f.start !== "number") {
291
+ return undefined;
292
+ }
293
+ if (!isString(f.indent))
294
+ return undefined;
295
+ if (f.doc === null)
296
+ return { name: f.name, start: f.start, indent: f.indent, doc: null };
297
+ const doc = f.doc === undefined ? undefined : fieldsOf(f.doc);
298
+ if (doc === undefined || typeof doc.start !== "number" || typeof doc.end !== "number") {
299
+ return undefined;
300
+ }
301
+ if (!isString(doc.text))
302
+ return undefined;
303
+ return {
304
+ name: f.name,
305
+ start: f.start,
306
+ indent: f.indent,
307
+ doc: { start: doc.start, end: doc.end, text: doc.text },
308
+ };
309
+ }
310
+ function readHelper(value) {
311
+ const f = fieldsOf(value);
312
+ const classes = each(f?.classes, (one) => (isString(one) ? one : undefined));
313
+ if (f === undefined || classes === undefined || !isString(f.id))
314
+ return undefined;
315
+ return typeof f.parameter === "number"
316
+ ? { id: f.id, parameter: f.parameter, classes }
317
+ : undefined;
318
+ }
319
+ function readPass(value) {
320
+ const f = fieldsOf(value);
321
+ if (f === undefined || typeof f.at !== "number" || typeof f.argument !== "number") {
322
+ return undefined;
323
+ }
324
+ return isString(f.callee) ? { at: f.at, callee: f.callee, argument: f.argument } : undefined;
325
+ }
326
+ /** The `@mustHandle` part of a summary as this rule wrote it; undefined for anything else. */
327
+ function readFacts(value) {
328
+ const f = fieldsOf(fieldsOf(value)?.mustHandle ?? null);
329
+ const tags = each(f?.tags, readTag);
330
+ const untracked = each(f?.untracked, spanOf);
331
+ const anchors = each(f?.anchors, readAnchor);
332
+ const helpers = each(f?.helpers, readHelper);
333
+ const passes = each(f?.passes, readPass);
334
+ return tags === undefined ||
335
+ untracked === undefined ||
336
+ anchors === undefined ||
337
+ helpers === undefined ||
338
+ passes === undefined
339
+ ? undefined
340
+ : { tags, untracked, anchors, helpers, passes };
341
+ }
342
+ /** The tag that makes `cls` must-handle: its own, or the nearest of a class it extends. */
343
+ function tagOf(cls, model) {
344
+ let current = cls;
345
+ const seen = new Set();
346
+ while (current !== null && !seen.has(current)) {
347
+ const tag = model.tags.get(current);
348
+ if (tag !== undefined)
349
+ return tag;
350
+ seen.add(current);
351
+ const fact = model.analysis.classes.get(current);
352
+ if (fact === undefined || fact.open)
353
+ return undefined;
354
+ current = fact.parent;
355
+ }
356
+ return undefined;
357
+ }
358
+ /** Whether naming `named` (in `instanceof` or `@throws`) is a choice about must-handle `cls`. */
359
+ function names(named, cls, model) {
360
+ return (relation(named, cls, model.analysis.classes) === "yes" && tagOf(named, model) !== undefined);
361
+ }
362
+ /** Whether `effects` rethrow the variable of the catch declared at `of`. */
363
+ function rethrows(effects, of) {
364
+ return effects.some((effect) => {
365
+ switch (effect.k) {
366
+ case "rethrow":
367
+ return effect.of === of;
368
+ case "try": {
369
+ const handler = effect.handler;
370
+ return (rethrows(effect.body, of) ||
371
+ rethrows(effect.fin, of) ||
372
+ (handler !== null &&
373
+ (rethrows(handler.pre, of) ||
374
+ handler.branches.some((branch) => rethrows(branch.body, of)) ||
375
+ rethrows(handler.otherwise ?? [], of) ||
376
+ rethrows(handler.after, of))));
377
+ }
378
+ case "class":
379
+ case "unknown":
380
+ case "maybe":
381
+ case "call":
382
+ case "package":
383
+ return false;
384
+ default: {
385
+ const unreachable = effect;
386
+ return unreachable;
387
+ }
388
+ }
389
+ });
390
+ }
391
+ /** Whether the `catch` hands its error to a helper that names `cls` first thing (one level). */
392
+ function passesToHelper(handler, file, cls, model) {
393
+ const passes = model.passes.get(file)?.get(handler.at[0]) ?? [];
394
+ return passes.some((pass) => {
395
+ const helper = model.helpers.get(pass.callee);
396
+ return (helper?.parameter === pass.argument && helper.classes.some((one) => names(one, cls, model)));
397
+ });
398
+ }
399
+ function fateIn(handler, file, cls, model) {
400
+ const { classes } = model.analysis;
401
+ const of = handler.of;
402
+ const passed = (escapes, named) => rethrows(handler.pre, of) || escapes ? "rethrown" : named ? "handled" : "generic";
403
+ for (const branch of handler.branches) {
404
+ const verdicts = branch.classes.map((named) => relation(named, cls, classes));
405
+ if (verdicts.includes("undecided"))
406
+ return "undecided";
407
+ if (!verdicts.includes("yes"))
408
+ continue;
409
+ const named = branch.classes.some((one) => names(one, cls, model));
410
+ // A test that is not only `instanceof` may let a generic branch pass the error on.
411
+ if (!named && !branch.exact)
412
+ continue;
413
+ const escapes = rethrows(branch.body, of) || (!branch.exits && rethrows(handler.after, of));
414
+ return passed(escapes, named);
415
+ }
416
+ const helped = passesToHelper(handler, file, cls, model);
417
+ if (handler.otherwise !== null) {
418
+ const escapes = rethrows(handler.otherwise, of) || (!handler.otherwiseExits && rethrows(handler.after, of));
419
+ return passed(escapes, helped);
420
+ }
421
+ return passed(rethrows(handler.after, of), helped);
422
+ }
423
+ function decide(cls, certain, stack, scope, model, file) {
424
+ if (!certain)
425
+ return { kind: "maybe" };
426
+ let by = null;
427
+ for (const handler of [...stack].reverse()) {
428
+ const fate = fateIn(handler, file, cls, model);
429
+ switch (fate) {
430
+ case "handled":
431
+ return { kind: "handled" };
432
+ case "undecided":
433
+ return { kind: "undecided" };
434
+ case "generic":
435
+ return { kind: "missing", by: { fate, handler } };
436
+ case "rethrown":
437
+ by = { fate, handler };
438
+ break;
439
+ default: {
440
+ const unreachable = fate;
441
+ return unreachable;
442
+ }
443
+ }
444
+ }
445
+ switch (scope.kind) {
446
+ case "callback":
447
+ return { kind: "callback" };
448
+ case "module":
449
+ return { kind: "missing", by };
450
+ case "function": {
451
+ const doc = scope.entry.fact.doc;
452
+ const declared = doc?.entries.some((item) => item.id !== null && names(item.id, cls, model));
453
+ return declared === true ? { kind: "handled" } : { kind: "missing", by };
454
+ }
455
+ default: {
456
+ const unreachable = scope;
457
+ return unreachable;
458
+ }
459
+ }
460
+ }
461
+ function walk(effects, file, stack, scope, model, meet) {
462
+ for (const effect of effects) {
463
+ switch (effect.k) {
464
+ case "call": {
465
+ const result = model.analysis.results.get(effect.id);
466
+ if (result === undefined)
467
+ break;
468
+ const found = new Map();
469
+ const add = (thrown) => {
470
+ for (const [id, certain] of thrown.classes) {
471
+ found.set(id, (found.get(id) ?? false) || certain);
472
+ }
473
+ };
474
+ if (effect.part !== "rejects")
475
+ add(result.throws);
476
+ if (effect.part !== "throws")
477
+ add(result.rejects);
478
+ for (const [cls, certain] of found) {
479
+ if (tagOf(cls, model) === undefined)
480
+ continue;
481
+ const outcome = decide(cls, certain, stack, scope, model, file);
482
+ meet({ file, leaf: effect, cls, scope, outcome });
483
+ }
484
+ break;
485
+ }
486
+ case "maybe":
487
+ // A callback the platform may run during the call, or later: where its errors go is not known.
488
+ walk(effect.body, file, [], { kind: "callback" }, model, meet);
489
+ break;
490
+ case "try": {
491
+ const handler = effect.handler;
492
+ walk(effect.body, file, handler === null ? stack : [...stack, handler], scope, model, meet);
493
+ if (handler !== null) {
494
+ walk(handler.pre, file, stack, scope, model, meet);
495
+ for (const branch of handler.branches)
496
+ walk(branch.body, file, stack, scope, model, meet);
497
+ walk(handler.otherwise ?? [], file, stack, scope, model, meet);
498
+ walk(handler.after, file, stack, scope, model, meet);
499
+ }
500
+ walk(effect.fin, file, stack, scope, model, meet);
501
+ break;
502
+ }
503
+ case "class":
504
+ case "unknown":
505
+ case "package":
506
+ case "rethrow":
507
+ // What these add reaches a function's callers as its result: met at the calls of it.
508
+ break;
509
+ default: {
510
+ const unreachable = effect;
511
+ return unreachable;
512
+ }
513
+ }
514
+ }
515
+ }
516
+ // ---- reporting -------------------------------------------------------------------------------------
517
+ /** An example line is at most 100 characters: 4 of indent, 5 for ` // `. */
518
+ const HOW_MAX = 91;
519
+ function locationOf(file, at) {
520
+ return { file, start: at[0], end: at[1] };
521
+ }
522
+ function hintLine(met, name, model) {
523
+ const outcome = met.outcome;
524
+ const by = outcome.kind === "missing" ? outcome.by : null;
525
+ const fn = met.scope.kind === "function" && met.scope.entry.fact.id !== ANONYMOUS
526
+ ? met.scope.entry.fact.name
527
+ : undefined;
528
+ const tag = catchName(met.cls, model.analysis.classes);
529
+ return fitted((cut) => {
530
+ const passOn = fn === undefined ? undefined : `\`@throws {${cut(tag)}}\``;
531
+ if (by?.fate === "generic") {
532
+ 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)}\`:`;
535
+ }
536
+ if (by?.fate === "rethrown") {
537
+ 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\`:`;
540
+ }
541
+ return passOn === undefined || fn === undefined
542
+ ? "name it in a `catch` around the call, or return it as a failure instead of throwing:"
543
+ : `name it in a \`catch\`, pass it on with ${passOn} on \`${cut(fn)}\`, or return it as a failure instead of throwing:`;
544
+ });
545
+ }
546
+ function hint(met, how, model) {
547
+ const name = className(met.cls, model.analysis.classes);
548
+ const outcome = met.outcome;
549
+ const variable = outcome.kind === "missing" && outcome.by !== null ? (outcome.by.handler.variable ?? "e") : "e";
550
+ return withExample(hintLine(met, name, model), [
551
+ `if (${variable} instanceof ${catchName(met.cls, model.analysis.classes)}) {`,
552
+ how === "" ? " …" : ` // ${shorten(how, HOW_MAX)}`,
553
+ "}",
554
+ ]);
555
+ }
556
+ function tagNote(met, where, model) {
557
+ const classes = model.analysis.classes;
558
+ const name = className(met.cls, classes);
559
+ const marked = where.tag.id === met.cls
560
+ ? `\`${name}\` must be handled`
561
+ : `\`${name}\` extends \`${className(where.tag.id, classes)}\`, which must be handled`;
562
+ return {
563
+ location: locationOf(where.file, where.tag.at),
564
+ message: where.tag.how === "" ? `${marked} (\`@mustHandle\`)` : `${marked}: ${where.tag.how}`,
565
+ };
566
+ }
567
+ /** The `@throws` the fix writes: every class the function surely lets out when it has none yet. */
568
+ function throwsText(entry, cls, model) {
569
+ const classes = model.analysis.classes;
570
+ const own = catchName(cls, classes);
571
+ if (entry.fact.doc !== null)
572
+ return own;
573
+ const body = evaluated(entry, model.analysis).body;
574
+ const all = new Set([own]);
575
+ for (const part of [body.throws, body.rejects]) {
576
+ for (const [id, info] of part.classes)
577
+ if (info.certain)
578
+ all.add(catchName(id, classes));
579
+ }
580
+ return [...all].sort().join(" | ");
581
+ }
582
+ /** "Pass it on": `@throws` in the function's JSDoc, which is created when there is none. */
583
+ function passOnFix(met, model) {
584
+ if (met.outcome.kind !== "missing" || met.outcome.by?.fate === "generic")
585
+ return undefined;
586
+ if (met.scope.kind !== "function" || met.scope.entry.fact.id === ANONYMOUS)
587
+ return undefined;
588
+ const entry = met.scope.entry;
589
+ const anchor = model.anchors.get(entry.file)?.get(entry.fact.at[0]);
590
+ if (anchor === undefined)
591
+ return undefined;
592
+ const types = throwsText(entry, met.cls, model);
593
+ const line = `@throws {${types}}`;
594
+ const { indent, doc } = anchor;
595
+ let edit;
596
+ if (doc === null) {
597
+ edit = { start: anchor.start, end: anchor.start, newText: `/** ${line} */\n${indent}` };
598
+ }
599
+ else if (doc.text.includes("\n")) {
600
+ const close = doc.text.length - "*/".length;
601
+ const lineStart = doc.text.lastIndexOf("\n", close) + 1;
602
+ // Only a JSDoc whose `*/` stands on its own line: a new line goes just above it.
603
+ if (doc.text.slice(lineStart, close).trim() !== "")
604
+ return undefined;
605
+ const at = doc.start + lineStart;
606
+ edit = { start: at, end: at, newText: `${indent} * ${line}\n` };
607
+ }
608
+ else {
609
+ const content = doc.text.slice("/**".length, -"*/".length).trim();
610
+ edit = {
611
+ start: doc.start,
612
+ end: doc.end,
613
+ newText: content === ""
614
+ ? `/** ${line} */`
615
+ : `/**\n${indent} * ${content}\n${indent} * ${line}\n${indent} */`,
616
+ };
617
+ }
618
+ return {
619
+ title: `Pass it on: add \`${line}\` to \`${entry.fact.name}\``,
620
+ edits: [
621
+ {
622
+ location: { file: entry.file, start: edit.start, end: edit.end },
623
+ newText: edit.newText,
624
+ },
625
+ ],
626
+ };
627
+ }
628
+ function report(met, model, out) {
629
+ const where = tagOf(met.cls, model);
630
+ if (where === undefined)
631
+ return;
632
+ const classes = model.analysis.classes;
633
+ const name = className(met.cls, classes);
634
+ const tag = catchName(met.cls, classes);
635
+ const call = `${met.leaf.text}(…)`;
636
+ const location = locationOf(met.file, met.leaf.at);
637
+ const { outcome } = met;
638
+ switch (outcome.kind) {
639
+ case "handled":
640
+ return;
641
+ case "missing": {
642
+ const related = [];
643
+ if (outcome.by !== null) {
644
+ related.push({
645
+ location: locationOf(met.file, outcome.by.handler.at),
646
+ message: outcome.by.fate === "generic"
647
+ ? `this \`catch\` does not name \`${name}\`: it handles every error alike`
648
+ : `this \`catch\` rethrows \`${name}\``,
649
+ });
650
+ }
651
+ related.push(tagNote(met, where, model));
652
+ const fix = passOnFix(met, model);
653
+ out.problem({
654
+ message: fitted((cut) => `\`${cut(call)}\` can throw \`${cut(name)}\`, which must be handled`),
655
+ hint: hint(met, where.tag.how, model),
656
+ location,
657
+ related,
658
+ fixes: fix === undefined ? [] : [fix],
659
+ });
660
+ return;
661
+ }
662
+ case "callback":
663
+ 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`),
666
+ location,
667
+ });
668
+ return;
669
+ case "undecided":
670
+ 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`),
673
+ location,
674
+ });
675
+ return;
676
+ case "maybe":
677
+ 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`),
680
+ location,
681
+ });
682
+ return;
683
+ default: {
684
+ const unreachable = outcome;
685
+ return unreachable;
686
+ }
687
+ }
688
+ }
689
+ function modelOf(summaries) {
690
+ const analysis = analyse(throwsOf(summaries));
691
+ const tags = new Map();
692
+ const anchors = new Map();
693
+ const helpers = new Map();
694
+ const passes = new Map();
695
+ const untracked = [];
696
+ for (const [file, value] of summaries) {
697
+ const facts = readFacts(value);
698
+ if (facts === undefined)
699
+ continue;
700
+ for (const tag of facts.tags)
701
+ tags.set(tag.id, { file, tag });
702
+ for (const at of facts.untracked)
703
+ untracked.push(locationOf(file, at));
704
+ anchors.set(file, new Map(facts.anchors.map((anchor) => [anchor.name, anchor])));
705
+ for (const helper of facts.helpers)
706
+ helpers.set(helper.id, helper);
707
+ const byCatch = new Map();
708
+ for (const pass of facts.passes)
709
+ byCatch.set(pass.at, [...(byCatch.get(pass.at) ?? []), pass]);
710
+ passes.set(file, byCatch);
711
+ }
712
+ return { model: { analysis, tags, anchors, helpers, passes }, untracked };
713
+ }
714
+ export const mustHandleError = defineRule({
715
+ kind: "project",
716
+ 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.",
718
+ defaultSeverity: "error",
719
+ tier: "core",
720
+ group: GROUP,
721
+ needsJsDoc: true,
722
+ listUnknowns: true,
723
+ explain: EXPLAIN,
724
+ },
725
+ summarize: (ctx) => ({
726
+ throws: summarizeOnce(ctx),
727
+ mustHandle: summarizeMustHandle(ctx),
728
+ }),
729
+ check(summaries, ctx) {
730
+ const { model, untracked } = modelOf(summaries);
731
+ for (const location of untracked) {
732
+ 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",
735
+ location,
736
+ });
737
+ }
738
+ if (model.tags.size === 0)
739
+ return;
740
+ const decided = new Set();
741
+ const mets = [];
742
+ const out = {
743
+ problem: (problem) => {
744
+ ctx.report(problem);
745
+ },
746
+ unknown: (unknown) => {
747
+ ctx.unknown(unknown);
748
+ },
749
+ };
750
+ const meet = (met) => {
751
+ const key = `${met.file}\u0000${String(met.leaf.at[0])}\u0000${met.cls}`;
752
+ if (decided.has(key))
753
+ return;
754
+ decided.add(key);
755
+ mets.push(met);
756
+ };
757
+ const anonymous = [];
758
+ // Named functions and the module first: a callback written inline (`items.forEach(…)`) is
759
+ // also evaluated where it is called, which knows the `try` around it.
760
+ for (const entry of model.analysis.functions.values()) {
761
+ if (entry.fact.id === ANONYMOUS) {
762
+ anonymous.push(entry);
763
+ continue;
764
+ }
765
+ const scope = { kind: "function", entry };
766
+ walk(entry.fact.body, entry.file, [], scope, model, meet);
767
+ walk(entry.fact.returns, entry.file, [], scope, model, meet);
768
+ }
769
+ for (const { file, effects } of model.analysis.modules) {
770
+ walk(effects, file, [], { kind: "module" }, model, meet);
771
+ }
772
+ for (const entry of anonymous) {
773
+ walk(entry.fact.body, entry.file, [], { kind: "callback" }, model, meet);
774
+ walk(entry.fact.returns, entry.file, [], { kind: "callback" }, model, meet);
775
+ }
776
+ // A helper declared inside a function is judged where it is called: when every call of it
777
+ // sits in a `try` that handles the class, its own body is not reported.
778
+ const calls = new Map();
779
+ for (const met of mets)
780
+ calls.set(met.leaf.id, [...(calls.get(met.leaf.id) ?? []), met]);
781
+ const covered = (met) => {
782
+ if (met.scope.kind !== "function" || !met.scope.entry.fact.local)
783
+ return false;
784
+ if (met.outcome.kind !== "missing")
785
+ return false;
786
+ const called = (calls.get(met.scope.entry.fact.id) ?? []).filter((one) => one.cls === met.cls);
787
+ return called.length > 0 && called.every((one) => one.outcome.kind === "handled");
788
+ };
789
+ for (const met of mets)
790
+ if (!covered(met))
791
+ report(met, model, out);
792
+ },
793
+ });
794
+ //# sourceMappingURL=must-handle-error.js.map