@stateward/plugin-errors 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -21
- package/dist/catch-hides-unexpected.js +47 -58
- package/dist/catch-misses-error.js +57 -67
- package/dist/finally-can-throw.js +45 -54
- package/dist/inference/format.d.ts +3 -3
- package/dist/inference/format.js +5 -5
- package/dist/inference/solve.js +1 -1
- package/dist/inference/summarize.d.ts +1 -1
- package/dist/inference/summarize.js +2 -2
- package/dist/must-handle-error.js +58 -66
- package/dist/narrow-try.d.ts +6 -1
- package/dist/narrow-try.js +137 -77
- package/dist/plugin.d.ts +0 -27
- package/dist/plugin.js +1 -3
- package/dist/react/boundaries.d.ts +3 -3
- package/dist/react/boundaries.js +5 -5
- package/dist/react/fallbacks.js +2 -2
- package/dist/refactors.d.ts +1 -1
- package/dist/refactors.js +1 -1
- package/dist/render-throw-without-boundary.js +64 -47
- package/dist/result-ignored.js +31 -37
- package/dist/result-unchecked.js +58 -61
- package/dist/rules.meta.d.ts +22 -47
- package/dist/rules.meta.js +19 -47
- package/dist/throws-doc-mismatch.js +35 -44
- package/package.json +2 -2
- package/dist/catch-without-intent.d.ts +0 -10
- package/dist/catch-without-intent.js +0 -590
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stateward/plugin-errors",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "stateward rules for errors: errors are handled where they can happen.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -32,6 +32,6 @@
|
|
|
32
32
|
"!dist/**/*.tsbuildinfo"
|
|
33
33
|
],
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@stateward/core": "1.0
|
|
35
|
+
"@stateward/core": "1.1.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
/** The group the rule belongs to in the docs and in `stateward status`. */
|
|
2
|
-
export declare const GROUP = "catch";
|
|
3
|
-
export declare const catchWithoutIntent: import("@stateward/core").FileRule<{
|
|
4
|
-
loggers: import("@stateward/core").StringsOption;
|
|
5
|
-
logMethods: import("@stateward/core").StringsOption;
|
|
6
|
-
logFunctions: import("@stateward/core").StringsOption;
|
|
7
|
-
edgeHelpers: import("@stateward/core").StringsOption;
|
|
8
|
-
swallowHelpers: import("@stateward/core").StringsOption;
|
|
9
|
-
}, "catch">;
|
|
10
|
-
//# sourceMappingURL=catch-without-intent.d.ts.map
|
|
@@ -1,590 +0,0 @@
|
|
|
1
|
-
import ts from "@stateward/core/typescript";
|
|
2
|
-
import { defineRule, matchesName, option } from "@stateward/core";
|
|
3
|
-
import { fitted, withExample } from "./text.js";
|
|
4
|
-
import { handsOnCaught, someRunning, stripParentheses, testsCaught } from "./syntax.js";
|
|
5
|
-
/** The group the rule belongs to in the docs and in `stateward status`. */
|
|
6
|
-
export const GROUP = "catch";
|
|
7
|
-
const EXPLAIN = `# errors/catch-without-intent
|
|
8
|
-
|
|
9
|
-
Experimental: on real projects it is mostly wrong; it may change or be removed.
|
|
10
|
-
|
|
11
|
-
A catch that handles every error says how: it narrows, reports, or falls back; one that only logs, or calls something stateward cannot judge, and carries on is reported.
|
|
12
|
-
|
|
13
|
-
## Why
|
|
14
|
-
|
|
15
|
-
In \`load\`, a failed \`fetchUser\` is logged and \`render(user)\` runs anyway, with a \`user\` that was never fetched. The page shows a blank profile instead of an error, and the only trace is a line in the console.
|
|
16
|
-
|
|
17
|
-
A \`catch\` that takes every error should be one of four kinds:
|
|
18
|
-
|
|
19
|
-
- **narrowing**: it tests the error (\`e instanceof NotFoundError\`, \`isApiError(e)\`, \`e.name === "AbortError"\`) and rethrows the rest; \`errors/catch-hides-unexpected\` checks that part;
|
|
20
|
-
- **edge**: it reports the error where it ends: through Sentry (\`Sentry.captureException(e)\`, \`captureMessage\`), Bugsnag (\`Bugsnag.notify(e)\`) or Datadog (\`datadogRum.addError(e)\`), which stateward knows; through one of your helpers (\`reportAndShow(e)\`), declared in \`edgeHelpers\`; or by telling the user: a store dispatch of an action named for it (\`dispatch(addNotification(…))\`, \`showToast\`, \`setError\`, \`showAlert\`), or a state setter given the error or a failure (\`setError(e)\`, \`setState("failed")\`);
|
|
21
|
-
- **fallback**: the code has a real default: assigned before the \`try\`, returned after the \`catch\`, or assigned or returned in the \`catch\`;
|
|
22
|
-
- **passed on**: it throws, or hands the error to a callback of the function around it (\`reject(e)\`, \`next(e)\`).
|
|
23
|
-
|
|
24
|
-
Anything else is reported.
|
|
25
|
-
|
|
26
|
-
## When to use it
|
|
27
|
-
|
|
28
|
-
It stays experimental until it is mostly right on real projects. Use it for code that must never lose a failure: payments, sync, data import; or to review a module's catches once. Declare your own reporters in \`edgeHelpers\` first. In most apps many catches that only log are deliberate fallbacks (a setting read from storage, a best-effort cache write), so it is off until you add its add-on (below), and no preset or \`init\` turns it on; then a warning in every preset. To be strict in one folder only, use \`overrides\`: on for \`src/payments/**\`, off elsewhere.
|
|
29
|
-
|
|
30
|
-
## What it catches
|
|
31
|
-
|
|
32
|
-
A \`catch\` that only logs (\`console.*\`, a call on \`logger\` or \`log\`, or \`.error\`, \`.warn\`, \`.info\`, \`.debug\` on any object), or calls a function of your own that is not declared as a helper, when the function carries on after the \`try\`. A function of your own counts only once it is declared: \`edgeHelpers\` for one that reports, \`swallowHelpers\` for one that ignores an error on purpose (\`ignoreError(e)\`). Until then the message asks which it is.
|
|
33
|
-
|
|
34
|
-
### Bad
|
|
35
|
-
|
|
36
|
-
\`\`\`ts
|
|
37
|
-
async function load(id: string) {
|
|
38
|
-
let user;
|
|
39
|
-
try {
|
|
40
|
-
user = await fetchUser(id);
|
|
41
|
-
} catch (e) {
|
|
42
|
-
console.error(e);
|
|
43
|
-
}
|
|
44
|
-
return render(user);
|
|
45
|
-
}
|
|
46
|
-
\`\`\`
|
|
47
|
-
|
|
48
|
-
### Good
|
|
49
|
-
|
|
50
|
-
\`\`\`ts
|
|
51
|
-
async function load(id: string) {
|
|
52
|
-
try {
|
|
53
|
-
return render(await fetchUser(id));
|
|
54
|
-
} catch (e) {
|
|
55
|
-
reportAndShow(e); // listed in edgeHelpers
|
|
56
|
-
return renderError();
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
\`\`\`
|
|
60
|
-
|
|
61
|
-
### Fix
|
|
62
|
-
|
|
63
|
-
The message names the better form for the shape of the code:
|
|
64
|
-
|
|
65
|
-
- the \`try\` assigns a variable declared with no value (\`let user;\`): give it a fallback first;
|
|
66
|
-
- the \`try\` returns a value and the \`catch\` returns nothing: return the default from the \`catch\`;
|
|
67
|
-
- the file already calls one of your \`edgeHelpers\`: report it like the rest of the file (\`reportAndShow(e);\`);
|
|
68
|
-
- the \`catch\` is in an async event handler (\`onClick={async () => …}\`, \`handleSubmit\`): show it to the user, since nothing above the handler will;
|
|
69
|
-
- otherwise: rethrow it, or \`return err(e);\` when the file imports a function named \`err\`.
|
|
70
|
-
|
|
71
|
-
When the \`catch\` calls a function of yours, the message first asks: if it reports the error, add it to \`edgeHelpers\`. There is no quick fix.
|
|
72
|
-
|
|
73
|
-
## What it does not catch
|
|
74
|
-
|
|
75
|
-
It stays quiet when:
|
|
76
|
-
|
|
77
|
-
- the \`catch\` throws, returns, assigns, tests the error, reports it (Sentry, Bugsnag, Datadog, a declared helper), tells the user (a dispatch of a notification, toast, error or alert action; a state setter given the error or a failure), hands the error to a callback parameter (\`reject(e)\`), or keeps it in a collection (\`errors.push(String(e))\`);
|
|
78
|
-
- the \`catch\` is empty: ESLint's \`no-empty\` (in \`eslint:recommended\`) and Biome's \`noEmptyBlockStatements\` already report an empty block, and accept one with a comment, as this rule did;
|
|
79
|
-
- the \`try\` returns its value and the code after it returns a default (\`try { return JSON.parse(stored); } catch (e) { console.warn(e); } return defaults;\`);
|
|
80
|
-
- every variable the \`try\` assigns already had a value to fall back on (\`let flags = DEFAULTS; try { flags = JSON.parse(stored); } catch …\`; a parameter counts, a \`let\` with no value does not);
|
|
81
|
-
- the \`try\` is the last thing the function, or a loop's body, does (a retry loop moves on to the next attempt).
|
|
82
|
-
|
|
83
|
-
Other tools: ESLint's \`no-useless-catch\` and Biome's \`noUselessCatch\` report a \`catch\` that only rethrows; eslint-plugin-unicorn's \`prefer-optional-catch-binding\` and \`catch-error-name\`, and typescript-eslint's \`use-unknown-in-catch-callback-variable\`, are about the catch variable. None of them looks at what a \`catch\` does with the error, so this rule does not repeat them.
|
|
84
|
-
|
|
85
|
-
## Turn it on or off
|
|
86
|
-
|
|
87
|
-
Turn it on in \`stateward.config.ts\`:
|
|
88
|
-
|
|
89
|
-
\`\`\`ts
|
|
90
|
-
addons: ["errors/catch-intent"]
|
|
91
|
-
\`\`\`
|
|
92
|
-
|
|
93
|
-
Then declare your helpers, change the rule, or set it per folder:
|
|
94
|
-
|
|
95
|
-
\`\`\`ts
|
|
96
|
-
rules: { "errors/catch-without-intent": ["warning", { edgeHelpers: ["reportAndShow", "Sentry.captureException"], swallowHelpers: ["ignoreError"] }] }
|
|
97
|
-
rules: { "errors/catch-without-intent": ["warning", { logFunctions: ["logError"] }] } // your own log helpers
|
|
98
|
-
overrides: [{ files: ["src/payments/**"], rules: { "errors/catch-without-intent": "error" } }]
|
|
99
|
-
rules: { "errors/catch-without-intent": "off" } // or "error"
|
|
100
|
-
groups: { "errors/catch": "off" } // the whole group
|
|
101
|
-
\`\`\`
|
|
102
|
-
`;
|
|
103
|
-
const OPTIONS = {
|
|
104
|
-
loggers: option.strings({
|
|
105
|
-
default: ["console", "logger", "log"],
|
|
106
|
-
description: "Objects whose every method only logs (globs like *Logger): a catch that only calls them swallows the error.",
|
|
107
|
-
}),
|
|
108
|
-
logMethods: option.strings({
|
|
109
|
-
default: ["error", "warn", "info", "debug"],
|
|
110
|
-
description: "Method names that only log, on any object (`Sentry.captureException` is not one).",
|
|
111
|
-
}),
|
|
112
|
-
logFunctions: option.strings({
|
|
113
|
-
default: [],
|
|
114
|
-
description: "Plain functions of your own that only log, like logError (globs allowed).",
|
|
115
|
-
}),
|
|
116
|
-
edgeHelpers: option.strings({
|
|
117
|
-
default: [],
|
|
118
|
-
description: "Your functions that report an error where it ends (to a service, to the user): a catch that calls one is handled, like reportAndShow (globs allowed). Sentry, Bugsnag and Datadog are known without it.",
|
|
119
|
-
}),
|
|
120
|
-
swallowHelpers: option.strings({
|
|
121
|
-
default: [],
|
|
122
|
-
description: "Functions that mark an error as ignored on purpose, like ignoreError (globs allowed): a catch that calls one is not reported.",
|
|
123
|
-
}),
|
|
124
|
-
};
|
|
125
|
-
/** The name an expression ends in: `logger`, `this.logger`, `app.log`. */
|
|
126
|
-
function lastName(expression) {
|
|
127
|
-
const node = stripParentheses(expression);
|
|
128
|
-
if (ts.isIdentifier(node))
|
|
129
|
-
return node.text;
|
|
130
|
-
return ts.isPropertyAccessExpression(node) ? node.name.text : undefined;
|
|
131
|
-
}
|
|
132
|
-
/**
|
|
133
|
-
* A call that only logs: any method of a logger (`console.x()`, `logger.x()`), a log method on any
|
|
134
|
-
* object (`app.warn()`), or one of the team's own log functions (`logError(e)`).
|
|
135
|
-
*/
|
|
136
|
-
function isLoggingCall(expression, logging) {
|
|
137
|
-
const call = stripParentheses(expression);
|
|
138
|
-
if (!ts.isCallExpression(call))
|
|
139
|
-
return false;
|
|
140
|
-
const callee = stripParentheses(call.expression);
|
|
141
|
-
if (ts.isIdentifier(callee))
|
|
142
|
-
return matchesName(callee.text, logging.logFunctions);
|
|
143
|
-
if (!ts.isPropertyAccessExpression(callee))
|
|
144
|
-
return false;
|
|
145
|
-
if (logging.logMethods.includes(callee.name.text))
|
|
146
|
-
return true;
|
|
147
|
-
const name = lastName(stripParentheses(callee.expression));
|
|
148
|
-
return name !== undefined && matchesName(name, logging.loggers);
|
|
149
|
-
}
|
|
150
|
-
/** `reportAndShow`, `Sentry.captureException`, `this.reporter.send`: how a call is matched to a name list. */
|
|
151
|
-
function calleeText(call) {
|
|
152
|
-
const parts = [];
|
|
153
|
-
let node = stripParentheses(call.expression);
|
|
154
|
-
for (;;) {
|
|
155
|
-
if (ts.isIdentifier(node)) {
|
|
156
|
-
parts.unshift(node.text);
|
|
157
|
-
return parts.join(".");
|
|
158
|
-
}
|
|
159
|
-
if (node.kind === ts.SyntaxKind.ThisKeyword) {
|
|
160
|
-
parts.unshift("this");
|
|
161
|
-
return parts.join(".");
|
|
162
|
-
}
|
|
163
|
-
if (!ts.isPropertyAccessExpression(node) || ts.isPrivateIdentifier(node.name))
|
|
164
|
-
return undefined;
|
|
165
|
-
parts.unshift(node.name.text);
|
|
166
|
-
node = stripParentheses(node.expression);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
function callsOneOf(call, names) {
|
|
170
|
-
const text = calleeText(call);
|
|
171
|
-
return text !== undefined && matchesName(text, names);
|
|
172
|
-
}
|
|
173
|
-
function stripAwait(expression) {
|
|
174
|
-
const inner = stripParentheses(expression);
|
|
175
|
-
return ts.isAwaitExpression(inner) ? stripParentheses(inner.expression) : inner;
|
|
176
|
-
}
|
|
177
|
-
/**
|
|
178
|
-
* Error reporters every project means the same thing by: Sentry, Bugsnag and
|
|
179
|
-
* Datadog RUM. A catch that calls one reports the error where it ends, as a declared edge helper
|
|
180
|
-
* does. `console.error` is not one: it is only seen by whoever has the console open.
|
|
181
|
-
*/
|
|
182
|
-
const KNOWN_REPORTERS = [
|
|
183
|
-
"Sentry.captureException",
|
|
184
|
-
"Sentry.captureMessage",
|
|
185
|
-
"captureException",
|
|
186
|
-
"captureMessage",
|
|
187
|
-
"*.captureException",
|
|
188
|
-
"*.captureMessage",
|
|
189
|
-
"Bugsnag.notify",
|
|
190
|
-
"bugsnagClient.notify",
|
|
191
|
-
"datadogRum.addError",
|
|
192
|
-
"DD_RUM.addError",
|
|
193
|
-
];
|
|
194
|
-
/** The last name of what a call calls: `dispatch` in `store.dispatch(…)`, `addNotification`. */
|
|
195
|
-
function calledName(call) {
|
|
196
|
-
return lastName(stripParentheses(call.expression));
|
|
197
|
-
}
|
|
198
|
-
/** An action that shows the user something: `addNotification(…)`, `showToast(…)`, `{ type: "alerts/show" }`. */
|
|
199
|
-
const TELLS_USER = /notif|toast|error|alert/i;
|
|
200
|
-
/**
|
|
201
|
-
* A store dispatch of an action that tells the user (`dispatch(addNotification(…))`): the failure
|
|
202
|
-
* reaches the user through the store, like a toast.
|
|
203
|
-
*/
|
|
204
|
-
function tellsTheUser(call) {
|
|
205
|
-
if (calledName(call) !== "dispatch")
|
|
206
|
-
return false;
|
|
207
|
-
const [action] = call.arguments;
|
|
208
|
-
if (action === undefined)
|
|
209
|
-
return false;
|
|
210
|
-
return isUserAction(action);
|
|
211
|
-
}
|
|
212
|
-
/** An action that tells the user; a `?:` counts when every branch does (`cond ? addNotification(…) : serverError(e)`). */
|
|
213
|
-
function isUserAction(action) {
|
|
214
|
-
const code = stripParentheses(action);
|
|
215
|
-
if (ts.isConditionalExpression(code)) {
|
|
216
|
-
return isUserAction(code.whenTrue) && isUserAction(code.whenFalse);
|
|
217
|
-
}
|
|
218
|
-
if (ts.isCallExpression(code))
|
|
219
|
-
return TELLS_USER.test(calledName(code) ?? "");
|
|
220
|
-
if (!ts.isObjectLiteralExpression(code))
|
|
221
|
-
return false;
|
|
222
|
-
return code.properties.some((property) => ts.isPropertyAssignment(property) &&
|
|
223
|
-
ts.isIdentifier(property.name) &&
|
|
224
|
-
property.name.text === "type" &&
|
|
225
|
-
ts.isStringLiteralLike(property.initializer) &&
|
|
226
|
-
TELLS_USER.test(property.initializer.text));
|
|
227
|
-
}
|
|
228
|
-
const SETTER = /^set[A-Z]/;
|
|
229
|
-
const FAILURE_WORD = /error|fail/i;
|
|
230
|
-
/**
|
|
231
|
-
* A state setter given an error-like value: `setError(e)`, `setState("failed")`,
|
|
232
|
-
* `this.setState({ error: e.message })`. The component then shows the failure.
|
|
233
|
-
*/
|
|
234
|
-
function setsFailureState(call, clause) {
|
|
235
|
-
const name = calledName(call);
|
|
236
|
-
if (name === undefined || !SETTER.test(name))
|
|
237
|
-
return false;
|
|
238
|
-
if (FAILURE_WORD.test(name))
|
|
239
|
-
return true;
|
|
240
|
-
const declaration = clause.variableDeclaration;
|
|
241
|
-
const variable = declaration !== undefined && ts.isIdentifier(declaration.name) ? declaration.name.text : "";
|
|
242
|
-
const errorLike = (node) => (ts.isIdentifier(node) && node.text === variable) ||
|
|
243
|
-
(ts.isStringLiteralLike(node) && FAILURE_WORD.test(node.text)) ||
|
|
244
|
-
ts.forEachChild(node, (child) => (errorLike(child) ? true : undefined)) === true;
|
|
245
|
-
return call.arguments.some(errorLike);
|
|
246
|
-
}
|
|
247
|
-
/** Methods that keep what they are given in a collection: `errors.push(…)`, `failed.set(id, …)`. */
|
|
248
|
-
const COLLECTING = new Set(["push", "unshift", "set", "add"]);
|
|
249
|
-
/**
|
|
250
|
-
* Whether `call` keeps the caught error, or a message made from it, in a collection
|
|
251
|
-
* (`errorMessages.push(\`\${url} failed: \${e}\`)`): the code after the loop reads it, like a
|
|
252
|
-
* fallback.
|
|
253
|
-
*/
|
|
254
|
-
function collectsCaught(call, clause) {
|
|
255
|
-
const declaration = clause.variableDeclaration;
|
|
256
|
-
if (declaration === undefined || !ts.isIdentifier(declaration.name))
|
|
257
|
-
return false;
|
|
258
|
-
const variable = declaration.name.text;
|
|
259
|
-
const callee = stripParentheses(call.expression);
|
|
260
|
-
if (!ts.isPropertyAccessExpression(callee) || !COLLECTING.has(callee.name.text))
|
|
261
|
-
return false;
|
|
262
|
-
const mentions = (node) => (ts.isIdentifier(node) && node.text === variable) ||
|
|
263
|
-
ts.forEachChild(node, (child) => (mentions(child) ? true : undefined)) === true;
|
|
264
|
-
return call.arguments.some(mentions);
|
|
265
|
-
}
|
|
266
|
-
const ASSIGNMENTS = new Set([
|
|
267
|
-
ts.SyntaxKind.EqualsToken,
|
|
268
|
-
ts.SyntaxKind.PlusEqualsToken,
|
|
269
|
-
ts.SyntaxKind.MinusEqualsToken,
|
|
270
|
-
ts.SyntaxKind.BarBarEqualsToken,
|
|
271
|
-
ts.SyntaxKind.QuestionQuestionEqualsToken,
|
|
272
|
-
ts.SyntaxKind.AmpersandAmpersandEqualsToken,
|
|
273
|
-
]);
|
|
274
|
-
function shapeOf(clause, logging) {
|
|
275
|
-
const statements = clause.block.statements.filter((statement) => !ts.isEmptyStatement(statement));
|
|
276
|
-
if (statements.length === 0 || testsCaught(clause))
|
|
277
|
-
return { kind: "handled" };
|
|
278
|
-
const declared = [...logging.edgeHelpers, ...logging.swallowHelpers];
|
|
279
|
-
const handled = someRunning(clause.block, (node) => {
|
|
280
|
-
if (ts.isThrowStatement(node) || ts.isReturnStatement(node))
|
|
281
|
-
return true;
|
|
282
|
-
if (ts.isBinaryExpression(node) && ASSIGNMENTS.has(node.operatorToken.kind))
|
|
283
|
-
return true;
|
|
284
|
-
if ((ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
|
|
285
|
-
(node.operator === ts.SyntaxKind.PlusPlusToken ||
|
|
286
|
-
node.operator === ts.SyntaxKind.MinusMinusToken)) {
|
|
287
|
-
return true;
|
|
288
|
-
}
|
|
289
|
-
return (ts.isCallExpression(node) &&
|
|
290
|
-
(callsOneOf(node, declared) ||
|
|
291
|
-
callsOneOf(node, KNOWN_REPORTERS) ||
|
|
292
|
-
tellsTheUser(node) ||
|
|
293
|
-
setsFailureState(node, clause) ||
|
|
294
|
-
handsOnCaught(node, clause) ||
|
|
295
|
-
collectsCaught(node, clause)));
|
|
296
|
-
});
|
|
297
|
-
if (handled)
|
|
298
|
-
return { kind: "handled" };
|
|
299
|
-
const onlyLogs = statements.every((statement) => ts.isExpressionStatement(statement) && isLoggingCall(statement.expression, logging));
|
|
300
|
-
if (onlyLogs)
|
|
301
|
-
return { kind: "logs" };
|
|
302
|
-
// The first statement that is a call, not logging: what the message asks about.
|
|
303
|
-
const first = statements
|
|
304
|
-
.map((statement) => ts.isExpressionStatement(statement) ? stripAwait(statement.expression) : undefined)
|
|
305
|
-
.find((expression) => expression !== undefined &&
|
|
306
|
-
ts.isCallExpression(expression) &&
|
|
307
|
-
!isLoggingCall(expression, logging));
|
|
308
|
-
return { kind: "other", call: first === undefined ? undefined : calleeText(first) };
|
|
309
|
-
}
|
|
310
|
-
/** Statements that run nothing: a bare `;` and declarations that only exist at compile time. */
|
|
311
|
-
function runsNothing(statement) {
|
|
312
|
-
return (ts.isEmptyStatement(statement) ||
|
|
313
|
-
ts.isFunctionDeclaration(statement) ||
|
|
314
|
-
ts.isInterfaceDeclaration(statement) ||
|
|
315
|
-
ts.isTypeAliasDeclaration(statement));
|
|
316
|
-
}
|
|
317
|
-
/** `return;`, `break;` and `continue;` end the flow: nothing after them carries on. */
|
|
318
|
-
function endsFlow(statement) {
|
|
319
|
-
if (ts.isReturnStatement(statement))
|
|
320
|
-
return statement.expression === undefined;
|
|
321
|
-
return ts.isBreakStatement(statement) || ts.isContinueStatement(statement);
|
|
322
|
-
}
|
|
323
|
-
/**
|
|
324
|
-
* Whether the function runs something after the `try` statement: a statement that follows it in
|
|
325
|
-
* its block or in a block around it, up to the function (or the file, for top-level code). The end
|
|
326
|
-
* of a loop's body runs nothing after it: the loop goes on to its next turn (a retry loop's next
|
|
327
|
-
* attempt).
|
|
328
|
-
*/
|
|
329
|
-
function continuesAfter(tryStatement) {
|
|
330
|
-
let node = tryStatement;
|
|
331
|
-
for (let parent = node.parent;; parent = node.parent) {
|
|
332
|
-
if (ts.isFunctionLike(parent))
|
|
333
|
-
return false;
|
|
334
|
-
if (ts.isBlock(parent) || ts.isSourceFile(parent) || ts.isCaseOrDefaultClause(parent)) {
|
|
335
|
-
const statements = parent.statements;
|
|
336
|
-
const after = statements.slice(statements.findIndex((candidate) => candidate === node) + 1);
|
|
337
|
-
const next = after.find((statement) => !runsNothing(statement));
|
|
338
|
-
if (next !== undefined)
|
|
339
|
-
return !endsFlow(next);
|
|
340
|
-
}
|
|
341
|
-
if (ts.isSourceFile(parent))
|
|
342
|
-
return false;
|
|
343
|
-
if (ts.isIterationStatement(parent, false) && parent.statement === node)
|
|
344
|
-
return false;
|
|
345
|
-
node = parent;
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
/** Whether `node` has a `return` with a value that runs in this function (not in a nested one). */
|
|
349
|
-
function returnsValue(node) {
|
|
350
|
-
if (ts.isReturnStatement(node))
|
|
351
|
-
return node.expression !== undefined;
|
|
352
|
-
if (ts.isFunctionLike(node) || ts.isClassLike(node))
|
|
353
|
-
return false;
|
|
354
|
-
return ts.forEachChild(node, (child) => (returnsValue(child) ? true : undefined)) === true;
|
|
355
|
-
}
|
|
356
|
-
/**
|
|
357
|
-
* A fallback: the `try` returns the value it produced, and the statement right after it returns
|
|
358
|
-
* another one (`try { return JSON.parse(stored); } catch (e) { console.warn(e); } return
|
|
359
|
-
* defaults;`). The caller gets a value either way; nothing runs on a result that was never made.
|
|
360
|
-
*/
|
|
361
|
-
function fallsBackToAValue(tryStatement) {
|
|
362
|
-
const list = tryStatement.parent;
|
|
363
|
-
if (!ts.isBlock(list) && !ts.isSourceFile(list) && !ts.isCaseOrDefaultClause(list))
|
|
364
|
-
return false;
|
|
365
|
-
const after = list.statements.slice(list.statements.indexOf(tryStatement) + 1);
|
|
366
|
-
const next = after.find((statement) => !runsNothing(statement));
|
|
367
|
-
return (next !== undefined &&
|
|
368
|
-
ts.isReturnStatement(next) &&
|
|
369
|
-
next.expression !== undefined &&
|
|
370
|
-
returnsValue(tryStatement.tryBlock));
|
|
371
|
-
}
|
|
372
|
-
/** The names the `try` block assigns with `=` (not in a nested function): `flags = parse(…)`. */
|
|
373
|
-
function assignedNames(block) {
|
|
374
|
-
const names = [];
|
|
375
|
-
const visit = (node) => {
|
|
376
|
-
if (ts.isFunctionLike(node) || ts.isClassLike(node))
|
|
377
|
-
return;
|
|
378
|
-
if (ts.isBinaryExpression(node) &&
|
|
379
|
-
node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
380
|
-
ts.isIdentifier(stripParentheses(node.left))) {
|
|
381
|
-
const target = stripParentheses(node.left);
|
|
382
|
-
if (ts.isIdentifier(target))
|
|
383
|
-
names.push(target.text);
|
|
384
|
-
}
|
|
385
|
-
ts.forEachChild(node, visit);
|
|
386
|
-
};
|
|
387
|
-
visit(block);
|
|
388
|
-
return names;
|
|
389
|
-
}
|
|
390
|
-
/**
|
|
391
|
-
* How `name` is declared before the `try`: a `let` or `var` with a value (`value`) or without one
|
|
392
|
-
* (`empty`), in a block around it (before it, up to the module), or a parameter of a function
|
|
393
|
-
* around it (`value`: it holds the caller's value). Undefined when it is declared elsewhere.
|
|
394
|
-
*/
|
|
395
|
-
function declaredBefore(name, tryStatement) {
|
|
396
|
-
let node = tryStatement;
|
|
397
|
-
for (let parent = node.parent;; parent = node.parent) {
|
|
398
|
-
if (ts.isBlock(parent) || ts.isSourceFile(parent) || ts.isCaseOrDefaultClause(parent)) {
|
|
399
|
-
const before = parent.statements.slice(0, parent.statements.findIndex((s) => s === node));
|
|
400
|
-
for (const statement of before) {
|
|
401
|
-
if (!ts.isVariableStatement(statement))
|
|
402
|
-
continue;
|
|
403
|
-
for (const declaration of statement.declarationList.declarations) {
|
|
404
|
-
if (ts.isIdentifier(declaration.name) && declaration.name.text === name) {
|
|
405
|
-
return declaration.initializer === undefined ? "empty" : "value";
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
// A parameter holds the caller's value; past it, a closure's or the module's variable.
|
|
411
|
-
if (ts.isFunctionLike(parent) &&
|
|
412
|
-
parent.parameters.some((parameter) => ts.isIdentifier(parameter.name) && parameter.name.text === name)) {
|
|
413
|
-
return "value";
|
|
414
|
-
}
|
|
415
|
-
if (ts.isSourceFile(parent))
|
|
416
|
-
return undefined;
|
|
417
|
-
node = parent;
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
/**
|
|
421
|
-
* A fallback assigned before the `try`: every variable the `try` assigns already held a value
|
|
422
|
-
* (`let flags = DEFAULTS; try { flags = parse(…); } catch (e) { log(e); } return flags;`). The
|
|
423
|
-
* code after the `try` runs on that value when the `try` fails, which is what the author chose.
|
|
424
|
-
*/
|
|
425
|
-
function replacesAFallback(tryStatement) {
|
|
426
|
-
const names = assignedNames(tryStatement.tryBlock);
|
|
427
|
-
return names.length > 0 && names.every((name) => declaredBefore(name, tryStatement) === "value");
|
|
428
|
-
}
|
|
429
|
-
function headline(shape) {
|
|
430
|
-
if (shape.kind === "logs")
|
|
431
|
-
return "this catch logs the error and carries on as if nothing failed";
|
|
432
|
-
if (shape.call === undefined) {
|
|
433
|
-
return "this catch neither reports the error nor gives a fallback, and the code carries on";
|
|
434
|
-
}
|
|
435
|
-
return fitted((cut) => `this catch calls \`${cut(shape.call ?? "")}\` and carries on; stateward cannot tell whether that reports the error`);
|
|
436
|
-
}
|
|
437
|
-
/** The local name of a function `err` the file imports (the project's Result helper, neverthrow's). */
|
|
438
|
-
function importedErr(sourceFile) {
|
|
439
|
-
for (const statement of sourceFile.statements) {
|
|
440
|
-
if (!ts.isImportDeclaration(statement) ||
|
|
441
|
-
!ts.isStringLiteral(statement.moduleSpecifier) ||
|
|
442
|
-
statement.importClause?.phaseModifier === ts.SyntaxKind.TypeKeyword) {
|
|
443
|
-
continue;
|
|
444
|
-
}
|
|
445
|
-
const bindings = statement.importClause?.namedBindings;
|
|
446
|
-
if (bindings === undefined || !ts.isNamedImports(bindings))
|
|
447
|
-
continue;
|
|
448
|
-
const element = bindings.elements.find((candidate) => !candidate.isTypeOnly && (candidate.propertyName ?? candidate.name).text === "err");
|
|
449
|
-
if (element !== undefined)
|
|
450
|
-
return element.name.text;
|
|
451
|
-
}
|
|
452
|
-
return undefined;
|
|
453
|
-
}
|
|
454
|
-
/** A variable the `try` assigns that was declared with no value: `let user;`. */
|
|
455
|
-
function unsetVariable(tryStatement) {
|
|
456
|
-
return assignedNames(tryStatement.tryBlock).find((name) => declaredBefore(name, tryStatement) === "empty");
|
|
457
|
-
}
|
|
458
|
-
/** The first call in the file of a declared edge helper, as written: `reportAndShow`. */
|
|
459
|
-
function edgeHelperOfFile(sourceFile, edgeHelpers) {
|
|
460
|
-
if (edgeHelpers.length === 0)
|
|
461
|
-
return undefined;
|
|
462
|
-
let found;
|
|
463
|
-
const visit = (node) => {
|
|
464
|
-
if (found !== undefined)
|
|
465
|
-
return;
|
|
466
|
-
if (ts.isCallExpression(node) && callsOneOf(node, edgeHelpers))
|
|
467
|
-
found = calleeText(node);
|
|
468
|
-
ts.forEachChild(node, visit);
|
|
469
|
-
};
|
|
470
|
-
visit(sourceFile);
|
|
471
|
-
return found;
|
|
472
|
-
}
|
|
473
|
-
const HANDLER_NAME = /^(handle|on)[A-Z]/;
|
|
474
|
-
/**
|
|
475
|
-
* Whether the catch runs in an async event handler: an `async` function passed to an `on…` JSX
|
|
476
|
-
* attribute or `addEventListener`, or named `handle…` / `on…`. Its caller is the browser, so a
|
|
477
|
-
* failure only reaches the user if the handler shows it.
|
|
478
|
-
*/
|
|
479
|
-
function inAsyncEventHandler(clause) {
|
|
480
|
-
let fn = clause;
|
|
481
|
-
while (!ts.isSourceFile(fn) && !ts.isFunctionLike(fn))
|
|
482
|
-
fn = fn.parent;
|
|
483
|
-
if (!ts.isFunctionLike(fn) || !hasAsync(fn))
|
|
484
|
-
return false;
|
|
485
|
-
const named = (ts.isFunctionDeclaration(fn) || ts.isMethodDeclaration(fn)) && fn.name !== undefined
|
|
486
|
-
? fn.name.getText()
|
|
487
|
-
: undefined;
|
|
488
|
-
if (named !== undefined)
|
|
489
|
-
return HANDLER_NAME.test(named);
|
|
490
|
-
const parent = fn.parent;
|
|
491
|
-
if (ts.isVariableDeclaration(parent) || ts.isPropertyAssignment(parent)) {
|
|
492
|
-
return HANDLER_NAME.test(parent.name.getText());
|
|
493
|
-
}
|
|
494
|
-
if (ts.isJsxExpression(parent) && ts.isJsxAttribute(parent.parent)) {
|
|
495
|
-
return HANDLER_NAME.test(parent.parent.name.getText());
|
|
496
|
-
}
|
|
497
|
-
if (ts.isCallExpression(parent)) {
|
|
498
|
-
const callee = calleeText(parent);
|
|
499
|
-
return (callee !== undefined &&
|
|
500
|
-
(callee === "addEventListener" || callee.endsWith(".addEventListener")));
|
|
501
|
-
}
|
|
502
|
-
return false;
|
|
503
|
-
}
|
|
504
|
-
function hasAsync(fn) {
|
|
505
|
-
return (ts.canHaveModifiers(fn) &&
|
|
506
|
-
ts.getModifiers(fn)?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) === true);
|
|
507
|
-
}
|
|
508
|
-
/**
|
|
509
|
-
* The better form for this catch's shape: a fallback for a variable left unset, a default
|
|
510
|
-
* for a function that returns one, the file's own way to report, the user for an event handler;
|
|
511
|
-
* else a rethrow, or the file's `err`.
|
|
512
|
-
*/
|
|
513
|
-
function advice(clause, variable, ctx) {
|
|
514
|
-
const tryStatement = clause.parent;
|
|
515
|
-
const unset = unsetVariable(tryStatement);
|
|
516
|
-
if (unset !== undefined) {
|
|
517
|
-
return {
|
|
518
|
-
line: `give \`${unset}\` a fallback before the \`try\`: \`let ${unset} = …;\``,
|
|
519
|
-
example: [],
|
|
520
|
-
};
|
|
521
|
-
}
|
|
522
|
-
if (returnsValue(tryStatement.tryBlock)) {
|
|
523
|
-
return {
|
|
524
|
-
line: "return the default from the `catch`",
|
|
525
|
-
example: [],
|
|
526
|
-
};
|
|
527
|
-
}
|
|
528
|
-
const edge = edgeHelperOfFile(ctx.sourceFile, [...ctx.options.edgeHelpers, ...KNOWN_REPORTERS]);
|
|
529
|
-
if (edge !== undefined) {
|
|
530
|
-
return { line: "report it like the rest of this file:", example: [`${edge}(${variable});`] };
|
|
531
|
-
}
|
|
532
|
-
if (inAsyncEventHandler(clause)) {
|
|
533
|
-
return {
|
|
534
|
-
line: "show it to the user: no caller of an event handler will",
|
|
535
|
-
example: [],
|
|
536
|
-
};
|
|
537
|
-
}
|
|
538
|
-
const err = importedErr(ctx.sourceFile);
|
|
539
|
-
return {
|
|
540
|
-
line: "rethrow it, return a failure the caller can see, or show it to the user:",
|
|
541
|
-
example: [err === undefined ? `throw ${variable};` : `return ${err}(${variable});`],
|
|
542
|
-
};
|
|
543
|
-
}
|
|
544
|
-
function hint(shape, clause, variable, ctx) {
|
|
545
|
-
const { line, example } = advice(clause, variable, ctx);
|
|
546
|
-
if (shape.kind === "other" && shape.call !== undefined) {
|
|
547
|
-
return withExample(`if \`${shape.call}\` reports it, add it to \`edgeHelpers\`; if not, ${line}`, example);
|
|
548
|
-
}
|
|
549
|
-
return withExample(line, example);
|
|
550
|
-
}
|
|
551
|
-
function checkCatch(clause, ctx) {
|
|
552
|
-
const shape = shapeOf(clause, ctx.options);
|
|
553
|
-
if (shape.kind === "handled" ||
|
|
554
|
-
!continuesAfter(clause.parent) ||
|
|
555
|
-
fallsBackToAValue(clause.parent) ||
|
|
556
|
-
replacesAFallback(clause.parent)) {
|
|
557
|
-
return;
|
|
558
|
-
}
|
|
559
|
-
const declaration = clause.variableDeclaration;
|
|
560
|
-
const variable = declaration !== undefined && ts.isIdentifier(declaration.name)
|
|
561
|
-
? declaration.name.text
|
|
562
|
-
: "error";
|
|
563
|
-
ctx.report({
|
|
564
|
-
node: clause.getFirstToken(ctx.sourceFile) ?? clause,
|
|
565
|
-
message: headline(shape),
|
|
566
|
-
hint: hint(shape, clause, variable, ctx),
|
|
567
|
-
});
|
|
568
|
-
}
|
|
569
|
-
export const catchWithoutIntent = defineRule({
|
|
570
|
-
kind: "file",
|
|
571
|
-
meta: {
|
|
572
|
-
description: "Experimental: on real projects it is mostly wrong; it may change or be removed. A catch that handles every error says how: it narrows, reports, or falls back; one that only logs, or calls something stateward cannot judge, and carries on is reported.",
|
|
573
|
-
defaultSeverity: "warning",
|
|
574
|
-
tier: "addon",
|
|
575
|
-
addon: "catch-intent",
|
|
576
|
-
group: GROUP,
|
|
577
|
-
heuristic: true,
|
|
578
|
-
options: OPTIONS,
|
|
579
|
-
explain: EXPLAIN,
|
|
580
|
-
},
|
|
581
|
-
check(ctx) {
|
|
582
|
-
const visit = (node) => {
|
|
583
|
-
if (ts.isCatchClause(node))
|
|
584
|
-
checkCatch(node, ctx);
|
|
585
|
-
ts.forEachChild(node, visit);
|
|
586
|
-
};
|
|
587
|
-
visit(ctx.sourceFile);
|
|
588
|
-
},
|
|
589
|
-
});
|
|
590
|
-
//# sourceMappingURL=catch-without-intent.js.map
|