@blumintinc/eslint-plugin-blumint 1.20.85 → 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 +1 -1
- package/lib/rules/enforce-assert-safe-object-key.js +7 -9
- package/lib/rules/enforce-memoize-async.js +8 -16
- package/lib/rules/enforce-memoize-getters.js +12 -16
- package/lib/rules/enforce-microdiff.js +14 -1
- package/lib/rules/enforce-querykey-ts.js +23 -7
- package/lib/rules/enforce-safe-stringify.js +7 -3
- package/lib/rules/enforce-snapshot-state-narrowing.js +24 -7
- package/lib/rules/enforce-stable-hash-spread-props.js +14 -3
- package/lib/rules/logical-top-to-bottom-grouping.js +70 -0
- package/lib/rules/no-array-length-in-deps.js +13 -1
- package/lib/rules/no-empty-dependency-use-callbacks.js +36 -0
- package/lib/rules/no-explicit-return-type.js +47 -59
- package/lib/rules/prefer-fragment-component.js +9 -27
- package/lib/rules/prefer-global-router-state-key.js +18 -24
- package/lib/rules/prefer-spread-over-reassembly.js +266 -11
- package/lib/rules/prefer-use-deep-compare-memo.js +14 -18
- package/lib/rules/react-memoize-literals.js +8 -7
- package/lib/rules/require-memo.js +10 -7
- package/lib/rules/require-memoize-jsx-returners.js +8 -13
- package/lib/utils/importInsertion.d.ts +52 -0
- package/lib/utils/importInsertion.js +121 -0
- package/lib/utils/importRemoval.d.ts +57 -0
- package/lib/utils/importRemoval.js +397 -0
- package/package.json +1 -1
- package/release-manifest.json +164 -0
package/lib/index.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
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
|
|
249
|
-
if (
|
|
250
|
-
|
|
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
|
-
//
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
235
|
-
if (
|
|
236
|
-
|
|
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
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)({
|