@blumintinc/eslint-plugin-blumint 1.20.32 → 1.20.34

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.32',
226
+ version: '1.20.34',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -119,31 +119,99 @@ exports.enforceFieldPathSyntaxInDocSetter = (0, createRule_1.createRule)({
119
119
  function needsQuoting(key) {
120
120
  return key.includes('.') || !/^(?:[$_A-Za-z][$\w]*)$/u.test(key);
121
121
  }
122
- // Helper function to convert an object to FieldPath syntax
123
- function convertToFieldPathSyntax(node, sourceCode, preflattened) {
124
- const idProperty = node.properties.find((prop) => prop.type === utils_1.AST_NODE_TYPES.Property &&
125
- prop.key.type === utils_1.AST_NODE_TYPES.Identifier &&
126
- prop.key.name === 'id');
127
- const flattenedProperties = {
128
- ...(preflattened ?? flattenObject(node, sourceCode)),
129
- };
130
- if (idProperty && idProperty.type === utils_1.AST_NODE_TYPES.Property) {
131
- delete flattenedProperties['id'];
122
+ // Collect the FieldPath entries a nested property flattens into, or bail out
123
+ // when flattening would silently drop payload data (spreads, computed keys,
124
+ // accessors/methods, unsupported key literals) or would produce nothing at
125
+ // all. Bailing leaves the report in place so the developer flattens by hand
126
+ // instead of receiving a fix that deletes fields or emits invalid syntax.
127
+ function collectFieldPathEntries(obj, prefix, sourceCode) {
128
+ const entries = [];
129
+ for (const property of obj.properties) {
130
+ if (property.type !== utils_1.AST_NODE_TYPES.Property ||
131
+ property.computed ||
132
+ property.method ||
133
+ property.kind !== 'init') {
134
+ return null;
135
+ }
136
+ const keyText = getPropertyKeyText(property);
137
+ if (keyText === undefined) {
138
+ return null;
139
+ }
140
+ const fullKey = `${prefix}.${keyText}`;
141
+ if (property.value.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
142
+ const nestedEntries = collectFieldPathEntries(property.value, fullKey, sourceCode);
143
+ if (!nestedEntries) {
144
+ return null;
145
+ }
146
+ entries.push(...nestedEntries);
147
+ continue;
148
+ }
149
+ entries.push([fullKey, sourceCode.getText(property.value)]);
150
+ }
151
+ return entries.length > 0 ? entries : null;
152
+ }
153
+ function getLineIndent(line) {
154
+ return /^[\t ]*/u.exec(line)?.[0] ?? '';
155
+ }
156
+ // Indentation of the property when it is the first thing on its line, which
157
+ // is the indentation the replacement text must reuse to keep the surrounding
158
+ // lines byte-identical. Returns null for properties sharing a line with
159
+ // other code, where the replacement stays inline.
160
+ function getOwnLineIndent(property, sourceCode) {
161
+ const line = sourceCode.lines[property.loc.start.line - 1] ?? '';
162
+ const linePrefix = line.slice(0, property.loc.start.column);
163
+ return /^[\t ]*$/u.test(linePrefix) ? linePrefix : null;
164
+ }
165
+ function printComment(comment) {
166
+ return comment.type === utils_1.AST_TOKEN_TYPES.Line
167
+ ? `//${comment.value}`
168
+ : `/*${comment.value}*/`;
169
+ }
170
+ // Render the dot-path replacement for a single nested property. Comments
171
+ // living inside the property are re-emitted ahead of the flattened entries
172
+ // so directives such as eslint-disable-next-line keep covering the rewritten
173
+ // code rather than being destroyed by the fix.
174
+ function renderFlattenedProperty(property, entries, sourceCode) {
175
+ const comments = sourceCode.getCommentsInside(property);
176
+ const commentTexts = comments.map(printComment);
177
+ const printedEntries = entries.map(([key, value]) => `${needsQuoting(key) ? `'${key}'` : key}: ${value}`);
178
+ const ownLineIndent = getOwnLineIndent(property, sourceCode);
179
+ // A carried line comment would swallow the rest of the line, so anything
180
+ // holding one has to be laid out across multiple lines
181
+ const carriesLineComment = comments.some((comment) => comment.type === utils_1.AST_TOKEN_TYPES.Line);
182
+ if (ownLineIndent === null && !carriesLineComment) {
183
+ return [...commentTexts, printedEntries.join(', ')].join(' ');
132
184
  }
133
- const entries = Object.entries(flattenedProperties);
134
- const propertyComma = ',';
135
- // Start with the id property if it exists
136
- let result = '{\n';
137
- if (idProperty && idProperty.type === utils_1.AST_NODE_TYPES.Property) {
138
- result += ` id: ${sourceCode.getText(idProperty.value)}${propertyComma}\n`;
185
+ const indent = ownLineIndent ??
186
+ `${getLineIndent(sourceCode.lines[property.loc.start.line - 1] ?? '')} `;
187
+ const separator = `\n${indent}`;
188
+ return [...commentTexts, printedEntries.join(`,${separator}`)].join(separator);
189
+ }
190
+ // Replace only the properties that actually need flattening, leaving every
191
+ // other property, its comments, and the original indentation untouched
192
+ function buildFieldPathFixes(node, sourceCode, fixer) {
193
+ const fixes = [];
194
+ for (const property of node.properties) {
195
+ if (property.type !== utils_1.AST_NODE_TYPES.Property ||
196
+ property.computed ||
197
+ property.method ||
198
+ property.kind !== 'init' ||
199
+ property.value.type !== utils_1.AST_NODE_TYPES.ObjectExpression) {
200
+ continue;
201
+ }
202
+ const keyText = getPropertyKeyText(property);
203
+ // Root-level numeric keys model array-style buckets rather than
204
+ // Firestore document fields, so they are never flattened
205
+ if (keyText === undefined || isNumericKey(property)) {
206
+ continue;
207
+ }
208
+ const entries = collectFieldPathEntries(property.value, keyText, sourceCode);
209
+ if (!entries) {
210
+ continue;
211
+ }
212
+ fixes.push(fixer.replaceTextRange(property.range, renderFlattenedProperty(property, entries, sourceCode)));
139
213
  }
140
- // Add the flattened properties (always include trailing commas for multiline objects)
141
- entries.forEach(([key, value]) => {
142
- const printedKey = needsQuoting(key) ? `'${key}'` : key;
143
- result += ` ${printedKey}: ${value}${propertyComma}\n`;
144
- });
145
- result += '}';
146
- return result;
214
+ return fixes;
147
215
  }
148
216
  function getPropertyKeyText(property) {
149
217
  if (property.key.type === utils_1.AST_NODE_TYPES.Identifier) {
@@ -208,7 +276,6 @@ exports.enforceFieldPathSyntaxInDocSetter = (0, createRule_1.createRule)({
208
276
  return {
209
277
  topLevelKey: propertyKeyText ?? 'nested field',
210
278
  exampleFieldPath,
211
- flattenedProperties,
212
279
  };
213
280
  }
214
281
  return {
@@ -251,8 +318,8 @@ exports.enforceFieldPathSyntaxInDocSetter = (0, createRule_1.createRule)({
251
318
  exampleFieldPath: violationDetails.exampleFieldPath,
252
319
  },
253
320
  fix(fixer) {
254
- const newText = convertToFieldPathSyntax(firstArg, sourceCode, violationDetails.flattenedProperties);
255
- return fixer.replaceText(firstArg, newText);
321
+ const fixes = buildFieldPathFixes(firstArg, sourceCode, fixer);
322
+ return fixes.length > 0 ? fixes : null;
256
323
  },
257
324
  });
258
325
  },
@@ -1 +1,2 @@
1
- export declare const enforceFirestoreSetMerge: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"preferSetMerge", [], import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
1
+ import { TSESLint } from '@typescript-eslint/utils';
2
+ export declare const enforceFirestoreSetMerge: TSESLint.RuleModule<"preferSetMerge", [], TSESLint.RuleListener>;
@@ -4,6 +4,69 @@ exports.enforceFirestoreSetMerge = 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 disableDirectives_1 = require("../utils/disableDirectives");
8
+ const FIRESTORE_MODULES = new Set(['firebase/firestore', 'firebase-admin']);
9
+ const UPDATE_DOC = 'updateDoc';
10
+ const SET_DOC = 'setDoc';
11
+ const MERGE_ARGUMENT = ', { merge: true }';
12
+ function isFirestoreDynamicImport(node) {
13
+ if (node?.type !== utils_1.AST_NODE_TYPES.AwaitExpression) {
14
+ return false;
15
+ }
16
+ const imported = node.argument;
17
+ return (imported.type === utils_1.AST_NODE_TYPES.ImportExpression &&
18
+ imported.source.type === utils_1.AST_NODE_TYPES.Literal &&
19
+ typeof imported.source.value === 'string' &&
20
+ FIRESTORE_MODULES.has(imported.source.value));
21
+ }
22
+ /**
23
+ * Reads a binding's origin off the AST rather than off a traversal flag, so the
24
+ * verdict is re-derived on every pass of a multi-pass `--fix` — including the
25
+ * passes that run after a previous pass inserted the `setDoc` binding.
26
+ */
27
+ function firestoreBindingOf(def) {
28
+ const { node } = def;
29
+ if (node.type === utils_1.AST_NODE_TYPES.ImportSpecifier) {
30
+ const declaration = node.parent;
31
+ if (declaration?.type !== utils_1.AST_NODE_TYPES.ImportDeclaration ||
32
+ !FIRESTORE_MODULES.has(declaration.source.value) ||
33
+ declaration.importKind === 'type' ||
34
+ node.importKind === 'type') {
35
+ return null;
36
+ }
37
+ return {
38
+ imported: node.imported.name,
39
+ node,
40
+ entries: declaration.specifiers,
41
+ };
42
+ }
43
+ if (node.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
44
+ node.id.type === utils_1.AST_NODE_TYPES.ObjectPattern &&
45
+ isFirestoreDynamicImport(node.init)) {
46
+ const property = def.name.parent;
47
+ if (property?.type !== utils_1.AST_NODE_TYPES.Property ||
48
+ property.parent !== node.id ||
49
+ property.value !== def.name ||
50
+ property.computed ||
51
+ property.key.type !== utils_1.AST_NODE_TYPES.Identifier) {
52
+ return null;
53
+ }
54
+ return {
55
+ imported: property.key.name,
56
+ node: property,
57
+ entries: node.id.properties,
58
+ };
59
+ }
60
+ return null;
61
+ }
62
+ function isComma(token) {
63
+ return token?.type === utils_1.AST_TOKEN_TYPES.Punctuator && token.value === ',';
64
+ }
65
+ /** Whether every declaration of a visible binding is the given firestore export. */
66
+ function bindsFirestoreExport(variable, imported) {
67
+ return (variable.defs.length > 0 &&
68
+ variable.defs.every((def) => firestoreBindingOf(def)?.imported === imported));
69
+ }
7
70
  exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
8
71
  name: 'enforce-firestore-set-merge',
9
72
  meta: {
@@ -22,7 +85,18 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
22
85
  },
23
86
  defaultOptions: [],
24
87
  create(context) {
88
+ const sourceCode = context.sourceCode;
25
89
  const updateAliases = new Set();
90
+ /**
91
+ * The `setDoc` binding rides on one violation's fix, which makes that
92
+ * violation the file's import carrier. ESLint calls `fix()` before it
93
+ * applies inline disable directives, so a suppressed carrier takes the
94
+ * binding down with it while the surviving violations still emit
95
+ * `setDoc(…)`. Resolving suppression up front passes the carrier slot to the
96
+ * first violation that actually survives.
97
+ */
98
+ const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
99
+ let plannedSetDocBinding = false;
26
100
  function isFirestoreUpdateCall(node) {
27
101
  // Check if it's a set() call with merge: true
28
102
  if (node.callee.type === utils_1.AST_NODE_TYPES.MemberExpression) {
@@ -118,7 +192,7 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
118
192
  }
119
193
  if (node.callee.type === utils_1.AST_NODE_TYPES.Identifier) {
120
194
  // Check if it's a setDoc() call with merge: true
121
- if (node.callee.name === 'setDoc') {
195
+ if (node.callee.name === SET_DOC) {
122
196
  const lastArg = node.arguments[node.arguments.length - 1];
123
197
  if (lastArg?.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
124
198
  const hasMergeTrue = lastArg.properties.some((prop) => prop.type === utils_1.AST_NODE_TYPES.Property &&
@@ -135,87 +209,190 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
135
209
  }
136
210
  return false;
137
211
  }
138
- function convertUpdateToSetMerge(node, sourceCode) {
212
+ /**
213
+ * A spread hides how many arguments the call really passes, so the options
214
+ * object cannot be positioned.
215
+ */
216
+ function hasSpreadArgument(node) {
217
+ return node.arguments.some((argument) => argument.type === utils_1.AST_NODE_TYPES.SpreadElement);
218
+ }
219
+ /**
220
+ * `ref.update(…)` becomes `ref.set(…, { merge: true })` by editing only the
221
+ * method name and the tail of the argument list. Re-emitting the call from
222
+ * the text of each argument dropped everything between them — comments
223
+ * included, and a dropped `eslint-disable` silently re-enables the rule it
224
+ * was suppressing — and dropped every argument past the second outright.
225
+ */
226
+ function fixUpdateMethod(fixer, node, callee) {
227
+ if (callee.computed ||
228
+ callee.property.type !== utils_1.AST_NODE_TYPES.Identifier) {
229
+ return null;
230
+ }
139
231
  const args = node.arguments;
140
- if (args.length === 0)
141
- return '';
142
- if (node.callee.type === utils_1.AST_NODE_TYPES.MemberExpression) {
143
- const object = sourceCode.getText(node.callee.object);
144
- if (object.includes('transaction')) {
145
- const docRef = sourceCode.getText(args[0]);
146
- const data = sourceCode.getText(args[1]);
147
- return `${object}.set(${docRef}, ${data}, { merge: true })`;
232
+ const lastArgument = args[args.length - 1];
233
+ if (!lastArgument || hasSpreadArgument(node)) {
234
+ return null;
235
+ }
236
+ const objectText = sourceCode.getText(callee.object);
237
+ // BatchManager takes a single descriptor object, so its arguments are
238
+ // genuinely restructured rather than extended.
239
+ if (objectText.includes('batchManager')) {
240
+ if (args.length < 2) {
241
+ return null;
148
242
  }
149
- if (object.includes('batchManager')) {
150
- const docRef = sourceCode.getText(args[0]);
151
- const data = sourceCode.getText(args[1]);
152
- return `${object}.set({
153
- ref: ${docRef},
154
- data: ${data},
243
+ return [
244
+ fixer.replaceText(node, `${objectText}.set({
245
+ ref: ${sourceCode.getText(args[0])},
246
+ data: ${sourceCode.getText(args[1])},
155
247
  merge: true,
156
- })`;
248
+ })`),
249
+ ];
250
+ }
251
+ return [
252
+ fixer.replaceText(callee.property, 'set'),
253
+ fixer.insertTextAfter(lastArgument, MERGE_ARGUMENT),
254
+ ];
255
+ }
256
+ /**
257
+ * Drops a binding whose last reference this fix rewrites, so `--fix` does not
258
+ * leave an unused import behind. Only the entry and the comma separating it
259
+ * from a sibling go, and only when nothing else lives in that span: a comment
260
+ * between the entry and its comma belongs to a neighbour as often as to the
261
+ * entry, and an unused specifier is inert where a deleted comment is not.
262
+ * A list that would end up empty is left alone too, since emptying it means
263
+ * rewriting the whole declaration.
264
+ */
265
+ function removeBinding(fixer, binding) {
266
+ if (binding.entries.length < 2) {
267
+ return [];
268
+ }
269
+ const before = sourceCode.getTokenBefore(binding.node, {
270
+ includeComments: true,
271
+ });
272
+ if (isComma(before)) {
273
+ return [fixer.removeRange([before.range[0], binding.node.range[1]])];
274
+ }
275
+ const after = sourceCode.getTokenAfter(binding.node, {
276
+ includeComments: true,
277
+ });
278
+ if (!isComma(after)) {
279
+ return [];
280
+ }
281
+ // Stopping at whatever follows the comma — comment or token — keeps a
282
+ // directive that documents the next entry attached to it.
283
+ const next = sourceCode.getTokenAfter(after, { includeComments: true });
284
+ return [
285
+ fixer.removeRange([
286
+ binding.node.range[0],
287
+ next ? next.range[0] : after.range[1],
288
+ ]),
289
+ ];
290
+ }
291
+ /**
292
+ * `updateDoc(ref, data)` becomes `setDoc(ref, data, { merge: true })`, which
293
+ * only works if `setDoc` is bound. The import edit and the call rewrite ship
294
+ * as one fix array: they sit in disjoint ranges, and a multi-rule `--fix`
295
+ * that applied one without the other would leave the file with an unbound
296
+ * name.
297
+ */
298
+ function fixUpdateDocCall(fixer, node, callee) {
299
+ if (isReportSuppressed(node)) {
300
+ return null;
301
+ }
302
+ const lastArgument = node.arguments[node.arguments.length - 1];
303
+ if (!lastArgument || hasSpreadArgument(node)) {
304
+ return null;
305
+ }
306
+ const scope = ASTHelpers_1.ASTHelpers.getScope(context, node);
307
+ const updateVariable = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, callee.name);
308
+ if (!updateVariable || updateVariable.defs.length !== 1) {
309
+ return null;
310
+ }
311
+ const updateBinding = firestoreBindingOf(updateVariable.defs[0]);
312
+ if (!updateBinding || updateBinding.imported !== UPDATE_DOC) {
313
+ return null;
314
+ }
315
+ // A `setDoc` that means something else makes both halves of the edit
316
+ // wrong: an added import collides with the declaration (TS2440/TS2300),
317
+ // and a narrower-scope shadow rebinds the emitted call to the local value
318
+ // with no diagnostic at all. Resolving from the call's own scope chain
319
+ // catches both, and declining before the binding is scheduled leaves the
320
+ // carrier slot to a violation whose scope is safe.
321
+ const setDocVariable = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, SET_DOC);
322
+ if (setDocVariable && !bindsFirestoreExport(setDocVariable, SET_DOC)) {
323
+ return null;
324
+ }
325
+ // Rewriting the last reference to `updateDoc` frees its binding site, so
326
+ // the entry is renamed in place — and an alias disappears together with
327
+ // the reference that used it. Any other reference keeps the old name
328
+ // alive: adding `setDoc` alongside it is then the only safe edit, because
329
+ // a multi-rule `--fix` can drop a sibling violation's fix and strand that
330
+ // reference on a binding this one just removed.
331
+ const reads = updateVariable.references.filter((reference) => reference.isRead());
332
+ const isSoleReference = reads.length === 1 && reads[0].identifier === callee;
333
+ const fixes = [];
334
+ if (!setDocVariable) {
335
+ if (!plannedSetDocBinding) {
336
+ fixes.push(isSoleReference
337
+ ? fixer.replaceText(updateBinding.node, SET_DOC)
338
+ : fixer.insertTextAfter(updateBinding.node, `, ${SET_DOC}`));
339
+ plannedSetDocBinding = true;
157
340
  }
158
- const data = sourceCode.getText(args[0]);
159
- return `${object}.set(${data}, { merge: true })`;
160
341
  }
161
- // For updateDoc from firebase/firestore
162
- const docRef = sourceCode.getText(args[0]);
163
- const data = args.length > 1 ? sourceCode.getText(args[1]) : '{}';
164
- return `setDoc(${docRef}, ${data}, { merge: true })`;
342
+ else if (isSoleReference) {
343
+ fixes.push(...removeBinding(fixer, updateBinding));
344
+ }
345
+ fixes.push(fixer.replaceText(callee, SET_DOC));
346
+ // `setDoc` takes the document data between the reference and the options,
347
+ // so a call that passed no data gets an empty object to merge.
348
+ fixes.push(fixer.insertTextAfter(lastArgument, node.arguments.length > 1 ? MERGE_ARGUMENT : `, {}${MERGE_ARGUMENT}`));
349
+ return fixes;
165
350
  }
166
351
  return {
167
352
  ImportDeclaration(node) {
168
- if (node.source.value === 'firebase/firestore' ||
169
- node.source.value === 'firebase-admin') {
353
+ if (FIRESTORE_MODULES.has(node.source.value)) {
170
354
  node.specifiers.forEach((specifier) => {
171
355
  if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier) {
172
- if (specifier.imported.name === 'updateDoc') {
356
+ if (specifier.imported.name === UPDATE_DOC) {
173
357
  updateAliases.add(specifier.local.name);
174
358
  }
175
359
  }
176
360
  });
177
361
  }
178
362
  },
179
- ImportExpression(node) {
180
- if (node.source.type === utils_1.AST_NODE_TYPES.Literal &&
181
- (node.source.value === 'firebase/firestore' ||
182
- node.source.value === 'firebase-admin')) {
183
- // Dynamic imports are handled in VariableDeclarator
184
- }
185
- },
186
363
  VariableDeclarator(node) {
187
- if (node.init?.type === utils_1.AST_NODE_TYPES.AwaitExpression &&
188
- node.init.argument.type === utils_1.AST_NODE_TYPES.ImportExpression) {
189
- const importSource = node.init.argument.source;
190
- if (importSource.type === utils_1.AST_NODE_TYPES.Literal &&
191
- (importSource.value === 'firebase/firestore' ||
192
- importSource.value === 'firebase-admin')) {
193
- // Handle destructured imports
194
- if (node.id.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
195
- node.id.properties.forEach((prop) => {
196
- if (prop.type === utils_1.AST_NODE_TYPES.Property &&
197
- prop.key.type === utils_1.AST_NODE_TYPES.Identifier &&
198
- prop.key.name === 'updateDoc') {
199
- if (prop.value.type === utils_1.AST_NODE_TYPES.Identifier) {
200
- updateAliases.add(prop.value.name);
201
- }
202
- }
203
- });
364
+ if (!isFirestoreDynamicImport(node.init)) {
365
+ return;
366
+ }
367
+ // Handle destructured imports
368
+ if (node.id.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
369
+ node.id.properties.forEach((prop) => {
370
+ if (prop.type === utils_1.AST_NODE_TYPES.Property &&
371
+ prop.key.type === utils_1.AST_NODE_TYPES.Identifier &&
372
+ prop.key.name === UPDATE_DOC &&
373
+ prop.value.type === utils_1.AST_NODE_TYPES.Identifier) {
374
+ updateAliases.add(prop.value.name);
204
375
  }
205
- }
376
+ });
206
377
  }
207
378
  },
208
379
  CallExpression(node) {
209
- if (isFirestoreUpdateCall(node)) {
210
- context.report({
211
- node,
212
- messageId: 'preferSetMerge',
213
- fix(fixer) {
214
- const newText = convertUpdateToSetMerge(node, context.sourceCode);
215
- return fixer.replaceText(node, newText);
216
- },
217
- });
380
+ if (!isFirestoreUpdateCall(node)) {
381
+ return;
218
382
  }
383
+ context.report({
384
+ node,
385
+ messageId: 'preferSetMerge',
386
+ fix(fixer) {
387
+ if (node.callee.type === utils_1.AST_NODE_TYPES.MemberExpression) {
388
+ return fixUpdateMethod(fixer, node, node.callee);
389
+ }
390
+ if (node.callee.type === utils_1.AST_NODE_TYPES.Identifier) {
391
+ return fixUpdateDocCall(fixer, node, node.callee);
392
+ }
393
+ return null;
394
+ },
395
+ });
219
396
  },
220
397
  };
221
398
  },
@@ -118,25 +118,63 @@ exports.enforceGlobalConstants = (0, createRule_1.createRule)({
118
118
  function hasIdentifiers(node) {
119
119
  return !!node && ASTHelpers_1.ASTHelpers.declarationIncludesIdentifier(node);
120
120
  }
121
- function alreadyHasConst(program, constName) {
122
- for (const stmt of program.body) {
123
- if (stmt.type === utils_1.AST_NODE_TYPES.VariableDeclaration &&
124
- stmt.kind === 'const') {
125
- for (const d of stmt.declarations) {
126
- if (d.id.type === utils_1.AST_NODE_TYPES.Identifier &&
127
- d.id.name === constName) {
128
- return true;
129
- }
130
- }
121
+ function classifyModuleBinding(variable) {
122
+ if (variable.defs.length !== 1) {
123
+ return { kind: 'blocked' };
124
+ }
125
+ const declarator = variable.defs[0].node;
126
+ if (declarator.type !== utils_1.AST_NODE_TYPES.VariableDeclarator) {
127
+ return { kind: 'blocked' };
128
+ }
129
+ const declaration = declarator.parent;
130
+ if (!declaration ||
131
+ declaration.type !== utils_1.AST_NODE_TYPES.VariableDeclaration ||
132
+ declaration.kind !== 'const' ||
133
+ declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier ||
134
+ !declarator.init) {
135
+ return { kind: 'blocked' };
136
+ }
137
+ return {
138
+ kind: 'reusable',
139
+ initText: sourceCode.getText(declarator.init),
140
+ };
141
+ }
142
+ /**
143
+ * `SourceCode#getScope` supersedes the deprecated `context.getScope`; the
144
+ * fallback keeps the rule working on ESLint versions that predate it.
145
+ */
146
+ function scopeOf(node) {
147
+ const scoped = sourceCode;
148
+ return typeof scoped.getScope === 'function'
149
+ ? scoped.getScope(node)
150
+ : context.getScope();
151
+ }
152
+ function resolveGeneratedName(scope, constName) {
153
+ let current = scope;
154
+ while (current) {
155
+ const variable = current.variables.find((v) => v.name === constName);
156
+ if (variable) {
157
+ return current.block.type === utils_1.AST_NODE_TYPES.Program
158
+ ? classifyModuleBinding(variable)
159
+ : { kind: 'blocked' };
131
160
  }
161
+ current = current.upper;
132
162
  }
133
- return false;
163
+ // An unresolved reference elsewhere in the file points at an ambient
164
+ // global; declaring the name at module scope would capture it.
165
+ const globalScope = sourceCode.scopeManager?.globalScope;
166
+ if (globalScope?.through.some((ref) => ref.identifier.name === constName)) {
167
+ return { kind: 'blocked' };
168
+ }
169
+ return { kind: 'free' };
134
170
  }
135
- function buildConstDeclarationLine(constName, initText) {
171
+ function buildInitializerText(initText) {
136
172
  const needsAsConst = /^(?:true|false|-?\d|\[|\{|[`'"])/.test(initText) &&
137
173
  !/\bas const\b/.test(initText);
138
- const initializer = needsAsConst ? `${initText} as const` : initText;
139
- return `const ${constName} = ${initializer};`;
174
+ return needsAsConst ? `${initText} as const` : initText;
175
+ }
176
+ function buildConstDeclarationLine(constName, initText) {
177
+ return `const ${constName} = ${buildInitializerText(initText)};`;
140
178
  }
141
179
  function reportStaticDefaults(patterns, enclosingFn, nodeForReport) {
142
180
  if (!enclosingFn || !isComponentOrHookFunction(enclosingFn))
@@ -153,23 +191,52 @@ exports.enforceGlobalConstants = (0, createRule_1.createRule)({
153
191
  });
154
192
  if (staticDefaults.length === 0)
155
193
  return;
194
+ const reportScope = scopeOf(nodeForReport);
156
195
  context.report({
157
196
  node: nodeForReport,
158
197
  messageId: 'extractDefaultToGlobalConstant',
159
198
  fix(fixer) {
160
199
  const fixes = [];
161
- const programNode = sourceCode.ast;
162
200
  const declLines = [];
201
+ // Names this fix commits to declaring, mapped to the initializer it
202
+ // declares them with, so sibling defaults sharing a generated name
203
+ // share the declaration instead of duplicating the binding.
204
+ const scheduledInits = new Map();
163
205
  for (const def of staticDefaults) {
164
206
  const { assignment, localName } = def;
165
207
  const right = assignment.right;
166
208
  const rightText = sourceCode.getText(right);
167
209
  const constName = `DEFAULT_${toUpperSnakeCase(localName)}`;
168
- if (!alreadyHasConst(programNode, constName)) {
169
- declLines.push(buildConstDeclarationLine(constName, rightText));
210
+ const initText = buildInitializerText(rightText);
211
+ const scheduled = scheduledInits.get(constName);
212
+ if (scheduled !== undefined) {
213
+ if (scheduled !== initText)
214
+ continue;
215
+ fixes.push(fixer.replaceText(right, constName));
216
+ continue;
217
+ }
218
+ const resolution = resolveGeneratedName(reportScope, constName);
219
+ if (resolution.kind === 'blocked') {
220
+ // Declining leaves the report in place: the developer extracts
221
+ // the constant by hand instead of the fixer corrupting the file.
222
+ continue;
223
+ }
224
+ if (resolution.kind === 'reusable') {
225
+ // Reuse is safe only when the existing constant holds the very
226
+ // same value; `as const` may be present on either side.
227
+ if (resolution.initText !== initText &&
228
+ resolution.initText !== rightText) {
229
+ continue;
230
+ }
231
+ fixes.push(fixer.replaceText(right, constName));
232
+ continue;
170
233
  }
234
+ declLines.push(buildConstDeclarationLine(constName, rightText));
235
+ scheduledInits.set(constName, initText);
171
236
  fixes.push(fixer.replaceText(right, constName));
172
237
  }
238
+ if (fixes.length === 0)
239
+ return null;
173
240
  if (declLines.length > 0) {
174
241
  const program = sourceCode.ast;
175
242
  const constSection = declLines.length === 1