@blumintinc/eslint-plugin-blumint 1.20.42 → 1.20.44

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.44',
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;
@@ -10,6 +10,7 @@ exports.enforceFirestoreDocRefGeneric = void 0;
10
10
  */
11
11
  const utils_1 = require("@typescript-eslint/utils");
12
12
  const createRule_1 = require("../utils/createRule");
13
+ const ASTHelpers_1 = require("../utils/ASTHelpers");
13
14
  /**
14
15
  * @type {import('eslint').Rule.RuleModule}
15
16
  */
@@ -305,8 +306,64 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
305
306
  if (findVariableDeclaration(node)) {
306
307
  return true;
307
308
  }
309
+ // Resolve the binding through the scope chain so that a typed collection
310
+ // stored in a variable still supplies the document generic to .doc().
311
+ if (isTypedCollectionBinding(node)) {
312
+ return true;
313
+ }
308
314
  return false;
309
315
  }
316
+ /**
317
+ * Resolves an identifier to its declaration and reports whether that
318
+ * declaration provably yields a typed CollectionReference.
319
+ *
320
+ * Deliberately conservative: only immutable (`const`) bindings with a
321
+ * single definition are followed, and only for one hop. An alias such as
322
+ * `const b = a;` is not resolved because chasing arbitrary dataflow
323
+ * syntactically produces unsound exemptions; `let`/`var` are refused
324
+ * because a later assignment can replace the value with an untyped
325
+ * collection. Anything unresolvable (parameters, imports, destructuring)
326
+ * keeps reporting, since the rule cannot prove the reference is typed.
327
+ */
328
+ function isTypedCollectionBinding(node) {
329
+ const scope = ASTHelpers_1.ASTHelpers.getScope(context, node);
330
+ const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, node.name);
331
+ if (!variable || variable.defs.length !== 1) {
332
+ return false;
333
+ }
334
+ const def = variable.defs[0];
335
+ if (def.type !== 'Variable' ||
336
+ def.node.type !== utils_1.AST_NODE_TYPES.VariableDeclarator ||
337
+ def.parent?.type !== utils_1.AST_NODE_TYPES.VariableDeclaration ||
338
+ def.parent.kind !== 'const') {
339
+ return false;
340
+ }
341
+ const declarator = def.node;
342
+ if (declarator.id.type === utils_1.AST_NODE_TYPES.Identifier &&
343
+ declarator.id.typeAnnotation) {
344
+ return hasCollectionReferenceType(declarator.id.typeAnnotation.typeAnnotation);
345
+ }
346
+ return isTypedCollectionInitializer(declarator.init);
347
+ }
348
+ function isTypedCollectionInitializer(init) {
349
+ if (!init) {
350
+ return false;
351
+ }
352
+ // An explicit assertion states the schema just as an annotation does.
353
+ if (init.type === utils_1.AST_NODE_TYPES.TSAsExpression) {
354
+ return hasCollectionReferenceType(init.typeAnnotation);
355
+ }
356
+ // Mirrors the chained `db.collection<T>('x').doc('y')` detection: the
357
+ // presence of the type argument is what matters here. An `any`/`{}`
358
+ // argument is already reported on the collection call itself, so it is
359
+ // not reported a second time on the derived document reference.
360
+ return (init.type === utils_1.AST_NODE_TYPES.CallExpression &&
361
+ init.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
362
+ init.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
363
+ init.callee.property.name === 'collection' &&
364
+ !!init.typeParameters &&
365
+ init.typeParameters.params.length > 0);
366
+ }
310
367
  function checkCallExpressionForCollectionReference(node) {
311
368
  // Check if this is a method call that returns CollectionReference
312
369
  if (node.callee.type === utils_1.AST_NODE_TYPES.MemberExpression) {
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.enforceTransformMemoization = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
+ const ASTHelpers_1 = require("../utils/ASTHelpers");
6
7
  exports.enforceTransformMemoization = (0, createRule_1.createRule)({
7
8
  name: 'enforce-transform-memoization',
8
9
  meta: {
@@ -395,6 +396,14 @@ exports.enforceTransformMemoization = (0, createRule_1.createRule)({
395
396
  !adaptValueNames.has(node.callee.name)) {
396
397
  return;
397
398
  }
399
+ // Every message this rule emits prescribes useMemo/useCallback, and a
400
+ // hook call is legal only inside a component or another hook. An
401
+ // adaptValue call at module scope, in a plain helper, or in a test body
402
+ // is not on a render path: nothing is "recreated on every render"
403
+ // there, and the prescribed fix would throw "Invalid hook call".
404
+ if (!ASTHelpers_1.ASTHelpers.isInsideComponentOrHook(node, context)) {
405
+ return;
406
+ }
398
407
  const optionsArg = node.arguments[0];
399
408
  if (!optionsArg)
400
409
  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.44",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,40 @@
1
1
  [
2
+ {
3
+ "version": "1.20.44",
4
+ "date": "2026-07-31T05:47:19.150Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-firestore-doc-ref-generic",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1498
11
+ ],
12
+ "summary": "honor a typed collection bound to a const (closes #1498)"
13
+ },
14
+ {
15
+ "name": "enforce-transform-memoization",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1497
19
+ ],
20
+ "summary": "only report inside a component or hook (closes #1497)"
21
+ }
22
+ ]
23
+ },
24
+ {
25
+ "version": "1.20.43",
26
+ "date": "2026-07-31T04:38:54.532Z",
27
+ "rules": [
28
+ {
29
+ "name": "enforce-callback-memo",
30
+ "changeType": "fix",
31
+ "issues": [
32
+ 1496
33
+ ],
34
+ "summary": "only report inside a component or hook (closes #1496)"
35
+ }
36
+ ]
37
+ },
2
38
  {
3
39
  "version": "1.20.42",
4
40
  "date": "2026-07-31T04:16:11.179Z",