@blumintinc/eslint-plugin-blumint 1.20.86 → 1.20.87

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.86',
226
+ version: '1.20.87',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -10,6 +10,7 @@ const utils_1 = require("@typescript-eslint/utils");
10
10
  const createRule_1 = require("../utils/createRule");
11
11
  const ASTHelpers_1 = require("../utils/ASTHelpers");
12
12
  const disableDirectives_1 = require("../utils/disableDirectives");
13
+ const importInsertion_1 = require("../utils/importInsertion");
13
14
  const DEFAULT_IMPORT_PATH = 'functions/src/util/assertSafe';
14
15
  const ASSERT_SAFE_NAME = 'assertSafe';
15
16
  /**
@@ -327,18 +328,15 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
327
328
  isAssertSafeModule(declaration.source.value));
328
329
  });
329
330
  /**
330
- * Helper function to add assertSafe import if needed
331
+ * Emits the `import { assertSafe }` statement the wrapped call needs. The
332
+ * position comes from the shared anchor so the file's prologue keeps its
333
+ * meaning: a `'use client'` directive stops being a directive the moment a
334
+ * statement precedes it, and a `#!` shebang stops parsing once it leaves
335
+ * character 0.
331
336
  */
332
337
  const addAssertSafeImport = (fixer) => {
333
- const program = context.sourceCode.ast;
334
- const firstImport = program.body.find((node) => node.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
335
338
  const importStatement = `import { assertSafe } from '${computeImportSpecifier()}';\n`;
336
- if (firstImport) {
337
- return fixer.insertTextBefore(firstImport, importStatement);
338
- }
339
- else {
340
- return fixer.insertTextBefore(program.body[0], importStatement);
341
- }
339
+ return (0, importInsertion_1.insertAtImportAnchor)(context.sourceCode, fixer, (0, importInsertion_1.importInsertionAnchor)(context.sourceCode), importStatement);
342
340
  };
343
341
  /**
344
342
  * Helper function to create fixes for a node
@@ -5,6 +5,7 @@ const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
6
  const ASTHelpers_1 = require("../utils/ASTHelpers");
7
7
  const disableDirectives_1 = require("../utils/disableDirectives");
8
+ const importInsertion_1 = require("../utils/importInsertion");
8
9
  const MEMOIZE_MODULE = '@blumintinc/typescript-memoize';
9
10
  const ALLOWED_MEMOIZE_MODULES = new Set([MEMOIZE_MODULE, 'typescript-memoize']);
10
11
  const MEMOIZE_NAME = 'Memoize';
@@ -342,22 +343,13 @@ exports.enforceMemoizeAsync = (0, createRule_1.createRule)({
342
343
  if (!hasMemoizeImport &&
343
344
  memoizeNamespaces.size === 0 &&
344
345
  !scheduledImportFix) {
345
- const programBody = sourceCode.ast.body;
346
- const firstImport = programBody.find((n) => n.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
347
- const anchorNode = (firstImport ?? programBody[0]);
348
- if (anchorNode) {
349
- const text = sourceCode.text;
350
- const anchorStart = anchorNode.range[0];
351
- const lineStart = text.lastIndexOf('\n', anchorStart - 1) + 1;
352
- const leadingWhitespace = text.slice(lineStart, anchorStart).match(/^[ \t]*/)?.[0] ??
353
- '';
354
- const importLine = `${leadingWhitespace}${importStatement}\n`;
355
- fixes.push(fixer.insertTextBeforeRange([lineStart, lineStart], importLine));
356
- }
357
- else {
358
- // Fallback: empty file
359
- fixes.push(fixer.insertTextBeforeRange([0, 0], `import { Memoize } from '${MEMOIZE_MODULE}';\n`));
360
- }
346
+ // The shared anchor keeps the import below whatever governs the
347
+ // top of the file a `'use client'` directive that must stay the
348
+ // first statement, a `#!` shebang that must stay at character 0,
349
+ // a header comment — while still placing it above the first
350
+ // existing import.
351
+ const anchor = (0, importInsertion_1.importInsertionAnchor)(sourceCode);
352
+ fixes.push((0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, (0, importInsertion_1.importAnchorLineStart)(sourceCode, anchor), `${(0, importInsertion_1.importAnchorIndent)(sourceCode, anchor)}${importStatement}\n`));
361
353
  scheduledImportFix = true;
362
354
  }
363
355
  // Add decorator for this method
@@ -6,6 +6,7 @@ const visitor_keys_1 = require("@typescript-eslint/visitor-keys");
6
6
  const createRule_1 = require("../utils/createRule");
7
7
  const ASTHelpers_1 = require("../utils/ASTHelpers");
8
8
  const disableDirectives_1 = require("../utils/disableDirectives");
9
+ const importInsertion_1 = require("../utils/importInsertion");
9
10
  const MEMOIZE_PREFERRED_MODULE = '@blumintinc/typescript-memoize';
10
11
  const MEMOIZE_MODULES = new Set([
11
12
  MEMOIZE_PREFERRED_MODULE,
@@ -473,23 +474,18 @@ exports.enforceMemoizeGetters = (0, createRule_1.createRule)({
473
474
  return null;
474
475
  }
475
476
  }
476
- // Insert import if needed, at the top alongside other imports
477
+ // Insert import if needed, at the top alongside other imports.
478
+ // The shared anchor keeps the edit below the file's prologue: a
479
+ // `'use client'` directive demoted to a plain expression statement
480
+ // and a shebang displaced off character 0 both change what the file
481
+ // means, and neither is recoverable from the emitted decorator.
477
482
  if (!hasMemoizeImport && !scheduledImportFix) {
478
- const programBody = sourceCode.ast.body;
479
- const firstImport = programBody.find((n) => n.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
480
- const anchorNode = (firstImport ?? programBody[0]);
481
- if (anchorNode) {
482
- const text = sourceCode.text;
483
- const anchorStart = anchorNode.range[0];
484
- const lineStart = text.lastIndexOf('\n', anchorStart - 1) + 1;
485
- const leadingWhitespace = text.slice(lineStart, anchorStart).match(/^[ \t]*/)?.[0] ??
486
- '';
487
- const importLine = `${leadingWhitespace}import { Memoize } from '${MEMOIZE_PREFERRED_MODULE}';\n`;
488
- fixes.push(fixer.insertTextBeforeRange([lineStart, lineStart], importLine));
489
- }
490
- else {
491
- fixes.push(fixer.insertTextBeforeRange([0, 0], `import { Memoize } from '${MEMOIZE_PREFERRED_MODULE}';\n`));
492
- }
483
+ const anchor = (0, importInsertion_1.importInsertionAnchor)(sourceCode);
484
+ // Indent comes from the anchor's own line, so the import lines up
485
+ // with the statement it displaces; widening to the line start
486
+ // then leaves that statement's indentation intact.
487
+ const indent = (0, importInsertion_1.importAnchorIndent)(sourceCode, anchor);
488
+ fixes.push((0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, (0, importInsertion_1.importAnchorLineStart)(sourceCode, anchor), `${indent}import { Memoize } from '${MEMOIZE_PREFERRED_MODULE}';\n`));
493
489
  scheduledImportFix = true;
494
490
  }
495
491
  // Insert decorator above the getter (or before the first decorator), preserving indentation
@@ -4,6 +4,7 @@ exports.enforceMicrodiff = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
6
  const ASTHelpers_1 = require("../utils/ASTHelpers");
7
+ const importInsertion_1 = require("../utils/importInsertion");
7
8
  const DIFF_NAME = 'diff';
8
9
  /**
9
10
  * The package the fix imports from. BluMint's fork is the dependency this
@@ -334,12 +335,24 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
334
335
  * the reported node, because the reported node is rarely at module scope: a
335
336
  * comparison inside a function body, or a function behind an `export`,
336
337
  * would otherwise take the import somewhere the grammar forbids it.
338
+ *
339
+ * The shared anchor decides where the prologue ends. Splicing at character
340
+ * 0 put the import above a `'use client'` directive, which demotes it to an
341
+ * ordinary expression statement, and above a `#!` shebang, which has to sit
342
+ * at character 0 for the file to parse at all.
337
343
  */
338
344
  function buildMicrodiffImportFix(fixer) {
339
345
  if (findMicrodiffImport(sourceCode.ast)) {
340
346
  return null;
341
347
  }
342
- return fixer.insertTextBeforeRange([0, 0], `${MICRODIFF_IMPORT}\n\n`);
348
+ const anchor = (0, importInsertion_1.importInsertionAnchor)(sourceCode);
349
+ // A blank line separates the import from the code below it, but not from
350
+ // another import: an import block split in two reads as two groups.
351
+ const separator = anchor.kind === 'before' &&
352
+ anchor.target.type === utils_1.AST_NODE_TYPES.ImportDeclaration
353
+ ? '\n'
354
+ : '\n\n';
355
+ return (0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, anchor, `${MICRODIFF_IMPORT}${separator}`);
343
356
  }
344
357
  // Add a specific set to track which import names are used
345
358
  const usedImportNames = new Set();
@@ -8,6 +8,7 @@ const path_1 = __importDefault(require("path"));
8
8
  const utils_1 = require("@typescript-eslint/utils");
9
9
  const createRule_1 = require("../utils/createRule");
10
10
  const disableDirectives_1 = require("../utils/disableDirectives");
11
+ const importInsertion_1 = require("../utils/importInsertion");
11
12
  // The module's path below the project root doubles as the bare specifier,
12
13
  // which is precisely why the root tsconfig `paths` and the Jest mapper resolve
13
14
  // it.
@@ -245,14 +246,29 @@ exports.enforceQueryKeyTs = (0, createRule_1.createRule)({
245
246
  return null;
246
247
  }
247
248
  const importText = `import { ${constants.join(', ')} } from '${source}';\n`;
248
- const [firstImport] = importDeclarations;
249
- if (firstImport) {
250
- return fixer.insertTextBefore(firstImport, importText);
249
+ const anchor = (0, importInsertion_1.importInsertionAnchor)(sourceCode);
250
+ if (importDeclarations.length) {
251
+ // The statement joins an import block, and the anchor is that block's
252
+ // first declaration (or a suppression comment bound to it), so it lands
253
+ // among its siblings with nothing above them displaced.
254
+ return (0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, anchor, importText);
251
255
  }
252
- // Keep the import visually separated from the code it precedes unless the
253
- // file already opens with a blank line.
254
- const separator = /^\r?\n/.test(sourceCode.text) ? '' : '\n';
255
- return fixer.insertTextBeforeRange([0, 0], `${importText}${separator}`);
256
+ // A file's first import opens the file, so it may cross the blank lines
257
+ // the source starts with. The anchor is the floor of that climb: a
258
+ // `'use client'` directive stops being a directive, a `#!` shebang stops
259
+ // parsing, and a header comment stops covering its subject the moment a
260
+ // statement precedes them, so only whitespace may be crossed.
261
+ const anchorIndex = anchor.kind === 'before' ? anchor.target.range[0] : anchor.index;
262
+ const opensFile = sourceCode.text.slice(0, anchorIndex).trim() === '';
263
+ const insertion = opensFile
264
+ ? { kind: 'index', index: 0 }
265
+ : anchor;
266
+ // Keep the import visually separated from the code it precedes unless a
267
+ // blank line already sits at the insertion point.
268
+ const separator = /^\r?\n/.test(sourceCode.text.slice(opensFile ? 0 : anchorIndex))
269
+ ? ''
270
+ : '\n';
271
+ return (0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, insertion, `${importText}${separator}`);
256
272
  }
257
273
  function flushReports() {
258
274
  const resolutions = new Map();
@@ -4,6 +4,7 @@ exports.enforceStableStringify = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
6
  const ASTHelpers_1 = require("../utils/ASTHelpers");
7
+ const importInsertion_1 = require("../utils/importInsertion");
7
8
  const STRINGIFY_MODULE = 'safe-stable-stringify';
8
9
  const STRINGIFY_NAME = 'stringify';
9
10
  /**
@@ -102,9 +103,12 @@ exports.enforceStableStringify = (0, createRule_1.createRule)({
102
103
  // application self-contained (the re-lint suppresses a
103
104
  // duplicate for later call sites).
104
105
  if (!importsStringify(program)) {
105
- const firstImport = program.body.find((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
106
- const importStatement = "import stringify from 'safe-stable-stringify';\n";
107
- fixes.push(fixer.insertTextBefore(firstImport ?? program.body[0], importStatement));
106
+ // Anchor through the shared helper so the import lands
107
+ // below the file's prologue: splicing it above a
108
+ // `'use client'` directive demotes the directive to a plain
109
+ // expression, and above a `#!` shebang leaves the file
110
+ // unparseable.
111
+ fixes.push((0, importInsertion_1.insertAtImportAnchor)(context.sourceCode, fixer, (0, importInsertion_1.importInsertionAnchor)(context.sourceCode), "import stringify from 'safe-stable-stringify';\n"));
108
112
  }
109
113
  fixes.push(fixer.replaceText(node, STRINGIFY_NAME));
110
114
  return fixes;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.enforceSnapshotStateNarrowing = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
+ const importInsertion_1 = require("../utils/importInsertion");
6
7
  const DEFAULT_SNAPSHOT_HOOKS = [
7
8
  'useDocSnapshot',
8
9
  'useCollectionSnapshot',
@@ -216,6 +217,7 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
216
217
  * otherwise add the canonical import statement.
217
218
  */
218
219
  function buildImportFix(fixer) {
220
+ const sourceCode = context.getSourceCode();
219
221
  const declarations = importDeclarationsOf();
220
222
  const guardDeclarations = declarations.filter((declaration) => isGuardModule(String(declaration.source.value)));
221
223
  const reusable = guardDeclarations.find((declaration) => declaration.importKind !== 'type' &&
@@ -231,14 +233,29 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
231
233
  ? String(guardDeclarations[0].source.value)
232
234
  : guardImportSource;
233
235
  const importText = `import { ${guardName} } from '${source}';\n`;
234
- const [firstImport] = declarations;
235
- if (firstImport) {
236
- return fixer.insertTextBefore(firstImport, importText);
236
+ const anchor = (0, importInsertion_1.importInsertionAnchor)(sourceCode);
237
+ if (declarations.length) {
238
+ // The statement joins an import block, and the anchor is that block's
239
+ // first declaration (or a suppression comment bound to it), so it lands
240
+ // among its siblings with nothing above them displaced.
241
+ return (0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, anchor, importText);
237
242
  }
238
- // Keep the import visually separated from the code it precedes unless the
239
- // file already opens with a blank line.
240
- const separator = /^\r?\n/.test(context.getSourceCode().text) ? '' : '\n';
241
- return fixer.insertTextBeforeRange([0, 0], `${importText}${separator}`);
243
+ // A file's first import opens the file, so it may cross the blank lines
244
+ // the source starts with. The anchor is the floor of that climb: a
245
+ // `'use client'` directive stops being a directive, a `#!` shebang stops
246
+ // parsing, and a header comment stops covering its subject the moment a
247
+ // statement precedes them, so only whitespace may be crossed.
248
+ const anchorIndex = anchor.kind === 'before' ? anchor.target.range[0] : anchor.index;
249
+ const opensFile = sourceCode.text.slice(0, anchorIndex).trim() === '';
250
+ const insertion = opensFile
251
+ ? { kind: 'index', index: 0 }
252
+ : anchor;
253
+ // Keep the import visually separated from the code it precedes unless a
254
+ // blank line already sits at the insertion point.
255
+ const separator = /^\r?\n/.test(sourceCode.text.slice(opensFile ? 0 : anchorIndex))
256
+ ? ''
257
+ : '\n';
258
+ return (0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, insertion, `${importText}${separator}`);
242
259
  }
243
260
  /**
244
261
  * Builds the single suggestion shared by every report: swap the flagged
@@ -5,6 +5,7 @@ const utils_1 = require("@typescript-eslint/utils");
5
5
  const ASTHelpers_1 = require("../utils/ASTHelpers");
6
6
  const createRule_1 = require("../utils/createRule");
7
7
  const disableDirectives_1 = require("../utils/disableDirectives");
8
+ const importInsertion_1 = require("../utils/importInsertion");
8
9
  const DEFAULT_HASH_IMPORT = {
9
10
  source: 'functions/src/util/hash/stableHash',
10
11
  importName: 'stableHash',
@@ -366,13 +367,23 @@ exports.enforceStableHashSpreadProps = (0, createRule_1.createRule)({
366
367
  if (!isStableHashImported(sourceCode, hashImport) &&
367
368
  !importPlanned) {
368
369
  const importText = `import { ${hashImport.importName} } from '${hashImport.source}';\n`;
369
- const program = sourceCode.ast;
370
- const firstImport = program.body.find((n) => n.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
370
+ const firstImport = sourceCode.ast.body.find((n) => n.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
371
371
  if (firstImport) {
372
372
  fixes.push(fixer.insertTextBefore(firstImport, importText));
373
373
  }
374
374
  else {
375
- fixes.push(fixer.insertTextBeforeRange([0, 0], importText));
375
+ // A file's first import may cross only the whitespace the
376
+ // source opens with. The shared anchor is the floor of that
377
+ // climb: text spliced above a `#!` shebang leaves the file
378
+ // unparseable, and text above a `'use client'` directive or a
379
+ // header comment strips the prologue of the meaning it
380
+ // carries only while it leads.
381
+ const anchor = (0, importInsertion_1.importInsertionAnchor)(sourceCode);
382
+ const anchorIndex = anchor.kind === 'before'
383
+ ? anchor.target.range[0]
384
+ : anchor.index;
385
+ const opensFile = sourceCode.text.slice(0, anchorIndex).trim() === '';
386
+ fixes.push((0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, opensFile ? { kind: 'index', index: 0 } : anchor, importText));
376
387
  }
377
388
  importPlanned = true;
378
389
  }
@@ -1430,6 +1430,63 @@ function applyMove(body, fromIndex, toIndex) {
1430
1430
  next.splice(toIndex < fromIndex ? toIndex : toIndex - 1, 0, moved);
1431
1431
  return next;
1432
1432
  }
1433
+ /**
1434
+ * A statement whose own value comes straight from an `await`, matching how
1435
+ * `parallelize-async-operations` recognizes the members of a sequential-await run:
1436
+ * an expression statement that *is* an await, or a declaration whose initializer *is*
1437
+ * an await. An await buried deeper (`const x = (await f()).y`) is deliberately not
1438
+ * counted — that rule does not group it either, so protecting it would cost autofixes
1439
+ * for no gain.
1440
+ */
1441
+ function isAwaitBearingStatement(statement) {
1442
+ if (statement.type === utils_1.AST_NODE_TYPES.ExpressionStatement) {
1443
+ return statement.expression.type === utils_1.AST_NODE_TYPES.AwaitExpression;
1444
+ }
1445
+ if (statement.type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
1446
+ return statement.declarations.some((declaration) => declaration.init?.type === utils_1.AST_NODE_TYPES.AwaitExpression);
1447
+ }
1448
+ return false;
1449
+ }
1450
+ /**
1451
+ * Maximal stretches of two or more adjacent await-bearing statements.
1452
+ *
1453
+ * Adjacency is the entire input to `parallelize-async-operations`: a single unrelated
1454
+ * statement dropped between two sequential awaits ends the run and silences that rule
1455
+ * outright. A run of one is not protected because no such rule exists for it.
1456
+ */
1457
+ function collectAwaitRuns(body) {
1458
+ const runs = [];
1459
+ let run = [];
1460
+ for (const statement of body) {
1461
+ if (isAwaitBearingStatement(statement)) {
1462
+ run.push(statement);
1463
+ continue;
1464
+ }
1465
+ if (run.length >= 2) {
1466
+ runs.push(run);
1467
+ }
1468
+ run = [];
1469
+ }
1470
+ if (run.length >= 2) {
1471
+ runs.push(run);
1472
+ }
1473
+ return runs;
1474
+ }
1475
+ /**
1476
+ * Whether a candidate ordering leaves every await run contiguous and internally
1477
+ * ordered as it was.
1478
+ *
1479
+ * Relative order matters as much as contiguity: `parallelize-async-operations` anchors
1480
+ * its report and its `Promise.all` rewrite on the run's first await, so permuting the
1481
+ * run relocates the transform even when the awaits stay adjacent.
1482
+ */
1483
+ function preservesAwaitRuns(order, runs) {
1484
+ return runs.every((run) => {
1485
+ const start = order.indexOf(run[0]);
1486
+ return (start !== -1 &&
1487
+ run.every((statement, offset) => order[start + offset] === statement));
1488
+ });
1489
+ }
1433
1490
  /**
1434
1491
  * Upper bound on candidate moves expanded per search node. Each candidate costs one
1435
1492
  * full detection pass, so a node offering more violations than this has its tail
@@ -1486,11 +1543,18 @@ function orderKey(order, indices) {
1486
1543
  * can still be the only route to a clean order, so the frontier is bounded by move
1487
1544
  * count and detection budget rather than by any notion of progress. Only the
1488
1545
  * zero-violation goal test decides whether a fix is emitted at all.
1546
+ *
1547
+ * The one hard constraint on the search is the block's await runs. Splitting a run of
1548
+ * sequential awaits destroys `parallelize-async-operations`' `Promise.all` rewrite,
1549
+ * and that rewrite carries a latency win this reordering does not — so orders that
1550
+ * break a run are not candidates at all, however clean they score. A block whose only
1551
+ * clean orders break a run is reported without a fix (#1651).
1489
1552
  */
1490
1553
  function findResolvingMoves(sourceCode, body, violations, maxMoves) {
1491
1554
  if (maxMoves === 0) {
1492
1555
  return null;
1493
1556
  }
1557
+ const awaitRuns = collectAwaitRuns(body);
1494
1558
  const indices = new Map(body.map((statement, index) => [statement, index]));
1495
1559
  const seen = new Set([orderKey(body, indices)]);
1496
1560
  const queue = [{ order: body, violations, moves: [] }];
@@ -1507,6 +1571,12 @@ function findResolvingMoves(sourceCode, body, violations, maxMoves) {
1507
1571
  }
1508
1572
  const { fromIndex, toIndex } = node.violations[candidate];
1509
1573
  const order = applyMove(node.order, fromIndex, toIndex);
1574
+ // Checked before the budget is charged: the test is a couple of array lookups,
1575
+ // and dropping the order here prunes the frontier as well as the goal, so no
1576
+ // route to a clean order passes through a broken run.
1577
+ if (!preservesAwaitRuns(order, awaitRuns)) {
1578
+ continue;
1579
+ }
1510
1580
  const key = orderKey(order, indices);
1511
1581
  if (seen.has(key)) {
1512
1582
  continue;
@@ -5,6 +5,7 @@ const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
6
  const ASTHelpers_1 = require("../utils/ASTHelpers");
7
7
  const disableDirectives_1 = require("../utils/disableDirectives");
8
+ const importInsertion_1 = require("../utils/importInsertion");
8
9
  // React hooks to check
9
10
  const HOOK_NAMES = new Set(['useEffect', 'useCallback', 'useMemo']);
10
11
  const REACT_MODULE = 'react';
@@ -561,7 +562,18 @@ exports.noArrayLengthInDeps = (0, createRule_1.createRule)({
561
562
  fixes.push(fixer.insertTextBefore(firstImport, importText));
562
563
  }
563
564
  else {
564
- fixes.push(fixer.insertTextBeforeRange([0, 0], importText));
565
+ // A file's first import may cross only the whitespace the
566
+ // source opens with. The shared anchor is the floor of that
567
+ // climb: text spliced above a `#!` shebang leaves the file
568
+ // unparseable, and text above a `'use client'` directive or
569
+ // a header comment strips the prologue of the meaning it
570
+ // carries only while it leads.
571
+ const anchor = (0, importInsertion_1.importInsertionAnchor)(sourceCode);
572
+ const anchorIndex = anchor.kind === 'before'
573
+ ? anchor.target.range[0]
574
+ : anchor.index;
575
+ const opensFile = sourceCode.text.slice(0, anchorIndex).trim() === '';
576
+ fixes.push((0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, opensFile ? { kind: 'index', index: 0 } : anchor, importText));
565
577
  }
566
578
  }
567
579
  importsPlanned = true;
@@ -5,6 +5,7 @@ const utils_1 = require("@typescript-eslint/utils");
5
5
  const minimatch_1 = require("minimatch");
6
6
  const ASTHelpers_1 = require("../utils/ASTHelpers");
7
7
  const createRule_1 = require("../utils/createRule");
8
+ const importRemoval_1 = require("../utils/importRemoval");
8
9
  const DEFAULT_TEST_PATTERNS = ['**/__tests__/**', '**/*.test.*', '**/*.spec.*'];
9
10
  const PREFER_UTILITY_FUNCTION_MESSAGE = [
10
11
  'What\'s wrong: "{{name}}" uses useCallback([]) but never reads component/hook state',
@@ -463,6 +464,34 @@ function dedentedCallbackText(sourceCode, callback, hoistTarget) {
463
464
  })
464
465
  .join('\n');
465
466
  }
467
+ /**
468
+ * The imports the hoist leaves bound to nothing, or `null` when none can be
469
+ * unbound safely.
470
+ *
471
+ * The hoist is not a deletion: the declared identifier and the callback text
472
+ * are re-emitted at module scope, so every reference inside them outlives the
473
+ * fix. Only the wrapper disappears — the `const` keyword, the ` = hook(`
474
+ * between the identifier and the callback, and the trailing `, []);`. Orphan-
475
+ * hood is judged against those three slices alone, which keeps a hook call
476
+ * nested in the callback body counted as a live reference (unwrapping the
477
+ * outer call carries the inner one along verbatim).
478
+ *
479
+ * A member callee (`React.useCallback`) is left alone. Its object is usually
480
+ * the JSX pragma, and under the classic runtime JSX consumes that binding
481
+ * through a transform no scope analysis records — a use this cannot see, so it
482
+ * must not delete it.
483
+ */
484
+ function planHoistImportRemoval(sourceCode, callExpression, declarator, callback, idRangeEnd, removal) {
485
+ if (callExpression.callee.type !== utils_1.AST_NODE_TYPES.Identifier) {
486
+ return null;
487
+ }
488
+ const deleted = [
489
+ [removal[0], declarator.id.range[0]],
490
+ [idRangeEnd, callback.range[0]],
491
+ [callback.range[1], removal[1]],
492
+ ];
493
+ return (0, importRemoval_1.planOrphanedImportRemoval)(sourceCode, deleted);
494
+ }
466
495
  function buildHoistFixes(context, callExpression, callback, hoistedIdentifierCache) {
467
496
  if (!callExpression.parent ||
468
497
  callExpression.parent.type !== utils_1.AST_NODE_TYPES.VariableDeclarator) {
@@ -536,9 +565,16 @@ function buildHoistFixes(context, callExpression, callback, hoistedIdentifierCac
536
565
  if (hasOnlyIndentBefore && hasOnlyWhitespaceAfter && lineEnd !== -1) {
537
566
  removeEnd = lineEnd + 1;
538
567
  }
568
+ // The hook import the call was the last consumer of goes with the hoist, in
569
+ // the same fix: applying the hoist alone trades this rule's report for an
570
+ // unused-import one, and nothing re-reports that debt once the hoist has
571
+ // resolved the original violation. An unremovable binding costs only the
572
+ // unused import — the hoist is the fix's value and is kept regardless.
573
+ const importRanges = planHoistImportRemoval(sourceCode, callExpression, declarator, callback, idRangeEnd, [removeStart, removeEnd]) ?? [];
539
574
  return (fixer) => [
540
575
  fixer.insertTextBefore(hoistTarget, hoisted),
541
576
  fixer.removeRange([removeStart, removeEnd]),
577
+ ...importRanges.map((range) => fixer.removeRange([range[0], range[1]])),
542
578
  ];
543
579
  }
544
580
  exports.noEmptyDependencyUseCallbacks = (0, createRule_1.createRule)({