@blumintinc/eslint-plugin-blumint 1.20.95 → 1.20.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/lib/index.js +1 -1
- 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/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/package.json +1 -1
- package/release-manifest.json +128 -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
|
@@ -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'() { },
|
|
@@ -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
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
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);
|