@blumintinc/eslint-plugin-blumint 1.20.21 → 1.20.23
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/no-array-length-in-deps.js +204 -84
- package/lib/rules/parallelize-async-operations.d.ts +1 -0
- package/lib/rules/parallelize-async-operations.js +38 -1
- package/lib/rules/prefer-clone-deep.js +37 -91
- package/lib/rules/require-dynamic-firebase-imports.js +90 -46
- package/package.json +1 -1
- package/release-manifest.json +44 -0
package/lib/index.js
CHANGED
|
@@ -97,6 +97,122 @@ function findEnclosingFunction(node) {
|
|
|
97
97
|
}
|
|
98
98
|
return null;
|
|
99
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* The memo declaration must land immediately before the statement containing
|
|
102
|
+
* the hook call, inside the innermost enclosing block. Module scope is never
|
|
103
|
+
* a valid target: useMemo is only legal inside a component/hook, and the
|
|
104
|
+
* tracked array is typically function-local, so a top-level insertion would
|
|
105
|
+
* reference an unbound variable and violate the rules of hooks.
|
|
106
|
+
*/
|
|
107
|
+
function findInsertionPoint(node) {
|
|
108
|
+
const enclosingFunction = findEnclosingFunction(node);
|
|
109
|
+
if (!enclosingFunction)
|
|
110
|
+
return null;
|
|
111
|
+
let current = node;
|
|
112
|
+
while (current.parent) {
|
|
113
|
+
// Reaching the function before any block means an expression-bodied
|
|
114
|
+
// arrow: there is no statement position to hold the declaration.
|
|
115
|
+
if (current === enclosingFunction)
|
|
116
|
+
return null;
|
|
117
|
+
const parent = current.parent;
|
|
118
|
+
if (parent.type === utils_1.AST_NODE_TYPES.BlockStatement) {
|
|
119
|
+
return { statement: current, block: parent };
|
|
120
|
+
}
|
|
121
|
+
current = parent;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Collects the identifiers a hoisted `stableHash(<base>)` expression would
|
|
127
|
+
* read: the root object of the member chain plus any computed keys. Property
|
|
128
|
+
* names are not variable references. Returns false for shapes that cannot be
|
|
129
|
+
* hoisted verbatim (this-expressions, calls, casts) so the fixer bails.
|
|
130
|
+
*/
|
|
131
|
+
function collectBaseReferences(expr, out) {
|
|
132
|
+
switch (expr.type) {
|
|
133
|
+
case utils_1.AST_NODE_TYPES.Identifier:
|
|
134
|
+
out.push(expr);
|
|
135
|
+
return true;
|
|
136
|
+
case utils_1.AST_NODE_TYPES.MemberExpression:
|
|
137
|
+
if (!collectBaseReferences(expr.object, out))
|
|
138
|
+
return false;
|
|
139
|
+
if (expr.computed)
|
|
140
|
+
return collectBaseReferences(expr.property, out);
|
|
141
|
+
return true;
|
|
142
|
+
case utils_1.AST_NODE_TYPES.ChainExpression:
|
|
143
|
+
return collectBaseReferences(expr.expression, out);
|
|
144
|
+
case utils_1.AST_NODE_TYPES.TSNonNullExpression:
|
|
145
|
+
return collectBaseReferences(expr.expression, out);
|
|
146
|
+
case utils_1.AST_NODE_TYPES.Literal:
|
|
147
|
+
return true;
|
|
148
|
+
default:
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* A base is safe to memoize at the insertion point only when every variable
|
|
154
|
+
* it reads is provably bound there: declared in a scope that encloses the
|
|
155
|
+
* insertion block, and (for lexical declarations) positioned before the
|
|
156
|
+
* consuming statement so the hoisted read cannot hit the temporal dead zone.
|
|
157
|
+
* Unresolvable or ambient names are rejected — a report without a fix is
|
|
158
|
+
* always preferable to generated code that references an unbound variable.
|
|
159
|
+
*/
|
|
160
|
+
function isBaseSafeToHoist(context, baseExpr, insertion) {
|
|
161
|
+
const identifiers = [];
|
|
162
|
+
if (!collectBaseReferences(baseExpr, identifiers))
|
|
163
|
+
return false;
|
|
164
|
+
if (identifiers.length === 0)
|
|
165
|
+
return false;
|
|
166
|
+
for (const identifier of identifiers) {
|
|
167
|
+
const scope = ASTHelpers_1.ASTHelpers.getScope(context, identifier);
|
|
168
|
+
const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, identifier.name);
|
|
169
|
+
if (!variable || variable.defs.length === 0)
|
|
170
|
+
return false;
|
|
171
|
+
// Visibility: the variable's scope must enclose the insertion block.
|
|
172
|
+
// Because the insertion point shares the hook's scope chain, an enclosing
|
|
173
|
+
// scope here guarantees the same binding resolves at both positions.
|
|
174
|
+
const scopeBlock = variable.scope.block;
|
|
175
|
+
if (!rangeContains(scopeBlock, insertion.block))
|
|
176
|
+
return false;
|
|
177
|
+
for (const def of variable.defs) {
|
|
178
|
+
if (def.type === 'Parameter' || def.type === 'ImportBinding')
|
|
179
|
+
continue;
|
|
180
|
+
if (def.type === 'FunctionName' &&
|
|
181
|
+
def.node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
// Lexical declarations must precede the insertion point textually.
|
|
185
|
+
if (def.name.range[1] > insertion.statement.range[0])
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* An `eslint-disable-next-line` comment directly above the hook statement
|
|
193
|
+
* targets the hook, so the memo declaration must go above the comment —
|
|
194
|
+
* inserting between them would silently re-point the suppression at the memo.
|
|
195
|
+
*/
|
|
196
|
+
function findDeclarationAnchor(sourceCode, statement) {
|
|
197
|
+
let anchor = statement;
|
|
198
|
+
const comments = sourceCode.getCommentsBefore(statement);
|
|
199
|
+
for (let i = comments.length - 1; i >= 0; i--) {
|
|
200
|
+
const comment = comments[i];
|
|
201
|
+
if (comment.loc.end.line === anchor.loc.start.line - 1 &&
|
|
202
|
+
/^\s*eslint-disable-next-line\b/.test(comment.value)) {
|
|
203
|
+
anchor = comment;
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return anchor;
|
|
210
|
+
}
|
|
211
|
+
function getAnchorIndent(sourceCode, anchor) {
|
|
212
|
+
const line = sourceCode.lines[anchor.loc.start.line - 1] ?? '';
|
|
213
|
+
const prefix = line.slice(0, anchor.loc.start.column);
|
|
214
|
+
return /^\s*$/.test(prefix) ? prefix : '';
|
|
215
|
+
}
|
|
100
216
|
function ensureWeakMapEntry(map, key, factory) {
|
|
101
217
|
const existing = map.get(key);
|
|
102
218
|
if (existing)
|
|
@@ -105,22 +221,48 @@ function ensureWeakMapEntry(map, key, factory) {
|
|
|
105
221
|
map.set(key, next);
|
|
106
222
|
return next;
|
|
107
223
|
}
|
|
108
|
-
function
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
224
|
+
function getValueImports(sourceCode, source) {
|
|
225
|
+
return sourceCode.ast.body.filter((node) => node.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
|
|
226
|
+
node.source.value === source &&
|
|
227
|
+
node.importKind !== 'type');
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* The generated code calls the helper by its canonical name, so an aliased
|
|
231
|
+
* specifier (`useMemo as um`) or a type-only specifier does not count — the
|
|
232
|
+
* value binding under the exact local name must exist.
|
|
233
|
+
*/
|
|
234
|
+
function hasNamedValueImport(sourceCode, source, name) {
|
|
235
|
+
for (const declaration of getValueImports(sourceCode, source)) {
|
|
236
|
+
for (const spec of declaration.specifiers) {
|
|
237
|
+
if (spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
238
|
+
spec.importKind !== 'type' &&
|
|
239
|
+
spec.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
240
|
+
spec.imported.name === name &&
|
|
241
|
+
spec.local.name === name) {
|
|
242
|
+
return true;
|
|
119
243
|
}
|
|
120
244
|
}
|
|
121
245
|
}
|
|
122
246
|
return false;
|
|
123
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* Extends an existing import from `source` with `name` instead of prepending
|
|
250
|
+
* a duplicate declaration. Namespace-only imports cannot host a named
|
|
251
|
+
* specifier, so those fall through to a separate declaration (null).
|
|
252
|
+
*/
|
|
253
|
+
function buildImportExtensionFix(fixer, sourceCode, source, name) {
|
|
254
|
+
for (const declaration of getValueImports(sourceCode, source)) {
|
|
255
|
+
const named = declaration.specifiers.filter((spec) => spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier);
|
|
256
|
+
if (named.length > 0) {
|
|
257
|
+
return fixer.insertTextAfter(named[named.length - 1], `, ${name}`);
|
|
258
|
+
}
|
|
259
|
+
const defaultSpec = declaration.specifiers.find((spec) => spec.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier);
|
|
260
|
+
if (defaultSpec) {
|
|
261
|
+
return fixer.insertTextAfter(defaultSpec, `, { ${name} }`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
124
266
|
/**
|
|
125
267
|
* Hook callback = first function-typed argument (the effect/factory/memo fn).
|
|
126
268
|
* Deps array is the LAST argument; the callback precedes it.
|
|
@@ -184,22 +326,6 @@ function isLengthOnlyUsage(context, callback, baseExpr) {
|
|
|
184
326
|
}
|
|
185
327
|
return bodyReferences.every((ref) => isLengthAccessOf(ref.identifier));
|
|
186
328
|
}
|
|
187
|
-
function isStableHashImported(sourceCode, hashSource, hashImportName) {
|
|
188
|
-
const program = sourceCode.ast;
|
|
189
|
-
for (const node of program.body) {
|
|
190
|
-
if (node.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
|
|
191
|
-
node.source.value === hashSource) {
|
|
192
|
-
for (const spec of node.specifiers) {
|
|
193
|
-
if (spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
194
|
-
spec.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
195
|
-
spec.imported.name === hashImportName) {
|
|
196
|
-
return true;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
return false;
|
|
202
|
-
}
|
|
203
329
|
exports.noArrayLengthInDeps = (0, createRule_1.createRule)({
|
|
204
330
|
name: 'no-array-length-in-deps',
|
|
205
331
|
meta: {
|
|
@@ -237,10 +363,13 @@ exports.noArrayLengthInDeps = (0, createRule_1.createRule)({
|
|
|
237
363
|
source: hashImport?.source ?? DEFAULT_HASH_IMPORT.source,
|
|
238
364
|
importName: hashImport?.importName ?? DEFAULT_HASH_IMPORT.importName,
|
|
239
365
|
};
|
|
240
|
-
// Track planned file-wide changes to avoid overlapping fixers
|
|
366
|
+
// Track planned file-wide changes to avoid overlapping fixers. Bases are
|
|
367
|
+
// deduplicated per insertion block: a memo declared in one block is not
|
|
368
|
+
// visible in a sibling block, so sharing across a whole function would
|
|
369
|
+
// strand references.
|
|
241
370
|
let importsPlanned = false;
|
|
242
|
-
const
|
|
243
|
-
const
|
|
371
|
+
const perBlockDeclaredBases = new WeakMap();
|
|
372
|
+
const perBlockBaseToVar = new WeakMap();
|
|
244
373
|
return {
|
|
245
374
|
CallExpression(node) {
|
|
246
375
|
if (!isHookCall(node))
|
|
@@ -283,10 +412,20 @@ exports.noArrayLengthInDeps = (0, createRule_1.createRule)({
|
|
|
283
412
|
dependencies,
|
|
284
413
|
},
|
|
285
414
|
fix(fixer) {
|
|
415
|
+
// All bail checks precede any shared-state mutation so a skipped
|
|
416
|
+
// fix cannot make a later fix believe imports or declarations are
|
|
417
|
+
// already handled.
|
|
418
|
+
const insertion = findInsertionPoint(node);
|
|
419
|
+
if (!insertion)
|
|
420
|
+
return null;
|
|
421
|
+
for (const { member } of lengthDeps) {
|
|
422
|
+
if (!isBaseSafeToHoist(context, getBaseExpression(member), insertion)) {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
286
426
|
const fixes = [];
|
|
287
|
-
const
|
|
288
|
-
const
|
|
289
|
-
const baseToVar = ensureWeakMapEntry(perFuncBaseToVar, hostFn, () => new Map());
|
|
427
|
+
const declaredBases = ensureWeakMapEntry(perBlockDeclaredBases, insertion.block, () => new Set());
|
|
428
|
+
const baseToVar = ensureWeakMapEntry(perBlockBaseToVar, insertion.block, () => new Map());
|
|
290
429
|
// Prepare variable names (consistent across file) and taken names (across all scopes)
|
|
291
430
|
const allTaken = collectAllTakenNames(sourceCode);
|
|
292
431
|
for (const name of baseToVar.values()) {
|
|
@@ -302,71 +441,52 @@ exports.noArrayLengthInDeps = (0, createRule_1.createRule)({
|
|
|
302
441
|
allTaken.add(varName);
|
|
303
442
|
}
|
|
304
443
|
}
|
|
305
|
-
// Build
|
|
444
|
+
// Build declaration lines (one per base) that land immediately
|
|
445
|
+
// before the statement consuming the hook, inside the same block
|
|
446
|
+
// as the tracked variable.
|
|
447
|
+
const anchor = findDeclarationAnchor(sourceCode, insertion.statement);
|
|
448
|
+
const indent = getAnchorIndent(sourceCode, anchor);
|
|
306
449
|
let declText = '';
|
|
307
450
|
for (const { member } of lengthDeps) {
|
|
308
451
|
const baseExpr = getBaseExpression(member);
|
|
309
452
|
const baseText = sourceCode.getText(baseExpr);
|
|
310
453
|
if (!declaredBases.has(baseText)) {
|
|
311
454
|
const varName = baseToVar.get(baseText);
|
|
312
|
-
declText += `const ${varName} = useMemo(() => ${hashImportConfig.importName}(${baseText}), [${baseText}]);\n`;
|
|
455
|
+
declText += `const ${varName} = useMemo(() => ${hashImportConfig.importName}(${baseText}), [${baseText}]);\n${indent}`;
|
|
313
456
|
declaredBases.add(baseText);
|
|
314
457
|
}
|
|
315
458
|
}
|
|
316
459
|
if (declText) {
|
|
317
|
-
|
|
318
|
-
declText += `\n`;
|
|
460
|
+
fixes.push(fixer.insertTextBeforeRange(anchor.range, declText));
|
|
319
461
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
? prefixBeforeProgram.slice(lastNewlineIndex + 1)
|
|
329
|
-
: prefixBeforeProgram;
|
|
330
|
-
let importText = '';
|
|
331
|
-
const needUseMemo = !isUseMemoImported(sourceCode);
|
|
332
|
-
const needStableHash = !isStableHashImported(sourceCode, hashImportConfig.source, hashImportConfig.importName);
|
|
333
|
-
if (needUseMemo)
|
|
334
|
-
importText += `${indent}import { useMemo } from 'react';\n`;
|
|
335
|
-
if (needStableHash)
|
|
336
|
-
importText += `${indent}import { ${hashImportConfig.importName} } from '${hashImportConfig.source}';\n`;
|
|
337
|
-
if (importDecls.length === 0) {
|
|
338
|
-
// No existing imports. Normalize by removing leading whitespace and inserting at file start with no indentation.
|
|
339
|
-
if (declText || importText) {
|
|
340
|
-
// Build non-indented versions of import and decl blocks
|
|
341
|
-
let importTextNoIndent = '';
|
|
342
|
-
if (needUseMemo)
|
|
343
|
-
importTextNoIndent += `import { useMemo } from 'react';\n`;
|
|
344
|
-
if (needStableHash)
|
|
345
|
-
importTextNoIndent += `import { ${hashImportConfig.importName} } from '${hashImportConfig.source}';\n`;
|
|
346
|
-
const declNoIndent = declText;
|
|
347
|
-
const combined = `${importTextNoIndent}${importTextNoIndent && declNoIndent ? '\n' : ''}${declNoIndent}`;
|
|
348
|
-
// Remove leading whitespace
|
|
349
|
-
fixes.push(fixer.replaceTextRange([0, program.range[0]], ''));
|
|
350
|
-
// Insert at column 0
|
|
351
|
-
fixes.push(fixer.insertTextBeforeRange([0, 0], combined));
|
|
462
|
+
if (!importsPlanned) {
|
|
463
|
+
const newImportLines = [];
|
|
464
|
+
if (!hasNamedValueImport(sourceCode, 'react', 'useMemo')) {
|
|
465
|
+
const extension = buildImportExtensionFix(fixer, sourceCode, 'react', 'useMemo');
|
|
466
|
+
if (extension)
|
|
467
|
+
fixes.push(extension);
|
|
468
|
+
else
|
|
469
|
+
newImportLines.push(`import { useMemo } from 'react';`);
|
|
352
470
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
fixes.push(fixer.insertTextBefore(firstImport, importText));
|
|
361
|
-
importsPlanned = true;
|
|
471
|
+
if (!hasNamedValueImport(sourceCode, hashImportConfig.source, hashImportConfig.importName)) {
|
|
472
|
+
const extension = buildImportExtensionFix(fixer, sourceCode, hashImportConfig.source, hashImportConfig.importName);
|
|
473
|
+
if (extension)
|
|
474
|
+
fixes.push(extension);
|
|
475
|
+
else {
|
|
476
|
+
newImportLines.push(`import { ${hashImportConfig.importName} } from '${hashImportConfig.source}';`);
|
|
477
|
+
}
|
|
362
478
|
}
|
|
363
|
-
if (
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
.
|
|
368
|
-
|
|
479
|
+
if (newImportLines.length > 0) {
|
|
480
|
+
const importText = `${newImportLines.join('\n')}\n`;
|
|
481
|
+
const firstImport = sourceCode.ast.body.find((n) => n.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
|
|
482
|
+
if (firstImport) {
|
|
483
|
+
fixes.push(fixer.insertTextBefore(firstImport, importText));
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
fixes.push(fixer.insertTextBeforeRange([0, 0], importText));
|
|
487
|
+
}
|
|
369
488
|
}
|
|
489
|
+
importsPlanned = true;
|
|
370
490
|
}
|
|
371
491
|
// Replace each .length dep with the corresponding var name
|
|
372
492
|
for (const { element, member } of lengthDeps) {
|
|
@@ -2,6 +2,7 @@ import { TSESLint } from '@typescript-eslint/utils';
|
|
|
2
2
|
type Options = [
|
|
3
3
|
{
|
|
4
4
|
sideEffectPatterns?: Array<string | RegExp>;
|
|
5
|
+
ignoreTestFiles?: boolean;
|
|
5
6
|
}
|
|
6
7
|
];
|
|
7
8
|
export declare const parallelizeAsyncOperations: TSESLint.RuleModule<"parallelizeAsyncOperations", Options, TSESLint.RuleListener>;
|
|
@@ -3,8 +3,26 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.parallelizeAsyncOperations = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
|
+
// Anchored at the end of the path so multi-part suffixes such as
|
|
7
|
+
// `EventRegistry.integration.test.ts` are recognized while production modules
|
|
8
|
+
// that merely contain the word (`testHelpers.ts`, `latest.ts`, `contest/Thing.ts`)
|
|
9
|
+
// keep their enforcement.
|
|
10
|
+
const TEST_FILE_SUFFIX = /\.(test|spec)\.[cm]?[jt]sx?$/;
|
|
11
|
+
// Jest convention directories hold test-only modules regardless of file name.
|
|
12
|
+
const TEST_FILE_DIRECTORY = /(^|\/)(__tests__|__mocks__)\//;
|
|
13
|
+
/**
|
|
14
|
+
* A test suite serves no requests and is not latency-critical, so the rule's
|
|
15
|
+
* rationale — that sequential awaits make network and I/O latency add up — does
|
|
16
|
+
* not apply to it. Its awaits instead encode ordering: `await` an interaction,
|
|
17
|
+
* then `await` an assertion that observes the DOM state the interaction
|
|
18
|
+
* produced. That dependency is a side effect rather than a value, so it is
|
|
19
|
+
* invisible to the syntactic barriers below, and Promise.all would race the
|
|
20
|
+
* assertion against the interaction (issue #1395).
|
|
21
|
+
*/
|
|
22
|
+
const isTestFile = (filename) => TEST_FILE_SUFFIX.test(filename) || TEST_FILE_DIRECTORY.test(filename);
|
|
6
23
|
const defaultOptions = [
|
|
7
24
|
{
|
|
25
|
+
ignoreTestFiles: true,
|
|
8
26
|
sideEffectPatterns: [
|
|
9
27
|
'updatecounter',
|
|
10
28
|
'setcounter',
|
|
@@ -28,6 +46,15 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
|
|
|
28
46
|
recommended: 'error',
|
|
29
47
|
},
|
|
30
48
|
fixable: 'code',
|
|
49
|
+
// `defaultOptions` above is the single source of truth for defaults; the
|
|
50
|
+
// schema deliberately declares none. ESLint validates rule options with an
|
|
51
|
+
// ajv instance configured `useDefaults: true`, which writes schema defaults
|
|
52
|
+
// INTO the supplied options object before `defaultOptions` are merged. A
|
|
53
|
+
// schema `default: []` on sideEffectPatterns therefore erases the built-in
|
|
54
|
+
// side-effect patterns for any consumer who passes an options object at all
|
|
55
|
+
// -- including one that only sets `ignoreTestFiles` -- so `commit`, `flush`,
|
|
56
|
+
// and the counter patterns stop acting as ordering barriers and the rule
|
|
57
|
+
// reports the very sequences it is meant to leave alone.
|
|
31
58
|
schema: [
|
|
32
59
|
{
|
|
33
60
|
type: 'object',
|
|
@@ -40,7 +67,9 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
|
|
|
40
67
|
{ type: 'object', instanceof: 'RegExp' },
|
|
41
68
|
],
|
|
42
69
|
},
|
|
43
|
-
|
|
70
|
+
},
|
|
71
|
+
ignoreTestFiles: {
|
|
72
|
+
type: 'boolean',
|
|
44
73
|
},
|
|
45
74
|
},
|
|
46
75
|
additionalProperties: false,
|
|
@@ -52,6 +81,14 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
|
|
|
52
81
|
},
|
|
53
82
|
defaultOptions,
|
|
54
83
|
create(context, [options]) {
|
|
84
|
+
// Normalize Windows backslash separators so the forward-slash directory
|
|
85
|
+
// check matches on every platform. Without this, `getFilename()` returns
|
|
86
|
+
// `C:\repo\src\__tests__\Foo.ts` on Windows and the exemption silently
|
|
87
|
+
// fails there.
|
|
88
|
+
const filename = context.getFilename().replace(/\\/g, '/');
|
|
89
|
+
if ((options?.ignoreTestFiles ?? true) && isTestFile(filename)) {
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
55
92
|
const sourceCode = context.sourceCode;
|
|
56
93
|
const sideEffectMatchers = (options?.sideEffectPatterns ?? []).map((pattern) => typeof pattern === 'string' ? new RegExp(pattern, 'i') : pattern);
|
|
57
94
|
const reportedRanges = new Set();
|
|
@@ -1,51 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
-
};
|
|
5
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
3
|
exports.preferCloneDeep = void 0;
|
|
7
|
-
const path_1 = __importDefault(require("path"));
|
|
8
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
9
5
|
const createRule_1 = require("../utils/createRule");
|
|
10
6
|
const CLONE_DEEP_NAME = 'cloneDeep';
|
|
11
7
|
const CLONE_DEEP_MODULE = 'functions/src/util/cloneDeep';
|
|
12
|
-
const CLONE_DEEP_TARGET = 'src/util/cloneDeep';
|
|
13
|
-
const FUNCTIONS_TIER_SEGMENT = '/functions/src/';
|
|
14
|
-
const FUNCTIONS_ROOT_SEGMENT = '/functions/';
|
|
15
8
|
const INDENT_STEP = ' ';
|
|
16
|
-
const toPosixPath = (filePath) => filePath.replace(/\\/g, '/');
|
|
17
|
-
const ensureRelativeSpecifier = (specifier) => specifier.startsWith('.') ? specifier : `./${specifier}`;
|
|
18
|
-
const isWindowsDrivePath = (filePath) => /^[A-Za-z]:[\\/]/.test(filePath);
|
|
19
|
-
const isValidRelativePath = (relativePath) => relativePath !== '' &&
|
|
20
|
-
!path_1.default.isAbsolute(relativePath) &&
|
|
21
|
-
!isWindowsDrivePath(relativePath);
|
|
22
|
-
/**
|
|
23
|
-
* The helper lives in one place but the two TypeScript tiers reach it
|
|
24
|
-
* differently: the root tsconfig maps `functions/*` through `paths`, so files
|
|
25
|
-
* outside `functions/` resolve the bare specifier, while `functions/tsconfig.json`
|
|
26
|
-
* is rooted at `functions/` and declares no `paths`, leaving backend files able
|
|
27
|
-
* to reach a sibling util only by relative path. A single hardcoded specifier
|
|
28
|
-
* therefore emits an unresolvable import for every backend fix (#1389).
|
|
29
|
-
*
|
|
30
|
-
* Returns null when no correct specifier exists, which makes the caller decline
|
|
31
|
-
* the fix rather than write an import that cannot resolve.
|
|
32
|
-
*/
|
|
33
|
-
function buildCloneDeepSpecifier(sourceFilePath, cwd) {
|
|
34
|
-
const absoluteFilename = toPosixPath(path_1.default.isAbsolute(sourceFilePath)
|
|
35
|
-
? sourceFilePath
|
|
36
|
-
: path_1.default.join(cwd, sourceFilePath));
|
|
37
|
-
const tierIndex = absoluteFilename.indexOf(FUNCTIONS_TIER_SEGMENT);
|
|
38
|
-
if (tierIndex === -1) {
|
|
39
|
-
return CLONE_DEEP_MODULE;
|
|
40
|
-
}
|
|
41
|
-
const functionsRoot = absoluteFilename.slice(0, tierIndex + FUNCTIONS_ROOT_SEGMENT.length);
|
|
42
|
-
const targetPath = path_1.default.join(functionsRoot, CLONE_DEEP_TARGET);
|
|
43
|
-
const relativePath = path_1.default.relative(path_1.default.dirname(absoluteFilename), targetPath);
|
|
44
|
-
if (!isValidRelativePath(relativePath)) {
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
|
-
return ensureRelativeSpecifier(toPosixPath(relativePath));
|
|
48
|
-
}
|
|
49
9
|
/**
|
|
50
10
|
* Only BluMint's own `cloneDeep` accepts an overrides argument, so an existing
|
|
51
11
|
* binding coming from anywhere else (notably `lodash`) must not be reused by the
|
|
@@ -84,8 +44,6 @@ exports.preferCloneDeep = (0, createRule_1.createRule)({
|
|
|
84
44
|
// Track processed nodes to avoid duplicate reports
|
|
85
45
|
const processedNodes = new Set();
|
|
86
46
|
const sourceCode = context.sourceCode;
|
|
87
|
-
const cwd = typeof context.getCwd === 'function' ? context.getCwd() : process.cwd();
|
|
88
|
-
const cloneDeepSpecifier = buildCloneDeepSpecifier(context.getFilename(), cwd);
|
|
89
47
|
function normalizedTextOf(node) {
|
|
90
48
|
return sourceCode.getText(node).replace(/\s+/g, '');
|
|
91
49
|
}
|
|
@@ -364,56 +322,45 @@ exports.preferCloneDeep = (0, createRule_1.createRule)({
|
|
|
364
322
|
return targets;
|
|
365
323
|
}
|
|
366
324
|
/**
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
* specifier that
|
|
325
|
+
* Whether the rewritten `cloneDeep(...)` call resolves in this file, which is
|
|
326
|
+
* true only where the file already imports the helper as a value.
|
|
327
|
+
*
|
|
328
|
+
* The specifier that reaches the helper is a property of the consuming
|
|
329
|
+
* project, not of this rule: the module is absent from some consumers
|
|
330
|
+
* entirely, and where it exists the two TypeScript tiers reach it by
|
|
331
|
+
* different forms. Writing an import from a guessed specifier therefore
|
|
332
|
+
* trades working code for a build error (#1396), so an existing import is
|
|
333
|
+
* demanded as proof of a path that resolves here — the same policy
|
|
334
|
+
* `enforce-querykey-ts` applies to its own import.
|
|
335
|
+
*
|
|
336
|
+
* A binding of the name from anywhere else is not proof and must not be
|
|
337
|
+
* reused: `lodash`'s `cloneDeep` accepts no overrides argument, a local
|
|
338
|
+
* declaration would shadow the helper, and a namespace or type-only import
|
|
339
|
+
* supplies no callable value.
|
|
371
340
|
*/
|
|
372
|
-
function
|
|
341
|
+
function bindsCloneDeepHelper(scope) {
|
|
373
342
|
const existing = utils_1.ASTUtils.findVariable(scope, CLONE_DEEP_NAME);
|
|
374
|
-
if (existing) {
|
|
375
|
-
|
|
376
|
-
if (!definition) {
|
|
377
|
-
return null;
|
|
378
|
-
}
|
|
379
|
-
const definitionNode = definition.node;
|
|
380
|
-
if (definitionNode.type !== utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
381
|
-
definitionNode.type !== utils_1.AST_NODE_TYPES.ImportDefaultSpecifier) {
|
|
382
|
-
return null;
|
|
383
|
-
}
|
|
384
|
-
if (definitionNode.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
385
|
-
definitionNode.importKind === 'type') {
|
|
386
|
-
return null;
|
|
387
|
-
}
|
|
388
|
-
const declaration = definitionNode.parent;
|
|
389
|
-
if (!declaration ||
|
|
390
|
-
declaration.type !== utils_1.AST_NODE_TYPES.ImportDeclaration ||
|
|
391
|
-
declaration.importKind === 'type' ||
|
|
392
|
-
!isCloneDeepModule(String(declaration.source.value))) {
|
|
393
|
-
return null;
|
|
394
|
-
}
|
|
395
|
-
return [];
|
|
343
|
+
if (!existing) {
|
|
344
|
+
return false;
|
|
396
345
|
}
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
declaration.specifiers.some((specifier) => specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier));
|
|
401
|
-
if (reusable) {
|
|
402
|
-
const namedSpecifiers = reusable.specifiers.filter((specifier) => specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier);
|
|
403
|
-
const lastSpecifier = namedSpecifiers[namedSpecifiers.length - 1];
|
|
404
|
-
return [fixer.insertTextAfter(lastSpecifier, `, ${CLONE_DEEP_NAME}`)];
|
|
346
|
+
const [definition] = existing.defs;
|
|
347
|
+
if (!definition) {
|
|
348
|
+
return false;
|
|
405
349
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
return
|
|
350
|
+
const definitionNode = definition.node;
|
|
351
|
+
if (definitionNode.type !== utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
352
|
+
definitionNode.type !== utils_1.AST_NODE_TYPES.ImportDefaultSpecifier) {
|
|
353
|
+
return false;
|
|
410
354
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
return [fixer.insertTextBefore(firstImport, importText)];
|
|
355
|
+
if (definitionNode.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
356
|
+
definitionNode.importKind === 'type') {
|
|
357
|
+
return false;
|
|
415
358
|
}
|
|
416
|
-
|
|
359
|
+
const declaration = definitionNode.parent;
|
|
360
|
+
return (!!declaration &&
|
|
361
|
+
declaration.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
|
|
362
|
+
declaration.importKind !== 'type' &&
|
|
363
|
+
isCloneDeepModule(String(declaration.source.value)));
|
|
417
364
|
}
|
|
418
365
|
// Find the outermost object expression that needs cloneDeep
|
|
419
366
|
function findOutermostPartialDeepCopy(node) {
|
|
@@ -468,6 +415,9 @@ exports.preferCloneDeep = (0, createRule_1.createRule)({
|
|
|
468
415
|
node,
|
|
469
416
|
messageId: 'preferCloneDeep',
|
|
470
417
|
fix(fixer) {
|
|
418
|
+
if (!bindsCloneDeepHelper(scope)) {
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
471
421
|
const rewrites = [];
|
|
472
422
|
const targets = startsWithSpread(node)
|
|
473
423
|
? [node]
|
|
@@ -482,11 +432,7 @@ exports.preferCloneDeep = (0, createRule_1.createRule)({
|
|
|
482
432
|
}
|
|
483
433
|
rewrites.push(fixer.replaceText(target, call));
|
|
484
434
|
}
|
|
485
|
-
|
|
486
|
-
if (importFixes === null) {
|
|
487
|
-
return null;
|
|
488
|
-
}
|
|
489
|
-
return [...importFixes, ...rewrites];
|
|
435
|
+
return rewrites;
|
|
490
436
|
},
|
|
491
437
|
});
|
|
492
438
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.RULE_NAME = void 0;
|
|
4
|
+
const utils_1 = require("@typescript-eslint/utils");
|
|
4
5
|
const createRule_1 = require("../utils/createRule");
|
|
5
6
|
exports.RULE_NAME = 'require-dynamic-firebase-imports';
|
|
6
7
|
exports.default = (0, createRule_1.createRule)({
|
|
@@ -23,62 +24,105 @@ exports.default = (0, createRule_1.createRule)({
|
|
|
23
24
|
return (source.startsWith('firebase/') ||
|
|
24
25
|
source.includes('config/firebase-client'));
|
|
25
26
|
};
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
/**
|
|
28
|
+
* `await import()` is only valid inside an async function. Rewriting a
|
|
29
|
+
* module-scope import would introduce top-level await, silently converting
|
|
30
|
+
* a synchronous module into an async one (and failing outright on build
|
|
31
|
+
* targets without top-level await support) — so those sites are reported
|
|
32
|
+
* without a fix.
|
|
33
|
+
*/
|
|
34
|
+
const isInsideAsyncFunction = (node) => {
|
|
35
|
+
let current = node.parent;
|
|
36
|
+
while (current) {
|
|
37
|
+
if (current.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
|
|
38
|
+
current.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
|
|
39
|
+
current.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression) {
|
|
40
|
+
// The nearest enclosing function decides await validity; an async
|
|
41
|
+
// ancestor beyond a sync function cannot host the await.
|
|
42
|
+
return current.async;
|
|
43
|
+
}
|
|
44
|
+
current = current.parent;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
};
|
|
48
|
+
const isTypeOnlySpecifier = (spec) => spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
49
|
+
spec.importKind === 'type';
|
|
50
|
+
const buildDynamicImport = (importSource, valueSpecifiers) => {
|
|
51
|
+
if (valueSpecifiers.length === 0) {
|
|
52
|
+
// Side-effect imports have no bindings to destructure.
|
|
31
53
|
return `await import('${importSource}');`;
|
|
32
54
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
const localName = spec.local.name;
|
|
43
|
-
if (importedName === localName) {
|
|
44
|
-
return `const { ${localName} } = await import('${importSource}');`;
|
|
45
|
-
}
|
|
46
|
-
return `const { ${importedName}: ${localName} } = await import('${importSource}');`;
|
|
55
|
+
const namespaceSpecifier = valueSpecifiers.find((spec) => spec.type === utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier);
|
|
56
|
+
const defaultSpecifier = valueSpecifiers.find((spec) => spec.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier);
|
|
57
|
+
const namedSpecifiers = valueSpecifiers.filter((spec) => spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier);
|
|
58
|
+
if (namespaceSpecifier) {
|
|
59
|
+
// The promise resolved by `import()` IS the module namespace object,
|
|
60
|
+
// so the `* as ns` binding maps directly onto it.
|
|
61
|
+
const namespaceDeclaration = `const ${namespaceSpecifier.local.name} = await import('${importSource}');`;
|
|
62
|
+
if (defaultSpecifier) {
|
|
63
|
+
return `${namespaceDeclaration} const ${defaultSpecifier.local.name} = ${namespaceSpecifier.local.name}.default;`;
|
|
47
64
|
}
|
|
65
|
+
return namespaceDeclaration;
|
|
48
66
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (
|
|
54
|
-
const
|
|
55
|
-
const localName = spec.local.name;
|
|
56
|
-
return importedName === localName
|
|
57
|
-
? localName
|
|
58
|
-
: `${importedName}: ${localName}`;
|
|
67
|
+
const destructuredNames = namedSpecifiers.map((spec) => spec.imported.name === spec.local.name
|
|
68
|
+
? spec.local.name
|
|
69
|
+
: `${spec.imported.name}: ${spec.local.name}`);
|
|
70
|
+
if (defaultSpecifier) {
|
|
71
|
+
if (destructuredNames.length === 0) {
|
|
72
|
+
return `const ${defaultSpecifier.local.name} = (await import('${importSource}')).default;`;
|
|
59
73
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
74
|
+
// The namespace object exposes the default export under `default`.
|
|
75
|
+
return `const { default: ${defaultSpecifier.local.name}, ${destructuredNames.join(', ')} } = await import('${importSource}');`;
|
|
76
|
+
}
|
|
77
|
+
return `const { ${destructuredNames.join(', ')} } = await import('${importSource}');`;
|
|
78
|
+
};
|
|
79
|
+
const buildStaticTypeImport = (importSource, typeSpecifiers) => {
|
|
80
|
+
const names = typeSpecifiers.map((spec) => spec.imported.name === spec.local.name
|
|
81
|
+
? spec.local.name
|
|
82
|
+
: `${spec.imported.name} as ${spec.local.name}`);
|
|
83
|
+
return `import type { ${names.join(', ')} } from '${importSource}';\n`;
|
|
65
84
|
};
|
|
66
85
|
return {
|
|
67
86
|
ImportDeclaration(node) {
|
|
68
87
|
const importSource = node.source.value;
|
|
69
|
-
if (typeof importSource
|
|
70
|
-
isFirebaseImport(importSource)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
88
|
+
if (typeof importSource !== 'string' ||
|
|
89
|
+
!isFirebaseImport(importSource)) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// `import type` statements are erased at compile time, so they add no
|
|
93
|
+
// bundle weight — and a dynamic import cannot supply types anyway.
|
|
94
|
+
if (node.importKind === 'type') {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const typeSpecifiers = node.specifiers.filter(isTypeOnlySpecifier);
|
|
98
|
+
const valueSpecifiers = node.specifiers.filter((spec) => !isTypeOnlySpecifier(spec));
|
|
99
|
+
// Inline `type` markers on every specifier make the whole statement
|
|
100
|
+
// erasable, exactly like `import type` — nothing to report.
|
|
101
|
+
if (node.specifiers.length > 0 && valueSpecifiers.length === 0) {
|
|
102
|
+
return;
|
|
81
103
|
}
|
|
104
|
+
context.report({
|
|
105
|
+
node,
|
|
106
|
+
messageId: 'requireDynamicImport',
|
|
107
|
+
data: { importSource },
|
|
108
|
+
fix(fixer) {
|
|
109
|
+
if (!isInsideAsyncFunction(node)) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const fixes = [
|
|
113
|
+
fixer.replaceText(node, buildDynamicImport(importSource, valueSpecifiers)),
|
|
114
|
+
];
|
|
115
|
+
if (typeSpecifiers.length > 0) {
|
|
116
|
+
// Type specifiers must not travel into the runtime
|
|
117
|
+
// destructuring: they have no runtime value, and dropping the
|
|
118
|
+
// `type` marker turns type references into dangling value
|
|
119
|
+
// bindings. Hoist them into a static `import type` at module
|
|
120
|
+
// scope, which is erased at compile time.
|
|
121
|
+
fixes.push(fixer.insertTextBeforeRange([0, 0], buildStaticTypeImport(importSource, typeSpecifiers)));
|
|
122
|
+
}
|
|
123
|
+
return fixes;
|
|
124
|
+
},
|
|
125
|
+
});
|
|
82
126
|
},
|
|
83
127
|
};
|
|
84
128
|
},
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,48 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.23",
|
|
4
|
+
"date": "2026-07-30T01:37:58.854Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "no-array-length-in-deps",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1398
|
|
11
|
+
],
|
|
12
|
+
"summary": "insert generated memo in the variable's own scope (closes #1398)"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "require-dynamic-firebase-imports",
|
|
16
|
+
"changeType": "fix",
|
|
17
|
+
"issues": [
|
|
18
|
+
1399
|
|
19
|
+
],
|
|
20
|
+
"summary": "keep type-only specifiers out of the runtime import (closes #1399)"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"version": "1.20.22",
|
|
26
|
+
"date": "2026-07-30T00:39:18.760Z",
|
|
27
|
+
"rules": [
|
|
28
|
+
{
|
|
29
|
+
"name": "parallelize-async-operations",
|
|
30
|
+
"changeType": "fix",
|
|
31
|
+
"issues": [
|
|
32
|
+
1395
|
|
33
|
+
],
|
|
34
|
+
"summary": "exempt test files and stop schema defaults erasing barriers (closes #1395)"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "prefer-clone-deep",
|
|
38
|
+
"changeType": "fix",
|
|
39
|
+
"issues": [
|
|
40
|
+
1396
|
|
41
|
+
],
|
|
42
|
+
"summary": "only autofix when the file already imports the helper (closes #1396)"
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
},
|
|
2
46
|
{
|
|
3
47
|
"version": "1.20.21",
|
|
4
48
|
"date": "2026-07-29T23:50:58.620Z",
|