@blumintinc/eslint-plugin-blumint 1.20.68 → 1.20.70

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.68',
226
+ version: '1.20.70',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -80,25 +80,42 @@ exports.classMethodsReadTopToBottom = (0, createRule_1.createRule)({
80
80
  if (actualMember !== expectedMember) {
81
81
  const classNameReport = className || 'this class';
82
82
  const sourceCode = context.getSourceCode();
83
- const newClassBody = sortedOrder
84
- .map((n) => {
85
- // Fetch the actual AST node corresponding to the name
86
- const memberNode = node.body.find((member) => getMemberName(member) === n);
87
- if (!memberNode) {
88
- return '';
89
- }
90
- const comments = sourceCode.getCommentsBefore(memberNode) || [];
91
- const nodeRange = memberNode.range;
92
- const newRange = comments.length > 0
93
- ? [
94
- Math.min(nodeRange[0], Math.min(...comments.map((comment) => comment.range[0]))),
95
- Math.max(nodeRange[1], Math.max(...comments.map((comment) => comment.range[1]))),
96
- ]
97
- : nodeRange;
98
- return sourceCode.getText({ ...memberNode, range: newRange });
99
- })
100
- .filter(Boolean)
101
- .join('\n');
83
+ const sourceText = sourceCode.getText();
84
+ // A member's block spans its leading comments through its own end,
85
+ // so documentation travels with the member it describes. Because
86
+ // every comment in the body is thereby absorbed into some block,
87
+ // the text between two adjacent blocks is pure whitespace.
88
+ const memberBlocks = node.body.map((member) => {
89
+ const comments = sourceCode.getCommentsBefore(member) || [];
90
+ const start = Math.min(member.range[0], ...comments.map((comment) => comment.range[0]));
91
+ return {
92
+ name: getMemberName(member),
93
+ text: sourceText.slice(start, member.range[1]),
94
+ start,
95
+ end: member.range[1],
96
+ };
97
+ });
98
+ // Reuse those whitespace runs positionally instead of joining with
99
+ // a bare '\n'. The blank lines between members are the part that
100
+ // matters: prettier preserves existing blank lines but never
101
+ // inserts new ones, so collapsing them is irreversible (#1592).
102
+ // Carrying the runs verbatim also reproduces the newline and
103
+ // indentation after `{` and the newline before `}` for free, since
104
+ // every member sits at the same depth.
105
+ const separators = memberBlocks
106
+ .slice(1)
107
+ .map((block, index) => sourceText.slice(memberBlocks[index].end, block.start));
108
+ const prefix = sourceText.slice(node.range[0] + 1, memberBlocks[0].start);
109
+ const suffix = sourceText.slice(memberBlocks[memberBlocks.length - 1].end, node.range[1] - 1);
110
+ const newClassBody = prefix +
111
+ sortedOrder
112
+ .map((n) => {
113
+ const block = memberBlocks.find(({ name }) => name === n);
114
+ return block ? block.text : '';
115
+ })
116
+ .map((text, index) => index === 0 ? text : separators[index - 1] + text)
117
+ .join('') +
118
+ suffix;
102
119
  return context.report({
103
120
  node,
104
121
  messageId: 'classMethodsReadTopToBottom',
@@ -3,6 +3,25 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.enforceEarlyDestructuring = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
+ /**
7
+ * Source forms that already bind tighter than `??`, so wrapping them in the
8
+ * hoisted `(obj) ?? {}` initializer adds parentheses a formatter then strips.
9
+ *
10
+ * The set is deliberately limited to what this rule can actually emit: only an
11
+ * identifier-rooted source is ever hoisted, so a call, conditional or logical
12
+ * source never reaches here. Anything absent keeps its parentheses, which is the
13
+ * safe direction — a stray pair is cosmetic, a missing pair changes what the
14
+ * initializer evaluates. `as` and `satisfies` are excluded on purpose: TypeScript
15
+ * rejects them beside `??` unparenthesized.
16
+ */
17
+ const TIGHTER_THAN_NULLISH = new Set([
18
+ utils_1.AST_NODE_TYPES.Identifier,
19
+ utils_1.AST_NODE_TYPES.ThisExpression,
20
+ utils_1.AST_NODE_TYPES.MemberExpression,
21
+ utils_1.AST_NODE_TYPES.ChainExpression,
22
+ utils_1.AST_NODE_TYPES.TSNonNullExpression,
23
+ ]);
24
+ const nullishSourceText = (objectText, init) => init && TIGHTER_THAN_NULLISH.has(init.type) ? objectText : `(${objectText})`;
6
25
  const HOOK_NAMES = new Set([
7
26
  'useEffect',
8
27
  'useMemo',
@@ -875,7 +894,7 @@ function generateHoistingFixes(groups, callback, depsArray, depTexts, insertionS
875
894
  for (const group of groups.values()) {
876
895
  const sortedProps = Array.from(group.properties.values()).sort((a, b) => a.order - b.order);
877
896
  const pattern = `{ ${sortedProps.map((p) => p.text).join(', ')} }`;
878
- hoistedLines.push(`${indent}const ${pattern} = (${group.objectText}) ?? {};`);
897
+ hoistedLines.push(`${indent}const ${pattern} = ${nullishSourceText(group.objectText, group.inits[0])} ?? {};`);
879
898
  }
880
899
  reservedNamesByScope.set(scope, updatedReservedNames);
881
900
  const newDepSet = new Set(newDepTexts);
@@ -1,3 +1,4 @@
1
+ import { TSESLint } from '@typescript-eslint/utils';
1
2
  type MessageIds = 'requireMemoizedTransformBefore' | 'requireMemoizedRender' | 'requireMemoizedRenderHits' | 'noDirectComponentInRender';
2
- export declare const enforceRenderHitsMemoization: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<MessageIds, [], import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
3
+ export declare const enforceRenderHitsMemoization: TSESLint.RuleModule<MessageIds, [], TSESLint.RuleListener>;
3
4
  export {};
@@ -3,6 +3,31 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.enforceRenderHitsMemoization = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
+ const LATEST_CALLBACK_MODULE = 'use-latest-callback';
7
+ const LATEST_CALLBACK_HOOK = 'useLatestCallback';
8
+ /**
9
+ * Declaration forms whose binding is created once for the program's lifetime.
10
+ *
11
+ * `let` and `var` are deliberately excluded: a reassignable binding can hand
12
+ * `useRenderHits` a different function on a later render, which is precisely
13
+ * the instability this rule exists to catch.
14
+ */
15
+ function isStableDeclaration(def) {
16
+ switch (def.type) {
17
+ case utils_1.TSESLint.Scope.DefinitionType.FunctionName:
18
+ return true;
19
+ case utils_1.TSESLint.Scope.DefinitionType.ImportBinding:
20
+ // A type-only import binds no value, so its local name names nothing
21
+ // callable — the same reason the memoization-callee set rejects one.
22
+ return (def.parent.importKind !== 'type' &&
23
+ (def.node.type !== utils_1.AST_NODE_TYPES.ImportSpecifier ||
24
+ def.node.importKind !== 'type'));
25
+ case utils_1.TSESLint.Scope.DefinitionType.Variable:
26
+ return def.parent.kind === 'const';
27
+ default:
28
+ return false;
29
+ }
30
+ }
6
31
  exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
7
32
  name: 'enforce-render-hits-memoization',
8
33
  meta: {
@@ -13,14 +38,36 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
13
38
  },
14
39
  schema: [],
15
40
  messages: {
16
- requireMemoizedTransformBefore: 'transformBefore prop must be memoized using useCallback',
17
- requireMemoizedRender: 'render prop must be memoized using useCallback',
18
- requireMemoizedRenderHits: 'renderHits must be used inside useMemo or useCallback',
41
+ requireMemoizedTransformBefore: 'transformBefore is recreated on every render, so useRenderHits sees a new transform identity each pass and re-derives (and re-renders) the whole hit list even when the hits did not change. Memoize it with useCallback, useMemo, or useLatestCallback so the reference stays stable across renders.',
42
+ requireMemoizedRender: 'render is recreated on every render, so useRenderHits sees a new render identity each pass and re-renders every hit even when the hits did not change. Memoize it with useCallback, useMemo, or useLatestCallback so the reference stays stable across renders.',
43
+ requireMemoizedRenderHits: 'renderHits builds a fresh element for every hit, so calling it outside a memoization boundary re-creates the entire list on each render. Wrap the call in useCallback, useMemo, or useLatestCallback so the elements are rebuilt only when the hits they came from change.',
19
44
  noDirectComponentInRender: 'Do not pass React components directly to render prop, use a memoized arrow function instead',
20
45
  },
21
46
  },
22
47
  defaultOptions: [],
23
48
  create(context) {
49
+ // Every callee this rule accepts as a memoization boundary. `useCallback`
50
+ // and `useMemo` are seeded bare because the rule never resolves React's
51
+ // import either; local names bound from `use-latest-callback` are added as
52
+ // its imports are visited.
53
+ //
54
+ // `useLatestCallback` belongs in the same set rather than a separate one:
55
+ // nothing here inspects a dependency array, so the hook taking none (it
56
+ // keeps the latest callback behind a ref that is stable for the component's
57
+ // whole life) costs the rule no precision. It has to be here because
58
+ // `use-latest-callback` — 'error' in the same recommended config, and
59
+ // fixable — rewrites every `useCallback` into it, and ESLint re-lints until
60
+ // the output settles, so one `eslint --fix` run does both steps. Without
61
+ // this entry correctly memoized code goes in and a demand to memoize it
62
+ // comes out, and the demand names the very hook the sibling fixer just
63
+ // removed, so following it loops forever (issue #1585).
64
+ const memoizationCallees = new Set([
65
+ 'useCallback',
66
+ 'useMemo',
67
+ LATEST_CALLBACK_HOOK,
68
+ ]);
69
+ const isMemoizationCallee = (node) => node.type === utils_1.AST_NODE_TYPES.Identifier &&
70
+ memoizationCallees.has(node.name);
24
71
  const isReactComponent = (node) => {
25
72
  if (node.type !== utils_1.AST_NODE_TYPES.Identifier)
26
73
  return false;
@@ -29,17 +76,30 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
29
76
  const isMemoizedCall = (node) => {
30
77
  if (node.type !== utils_1.AST_NODE_TYPES.CallExpression)
31
78
  return false;
32
- if (!node.callee || node.callee.type !== utils_1.AST_NODE_TYPES.Identifier)
79
+ if (!node.callee)
33
80
  return false;
34
- return (node.callee.name === 'useCallback' || node.callee.name === 'useMemo');
81
+ return isMemoizationCallee(node.callee);
82
+ };
83
+ const isWithinMemoizationCall = (node) => {
84
+ let current = node;
85
+ while (current?.parent) {
86
+ if (current.parent.type === utils_1.AST_NODE_TYPES.CallExpression &&
87
+ isMemoizationCallee(current.parent.callee)) {
88
+ return true;
89
+ }
90
+ current = current.parent;
91
+ }
92
+ return false;
35
93
  };
36
94
  const isMemoizedVariable = (node) => {
37
95
  if (node.type !== utils_1.AST_NODE_TYPES.Identifier)
38
96
  return false;
39
- // Get the variable declaration for this identifier
40
- const variable = context
41
- .getScope()
42
- .variables.find((v) => v.name === node.name);
97
+ // The whole scope chain has to be searched rather than the current
98
+ // scope's own variable list: a useRenderHits call sitting inside a nested
99
+ // block or a nested component reaches its memoized declaration through an
100
+ // enclosing scope, and reading one scope's `variables` would miss it and
101
+ // demand a useCallback around a value that already has one.
102
+ const variable = utils_1.ASTUtils.findVariable(context.getScope(), node);
43
103
  if (!variable)
44
104
  return false;
45
105
  // Check if the variable is initialized with a memoized call
@@ -55,6 +115,45 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
55
115
  }
56
116
  return false;
57
117
  };
118
+ /**
119
+ * A prop pointing at a declaration that lives outside every component body.
120
+ *
121
+ * Module scope creates the binding once for the program's lifetime, so its
122
+ * identity is strictly more stable than anything a hook can hand back:
123
+ * demanding a `useCallback` wrapper around it asks for work that can only
124
+ * make the reference less stable, never more.
125
+ *
126
+ * Both `module` and `global` count. Under `sourceType: 'script'` — the
127
+ * parser default, and what a consumer's config may well leave in place — a
128
+ * top-level declaration binds to the *global* scope and no module scope
129
+ * exists at all (issue #1578), so keying on `module` alone would silently
130
+ * drop the carve-out for exactly the consumers who never opted into module
131
+ * parsing.
132
+ *
133
+ * The shape is not hypothetical:
134
+ * `no-empty-dependency-use-callbacks` — 'error' in the same recommended
135
+ * config, and fixable — hoists a dependency-free callback to module scope
136
+ * and drops the hook, so one `eslint --fix` run rewrites memoized code into
137
+ * exactly this form. Without the carve-out the config demands the very hook
138
+ * its own fixer just removed (issue #1586).
139
+ */
140
+ const isStableOuterScopeBinding = (node) => {
141
+ if (node.type !== utils_1.AST_NODE_TYPES.Identifier)
142
+ return false;
143
+ // The scope chain has to be walked rather than a single scope's variable
144
+ // list read: the useRenderHits call sits inside the component, so a
145
+ // module-scope declaration is never among the current scope's own
146
+ // variables.
147
+ const variable = utils_1.ASTUtils.findVariable(context.getScope(), node);
148
+ if (!variable)
149
+ return false;
150
+ const scopeType = variable.scope.type;
151
+ if (scopeType !== utils_1.TSESLint.Scope.ScopeType.module &&
152
+ scopeType !== utils_1.TSESLint.Scope.ScopeType.global) {
153
+ return false;
154
+ }
155
+ return variable.defs.some(isStableDeclaration);
156
+ };
58
157
  const isInsideMemoizedCall = (node) => {
59
158
  // Handle the case when node is already a memoized call
60
159
  if (isMemoizedCall(node))
@@ -62,18 +161,13 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
62
161
  // Check if the node is a reference to a memoized variable
63
162
  if (isMemoizedVariable(node))
64
163
  return true;
164
+ // A declaration outside every component body needs no memoization: it is
165
+ // already as stable as a reference can be.
166
+ if (isStableOuterScopeBinding(node))
167
+ return true;
65
168
  // Check if the node is inside a memoization hook call
66
- let current = node;
67
- while (current?.parent) {
68
- if (current.parent.type === utils_1.AST_NODE_TYPES.CallExpression) {
69
- const callee = current.parent.callee;
70
- if (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
71
- (callee.name === 'useCallback' || callee.name === 'useMemo')) {
72
- return true;
73
- }
74
- }
75
- current = current.parent;
76
- }
169
+ if (isWithinMemoizationCall(node))
170
+ return true;
77
171
  // Check if the node is a reference to a memoized value
78
172
  const scope = context.getScope();
79
173
  // Make sure node is an Identifier before accessing name property
@@ -88,44 +182,22 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
88
182
  for (const def of variable.defs) {
89
183
  const parent = def.node.parent;
90
184
  if (parent?.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
91
- parent.init?.type === utils_1.AST_NODE_TYPES.CallExpression) {
92
- const callee = parent.init.callee;
93
- if (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
94
- (callee.name === 'useCallback' || callee.name === 'useMemo')) {
95
- return true;
96
- }
185
+ parent.init?.type === utils_1.AST_NODE_TYPES.CallExpression &&
186
+ isMemoizationCallee(parent.init.callee)) {
187
+ return true;
97
188
  }
98
189
  }
99
190
  // Check if any reference is inside a memoized call
100
191
  for (const ref of variable.references) {
101
- let current = ref.identifier;
102
- while (current?.parent) {
103
- if (current.parent.type === utils_1.AST_NODE_TYPES.CallExpression) {
104
- const callee = current.parent.callee;
105
- if (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
106
- (callee.name === 'useCallback' || callee.name === 'useMemo')) {
107
- return true;
108
- }
109
- }
110
- current = current.parent;
111
- }
192
+ if (isWithinMemoizationCall(ref.identifier))
193
+ return true;
112
194
  }
113
195
  // Check if the node is a property of an object that is memoized
114
196
  const parent = node.parent;
115
197
  if (parent?.type === utils_1.AST_NODE_TYPES.Property &&
116
198
  parent.parent?.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
117
- const objectExpression = parent.parent;
118
- let current = objectExpression;
119
- while (current?.parent) {
120
- if (current.parent.type === utils_1.AST_NODE_TYPES.CallExpression) {
121
- const callee = current.parent.callee;
122
- if (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
123
- (callee.name === 'useCallback' || callee.name === 'useMemo')) {
124
- return true;
125
- }
126
- }
127
- current = current.parent;
128
- }
199
+ if (isWithinMemoizationCall(parent.parent))
200
+ return true;
129
201
  }
130
202
  return false;
131
203
  };
@@ -133,6 +205,23 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
133
205
  let renderHitsName = 'renderHits';
134
206
  return {
135
207
  ImportDeclaration(node) {
208
+ // The module's sole export is the hook, so its DEFAULT specifier binds
209
+ // it under whatever local name the file chose — a shape a set of bare
210
+ // hook names cannot see. `use-latest-callback`'s own fixer picks that
211
+ // name, falling back to `useLatestCallback2` when `useLatestCallback`
212
+ // is already taken in the file, so the alias is not hypothetical.
213
+ if (node.source.value === LATEST_CALLBACK_MODULE &&
214
+ (!node.importKind || node.importKind === 'value')) {
215
+ for (const specifier of node.specifiers) {
216
+ if (specifier.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier ||
217
+ (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
218
+ specifier.importKind !== 'type' &&
219
+ specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
220
+ specifier.imported.name === LATEST_CALLBACK_HOOK)) {
221
+ memoizationCallees.add(specifier.local.name);
222
+ }
223
+ }
224
+ }
136
225
  if (node.source.value.endsWith('useRenderHits')) {
137
226
  for (const specifier of node.specifiers) {
138
227
  if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
@@ -160,43 +249,19 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
160
249
  const options = node.arguments[0];
161
250
  if (options.type !== utils_1.AST_NODE_TYPES.ObjectExpression)
162
251
  return;
163
- // Variable to track if we need to check properties (check both by default)
164
- const checkProps = {
165
- transformBefore: true,
166
- render: true,
167
- };
168
- // First pass: Check if transformBefore or render properties exist as shorthand
169
- for (const prop of options.properties) {
170
- if (prop.type !== utils_1.AST_NODE_TYPES.Property)
171
- continue;
172
- if (prop.key.type !== utils_1.AST_NODE_TYPES.Identifier)
173
- continue;
174
- // If it's shorthand property syntax like { transformBefore } and already a memoized variable
175
- if (prop.key.name === 'transformBefore' &&
176
- prop.shorthand &&
177
- prop.key.type === utils_1.AST_NODE_TYPES.Identifier) {
178
- checkProps.transformBefore = !isMemoizedVariable(prop.key);
179
- }
180
- else if (prop.key.name === 'render' &&
181
- prop.shorthand &&
182
- prop.key.type === utils_1.AST_NODE_TYPES.Identifier) {
183
- checkProps.render = !isMemoizedVariable(prop.key);
184
- }
185
- }
186
- // Second pass: Check non-shorthand properties
252
+ // Shorthand props are checked exactly like written-out ones. `{ render }`
253
+ // and `render: render` describe the same value, and the config's own
254
+ // fixable `object-shorthand: ['error', 'always']` rewrites the second
255
+ // into the first, so exempting the shorthand form would let a single
256
+ // `eslint --fix` erase every report this rule makes about a prop whose
257
+ // variable happens to share the API's name the shape idiomatic code
258
+ // reaches for first (issue #1588).
187
259
  for (const prop of options.properties) {
188
260
  if (prop.type !== utils_1.AST_NODE_TYPES.Property)
189
261
  continue;
190
262
  if (prop.key.type !== utils_1.AST_NODE_TYPES.Identifier)
191
263
  continue;
192
- // Skip shorthand properties that we already checked
193
- if (prop.shorthand)
194
- continue;
195
- if (prop.key.name === 'transformBefore' &&
196
- checkProps.transformBefore) {
197
- // Skip if the value is already a memoized call
198
- if (isMemoizedCall(prop.value))
199
- continue;
264
+ if (prop.key.name === 'transformBefore') {
200
265
  if (!isInsideMemoizedCall(prop.value)) {
201
266
  context.report({
202
267
  node: prop.value,
@@ -204,7 +269,7 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
204
269
  });
205
270
  }
206
271
  }
207
- else if (prop.key.name === 'render' && checkProps.render) {
272
+ else if (prop.key.name === 'render') {
208
273
  if (isReactComponent(prop.value)) {
209
274
  context.report({
210
275
  node: prop.value,
@@ -222,17 +287,8 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
222
287
  }
223
288
  if (node.callee.type === utils_1.AST_NODE_TYPES.Identifier &&
224
289
  node.callee.name === renderHitsName) {
225
- let current = node;
226
- while (current?.parent) {
227
- if (current.parent.type === utils_1.AST_NODE_TYPES.CallExpression) {
228
- const callee = current.parent.callee;
229
- if (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
230
- (callee.name === 'useCallback' || callee.name === 'useMemo')) {
231
- return;
232
- }
233
- }
234
- current = current.parent;
235
- }
290
+ if (isWithinMemoizationCall(node))
291
+ return;
236
292
  context.report({
237
293
  node,
238
294
  messageId: 'requireMemoizedRenderHits',
@@ -4,6 +4,8 @@ exports.enforceTransformMemoization = 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 LATEST_CALLBACK_MODULE = 'use-latest-callback';
8
+ const LATEST_CALLBACK_HOOK = 'useLatestCallback';
7
9
  exports.enforceTransformMemoization = (0, createRule_1.createRule)({
8
10
  name: 'enforce-transform-memoization',
9
11
  meta: {
@@ -26,7 +28,14 @@ exports.enforceTransformMemoization = (0, createRule_1.createRule)({
26
28
  const scopeManager = sourceCode.scopeManager;
27
29
  const adaptValueNames = new Set(['adaptValue']);
28
30
  const memoizingHooks = new Set(['useMemo', 'useCallback']);
29
- const stabilizingUtilities = new Set(['useEvent']);
31
+ // Hooks that hand back a reference stable for the component's whole life and
32
+ // take no dependency array, so there is none to audit. `useLatestCallback`
33
+ // belongs here because `use-latest-callback` — 'error' in the same
34
+ // recommended config, and fixable — rewrites every `useCallback` into it and
35
+ // drops the array. ESLint re-lints until the output settles, so one
36
+ // `eslint --fix` run does both steps: without this entry, correctly
37
+ // memoized code goes in and a demand to memoize it comes out (issue #1584).
38
+ const stabilizingUtilities = new Set(['useEvent', LATEST_CALLBACK_HOOK]);
30
39
  const getPropertyName = (key) => {
31
40
  if (key.type === utils_1.AST_NODE_TYPES.Identifier) {
32
41
  return key.name;
@@ -378,6 +387,23 @@ exports.enforceTransformMemoization = (0, createRule_1.createRule)({
378
387
  return {
379
388
  ImportDeclaration(node) {
380
389
  const sourceValue = typeof node.source.value === 'string' ? node.source.value : '';
390
+ // The module's sole export is the hook, so its DEFAULT specifier binds
391
+ // it under whatever local name the file chose — a shape a set of bare
392
+ // hook names cannot see. `use-latest-callback`'s own fixer picks that
393
+ // name, and falls back to `useLatestCallback2` when `useLatestCallback`
394
+ // is already taken in the file, so the alias is not hypothetical.
395
+ if (sourceValue === LATEST_CALLBACK_MODULE &&
396
+ (!node.importKind || node.importKind === 'value')) {
397
+ for (const specifier of node.specifiers) {
398
+ if (specifier.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier ||
399
+ (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
400
+ specifier.importKind !== 'type' &&
401
+ specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
402
+ specifier.imported.name === LATEST_CALLBACK_HOOK)) {
403
+ stabilizingUtilities.add(specifier.local.name);
404
+ }
405
+ }
406
+ }
381
407
  for (const specifier of node.specifiers) {
382
408
  if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
383
409
  specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
@@ -381,6 +381,22 @@ exports.noUselessUsememoPrimitives = (0, createRule_1.createRule)({
381
381
  valueKind,
382
382
  },
383
383
  fix(fixer) {
384
+ // Inlining replaces the entire useMemo(...) call with the returned
385
+ // expression's text, so any comment inside the call but outside
386
+ // that expression — an eslint-disable-next-line directive on the
387
+ // return statement among them — has no representation in the
388
+ // replacement and would be silently destroyed, changing which
389
+ // rules report on the file (#1591). The inlined expression lands
390
+ // mid-line (e.g. `const label = <expr>;`), where a -next-line
391
+ // directive cannot be hosted, so the autofix declines and leaves
392
+ // the report for a manual fix.
393
+ const strandedComments = sourceCode
394
+ .getCommentsInside(node)
395
+ .filter((comment) => comment.range[0] < returnedExpression.range[0] ||
396
+ comment.range[1] > returnedExpression.range[1]);
397
+ if (strandedComments.length > 0) {
398
+ return null;
399
+ }
384
400
  const replacement = `(${sourceCode.getText(returnedExpression)})`;
385
401
  return fixer.replaceText(node, replacement);
386
402
  },
@@ -101,6 +101,12 @@ function isPrimitiveTypeNode(node) {
101
101
  * operands: comparisons and arithmetic yield numbers/strings/booleans, and `!`,
102
102
  * `typeof` and friends yield booleans/strings/numbers.
103
103
  */
104
+ /** `as const` — a type reference whose name is the `const` contextual keyword. */
105
+ function isConstAssertion(typeNode) {
106
+ return (typeNode.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
107
+ typeNode.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
108
+ typeNode.typeName.name === 'const');
109
+ }
104
110
  function isPrimitiveExpression(node) {
105
111
  if (!node) {
106
112
  return false;
@@ -120,7 +126,19 @@ function isPrimitiveExpression(node) {
120
126
  case utils_1.AST_NODE_TYPES.BinaryExpression:
121
127
  return true;
122
128
  case utils_1.AST_NODE_TYPES.TSAsExpression:
123
- return isPrimitiveTypeNode(node.typeAnnotation);
129
+ // `as const` narrows rather than retypes, so the asserted expression
130
+ // decides. Recursing keeps `{ a: 1 } as const` an object while accepting
131
+ // `0 as const` — the form global-const-style and
132
+ // enforce-object-literal-as-const rewrite bare constants into, so without
133
+ // this the plugin's own fixers manufacture the report (#1581).
134
+ return isConstAssertion(node.typeAnnotation)
135
+ ? isPrimitiveExpression(node.expression)
136
+ : isPrimitiveTypeNode(node.typeAnnotation);
137
+ // `satisfies` never changes the value, only checks it.
138
+ case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
139
+ return isPrimitiveExpression(node.expression);
140
+ case utils_1.AST_NODE_TYPES.TSNonNullExpression:
141
+ return isPrimitiveExpression(node.expression);
124
142
  default:
125
143
  return false;
126
144
  }