@blumintinc/eslint-plugin-blumint 1.21.14 → 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.14',
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
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const utils_1 = require("@typescript-eslint/utils");
4
+ const ASTHelpers_1 = require("../utils/ASTHelpers");
4
5
  const createRule_1 = require("../utils/createRule");
5
6
  const isUpperSnakeCase = (str) => /^[A-Z][A-Z0-9_]*$/.test(str);
6
7
  /**
@@ -458,14 +459,123 @@ const isStructuredCloneCallee = (callee) => {
458
459
  const value = outermostValueOf(callee);
459
460
  return (value.type === utils_1.AST_NODE_TYPES.Identifier && value.name === 'structuredClone');
460
461
  };
462
+ const FUNCTION_TYPES = new Set([
463
+ utils_1.AST_NODE_TYPES.FunctionDeclaration,
464
+ utils_1.AST_NODE_TYPES.FunctionExpression,
465
+ utils_1.AST_NODE_TYPES.ArrowFunctionExpression,
466
+ utils_1.AST_NODE_TYPES.TSDeclareFunction,
467
+ ]);
468
+ /**
469
+ * The identifier an ACCESS PATH is rooted at — `x` for `x`, `x.n`, `x.a[0]`,
470
+ * `x?.n` and `x!.n` alike — or the expression itself when it is not an access
471
+ * path at all. Descends through the wrappers `outermostValueOf` climbs out of,
472
+ * so the root cannot depend on which type syntax annotates a step of the path.
473
+ */
474
+ const accessPathRootOf = (node) => {
475
+ let current = unwrapValueWrappers(node);
476
+ for (;;) {
477
+ if (current.type === utils_1.AST_NODE_TYPES.ChainExpression) {
478
+ current = unwrapValueWrappers(current.expression);
479
+ continue;
480
+ }
481
+ if (current.type === utils_1.AST_NODE_TYPES.MemberExpression) {
482
+ current = unwrapValueWrappers(current.object);
483
+ continue;
484
+ }
485
+ return current;
486
+ }
487
+ };
488
+ /**
489
+ * Every value a function hands back: its expression body, or the argument of
490
+ * each `return` in its block.
491
+ *
492
+ * Descent stops at a nested function, whose `return` answers for THAT function
493
+ * rather than this one — the same boundary `ASTHelpers.hasReturnStatement`
494
+ * keeps, for the same reason.
495
+ */
496
+ const returnedValuesOf = (callback) => {
497
+ if (callback.body.type !== utils_1.AST_NODE_TYPES.BlockStatement) {
498
+ return [callback.body];
499
+ }
500
+ const returned = [];
501
+ const visit = (node) => {
502
+ if (FUNCTION_TYPES.has(node.type)) {
503
+ return;
504
+ }
505
+ if (node.type === utils_1.AST_NODE_TYPES.ReturnStatement) {
506
+ if (node.argument) {
507
+ returned.push(node.argument);
508
+ }
509
+ return;
510
+ }
511
+ for (const [key, value] of Object.entries(node)) {
512
+ if (key === 'parent') {
513
+ continue;
514
+ }
515
+ for (const child of Array.isArray(value) ? value : [value]) {
516
+ if (ASTHelpers_1.ASTHelpers.isNode(child)) {
517
+ visit(child);
518
+ }
519
+ }
520
+ }
521
+ };
522
+ visit(callback.body);
523
+ return returned;
524
+ };
525
+ /**
526
+ * Whether a callback hands back the value it is given at `elementIndex`, or an
527
+ * access path rooted at it.
528
+ *
529
+ * This is what decides whether a `map`-shaped call keeps the receiver's element
530
+ * type. `(x) => x.n` over a frozen `[{ n: 1 }]` yields `1[]` rather than
531
+ * `number[]`, so `ns.push(3)` is TS2345 for an input that compiled — and that
532
+ * mapper is the commonest one written, not the no-op spelling the exclusion was
533
+ * justified against (Issue #2342). A callback that COMPUTES (`(x) => x * 2`,
534
+ * `() => Math.random()`) widens, carries nothing of the constant into its
535
+ * result, and keeps its report.
536
+ *
537
+ * The parameter is matched by NAME within the callback's own body, the single
538
+ * span this question is asked over, because a derivation resolver is handed a
539
+ * node and no scope. A name a nested function rebinds is unreachable — descent
540
+ * stops at every function boundary — so the worst a shadow can do is withhold
541
+ * the assertion from a call that would have kept it, the cheap error of the two.
542
+ *
543
+ * ANY returned value rooted at the element is enough. Branches returning
544
+ * different things widen their union, so the assertion may then reach nothing
545
+ * and the withhold costs a report; demanding EVERY branch would instead ship a
546
+ * `--fix` that stops the file compiling.
547
+ */
548
+ const returnsHandedElement = (callback, elementIndex) => {
549
+ if (!callback || !isFunctionValue(callback)) {
550
+ return false;
551
+ }
552
+ const parameter = callback.params[elementIndex];
553
+ if (!parameter || parameter.type !== utils_1.AST_NODE_TYPES.Identifier) {
554
+ return false;
555
+ }
556
+ return returnedValuesOf(callback).some((value) => {
557
+ const root = accessPathRootOf(value);
558
+ return (root.type === utils_1.AST_NODE_TYPES.Identifier && root.name === parameter.name);
559
+ });
560
+ };
561
+ /**
562
+ * The position the receiver's element arrives in for `Array.from`'s mapper.
563
+ *
564
+ * Named because the mapper is the SECOND argument while its element is the
565
+ * first parameter, so two different zeroes and ones sit beside each other here.
566
+ */
567
+ const ARRAY_FROM_MAPPER_ELEMENT_INDEX = 0;
461
568
  /**
462
569
  * Whether a call COPIES the argument at `index` while keeping its type.
463
570
  *
464
571
  * `Array.from(X)` and `structuredClone(X)` both hand back a fresh, mutable
465
572
  * value whose element or property types are the argument's — so freezing the
466
- * argument narrows the copy exactly as a spread does. `Array.from(X, fn)` is
467
- * excluded for the same reason `map` is: a mapper retypes the result, so
468
- * nothing of the constant's type survives into it.
573
+ * argument narrows the copy exactly as a spread does.
574
+ *
575
+ * `Array.from(X, fn)` is decided per CALL for the reason `map` is: a mapper
576
+ * that hands back the element or a property of it keeps the frozen type, so
577
+ * `Array.from(ITEMS, (x) => x.n)` is TS2345 on a later `push` for an input that
578
+ * compiled, while a mapper that COMPUTES widens and carries nothing.
469
579
  */
470
580
  const isCopyingCall = (call, index) => {
471
581
  if (isObjectAssignCallee(call.callee)) {
@@ -478,16 +588,26 @@ const isCopyingCall = (call, index) => {
478
588
  return true;
479
589
  }
480
590
  if (isNamespacedCallee(call.callee, 'Array', 'from')) {
481
- return call.arguments.length === 1;
591
+ return (call.arguments.length === 1 ||
592
+ returnsHandedElement(call.arguments[1], ARRAY_FROM_MAPPER_ELEMENT_INDEX));
482
593
  }
483
594
  return false;
484
595
  };
485
596
  /**
486
- * Array methods whose result keeps the receiver's ELEMENT type. `map` is
487
- * absent because its result is typed from the CALLBACK, so the constant's type
488
- * reaches it only for a callback that returns its argument unchanged — a no-op
489
- * `map`. Admitting it would withhold the assertion from every derived array
490
- * anything is computed from, to cover a spelling nobody writes.
597
+ * The copying array methods whose result is typed from a CALLBACK, mapped to
598
+ * the position the receiver's element arrives in.
599
+ *
600
+ * They are carried apart from `TYPE_PRESERVING_COPY_METHODS` because the
601
+ * question they raise is answered per CALL rather than per method — see
602
+ * `returnsHandedElement`.
603
+ */
604
+ const CALLBACK_TYPED_COPY_METHODS = new Map([['map', 0]]);
605
+ /**
606
+ * Array methods whose result keeps the receiver's ELEMENT type WHATEVER the
607
+ * call spells: nothing they are passed can retype what they hand back.
608
+ *
609
+ * `map` is carried separately rather than absent — see
610
+ * `CALLBACK_TYPED_COPY_METHODS`.
491
611
  */
492
612
  const TYPE_PRESERVING_COPY_METHODS = new Set([
493
613
  'concat',
@@ -538,15 +658,66 @@ const copyExpressionOf = (node) => {
538
658
  const callee = outermostValueOf(parent);
539
659
  // A method REFERENCE (`const take = ITEMS.concat;`) builds nothing, so the
540
660
  // copy only exists once the method is actually called.
541
- if (method !== null &&
542
- TYPE_PRESERVING_COPY_METHODS.has(method) &&
543
- callee.parent?.type === utils_1.AST_NODE_TYPES.CallExpression &&
544
- callee.parent.callee === callee) {
661
+ if (method === null ||
662
+ callee.parent?.type !== utils_1.AST_NODE_TYPES.CallExpression ||
663
+ callee.parent.callee !== callee) {
664
+ return null;
665
+ }
666
+ if (TYPE_PRESERVING_COPY_METHODS.has(method)) {
545
667
  return callee.parent;
546
668
  }
669
+ const elementIndex = CALLBACK_TYPED_COPY_METHODS.get(method);
670
+ return elementIndex !== undefined &&
671
+ returnsHandedElement(callee.parent.arguments[0], elementIndex)
672
+ ? callee.parent
673
+ : null;
547
674
  }
548
675
  return null;
549
676
  };
677
+ /**
678
+ * Array methods whose result is an ELEMENT of the receiver rather than a fresh
679
+ * array over it.
680
+ *
681
+ * `as const` freezes in depth, so the element they hand back carries the
682
+ * assertion exactly as one reached by index does: `const first = ITEMS.at(0)!;
683
+ * first.n = 2;` is TS2540 once `ITEMS` is frozen, for an input that compiled
684
+ * (Issue #2341). They belong with the element family rather than with
685
+ * `TYPE_PRESERVING_COPY_METHODS`, whose members hand back a container.
686
+ */
687
+ const ELEMENT_RETURNING_METHODS = new Set(['at', 'find', 'findLast']);
688
+ /**
689
+ * The folds whose result is an element of the receiver, in the SEEDLESS
690
+ * spelling alone.
691
+ *
692
+ * `ITEMS.reduce((a, b) => b)` is typed `T` because the overload without an
693
+ * initial value takes the first element as the seed. Given a seed the result is
694
+ * typed from THAT value, which the constant need not have given —
695
+ * `NUMS.reduce((sum, n) => sum + n, 0)` is `number` however `NUMS` is frozen —
696
+ * so enrolling the seeded spelling would withhold the assertion for a break
697
+ * that cannot happen.
698
+ */
699
+ const ELEMENT_FOLD_METHODS = new Set(['reduce', 'reduceRight']);
700
+ /** The call that hands back an ELEMENT of this value — see the two sets above. */
701
+ const elementExpressionOf = (node) => {
702
+ const parent = node.parent;
703
+ if (parent?.type !== utils_1.AST_NODE_TYPES.MemberExpression ||
704
+ parent.object !== node) {
705
+ return null;
706
+ }
707
+ const method = accessedPropertyName(parent);
708
+ if (method === null) {
709
+ return null;
710
+ }
711
+ const callee = outermostValueOf(parent);
712
+ const call = callee.parent;
713
+ if (call?.type !== utils_1.AST_NODE_TYPES.CallExpression || call.callee !== callee) {
714
+ return null;
715
+ }
716
+ return ELEMENT_RETURNING_METHODS.has(method) ||
717
+ (ELEMENT_FOLD_METHODS.has(method) && call.arguments.length === 1)
718
+ ? call
719
+ : null;
720
+ };
550
721
  /** Pattern nodes a parameter's binding can be nested inside. */
551
722
  const PATTERN_CONTAINERS = new Set([
552
723
  utils_1.AST_NODE_TYPES.AssignmentPattern,
@@ -559,12 +730,6 @@ const PATTERN_CONTAINERS = new Set([
559
730
  // constructor's params and `constructor(public stage = DEFAULT)` narrows.
560
731
  utils_1.AST_NODE_TYPES.TSParameterProperty,
561
732
  ]);
562
- const FUNCTION_TYPES = new Set([
563
- utils_1.AST_NODE_TYPES.FunctionDeclaration,
564
- utils_1.AST_NODE_TYPES.FunctionExpression,
565
- utils_1.AST_NODE_TYPES.ArrowFunctionExpression,
566
- utils_1.AST_NODE_TYPES.TSDeclareFunction,
567
- ]);
568
733
  /**
569
734
  * Whether a default value is what a PARAMETER's type is inferred FROM.
570
735
  *
@@ -752,6 +917,38 @@ const elementProjectionCallOf = (node) => {
752
917
  ? parent
753
918
  : null;
754
919
  };
920
+ /**
921
+ * Constructors that build a collection out of the argument's ELEMENTS.
922
+ *
923
+ * `new Set(ITEMS)` holds the constant's own contents, so iterating it hands out
924
+ * the frozen elements and `for (const item of new Set(ITEMS)) { item.n = 2; }`
925
+ * is TS2540 for an input that compiled (Issue #2340).
926
+ *
927
+ * The construction is not a copy in `copyExpressionOf`'s sense — the result has
928
+ * a different shape from the argument, so a write to the collection says
929
+ * nothing about the constant — which is why it is resolved here, where only the
930
+ * ITERATION question is asked. `WeakSet`/`WeakMap` are absent because they are
931
+ * not iterable, so no binding can be taken from one.
932
+ */
933
+ const ELEMENT_PRESERVING_COLLECTION_NAMES = new Set(['Set', 'Map']);
934
+ /** Whether an expression CONSTRUCTS one of those collections. */
935
+ const isElementPreservingCollection = (node) => {
936
+ const value = unwrapValueWrappers(node);
937
+ if (value.type !== utils_1.AST_NODE_TYPES.NewExpression) {
938
+ return false;
939
+ }
940
+ const callee = unwrapValueWrappers(value.callee);
941
+ return (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
942
+ ELEMENT_PRESERVING_COLLECTION_NAMES.has(callee.name));
943
+ };
944
+ const elementCollectionOf = (node) => {
945
+ const parent = node.parent;
946
+ return parent?.type === utils_1.AST_NODE_TYPES.NewExpression &&
947
+ parent.arguments[0] === node &&
948
+ isElementPreservingCollection(parent)
949
+ ? parent
950
+ : null;
951
+ };
755
952
  /**
756
953
  * Array methods whose result ITERATES the receiver's own elements.
757
954
  *
@@ -764,11 +961,29 @@ const elementProjectionCallOf = (node) => {
764
961
  * is frozen, for an input that compiled (Issue #2340). `entries` yields
765
962
  * `[index, element]` pairs, which carry the element exactly as `values` does.
766
963
  *
767
- * `keys` is absent for the reason `Object.keys` is: its result is a number
768
- * whatever the receiver holds, so the assertion cannot reach a binding taken
769
- * from it.
964
+ * `keys` is absent for an ARRAY receiver, for the reason `Object.keys` is: it
965
+ * yields INDICES, numbers whatever the receiver holds, which the assertion
966
+ * cannot reach.
770
967
  */
771
968
  const ELEMENT_ITERATOR_METHODS = new Set(['values', 'entries']);
969
+ /**
970
+ * The same methods for a Set/Map receiver, where `keys` joins them.
971
+ *
972
+ * `Set.prototype.keys` is an alias for `values`, and a `Map`'s hands back the
973
+ * frozen key of each entry, so `for (const item of new Set(ITEMS).keys()) {
974
+ * item.n = 2; }` is TS2540 once `ITEMS` is frozen, for an input that compiled
975
+ * — while the `for…of` and `forEach` spellings over the same `new Set(ITEMS)`
976
+ * already decline (Issue #2341). One set per receiver, because a single set
977
+ * would decide the two receivers by the same name and be wrong for one of them.
978
+ *
979
+ * The receiver is read syntactically: the collection this iterator is reached
980
+ * through is the `new Set(ITEMS)` expression the derivation walk just resolved.
981
+ */
982
+ const COLLECTION_ELEMENT_ITERATOR_METHODS = new Set([
983
+ 'values',
984
+ 'entries',
985
+ 'keys',
986
+ ]);
772
987
  const iteratorProjectionCallOf = (node) => {
773
988
  const parent = node.parent;
774
989
  if (parent?.type !== utils_1.AST_NODE_TYPES.MemberExpression ||
@@ -776,7 +991,10 @@ const iteratorProjectionCallOf = (node) => {
776
991
  return null;
777
992
  }
778
993
  const method = accessedPropertyName(parent);
779
- if (method === null || !ELEMENT_ITERATOR_METHODS.has(method)) {
994
+ const iteratorMethods = isElementPreservingCollection(node)
995
+ ? COLLECTION_ELEMENT_ITERATOR_METHODS
996
+ : ELEMENT_ITERATOR_METHODS;
997
+ if (method === null || !iteratorMethods.has(method)) {
780
998
  return null;
781
999
  }
782
1000
  // A method REFERENCE (`const walk = ITEMS.values;`) iterates nothing, so the
@@ -788,32 +1006,6 @@ const iteratorProjectionCallOf = (node) => {
788
1006
  ? callee.parent
789
1007
  : null;
790
1008
  };
791
- /**
792
- * Constructors that build a collection out of the argument's ELEMENTS.
793
- *
794
- * `new Set(ITEMS)` holds the constant's own contents, so iterating it hands out
795
- * the frozen elements and `for (const item of new Set(ITEMS)) { item.n = 2; }`
796
- * is TS2540 for an input that compiled (Issue #2340).
797
- *
798
- * The construction is not a copy in `copyExpressionOf`'s sense — the result has
799
- * a different shape from the argument, so a write to the collection says
800
- * nothing about the constant — which is why it is resolved here, where only the
801
- * ITERATION question is asked. `WeakSet`/`WeakMap` are absent because they are
802
- * not iterable, so no binding can be taken from one.
803
- */
804
- const ELEMENT_PRESERVING_COLLECTION_NAMES = new Set(['Set', 'Map']);
805
- const elementCollectionOf = (node) => {
806
- const parent = node.parent;
807
- if (parent?.type !== utils_1.AST_NODE_TYPES.NewExpression ||
808
- parent.arguments[0] !== node) {
809
- return null;
810
- }
811
- const callee = unwrapValueWrappers(parent.callee);
812
- return callee.type === utils_1.AST_NODE_TYPES.Identifier &&
813
- ELEMENT_PRESERVING_COLLECTION_NAMES.has(callee.name)
814
- ? parent
815
- : null;
816
- };
817
1009
  /**
818
1010
  * The expressions a reference denotes a FROZEN value through: the reference
819
1011
  * itself and every property access rooted at it — `CONFIG`, `CONFIG.list`,
@@ -840,6 +1032,192 @@ const accessPathsRootedAt = (identifier) => {
840
1032
  current = outermostValueOf(parent);
841
1033
  }
842
1034
  };
1035
+ /**
1036
+ * Whether `as const` reaches INTO this value, or stops at the property that
1037
+ * holds it.
1038
+ *
1039
+ * The assertion retypes the literal it is written on: nested array and object
1040
+ * literals become `readonly`, and primitive literals narrow to their literal
1041
+ * type. A value the literal merely refers to keeps whatever type it already
1042
+ * had, and an explicit `as T` cast is exactly such a value — so
1043
+ * `{ items: [] as string[] } as const` freezes the `items` PROPERTY while
1044
+ * leaving the array it holds a mutable `string[]`.
1045
+ *
1046
+ * Only the cast is screened, because it is the one spelling that PROVES the
1047
+ * assertion cannot deepen. Anything else answers true, so an unrecognized value
1048
+ * is still enrolled and the walk keeps declining — the direction that withholds
1049
+ * an assertion rather than breaking a build (Issue #2341).
1050
+ */
1051
+ const assertionDeepensInto = (node) => {
1052
+ if (node.type !== utils_1.AST_NODE_TYPES.TSAsExpression &&
1053
+ node.type !== utils_1.AST_NODE_TYPES.TSTypeAssertion) {
1054
+ return true;
1055
+ }
1056
+ return (node.typeAnnotation.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
1057
+ node.typeAnnotation.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
1058
+ node.typeAnnotation.typeName.name === 'const');
1059
+ };
1060
+ /**
1061
+ * The value a single access step reads out of a literal, or `undefined` when
1062
+ * the step cannot be resolved statically.
1063
+ *
1064
+ * A spread makes an object literal's own properties an incomplete account of
1065
+ * what it holds, so a miss under one is unresolved rather than absent.
1066
+ */
1067
+ const literalValueAtKey = (value, key) => {
1068
+ if (value.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
1069
+ if (typeof key !== 'string') {
1070
+ return undefined;
1071
+ }
1072
+ let resolved;
1073
+ for (const property of value.properties) {
1074
+ if (property.type === utils_1.AST_NODE_TYPES.SpreadElement) {
1075
+ return undefined;
1076
+ }
1077
+ const propertyKey = property.key;
1078
+ const keyName = !property.computed && propertyKey.type === utils_1.AST_NODE_TYPES.Identifier
1079
+ ? propertyKey.name
1080
+ : propertyKey.type === utils_1.AST_NODE_TYPES.Literal &&
1081
+ typeof propertyKey.value === 'string'
1082
+ ? propertyKey.value
1083
+ : null;
1084
+ // The LAST matching key wins, as it does at runtime.
1085
+ if (keyName === key) {
1086
+ resolved = property.value;
1087
+ }
1088
+ }
1089
+ return resolved;
1090
+ }
1091
+ if (value.type === utils_1.AST_NODE_TYPES.ArrayExpression) {
1092
+ if (typeof key !== 'number') {
1093
+ return undefined;
1094
+ }
1095
+ const element = value.elements[key];
1096
+ return element === null ||
1097
+ element === undefined ||
1098
+ element.type === utils_1.AST_NODE_TYPES.SpreadElement
1099
+ ? undefined
1100
+ : element;
1101
+ }
1102
+ return undefined;
1103
+ };
1104
+ /**
1105
+ * The value a single MEMBER ACCESS step reads out of a literal, or `undefined`
1106
+ * when the step cannot be resolved statically.
1107
+ *
1108
+ * Delegates to `literalValueAtKey` so a property reached by a member access and
1109
+ * the same property reached by a destructuring pattern cannot be read on
1110
+ * different terms — the divergence between those two spellings is what this
1111
+ * screen exists to close (Issue #2341).
1112
+ */
1113
+ const literalValueAtStep = (value, step) => {
1114
+ const name = accessedPropertyName(step);
1115
+ if (name !== null) {
1116
+ return literalValueAtKey(value, name);
1117
+ }
1118
+ return step.computed &&
1119
+ step.property.type === utils_1.AST_NODE_TYPES.Literal &&
1120
+ typeof step.property.value === 'number'
1121
+ ? literalValueAtKey(value, step.property.value)
1122
+ : undefined;
1123
+ };
1124
+ /**
1125
+ * The names a destructuring pattern binds to values `as const` does NOT reach.
1126
+ *
1127
+ * A pattern binds a property WITHOUT writing a member access, so the step
1128
+ * screen in `frozenAccessPathsRootedAt` never sees `const { items } = CONFIG`
1129
+ * — it only sees `CONFIG.items`. Reading the pattern against the same literal
1130
+ * keeps the two spellings of one extraction on identical terms. They must
1131
+ * agree: `prefer-destructuring-no-class` rewrites the first into the second
1132
+ * under `--fix`, so a screen applied to one spelling alone lets a sibling fixer
1133
+ * flip this rule's verdict on unchanged semantics (Issue #2341).
1134
+ *
1135
+ * A `RestElement` is left enrolled because it gathers whatever the pattern did
1136
+ * not name, which no single literal value answers for.
1137
+ */
1138
+ const collectUnfrozenPatternNames = (id, value, unfrozen) => {
1139
+ if (value === undefined) {
1140
+ return;
1141
+ }
1142
+ if (id.type === utils_1.AST_NODE_TYPES.AssignmentPattern) {
1143
+ collectUnfrozenPatternNames(id.left, value, unfrozen);
1144
+ return;
1145
+ }
1146
+ if (id.type === utils_1.AST_NODE_TYPES.Identifier) {
1147
+ if (!assertionDeepensInto(value)) {
1148
+ unfrozen.add(id.name);
1149
+ }
1150
+ return;
1151
+ }
1152
+ const literal = unwrapValueWrappers(value);
1153
+ if (id.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
1154
+ for (const property of id.properties) {
1155
+ if (property.type !== utils_1.AST_NODE_TYPES.Property) {
1156
+ continue;
1157
+ }
1158
+ const key = property.key;
1159
+ const name = !property.computed && key.type === utils_1.AST_NODE_TYPES.Identifier
1160
+ ? key.name
1161
+ : key.type === utils_1.AST_NODE_TYPES.Literal && typeof key.value === 'string'
1162
+ ? key.value
1163
+ : null;
1164
+ if (name === null) {
1165
+ continue;
1166
+ }
1167
+ collectUnfrozenPatternNames(property.value, literalValueAtKey(literal, name), unfrozen);
1168
+ }
1169
+ return;
1170
+ }
1171
+ if (id.type === utils_1.AST_NODE_TYPES.ArrayPattern) {
1172
+ id.elements.forEach((element, index) => {
1173
+ if (!element || element.type === utils_1.AST_NODE_TYPES.RestElement) {
1174
+ return;
1175
+ }
1176
+ collectUnfrozenPatternNames(element, literalValueAtKey(literal, index), unfrozen);
1177
+ });
1178
+ }
1179
+ };
1180
+ /**
1181
+ * The access paths rooted at a reference that the assertion actually FREEZES,
1182
+ * read against the literal the constant is declared from.
1183
+ *
1184
+ * The climb stops before the first step whose value `as const` cannot deepen
1185
+ * into, because neither that value nor anything reached through it carries the
1186
+ * assertion — a binding taken from it is no second name for frozen contents and
1187
+ * enrolling it would withhold the assertion for a break that cannot happen.
1188
+ *
1189
+ * Without a literal to read, every step answers unresolved and the result is
1190
+ * the whole path, which is what `accessPathsRootedAt` returns on its own.
1191
+ */
1192
+ const frozenAccessPathsRootedAt = (identifier, frozenValue) => {
1193
+ const paths = [];
1194
+ let current = outermostValueOf(identifier);
1195
+ let value = frozenValue
1196
+ ? unwrapValueWrappers(frozenValue)
1197
+ : undefined;
1198
+ for (;;) {
1199
+ paths.push({ node: current, literal: value });
1200
+ const parent = current.parent;
1201
+ if (!parent ||
1202
+ parent.type !== utils_1.AST_NODE_TYPES.MemberExpression ||
1203
+ parent.object !== current) {
1204
+ return paths;
1205
+ }
1206
+ if (value !== undefined) {
1207
+ const stepValue = literalValueAtStep(value, parent);
1208
+ if (stepValue === undefined) {
1209
+ value = undefined;
1210
+ }
1211
+ else if (!assertionDeepensInto(stepValue)) {
1212
+ return paths;
1213
+ }
1214
+ else {
1215
+ value = unwrapValueWrappers(stepValue);
1216
+ }
1217
+ }
1218
+ current = outermostValueOf(parent);
1219
+ }
1220
+ };
843
1221
  /**
844
1222
  * Every way a value derived from this one keeps the constant's ELEMENT types:
845
1223
  * a copy of it, the `Object.values`/`Object.entries` array over it, the
@@ -853,6 +1231,46 @@ const DERIVATION_RESOLVERS = [
853
1231
  iteratorProjectionCallOf,
854
1232
  elementCollectionOf,
855
1233
  ];
1234
+ /**
1235
+ * The derivations that carry the constant's frozen type into a value a BINDING
1236
+ * can be initialized from: a copy of it and an element taken out of it.
1237
+ *
1238
+ * The three iteration resolvers are absent because each hands back a value of a
1239
+ * DIFFERENT SHAPE whose container is fresh and mutable — `Object.values(CONFIG)`
1240
+ * and `new Set(ITEMS)` are arrays and sets nothing frozen was written to, so a
1241
+ * write to the container itself is no readonly violation. Only their ELEMENTS
1242
+ * carry the assertion, which is the question the iteration walk asks.
1243
+ */
1244
+ const ALIAS_DERIVATION_RESOLVERS = [copyExpressionOf, elementExpressionOf];
1245
+ /**
1246
+ * Every expression that denotes a value typed from this reference: the
1247
+ * reference and each step of the access path rooted at it, then — transitively
1248
+ * — whatever `resolvers` derive from any of them.
1249
+ *
1250
+ * Grown in place and walked by index, so a derivation OF a derivation is
1251
+ * reached by the same loop without recursion of its own. Every resolver returns
1252
+ * an ANCESTOR of the node it is given, so the walk strictly ascends and
1253
+ * terminates; `visited` keeps a node two resolvers agree on from being expanded
1254
+ * twice.
1255
+ */
1256
+ const derivedValueExpressionsOf = (identifier, resolvers, frozenValue) => {
1257
+ const pending = frozenAccessPathsRootedAt(identifier, frozenValue);
1258
+ const visited = new Set(pending.map(({ node }) => node));
1259
+ for (let index = 0; index < pending.length; index += 1) {
1260
+ for (const resolveDerivation of resolvers) {
1261
+ const derived = resolveDerivation(pending[index].node);
1262
+ if (!derived || visited.has(derived)) {
1263
+ continue;
1264
+ }
1265
+ visited.add(derived);
1266
+ // A copy or an element call hands back a value with no literal of its
1267
+ // own, so nothing downstream of one is screened — the direction that
1268
+ // keeps enrolling rather than withholding the assertion.
1269
+ pending.push({ node: derived, literal: undefined });
1270
+ }
1271
+ }
1272
+ return pending;
1273
+ };
856
1274
  const enrolFully = (variables) => variables.map((variable) => ({
857
1275
  variable,
858
1276
  breaksOnAnyMutatingMethod: true,
@@ -918,9 +1336,9 @@ const parameterBindingsOf = (callback, positions, declaredVariablesOf) => {
918
1336
  */
919
1337
  const bindingsOfIterationOver = (iterable, declaredVariablesOf, iteratesConstantValue) => {
920
1338
  // The member path is resolved first because the iterated expression is
921
- // routinely a PROPERTY of the constant (`for (const x of CONFIG.list)`),
922
- // which the alias walk refuses precisely because it arrives through a member
923
- // access — the property is frozen with the object that holds it.
1339
+ // routinely a PROPERTY of the constant (`for (const x of CONFIG.list)`): the
1340
+ // property is frozen with the object that holds it, so iterating it hands the
1341
+ // body the constant's own frozen contents.
924
1342
  const path = accessPathOf(iterable);
925
1343
  const value = outermostValueOf(path ?? iterable);
926
1344
  const parent = value.parent;
@@ -1026,23 +1444,16 @@ const iterationBindingsOf = (identifier, declaredVariablesOf) => {
1026
1444
  const bindings = [
1027
1445
  ...bindingsOfIterationOver(value, declaredVariablesOf, true),
1028
1446
  ];
1029
- // Grown in place and walked by index, so a derivation OF a derivation is
1030
- // reached by the same loop without recursion of its own. Every resolver
1031
- // returns an ancestor of the node it is given, so the walk strictly ascends
1032
- // and terminates; `visited` keeps a node two resolvers agree on from being
1033
- // expanded twice.
1034
- const pending = accessPathsRootedAt(value);
1035
- const visited = new Set(pending);
1036
- for (let index = 0; index < pending.length; index += 1) {
1037
- for (const resolveDerivation of DERIVATION_RESOLVERS) {
1038
- const derived = resolveDerivation(pending[index]);
1039
- if (!derived || visited.has(derived)) {
1040
- continue;
1041
- }
1042
- visited.add(derived);
1043
- pending.push(derived);
1044
- bindings.push(...bindingsOfIterationOver(derived, declaredVariablesOf, false));
1447
+ // The constant's own value and access path are iterated by the call above,
1448
+ // which resolves the path itself; every DERIVED value is iterated on the
1449
+ // narrower terms — it hands a callback a fresh outer value rather than the
1450
+ // constant.
1451
+ const ownValues = new Set(accessPathsRootedAt(value));
1452
+ for (const { node: derived } of derivedValueExpressionsOf(value, DERIVATION_RESOLVERS)) {
1453
+ if (ownValues.has(derived)) {
1454
+ continue;
1045
1455
  }
1456
+ bindings.push(...bindingsOfIterationOver(derived, declaredVariablesOf, false));
1046
1457
  }
1047
1458
  return bindings;
1048
1459
  };
@@ -1097,10 +1508,20 @@ const iterationBindingsOf = (identifier, declaredVariablesOf) => {
1097
1508
  * `--fix`.
1098
1509
  */
1099
1510
  const blocksAsConstAssertion = (variable, declaredVariablesOf) => {
1511
+ // The literal the constant is declared from, so an access path through it can
1512
+ // be screened against what `as const` actually freezes. Only the constant's
1513
+ // own declarator carries one: an ALIAS is initialized from a path into that
1514
+ // same literal, and resolving through it would need the path carried too, so
1515
+ // an alias is left unresolved and every step of it stays enrolled.
1516
+ const declaredValue = variable.defs.find((def) => def.node.type === utils_1.AST_NODE_TYPES.VariableDeclarator)?.node;
1517
+ const frozenValue = declaredValue?.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
1518
+ declaredValue.init
1519
+ ? declaredValue.init
1520
+ : undefined;
1100
1521
  // Grown in place and walked by index: an alias found mid-walk is appended and
1101
1522
  // reached by the same loop, so the traversal needs no recursion of its own.
1102
1523
  const pending = [
1103
- { variable, breaksOnAnyMutatingMethod: true },
1524
+ { variable, breaksOnAnyMutatingMethod: true, frozenValue },
1104
1525
  ];
1105
1526
  const visited = new Set([variable]);
1106
1527
  /**
@@ -1128,7 +1549,7 @@ const blocksAsConstAssertion = (variable, declaredVariablesOf) => {
1128
1549
  return false;
1129
1550
  };
1130
1551
  for (let index = 0; index < pending.length; index += 1) {
1131
- const { variable: enrolled, breaksOnAnyMutatingMethod } = pending[index];
1552
+ const { variable: enrolled, breaksOnAnyMutatingMethod, frozenValue: enrolledValue, } = pending[index];
1132
1553
  for (const reference of enrolled.references) {
1133
1554
  // Reassigning an alias is as disqualifying as writing through one. A
1134
1555
  // binding that takes its type from the constant narrows to the frozen
@@ -1158,23 +1579,43 @@ const blocksAsConstAssertion = (variable, declaredVariablesOf) => {
1158
1579
  introducesForeignElement(path, isEnrolledReference))) {
1159
1580
  return true;
1160
1581
  }
1161
- // A copy carries the constant's frozen type into a second binding, so it
1162
- // is enrolled on the same terms as an alias — but it is reached through a
1163
- // member access (`ITEMS.concat()`), which the alias walk deliberately
1164
- // refuses, so it is resolved before that refusal applies.
1165
- const copy = copyExpressionOf(outermostValueOf(reference.identifier));
1166
- const declarator = copy
1167
- ? aliasDeclaratorOf(copy)
1168
- : path === null
1169
- ? aliasDeclaratorOf(reference.identifier)
1170
- : null;
1582
+ // A binding is initialized from any expression that denotes the
1583
+ // constant's frozen contents, not from the reference alone. A PROPERTY or
1584
+ // ELEMENT of the constant carries the assertion exactly as the constant
1585
+ // does, so `const list = CONFIG.list; list.push(3);` is the same TS2339
1586
+ // the rule already declines for when the identical call is written
1587
+ // directly — only the extracted spelling escaped it, while the
1588
+ // DESTRUCTURED spelling of the same extraction was enrolled all along
1589
+ // (Issue #2341). A copy taken of the constant or of any step of that path
1590
+ // (`ITEMS.concat()`, `[...CONFIG.a.b]`) carries the frozen TYPE into a
1591
+ // fresh value on the same terms.
1592
+ const aliases = derivedValueExpressionsOf(reference.identifier, ALIAS_DERIVATION_RESOLVERS, enrolledValue).flatMap(({ node, literal }) => {
1593
+ const declarator = aliasDeclaratorOf(node);
1594
+ if (!declarator) {
1595
+ return [];
1596
+ }
1597
+ const variables = declaredVariablesOf(declarator);
1598
+ // The pattern screen applies only where the declarator destructures
1599
+ // THIS value directly. Reached through a storage container
1600
+ // (`const HOLDER = { ITEMS }`), the pattern names the container's own
1601
+ // properties, which this literal does not answer for.
1602
+ if (literal === undefined ||
1603
+ declarator.init !== outermostValueOf(node)) {
1604
+ return variables;
1605
+ }
1606
+ const unfrozen = new Set();
1607
+ collectUnfrozenPatternNames(declarator.id, literal, unfrozen);
1608
+ return unfrozen.size === 0
1609
+ ? variables
1610
+ : variables.filter((variable) => !unfrozen.has(variable.name));
1611
+ });
1171
1612
  // A binding introduced by ITERATING the constant is enrolled beside the
1172
1613
  // aliases: it names the constant's CONTENTS, which the assertion freezes
1173
1614
  // with the constant itself — see `iterationBindingsOf`. An alias is
1174
1615
  // enrolled on the constant's own terms, because it denotes the constant's
1175
1616
  // value and so carries its readonly-ness whole.
1176
1617
  const derived = [
1177
- ...enrolFully(declarator ? declaredVariablesOf(declarator) : []),
1618
+ ...enrolFully(aliases),
1178
1619
  ...iterationBindingsOf(reference.identifier, declaredVariablesOf),
1179
1620
  ];
1180
1621
  for (const alias of derived) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.21.14",
3
+ "version": "1.21.15",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,35 @@
1
1
  [
2
+ {
3
+ "version": "1.21.15",
4
+ "date": "2026-09-06T18:24:58.623Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-empty-object-check",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2344
11
+ ],
12
+ "summary": "keep a declared type's verdict when the checker cannot resolve it (closes #2344)"
13
+ },
14
+ {
15
+ "name": "enforce-memoize-async",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 2343
19
+ ],
20
+ "summary": "withhold from methods that release what they acquired and from methods reporting an effect (closes #2343)"
21
+ },
22
+ {
23
+ "name": "global-const-style",
24
+ "changeType": "fix",
25
+ "issues": [
26
+ 2341,
27
+ 2342
28
+ ],
29
+ "summary": "enrol bindings extracted by member access and by a type-preserving map (closes #2341, closes #2342)"
30
+ }
31
+ ]
32
+ },
2
33
  {
3
34
  "version": "1.21.14",
4
35
  "date": "2026-09-06T01:53:58.858Z",