@blumintinc/eslint-plugin-blumint 1.20.202 → 1.21.1
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 +2 -1
- package/lib/index.js +4 -1
- package/lib/rules/enforce-use-flex-gap-on-wrap.d.ts +14 -0
- package/lib/rules/enforce-use-flex-gap-on-wrap.js +436 -0
- package/lib/rules/enforce-verb-noun-naming.js +2 -0
- 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 +70 -5
- package/package.json +1 -1
- package/release-manifest.json +44 -0
package/README.md
CHANGED
|
@@ -139,6 +139,7 @@ full closed loop is documented in agora's `.claude/skills/eslint-autonomy/SKILL.
|
|
|
139
139
|
| [enforce-types-directory-placement](docs/rules/enforce-types-directory-placement.md) | Enforce that type-only files (containing only type/interface/enum declarations) live under the canonical types directory | ✅ | | | | |
|
|
140
140
|
| [enforce-typescript-markdown-code-blocks](docs/rules/enforce-typescript-markdown-code-blocks.md) | Ensure Markdown fenced code blocks without a language specifier default to typescript for consistent highlighting. | ✅ | | 🔧 | | |
|
|
141
141
|
| [enforce-unique-cursor-headers](docs/rules/enforce-unique-cursor-headers.md) | Ensure files have exactly one cursor header containing required tags (e.g., @fileoverview) before any code | ✅ | | 🔧 | | |
|
|
142
|
+
| [enforce-use-flex-gap-on-wrap](docs/rules/enforce-use-flex-gap-on-wrap.md) | Require `useFlexGap` on a MUI `Stack` that wraps and passes `spacing`, because margin-based spacing leaves the wrapped line with no row gap and a phantom leading indent | ✅ | | 🔧 | | |
|
|
142
143
|
| [enforce-verb-noun-naming](docs/rules/enforce-verb-noun-naming.md) | Enforce verb phrases for functions and methods | ✅ | | | | |
|
|
143
144
|
| [ensure-pointer-events-none](docs/rules/ensure-pointer-events-none.md) | Ensure pointer-events: none is added to non-interactive pseudo-elements | ✅ | | 🔧 | | |
|
|
144
145
|
| [export-if-in-doubt](docs/rules/export-if-in-doubt.md) | All top-level variable declarations, type definitions, and functions should be exported | ✅ | | | | |
|
|
@@ -240,7 +241,7 @@ full closed loop is documented in agora's `.claude/skills/eslint-autonomy/SKILL.
|
|
|
240
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 | ✅ | | 🔧 | | |
|
|
241
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. | ✅ | | 🔧 | | |
|
|
242
243
|
| [prefer-settings-object](docs/rules/prefer-settings-object.md) | Enforce using a settings object for functions with multiple parameters | ✅ | | | | |
|
|
243
|
-
| [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 | ✅ | | 🔧 | 💡 | |
|
|
244
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. | ✅ | | 🔧 | | |
|
|
245
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. | ✅ | | | | |
|
|
246
247
|
| [prefer-type-over-interface](docs/rules/prefer-type-over-interface.md) | Prefer using type alias over interface | ✅ | | 🔧 | | |
|
package/lib/index.js
CHANGED
|
@@ -197,6 +197,7 @@ const no_portal_inside_tooltip_1 = require("./rules/no-portal-inside-tooltip");
|
|
|
197
197
|
const no_satisfies_in_frontend_bundle_1 = require("./rules/no-satisfies-in-frontend-bundle");
|
|
198
198
|
const prefer_utility_function_own_file_1 = require("./rules/prefer-utility-function-own-file");
|
|
199
199
|
const no_render_function_components_1 = require("./rules/no-render-function-components");
|
|
200
|
+
const enforce_use_flex_gap_on_wrap_1 = require("./rules/enforce-use-flex-gap-on-wrap");
|
|
200
201
|
const NO_FRONTEND_IMPORTS_FROM_FUNCTIONS_MESSAGE = 'Backend Cloud Functions (.f.ts under functions/) must not import frontend modules from the repo root src/**. Frontend code can depend on browser-only APIs and bundling it into Cloud Functions breaks server execution; move shared logic into functions/src or a shared package.';
|
|
201
202
|
function noFrontendImportsFromFunctionsPatterns(pattern) {
|
|
202
203
|
return [
|
|
@@ -223,7 +224,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
|
|
|
223
224
|
module.exports = {
|
|
224
225
|
meta: {
|
|
225
226
|
name: '@blumintinc/eslint-plugin-blumint',
|
|
226
|
-
version: '1.
|
|
227
|
+
version: '1.21.1',
|
|
227
228
|
},
|
|
228
229
|
parseOptions: {
|
|
229
230
|
ecmaVersion: 2020,
|
|
@@ -431,6 +432,7 @@ module.exports = {
|
|
|
431
432
|
'@blumintinc/blumint/no-satisfies-in-frontend-bundle': 'error',
|
|
432
433
|
'@blumintinc/blumint/prefer-utility-function-own-file': 'error',
|
|
433
434
|
'@blumintinc/blumint/no-render-function-components': 'error',
|
|
435
|
+
'@blumintinc/blumint/enforce-use-flex-gap-on-wrap': 'error',
|
|
434
436
|
},
|
|
435
437
|
/**
|
|
436
438
|
* Depth-specific overrides block only import strings that traverse to the
|
|
@@ -708,6 +710,7 @@ module.exports = {
|
|
|
708
710
|
'no-satisfies-in-frontend-bundle': no_satisfies_in_frontend_bundle_1.noSatisfiesInFrontendBundle,
|
|
709
711
|
'prefer-utility-function-own-file': prefer_utility_function_own_file_1.preferUtilityFunctionOwnFile,
|
|
710
712
|
'no-render-function-components': no_render_function_components_1.noRenderFunctionComponents,
|
|
713
|
+
'enforce-use-flex-gap-on-wrap': enforce_use_flex_gap_on_wrap_1.enforceUseFlexGapOnWrap,
|
|
711
714
|
},
|
|
712
715
|
};
|
|
713
716
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { TSESLint } from '@typescript-eslint/utils';
|
|
2
|
+
/**
|
|
3
|
+
* `applyDefault` deep merges `defaultOptions` into whatever the consumer passes
|
|
4
|
+
* and an array value replaces rather than extends, so both keys are present by
|
|
5
|
+
* the time `create` reads them.
|
|
6
|
+
*/
|
|
7
|
+
type Options = [
|
|
8
|
+
{
|
|
9
|
+
stackComponents: string[];
|
|
10
|
+
importSources: string[];
|
|
11
|
+
}
|
|
12
|
+
];
|
|
13
|
+
export declare const enforceUseFlexGapOnWrap: TSESLint.RuleModule<"useFlexGapRequired", Options, TSESLint.RuleListener>;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.enforceUseFlexGapOnWrap = void 0;
|
|
4
|
+
const utils_1 = require("@typescript-eslint/utils");
|
|
5
|
+
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
6
|
+
const createRule_1 = require("../utils/createRule");
|
|
7
|
+
const DEFAULT_STACK_COMPONENTS = ['Stack'];
|
|
8
|
+
/**
|
|
9
|
+
* Both spellings occur in the consuming codebase: the deep default path in the
|
|
10
|
+
* overwhelming majority of files, the barrel in a handful.
|
|
11
|
+
*/
|
|
12
|
+
const DEFAULT_IMPORT_SOURCES = ['@mui/material/Stack', '@mui/material'];
|
|
13
|
+
/** The attribute whose presence exempts, and the one the fixer writes. */
|
|
14
|
+
const USE_FLEX_GAP = 'useFlexGap';
|
|
15
|
+
/**
|
|
16
|
+
* The two `flex-wrap` values that put children on more than one line. Everything
|
|
17
|
+
* else — `nowrap`, the initial value, an unresolvable expression — leaves a
|
|
18
|
+
* single line, where margin-based spacing renders correctly.
|
|
19
|
+
*/
|
|
20
|
+
const WRAPPING_VALUES = new Set(['wrap', 'wrap-reverse']);
|
|
21
|
+
/**
|
|
22
|
+
* `x as T`, `<T>x`, `x satisfies T` and `x!` assert a type about the expression
|
|
23
|
+
* they wrap without contributing a value, so a read that classifies the SHAPE or
|
|
24
|
+
* the VALUE of an expression must look through all four alike.
|
|
25
|
+
*
|
|
26
|
+
* This is load-bearing rather than defensive here: `global-const-style` rewrites
|
|
27
|
+
* hoisted style constants into `const ROW_SX = { ... } as const`, so the `sx`
|
|
28
|
+
* constants this rule resolves arrive assertion-wrapped and a resolver keyed on
|
|
29
|
+
* `ObjectExpression` alone goes silently quiet on them (#1805).
|
|
30
|
+
*/
|
|
31
|
+
const ASSERTION_EXPRESSION_TYPES = new Set([
|
|
32
|
+
utils_1.AST_NODE_TYPES.TSAsExpression,
|
|
33
|
+
utils_1.AST_NODE_TYPES.TSSatisfiesExpression,
|
|
34
|
+
utils_1.AST_NODE_TYPES.TSNonNullExpression,
|
|
35
|
+
utils_1.AST_NODE_TYPES.TSTypeAssertion,
|
|
36
|
+
]);
|
|
37
|
+
const isAssertionExpression = (node) => ASSERTION_EXPRESSION_TYPES.has(node.type);
|
|
38
|
+
/** Peels every assertion wrapper, since assertions nest. */
|
|
39
|
+
function unwrapAssertions(node) {
|
|
40
|
+
let target = node;
|
|
41
|
+
while (isAssertionExpression(target)) {
|
|
42
|
+
target = target.expression;
|
|
43
|
+
}
|
|
44
|
+
return target;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The component a deep import path names. `@mui/material/Stack` denotes `Stack`
|
|
48
|
+
* whatever the local binding is called, which is what separates the real import
|
|
49
|
+
* from the copy-paste defect `import Stack from '@mui/material/Typography'`.
|
|
50
|
+
*/
|
|
51
|
+
function finalSegmentOf(source) {
|
|
52
|
+
const segments = source.split('/').filter(Boolean);
|
|
53
|
+
return segments[segments.length - 1] ?? source;
|
|
54
|
+
}
|
|
55
|
+
exports.enforceUseFlexGapOnWrap = (0, createRule_1.createRule)({
|
|
56
|
+
name: 'enforce-use-flex-gap-on-wrap',
|
|
57
|
+
meta: {
|
|
58
|
+
type: 'problem',
|
|
59
|
+
docs: {
|
|
60
|
+
description: 'Require `useFlexGap` on a MUI `Stack` that wraps and passes `spacing`, because margin-based spacing leaves the wrapped line with no row gap and a phantom leading indent',
|
|
61
|
+
recommended: 'error',
|
|
62
|
+
},
|
|
63
|
+
fixable: 'code',
|
|
64
|
+
schema: [
|
|
65
|
+
{
|
|
66
|
+
type: 'object',
|
|
67
|
+
properties: {
|
|
68
|
+
stackComponents: {
|
|
69
|
+
type: 'array',
|
|
70
|
+
items: { type: 'string' },
|
|
71
|
+
description: 'Component names treated as MUI Stack. A name here is checked only after its binding resolves to an allowed import source, so a local component that shadows the name is never flagged.',
|
|
72
|
+
},
|
|
73
|
+
importSources: {
|
|
74
|
+
type: 'array',
|
|
75
|
+
items: { type: 'string' },
|
|
76
|
+
description: 'Import sources whose Stack binding this rule governs.',
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
additionalProperties: false,
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
messages: {
|
|
83
|
+
useFlexGapRequired: "`Stack` sets `flexWrap: 'wrap'` with margin-based `spacing`: the wrapped line gets no row gap and a phantom leading indent. Add `useFlexGap` to route `spacing` onto CSS `gap`.",
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
defaultOptions: [
|
|
87
|
+
{
|
|
88
|
+
stackComponents: DEFAULT_STACK_COMPONENTS,
|
|
89
|
+
importSources: DEFAULT_IMPORT_SOURCES,
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
create(context, [options]) {
|
|
93
|
+
const stackComponents = new Set(options.stackComponents ?? DEFAULT_STACK_COMPONENTS);
|
|
94
|
+
const importSources = new Set(options.importSources ?? DEFAULT_IMPORT_SOURCES);
|
|
95
|
+
const sourceCode = context.getSourceCode();
|
|
96
|
+
/**
|
|
97
|
+
* The single expression a name is initialized with, or undefined when the
|
|
98
|
+
* name is not a write-once local. Resolution walks the real scope chain, so
|
|
99
|
+
* a module constant read from inside a component is found while a parameter
|
|
100
|
+
* or any other inner binding of the same name shadows it correctly.
|
|
101
|
+
*
|
|
102
|
+
* A name written more than once does not denote its initializer, so
|
|
103
|
+
* following it would trade a conservative miss for a wrong answer.
|
|
104
|
+
*/
|
|
105
|
+
function initializerOf(node) {
|
|
106
|
+
const scope = ASTHelpers_1.ASTHelpers.getScope(context, node);
|
|
107
|
+
const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, node.name);
|
|
108
|
+
if (!variable || variable.defs.length !== 1) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
const [definition] = variable.defs;
|
|
112
|
+
if (definition.node.type !== utils_1.AST_NODE_TYPES.VariableDeclarator ||
|
|
113
|
+
!definition.node.init) {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
return unwrapAssertions(definition.node.init);
|
|
117
|
+
}
|
|
118
|
+
/** Whether one import binding is MUI's `Stack` under the configured sets. */
|
|
119
|
+
function bindsMuiStack(definition) {
|
|
120
|
+
const declaration = definition.parent;
|
|
121
|
+
if (!declaration ||
|
|
122
|
+
declaration.type !== utils_1.AST_NODE_TYPES.ImportDeclaration ||
|
|
123
|
+
typeof declaration.source.value !== 'string' ||
|
|
124
|
+
!importSources.has(declaration.source.value)) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
const specifier = definition.node;
|
|
128
|
+
if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier) {
|
|
129
|
+
const { imported } = specifier;
|
|
130
|
+
return (imported.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
131
|
+
stackComponents.has(imported.name));
|
|
132
|
+
}
|
|
133
|
+
if (specifier.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier) {
|
|
134
|
+
// The PATH names the component for a default import, which is what makes
|
|
135
|
+
// `import Stack from '@mui/material/Typography'` a non-match.
|
|
136
|
+
return stackComponents.has(finalSegmentOf(declaration.source.value));
|
|
137
|
+
}
|
|
138
|
+
// A namespace import is reached as `<Mui.Stack>`, a member expression this
|
|
139
|
+
// rule does not visit.
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Whether the element name denotes MUI's `Stack` at this point in the file.
|
|
144
|
+
* Every definition must qualify: a name bound both by an import and by a
|
|
145
|
+
* local declaration is not provably the import at the use site.
|
|
146
|
+
*/
|
|
147
|
+
function resolvesToMuiStack(node) {
|
|
148
|
+
const scope = ASTHelpers_1.ASTHelpers.getScope(context, node);
|
|
149
|
+
const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, node.name);
|
|
150
|
+
if (!variable || variable.defs.length === 0) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
return variable.defs.every(bindsMuiStack);
|
|
154
|
+
}
|
|
155
|
+
/** The expression an attribute carries, or undefined for a bare attribute. */
|
|
156
|
+
function attributeValueOf(attribute) {
|
|
157
|
+
const { value } = attribute;
|
|
158
|
+
if (!value) {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
if (value.type === utils_1.AST_NODE_TYPES.Literal) {
|
|
162
|
+
return value;
|
|
163
|
+
}
|
|
164
|
+
if (value.type === utils_1.AST_NODE_TYPES.JSXExpressionContainer) {
|
|
165
|
+
const expression = unwrapAssertions(value.expression);
|
|
166
|
+
return expression.type === utils_1.AST_NODE_TYPES.JSXEmptyExpression
|
|
167
|
+
? undefined
|
|
168
|
+
: expression;
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
/** The static name of an object property key, or undefined when computed. */
|
|
173
|
+
function propertyNameOf(property) {
|
|
174
|
+
const key = unwrapAssertions(property.key);
|
|
175
|
+
if (key.type === utils_1.AST_NODE_TYPES.Identifier && !property.computed) {
|
|
176
|
+
return key.name;
|
|
177
|
+
}
|
|
178
|
+
if (key.type === utils_1.AST_NODE_TYPES.Literal) {
|
|
179
|
+
return typeof key.value === 'string' ? key.value : undefined;
|
|
180
|
+
}
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Whether a `flexWrap` value puts children on more than one line.
|
|
185
|
+
*
|
|
186
|
+
* A responsive object answers true when ANY breakpoint wraps: the seam is
|
|
187
|
+
* real at that width, and the rule deliberately does not reconcile a
|
|
188
|
+
* responsive `flexWrap` against a responsive `spacing` breakpoint by
|
|
189
|
+
* breakpoint.
|
|
190
|
+
*/
|
|
191
|
+
function wrapVerdict(node, seen) {
|
|
192
|
+
if (!node) {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
const target = unwrapAssertions(node);
|
|
196
|
+
switch (target.type) {
|
|
197
|
+
case utils_1.AST_NODE_TYPES.Literal:
|
|
198
|
+
return typeof target.value === 'string'
|
|
199
|
+
? WRAPPING_VALUES.has(target.value)
|
|
200
|
+
: undefined;
|
|
201
|
+
case utils_1.AST_NODE_TYPES.TemplateLiteral:
|
|
202
|
+
return target.expressions.length === 0
|
|
203
|
+
? WRAPPING_VALUES.has(target.quasis[0]?.value.cooked ?? '')
|
|
204
|
+
: undefined;
|
|
205
|
+
case utils_1.AST_NODE_TYPES.Identifier: {
|
|
206
|
+
if (seen.has(target)) {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
seen.add(target);
|
|
210
|
+
return wrapVerdict(initializerOf(target), seen);
|
|
211
|
+
}
|
|
212
|
+
case utils_1.AST_NODE_TYPES.ObjectExpression: {
|
|
213
|
+
let verdict = false;
|
|
214
|
+
for (const property of target.properties) {
|
|
215
|
+
const value = property.type === utils_1.AST_NODE_TYPES.SpreadElement
|
|
216
|
+
? wrapVerdict(property.argument, seen)
|
|
217
|
+
: wrapVerdict(property.value, seen);
|
|
218
|
+
if (value === true) {
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
if (value === undefined) {
|
|
222
|
+
verdict = undefined;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return verdict;
|
|
226
|
+
}
|
|
227
|
+
case utils_1.AST_NODE_TYPES.ConditionalExpression: {
|
|
228
|
+
const consequent = wrapVerdict(target.consequent, seen);
|
|
229
|
+
const alternate = wrapVerdict(target.alternate, seen);
|
|
230
|
+
if (consequent === true || alternate === true) {
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
return consequent === false && alternate === false
|
|
234
|
+
? false
|
|
235
|
+
: undefined;
|
|
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
|
+
}
|
|
245
|
+
default:
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** Whether an object literal states a wrapping `flexWrap`. */
|
|
250
|
+
function objectWraps(object, seen) {
|
|
251
|
+
for (const property of object.properties) {
|
|
252
|
+
if (property.type === utils_1.AST_NODE_TYPES.SpreadElement) {
|
|
253
|
+
// A spread of a local constant is read through; a spread of an
|
|
254
|
+
// imported or caller-supplied value stays opaque, because resolving it
|
|
255
|
+
// would need the cross-module analysis this rule declines.
|
|
256
|
+
if (sxWraps(property.argument, seen)) {
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (propertyNameOf(property) !== 'flexWrap') {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (wrapVerdict(property.value, seen) === true) {
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Whether an `sx` expression states a wrapping `flexWrap` anywhere the rule
|
|
272
|
+
* can read statically. Four of the six wrapping Stacks in the consuming
|
|
273
|
+
* codebase hoist their `sx` to a module constant, including both live
|
|
274
|
+
* violations, so identifier resolution is the path that matters most.
|
|
275
|
+
*/
|
|
276
|
+
function sxWraps(node, seen) {
|
|
277
|
+
if (!node) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
const target = unwrapAssertions(node);
|
|
281
|
+
switch (target.type) {
|
|
282
|
+
case utils_1.AST_NODE_TYPES.ObjectExpression:
|
|
283
|
+
return objectWraps(target, seen);
|
|
284
|
+
case utils_1.AST_NODE_TYPES.Identifier: {
|
|
285
|
+
if (seen.has(target)) {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
seen.add(target);
|
|
289
|
+
return sxWraps(initializerOf(target), seen);
|
|
290
|
+
}
|
|
291
|
+
case utils_1.AST_NODE_TYPES.ArrowFunctionExpression: {
|
|
292
|
+
const body = unwrapAssertions(target.body);
|
|
293
|
+
if (body.type === utils_1.AST_NODE_TYPES.BlockStatement) {
|
|
294
|
+
return body.body.some((statement) => statement.type === utils_1.AST_NODE_TYPES.ReturnStatement &&
|
|
295
|
+
sxWraps(statement.argument ?? undefined, seen));
|
|
296
|
+
}
|
|
297
|
+
return sxWraps(body, seen);
|
|
298
|
+
}
|
|
299
|
+
case utils_1.AST_NODE_TYPES.ConditionalExpression:
|
|
300
|
+
// Either branch renders, so either branch wrapping is a real seam.
|
|
301
|
+
return (sxWraps(target.consequent, seen) || sxWraps(target.alternate, seen));
|
|
302
|
+
case utils_1.AST_NODE_TYPES.ArrayExpression:
|
|
303
|
+
// MUI merges an array of sx entries left to right.
|
|
304
|
+
return target.elements.some((element) => element ? sxWraps(element, seen) : false);
|
|
305
|
+
default:
|
|
306
|
+
// A call expression is opaque. Guessing at what it returns would
|
|
307
|
+
// report on code the rule cannot read.
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Whether a `spacing` value is statically zero. The value is not interpreted
|
|
313
|
+
* further: `spacing={0}` exists precisely so an `sx` gap owns the rhythm,
|
|
314
|
+
* while every non-zero value emits the sibling margins this rule is about.
|
|
315
|
+
*/
|
|
316
|
+
function isZeroSpacing(node, seen) {
|
|
317
|
+
if (!node) {
|
|
318
|
+
// A bare `spacing` attribute is not zero.
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
const target = unwrapAssertions(node);
|
|
322
|
+
if (target.type === utils_1.AST_NODE_TYPES.Literal) {
|
|
323
|
+
return target.value === 0 || target.value === '0';
|
|
324
|
+
}
|
|
325
|
+
if (target.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
326
|
+
if (seen.has(target)) {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
seen.add(target);
|
|
330
|
+
return isZeroSpacing(initializerOf(target), seen);
|
|
331
|
+
}
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* The whitespace run that ends the gap before a node, reused so the inserted
|
|
336
|
+
* attribute lands in the layout the author (and prettier) already chose.
|
|
337
|
+
* Only the whitespace is copied: a comment can sit between two attributes,
|
|
338
|
+
* and carrying the whole gap would duplicate it.
|
|
339
|
+
*/
|
|
340
|
+
function separatorBefore(node) {
|
|
341
|
+
const previous = sourceCode.getTokenBefore(node, {
|
|
342
|
+
includeComments: true,
|
|
343
|
+
});
|
|
344
|
+
const gap = previous
|
|
345
|
+
? sourceCode.text.slice(previous.range[1], node.range[0])
|
|
346
|
+
: ' ';
|
|
347
|
+
const trailing = /\s*$/.exec(gap)?.[0] ?? '';
|
|
348
|
+
const lastBreak = trailing.lastIndexOf('\n');
|
|
349
|
+
return lastBreak === -1 ? ' ' : `\n${trailing.slice(lastBreak + 1)}`;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Inserts the bare `useFlexGap` attribute in alphabetical position, which is
|
|
353
|
+
* how this codebase orders JSX attributes: appending at the end would land a
|
|
354
|
+
* second lint error on top of the fix.
|
|
355
|
+
*
|
|
356
|
+
* The insert joins the run of named attributes FOLLOWING the last spread. A
|
|
357
|
+
* spread can carry `useFlexGap` of its own, and JSX resolves the later
|
|
358
|
+
* writer, so writing after it is what makes the fix take effect.
|
|
359
|
+
*/
|
|
360
|
+
function insertUseFlexGap(fixer, node) {
|
|
361
|
+
const { attributes } = node;
|
|
362
|
+
if (attributes.length === 0) {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
let groupStart = 0;
|
|
366
|
+
attributes.forEach((attribute, index) => {
|
|
367
|
+
if (attribute.type === utils_1.AST_NODE_TYPES.JSXSpreadAttribute) {
|
|
368
|
+
groupStart = index + 1;
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
const successor = attributes
|
|
372
|
+
.slice(groupStart)
|
|
373
|
+
.find((attribute) => attribute.type === utils_1.AST_NODE_TYPES.JSXAttribute &&
|
|
374
|
+
attribute.name.type === utils_1.AST_NODE_TYPES.JSXIdentifier &&
|
|
375
|
+
attribute.name.name > USE_FLEX_GAP);
|
|
376
|
+
if (successor) {
|
|
377
|
+
return fixer.insertTextBefore(successor, `${USE_FLEX_GAP}${separatorBefore(successor)}`);
|
|
378
|
+
}
|
|
379
|
+
const last = attributes[attributes.length - 1];
|
|
380
|
+
return fixer.insertTextAfter(last, `${separatorBefore(last)}${USE_FLEX_GAP}`);
|
|
381
|
+
}
|
|
382
|
+
return {
|
|
383
|
+
JSXOpeningElement(node) {
|
|
384
|
+
const elementName = node.name;
|
|
385
|
+
if (elementName.type !== utils_1.AST_NODE_TYPES.JSXIdentifier) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (!stackComponents.has(elementName.name)) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const named = new Map();
|
|
392
|
+
for (const attribute of node.attributes) {
|
|
393
|
+
if (attribute.type === utils_1.AST_NODE_TYPES.JSXAttribute &&
|
|
394
|
+
attribute.name.type === utils_1.AST_NODE_TYPES.JSXIdentifier) {
|
|
395
|
+
named.set(attribute.name.name, attribute);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// Whatever else it sets, an element that already states the pairing is
|
|
399
|
+
// compliant — including an explicit opt-out, which is a decision rather
|
|
400
|
+
// than an oversight.
|
|
401
|
+
if (named.has(USE_FLEX_GAP)) {
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
// `spacing` is the trigger, never the absence of a gap. A wrapping Stack
|
|
405
|
+
// that reaches past `useFlexGap` for `rowGap` or an `sx` `gap` buys some
|
|
406
|
+
// separation and leaves the phantom indent in place, so neither shape
|
|
407
|
+
// can exempt.
|
|
408
|
+
const spacing = named.get('spacing');
|
|
409
|
+
if (!spacing || isZeroSpacing(attributeValueOf(spacing), new Set())) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const flexWrap = named.get('flexWrap');
|
|
413
|
+
const attributeVerdict = flexWrap
|
|
414
|
+
? wrapVerdict(attributeValueOf(flexWrap), new Set())
|
|
415
|
+
: undefined;
|
|
416
|
+
const sx = named.get('sx');
|
|
417
|
+
// The attribute wins where both spell `flexWrap` and disagree; an
|
|
418
|
+
// unreadable attribute is not a disagreement, so it falls through.
|
|
419
|
+
const wraps = attributeVerdict ??
|
|
420
|
+
(sx ? sxWraps(attributeValueOf(sx), new Set()) : false);
|
|
421
|
+
if (!wraps) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (!resolvesToMuiStack(elementName)) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
context.report({
|
|
428
|
+
node: elementName,
|
|
429
|
+
messageId: 'useFlexGapRequired',
|
|
430
|
+
fix: (fixer) => insertUseFlexGap(fixer, node),
|
|
431
|
+
});
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
//# sourceMappingURL=enforce-use-flex-gap-on-wrap.js.map
|
|
@@ -372,6 +372,7 @@ const ALLOWLIST = {
|
|
|
372
372
|
'bottle',
|
|
373
373
|
'bottom',
|
|
374
374
|
'bounce',
|
|
375
|
+
'bound',
|
|
375
376
|
'bow',
|
|
376
377
|
'box',
|
|
377
378
|
'boycott',
|
|
@@ -1126,6 +1127,7 @@ const ALLOWLIST = {
|
|
|
1126
1127
|
'electron',
|
|
1127
1128
|
'elevate',
|
|
1128
1129
|
'elicit',
|
|
1130
|
+
'elide',
|
|
1129
1131
|
'eliminate',
|
|
1130
1132
|
'elongate',
|
|
1131
1133
|
'emancipate',
|
|
@@ -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 {
|
|
@@ -407,6 +407,64 @@ const sxSlotOf = (sxAttr) => {
|
|
|
407
407
|
}
|
|
408
408
|
return { kind: 'spread', expression: value };
|
|
409
409
|
};
|
|
410
|
+
/**
|
|
411
|
+
* The property names an `sx` object literal declares, or null when one of its
|
|
412
|
+
* keys is not statically readable. A computed key built from anything but a
|
|
413
|
+
* literal resolves to a name only at runtime, so it is reported as unknown
|
|
414
|
+
* rather than as no key at all, and the caller reads that as a possible
|
|
415
|
+
* collision with every moved prop.
|
|
416
|
+
*
|
|
417
|
+
* A spread's own members are deliberately not counted: the moved props are
|
|
418
|
+
* spliced in as new members of this literal, and a name the spread happens to
|
|
419
|
+
* carry is not duplicated by that splice — it is overridden, exactly as any
|
|
420
|
+
* other member written beside the spread overrides it.
|
|
421
|
+
*/
|
|
422
|
+
const declaredKeysOf = (object) => {
|
|
423
|
+
const keys = new Set();
|
|
424
|
+
for (const property of object.properties) {
|
|
425
|
+
if (property.type !== utils_1.AST_NODE_TYPES.Property) {
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
const { key } = property;
|
|
429
|
+
if (!property.computed && key.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
430
|
+
keys.add(key.name);
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
// A literal key is readable whether or not it is written computed:
|
|
434
|
+
// `'display'` and `['display']` both name the same property.
|
|
435
|
+
if (key.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
436
|
+
(typeof key.value === 'string' || typeof key.value === 'number')) {
|
|
437
|
+
keys.add(String(key.value));
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
return keys;
|
|
443
|
+
};
|
|
444
|
+
/**
|
|
445
|
+
* The moved props whose name the `sx` object literal already declares. Splicing
|
|
446
|
+
* one in emits `{ display: 'flex', display: 'block' }` — TS1117, and whichever
|
|
447
|
+
* value the runtime keeps, one of the two spellings the author wrote is
|
|
448
|
+
* discarded. The two disagree and only the author can say which wins, so the
|
|
449
|
+
* fix stands down for those props while every other prop on the element still
|
|
450
|
+
* merges (#2296).
|
|
451
|
+
*
|
|
452
|
+
* Only the object slot is merged into in place. A new `sx`, an array entry and
|
|
453
|
+
* the `{ ...moved, ...expr }` wrap each emit a fresh object literal, whose keys
|
|
454
|
+
* cannot duplicate a name written elsewhere.
|
|
455
|
+
*/
|
|
456
|
+
const collidingPropsOf = (systemPropAttrs, sxAttr) => {
|
|
457
|
+
const slot = sxSlotOf(sxAttr);
|
|
458
|
+
if (slot.kind !== 'object') {
|
|
459
|
+
return new Set();
|
|
460
|
+
}
|
|
461
|
+
const declared = declaredKeysOf(slot.object);
|
|
462
|
+
if (declared === null) {
|
|
463
|
+
return new Set(systemPropAttrs);
|
|
464
|
+
}
|
|
465
|
+
return new Set(systemPropAttrs.filter((attr) => attr.name.type === utils_1.AST_NODE_TYPES.JSXIdentifier &&
|
|
466
|
+
declared.has(attr.name.name)));
|
|
467
|
+
};
|
|
410
468
|
/**
|
|
411
469
|
* Plans every edit the autofix makes for one JSX element.
|
|
412
470
|
*
|
|
@@ -1000,9 +1058,16 @@ exports.preferSxPropOverSystemProps = (0, createRule_1.createRule)({
|
|
|
1000
1058
|
if (systemPropAttrs.length === 0)
|
|
1001
1059
|
return;
|
|
1002
1060
|
const sourceCode = context.getSourceCode();
|
|
1003
|
-
//
|
|
1004
|
-
//
|
|
1005
|
-
|
|
1061
|
+
// A prop whose name the `sx` literal already declares is reported
|
|
1062
|
+
// without a fix: merging it would duplicate the key. The rest of the
|
|
1063
|
+
// element is still merged, so one disagreeing pair does not hold the
|
|
1064
|
+
// other props back.
|
|
1065
|
+
const collidingProps = collidingPropsOf(systemPropAttrs, sxAttr);
|
|
1066
|
+
const fixableAttrs = systemPropAttrs.filter((attr) => !collidingProps.has(attr));
|
|
1067
|
+
// Report each system prop. Only the first fixable one carries the fixer
|
|
1068
|
+
// to avoid overlapping fix ranges on the same element.
|
|
1069
|
+
const fixAnchor = fixableAttrs[0] ?? null;
|
|
1070
|
+
systemPropAttrs.forEach((attr) => {
|
|
1006
1071
|
const propName = attr.name.type === utils_1.AST_NODE_TYPES.JSXIdentifier
|
|
1007
1072
|
? attr.name.name
|
|
1008
1073
|
: '';
|
|
@@ -1010,8 +1075,8 @@ exports.preferSxPropOverSystemProps = (0, createRule_1.createRule)({
|
|
|
1010
1075
|
node: attr,
|
|
1011
1076
|
messageId: 'preferSxProp',
|
|
1012
1077
|
data: { prop: propName },
|
|
1013
|
-
fix:
|
|
1014
|
-
? (fixer) => planSxEdits(sourceCode, node,
|
|
1078
|
+
fix: attr === fixAnchor
|
|
1079
|
+
? (fixer) => planSxEdits(sourceCode, node, fixableAttrs, sxAttr, printWidth).map((edit) => fixer.replaceTextRange(edit.range, edit.text))
|
|
1015
1080
|
: null,
|
|
1016
1081
|
});
|
|
1017
1082
|
});
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,48 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.21.1",
|
|
4
|
+
"date": "2026-09-02T22:30:34.104Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "prefer-spread-over-reassembly",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
2298
|
|
11
|
+
],
|
|
12
|
+
"summary": "apply the spread only where the pick is proven exhaustive (closes #2298)"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "1.21.0",
|
|
18
|
+
"date": "2026-09-02T18:04:18.331Z",
|
|
19
|
+
"rules": [
|
|
20
|
+
{
|
|
21
|
+
"name": "enforce-use-flex-gap-on-wrap",
|
|
22
|
+
"changeType": "feat",
|
|
23
|
+
"issues": [
|
|
24
|
+
2289
|
|
25
|
+
],
|
|
26
|
+
"summary": "flag a wrapping Stack that spaces with spacing but omits useFlexGap (closes #2289)"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "enforce-verb-noun-naming",
|
|
30
|
+
"changeType": "fix",
|
|
31
|
+
"issues": [
|
|
32
|
+
2295
|
|
33
|
+
],
|
|
34
|
+
"summary": "admit elide and bound as verbs (closes #2295)"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "prefer-sx-prop-over-system-props",
|
|
38
|
+
"changeType": "fix",
|
|
39
|
+
"issues": [
|
|
40
|
+
2296
|
|
41
|
+
],
|
|
42
|
+
"summary": "decline the fix when the sx literal already declares the moved key (closes #2296)"
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
},
|
|
2
46
|
{
|
|
3
47
|
"version": "1.20.202",
|
|
4
48
|
"date": "2026-09-02T04:11:09.443Z",
|