@blumintinc/eslint-plugin-blumint 1.21.12 → 1.21.14

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.12',
227
+ version: '1.21.14',
228
228
  },
229
229
  parseOptions: {
230
230
  ecmaVersion: 2020,
@@ -270,6 +270,68 @@ const isMutatingMethodCall = (path) => {
270
270
  return (callee.parent?.type === utils_1.AST_NODE_TYPES.CallExpression &&
271
271
  callee.parent.callee === callee);
272
272
  };
273
+ /**
274
+ * The mutating methods that INSERT a value into the receiver, mapped to the
275
+ * argument positions that value can occupy.
276
+ *
277
+ * The positions are carried per method rather than taken as "every argument",
278
+ * because two of these spend leading or trailing arguments on INDICES:
279
+ * `splice(start, deleteCount, ...items)` inserts from the third argument on,
280
+ * and `fill(value, start, end)` inserts at the first alone.
281
+ *
282
+ * `sort`, `reverse`, `pop`, `shift` and `copyWithin` are absent because they
283
+ * insert nothing — they reorder, remove or copy elements the receiver already
284
+ * holds, so no element type can reject what they write. (`sort`'s argument is a
285
+ * comparator function, `copyWithin`'s three are indices.)
286
+ */
287
+ const INSERTED_VALUE_POSITIONS_BY_METHOD = new Map([
288
+ ['push', { first: 0 }],
289
+ ['unshift', { first: 0 }],
290
+ ['splice', { first: 2 }],
291
+ ['fill', { first: 0, last: 0 }],
292
+ ]);
293
+ /**
294
+ * Whether a mutating call INTRODUCES a value the receiver's element type would
295
+ * have to accept from outside the constant.
296
+ *
297
+ * This is the question that decides a mutating call through a receiver-array
298
+ * parameter the lib declares MUTABLE, where no readonly violation is possible
299
+ * and the only way the assertion can break the call is by narrowing what the
300
+ * array accepts — see `MUTABLE_ARRAY_PARAMETER_METHODS`. Three answers, and the
301
+ * boundary sits between the second and the third:
302
+ *
303
+ * - a method that inserts nothing (`arr.sort()`) cannot narrow-break, because
304
+ * it writes back only elements the receiver already holds;
305
+ * - an inserted value that is a REFERENCE to a binding already enrolled for
306
+ * this constant (`arr.push(item)`, where `item` is the element the callback
307
+ * was handed) is typed from the constant itself, so the assertion narrows the
308
+ * argument and the parameter together and the call keeps compiling;
309
+ * - an inserted value from anywhere else (`arr.push({ n: 3 })`) is typed
310
+ * independently of the constant, so narrowing the element type can reject it:
311
+ * TS2322 for an input that compiled (Issue #2340).
312
+ *
313
+ * A SPREAD argument is treated as introducing a foreign value even when it
314
+ * spreads the constant. Its elements do satisfy the narrowed type, so this
315
+ * withholds the assertion from a call that would have compiled — the cheap
316
+ * error of the two, and the one this predicate exists to prefer.
317
+ */
318
+ const introducesForeignElement = (path, isEnrolledReference) => {
319
+ const method = accessedPropertyName(path);
320
+ const positions = method === null
321
+ ? undefined
322
+ : INSERTED_VALUE_POSITIONS_BY_METHOD.get(method);
323
+ if (!positions) {
324
+ return false;
325
+ }
326
+ const call = outermostValueOf(path).parent;
327
+ if (call?.type !== utils_1.AST_NODE_TYPES.CallExpression) {
328
+ return false;
329
+ }
330
+ const last = positions.last ?? call.arguments.length - 1;
331
+ return call.arguments
332
+ .slice(positions.first, last + 1)
333
+ .some((argument) => !isEnrolledReference(argument));
334
+ };
273
335
  /**
274
336
  * Whether `node` sits in a position that writes to it: the left of an
275
337
  * assignment (plain or compound), the operand of `++`/`--` or `delete`, the
@@ -588,8 +650,10 @@ const isInferenceSite = (identifier) => {
588
650
  * second: a walk keyed on the first parameter would enrol a binding typed from
589
651
  * the seed value and miss the one typed from the constant (Issue #2338).
590
652
  *
591
- * Only the element parameter is enrolled. The index beside it is a `number`
592
- * whatever the receiver holds, so nothing the assertion changes reaches it.
653
+ * The INDEX parameter is absent from every map here, and from nothing else:
654
+ * it is a `number` whatever the receiver holds, so nothing the assertion
655
+ * changes reaches it. The parameter AFTER the index is a different matter —
656
+ * see `ARRAY_PARAMETER_INDEX_BY_METHOD`.
593
657
  */
594
658
  const ELEMENT_PARAMETER_INDEX_BY_METHOD = new Map([
595
659
  ['forEach', 0],
@@ -605,6 +669,65 @@ const ELEMENT_PARAMETER_INDEX_BY_METHOD = new Map([
605
669
  ['reduce', 1],
606
670
  ['reduceRight', 1],
607
671
  ]);
672
+ /**
673
+ * The same methods, mapped to the position the RECEIVER ARRAY arrives in.
674
+ *
675
+ * That parameter is a second name for the iterated value itself, so a mutating
676
+ * call through it writes to the constant:
677
+ * `ITEMS.forEach((item, index, arr) => { arr.push(2); })` is TS2339 once
678
+ * `ITEMS` is frozen, for an input that compiled — the identical call written
679
+ * directly as `ITEMS.push(2)` is one the rule already declines for, so only the
680
+ * handed-node spelling escapes it (Issue #2339).
681
+ *
682
+ * The position is carried apart from the element's because the two are enrolled
683
+ * on different terms rather than because they differ by one: an element keeps
684
+ * the constant's type through every derivation the iteration walk follows,
685
+ * while the receiver is the constant only when the iteration reads the
686
+ * constant's own value or member path — see `iterationBindingsOf`.
687
+ * `reduce`/`reduceRight` push it to fourth, having spent the first position on
688
+ * the accumulator.
689
+ *
690
+ * `flatMap` is listed for its ELEMENTS alone. Its lib signature declares the
691
+ * parameter `T[]` where every sibling declares it `readonly T[]` — measured
692
+ * against `lib.es2020` — so `arr[0].n = 2` inside a `flatMap` callback is
693
+ * TS2540 for an input that compiled, while a mutating method called through the
694
+ * parameter compiles unchanged. `MUTABLE_ARRAY_PARAMETER_METHODS` carries that
695
+ * second half, which enrolment alone cannot express (Issue #2340).
696
+ */
697
+ const ARRAY_PARAMETER_INDEX_BY_METHOD = new Map([
698
+ ['forEach', 2],
699
+ ['map', 2],
700
+ ['filter', 2],
701
+ ['find', 2],
702
+ ['findIndex', 2],
703
+ ['findLast', 2],
704
+ ['findLastIndex', 2],
705
+ ['some', 2],
706
+ ['every', 2],
707
+ ['flatMap', 2],
708
+ ['reduce', 3],
709
+ ['reduceRight', 3],
710
+ ]);
711
+ /**
712
+ * The methods above whose receiver-array parameter is declared MUTABLE `T[]`.
713
+ *
714
+ * Enrolling that parameter answers two questions at once, and each needs its own
715
+ * answer. Its ELEMENTS are frozen with the constant, so an element write through
716
+ * it is TS2540 and the assertion is withheld. A mutating METHOD through it is no
717
+ * readonly violation at all — the declared type is mutable, so there is no
718
+ * TS2339 to have — and withholding the assertion for one costs a report for a
719
+ * break that does not happen: `ITEMS.flatMap((x, i, arr) => { arr.sort(); return
720
+ * [x]; })` and the `arr.push(x)` spelling both compile under the assertion,
721
+ * measured by appending it by hand and reading the checker.
722
+ *
723
+ * What such a call CAN break is assignability, and only by introducing a value
724
+ * from outside the constant: `arr.push({ n: 3 })` is TS2322 once the assertion
725
+ * narrows the element type. That is decided per CALL by
726
+ * `introducesForeignElement`, not per method — an exemption keyed on the method
727
+ * alone would trade two over-declines for a `--fix` that stops the file
728
+ * compiling, which is the defect this walk exists to prevent (Issue #2340).
729
+ */
730
+ const MUTABLE_ARRAY_PARAMETER_METHODS = new Set(['flatMap']);
608
731
  /**
609
732
  * The `Object.values(X)` / `Object.entries(X)` call this value feeds — a fresh
610
733
  * array whose ELEMENTS are the constant's own property values, so freezing the
@@ -630,9 +753,150 @@ const elementProjectionCallOf = (node) => {
630
753
  : null;
631
754
  };
632
755
  /**
633
- * The bindings a construct that ITERATES `iterable` introduces for its
634
- * elements: the head of a `for…of` over it, or the parameter an array method
635
- * hands each element to.
756
+ * Array methods whose result ITERATES the receiver's own elements.
757
+ *
758
+ * The result is an iterator rather than an array, which is why it belongs to
759
+ * neither of the maps the walk already reads: nothing is copied, so
760
+ * `TYPE_PRESERVING_COPY_METHODS` refuses it, and nothing is handed to a
761
+ * callback, so `ELEMENT_PARAMETER_INDEX_BY_METHOD` refuses it too. Every
762
+ * binding taken from it still carries the constant's element type, so
763
+ * `for (const item of ITEMS.values()) { item.n = 2; }` is TS2540 once `ITEMS`
764
+ * is frozen, for an input that compiled (Issue #2340). `entries` yields
765
+ * `[index, element]` pairs, which carry the element exactly as `values` does.
766
+ *
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.
770
+ */
771
+ const ELEMENT_ITERATOR_METHODS = new Set(['values', 'entries']);
772
+ const iteratorProjectionCallOf = (node) => {
773
+ const parent = node.parent;
774
+ if (parent?.type !== utils_1.AST_NODE_TYPES.MemberExpression ||
775
+ parent.object !== node) {
776
+ return null;
777
+ }
778
+ const method = accessedPropertyName(parent);
779
+ if (method === null || !ELEMENT_ITERATOR_METHODS.has(method)) {
780
+ return null;
781
+ }
782
+ // A method REFERENCE (`const walk = ITEMS.values;`) iterates nothing, so the
783
+ // iterator exists only once the method is called — the same terms
784
+ // `copyExpressionOf` reads a copy on.
785
+ const callee = outermostValueOf(parent);
786
+ return callee.parent?.type === utils_1.AST_NODE_TYPES.CallExpression &&
787
+ callee.parent.callee === callee
788
+ ? callee.parent
789
+ : null;
790
+ };
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
+ /**
818
+ * The expressions a reference denotes a FROZEN value through: the reference
819
+ * itself and every property access rooted at it — `CONFIG`, `CONFIG.list`,
820
+ * `CONFIG.list.rows` for `CONFIG.list.rows`.
821
+ *
822
+ * `as const` freezes the value in depth, so a property of the constant carries
823
+ * the assertion exactly as the constant does, and an iteration is routinely
824
+ * reached through one (`CONFIG.list.values()`, `Array.from(CONFIG.list, fn)`).
825
+ * A derivation resolver reads a node's immediate parent, so it sees only the
826
+ * innermost access unless each step of the path is offered to it in turn
827
+ * (Issue #2340).
828
+ */
829
+ const accessPathsRootedAt = (identifier) => {
830
+ const paths = [];
831
+ let current = outermostValueOf(identifier);
832
+ for (;;) {
833
+ paths.push(current);
834
+ const parent = current.parent;
835
+ if (!parent ||
836
+ parent.type !== utils_1.AST_NODE_TYPES.MemberExpression ||
837
+ parent.object !== current) {
838
+ return paths;
839
+ }
840
+ current = outermostValueOf(parent);
841
+ }
842
+ };
843
+ /**
844
+ * Every way a value derived from this one keeps the constant's ELEMENT types:
845
+ * a copy of it, the `Object.values`/`Object.entries` array over it, the
846
+ * iterator its own `values`/`entries` hands back, and the collection built out
847
+ * of it. Each resolver returns an ANCESTOR of the node it is given, which is
848
+ * what lets the iteration walk follow them transitively without looping.
849
+ */
850
+ const DERIVATION_RESOLVERS = [
851
+ copyExpressionOf,
852
+ elementProjectionCallOf,
853
+ iteratorProjectionCallOf,
854
+ elementCollectionOf,
855
+ ];
856
+ const enrolFully = (variables) => variables.map((variable) => ({
857
+ variable,
858
+ breaksOnAnyMutatingMethod: true,
859
+ }));
860
+ /**
861
+ * The bindings a callback's parameters at `positions` introduce, each carrying
862
+ * the question its position can break on.
863
+ *
864
+ * A callback routinely declares fewer parameters than the caller passes, so a
865
+ * position is taken only where the signature actually spells it.
866
+ *
867
+ * The scope manager answers for the WHOLE function — every parameter, and a
868
+ * function expression's own name — so the enrolled parameters' bindings are
869
+ * picked out by the spans they are declared in. Taking the function's list
870
+ * whole would enrol the accumulator of a `reduce`, typed from the seed value
871
+ * rather than from the constant, and the index, which the assertion cannot
872
+ * reach.
873
+ */
874
+ const parameterBindingsOf = (callback, positions, declaredVariablesOf) => {
875
+ const enrolled = positions.flatMap(({ index, breaksOnAnyMutatingMethod }) => {
876
+ const param = callback.params[index];
877
+ return param ? [{ param, breaksOnAnyMutatingMethod }] : [];
878
+ });
879
+ if (enrolled.length === 0) {
880
+ return [];
881
+ }
882
+ return declaredVariablesOf(callback).flatMap((variable) => {
883
+ const position = enrolled.find(({ param }) => variable.defs.some((def) => def.name.range[0] >= param.range[0] &&
884
+ def.name.range[1] <= param.range[1]));
885
+ return position
886
+ ? [
887
+ {
888
+ variable,
889
+ breaksOnAnyMutatingMethod: position.breaksOnAnyMutatingMethod,
890
+ },
891
+ ]
892
+ : [];
893
+ });
894
+ };
895
+ /**
896
+ * The bindings a construct that ITERATES `iterable` introduces: the head of a
897
+ * `for…of` over it, the parameter an array method hands each element to, and —
898
+ * when the iterated expression is the constant itself — the parameter that
899
+ * method hands the RECEIVER ARRAY to.
636
900
  *
637
901
  * A `for…of` head is accepted in all three binding spellings, on the same terms
638
902
  * as `aliasDeclaratorOf` accepts all three declarator spellings — every name a
@@ -645,8 +909,14 @@ const elementProjectionCallOf = (node) => {
645
909
  * A callback parameter is reached only through a function LITERAL: a callback
646
910
  * passed by name is declared elsewhere, where its parameter carries whatever
647
911
  * type that declaration gives it rather than one read off the constant.
912
+ *
913
+ * `iteratesConstantValue` says whether `iterable` is the constant's own value
914
+ * or member path rather than something derived from it. It gates the receiver
915
+ * ARRAY parameter alone: the element parameter is typed from the constant
916
+ * either way, while the array parameter names the constant only in the first
917
+ * case — see `iterationBindingsOf`.
648
918
  */
649
- const elementBindingsOfIteration = (iterable, declaredVariablesOf) => {
919
+ const bindingsOfIterationOver = (iterable, declaredVariablesOf, iteratesConstantValue) => {
650
920
  // The member path is resolved first because the iterated expression is
651
921
  // routinely a PROPERTY of the constant (`for (const x of CONFIG.list)`),
652
922
  // which the alias walk refuses precisely because it arrives through a member
@@ -660,13 +930,32 @@ const elementBindingsOfIteration = (iterable, declaredVariablesOf) => {
660
930
  if (parent.type === utils_1.AST_NODE_TYPES.ForOfStatement &&
661
931
  parent.right === value &&
662
932
  parent.left.type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
663
- return declaredVariablesOf(parent.left);
933
+ return enrolFully(declaredVariablesOf(parent.left));
934
+ }
935
+ // `Array.from(X, mapfn)` hands each element of `X` to `mapfn` exactly as
936
+ // `X.map` hands it to a callback, so the mapper's first parameter is typed
937
+ // from the constant and `Array.from(ITEMS, (item) => { item.n = 2; … })` is
938
+ // TS2540 once `ITEMS` is frozen. The two-argument form reaches the walk
939
+ // nowhere else: `isCopyingCall` admits `Array.from` at one argument alone,
940
+ // because a mapper retypes the RESULT — which says nothing about the element
941
+ // it is handed (Issue #2340). The mapper takes the element first and the
942
+ // index second, and is handed no receiver array at all.
943
+ if (parent.type === utils_1.AST_NODE_TYPES.CallExpression &&
944
+ parent.arguments[0] === value &&
945
+ isNamespacedCallee(parent.callee, 'Array', 'from')) {
946
+ const mapper = parent.arguments[1];
947
+ return mapper && isFunctionValue(mapper)
948
+ ? parameterBindingsOf(mapper, [{ index: 0, breaksOnAnyMutatingMethod: true }], declaredVariablesOf)
949
+ : [];
664
950
  }
665
951
  if (path === null) {
666
952
  return [];
667
953
  }
668
954
  const method = accessedPropertyName(path);
669
- const elementIndex = method === null ? undefined : ELEMENT_PARAMETER_INDEX_BY_METHOD.get(method);
955
+ if (method === null) {
956
+ return [];
957
+ }
958
+ const elementIndex = ELEMENT_PARAMETER_INDEX_BY_METHOD.get(method);
670
959
  if (elementIndex === undefined ||
671
960
  parent.type !== utils_1.AST_NODE_TYPES.CallExpression ||
672
961
  parent.callee !== value) {
@@ -676,25 +965,29 @@ const elementBindingsOfIteration = (iterable, declaredVariablesOf) => {
676
965
  if (!callback || !isFunctionValue(callback)) {
677
966
  return [];
678
967
  }
679
- const element = callback.params[elementIndex];
680
- if (!element) {
681
- return [];
682
- }
683
- // The scope manager answers for the WHOLE function — every parameter, and a
684
- // function expression's own name — so the element parameter's bindings are
685
- // picked out by the span they are declared in. Taking the function's list
686
- // whole would enrol the accumulator of a `reduce`, which is typed from the
687
- // seed value rather than from the constant.
688
- return declaredVariablesOf(callback).filter((variable) => variable.defs.some((def) => def.name.range[0] >= element.range[0] &&
689
- def.name.range[1] <= element.range[1]));
968
+ const arrayIndex = iteratesConstantValue
969
+ ? ARRAY_PARAMETER_INDEX_BY_METHOD.get(method)
970
+ : undefined;
971
+ return parameterBindingsOf(callback, [
972
+ { index: elementIndex, breaksOnAnyMutatingMethod: true },
973
+ ...(arrayIndex === undefined
974
+ ? []
975
+ : [
976
+ {
977
+ index: arrayIndex,
978
+ breaksOnAnyMutatingMethod: !MUTABLE_ARRAY_PARAMETER_METHODS.has(method),
979
+ },
980
+ ]),
981
+ ], declaredVariablesOf);
690
982
  };
691
983
  /**
692
984
  * The bindings ITERATING this reference introduces, directly or through a value
693
985
  * derived from it that keeps its element types.
694
986
  *
695
987
  * Such a binding is typed from the constant exactly as a destructured copy is —
696
- * it carries the ELEMENT type rather than the whole value — so a write through
697
- * it breaks on the assertion the same way a write through an alias does:
988
+ * it carries the ELEMENT type, or for the receiver parameter the whole value —
989
+ * so a write through it breaks on the assertion as a write through an alias
990
+ * does:
698
991
  * `for (const item of ITEMS) { item.label = 'b'; }` is TS2540 once `ITEMS` is
699
992
  * frozen, for an input that compiled (Issue #2338). Enrolling the binding is
700
993
  * therefore the whole remedy; the walk's existing write, mutating-method and
@@ -702,20 +995,56 @@ const elementBindingsOfIteration = (iterable, declaredVariablesOf) => {
702
995
  * only READS its element fixable.
703
996
  *
704
997
  * The derivations are followed because the receiver of the iteration is
705
- * routinely one step removed from the constant (`[...ITEMS].forEach(…)`,
706
- * `ITEMS.filter(Boolean).forEach(…)`, `Object.values(CONFIG).forEach(…)`): each
707
- * builds a fresh OUTER value whose elements are still the frozen ones, so the
708
- * element binding breaks identically. One derivation step is followed, matching
709
- * the depth the alias walk already follows a copy to.
998
+ * routinely removed from the constant (`[...ITEMS].forEach(…)`,
999
+ * `ITEMS.filter(Boolean).forEach(…)`, `Object.values(CONFIG).forEach(…)`,
1000
+ * `ITEMS.values()`, `new Set(ITEMS)`): each builds a fresh OUTER value whose
1001
+ * elements are still the frozen ones, so the element binding breaks
1002
+ * identically.
1003
+ *
1004
+ * Following them is TRANSITIVE, on the same reasoning as the alias walk — every
1005
+ * hop keeps the element type, so a chain of them keeps it too, and
1006
+ * `ITEMS.filter(Boolean).slice().forEach((item) => { item.n = 2; })` is the
1007
+ * same TS2540 as the one-hop spelling that already declines. A single step
1008
+ * gave two spellings of one construct opposite verdicts (Issue #2340).
1009
+ *
1010
+ * The derivations are resolved from each step of the reference's own access
1011
+ * path as well as from the reference, since the value a derivation is taken
1012
+ * from is routinely a PROPERTY of the constant — see `accessPathsRootedAt`.
1013
+ *
1014
+ * The receiver ARRAY parameter is enrolled for the constant's own value or
1015
+ * member path ALONE, the one iterable that hands the callback the constant
1016
+ * itself. A derivation hands it the fresh outer value it
1017
+ * built, and mutating that is no readonly violation:
1018
+ * `[...ITEMS].forEach((item, index, arr) => { arr.push(3); })` does break after
1019
+ * the fix, but as TS2345 — the spread narrows the element type, so `3` is not
1020
+ * assignable — which belongs to the literal-narrowing family filed as #2330 and
1021
+ * needs the type checker. Enrolling it here would withhold the assertion for a
1022
+ * reason this arm cannot justify, so it is an over-decline (Issue #2339).
710
1023
  */
711
1024
  const iterationBindingsOf = (identifier, declaredVariablesOf) => {
712
1025
  const value = outermostValueOf(identifier);
713
- const iterables = [
714
- value,
715
- copyExpressionOf(value),
716
- elementProjectionCallOf(value),
1026
+ const bindings = [
1027
+ ...bindingsOfIterationOver(value, declaredVariablesOf, true),
717
1028
  ];
718
- return iterables.flatMap((iterable) => iterable ? elementBindingsOfIteration(iterable, declaredVariablesOf) : []);
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));
1045
+ }
1046
+ }
1047
+ return bindings;
719
1048
  };
720
1049
  /**
721
1050
  * Whether anything in the file stops this binding taking `as const`, under its
@@ -748,13 +1077,16 @@ const iterationBindingsOf = (identifier, declaredVariablesOf) => {
748
1077
  * the one value — and `visited` keeps a chain that leads back on itself, which
749
1078
  * a redeclared `var` can build, from looping forever.
750
1079
  *
751
- * Iteration is followed on the same reasoning, keyed on the ELEMENT rather than
752
- * the whole value: a `for…of` head and an iteration callback's parameter are
753
- * second names for the constant's contents, so `for (const item of ITEMS) {
1080
+ * Iteration is followed on the same reasoning, keyed on what the construct
1081
+ * HANDS its body: a `for…of` head and an iteration callback's element parameter
1082
+ * are second names for the constant's contents, so `for (const item of ITEMS) {
754
1083
  * item.label = 'b'; }` is TS2540 once `ITEMS` is frozen while `ITEMS`'s own
755
- * references show nothing but a read (Issue #2338). Enrolling the binding is
756
- * all it takes — the checks above then decide, so a loop that only reads its
757
- * element keeps the assertion.
1084
+ * references show nothing but a read (Issue #2338); the parameter after the
1085
+ * index is a second name for the constant ITSELF, so `arr.push(2)` inside the
1086
+ * callback is the TS2339 the rule already declines for when the same call is
1087
+ * written directly (Issue #2339). Enrolling the binding is all it takes — the
1088
+ * checks above then decide, so a callback that only reads what it is handed
1089
+ * keeps the assertion.
758
1090
  *
759
1091
  * The declaring KEYWORD is deliberately not screened. `as const` types the
760
1092
  * value `readonly`, and a binding takes its declared type from its initializer,
@@ -767,10 +1099,37 @@ const iterationBindingsOf = (identifier, declaredVariablesOf) => {
767
1099
  const blocksAsConstAssertion = (variable, declaredVariablesOf) => {
768
1100
  // Grown in place and walked by index: an alias found mid-walk is appended and
769
1101
  // reached by the same loop, so the traversal needs no recursion of its own.
770
- const pending = [variable];
771
- const visited = new Set(pending);
1102
+ const pending = [
1103
+ { variable, breaksOnAnyMutatingMethod: true },
1104
+ ];
1105
+ const visited = new Set([variable]);
1106
+ /**
1107
+ * Whether a node is a REFERENCE to a binding already enrolled for this
1108
+ * constant, and so holds a value typed from the constant itself.
1109
+ *
1110
+ * Answered from the scope manager's reference lists rather than by name, on
1111
+ * the same terms as the rest of the walk: a same-named binding from another
1112
+ * scope names another value and must not exempt anything.
1113
+ *
1114
+ * A binding enrolled LATER in the walk than the one being examined answers
1115
+ * false here. That can only withhold an exemption, never grant one wrongly,
1116
+ * so the walk order costs a report at worst.
1117
+ */
1118
+ const isEnrolledReference = (node) => {
1119
+ const value = unwrapValueWrappers(node);
1120
+ if (value.type !== utils_1.AST_NODE_TYPES.Identifier) {
1121
+ return false;
1122
+ }
1123
+ for (const enrolledVariable of visited) {
1124
+ if (enrolledVariable.references.some((enrolledReference) => enrolledReference.identifier === value)) {
1125
+ return true;
1126
+ }
1127
+ }
1128
+ return false;
1129
+ };
772
1130
  for (let index = 0; index < pending.length; index += 1) {
773
- for (const reference of pending[index].references) {
1131
+ const { variable: enrolled, breaksOnAnyMutatingMethod } = pending[index];
1132
+ for (const reference of enrolled.references) {
774
1133
  // Reassigning an alias is as disqualifying as writing through one. A
775
1134
  // binding that takes its type from the constant narrows to the frozen
776
1135
  // literal, so `let stage = DEFAULT; stage = 'live';` becomes TS2322 for
@@ -783,8 +1142,20 @@ const blocksAsConstAssertion = (variable, declaredVariablesOf) => {
783
1142
  return true;
784
1143
  }
785
1144
  const path = accessPathOf(reference.identifier);
1145
+ // The ELEMENT question is asked of every binding alike: the elements are
1146
+ // frozen whatever the container's own declaration says.
1147
+ if (path !== null && isWriteTarget(path)) {
1148
+ return true;
1149
+ }
1150
+ // The mutating-method question is asked in full of every binding the
1151
+ // assertion types `readonly`, where any such call is a TS2339. A
1152
+ // parameter the lib declares MUTABLE has no such break to have, so it is
1153
+ // asked the narrower question that remains: does this call introduce a
1154
+ // value the narrowed element type would have to accept (Issue #2340).
786
1155
  if (path !== null &&
787
- (isMutatingMethodCall(path) || isWriteTarget(path))) {
1156
+ isMutatingMethodCall(path) &&
1157
+ (breaksOnAnyMutatingMethod ||
1158
+ introducesForeignElement(path, isEnrolledReference))) {
788
1159
  return true;
789
1160
  }
790
1161
  // A copy carries the constant's frozen type into a second binding, so it
@@ -799,14 +1170,16 @@ const blocksAsConstAssertion = (variable, declaredVariablesOf) => {
799
1170
  : null;
800
1171
  // A binding introduced by ITERATING the constant is enrolled beside the
801
1172
  // aliases: it names the constant's CONTENTS, which the assertion freezes
802
- // with the constant itself — see `iterationBindingsOf`.
1173
+ // with the constant itself — see `iterationBindingsOf`. An alias is
1174
+ // enrolled on the constant's own terms, because it denotes the constant's
1175
+ // value and so carries its readonly-ness whole.
803
1176
  const derived = [
804
- ...(declarator ? declaredVariablesOf(declarator) : []),
1177
+ ...enrolFully(declarator ? declaredVariablesOf(declarator) : []),
805
1178
  ...iterationBindingsOf(reference.identifier, declaredVariablesOf),
806
1179
  ];
807
1180
  for (const alias of derived) {
808
- if (!visited.has(alias)) {
809
- visited.add(alias);
1181
+ if (!visited.has(alias.variable)) {
1182
+ visited.add(alias.variable);
810
1183
  pending.push(alias);
811
1184
  }
812
1185
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.21.12",
3
+ "version": "1.21.14",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,32 @@
1
1
  [
2
+ {
3
+ "version": "1.21.14",
4
+ "date": "2026-09-06T01:53:58.858Z",
5
+ "rules": [
6
+ {
7
+ "name": "global-const-style",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2340
11
+ ],
12
+ "summary": "enrol the element bindings of derived iterables (closes #2340)"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "version": "1.21.13",
18
+ "date": "2026-09-05T22:39:56.997Z",
19
+ "rules": [
20
+ {
21
+ "name": "global-const-style",
22
+ "changeType": "fix",
23
+ "issues": [
24
+ 2339
25
+ ],
26
+ "summary": "enrol the receiver array parameter of an iteration over the constant (closes #2339)"
27
+ }
28
+ ]
29
+ },
2
30
  {
3
31
  "version": "1.21.12",
4
32
  "date": "2026-09-05T18:30:20.453Z",