@blumintinc/eslint-plugin-blumint 1.20.126 → 1.20.128

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.128',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -129,6 +129,10 @@ function isInsideMockFactory(node) {
129
129
  *
130
130
  * The peel repeats because the wrappers nest: `(x as any)!` is a non-null
131
131
  * assertion over a type assertion.
132
+ *
133
+ * Everything peeled here erases before the code runs, which is why the peel is
134
+ * unconditional. An optional chain does not, so it is handled apart from these
135
+ * — see `unwrapOptionalChain`.
132
136
  */
133
137
  function unwrapKeyExpression(node) {
134
138
  let current = node;
@@ -148,6 +152,43 @@ function unwrapKeyExpression(node) {
148
152
  }
149
153
  }
150
154
  }
155
+ /**
156
+ * Reads through an optional chain to the member access or call it holds.
157
+ * `source?.key` parses as a `ChainExpression` wrapping the member expression,
158
+ * so a classification that matches a bare `MemberExpression` — or a numeric
159
+ * proof that matches `.length` — sees the wrapper and recognizes nothing.
160
+ *
161
+ * Kept apart from `unwrapKeyExpression` rather than folded into it because the
162
+ * two make different claims. Those wrappers are gone before the code runs; `?.`
163
+ * survives and short-circuits, so it is read through only where the question is
164
+ * "what value names this property", never where the question is what the
165
+ * expression does. That value is what the chain evaluates to, `undefined`
166
+ * included — and the chain guards a nullish RECEIVER, not a hostile KEY:
167
+ * `"__proto__"` is a perfectly non-nullish string, so `store[req.body?.key]`
168
+ * reaches the prototype surface exactly as `store[req.body.key]` does.
169
+ */
170
+ function unwrapOptionalChain(node) {
171
+ return node.type === utils_1.AST_NODE_TYPES.ChainExpression ? node.expression : node;
172
+ }
173
+ /**
174
+ * The key expression stripped of every wrapper standing between it and the
175
+ * value that names the property: the compile-time assertions and the `await`
176
+ * that `unwrapKeyExpression` peels, plus an optional chain.
177
+ *
178
+ * The peel repeats because the two kinds nest in either order — `source?.key as
179
+ * string` is an assertion over a chain, `(source as Raw)?.key` a chain over an
180
+ * assertion.
181
+ */
182
+ function unwrapWrittenKey(node) {
183
+ let current = node;
184
+ for (;;) {
185
+ const peeled = unwrapOptionalChain(unwrapKeyExpression(current));
186
+ if (peeled === current) {
187
+ return current;
188
+ }
189
+ current = peeled;
190
+ }
191
+ }
151
192
  /** Names that read as a positional sequence rather than a keyed record. */
152
193
  const ARRAY_LIKE_NAME = /^(array|arr|items|elements|list|collection|data)s?$/i;
153
194
  /**
@@ -534,7 +575,8 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
534
575
  },
535
576
  });
536
577
  /**
537
- * Reports a key whose written form may carry assertion or await wrappers.
578
+ * Reports a key whose written form may carry assertion or await wrappers or
579
+ * an optional chain.
538
580
  *
539
581
  * The report and the fix sit on the outermost written node, so the wrapper
540
582
  * the author put there survives the rewrite: `m[assertSafe(k as string)]`
@@ -542,7 +584,10 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
542
584
  * own. `assertSafe` is identity-typed (`<T extends PropertyKey>(key: T): T`),
543
585
  * so wrapping the asserted expression preserves the key's type, and wrapping
544
586
  * an `await` keeps the validation on the resolved key rather than moving it
545
- * onto the promise.
587
+ * onto the promise. Wrapping the whole chain is what keeps the short-circuit
588
+ * intact: `m[assertSafe(source?.key)]` evaluates `source?.key` once, in the
589
+ * position the author wrote it, and hands assertSafe what it produces — the
590
+ * rewrite adds a validation, it does not move a dereference.
546
591
  *
547
592
  * A key written without a wrapper keeps the narrower argument the fix has
548
593
  * always emitted: `String(id)` and `` `${id}` `` collapse to `id`, whose
@@ -563,8 +608,11 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
563
608
  if (!variable)
564
609
  return false;
565
610
  return variable.defs.some((def) => {
566
- const init = def.node.type === utils_1.AST_NODE_TYPES.VariableDeclarator
567
- ? def.node.init
611
+ // `assertSafe?.(rawKey)` produces the very same validated key as
612
+ // `assertSafe(rawKey)` — the chain guards only a nullish callee — so
613
+ // the exemption reads through it rather than re-reporting the binding.
614
+ const init = def.node.type === utils_1.AST_NODE_TYPES.VariableDeclarator && def.node.init
615
+ ? unwrapOptionalChain(def.node.init)
568
616
  : null;
569
617
  return (!!init &&
570
618
  init.type === utils_1.AST_NODE_TYPES.CallExpression &&
@@ -584,8 +632,11 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
584
632
  // An assertion or an await around an operand leaves its run-time value
585
633
  // alone, so the proof reads through to what the wrapper holds. The
586
634
  // annotation on the binding underneath is what proves the key numeric —
587
- // an assertion asserts and proves nothing on its own.
588
- const target = unwrapKeyExpression(node);
635
+ // an assertion asserts and proves nothing on its own. An optional chain
636
+ // is read through as well: `xs?.length` is the same `.length` proof, and
637
+ // its short-circuit yields `undefined`, which stringifies to "undefined"
638
+ // and so still names no field of the prototype surface.
639
+ const target = unwrapWrittenKey(node);
589
640
  switch (target.type) {
590
641
  case utils_1.AST_NODE_TYPES.Literal:
591
642
  return typeof target.value === 'number';
@@ -662,7 +713,7 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
662
713
  Property(node) {
663
714
  if (node.computed && node.key) {
664
715
  const written = node.key;
665
- const key = unwrapKeyExpression(written);
716
+ const key = unwrapWrittenKey(written);
666
717
  // Check for String(id) pattern
667
718
  if (key.type === utils_1.AST_NODE_TYPES.CallExpression &&
668
719
  key.callee.type === utils_1.AST_NODE_TYPES.Identifier &&
@@ -687,7 +738,7 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
687
738
  BinaryExpression(node) {
688
739
  if (node.operator === 'in') {
689
740
  const written = node.left;
690
- const left = unwrapKeyExpression(written);
741
+ const left = unwrapWrittenKey(written);
691
742
  // Check for String(id) pattern
692
743
  if (left.type === utils_1.AST_NODE_TYPES.CallExpression &&
693
744
  left.callee.type === utils_1.AST_NODE_TYPES.Identifier &&
@@ -712,9 +763,9 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
712
763
  if (node.computed) {
713
764
  const written = node.property;
714
765
  // The written key may sit under assertion or await wrappers that erase
715
- // at run time; what they hold is what names the property, so that is
716
- // what the branches below classify.
717
- const property = unwrapKeyExpression(written);
766
+ // at run time, or under an optional chain; what they hold is what
767
+ // names the property, so that is what the branches below classify.
768
+ const property = unwrapWrittenKey(written);
718
769
  // Skip if already using assertSafe
719
770
  if (property.type === utils_1.AST_NODE_TYPES.CallExpression &&
720
771
  property.callee.type === utils_1.AST_NODE_TYPES.Identifier &&
@@ -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 &&
@@ -377,6 +377,16 @@ exports.enforceQueryKeyTs = (0, createRule_1.createRule)({
377
377
  * Check if a node represents a valid query key usage
378
378
  */
379
379
  function isValidQueryKeyUsage(node) {
380
+ // `config?.getQueryKey()` parses as a `ChainExpression` wrapping the call,
381
+ // a type this switch does not name — so the optional spelling alone fell
382
+ // through to `return false` and bypassed the carve-outs below, reporting a
383
+ // key the plain spelling is allowed to build (#1832). Optionality is
384
+ // orthogonal to what this function asks: the question is where the key
385
+ // comes from, and a short-circuit changes only whether the same source is
386
+ // evaluated, never which source it is.
387
+ if (node.type === utils_1.AST_NODE_TYPES.ChainExpression) {
388
+ return isValidQueryKeyUsage(node.expression);
389
+ }
380
390
  if (node.type === utils_1.AST_NODE_TYPES.Identifier) {
381
391
  const importInfo = queryKeyImports.get(node.name);
382
392
  if (importInfo && isQueryKeysSource(importInfo.source)) {
@@ -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 {};