@noctcore/eslint-plugin-async-safety 0.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 +61 -0
- package/dist/index.cjs +847 -0
- package/dist/index.d.cts +59 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.js +819 -0
- package/docs/rules/forward-abort-signal.md +46 -0
- package/docs/rules/no-concurrent-shared-mutation.md +48 -0
- package/docs/rules/no-shared-mutable-module-state.md +62 -0
- package/docs/rules/prefer-parallel-awaits.md +45 -0
- package/docs/rules/require-fetch-timeout.md +55 -0
- package/package.json +67 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,819 @@
|
|
|
1
|
+
// src/configs/recommended.ts
|
|
2
|
+
var recommended = {
|
|
3
|
+
// Precise, syntactic — safe as errors.
|
|
4
|
+
"noctcore-async-safety/require-fetch-timeout": "error",
|
|
5
|
+
// Inert until you set `include` globs, so it ships enabled but off by default.
|
|
6
|
+
"noctcore-async-safety/no-shared-mutable-module-state": "error",
|
|
7
|
+
// Heuristic — advisory. Warns rather than blocking.
|
|
8
|
+
"noctcore-async-safety/forward-abort-signal": "warn",
|
|
9
|
+
"noctcore-async-safety/prefer-parallel-awaits": "warn",
|
|
10
|
+
"noctcore-async-safety/no-concurrent-shared-mutation": "warn"
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// src/rules/forward-abort-signal.ts
|
|
14
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES2 } from "@typescript-eslint/utils";
|
|
15
|
+
|
|
16
|
+
// src/createRule.ts
|
|
17
|
+
import { makeCreateRule } from "@noctcore/eslint-utils";
|
|
18
|
+
var createRule = makeCreateRule("async-safety");
|
|
19
|
+
|
|
20
|
+
// src/utils.ts
|
|
21
|
+
import { AST_NODE_TYPES } from "@typescript-eslint/utils";
|
|
22
|
+
function calleeText(callee) {
|
|
23
|
+
if (callee.type === AST_NODE_TYPES.Identifier) {
|
|
24
|
+
return callee.name;
|
|
25
|
+
}
|
|
26
|
+
if (callee.type === AST_NODE_TYPES.MemberExpression && !callee.computed) {
|
|
27
|
+
const object = calleeText(callee.object);
|
|
28
|
+
if (object === null || callee.property.type !== AST_NODE_TYPES.Identifier) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return `${object}.${callee.property.name}`;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
function globToRegExp(glob) {
|
|
36
|
+
let source = "";
|
|
37
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
38
|
+
const char = glob[i];
|
|
39
|
+
if (char === "*") {
|
|
40
|
+
if (glob[i + 1] === "*") {
|
|
41
|
+
i += 1;
|
|
42
|
+
if (glob[i + 1] === "/") {
|
|
43
|
+
i += 1;
|
|
44
|
+
source += "(?:.*/)?";
|
|
45
|
+
} else {
|
|
46
|
+
source += ".*";
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
source += "[^/]*";
|
|
50
|
+
}
|
|
51
|
+
} else if (char === "?") {
|
|
52
|
+
source += "[^/]";
|
|
53
|
+
} else if ("\\^$.|+()[]{}".includes(char)) {
|
|
54
|
+
source += `\\${char}`;
|
|
55
|
+
} else {
|
|
56
|
+
source += char;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return new RegExp(`^${source}$`);
|
|
60
|
+
}
|
|
61
|
+
function matchesAnyGlob(filename, globs) {
|
|
62
|
+
if (globs.length === 0) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
const normalized = filename.split("\\").join("/");
|
|
66
|
+
return globs.some((glob) => globToRegExp(glob).test(normalized));
|
|
67
|
+
}
|
|
68
|
+
function walk(node, visit) {
|
|
69
|
+
visit(node);
|
|
70
|
+
for (const key of Object.keys(node)) {
|
|
71
|
+
if (key === "parent") {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const value = node[key];
|
|
75
|
+
if (Array.isArray(value)) {
|
|
76
|
+
for (const child of value) {
|
|
77
|
+
if (isNode(child)) {
|
|
78
|
+
walk(child, visit);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} else if (isNode(value)) {
|
|
82
|
+
walk(value, visit);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function isNode(value) {
|
|
87
|
+
return typeof value === "object" && value !== null && typeof value.type === "string";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/rules/forward-abort-signal.ts
|
|
91
|
+
var RULE_NAME = "forward-abort-signal";
|
|
92
|
+
function signalBindingNames(params) {
|
|
93
|
+
const names = /* @__PURE__ */ new Set();
|
|
94
|
+
let reportNode = null;
|
|
95
|
+
const consider = (name, node) => {
|
|
96
|
+
names.add(name);
|
|
97
|
+
reportNode ??= node;
|
|
98
|
+
};
|
|
99
|
+
for (const rawParam of params) {
|
|
100
|
+
let param = rawParam;
|
|
101
|
+
if (param.type === AST_NODE_TYPES2.AssignmentPattern) {
|
|
102
|
+
param = param.left;
|
|
103
|
+
}
|
|
104
|
+
if (param.type === AST_NODE_TYPES2.Identifier) {
|
|
105
|
+
const typedAbortSignal = param.typeAnnotation?.typeAnnotation.type === AST_NODE_TYPES2.TSTypeReference && param.typeAnnotation.typeAnnotation.typeName.type === AST_NODE_TYPES2.Identifier && param.typeAnnotation.typeAnnotation.typeName.name === "AbortSignal";
|
|
106
|
+
if (param.name === "signal" || typedAbortSignal) {
|
|
107
|
+
consider(param.name, param);
|
|
108
|
+
}
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (param.type === AST_NODE_TYPES2.ObjectPattern) {
|
|
112
|
+
for (const property of param.properties) {
|
|
113
|
+
if (property.type !== AST_NODE_TYPES2.Property || property.key.type !== AST_NODE_TYPES2.Identifier || property.key.name !== "signal") {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
let value = property.value;
|
|
117
|
+
if (value.type === AST_NODE_TYPES2.AssignmentPattern) {
|
|
118
|
+
value = value.left;
|
|
119
|
+
}
|
|
120
|
+
if (value.type === AST_NODE_TYPES2.Identifier) {
|
|
121
|
+
consider(value.name, property);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { names, reportNode };
|
|
127
|
+
}
|
|
128
|
+
function hasCancellableWork(fn) {
|
|
129
|
+
let found = false;
|
|
130
|
+
walk(fn, (node) => {
|
|
131
|
+
if (found) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (node.type === AST_NODE_TYPES2.AwaitExpression && node.argument.type === AST_NODE_TYPES2.CallExpression) {
|
|
135
|
+
found = true;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (node.type === AST_NODE_TYPES2.CallExpression && calleeText(node.callee) === "fetch") {
|
|
139
|
+
found = true;
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
return found;
|
|
143
|
+
}
|
|
144
|
+
function isCheckOnlyRead(identifier) {
|
|
145
|
+
const parent = identifier.parent;
|
|
146
|
+
return parent.type === AST_NODE_TYPES2.MemberExpression && parent.object === identifier;
|
|
147
|
+
}
|
|
148
|
+
var forwardAbortSignalRule = createRule({
|
|
149
|
+
name: RULE_NAME,
|
|
150
|
+
meta: {
|
|
151
|
+
type: "problem",
|
|
152
|
+
docs: {
|
|
153
|
+
description: "A function that accepts an `AbortSignal` (param named `signal` or typed `AbortSignal`) but awaits a call without ever forwarding it leaves that work uncancellable."
|
|
154
|
+
},
|
|
155
|
+
schema: [],
|
|
156
|
+
messages: {
|
|
157
|
+
unforwardedSignal: "`{{name}}` is an AbortSignal but is never forwarded to a call in this function \u2014 thread it into the awaited work (e.g. `fetch(url, { signal })`) so the operation can be cancelled."
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
defaultOptions: [],
|
|
161
|
+
create(context) {
|
|
162
|
+
function check(fn) {
|
|
163
|
+
const { names, reportNode } = signalBindingNames(fn.params);
|
|
164
|
+
if (names.size === 0 || reportNode === null) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!hasCancellableWork(fn)) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const declared = context.sourceCode.getDeclaredVariables(fn);
|
|
171
|
+
for (const variable of declared) {
|
|
172
|
+
if (!names.has(variable.name)) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const reads = variable.references.filter((ref) => ref.isRead());
|
|
176
|
+
const forwarded = reads.some(
|
|
177
|
+
(ref) => ref.identifier.type === AST_NODE_TYPES2.Identifier && !isCheckOnlyRead(ref.identifier)
|
|
178
|
+
);
|
|
179
|
+
if (!forwarded) {
|
|
180
|
+
context.report({
|
|
181
|
+
node: reportNode,
|
|
182
|
+
messageId: "unforwardedSignal",
|
|
183
|
+
data: { name: variable.name }
|
|
184
|
+
});
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
FunctionDeclaration: check,
|
|
191
|
+
FunctionExpression: check,
|
|
192
|
+
ArrowFunctionExpression: check
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// src/rules/no-concurrent-shared-mutation.ts
|
|
198
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES3 } from "@typescript-eslint/utils";
|
|
199
|
+
var RULE_NAME2 = "no-concurrent-shared-mutation";
|
|
200
|
+
var CONCURRENT_ITERATORS = /* @__PURE__ */ new Set(["map", "flatMap", "forEach"]);
|
|
201
|
+
function isPromiseConcurrency(callee) {
|
|
202
|
+
return callee.type === AST_NODE_TYPES3.MemberExpression && !callee.computed && callee.object.type === AST_NODE_TYPES3.Identifier && callee.object.name === "Promise" && callee.property.type === AST_NODE_TYPES3.Identifier && (callee.property.name === "all" || callee.property.name === "allSettled");
|
|
203
|
+
}
|
|
204
|
+
function asyncIterationCallback(node) {
|
|
205
|
+
if (node.type !== AST_NODE_TYPES3.CallExpression || node.callee.type !== AST_NODE_TYPES3.MemberExpression || node.callee.computed || node.callee.property.type !== AST_NODE_TYPES3.Identifier || !CONCURRENT_ITERATORS.has(node.callee.property.name)) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
const callback = node.arguments[node.arguments.length - 1];
|
|
209
|
+
if (callback !== void 0 && (callback.type === AST_NODE_TYPES3.ArrowFunctionExpression || callback.type === AST_NODE_TYPES3.FunctionExpression) && callback.async) {
|
|
210
|
+
return callback;
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
function containsAwait(node) {
|
|
215
|
+
let found = false;
|
|
216
|
+
walk(node, (inner) => {
|
|
217
|
+
if (inner.type === AST_NODE_TYPES3.AwaitExpression) {
|
|
218
|
+
found = true;
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
return found;
|
|
222
|
+
}
|
|
223
|
+
function referencedNames(node) {
|
|
224
|
+
const names = /* @__PURE__ */ new Set();
|
|
225
|
+
walk(node, (inner) => {
|
|
226
|
+
if (inner.type === AST_NODE_TYPES3.Identifier) {
|
|
227
|
+
names.add(inner.name);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
return names;
|
|
231
|
+
}
|
|
232
|
+
function resolveVariable(scope, name) {
|
|
233
|
+
for (let current = scope; current; current = current.upper) {
|
|
234
|
+
const variable = current.set.get(name);
|
|
235
|
+
if (variable) {
|
|
236
|
+
return variable;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
var noConcurrentSharedMutationRule = createRule({
|
|
242
|
+
name: RULE_NAME2,
|
|
243
|
+
meta: {
|
|
244
|
+
type: "problem",
|
|
245
|
+
docs: {
|
|
246
|
+
description: "A read-modify-write of an outer-scope binding inside a concurrent `Promise.all(arr.map(async \u2026))` callback can lose updates."
|
|
247
|
+
},
|
|
248
|
+
schema: [],
|
|
249
|
+
messages: {
|
|
250
|
+
concurrentMutation: "`{{name}}` is read-modified-written inside a concurrent `Promise.all` callback \u2014 interleaved iterations can lose updates. Collect results into an array and reduce after the `Promise.all`, or update atomically."
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
defaultOptions: [],
|
|
254
|
+
create(context) {
|
|
255
|
+
const reported = /* @__PURE__ */ new Set();
|
|
256
|
+
function isOuterWrite(id, callback) {
|
|
257
|
+
const variable = resolveVariable(context.sourceCode.getScope(id), id.name);
|
|
258
|
+
const def = variable?.defs[0];
|
|
259
|
+
if (def === void 0) {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
const defRange = def.name.range;
|
|
263
|
+
const inside = defRange[0] >= callback.range[0] && defRange[1] <= callback.range[1];
|
|
264
|
+
return !inside;
|
|
265
|
+
}
|
|
266
|
+
function checkCallback(callback) {
|
|
267
|
+
if (!containsAwait(callback)) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
walk(callback, (node) => {
|
|
271
|
+
if (node.type === AST_NODE_TYPES3.UpdateExpression) {
|
|
272
|
+
const arg = node.argument;
|
|
273
|
+
if (arg.type === AST_NODE_TYPES3.Identifier && !reported.has(arg) && isOuterWrite(arg, callback)) {
|
|
274
|
+
reported.add(arg);
|
|
275
|
+
context.report({ node: arg, messageId: "concurrentMutation", data: { name: arg.name } });
|
|
276
|
+
}
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (node.type === AST_NODE_TYPES3.AssignmentExpression && node.left.type === AST_NODE_TYPES3.Identifier) {
|
|
280
|
+
const left = node.left;
|
|
281
|
+
const compound = node.operator !== "=";
|
|
282
|
+
const selfReferential = node.operator === "=" && referencedNames(node.right).has(left.name);
|
|
283
|
+
if ((compound || selfReferential) && !reported.has(left) && isOuterWrite(left, callback)) {
|
|
284
|
+
reported.add(left);
|
|
285
|
+
context.report({ node: left, messageId: "concurrentMutation", data: { name: left.name } });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
CallExpression(node) {
|
|
292
|
+
if (!isPromiseConcurrency(node.callee)) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const arg = node.arguments[0];
|
|
296
|
+
if (arg === void 0 || arg.type === AST_NODE_TYPES3.SpreadElement) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
walk(arg, (inner) => {
|
|
300
|
+
const callback = asyncIterationCallback(inner);
|
|
301
|
+
if (callback !== null) {
|
|
302
|
+
checkCallback(callback);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// src/rules/no-shared-mutable-module-state.ts
|
|
311
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES4 } from "@typescript-eslint/utils";
|
|
312
|
+
var RULE_NAME3 = "no-shared-mutable-module-state";
|
|
313
|
+
var CONTAINER_CTORS = /* @__PURE__ */ new Set([
|
|
314
|
+
"Map",
|
|
315
|
+
"Set",
|
|
316
|
+
"WeakMap",
|
|
317
|
+
"WeakSet",
|
|
318
|
+
"Array"
|
|
319
|
+
]);
|
|
320
|
+
var MUTATING_METHODS = /* @__PURE__ */ new Set([
|
|
321
|
+
"push",
|
|
322
|
+
"pop",
|
|
323
|
+
"shift",
|
|
324
|
+
"unshift",
|
|
325
|
+
"splice",
|
|
326
|
+
"sort",
|
|
327
|
+
"reverse",
|
|
328
|
+
"fill",
|
|
329
|
+
"copyWithin",
|
|
330
|
+
"set",
|
|
331
|
+
"add",
|
|
332
|
+
"delete",
|
|
333
|
+
"clear"
|
|
334
|
+
]);
|
|
335
|
+
var HANDLER_NAMES = /* @__PURE__ */ new Set([
|
|
336
|
+
"GET",
|
|
337
|
+
"POST",
|
|
338
|
+
"PUT",
|
|
339
|
+
"PATCH",
|
|
340
|
+
"DELETE",
|
|
341
|
+
"HEAD",
|
|
342
|
+
"OPTIONS",
|
|
343
|
+
"loader",
|
|
344
|
+
"action",
|
|
345
|
+
"handler",
|
|
346
|
+
"middleware"
|
|
347
|
+
]);
|
|
348
|
+
var optionSchema = {
|
|
349
|
+
type: "object",
|
|
350
|
+
additionalProperties: false,
|
|
351
|
+
properties: {
|
|
352
|
+
include: { type: "array", items: { type: "string" }, uniqueItems: true },
|
|
353
|
+
allow: { type: "array", items: { type: "string" }, uniqueItems: true }
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
function isHandlerName(name) {
|
|
357
|
+
return name !== null && (HANDLER_NAMES.has(name) || name.endsWith("Handler"));
|
|
358
|
+
}
|
|
359
|
+
function qualifies(fn, name) {
|
|
360
|
+
return fn.async || isHandlerName(name);
|
|
361
|
+
}
|
|
362
|
+
function isContainerInit(init) {
|
|
363
|
+
if (!init) {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
if (init.type === AST_NODE_TYPES4.ArrayExpression || init.type === AST_NODE_TYPES4.ObjectExpression) {
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
return init.type === AST_NODE_TYPES4.NewExpression && init.callee.type === AST_NODE_TYPES4.Identifier && CONTAINER_CTORS.has(init.callee.name);
|
|
370
|
+
}
|
|
371
|
+
function classify(variable) {
|
|
372
|
+
const def = variable.defs[0];
|
|
373
|
+
if (def === void 0 || def.type !== "Variable" || def.parent.type !== AST_NODE_TYPES4.VariableDeclaration) {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
const kind = def.parent.kind;
|
|
377
|
+
if (kind === "let" || kind === "var") {
|
|
378
|
+
return { reassignable: true };
|
|
379
|
+
}
|
|
380
|
+
const declarator = def.node;
|
|
381
|
+
if (declarator.type === AST_NODE_TYPES4.VariableDeclarator && isContainerInit(declarator.init)) {
|
|
382
|
+
return { reassignable: false };
|
|
383
|
+
}
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
function isLazyInit(identifier) {
|
|
387
|
+
const parent = identifier.parent;
|
|
388
|
+
return parent.type === AST_NODE_TYPES4.AssignmentExpression && parent.left === identifier && (parent.operator === "??=" || parent.operator === "||=");
|
|
389
|
+
}
|
|
390
|
+
function isContainerMutation(identifier) {
|
|
391
|
+
const member = identifier.parent;
|
|
392
|
+
if (member.type !== AST_NODE_TYPES4.MemberExpression || member.object !== identifier) {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
const outer = member.parent;
|
|
396
|
+
if (outer.type === AST_NODE_TYPES4.AssignmentExpression && outer.left === member) {
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
if (outer.type === AST_NODE_TYPES4.UpdateExpression && outer.argument === member) {
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
return outer.type === AST_NODE_TYPES4.CallExpression && outer.callee === member && !member.computed && member.property.type === AST_NODE_TYPES4.Identifier && MUTATING_METHODS.has(member.property.name);
|
|
403
|
+
}
|
|
404
|
+
function moduleScopeOf(scope) {
|
|
405
|
+
if (scope.type === "module") {
|
|
406
|
+
return scope;
|
|
407
|
+
}
|
|
408
|
+
const child = scope.childScopes.find((inner) => inner.type === "module");
|
|
409
|
+
return child ?? scope;
|
|
410
|
+
}
|
|
411
|
+
function enclosingExportedFn(node, exported) {
|
|
412
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
413
|
+
if (exported.has(current)) {
|
|
414
|
+
return exported.get(current);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return void 0;
|
|
418
|
+
}
|
|
419
|
+
function collectExportedFns(program) {
|
|
420
|
+
const result = /* @__PURE__ */ new Map();
|
|
421
|
+
const addFn = (fn, name) => {
|
|
422
|
+
if (qualifies(fn, name)) {
|
|
423
|
+
result.set(fn, name);
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
for (const statement of program.body) {
|
|
427
|
+
if (statement.type === AST_NODE_TYPES4.ExportNamedDeclaration && statement.declaration) {
|
|
428
|
+
const decl = statement.declaration;
|
|
429
|
+
if (decl.type === AST_NODE_TYPES4.FunctionDeclaration) {
|
|
430
|
+
addFn(decl, decl.id ? decl.id.name : null);
|
|
431
|
+
} else if (decl.type === AST_NODE_TYPES4.VariableDeclaration) {
|
|
432
|
+
for (const d of decl.declarations) {
|
|
433
|
+
if (d.id.type === AST_NODE_TYPES4.Identifier && d.init && (d.init.type === AST_NODE_TYPES4.ArrowFunctionExpression || d.init.type === AST_NODE_TYPES4.FunctionExpression)) {
|
|
434
|
+
addFn(d.init, d.id.name);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
} else if (statement.type === AST_NODE_TYPES4.ExportDefaultDeclaration) {
|
|
439
|
+
const decl = statement.declaration;
|
|
440
|
+
if (decl.type === AST_NODE_TYPES4.FunctionDeclaration || decl.type === AST_NODE_TYPES4.ArrowFunctionExpression || decl.type === AST_NODE_TYPES4.FunctionExpression) {
|
|
441
|
+
addFn(decl, decl.type === AST_NODE_TYPES4.FunctionDeclaration && decl.id ? decl.id.name : null);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return result;
|
|
446
|
+
}
|
|
447
|
+
var noSharedMutableModuleStateRule = createRule({
|
|
448
|
+
name: RULE_NAME3,
|
|
449
|
+
meta: {
|
|
450
|
+
type: "problem",
|
|
451
|
+
docs: {
|
|
452
|
+
description: "A module-scoped mutable binding written inside an exported async/handler function is shared across concurrent requests. Opt in per file via `include`."
|
|
453
|
+
},
|
|
454
|
+
schema: [optionSchema],
|
|
455
|
+
messages: {
|
|
456
|
+
sharedMutableState: "Module-scoped mutable `{{name}}` is written inside exported `{{fn}}` \u2014 concurrent requests share this state and can contaminate each other. Scope it per request, or guard it."
|
|
457
|
+
}
|
|
458
|
+
},
|
|
459
|
+
defaultOptions: [{ include: [], allow: [] }],
|
|
460
|
+
create(context, [options]) {
|
|
461
|
+
const include = options.include ?? [];
|
|
462
|
+
if (!matchesAnyGlob(context.filename, include)) {
|
|
463
|
+
return {};
|
|
464
|
+
}
|
|
465
|
+
const allow = new Set(options.allow ?? []);
|
|
466
|
+
return {
|
|
467
|
+
Program(program) {
|
|
468
|
+
const exported = collectExportedFns(program);
|
|
469
|
+
if (exported.size === 0) {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const moduleScope = moduleScopeOf(context.sourceCode.getScope(program));
|
|
473
|
+
for (const variable of moduleScope.variables) {
|
|
474
|
+
if (allow.has(variable.name)) {
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
const binding = classify(variable);
|
|
478
|
+
if (binding === null) {
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
for (const ref of variable.references) {
|
|
482
|
+
const id = ref.identifier;
|
|
483
|
+
if (id.type !== AST_NODE_TYPES4.Identifier) {
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
let isWrite = false;
|
|
487
|
+
if (binding.reassignable && ref.isWrite() && !ref.init) {
|
|
488
|
+
if (isLazyInit(id)) {
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
isWrite = true;
|
|
492
|
+
} else if (ref.isRead() && isContainerMutation(id)) {
|
|
493
|
+
isWrite = true;
|
|
494
|
+
}
|
|
495
|
+
if (!isWrite) {
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
const fnName = enclosingExportedFn(id, exported);
|
|
499
|
+
if (fnName === void 0) {
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
context.report({
|
|
503
|
+
node: id,
|
|
504
|
+
messageId: "sharedMutableState",
|
|
505
|
+
data: { name: variable.name, fn: fnName ?? "default export" }
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// src/rules/prefer-parallel-awaits.ts
|
|
515
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES5 } from "@typescript-eslint/utils";
|
|
516
|
+
var RULE_NAME4 = "prefer-parallel-awaits";
|
|
517
|
+
var MUTATION_PREFIXES = [
|
|
518
|
+
"set",
|
|
519
|
+
"save",
|
|
520
|
+
"update",
|
|
521
|
+
"upsert",
|
|
522
|
+
"insert",
|
|
523
|
+
"create",
|
|
524
|
+
"delete",
|
|
525
|
+
"destroy",
|
|
526
|
+
"remove",
|
|
527
|
+
"drop",
|
|
528
|
+
"write",
|
|
529
|
+
"put",
|
|
530
|
+
"post",
|
|
531
|
+
"patch",
|
|
532
|
+
"send",
|
|
533
|
+
"push",
|
|
534
|
+
"add",
|
|
535
|
+
"emit",
|
|
536
|
+
"dispatch",
|
|
537
|
+
"publish",
|
|
538
|
+
"begin",
|
|
539
|
+
"commit",
|
|
540
|
+
"rollback",
|
|
541
|
+
"mutate",
|
|
542
|
+
"sync"
|
|
543
|
+
];
|
|
544
|
+
function asAwaitDecl(statement) {
|
|
545
|
+
if (statement.type !== AST_NODE_TYPES5.VariableDeclaration || statement.kind !== "const" || statement.declarations.length !== 1) {
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
const declarator = statement.declarations[0];
|
|
549
|
+
if (declarator === void 0 || declarator.id.type !== AST_NODE_TYPES5.Identifier || declarator.init?.type !== AST_NODE_TYPES5.AwaitExpression || declarator.init.argument.type !== AST_NODE_TYPES5.CallExpression) {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
return { statement, name: declarator.id.name, call: declarator.init.argument };
|
|
553
|
+
}
|
|
554
|
+
function calleeLastName(callee) {
|
|
555
|
+
if (callee.type === AST_NODE_TYPES5.Identifier) {
|
|
556
|
+
return callee.name;
|
|
557
|
+
}
|
|
558
|
+
if (callee.type === AST_NODE_TYPES5.MemberExpression && !callee.computed && callee.property.type === AST_NODE_TYPES5.Identifier) {
|
|
559
|
+
return callee.property.name;
|
|
560
|
+
}
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
function looksLikeMutation(name) {
|
|
564
|
+
const lower = name.toLowerCase();
|
|
565
|
+
return MUTATION_PREFIXES.some((prefix) => lower.startsWith(prefix));
|
|
566
|
+
}
|
|
567
|
+
function isSimpleReadCall(call) {
|
|
568
|
+
const name = calleeLastName(call.callee);
|
|
569
|
+
if (name === null || looksLikeMutation(name)) {
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
let clean = true;
|
|
573
|
+
walk(call, (node) => {
|
|
574
|
+
if (node === call || !clean) {
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
if (node.type === AST_NODE_TYPES5.CallExpression || node.type === AST_NODE_TYPES5.NewExpression || node.type === AST_NODE_TYPES5.AwaitExpression || node.type === AST_NODE_TYPES5.AssignmentExpression || node.type === AST_NODE_TYPES5.UpdateExpression || node.type === AST_NODE_TYPES5.TaggedTemplateExpression) {
|
|
578
|
+
clean = false;
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
return clean;
|
|
582
|
+
}
|
|
583
|
+
function referencedNames2(node) {
|
|
584
|
+
const names = /* @__PURE__ */ new Set();
|
|
585
|
+
walk(node, (inner) => {
|
|
586
|
+
if (inner.type === AST_NODE_TYPES5.Identifier) {
|
|
587
|
+
names.add(inner.name);
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
return names;
|
|
591
|
+
}
|
|
592
|
+
var preferParallelAwaitsRule = createRule({
|
|
593
|
+
name: RULE_NAME4,
|
|
594
|
+
meta: {
|
|
595
|
+
type: "suggestion",
|
|
596
|
+
docs: {
|
|
597
|
+
description: "Consecutive independent `const x = await read()` statements can run concurrently via `await Promise.all([...])`."
|
|
598
|
+
},
|
|
599
|
+
hasSuggestions: true,
|
|
600
|
+
schema: [],
|
|
601
|
+
messages: {
|
|
602
|
+
parallelizable: "{{count}} consecutive independent awaits run sequentially \u2014 they have no data dependency and could run concurrently with `await Promise.all([...])`.",
|
|
603
|
+
useParallel: "Combine into a single `await Promise.all([...])`."
|
|
604
|
+
}
|
|
605
|
+
},
|
|
606
|
+
defaultOptions: [],
|
|
607
|
+
create(context) {
|
|
608
|
+
function checkBlock(node) {
|
|
609
|
+
const body = node.body;
|
|
610
|
+
for (let i = 0; i < body.length; ) {
|
|
611
|
+
const first = body[i];
|
|
612
|
+
if (first === void 0 || asAwaitDecl(first) === null) {
|
|
613
|
+
i += 1;
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
const run = [];
|
|
617
|
+
let j = i;
|
|
618
|
+
for (; j < body.length; j += 1) {
|
|
619
|
+
const statement = body[j];
|
|
620
|
+
const decl = statement === void 0 ? null : asAwaitDecl(statement);
|
|
621
|
+
if (decl === null) {
|
|
622
|
+
break;
|
|
623
|
+
}
|
|
624
|
+
run.push(decl);
|
|
625
|
+
}
|
|
626
|
+
if (run.length >= 2 && isParallelizable(run)) {
|
|
627
|
+
reportRun(run);
|
|
628
|
+
}
|
|
629
|
+
i = j > i ? j : i + 1;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
function isParallelizable(run) {
|
|
633
|
+
const introduced = /* @__PURE__ */ new Set();
|
|
634
|
+
for (const decl of run) {
|
|
635
|
+
if (!isSimpleReadCall(decl.call)) {
|
|
636
|
+
return false;
|
|
637
|
+
}
|
|
638
|
+
const used = referencedNames2(decl.call);
|
|
639
|
+
for (const name of introduced) {
|
|
640
|
+
if (used.has(name)) {
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
introduced.add(decl.name);
|
|
645
|
+
}
|
|
646
|
+
return true;
|
|
647
|
+
}
|
|
648
|
+
function reportRun(run) {
|
|
649
|
+
const firstDecl = run[0];
|
|
650
|
+
const lastDecl = run[run.length - 1];
|
|
651
|
+
if (firstDecl === void 0 || lastDecl === void 0) {
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
const names = run.map((decl) => decl.name);
|
|
655
|
+
const exprs = run.map((decl) => context.sourceCode.getText(decl.call));
|
|
656
|
+
const replacement = `const [${names.join(", ")}] = await Promise.all([${exprs.join(", ")}]);`;
|
|
657
|
+
context.report({
|
|
658
|
+
node: firstDecl.statement,
|
|
659
|
+
messageId: "parallelizable",
|
|
660
|
+
data: { count: run.length },
|
|
661
|
+
suggest: [
|
|
662
|
+
{
|
|
663
|
+
messageId: "useParallel",
|
|
664
|
+
fix: (fixer) => fixer.replaceTextRange(
|
|
665
|
+
[firstDecl.statement.range[0], lastDecl.statement.range[1]],
|
|
666
|
+
replacement
|
|
667
|
+
)
|
|
668
|
+
}
|
|
669
|
+
]
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
return {
|
|
673
|
+
BlockStatement: checkBlock
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
// src/rules/require-fetch-timeout.ts
|
|
679
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES6 } from "@typescript-eslint/utils";
|
|
680
|
+
var RULE_NAME5 = "require-fetch-timeout";
|
|
681
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
682
|
+
var SIGNAL_KEYS = /* @__PURE__ */ new Set(["signal", "timeout"]);
|
|
683
|
+
var optionSchema2 = {
|
|
684
|
+
type: "object",
|
|
685
|
+
additionalProperties: false,
|
|
686
|
+
properties: {
|
|
687
|
+
callees: { type: "array", items: { type: "string" }, uniqueItems: true },
|
|
688
|
+
defaultTimeoutMs: { type: "integer", minimum: 1 }
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
function isUrlLike(node) {
|
|
692
|
+
return node.type === AST_NODE_TYPES6.TemplateLiteral || node.type === AST_NODE_TYPES6.Literal && typeof node.value === "string";
|
|
693
|
+
}
|
|
694
|
+
function probeOptionsObject(object) {
|
|
695
|
+
for (const property of object.properties) {
|
|
696
|
+
if (property.type === AST_NODE_TYPES6.SpreadElement) {
|
|
697
|
+
return { satisfied: false, opaque: true };
|
|
698
|
+
}
|
|
699
|
+
const key = property.key;
|
|
700
|
+
const name = key.type === AST_NODE_TYPES6.Identifier ? key.name : key.type === AST_NODE_TYPES6.Literal && typeof key.value === "string" ? key.value : null;
|
|
701
|
+
if (name !== null && SIGNAL_KEYS.has(name)) {
|
|
702
|
+
return { satisfied: true, opaque: false };
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return { satisfied: false, opaque: false };
|
|
706
|
+
}
|
|
707
|
+
var requireFetchTimeoutRule = createRule({
|
|
708
|
+
name: RULE_NAME5,
|
|
709
|
+
meta: {
|
|
710
|
+
type: "problem",
|
|
711
|
+
docs: {
|
|
712
|
+
description: "A `fetch` (or configured wrapper) call must carry a `signal`/`timeout` in its options \u2014 an unbounded request can hang forever."
|
|
713
|
+
},
|
|
714
|
+
hasSuggestions: true,
|
|
715
|
+
schema: [optionSchema2],
|
|
716
|
+
messages: {
|
|
717
|
+
missingTimeout: "`{{callee}}` has no timeout \u2014 pass a `signal` (e.g. `AbortSignal.timeout({{ms}})`) or a `timeout` option so the request cannot hang indefinitely.",
|
|
718
|
+
addTimeout: "Add `signal: AbortSignal.timeout({{ms}})`."
|
|
719
|
+
}
|
|
720
|
+
},
|
|
721
|
+
defaultOptions: [{ callees: [], defaultTimeoutMs: DEFAULT_TIMEOUT_MS }],
|
|
722
|
+
create(context, [options]) {
|
|
723
|
+
const timeoutMs = options.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
724
|
+
const matched = /* @__PURE__ */ new Set(["fetch", ...options.callees ?? []]);
|
|
725
|
+
return {
|
|
726
|
+
CallExpression(node) {
|
|
727
|
+
const name = calleeText(node.callee);
|
|
728
|
+
if (name === null || !matched.has(name)) {
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
const args = node.arguments;
|
|
732
|
+
if (args.some((arg) => arg.type === AST_NODE_TYPES6.SpreadElement)) {
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
const objectArgs = args.filter(
|
|
736
|
+
(arg) => arg.type === AST_NODE_TYPES6.ObjectExpression
|
|
737
|
+
);
|
|
738
|
+
if (objectArgs.length > 0) {
|
|
739
|
+
const probes = objectArgs.map(probeOptionsObject);
|
|
740
|
+
if (probes.some((probe) => probe.satisfied || probe.opaque)) {
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const target = objectArgs[objectArgs.length - 1];
|
|
744
|
+
if (target === void 0) {
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
context.report({
|
|
748
|
+
node: node.callee,
|
|
749
|
+
messageId: "missingTimeout",
|
|
750
|
+
data: { callee: name, ms: timeoutMs },
|
|
751
|
+
suggest: [
|
|
752
|
+
{
|
|
753
|
+
messageId: "addTimeout",
|
|
754
|
+
data: { ms: timeoutMs },
|
|
755
|
+
fix: (fixer) => {
|
|
756
|
+
const insertion = `signal: AbortSignal.timeout(${timeoutMs})`;
|
|
757
|
+
const first = target.properties[0];
|
|
758
|
+
if (first === void 0) {
|
|
759
|
+
return fixer.replaceText(target, `{ ${insertion} }`);
|
|
760
|
+
}
|
|
761
|
+
return fixer.insertTextBefore(first, `${insertion}, `);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
]
|
|
765
|
+
});
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
if (args.length === 0 || !args.every(isUrlLike)) {
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
const lastArg = args[args.length - 1];
|
|
772
|
+
if (lastArg === void 0) {
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
context.report({
|
|
776
|
+
node: node.callee,
|
|
777
|
+
messageId: "missingTimeout",
|
|
778
|
+
data: { callee: name, ms: timeoutMs },
|
|
779
|
+
suggest: [
|
|
780
|
+
{
|
|
781
|
+
messageId: "addTimeout",
|
|
782
|
+
data: { ms: timeoutMs },
|
|
783
|
+
fix: (fixer) => fixer.insertTextAfter(lastArg, `, { signal: AbortSignal.timeout(${timeoutMs}) }`)
|
|
784
|
+
}
|
|
785
|
+
]
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
// src/rules/index.ts
|
|
793
|
+
var rules = {
|
|
794
|
+
"require-fetch-timeout": requireFetchTimeoutRule,
|
|
795
|
+
"forward-abort-signal": forwardAbortSignalRule,
|
|
796
|
+
"no-shared-mutable-module-state": noSharedMutableModuleStateRule,
|
|
797
|
+
"prefer-parallel-awaits": preferParallelAwaitsRule,
|
|
798
|
+
"no-concurrent-shared-mutation": noConcurrentSharedMutationRule
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
// src/index.ts
|
|
802
|
+
var NAMESPACE = "noctcore-async-safety";
|
|
803
|
+
var VERSION = "0.1.0";
|
|
804
|
+
var plugin = {
|
|
805
|
+
meta: { name: "@noctcore/eslint-plugin-async-safety", version: VERSION },
|
|
806
|
+
rules,
|
|
807
|
+
configs: {}
|
|
808
|
+
};
|
|
809
|
+
plugin.configs.recommended = {
|
|
810
|
+
plugins: { [NAMESPACE]: plugin },
|
|
811
|
+
rules: recommended
|
|
812
|
+
};
|
|
813
|
+
var configs = plugin.configs;
|
|
814
|
+
var index_default = plugin;
|
|
815
|
+
export {
|
|
816
|
+
configs,
|
|
817
|
+
index_default as default,
|
|
818
|
+
rules
|
|
819
|
+
};
|