@blumintinc/eslint-plugin-blumint 1.20.32 → 1.20.34
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-fieldpath-syntax-in-docsetter.js +93 -26
- package/lib/rules/enforce-firestore-set-merge.d.ts +2 -1
- package/lib/rules/enforce-firestore-set-merge.js +237 -60
- package/lib/rules/enforce-global-constants.js +84 -17
- package/lib/rules/enforce-microdiff.js +162 -67
- package/lib/rules/fast-deep-equal-over-microdiff.js +154 -83
- package/lib/rules/flatten-push-calls.js +163 -39
- package/lib/rules/no-unused-usestate.d.ts +2 -2
- package/lib/rules/no-unused-usestate.js +32 -1
- package/lib/rules/prefer-global-router-state-key.js +54 -9
- package/lib/rules/prefer-spread-over-reassembly.js +178 -28
- package/lib/rules/prefer-usecallback-over-usememo-for-functions.js +143 -35
- package/lib/rules/use-custom-memo.js +131 -1
- package/lib/rules/use-latest-callback.js +94 -17
- package/package.json +1 -1
- package/release-manifest.json +112 -0
package/lib/index.js
CHANGED
|
@@ -119,31 +119,99 @@ exports.enforceFieldPathSyntaxInDocSetter = (0, createRule_1.createRule)({
|
|
|
119
119
|
function needsQuoting(key) {
|
|
120
120
|
return key.includes('.') || !/^(?:[$_A-Za-z][$\w]*)$/u.test(key);
|
|
121
121
|
}
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
122
|
+
// Collect the FieldPath entries a nested property flattens into, or bail out
|
|
123
|
+
// when flattening would silently drop payload data (spreads, computed keys,
|
|
124
|
+
// accessors/methods, unsupported key literals) or would produce nothing at
|
|
125
|
+
// all. Bailing leaves the report in place so the developer flattens by hand
|
|
126
|
+
// instead of receiving a fix that deletes fields or emits invalid syntax.
|
|
127
|
+
function collectFieldPathEntries(obj, prefix, sourceCode) {
|
|
128
|
+
const entries = [];
|
|
129
|
+
for (const property of obj.properties) {
|
|
130
|
+
if (property.type !== utils_1.AST_NODE_TYPES.Property ||
|
|
131
|
+
property.computed ||
|
|
132
|
+
property.method ||
|
|
133
|
+
property.kind !== 'init') {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
const keyText = getPropertyKeyText(property);
|
|
137
|
+
if (keyText === undefined) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const fullKey = `${prefix}.${keyText}`;
|
|
141
|
+
if (property.value.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
|
|
142
|
+
const nestedEntries = collectFieldPathEntries(property.value, fullKey, sourceCode);
|
|
143
|
+
if (!nestedEntries) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
entries.push(...nestedEntries);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
entries.push([fullKey, sourceCode.getText(property.value)]);
|
|
150
|
+
}
|
|
151
|
+
return entries.length > 0 ? entries : null;
|
|
152
|
+
}
|
|
153
|
+
function getLineIndent(line) {
|
|
154
|
+
return /^[\t ]*/u.exec(line)?.[0] ?? '';
|
|
155
|
+
}
|
|
156
|
+
// Indentation of the property when it is the first thing on its line, which
|
|
157
|
+
// is the indentation the replacement text must reuse to keep the surrounding
|
|
158
|
+
// lines byte-identical. Returns null for properties sharing a line with
|
|
159
|
+
// other code, where the replacement stays inline.
|
|
160
|
+
function getOwnLineIndent(property, sourceCode) {
|
|
161
|
+
const line = sourceCode.lines[property.loc.start.line - 1] ?? '';
|
|
162
|
+
const linePrefix = line.slice(0, property.loc.start.column);
|
|
163
|
+
return /^[\t ]*$/u.test(linePrefix) ? linePrefix : null;
|
|
164
|
+
}
|
|
165
|
+
function printComment(comment) {
|
|
166
|
+
return comment.type === utils_1.AST_TOKEN_TYPES.Line
|
|
167
|
+
? `//${comment.value}`
|
|
168
|
+
: `/*${comment.value}*/`;
|
|
169
|
+
}
|
|
170
|
+
// Render the dot-path replacement for a single nested property. Comments
|
|
171
|
+
// living inside the property are re-emitted ahead of the flattened entries
|
|
172
|
+
// so directives such as eslint-disable-next-line keep covering the rewritten
|
|
173
|
+
// code rather than being destroyed by the fix.
|
|
174
|
+
function renderFlattenedProperty(property, entries, sourceCode) {
|
|
175
|
+
const comments = sourceCode.getCommentsInside(property);
|
|
176
|
+
const commentTexts = comments.map(printComment);
|
|
177
|
+
const printedEntries = entries.map(([key, value]) => `${needsQuoting(key) ? `'${key}'` : key}: ${value}`);
|
|
178
|
+
const ownLineIndent = getOwnLineIndent(property, sourceCode);
|
|
179
|
+
// A carried line comment would swallow the rest of the line, so anything
|
|
180
|
+
// holding one has to be laid out across multiple lines
|
|
181
|
+
const carriesLineComment = comments.some((comment) => comment.type === utils_1.AST_TOKEN_TYPES.Line);
|
|
182
|
+
if (ownLineIndent === null && !carriesLineComment) {
|
|
183
|
+
return [...commentTexts, printedEntries.join(', ')].join(' ');
|
|
132
184
|
}
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
185
|
+
const indent = ownLineIndent ??
|
|
186
|
+
`${getLineIndent(sourceCode.lines[property.loc.start.line - 1] ?? '')} `;
|
|
187
|
+
const separator = `\n${indent}`;
|
|
188
|
+
return [...commentTexts, printedEntries.join(`,${separator}`)].join(separator);
|
|
189
|
+
}
|
|
190
|
+
// Replace only the properties that actually need flattening, leaving every
|
|
191
|
+
// other property, its comments, and the original indentation untouched
|
|
192
|
+
function buildFieldPathFixes(node, sourceCode, fixer) {
|
|
193
|
+
const fixes = [];
|
|
194
|
+
for (const property of node.properties) {
|
|
195
|
+
if (property.type !== utils_1.AST_NODE_TYPES.Property ||
|
|
196
|
+
property.computed ||
|
|
197
|
+
property.method ||
|
|
198
|
+
property.kind !== 'init' ||
|
|
199
|
+
property.value.type !== utils_1.AST_NODE_TYPES.ObjectExpression) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const keyText = getPropertyKeyText(property);
|
|
203
|
+
// Root-level numeric keys model array-style buckets rather than
|
|
204
|
+
// Firestore document fields, so they are never flattened
|
|
205
|
+
if (keyText === undefined || isNumericKey(property)) {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const entries = collectFieldPathEntries(property.value, keyText, sourceCode);
|
|
209
|
+
if (!entries) {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
fixes.push(fixer.replaceTextRange(property.range, renderFlattenedProperty(property, entries, sourceCode)));
|
|
139
213
|
}
|
|
140
|
-
|
|
141
|
-
entries.forEach(([key, value]) => {
|
|
142
|
-
const printedKey = needsQuoting(key) ? `'${key}'` : key;
|
|
143
|
-
result += ` ${printedKey}: ${value}${propertyComma}\n`;
|
|
144
|
-
});
|
|
145
|
-
result += '}';
|
|
146
|
-
return result;
|
|
214
|
+
return fixes;
|
|
147
215
|
}
|
|
148
216
|
function getPropertyKeyText(property) {
|
|
149
217
|
if (property.key.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
@@ -208,7 +276,6 @@ exports.enforceFieldPathSyntaxInDocSetter = (0, createRule_1.createRule)({
|
|
|
208
276
|
return {
|
|
209
277
|
topLevelKey: propertyKeyText ?? 'nested field',
|
|
210
278
|
exampleFieldPath,
|
|
211
|
-
flattenedProperties,
|
|
212
279
|
};
|
|
213
280
|
}
|
|
214
281
|
return {
|
|
@@ -251,8 +318,8 @@ exports.enforceFieldPathSyntaxInDocSetter = (0, createRule_1.createRule)({
|
|
|
251
318
|
exampleFieldPath: violationDetails.exampleFieldPath,
|
|
252
319
|
},
|
|
253
320
|
fix(fixer) {
|
|
254
|
-
const
|
|
255
|
-
return
|
|
321
|
+
const fixes = buildFieldPathFixes(firstArg, sourceCode, fixer);
|
|
322
|
+
return fixes.length > 0 ? fixes : null;
|
|
256
323
|
},
|
|
257
324
|
});
|
|
258
325
|
},
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
import { TSESLint } from '@typescript-eslint/utils';
|
|
2
|
+
export declare const enforceFirestoreSetMerge: TSESLint.RuleModule<"preferSetMerge", [], TSESLint.RuleListener>;
|
|
@@ -4,6 +4,69 @@ exports.enforceFirestoreSetMerge = void 0;
|
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
6
|
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
7
|
+
const disableDirectives_1 = require("../utils/disableDirectives");
|
|
8
|
+
const FIRESTORE_MODULES = new Set(['firebase/firestore', 'firebase-admin']);
|
|
9
|
+
const UPDATE_DOC = 'updateDoc';
|
|
10
|
+
const SET_DOC = 'setDoc';
|
|
11
|
+
const MERGE_ARGUMENT = ', { merge: true }';
|
|
12
|
+
function isFirestoreDynamicImport(node) {
|
|
13
|
+
if (node?.type !== utils_1.AST_NODE_TYPES.AwaitExpression) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const imported = node.argument;
|
|
17
|
+
return (imported.type === utils_1.AST_NODE_TYPES.ImportExpression &&
|
|
18
|
+
imported.source.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
19
|
+
typeof imported.source.value === 'string' &&
|
|
20
|
+
FIRESTORE_MODULES.has(imported.source.value));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Reads a binding's origin off the AST rather than off a traversal flag, so the
|
|
24
|
+
* verdict is re-derived on every pass of a multi-pass `--fix` — including the
|
|
25
|
+
* passes that run after a previous pass inserted the `setDoc` binding.
|
|
26
|
+
*/
|
|
27
|
+
function firestoreBindingOf(def) {
|
|
28
|
+
const { node } = def;
|
|
29
|
+
if (node.type === utils_1.AST_NODE_TYPES.ImportSpecifier) {
|
|
30
|
+
const declaration = node.parent;
|
|
31
|
+
if (declaration?.type !== utils_1.AST_NODE_TYPES.ImportDeclaration ||
|
|
32
|
+
!FIRESTORE_MODULES.has(declaration.source.value) ||
|
|
33
|
+
declaration.importKind === 'type' ||
|
|
34
|
+
node.importKind === 'type') {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
imported: node.imported.name,
|
|
39
|
+
node,
|
|
40
|
+
entries: declaration.specifiers,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (node.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
|
|
44
|
+
node.id.type === utils_1.AST_NODE_TYPES.ObjectPattern &&
|
|
45
|
+
isFirestoreDynamicImport(node.init)) {
|
|
46
|
+
const property = def.name.parent;
|
|
47
|
+
if (property?.type !== utils_1.AST_NODE_TYPES.Property ||
|
|
48
|
+
property.parent !== node.id ||
|
|
49
|
+
property.value !== def.name ||
|
|
50
|
+
property.computed ||
|
|
51
|
+
property.key.type !== utils_1.AST_NODE_TYPES.Identifier) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
imported: property.key.name,
|
|
56
|
+
node: property,
|
|
57
|
+
entries: node.id.properties,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
function isComma(token) {
|
|
63
|
+
return token?.type === utils_1.AST_TOKEN_TYPES.Punctuator && token.value === ',';
|
|
64
|
+
}
|
|
65
|
+
/** Whether every declaration of a visible binding is the given firestore export. */
|
|
66
|
+
function bindsFirestoreExport(variable, imported) {
|
|
67
|
+
return (variable.defs.length > 0 &&
|
|
68
|
+
variable.defs.every((def) => firestoreBindingOf(def)?.imported === imported));
|
|
69
|
+
}
|
|
7
70
|
exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
|
|
8
71
|
name: 'enforce-firestore-set-merge',
|
|
9
72
|
meta: {
|
|
@@ -22,7 +85,18 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
|
|
|
22
85
|
},
|
|
23
86
|
defaultOptions: [],
|
|
24
87
|
create(context) {
|
|
88
|
+
const sourceCode = context.sourceCode;
|
|
25
89
|
const updateAliases = new Set();
|
|
90
|
+
/**
|
|
91
|
+
* The `setDoc` binding rides on one violation's fix, which makes that
|
|
92
|
+
* violation the file's import carrier. ESLint calls `fix()` before it
|
|
93
|
+
* applies inline disable directives, so a suppressed carrier takes the
|
|
94
|
+
* binding down with it while the surviving violations still emit
|
|
95
|
+
* `setDoc(…)`. Resolving suppression up front passes the carrier slot to the
|
|
96
|
+
* first violation that actually survives.
|
|
97
|
+
*/
|
|
98
|
+
const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
|
|
99
|
+
let plannedSetDocBinding = false;
|
|
26
100
|
function isFirestoreUpdateCall(node) {
|
|
27
101
|
// Check if it's a set() call with merge: true
|
|
28
102
|
if (node.callee.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
@@ -118,7 +192,7 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
|
|
|
118
192
|
}
|
|
119
193
|
if (node.callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
120
194
|
// Check if it's a setDoc() call with merge: true
|
|
121
|
-
if (node.callee.name ===
|
|
195
|
+
if (node.callee.name === SET_DOC) {
|
|
122
196
|
const lastArg = node.arguments[node.arguments.length - 1];
|
|
123
197
|
if (lastArg?.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
|
|
124
198
|
const hasMergeTrue = lastArg.properties.some((prop) => prop.type === utils_1.AST_NODE_TYPES.Property &&
|
|
@@ -135,87 +209,190 @@ exports.enforceFirestoreSetMerge = (0, createRule_1.createRule)({
|
|
|
135
209
|
}
|
|
136
210
|
return false;
|
|
137
211
|
}
|
|
138
|
-
|
|
212
|
+
/**
|
|
213
|
+
* A spread hides how many arguments the call really passes, so the options
|
|
214
|
+
* object cannot be positioned.
|
|
215
|
+
*/
|
|
216
|
+
function hasSpreadArgument(node) {
|
|
217
|
+
return node.arguments.some((argument) => argument.type === utils_1.AST_NODE_TYPES.SpreadElement);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* `ref.update(…)` becomes `ref.set(…, { merge: true })` by editing only the
|
|
221
|
+
* method name and the tail of the argument list. Re-emitting the call from
|
|
222
|
+
* the text of each argument dropped everything between them — comments
|
|
223
|
+
* included, and a dropped `eslint-disable` silently re-enables the rule it
|
|
224
|
+
* was suppressing — and dropped every argument past the second outright.
|
|
225
|
+
*/
|
|
226
|
+
function fixUpdateMethod(fixer, node, callee) {
|
|
227
|
+
if (callee.computed ||
|
|
228
|
+
callee.property.type !== utils_1.AST_NODE_TYPES.Identifier) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
139
231
|
const args = node.arguments;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
232
|
+
const lastArgument = args[args.length - 1];
|
|
233
|
+
if (!lastArgument || hasSpreadArgument(node)) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
const objectText = sourceCode.getText(callee.object);
|
|
237
|
+
// BatchManager takes a single descriptor object, so its arguments are
|
|
238
|
+
// genuinely restructured rather than extended.
|
|
239
|
+
if (objectText.includes('batchManager')) {
|
|
240
|
+
if (args.length < 2) {
|
|
241
|
+
return null;
|
|
148
242
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
ref: ${docRef},
|
|
154
|
-
data: ${data},
|
|
243
|
+
return [
|
|
244
|
+
fixer.replaceText(node, `${objectText}.set({
|
|
245
|
+
ref: ${sourceCode.getText(args[0])},
|
|
246
|
+
data: ${sourceCode.getText(args[1])},
|
|
155
247
|
merge: true,
|
|
156
|
-
})
|
|
248
|
+
})`),
|
|
249
|
+
];
|
|
250
|
+
}
|
|
251
|
+
return [
|
|
252
|
+
fixer.replaceText(callee.property, 'set'),
|
|
253
|
+
fixer.insertTextAfter(lastArgument, MERGE_ARGUMENT),
|
|
254
|
+
];
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Drops a binding whose last reference this fix rewrites, so `--fix` does not
|
|
258
|
+
* leave an unused import behind. Only the entry and the comma separating it
|
|
259
|
+
* from a sibling go, and only when nothing else lives in that span: a comment
|
|
260
|
+
* between the entry and its comma belongs to a neighbour as often as to the
|
|
261
|
+
* entry, and an unused specifier is inert where a deleted comment is not.
|
|
262
|
+
* A list that would end up empty is left alone too, since emptying it means
|
|
263
|
+
* rewriting the whole declaration.
|
|
264
|
+
*/
|
|
265
|
+
function removeBinding(fixer, binding) {
|
|
266
|
+
if (binding.entries.length < 2) {
|
|
267
|
+
return [];
|
|
268
|
+
}
|
|
269
|
+
const before = sourceCode.getTokenBefore(binding.node, {
|
|
270
|
+
includeComments: true,
|
|
271
|
+
});
|
|
272
|
+
if (isComma(before)) {
|
|
273
|
+
return [fixer.removeRange([before.range[0], binding.node.range[1]])];
|
|
274
|
+
}
|
|
275
|
+
const after = sourceCode.getTokenAfter(binding.node, {
|
|
276
|
+
includeComments: true,
|
|
277
|
+
});
|
|
278
|
+
if (!isComma(after)) {
|
|
279
|
+
return [];
|
|
280
|
+
}
|
|
281
|
+
// Stopping at whatever follows the comma — comment or token — keeps a
|
|
282
|
+
// directive that documents the next entry attached to it.
|
|
283
|
+
const next = sourceCode.getTokenAfter(after, { includeComments: true });
|
|
284
|
+
return [
|
|
285
|
+
fixer.removeRange([
|
|
286
|
+
binding.node.range[0],
|
|
287
|
+
next ? next.range[0] : after.range[1],
|
|
288
|
+
]),
|
|
289
|
+
];
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* `updateDoc(ref, data)` becomes `setDoc(ref, data, { merge: true })`, which
|
|
293
|
+
* only works if `setDoc` is bound. The import edit and the call rewrite ship
|
|
294
|
+
* as one fix array: they sit in disjoint ranges, and a multi-rule `--fix`
|
|
295
|
+
* that applied one without the other would leave the file with an unbound
|
|
296
|
+
* name.
|
|
297
|
+
*/
|
|
298
|
+
function fixUpdateDocCall(fixer, node, callee) {
|
|
299
|
+
if (isReportSuppressed(node)) {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
const lastArgument = node.arguments[node.arguments.length - 1];
|
|
303
|
+
if (!lastArgument || hasSpreadArgument(node)) {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
const scope = ASTHelpers_1.ASTHelpers.getScope(context, node);
|
|
307
|
+
const updateVariable = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, callee.name);
|
|
308
|
+
if (!updateVariable || updateVariable.defs.length !== 1) {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
const updateBinding = firestoreBindingOf(updateVariable.defs[0]);
|
|
312
|
+
if (!updateBinding || updateBinding.imported !== UPDATE_DOC) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
// A `setDoc` that means something else makes both halves of the edit
|
|
316
|
+
// wrong: an added import collides with the declaration (TS2440/TS2300),
|
|
317
|
+
// and a narrower-scope shadow rebinds the emitted call to the local value
|
|
318
|
+
// with no diagnostic at all. Resolving from the call's own scope chain
|
|
319
|
+
// catches both, and declining before the binding is scheduled leaves the
|
|
320
|
+
// carrier slot to a violation whose scope is safe.
|
|
321
|
+
const setDocVariable = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, SET_DOC);
|
|
322
|
+
if (setDocVariable && !bindsFirestoreExport(setDocVariable, SET_DOC)) {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
// Rewriting the last reference to `updateDoc` frees its binding site, so
|
|
326
|
+
// the entry is renamed in place — and an alias disappears together with
|
|
327
|
+
// the reference that used it. Any other reference keeps the old name
|
|
328
|
+
// alive: adding `setDoc` alongside it is then the only safe edit, because
|
|
329
|
+
// a multi-rule `--fix` can drop a sibling violation's fix and strand that
|
|
330
|
+
// reference on a binding this one just removed.
|
|
331
|
+
const reads = updateVariable.references.filter((reference) => reference.isRead());
|
|
332
|
+
const isSoleReference = reads.length === 1 && reads[0].identifier === callee;
|
|
333
|
+
const fixes = [];
|
|
334
|
+
if (!setDocVariable) {
|
|
335
|
+
if (!plannedSetDocBinding) {
|
|
336
|
+
fixes.push(isSoleReference
|
|
337
|
+
? fixer.replaceText(updateBinding.node, SET_DOC)
|
|
338
|
+
: fixer.insertTextAfter(updateBinding.node, `, ${SET_DOC}`));
|
|
339
|
+
plannedSetDocBinding = true;
|
|
157
340
|
}
|
|
158
|
-
const data = sourceCode.getText(args[0]);
|
|
159
|
-
return `${object}.set(${data}, { merge: true })`;
|
|
160
341
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
342
|
+
else if (isSoleReference) {
|
|
343
|
+
fixes.push(...removeBinding(fixer, updateBinding));
|
|
344
|
+
}
|
|
345
|
+
fixes.push(fixer.replaceText(callee, SET_DOC));
|
|
346
|
+
// `setDoc` takes the document data between the reference and the options,
|
|
347
|
+
// so a call that passed no data gets an empty object to merge.
|
|
348
|
+
fixes.push(fixer.insertTextAfter(lastArgument, node.arguments.length > 1 ? MERGE_ARGUMENT : `, {}${MERGE_ARGUMENT}`));
|
|
349
|
+
return fixes;
|
|
165
350
|
}
|
|
166
351
|
return {
|
|
167
352
|
ImportDeclaration(node) {
|
|
168
|
-
if (node.source.value
|
|
169
|
-
node.source.value === 'firebase-admin') {
|
|
353
|
+
if (FIRESTORE_MODULES.has(node.source.value)) {
|
|
170
354
|
node.specifiers.forEach((specifier) => {
|
|
171
355
|
if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier) {
|
|
172
|
-
if (specifier.imported.name ===
|
|
356
|
+
if (specifier.imported.name === UPDATE_DOC) {
|
|
173
357
|
updateAliases.add(specifier.local.name);
|
|
174
358
|
}
|
|
175
359
|
}
|
|
176
360
|
});
|
|
177
361
|
}
|
|
178
362
|
},
|
|
179
|
-
ImportExpression(node) {
|
|
180
|
-
if (node.source.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
181
|
-
(node.source.value === 'firebase/firestore' ||
|
|
182
|
-
node.source.value === 'firebase-admin')) {
|
|
183
|
-
// Dynamic imports are handled in VariableDeclarator
|
|
184
|
-
}
|
|
185
|
-
},
|
|
186
363
|
VariableDeclarator(node) {
|
|
187
|
-
if (node.init
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
prop.key.name === 'updateDoc') {
|
|
199
|
-
if (prop.value.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
200
|
-
updateAliases.add(prop.value.name);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
});
|
|
364
|
+
if (!isFirestoreDynamicImport(node.init)) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
// Handle destructured imports
|
|
368
|
+
if (node.id.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
|
|
369
|
+
node.id.properties.forEach((prop) => {
|
|
370
|
+
if (prop.type === utils_1.AST_NODE_TYPES.Property &&
|
|
371
|
+
prop.key.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
372
|
+
prop.key.name === UPDATE_DOC &&
|
|
373
|
+
prop.value.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
374
|
+
updateAliases.add(prop.value.name);
|
|
204
375
|
}
|
|
205
|
-
}
|
|
376
|
+
});
|
|
206
377
|
}
|
|
207
378
|
},
|
|
208
379
|
CallExpression(node) {
|
|
209
|
-
if (isFirestoreUpdateCall(node)) {
|
|
210
|
-
|
|
211
|
-
node,
|
|
212
|
-
messageId: 'preferSetMerge',
|
|
213
|
-
fix(fixer) {
|
|
214
|
-
const newText = convertUpdateToSetMerge(node, context.sourceCode);
|
|
215
|
-
return fixer.replaceText(node, newText);
|
|
216
|
-
},
|
|
217
|
-
});
|
|
380
|
+
if (!isFirestoreUpdateCall(node)) {
|
|
381
|
+
return;
|
|
218
382
|
}
|
|
383
|
+
context.report({
|
|
384
|
+
node,
|
|
385
|
+
messageId: 'preferSetMerge',
|
|
386
|
+
fix(fixer) {
|
|
387
|
+
if (node.callee.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
388
|
+
return fixUpdateMethod(fixer, node, node.callee);
|
|
389
|
+
}
|
|
390
|
+
if (node.callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
391
|
+
return fixUpdateDocCall(fixer, node, node.callee);
|
|
392
|
+
}
|
|
393
|
+
return null;
|
|
394
|
+
},
|
|
395
|
+
});
|
|
219
396
|
},
|
|
220
397
|
};
|
|
221
398
|
},
|
|
@@ -118,25 +118,63 @@ exports.enforceGlobalConstants = (0, createRule_1.createRule)({
|
|
|
118
118
|
function hasIdentifiers(node) {
|
|
119
119
|
return !!node && ASTHelpers_1.ASTHelpers.declarationIncludesIdentifier(node);
|
|
120
120
|
}
|
|
121
|
-
function
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
121
|
+
function classifyModuleBinding(variable) {
|
|
122
|
+
if (variable.defs.length !== 1) {
|
|
123
|
+
return { kind: 'blocked' };
|
|
124
|
+
}
|
|
125
|
+
const declarator = variable.defs[0].node;
|
|
126
|
+
if (declarator.type !== utils_1.AST_NODE_TYPES.VariableDeclarator) {
|
|
127
|
+
return { kind: 'blocked' };
|
|
128
|
+
}
|
|
129
|
+
const declaration = declarator.parent;
|
|
130
|
+
if (!declaration ||
|
|
131
|
+
declaration.type !== utils_1.AST_NODE_TYPES.VariableDeclaration ||
|
|
132
|
+
declaration.kind !== 'const' ||
|
|
133
|
+
declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier ||
|
|
134
|
+
!declarator.init) {
|
|
135
|
+
return { kind: 'blocked' };
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
kind: 'reusable',
|
|
139
|
+
initText: sourceCode.getText(declarator.init),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* `SourceCode#getScope` supersedes the deprecated `context.getScope`; the
|
|
144
|
+
* fallback keeps the rule working on ESLint versions that predate it.
|
|
145
|
+
*/
|
|
146
|
+
function scopeOf(node) {
|
|
147
|
+
const scoped = sourceCode;
|
|
148
|
+
return typeof scoped.getScope === 'function'
|
|
149
|
+
? scoped.getScope(node)
|
|
150
|
+
: context.getScope();
|
|
151
|
+
}
|
|
152
|
+
function resolveGeneratedName(scope, constName) {
|
|
153
|
+
let current = scope;
|
|
154
|
+
while (current) {
|
|
155
|
+
const variable = current.variables.find((v) => v.name === constName);
|
|
156
|
+
if (variable) {
|
|
157
|
+
return current.block.type === utils_1.AST_NODE_TYPES.Program
|
|
158
|
+
? classifyModuleBinding(variable)
|
|
159
|
+
: { kind: 'blocked' };
|
|
131
160
|
}
|
|
161
|
+
current = current.upper;
|
|
132
162
|
}
|
|
133
|
-
|
|
163
|
+
// An unresolved reference elsewhere in the file points at an ambient
|
|
164
|
+
// global; declaring the name at module scope would capture it.
|
|
165
|
+
const globalScope = sourceCode.scopeManager?.globalScope;
|
|
166
|
+
if (globalScope?.through.some((ref) => ref.identifier.name === constName)) {
|
|
167
|
+
return { kind: 'blocked' };
|
|
168
|
+
}
|
|
169
|
+
return { kind: 'free' };
|
|
134
170
|
}
|
|
135
|
-
function
|
|
171
|
+
function buildInitializerText(initText) {
|
|
136
172
|
const needsAsConst = /^(?:true|false|-?\d|\[|\{|[`'"])/.test(initText) &&
|
|
137
173
|
!/\bas const\b/.test(initText);
|
|
138
|
-
|
|
139
|
-
|
|
174
|
+
return needsAsConst ? `${initText} as const` : initText;
|
|
175
|
+
}
|
|
176
|
+
function buildConstDeclarationLine(constName, initText) {
|
|
177
|
+
return `const ${constName} = ${buildInitializerText(initText)};`;
|
|
140
178
|
}
|
|
141
179
|
function reportStaticDefaults(patterns, enclosingFn, nodeForReport) {
|
|
142
180
|
if (!enclosingFn || !isComponentOrHookFunction(enclosingFn))
|
|
@@ -153,23 +191,52 @@ exports.enforceGlobalConstants = (0, createRule_1.createRule)({
|
|
|
153
191
|
});
|
|
154
192
|
if (staticDefaults.length === 0)
|
|
155
193
|
return;
|
|
194
|
+
const reportScope = scopeOf(nodeForReport);
|
|
156
195
|
context.report({
|
|
157
196
|
node: nodeForReport,
|
|
158
197
|
messageId: 'extractDefaultToGlobalConstant',
|
|
159
198
|
fix(fixer) {
|
|
160
199
|
const fixes = [];
|
|
161
|
-
const programNode = sourceCode.ast;
|
|
162
200
|
const declLines = [];
|
|
201
|
+
// Names this fix commits to declaring, mapped to the initializer it
|
|
202
|
+
// declares them with, so sibling defaults sharing a generated name
|
|
203
|
+
// share the declaration instead of duplicating the binding.
|
|
204
|
+
const scheduledInits = new Map();
|
|
163
205
|
for (const def of staticDefaults) {
|
|
164
206
|
const { assignment, localName } = def;
|
|
165
207
|
const right = assignment.right;
|
|
166
208
|
const rightText = sourceCode.getText(right);
|
|
167
209
|
const constName = `DEFAULT_${toUpperSnakeCase(localName)}`;
|
|
168
|
-
|
|
169
|
-
|
|
210
|
+
const initText = buildInitializerText(rightText);
|
|
211
|
+
const scheduled = scheduledInits.get(constName);
|
|
212
|
+
if (scheduled !== undefined) {
|
|
213
|
+
if (scheduled !== initText)
|
|
214
|
+
continue;
|
|
215
|
+
fixes.push(fixer.replaceText(right, constName));
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const resolution = resolveGeneratedName(reportScope, constName);
|
|
219
|
+
if (resolution.kind === 'blocked') {
|
|
220
|
+
// Declining leaves the report in place: the developer extracts
|
|
221
|
+
// the constant by hand instead of the fixer corrupting the file.
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (resolution.kind === 'reusable') {
|
|
225
|
+
// Reuse is safe only when the existing constant holds the very
|
|
226
|
+
// same value; `as const` may be present on either side.
|
|
227
|
+
if (resolution.initText !== initText &&
|
|
228
|
+
resolution.initText !== rightText) {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
fixes.push(fixer.replaceText(right, constName));
|
|
232
|
+
continue;
|
|
170
233
|
}
|
|
234
|
+
declLines.push(buildConstDeclarationLine(constName, rightText));
|
|
235
|
+
scheduledInits.set(constName, initText);
|
|
171
236
|
fixes.push(fixer.replaceText(right, constName));
|
|
172
237
|
}
|
|
238
|
+
if (fixes.length === 0)
|
|
239
|
+
return null;
|
|
173
240
|
if (declLines.length > 0) {
|
|
174
241
|
const program = sourceCode.ast;
|
|
175
242
|
const constSection = declLines.length === 1
|