@blumintinc/eslint-plugin-blumint 1.20.126 → 1.20.127

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.126',
226
+ version: '1.20.127',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -261,26 +261,49 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
261
261
  const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, callee), 'Boolean');
262
262
  return !variable || variable.defs.length === 0;
263
263
  }
264
+ /**
265
+ * An optional link wraps the member access or call it belongs to in a
266
+ * `ChainExpression`, so `user?.isLoggedIn` and `canDelete?.('x')` reach a
267
+ * value check as a wrapper node rather than as the member/call the check
268
+ * looks for.
269
+ *
270
+ * Unwrapping is the right answer for THIS rule even though `a?.b` is
271
+ * `boolean | undefined` where `a.b` is `boolean`: the rule's remedy is a
272
+ * rename of the binding, which never changes how the initializer
273
+ * short-circuits, and the rule already requires the prefix on
274
+ * possibly-undefined booleans elsewhere — `deletable?: boolean` on a
275
+ * parameter, class property or method all report, and so does
276
+ * `const loggedIn = user && user.isLoggedIn`, whose type is exactly the
277
+ * `boolean | undefined` an optional chain produces. A value that may be
278
+ * absent is where an unprefixed name misleads most, because a falsy result
279
+ * no longer distinguishes "false" from "receiver was missing".
280
+ */
281
+ function unwrapChainExpression(expression) {
282
+ return expression.type === utils_1.AST_NODE_TYPES.ChainExpression
283
+ ? expression.expression
284
+ : expression;
285
+ }
264
286
  /**
265
287
  * Check if a node is initialized with a boolean value
266
288
  */
267
289
  function hasInitialBooleanValue(node) {
268
290
  if (node.type === utils_1.AST_NODE_TYPES.VariableDeclarator && node.init) {
291
+ const init = unwrapChainExpression(node.init);
269
292
  // Check for direct boolean literal initialization
270
- if (node.init.type === utils_1.AST_NODE_TYPES.Literal &&
271
- typeof node.init.value === 'boolean') {
293
+ if (init.type === utils_1.AST_NODE_TYPES.Literal &&
294
+ typeof init.value === 'boolean') {
272
295
  return true;
273
296
  }
274
297
  // Check for logical expressions that typically return boolean
275
- if (node.init.type === utils_1.AST_NODE_TYPES.BinaryExpression &&
276
- BOOLEANISH_BINARY_OPERATORS.has(node.init.operator)) {
298
+ if (init.type === utils_1.AST_NODE_TYPES.BinaryExpression &&
299
+ BOOLEANISH_BINARY_OPERATORS.has(init.operator)) {
277
300
  return true;
278
301
  }
279
302
  // Check for logical expressions (&&)
280
- if (node.init.type === utils_1.AST_NODE_TYPES.LogicalExpression &&
281
- node.init.operator === '&&') {
282
- const left = evaluateBooleanishExpression(node.init.left);
283
- const right = evaluateBooleanishExpression(node.init.right);
303
+ if (init.type === utils_1.AST_NODE_TYPES.LogicalExpression &&
304
+ init.operator === '&&') {
305
+ const left = evaluateBooleanishExpression(init.left);
306
+ const right = evaluateBooleanishExpression(init.right);
284
307
  // If both sides are boolean, the result is boolean.
285
308
  if (left === 'boolean' && right === 'boolean') {
286
309
  return true;
@@ -297,10 +320,10 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
297
320
  // Special case for logical OR (||) - only consider it boolean if:
298
321
  // 1. It's used with boolean literals or
299
322
  // 2. It's not used with array/object literals as fallbacks
300
- if (node.init.type === utils_1.AST_NODE_TYPES.LogicalExpression &&
301
- node.init.operator === '||') {
323
+ if (init.type === utils_1.AST_NODE_TYPES.LogicalExpression &&
324
+ init.operator === '||') {
302
325
  // Check if right side is a non-boolean literal (array, object, string, number)
303
- const rightSide = node.init.right;
326
+ const rightSide = init.right;
304
327
  if (rightSide.type === utils_1.AST_NODE_TYPES.ArrayExpression ||
305
328
  rightSide.type === utils_1.AST_NODE_TYPES.ObjectExpression ||
306
329
  (rightSide.type === utils_1.AST_NODE_TYPES.Literal &&
@@ -314,7 +337,7 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
314
337
  }
315
338
  // For other cases, we need to be more careful
316
339
  // If we can determine the left side is a boolean, then it's a boolean variable
317
- const leftSide = node.init.left;
340
+ const leftSide = unwrapChainExpression(init.left);
318
341
  if ((leftSide.type === utils_1.AST_NODE_TYPES.Literal &&
319
342
  typeof leftSide.value === 'boolean') ||
320
343
  (leftSide.type === utils_1.AST_NODE_TYPES.UnaryExpression &&
@@ -336,20 +359,20 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
336
359
  return false;
337
360
  }
338
361
  // Check for unary expressions with ! operator
339
- if (node.init.type === utils_1.AST_NODE_TYPES.UnaryExpression &&
340
- node.init.operator === '!') {
362
+ if (init.type === utils_1.AST_NODE_TYPES.UnaryExpression &&
363
+ init.operator === '!') {
341
364
  return true;
342
365
  }
343
366
  // Check for function calls that might return boolean
344
- if (node.init.type === utils_1.AST_NODE_TYPES.CallExpression &&
345
- node.init.callee.type === utils_1.AST_NODE_TYPES.Identifier) {
367
+ if (init.type === utils_1.AST_NODE_TYPES.CallExpression &&
368
+ init.callee.type === utils_1.AST_NODE_TYPES.Identifier) {
346
369
  // A coercion through the global `Boolean` is as definitive as `!!x`,
347
370
  // and its callee carries no approved prefix for the name heuristic
348
371
  // below to recognize.
349
- if (isGlobalBooleanCall(node.init)) {
372
+ if (isGlobalBooleanCall(init)) {
350
373
  return true;
351
374
  }
352
- const calleeName = node.init.callee.name;
375
+ const calleeName = init.callee.name;
353
376
  const lowerCallee = calleeName.toLowerCase();
354
377
  // For assert*-style utilities, only treat as boolean if we can confirm boolean return type
355
378
  if (lowerCallee.startsWith('assert')) {
@@ -1110,7 +1133,7 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
1110
1133
  const variableDeclarator = node.parent;
1111
1134
  if (variableDeclarator?.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
1112
1135
  variableDeclarator.init) {
1113
- const init = variableDeclarator.init;
1136
+ const init = unwrapChainExpression(variableDeclarator.init);
1114
1137
  // Check for direct boolean initialization
1115
1138
  if (init.type === utils_1.AST_NODE_TYPES.Literal &&
1116
1139
  typeof init.value === 'boolean') {
@@ -39,6 +39,22 @@ const referenceTypeNameOf = (typeName) => {
39
39
  }
40
40
  return undefined;
41
41
  };
42
+ /**
43
+ * The expression an optional link wraps, so a receiver spelled with `?.` is
44
+ * read as the expression it actually evaluates.
45
+ *
46
+ * `a?.b` interposes a `ChainExpression` between the member/call and its real
47
+ * parent. That link perturbs nullability, not the document schema:
48
+ * `db?.collection<T>('x')` has type `CollectionReference<T> | undefined`, whose
49
+ * schema is still `T`, never `DocumentData`. Leaving the wrapper in place makes
50
+ * a typed collection look unrecognizable, and the `.doc()` that inherits its
51
+ * schema draws a missing-generic report whose only remedy — `doc<T>(...)` —
52
+ * does not compile, since `CollectionReference<T>.doc` declares no type
53
+ * parameters.
54
+ */
55
+ function unwrapOptionalChain(node) {
56
+ return node.type === utils_1.AST_NODE_TYPES.ChainExpression ? node.expression : node;
57
+ }
42
58
  /**
43
59
  * The type declaration a statement makes, looking through `export`.
44
60
  *
@@ -344,9 +360,10 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
344
360
  return false;
345
361
  }
346
362
  const isTypedCollectionReferenceCache = new Map();
347
- function isTypedCollectionReference(node) {
348
- if (!node)
363
+ function isTypedCollectionReference(receiver) {
364
+ if (!receiver)
349
365
  return false;
366
+ const node = unwrapOptionalChain(receiver);
350
367
  if (isTypedCollectionReferenceCache.has(node)) {
351
368
  return isTypedCollectionReferenceCache.get(node);
352
369
  }
@@ -511,10 +528,11 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
511
528
  }
512
529
  return isTypedCollectionInitializer(declarator.init);
513
530
  }
514
- function isTypedCollectionInitializer(init) {
515
- if (!init) {
531
+ function isTypedCollectionInitializer(initializer) {
532
+ if (!initializer) {
516
533
  return false;
517
534
  }
535
+ const init = unwrapOptionalChain(initializer);
518
536
  // An explicit assertion states the schema just as an annotation does.
519
537
  if (init.type === utils_1.AST_NODE_TYPES.TSAsExpression) {
520
538
  return hasCollectionReferenceType(init.typeAnnotation);
@@ -193,9 +193,37 @@ function isPrimitiveLiteral(node) {
193
193
  typeof value === 'boolean' ||
194
194
  typeof value === 'bigint');
195
195
  }
196
- /** Whether a declarator is initialized from a `<x>.firestore()` call. */
196
+ /**
197
+ * Strips the wrappers that leave an expression's shape intact, assertions plus
198
+ * the `ChainExpression` an optional link parks on the outermost node of a chain.
199
+ *
200
+ * The two are kept apart rather than merged into `unwrapAssertions` because a
201
+ * chain is not erased at runtime: `admin?.firestore()` evaluates to the handle
202
+ * or to `undefined`, so a caller reasoning about the *value* an expression
203
+ * produces — `isPrimitiveLiteral` — must keep seeing the chain. A caller
204
+ * reasoning about the *shape* it is written in, which is what the evidence scan
205
+ * asks, must look through it: the optional link decides whether the handle is
206
+ * produced, never which instance it is.
207
+ */
208
+ function unwrapTransparent(node) {
209
+ const stripped = unwrapAssertions(node);
210
+ return stripped.type === utils_1.AST_NODE_TYPES.ChainExpression
211
+ ? unwrapTransparent(stripped.expression)
212
+ : stripped;
213
+ }
214
+ /**
215
+ * Whether a declarator is initialized from a `<x>.firestore()` call.
216
+ *
217
+ * Both optional spellings — `admin?.firestore()` and `admin.firestore?.()` —
218
+ * parse as `ChainExpression > CallExpression`, so testing the initializer's own
219
+ * type read `ChainExpression` and answered no. Since this scan is the last
220
+ * detector left for a bare-identifier receiver, that miss dropped the report
221
+ * silently, and it hit the more careful spellings hardest: `admin.apps[0]?.
222
+ * firestore()` and `admin.app()?.firestore()` are the idiomatic admin-SDK
223
+ * singleton bootstrap, not exotic code.
224
+ */
197
225
  function initializesFirestore(declarator) {
198
- const { init } = declarator;
226
+ const init = declarator.init ? unwrapTransparent(declarator.init) : null;
199
227
  return (init?.type === utils_1.AST_NODE_TYPES.CallExpression &&
200
228
  init.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
201
229
  init.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
@@ -66,6 +66,17 @@ function bindsFastDeepEqual(variable) {
66
66
  fastDeepEqualModules_1.FAST_DEEP_EQUAL_MODULES.has(String(declaration.source.value)));
67
67
  }));
68
68
  }
69
+ /**
70
+ * The expression an optional chain wraps. ESTree interposes a
71
+ * `ChainExpression` between an optional member/call and its real parent, so
72
+ * `changes?.length` reaches an operand test as a ChainExpression while
73
+ * `changes.length` reaches it as a MemberExpression. Every arm that inspects an
74
+ * operand has to unwrap first, or one spelling of a single idiom escapes the
75
+ * rule while the other is reported.
76
+ */
77
+ function unwrapChain(node) {
78
+ return node.type === utils_1.AST_NODE_TYPES.ChainExpression ? node.expression : node;
79
+ }
69
80
  /**
70
81
  * Whether two edits touch the same characters. ESLint sorts the fixes of one
71
82
  * report and asserts each starts at or after the end of the previous one, so
@@ -107,7 +118,6 @@ exports.fastDeepEqualOverMicrodiff = (0, createRule_1.createRule)({
107
118
  * violations still emit `isEqual(...)` calls, leaving them unbound.
108
119
  */
109
120
  const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
110
- const isChainExpression = (node) => node.type === utils_1.AST_NODE_TYPES.ChainExpression;
111
121
  function isMicrodiffCallee(callee) {
112
122
  if (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
113
123
  callee.name === microdiffImportName) {
@@ -265,11 +275,16 @@ exports.fastDeepEqualOverMicrodiff = (0, createRule_1.createRule)({
265
275
  '!=',
266
276
  ];
267
277
  if (operators.includes(node.operator)) {
278
+ // `diff(a, b)?.length`, `changes?.length` and `diff?.(a, b).length`
279
+ // each reach the operand as a ChainExpression, so the unwrap is what
280
+ // keeps the comparison spellings of one idiom from diverging.
281
+ const left = unwrapChain(node.left);
282
+ const right = unwrapChain(node.right);
268
283
  // side A: MemberExpression .length, side B: 0
269
- if (node.right.type === utils_1.AST_NODE_TYPES.Literal &&
270
- node.right.value === 0 &&
271
- node.left.type === utils_1.AST_NODE_TYPES.MemberExpression) {
272
- const { diffCall } = getMicrodiffCallFromLengthAccess(node.left);
284
+ if (right.type === utils_1.AST_NODE_TYPES.Literal &&
285
+ right.value === 0 &&
286
+ left.type === utils_1.AST_NODE_TYPES.MemberExpression) {
287
+ const { diffCall } = getMicrodiffCallFromLengthAccess(left);
273
288
  if (diffCall) {
274
289
  return {
275
290
  isEquality: node.operator === '===' || node.operator === '==',
@@ -278,10 +293,10 @@ exports.fastDeepEqualOverMicrodiff = (0, createRule_1.createRule)({
278
293
  }
279
294
  }
280
295
  // side A: 0, side B: MemberExpression .length
281
- if (node.left.type === utils_1.AST_NODE_TYPES.Literal &&
282
- node.left.value === 0 &&
283
- node.right.type === utils_1.AST_NODE_TYPES.MemberExpression) {
284
- const { diffCall } = getMicrodiffCallFromLengthAccess(node.right);
296
+ if (left.type === utils_1.AST_NODE_TYPES.Literal &&
297
+ left.value === 0 &&
298
+ right.type === utils_1.AST_NODE_TYPES.MemberExpression) {
299
+ const { diffCall } = getMicrodiffCallFromLengthAccess(right);
285
300
  if (diffCall) {
286
301
  return {
287
302
  isEquality: node.operator === '===' || node.operator === '==',
@@ -294,10 +309,7 @@ exports.fastDeepEqualOverMicrodiff = (0, createRule_1.createRule)({
294
309
  // Check for unary expressions like !diff(a, b).length or !changes.length (including optional chaining)
295
310
  if (node.type === utils_1.AST_NODE_TYPES.UnaryExpression &&
296
311
  node.operator === '!') {
297
- const argumentNode = node.argument;
298
- const target = isChainExpression(argumentNode)
299
- ? argumentNode.expression
300
- : argumentNode;
312
+ const target = unwrapChain(node.argument);
301
313
  if (target.type === utils_1.AST_NODE_TYPES.MemberExpression) {
302
314
  const { diffCall } = getMicrodiffCallFromLengthAccess(target);
303
315
  if (diffCall) {
@@ -315,13 +327,18 @@ exports.fastDeepEqualOverMicrodiff = (0, createRule_1.createRule)({
315
327
  * Try to find the identifier used as `<id>.length` for the given equality node
316
328
  */
317
329
  function getLengthIdentifierFromNode(node) {
330
+ // The operands are unwrapped for the same reason the detection arm
331
+ // unwraps them: `changes?.length` hides the identifier behind a
332
+ // ChainExpression. Missing it here does not silence the report — it
333
+ // rewrites the comparison and leaves the now-dead
334
+ // `const changes = diff(a, b);` behind.
335
+ const isLengthMember = (n) => n.type === utils_1.AST_NODE_TYPES.MemberExpression &&
336
+ !n.computed &&
337
+ n.property.type === utils_1.AST_NODE_TYPES.Identifier &&
338
+ n.property.name === 'length';
318
339
  if (node.type === utils_1.AST_NODE_TYPES.BinaryExpression) {
319
- const left = node.left;
320
- const right = node.right;
321
- const isLengthMember = (n) => n.type === utils_1.AST_NODE_TYPES.MemberExpression &&
322
- !n.computed &&
323
- n.property.type === utils_1.AST_NODE_TYPES.Identifier &&
324
- n.property.name === 'length';
340
+ const left = unwrapChain(node.left);
341
+ const right = unwrapChain(node.right);
325
342
  if (isLengthMember(left) &&
326
343
  left.object.type === utils_1.AST_NODE_TYPES.Identifier) {
327
344
  return left.object;
@@ -332,11 +349,8 @@ exports.fastDeepEqualOverMicrodiff = (0, createRule_1.createRule)({
332
349
  }
333
350
  }
334
351
  if (node.type === utils_1.AST_NODE_TYPES.UnaryExpression) {
335
- const arg = node.argument;
336
- if (arg.type === utils_1.AST_NODE_TYPES.MemberExpression &&
337
- !arg.computed &&
338
- arg.property.type === utils_1.AST_NODE_TYPES.Identifier &&
339
- arg.property.name === 'length' &&
352
+ const arg = unwrapChain(node.argument);
353
+ if (isLengthMember(arg) &&
340
354
  arg.object.type === utils_1.AST_NODE_TYPES.Identifier) {
341
355
  return arg.object;
342
356
  }
@@ -4,5 +4,6 @@ type Options = [
4
4
  functionPatterns?: string[];
5
5
  }
6
6
  ];
7
- export declare const noDirectFunctionState: TSESLint.RuleModule<"noDirectFunctionState", Options, TSESLint.RuleListener>;
7
+ type MessageIds = 'noDirectFunctionState' | 'noDirectFunctionStateAssertion';
8
+ export declare const noDirectFunctionState: TSESLint.RuleModule<MessageIds, Options, TSESLint.RuleListener>;
8
9
  export {};
@@ -104,13 +104,68 @@ function useStateHasFunctionTypeParam(callNode, resolveFrom) {
104
104
  }
105
105
  return isFunctionTypeAnnotation(typeParams.params[0], resolveFrom);
106
106
  }
107
+ /**
108
+ * The expression a runtime-transparent wrapper stands in for.
109
+ *
110
+ * `a?.b` parses as a `ChainExpression` around the member read, and `x as T`,
111
+ * `<T>x`, `x satisfies T`, `x!` and `fn<T>` each wrap their operand in a node
112
+ * that is erased before execution. Every one of them evaluates to exactly what
113
+ * its operand evaluates to, so a question about what a setter argument *is* has
114
+ * to be asked of the operand — asking the wrapper answers about the wrapper and
115
+ * silently loses the argument, in whichever direction the caller's default
116
+ * happens to point.
117
+ *
118
+ * Recursive because the wrappers stack: `props?.onClose as any` is a
119
+ * `TSAsExpression` over a `ChainExpression` over the member read.
120
+ */
121
+ function unwrapTransparent(node) {
122
+ switch (node.type) {
123
+ case utils_1.AST_NODE_TYPES.ChainExpression:
124
+ case utils_1.AST_NODE_TYPES.TSAsExpression:
125
+ case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
126
+ case utils_1.AST_NODE_TYPES.TSTypeAssertion:
127
+ case utils_1.AST_NODE_TYPES.TSNonNullExpression:
128
+ case utils_1.AST_NODE_TYPES.TSInstantiationExpression:
129
+ return unwrapTransparent(node.expression);
130
+ default:
131
+ return node;
132
+ }
133
+ }
134
+ /**
135
+ * Whether wrapping this argument in a thunk would leave an arrow whose body is
136
+ * a type assertion.
137
+ *
138
+ * `no-type-assertion-returns` is `error` in the same recommended config and
139
+ * reports exactly that shape, so emitting `setX(() => props.onClose as any)`
140
+ * would trade one error for another and leave `eslint --fix` non-converging.
141
+ * Moving the assertion outside the thunk instead — `(() => x) as T` — is not an
142
+ * option either: it asserts a different value, and for a `T` that is neither
143
+ * assignable to nor from `() => T` it does not even compile. So the report
144
+ * stands without a fix and names the hoist that does converge (verified end to
145
+ * end under the whole recommended config).
146
+ *
147
+ * Only a top-level assertion matters. An assertion nested inside the argument
148
+ * (`(props as any).onClose`) leaves the thunk returning a member read, which
149
+ * that rule exempts.
150
+ */
151
+ function thunkWouldReturnAssertion(arg) {
152
+ return (arg.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
153
+ arg.type === utils_1.AST_NODE_TYPES.TSTypeAssertion);
154
+ }
107
155
  /**
108
156
  * Returns true when the AST node is a safe value to pass to a setter — i.e.,
109
157
  * NOT a bare identifier or member expression that could be a function reference.
110
158
  * Arrow/function expressions are always safe (they are intentional).
111
159
  * Literals, null, undefined, call expressions, arrays, objects are all safe.
160
+ *
161
+ * The argument is unwrapped first so the carve-outs below are decided by what
162
+ * actually reaches the setter. Without it a wrapped argument falls to the
163
+ * `default` arm, which is the *unsafe* verdict — so `factory?.build()` would
164
+ * lose the deliberate CallExpression exemption and be rewritten into
165
+ * `() => factory?.build()`, deferring the call into a React updater.
112
166
  */
113
- function isDefinitelySafeArg(node) {
167
+ function isDefinitelySafeArg(argNode) {
168
+ const node = unwrapTransparent(argNode);
114
169
  switch (node.type) {
115
170
  case utils_1.AST_NODE_TYPES.ArrowFunctionExpression:
116
171
  case utils_1.AST_NODE_TYPES.FunctionExpression:
@@ -137,11 +192,6 @@ function isDefinitelySafeArg(node) {
137
192
  case utils_1.AST_NODE_TYPES.ArrayExpression:
138
193
  case utils_1.AST_NODE_TYPES.ObjectExpression:
139
194
  return true;
140
- case utils_1.AST_NODE_TYPES.TSAsExpression:
141
- case utils_1.AST_NODE_TYPES.TSTypeAssertion:
142
- case utils_1.AST_NODE_TYPES.TSNonNullExpression:
143
- // Unwrap type assertions and recurse
144
- return isDefinitelySafeArg(node.expression);
145
195
  default:
146
196
  // MemberExpression, Identifier (non-undefined), etc. are NOT definitely safe
147
197
  return false;
@@ -169,8 +219,14 @@ function matchesFunctionPattern(name, patterns) {
169
219
  * Extracts the identifier name from an argument node for pattern matching.
170
220
  * For MemberExpression like `obj.handler`, returns `handler`.
171
221
  * For Identifier like `myCallback`, returns `myCallback`.
222
+ *
223
+ * The argument is unwrapped first: `props?.onClose` and `props.onClose as any`
224
+ * name the same property as `props.onClose`. Under an untyped `useState` the
225
+ * name pattern is the only live signal, so returning `null` for a wrapped
226
+ * argument silences the rule entirely rather than merely weakening it.
172
227
  */
173
- function getArgName(node) {
228
+ function getArgName(argNode) {
229
+ const node = unwrapTransparent(argNode);
174
230
  if (node.type === utils_1.AST_NODE_TYPES.Identifier) {
175
231
  return node.name;
176
232
  }
@@ -236,6 +292,10 @@ exports.noDirectFunctionState = (0, createRule_1.createRule)({
236
292
  noDirectFunctionState: 'What\'s wrong: "{{argText}}" is passed directly to "{{setterName}}", but React invokes a function argument as a functional updater (prev => next) instead of storing it. ' +
237
293
  'Why it matters: The function will be called with the previous state value and its return value stored — a silent bug with no error. ' +
238
294
  'How to fix: Wrap it in a thunk so React stores the function as a value: {{setterName}}(() => {{argText}})',
295
+ noDirectFunctionStateAssertion: 'What\'s wrong: "{{argText}}" is passed directly to "{{setterName}}", but React invokes a function argument as a functional updater (prev => next) instead of storing it. ' +
296
+ 'Why it matters: The function will be called with the previous state value and its return value stored — a silent bug with no error. ' +
297
+ 'How to fix: Give the asserted value a name, then store that name through a thunk: const value = {{argText}}; {{setterName}}(() => value). ' +
298
+ 'The assertion is hoisted out because a thunk that returned it would be an arrow returning a cast, which no-type-assertion-returns reports.',
239
299
  },
240
300
  },
241
301
  defaultOptions: [{ functionPatterns: DEFAULT_FUNCTION_PATTERNS }],
@@ -252,12 +312,18 @@ exports.noDirectFunctionState = (0, createRule_1.createRule)({
252
312
  VariableDeclarator(node) {
253
313
  // Look for `const [state, setter] = useState<T>(...)` or
254
314
  // `const [state, setter] = React.useState<T>(...)`.
255
- if (node.id.type !== utils_1.AST_NODE_TYPES.ArrayPattern ||
256
- !node.init ||
257
- node.init.type !== utils_1.AST_NODE_TYPES.CallExpression) {
315
+ if (node.id.type !== utils_1.AST_NODE_TYPES.ArrayPattern || !node.init) {
258
316
  return;
259
317
  }
260
- const callNode = node.init;
318
+ // `React?.useState(...)` and `useState?.(...)` wrap the call in a
319
+ // ChainExpression. Reading `init` without unwrapping registers no
320
+ // setter, which blinds every setter call in the file rather than just
321
+ // this declaration.
322
+ const init = unwrapTransparent(node.init);
323
+ if (init.type !== utils_1.AST_NODE_TYPES.CallExpression) {
324
+ return;
325
+ }
326
+ const callNode = init;
261
327
  const callee = callNode.callee;
262
328
  const isUseStateCall = (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
263
329
  callee.name === 'useState') ||
@@ -304,7 +370,8 @@ exports.noDirectFunctionState = (0, createRule_1.createRule)({
304
370
  // skip without further checks
305
371
  if (isDefinitelySafeArg(arg))
306
372
  return;
307
- // At this point arg is an Identifier (non-undefined) or MemberExpression.
373
+ // At this point arg is an Identifier (non-undefined) or MemberExpression,
374
+ // possibly behind transparent wrappers (`?.`, `as`, `!`).
308
375
  // Decide whether it is a function reference.
309
376
  const isFunctionTypedState = setterFunctionTyped.get(setterName) === true;
310
377
  if (isFunctionTypedState) {
@@ -320,11 +387,14 @@ exports.noDirectFunctionState = (0, createRule_1.createRule)({
320
387
  reportAndFix(node, arg, setterName, context);
321
388
  return;
322
389
  }
323
- // Check if the identifier is bound to a function in scope
324
- if (arg.type === utils_1.AST_NODE_TYPES.Identifier &&
325
- arg.name !== 'undefined') {
390
+ // Check if the identifier is bound to a function in scope. This reads
391
+ // the same argument the two signals above do, so it has to see through
392
+ // the same wrappers: `myCallback!` still references `myCallback`.
393
+ const unwrappedArg = unwrapTransparent(arg);
394
+ if (unwrappedArg.type === utils_1.AST_NODE_TYPES.Identifier &&
395
+ unwrappedArg.name !== 'undefined') {
326
396
  const scope = context.getScope();
327
- if (isIdentifierBoundToFunction(arg.name, scope)) {
397
+ if (isIdentifierBoundToFunction(unwrappedArg.name, scope)) {
328
398
  reportAndFix(node, arg, setterName, context);
329
399
  return;
330
400
  }
@@ -336,14 +406,20 @@ exports.noDirectFunctionState = (0, createRule_1.createRule)({
336
406
  function reportAndFix(callNode, arg, setterName, context) {
337
407
  const sourceCode = context.getSourceCode();
338
408
  const argText = sourceCode.getText(arg);
409
+ const returnsAssertion = thunkWouldReturnAssertion(arg);
339
410
  context.report({
340
411
  node: callNode,
341
- messageId: 'noDirectFunctionState',
412
+ messageId: returnsAssertion
413
+ ? 'noDirectFunctionStateAssertion'
414
+ : 'noDirectFunctionState',
342
415
  data: {
343
416
  argText,
344
417
  setterName,
345
418
  },
346
419
  fix(fixer) {
420
+ if (returnsAssertion) {
421
+ return null;
422
+ }
347
423
  return fixer.replaceText(arg, `() => ${argText}`);
348
424
  },
349
425
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.126",
3
+ "version": "1.20.127",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -72,6 +72,7 @@
72
72
  "@semantic-release/release-notes-generator": "14.1.0",
73
73
  "@types/eslint": "8.37.0",
74
74
  "@types/jest": "29.5.14",
75
+ "@types/js-yaml": "4.0.9",
75
76
  "@types/node": "22.20.0",
76
77
  "@types/semver": "7.5.8",
77
78
  "@typescript-eslint/eslint-plugin": "5.34.0",
@@ -96,6 +97,7 @@
96
97
  "husky": "9.1.7",
97
98
  "jest": "29.7.0",
98
99
  "jest-junit": "14.0.0",
100
+ "js-yaml": "4.3.1",
99
101
  "jsonc-eslint-parser": "2.3.0",
100
102
  "markdown-eslint-parser": "1.2.1",
101
103
  "npm-run-all": "4.1.5",
@@ -1,4 +1,50 @@
1
1
  [
2
+ {
3
+ "version": "1.20.127",
4
+ "date": "2026-08-07T02:31:31.437Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-boolean-naming-prefixes",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1828
11
+ ],
12
+ "summary": "read an optional-chained initializer as boolean (closes #1828)"
13
+ },
14
+ {
15
+ "name": "enforce-firestore-doc-ref-generic",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1826
19
+ ],
20
+ "summary": "resolve a typed collection through an optional link (closes #1826)"
21
+ },
22
+ {
23
+ "name": "enforce-firestore-set-merge",
24
+ "changeType": "fix",
25
+ "issues": [
26
+ 1827
27
+ ],
28
+ "summary": "see a Firestore handle through an optional link (closes #1827)"
29
+ },
30
+ {
31
+ "name": "fast-deep-equal-over-microdiff",
32
+ "changeType": "fix",
33
+ "issues": [
34
+ 1825
35
+ ],
36
+ "summary": "unwrap ChainExpression in the binary comparison arm (closes #1825)"
37
+ },
38
+ {
39
+ "name": "no-direct-function-state",
40
+ "changeType": "fix",
41
+ "issues": [
42
+ 1824
43
+ ],
44
+ "summary": "read the setter argument through transparent wrappers (closes #1824)"
45
+ }
46
+ ]
47
+ },
2
48
  {
3
49
  "version": "1.20.126",
4
50
  "date": "2026-08-06T22:42:22.372Z",