@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 +1 -1
- package/lib/rules/enforce-callback-memo.js +9 -0
- package/lib/rules/enforce-firestore-doc-ref-generic.js +57 -0
- package/lib/rules/enforce-transform-memoization.js +9 -0
- package/lib/utils/ASTHelpers.d.ts +41 -0
- package/lib/utils/ASTHelpers.js +146 -0
- package/package.json +1 -1
- package/release-manifest.json +36 -0
package/lib/index.js
CHANGED
|
@@ -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).
|
package/lib/utils/ASTHelpers.js
CHANGED
|
@@ -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
package/release-manifest.json
CHANGED
|
@@ -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",
|