@blumintinc/eslint-plugin-blumint 1.21.13 → 1.21.15

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 CHANGED
@@ -224,7 +224,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
224
224
  module.exports = {
225
225
  meta: {
226
226
  name: '@blumintinc/eslint-plugin-blumint',
227
- version: '1.21.13',
227
+ version: '1.21.15',
228
228
  },
229
229
  parseOptions: {
230
230
  ecmaVersion: 2020,
@@ -1404,6 +1404,68 @@ exports.enforceEmptyObjectCheck = (0, createRule_1.createRule)({
1404
1404
  }
1405
1405
  return false;
1406
1406
  }
1407
+ /**
1408
+ * The type the binding behind this value DECLARES, if it declares one.
1409
+ *
1410
+ * Parameters and variables are the two declarations that annotate the
1411
+ * value a guard tests; an import is answered ahead of this by
1412
+ * `tracesToImport`. The annotation has to sit on the BINDING: the one on
1413
+ * `const { config }: Props = load()` describes the container, and reading
1414
+ * it as a verdict on a single property would need exactly the resolution
1415
+ * that failed, so a destructured binding keeps the naming heuristic.
1416
+ */
1417
+ function declaredTypeOf(identifier) {
1418
+ const variable = variableFor(identifier);
1419
+ if (!variable) {
1420
+ return null;
1421
+ }
1422
+ for (const def of variable.defs) {
1423
+ if (def.type !== 'Parameter' && def.type !== 'Variable') {
1424
+ continue;
1425
+ }
1426
+ if (def.name.typeAnnotation) {
1427
+ return def.name.typeAnnotation.typeAnnotation;
1428
+ }
1429
+ }
1430
+ return null;
1431
+ }
1432
+ /**
1433
+ * Whether a declared type declares NOTHING.
1434
+ *
1435
+ * An explicit `any` is the checker reporting what the source told it, not
1436
+ * a resolution failure, so it leaves the value in the same position as an
1437
+ * unannotated one and the naming heuristic keeps answering. `unknown` is
1438
+ * excluded from this reading on purpose: `Object.keys` rejects an
1439
+ * `unknown` operand, so the fix the heuristic would attach there does not
1440
+ * typecheck.
1441
+ */
1442
+ function declaresNothing(node) {
1443
+ if (node.type === utils_1.AST_NODE_TYPES.TSUnionType) {
1444
+ return node.types.some(declaresNothing);
1445
+ }
1446
+ return node.type === utils_1.AST_NODE_TYPES.TSAnyKeyword;
1447
+ }
1448
+ /**
1449
+ * Whether a declared type the checker could not resolve nonetheless
1450
+ * SPELLS an index-signature dictionary.
1451
+ *
1452
+ * `Record<K, V>` is an index signature whichever way `K` and `V` resolve,
1453
+ * so the value is a plain data map rather than a class instance and
1454
+ * `Object.keys` measures its emptiness correctly. Reading that from the
1455
+ * source keeps the verdict a complete program gives — `object` — reachable
1456
+ * without one, which is why a union answers yes as soon as one member
1457
+ * spells it, mirroring `isObjectLikeType`. A reference the source does not
1458
+ * pin this way, `Readonly<NextResponse>` among them, carries no such
1459
+ * guarantee.
1460
+ */
1461
+ function spellsDictionary(node) {
1462
+ if (node.type === utils_1.AST_NODE_TYPES.TSUnionType) {
1463
+ return node.types.some(spellsDictionary);
1464
+ }
1465
+ return (node.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
1466
+ node.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
1467
+ node.typeName.name === 'Record');
1468
+ }
1407
1469
  function isLikelyObject(identifier) {
1408
1470
  if (checker && parserServices?.esTreeNodeToTSNodeMap) {
1409
1471
  try {
@@ -1424,13 +1486,39 @@ exports.enforceEmptyObjectCheck = (0, createRule_1.createRule)({
1424
1486
  * absence of evidence became evidence the value can be `{}`.
1425
1487
  *
1426
1488
  * The heuristic is still the right answer for a value with no
1427
- * declaration to resolve — removing it outright silences 52 of this
1428
- * rule's own 92 fixtures — so only the unresolved-IMPORT case is
1429
- * carved out (#2252).
1489
+ * declaration to resolve — removing it outright silences 56 of this
1490
+ * rule's own 108 fixtures — so the fall-through is carved out only
1491
+ * where evidence EXISTS and went unread: a value from an unresolved
1492
+ * IMPORT (#2252), and a binding that DECLARES a type the checker
1493
+ * could not resolve (#2344). An unresolvable annotation means the
1494
+ * checker could not look, not that the value is loosely typed:
1495
+ * `response: Readonly<NextResponse>` read off its name alone
1496
+ * reported a class instance, whose state lives behind prototype
1497
+ * accessors, so the prescribed `Object.keys` clause holds for every
1498
+ * valid value and inverts the guard it was meant to harden.
1499
+ *
1500
+ * The carve-out costs reach, and the cost is measured rather than
1501
+ * assumed: across the consumer's 14,059 tracked sources it drops 8
1502
+ * reports, and re-running each under a real `ts.Program` — the
1503
+ * verdict this arm exists to preserve — agrees with 6 of them. The
1504
+ * remaining 2 annotate an all-optional imported type
1505
+ * (`Record<string, string[]>` behind a local alias, and an
1506
+ * `algoliasearch-helper` parameter bag), which a resolved program
1507
+ * calls `object` and reports. Nothing at the annotation site
1508
+ * separates those from `Readonly<NextResponse>`: both are bare
1509
+ * references into a module this program did not load, so recovering
1510
+ * them needs the cross-file resolution whose absence defines this
1511
+ * branch. Two silent true positives is the accepted price of six
1512
+ * guard inversions, because a fix that rewrites correct code costs
1513
+ * more than one the rule declines to make.
1430
1514
  */
1431
1515
  if (tracesToImport(identifier)) {
1432
1516
  return false;
1433
1517
  }
1518
+ const declared = declaredTypeOf(identifier);
1519
+ if (declared && !declaresNothing(declared)) {
1520
+ return spellsDictionary(declared);
1521
+ }
1434
1522
  }
1435
1523
  catch {
1436
1524
  // TypeScript parser services can throw when AST-to-TS node mapping fails; fall back to naming heuristic so linting does not crash.
@@ -423,6 +423,168 @@ function transactionParticipantsOf(body) {
423
423
  }
424
424
  return participants;
425
425
  }
426
+ /**
427
+ * Node types that own the statements written inside them. A walk bounded by
428
+ * them answers about what the method DOES rather than about what it merely
429
+ * defines: a `try`/`finally` written inside a callback is that callback's
430
+ * acquire/release pair, and a field written inside one is written on whatever
431
+ * schedule the callback runs on. The decorator caches the method's own promise,
432
+ * so only the method's own steps bear on whether caching is safe.
433
+ */
434
+ const OWN_STEP_BOUNDARIES = new Set([
435
+ utils_1.AST_NODE_TYPES.ArrowFunctionExpression,
436
+ utils_1.AST_NODE_TYPES.FunctionDeclaration,
437
+ utils_1.AST_NODE_TYPES.FunctionExpression,
438
+ ]);
439
+ /**
440
+ * Every node the enclosing function executes as its own step, `root` included.
441
+ * Descent stops at a nested function, which `subtreeOf` enters.
442
+ */
443
+ function* ownSubtreeOf(root) {
444
+ yield root;
445
+ for (const [key, value] of Object.entries(root)) {
446
+ if (NON_TRAVERSABLE_KEYS.has(key)) {
447
+ continue;
448
+ }
449
+ const children = Array.isArray(value) ? value : [value];
450
+ for (const child of children) {
451
+ if (ASTHelpers_1.ASTHelpers.isNode(child) && !OWN_STEP_BOUNDARIES.has(child.type)) {
452
+ yield* ownSubtreeOf(child);
453
+ }
454
+ }
455
+ }
456
+ }
457
+ /**
458
+ * Whether the block performs a call as a step of the function that owns it.
459
+ * A call written inside a function the block only DEFINES — `finally { const
460
+ * undo = () => release(t); }` — performs nothing, so the bounded walk is what
461
+ * separates a release from a closure that could perform one elsewhere.
462
+ */
463
+ function performsCall(block) {
464
+ for (const node of ownSubtreeOf(block)) {
465
+ if (node.type === utils_1.AST_NODE_TYPES.CallExpression) {
466
+ return true;
467
+ }
468
+ }
469
+ return false;
470
+ }
471
+ /**
472
+ * Whether the method owns an acquire/release pair: a `try` among its own steps
473
+ * whose `finalizer` calls something.
474
+ *
475
+ * A `finally` that calls something exists to undo an effect the `try`
476
+ * performed. What such a method hands back describes one attempt — a grant held
477
+ * while a queue ticket was outstanding, a read taken while a lock was held —
478
+ * rather than a fact that stays true once the release has run.
479
+ *
480
+ * An empty `finalizer` releases nothing, and one that only writes a local
481
+ * (`finally { done = true; }`) records that the attempt finished instead of
482
+ * undoing it, so neither costs the method its report.
483
+ */
484
+ function releasesInOwnFinalizer(fn) {
485
+ for (const node of ownSubtreeOf(fn.body)) {
486
+ if (node.type === utils_1.AST_NODE_TYPES.TryStatement &&
487
+ node.finalizer &&
488
+ performsCall(node.finalizer)) {
489
+ return true;
490
+ }
491
+ }
492
+ return false;
493
+ }
494
+ /**
495
+ * The expression under the wrappers that stand between a value and the access
496
+ * path it evaluates. `await this.pending`, `this.pending!`, `this.pending as
497
+ * Ready` and `this?.pending` all read the same field, and a discriminator blind
498
+ * to one of those spellings would answer "hands back something else" about a
499
+ * method that hands back exactly what it wrote.
500
+ */
501
+ function withoutValueWrappers(node) {
502
+ switch (node.type) {
503
+ case utils_1.AST_NODE_TYPES.AwaitExpression:
504
+ return withoutValueWrappers(node.argument);
505
+ case utils_1.AST_NODE_TYPES.ChainExpression:
506
+ case utils_1.AST_NODE_TYPES.TSAsExpression:
507
+ case utils_1.AST_NODE_TYPES.TSNonNullExpression:
508
+ case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
509
+ case utils_1.AST_NODE_TYPES.TSTypeAssertion:
510
+ return withoutValueWrappers(node.expression);
511
+ default:
512
+ return node;
513
+ }
514
+ }
515
+ /**
516
+ * The instance field an expression is rooted at: `cached` for `this.cached`,
517
+ * for `this.cached.value` and for `this.cache[id]` alike.
518
+ *
519
+ * Both sides of the discriminator below read the ROOT, so an element write
520
+ * (`this.cache[id] = …`) and the read that answers it (`return this.cache[id]`)
521
+ * meet on the same name. A computed root (`this[key]`) names no field
522
+ * statically and answers nothing, which leaves the method reporting.
523
+ */
524
+ function rootThisField(node) {
525
+ const expression = withoutValueWrappers(node);
526
+ if (expression.type !== utils_1.AST_NODE_TYPES.MemberExpression) {
527
+ return undefined;
528
+ }
529
+ const object = withoutValueWrappers(expression.object);
530
+ if (object.type !== utils_1.AST_NODE_TYPES.ThisExpression) {
531
+ return rootThisField(object);
532
+ }
533
+ return !expression.computed &&
534
+ expression.property.type === utils_1.AST_NODE_TYPES.Identifier
535
+ ? expression.property.name
536
+ : undefined;
537
+ }
538
+ /**
539
+ * Whether the expression reads a field the method wrote. Depth and nesting are
540
+ * immaterial — `this.cached ?? EMPTY` and `() => this.cached` hand the written
541
+ * state back as plainly as a bare `this.cached` does — because the question is
542
+ * whether the result carries that state at all, not how it is spelled.
543
+ */
544
+ function readsWrittenField(node, fields) {
545
+ for (const descendant of subtreeOf(node)) {
546
+ const field = rootThisField(descendant);
547
+ if (field !== undefined && fields.has(field)) {
548
+ return true;
549
+ }
550
+ }
551
+ return false;
552
+ }
553
+ /**
554
+ * Whether the method writes an instance field and hands back a result that
555
+ * reads none of the fields it wrote.
556
+ *
557
+ * Such a method reports on an effect: its result says what THIS call did, so
558
+ * the next call has to be free to do it again. Writing a field and returning it
559
+ * is the opposite shape — a hand-rolled cache, which is the very thing
560
+ * `@Memoize()` replaces — and the field read is what separates the two. A
561
+ * single returned read is enough, because a method with one cache-hit path
562
+ * hands back state on that path however many effects its other paths report.
563
+ *
564
+ * A method with no value-returning `return` is left alone. Its result is void
565
+ * by inference rather than by declaration, and #1548 fenced inferred void out
566
+ * of this rule deliberately: an unannotated body carries no declaration of
567
+ * intent to honour.
568
+ */
569
+ function writesFieldItDoesNotReturn(fn) {
570
+ const written = new Set();
571
+ const results = [];
572
+ for (const node of ownSubtreeOf(fn.body)) {
573
+ if (node.type === utils_1.AST_NODE_TYPES.AssignmentExpression) {
574
+ const field = rootThisField(node.left);
575
+ if (field !== undefined) {
576
+ written.add(field);
577
+ }
578
+ }
579
+ else if (node.type === utils_1.AST_NODE_TYPES.ReturnStatement && node.argument) {
580
+ results.push(node.argument);
581
+ }
582
+ }
583
+ if (written.size === 0 || results.length === 0) {
584
+ return false;
585
+ }
586
+ return !results.some((result) => readsWrittenField(result, written));
587
+ }
426
588
  /** The statically known name of a method, for matching call sites against it. */
427
589
  function methodName(node) {
428
590
  if (node.computed) {
@@ -664,6 +826,37 @@ exports.enforceMemoizeAsync = (0, createRule_1.createRule)({
664
826
  if (participatesInTransaction(node, node.value)) {
665
827
  return;
666
828
  }
829
+ // A `finally` that calls something exists to undo an effect the `try`
830
+ // performed: the method takes a resource, works while it holds it, and
831
+ // gives it back on the way out. What it hands back describes that one
832
+ // attempt, not a fact that stays true afterwards. Cached, the
833
+ // acquire/release pair runs once per instance and every later caller
834
+ // receives a value computed while a resource the `finally` has since
835
+ // released was still held — the hazard the resource-handle exemption
836
+ // above names, seen from the side where the method releases the handle
837
+ // itself, so nothing of it reaches the return type for a signature-keyed
838
+ // gate to read. The failure is silent and arrives only under
839
+ // concurrency, so the fixer would apply it unattended under `--fix`,
840
+ // and both report and fix are withheld.
841
+ if (releasesInOwnFinalizer(node.value)) {
842
+ return;
843
+ }
844
+ // A method that writes an instance field and hands back a result
845
+ // reading none of the fields it wrote reports on an effect: the result
846
+ // says what THIS call did — "I reclaimed the slot" — and the next call
847
+ // has to be free to do it again. Cached, the write happens once per
848
+ // instance while every later caller reads the first call's verdict
849
+ // about work that call alone performed, and a caller passing a freshly
850
+ // built argument object instead accumulates one dead entry per call.
851
+ //
852
+ // The read is the discriminator, and it is deliberately the whole test:
853
+ // a method that writes a field and returns it is a hand-rolled cache,
854
+ // which is the shape `@Memoize()` exists to replace, so it keeps both
855
+ // report and fix. Carving out every write to `this` would silence that
856
+ // shape along with these.
857
+ if (writesFieldItDoesNotReturn(node.value)) {
858
+ return;
859
+ }
667
860
  const { aliases: memoizeAliases, namespaces: memoizeNamespaces } = memoizeImports();
668
861
  const hasMemoizeImport = memoizeAliases.size > 0 || memoizeNamespaces.size > 0;
669
862
  // Check if method already has @Memoize or @Memoize() decorator