@blumintinc/eslint-plugin-blumint 1.21.8 → 1.21.10

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
@@ -224,7 +224,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
224
224
  module.exports = {
225
225
  meta: {
226
226
  name: '@blumintinc/eslint-plugin-blumint',
227
- version: '1.21.8',
227
+ version: '1.21.10',
228
228
  },
229
229
  parseOptions: {
230
230
  ecmaVersion: 2020,
@@ -313,8 +313,10 @@ const isWriteTarget = (node) => {
313
313
  * Storing a reference does not copy it: the same array stays reachable through
314
314
  * the container, so `holder.items.push(3)` writes through to the binding
315
315
  * exactly as a direct alias does, and freezing it raises the same TS2339. A
316
- * `SpreadElement` is excluded because it genuinely builds a fresh value
317
- * (`const COPY = [...ITEMS]`), and a computed key is excluded because it coerces
316
+ * `SpreadElement` is excluded because it builds a fresh VALUE
317
+ * (`const COPY = [...ITEMS]`) — it is not excluded from the walk entirely,
318
+ * because the copy still carries the constant's frozen TYPE, which
319
+ * `copyExpressionOf` handles. A computed key is excluded because it coerces
318
320
  * the reference to a property name rather than retaining it.
319
321
  */
320
322
  const storageContainerOf = (node) => {
@@ -359,7 +361,8 @@ const aliasDeclaratorOf = (identifier) => {
359
361
  const declarator = value.parent;
360
362
  if (declarator?.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
361
363
  declarator.init === value &&
362
- declarator.id.type === utils_1.AST_NODE_TYPES.Identifier) {
364
+ (declarator.id.type === utils_1.AST_NODE_TYPES.Identifier ||
365
+ declarator.id.type === utils_1.AST_NODE_TYPES.ObjectPattern)) {
363
366
  return declarator;
364
367
  }
365
368
  const container = storageContainerOf(value);
@@ -369,6 +372,113 @@ const aliasDeclaratorOf = (identifier) => {
369
372
  value = outermostValueOf(container);
370
373
  }
371
374
  };
375
+ /**
376
+ * Whether a callee spells `Object.assign`, in either the dotted or the
377
+ * bracketed form — read through `accessedPropertyName` so the two spellings
378
+ * cannot diverge from how the mutation walk already reads a method name.
379
+ */
380
+ const isNamespacedCallee = (callee, namespace, method) => {
381
+ const value = outermostValueOf(callee);
382
+ return (value.type === utils_1.AST_NODE_TYPES.MemberExpression &&
383
+ value.object.type === utils_1.AST_NODE_TYPES.Identifier &&
384
+ value.object.name === namespace &&
385
+ accessedPropertyName(value) === method);
386
+ };
387
+ const isObjectAssignCallee = (callee) => isNamespacedCallee(callee, 'Object', 'assign');
388
+ /** Whether a callee is the bare global `structuredClone`. */
389
+ const isStructuredCloneCallee = (callee) => {
390
+ const value = outermostValueOf(callee);
391
+ return (value.type === utils_1.AST_NODE_TYPES.Identifier && value.name === 'structuredClone');
392
+ };
393
+ /**
394
+ * Whether a call COPIES the argument at `index` while keeping its type.
395
+ *
396
+ * `Array.from(X)` and `structuredClone(X)` both hand back a fresh, mutable
397
+ * value whose element or property types are the argument's — so freezing the
398
+ * argument narrows the copy exactly as a spread does. `Array.from(X, fn)` is
399
+ * excluded for the same reason `map` is: a mapper retypes the result, so
400
+ * nothing of the constant's type survives into it.
401
+ */
402
+ const isCopyingCall = (call, index) => {
403
+ if (isObjectAssignCallee(call.callee)) {
404
+ return true;
405
+ }
406
+ if (index !== 0) {
407
+ return false;
408
+ }
409
+ if (isStructuredCloneCallee(call.callee)) {
410
+ return true;
411
+ }
412
+ if (isNamespacedCallee(call.callee, 'Array', 'from')) {
413
+ return call.arguments.length === 1;
414
+ }
415
+ return false;
416
+ };
417
+ /**
418
+ * Array methods whose result keeps the receiver's ELEMENT type. `map` is
419
+ * absent because its result is typed from the CALLBACK, so the constant's type
420
+ * reaches it only for a callback that returns its argument unchanged — a no-op
421
+ * `map`. Admitting it would withhold the assertion from every derived array
422
+ * anything is computed from, to cover a spelling nobody writes.
423
+ */
424
+ const TYPE_PRESERVING_COPY_METHODS = new Set([
425
+ 'concat',
426
+ 'slice',
427
+ 'filter',
428
+ 'flat',
429
+ // The ES2023 copying methods. Listed even though this repo's TypeScript
430
+ // predates them, because they are the same category and admitting them costs
431
+ // nothing: a name that does not resolve produces no reports to lose.
432
+ 'toSorted',
433
+ 'toReversed',
434
+ 'toSpliced',
435
+ 'with',
436
+ ]);
437
+ /**
438
+ * The expression that builds a COPY carrying this value's type — the literal
439
+ * around a spread of it, the call of a copying array method on it, or an
440
+ * `Object.assign` it feeds.
441
+ *
442
+ * A copy is a fresh, mutable value, which is why `storageContainerOf` refuses
443
+ * it: writing to the copy cannot write through to the constant. But `as const`
444
+ * changes the constant's TYPE as well as its mutability, and a copy inherits
445
+ * that type — `[...ITEMS]` of a frozen `readonly [1, 2]` is `(1 | 2)[]`, so
446
+ * `COPY.push(3)` is TS2345 for an input that compiled. The copy is therefore
447
+ * followed for exactly the same question the alias walk asks: is the derived
448
+ * binding written?
449
+ */
450
+ const copyExpressionOf = (node) => {
451
+ const parent = node.parent;
452
+ if (!parent) {
453
+ return null;
454
+ }
455
+ if (parent.type === utils_1.AST_NODE_TYPES.SpreadElement &&
456
+ parent.argument === node &&
457
+ (parent.parent?.type === utils_1.AST_NODE_TYPES.ObjectExpression ||
458
+ parent.parent?.type === utils_1.AST_NODE_TYPES.ArrayExpression)) {
459
+ return parent.parent;
460
+ }
461
+ if (parent.type === utils_1.AST_NODE_TYPES.CallExpression) {
462
+ const index = parent.arguments.indexOf(node);
463
+ if (index !== -1 && isCopyingCall(parent, index)) {
464
+ return parent;
465
+ }
466
+ }
467
+ if (parent.type === utils_1.AST_NODE_TYPES.MemberExpression &&
468
+ parent.object === node) {
469
+ const method = accessedPropertyName(parent);
470
+ const callee = outermostValueOf(parent);
471
+ // A method REFERENCE (`const take = ITEMS.concat;`) builds nothing, so the
472
+ // copy only exists once the method is actually called.
473
+ if (method !== null &&
474
+ TYPE_PRESERVING_COPY_METHODS.has(method) &&
475
+ callee.parent?.type === utils_1.AST_NODE_TYPES.CallExpression &&
476
+ callee.parent.callee === callee) {
477
+ return callee.parent;
478
+ }
479
+ }
480
+ return null;
481
+ };
372
482
  /** Pattern nodes a parameter's binding can be nested inside. */
373
483
  const PATTERN_CONTAINERS = new Set([
374
484
  utils_1.AST_NODE_TYPES.AssignmentPattern,
@@ -376,6 +486,10 @@ const PATTERN_CONTAINERS = new Set([
376
486
  utils_1.AST_NODE_TYPES.ObjectPattern,
377
487
  utils_1.AST_NODE_TYPES.ArrayPattern,
378
488
  utils_1.AST_NODE_TYPES.RestElement,
489
+ // A parameter property is a parameter AND declares a class property, so it
490
+ // infers twice over. Without it the walk stops before reaching the
491
+ // constructor's params and `constructor(public stage = DEFAULT)` narrows.
492
+ utils_1.AST_NODE_TYPES.TSParameterProperty,
379
493
  ]);
380
494
  const FUNCTION_TYPES = new Set([
381
495
  utils_1.AST_NODE_TYPES.FunctionDeclaration,
@@ -414,16 +528,27 @@ const isInferredParameterDefault = (pattern) => {
414
528
  }
415
529
  };
416
530
  /**
417
- * Whether a reference sits where TypeScript INFERS a type from it — the value
418
- * of a default parameter, reached directly or through a composite literal it
419
- * is stored into.
531
+ * Whether a reference sits where TypeScript INFERS a type from it — a default
532
+ * parameter or a class property initializer — reached directly or through a
533
+ * composite literal it is stored into.
420
534
  *
421
535
  * `as const` does not only freeze: it makes the literal type NON-WIDENING, and
422
536
  * an inference site that widened `'ready'` to `string` then keeps the literal.
423
537
  * A parameter defaulted from the constant therefore narrows to that one value,
424
538
  * and every call passing a different one stops compiling (TS2345) for an input
425
539
  * that compiled. The mutation walk cannot see this: nothing is written, the
426
- * signature is simply inferred from a value the assertion changes.
540
+ * declaration is simply inferred from a value the assertion changes.
541
+ *
542
+ * Both sites are answered on the same terms, because an annotation is what
543
+ * settles the question in each: a type written by hand is DECLARED, so nothing
544
+ * infers from the value and freezing it cannot move the declaration. Only the
545
+ * unannotated spelling narrows.
546
+ *
547
+ * A RETURN position infers in exactly the same way and is deliberately absent.
548
+ * Declining there costs 59 of 778 consumer reports (7.6%) — the constant need
549
+ * only be held in a literal that is returned — to prevent breaks that the
550
+ * consumer does not contain, so it is documented as a limitation instead. The
551
+ * comparable trade in #2330 was rejected at 5%.
427
552
  */
428
553
  const isInferenceSite = (identifier) => {
429
554
  let value = outermostValueOf(identifier);
@@ -433,6 +558,14 @@ const isInferenceSite = (identifier) => {
433
558
  parent.right === value) {
434
559
  return isInferredParameterDefault(parent.left);
435
560
  }
561
+ // A class property's type is inferred from its initializer exactly as a
562
+ // parameter's is from its default, so `session.stage = 'live'` becomes
563
+ // TS2322 once the constant behind `stage = DEFAULT_STAGE` is frozen.
564
+ if ((parent?.type === utils_1.AST_NODE_TYPES.PropertyDefinition ||
565
+ parent?.type === utils_1.AST_NODE_TYPES.AccessorProperty) &&
566
+ parent.value === value) {
567
+ return !parent.typeAnnotation;
568
+ }
436
569
  const container = storageContainerOf(value);
437
570
  if (!container) {
438
571
  return false;
@@ -448,8 +581,13 @@ const isInferenceSite = (identifier) => {
448
581
  * value, so a WRITE — through the binding (`X.push(1)`), or to a binding that
449
582
  * aliases it (`other = X`) — becomes TS2339/TS2540. And it makes the literal
450
583
  * type NON-WIDENING, so an INFERENCE site that read the widened type keeps the
451
- * literal instead, which rewrites a signature the assertion was never asked to
452
- * touch.
584
+ * literal instead, which rewrites a declaration the assertion was never asked
585
+ * to touch.
586
+ *
587
+ * The type half reaches further than the value half, so the walk follows one
588
+ * edge the mutation question does not need: a COPY (`[...X]`, `X.concat()`),
589
+ * which is a fresh value but not a fresh type, and breaks on a write to the
590
+ * copy rather than to `X`.
453
591
  *
454
592
  * Answered from the scope manager's reference list rather than a textual
455
593
  * search for the name, so a same-named binding in
@@ -493,13 +631,20 @@ const blocksAsConstAssertion = (variable, declaredVariablesOf) => {
493
631
  return true;
494
632
  }
495
633
  const path = accessPathOf(reference.identifier);
496
- if (path !== null) {
497
- if (isMutatingMethodCall(path) || isWriteTarget(path)) {
498
- return true;
499
- }
500
- continue;
634
+ if (path !== null &&
635
+ (isMutatingMethodCall(path) || isWriteTarget(path))) {
636
+ return true;
501
637
  }
502
- const declarator = aliasDeclaratorOf(reference.identifier);
638
+ // A copy carries the constant's frozen type into a second binding, so it
639
+ // is enrolled on the same terms as an alias — but it is reached through a
640
+ // member access (`ITEMS.concat()`), which the alias walk deliberately
641
+ // refuses, so it is resolved before that refusal applies.
642
+ const copy = copyExpressionOf(outermostValueOf(reference.identifier));
643
+ const declarator = copy
644
+ ? aliasDeclaratorOf(copy)
645
+ : path === null
646
+ ? aliasDeclaratorOf(reference.identifier)
647
+ : null;
503
648
  if (!declarator) {
504
649
  continue;
505
650
  }
@@ -347,6 +347,90 @@ const isInsideFunction = (node) => {
347
347
  return findEnclosingFunction(node) !== null;
348
348
  };
349
349
  const isPascalCaseName = (name) => /^[A-Z]/.test(name);
350
+ /**
351
+ * Prop names whose value a parent MOUNTS rather than calls. Kept identical to
352
+ * the `JSXAttribute` visitor's own test so the two paths cannot disagree about
353
+ * what a component-type prop is — the disagreement between them is #2334.
354
+ */
355
+ const COMPONENT_PROP_SUFFIX = /(Wrapper|Component|Template|Header|Footer)$/;
356
+ const isComponentPropName = (name) => isPascalCaseName(name) && COMPONENT_PROP_SUFFIX.test(name);
357
+ const consumptionOfReference = (identifier, reactImports) => {
358
+ // `<Binding />`. The scope manager reports the tag name as a reference whose
359
+ // identifier is a `JSXIdentifier`, which no other position produces.
360
+ if (identifier.type === utils_1.AST_NODE_TYPES.JSXIdentifier) {
361
+ return 'component';
362
+ }
363
+ let current = identifier;
364
+ for (;;) {
365
+ const parent = parentBeyondChain(current);
366
+ if (!parent) {
367
+ return 'unknown';
368
+ }
369
+ // `Binding as Something` / `Binding!` keep the value on its way to a use.
370
+ if ((parent.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
371
+ parent.type === utils_1.AST_NODE_TYPES.TSNonNullExpression ||
372
+ parent.type === utils_1.AST_NODE_TYPES.TSSatisfiesExpression) &&
373
+ parent.expression === current) {
374
+ current = parent;
375
+ continue;
376
+ }
377
+ if (parent.type === utils_1.AST_NODE_TYPES.CallExpression) {
378
+ // `createElement(Binding, ...)` mounts it exactly as a tag name does.
379
+ if (parent.arguments[0] === current &&
380
+ isReactCreateElementCall(parent, reactImports)) {
381
+ return 'component';
382
+ }
383
+ // `Binding(onClose)` — the parent INVOKES it, which is what a render
384
+ // callback is for. A component is never called directly.
385
+ if (parent.callee === current) {
386
+ return 'callback';
387
+ }
388
+ return 'unknown';
389
+ }
390
+ // `<Host ContentComponent={Binding} />` mounts it; `<Host render={Binding} />`
391
+ // calls it. The prop name is the parent's contract, and it is read on the
392
+ // same terms the `JSXAttribute` visitor uses so the two cannot disagree.
393
+ if (parent.type === utils_1.AST_NODE_TYPES.JSXExpressionContainer &&
394
+ parent.parent?.type === utils_1.AST_NODE_TYPES.JSXAttribute &&
395
+ parent.parent.name.type === utils_1.AST_NODE_TYPES.JSXIdentifier) {
396
+ return isComponentPropName(parent.parent.name.name)
397
+ ? 'component'
398
+ : 'callback';
399
+ }
400
+ return 'unknown';
401
+ }
402
+ };
403
+ /**
404
+ * Whether the binding a memo-hook call initializes is MOUNTED, CALLED, or
405
+ * neither, read from the scope manager's reference list rather than a textual
406
+ * search so a same-named binding in a sibling scope cannot answer for this one.
407
+ *
408
+ * `unknown` is the honest answer for a binding with no informative reference —
409
+ * an exported component has none in its own file, and so does a fixture
410
+ * fragment. Falling back to the name there keeps the rule's reach while letting
411
+ * evidence override the guess wherever evidence exists.
412
+ */
413
+ const consumptionOfBinding = (node, context, reactImports) => {
414
+ const declarator = parentBeyondChain(node);
415
+ if (declarator?.type !== utils_1.AST_NODE_TYPES.VariableDeclarator) {
416
+ return 'unknown';
417
+ }
418
+ let sawCallback = false;
419
+ for (const variable of context.getDeclaredVariables(declarator)) {
420
+ for (const reference of variable.references) {
421
+ const consumption = consumptionOfReference(reference.identifier, reactImports);
422
+ // A single mounting use settles it: the identity churn happens there
423
+ // regardless of how many other places merely call it.
424
+ if (consumption === 'component') {
425
+ return 'component';
426
+ }
427
+ if (consumption === 'callback') {
428
+ sawCallback = true;
429
+ }
430
+ }
431
+ }
432
+ return sawCallback ? 'callback' : 'unknown';
433
+ };
350
434
  /**
351
435
  * The values a container hands to its caller: object property values and array
352
436
  * elements. Mirrors `containedValues` in the paired `require-memo` rule (#1919),
@@ -725,10 +809,20 @@ See: https://react.dev/learn/your-first-component#nesting-and-organizing-compone
725
809
  }
726
810
  }
727
811
  const variableName = getVariableName(node);
728
- // A non-PascalCase binding (e.g. renderHit) is a render callback used
729
- // with a render={...} prop, not a component—skip it.
730
- if (variableName && !isPascalCaseName(variableName)) {
731
- return;
812
+ // The NAME was the whole discriminator here, which reported every
813
+ // PascalCase render callback the message explicitly exempts and missed
814
+ // every lowercase binding handed to a component-type prop. Evidence
815
+ // from the use site overrides it in both directions; the name still
816
+ // decides where there is no evidence, which is where an exported
817
+ // component lives (#2334).
818
+ if (variableName) {
819
+ const consumption = consumptionOfBinding(node, context, reactImports);
820
+ if (consumption === 'callback') {
821
+ return;
822
+ }
823
+ if (consumption === 'unknown' && !isPascalCaseName(variableName)) {
824
+ return;
825
+ }
732
826
  }
733
827
  // Inside an HOC factory the binding has a stable identity, so it does
734
828
  // not remount on re-render and must not be flagged.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.21.8",
3
+ "version": "1.21.10",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,40 @@
1
1
  [
2
+ {
3
+ "version": "1.21.10",
4
+ "date": "2026-09-05T09:28:38.523Z",
5
+ "rules": [
6
+ {
7
+ "name": "global-const-style",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2333
11
+ ],
12
+ "summary": "follow every copy that carries the frozen type, and the parameter property that infers from it (closes #2333)"
13
+ },
14
+ {
15
+ "name": "memo-nested-react-components",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 2334
19
+ ],
20
+ "summary": "decide by the use site, not the binding's first letter (closes #2334)"
21
+ }
22
+ ]
23
+ },
24
+ {
25
+ "version": "1.21.9",
26
+ "date": "2026-09-05T05:55:33.427Z",
27
+ "rules": [
28
+ {
29
+ "name": "global-const-style",
30
+ "changeType": "fix",
31
+ "issues": [
32
+ 2331
33
+ ],
34
+ "summary": "withhold the assertion where a copy or a class property carries the frozen type (closes #2331)"
35
+ }
36
+ ]
37
+ },
2
38
  {
3
39
  "version": "1.21.8",
4
40
  "date": "2026-09-05T03:15:57.605Z",