@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 CHANGED
@@ -223,7 +223,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
223
223
  module.exports = {
224
224
  meta: {
225
225
  name: '@blumintinc/eslint-plugin-blumint',
226
- version: '1.20.107',
226
+ version: '1.20.109',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -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.type === utils_1.AST_NODE_TYPES.Literal &&
124
- typeof property.value === 'string') {
125
- // Computed property access with string literal: transaction['get']
126
- methodName = property.value;
127
- }
128
- else if (callee.computed &&
129
- property.type === utils_1.AST_NODE_TYPES.Identifier) {
130
- // Computed property access with variable: transaction[methodName]
131
- // This is tricky to analyze statically. For now, we'll be conservative
132
- // and assume it could be any method. We'll handle this in the caller.
133
- return { isRead: true, isWrite: true, methodName: null }; // Could be either - let caller decide
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
- return `${objectName}[computed]`;
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
- redundantWrapper: 'useCallback is wrapping memoized callback "{{callbackName}}", adding a redundant dependency array without improving stability. Pass the hook/context callback directly so React keeps the original stable reference and avoids wrapper allocations and dependency drift.',
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 useCallback wrappers (including React.useCallback)
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 isUseCallback = (calleeNode.type === utils_1.AST_NODE_TYPES.Identifier &&
153
- calleeNode.name === 'useCallback') ||
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: { callbackName: sourceCode.getText(unwrappedArg) },
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: { callbackName: sourceCode.getText(unwrappedArg) },
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: { callbackName: sourceCode.getText(callee) },
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: { callbackName: sourceCode.getText(callee) },
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: { callbackName: sourceCode.getText(callee) },
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: { callbackName: sourceCode.getText(callee) },
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 all variables declared INSIDE the loop body, including inside
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 collectAssignmentTargetNames(target, names) {
313
+ function collectAssignmentTargetIdentifiers(target, identifiers) {
363
314
  switch (target.type) {
364
315
  case utils_1.AST_NODE_TYPES.Identifier:
365
- names.add(target.name);
316
+ identifiers.push(target);
366
317
  return true;
367
318
  case utils_1.AST_NODE_TYPES.MemberExpression:
368
- return collectAssignmentTargetNames(target.object, names);
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 collectAssignmentTargetNames(target.expression, names);
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 (!collectAssignmentTargetNames(inner, names))
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 && !collectAssignmentTargetNames(element, names)) {
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 collectAssignmentTargetNames(target.argument, names);
347
+ return collectAssignmentTargetIdentifiers(target.argument, identifiers);
395
348
  case utils_1.AST_NODE_TYPES.AssignmentPattern:
396
- return collectAssignmentTargetNames(target.left, names);
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 (i.e., not
406
- * in localVars) is ASSIGNED inside the loop body, whether directly or
407
- * from inside a callback the body hands to the awaited call. Examples:
408
- * `total += value`, `cursor = page.nextCursor`, `previousResult =
409
- * result`. This catches running totals, pagination cursors, and chained
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, loopLocalVars) {
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 names = new Set();
427
- if (!collectAssignmentTargetNames(target, names))
410
+ const identifiers = [];
411
+ if (!collectAssignmentTargetIdentifiers(target, identifiers)) {
428
412
  return true;
429
- for (const name of names) {
430
- if (!loopLocalVars.has(name))
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
- const loopLocalVars = collectLoopLocalVars(body);
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.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
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
- node.init &&
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 !== utils_1.AST_NODE_TYPES.ObjectExpression)
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
- reportNewDatesInObject(node.body, context);
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
- if (!node.argument)
400
- return;
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(node.argument, context);
458
+ reportNewDatesInObject(object, context);
413
459
  }
414
460
  break;
415
461
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.107",
3
+ "version": "1.20.109",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,48 @@
1
1
  [
2
+ {
3
+ "version": "1.20.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",