@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 +1 -1
- package/lib/index.js +1 -1
- package/lib/rules/enforce-boolean-naming-prefixes.js +38 -3
- package/lib/rules/enforce-css-media-queries.d.ts +2 -1
- package/lib/rules/enforce-css-media-queries.js +203 -8
- package/lib/rules/enforce-exported-function-types.js +209 -6
- package/lib/rules/enforce-firestore-doc-ref-generic.js +71 -18
- package/lib/rules/enforce-mui-rounded-icons.d.ts +2 -1
- package/lib/rules/enforce-mui-rounded-icons.js +194 -13
- package/lib/rules/enforce-positive-naming.js +112 -24
- package/lib/rules/generic-starts-with-t.js +49 -0
- package/lib/rules/global-const-style.js +101 -51
- package/lib/rules/no-always-true-false-conditions.js +231 -62
- package/lib/rules/no-unnecessary-verb-suffix.js +52 -5
- package/lib/rules/parallelize-loop-awaits.d.ts +1 -0
- package/lib/rules/parallelize-loop-awaits.js +233 -43
- package/lib/rules/prefer-getter-over-parameterless-method.d.ts +2 -1
- package/lib/rules/prefer-getter-over-parameterless-method.js +259 -0
- package/lib/rules/prefer-type-alias-over-typeof-constant.js +63 -4
- package/lib/rules/require-https-error.js +6 -2
- package/lib/rules/use-custom-link.js +45 -1
- package/lib/rules/use-custom-memo.js +30 -0
- package/lib/rules/use-custom-router.js +32 -2
- package/lib/utils/harvestRuleTesterCases.d.ts +48 -0
- package/lib/utils/harvestRuleTesterCases.js +168 -0
- package/package.json +1 -1
- package/release-manifest.json +151 -0
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
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
57
|
-
(node.callee.name
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
511
|
-
|
|
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'() { },
|