@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.
- package/LICENSE +21 -0
- package/README.md +20 -2
- package/dist/async-handler-unhandled.d.ts +7 -0
- package/dist/async-handler-unhandled.js +342 -0
- package/dist/catch-hides-unexpected.d.ts +6 -0
- package/dist/catch-hides-unexpected.js +260 -0
- package/dist/catch-misses-error.d.ts +6 -0
- package/dist/catch-misses-error.js +208 -0
- package/dist/catch-without-intent.d.ts +10 -0
- package/dist/catch-without-intent.js +590 -0
- package/dist/exhaustive-catch.d.ts +6 -0
- package/dist/exhaustive-catch.js +190 -0
- package/dist/exhaustive-fallback.d.ts +6 -0
- package/dist/exhaustive-fallback.js +157 -0
- package/dist/finally-can-throw.d.ts +6 -0
- package/dist/finally-can-throw.js +184 -0
- package/dist/finally-may-throw.d.ts +6 -0
- package/dist/finally-may-throw.js +184 -0
- package/dist/hover.d.ts +11 -0
- package/dist/hover.js +62 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +2 -0
- package/dist/inference/builtins.d.ts +52 -0
- package/dist/inference/builtins.js +179 -0
- package/dist/inference/catches.d.ts +9 -0
- package/dist/inference/catches.js +33 -0
- package/dist/inference/deps.d.ts +12 -0
- package/dist/inference/deps.js +79 -0
- package/dist/inference/format.d.ts +25 -0
- package/dist/inference/format.js +72 -0
- package/dist/inference/packages.d.ts +40 -0
- package/dist/inference/packages.js +723 -0
- package/dist/inference/project.d.ts +8 -0
- package/dist/inference/project.js +22 -0
- package/dist/inference/solve.d.ts +128 -0
- package/dist/inference/solve.js +575 -0
- package/dist/inference/summarize.d.ts +50 -0
- package/dist/inference/summarize.js +1324 -0
- package/dist/inference/summary.d.ts +191 -0
- package/dist/inference/summary.js +237 -0
- package/dist/inlay.d.ts +19 -0
- package/dist/inlay.js +168 -0
- package/dist/must-handle-error.d.ts +55 -0
- package/dist/must-handle-error.js +794 -0
- package/dist/narrow-try.d.ts +52 -0
- package/dist/narrow-try.js +641 -0
- package/dist/no-swallow.d.ts +8 -0
- package/dist/no-swallow.js +320 -0
- package/dist/plugin.d.ts +116 -0
- package/dist/plugin.js +39 -0
- package/dist/react/boundaries.d.ts +83 -0
- package/dist/react/boundaries.js +371 -0
- package/dist/react/fallbacks.d.ts +12 -0
- package/dist/react/fallbacks.js +83 -0
- package/dist/react/render-guards.d.ts +24 -0
- package/dist/react/render-guards.js +271 -0
- package/dist/react/tree.d.ts +98 -0
- package/dist/react/tree.js +863 -0
- package/dist/refactors.d.ts +15 -0
- package/dist/refactors.js +69 -0
- package/dist/render-throw-without-boundary.d.ts +11 -0
- package/dist/render-throw-without-boundary.js +223 -0
- package/dist/render-throws-without-boundary.d.ts +6 -0
- package/dist/render-throws-without-boundary.js +151 -0
- package/dist/result-ignored.d.ts +6 -0
- package/dist/result-ignored.js +167 -0
- package/dist/result-type.d.ts +18 -0
- package/dist/result-type.js +55 -0
- package/dist/result-unchecked.d.ts +2 -0
- package/dist/result-unchecked.js +746 -0
- package/dist/rethrow-unknown.d.ts +6 -0
- package/dist/rethrow-unknown.js +260 -0
- package/dist/rules.meta.d.ts +214 -0
- package/dist/rules.meta.js +212 -0
- package/dist/syntax.d.ts +41 -0
- package/dist/syntax.js +171 -0
- package/dist/text.d.ts +22 -0
- package/dist/text.js +43 -0
- package/dist/throw-in-hook.d.ts +8 -0
- package/dist/throw-in-hook.js +510 -0
- package/dist/throws-doc-mismatch.d.ts +7 -0
- package/dist/throws-doc-mismatch.js +188 -0
- package/dist/throws-matches-doc.d.ts +7 -0
- package/dist/throws-matches-doc.js +188 -0
- package/package.json +34 -3
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Span, ThrowsSummary } from "./inference/summary.js";
|
|
2
|
+
/** The group the rule belongs to in the docs and in `stateward status`. */
|
|
3
|
+
export declare const GROUP = "catch";
|
|
4
|
+
/** One statement of a `try` block, as far as moving it is concerned. */
|
|
5
|
+
type Line = {
|
|
6
|
+
readonly at: Span;
|
|
7
|
+
/** The statement on one line, for the message. */
|
|
8
|
+
readonly text: string;
|
|
9
|
+
/** The source from the end of the statement before it (or the `{`) to its end: what a move takes. */
|
|
10
|
+
readonly raw: string;
|
|
11
|
+
/**
|
|
12
|
+
* It can be reported: a statement that calls something (an expression, a declaration or a
|
|
13
|
+
* `return`), not a condition, a loop, `break` or `continue`, and not a store dispatch
|
|
14
|
+
* (`dispatch(addNotification(…))`), which only records what happened.
|
|
15
|
+
*/
|
|
16
|
+
readonly reportable: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* The variables it declares or assigns with data (`const plan = await getPlan(id)`,
|
|
19
|
+
* `JSON.parse(text)`): a value that may not have the shape its type says.
|
|
20
|
+
*/
|
|
21
|
+
readonly data: readonly string[];
|
|
22
|
+
/** What its `await`s wait for, outside nested functions. */
|
|
23
|
+
readonly awaits: readonly Span[];
|
|
24
|
+
/** The first thing it awaits, as written (`promise`), or null. */
|
|
25
|
+
readonly awaited: string | null;
|
|
26
|
+
/** The names it declares in the block. */
|
|
27
|
+
readonly declares: readonly string[];
|
|
28
|
+
/** Every name it mentions. */
|
|
29
|
+
readonly uses: readonly string[];
|
|
30
|
+
};
|
|
31
|
+
type TryShape = {
|
|
32
|
+
/** The `try` statement's start and end. */
|
|
33
|
+
readonly at: Span;
|
|
34
|
+
/** The `catch` tests the error or rethrows it: bugs already get through. */
|
|
35
|
+
readonly passesBugsOn: boolean;
|
|
36
|
+
/** The `catch` block ends with `return` or `throw`. */
|
|
37
|
+
readonly catchExits: boolean;
|
|
38
|
+
readonly hasFinally: boolean;
|
|
39
|
+
/** The indentation of the `try` line, and of the lines inside its block. */
|
|
40
|
+
readonly indent: string;
|
|
41
|
+
readonly inner: string;
|
|
42
|
+
readonly lines: readonly Line[];
|
|
43
|
+
/** Names mentioned in the block around the `try`, outside it: a moved declaration must not clash. */
|
|
44
|
+
readonly around: readonly string[];
|
|
45
|
+
};
|
|
46
|
+
export type NarrowTrySummary = {
|
|
47
|
+
readonly throws: ThrowsSummary;
|
|
48
|
+
readonly tries: readonly TryShape[];
|
|
49
|
+
};
|
|
50
|
+
export declare const narrowTry: import("@stateward/core").ProjectRule<NarrowTrySummary, Readonly<Record<string, never>>, "catch">;
|
|
51
|
+
export {};
|
|
52
|
+
//# sourceMappingURL=narrow-try.d.ts.map
|
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
import ts from "@stateward/core/typescript";
|
|
2
|
+
import { defineRule } from "@stateward/core";
|
|
3
|
+
import { summarizeOnce } from "./inference/project.js";
|
|
4
|
+
import { REASON } from "./inference/summarize.js";
|
|
5
|
+
import { analyse, collected, evaluate } from "./inference/solve.js";
|
|
6
|
+
import { each, fieldsOf, isString, readStrings, spanOf } from "./inference/summary.js";
|
|
7
|
+
import { throwsOf } from "./react/boundaries.js";
|
|
8
|
+
import { handsOnCaught, isFunctionBoundary, someRunning, stripParentheses, testsCaught, } from "./syntax.js";
|
|
9
|
+
import { fitted, oneLine, shorten } from "./text.js";
|
|
10
|
+
/** The group the rule belongs to in the docs and in `stateward status`. */
|
|
11
|
+
export const GROUP = "catch";
|
|
12
|
+
const EXPLAIN = `# errors/narrow-try
|
|
13
|
+
|
|
14
|
+
Lines after the call a \`try\` is for, which call your own code, belong after the \`try\`.
|
|
15
|
+
|
|
16
|
+
## Why
|
|
17
|
+
|
|
18
|
+
\`\`\`ts
|
|
19
|
+
try {
|
|
20
|
+
const plan = await getPlan(id);
|
|
21
|
+
openEditor(plan);
|
|
22
|
+
} catch (e) {
|
|
23
|
+
showError("Could not load the plan");
|
|
24
|
+
}
|
|
25
|
+
\`\`\`
|
|
26
|
+
|
|
27
|
+
The \`catch\` is for a failed \`getPlan\`. When \`openEditor\` has a bug (it reads \`plan.details.title\` and a plan comes back without \`details\`), the \`TypeError\` is handled like a failed request: the user sees "Could not load the plan", the console shows nothing useful, and the bug is never reported.
|
|
28
|
+
|
|
29
|
+
## When to use it
|
|
30
|
+
|
|
31
|
+
Use it to review how wide your \`try\` blocks are. It is an add-on, off until you list it: in some code styles most \`try\` blocks hold the whole success path on purpose, and then it is noise. It needs to know which call in the \`try\` can fail as expected, so it works best where your functions throw classes or document \`@throws\`, and with packages stateward knows (\`stateward deps\`).
|
|
32
|
+
|
|
33
|
+
## What it catches
|
|
34
|
+
|
|
35
|
+
A \`try\` whose last statements come after the last call that can fail as expected, and can themselves fail only as bugs. A call can fail as expected when stateward knows an error class it throws (\`throw new RequestError(…)\` in your code, \`@throws\`, a built-in like \`JSON.parse\` or \`new URL\`), when it is awaited and calls a package whose errors are not all known, or when it is awaited and its rejection is not known to be nothing. The message names that call, the request the \`try\` is for. A call of a package that is not awaited (\`toast.success(…)\`) is not one, and with no such call the rule reports nothing.
|
|
36
|
+
|
|
37
|
+
Of the lines after it, the first that calls your own code is reported: a function of the project given data the \`try\` got (\`openEditor(plan)\`), or a callback the function was given (\`onClose()\`). Data is what an \`await\` gives, and what is read from it (unless declared \`unknown\`, which TypeScript makes you test first); what \`JSON.parse\` or \`await response.json()\` gives is not, since a value of the wrong shape is the same failure as bad JSON, which the \`catch\` is for (a callback given it, \`onEvent(event)\`, is still reported).
|
|
38
|
+
|
|
39
|
+
### Bad
|
|
40
|
+
|
|
41
|
+
\`\`\`ts
|
|
42
|
+
try {
|
|
43
|
+
await savePlan(plan);
|
|
44
|
+
closeEditor();
|
|
45
|
+
onSaved();
|
|
46
|
+
} catch (e) {
|
|
47
|
+
showError(e);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
\`\`\`
|
|
51
|
+
|
|
52
|
+
### Good
|
|
53
|
+
|
|
54
|
+
\`\`\`ts
|
|
55
|
+
try {
|
|
56
|
+
await savePlan(plan);
|
|
57
|
+
} catch (e) {
|
|
58
|
+
showError(e);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
closeEditor();
|
|
62
|
+
onSaved();
|
|
63
|
+
\`\`\`
|
|
64
|
+
|
|
65
|
+
### Fix
|
|
66
|
+
|
|
67
|
+
The quick fix moves the lines after the \`try\`. It is offered only when the move keeps what runs the same for every outcome but the bug: the \`catch\` ends with \`return\` or \`throw\`, there is no \`finally\`, and the moved lines use nothing declared in the \`try\`. Where they use the result (\`plan\` above), declare it before the \`try\`, assign it inside, and return from the \`catch\`:
|
|
68
|
+
|
|
69
|
+
\`\`\`ts
|
|
70
|
+
let plan: Plan;
|
|
71
|
+
try {
|
|
72
|
+
plan = await getPlan(id);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
showError("Could not load the plan");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
openEditor(plan);
|
|
78
|
+
\`\`\`
|
|
79
|
+
|
|
80
|
+
## What it does not catch
|
|
81
|
+
|
|
82
|
+
- Lines that only read, test or default what the \`try\` got (\`setTitle(plan.title)\`, \`if ("items" in data)\`, \`data.items ?? []\`), \`break\` and \`continue\`, store dispatches (\`dispatch(addNotification(…))\`), and a promise's own \`resolve(…)\`: most of those are written inside the \`try\` on purpose, and reporting them was noise.
|
|
83
|
+
- A \`catch\` that tests the error (\`instanceof\`, \`e.name === …\`, a type guard) or rethrows it: it already lets bugs through.
|
|
84
|
+
- A \`try\` where nothing is known to fail as expected: every call in it unknown and not awaited.
|
|
85
|
+
- Lines after the expected call that only use built-ins (\`seen.set(id, host)\`, \`url.searchParams.get("t")\`), or cannot fail (\`return true;\`).
|
|
86
|
+
- A \`catch\` that hands the error to \`reject(e)\` or another callback it was given: the bug reaches the caller.
|
|
87
|
+
- Code in a callback written inside the \`try\` counts as the callback's call: when stateward cannot tell what it throws, it assumes the worst and stays quiet.
|
|
88
|
+
|
|
89
|
+
A limit: an awaited result of your own function (\`result.success\` after \`await convert(…)\`) counts as data too; the rule cannot tell a typed result from a response.
|
|
90
|
+
|
|
91
|
+
ESLint, typescript-eslint, eslint-plugin-unicorn and Biome have no rule about the width of a \`try\`: their catch rules are about empty catches (\`no-empty\`, \`noEmptyBlockStatements\`), catches that only rethrow (\`no-useless-catch\`, \`noUselessCatch\`) and the catch variable (\`prefer-optional-catch-binding\`, \`catch-error-name\`, \`use-unknown-in-catch-callback-variable\`).
|
|
92
|
+
|
|
93
|
+
## Turn it on or off
|
|
94
|
+
|
|
95
|
+
Turn it on in \`stateward.config.ts\`:
|
|
96
|
+
|
|
97
|
+
\`\`\`ts
|
|
98
|
+
addons: ["errors/narrow-try"]
|
|
99
|
+
\`\`\`
|
|
100
|
+
|
|
101
|
+
Then change it, or turn it off in one folder:
|
|
102
|
+
|
|
103
|
+
\`\`\`ts
|
|
104
|
+
rules: { "errors/narrow-try": "off" } // or "error"
|
|
105
|
+
overrides: [{ files: ["src/scripts/**"], rules: { "errors/narrow-try": "off" } }] // one folder
|
|
106
|
+
\`\`\`
|
|
107
|
+
`;
|
|
108
|
+
/** A line of code is shown whole up to this length, then cut: the headline stays readable. */
|
|
109
|
+
const LINE_MAX = 60;
|
|
110
|
+
/** The whitespace at the start of the line that holds `position`. */
|
|
111
|
+
function indentationAt(position, text) {
|
|
112
|
+
const lineStart = text.lastIndexOf("\n", position - 1) + 1;
|
|
113
|
+
return /^[ \t]*/.exec(text.slice(lineStart, position))?.[0] ?? "";
|
|
114
|
+
}
|
|
115
|
+
/** Visits `root` without entering nested functions or classes. */
|
|
116
|
+
function running(root, visit) {
|
|
117
|
+
const walk = (node) => {
|
|
118
|
+
if (node !== root && isFunctionBoundary(node))
|
|
119
|
+
return;
|
|
120
|
+
visit(node);
|
|
121
|
+
ts.forEachChild(node, walk);
|
|
122
|
+
};
|
|
123
|
+
walk(root);
|
|
124
|
+
}
|
|
125
|
+
/** Every identifier under `root`, nested functions included, except property names after a dot. */
|
|
126
|
+
function namesIn(root) {
|
|
127
|
+
const names = new Set();
|
|
128
|
+
const walk = (node) => {
|
|
129
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
130
|
+
walk(node.expression);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (ts.isIdentifier(node))
|
|
134
|
+
names.add(node.text);
|
|
135
|
+
ts.forEachChild(node, walk);
|
|
136
|
+
};
|
|
137
|
+
walk(root);
|
|
138
|
+
return [...names].sort();
|
|
139
|
+
}
|
|
140
|
+
function bindingNames(name) {
|
|
141
|
+
if (ts.isIdentifier(name))
|
|
142
|
+
return [name.text];
|
|
143
|
+
return name.elements.flatMap((element) => ts.isOmittedExpression(element) ? [] : bindingNames(element.name));
|
|
144
|
+
}
|
|
145
|
+
function declaredBy(statement) {
|
|
146
|
+
if (ts.isVariableStatement(statement)) {
|
|
147
|
+
return statement.declarationList.declarations.flatMap((one) => bindingNames(one.name));
|
|
148
|
+
}
|
|
149
|
+
if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) &&
|
|
150
|
+
statement.name !== undefined) {
|
|
151
|
+
return [statement.name.text];
|
|
152
|
+
}
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
/** The variable a chain of reads starts from: `plan` in `plan.details.title`. */
|
|
156
|
+
function rootOf(expression) {
|
|
157
|
+
let node = stripParentheses(expression);
|
|
158
|
+
for (;;) {
|
|
159
|
+
if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
|
|
160
|
+
node = stripParentheses(node.expression);
|
|
161
|
+
}
|
|
162
|
+
else if (ts.isCallExpression(node) || ts.isNonNullExpression(node)) {
|
|
163
|
+
node = stripParentheses(node.expression);
|
|
164
|
+
}
|
|
165
|
+
else if (ts.isAwaitExpression(node) || ts.isAsExpression(node)) {
|
|
166
|
+
node = stripParentheses(node.expression);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
return node;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** `dispatch(…)`, `store.dispatch(…)`, `await dispatch(…)`: a store dispatch. */
|
|
174
|
+
function isDispatch(expression) {
|
|
175
|
+
const code = stripParentheses(expression);
|
|
176
|
+
const call = ts.isAwaitExpression(code) ? stripParentheses(code.expression) : code;
|
|
177
|
+
if (!ts.isCallExpression(call))
|
|
178
|
+
return false;
|
|
179
|
+
const callee = stripParentheses(call.expression);
|
|
180
|
+
const name = ts.isPropertyAccessExpression(callee) ? callee.name : callee;
|
|
181
|
+
return ts.isIdentifier(name) && name.text === "dispatch";
|
|
182
|
+
}
|
|
183
|
+
/** See `Line.reportable`. */
|
|
184
|
+
function reportable(statement) {
|
|
185
|
+
if (ts.isExpressionStatement(statement))
|
|
186
|
+
return !isDispatch(statement.expression);
|
|
187
|
+
if (ts.isReturnStatement(statement)) {
|
|
188
|
+
return statement.expression !== undefined && !isDispatch(statement.expression);
|
|
189
|
+
}
|
|
190
|
+
if (ts.isVariableStatement(statement)) {
|
|
191
|
+
return statement.declarationList.declarations.every((one) => one.initializer === undefined || !isDispatch(one.initializer));
|
|
192
|
+
}
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Whether `value` brings data from outside the program: it awaits (a response, a file, a message)
|
|
197
|
+
* or parses JSON, or reads from a variable that holds such data. What a synchronous call returns
|
|
198
|
+
* (`statSync(file)`, `new URL(…)`, a function of the project) has the shape its type says.
|
|
199
|
+
*/
|
|
200
|
+
function bringsData(value, data) {
|
|
201
|
+
// What `JSON.parse` gives has no checked shape either, but a wrong shape is the failure the
|
|
202
|
+
// `catch` is there for, like bad JSON: reading it is not a bug-only line (real-world corpus).
|
|
203
|
+
if (isJsonParse(unwrapped(value)) || isBodyJson(unwrapped(value)))
|
|
204
|
+
return false;
|
|
205
|
+
const root = rootOf(value);
|
|
206
|
+
if (ts.isIdentifier(root) && data.has(root.text))
|
|
207
|
+
return true;
|
|
208
|
+
return someRunning(value, (node) => ts.isAwaitExpression(node));
|
|
209
|
+
}
|
|
210
|
+
/** `JSON.parse(…)`. */
|
|
211
|
+
function isJsonParse(node) {
|
|
212
|
+
if (!ts.isCallExpression(node))
|
|
213
|
+
return false;
|
|
214
|
+
const callee = stripParentheses(node.expression);
|
|
215
|
+
return (ts.isPropertyAccessExpression(callee) &&
|
|
216
|
+
callee.name.text === "parse" &&
|
|
217
|
+
ts.isIdentifier(callee.expression) &&
|
|
218
|
+
callee.expression.text === "JSON");
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* `await response.json()`: a response body parsed as JSON, the same as `JSON.parse` (real-world corpus:
|
|
222
|
+
* outline's API client reads the error body it parsed).
|
|
223
|
+
*/
|
|
224
|
+
function isBodyJson(node) {
|
|
225
|
+
if (!ts.isAwaitExpression(node))
|
|
226
|
+
return false;
|
|
227
|
+
const call = unwrapped(node.expression);
|
|
228
|
+
if (!ts.isCallExpression(call) || call.arguments.length > 0)
|
|
229
|
+
return false;
|
|
230
|
+
const callee = stripParentheses(call.expression);
|
|
231
|
+
return ts.isPropertyAccessExpression(callee) && callee.name.text === "json";
|
|
232
|
+
}
|
|
233
|
+
/** The expression inside parentheses, `as`, `satisfies` and `!`. */
|
|
234
|
+
function unwrapped(node) {
|
|
235
|
+
let current = node;
|
|
236
|
+
for (;;) {
|
|
237
|
+
if (ts.isParenthesizedExpression(current) ||
|
|
238
|
+
ts.isAsExpression(current) ||
|
|
239
|
+
ts.isSatisfiesExpression(current) ||
|
|
240
|
+
ts.isNonNullExpression(current)) {
|
|
241
|
+
current = current.expression;
|
|
242
|
+
}
|
|
243
|
+
else
|
|
244
|
+
return current;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/** The names `statement` declares or assigns with data (`bringsData`); they are added to `data`. */
|
|
248
|
+
function dataOf(statement, data) {
|
|
249
|
+
const assigned = [];
|
|
250
|
+
if (ts.isVariableStatement(statement)) {
|
|
251
|
+
for (const one of statement.declarationList.declarations) {
|
|
252
|
+
// Typed `unknown`, the value must be tested before any read: TypeScript already guards it.
|
|
253
|
+
if (one.initializer !== undefined && one.type?.kind !== ts.SyntaxKind.UnknownKeyword) {
|
|
254
|
+
assigned.push({ names: bindingNames(one.name), value: one.initializer });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
else if (ts.isExpressionStatement(statement)) {
|
|
259
|
+
const expression = stripParentheses(statement.expression);
|
|
260
|
+
if (ts.isBinaryExpression(expression) &&
|
|
261
|
+
expression.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
262
|
+
ts.isIdentifier(expression.left)) {
|
|
263
|
+
assigned.push({ names: [expression.left.text], value: expression.right });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const found = [];
|
|
267
|
+
for (const { names, value } of assigned) {
|
|
268
|
+
if (bringsData(value, data))
|
|
269
|
+
found.push(...names);
|
|
270
|
+
}
|
|
271
|
+
for (const name of found)
|
|
272
|
+
data.add(name);
|
|
273
|
+
return found;
|
|
274
|
+
}
|
|
275
|
+
/** Whether the catch tests what it caught, throws, or hands it to `reject(e)`: a bug gets through. */
|
|
276
|
+
function passesBugsOn(clause) {
|
|
277
|
+
const throws = someRunning(clause.block, (node) => ts.isThrowStatement(node) || (ts.isCallExpression(node) && handsOnCaught(node, clause)));
|
|
278
|
+
return throws || testsCaught(clause);
|
|
279
|
+
}
|
|
280
|
+
function endsWithExit(block) {
|
|
281
|
+
const last = block.statements.at(-1);
|
|
282
|
+
return last !== undefined && (ts.isReturnStatement(last) || ts.isThrowStatement(last));
|
|
283
|
+
}
|
|
284
|
+
function shapeOf(node, sourceFile) {
|
|
285
|
+
const clause = node.catchClause;
|
|
286
|
+
if (clause === undefined)
|
|
287
|
+
return undefined;
|
|
288
|
+
const text = sourceFile.text;
|
|
289
|
+
const lines = [];
|
|
290
|
+
const data = new Set();
|
|
291
|
+
let previousEnd = node.tryBlock.getStart(sourceFile) + 1;
|
|
292
|
+
for (const statement of node.tryBlock.statements) {
|
|
293
|
+
const awaits = [];
|
|
294
|
+
let awaited = null;
|
|
295
|
+
running(statement, (inner) => {
|
|
296
|
+
if (ts.isAwaitExpression(inner)) {
|
|
297
|
+
awaits.push([inner.expression.getStart(sourceFile), inner.expression.getEnd()]);
|
|
298
|
+
awaited ??= shorten(oneLine(inner.expression.getText(sourceFile)), LINE_MAX);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
lines.push({
|
|
302
|
+
at: [statement.getStart(sourceFile), statement.getEnd()],
|
|
303
|
+
text: oneLine(statement.getText(sourceFile)).replace(/;$/, ""),
|
|
304
|
+
raw: text.slice(previousEnd, statement.getEnd()),
|
|
305
|
+
reportable: reportable(statement),
|
|
306
|
+
data: dataOf(statement, data),
|
|
307
|
+
awaits,
|
|
308
|
+
awaited,
|
|
309
|
+
declares: declaredBy(statement),
|
|
310
|
+
uses: namesIn(statement),
|
|
311
|
+
});
|
|
312
|
+
previousEnd = statement.getEnd();
|
|
313
|
+
}
|
|
314
|
+
const parent = node.parent;
|
|
315
|
+
const around = ts.isBlock(parent) || ts.isSourceFile(parent) || ts.isCaseOrDefaultClause(parent)
|
|
316
|
+
? parent.statements.filter((other) => other !== node).flatMap((other) => namesIn(other))
|
|
317
|
+
: [];
|
|
318
|
+
const first = node.tryBlock.statements[0];
|
|
319
|
+
return {
|
|
320
|
+
at: [node.getStart(sourceFile), node.getEnd()],
|
|
321
|
+
passesBugsOn: passesBugsOn(clause),
|
|
322
|
+
catchExits: endsWithExit(clause.block),
|
|
323
|
+
hasFinally: node.finallyBlock !== undefined,
|
|
324
|
+
indent: indentationAt(node.getStart(sourceFile), text),
|
|
325
|
+
inner: first === undefined ? "" : indentationAt(first.getStart(sourceFile), text),
|
|
326
|
+
lines,
|
|
327
|
+
around: [...new Set(around)].sort(),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
function summarizeTries(ctx) {
|
|
331
|
+
const tries = [];
|
|
332
|
+
const visit = (node) => {
|
|
333
|
+
if (ts.isTryStatement(node)) {
|
|
334
|
+
const shape = shapeOf(node, ctx.sourceFile);
|
|
335
|
+
if (shape !== undefined)
|
|
336
|
+
tries.push(shape);
|
|
337
|
+
}
|
|
338
|
+
ts.forEachChild(node, visit);
|
|
339
|
+
};
|
|
340
|
+
visit(ctx.sourceFile);
|
|
341
|
+
return tries;
|
|
342
|
+
}
|
|
343
|
+
function readLine(value) {
|
|
344
|
+
const f = fieldsOf(value);
|
|
345
|
+
const at = spanOf(f?.at);
|
|
346
|
+
const awaits = each(f?.awaits, spanOf);
|
|
347
|
+
const declares = readStrings(f?.declares);
|
|
348
|
+
const uses = readStrings(f?.uses);
|
|
349
|
+
if (f === undefined || at === undefined || awaits === undefined)
|
|
350
|
+
return undefined;
|
|
351
|
+
const data = readStrings(f.data);
|
|
352
|
+
if (declares === undefined || uses === undefined || data === undefined)
|
|
353
|
+
return undefined;
|
|
354
|
+
const awaited = f.awaited === null ? null : isString(f.awaited) ? f.awaited : undefined;
|
|
355
|
+
const { reportable: canReport } = f;
|
|
356
|
+
return isString(f.text) &&
|
|
357
|
+
isString(f.raw) &&
|
|
358
|
+
awaited !== undefined &&
|
|
359
|
+
typeof canReport === "boolean"
|
|
360
|
+
? { at, text: f.text, raw: f.raw, reportable: canReport, data, awaits, awaited, declares, uses }
|
|
361
|
+
: undefined;
|
|
362
|
+
}
|
|
363
|
+
function readShape(value) {
|
|
364
|
+
const f = fieldsOf(value);
|
|
365
|
+
const at = spanOf(f?.at);
|
|
366
|
+
const lines = each(f?.lines, readLine);
|
|
367
|
+
const around = readStrings(f?.around);
|
|
368
|
+
if (f === undefined || at === undefined || lines === undefined || around === undefined) {
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
const { passesBugsOn: passes, catchExits, hasFinally, indent, inner } = f;
|
|
372
|
+
if (typeof passes !== "boolean" ||
|
|
373
|
+
typeof catchExits !== "boolean" ||
|
|
374
|
+
typeof hasFinally !== "boolean" ||
|
|
375
|
+
!isString(indent) ||
|
|
376
|
+
!isString(inner)) {
|
|
377
|
+
return undefined;
|
|
378
|
+
}
|
|
379
|
+
return { at, passesBugsOn: passes, catchExits, hasFinally, indent, inner, lines, around };
|
|
380
|
+
}
|
|
381
|
+
function readTries(value) {
|
|
382
|
+
return each(fieldsOf(value)?.tries, readShape);
|
|
383
|
+
}
|
|
384
|
+
function labelOf(effect) {
|
|
385
|
+
switch (effect.k) {
|
|
386
|
+
case "class":
|
|
387
|
+
return effect.text ?? "throw";
|
|
388
|
+
case "call":
|
|
389
|
+
case "package":
|
|
390
|
+
case "unknown":
|
|
391
|
+
case "maybe":
|
|
392
|
+
return `${effect.text}(…)`;
|
|
393
|
+
case "rethrow":
|
|
394
|
+
return "throw";
|
|
395
|
+
case "try":
|
|
396
|
+
return "try";
|
|
397
|
+
default: {
|
|
398
|
+
const unreachable = effect;
|
|
399
|
+
return unreachable;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
/** Every effect of the file, each judged on its own, with the `try` it sits in. */
|
|
404
|
+
function spotsOf(analysis, file, effects, out) {
|
|
405
|
+
const context = {
|
|
406
|
+
file,
|
|
407
|
+
classes: analysis.classes,
|
|
408
|
+
resultOf: (id) => analysis.results.get(id),
|
|
409
|
+
};
|
|
410
|
+
const visit = (list, parent) => {
|
|
411
|
+
for (const effect of list) {
|
|
412
|
+
if (effect.k === "rethrow") {
|
|
413
|
+
out.push({
|
|
414
|
+
at: effect.at,
|
|
415
|
+
parent,
|
|
416
|
+
label: "throw",
|
|
417
|
+
known: true,
|
|
418
|
+
packageOnly: false,
|
|
419
|
+
unknown: false,
|
|
420
|
+
project: null,
|
|
421
|
+
});
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
const into = collected();
|
|
425
|
+
evaluate([effect], context, new Map(), into);
|
|
426
|
+
const fromPackage = [...into.unknowns.values()].some((one) => one.package !== undefined);
|
|
427
|
+
out.push({
|
|
428
|
+
at: effect.at,
|
|
429
|
+
parent,
|
|
430
|
+
label: labelOf(effect),
|
|
431
|
+
known: into.classes.size > 0 || fromPackage,
|
|
432
|
+
packageOnly: into.classes.size === 0 && fromPackage,
|
|
433
|
+
unknown: into.unknowns.size > 0 || into.more,
|
|
434
|
+
project: effect.k === "call"
|
|
435
|
+
? "function"
|
|
436
|
+
: effect.k === "unknown" && effect.reason === REASON.callback
|
|
437
|
+
? "callback"
|
|
438
|
+
: null,
|
|
439
|
+
});
|
|
440
|
+
if (effect.k === "try") {
|
|
441
|
+
visit(effect.body, effect.at);
|
|
442
|
+
if (effect.handler !== null) {
|
|
443
|
+
const { handler } = effect;
|
|
444
|
+
visit(handler.pre, effect.at);
|
|
445
|
+
for (const branch of handler.branches)
|
|
446
|
+
visit(branch.body, effect.at);
|
|
447
|
+
if (handler.otherwise !== null)
|
|
448
|
+
visit(handler.otherwise, effect.at);
|
|
449
|
+
visit(handler.after, effect.at);
|
|
450
|
+
}
|
|
451
|
+
visit(effect.fin, effect.at);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
visit(effects, undefined);
|
|
456
|
+
}
|
|
457
|
+
const within = (inner, outer) => inner[0] >= outer[0] && inner[1] <= outer[1];
|
|
458
|
+
/** The spots of `line` that are its own: not inside a `try` that is itself inside the line. */
|
|
459
|
+
function spotsIn(line, spots) {
|
|
460
|
+
return spots.filter((spot) => within(spot.at, line.at) && (spot.parent === undefined || !within(spot.parent, line.at)));
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* What can fail as expected in `line`: a spot with a known error, or one stateward cannot follow
|
|
464
|
+
* that the line awaits (a rejection not known to be nothing). The last one, by position.
|
|
465
|
+
*/
|
|
466
|
+
function expectedIn(line, spots) {
|
|
467
|
+
const awaited = (spot) => line.awaits.some((span) => within(spot.at, span));
|
|
468
|
+
// A call of a package that is not awaited (`toast.success(…)`) is not the request the `try` is for.
|
|
469
|
+
const found = spotsIn(line, spots).filter((spot) => spot.packageOnly ? awaited(spot) : spot.known || (spot.unknown && awaited(spot)));
|
|
470
|
+
// By where it ends: `JSON.parse(readFileSync(…))` is named, not the call inside it.
|
|
471
|
+
found.sort((a, b) => a.at[1] - b.at[1] || b.at[0] - a.at[0]);
|
|
472
|
+
const last = found.at(-1);
|
|
473
|
+
if (last !== undefined)
|
|
474
|
+
return last;
|
|
475
|
+
// An `await` of a value the analysis has no leaf for at all (`await promise`): a rejection.
|
|
476
|
+
return line.awaits.length > 0 && spotsIn(line, spots).length === 0
|
|
477
|
+
? {
|
|
478
|
+
at: line.at,
|
|
479
|
+
parent: undefined,
|
|
480
|
+
label: line.awaited ?? "await",
|
|
481
|
+
known: false,
|
|
482
|
+
packageOnly: false,
|
|
483
|
+
unknown: true,
|
|
484
|
+
project: null,
|
|
485
|
+
}
|
|
486
|
+
: undefined;
|
|
487
|
+
}
|
|
488
|
+
/** A line with nothing known to fail as expected in it. */
|
|
489
|
+
function bugOnly(line, spots) {
|
|
490
|
+
return (line.awaits.length === 0 &&
|
|
491
|
+
spotsIn(line, spots).every((spot) => !spot.known || spot.packageOnly));
|
|
492
|
+
}
|
|
493
|
+
/** The move of `moved` after the `try`, one level less indented; undefined when it is not safe. */
|
|
494
|
+
function moveFix(shape, kept, moved, file) {
|
|
495
|
+
if (!shape.catchExits || shape.hasFinally || shape.inner === "")
|
|
496
|
+
return undefined;
|
|
497
|
+
const keptNames = new Set(kept.flatMap((line) => line.declares));
|
|
498
|
+
if (moved.some((line) => line.uses.some((name) => keptNames.has(name))))
|
|
499
|
+
return undefined;
|
|
500
|
+
const movedNames = moved.flatMap((line) => line.declares);
|
|
501
|
+
if (movedNames.some((name) => shape.around.includes(name)))
|
|
502
|
+
return undefined;
|
|
503
|
+
const lastKept = kept.at(-1);
|
|
504
|
+
const lastMoved = moved.at(-1);
|
|
505
|
+
if (lastKept === undefined || lastMoved === undefined)
|
|
506
|
+
return undefined;
|
|
507
|
+
const raw = moved.map((line) => line.raw).join("");
|
|
508
|
+
const dedented = raw.split(`\n${shape.inner}`).join(`\n${shape.indent}`);
|
|
509
|
+
const fix = {
|
|
510
|
+
title: moved.length === 1 ? "Move this line after the `try`" : "Move these lines after the `try`",
|
|
511
|
+
edits: [
|
|
512
|
+
{ location: { file, start: lastKept.at[1], end: lastMoved.at[1] }, newText: "" },
|
|
513
|
+
{ location: { file, start: shape.at[1], end: shape.at[1] }, newText: dedented },
|
|
514
|
+
],
|
|
515
|
+
};
|
|
516
|
+
return fix;
|
|
517
|
+
}
|
|
518
|
+
/** A line as code in a message: cut before a backtick of its own, which would end the code span. */
|
|
519
|
+
function asCode(text) {
|
|
520
|
+
const tick = text.indexOf("`");
|
|
521
|
+
const shown = shorten(text, LINE_MAX);
|
|
522
|
+
return tick < 0 || tick >= shown.length ? shown : `${text.slice(0, tick).trimEnd()} …`;
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* How the message names what the `try` is for. A `throw` names the thrown expression with its
|
|
526
|
+
* arguments cut (`throw new AccessError(…)`), not the keyword alone.
|
|
527
|
+
*/
|
|
528
|
+
function nameOf(spot, line) {
|
|
529
|
+
if (spot.label !== "throw" || line === undefined)
|
|
530
|
+
return spot.label;
|
|
531
|
+
const thrown = /\bthrow\s+(.+)$/.exec(line.text)?.[1];
|
|
532
|
+
if (thrown === undefined)
|
|
533
|
+
return spot.label;
|
|
534
|
+
const open = thrown.indexOf("(");
|
|
535
|
+
return `throw ${open < 0 ? thrown : `${thrown.slice(0, open)}(…)`}`;
|
|
536
|
+
}
|
|
537
|
+
function headline(line, expected) {
|
|
538
|
+
return fitted((cut) => `\`${asCode(cut(line))}\` is inside the \`try\` meant for \`${cut(expected)}\``);
|
|
539
|
+
}
|
|
540
|
+
function hint(expected, fixable, count) {
|
|
541
|
+
const lines = count === 1 ? "it" : "these lines";
|
|
542
|
+
const start = `a bug here would be handled like a failed \`${expected}\`; move ${lines} after the \`try\``;
|
|
543
|
+
if (fixable)
|
|
544
|
+
return `${start} (quick fix)`;
|
|
545
|
+
return `${start} and return from the \`catch\` (declare what ${count === 1 ? "it uses" : "they use"} before the \`try\`)`;
|
|
546
|
+
}
|
|
547
|
+
/** A promise's own `resolve` and `reject` (excalidraw): they never throw. */
|
|
548
|
+
const SETTLES = new Set(["resolve(…)", "reject(…)"]);
|
|
549
|
+
function checkTry(shape, spots, file, report) {
|
|
550
|
+
if (shape.passesBugsOn)
|
|
551
|
+
return;
|
|
552
|
+
let lastExpected = -1;
|
|
553
|
+
let expected;
|
|
554
|
+
for (const [index, line] of shape.lines.entries()) {
|
|
555
|
+
const found = expectedIn(line, spots);
|
|
556
|
+
if (found !== undefined) {
|
|
557
|
+
lastExpected = index;
|
|
558
|
+
expected = found;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (expected === undefined)
|
|
562
|
+
return;
|
|
563
|
+
const target = nameOf(expected, shape.lines[lastExpected]);
|
|
564
|
+
const kept = shape.lines.slice(0, lastExpected + 1);
|
|
565
|
+
const moved = shape.lines.slice(lastExpected + 1);
|
|
566
|
+
if (!moved.every((line) => bugOnly(line, spots)))
|
|
567
|
+
return;
|
|
568
|
+
// A line is reported when it hands the data the `try` got to a function of the project, or
|
|
569
|
+
// calls a callback it was given (`onClose()`): code whose bugs the `catch` would hide. Plain
|
|
570
|
+
// reads, conditions, defaults (`?? []`) and store dispatches are left alone: on a large app (111 warnings) nearly all of those were noise.
|
|
571
|
+
const data = new Set(shape.lines.flatMap((line) => line.data));
|
|
572
|
+
const usesData = (line) => line.uses.some((name) => data.has(name));
|
|
573
|
+
const first = moved.find((line) => line.reportable &&
|
|
574
|
+
spotsIn(line, spots).some((spot) => (spot.project === "callback" && !SETTLES.has(spot.label)) ||
|
|
575
|
+
(spot.project === "function" && usesData(line))));
|
|
576
|
+
if (first === undefined)
|
|
577
|
+
return;
|
|
578
|
+
const fix = moveFix(shape, kept, moved, file);
|
|
579
|
+
report({
|
|
580
|
+
message: headline(first.text, target),
|
|
581
|
+
hint: hint(target, fix !== undefined, moved.length),
|
|
582
|
+
at: first.at,
|
|
583
|
+
fixes: fix === undefined ? [] : [fix],
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
export const narrowTry = defineRule({
|
|
587
|
+
kind: "project",
|
|
588
|
+
meta: {
|
|
589
|
+
description: "Calls to your own code placed in a `try` after the call it is for: move them after the `try`.",
|
|
590
|
+
defaultSeverity: "warning",
|
|
591
|
+
tier: "addon",
|
|
592
|
+
addon: "narrow-try",
|
|
593
|
+
group: GROUP,
|
|
594
|
+
needsJsDoc: true,
|
|
595
|
+
explain: EXPLAIN,
|
|
596
|
+
},
|
|
597
|
+
summarize: (ctx) => ({
|
|
598
|
+
throws: summarizeOnce(ctx),
|
|
599
|
+
tries: summarizeTries(ctx),
|
|
600
|
+
}),
|
|
601
|
+
check(summaries, ctx) {
|
|
602
|
+
const analysis = analyse(throwsOf(summaries));
|
|
603
|
+
const spotsByFile = new Map();
|
|
604
|
+
const spotsFor = (file) => {
|
|
605
|
+
const known = spotsByFile.get(file);
|
|
606
|
+
if (known !== undefined)
|
|
607
|
+
return known;
|
|
608
|
+
const spots = [];
|
|
609
|
+
for (const entry of analysis.functions.values()) {
|
|
610
|
+
if (entry.file !== file)
|
|
611
|
+
continue;
|
|
612
|
+
spotsOf(analysis, file, entry.fact.body, spots);
|
|
613
|
+
spotsOf(analysis, file, entry.fact.returns, spots);
|
|
614
|
+
}
|
|
615
|
+
for (const module of analysis.modules) {
|
|
616
|
+
if (module.file === file)
|
|
617
|
+
spotsOf(analysis, file, module.effects, spots);
|
|
618
|
+
}
|
|
619
|
+
spotsByFile.set(file, spots);
|
|
620
|
+
return spots;
|
|
621
|
+
};
|
|
622
|
+
for (const file of [...summaries.keys()].sort()) {
|
|
623
|
+
const value = summaries.get(file);
|
|
624
|
+
const tries = value === undefined ? undefined : readTries(value);
|
|
625
|
+
if (tries === undefined || tries.length === 0)
|
|
626
|
+
continue;
|
|
627
|
+
const spots = spotsFor(file);
|
|
628
|
+
for (const shape of tries) {
|
|
629
|
+
checkTry(shape, spots, file, (problem) => {
|
|
630
|
+
ctx.report({
|
|
631
|
+
message: problem.message,
|
|
632
|
+
hint: problem.hint,
|
|
633
|
+
location: { file, start: problem.at[0], end: problem.at[1] },
|
|
634
|
+
fixes: problem.fixes,
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
//# sourceMappingURL=narrow-try.js.map
|