@blumintinc/eslint-plugin-blumint 1.21.9 → 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.9',
227
+ version: '1.21.10',
228
228
  },
229
229
  parseOptions: {
230
230
  ecmaVersion: 2020,
@@ -361,7 +361,8 @@ const aliasDeclaratorOf = (identifier) => {
361
361
  const declarator = value.parent;
362
362
  if (declarator?.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
363
363
  declarator.init === value &&
364
- 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)) {
365
366
  return declarator;
366
367
  }
367
368
  const container = storageContainerOf(value);
@@ -376,12 +377,42 @@ const aliasDeclaratorOf = (identifier) => {
376
377
  * bracketed form — read through `accessedPropertyName` so the two spellings
377
378
  * cannot diverge from how the mutation walk already reads a method name.
378
379
  */
379
- const isObjectAssignCallee = (callee) => {
380
+ const isNamespacedCallee = (callee, namespace, method) => {
380
381
  const value = outermostValueOf(callee);
381
382
  return (value.type === utils_1.AST_NODE_TYPES.MemberExpression &&
382
383
  value.object.type === utils_1.AST_NODE_TYPES.Identifier &&
383
- value.object.name === 'Object' &&
384
- accessedPropertyName(value) === 'assign');
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;
385
416
  };
386
417
  /**
387
418
  * Array methods whose result keeps the receiver's ELEMENT type. `map` is
@@ -390,7 +421,19 @@ const isObjectAssignCallee = (callee) => {
390
421
  * `map`. Admitting it would withhold the assertion from every derived array
391
422
  * anything is computed from, to cover a spelling nobody writes.
392
423
  */
393
- const TYPE_PRESERVING_COPY_METHODS = new Set(['concat', 'slice', 'filter']);
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
+ ]);
394
437
  /**
395
438
  * The expression that builds a COPY carrying this value's type — the literal
396
439
  * around a spread of it, the call of a copying array method on it, or an
@@ -415,10 +458,11 @@ const copyExpressionOf = (node) => {
415
458
  parent.parent?.type === utils_1.AST_NODE_TYPES.ArrayExpression)) {
416
459
  return parent.parent;
417
460
  }
418
- if (parent.type === utils_1.AST_NODE_TYPES.CallExpression &&
419
- parent.arguments.includes(node) &&
420
- isObjectAssignCallee(parent.callee)) {
421
- return parent;
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
+ }
422
466
  }
423
467
  if (parent.type === utils_1.AST_NODE_TYPES.MemberExpression &&
424
468
  parent.object === node) {
@@ -442,6 +486,10 @@ const PATTERN_CONTAINERS = new Set([
442
486
  utils_1.AST_NODE_TYPES.ObjectPattern,
443
487
  utils_1.AST_NODE_TYPES.ArrayPattern,
444
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,
445
493
  ]);
446
494
  const FUNCTION_TYPES = new Set([
447
495
  utils_1.AST_NODE_TYPES.FunctionDeclaration,
@@ -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.9",
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,26 @@
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
+ },
2
24
  {
3
25
  "version": "1.21.9",
4
26
  "date": "2026-09-05T05:55:33.427Z",