@blumintinc/eslint-plugin-blumint 1.20.33 → 1.20.35

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.33',
226
+ version: '1.20.35',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -118,25 +118,63 @@ exports.enforceGlobalConstants = (0, createRule_1.createRule)({
118
118
  function hasIdentifiers(node) {
119
119
  return !!node && ASTHelpers_1.ASTHelpers.declarationIncludesIdentifier(node);
120
120
  }
121
- function alreadyHasConst(program, constName) {
122
- for (const stmt of program.body) {
123
- if (stmt.type === utils_1.AST_NODE_TYPES.VariableDeclaration &&
124
- stmt.kind === 'const') {
125
- for (const d of stmt.declarations) {
126
- if (d.id.type === utils_1.AST_NODE_TYPES.Identifier &&
127
- d.id.name === constName) {
128
- return true;
129
- }
130
- }
121
+ function classifyModuleBinding(variable) {
122
+ if (variable.defs.length !== 1) {
123
+ return { kind: 'blocked' };
124
+ }
125
+ const declarator = variable.defs[0].node;
126
+ if (declarator.type !== utils_1.AST_NODE_TYPES.VariableDeclarator) {
127
+ return { kind: 'blocked' };
128
+ }
129
+ const declaration = declarator.parent;
130
+ if (!declaration ||
131
+ declaration.type !== utils_1.AST_NODE_TYPES.VariableDeclaration ||
132
+ declaration.kind !== 'const' ||
133
+ declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier ||
134
+ !declarator.init) {
135
+ return { kind: 'blocked' };
136
+ }
137
+ return {
138
+ kind: 'reusable',
139
+ initText: sourceCode.getText(declarator.init),
140
+ };
141
+ }
142
+ /**
143
+ * `SourceCode#getScope` supersedes the deprecated `context.getScope`; the
144
+ * fallback keeps the rule working on ESLint versions that predate it.
145
+ */
146
+ function scopeOf(node) {
147
+ const scoped = sourceCode;
148
+ return typeof scoped.getScope === 'function'
149
+ ? scoped.getScope(node)
150
+ : context.getScope();
151
+ }
152
+ function resolveGeneratedName(scope, constName) {
153
+ let current = scope;
154
+ while (current) {
155
+ const variable = current.variables.find((v) => v.name === constName);
156
+ if (variable) {
157
+ return current.block.type === utils_1.AST_NODE_TYPES.Program
158
+ ? classifyModuleBinding(variable)
159
+ : { kind: 'blocked' };
131
160
  }
161
+ current = current.upper;
132
162
  }
133
- return false;
163
+ // An unresolved reference elsewhere in the file points at an ambient
164
+ // global; declaring the name at module scope would capture it.
165
+ const globalScope = sourceCode.scopeManager?.globalScope;
166
+ if (globalScope?.through.some((ref) => ref.identifier.name === constName)) {
167
+ return { kind: 'blocked' };
168
+ }
169
+ return { kind: 'free' };
134
170
  }
135
- function buildConstDeclarationLine(constName, initText) {
171
+ function buildInitializerText(initText) {
136
172
  const needsAsConst = /^(?:true|false|-?\d|\[|\{|[`'"])/.test(initText) &&
137
173
  !/\bas const\b/.test(initText);
138
- const initializer = needsAsConst ? `${initText} as const` : initText;
139
- return `const ${constName} = ${initializer};`;
174
+ return needsAsConst ? `${initText} as const` : initText;
175
+ }
176
+ function buildConstDeclarationLine(constName, initText) {
177
+ return `const ${constName} = ${buildInitializerText(initText)};`;
140
178
  }
141
179
  function reportStaticDefaults(patterns, enclosingFn, nodeForReport) {
142
180
  if (!enclosingFn || !isComponentOrHookFunction(enclosingFn))
@@ -153,23 +191,52 @@ exports.enforceGlobalConstants = (0, createRule_1.createRule)({
153
191
  });
154
192
  if (staticDefaults.length === 0)
155
193
  return;
194
+ const reportScope = scopeOf(nodeForReport);
156
195
  context.report({
157
196
  node: nodeForReport,
158
197
  messageId: 'extractDefaultToGlobalConstant',
159
198
  fix(fixer) {
160
199
  const fixes = [];
161
- const programNode = sourceCode.ast;
162
200
  const declLines = [];
201
+ // Names this fix commits to declaring, mapped to the initializer it
202
+ // declares them with, so sibling defaults sharing a generated name
203
+ // share the declaration instead of duplicating the binding.
204
+ const scheduledInits = new Map();
163
205
  for (const def of staticDefaults) {
164
206
  const { assignment, localName } = def;
165
207
  const right = assignment.right;
166
208
  const rightText = sourceCode.getText(right);
167
209
  const constName = `DEFAULT_${toUpperSnakeCase(localName)}`;
168
- if (!alreadyHasConst(programNode, constName)) {
169
- declLines.push(buildConstDeclarationLine(constName, rightText));
210
+ const initText = buildInitializerText(rightText);
211
+ const scheduled = scheduledInits.get(constName);
212
+ if (scheduled !== undefined) {
213
+ if (scheduled !== initText)
214
+ continue;
215
+ fixes.push(fixer.replaceText(right, constName));
216
+ continue;
217
+ }
218
+ const resolution = resolveGeneratedName(reportScope, constName);
219
+ if (resolution.kind === 'blocked') {
220
+ // Declining leaves the report in place: the developer extracts
221
+ // the constant by hand instead of the fixer corrupting the file.
222
+ continue;
223
+ }
224
+ if (resolution.kind === 'reusable') {
225
+ // Reuse is safe only when the existing constant holds the very
226
+ // same value; `as const` may be present on either side.
227
+ if (resolution.initText !== initText &&
228
+ resolution.initText !== rightText) {
229
+ continue;
230
+ }
231
+ fixes.push(fixer.replaceText(right, constName));
232
+ continue;
170
233
  }
234
+ declLines.push(buildConstDeclarationLine(constName, rightText));
235
+ scheduledInits.set(constName, initText);
171
236
  fixes.push(fixer.replaceText(right, constName));
172
237
  }
238
+ if (fixes.length === 0)
239
+ return null;
173
240
  if (declLines.length > 0) {
174
241
  const program = sourceCode.ast;
175
242
  const constSection = declLines.length === 1
@@ -151,6 +151,31 @@ exports.preferGlobalRouterStateKey = (0, createRule_1.createRule)({
151
151
  isQueryKeysSource(declaration.source.value));
152
152
  }));
153
153
  }
154
+ /**
155
+ * Whether every declaration of a visible binding is the namespace or
156
+ * default import of queryKeys.ts that a qualified `alias.CONSTANT` fix
157
+ * reaches the constant through. The alias only carries the module's
158
+ * exports where it still resolves to that import at the reference: an inner
159
+ * `const QueryKeys = {…}` captures the emitted reference, the member access
160
+ * type-checks against the object, and the router key silently becomes that
161
+ * object's value instead of the shared constant.
162
+ */
163
+ function bindsQueryKeysModule(variable) {
164
+ return (variable.defs.length > 0 &&
165
+ variable.defs.every((def) => {
166
+ const specifier = def.node;
167
+ if (specifier.type !== utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier &&
168
+ specifier.type !== utils_1.AST_NODE_TYPES.ImportDefaultSpecifier) {
169
+ return false;
170
+ }
171
+ const declaration = specifier.parent;
172
+ return (declaration?.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
173
+ declaration.importKind !== 'type' &&
174
+ declaration.source.type === utils_1.AST_NODE_TYPES.Literal &&
175
+ typeof declaration.source.value === 'string' &&
176
+ isQueryKeysSource(declaration.source.value));
177
+ }));
178
+ }
154
179
  /**
155
180
  * `SourceCode#getScope` supersedes the deprecated `context.getScope`; the
156
181
  * fallback keeps the rule working on ESLint versions that predate it.
@@ -391,21 +416,41 @@ exports.preferGlobalRouterStateKey = (0, createRule_1.createRule)({
391
416
  const replacementText = localName
392
417
  ? localName
393
418
  : formatConstantReference(importAlias, suggestedConstant);
419
+ // An already-imported constant is referenced by its
420
+ // own local name, so the alias leads the emitted
421
+ // text only when no such import exists.
422
+ const referenceAlias = localName
423
+ ? undefined
424
+ : importAlias;
425
+ // Both hazards below turn on what the emitted text's
426
+ // leading name resolves to where it is written, so
427
+ // the scope chain is entered at the literal rather
428
+ // than at module scope.
429
+ const scopeAtLiteral = ASTHelpers_1.ASTHelpers.getScope(context, keyValue);
430
+ // The qualified `alias.CONSTANT` form claims no name
431
+ // of its own, yet it reaches the module's exports
432
+ // only while the alias still resolves to that import
433
+ // here. An inner binding of the alias captures it
434
+ // silently — the member access type-checks against
435
+ // whatever the local holds — so the router key would
436
+ // become that value instead of the constant.
437
+ if (referenceAlias) {
438
+ const aliasBinding = ASTHelpers_1.ASTHelpers.findVariableInScope(scopeAtLiteral, referenceAlias);
439
+ if (!aliasBinding ||
440
+ !bindsQueryKeysModule(aliasBinding)) {
441
+ return null;
442
+ }
443
+ }
394
444
  // A binding that already owns the emitted name
395
445
  // makes both halves of the edit wrong: the inserted
396
446
  // import becomes a second declaration of it
397
447
  // (TS2440/TS2300), and a shadowing local or
398
448
  // parameter captures the bare reference with no
399
- // diagnostic at all. Resolving through the scope
400
- // chain at the literal rather than the module scope
401
- // is what exposes such a shadow. Declining leaves
402
- // the report in place for the author to resolve.
403
- // The qualified `alias.CONSTANT` form reaches the
404
- // constant through the alias and claims no name of
405
- // its own.
406
- const visibleBinding = importAlias
449
+ // diagnostic at all. Declining leaves the report in
450
+ // place for the author to resolve.
451
+ const visibleBinding = referenceAlias
407
452
  ? null
408
- : ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, keyValue), replacementText);
453
+ : ASTHelpers_1.ASTHelpers.findVariableInScope(scopeAtLiteral, replacementText);
409
454
  const bindingIsQueryKeyImport = visibleBinding !== null &&
410
455
  bindsQueryKeyConstant(visibleBinding, suggestedConstant);
411
456
  if (visibleBinding && !bindingIsQueryKeyImport) {
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  const utils_1 = require("@typescript-eslint/utils");
3
3
  const path_1 = require("path");
4
+ const ASTHelpers_1 = require("../utils/ASTHelpers");
4
5
  const createRule_1 = require("../utils/createRule");
5
6
  const DEFAULT_COMPONENT_PATH = 'src/components/image/ImageOptimized';
6
7
  /** The JSX name the fixer writes, and the named export it comes from. */
@@ -82,6 +83,24 @@ const isBoundAsValue = (scope, name) => {
82
83
  variable.defs.length > 0 &&
83
84
  variable.defs.some((definition) => !isTypeOnlyImport(definition)));
84
85
  };
86
+ /**
87
+ * Whether the name the fixer is about to emit still resolves, at the report
88
+ * site, to a declaration in the file's module scope — where the component's
89
+ * import, and any module-scope stand-in such as `const ImageOptimized =
90
+ * dynamic(...)`, live. A binding introduced by an enclosing inner scope (a
91
+ * local, a parameter, a block-scoped const) captures the emitted element
92
+ * instead: the name is bound, so no reference is stranded and TypeScript
93
+ * accepts the element, yet the fix silently renders that local value rather
94
+ * than the shared wrapper.
95
+ */
96
+ const resolvesToModuleBinding = (scope, name) => {
97
+ const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, name);
98
+ // A variable with no definition is an ambient global, which is no component
99
+ // and cannot be the import the emitted element is meant to reach.
100
+ return (!!variable &&
101
+ variable.defs.length > 0 &&
102
+ variable.scope.block.type === utils_1.AST_NODE_TYPES.Program);
103
+ };
85
104
  /**
86
105
  * Local name the component is imported under, when it is aliased away from
87
106
  * `ImageOptimized`. Reusing the alias keeps the fix bound to a real import
@@ -158,7 +177,7 @@ module.exports = (0, createRule_1.createRule)({
158
177
  isInsideComponentMock(node, componentModule)) {
159
178
  return;
160
179
  }
161
- const scope = context.getScope();
180
+ const scope = ASTHelpers_1.ASTHelpers.getScope(context, node);
162
181
  const localName = isBoundAsValue(scope, COMPONENT_NAME)
163
182
  ? COMPONENT_NAME
164
183
  : aliasedLocalName(sourceCode.ast, componentModule);
@@ -176,6 +195,12 @@ module.exports = (0, createRule_1.createRule)({
176
195
  if (!localName) {
177
196
  return null;
178
197
  }
198
+ // A shadow of that name over the report site would make the swap
199
+ // render the shadow's value; declining leaves the report for the
200
+ // author to resolve the shadow by hand.
201
+ if (!resolvesToModuleBinding(scope, localName)) {
202
+ return null;
203
+ }
179
204
  const attributes = node.openingElement.attributes
180
205
  .map((attribute) => sourceCode.getText(attribute))
181
206
  .join(' ');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.33",
3
+ "version": "1.20.35",
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.35",
4
+ "date": "2026-07-30T16:38:05.368Z",
5
+ "rules": [
6
+ {
7
+ "name": "require-image-optimized",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1457
11
+ ],
12
+ "summary": "decline the autofix when a shadow captures the reused import alias (closes #1457)"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "version": "1.20.34",
18
+ "date": "2026-07-30T16:15:43.598Z",
19
+ "rules": [
20
+ {
21
+ "name": "enforce-global-constants",
22
+ "changeType": "fix",
23
+ "issues": [
24
+ 1455
25
+ ],
26
+ "summary": "decline the autofix when the generated name is taken (closes #1455)"
27
+ },
28
+ {
29
+ "name": "prefer-global-router-state-key",
30
+ "changeType": "fix",
31
+ "issues": [
32
+ 1456
33
+ ],
34
+ "summary": "decline the autofix when a shadow captures the emitted reference (closes #1456)"
35
+ }
36
+ ]
37
+ },
2
38
  {
3
39
  "version": "1.20.33",
4
40
  "date": "2026-07-30T14:50:36.025Z",