@blumintinc/eslint-plugin-blumint 1.20.107 → 1.20.109
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/firestore-transaction-reads-before-writes.js +100 -13
- package/lib/rules/no-redundant-usecallback-wrapper.js +83 -15
- package/lib/rules/parallelize-loop-awaits.js +60 -76
- package/lib/rules/require-server-timestamp-for-firestore-dates.js +69 -23
- package/package.json +1 -1
- package/release-manifest.json +44 -0
package/lib/index.js
CHANGED
|
@@ -6,6 +6,85 @@ const createRule_1 = require("../utils/createRule");
|
|
|
6
6
|
// Define the operations that are considered reads and writes
|
|
7
7
|
const READ_OPERATIONS = new Set(['get']);
|
|
8
8
|
const WRITE_OPERATIONS = new Set(['set', 'update', 'delete']);
|
|
9
|
+
/**
|
|
10
|
+
* Helpers that validate a property key and hand back that very key, so a call
|
|
11
|
+
* to one names exactly the method its argument names. `enforce-assert-safe-object-key`
|
|
12
|
+
* is `error` in the same recommended config and its fixer wraps computed keys in
|
|
13
|
+
* `assertSafe(...)`, which means this shape is machine-generated from ordinary
|
|
14
|
+
* `transaction[methodName]` code rather than hand-written.
|
|
15
|
+
*/
|
|
16
|
+
const KEY_ASSERTION_HELPERS = new Set(['assertSafe']);
|
|
17
|
+
/**
|
|
18
|
+
* Strips wrappers that erase at compile time or resolve to the key itself.
|
|
19
|
+
* `k as string`, `k satisfies string`, `<string>k` and `k!` emit nothing, and
|
|
20
|
+
* `await k` yields the same key, so none of them changes which method the
|
|
21
|
+
* lookup selects.
|
|
22
|
+
*
|
|
23
|
+
* The peel repeats because the wrappers nest: `(k as any)!`.
|
|
24
|
+
*/
|
|
25
|
+
function unwrapErasedKey(node) {
|
|
26
|
+
let current = node;
|
|
27
|
+
for (;;) {
|
|
28
|
+
switch (current.type) {
|
|
29
|
+
case utils_1.AST_NODE_TYPES.TSAsExpression:
|
|
30
|
+
case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
|
|
31
|
+
case utils_1.AST_NODE_TYPES.TSNonNullExpression:
|
|
32
|
+
case utils_1.AST_NODE_TYPES.TSTypeAssertion:
|
|
33
|
+
current = current.expression;
|
|
34
|
+
break;
|
|
35
|
+
case utils_1.AST_NODE_TYPES.AwaitExpression:
|
|
36
|
+
current = current.argument;
|
|
37
|
+
break;
|
|
38
|
+
default:
|
|
39
|
+
return current;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** True for `assertSafe(k)` and for a namespaced `utils.assertSafe(k)`. */
|
|
44
|
+
function isKeyAssertionCall(node) {
|
|
45
|
+
const { callee } = node;
|
|
46
|
+
if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
47
|
+
return KEY_ASSERTION_HELPERS.has(callee.name);
|
|
48
|
+
}
|
|
49
|
+
return (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
50
|
+
!callee.computed &&
|
|
51
|
+
callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
52
|
+
KEY_ASSERTION_HELPERS.has(callee.property.name));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Resolves the method a computed access selects: `transaction[<key>]()`.
|
|
56
|
+
*
|
|
57
|
+
* A key assertion returns its argument untouched, so reading through one is
|
|
58
|
+
* information-free — `transaction[assertSafe(k)]` must land on exactly the
|
|
59
|
+
* verdict `transaction[k]` lands on, definite name and all. Every other call
|
|
60
|
+
* may return something other than what it was handed, so its argument proves
|
|
61
|
+
* nothing about the method and the key counts as unresolved. That keeps the
|
|
62
|
+
* safety net on for any wrapper (`transaction[String(k)]`) while never minting
|
|
63
|
+
* a definite read/write verdict out of a call whose result is unknown.
|
|
64
|
+
*/
|
|
65
|
+
function resolveComputedKey(property) {
|
|
66
|
+
let current = unwrapErasedKey(property);
|
|
67
|
+
for (;;) {
|
|
68
|
+
if (current.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
69
|
+
typeof current.value === 'string') {
|
|
70
|
+
return { kind: 'name', name: current.value };
|
|
71
|
+
}
|
|
72
|
+
if (current.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
73
|
+
return { kind: 'unresolved' };
|
|
74
|
+
}
|
|
75
|
+
if (current.type === utils_1.AST_NODE_TYPES.CallExpression) {
|
|
76
|
+
const [argument] = current.arguments;
|
|
77
|
+
if (!isKeyAssertionCall(current) ||
|
|
78
|
+
current.arguments.length !== 1 ||
|
|
79
|
+
argument.type === utils_1.AST_NODE_TYPES.SpreadElement) {
|
|
80
|
+
return { kind: 'unresolved' };
|
|
81
|
+
}
|
|
82
|
+
current = unwrapErasedKey(argument);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
return { kind: 'opaque' };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
9
88
|
exports.firestoreTransactionReadsBeforeWrites = (0, createRule_1.createRule)({
|
|
10
89
|
name: 'firestore-transaction-reads-before-writes',
|
|
11
90
|
meta: {
|
|
@@ -119,18 +198,19 @@ exports.firestoreTransactionReadsBeforeWrites = (0, createRule_1.createRule)({
|
|
|
119
198
|
// Normal property access: transaction.get()
|
|
120
199
|
methodName = property.name;
|
|
121
200
|
}
|
|
122
|
-
else if (callee.computed
|
|
123
|
-
property
|
|
124
|
-
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
201
|
+
else if (callee.computed) {
|
|
202
|
+
// Computed property access: transaction['get'], transaction[methodName],
|
|
203
|
+
// transaction[assertSafe(methodName)]. A key that survives resolution
|
|
204
|
+
// as a definite string names the method; one that cannot be resolved
|
|
205
|
+
// could be any method, so it is answered conservatively and the caller
|
|
206
|
+
// decides.
|
|
207
|
+
const resolved = resolveComputedKey(property);
|
|
208
|
+
if (resolved.kind === 'unresolved') {
|
|
209
|
+
return { isRead: true, isWrite: true, methodName: null };
|
|
210
|
+
}
|
|
211
|
+
if (resolved.kind === 'name') {
|
|
212
|
+
methodName = resolved.name;
|
|
213
|
+
}
|
|
134
214
|
}
|
|
135
215
|
if (!methodName) {
|
|
136
216
|
return { isRead: false, isWrite: false, methodName: null };
|
|
@@ -154,7 +234,14 @@ exports.firestoreTransactionReadsBeforeWrites = (0, createRule_1.createRule)({
|
|
|
154
234
|
if (callee.property.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
155
235
|
return `${objectName}[${callee.property.name}]`;
|
|
156
236
|
}
|
|
157
|
-
|
|
237
|
+
// A wrapped key such as assertSafe(methodName) is quoted verbatim so
|
|
238
|
+
// the message names text that exists in the file and can be searched
|
|
239
|
+
// for, rather than the resolved key the reader never wrote.
|
|
240
|
+
const keyText = context
|
|
241
|
+
.getSourceCode()
|
|
242
|
+
.getText(callee.property)
|
|
243
|
+
.replace(/\s+/g, ' ');
|
|
244
|
+
return `${objectName}[${keyText}]`;
|
|
158
245
|
}
|
|
159
246
|
if (callee.property.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
160
247
|
return `${objectName}.${callee.property.name}`;
|
|
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.noRedundantUseCallbackWrapper = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
|
+
const LATEST_CALLBACK_MODULE = 'use-latest-callback';
|
|
7
|
+
const LATEST_CALLBACK_HOOK = 'useLatestCallback';
|
|
6
8
|
function isHookLikeName(name) {
|
|
7
9
|
return name.startsWith('use');
|
|
8
10
|
}
|
|
@@ -100,7 +102,10 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
|
|
|
100
102
|
},
|
|
101
103
|
],
|
|
102
104
|
messages: {
|
|
103
|
-
|
|
105
|
+
// The wrapper is named rather than hardcoded: the rule reports
|
|
106
|
+
// `useLatestCallback` too, which has no dependency array, so a message
|
|
107
|
+
// asserting one would describe code the reader cannot find.
|
|
108
|
+
redundantWrapper: '{{wrapper}} is wrapping memoized callback "{{callbackName}}", adding a redundant memoization layer without improving stability. Pass the hook/context callback directly so React keeps the original stable reference and avoids wrapper allocations and dependency drift.',
|
|
104
109
|
},
|
|
105
110
|
},
|
|
106
111
|
defaultOptions: [{}],
|
|
@@ -109,10 +114,59 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
|
|
|
109
114
|
const knownHooks = new Set(option.memoizedHookNames ?? []);
|
|
110
115
|
const assumeAllUseAreMemoized = option.assumeAllUseAreMemoized === true;
|
|
111
116
|
const sourceCode = context.sourceCode;
|
|
117
|
+
// Every callee that memoizes the callback handed to it. `useLatestCallback`
|
|
118
|
+
// belongs here because `use-latest-callback` — 'error' in the same
|
|
119
|
+
// recommended config, and fixable — rewrites every `useCallback(fn, deps)`
|
|
120
|
+
// into `useLatestCallback(fn)`. The wrapper it produces is the very
|
|
121
|
+
// construct this rule objects to, still allocating a fresh arrow around an
|
|
122
|
+
// already stable callback, so without this entry one `eslint --fix` renames
|
|
123
|
+
// the violation out of view while leaving it byte-for-byte intact — and the
|
|
124
|
+
// config mandating that spelling means it is also written by hand (#1726).
|
|
125
|
+
const wrapperNames = new Set(['useCallback', LATEST_CALLBACK_HOOK]);
|
|
126
|
+
/**
|
|
127
|
+
* The wrapper's name if this callee is one, else null. Reading the name
|
|
128
|
+
* rather than a boolean lets the report say which wrapper it found, since
|
|
129
|
+
* the local binding need not be spelled `useLatestCallback` at all.
|
|
130
|
+
*/
|
|
131
|
+
const wrapperNameOf = (callee) => {
|
|
132
|
+
if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
133
|
+
return wrapperNames.has(callee.name) ? callee.name : null;
|
|
134
|
+
}
|
|
135
|
+
// Namespaced spelling, e.g. React.useCallback
|
|
136
|
+
if (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
137
|
+
!callee.computed &&
|
|
138
|
+
callee.property.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
139
|
+
return wrapperNames.has(callee.property.name)
|
|
140
|
+
? callee.property.name
|
|
141
|
+
: null;
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
};
|
|
112
145
|
// Track identifiers coming from hook-like calls
|
|
113
146
|
const hookReturnObjects = new Set(); // variables assigned to a hook call result (object or function)
|
|
114
147
|
const hookReturnProps = new Set(); // properties destructured from a hook call result
|
|
115
148
|
return {
|
|
149
|
+
ImportDeclaration(node) {
|
|
150
|
+
// The module's sole export is the hook, so its DEFAULT specifier binds
|
|
151
|
+
// it under whatever local name the file chose — a shape a set of bare
|
|
152
|
+
// hook names cannot see. `use-latest-callback`'s own fixer picks that
|
|
153
|
+
// name with `freeImportName`, falling back to `useLatestCallback2` when
|
|
154
|
+
// `useLatestCallback` is already taken in the file, so the alias is
|
|
155
|
+
// authored by the sibling fixer rather than being hypothetical.
|
|
156
|
+
if (node.source.value !== LATEST_CALLBACK_MODULE ||
|
|
157
|
+
(node.importKind && node.importKind !== 'value')) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
for (const specifier of node.specifiers) {
|
|
161
|
+
if (specifier.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier ||
|
|
162
|
+
(specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
163
|
+
specifier.importKind !== 'type' &&
|
|
164
|
+
specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
165
|
+
specifier.imported.name === LATEST_CALLBACK_HOOK)) {
|
|
166
|
+
wrapperNames.add(specifier.local.name);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
},
|
|
116
170
|
VariableDeclarator(node) {
|
|
117
171
|
if (!node.init)
|
|
118
172
|
return;
|
|
@@ -145,17 +199,13 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
|
|
|
145
199
|
}
|
|
146
200
|
},
|
|
147
201
|
CallExpression(node) {
|
|
148
|
-
// Detect
|
|
202
|
+
// Detect memoization wrappers (including React.useCallback and the
|
|
203
|
+
// useLatestCallback spelling the config's own fixer produces)
|
|
149
204
|
const calleeNode = unwrapChainExpression(node.callee);
|
|
150
205
|
if (!calleeNode)
|
|
151
206
|
return;
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
(calleeNode.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
155
|
-
!calleeNode.computed &&
|
|
156
|
-
calleeNode.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
157
|
-
calleeNode.property.name === 'useCallback');
|
|
158
|
-
if (isUseCallback && node.arguments.length >= 1) {
|
|
207
|
+
const wrapper = wrapperNameOf(calleeNode);
|
|
208
|
+
if (wrapper && node.arguments.length >= 1) {
|
|
159
209
|
const arg = node.arguments[0];
|
|
160
210
|
const unwrappedArg = unwrapChainExpression(arg);
|
|
161
211
|
// Case 1: useCallback(memoizedFn, ...) or useCallback(ctx.memoized, ...)
|
|
@@ -173,7 +223,10 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
|
|
|
173
223
|
context.report({
|
|
174
224
|
node,
|
|
175
225
|
messageId: 'redundantWrapper',
|
|
176
|
-
data: {
|
|
226
|
+
data: {
|
|
227
|
+
wrapper,
|
|
228
|
+
callbackName: sourceCode.getText(unwrappedArg),
|
|
229
|
+
},
|
|
177
230
|
fix: (fixer) => fixer.replaceText(node, replaceText),
|
|
178
231
|
});
|
|
179
232
|
}
|
|
@@ -182,7 +235,10 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
|
|
|
182
235
|
context.report({
|
|
183
236
|
node,
|
|
184
237
|
messageId: 'redundantWrapper',
|
|
185
|
-
data: {
|
|
238
|
+
data: {
|
|
239
|
+
wrapper,
|
|
240
|
+
callbackName: sourceCode.getText(unwrappedArg),
|
|
241
|
+
},
|
|
186
242
|
});
|
|
187
243
|
}
|
|
188
244
|
}
|
|
@@ -214,7 +270,10 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
|
|
|
214
270
|
context.report({
|
|
215
271
|
node,
|
|
216
272
|
messageId: 'redundantWrapper',
|
|
217
|
-
data: {
|
|
273
|
+
data: {
|
|
274
|
+
wrapper,
|
|
275
|
+
callbackName: sourceCode.getText(callee),
|
|
276
|
+
},
|
|
218
277
|
fix: (fixer) => fixer.replaceText(node, replaceText),
|
|
219
278
|
});
|
|
220
279
|
}
|
|
@@ -223,7 +282,10 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
|
|
|
223
282
|
context.report({
|
|
224
283
|
node,
|
|
225
284
|
messageId: 'redundantWrapper',
|
|
226
|
-
data: {
|
|
285
|
+
data: {
|
|
286
|
+
wrapper,
|
|
287
|
+
callbackName: sourceCode.getText(callee),
|
|
288
|
+
},
|
|
227
289
|
});
|
|
228
290
|
}
|
|
229
291
|
}
|
|
@@ -272,7 +334,10 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
|
|
|
272
334
|
context.report({
|
|
273
335
|
node,
|
|
274
336
|
messageId: 'redundantWrapper',
|
|
275
|
-
data: {
|
|
337
|
+
data: {
|
|
338
|
+
wrapper,
|
|
339
|
+
callbackName: sourceCode.getText(callee),
|
|
340
|
+
},
|
|
276
341
|
fix: (fixer) => fixer.replaceText(node, replaceText),
|
|
277
342
|
});
|
|
278
343
|
}
|
|
@@ -281,7 +346,10 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
|
|
|
281
346
|
context.report({
|
|
282
347
|
node,
|
|
283
348
|
messageId: 'redundantWrapper',
|
|
284
|
-
data: {
|
|
349
|
+
data: {
|
|
350
|
+
wrapper,
|
|
351
|
+
callbackName: sourceCode.getText(callee),
|
|
352
|
+
},
|
|
285
353
|
});
|
|
286
354
|
}
|
|
287
355
|
}
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.parallelizeLoopAwaits = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
|
+
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
6
7
|
// Anchored at the end of the path so multi-part suffixes such as
|
|
7
8
|
// `EventRegistry.integration.test.ts` are recognized while production modules
|
|
8
9
|
// that merely contain the word (`testHelpers.ts`, `latest.ts`, `contest/Thing.ts`)
|
|
@@ -291,61 +292,7 @@ exports.parallelizeLoopAwaits = (0, createRule_1.createRule)({
|
|
|
291
292
|
return false;
|
|
292
293
|
}
|
|
293
294
|
/**
|
|
294
|
-
* Collects
|
|
295
|
-
* callbacks written there. These are iteration-local variables: nothing
|
|
296
|
-
* they hold outlives the iteration that created them.
|
|
297
|
-
*
|
|
298
|
-
* The walk crosses nested function boundaries because the write scan that
|
|
299
|
-
* consults this set crosses them too. A name both declared and assigned
|
|
300
|
-
* inside a callback (`async () => { let tmp; tmp = 1; }`) publishes nothing
|
|
301
|
-
* to the enclosing scope, so if the set stopped at the boundary the write
|
|
302
|
-
* would read as a cross-iteration dependency and silence the loop. (#1724)
|
|
303
|
-
*/
|
|
304
|
-
function collectLoopLocalVars(body) {
|
|
305
|
-
const localVars = new Set();
|
|
306
|
-
function visit(node, isRoot) {
|
|
307
|
-
// A callback's parameters bind afresh on every invocation, so a write
|
|
308
|
-
// through one (`async (page) => { page.total = 1 }`) reaches whatever
|
|
309
|
-
// the caller handed that call rather than state the iterations share.
|
|
310
|
-
if (!isRoot &&
|
|
311
|
-
(node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
|
|
312
|
-
node.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
|
|
313
|
-
node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression)) {
|
|
314
|
-
for (const param of node.params) {
|
|
315
|
-
collectBindingNames(param, localVars);
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
if (node.type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
|
|
319
|
-
for (const declarator of node.declarations) {
|
|
320
|
-
collectBindingNames(declarator.id, localVars);
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
for (const key in node) {
|
|
324
|
-
if (key === 'parent' ||
|
|
325
|
-
key === 'range' ||
|
|
326
|
-
key === 'loc' ||
|
|
327
|
-
key === 'type')
|
|
328
|
-
continue;
|
|
329
|
-
const child = node[key];
|
|
330
|
-
if (child && typeof child === 'object') {
|
|
331
|
-
if (Array.isArray(child)) {
|
|
332
|
-
for (const item of child) {
|
|
333
|
-
if (item && typeof item === 'object' && 'type' in item) {
|
|
334
|
-
visit(item, false);
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
else if ('type' in child) {
|
|
339
|
-
visit(child, false);
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
visit(body, true);
|
|
345
|
-
return localVars;
|
|
346
|
-
}
|
|
347
|
-
/**
|
|
348
|
-
* Collects the BINDINGS an assignment target writes through, returning
|
|
295
|
+
* Collects the IDENTIFIERS an assignment target writes through, returning
|
|
349
296
|
* false when the target's root is not a plain binding at all.
|
|
350
297
|
*
|
|
351
298
|
* A member write reaches the object its ROOT names: `box.value = 1` writes
|
|
@@ -358,62 +305,93 @@ exports.parallelizeLoopAwaits = (0, createRule_1.createRule)({
|
|
|
358
305
|
* A root the analysis cannot name — `this.count += 1` reaches instance
|
|
359
306
|
* state every iteration shares — returns false, and the caller reads that
|
|
360
307
|
* as an outer write. The plugin prefers a missed report to a spurious one.
|
|
308
|
+
*
|
|
309
|
+
* The identifier NODE is carried rather than its name, because locality is
|
|
310
|
+
* a question about scope: two bindings can share a spelling, and only the
|
|
311
|
+
* node knows which one a given write reaches. (#1725)
|
|
361
312
|
*/
|
|
362
|
-
function
|
|
313
|
+
function collectAssignmentTargetIdentifiers(target, identifiers) {
|
|
363
314
|
switch (target.type) {
|
|
364
315
|
case utils_1.AST_NODE_TYPES.Identifier:
|
|
365
|
-
|
|
316
|
+
identifiers.push(target);
|
|
366
317
|
return true;
|
|
367
318
|
case utils_1.AST_NODE_TYPES.MemberExpression:
|
|
368
|
-
return
|
|
319
|
+
return collectAssignmentTargetIdentifiers(target.object, identifiers);
|
|
369
320
|
case utils_1.AST_NODE_TYPES.ChainExpression:
|
|
370
321
|
case utils_1.AST_NODE_TYPES.TSNonNullExpression:
|
|
371
322
|
case utils_1.AST_NODE_TYPES.TSAsExpression:
|
|
372
|
-
return
|
|
323
|
+
return collectAssignmentTargetIdentifiers(target.expression, identifiers);
|
|
373
324
|
case utils_1.AST_NODE_TYPES.ObjectPattern: {
|
|
374
325
|
let resolved = true;
|
|
375
326
|
for (const property of target.properties) {
|
|
376
327
|
const inner = property.type === utils_1.AST_NODE_TYPES.RestElement
|
|
377
328
|
? property.argument
|
|
378
329
|
: property.value;
|
|
379
|
-
if (!
|
|
330
|
+
if (!collectAssignmentTargetIdentifiers(inner, identifiers)) {
|
|
380
331
|
resolved = false;
|
|
332
|
+
}
|
|
381
333
|
}
|
|
382
334
|
return resolved;
|
|
383
335
|
}
|
|
384
336
|
case utils_1.AST_NODE_TYPES.ArrayPattern: {
|
|
385
337
|
let resolved = true;
|
|
386
338
|
for (const element of target.elements) {
|
|
387
|
-
if (element &&
|
|
339
|
+
if (element &&
|
|
340
|
+
!collectAssignmentTargetIdentifiers(element, identifiers)) {
|
|
388
341
|
resolved = false;
|
|
389
342
|
}
|
|
390
343
|
}
|
|
391
344
|
return resolved;
|
|
392
345
|
}
|
|
393
346
|
case utils_1.AST_NODE_TYPES.RestElement:
|
|
394
|
-
return
|
|
347
|
+
return collectAssignmentTargetIdentifiers(target.argument, identifiers);
|
|
395
348
|
case utils_1.AST_NODE_TYPES.AssignmentPattern:
|
|
396
|
-
return
|
|
349
|
+
return collectAssignmentTargetIdentifiers(target.left, identifiers);
|
|
397
350
|
default:
|
|
398
351
|
return false;
|
|
399
352
|
}
|
|
400
353
|
}
|
|
354
|
+
/**
|
|
355
|
+
* Reports whether an identifier resolves to a binding DECLARED inside the
|
|
356
|
+
* given root node.
|
|
357
|
+
*
|
|
358
|
+
* Such a binding is iteration-local: nothing it holds outlives the
|
|
359
|
+
* iteration that created it, so writing it couples no two iterations.
|
|
360
|
+
* `async () => { let tmp; tmp = 1; }` publishes nothing to the enclosing
|
|
361
|
+
* scope, and a callback's parameters bind afresh on every invocation.
|
|
362
|
+
*
|
|
363
|
+
* The question is settled by SCOPE rather than by spelling. A flat set of
|
|
364
|
+
* declared NAMES cannot tell an outer binding from a nested one that merely
|
|
365
|
+
* reuses the identifier, so a genuine cross-iteration write to `cursor`
|
|
366
|
+
* would read as local the moment any callback in the loop happened to name
|
|
367
|
+
* a parameter `cursor`. (#1725)
|
|
368
|
+
*
|
|
369
|
+
* An UNRESOLVED name — an implicit global — counts as external, which keeps
|
|
370
|
+
* the barrier in place for the case the analysis cannot see.
|
|
371
|
+
*/
|
|
372
|
+
function isDeclaredWithin(identifier, root) {
|
|
373
|
+
const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, identifier), identifier.name);
|
|
374
|
+
if (!variable || variable.defs.length === 0) {
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
return variable.defs.every((definition) => definition.name.range[0] >= root.range[0] &&
|
|
378
|
+
definition.name.range[1] <= root.range[1]);
|
|
379
|
+
}
|
|
401
380
|
/**
|
|
402
381
|
* Detects cross-iteration state patterns that require sequential
|
|
403
382
|
* execution:
|
|
404
383
|
*
|
|
405
|
-
* 1. Accumulator: a variable declared OUTSIDE the loop body
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
* results.
|
|
384
|
+
* 1. Accumulator: a variable declared OUTSIDE the loop body is ASSIGNED
|
|
385
|
+
* inside it, whether directly or from inside a callback the body hands
|
|
386
|
+
* to the awaited call. Examples: `total += value`, `cursor =
|
|
387
|
+
* page.nextCursor`, `previousResult = result`. This catches running
|
|
388
|
+
* totals, pagination cursors, and chained results.
|
|
411
389
|
*
|
|
412
390
|
* 2. Direct cross-await dependency: a variable declared by an await
|
|
413
391
|
* inside the loop is then read as an argument to another await in the
|
|
414
392
|
* same loop body. Example: `const a = await f(); const b = await g(a);`.
|
|
415
393
|
*/
|
|
416
|
-
function hasSequentialDependency(body
|
|
394
|
+
function hasSequentialDependency(body) {
|
|
417
395
|
// Pattern 1: outer variable is written inside the loop body.
|
|
418
396
|
// Collect every assignment target — the left-hand side of an assignment
|
|
419
397
|
// or compound assignment, and the operand of an increment in a callback.
|
|
@@ -421,13 +399,20 @@ exports.parallelizeLoopAwaits = (0, createRule_1.createRule)({
|
|
|
421
399
|
/**
|
|
422
400
|
* Reports whether an assignment target reaches a binding the iterations
|
|
423
401
|
* share rather than one the iteration creates.
|
|
402
|
+
*
|
|
403
|
+
* The body is the locality root, so a binding introduced by the loop's
|
|
404
|
+
* own HEAD reads as shared. That is the conservative reading and the
|
|
405
|
+
* correct one for a C-style counter: `for (let i = 0; i < n; i += 1)`
|
|
406
|
+
* carries `i` forward between iterations, so a body write to it really
|
|
407
|
+
* does couple them.
|
|
424
408
|
*/
|
|
425
409
|
function writesOuterBinding(target) {
|
|
426
|
-
const
|
|
427
|
-
if (!
|
|
410
|
+
const identifiers = [];
|
|
411
|
+
if (!collectAssignmentTargetIdentifiers(target, identifiers)) {
|
|
428
412
|
return true;
|
|
429
|
-
|
|
430
|
-
|
|
413
|
+
}
|
|
414
|
+
for (const identifier of identifiers) {
|
|
415
|
+
if (!isDeclaredWithin(identifier, body))
|
|
431
416
|
return true;
|
|
432
417
|
}
|
|
433
418
|
return false;
|
|
@@ -829,8 +814,7 @@ exports.parallelizeLoopAwaits = (0, createRule_1.createRule)({
|
|
|
829
814
|
return null;
|
|
830
815
|
// Exclusion: accumulator / pagination patterns — sequential dependency
|
|
831
816
|
// detected between iterations
|
|
832
|
-
|
|
833
|
-
if (hasSequentialDependency(body, loopLocalVars))
|
|
817
|
+
if (hasSequentialDependency(body))
|
|
834
818
|
return null;
|
|
835
819
|
// Exclusion: the specific await being reported is a rate-limiting call
|
|
836
820
|
const callNames = getCallNames(awaitExpr);
|
|
@@ -91,20 +91,60 @@ function typeAnnotationReferencesFirestoreType(typeAnnotation, firestoreTypeName
|
|
|
91
91
|
const names = collectTypeReferenceNames(typeAnnotation);
|
|
92
92
|
return names.some((n) => firestoreTypeNames.has(n));
|
|
93
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* True for the assertion wrappers that leave the underlying expression intact:
|
|
96
|
+
* `x as T` and `x satisfies T`.
|
|
97
|
+
*/
|
|
98
|
+
function isCastExpression(node) {
|
|
99
|
+
return (node.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
|
|
100
|
+
node.type ===
|
|
101
|
+
utils_1.AST_NODE_TYPES
|
|
102
|
+
.TSSatisfiesExpression);
|
|
103
|
+
}
|
|
94
104
|
/**
|
|
95
105
|
* Returns the inner expression, unwrapping TSAsExpression / TSSatisfiesExpression
|
|
96
106
|
* chains, so we can inspect what lies under a cast.
|
|
97
107
|
*/
|
|
98
108
|
function unwrapCast(node) {
|
|
99
109
|
let current = node;
|
|
100
|
-
while (current
|
|
101
|
-
current.type ===
|
|
102
|
-
utils_1.AST_NODE_TYPES
|
|
103
|
-
.TSSatisfiesExpression) {
|
|
110
|
+
while (isCastExpression(current)) {
|
|
104
111
|
current = current.expression;
|
|
105
112
|
}
|
|
106
113
|
return current;
|
|
107
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* True when some `as` cast in the wrapper chain targets a Firestore type. Those
|
|
117
|
+
* casts are the TSAsExpression visitor's own entry point, so the annotation- and
|
|
118
|
+
* return-type-driven visitors must stand down on them or the same `new Date()`
|
|
119
|
+
* is reported twice.
|
|
120
|
+
*/
|
|
121
|
+
function castChainTargetsFirestoreType(node, firestoreTypeNames) {
|
|
122
|
+
let current = node;
|
|
123
|
+
while (isCastExpression(current)) {
|
|
124
|
+
const cast = current;
|
|
125
|
+
if (cast.type === utils_1.AST_NODE_TYPES.TSAsExpression &&
|
|
126
|
+
typeAnnotationReferencesFirestoreType(cast.typeAnnotation, firestoreTypeNames)) {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
current = cast.expression;
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Resolves an expression in a Firestore-typed position to the object literal it
|
|
135
|
+
* ultimately denotes, seeing through `as const` and other assertion wrappers.
|
|
136
|
+
* A wrapper is a syntactic no-op — `{ createdAt: new Date() } as const` still
|
|
137
|
+
* stamps the document with the client clock — so it must not hide the object.
|
|
138
|
+
* Returns null when the chain is already owned by the TSAsExpression visitor.
|
|
139
|
+
*/
|
|
140
|
+
function resolveObjectLiteral(node, firestoreTypeNames) {
|
|
141
|
+
if (!node)
|
|
142
|
+
return null;
|
|
143
|
+
if (castChainTargetsFirestoreType(node, firestoreTypeNames))
|
|
144
|
+
return null;
|
|
145
|
+
const inner = unwrapCast(node);
|
|
146
|
+
return inner.type === utils_1.AST_NODE_TYPES.ObjectExpression ? inner : null;
|
|
147
|
+
}
|
|
108
148
|
/**
|
|
109
149
|
* Checks whether an expression is `new Date(...)` (possibly wrapped in casts).
|
|
110
150
|
*/
|
|
@@ -343,19 +383,22 @@ exports.requireServerTimestampForFirestoreDates = (0, createRule_1.createRule)({
|
|
|
343
383
|
VariableDeclarator(node) {
|
|
344
384
|
if (firestoreTypeNames.size === 0)
|
|
345
385
|
return;
|
|
346
|
-
// Pattern: const x: FirestoreType = { ... }
|
|
386
|
+
// Pattern: const x: FirestoreType = { ... }, including when the literal
|
|
387
|
+
// is wrapped in `as const` or another assertion.
|
|
347
388
|
const typeAnnotation = node.id.typeAnnotation?.typeAnnotation;
|
|
348
|
-
if (typeAnnotation
|
|
349
|
-
typeAnnotationReferencesFirestoreType(typeAnnotation, firestoreTypeNames)
|
|
350
|
-
|
|
351
|
-
node.init.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
|
|
352
|
-
// Exempt local render seeds handed to React state, never written to
|
|
353
|
-
// Firestore — being typed as a Firestore doc is not a write.
|
|
354
|
-
if (context.getDeclaredVariables(node).some(isLocalRenderSeedVariable)) {
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
reportNewDatesInObject(node.init, context);
|
|
389
|
+
if (!typeAnnotation ||
|
|
390
|
+
!typeAnnotationReferencesFirestoreType(typeAnnotation, firestoreTypeNames)) {
|
|
391
|
+
return;
|
|
358
392
|
}
|
|
393
|
+
const object = resolveObjectLiteral(node.init, firestoreTypeNames);
|
|
394
|
+
if (!object)
|
|
395
|
+
return;
|
|
396
|
+
// Exempt local render seeds handed to React state, never written to
|
|
397
|
+
// Firestore — being typed as a Firestore doc is not a write.
|
|
398
|
+
if (context.getDeclaredVariables(node).some(isLocalRenderSeedVariable)) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
reportNewDatesInObject(object, context);
|
|
359
402
|
},
|
|
360
403
|
// Pattern: { ... } as FirestoreType or { ... } satisfies FirestoreType
|
|
361
404
|
TSAsExpression(node) {
|
|
@@ -384,21 +427,24 @@ exports.requireServerTimestampForFirestoreDates = (0, createRule_1.createRule)({
|
|
|
384
427
|
ArrowFunctionExpression(node) {
|
|
385
428
|
if (firestoreTypeNames.size === 0)
|
|
386
429
|
return;
|
|
387
|
-
if (node.body.type
|
|
430
|
+
if (node.body.type === utils_1.AST_NODE_TYPES.BlockStatement)
|
|
388
431
|
return;
|
|
389
432
|
const returnType = node.returnType?.typeAnnotation;
|
|
390
|
-
if (returnType
|
|
391
|
-
typeAnnotationReferencesFirestoreType(returnType, firestoreTypeNames)) {
|
|
392
|
-
|
|
433
|
+
if (!returnType ||
|
|
434
|
+
!typeAnnotationReferencesFirestoreType(returnType, firestoreTypeNames)) {
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const object = resolveObjectLiteral(node.body, firestoreTypeNames);
|
|
438
|
+
if (object) {
|
|
439
|
+
reportNewDatesInObject(object, context);
|
|
393
440
|
}
|
|
394
441
|
},
|
|
395
442
|
// Return statements in functions with explicit Firestore return type annotation
|
|
396
443
|
ReturnStatement(node) {
|
|
397
444
|
if (firestoreTypeNames.size === 0)
|
|
398
445
|
return;
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
if (node.argument.type !== utils_1.AST_NODE_TYPES.ObjectExpression)
|
|
446
|
+
const object = resolveObjectLiteral(node.argument, firestoreTypeNames);
|
|
447
|
+
if (!object)
|
|
402
448
|
return;
|
|
403
449
|
// Walk up to find the enclosing function and check its return type
|
|
404
450
|
let ancestor = node.parent;
|
|
@@ -409,7 +455,7 @@ exports.requireServerTimestampForFirestoreDates = (0, createRule_1.createRule)({
|
|
|
409
455
|
const returnType = ancestor.returnType?.typeAnnotation;
|
|
410
456
|
if (returnType &&
|
|
411
457
|
typeAnnotationReferencesFirestoreType(returnType, firestoreTypeNames)) {
|
|
412
|
-
reportNewDatesInObject(
|
|
458
|
+
reportNewDatesInObject(object, context);
|
|
413
459
|
}
|
|
414
460
|
break;
|
|
415
461
|
}
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,48 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.109",
|
|
4
|
+
"date": "2026-08-05T07:42:30.150Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "firestore-transaction-reads-before-writes",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1728
|
|
11
|
+
],
|
|
12
|
+
"summary": "resolve a call-wrapped computed key (closes #1728)"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "no-redundant-usecallback-wrapper",
|
|
16
|
+
"changeType": "fix",
|
|
17
|
+
"issues": [
|
|
18
|
+
1726
|
|
19
|
+
],
|
|
20
|
+
"summary": "see the useLatestCallback spelling (closes #1726)"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "require-server-timestamp-for-firestore-dates",
|
|
24
|
+
"changeType": "fix",
|
|
25
|
+
"issues": [
|
|
26
|
+
1727
|
|
27
|
+
],
|
|
28
|
+
"summary": "look through cast wrappers (closes #1727)"
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"version": "1.20.108",
|
|
34
|
+
"date": "2026-08-05T05:55:32.346Z",
|
|
35
|
+
"rules": [
|
|
36
|
+
{
|
|
37
|
+
"name": "parallelize-loop-awaits",
|
|
38
|
+
"changeType": "fix",
|
|
39
|
+
"issues": [
|
|
40
|
+
1725
|
|
41
|
+
],
|
|
42
|
+
"summary": "resolve write locality by scope, not by name (closes #1725)"
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
},
|
|
2
46
|
{
|
|
3
47
|
"version": "1.20.107",
|
|
4
48
|
"date": "2026-08-05T05:11:58.347Z",
|