@blumintinc/eslint-plugin-blumint 1.20.166 → 1.20.168
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
|
@@ -838,7 +838,86 @@ function isExternalDeclaration(declaration) {
|
|
|
838
838
|
/[/\\]node_modules[/\\]/.test(fileName));
|
|
839
839
|
}
|
|
840
840
|
/**
|
|
841
|
-
*
|
|
841
|
+
* Classifies `type` itself by the declaration sites of the alias it was written
|
|
842
|
+
* as (`SystemProps`, `Readonly`) or, absent an alias, of its own symbol — an
|
|
843
|
+
* interface declaration, an object type literal, or the `MappedTypeNode` a
|
|
844
|
+
* mapped type is synthesized from.
|
|
845
|
+
*/
|
|
846
|
+
function classifyTypeOrigin(type) {
|
|
847
|
+
const symbol = type.aliasSymbol ??
|
|
848
|
+
type.symbol;
|
|
849
|
+
const declarations = symbol?.declarations;
|
|
850
|
+
if (!declarations || declarations.length === 0)
|
|
851
|
+
return 'unknown';
|
|
852
|
+
return declarations.every(isExternalDeclaration) ? 'external' : 'authored';
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* The sub-types of `type` that could have contributed `propName`: intersection
|
|
856
|
+
* constituents, union members, and the arguments a generic alias was
|
|
857
|
+
* instantiated with. The alias arm is what sees through a dependency's wrapper
|
|
858
|
+
* — `Readonly<X>`/`Omit<X, K>` re-synthesize members and present themselves as
|
|
859
|
+
* `lib.es5.d.ts`, while the surface they wrap may belong to either side.
|
|
860
|
+
*/
|
|
861
|
+
function propertyCarriersOf(type, propName, checker, ts) {
|
|
862
|
+
const candidates = [];
|
|
863
|
+
if (type.flags & (ts.TypeFlags.Union | ts.TypeFlags.Intersection)) {
|
|
864
|
+
candidates.push(...type.types);
|
|
865
|
+
}
|
|
866
|
+
const aliasTypeArguments = type
|
|
867
|
+
.aliasTypeArguments;
|
|
868
|
+
if (aliasTypeArguments)
|
|
869
|
+
candidates.push(...aliasTypeArguments);
|
|
870
|
+
return candidates.filter((candidate) => {
|
|
871
|
+
if (candidate === type)
|
|
872
|
+
return false;
|
|
873
|
+
try {
|
|
874
|
+
return Boolean(checker.getPropertyOfType?.(candidate, propName));
|
|
875
|
+
}
|
|
876
|
+
catch {
|
|
877
|
+
return false;
|
|
878
|
+
}
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
// Guards the descent against a self-referential alias; real props types nest a
|
|
882
|
+
// handful of wrappers at most.
|
|
883
|
+
const MAX_ORIGIN_SEARCH_DEPTH = 8;
|
|
884
|
+
/**
|
|
885
|
+
* Answers who wrote `propName` by finding the constituent of `type` that
|
|
886
|
+
* actually carries it, rather than by asking the outermost wrapper.
|
|
887
|
+
*
|
|
888
|
+
* Ownership is decided by the innermost carrier, so a dependency's wrapper over
|
|
889
|
+
* the author's type stays authored (`Readonly<{ [K in Keys]: … }>`) and the
|
|
890
|
+
* author's wrapper over a dependency's type stays external.
|
|
891
|
+
*/
|
|
892
|
+
function classifyPropertyOrigin(propName, type, checker, ts, depth = 0) {
|
|
893
|
+
const carriers = depth < MAX_ORIGIN_SEARCH_DEPTH
|
|
894
|
+
? propertyCarriersOf(type, propName, checker, ts)
|
|
895
|
+
: [];
|
|
896
|
+
if (carriers.length > 0) {
|
|
897
|
+
const origins = carriers.map((carrier) => classifyPropertyOrigin(propName, carrier, checker, ts, depth + 1));
|
|
898
|
+
// One authored carrier is enough: the author declared the prop somewhere in
|
|
899
|
+
// the composition, so the remedy is theirs to apply.
|
|
900
|
+
if (origins.some((origin) => origin === 'authored'))
|
|
901
|
+
return 'authored';
|
|
902
|
+
return origins.every((origin) => origin === 'external')
|
|
903
|
+
? 'external'
|
|
904
|
+
: 'unknown';
|
|
905
|
+
}
|
|
906
|
+
const ownOrigin = classifyTypeOrigin(type);
|
|
907
|
+
if (ownOrigin !== 'external')
|
|
908
|
+
return ownOrigin;
|
|
909
|
+
// No carrier holds the prop, yet the type is a dependency's: a mapping
|
|
910
|
+
// construct such as `Record<OwnKeys, T>` synthesizes members from the
|
|
911
|
+
// author's inputs while presenting `lib.es5.d.ts` as its own declaration
|
|
912
|
+
// site. The library supplied the machinery, the author supplied the keys.
|
|
913
|
+
const aliasTypeArguments = type
|
|
914
|
+
.aliasTypeArguments;
|
|
915
|
+
const hasAuthoredInput = aliasTypeArguments?.some((argument) => classifyTypeOrigin(argument) === 'authored');
|
|
916
|
+
return hasAuthoredInput ? 'authored' : 'external';
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* Returns true when `prop` belongs to a dependency rather than to the component
|
|
920
|
+
* under lint.
|
|
842
921
|
*
|
|
843
922
|
* `checker.getPropertiesOfType` returns INHERITED members, so a props type that
|
|
844
923
|
* extends or intersects a library interface (MUI's `TypographyProps`, React's
|
|
@@ -847,21 +926,27 @@ function isExternalDeclaration(declaration) {
|
|
|
847
926
|
* the component neither declares nor receives them, and the lists run past a
|
|
848
927
|
* hundred names — so the only available exit is a blanket rule disable.
|
|
849
928
|
*
|
|
850
|
-
* The gate is `every` rather than "first declaration", so a prop the
|
|
851
|
-
* redeclares alongside the library's (the intersection of two same-named
|
|
852
|
-
* members yields one symbol carrying BOTH declarations) still counts as
|
|
853
|
-
*
|
|
929
|
+
* The declaration gate is `every` rather than "first declaration", so a prop the
|
|
930
|
+
* author redeclares alongside the library's (the intersection of two same-named
|
|
931
|
+
* members yields one symbol carrying BOTH declarations) still counts as authored
|
|
932
|
+
* and is still reported.
|
|
854
933
|
*
|
|
855
|
-
* A prop with
|
|
856
|
-
*
|
|
857
|
-
*
|
|
858
|
-
*
|
|
934
|
+
* A prop with NO declaration site cannot be answered that way at all, and a
|
|
935
|
+
* missing declaration is evidence of neither side. TypeScript propagates
|
|
936
|
+
* declarations only through HOMOMORPHIC mapped types (`Readonly<T>`,
|
|
937
|
+
* `Pick<T, K>`); a keyed mapped type (`{ [K in SystemKeys]?: … }` — the shape
|
|
938
|
+
* MUI's `SystemProps<Theme>` gives every `Box`-derived component) synthesizes
|
|
939
|
+
* fresh symbols with none, whoever wrote it. Such a prop is classified by the
|
|
940
|
+
* ORIGIN of the type that carries it, which keeps a library's ~100 style
|
|
941
|
+
* shorthands out of the report while the author's own mapped types stay in it.
|
|
859
942
|
*/
|
|
860
|
-
function
|
|
943
|
+
function isDependencyOwnedProperty(prop, containingType, checker, ts) {
|
|
861
944
|
const declarations = prop.declarations;
|
|
862
|
-
if (
|
|
863
|
-
return
|
|
864
|
-
|
|
945
|
+
if (declarations && declarations.length > 0) {
|
|
946
|
+
return declarations.every(isExternalDeclaration);
|
|
947
|
+
}
|
|
948
|
+
return (classifyPropertyOrigin(prop.name, containingType, checker, ts) ===
|
|
949
|
+
'external');
|
|
865
950
|
}
|
|
866
951
|
function getComplexPropertiesFromType(type, checker, tsNode, ts, treatAnyAsComplex = false, parentTypeFlags = 0) {
|
|
867
952
|
const properties = checker.getPropertiesOfType(type);
|
|
@@ -869,7 +954,7 @@ function getComplexPropertiesFromType(type, checker, tsNode, ts, treatAnyAsCompl
|
|
|
869
954
|
for (const prop of properties) {
|
|
870
955
|
if (isReservedReactPropName(prop.name))
|
|
871
956
|
continue;
|
|
872
|
-
if (
|
|
957
|
+
if (isDependencyOwnedProperty(prop, type, checker, ts))
|
|
873
958
|
continue;
|
|
874
959
|
if (isPropertyComplex(prop, checker, tsNode, ts, treatAnyAsComplex, parentTypeFlags)) {
|
|
875
960
|
complexProps.push(prop.name);
|
|
@@ -69,6 +69,122 @@ function isPossiblyNullish(type, checker) {
|
|
|
69
69
|
ts.TypeFlags.Unknown)) !==
|
|
70
70
|
0);
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* The union members of a type, following a type parameter to its constraint so a
|
|
74
|
+
* generic operand is read through the same lens as a written-out union.
|
|
75
|
+
*/
|
|
76
|
+
function unionMembers(type, checker) {
|
|
77
|
+
if (type.isUnion()) {
|
|
78
|
+
return type.types.flatMap((member) => unionMembers(member, checker));
|
|
79
|
+
}
|
|
80
|
+
if (type.getFlags() & ts.TypeFlags.TypeParameter) {
|
|
81
|
+
if (checker) {
|
|
82
|
+
const constraint = checker.getBaseConstraintOfType(type);
|
|
83
|
+
if (constraint && constraint !== type) {
|
|
84
|
+
return unionMembers(constraint, checker);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return [type];
|
|
88
|
+
}
|
|
89
|
+
return [type];
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* A member every value of which is falsy while none of them is nullish: the part
|
|
93
|
+
* of a union that `||` discards and `??` keeps.
|
|
94
|
+
*/
|
|
95
|
+
function isNonNullishFalsy(type) {
|
|
96
|
+
const flags = type.getFlags();
|
|
97
|
+
if (flags & ts.TypeFlags.BooleanLiteral) {
|
|
98
|
+
return (type.intrinsicName === 'false');
|
|
99
|
+
}
|
|
100
|
+
if (flags & (ts.TypeFlags.NumberLiteral | ts.TypeFlags.StringLiteral)) {
|
|
101
|
+
const { value } = type;
|
|
102
|
+
// `-0` compares equal to `0`, so both numeric zeroes are covered.
|
|
103
|
+
return value === 0 || value === '';
|
|
104
|
+
}
|
|
105
|
+
if (flags & ts.TypeFlags.BigIntLiteral) {
|
|
106
|
+
const { value } = type;
|
|
107
|
+
return typeof value === 'object' && value.base10Value === '0';
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
function isNullishMember(type) {
|
|
112
|
+
return ((type.getFlags() &
|
|
113
|
+
(ts.TypeFlags.Null |
|
|
114
|
+
ts.TypeFlags.Undefined |
|
|
115
|
+
ts.TypeFlags.Void |
|
|
116
|
+
ts.TypeFlags.Any |
|
|
117
|
+
ts.TypeFlags.Unknown)) !==
|
|
118
|
+
0);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The primitive domain a union member belongs to. Members of one domain form the
|
|
122
|
+
* unions the rule exists for (`string | undefined`, `boolean | undefined`,
|
|
123
|
+
* `0 | 1 | undefined`), where a falsy value is a value of the same kind as the
|
|
124
|
+
* fallback and preserving it is the point.
|
|
125
|
+
*/
|
|
126
|
+
function domainOf(type) {
|
|
127
|
+
const flags = type.getFlags();
|
|
128
|
+
if (flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral)) {
|
|
129
|
+
return 'boolean';
|
|
130
|
+
}
|
|
131
|
+
if (flags & (ts.TypeFlags.Number | ts.TypeFlags.NumberLiteral)) {
|
|
132
|
+
return 'number';
|
|
133
|
+
}
|
|
134
|
+
if (flags & (ts.TypeFlags.String | ts.TypeFlags.StringLiteral)) {
|
|
135
|
+
return 'string';
|
|
136
|
+
}
|
|
137
|
+
if (flags & (ts.TypeFlags.BigInt | ts.TypeFlags.BigIntLiteral)) {
|
|
138
|
+
return 'bigint';
|
|
139
|
+
}
|
|
140
|
+
return 'other';
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Whether the `||` is load-bearing because its left operand carries a falsy
|
|
144
|
+
* sentinel from outside the payload's domain.
|
|
145
|
+
*
|
|
146
|
+
* `cond && payload` evaluates to the falsy `cond` itself when it short-circuits,
|
|
147
|
+
* so the operand's type is `false | payload` (or `0 | payload`, `'' | payload`).
|
|
148
|
+
* The trailing `||` exists to strip that sentinel; `??` strips only `null` and
|
|
149
|
+
* `undefined`, so the rewrite leaks a `false` into a position typed for the
|
|
150
|
+
* payload alone and the program stops compiling. The same union written out by
|
|
151
|
+
* hand behaves identically, so the test is a property of the type rather than of
|
|
152
|
+
* the `&&` that usually produces it.
|
|
153
|
+
*
|
|
154
|
+
* A union confined to a single domain is left alone: there the falsy member is a
|
|
155
|
+
* value of the payload's own kind, which is exactly the state the rule asks
|
|
156
|
+
* callers to preserve.
|
|
157
|
+
*
|
|
158
|
+
* Positive evidence from the checker is required. Without type information the
|
|
159
|
+
* answer is unknowable, and guessing would silence the rule across every
|
|
160
|
+
* untyped operand.
|
|
161
|
+
*/
|
|
162
|
+
function stripsForeignFalsyMember(node, checker, parserServices) {
|
|
163
|
+
if (!checker || !parserServices) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
let members;
|
|
167
|
+
try {
|
|
168
|
+
const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node);
|
|
169
|
+
const type = checker.getTypeAtLocation(tsNode);
|
|
170
|
+
if (!type) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
members = unionMembers(type, checker);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// esTreeNodeToTSNodeMap may fail for synthetic nodes and getTypeAtLocation
|
|
177
|
+
// may throw for nodes without type information; both mean no evidence.
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
const sentinelDomains = new Set(members.filter(isNonNullishFalsy).map(domainOf));
|
|
181
|
+
if (sentinelDomains.size === 0) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
return members.some((member) => !isNullishMember(member) &&
|
|
185
|
+
!isNonNullishFalsy(member) &&
|
|
186
|
+
!sentinelDomains.has(domainOf(member)));
|
|
187
|
+
}
|
|
72
188
|
function isInJSXBooleanAttribute(node) {
|
|
73
189
|
const parent = node.parent;
|
|
74
190
|
if (parent?.type !== utils_1.AST_NODE_TYPES.JSXAttribute)
|
|
@@ -634,6 +750,11 @@ exports.preferNullishCoalescingBooleanProps = (0, createRule_1.createRule)({
|
|
|
634
750
|
// Check if this could benefit from nullish coalescing
|
|
635
751
|
// We only suggest nullish coalescing when the left operand could be nullish
|
|
636
752
|
if (couldBeNullish(node.left, checker, parserServices)) {
|
|
753
|
+
// A `||` that strips a falsy sentinel from outside its payload's
|
|
754
|
+
// domain cannot become `??` without changing the expression's type.
|
|
755
|
+
if (stripsForeignFalsyMember(node.left, checker, parserServices)) {
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
637
758
|
const sourceCode = context.getSourceCode();
|
|
638
759
|
const leftText = sourceCode.getText(node.left);
|
|
639
760
|
const rightText = sourceCode.getText(node.right);
|
|
@@ -119,8 +119,8 @@ function getTypeReferenceName(node) {
|
|
|
119
119
|
/**
|
|
120
120
|
* Recursively check if a TS type node composes with the given propsTypeName
|
|
121
121
|
* via Pick/Omit (at any level of intersection / Readonly wrapping, union arm,
|
|
122
|
-
* named-alias indirection, or nested in a TSTypeLiteral
|
|
123
|
-
* annotation).
|
|
122
|
+
* array or tuple element, named-alias indirection, or nested in a TSTypeLiteral
|
|
123
|
+
* property's type annotation).
|
|
124
124
|
*
|
|
125
125
|
* `scope` (when supplied) is the node the alias lookup walks outward from, which
|
|
126
126
|
* enables resolving a locally-declared named type alias to its definition, so
|
|
@@ -185,6 +185,46 @@ function typeNodeComposesWithProps(typeNode, propsTypeName, scope, seenAliases =
|
|
|
185
185
|
// case, where every arm composes with the single shared child.
|
|
186
186
|
return typeNode.types.some((t) => typeNodeComposesWithProps(t, propsTypeName, scope, seenAliases));
|
|
187
187
|
}
|
|
188
|
+
case utils_1.AST_NODE_TYPES.TSArrayType: {
|
|
189
|
+
// A list renderer's composition lives in the ELEMENT type: a parent
|
|
190
|
+
// declaring `buttons: readonly ActionButtonProps[]` and spreading one
|
|
191
|
+
// element onto each child forwards the child's ENTIRE prop object, which
|
|
192
|
+
// is the same DRY guarantee a direct `Pick`/`Omit` prop gives — the
|
|
193
|
+
// composition is simply one level of indirection away, inside the array.
|
|
194
|
+
// The child's contract belongs to the element type there, so demanding a
|
|
195
|
+
// whole-props Pick/Omit on the parent would name a composition a list
|
|
196
|
+
// renderer must not have (issue #2038). This is the array analogue of the
|
|
197
|
+
// union-arm unwrapping issue #1343 established.
|
|
198
|
+
return typeNodeComposesWithProps(typeNode.elementType, propsTypeName, scope, seenAliases);
|
|
199
|
+
}
|
|
200
|
+
case utils_1.AST_NODE_TYPES.TSTupleType: {
|
|
201
|
+
// A tuple is a fixed-length list, and each slot is handed to a child
|
|
202
|
+
// exactly as an array element is, so any slot that composes composes.
|
|
203
|
+
return typeNode.elementTypes.some((element) => typeNodeComposesWithProps(element, propsTypeName, scope, seenAliases));
|
|
204
|
+
}
|
|
205
|
+
case utils_1.AST_NODE_TYPES.TSNamedTupleMember: {
|
|
206
|
+
// A tuple slot's label names the slot, never its surface.
|
|
207
|
+
return typeNodeComposesWithProps(typeNode.elementType, propsTypeName, scope, seenAliases);
|
|
208
|
+
}
|
|
209
|
+
case utils_1.AST_NODE_TYPES.TSOptionalType:
|
|
210
|
+
case utils_1.AST_NODE_TYPES.TSRestType: {
|
|
211
|
+
// `?` and `...` govern how many slots a tuple carries, not what a slot
|
|
212
|
+
// carries, so the decorated type is the surface to test.
|
|
213
|
+
return typeNodeComposesWithProps(typeNode.typeAnnotation, propsTypeName, scope, seenAliases);
|
|
214
|
+
}
|
|
215
|
+
case utils_1.AST_NODE_TYPES.TSTypeOperator: {
|
|
216
|
+
// `readonly T[]` describes the same prop surface as `T[]`: the modifier
|
|
217
|
+
// constrains mutation of the container, never the shape of its elements.
|
|
218
|
+
//
|
|
219
|
+
// Only `readonly` unwraps. `keyof ChildProps` is the child's KEY union — a
|
|
220
|
+
// set of strings that hands the child nothing — and `unique symbol` is a
|
|
221
|
+
// nominal token, so crediting either would let a parent that merely names
|
|
222
|
+
// the child's keys pass as composing with its props.
|
|
223
|
+
if (typeNode.operator !== 'readonly' || !typeNode.typeAnnotation) {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
return typeNodeComposesWithProps(typeNode.typeAnnotation, propsTypeName, scope, seenAliases);
|
|
227
|
+
}
|
|
188
228
|
case utils_1.AST_NODE_TYPES.TSTypeLiteral: {
|
|
189
229
|
// Check property signatures for nested composition
|
|
190
230
|
// e.g. { iconProps?: Omit<GradientIconButtonProps, 'IconComponent'> }
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,40 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.168",
|
|
4
|
+
"date": "2026-08-18T08:47:17.522Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "memo-compare-deeply-complex-props",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
2039
|
|
11
|
+
],
|
|
12
|
+
"summary": "classify zero-declaration props by their carrier type (closes #2039)"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "prefer-nullish-coalescing-boolean-props",
|
|
16
|
+
"changeType": "fix",
|
|
17
|
+
"issues": [
|
|
18
|
+
2040
|
|
19
|
+
],
|
|
20
|
+
"summary": "keep || where it strips a short-circuit sentinel (closes #2040)"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"version": "1.20.167",
|
|
26
|
+
"date": "2026-08-18T04:28:02.499Z",
|
|
27
|
+
"rules": [
|
|
28
|
+
{
|
|
29
|
+
"name": "require-props-composition",
|
|
30
|
+
"changeType": "fix",
|
|
31
|
+
"issues": [
|
|
32
|
+
2038
|
|
33
|
+
],
|
|
34
|
+
"summary": "credit composition through an array element (closes #2038)"
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
},
|
|
2
38
|
{
|
|
3
39
|
"version": "1.20.166",
|
|
4
40
|
"date": "2026-08-18T03:18:38.188Z",
|