@blumintinc/eslint-plugin-blumint 1.20.45 → 1.20.47

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.45',
226
+ version: '1.20.47',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -61,7 +61,7 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
61
61
  guardFunctions: {
62
62
  type: 'array',
63
63
  items: { type: 'string' },
64
- description: 'Canonical type guard function names',
64
+ description: 'Type guard function names; the first is the one suggestions call and import',
65
65
  },
66
66
  excludeFiles: {
67
67
  type: 'array',
@@ -70,23 +70,32 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
70
70
  },
71
71
  guardImportSource: {
72
72
  type: 'string',
73
- description: 'Module specifier the suggestion imports isSnapshotReady from',
73
+ description: 'Module specifier the suggestion imports the guard from',
74
74
  },
75
75
  },
76
76
  additionalProperties: false,
77
77
  },
78
78
  ],
79
79
  messages: {
80
- noFalsyCheck: "Do not use boolean coercion on FirestoreSnapshotState<T>. All string states ('idle', 'loading', 'not-found') are truthy, so '{{expression}}' does not behave as intended. Use isSnapshotReady(state) to narrow to T, or compare explicitly (e.g., state === 'loading').",
81
- noRawTypeof: "Do not use '{{expression}}' to narrow FirestoreSnapshotState<T> to data. Use isSnapshotReady(state) instead to maintain the abstraction boundary.",
80
+ // `guard` is the configured canonical guard name, so a consumer who
81
+ // renames it is not told to call a function their config says does not
82
+ // exist. Every report and suggestion supplies it.
83
+ noFalsyCheck: "Do not use boolean coercion on FirestoreSnapshotState<T>. All string states ('idle', 'loading', 'not-found') are truthy, so '{{expression}}' does not behave as intended. Use {{guard}}(state) to narrow to T, or compare explicitly (e.g., state === 'loading').",
84
+ noRawTypeof: "Do not use '{{expression}}' to narrow FirestoreSnapshotState<T> to data. Use {{guard}}(state) instead to maintain the abstraction boundary.",
82
85
  },
83
86
  },
84
87
  defaultOptions: [{}],
85
88
  create(context, [options]) {
86
89
  const snapshotHooks = new Set(options?.snapshotHooks ?? DEFAULT_SNAPSHOT_HOOKS);
87
- // guardFunctions is accepted in config for documentation and future extensibility
88
- // but detection is purely syntactic (by hook source), not by guard function name.
89
- void (options?.guardFunctions ?? DEFAULT_GUARD_FUNCTIONS);
90
+ /**
91
+ * The name every suggestion calls, resolves in scope, and imports.
92
+ * `guardFunctions` may list several recognized guards; the first usable one
93
+ * is canonical. A list that names nothing callable — empty, or holding only
94
+ * blanks — falls back to the default rather than leaving the suggestion to
95
+ * emit `undefined(state)` or `(state)`.
96
+ */
97
+ const guardName = options?.guardFunctions?.find((name) => name.trim().length > 0) ??
98
+ DEFAULT_GUARD_FUNCTIONS[0];
90
99
  const excludeFiles = options?.excludeFiles ?? [
91
100
  'src/types/FirestoreSnapshotState.ts',
92
101
  ];
@@ -163,7 +172,7 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
163
172
  * import, or would collide with an unrelated binding of the same name.
164
173
  */
165
174
  function resolveGuardBinding(scope) {
166
- const variable = utils_1.ASTUtils.findVariable(scope, GUARD_NAME);
175
+ const variable = utils_1.ASTUtils.findVariable(scope, guardName);
167
176
  if (!variable) {
168
177
  return 'missing';
169
178
  }
@@ -214,14 +223,14 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
214
223
  if (reusable) {
215
224
  const namedSpecifiers = reusable.specifiers.filter(isValueImportSpecifier);
216
225
  const lastSpecifier = namedSpecifiers[namedSpecifiers.length - 1];
217
- return fixer.insertTextAfter(lastSpecifier, `, ${GUARD_NAME}`);
226
+ return fixer.insertTextAfter(lastSpecifier, `, ${guardName}`);
218
227
  }
219
228
  // A namespace or type-only import of the module cannot take a named value
220
229
  // specifier, but its path is proof of how this file reaches the module.
221
230
  const source = guardDeclarations.length
222
231
  ? String(guardDeclarations[0].source.value)
223
232
  : guardImportSource;
224
- const importText = `import { ${GUARD_NAME} } from '${source}';\n`;
233
+ const importText = `import { ${guardName} } from '${source}';\n`;
225
234
  const [firstImport] = declarations;
226
235
  if (firstImport) {
227
236
  return fixer.insertTextBefore(firstImport, importText);
@@ -233,7 +242,7 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
233
242
  }
234
243
  /**
235
244
  * Builds the single suggestion shared by every report: swap the flagged
236
- * expression for its guard-based equivalent and bring `isSnapshotReady` into
245
+ * expression for its guard-based equivalent and bring the guard into
237
246
  * scope. Declines (no suggestion) when the name is already taken by
238
247
  * something that is not the guard.
239
248
  */
@@ -241,7 +250,7 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
241
250
  return [
242
251
  {
243
252
  messageId,
244
- data: { expression: replacement },
253
+ data: { expression: replacement, guard: guardName },
245
254
  fix(fixer) {
246
255
  const binding = resolveGuardBinding(scope);
247
256
  if (binding === 'conflict') {
@@ -306,8 +315,8 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
306
315
  context.report({
307
316
  node,
308
317
  messageId: 'noRawTypeof',
309
- data: { expression: getText(node) },
310
- suggest: guardSuggestion('noRawTypeof', node, `${GUARD_NAME}(${operand.name})`, context.getScope()),
318
+ data: { expression: getText(node), guard: guardName },
319
+ suggest: guardSuggestion('noRawTypeof', node, `${guardName}(${operand.name})`, context.getScope()),
311
320
  });
312
321
  }
313
322
  // typeof state === 'string' — allowed (narrows to non-data states)
@@ -327,7 +336,7 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
327
336
  context.report({
328
337
  node,
329
338
  messageId: 'noFalsyCheck',
330
- data: { expression },
339
+ data: { expression, guard: guardName },
331
340
  suggest: guardSuggestion('noFalsyCheck', fixNode, replacement, context.getScope()),
332
341
  });
333
342
  }
@@ -372,7 +381,7 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
372
381
  // stay negated or the suggestion would reverse the control flow.
373
382
  if (argument.type === utils_1.AST_NODE_TYPES.Identifier &&
374
383
  isSnapshotVar(argument)) {
375
- reportFalsyCheck(node, `!${argument.name}`, `!${GUARD_NAME}(${argument.name})`);
384
+ reportFalsyCheck(node, `!${argument.name}`, `!${guardName}(${argument.name})`);
376
385
  }
377
386
  // !!state — the argument is another `!` whose argument is the snapshot var.
378
387
  // The double negation is a truthiness coercion, so the guard is positive.
@@ -381,7 +390,7 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
381
390
  argument.argument.type === utils_1.AST_NODE_TYPES.Identifier &&
382
391
  isSnapshotVar(argument.argument)) {
383
392
  const varName = argument.argument.name;
384
- reportFalsyCheck(node, `!!${varName}`, `${GUARD_NAME}(${varName})`);
393
+ reportFalsyCheck(node, `!!${varName}`, `${guardName}(${varName})`);
385
394
  }
386
395
  },
387
396
  // IfStatement: if (state) { ... } or if (!state) { ... }
@@ -389,14 +398,14 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
389
398
  IfStatement(node) {
390
399
  const test = node.test;
391
400
  if (test.type === utils_1.AST_NODE_TYPES.Identifier && isSnapshotVar(test)) {
392
- reportFalsyCheck(test, test.name, `${GUARD_NAME}(${test.name})`);
401
+ reportFalsyCheck(test, test.name, `${guardName}(${test.name})`);
393
402
  }
394
403
  },
395
404
  // ConditionalExpression: state ? a : b
396
405
  ConditionalExpression(node) {
397
406
  const test = node.test;
398
407
  if (test.type === utils_1.AST_NODE_TYPES.Identifier && isSnapshotVar(test)) {
399
- reportFalsyCheck(test, test.name, `${GUARD_NAME}(${test.name})`);
408
+ reportFalsyCheck(test, test.name, `${guardName}(${test.name})`);
400
409
  }
401
410
  },
402
411
  // LogicalExpression: state && expr, state || expr
@@ -408,13 +417,13 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
408
417
  if (node.operator === '&&') {
409
418
  // `state && expr` guards expr, so swapping the operand for the
410
419
  // guard keeps both the polarity and the narrowing of `state`.
411
- reportFalsyCheck(left, left.name, `${GUARD_NAME}(${left.name})`);
420
+ reportFalsyCheck(left, left.name, `${guardName}(${left.name})`);
412
421
  return;
413
422
  }
414
423
  // `state || fallback` evaluates to the state itself when it is
415
424
  // usable, so a bare operand swap would yield `true` instead of the
416
425
  // data. Only the conditional form preserves that value.
417
- const guarded = `${GUARD_NAME}(${left.name}) ? ${left.name} : ${getText(node.right)}`;
426
+ const guarded = `${guardName}(${left.name}) ? ${left.name} : ${getText(node.right)}`;
418
427
  reportFalsyCheck(left, left.name, needsParentheses(node) ? `(${guarded})` : guarded, node);
419
428
  }
420
429
  },
@@ -432,7 +441,7 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
432
441
  node.arguments[0].type === utils_1.AST_NODE_TYPES.Identifier &&
433
442
  isSnapshotVar(node.arguments[0])) {
434
443
  const varName = node.arguments[0].name;
435
- reportFalsyCheck(node, `Boolean(${varName})`, `${GUARD_NAME}(${varName})`);
444
+ reportFalsyCheck(node, `Boolean(${varName})`, `${guardName}(${varName})`);
436
445
  }
437
446
  },
438
447
  };
@@ -148,6 +148,306 @@ function isRecursiveFunction(node) {
148
148
  checkNode(node.body);
149
149
  return hasRecursiveCall;
150
150
  }
151
+ const THIS_OWNER = 'this';
152
+ const FUNCTION_NODE_TYPES = new Set([
153
+ utils_1.AST_NODE_TYPES.FunctionDeclaration,
154
+ utils_1.AST_NODE_TYPES.FunctionExpression,
155
+ utils_1.AST_NODE_TYPES.ArrowFunctionExpression,
156
+ ]);
157
+ function pushChildren(node, visitorKeys, stack) {
158
+ for (const key of visitorKeys[node.type] ?? []) {
159
+ const value = node[key];
160
+ const children = Array.isArray(value) ? value : [value];
161
+ for (const child of children) {
162
+ if (child && typeof child === 'object' && 'type' in child) {
163
+ stack.push(child);
164
+ }
165
+ }
166
+ }
167
+ }
168
+ /**
169
+ * Return expressions belonging to `fn` itself: the concise arrow body, or the
170
+ * arguments of every `return` whose nearest enclosing function is `fn`.
171
+ * Returns of nested functions belong to those functions, not to `fn`.
172
+ */
173
+ function collectOwnReturnExpressions(fn, visitorKeys) {
174
+ if (fn.body.type !== utils_1.AST_NODE_TYPES.BlockStatement) {
175
+ return [fn.body];
176
+ }
177
+ const returnExpressions = [];
178
+ const stack = [...fn.body.body];
179
+ while (stack.length > 0) {
180
+ const current = stack.pop();
181
+ if (FUNCTION_NODE_TYPES.has(current.type))
182
+ continue;
183
+ if (current.type === utils_1.AST_NODE_TYPES.ReturnStatement) {
184
+ if (current.argument) {
185
+ returnExpressions.push(current.argument);
186
+ }
187
+ continue;
188
+ }
189
+ pushChildren(current, visitorKeys, stack);
190
+ }
191
+ return returnExpressions;
192
+ }
193
+ /**
194
+ * Identifiers that name something (object keys, member property names) are not
195
+ * references to the binding of the same name.
196
+ */
197
+ function isReferencePosition(node) {
198
+ const parent = node.parent;
199
+ if (!parent)
200
+ return true;
201
+ if (parent.type === utils_1.AST_NODE_TYPES.MemberExpression &&
202
+ !parent.computed &&
203
+ parent.property === node) {
204
+ return false;
205
+ }
206
+ if ((parent.type === utils_1.AST_NODE_TYPES.Property ||
207
+ parent.type === utils_1.AST_NODE_TYPES.PropertyDefinition ||
208
+ parent.type === utils_1.AST_NODE_TYPES.MethodDefinition) &&
209
+ !parent.computed &&
210
+ parent.key === node) {
211
+ return false;
212
+ }
213
+ return true;
214
+ }
215
+ function ownerMatches(object, owners) {
216
+ if (object.type === utils_1.AST_NODE_TYPES.ThisExpression) {
217
+ return owners.has(THIS_OWNER);
218
+ }
219
+ if (object.type === utils_1.AST_NODE_TYPES.Identifier) {
220
+ return owners.has(object.name);
221
+ }
222
+ return false;
223
+ }
224
+ /**
225
+ * Searches a whole expression subtree, nested functions included. A closure in
226
+ * the returned value is part of the return type, so a self-reference inside it
227
+ * can be what defeats inference: `return { orderBy: () => buildQuery(p) }
228
+ * satisfies FakeQuery` is TS7023. Whether TypeScript manages to break such a
229
+ * cycle depends on type information this rule does not have, so every reference
230
+ * in a return expression counts — erring toward silence, per the repo's
231
+ * preference for false negatives over false positives.
232
+ */
233
+ function subtreeReferences(root, selfReferences, visitorKeys) {
234
+ const stack = [root];
235
+ while (stack.length > 0) {
236
+ const current = stack.pop();
237
+ if (current.type === utils_1.AST_NODE_TYPES.Identifier &&
238
+ isReferencePosition(current) &&
239
+ selfReferences.some((reference) => reference.kind === 'identifier' && reference.name === current.name)) {
240
+ return true;
241
+ }
242
+ if (current.type === utils_1.AST_NODE_TYPES.MemberExpression &&
243
+ !current.computed &&
244
+ current.property.type === utils_1.AST_NODE_TYPES.Identifier) {
245
+ const propertyName = current.property.name;
246
+ const matchesMember = selfReferences.some((reference) => reference.kind === 'member' &&
247
+ reference.name === propertyName &&
248
+ ownerMatches(current.object, reference.owners));
249
+ if (matchesMember)
250
+ return true;
251
+ }
252
+ pushChildren(current, visitorKeys, stack);
253
+ }
254
+ return false;
255
+ }
256
+ function ownerNamesOfObjectExpression(objectExpression) {
257
+ const owners = new Set([THIS_OWNER]);
258
+ const parent = objectExpression.parent;
259
+ if (parent?.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
260
+ parent.id.type === utils_1.AST_NODE_TYPES.Identifier) {
261
+ owners.add(parent.id.name);
262
+ }
263
+ return owners;
264
+ }
265
+ function ownerNamesOfClassMember(member) {
266
+ const owners = new Set([THIS_OWNER]);
267
+ const classBody = member.parent;
268
+ const classNode = classBody?.parent;
269
+ if (classNode?.type === utils_1.AST_NODE_TYPES.ClassDeclaration ||
270
+ classNode?.type === utils_1.AST_NODE_TYPES.ClassExpression) {
271
+ if (classNode.id) {
272
+ owners.add(classNode.id.name);
273
+ }
274
+ // A class expression assigned to a binding is also reachable by that name.
275
+ if (classNode.parent?.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
276
+ classNode.parent.id.type === utils_1.AST_NODE_TYPES.Identifier) {
277
+ owners.add(classNode.parent.id.name);
278
+ }
279
+ }
280
+ return owners;
281
+ }
282
+ function keyName(node) {
283
+ if (node.computed)
284
+ return undefined;
285
+ if (node.key.type === utils_1.AST_NODE_TYPES.Identifier ||
286
+ (node.key.type === utils_1.AST_NODE_TYPES.Literal &&
287
+ typeof node.key.value === 'string')) {
288
+ return getNameFromIdentifierOrLiteral(node.key);
289
+ }
290
+ return undefined;
291
+ }
292
+ /**
293
+ * Every name by which the function can reach itself. A function with no
294
+ * resolvable name cannot be self-referential by name, so it yields none.
295
+ */
296
+ function resolveSelfReferences(node) {
297
+ const selfReferences = [];
298
+ if (node.type === utils_1.AST_NODE_TYPES.MethodDefinition) {
299
+ const name = keyName(node);
300
+ if (name) {
301
+ selfReferences.push({
302
+ kind: 'member',
303
+ name,
304
+ owners: ownerNamesOfClassMember(node),
305
+ });
306
+ }
307
+ return selfReferences;
308
+ }
309
+ if ((node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
310
+ node.type === utils_1.AST_NODE_TYPES.FunctionExpression) &&
311
+ node.id) {
312
+ selfReferences.push({ kind: 'identifier', name: node.id.name });
313
+ }
314
+ const parent = node.parent;
315
+ if (parent?.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
316
+ parent.id.type === utils_1.AST_NODE_TYPES.Identifier) {
317
+ selfReferences.push({ kind: 'identifier', name: parent.id.name });
318
+ }
319
+ if (parent?.type === utils_1.AST_NODE_TYPES.Property) {
320
+ const name = keyName(parent);
321
+ if (name) {
322
+ selfReferences.push({
323
+ kind: 'member',
324
+ name,
325
+ owners: ownerNamesOfObjectExpression(parent.parent),
326
+ });
327
+ }
328
+ }
329
+ if (parent?.type === utils_1.AST_NODE_TYPES.PropertyDefinition) {
330
+ const name = keyName(parent);
331
+ if (name) {
332
+ selfReferences.push({
333
+ kind: 'member',
334
+ name,
335
+ owners: ownerNamesOfClassMember(parent),
336
+ });
337
+ }
338
+ }
339
+ if (parent?.type === utils_1.AST_NODE_TYPES.AssignmentExpression) {
340
+ const target = parent.left;
341
+ if (target.type === utils_1.AST_NODE_TYPES.Identifier) {
342
+ selfReferences.push({ kind: 'identifier', name: target.name });
343
+ }
344
+ else if (target.type === utils_1.AST_NODE_TYPES.MemberExpression &&
345
+ !target.computed &&
346
+ target.property.type === utils_1.AST_NODE_TYPES.Identifier) {
347
+ const owners = new Set();
348
+ if (target.object.type === utils_1.AST_NODE_TYPES.ThisExpression) {
349
+ owners.add(THIS_OWNER);
350
+ }
351
+ else if (target.object.type === utils_1.AST_NODE_TYPES.Identifier) {
352
+ owners.add(target.object.name);
353
+ }
354
+ if (owners.size > 0) {
355
+ selfReferences.push({
356
+ kind: 'member',
357
+ name: target.property.name,
358
+ owners,
359
+ });
360
+ }
361
+ }
362
+ }
363
+ return selfReferences;
364
+ }
365
+ function bodyOf(node) {
366
+ if (node.type === utils_1.AST_NODE_TYPES.MethodDefinition) {
367
+ return node.value.body ? node.value : undefined;
368
+ }
369
+ if ((node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
370
+ node.type === utils_1.AST_NODE_TYPES.FunctionExpression) &&
371
+ node.body) {
372
+ return node;
373
+ }
374
+ if (node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression) {
375
+ return node;
376
+ }
377
+ return undefined;
378
+ }
379
+ /** Bare identifiers referenced from a function's own return expressions. */
380
+ function collectReturnIdentifierNames(fn, visitorKeys) {
381
+ const names = new Set();
382
+ for (const returnExpression of collectOwnReturnExpressions(fn, visitorKeys)) {
383
+ const stack = [returnExpression];
384
+ while (stack.length > 0) {
385
+ const current = stack.pop();
386
+ if (current.type === utils_1.AST_NODE_TYPES.Identifier &&
387
+ isReferencePosition(current)) {
388
+ names.add(current.name);
389
+ }
390
+ pushChildren(current, visitorKeys, stack);
391
+ }
392
+ }
393
+ return names;
394
+ }
395
+ function moduleScopeFunctions(program) {
396
+ const functions = new Map();
397
+ const statements = program.body.map((statement) => {
398
+ if (statement.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration ||
399
+ statement.type === utils_1.AST_NODE_TYPES.ExportDefaultDeclaration) {
400
+ return statement.declaration ?? statement;
401
+ }
402
+ return statement;
403
+ });
404
+ for (const statement of statements) {
405
+ if (statement.type === utils_1.AST_NODE_TYPES.FunctionDeclaration &&
406
+ statement.id &&
407
+ statement.body) {
408
+ functions.set(statement.id.name, statement);
409
+ continue;
410
+ }
411
+ if (statement.type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
412
+ for (const declarator of statement.declarations) {
413
+ const init = declarator.init;
414
+ if (declarator.id.type === utils_1.AST_NODE_TYPES.Identifier &&
415
+ init &&
416
+ (init.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
417
+ (init.type === utils_1.AST_NODE_TYPES.FunctionExpression && init.body))) {
418
+ functions.set(declarator.id.name, init);
419
+ }
420
+ }
421
+ }
422
+ }
423
+ return functions;
424
+ }
425
+ /**
426
+ * Maps each module-scope function name to the names it references from its own
427
+ * return expressions. A cycle in this graph is mutual recursion, which triggers
428
+ * the same TS7023 as direct self-reference.
429
+ */
430
+ function buildReturnReferenceGraph(program, visitorKeys) {
431
+ const graph = new Map();
432
+ for (const [name, fn] of moduleScopeFunctions(program)) {
433
+ graph.set(name, collectReturnIdentifierNames(fn, visitorKeys));
434
+ }
435
+ return graph;
436
+ }
437
+ function participatesInReturnCycle(name, graph) {
438
+ const seen = new Set();
439
+ const stack = [...(graph.get(name) ?? [])];
440
+ while (stack.length > 0) {
441
+ const current = stack.pop();
442
+ if (current === name)
443
+ return true;
444
+ if (seen.has(current))
445
+ continue;
446
+ seen.add(current);
447
+ stack.push(...(graph.get(current) ?? []));
448
+ }
449
+ return false;
450
+ }
151
451
  function isOverloadedFunction(node) {
152
452
  if (!node.parent)
153
453
  return false;
@@ -310,6 +610,40 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
310
610
  create(context, [options]) {
311
611
  const mergedOptions = { ...defaultOptions, ...options };
312
612
  const filename = context.getFilename();
613
+ const sourceCode = context.getSourceCode();
614
+ const visitorKeys = sourceCode.visitorKeys;
615
+ // Built at most once per file, and only when a direct self-reference has
616
+ // already been ruled out.
617
+ let returnReferenceGraph;
618
+ /**
619
+ * True when TypeScript cannot infer the return type because the function
620
+ * is referenced from within its own return expression (TS7023). Removing
621
+ * the annotation in that case does not compile, so the rule stays silent.
622
+ */
623
+ function isReturnTypeRequiredByRecursion(node) {
624
+ if (!mergedOptions.allowRecursiveFunctions)
625
+ return false;
626
+ const fn = bodyOf(node);
627
+ if (!fn)
628
+ return false;
629
+ const selfReferences = resolveSelfReferences(node);
630
+ if (selfReferences.length === 0)
631
+ return false;
632
+ const returnExpressions = collectOwnReturnExpressions(fn, visitorKeys);
633
+ const referencesItself = returnExpressions.some((expression) => subtreeReferences(expression, selfReferences, visitorKeys));
634
+ if (referencesItself)
635
+ return true;
636
+ const identifierNames = selfReferences
637
+ .filter((reference) => reference.kind === 'identifier')
638
+ .map((reference) => reference.name);
639
+ if (identifierNames.length === 0)
640
+ return false;
641
+ if (!returnReferenceGraph) {
642
+ returnReferenceGraph = buildReturnReferenceGraph(sourceCode.ast, visitorKeys);
643
+ }
644
+ const graph = returnReferenceGraph;
645
+ return identifierNames.some((name) => participatesInReturnCycle(name, graph));
646
+ }
313
647
  if ((mergedOptions.allowDtsFiles && filename.endsWith('.d.ts')) ||
314
648
  (mergedOptions.allowFirestoreFunctionFiles &&
315
649
  filename.endsWith('.f.ts'))) {
@@ -333,7 +667,9 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
333
667
  return;
334
668
  if (isTypeGuardFunction(node) ||
335
669
  isReadonlyWideningReturnType(returnType) ||
336
- (mergedOptions.allowRecursiveFunctions && isRecursiveFunction(node))) {
670
+ (mergedOptions.allowRecursiveFunctions &&
671
+ isRecursiveFunction(node)) ||
672
+ isReturnTypeRequiredByRecursion(node)) {
337
673
  return;
338
674
  }
339
675
  const isInferable = Boolean(node.body);
@@ -357,7 +693,9 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
357
693
  }
358
694
  if (isTypeGuardFunction(node) ||
359
695
  isReadonlyWideningReturnType(returnType) ||
360
- (mergedOptions.allowRecursiveFunctions && isRecursiveFunction(node))) {
696
+ (mergedOptions.allowRecursiveFunctions &&
697
+ isRecursiveFunction(node)) ||
698
+ isReturnTypeRequiredByRecursion(node)) {
361
699
  return;
362
700
  }
363
701
  context.report({
@@ -372,7 +710,8 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
372
710
  if (!returnType)
373
711
  return;
374
712
  if (isTypeGuardFunction(node) ||
375
- isReadonlyWideningReturnType(returnType)) {
713
+ isReadonlyWideningReturnType(returnType) ||
714
+ isReturnTypeRequiredByRecursion(node)) {
376
715
  return;
377
716
  }
378
717
  context.report({
@@ -406,7 +745,8 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
406
745
  if (isTypeGuardFunction(node.value) ||
407
746
  isReadonlyWideningReturnType(returnType) ||
408
747
  (mergedOptions.allowAbstractMethodSignatures &&
409
- isInterfaceOrAbstractMethodSignature(node))) {
748
+ isInterfaceOrAbstractMethodSignature(node)) ||
749
+ isReturnTypeRequiredByRecursion(node)) {
410
750
  return;
411
751
  }
412
752
  const isInferable = Boolean(node.value.body);
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.noMockFirebaseAdmin = void 0;
7
7
  const path_1 = __importDefault(require("path"));
8
8
  const utils_1 = require("@typescript-eslint/utils");
9
+ const ASTHelpers_1 = require("../utils/ASTHelpers");
9
10
  const createRule_1 = require("../utils/createRule");
10
11
  const FIREBASE_ADMIN_MODULE = 'firebaseAdmin';
11
12
  const MODULE_EXTENSION = /\.(?:tsx?|jsx?|mjs|cjs)$/;
@@ -49,6 +50,72 @@ const bypassesSharedMock = (comparisonPath) => {
49
50
  // shared mock.
50
51
  return true;
51
52
  };
53
+ /**
54
+ * The one Firestore surface the shared `mockFirestore` fake cannot express: it
55
+ * seeds collections by path and exposes no `collectionGroup` whatsoever. A suite
56
+ * that must drive a collection-group query — e.g. the `__name__`-ordered,
57
+ * index-free `orderBy`/`limit`/`startAfter` pagination loop the migration
58
+ * scripts run — therefore has no way to obey the message's remedy, and
59
+ * reporting it demands deleting the assertions the suite exists to make.
60
+ *
61
+ * Cursor pagination alone is NOT part of this exemption: `orderBy`, `limit` and
62
+ * `startAfter` over an ordinary collection are expressible through the shared
63
+ * fake, so exempting on those would excuse nearly every hand-rolled factory.
64
+ */
65
+ const COLLECTION_GROUP = 'collectionGroup';
66
+ /**
67
+ * A string literal only counts where it names a member — an object key or a
68
+ * computed access — so prose that merely mentions the method (an error message,
69
+ * a comment-like string) cannot buy an exemption.
70
+ */
71
+ const namesCollectionGroupLiteral = (node) => {
72
+ if (node.type === utils_1.AST_NODE_TYPES.Property) {
73
+ return (node.key.type === utils_1.AST_NODE_TYPES.Literal &&
74
+ node.key.value === COLLECTION_GROUP);
75
+ }
76
+ if (node.type === utils_1.AST_NODE_TYPES.MemberExpression) {
77
+ return (node.computed &&
78
+ node.property.type === utils_1.AST_NODE_TYPES.Literal &&
79
+ node.property.value === COLLECTION_GROUP);
80
+ }
81
+ return false;
82
+ };
83
+ /**
84
+ * Walk the factory for any reference to `collectionGroup`, at any depth: agora's
85
+ * fake defines it on a returned `db` object, but an equivalent fake may call it
86
+ * from inside a nested helper or method body.
87
+ */
88
+ const exercisesCollectionGroup = (factory) => {
89
+ const stack = [factory];
90
+ while (stack.length > 0) {
91
+ const node = stack.pop();
92
+ if (node.type === utils_1.AST_NODE_TYPES.Identifier &&
93
+ node.name === COLLECTION_GROUP) {
94
+ return true;
95
+ }
96
+ if (namesCollectionGroupLiteral(node)) {
97
+ return true;
98
+ }
99
+ for (const [key, value] of Object.entries(node)) {
100
+ // `parent` points back up the tree; following it would walk the whole
101
+ // program and exempt any file that mentions collectionGroup anywhere.
102
+ if (key === 'parent') {
103
+ continue;
104
+ }
105
+ if (Array.isArray(value)) {
106
+ for (const item of value) {
107
+ if (ASTHelpers_1.ASTHelpers.isNode(item)) {
108
+ stack.push(item);
109
+ }
110
+ }
111
+ }
112
+ else if (ASTHelpers_1.ASTHelpers.isNode(value)) {
113
+ stack.push(value);
114
+ }
115
+ }
116
+ }
117
+ return false;
118
+ };
52
119
  exports.noMockFirebaseAdmin = (0, createRule_1.createRule)({
53
120
  name: 'no-mock-firebase-admin',
54
121
  meta: {
@@ -101,6 +168,10 @@ exports.noMockFirebaseAdmin = (0, createRule_1.createRule)({
101
168
  if (!bypassesSharedMock(comparisonPathOf(mockPath, filename))) {
102
169
  return;
103
170
  }
171
+ const factory = node.arguments[1];
172
+ if (factory && exercisesCollectionGroup(factory)) {
173
+ return;
174
+ }
104
175
  context.report({
105
176
  node,
106
177
  messageId: 'noMockFirebaseAdmin',
@@ -248,14 +248,50 @@ function declaredTypeNode(node) {
248
248
  function checksExcessProperties(typeNode) {
249
249
  return typeNode !== null && !UNCHECKED_ANNOTATION_TYPES.has(typeNode.type);
250
250
  }
251
+ function isFunctionNode(node) {
252
+ return (node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
253
+ node.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
254
+ node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression);
255
+ }
256
+ /**
257
+ * The function a `return` statement belongs to — the nearest function ancestor,
258
+ * which is what the language binds the return to. Scoping to the NEAREST one
259
+ * keeps a nested callback's literal from inheriting an outer function's
260
+ * annotation.
261
+ */
262
+ function enclosingFunction(node) {
263
+ let current = node.parent;
264
+ while (current) {
265
+ if (isFunctionNode(current)) {
266
+ return current;
267
+ }
268
+ current = current.parent;
269
+ }
270
+ return null;
271
+ }
272
+ /**
273
+ * Reports whether `fn` declares a return type that checks the shape of what it
274
+ * returns. Which type it names is irrelevant — the signal is that the author
275
+ * declared a contract at all, matching how an annotated variable is treated.
276
+ */
277
+ function declaresCheckedReturnType(fn) {
278
+ return (fn !== null && checksExcessProperties(fn.returnType?.typeAnnotation ?? null));
279
+ }
251
280
  /**
252
281
  * Reports whether `node` sits inside a value whose shape TypeScript checks
253
- * against a declared type — a type-annotated variable or class field, or a
254
- * `satisfies` clause. Excess-property checking makes such a literal unable to
255
- * carry a member the target type does not declare, so a member name there is
256
- * dictated by that type rather than chosen by the author, and renaming it would
257
- * break conformance (#1350). No member resolution is needed: the signal alone
258
- * is proof, because code carrying an undeclared member does not compile.
282
+ * against a declared type — a type-annotated variable or class field, a
283
+ * `satisfies` clause, or the return-type annotation of the function that
284
+ * returns it. Excess-property checking makes such a literal unable to carry a
285
+ * member the target type does not declare, so a member name there is dictated
286
+ * by that type rather than chosen by the author, and renaming it would break
287
+ * conformance (#1350). No member resolution is needed: the signal alone is
288
+ * proof, because code carrying an undeclared member does not compile.
289
+ *
290
+ * The return-type form is the only one a RECURSIVE factory can reach (#1511):
291
+ * `return {...} satisfies Q` inside a self-referencing factory does not compile
292
+ * at all (TS7023 — the return type becomes implicitly `any` because the
293
+ * function is referenced in its own return expression), so the annotation is
294
+ * that shape's sole way to declare the contract it imitates.
259
295
  *
260
296
  * The walk climbs object/array containers so an outer signal covers nested
261
297
  * members, and stops at anything else — notably `as` assertions, which do not
@@ -278,6 +314,14 @@ function hasConformanceSignal(node) {
278
314
  case utils_1.AST_NODE_TYPES.PropertyDefinition:
279
315
  return (parent.value === current &&
280
316
  checksExcessProperties(declaredTypeNode(parent)));
317
+ case utils_1.AST_NODE_TYPES.ReturnStatement:
318
+ // Only the returned value is covered; a literal elsewhere in the body
319
+ // of an annotated function is unrelated to its declared return type.
320
+ return (parent.argument === current &&
321
+ declaresCheckedReturnType(enclosingFunction(parent)));
322
+ case utils_1.AST_NODE_TYPES.ArrowFunctionExpression:
323
+ // A concise arrow body is the returned value.
324
+ return parent.body === current && declaresCheckedReturnType(parent);
281
325
  case utils_1.AST_NODE_TYPES.Property:
282
326
  case utils_1.AST_NODE_TYPES.ObjectExpression:
283
327
  case utils_1.AST_NODE_TYPES.ArrayExpression:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.45",
3
+ "version": "1.20.47",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,48 @@
1
1
  [
2
+ {
3
+ "version": "1.20.47",
4
+ "date": "2026-07-31T08:38:59.580Z",
5
+ "rules": [
6
+ {
7
+ "name": "no-explicit-return-type",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1512
11
+ ],
12
+ "summary": "stop reporting an annotation TypeScript requires (closes #1512)"
13
+ },
14
+ {
15
+ "name": "no-mock-firebase-admin",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1510
19
+ ],
20
+ "summary": "stop reporting factories the shared fake cannot replace (closes #1510)"
21
+ },
22
+ {
23
+ "name": "no-unnecessary-verb-suffix",
24
+ "changeType": "fix",
25
+ "issues": [
26
+ 1511
27
+ ],
28
+ "summary": "accept a function's own return type as a conformance signal (closes #1511)"
29
+ }
30
+ ]
31
+ },
32
+ {
33
+ "version": "1.20.46",
34
+ "date": "2026-07-31T07:08:55.415Z",
35
+ "rules": [
36
+ {
37
+ "name": "enforce-snapshot-state-narrowing",
38
+ "changeType": "fix",
39
+ "issues": [
40
+ 1505
41
+ ],
42
+ "summary": "make the guardFunctions option actually select the emitted guard name (closes #1505)"
43
+ }
44
+ ]
45
+ },
2
46
  {
3
47
  "version": "1.20.45",
4
48
  "date": "2026-07-31T06:37:56.216Z",