@blumintinc/eslint-plugin-blumint 1.3.0 → 1.3.2

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
@@ -43,7 +43,7 @@ const enforce_mock_firestore_1 = require("./rules/enforce-mock-firestore");
43
43
  module.exports = {
44
44
  meta: {
45
45
  name: '@blumintinc/eslint-plugin-blumint',
46
- version: '1.3.0',
46
+ version: '1.3.2',
47
47
  },
48
48
  parseOptions: {
49
49
  ecmaVersion: 2020,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @fileoverview Enforce generic argument for Firestore DocumentReference
2
+ * @fileoverview Enforce generic argument for Firestore DocumentReference, CollectionReference and CollectionGroup
3
3
  * @author BluMint
4
4
  */
5
5
  type MessageIds = 'missingGeneric' | 'invalidGeneric';
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  /**
3
- * @fileoverview Enforce generic argument for Firestore DocumentReference
3
+ * @fileoverview Enforce generic argument for Firestore DocumentReference, CollectionReference and CollectionGroup
4
4
  * @author BluMint
5
5
  */
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -18,19 +18,20 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
18
18
  meta: {
19
19
  type: 'problem',
20
20
  docs: {
21
- description: 'Enforce generic argument for Firestore DocumentReference',
21
+ description: 'Enforce generic argument for Firestore DocumentReference, CollectionReference and CollectionGroup',
22
22
  recommended: 'error',
23
23
  requiresTypeChecking: true,
24
24
  },
25
25
  schema: [],
26
26
  messages: {
27
- missingGeneric: 'DocumentReference must specify a generic type argument',
28
- invalidGeneric: 'DocumentReference must not use "any" or "{}" as generic type argument',
27
+ missingGeneric: '{{ type }} must specify a generic type argument',
28
+ invalidGeneric: '{{ type }} must not use "any" or "{}" as generic type argument',
29
29
  },
30
30
  },
31
31
  defaultOptions: [],
32
32
  create(context) {
33
33
  const typeCache = new Map();
34
+ const nodeCache = new WeakMap();
34
35
  function hasInvalidType(node) {
35
36
  if (!node)
36
37
  return false;
@@ -106,15 +107,97 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
106
107
  return false;
107
108
  }
108
109
  }
110
+ function hasTypeAnnotation(node) {
111
+ if (nodeCache.has(node)) {
112
+ return nodeCache.get(node);
113
+ }
114
+ let current = node;
115
+ while (current) {
116
+ // Variable declarations with type annotations
117
+ if (current.type === utils_1.AST_NODE_TYPES.VariableDeclarator && current.id.typeAnnotation) {
118
+ nodeCache.set(node, true);
119
+ return true;
120
+ }
121
+ // Class property definitions with type annotations
122
+ if (current.type === utils_1.AST_NODE_TYPES.PropertyDefinition && current.typeAnnotation) {
123
+ nodeCache.set(node, true);
124
+ return true;
125
+ }
126
+ // Return statements in functions with return type annotations
127
+ if (current.type === utils_1.AST_NODE_TYPES.ReturnStatement) {
128
+ const func = current.parent?.parent;
129
+ if (func?.type === utils_1.AST_NODE_TYPES.FunctionDeclaration && func.returnType) {
130
+ nodeCache.set(node, true);
131
+ return true;
132
+ }
133
+ }
134
+ // Assignment expressions to class properties
135
+ if (current.type === utils_1.AST_NODE_TYPES.AssignmentExpression) {
136
+ const left = current.left;
137
+ if (left.type === utils_1.AST_NODE_TYPES.MemberExpression) {
138
+ const obj = left.object;
139
+ if (obj.type === utils_1.AST_NODE_TYPES.ThisExpression) {
140
+ const classNode = findParentClass(current);
141
+ if (classNode) {
142
+ const property = classNode.body.body.find((member) => member.type === utils_1.AST_NODE_TYPES.PropertyDefinition &&
143
+ member.key.type === utils_1.AST_NODE_TYPES.Identifier &&
144
+ member.key.name === left.property.name);
145
+ if (property?.typeAnnotation) {
146
+ nodeCache.set(node, true);
147
+ return true;
148
+ }
149
+ }
150
+ }
151
+ }
152
+ }
153
+ current = current.parent;
154
+ }
155
+ nodeCache.set(node, false);
156
+ return false;
157
+ }
158
+ function findParentClass(node) {
159
+ let current = node;
160
+ while (current) {
161
+ if (current.type === utils_1.AST_NODE_TYPES.ClassDeclaration) {
162
+ return current;
163
+ }
164
+ current = current.parent;
165
+ }
166
+ return undefined;
167
+ }
168
+ function isPartOfMethodChain(node) {
169
+ if (node.callee.type !== utils_1.AST_NODE_TYPES.MemberExpression) {
170
+ return false;
171
+ }
172
+ // Check if this node is part of a method chain as the object
173
+ const obj = node.callee.object;
174
+ if (obj.type === utils_1.AST_NODE_TYPES.CallExpression) {
175
+ return true;
176
+ }
177
+ // Check if this node is part of a method chain as the callee
178
+ let current = node;
179
+ while (current) {
180
+ if (current.parent?.type === utils_1.AST_NODE_TYPES.MemberExpression &&
181
+ current.parent.parent?.type === utils_1.AST_NODE_TYPES.CallExpression) {
182
+ return true;
183
+ }
184
+ current = current.parent;
185
+ }
186
+ return false;
187
+ }
109
188
  return {
110
189
  TSTypeReference(node) {
111
190
  if (node.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
112
- node.typeName.name === 'DocumentReference') {
191
+ (node.typeName.name === 'DocumentReference' ||
192
+ node.typeName.name === 'CollectionReference' ||
193
+ node.typeName.name === 'CollectionGroup')) {
194
+ const typeName = node.typeName.name;
113
195
  // Check if generic type argument is missing
114
196
  if (!node.typeParameters || node.typeParameters.params.length === 0) {
115
197
  context.report({
116
198
  node,
117
199
  messageId: 'missingGeneric',
200
+ data: { type: typeName }
118
201
  });
119
202
  return;
120
203
  }
@@ -124,6 +207,74 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
124
207
  context.report({
125
208
  node,
126
209
  messageId: 'invalidGeneric',
210
+ data: { type: typeName }
211
+ });
212
+ }
213
+ }
214
+ },
215
+ CallExpression(node) {
216
+ // Only check method calls if there's no type annotation
217
+ if (hasTypeAnnotation(node)) {
218
+ return;
219
+ }
220
+ // Check for .doc() calls
221
+ if (node.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
222
+ node.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
223
+ node.callee.property.name === 'doc') {
224
+ const typeAnnotation = node.typeParameters;
225
+ if (!typeAnnotation) {
226
+ context.report({
227
+ node,
228
+ messageId: 'missingGeneric',
229
+ data: { type: 'DocumentReference' }
230
+ });
231
+ }
232
+ else if (hasInvalidType(typeAnnotation.params[0])) {
233
+ context.report({
234
+ node,
235
+ messageId: 'invalidGeneric',
236
+ data: { type: 'DocumentReference' }
237
+ });
238
+ }
239
+ }
240
+ // Check for .collection() calls
241
+ else if (node.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
242
+ node.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
243
+ node.callee.property.name === 'collection' &&
244
+ !isPartOfMethodChain(node)) {
245
+ const typeAnnotation = node.typeParameters;
246
+ if (!typeAnnotation) {
247
+ context.report({
248
+ node,
249
+ messageId: 'missingGeneric',
250
+ data: { type: 'CollectionReference' }
251
+ });
252
+ }
253
+ else if (hasInvalidType(typeAnnotation.params[0])) {
254
+ context.report({
255
+ node,
256
+ messageId: 'invalidGeneric',
257
+ data: { type: 'CollectionReference' }
258
+ });
259
+ }
260
+ }
261
+ // Check for .collectionGroup() calls
262
+ else if (node.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
263
+ node.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
264
+ node.callee.property.name === 'collectionGroup') {
265
+ const typeAnnotation = node.typeParameters;
266
+ if (!typeAnnotation) {
267
+ context.report({
268
+ node,
269
+ messageId: 'missingGeneric',
270
+ data: { type: 'CollectionGroup' }
271
+ });
272
+ }
273
+ else if (hasInvalidType(typeAnnotation.params[0])) {
274
+ context.report({
275
+ node,
276
+ messageId: 'invalidGeneric',
277
+ data: { type: 'CollectionGroup' }
127
278
  });
128
279
  }
129
280
  }
@@ -17,6 +17,8 @@ function isArrayOrPrimitive(checker, esTreeNode, nodeMap) {
17
17
  // Check if it's a primitive type
18
18
  if (type.flags &
19
19
  (typescript_1.TypeFlags.String |
20
+ typescript_1.TypeFlags.StringLike |
21
+ typescript_1.TypeFlags.StringLiteral |
20
22
  typescript_1.TypeFlags.Number |
21
23
  typescript_1.TypeFlags.Boolean |
22
24
  typescript_1.TypeFlags.Null |
@@ -36,28 +38,41 @@ function isArrayOrPrimitive(checker, esTreeNode, nodeMap) {
36
38
  (typeNode && ((0, typescript_1.isArrayTypeNode)(typeNode) || (0, typescript_1.isTupleTypeNode)(typeNode)))) {
37
39
  return true;
38
40
  }
41
+ // Check if it's a string type with methods (like String object)
42
+ if (type.symbol?.name === 'String' || type.symbol?.escapedName === 'String') {
43
+ return true;
44
+ }
39
45
  // If it's not a primitive or array, and has properties, it's an object
40
46
  return false;
41
47
  }
42
48
  function getObjectUsagesInHook(hookBody, objectName) {
43
49
  const usages = new Set();
44
50
  const visited = new Set();
51
+ let needsEntireObject = false;
45
52
  function buildAccessPath(node) {
46
53
  const parts = [];
47
54
  let current = node;
55
+ let hasOptionalChaining = false;
56
+ // Collect all parts from leaf to root
48
57
  while (current.type === utils_1.AST_NODE_TYPES.MemberExpression) {
49
- if (current.computed) {
58
+ const memberExpr = current;
59
+ if (memberExpr.computed) {
50
60
  return null; // Skip computed properties
51
61
  }
52
- if (current.property.type !== utils_1.AST_NODE_TYPES.Identifier) {
62
+ if (memberExpr.property.type !== utils_1.AST_NODE_TYPES.Identifier) {
53
63
  return null;
54
64
  }
55
- parts.unshift(current.property.name);
56
- current = current.object;
65
+ parts.unshift(memberExpr.property.name);
66
+ if (memberExpr.optional) {
67
+ hasOptionalChaining = true;
68
+ }
69
+ current = memberExpr.object;
57
70
  }
58
- if (current.type === utils_1.AST_NODE_TYPES.Identifier &&
59
- current.name === objectName) {
60
- return parts.join('.');
71
+ // Check if we reached the target identifier
72
+ if (current.type === utils_1.AST_NODE_TYPES.Identifier && current.name === objectName) {
73
+ // Build the path with optional chaining
74
+ const path = objectName + (hasOptionalChaining ? '?' : '') + parts.map(part => '.' + part).join('');
75
+ return path;
61
76
  }
62
77
  return null;
63
78
  }
@@ -65,10 +80,27 @@ function getObjectUsagesInHook(hookBody, objectName) {
65
80
  if (visited.has(node))
66
81
  return;
67
82
  visited.add(node);
68
- if (node.type === utils_1.AST_NODE_TYPES.MemberExpression) {
83
+ if (node.type === utils_1.AST_NODE_TYPES.CallExpression) {
84
+ // Check if the object is directly passed as an argument
85
+ node.arguments.forEach((arg) => {
86
+ if (arg.type === utils_1.AST_NODE_TYPES.Identifier &&
87
+ arg.name === objectName) {
88
+ needsEntireObject = true;
89
+ }
90
+ });
91
+ }
92
+ else if (node.type === utils_1.AST_NODE_TYPES.SpreadElement) {
93
+ // If we find a spread operator with our target object, consider it as accessing all properties
94
+ if (node.argument.type === utils_1.AST_NODE_TYPES.Identifier &&
95
+ node.argument.name === objectName) {
96
+ needsEntireObject = true;
97
+ return;
98
+ }
99
+ }
100
+ else if (node.type === utils_1.AST_NODE_TYPES.MemberExpression) {
69
101
  const path = buildAccessPath(node);
70
102
  if (path) {
71
- usages.add(`${objectName}.${path}`);
103
+ usages.add(path);
72
104
  }
73
105
  }
74
106
  // Visit all child nodes
@@ -90,6 +122,10 @@ function getObjectUsagesInHook(hookBody, objectName) {
90
122
  }
91
123
  }
92
124
  visit(hookBody);
125
+ // If the entire object is needed, return an empty set to indicate valid usage
126
+ if (needsEntireObject) {
127
+ return new Set();
128
+ }
93
129
  // Filter out intermediate paths
94
130
  const paths = Array.from(usages);
95
131
  const filteredPaths = paths.filter((path) => !paths.some((otherPath) => otherPath !== path && otherPath.startsWith(path + '.')));
@@ -100,8 +136,9 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
100
136
  meta: {
101
137
  type: 'suggestion',
102
138
  docs: {
103
- description: 'Avoid using entire objects in React hook dependency arrays when only specific fields are used',
139
+ description: 'Avoid using entire objects in React hook dependency arrays when only specific fields are used. Requires TypeScript and `parserOptions.project` to be configured.',
104
140
  recommended: 'error',
141
+ requiresTypeChecking: true,
105
142
  },
106
143
  fixable: 'code',
107
144
  schema: [],
@@ -145,6 +182,7 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
145
182
  }
146
183
  const usages = getObjectUsagesInHook(callbackArg.body, objectName);
147
184
  // If we found specific field usages and the entire object is in deps
185
+ // Skip reporting if usages is empty (indicates spread operator usage)
148
186
  if (usages.size > 0) {
149
187
  const fields = Array.from(usages).join(', ');
150
188
  context.report({
@@ -22,23 +22,32 @@ module.exports = (0, createRule_1.createRule)({
22
22
  if (!filename.includes('functions/src')) {
23
23
  return {};
24
24
  }
25
- let hasFirebaseAdminImport = false;
26
25
  let httpsIdentifier = null;
26
+ let httpsErrorIdentifier = null;
27
27
  return {
28
28
  ImportDeclaration(node) {
29
29
  if (node.source.value === 'firebase-admin' ||
30
30
  node.source.value === 'firebase-admin/lib/https-error') {
31
- hasFirebaseAdminImport = true;
32
- // Track the local name of the https import
31
+ // Check for direct HttpsError import
32
+ const httpsErrorSpecifier = node.specifiers.find((spec) => spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
33
+ spec.imported.name === 'HttpsError');
34
+ // Check for https import that could be used for https.HttpsError
33
35
  const httpsSpecifier = node.specifiers.find((spec) => spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
34
36
  spec.imported.name === 'https');
37
+ if (httpsErrorSpecifier && 'local' in httpsErrorSpecifier) {
38
+ httpsErrorIdentifier = httpsErrorSpecifier.local.name;
39
+ context.report({
40
+ node,
41
+ messageId: 'useProprietaryHttpsError',
42
+ });
43
+ }
35
44
  if (httpsSpecifier && 'local' in httpsSpecifier) {
36
45
  httpsIdentifier = httpsSpecifier.local.name;
46
+ context.report({
47
+ node,
48
+ messageId: 'useProprietaryHttpsError',
49
+ });
37
50
  }
38
- context.report({
39
- node,
40
- messageId: 'useProprietaryHttpsError',
41
- });
42
51
  }
43
52
  },
44
53
  ThrowStatement(node) {
@@ -60,17 +69,14 @@ module.exports = (0, createRule_1.createRule)({
60
69
  return;
61
70
  }
62
71
  // Check for firebase-admin HttpsError usage
63
- if (!hasFirebaseAdminImport) {
64
- return;
65
- }
66
- const isHttpsError = callee.type === utils_1.AST_NODE_TYPES.Identifier &&
67
- callee.name === 'HttpsError';
68
72
  const isFirebaseHttpsError = callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
69
73
  callee.object.type === utils_1.AST_NODE_TYPES.Identifier &&
70
74
  callee.object.name === httpsIdentifier &&
71
75
  callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
72
76
  callee.property.name === 'HttpsError';
73
- if (isHttpsError || isFirebaseHttpsError) {
77
+ const isDirectHttpsError = callee.type === utils_1.AST_NODE_TYPES.Identifier &&
78
+ callee.name === httpsErrorIdentifier;
79
+ if (isFirebaseHttpsError || isDirectHttpsError) {
74
80
  context.report({
75
81
  node,
76
82
  messageId: 'useProprietaryHttpsError',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",