@blumintinc/eslint-plugin-blumint 1.20.164 → 1.20.165

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.164',
226
+ version: '1.20.165',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -16,6 +16,11 @@ const IMPORT_BINDING_NODE_TYPES = new Set([
16
16
  utils_1.AST_NODE_TYPES.ImportDefaultSpecifier,
17
17
  utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier,
18
18
  ]);
19
+ const membersDeclareIdString = (members) => members.some((member) => member.type === utils_1.AST_NODE_TYPES.TSPropertySignature &&
20
+ member.key.type === utils_1.AST_NODE_TYPES.Identifier &&
21
+ member.key.name === 'id' &&
22
+ member.typeAnnotation?.typeAnnotation.type ===
23
+ utils_1.AST_NODE_TYPES.TSStringKeyword);
19
24
  exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
20
25
  name: 'enforce-identifiable-firestore-type',
21
26
  meta: {
@@ -26,8 +31,10 @@ exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
26
31
  },
27
32
  schema: [],
28
33
  messages: {
29
- missingType: 'Expected exported type "{{ typeName }}" in index.ts under folder "{{ folderName }}". Create a type that matches the folder name: `export type {{ typeName }} = { /* fields */ }`.',
30
- notExtendingIdentifiable: 'Type "{{ typeName }}" must extend "Identifiable" to ensure all Firestore documents have an ID field. Add `extends Identifiable` or include `id: string`: `export type {{ typeName }} = { id: string; /* other fields */ }`.',
34
+ // Every spelling named here must clear the rule; a message that
35
+ // prescribes a remedy the rule then reports on is unfollowable (#2035).
36
+ missingType: 'Expected exported type "{{ typeName }}" in index.ts under folder "{{ folderName }}". Create a type that matches the folder name: `export type {{ typeName }} = { id: string; /* other fields */ }`.',
37
+ notExtendingIdentifiable: 'Type "{{ typeName }}" must carry an `id` field so every Firestore document is identifiable. Intersect Identifiable (`export type {{ typeName }} = Identifiable & { /* other fields */ }`), extend it (`export interface {{ typeName }} extends Identifiable { /* other fields */ }`), or declare the field inline (`export type {{ typeName }} = { id: string; /* other fields */ }`).',
31
38
  },
32
39
  },
33
40
  defaultOptions: [],
@@ -46,32 +53,259 @@ exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
46
53
  }
47
54
  // Get the expected type name from the parent folder
48
55
  const folderName = path_1.default.basename(path_1.default.dirname(filename));
49
- // The folder-matching alias only satisfies the rule if it is reachable by
50
- // consumers. An alias can be exported inline (`export type X = ...`) or
51
- // separately (`export { X }` / `export type { X }`), and the separate form
52
- // can appear either before or after the declaration, so exportedness can't
53
- // be decided inside the TSTypeAliasDeclaration visitor alone — it's
54
- // resolved once the whole module has been scanned, in Program:exit.
55
- let matchingAliasNode = null;
56
- let matchingAliasInlineExported = false;
57
- let matchingAliasHasIdentifiable = false;
58
- // Set when resolving the matching alias runs into a type that is declared
59
- // in another module. Such a type is opaque to this single-file walk, so the
60
- // absence of `Identifiable` is unproven rather than disproven.
61
- let matchingAliasLeavesModule = false;
56
+ // The folder-matching declaration only satisfies the rule if it is
57
+ // reachable by consumers. It can be exported inline (`export type X = ...`)
58
+ // or separately (`export { X }` / `export type { X }`), and the separate
59
+ // form can appear either before or after the declaration, so exportedness
60
+ // can't be decided inside the declaration visitor alone — it's resolved once
61
+ // the whole module has been scanned, in Program:exit.
62
+ let matchingDeclarationNode = null;
63
+ let matchingDeclarationInlineExported = false;
64
+ let matchingDeclarationProvidesId = false;
65
+ // Set when resolving the matching declaration runs into a type that is
66
+ // declared in another module. Such a type is opaque to this single-file
67
+ // walk, so the absence of an id is unproven rather than disproven.
68
+ let matchingDeclarationLeavesModule = false;
62
69
  const locallyExportedNames = new Set();
70
+ // Interfaces merge, so a second declaration of the same name adds to the
71
+ // first rather than replacing it; accumulating with OR keeps every arm that
72
+ // any declaration of the name satisfies.
73
+ const analyzeMatchingDeclaration = (node) => {
74
+ // Raised by the resolution walks below whenever a referenced type name
75
+ // resolves to an imported binding, i.e. the chain ran out of this file.
76
+ // Scoped to this declaration so an import that no chain ever reaches — an
77
+ // unrelated `Timestamp`, or a type argument the walk never descends into
78
+ // — grants no amnesty.
79
+ let leavesModule = false;
80
+ const isImportedBinding = (name) => {
81
+ let scope = context.getScope();
82
+ while (scope) {
83
+ const variable = scope.variables.find((variableNode) => variableNode.name === name);
84
+ if (variable) {
85
+ return variable.defs.some((definition) => IMPORT_BINDING_NODE_TYPES.has(definition.node.type));
86
+ }
87
+ scope = scope.upper;
88
+ }
89
+ return false;
90
+ };
91
+ const findLocalDeclaration = (typeName) => {
92
+ let scope = context.getScope();
93
+ while (scope) {
94
+ const variable = scope.variables.find((variableNode) => variableNode.name === typeName);
95
+ if (variable) {
96
+ const definition = variable.defs.find((candidate) => candidate.node.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration ||
97
+ candidate.node.type === utils_1.AST_NODE_TYPES.TSInterfaceDeclaration);
98
+ if (definition) {
99
+ return definition.node;
100
+ }
101
+ // The name is bound, but only to something this file cannot see
102
+ // through: an import. Record that the chain crossed the module
103
+ // boundary so the caller distinguishes "proved no id" from "could
104
+ // not look". A name with no binding at all (a lib global such as
105
+ // `Readonly` or `Map`) is left alone: those are known not to be
106
+ // Identifiable-bearing declarations, and treating them as unknown
107
+ // would silence the rule almost everywhere.
108
+ if (variable.defs.some((candidate) => IMPORT_BINDING_NODE_TYPES.has(candidate.node.type))) {
109
+ leavesModule = true;
110
+ }
111
+ }
112
+ scope = scope.upper;
113
+ }
114
+ return null;
115
+ };
116
+ // `Types.Team` from `import * as Types from '../Team'` names a type in
117
+ // another module just as `Team` from a named import does, but it carries a
118
+ // TSQualifiedName (in a type position) or a MemberExpression (in an
119
+ // interface heritage clause) that the walks below never resolve. The
120
+ // leftmost identifier is the namespace binding, so it answers the same
121
+ // question: did the chain leave this file?
122
+ // ESTree wraps `Types?.Team` in a ChainExpression, so the walk has to see
123
+ // through that wrapper as well or the optional spelling loses the amnesty
124
+ // the plain one gets.
125
+ const noteImportedNamespace = (root) => {
126
+ let leftmost = root;
127
+ while (leftmost.type === utils_1.AST_NODE_TYPES.TSQualifiedName ||
128
+ leftmost.type === utils_1.AST_NODE_TYPES.MemberExpression ||
129
+ leftmost.type === utils_1.AST_NODE_TYPES.ChainExpression) {
130
+ if (leftmost.type === utils_1.AST_NODE_TYPES.ChainExpression) {
131
+ leftmost = leftmost.expression;
132
+ continue;
133
+ }
134
+ leftmost =
135
+ leftmost.type === utils_1.AST_NODE_TYPES.TSQualifiedName
136
+ ? leftmost.left
137
+ : leftmost.object;
138
+ }
139
+ if (leftmost.type === utils_1.AST_NODE_TYPES.Identifier &&
140
+ isImportedBinding(leftmost.name)) {
141
+ leavesModule = true;
142
+ }
143
+ };
144
+ const isParenthesizedType = (node) => node?.type === 'TSParenthesizedType';
145
+ const isReadonlyTypeOperator = (node) => node?.type === utils_1.AST_NODE_TYPES.TSTypeOperator &&
146
+ node.operator === 'readonly';
147
+ const unwrapTransparentType = (typeNode) => {
148
+ let current = typeNode;
149
+ while (current) {
150
+ if (isParenthesizedType(current)) {
151
+ const parenthesized = current;
152
+ current = parenthesized.typeAnnotation ?? null;
153
+ continue;
154
+ }
155
+ if (isReadonlyTypeOperator(current)) {
156
+ current = current.typeAnnotation ?? null;
157
+ continue;
158
+ }
159
+ break;
160
+ }
161
+ return current ?? null;
162
+ };
163
+ const findIdentifiable = (type, checkedTypes = new Set()) => {
164
+ const resolvedType = unwrapTransparentType(type);
165
+ if (!resolvedType) {
166
+ return false;
167
+ }
168
+ // A resolved name lands on the declaration itself, so both declaration
169
+ // kinds are walked here rather than at every call site.
170
+ if (resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration) {
171
+ return findIdentifiable(resolvedType.typeAnnotation, checkedTypes);
172
+ }
173
+ if (resolvedType.type === utils_1.AST_NODE_TYPES.TSInterfaceDeclaration) {
174
+ return (resolvedType.extends ?? []).some((heritage) => findIdentifiable(heritage, new Set(checkedTypes)));
175
+ }
176
+ const referencedName = resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
177
+ resolvedType.typeName.type === utils_1.AST_NODE_TYPES.Identifier
178
+ ? resolvedType.typeName.name
179
+ : resolvedType.type === utils_1.AST_NODE_TYPES.TSInterfaceHeritage &&
180
+ resolvedType.expression.type === utils_1.AST_NODE_TYPES.Identifier
181
+ ? resolvedType.expression.name
182
+ : null;
183
+ if (referencedName !== null) {
184
+ if (referencedName === 'Identifiable') {
185
+ return true;
186
+ }
187
+ if (TRANSPARENT_TYPE_NAMES.has(referencedName) &&
188
+ resolvedType.typeParameters?.params?.some((param) => findIdentifiable(param, checkedTypes))) {
189
+ return true;
190
+ }
191
+ if (checkedTypes.has(referencedName)) {
192
+ return false;
193
+ }
194
+ checkedTypes.add(referencedName);
195
+ return findIdentifiable(findLocalDeclaration(referencedName), checkedTypes);
196
+ }
197
+ // A heritage clause that is not a bare name is a namespace-qualified
198
+ // one (`Types.Team`, or its optional-chained spelling), which names a
199
+ // type this file cannot see through.
200
+ if (resolvedType.type === utils_1.AST_NODE_TYPES.TSInterfaceHeritage) {
201
+ noteImportedNamespace(resolvedType.expression);
202
+ return false;
203
+ }
204
+ if (resolvedType.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
205
+ return resolvedType.types.some((part) => findIdentifiable(part, new Set(checkedTypes)));
206
+ }
207
+ return false;
208
+ };
209
+ // Whether the type declares `id: string` itself. The
210
+ // notExtendingIdentifiable message offers this as an alternative to
211
+ // Identifiable, and it delivers exactly what the rule exists to
212
+ // guarantee — the document's ID field — so it satisfies the rule wherever
213
+ // it appears, not only under a `Resolve<>` wrapper.
214
+ const declaresIdField = (type, visitedTypes = new Set()) => {
215
+ const resolvedType = unwrapTransparentType(type);
216
+ if (!resolvedType) {
217
+ return false;
218
+ }
219
+ if (resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeLiteral) {
220
+ return membersDeclareIdString(resolvedType.members);
221
+ }
222
+ if (resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration) {
223
+ return declaresIdField(resolvedType.typeAnnotation, visitedTypes);
224
+ }
225
+ if (resolvedType.type === utils_1.AST_NODE_TYPES.TSInterfaceDeclaration) {
226
+ return (membersDeclareIdString(resolvedType.body.body) ||
227
+ (resolvedType.extends ?? []).some((heritage) => declaresIdField(heritage, new Set(visitedTypes))));
228
+ }
229
+ if (resolvedType.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
230
+ return resolvedType.types.some((part) => declaresIdField(part, new Set(visitedTypes)));
231
+ }
232
+ const referencedName = resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
233
+ resolvedType.typeName.type === utils_1.AST_NODE_TYPES.Identifier
234
+ ? resolvedType.typeName.name
235
+ : resolvedType.type === utils_1.AST_NODE_TYPES.TSInterfaceHeritage &&
236
+ resolvedType.expression.type === utils_1.AST_NODE_TYPES.Identifier
237
+ ? resolvedType.expression.name
238
+ : null;
239
+ if (referencedName === null) {
240
+ return false;
241
+ }
242
+ if (referencedName === 'Identifiable') {
243
+ return true;
244
+ }
245
+ if (TRANSPARENT_TYPE_NAMES.has(referencedName) &&
246
+ resolvedType.typeParameters?.params?.some((param) => declaresIdField(param, visitedTypes))) {
247
+ return true;
248
+ }
249
+ if (visitedTypes.has(referencedName)) {
250
+ return false;
251
+ }
252
+ visitedTypes.add(referencedName);
253
+ return declaresIdField(findLocalDeclaration(referencedName), visitedTypes);
254
+ };
255
+ // Recursively check the declaration, its alias chain and its parameters
256
+ const providesId = (type, visitedTypes = new Set()) => {
257
+ const resolvedType = unwrapTransparentType(type);
258
+ if (!resolvedType) {
259
+ return false;
260
+ }
261
+ if (findIdentifiable(resolvedType) || declaresIdField(resolvedType)) {
262
+ return true;
263
+ }
264
+ if (resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration) {
265
+ return providesId(resolvedType.typeAnnotation, visitedTypes);
266
+ }
267
+ if (resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
268
+ resolvedType.typeName.type === utils_1.AST_NODE_TYPES.Identifier) {
269
+ const typeName = resolvedType.typeName.name;
270
+ if (visitedTypes.has(typeName)) {
271
+ return false;
272
+ }
273
+ visitedTypes.add(typeName);
274
+ return providesId(findLocalDeclaration(typeName), visitedTypes);
275
+ }
276
+ if (resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
277
+ resolvedType.typeName.type === utils_1.AST_NODE_TYPES.TSQualifiedName) {
278
+ noteImportedNamespace(resolvedType.typeName);
279
+ return false;
280
+ }
281
+ if (resolvedType.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
282
+ return resolvedType.types.some((part) => providesId(part, new Set(visitedTypes)));
283
+ }
284
+ return false;
285
+ };
286
+ matchingDeclarationNode = node;
287
+ matchingDeclarationInlineExported =
288
+ matchingDeclarationInlineExported ||
289
+ node.parent?.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration ||
290
+ node.parent?.type === utils_1.AST_NODE_TYPES.ExportDefaultDeclaration;
291
+ matchingDeclarationProvidesId =
292
+ matchingDeclarationProvidesId || providesId(node);
293
+ matchingDeclarationLeavesModule =
294
+ matchingDeclarationLeavesModule || leavesModule;
295
+ };
63
296
  return {
64
297
  Program() {
65
298
  // Reset flags for each file
66
- matchingAliasNode = null;
67
- matchingAliasInlineExported = false;
68
- matchingAliasHasIdentifiable = false;
69
- matchingAliasLeavesModule = false;
299
+ matchingDeclarationNode = null;
300
+ matchingDeclarationInlineExported = false;
301
+ matchingDeclarationProvidesId = false;
302
+ matchingDeclarationLeavesModule = false;
70
303
  locallyExportedNames.clear();
71
304
  },
72
305
  'Program:exit'(node) {
73
- const hasExpectedType = matchingAliasNode !== null &&
74
- (matchingAliasInlineExported || locallyExportedNames.has(folderName));
306
+ const hasExpectedType = matchingDeclarationNode !== null &&
307
+ (matchingDeclarationInlineExported ||
308
+ locallyExportedNames.has(folderName));
75
309
  if (!hasExpectedType) {
76
310
  context.report({
77
311
  node,
@@ -82,8 +316,8 @@ exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
82
316
  },
83
317
  });
84
318
  }
85
- else if (!matchingAliasHasIdentifiable &&
86
- !matchingAliasLeavesModule) {
319
+ else if (!matchingDeclarationProvidesId &&
320
+ !matchingDeclarationLeavesModule) {
87
321
  // A chain that leaves the module is unknowable here: the imported
88
322
  // type may well intersect `Identifiable`, and reporting would demand
89
323
  // a type-theoretically redundant `Identifiable &` to silence a claim
@@ -100,8 +334,8 @@ exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
100
334
  },
101
335
  ExportNamedDeclaration(node) {
102
336
  // A re-export (`export { X } from './elsewhere'`) exports a binding
103
- // from another module, not the local alias declared in this file, so
104
- // it must never satisfy the gate.
337
+ // from another module, not the local declaration in this file, so it
338
+ // must never satisfy the gate.
105
339
  if (node.source != null) {
106
340
  return;
107
341
  }
@@ -111,198 +345,12 @@ exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
111
345
  },
112
346
  TSTypeAliasDeclaration(node) {
113
347
  if (node.id.name === folderName) {
114
- matchingAliasNode = node;
115
- matchingAliasInlineExported =
116
- node.parent?.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration;
117
- // Raised by the resolution walk below whenever a referenced type name
118
- // resolves to an imported binding, i.e. the alias chain ran out of
119
- // this file. Scoped to this alias so an import that no chain ever
120
- // reaches — an unrelated `Timestamp`, or a type argument the walk
121
- // never descends into — grants no amnesty.
122
- let leavesModule = false;
123
- const isImportedBinding = (name) => {
124
- let scope = context.getScope();
125
- while (scope) {
126
- const variable = scope.variables.find((variableNode) => variableNode.name === name);
127
- if (variable) {
128
- return variable.defs.some((definition) => IMPORT_BINDING_NODE_TYPES.has(definition.node.type));
129
- }
130
- scope = scope.upper;
131
- }
132
- return false;
133
- };
134
- const findTypeAliasAnnotation = (typeName) => {
135
- let scope = context.getScope();
136
- while (scope) {
137
- const variable = scope.variables.find((variableNode) => variableNode.name === typeName);
138
- if (variable) {
139
- const definition = variable.defs.find((definition) => definition.node.type ===
140
- utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration);
141
- if (definition &&
142
- definition.node.type ===
143
- utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration &&
144
- definition.node.typeAnnotation) {
145
- return definition.node.typeAnnotation;
146
- }
147
- // The name is bound, but only to something this file cannot
148
- // see through: an import. Record that the chain crossed the
149
- // module boundary so the caller distinguishes "proved no
150
- // Identifiable" from "could not look". A name with no binding
151
- // at all (a lib global such as `Readonly` or `Map`) is left
152
- // alone: those are known not to be Identifiable-bearing
153
- // aliases, and treating them as unknown would silence the
154
- // rule almost everywhere.
155
- if (variable.defs.some((definition) => IMPORT_BINDING_NODE_TYPES.has(definition.node.type))) {
156
- leavesModule = true;
157
- }
158
- }
159
- scope = scope.upper;
160
- }
161
- return null;
162
- };
163
- // `Types.Team` from `import * as Types from '../Team'` names a type
164
- // in another module just as `Team` from a named import does, but it
165
- // carries a TSQualifiedName that the walks below never resolve. The
166
- // leftmost identifier is the namespace binding, so it answers the
167
- // same question: did the chain leave this file?
168
- const noteQualifiedNamespaceImport = (qualifiedName) => {
169
- let left = qualifiedName.left;
170
- while (left.type === utils_1.AST_NODE_TYPES.TSQualifiedName) {
171
- left = left.left;
172
- }
173
- if (left.type === utils_1.AST_NODE_TYPES.Identifier &&
174
- isImportedBinding(left.name)) {
175
- leavesModule = true;
176
- }
177
- };
178
- const isParenthesizedType = (node) => node?.type === 'TSParenthesizedType';
179
- const isReadonlyTypeOperator = (node) => node?.type === utils_1.AST_NODE_TYPES.TSTypeOperator &&
180
- node.operator === 'readonly';
181
- const unwrapTransparentType = (typeNode) => {
182
- let current = typeNode;
183
- while (current) {
184
- if (isParenthesizedType(current)) {
185
- const parenthesized = current;
186
- current = parenthesized.typeAnnotation ?? null;
187
- continue;
188
- }
189
- if (isReadonlyTypeOperator(current)) {
190
- current = current.typeAnnotation ?? null;
191
- continue;
192
- }
193
- break;
194
- }
195
- return current ?? null;
196
- };
197
- const findIdentifiable = (type, checkedTypes = new Set()) => {
198
- const resolvedType = unwrapTransparentType(type);
199
- if (!resolvedType) {
200
- return false;
201
- }
202
- if (resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
203
- resolvedType.typeName.type === utils_1.AST_NODE_TYPES.Identifier) {
204
- const typeName = resolvedType.typeName.name;
205
- if (typeName === 'Identifiable') {
206
- return true;
207
- }
208
- if (TRANSPARENT_TYPE_NAMES.has(typeName) &&
209
- resolvedType.typeParameters?.params?.some((param) => findIdentifiable(param, checkedTypes))) {
210
- return true;
211
- }
212
- if (checkedTypes.has(typeName)) {
213
- return false;
214
- }
215
- checkedTypes.add(typeName);
216
- const aliasAnnotation = findTypeAliasAnnotation(typeName);
217
- return findIdentifiable(aliasAnnotation, checkedTypes);
218
- }
219
- if (resolvedType.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
220
- return resolvedType.types.some((part) => findIdentifiable(part, new Set(checkedTypes)));
221
- }
222
- return false;
223
- };
224
- // Check if type has id: string field
225
- const checkIdField = (type, visitedTypes = new Set()) => {
226
- const resolvedType = unwrapTransparentType(type);
227
- if (!resolvedType) {
228
- return false;
229
- }
230
- if (resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeLiteral) {
231
- return resolvedType.members.some((member) => member.type === utils_1.AST_NODE_TYPES.TSPropertySignature &&
232
- member.key.type === utils_1.AST_NODE_TYPES.Identifier &&
233
- member.key.name === 'id' &&
234
- member.typeAnnotation?.typeAnnotation.type ===
235
- utils_1.AST_NODE_TYPES.TSStringKeyword);
236
- }
237
- if (resolvedType.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
238
- return resolvedType.types.some((part) => checkIdField(part, new Set(visitedTypes)));
239
- }
240
- if (resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeReference) {
241
- if (resolvedType.typeName.type !== utils_1.AST_NODE_TYPES.Identifier) {
242
- return false;
243
- }
244
- const typeName = resolvedType.typeName.name;
245
- if (typeName === 'Identifiable') {
246
- return true;
247
- }
248
- if (TRANSPARENT_TYPE_NAMES.has(typeName) &&
249
- resolvedType.typeParameters?.params?.some((param) => checkIdField(param, visitedTypes))) {
250
- return true;
251
- }
252
- if (visitedTypes.has(typeName)) {
253
- return false;
254
- }
255
- visitedTypes.add(typeName);
256
- const referencedType = findTypeAliasAnnotation(typeName);
257
- return checkIdField(referencedType, visitedTypes);
258
- }
259
- return false;
260
- };
261
- // Check if type is wrapped in a utility type
262
- const isUtilityType = (type) => {
263
- if (!type) {
264
- return false;
265
- }
266
- return (type.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
267
- type.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
268
- type.typeName.name === 'Resolve');
269
- };
270
- // Recursively check the type and its parameters
271
- const checkType = (type, visitedTypes = new Set()) => {
272
- const resolvedType = unwrapTransparentType(type);
273
- if (!resolvedType) {
274
- return false;
275
- }
276
- if (findIdentifiable(resolvedType)) {
277
- return true;
278
- }
279
- if (isUtilityType(resolvedType) &&
280
- resolvedType.typeParameters?.params?.[0] &&
281
- checkIdField(resolvedType.typeParameters.params[0])) {
282
- return true;
283
- }
284
- if (resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
285
- resolvedType.typeName.type === utils_1.AST_NODE_TYPES.Identifier) {
286
- const typeName = resolvedType.typeName.name;
287
- if (visitedTypes.has(typeName)) {
288
- return false;
289
- }
290
- visitedTypes.add(typeName);
291
- const aliasAnnotation = findTypeAliasAnnotation(typeName);
292
- return checkType(aliasAnnotation, visitedTypes);
293
- }
294
- if (resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
295
- resolvedType.typeName.type === utils_1.AST_NODE_TYPES.TSQualifiedName) {
296
- noteQualifiedNamespaceImport(resolvedType.typeName);
297
- return false;
298
- }
299
- if (resolvedType.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
300
- return resolvedType.types.some((part) => checkType(part, new Set(visitedTypes)));
301
- }
302
- return false;
303
- };
304
- matchingAliasHasIdentifiable = checkType(node.typeAnnotation);
305
- matchingAliasLeavesModule = leavesModule;
348
+ analyzeMatchingDeclaration(node);
349
+ }
350
+ },
351
+ TSInterfaceDeclaration(node) {
352
+ if (node.id.name === folderName) {
353
+ analyzeMatchingDeclaration(node);
306
354
  }
307
355
  },
308
356
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.164",
3
+ "version": "1.20.165",
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.165",
4
+ "date": "2026-08-18T00:07:35.027Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-identifiable-firestore-type",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2035
11
+ ],
12
+ "summary": "accept every remedy its message prescribes (closes #2035)"
13
+ }
14
+ ]
15
+ },
2
16
  {
3
17
  "version": "1.20.164",
4
18
  "date": "2026-08-17T22:26:37.717Z",