@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 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.20.202',
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
- // Report each system prop. Only the first carries the fixer to avoid
1004
- // overlapping fix ranges on the same element.
1005
- systemPropAttrs.forEach((attr, index) => {
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: index === 0
1014
- ? (fixer) => planSxEdits(sourceCode, node, systemPropAttrs, sxAttr, printWidth).map((edit) => fixer.replaceTextRange(edit.range, edit.text))
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.202",
3
+ "version": "1.21.0",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -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",