@blumintinc/eslint-plugin-blumint 1.20.71 → 1.20.73

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.71',
226
+ version: '1.20.73',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -7,6 +7,7 @@ exports.enforceBooleanNamingPrefixes = void 0;
7
7
  const utils_1 = require("@typescript-eslint/utils");
8
8
  const pluralize_1 = __importDefault(require("pluralize"));
9
9
  const createRule_1 = require("../utils/createRule");
10
+ const ASTHelpers_1 = require("../utils/ASTHelpers");
10
11
  // Default approved boolean prefixes. Some less common prefixes (e.g., 'are',
11
12
  // 'includes') stay allowed for flexibility even though the user-facing message
12
13
  // highlights only the most common ones. Underscore-prefixed names are also
@@ -226,6 +227,27 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
226
227
  }
227
228
  return false;
228
229
  }
230
+ /**
231
+ * Recognize `Boolean(x)` — the explicit spelling of `!!x` — as producing a
232
+ * primitive boolean.
233
+ *
234
+ * The callee name alone cannot decide this. A local binding, a parameter or
235
+ * an import named `Boolean` shadows the global and may return anything, so
236
+ * the identifier is resolved through the scope chain at the call site: only
237
+ * an unresolved reference, or one reaching a definition-less global, is the
238
+ * built-in. `new Boolean(x)` is deliberately not covered here — a
239
+ * `NewExpression` builds a Boolean wrapper *object*, which is always truthy
240
+ * and never a primitive boolean.
241
+ */
242
+ function isGlobalBooleanCall(callExpression) {
243
+ const { callee } = callExpression;
244
+ if (callee.type !== utils_1.AST_NODE_TYPES.Identifier ||
245
+ callee.name !== 'Boolean') {
246
+ return false;
247
+ }
248
+ const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, callee), 'Boolean');
249
+ return !variable || variable.defs.length === 0;
250
+ }
229
251
  /**
230
252
  * Check if a node is initialized with a boolean value
231
253
  */
@@ -308,6 +330,12 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
308
330
  // Check for function calls that might return boolean
309
331
  if (node.init.type === utils_1.AST_NODE_TYPES.CallExpression &&
310
332
  node.init.callee.type === utils_1.AST_NODE_TYPES.Identifier) {
333
+ // A coercion through the global `Boolean` is as definitive as `!!x`,
334
+ // and its callee carries no approved prefix for the name heuristic
335
+ // below to recognize.
336
+ if (isGlobalBooleanCall(node.init)) {
337
+ return true;
338
+ }
311
339
  const calleeName = node.init.callee.name;
312
340
  const lowerCallee = calleeName.toLowerCase();
313
341
  // For assert*-style utilities, only treat as boolean if we can confirm boolean return type
@@ -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) &&
@@ -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)));
@@ -701,6 +701,85 @@ function buildHookImportFix(fixer, program, hookName) {
701
701
  ? fixer.insertTextBefore(anchor, statement)
702
702
  : fixer.insertTextAfterRange([0, 0], statement);
703
703
  }
704
+ /**
705
+ * Scope kinds whose bindings are established once per module evaluation:
706
+ * globals, imports and module-level declarations. Such a value is identical on
707
+ * every render, so it can never belong in a dependency array.
708
+ */
709
+ const MODULE_LEVEL_SCOPE_TYPES = new Set(['global', 'module']);
710
+ /**
711
+ * True when `inner` lies entirely inside `outer`'s source range.
712
+ */
713
+ function isRangeWithin(inner, outer) {
714
+ return inner[0] >= outer[0] && inner[1] <= outer[1];
715
+ }
716
+ /**
717
+ * True when a reference appears purely in type position (an annotation or a
718
+ * `satisfies`/`as` target inside the literal). Types erase at compile time, so
719
+ * such a name never becomes a dependency however it resolves. The flags are read
720
+ * defensively: an analyzer that omits them leaves the reference classified as a
721
+ * value, which keeps the suggestion — the conservative direction.
722
+ */
723
+ function isTypeOnlyReference(reference) {
724
+ const flags = reference;
725
+ return flags.isTypeReference === true && flags.isValueReference === false;
726
+ }
727
+ /**
728
+ * True when a reference names a value that can differ between renders, i.e. one
729
+ * bound in a scope INSIDE the module and OUTSIDE the literal: a prop, a local, a
730
+ * destructured value, another hook's result.
731
+ *
732
+ * Everything else is unusable as a dependency. An unresolved name is a global.
733
+ * A module- or global-scoped binding is fixed for the module's lifetime. A
734
+ * binding whose own scope sits inside the literal — an inline function's
735
+ * parameters, its locals, its `arguments` — is not closed over at all.
736
+ */
737
+ function isRenderScopeReference(reference, literalRange) {
738
+ const variable = reference.resolved;
739
+ if (!variable) {
740
+ return false;
741
+ }
742
+ if (MODULE_LEVEL_SCOPE_TYPES.has(variable.scope.type)) {
743
+ return false;
744
+ }
745
+ return !isRangeWithin(variable.scope.block.range, literalRange);
746
+ }
747
+ /**
748
+ * True when the literal reads at least one value a dependency array could hold.
749
+ *
750
+ * Answered from RESOLVED scope references rather than identifier names, so
751
+ * shadowing, destructuring and imports are all accounted for exactly as the
752
+ * scope analyzer sees them.
753
+ *
754
+ * The walk starts at the literal's own scope and descends into every scope
755
+ * nested inside it — a callback buried in an object property closes over the
756
+ * component's scope just as a property value would — while the range filter
757
+ * keeps the literal's siblings out of the answer.
758
+ */
759
+ function closesOverRenderScopeValue(node, scope) {
760
+ const literalRange = node.range;
761
+ const pending = [scope];
762
+ while (pending.length > 0) {
763
+ const current = pending.pop();
764
+ for (const reference of current.references) {
765
+ if (!isRangeWithin(reference.identifier.range, literalRange)) {
766
+ continue;
767
+ }
768
+ if (isTypeOnlyReference(reference)) {
769
+ continue;
770
+ }
771
+ if (isRenderScopeReference(reference, literalRange)) {
772
+ return true;
773
+ }
774
+ }
775
+ for (const child of current.childScopes) {
776
+ if (isRangeWithin(child.block.range, literalRange)) {
777
+ pending.push(child);
778
+ }
779
+ }
780
+ }
781
+ return false;
782
+ }
704
783
  /**
705
784
  * Builds memoization suggestions with dependency placeholders for developers.
706
785
  * @param node Literal node to wrap.
@@ -725,6 +804,19 @@ function buildMemoSuggestions(node, descriptor, sourceCode, context) {
725
804
  memoHook: descriptor.memoHook,
726
805
  },
727
806
  fix(fixer) {
807
+ // The wrap writes an EMPTY dependency array for the author to fill in.
808
+ // A literal that closes over nothing has nothing to fill it with, and
809
+ // `enforce-global-constants` forbids precisely that shape — a useMemo
810
+ // over an object literal with empty deps — while offering no fixer of
811
+ // its own. Accepting the suggestion would therefore trade this report
812
+ // for a permanent, non-autofixable one. Hoisting is the correct branch
813
+ // when nothing is closed over, and the report's own message already
814
+ // prescribes it, so decline rather than emit a state the author cannot
815
+ // complete (the #1417 principle, applied here as in the shadowed-hook
816
+ // guard below).
817
+ if (!closesOverRenderScopeValue(node, ASTHelpers_1.ASTHelpers.getScope(context, node))) {
818
+ return null;
819
+ }
728
820
  // The wrapper is only correct if the hook name resolves to React's
729
821
  // hook. A shadowing local/parameter would silently call that value
730
822
  // instead, and an import of the same name from another module would
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.71",
3
+ "version": "1.20.73",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,64 @@
1
1
  [
2
+ {
3
+ "version": "1.20.73",
4
+ "date": "2026-08-02T05:14:39.301Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-boolean-naming-prefixes",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1602
11
+ ],
12
+ "summary": "recognise a direct Boolean(...) initializer (closes #1602)"
13
+ },
14
+ {
15
+ "name": "react-memoize-literals",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1600
19
+ ],
20
+ "summary": "decline the memo suggestion when the literal closes over nothing (closes #1600)"
21
+ }
22
+ ]
23
+ },
24
+ {
25
+ "version": "1.20.72",
26
+ "date": "2026-08-02T03:33:22.821Z",
27
+ "rules": [
28
+ {
29
+ "name": "enforce-firestore-doc-ref-generic",
30
+ "changeType": "fix",
31
+ "issues": [
32
+ 1595
33
+ ],
34
+ "summary": "infer collection schema from the returned expression, not only the annotation (closes #1595)"
35
+ },
36
+ {
37
+ "name": "enforce-verb-noun-naming",
38
+ "changeType": "fix",
39
+ "issues": [
40
+ 1596
41
+ ],
42
+ "summary": "recognize a React component without its return annotation (closes #1596)"
43
+ },
44
+ {
45
+ "name": "no-explicit-return-type",
46
+ "changeType": "fix",
47
+ "issues": [
48
+ 1598
49
+ ],
50
+ "summary": "see overload siblings inside a type literal, not only an interface body (closes #1598)"
51
+ },
52
+ {
53
+ "name": "no-unnecessary-verb-suffix",
54
+ "changeType": "fix",
55
+ "issues": [
56
+ 1597
57
+ ],
58
+ "summary": "climb through assertion wrappers when seeking a conformance signal (closes #1597)"
59
+ }
60
+ ]
61
+ },
2
62
  {
3
63
  "version": "1.20.71",
4
64
  "date": "2026-08-02T01:25:22.489Z",