@blumintinc/eslint-plugin-blumint 1.21.0 → 1.21.2
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-use-flex-gap-on-wrap.js +76 -41
- package/lib/rules/no-hungarian.js +105 -2
- package/lib/rules/prefer-spread-over-reassembly.d.ts +2 -1
- package/lib/rules/prefer-spread-over-reassembly.js +274 -55
- package/lib/rules/prefer-sx-prop-over-system-props.js +39 -18
- package/package.json +1 -1
- package/release-manifest.json +45 -0
package/README.md
CHANGED
|
@@ -241,7 +241,7 @@ full closed loop is documented in agora's `.claude/skills/eslint-autonomy/SKILL.
|
|
|
241
241
|
| [prefer-nullish-coalescing-boolean-props](docs/rules/prefer-nullish-coalescing-boolean-props.md) | Prefer nullish coalescing over logical OR, but allow logical OR in boolean contexts | ✅ | | 🔧 | | |
|
|
242
242
|
| [prefer-params-over-parent-id](docs/rules/prefer-params-over-parent-id.md) | Prefer event.params over ref.parent.id for type-safe Firebase trigger paths. | ✅ | | 🔧 | | |
|
|
243
243
|
| [prefer-settings-object](docs/rules/prefer-settings-object.md) | Enforce using a settings object for functions with multiple parameters | ✅ | | | | |
|
|
244
|
-
| [prefer-spread-over-reassembly](docs/rules/prefer-spread-over-reassembly.md) | Prefer spread syntax over destructure-then-reassemble when all destructured fields are forwarded identically to a single target | ✅ | | 🔧 |
|
|
244
|
+
| [prefer-spread-over-reassembly](docs/rules/prefer-spread-over-reassembly.md) | Prefer spread syntax over destructure-then-reassemble when all destructured fields are forwarded identically to a single target | ✅ | | 🔧 | 💡 | |
|
|
245
245
|
| [prefer-sx-prop-over-system-props](docs/rules/prefer-sx-prop-over-system-props.md) | Enforce using the MUI `sx` prop instead of deprecated system props (e.g. `mt`, `display`, `flexDirection`) on MUI components. | ✅ | | 🔧 | | |
|
|
246
246
|
| [prefer-type-alias-over-typeof-constant](docs/rules/prefer-type-alias-over-typeof-constant.md) | Prefer named type aliases over `typeof` on same-file global constants; ensure types are declared before constants. | ✅ | | | | |
|
|
247
247
|
| [prefer-type-over-interface](docs/rules/prefer-type-over-interface.md) | Prefer using type alias over interface | ✅ | | 🔧 | | |
|
package/lib/index.js
CHANGED
|
@@ -52,6 +52,33 @@ function finalSegmentOf(source) {
|
|
|
52
52
|
const segments = source.split('/').filter(Boolean);
|
|
53
53
|
return segments[segments.length - 1] ?? source;
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* The verdict over alternatives only one of which renders: the branches of a
|
|
57
|
+
* conditional, the operands of a logical, the several returns of a function
|
|
58
|
+
* `sx`. A wrapping alternative is a real seam whatever the others say, and only
|
|
59
|
+
* an all-`false` set proves the absence of one.
|
|
60
|
+
*/
|
|
61
|
+
function eitherBranchWraps(verdicts) {
|
|
62
|
+
if (verdicts.includes(true)) {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
return verdicts.length > 0 && verdicts.every((verdict) => verdict === false)
|
|
66
|
+
? false
|
|
67
|
+
: undefined;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The verdict over members merged in source order. An object literal and a MUI
|
|
71
|
+
* `sx` array are both last-write-wins, so a member that resolves `flexWrap`
|
|
72
|
+
* overrides every earlier one — including back to `false`, which is what makes
|
|
73
|
+
* `{ flexWrap: 'wrap', ...NOWRAP }` a non-wrapping object (#2299).
|
|
74
|
+
*
|
|
75
|
+
* An `undefined` member is opaque rather than empty: an imported or
|
|
76
|
+
* caller-supplied spread may or may not carry `flexWrap`, so it leaves the last
|
|
77
|
+
* known verdict standing rather than erasing it.
|
|
78
|
+
*/
|
|
79
|
+
function mergeVerdict(previous, next) {
|
|
80
|
+
return next === undefined ? previous : next;
|
|
81
|
+
}
|
|
55
82
|
exports.enforceUseFlexGapOnWrap = (0, createRule_1.createRule)({
|
|
56
83
|
name: 'enforce-use-flex-gap-on-wrap',
|
|
57
84
|
meta: {
|
|
@@ -224,58 +251,56 @@ exports.enforceUseFlexGapOnWrap = (0, createRule_1.createRule)({
|
|
|
224
251
|
}
|
|
225
252
|
return verdict;
|
|
226
253
|
}
|
|
227
|
-
case utils_1.AST_NODE_TYPES.ConditionalExpression:
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
return
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
case utils_1.AST_NODE_TYPES.LogicalExpression: {
|
|
238
|
-
const left = wrapVerdict(target.left, seen);
|
|
239
|
-
const right = wrapVerdict(target.right, seen);
|
|
240
|
-
if (left === true || right === true) {
|
|
241
|
-
return true;
|
|
242
|
-
}
|
|
243
|
-
return left === false && right === false ? false : undefined;
|
|
244
|
-
}
|
|
254
|
+
case utils_1.AST_NODE_TYPES.ConditionalExpression:
|
|
255
|
+
return eitherBranchWraps([
|
|
256
|
+
wrapVerdict(target.consequent, seen),
|
|
257
|
+
wrapVerdict(target.alternate, seen),
|
|
258
|
+
]);
|
|
259
|
+
case utils_1.AST_NODE_TYPES.LogicalExpression:
|
|
260
|
+
return eitherBranchWraps([
|
|
261
|
+
wrapVerdict(target.left, seen),
|
|
262
|
+
wrapVerdict(target.right, seen),
|
|
263
|
+
]);
|
|
245
264
|
default:
|
|
246
265
|
return undefined;
|
|
247
266
|
}
|
|
248
267
|
}
|
|
249
|
-
/**
|
|
268
|
+
/**
|
|
269
|
+
* What an object literal states about `flexWrap`. The members are merged in
|
|
270
|
+
* source order rather than scanned for any wrapping one, because an `sx`
|
|
271
|
+
* object is last-write-wins: `{ flexWrap: 'wrap', ...BASE }` renders
|
|
272
|
+
* whatever `BASE` says, and only the mirror ordering wraps.
|
|
273
|
+
*/
|
|
250
274
|
function objectWraps(object, seen) {
|
|
275
|
+
let verdict = undefined;
|
|
251
276
|
for (const property of object.properties) {
|
|
252
277
|
if (property.type === utils_1.AST_NODE_TYPES.SpreadElement) {
|
|
253
278
|
// A spread of a local constant is read through; a spread of an
|
|
254
279
|
// imported or caller-supplied value stays opaque, because resolving it
|
|
255
280
|
// would need the cross-module analysis this rule declines.
|
|
256
|
-
|
|
257
|
-
return true;
|
|
258
|
-
}
|
|
281
|
+
verdict = mergeVerdict(verdict, sxWraps(property.argument, seen));
|
|
259
282
|
continue;
|
|
260
283
|
}
|
|
261
284
|
if (propertyNameOf(property) !== 'flexWrap') {
|
|
262
285
|
continue;
|
|
263
286
|
}
|
|
264
|
-
|
|
265
|
-
return true;
|
|
266
|
-
}
|
|
287
|
+
verdict = mergeVerdict(verdict, wrapVerdict(property.value, seen));
|
|
267
288
|
}
|
|
268
|
-
return
|
|
289
|
+
return verdict;
|
|
269
290
|
}
|
|
270
291
|
/**
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
292
|
+
* What an `sx` expression states about `flexWrap` where the rule can read it
|
|
293
|
+
* statically. Four of the six wrapping Stacks in the consuming codebase
|
|
294
|
+
* hoist their `sx` to a module constant, including both live violations, so
|
|
295
|
+
* identifier resolution is the path that matters most.
|
|
296
|
+
*
|
|
297
|
+
* The verdict is the tri-state rather than a boolean because this answer
|
|
298
|
+
* feeds a merge: a spread of `{ flexWrap: 'nowrap' }` has to override an
|
|
299
|
+
* earlier wrapping member, which a `false` meaning "nothing known" cannot.
|
|
275
300
|
*/
|
|
276
301
|
function sxWraps(node, seen) {
|
|
277
302
|
if (!node) {
|
|
278
|
-
return
|
|
303
|
+
return undefined;
|
|
279
304
|
}
|
|
280
305
|
const target = unwrapAssertions(node);
|
|
281
306
|
switch (target.type) {
|
|
@@ -283,7 +308,7 @@ exports.enforceUseFlexGapOnWrap = (0, createRule_1.createRule)({
|
|
|
283
308
|
return objectWraps(target, seen);
|
|
284
309
|
case utils_1.AST_NODE_TYPES.Identifier: {
|
|
285
310
|
if (seen.has(target)) {
|
|
286
|
-
return
|
|
311
|
+
return undefined;
|
|
287
312
|
}
|
|
288
313
|
seen.add(target);
|
|
289
314
|
return sxWraps(initializerOf(target), seen);
|
|
@@ -291,21 +316,31 @@ exports.enforceUseFlexGapOnWrap = (0, createRule_1.createRule)({
|
|
|
291
316
|
case utils_1.AST_NODE_TYPES.ArrowFunctionExpression: {
|
|
292
317
|
const body = unwrapAssertions(target.body);
|
|
293
318
|
if (body.type === utils_1.AST_NODE_TYPES.BlockStatement) {
|
|
294
|
-
return body.body
|
|
295
|
-
|
|
319
|
+
return eitherBranchWraps(body.body
|
|
320
|
+
.filter((statement) => statement.type === utils_1.AST_NODE_TYPES.ReturnStatement)
|
|
321
|
+
.map((statement) => sxWraps(statement.argument ?? undefined, seen)));
|
|
296
322
|
}
|
|
297
323
|
return sxWraps(body, seen);
|
|
298
324
|
}
|
|
299
325
|
case utils_1.AST_NODE_TYPES.ConditionalExpression:
|
|
300
326
|
// Either branch renders, so either branch wrapping is a real seam.
|
|
301
|
-
return (
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
327
|
+
return eitherBranchWraps([
|
|
328
|
+
sxWraps(target.consequent, seen),
|
|
329
|
+
sxWraps(target.alternate, seen),
|
|
330
|
+
]);
|
|
331
|
+
case utils_1.AST_NODE_TYPES.ArrayExpression: {
|
|
332
|
+
// MUI merges an array of sx entries left to right, so the entry that
|
|
333
|
+
// resolves `flexWrap` last owns the value.
|
|
334
|
+
let verdict = undefined;
|
|
335
|
+
for (const element of target.elements) {
|
|
336
|
+
verdict = mergeVerdict(verdict, element ? sxWraps(element, seen) : undefined);
|
|
337
|
+
}
|
|
338
|
+
return verdict;
|
|
339
|
+
}
|
|
305
340
|
default:
|
|
306
341
|
// A call expression is opaque. Guessing at what it returns would
|
|
307
342
|
// report on code the rule cannot read.
|
|
308
|
-
return
|
|
343
|
+
return undefined;
|
|
309
344
|
}
|
|
310
345
|
}
|
|
311
346
|
/**
|
|
@@ -417,8 +452,8 @@ exports.enforceUseFlexGapOnWrap = (0, createRule_1.createRule)({
|
|
|
417
452
|
// The attribute wins where both spell `flexWrap` and disagree; an
|
|
418
453
|
// unreadable attribute is not a disagreement, so it falls through.
|
|
419
454
|
const wraps = attributeVerdict ??
|
|
420
|
-
(sx ? sxWraps(attributeValueOf(sx), new Set()) :
|
|
421
|
-
if (
|
|
455
|
+
(sx ? sxWraps(attributeValueOf(sx), new Set()) : undefined);
|
|
456
|
+
if (wraps !== true) {
|
|
422
457
|
return;
|
|
423
458
|
}
|
|
424
459
|
if (!resolvesToMuiStack(elementName)) {
|
|
@@ -202,6 +202,27 @@ const DOMAIN_CLASS_HEAD_NOUNS = new Set([
|
|
|
202
202
|
'drug',
|
|
203
203
|
'vehicle',
|
|
204
204
|
]);
|
|
205
|
+
// Conversion heads: the verb or preposition a CONVERTER function's name opens
|
|
206
|
+
// with. In <head><Type> the trailing type word names what the function
|
|
207
|
+
// PRODUCES (or consumes, for `from`), never the type of the value the
|
|
208
|
+
// identifier holds — the identifier holds a function. Hungarian notation tags a
|
|
209
|
+
// value with its own type, so `toNumber` is outside the notation entirely, and
|
|
210
|
+
// stripping the type word destroys the name (`to`, `parse` and `from` denote
|
|
211
|
+
// nothing on their own), which is this rule's own test for a domain compound
|
|
212
|
+
// versus a tag. The same reasoning the rule already applies to the type-concept
|
|
213
|
+
// names it exempts (`StringToNumber`) and to the `Parsed` / `Converted`
|
|
214
|
+
// suffixes, applied to function names (#2302).
|
|
215
|
+
//
|
|
216
|
+
// `convertto` is the two-segment head `convertTo`, stored joined because the
|
|
217
|
+
// lookup is done on the head segments concatenated and lowercased.
|
|
218
|
+
const CONVERSION_HEADS = new Set([
|
|
219
|
+
'to',
|
|
220
|
+
'as',
|
|
221
|
+
'from',
|
|
222
|
+
'parse',
|
|
223
|
+
'into',
|
|
224
|
+
'convertto',
|
|
225
|
+
]);
|
|
205
226
|
// Common built-in JavaScript prototype methods
|
|
206
227
|
const BUILT_IN_METHODS = new Set([
|
|
207
228
|
// String methods
|
|
@@ -578,6 +599,63 @@ function isClassValuedDeclaration(node) {
|
|
|
578
599
|
return false;
|
|
579
600
|
}
|
|
580
601
|
}
|
|
602
|
+
// Is `name` a conversion compound of the form <head><Type> (toNumber,
|
|
603
|
+
// parseBoolean, fromString, asArray, convertToNumber), where the FINAL segment
|
|
604
|
+
// is a full type word and everything before it is a conversion head? The type
|
|
605
|
+
// word must be final and must follow the head directly, so a name that merely
|
|
606
|
+
// contains a head keeps firing: `toNumberValue` names a value (the type word is
|
|
607
|
+
// not the target), `numberToValue` leads with the type word, and `strToNumber`
|
|
608
|
+
// carries an abbreviation marker, which no English word is spelled with.
|
|
609
|
+
// Abbreviation markers are excluded by construction — FULL_TYPE_WORDS holds only
|
|
610
|
+
// the spelled-out type words — so `toNum` / `toStr` are untouched.
|
|
611
|
+
function isConversionTargetCompound(name) {
|
|
612
|
+
const segments = splitCamelSegments(name);
|
|
613
|
+
if (segments.length < 2) {
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
const target = segments[segments.length - 1];
|
|
617
|
+
if (!FULL_TYPE_WORDS.has(target.toLowerCase())) {
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
const head = segments.slice(0, -1).join('').toLowerCase();
|
|
621
|
+
return CONVERSION_HEADS.has(head);
|
|
622
|
+
}
|
|
623
|
+
// Does the declaration site PROVE, syntactically, that the named value is a
|
|
624
|
+
// function? Only a function declaration's own name, a function/arrow
|
|
625
|
+
// initializer, or a class METHOD are conclusive without type information — an
|
|
626
|
+
// aliased function (`const toNumber = parseFloat`) and a bare `(v: string) =>
|
|
627
|
+
// number` annotation are deliberately not read, keeping the carve-out on the
|
|
628
|
+
// shapes where the function body is written at the declaration itself.
|
|
629
|
+
//
|
|
630
|
+
// This is the mirror image of isSymbolTypedDeclaration (#1835) and
|
|
631
|
+
// isClassValuedDeclaration (#2030): there the syntactic proof WITHDRAWS a
|
|
632
|
+
// carve-out because it confirms the suffix encodes the value's type; here it
|
|
633
|
+
// GRANTS one, because a function value is precisely what the type word cannot
|
|
634
|
+
// be describing. Accessors are excluded (`get toNumber()` is read as a value at
|
|
635
|
+
// every use site, so its `Number` does tag that value), as are computed keys,
|
|
636
|
+
// whose identifier is a reference to some other binding rather than a
|
|
637
|
+
// declaration.
|
|
638
|
+
function isFunctionValuedDeclaration(node) {
|
|
639
|
+
const parent = node.parent;
|
|
640
|
+
if (!parent) {
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
const isFunctionValue = (value) => !!value &&
|
|
644
|
+
(value.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
645
|
+
value.type === utils_1.AST_NODE_TYPES.FunctionExpression);
|
|
646
|
+
switch (parent.type) {
|
|
647
|
+
case utils_1.AST_NODE_TYPES.FunctionDeclaration:
|
|
648
|
+
return parent.id === node;
|
|
649
|
+
case utils_1.AST_NODE_TYPES.VariableDeclarator:
|
|
650
|
+
return parent.id === node && isFunctionValue(parent.init);
|
|
651
|
+
case utils_1.AST_NODE_TYPES.MethodDefinition:
|
|
652
|
+
return (parent.key === node && !parent.computed && parent.kind === 'method');
|
|
653
|
+
case utils_1.AST_NODE_TYPES.PropertyDefinition:
|
|
654
|
+
return (parent.key === node && !parent.computed && isFunctionValue(parent.value));
|
|
655
|
+
default:
|
|
656
|
+
return false;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
581
659
|
// Rebuild a SCREAMING_SNAKE_CASE identifier's segments into a PascalCase compound
|
|
582
660
|
// (["MATCH","NUMBER"] -> "MatchNumber") so the snake-case branch can reuse the
|
|
583
661
|
// camelCase isDomainNumberCompound / DOMAIN_NUMBER_HEAD_NOUNS exemption verbatim,
|
|
@@ -616,7 +694,9 @@ exports.noHungarian = (0, createRule_1.createRule)({
|
|
|
616
694
|
// `symbol` value, which vetoes the <domain>Symbol glyph exemption.
|
|
617
695
|
// `isClassValued` is true when the declaration syntactically proves a JS
|
|
618
696
|
// class value, which vetoes the <taxonomy>Class exemption.
|
|
619
|
-
|
|
697
|
+
// `isFunctionValued` is true when the declaration syntactically proves a
|
|
698
|
+
// function value, which GRANTS the converter-function exemption.
|
|
699
|
+
function hasTypeMarker(variableName, isTypeName = false, isSymbolTyped = false, isClassValued = false, isFunctionValued = false) {
|
|
620
700
|
// Type names whose type-word denotes a concept/relation (StringToNumber,
|
|
621
701
|
// CapitalizedString, FuncKeys, PromiseOrValue) are not Hungarian — the word
|
|
622
702
|
// is part of the type's meaning, like the allowed compound noun PhoneNumber.
|
|
@@ -702,6 +782,17 @@ exports.noHungarian = (0, createRule_1.createRule)({
|
|
|
702
782
|
if (index !== 0 && index !== lastIndex) {
|
|
703
783
|
return false;
|
|
704
784
|
}
|
|
785
|
+
// A trailing "..._TYPE" word directly after a conversion head on a
|
|
786
|
+
// declaration that proves a function (TO_NUMBER, PARSE_BOOLEAN as
|
|
787
|
+
// arrow consts) names the conversion TARGET, not the constant's own
|
|
788
|
+
// type — the same carve-out as camelCase toNumber (#2302), routed
|
|
789
|
+
// through the shared PascalCase helper so the two casings cannot
|
|
790
|
+
// diverge (the #1294 asymmetry).
|
|
791
|
+
if (isFunctionValued &&
|
|
792
|
+
index === lastIndex &&
|
|
793
|
+
isConversionTargetCompound(screamingSnakePartsToPascalCase(parts))) {
|
|
794
|
+
return false;
|
|
795
|
+
}
|
|
705
796
|
// A trailing "..._NUMBER" whose preceding head noun is a domain
|
|
706
797
|
// entity (MATCH_NUMBER, ISSUE_NUMBER, CURRENT_LINE_NUMBER) is a
|
|
707
798
|
// domain compound, not a Hungarian type tag — route through the same
|
|
@@ -782,6 +873,18 @@ exports.noHungarian = (0, createRule_1.createRule)({
|
|
|
782
873
|
normalizedVarName.length > normalizedMarker.length &&
|
|
783
874
|
(/[A-Z0-9]/.test(variableName[variableName.length - normalizedMarker.length - 1]) ||
|
|
784
875
|
/[A-Z]/.test(variableName[variableName.length - normalizedMarker.length]))) {
|
|
876
|
+
// A trailing full type word directly after a conversion head, on a
|
|
877
|
+
// declaration that syntactically proves a function (toNumber,
|
|
878
|
+
// parseBoolean, fromString, asArray, convertToNumber), names what the
|
|
879
|
+
// conversion PRODUCES — the identifier itself holds a function, so
|
|
880
|
+
// there is no value type being tagged, and stripping the word leaves
|
|
881
|
+
// `to` / `parse` / `from`, which name nothing (#2302). Scoped to
|
|
882
|
+
// full-word markers in SUFFIX position on a proven function, so
|
|
883
|
+
// `const toNumber = 5`, `toNum` / `toStr` / `strToNumber`, and
|
|
884
|
+
// `numberToValue` all keep firing.
|
|
885
|
+
if (isFunctionValued && isConversionTargetCompound(variableName)) {
|
|
886
|
+
return false;
|
|
887
|
+
}
|
|
785
888
|
// A trailing "...Number" whose head noun is a domain entity
|
|
786
889
|
// (issueNumber, lineNumber, roundNumber, versionNumber) is a domain
|
|
787
890
|
// compound, not a Hungarian type tag: the suffix names WHAT the value
|
|
@@ -899,7 +1002,7 @@ exports.noHungarian = (0, createRule_1.createRule)({
|
|
|
899
1002
|
if (isExternalOrBuiltIn(node))
|
|
900
1003
|
return;
|
|
901
1004
|
// Check for type markers
|
|
902
|
-
if (hasTypeMarker(name, isTypeName, isSymbolTypedDeclaration(node), isClassValuedDeclaration(node))) {
|
|
1005
|
+
if (hasTypeMarker(name, isTypeName, isSymbolTypedDeclaration(node), isClassValuedDeclaration(node), isFunctionValuedDeclaration(node))) {
|
|
903
1006
|
context.report({
|
|
904
1007
|
node,
|
|
905
1008
|
messageId: 'noHungarian',
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { TSESLint } from '@typescript-eslint/utils';
|
|
2
|
+
type MessageIds = 'preferSpread' | 'applySpread';
|
|
2
3
|
type Options = [{
|
|
3
4
|
minFields?: number;
|
|
4
5
|
}];
|
|
5
|
-
export declare const preferSpreadOverReassembly: TSESLint.RuleModule<
|
|
6
|
+
export declare const preferSpreadOverReassembly: TSESLint.RuleModule<MessageIds, Options, TSESLint.RuleListener>;
|
|
6
7
|
export {};
|
|
@@ -433,8 +433,8 @@ function findRelativeTypeImport(program, localName) {
|
|
|
433
433
|
return null;
|
|
434
434
|
}
|
|
435
435
|
/**
|
|
436
|
-
*
|
|
437
|
-
*
|
|
436
|
+
* Resolves a type node to the member list it writes down, or null when the
|
|
437
|
+
* member set cannot be established with certainty.
|
|
438
438
|
*
|
|
439
439
|
* Only an unambiguous, fully written-out member list qualifies, reached either
|
|
440
440
|
* directly or through the key-preserving operators in
|
|
@@ -446,9 +446,9 @@ function findRelativeTypeImport(program, localName) {
|
|
|
446
446
|
* A name the file does not declare is looked up in the relative module that
|
|
447
447
|
* imports it, once; see {@link importedTypeMemberNames}.
|
|
448
448
|
*/
|
|
449
|
-
function
|
|
449
|
+
function resolveTypeMembers(typeNode, scope, seen = new Set()) {
|
|
450
450
|
if (typeNode.type === utils_1.AST_NODE_TYPES.TSTypeLiteral) {
|
|
451
|
-
return
|
|
451
|
+
return { kind: 'members', members: typeNode.members };
|
|
452
452
|
}
|
|
453
453
|
if (typeNode.type !== utils_1.AST_NODE_TYPES.TSTypeReference ||
|
|
454
454
|
typeNode.typeName.type !== utils_1.AST_NODE_TYPES.Identifier) {
|
|
@@ -463,13 +463,14 @@ function memberNamesOf(typeNode, scope, seen = new Set()) {
|
|
|
463
463
|
scope.isLocallyBound(name)) {
|
|
464
464
|
return null;
|
|
465
465
|
}
|
|
466
|
-
return
|
|
466
|
+
return resolveTypeMembers(typeNode.typeParameters.params[0], scope, seen);
|
|
467
467
|
}
|
|
468
468
|
const declaration = findLocalTypeDeclaration(scope.program, typeNode, name);
|
|
469
469
|
if (!declaration) {
|
|
470
470
|
// The file does not declare the name, so the only remaining source of a
|
|
471
471
|
// written-down member list is the module it comes from.
|
|
472
|
-
|
|
472
|
+
const names = scope.resolveImportedMembers?.(name);
|
|
473
|
+
return names ? { kind: 'names', names } : null;
|
|
473
474
|
}
|
|
474
475
|
// A self-referential alias (`type T = T`) would otherwise recur forever. The
|
|
475
476
|
// guard is keyed on the DECLARATION rather than the name: one name denotes
|
|
@@ -479,24 +480,166 @@ function memberNamesOf(typeNode, scope, seen = new Set()) {
|
|
|
479
480
|
return null;
|
|
480
481
|
}
|
|
481
482
|
seen.add(declaration);
|
|
482
|
-
return
|
|
483
|
+
return resolveTypeMembersOfDeclaration(declaration, scope, seen);
|
|
483
484
|
}
|
|
484
485
|
/**
|
|
485
|
-
* The
|
|
486
|
+
* The members a type alias or interface declares. A generic declaration
|
|
486
487
|
* describes a different member set per instantiation and an interface with an
|
|
487
488
|
* `extends` clause inherits members written elsewhere, so neither enumerates.
|
|
488
489
|
*/
|
|
489
|
-
function
|
|
490
|
+
function resolveTypeMembersOfDeclaration(declaration, scope, seen) {
|
|
490
491
|
if (declaration.typeParameters) {
|
|
491
492
|
return null;
|
|
492
493
|
}
|
|
493
494
|
if (declaration.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration) {
|
|
494
|
-
return
|
|
495
|
+
return resolveTypeMembers(declaration.typeAnnotation, scope, seen);
|
|
495
496
|
}
|
|
496
497
|
if (declaration.extends && declaration.extends.length > 0) {
|
|
497
498
|
return null;
|
|
498
499
|
}
|
|
499
|
-
return
|
|
500
|
+
return { kind: 'members', members: declaration.body.body };
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Enumerates every property name a type node declares, or null when the member
|
|
504
|
+
* list cannot be established with certainty.
|
|
505
|
+
*/
|
|
506
|
+
function memberNamesOf(typeNode, scope, seen = new Set()) {
|
|
507
|
+
const resolved = resolveTypeMembers(typeNode, scope, seen);
|
|
508
|
+
if (!resolved) {
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
return resolved.kind === 'names'
|
|
512
|
+
? resolved.names
|
|
513
|
+
: namesOfMembers(resolved.members);
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* The type a chain of non-generic local aliases finally spells, or the node
|
|
517
|
+
* itself when the chain cannot be followed.
|
|
518
|
+
*
|
|
519
|
+
* A catalog names its element and callback shapes through aliases rather than
|
|
520
|
+
* writing them inline, so a reader that only recognises the literal spelling
|
|
521
|
+
* loses the proof on the layout it exists to serve.
|
|
522
|
+
*/
|
|
523
|
+
function unwrapLocalTypeAlias(typeNode, scope, seen = new Set()) {
|
|
524
|
+
if (typeNode.type !== utils_1.AST_NODE_TYPES.TSTypeReference ||
|
|
525
|
+
typeNode.typeName.type !== utils_1.AST_NODE_TYPES.Identifier ||
|
|
526
|
+
typeNode.typeParameters) {
|
|
527
|
+
return typeNode;
|
|
528
|
+
}
|
|
529
|
+
const declaration = findLocalTypeDeclaration(scope.program, typeNode, typeNode.typeName.name);
|
|
530
|
+
if (!declaration ||
|
|
531
|
+
declaration.type !== utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration ||
|
|
532
|
+
declaration.typeParameters ||
|
|
533
|
+
seen.has(declaration)) {
|
|
534
|
+
return typeNode;
|
|
535
|
+
}
|
|
536
|
+
seen.add(declaration);
|
|
537
|
+
return unwrapLocalTypeAlias(declaration.typeAnnotation, scope, seen);
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* The declared type of one named member of a type, or null when the type does
|
|
541
|
+
* not write that member down exactly once.
|
|
542
|
+
*
|
|
543
|
+
* A name carried twice is an overload set or a declaration merge, whose
|
|
544
|
+
* effective signature is not the one written at either site, so it proves
|
|
545
|
+
* nothing about the parameter a callback receives.
|
|
546
|
+
*/
|
|
547
|
+
function memberTypeNodeOf(typeNode, scope, memberName) {
|
|
548
|
+
const resolved = resolveTypeMembers(typeNode, scope);
|
|
549
|
+
if (!resolved || resolved.kind !== 'members') {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
let matches = 0;
|
|
553
|
+
let annotation = null;
|
|
554
|
+
for (const member of resolved.members) {
|
|
555
|
+
if (member.type !== utils_1.AST_NODE_TYPES.TSPropertySignature || member.computed) {
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
const key = member.key;
|
|
559
|
+
const name = key.type === utils_1.AST_NODE_TYPES.Identifier
|
|
560
|
+
? key.name
|
|
561
|
+
: key.type === utils_1.AST_NODE_TYPES.Literal && typeof key.value === 'string'
|
|
562
|
+
? key.value
|
|
563
|
+
: null;
|
|
564
|
+
if (name !== memberName) {
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
matches += 1;
|
|
568
|
+
annotation = member.typeAnnotation?.typeAnnotation ?? null;
|
|
569
|
+
}
|
|
570
|
+
return matches === 1 ? annotation : null;
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* The declared type of the sole parameter of a function type, or null when the
|
|
574
|
+
* type is not a one-parameter signature that writes its parameter's type down.
|
|
575
|
+
*
|
|
576
|
+
* More than one parameter means the callback under lint is not the one this
|
|
577
|
+
* signature describes, and a rest parameter names no fixed member set at all.
|
|
578
|
+
*/
|
|
579
|
+
function soleParameterTypeOf(typeNode, scope) {
|
|
580
|
+
const signature = unwrapLocalTypeAlias(typeNode, scope);
|
|
581
|
+
if (signature.type !== utils_1.AST_NODE_TYPES.TSFunctionType ||
|
|
582
|
+
signature.params.length !== 1) {
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
const param = signature.params[0];
|
|
586
|
+
if (param.type === utils_1.AST_NODE_TYPES.RestElement ||
|
|
587
|
+
param.type === utils_1.AST_NODE_TYPES.TSParameterProperty) {
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
return param.typeAnnotation?.typeAnnotation ?? null;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* The type node an expression is declared to have by the binding or assertion
|
|
594
|
+
* it is written into.
|
|
595
|
+
*
|
|
596
|
+
* A codebase that centralises its shapes states them once at the binding —
|
|
597
|
+
* `const CASES: readonly CaseEntry[] = [...]` — and leaves every callback
|
|
598
|
+
* inside to be contextually typed by it, so a reader that only consults
|
|
599
|
+
* parameter annotations never sees the type that is actually written (#2298).
|
|
600
|
+
*/
|
|
601
|
+
function declaredTypeOfInitializer(node) {
|
|
602
|
+
const parent = node.parent;
|
|
603
|
+
if (!parent) {
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
if ((parent.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
|
|
607
|
+
parent.type === utils_1.AST_NODE_TYPES.TSSatisfiesExpression) &&
|
|
608
|
+
parent.expression === node) {
|
|
609
|
+
// `as const` states immutability rather than a shape, and it is written
|
|
610
|
+
// BELOW the binding's annotation, so the walk continues past it.
|
|
611
|
+
if (isConstAssertionType(parent.typeAnnotation)) {
|
|
612
|
+
return declaredTypeOfInitializer(parent);
|
|
613
|
+
}
|
|
614
|
+
return parent.typeAnnotation;
|
|
615
|
+
}
|
|
616
|
+
if (parent.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
|
|
617
|
+
parent.init === node &&
|
|
618
|
+
parent.id.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
619
|
+
parent.id.typeAnnotation) {
|
|
620
|
+
return parent.id.typeAnnotation.typeAnnotation;
|
|
621
|
+
}
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
function isConstAssertionType(typeNode) {
|
|
625
|
+
return (typeNode.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
|
|
626
|
+
typeNode.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
627
|
+
typeNode.typeName.name === 'const');
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* The declared type of the entry an object literal spells, looking through the
|
|
631
|
+
* array literal a catalog wraps its entries in.
|
|
632
|
+
*/
|
|
633
|
+
function declaredEntryTypeOf(literal, scope) {
|
|
634
|
+
const array = literal.parent;
|
|
635
|
+
if (array?.type === utils_1.AST_NODE_TYPES.ArrayExpression &&
|
|
636
|
+
array.elements.includes(literal)) {
|
|
637
|
+
const declared = declaredTypeOfInitializer(array);
|
|
638
|
+
return declared
|
|
639
|
+
? arrayElementTypeOf(unwrapLocalTypeAlias(declared, scope))
|
|
640
|
+
: null;
|
|
641
|
+
}
|
|
642
|
+
return declaredTypeOfInitializer(literal);
|
|
500
643
|
}
|
|
501
644
|
/**
|
|
502
645
|
* The member names of a type the file under lint imports from a relative
|
|
@@ -555,7 +698,15 @@ function readExportedTypeMembers(filePath, exported) {
|
|
|
555
698
|
return siblingBoundNames.has(name);
|
|
556
699
|
},
|
|
557
700
|
};
|
|
558
|
-
|
|
701
|
+
const resolved = resolveTypeMembersOfDeclaration(declaration, siblingScope, new Set([declaration]));
|
|
702
|
+
if (!resolved) {
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
// The sibling's scope carries no import resolver, so the walk inside it can
|
|
706
|
+
// only ever end at member nodes the sibling itself writes down.
|
|
707
|
+
return resolved.kind === 'names'
|
|
708
|
+
? resolved.names
|
|
709
|
+
: namesOfMembers(resolved.members);
|
|
559
710
|
}
|
|
560
711
|
/**
|
|
561
712
|
* For a JSX element, returns the set of destructured names that are forwarded
|
|
@@ -958,6 +1109,7 @@ exports.preferSpreadOverReassembly = (0, createRule_1.createRule)({
|
|
|
958
1109
|
recommended: 'error',
|
|
959
1110
|
},
|
|
960
1111
|
fixable: 'code',
|
|
1112
|
+
hasSuggestions: true,
|
|
961
1113
|
schema: [
|
|
962
1114
|
{
|
|
963
1115
|
type: 'object',
|
|
@@ -973,6 +1125,8 @@ exports.preferSpreadOverReassembly = (0, createRule_1.createRule)({
|
|
|
973
1125
|
messages: {
|
|
974
1126
|
preferSpread: 'Prefer spread over destructure-then-reassemble: replace the destructured parameter with a single identifier and use spread syntax on the target. ' +
|
|
975
1127
|
'This avoids silent bugs when new fields are added to the type.',
|
|
1128
|
+
applySpread: 'Replace the destructured parameter with a single identifier and spread it onto the target. ' +
|
|
1129
|
+
'Check first that the parameter carries no members beyond the ones destructured, since the spread forwards every member it has.',
|
|
976
1130
|
},
|
|
977
1131
|
},
|
|
978
1132
|
defaultOptions: [{ minFields: DEFAULT_MIN_FIELDS }],
|
|
@@ -1137,29 +1291,83 @@ exports.preferSpreadOverReassembly = (0, createRule_1.createRule)({
|
|
|
1137
1291
|
return elementType ? memberNamesOf(elementType, typeScope) : null;
|
|
1138
1292
|
}
|
|
1139
1293
|
/**
|
|
1140
|
-
*
|
|
1141
|
-
*
|
|
1142
|
-
*
|
|
1143
|
-
*
|
|
1294
|
+
* The member names of the parameter type a typed binding gives the
|
|
1295
|
+
* function through the object literal it is a property of.
|
|
1296
|
+
*
|
|
1297
|
+
* A catalog states its entries' shape once, on the binding
|
|
1298
|
+
* (`const CASES: readonly CaseEntry[] = [...]`), and leaves every callback
|
|
1299
|
+
* inside to be contextually typed by it. Such a parameter narrows exactly
|
|
1300
|
+
* as an annotated one does, so a reader that stops at annotations rewrites
|
|
1301
|
+
* a deliberate pick into a widening spread (#2298).
|
|
1302
|
+
*/
|
|
1303
|
+
function contextualBindingMemberNames(fn) {
|
|
1304
|
+
const property = fn.parent;
|
|
1305
|
+
if (!property ||
|
|
1306
|
+
property.type !== utils_1.AST_NODE_TYPES.Property ||
|
|
1307
|
+
property.value !== fn ||
|
|
1308
|
+
property.computed) {
|
|
1309
|
+
return null;
|
|
1310
|
+
}
|
|
1311
|
+
const key = property.key;
|
|
1312
|
+
const propertyName = key.type === utils_1.AST_NODE_TYPES.Identifier
|
|
1313
|
+
? key.name
|
|
1314
|
+
: key.type === utils_1.AST_NODE_TYPES.Literal && typeof key.value === 'string'
|
|
1315
|
+
? key.value
|
|
1316
|
+
: null;
|
|
1317
|
+
if (propertyName === null) {
|
|
1318
|
+
return null;
|
|
1319
|
+
}
|
|
1320
|
+
const literal = property.parent;
|
|
1321
|
+
if (!literal || literal.type !== utils_1.AST_NODE_TYPES.ObjectExpression) {
|
|
1322
|
+
return null;
|
|
1323
|
+
}
|
|
1324
|
+
const entryType = declaredEntryTypeOf(literal, typeScope);
|
|
1325
|
+
if (!entryType) {
|
|
1326
|
+
return null;
|
|
1327
|
+
}
|
|
1328
|
+
const memberType = memberTypeNodeOf(entryType, typeScope, propertyName);
|
|
1329
|
+
if (!memberType) {
|
|
1330
|
+
return null;
|
|
1331
|
+
}
|
|
1332
|
+
const parameterType = soleParameterTypeOf(memberType, typeScope);
|
|
1333
|
+
return parameterType ? memberNamesOf(parameterType, typeScope) : null;
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* How much the source object's own type proves about the destructured pick.
|
|
1144
1337
|
*
|
|
1145
|
-
*
|
|
1146
|
-
*
|
|
1147
|
-
*
|
|
1148
|
-
*
|
|
1149
|
-
*
|
|
1150
|
-
*
|
|
1151
|
-
*
|
|
1338
|
+
* `narrowing` — the pick is a PROPER subset of a resolved member set, so
|
|
1339
|
+
* spreading the parameter would reinstate the members the author left out
|
|
1340
|
+
* and change what the function produces (#1642: a GitHub review payload
|
|
1341
|
+
* gained unknown keys). The rule stays silent.
|
|
1342
|
+
*
|
|
1343
|
+
* `exhaustive` — the resolved member set is exactly the pick, so the
|
|
1344
|
+
* rewrite is demonstrably behavior-preserving and is applied.
|
|
1345
|
+
*
|
|
1346
|
+
* `unproven` — no member set resolves, or one resolves that does not even
|
|
1347
|
+
* contain the pick. A union, an index signature, an instantiation of
|
|
1348
|
+
* anything but a key-preserving operator, a `Parameters<>` indirection or
|
|
1349
|
+
* an import whose module the walk declines to follow all land here. The
|
|
1350
|
+
* finding still stands, but nothing about the rewrite's safety follows, so
|
|
1351
|
+
* it is offered rather than applied.
|
|
1152
1352
|
*/
|
|
1153
|
-
function
|
|
1353
|
+
function classifyPick(fn, param, destructuredNames) {
|
|
1154
1354
|
// An explicit annotation overrides whatever the call site would imply,
|
|
1155
|
-
// so the contextual
|
|
1355
|
+
// so the contextual routes are consulted only in its absence.
|
|
1156
1356
|
const memberNames = param.typeAnnotation
|
|
1157
1357
|
? memberNamesOf(param.typeAnnotation.typeAnnotation, typeScope)
|
|
1158
|
-
: contextualElementMemberNames(fn);
|
|
1159
|
-
if (!memberNames
|
|
1160
|
-
return
|
|
1358
|
+
: contextualElementMemberNames(fn) ?? contextualBindingMemberNames(fn);
|
|
1359
|
+
if (!memberNames) {
|
|
1360
|
+
return 'unproven';
|
|
1361
|
+
}
|
|
1362
|
+
// A pick naming something the resolved type does not declare means the
|
|
1363
|
+
// resolution describes a different shape than the one destructured, so
|
|
1364
|
+
// its size says nothing about what a spread would forward.
|
|
1365
|
+
if (!destructuredNames.every((name) => memberNames.has(name))) {
|
|
1366
|
+
return 'unproven';
|
|
1161
1367
|
}
|
|
1162
|
-
return
|
|
1368
|
+
return memberNames.size > destructuredNames.length
|
|
1369
|
+
? 'narrowing'
|
|
1370
|
+
: 'exhaustive';
|
|
1163
1371
|
}
|
|
1164
1372
|
function checkFunction(fn) {
|
|
1165
1373
|
// Must have exactly one parameter that is an ObjectPattern.
|
|
@@ -1218,37 +1426,48 @@ exports.preferSpreadOverReassembly = (0, createRule_1.createRule)({
|
|
|
1218
1426
|
}
|
|
1219
1427
|
// The pick may exist precisely because the omitted members must not flow
|
|
1220
1428
|
// through; spreading would reinstate them.
|
|
1221
|
-
|
|
1429
|
+
const classification = classifyPick(fn, param, destructuredNames);
|
|
1430
|
+
if (classification === 'narrowing') {
|
|
1222
1431
|
return;
|
|
1223
1432
|
}
|
|
1433
|
+
const applySpread = (fixer) => {
|
|
1434
|
+
const fixes = [];
|
|
1435
|
+
// Choose a fresh name for the props parameter that does not collide
|
|
1436
|
+
// with any binding currently in scope.
|
|
1437
|
+
const propsName = freshPropsName(namesSet);
|
|
1438
|
+
// 1. Replace the destructured parameter with `props` (or fresh name),
|
|
1439
|
+
// keeping any type annotation intact.
|
|
1440
|
+
const paramFix = replacePatternWithName(fixer, sourceCode, param, propsName);
|
|
1441
|
+
if (!paramFix) {
|
|
1442
|
+
return null;
|
|
1443
|
+
}
|
|
1444
|
+
fixes.push(paramFix);
|
|
1445
|
+
// 2. Collapse the forwarded fields into a single spread. Only the
|
|
1446
|
+
// ranges of the fields being collapsed are spliced out; the retained
|
|
1447
|
+
// ones — and the comments attached to them, which may be
|
|
1448
|
+
// `eslint-disable` directives — are left exactly as authored.
|
|
1449
|
+
const targetFix = target.kind === 'jsx'
|
|
1450
|
+
? buildJsxSpreadFix(fixer, sourceCode, target.openingElement, forwardedNodes, propsName)
|
|
1451
|
+
: buildObjectSpreadFix(fixer, sourceCode, target.expression, forwardedNodes, propsName);
|
|
1452
|
+
if (!targetFix) {
|
|
1453
|
+
return null;
|
|
1454
|
+
}
|
|
1455
|
+
fixes.push(targetFix);
|
|
1456
|
+
return fixes;
|
|
1457
|
+
};
|
|
1224
1458
|
context.report({
|
|
1225
1459
|
node: param,
|
|
1226
1460
|
messageId: 'preferSpread',
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
}
|
|
1238
|
-
fixes.push(paramFix);
|
|
1239
|
-
// 2. Collapse the forwarded fields into a single spread. Only the
|
|
1240
|
-
// ranges of the fields being collapsed are spliced out; the retained
|
|
1241
|
-
// ones — and the comments attached to them, which may be
|
|
1242
|
-
// `eslint-disable` directives — are left exactly as authored.
|
|
1243
|
-
const targetFix = target.kind === 'jsx'
|
|
1244
|
-
? buildJsxSpreadFix(fixer, sourceCode, target.openingElement, forwardedNodes, propsName)
|
|
1245
|
-
: buildObjectSpreadFix(fixer, sourceCode, target.expression, forwardedNodes, propsName);
|
|
1246
|
-
if (!targetFix) {
|
|
1247
|
-
return null;
|
|
1248
|
-
}
|
|
1249
|
-
fixes.push(targetFix);
|
|
1250
|
-
return fixes;
|
|
1251
|
-
},
|
|
1461
|
+
// An exhaustive pick is PROVEN to spread to the same member set, so the
|
|
1462
|
+
// rewrite is applied. Everything else is offered rather than applied:
|
|
1463
|
+
// the spread forwards whatever the parameter's real type turns out to
|
|
1464
|
+
// carry, and a widening this reader cannot disprove has silently
|
|
1465
|
+
// changed consumer behaviour five times (#1642, #1643, #1644, #1769,
|
|
1466
|
+
// #2298). A suggestion costs the author one keystroke; a wrong
|
|
1467
|
+
// `--fix` costs a behaviour change nobody reviewed.
|
|
1468
|
+
...(classification === 'exhaustive'
|
|
1469
|
+
? { fix: applySpread }
|
|
1470
|
+
: { suggest: [{ messageId: 'applySpread', fix: applySpread }] }),
|
|
1252
1471
|
});
|
|
1253
1472
|
}
|
|
1254
1473
|
return {
|
|
@@ -441,29 +441,50 @@ const declaredKeysOf = (object) => {
|
|
|
441
441
|
}
|
|
442
442
|
return keys;
|
|
443
443
|
};
|
|
444
|
+
/** The names carried by more than one of the moved props. */
|
|
445
|
+
const duplicatedNamesOf = (systemPropAttrs) => {
|
|
446
|
+
const seen = new Set();
|
|
447
|
+
const duplicated = new Set();
|
|
448
|
+
for (const attr of systemPropAttrs) {
|
|
449
|
+
if (attr.name.type !== utils_1.AST_NODE_TYPES.JSXIdentifier) {
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
const { name } = attr.name;
|
|
453
|
+
if (seen.has(name)) {
|
|
454
|
+
duplicated.add(name);
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
seen.add(name);
|
|
458
|
+
}
|
|
459
|
+
return duplicated;
|
|
460
|
+
};
|
|
444
461
|
/**
|
|
445
|
-
* The moved props
|
|
446
|
-
*
|
|
447
|
-
* value the runtime keeps, one of the two spellings the
|
|
448
|
-
* discarded. The two disagree and only the author can say which
|
|
449
|
-
* fix stands down for those props while every other prop on the
|
|
450
|
-
* merges (#2296).
|
|
462
|
+
* The moved props that cannot be spliced into one object literal without
|
|
463
|
+
* duplicating a key. Doing so emits `{ display: 'flex', display: 'block' }` —
|
|
464
|
+
* TS1117, and whichever value the runtime keeps, one of the two spellings the
|
|
465
|
+
* author wrote is discarded. The two disagree and only the author can say which
|
|
466
|
+
* wins, so the fix stands down for those props while every other prop on the
|
|
467
|
+
* element still merges (#2296).
|
|
451
468
|
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
469
|
+
* A name is duplicated two ways. The `sx` object literal may already declare
|
|
470
|
+
* it, which only the object slot can do because only that slot is merged into
|
|
471
|
+
* in place. Or two moved props may carry the SAME name (`<Box display="flex"
|
|
472
|
+
* display="block" />`, itself TS17001): the fresh object literal a new `sx`, an
|
|
473
|
+
* array entry or the `{ ...moved, ...expr }` wrap emits carries one entry per
|
|
474
|
+
* moved prop, so it duplicates that name on its own with no `sx` involved
|
|
475
|
+
* (#2300). Both duplicates stand down — the rule cannot know which the author
|
|
476
|
+
* meant — while the report on each stays, so the author is still told to move
|
|
477
|
+
* the prop.
|
|
455
478
|
*/
|
|
456
479
|
const collidingPropsOf = (systemPropAttrs, sxAttr) => {
|
|
480
|
+
const duplicated = duplicatedNamesOf(systemPropAttrs);
|
|
457
481
|
const slot = sxSlotOf(sxAttr);
|
|
458
|
-
|
|
459
|
-
return new Set();
|
|
460
|
-
}
|
|
461
|
-
const declared = declaredKeysOf(slot.object);
|
|
482
|
+
const declared = slot.kind === 'object' ? declaredKeysOf(slot.object) : new Set();
|
|
462
483
|
if (declared === null) {
|
|
463
484
|
return new Set(systemPropAttrs);
|
|
464
485
|
}
|
|
465
486
|
return new Set(systemPropAttrs.filter((attr) => attr.name.type === utils_1.AST_NODE_TYPES.JSXIdentifier &&
|
|
466
|
-
declared.has(attr.name.name)));
|
|
487
|
+
(declared.has(attr.name.name) || duplicated.has(attr.name.name))));
|
|
467
488
|
};
|
|
468
489
|
/**
|
|
469
490
|
* Plans every edit the autofix makes for one JSX element.
|
|
@@ -1058,10 +1079,10 @@ exports.preferSxPropOverSystemProps = (0, createRule_1.createRule)({
|
|
|
1058
1079
|
if (systemPropAttrs.length === 0)
|
|
1059
1080
|
return;
|
|
1060
1081
|
const sourceCode = context.getSourceCode();
|
|
1061
|
-
// A prop whose name the `sx` literal already declares
|
|
1062
|
-
//
|
|
1063
|
-
//
|
|
1064
|
-
// other props back.
|
|
1082
|
+
// A prop whose name the `sx` literal already declares, or that another
|
|
1083
|
+
// moved prop on this element repeats, is reported without a fix:
|
|
1084
|
+
// merging it would duplicate the key. The rest of the element is still
|
|
1085
|
+
// merged, so one disagreeing pair does not hold the other props back.
|
|
1065
1086
|
const collidingProps = collidingPropsOf(systemPropAttrs, sxAttr);
|
|
1066
1087
|
const fixableAttrs = systemPropAttrs.filter((attr) => !collidingProps.has(attr));
|
|
1067
1088
|
// Report each system prop. Only the first fixable one carries the fixer
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,49 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.21.2",
|
|
4
|
+
"date": "2026-09-03T01:55:23.074Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "enforce-use-flex-gap-on-wrap",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
2299,
|
|
11
|
+
2301
|
|
12
|
+
],
|
|
13
|
+
"summary": "merge sx members last-write-wins (closes #2299, closes #2301)"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"name": "no-hungarian",
|
|
17
|
+
"changeType": "fix",
|
|
18
|
+
"issues": [
|
|
19
|
+
2302
|
|
20
|
+
],
|
|
21
|
+
"summary": "exempt converter functions whose final segment names the conversion target (closes #2302)"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"name": "prefer-sx-prop-over-system-props",
|
|
25
|
+
"changeType": "fix",
|
|
26
|
+
"issues": [
|
|
27
|
+
2300
|
|
28
|
+
],
|
|
29
|
+
"summary": "decline a name written twice among the moved props (closes #2300)"
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"version": "1.21.1",
|
|
35
|
+
"date": "2026-09-02T22:30:34.104Z",
|
|
36
|
+
"rules": [
|
|
37
|
+
{
|
|
38
|
+
"name": "prefer-spread-over-reassembly",
|
|
39
|
+
"changeType": "fix",
|
|
40
|
+
"issues": [
|
|
41
|
+
2298
|
|
42
|
+
],
|
|
43
|
+
"summary": "apply the spread only where the pick is proven exhaustive (closes #2298)"
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
},
|
|
2
47
|
{
|
|
3
48
|
"version": "1.21.0",
|
|
4
49
|
"date": "2026-09-02T18:04:18.331Z",
|