@blumintinc/eslint-plugin-blumint 1.20.177 → 1.20.178

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.177',
226
+ version: '1.20.178',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -23,18 +23,43 @@ const CLIENT_BATCH_FACTORY = 'writeBatch';
23
23
  // while the admin SDK only exposes the `db.runTransaction(cb)` method, so the
24
24
  // callee shape, not the name, decides which SDK a transaction belongs to.
25
25
  const TRANSACTION_RUNNER = 'runTransaction';
26
+ // Realtime Database modules. DocSetter/DocSetterTransaction wrap Firestore and
27
+ // have no Realtime Database counterpart, so reporting a RealtimeDB receiver
28
+ // prescribes a remedy that cannot exist. Keying the carve-out on the import
29
+ // source rather than on the bare type name keeps an unrelated local type named
30
+ // `Reference` from silencing a genuine Firestore write.
31
+ const REALTIME_DB_MODULES = new Set([
32
+ 'firebase-admin/database',
33
+ 'firebase/database',
34
+ '@firebase/database',
35
+ ]);
36
+ // Exports of those modules that type a RealtimeDB node reference.
37
+ const REALTIME_DB_REFERENCE_TYPES = new Set(['Reference', 'ThenableReference']);
38
+ // Exports that type the database handle a reference is obtained from.
39
+ const REALTIME_DB_HANDLE_TYPES = new Set(['Database']);
40
+ // The modular accessor that returns a handle, so `getDatabase().ref(...)` is
41
+ // recognized without relying on the receiver being spelled `realtimeDb`.
42
+ const REALTIME_DB_HANDLE_FACTORIES = new Set(['getDatabase']);
26
43
  const isMemberExpression = (node) => node.type === utils_1.AST_NODE_TYPES.MemberExpression;
27
44
  const isCallExpression = (node) => node.type === utils_1.AST_NODE_TYPES.CallExpression;
28
45
  const isIdentifier = (node) => node.type === utils_1.AST_NODE_TYPES.Identifier;
29
- const unwrapTSAsExpression = (node) => {
30
- if (node.type === utils_1.AST_NODE_TYPES.TSAsExpression) {
31
- return unwrapTSAsExpression(node.expression);
46
+ // A TS-only wrapper changes the type of an expression, never the receiver it
47
+ // evaluates to, so every shape check has to look through one. Shared by both
48
+ // the Firestore and the RealtimeDB arms so the two cannot drift apart.
49
+ const unwrapTypeWrappers = (node) => {
50
+ switch (node.type) {
51
+ case utils_1.AST_NODE_TYPES.TSAsExpression:
52
+ case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
53
+ case utils_1.AST_NODE_TYPES.TSNonNullExpression:
54
+ case utils_1.AST_NODE_TYPES.TSTypeAssertion:
55
+ return unwrapTypeWrappers(node.expression);
56
+ default:
57
+ return node;
32
58
  }
33
- return node;
34
59
  };
35
60
  const getLeftmostIdentifier = (node) => {
36
61
  let current = node
37
- ? unwrapTSAsExpression(node)
62
+ ? unwrapTypeWrappers(node)
38
63
  : null;
39
64
  while (current) {
40
65
  if (isIdentifier(current)) {
@@ -104,6 +129,19 @@ exports.enforceFirestoreFacade = (0, createRule_1.createRule)({
104
129
  create(context) {
105
130
  const realtimeDbRefVariables = new Set();
106
131
  const realtimeDbChildVariables = new Set();
132
+ // Bindings holding a RealtimeDB `Database`, from which `.ref(...)` yields a
133
+ // RealtimeDB reference regardless of how the handle is spelled.
134
+ const realtimeDbHandleVariables = new Set();
135
+ // Local names of RealtimeDB type exports, so a binding is classified by its
136
+ // declared type rather than by the receiver's spelling. Aliases
137
+ // (`import type { Reference as RtdbRef }`) are carried by the local name.
138
+ const realtimeDbReferenceTypeLocals = new Set();
139
+ const realtimeDbHandleTypeLocals = new Set();
140
+ // Namespace bindings for a RealtimeDB module (`import * as database`),
141
+ // which put the same types behind a qualified name.
142
+ const realtimeDbNamespaceLocals = new Set();
143
+ // Local names of value exports that return a handle (`getDatabase`).
144
+ const realtimeDbHandleFactoryLocals = new Set();
107
145
  const collectionObjectVariables = new Set();
108
146
  const firestoreCollectionVariables = new Set();
109
147
  const firestoreDocRefVariables = new Set();
@@ -132,7 +170,7 @@ exports.enforceFirestoreFacade = (0, createRule_1.createRule)({
132
170
  clientFirestoreVariables.delete(name);
133
171
  };
134
172
  const isClientSdkExportReference = (node, exportName, allowUntracedSpelling) => {
135
- const callee = unwrapTSAsExpression(node);
173
+ const callee = unwrapTypeWrappers(node);
136
174
  if (isIdentifier(callee)) {
137
175
  if (clientSdkImportedLocals.get(callee.name) === exportName) {
138
176
  return true;
@@ -151,7 +189,7 @@ exports.enforceFirestoreFacade = (0, createRule_1.createRule)({
151
189
  };
152
190
  // `writeBatch(firestore)` / `fs.writeBatch(firestore)` — client SDK only.
153
191
  const isClientBatchFactoryCall = (node) => {
154
- const candidate = unwrapTSAsExpression(node);
192
+ const candidate = unwrapTypeWrappers(node);
155
193
  return (isCallExpression(candidate) &&
156
194
  isClientSdkExportReference(candidate.callee, CLIENT_BATCH_FACTORY, true));
157
195
  };
@@ -160,7 +198,7 @@ exports.enforceFirestoreFacade = (0, createRule_1.createRule)({
160
198
  // could share it, so the binding must be traced back to the SDK.
161
199
  const isClientTransactionRunnerCall = (node) => isClientSdkExportReference(node.callee, TRANSACTION_RUNNER, false);
162
200
  const recordFirestoreVariable = (varName, expression) => {
163
- const target = unwrapTSAsExpression(expression);
201
+ const target = unwrapTypeWrappers(expression);
164
202
  if (target.type === utils_1.AST_NODE_TYPES.ConditionalExpression) {
165
203
  const matchedConsequent = recordFirestoreVariable(varName, target.consequent);
166
204
  const matchedAlternate = recordFirestoreVariable(varName, target.alternate);
@@ -232,7 +270,7 @@ exports.enforceFirestoreFacade = (0, createRule_1.createRule)({
232
270
  return false;
233
271
  };
234
272
  const isFirestoreCollectionCall = (node) => {
235
- const candidate = unwrapTSAsExpression(node);
273
+ const candidate = unwrapTypeWrappers(node);
236
274
  if (!isCallExpression(candidate))
237
275
  return false;
238
276
  if (!isMemberExpression(candidate.callee))
@@ -243,7 +281,7 @@ exports.enforceFirestoreFacade = (0, createRule_1.createRule)({
243
281
  return isFirestoreRoot(candidate.callee.object, firestoreCollectionVariables, firestoreDocRefVariables);
244
282
  };
245
283
  const isFirestoreDocumentReference = (node) => {
246
- const candidate = unwrapTSAsExpression(node);
284
+ const candidate = unwrapTypeWrappers(node);
247
285
  if (!isCallExpression(candidate))
248
286
  return false;
249
287
  if (!isMemberExpression(candidate.callee))
@@ -271,10 +309,36 @@ exports.enforceFirestoreFacade = (0, createRule_1.createRule)({
271
309
  const varName = node.left.name;
272
310
  const right = node.right;
273
311
  clearFirestoreTrackingFor(varName);
312
+ // A RealtimeDB binding declared elsewhere keeps its role: the write it
313
+ // receives here has no Firestore facade to route through either way.
314
+ if (registerRealtimeDbValue(varName, right))
315
+ return;
274
316
  recordFirestoreVariable(varName, right);
275
317
  };
276
- const recordImportBinding = (localName, importedName, isClientSource) => {
277
- if (!isClientSource) {
318
+ // A `null` imported name marks a namespace or default binding, which puts
319
+ // the module's exports behind a qualifier.
320
+ const recordRealtimeDbImport = (localName, importedName, source) => {
321
+ if (!REALTIME_DB_MODULES.has(source))
322
+ return;
323
+ if (importedName === null) {
324
+ realtimeDbNamespaceLocals.add(localName);
325
+ return;
326
+ }
327
+ if (REALTIME_DB_REFERENCE_TYPES.has(importedName)) {
328
+ realtimeDbReferenceTypeLocals.add(localName);
329
+ return;
330
+ }
331
+ if (REALTIME_DB_HANDLE_TYPES.has(importedName)) {
332
+ realtimeDbHandleTypeLocals.add(localName);
333
+ return;
334
+ }
335
+ if (REALTIME_DB_HANDLE_FACTORIES.has(importedName)) {
336
+ realtimeDbHandleFactoryLocals.add(localName);
337
+ }
338
+ };
339
+ const recordImportBinding = (localName, importedName, source) => {
340
+ recordRealtimeDbImport(localName, importedName, source);
341
+ if (!CLIENT_SDK_MODULES.has(source)) {
278
342
  nonClientImportedLocals.add(localName);
279
343
  return;
280
344
  }
@@ -285,7 +349,6 @@ exports.enforceFirestoreFacade = (0, createRule_1.createRule)({
285
349
  clientSdkImportedLocals.set(localName, importedName);
286
350
  };
287
351
  const recordImportPattern = (pattern, source) => {
288
- const isClientSource = CLIENT_SDK_MODULES.has(source);
289
352
  if (pattern.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
290
353
  for (const property of pattern.properties) {
291
354
  if (property.type !== utils_1.AST_NODE_TYPES.Property)
@@ -293,16 +356,16 @@ exports.enforceFirestoreFacade = (0, createRule_1.createRule)({
293
356
  if (!isIdentifier(property.key) || !isIdentifier(property.value)) {
294
357
  continue;
295
358
  }
296
- recordImportBinding(property.value.name, property.key.name, isClientSource);
359
+ recordImportBinding(property.value.name, property.key.name, source);
297
360
  }
298
361
  return;
299
362
  }
300
363
  if (isIdentifier(pattern)) {
301
- recordImportBinding(pattern.name, null, isClientSource);
364
+ recordImportBinding(pattern.name, null, source);
302
365
  }
303
366
  };
304
367
  const getImportExpressionSource = (node) => {
305
- const expression = unwrapTSAsExpression(node);
368
+ const expression = unwrapTypeWrappers(node);
306
369
  if (expression.type !== utils_1.AST_NODE_TYPES.ImportExpression)
307
370
  return null;
308
371
  const source = expression.source;
@@ -324,7 +387,7 @@ exports.enforceFirestoreFacade = (0, createRule_1.createRule)({
324
387
  const init = node.init;
325
388
  if (!init || init.type !== utils_1.AST_NODE_TYPES.AwaitExpression)
326
389
  return;
327
- const awaited = unwrapTSAsExpression(init.argument);
390
+ const awaited = unwrapTypeWrappers(init.argument);
328
391
  const directSource = getImportExpressionSource(awaited);
329
392
  if (directSource !== null) {
330
393
  recordImportPattern(node.id, directSource);
@@ -377,59 +440,157 @@ exports.enforceFirestoreFacade = (0, createRule_1.createRule)({
377
440
  firestoreTransactionVariables.add(param.name);
378
441
  }
379
442
  };
380
- const isRealtimeDbRefAssignment = (node) => {
381
- if (node.type !== utils_1.AST_NODE_TYPES.VariableDeclarator)
382
- return false;
383
- const init = node.init;
384
- if (!init)
385
- return false;
386
- if (init.type === utils_1.AST_NODE_TYPES.CallExpression &&
387
- isMemberExpression(init.callee) &&
388
- isIdentifier(init.callee.property) &&
389
- init.callee.property.name === 'ref' &&
390
- isIdentifier(init.callee.object) &&
391
- (init.callee.object.name === 'realtimeDb' ||
392
- init.callee.object.name.includes('realtimeDb')) &&
393
- isIdentifier(node.id)) {
394
- realtimeDbRefVariables.add(node.id.name);
443
+ const classifyRealtimeDbTypeName = (typeName) => {
444
+ if (isIdentifier(typeName)) {
445
+ if (realtimeDbReferenceTypeLocals.has(typeName.name)) {
446
+ return 'reference';
447
+ }
448
+ return realtimeDbHandleTypeLocals.has(typeName.name) ? 'handle' : null;
449
+ }
450
+ if (typeName.type === utils_1.AST_NODE_TYPES.TSQualifiedName) {
451
+ const qualifier = getLeftmostIdentifier(typeName.left);
452
+ if (!qualifier || !realtimeDbNamespaceLocals.has(qualifier.name)) {
453
+ return null;
454
+ }
455
+ if (REALTIME_DB_REFERENCE_TYPES.has(typeName.right.name)) {
456
+ return 'reference';
457
+ }
458
+ return REALTIME_DB_HANDLE_TYPES.has(typeName.right.name)
459
+ ? 'handle'
460
+ : null;
461
+ }
462
+ return null;
463
+ };
464
+ // `Reference | null` is the ordinary spelling for a ref held across calls,
465
+ // so a union member carries the signal just as a bare annotation does.
466
+ const classifyRealtimeDbType = (typeNode) => {
467
+ if (typeNode.type === utils_1.AST_NODE_TYPES.TSTypeReference) {
468
+ return classifyRealtimeDbTypeName(typeNode.typeName);
469
+ }
470
+ if (typeNode.type === utils_1.AST_NODE_TYPES.TSUnionType ||
471
+ typeNode.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
472
+ for (const member of typeNode.types) {
473
+ const kind = classifyRealtimeDbType(member);
474
+ if (kind)
475
+ return kind;
476
+ }
477
+ }
478
+ return null;
479
+ };
480
+ // An assertion states the author's intent as plainly as an annotation, and
481
+ // is the shape RealtimeDB refs take at call sites that narrow away `null`.
482
+ const classifyRealtimeDbAssertion = (node) => {
483
+ let current = node;
484
+ for (;;) {
485
+ if (current.type === utils_1.AST_NODE_TYPES.TSNonNullExpression) {
486
+ current = current.expression;
487
+ continue;
488
+ }
489
+ if (current.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
490
+ current.type === utils_1.AST_NODE_TYPES.TSSatisfiesExpression ||
491
+ current.type === utils_1.AST_NODE_TYPES.TSTypeAssertion) {
492
+ const kind = classifyRealtimeDbType(current.typeAnnotation);
493
+ if (kind)
494
+ return kind;
495
+ current = current.expression;
496
+ continue;
497
+ }
498
+ return null;
499
+ }
500
+ };
501
+ const registerRealtimeDbBinding = (name, kind) => {
502
+ if (kind === 'reference') {
503
+ realtimeDbRefVariables.add(name);
395
504
  return true;
396
505
  }
397
- if (init.type === utils_1.AST_NODE_TYPES.CallExpression &&
398
- isMemberExpression(init.callee) &&
399
- isIdentifier(init.callee.property) &&
400
- init.callee.property.name === 'child' &&
401
- isIdentifier(init.callee.object) &&
402
- realtimeDbRefVariables.has(init.callee.object.name) &&
403
- isIdentifier(node.id)) {
404
- realtimeDbChildVariables.add(node.id.name);
506
+ if (kind === 'handle') {
507
+ realtimeDbHandleVariables.add(name);
405
508
  return true;
406
509
  }
407
510
  return false;
408
511
  };
512
+ const isRealtimeDbHandleFactoryCall = (node) => {
513
+ const target = unwrapTypeWrappers(node);
514
+ if (!isCallExpression(target))
515
+ return false;
516
+ const callee = target.callee;
517
+ if (isIdentifier(callee)) {
518
+ return realtimeDbHandleFactoryLocals.has(callee.name);
519
+ }
520
+ return (isMemberExpression(callee) &&
521
+ isIdentifier(callee.property) &&
522
+ REALTIME_DB_HANDLE_FACTORIES.has(callee.property.name) &&
523
+ isIdentifier(callee.object) &&
524
+ realtimeDbNamespaceLocals.has(callee.object.name));
525
+ };
526
+ const isRealtimeDbHandle = (node) => {
527
+ if (classifyRealtimeDbAssertion(node) === 'handle')
528
+ return true;
529
+ if (isRealtimeDbHandleFactoryCall(node))
530
+ return true;
531
+ const target = unwrapTypeWrappers(node);
532
+ return (isIdentifier(target) &&
533
+ (target.name.includes('realtimeDb') ||
534
+ realtimeDbHandleVariables.has(target.name)));
535
+ };
536
+ const isRealtimeDbMethodCallOn = (node, method, isRealtimeDbReceiver) => {
537
+ const target = unwrapTypeWrappers(node);
538
+ return (isCallExpression(target) &&
539
+ isMemberExpression(target.callee) &&
540
+ isIdentifier(target.callee.property) &&
541
+ target.callee.property.name === method &&
542
+ isRealtimeDbReceiver(target.callee.object));
543
+ };
409
544
  const isRealtimeDbReference = (node) => {
410
- if (node.type === utils_1.AST_NODE_TYPES.CallExpression &&
411
- isMemberExpression(node.callee) &&
412
- isIdentifier(node.callee.property) &&
413
- node.callee.property.name === 'ref' &&
414
- isIdentifier(node.callee.object) &&
415
- (node.callee.object.name === 'realtimeDb' ||
416
- node.callee.object.name.includes('realtimeDb'))) {
545
+ if (classifyRealtimeDbAssertion(node) === 'reference')
417
546
  return true;
547
+ const target = unwrapTypeWrappers(node);
548
+ if (isIdentifier(target)) {
549
+ return (realtimeDbRefVariables.has(target.name) ||
550
+ realtimeDbChildVariables.has(target.name));
418
551
  }
419
- if (isIdentifier(node)) {
420
- return (realtimeDbRefVariables.has(node.name) ||
421
- realtimeDbChildVariables.has(node.name));
422
- }
423
- if (node.type === utils_1.AST_NODE_TYPES.CallExpression &&
424
- isMemberExpression(node.callee) &&
425
- isIdentifier(node.callee.property) &&
426
- node.callee.property.name === 'child' &&
427
- isIdentifier(node.callee.object) &&
428
- (realtimeDbRefVariables.has(node.callee.object.name) ||
429
- realtimeDbChildVariables.has(node.callee.object.name))) {
552
+ return (isRealtimeDbMethodCallOn(target, 'ref', isRealtimeDbHandle) ||
553
+ isRealtimeDbMethodCallOn(target, 'child', isRealtimeDbReference));
554
+ };
555
+ // Returns the RealtimeDB role a value carries, so the two registration
556
+ // sites (declarator init and later assignment) share one classification.
557
+ const classifyRealtimeDbValue = (node) => {
558
+ const asserted = classifyRealtimeDbAssertion(node);
559
+ if (asserted)
560
+ return asserted;
561
+ if (isRealtimeDbHandleFactoryCall(node))
562
+ return 'handle';
563
+ if (isRealtimeDbMethodCallOn(node, 'ref', isRealtimeDbHandle)) {
564
+ return 'reference';
565
+ }
566
+ return isRealtimeDbMethodCallOn(node, 'child', isRealtimeDbReference)
567
+ ? 'child'
568
+ : null;
569
+ };
570
+ const registerRealtimeDbValue = (name, value) => {
571
+ const kind = classifyRealtimeDbValue(value);
572
+ if (kind === 'child') {
573
+ realtimeDbChildVariables.add(name);
430
574
  return true;
431
575
  }
432
- return false;
576
+ return registerRealtimeDbBinding(name, kind);
577
+ };
578
+ const isRealtimeDbRefAssignment = (node) => {
579
+ if (node.type !== utils_1.AST_NODE_TYPES.VariableDeclarator)
580
+ return false;
581
+ if (!isIdentifier(node.id))
582
+ return false;
583
+ const varName = node.id.name;
584
+ // Registering from the annotation covers `let ref: Reference | null`,
585
+ // where the RealtimeDB call arrives later as an assignment rather than as
586
+ // this declarator's init.
587
+ const annotation = node.id.typeAnnotation?.typeAnnotation;
588
+ if (annotation &&
589
+ registerRealtimeDbBinding(varName, classifyRealtimeDbType(annotation))) {
590
+ return true;
591
+ }
592
+ const init = node.init;
593
+ return !!init && registerRealtimeDbValue(varName, init);
433
594
  };
434
595
  const isTrackedFirestoreName = (name) => firestoreDocRefVariables.has(name) ||
435
596
  firestoreBatchVariables.has(name) ||
@@ -505,7 +666,7 @@ exports.enforceFirestoreFacade = (0, createRule_1.createRule)({
505
666
  }
506
667
  let current = object;
507
668
  while (current) {
508
- const unwrapped = unwrapTSAsExpression(current);
669
+ const unwrapped = unwrapTypeWrappers(current);
509
670
  if (isCallExpression(unwrapped) &&
510
671
  isMemberExpression(unwrapped.callee) &&
511
672
  isIdentifier(unwrapped.callee.property)) {
@@ -539,16 +700,23 @@ exports.enforceFirestoreFacade = (0, createRule_1.createRule)({
539
700
  };
540
701
  return {
541
702
  ImportDeclaration(node) {
542
- const source = node.source.value;
543
- const isClientSource = typeof source === 'string' && CLIENT_SDK_MODULES.has(source);
703
+ const source = typeof node.source.value === 'string' ? node.source.value : '';
544
704
  for (const specifier of node.specifiers) {
545
705
  if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier) {
546
- recordImportBinding(specifier.local.name, specifier.imported.name, isClientSource);
706
+ recordImportBinding(specifier.local.name, specifier.imported.name, source);
547
707
  continue;
548
708
  }
549
- recordImportBinding(specifier.local.name, null, isClientSource);
709
+ recordImportBinding(specifier.local.name, null, source);
550
710
  }
551
711
  },
712
+ // Parameters and other annotated bindings declare their RealtimeDB role
713
+ // without ever holding a `ref(...)` call of their own.
714
+ Identifier(node) {
715
+ const annotation = node.typeAnnotation?.typeAnnotation;
716
+ if (!annotation)
717
+ return;
718
+ registerRealtimeDbBinding(node.name, classifyRealtimeDbType(annotation));
719
+ },
552
720
  VariableDeclarator(node) {
553
721
  recordDynamicImportBindings(node);
554
722
  isRealtimeDbRefAssignment(node);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.177",
3
+ "version": "1.20.178",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "version": "1.20.178",
4
+ "date": "2026-08-26T22:11:53.342Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-firestore-facade",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2149
11
+ ],
12
+ "summary": "key the RealtimeDB carve-out on declared type (closes #2149)"
13
+ }
14
+ ]
15
+ },
2
16
  {
3
17
  "version": "1.20.177",
4
18
  "date": "2026-08-26T16:36:01.459Z",