@blumintinc/eslint-plugin-blumint 1.20.24 → 1.20.26

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.
@@ -55,6 +55,21 @@ function isArrayOrPrimitive(checker, esTreeNode, nodeMap) {
55
55
  return false;
56
56
  }
57
57
  }
58
+ function renderPathSegments(baseName, segments) {
59
+ let path = baseName;
60
+ for (const segment of segments) {
61
+ if (segment.computed) {
62
+ // why: an optional computed access must render as `?.[` — a bare `?`
63
+ // before `[` parses as a conditional expression, so `state?[0]` is a
64
+ // syntax error while `state?.[0]` is the valid optional element access.
65
+ path += segment.optional ? `?.${segment.text}` : segment.text;
66
+ }
67
+ else {
68
+ path += segment.optional ? `?.${segment.text}` : `.${segment.text}`;
69
+ }
70
+ }
71
+ return path;
72
+ }
58
73
  function unwrapExpression(expr) {
59
74
  let current = expr;
60
75
  while (current.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
@@ -67,6 +82,10 @@ function unwrapExpression(expr) {
67
82
  }
68
83
  function getObjectUsagesInHook(hookBody, objectName) {
69
84
  const usages = new Map(); // Track usage and its position
85
+ // why: derived dependency paths (first-optional intermediate, array base)
86
+ // must be re-rendered from structured links — string surgery on the
87
+ // rendered path cannot place `?.` markers correctly.
88
+ const pathSegments = new Map();
70
89
  const visited = new Set();
71
90
  let needsEntireObject = false;
72
91
  let isUsed = false;
@@ -123,23 +142,33 @@ function getObjectUsagesInHook(hookBody, objectName) {
123
142
  'includes',
124
143
  ]);
125
144
  function buildAccessPath(node) {
126
- const parts = [];
145
+ // why: optionality belongs to individual links, not the whole chain.
146
+ // Rendering from per-link segments keeps `?.` markers at their real
147
+ // position (a.b?.[0] stays a.b?.[0], not a?.b[0]) and forms the mandatory
148
+ // `?.[` for optional computed access (state?.[0], never state?[0]).
149
+ const segments = [];
127
150
  let current = node;
128
- let hasOptionalChaining = false;
129
- // Collect all parts from leaf to root
151
+ // Collect all links from leaf to root
130
152
  while (current.type === utils_1.AST_NODE_TYPES.MemberExpression) {
131
153
  const memberExpr = current;
132
154
  // Handle computed properties (like array indices)
133
155
  if (memberExpr.computed) {
134
- // why: only a *literal* computed key (obj[0], obj['special-key'])
135
- // narrows to a single, stable field. EVERY other computed key —
136
- // Identifier (obj[i]), CallExpression (obj[assertSafe(i)]),
137
- // BinaryExpression (obj[i+1]), MemberExpression (obj[keys[j]]),
138
- // TSAsExpression (obj[k as K]), TemplateLiteral (obj[`row-${i}`]), etc.
139
- // — is a dynamic access that can read arbitrary elements across an
140
- // iteration. There is no single narrowable field, so the whole object
141
- // is a legitimate dependency: resolve the base and mark it accordingly.
142
- if (memberExpr.property.type !== utils_1.AST_NODE_TYPES.Literal) {
156
+ // why: only a *literal* string/number computed key (obj[0],
157
+ // obj['special-key']) narrows to a single, stable field. EVERY other
158
+ // computed key — Identifier (obj[i]), CallExpression
159
+ // (obj[assertSafe(i)]), BinaryExpression (obj[i+1]), MemberExpression
160
+ // (obj[keys[j]]), TSAsExpression (obj[k as K]), TemplateLiteral
161
+ // (obj[`row-${i}`]), etc. — is a dynamic access that can read
162
+ // arbitrary elements across an iteration. Remaining literal kinds
163
+ // (boolean/null/regex/bigint keys) have no rendering the fixer can
164
+ // guarantee round-trips, so they decline narrowing too rather than
165
+ // emit unreliable text. In every declined case the whole object is a
166
+ // legitimate dependency: resolve the base and mark it accordingly.
167
+ const literalValue = memberExpr.property.type === utils_1.AST_NODE_TYPES.Literal
168
+ ? memberExpr.property.value
169
+ : undefined;
170
+ if (typeof literalValue !== 'number' &&
171
+ typeof literalValue !== 'string') {
143
172
  // Check if this is accessing our target object
144
173
  let currentBase = unwrapExpression(memberExpr.object);
145
174
  while (currentBase.type === utils_1.AST_NODE_TYPES.MemberExpression) {
@@ -147,24 +176,18 @@ function getObjectUsagesInHook(hookBody, objectName) {
147
176
  }
148
177
  if (currentBase.type === utils_1.AST_NODE_TYPES.Identifier &&
149
178
  currentBase.name === objectName) {
150
- // This is a dynamic computed property access on our target object,
151
- // so we need the entire object (no narrowable field exists).
179
+ // No narrowable field exists, so the entire object is required.
152
180
  needsEntireObject = true;
153
181
  }
154
182
  return null;
155
183
  }
156
- // For computed properties with literals
157
- const literalProp = memberExpr.property;
158
- if (typeof literalProp.value === 'number') {
159
- parts.unshift(`[${literalProp.value}]`);
160
- }
161
- else if (typeof literalProp.value === 'string') {
162
- parts.unshift(`[${JSON.stringify(literalProp.value)}]`);
163
- }
164
- else {
165
- // For other literal computed properties (e.g. boolean/null), wildcard
166
- parts.unshift('[*]');
167
- }
184
+ segments.unshift({
185
+ text: typeof literalValue === 'number'
186
+ ? `[${literalValue}]`
187
+ : `[${JSON.stringify(literalValue)}]`,
188
+ computed: true,
189
+ optional: memberExpr.optional,
190
+ });
168
191
  }
169
192
  else {
170
193
  // Regular property access
@@ -175,57 +198,37 @@ function getObjectUsagesInHook(hookBody, objectName) {
175
198
  if (memberExpr.property.name &&
176
199
  (ARRAY_METHODS.has(memberExpr.property.name) ||
177
200
  STRING_METHODS.has(memberExpr.property.name))) {
178
- // Check if this is accessing our target object or a property of it
179
- let currentBase = unwrapExpression(memberExpr.object);
180
- const pathParts = [];
181
- let hasOptionalChainingInMethod = false;
182
- // Build the path to the array/string being accessed
183
- while (currentBase.type === utils_1.AST_NODE_TYPES.MemberExpression) {
184
- const currentMember = currentBase;
185
- if (currentMember.property.type === utils_1.AST_NODE_TYPES.Identifier) {
186
- pathParts.unshift(currentMember.property.name);
187
- }
188
- if (currentMember.optional) {
189
- hasOptionalChainingInMethod = true;
190
- }
191
- currentBase = unwrapExpression(currentMember.object);
192
- }
193
- if (currentBase.type === utils_1.AST_NODE_TYPES.Identifier &&
194
- currentBase.name === objectName) {
195
- if (pathParts.length === 0) {
196
- // Direct method call on the object (e.g., userData.map(...))
197
- needsEntireObject = true;
198
- }
199
- else {
200
- // Method call on a property (e.g., userData.items.map(...) or userData?.items?.map(...))
201
- let path = objectName + (hasOptionalChainingInMethod ? '?' : '');
202
- path += '.' + pathParts.join('.');
201
+ const methodTarget = unwrapExpression(memberExpr.object);
202
+ if (methodTarget.type === utils_1.AST_NODE_TYPES.MemberExpression) {
203
+ // Method call on a property (e.g., userData.items.map(...) or
204
+ // userData?.items?.map(...)): depend on that property's own path,
205
+ // rendered with the same per-link optional markers as any other
206
+ // access.
207
+ const path = buildAccessPath(methodTarget);
208
+ if (path) {
203
209
  usages.set(path, memberExpr.range?.[0] || 0);
204
210
  }
205
211
  }
212
+ else if (methodTarget.type === utils_1.AST_NODE_TYPES.Identifier &&
213
+ methodTarget.name === objectName) {
214
+ // Direct method call on the object (e.g., userData.map(...))
215
+ needsEntireObject = true;
216
+ }
206
217
  return null;
207
218
  }
208
- parts.unshift(memberExpr.property.name);
209
- }
210
- if (memberExpr.optional) {
211
- hasOptionalChaining = true;
219
+ segments.unshift({
220
+ text: memberExpr.property.name,
221
+ computed: false,
222
+ optional: memberExpr.optional,
223
+ });
212
224
  }
213
225
  current = unwrapExpression(memberExpr.object);
214
226
  }
215
227
  // Check if we reached the target identifier
216
228
  const base = unwrapExpression(current);
217
229
  if (base.type === utils_1.AST_NODE_TYPES.Identifier && base.name === objectName) {
218
- // Build the path with optional chaining
219
- let path = objectName + (hasOptionalChaining ? '?' : '');
220
- // Add each part with proper formatting (dot notation or bracket notation)
221
- for (const part of parts) {
222
- if (part.startsWith('[')) {
223
- path += part; // Already formatted as bracket notation
224
- }
225
- else {
226
- path += '.' + part; // Dot notation
227
- }
228
- }
230
+ const path = renderPathSegments(objectName, segments);
231
+ pathSegments.set(path, segments);
229
232
  return path;
230
233
  }
231
234
  return null;
@@ -446,29 +449,24 @@ function getObjectUsagesInHook(hookBody, objectName) {
446
449
  paths.forEach((path) => {
447
450
  // Always include the main path
448
451
  finalPaths.add(path);
449
- // For optional chaining, include the FIRST optional chaining point as intermediate
450
- if (path.includes('?.')) {
451
- // Find the first optional chaining point
452
- // For userData?.profile.settings.theme.primary, we want to include userData?.profile
453
- const firstOptionalIndex = path.indexOf('?.');
454
- if (firstOptionalIndex !== -1) {
455
- // Find the end of the first property after the optional chaining
456
- const afterOptional = path.substring(firstOptionalIndex + 2);
457
- const nextDotIndex = afterOptional.indexOf('.');
458
- if (nextDotIndex !== -1) {
459
- // There are more properties after the first optional property
460
- const firstOptionalPath = path.substring(0, firstOptionalIndex + 2 + nextDotIndex);
461
- finalPaths.add(firstOptionalPath);
462
- }
463
- }
452
+ const segments = pathSegments.get(path);
453
+ if (!segments) {
454
+ return;
464
455
  }
465
- // For array access, include the array property itself
466
- if (path.includes('[') && path.includes(']')) {
467
- const bracketIndex = path.indexOf('[');
468
- if (bracketIndex > 0) {
469
- const arrayPath = path.substring(0, bracketIndex);
470
- finalPaths.add(arrayPath);
471
- }
456
+ // Include the FIRST optional link as an intermediate dependency when more
457
+ // links follow it: for userData?.profile.settings.theme.primary we also
458
+ // want userData?.profile.
459
+ const firstOptionalIndex = segments.findIndex((segment) => segment.optional);
460
+ if (firstOptionalIndex !== -1 && firstOptionalIndex < segments.length - 1) {
461
+ finalPaths.add(renderPathSegments(objectName, segments.slice(0, firstOptionalIndex + 1)));
462
+ }
463
+ // For array access, include the array property itself: for
464
+ // userData.items[0] we also want userData.items. A bracket directly on
465
+ // the base (state[0], state?.[0]) adds nothing — its "array" is the
466
+ // entire object dependency this rule exists to narrow away.
467
+ const firstComputedIndex = segments.findIndex((segment) => segment.computed);
468
+ if (firstComputedIndex > 0) {
469
+ finalPaths.add(renderPathSegments(objectName, segments.slice(0, firstComputedIndex)));
472
470
  }
473
471
  });
474
472
  // Convert to array for sorting
@@ -477,9 +475,11 @@ function getObjectUsagesInHook(hookBody, objectName) {
477
475
  // Exception: keep array paths with optional chaining as they represent different dependencies
478
476
  const filteredPaths = pathsArray.filter((path) => {
479
477
  // Skip array paths if we're accessing specific indices, unless it's optional chaining
478
+ // why: an optional bracket renders as `?.[`, so the specific-index probe
479
+ // must accept both `base[0]` and `base?.[0]` shapes.
480
480
  const isArrayWithSpecificIndices = pathsArray.some((otherPath) => otherPath !== path &&
481
481
  (otherPath.startsWith(path + '[') ||
482
- otherPath.match(new RegExp(`^${path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\[\\d+\\]`))));
482
+ otherPath.startsWith(path + '?.[')));
483
483
  // Keep array paths with optional chaining even if specific indices are accessed
484
484
  if (isArrayWithSpecificIndices && path.includes('?.')) {
485
485
  return true;
@@ -3,6 +3,7 @@ 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 disableDirectives_1 = require("../utils/disableDirectives");
6
7
  exports.preferFragmentComponent = (0, createRule_1.createRule)({
7
8
  name: 'prefer-fragment-component',
8
9
  meta: {
@@ -21,6 +22,13 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
21
22
  defaultOptions: [],
22
23
  create(context) {
23
24
  const sourceCode = context.sourceCode;
25
+ // The `import { Fragment } from 'react'` edit rides on one violation's fix,
26
+ // making that violation the file's import carrier. ESLint builds fixes
27
+ // eagerly and drops inline-disabled reports afterwards, so a suppressed
28
+ // carrier would take the import down with it while the surviving
29
+ // violations still emit <Fragment>. Resolving suppression before the latch
30
+ // is read hands the carrier slot to the first violation that survives.
31
+ const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
24
32
  let hasFragmentImport = false;
25
33
  let reactImportNode = null;
26
34
  let defaultReactImportNode = null;
@@ -90,6 +98,29 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
90
98
  }
91
99
  }
92
100
  }
101
+ /**
102
+ * Where a brand-new import statement can be spliced in without changing
103
+ * what the file's directives govern. A whole-line insertion directly before
104
+ * the first statement slides in between an `eslint-disable-next-line`
105
+ * comment and the line it applies to, silently retargeting that directive
106
+ * at the import. Anchoring above such comments keeps every directive
107
+ * pointed at the code its author aimed it at.
108
+ */
109
+ function findImportAnchor() {
110
+ const firstStatement = sourceCode.ast.body[0];
111
+ let anchor = firstStatement;
112
+ const leadingComments = sourceCode.getCommentsBefore(firstStatement);
113
+ for (let index = leadingComments.length - 1; index >= 0; index--) {
114
+ const comment = leadingComments[index];
115
+ const [directive] = (0, disableDirectives_1.parseDisableDirectives)([comment]);
116
+ if (directive?.kind !== 'disable-next-line' ||
117
+ comment.loc.end.line + 1 !== anchor.loc.start.line) {
118
+ break;
119
+ }
120
+ anchor = comment;
121
+ }
122
+ return anchor;
123
+ }
93
124
  /**
94
125
  * Adds Fragment import to an appropriate React import or creates a new one
95
126
  */
@@ -116,7 +147,7 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
116
147
  // No React import found, create a new one
117
148
  const importText = "import { Fragment } from 'react';\n";
118
149
  const indentation = sourceCode.text.match(/^[ \t]*/m)?.[0] || '';
119
- return fixer.insertTextBefore(sourceCode.ast.body[0], indentation + importText);
150
+ return fixer.insertTextBefore(findImportAnchor(), indentation + importText);
120
151
  }
121
152
  return {
122
153
  ImportDeclaration: checkFragmentImport,
@@ -149,6 +180,11 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
149
180
  messageId: 'preferFragment',
150
181
  data: { type: 'shorthand fragment (<>)' },
151
182
  fix(fixer) {
183
+ // A suppressed report is discarded together with its fix, so it
184
+ // must not claim the import carrier slot.
185
+ if (isReportSuppressed(node)) {
186
+ return null;
187
+ }
152
188
  const fixes = [];
153
189
  // Add Fragment import if needed
154
190
  if (!hasFragmentImport) {
@@ -204,6 +240,11 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
204
240
  messageId: 'preferFragment',
205
241
  data: { type: 'shorthand fragment (<>)' },
206
242
  fix(fixer) {
243
+ // A suppressed report is discarded together with its fix, so it
244
+ // must not claim the import carrier slot.
245
+ if (isReportSuppressed(node)) {
246
+ return null;
247
+ }
207
248
  const fixes = [];
208
249
  // Add Fragment import if needed
209
250
  if (!hasFragmentImport) {
@@ -250,6 +291,11 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
250
291
  messageId: 'preferFragment',
251
292
  data: { type: 'React.Fragment' },
252
293
  fix(fixer) {
294
+ // A suppressed report is discarded together with its fix, so it
295
+ // must not claim the import carrier slot.
296
+ if (isReportSuppressed(node.name)) {
297
+ return null;
298
+ }
253
299
  const fixes = [];
254
300
  // Add Fragment import if needed
255
301
  if (!hasFragmentImport) {
@@ -281,6 +327,11 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
281
327
  messageId: 'preferFragment',
282
328
  data: { type: 'React.Fragment' },
283
329
  fix(fixer) {
330
+ // A suppressed report is discarded together with its fix, so it
331
+ // must not claim the import carrier slot.
332
+ if (isReportSuppressed(node.name)) {
333
+ return null;
334
+ }
284
335
  const fixes = [];
285
336
  // Add Fragment import if needed
286
337
  if (!hasFragmentImport) {
@@ -50,6 +50,18 @@ const FUNCTION_TYPES = new Set([
50
50
  utils_1.AST_NODE_TYPES.FunctionExpression,
51
51
  utils_1.AST_NODE_TYPES.FunctionDeclaration,
52
52
  ]);
53
+ /**
54
+ * Function/constructor/conditional type notation must be parenthesized to
55
+ * appear as a `|` union member, or the emitted annotation does not parse
56
+ * ("Function type notation must be parenthesized when used in a union type").
57
+ * Over-wrapping is harmless — a parenthesized type is valid in any type
58
+ * position — so this tests the printed text conservatively instead of
59
+ * re-deriving the printer's precedence rules.
60
+ */
61
+ function parenthesizeForUnion(text) {
62
+ const needsParens = text.includes('=>') || /^new\b/.test(text) || /\bextends\b/.test(text);
63
+ return needsParens ? `(${text})` : text;
64
+ }
53
65
  exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
54
66
  name: 'prefer-map-over-conditional-dispatch',
55
67
  meta: {
@@ -194,7 +206,13 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
194
206
  parts.push(text);
195
207
  }
196
208
  }
197
- return parts.length > 0 ? parts.join(' | ') : null;
209
+ if (parts.length === 0) {
210
+ return null;
211
+ }
212
+ if (parts.length === 1) {
213
+ return parts[0];
214
+ }
215
+ return parts.map(parenthesizeForUnion).join(' | ');
198
216
  }
199
217
  function discriminantTypeText(type, discriminant) {
200
218
  try {
@@ -209,6 +227,94 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
209
227
  return null;
210
228
  }
211
229
  }
230
+ /**
231
+ * `checker.typeToString()` prints a symbol's bare name with no regard for
232
+ * whether that name resolves at the fix site (an unimported helper type
233
+ * prints the same as an imported one), and exotic printer output can be
234
+ * invalid in an annotation position. Shipping such an annotation breaks
235
+ * the build, so the fix is gated on the synthesized text (a) parsing
236
+ * standalone and (b) referencing only names in scope where the Record is
237
+ * inserted. Failing the gate downgrades to the report-only path — the
238
+ * plugin prefers a skipped fix over one that does not compile.
239
+ */
240
+ function validateAnnotation(annotationText, fixSite) {
241
+ const sourceFile = ts.createSourceFile('__annotation__.ts', `type __T = ${annotationText};`, ts.ScriptTarget.Latest, true);
242
+ // parseDiagnostics is absent from the public SourceFile type but always
243
+ // populated at runtime; a diagnostic here means the annotation cannot
244
+ // ship (e.g. printer truncation, or an unparenthesized member shape the
245
+ // union-wrapping heuristic does not recognize).
246
+ const parseDiagnostics = sourceFile.parseDiagnostics;
247
+ if (!parseDiagnostics || parseDiagnostics.length > 0) {
248
+ return 'unparseable';
249
+ }
250
+ const typeReferenceRoots = new Set();
251
+ const typeQueryRoots = new Set();
252
+ let hasImportType = false;
253
+ const rootNameOf = (name) => {
254
+ let current = name;
255
+ while (ts.isQualifiedName(current)) {
256
+ current = current.left;
257
+ }
258
+ return current.text;
259
+ };
260
+ const collect = (node) => {
261
+ if (ts.isTypeReferenceNode(node)) {
262
+ typeReferenceRoots.add(rootNameOf(node.typeName));
263
+ }
264
+ else if (ts.isTypeQueryNode(node)) {
265
+ typeQueryRoots.add(rootNameOf(node.exprName));
266
+ }
267
+ else if (ts.isImportTypeNode(node)) {
268
+ // `import("...").T` paths printed by the checker are absolute or
269
+ // resolver-relative — never portable source text.
270
+ hasImportType = true;
271
+ }
272
+ ts.forEachChild(node, collect);
273
+ };
274
+ collect(sourceFile);
275
+ if (hasImportType) {
276
+ return 'unresolvable';
277
+ }
278
+ // `Record` is the wrapper this rule itself emits — a TS lib global that
279
+ // is present in any real project. The isolated single-file program a
280
+ // RuleTester builds loads no lib files at all, so requiring it in scope
281
+ // would falsely downgrade every fix under test.
282
+ typeReferenceRoots.delete('Record');
283
+ if (typeReferenceRoots.size === 0 && typeQueryRoots.size === 0) {
284
+ return 'ok';
285
+ }
286
+ const tsFixSite = esTreeNodeToTSNodeMap.get(fixSite);
287
+ if (!tsFixSite) {
288
+ return 'unresolvable';
289
+ }
290
+ try {
291
+ const namesInScope = (meaning) => new Set(checker
292
+ .getSymbolsInScope(tsFixSite, meaning)
293
+ .map((symbol) => symbol.name));
294
+ if (typeReferenceRoots.size > 0) {
295
+ const typeNames = namesInScope(ts.SymbolFlags.Type |
296
+ ts.SymbolFlags.Namespace |
297
+ ts.SymbolFlags.Alias);
298
+ for (const root of typeReferenceRoots) {
299
+ if (!typeNames.has(root)) {
300
+ return 'unresolvable';
301
+ }
302
+ }
303
+ }
304
+ if (typeQueryRoots.size > 0) {
305
+ const valueNames = namesInScope(ts.SymbolFlags.Value | ts.SymbolFlags.Alias);
306
+ for (const root of typeQueryRoots) {
307
+ if (!valueNames.has(root)) {
308
+ return 'unresolvable';
309
+ }
310
+ }
311
+ }
312
+ }
313
+ catch {
314
+ return 'unresolvable';
315
+ }
316
+ return 'ok';
317
+ }
212
318
  // ---- AST helpers --------------------------------------------------------
213
319
  /** Whether a discriminant expression is an identifier or a call-free,
214
320
  * non-optional member chain (safe to collapse repeated evaluations). */
@@ -500,6 +606,19 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
500
606
  });
501
607
  return;
502
608
  }
609
+ const verdict = validateAnnotation(`Record<${dText}, ${vText}>`, discriminantOf(node));
610
+ if (verdict !== 'ok') {
611
+ context.report({
612
+ node,
613
+ messageId: 'preferMapManual',
614
+ data: {
615
+ reason: verdict === 'unparseable'
616
+ ? 'the branch value type does not print as a parseable annotation — write the Record with an explicit type manually'
617
+ : 'the annotation would name types that are not in scope at the fix site — import them or write the Record manually',
618
+ },
619
+ });
620
+ return;
621
+ }
503
622
  context.report({
504
623
  node,
505
624
  messageId: 'preferMap',
@@ -30,19 +30,46 @@ exports.preferTypeOverInterface = (0, createRule_1.createRule)({
30
30
  },
31
31
  fix(fixer) {
32
32
  const sourceCode = context.sourceCode;
33
- const openingBrace = sourceCode.getTokenAfter(node.id, {
34
- filter: (token) => token.value === '{',
35
- });
36
- const fixes = [
37
- fixer.replaceTextRange([node.range[0], node.id.range[1]], `type ${node.id.name} =`),
33
+ // The `=` must land after the entire declaration header (the
34
+ // name plus any type-parameter list); anchoring on the
35
+ // identifier alone emits unparseable `type Name =<T> {`.
36
+ const header = node.typeParameters ?? node.id;
37
+ const keywordSpan = [
38
+ node.range[0],
39
+ node.id.range[0],
38
40
  ];
39
- if (node.extends && node.extends.length > 0 && openingBrace) {
40
- const extendsKeyword = sourceCode.getFirstTokenBetween(node.id, openingBrace, { filter: (token) => token.value === 'extends' });
41
- fixes.push(
42
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
43
- fixer.remove(extendsKeyword), fixer.insertTextBefore(openingBrace, '& '));
41
+ // Everything between the header and the opening brace is
42
+ // rewritten wholesale rather than patched token by token: the
43
+ // heritage list needs `,` turned into `&` and the `extends`
44
+ // keyword dropped, and surgical edits leave the separators and
45
+ // the keyword's surrounding whitespace behind. The body starts at
46
+ // the opening brace, so this span cannot swallow a `{` belonging
47
+ // to a heritage type argument or a type-parameter constraint.
48
+ const headerSpan = [
49
+ header.range[1],
50
+ node.body.range[0],
51
+ ];
52
+ // Both rewritten spans are replaced in full, so a comment sitting
53
+ // inside either one would be silently destroyed (and a line
54
+ // comment would even swallow the `=` that follows it). Reporting
55
+ // without a fix preserves the author's prose; the conversion is
56
+ // then made by hand.
57
+ const clobbersComment = sourceCode
58
+ .getAllComments()
59
+ .some((comment) => [keywordSpan, headerSpan].some(([start, end]) => comment.range[0] < end && comment.range[1] > start));
60
+ if (clobbersComment) {
61
+ return null;
44
62
  }
45
- return fixes;
63
+ const heritage = node.extends ?? [];
64
+ // `getText` round-trips type arguments and qualified names, so
65
+ // `extends ns.B<T>, C` becomes `ns.B<T> & C`.
66
+ const intersection = heritage
67
+ .map((clause) => sourceCode.getText(clause))
68
+ .join(' & ');
69
+ return [
70
+ fixer.replaceTextRange(keywordSpan, 'type '),
71
+ fixer.replaceTextRange(headerSpan, heritage.length > 0 ? ` = ${intersection} & ` : ' = '),
72
+ ];
46
73
  },
47
74
  });
48
75
  },
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.preferUseCallbackOverUseMemoForFunctions = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
+ const disableDirectives_1 = require("../utils/disableDirectives");
6
7
  exports.preferUseCallbackOverUseMemoForFunctions = (0, createRule_1.createRule)({
7
8
  name: 'prefer-usecallback-over-usememo-for-functions',
8
9
  meta: {
@@ -119,6 +120,14 @@ exports.preferUseCallbackOverUseMemoForFunctions = (0, createRule_1.createRule)(
119
120
  // knowing every conversion in the file: useMemo may only be dropped from the
120
121
  // import when no reference to it survives the fixes.
121
122
  const candidates = [];
123
+ /**
124
+ * A suppressed report is discarded together with its fix, yet its
125
+ * `useMemo(...)` call stays in the file. Both halves of the import rewrite
126
+ * therefore hinge on suppression: the rename must not ride on a violation
127
+ * that disappears, and it must not retire a specifier a suppressed call
128
+ * still resolves to.
129
+ */
130
+ const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
122
131
  return {
123
132
  CallExpression(node) {
124
133
  // Check if the call is to useMemo
@@ -149,7 +158,7 @@ exports.preferUseCallbackOverUseMemoForFunctions = (0, createRule_1.createRule)(
149
158
  if (candidates.length === 0) {
150
159
  return;
151
160
  }
152
- const plan = planConversion(context, candidates);
161
+ const plan = planConversion(context, candidates, isReportSuppressed);
153
162
  for (const candidate of candidates) {
154
163
  reportAndFix(candidate.node, context, plan);
155
164
  }
@@ -261,7 +270,7 @@ function removeImportSpecifierFixes(sourceCode, fixer, binding) {
261
270
  * Works out which conversions can be autofixed and how the import list must
262
271
  * change so the emitted useCallback resolves and useMemo is not left dangling.
263
272
  */
264
- function planConversion(context, candidates) {
273
+ function planConversion(context, candidates, isReportSuppressed) {
265
274
  const sourceCode = context.getSourceCode();
266
275
  const program = sourceCode.ast;
267
276
  const existingUseCallback = findImportedSpecifier(program, 'useCallback');
@@ -270,7 +279,13 @@ function planConversion(context, candidates) {
270
279
  : 'useCallback';
271
280
  // A local binding of the target name would capture the emitted call, so those
272
281
  // conversions are reported without a fix rather than silently miscompiled.
282
+ // A suppressed violation is skipped for the same reason it is skipped for the
283
+ // import: ESLint drops its fix, so treating it as unfixable keeps the plan
284
+ // honest about which useMemo calls actually go away.
273
285
  const fixableCandidates = candidates.filter((candidate) => {
286
+ if (isReportSuppressed(candidate.node)) {
287
+ return false;
288
+ }
274
289
  const bound = resolveVariable(candidate.scope, calleeName);
275
290
  if (!existingUseCallback) {
276
291
  return bound === null;
@@ -298,6 +313,10 @@ function planConversion(context, candidates) {
298
313
  const convertedReferences = new Set(fixableCandidates
299
314
  .filter((candidate) => toImportBinding(resolveVariable(candidate.scope, candidate.node.callee.name))?.variable === useMemoBinding.variable)
300
315
  .map((candidate) => candidate.node.callee));
316
+ // Every reference the fixes do not rewrite still needs the specifier: a plain
317
+ // value memo, a bare `useMemo` mention, a conversion blocked by shadowing, or
318
+ // the call behind a disable directive. Any one of them turns the rename into
319
+ // a plain insertion of useCallback beside useMemo.
301
320
  const useMemoSurvives = useMemoBinding.variable.references.some((reference) => !convertedReferences.has(reference.identifier));
302
321
  const shouldAdd = existingUseCallback === null;
303
322
  const shouldRemove = !useMemoSurvives;
@@ -307,6 +326,8 @@ function planConversion(context, candidates) {
307
326
  return {
308
327
  calleeName,
309
328
  fixable,
329
+ // The carrier is the first violation whose fix actually survives, so a
330
+ // suppressed leading violation cannot take the import edit down with it.
310
331
  importOwner: fixableCandidates[0].node,
311
332
  importFixes: (fixer) => {
312
333
  if (shouldAdd && shouldRemove) {