@blumintinc/eslint-plugin-blumint 1.20.30 → 1.20.31

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.
@@ -3,7 +3,61 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.preferFragmentComponent = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
+ const ASTHelpers_1 = require("../utils/ASTHelpers");
6
7
  const disableDirectives_1 = require("../utils/disableDirectives");
8
+ const REACT_MODULE = 'react';
9
+ const FRAGMENT_NAME = 'Fragment';
10
+ /**
11
+ * Value `react` import declarations in source order. A type-only declaration
12
+ * (`import type { FC } from 'react'`) is excluded because a specifier appended
13
+ * to one erases at compile time, leaving the rewritten <Fragment> unbound at
14
+ * runtime.
15
+ */
16
+ function reactValueImports(program) {
17
+ return program.body.filter((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
18
+ statement.source.value === REACT_MODULE &&
19
+ statement.importKind !== 'type');
20
+ }
21
+ /**
22
+ * A named specifier that binds `Fragment` under its own name — the only shape
23
+ * that makes a bare <Fragment> element resolve to react's Fragment. An alias
24
+ * (`import { Fragment as Frag }`) leaves the name free, and a type-only
25
+ * specifier binds nothing at runtime.
26
+ */
27
+ function isFragmentSpecifier(specifier) {
28
+ return (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
29
+ specifier.importKind !== 'type' &&
30
+ specifier.imported.name === FRAGMENT_NAME &&
31
+ specifier.local.name === FRAGMENT_NAME);
32
+ }
33
+ /**
34
+ * Import state is read off `Program.body` at fix time rather than from a
35
+ * traversal flag: a fragment that precedes the import declaration in source
36
+ * order is fixed before the `ImportDeclaration` visitor has run, so a flag
37
+ * would call the import missing and insert a duplicate.
38
+ */
39
+ function importsFragment(program) {
40
+ return reactValueImports(program).some((declaration) => declaration.specifiers.some(isFragmentSpecifier));
41
+ }
42
+ /**
43
+ * Whether every declaration of a visible `Fragment` binding is react's Fragment
44
+ * import. A const/let/function/class, a parameter, a namespace or default
45
+ * import, an alias, or a named import from another module all mean the
46
+ * rewritten <Fragment> renders something other than react's Fragment.
47
+ */
48
+ function bindsReactFragment(variable) {
49
+ return (variable.defs.length > 0 &&
50
+ variable.defs.every((def) => {
51
+ const specifier = def.node;
52
+ if (!isFragmentSpecifier(specifier)) {
53
+ return false;
54
+ }
55
+ const declaration = specifier.parent;
56
+ return (declaration?.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
57
+ declaration.importKind !== 'type' &&
58
+ declaration.source.value === REACT_MODULE);
59
+ }));
60
+ }
7
61
  exports.preferFragmentComponent = (0, createRule_1.createRule)({
8
62
  name: 'prefer-fragment-component',
9
63
  meta: {
@@ -29,11 +83,34 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
29
83
  // violations still emit <Fragment>. Resolving suppression before the latch
30
84
  // is read hands the carrier slot to the first violation that survives.
31
85
  const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
32
- let hasFragmentImport = false;
33
- let reactImportNode = null;
34
- let defaultReactImportNode = null;
86
+ // One pass rewrites several fragments but must insert the import once. The
87
+ // AST does not change mid-pass, so the latch records only what an earlier
88
+ // fix in this same pass already scheduled; existing imports are read from
89
+ // the AST instead.
90
+ let importScheduled = false;
35
91
  // Track nodes we've already reported to avoid duplicates
36
92
  const reportedNodes = new Set();
93
+ /**
94
+ * Whether a bare <Fragment> emitted at `node` would resolve to react's
95
+ * Fragment. Any other visible binding of the name breaks the edit two ways:
96
+ * the inserted import collides with the existing declaration (TS2440, or
97
+ * TS2300 when that declaration is itself an import), and a narrower-scope
98
+ * shadow captures the emitted element with no compile error at all.
99
+ * Resolving through the scope chain from the reported element covers both,
100
+ * and keeps `React.Fragment` — a member access on the default import, not a
101
+ * `Fragment` binding — off the collision path.
102
+ */
103
+ function resolvesToReactFragment(node) {
104
+ const existing = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, node), FRAGMENT_NAME);
105
+ return !existing || bindsReactFragment(existing);
106
+ }
107
+ /**
108
+ * True once the file binds react's Fragment, counting an insertion an
109
+ * earlier fix in this pass already scheduled.
110
+ */
111
+ function fragmentImportPresent() {
112
+ return importScheduled || importsFragment(sourceCode.ast);
113
+ }
37
114
  /**
38
115
  * Checks if a node is a React.Fragment element
39
116
  */
@@ -74,30 +151,6 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
74
151
  }
75
152
  return null;
76
153
  }
77
- /**
78
- * Checks for Fragment import from react
79
- */
80
- function checkFragmentImport(node) {
81
- if (node.source.value === 'react') {
82
- // Keep track of all React imports
83
- if (!reactImportNode) {
84
- reactImportNode = node;
85
- }
86
- // Keep track of default import for prioritization
87
- const hasDefaultImport = node.specifiers.some((spec) => spec.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier);
88
- if (hasDefaultImport) {
89
- defaultReactImportNode = node;
90
- }
91
- // Check if Fragment is already imported
92
- for (const specifier of node.specifiers) {
93
- if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
94
- specifier.imported.name === 'Fragment') {
95
- hasFragmentImport = true;
96
- break;
97
- }
98
- }
99
- }
100
- }
101
154
  /**
102
155
  * Where a brand-new import statement can be spliced in without changing
103
156
  * what the file's directives govern. A whole-line insertion directly before
@@ -122,35 +175,42 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
122
175
  return anchor;
123
176
  }
124
177
  /**
125
- * Adds Fragment import to an appropriate React import or creates a new one
178
+ * Adds Fragment to an existing react import or creates a new one. The
179
+ * emitted shape is a value named specifier, since the rewritten element
180
+ * references `Fragment` at runtime. Declarations come from `Program.body`
181
+ * at fix time so the choice does not depend on how far traversal has
182
+ * progressed.
126
183
  */
127
184
  function addFragmentImport(fixer) {
128
- // Use default import if available, otherwise use first React import
129
- const targetImportNode = defaultReactImportNode || reactImportNode;
185
+ const declarations = reactValueImports(sourceCode.ast);
186
+ // A declaration carrying the default import keeps `React` and `Fragment`
187
+ // on one line; otherwise the first declaration that can host a specifier
188
+ // wins. A bare `import 'react';` hosts none, so it falls through.
189
+ const targetImportNode = declarations.find((declaration) => declaration.specifiers.some((spec) => spec.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier)) ??
190
+ declarations.find((declaration) => declaration.specifiers.length > 0);
130
191
  if (targetImportNode) {
131
- // Check if it's a namespace import
192
+ // `import * as React, { Fragment }` is a syntax error, so a namespace
193
+ // import gets its own declaration beside it.
132
194
  const hasNamespaceImport = targetImportNode.specifiers.some((spec) => spec.type === utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier);
133
195
  if (hasNamespaceImport) {
134
- // Add separate import for Fragment
135
- return fixer.insertTextAfter(targetImportNode, "\nimport { Fragment } from 'react';");
196
+ return fixer.insertTextAfter(targetImportNode, `\nimport { ${FRAGMENT_NAME} } from '${REACT_MODULE}';`);
136
197
  }
137
198
  // Add Fragment to existing React import
138
199
  const lastSpecifier = targetImportNode.specifiers[targetImportNode.specifiers.length - 1];
139
200
  const hasNamedImports = targetImportNode.specifiers.some((spec) => spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier);
140
201
  if (hasNamedImports) {
141
- return fixer.insertTextAfter(lastSpecifier, ', Fragment');
202
+ return fixer.insertTextAfter(lastSpecifier, `, ${FRAGMENT_NAME}`);
142
203
  }
143
204
  else {
144
- return fixer.insertTextAfter(lastSpecifier, ', { Fragment }');
205
+ return fixer.insertTextAfter(lastSpecifier, `, { ${FRAGMENT_NAME} }`);
145
206
  }
146
207
  }
147
208
  // No React import found, create a new one
148
- const importText = "import { Fragment } from 'react';\n";
209
+ const importText = `import { ${FRAGMENT_NAME} } from '${REACT_MODULE}';\n`;
149
210
  const indentation = sourceCode.text.match(/^[ \t]*/m)?.[0] || '';
150
211
  return fixer.insertTextBefore(findImportAnchor(), indentation + importText);
151
212
  }
152
213
  return {
153
- ImportDeclaration: checkFragmentImport,
154
214
  // Find JSX Fragment shorthand (<></>)
155
215
  JSXFragment(node) {
156
216
  // Skip if already reported
@@ -185,11 +245,18 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
185
245
  if (isReportSuppressed(node)) {
186
246
  return null;
187
247
  }
248
+ // Both tags are rewritten to the same bare name, so resolving at
249
+ // the inner fragment — the deeper of the two scopes — covers the
250
+ // outer one too. Every bail precedes the latch so a withheld edit
251
+ // cannot make a later fix believe the import is handled.
252
+ if (!resolvesToReactFragment(node)) {
253
+ return null;
254
+ }
188
255
  const fixes = [];
189
256
  // Add Fragment import if needed
190
- if (!hasFragmentImport) {
257
+ if (!fragmentImportPresent()) {
191
258
  fixes.push(addFragmentImport(fixer));
192
- hasFragmentImport = true;
259
+ importScheduled = true;
193
260
  }
194
261
  // Fix the outer React.Fragment
195
262
  const outerOpeningElement = reactFragmentParent.openingElement;
@@ -245,11 +312,16 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
245
312
  if (isReportSuppressed(node)) {
246
313
  return null;
247
314
  }
315
+ // Every bail precedes the latch so a withheld edit cannot make a
316
+ // later fix believe the import is already handled.
317
+ if (!resolvesToReactFragment(node)) {
318
+ return null;
319
+ }
248
320
  const fixes = [];
249
321
  // Add Fragment import if needed
250
- if (!hasFragmentImport) {
322
+ if (!fragmentImportPresent()) {
251
323
  fixes.push(addFragmentImport(fixer));
252
- hasFragmentImport = true;
324
+ importScheduled = true;
253
325
  }
254
326
  // Replace fragment tags
255
327
  const openingText = sourceCode.getText(node.openingFragment);
@@ -296,11 +368,16 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
296
368
  if (isReportSuppressed(node.name)) {
297
369
  return null;
298
370
  }
371
+ // Every bail precedes the latch so a withheld edit cannot make a
372
+ // later fix believe the import is already handled.
373
+ if (!resolvesToReactFragment(node)) {
374
+ return null;
375
+ }
299
376
  const fixes = [];
300
377
  // Add Fragment import if needed
301
- if (!hasFragmentImport) {
378
+ if (!fragmentImportPresent()) {
302
379
  fixes.push(addFragmentImport(fixer));
303
- hasFragmentImport = true;
380
+ importScheduled = true;
304
381
  }
305
382
  // Replace opening tag
306
383
  const openingText = sourceCode.getText(node);
@@ -332,11 +409,16 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
332
409
  if (isReportSuppressed(node.name)) {
333
410
  return null;
334
411
  }
412
+ // Every bail precedes the latch so a withheld edit cannot make a
413
+ // later fix believe the import is already handled.
414
+ if (!resolvesToReactFragment(node)) {
415
+ return null;
416
+ }
335
417
  const fixes = [];
336
418
  // Add Fragment import if needed
337
- if (!hasFragmentImport) {
419
+ if (!fragmentImportPresent()) {
338
420
  fixes.push(addFragmentImport(fixer));
339
- hasFragmentImport = true;
421
+ importScheduled = true;
340
422
  }
341
423
  // Replace opening tag
342
424
  const openingText = sourceCode.getText(node);
@@ -352,34 +434,6 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
352
434
  },
353
435
  });
354
436
  },
355
- 'Program:exit'() {
356
- // Find appropriate React import for adding Fragment
357
- if (!hasFragmentImport) {
358
- // Get all React imports
359
- const reactImports = [];
360
- for (const node of sourceCode.ast.body) {
361
- if (node.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
362
- node.source.value === 'react') {
363
- reactImports.push(node);
364
- }
365
- }
366
- // Prioritize default import for adding Fragment
367
- if (reactImports.length > 0) {
368
- for (const importNode of reactImports) {
369
- const hasDefaultImport = importNode.specifiers.some((spec) => spec.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier);
370
- if (hasDefaultImport) {
371
- defaultReactImportNode = importNode;
372
- reactImportNode = importNode;
373
- break;
374
- }
375
- }
376
- // If no default import found, use the first import
377
- if (!defaultReactImportNode && reactImports.length > 0) {
378
- reactImportNode = reactImports[0];
379
- }
380
- }
381
- }
382
- },
383
437
  };
384
438
  },
385
439
  });
@@ -55,10 +55,81 @@ const isUnmemoizedExportedFunctionComponent = (parentNode, node) => {
55
55
  startsWithUppercase(node.id.name) &&
56
56
  !isComponentExplicitlyUnmemoized(node.id.name));
57
57
  };
58
+ const MEMO_NAME = 'memo';
58
59
  function isMemoImport(importPath) {
59
60
  // Match both absolute and relative paths ending with util/memo
60
61
  return /(?:^|\/|\\)util\/memo$/.test(importPath);
61
62
  }
63
+ /**
64
+ * Value `util/memo` import declarations in source order. A type-only
65
+ * declaration (`import type { MemoOptions } from '../util/memo'`) is excluded
66
+ * because a specifier appended to one erases at compile time, leaving the
67
+ * emitted `memo(...)` call unbound at runtime.
68
+ */
69
+ function memoValueImports(program) {
70
+ return program.body.filter((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
71
+ statement.importKind !== 'type' &&
72
+ isMemoImport(statement.source.value));
73
+ }
74
+ /**
75
+ * A named specifier that binds `memo` under its own name — the only shape that
76
+ * makes the emitted `memo(...)` call reach the helper. An alias in either
77
+ * direction (`memo as m`, `createMemo as memo`) or a type-only specifier binds
78
+ * something else, or nothing at all, at runtime.
79
+ */
80
+ function isMemoSpecifier(specifier) {
81
+ return (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
82
+ specifier.importKind !== 'type' &&
83
+ specifier.imported.name === MEMO_NAME &&
84
+ specifier.local.name === MEMO_NAME);
85
+ }
86
+ /**
87
+ * Import state is read off `Program.body` at fix time rather than from a
88
+ * traversal flag: a component that precedes the import declaration in source
89
+ * order is fixed before an `ImportDeclaration` visitor would have run, so a
90
+ * flag would call the import missing and insert a duplicate.
91
+ */
92
+ function importsMemo(program) {
93
+ return memoValueImports(program).some((declaration) => declaration.specifiers.some(isMemoSpecifier));
94
+ }
95
+ /**
96
+ * Whether every declaration of a visible `memo` binding is the helper import.
97
+ * A const/let/function/class, a parameter, a namespace or default import, an
98
+ * alias, or a named import from another module all mean the emitted `memo(...)`
99
+ * call would resolve somewhere other than the helper.
100
+ */
101
+ function bindsMemoHelper(variable) {
102
+ return (variable.defs.length > 0 &&
103
+ variable.defs.every((def) => {
104
+ const specifier = def.node;
105
+ if (!isMemoSpecifier(specifier)) {
106
+ return false;
107
+ }
108
+ const declaration = specifier.parent;
109
+ return (declaration?.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
110
+ declaration.importKind !== 'type' &&
111
+ isMemoImport(declaration.source.value));
112
+ }));
113
+ }
114
+ /**
115
+ * Extends an existing value `util/memo` import with a `memo` specifier instead
116
+ * of adding a second declaration. A namespace-only or side-effect-only
117
+ * declaration has nowhere to put a named specifier, so those fall through to a
118
+ * separate declaration (null).
119
+ */
120
+ function buildImportExtensionFix(fixer, program) {
121
+ for (const declaration of memoValueImports(program)) {
122
+ const named = declaration.specifiers.filter((specifier) => specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier);
123
+ if (named.length > 0) {
124
+ return fixer.insertTextAfter(named[named.length - 1], `, ${MEMO_NAME}`);
125
+ }
126
+ const defaultSpecifier = declaration.specifiers.find((specifier) => specifier.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier);
127
+ if (defaultSpecifier) {
128
+ return fixer.insertTextAfter(defaultSpecifier, `, { ${MEMO_NAME} }`);
129
+ }
130
+ }
131
+ return null;
132
+ }
62
133
  function checkFunction(context, node) {
63
134
  const fileName = context.getFilename();
64
135
  if (!fileName.endsWith('.tsx')) {
@@ -96,33 +167,37 @@ function checkFunction(context, node) {
96
167
  return null;
97
168
  }
98
169
  const sourceCode = context.sourceCode;
99
- let importFix = null;
100
- // Search for memo import statement
101
- const importDeclarations = sourceCode.ast.body.filter((node) => node.type === 'ImportDeclaration');
102
- const memoImport = importDeclarations.find((importDeclaration) => isMemoImport(importDeclaration.source.value));
103
- if (memoImport) {
104
- // Check if memo is already imported
105
- if (!memoImport.specifiers.some((specifier) => specifier.local.name === 'memo')) {
106
- // Add memo to existing import statement
107
- const lastSpecifier = memoImport.specifiers[memoImport.specifiers.length - 1];
108
- importFix = fixer.insertTextAfter(lastSpecifier, ', memo');
109
- }
170
+ const program = sourceCode.ast;
171
+ // Resolve `memo` through the scope chain at the fixed node. A
172
+ // binding that is not the helper import breaks the edit two
173
+ // ways: the inserted import collides with the existing
174
+ // declaration (TS2440, or TS2300 when that declaration is
175
+ // itself an import), and a binding visible at the fix site
176
+ // captures the emitted call with no compile error at all.
177
+ // Declining leaves the report standing so the author resolves
178
+ // the clash deliberately. `React.memo` is a member access on
179
+ // the default import rather than a `memo` binding, so it never
180
+ // reaches this path.
181
+ const existingMemo = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, node), MEMO_NAME);
182
+ if (existingMemo && !bindsMemoHelper(existingMemo)) {
183
+ return null;
110
184
  }
111
- else {
112
- // Calculate relative path based on current file location
113
- const currentFilePath = context.getFilename();
114
- const importPath = calculateImportPath(currentFilePath);
115
- // Find the first import statement to insert after
116
- const firstImport = importDeclarations[0];
117
- // Add new import statement for memo
118
- const importStatement = `import { memo } from '${importPath}';\n`;
119
- if (firstImport) {
120
- // Insert after the first import with a single newline
121
- importFix = fixer.insertTextAfter(firstImport, '\n' + importStatement.trim());
122
- }
123
- else {
124
- // Insert at the start of the file
125
- importFix = fixer.insertTextBeforeRange([sourceCode.ast.range[0], sourceCode.ast.range[0]], importStatement.trim() + '\n');
185
+ let importFix = null;
186
+ if (!importsMemo(program)) {
187
+ importFix = buildImportExtensionFix(fixer, program);
188
+ if (!importFix) {
189
+ // Calculate relative path based on current file location
190
+ const currentFilePath = context.getFilename();
191
+ const importPath = calculateImportPath(currentFilePath);
192
+ // Find the first import statement to insert after
193
+ const firstImport = program.body.find((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
194
+ // Add new import statement for memo
195
+ const importStatement = `import { memo } from '${importPath}';`;
196
+ importFix = firstImport
197
+ ? // Insert after the first import with a single newline
198
+ fixer.insertTextAfter(firstImport, '\n' + importStatement)
199
+ : // Insert at the start of the file
200
+ fixer.insertTextBeforeRange([program.range[0], program.range[0]], importStatement + '\n');
126
201
  }
127
202
  }
128
203
  const functionKeywordRange = [
@@ -3,6 +3,47 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.useLatestCallback = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
+ const ASTHelpers_1 = require("../utils/ASTHelpers");
7
+ const LATEST_CALLBACK_MODULE = 'use-latest-callback';
8
+ const LATEST_CALLBACK_HOOK = 'useLatestCallback';
9
+ /**
10
+ * A specifier that binds the hook itself as a value. The module's sole export
11
+ * is the hook, so a default specifier binds it under any local name. A
12
+ * namespace specifier binds the module object rather than a callable, and a
13
+ * type-only specifier erases at compile time, so neither can carry the call.
14
+ */
15
+ const bindsLatestCallback = (specifier) => {
16
+ if (specifier.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier) {
17
+ return true;
18
+ }
19
+ return (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
20
+ specifier.importKind !== 'type' &&
21
+ specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
22
+ specifier.imported.name === LATEST_CALLBACK_HOOK);
23
+ };
24
+ /**
25
+ * The hook's import read off `Program.body` rather than off a flag set by an
26
+ * ImportDeclaration visitor, so the answer holds wherever the import sits
27
+ * relative to the fix site. Reading the AST also exposes the shapes a
28
+ * name-only flag conflates with a usable binding: a namespace import, a
29
+ * type-only import, and a reverse alias
30
+ * (`import { other as useLatestCallback }`) each bind a name without binding
31
+ * the hook.
32
+ */
33
+ const findLatestCallbackImport = (program) => {
34
+ for (const statement of program.body) {
35
+ if (statement.type !== utils_1.AST_NODE_TYPES.ImportDeclaration ||
36
+ statement.source.value !== LATEST_CALLBACK_MODULE ||
37
+ (statement.importKind && statement.importKind !== 'value')) {
38
+ continue;
39
+ }
40
+ const specifier = statement.specifiers.find(bindsLatestCallback);
41
+ if (specifier) {
42
+ return { specifier, reference: specifier.local.name };
43
+ }
44
+ }
45
+ return null;
46
+ };
6
47
  exports.useLatestCallback = (0, createRule_1.createRule)({
7
48
  name: 'use-latest-callback',
8
49
  meta: {
@@ -23,9 +64,6 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
23
64
  if (filename.includes('/node_modules/')) {
24
65
  return {};
25
66
  }
26
- // Track if the file already has a useLatestCallback import
27
- let hasUseLatestCallbackImport = false;
28
- let useLatestCallbackImportName = 'useLatestCallback';
29
67
  const useCallbackLocalNames = new Set();
30
68
  const reactNamespaceNames = new Set();
31
69
  const reactDefaultLikeNames = new Set();
@@ -77,31 +115,7 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
77
115
  ImportDeclaration(node) {
78
116
  if (node.importKind && node.importKind !== 'value')
79
117
  return;
80
- if (node.source.value === 'use-latest-callback') {
81
- // Check if useLatestCallback is imported
82
- const specifiers = node.specifiers.filter((specifier) => {
83
- if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier) {
84
- return (specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
85
- specifier.imported.name === 'useLatestCallback');
86
- }
87
- return (specifier.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier ||
88
- specifier.type === utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier);
89
- });
90
- if (specifiers.length > 0) {
91
- hasUseLatestCallbackImport = true;
92
- // Get the local name of the import (in case it's renamed)
93
- if (specifiers[0].type === utils_1.AST_NODE_TYPES.ImportSpecifier) {
94
- useLatestCallbackImportName = specifiers[0].local.name;
95
- }
96
- else if (specifiers[0].type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier) {
97
- useLatestCallbackImportName = specifiers[0].local.name;
98
- }
99
- else if (specifiers[0].type === utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier) {
100
- useLatestCallbackImportName = `${specifiers[0].local.name}.useLatestCallback`;
101
- }
102
- }
103
- }
104
- else if (node.source.value === 'react') {
118
+ if (node.source.value === 'react') {
105
119
  // Check if useCallback is imported from React
106
120
  const useCallbackSpecifiers = node.specifiers.filter((specifier) => {
107
121
  return (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
@@ -171,6 +185,7 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
171
185
  callee: node.callee.type === utils_1.AST_NODE_TYPES.Identifier
172
186
  ? node.callee
173
187
  : null,
188
+ scope: ASTHelpers_1.ASTHelpers.getScope(context, node),
174
189
  });
175
190
  }
176
191
  }
@@ -226,23 +241,47 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
226
241
  scope.variables.forEach((variable) => taken.add(variable.name));
227
242
  scope.childScopes.forEach(collectNames);
228
243
  };
229
- collectNames(context.getScope());
230
- let name = 'useLatestCallback';
244
+ collectNames(ASTHelpers_1.ASTHelpers.getScope(context, program));
245
+ let name = LATEST_CALLBACK_HOOK;
231
246
  let suffix = 2;
232
247
  while (taken.has(name)) {
233
- name = `useLatestCallback${suffix++}`;
248
+ name = `${LATEST_CALLBACK_HOOK}${suffix++}`;
234
249
  }
235
250
  return name;
236
251
  };
252
+ const latestCallbackImport = findLatestCallbackImport(program);
237
253
  const useCallbackLocalName = specifiers[0]?.local.name ?? 'useCallback';
238
- const recommendedHook = hasUseLatestCallbackImport
239
- ? useLatestCallbackImportName
254
+ const recommendedHook = latestCallbackImport
255
+ ? latestCallbackImport.reference
240
256
  : hasSurvivingReference
241
257
  ? freeImportName()
242
258
  : useCallbackLocalName === 'useCallback'
243
- ? 'useLatestCallback'
259
+ ? LATEST_CALLBACK_HOOK
244
260
  : useCallbackLocalName;
245
- const importText = `import ${recommendedHook} from 'use-latest-callback';`;
261
+ const importText = `import ${recommendedHook} from '${LATEST_CALLBACK_MODULE}';`;
262
+ // The react specifiers this fix deletes stop binding their names, so a
263
+ // name that comes only from them is free for the rewritten calls to
264
+ // claim — that is how an aliased `useCallback as uc` hands `uc` over to
265
+ // the new import.
266
+ const releasedSpecifiers = hasSurvivingReference || specifiers.length === 0 ? [] : specifiers;
267
+ const claimableDefinitions = new Set(releasedSpecifiers);
268
+ if (latestCallbackImport) {
269
+ claimableDefinitions.add(latestCallbackImport.specifier);
270
+ }
271
+ // Whether the rewritten call reaches the hook rather than an unrelated
272
+ // binding of the same name. Resolving through the scope chain at each
273
+ // rewritten call catches both failure modes: a module-scope binding
274
+ // that the inserted import would redeclare (TS2440, or TS2300 against
275
+ // another import), and a narrower shadow that silently captures the
276
+ // call with no diagnostic at all.
277
+ const reachesHook = (conversion) => {
278
+ const existing = ASTHelpers_1.ASTHelpers.findVariableInScope(conversion.scope, recommendedHook);
279
+ if (!existing) {
280
+ return true;
281
+ }
282
+ return (existing.defs.length > 0 &&
283
+ existing.defs.every((def) => claimableDefinitions.has(def.node)));
284
+ };
246
285
  // The react import statement participates in the change set only when
247
286
  // it binds useCallback or anchors a React.useCallback member call.
248
287
  const touchesImport = specifiers.length > 0 || hasReactMemberUseCallback;
@@ -253,7 +292,7 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
253
292
  // A surviving reference (or a member-only file) leaves the react
254
293
  // import untouched; only the new import is added when missing.
255
294
  if (hasSurvivingReference || specifiers.length === 0) {
256
- if (hasUseLatestCallbackImport) {
295
+ if (latestCallbackImport) {
257
296
  return [];
258
297
  }
259
298
  return [fixer.insertTextBefore(statement, `${importText}\n`)];
@@ -267,7 +306,7 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
267
306
  ? 'import type'
268
307
  : 'import';
269
308
  if (remainingNamed.length === 0 && !defaultOrNamespace) {
270
- if (!hasUseLatestCallbackImport) {
309
+ if (!latestCallbackImport) {
271
310
  return [fixer.replaceText(statement, importText)];
272
311
  }
273
312
  return [fixer.remove(statement)];
@@ -283,7 +322,7 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
283
322
  }
284
323
  const replacement = `${prefix} ${parts.join(', ')} from 'react';`;
285
324
  const fixes = [];
286
- if (!hasUseLatestCallbackImport) {
325
+ if (!latestCallbackImport) {
287
326
  fixes.push(fixer.insertTextBefore(statement, `${importText}\n`));
288
327
  }
289
328
  fixes.push(fixer.replaceText(statement, replacement));
@@ -313,6 +352,14 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
313
352
  recommendedHook,
314
353
  },
315
354
  fix(fixer) {
355
+ // Withhold the whole edit — nothing before this point mutates
356
+ // state, so a withheld pass cannot leave a later one believing the
357
+ // import is in place. The violation still stands as a report so
358
+ // the author resolves the name clash deliberately. The batch is
359
+ // atomic, so one unreachable call site withholds all of it.
360
+ if (!batchedConversions.every(reachesHook)) {
361
+ return null;
362
+ }
316
363
  return [
317
364
  ...batchedConversions.map((conversion) => conversionFix(fixer, conversion)),
318
365
  ...importFixes(fixer),
@@ -332,7 +379,7 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
332
379
  if (!touchesImport) {
333
380
  return;
334
381
  }
335
- if (hasSurvivingReference && hasUseLatestCallbackImport) {
382
+ if (hasSurvivingReference && latestCallbackImport) {
336
383
  return; // The react import stays as is and the new import exists
337
384
  }
338
385
  context.report({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.30",
3
+ "version": "1.20.31",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",