@blumintinc/eslint-plugin-blumint 1.20.104 → 1.20.105

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.104',
226
+ version: '1.20.105',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -1,5 +1,5 @@
1
- import { TSESTree } from '@typescript-eslint/utils';
2
- declare const enforceFirebaseImports: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"noDynamicImport", never[], {
1
+ import { TSESLint, TSESTree } from '@typescript-eslint/utils';
2
+ declare const enforceFirebaseImports: TSESLint.RuleModule<"noDynamicImport", never[], {} | {
3
3
  ImportDeclaration(node: TSESTree.ImportDeclaration): void;
4
4
  }>;
5
5
  export default enforceFirebaseImports;
@@ -1,6 +1,60 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ const utils_1 = require("@typescript-eslint/utils");
3
4
  const createRule_1 = require("../utils/createRule");
5
+ const isFunctionNode = (node) => node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
6
+ node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
7
+ node.type === utils_1.AST_NODE_TYPES.FunctionExpression;
8
+ /**
9
+ * Walks outward from a reference to the innermost `async` function whose block
10
+ * body contains it.
11
+ *
12
+ * A reference sitting in a *synchronous* callback nested inside an async
13
+ * function still resolves once the declaration heads the async body, because
14
+ * the callback cannot run before the first statement of the body it is created
15
+ * in — so the walk continues past non-async functions rather than giving up.
16
+ *
17
+ * The containment check is against the body rather than the function: a
18
+ * reference in a parameter default or a signature type annotation is evaluated
19
+ * before the body runs, so a declaration at the top of the body would come too
20
+ * late for it.
21
+ */
22
+ const enclosingAsyncBodyOf = (identifier) => {
23
+ let current = identifier.parent;
24
+ while (current) {
25
+ if (isFunctionNode(current) &&
26
+ current.async &&
27
+ current.body.type === utils_1.AST_NODE_TYPES.BlockStatement &&
28
+ identifier.range[0] >= current.body.range[0] &&
29
+ identifier.range[1] <= current.body.range[1]) {
30
+ return current;
31
+ }
32
+ current = current.parent;
33
+ }
34
+ return undefined;
35
+ };
36
+ const THIRD_PARTY_DIRECTORY = /(^|\/)node_modules(\/|$)/;
37
+ // Anchored at the end of the path so multi-part suffixes such as
38
+ // `useStartMatch.integration.test.ts` are recognized while production modules
39
+ // that merely contain the word (`latest.tsx`, `contest.ts`, `testHelpers.ts`)
40
+ // keep their enforcement.
41
+ const TEST_FILE_SUFFIX = /\.(test|spec)\.[cm]?[jt]sx?$/;
42
+ // Jest convention directories hold test-only modules regardless of file name.
43
+ const TEST_FILE_DIRECTORY = /(^|\/)(__tests__|__mocks__)\//;
44
+ /**
45
+ * The rule's rationale is bundle weight: a static import pulls Firebase into the
46
+ * initial client chunk. A suite, a Jest manual mock and a declaration file are
47
+ * never part of that chunk, so there is nothing to inflate and the rule has
48
+ * nothing to enforce there.
49
+ *
50
+ * The exemption is load-bearing rather than cosmetic because the rule is
51
+ * fixable: a suite's static binding is exactly what `jest.mock()` hoisting
52
+ * intercepts, and rewriting it emits a module-scope `await import(...)` that a
53
+ * CommonJS test transform cannot even parse (issue #1715).
54
+ */
55
+ const isNeverBundled = (filename) => filename.endsWith('.d.ts') ||
56
+ TEST_FILE_SUFFIX.test(filename) ||
57
+ TEST_FILE_DIRECTORY.test(filename);
4
58
  const enforceFirebaseImports = (0, createRule_1.createRule)({
5
59
  name: 'enforce-dynamic-firebase-imports',
6
60
  meta: {
@@ -13,18 +67,26 @@ const enforceFirebaseImports = (0, createRule_1.createRule)({
13
67
  hasSuggestions: true,
14
68
  schema: [],
15
69
  messages: {
16
- noDynamicImport: 'Static import from firebaseCloud path "{{importPath}}" eagerly bundles Firebase code into the initial client chunk, which inflates startup time and prevents lazy loading. Replace it with an awaited dynamic import so the code only loads when invoked (e.g., `const module = await import(\'{{importPath}}\')` or destructure the exports you need).',
70
+ noDynamicImport: 'Static import from firebaseCloud path "{{importPath}}" eagerly bundles Firebase code into the initial client chunk, which inflates startup time and prevents lazy loading. Load it at the call site instead, inside an async function body (e.g., `const { export } = await import(\'{{importPath}}\')`). Keep it out of module scope: a top-level `await import(...)` defers nothing and does not parse once the module is compiled to CommonJS.',
17
71
  },
18
72
  },
19
73
  defaultOptions: [],
20
74
  create(context) {
75
+ const sourceCode = context.getSourceCode();
76
+ // Normalize Windows backslash separators so the forward-slash directory
77
+ // checks match on every platform. Without this, `getFilename()` returns
78
+ // `C:\repo\src\hooks\__tests__\Foo.ts` on Windows and the exemption
79
+ // silently fails there.
80
+ const filename = (context.getFilename?.() ?? '').replace(/\\/g, '/');
81
+ // `<input>`/`<text>` are the synthetic names RuleTester uses when a case
82
+ // declares no filename. They match none of the exemptions below, so a
83
+ // snippet keeps its enforcement — unlike a path-gated rule, this one has no
84
+ // include list to fall outside of.
85
+ if (THIRD_PARTY_DIRECTORY.test(filename) || isNeverBundled(filename)) {
86
+ return {};
87
+ }
21
88
  return {
22
89
  ImportDeclaration(node) {
23
- // Skip third-party files
24
- const filename = context.getFilename?.();
25
- if (filename && /(^|[\\/])node_modules([\\/]|$)/.test(filename)) {
26
- return;
27
- }
28
90
  // Skip type-only import declarations
29
91
  if (node.importKind === 'type') {
30
92
  return;
@@ -51,75 +113,152 @@ const enforceFirebaseImports = (0, createRule_1.createRule)({
51
113
  ? spec.imported.name
52
114
  : `${spec.imported.name} as ${spec.local.name}`)
53
115
  .join(', ');
54
- const buildReplacement = (options = {}) => {
55
- const statements = [];
56
- if (typeOnlySpecifiers.length > 0) {
57
- statements.push(`import type { ${buildTypeNames()} } from '${importPath}';`);
58
- }
116
+ const destructureEntry = (spec) => spec.imported.name === spec.local.name
117
+ ? spec.local.name
118
+ : `${spec.imported.name}: ${spec.local.name}`;
119
+ const buildValueStatements = () => {
59
120
  if (namespaceSpecifier) {
60
121
  const nsLocal = namespaceSpecifier.local.name;
61
- statements.push(`const ${nsLocal} = await import('${importPath}');`);
122
+ const statements = [
123
+ `const ${nsLocal} = await import('${importPath}');`,
124
+ ];
62
125
  if (defaultSpecifier) {
63
- const defLocal = defaultSpecifier.local.name;
64
- statements.push(`const ${defLocal} = ${nsLocal}.default;`);
126
+ statements.push(`const ${defaultSpecifier.local.name} = ${nsLocal}.default;`);
65
127
  }
66
- const destructureFromNamespace = [];
67
128
  if (namedSpecifiers.length > 0) {
68
- const destructureParts = namedSpecifiers.map((spec) => {
69
- const imported = spec.imported.name;
70
- const local = spec.local.name;
71
- return imported === local ? imported : `${imported}: ${local}`;
72
- });
73
- destructureFromNamespace.push(...destructureParts);
129
+ statements.push(`const { ${namedSpecifiers
130
+ .map(destructureEntry)
131
+ .join(', ')} } = ${nsLocal};`);
74
132
  }
75
- if (destructureFromNamespace.length > 0) {
76
- statements.push(`const { ${destructureFromNamespace.join(', ')} } = ${nsLocal};`);
77
- }
78
- return statements.join(' ');
133
+ return statements;
79
134
  }
80
- const destructureParts = [];
81
- if (defaultSpecifier) {
82
- const defLocal = defaultSpecifier.local.name;
83
- destructureParts.push(`default: ${defLocal}`);
135
+ const destructureParts = [
136
+ ...(defaultSpecifier
137
+ ? [`default: ${defaultSpecifier.local.name}`]
138
+ : []),
139
+ ...namedSpecifiers.map(destructureEntry),
140
+ ];
141
+ // A side-effect import binds nothing, so there is no declaration to
142
+ // relocate — the awaited call would have to stay at module scope.
143
+ return destructureParts.length > 0
144
+ ? [
145
+ `const { ${destructureParts.join(', ')} } = await import('${importPath}');`,
146
+ ]
147
+ : [];
148
+ };
149
+ /**
150
+ * An `ImportDeclaration` only ever sits at module scope, so rewriting
151
+ * it in place can only ever produce a module-scope `await import(...)`
152
+ * — which defers nothing (the module still awaits it during
153
+ * evaluation) and does not even parse once the file is compiled to
154
+ * CommonJS, where top-level await does not exist (issue #1716).
155
+ *
156
+ * The rewrite is therefore only expressible when every value reference
157
+ * lives in one async function body: the declaration can then head that
158
+ * body, exactly the shape the codebase writes by hand. Anything else
159
+ * is a per-call-site refactor the fixer declines rather than corrupts.
160
+ */
161
+ const findRelocationTarget = () => {
162
+ const valueLocalNames = new Set([
163
+ defaultSpecifier?.local.name,
164
+ namespaceSpecifier?.local.name,
165
+ ...namedSpecifiers.map((spec) => spec.local.name),
166
+ ].filter((name) => name !== undefined));
167
+ const references = context
168
+ .getDeclaredVariables(node)
169
+ .filter((variable) => valueLocalNames.has(variable.name))
170
+ .flatMap((variable) => variable.references);
171
+ // Nothing reads the binding, so there is no call site to defer to.
172
+ if (references.length === 0) {
173
+ return undefined;
84
174
  }
85
- if (namedSpecifiers.length > 0) {
86
- for (const spec of namedSpecifiers) {
87
- const imported = spec.imported.name;
88
- const local = spec.local.name;
89
- destructureParts.push(imported === local ? imported : `${imported}: ${local}`);
175
+ let target;
176
+ for (const reference of references) {
177
+ const enclosing = enclosingAsyncBodyOf(reference.identifier);
178
+ if (!enclosing || (target && target !== enclosing)) {
179
+ return undefined;
90
180
  }
181
+ target = enclosing;
91
182
  }
92
- if (destructureParts.length > 0) {
93
- statements.push(`const { ${destructureParts.join(', ')} } = await import('${importPath}');`);
94
- return statements.join(' ');
183
+ return target;
184
+ };
185
+ const indentationAt = (line) => /^[ \t]*/.exec(sourceCode.lines[line - 1] ?? '')?.[0] ?? '';
186
+ /**
187
+ * Consumes the import's own trailing whitespace, and its line break
188
+ * when the import owns the line, so the removal strands neither a blank
189
+ * line nor the indentation of whatever shared the line with it.
190
+ * Anything that is not whitespace — a trailing comment, a statement —
191
+ * is left untouched.
192
+ */
193
+ const removalEnd = () => {
194
+ const text = sourceCode.getText();
195
+ let cursor = node.range[1];
196
+ while (cursor < text.length &&
197
+ (text[cursor] === ' ' || text[cursor] === '\t')) {
198
+ cursor += 1;
199
+ }
200
+ if (text[cursor] === '\n') {
201
+ return cursor + 1;
95
202
  }
96
- if (node.specifiers.length === 0) {
97
- return options.allowSideEffectFix !== false
98
- ? `await import('${importPath}');`
99
- : null;
203
+ if (text[cursor] === '\r' && text[cursor + 1] === '\n') {
204
+ return cursor + 2;
205
+ }
206
+ return cursor;
207
+ };
208
+ const buildFix = (fixer) => {
209
+ const target = findRelocationTarget();
210
+ const statements = buildValueStatements();
211
+ if (!target || statements.length === 0) {
212
+ return null;
100
213
  }
101
- return null;
214
+ const body = target.body;
215
+ // A directive stops being a directive the moment a declaration
216
+ // precedes it, so `'use server'` on a server action would silently
217
+ // become a discarded string expression. The declaration goes after
218
+ // the whole prologue instead.
219
+ const prologueLength = body.body.findIndex((statement) => statement.type !== utils_1.AST_NODE_TYPES.ExpressionStatement ||
220
+ statement.expression.type !== utils_1.AST_NODE_TYPES.Literal ||
221
+ typeof statement.expression.value !== 'string');
222
+ const directives = body.body.slice(0, prologueLength === -1 ? body.body.length : prologueLength);
223
+ const lastDirective = directives[directives.length - 1];
224
+ const following = body.body[directives.length];
225
+ const anchorLine = lastDirective
226
+ ? lastDirective.loc.end.line
227
+ : body.loc.start.line;
228
+ const neighbour = following ?? lastDirective;
229
+ // A body written on one line keeps its shape; a multi-line body gets
230
+ // the declaration on its own line at the body's own indentation.
231
+ const insertion = following && following.loc.start.line === anchorLine
232
+ ? ` ${statements.join(' ')}`
233
+ : statements
234
+ .map((statement) => {
235
+ const indent = neighbour
236
+ ? indentationAt(neighbour.loc.start.line)
237
+ : `${indentationAt(target.loc.start.line)} `;
238
+ return `\n${indent}${statement}`;
239
+ })
240
+ .join('');
241
+ 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()]),
247
+ lastDirective
248
+ ? fixer.insertTextAfter(lastDirective, insertion)
249
+ : fixer.insertTextAfterRange([body.range[0], body.range[0] + 1], insertion),
250
+ ];
102
251
  };
103
252
  context.report({
104
253
  node,
105
254
  messageId: 'noDynamicImport',
106
255
  data: { importPath },
107
- fix(fixer) {
108
- const replacement = buildReplacement();
109
- return replacement ? fixer.replaceText(node, replacement) : null;
110
- },
256
+ fix: buildFix,
111
257
  suggest: [
112
258
  {
113
259
  messageId: 'noDynamicImport',
114
260
  data: { importPath },
115
- fix(fixer) {
116
- const replacement = buildReplacement({
117
- allowSideEffectFix: true,
118
- });
119
- return replacement
120
- ? fixer.replaceText(node, replacement)
121
- : null;
122
- },
261
+ fix: buildFix,
123
262
  },
124
263
  ],
125
264
  });
@@ -215,6 +215,20 @@ function isObjectLikeType(type, checker) {
215
215
  if (type.getCallSignatures().length > 0) {
216
216
  return 'non-object';
217
217
  }
218
+ /**
219
+ * A construct-signature-only type — a class reference, a `…Constructor<P>`
220
+ * interface, the `ComponentClass` half of `ComponentType` — carries behaviour,
221
+ * not data. Its own properties are statics, so `Object.keys()` is `[]` for a
222
+ * plain class or component even when a valid value was supplied, and the
223
+ * emptiness check this rule prescribes would invert the guard rather than
224
+ * harden it. Unions reach this branch through the recursive call above, which
225
+ * matters because a union counts as an object when ANY member does: without
226
+ * this, the constructor half alone classified a whole `ComponentType` union as
227
+ * a data object.
228
+ */
229
+ if (type.getConstructSignatures().length > 0) {
230
+ return 'non-object';
231
+ }
218
232
  if (hasRequiredProperties(type, checker)) {
219
233
  return 'non-object';
220
234
  }
@@ -1,7 +1,8 @@
1
+ import { TSESLint } from '@typescript-eslint/utils';
1
2
  type Options = [
2
3
  {
3
4
  additionalHocNames?: string[];
4
5
  }
5
6
  ];
6
- export declare const memoizeRootLevelHocs: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"wrapHocInUseMemo", Options, import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
7
+ export declare const memoizeRootLevelHocs: TSESLint.RuleModule<"wrapHocInUseMemo", Options, TSESLint.RuleListener>;
7
8
  export {};
@@ -121,26 +121,150 @@ const getCallableIdentifierName = (callee) => {
121
121
  }
122
122
  return null;
123
123
  };
124
- const isHocIdentifier = (name, additionalHocs) => {
125
- if (additionalHocs.has(name)) {
126
- return true;
127
- }
124
+ const hasHocNameShape = (name) => {
128
125
  if (!name.startsWith('with')) {
129
126
  return false;
130
127
  }
131
128
  const suffix = name.charAt(4);
132
129
  return Boolean(suffix) && /^[A-Z]$/.test(suffix);
133
130
  };
134
- const findHocName = (node, additionalHocs) => {
131
+ /**
132
+ * Strips wrappers that carry no runtime meaning so a component argument stays
133
+ * recognizable behind `as`, `!`, `satisfies` and optional-chaining nodes.
134
+ */
135
+ const unwrapExpression = (node) => {
136
+ let current = node;
137
+ for (;;) {
138
+ if (current.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
139
+ current.type === utils_1.AST_NODE_TYPES.TSSatisfiesExpression ||
140
+ current.type === utils_1.AST_NODE_TYPES.TSNonNullExpression ||
141
+ current.type === utils_1.AST_NODE_TYPES.TSTypeAssertion ||
142
+ current.type === utils_1.AST_NODE_TYPES.TSInstantiationExpression ||
143
+ current.type === utils_1.AST_NODE_TYPES.ChainExpression) {
144
+ current = current.expression;
145
+ continue;
146
+ }
147
+ return current;
148
+ }
149
+ };
150
+ const findHocNameMatch = (node, additionalHocs) => {
135
151
  const identifier = getCallableIdentifierName(node.callee);
136
- if (identifier && isHocIdentifier(identifier, additionalHocs)) {
137
- return identifier;
152
+ if (identifier) {
153
+ if (additionalHocs.has(identifier)) {
154
+ return { name: identifier, configured: true };
155
+ }
156
+ if (hasHocNameShape(identifier)) {
157
+ return { name: identifier, configured: false };
158
+ }
138
159
  }
139
- if (node.callee.type === utils_1.AST_NODE_TYPES.CallExpression) {
140
- return findHocName(node.callee, additionalHocs);
160
+ const callee = unwrapExpression(node.callee);
161
+ if (callee.type === utils_1.AST_NODE_TYPES.CallExpression) {
162
+ return findHocNameMatch(callee, additionalHocs);
141
163
  }
142
164
  return null;
143
165
  };
166
+ /**
167
+ * Collects the arguments of every call in a curried chain, so the component
168
+ * passed to `withStyles(styles)(Component)` still counts as evidence for the
169
+ * outer call even though it names the HOC through its callee.
170
+ */
171
+ const collectCallChainArguments = (node) => {
172
+ const args = [];
173
+ let current = node;
174
+ while (current.type === utils_1.AST_NODE_TYPES.CallExpression) {
175
+ args.push(...current.arguments);
176
+ current = unwrapExpression(current.callee);
177
+ }
178
+ return args;
179
+ };
180
+ /**
181
+ * A `with[A-Z]…` name alone says nothing: string utilities such as
182
+ * `withOpacity(color, 0.3)` share the shape. Reporting requires positive
183
+ * structural evidence that the call operates on a component.
184
+ */
185
+ const isComponentEvidence = (node, ctx) => {
186
+ const target = unwrapExpression(node);
187
+ if (ctx.visiting.has(target)) {
188
+ return false;
189
+ }
190
+ ctx.visiting.add(target);
191
+ try {
192
+ switch (target.type) {
193
+ case utils_1.AST_NODE_TYPES.Identifier:
194
+ return (isComponentName(target.name) || resolvesToComponentValue(target, ctx));
195
+ case utils_1.AST_NODE_TYPES.MemberExpression:
196
+ return (!target.computed &&
197
+ target.property.type === utils_1.AST_NODE_TYPES.Identifier &&
198
+ isComponentName(target.property.name));
199
+ case utils_1.AST_NODE_TYPES.ArrowFunctionExpression:
200
+ case utils_1.AST_NODE_TYPES.FunctionExpression:
201
+ case utils_1.AST_NODE_TYPES.FunctionDeclaration:
202
+ return containsJsx(getBodyNodeForJsxCheck(target));
203
+ case utils_1.AST_NODE_TYPES.ClassExpression:
204
+ case utils_1.AST_NODE_TYPES.ClassDeclaration:
205
+ return true;
206
+ case utils_1.AST_NODE_TYPES.CallExpression:
207
+ return getHocName(target, ctx) !== null;
208
+ case utils_1.AST_NODE_TYPES.ConditionalExpression:
209
+ return (isComponentEvidence(target.consequent, ctx) ||
210
+ isComponentEvidence(target.alternate, ctx));
211
+ case utils_1.AST_NODE_TYPES.LogicalExpression:
212
+ return (isComponentEvidence(target.left, ctx) ||
213
+ isComponentEvidence(target.right, ctx));
214
+ case utils_1.AST_NODE_TYPES.SpreadElement:
215
+ return isComponentEvidence(target.argument, ctx);
216
+ default:
217
+ return false;
218
+ }
219
+ }
220
+ finally {
221
+ ctx.visiting.delete(target);
222
+ }
223
+ };
224
+ /**
225
+ * Resolves a lowercase argument through scope analysis rather than assuming it
226
+ * might be a component. Component bindings are conventionally capitalized, so a
227
+ * lowercase name only counts when its declaration proves it holds a component —
228
+ * which keeps `withPortal(build)` reported while leaving `withOpacity(color,
229
+ * 0.3)` alone. A binding the scope cannot resolve (an import, a parameter, a
230
+ * global) proves nothing and is therefore not evidence.
231
+ */
232
+ const resolvesToComponentValue = (identifier, ctx) => {
233
+ const variable = ctx.resolveVariable(identifier);
234
+ if (!variable) {
235
+ return false;
236
+ }
237
+ return variable.defs.some((def) => {
238
+ const defNode = def.node;
239
+ if (defNode.type === utils_1.AST_NODE_TYPES.VariableDeclarator) {
240
+ return (defNode.id === def.name &&
241
+ Boolean(defNode.init) &&
242
+ isComponentEvidence(defNode.init, ctx));
243
+ }
244
+ // A parameter definition shares its node with the enclosing function, so the
245
+ // function's own JSX must not be credited to the parameter.
246
+ if (defNode.type === utils_1.AST_NODE_TYPES.FunctionDeclaration) {
247
+ return (defNode.id === def.name && containsJsx(getBodyNodeForJsxCheck(defNode)));
248
+ }
249
+ if (defNode.type === utils_1.AST_NODE_TYPES.ClassDeclaration) {
250
+ return defNode.id === def.name;
251
+ }
252
+ return false;
253
+ });
254
+ };
255
+ const getHocName = (node, ctx) => {
256
+ const match = findHocNameMatch(node, ctx.additionalHocs);
257
+ if (!match) {
258
+ return null;
259
+ }
260
+ // An explicitly configured name is a deliberate opt-in, so it is trusted
261
+ // without any structural confirmation.
262
+ if (match.configured) {
263
+ return match.name;
264
+ }
265
+ const hasEvidence = collectCallChainArguments(node).some((argument) => isComponentEvidence(argument, ctx));
266
+ return hasEvidence ? match.name : null;
267
+ };
144
268
  /**
145
269
  * Detects chained HOC calls where an inner call is immediately invoked by
146
270
  * another call (for example, withHoc(Component)()). We only treat calls as
@@ -183,6 +307,38 @@ exports.memoizeRootLevelHocs = (0, createRule_1.createRule)({
183
307
  defaultOptions,
184
308
  create(context, [options]) {
185
309
  const additionalHocs = new Set(options?.additionalHocNames ?? []);
310
+ const sourceCode = context.getSourceCode();
311
+ /**
312
+ * Walks outward to the nearest scope owning the identifier, then up the
313
+ * scope chain. Acquiring scopes from the node keeps resolution independent
314
+ * of where the traversal currently sits.
315
+ */
316
+ const resolveVariable = (identifier) => {
317
+ const { scopeManager } = sourceCode;
318
+ if (!scopeManager) {
319
+ return null;
320
+ }
321
+ let scope = null;
322
+ let current = identifier;
323
+ while (current && !scope) {
324
+ scope = scopeManager.acquire(current, true);
325
+ current = current.parent;
326
+ }
327
+ scope = scope ?? scopeManager.globalScope;
328
+ while (scope) {
329
+ const variable = scope.variables.find((candidate) => candidate.name === identifier.name);
330
+ if (variable) {
331
+ return variable;
332
+ }
333
+ scope = scope.upper;
334
+ }
335
+ return null;
336
+ };
337
+ const hocContext = {
338
+ additionalHocs,
339
+ resolveVariable,
340
+ visiting: new Set(),
341
+ };
186
342
  const reportUnmemoizedHoc = (node, hocName, contextInfo) => {
187
343
  context.report({
188
344
  node,
@@ -194,9 +350,9 @@ exports.memoizeRootLevelHocs = (0, createRule_1.createRule)({
194
350
  });
195
351
  };
196
352
  const checkHocCall = (callExpr, contextInfo) => {
197
- const hocName = findHocName(callExpr, additionalHocs);
353
+ const hocName = getHocName(callExpr, hocContext);
198
354
  const parentCall = getParentCallExpression(callExpr);
199
- const parentHocName = parentCall && findHocName(parentCall, additionalHocs);
355
+ const parentHocName = parentCall && getHocName(parentCall, hocContext);
200
356
  if (hocName && !isPartOfHocChain(hocName, parentHocName)) {
201
357
  reportUnmemoizedHoc(callExpr, hocName, contextInfo);
202
358
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.104",
3
+ "version": "1.20.105",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,35 @@
1
1
  [
2
+ {
3
+ "version": "1.20.105",
4
+ "date": "2026-08-04T22:42:11.381Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-dynamic-firebase-imports",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1715,
11
+ 1716
12
+ ],
13
+ "summary": "relocate the dynamic import to its call site (closes #1716); exempt never-bundled files (closes #1715)"
14
+ },
15
+ {
16
+ "name": "enforce-empty-object-check",
17
+ "changeType": "fix",
18
+ "issues": [
19
+ 1718
20
+ ],
21
+ "summary": "exempt constructable types, not just callable ones (closes #1718)"
22
+ },
23
+ {
24
+ "name": "memoize-root-level-hocs",
25
+ "changeType": "fix",
26
+ "issues": [
27
+ 1717
28
+ ],
29
+ "summary": "require component evidence, not just a with[A-Z] name (closes #1717)"
30
+ }
31
+ ]
32
+ },
2
33
  {
3
34
  "version": "1.20.104",
4
35
  "date": "2026-08-04T20:46:47.102Z",