@blumintinc/eslint-plugin-blumint 1.21.11 → 1.21.12

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.11',
227
+ version: '1.21.12',
228
228
  },
229
229
  parseOptions: {
230
230
  ecmaVersion: 2020,
@@ -579,6 +579,144 @@ const isInferenceSite = (identifier) => {
579
579
  value = outermostValueOf(container);
580
580
  }
581
581
  };
582
+ /**
583
+ * Array methods that hand an ELEMENT of the receiver to a callback, mapped to
584
+ * the parameter position that element arrives in.
585
+ *
586
+ * The position is carried per method rather than assumed to be the first,
587
+ * because `reduce`/`reduceRight` pass the accumulator first and the element
588
+ * second: a walk keyed on the first parameter would enrol a binding typed from
589
+ * the seed value and miss the one typed from the constant (Issue #2338).
590
+ *
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.
593
+ */
594
+ const ELEMENT_PARAMETER_INDEX_BY_METHOD = new Map([
595
+ ['forEach', 0],
596
+ ['map', 0],
597
+ ['filter', 0],
598
+ ['find', 0],
599
+ ['findIndex', 0],
600
+ ['findLast', 0],
601
+ ['findLastIndex', 0],
602
+ ['some', 0],
603
+ ['every', 0],
604
+ ['flatMap', 0],
605
+ ['reduce', 1],
606
+ ['reduceRight', 1],
607
+ ]);
608
+ /**
609
+ * The `Object.values(X)` / `Object.entries(X)` call this value feeds — a fresh
610
+ * array whose ELEMENTS are the constant's own property values, so freezing the
611
+ * constant retypes them exactly as it retypes an array's elements.
612
+ *
613
+ * It is not a copy in `copyExpressionOf`'s sense: the result has a different
614
+ * shape from the argument, so a write to the array itself says nothing about
615
+ * the constant. It is resolved here instead, where only the ITERATION question
616
+ * is asked and a decline still requires a write through the element binding.
617
+ *
618
+ * `Object.keys` is absent because its result is `string[]` whatever the
619
+ * argument's type, so the assertion cannot reach a binding taken from it.
620
+ */
621
+ const elementProjectionCallOf = (node) => {
622
+ const parent = node.parent;
623
+ if (parent?.type !== utils_1.AST_NODE_TYPES.CallExpression ||
624
+ parent.arguments[0] !== node) {
625
+ return null;
626
+ }
627
+ return isNamespacedCallee(parent.callee, 'Object', 'values') ||
628
+ isNamespacedCallee(parent.callee, 'Object', 'entries')
629
+ ? parent
630
+ : null;
631
+ };
632
+ /**
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.
636
+ *
637
+ * A `for…of` head is accepted in all three binding spellings, on the same terms
638
+ * as `aliasDeclaratorOf` accepts all three declarator spellings — every name a
639
+ * pattern introduces is typed from the value it destructures. A head that is
640
+ * not a declaration assigns into a binding declared elsewhere, whose type the
641
+ * constant never gave it, so it introduces nothing to enrol. `for await` is the
642
+ * same node with `await` set and binds its element the same way, so the flag is
643
+ * not screened.
644
+ *
645
+ * A callback parameter is reached only through a function LITERAL: a callback
646
+ * passed by name is declared elsewhere, where its parameter carries whatever
647
+ * type that declaration gives it rather than one read off the constant.
648
+ */
649
+ const elementBindingsOfIteration = (iterable, declaredVariablesOf) => {
650
+ // The member path is resolved first because the iterated expression is
651
+ // routinely a PROPERTY of the constant (`for (const x of CONFIG.list)`),
652
+ // which the alias walk refuses precisely because it arrives through a member
653
+ // access — the property is frozen with the object that holds it.
654
+ const path = accessPathOf(iterable);
655
+ const value = outermostValueOf(path ?? iterable);
656
+ const parent = value.parent;
657
+ if (!parent) {
658
+ return [];
659
+ }
660
+ if (parent.type === utils_1.AST_NODE_TYPES.ForOfStatement &&
661
+ parent.right === value &&
662
+ parent.left.type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
663
+ return declaredVariablesOf(parent.left);
664
+ }
665
+ if (path === null) {
666
+ return [];
667
+ }
668
+ const method = accessedPropertyName(path);
669
+ const elementIndex = method === null ? undefined : ELEMENT_PARAMETER_INDEX_BY_METHOD.get(method);
670
+ if (elementIndex === undefined ||
671
+ parent.type !== utils_1.AST_NODE_TYPES.CallExpression ||
672
+ parent.callee !== value) {
673
+ return [];
674
+ }
675
+ const callback = parent.arguments[0];
676
+ if (!callback || !isFunctionValue(callback)) {
677
+ return [];
678
+ }
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]));
690
+ };
691
+ /**
692
+ * The bindings ITERATING this reference introduces, directly or through a value
693
+ * derived from it that keeps its element types.
694
+ *
695
+ * 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:
698
+ * `for (const item of ITEMS) { item.label = 'b'; }` is TS2540 once `ITEMS` is
699
+ * frozen, for an input that compiled (Issue #2338). Enrolling the binding is
700
+ * therefore the whole remedy; the walk's existing write, mutating-method and
701
+ * inference checks answer the question on it, which is what keeps a loop that
702
+ * only READS its element fixable.
703
+ *
704
+ * 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.
710
+ */
711
+ const iterationBindingsOf = (identifier, declaredVariablesOf) => {
712
+ const value = outermostValueOf(identifier);
713
+ const iterables = [
714
+ value,
715
+ copyExpressionOf(value),
716
+ elementProjectionCallOf(value),
717
+ ];
718
+ return iterables.flatMap((iterable) => iterable ? elementBindingsOfIteration(iterable, declaredVariablesOf) : []);
719
+ };
582
720
  /**
583
721
  * Whether anything in the file stops this binding taking `as const`, under its
584
722
  * own name or through an alias of it.
@@ -610,6 +748,14 @@ const isInferenceSite = (identifier) => {
610
748
  * the one value — and `visited` keeps a chain that leads back on itself, which
611
749
  * a redeclared `var` can build, from looping forever.
612
750
  *
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) {
754
+ * 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.
758
+ *
613
759
  * The declaring KEYWORD is deliberately not screened. `as const` types the
614
760
  * value `readonly`, and a binding takes its declared type from its initializer,
615
761
  * so `let other = ITEMS; other.push(3);` is the same TS2339 as the `const`
@@ -651,10 +797,14 @@ const blocksAsConstAssertion = (variable, declaredVariablesOf) => {
651
797
  : path === null
652
798
  ? aliasDeclaratorOf(reference.identifier)
653
799
  : null;
654
- if (!declarator) {
655
- continue;
656
- }
657
- for (const alias of declaredVariablesOf(declarator)) {
800
+ // A binding introduced by ITERATING the constant is enrolled beside the
801
+ // aliases: it names the constant's CONTENTS, which the assertion freezes
802
+ // with the constant itself — see `iterationBindingsOf`.
803
+ const derived = [
804
+ ...(declarator ? declaredVariablesOf(declarator) : []),
805
+ ...iterationBindingsOf(reference.identifier, declaredVariablesOf),
806
+ ];
807
+ for (const alias of derived) {
658
808
  if (!visited.has(alias)) {
659
809
  visited.add(alias);
660
810
  pending.push(alias);
@@ -348,9 +348,10 @@ const isInsideFunction = (node) => {
348
348
  };
349
349
  const isPascalCaseName = (name) => /^[A-Z]/.test(name);
350
350
  /**
351
- * Prop names whose value a parent MOUNTS rather than calls. Kept identical to
352
- * the `JSXAttribute` visitor's own test so the two paths cannot disagree about
353
- * what a component-type prop is — the disagreement between them is #2334.
351
+ * Prop names whose value a parent MOUNTS rather than calls. The `JSXAttribute`
352
+ * visitor and the binding-side classifier both read it, so the two paths cannot
353
+ * disagree about what a component-type prop is — the disagreement between them
354
+ * is #2334, and the duplicated literal that would let it return is #2337.
354
355
  */
355
356
  const COMPONENT_PROP_SUFFIX = /(Wrapper|Component|Template|Header|Footer)$/;
356
357
  const isComponentPropName = (name) => isPascalCaseName(name) && COMPONENT_PROP_SUFFIX.test(name);
@@ -901,12 +902,15 @@ See: https://react.dev/learn/your-first-component#nesting-and-organizing-compone
901
902
  if (node.name.type !== utils_1.AST_NODE_TYPES.JSXIdentifier)
902
903
  return;
903
904
  const attrName = node.name.name;
904
- // Check if it's a component-type prop. A non-PascalCase prop (e.g.
905
- // renderHeader) is a render callback used with a render={...} prop, not
906
- // a component—skip it, mirroring the binding-side carve-out above. The
907
- // suffix alone is not enough: it matches the tail of renderHeader.
908
- if (!isPascalCaseName(attrName) ||
909
- !/(Wrapper|Component|Template|Header|Footer)$/.test(attrName)) {
905
+ // A non-PascalCase prop (e.g. renderHeader) is a render callback used
906
+ // with a render={...} prop, not a component. The suffix alone is not
907
+ // enough: it matches the tail of renderHeader.
908
+ //
909
+ // Read through the shared helper rather than a second copy of the
910
+ // pattern, so the binding side and this one cannot answer differently
911
+ // about what a component-type prop is — the disagreement between the
912
+ // two paths is #2334, and a duplicated literal is what lets it return.
913
+ if (!isComponentPropName(attrName)) {
910
914
  return;
911
915
  }
912
916
  if (!node.value ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.21.11",
3
+ "version": "1.21.12",
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.12",
4
+ "date": "2026-09-05T18:30:20.453Z",
5
+ "rules": [
6
+ {
7
+ "name": "global-const-style",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2338
11
+ ],
12
+ "summary": "withhold `as const` when a binding introduced by iterating the constant is written (closes #2338)"
13
+ }
14
+ ]
15
+ },
2
16
  {
3
17
  "version": "1.21.11",
4
18
  "date": "2026-09-05T11:06:01.167Z",