@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 CHANGED
@@ -223,7 +223,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
223
223
  module.exports = {
224
224
  meta: {
225
225
  name: '@blumintinc/eslint-plugin-blumint',
226
- version: '1.20.21',
226
+ version: '1.20.23',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -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 isUseMemoImported(sourceCode) {
109
- const program = sourceCode.ast;
110
- for (const node of program.body) {
111
- if (node.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
112
- node.source.value === 'react') {
113
- for (const spec of node.specifiers) {
114
- if (spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
115
- spec.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
116
- spec.imported.name === 'useMemo') {
117
- return true;
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 perFuncDeclaredBases = new WeakMap();
243
- const perFuncBaseToVar = new WeakMap();
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 hostFn = findEnclosingFunction(node) ?? sourceCode.ast;
288
- const declaredBases = ensureWeakMapEntry(perFuncDeclaredBases, hostFn, () => new Set());
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 declarations text (one per base)
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
- // Add a blank line after declarations block
318
- declText += `\n`;
460
+ fixes.push(fixer.insertTextBeforeRange(anchor.range, declText));
319
461
  }
320
- // Determine import text and insertion strategy
321
- const program = sourceCode.ast;
322
- const importDecls = program.body.filter((n) => n.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
323
- // Compute indentation based on the whitespace before the first token
324
- const fullText = sourceCode.getText();
325
- const prefixBeforeProgram = fullText.slice(0, program.range[0]);
326
- const lastNewlineIndex = prefixBeforeProgram.lastIndexOf('\n');
327
- const indent = lastNewlineIndex >= 0
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
- importsPlanned = true;
354
- }
355
- else {
356
- // Existing imports present: insert missing import lines before the first import, and declarations after the last import
357
- const firstImport = importDecls[0];
358
- const lastImport = importDecls[importDecls.length - 1];
359
- if (importText && !importsPlanned) {
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 (declText) {
364
- const declWithIndent = declText
365
- .split('\n')
366
- .map((line) => (line ? `${indent}${line}` : line))
367
- .join('\n');
368
- fixes.push(fixer.insertTextAfter(lastImport, `\n${declWithIndent}`));
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
- default: [],
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
- * Returns the fixes required for `cloneDeep` to resolve, an empty list when
368
- * it already does, or null when a conflicting binding of that name exists —
369
- * shadowing it would silently call something else — or when no import
370
- * specifier that resolves from this file can be derived.
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 buildImportFixes(fixer, scope) {
341
+ function bindsCloneDeepHelper(scope) {
373
342
  const existing = utils_1.ASTUtils.findVariable(scope, CLONE_DEEP_NAME);
374
- if (existing) {
375
- const [definition] = existing.defs;
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 importDeclarations = sourceCode.ast.body.filter((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
398
- const reusable = importDeclarations.find((declaration) => declaration.importKind !== 'type' &&
399
- isCloneDeepModule(String(declaration.source.value)) &&
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
- // Reusing an existing import needs no specifier of its own, so only a
407
- // freshly written import depends on one being derivable.
408
- if (cloneDeepSpecifier === null) {
409
- return null;
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
- const importText = `import { ${CLONE_DEEP_NAME} } from '${cloneDeepSpecifier}';\n`;
412
- const [firstImport] = importDeclarations;
413
- if (firstImport) {
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
- return [fixer.insertTextBeforeRange([0, 0], importText)];
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
- const importFixes = buildImportFixes(fixer, scope);
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
- const createDynamicImport = (node) => {
27
- const importSource = node.source.value;
28
- const importSpecifiers = node.specifiers;
29
- if (importSpecifiers.length === 0) {
30
- // For side-effect imports like 'firebase/auth'
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
- if (importSpecifiers.length === 1) {
34
- const spec = importSpecifiers[0];
35
- if (spec.type === 'ImportDefaultSpecifier') {
36
- // For default imports
37
- return `const ${spec.local.name} = (await import('${importSource}')).default;`;
38
- }
39
- if (spec.type === 'ImportSpecifier') {
40
- // For single named import
41
- const importedName = spec.imported.name;
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
- // For multiple named imports
50
- const importedModule = `await import('${importSource}')`;
51
- const namedImports = importSpecifiers
52
- .map((spec) => {
53
- if (spec.type === 'ImportSpecifier') {
54
- const importedName = spec.imported.name;
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
- return '';
61
- })
62
- .filter(Boolean)
63
- .join(', ');
64
- return `const { ${namedImports} } = ${importedModule};`;
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 === 'string' &&
70
- isFirebaseImport(importSource) &&
71
- !node.importKind?.includes('type')) {
72
- context.report({
73
- node,
74
- messageId: 'requireDynamicImport',
75
- data: { importSource },
76
- fix(fixer) {
77
- const dynamicImport = createDynamicImport(node);
78
- return fixer.replaceText(node, dynamicImport);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.21",
3
+ "version": "1.20.23",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -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",