@blumintinc/eslint-plugin-blumint 1.20.164 → 1.20.166
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/lib/index.js
CHANGED
|
@@ -16,6 +16,11 @@ const IMPORT_BINDING_NODE_TYPES = new Set([
|
|
|
16
16
|
utils_1.AST_NODE_TYPES.ImportDefaultSpecifier,
|
|
17
17
|
utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier,
|
|
18
18
|
]);
|
|
19
|
+
const membersDeclareIdString = (members) => members.some((member) => member.type === utils_1.AST_NODE_TYPES.TSPropertySignature &&
|
|
20
|
+
member.key.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
21
|
+
member.key.name === 'id' &&
|
|
22
|
+
member.typeAnnotation?.typeAnnotation.type ===
|
|
23
|
+
utils_1.AST_NODE_TYPES.TSStringKeyword);
|
|
19
24
|
exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
|
|
20
25
|
name: 'enforce-identifiable-firestore-type',
|
|
21
26
|
meta: {
|
|
@@ -26,8 +31,10 @@ exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
|
|
|
26
31
|
},
|
|
27
32
|
schema: [],
|
|
28
33
|
messages: {
|
|
29
|
-
|
|
30
|
-
|
|
34
|
+
// Every spelling named here must clear the rule; a message that
|
|
35
|
+
// prescribes a remedy the rule then reports on is unfollowable (#2035).
|
|
36
|
+
missingType: 'Expected exported type "{{ typeName }}" in index.ts under folder "{{ folderName }}". Create a type that matches the folder name: `export type {{ typeName }} = { id: string; /* other fields */ }`.',
|
|
37
|
+
notExtendingIdentifiable: 'Type "{{ typeName }}" must carry an `id` field so every Firestore document is identifiable. Intersect Identifiable (`export type {{ typeName }} = Identifiable & { /* other fields */ }`), extend it (`export interface {{ typeName }} extends Identifiable { /* other fields */ }`), or declare the field inline (`export type {{ typeName }} = { id: string; /* other fields */ }`).',
|
|
31
38
|
},
|
|
32
39
|
},
|
|
33
40
|
defaultOptions: [],
|
|
@@ -46,32 +53,259 @@ exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
|
|
|
46
53
|
}
|
|
47
54
|
// Get the expected type name from the parent folder
|
|
48
55
|
const folderName = path_1.default.basename(path_1.default.dirname(filename));
|
|
49
|
-
// The folder-matching
|
|
50
|
-
// consumers.
|
|
51
|
-
// separately (`export { X }` / `export type { X }`), and the separate
|
|
52
|
-
// can appear either before or after the declaration, so exportedness
|
|
53
|
-
// be decided inside the
|
|
54
|
-
//
|
|
55
|
-
let
|
|
56
|
-
let
|
|
57
|
-
let
|
|
58
|
-
// Set when resolving the matching
|
|
59
|
-
// in another module. Such a type is opaque to this single-file
|
|
60
|
-
// absence of
|
|
61
|
-
let
|
|
56
|
+
// The folder-matching declaration only satisfies the rule if it is
|
|
57
|
+
// reachable by consumers. It can be exported inline (`export type X = ...`)
|
|
58
|
+
// or separately (`export { X }` / `export type { X }`), and the separate
|
|
59
|
+
// form can appear either before or after the declaration, so exportedness
|
|
60
|
+
// can't be decided inside the declaration visitor alone — it's resolved once
|
|
61
|
+
// the whole module has been scanned, in Program:exit.
|
|
62
|
+
let matchingDeclarationNode = null;
|
|
63
|
+
let matchingDeclarationInlineExported = false;
|
|
64
|
+
let matchingDeclarationProvidesId = false;
|
|
65
|
+
// Set when resolving the matching declaration runs into a type that is
|
|
66
|
+
// declared in another module. Such a type is opaque to this single-file
|
|
67
|
+
// walk, so the absence of an id is unproven rather than disproven.
|
|
68
|
+
let matchingDeclarationLeavesModule = false;
|
|
62
69
|
const locallyExportedNames = new Set();
|
|
70
|
+
// Interfaces merge, so a second declaration of the same name adds to the
|
|
71
|
+
// first rather than replacing it; accumulating with OR keeps every arm that
|
|
72
|
+
// any declaration of the name satisfies.
|
|
73
|
+
const analyzeMatchingDeclaration = (node) => {
|
|
74
|
+
// Raised by the resolution walks below whenever a referenced type name
|
|
75
|
+
// resolves to an imported binding, i.e. the chain ran out of this file.
|
|
76
|
+
// Scoped to this declaration so an import that no chain ever reaches — an
|
|
77
|
+
// unrelated `Timestamp`, or a type argument the walk never descends into
|
|
78
|
+
// — grants no amnesty.
|
|
79
|
+
let leavesModule = false;
|
|
80
|
+
const isImportedBinding = (name) => {
|
|
81
|
+
let scope = context.getScope();
|
|
82
|
+
while (scope) {
|
|
83
|
+
const variable = scope.variables.find((variableNode) => variableNode.name === name);
|
|
84
|
+
if (variable) {
|
|
85
|
+
return variable.defs.some((definition) => IMPORT_BINDING_NODE_TYPES.has(definition.node.type));
|
|
86
|
+
}
|
|
87
|
+
scope = scope.upper;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
};
|
|
91
|
+
const findLocalDeclaration = (typeName) => {
|
|
92
|
+
let scope = context.getScope();
|
|
93
|
+
while (scope) {
|
|
94
|
+
const variable = scope.variables.find((variableNode) => variableNode.name === typeName);
|
|
95
|
+
if (variable) {
|
|
96
|
+
const definition = variable.defs.find((candidate) => candidate.node.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration ||
|
|
97
|
+
candidate.node.type === utils_1.AST_NODE_TYPES.TSInterfaceDeclaration);
|
|
98
|
+
if (definition) {
|
|
99
|
+
return definition.node;
|
|
100
|
+
}
|
|
101
|
+
// The name is bound, but only to something this file cannot see
|
|
102
|
+
// through: an import. Record that the chain crossed the module
|
|
103
|
+
// boundary so the caller distinguishes "proved no id" from "could
|
|
104
|
+
// not look". A name with no binding at all (a lib global such as
|
|
105
|
+
// `Readonly` or `Map`) is left alone: those are known not to be
|
|
106
|
+
// Identifiable-bearing declarations, and treating them as unknown
|
|
107
|
+
// would silence the rule almost everywhere.
|
|
108
|
+
if (variable.defs.some((candidate) => IMPORT_BINDING_NODE_TYPES.has(candidate.node.type))) {
|
|
109
|
+
leavesModule = true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
scope = scope.upper;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
};
|
|
116
|
+
// `Types.Team` from `import * as Types from '../Team'` names a type in
|
|
117
|
+
// another module just as `Team` from a named import does, but it carries a
|
|
118
|
+
// TSQualifiedName (in a type position) or a MemberExpression (in an
|
|
119
|
+
// interface heritage clause) that the walks below never resolve. The
|
|
120
|
+
// leftmost identifier is the namespace binding, so it answers the same
|
|
121
|
+
// question: did the chain leave this file?
|
|
122
|
+
// ESTree wraps `Types?.Team` in a ChainExpression, so the walk has to see
|
|
123
|
+
// through that wrapper as well or the optional spelling loses the amnesty
|
|
124
|
+
// the plain one gets.
|
|
125
|
+
const noteImportedNamespace = (root) => {
|
|
126
|
+
let leftmost = root;
|
|
127
|
+
while (leftmost.type === utils_1.AST_NODE_TYPES.TSQualifiedName ||
|
|
128
|
+
leftmost.type === utils_1.AST_NODE_TYPES.MemberExpression ||
|
|
129
|
+
leftmost.type === utils_1.AST_NODE_TYPES.ChainExpression) {
|
|
130
|
+
if (leftmost.type === utils_1.AST_NODE_TYPES.ChainExpression) {
|
|
131
|
+
leftmost = leftmost.expression;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
leftmost =
|
|
135
|
+
leftmost.type === utils_1.AST_NODE_TYPES.TSQualifiedName
|
|
136
|
+
? leftmost.left
|
|
137
|
+
: leftmost.object;
|
|
138
|
+
}
|
|
139
|
+
if (leftmost.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
140
|
+
isImportedBinding(leftmost.name)) {
|
|
141
|
+
leavesModule = true;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const isParenthesizedType = (node) => node?.type === 'TSParenthesizedType';
|
|
145
|
+
const isReadonlyTypeOperator = (node) => node?.type === utils_1.AST_NODE_TYPES.TSTypeOperator &&
|
|
146
|
+
node.operator === 'readonly';
|
|
147
|
+
const unwrapTransparentType = (typeNode) => {
|
|
148
|
+
let current = typeNode;
|
|
149
|
+
while (current) {
|
|
150
|
+
if (isParenthesizedType(current)) {
|
|
151
|
+
const parenthesized = current;
|
|
152
|
+
current = parenthesized.typeAnnotation ?? null;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (isReadonlyTypeOperator(current)) {
|
|
156
|
+
current = current.typeAnnotation ?? null;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
return current ?? null;
|
|
162
|
+
};
|
|
163
|
+
const findIdentifiable = (type, checkedTypes = new Set()) => {
|
|
164
|
+
const resolvedType = unwrapTransparentType(type);
|
|
165
|
+
if (!resolvedType) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
// A resolved name lands on the declaration itself, so both declaration
|
|
169
|
+
// kinds are walked here rather than at every call site.
|
|
170
|
+
if (resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration) {
|
|
171
|
+
return findIdentifiable(resolvedType.typeAnnotation, checkedTypes);
|
|
172
|
+
}
|
|
173
|
+
if (resolvedType.type === utils_1.AST_NODE_TYPES.TSInterfaceDeclaration) {
|
|
174
|
+
return (resolvedType.extends ?? []).some((heritage) => findIdentifiable(heritage, new Set(checkedTypes)));
|
|
175
|
+
}
|
|
176
|
+
const referencedName = resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
|
|
177
|
+
resolvedType.typeName.type === utils_1.AST_NODE_TYPES.Identifier
|
|
178
|
+
? resolvedType.typeName.name
|
|
179
|
+
: resolvedType.type === utils_1.AST_NODE_TYPES.TSInterfaceHeritage &&
|
|
180
|
+
resolvedType.expression.type === utils_1.AST_NODE_TYPES.Identifier
|
|
181
|
+
? resolvedType.expression.name
|
|
182
|
+
: null;
|
|
183
|
+
if (referencedName !== null) {
|
|
184
|
+
if (referencedName === 'Identifiable') {
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
if (TRANSPARENT_TYPE_NAMES.has(referencedName) &&
|
|
188
|
+
resolvedType.typeParameters?.params?.some((param) => findIdentifiable(param, checkedTypes))) {
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
if (checkedTypes.has(referencedName)) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
checkedTypes.add(referencedName);
|
|
195
|
+
return findIdentifiable(findLocalDeclaration(referencedName), checkedTypes);
|
|
196
|
+
}
|
|
197
|
+
// A heritage clause that is not a bare name is a namespace-qualified
|
|
198
|
+
// one (`Types.Team`, or its optional-chained spelling), which names a
|
|
199
|
+
// type this file cannot see through.
|
|
200
|
+
if (resolvedType.type === utils_1.AST_NODE_TYPES.TSInterfaceHeritage) {
|
|
201
|
+
noteImportedNamespace(resolvedType.expression);
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
if (resolvedType.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
|
|
205
|
+
return resolvedType.types.some((part) => findIdentifiable(part, new Set(checkedTypes)));
|
|
206
|
+
}
|
|
207
|
+
return false;
|
|
208
|
+
};
|
|
209
|
+
// Whether the type declares `id: string` itself. The
|
|
210
|
+
// notExtendingIdentifiable message offers this as an alternative to
|
|
211
|
+
// Identifiable, and it delivers exactly what the rule exists to
|
|
212
|
+
// guarantee — the document's ID field — so it satisfies the rule wherever
|
|
213
|
+
// it appears, not only under a `Resolve<>` wrapper.
|
|
214
|
+
const declaresIdField = (type, visitedTypes = new Set()) => {
|
|
215
|
+
const resolvedType = unwrapTransparentType(type);
|
|
216
|
+
if (!resolvedType) {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
if (resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeLiteral) {
|
|
220
|
+
return membersDeclareIdString(resolvedType.members);
|
|
221
|
+
}
|
|
222
|
+
if (resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration) {
|
|
223
|
+
return declaresIdField(resolvedType.typeAnnotation, visitedTypes);
|
|
224
|
+
}
|
|
225
|
+
if (resolvedType.type === utils_1.AST_NODE_TYPES.TSInterfaceDeclaration) {
|
|
226
|
+
return (membersDeclareIdString(resolvedType.body.body) ||
|
|
227
|
+
(resolvedType.extends ?? []).some((heritage) => declaresIdField(heritage, new Set(visitedTypes))));
|
|
228
|
+
}
|
|
229
|
+
if (resolvedType.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
|
|
230
|
+
return resolvedType.types.some((part) => declaresIdField(part, new Set(visitedTypes)));
|
|
231
|
+
}
|
|
232
|
+
const referencedName = resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
|
|
233
|
+
resolvedType.typeName.type === utils_1.AST_NODE_TYPES.Identifier
|
|
234
|
+
? resolvedType.typeName.name
|
|
235
|
+
: resolvedType.type === utils_1.AST_NODE_TYPES.TSInterfaceHeritage &&
|
|
236
|
+
resolvedType.expression.type === utils_1.AST_NODE_TYPES.Identifier
|
|
237
|
+
? resolvedType.expression.name
|
|
238
|
+
: null;
|
|
239
|
+
if (referencedName === null) {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
if (referencedName === 'Identifiable') {
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
if (TRANSPARENT_TYPE_NAMES.has(referencedName) &&
|
|
246
|
+
resolvedType.typeParameters?.params?.some((param) => declaresIdField(param, visitedTypes))) {
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
if (visitedTypes.has(referencedName)) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
visitedTypes.add(referencedName);
|
|
253
|
+
return declaresIdField(findLocalDeclaration(referencedName), visitedTypes);
|
|
254
|
+
};
|
|
255
|
+
// Recursively check the declaration, its alias chain and its parameters
|
|
256
|
+
const providesId = (type, visitedTypes = new Set()) => {
|
|
257
|
+
const resolvedType = unwrapTransparentType(type);
|
|
258
|
+
if (!resolvedType) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
if (findIdentifiable(resolvedType) || declaresIdField(resolvedType)) {
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
if (resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration) {
|
|
265
|
+
return providesId(resolvedType.typeAnnotation, visitedTypes);
|
|
266
|
+
}
|
|
267
|
+
if (resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
|
|
268
|
+
resolvedType.typeName.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
269
|
+
const typeName = resolvedType.typeName.name;
|
|
270
|
+
if (visitedTypes.has(typeName)) {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
visitedTypes.add(typeName);
|
|
274
|
+
return providesId(findLocalDeclaration(typeName), visitedTypes);
|
|
275
|
+
}
|
|
276
|
+
if (resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
|
|
277
|
+
resolvedType.typeName.type === utils_1.AST_NODE_TYPES.TSQualifiedName) {
|
|
278
|
+
noteImportedNamespace(resolvedType.typeName);
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
if (resolvedType.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
|
|
282
|
+
return resolvedType.types.some((part) => providesId(part, new Set(visitedTypes)));
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
};
|
|
286
|
+
matchingDeclarationNode = node;
|
|
287
|
+
matchingDeclarationInlineExported =
|
|
288
|
+
matchingDeclarationInlineExported ||
|
|
289
|
+
node.parent?.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration ||
|
|
290
|
+
node.parent?.type === utils_1.AST_NODE_TYPES.ExportDefaultDeclaration;
|
|
291
|
+
matchingDeclarationProvidesId =
|
|
292
|
+
matchingDeclarationProvidesId || providesId(node);
|
|
293
|
+
matchingDeclarationLeavesModule =
|
|
294
|
+
matchingDeclarationLeavesModule || leavesModule;
|
|
295
|
+
};
|
|
63
296
|
return {
|
|
64
297
|
Program() {
|
|
65
298
|
// Reset flags for each file
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
299
|
+
matchingDeclarationNode = null;
|
|
300
|
+
matchingDeclarationInlineExported = false;
|
|
301
|
+
matchingDeclarationProvidesId = false;
|
|
302
|
+
matchingDeclarationLeavesModule = false;
|
|
70
303
|
locallyExportedNames.clear();
|
|
71
304
|
},
|
|
72
305
|
'Program:exit'(node) {
|
|
73
|
-
const hasExpectedType =
|
|
74
|
-
(
|
|
306
|
+
const hasExpectedType = matchingDeclarationNode !== null &&
|
|
307
|
+
(matchingDeclarationInlineExported ||
|
|
308
|
+
locallyExportedNames.has(folderName));
|
|
75
309
|
if (!hasExpectedType) {
|
|
76
310
|
context.report({
|
|
77
311
|
node,
|
|
@@ -82,8 +316,8 @@ exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
|
|
|
82
316
|
},
|
|
83
317
|
});
|
|
84
318
|
}
|
|
85
|
-
else if (!
|
|
86
|
-
!
|
|
319
|
+
else if (!matchingDeclarationProvidesId &&
|
|
320
|
+
!matchingDeclarationLeavesModule) {
|
|
87
321
|
// A chain that leaves the module is unknowable here: the imported
|
|
88
322
|
// type may well intersect `Identifiable`, and reporting would demand
|
|
89
323
|
// a type-theoretically redundant `Identifiable &` to silence a claim
|
|
@@ -100,8 +334,8 @@ exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
|
|
|
100
334
|
},
|
|
101
335
|
ExportNamedDeclaration(node) {
|
|
102
336
|
// A re-export (`export { X } from './elsewhere'`) exports a binding
|
|
103
|
-
// from another module, not the local
|
|
104
|
-
//
|
|
337
|
+
// from another module, not the local declaration in this file, so it
|
|
338
|
+
// must never satisfy the gate.
|
|
105
339
|
if (node.source != null) {
|
|
106
340
|
return;
|
|
107
341
|
}
|
|
@@ -111,198 +345,12 @@ exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
|
|
|
111
345
|
},
|
|
112
346
|
TSTypeAliasDeclaration(node) {
|
|
113
347
|
if (node.id.name === folderName) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
// reaches — an unrelated `Timestamp`, or a type argument the walk
|
|
121
|
-
// never descends into — grants no amnesty.
|
|
122
|
-
let leavesModule = false;
|
|
123
|
-
const isImportedBinding = (name) => {
|
|
124
|
-
let scope = context.getScope();
|
|
125
|
-
while (scope) {
|
|
126
|
-
const variable = scope.variables.find((variableNode) => variableNode.name === name);
|
|
127
|
-
if (variable) {
|
|
128
|
-
return variable.defs.some((definition) => IMPORT_BINDING_NODE_TYPES.has(definition.node.type));
|
|
129
|
-
}
|
|
130
|
-
scope = scope.upper;
|
|
131
|
-
}
|
|
132
|
-
return false;
|
|
133
|
-
};
|
|
134
|
-
const findTypeAliasAnnotation = (typeName) => {
|
|
135
|
-
let scope = context.getScope();
|
|
136
|
-
while (scope) {
|
|
137
|
-
const variable = scope.variables.find((variableNode) => variableNode.name === typeName);
|
|
138
|
-
if (variable) {
|
|
139
|
-
const definition = variable.defs.find((definition) => definition.node.type ===
|
|
140
|
-
utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration);
|
|
141
|
-
if (definition &&
|
|
142
|
-
definition.node.type ===
|
|
143
|
-
utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration &&
|
|
144
|
-
definition.node.typeAnnotation) {
|
|
145
|
-
return definition.node.typeAnnotation;
|
|
146
|
-
}
|
|
147
|
-
// The name is bound, but only to something this file cannot
|
|
148
|
-
// see through: an import. Record that the chain crossed the
|
|
149
|
-
// module boundary so the caller distinguishes "proved no
|
|
150
|
-
// Identifiable" from "could not look". A name with no binding
|
|
151
|
-
// at all (a lib global such as `Readonly` or `Map`) is left
|
|
152
|
-
// alone: those are known not to be Identifiable-bearing
|
|
153
|
-
// aliases, and treating them as unknown would silence the
|
|
154
|
-
// rule almost everywhere.
|
|
155
|
-
if (variable.defs.some((definition) => IMPORT_BINDING_NODE_TYPES.has(definition.node.type))) {
|
|
156
|
-
leavesModule = true;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
scope = scope.upper;
|
|
160
|
-
}
|
|
161
|
-
return null;
|
|
162
|
-
};
|
|
163
|
-
// `Types.Team` from `import * as Types from '../Team'` names a type
|
|
164
|
-
// in another module just as `Team` from a named import does, but it
|
|
165
|
-
// carries a TSQualifiedName that the walks below never resolve. The
|
|
166
|
-
// leftmost identifier is the namespace binding, so it answers the
|
|
167
|
-
// same question: did the chain leave this file?
|
|
168
|
-
const noteQualifiedNamespaceImport = (qualifiedName) => {
|
|
169
|
-
let left = qualifiedName.left;
|
|
170
|
-
while (left.type === utils_1.AST_NODE_TYPES.TSQualifiedName) {
|
|
171
|
-
left = left.left;
|
|
172
|
-
}
|
|
173
|
-
if (left.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
174
|
-
isImportedBinding(left.name)) {
|
|
175
|
-
leavesModule = true;
|
|
176
|
-
}
|
|
177
|
-
};
|
|
178
|
-
const isParenthesizedType = (node) => node?.type === 'TSParenthesizedType';
|
|
179
|
-
const isReadonlyTypeOperator = (node) => node?.type === utils_1.AST_NODE_TYPES.TSTypeOperator &&
|
|
180
|
-
node.operator === 'readonly';
|
|
181
|
-
const unwrapTransparentType = (typeNode) => {
|
|
182
|
-
let current = typeNode;
|
|
183
|
-
while (current) {
|
|
184
|
-
if (isParenthesizedType(current)) {
|
|
185
|
-
const parenthesized = current;
|
|
186
|
-
current = parenthesized.typeAnnotation ?? null;
|
|
187
|
-
continue;
|
|
188
|
-
}
|
|
189
|
-
if (isReadonlyTypeOperator(current)) {
|
|
190
|
-
current = current.typeAnnotation ?? null;
|
|
191
|
-
continue;
|
|
192
|
-
}
|
|
193
|
-
break;
|
|
194
|
-
}
|
|
195
|
-
return current ?? null;
|
|
196
|
-
};
|
|
197
|
-
const findIdentifiable = (type, checkedTypes = new Set()) => {
|
|
198
|
-
const resolvedType = unwrapTransparentType(type);
|
|
199
|
-
if (!resolvedType) {
|
|
200
|
-
return false;
|
|
201
|
-
}
|
|
202
|
-
if (resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
|
|
203
|
-
resolvedType.typeName.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
204
|
-
const typeName = resolvedType.typeName.name;
|
|
205
|
-
if (typeName === 'Identifiable') {
|
|
206
|
-
return true;
|
|
207
|
-
}
|
|
208
|
-
if (TRANSPARENT_TYPE_NAMES.has(typeName) &&
|
|
209
|
-
resolvedType.typeParameters?.params?.some((param) => findIdentifiable(param, checkedTypes))) {
|
|
210
|
-
return true;
|
|
211
|
-
}
|
|
212
|
-
if (checkedTypes.has(typeName)) {
|
|
213
|
-
return false;
|
|
214
|
-
}
|
|
215
|
-
checkedTypes.add(typeName);
|
|
216
|
-
const aliasAnnotation = findTypeAliasAnnotation(typeName);
|
|
217
|
-
return findIdentifiable(aliasAnnotation, checkedTypes);
|
|
218
|
-
}
|
|
219
|
-
if (resolvedType.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
|
|
220
|
-
return resolvedType.types.some((part) => findIdentifiable(part, new Set(checkedTypes)));
|
|
221
|
-
}
|
|
222
|
-
return false;
|
|
223
|
-
};
|
|
224
|
-
// Check if type has id: string field
|
|
225
|
-
const checkIdField = (type, visitedTypes = new Set()) => {
|
|
226
|
-
const resolvedType = unwrapTransparentType(type);
|
|
227
|
-
if (!resolvedType) {
|
|
228
|
-
return false;
|
|
229
|
-
}
|
|
230
|
-
if (resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeLiteral) {
|
|
231
|
-
return resolvedType.members.some((member) => member.type === utils_1.AST_NODE_TYPES.TSPropertySignature &&
|
|
232
|
-
member.key.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
233
|
-
member.key.name === 'id' &&
|
|
234
|
-
member.typeAnnotation?.typeAnnotation.type ===
|
|
235
|
-
utils_1.AST_NODE_TYPES.TSStringKeyword);
|
|
236
|
-
}
|
|
237
|
-
if (resolvedType.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
|
|
238
|
-
return resolvedType.types.some((part) => checkIdField(part, new Set(visitedTypes)));
|
|
239
|
-
}
|
|
240
|
-
if (resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeReference) {
|
|
241
|
-
if (resolvedType.typeName.type !== utils_1.AST_NODE_TYPES.Identifier) {
|
|
242
|
-
return false;
|
|
243
|
-
}
|
|
244
|
-
const typeName = resolvedType.typeName.name;
|
|
245
|
-
if (typeName === 'Identifiable') {
|
|
246
|
-
return true;
|
|
247
|
-
}
|
|
248
|
-
if (TRANSPARENT_TYPE_NAMES.has(typeName) &&
|
|
249
|
-
resolvedType.typeParameters?.params?.some((param) => checkIdField(param, visitedTypes))) {
|
|
250
|
-
return true;
|
|
251
|
-
}
|
|
252
|
-
if (visitedTypes.has(typeName)) {
|
|
253
|
-
return false;
|
|
254
|
-
}
|
|
255
|
-
visitedTypes.add(typeName);
|
|
256
|
-
const referencedType = findTypeAliasAnnotation(typeName);
|
|
257
|
-
return checkIdField(referencedType, visitedTypes);
|
|
258
|
-
}
|
|
259
|
-
return false;
|
|
260
|
-
};
|
|
261
|
-
// Check if type is wrapped in a utility type
|
|
262
|
-
const isUtilityType = (type) => {
|
|
263
|
-
if (!type) {
|
|
264
|
-
return false;
|
|
265
|
-
}
|
|
266
|
-
return (type.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
|
|
267
|
-
type.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
268
|
-
type.typeName.name === 'Resolve');
|
|
269
|
-
};
|
|
270
|
-
// Recursively check the type and its parameters
|
|
271
|
-
const checkType = (type, visitedTypes = new Set()) => {
|
|
272
|
-
const resolvedType = unwrapTransparentType(type);
|
|
273
|
-
if (!resolvedType) {
|
|
274
|
-
return false;
|
|
275
|
-
}
|
|
276
|
-
if (findIdentifiable(resolvedType)) {
|
|
277
|
-
return true;
|
|
278
|
-
}
|
|
279
|
-
if (isUtilityType(resolvedType) &&
|
|
280
|
-
resolvedType.typeParameters?.params?.[0] &&
|
|
281
|
-
checkIdField(resolvedType.typeParameters.params[0])) {
|
|
282
|
-
return true;
|
|
283
|
-
}
|
|
284
|
-
if (resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
|
|
285
|
-
resolvedType.typeName.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
286
|
-
const typeName = resolvedType.typeName.name;
|
|
287
|
-
if (visitedTypes.has(typeName)) {
|
|
288
|
-
return false;
|
|
289
|
-
}
|
|
290
|
-
visitedTypes.add(typeName);
|
|
291
|
-
const aliasAnnotation = findTypeAliasAnnotation(typeName);
|
|
292
|
-
return checkType(aliasAnnotation, visitedTypes);
|
|
293
|
-
}
|
|
294
|
-
if (resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
|
|
295
|
-
resolvedType.typeName.type === utils_1.AST_NODE_TYPES.TSQualifiedName) {
|
|
296
|
-
noteQualifiedNamespaceImport(resolvedType.typeName);
|
|
297
|
-
return false;
|
|
298
|
-
}
|
|
299
|
-
if (resolvedType.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
|
|
300
|
-
return resolvedType.types.some((part) => checkType(part, new Set(visitedTypes)));
|
|
301
|
-
}
|
|
302
|
-
return false;
|
|
303
|
-
};
|
|
304
|
-
matchingAliasHasIdentifiable = checkType(node.typeAnnotation);
|
|
305
|
-
matchingAliasLeavesModule = leavesModule;
|
|
348
|
+
analyzeMatchingDeclaration(node);
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
TSInterfaceDeclaration(node) {
|
|
352
|
+
if (node.id.name === folderName) {
|
|
353
|
+
analyzeMatchingDeclaration(node);
|
|
306
354
|
}
|
|
307
355
|
},
|
|
308
356
|
};
|
|
@@ -822,12 +822,55 @@ function isPropertyComplex(prop, checker, tsNode, ts, treatAnyAsComplex, parentT
|
|
|
822
822
|
}
|
|
823
823
|
return shouldTreatAnyAsComplex(prop, propType, ts, treatAnyAsComplex, parentTypeFlags, checker);
|
|
824
824
|
}
|
|
825
|
+
/**
|
|
826
|
+
* Returns true when `declaration` was written by a dependency rather than by the
|
|
827
|
+
* component's author: a declaration file, or any file under `node_modules`.
|
|
828
|
+
* Keys on the same `getSourceFile().fileName` inspection the React and DOM
|
|
829
|
+
* origin gates use.
|
|
830
|
+
*/
|
|
831
|
+
function isExternalDeclaration(declaration) {
|
|
832
|
+
const sourceFile = declaration.getSourceFile?.();
|
|
833
|
+
if (!sourceFile)
|
|
834
|
+
return false;
|
|
835
|
+
const fileName = sourceFile.fileName ?? '';
|
|
836
|
+
return (sourceFile.isDeclarationFile === true ||
|
|
837
|
+
/\.d\.[cm]?ts$/.test(fileName) ||
|
|
838
|
+
/[/\\]node_modules[/\\]/.test(fileName));
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Returns true when every declaration site of `prop` lives in a dependency.
|
|
842
|
+
*
|
|
843
|
+
* `checker.getPropertiesOfType` returns INHERITED members, so a props type that
|
|
844
|
+
* extends or intersects a library interface (MUI's `TypographyProps`, React's
|
|
845
|
+
* `HTMLAttributes`, …) surfaces that library's entire surface. Demanding the
|
|
846
|
+
* author name those in `compareDeeply` prescribes a remedy nobody can act on —
|
|
847
|
+
* the component neither declares nor receives them, and the lists run past a
|
|
848
|
+
* hundred names — so the only available exit is a blanket rule disable.
|
|
849
|
+
*
|
|
850
|
+
* The gate is `every` rather than "first declaration", so a prop the author
|
|
851
|
+
* redeclares alongside the library's (the intersection of two same-named
|
|
852
|
+
* members yields one symbol carrying BOTH declarations) still counts as
|
|
853
|
+
* authored and is still reported.
|
|
854
|
+
*
|
|
855
|
+
* A prop with no declarations at all is treated as authored. Synthesized
|
|
856
|
+
* symbols reach here from mapped and generic types over the component's own
|
|
857
|
+
* props, and reporting them preserves the rule's core behaviour; a missing
|
|
858
|
+
* declaration is not evidence of a dependency.
|
|
859
|
+
*/
|
|
860
|
+
function isExternallyDeclaredProperty(prop) {
|
|
861
|
+
const declarations = prop.declarations;
|
|
862
|
+
if (!declarations || declarations.length === 0)
|
|
863
|
+
return false;
|
|
864
|
+
return declarations.every(isExternalDeclaration);
|
|
865
|
+
}
|
|
825
866
|
function getComplexPropertiesFromType(type, checker, tsNode, ts, treatAnyAsComplex = false, parentTypeFlags = 0) {
|
|
826
867
|
const properties = checker.getPropertiesOfType(type);
|
|
827
868
|
const complexProps = [];
|
|
828
869
|
for (const prop of properties) {
|
|
829
870
|
if (isReservedReactPropName(prop.name))
|
|
830
871
|
continue;
|
|
872
|
+
if (isExternallyDeclaredProperty(prop))
|
|
873
|
+
continue;
|
|
831
874
|
if (isPropertyComplex(prop, checker, tsNode, ts, treatAnyAsComplex, parentTypeFlags)) {
|
|
832
875
|
complexProps.push(prop.name);
|
|
833
876
|
}
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,32 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.166",
|
|
4
|
+
"date": "2026-08-18T03:18:38.188Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "memo-compare-deeply-complex-props",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
2037
|
|
11
|
+
],
|
|
12
|
+
"summary": "exempt props declared by a dependency (closes #2037)"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "1.20.165",
|
|
18
|
+
"date": "2026-08-18T00:07:35.027Z",
|
|
19
|
+
"rules": [
|
|
20
|
+
{
|
|
21
|
+
"name": "enforce-identifiable-firestore-type",
|
|
22
|
+
"changeType": "fix",
|
|
23
|
+
"issues": [
|
|
24
|
+
2035
|
|
25
|
+
],
|
|
26
|
+
"summary": "accept every remedy its message prescribes (closes #2035)"
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
},
|
|
2
30
|
{
|
|
3
31
|
"version": "1.20.164",
|
|
4
32
|
"date": "2026-08-17T22:26:37.717Z",
|