@blumintinc/eslint-plugin-blumint 1.20.70 → 1.20.72

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.70',
226
+ version: '1.20.72',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -277,23 +277,53 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
277
277
  member.key.type === utils_1.AST_NODE_TYPES.Identifier &&
278
278
  member.key.name === property.name);
279
279
  if (getter) {
280
- // Check explicit return type
281
- if (getter.value.returnType) {
282
- return hasCollectionReferenceType(getter.value.returnType.typeAnnotation);
283
- }
284
- // Check return statement to infer type
285
- if (getter.value.body &&
286
- getter.value.body.type === utils_1.AST_NODE_TYPES.BlockStatement) {
287
- const returnStmt = getter.value.body.body.find((stmt) => stmt.type === utils_1.AST_NODE_TYPES.ReturnStatement);
288
- if (returnStmt?.argument?.type === utils_1.AST_NODE_TYPES.MemberExpression) {
289
- return checkMemberExpressionForCollectionReference(returnStmt.argument);
290
- }
291
- }
280
+ return yieldsTypedCollectionReference(getter.value);
292
281
  }
293
282
  }
294
283
  }
295
284
  return false;
296
285
  }
286
+ /**
287
+ * Guards the return-expression inference below against a class whose
288
+ * members refer to one another, such as `get a() { return this.a; }` or a
289
+ * pair of mutually recursive methods. Following the expression is otherwise
290
+ * unbounded, and a cycle is legal input that must terminate rather than
291
+ * exhaust the stack.
292
+ */
293
+ const inferenceInProgress = new Set();
294
+ /**
295
+ * Reports whether a class member provably hands back a typed
296
+ * CollectionReference.
297
+ *
298
+ * The explicit return annotation is authoritative where it exists, but it
299
+ * cannot be the only evidence read: `no-explicit-return-type` ships in the
300
+ * same recommended config and is fixable, so a single `eslint --fix` pass
301
+ * deletes it. The schema the annotation described still lives in the
302
+ * expression the member returns, and that expression is what the fixer
303
+ * leaves behind, so it is read as the fallback.
304
+ */
305
+ function yieldsTypedCollectionReference(fn) {
306
+ if (fn.returnType) {
307
+ return hasCollectionReferenceType(fn.returnType.typeAnnotation);
308
+ }
309
+ if (!fn.body || inferenceInProgress.has(fn)) {
310
+ return false;
311
+ }
312
+ inferenceInProgress.add(fn);
313
+ try {
314
+ if (fn.body.type !== utils_1.AST_NODE_TYPES.BlockStatement) {
315
+ return isTypedCollectionReference(fn.body);
316
+ }
317
+ // Only top-level returns are read; a return nested inside another
318
+ // function belongs to that one.
319
+ return fn.body.body.some((statement) => statement.type === utils_1.AST_NODE_TYPES.ReturnStatement &&
320
+ !!statement.argument &&
321
+ isTypedCollectionReference(statement.argument));
322
+ }
323
+ finally {
324
+ inferenceInProgress.delete(fn);
325
+ }
326
+ }
297
327
  function checkIdentifierForCollectionReference(node) {
298
328
  // Check function parameters
299
329
  const functionParam = findFunctionParameter(node);
@@ -374,12 +404,9 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
374
404
  if (obj.type === utils_1.AST_NODE_TYPES.ThisExpression) {
375
405
  const classNode = findParentClass(node);
376
406
  if (classNode) {
377
- const method = classNode.body.body.find((member) => member.type === utils_1.AST_NODE_TYPES.MethodDefinition &&
378
- member.key.type === utils_1.AST_NODE_TYPES.Identifier &&
379
- member.key.name === property.name &&
380
- !!member.value.returnType);
381
- if (method?.value.returnType) {
382
- return hasCollectionReferenceType(method.value.returnType.typeAnnotation);
407
+ const callee = findClassCallable(classNode, property.name);
408
+ if (callee) {
409
+ return yieldsTypedCollectionReference(callee);
383
410
  }
384
411
  }
385
412
  }
@@ -387,6 +414,37 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
387
414
  }
388
415
  return false;
389
416
  }
417
+ /**
418
+ * Resolves `this.<name>()` to the function the call runs, covering both a
419
+ * method declaration and a property holding a function expression. The
420
+ * member is matched by name alone: requiring a return annotation here would
421
+ * make the resolution disappear the moment `no-explicit-return-type`
422
+ * strips it, even though the returned expression is unchanged.
423
+ */
424
+ function findClassCallable(classNode, name) {
425
+ for (const member of classNode.body.body) {
426
+ if (member.type === utils_1.AST_NODE_TYPES.MethodDefinition &&
427
+ member.kind === 'method' &&
428
+ member.key.type === utils_1.AST_NODE_TYPES.Identifier &&
429
+ member.key.name === name) {
430
+ return member.value;
431
+ }
432
+ if (member.type === utils_1.AST_NODE_TYPES.PropertyDefinition &&
433
+ member.key.type === utils_1.AST_NODE_TYPES.Identifier &&
434
+ member.key.name === name) {
435
+ // An annotated property is already resolved by the member-expression
436
+ // path, which reads the annotation rather than the initializer.
437
+ if (member.typeAnnotation || !member.value) {
438
+ return undefined;
439
+ }
440
+ const initializer = member.value;
441
+ const isFunction = initializer.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
442
+ initializer.type === utils_1.AST_NODE_TYPES.FunctionExpression;
443
+ return isFunction ? initializer : undefined;
444
+ }
445
+ }
446
+ return undefined;
447
+ }
390
448
  function getTypeOfMemberExpression(node) {
391
449
  if (node.object.type === utils_1.AST_NODE_TYPES.ThisExpression &&
392
450
  node.property.type === utils_1.AST_NODE_TYPES.Identifier) {
@@ -3767,6 +3767,154 @@ const ALLOWLIST = {
3767
3767
  'zoom',
3768
3768
  ]),
3769
3769
  };
3770
+ /** Higher-order components that only ever wrap another component. */
3771
+ const COMPONENT_WRAPPERS = new Set(['memo', 'forwardRef']);
3772
+ /** Element factories a component uses when it renders without JSX syntax. */
3773
+ const ELEMENT_FACTORIES = new Set(['createElement', 'cloneElement']);
3774
+ /** A hook call is named `useX`; a PascalCase caller of one is a component. */
3775
+ const HOOK_CALL = /^use[A-Z]/;
3776
+ /**
3777
+ * A nested function owns its own returns and its own hook calls, so evidence
3778
+ * gathered about the enclosing function must stop at its boundary.
3779
+ */
3780
+ const FUNCTION_LIKE_TYPES = new Set([
3781
+ utils_1.AST_NODE_TYPES.FunctionDeclaration,
3782
+ utils_1.AST_NODE_TYPES.FunctionExpression,
3783
+ utils_1.AST_NODE_TYPES.ArrowFunctionExpression,
3784
+ ]);
3785
+ /**
3786
+ * Visits every descendant of `node` that belongs to the same function scope.
3787
+ * Nested functions are handed to `visit` but not descended into.
3788
+ */
3789
+ function forEachNodeInOwnScope(node, visit) {
3790
+ for (const key of Object.keys(node)) {
3791
+ if (key === 'parent') {
3792
+ continue;
3793
+ }
3794
+ const value = node[key];
3795
+ const children = Array.isArray(value) ? value : [value];
3796
+ for (const child of children) {
3797
+ if (!ASTHelpers_1.ASTHelpers.isNode(child)) {
3798
+ continue;
3799
+ }
3800
+ visit(child);
3801
+ if (!FUNCTION_LIKE_TYPES.has(child.type)) {
3802
+ forEachNodeInOwnScope(child, visit);
3803
+ }
3804
+ }
3805
+ }
3806
+ }
3807
+ /**
3808
+ * The name of a call to `name(...)` or `React.name(...)`. Member calls are
3809
+ * confined to the React namespace so `document.createElement(...)` — an ordinary
3810
+ * DOM call — is not mistaken for a render.
3811
+ */
3812
+ function calleeName(callee) {
3813
+ if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
3814
+ return callee.name;
3815
+ }
3816
+ if (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
3817
+ !callee.computed &&
3818
+ callee.object.type === utils_1.AST_NODE_TYPES.Identifier &&
3819
+ callee.object.name === 'React' &&
3820
+ callee.property.type === utils_1.AST_NODE_TYPES.Identifier) {
3821
+ return callee.property.name;
3822
+ }
3823
+ return undefined;
3824
+ }
3825
+ /** A generator yields values rather than rendering, so it is never a component. */
3826
+ function isGeneratorFunction(node) {
3827
+ return node.type !== utils_1.AST_NODE_TYPES.ArrowFunctionExpression && node.generator;
3828
+ }
3829
+ /**
3830
+ * Whether a returned expression is something React renders. `null`/`undefined`
3831
+ * are the "render nothing" case a component reaches through an early return,
3832
+ * and `createElement` is how a component renders from a file that cannot hold
3833
+ * JSX syntax.
3834
+ */
3835
+ function isRenderableValue(value) {
3836
+ if (!value) {
3837
+ return false;
3838
+ }
3839
+ switch (value.type) {
3840
+ case utils_1.AST_NODE_TYPES.Literal:
3841
+ // Matched on `raw` because a regex literal an environment cannot compile
3842
+ // also carries `value === null`.
3843
+ return value.raw === 'null';
3844
+ case utils_1.AST_NODE_TYPES.Identifier:
3845
+ return value.name === 'undefined';
3846
+ case utils_1.AST_NODE_TYPES.JSXElement:
3847
+ case utils_1.AST_NODE_TYPES.JSXFragment:
3848
+ return true;
3849
+ case utils_1.AST_NODE_TYPES.CallExpression: {
3850
+ const name = calleeName(value.callee);
3851
+ return !!name && ELEMENT_FACTORIES.has(name);
3852
+ }
3853
+ case utils_1.AST_NODE_TYPES.TSAsExpression:
3854
+ case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
3855
+ case utils_1.AST_NODE_TYPES.TSNonNullExpression:
3856
+ case utils_1.AST_NODE_TYPES.TSTypeAssertion:
3857
+ return isRenderableValue(value.expression);
3858
+ case utils_1.AST_NODE_TYPES.ConditionalExpression:
3859
+ // Every branch has to render, otherwise `cond ? value : null` — an
3860
+ // ordinary lookup-with-fallback — would read as a component.
3861
+ return (isRenderableValue(value.consequent) &&
3862
+ isRenderableValue(value.alternate));
3863
+ default:
3864
+ return false;
3865
+ }
3866
+ }
3867
+ /**
3868
+ * Whether every value the function returns is renderable, and it returns at
3869
+ * least once. Demanding *every* return keeps a helper that merely falls back to
3870
+ * `null` on one branch out of the component exemption, and demanding one return
3871
+ * keeps a function that returns nothing at all out of it.
3872
+ */
3873
+ function rendersEveryReturn(node) {
3874
+ if (node.body.type !== utils_1.AST_NODE_TYPES.BlockStatement) {
3875
+ return isRenderableValue(node.body);
3876
+ }
3877
+ const returned = [];
3878
+ forEachNodeInOwnScope(node.body, (child) => {
3879
+ if (child.type === utils_1.AST_NODE_TYPES.ReturnStatement) {
3880
+ returned.push(child.argument);
3881
+ }
3882
+ });
3883
+ return returned.length > 0 && returned.every(isRenderableValue);
3884
+ }
3885
+ function callsReactHook(node) {
3886
+ let found = false;
3887
+ forEachNodeInOwnScope(node.body, (child) => {
3888
+ if (found || child.type !== utils_1.AST_NODE_TYPES.CallExpression) {
3889
+ return;
3890
+ }
3891
+ const name = calleeName(child.callee);
3892
+ found = !!name && HOOK_CALL.test(name);
3893
+ });
3894
+ return found;
3895
+ }
3896
+ /**
3897
+ * Whether a reference to the function names it as a component: rendered as a
3898
+ * JSX element, or handed to a higher-order component.
3899
+ */
3900
+ function isComponentReference(identifier) {
3901
+ const parent = identifier.parent;
3902
+ if (!parent) {
3903
+ return false;
3904
+ }
3905
+ if (parent.type === utils_1.AST_NODE_TYPES.JSXOpeningElement ||
3906
+ parent.type === utils_1.AST_NODE_TYPES.JSXClosingElement ||
3907
+ parent.type === utils_1.AST_NODE_TYPES.JSXMemberExpression) {
3908
+ return true;
3909
+ }
3910
+ if (parent.type === utils_1.AST_NODE_TYPES.CallExpression) {
3911
+ const name = calleeName(parent.callee);
3912
+ return (!!name &&
3913
+ COMPONENT_WRAPPERS.has(name) &&
3914
+ parent.arguments.some((argument) => argument === identifier));
3915
+ }
3916
+ return false;
3917
+ }
3770
3918
  exports.enforceVerbNounNaming = (0, createRule_1.createRule)({
3771
3919
  name: 'enforce-verb-noun-naming',
3772
3920
  meta: {
@@ -3960,6 +4108,19 @@ exports.enforceVerbNounNaming = (0, createRule_1.createRule)({
3960
4108
  }
3961
4109
  return false;
3962
4110
  }
4111
+ /**
4112
+ * Whether the file names the function as a component somewhere other than
4113
+ * its declaration — `<MyComponent />`, `memo(MyComponent)`. Resolved through
4114
+ * scope analysis, which records JSX element names as references.
4115
+ */
4116
+ function isUsedAsReactComponent(node, functionName) {
4117
+ const scope = ASTHelpers_1.ASTHelpers.getScope(context, node);
4118
+ const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, functionName);
4119
+ if (!variable) {
4120
+ return false;
4121
+ }
4122
+ return variable.references.some((reference) => isComponentReference(reference.identifier));
4123
+ }
3963
4124
  function isReactComponent(node) {
3964
4125
  if (node.type !== utils_1.AST_NODE_TYPES.FunctionDeclaration &&
3965
4126
  node.type !== utils_1.AST_NODE_TYPES.ArrowFunctionExpression &&
@@ -3985,6 +4146,18 @@ exports.enforceVerbNounNaming = (0, createRule_1.createRule)({
3985
4146
  if (isJsxFile || returnsJsx || hasReactType) {
3986
4147
  return true;
3987
4148
  }
4149
+ // The annotation cannot be the only evidence a `.ts` file offers, because
4150
+ // `no-explicit-return-type --fix` — shipped in the same recommended config
4151
+ // — deletes it, leaving a component indistinguishable from a helper and
4152
+ // demanding a rename that would break every JSX call site. A component
4153
+ // is therefore also recognised by what it renders, by the hooks it calls,
4154
+ // and by how the rest of the file uses it.
4155
+ if (!isGeneratorFunction(node) &&
4156
+ (rendersEveryReturn(node) ||
4157
+ callsReactHook(node) ||
4158
+ isUsedAsReactComponent(node, functionName))) {
4159
+ return true;
4160
+ }
3988
4161
  }
3989
4162
  // If we have explicit React type, still treat it as a component to avoid false naming violations.
3990
4163
  if (hasReactType) {
@@ -34,16 +34,22 @@ function describeClassMethod(node) {
34
34
  return 'class method';
35
35
  }
36
36
  function describeMethodSignature(node) {
37
+ // A method signature is equally non-inferable in either container, but naming
38
+ // the wrong one sends the reader looking for an `interface` keyword that the
39
+ // source does not contain.
40
+ const kind = node.parent?.type === utils_1.AST_NODE_TYPES.TSTypeLiteral
41
+ ? 'type literal method'
42
+ : 'interface method';
37
43
  if (!node.computed &&
38
44
  (node.key.type === utils_1.AST_NODE_TYPES.Identifier ||
39
45
  (node.key.type === utils_1.AST_NODE_TYPES.Literal &&
40
46
  typeof node.key.value === 'string'))) {
41
47
  const name = getNameFromIdentifierOrLiteral(node.key);
42
48
  if (name) {
43
- return `interface method "${name}"`;
49
+ return `${kind} "${name}"`;
44
50
  }
45
51
  }
46
- return 'interface method';
52
+ return kind;
47
53
  }
48
54
  function describeFunctionDeclaration(node) {
49
55
  if (node.id?.name) {
@@ -449,12 +455,29 @@ function participatesInReturnCycle(name, graph) {
449
455
  }
450
456
  return false;
451
457
  }
458
+ /**
459
+ * The sibling members of a method signature's container. An interface body and a
460
+ * type literal declare exactly the same members — `interface X { f(): void }` and
461
+ * `type X = { f(): void }` differ only in the keyword that introduces them, and
462
+ * `prefer-type-over-interface` (also fixable, also in the recommended config)
463
+ * rewrites the first into the second. A member's inferability cannot depend on
464
+ * which keyword declared its container, so both are read here.
465
+ */
466
+ function signatureContainerMembers(container) {
467
+ if (container?.type === utils_1.AST_NODE_TYPES.TSInterfaceBody) {
468
+ return container.body;
469
+ }
470
+ if (container?.type === utils_1.AST_NODE_TYPES.TSTypeLiteral) {
471
+ return container.members;
472
+ }
473
+ return undefined;
474
+ }
452
475
  function isOverloadedFunction(node) {
453
476
  if (!node.parent)
454
477
  return false;
455
478
  if (node.type === utils_1.AST_NODE_TYPES.TSMethodSignature) {
456
- const interfaceBody = node.parent;
457
- if (interfaceBody.type !== utils_1.AST_NODE_TYPES.TSInterfaceBody)
479
+ const members = signatureContainerMembers(node.parent);
480
+ if (!members)
458
481
  return false;
459
482
  if (node.computed)
460
483
  return false;
@@ -464,7 +487,7 @@ function isOverloadedFunction(node) {
464
487
  : undefined;
465
488
  if (!methodName)
466
489
  return false;
467
- return (interfaceBody.body.filter((member) => member.type === utils_1.AST_NODE_TYPES.TSMethodSignature &&
490
+ return (members.filter((member) => member.type === utils_1.AST_NODE_TYPES.TSMethodSignature &&
468
491
  !member.computed &&
469
492
  (member.key.type === utils_1.AST_NODE_TYPES.Identifier ||
470
493
  member.key.type === utils_1.AST_NODE_TYPES.Literal) &&
@@ -43,6 +43,42 @@ const isParenthesizedType = (node) => {
43
43
  return (candidate.type === 'TSParenthesizedType' &&
44
44
  candidate.typeAnnotation !== undefined);
45
45
  };
46
+ const unwrapParenthesizedTypeNode = (node) => {
47
+ let current = node;
48
+ // Cap iterations for the same reason as unwrapArrayElementType: wrappers are
49
+ // finite, but a future wrapper case must not be able to loop forever.
50
+ for (let i = 0; i < 10; i++) {
51
+ if (isParenthesizedType(current)) {
52
+ current = current.typeAnnotation;
53
+ continue;
54
+ }
55
+ break;
56
+ }
57
+ return current;
58
+ };
59
+ // Assertion wrappers never change the runtime value, so `[...] as const` and
60
+ // `[...] as const satisfies readonly string[]` both still describe an array.
61
+ const EXPRESSION_ASSERTION_TYPES = new Set([
62
+ utils_1.AST_NODE_TYPES.TSAsExpression,
63
+ utils_1.AST_NODE_TYPES.TSNonNullExpression,
64
+ utils_1.AST_NODE_TYPES.TSTypeAssertion,
65
+ 'TSSatisfiesExpression',
66
+ ]);
67
+ const unwrapExpressionAssertions = (node) => {
68
+ let current = node;
69
+ for (let i = 0; i < 10; i++) {
70
+ if (EXPRESSION_ASSERTION_TYPES.has(current.type)) {
71
+ const inner = current
72
+ .expression;
73
+ if (!inner)
74
+ break;
75
+ current = inner;
76
+ continue;
77
+ }
78
+ break;
79
+ }
80
+ return current;
81
+ };
46
82
  const unwrapArrayElementType = (node) => {
47
83
  let current = node;
48
84
  // Fixpoint loop: peel wrappers in any order until none remain
@@ -181,6 +217,7 @@ exports.noFirestoreObjectArrays = (0, createRule_1.createRule)({
181
217
  const aliasNameToType = new Map();
182
218
  const interfaceNames = new Set();
183
219
  const enumNames = new Set();
220
+ const constArrayNameToLiteral = new Map();
184
221
  const visitNode = (n) => {
185
222
  switch (n.type) {
186
223
  case utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration: {
@@ -195,6 +232,22 @@ exports.noFirestoreObjectArrays = (0, createRule_1.createRule)({
195
232
  enumNames.add(n.id.name);
196
233
  break;
197
234
  }
235
+ case utils_1.AST_NODE_TYPES.VariableDeclaration: {
236
+ // Only `const` bindings can back a `(typeof X)[number]` element union
237
+ if (n.kind !== 'const')
238
+ break;
239
+ for (const declarator of n.declarations) {
240
+ if (declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier ||
241
+ !declarator.init) {
242
+ continue;
243
+ }
244
+ const init = unwrapExpressionAssertions(declarator.init);
245
+ if (init.type === utils_1.AST_NODE_TYPES.ArrayExpression) {
246
+ constArrayNameToLiteral.set(declarator.id.name, init);
247
+ }
248
+ }
249
+ break;
250
+ }
198
251
  case utils_1.AST_NODE_TYPES.ExportNamedDeclaration: {
199
252
  if (n.declaration)
200
253
  visitNode(n.declaration);
@@ -235,6 +288,85 @@ exports.noFirestoreObjectArrays = (0, createRule_1.createRule)({
235
288
  }
236
289
  const seenAlias = new Set();
237
290
  const visitingAliases = new Set();
291
+ const isPrimitiveLiteralElement = (element, visitedConstArrays) => {
292
+ // Array holes resolve to `undefined`, but the shape is unusual enough
293
+ // that refusing to classify it keeps the narrowing conservative.
294
+ if (!element)
295
+ return false;
296
+ if (element.type === utils_1.AST_NODE_TYPES.SpreadElement) {
297
+ const argument = unwrapExpressionAssertions(element.argument);
298
+ if (argument.type === utils_1.AST_NODE_TYPES.ArrayExpression) {
299
+ return argument.elements.every((nested) => isPrimitiveLiteralElement(nested, visitedConstArrays));
300
+ }
301
+ if (argument.type === utils_1.AST_NODE_TYPES.Identifier) {
302
+ return isPrimitiveConstArray(argument.name, visitedConstArrays);
303
+ }
304
+ return false;
305
+ }
306
+ const expression = unwrapExpressionAssertions(element);
307
+ switch (expression.type) {
308
+ case utils_1.AST_NODE_TYPES.Literal: {
309
+ // A regex literal is an object; its `value` is engine-dependent, so
310
+ // reject it explicitly rather than relying on the typeof check.
311
+ if (expression.regex)
312
+ return false;
313
+ const value = expression.value;
314
+ return (value === null ||
315
+ typeof value === 'string' ||
316
+ typeof value === 'number' ||
317
+ typeof value === 'boolean' ||
318
+ typeof value === 'bigint');
319
+ }
320
+ case utils_1.AST_NODE_TYPES.TemplateLiteral:
321
+ return true; // a template literal always produces a string
322
+ case utils_1.AST_NODE_TYPES.ArrayExpression:
323
+ // Nested primitive arrays mirror the allowance for `string[][]` and
324
+ // tuples of primitives elsewhere in this rule.
325
+ return expression.elements.every((nested) => isPrimitiveLiteralElement(nested, visitedConstArrays));
326
+ case utils_1.AST_NODE_TYPES.UnaryExpression: {
327
+ const unary = expression;
328
+ if (unary.operator !== '-' && unary.operator !== '+')
329
+ return false;
330
+ return isPrimitiveLiteralElement(unary.argument, visitedConstArrays);
331
+ }
332
+ case utils_1.AST_NODE_TYPES.Identifier:
333
+ return expression.name === 'undefined';
334
+ default:
335
+ return false;
336
+ }
337
+ };
338
+ const isPrimitiveConstArray = (name, visitedConstArrays) => {
339
+ // A cyclic spread cannot be resolved syntactically; refuse to classify it
340
+ if (visitedConstArrays.has(name))
341
+ return false;
342
+ const arrayLiteral = constArrayNameToLiteral.get(name);
343
+ if (!arrayLiteral)
344
+ return false;
345
+ visitedConstArrays.add(name);
346
+ const result = arrayLiteral.elements.every((element) => isPrimitiveLiteralElement(element, visitedConstArrays));
347
+ visitedConstArrays.delete(name);
348
+ return result;
349
+ };
350
+ /**
351
+ * Recognizes `(typeof VALUES)[number]` where VALUES is a same-file const
352
+ * array of primitive literals. That form denotes the union of those
353
+ * literals, not an object lookup, and it is exactly what the sibling
354
+ * rule prefer-union-from-const-array autofixes toward.
355
+ */
356
+ const isConstArrayElementUnion = (node) => {
357
+ const indexType = unwrapParenthesizedTypeNode(node.indexType);
358
+ // Only a `number` index yields the element union; `['length']` or any key
359
+ // lookup resolves to something this syntactic check cannot vouch for.
360
+ if (indexType.type !== utils_1.AST_NODE_TYPES.TSNumberKeyword)
361
+ return false;
362
+ const objectType = unwrapParenthesizedTypeNode(node.objectType);
363
+ if (objectType.type !== utils_1.AST_NODE_TYPES.TSTypeQuery)
364
+ return false;
365
+ const exprName = objectType.exprName;
366
+ if (exprName.type !== utils_1.AST_NODE_TYPES.Identifier)
367
+ return false;
368
+ return isPrimitiveConstArray(exprName.name, new Set());
369
+ };
238
370
  const isPrimitiveLikeAlias = (name, recursionDepth) => {
239
371
  if (seenAlias.has(name))
240
372
  return true;
@@ -286,6 +418,8 @@ exports.noFirestoreObjectArrays = (0, createRule_1.createRule)({
286
418
  return false;
287
419
  case utils_1.AST_NODE_TYPES.TSLiteralType:
288
420
  return true; // string/number/boolean literals
421
+ case utils_1.AST_NODE_TYPES.TSIndexedAccessType:
422
+ return isConstArrayElementUnion(node);
289
423
  case utils_1.AST_NODE_TYPES.TSTypeReference: {
290
424
  // Allow known primitive-like references and enums or primitive-like aliases
291
425
  const ref = node;
@@ -361,8 +495,10 @@ exports.noFirestoreObjectArrays = (0, createRule_1.createRule)({
361
495
  case utils_1.AST_NODE_TYPES.TSMappedType:
362
496
  return true;
363
497
  case utils_1.AST_NODE_TYPES.TSIndexedAccessType:
364
- // Treat indexed access as object-like to align with existing tests
365
- return true;
498
+ // An indexed access such as `DataShape['user']` is an object lookup,
499
+ // but `(typeof VALUES)[number]` over a const array of primitive
500
+ // literals is a primitive union and must not be flagged.
501
+ return !isConstArrayElementUnion(node);
366
502
  case utils_1.AST_NODE_TYPES.TSTypeOperator:
367
503
  if (node.operator === 'readonly') {
368
504
  return isObjectType(node
@@ -277,6 +277,19 @@ function enclosingFunction(node) {
277
277
  function declaresCheckedReturnType(fn) {
278
278
  return (fn !== null && checksExcessProperties(fn.returnType?.typeAnnotation ?? null));
279
279
  }
280
+ /**
281
+ * Assertion wrappers, which never change the runtime value they wrap. An
282
+ * `as const`, `as T`, `satisfies T` or `!` therefore leaves the literal beneath
283
+ * it the same object, still checked by whatever declared type sits OUTSIDE the
284
+ * wrapper. Mirrors the set `no-firestore-object-arrays` unwraps for the same
285
+ * reason.
286
+ */
287
+ const EXPRESSION_ASSERTION_TYPES = new Set([
288
+ utils_1.AST_NODE_TYPES.TSAsExpression,
289
+ utils_1.AST_NODE_TYPES.TSSatisfiesExpression,
290
+ utils_1.AST_NODE_TYPES.TSNonNullExpression,
291
+ utils_1.AST_NODE_TYPES.TSTypeAssertion,
292
+ ]);
280
293
  /**
281
294
  * Reports whether `node` sits inside a value whose shape TypeScript checks
282
295
  * against a declared type — a type-annotated variable or class field, a
@@ -294,8 +307,13 @@ function declaresCheckedReturnType(fn) {
294
307
  * that shape's sole way to declare the contract it imitates.
295
308
  *
296
309
  * The walk climbs object/array containers so an outer signal covers nested
297
- * members, and stops at anything else notably `as` assertions, which do not
298
- * reject undeclared members the same way.
310
+ * members, and climbs THROUGH assertion wrappers, which change no runtime value
311
+ * and so cannot detach a literal from the declared type it is assigned to
312
+ * (#1597) — `enforce-object-literal-as-const` ships in the same recommended
313
+ * config and appends `as const` to exactly these literals by `--fix`. An
314
+ * assertion is transparent, never itself a signal: `{...} as T` still reports,
315
+ * because an `as` clause does not reject undeclared members the way an
316
+ * annotation does. The walk stops at anything else.
299
317
  */
300
318
  function hasConformanceSignal(node) {
301
319
  let current = node;
@@ -304,10 +322,19 @@ function hasConformanceSignal(node) {
304
322
  if (!parent) {
305
323
  return false;
306
324
  }
325
+ // A `satisfies` clause both asserts and checks, so it is answered before
326
+ // the wrapper is stepped over; an unchecked one (`satisfies any`) proves
327
+ // nothing on its own and the walk continues past it to the outer context.
328
+ if (parent.type === utils_1.AST_NODE_TYPES.TSSatisfiesExpression &&
329
+ parent.expression === current &&
330
+ checksExcessProperties(parent.typeAnnotation)) {
331
+ return true;
332
+ }
333
+ if (EXPRESSION_ASSERTION_TYPES.has(parent.type)) {
334
+ current = parent;
335
+ continue;
336
+ }
307
337
  switch (parent.type) {
308
- case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
309
- return (parent.expression === current &&
310
- checksExcessProperties(parent.typeAnnotation));
311
338
  case utils_1.AST_NODE_TYPES.VariableDeclarator:
312
339
  return (parent.init === current &&
313
340
  checksExcessProperties(declaredTypeNode(parent.id)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.70",
3
+ "version": "1.20.72",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,56 @@
1
1
  [
2
+ {
3
+ "version": "1.20.72",
4
+ "date": "2026-08-02T03:33:22.821Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-firestore-doc-ref-generic",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1595
11
+ ],
12
+ "summary": "infer collection schema from the returned expression, not only the annotation (closes #1595)"
13
+ },
14
+ {
15
+ "name": "enforce-verb-noun-naming",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1596
19
+ ],
20
+ "summary": "recognize a React component without its return annotation (closes #1596)"
21
+ },
22
+ {
23
+ "name": "no-explicit-return-type",
24
+ "changeType": "fix",
25
+ "issues": [
26
+ 1598
27
+ ],
28
+ "summary": "see overload siblings inside a type literal, not only an interface body (closes #1598)"
29
+ },
30
+ {
31
+ "name": "no-unnecessary-verb-suffix",
32
+ "changeType": "fix",
33
+ "issues": [
34
+ 1597
35
+ ],
36
+ "summary": "climb through assertion wrappers when seeking a conformance signal (closes #1597)"
37
+ }
38
+ ]
39
+ },
40
+ {
41
+ "version": "1.20.71",
42
+ "date": "2026-08-02T01:25:22.489Z",
43
+ "rules": [
44
+ {
45
+ "name": "no-firestore-object-arrays",
46
+ "changeType": "fix",
47
+ "issues": [
48
+ 1594
49
+ ],
50
+ "summary": "treat (typeof X)[number] over a primitive const array as a primitive union (closes #1594)"
51
+ }
52
+ ]
53
+ },
2
54
  {
3
55
  "version": "1.20.70",
4
56
  "date": "2026-08-01T23:31:02.214Z",