@blumintinc/eslint-plugin-blumint 1.20.120 → 1.20.121

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.120',
226
+ version: '1.20.121',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -6,8 +6,8 @@ const isFunctionNode = (node) => node.type === utils_1.AST_NODE_TYPES.ArrowFunct
6
6
  node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
7
7
  node.type === utils_1.AST_NODE_TYPES.FunctionExpression;
8
8
  /**
9
- * Walks outward from a reference to the innermost `async` function whose block
10
- * body contains it.
9
+ * Walks outward from a reference to the innermost `async` function whose body
10
+ * both contains it and satisfies `hostsDeclaration`.
11
11
  *
12
12
  * A reference sitting in a *synchronous* callback nested inside an async
13
13
  * function still resolves once the declaration heads the async body, because
@@ -19,12 +19,12 @@ const isFunctionNode = (node) => node.type === utils_1.AST_NODE_TYPES.ArrowFunct
19
19
  * before the body runs, so a declaration at the top of the body would come too
20
20
  * late for it.
21
21
  */
22
- const enclosingAsyncBodyOf = (identifier) => {
22
+ const enclosingAsyncFunctionOf = (identifier, hostsDeclaration) => {
23
23
  let current = identifier.parent;
24
24
  while (current) {
25
25
  if (isFunctionNode(current) &&
26
26
  current.async &&
27
- current.body.type === utils_1.AST_NODE_TYPES.BlockStatement &&
27
+ hostsDeclaration(current) &&
28
28
  identifier.range[0] >= current.body.range[0] &&
29
29
  identifier.range[1] <= current.body.range[1]) {
30
30
  return current;
@@ -33,6 +33,16 @@ const enclosingAsyncBodyOf = (identifier) => {
33
33
  }
34
34
  return undefined;
35
35
  };
36
+ const enclosingAsyncBodyOf = (identifier) => enclosingAsyncFunctionOf(identifier, (fn) => fn.body.type === utils_1.AST_NODE_TYPES.BlockStatement);
37
+ /**
38
+ * The innermost `async` arrow whose *concise* body holds the reference.
39
+ *
40
+ * A concise body is a single expression with no statement list to head, so the
41
+ * declaration only becomes expressible once the arrow gains a block. That is a
42
+ * larger edit than heading an existing body, which is why this search runs only
43
+ * for references no async block encloses — an enclosing block always wins.
44
+ */
45
+ const enclosingConciseAsyncArrowOf = (identifier) => enclosingAsyncFunctionOf(identifier, (fn) => fn.body.type !== utils_1.AST_NODE_TYPES.BlockStatement);
36
46
  const THIRD_PARTY_DIRECTORY = /(^|\/)node_modules(\/|$)/;
37
47
  // Anchored at the end of the path so multi-part suffixes such as
38
48
  // `useStartMatch.integration.test.ts` are recognized while production modules
@@ -174,7 +184,8 @@ const enforceFirebaseImports = (0, createRule_1.createRule)({
174
184
  }
175
185
  let target;
176
186
  for (const reference of references) {
177
- const enclosing = enclosingAsyncBodyOf(reference.identifier);
187
+ const enclosing = enclosingAsyncBodyOf(reference.identifier) ??
188
+ enclosingConciseAsyncArrowOf(reference.identifier);
178
189
  if (!enclosing || (target && target !== enclosing)) {
179
190
  return undefined;
180
191
  }
@@ -205,12 +216,50 @@ const enforceFirebaseImports = (0, createRule_1.createRule)({
205
216
  }
206
217
  return cursor;
207
218
  };
219
+ /**
220
+ * Gives a concise-bodied arrow the block its declaration needs, turning
221
+ * the returned expression into an explicit `return`.
222
+ *
223
+ * The expression is spliced verbatim out of the source rather than
224
+ * reprinted from the AST: the parentheses around an object literal are
225
+ * not part of its node, and a comment sitting between `=>` and the
226
+ * expression belongs to neither, so both survive only by copying the
227
+ * text the arrow already owns.
228
+ */
229
+ const blockifyConciseBody = (fixer, arrow, statements) => {
230
+ const arrowToken = sourceCode.getTokenBefore(arrow.body, {
231
+ filter: (token) => token.type === utils_1.AST_TOKEN_TYPES.Punctuator && token.value === '=>',
232
+ });
233
+ if (!arrowToken) {
234
+ return null;
235
+ }
236
+ const expression = sourceCode
237
+ .getText()
238
+ .slice(arrowToken.range[1], arrow.range[1])
239
+ .trim();
240
+ const indent = indentationAt(arrow.loc.start.line);
241
+ const bodyIndent = `${indent} `;
242
+ const lines = [...statements, `return ${expression};`]
243
+ .map((statement) => `\n${bodyIndent}${statement}`)
244
+ .join('');
245
+ return fixer.replaceTextRange([arrowToken.range[1], arrow.range[1]], ` {${lines}\n${indent}}`);
246
+ };
208
247
  const buildFix = (fixer) => {
209
248
  const target = findRelocationTarget();
210
249
  const statements = buildValueStatements();
211
250
  if (!target || statements.length === 0) {
212
251
  return null;
213
252
  }
253
+ // Type-only specifiers are erased at compile time, so they stay where
254
+ // they are instead of riding along into the function body.
255
+ const importEdit = typeOnlySpecifiers.length > 0
256
+ ? fixer.replaceText(node, `import type { ${buildTypeNames()} } from '${importPath}';`)
257
+ : fixer.removeRange([node.range[0], removalEnd()]);
258
+ if (target.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression &&
259
+ target.body.type !== utils_1.AST_NODE_TYPES.BlockStatement) {
260
+ const blockified = blockifyConciseBody(fixer, target, statements);
261
+ return blockified ? [importEdit, blockified] : null;
262
+ }
214
263
  const body = target.body;
215
264
  // A directive stops being a directive the moment a declaration
216
265
  // precedes it, so `'use server'` on a server action would silently
@@ -239,11 +288,7 @@ const enforceFirebaseImports = (0, createRule_1.createRule)({
239
288
  })
240
289
  .join('');
241
290
  return [
242
- // Type-only specifiers are erased at compile time, so they stay
243
- // where they are instead of riding along into the function body.
244
- typeOnlySpecifiers.length > 0
245
- ? fixer.replaceText(node, `import type { ${buildTypeNames()} } from '${importPath}';`)
246
- : fixer.removeRange([node.range[0], removalEnd()]),
291
+ importEdit,
247
292
  lastDirective
248
293
  ? fixer.insertTextAfter(lastDirective, insertion)
249
294
  : fixer.insertTextAfterRange([body.range[0], body.range[0] + 1], insertion),
@@ -227,6 +227,22 @@ exports.noJsxInHooks = (0, createRule_1.createRule)({
227
227
  data: { hookName: parent.id.name },
228
228
  });
229
229
  }
230
+ return;
231
+ }
232
+ /**
233
+ * A concise body that is a call expression returns whatever the call
234
+ * yields, so `() => useMemo(() => <div />, [])` is the same violation
235
+ * as its block-bodied spelling. Without this branch the block scanner
236
+ * — the only place the useMemo unwrapper is reached from — never runs
237
+ * for it, and rewriting a hook to a concise arrow silences the rule.
238
+ */
239
+ if (node.body.type === utils_1.AST_NODE_TYPES.CallExpression &&
240
+ containsJsxInUseMemo(node.body)) {
241
+ context.report({
242
+ node: parent.id,
243
+ messageId: 'noJsxInHooks',
244
+ data: { hookName: parent.id.name },
245
+ });
230
246
  }
231
247
  }
232
248
  },
@@ -442,9 +442,16 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
442
442
  : first.expression;
443
443
  if (expr && expr.type === utils_1.AST_NODE_TYPES.CallExpression) {
444
444
  const callee = unwrapChainExpression(expr.callee);
445
+ // A bare identifier is a memoized callback whether the hook
446
+ // handed it back directly (`const signIn = useThing()`) or
447
+ // through a destructuring pattern, so both sets answer here.
448
+ // The arrow-body spelling is not part of the question: a
449
+ // block body delegating to the same callback is the same
450
+ // redundant wrapper the concise spelling is.
445
451
  const isHookProp = callee &&
446
452
  callee.type === utils_1.AST_NODE_TYPES.Identifier &&
447
- (hookReturnProps.has(callee.name) ||
453
+ (hookReturnObjects.has(callee.name) ||
454
+ hookReturnProps.has(callee.name) ||
448
455
  isLocallyMemoizedCallback(callee, node));
449
456
  const isHookObjMember = callee &&
450
457
  callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
@@ -82,6 +82,21 @@ function checkFunctionBody(body, param, context) {
82
82
  }
83
83
  if (!paramName)
84
84
  return;
85
+ // A block whose only statement is `return <expr>;` makes exactly the claim
86
+ // the implicit-return spelling of that same expression makes, so it is
87
+ // answered by the same predicate. A block that does other work first is a
88
+ // different claim and stays out of scope.
89
+ const soleStatement = body.body.length === 1 ? body.body[0] : null;
90
+ if (soleStatement?.type === 'ReturnStatement' &&
91
+ soleStatement.argument &&
92
+ isNullishPassthroughExpression(soleStatement.argument, paramName)) {
93
+ context.report({
94
+ node: soleStatement,
95
+ messageId: 'unexpected',
96
+ data: { paramName },
97
+ });
98
+ return;
99
+ }
85
100
  // Look for early returns based on parameter being null/undefined
86
101
  for (const statement of body.body) {
87
102
  if (statement.type === 'IfStatement') {
@@ -209,6 +224,32 @@ function containsParameterTransformation(node, paramName) {
209
224
  // are NOT considered transformations for the purpose of this rule
210
225
  return false;
211
226
  }
227
+ /**
228
+ * Recognizes the expressions that hand a nullish parameter straight back to the
229
+ * caller: `param && ...` and `param ? ... : null/undefined`.
230
+ *
231
+ * A function states this shape either as an implicit return or as a block whose
232
+ * sole statement returns it; both spellings mean the same thing, so they share
233
+ * this predicate rather than each carrying a copy that can drift.
234
+ *
235
+ * The bare-identifier passthrough (`(param) => param`) is deliberately absent.
236
+ * Its boundary is unsettled — inline callback arguments such as
237
+ * `items.filter((x) => x)` reach that shape without the prescribed remedy
238
+ * applying — so it stays confined to the implicit-return path instead of being
239
+ * duplicated into a second spelling that would have to be narrowed twice.
240
+ */
241
+ function isNullishPassthroughExpression(node, paramName) {
242
+ // (param) => param ? param.value : null
243
+ if (node.type === 'ConditionalExpression') {
244
+ return (isParameterReference(node.test, paramName) &&
245
+ isNullOrUndefinedLiteral(node.alternate));
246
+ }
247
+ // (param) => param && doSomething(param)
248
+ if (node.type === 'LogicalExpression') {
249
+ return node.operator === '&&' && isParameterReference(node.left, paramName);
250
+ }
251
+ return false;
252
+ }
212
253
  /**
213
254
  * Check arrow functions with expression bodies (implicit returns)
214
255
  */
@@ -224,28 +265,12 @@ function checkImplicitReturn(node, context) {
224
265
  }
225
266
  if (!paramName)
226
267
  return;
227
- // Check for patterns like: (param) => param ? param.value : null
228
- if (node.body.type === 'ConditionalExpression') {
229
- const test = node.body.test;
230
- if (isParameterReference(test, paramName) &&
231
- isNullOrUndefinedLiteral(node.body.alternate)) {
232
- context.report({
233
- node,
234
- messageId: 'unexpected',
235
- data: { paramName },
236
- });
237
- }
238
- }
239
- else if (node.body.type === 'LogicalExpression') {
240
- // Check for (param) => param && doSomething(param)
241
- if (node.body.operator === '&&' &&
242
- isParameterReference(node.body.left, paramName)) {
243
- context.report({
244
- node,
245
- messageId: 'unexpected',
246
- data: { paramName },
247
- });
248
- }
268
+ if (isNullishPassthroughExpression(node.body, paramName)) {
269
+ context.report({
270
+ node,
271
+ messageId: 'unexpected',
272
+ data: { paramName },
273
+ });
249
274
  }
250
275
  else if (node.body.type === 'Identifier' && node.body.name === paramName) {
251
276
  // Check for (param) => param
@@ -77,6 +77,26 @@ exports.preferUseMemoOverUseEffectUseState = (0, createRule_1.createRule)({
77
77
  const isIdentifierReference = (node) => {
78
78
  return node.type === 'Identifier';
79
79
  };
80
+ // The expression a lazy useState initializer produces, if it produces one.
81
+ // A concise arrow body and a single `return` statement declare the same
82
+ // initializer, so both spellings must resolve to the same expression rather
83
+ // than the exemption below recognizing only one of them. A block with any
84
+ // other shape does work beyond producing a value, which is outside what the
85
+ // exemption covers.
86
+ const lazyInitializerResult = (node) => {
87
+ if (node.type !== 'ArrowFunctionExpression' &&
88
+ node.type !== 'FunctionExpression') {
89
+ return null;
90
+ }
91
+ if (node.body.type !== 'BlockStatement') {
92
+ return node.body;
93
+ }
94
+ if (node.body.body.length !== 1) {
95
+ return null;
96
+ }
97
+ const statement = node.body.body[0];
98
+ return statement.type === 'ReturnStatement' ? statement.argument : null;
99
+ };
80
100
  // Helper to check if this is a state synchronization pattern
81
101
  const isStateSynchronization = (initialValue, setterArgument) => {
82
102
  // If the initial value is a reference to a prop/variable and the setter argument
@@ -88,13 +108,13 @@ exports.preferUseMemoOverUseEffectUseState = (0, createRule_1.createRule)({
88
108
  setterArgument.name) {
89
109
  return true;
90
110
  }
91
- // If the initial value is a function that references a prop and the setter argument
92
- // is that same prop, this is likely state synchronization
93
- if (initialValue &&
94
- initialValue.type === 'ArrowFunctionExpression' &&
95
- initialValue.body.type === 'Identifier' &&
111
+ // If the initial value is a lazy initializer producing a prop and the
112
+ // setter argument is that same prop, this is likely state synchronization
113
+ const lazyResult = initialValue && lazyInitializerResult(initialValue);
114
+ if (lazyResult &&
115
+ lazyResult.type === 'Identifier' &&
96
116
  isIdentifierReference(setterArgument) &&
97
- initialValue.body.name === setterArgument.name) {
117
+ lazyResult.name === setterArgument.name) {
98
118
  return true;
99
119
  }
100
120
  return false;
@@ -239,6 +239,143 @@ function buildImportExtensionFix(fixer, program) {
239
239
  }
240
240
  return null;
241
241
  }
242
+ /**
243
+ * An `async` or generator function cannot be a React component — React renders
244
+ * neither a promise nor an iterator — so memoizing one would enshrine a shape
245
+ * that never renders. The report stands, the edit is withheld.
246
+ */
247
+ const isRewritableFunction = (node) => !node.async && !node.generator;
248
+ /**
249
+ * Whether the emitted `memo(...)` call can reach the helper, and the import edit
250
+ * that makes it so (null when the helper is already imported).
251
+ *
252
+ * `available: false` withholds the whole rewrite. `memo` is resolved through the
253
+ * scope chain at the rewritten component because a binding that is not the
254
+ * helper import breaks the edit two ways: the inserted import collides with the
255
+ * existing declaration (TS2440, or TS2300 when that declaration is itself an
256
+ * import), and a binding visible at the fix site captures the emitted call with
257
+ * no compile error at all. Declining leaves the report standing so the author
258
+ * resolves the clash deliberately. `React.memo` is a member access on the
259
+ * default import rather than a `memo` binding, so it never reaches this path.
260
+ */
261
+ function planMemoBinding(context, fixer, node) {
262
+ const existingMemo = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, node), MEMO_NAME);
263
+ if (existingMemo && !bindsMemoHelper(existingMemo)) {
264
+ return { available: false, importFix: null };
265
+ }
266
+ const sourceCode = context.sourceCode;
267
+ const program = sourceCode.ast;
268
+ if (importsMemo(program)) {
269
+ return { available: true, importFix: null };
270
+ }
271
+ const extensionFix = buildImportExtensionFix(fixer, program);
272
+ if (extensionFix) {
273
+ return { available: true, importFix: extensionFix };
274
+ }
275
+ const importPath = calculateImportPath(context.getFilename());
276
+ const importStatement = `import { memo } from '${importPath}';`;
277
+ const firstImport = program.body.find((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
278
+ // An existing import hosts the helper import directly after it, keeping the
279
+ // module's imports contiguous. With none to follow, the shared anchor keeps
280
+ // the file's prologue in place: a `'use client'` directive only counts as one
281
+ // while it is the first statement, and a `#!` shebang only parses at
282
+ // character 0.
283
+ return {
284
+ available: true,
285
+ importFix: firstImport
286
+ ? fixer.insertTextAfter(firstImport, `\n${importStatement}`)
287
+ : (0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, (0, importInsertion_1.importInsertionAnchor)(sourceCode), `${importStatement}\n`),
288
+ };
289
+ }
290
+ /**
291
+ * Whether `memo(...)` can be wrapped around the initializer of `declarator`
292
+ * without changing what the binding means.
293
+ *
294
+ * A type annotation on the binding is the decisive exclusion: the wrapper's
295
+ * return type is the memo helper's, which need not be assignable to the
296
+ * declared type (`const Row: FC<Props> = ...`), so the edit would trade a
297
+ * lint report for a type error. A lone `const` declarator is the shape whose
298
+ * initializer is the binding's only definition — `let`/`var` can be reassigned
299
+ * afterwards, leaving the name bound to an unmemoized value that the edit only
300
+ * appears to have fixed, and a shared declaration's other declarators may carry
301
+ * reports of their own whose edits then compete for the same import anchor.
302
+ */
303
+ function isWrappableInitializer(declarator) {
304
+ if (declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier ||
305
+ declarator.id.typeAnnotation) {
306
+ return false;
307
+ }
308
+ const declaration = declarator.parent;
309
+ return (declaration?.type === utils_1.AST_NODE_TYPES.VariableDeclaration &&
310
+ declaration.kind === 'const' &&
311
+ declaration.declarations.length === 1);
312
+ }
313
+ /**
314
+ * Rewrites a function declaration into a memoized `const`, renaming the function
315
+ * itself to `<Name>Unmemoized` so the wrapped component keeps a display name.
316
+ */
317
+ function memoizeDeclaration(context, node) {
318
+ return function fix(fixer) {
319
+ if (!isRewritableFunction(node)) {
320
+ return null;
321
+ }
322
+ const { available, importFix } = planMemoBinding(context, fixer, node);
323
+ if (!available) {
324
+ return null;
325
+ }
326
+ const functionKeywordRange = [
327
+ node.range[0],
328
+ node.range[0] + 'function'.length,
329
+ ];
330
+ const functionKeywordReplacement = `const ${node.id.name} = memo(`;
331
+ const functionNameReplacement = `function ${node.id.name}Unmemoized`;
332
+ // `export default const X = memo(...)` is a syntax error, so a
333
+ // default-exported declaration becomes a memoized const plus a trailing
334
+ // `export default X;`. The local binding is preserved because other
335
+ // statements in the module may reference it.
336
+ const defaultExport = node.parent.type === utils_1.AST_NODE_TYPES.ExportDefaultDeclaration
337
+ ? node.parent
338
+ : null;
339
+ const fixes = [
340
+ fixer.replaceTextRange(functionKeywordRange, functionKeywordReplacement),
341
+ fixer.insertTextAfterRange([node.range[1], node.range[1]], defaultExport ? `);\nexport default ${node.id.name};` : ');'),
342
+ fixer.replaceTextRange([node.id.range[0] - 1, node.id.range[1]], functionNameReplacement),
343
+ ];
344
+ if (defaultExport) {
345
+ fixes.push(fixer.removeRange([defaultExport.range[0], node.range[0]]));
346
+ }
347
+ if (importFix) {
348
+ fixes.push(importFix);
349
+ }
350
+ return fixes;
351
+ };
352
+ }
353
+ /**
354
+ * Wraps a `const X = <function>` initializer in `memo(...)` where it stands.
355
+ *
356
+ * The binding, its name and the function's own text are left untouched, so
357
+ * every reference to the component keeps resolving to the same name and an
358
+ * anonymous initializer is not forced into a spelling it did not have.
359
+ */
360
+ function memoizeInitializer(context, node) {
361
+ return function fix(fixer) {
362
+ if (!isRewritableFunction(node)) {
363
+ return null;
364
+ }
365
+ const { available, importFix } = planMemoBinding(context, fixer, node);
366
+ if (!available) {
367
+ return null;
368
+ }
369
+ const fixes = [
370
+ fixer.insertTextBefore(node, `${MEMO_NAME}(`),
371
+ fixer.insertTextAfter(node, ')'),
372
+ ];
373
+ if (importFix) {
374
+ fixes.push(importFix);
375
+ }
376
+ return fixes;
377
+ };
378
+ }
242
379
  function checkFunction(context, node) {
243
380
  const fileName = context.getFilename();
244
381
  if (!fileName.endsWith('.tsx')) {
@@ -261,83 +398,24 @@ function checkFunction(context, node) {
261
398
  parentNode.id.type === 'Identifier' &&
262
399
  parentNode.id.name) ||
263
400
  'component';
401
+ // Both spellings of a component carry the same remedy, so both carry an
402
+ // edit: the declaration one becomes a memoized const, and an initializer
403
+ // is wrapped in place.
404
+ const fixDeclaration = isDeclarationComponent && canHostConstDeclaration(parentNode);
405
+ const fixInitializer = isArrowComponent &&
406
+ parentNode.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
407
+ isWrappableInitializer(parentNode);
264
408
  context.report({
265
409
  node,
266
410
  messageId: 'requireMemo',
267
411
  data: {
268
412
  name: componentName,
269
413
  },
270
- fix: isDeclarationComponent && canHostConstDeclaration(parentNode)
271
- ? function fix(fixer) {
272
- if (node.async || node.generator) {
273
- return null;
274
- }
275
- const sourceCode = context.sourceCode;
276
- const program = sourceCode.ast;
277
- // Resolve `memo` through the scope chain at the fixed node. A
278
- // binding that is not the helper import breaks the edit two
279
- // ways: the inserted import collides with the existing
280
- // declaration (TS2440, or TS2300 when that declaration is
281
- // itself an import), and a binding visible at the fix site
282
- // captures the emitted call with no compile error at all.
283
- // Declining leaves the report standing so the author resolves
284
- // the clash deliberately. `React.memo` is a member access on
285
- // the default import rather than a `memo` binding, so it never
286
- // reaches this path.
287
- const existingMemo = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, node), MEMO_NAME);
288
- if (existingMemo && !bindsMemoHelper(existingMemo)) {
289
- return null;
290
- }
291
- let importFix = null;
292
- if (!importsMemo(program)) {
293
- importFix = buildImportExtensionFix(fixer, program);
294
- if (!importFix) {
295
- // Calculate relative path based on current file location
296
- const currentFilePath = context.getFilename();
297
- const importPath = calculateImportPath(currentFilePath);
298
- const importStatement = `import { memo } from '${importPath}';`;
299
- const firstImport = program.body.find((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
300
- // An existing import hosts the helper import directly after
301
- // it, keeping the module's imports contiguous. With none to
302
- // follow, the shared anchor keeps the file's prologue in
303
- // place: a `'use client'` directive only counts as one while
304
- // it is the first statement, and a `#!` shebang only parses
305
- // at character 0.
306
- importFix = firstImport
307
- ? fixer.insertTextAfter(firstImport, `\n${importStatement}`)
308
- : (0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, (0, importInsertion_1.importInsertionAnchor)(sourceCode), `${importStatement}\n`);
309
- }
310
- }
311
- const functionKeywordRange = [
312
- node.range[0],
313
- node.range[0] + 'function'.length,
314
- ];
315
- const functionKeywordReplacement = `const ${node.id.name} = memo(`;
316
- // Step 3: Rename function
317
- const functionNameReplacement = `function ${node.id.name}Unmemoized`;
318
- // `export default const X = memo(...)` is a syntax error, so a
319
- // default-exported declaration becomes a memoized const plus a
320
- // trailing `export default X;`. The local binding is preserved
321
- // because other statements in the module may reference it.
322
- const defaultExport = parentNode.type === utils_1.AST_NODE_TYPES.ExportDefaultDeclaration
323
- ? parentNode
324
- : null;
325
- const fixes = [
326
- fixer.replaceTextRange(functionKeywordRange, functionKeywordReplacement),
327
- fixer.insertTextAfterRange([node.range[1], node.range[1]], defaultExport
328
- ? `);\nexport default ${node.id.name};`
329
- : ');'),
330
- fixer.replaceTextRange([node.id.range[0] - 1, node.id.range[1]], functionNameReplacement),
331
- ];
332
- if (defaultExport) {
333
- fixes.push(fixer.removeRange([defaultExport.range[0], node.range[0]]));
334
- }
335
- if (importFix) {
336
- fixes.push(importFix);
337
- }
338
- return fixes;
339
- }
340
- : undefined,
414
+ fix: fixDeclaration
415
+ ? memoizeDeclaration(context, node)
416
+ : fixInitializer
417
+ ? memoizeInitializer(context, node)
418
+ : undefined,
341
419
  });
342
420
  }
343
421
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.120",
3
+ "version": "1.20.121",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,58 @@
1
1
  [
2
+ {
3
+ "version": "1.20.121",
4
+ "date": "2026-08-06T10:37:08.150Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-dynamic-firebase-imports",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1790
11
+ ],
12
+ "summary": "remediate a concise-bodied async arrow (closes #1790)"
13
+ },
14
+ {
15
+ "name": "no-jsx-in-hooks",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1792
19
+ ],
20
+ "summary": "report a concise arrow body that is itself a memoized JSX call (closes #1792)"
21
+ },
22
+ {
23
+ "name": "no-redundant-usecallback-wrapper",
24
+ "changeType": "fix",
25
+ "issues": [
26
+ 1793
27
+ ],
28
+ "summary": "consult hookReturnObjects from the block-body arm (closes #1793)"
29
+ },
30
+ {
31
+ "name": "no-undefined-null-passthrough",
32
+ "changeType": "fix",
33
+ "issues": [
34
+ 1794
35
+ ],
36
+ "summary": "detect the guard shapes in a block-bodied arrow (closes #1794)"
37
+ },
38
+ {
39
+ "name": "prefer-usememo-over-useeffect-usestate",
40
+ "changeType": "fix",
41
+ "issues": [
42
+ 1791
43
+ ],
44
+ "summary": "recognise a block-bodied lazy initializer as state synchronization (closes #1791)"
45
+ },
46
+ {
47
+ "name": "require-memo",
48
+ "changeType": "fix",
49
+ "issues": [
50
+ 1789
51
+ ],
52
+ "summary": "wrap arrow and function-expression components in memo (closes #1789)"
53
+ }
54
+ ]
55
+ },
2
56
  {
3
57
  "version": "1.20.120",
4
58
  "date": "2026-08-06T05:46:26.078Z",