@blumintinc/eslint-plugin-blumint 1.20.104 → 1.20.106
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/consistent-callback-naming.d.ts +2 -1
- package/lib/rules/consistent-callback-naming.js +317 -21
- package/lib/rules/enforce-dynamic-firebase-imports.d.ts +2 -2
- package/lib/rules/enforce-dynamic-firebase-imports.js +193 -54
- package/lib/rules/enforce-empty-object-check.js +14 -0
- package/lib/rules/memoize-root-level-hocs.d.ts +2 -1
- package/lib/rules/memoize-root-level-hocs.js +167 -11
- package/lib/rules/no-entire-object-hook-deps.js +78 -9
- package/lib/rules/prefer-nullish-coalescing-boolean-props.d.ts +2 -1
- package/lib/rules/prefer-nullish-coalescing-boolean-props.js +46 -1
- package/package.json +1 -1
- package/release-manifest.json +61 -0
package/lib/index.js
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
|
|
1
|
+
import { TSESLint } from '@typescript-eslint/utils';
|
|
2
|
+
declare const _default: TSESLint.RuleModule<"callbackPropPrefix" | "callbackFunctionPrefix", [], TSESLint.RuleListener>;
|
|
2
3
|
export = _default;
|
|
@@ -39,6 +39,183 @@ const ts = __importStar(require("typescript"));
|
|
|
39
39
|
function hasHandlePrefix(name) {
|
|
40
40
|
return /^handle[A-Z]/.test(name);
|
|
41
41
|
}
|
|
42
|
+
function stripHandlePrefix(name) {
|
|
43
|
+
return name.slice(6).charAt(0).toLowerCase() + name.slice(7);
|
|
44
|
+
}
|
|
45
|
+
// Stripping the prefix can land the rename squarely on a keyword:
|
|
46
|
+
// `handleDelete` -> `delete`, `handleNew` -> `new`, `handleReturn` -> `return`,
|
|
47
|
+
// `handleTrue` -> `true`. None of those is a legal binding name, so a fix that
|
|
48
|
+
// emits one turns a working file into a parse error — `const delete = fn` and
|
|
49
|
+
// `const { delete } = api` are both SyntaxErrors (Bug #1719). The set covers the
|
|
50
|
+
// ES reserved words, the strict-mode/module reserved words (the linted codebase
|
|
51
|
+
// is entirely ES modules, where `await` and `implements` et al. are reserved
|
|
52
|
+
// too) and the three keyword literals. The guard is applied at every emission
|
|
53
|
+
// site, including member names where a keyword happens to be legal
|
|
54
|
+
// (`class C { delete() {} }`): the rule cannot see whether that member is later
|
|
55
|
+
// destructured into a binding, and a fixer that is safe only sometimes is not
|
|
56
|
+
// safe.
|
|
57
|
+
const RESERVED_WORDS = new Set([
|
|
58
|
+
'arguments',
|
|
59
|
+
'await',
|
|
60
|
+
'break',
|
|
61
|
+
'case',
|
|
62
|
+
'catch',
|
|
63
|
+
'class',
|
|
64
|
+
'const',
|
|
65
|
+
'continue',
|
|
66
|
+
'debugger',
|
|
67
|
+
'default',
|
|
68
|
+
'delete',
|
|
69
|
+
'do',
|
|
70
|
+
'else',
|
|
71
|
+
'enum',
|
|
72
|
+
'eval',
|
|
73
|
+
'export',
|
|
74
|
+
'extends',
|
|
75
|
+
'false',
|
|
76
|
+
'finally',
|
|
77
|
+
'for',
|
|
78
|
+
'function',
|
|
79
|
+
'if',
|
|
80
|
+
'implements',
|
|
81
|
+
'import',
|
|
82
|
+
'in',
|
|
83
|
+
'instanceof',
|
|
84
|
+
'interface',
|
|
85
|
+
'let',
|
|
86
|
+
'new',
|
|
87
|
+
'null',
|
|
88
|
+
'package',
|
|
89
|
+
'private',
|
|
90
|
+
'protected',
|
|
91
|
+
'public',
|
|
92
|
+
'return',
|
|
93
|
+
'static',
|
|
94
|
+
'super',
|
|
95
|
+
'switch',
|
|
96
|
+
'this',
|
|
97
|
+
'throw',
|
|
98
|
+
'true',
|
|
99
|
+
'try',
|
|
100
|
+
'typeof',
|
|
101
|
+
'var',
|
|
102
|
+
'void',
|
|
103
|
+
'while',
|
|
104
|
+
'with',
|
|
105
|
+
'yield',
|
|
106
|
+
]);
|
|
107
|
+
function isEmittableName(name) {
|
|
108
|
+
return name.length > 0 && !RESERVED_WORDS.has(name);
|
|
109
|
+
}
|
|
110
|
+
function isExportedDeclaration(node) {
|
|
111
|
+
let current = node;
|
|
112
|
+
while (current) {
|
|
113
|
+
if (current.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration ||
|
|
114
|
+
current.type === utils_1.AST_NODE_TYPES.ExportDefaultDeclaration) {
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
current = current.parent;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
// An object literal that is exported or returned is a value other modules read
|
|
122
|
+
// by member name (`api.handleOpenThread`, `const { handleOpenThread } = useX()`).
|
|
123
|
+
// Renaming a member of it edits one end of a contract whose readers live in
|
|
124
|
+
// files a single-file fixer cannot even see, so the violation is reported
|
|
125
|
+
// without a fix — the same reasoning that withholds the JSX prop rename.
|
|
126
|
+
function isApiSurfaceValue(node) {
|
|
127
|
+
let child = node;
|
|
128
|
+
let current = node.parent;
|
|
129
|
+
while (current) {
|
|
130
|
+
switch (current.type) {
|
|
131
|
+
case utils_1.AST_NODE_TYPES.ExportNamedDeclaration:
|
|
132
|
+
case utils_1.AST_NODE_TYPES.ExportDefaultDeclaration:
|
|
133
|
+
case utils_1.AST_NODE_TYPES.ReturnStatement:
|
|
134
|
+
return true;
|
|
135
|
+
case utils_1.AST_NODE_TYPES.ArrowFunctionExpression:
|
|
136
|
+
// A concise body is a return with no `return` keyword.
|
|
137
|
+
return current.body === child;
|
|
138
|
+
case utils_1.AST_NODE_TYPES.BlockStatement:
|
|
139
|
+
case utils_1.AST_NODE_TYPES.ClassBody:
|
|
140
|
+
case utils_1.AST_NODE_TYPES.FunctionDeclaration:
|
|
141
|
+
case utils_1.AST_NODE_TYPES.FunctionExpression:
|
|
142
|
+
case utils_1.AST_NODE_TYPES.Program:
|
|
143
|
+
return false;
|
|
144
|
+
default:
|
|
145
|
+
child = current;
|
|
146
|
+
current = current.parent;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
/** The member names already declared alongside `node`, keyed by identifier. */
|
|
152
|
+
function siblingMemberNames(node) {
|
|
153
|
+
const names = new Set();
|
|
154
|
+
const record = (member) => {
|
|
155
|
+
const key = member.key;
|
|
156
|
+
if (!member.computed &&
|
|
157
|
+
key?.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
158
|
+
names.add(key.name);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
if (node?.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
|
|
162
|
+
node.properties.forEach(record);
|
|
163
|
+
}
|
|
164
|
+
else if (node?.type === utils_1.AST_NODE_TYPES.ClassBody) {
|
|
165
|
+
node.body.forEach(record);
|
|
166
|
+
}
|
|
167
|
+
return names;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Every member name the file reads by name — `obj.handleClick`,
|
|
171
|
+
* `obj['handleClick']`, `const { handleClick } = obj`.
|
|
172
|
+
*
|
|
173
|
+
* Renaming an object literal's key has to move every one of those reads with
|
|
174
|
+
* it, and a fixer scoped to the literal moves none of them: `const o = { click:
|
|
175
|
+
* fn }; o.handleClick()` type-checks as a missing property and throws at
|
|
176
|
+
* runtime. The presence of any reader therefore withholds the rewrite. The walk
|
|
177
|
+
* is over the whole program because a reader may appear anywhere, including
|
|
178
|
+
* before the literal.
|
|
179
|
+
*/
|
|
180
|
+
function collectMemberReads(program, visitorKeys) {
|
|
181
|
+
const names = new Set();
|
|
182
|
+
const stack = [program];
|
|
183
|
+
const push = (value) => {
|
|
184
|
+
if (Array.isArray(value)) {
|
|
185
|
+
value.forEach(push);
|
|
186
|
+
}
|
|
187
|
+
else if (value && typeof value === 'object' && 'type' in value) {
|
|
188
|
+
stack.push(value);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
while (stack.length > 0) {
|
|
192
|
+
const node = stack.pop();
|
|
193
|
+
if (node.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
194
|
+
if (!node.computed && node.property.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
195
|
+
names.add(node.property.name);
|
|
196
|
+
}
|
|
197
|
+
else if (node.property.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
198
|
+
typeof node.property.value === 'string') {
|
|
199
|
+
names.add(node.property.value);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Read directly off the pattern rather than through `parent`, which is not
|
|
203
|
+
// guaranteed to be assigned on nodes the traversal has not reached.
|
|
204
|
+
if (node.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
|
|
205
|
+
for (const property of node.properties) {
|
|
206
|
+
if (property.type === utils_1.AST_NODE_TYPES.Property &&
|
|
207
|
+
!property.computed &&
|
|
208
|
+
property.key.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
209
|
+
names.add(property.key.name);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
for (const key of visitorKeys[node.type] ?? []) {
|
|
214
|
+
push(node[key]);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return names;
|
|
218
|
+
}
|
|
42
219
|
module.exports = (0, createRule_1.createRule)({
|
|
43
220
|
name: 'consistent-callback-naming',
|
|
44
221
|
meta: {
|
|
@@ -200,6 +377,101 @@ module.exports = (0, createRule_1.createRule)({
|
|
|
200
377
|
// that includes a void-returning signature keeps the handler semantics.
|
|
201
378
|
return signatures.every((signature) => !returnsVoidLike(checker.getReturnTypeOfSignature(signature)));
|
|
202
379
|
}
|
|
380
|
+
// Built once per file, and only when an object literal member is actually a
|
|
381
|
+
// rename candidate.
|
|
382
|
+
let memberReads;
|
|
383
|
+
function isReadByName(name) {
|
|
384
|
+
const sourceCode = context.getSourceCode();
|
|
385
|
+
memberReads ??= collectMemberReads(sourceCode.ast, sourceCode.visitorKeys);
|
|
386
|
+
return memberReads.has(name);
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* The variable a pattern identifier binds. `getDeclaredVariables` is
|
|
390
|
+
* authoritative — it is asked of the declaring ancestor (the
|
|
391
|
+
* `VariableDeclaration`, the function owning a destructured parameter, the
|
|
392
|
+
* `CatchClause`) rather than reconstructed by crawling scopes by name,
|
|
393
|
+
* which cannot tell two same-named bindings apart.
|
|
394
|
+
*/
|
|
395
|
+
function findPatternVariable(id) {
|
|
396
|
+
let current = id.parent;
|
|
397
|
+
while (current) {
|
|
398
|
+
const match = context
|
|
399
|
+
.getDeclaredVariables(current)
|
|
400
|
+
.find((variable) => variable.identifiers.includes(id));
|
|
401
|
+
if (match) {
|
|
402
|
+
return match;
|
|
403
|
+
}
|
|
404
|
+
current = current.parent;
|
|
405
|
+
}
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
// Renaming a binding that leaves the module — `export const { a: handleX }`,
|
|
409
|
+
// or `export { handleX }` — breaks importers the fixer cannot edit.
|
|
410
|
+
function isExportedBinding(variable) {
|
|
411
|
+
const namedByExportSpecifier = (id) => id.parent?.type === utils_1.AST_NODE_TYPES.ExportSpecifier;
|
|
412
|
+
return (variable.references.some((ref) => namedByExportSpecifier(ref.identifier)) ||
|
|
413
|
+
variable.identifiers.some(namedByExportSpecifier) ||
|
|
414
|
+
variable.defs.some((def) => isExportedDeclaration(def.node)));
|
|
415
|
+
}
|
|
416
|
+
// A rename that collides with a name already visible where the binding (or
|
|
417
|
+
// any of its references) lives silently re-points those references at the
|
|
418
|
+
// other declaration.
|
|
419
|
+
function isNameTaken(variable, newName) {
|
|
420
|
+
let scope = variable.scope;
|
|
421
|
+
while (scope) {
|
|
422
|
+
if (scope.set.has(newName)) {
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
scope = scope.upper;
|
|
426
|
+
}
|
|
427
|
+
return variable.scope.childScopes.some((child) => child.set.has(newName));
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* A `Property` inside an `ObjectPattern`. Its key names a property of the
|
|
431
|
+
* object being destructured — someone else's API (`const { handleDelete: fn }
|
|
432
|
+
* = useMessage('handleDelete')` reads Stream Chat's own member) — so
|
|
433
|
+
* rewriting the key changes WHICH property is read, strands every reader of
|
|
434
|
+
* the old name, and can emit a keyword that is not a legal binding
|
|
435
|
+
* (Bug #1719). The key is therefore never reported and never rewritten. The
|
|
436
|
+
* only name the file owns here is the local binding, so that is what the
|
|
437
|
+
* report targets when it too carries the prefix.
|
|
438
|
+
*/
|
|
439
|
+
function reportDestructuredBinding(node) {
|
|
440
|
+
// A shorthand binding is a single token that is simultaneously the
|
|
441
|
+
// foreign property name and the local name: there is no name the file
|
|
442
|
+
// chose independently, and no in-place edit can change one without the
|
|
443
|
+
// other. Left alone entirely rather than reported with no remedy.
|
|
444
|
+
if (node.shorthand || node.value.type !== utils_1.AST_NODE_TYPES.Identifier) {
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const binding = node.value;
|
|
448
|
+
if (!hasHandlePrefix(binding.name)) {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const newName = stripHandlePrefix(binding.name);
|
|
452
|
+
const variable = findPatternVariable(binding);
|
|
453
|
+
const canFix = isEmittableName(newName) &&
|
|
454
|
+
!!variable &&
|
|
455
|
+
!isExportedBinding(variable) &&
|
|
456
|
+
!isNameTaken(variable, newName);
|
|
457
|
+
context.report({
|
|
458
|
+
node: binding,
|
|
459
|
+
messageId: 'callbackFunctionPrefix',
|
|
460
|
+
data: { functionName: binding.name },
|
|
461
|
+
fix(fixer) {
|
|
462
|
+
if (!canFix || !variable) {
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
// Every occurrence in one edit: a rename that reaches the declaration
|
|
466
|
+
// but not its readers is worse than no rename at all.
|
|
467
|
+
const targets = new Set([binding]);
|
|
468
|
+
for (const ref of variable.references) {
|
|
469
|
+
targets.add(ref.identifier);
|
|
470
|
+
}
|
|
471
|
+
return [...targets].map((id) => fixer.replaceText(id, newName));
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
}
|
|
203
475
|
return {
|
|
204
476
|
// Check JSX attributes for callback props
|
|
205
477
|
JSXAttribute(node) {
|
|
@@ -341,8 +613,12 @@ module.exports = (0, createRule_1.createRule)({
|
|
|
341
613
|
data: { functionName },
|
|
342
614
|
fix(fixer) {
|
|
343
615
|
// Remove 'handle' prefix and convert first character to lowercase
|
|
344
|
-
const newName = functionName
|
|
345
|
-
|
|
616
|
+
const newName = stripHandlePrefix(functionName);
|
|
617
|
+
// `const handleDelete = fn` would become `const delete = fn`,
|
|
618
|
+
// which does not parse (Bug #1719).
|
|
619
|
+
if (!isEmittableName(newName)) {
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
346
622
|
// Fix the declaration and all references
|
|
347
623
|
const fixes = [];
|
|
348
624
|
fixes.push(fixer.replaceText(node.id, newName));
|
|
@@ -358,30 +634,50 @@ module.exports = (0, createRule_1.createRule)({
|
|
|
358
634
|
},
|
|
359
635
|
// Check class methods and object methods
|
|
360
636
|
'MethodDefinition, Property'(node) {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
});
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
637
|
+
const key = node.key;
|
|
638
|
+
if (key.type !== utils_1.AST_NODE_TYPES.Identifier ||
|
|
639
|
+
!key.name ||
|
|
640
|
+
!hasHandlePrefix(key.name)) {
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
const name = key.name;
|
|
644
|
+
// Skip autofixing for class parameters and getters
|
|
645
|
+
if (node.type === utils_1.AST_NODE_TYPES.MethodDefinition &&
|
|
646
|
+
node.kind === 'get') {
|
|
374
647
|
context.report({
|
|
375
|
-
node:
|
|
648
|
+
node: key,
|
|
376
649
|
messageId: 'callbackFunctionPrefix',
|
|
377
650
|
data: { functionName: name },
|
|
378
|
-
fix(fixer) {
|
|
379
|
-
// Remove 'handle' prefix and convert first character to lowercase
|
|
380
|
-
const newName = name.slice(6).charAt(0).toLowerCase() + name.slice(7);
|
|
381
|
-
return fixer.replaceText(node.key, newName);
|
|
382
|
-
},
|
|
383
651
|
});
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
const isProperty = node.type === utils_1.AST_NODE_TYPES.Property;
|
|
655
|
+
if (isProperty && node.parent?.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
|
|
656
|
+
reportDestructuredBinding(node);
|
|
657
|
+
return;
|
|
384
658
|
}
|
|
659
|
+
const newName = stripHandlePrefix(name);
|
|
660
|
+
// A shorthand property's key and value are the same token, so replacing
|
|
661
|
+
// the key also replaces the value: `{ handleClick }` becomes
|
|
662
|
+
// `{ click }`, which both renames the member and re-points it at a
|
|
663
|
+
// binding that need not exist (Bug #1719).
|
|
664
|
+
const canFix = isEmittableName(newName) &&
|
|
665
|
+
// A sibling already holding the target name turns the rename into a
|
|
666
|
+
// duplicate member: `{ click: a, handleClick: b }` would collapse to
|
|
667
|
+
// two `click` keys, silently discarding the first.
|
|
668
|
+
!siblingMemberNames(node.parent).has(newName) &&
|
|
669
|
+
!(isProperty && node.shorthand) &&
|
|
670
|
+
!(isProperty &&
|
|
671
|
+
node.parent &&
|
|
672
|
+
(isApiSurfaceValue(node.parent) || isReadByName(name)));
|
|
673
|
+
context.report({
|
|
674
|
+
node: key,
|
|
675
|
+
messageId: 'callbackFunctionPrefix',
|
|
676
|
+
data: { functionName: name },
|
|
677
|
+
fix(fixer) {
|
|
678
|
+
return canFix ? fixer.replaceText(key, newName) : null;
|
|
679
|
+
},
|
|
680
|
+
});
|
|
385
681
|
},
|
|
386
682
|
// Check constructor parameters
|
|
387
683
|
TSParameterProperty(node) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { TSESTree } from '@typescript-eslint/utils';
|
|
2
|
-
declare const enforceFirebaseImports:
|
|
1
|
+
import { TSESLint, TSESTree } from '@typescript-eslint/utils';
|
|
2
|
+
declare const enforceFirebaseImports: TSESLint.RuleModule<"noDynamicImport", never[], {} | {
|
|
3
3
|
ImportDeclaration(node: TSESTree.ImportDeclaration): void;
|
|
4
4
|
}>;
|
|
5
5
|
export default enforceFirebaseImports;
|
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const utils_1 = require("@typescript-eslint/utils");
|
|
3
4
|
const createRule_1 = require("../utils/createRule");
|
|
5
|
+
const isFunctionNode = (node) => node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
6
|
+
node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
|
|
7
|
+
node.type === utils_1.AST_NODE_TYPES.FunctionExpression;
|
|
8
|
+
/**
|
|
9
|
+
* Walks outward from a reference to the innermost `async` function whose block
|
|
10
|
+
* body contains it.
|
|
11
|
+
*
|
|
12
|
+
* A reference sitting in a *synchronous* callback nested inside an async
|
|
13
|
+
* function still resolves once the declaration heads the async body, because
|
|
14
|
+
* the callback cannot run before the first statement of the body it is created
|
|
15
|
+
* in — so the walk continues past non-async functions rather than giving up.
|
|
16
|
+
*
|
|
17
|
+
* The containment check is against the body rather than the function: a
|
|
18
|
+
* reference in a parameter default or a signature type annotation is evaluated
|
|
19
|
+
* before the body runs, so a declaration at the top of the body would come too
|
|
20
|
+
* late for it.
|
|
21
|
+
*/
|
|
22
|
+
const enclosingAsyncBodyOf = (identifier) => {
|
|
23
|
+
let current = identifier.parent;
|
|
24
|
+
while (current) {
|
|
25
|
+
if (isFunctionNode(current) &&
|
|
26
|
+
current.async &&
|
|
27
|
+
current.body.type === utils_1.AST_NODE_TYPES.BlockStatement &&
|
|
28
|
+
identifier.range[0] >= current.body.range[0] &&
|
|
29
|
+
identifier.range[1] <= current.body.range[1]) {
|
|
30
|
+
return current;
|
|
31
|
+
}
|
|
32
|
+
current = current.parent;
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
};
|
|
36
|
+
const THIRD_PARTY_DIRECTORY = /(^|\/)node_modules(\/|$)/;
|
|
37
|
+
// Anchored at the end of the path so multi-part suffixes such as
|
|
38
|
+
// `useStartMatch.integration.test.ts` are recognized while production modules
|
|
39
|
+
// that merely contain the word (`latest.tsx`, `contest.ts`, `testHelpers.ts`)
|
|
40
|
+
// keep their enforcement.
|
|
41
|
+
const TEST_FILE_SUFFIX = /\.(test|spec)\.[cm]?[jt]sx?$/;
|
|
42
|
+
// Jest convention directories hold test-only modules regardless of file name.
|
|
43
|
+
const TEST_FILE_DIRECTORY = /(^|\/)(__tests__|__mocks__)\//;
|
|
44
|
+
/**
|
|
45
|
+
* The rule's rationale is bundle weight: a static import pulls Firebase into the
|
|
46
|
+
* initial client chunk. A suite, a Jest manual mock and a declaration file are
|
|
47
|
+
* never part of that chunk, so there is nothing to inflate and the rule has
|
|
48
|
+
* nothing to enforce there.
|
|
49
|
+
*
|
|
50
|
+
* The exemption is load-bearing rather than cosmetic because the rule is
|
|
51
|
+
* fixable: a suite's static binding is exactly what `jest.mock()` hoisting
|
|
52
|
+
* intercepts, and rewriting it emits a module-scope `await import(...)` that a
|
|
53
|
+
* CommonJS test transform cannot even parse (issue #1715).
|
|
54
|
+
*/
|
|
55
|
+
const isNeverBundled = (filename) => filename.endsWith('.d.ts') ||
|
|
56
|
+
TEST_FILE_SUFFIX.test(filename) ||
|
|
57
|
+
TEST_FILE_DIRECTORY.test(filename);
|
|
4
58
|
const enforceFirebaseImports = (0, createRule_1.createRule)({
|
|
5
59
|
name: 'enforce-dynamic-firebase-imports',
|
|
6
60
|
meta: {
|
|
@@ -13,18 +67,26 @@ const enforceFirebaseImports = (0, createRule_1.createRule)({
|
|
|
13
67
|
hasSuggestions: true,
|
|
14
68
|
schema: [],
|
|
15
69
|
messages: {
|
|
16
|
-
noDynamicImport: 'Static import from firebaseCloud path "{{importPath}}" eagerly bundles Firebase code into the initial client chunk, which inflates startup time and prevents lazy loading.
|
|
70
|
+
noDynamicImport: 'Static import from firebaseCloud path "{{importPath}}" eagerly bundles Firebase code into the initial client chunk, which inflates startup time and prevents lazy loading. Load it at the call site instead, inside an async function body (e.g., `const { export } = await import(\'{{importPath}}\')`). Keep it out of module scope: a top-level `await import(...)` defers nothing and does not parse once the module is compiled to CommonJS.',
|
|
17
71
|
},
|
|
18
72
|
},
|
|
19
73
|
defaultOptions: [],
|
|
20
74
|
create(context) {
|
|
75
|
+
const sourceCode = context.getSourceCode();
|
|
76
|
+
// Normalize Windows backslash separators so the forward-slash directory
|
|
77
|
+
// checks match on every platform. Without this, `getFilename()` returns
|
|
78
|
+
// `C:\repo\src\hooks\__tests__\Foo.ts` on Windows and the exemption
|
|
79
|
+
// silently fails there.
|
|
80
|
+
const filename = (context.getFilename?.() ?? '').replace(/\\/g, '/');
|
|
81
|
+
// `<input>`/`<text>` are the synthetic names RuleTester uses when a case
|
|
82
|
+
// declares no filename. They match none of the exemptions below, so a
|
|
83
|
+
// snippet keeps its enforcement — unlike a path-gated rule, this one has no
|
|
84
|
+
// include list to fall outside of.
|
|
85
|
+
if (THIRD_PARTY_DIRECTORY.test(filename) || isNeverBundled(filename)) {
|
|
86
|
+
return {};
|
|
87
|
+
}
|
|
21
88
|
return {
|
|
22
89
|
ImportDeclaration(node) {
|
|
23
|
-
// Skip third-party files
|
|
24
|
-
const filename = context.getFilename?.();
|
|
25
|
-
if (filename && /(^|[\\/])node_modules([\\/]|$)/.test(filename)) {
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
90
|
// Skip type-only import declarations
|
|
29
91
|
if (node.importKind === 'type') {
|
|
30
92
|
return;
|
|
@@ -51,75 +113,152 @@ const enforceFirebaseImports = (0, createRule_1.createRule)({
|
|
|
51
113
|
? spec.imported.name
|
|
52
114
|
: `${spec.imported.name} as ${spec.local.name}`)
|
|
53
115
|
.join(', ');
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
116
|
+
const destructureEntry = (spec) => spec.imported.name === spec.local.name
|
|
117
|
+
? spec.local.name
|
|
118
|
+
: `${spec.imported.name}: ${spec.local.name}`;
|
|
119
|
+
const buildValueStatements = () => {
|
|
59
120
|
if (namespaceSpecifier) {
|
|
60
121
|
const nsLocal = namespaceSpecifier.local.name;
|
|
61
|
-
|
|
122
|
+
const statements = [
|
|
123
|
+
`const ${nsLocal} = await import('${importPath}');`,
|
|
124
|
+
];
|
|
62
125
|
if (defaultSpecifier) {
|
|
63
|
-
const
|
|
64
|
-
statements.push(`const ${defLocal} = ${nsLocal}.default;`);
|
|
126
|
+
statements.push(`const ${defaultSpecifier.local.name} = ${nsLocal}.default;`);
|
|
65
127
|
}
|
|
66
|
-
const destructureFromNamespace = [];
|
|
67
128
|
if (namedSpecifiers.length > 0) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return imported === local ? imported : `${imported}: ${local}`;
|
|
72
|
-
});
|
|
73
|
-
destructureFromNamespace.push(...destructureParts);
|
|
129
|
+
statements.push(`const { ${namedSpecifiers
|
|
130
|
+
.map(destructureEntry)
|
|
131
|
+
.join(', ')} } = ${nsLocal};`);
|
|
74
132
|
}
|
|
75
|
-
|
|
76
|
-
statements.push(`const { ${destructureFromNamespace.join(', ')} } = ${nsLocal};`);
|
|
77
|
-
}
|
|
78
|
-
return statements.join(' ');
|
|
133
|
+
return statements;
|
|
79
134
|
}
|
|
80
|
-
const destructureParts = [
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
135
|
+
const destructureParts = [
|
|
136
|
+
...(defaultSpecifier
|
|
137
|
+
? [`default: ${defaultSpecifier.local.name}`]
|
|
138
|
+
: []),
|
|
139
|
+
...namedSpecifiers.map(destructureEntry),
|
|
140
|
+
];
|
|
141
|
+
// A side-effect import binds nothing, so there is no declaration to
|
|
142
|
+
// relocate — the awaited call would have to stay at module scope.
|
|
143
|
+
return destructureParts.length > 0
|
|
144
|
+
? [
|
|
145
|
+
`const { ${destructureParts.join(', ')} } = await import('${importPath}');`,
|
|
146
|
+
]
|
|
147
|
+
: [];
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* An `ImportDeclaration` only ever sits at module scope, so rewriting
|
|
151
|
+
* it in place can only ever produce a module-scope `await import(...)`
|
|
152
|
+
* — which defers nothing (the module still awaits it during
|
|
153
|
+
* evaluation) and does not even parse once the file is compiled to
|
|
154
|
+
* CommonJS, where top-level await does not exist (issue #1716).
|
|
155
|
+
*
|
|
156
|
+
* The rewrite is therefore only expressible when every value reference
|
|
157
|
+
* lives in one async function body: the declaration can then head that
|
|
158
|
+
* body, exactly the shape the codebase writes by hand. Anything else
|
|
159
|
+
* is a per-call-site refactor the fixer declines rather than corrupts.
|
|
160
|
+
*/
|
|
161
|
+
const findRelocationTarget = () => {
|
|
162
|
+
const valueLocalNames = new Set([
|
|
163
|
+
defaultSpecifier?.local.name,
|
|
164
|
+
namespaceSpecifier?.local.name,
|
|
165
|
+
...namedSpecifiers.map((spec) => spec.local.name),
|
|
166
|
+
].filter((name) => name !== undefined));
|
|
167
|
+
const references = context
|
|
168
|
+
.getDeclaredVariables(node)
|
|
169
|
+
.filter((variable) => valueLocalNames.has(variable.name))
|
|
170
|
+
.flatMap((variable) => variable.references);
|
|
171
|
+
// Nothing reads the binding, so there is no call site to defer to.
|
|
172
|
+
if (references.length === 0) {
|
|
173
|
+
return undefined;
|
|
84
174
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
175
|
+
let target;
|
|
176
|
+
for (const reference of references) {
|
|
177
|
+
const enclosing = enclosingAsyncBodyOf(reference.identifier);
|
|
178
|
+
if (!enclosing || (target && target !== enclosing)) {
|
|
179
|
+
return undefined;
|
|
90
180
|
}
|
|
181
|
+
target = enclosing;
|
|
91
182
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
183
|
+
return target;
|
|
184
|
+
};
|
|
185
|
+
const indentationAt = (line) => /^[ \t]*/.exec(sourceCode.lines[line - 1] ?? '')?.[0] ?? '';
|
|
186
|
+
/**
|
|
187
|
+
* Consumes the import's own trailing whitespace, and its line break
|
|
188
|
+
* when the import owns the line, so the removal strands neither a blank
|
|
189
|
+
* line nor the indentation of whatever shared the line with it.
|
|
190
|
+
* Anything that is not whitespace — a trailing comment, a statement —
|
|
191
|
+
* is left untouched.
|
|
192
|
+
*/
|
|
193
|
+
const removalEnd = () => {
|
|
194
|
+
const text = sourceCode.getText();
|
|
195
|
+
let cursor = node.range[1];
|
|
196
|
+
while (cursor < text.length &&
|
|
197
|
+
(text[cursor] === ' ' || text[cursor] === '\t')) {
|
|
198
|
+
cursor += 1;
|
|
199
|
+
}
|
|
200
|
+
if (text[cursor] === '\n') {
|
|
201
|
+
return cursor + 1;
|
|
95
202
|
}
|
|
96
|
-
if (
|
|
97
|
-
return
|
|
98
|
-
|
|
99
|
-
|
|
203
|
+
if (text[cursor] === '\r' && text[cursor + 1] === '\n') {
|
|
204
|
+
return cursor + 2;
|
|
205
|
+
}
|
|
206
|
+
return cursor;
|
|
207
|
+
};
|
|
208
|
+
const buildFix = (fixer) => {
|
|
209
|
+
const target = findRelocationTarget();
|
|
210
|
+
const statements = buildValueStatements();
|
|
211
|
+
if (!target || statements.length === 0) {
|
|
212
|
+
return null;
|
|
100
213
|
}
|
|
101
|
-
|
|
214
|
+
const body = target.body;
|
|
215
|
+
// A directive stops being a directive the moment a declaration
|
|
216
|
+
// precedes it, so `'use server'` on a server action would silently
|
|
217
|
+
// become a discarded string expression. The declaration goes after
|
|
218
|
+
// the whole prologue instead.
|
|
219
|
+
const prologueLength = body.body.findIndex((statement) => statement.type !== utils_1.AST_NODE_TYPES.ExpressionStatement ||
|
|
220
|
+
statement.expression.type !== utils_1.AST_NODE_TYPES.Literal ||
|
|
221
|
+
typeof statement.expression.value !== 'string');
|
|
222
|
+
const directives = body.body.slice(0, prologueLength === -1 ? body.body.length : prologueLength);
|
|
223
|
+
const lastDirective = directives[directives.length - 1];
|
|
224
|
+
const following = body.body[directives.length];
|
|
225
|
+
const anchorLine = lastDirective
|
|
226
|
+
? lastDirective.loc.end.line
|
|
227
|
+
: body.loc.start.line;
|
|
228
|
+
const neighbour = following ?? lastDirective;
|
|
229
|
+
// A body written on one line keeps its shape; a multi-line body gets
|
|
230
|
+
// the declaration on its own line at the body's own indentation.
|
|
231
|
+
const insertion = following && following.loc.start.line === anchorLine
|
|
232
|
+
? ` ${statements.join(' ')}`
|
|
233
|
+
: statements
|
|
234
|
+
.map((statement) => {
|
|
235
|
+
const indent = neighbour
|
|
236
|
+
? indentationAt(neighbour.loc.start.line)
|
|
237
|
+
: `${indentationAt(target.loc.start.line)} `;
|
|
238
|
+
return `\n${indent}${statement}`;
|
|
239
|
+
})
|
|
240
|
+
.join('');
|
|
241
|
+
return [
|
|
242
|
+
// Type-only specifiers are erased at compile time, so they stay
|
|
243
|
+
// where they are instead of riding along into the function body.
|
|
244
|
+
typeOnlySpecifiers.length > 0
|
|
245
|
+
? fixer.replaceText(node, `import type { ${buildTypeNames()} } from '${importPath}';`)
|
|
246
|
+
: fixer.removeRange([node.range[0], removalEnd()]),
|
|
247
|
+
lastDirective
|
|
248
|
+
? fixer.insertTextAfter(lastDirective, insertion)
|
|
249
|
+
: fixer.insertTextAfterRange([body.range[0], body.range[0] + 1], insertion),
|
|
250
|
+
];
|
|
102
251
|
};
|
|
103
252
|
context.report({
|
|
104
253
|
node,
|
|
105
254
|
messageId: 'noDynamicImport',
|
|
106
255
|
data: { importPath },
|
|
107
|
-
fix
|
|
108
|
-
const replacement = buildReplacement();
|
|
109
|
-
return replacement ? fixer.replaceText(node, replacement) : null;
|
|
110
|
-
},
|
|
256
|
+
fix: buildFix,
|
|
111
257
|
suggest: [
|
|
112
258
|
{
|
|
113
259
|
messageId: 'noDynamicImport',
|
|
114
260
|
data: { importPath },
|
|
115
|
-
fix
|
|
116
|
-
const replacement = buildReplacement({
|
|
117
|
-
allowSideEffectFix: true,
|
|
118
|
-
});
|
|
119
|
-
return replacement
|
|
120
|
-
? fixer.replaceText(node, replacement)
|
|
121
|
-
: null;
|
|
122
|
-
},
|
|
261
|
+
fix: buildFix,
|
|
123
262
|
},
|
|
124
263
|
],
|
|
125
264
|
});
|
|
@@ -215,6 +215,20 @@ function isObjectLikeType(type, checker) {
|
|
|
215
215
|
if (type.getCallSignatures().length > 0) {
|
|
216
216
|
return 'non-object';
|
|
217
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* A construct-signature-only type — a class reference, a `…Constructor<P>`
|
|
220
|
+
* interface, the `ComponentClass` half of `ComponentType` — carries behaviour,
|
|
221
|
+
* not data. Its own properties are statics, so `Object.keys()` is `[]` for a
|
|
222
|
+
* plain class or component even when a valid value was supplied, and the
|
|
223
|
+
* emptiness check this rule prescribes would invert the guard rather than
|
|
224
|
+
* harden it. Unions reach this branch through the recursive call above, which
|
|
225
|
+
* matters because a union counts as an object when ANY member does: without
|
|
226
|
+
* this, the constructor half alone classified a whole `ComponentType` union as
|
|
227
|
+
* a data object.
|
|
228
|
+
*/
|
|
229
|
+
if (type.getConstructSignatures().length > 0) {
|
|
230
|
+
return 'non-object';
|
|
231
|
+
}
|
|
218
232
|
if (hasRequiredProperties(type, checker)) {
|
|
219
233
|
return 'non-object';
|
|
220
234
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { TSESLint } from '@typescript-eslint/utils';
|
|
1
2
|
type Options = [
|
|
2
3
|
{
|
|
3
4
|
additionalHocNames?: string[];
|
|
4
5
|
}
|
|
5
6
|
];
|
|
6
|
-
export declare const memoizeRootLevelHocs:
|
|
7
|
+
export declare const memoizeRootLevelHocs: TSESLint.RuleModule<"wrapHocInUseMemo", Options, TSESLint.RuleListener>;
|
|
7
8
|
export {};
|
|
@@ -121,26 +121,150 @@ const getCallableIdentifierName = (callee) => {
|
|
|
121
121
|
}
|
|
122
122
|
return null;
|
|
123
123
|
};
|
|
124
|
-
const
|
|
125
|
-
if (additionalHocs.has(name)) {
|
|
126
|
-
return true;
|
|
127
|
-
}
|
|
124
|
+
const hasHocNameShape = (name) => {
|
|
128
125
|
if (!name.startsWith('with')) {
|
|
129
126
|
return false;
|
|
130
127
|
}
|
|
131
128
|
const suffix = name.charAt(4);
|
|
132
129
|
return Boolean(suffix) && /^[A-Z]$/.test(suffix);
|
|
133
130
|
};
|
|
134
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Strips wrappers that carry no runtime meaning so a component argument stays
|
|
133
|
+
* recognizable behind `as`, `!`, `satisfies` and optional-chaining nodes.
|
|
134
|
+
*/
|
|
135
|
+
const unwrapExpression = (node) => {
|
|
136
|
+
let current = node;
|
|
137
|
+
for (;;) {
|
|
138
|
+
if (current.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
|
|
139
|
+
current.type === utils_1.AST_NODE_TYPES.TSSatisfiesExpression ||
|
|
140
|
+
current.type === utils_1.AST_NODE_TYPES.TSNonNullExpression ||
|
|
141
|
+
current.type === utils_1.AST_NODE_TYPES.TSTypeAssertion ||
|
|
142
|
+
current.type === utils_1.AST_NODE_TYPES.TSInstantiationExpression ||
|
|
143
|
+
current.type === utils_1.AST_NODE_TYPES.ChainExpression) {
|
|
144
|
+
current = current.expression;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
return current;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
const findHocNameMatch = (node, additionalHocs) => {
|
|
135
151
|
const identifier = getCallableIdentifierName(node.callee);
|
|
136
|
-
if (identifier
|
|
137
|
-
|
|
152
|
+
if (identifier) {
|
|
153
|
+
if (additionalHocs.has(identifier)) {
|
|
154
|
+
return { name: identifier, configured: true };
|
|
155
|
+
}
|
|
156
|
+
if (hasHocNameShape(identifier)) {
|
|
157
|
+
return { name: identifier, configured: false };
|
|
158
|
+
}
|
|
138
159
|
}
|
|
139
|
-
|
|
140
|
-
|
|
160
|
+
const callee = unwrapExpression(node.callee);
|
|
161
|
+
if (callee.type === utils_1.AST_NODE_TYPES.CallExpression) {
|
|
162
|
+
return findHocNameMatch(callee, additionalHocs);
|
|
141
163
|
}
|
|
142
164
|
return null;
|
|
143
165
|
};
|
|
166
|
+
/**
|
|
167
|
+
* Collects the arguments of every call in a curried chain, so the component
|
|
168
|
+
* passed to `withStyles(styles)(Component)` still counts as evidence for the
|
|
169
|
+
* outer call even though it names the HOC through its callee.
|
|
170
|
+
*/
|
|
171
|
+
const collectCallChainArguments = (node) => {
|
|
172
|
+
const args = [];
|
|
173
|
+
let current = node;
|
|
174
|
+
while (current.type === utils_1.AST_NODE_TYPES.CallExpression) {
|
|
175
|
+
args.push(...current.arguments);
|
|
176
|
+
current = unwrapExpression(current.callee);
|
|
177
|
+
}
|
|
178
|
+
return args;
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
181
|
+
* A `with[A-Z]…` name alone says nothing: string utilities such as
|
|
182
|
+
* `withOpacity(color, 0.3)` share the shape. Reporting requires positive
|
|
183
|
+
* structural evidence that the call operates on a component.
|
|
184
|
+
*/
|
|
185
|
+
const isComponentEvidence = (node, ctx) => {
|
|
186
|
+
const target = unwrapExpression(node);
|
|
187
|
+
if (ctx.visiting.has(target)) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
ctx.visiting.add(target);
|
|
191
|
+
try {
|
|
192
|
+
switch (target.type) {
|
|
193
|
+
case utils_1.AST_NODE_TYPES.Identifier:
|
|
194
|
+
return (isComponentName(target.name) || resolvesToComponentValue(target, ctx));
|
|
195
|
+
case utils_1.AST_NODE_TYPES.MemberExpression:
|
|
196
|
+
return (!target.computed &&
|
|
197
|
+
target.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
198
|
+
isComponentName(target.property.name));
|
|
199
|
+
case utils_1.AST_NODE_TYPES.ArrowFunctionExpression:
|
|
200
|
+
case utils_1.AST_NODE_TYPES.FunctionExpression:
|
|
201
|
+
case utils_1.AST_NODE_TYPES.FunctionDeclaration:
|
|
202
|
+
return containsJsx(getBodyNodeForJsxCheck(target));
|
|
203
|
+
case utils_1.AST_NODE_TYPES.ClassExpression:
|
|
204
|
+
case utils_1.AST_NODE_TYPES.ClassDeclaration:
|
|
205
|
+
return true;
|
|
206
|
+
case utils_1.AST_NODE_TYPES.CallExpression:
|
|
207
|
+
return getHocName(target, ctx) !== null;
|
|
208
|
+
case utils_1.AST_NODE_TYPES.ConditionalExpression:
|
|
209
|
+
return (isComponentEvidence(target.consequent, ctx) ||
|
|
210
|
+
isComponentEvidence(target.alternate, ctx));
|
|
211
|
+
case utils_1.AST_NODE_TYPES.LogicalExpression:
|
|
212
|
+
return (isComponentEvidence(target.left, ctx) ||
|
|
213
|
+
isComponentEvidence(target.right, ctx));
|
|
214
|
+
case utils_1.AST_NODE_TYPES.SpreadElement:
|
|
215
|
+
return isComponentEvidence(target.argument, ctx);
|
|
216
|
+
default:
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
ctx.visiting.delete(target);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
/**
|
|
225
|
+
* Resolves a lowercase argument through scope analysis rather than assuming it
|
|
226
|
+
* might be a component. Component bindings are conventionally capitalized, so a
|
|
227
|
+
* lowercase name only counts when its declaration proves it holds a component —
|
|
228
|
+
* which keeps `withPortal(build)` reported while leaving `withOpacity(color,
|
|
229
|
+
* 0.3)` alone. A binding the scope cannot resolve (an import, a parameter, a
|
|
230
|
+
* global) proves nothing and is therefore not evidence.
|
|
231
|
+
*/
|
|
232
|
+
const resolvesToComponentValue = (identifier, ctx) => {
|
|
233
|
+
const variable = ctx.resolveVariable(identifier);
|
|
234
|
+
if (!variable) {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
return variable.defs.some((def) => {
|
|
238
|
+
const defNode = def.node;
|
|
239
|
+
if (defNode.type === utils_1.AST_NODE_TYPES.VariableDeclarator) {
|
|
240
|
+
return (defNode.id === def.name &&
|
|
241
|
+
Boolean(defNode.init) &&
|
|
242
|
+
isComponentEvidence(defNode.init, ctx));
|
|
243
|
+
}
|
|
244
|
+
// A parameter definition shares its node with the enclosing function, so the
|
|
245
|
+
// function's own JSX must not be credited to the parameter.
|
|
246
|
+
if (defNode.type === utils_1.AST_NODE_TYPES.FunctionDeclaration) {
|
|
247
|
+
return (defNode.id === def.name && containsJsx(getBodyNodeForJsxCheck(defNode)));
|
|
248
|
+
}
|
|
249
|
+
if (defNode.type === utils_1.AST_NODE_TYPES.ClassDeclaration) {
|
|
250
|
+
return defNode.id === def.name;
|
|
251
|
+
}
|
|
252
|
+
return false;
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
const getHocName = (node, ctx) => {
|
|
256
|
+
const match = findHocNameMatch(node, ctx.additionalHocs);
|
|
257
|
+
if (!match) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
// An explicitly configured name is a deliberate opt-in, so it is trusted
|
|
261
|
+
// without any structural confirmation.
|
|
262
|
+
if (match.configured) {
|
|
263
|
+
return match.name;
|
|
264
|
+
}
|
|
265
|
+
const hasEvidence = collectCallChainArguments(node).some((argument) => isComponentEvidence(argument, ctx));
|
|
266
|
+
return hasEvidence ? match.name : null;
|
|
267
|
+
};
|
|
144
268
|
/**
|
|
145
269
|
* Detects chained HOC calls where an inner call is immediately invoked by
|
|
146
270
|
* another call (for example, withHoc(Component)()). We only treat calls as
|
|
@@ -183,6 +307,38 @@ exports.memoizeRootLevelHocs = (0, createRule_1.createRule)({
|
|
|
183
307
|
defaultOptions,
|
|
184
308
|
create(context, [options]) {
|
|
185
309
|
const additionalHocs = new Set(options?.additionalHocNames ?? []);
|
|
310
|
+
const sourceCode = context.getSourceCode();
|
|
311
|
+
/**
|
|
312
|
+
* Walks outward to the nearest scope owning the identifier, then up the
|
|
313
|
+
* scope chain. Acquiring scopes from the node keeps resolution independent
|
|
314
|
+
* of where the traversal currently sits.
|
|
315
|
+
*/
|
|
316
|
+
const resolveVariable = (identifier) => {
|
|
317
|
+
const { scopeManager } = sourceCode;
|
|
318
|
+
if (!scopeManager) {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
let scope = null;
|
|
322
|
+
let current = identifier;
|
|
323
|
+
while (current && !scope) {
|
|
324
|
+
scope = scopeManager.acquire(current, true);
|
|
325
|
+
current = current.parent;
|
|
326
|
+
}
|
|
327
|
+
scope = scope ?? scopeManager.globalScope;
|
|
328
|
+
while (scope) {
|
|
329
|
+
const variable = scope.variables.find((candidate) => candidate.name === identifier.name);
|
|
330
|
+
if (variable) {
|
|
331
|
+
return variable;
|
|
332
|
+
}
|
|
333
|
+
scope = scope.upper;
|
|
334
|
+
}
|
|
335
|
+
return null;
|
|
336
|
+
};
|
|
337
|
+
const hocContext = {
|
|
338
|
+
additionalHocs,
|
|
339
|
+
resolveVariable,
|
|
340
|
+
visiting: new Set(),
|
|
341
|
+
};
|
|
186
342
|
const reportUnmemoizedHoc = (node, hocName, contextInfo) => {
|
|
187
343
|
context.report({
|
|
188
344
|
node,
|
|
@@ -194,9 +350,9 @@ exports.memoizeRootLevelHocs = (0, createRule_1.createRule)({
|
|
|
194
350
|
});
|
|
195
351
|
};
|
|
196
352
|
const checkHocCall = (callExpr, contextInfo) => {
|
|
197
|
-
const hocName =
|
|
353
|
+
const hocName = getHocName(callExpr, hocContext);
|
|
198
354
|
const parentCall = getParentCallExpression(callExpr);
|
|
199
|
-
const parentHocName = parentCall &&
|
|
355
|
+
const parentHocName = parentCall && getHocName(parentCall, hocContext);
|
|
200
356
|
if (hocName && !isPartOfHocChain(hocName, parentHocName)) {
|
|
201
357
|
reportUnmemoizedHoc(callExpr, hocName, contextInfo);
|
|
202
358
|
}
|
|
@@ -122,6 +122,53 @@ function isArrayOrPrimitive(checker, esTreeNode, nodeMap) {
|
|
|
122
122
|
return false;
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Whether a member access reads a method — a function declared as a member of
|
|
127
|
+
* a class or interface, which lives on the prototype.
|
|
128
|
+
*
|
|
129
|
+
* why: such a reference is one shared value across every instance of the type
|
|
130
|
+
* (`new Set().has === new Set().has`, `f1.call === f2.call`). Narrowing a
|
|
131
|
+
* dependency from `set` to `set.has` therefore pins a constant: the hook never
|
|
132
|
+
* invalidates again and serves a stale value forever, so the whole object has
|
|
133
|
+
* to stay the dependency. This is the checker-driven generalisation of the
|
|
134
|
+
* `ARRAY_METHODS`/`STRING_METHODS` name lists, which recognise the identical
|
|
135
|
+
* hazard for two built-ins only; `Map`, `Set`, `Promise`, `Date`, `Intl.*` and
|
|
136
|
+
* every user-defined class come for free.
|
|
137
|
+
*
|
|
138
|
+
* The discriminator is how the member is *declared*, not merely "the type is
|
|
139
|
+
* callable". A function-valued data property (`{ getName?: () => string }`, or
|
|
140
|
+
* a class field holding an arrow function) is per-instance state: it genuinely
|
|
141
|
+
* changes when the object carrying it is rebuilt, so narrowing to it is correct
|
|
142
|
+
* and stays allowed.
|
|
143
|
+
*
|
|
144
|
+
* The question is asked of the symbol's flags rather than its declarations'
|
|
145
|
+
* `SyntaxKind`, because a rule must survive a version skew between the
|
|
146
|
+
* TypeScript this package resolves and the one the consumer's parser built the
|
|
147
|
+
* program with. `SyntaxKind` is renumbered whenever a kind is inserted —
|
|
148
|
+
* `MethodSignature` is 170 under 5.0 and 174 under 5.9 — so a `ts.isMethodX`
|
|
149
|
+
* guard imported here silently answers `false` for every node of a consumer on
|
|
150
|
+
* a different minor, making the carve-out a no-op in exactly the place it
|
|
151
|
+
* matters. `SymbolFlags` is an append-only bit set (`Method` has been 8192
|
|
152
|
+
* throughout), so the flag test holds across versions.
|
|
153
|
+
*/
|
|
154
|
+
function isMethodMember(checker, esTreeNode, nodeMap) {
|
|
155
|
+
try {
|
|
156
|
+
const tsNode = nodeMap.get(esTreeNode);
|
|
157
|
+
if (!tsNode)
|
|
158
|
+
return false;
|
|
159
|
+
// why: an unresolved member (an `any` receiver, a missing type) yields no
|
|
160
|
+
// symbol, so the check stays inert rather than guessing — matching the
|
|
161
|
+
// conservative stance `isArrayOrPrimitive` takes on Any/Unknown.
|
|
162
|
+
const symbol = checker.getSymbolAtLocation(tsNode);
|
|
163
|
+
if (!symbol)
|
|
164
|
+
return false;
|
|
165
|
+
return (symbol.flags & typescript_1.SymbolFlags.Method) !== 0;
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
// A type-checker failure must not change what the rule reports.
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
125
172
|
function renderPathSegments(baseName, segments) {
|
|
126
173
|
let path = baseName;
|
|
127
174
|
for (const segment of segments) {
|
|
@@ -196,7 +243,7 @@ function callsCorrespondingSetter(hookBody, dependencyName) {
|
|
|
196
243
|
}
|
|
197
244
|
return visit(hookBody);
|
|
198
245
|
}
|
|
199
|
-
function getObjectUsagesInHook(hookBody, objectName) {
|
|
246
|
+
function getObjectUsagesInHook(hookBody, objectName, typeInfo) {
|
|
200
247
|
const usages = new Map(); // Track usage and its position
|
|
201
248
|
// why: derived dependency paths (first-optional intermediate, array base)
|
|
202
249
|
// must be re-rendered from structured links — string surgery on the
|
|
@@ -310,10 +357,17 @@ function getObjectUsagesInHook(hookBody, objectName) {
|
|
|
310
357
|
if (memberExpr.property.type !== utils_1.AST_NODE_TYPES.Identifier) {
|
|
311
358
|
return null;
|
|
312
359
|
}
|
|
313
|
-
// Check for
|
|
314
|
-
|
|
360
|
+
// Check for a member that cannot serve as a narrowed dependency: a
|
|
361
|
+
// built-in array/string method by name, or — when type information is
|
|
362
|
+
// available — any method of a class or interface. Both denote usage of
|
|
363
|
+
// the entire receiver, because the member itself is a prototype-shared
|
|
364
|
+
// reference rather than per-instance state.
|
|
365
|
+
const isBuiltInWholeObjectMethod = !!memberExpr.property.name &&
|
|
315
366
|
(ARRAY_METHODS.has(memberExpr.property.name) ||
|
|
316
|
-
STRING_METHODS.has(memberExpr.property.name))
|
|
367
|
+
STRING_METHODS.has(memberExpr.property.name));
|
|
368
|
+
if (isBuiltInWholeObjectMethod ||
|
|
369
|
+
(typeInfo !== undefined &&
|
|
370
|
+
isMethodMember(typeInfo.checker, memberExpr, typeInfo.nodeMap))) {
|
|
317
371
|
const methodTarget = unwrapExpression(memberExpr.object);
|
|
318
372
|
if (methodTarget.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
319
373
|
// Method call on a property (e.g., userData.items.map(...) or
|
|
@@ -661,6 +715,22 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
|
|
|
661
715
|
// In a real environment, we would want to enforce this
|
|
662
716
|
// throw new Error('You have to enable the `project` setting in parser options to use this rule');
|
|
663
717
|
}
|
|
718
|
+
// why: building the checker is the expensive half of a typed lint, so the
|
|
719
|
+
// handles are resolved once per file and shared by every type-driven check
|
|
720
|
+
// rather than re-fetched per dependency.
|
|
721
|
+
let typeInfo;
|
|
722
|
+
function getTypeInfo() {
|
|
723
|
+
if (!hasFullTypeChecking || !parserServices) {
|
|
724
|
+
return undefined;
|
|
725
|
+
}
|
|
726
|
+
if (!typeInfo) {
|
|
727
|
+
typeInfo = {
|
|
728
|
+
checker: parserServices.program.getTypeChecker(),
|
|
729
|
+
nodeMap: parserServices.esTreeNodeToTSNodeMap,
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
return typeInfo;
|
|
733
|
+
}
|
|
664
734
|
const sourceCode = context.getSourceCode();
|
|
665
735
|
// why: scanning every comment once per file rather than once per hook call
|
|
666
736
|
// keeps the check off the hot path of files with many hooks.
|
|
@@ -736,16 +806,15 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
|
|
|
736
806
|
if (unwrappedElement.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
737
807
|
const objectName = unwrappedElement.name;
|
|
738
808
|
// Skip type checking if we don't have TypeScript services
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
const nodeMap = parserServices.esTreeNodeToTSNodeMap;
|
|
809
|
+
const dependencyTypeInfo = getTypeInfo();
|
|
810
|
+
if (dependencyTypeInfo) {
|
|
742
811
|
// Skip if the dependency is an array or primitive type
|
|
743
|
-
if (isArrayOrPrimitive(checker, unwrappedElement, nodeMap)) {
|
|
812
|
+
if (isArrayOrPrimitive(dependencyTypeInfo.checker, unwrappedElement, dependencyTypeInfo.nodeMap)) {
|
|
744
813
|
return;
|
|
745
814
|
}
|
|
746
815
|
}
|
|
747
816
|
// For testing without TypeScript services, we'll assume all identifiers are objects
|
|
748
|
-
const result = getObjectUsagesInHook(callbackBody, objectName);
|
|
817
|
+
const result = getObjectUsagesInHook(callbackBody, objectName, dependencyTypeInfo);
|
|
749
818
|
// If the object is not used at all, suggest removing it
|
|
750
819
|
if (result.notUsed) {
|
|
751
820
|
// why: deleting an entry from an array the author maintains by
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
import { TSESLint } from '@typescript-eslint/utils';
|
|
2
|
+
export declare const preferNullishCoalescingBooleanProps: TSESLint.RuleModule<"preferNullishCoalescing", [], TSESLint.RuleListener>;
|
|
@@ -489,6 +489,48 @@ function couldBeNullish(node, checker, parserServices) {
|
|
|
489
489
|
// For other expressions, conservatively assume they could be nullish
|
|
490
490
|
return true;
|
|
491
491
|
}
|
|
492
|
+
/**
|
|
493
|
+
* ECMAScript forbids `??` from sharing an expression with an unparenthesized
|
|
494
|
+
* `&&`/`||`. Source-level parentheses are not part of an ESTree node's range,
|
|
495
|
+
* so rewriting a whole LogicalExpression drops the parens around its operands —
|
|
496
|
+
* exactly the ones the operator swap makes mandatory. Re-adding them around any
|
|
497
|
+
* logical operand is unconditionally safe: the sub-expression was already
|
|
498
|
+
* evaluated as a unit, so redundant parens cannot change semantics.
|
|
499
|
+
*/
|
|
500
|
+
function parenthesizeLogical(text, operand) {
|
|
501
|
+
return operand.type === utils_1.AST_NODE_TYPES.LogicalExpression ? `(${text})` : text;
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Detects parentheses that wrap the node itself. They live outside the node's
|
|
505
|
+
* range, so a `replaceText` of the node preserves them and the rewrite needs no
|
|
506
|
+
* parens of its own.
|
|
507
|
+
*/
|
|
508
|
+
function isParenthesized(node, sourceCode) {
|
|
509
|
+
const before = sourceCode.getTokenBefore(node);
|
|
510
|
+
const after = sourceCode.getTokenAfter(node);
|
|
511
|
+
return (!!before &&
|
|
512
|
+
!!after &&
|
|
513
|
+
before.type === utils_1.AST_TOKEN_TYPES.Punctuator &&
|
|
514
|
+
before.value === '(' &&
|
|
515
|
+
after.type === utils_1.AST_TOKEN_TYPES.Punctuator &&
|
|
516
|
+
after.value === ')');
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* A partially converted chain (`a ?? b || c`) is a syntax error just like an
|
|
520
|
+
* unparenthesized operand. Only one fix per overlapping range survives a pass,
|
|
521
|
+
* so converting one link of a `||` chain always leaves the sibling links
|
|
522
|
+
* untouched; parenthesizing the rewritten link keeps the emitted program
|
|
523
|
+
* parseable while later passes convert the remaining links.
|
|
524
|
+
*/
|
|
525
|
+
function needsSelfParens(node, sourceCode) {
|
|
526
|
+
const { parent } = node;
|
|
527
|
+
if (!parent ||
|
|
528
|
+
parent.type !== utils_1.AST_NODE_TYPES.LogicalExpression ||
|
|
529
|
+
parent.operator === '??') {
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
return !isParenthesized(node, sourceCode);
|
|
533
|
+
}
|
|
492
534
|
exports.preferNullishCoalescingBooleanProps = (0, createRule_1.createRule)({
|
|
493
535
|
name: 'prefer-nullish-coalescing-boolean-props',
|
|
494
536
|
meta: {
|
|
@@ -540,7 +582,10 @@ exports.preferNullishCoalescingBooleanProps = (0, createRule_1.createRule)({
|
|
|
540
582
|
right: rightText,
|
|
541
583
|
},
|
|
542
584
|
fix(fixer) {
|
|
543
|
-
|
|
585
|
+
const replacement = `${parenthesizeLogical(leftText, node.left)} ?? ${parenthesizeLogical(rightText, node.right)}`;
|
|
586
|
+
return fixer.replaceText(node, needsSelfParens(node, sourceCode)
|
|
587
|
+
? `(${replacement})`
|
|
588
|
+
: replacement);
|
|
544
589
|
},
|
|
545
590
|
});
|
|
546
591
|
}
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,65 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.106",
|
|
4
|
+
"date": "2026-08-05T01:12:16.076Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "consistent-callback-naming",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1719
|
|
11
|
+
],
|
|
12
|
+
"summary": "stop renaming destructuring keys and reserved words (closes #1719)"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "no-entire-object-hook-deps",
|
|
16
|
+
"changeType": "fix",
|
|
17
|
+
"issues": [
|
|
18
|
+
1721
|
|
19
|
+
],
|
|
20
|
+
"summary": "keep the whole object when the member is a method (closes #1721)"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "prefer-nullish-coalescing-boolean-props",
|
|
24
|
+
"changeType": "fix",
|
|
25
|
+
"issues": [
|
|
26
|
+
1720
|
|
27
|
+
],
|
|
28
|
+
"summary": "keep the parens ?? requires (closes #1720)"
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"version": "1.20.105",
|
|
34
|
+
"date": "2026-08-04T22:42:11.381Z",
|
|
35
|
+
"rules": [
|
|
36
|
+
{
|
|
37
|
+
"name": "enforce-dynamic-firebase-imports",
|
|
38
|
+
"changeType": "fix",
|
|
39
|
+
"issues": [
|
|
40
|
+
1715,
|
|
41
|
+
1716
|
|
42
|
+
],
|
|
43
|
+
"summary": "relocate the dynamic import to its call site (closes #1716); exempt never-bundled files (closes #1715)"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"name": "enforce-empty-object-check",
|
|
47
|
+
"changeType": "fix",
|
|
48
|
+
"issues": [
|
|
49
|
+
1718
|
|
50
|
+
],
|
|
51
|
+
"summary": "exempt constructable types, not just callable ones (closes #1718)"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"name": "memoize-root-level-hocs",
|
|
55
|
+
"changeType": "fix",
|
|
56
|
+
"issues": [
|
|
57
|
+
1717
|
|
58
|
+
],
|
|
59
|
+
"summary": "require component evidence, not just a with[A-Z] name (closes #1717)"
|
|
60
|
+
}
|
|
61
|
+
]
|
|
62
|
+
},
|
|
2
63
|
{
|
|
3
64
|
"version": "1.20.104",
|
|
4
65
|
"date": "2026-08-04T20:46:47.102Z",
|