@blumintinc/eslint-plugin-blumint 1.21.1 → 1.21.3

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
@@ -224,7 +224,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
224
224
  module.exports = {
225
225
  meta: {
226
226
  name: '@blumintinc/eslint-plugin-blumint',
227
- version: '1.21.1',
227
+ version: '1.21.3',
228
228
  },
229
229
  parseOptions: {
230
230
  ecmaVersion: 2020,
@@ -44,6 +44,25 @@ const DEFAULT_OPTIONS = {
44
44
  enforceForPropertySignatures: false,
45
45
  };
46
46
  const BOOLEANISH_BINARY_OPERATORS = new Set(['===', '!==', '==', '!=', '>', '<', '>=', '<=', 'in', 'instanceof']);
47
+ /**
48
+ * The operators that spell a DEFAULTED value: `left OP fallback` yields the
49
+ * left operand when it is present and the right operand only as a default. What
50
+ * makes such an initializer boolean is its operands, never which of the two
51
+ * joins them — `||` and `??` differ solely in which absent-ish left values hand
52
+ * over to the fallback.
53
+ *
54
+ * Both spellings must be read, because they are interconvertible under this
55
+ * plugin's own recommended config: `prefer-nullish-coalescing-boolean-props`
56
+ * rewrites `||` to `??` under `--fix`, so recognizing only `||` lets a sibling
57
+ * rule's fixer silence this one on every binding it touches.
58
+ *
59
+ * `&&` is deliberately excluded. Its right operand is the RESULT when the left
60
+ * is truthy rather than a default, so the fallback screen below — "a
61
+ * non-boolean literal on the right means the value is not a boolean" — would
62
+ * read the wrong operand. Conjunctions keep their own both-operand analysis,
63
+ * which already classifies `user && user.isActive` as boolean.
64
+ */
65
+ const BOOLEAN_FALLBACK_OPERATORS = new Set(['||', '??']);
47
66
  /**
48
67
  * The name a class member key declares, for both spellings of privacy.
49
68
  *
@@ -429,7 +448,9 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
429
448
  BOOLEANISH_BINARY_OPERATORS.has(init.operator)) {
430
449
  return true;
431
450
  }
432
- // Check for logical expressions (&&)
451
+ // Conjunctions classify from BOTH operands rather than through the
452
+ // fallback screen below, which is why `&&` is kept out of
453
+ // `BOOLEAN_FALLBACK_OPERATORS`.
433
454
  if (init.type === utils_1.AST_NODE_TYPES.LogicalExpression &&
434
455
  init.operator === '&&') {
435
456
  const left = evaluateBooleanishExpression(init.left);
@@ -447,11 +468,12 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
447
468
  }
448
469
  return false;
449
470
  }
450
- // Special case for logical OR (||) - only consider it boolean if:
471
+ // Special case for a defaulted value (`||`, `??`) - only consider it
472
+ // boolean if:
451
473
  // 1. It's used with boolean literals or
452
474
  // 2. It's not used with array/object literals as fallbacks
453
475
  if (init.type === utils_1.AST_NODE_TYPES.LogicalExpression &&
454
- init.operator === '||') {
476
+ BOOLEAN_FALLBACK_OPERATORS.has(init.operator)) {
455
477
  // Check if right side is a non-boolean literal (array, object, string, number)
456
478
  const rightSide = init.right;
457
479
  if (rightSide.type === utils_1.AST_NODE_TYPES.ArrayExpression ||
@@ -1051,9 +1073,10 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
1051
1073
  return (!isGlobalBooleanCall(value) &&
1052
1074
  calleeReturnEvaluation(value.callee.name) === 'indeterminate');
1053
1075
  }
1054
- // `isFoo(x) || fallback` reaches booleanness through its left operand.
1076
+ // `isFoo(x) || fallback` reaches booleanness through its left operand,
1077
+ // and so does the `??` spelling of the same default.
1055
1078
  if (value.type === utils_1.AST_NODE_TYPES.LogicalExpression &&
1056
- value.operator === '||') {
1079
+ BOOLEAN_FALLBACK_OPERATORS.has(value.operator)) {
1057
1080
  return restsOnName(value.left);
1058
1081
  }
1059
1082
  return false;
@@ -1 +1,2 @@
1
- export declare const enforceCentralizedMockFirestore: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"useCentralizedMockFirestore", [], import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
1
+ import { TSESLint } from '@typescript-eslint/utils';
2
+ export declare const enforceCentralizedMockFirestore: TSESLint.RuleModule<"useCentralizedMockFirestore", [], TSESLint.RuleListener>;
@@ -14,6 +14,35 @@ const importRemoval_1 = require("../utils/importRemoval");
14
14
  */
15
15
  const MOCK_FIRESTORE_MODULE = '__test-utils__/mockFirestore';
16
16
  const MOCK_FIRESTORE_PATH = `../../../../../${MOCK_FIRESTORE_MODULE}`;
17
+ /**
18
+ * The name the centralized module actually exports, and therefore the exact
19
+ * text the fixer emits and the exact text every emitted-name comparison is
20
+ * made against. Detection normalizes (see {@link isMockFirestoreName});
21
+ * emission cannot — `import { MOCK_FIRESTORE } from …` would import a name the
22
+ * shared module does not export, and rewriting a reference to anything but
23
+ * this spelling leaves it unbound.
24
+ */
25
+ const MOCK_FIRESTORE_EXPORT = 'mockFirestore';
26
+ /**
27
+ * The local mock's name reduced to the one form every spelling of it shares:
28
+ * separators dropped and case folded.
29
+ *
30
+ * A mock must not be able to hide behind its spelling. `global-const-style` —
31
+ * also `recommended: 'error'`, also fixable — renames module constants into
32
+ * SCREAMING_SNAKE_CASE, so the composed config walks a `mockFirestore` out of
33
+ * this rule's view by turning it into `MOCK_FIRESTORE`; a hand-written
34
+ * `MOCK_FIRESTORE` is equally invisible with no fixer involved (#2307).
35
+ */
36
+ const MOCK_FIRESTORE_BINDING = 'mockfirestore';
37
+ /**
38
+ * Whether an identifier names the local mock, comparing whole identifiers
39
+ * rather than substrings. `mockFirestoreAdmin`, `MOCK_FIRESTORE_ADMIN` and
40
+ * `firestoreMock` name something else, and claiming them is another rule's
41
+ * business.
42
+ */
43
+ function isMockFirestoreName(name) {
44
+ return name.replace(/[_-]/g, '').toLowerCase() === MOCK_FIRESTORE_BINDING;
45
+ }
17
46
  const SOURCE_EXTENSION = /\.(?:ts|tsx|js|jsx)$/;
18
47
  /**
19
48
  * The module every other file is told to import from is the one module that
@@ -48,16 +77,23 @@ const isCentralizedMockModule = (filename) => {
48
77
  * safest. `global-const-style` and `renameFixes` withhold their fixes on the
49
78
  * same grounds.
50
79
  *
51
- * Only `ExportNamedDeclaration` can front a flagged node: `export default`
52
- * takes an expression, never a `const`, and a class property the other shape
53
- * this rule flags belongs to its class rather than to the module, so
80
+ * Both export forms can front a flagged node. `export default` takes an
81
+ * expression rather than a `const`, but it does take a FUNCTION declaration,
82
+ * and `export default function mockFirestore() {}` makes the module's default
83
+ * export the very thing being retired — the same cross-file contract break
84
+ * under a different name. A class property is on neither surface: it belongs
85
+ * to its class rather than to the module, so
54
86
  * `export default class { mockFirestore = … }` exports nothing by that name.
55
87
  */
88
+ const EXPORT_FRONTS = new Set([
89
+ utils_1.AST_NODE_TYPES.ExportNamedDeclaration,
90
+ utils_1.AST_NODE_TYPES.ExportDefaultDeclaration,
91
+ ]);
56
92
  function isExportedDeclaration(node) {
57
93
  const statement = node.parent?.type === utils_1.AST_NODE_TYPES.VariableDeclaration
58
94
  ? node.parent
59
95
  : node;
60
- return statement.parent?.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration;
96
+ return (!!statement.parent && EXPORT_FRONTS.has(statement.parent.type));
61
97
  }
62
98
  function isHorizontalWhitespace(character) {
63
99
  return character === ' ' || character === '\t';
@@ -112,34 +148,30 @@ const STATEMENT_CONTAINERS = new Set([
112
148
  utils_1.AST_NODE_TYPES.TSModuleBlock,
113
149
  ]);
114
150
  /**
115
- * The declaration when it stands as a statement of its own, and nothing
116
- * otherwise.
151
+ * Whether the node stands as a statement of its own, and so may give up the
152
+ * whole line it occupies.
117
153
  *
118
- * An `export const` is deliberately not widened to the `export` that fronts it:
119
- * swallowing the keyword retires the name from the module's export surface, so
120
- * such a declaration has no retirable statement at all and its enclosing
121
- * `ExportNamedDeclaration` is absent from `STATEMENT_CONTAINERS` for that
122
- * reason. `isExportedDeclaration` refuses the same shape ahead of this call and
123
- * covers the multi-declarator form this branch never sees.
154
+ * An `export`ed declaration is deliberately not widened to the `export` that
155
+ * fronts it: swallowing the keyword retires the name from the module's export
156
+ * surface, so such a declaration has no retirable statement at all and the
157
+ * export node types are absent from `STATEMENT_CONTAINERS` for that reason.
158
+ * `isExportedDeclaration` refuses the same shape ahead of this call and covers
159
+ * the multi-declarator form this branch never sees.
124
160
  */
125
- function retirableStatement(declaration) {
126
- return declaration.parent &&
127
- STATEMENT_CONTAINERS.has(declaration.parent.type)
128
- ? declaration
129
- : undefined;
161
+ function isRetirableStatement(node) {
162
+ return !!node.parent && STATEMENT_CONTAINERS.has(node.parent.type);
130
163
  }
131
164
  function retiredSpan(node) {
132
165
  const parent = node.parent;
133
166
  if (parent?.type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
134
167
  const { declarations } = parent;
135
168
  if (declarations.length === 1) {
136
- const statement = retirableStatement(parent);
137
- if (!statement) {
169
+ if (!isRetirableStatement(parent)) {
138
170
  return undefined;
139
171
  }
140
172
  return {
141
- start: statement.range[0],
142
- end: statement.range[1],
173
+ start: parent.range[0],
174
+ end: parent.range[1],
143
175
  whole: true,
144
176
  };
145
177
  }
@@ -162,6 +194,15 @@ function retiredSpan(node) {
162
194
  }
163
195
  return undefined;
164
196
  }
197
+ if (node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration) {
198
+ // A function declaration nested anywhere but a statement position — the
199
+ // body of an `if` under Annex B, say — cannot be excised without leaving
200
+ // the enclosing construct malformed, so it forfeits the fix like any other
201
+ // unretirable shape.
202
+ return isRetirableStatement(node)
203
+ ? { start: node.range[0], end: node.range[1], whole: true }
204
+ : undefined;
205
+ }
165
206
  if (node.type === utils_1.AST_NODE_TYPES.PropertyDefinition) {
166
207
  return { start: node.range[0], end: node.range[1], whole: true };
167
208
  }
@@ -195,9 +236,23 @@ const retirementCarriesOrphan = (variables, removed) => variables.every((variabl
195
236
  : null;
196
237
  /**
197
238
  * Collapses edits that touch, so an overlap can never rewrite a range twice.
239
+ *
240
+ * Identical edits are dropped rather than merged: two visitors can reach the
241
+ * same reference — a renamed destructured binding is both a tracked call site
242
+ * and a retired binding's reference — and merging concatenates the texts, which
243
+ * would emit `mockFirestoremockFirestore` in place of the name.
198
244
  */
199
245
  function mergeEdits(edits) {
200
- const sorted = [...edits].sort((a, b) => a.start - b.start || a.end - b.end);
246
+ const seen = new Set();
247
+ const unique = edits.filter((edit) => {
248
+ const key = `${edit.start}:${edit.end}:${edit.text}`;
249
+ if (seen.has(key)) {
250
+ return false;
251
+ }
252
+ seen.add(key);
253
+ return true;
254
+ });
255
+ const sorted = [...unique].sort((a, b) => a.start - b.start || a.end - b.end);
201
256
  const merged = [];
202
257
  for (const edit of sorted) {
203
258
  const last = merged[merged.length - 1];
@@ -230,6 +285,29 @@ function importPlacement(sourceCode, anchor, edits) {
230
285
  const enclosing = edits.find((edit) => edit.start < offset && offset < edit.end);
231
286
  return enclosing ? { kind: 'index', index: enclosing.start } : placement;
232
287
  }
288
+ /**
289
+ * Whether a binding spelled exactly `mockFirestore` outlives the retirement in
290
+ * the scope the injected import binds into.
291
+ *
292
+ * Normalized detection is what puts this within reach: a local spelled
293
+ * `MOCK_FIRESTORE` is retired while an unrelated `mockFirestore` — a function
294
+ * declaration, an import of the same name from somewhere else — is left
295
+ * standing, and the two meet only once the import lands, so the emitted file
296
+ * redeclares the name. The fix is withheld and the report stands, which still
297
+ * surfaces the local mock for a human to retire (#2307).
298
+ */
299
+ function collidesWithSurvivingBinding(sourceCode, removed) {
300
+ const globalScope = sourceCode.scopeManager?.globalScope;
301
+ if (!globalScope) {
302
+ return false;
303
+ }
304
+ const scopes = [
305
+ globalScope,
306
+ ...globalScope.childScopes.filter((scope) => scope.type === 'module'),
307
+ ];
308
+ return scopes.some((scope) => scope.variables.some((variable) => variable.name === MOCK_FIRESTORE_EXPORT &&
309
+ variable.defs.some((definition) => !isWithinAny(definition.name.range, removed))));
310
+ }
233
311
  exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
234
312
  name: 'enforce-centralized-mock-firestore',
235
313
  meta: {
@@ -271,10 +349,17 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
271
349
  hasCentralizedImport = true;
272
350
  // Check for renamed imports
273
351
  for (const specifier of node.specifiers) {
352
+ // Both comparisons stay EXACT. `imported` names the shared
353
+ // module's export surface, which spells the mock one way; a
354
+ // normalized match here would treat `import { MOCK_FIRESTORE }`
355
+ // as the shared mock and rewrite its call sites to a name the
356
+ // module never exported. `local` is compared against the text the
357
+ // fixer emits, and a binding differing only in case is still a
358
+ // different binding that the rewrite has to reach.
274
359
  if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
275
360
  specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
276
- specifier.imported.name === 'mockFirestore' &&
277
- specifier.local.name !== 'mockFirestore') {
361
+ specifier.imported.name === MOCK_FIRESTORE_EXPORT &&
362
+ specifier.local.name !== MOCK_FIRESTORE_EXPORT) {
278
363
  customMockFirestoreNames.add(specifier.local.name);
279
364
  }
280
365
  }
@@ -282,18 +367,19 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
282
367
  },
283
368
  VariableDeclarator(node) {
284
369
  if (node.id.type === utils_1.AST_NODE_TYPES.Identifier &&
285
- node.id.name === 'mockFirestore') {
370
+ isMockFirestoreName(node.id.name)) {
286
371
  mockFirestoreNodes.add(node);
287
372
  }
288
373
  else if (node.id.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
289
374
  for (const prop of node.id.properties) {
290
375
  if (prop.type === utils_1.AST_NODE_TYPES.Property &&
291
376
  prop.key.type === utils_1.AST_NODE_TYPES.Identifier &&
292
- prop.key.name === 'mockFirestore') {
377
+ isMockFirestoreName(prop.key.name)) {
293
378
  mockFirestoreNodes.add(node);
294
- // Track renamed destructured imports
379
+ // Track renamed destructured imports. The comparison is against
380
+ // the emitted name, so any other spelling needs rewriting.
295
381
  if (prop.value.type === utils_1.AST_NODE_TYPES.Identifier &&
296
- prop.value.name !== 'mockFirestore') {
382
+ prop.value.name !== MOCK_FIRESTORE_EXPORT) {
297
383
  customMockFirestoreNames.add(prop.value.name);
298
384
  }
299
385
  break;
@@ -301,9 +387,21 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
301
387
  }
302
388
  }
303
389
  },
390
+ // A local mock declared as a function is the same ad-hoc local mock as one
391
+ // declared as a `const` holding an arrow, which this rule already retires.
392
+ // Spelling and declaration FORM are two doors into the same hiding place:
393
+ // leaving this one open costs more than the missed report, because a file
394
+ // whose other local mock IS flagged loses its fix outright — the injected
395
+ // `import { mockFirestore }` would redeclare the surviving function, so
396
+ // `collidesWithSurvivingBinding` withholds the whole rewrite (#2307).
397
+ FunctionDeclaration(node) {
398
+ if (node.id && isMockFirestoreName(node.id.name)) {
399
+ mockFirestoreNodes.add(node);
400
+ }
401
+ },
304
402
  PropertyDefinition(node) {
305
403
  if (node.key.type === utils_1.AST_NODE_TYPES.Identifier &&
306
- node.key.name === 'mockFirestore') {
404
+ isMockFirestoreName(node.key.name)) {
307
405
  mockFirestoreNodes.add(node);
308
406
  }
309
407
  },
@@ -325,11 +423,12 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
325
423
  for (const prop of parent.id.properties) {
326
424
  if (prop.type === utils_1.AST_NODE_TYPES.Property &&
327
425
  prop.key.type === utils_1.AST_NODE_TYPES.Identifier &&
328
- prop.key.name === 'mockFirestore') {
426
+ isMockFirestoreName(prop.key.name)) {
329
427
  mockFirestoreNodes.add(parent);
330
- // Track renamed destructured imports
428
+ // Track renamed destructured imports. The comparison is
429
+ // against the emitted name, as above.
331
430
  if (prop.value.type === utils_1.AST_NODE_TYPES.Identifier &&
332
- prop.value.name !== 'mockFirestore') {
431
+ prop.value.name !== MOCK_FIRESTORE_EXPORT) {
333
432
  customMockFirestoreNames.add(prop.value.name);
334
433
  }
335
434
  break;
@@ -338,8 +437,14 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
338
437
  }
339
438
  }
340
439
  },
341
- // Handle complex object destructuring
342
- 'ObjectPattern > Property > ObjectPattern > Property > ObjectPattern > Property[key.name="mockFirestore"]'(node) {
440
+ // Handle complex object destructuring. The name test lives in the body
441
+ // rather than in the selector because an esquery attribute match is
442
+ // literal, and this one has to normalize like every other detection site.
443
+ 'ObjectPattern > Property > ObjectPattern > Property > ObjectPattern > Property'(node) {
444
+ if (node.key.type !== utils_1.AST_NODE_TYPES.Identifier ||
445
+ !isMockFirestoreName(node.key.name)) {
446
+ return;
447
+ }
343
448
  let current = node;
344
449
  while (current.parent) {
345
450
  if (current.parent.type === utils_1.AST_NODE_TYPES.VariableDeclarator) {
@@ -353,13 +458,23 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
353
458
  MemberExpression(node) {
354
459
  if (node.object.type === utils_1.AST_NODE_TYPES.ThisExpression &&
355
460
  node.property.type === utils_1.AST_NODE_TYPES.Identifier &&
356
- node.property.name === 'mockFirestore') {
461
+ isMockFirestoreName(node.property.name)) {
357
462
  thisExpressions.push(node);
358
463
  }
359
464
  },
360
465
  'Program:exit'() {
361
466
  if (mockFirestoreNodes.size > 0) {
362
467
  const sourceCode = context.getSourceCode();
468
+ // The bindings a flagged declaration introduces that are the local
469
+ // mock under a spelling other than the imported one. Filtering by
470
+ // name keeps a sibling of the same pattern — the `mocks` of
471
+ // `const { mocks: { firestore: { MOCK_FIRESTORE } } }` — out of the
472
+ // rewrite, since `getDeclaredVariables` answers for the whole
473
+ // declarator rather than for the matched property.
474
+ const renamedMockBindings = () => Array.from(mockFirestoreNodes).flatMap((node) => context
475
+ .getDeclaredVariables(node)
476
+ .filter((variable) => variable.name !== MOCK_FIRESTORE_EXPORT &&
477
+ isMockFirestoreName(variable.name)));
363
478
  // Report only once for the entire file
364
479
  context.report({
365
480
  node: Array.from(mockFirestoreNodes)[0],
@@ -405,15 +520,35 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
405
520
  return null;
406
521
  }
407
522
  const orphanRemovals = orphaned.map(([start, end]) => ({ start, end, text: '' }));
523
+ const deletedRanges = [...removals, ...orphanRemovals].map(({ start, end }) => [start, end]);
524
+ if (!hasCentralizedImport &&
525
+ collidesWithSurvivingBinding(sourceCode, deletedRanges)) {
526
+ return null;
527
+ }
408
528
  // Replace custom mockFirestore references with the standard one
409
529
  const replacements = [];
530
+ // Every reference to a retired binding whose spelling is not
531
+ // the imported one would be left unbound by the retirement, so
532
+ // the fix rewrites all of them — not merely the call sites the
533
+ // rename tracking above covers. `const MOCK_FIRESTORE = …` is
534
+ // replaced by `import { mockFirestore }`, and a surviving
535
+ // `MOCK_FIRESTORE` reference names nothing (#2307).
536
+ for (const variable of renamedMockBindings()) {
537
+ for (const reference of variable.references) {
538
+ replacements.push({
539
+ start: reference.identifier.range[0],
540
+ end: reference.identifier.range[1],
541
+ text: MOCK_FIRESTORE_EXPORT,
542
+ });
543
+ }
544
+ }
410
545
  // Add replacements for custom mockFirestore names
411
546
  customMockFirestoreCallExpressions.forEach((node) => {
412
547
  if (node.callee.type === utils_1.AST_NODE_TYPES.Identifier) {
413
548
  replacements.push({
414
549
  start: node.callee.range[0],
415
550
  end: node.callee.range[1],
416
- text: 'mockFirestore',
551
+ text: MOCK_FIRESTORE_EXPORT,
417
552
  });
418
553
  }
419
554
  });
@@ -422,7 +557,7 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
422
557
  replacements.push({
423
558
  start: expr.range[0],
424
559
  end: expr.range[1],
425
- text: 'mockFirestore',
560
+ text: MOCK_FIRESTORE_EXPORT,
426
561
  });
427
562
  });
428
563
  // A reference inside a deleted range goes away with it, so
@@ -448,7 +583,7 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
448
583
  // and above a `#!` shebang it leaves the file unparseable.
449
584
  const anchor = (0, importInsertion_1.importInsertionAnchor)(sourceCode);
450
585
  const indent = (0, importInsertion_1.importAnchorIndent)(sourceCode, anchor);
451
- fixes.push((0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, importPlacement(sourceCode, anchor, edits), `${indent}import { mockFirestore } from '${MOCK_FIRESTORE_PATH}';\n`));
586
+ fixes.push((0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, importPlacement(sourceCode, anchor, edits), `${indent}import { ${MOCK_FIRESTORE_EXPORT} } from '${MOCK_FIRESTORE_PATH}';\n`));
452
587
  }
453
588
  return fixes;
454
589
  },
@@ -349,31 +349,96 @@ exports.enforceFieldPathSyntaxInDocSetter = (0, createRule_1.createRule)({
349
349
  const separator = `\n${landingIndent}`;
350
350
  return [...commentTexts, printedEntries.join(`,${separator}`)].join(separator);
351
351
  }
352
+ // Keys the rewritten literal writes, counted. A flattening fix replaces the
353
+ // property whole, so a dropped plan contributes its own key back rather than
354
+ // its entries.
355
+ function countEmittedKeys(plans, dropped, keptKeys) {
356
+ const counts = new Map();
357
+ const count = (key) => counts.set(key, (counts.get(key) ?? 0) + 1);
358
+ for (const key of keptKeys) {
359
+ count(key);
360
+ }
361
+ for (const plan of plans) {
362
+ if (dropped.has(plan)) {
363
+ count(plan.key);
364
+ continue;
365
+ }
366
+ for (const entry of plan.entries) {
367
+ count(entry.key);
368
+ }
369
+ }
370
+ return counts;
371
+ }
372
+ // Flattening synthesizes a dot-path key that the enclosing literal may
373
+ // already write: `{ 'roles.contributor': a, roles: { contributor: b } }`
374
+ // flattens onto its own sibling. Emitting it spells the same key twice,
375
+ // which is TS1117, and the input carries no answer to which value should
376
+ // win, so the property is left for a human to resolve while the report
377
+ // stands (#2303). A spread between the two does not separate them, since it
378
+ // writes no statically known key of its own.
379
+ //
380
+ // The whole property is dropped rather than the colliding entry alone: the
381
+ // fix rewrites the property as one span, so emitting only its clean leaves
382
+ // would delete the colliding leaf's value outright — silent data loss in
383
+ // place of a duplicate key.
384
+ //
385
+ // Dropping a plan restores its own key to the literal, which can collide in
386
+ // turn with a key another plan flattens into, so the decision is taken to a
387
+ // fixpoint. Dropping only ever grows the set, which is what terminates it.
388
+ function dropCollidingPlans(plans, keptKeys) {
389
+ const dropped = new Set();
390
+ let settled = false;
391
+ while (!settled) {
392
+ settled = true;
393
+ const counts = countEmittedKeys(plans, dropped, keptKeys);
394
+ for (const plan of plans) {
395
+ if (dropped.has(plan)) {
396
+ continue;
397
+ }
398
+ if (plan.entries.some((entry) => (counts.get(entry.key) ?? 0) > 1)) {
399
+ dropped.add(plan);
400
+ settled = false;
401
+ }
402
+ }
403
+ }
404
+ return dropped;
405
+ }
352
406
  // Replace only the properties that actually need flattening, leaving every
353
407
  // other property, its comments, and the original indentation untouched
354
408
  function buildFieldPathFixes(node, sourceCode, fixer) {
355
- const fixes = [];
409
+ const plans = [];
410
+ // Keys the literal keeps as written, which the synthesized keys must not
411
+ // land on
412
+ const keptKeys = [];
356
413
  for (const property of node.properties) {
357
- if (property.type !== utils_1.AST_NODE_TYPES.Property ||
358
- property.computed ||
359
- property.method ||
360
- property.kind !== 'init' ||
361
- property.value.type !== utils_1.AST_NODE_TYPES.ObjectExpression) {
414
+ // A spread or a computed key writes nothing knowable statically, so it
415
+ // is neither flattened nor comparable against a synthesized key
416
+ if (property.type !== utils_1.AST_NODE_TYPES.Property || property.computed) {
362
417
  continue;
363
418
  }
364
419
  const keyText = getPropertyKeyText(property);
420
+ if (keyText === undefined) {
421
+ continue;
422
+ }
365
423
  // Root-level numeric keys model array-style buckets rather than
366
424
  // Firestore document fields, so they are never flattened
367
- if (keyText === undefined || isNumericKey(property)) {
368
- continue;
425
+ const entries = !property.method &&
426
+ property.kind === 'init' &&
427
+ property.value.type === utils_1.AST_NODE_TYPES.ObjectExpression &&
428
+ !isNumericKey(property)
429
+ ? collectFieldPathEntries(property.value, keyText, sourceCode)
430
+ : null;
431
+ if (entries) {
432
+ plans.push({ property, key: keyText, entries });
369
433
  }
370
- const entries = collectFieldPathEntries(property.value, keyText, sourceCode);
371
- if (!entries) {
372
- continue;
434
+ else {
435
+ keptKeys.push(keyText);
373
436
  }
374
- fixes.push(fixer.replaceTextRange(property.range, renderFlattenedProperty(property, entries, sourceCode)));
375
437
  }
376
- return fixes;
438
+ const dropped = dropCollidingPlans(plans, keptKeys);
439
+ return plans
440
+ .filter((plan) => !dropped.has(plan))
441
+ .map((plan) => fixer.replaceTextRange(plan.property.range, renderFlattenedProperty(plan.property, plan.entries, sourceCode)));
377
442
  }
378
443
  function getPropertyKeyText(property) {
379
444
  if (property.key.type === utils_1.AST_NODE_TYPES.Identifier) {