@blumintinc/eslint-plugin-blumint 1.20.102 → 1.20.103
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
|
@@ -256,7 +256,7 @@ full closed loop is documented in agora's `.claude/skills/eslint-autonomy/SKILL.
|
|
|
256
256
|
| [prevent-children-clobber](docs/rules/prevent-children-clobber.md) | Prevent JSX spreads from silently discarding props.children | ✅ | | | | |
|
|
257
257
|
| [react-memoize-literals](docs/rules/react-memoize-literals.md) | Detect object, array, and function literals created in React components or hooks that create new references every render. Prefer memoized values (useMemo/useCallback) or module-level constants to keep referential stability. | ✅ | | | 💡 | |
|
|
258
258
|
| [react-usememo-should-be-component](docs/rules/react-usememo-should-be-component.md) | Enforce that useMemo hooks explicitly returning JSX should be abstracted into separate React components | ✅ | | | | |
|
|
259
|
-
| [require-dynamic-firebase-imports](docs/rules/require-dynamic-firebase-imports.md) | Enforce dynamic imports for Firebase dependencies | ✅ | |
|
|
259
|
+
| [require-dynamic-firebase-imports](docs/rules/require-dynamic-firebase-imports.md) | Enforce dynamic imports for Firebase dependencies | ✅ | | | | |
|
|
260
260
|
| [require-hooks-default-params](docs/rules/require-hooks-default-params.md) | Enforce React hooks with optional parameters to default to an empty object | ✅ | | 🔧 | | |
|
|
261
261
|
| [require-https-error](docs/rules/require-https-error.md) | Enforce using proprietary HttpsError instead of throw new Error or firebase-admin HttpsError in functions/src | ✅ | | | | |
|
|
262
262
|
| [require-https-error-cause](docs/rules/require-https-error-cause.md) | Ensure HttpsError calls inside catch blocks pass the caught error as the fourth "cause" argument to preserve stack traces for monitoring. | ✅ | | | | |
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { TSESTree } from '@typescript-eslint/utils';
|
|
2
2
|
export declare const RULE_NAME = "require-dynamic-firebase-imports";
|
|
3
|
-
declare const _default:
|
|
3
|
+
declare const _default: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"requireDynamicImport", never[], {
|
|
4
4
|
ImportDeclaration(node: TSESTree.ImportDeclaration): void;
|
|
5
5
|
}>;
|
|
6
6
|
export default _default;
|
|
@@ -3,79 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.RULE_NAME = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
|
-
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
7
6
|
exports.RULE_NAME = 'require-dynamic-firebase-imports';
|
|
8
|
-
/**
|
|
9
|
-
* A specifier is erased at compile time when either the whole statement is an
|
|
10
|
-
* `import type` or the specifier carries an inline `type` marker. Only such a
|
|
11
|
-
* specifier is interchangeable with the `import type` the fixer hoists.
|
|
12
|
-
*/
|
|
13
|
-
const isErasableTypeSpecifier = (declaration, specifier) => declaration.importKind === 'type' || specifier.importKind === 'type';
|
|
14
|
-
/**
|
|
15
|
-
* Whether `specifier` binds the same local name to the same export of
|
|
16
|
-
* `importSource` in type position as `target` — i.e. whether it is exactly the
|
|
17
|
-
* binding the hoisted `import type` would introduce.
|
|
18
|
-
*/
|
|
19
|
-
const bindsSameTypeImport = (specifier, target, importSource) => {
|
|
20
|
-
if (specifier.type !== utils_1.AST_NODE_TYPES.ImportSpecifier ||
|
|
21
|
-
specifier.local.name !== target.local.name ||
|
|
22
|
-
specifier.imported.name !== target.imported.name) {
|
|
23
|
-
return false;
|
|
24
|
-
}
|
|
25
|
-
const declaration = specifier.parent;
|
|
26
|
-
return (declaration?.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
|
|
27
|
-
declaration.source.value === importSource &&
|
|
28
|
-
isErasableTypeSpecifier(declaration, specifier));
|
|
29
|
-
};
|
|
30
|
-
/**
|
|
31
|
-
* Read the module-scope import state off `Program.body` rather than a traversal
|
|
32
|
-
* flag: `--fix` runs multiple passes, and a sibling report in an earlier pass may
|
|
33
|
-
* already have hoisted the same `import type`. A flag set by the
|
|
34
|
-
* `ImportDeclaration` visitor also depends on source order, so it would be stale
|
|
35
|
-
* for any import that precedes the one being rewritten.
|
|
36
|
-
*/
|
|
37
|
-
const findHoistedTypeImport = (program, target, importSource) => {
|
|
38
|
-
for (const statement of program.body) {
|
|
39
|
-
if (statement.type !== utils_1.AST_NODE_TYPES.ImportDeclaration) {
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
const match = statement.specifiers.find((specifier) => bindsSameTypeImport(specifier, target, importSource));
|
|
43
|
-
if (match) {
|
|
44
|
-
return match;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return undefined;
|
|
48
|
-
};
|
|
49
|
-
/**
|
|
50
|
-
* The binding that makes hoisting `target` unsafe, or `null` when the name is
|
|
51
|
-
* free (or already bound to the very import being hoisted).
|
|
52
|
-
*
|
|
53
|
-
* Resolution walks the scope chain of the rewritten import instead of stopping
|
|
54
|
-
* at module scope, because a narrower shadow raises no TypeScript diagnostic yet
|
|
55
|
-
* silently binds the hoisted type to the wrong declaration. Each hop resolves a
|
|
56
|
-
* single step further up: the rewritten statement binds its own inline `type`
|
|
57
|
-
* specifier in the enclosing function scope, which would otherwise mask the
|
|
58
|
-
* colliding declaration above it. Bindings without a definition are ambient
|
|
59
|
-
* (TypeScript lib types, configured globals); a module-scope import legally
|
|
60
|
-
* shadows those, so they are not collisions.
|
|
61
|
-
*/
|
|
62
|
-
const findCollidingBinding = (scope, target, importSource) => {
|
|
63
|
-
let current = scope;
|
|
64
|
-
while (current) {
|
|
65
|
-
const existing = ASTHelpers_1.ASTHelpers.findVariableInScope(current, target.local.name);
|
|
66
|
-
if (!existing) {
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
if (existing.defs.length > 0) {
|
|
70
|
-
const bindsTheDesiredImport = existing.defs.every((def) => bindsSameTypeImport(def.node, target, importSource));
|
|
71
|
-
if (!bindsTheDesiredImport) {
|
|
72
|
-
return existing;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
current = existing.scope.upper;
|
|
76
|
-
}
|
|
77
|
-
return null;
|
|
78
|
-
};
|
|
79
7
|
exports.default = (0, createRule_1.createRule)({
|
|
80
8
|
name: exports.RULE_NAME,
|
|
81
9
|
meta: {
|
|
@@ -84,7 +12,6 @@ exports.default = (0, createRule_1.createRule)({
|
|
|
84
12
|
description: 'Enforce dynamic imports for Firebase dependencies',
|
|
85
13
|
recommended: 'error',
|
|
86
14
|
},
|
|
87
|
-
fixable: 'code',
|
|
88
15
|
schema: [],
|
|
89
16
|
messages: {
|
|
90
17
|
requireDynamicImport: 'Static Firebase import from "{{importSource}}" keeps the Firebase SDK in the initial bundle and blocks route-level code splitting. Use an `await import(\'{{importSource}}\')` dynamic import so Firebase loads only on the code path that needs it; type-only imports remain allowed.',
|
|
@@ -96,64 +23,8 @@ exports.default = (0, createRule_1.createRule)({
|
|
|
96
23
|
return (source.startsWith('firebase/') ||
|
|
97
24
|
source.includes('config/firebase-client'));
|
|
98
25
|
};
|
|
99
|
-
/**
|
|
100
|
-
* `await import()` is only valid inside an async function. Rewriting a
|
|
101
|
-
* module-scope import would introduce top-level await, silently converting
|
|
102
|
-
* a synchronous module into an async one (and failing outright on build
|
|
103
|
-
* targets without top-level await support) — so those sites are reported
|
|
104
|
-
* without a fix.
|
|
105
|
-
*/
|
|
106
|
-
const isInsideAsyncFunction = (node) => {
|
|
107
|
-
let current = node.parent;
|
|
108
|
-
while (current) {
|
|
109
|
-
if (current.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
|
|
110
|
-
current.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
|
|
111
|
-
current.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression) {
|
|
112
|
-
// The nearest enclosing function decides await validity; an async
|
|
113
|
-
// ancestor beyond a sync function cannot host the await.
|
|
114
|
-
return current.async;
|
|
115
|
-
}
|
|
116
|
-
current = current.parent;
|
|
117
|
-
}
|
|
118
|
-
return false;
|
|
119
|
-
};
|
|
120
26
|
const isTypeOnlySpecifier = (spec) => spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
121
27
|
spec.importKind === 'type';
|
|
122
|
-
const buildDynamicImport = (importSource, valueSpecifiers) => {
|
|
123
|
-
if (valueSpecifiers.length === 0) {
|
|
124
|
-
// Side-effect imports have no bindings to destructure.
|
|
125
|
-
return `await import('${importSource}');`;
|
|
126
|
-
}
|
|
127
|
-
const namespaceSpecifier = valueSpecifiers.find((spec) => spec.type === utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier);
|
|
128
|
-
const defaultSpecifier = valueSpecifiers.find((spec) => spec.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier);
|
|
129
|
-
const namedSpecifiers = valueSpecifiers.filter((spec) => spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier);
|
|
130
|
-
if (namespaceSpecifier) {
|
|
131
|
-
// The promise resolved by `import()` IS the module namespace object,
|
|
132
|
-
// so the `* as ns` binding maps directly onto it.
|
|
133
|
-
const namespaceDeclaration = `const ${namespaceSpecifier.local.name} = await import('${importSource}');`;
|
|
134
|
-
if (defaultSpecifier) {
|
|
135
|
-
return `${namespaceDeclaration} const ${defaultSpecifier.local.name} = ${namespaceSpecifier.local.name}.default;`;
|
|
136
|
-
}
|
|
137
|
-
return namespaceDeclaration;
|
|
138
|
-
}
|
|
139
|
-
const destructuredNames = namedSpecifiers.map((spec) => spec.imported.name === spec.local.name
|
|
140
|
-
? spec.local.name
|
|
141
|
-
: `${spec.imported.name}: ${spec.local.name}`);
|
|
142
|
-
if (defaultSpecifier) {
|
|
143
|
-
if (destructuredNames.length === 0) {
|
|
144
|
-
return `const ${defaultSpecifier.local.name} = (await import('${importSource}')).default;`;
|
|
145
|
-
}
|
|
146
|
-
// The namespace object exposes the default export under `default`.
|
|
147
|
-
return `const { default: ${defaultSpecifier.local.name}, ${destructuredNames.join(', ')} } = await import('${importSource}');`;
|
|
148
|
-
}
|
|
149
|
-
return `const { ${destructuredNames.join(', ')} } = await import('${importSource}');`;
|
|
150
|
-
};
|
|
151
|
-
const buildStaticTypeImport = (importSource, typeSpecifiers) => {
|
|
152
|
-
const names = typeSpecifiers.map((spec) => spec.imported.name === spec.local.name
|
|
153
|
-
? spec.local.name
|
|
154
|
-
: `${spec.imported.name} as ${spec.local.name}`);
|
|
155
|
-
return `import type { ${names.join(', ')} } from '${importSource}';\n`;
|
|
156
|
-
};
|
|
157
28
|
return {
|
|
158
29
|
ImportDeclaration(node) {
|
|
159
30
|
const importSource = node.source.value;
|
|
@@ -166,50 +37,23 @@ exports.default = (0, createRule_1.createRule)({
|
|
|
166
37
|
if (node.importKind === 'type') {
|
|
167
38
|
return;
|
|
168
39
|
}
|
|
169
|
-
const typeSpecifiers = node.specifiers.filter(isTypeOnlySpecifier);
|
|
170
40
|
const valueSpecifiers = node.specifiers.filter((spec) => !isTypeOnlySpecifier(spec));
|
|
171
41
|
// Inline `type` markers on every specifier make the whole statement
|
|
172
42
|
// erasable, exactly like `import type` — nothing to report.
|
|
173
43
|
if (node.specifiers.length > 0 && valueSpecifiers.length === 0) {
|
|
174
44
|
return;
|
|
175
45
|
}
|
|
46
|
+
// The rule is report-only. A static `import` declaration is legal only
|
|
47
|
+
// at the top level of a module or namespace, so every reported site is
|
|
48
|
+
// at module scope, where the `await import()` replacement would
|
|
49
|
+
// introduce top-level await — silently converting a synchronous module
|
|
50
|
+
// into an async one and breaking build targets that lack it. The
|
|
51
|
+
// author must instead move the import into the async code path that
|
|
52
|
+
// needs it, which is a restructuring no autofix can make safely.
|
|
176
53
|
context.report({
|
|
177
54
|
node,
|
|
178
55
|
messageId: 'requireDynamicImport',
|
|
179
56
|
data: { importSource },
|
|
180
|
-
fix(fixer) {
|
|
181
|
-
if (!isInsideAsyncFunction(node)) {
|
|
182
|
-
return null;
|
|
183
|
-
}
|
|
184
|
-
// Type specifiers must not travel into the runtime destructuring:
|
|
185
|
-
// they have no runtime value, and dropping the `type` marker turns
|
|
186
|
-
// type references into dangling value bindings. They hoist into a
|
|
187
|
-
// static `import type` at module scope, which is erased at compile
|
|
188
|
-
// time.
|
|
189
|
-
const scope = ASTHelpers_1.ASTHelpers.getScope(context, node);
|
|
190
|
-
const program = context.getSourceCode().ast;
|
|
191
|
-
const specifiersToHoist = [];
|
|
192
|
-
for (const specifier of typeSpecifiers) {
|
|
193
|
-
if (findCollidingBinding(scope, specifier, importSource)) {
|
|
194
|
-
// Declining the whole fix keeps the file compiling: applying
|
|
195
|
-
// the dynamic import while skipping a colliding hoist would
|
|
196
|
-
// strand the type reference (TS2749) instead of duplicating an
|
|
197
|
-
// identifier (TS2440/TS2300). The report stands so the author
|
|
198
|
-
// resolves the name clash deliberately.
|
|
199
|
-
return null;
|
|
200
|
-
}
|
|
201
|
-
if (!findHoistedTypeImport(program, specifier, importSource)) {
|
|
202
|
-
specifiersToHoist.push(specifier);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
const fixes = [
|
|
206
|
-
fixer.replaceText(node, buildDynamicImport(importSource, valueSpecifiers)),
|
|
207
|
-
];
|
|
208
|
-
if (specifiersToHoist.length > 0) {
|
|
209
|
-
fixes.push(fixer.insertTextBeforeRange([0, 0], buildStaticTypeImport(importSource, specifiersToHoist)));
|
|
210
|
-
}
|
|
211
|
-
return fixes;
|
|
212
|
-
},
|
|
213
57
|
});
|
|
214
58
|
},
|
|
215
59
|
};
|
|
@@ -505,6 +505,7 @@ const DEFAULT_BINDING = 'default';
|
|
|
505
505
|
*/
|
|
506
506
|
const moduleResolutionCache = new Map();
|
|
507
507
|
const propLessBindingCache = new Map();
|
|
508
|
+
const unionMemberCache = new Map();
|
|
508
509
|
/**
|
|
509
510
|
* Stat a candidate path, returning its identity stamp when it is a file. The
|
|
510
511
|
* single stat both resolves existence and stamps the file, so proving a child
|
|
@@ -902,6 +903,170 @@ function getDependencyPropsSourceType(program, depName) {
|
|
|
902
903
|
}
|
|
903
904
|
return null;
|
|
904
905
|
}
|
|
906
|
+
/**
|
|
907
|
+
* Name one arm of a union, and flatten whatever the arm turns out to be.
|
|
908
|
+
*
|
|
909
|
+
* Only an arm spelled as a type *reference* yields a name: an inline object arm
|
|
910
|
+
* has no name a parent could compose with, and an intersection arm's members
|
|
911
|
+
* describe a fragment of the arm rather than the arm itself. A named arm is
|
|
912
|
+
* followed to its definition too, because an alias of a union flattens into the
|
|
913
|
+
* enclosing union in TypeScript, so its own arms are arms here.
|
|
914
|
+
*/
|
|
915
|
+
function collectUnionArmNames(arm, program, seenAliases, into) {
|
|
916
|
+
if (arm.type === utils_1.AST_NODE_TYPES.TSUnionType) {
|
|
917
|
+
for (const nested of arm.types) {
|
|
918
|
+
collectUnionArmNames(nested, program, seenAliases, into);
|
|
919
|
+
}
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
if (arm.type !== utils_1.AST_NODE_TYPES.TSTypeReference) {
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
const name = getTypeReferenceName(arm);
|
|
926
|
+
if (name === 'Readonly') {
|
|
927
|
+
// A `Readonly<X>` arm is the X arm: the wrapper adds no surface of its own.
|
|
928
|
+
const inner = arm.typeParameters?.params[0];
|
|
929
|
+
if (inner) {
|
|
930
|
+
collectUnionArmNames(inner, program, seenAliases, into);
|
|
931
|
+
}
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
if (!name || seenAliases.has(name)) {
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
into.add(name);
|
|
938
|
+
const alias = findPropsTypeAliasByName(program, name);
|
|
939
|
+
if (alias) {
|
|
940
|
+
const nextSeen = new Set(seenAliases);
|
|
941
|
+
nextSeen.add(name);
|
|
942
|
+
collectUnionArmNames(alias.typeAnnotation, program, nextSeen, into);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Collect the named arms of a props type that resolves to a union, following
|
|
947
|
+
* `Readonly<...>` wrappers and named-alias indirection within `program` to reach
|
|
948
|
+
* it. A props type that is not a union contributes nothing, so a single-shape
|
|
949
|
+
* child keeps demanding composition with its own name.
|
|
950
|
+
*
|
|
951
|
+
* Resolving *to* the union never names anything on the way: only an arm of a
|
|
952
|
+
* real union is a member, so an alias chain that ends at a single object type
|
|
953
|
+
* credits none of the names it passed through.
|
|
954
|
+
*/
|
|
955
|
+
function collectUnionMemberNames(typeNode, program, seenAliases = new Set(), into = new Set()) {
|
|
956
|
+
if (!typeNode) {
|
|
957
|
+
return into;
|
|
958
|
+
}
|
|
959
|
+
if (typeNode.type === utils_1.AST_NODE_TYPES.TSUnionType) {
|
|
960
|
+
for (const arm of typeNode.types) {
|
|
961
|
+
collectUnionArmNames(arm, program, seenAliases, into);
|
|
962
|
+
}
|
|
963
|
+
return into;
|
|
964
|
+
}
|
|
965
|
+
if (typeNode.type === utils_1.AST_NODE_TYPES.TSTypeReference) {
|
|
966
|
+
const name = getTypeReferenceName(typeNode);
|
|
967
|
+
if (name === 'Readonly') {
|
|
968
|
+
const inner = typeNode.typeParameters?.params[0];
|
|
969
|
+
if (inner) {
|
|
970
|
+
collectUnionMemberNames(inner, program, seenAliases, into);
|
|
971
|
+
}
|
|
972
|
+
return into;
|
|
973
|
+
}
|
|
974
|
+
if (name && !seenAliases.has(name)) {
|
|
975
|
+
const alias = findPropsTypeAliasByName(program, name);
|
|
976
|
+
if (alias) {
|
|
977
|
+
const nextSeen = new Set(seenAliases);
|
|
978
|
+
nextSeen.add(name);
|
|
979
|
+
collectUnionMemberNames(alias.typeAnnotation, program, nextSeen, into);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
return into;
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* The props type node a module exports alongside `binding`: its
|
|
987
|
+
* `{Binding}Props` alias when one exists, otherwise the exported component's
|
|
988
|
+
* own first-parameter annotation. Mirrors getDependencyPropsSourceType for a
|
|
989
|
+
* foreign program, resolving the export's local name first so
|
|
990
|
+
* `const X = …; export { X as Y }` reads X's props type.
|
|
991
|
+
*/
|
|
992
|
+
function getExportedPropsSourceType(program, binding) {
|
|
993
|
+
if (binding === DEFAULT_BINDING) {
|
|
994
|
+
const fn = findDefaultExportFunction(program);
|
|
995
|
+
return fn ? getFirstParamTypeNode(fn) : null;
|
|
996
|
+
}
|
|
997
|
+
const local = findExportedLocalName(program, binding);
|
|
998
|
+
return local === null ? null : getDependencyPropsSourceType(program, local);
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* The union arms of a dependency's props type as declared in the sibling module
|
|
1002
|
+
* it is imported from. Resolution mirrors isPropLessImportedComponent — a
|
|
1003
|
+
* relative specifier, a file that exists on disk, no shadowing declaration
|
|
1004
|
+
* inside the rendering component — so anything the parse cannot decide yields
|
|
1005
|
+
* no members and the composition requirement stands.
|
|
1006
|
+
*/
|
|
1007
|
+
function getImportedDependencyUnionMembers(program, localName, filename, componentRoot, cwd) {
|
|
1008
|
+
const relativeImport = findRelativeImport(program, localName);
|
|
1009
|
+
if (!relativeImport) {
|
|
1010
|
+
return [];
|
|
1011
|
+
}
|
|
1012
|
+
if (isNameDeclaredWithin(componentRoot, localName)) {
|
|
1013
|
+
return [];
|
|
1014
|
+
}
|
|
1015
|
+
try {
|
|
1016
|
+
const absolute = path_1.default.isAbsolute(filename)
|
|
1017
|
+
? filename
|
|
1018
|
+
: path_1.default.resolve(cwd, filename);
|
|
1019
|
+
const resolved = resolveRelativeModule(path_1.default.dirname(absolute), relativeImport.source);
|
|
1020
|
+
if (!resolved) {
|
|
1021
|
+
return [];
|
|
1022
|
+
}
|
|
1023
|
+
const cacheKey = JSON.stringify([
|
|
1024
|
+
resolved.filePath,
|
|
1025
|
+
relativeImport.binding,
|
|
1026
|
+
]);
|
|
1027
|
+
const cached = unionMemberCache.get(cacheKey);
|
|
1028
|
+
if (cached !== undefined &&
|
|
1029
|
+
cached.mtimeMs === resolved.mtimeMs &&
|
|
1030
|
+
cached.size === resolved.size) {
|
|
1031
|
+
return cached.members;
|
|
1032
|
+
}
|
|
1033
|
+
const childProgram = parseModuleProgram(resolved.filePath);
|
|
1034
|
+
const members = childProgram
|
|
1035
|
+
? Array.from(collectUnionMemberNames(getExportedPropsSourceType(childProgram, relativeImport.binding), childProgram))
|
|
1036
|
+
: [];
|
|
1037
|
+
unionMemberCache.set(cacheKey, {
|
|
1038
|
+
mtimeMs: resolved.mtimeMs,
|
|
1039
|
+
size: resolved.size,
|
|
1040
|
+
members,
|
|
1041
|
+
});
|
|
1042
|
+
return members;
|
|
1043
|
+
}
|
|
1044
|
+
catch {
|
|
1045
|
+
return [];
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
/**
|
|
1049
|
+
* Every name the file under lint can use to reference an imported type: the
|
|
1050
|
+
* exported name itself plus each `import { Exported as Local }` rename of it.
|
|
1051
|
+
* A union member declared in a sibling module is written with the local
|
|
1052
|
+
* spelling at the composition site.
|
|
1053
|
+
*/
|
|
1054
|
+
function collectImportSpellings(program, importedName) {
|
|
1055
|
+
const spellings = [importedName];
|
|
1056
|
+
for (const stmt of program.body) {
|
|
1057
|
+
if (stmt.type !== utils_1.AST_NODE_TYPES.ImportDeclaration)
|
|
1058
|
+
continue;
|
|
1059
|
+
for (const specifier of stmt.specifiers) {
|
|
1060
|
+
if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
1061
|
+
specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
1062
|
+
specifier.imported.name === importedName &&
|
|
1063
|
+
specifier.local.name !== importedName) {
|
|
1064
|
+
spellings.push(specifier.local.name);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
return spellings;
|
|
1069
|
+
}
|
|
905
1070
|
exports.requirePropsComposition = (0, createRule_1.createRule)({
|
|
906
1071
|
name: 'require-props-composition',
|
|
907
1072
|
meta: {
|
|
@@ -1019,6 +1184,25 @@ exports.requirePropsComposition = (0, createRule_1.createRule)({
|
|
|
1019
1184
|
checkComponentWithProgram(componentName, node, node.id, prog);
|
|
1020
1185
|
},
|
|
1021
1186
|
};
|
|
1187
|
+
/**
|
|
1188
|
+
* Whether the parent's props type composes with a member of `dep`'s props
|
|
1189
|
+
* union. Both spellings the member can take are tried: the in-file
|
|
1190
|
+
* declaration, and the sibling module's export (under the exported name and
|
|
1191
|
+
* under any local rename the parent imports it as).
|
|
1192
|
+
*
|
|
1193
|
+
* A dependency whose props type is not a union yields no members, so this
|
|
1194
|
+
* never credits a parent that composes with nothing.
|
|
1195
|
+
*/
|
|
1196
|
+
function composesWithUnionMember(dep, propsTypeNode, prog, componentRoot) {
|
|
1197
|
+
const localMembers = collectUnionMemberNames(getDependencyPropsSourceType(prog, dep), prog);
|
|
1198
|
+
for (const member of localMembers) {
|
|
1199
|
+
if (typeNodeComposesWithProps(propsTypeNode, member, prog)) {
|
|
1200
|
+
return true;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
const importedMembers = getImportedDependencyUnionMembers(prog, dep, rawFilename, componentRoot, cwd);
|
|
1204
|
+
return importedMembers.some((member) => collectImportSpellings(prog, member).some((spelling) => typeNodeComposesWithProps(propsTypeNode, spelling, prog)));
|
|
1205
|
+
}
|
|
1022
1206
|
function checkComponentWithProgram(componentName, funcNode, reportNode, prog) {
|
|
1023
1207
|
// Collect all JSX element names used in the component body
|
|
1024
1208
|
const body = funcNode.body ?? funcNode;
|
|
@@ -1084,6 +1268,25 @@ exports.requirePropsComposition = (0, createRule_1.createRule)({
|
|
|
1084
1268
|
missingComposition.push(dep);
|
|
1085
1269
|
}
|
|
1086
1270
|
}
|
|
1271
|
+
// A child whose props type is a union is composed by composing with any
|
|
1272
|
+
// one of its MEMBERS: `Pick<ChipToggleProps, 'label'>` inherits exactly
|
|
1273
|
+
// the surface the child accepts on that arm, so the DRY guarantee holds
|
|
1274
|
+
// just as it does for the union alias itself. This mirrors on the child
|
|
1275
|
+
// side the `.some` rule issue #1343 established on the parent side.
|
|
1276
|
+
//
|
|
1277
|
+
// Deferred behind the whole cheap pass because resolving a sibling
|
|
1278
|
+
// module's members reads it off disk: a file that already composes never
|
|
1279
|
+
// pays for it.
|
|
1280
|
+
if (missingComposition.length > 0 &&
|
|
1281
|
+
(requireAllDependencies || composedWith.size === 0)) {
|
|
1282
|
+
for (let index = missingComposition.length - 1; index >= 0; index--) {
|
|
1283
|
+
const dep = missingComposition[index];
|
|
1284
|
+
if (composesWithUnionMember(dep, propsTypeNode, prog, funcNode)) {
|
|
1285
|
+
composedWith.add(dep);
|
|
1286
|
+
missingComposition.splice(index, 1);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1087
1290
|
if (!requireAllDependencies) {
|
|
1088
1291
|
// Only flag when NO dependency has composition
|
|
1089
1292
|
if (composedWith.size > 0) {
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,26 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.103",
|
|
4
|
+
"date": "2026-08-04T18:19:46.054Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "require-dynamic-firebase-imports",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1708
|
|
11
|
+
],
|
|
12
|
+
"summary": "drop the unreachable autofix declaration (closes #1708)"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "require-props-composition",
|
|
16
|
+
"changeType": "fix",
|
|
17
|
+
"issues": [
|
|
18
|
+
1709
|
|
19
|
+
],
|
|
20
|
+
"summary": "credit composition with a member of the child's props union (closes #1709)"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
},
|
|
2
24
|
{
|
|
3
25
|
"version": "1.20.102",
|
|
4
26
|
"date": "2026-08-04T17:20:02.924Z",
|