@blumintinc/eslint-plugin-blumint 1.21.9 → 1.21.11

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.9',
227
+ version: '1.21.11',
228
228
  },
229
229
  parseOptions: {
230
230
  ecmaVersion: 2020,
@@ -350,9 +350,14 @@ const storageContainerOf = (node) => {
350
350
  * the file compiling is not.
351
351
  *
352
352
  * A reference STORED INTO a composite literal is followed through that
353
- * container, since storing does not copy — see `storageContainerOf`. A
354
- * destructuring id extracts a member rather than the whole, so it is not an
355
- * alias here.
353
+ * container, since storing does not copy — see `storageContainerOf`.
354
+ *
355
+ * A destructuring id is accepted in BOTH spellings. It does not name the whole
356
+ * value, but every binding it introduces is typed from that value, and a rest
357
+ * element is itself a fresh container the assertion narrows: `const [, ...rest]
358
+ * = ITEMS` gives `rest` the frozen element type, so `rest.push(4)` is TS2345
359
+ * for an input that compiled. Admitting only the object spelling gave the same
360
+ * construct opposite verdicts (#2336).
356
361
  */
357
362
  const aliasDeclaratorOf = (identifier) => {
358
363
  // Ascends strictly, so reaching a node with no parent terminates the walk.
@@ -361,7 +366,9 @@ const aliasDeclaratorOf = (identifier) => {
361
366
  const declarator = value.parent;
362
367
  if (declarator?.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
363
368
  declarator.init === value &&
364
- declarator.id.type === utils_1.AST_NODE_TYPES.Identifier) {
369
+ (declarator.id.type === utils_1.AST_NODE_TYPES.Identifier ||
370
+ declarator.id.type === utils_1.AST_NODE_TYPES.ObjectPattern ||
371
+ declarator.id.type === utils_1.AST_NODE_TYPES.ArrayPattern)) {
365
372
  return declarator;
366
373
  }
367
374
  const container = storageContainerOf(value);
@@ -376,12 +383,42 @@ const aliasDeclaratorOf = (identifier) => {
376
383
  * bracketed form — read through `accessedPropertyName` so the two spellings
377
384
  * cannot diverge from how the mutation walk already reads a method name.
378
385
  */
379
- const isObjectAssignCallee = (callee) => {
386
+ const isNamespacedCallee = (callee, namespace, method) => {
380
387
  const value = outermostValueOf(callee);
381
388
  return (value.type === utils_1.AST_NODE_TYPES.MemberExpression &&
382
389
  value.object.type === utils_1.AST_NODE_TYPES.Identifier &&
383
- value.object.name === 'Object' &&
384
- accessedPropertyName(value) === 'assign');
390
+ value.object.name === namespace &&
391
+ accessedPropertyName(value) === method);
392
+ };
393
+ const isObjectAssignCallee = (callee) => isNamespacedCallee(callee, 'Object', 'assign');
394
+ /** Whether a callee is the bare global `structuredClone`. */
395
+ const isStructuredCloneCallee = (callee) => {
396
+ const value = outermostValueOf(callee);
397
+ return (value.type === utils_1.AST_NODE_TYPES.Identifier && value.name === 'structuredClone');
398
+ };
399
+ /**
400
+ * Whether a call COPIES the argument at `index` while keeping its type.
401
+ *
402
+ * `Array.from(X)` and `structuredClone(X)` both hand back a fresh, mutable
403
+ * value whose element or property types are the argument's — so freezing the
404
+ * argument narrows the copy exactly as a spread does. `Array.from(X, fn)` is
405
+ * excluded for the same reason `map` is: a mapper retypes the result, so
406
+ * nothing of the constant's type survives into it.
407
+ */
408
+ const isCopyingCall = (call, index) => {
409
+ if (isObjectAssignCallee(call.callee)) {
410
+ return true;
411
+ }
412
+ if (index !== 0) {
413
+ return false;
414
+ }
415
+ if (isStructuredCloneCallee(call.callee)) {
416
+ return true;
417
+ }
418
+ if (isNamespacedCallee(call.callee, 'Array', 'from')) {
419
+ return call.arguments.length === 1;
420
+ }
421
+ return false;
385
422
  };
386
423
  /**
387
424
  * Array methods whose result keeps the receiver's ELEMENT type. `map` is
@@ -390,7 +427,19 @@ const isObjectAssignCallee = (callee) => {
390
427
  * `map`. Admitting it would withhold the assertion from every derived array
391
428
  * anything is computed from, to cover a spelling nobody writes.
392
429
  */
393
- const TYPE_PRESERVING_COPY_METHODS = new Set(['concat', 'slice', 'filter']);
430
+ const TYPE_PRESERVING_COPY_METHODS = new Set([
431
+ 'concat',
432
+ 'slice',
433
+ 'filter',
434
+ 'flat',
435
+ // The ES2023 copying methods. Listed even though this repo's TypeScript
436
+ // predates them, because they are the same category and admitting them costs
437
+ // nothing: a name that does not resolve produces no reports to lose.
438
+ 'toSorted',
439
+ 'toReversed',
440
+ 'toSpliced',
441
+ 'with',
442
+ ]);
394
443
  /**
395
444
  * The expression that builds a COPY carrying this value's type — the literal
396
445
  * around a spread of it, the call of a copying array method on it, or an
@@ -415,10 +464,11 @@ const copyExpressionOf = (node) => {
415
464
  parent.parent?.type === utils_1.AST_NODE_TYPES.ArrayExpression)) {
416
465
  return parent.parent;
417
466
  }
418
- if (parent.type === utils_1.AST_NODE_TYPES.CallExpression &&
419
- parent.arguments.includes(node) &&
420
- isObjectAssignCallee(parent.callee)) {
421
- return parent;
467
+ if (parent.type === utils_1.AST_NODE_TYPES.CallExpression) {
468
+ const index = parent.arguments.indexOf(node);
469
+ if (index !== -1 && isCopyingCall(parent, index)) {
470
+ return parent;
471
+ }
422
472
  }
423
473
  if (parent.type === utils_1.AST_NODE_TYPES.MemberExpression &&
424
474
  parent.object === node) {
@@ -442,6 +492,10 @@ const PATTERN_CONTAINERS = new Set([
442
492
  utils_1.AST_NODE_TYPES.ObjectPattern,
443
493
  utils_1.AST_NODE_TYPES.ArrayPattern,
444
494
  utils_1.AST_NODE_TYPES.RestElement,
495
+ // A parameter property is a parameter AND declares a class property, so it
496
+ // infers twice over. Without it the walk stops before reaching the
497
+ // constructor's params and `constructor(public stage = DEFAULT)` narrows.
498
+ utils_1.AST_NODE_TYPES.TSParameterProperty,
445
499
  ]);
446
500
  const FUNCTION_TYPES = new Set([
447
501
  utils_1.AST_NODE_TYPES.FunctionDeclaration,
@@ -347,6 +347,101 @@ 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 consumptionOfDeclaration = (declaration, id, context, reactImports) => {
414
+ // `getDeclaredVariables` on a FunctionDeclaration yields its PARAMETERS
415
+ // alongside the function name, and a parameter handed to a non-component prop
416
+ // votes `callback` for a binding it says nothing about. Keep only the
417
+ // variable this declaration's own id introduces.
418
+ const declared = context
419
+ .getDeclaredVariables(declaration)
420
+ .filter((variable) => variable.defs.some((def) => def.name === id));
421
+ let sawCallback = false;
422
+ for (const variable of declared) {
423
+ for (const reference of variable.references) {
424
+ const consumption = consumptionOfReference(reference.identifier, reactImports);
425
+ // A single mounting use settles it: the identity churn happens there
426
+ // regardless of how many other places merely call it.
427
+ if (consumption === 'component') {
428
+ return 'component';
429
+ }
430
+ if (consumption === 'callback') {
431
+ sawCallback = true;
432
+ }
433
+ }
434
+ }
435
+ return sawCallback ? 'callback' : 'unknown';
436
+ };
437
+ const consumptionOfBinding = (node, context, reactImports) => {
438
+ const declarator = parentBeyondChain(node);
439
+ if (declarator?.type !== utils_1.AST_NODE_TYPES.VariableDeclarator ||
440
+ declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier) {
441
+ return 'unknown';
442
+ }
443
+ return consumptionOfDeclaration(declarator, declarator.id, context, reactImports);
444
+ };
350
445
  /**
351
446
  * The values a container hands to its caller: object property values and array
352
447
  * elements. Mirrors `containedValues` in the paired `require-memo` rule (#1919),
@@ -725,10 +820,20 @@ See: https://react.dev/learn/your-first-component#nesting-and-organizing-compone
725
820
  }
726
821
  }
727
822
  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;
823
+ // The NAME was the whole discriminator here, which reported every
824
+ // PascalCase render callback the message explicitly exempts and missed
825
+ // every lowercase binding handed to a component-type prop. Evidence
826
+ // from the use site overrides it in both directions; the name still
827
+ // decides where there is no evidence, which is where an exported
828
+ // component lives (#2334).
829
+ if (variableName) {
830
+ const consumption = consumptionOfBinding(node, context, reactImports);
831
+ if (consumption === 'callback') {
832
+ return;
833
+ }
834
+ if (consumption === 'unknown' && !isPascalCaseName(variableName)) {
835
+ return;
836
+ }
732
837
  }
733
838
  // Inside an HOC factory the binding has a stable identity, so it does
734
839
  // not remount on re-render and must not be flagged.
@@ -747,8 +852,10 @@ See: https://react.dev/learn/your-first-component#nesting-and-organizing-compone
747
852
  return;
748
853
  if (node.id.type !== utils_1.AST_NODE_TYPES.Identifier)
749
854
  return;
750
- // Only check if name starts with uppercase (convention for components)
751
- if (!isPascalCaseName(node.id.name))
855
+ const vdConsumption = consumptionOfDeclaration(node, node.id, context, reactImports);
856
+ if (vdConsumption === 'callback')
857
+ return;
858
+ if (vdConsumption === 'unknown' && !isPascalCaseName(node.id.name))
752
859
  return;
753
860
  if (!isInsideFunction(node))
754
861
  return;
@@ -772,7 +879,12 @@ See: https://react.dev/learn/your-first-component#nesting-and-organizing-compone
772
879
  reportNestedComponentViolation(node, node.id.name, 'a render body');
773
880
  },
774
881
  FunctionDeclaration(node) {
775
- if (!node.id || !isPascalCaseName(node.id.name))
882
+ if (!node.id)
883
+ return;
884
+ const fdConsumption = consumptionOfDeclaration(node, node.id, context, reactImports);
885
+ if (fdConsumption === 'callback')
886
+ return;
887
+ if (fdConsumption === 'unknown' && !isPascalCaseName(node.id.name))
776
888
  return;
777
889
  if (!isInsideFunction(node))
778
890
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.21.9",
3
+ "version": "1.21.11",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,48 @@
1
1
  [
2
+ {
3
+ "version": "1.21.11",
4
+ "date": "2026-09-05T11:06:01.167Z",
5
+ "rules": [
6
+ {
7
+ "name": "global-const-style",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2336
11
+ ],
12
+ "summary": "follow the array spelling of a destructured copy, and the rest element that needs no copy (closes #2336)"
13
+ },
14
+ {
15
+ "name": "memo-nested-react-components",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 2335
19
+ ],
20
+ "summary": "decide by the use site in the two non-hook visitors too (closes #2335)"
21
+ }
22
+ ]
23
+ },
24
+ {
25
+ "version": "1.21.10",
26
+ "date": "2026-09-05T09:28:38.523Z",
27
+ "rules": [
28
+ {
29
+ "name": "global-const-style",
30
+ "changeType": "fix",
31
+ "issues": [
32
+ 2333
33
+ ],
34
+ "summary": "follow every copy that carries the frozen type, and the parameter property that infers from it (closes #2333)"
35
+ },
36
+ {
37
+ "name": "memo-nested-react-components",
38
+ "changeType": "fix",
39
+ "issues": [
40
+ 2334
41
+ ],
42
+ "summary": "decide by the use site, not the binding's first letter (closes #2334)"
43
+ }
44
+ ]
45
+ },
2
46
  {
3
47
  "version": "1.21.9",
4
48
  "date": "2026-09-05T05:55:33.427Z",