@blumintinc/eslint-plugin-blumint 1.20.42 → 1.20.43

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
@@ -223,7 +223,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
223
223
  module.exports = {
224
224
  meta: {
225
225
  name: '@blumintinc/eslint-plugin-blumint',
226
- version: '1.20.42',
226
+ version: '1.20.43',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const createRule_1 = require("../utils/createRule");
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
+ const ASTHelpers_1 = require("../utils/ASTHelpers");
5
6
  exports.default = (0, createRule_1.createRule)({
6
7
  name: 'enforce-callback-memo',
7
8
  meta: {
@@ -194,6 +195,14 @@ exports.default = (0, createRule_1.createRule)({
194
195
  node.value.type !== utils_1.AST_NODE_TYPES.JSXExpressionContainer) {
195
196
  return;
196
197
  }
198
+ // The only remediation this rule offers is a hook call, which is legal
199
+ // solely inside a component or a hook. Module-scope JSX, a plain helper
200
+ // that happens to build JSX, and JSX rendered from a test body are all
201
+ // outside any render path, so wrapping there would throw
202
+ // "Invalid hook call" while saving no re-render.
203
+ if (!ASTHelpers_1.ASTHelpers.isInsideComponentOrHook(node, context)) {
204
+ return;
205
+ }
197
206
  // Props of JSX built inside a useMemo factory inherit the memo's stability
198
207
  if (isInsideUseMemoFactory(node)) {
199
208
  return;
@@ -61,6 +61,47 @@ export declare class ASTHelpers {
61
61
  * parenthesized expressions to get to the underlying expression.
62
62
  */
63
63
  static unwrapTSAssertions(node: TSESTree.Node): TSESTree.Node;
64
+ /**
65
+ * Calls that wrap a component/hook definition without renaming it, so the
66
+ * binding they are assigned to still names the wrapped function
67
+ * (`const Component = memo(() => ...)`).
68
+ */
69
+ private static readonly TRANSPARENT_WRAPPER_CALLEES;
70
+ private static isFunctionNode;
71
+ private static isTransparentWrapperCall;
72
+ private static staticPropertyName;
73
+ /**
74
+ * Resolves the name a function is known by: its own identifier, or the
75
+ * binding it is assigned to (variable, object property, class field,
76
+ * assignment target). Returns null only when the function is truly
77
+ * anonymous, e.g. an inline callback argument such as `items.map(() => ...)`.
78
+ */
79
+ static inferFunctionName(node: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression | TSESTree.FunctionDeclaration): string | null;
80
+ /**
81
+ * React's universal convention: only PascalCase-initial identifiers are
82
+ * components, and only `use`-prefixed ones are hooks. A camelCase name is a
83
+ * plain helper or a render-prop callback.
84
+ */
85
+ private static isComponentOrHookName;
86
+ /**
87
+ * Reports whether a node sits anywhere inside a React component or hook, so a
88
+ * rule whose remediation is a hook call (useCallback/useMemo/useState) can
89
+ * stay silent where that call would be a Rules-of-Hooks violation: module
90
+ * scope, a plain helper function, or a test body such as `it(() => ...)`.
91
+ *
92
+ * The whole enclosing-function ancestry is consulted, not just the nearest
93
+ * function: a `.map()` render callback inside a component is still a render
94
+ * path and must stay reportable.
95
+ *
96
+ * Classification is name-first. A function the developer named is judged by
97
+ * that name alone — `buildTree` is not a component even though it returns
98
+ * JSX, because the name is an explicit signal about its role. Only a truly
99
+ * anonymous function falls back to "does it return JSX", which is what makes
100
+ * `memo(() => <div />)` a component. That fallback is suppressed when some
101
+ * enclosing function carries a non-component name, since a callback nested in
102
+ * a plain helper is no more of a render path than the helper itself.
103
+ */
104
+ static isInsideComponentOrHook(node: TSESTree.Node, context?: Readonly<TSESLint.RuleContext<string, readonly unknown[]>>): boolean;
64
105
  /**
65
106
  * Helper to get ancestors of a node in a way that is compatible with both ESLint v8 and v9.
66
107
  * In ESLint v9, context.getAncestors() is deprecated and moved to context.sourceCode.getAncestors(node).
@@ -661,6 +661,140 @@ class ASTHelpers {
661
661
  }
662
662
  return inner;
663
663
  }
664
+ static isFunctionNode(node) {
665
+ return (node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
666
+ node.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
667
+ node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration);
668
+ }
669
+ static isTransparentWrapperCall(node) {
670
+ const { callee } = node;
671
+ if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
672
+ return this.TRANSPARENT_WRAPPER_CALLEES.has(callee.name);
673
+ }
674
+ return (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
675
+ !callee.computed &&
676
+ callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
677
+ this.TRANSPARENT_WRAPPER_CALLEES.has(callee.property.name));
678
+ }
679
+ static staticPropertyName(key, computed) {
680
+ if (computed) {
681
+ return null;
682
+ }
683
+ if (key.type === utils_1.AST_NODE_TYPES.Identifier) {
684
+ return key.name;
685
+ }
686
+ if (key.type === utils_1.AST_NODE_TYPES.Literal && typeof key.value === 'string') {
687
+ return key.value;
688
+ }
689
+ return null;
690
+ }
691
+ /**
692
+ * Resolves the name a function is known by: its own identifier, or the
693
+ * binding it is assigned to (variable, object property, class field,
694
+ * assignment target). Returns null only when the function is truly
695
+ * anonymous, e.g. an inline callback argument such as `items.map(() => ...)`.
696
+ */
697
+ static inferFunctionName(node) {
698
+ if (node.id?.name) {
699
+ return node.id.name;
700
+ }
701
+ let child = node;
702
+ let parent = node.parent;
703
+ while (parent) {
704
+ switch (parent.type) {
705
+ case utils_1.AST_NODE_TYPES.VariableDeclarator:
706
+ return parent.id.type === utils_1.AST_NODE_TYPES.Identifier
707
+ ? parent.id.name
708
+ : null;
709
+ case utils_1.AST_NODE_TYPES.Property:
710
+ return this.staticPropertyName(parent.key, parent.computed);
711
+ case utils_1.AST_NODE_TYPES.PropertyDefinition:
712
+ case utils_1.AST_NODE_TYPES.MethodDefinition:
713
+ return this.staticPropertyName(parent.key, parent.computed);
714
+ case utils_1.AST_NODE_TYPES.AssignmentExpression: {
715
+ const { left } = parent;
716
+ if (left.type === utils_1.AST_NODE_TYPES.Identifier) {
717
+ return left.name;
718
+ }
719
+ if (left.type === utils_1.AST_NODE_TYPES.MemberExpression) {
720
+ return this.staticPropertyName(left.property, left.computed);
721
+ }
722
+ return null;
723
+ }
724
+ case utils_1.AST_NODE_TYPES.TSAsExpression:
725
+ case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
726
+ case utils_1.AST_NODE_TYPES.TSNonNullExpression:
727
+ case utils_1.AST_NODE_TYPES.TSTypeAssertion:
728
+ child = parent;
729
+ parent = parent.parent;
730
+ continue;
731
+ case utils_1.AST_NODE_TYPES.CallExpression:
732
+ // Only step through wrappers that preserve identity; an arbitrary
733
+ // callback argument (`items.map(fn)`) is not named by whatever the
734
+ // call's result is assigned to.
735
+ if (parent.arguments.includes(child) &&
736
+ this.isTransparentWrapperCall(parent)) {
737
+ child = parent;
738
+ parent = parent.parent;
739
+ continue;
740
+ }
741
+ return null;
742
+ default:
743
+ return null;
744
+ }
745
+ }
746
+ return null;
747
+ }
748
+ /**
749
+ * React's universal convention: only PascalCase-initial identifiers are
750
+ * components, and only `use`-prefixed ones are hooks. A camelCase name is a
751
+ * plain helper or a render-prop callback.
752
+ */
753
+ static isComponentOrHookName(name) {
754
+ return /^[A-Z]/.test(name) || /^use[A-Z0-9_]/.test(name);
755
+ }
756
+ /**
757
+ * Reports whether a node sits anywhere inside a React component or hook, so a
758
+ * rule whose remediation is a hook call (useCallback/useMemo/useState) can
759
+ * stay silent where that call would be a Rules-of-Hooks violation: module
760
+ * scope, a plain helper function, or a test body such as `it(() => ...)`.
761
+ *
762
+ * The whole enclosing-function ancestry is consulted, not just the nearest
763
+ * function: a `.map()` render callback inside a component is still a render
764
+ * path and must stay reportable.
765
+ *
766
+ * Classification is name-first. A function the developer named is judged by
767
+ * that name alone — `buildTree` is not a component even though it returns
768
+ * JSX, because the name is an explicit signal about its role. Only a truly
769
+ * anonymous function falls back to "does it return JSX", which is what makes
770
+ * `memo(() => <div />)` a component. That fallback is suppressed when some
771
+ * enclosing function carries a non-component name, since a callback nested in
772
+ * a plain helper is no more of a render path than the helper itself.
773
+ */
774
+ static isInsideComponentOrHook(node, context) {
775
+ const anonymousFunctions = [];
776
+ let hasNamedNonComponent = false;
777
+ let current = node.parent;
778
+ while (current) {
779
+ if (this.isFunctionNode(current)) {
780
+ const name = this.inferFunctionName(current);
781
+ if (name === null) {
782
+ anonymousFunctions.push(current);
783
+ }
784
+ else if (this.isComponentOrHookName(name)) {
785
+ return true;
786
+ }
787
+ else {
788
+ hasNamedNonComponent = true;
789
+ }
790
+ }
791
+ current = current.parent;
792
+ }
793
+ if (hasNamedNonComponent) {
794
+ return false;
795
+ }
796
+ return anonymousFunctions.some((fn) => this.returnsJSX(fn, context));
797
+ }
664
798
  /**
665
799
  * Helper to get ancestors of a node in a way that is compatible with both ESLint v8 and v9.
666
800
  * In ESLint v9, context.getAncestors() is deprecated and moved to context.sourceCode.getAncestors(node).
@@ -671,5 +805,17 @@ class ASTHelpers {
671
805
  (context.getAncestors ? context.getAncestors() : []));
672
806
  }
673
807
  }
808
+ /**
809
+ * Calls that wrap a component/hook definition without renaming it, so the
810
+ * binding they are assigned to still names the wrapped function
811
+ * (`const Component = memo(() => ...)`).
812
+ */
813
+ ASTHelpers.TRANSPARENT_WRAPPER_CALLEES = new Set([
814
+ 'forwardRef',
815
+ 'memo',
816
+ 'observer',
817
+ 'useCallback',
818
+ 'useMemo',
819
+ ]);
674
820
  exports.ASTHelpers = ASTHelpers;
675
821
  //# sourceMappingURL=ASTHelpers.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.42",
3
+ "version": "1.20.43",
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.20.43",
4
+ "date": "2026-07-31T04:38:54.532Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-callback-memo",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1496
11
+ ],
12
+ "summary": "only report inside a component or hook (closes #1496)"
13
+ }
14
+ ]
15
+ },
2
16
  {
3
17
  "version": "1.20.42",
4
18
  "date": "2026-07-31T04:16:11.179Z",