@blumintinc/eslint-plugin-blumint 1.20.95 → 1.20.96

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.
@@ -1 +1,2 @@
1
- export declare const enforceMuiRoundedIcons: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"enforceRoundedVariant", [], import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
1
+ import { TSESLint } from '@typescript-eslint/utils';
2
+ export declare const enforceMuiRoundedIcons: TSESLint.RuleModule<"enforceRoundedVariant", [], TSESLint.RuleListener>;
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.enforceMuiRoundedIcons = void 0;
4
4
  const createRule_1 = require("../utils/createRule");
5
5
  const utils_1 = require("@typescript-eslint/utils");
6
+ const renameFixes_1 = require("../utils/renameFixes");
7
+ const ASTHelpers_1 = require("../utils/ASTHelpers");
6
8
  const MUI_ICONS_BARREL = '@mui/icons-material';
7
9
  const MUI_ICONS_DEEP_PREFIX = `${MUI_ICONS_BARREL}/`;
8
10
  /**
@@ -66,6 +68,52 @@ const toRoundedIconName = (iconName) => {
66
68
  }
67
69
  return `${baseIconName}Rounded`;
68
70
  };
71
+ /** The icon a deep module path names, e.g. `AddReactionOutlined`. */
72
+ const deepImportIconName = (source) => source.split('/').pop();
73
+ /** The binding a deep import introduces for the icon its path names. */
74
+ const deepImportBinding = (node) => {
75
+ const defaultSpecifier = node.specifiers.find((specifier) => specifier.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier);
76
+ return defaultSpecifier ? defaultSpecifier.local : null;
77
+ };
78
+ /**
79
+ * The Rounded names this declaration would give to *bindings* if its fix ran.
80
+ *
81
+ * Only a binding whose name is the icon name being replaced is ever renamed, so
82
+ * an aliased specifier — whose fix touches the imported name alone — claims no
83
+ * name here. Collected across the file so two variants of one icon
84
+ * (`PersonOutlined` and `PersonSharp`, both retargeting to `PersonRounded`) can
85
+ * be spotted before either fix runs: renaming both bindings would declare the
86
+ * same name twice.
87
+ */
88
+ const claimedRoundedNames = (node) => {
89
+ if (node.source.type !== utils_1.AST_NODE_TYPES.Literal ||
90
+ typeof node.source.value !== 'string') {
91
+ return [];
92
+ }
93
+ const source = node.source.value;
94
+ if (source.startsWith(MUI_ICONS_DEEP_PREFIX)) {
95
+ const iconName = deepImportIconName(source);
96
+ const roundedVariant = iconName ? toRoundedIconName(iconName) : null;
97
+ const local = deepImportBinding(node);
98
+ return roundedVariant && local?.name === iconName ? [roundedVariant] : [];
99
+ }
100
+ if (source !== MUI_ICONS_BARREL || node.importKind === 'type') {
101
+ return [];
102
+ }
103
+ const claimed = [];
104
+ for (const specifier of node.specifiers) {
105
+ if (specifier.type !== utils_1.AST_NODE_TYPES.ImportSpecifier ||
106
+ specifier.importKind === 'type' ||
107
+ specifier.local.name !== specifier.imported.name) {
108
+ continue;
109
+ }
110
+ const roundedVariant = toRoundedIconName(specifier.imported.name);
111
+ if (roundedVariant) {
112
+ claimed.push(roundedVariant);
113
+ }
114
+ }
115
+ return claimed;
116
+ };
69
117
  exports.enforceMuiRoundedIcons = (0, createRule_1.createRule)({
70
118
  name: 'enforce-mui-rounded-icons',
71
119
  meta: {
@@ -82,19 +130,124 @@ exports.enforceMuiRoundedIcons = (0, createRule_1.createRule)({
82
130
  },
83
131
  defaultOptions: [],
84
132
  create(context) {
133
+ const sourceCode = context.getSourceCode();
134
+ /**
135
+ * Rounded names that more than one binding in the file would take. Renaming
136
+ * every claimant would declare the same name twice, so no claimant of a
137
+ * contested name is renamed.
138
+ */
139
+ const contestedNames = new Set();
140
+ /**
141
+ * Rewrites one reference to a renamed binding, or answers null when that
142
+ * site cannot be rewritten without changing more than the name.
143
+ */
144
+ const referenceRenameFixes = (fixer, referenceId, newName) => {
145
+ const parent = referenceId.parent;
146
+ // `export { PersonOutlined }` binds the module's public export name to
147
+ // this token, so renaming it would change an API contract whose importers
148
+ // a single-file fix cannot reach.
149
+ if (parent?.type === utils_1.AST_NODE_TYPES.ExportSpecifier) {
150
+ return null;
151
+ }
152
+ // A shorthand property is one token serving as both key and value:
153
+ // rewriting it renames the key too, and expanding it to `key: value`
154
+ // reshapes source the rule has no business reshaping.
155
+ if (parent?.type === utils_1.AST_NODE_TYPES.Property && parent.shorthand) {
156
+ return null;
157
+ }
158
+ // `<Icon.Sub />` and `<svg:icon />` name the binding through a compound
159
+ // tag whose closing half is not a scope reference, so the two tags cannot
160
+ // be kept in sync from here.
161
+ if (parent?.type === utils_1.AST_NODE_TYPES.JSXMemberExpression ||
162
+ parent?.type === utils_1.AST_NODE_TYPES.JSXNamespacedName) {
163
+ return null;
164
+ }
165
+ const fix = (0, renameFixes_1.renameIdentifierToken)(fixer, sourceCode, referenceId, newName);
166
+ if (!fix) {
167
+ return null;
168
+ }
169
+ if (parent?.type !== utils_1.AST_NODE_TYPES.JSXOpeningElement ||
170
+ parent.name !== referenceId) {
171
+ return [fix];
172
+ }
173
+ // The scope manager records the opening tag's name as a reference but not
174
+ // the closing tag's, so renaming the opening half alone would leave
175
+ // `<PersonRounded></PersonOutlined>` — code that no longer parses.
176
+ const element = parent.parent;
177
+ const closingName = element?.type === utils_1.AST_NODE_TYPES.JSXElement
178
+ ? element.closingElement?.name
179
+ : undefined;
180
+ if (!closingName) {
181
+ return [fix];
182
+ }
183
+ // JSX requires the closing name to spell the opening one, so its first
184
+ // token is the same name this rename replaces.
185
+ const closingFix = (0, renameFixes_1.renameIdentifierToken)(fixer, sourceCode, closingName, newName);
186
+ return closingFix ? [fix, closingFix] : null;
187
+ };
188
+ /**
189
+ * Builds the rewrites that rename the binding an icon import introduces:
190
+ * the declaring token(s) plus every in-file reference to it.
191
+ *
192
+ * Answers null when the rename cannot be applied everywhere — the caller
193
+ * then keeps the retarget that leaves the binding alone, or reports without
194
+ * a fix where no such retarget exists.
195
+ */
196
+ const bindingRenameFixes = (fixer, declaration, localIdentifier, declarationTokens, newName) => {
197
+ if (contestedNames.has(newName)) {
198
+ return null;
199
+ }
200
+ // Resolved by declaration identity rather than by name: a file may bind
201
+ // the same name in several scopes, and only this declaration's symbol
202
+ // carries the references the fix has to rewrite.
203
+ const variable = ASTHelpers_1.ASTHelpers.getDeclaredVariables(context, declaration).find((candidate) => candidate.defs.some((def) => def.name === localIdentifier));
204
+ if (!variable || (0, renameFixes_1.renameWouldCollide)(variable, newName)) {
205
+ return null;
206
+ }
207
+ const fixes = [];
208
+ for (const token of declarationTokens) {
209
+ const fix = (0, renameFixes_1.renameIdentifierToken)(fixer, sourceCode, token, newName);
210
+ if (!fix) {
211
+ return null;
212
+ }
213
+ fixes.push(fix);
214
+ }
215
+ for (const reference of variable.references) {
216
+ if (reference.identifier === localIdentifier) {
217
+ continue;
218
+ }
219
+ const referenceFixes = referenceRenameFixes(fixer, reference.identifier, newName);
220
+ if (!referenceFixes) {
221
+ return null;
222
+ }
223
+ fixes.push(...referenceFixes);
224
+ }
225
+ return fixes;
226
+ };
85
227
  /** `import LogoutIcon from '@mui/icons-material/Logout'` — the icon is named by the module path. */
86
228
  const checkDeepImport = (node, iconPath) => {
87
- const iconName = iconPath.split('/').pop();
229
+ const iconName = deepImportIconName(iconPath);
88
230
  const roundedVariant = iconName ? toRoundedIconName(iconName) : null;
89
231
  if (!roundedVariant) {
90
232
  return;
91
233
  }
234
+ const local = deepImportBinding(node);
235
+ // A hand-chosen alias (`import BellIcon from '.../NotificationsActive'`)
236
+ // names the icon's role, not its variant, so only a binding that repeats
237
+ // the icon name is renamed with the path.
238
+ const renamedBinding = local?.name === iconName ? local : null;
92
239
  context.report({
93
240
  node,
94
241
  messageId: 'enforceRoundedVariant',
95
- // The local binding is untouched: only the module the icon comes from
96
- // changes.
97
- fix: (fixer) => fixer.replaceText(node.source, `'${MUI_ICONS_DEEP_PREFIX}${roundedVariant}'`),
242
+ fix: (fixer) => {
243
+ const pathFix = fixer.replaceText(node.source, `'${MUI_ICONS_DEEP_PREFIX}${roundedVariant}'`);
244
+ const renameFixes = renamedBinding
245
+ ? bindingRenameFixes(fixer, node, renamedBinding, [renamedBinding], roundedVariant)
246
+ : null;
247
+ // Path and binding move together: one fixer emits both, so a binding
248
+ // rename can never land without the retarget that motivates it.
249
+ return renameFixes ? [pathFix, ...renameFixes] : pathFix;
250
+ },
98
251
  });
99
252
  };
100
253
  /** `import { Logout } from '@mui/icons-material'` — the icon is named by each specifier. */
@@ -116,22 +269,50 @@ exports.enforceMuiRoundedIcons = (0, createRule_1.createRule)({
116
269
  continue;
117
270
  }
118
271
  // An alias keeps the local binding independent of the imported name, so
119
- // swapping the imported name is a self-contained edit. Without one, a
120
- // fix would rename the binding and would have to rewrite every
121
- // reference to it (shorthand properties, export specifiers, JSX) — so
122
- // that form is reported without a fix rather than risking a corrupting
123
- // edit.
124
- const isAliased = specifier.local.name !== importedName;
272
+ // swapping the imported name is a self-contained edit that leaves the
273
+ // binding — and every reference to it — alone.
274
+ if (specifier.local.name !== importedName) {
275
+ context.report({
276
+ node: specifier,
277
+ messageId: 'enforceRoundedVariant',
278
+ fix: (fixer) => fixer.replaceText(specifier.imported, roundedVariant),
279
+ });
280
+ continue;
281
+ }
282
+ // Without an alias the imported name IS the local binding, so changing
283
+ // it renames the binding: the fix must own every reference to it or not
284
+ // exist at all. `import { Logout }` spells the imported name and the
285
+ // local one with a single token, which is rewritten once; the redundant
286
+ // `import { Logout as Logout }` spells them separately, so both move.
287
+ const declarationTokens = specifier.imported.range[0] === specifier.local.range[0]
288
+ ? [specifier.local]
289
+ : [specifier.imported, specifier.local];
125
290
  context.report({
126
291
  node: specifier,
127
292
  messageId: 'enforceRoundedVariant',
128
- fix: isAliased
129
- ? (fixer) => fixer.replaceText(specifier.imported, roundedVariant)
130
- : null,
293
+ fix: (fixer) => bindingRenameFixes(fixer, node, specifier.local, declarationTokens, roundedVariant),
131
294
  });
132
295
  }
133
296
  };
134
297
  return {
298
+ // Every import is a top-level statement, so the names the file's fixes
299
+ // would introduce are all knowable before the first report.
300
+ Program(node) {
301
+ const claimCounts = new Map();
302
+ for (const statement of node.body) {
303
+ if (statement.type !== utils_1.AST_NODE_TYPES.ImportDeclaration) {
304
+ continue;
305
+ }
306
+ for (const claimed of claimedRoundedNames(statement)) {
307
+ claimCounts.set(claimed, (claimCounts.get(claimed) ?? 0) + 1);
308
+ }
309
+ }
310
+ for (const [name, count] of claimCounts) {
311
+ if (count > 1) {
312
+ contestedNames.add(name);
313
+ }
314
+ }
315
+ },
135
316
  ImportDeclaration(node) {
136
317
  if (node.source.type !== utils_1.AST_NODE_TYPES.Literal ||
137
318
  typeof node.source.value !== 'string') {
@@ -2,10 +2,59 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.genericStartsWithT = void 0;
4
4
  const createRule_1 = require("../utils/createRule");
5
+ const utils_1 = require("@typescript-eslint/utils");
6
+ /**
7
+ * A module augmentation targets either an external module
8
+ * (`declare module 'pkg'`, whose id is a string literal) or the global scope
9
+ * (`declare global`). TypeScript requires every declaration of a merged entity
10
+ * to spell its type parameters identically (TS2428, "All declarations of 'X'
11
+ * must have identical type parameters"), so the name inside an augmentation is
12
+ * fixed by the upstream declaration rather than chosen by the author. Renaming
13
+ * it to satisfy the convention breaks the merge.
14
+ *
15
+ * A plain `namespace X` — including the ambient `declare namespace X` and
16
+ * `declare module Foo`, both of which carry an identifier id — augments nothing
17
+ * upstream, so its type parameters stay author-owned and reportable.
18
+ */
19
+ function isModuleAugmentation(node) {
20
+ if (node.id.type === utils_1.AST_NODE_TYPES.Literal &&
21
+ typeof node.id.value === 'string') {
22
+ return true;
23
+ }
24
+ // `declare global` carries a dedicated flag, which also covers the bare
25
+ // `global { ... }` form nested inside an ambient module (that form has no
26
+ // `declare` of its own).
27
+ if (node.global === true) {
28
+ return true;
29
+ }
30
+ // Parser versions that predate the `global` flag spell the same block as an
31
+ // ambient module whose id is the `global` keyword. Requiring `declare` keeps
32
+ // an ordinary `namespace global { ... }` reportable.
33
+ return (node.declare === true &&
34
+ node.id.type === utils_1.AST_NODE_TYPES.Identifier &&
35
+ node.id.name === 'global');
36
+ }
37
+ /**
38
+ * The declaration need not be a direct child of the augmentation block; it can
39
+ * sit inside a nested namespace, an interface member signature or any other
40
+ * container within it, so the whole ancestor chain is inspected.
41
+ */
42
+ function isInsideModuleAugmentation(node) {
43
+ for (let ancestor = node.parent; ancestor; ancestor = ancestor.parent) {
44
+ if (ancestor.type === utils_1.AST_NODE_TYPES.TSModuleDeclaration &&
45
+ isModuleAugmentation(ancestor)) {
46
+ return true;
47
+ }
48
+ }
49
+ return false;
50
+ }
5
51
  exports.genericStartsWithT = (0, createRule_1.createRule)({
6
52
  create(context) {
7
53
  return {
8
54
  TSTypeParameterDeclaration(node) {
55
+ if (isInsideModuleAugmentation(node)) {
56
+ return;
57
+ }
9
58
  for (const param of node.params) {
10
59
  if (typeof param.name.name === 'string' &&
11
60
  param.name.name[0] !== 'T') {
@@ -23,6 +23,28 @@ const toUpperSnakeCase = (name) => name
23
23
  .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
24
24
  .toUpperCase()
25
25
  .replace(/^_/, '');
26
+ // `x as T`, `<T>x`, `x satisfies T` and `x!` annotate or assert an expression
27
+ // without contributing a value of their own, so a check that classifies the
28
+ // *shape* of an initializer must look through all four alike. Recognizing only
29
+ // some of them makes the rule's carve-outs depend on which type syntax an
30
+ // author happened to reach for: a React component written
31
+ // `memo(Foo) satisfies ComponentType` or `memo(Foo)!` read as opaque
32
+ // expressions and were renamed to UPPER_SNAKE_CASE while `memo(Foo) as FC` was
33
+ // exempt (Issue #1681).
34
+ const VALUE_WRAPPER_TYPES = new Set([
35
+ utils_1.AST_NODE_TYPES.TSAsExpression,
36
+ utils_1.AST_NODE_TYPES.TSTypeAssertion,
37
+ utils_1.AST_NODE_TYPES.TSSatisfiesExpression,
38
+ utils_1.AST_NODE_TYPES.TSNonNullExpression,
39
+ ]);
40
+ const isValueWrapper = (node) => VALUE_WRAPPER_TYPES.has(node.type);
41
+ const unwrapValueWrappers = (node) => {
42
+ let target = node;
43
+ while (isValueWrapper(target)) {
44
+ target = target.expression;
45
+ }
46
+ return target;
47
+ };
26
48
  // Jest mock handles produced by an `as` cast to a `jest.Mock*` type are
27
49
  // stateful test doubles that are reassigned/mutated through
28
50
  // `.mockImplementation()`, `.mockReturnValue()`, etc. They are not immutable
@@ -38,11 +60,7 @@ const JEST_MOCK_TYPE_NAMES = new Set([
38
60
  // `jest.Mocked<...>` / `jest.MockedClass<...>`. The match is kept deliberately
39
61
  // narrow — a qualified `jest.<MockType>` type reference — so unrelated `as`
40
62
  // casts keep triggering the rename check.
41
- const isJestMockCast = (node) => {
42
- if (node.type !== utils_1.AST_NODE_TYPES.TSAsExpression) {
43
- return false;
44
- }
45
- const typeAnnotation = node.typeAnnotation;
63
+ const isJestMockTypeReference = (typeAnnotation) => {
46
64
  if (typeAnnotation.type !== utils_1.AST_NODE_TYPES.TSTypeReference) {
47
65
  return false;
48
66
  }
@@ -53,6 +71,43 @@ const isJestMockCast = (node) => {
53
71
  typeName.right.type === utils_1.AST_NODE_TYPES.Identifier &&
54
72
  JEST_MOCK_TYPE_NAMES.has(typeName.right.name));
55
73
  };
74
+ // The cast can sit anywhere in a wrapper chain (`(foo as jest.Mock)!`,
75
+ // `foo as jest.Mock satisfies unknown`), so the whole chain is scanned rather
76
+ // than the outermost node alone — a mock handle stays a mock handle whatever is
77
+ // wrapped around the cast.
78
+ const isJestMockCast = (node) => {
79
+ let current = node;
80
+ while (isValueWrapper(current)) {
81
+ if (current.type === utils_1.AST_NODE_TYPES.TSAsExpression &&
82
+ isJestMockTypeReference(current.typeAnnotation)) {
83
+ return true;
84
+ }
85
+ current = current.expression;
86
+ }
87
+ return false;
88
+ };
89
+ // React's component factories, called bare (`memo(Foo)`) or through a namespace
90
+ // import (`React.memo(Foo)`).
91
+ const COMPONENT_FACTORY_NAMES = new Set(['forwardRef', 'memo']);
92
+ const isComponentFactoryCall = (node) => {
93
+ if (node.type !== utils_1.AST_NODE_TYPES.CallExpression) {
94
+ return false;
95
+ }
96
+ const { callee } = node;
97
+ if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
98
+ return COMPONENT_FACTORY_NAMES.has(callee.name);
99
+ }
100
+ return (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
101
+ !callee.computed &&
102
+ callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
103
+ COMPONENT_FACTORY_NAMES.has(callee.property.name));
104
+ };
105
+ // A function value is a component, hook or helper, never the module-level
106
+ // configuration value this rule governs. The two spellings are interchangeable
107
+ // at a declaration site, so `const Row = function (props) {...}` is exempt on
108
+ // the same terms as `const Row = (props) => {...}` (Issue #1681).
109
+ const isFunctionValue = (node) => node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
110
+ node.type === utils_1.AST_NODE_TYPES.FunctionExpression;
56
111
  /**
57
112
  * Walks the scope chain upward from `scope` (inclusive) and reports whether
58
113
  * `targetName` is bound anywhere between `scope` and `stopScope` (inclusive).
@@ -141,7 +196,16 @@ exports.default = (0, createRule_1.createRule)({
141
196
  // Check if the file is a TypeScript file
142
197
  const isTypeScript = context.getFilename().endsWith('.ts') ||
143
198
  context.getFilename().endsWith('.tsx');
144
- const unwrapAssertions = (node) => {
199
+ /**
200
+ * Strips `as`/`<T>` casts only, and is deliberately narrower than
201
+ * `unwrapValueWrappers`. The two carve-outs below — dynamic values and
202
+ * binding aliases — silence the rule entirely, so widening them to see
203
+ * through `!`/`satisfies` would newly exempt `const value = getValue()!`
204
+ * and `const alias = other!` from the rename check. That is a detection
205
+ * change of its own, distinct from the wrapper-blind component exemption
206
+ * `unwrapValueWrappers` cures (Issue #1681).
207
+ */
208
+ const unwrapCasts = (node) => {
145
209
  let target = node;
146
210
  while (target.type === utils_1.AST_NODE_TYPES.TSTypeAssertion ||
147
211
  target.type === utils_1.AST_NODE_TYPES.TSAsExpression) {
@@ -150,7 +214,7 @@ exports.default = (0, createRule_1.createRule)({
150
214
  return target;
151
215
  };
152
216
  const isDynamicValue = (node) => {
153
- const target = unwrapAssertions(node);
217
+ const target = unwrapCasts(node);
154
218
  if (target.type === utils_1.AST_NODE_TYPES.CallExpression ||
155
219
  target.type === utils_1.AST_NODE_TYPES.NewExpression ||
156
220
  target.type === utils_1.AST_NODE_TYPES.BinaryExpression) {
@@ -174,18 +238,18 @@ exports.default = (0, createRule_1.createRule)({
174
238
  * such a re-export is preserving a name importers depend on and a
175
239
  * single-file fixer cannot rewrite them (Issue #1418).
176
240
  *
177
- * The check unwraps assertions so a type-pinned alias (`x as Foo`,
241
+ * The check unwraps casts so a type-pinned alias (`x as Foo`,
178
242
  * `x as const`) is treated the same as the bare form. A `MemberExpression`
179
243
  * (`Foo.bar`) is deliberately not covered — it keeps whatever behavior
180
244
  * `isDynamicValue` already gives it.
181
245
  */
182
246
  const isBindingAlias = (node) => {
183
- const target = unwrapAssertions(node);
247
+ const target = unwrapCasts(node);
184
248
  return (target.type === utils_1.AST_NODE_TYPES.Identifier &&
185
249
  !PRIMITIVE_VALUE_GLOBALS.has(target.name));
186
250
  };
187
251
  const describeValueKind = (node) => {
188
- const target = unwrapAssertions(node);
252
+ const target = unwrapValueWrappers(node);
189
253
  if (target.type === utils_1.AST_NODE_TYPES.ArrayExpression) {
190
254
  return 'an array literal';
191
255
  }
@@ -208,41 +272,22 @@ exports.default = (0, createRule_1.createRule)({
208
272
  node.parent?.type !== utils_1.AST_NODE_TYPES.ExportNamedDeclaration) {
209
273
  return;
210
274
  }
211
- // Skip if any declaration is a function component, arrow function, forwardRef, or memo
275
+ // Skip if any declaration is a function value (component, hook or
276
+ // helper) or a `memo`/`forwardRef` component factory call. The
277
+ // initializer is classified through any type wrappers, so the pinned
278
+ // forms (`… as FC`, `… satisfies ComponentType`, `…!`) are exempt on
279
+ // the same terms as the bare expression they wrap.
212
280
  const shouldSkip = node.declarations.some((declaration) => {
213
281
  if (declaration.id.type !== utils_1.AST_NODE_TYPES.Identifier) {
214
282
  return false;
215
283
  }
216
- const name = declaration.id.name;
217
284
  const init = declaration.init;
218
285
  // Skip if no initializer
219
286
  if (!init) {
220
287
  return false;
221
288
  }
222
- // Skip function components (uppercase name + arrow function)
223
- if (/^[A-Z]/.test(name) &&
224
- init.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression) {
225
- return true;
226
- }
227
- // Skip any arrow function
228
- if (init.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression) {
229
- return true;
230
- }
231
- // Skip forwardRef and memo calls
232
- if (init.type === utils_1.AST_NODE_TYPES.CallExpression) {
233
- if (init.callee.type === utils_1.AST_NODE_TYPES.Identifier) {
234
- return ['forwardRef', 'memo'].includes(init.callee.name);
235
- }
236
- }
237
- // Skip type assertions on forwardRef and memo calls
238
- if (init.type === utils_1.AST_NODE_TYPES.TSAsExpression) {
239
- const expression = init.expression;
240
- if (expression.type === utils_1.AST_NODE_TYPES.CallExpression &&
241
- expression.callee.type === utils_1.AST_NODE_TYPES.Identifier) {
242
- return ['forwardRef', 'memo'].includes(expression.callee.name);
243
- }
244
- }
245
- return false;
289
+ const target = unwrapValueWrappers(init);
290
+ return isFunctionValue(target) || isComponentFactoryCall(target);
246
291
  });
247
292
  if (shouldSkip) {
248
293
  return;
@@ -267,15 +312,19 @@ exports.default = (0, createRule_1.createRule)({
267
312
  : '';
268
313
  // Only check for as const in TypeScript files
269
314
  if (isTypeScript) {
315
+ // An `as const` anywhere in the wrapper chain already freezes the
316
+ // value, including when a later wrapper hides it
317
+ // (`{...} as const satisfies Config`, `({...} as const)!`).
270
318
  const hasAsConstAssertion = (node) => {
271
319
  let current = node;
272
- while (current &&
273
- (current.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
274
- current.type === utils_1.AST_NODE_TYPES.TSTypeAssertion)) {
275
- const { typeAnnotation } = current;
276
- if (typeAnnotation?.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
277
- typeAnnotation.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
278
- typeAnnotation.typeName.name === 'const') {
320
+ while (isValueWrapper(current)) {
321
+ if ((current.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
322
+ current.type === utils_1.AST_NODE_TYPES.TSTypeAssertion) &&
323
+ current.typeAnnotation.type ===
324
+ utils_1.AST_NODE_TYPES.TSTypeReference &&
325
+ current.typeAnnotation.typeName.type ===
326
+ utils_1.AST_NODE_TYPES.Identifier &&
327
+ current.typeAnnotation.typeName.name === 'const') {
279
328
  return true;
280
329
  }
281
330
  current = current.expression;
@@ -287,14 +336,15 @@ exports.default = (0, createRule_1.createRule)({
287
336
  if (hasAsConstAssertion(node)) {
288
337
  return false;
289
338
  }
290
- const target = unwrapAssertions(node);
291
- // Skip an initializer already wrapped in a non-`const` assertion
292
- // (`{...} as T`, `<T>{...}`, `{...} as unknown as T`). A `const`
293
- // assertion may only be applied to a literal, so appending one
294
- // after an assertion chain is TS1355 — the same failure mode the
295
- // regex/null/boolean carve-outs below exist for. Such a cast is
296
- // also the author pinning the type deliberately, exactly like the
297
- // `id.typeAnnotation` case skipped next.
339
+ const target = unwrapValueWrappers(node);
340
+ // Skip an initializer already wrapped in a non-`const` type
341
+ // wrapper (`{...} as T`, `<T>{...}`, `{...} as unknown as T`,
342
+ // `{...} satisfies T`, `{...}!`). A `const` assertion may only be
343
+ // applied to a literal, so appending one after such a chain is
344
+ // TS1355 — the same failure mode the regex/null/boolean carve-outs
345
+ // below exist for. Such a wrapper is also the author pinning the
346
+ // type deliberately, exactly like the `id.typeAnnotation` case
347
+ // skipped next.
298
348
  if (target !== node) {
299
349
  return false;
300
350
  }