@stateward/plugin-errors 1.0.1-rc.1 → 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.
@@ -1,188 +0,0 @@
1
- import { defineRule, matchesName, option } from "@stateward/core";
2
- import { catchName, className } from "./inference/format.js";
3
- import { analyse, collected, evaluated, relation } from "./inference/solve.js";
4
- import { summarizeOnce } from "./inference/project.js";
5
- import { HEADLINE_MAX, codeList, fitted } from "./text.js";
6
- /** The group the rule belongs to in the docs and in `stateward status`. */
7
- export const GROUP = "inference";
8
- const EXPLAIN = `# errors/throws-matches-doc
9
-
10
- A function's @throws tag lists exactly the error classes it can throw, as far as stateward can see.
11
-
12
- ## Why
13
-
14
- \`@throws {T}\` tells callers which errors to expect, and stateward checks every caller's \`catch\` against it. A tag that has drifted from the code misleads both. If \`loadUser\` says \`@throws {NotFoundError}\` but also calls \`JSON.parse\`, a \`SyntaxError\` slips past a \`catch\` written for \`NotFoundError\`. A tag that lists an error the function can no longer throw sends callers to handle something that never comes.
15
-
16
- ## When to use it
17
-
18
- Use it if you write \`@throws\` tags, typically on public functions and module boundaries. It is on as an error in every preset, and does nothing for functions without the tag, so it costs nothing until you start writing them.
19
-
20
- ## What it catches
21
-
22
- It compares the tag with what the function can throw (its own \`throw new X(…)\`, the functions it calls, and the built-ins stateward knows), and reports:
23
-
24
- - a class in the tag that the function cannot throw, neither it nor a subclass;
25
- - a class the function can throw that the tag does not cover, by itself or by a class it extends.
26
-
27
- On a function that returns a promise, \`@throws\` means what the promise rejects with: what callers see when they \`await\` it or use \`.catch\`.
28
-
29
- ### Bad
30
-
31
- \`\`\`ts
32
- /** @throws {NotFoundError} */
33
- export async function loadUser(id: string): Promise<User> {
34
- const text = await readUser(id);
35
- return JSON.parse(text); // SyntaxError, not listed
36
- }
37
- \`\`\`
38
-
39
- ### Good
40
-
41
- \`\`\`ts
42
- /** @throws {NotFoundError | SyntaxError} */
43
- export async function loadUser(id: string): Promise<User> {
44
- const text = await readUser(id);
45
- return JSON.parse(text);
46
- }
47
- \`\`\`
48
-
49
- ### Fix
50
-
51
- Update the tag; the message gives the one to write and links to the call that throws each missing class. There is no quick fix.
52
-
53
- ## What it does not catch
54
-
55
- - A function without \`@throws\`.
56
- - A tag without a type (\`@throws when the id is empty\`): it only says the function throws.
57
- - A listed class that comes from a call stateward cannot see into (a package, a callback, a dynamic call). It says so in an info finding, naming the call: remove the class from the tag if the function cannot throw it, or declare the package's errors under \`facts\` in the config.
58
- - A name in the tag that is not a class in scope: also an info finding. Import the class, or use a name in scope.
59
- - A class that a surrounding \`try\` may catch: only classes that surely escape are reported as missing.
60
- - Errors from the JavaScript engine itself, such as reading a property of \`undefined\`.
61
-
62
- ## Turn it on or off
63
-
64
- In \`stateward.config.ts\`:
65
-
66
- \`\`\`ts
67
- rules: { "errors/throws-matches-doc": "off" } // or "warning"
68
- groups: { "errors/inference": "off" } // the whole group
69
- rules: { "errors/throws-matches-doc": ["error", { severities: { extra: "warning" } }] } // a stale tag only warns
70
- \`\`\`
71
- `;
72
- function locationOf(file, at) {
73
- return { file, start: at[0], end: at[1] };
74
- }
75
- const OPTIONS = {
76
- severities: option.severities({
77
- kinds: {
78
- missing: "the function can throw a class its `@throws` does not list.",
79
- extra: "the `@throws` lists a class the function cannot throw (stale, not dangerous).",
80
- },
81
- description: "A severity per kind of mismatch.",
82
- }),
83
- ignoreClasses: option.strings({
84
- default: [],
85
- description: "Error classes a `@throws` never needs to list (TypeError, *AbortError), as a catch names them.",
86
- }),
87
- };
88
- function check(entry, analysis, ctx) {
89
- const doc = entry.fact.doc;
90
- if (doc === null)
91
- return;
92
- const { classes } = analysis;
93
- const result = evaluated(entry, analysis).body;
94
- const all = collected();
95
- for (const part of [result.throws, result.rejects]) {
96
- for (const [id, info] of part.classes) {
97
- const known = all.classes.get(id);
98
- if (known === undefined)
99
- all.classes.set(id, { ...info });
100
- else
101
- known.certain ||= info.certain;
102
- }
103
- for (const [key, source] of part.unknowns)
104
- all.unknowns.set(key, source);
105
- }
106
- const name = entry.fact.name;
107
- const firstUnknown = [...all.unknowns.values()][0];
108
- for (const item of doc.entries) {
109
- const location = locationOf(entry.file, item.at);
110
- if (item.id === null) {
111
- ctx.unknown({
112
- message: fitted(HEADLINE_MAX, (cut) => `stateward cannot find the class \`${cut(item.name)}\` this \`@throws\` names`),
113
- hint: "name an error class that is in scope in this file, or import it",
114
- location,
115
- });
116
- continue;
117
- }
118
- const id = item.id;
119
- const possible = [...all.classes.keys()].some((thrown) => relation(id, thrown, classes) !== "no");
120
- if (possible)
121
- continue;
122
- const listed = className(id, classes);
123
- if (firstUnknown !== undefined) {
124
- ctx.unknown({
125
- message: fitted(HEADLINE_MAX, (cut) => `stateward cannot tell whether \`${cut(name)}\` throws \`${cut(listed)}\` (via \`${cut(firstUnknown.text)}\`: ${firstUnknown.reason})`),
126
- hint: `if \`${name}\` cannot throw it, remove \`${listed}\` from the tag; else add \`@throws\` where it comes from`,
127
- location,
128
- });
129
- continue;
130
- }
131
- ctx.report({
132
- message: fitted(HEADLINE_MAX, (cut) => `\`@throws\` lists \`${cut(listed)}\`, which \`${cut(name)}\` cannot throw`),
133
- hint: `remove \`${listed}\` from the tag, or throw it where it can happen`,
134
- location,
135
- findingKind: "extra",
136
- });
137
- }
138
- if (doc.untyped.length > 0 || doc.entries.some((item) => item.id === null))
139
- return;
140
- const missing = [];
141
- for (const [id, info] of all.classes) {
142
- if (!info.certain || matchesName(catchName(id, classes), ctx.options.ignoreClasses))
143
- continue;
144
- const verdicts = doc.entries.map((item) => item.id === null ? "undecided" : relation(item.id, id, classes));
145
- if (verdicts.includes("yes") || verdicts.includes("undecided"))
146
- continue;
147
- const via = info.via;
148
- missing.push({
149
- id,
150
- related: via === null
151
- ? undefined
152
- : {
153
- location: { file: via.file, start: via.start, end: via.end },
154
- message: `\`${via.text}\` can throw \`${className(id, classes)}\``,
155
- },
156
- });
157
- }
158
- if (missing.length === 0)
159
- return;
160
- const names = missing.map((one) => className(one.id, classes)).sort();
161
- const tagNames = [...new Set(missing.map((one) => catchName(one.id, classes)))].sort();
162
- ctx.report({
163
- message: fitted(HEADLINE_MAX, (cut) => `\`${cut(name)}\` can throw ${codeList(names, cut)}, which its \`@throws\` does not list`),
164
- hint: `add ${missing.length === 1 ? "it" : "them"} to the tag: \`@throws {${tagNames.join(" | ")}}\``,
165
- location: locationOf(entry.file, entry.fact.at),
166
- related: missing.flatMap((one) => (one.related === undefined ? [] : [one.related])).slice(0, 3),
167
- findingKind: "missing",
168
- });
169
- }
170
- export const throwsMatchesDoc = defineRule({
171
- kind: "project",
172
- meta: {
173
- description: "A function's @throws tag lists exactly the error classes it can throw, as far as stateward can see.",
174
- defaultSeverity: "error",
175
- tier: "core",
176
- group: GROUP,
177
- needsJsDoc: true,
178
- options: OPTIONS,
179
- explain: EXPLAIN,
180
- },
181
- summarize: (ctx) => summarizeOnce(ctx),
182
- check(summaries, ctx) {
183
- const analysis = analyse(summaries);
184
- for (const entry of analysis.functions.values())
185
- check(entry, analysis, ctx);
186
- },
187
- });
188
- //# sourceMappingURL=throws-matches-doc.js.map