@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 +1 -1
- package/lib/rules/enforce-snapshot-state-narrowing.js +31 -22
- package/lib/rules/no-explicit-return-type.js +344 -4
- package/lib/rules/no-mock-firebase-admin.js +71 -0
- package/lib/rules/no-unnecessary-verb-suffix.js +50 -6
- package/package.json +1 -1
- package/release-manifest.json +44 -0
package/lib/index.js
CHANGED
|
@@ -61,7 +61,7 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
|
|
|
61
61
|
guardFunctions: {
|
|
62
62
|
type: 'array',
|
|
63
63
|
items: { type: 'string' },
|
|
64
|
-
description: '
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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,
|
|
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, `, ${
|
|
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 { ${
|
|
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
|
|
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, `${
|
|
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}`, `!${
|
|
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}`, `${
|
|
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, `${
|
|
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, `${
|
|
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, `${
|
|
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 = `${
|
|
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})`, `${
|
|
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 &&
|
|
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 &&
|
|
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,
|
|
254
|
-
* `satisfies` clause
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
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
package/release-manifest.json
CHANGED
|
@@ -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",
|