@blumintinc/eslint-plugin-blumint 1.20.102 → 1.20.104

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/README.md CHANGED
@@ -256,7 +256,7 @@ full closed loop is documented in agora's `.claude/skills/eslint-autonomy/SKILL.
256
256
  | [prevent-children-clobber](docs/rules/prevent-children-clobber.md) | Prevent JSX spreads from silently discarding props.children | ✅ | | | | |
257
257
  | [react-memoize-literals](docs/rules/react-memoize-literals.md) | Detect object, array, and function literals created in React components or hooks that create new references every render. Prefer memoized values (useMemo/useCallback) or module-level constants to keep referential stability. | ✅ | | | 💡 | |
258
258
  | [react-usememo-should-be-component](docs/rules/react-usememo-should-be-component.md) | Enforce that useMemo hooks explicitly returning JSX should be abstracted into separate React components | ✅ | | | | |
259
- | [require-dynamic-firebase-imports](docs/rules/require-dynamic-firebase-imports.md) | Enforce dynamic imports for Firebase dependencies | ✅ | | 🔧 | | |
259
+ | [require-dynamic-firebase-imports](docs/rules/require-dynamic-firebase-imports.md) | Enforce dynamic imports for Firebase dependencies | ✅ | | | | |
260
260
  | [require-hooks-default-params](docs/rules/require-hooks-default-params.md) | Enforce React hooks with optional parameters to default to an empty object | ✅ | | 🔧 | | |
261
261
  | [require-https-error](docs/rules/require-https-error.md) | Enforce using proprietary HttpsError instead of throw new Error or firebase-admin HttpsError in functions/src | ✅ | | | | |
262
262
  | [require-https-error-cause](docs/rules/require-https-error-cause.md) | Ensure HttpsError calls inside catch blocks pass the caught error as the fourth "cause" argument to preserve stack traces for monitoring. | ✅ | | | | |
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.102',
226
+ version: '1.20.104',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -118,6 +118,36 @@ function isInsideMockFactory(node) {
118
118
  }
119
119
  return false;
120
120
  }
121
+ /**
122
+ * Strips the wrappers that stand between a computed key and the value that
123
+ * actually names the property. `k as string`, `k satisfies string`, `<string>k`
124
+ * and `k!` erase at compile time, and `await k` resolves to the very same key,
125
+ * so every one of them leaves the run-time lookup untouched — including a lookup
126
+ * of `__proto__` or `constructor`. Reading the wrapper instead of what it holds
127
+ * classifies nothing and turns appending `as string` into a silent bypass of the
128
+ * guard, so the wrappers are peeled off before the key is judged.
129
+ *
130
+ * The peel repeats because the wrappers nest: `(x as any)!` is a non-null
131
+ * assertion over a type assertion.
132
+ */
133
+ function unwrapKeyExpression(node) {
134
+ let current = node;
135
+ for (;;) {
136
+ switch (current.type) {
137
+ case utils_1.AST_NODE_TYPES.TSAsExpression:
138
+ case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
139
+ case utils_1.AST_NODE_TYPES.TSNonNullExpression:
140
+ case utils_1.AST_NODE_TYPES.TSTypeAssertion:
141
+ current = current.expression;
142
+ break;
143
+ case utils_1.AST_NODE_TYPES.AwaitExpression:
144
+ current = current.argument;
145
+ break;
146
+ default:
147
+ return current;
148
+ }
149
+ }
150
+ }
121
151
  /** Names that read as a positional sequence rather than a keyed record. */
122
152
  const ARRAY_LIKE_NAME = /^(array|arr|items|elements|list|collection|data)s?$/i;
123
153
  /**
@@ -186,18 +216,90 @@ function isNumericCall(node) {
186
216
  callee.object.name === 'Math');
187
217
  }
188
218
  /**
189
- * A `: number` annotation on a binding name. Only parameters and variable
190
- * declarators carry one, and a declarator is judged from its writes, so this
191
- * effectively identifies a numeric parameter.
219
+ * A `: number` annotation on a binding name. Parameters and variable
220
+ * declarators are the bindings that carry one, and TypeScript checks every
221
+ * value that reaches such a binding against it.
192
222
  */
193
223
  function isNumberAnnotated(node) {
194
224
  return (node.type === utils_1.AST_NODE_TYPES.Identifier &&
195
225
  node.typeAnnotation?.typeAnnotation.type === utils_1.AST_NODE_TYPES.TSNumberKeyword);
196
226
  }
227
+ /** The types an assertion can launder any value through without complaint. */
228
+ const LAUNDERING_ASSERTION_TYPES = new Set([
229
+ utils_1.AST_NODE_TYPES.TSAnyKeyword,
230
+ utils_1.AST_NODE_TYPES.TSUnknownKeyword,
231
+ ]);
232
+ /**
233
+ * An assertion naming `number` over the value it wraps — `f() as number`,
234
+ * `f() satisfies number`, `<number>f()`. An assertion to anything other than
235
+ * the `number` keyword — `as any`, `as unknown`, `as string`, `as const`, a
236
+ * union, a generic — is not this claim at all.
237
+ *
238
+ * The claim is only worth trusting because TypeScript checks it: `f() as number`
239
+ * is rejected unless the operand's type overlaps `number`. A step through `any`
240
+ * or `unknown` removes exactly that check, which is what makes
241
+ * `userInput as unknown as number` the idiom for asserting anything at all — so
242
+ * a chain carrying one proves nothing, and a string laundered through it would
243
+ * re-open the `__proto__` key this rule exists to stop.
244
+ */
245
+ function assertsNumberType(node) {
246
+ if ((node.type !== utils_1.AST_NODE_TYPES.TSAsExpression &&
247
+ node.type !== utils_1.AST_NODE_TYPES.TSSatisfiesExpression &&
248
+ node.type !== utils_1.AST_NODE_TYPES.TSTypeAssertion) ||
249
+ node.typeAnnotation.type !== utils_1.AST_NODE_TYPES.TSNumberKeyword) {
250
+ return false;
251
+ }
252
+ for (let inner = node.expression; inner.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
253
+ inner.type === utils_1.AST_NODE_TYPES.TSSatisfiesExpression ||
254
+ inner.type === utils_1.AST_NODE_TYPES.TSTypeAssertion; inner = inner.expression) {
255
+ if (LAUNDERING_ASSERTION_TYPES.has(inner.typeAnnotation.type)) {
256
+ return false;
257
+ }
258
+ }
259
+ return true;
260
+ }
261
+ /**
262
+ * Whether the write is the initializer of a declaration that declares itself
263
+ * numeric — either by annotating the binding name (`const k: number =
264
+ * rankOf(id)`, `(index: number = rankOf(id)) =>`) or by asserting the
265
+ * initializing value (`const k = rankOf(id) as number`). TypeScript rejects a
266
+ * non-numeric value under either spelling, so on a TypeScript source both are
267
+ * syntactic proof that the value is a number — the same trust a
268
+ * `(index: number) =>` parameter already earns. Without them an author whose
269
+ * index comes from a call has no compliant spelling at all, because the shape
270
+ * of a call proves nothing on its own.
271
+ *
272
+ * The proof covers the initializer alone. A later assignment is a separate
273
+ * statement and is where a value out of a `catch` binding or an `any`-typed
274
+ * source enters the binding, so every other write still has to prove itself by
275
+ * its own shape — including a `for (k of xs)` binding, whose write expression
276
+ * is the iterated value rather than an initializer.
277
+ */
278
+ function initializesNumericDeclaration(writeExpr) {
279
+ const site = writeExpr.parent;
280
+ switch (site?.type) {
281
+ case utils_1.AST_NODE_TYPES.VariableDeclarator:
282
+ // A destructuring pattern takes the initializer apart before binding, so
283
+ // an assertion over the whole initializer describes the container rather
284
+ // than the element bound out of it: `const { a } = f() as number` says
285
+ // nothing about `a`.
286
+ return (site.id.type === utils_1.AST_NODE_TYPES.Identifier &&
287
+ (isNumberAnnotated(site.id) || assertsNumberType(writeExpr)));
288
+ // A parameter default is checked against the parameter's own annotation the
289
+ // same way a declarator's initializer is checked against its own. That
290
+ // annotation is also what admits the parameter as a numeric binding at all,
291
+ // so an assertion on the default decides nothing here.
292
+ case utils_1.AST_NODE_TYPES.AssignmentPattern:
293
+ return isNumberAnnotated(site.left);
294
+ default:
295
+ return false;
296
+ }
297
+ }
197
298
  /**
198
299
  * Whether the definition can hold a number: a declarator (whose value is proven
199
- * by its writes) or a parameter annotated `: number`. Anything else — an
200
- * import, a function or class name, a catch binding — is not.
300
+ * by its declaration site and its writes) or a parameter annotated `: number`.
301
+ * Anything else — an import, a function or class name, a catch binding — is
302
+ * not.
201
303
  */
202
304
  function definesNumericBinding(def) {
203
305
  return (def.node.type === utils_1.AST_NODE_TYPES.VariableDeclarator ||
@@ -431,6 +533,22 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
431
533
  return createFixes(fixer, node, expressionText);
432
534
  },
433
535
  });
536
+ /**
537
+ * Reports a key whose written form may carry assertion or await wrappers.
538
+ *
539
+ * The report and the fix sit on the outermost written node, so the wrapper
540
+ * the author put there survives the rewrite: `m[assertSafe(k as string)]`
541
+ * rather than `m[assertSafe(k)]`, which would delete text the fixer does not
542
+ * own. `assertSafe` is identity-typed (`<T extends PropertyKey>(key: T): T`),
543
+ * so wrapping the asserted expression preserves the key's type, and wrapping
544
+ * an `await` keeps the validation on the resolved key rather than moving it
545
+ * onto the promise.
546
+ *
547
+ * A key written without a wrapper keeps the narrower argument the fix has
548
+ * always emitted: `String(id)` and `` `${id}` `` collapse to `id`, whose
549
+ * conversion assertSafe subsumes.
550
+ */
551
+ const reportWrittenKey = (written, unwrapped, innerText) => reportUseAssertSafe(written, written === unwrapped ? innerText : context.sourceCode.getText(written));
434
552
  /**
435
553
  * Returns true when the identifier was initialized directly from an
436
554
  * assertSafe(...) call, e.g. `const safeKey = assertSafe(rawKey)`.
@@ -463,34 +581,36 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
463
581
  * does not prove numeric keeps being reported.
464
582
  */
465
583
  const isStaticallyNumeric = (node, seen = new Set()) => {
466
- switch (node.type) {
584
+ // An assertion or an await around an operand leaves its run-time value
585
+ // alone, so the proof reads through to what the wrapper holds. The
586
+ // annotation on the binding underneath is what proves the key numeric —
587
+ // an assertion asserts and proves nothing on its own.
588
+ const target = unwrapKeyExpression(node);
589
+ switch (target.type) {
467
590
  case utils_1.AST_NODE_TYPES.Literal:
468
- return typeof node.value === 'number';
591
+ return typeof target.value === 'number';
469
592
  case utils_1.AST_NODE_TYPES.UpdateExpression:
470
593
  return true;
471
594
  case utils_1.AST_NODE_TYPES.UnaryExpression:
472
- return (node.operator === '-' ||
473
- node.operator === '+' ||
474
- node.operator === '~');
595
+ return (target.operator === '-' ||
596
+ target.operator === '+' ||
597
+ target.operator === '~');
475
598
  case utils_1.AST_NODE_TYPES.BinaryExpression:
476
- if (NUMERIC_BINARY_OPERATORS.has(node.operator)) {
599
+ if (NUMERIC_BINARY_OPERATORS.has(target.operator)) {
477
600
  return true;
478
601
  }
479
- return (node.operator === '+' &&
480
- isStaticallyNumeric(node.left, seen) &&
481
- isStaticallyNumeric(node.right, seen));
602
+ return (target.operator === '+' &&
603
+ isStaticallyNumeric(target.left, seen) &&
604
+ isStaticallyNumeric(target.right, seen));
482
605
  case utils_1.AST_NODE_TYPES.CallExpression:
483
- return isNumericCall(node);
606
+ return isNumericCall(target);
484
607
  case utils_1.AST_NODE_TYPES.MemberExpression:
485
608
  // `.length` is a number on arrays, typed arrays and strings alike.
486
- return (!node.computed &&
487
- node.property.type === utils_1.AST_NODE_TYPES.Identifier &&
488
- node.property.name === 'length');
489
- case utils_1.AST_NODE_TYPES.TSAsExpression:
490
- case utils_1.AST_NODE_TYPES.TSNonNullExpression:
491
- return isStaticallyNumeric(node.expression, seen);
609
+ return (!target.computed &&
610
+ target.property.type === utils_1.AST_NODE_TYPES.Identifier &&
611
+ target.property.name === 'length');
492
612
  case utils_1.AST_NODE_TYPES.Identifier:
493
- return isNumericIdentifier(node, seen);
613
+ return isNumericIdentifier(target, seen);
494
614
  default:
495
615
  return false;
496
616
  }
@@ -523,6 +643,9 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
523
643
  NUMERIC_ASSIGNMENT_OPERATORS.has(assignment.operator)) {
524
644
  return true;
525
645
  }
646
+ if (initializesNumericDeclaration(writeExpr)) {
647
+ return true;
648
+ }
526
649
  return isStaticallyNumeric(writeExpr, nextSeen);
527
650
  });
528
651
  if (!staysNumeric) {
@@ -538,14 +661,15 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
538
661
  // Handle computed property in object destructuring
539
662
  Property(node) {
540
663
  if (node.computed && node.key) {
541
- const key = node.key;
664
+ const written = node.key;
665
+ const key = unwrapKeyExpression(written);
542
666
  // Check for String(id) pattern
543
667
  if (key.type === utils_1.AST_NODE_TYPES.CallExpression &&
544
668
  key.callee.type === utils_1.AST_NODE_TYPES.Identifier &&
545
669
  key.callee.name === 'String') {
546
670
  const arg = key.arguments[0];
547
671
  const argText = context.sourceCode.getText(arg);
548
- reportUseAssertSafe(key, argText);
672
+ reportWrittenKey(written, key, argText);
549
673
  }
550
674
  // Check for template literals like `${id}`
551
675
  if (key.type === utils_1.AST_NODE_TYPES.TemplateLiteral &&
@@ -555,21 +679,22 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
555
679
  key.quasis[1].value.raw === '') {
556
680
  const expr = key.expressions[0];
557
681
  const exprText = context.sourceCode.getText(expr);
558
- reportUseAssertSafe(key, exprText);
682
+ reportWrittenKey(written, key, exprText);
559
683
  }
560
684
  }
561
685
  },
562
686
  // Handle binary expressions like 'key' in obj
563
687
  BinaryExpression(node) {
564
688
  if (node.operator === 'in') {
565
- const left = node.left;
689
+ const written = node.left;
690
+ const left = unwrapKeyExpression(written);
566
691
  // Check for String(id) pattern
567
692
  if (left.type === utils_1.AST_NODE_TYPES.CallExpression &&
568
693
  left.callee.type === utils_1.AST_NODE_TYPES.Identifier &&
569
694
  left.callee.name === 'String') {
570
695
  const arg = left.arguments[0];
571
696
  const argText = context.sourceCode.getText(arg);
572
- reportUseAssertSafe(left, argText);
697
+ reportWrittenKey(written, left, argText);
573
698
  }
574
699
  // Check for template literals like `${id}`
575
700
  if (left.type === utils_1.AST_NODE_TYPES.TemplateLiteral &&
@@ -579,13 +704,17 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
579
704
  left.quasis[1].value.raw === '') {
580
705
  const expr = left.expressions[0];
581
706
  const exprText = context.sourceCode.getText(expr);
582
- reportUseAssertSafe(left, exprText);
707
+ reportWrittenKey(written, left, exprText);
583
708
  }
584
709
  }
585
710
  },
586
711
  MemberExpression(node) {
587
712
  if (node.computed) {
588
- const property = node.property;
713
+ const written = node.property;
714
+ // The written key may sit under assertion or await wrappers that erase
715
+ // at run time; what they hold is what names the property, so that is
716
+ // what the branches below classify.
717
+ const property = unwrapKeyExpression(written);
589
718
  // Skip if already using assertSafe
590
719
  if (property.type === utils_1.AST_NODE_TYPES.CallExpression &&
591
720
  property.callee.type === utils_1.AST_NODE_TYPES.Identifier &&
@@ -619,7 +748,7 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
619
748
  property.callee.name === 'String') {
620
749
  const arg = property.arguments[0];
621
750
  const argText = context.sourceCode.getText(arg);
622
- reportUseAssertSafe(property, argText);
751
+ reportWrittenKey(written, property, argText);
623
752
  return;
624
753
  }
625
754
  // Check for template literals
@@ -640,7 +769,7 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
640
769
  }
641
770
  const expr = property.expressions[0];
642
771
  const exprText = context.sourceCode.getText(expr);
643
- reportUseAssertSafe(property, exprText);
772
+ reportWrittenKey(written, property, exprText);
644
773
  return;
645
774
  }
646
775
  // Check for direct variable usage (identifiers)
@@ -659,7 +788,7 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
659
788
  return;
660
789
  }
661
790
  const propText = context.sourceCode.getText(property);
662
- reportUseAssertSafe(property, propText);
791
+ reportWrittenKey(written, property, propText);
663
792
  return;
664
793
  }
665
794
  // Check for binary expressions (like index + 1)
@@ -669,7 +798,7 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
669
798
  return;
670
799
  }
671
800
  const propText = context.sourceCode.getText(property);
672
- reportUseAssertSafe(property, propText);
801
+ reportWrittenKey(written, property, propText);
673
802
  return;
674
803
  }
675
804
  // Check for boolean expressions and other literals
@@ -681,7 +810,7 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
681
810
  return;
682
811
  }
683
812
  const propText = context.sourceCode.getText(property);
684
- reportUseAssertSafe(property, propText);
813
+ reportWrittenKey(written, property, propText);
685
814
  return;
686
815
  }
687
816
  // Check for function calls (anything that isn't handled above)
@@ -694,7 +823,7 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
694
823
  return;
695
824
  }
696
825
  const propText = context.sourceCode.getText(property);
697
- reportUseAssertSafe(property, propText);
826
+ reportWrittenKey(written, property, propText);
698
827
  return;
699
828
  }
700
829
  }
@@ -9,6 +9,16 @@ const FIRESTORE_MODULES = new Set(['firebase/firestore', 'firebase-admin']);
9
9
  const UPDATE_DOC = 'updateDoc';
10
10
  const SET_DOC = 'setDoc';
11
11
  const MERGE_ARGUMENT = ', { merge: true }';
12
+ const BATCH_MANAGER = 'batchManager';
13
+ /**
14
+ * Realtime Database's batch manager is held under the same `batchManager` field
15
+ * name as the Firestore one, yet it exposes no `set` method at all — its
16
+ * positional `update(path, data)` is the only write path it has, and RTDB's
17
+ * update already merges shallowly. Rewriting one of its calls emits a method
18
+ * that does not exist (TS2339), so a receiver proven to be this class is out of
19
+ * the rule's scope entirely.
20
+ */
21
+ const REALTIME_BATCH_MANAGER = 'RealtimeBatchManager';
12
22
  function isFirestoreDynamicImport(node) {
13
23
  if (node?.type !== utils_1.AST_NODE_TYPES.AwaitExpression) {
14
24
  return false;
@@ -67,6 +77,121 @@ function bindsFirestoreExport(variable, imported) {
67
77
  return (variable.defs.length > 0 &&
68
78
  variable.defs.every((def) => firestoreBindingOf(def)?.imported === imported));
69
79
  }
80
+ /** The rightmost segment of a type name, so `realtimeDb.X` reads like a bare `X`. */
81
+ function typeNameOf(node) {
82
+ if (node.type === utils_1.AST_NODE_TYPES.Identifier) {
83
+ return node.name;
84
+ }
85
+ if (node.type === utils_1.AST_NODE_TYPES.TSQualifiedName) {
86
+ return typeNameOf(node.right);
87
+ }
88
+ return null;
89
+ }
90
+ /**
91
+ * Whether a type annotation names the Realtime Database batch manager. Wrappers
92
+ * that preserve the instance type — `Readonly<…>`, a union, an intersection —
93
+ * are looked through, because the field they annotate still holds the class.
94
+ */
95
+ function isRealtimeType(node) {
96
+ if (!node) {
97
+ return false;
98
+ }
99
+ switch (node.type) {
100
+ case utils_1.AST_NODE_TYPES.TSTypeReference:
101
+ return (typeNameOf(node.typeName) === REALTIME_BATCH_MANAGER ||
102
+ (node.typeParameters?.params ?? []).some(isRealtimeType));
103
+ case utils_1.AST_NODE_TYPES.TSUnionType:
104
+ case utils_1.AST_NODE_TYPES.TSIntersectionType:
105
+ return node.types.some(isRealtimeType);
106
+ default:
107
+ return false;
108
+ }
109
+ }
110
+ function isRealtimeAnnotation(annotation) {
111
+ return isRealtimeType(annotation?.typeAnnotation);
112
+ }
113
+ /** Whether an initializer constructs the Realtime Database batch manager. */
114
+ function isRealtimeInstance(node) {
115
+ if (node?.type !== utils_1.AST_NODE_TYPES.NewExpression) {
116
+ return false;
117
+ }
118
+ const { callee } = node;
119
+ if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
120
+ return callee.name === REALTIME_BATCH_MANAGER;
121
+ }
122
+ return (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
123
+ !callee.computed &&
124
+ callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
125
+ callee.property.name === REALTIME_BATCH_MANAGER);
126
+ }
127
+ /**
128
+ * Whether a parameter binds `name` to the Realtime batch manager. A parameter
129
+ * property declares the field outright; a plain constructor parameter of the
130
+ * same name is the evidence a subclass carries when it forwards the manager to a
131
+ * `super()` that stores it.
132
+ */
133
+ function parameterBindsRealtime(param, name) {
134
+ const declared = param.type === utils_1.AST_NODE_TYPES.TSParameterProperty ? param.parameter : param;
135
+ const identifier = declared.type === utils_1.AST_NODE_TYPES.AssignmentPattern
136
+ ? declared.left
137
+ : declared;
138
+ const initializer = declared.type === utils_1.AST_NODE_TYPES.AssignmentPattern ? declared.right : null;
139
+ return (identifier.type === utils_1.AST_NODE_TYPES.Identifier &&
140
+ identifier.name === name &&
141
+ (isRealtimeAnnotation(identifier.typeAnnotation) ||
142
+ isRealtimeInstance(initializer)));
143
+ }
144
+ /** Whether a class member identifies `name` as the Realtime batch manager. */
145
+ function memberBindsRealtime(member, name) {
146
+ if (member.type === utils_1.AST_NODE_TYPES.PropertyDefinition) {
147
+ return (!member.computed &&
148
+ member.key.type === utils_1.AST_NODE_TYPES.Identifier &&
149
+ member.key.name === name &&
150
+ (isRealtimeInstance(member.value) ||
151
+ isRealtimeAnnotation(member.typeAnnotation)));
152
+ }
153
+ return (member.type === utils_1.AST_NODE_TYPES.MethodDefinition &&
154
+ member.kind === 'constructor' &&
155
+ member.value.params.some((param) => parameterBindsRealtime(param, name)));
156
+ }
157
+ /** Strips assertions, which change a literal's type but not its value. */
158
+ function unwrapAssertions(node) {
159
+ if (node.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
160
+ node.type === utils_1.AST_NODE_TYPES.TSSatisfiesExpression ||
161
+ node.type === utils_1.AST_NODE_TYPES.TSTypeAssertion ||
162
+ node.type === utils_1.AST_NODE_TYPES.TSNonNullExpression) {
163
+ return unwrapAssertions(node.expression);
164
+ }
165
+ return node;
166
+ }
167
+ /**
168
+ * Whether an expression evaluates to a primitive value on its face. Firestore's
169
+ * update data is an object of field updates, so a primitive in the data
170
+ * position proves the call is not Firestore's — the only signal available where
171
+ * the receiver is inherited from another module.
172
+ */
173
+ function isPrimitiveLiteral(node) {
174
+ if (!node) {
175
+ return false;
176
+ }
177
+ const expression = unwrapAssertions(node);
178
+ // A template literal evaluates to a string however it interpolates.
179
+ if (expression.type === utils_1.AST_NODE_TYPES.TemplateLiteral) {
180
+ return true;
181
+ }
182
+ if (expression.type === utils_1.AST_NODE_TYPES.UnaryExpression &&
183
+ (expression.operator === '-' || expression.operator === '+')) {
184
+ return isPrimitiveLiteral(expression.argument);
185
+ }
186
+ if (expression.type !== utils_1.AST_NODE_TYPES.Literal) {
187
+ return false;
188
+ }
189
+ const { value } = expression;
190
+ return (typeof value === 'string' ||
191
+ typeof value === 'number' ||
192
+ typeof value === 'boolean' ||
193
+ typeof value === 'bigint');
194
+ }
70
195
  exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
71
196
  name: 'enforce-firestore-set-merge',
72
197
  meta: {
@@ -97,6 +222,109 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
97
222
  */
98
223
  const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
99
224
  let plannedSetDocBinding = false;
225
+ /**
226
+ * Top-level classes by name, so a field inherited from a superclass declared
227
+ * in the same file resolves to the declaration that carries its evidence.
228
+ */
229
+ let topLevelClasses = null;
230
+ function classBodiesByName() {
231
+ if (topLevelClasses) {
232
+ return topLevelClasses;
233
+ }
234
+ topLevelClasses = new Map();
235
+ for (const statement of sourceCode.ast.body) {
236
+ const declaration = statement.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration ||
237
+ statement.type === utils_1.AST_NODE_TYPES.ExportDefaultDeclaration
238
+ ? statement.declaration
239
+ : statement;
240
+ if (declaration?.type === utils_1.AST_NODE_TYPES.ClassDeclaration &&
241
+ declaration.id) {
242
+ topLevelClasses.set(declaration.id.name, declaration.body);
243
+ continue;
244
+ }
245
+ if (declaration?.type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
246
+ for (const declarator of declaration.declarations) {
247
+ if (declarator.id.type === utils_1.AST_NODE_TYPES.Identifier &&
248
+ declarator.init?.type === utils_1.AST_NODE_TYPES.ClassExpression) {
249
+ topLevelClasses.set(declarator.id.name, declarator.init.body);
250
+ }
251
+ }
252
+ }
253
+ }
254
+ return topLevelClasses;
255
+ }
256
+ function enclosingClassBody(node) {
257
+ let current = node.parent;
258
+ while (current) {
259
+ if (current.type === utils_1.AST_NODE_TYPES.ClassBody) {
260
+ return current;
261
+ }
262
+ current = current.parent;
263
+ }
264
+ return null;
265
+ }
266
+ /** Follows `extends` in-file, since a subclass inherits its field's type. */
267
+ function classBindsRealtime(body, name, seen) {
268
+ if (seen.has(body)) {
269
+ return false;
270
+ }
271
+ seen.add(body);
272
+ if (body.body.some((member) => memberBindsRealtime(member, name))) {
273
+ return true;
274
+ }
275
+ const declaration = body.parent;
276
+ const superClass = declaration?.type === utils_1.AST_NODE_TYPES.ClassDeclaration ||
277
+ declaration?.type === utils_1.AST_NODE_TYPES.ClassExpression
278
+ ? declaration.superClass
279
+ : null;
280
+ if (superClass?.type !== utils_1.AST_NODE_TYPES.Identifier) {
281
+ return false;
282
+ }
283
+ const superBody = classBodiesByName().get(superClass.name);
284
+ return superBody ? classBindsRealtime(superBody, name, seen) : false;
285
+ }
286
+ function identifierBindsRealtime(identifier) {
287
+ const scope = ASTHelpers_1.ASTHelpers.getScope(context, identifier);
288
+ const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, identifier.name);
289
+ return (variable?.defs ?? []).some((def) => {
290
+ const declaredName = def.name;
291
+ return ((declaredName.type === utils_1.AST_NODE_TYPES.Identifier &&
292
+ isRealtimeAnnotation(declaredName.typeAnnotation)) ||
293
+ (def.node.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
294
+ isRealtimeInstance(def.node.init)));
295
+ });
296
+ }
297
+ /**
298
+ * Whether the file itself proves the receiver holds a RealtimeBatchManager:
299
+ * `this.batchManager` against the class (or an in-file superclass) that
300
+ * declares the field, and a plain identifier against its binding.
301
+ */
302
+ function receiverBindsRealtime(node, receiver) {
303
+ if (receiver.type === utils_1.AST_NODE_TYPES.Identifier) {
304
+ return identifierBindsRealtime(receiver);
305
+ }
306
+ if (receiver.type !== utils_1.AST_NODE_TYPES.MemberExpression ||
307
+ receiver.computed ||
308
+ receiver.property.type !== utils_1.AST_NODE_TYPES.Identifier ||
309
+ receiver.object.type !== utils_1.AST_NODE_TYPES.ThisExpression) {
310
+ return false;
311
+ }
312
+ const body = enclosingClassBody(node);
313
+ return body
314
+ ? classBindsRealtime(body, receiver.property.name, new Set())
315
+ : false;
316
+ }
317
+ /**
318
+ * Either syntactic signal puts a `batchManager.update(…)` call outside the
319
+ * rule: the receiver resolves in-file to the Realtime Database manager, or
320
+ * the data argument is a primitive literal, which Firestore's object of
321
+ * field updates can never be. A call with no data argument has nothing in
322
+ * that position, so only the receiver can answer for it.
323
+ */
324
+ function isRealtimeBatchUpdate(node, receiver) {
325
+ return (isPrimitiveLiteral(node.arguments[1]) ||
326
+ receiverBindsRealtime(node, receiver));
327
+ }
100
328
  function isFirestoreUpdateCall(node) {
101
329
  // Check if it's a set() call with merge: true
102
330
  if (node.callee.type === utils_1.AST_NODE_TYPES.MemberExpression) {
@@ -119,11 +347,13 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
119
347
  // Only flag update() calls that are Firestore operations
120
348
  if (property.name === 'update') {
121
349
  const object = node.callee.object;
122
- // Check for BatchManager update calls
350
+ // Check for BatchManager update calls. The Realtime Database
351
+ // manager answers to the same field name without a `set` method, so
352
+ // its calls are not Firestore operations at all.
123
353
  if (object.type === utils_1.AST_NODE_TYPES.MemberExpression &&
124
354
  object.property.type === utils_1.AST_NODE_TYPES.Identifier &&
125
- object.property.name === 'batchManager') {
126
- return true;
355
+ object.property.name === BATCH_MANAGER) {
356
+ return !isRealtimeBatchUpdate(node, object);
127
357
  }
128
358
  if (object.type === utils_1.AST_NODE_TYPES.CallExpression) {
129
359
  // Check if it's a createHash().update() call