@blumintinc/eslint-plugin-blumint 1.20.94 → 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.
package/README.md CHANGED
@@ -101,7 +101,7 @@ full closed loop is documented in agora's `.claude/skills/eslint-autonomy/SKILL.
101
101
  | [enforce-dynamic-imports](docs/rules/enforce-dynamic-imports.md) | Enforce dynamic imports for external libraries by default to optimize bundle size, unless explicitly ignored | ✅ | | | | |
102
102
  | [enforce-early-destructuring](docs/rules/enforce-early-destructuring.md) | Hoist object destructuring out of React hooks so dependency arrays track the fields in use instead of the entire object. | ✅ | | 🔧 | | |
103
103
  | [enforce-empty-object-check](docs/rules/enforce-empty-object-check.md) | Ensure object existence checks also guard against empty objects so that empty payloads are treated like missing data. | ✅ | | 🔧 | | |
104
- | [enforce-exported-function-types](docs/rules/enforce-exported-function-types.md) | Enforce exporting types for function props and return values | ✅ | | | | |
104
+ | [enforce-exported-function-types](docs/rules/enforce-exported-function-types.md) | Enforce exporting types for function props and return values | ✅ | | 🔧 | | |
105
105
  | [enforce-f-extension-for-entry-points](docs/rules/enforce-f-extension-for-entry-points.md) | Enforce .f.ts extension for entry points | ✅ | | | | |
106
106
  | [enforce-fieldpath-syntax-in-docsetter](docs/rules/enforce-fieldpath-syntax-in-docsetter.md) | Enforce the use of Firestore FieldPath syntax when passing documentData into DocSetter. Instead of using nested object syntax, developers should use dot notation for deeply nested fields. | ✅ | | 🔧 | | |
107
107
  | [enforce-firestore-doc-ref-generic](docs/rules/enforce-firestore-doc-ref-generic.md) | Enforce generic argument for Firestore DocumentReference, CollectionReference and CollectionGroup | ✅ | | | | 💭 |
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.94',
226
+ version: '1.20.96',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -1,5 +1,6 @@
1
+ import { TSESLint } from '@typescript-eslint/utils';
1
2
  /**
2
3
  * This rule enforces the use of CSS media queries instead of JavaScript-based breakpoints
3
4
  * in React components for better performance and separation of concerns.
4
5
  */
5
- export declare const enforceCssMediaQueries: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"enforceCssMediaQueries", [], import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
6
+ export declare const enforceCssMediaQueries: TSESLint.RuleModule<"enforceCssMediaQueries", [], TSESLint.RuleListener>;
@@ -2,7 +2,158 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.enforceCssMediaQueries = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
+ const ASTHelpers_1 = require("../utils/ASTHelpers");
5
6
  const createRule_1 = require("../utils/createRule");
7
+ /** Hooks whose calls perform JavaScript media detection. */
8
+ const MEDIA_HOOKS = new Set(['useMediaQuery', 'useMobile']);
9
+ /**
10
+ * Features that describe the viewport's geometry. A stylesheet evaluates these
11
+ * itself, so asking JavaScript for the answer duplicates a CSS breakpoint and
12
+ * the rule's remedy — move the breakpoint into `@media` — applies.
13
+ */
14
+ const LAYOUT_FEATURES = new Set([
15
+ 'width',
16
+ 'min-width',
17
+ 'max-width',
18
+ 'height',
19
+ 'min-height',
20
+ 'max-height',
21
+ 'aspect-ratio',
22
+ 'min-aspect-ratio',
23
+ 'max-aspect-ratio',
24
+ 'resolution',
25
+ 'min-resolution',
26
+ 'max-resolution',
27
+ 'device-width',
28
+ 'min-device-width',
29
+ 'max-device-width',
30
+ 'device-height',
31
+ 'min-device-height',
32
+ 'max-device-height',
33
+ 'device-aspect-ratio',
34
+ 'min-device-aspect-ratio',
35
+ 'max-device-aspect-ratio',
36
+ ]);
37
+ /**
38
+ * Features that describe the device's capabilities or the user's preferences.
39
+ * These gate JavaScript behaviour — whether a component mounts, which animation
40
+ * duration a prop carries, which branch a hook takes — and no class name can
41
+ * express that decision, so the rule's remedy is unreachable and the query is
42
+ * exempt.
43
+ */
44
+ const NON_LAYOUT_FEATURES = new Set([
45
+ 'hover',
46
+ 'any-hover',
47
+ 'pointer',
48
+ 'any-pointer',
49
+ 'orientation',
50
+ 'display-mode',
51
+ 'forced-colors',
52
+ 'inverted-colors',
53
+ 'update',
54
+ 'scripting',
55
+ ]);
56
+ /** Every `prefers-*` feature is a user preference, never a layout measurement. */
57
+ const PREFERENCE_FEATURE_PREFIX = 'prefers-';
58
+ /**
59
+ * Guards the text a query carries outside its feature groups — media types and
60
+ * combinators such as `screen`, `and`, `not`. A layout name appearing there
61
+ * means the query is shaped in a way the group scanner does not read, so the
62
+ * rule keeps reporting it rather than guessing.
63
+ */
64
+ const LAYOUT_NAME = new RegExp([...LAYOUT_FEATURES].join('|'));
65
+ const FEATURE_GROUP = /\(([^()]*)\)/g;
66
+ /** Follows at most this many indirections while resolving a query argument. */
67
+ const MAX_RESOLUTION_DEPTH = 4;
68
+ /**
69
+ * The feature a parenthesized query group tests, or `null` when the group's
70
+ * shape leaves it ambiguous (range syntax such as `(width >= 600px)`, a nested
71
+ * `calc()`, an empty group).
72
+ */
73
+ function featureNameOf(group) {
74
+ const separator = group.indexOf(':');
75
+ const name = (separator === -1 ? group : group.slice(0, separator))
76
+ .trim()
77
+ .toLowerCase();
78
+ if (name === '' || /[^a-z-]/.test(name)) {
79
+ return null;
80
+ }
81
+ return name;
82
+ }
83
+ function isNonLayoutFeature(name) {
84
+ return (NON_LAYOUT_FEATURES.has(name) || name.startsWith(PREFERENCE_FEATURE_PREFIX));
85
+ }
86
+ /**
87
+ * Whether every feature the query tests is a capability or preference. An
88
+ * unrecognized feature, a mixed query (`(hover: hover) and (min-width: 600px)`)
89
+ * or a query naming no feature at all is not exempt: the rule reports whenever
90
+ * it cannot prove the query is free of layout.
91
+ */
92
+ function testsOnlyNonLayoutFeatures(query) {
93
+ const normalized = query.toLowerCase();
94
+ const groups = new RegExp(FEATURE_GROUP.source, FEATURE_GROUP.flags);
95
+ let match;
96
+ let sawFeature = false;
97
+ while ((match = groups.exec(normalized)) !== null) {
98
+ const name = featureNameOf(match[1]);
99
+ // A layout feature and an unrecognized one both keep the query reportable:
100
+ // the exemption exists only where the query is provably free of layout.
101
+ if (name === null || !isNonLayoutFeature(name)) {
102
+ return false;
103
+ }
104
+ sawFeature = true;
105
+ }
106
+ const outsideGroups = normalized.replace(new RegExp(FEATURE_GROUP.source, FEATURE_GROUP.flags), ' ');
107
+ return sawFeature && !LAYOUT_NAME.test(outsideGroups);
108
+ }
109
+ /**
110
+ * The query string an argument carries, or `null` when the rule cannot see it.
111
+ * A `theme.breakpoints.*` expression, an imported constant, a template with
112
+ * interpolations and a reassignable binding all resolve to `null`, which keeps
113
+ * them reportable.
114
+ */
115
+ function resolveQuery(node, scope, depth = 0) {
116
+ if (depth > MAX_RESOLUTION_DEPTH) {
117
+ return null;
118
+ }
119
+ switch (node.type) {
120
+ case utils_1.AST_NODE_TYPES.Literal:
121
+ return typeof node.value === 'string' ? node.value : null;
122
+ case utils_1.AST_NODE_TYPES.TemplateLiteral:
123
+ return node.expressions.length === 0
124
+ ? node.quasis[0]?.value.cooked ?? null
125
+ : null;
126
+ // `as const`, `satisfies` and angle-bracket assertions only annotate the
127
+ // string they wrap, so the query survives them unchanged.
128
+ case utils_1.AST_NODE_TYPES.TSAsExpression:
129
+ case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
130
+ case utils_1.AST_NODE_TYPES.TSTypeAssertion:
131
+ case utils_1.AST_NODE_TYPES.TSNonNullExpression:
132
+ return resolveQuery(node.expression, scope, depth + 1);
133
+ case utils_1.AST_NODE_TYPES.Identifier:
134
+ return resolveBinding(node, scope, depth);
135
+ default:
136
+ return null;
137
+ }
138
+ }
139
+ function resolveBinding(node, scope, depth) {
140
+ const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, node.name);
141
+ if (!variable || variable.defs.length !== 1) {
142
+ return null;
143
+ }
144
+ const definition = variable.defs[0];
145
+ if (definition.type !== 'Variable' || !definition.node.init) {
146
+ return null;
147
+ }
148
+ // Only a `const` proves the query the call receives is the one declared here;
149
+ // a `let` may hold a width query by the time the hook runs.
150
+ const declaration = definition.parent;
151
+ if (declaration?.type !== utils_1.AST_NODE_TYPES.VariableDeclaration ||
152
+ declaration.kind !== 'const') {
153
+ return null;
154
+ }
155
+ return resolveQuery(definition.node.init, scope, depth + 1);
156
+ }
6
157
  /**
7
158
  * This rule enforces the use of CSS media queries instead of JavaScript-based breakpoints
8
159
  * in React components for better performance and separation of concerns.
@@ -22,11 +173,28 @@ exports.enforceCssMediaQueries = (0, createRule_1.createRule)({
22
173
  },
23
174
  defaultOptions: [],
24
175
  create(context) {
25
- const reportUsage = (node, source) => context.report({
176
+ /**
177
+ * Import reports are held back until traversal ends: an import whose hook is
178
+ * called in the same file is the same single usage as the call, and one
179
+ * usage earns one report (and therefore one disable comment).
180
+ */
181
+ const pendingImports = [];
182
+ const calledHooks = new Set();
183
+ const reportableCalls = [];
184
+ const reportUsage = ({ node, source }) => context.report({
26
185
  node,
27
186
  messageId: 'enforceCssMediaQueries',
28
187
  data: { source },
29
188
  });
189
+ const localNamesOf = (node) => node.specifiers.map((specifier) => specifier.local.name);
190
+ const isExemptCall = (node) => {
191
+ const [argument] = node.arguments;
192
+ if (!argument) {
193
+ return false;
194
+ }
195
+ const query = resolveQuery(argument, ASTHelpers_1.ASTHelpers.getScope(context, node));
196
+ return query !== null && testsOnlyNonLayoutFeatures(query);
197
+ };
30
198
  return {
31
199
  // Only react-responsive is handled at the declaration level to avoid duplicates.
32
200
  ImportDeclaration(node) {
@@ -34,7 +202,11 @@ exports.enforceCssMediaQueries = (0, createRule_1.createRule)({
34
202
  !node.source.value.includes('react-responsive/')) {
35
203
  return;
36
204
  }
37
- reportUsage(node, `react-responsive import "${String(node.source.value)}"`);
205
+ pendingImports.push({
206
+ node,
207
+ source: `react-responsive import "${String(node.source.value)}"`,
208
+ localNames: localNamesOf(node),
209
+ });
38
210
  },
39
211
  // Handle specific specifiers to avoid duplicate diagnostics.
40
212
  ImportSpecifier(node) {
@@ -42,22 +214,45 @@ exports.enforceCssMediaQueries = (0, createRule_1.createRule)({
42
214
  node.imported.type === utils_1.AST_NODE_TYPES.Identifier) {
43
215
  if (node.parent.source.value === '@mui/material' &&
44
216
  node.imported.name === 'useMediaQuery') {
45
- reportUsage(node, 'useMediaQuery import from @mui/material');
217
+ pendingImports.push({
218
+ node,
219
+ source: 'useMediaQuery import from @mui/material',
220
+ localNames: [node.local.name],
221
+ });
46
222
  return;
47
223
  }
48
224
  if (node.imported.name === 'useMobile') {
49
- reportUsage(node, `useMobile import from ${String(node.parent.source.value)}`);
225
+ pendingImports.push({
226
+ node,
227
+ source: `useMobile import from ${String(node.parent.source.value)}`,
228
+ localNames: [node.local.name],
229
+ });
50
230
  return;
51
231
  }
52
232
  }
53
233
  },
54
234
  // Check for useMediaQuery and useMobile calls
55
235
  CallExpression(node) {
56
- if (node.callee.type === utils_1.AST_NODE_TYPES.Identifier &&
57
- (node.callee.name === 'useMediaQuery' ||
58
- node.callee.name === 'useMobile')) {
59
- reportUsage(node, `${node.callee.name} call`);
236
+ if (node.callee.type !== utils_1.AST_NODE_TYPES.Identifier ||
237
+ !MEDIA_HOOKS.has(node.callee.name)) {
238
+ return;
239
+ }
240
+ calledHooks.add(node.callee.name);
241
+ if (isExemptCall(node)) {
242
+ return;
60
243
  }
244
+ reportableCalls.push({ node, source: `${node.callee.name} call` });
245
+ },
246
+ 'Program:exit'() {
247
+ reportableCalls.forEach(reportUsage);
248
+ pendingImports
249
+ // An import the file also calls is covered by the call: reported
250
+ // through it when the query is a breakpoint, and exempt with it when
251
+ // every query is a capability or preference probe. An import with no
252
+ // call — unused, re-exported, passed around as a value — has no call
253
+ // to carry the report, so it keeps its own.
254
+ .filter(({ localNames }) => !localNames.some((n) => calledHooks.has(n)))
255
+ .forEach(reportUsage);
61
256
  },
62
257
  };
63
258
  },
@@ -4,6 +4,67 @@ exports.enforceExportedFunctionTypes = void 0;
4
4
  /* eslint-disable @typescript-eslint/no-empty-function */
5
5
  const utils_1 = require("@typescript-eslint/utils");
6
6
  const createRule_1 = require("../utils/createRule");
7
+ /**
8
+ * `require-memo` rewrites `export function Banner(props: P)` into
9
+ * `export const Banner = memo(function BannerUnmemoized(props: P) {...})`, so
10
+ * every wrapper it can emit has to be unwrapped here or the config's own
11
+ * autofix hides the component from this rule.
12
+ */
13
+ const COMPONENT_WRAPPERS = new Set(['memo', 'forwardRef']);
14
+ function isComponentName(name) {
15
+ return /^[A-Z]/.test(name);
16
+ }
17
+ function isComponentWrapperCallee(callee) {
18
+ if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
19
+ return COMPONENT_WRAPPERS.has(callee.name);
20
+ }
21
+ if (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
22
+ !callee.computed &&
23
+ callee.property.type === utils_1.AST_NODE_TYPES.Identifier) {
24
+ return COMPONENT_WRAPPERS.has(callee.property.name);
25
+ }
26
+ return false;
27
+ }
28
+ /**
29
+ * Resolves the function that actually receives the props, peeling any nesting
30
+ * of component wrappers (`memo(forwardRef(fn))`) along the way.
31
+ *
32
+ * `seen` records the binding names already followed, so mutually aliased
33
+ * declarations (`const A = memo(B); const B = memo(A);`) terminate instead of
34
+ * recursing forever.
35
+ */
36
+ function unwrapComponentFunction(node, resolveComponent, isWrapped = false, seen = new Set()) {
37
+ if (!node)
38
+ return undefined;
39
+ if (node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
40
+ node.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
41
+ node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration) {
42
+ return { fn: node, isWrapped };
43
+ }
44
+ if (node.type === utils_1.AST_NODE_TYPES.CallExpression &&
45
+ isComponentWrapperCallee(node.callee)) {
46
+ const [firstArgument] = node.arguments;
47
+ if (!firstArgument || firstArgument.type === utils_1.AST_NODE_TYPES.SpreadElement) {
48
+ return undefined;
49
+ }
50
+ return unwrapComponentFunction(firstArgument, resolveComponent, true, seen);
51
+ }
52
+ // `export const Banner = memo(BannerUnmemoized)` is the shape `require-memo`
53
+ // leaves behind most often, and the props it wraps live one hop away on the
54
+ // named declaration. Resolution is confined to wrapper arguments: a bare
55
+ // `export const Banner = Other` re-exports a value rather than declaring a
56
+ // component here.
57
+ if (isWrapped && node.type === utils_1.AST_NODE_TYPES.Identifier) {
58
+ if (seen.has(node.name))
59
+ return undefined;
60
+ seen.add(node.name);
61
+ const component = unwrapComponentFunction(resolveComponent(node.name), resolveComponent, true, seen);
62
+ if (!component)
63
+ return undefined;
64
+ return { ...component, resolvedName: component.resolvedName ?? node.name };
65
+ }
66
+ return undefined;
67
+ }
7
68
  exports.enforceExportedFunctionTypes = (0, createRule_1.createRule)({
8
69
  name: 'enforce-exported-function-types',
9
70
  meta: {
@@ -12,6 +73,7 @@ exports.enforceExportedFunctionTypes = (0, createRule_1.createRule)({
12
73
  description: 'Enforce exporting types for function props and return values',
13
74
  recommended: 'error',
14
75
  },
76
+ fixable: 'code',
15
77
  schema: [],
16
78
  messages: {
17
79
  missingExportedType: 'Type "{{typeName}}" is used in a parameter of an exported function but is not exported. Callers cannot import the parameter contract, which forces duplicate or ad-hoc types and makes the API drift. Export the type (e.g., `export type {{typeName}} = ...`) or reuse an already exported type.',
@@ -373,8 +435,26 @@ exports.enforceExportedFunctionTypes = (0, createRule_1.createRule)({
373
435
  ]);
374
436
  return builtInTypes.has(typeName);
375
437
  }
376
- function checkAndReportType(node, parentNode, messageId) {
377
- const typeNames = getTypeNames(node);
438
+ /**
439
+ * Locates the module-scope declaration of a type so the report can offer to
440
+ * export it. Only a bare declaration in `Program.body` qualifies: anything
441
+ * already carrying `export` is an `ExportNamedDeclaration` (and never
442
+ * reported), while a declaration nested in a namespace or a function body
443
+ * cannot be exported by inserting a single keyword.
444
+ *
445
+ * Merged declarations (repeated `interface Props`) are left alone: TypeScript
446
+ * requires every declaration of a merged name to be exported or none of
447
+ * them, so exporting one of several would trade a lint report for a compile
448
+ * error.
449
+ */
450
+ function findExportableTypeDeclaration(typeName) {
451
+ const declarations = context.sourceCode.ast.body.filter((statement) => (statement.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration ||
452
+ statement.type === utils_1.AST_NODE_TYPES.TSInterfaceDeclaration) &&
453
+ statement.id.name === typeName);
454
+ return declarations.length === 1 ? declarations[0] : undefined;
455
+ }
456
+ function checkAndReportType(node, parentNode, messageId, typeParams) {
457
+ const typeNames = getTypeNames(node, typeParams);
378
458
  for (const typeName of typeNames) {
379
459
  if (typeName !== 'AnonymousType' &&
380
460
  !isBuiltInType(typeName) &&
@@ -383,18 +463,102 @@ exports.enforceExportedFunctionTypes = (0, createRule_1.createRule)({
383
463
  const key = `${typeName}-${parentNode.loc?.start.line}-${parentNode.loc?.start.column}`;
384
464
  if (!reportedTypes.has(key)) {
385
465
  reportedTypes.add(key);
466
+ // The props contract is the shape consumers compose against, so its
467
+ // remedy — exporting the local declaration — is offered as a fix.
468
+ const declaration = messageId === 'missingExportedPropsType'
469
+ ? findExportableTypeDeclaration(typeName)
470
+ : undefined;
386
471
  context.report({
387
472
  node: parentNode,
388
473
  messageId,
389
474
  data: { typeName },
475
+ fix: declaration
476
+ ? (fixer) => fixer.insertTextBefore(declaration, 'export ')
477
+ : undefined,
390
478
  });
391
479
  }
392
480
  }
393
481
  }
394
482
  }
395
- function checkAndReportParameterType(param, messageId) {
483
+ /**
484
+ * Applies the props check every exported component shape shares, reusing
485
+ * the parameter walk the `export function Banner(props: P)` visitor
486
+ * performs so the widened shapes cannot drift from it.
487
+ *
488
+ * Only the first parameter carries props. `forwardRef` hands the second one
489
+ * a ref, whose type (`Ref<HTMLDivElement>`) is no part of the contract a
490
+ * consumer composes against, so reporting it would be noise.
491
+ */
492
+ function checkComponentProps(fn) {
493
+ const [props] = fn.params;
494
+ if (!props)
495
+ return;
496
+ const typeParams = componentTypeParameters(fn);
497
+ // Destructuring the props changes nothing about the contract a consumer
498
+ // composes against, so `({ message }: BannerProps)` is read exactly like
499
+ // `(props: BannerProps)`. The widening stays on the props path: the
500
+ // parameter helper the other messageIds share keeps reading named
501
+ // parameters only.
502
+ if (props.type === utils_1.AST_NODE_TYPES.ObjectPattern && props.typeAnnotation) {
503
+ checkAndReportType(props.typeAnnotation.typeAnnotation, props.typeAnnotation, 'missingExportedPropsType', typeParams);
504
+ return;
505
+ }
506
+ checkAndReportParameterType(props, 'missingExportedPropsType', typeParams);
507
+ }
508
+ /**
509
+ * Collects the generic parameters a component may legitimately name in its
510
+ * props annotation (`memo(function ListUnmemoized<T>(props: ListProps<T>))`),
511
+ * which are contracts the module cannot export.
512
+ *
513
+ * Reading them off the function itself is what keeps the check correct for
514
+ * the shapes it reaches indirectly: `parent` links exist only for nodes
515
+ * ESLint has already traversed, so a component resolved through
516
+ * `memo(ListUnmemoized)` above its declaration has no ancestor chain to walk
517
+ * yet, and a function expression is no part of the ancestor walk to begin
518
+ * with. The ancestors are still unioned in for a component nested inside a
519
+ * generic function.
520
+ */
521
+ function componentTypeParameters(fn) {
522
+ const typeParams = findTypeParameters(fn);
523
+ for (const param of fn.typeParameters?.params ?? []) {
524
+ if (param.type === utils_1.AST_NODE_TYPES.TSTypeParameter) {
525
+ typeParams.add(param.name.name);
526
+ }
527
+ }
528
+ return typeParams;
529
+ }
530
+ /**
531
+ * Resolves a module-scope binding to the declaration or initializer it
532
+ * names, which is what turns `memo(BannerUnmemoized)` into the function
533
+ * carrying the props.
534
+ *
535
+ * A name with no module-scope declaration belongs to another module. Its
536
+ * props type is declared there and cannot be exported by editing this file,
537
+ * so an unresolved name leaves the component unchecked rather than guessing.
538
+ */
539
+ function findModuleScopeDeclaration(name) {
540
+ for (const statement of context.sourceCode.ast.body) {
541
+ const declaration = statement.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration &&
542
+ statement.declaration
543
+ ? statement.declaration
544
+ : statement;
545
+ if (declaration.type === utils_1.AST_NODE_TYPES.FunctionDeclaration &&
546
+ declaration.id?.name === name) {
547
+ return declaration;
548
+ }
549
+ if (declaration.type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
550
+ const declarator = declaration.declarations.find((candidate) => candidate.id.type === utils_1.AST_NODE_TYPES.Identifier &&
551
+ candidate.id.name === name);
552
+ if (declarator?.init) {
553
+ return declarator.init;
554
+ }
555
+ }
556
+ }
557
+ return undefined;
558
+ }
559
+ function checkAndReportParameterType(param, messageId, typeParams) {
396
560
  if (param.type === utils_1.AST_NODE_TYPES.Identifier && param.typeAnnotation) {
397
- checkAndReportType(param.typeAnnotation.typeAnnotation, param.typeAnnotation, messageId);
561
+ checkAndReportType(param.typeAnnotation.typeAnnotation, param.typeAnnotation, messageId, typeParams);
398
562
  }
399
563
  }
400
564
  function isTypeExported(typeName) {
@@ -507,9 +671,14 @@ exports.enforceExportedFunctionTypes = (0, createRule_1.createRule)({
507
671
  FunctionDeclaration(node) {
508
672
  if (!isExported(node))
509
673
  return;
510
- // Skip React components
511
- if (node.id?.name && /^[A-Z]/.test(node.id.name))
674
+ // A component declaration takes the props path, which reads the first
675
+ // parameter however it is spelled. Routing it through the same helper
676
+ // as the expression and wrapper shapes is what keeps a destructured
677
+ // parameter from being visible in one shape and invisible in another.
678
+ if (node.id?.name && isComponentName(node.id.name)) {
679
+ checkComponentProps(node);
512
680
  return;
681
+ }
513
682
  // Check return type
514
683
  if (node.returnType?.typeAnnotation) {
515
684
  checkAndReportType(node.returnType.typeAnnotation, node.returnType, 'missingExportedReturnType');
@@ -545,6 +714,40 @@ exports.enforceExportedFunctionTypes = (0, createRule_1.createRule)({
545
714
  checkAndReportType(node.typeAnnotation.typeAnnotation, node.typeAnnotation, 'missingExportedPropsType');
546
715
  }
547
716
  },
717
+ // Handle exported components written as an expression:
718
+ // `export const Banner = (props: P) => ...`,
719
+ // `export const Banner = function (props: P) {...}`, any
720
+ // `memo`/`forwardRef` nesting around either form, and the wrappers whose
721
+ // argument names a same-file function (`memo(BannerUnmemoized)`).
722
+ VariableDeclarator(node) {
723
+ if (node.id.type !== utils_1.AST_NODE_TYPES.Identifier ||
724
+ !isComponentName(node.id.name) ||
725
+ !isExported(node.parent)) {
726
+ return;
727
+ }
728
+ const component = unwrapComponentFunction(node.init, findModuleScopeDeclaration);
729
+ if (!component)
730
+ return;
731
+ checkComponentProps(component.fn);
732
+ },
733
+ // `export default memo(function Banner(props: P) {...})` mirrors the
734
+ // `export default function Banner(props: P)` form the declaration
735
+ // visitors already cover.
736
+ ExportDefaultDeclaration(node) {
737
+ const component = unwrapComponentFunction(node.declaration, findModuleScopeDeclaration);
738
+ if (!component)
739
+ return;
740
+ // The binding a wrapper argument named outranks the inner function's
741
+ // own name: `export default memo(BannerUnmemoized)` is the component
742
+ // consumers see, whatever the resolved expression calls itself.
743
+ const name = component.resolvedName ?? component.fn.id?.name;
744
+ // An anonymous default export is only recognizable as a component
745
+ // through its wrapper, since there is no name to inspect.
746
+ const isComponent = name === undefined ? component.isWrapped : isComponentName(name);
747
+ if (!isComponent)
748
+ return;
749
+ checkComponentProps(component.fn);
750
+ },
548
751
  // Skip type checking for React components since we handle them separately
549
752
  'FunctionDeclaration[id.name=/^[A-Z]/] > TSTypeAnnotation'() { },
550
753
  'FunctionDeclaration[id.name=/^[A-Z]/] > TSTypeAnnotation > TSTypeReference'() { },
@@ -55,13 +55,7 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
55
55
  if (!node.members || node.members.length === 0) {
56
56
  return true;
57
57
  }
58
- return node.members.some((member) => {
59
- if (member.type === utils_1.AST_NODE_TYPES.TSPropertySignature &&
60
- member.typeAnnotation) {
61
- return hasInvalidType(member.typeAnnotation.typeAnnotation);
62
- }
63
- return false;
64
- });
58
+ return membersHaveInvalidType(node.members);
65
59
  case utils_1.AST_NODE_TYPES.TSTypeReference:
66
60
  if (node.typeParameters) {
67
61
  return node.typeParameters.params.some(hasInvalidType);
@@ -74,17 +68,9 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
74
68
  }
75
69
  // Prevent infinite recursion
76
70
  typeCache.set(typeName, false);
77
- const program = context.sourceCode.ast;
78
- const interfaceDecl = program.body.find((n) => n.type === utils_1.AST_NODE_TYPES.TSInterfaceDeclaration &&
79
- n.id.name === typeName);
80
- if (interfaceDecl) {
81
- const result = interfaceDecl.body.body.some((member) => {
82
- if (member.type === utils_1.AST_NODE_TYPES.TSPropertySignature &&
83
- member.typeAnnotation) {
84
- return hasInvalidType(member.typeAnnotation.typeAnnotation);
85
- }
86
- return false;
87
- });
71
+ const members = declaredMembersOf(typeName);
72
+ if (members) {
73
+ const result = membersHaveInvalidType(members);
88
74
  typeCache.set(typeName, result);
89
75
  return result;
90
76
  }
@@ -120,6 +106,73 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
120
106
  return false;
121
107
  }
122
108
  }
109
+ function membersHaveInvalidType(members) {
110
+ return members.some((member) => {
111
+ if (member.type === utils_1.AST_NODE_TYPES.TSPropertySignature &&
112
+ member.typeAnnotation) {
113
+ return hasInvalidType(member.typeAnnotation.typeAnnotation);
114
+ }
115
+ return false;
116
+ });
117
+ }
118
+ /**
119
+ * Wrappers an alias may place around its type literal without changing the
120
+ * fields the document declares. `Readonly<{...}>` written inline at the
121
+ * reference is already looked through by the type-argument recursion in
122
+ * `hasInvalidType`, so reading it here keeps the two spellings in agreement.
123
+ * A wrapper that drops fields, such as `Omit`, is excluded: its members are
124
+ * not the document's members, and checking them invents reports.
125
+ */
126
+ const FIELD_PRESERVING_WRAPPERS = new Set(['Readonly']);
127
+ /**
128
+ * Reads the type literal an alias declares, looking through at most one
129
+ * field-preserving wrapper. Anything else — a union, an intersection, a
130
+ * mapped type, a reference to another named or imported type — has no
131
+ * members this rule can read syntactically, and guessing at them is how
132
+ * false positives arrive, so it stays unresolved.
133
+ */
134
+ function aliasedTypeLiteral(typeNode) {
135
+ if (typeNode.type === utils_1.AST_NODE_TYPES.TSTypeLiteral) {
136
+ return typeNode;
137
+ }
138
+ if (typeNode.type !== utils_1.AST_NODE_TYPES.TSTypeReference ||
139
+ typeNode.typeName.type !== utils_1.AST_NODE_TYPES.Identifier ||
140
+ !FIELD_PRESERVING_WRAPPERS.has(typeNode.typeName.name)) {
141
+ return undefined;
142
+ }
143
+ const wrapperArguments = typeNode.typeParameters?.params;
144
+ if (!wrapperArguments || wrapperArguments.length !== 1) {
145
+ return undefined;
146
+ }
147
+ const [wrapped] = wrapperArguments;
148
+ return wrapped.type === utils_1.AST_NODE_TYPES.TSTypeLiteral
149
+ ? wrapped
150
+ : undefined;
151
+ }
152
+ /**
153
+ * Resolves a named generic to the members its declaration lists, reading an
154
+ * interface and a type alias alike.
155
+ *
156
+ * The alias spelling is not an extra convenience: `prefer-type-over-interface`
157
+ * ships in the same recommended config and is fixable, so a single
158
+ * `eslint --fix` pass rewrites every interface into a type alias. A lookup
159
+ * that reads interfaces alone therefore resolves nothing on a codebase that
160
+ * has run the config, and a nested `any` in a document schema goes
161
+ * unreported.
162
+ */
163
+ function declaredMembersOf(typeName) {
164
+ for (const statement of context.sourceCode.ast.body) {
165
+ if (statement.type === utils_1.AST_NODE_TYPES.TSInterfaceDeclaration &&
166
+ statement.id.name === typeName) {
167
+ return statement.body.body;
168
+ }
169
+ if (statement.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration &&
170
+ statement.id.name === typeName) {
171
+ return aliasedTypeLiteral(statement.typeAnnotation)?.members;
172
+ }
173
+ }
174
+ return undefined;
175
+ }
123
176
  function hasTypeAnnotation(node) {
124
177
  if (nodeCache.has(node)) {
125
178
  return nodeCache.get(node);