@blumintinc/eslint-plugin-blumint 1.20.30 → 1.20.31
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-assert-safe-object-key.js +112 -25
- package/lib/rules/enforce-memoize-async.js +97 -19
- package/lib/rules/enforce-memoize-getters.js +108 -21
- package/lib/rules/no-array-length-in-deps.js +65 -9
- package/lib/rules/prefer-fragment-component.js +127 -73
- package/lib/rules/require-memo.js +101 -26
- package/lib/rules/use-latest-callback.js +86 -39
- package/package.json +1 -1
- package/release-manifest.json +62 -0
package/lib/index.js
CHANGED
|
@@ -10,6 +10,26 @@ const createRule_1 = require("../utils/createRule");
|
|
|
10
10
|
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
11
11
|
const disableDirectives_1 = require("../utils/disableDirectives");
|
|
12
12
|
const DEFAULT_IMPORT_PATH = 'functions/src/util/assertSafe';
|
|
13
|
+
const ASSERT_SAFE_NAME = 'assertSafe';
|
|
14
|
+
/**
|
|
15
|
+
* A named specifier that binds `assertSafe` under its own name — the only shape
|
|
16
|
+
* that makes a bare `assertSafe(...)` call resolve to the helper. An alias
|
|
17
|
+
* (`import { assertSafe as ensureSafe }`) leaves the name free for the injected
|
|
18
|
+
* import, and a type-only specifier erases at compile time.
|
|
19
|
+
*/
|
|
20
|
+
function isAssertSafeSpecifier(specifier) {
|
|
21
|
+
return (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
22
|
+
specifier.importKind !== 'type' &&
|
|
23
|
+
specifier.imported.name === ASSERT_SAFE_NAME &&
|
|
24
|
+
specifier.local.name === ASSERT_SAFE_NAME);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Drops the extension and normalizes separators so two spellings of one module
|
|
28
|
+
* compare equal.
|
|
29
|
+
*/
|
|
30
|
+
function normalizeModulePath(value) {
|
|
31
|
+
return value.replace(/\\/g, '/').replace(/\.(tsx?|jsx?|mts|cts)$/i, '');
|
|
32
|
+
}
|
|
13
33
|
exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
14
34
|
name: 'enforce-assert-safe-object-key',
|
|
15
35
|
meta: {
|
|
@@ -35,7 +55,14 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
|
35
55
|
defaultOptions: [{}],
|
|
36
56
|
create(context, [options]) {
|
|
37
57
|
const importPath = options?.assertSafeImportPath || DEFAULT_IMPORT_PATH;
|
|
38
|
-
|
|
58
|
+
// Repo-root-anchored location of the helper, the yardstick every module
|
|
59
|
+
// specifier written in the file is compared against.
|
|
60
|
+
const assertSafeTarget = normalizeModulePath(importPath);
|
|
61
|
+
// Whether an earlier fix in this pass already carries the import. The AST is
|
|
62
|
+
// not re-parsed between the fixes of a single pass, so the import can only
|
|
63
|
+
// be claimed once: a second fix repeating it would span the same insertion
|
|
64
|
+
// point, overlap, and be dropped along with its wrap.
|
|
65
|
+
let importClaimed = false;
|
|
39
66
|
/**
|
|
40
67
|
* The `import { assertSafe }` statement rides on a single violation's fix,
|
|
41
68
|
* making that violation the file's import carrier. A suppressed carrier
|
|
@@ -43,6 +70,21 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
|
43
70
|
* emit `assertSafe(...)`, leaving the call unbound.
|
|
44
71
|
*/
|
|
45
72
|
const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
|
|
73
|
+
/**
|
|
74
|
+
* Directory of the file being fixed, relative to the repo root, or null for
|
|
75
|
+
* virtual/stdin files (RuleTester default 'file.ts', '<input>', '<text>')
|
|
76
|
+
* whose non-absolute name cannot anchor a relative path.
|
|
77
|
+
*/
|
|
78
|
+
const fileDirFromRoot = () => {
|
|
79
|
+
const rawFilename = context.getFilename().replace(/\\/g, '/');
|
|
80
|
+
if (!path_1.default.isAbsolute(rawFilename)) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const fileRelToCwd = path_1.default
|
|
84
|
+
.relative(process.cwd(), rawFilename)
|
|
85
|
+
.replace(/\\/g, '/');
|
|
86
|
+
return path_1.default.posix.dirname(fileRelToCwd);
|
|
87
|
+
};
|
|
46
88
|
/**
|
|
47
89
|
* Computes the module specifier for the injected assertSafe import.
|
|
48
90
|
*
|
|
@@ -56,27 +98,69 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
|
56
98
|
* file being fixed so the emitted import resolves from that file's location.
|
|
57
99
|
*/
|
|
58
100
|
const computeImportSpecifier = () => {
|
|
59
|
-
const
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
// non-file lints).
|
|
64
|
-
if (!path_1.default.isAbsolute(rawFilename)) {
|
|
101
|
+
const fileDir = fileDirFromRoot();
|
|
102
|
+
// Emitting the configured path verbatim preserves the option's literal
|
|
103
|
+
// value for non-file lints.
|
|
104
|
+
if (fileDir === null) {
|
|
65
105
|
return importPath;
|
|
66
106
|
}
|
|
67
|
-
|
|
68
|
-
.replace(/\\/g, '/')
|
|
69
|
-
.replace(/\.(tsx?|jsx?|mts|cts)$/i, '');
|
|
70
|
-
const fileRelToCwd = path_1.default
|
|
71
|
-
.relative(process.cwd(), rawFilename)
|
|
72
|
-
.replace(/\\/g, '/');
|
|
73
|
-
const fileDir = path_1.default.posix.dirname(fileRelToCwd);
|
|
74
|
-
let specifier = path_1.default.posix.relative(fileDir, target);
|
|
107
|
+
let specifier = path_1.default.posix.relative(fileDir, assertSafeTarget);
|
|
75
108
|
if (!specifier.startsWith('.')) {
|
|
76
109
|
specifier = `./${specifier}`;
|
|
77
110
|
}
|
|
78
111
|
return specifier;
|
|
79
112
|
};
|
|
113
|
+
/**
|
|
114
|
+
* Whether a module specifier written in the file denotes the same helper the
|
|
115
|
+
* fix imports. A relative specifier is resolved against the file's own
|
|
116
|
+
* directory before the comparison, because the configured path is anchored
|
|
117
|
+
* at the repo root: `../../assertSafe` inside functions/src/util/a/b and
|
|
118
|
+
* `functions/src/util/assertSafe` name one module, and treating them as
|
|
119
|
+
* different would withhold the fix from files that import the helper
|
|
120
|
+
* perfectly well.
|
|
121
|
+
*/
|
|
122
|
+
const isAssertSafeModule = (source) => {
|
|
123
|
+
const normalized = normalizeModulePath(source);
|
|
124
|
+
if (normalized === assertSafeTarget) {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
if (!normalized.startsWith('.')) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
const fileDir = fileDirFromRoot();
|
|
131
|
+
if (fileDir === null) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
return (path_1.default.posix.normalize(path_1.default.posix.join(fileDir, normalized)) ===
|
|
135
|
+
assertSafeTarget);
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* Read the import off the AST instead of a traversal flag: a violation that
|
|
139
|
+
* precedes the import declaration in source order would otherwise be judged
|
|
140
|
+
* against a flag the `ImportDeclaration` visitor has not set yet, emitting a
|
|
141
|
+
* duplicate import.
|
|
142
|
+
*/
|
|
143
|
+
const importsAssertSafe = (program) => program.body.some((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
|
|
144
|
+
statement.importKind !== 'type' &&
|
|
145
|
+
isAssertSafeModule(statement.source.value) &&
|
|
146
|
+
statement.specifiers.some(isAssertSafeSpecifier));
|
|
147
|
+
/**
|
|
148
|
+
* Whether every declaration of a visible `assertSafe` binding is the helper
|
|
149
|
+
* import itself. A local const/function/class, a parameter, a namespace or
|
|
150
|
+
* default import, or a named import from another module all mean the emitted
|
|
151
|
+
* `assertSafe(...)` call would resolve somewhere other than the helper.
|
|
152
|
+
*/
|
|
153
|
+
const bindsAssertSafe = (variable) => variable.defs.length > 0 &&
|
|
154
|
+
variable.defs.every((def) => {
|
|
155
|
+
const specifier = def.node;
|
|
156
|
+
if (!isAssertSafeSpecifier(specifier)) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
const declaration = specifier.parent;
|
|
160
|
+
return (declaration?.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
|
|
161
|
+
declaration.importKind !== 'type' &&
|
|
162
|
+
isAssertSafeModule(declaration.source.value));
|
|
163
|
+
});
|
|
80
164
|
/**
|
|
81
165
|
* Helper function to add assertSafe import if needed
|
|
82
166
|
*/
|
|
@@ -96,10 +180,9 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
|
96
180
|
*/
|
|
97
181
|
const createFixes = (fixer, node, argText) => {
|
|
98
182
|
const fixes = [];
|
|
99
|
-
|
|
100
|
-
if (!hasAssertSafeImport) {
|
|
183
|
+
if (!importClaimed && !importsAssertSafe(context.sourceCode.ast)) {
|
|
101
184
|
fixes.push(addAssertSafeImport(fixer));
|
|
102
|
-
|
|
185
|
+
importClaimed = true;
|
|
103
186
|
}
|
|
104
187
|
// Replace the node with assertSafe(argText)
|
|
105
188
|
fixes.push(fixer.replaceText(node, `assertSafe(${argText})`));
|
|
@@ -119,6 +202,17 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
|
119
202
|
if (isReportSuppressed(node)) {
|
|
120
203
|
return null;
|
|
121
204
|
}
|
|
205
|
+
// Resolve `assertSafe` through the scope chain at the fixed node. A
|
|
206
|
+
// binding that is not the helper import breaks the edit two ways: the
|
|
207
|
+
// injected import collides with a module-scope declaration (TS2440,
|
|
208
|
+
// or TS2300 when the binding is itself an import), and a shadowing
|
|
209
|
+
// parameter or block-scoped binding captures the emitted call with no
|
|
210
|
+
// compile error at all. Declining leaves the report standing so the
|
|
211
|
+
// author resolves the clash deliberately.
|
|
212
|
+
const existing = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, node), ASSERT_SAFE_NAME);
|
|
213
|
+
if (existing && !bindsAssertSafe(existing)) {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
122
216
|
return createFixes(fixer, node, expressionText);
|
|
123
217
|
},
|
|
124
218
|
});
|
|
@@ -146,13 +240,6 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
|
146
240
|
});
|
|
147
241
|
};
|
|
148
242
|
return {
|
|
149
|
-
ImportDeclaration(node) {
|
|
150
|
-
// Check if assertSafe is already imported
|
|
151
|
-
if (node.specifiers.some((specifier) => specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
152
|
-
specifier.imported.name === 'assertSafe')) {
|
|
153
|
-
hasAssertSafeImport = true;
|
|
154
|
-
}
|
|
155
|
-
},
|
|
156
243
|
// Handle computed property in object destructuring
|
|
157
244
|
Property(node) {
|
|
158
245
|
if (node.computed && node.key) {
|
|
@@ -3,9 +3,45 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.enforceMemoizeAsync = 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
|
const disableDirectives_1 = require("../utils/disableDirectives");
|
|
7
8
|
const MEMOIZE_MODULE = '@blumintinc/typescript-memoize';
|
|
8
9
|
const ALLOWED_MEMOIZE_MODULES = new Set([MEMOIZE_MODULE, 'typescript-memoize']);
|
|
10
|
+
const MEMOIZE_NAME = 'Memoize';
|
|
11
|
+
/**
|
|
12
|
+
* A named specifier that binds `Memoize` under its own name — the only shape
|
|
13
|
+
* that makes a bare `@Memoize()` decorator resolve to the decorator factory. An
|
|
14
|
+
* alias (`import { Memoize as Cache }`) leaves the name free for the injected
|
|
15
|
+
* import, and a type-only specifier erases at compile time, so neither backs a
|
|
16
|
+
* value reference.
|
|
17
|
+
*/
|
|
18
|
+
function isMemoizeSpecifier(specifier) {
|
|
19
|
+
return (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
20
|
+
specifier.importKind !== 'type' &&
|
|
21
|
+
specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
22
|
+
specifier.imported.name === MEMOIZE_NAME &&
|
|
23
|
+
specifier.local.name === MEMOIZE_NAME);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Whether every declaration of a visible `Memoize` binding is a value import of
|
|
27
|
+
* the decorator itself. A local const/function/class, an enclosing class of the
|
|
28
|
+
* same name, a parameter, a namespace or default import, or a named import from
|
|
29
|
+
* any other module all mean the emitted `@Memoize()` would resolve somewhere
|
|
30
|
+
* other than the decorator factory.
|
|
31
|
+
*/
|
|
32
|
+
function bindsMemoize(variable) {
|
|
33
|
+
return (variable.defs.length > 0 &&
|
|
34
|
+
variable.defs.every((def) => {
|
|
35
|
+
const specifier = def.node;
|
|
36
|
+
if (!isMemoizeSpecifier(specifier)) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
const declaration = specifier.parent;
|
|
40
|
+
return (declaration?.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
|
|
41
|
+
declaration.importKind !== 'type' &&
|
|
42
|
+
ALLOWED_MEMOIZE_MODULES.has(String(declaration.source.value)));
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
9
45
|
/**
|
|
10
46
|
* Matches a memoize decorator in supported syntaxes:
|
|
11
47
|
* - @Alias()
|
|
@@ -51,9 +87,6 @@ exports.enforceMemoizeAsync = (0, createRule_1.createRule)({
|
|
|
51
87
|
},
|
|
52
88
|
defaultOptions: [],
|
|
53
89
|
create(context) {
|
|
54
|
-
let hasMemoizeImport = false;
|
|
55
|
-
const memoizeAliases = new Map(); // alias -> source module
|
|
56
|
-
const memoizeNamespaces = new Map(); // namespace -> source module
|
|
57
90
|
let scheduledImportFix = false;
|
|
58
91
|
/**
|
|
59
92
|
* The `import { Memoize }` statement rides on a single violation's fix, so
|
|
@@ -62,23 +95,48 @@ exports.enforceMemoizeAsync = (0, createRule_1.createRule)({
|
|
|
62
95
|
* `@Memoize()`, leaving a decorator with no import.
|
|
63
96
|
*/
|
|
64
97
|
const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Memoize bindings the file already imports, keyed by local name: aliases
|
|
100
|
+
* for `import { Memoize as X }` and namespaces for `import * as X`.
|
|
101
|
+
*
|
|
102
|
+
* Read off `Program.body` rather than accumulated by an `ImportDeclaration`
|
|
103
|
+
* visitor, because a class that precedes the import declaration in source
|
|
104
|
+
* order is visited — and fixed — before that visitor runs, and would be
|
|
105
|
+
* judged against state recorded for no import at all. The AST is fixed for
|
|
106
|
+
* the pass, so a single scan serves every violation.
|
|
107
|
+
*/
|
|
108
|
+
const readMemoizeImports = () => {
|
|
109
|
+
const aliases = new Map();
|
|
110
|
+
const namespaces = new Map();
|
|
111
|
+
for (const statement of context.sourceCode.ast.body) {
|
|
112
|
+
if (statement.type !== utils_1.AST_NODE_TYPES.ImportDeclaration) {
|
|
113
|
+
continue;
|
|
80
114
|
}
|
|
81
|
-
|
|
115
|
+
const source = String(statement.source.value);
|
|
116
|
+
if (!ALLOWED_MEMOIZE_MODULES.has(source)) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
for (const spec of statement.specifiers) {
|
|
120
|
+
if (spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
121
|
+
spec.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
122
|
+
spec.imported.name === MEMOIZE_NAME) {
|
|
123
|
+
aliases.set(spec.local.name, source);
|
|
124
|
+
}
|
|
125
|
+
else if (spec.type === utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier) {
|
|
126
|
+
namespaces.set(spec.local.name, source);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { aliases, namespaces };
|
|
131
|
+
};
|
|
132
|
+
let memoizeImportCache = null;
|
|
133
|
+
const memoizeImports = () => {
|
|
134
|
+
if (!memoizeImportCache) {
|
|
135
|
+
memoizeImportCache = readMemoizeImports();
|
|
136
|
+
}
|
|
137
|
+
return memoizeImportCache;
|
|
138
|
+
};
|
|
139
|
+
return {
|
|
82
140
|
MethodDefinition(node) {
|
|
83
141
|
// Only process async instance methods (skip static methods)
|
|
84
142
|
if (node.value.type !== utils_1.AST_NODE_TYPES.FunctionExpression ||
|
|
@@ -90,6 +148,8 @@ exports.enforceMemoizeAsync = (0, createRule_1.createRule)({
|
|
|
90
148
|
if (node.value.params.length > 1) {
|
|
91
149
|
return;
|
|
92
150
|
}
|
|
151
|
+
const { aliases: memoizeAliases, namespaces: memoizeNamespaces } = memoizeImports();
|
|
152
|
+
const hasMemoizeImport = memoizeAliases.size > 0 || memoizeNamespaces.size > 0;
|
|
93
153
|
// Check if method already has @Memoize or @Memoize() decorator
|
|
94
154
|
const hasDecorator = node.decorators?.some((decorator) => {
|
|
95
155
|
// If no named imports were found, we assume 'Memoize' is the intended name (for legacy/global support)
|
|
@@ -164,6 +224,24 @@ exports.enforceMemoizeAsync = (0, createRule_1.createRule)({
|
|
|
164
224
|
}
|
|
165
225
|
}
|
|
166
226
|
const importStatement = `import { Memoize } from '${MEMOIZE_MODULE}';`;
|
|
227
|
+
// Resolve `Memoize` through the scope chain at the fixed node
|
|
228
|
+
// whenever the edit spells the decorator bare. A binding that is
|
|
229
|
+
// not a memoize import breaks the edit two ways: the injected
|
|
230
|
+
// import collides with a module-scope declaration (TS2440, or
|
|
231
|
+
// TS2300 when the binding is itself an import), and a shadowing
|
|
232
|
+
// parameter or block-scoped binding captures the emitted decorator
|
|
233
|
+
// with no compile error at all. Declining leaves the report
|
|
234
|
+
// standing so the author resolves the clash deliberately.
|
|
235
|
+
//
|
|
236
|
+
// An alias or namespace decorator (`@Cache()`, `@ns.Memoize()`)
|
|
237
|
+
// neither references the bare name nor injects the import, so it is
|
|
238
|
+
// unaffected by a `Memoize` binding and must not be declined.
|
|
239
|
+
if (decoratorIdent === MEMOIZE_NAME) {
|
|
240
|
+
const existing = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, node), MEMOIZE_NAME);
|
|
241
|
+
if (existing && !bindsMemoize(existing)) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
167
245
|
// Add import if it's not already present; ensure we only add once per file
|
|
168
246
|
if (!hasMemoizeImport &&
|
|
169
247
|
memoizeNamespaces.size === 0 &&
|
|
@@ -3,12 +3,48 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.enforceMemoizeGetters = 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
|
const disableDirectives_1 = require("../utils/disableDirectives");
|
|
7
8
|
const MEMOIZE_PREFERRED_MODULE = '@blumintinc/typescript-memoize';
|
|
8
9
|
const MEMOIZE_MODULES = new Set([
|
|
9
10
|
MEMOIZE_PREFERRED_MODULE,
|
|
10
11
|
'typescript-memoize',
|
|
11
12
|
]);
|
|
13
|
+
const MEMOIZE_NAME = 'Memoize';
|
|
14
|
+
/**
|
|
15
|
+
* A named specifier that binds `Memoize` under its own name — the only shape
|
|
16
|
+
* that makes a bare `@Memoize()` decorator resolve to the decorator factory. An
|
|
17
|
+
* alias (`import { Memoize as Cache }`) leaves the name free for the injected
|
|
18
|
+
* import, and a type-only specifier erases at compile time, so neither backs a
|
|
19
|
+
* value reference.
|
|
20
|
+
*/
|
|
21
|
+
function isMemoizeSpecifier(specifier) {
|
|
22
|
+
return (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
23
|
+
specifier.importKind !== 'type' &&
|
|
24
|
+
specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
25
|
+
specifier.imported.name === MEMOIZE_NAME &&
|
|
26
|
+
specifier.local.name === MEMOIZE_NAME);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Whether every declaration of a visible `Memoize` binding is a value import of
|
|
30
|
+
* the decorator itself. A local const/function/class, an enclosing class of the
|
|
31
|
+
* same name, a parameter, a namespace or default import, or a named import from
|
|
32
|
+
* any other module all mean the emitted `@Memoize()` would resolve somewhere
|
|
33
|
+
* other than the decorator factory.
|
|
34
|
+
*/
|
|
35
|
+
function bindsMemoize(variable) {
|
|
36
|
+
return (variable.defs.length > 0 &&
|
|
37
|
+
variable.defs.every((def) => {
|
|
38
|
+
const specifier = def.node;
|
|
39
|
+
if (!isMemoizeSpecifier(specifier)) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
const declaration = specifier.parent;
|
|
43
|
+
return (declaration?.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
|
|
44
|
+
declaration.importKind !== 'type' &&
|
|
45
|
+
MEMOIZE_MODULES.has(String(declaration.source.value)));
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
12
48
|
function isMemoizeDecorator(decorator, alias) {
|
|
13
49
|
const expression = decorator.expression;
|
|
14
50
|
// @Alias()
|
|
@@ -54,10 +90,6 @@ exports.enforceMemoizeGetters = (0, createRule_1.createRule)({
|
|
|
54
90
|
return {};
|
|
55
91
|
}
|
|
56
92
|
const sourceCode = context.getSourceCode();
|
|
57
|
-
let hasMemoizeImport = false;
|
|
58
|
-
let memoizeAlias = 'Memoize';
|
|
59
|
-
let memoizeNamespace = null;
|
|
60
|
-
let hasNamedImport = false;
|
|
61
93
|
let scheduledImportFix = false;
|
|
62
94
|
/**
|
|
63
95
|
* The `import { Memoize }` statement rides on a single violation's fix, so
|
|
@@ -66,25 +98,59 @@ exports.enforceMemoizeGetters = (0, createRule_1.createRule)({
|
|
|
66
98
|
* `@Memoize()`, leaving a decorator with no import.
|
|
67
99
|
*/
|
|
68
100
|
const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
101
|
+
/**
|
|
102
|
+
* The memoize import the file already carries: the local name of a named
|
|
103
|
+
* `Memoize` specifier, or the local name of a namespace import.
|
|
104
|
+
*
|
|
105
|
+
* Read off `Program.body` rather than accumulated by an `ImportDeclaration`
|
|
106
|
+
* visitor, because a class that precedes the import declaration in source
|
|
107
|
+
* order is visited — and fixed — before that visitor runs, and would be
|
|
108
|
+
* judged against state recorded for no import at all. The AST is fixed for
|
|
109
|
+
* the pass, so a single scan serves every violation.
|
|
110
|
+
*/
|
|
111
|
+
const readMemoizeImports = () => {
|
|
112
|
+
let hasMemoizeImport = false;
|
|
113
|
+
let memoizeAlias = MEMOIZE_NAME;
|
|
114
|
+
let memoizeNamespace = null;
|
|
115
|
+
let hasNamedImport = false;
|
|
116
|
+
for (const statement of sourceCode.ast.body) {
|
|
117
|
+
if (statement.type !== utils_1.AST_NODE_TYPES.ImportDeclaration) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!MEMOIZE_MODULES.has(String(statement.source.value))) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
for (const spec of statement.specifiers) {
|
|
124
|
+
if (spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
125
|
+
spec.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
126
|
+
spec.imported.name === MEMOIZE_NAME) {
|
|
127
|
+
hasMemoizeImport = true;
|
|
128
|
+
hasNamedImport = true;
|
|
129
|
+
memoizeAlias = spec.local.name;
|
|
130
|
+
}
|
|
131
|
+
else if (spec.type === utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier) {
|
|
132
|
+
hasMemoizeImport = true;
|
|
133
|
+
if (!hasNamedImport) {
|
|
134
|
+
memoizeNamespace = spec.local.name;
|
|
84
135
|
}
|
|
85
136
|
}
|
|
86
137
|
}
|
|
87
|
-
}
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
hasMemoizeImport,
|
|
141
|
+
memoizeAlias,
|
|
142
|
+
memoizeNamespace,
|
|
143
|
+
hasNamedImport,
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
let memoizeImportCache = null;
|
|
147
|
+
const memoizeImports = () => {
|
|
148
|
+
if (!memoizeImportCache) {
|
|
149
|
+
memoizeImportCache = readMemoizeImports();
|
|
150
|
+
}
|
|
151
|
+
return memoizeImportCache;
|
|
152
|
+
};
|
|
153
|
+
return {
|
|
88
154
|
MethodDefinition(node) {
|
|
89
155
|
// Target: instance private getters
|
|
90
156
|
if (node.kind !== 'get')
|
|
@@ -95,7 +161,10 @@ exports.enforceMemoizeGetters = (0, createRule_1.createRule)({
|
|
|
95
161
|
// enforce only "private" accessibility (undefined => public)
|
|
96
162
|
if (node.accessibility !== 'private')
|
|
97
163
|
return;
|
|
98
|
-
const
|
|
164
|
+
const { hasMemoizeImport, memoizeAlias, memoizeNamespace, hasNamedImport, } = memoizeImports();
|
|
165
|
+
const decoratorAliases = memoizeAlias === MEMOIZE_NAME
|
|
166
|
+
? [MEMOIZE_NAME]
|
|
167
|
+
: [MEMOIZE_NAME, memoizeAlias];
|
|
99
168
|
const hasDecorator = node.decorators?.some((decorator) => decoratorAliases.some((alias) => isMemoizeDecorator(decorator, alias)));
|
|
100
169
|
if (hasDecorator)
|
|
101
170
|
return;
|
|
@@ -127,6 +196,24 @@ exports.enforceMemoizeGetters = (0, createRule_1.createRule)({
|
|
|
127
196
|
return memoizeAlias;
|
|
128
197
|
};
|
|
129
198
|
const decoratorIdent = getDecoratorIdent();
|
|
199
|
+
// Resolve `Memoize` through the scope chain at the fixed node
|
|
200
|
+
// whenever the edit spells the decorator bare. A binding that is
|
|
201
|
+
// not a memoize import breaks the edit two ways: the injected
|
|
202
|
+
// import collides with a module-scope declaration (TS2440, or
|
|
203
|
+
// TS2300 when the binding is itself an import), and a shadowing
|
|
204
|
+
// parameter or block-scoped binding captures the emitted decorator
|
|
205
|
+
// with no compile error at all. Declining leaves the report
|
|
206
|
+
// standing so the author resolves the clash deliberately.
|
|
207
|
+
//
|
|
208
|
+
// An alias or namespace decorator (`@Cache()`, `@ns.Memoize()`)
|
|
209
|
+
// neither references the bare name nor injects the import, so it is
|
|
210
|
+
// unaffected by a `Memoize` binding and must not be declined.
|
|
211
|
+
if (decoratorIdent === MEMOIZE_NAME) {
|
|
212
|
+
const existing = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, node), MEMOIZE_NAME);
|
|
213
|
+
if (existing && !bindsMemoize(existing)) {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
130
217
|
// Insert import if needed, at the top alongside other imports
|
|
131
218
|
if (!hasMemoizeImport && !scheduledImportFix) {
|
|
132
219
|
const programBody = sourceCode.ast.body;
|
|
@@ -7,6 +7,8 @@ const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
|
7
7
|
const disableDirectives_1 = require("../utils/disableDirectives");
|
|
8
8
|
// React hooks to check
|
|
9
9
|
const HOOK_NAMES = new Set(['useEffect', 'useCallback', 'useMemo']);
|
|
10
|
+
const REACT_MODULE = 'react';
|
|
11
|
+
const MEMO_HOOK_NAME = 'useMemo';
|
|
10
12
|
const DEFAULT_HASH_IMPORT = {
|
|
11
13
|
source: 'functions/src/util/hash/stableHash',
|
|
12
14
|
importName: 'stableHash',
|
|
@@ -232,20 +234,49 @@ function getValueImports(sourceCode, source) {
|
|
|
232
234
|
* specifier (`useMemo as um`) or a type-only specifier does not count — the
|
|
233
235
|
* value binding under the exact local name must exist.
|
|
234
236
|
*/
|
|
237
|
+
function isNamedValueSpecifier(specifier, name) {
|
|
238
|
+
return (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
239
|
+
specifier.importKind !== 'type' &&
|
|
240
|
+
specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
241
|
+
specifier.imported.name === name &&
|
|
242
|
+
specifier.local.name === name);
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Import presence is read off the AST at fix time rather than from a traversal
|
|
246
|
+
* flag: a hook call that precedes the import declaration in source order would
|
|
247
|
+
* otherwise be judged against a flag the `ImportDeclaration` visitor has not
|
|
248
|
+
* set yet, duplicating the import.
|
|
249
|
+
*/
|
|
235
250
|
function hasNamedValueImport(sourceCode, source, name) {
|
|
236
251
|
for (const declaration of getValueImports(sourceCode, source)) {
|
|
237
252
|
for (const spec of declaration.specifiers) {
|
|
238
|
-
if (spec
|
|
239
|
-
spec.importKind !== 'type' &&
|
|
240
|
-
spec.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
241
|
-
spec.imported.name === name &&
|
|
242
|
-
spec.local.name === name) {
|
|
253
|
+
if (isNamedValueSpecifier(spec, name)) {
|
|
243
254
|
return true;
|
|
244
255
|
}
|
|
245
256
|
}
|
|
246
257
|
}
|
|
247
258
|
return false;
|
|
248
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* Whether every declaration of a visible binding is the value import this fix
|
|
262
|
+
* intends to add. A local const/function/class, a parameter, a namespace or
|
|
263
|
+
* default import, an alias, or a named import from any other module all mean
|
|
264
|
+
* the emitted `useMemo(() => stableHash(...))` would resolve somewhere other
|
|
265
|
+
* than the helper — so the binding cannot be reused.
|
|
266
|
+
*/
|
|
267
|
+
function bindsIntendedImport(variable, name, source) {
|
|
268
|
+
return (variable.defs.length > 0 &&
|
|
269
|
+
variable.defs.every((def) => {
|
|
270
|
+
const specifier = def.node;
|
|
271
|
+
if (!isNamedValueSpecifier(specifier, name)) {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
const declaration = specifier.parent;
|
|
275
|
+
return (declaration?.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
|
|
276
|
+
declaration.importKind !== 'type' &&
|
|
277
|
+
declaration.source.value === source);
|
|
278
|
+
}));
|
|
279
|
+
}
|
|
249
280
|
/**
|
|
250
281
|
* Extends an existing import from `source` with `name` instead of prepending
|
|
251
282
|
* a duplicate declaration. Namespace-only imports cannot host a named
|
|
@@ -437,6 +468,30 @@ exports.noArrayLengthInDeps = (0, createRule_1.createRule)({
|
|
|
437
468
|
// All bail checks precede any shared-state mutation so a skipped
|
|
438
469
|
// fix cannot make a later fix believe imports or declarations are
|
|
439
470
|
// already handled.
|
|
471
|
+
// The generated declaration spells both helper names bare and
|
|
472
|
+
// imports both, so a binding of either name breaks the edit two
|
|
473
|
+
// ways: the inserted import collides with a declaration of that
|
|
474
|
+
// name (TS2440, or TS2300 when the binding is itself an import),
|
|
475
|
+
// and a shadowing parameter or block-scoped binding captures the
|
|
476
|
+
// emitted call with no compile error at all. Resolving through the
|
|
477
|
+
// scope chain at the hook call catches both, since the insertion
|
|
478
|
+
// point shares that scope. Because the emitted code needs BOTH
|
|
479
|
+
// names, a clash on either withholds the whole edit — a partial
|
|
480
|
+
// one would still be broken. The report stands so the author
|
|
481
|
+
// resolves the clash deliberately.
|
|
482
|
+
const scope = ASTHelpers_1.ASTHelpers.getScope(context, node);
|
|
483
|
+
const collides = [
|
|
484
|
+
{ name: MEMO_HOOK_NAME, source: REACT_MODULE },
|
|
485
|
+
{
|
|
486
|
+
name: hashImportConfig.importName,
|
|
487
|
+
source: hashImportConfig.source,
|
|
488
|
+
},
|
|
489
|
+
].some(({ name, source }) => {
|
|
490
|
+
const existing = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, name);
|
|
491
|
+
return !!existing && !bindsIntendedImport(existing, name, source);
|
|
492
|
+
});
|
|
493
|
+
if (collides)
|
|
494
|
+
return null;
|
|
440
495
|
const insertion = findInsertionPoint(node);
|
|
441
496
|
if (!insertion)
|
|
442
497
|
return null;
|
|
@@ -483,12 +538,13 @@ exports.noArrayLengthInDeps = (0, createRule_1.createRule)({
|
|
|
483
538
|
}
|
|
484
539
|
if (!importsPlanned) {
|
|
485
540
|
const newImportLines = [];
|
|
486
|
-
if (!hasNamedValueImport(sourceCode,
|
|
487
|
-
const extension = buildImportExtensionFix(fixer, sourceCode,
|
|
541
|
+
if (!hasNamedValueImport(sourceCode, REACT_MODULE, MEMO_HOOK_NAME)) {
|
|
542
|
+
const extension = buildImportExtensionFix(fixer, sourceCode, REACT_MODULE, MEMO_HOOK_NAME);
|
|
488
543
|
if (extension)
|
|
489
544
|
fixes.push(extension);
|
|
490
|
-
else
|
|
491
|
-
newImportLines.push(`import {
|
|
545
|
+
else {
|
|
546
|
+
newImportLines.push(`import { ${MEMO_HOOK_NAME} } from '${REACT_MODULE}';`);
|
|
547
|
+
}
|
|
492
548
|
}
|
|
493
549
|
if (!hasNamedValueImport(sourceCode, hashImportConfig.source, hashImportConfig.importName)) {
|
|
494
550
|
const extension = buildImportExtensionFix(fixer, sourceCode, hashImportConfig.source, hashImportConfig.importName);
|