@blumintinc/eslint-plugin-blumint 1.21.2 → 1.21.4
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-boolean-naming-prefixes.js +28 -5
- package/lib/rules/enforce-centralized-mock-firestore.d.ts +2 -1
- package/lib/rules/enforce-centralized-mock-firestore.js +173 -38
- package/lib/rules/enforce-fieldpath-syntax-in-docsetter.js +78 -13
- package/lib/rules/enforce-firestore-set-merge.js +155 -4
- package/lib/rules/enforce-object-literal-as-const.js +85 -4
- package/lib/rules/enforce-positive-naming.js +17 -13
- package/lib/rules/enforce-snapshot-state-narrowing.d.ts +1 -1
- package/lib/rules/enforce-snapshot-state-narrowing.js +39 -19
- package/lib/rules/memo-nested-react-components.js +12 -0
- package/lib/rules/no-entire-object-hook-deps.js +26 -3
- package/lib/rules/no-hungarian.js +14 -0
- package/lib/rules/no-inline-component-prop.js +83 -14
- package/lib/rules/no-restricted-properties-fix.js +53 -3
- package/lib/rules/no-useless-usememo-primitives.js +43 -11
- package/lib/rules/prefer-destructuring-no-class.js +65 -11
- package/lib/rules/prefer-document-flattening.js +71 -35
- package/lib/rules/prefer-map-over-conditional-dispatch.js +112 -5
- package/lib/rules/require-memoize-jsx-returners.js +18 -4
- package/lib/rules/use-custom-memo.js +4 -6
- package/lib/utils/memoModule.d.ts +26 -0
- package/lib/utils/memoModule.js +54 -0
- package/package.json +1 -1
- package/release-manifest.json +148 -0
package/lib/index.js
CHANGED
|
@@ -44,6 +44,25 @@ const DEFAULT_OPTIONS = {
|
|
|
44
44
|
enforceForPropertySignatures: false,
|
|
45
45
|
};
|
|
46
46
|
const BOOLEANISH_BINARY_OPERATORS = new Set(['===', '!==', '==', '!=', '>', '<', '>=', '<=', 'in', 'instanceof']);
|
|
47
|
+
/**
|
|
48
|
+
* The operators that spell a DEFAULTED value: `left OP fallback` yields the
|
|
49
|
+
* left operand when it is present and the right operand only as a default. What
|
|
50
|
+
* makes such an initializer boolean is its operands, never which of the two
|
|
51
|
+
* joins them — `||` and `??` differ solely in which absent-ish left values hand
|
|
52
|
+
* over to the fallback.
|
|
53
|
+
*
|
|
54
|
+
* Both spellings must be read, because they are interconvertible under this
|
|
55
|
+
* plugin's own recommended config: `prefer-nullish-coalescing-boolean-props`
|
|
56
|
+
* rewrites `||` to `??` under `--fix`, so recognizing only `||` lets a sibling
|
|
57
|
+
* rule's fixer silence this one on every binding it touches.
|
|
58
|
+
*
|
|
59
|
+
* `&&` is deliberately excluded. Its right operand is the RESULT when the left
|
|
60
|
+
* is truthy rather than a default, so the fallback screen below — "a
|
|
61
|
+
* non-boolean literal on the right means the value is not a boolean" — would
|
|
62
|
+
* read the wrong operand. Conjunctions keep their own both-operand analysis,
|
|
63
|
+
* which already classifies `user && user.isActive` as boolean.
|
|
64
|
+
*/
|
|
65
|
+
const BOOLEAN_FALLBACK_OPERATORS = new Set(['||', '??']);
|
|
47
66
|
/**
|
|
48
67
|
* The name a class member key declares, for both spellings of privacy.
|
|
49
68
|
*
|
|
@@ -429,7 +448,9 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
|
|
|
429
448
|
BOOLEANISH_BINARY_OPERATORS.has(init.operator)) {
|
|
430
449
|
return true;
|
|
431
450
|
}
|
|
432
|
-
//
|
|
451
|
+
// Conjunctions classify from BOTH operands rather than through the
|
|
452
|
+
// fallback screen below, which is why `&&` is kept out of
|
|
453
|
+
// `BOOLEAN_FALLBACK_OPERATORS`.
|
|
433
454
|
if (init.type === utils_1.AST_NODE_TYPES.LogicalExpression &&
|
|
434
455
|
init.operator === '&&') {
|
|
435
456
|
const left = evaluateBooleanishExpression(init.left);
|
|
@@ -447,11 +468,12 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
|
|
|
447
468
|
}
|
|
448
469
|
return false;
|
|
449
470
|
}
|
|
450
|
-
// Special case for
|
|
471
|
+
// Special case for a defaulted value (`||`, `??`) - only consider it
|
|
472
|
+
// boolean if:
|
|
451
473
|
// 1. It's used with boolean literals or
|
|
452
474
|
// 2. It's not used with array/object literals as fallbacks
|
|
453
475
|
if (init.type === utils_1.AST_NODE_TYPES.LogicalExpression &&
|
|
454
|
-
init.operator
|
|
476
|
+
BOOLEAN_FALLBACK_OPERATORS.has(init.operator)) {
|
|
455
477
|
// Check if right side is a non-boolean literal (array, object, string, number)
|
|
456
478
|
const rightSide = init.right;
|
|
457
479
|
if (rightSide.type === utils_1.AST_NODE_TYPES.ArrayExpression ||
|
|
@@ -1051,9 +1073,10 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
|
|
|
1051
1073
|
return (!isGlobalBooleanCall(value) &&
|
|
1052
1074
|
calleeReturnEvaluation(value.callee.name) === 'indeterminate');
|
|
1053
1075
|
}
|
|
1054
|
-
// `isFoo(x) || fallback` reaches booleanness through its left operand
|
|
1076
|
+
// `isFoo(x) || fallback` reaches booleanness through its left operand,
|
|
1077
|
+
// and so does the `??` spelling of the same default.
|
|
1055
1078
|
if (value.type === utils_1.AST_NODE_TYPES.LogicalExpression &&
|
|
1056
|
-
value.operator
|
|
1079
|
+
BOOLEAN_FALLBACK_OPERATORS.has(value.operator)) {
|
|
1057
1080
|
return restsOnName(value.left);
|
|
1058
1081
|
}
|
|
1059
1082
|
return false;
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
import { TSESLint } from '@typescript-eslint/utils';
|
|
2
|
+
export declare const enforceCentralizedMockFirestore: TSESLint.RuleModule<"useCentralizedMockFirestore", [], TSESLint.RuleListener>;
|
|
@@ -14,6 +14,35 @@ const importRemoval_1 = require("../utils/importRemoval");
|
|
|
14
14
|
*/
|
|
15
15
|
const MOCK_FIRESTORE_MODULE = '__test-utils__/mockFirestore';
|
|
16
16
|
const MOCK_FIRESTORE_PATH = `../../../../../${MOCK_FIRESTORE_MODULE}`;
|
|
17
|
+
/**
|
|
18
|
+
* The name the centralized module actually exports, and therefore the exact
|
|
19
|
+
* text the fixer emits and the exact text every emitted-name comparison is
|
|
20
|
+
* made against. Detection normalizes (see {@link isMockFirestoreName});
|
|
21
|
+
* emission cannot — `import { MOCK_FIRESTORE } from …` would import a name the
|
|
22
|
+
* shared module does not export, and rewriting a reference to anything but
|
|
23
|
+
* this spelling leaves it unbound.
|
|
24
|
+
*/
|
|
25
|
+
const MOCK_FIRESTORE_EXPORT = 'mockFirestore';
|
|
26
|
+
/**
|
|
27
|
+
* The local mock's name reduced to the one form every spelling of it shares:
|
|
28
|
+
* separators dropped and case folded.
|
|
29
|
+
*
|
|
30
|
+
* A mock must not be able to hide behind its spelling. `global-const-style` —
|
|
31
|
+
* also `recommended: 'error'`, also fixable — renames module constants into
|
|
32
|
+
* SCREAMING_SNAKE_CASE, so the composed config walks a `mockFirestore` out of
|
|
33
|
+
* this rule's view by turning it into `MOCK_FIRESTORE`; a hand-written
|
|
34
|
+
* `MOCK_FIRESTORE` is equally invisible with no fixer involved (#2307).
|
|
35
|
+
*/
|
|
36
|
+
const MOCK_FIRESTORE_BINDING = 'mockfirestore';
|
|
37
|
+
/**
|
|
38
|
+
* Whether an identifier names the local mock, comparing whole identifiers
|
|
39
|
+
* rather than substrings. `mockFirestoreAdmin`, `MOCK_FIRESTORE_ADMIN` and
|
|
40
|
+
* `firestoreMock` name something else, and claiming them is another rule's
|
|
41
|
+
* business.
|
|
42
|
+
*/
|
|
43
|
+
function isMockFirestoreName(name) {
|
|
44
|
+
return name.replace(/[_-]/g, '').toLowerCase() === MOCK_FIRESTORE_BINDING;
|
|
45
|
+
}
|
|
17
46
|
const SOURCE_EXTENSION = /\.(?:ts|tsx|js|jsx)$/;
|
|
18
47
|
/**
|
|
19
48
|
* The module every other file is told to import from is the one module that
|
|
@@ -48,16 +77,23 @@ const isCentralizedMockModule = (filename) => {
|
|
|
48
77
|
* safest. `global-const-style` and `renameFixes` withhold their fixes on the
|
|
49
78
|
* same grounds.
|
|
50
79
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
80
|
+
* Both export forms can front a flagged node. `export default` takes an
|
|
81
|
+
* expression rather than a `const`, but it does take a FUNCTION declaration,
|
|
82
|
+
* and `export default function mockFirestore() {}` makes the module's default
|
|
83
|
+
* export the very thing being retired — the same cross-file contract break
|
|
84
|
+
* under a different name. A class property is on neither surface: it belongs
|
|
85
|
+
* to its class rather than to the module, so
|
|
54
86
|
* `export default class { mockFirestore = … }` exports nothing by that name.
|
|
55
87
|
*/
|
|
88
|
+
const EXPORT_FRONTS = new Set([
|
|
89
|
+
utils_1.AST_NODE_TYPES.ExportNamedDeclaration,
|
|
90
|
+
utils_1.AST_NODE_TYPES.ExportDefaultDeclaration,
|
|
91
|
+
]);
|
|
56
92
|
function isExportedDeclaration(node) {
|
|
57
93
|
const statement = node.parent?.type === utils_1.AST_NODE_TYPES.VariableDeclaration
|
|
58
94
|
? node.parent
|
|
59
95
|
: node;
|
|
60
|
-
return statement.parent
|
|
96
|
+
return (!!statement.parent && EXPORT_FRONTS.has(statement.parent.type));
|
|
61
97
|
}
|
|
62
98
|
function isHorizontalWhitespace(character) {
|
|
63
99
|
return character === ' ' || character === '\t';
|
|
@@ -112,34 +148,30 @@ const STATEMENT_CONTAINERS = new Set([
|
|
|
112
148
|
utils_1.AST_NODE_TYPES.TSModuleBlock,
|
|
113
149
|
]);
|
|
114
150
|
/**
|
|
115
|
-
*
|
|
116
|
-
*
|
|
151
|
+
* Whether the node stands as a statement of its own, and so may give up the
|
|
152
|
+
* whole line it occupies.
|
|
117
153
|
*
|
|
118
|
-
* An `export
|
|
119
|
-
* swallowing the keyword retires the name from the module's export
|
|
120
|
-
* such a declaration has no retirable statement at all and
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
154
|
+
* An `export`ed declaration is deliberately not widened to the `export` that
|
|
155
|
+
* fronts it: swallowing the keyword retires the name from the module's export
|
|
156
|
+
* surface, so such a declaration has no retirable statement at all and the
|
|
157
|
+
* export node types are absent from `STATEMENT_CONTAINERS` for that reason.
|
|
158
|
+
* `isExportedDeclaration` refuses the same shape ahead of this call and covers
|
|
159
|
+
* the multi-declarator form this branch never sees.
|
|
124
160
|
*/
|
|
125
|
-
function
|
|
126
|
-
return
|
|
127
|
-
STATEMENT_CONTAINERS.has(declaration.parent.type)
|
|
128
|
-
? declaration
|
|
129
|
-
: undefined;
|
|
161
|
+
function isRetirableStatement(node) {
|
|
162
|
+
return !!node.parent && STATEMENT_CONTAINERS.has(node.parent.type);
|
|
130
163
|
}
|
|
131
164
|
function retiredSpan(node) {
|
|
132
165
|
const parent = node.parent;
|
|
133
166
|
if (parent?.type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
|
|
134
167
|
const { declarations } = parent;
|
|
135
168
|
if (declarations.length === 1) {
|
|
136
|
-
|
|
137
|
-
if (!statement) {
|
|
169
|
+
if (!isRetirableStatement(parent)) {
|
|
138
170
|
return undefined;
|
|
139
171
|
}
|
|
140
172
|
return {
|
|
141
|
-
start:
|
|
142
|
-
end:
|
|
173
|
+
start: parent.range[0],
|
|
174
|
+
end: parent.range[1],
|
|
143
175
|
whole: true,
|
|
144
176
|
};
|
|
145
177
|
}
|
|
@@ -162,6 +194,15 @@ function retiredSpan(node) {
|
|
|
162
194
|
}
|
|
163
195
|
return undefined;
|
|
164
196
|
}
|
|
197
|
+
if (node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration) {
|
|
198
|
+
// A function declaration nested anywhere but a statement position — the
|
|
199
|
+
// body of an `if` under Annex B, say — cannot be excised without leaving
|
|
200
|
+
// the enclosing construct malformed, so it forfeits the fix like any other
|
|
201
|
+
// unretirable shape.
|
|
202
|
+
return isRetirableStatement(node)
|
|
203
|
+
? { start: node.range[0], end: node.range[1], whole: true }
|
|
204
|
+
: undefined;
|
|
205
|
+
}
|
|
165
206
|
if (node.type === utils_1.AST_NODE_TYPES.PropertyDefinition) {
|
|
166
207
|
return { start: node.range[0], end: node.range[1], whole: true };
|
|
167
208
|
}
|
|
@@ -195,9 +236,23 @@ const retirementCarriesOrphan = (variables, removed) => variables.every((variabl
|
|
|
195
236
|
: null;
|
|
196
237
|
/**
|
|
197
238
|
* Collapses edits that touch, so an overlap can never rewrite a range twice.
|
|
239
|
+
*
|
|
240
|
+
* Identical edits are dropped rather than merged: two visitors can reach the
|
|
241
|
+
* same reference — a renamed destructured binding is both a tracked call site
|
|
242
|
+
* and a retired binding's reference — and merging concatenates the texts, which
|
|
243
|
+
* would emit `mockFirestoremockFirestore` in place of the name.
|
|
198
244
|
*/
|
|
199
245
|
function mergeEdits(edits) {
|
|
200
|
-
const
|
|
246
|
+
const seen = new Set();
|
|
247
|
+
const unique = edits.filter((edit) => {
|
|
248
|
+
const key = `${edit.start}:${edit.end}:${edit.text}`;
|
|
249
|
+
if (seen.has(key)) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
seen.add(key);
|
|
253
|
+
return true;
|
|
254
|
+
});
|
|
255
|
+
const sorted = [...unique].sort((a, b) => a.start - b.start || a.end - b.end);
|
|
201
256
|
const merged = [];
|
|
202
257
|
for (const edit of sorted) {
|
|
203
258
|
const last = merged[merged.length - 1];
|
|
@@ -230,6 +285,29 @@ function importPlacement(sourceCode, anchor, edits) {
|
|
|
230
285
|
const enclosing = edits.find((edit) => edit.start < offset && offset < edit.end);
|
|
231
286
|
return enclosing ? { kind: 'index', index: enclosing.start } : placement;
|
|
232
287
|
}
|
|
288
|
+
/**
|
|
289
|
+
* Whether a binding spelled exactly `mockFirestore` outlives the retirement in
|
|
290
|
+
* the scope the injected import binds into.
|
|
291
|
+
*
|
|
292
|
+
* Normalized detection is what puts this within reach: a local spelled
|
|
293
|
+
* `MOCK_FIRESTORE` is retired while an unrelated `mockFirestore` — a function
|
|
294
|
+
* declaration, an import of the same name from somewhere else — is left
|
|
295
|
+
* standing, and the two meet only once the import lands, so the emitted file
|
|
296
|
+
* redeclares the name. The fix is withheld and the report stands, which still
|
|
297
|
+
* surfaces the local mock for a human to retire (#2307).
|
|
298
|
+
*/
|
|
299
|
+
function collidesWithSurvivingBinding(sourceCode, removed) {
|
|
300
|
+
const globalScope = sourceCode.scopeManager?.globalScope;
|
|
301
|
+
if (!globalScope) {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
const scopes = [
|
|
305
|
+
globalScope,
|
|
306
|
+
...globalScope.childScopes.filter((scope) => scope.type === 'module'),
|
|
307
|
+
];
|
|
308
|
+
return scopes.some((scope) => scope.variables.some((variable) => variable.name === MOCK_FIRESTORE_EXPORT &&
|
|
309
|
+
variable.defs.some((definition) => !isWithinAny(definition.name.range, removed))));
|
|
310
|
+
}
|
|
233
311
|
exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
|
|
234
312
|
name: 'enforce-centralized-mock-firestore',
|
|
235
313
|
meta: {
|
|
@@ -271,10 +349,17 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
|
|
|
271
349
|
hasCentralizedImport = true;
|
|
272
350
|
// Check for renamed imports
|
|
273
351
|
for (const specifier of node.specifiers) {
|
|
352
|
+
// Both comparisons stay EXACT. `imported` names the shared
|
|
353
|
+
// module's export surface, which spells the mock one way; a
|
|
354
|
+
// normalized match here would treat `import { MOCK_FIRESTORE }`
|
|
355
|
+
// as the shared mock and rewrite its call sites to a name the
|
|
356
|
+
// module never exported. `local` is compared against the text the
|
|
357
|
+
// fixer emits, and a binding differing only in case is still a
|
|
358
|
+
// different binding that the rewrite has to reach.
|
|
274
359
|
if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
275
360
|
specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
276
|
-
specifier.imported.name ===
|
|
277
|
-
specifier.local.name !==
|
|
361
|
+
specifier.imported.name === MOCK_FIRESTORE_EXPORT &&
|
|
362
|
+
specifier.local.name !== MOCK_FIRESTORE_EXPORT) {
|
|
278
363
|
customMockFirestoreNames.add(specifier.local.name);
|
|
279
364
|
}
|
|
280
365
|
}
|
|
@@ -282,18 +367,19 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
|
|
|
282
367
|
},
|
|
283
368
|
VariableDeclarator(node) {
|
|
284
369
|
if (node.id.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
285
|
-
node.id.name
|
|
370
|
+
isMockFirestoreName(node.id.name)) {
|
|
286
371
|
mockFirestoreNodes.add(node);
|
|
287
372
|
}
|
|
288
373
|
else if (node.id.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
|
|
289
374
|
for (const prop of node.id.properties) {
|
|
290
375
|
if (prop.type === utils_1.AST_NODE_TYPES.Property &&
|
|
291
376
|
prop.key.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
292
|
-
prop.key.name
|
|
377
|
+
isMockFirestoreName(prop.key.name)) {
|
|
293
378
|
mockFirestoreNodes.add(node);
|
|
294
|
-
// Track renamed destructured imports
|
|
379
|
+
// Track renamed destructured imports. The comparison is against
|
|
380
|
+
// the emitted name, so any other spelling needs rewriting.
|
|
295
381
|
if (prop.value.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
296
|
-
prop.value.name !==
|
|
382
|
+
prop.value.name !== MOCK_FIRESTORE_EXPORT) {
|
|
297
383
|
customMockFirestoreNames.add(prop.value.name);
|
|
298
384
|
}
|
|
299
385
|
break;
|
|
@@ -301,9 +387,21 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
|
|
|
301
387
|
}
|
|
302
388
|
}
|
|
303
389
|
},
|
|
390
|
+
// A local mock declared as a function is the same ad-hoc local mock as one
|
|
391
|
+
// declared as a `const` holding an arrow, which this rule already retires.
|
|
392
|
+
// Spelling and declaration FORM are two doors into the same hiding place:
|
|
393
|
+
// leaving this one open costs more than the missed report, because a file
|
|
394
|
+
// whose other local mock IS flagged loses its fix outright — the injected
|
|
395
|
+
// `import { mockFirestore }` would redeclare the surviving function, so
|
|
396
|
+
// `collidesWithSurvivingBinding` withholds the whole rewrite (#2307).
|
|
397
|
+
FunctionDeclaration(node) {
|
|
398
|
+
if (node.id && isMockFirestoreName(node.id.name)) {
|
|
399
|
+
mockFirestoreNodes.add(node);
|
|
400
|
+
}
|
|
401
|
+
},
|
|
304
402
|
PropertyDefinition(node) {
|
|
305
403
|
if (node.key.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
306
|
-
node.key.name
|
|
404
|
+
isMockFirestoreName(node.key.name)) {
|
|
307
405
|
mockFirestoreNodes.add(node);
|
|
308
406
|
}
|
|
309
407
|
},
|
|
@@ -325,11 +423,12 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
|
|
|
325
423
|
for (const prop of parent.id.properties) {
|
|
326
424
|
if (prop.type === utils_1.AST_NODE_TYPES.Property &&
|
|
327
425
|
prop.key.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
328
|
-
prop.key.name
|
|
426
|
+
isMockFirestoreName(prop.key.name)) {
|
|
329
427
|
mockFirestoreNodes.add(parent);
|
|
330
|
-
// Track renamed destructured imports
|
|
428
|
+
// Track renamed destructured imports. The comparison is
|
|
429
|
+
// against the emitted name, as above.
|
|
331
430
|
if (prop.value.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
332
|
-
prop.value.name !==
|
|
431
|
+
prop.value.name !== MOCK_FIRESTORE_EXPORT) {
|
|
333
432
|
customMockFirestoreNames.add(prop.value.name);
|
|
334
433
|
}
|
|
335
434
|
break;
|
|
@@ -338,8 +437,14 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
|
|
|
338
437
|
}
|
|
339
438
|
}
|
|
340
439
|
},
|
|
341
|
-
// Handle complex object destructuring
|
|
342
|
-
|
|
440
|
+
// Handle complex object destructuring. The name test lives in the body
|
|
441
|
+
// rather than in the selector because an esquery attribute match is
|
|
442
|
+
// literal, and this one has to normalize like every other detection site.
|
|
443
|
+
'ObjectPattern > Property > ObjectPattern > Property > ObjectPattern > Property'(node) {
|
|
444
|
+
if (node.key.type !== utils_1.AST_NODE_TYPES.Identifier ||
|
|
445
|
+
!isMockFirestoreName(node.key.name)) {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
343
448
|
let current = node;
|
|
344
449
|
while (current.parent) {
|
|
345
450
|
if (current.parent.type === utils_1.AST_NODE_TYPES.VariableDeclarator) {
|
|
@@ -353,13 +458,23 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
|
|
|
353
458
|
MemberExpression(node) {
|
|
354
459
|
if (node.object.type === utils_1.AST_NODE_TYPES.ThisExpression &&
|
|
355
460
|
node.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
356
|
-
node.property.name
|
|
461
|
+
isMockFirestoreName(node.property.name)) {
|
|
357
462
|
thisExpressions.push(node);
|
|
358
463
|
}
|
|
359
464
|
},
|
|
360
465
|
'Program:exit'() {
|
|
361
466
|
if (mockFirestoreNodes.size > 0) {
|
|
362
467
|
const sourceCode = context.getSourceCode();
|
|
468
|
+
// The bindings a flagged declaration introduces that are the local
|
|
469
|
+
// mock under a spelling other than the imported one. Filtering by
|
|
470
|
+
// name keeps a sibling of the same pattern — the `mocks` of
|
|
471
|
+
// `const { mocks: { firestore: { MOCK_FIRESTORE } } }` — out of the
|
|
472
|
+
// rewrite, since `getDeclaredVariables` answers for the whole
|
|
473
|
+
// declarator rather than for the matched property.
|
|
474
|
+
const renamedMockBindings = () => Array.from(mockFirestoreNodes).flatMap((node) => context
|
|
475
|
+
.getDeclaredVariables(node)
|
|
476
|
+
.filter((variable) => variable.name !== MOCK_FIRESTORE_EXPORT &&
|
|
477
|
+
isMockFirestoreName(variable.name)));
|
|
363
478
|
// Report only once for the entire file
|
|
364
479
|
context.report({
|
|
365
480
|
node: Array.from(mockFirestoreNodes)[0],
|
|
@@ -405,15 +520,35 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
|
|
|
405
520
|
return null;
|
|
406
521
|
}
|
|
407
522
|
const orphanRemovals = orphaned.map(([start, end]) => ({ start, end, text: '' }));
|
|
523
|
+
const deletedRanges = [...removals, ...orphanRemovals].map(({ start, end }) => [start, end]);
|
|
524
|
+
if (!hasCentralizedImport &&
|
|
525
|
+
collidesWithSurvivingBinding(sourceCode, deletedRanges)) {
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
408
528
|
// Replace custom mockFirestore references with the standard one
|
|
409
529
|
const replacements = [];
|
|
530
|
+
// Every reference to a retired binding whose spelling is not
|
|
531
|
+
// the imported one would be left unbound by the retirement, so
|
|
532
|
+
// the fix rewrites all of them — not merely the call sites the
|
|
533
|
+
// rename tracking above covers. `const MOCK_FIRESTORE = …` is
|
|
534
|
+
// replaced by `import { mockFirestore }`, and a surviving
|
|
535
|
+
// `MOCK_FIRESTORE` reference names nothing (#2307).
|
|
536
|
+
for (const variable of renamedMockBindings()) {
|
|
537
|
+
for (const reference of variable.references) {
|
|
538
|
+
replacements.push({
|
|
539
|
+
start: reference.identifier.range[0],
|
|
540
|
+
end: reference.identifier.range[1],
|
|
541
|
+
text: MOCK_FIRESTORE_EXPORT,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
}
|
|
410
545
|
// Add replacements for custom mockFirestore names
|
|
411
546
|
customMockFirestoreCallExpressions.forEach((node) => {
|
|
412
547
|
if (node.callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
413
548
|
replacements.push({
|
|
414
549
|
start: node.callee.range[0],
|
|
415
550
|
end: node.callee.range[1],
|
|
416
|
-
text:
|
|
551
|
+
text: MOCK_FIRESTORE_EXPORT,
|
|
417
552
|
});
|
|
418
553
|
}
|
|
419
554
|
});
|
|
@@ -422,7 +557,7 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
|
|
|
422
557
|
replacements.push({
|
|
423
558
|
start: expr.range[0],
|
|
424
559
|
end: expr.range[1],
|
|
425
|
-
text:
|
|
560
|
+
text: MOCK_FIRESTORE_EXPORT,
|
|
426
561
|
});
|
|
427
562
|
});
|
|
428
563
|
// A reference inside a deleted range goes away with it, so
|
|
@@ -448,7 +583,7 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
|
|
|
448
583
|
// and above a `#!` shebang it leaves the file unparseable.
|
|
449
584
|
const anchor = (0, importInsertion_1.importInsertionAnchor)(sourceCode);
|
|
450
585
|
const indent = (0, importInsertion_1.importAnchorIndent)(sourceCode, anchor);
|
|
451
|
-
fixes.push((0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, importPlacement(sourceCode, anchor, edits), `${indent}import {
|
|
586
|
+
fixes.push((0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, importPlacement(sourceCode, anchor, edits), `${indent}import { ${MOCK_FIRESTORE_EXPORT} } from '${MOCK_FIRESTORE_PATH}';\n`));
|
|
452
587
|
}
|
|
453
588
|
return fixes;
|
|
454
589
|
},
|
|
@@ -349,31 +349,96 @@ exports.enforceFieldPathSyntaxInDocSetter = (0, createRule_1.createRule)({
|
|
|
349
349
|
const separator = `\n${landingIndent}`;
|
|
350
350
|
return [...commentTexts, printedEntries.join(`,${separator}`)].join(separator);
|
|
351
351
|
}
|
|
352
|
+
// Keys the rewritten literal writes, counted. A flattening fix replaces the
|
|
353
|
+
// property whole, so a dropped plan contributes its own key back rather than
|
|
354
|
+
// its entries.
|
|
355
|
+
function countEmittedKeys(plans, dropped, keptKeys) {
|
|
356
|
+
const counts = new Map();
|
|
357
|
+
const count = (key) => counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
358
|
+
for (const key of keptKeys) {
|
|
359
|
+
count(key);
|
|
360
|
+
}
|
|
361
|
+
for (const plan of plans) {
|
|
362
|
+
if (dropped.has(plan)) {
|
|
363
|
+
count(plan.key);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
for (const entry of plan.entries) {
|
|
367
|
+
count(entry.key);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return counts;
|
|
371
|
+
}
|
|
372
|
+
// Flattening synthesizes a dot-path key that the enclosing literal may
|
|
373
|
+
// already write: `{ 'roles.contributor': a, roles: { contributor: b } }`
|
|
374
|
+
// flattens onto its own sibling. Emitting it spells the same key twice,
|
|
375
|
+
// which is TS1117, and the input carries no answer to which value should
|
|
376
|
+
// win, so the property is left for a human to resolve while the report
|
|
377
|
+
// stands (#2303). A spread between the two does not separate them, since it
|
|
378
|
+
// writes no statically known key of its own.
|
|
379
|
+
//
|
|
380
|
+
// The whole property is dropped rather than the colliding entry alone: the
|
|
381
|
+
// fix rewrites the property as one span, so emitting only its clean leaves
|
|
382
|
+
// would delete the colliding leaf's value outright — silent data loss in
|
|
383
|
+
// place of a duplicate key.
|
|
384
|
+
//
|
|
385
|
+
// Dropping a plan restores its own key to the literal, which can collide in
|
|
386
|
+
// turn with a key another plan flattens into, so the decision is taken to a
|
|
387
|
+
// fixpoint. Dropping only ever grows the set, which is what terminates it.
|
|
388
|
+
function dropCollidingPlans(plans, keptKeys) {
|
|
389
|
+
const dropped = new Set();
|
|
390
|
+
let settled = false;
|
|
391
|
+
while (!settled) {
|
|
392
|
+
settled = true;
|
|
393
|
+
const counts = countEmittedKeys(plans, dropped, keptKeys);
|
|
394
|
+
for (const plan of plans) {
|
|
395
|
+
if (dropped.has(plan)) {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (plan.entries.some((entry) => (counts.get(entry.key) ?? 0) > 1)) {
|
|
399
|
+
dropped.add(plan);
|
|
400
|
+
settled = false;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return dropped;
|
|
405
|
+
}
|
|
352
406
|
// Replace only the properties that actually need flattening, leaving every
|
|
353
407
|
// other property, its comments, and the original indentation untouched
|
|
354
408
|
function buildFieldPathFixes(node, sourceCode, fixer) {
|
|
355
|
-
const
|
|
409
|
+
const plans = [];
|
|
410
|
+
// Keys the literal keeps as written, which the synthesized keys must not
|
|
411
|
+
// land on
|
|
412
|
+
const keptKeys = [];
|
|
356
413
|
for (const property of node.properties) {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
property.kind !== 'init' ||
|
|
361
|
-
property.value.type !== utils_1.AST_NODE_TYPES.ObjectExpression) {
|
|
414
|
+
// A spread or a computed key writes nothing knowable statically, so it
|
|
415
|
+
// is neither flattened nor comparable against a synthesized key
|
|
416
|
+
if (property.type !== utils_1.AST_NODE_TYPES.Property || property.computed) {
|
|
362
417
|
continue;
|
|
363
418
|
}
|
|
364
419
|
const keyText = getPropertyKeyText(property);
|
|
420
|
+
if (keyText === undefined) {
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
365
423
|
// Root-level numeric keys model array-style buckets rather than
|
|
366
424
|
// Firestore document fields, so they are never flattened
|
|
367
|
-
|
|
368
|
-
|
|
425
|
+
const entries = !property.method &&
|
|
426
|
+
property.kind === 'init' &&
|
|
427
|
+
property.value.type === utils_1.AST_NODE_TYPES.ObjectExpression &&
|
|
428
|
+
!isNumericKey(property)
|
|
429
|
+
? collectFieldPathEntries(property.value, keyText, sourceCode)
|
|
430
|
+
: null;
|
|
431
|
+
if (entries) {
|
|
432
|
+
plans.push({ property, key: keyText, entries });
|
|
369
433
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
continue;
|
|
434
|
+
else {
|
|
435
|
+
keptKeys.push(keyText);
|
|
373
436
|
}
|
|
374
|
-
fixes.push(fixer.replaceTextRange(property.range, renderFlattenedProperty(property, entries, sourceCode)));
|
|
375
437
|
}
|
|
376
|
-
|
|
438
|
+
const dropped = dropCollidingPlans(plans, keptKeys);
|
|
439
|
+
return plans
|
|
440
|
+
.filter((plan) => !dropped.has(plan))
|
|
441
|
+
.map((plan) => fixer.replaceTextRange(plan.property.range, renderFlattenedProperty(plan.property, plan.entries, sourceCode)));
|
|
377
442
|
}
|
|
378
443
|
function getPropertyKeyText(property) {
|
|
379
444
|
if (property.key.type === utils_1.AST_NODE_TYPES.Identifier) {
|