@blumintinc/eslint-plugin-blumint 1.20.162 → 1.20.164

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.162',
226
+ version: '1.20.164',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -234,24 +234,49 @@ function isObjectLikeType(type, checker) {
234
234
  }
235
235
  return 'object';
236
236
  }
237
+ /**
238
+ * Reads through an optional chain to the member access or call it holds.
239
+ * `Object?.keys?.(payload)?.length` parses as a single `ChainExpression`
240
+ * wrapping the whole chain, so a matcher written against a bare
241
+ * `MemberExpression` sees the wrapper and recognizes nothing.
242
+ *
243
+ * Reading through it is sound for the only question asked of it — "is an
244
+ * emptiness check already written here?" — because every optional link guards a
245
+ * nullish RECEIVER, and neither the `Object` global nor the array `Object.keys`
246
+ * hands back is ever nullish. The chained spelling therefore evaluates to
247
+ * exactly what the plain one does, making the two the same guard.
248
+ */
249
+ function unwrapOptionalChain(node) {
250
+ let current = node;
251
+ while (current.type === utils_1.AST_NODE_TYPES.ChainExpression) {
252
+ current = current.expression;
253
+ }
254
+ return current;
255
+ }
237
256
  function isObjectKeysLengthExpression(node, name) {
238
- if (node.type === utils_1.AST_NODE_TYPES.MemberExpression &&
239
- !node.computed &&
240
- node.property.type === utils_1.AST_NODE_TYPES.Identifier &&
241
- node.property.name === 'length' &&
242
- node.object.type === utils_1.AST_NODE_TYPES.CallExpression &&
243
- node.object.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
244
- !node.object.callee.computed &&
245
- node.object.callee.object.type === utils_1.AST_NODE_TYPES.Identifier &&
246
- node.object.callee.object.name === 'Object' &&
247
- node.object.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
248
- node.object.callee.property.name === 'keys' &&
249
- node.object.arguments.length === 1 &&
250
- node.object.arguments[0].type === utils_1.AST_NODE_TYPES.Identifier &&
251
- node.object.arguments[0].name === name) {
252
- return true;
257
+ const lengthRead = unwrapOptionalChain(node);
258
+ if (lengthRead.type !== utils_1.AST_NODE_TYPES.MemberExpression ||
259
+ lengthRead.computed ||
260
+ lengthRead.property.type !== utils_1.AST_NODE_TYPES.Identifier ||
261
+ lengthRead.property.name !== 'length') {
262
+ return false;
253
263
  }
254
- return false;
264
+ const keysCall = unwrapOptionalChain(lengthRead.object);
265
+ if (keysCall.type !== utils_1.AST_NODE_TYPES.CallExpression ||
266
+ keysCall.arguments.length !== 1) {
267
+ return false;
268
+ }
269
+ const callee = unwrapOptionalChain(keysCall.callee);
270
+ if (callee.type !== utils_1.AST_NODE_TYPES.MemberExpression ||
271
+ callee.computed ||
272
+ callee.object.type !== utils_1.AST_NODE_TYPES.Identifier ||
273
+ callee.object.name !== 'Object' ||
274
+ callee.property.type !== utils_1.AST_NODE_TYPES.Identifier ||
275
+ callee.property.name !== 'keys') {
276
+ return false;
277
+ }
278
+ const argument = keysCall.arguments[0];
279
+ return argument.type === utils_1.AST_NODE_TYPES.Identifier && argument.name === name;
255
280
  }
256
281
  function isZeroLiteral(node) {
257
282
  return node.type === utils_1.AST_NODE_TYPES.Literal && node.value === 0;
@@ -297,8 +322,18 @@ function conditionHasEmptyCheck(node, name, emptyCheckFunctions, negationDepth =
297
322
  return conditionHasEmptyCheck(node.argument, name, emptyCheckFunctions, negationDepth + 1);
298
323
  }
299
324
  return conditionHasEmptyCheck(node.argument, name, emptyCheckFunctions, negationDepth);
325
+ /**
326
+ * A whole optional chain arrives wrapped, so every arm below — each written
327
+ * against a bare member access or call — is handed a node type it does not
328
+ * match. Delegating to the wrapped expression at the SAME negation depth
329
+ * keeps the wrapper invisible, which is what lets
330
+ * `!Object.keys(data)?.length` and `isEmpty?.(data)` count as the emptiness
331
+ * checks they already are instead of being reported as missing ones.
332
+ */
333
+ case utils_1.AST_NODE_TYPES.ChainExpression:
334
+ return conditionHasEmptyCheck(node.expression, name, emptyCheckFunctions, negationDepth);
300
335
  case utils_1.AST_NODE_TYPES.CallExpression: {
301
- const callee = node.callee;
336
+ const callee = unwrapOptionalChain(node.callee);
302
337
  const firstArgIsTarget = node.arguments[0] &&
303
338
  node.arguments[0].type === utils_1.AST_NODE_TYPES.Identifier &&
304
339
  node.arguments[0].name === name;
@@ -34,15 +34,13 @@ function isConstAssertion(node) {
34
34
  /**
35
35
  * A `const` assertion is legal only on a literal, so leaving one wrapped around
36
36
  * the emitted `cloneDeep(...)` call yields TS1355 and turns a compiling file
37
- * into a broken one (#2011). The fix is declined there rather than absorbing
38
- * the assertion, which is the conservative reading of a `const` the author
39
- * asked for on a value this rule replaces.
37
+ * into a broken one (#2011).
40
38
  *
41
- * The whole assertion chain is walked because each of its links still applies
42
- * to the emitted call: `as Foo as const`, `satisfies Foo as const` and
43
- * `! as const` are TS1355 just the same. The walk stops at the first parent
44
- * that is not an assertion, which keeps `as const` on an ENCLOSING literal
45
- * fixable — that assertion still has a literal to apply to.
39
+ * The whole assertion chain above `node` is walked because each of its links
40
+ * still applies to the emitted call: `as Foo as const`, `satisfies Foo as
41
+ * const` and `! as const` are TS1355 just the same. The walk stops at the first
42
+ * parent that is not an assertion, which keeps `as const` on an ENCLOSING
43
+ * literal fixable — that assertion still has a literal to apply to.
46
44
  *
47
45
  * Only a `const` assertion is disqualifying: `as Foo` and `satisfies Foo` are
48
46
  * legal on a call expression and keep their fix.
@@ -60,6 +58,30 @@ function isConstAsserted(node) {
60
58
  }
61
59
  return false;
62
60
  }
61
+ /**
62
+ * The node the `cloneDeep(...)` call replaces for a rewritten literal, or null
63
+ * where the fix has to be declined.
64
+ *
65
+ * A `const` assertion applied DIRECTLY to the literal (`{ ... } as const`,
66
+ * optionally followed by `as Foo`, `satisfies Foo` or `!`) is absorbed: the
67
+ * replaced range covers the assertion, so the call takes its place with no
68
+ * `as const` left to wrap it. The emitted call already spells `as const` on its
69
+ * overrides literal, which is the only place a `const` assertion stays legal
70
+ * after the rewrite, so the author's literal typing lands there. Absorbing it
71
+ * is also what keeps the fix reachable under a composed `--fix`:
72
+ * `global-const-style` wins the range race on a module-scope constant and
73
+ * appends `as const` before this rule's turn, and declining on that assertion
74
+ * would report the hazard forever without ever fixing it (#2032).
75
+ *
76
+ * A `const` assertion behind another link (`as Foo as const`) cannot be
77
+ * absorbed without dropping the intervening assertion, and it would still wrap
78
+ * the emitted call — TS1355 either way — so the fix is declined there.
79
+ */
80
+ function rewriteSiteOf(target) {
81
+ const parent = target.parent;
82
+ const site = parent && isConstAssertion(parent) ? parent : target;
83
+ return isConstAsserted(site) ? null : site;
84
+ }
63
85
  exports.preferCloneDeep = (0, createRule_1.createRule)({
64
86
  name: 'prefer-clone-deep',
65
87
  meta: {
@@ -479,14 +501,15 @@ exports.preferCloneDeep = (0, createRule_1.createRule)({
479
501
  return null;
480
502
  }
481
503
  for (const target of targets) {
482
- if (isConstAsserted(target)) {
504
+ const site = rewriteSiteOf(target);
505
+ if (site === null) {
483
506
  return null;
484
507
  }
485
508
  const call = buildCloneDeepCall(target);
486
509
  if (call === null) {
487
510
  return null;
488
511
  }
489
- rewrites.push(fixer.replaceText(target, call));
512
+ rewrites.push(fixer.replaceText(site, call));
490
513
  }
491
514
  return rewrites;
492
515
  },
@@ -12,6 +12,25 @@ const MEMOIZE_MODULES = new Set([
12
12
  'typescript-memoize',
13
13
  ]);
14
14
  const MEMOIZE_EXPORT_NAME = 'Memoize';
15
+ /**
16
+ * The base classes React ships for class components. A class extending one of
17
+ * them hands its `render()` to React, which re-invokes it on every state and
18
+ * props change by contract (see `isReactComponentClass`).
19
+ */
20
+ const REACT_COMPONENT_BASE_NAMES = new Set(['Component', 'PureComponent']);
21
+ const RENDER_METHOD_NAME = 'render';
22
+ /**
23
+ * The wrappers `unwrapSuperClass` strips. `ChainExpression` is ESTree's
24
+ * envelope for `extends X?.Component`, which is grammatical and, whenever the
25
+ * receiver is defined, means exactly `X.Component`.
26
+ */
27
+ const SUPERCLASS_WRAPPER_TYPES = new Set([
28
+ utils_1.AST_NODE_TYPES.TSAsExpression,
29
+ utils_1.AST_NODE_TYPES.TSNonNullExpression,
30
+ utils_1.AST_NODE_TYPES.TSTypeAssertion,
31
+ utils_1.AST_NODE_TYPES.TSSatisfiesExpression,
32
+ utils_1.AST_NODE_TYPES.ChainExpression,
33
+ ]);
15
34
  function isMemoizeDecorator(decorator, alias, namespaceAlias) {
16
35
  const expression = decorator.expression;
17
36
  const matchesAliasIdentifier = (node) => !!node && node.type === utils_1.AST_NODE_TYPES.Identifier && node.name === alias;
@@ -46,6 +65,100 @@ function isMemoizeDecorator(decorator, alias, namespaceAlias) {
46
65
  }
47
66
  return false;
48
67
  }
68
+ /**
69
+ * The expression a class extends, with the wrappers an author can put around it
70
+ * stripped: `extends (React.Component)`, `extends (Component as any)`,
71
+ * `extends Base!`, `extends React?.Component`. The type arguments in
72
+ * `extends Component<Props, State>` live on `superTypeParameters` and never
73
+ * reach here.
74
+ */
75
+ function unwrapSuperClass(expression) {
76
+ let current = expression;
77
+ for (;;) {
78
+ if (isParenthesizedExpression(current)) {
79
+ current = current.expression;
80
+ continue;
81
+ }
82
+ // Compared as strings: `superClass` is typed as a LeftHandSideExpression,
83
+ // which excludes the assertion forms the parser nevertheless yields there.
84
+ if (SUPERCLASS_WRAPPER_TYPES.has(current.type)) {
85
+ current = current
86
+ .expression;
87
+ continue;
88
+ }
89
+ return current;
90
+ }
91
+ }
92
+ /**
93
+ * Whether the class hands its `render()` to React — that is, whether it extends
94
+ * React's `Component` or `PureComponent`.
95
+ *
96
+ * The match is keyed on React's VOCABULARY, not on the binding's provenance: a
97
+ * superclass spelled `Component` or `PureComponent` qualifies wherever the name
98
+ * is bound — an unaliased `import { Component } from 'react'`, an ambient
99
+ * global, a fixture that omits the import — and so does `X.Component` /
100
+ * `X.PureComponent` through any namespace object (`React.Component`,
101
+ * `Preact.PureComponent`, an aliased default import). Only where the spelling
102
+ * carries no vocabulary is the binding resolved through the scope chain: an
103
+ * import specifier renamed away from those names
104
+ * (`import { Component as ReactComponent } from 'react'`) and a same-file class
105
+ * that itself extends one of them (`class Base extends React.Component {}` …
106
+ * `class Boundary extends Base {}`). A superclass whose name is neither of
107
+ * those and resolves to nothing React-shaped in this file — `extends Base` from
108
+ * another module — is NOT treated as a component.
109
+ *
110
+ * Provenance is deliberately not verified: `class Foo extends Component` where
111
+ * `Component` is an unrelated local class is a corner case whose cost is one
112
+ * unreported factory named `render`, while treating a real component's `render`
113
+ * as a factory hands `--fix` a decorator that pins the component to its first
114
+ * output — a silent behavioural break (#2033). A false negative is the cheaper
115
+ * mistake, so the vocabulary wins.
116
+ */
117
+ function isReactComponentClass(classNode, context, visited = new Set()) {
118
+ if (!classNode.superClass || visited.has(classNode)) {
119
+ return false;
120
+ }
121
+ visited.add(classNode);
122
+ const superClass = unwrapSuperClass(classNode.superClass);
123
+ if (superClass.type === utils_1.AST_NODE_TYPES.MemberExpression &&
124
+ !superClass.computed &&
125
+ superClass.property.type === utils_1.AST_NODE_TYPES.Identifier) {
126
+ return REACT_COMPONENT_BASE_NAMES.has(superClass.property.name);
127
+ }
128
+ if (superClass.type !== utils_1.AST_NODE_TYPES.Identifier) {
129
+ return false;
130
+ }
131
+ if (REACT_COMPONENT_BASE_NAMES.has(superClass.name)) {
132
+ return true;
133
+ }
134
+ const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, classNode), superClass.name);
135
+ if (!variable) {
136
+ return false;
137
+ }
138
+ return variable.defs.some((def) => {
139
+ const declaration = def.node;
140
+ if (declaration.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
141
+ declaration.imported.type === utils_1.AST_NODE_TYPES.Identifier) {
142
+ return REACT_COMPONENT_BASE_NAMES.has(declaration.imported.name);
143
+ }
144
+ if (declaration.type === utils_1.AST_NODE_TYPES.ClassDeclaration ||
145
+ declaration.type === utils_1.AST_NODE_TYPES.ClassExpression) {
146
+ return isReactComponentClass(declaration, context, visited);
147
+ }
148
+ return false;
149
+ });
150
+ }
151
+ /**
152
+ * Whether the member is the `render` React calls — the key is read literally,
153
+ * so a computed `[render]()` naming some other value does not qualify.
154
+ */
155
+ function isRenderMember(node) {
156
+ const { key } = node;
157
+ if (key.type === utils_1.AST_NODE_TYPES.Identifier && !node.computed) {
158
+ return key.name === RENDER_METHOD_NAME;
159
+ }
160
+ return (key.type === utils_1.AST_NODE_TYPES.Literal && key.value === RENDER_METHOD_NAME);
161
+ }
49
162
  function getMemberName(node) {
50
163
  const key = node.key;
51
164
  if (key.type === utils_1.AST_NODE_TYPES.Identifier) {
@@ -548,6 +661,30 @@ exports.requireMemoizeJsxReturners = (0, createRule_1.createRule)({
548
661
  if (classBody?.parent?.type === utils_1.AST_NODE_TYPES.ClassExpression) {
549
662
  return;
550
663
  }
664
+ // React re-invokes a class component's `render()` on every state and
665
+ // props change BY CONTRACT, so `@Memoize()` there is never a remedy:
666
+ // it pins the component to the output of its first render. An error
667
+ // boundary is the sharpest case — it catches, `getDerivedStateFromError`
668
+ // sets state, React re-renders, and the memoized `render()` hands back
669
+ // the cached pre-error children, so the fallback can never appear
670
+ // (#2033). Unlike this rule's compile-breaking autofix defects
671
+ // (#1414, #1434, #1950, #1951, #1955), the result compiles and lints
672
+ // clean, so nothing downstream catches it. Report and fix are both
673
+ // withheld — the message's only remedy is the very edit that breaks
674
+ // the component. `render` is the ONLY instance lifecycle method that
675
+ // returns an element (`shouldComponentUpdate` returns a boolean,
676
+ // `getSnapshotBeforeUpdate` an opaque snapshot, the rest `void`), and
677
+ // the statics React also calls — `getDerivedStateFromError`,
678
+ // `getDerivedStateFromProps` — return state and are out of scope above
679
+ // regardless, so the exemption is keyed on that one name. Other
680
+ // members of a class component are the author's own factories, called
681
+ // on the author's schedule, and stay under the rule. Withholding the
682
+ // report here also keeps `render` out of the import-carrier race below.
683
+ if (isRenderMember(node) &&
684
+ classBody?.parent?.type === utils_1.AST_NODE_TYPES.ClassDeclaration &&
685
+ isReactComponentClass(classBody.parent, context)) {
686
+ return;
687
+ }
551
688
  const hasDecorator = node.decorators?.some((decorator) => isMemoizeDecorator(decorator, memoizeAlias, memoizeNamespace));
552
689
  if (hasDecorator) {
553
690
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.162",
3
+ "version": "1.20.164",
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.20.164",
4
+ "date": "2026-08-17T22:26:37.717Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-empty-object-check",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2034
11
+ ],
12
+ "summary": "see through optional chains (closes #2034)"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "version": "1.20.163",
18
+ "date": "2026-08-17T20:02:54.585Z",
19
+ "rules": [
20
+ {
21
+ "name": "prefer-clone-deep",
22
+ "changeType": "fix",
23
+ "issues": [
24
+ 2032
25
+ ],
26
+ "summary": "absorb a direct `as const` so the composed --fix lands (closes #2032)"
27
+ },
28
+ {
29
+ "name": "require-memoize-jsx-returners",
30
+ "changeType": "fix",
31
+ "issues": [
32
+ 2033
33
+ ],
34
+ "summary": "exempt render() on a React class component (closes #2033)"
35
+ }
36
+ ]
37
+ },
2
38
  {
3
39
  "version": "1.20.162",
4
40
  "date": "2026-08-17T15:38:50.817Z",