@blumintinc/eslint-plugin-blumint 1.20.95 → 1.20.97

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.95',
226
+ version: '1.20.97',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -113,12 +113,25 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
113
113
  return true;
114
114
  }
115
115
  const nextChar = normalizedName.charAt(p.length);
116
- // For SCREAMING_SNAKE_CASE or similar all-uppercase names,
117
- // we require an underscore boundary.
116
+ // For SCREAMING_SNAKE_CASE or similar all-uppercase names the prefix must
117
+ // end at a separator, since case can no longer mark the word boundary
118
+ // (ISVALID stays unprefixed). Digits fused onto the prefix belong to that
119
+ // first segment rather than to the next word — ARE2_VALID is the
120
+ // UPPER_SNAKE spelling of are2Valid, which the camelCase branch below
121
+ // accepts — so a trailing digit run is consumed before the separator is
122
+ // examined, and the same separators the camelCase branch honours (`_`,
123
+ // `$`, end of name) close the segment. Only a digit run directly after
124
+ // the prefix qualifies: ARENA2_MAP still fails because a letter follows
125
+ // the prefix, and H2AS_ITEMS never reaches here because the prefix does
126
+ // not match the segment's start.
118
127
  const isAllUppercase = normalizedName === normalizedName.toUpperCase() &&
119
128
  /[a-z]/i.test(normalizedName);
120
129
  if (isAllUppercase) {
121
- return nextChar === '_';
130
+ const afterPrefix = normalizedName
131
+ .slice(p.length)
132
+ .replace(/^\d+/, '');
133
+ const boundaryChar = afterPrefix.charAt(0);
134
+ return (boundaryChar === '' || boundaryChar === '_' || boundaryChar === '$');
122
135
  }
123
136
  // For camelCase, the next char must be uppercase, a digit, or $
124
137
  return (nextChar === '_' ||
@@ -710,6 +723,28 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
710
723
  expression.name === 'undefined') {
711
724
  return 'nonBoolean';
712
725
  }
726
+ // A returned binding is governed by this very rule: a boolean variable,
727
+ // parameter or function must carry an approved prefix. So an unprefixed
728
+ // `id` — or the result of calling an unprefixed `compute(x)` — is not a
729
+ // boolean under the regime the rule enforces, and the callee's own name
730
+ // must not override that. Deciding here keeps the exemption in the body
731
+ // rather than in a return annotation, which `no-explicit-return-type`
732
+ // deletes (issue #1691).
733
+ //
734
+ // Member accesses (`source.flag`, `source.read()`) are deliberately
735
+ // excluded: property signatures are only enforced under
736
+ // `enforceForPropertySignatures` and third-party method names are outside
737
+ // this rule's reach, so an unprefixed member may legitimately yield a
738
+ // boolean and the callee's name keeps its say.
739
+ if (expression.type === utils_1.AST_NODE_TYPES.Identifier) {
740
+ return identifierIsBoolean(expression) ? 'boolean' : 'nonBoolean';
741
+ }
742
+ if (expression.type === utils_1.AST_NODE_TYPES.CallExpression &&
743
+ expression.callee.type === utils_1.AST_NODE_TYPES.Identifier) {
744
+ return callExpressionLooksBoolean(expression) === 'boolean'
745
+ ? 'boolean'
746
+ : 'nonBoolean';
747
+ }
713
748
  if (expression.type === utils_1.AST_NODE_TYPES.UnaryExpression) {
714
749
  if (expression.operator === '!' || expression.operator === 'delete') {
715
750
  return 'boolean';
@@ -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'() { },