@blumintinc/eslint-plugin-blumint 1.20.159 → 1.20.161

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.159',
226
+ version: '1.20.161',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -191,6 +191,68 @@ const isGlobalConstStyleGoverned = (declarator) => {
191
191
  }
192
192
  return (!isDynamicValue(init) && !isBindingAlias(init) && !isJestMockCast(init));
193
193
  };
194
+ // `as const` is spelled as a `TSTypeReference` named `const`, so it reaches
195
+ // `getTypeName` like any other type name and has to be excluded by name.
196
+ const isAsConstAssertion = (typeAnnotation) => typeAnnotation.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
197
+ typeAnnotation.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
198
+ typeAnnotation.typeName.name === 'const';
199
+ /**
200
+ * The React type an `as`/`<T>` assertion pins on an initializer, or `undefined`.
201
+ *
202
+ * An assertion declares the binding's type exactly as bindingly as an
203
+ * annotation does — `tsc --emitDeclarationOnly` emits the same
204
+ * `export declare const config: FC;` for `const config: FC = {} as FC` and for
205
+ * `const config = {} as FC` — so it is a detection carrier, not decoration.
206
+ * Reading only the annotation made the shipped config's own `--fix` erase this
207
+ * rule's report: `no-redundant-annotation-assertion` deletes the redundant
208
+ * ANNOTATION and keeps the assertion (its fix direction is the type-safe one,
209
+ * since dropping the assertion instead leaves `const config: FC = {}`, TS2322),
210
+ * so a reported violation became an unreportable one (Issue #2029).
211
+ *
212
+ * Only the OUTERMOST assertion applied to the initializer answers. A nested one
213
+ * describes a sub-expression rather than the binding: in `(e as FC)()` the
214
+ * binding holds FC's RETURN value, and in `thing as unknown as FC` the
215
+ * intermediate `unknown` is discarded by the outer `as FC`. Parentheses need no
216
+ * handling because TSESTree does not model them, so `({} as FC)` IS the
217
+ * assertion node; `!` does, and is read through — non-nullability cannot change
218
+ * which React type names the value.
219
+ *
220
+ * `satisfies` is excluded because it leaves the expression's type alone
221
+ * (`{} satisfies FC` is still `{}`), and `as const` names no React type.
222
+ */
223
+ const assertedTypeOf = (init) => {
224
+ let target = init ?? undefined;
225
+ while (target?.type === utils_1.AST_NODE_TYPES.TSNonNullExpression) {
226
+ target = target.expression;
227
+ }
228
+ if (target?.type !== utils_1.AST_NODE_TYPES.TSAsExpression &&
229
+ target?.type !== utils_1.AST_NODE_TYPES.TSTypeAssertion) {
230
+ return undefined;
231
+ }
232
+ return isAsConstAssertion(target.typeAnnotation)
233
+ ? undefined
234
+ : target.typeAnnotation;
235
+ };
236
+ /**
237
+ * Reports whether this identifier is the NAME of a JSX element (`<Content />`),
238
+ * as opposed to a value reference inside one (`{Content}`).
239
+ *
240
+ * A JSX element name resolves to a binding only while it starts uppercase: the
241
+ * scope analyzer models `<content />` as an intrinsic host element instead. So
242
+ * lowercasing such a name does not carry the reference across — it unbinds it,
243
+ * leaving the declaration orphaned and an unknown HTML tag rendered in its
244
+ * place. `JSXMemberExpression` is excluded on purpose: a dot always means value
245
+ * access, so `<ns.Thing />` keeps resolving whatever the case of `ns`.
246
+ */
247
+ const isJsxElementName = (node) => {
248
+ const { parent } = node;
249
+ if (!parent) {
250
+ return false;
251
+ }
252
+ return ((parent.type === utils_1.AST_NODE_TYPES.JSXOpeningElement ||
253
+ parent.type === utils_1.AST_NODE_TYPES.JSXClosingElement) &&
254
+ parent.name === node);
255
+ };
194
256
  exports.enforceReactTypeNaming = (0, createRule_1.createRule)({
195
257
  name: 'enforce-react-type-naming',
196
258
  meta: {
@@ -296,6 +358,28 @@ exports.enforceReactTypeNaming = (0, createRule_1.createRule)({
296
358
  newName,
297
359
  });
298
360
  }
361
+ /**
362
+ * Reports whether lowercasing this variable's name would unbind a
363
+ * `<Name />` that resolves to it.
364
+ *
365
+ * The rename is withheld there and the report stands alone, on the same
366
+ * terms as a colliding rename: an incomplete rename is worse than none. The
367
+ * rewritten element would render an intrinsic host tag while the declaration
368
+ * it used to name sits unreferenced, which is dead code the fix itself
369
+ * created — and a consumer building with `noUnusedLocals` gets a red CI out
370
+ * of running `--fix`.
371
+ *
372
+ * Asked of variables only. An unused PARAMETER is an ordinary
373
+ * signature-driven shape rather than dead code, so the parameter rename
374
+ * stays as #1357 pinned it.
375
+ */
376
+ function renameUnbindsJsxElement(owner, declarationId) {
377
+ const variable = context
378
+ .getDeclaredVariables(owner)
379
+ .find((candidate) => candidate.defs.some((def) => def.name === declarationId));
380
+ return !!variable?.references.some((reference) => reference.identifier !== declarationId &&
381
+ isJsxElementName(reference.identifier));
382
+ }
299
383
  /**
300
384
  * Check variable declarations for React type naming conventions
301
385
  */
@@ -313,8 +397,11 @@ exports.enforceReactTypeNaming = (0, createRule_1.createRule)({
313
397
  if (isGlobalConstStyleGoverned(node))
314
398
  return;
315
399
  const variableName = id.name;
316
- // Get the type annotation
317
- const typeAnnotation = id.typeAnnotation?.typeAnnotation;
400
+ // The declared type, from either carrier: the annotation when it is
401
+ // written, otherwise the assertion that stands in for it (#2029). The
402
+ // annotation wins when both are present — it is what the binding's type
403
+ // resolves to — so `const x: unknown = {} as FC` reads `unknown`.
404
+ const typeAnnotation = id.typeAnnotation?.typeAnnotation ?? assertedTypeOf(node.init);
318
405
  const typeName = getTypeName(typeAnnotation);
319
406
  if (!typeName)
320
407
  return;
@@ -328,7 +415,9 @@ exports.enforceReactTypeNaming = (0, createRule_1.createRule)({
328
415
  type: typeName,
329
416
  suggestion,
330
417
  },
331
- fix: (fixer) => buildRenameFixes(fixer, node, id, suggestion),
418
+ fix: (fixer) => renameUnbindsJsxElement(node, id)
419
+ ? null
420
+ : buildRenameFixes(fixer, node, id, suggestion),
332
421
  });
333
422
  }
334
423
  // Check if it's a ComponentType or FC (should be uppercase)
@@ -341,6 +430,8 @@ exports.enforceReactTypeNaming = (0, createRule_1.createRule)({
341
430
  type: typeName,
342
431
  suggestion,
343
432
  },
433
+ // Uppercasing cannot unbind a `<Name />`: the target starts uppercase,
434
+ // which is exactly what a JSX element name needs to resolve at all.
344
435
  fix: (fixer) => buildRenameFixes(fixer, node, id, suggestion),
345
436
  });
346
437
  }
@@ -323,6 +323,44 @@ function buildImportExtensionFix(fixer, program) {
323
323
  * that never renders. The report stands, the edit is withheld.
324
324
  */
325
325
  const isRewritableFunction = (node) => !node.async && !node.generator;
326
+ /**
327
+ * Whether the call registers the one module factory jest hoists.
328
+ *
329
+ * `jest.mock` alone: `babel-plugin-jest-hoist` carries no case for `doMock` or
330
+ * `setMock`, which exist precisely to run in place, so a factory passed to
331
+ * either keeps its access to the module's bindings and needs no carve-out.
332
+ */
333
+ function isHoistedMockCall(node) {
334
+ const { callee } = node;
335
+ if (callee.type !== utils_1.AST_NODE_TYPES.MemberExpression || callee.computed) {
336
+ return false;
337
+ }
338
+ const { object, property } = callee;
339
+ return (object.type === utils_1.AST_NODE_TYPES.Identifier &&
340
+ object.name === 'jest' &&
341
+ property.type === utils_1.AST_NODE_TYPES.Identifier &&
342
+ property.name === 'mock');
343
+ }
344
+ /**
345
+ * Whether the node sits inside the factory jest hoists — the second argument of
346
+ * the call. The module specifier that precedes it is evaluated in place and
347
+ * keeps its access to the file's imports, so only the factory subtree is out of
348
+ * reach.
349
+ */
350
+ function isInsideMockFactory(node) {
351
+ let child = node;
352
+ let parent = node.parent;
353
+ while (parent) {
354
+ if (parent.type === utils_1.AST_NODE_TYPES.CallExpression &&
355
+ parent.arguments[1] === child &&
356
+ isHoistedMockCall(parent)) {
357
+ return true;
358
+ }
359
+ child = parent;
360
+ parent = parent.parent;
361
+ }
362
+ return false;
363
+ }
326
364
  /**
327
365
  * Whether the emitted `memo(...)` call can reach the helper, and the import edit
328
366
  * that makes it so (null when the helper is already imported).
@@ -337,6 +375,15 @@ const isRewritableFunction = (node) => !node.async && !node.generator;
337
375
  * default import rather than a `memo` binding, so it never reaches this path.
338
376
  */
339
377
  function planMemoBinding(context, fixer, node) {
378
+ // Jest hoists a `jest.mock()` factory above the module's imports, so the
379
+ // helper is unbound when the factory runs and jest rejects the reference
380
+ // outright ("Invalid variable access: memo"). This holds whether the import
381
+ // is injected here or already present, so it is decided before the
382
+ // already-imported shortcut below. The report stands because legal spellings
383
+ // exist: `import { memo as mockMemo }`, or `jest.requireActual` in-factory.
384
+ if (isInsideMockFactory(node)) {
385
+ return { available: false, importFix: null };
386
+ }
340
387
  const existingMemo = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, node), MEMO_NAME);
341
388
  if (existingMemo && !bindsMemoHelper(existingMemo)) {
342
389
  return { available: false, importFix: null };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.159",
3
+ "version": "1.20.161",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,32 @@
1
1
  [
2
+ {
3
+ "version": "1.20.161",
4
+ "date": "2026-08-17T13:22:13.676Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-react-type-naming",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2029
11
+ ],
12
+ "summary": "read the type from a type assertion (closes #2029)"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "version": "1.20.160",
18
+ "date": "2026-08-17T02:55:06.538Z",
19
+ "rules": [
20
+ {
21
+ "name": "require-memo",
22
+ "changeType": "fix",
23
+ "issues": [
24
+ 2028
25
+ ],
26
+ "summary": "decline the memo rewrite inside a jest.mock factory (closes #2028)"
27
+ }
28
+ ]
29
+ },
2
30
  {
3
31
  "version": "1.20.159",
4
32
  "date": "2026-08-17T02:05:07.694Z",