@blumintinc/eslint-plugin-blumint 1.20.80 → 1.20.82

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.80',
226
+ version: '1.20.82',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -156,6 +156,44 @@ exports.noUnusedProps = (0, createRule_1.createRule)({
156
156
  }
157
157
  return null;
158
158
  };
159
+ /**
160
+ * The props type carried by a component annotation on the DECLARATOR —
161
+ * `const C: React.FC<X> = ({ ... }) => ...` — used only when the parameter
162
+ * itself is unannotated (#1620). Gated to FC-shaped annotations so an
163
+ * arbitrary declarator type is never mistaken for a props source; the
164
+ * returned argument runs through the same resolver as a direct annotation,
165
+ * so wrappers like `React.FC<Readonly<XProps>>` resolve for free.
166
+ */
167
+ const FC_TYPE_NAMES = new Set([
168
+ 'FC',
169
+ 'FunctionComponent',
170
+ 'VFC',
171
+ 'VoidFunctionComponent',
172
+ ]);
173
+ const propsArgumentOfFcAnnotation = (declarationId) => {
174
+ if (declarationId.type !== utils_1.AST_NODE_TYPES.Identifier) {
175
+ return undefined;
176
+ }
177
+ const annotation = declarationId.typeAnnotation?.typeAnnotation;
178
+ if (annotation?.type !== utils_1.AST_NODE_TYPES.TSTypeReference) {
179
+ return undefined;
180
+ }
181
+ const { typeName } = annotation;
182
+ const localName = typeName.type === utils_1.AST_NODE_TYPES.Identifier
183
+ ? typeName.name
184
+ : typeName.type === utils_1.AST_NODE_TYPES.TSQualifiedName &&
185
+ typeName.left.type === utils_1.AST_NODE_TYPES.Identifier &&
186
+ typeName.left.name === 'React' &&
187
+ typeName.right.type === utils_1.AST_NODE_TYPES.Identifier
188
+ ? typeName.right.name
189
+ : null;
190
+ if (localName === null || !FC_TYPE_NAMES.has(localName)) {
191
+ return undefined;
192
+ }
193
+ const typeArgs = annotation.typeParameters ??
194
+ annotation.typeArguments;
195
+ return typeArgs?.params[0];
196
+ };
159
197
  const isAnyPropFromSpreadTypeUsed = (spreadTypeName, used, knownProps) => {
160
198
  const spreadTypeProps = spreadTypeToPropNames.get(spreadTypeName);
161
199
  if (!spreadTypeProps || spreadTypeProps.size === 0) {
@@ -769,8 +807,10 @@ exports.noUnusedProps = (0, createRule_1.createRule)({
769
807
  const param = fn.params[0];
770
808
  if (param?.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
771
809
  // Resolve through generic wrappers (e.g. `Readonly<FooProps>`) as well
772
- // as the plain `FooProps` annotation.
773
- const typeName = resolvePropsTypeName(param.typeAnnotation?.typeAnnotation);
810
+ // as the plain `FooProps` annotation; an unannotated pattern under an
811
+ // FC-annotated declarator resolves from the annotation's argument.
812
+ const typeName = resolvePropsTypeName(param.typeAnnotation?.typeAnnotation ??
813
+ propsArgumentOfFcAnnotation(declaration.id));
774
814
  if (typeName) {
775
815
  const used = new Set();
776
816
  const restUsed = collectUsedFromObjectPattern(param, typeName, used);
@@ -782,7 +822,8 @@ exports.noUnusedProps = (0, createRule_1.createRule)({
782
822
  // Identifier param (`props: FooProps`): the destructuring happens in
783
823
  // the body. Resolve the Props type (unwrapping generic wrappers) and
784
824
  // scan the body for `const { ... } = props`.
785
- const typeName = resolvePropsTypeName(param.typeAnnotation?.typeAnnotation);
825
+ const typeName = resolvePropsTypeName(param.typeAnnotation?.typeAnnotation ??
826
+ propsArgumentOfFcAnnotation(declaration.id));
786
827
  if (!typeName) {
787
828
  return;
788
829
  }
@@ -9,6 +9,59 @@ const defaultOptions = [
9
9
  enforceForRenamedProperties: false,
10
10
  },
11
11
  ];
12
+ /**
13
+ * Names of every class declared anywhere in the file. A purely syntactic rule
14
+ * cannot see an imported class, so same-file declarations are the entire
15
+ * population a type annotation can be resolved against (#1619).
16
+ */
17
+ function collectClassNames(sourceCode) {
18
+ const names = new Set();
19
+ const stack = [sourceCode.ast];
20
+ while (stack.length > 0) {
21
+ const current = stack.pop();
22
+ if ((current.type === utils_1.AST_NODE_TYPES.ClassDeclaration ||
23
+ current.type === utils_1.AST_NODE_TYPES.ClassExpression) &&
24
+ current.id) {
25
+ names.add(current.id.name);
26
+ }
27
+ for (const key of sourceCode.visitorKeys[current.type] ?? []) {
28
+ const value = current[key];
29
+ const children = Array.isArray(value) ? value : [value];
30
+ for (const child of children) {
31
+ if (child && typeof child === 'object' && 'type' in child) {
32
+ stack.push(child);
33
+ }
34
+ }
35
+ }
36
+ }
37
+ return names;
38
+ }
39
+ /**
40
+ * Reports whether an identifier's declared type names a class declared in this
41
+ * file — the annotation-carried form of a class instance (`user: User` as a
42
+ * parameter or an annotated variable), which the docs promise the same
43
+ * exemption as a `new User()` initializer (#1619).
44
+ */
45
+ function annotationNamesFileClass(identifier, classNames) {
46
+ if (!identifier || identifier.type !== utils_1.AST_NODE_TYPES.Identifier) {
47
+ return false;
48
+ }
49
+ const annotation = identifier.typeAnnotation?.typeAnnotation;
50
+ return (annotation?.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
51
+ annotation.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
52
+ classNames.has(annotation.typeName.name));
53
+ }
54
+ const fileClassNames = new WeakMap();
55
+ function classNamesFor(context) {
56
+ const sourceCode = context.getSourceCode();
57
+ const cached = fileClassNames.get(sourceCode.ast);
58
+ if (cached) {
59
+ return cached;
60
+ }
61
+ const names = collectClassNames(sourceCode);
62
+ fileClassNames.set(sourceCode.ast, names);
63
+ return names;
64
+ }
12
65
  function isClassInstance(node, context) {
13
66
  // Check if node is a MemberExpression
14
67
  if (node.type === utils_1.AST_NODE_TYPES.MemberExpression) {
@@ -22,12 +75,22 @@ function isClassInstance(node, context) {
22
75
  const variable = object.name;
23
76
  const scope = context.getScope();
24
77
  const ref = scope.references.find((ref) => ref.identifier.name === variable);
25
- if (ref?.resolved?.defs[0]?.node.type === utils_1.AST_NODE_TYPES.VariableDeclarator) {
26
- const init = ref.resolved.defs[0].node.init;
27
- return init?.type === utils_1.AST_NODE_TYPES.NewExpression;
78
+ const def = ref?.resolved?.defs[0];
79
+ if (def?.node.type === utils_1.AST_NODE_TYPES.VariableDeclarator) {
80
+ const init = def.node.init;
81
+ return (init?.type === utils_1.AST_NODE_TYPES.NewExpression ||
82
+ // `const user: User = getUser();` — the annotation, not the
83
+ // initializer, is what marks the value as a class instance.
84
+ annotationNamesFileClass(def.node.id, classNamesFor(context)));
85
+ }
86
+ // `function greet(user: User)` — a parameter typed with a same-file
87
+ // class is a class instance the initializer-based check cannot see.
88
+ if (def?.type === 'Parameter' &&
89
+ annotationNamesFileClass(def.name, classNamesFor(context))) {
90
+ return true;
28
91
  }
29
92
  // Check if the identifier refers to a class (not an instance)
30
- if (ref?.resolved?.defs[0]?.node.type === utils_1.AST_NODE_TYPES.ClassDeclaration) {
93
+ if (def?.node.type === utils_1.AST_NODE_TYPES.ClassDeclaration) {
31
94
  return false;
32
95
  }
33
96
  }
@@ -62,21 +62,75 @@ const isInsideComponentMock = (node, componentModule) => {
62
62
  }
63
63
  return false;
64
64
  };
65
+ /**
66
+ * A type-only specifier binds no value: it renders nothing, so it neither
67
+ * bypasses the optimization pipeline nor can back a fix. The modifier lives
68
+ * either on the specifier (`{ type Image }`) or on the whole declaration
69
+ * (`import type ...`).
70
+ */
71
+ const isTypeOnlySpecifier = (specifier) => {
72
+ if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
73
+ specifier.importKind === 'type') {
74
+ return true;
75
+ }
76
+ return (specifier.parent?.importKind ===
77
+ 'type');
78
+ };
65
79
  /** A type-only binding cannot be rendered, so it is no basis for a fix. */
66
80
  const isTypeOnlyImport = (definition) => {
67
81
  const { node } = definition;
68
- if (node.type === utils_1.AST_NODE_TYPES.ImportSpecifier) {
69
- return (node.importKind === 'type' ||
70
- node.parent?.importKind ===
71
- 'type');
72
- }
73
- if (node.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier ||
82
+ if (node.type === utils_1.AST_NODE_TYPES.ImportSpecifier ||
83
+ node.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier ||
74
84
  node.type === utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier) {
75
- return (node.parent?.importKind ===
76
- 'type');
85
+ return isTypeOnlySpecifier(node);
77
86
  }
78
87
  return false;
79
88
  };
89
+ /**
90
+ * Whether a specifier binds `next/image`'s Image component. The default export
91
+ * *is* that component whatever local name it is bound to, so the binding's
92
+ * identity decides rather than the local name — otherwise `import Img from
93
+ * 'next/image'` becomes a rename-shaped bypass of the rule. `{ default as X }`
94
+ * is the same binding written differently. The named `Image` form is matched
95
+ * too, while every other named export (`getImageProps`, the prop types) is
96
+ * left alone: those are not the optimization bypass.
97
+ */
98
+ const bindsImageComponent = (specifier) => {
99
+ if (isTypeOnlySpecifier(specifier)) {
100
+ return false;
101
+ }
102
+ if (specifier.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier) {
103
+ return true;
104
+ }
105
+ // A namespace binds the module rather than the component, and is consumed
106
+ // through a member expression the fix has no shape for.
107
+ if (specifier.type !== utils_1.AST_NODE_TYPES.ImportSpecifier) {
108
+ return false;
109
+ }
110
+ return (specifier.imported.name === 'default' || specifier.imported.name === 'Image');
111
+ };
112
+ /**
113
+ * The declaration text that keeps the specifiers the fix does not move pointed
114
+ * at their original source. Rewriting the whole declaration would drop them
115
+ * while they are still referenced, and their bindings (`ImageProps`,
116
+ * `getImageProps`) come from `next/image` alone — the wrapper does not
117
+ * re-export them.
118
+ */
119
+ const retainedImportText = (specifiers, sourceCode, source) => {
120
+ const named = specifiers.filter((specifier) => specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier);
121
+ const standalone = specifiers.filter((specifier) => specifier.type !== utils_1.AST_NODE_TYPES.ImportSpecifier);
122
+ const clauses = [
123
+ ...standalone.map((specifier) => sourceCode.getText(specifier)),
124
+ ...(named.length > 0
125
+ ? [
126
+ `{ ${named
127
+ .map((specifier) => sourceCode.getText(specifier))
128
+ .join(', ')} }`,
129
+ ]
130
+ : []),
131
+ ];
132
+ return `import ${clauses.join(', ')} from ${sourceCode.getText(source)};`;
133
+ };
80
134
  const isBoundAsValue = (scope, name) => {
81
135
  const variable = utils_1.ASTUtils.findVariable(scope, name);
82
136
  return (!!variable &&
@@ -213,28 +267,32 @@ module.exports = (0, createRule_1.createRule)({
213
267
  if (isComponentImplementationFile) {
214
268
  return;
215
269
  }
216
- if (node.source.value === 'next/image' && node.specifiers.length > 0) {
217
- const imageSpecifier = node.specifiers.find((spec) => (spec.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier ||
218
- spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier) &&
219
- (spec.local.name === 'Image' ||
220
- (spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
221
- spec.imported.name === 'Image')));
222
- if (imageSpecifier) {
223
- const localName = imageSpecifier.local.name;
224
- // Report the import
225
- context.report({
226
- node,
227
- messageId: 'useImageOptimized',
228
- data: {
229
- componentPath,
230
- component: 'next/image',
231
- },
232
- fix(fixer) {
233
- return fixer.replaceText(node, `import ${localName} from '${componentPath}';`);
234
- },
235
- });
236
- }
270
+ if (node.source.value !== 'next/image') {
271
+ return;
272
+ }
273
+ // A default binding comes first in the specifier list, so this prefers
274
+ // it over a redundant named `Image` alongside it.
275
+ const imageSpecifier = node.specifiers.find(bindsImageComponent);
276
+ if (!imageSpecifier) {
277
+ return;
237
278
  }
279
+ const localName = imageSpecifier.local.name;
280
+ const retained = node.specifiers.filter((specifier) => specifier !== imageSpecifier);
281
+ context.report({
282
+ node,
283
+ messageId: 'useImageOptimized',
284
+ data: {
285
+ componentPath,
286
+ component: 'next/image',
287
+ },
288
+ fix(fixer) {
289
+ const swapped = `import ${localName} from '${componentPath}';`;
290
+ if (retained.length === 0) {
291
+ return fixer.replaceText(node, swapped);
292
+ }
293
+ return fixer.replaceText(node, `${retainedImportText(retained, sourceCode, node.source)}\n${swapped}`);
294
+ },
295
+ });
238
296
  },
239
297
  };
240
298
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.80",
3
+ "version": "1.20.82",
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.82",
4
+ "date": "2026-08-02T21:42:12.697Z",
5
+ "rules": [
6
+ {
7
+ "name": "require-image-optimized",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1623
11
+ ],
12
+ "summary": "key next/image detection on the imported binding (closes #1623)"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "version": "1.20.81",
18
+ "date": "2026-08-02T12:22:56.245Z",
19
+ "rules": [
20
+ {
21
+ "name": "no-unused-props",
22
+ "changeType": "fix",
23
+ "issues": [
24
+ 1620
25
+ ],
26
+ "summary": "resolve the props type from an FC-annotated declarator (closes #1620)"
27
+ },
28
+ {
29
+ "name": "prefer-destructuring-no-class",
30
+ "changeType": "fix",
31
+ "issues": [
32
+ 1619
33
+ ],
34
+ "summary": "recognize annotation-carried class instances (closes #1619)"
35
+ }
36
+ ]
37
+ },
2
38
  {
3
39
  "version": "1.20.80",
4
40
  "date": "2026-08-02T11:35:36.730Z",