@blumintinc/eslint-plugin-blumint 1.20.170 → 1.20.171

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.170',
226
+ version: '1.20.171',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -640,6 +640,47 @@ function foldKeyDomains(domains) {
640
640
  }
641
641
  return values;
642
642
  }
643
+ /**
644
+ * The span of the access a written key belongs to: `obj[key]` including the
645
+ * object and both brackets, `[key]` for a computed property (whose value is an
646
+ * expression of its own), and the whole comparison for `key in obj`. Null where
647
+ * the key sits in none of those, which leaves the wrap to span the key alone.
648
+ *
649
+ * This is the unit a fix that rewrites a key has to claim. The key's own range
650
+ * stops short of the `]` that closes the access, and a fixer that reformats the
651
+ * access spreads its edits across the whole of it — so a span ending between
652
+ * the key and its bracket splits that set in half. Claiming the access instead
653
+ * leaves a competing rewrite of it either wholly discarded (and re-made against
654
+ * the fixed text on a later pass) or wholly applied, both of which parse.
655
+ */
656
+ function accessSpan(sourceCode, written) {
657
+ const { parent } = written;
658
+ if (!parent) {
659
+ return null;
660
+ }
661
+ if (parent.type === utils_1.AST_NODE_TYPES.MemberExpression &&
662
+ parent.computed &&
663
+ parent.property === written) {
664
+ return [parent.range[0], parent.range[1]];
665
+ }
666
+ if (parent.type === utils_1.AST_NODE_TYPES.Property &&
667
+ parent.computed &&
668
+ parent.key === written) {
669
+ // A computed property's range runs past its key to the end of its value,
670
+ // which the wrap has no business claiming; the bracket that closes the key
671
+ // is where this access ends.
672
+ const closing = sourceCode.getTokenAfter(written);
673
+ return closing?.value === ']'
674
+ ? [parent.range[0], closing.range[1]]
675
+ : [written.range[0], written.range[1]];
676
+ }
677
+ if (parent.type === utils_1.AST_NODE_TYPES.BinaryExpression &&
678
+ parent.operator === 'in' &&
679
+ parent.left === written) {
680
+ return [parent.range[0], parent.range[1]];
681
+ }
682
+ return null;
683
+ }
643
684
  /**
644
685
  * An enum is a compiler-checked finite set. Members with literal initializers
645
686
  * enumerate their runtime key strings (which is what lets the forbidden-name
@@ -836,17 +877,51 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
836
877
  const importStatement = `import { assertSafe } from '${computeImportSpecifier()}';\n`;
837
878
  return (0, importInsertion_1.insertAtImportAnchor)(context.sourceCode, fixer, (0, importInsertion_1.importInsertionAnchor)(context.sourceCode), importStatement);
838
879
  };
880
+ /**
881
+ * The wrap of the key, emitted over the whole access the key belongs to
882
+ * rather than over the key alone.
883
+ *
884
+ * ESLint merges the fixes of one report into a single edit spanning
885
+ * [first start, last end], splicing the original text back in between. The
886
+ * import anchor sits at the top of the file, so bundling it with the wrap
887
+ * turns a three-character edit into one that claims everything from the
888
+ * file's first statement to the end of the key — which is a point in the
889
+ * middle of the access, before the `]` that closes it. That span sorts
890
+ * ahead of every competing fix and wins the race against all of them, but
891
+ * only up to its end. A fixer whose edits are coherent only as a set (a
892
+ * formatter re-wrapping one access across several lines is the common
893
+ * case) then has the edits inside the span discarded and the edits past
894
+ * its end applied, and the two halves do not fit together: the emitted
895
+ * file does not parse.
896
+ *
897
+ * Ending the span where the access ends puts every edit of such a
898
+ * competing rewrite on one side of the boundary — either wholly inside the
899
+ * span (discarded whole, and re-made against the fixed text on the next
900
+ * pass) or wholly outside it. Both parse. The re-emitted head and tail are
901
+ * copied verbatim from the source, so the text this fix produces is
902
+ * character-for-character what replacing the key alone produced.
903
+ */
904
+ const wrapKey = (fixer, node, argText) => {
905
+ const replacement = `assertSafe(${argText})`;
906
+ const span = accessSpan(context.sourceCode, node);
907
+ if (!span) {
908
+ return fixer.replaceText(node, replacement);
909
+ }
910
+ const [start, end] = span;
911
+ const { text } = context.sourceCode;
912
+ return fixer.replaceTextRange([start, end], `${text.slice(start, node.range[0])}${replacement}${text.slice(node.range[1], end)}`);
913
+ };
839
914
  /**
840
915
  * Helper function to create fixes for a node
841
916
  */
842
917
  const createFixes = (fixer, node, argText) => {
843
918
  const fixes = [];
844
- if (!importClaimed && !importsAssertSafe(context.sourceCode.ast)) {
919
+ const carriesImport = !importClaimed && !importsAssertSafe(context.sourceCode.ast);
920
+ if (carriesImport) {
845
921
  fixes.push(addAssertSafeImport(fixer));
846
922
  importClaimed = true;
847
923
  }
848
- // Replace the node with assertSafe(argText)
849
- fixes.push(fixer.replaceText(node, `assertSafe(${argText})`));
924
+ fixes.push(wrapKey(fixer, node, argText));
850
925
  return fixes;
851
926
  };
852
927
  // The report is emitted even when suppressed: ESLint discards it, and
@@ -6,6 +6,7 @@ const createRule_1 = require("../utils/createRule");
6
6
  const ASTHelpers_1 = require("../utils/ASTHelpers");
7
7
  const disableDirectives_1 = require("../utils/disableDirectives");
8
8
  const importInsertion_1 = require("../utils/importInsertion");
9
+ const resourceHandleType_1 = require("../utils/resourceHandleType");
9
10
  const MEMOIZE_MODULE = '@blumintinc/typescript-memoize';
10
11
  const ALLOWED_MEMOIZE_MODULES = new Set([MEMOIZE_MODULE, 'typescript-memoize']);
11
12
  const MEMOIZE_NAME = 'Memoize';
@@ -641,6 +642,17 @@ exports.enforceMemoizeAsync = (0, createRule_1.createRule)({
641
642
  if (declaresVoidResult(node.value.returnType)) {
642
643
  return;
643
644
  }
645
+ // A method that hands back a resource handle — an object carrying the
646
+ // closure that releases what the call allocated — must run per caller.
647
+ // Cached, N concurrent callers share one lease and one release closure:
648
+ // the first `release()` frees it while the remaining N-1 keep running
649
+ // against budget nobody accounts for, and a caller's `finally` block
650
+ // disposes another caller's resource. The failure is silent and
651
+ // load-dependent, so the fixer would apply it unattended under `--fix`
652
+ // and a passing concurrency suite would keep passing.
653
+ if ((0, resourceHandleType_1.declaresResourceHandleResult)(node.value.returnType)) {
654
+ return;
655
+ }
644
656
  // A transaction handle is valid only for the attempt that created it,
645
657
  // so a result derived from one must not outlive that attempt. Caching
646
658
  // the body of a `runTransaction` callback — or the method that owns the
@@ -7,6 +7,7 @@ const createRule_1 = require("../utils/createRule");
7
7
  const ASTHelpers_1 = require("../utils/ASTHelpers");
8
8
  const disableDirectives_1 = require("../utils/disableDirectives");
9
9
  const importInsertion_1 = require("../utils/importInsertion");
10
+ const resourceHandleType_1 = require("../utils/resourceHandleType");
10
11
  const MEMOIZE_PREFERRED_MODULE = '@blumintinc/typescript-memoize';
11
12
  const MEMOIZE_MODULES = new Set([
12
13
  MEMOIZE_PREFERRED_MODULE,
@@ -436,6 +437,25 @@ exports.enforceMemoizeGetters = (0, createRule_1.createRule)({
436
437
  // declaration despite having no name.
437
438
  if (classBody?.parent?.type === utils_1.AST_NODE_TYPES.ClassExpression)
438
439
  return;
440
+ // A getter that hands back a resource handle — an object carrying the
441
+ // closure that releases what the read allocated — allocates that handle
442
+ // for the reader taking it. Memoized, every later read receives the
443
+ // FIRST reader's live lease and the release closure bound to it: the
444
+ // first `release()` frees it while the remaining readers keep running
445
+ // against budget nobody accounts for, and one reader's `finally` block
446
+ // disposes another reader's resource. The failure is silent and
447
+ // load-dependent, so the fixer would apply it unattended under `--fix`
448
+ // past a green concurrency suite.
449
+ //
450
+ // The predicate is SHARED with `enforce-memoize-async` and with
451
+ // `no-explicit-return-type`, which preserves exactly this annotation
452
+ // rather than stripping it as a restatement of the result. Split
453
+ // predicates would not merely drift, they would cancel: a shape this
454
+ // rule exempts and the reader strips loses the carve-out inside a
455
+ // single unattended `eslint --fix` run.
456
+ if ((0, resourceHandleType_1.declaresResourceHandleResult)(node.value.returnType)) {
457
+ return;
458
+ }
439
459
  // A getter whose value is a fresh read of live external state is not a
440
460
  // lazy factory: memoizing it pins the first observation forever, so the
441
461
  // report is dropped along with its unattended `--fix` edit.
@@ -171,6 +171,275 @@ function getIndentBeforeNode(sourceCode, node) {
171
171
  const match = lineText.match(/^[ \t]*/);
172
172
  return match ? match[0] : '';
173
173
  }
174
+ const EXHAUSTIVE_DEPS_DISABLE = '// eslint-disable-next-line react-hooks/exhaustive-deps';
175
+ /**
176
+ * One level of indentation, spelled because the fixer prints an argument list
177
+ * rather than nudging the existing one. Two spaces is prettier's `tabWidth`
178
+ * default and this repo's and its consumers' setting; a tab-indented region is
179
+ * declined below instead of being indented with a mixture of the two.
180
+ */
181
+ const INDENT_STEP = ' ';
182
+ /**
183
+ * Wrappers that sit between a call and its statement without changing where
184
+ * prettier indents the call's arguments.
185
+ */
186
+ const TRANSPARENT_PARENTS = new Set([
187
+ utils_1.AST_NODE_TYPES.AwaitExpression,
188
+ utils_1.AST_NODE_TYPES.ChainExpression,
189
+ utils_1.AST_NODE_TYPES.TSAsExpression,
190
+ utils_1.AST_NODE_TYPES.TSNonNullExpression,
191
+ ]);
192
+ /**
193
+ * Where an expanded argument list is measured from, or null where that cannot
194
+ * be resolved.
195
+ *
196
+ * Giving an argument a leading own-line comment forces prettier to print one
197
+ * argument per line — the decision is the comment's, not the line width's —
198
+ * indented one step past the enclosing statement, with the closing paren back
199
+ * at the statement's indentation. That holds while the call is the whole of a
200
+ * statement. A call nested inside another expression sits in a group prettier
201
+ * may break as well, and its arguments then indent against that break rather
202
+ * than against the line the call starts on, so those positions are declined
203
+ * rather than guessed at.
204
+ *
205
+ * A concise arrow body is the one nested position with a settled answer, and it
206
+ * has to be handled rather than declined because it is the same call spelled
207
+ * another way: `() => useCallback(…)` and `() => { return useCallback(…); }`
208
+ * are one function, and a fixer that remedies only one of them reports a
209
+ * violation it will not fix. Measured at the repo's prettier settings, the
210
+ * break lands after the final `=>` with the call one step past the statement,
211
+ * whatever the length of the arrow chain ahead of it.
212
+ */
213
+ function expandedArgumentAnchor(node) {
214
+ const skipTransparent = (from) => {
215
+ let current = from;
216
+ while (current.parent && TRANSPARENT_PARENTS.has(current.parent.type)) {
217
+ current = current.parent;
218
+ }
219
+ return current;
220
+ };
221
+ let current = skipTransparent(node);
222
+ let followsArrow = false;
223
+ while (current.parent?.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression &&
224
+ current.parent.body === current) {
225
+ followsArrow = true;
226
+ current = skipTransparent(current.parent);
227
+ }
228
+ const parent = current.parent;
229
+ if (!parent) {
230
+ return null;
231
+ }
232
+ if (parent.type === utils_1.AST_NODE_TYPES.ExpressionStatement ||
233
+ parent.type === utils_1.AST_NODE_TYPES.ReturnStatement) {
234
+ return { statement: parent, followsArrow };
235
+ }
236
+ // A second declarator is a break of its own, which moves the indentation the
237
+ // argument list is measured from off the declaration's line.
238
+ if (parent.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
239
+ parent.parent?.type === utils_1.AST_NODE_TYPES.VariableDeclaration &&
240
+ parent.parent.declarations.length === 1) {
241
+ return { statement: parent.parent, followsArrow };
242
+ }
243
+ if (parent.type === utils_1.AST_NODE_TYPES.AssignmentExpression &&
244
+ parent.parent?.type === utils_1.AST_NODE_TYPES.ExpressionStatement) {
245
+ return { statement: parent.parent, followsArrow };
246
+ }
247
+ return null;
248
+ }
249
+ /**
250
+ * Source lines whose leading whitespace belongs to a string's value rather than
251
+ * to the file's indentation. Re-indenting one of them would change what the
252
+ * program prints, and prettier leaves them alone for the same reason.
253
+ */
254
+ function stringContinuationLines(sourceCode, node) {
255
+ const lines = new Set();
256
+ for (const token of sourceCode.getTokens(node)) {
257
+ if (token.type !== utils_1.AST_TOKEN_TYPES.String &&
258
+ token.type !== utils_1.AST_TOKEN_TYPES.Template) {
259
+ continue;
260
+ }
261
+ for (let line = token.loc.start.line + 1; line <= token.loc.end.line; line += 1) {
262
+ lines.add(line);
263
+ }
264
+ }
265
+ return lines;
266
+ }
267
+ /**
268
+ * Moves a span of source text by `shift` columns, leaving its first line alone
269
+ * because that line's indentation is written by the caller.
270
+ *
271
+ * Returns null when a line carries a tab, since a shift expressed in spaces
272
+ * cannot preserve a tab-indented line's column.
273
+ */
274
+ function shiftIndentation(text, shift, firstLine, protectedLines) {
275
+ if (shift === 0) {
276
+ return text;
277
+ }
278
+ const shifted = [];
279
+ const lines = text.split('\n');
280
+ for (const [index, line] of lines.entries()) {
281
+ // A blank line gets no indentation: padding one is exactly the trailing
282
+ // whitespace this fixer exists to stop emitting.
283
+ if (index === 0 || protectedLines.has(firstLine + index) || !line.trim()) {
284
+ shifted.push(line);
285
+ continue;
286
+ }
287
+ if (line.startsWith('\t')) {
288
+ return null;
289
+ }
290
+ shifted.push(shift > 0
291
+ ? `${' '.repeat(shift)}${line}`
292
+ : line.slice(Math.min(-shift, line.length - line.trimStart().length)));
293
+ }
294
+ return shifted.join('\n');
295
+ }
296
+ /**
297
+ * Whether an argument that spans several lines keeps the layout prettier gave
298
+ * it once the expansion moves it to `argumentIndent`.
299
+ *
300
+ * Prettier breaks an argument for one of two reasons: the construct forces it,
301
+ * or it did not fit the room it had. Only the first survives the move, because
302
+ * the expansion hands every argument a different amount of room — an argument
303
+ * broken purely for width may fit on one line where it lands, and prettier would
304
+ * join it back up over text this fixer had copied verbatim.
305
+ *
306
+ * Two cases qualify. An argument already sitting alone at the target indent has
307
+ * lost no room at all. And a function with a non-empty block body is the shape
308
+ * prettier never prints on one line, whatever room it is given, provided
309
+ * everything ahead of the body already fits on one line.
310
+ */
311
+ function keepsItsLayoutWhenMoved(sourceCode, argument, argumentIndent, currentIndent) {
312
+ if (argument.loc.start.line === argument.loc.end.line) {
313
+ return true;
314
+ }
315
+ if (currentIndent === argumentIndent &&
316
+ argument.loc.start.column === currentIndent.length) {
317
+ return true;
318
+ }
319
+ if (argument.type !== utils_1.AST_NODE_TYPES.ArrowFunctionExpression &&
320
+ argument.type !== utils_1.AST_NODE_TYPES.FunctionExpression) {
321
+ return false;
322
+ }
323
+ const { body } = argument;
324
+ return (body.type === utils_1.AST_NODE_TYPES.BlockStatement &&
325
+ body.loc.start.line === argument.loc.start.line &&
326
+ (body.body.length > 0 || sourceCode.getCommentsInside(body).length > 0));
327
+ }
328
+ /**
329
+ * The text of `node` with the rewrites that fall inside it already applied, so
330
+ * that a caller re-emitting the surrounding span carries them along instead of
331
+ * overlapping them with a second edit.
332
+ */
333
+ function applyRewrites(sourceCode, node, rewrites) {
334
+ const inside = rewrites
335
+ .filter(({ range }) => range[0] >= node.range[0] && range[1] <= node.range[1])
336
+ .sort((left, right) => left.range[0] - right.range[0]);
337
+ let cursor = node.range[0];
338
+ let text = '';
339
+ for (const rewrite of inside) {
340
+ text += sourceCode.text.slice(cursor, rewrite.range[0]) + rewrite.text;
341
+ cursor = rewrite.range[1];
342
+ }
343
+ return text + sourceCode.text.slice(cursor, node.range[1]);
344
+ }
345
+ /**
346
+ * Re-emits a call's argument list in the one-argument-per-line shape prettier
347
+ * prints once an argument carries an own-line comment, with `comment` placed
348
+ * above `commentTarget` and `rewrites` folded into the arguments they fall in.
349
+ *
350
+ * Inserting the comment in place instead leaves two marks of the pre-image on
351
+ * the file: the separator whitespace the inserted line break strands at the end
352
+ * of the preceding line, and the arguments' pre-expansion indentation. Owning
353
+ * the whole span between the parentheses settles both, at the cost of having to
354
+ * reproduce every argument — so a span this cannot reproduce faithfully returns
355
+ * null and the caller declines the fix outright.
356
+ */
357
+ function expandArgumentList(sourceCode, node, commentTarget, comment, rewrites) {
358
+ const anchor = expandedArgumentAnchor(node);
359
+ if (!anchor) {
360
+ return null;
361
+ }
362
+ const { statement, followsArrow } = anchor;
363
+ const lineStart = sourceCode.getIndexFromLoc({
364
+ line: statement.loc.start.line,
365
+ column: 0,
366
+ });
367
+ const statementIndent = sourceCode.text.slice(lineStart, statement.range[0]);
368
+ if (!/^ *$/.test(statementIndent)) {
369
+ return null;
370
+ }
371
+ const edits = [];
372
+ let callIndent = statementIndent;
373
+ if (followsArrow) {
374
+ // The arrow chain ahead of the body stays on the statement's line, so the
375
+ // break the expansion forces is the one after the final `=>` — and the
376
+ // whitespace across that break is the fixer's to write, for the same reason
377
+ // the separator between two arguments is.
378
+ const arrowToken = sourceCode.getTokenBefore(node);
379
+ if (!arrowToken ||
380
+ arrowToken.value !== '=>' ||
381
+ arrowToken.loc.end.line !== statement.loc.start.line ||
382
+ !/^\s*$/.test(sourceCode.text.slice(arrowToken.range[1], node.range[0]))) {
383
+ return null;
384
+ }
385
+ callIndent = `${statementIndent}${INDENT_STEP}`;
386
+ edits.push({
387
+ range: [arrowToken.range[1], node.range[0]],
388
+ text: `\n${callIndent}`,
389
+ });
390
+ }
391
+ else if (statement.loc.start.line !== node.loc.start.line) {
392
+ return null;
393
+ }
394
+ const openParen = sourceCode.getTokenAfter(node.typeParameters ?? node.callee, {
395
+ filter: utils_1.ASTUtils.isOpeningParenToken,
396
+ });
397
+ const closeParen = sourceCode.getLastToken(node);
398
+ if (!openParen || !closeParen || !utils_1.ASTUtils.isClosingParenToken(closeParen)) {
399
+ return null;
400
+ }
401
+ // A comment written between the arguments belongs to no argument, so the
402
+ // re-emitted list has nowhere to carry it and would delete it. Declining is
403
+ // the deliberate choice here: a formatting correction does not justify
404
+ // dropping a comment (#1877).
405
+ const strandsAComment = sourceCode
406
+ .getCommentsInside(node)
407
+ .some((existing) => existing.range[0] >= openParen.range[1] &&
408
+ existing.range[1] <= closeParen.range[0] &&
409
+ !node.arguments.some((argument) => existing.range[0] >= argument.range[0] &&
410
+ existing.range[1] <= argument.range[1]));
411
+ if (strandsAComment) {
412
+ return null;
413
+ }
414
+ const argumentIndent = `${callIndent}${INDENT_STEP}`;
415
+ const protectedLines = stringContinuationLines(sourceCode, node);
416
+ const parts = [];
417
+ for (const argument of node.arguments) {
418
+ const base = getIndentBeforeNode(sourceCode, argument);
419
+ if (!/^ *$/.test(base)) {
420
+ return null;
421
+ }
422
+ if (!keepsItsLayoutWhenMoved(sourceCode, argument, argumentIndent, base)) {
423
+ return null;
424
+ }
425
+ // Wrapping an element in a call adds no line terminator, so each line of
426
+ // the rewritten text still stands for the source line at the same offset
427
+ // and the protected-line numbering survives the rewrite.
428
+ const shifted = shiftIndentation(applyRewrites(sourceCode, argument, rewrites), argumentIndent.length - base.length, argument.loc.start.line, protectedLines);
429
+ if (shifted === null) {
430
+ return null;
431
+ }
432
+ if (argument === commentTarget) {
433
+ parts.push(`${argumentIndent}${comment}\n`);
434
+ }
435
+ parts.push(`${argumentIndent}${shifted},\n`);
436
+ }
437
+ edits.push({
438
+ range: [openParen.range[1], closeParen.range[0]],
439
+ text: `\n${parts.join('')}${callIndent}`,
440
+ });
441
+ return edits;
442
+ }
174
443
  function hasExhaustiveDepsDisable(sourceCode, callNode, depsNode) {
175
444
  const [start, end] = [callNode.range[0], depsNode.range[1]];
176
445
  const callStartLine = callNode.loc.start.line;
@@ -355,15 +624,40 @@ exports.enforceStableHashSpreadProps = (0, createRule_1.createRule)({
355
624
  !bindsHashImport(existingBinding, hashImport)) {
356
625
  return null;
357
626
  }
358
- const fixes = [];
359
627
  const seen = new Set();
628
+ const rewrites = [];
360
629
  for (const { node: targetNode } of offendingElements) {
361
630
  if (seen.has(targetNode.range[0]))
362
631
  continue;
363
632
  seen.add(targetNode.range[0]);
364
633
  const original = sourceCode.getText(targetNode);
365
- fixes.push(fixer.replaceText(targetNode, `${hashIdentifier}(${original})`));
634
+ rewrites.push({
635
+ range: targetNode.range,
636
+ text: `${hashIdentifier}(${original})`,
637
+ });
366
638
  }
639
+ // The disable comment and the `stableHash(...)` wraps are one edit
640
+ // whenever the comment lands: an own-line comment forces prettier
641
+ // to expand the argument list, and the expansion re-emits the very
642
+ // span the wraps live in, which ESLint rejects as two overlapping
643
+ // fixes. Where the comment is already there, the wraps stand alone
644
+ // and the call's layout is left as the author wrote it.
645
+ const needsDisable = !hasExhaustiveDepsDisable(sourceCode, node, depsArg);
646
+ const expansion = needsDisable
647
+ ? expandArgumentList(sourceCode, node, depsArg, EXHAUSTIVE_DEPS_DISABLE, rewrites)
648
+ : null;
649
+ // A call whose argument list cannot be reproduced still needs the
650
+ // disable, since the wrapped dependency is what makes
651
+ // `react-hooks/exhaustive-deps` fire. Emitting the wraps without it
652
+ // would trade this rule's report for that one, so the whole fix is
653
+ // declined and the report stands for the author. The decision is
654
+ // taken before any fix is scheduled: `importPlanned` claims the
655
+ // file's import for this violation, and a later `return null` would
656
+ // strand the surviving violations with no import at all.
657
+ if (needsDisable && !expansion) {
658
+ return null;
659
+ }
660
+ const fixes = (expansion ?? rewrites).map(({ range, text }) => fixer.replaceTextRange(range, text));
367
661
  if (!isStableHashImported(sourceCode, hashImport) &&
368
662
  !importPlanned) {
369
663
  const importText = `import { ${hashImport.importName} } from '${hashImport.source}';\n`;
@@ -387,17 +681,6 @@ exports.enforceStableHashSpreadProps = (0, createRule_1.createRule)({
387
681
  }
388
682
  importPlanned = true;
389
683
  }
390
- if (!hasExhaustiveDepsDisable(sourceCode, node, depsArg)) {
391
- const indent = getIndentBeforeNode(sourceCode, depsArg);
392
- const tokenBefore = sourceCode.getTokenBefore(depsArg, {
393
- includeComments: true,
394
- });
395
- const needsLeadingNewline = tokenBefore?.loc.end.line === depsArg.loc.start.line;
396
- const commentText = needsLeadingNewline
397
- ? `\n${indent}// eslint-disable-next-line react-hooks/exhaustive-deps\n${indent}`
398
- : `// eslint-disable-next-line react-hooks/exhaustive-deps\n${indent}`;
399
- fixes.push(fixer.insertTextBefore(depsArg, commentText));
400
- }
401
684
  return fixes;
402
685
  },
403
686
  });
@@ -532,12 +532,40 @@ exports.fastDeepEqualOverMicrodiff = (0, createRule_1.createRule)({
532
532
  * declares, so any other name would be written as an import that resolves
533
533
  * nowhere.
534
534
  */
535
+ function importDeclarationsOf() {
536
+ return sourceCode.ast.body.filter((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
537
+ }
538
+ /**
539
+ * Where {@link planFastDeepEqualImport} lands its statement, told to the
540
+ * removal planner so the blank line under a deleted microdiff import is
541
+ * judged against a file that opens on the replacement rather than on the
542
+ * module body (issue #2078).
543
+ *
544
+ * The anchor positions are a superset of the one the fixer picks, since which
545
+ * declarations the plan deletes is not known until it exists. Naming a
546
+ * position the fix does not use only leaves a blank line standing, which is
547
+ * the layout the file already had.
548
+ */
549
+ function fastDeepEqualImportSites() {
550
+ if (hasFastDeepEqualImport)
551
+ return [];
552
+ const importDeclarations = importDeclarationsOf();
553
+ const microdiffImports = importDeclarations.filter((declaration) => microdiffModules_1.MICRODIFF_MODULES.has(String(declaration.source.value)));
554
+ if (microdiffImports.length > 0) {
555
+ return [
556
+ ...microdiffImports.map((declaration) => declaration.range[0]),
557
+ microdiffImports[0].range[1],
558
+ ];
559
+ }
560
+ const last = importDeclarations[importDeclarations.length - 1];
561
+ return [last ? last.range[1] : 0];
562
+ }
535
563
  function planFastDeepEqualImport(fixer, removalRanges) {
536
564
  if (hasFastDeepEqualImport) {
537
565
  return [];
538
566
  }
539
567
  const importStatement = (0, fastDeepEqualModules_1.fastDeepEqualImport)(fastDeepEqualImportName);
540
- const importDeclarations = sourceCode.ast.body.filter((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
568
+ const importDeclarations = importDeclarationsOf();
541
569
  const removalStartOf = (declaration) => removalRanges.find((range) => range[0] <= declaration.range[0] &&
542
570
  range[1] >= declaration.range[1])?.[0];
543
571
  const microdiffImports = importDeclarations.filter((declaration) => microdiffModules_1.MICRODIFF_MODULES.has(String(declaration.source.value)));
@@ -741,7 +769,7 @@ exports.fastDeepEqualOverMicrodiff = (0, createRule_1.createRule)({
741
769
  // its last use, and the pass that applies them all resolves every
742
770
  // report — so this is the only moment the stranded import is visible.
743
771
  const importRemoval = planned.length > 0
744
- ? (0, importRemoval_1.planOrphanedImportRemoval)(sourceCode, planned.flatMap((entry) => entry.removed))
772
+ ? (0, importRemoval_1.planOrphanedImportRemoval)(sourceCode, planned.flatMap((entry) => entry.removed), { insertions: fastDeepEqualImportSites() })
745
773
  : null;
746
774
  const removalRanges = importRemoval ?? [];
747
775
  // The whole batch ships as one fix, so no rewrite can land without the
@@ -8,6 +8,7 @@ const importRemoval_1 = require("../utils/importRemoval");
8
8
  const typeDeclarationRemoval_1 = require("../utils/typeDeclarationRemoval");
9
9
  const replacementSegments_1 = require("../utils/replacementSegments");
10
10
  const lexicalScope_1 = require("../utils/lexicalScope");
11
+ const resourceHandleType_1 = require("../utils/resourceHandleType");
11
12
  const arrowAnnotationGap_1 = require("../utils/arrowAnnotationGap");
12
13
  const defaultOptions = {
13
14
  allowRecursiveFunctions: true,
@@ -845,6 +846,36 @@ function declaresVoidResult(returnType) {
845
846
  return (typeArguments?.length === 1 &&
846
847
  typeArguments[0].type === utils_1.AST_NODE_TYPES.TSVoidKeyword);
847
848
  }
849
+ /**
850
+ * Returns true when the annotation declares that the function hands back a
851
+ * RESOURCE HANDLE: an object result carrying a function-valued member, which is
852
+ * the closure that releases whatever the call allocated.
853
+ *
854
+ * `declaresVoidResult` above answers the same objection for the opposite shape,
855
+ * and the reasoning transfers verbatim. This annotation is not a restatement of
856
+ * a result either — it is the evidence a sibling rule's correctness decision
857
+ * rests on. `enforce-memoize-async` reads exactly this shape and declines to
858
+ * demand `@Memoize()` on the method, because a memoized handle factory serves
859
+ * every caller after the first the FIRST caller's live lease and the release
860
+ * closure bound to it: N concurrent callers hold one lease between them while
861
+ * the pool accounts for N, and whichever caller finishes first disposes a
862
+ * resource the others still believe they own.
863
+ *
864
+ * Stripping the annotation therefore destroys information rather than removing
865
+ * redundancy, and it destroys it unattended: `eslint --fix` re-lints until the
866
+ * output settles, so the strip and the memoization it re-arms land in the same
867
+ * run. Nothing downstream catches what that re-arms, because the failure is
868
+ * silent and load-dependent — it needs concurrent callers to show itself, so a
869
+ * green suite stays green and the corruption surfaces under load.
870
+ *
871
+ * The predicate is SHARED with the owner (`../utils/resourceHandleType`) rather
872
+ * than restated here. A shape one rule exempts while the other strips is the
873
+ * whole defect; a shape one rule strips while the other exempts is the same
874
+ * defect pointing the other way, so the two cannot be allowed to drift apart.
875
+ */
876
+ function isResourceHandleReturnType(returnType) {
877
+ return (0, resourceHandleType_1.declaresResourceHandleResult)(returnType);
878
+ }
848
879
  // TypeScript's built-in decorator signatures. A factory annotated with one of
849
880
  // these is the one shape where the annotation is WIDER than what inference
850
881
  // produces rather than a restatement of it: `MethodDecorator` accepts three
@@ -1364,6 +1395,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
1364
1395
  if (isTypeGuardFunction(node) ||
1365
1396
  isReadonlyWideningReturnType(returnType) ||
1366
1397
  isAllowedVoidReturnType(returnType) ||
1398
+ isResourceHandleReturnType(returnType) ||
1367
1399
  isDecoratorFactory(node, returnType) ||
1368
1400
  isOverloadImplementation(node) ||
1369
1401
  (mergedOptions.allowRecursiveFunctions &&
@@ -1383,6 +1415,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
1383
1415
  if (isTypeGuardFunction(node) ||
1384
1416
  isReadonlyWideningReturnType(returnType) ||
1385
1417
  isAllowedVoidReturnType(returnType) ||
1418
+ isResourceHandleReturnType(returnType) ||
1386
1419
  isDecoratorFactory(node, returnType) ||
1387
1420
  (mergedOptions.allowRecursiveFunctions &&
1388
1421
  isRecursiveFunction(node)) ||
@@ -1398,6 +1431,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
1398
1431
  if (isTypeGuardFunction(node) ||
1399
1432
  isReadonlyWideningReturnType(returnType) ||
1400
1433
  isAllowedVoidReturnType(returnType) ||
1434
+ isResourceHandleReturnType(returnType) ||
1401
1435
  isDecoratorFactory(node, returnType) ||
1402
1436
  isReturnTypeRequiredByRecursion(node)) {
1403
1437
  return;
@@ -1424,6 +1458,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
1424
1458
  if (isTypeGuardFunction(node.value) ||
1425
1459
  isReadonlyWideningReturnType(returnType) ||
1426
1460
  isAllowedVoidReturnType(returnType) ||
1461
+ isResourceHandleReturnType(returnType) ||
1427
1462
  isDecoratorFactory(node, returnType) ||
1428
1463
  isOverloadImplementationMethod(node) ||
1429
1464
  (mergedOptions.allowOverloadedFunctions &&