@blumintinc/eslint-plugin-blumint 1.20.144 → 1.20.146
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/lib/index.js +1 -1
- package/lib/rules/enforce-memoize-async.js +311 -0
- package/lib/rules/no-always-true-false-conditions.js +80 -1
- package/lib/rules/no-explicit-return-type.js +11 -125
- package/lib/rules/no-redundant-annotation-assertion.js +69 -5
- package/lib/rules/no-undefined-null-passthrough.js +15 -8
- package/lib/rules/prefer-use-deep-compare-memo.js +307 -19
- package/lib/rules/prevent-children-clobber.js +297 -35
- package/lib/rules/vertically-group-related-functions.js +154 -0
- package/package.json +1 -1
- package/release-manifest.json +68 -0
package/lib/index.js
CHANGED
|
@@ -196,6 +196,246 @@ function declaresFunctionParameter(params) {
|
|
|
196
196
|
}
|
|
197
197
|
return parameterAnnotation(params[0])?.type === utils_1.AST_NODE_TYPES.TSFunctionType;
|
|
198
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* A database transaction handle is valid only for the attempt that created it,
|
|
201
|
+
* and a transaction body is re-run whenever the driver retries — Firestore
|
|
202
|
+
* retries an attempt whose reads a concurrent write invalidated. A memoized
|
|
203
|
+
* body hands the retry the first attempt's cached promise, so the retry queues
|
|
204
|
+
* no writes on its own handle, commits empty, and the caller reads the first
|
|
205
|
+
* attempt's return value and reports success. Memoizing the method that OWNS
|
|
206
|
+
* the transaction is the same defect one level up: the whole transaction, writes
|
|
207
|
+
* included, then runs once per instance.
|
|
208
|
+
*/
|
|
209
|
+
const TRANSACTION_TYPE_NAME = 'Transaction';
|
|
210
|
+
const RUN_TRANSACTION_NAME = 'runTransaction';
|
|
211
|
+
/**
|
|
212
|
+
* Keys that hold source positions or the tree's only back-edge rather than
|
|
213
|
+
* child nodes; `parent` would make a subtree walk non-terminating.
|
|
214
|
+
*/
|
|
215
|
+
const NON_TRAVERSABLE_KEYS = new Set(['parent', 'range', 'loc', 'type']);
|
|
216
|
+
/** Every node of `root`'s subtree, `root` included. */
|
|
217
|
+
function* subtreeOf(root) {
|
|
218
|
+
yield root;
|
|
219
|
+
for (const [key, value] of Object.entries(root)) {
|
|
220
|
+
if (NON_TRAVERSABLE_KEYS.has(key)) {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (Array.isArray(value)) {
|
|
224
|
+
for (const element of value) {
|
|
225
|
+
if (ASTHelpers_1.ASTHelpers.isNode(element)) {
|
|
226
|
+
yield* subtreeOf(element);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else if (ASTHelpers_1.ASTHelpers.isNode(value)) {
|
|
231
|
+
yield* subtreeOf(value);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** The name a call invokes, for `f()` and for `o.f()` alike. */
|
|
236
|
+
function calleeName(node) {
|
|
237
|
+
const callee = withoutChain(node.callee);
|
|
238
|
+
if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
239
|
+
return callee.name;
|
|
240
|
+
}
|
|
241
|
+
if (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
242
|
+
!callee.computed &&
|
|
243
|
+
callee.property.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
244
|
+
return callee.property.name;
|
|
245
|
+
}
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Whether the node is a `runTransaction(…)` call, under any receiver:
|
|
250
|
+
* `db.runTransaction`, `firestore.runTransaction` and a bare imported
|
|
251
|
+
* `runTransaction` all open a retryable transaction.
|
|
252
|
+
*/
|
|
253
|
+
function isRunTransactionCall(node) {
|
|
254
|
+
return (node.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
255
|
+
calleeName(node) === RUN_TRANSACTION_NAME);
|
|
256
|
+
}
|
|
257
|
+
/** Whether the method opens a transaction anywhere in its own body. */
|
|
258
|
+
function ownsTransaction(fn) {
|
|
259
|
+
for (const node of subtreeOf(fn)) {
|
|
260
|
+
if (isRunTransactionCall(node)) {
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Whether a type name denotes the transaction handle. The rightmost segment is
|
|
268
|
+
* the type's own name, so the qualified spellings — `FirebaseFirestore.
|
|
269
|
+
* Transaction`, `admin.firestore.Transaction` — answer alongside the bare one;
|
|
270
|
+
* a locally aliased import (`import { Transaction as Txn }`) answers through
|
|
271
|
+
* the alias set the file's imports define.
|
|
272
|
+
*/
|
|
273
|
+
function namesTransaction(typeName, aliases) {
|
|
274
|
+
if (typeName.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
275
|
+
return (typeName.name === TRANSACTION_TYPE_NAME || aliases.has(typeName.name));
|
|
276
|
+
}
|
|
277
|
+
if (typeName.type === utils_1.AST_NODE_TYPES.TSQualifiedName) {
|
|
278
|
+
return typeName.right.name === TRANSACTION_TYPE_NAME;
|
|
279
|
+
}
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Whether a declared type hands the method a transaction handle: written
|
|
284
|
+
* directly, as one arm of a union or intersection, or as a property of an
|
|
285
|
+
* object type — the shape a destructured `{ transaction }: { transaction:
|
|
286
|
+
* Transaction }` parameter carries.
|
|
287
|
+
*
|
|
288
|
+
* Type ARGUMENTS are deliberately not entered: `Map<string, Transaction>` or
|
|
289
|
+
* `Promise<Transaction>` describes a collection of handles or a handle yet to
|
|
290
|
+
* exist, neither of which is the attempt-scoped handle this carve-out is about.
|
|
291
|
+
*/
|
|
292
|
+
function declaresTransactionType(annotation, aliases) {
|
|
293
|
+
if (!annotation) {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
if (annotation.type === utils_1.AST_NODE_TYPES.TSTypeReference) {
|
|
297
|
+
return namesTransaction(annotation.typeName, aliases);
|
|
298
|
+
}
|
|
299
|
+
if (annotation.type === utils_1.AST_NODE_TYPES.TSUnionType ||
|
|
300
|
+
annotation.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
|
|
301
|
+
return annotation.types.some((member) => declaresTransactionType(member, aliases));
|
|
302
|
+
}
|
|
303
|
+
if (annotation.type === utils_1.AST_NODE_TYPES.TSTypeLiteral) {
|
|
304
|
+
return annotation.members.some((member) => member.type === utils_1.AST_NODE_TYPES.TSPropertySignature &&
|
|
305
|
+
declaresTransactionType(member.typeAnnotation?.typeAnnotation, aliases));
|
|
306
|
+
}
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* The type annotation a parameter declares, including the destructuring shapes
|
|
311
|
+
* `parameterAnnotation` does not reach: an object or array pattern carries its
|
|
312
|
+
* annotation on the pattern itself.
|
|
313
|
+
*/
|
|
314
|
+
function bindingAnnotation(param) {
|
|
315
|
+
if (param.type === utils_1.AST_NODE_TYPES.ObjectPattern ||
|
|
316
|
+
param.type === utils_1.AST_NODE_TYPES.ArrayPattern) {
|
|
317
|
+
return param.typeAnnotation?.typeAnnotation;
|
|
318
|
+
}
|
|
319
|
+
return parameterAnnotation(param);
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Whether the method declares a parameter typed as a transaction handle.
|
|
323
|
+
*
|
|
324
|
+
* The test reads the ANNOTATION, not the parameter's name: a parameter merely
|
|
325
|
+
* named `transaction` is as likely to hold a payment or a ledger entry, and the
|
|
326
|
+
* rule's other carve-outs (void result, callback parameter) are annotation-driven
|
|
327
|
+
* for the same reason. A bare `async apply(transaction)` therefore keeps
|
|
328
|
+
* reporting — it declares nothing to honour, and under the `noImplicitAny` its
|
|
329
|
+
* consumers compile with it does not type-check anyway. Where the handle arrives
|
|
330
|
+
* through an unresolvable alias (`args: MembershipArgs`), the call-site test
|
|
331
|
+
* below is what recognises it.
|
|
332
|
+
*/
|
|
333
|
+
function declaresTransactionParameter(params, aliases) {
|
|
334
|
+
return params.some((param) => declaresTransactionType(bindingAnnotation(param), aliases));
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* The expression itself, with any `ChainExpression` wrapper removed. ESTree
|
|
338
|
+
* wraps a whole optional chain in that node, so `this?.body` and
|
|
339
|
+
* `this.body.bind?.(this)` reach a bare member/call test as something else
|
|
340
|
+
* entirely. Nullish spellings carry the transaction handle exactly as the plain
|
|
341
|
+
* ones do, and reading through the wrapper is what keeps the carve-out from
|
|
342
|
+
* lapsing on them — a lapse that would restore the empty-commit autofix.
|
|
343
|
+
*/
|
|
344
|
+
function withoutChain(node) {
|
|
345
|
+
return node.type === utils_1.AST_NODE_TYPES.ChainExpression
|
|
346
|
+
? withoutChain(node.expression)
|
|
347
|
+
: node;
|
|
348
|
+
}
|
|
349
|
+
/** The own-method name a `this.foo` reference reads, if it reads one. */
|
|
350
|
+
function thisMemberName(node) {
|
|
351
|
+
const expression = withoutChain(node);
|
|
352
|
+
if (expression.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
353
|
+
!expression.computed &&
|
|
354
|
+
withoutChain(expression.object).type === utils_1.AST_NODE_TYPES.ThisExpression &&
|
|
355
|
+
expression.property.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
356
|
+
return expression.property.name;
|
|
357
|
+
}
|
|
358
|
+
// `this.body.bind(this)` passes the same method, one wrapper out.
|
|
359
|
+
if (expression.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
360
|
+
expression.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
361
|
+
!expression.callee.computed &&
|
|
362
|
+
expression.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
363
|
+
expression.callee.property.name === 'bind') {
|
|
364
|
+
return thisMemberName(expression.callee.object);
|
|
365
|
+
}
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
/** Whether the subtree mentions the binding, under any nesting. */
|
|
369
|
+
function mentionsBinding(node, name) {
|
|
370
|
+
for (const descendant of subtreeOf(node)) {
|
|
371
|
+
if (descendant.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
372
|
+
descendant.name === name) {
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Own methods a `runTransaction` argument hands the attempt to: the method
|
|
380
|
+
* passed as the callback itself, and every `this.method(…)` the callback body
|
|
381
|
+
* invokes with the attempt's handle among its arguments.
|
|
382
|
+
*
|
|
383
|
+
* Passing the handle on is what makes a method part of the attempt, so a
|
|
384
|
+
* helper the callback calls WITHOUT it — a config read, a lookup that takes no
|
|
385
|
+
* transaction — is untouched and keeps reporting.
|
|
386
|
+
*/
|
|
387
|
+
function collectTransactionParticipants(argument, participants) {
|
|
388
|
+
const passed = thisMemberName(argument);
|
|
389
|
+
if (passed) {
|
|
390
|
+
participants.add(passed);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (argument.type !== utils_1.AST_NODE_TYPES.ArrowFunctionExpression &&
|
|
394
|
+
argument.type !== utils_1.AST_NODE_TYPES.FunctionExpression) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const handle = argument.params[0];
|
|
398
|
+
if (handle?.type !== utils_1.AST_NODE_TYPES.Identifier) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
for (const node of subtreeOf(argument.body)) {
|
|
402
|
+
if (node.type !== utils_1.AST_NODE_TYPES.CallExpression) {
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
const method = thisMemberName(node.callee);
|
|
406
|
+
if (method &&
|
|
407
|
+
node.arguments.some((arg) => mentionsBinding(arg, handle.name))) {
|
|
408
|
+
participants.add(method);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
/** Every own method the class hands a transaction handle to. */
|
|
413
|
+
function transactionParticipantsOf(body) {
|
|
414
|
+
const participants = new Set();
|
|
415
|
+
for (const node of subtreeOf(body)) {
|
|
416
|
+
if (!isRunTransactionCall(node)) {
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
for (const argument of node.arguments) {
|
|
420
|
+
collectTransactionParticipants(argument, participants);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return participants;
|
|
424
|
+
}
|
|
425
|
+
/** The statically known name of a method, for matching call sites against it. */
|
|
426
|
+
function methodName(node) {
|
|
427
|
+
if (node.computed) {
|
|
428
|
+
return undefined;
|
|
429
|
+
}
|
|
430
|
+
const { key } = node;
|
|
431
|
+
if (key.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
432
|
+
return key.name;
|
|
433
|
+
}
|
|
434
|
+
if (key.type === utils_1.AST_NODE_TYPES.Literal && typeof key.value === 'string') {
|
|
435
|
+
return key.value;
|
|
436
|
+
}
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
199
439
|
/**
|
|
200
440
|
* Matches a memoize decorator in supported syntaxes:
|
|
201
441
|
* - @Alias()
|
|
@@ -290,6 +530,66 @@ exports.enforceMemoizeAsync = (0, createRule_1.createRule)({
|
|
|
290
530
|
}
|
|
291
531
|
return memoizeImportCache;
|
|
292
532
|
};
|
|
533
|
+
/**
|
|
534
|
+
* Local names bound to an imported `Transaction` type, so that an aliased
|
|
535
|
+
* import (`import { Transaction as Txn } from 'firebase-admin/firestore'`)
|
|
536
|
+
* is read as the handle it is. The module is not constrained: a handle is
|
|
537
|
+
* re-exported through as many paths as a codebase has layers, and the
|
|
538
|
+
* imported NAME already carries the signal.
|
|
539
|
+
*/
|
|
540
|
+
let transactionAliasCache = null;
|
|
541
|
+
const transactionAliases = () => {
|
|
542
|
+
if (!transactionAliasCache) {
|
|
543
|
+
transactionAliasCache = new Set();
|
|
544
|
+
for (const statement of context.sourceCode.ast.body) {
|
|
545
|
+
if (statement.type !== utils_1.AST_NODE_TYPES.ImportDeclaration) {
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
for (const spec of statement.specifiers) {
|
|
549
|
+
if (spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
550
|
+
spec.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
551
|
+
spec.imported.name === TRANSACTION_TYPE_NAME) {
|
|
552
|
+
transactionAliasCache.add(spec.local.name);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return transactionAliasCache;
|
|
558
|
+
};
|
|
559
|
+
/**
|
|
560
|
+
* The class-level scan is shared by every method of the class, so it runs
|
|
561
|
+
* once per class body rather than once per candidate method.
|
|
562
|
+
*/
|
|
563
|
+
const participantCache = new WeakMap();
|
|
564
|
+
const transactionParticipants = (body) => {
|
|
565
|
+
let participants = participantCache.get(body);
|
|
566
|
+
if (!participants) {
|
|
567
|
+
participants = transactionParticipantsOf(body);
|
|
568
|
+
participantCache.set(body, participants);
|
|
569
|
+
}
|
|
570
|
+
return participants;
|
|
571
|
+
};
|
|
572
|
+
/**
|
|
573
|
+
* Whether the method takes part in a database transaction attempt, either
|
|
574
|
+
* by opening one or by being handed the attempt's handle. Caching such a
|
|
575
|
+
* method is never an optimisation: a retried attempt would replay the first
|
|
576
|
+
* attempt's promise, writing nothing on its own handle while the caller
|
|
577
|
+
* reads a success it did not get.
|
|
578
|
+
*/
|
|
579
|
+
const participatesInTransaction = (node, fn) => {
|
|
580
|
+
if (declaresTransactionParameter(fn.params, transactionAliases())) {
|
|
581
|
+
return true;
|
|
582
|
+
}
|
|
583
|
+
if (ownsTransaction(fn)) {
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
586
|
+
const body = node.parent;
|
|
587
|
+
if (body?.type !== utils_1.AST_NODE_TYPES.ClassBody) {
|
|
588
|
+
return false;
|
|
589
|
+
}
|
|
590
|
+
const name = methodName(node);
|
|
591
|
+
return name !== undefined && transactionParticipants(body).has(name);
|
|
592
|
+
};
|
|
293
593
|
return {
|
|
294
594
|
MethodDefinition(node) {
|
|
295
595
|
// Only process async instance methods (skip static methods)
|
|
@@ -341,6 +641,17 @@ exports.enforceMemoizeAsync = (0, createRule_1.createRule)({
|
|
|
341
641
|
if (declaresVoidResult(node.value.returnType)) {
|
|
342
642
|
return;
|
|
343
643
|
}
|
|
644
|
+
// A transaction handle is valid only for the attempt that created it,
|
|
645
|
+
// so a result derived from one must not outlive that attempt. Caching
|
|
646
|
+
// the body of a `runTransaction` callback — or the method that owns the
|
|
647
|
+
// call — turns the retry a concurrent write provokes into a silent
|
|
648
|
+
// no-op: the retry replays the first attempt's promise, queues nothing
|
|
649
|
+
// on its own handle, commits empty, and reports the first attempt's
|
|
650
|
+
// value as success. The fixer would apply that unattended under
|
|
651
|
+
// `--fix`, so both report and fix are withheld.
|
|
652
|
+
if (participatesInTransaction(node, node.value)) {
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
344
655
|
const { aliases: memoizeAliases, namespaces: memoizeNamespaces } = memoizeImports();
|
|
345
656
|
const hasMemoizeImport = memoizeAliases.size > 0 || memoizeNamespaces.size > 0;
|
|
346
657
|
// Check if method already has @Memoize or @Memoize() decorator
|
|
@@ -3,6 +3,81 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.noAlwaysTrueFalseConditions = void 0;
|
|
4
4
|
const createRule_1 = require("../utils/createRule");
|
|
5
5
|
const utils_1 = require("@typescript-eslint/utils");
|
|
6
|
+
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
7
|
+
const BREAKABLE = new Set([
|
|
8
|
+
utils_1.AST_NODE_TYPES.WhileStatement,
|
|
9
|
+
utils_1.AST_NODE_TYPES.DoWhileStatement,
|
|
10
|
+
utils_1.AST_NODE_TYPES.ForStatement,
|
|
11
|
+
utils_1.AST_NODE_TYPES.ForInStatement,
|
|
12
|
+
utils_1.AST_NODE_TYPES.ForOfStatement,
|
|
13
|
+
utils_1.AST_NODE_TYPES.SwitchStatement,
|
|
14
|
+
]);
|
|
15
|
+
/**
|
|
16
|
+
* Whether `body` can leave the loop that owns it.
|
|
17
|
+
*
|
|
18
|
+
* An unlabeled `break` binds to the nearest enclosing loop or switch, so a
|
|
19
|
+
* nested breakable is still walked but its own unlabeled breaks do not count; a
|
|
20
|
+
* labeled one counts when it names this loop. `return` and `throw` leave the
|
|
21
|
+
* loop as well, unless they sit inside a nested function, which has its own.
|
|
22
|
+
*/
|
|
23
|
+
function canExitLoop(body, label) {
|
|
24
|
+
let escapes = false;
|
|
25
|
+
const visit = (node, insideNestedBreakable) => {
|
|
26
|
+
if (escapes)
|
|
27
|
+
return;
|
|
28
|
+
switch (node.type) {
|
|
29
|
+
case utils_1.AST_NODE_TYPES.FunctionDeclaration:
|
|
30
|
+
case utils_1.AST_NODE_TYPES.FunctionExpression:
|
|
31
|
+
case utils_1.AST_NODE_TYPES.ArrowFunctionExpression:
|
|
32
|
+
return;
|
|
33
|
+
case utils_1.AST_NODE_TYPES.BreakStatement:
|
|
34
|
+
if (node.label ? node.label.name === label : !insideNestedBreakable) {
|
|
35
|
+
escapes = true;
|
|
36
|
+
}
|
|
37
|
+
return;
|
|
38
|
+
case utils_1.AST_NODE_TYPES.ReturnStatement:
|
|
39
|
+
case utils_1.AST_NODE_TYPES.ThrowStatement:
|
|
40
|
+
escapes = true;
|
|
41
|
+
return;
|
|
42
|
+
default:
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
const nested = insideNestedBreakable || BREAKABLE.has(node.type);
|
|
46
|
+
for (const [key, value] of Object.entries(node)) {
|
|
47
|
+
if (key === 'parent')
|
|
48
|
+
continue;
|
|
49
|
+
if (Array.isArray(value)) {
|
|
50
|
+
for (const child of value) {
|
|
51
|
+
if (ASTHelpers_1.ASTHelpers.isNode(child))
|
|
52
|
+
visit(child, nested);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else if (ASTHelpers_1.ASTHelpers.isNode(value)) {
|
|
56
|
+
visit(value, nested);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
visit(body, false);
|
|
61
|
+
return escapes;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* `while (true)` and `do … while (true)` around a `break` are how a loop whose
|
|
65
|
+
* exit is only known mid-body is written — cursor pagination being the usual
|
|
66
|
+
* case. The condition is the point, and unlike an `if` it cannot be removed:
|
|
67
|
+
* the only way to satisfy the report is to rewrite the loop as `for (;;)`,
|
|
68
|
+
* which this rule already accepts because it has no test node to check. A
|
|
69
|
+
* literal `true` over a body with no way out is still reported, since that loop
|
|
70
|
+
* really does run forever (#1973).
|
|
71
|
+
*/
|
|
72
|
+
function isDeliberateInfiniteLoop(loop) {
|
|
73
|
+
if (loop.test?.type !== utils_1.AST_NODE_TYPES.Literal || loop.test.value !== true) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
const label = loop.parent?.type === utils_1.AST_NODE_TYPES.LabeledStatement
|
|
77
|
+
? loop.parent.label.name
|
|
78
|
+
: null;
|
|
79
|
+
return canExitLoop(loop.body, label);
|
|
80
|
+
}
|
|
6
81
|
exports.noAlwaysTrueFalseConditions = (0, createRule_1.createRule)({
|
|
7
82
|
name: 'no-always-true-false-conditions',
|
|
8
83
|
meta: {
|
|
@@ -1498,15 +1573,19 @@ exports.noAlwaysTrueFalseConditions = (0, createRule_1.createRule)({
|
|
|
1498
1573
|
},
|
|
1499
1574
|
// Check while loops
|
|
1500
1575
|
WhileStatement(node) {
|
|
1576
|
+
if (isDeliberateInfiniteLoop(node))
|
|
1577
|
+
return;
|
|
1501
1578
|
checkCondition(node.test);
|
|
1502
1579
|
},
|
|
1503
1580
|
// Check do-while loops
|
|
1504
1581
|
DoWhileStatement(node) {
|
|
1582
|
+
if (isDeliberateInfiniteLoop(node))
|
|
1583
|
+
return;
|
|
1505
1584
|
checkCondition(node.test);
|
|
1506
1585
|
},
|
|
1507
1586
|
// Check for loop conditions
|
|
1508
1587
|
ForStatement(node) {
|
|
1509
|
-
if (node.test) {
|
|
1588
|
+
if (node.test && !isDeliberateInfiniteLoop(node)) {
|
|
1510
1589
|
checkCondition(node.test);
|
|
1511
1590
|
}
|
|
1512
1591
|
},
|
|
@@ -8,6 +8,7 @@ const importRemoval_1 = require("../utils/importRemoval");
|
|
|
8
8
|
const typeDeclarationRemoval_1 = require("../utils/typeDeclarationRemoval");
|
|
9
9
|
const replacementSegments_1 = require("../utils/replacementSegments");
|
|
10
10
|
const lexicalScope_1 = require("../utils/lexicalScope");
|
|
11
|
+
const arrowAnnotationGap_1 = require("../utils/arrowAnnotationGap");
|
|
11
12
|
const defaultOptions = {
|
|
12
13
|
allowRecursiveFunctions: true,
|
|
13
14
|
allowOverloadedFunctions: true,
|
|
@@ -780,25 +781,6 @@ function batchAnnotations(source, candidates) {
|
|
|
780
781
|
});
|
|
781
782
|
return [...batches.values()];
|
|
782
783
|
}
|
|
783
|
-
/**
|
|
784
|
-
* A comment whose meaning is tied to where it sits. Re-emitting one somewhere
|
|
785
|
-
* else retargets it — a disable directive lands on an unrelated line and a
|
|
786
|
-
* `@ts-expect-error` becomes an error of its own — so a removal that would move
|
|
787
|
-
* one is withheld instead.
|
|
788
|
-
*/
|
|
789
|
-
function isPositionalDirective(comment) {
|
|
790
|
-
if ((0, disableDirectives_1.parseDisableDirectives)([comment]).length > 0) {
|
|
791
|
-
return true;
|
|
792
|
-
}
|
|
793
|
-
const value = comment.value.trim();
|
|
794
|
-
return value.startsWith('@ts-expect-error') || value.startsWith('@ts-ignore');
|
|
795
|
-
}
|
|
796
|
-
/** The indentation of the line `offset` sits on, for a carried line break. */
|
|
797
|
-
function indentAt(source, offset) {
|
|
798
|
-
const lineStart = source.text.lastIndexOf('\n', offset - 1) + 1;
|
|
799
|
-
const [indent] = /^[ \t]*/.exec(source.text.slice(lineStart, offset)) ?? [''];
|
|
800
|
-
return indent;
|
|
801
|
-
}
|
|
802
784
|
/**
|
|
803
785
|
* Whether the span deletes a whole declaration of the program.
|
|
804
786
|
*
|
|
@@ -827,9 +809,9 @@ function carriedText(source, range) {
|
|
|
827
809
|
.filter((comment) => comment.range[0] >= range[0] && comment.range[1] <= range[1]);
|
|
828
810
|
if (comments.length === 0)
|
|
829
811
|
return '';
|
|
830
|
-
if (comments.some(isPositionalDirective))
|
|
812
|
+
if (comments.some(arrowAnnotationGap_1.isPositionalDirective))
|
|
831
813
|
return null;
|
|
832
|
-
const indent = indentAt(source, range[0]);
|
|
814
|
+
const indent = (0, arrowAnnotationGap_1.indentAt)(source, range[0]);
|
|
833
815
|
const segments = comments.map((comment) => ({
|
|
834
816
|
text: source.text.slice(comment.range[0], comment.range[1]),
|
|
835
817
|
breakAfter: (0, replacementSegments_1.requiresLineBreakAfter)(comment),
|
|
@@ -846,70 +828,20 @@ function carriedText(source, range) {
|
|
|
846
828
|
: ' ';
|
|
847
829
|
return `${lead}${body}${trail}`;
|
|
848
830
|
}
|
|
849
|
-
/** Every character the syntactic grammar counts as a LineTerminator. */
|
|
850
|
-
const LINE_TERMINATOR = /[\n\r\u2028\u2029]/;
|
|
851
|
-
const textOf = (source, range) => source.text.slice(range[0], range[1]);
|
|
852
|
-
/**
|
|
853
|
-
* The span an arrow's return annotation occupies between the parameter list and
|
|
854
|
-
* the `=>`, together with that arrow token.
|
|
855
|
-
*
|
|
856
|
-
* The span holds the annotation, whitespace and comments and nothing else,
|
|
857
|
-
* which is what makes it safe to rewrite wholesale: no binding reference can
|
|
858
|
-
* hide in it beyond the ones the annotation itself names.
|
|
859
|
-
*/
|
|
860
|
-
function arrowAnnotationGap(source, returnType) {
|
|
861
|
-
const parametersEnd = source.getTokenBefore(returnType);
|
|
862
|
-
const arrow = source.getTokenAfter(returnType, {
|
|
863
|
-
filter: (token) => token.value === '=>',
|
|
864
|
-
});
|
|
865
|
-
if (!parametersEnd || !arrow)
|
|
866
|
-
return null;
|
|
867
|
-
const gap = [parametersEnd.range[1], arrow.range[0]];
|
|
868
|
-
return containsRange(gap, returnType.range) ? { gap, arrow } : null;
|
|
869
|
-
}
|
|
870
|
-
/**
|
|
871
|
-
* Re-emits `comments` on the far side of the arrow, where a line terminator is
|
|
872
|
-
* inert, consuming the horizontal whitespace the arrow already had after it so
|
|
873
|
-
* the body keeps a single separator.
|
|
874
|
-
*/
|
|
875
|
-
function hoistPastArrow(source, arrow, comments) {
|
|
876
|
-
const indent = indentAt(source, arrow.range[0]);
|
|
877
|
-
const trailingText = source.text.slice(arrow.range[1]);
|
|
878
|
-
const [spacing] = /^[ \t]*/.exec(trailingText) ?? [''];
|
|
879
|
-
const body = (0, replacementSegments_1.joinSegmentBody)(comments.map((comment) => ({
|
|
880
|
-
text: textOf(source, comment.range),
|
|
881
|
-
breakAfter: true,
|
|
882
|
-
})), indent);
|
|
883
|
-
const rest = trailingText.slice(spacing.length);
|
|
884
|
-
const separator = LINE_TERMINATOR.test(rest.charAt(0))
|
|
885
|
-
? ''
|
|
886
|
-
: (0, replacementSegments_1.requiresLineBreakAfter)(comments[comments.length - 1])
|
|
887
|
-
? `\n${indent}`
|
|
888
|
-
: ' ';
|
|
889
|
-
return {
|
|
890
|
-
range: [arrow.range[1], arrow.range[1] + spacing.length],
|
|
891
|
-
text: ` ${body}${separator}`,
|
|
892
|
-
};
|
|
893
|
-
}
|
|
894
831
|
/**
|
|
895
832
|
* The edits that strip one annotation, carrying every comment the strip
|
|
896
833
|
* strands rather than deleting it (#1877). `null` withholds the fix, for a
|
|
897
834
|
* comment whose meaning is its position and which cannot stay where it is.
|
|
898
835
|
*
|
|
899
836
|
* An arrow is the one subject whose annotation sits inside a restricted
|
|
900
|
-
* production
|
|
901
|
-
*
|
|
902
|
-
*
|
|
903
|
-
*
|
|
904
|
-
* hard SyntaxError that only V8 reports, since `@typescript-eslint/parser`
|
|
905
|
-
* accepts it (#1964). Such a comment is re-emitted past the `=>` instead, the
|
|
906
|
-
* nearest position outside the restricted gap that cannot itself begin one;
|
|
907
|
-
* hoisting it above the enclosing line would anchor an insertion at a column
|
|
908
|
-
* zero that may sit inside a template literal or JSX text, where the comment
|
|
909
|
-
* would become content rather than code.
|
|
837
|
+
* production, so its edits come from the shared planner that answers for that
|
|
838
|
+
* grammar (#1964). The removal span handed to it is the annotation's own
|
|
839
|
+
* range: unlike the planner's other caller, nothing here reaches back over the
|
|
840
|
+
* whitespace ahead of the `:`.
|
|
910
841
|
*
|
|
911
842
|
* Every other subject ends its parameter list at a body or a semicolon, so its
|
|
912
|
-
* stranded comments stay where they were written
|
|
843
|
+
* stranded comments stay where they were written and a deletion that carries
|
|
844
|
+
* them in place is correct.
|
|
913
845
|
*/
|
|
914
846
|
function planAnnotationEdits(source, entry) {
|
|
915
847
|
const range = entry.returnType.range;
|
|
@@ -917,53 +849,7 @@ function planAnnotationEdits(source, entry) {
|
|
|
917
849
|
const carried = carriedText(source, range);
|
|
918
850
|
return carried === null ? null : [{ range, text: carried }];
|
|
919
851
|
}
|
|
920
|
-
|
|
921
|
-
if (!gapInfo)
|
|
922
|
-
return null;
|
|
923
|
-
const { gap, arrow } = gapInfo;
|
|
924
|
-
const comments = source
|
|
925
|
-
.getAllComments()
|
|
926
|
-
.filter((comment) => containsRange(gap, comment.range));
|
|
927
|
-
const stranded = comments.filter((comment) => containsRange(range, comment.range));
|
|
928
|
-
// What the plain deletion would leave between the parameters and the arrow.
|
|
929
|
-
// A comment left there contributes its own text, so a line comment or a
|
|
930
|
-
// multi-line block comment shows up here as the line terminator it is.
|
|
931
|
-
const residue = `${textOf(source, [gap[0], range[0]])}${textOf(source, [
|
|
932
|
-
range[1],
|
|
933
|
-
gap[1],
|
|
934
|
-
])}`;
|
|
935
|
-
// The plain deletion is kept wherever it already lands a legal gap and
|
|
936
|
-
// strands nothing, so no output that survives today moves by a byte.
|
|
937
|
-
if (stranded.length === 0 && !LINE_TERMINATOR.test(residue)) {
|
|
938
|
-
return [{ range, text: '' }];
|
|
939
|
-
}
|
|
940
|
-
// Rewriting the gap collapses the lines it spanned, which moves the line a
|
|
941
|
-
// directive inside it points at, so the whole fix is withheld rather than
|
|
942
|
-
// retargeting one. The gap a directive can share with nothing else is left
|
|
943
|
-
// untouched by the branch above.
|
|
944
|
-
if (comments.some(isPositionalDirective))
|
|
945
|
-
return null;
|
|
946
|
-
const hoisted = comments.filter(replacementSegments_1.requiresOwnLine);
|
|
947
|
-
const inline = comments
|
|
948
|
-
.filter((comment) => !(0, replacementSegments_1.requiresOwnLine)(comment))
|
|
949
|
-
.map((comment) => textOf(source, comment.range));
|
|
950
|
-
const edits = [
|
|
951
|
-
{ range: gap, text: inline.length === 0 ? ' ' : ` ${inline.join(' ')} ` },
|
|
952
|
-
];
|
|
953
|
-
if (hoisted.length > 0) {
|
|
954
|
-
edits.push(hoistPastArrow(source, arrow, hoisted));
|
|
955
|
-
}
|
|
956
|
-
return edits;
|
|
957
|
-
}
|
|
958
|
-
/**
|
|
959
|
-
* ESLint applies a fix whole or not at all, and rejects one whose edits
|
|
960
|
-
* overlap. Two spans planned independently — an annotation and the declaration
|
|
961
|
-
* that strands it — can only overlap if a premise here is wrong, so an overlap
|
|
962
|
-
* withdraws the fix rather than throwing at apply time.
|
|
963
|
-
*/
|
|
964
|
-
function isDisjoint(edits) {
|
|
965
|
-
const sorted = [...edits].sort((left, right) => left.range[0] - right.range[0]);
|
|
966
|
-
return sorted.every((edit, index) => index === 0 || sorted[index - 1].range[1] <= edit.range[0]);
|
|
852
|
+
return (0, arrowAnnotationGap_1.planArrowAnnotationEdits)(source, entry.returnType, range);
|
|
967
853
|
}
|
|
968
854
|
/**
|
|
969
855
|
* The edits a single fix makes for `batch`: the annotations themselves plus the
|
|
@@ -1002,7 +888,7 @@ function planRemoval(source, removalSource, batch) {
|
|
|
1002
888
|
return null;
|
|
1003
889
|
edits.push({ range, text: carried });
|
|
1004
890
|
}
|
|
1005
|
-
return isDisjoint(edits) ? edits : null;
|
|
891
|
+
return (0, arrowAnnotationGap_1.isDisjoint)(edits) ? edits : null;
|
|
1006
892
|
}
|
|
1007
893
|
exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
1008
894
|
name: 'no-explicit-return-type',
|