@blumintinc/eslint-plugin-blumint 1.20.108 → 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.108',
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
  }
@@ -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.108",
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,34 @@
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
+ },
2
32
  {
3
33
  "version": "1.20.108",
4
34
  "date": "2026-08-05T05:55:32.346Z",