@stateward/plugin-errors 1.0.0 → 1.0.1
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 +61 -3
- package/package.json +2 -2
- package/dist/async-handler-unhandled.d.ts +0 -7
- package/dist/async-handler-unhandled.js +0 -342
- package/dist/exhaustive-catch.d.ts +0 -6
- package/dist/exhaustive-catch.js +0 -190
- package/dist/exhaustive-fallback.d.ts +0 -6
- package/dist/exhaustive-fallback.js +0 -157
- package/dist/finally-may-throw.d.ts +0 -6
- package/dist/finally-may-throw.js +0 -184
- package/dist/no-swallow.d.ts +0 -8
- package/dist/no-swallow.js +0 -320
- package/dist/render-throws-without-boundary.d.ts +0 -6
- package/dist/render-throws-without-boundary.js +0 -151
- package/dist/rethrow-unknown.d.ts +0 -6
- package/dist/rethrow-unknown.js +0 -260
- package/dist/throw-in-hook.d.ts +0 -8
- package/dist/throw-in-hook.js +0 -510
- package/dist/throws-matches-doc.d.ts +0 -7
- package/dist/throws-matches-doc.js +0 -188
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
import { defineRule, matchesName, option } from "@stateward/core";
|
|
2
|
-
import { catchName, className } from "./inference/format.js";
|
|
3
|
-
import { relation } from "./inference/solve.js";
|
|
4
|
-
import { modelOf, summarizeReact } from "./react/boundaries.js";
|
|
5
|
-
import { HEADLINE_MAX, codeList, fitted, withExample } from "./text.js";
|
|
6
|
-
/** The group the rule belongs to in the docs and in `stateward status`. */
|
|
7
|
-
export const GROUP = "react";
|
|
8
|
-
const EXPLAIN = `# errors/exhaustive-fallback
|
|
9
|
-
|
|
10
|
-
An error boundary's fallback that branches on the error with instanceof handles every error class the boundary can catch, or has a branch for the rest.
|
|
11
|
-
|
|
12
|
-
## Why
|
|
13
|
-
|
|
14
|
-
A fallback that checks \`error instanceof NotFoundError\` says which errors it expects. When a page under the same boundary can also throw \`ParseError\`, and the fallback ends with \`return null\`, the user gets an empty screen with no message and no way back. This rule lists the classes the boundary can catch that no branch names.
|
|
15
|
-
|
|
16
|
-
## When to use it
|
|
17
|
-
|
|
18
|
-
Use it with error boundaries whose fallback shows something different per error class. It is a core rule, a warning in every preset; a fallback that does not branch on \`instanceof\` is left alone.
|
|
19
|
-
|
|
20
|
-
## What it catches
|
|
21
|
-
|
|
22
|
-
A fallback that branches with \`if\` / \`else if\`, \`switch (true)\` or \`? :\` on \`error instanceof X\`, has no branch for any other error, and belongs to a boundary that can catch a class no branch names. The fallback is react-error-boundary's \`FallbackComponent\`, \`fallbackRender\` or \`onError\`, or a boundary class's \`getDerivedStateFromError\`. What the boundary can catch is what the components below it throw while rendering and in their effects, down to the next boundary (the hover on the boundary lists it).
|
|
23
|
-
|
|
24
|
-
A branch for the rest is a final \`else\` or \`default\`, or code after the chain, that does more than return \`null\` or nothing.
|
|
25
|
-
|
|
26
|
-
### Bad
|
|
27
|
-
|
|
28
|
-
\`\`\`tsx
|
|
29
|
-
// SettingsPage, under this boundary, can throw ParseError.
|
|
30
|
-
function UserErrorPage({ error }: FallbackProps) {
|
|
31
|
-
if (error instanceof NotFoundError) return <p>This user does not exist.</p>;
|
|
32
|
-
return null;
|
|
33
|
-
}
|
|
34
|
-
\`\`\`
|
|
35
|
-
|
|
36
|
-
### Good
|
|
37
|
-
|
|
38
|
-
\`\`\`tsx
|
|
39
|
-
function UserErrorPage({ error }: FallbackProps) {
|
|
40
|
-
if (error instanceof NotFoundError) return <p>This user does not exist.</p>;
|
|
41
|
-
return <p>Something went wrong: {String(error)}</p>;
|
|
42
|
-
}
|
|
43
|
-
\`\`\`
|
|
44
|
-
|
|
45
|
-
### Fix
|
|
46
|
-
|
|
47
|
-
Add a branch for each class the message lists, or end the chain with a fallback for any other error. There is no quick fix.
|
|
48
|
-
|
|
49
|
-
## What it does not catch
|
|
50
|
-
|
|
51
|
-
- A fallback that does not branch on \`instanceof\`, or whose tests are not only \`instanceof\` (\`error instanceof A && error.status === 404\`): it does not guess what such a branch handles.
|
|
52
|
-
- Errors stateward cannot see (from a package it cannot read, a callback, a dynamic call): only known classes are checked.
|
|
53
|
-
- Errors from event handlers and async code: a boundary never receives them.
|
|
54
|
-
- A fallback passed as anything other than a function written in place or the name of a function of the project.
|
|
55
|
-
|
|
56
|
-
## Turn it on or off
|
|
57
|
-
|
|
58
|
-
In \`stateward.config.ts\`:
|
|
59
|
-
|
|
60
|
-
\`\`\`ts
|
|
61
|
-
rules: { "errors/exhaustive-fallback": "off" } // or "error"
|
|
62
|
-
groups: { "errors/react": "off" } // the whole group
|
|
63
|
-
rules: { "errors/exhaustive-fallback": ["warning", { ignoreClasses: ["ChunkLoadError"] }] } // never require a branch for these
|
|
64
|
-
\`\`\`
|
|
65
|
-
`;
|
|
66
|
-
/** What the fallback does with any other error, in the example: what it returns or does. */
|
|
67
|
-
function restLine(kind, variable) {
|
|
68
|
-
switch (kind) {
|
|
69
|
-
case "FallbackComponent":
|
|
70
|
-
case "fallbackRender":
|
|
71
|
-
return `return <p>Something went wrong: {String(${variable})}</p>;`;
|
|
72
|
-
case "onError":
|
|
73
|
-
return `console.error(${variable});`;
|
|
74
|
-
case "getDerivedStateFromError":
|
|
75
|
-
return `return { error: ${variable} };`;
|
|
76
|
-
default: {
|
|
77
|
-
const unreachable = kind;
|
|
78
|
-
return unreachable;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
function hint(fallback, chain, handler, count) {
|
|
83
|
-
const line = `add a branch for ${count === 1 ? "it" : "each"}, or end with one for any other error:`;
|
|
84
|
-
const rest = restLine(fallback.kind, chain.variable);
|
|
85
|
-
return chain.dispatch === "switch"
|
|
86
|
-
? withExample(line, [
|
|
87
|
-
`case ${chain.variable} instanceof ${handler}:`,
|
|
88
|
-
" …",
|
|
89
|
-
"default:",
|
|
90
|
-
` ${rest}`,
|
|
91
|
-
])
|
|
92
|
-
: withExample(line, [`if (${chain.variable} instanceof ${handler}) {`, " …", "}", rest]);
|
|
93
|
-
}
|
|
94
|
-
export const exhaustiveFallback = defineRule({
|
|
95
|
-
kind: "project",
|
|
96
|
-
meta: {
|
|
97
|
-
description: "An error boundary's fallback that branches on the error with instanceof handles every error class the boundary can catch, or has a branch for the rest.",
|
|
98
|
-
defaultSeverity: "warning",
|
|
99
|
-
tier: "core",
|
|
100
|
-
group: GROUP,
|
|
101
|
-
needsJsDoc: true,
|
|
102
|
-
options: {
|
|
103
|
-
ignoreClasses: option.strings({
|
|
104
|
-
default: [],
|
|
105
|
-
description: "Error classes a fallback never needs a branch for, as its instanceof test names them (globs allowed).",
|
|
106
|
-
}),
|
|
107
|
-
},
|
|
108
|
-
explain: EXPLAIN,
|
|
109
|
-
},
|
|
110
|
-
summarize: (ctx) => summarizeReact(ctx),
|
|
111
|
-
check(summaries, ctx) {
|
|
112
|
-
const model = modelOf(summaries);
|
|
113
|
-
const classes = model.analysis.classes;
|
|
114
|
-
for (const [key, { file, element }] of model.boundaries) {
|
|
115
|
-
for (const fallback of element.fallbacks) {
|
|
116
|
-
const chain = fallback.chain;
|
|
117
|
-
if (chain === null || chain.generic || !chain.exact)
|
|
118
|
-
continue;
|
|
119
|
-
const handled = [];
|
|
120
|
-
let readable = true;
|
|
121
|
-
for (const branch of chain.branches) {
|
|
122
|
-
if (branch === null)
|
|
123
|
-
readable = false;
|
|
124
|
-
else
|
|
125
|
-
handled.push(...branch);
|
|
126
|
-
}
|
|
127
|
-
if (!readable)
|
|
128
|
-
continue;
|
|
129
|
-
const caught = model.catches(key);
|
|
130
|
-
const missing = [...caught.classes]
|
|
131
|
-
.filter(([id, brought]) => brought.certain &&
|
|
132
|
-
!matchesName(catchName(id, classes), ctx.options.ignoreClasses) &&
|
|
133
|
-
handled.every((one) => relation(one, id, classes) === "no"))
|
|
134
|
-
.map(([id, brought]) => ({ id, brought, name: className(id, classes) }))
|
|
135
|
-
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
136
|
-
const [first] = missing;
|
|
137
|
-
if (first === undefined)
|
|
138
|
-
continue;
|
|
139
|
-
const related = missing.slice(0, 3).map(({ brought, name }) => ({
|
|
140
|
-
location: brought.location,
|
|
141
|
-
message: `${brought.what} \`${name}\``,
|
|
142
|
-
}));
|
|
143
|
-
related.push({
|
|
144
|
-
location: { file: chain.file, start: chain.at[0], end: chain.at[1] },
|
|
145
|
-
message: `\`${fallback.name}\` branches on the error here`,
|
|
146
|
-
});
|
|
147
|
-
ctx.report({
|
|
148
|
-
message: fitted(HEADLINE_MAX, (cut) => `\`${cut(fallback.name)}\` does not handle ${codeList(missing.map((one) => one.name), cut)}, which this boundary can catch`),
|
|
149
|
-
hint: hint(fallback, chain, catchName(first.id, classes), missing.length),
|
|
150
|
-
location: { file, start: fallback.at[0], end: fallback.at[1] },
|
|
151
|
-
related,
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
},
|
|
156
|
-
});
|
|
157
|
-
//# sourceMappingURL=exhaustive-fallback.js.map
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
/** The group the rule belongs to in the docs and in `stateward status`. */
|
|
2
|
-
export declare const GROUP = "finally";
|
|
3
|
-
export declare const finallyMayThrow: import("@stateward/core").FileRule<{
|
|
4
|
-
severities: import("@stateward/core").SeveritiesOption<"throw" | "builtin" | "own-function">;
|
|
5
|
-
}, "finally">;
|
|
6
|
-
//# sourceMappingURL=finally-may-throw.d.ts.map
|
|
@@ -1,184 +0,0 @@
|
|
|
1
|
-
import ts from "@stateward/core/typescript";
|
|
2
|
-
import { defineRule, option } from "@stateward/core";
|
|
3
|
-
import { hasModifier, isBuiltinGlobal, sameFileFunction, scanRunning, stripParentheses, throwsUncaught, } from "./syntax.js";
|
|
4
|
-
import { HEADLINE_MAX, fitted, oneLine, withExample } from "./text.js";
|
|
5
|
-
/** The group the rule belongs to in the docs and in `stateward status`. */
|
|
6
|
-
export const GROUP = "finally";
|
|
7
|
-
const EXPLAIN = `# errors/finally-may-throw
|
|
8
|
-
|
|
9
|
-
A finally block does not call something that throws, so the error that led to it is not lost.
|
|
10
|
-
|
|
11
|
-
## Why
|
|
12
|
-
|
|
13
|
-
When code in a \`finally\` throws, its error replaces the one that sent the code there. An upload that fails with a network error then reports a \`SyntaxError\` from parsing the saved state: the real cause, with its message and stack, is gone, and the rest of the cleanup does not run.
|
|
14
|
-
|
|
15
|
-
## When to use it
|
|
16
|
-
|
|
17
|
-
Use it in any code that cleans up in \`finally\`. It is on as an error in every preset; keep it on. Turn it off only if your cleanup's own errors are meant to win.
|
|
18
|
-
|
|
19
|
-
## What it catches
|
|
20
|
-
|
|
21
|
-
In a \`finally\` block, outside a nested \`try\` that has a \`catch\`:
|
|
22
|
-
|
|
23
|
-
- a call to a built-in that throws: \`JSON.parse\`, \`JSON.stringify\` (on circular values), \`new URL\`, \`decodeURIComponent\`, \`decodeURI\`, \`structuredClone\`, \`atob\`;
|
|
24
|
-
- a \`throw\`;
|
|
25
|
-
- a call to a function in the same file that has a \`throw\` outside its own \`try\` / \`catch\` (an \`async\` one when the call is awaited).
|
|
26
|
-
|
|
27
|
-
One finding per \`finally\`, on the first of these.
|
|
28
|
-
|
|
29
|
-
### Bad
|
|
30
|
-
|
|
31
|
-
\`\`\`ts
|
|
32
|
-
try {
|
|
33
|
-
await upload(file);
|
|
34
|
-
} finally {
|
|
35
|
-
const state = JSON.parse(saved);
|
|
36
|
-
restore(state);
|
|
37
|
-
}
|
|
38
|
-
\`\`\`
|
|
39
|
-
|
|
40
|
-
### Good
|
|
41
|
-
|
|
42
|
-
\`\`\`ts
|
|
43
|
-
try {
|
|
44
|
-
await upload(file);
|
|
45
|
-
} finally {
|
|
46
|
-
try {
|
|
47
|
-
restore(JSON.parse(saved));
|
|
48
|
-
} catch (e) {
|
|
49
|
-
report(e);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
\`\`\`
|
|
53
|
-
|
|
54
|
-
### Fix
|
|
55
|
-
|
|
56
|
-
Move the call out of the \`finally\`, or wrap it in its own \`try\` / \`catch\`. There is no quick fix: what the cleanup should do when it fails is your call.
|
|
57
|
-
|
|
58
|
-
## What it does not catch
|
|
59
|
-
|
|
60
|
-
- Functions from other files, and package calls: only the built-ins above and functions in the same file are read.
|
|
61
|
-
- Other built-ins, such as \`fetch\` or \`BigInt\`.
|
|
62
|
-
- A function that throws only inside its own \`try\` / \`catch\`.
|
|
63
|
-
- Anything inside a nested \`try\` that has a \`catch\`.
|
|
64
|
-
- A second throwing call in the same \`finally\`: only the first is reported.
|
|
65
|
-
|
|
66
|
-
## Turn it on or off
|
|
67
|
-
|
|
68
|
-
In \`stateward.config.ts\`:
|
|
69
|
-
|
|
70
|
-
\`\`\`ts
|
|
71
|
-
rules: { "errors/finally-may-throw": "off" } // or "warning"
|
|
72
|
-
groups: { "errors/finally": "off" } // the whole group
|
|
73
|
-
rules: { "errors/finally-may-throw": ["error", { severities: { builtin: "warning" } }] } // built-ins only warn
|
|
74
|
-
\`\`\`
|
|
75
|
-
`;
|
|
76
|
-
/** Built-in functions that throw on input they cannot handle, as `Owner.name` or `name`. */
|
|
77
|
-
const THROWING_CALLS = new Set([
|
|
78
|
-
"JSON.parse",
|
|
79
|
-
"JSON.stringify",
|
|
80
|
-
"decodeURIComponent",
|
|
81
|
-
"decodeURI",
|
|
82
|
-
"structuredClone",
|
|
83
|
-
"atob",
|
|
84
|
-
]);
|
|
85
|
-
/** Built-in constructors that throw. */
|
|
86
|
-
const THROWING_CONSTRUCTORS = new Set(["URL"]);
|
|
87
|
-
/** `JSON.parse` or `atob`, when the callee is one of the globals in the table. */
|
|
88
|
-
function builtinCall(call, ctx) {
|
|
89
|
-
const callee = stripParentheses(call.expression);
|
|
90
|
-
if (ts.isIdentifier(callee)) {
|
|
91
|
-
return THROWING_CALLS.has(callee.text) && isBuiltinGlobal(callee, ctx)
|
|
92
|
-
? callee.text
|
|
93
|
-
: undefined;
|
|
94
|
-
}
|
|
95
|
-
if (!ts.isPropertyAccessExpression(callee))
|
|
96
|
-
return undefined;
|
|
97
|
-
const owner = stripParentheses(callee.expression);
|
|
98
|
-
if (!ts.isIdentifier(owner))
|
|
99
|
-
return undefined;
|
|
100
|
-
const name = `${owner.text}.${callee.name.text}`;
|
|
101
|
-
return THROWING_CALLS.has(name) && isBuiltinGlobal(owner, ctx) ? name : undefined;
|
|
102
|
-
}
|
|
103
|
-
/** The kinds of finding: they differ in how sure the rule is that the call throws. */
|
|
104
|
-
const KINDS = {
|
|
105
|
-
throw: "a `throw` written in the finally: it throws whenever it runs.",
|
|
106
|
-
builtin: "a built-in such as `JSON.parse` or `new URL`: it throws only on input it cannot handle.",
|
|
107
|
-
"own-function": "a function in the same file with a `throw` in it: it throws on some paths.",
|
|
108
|
-
};
|
|
109
|
-
function causeOf(node, awaited, ctx) {
|
|
110
|
-
if (ts.isThrowStatement(node))
|
|
111
|
-
return { node, subject: () => "this `throw` runs", kind: "throw" };
|
|
112
|
-
if (ts.isNewExpression(node)) {
|
|
113
|
-
const callee = stripParentheses(node.expression);
|
|
114
|
-
if (ts.isIdentifier(callee) &&
|
|
115
|
-
THROWING_CONSTRUCTORS.has(callee.text) &&
|
|
116
|
-
isBuiltinGlobal(callee, ctx)) {
|
|
117
|
-
return { node, subject: () => `\`new ${callee.text}(…)\` throws here`, kind: "builtin" };
|
|
118
|
-
}
|
|
119
|
-
return undefined;
|
|
120
|
-
}
|
|
121
|
-
if (!ts.isCallExpression(node))
|
|
122
|
-
return undefined;
|
|
123
|
-
const builtin = builtinCall(node, ctx);
|
|
124
|
-
if (builtin !== undefined) {
|
|
125
|
-
return { node, subject: () => `\`${builtin}(…)\` throws here`, kind: "builtin" };
|
|
126
|
-
}
|
|
127
|
-
const declared = sameFileFunction(node.expression, ctx);
|
|
128
|
-
if (declared === undefined || !throwsUncaught(declared))
|
|
129
|
-
return undefined;
|
|
130
|
-
// An async function turns its throw into a rejected promise, which only an `await` rethrows.
|
|
131
|
-
if (hasModifier(declared, ts.SyntaxKind.AsyncKeyword) && !awaited)
|
|
132
|
-
return undefined;
|
|
133
|
-
const callee = oneLine(stripParentheses(node.expression).getText(ctx.sourceFile));
|
|
134
|
-
return { node, subject: (cut) => `\`${cut(callee)}(…)\` throws here`, kind: "own-function" };
|
|
135
|
-
}
|
|
136
|
-
function headline(cause) {
|
|
137
|
-
return fitted(HEADLINE_MAX, (cut) => `if ${cause.subject(cut)}, the error that led to this finally is lost`);
|
|
138
|
-
}
|
|
139
|
-
const HINT = withExample("move it out of finally, or catch it inside:", [
|
|
140
|
-
"finally {",
|
|
141
|
-
" try { cleanup(); } catch (e) { report(e); }",
|
|
142
|
-
"}",
|
|
143
|
-
]);
|
|
144
|
-
/** `shown` holds what is already reported: a `finally` inside a `finally` is looked at twice. */
|
|
145
|
-
function checkFinally(block, shown, ctx) {
|
|
146
|
-
let found;
|
|
147
|
-
scanRunning(block, (node, guarded) => {
|
|
148
|
-
if (found !== undefined || guarded)
|
|
149
|
-
return;
|
|
150
|
-
const awaited = ts.isAwaitExpression(node.parent);
|
|
151
|
-
found = causeOf(node, awaited, ctx);
|
|
152
|
-
});
|
|
153
|
-
if (found === undefined || shown.has(found.node))
|
|
154
|
-
return;
|
|
155
|
-
shown.add(found.node);
|
|
156
|
-
ctx.report({ node: found.node, message: headline(found), hint: HINT, findingKind: found.kind });
|
|
157
|
-
}
|
|
158
|
-
export const finallyMayThrow = defineRule({
|
|
159
|
-
kind: "file",
|
|
160
|
-
meta: {
|
|
161
|
-
description: "A finally block does not call something that throws, so the error that led to it is not lost.",
|
|
162
|
-
defaultSeverity: "error",
|
|
163
|
-
tier: "core",
|
|
164
|
-
group: GROUP,
|
|
165
|
-
options: {
|
|
166
|
-
severities: option.severities({
|
|
167
|
-
kinds: KINDS,
|
|
168
|
-
description: "A severity per kind of call, where you trust one kind less.",
|
|
169
|
-
}),
|
|
170
|
-
},
|
|
171
|
-
explain: EXPLAIN,
|
|
172
|
-
},
|
|
173
|
-
check(ctx) {
|
|
174
|
-
const shown = new Set();
|
|
175
|
-
const visit = (node) => {
|
|
176
|
-
if (ts.isTryStatement(node) && node.finallyBlock !== undefined) {
|
|
177
|
-
checkFinally(node.finallyBlock, shown, ctx);
|
|
178
|
-
}
|
|
179
|
-
ts.forEachChild(node, visit);
|
|
180
|
-
};
|
|
181
|
-
visit(ctx.sourceFile);
|
|
182
|
-
},
|
|
183
|
-
});
|
|
184
|
-
//# sourceMappingURL=finally-may-throw.js.map
|
package/dist/no-swallow.d.ts
DELETED
|
@@ -1,8 +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 noSwallow: import("@stateward/core").FileRule<{
|
|
4
|
-
loggers: import("@stateward/core").StringsOption;
|
|
5
|
-
logMethods: import("@stateward/core").StringsOption;
|
|
6
|
-
logFunctions: import("@stateward/core").StringsOption;
|
|
7
|
-
}, "catch">;
|
|
8
|
-
//# sourceMappingURL=no-swallow.d.ts.map
|
package/dist/no-swallow.js
DELETED
|
@@ -1,320 +0,0 @@
|
|
|
1
|
-
import ts from "@stateward/core/typescript";
|
|
2
|
-
import { defineRule, matchesName, option } from "@stateward/core";
|
|
3
|
-
import { withExample } from "./text.js";
|
|
4
|
-
import { stripParentheses } 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/no-swallow
|
|
8
|
-
|
|
9
|
-
A catch that only logs, or does nothing, and then carries on treats a failure as if it had not happened.
|
|
10
|
-
|
|
11
|
-
## Why
|
|
12
|
-
|
|
13
|
-
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.
|
|
14
|
-
|
|
15
|
-
## When to use it
|
|
16
|
-
|
|
17
|
-
Use it for code that must never lose a failure: payments, sync, data import; or to review a module's catches once. 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), then a warning in every preset. Make it an error once that code is clean.
|
|
18
|
-
|
|
19
|
-
## What it catches
|
|
20
|
-
|
|
21
|
-
A \`catch\` that is empty or only logs (\`console.*\`, a call on \`logger\` or \`log\`, or \`.error\`, \`.warn\`, \`.info\`, \`.debug\` on any object; the options below change these lists), when the function carries on after the \`try\`.
|
|
22
|
-
|
|
23
|
-
### Bad
|
|
24
|
-
|
|
25
|
-
\`\`\`ts
|
|
26
|
-
async function load(id: string) {
|
|
27
|
-
let user;
|
|
28
|
-
try {
|
|
29
|
-
user = await fetchUser(id);
|
|
30
|
-
} catch (e) {
|
|
31
|
-
console.error(e);
|
|
32
|
-
}
|
|
33
|
-
return render(user);
|
|
34
|
-
}
|
|
35
|
-
\`\`\`
|
|
36
|
-
|
|
37
|
-
### Good
|
|
38
|
-
|
|
39
|
-
\`\`\`ts
|
|
40
|
-
async function load(id: string) {
|
|
41
|
-
try {
|
|
42
|
-
return render(await fetchUser(id));
|
|
43
|
-
} catch (e) {
|
|
44
|
-
return err(e);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
\`\`\`
|
|
48
|
-
|
|
49
|
-
### Fix
|
|
50
|
-
|
|
51
|
-
Rethrow the error, return a failure the caller can see, or show it to the user. The message shows \`throw e;\`, or \`return err(e);\` when the file imports \`err\` from \`@stateward/values\`. There is no quick fix.
|
|
52
|
-
|
|
53
|
-
## What it does not catch
|
|
54
|
-
|
|
55
|
-
It stays quiet when:
|
|
56
|
-
|
|
57
|
-
- the \`catch\` does anything else (\`return\`, \`throw\`, a call that shows the error);
|
|
58
|
-
- an empty \`catch\` holds a comment, which says it is on purpose;
|
|
59
|
-
- 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;\`);
|
|
60
|
-
- 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);
|
|
61
|
-
- the \`try\` is the last thing the function, or a loop's body, does (a retry loop moves on to the next attempt).
|
|
62
|
-
|
|
63
|
-
A function of your own that only logs is not recognised as logging until you list it in \`logFunctions\`.
|
|
64
|
-
|
|
65
|
-
## Turn it on or off
|
|
66
|
-
|
|
67
|
-
Turn it on in \`stateward.config.ts\`:
|
|
68
|
-
|
|
69
|
-
\`\`\`ts
|
|
70
|
-
addons: ["errors/swallow"]
|
|
71
|
-
\`\`\`
|
|
72
|
-
|
|
73
|
-
Then change or turn off the rule itself:
|
|
74
|
-
|
|
75
|
-
\`\`\`ts
|
|
76
|
-
rules: { "errors/no-swallow": "off" } // or "error"
|
|
77
|
-
groups: { "errors/catch": "off" } // the whole group
|
|
78
|
-
rules: { "errors/no-swallow": ["warning", { logFunctions: ["logError"] }] } // your own log helpers
|
|
79
|
-
\`\`\`
|
|
80
|
-
`;
|
|
81
|
-
const OPTIONS = {
|
|
82
|
-
loggers: option.strings({
|
|
83
|
-
default: ["console", "logger", "log"],
|
|
84
|
-
description: "Objects whose every method only logs (globs like *Logger): a catch that only calls them swallows the error.",
|
|
85
|
-
}),
|
|
86
|
-
logMethods: option.strings({
|
|
87
|
-
default: ["error", "warn", "info", "debug"],
|
|
88
|
-
description: "Method names that only log, on any object (`Sentry.captureException` is not one).",
|
|
89
|
-
}),
|
|
90
|
-
logFunctions: option.strings({
|
|
91
|
-
default: [],
|
|
92
|
-
description: "Plain functions of your own that only log, like logError (globs allowed).",
|
|
93
|
-
}),
|
|
94
|
-
};
|
|
95
|
-
/** The name an expression ends in: `logger`, `this.logger`, `app.log`. */
|
|
96
|
-
function lastName(expression) {
|
|
97
|
-
const node = stripParentheses(expression);
|
|
98
|
-
if (ts.isIdentifier(node))
|
|
99
|
-
return node.text;
|
|
100
|
-
return ts.isPropertyAccessExpression(node) ? node.name.text : undefined;
|
|
101
|
-
}
|
|
102
|
-
/**
|
|
103
|
-
* A call that only logs: any method of a logger (`console.x()`, `logger.x()`), a log method on any
|
|
104
|
-
* object (`app.warn()`), or one of the team's own log functions (`logError(e)`).
|
|
105
|
-
*/
|
|
106
|
-
function isLoggingCall(expression, logging) {
|
|
107
|
-
const call = stripParentheses(expression);
|
|
108
|
-
if (!ts.isCallExpression(call))
|
|
109
|
-
return false;
|
|
110
|
-
const callee = stripParentheses(call.expression);
|
|
111
|
-
if (ts.isIdentifier(callee))
|
|
112
|
-
return matchesName(callee.text, logging.logFunctions);
|
|
113
|
-
if (!ts.isPropertyAccessExpression(callee))
|
|
114
|
-
return false;
|
|
115
|
-
if (logging.logMethods.includes(callee.name.text))
|
|
116
|
-
return true;
|
|
117
|
-
const name = lastName(stripParentheses(callee.expression));
|
|
118
|
-
return name !== undefined && matchesName(name, logging.loggers);
|
|
119
|
-
}
|
|
120
|
-
/** What the catch does: nothing, or only log. Undefined when it does anything else. */
|
|
121
|
-
function swallowKind(block, sourceFile, logging) {
|
|
122
|
-
const statements = block.statements.filter((statement) => !ts.isEmptyStatement(statement));
|
|
123
|
-
if (statements.length === 0) {
|
|
124
|
-
// A comment inside an empty catch says it is on purpose.
|
|
125
|
-
return hasComment(block, sourceFile) ? undefined : "ignores";
|
|
126
|
-
}
|
|
127
|
-
const onlyLogs = statements.every((statement) => ts.isExpressionStatement(statement) && isLoggingCall(statement.expression, logging));
|
|
128
|
-
return onlyLogs ? "logs" : undefined;
|
|
129
|
-
}
|
|
130
|
-
function hasComment(block, sourceFile) {
|
|
131
|
-
const inner = sourceFile.text.slice(block.getStart(sourceFile) + 1, block.getEnd() - 1);
|
|
132
|
-
return inner.trim() !== "";
|
|
133
|
-
}
|
|
134
|
-
/** Statements that run nothing: a bare `;` and declarations that only exist at compile time. */
|
|
135
|
-
function runsNothing(statement) {
|
|
136
|
-
return (ts.isEmptyStatement(statement) ||
|
|
137
|
-
ts.isFunctionDeclaration(statement) ||
|
|
138
|
-
ts.isInterfaceDeclaration(statement) ||
|
|
139
|
-
ts.isTypeAliasDeclaration(statement));
|
|
140
|
-
}
|
|
141
|
-
/** `return;`, `break;` and `continue;` end the flow: nothing after them carries on. */
|
|
142
|
-
function endsFlow(statement) {
|
|
143
|
-
if (ts.isReturnStatement(statement))
|
|
144
|
-
return statement.expression === undefined;
|
|
145
|
-
return ts.isBreakStatement(statement) || ts.isContinueStatement(statement);
|
|
146
|
-
}
|
|
147
|
-
/**
|
|
148
|
-
* Whether the function runs something after the `try` statement: a statement that follows it in
|
|
149
|
-
* its block or in a block around it, up to the function (or the file, for top-level code). The end
|
|
150
|
-
* of a loop's body runs nothing after it: the loop goes on to its next turn (a retry loop's next
|
|
151
|
-
* attempt).
|
|
152
|
-
*/
|
|
153
|
-
function continuesAfter(tryStatement) {
|
|
154
|
-
let node = tryStatement;
|
|
155
|
-
for (let parent = node.parent;; parent = node.parent) {
|
|
156
|
-
if (ts.isFunctionLike(parent))
|
|
157
|
-
return false;
|
|
158
|
-
if (ts.isBlock(parent) || ts.isSourceFile(parent) || ts.isCaseOrDefaultClause(parent)) {
|
|
159
|
-
const statements = parent.statements;
|
|
160
|
-
const after = statements.slice(statements.findIndex((candidate) => candidate === node) + 1);
|
|
161
|
-
const next = after.find((statement) => !runsNothing(statement));
|
|
162
|
-
if (next !== undefined)
|
|
163
|
-
return !endsFlow(next);
|
|
164
|
-
}
|
|
165
|
-
if (ts.isSourceFile(parent))
|
|
166
|
-
return false;
|
|
167
|
-
if (ts.isIterationStatement(parent, false) && parent.statement === node)
|
|
168
|
-
return false;
|
|
169
|
-
node = parent;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
/** Whether `node` has a `return` with a value that runs in this function (not in a nested one). */
|
|
173
|
-
function returnsValue(node) {
|
|
174
|
-
if (ts.isReturnStatement(node))
|
|
175
|
-
return node.expression !== undefined;
|
|
176
|
-
if (ts.isFunctionLike(node) || ts.isClassLike(node))
|
|
177
|
-
return false;
|
|
178
|
-
return ts.forEachChild(node, (child) => (returnsValue(child) ? true : undefined)) === true;
|
|
179
|
-
}
|
|
180
|
-
/**
|
|
181
|
-
* A fallback: the `try` returns the value it produced, and the statement right after it returns
|
|
182
|
-
* another one (`try { return JSON.parse(stored); } catch (e) { console.warn(e); } return
|
|
183
|
-
* defaults;`). The caller gets a value either way; nothing runs on a result that was never made.
|
|
184
|
-
*/
|
|
185
|
-
function fallsBackToAValue(tryStatement) {
|
|
186
|
-
const list = tryStatement.parent;
|
|
187
|
-
if (!ts.isBlock(list) && !ts.isSourceFile(list) && !ts.isCaseOrDefaultClause(list))
|
|
188
|
-
return false;
|
|
189
|
-
const after = list.statements.slice(list.statements.indexOf(tryStatement) + 1);
|
|
190
|
-
const next = after.find((statement) => !runsNothing(statement));
|
|
191
|
-
return (next !== undefined &&
|
|
192
|
-
ts.isReturnStatement(next) &&
|
|
193
|
-
next.expression !== undefined &&
|
|
194
|
-
returnsValue(tryStatement.tryBlock));
|
|
195
|
-
}
|
|
196
|
-
/** The names the `try` block assigns with `=` (not in a nested function): `flags = parse(…)`. */
|
|
197
|
-
function assignedNames(block) {
|
|
198
|
-
const names = [];
|
|
199
|
-
const visit = (node) => {
|
|
200
|
-
if (ts.isFunctionLike(node) || ts.isClassLike(node))
|
|
201
|
-
return;
|
|
202
|
-
if (ts.isBinaryExpression(node) &&
|
|
203
|
-
node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
204
|
-
ts.isIdentifier(stripParentheses(node.left))) {
|
|
205
|
-
const target = stripParentheses(node.left);
|
|
206
|
-
if (ts.isIdentifier(target))
|
|
207
|
-
names.push(target.text);
|
|
208
|
-
}
|
|
209
|
-
ts.forEachChild(node, visit);
|
|
210
|
-
};
|
|
211
|
-
visit(block);
|
|
212
|
-
return names;
|
|
213
|
-
}
|
|
214
|
-
/**
|
|
215
|
-
* Whether `name` already holds a value before the `try`: a `let` or `var` declared with an
|
|
216
|
-
* initializer in a block around it (before it, up to the module), or a parameter of a function
|
|
217
|
-
* around it.
|
|
218
|
-
*/
|
|
219
|
-
function holdsAValueBefore(name, tryStatement) {
|
|
220
|
-
let node = tryStatement;
|
|
221
|
-
for (let parent = node.parent;; parent = node.parent) {
|
|
222
|
-
if (ts.isBlock(parent) || ts.isSourceFile(parent) || ts.isCaseOrDefaultClause(parent)) {
|
|
223
|
-
const before = parent.statements.slice(0, parent.statements.findIndex((s) => s === node));
|
|
224
|
-
for (const statement of before) {
|
|
225
|
-
if (!ts.isVariableStatement(statement))
|
|
226
|
-
continue;
|
|
227
|
-
for (const declaration of statement.declarationList.declarations) {
|
|
228
|
-
if (ts.isIdentifier(declaration.name) && declaration.name.text === name) {
|
|
229
|
-
return declaration.initializer !== undefined;
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
// A parameter holds the caller's value; past it, a closure's or the module's variable.
|
|
235
|
-
if (ts.isFunctionLike(parent) &&
|
|
236
|
-
parent.parameters.some((parameter) => ts.isIdentifier(parameter.name) && parameter.name.text === name)) {
|
|
237
|
-
return true;
|
|
238
|
-
}
|
|
239
|
-
if (ts.isSourceFile(parent))
|
|
240
|
-
return false;
|
|
241
|
-
node = parent;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
/**
|
|
245
|
-
* A fallback assigned before the `try`: every variable the `try` assigns already held a value
|
|
246
|
-
* (`let flags = DEFAULTS; try { flags = parse(…); } catch (e) { log(e); } return flags;`). The
|
|
247
|
-
* code after the `try` runs on that value when the `try` fails, which is what the author chose.
|
|
248
|
-
*/
|
|
249
|
-
function replacesAFallback(tryStatement) {
|
|
250
|
-
const names = assignedNames(tryStatement.tryBlock);
|
|
251
|
-
return names.length > 0 && names.every((name) => holdsAValueBefore(name, tryStatement));
|
|
252
|
-
}
|
|
253
|
-
function headline(kind) {
|
|
254
|
-
return `this catch ${kind} the error and carries on as if nothing failed`;
|
|
255
|
-
}
|
|
256
|
-
/** The local name of `err` when the file imports it from `@stateward/values`. */
|
|
257
|
-
function importedErr(sourceFile) {
|
|
258
|
-
for (const statement of sourceFile.statements) {
|
|
259
|
-
if (!ts.isImportDeclaration(statement) ||
|
|
260
|
-
!ts.isStringLiteral(statement.moduleSpecifier) ||
|
|
261
|
-
statement.moduleSpecifier.text !== "@stateward/values" ||
|
|
262
|
-
statement.importClause?.phaseModifier === ts.SyntaxKind.TypeKeyword) {
|
|
263
|
-
continue;
|
|
264
|
-
}
|
|
265
|
-
const bindings = statement.importClause?.namedBindings;
|
|
266
|
-
if (bindings === undefined || !ts.isNamedImports(bindings))
|
|
267
|
-
continue;
|
|
268
|
-
const element = bindings.elements.find((candidate) => !candidate.isTypeOnly && (candidate.propertyName ?? candidate.name).text === "err");
|
|
269
|
-
if (element !== undefined)
|
|
270
|
-
return element.name.text;
|
|
271
|
-
}
|
|
272
|
-
return undefined;
|
|
273
|
-
}
|
|
274
|
-
/** The example uses only what the file has: `err` when it imports it, else a plain rethrow. */
|
|
275
|
-
function hint(variable, sourceFile) {
|
|
276
|
-
const err = importedErr(sourceFile);
|
|
277
|
-
return withExample("rethrow it, return a failure the caller can see, or show it to the user:", [
|
|
278
|
-
err === undefined ? `throw ${variable};` : `return ${err}(${variable});`,
|
|
279
|
-
]);
|
|
280
|
-
}
|
|
281
|
-
function checkCatch(clause, ctx) {
|
|
282
|
-
const kind = swallowKind(clause.block, ctx.sourceFile, ctx.options);
|
|
283
|
-
if (kind === undefined ||
|
|
284
|
-
!continuesAfter(clause.parent) ||
|
|
285
|
-
fallsBackToAValue(clause.parent) ||
|
|
286
|
-
replacesAFallback(clause.parent)) {
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
const declaration = clause.variableDeclaration;
|
|
290
|
-
const variable = declaration !== undefined && ts.isIdentifier(declaration.name)
|
|
291
|
-
? declaration.name.text
|
|
292
|
-
: "error";
|
|
293
|
-
ctx.report({
|
|
294
|
-
node: clause.getFirstToken(ctx.sourceFile) ?? clause,
|
|
295
|
-
message: headline(kind),
|
|
296
|
-
hint: hint(variable, ctx.sourceFile),
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
|
-
export const noSwallow = defineRule({
|
|
300
|
-
kind: "file",
|
|
301
|
-
meta: {
|
|
302
|
-
description: "A catch that only logs, or does nothing, and then carries on treats a failure as if it had not happened.",
|
|
303
|
-
defaultSeverity: "warning",
|
|
304
|
-
tier: "addon",
|
|
305
|
-
addon: "swallow",
|
|
306
|
-
group: GROUP,
|
|
307
|
-
heuristic: true,
|
|
308
|
-
options: OPTIONS,
|
|
309
|
-
explain: EXPLAIN,
|
|
310
|
-
},
|
|
311
|
-
check(ctx) {
|
|
312
|
-
const visit = (node) => {
|
|
313
|
-
if (ts.isCatchClause(node))
|
|
314
|
-
checkCatch(node, ctx);
|
|
315
|
-
ts.forEachChild(node, visit);
|
|
316
|
-
};
|
|
317
|
-
visit(ctx.sourceFile);
|
|
318
|
-
},
|
|
319
|
-
});
|
|
320
|
-
//# sourceMappingURL=no-swallow.js.map
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
/** The group the rule belongs to in the docs and in `stateward status`. */
|
|
2
|
-
export declare const GROUP = "react";
|
|
3
|
-
export declare const renderThrowsWithoutBoundary: import("@stateward/core").ProjectRule<import("./react/boundaries.js").ReactSummary, {
|
|
4
|
-
boundaries: import("@stateward/core").RecordOption;
|
|
5
|
-
}, "react">;
|
|
6
|
-
//# sourceMappingURL=render-throws-without-boundary.d.ts.map
|