@blumintinc/eslint-plugin-blumint 1.20.22 → 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.22',
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) {
@@ -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.22",
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,26 @@
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
+ },
2
24
  {
3
25
  "version": "1.20.22",
4
26
  "date": "2026-07-30T00:39:18.760Z",