@blumintinc/eslint-plugin-blumint 1.20.202 → 1.21.0
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 -0
- 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-sx-prop-over-system-props.js +70 -5
- package/package.json +1 -1
- package/release-manifest.json +30 -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 | ✅ | | | | |
|
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.0',
|
|
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',
|
|
@@ -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,34 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.21.0",
|
|
4
|
+
"date": "2026-09-02T18:04:18.331Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "enforce-use-flex-gap-on-wrap",
|
|
8
|
+
"changeType": "feat",
|
|
9
|
+
"issues": [
|
|
10
|
+
2289
|
|
11
|
+
],
|
|
12
|
+
"summary": "flag a wrapping Stack that spaces with spacing but omits useFlexGap (closes #2289)"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "enforce-verb-noun-naming",
|
|
16
|
+
"changeType": "fix",
|
|
17
|
+
"issues": [
|
|
18
|
+
2295
|
|
19
|
+
],
|
|
20
|
+
"summary": "admit elide and bound as verbs (closes #2295)"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "prefer-sx-prop-over-system-props",
|
|
24
|
+
"changeType": "fix",
|
|
25
|
+
"issues": [
|
|
26
|
+
2296
|
|
27
|
+
],
|
|
28
|
+
"summary": "decline the fix when the sx literal already declares the moved key (closes #2296)"
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
},
|
|
2
32
|
{
|
|
3
33
|
"version": "1.20.202",
|
|
4
34
|
"date": "2026-09-02T04:11:09.443Z",
|