@blumintinc/eslint-plugin-blumint 1.21.13 → 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.13',
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
@@ -625,12 +687,12 @@ const ELEMENT_PARAMETER_INDEX_BY_METHOD = new Map([
625
687
  * `reduce`/`reduceRight` push it to fourth, having spent the first position on
626
688
  * the accumulator.
627
689
  *
628
- * `flatMap` is listed even though its lib signature declares the parameter
629
- * `T[]` where every sibling declares it `readonly T[]` — measured against
630
- * `lib.es2020`, so a mutating METHOD through it survives the assertion. Its
631
- * ELEMENTS are frozen regardless, so `arr[0].n = 2` inside a `flatMap` callback
632
- * is TS2540 for an input that compiled, and the walk's write check reaches it
633
- * only once the parameter is enrolled.
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).
634
696
  */
635
697
  const ARRAY_PARAMETER_INDEX_BY_METHOD = new Map([
636
698
  ['forEach', 2],
@@ -646,6 +708,26 @@ const ARRAY_PARAMETER_INDEX_BY_METHOD = new Map([
646
708
  ['reduce', 3],
647
709
  ['reduceRight', 3],
648
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']);
649
731
  /**
650
732
  * The `Object.values(X)` / `Object.entries(X)` call this value feeds — a fresh
651
733
  * array whose ELEMENTS are the constant's own property values, so freezing the
@@ -670,6 +752,146 @@ const elementProjectionCallOf = (node) => {
670
752
  ? parent
671
753
  : null;
672
754
  };
755
+ /**
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
+ };
673
895
  /**
674
896
  * The bindings a construct that ITERATES `iterable` introduces: the head of a
675
897
  * `for…of` over it, the parameter an array method hands each element to, and —
@@ -708,7 +930,23 @@ const bindingsOfIterationOver = (iterable, declaredVariablesOf, iteratesConstant
708
930
  if (parent.type === utils_1.AST_NODE_TYPES.ForOfStatement &&
709
931
  parent.right === value &&
710
932
  parent.left.type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
711
- 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
+ : [];
712
950
  }
713
951
  if (path === null) {
714
952
  return [];
@@ -730,23 +968,17 @@ const bindingsOfIterationOver = (iterable, declaredVariablesOf, iteratesConstant
730
968
  const arrayIndex = iteratesConstantValue
731
969
  ? ARRAY_PARAMETER_INDEX_BY_METHOD.get(method)
732
970
  : undefined;
733
- // A callback routinely declares fewer parameters than the method passes, so
734
- // each position is taken only where the signature actually spells it.
735
- const enrolled = [
736
- callback.params[elementIndex],
737
- arrayIndex === undefined ? undefined : callback.params[arrayIndex],
738
- ].filter((param) => param !== undefined);
739
- if (enrolled.length === 0) {
740
- return [];
741
- }
742
- // The scope manager answers for the WHOLE function — every parameter, and a
743
- // function expression's own name — so the enrolled parameters' bindings are
744
- // picked out by the spans they are declared in. Taking the function's list
745
- // whole would enrol the accumulator of a `reduce`, typed from the seed value
746
- // rather than from the constant, and the index, which the assertion cannot
747
- // reach.
748
- return declaredVariablesOf(callback).filter((variable) => variable.defs.some((def) => enrolled.some((param) => def.name.range[0] >= param.range[0] &&
749
- def.name.range[1] <= param.range[1])));
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);
750
982
  };
751
983
  /**
752
984
  * The bindings ITERATING this reference introduces, directly or through a value
@@ -763,15 +995,25 @@ const bindingsOfIterationOver = (iterable, declaredVariablesOf, iteratesConstant
763
995
  * only READS its element fixable.
764
996
  *
765
997
  * The derivations are followed because the receiver of the iteration is
766
- * routinely one step removed from the constant (`[...ITEMS].forEach(…)`,
767
- * `ITEMS.filter(Boolean).forEach(…)`, `Object.values(CONFIG).forEach(…)`): each
768
- * builds a fresh OUTER value whose elements are still the frozen ones, so the
769
- * element binding breaks identically. One derivation step is followed, matching
770
- * 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`.
771
1013
  *
772
1014
  * The receiver ARRAY parameter is enrolled for the constant's own value or
773
- * member path ALONE, which is the one iterable of the three that hands the
774
- * callback the constant itself. A derivation hands it the fresh outer value it
1015
+ * member path ALONE, the one iterable that hands the callback the constant
1016
+ * itself. A derivation hands it the fresh outer value it
775
1017
  * built, and mutating that is no readonly violation:
776
1018
  * `[...ITEMS].forEach((item, index, arr) => { arr.push(3); })` does break after
777
1019
  * the fix, but as TS2345 — the spread narrows the element type, so `3` is not
@@ -781,13 +1023,28 @@ const bindingsOfIterationOver = (iterable, declaredVariablesOf, iteratesConstant
781
1023
  */
782
1024
  const iterationBindingsOf = (identifier, declaredVariablesOf) => {
783
1025
  const value = outermostValueOf(identifier);
784
- const derivations = [copyExpressionOf(value), elementProjectionCallOf(value)];
785
- return [
1026
+ const bindings = [
786
1027
  ...bindingsOfIterationOver(value, declaredVariablesOf, true),
787
- ...derivations.flatMap((iterable) => iterable
788
- ? bindingsOfIterationOver(iterable, declaredVariablesOf, false)
789
- : []),
790
1028
  ];
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;
791
1048
  };
792
1049
  /**
793
1050
  * Whether anything in the file stops this binding taking `as const`, under its
@@ -842,10 +1099,37 @@ const iterationBindingsOf = (identifier, declaredVariablesOf) => {
842
1099
  const blocksAsConstAssertion = (variable, declaredVariablesOf) => {
843
1100
  // Grown in place and walked by index: an alias found mid-walk is appended and
844
1101
  // reached by the same loop, so the traversal needs no recursion of its own.
845
- const pending = [variable];
846
- 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
+ };
847
1130
  for (let index = 0; index < pending.length; index += 1) {
848
- for (const reference of pending[index].references) {
1131
+ const { variable: enrolled, breaksOnAnyMutatingMethod } = pending[index];
1132
+ for (const reference of enrolled.references) {
849
1133
  // Reassigning an alias is as disqualifying as writing through one. A
850
1134
  // binding that takes its type from the constant narrows to the frozen
851
1135
  // literal, so `let stage = DEFAULT; stage = 'live';` becomes TS2322 for
@@ -858,8 +1142,20 @@ const blocksAsConstAssertion = (variable, declaredVariablesOf) => {
858
1142
  return true;
859
1143
  }
860
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).
861
1155
  if (path !== null &&
862
- (isMutatingMethodCall(path) || isWriteTarget(path))) {
1156
+ isMutatingMethodCall(path) &&
1157
+ (breaksOnAnyMutatingMethod ||
1158
+ introducesForeignElement(path, isEnrolledReference))) {
863
1159
  return true;
864
1160
  }
865
1161
  // A copy carries the constant's frozen type into a second binding, so it
@@ -874,14 +1170,16 @@ const blocksAsConstAssertion = (variable, declaredVariablesOf) => {
874
1170
  : null;
875
1171
  // A binding introduced by ITERATING the constant is enrolled beside the
876
1172
  // aliases: it names the constant's CONTENTS, which the assertion freezes
877
- // 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.
878
1176
  const derived = [
879
- ...(declarator ? declaredVariablesOf(declarator) : []),
1177
+ ...enrolFully(declarator ? declaredVariablesOf(declarator) : []),
880
1178
  ...iterationBindingsOf(reference.identifier, declaredVariablesOf),
881
1179
  ];
882
1180
  for (const alias of derived) {
883
- if (!visited.has(alias)) {
884
- visited.add(alias);
1181
+ if (!visited.has(alias.variable)) {
1182
+ visited.add(alias.variable);
885
1183
  pending.push(alias);
886
1184
  }
887
1185
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.21.13",
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,18 @@
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
+ },
2
16
  {
3
17
  "version": "1.21.13",
4
18
  "date": "2026-09-05T22:39:56.997Z",