@blumintinc/eslint-plugin-blumint 1.21.17 → 1.22.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/lib/index.js CHANGED
@@ -224,7 +224,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
224
224
  module.exports = {
225
225
  meta: {
226
226
  name: '@blumintinc/eslint-plugin-blumint',
227
- version: '1.21.17',
227
+ version: '1.22.0',
228
228
  },
229
229
  parseOptions: {
230
230
  ecmaVersion: 2020,
@@ -1,9 +1,23 @@
1
1
  import { TSESLint } from '@typescript-eslint/utils';
2
+ /**
3
+ * The emptiness call the fixer emits, and where its binding comes from.
4
+ *
5
+ * Recognition and EMISSION are separate settings on purpose. `emptyCheckFunctions`
6
+ * is an allowlist of spellings the rule accepts and carries no import path, so
7
+ * reusing it to emit would write a bare call to an identifier the file does not
8
+ * import, and — since it defaults to `isEmpty` — would silently change what every
9
+ * existing consumer's `--fix` produces (#2360).
10
+ */
11
+ type EmptyCheckFix = {
12
+ name: string;
13
+ importPath?: string;
14
+ };
2
15
  type Options = [
3
16
  {
4
17
  objectNamePattern?: string[];
5
18
  ignoreInLoops?: boolean;
6
19
  emptyCheckFunctions?: string[];
20
+ emptyCheckFix?: EmptyCheckFix;
7
21
  printWidth?: number;
8
22
  }
9
23
  ];
@@ -28,6 +28,7 @@ const utils_1 = require("@typescript-eslint/utils");
28
28
  const ts = __importStar(require("typescript"));
29
29
  const createRule_1 = require("../utils/createRule");
30
30
  const ASTHelpers_1 = require("../utils/ASTHelpers");
31
+ const importInsertion_1 = require("../utils/importInsertion");
31
32
  const DEFAULT_OBJECT_SUFFIXES = [
32
33
  'Config',
33
34
  'Configs',
@@ -79,6 +80,20 @@ const NON_OBJECT_LIKE_NAMES = [
79
80
  'array',
80
81
  'arr',
81
82
  ];
83
+ /**
84
+ * The emptiness operand the fixer writes beside the falsiness check.
85
+ *
86
+ * `Object.keys(...).length === 0` needs nothing in scope, so it stays the
87
+ * default and every consumer that has not opted in keeps byte-identical output.
88
+ * A consumer whose config BANS the `Object` accessors names a helper instead,
89
+ * without which `--fix` has no fixed point: the rewrite it just wrote is
90
+ * rejected by the same run that produced it (#2360).
91
+ */
92
+ function buildEmptyCheckText(identifierText, emptyCheckFix) {
93
+ return emptyCheckFix
94
+ ? `${emptyCheckFix.name}(${identifierText})`
95
+ : `Object.keys(${identifierText}).length === 0`;
96
+ }
82
97
  function hasBooleanPrefixBoundary(name) {
83
98
  const lower = name.toLowerCase();
84
99
  return BOOLEAN_PREFIXES.some((prefix) => {
@@ -1163,7 +1178,7 @@ function getDeclarationStatement(declaration) {
1163
1178
  * width was measured (#2095).
1164
1179
  */
1165
1180
  function planWidenedFix(input) {
1166
- const { sourceCode, target, identifierText, needsParentheses } = input;
1181
+ const { sourceCode, target, identifierText, emptyCheckText, needsParentheses, } = input;
1167
1182
  const source = sourceCode.getText();
1168
1183
  const root = getLayoutRoot(target);
1169
1184
  const parent = root.parent;
@@ -1175,7 +1190,7 @@ function planWidenedFix(input) {
1175
1190
  operator: '||',
1176
1191
  parts: [
1177
1192
  { kind: 'leaf', text: `${target.operator}${identifierText}` },
1178
- { kind: 'leaf', text: `Object.keys(${identifierText}).length === 0` },
1193
+ { kind: 'leaf', text: emptyCheckText },
1179
1194
  ],
1180
1195
  };
1181
1196
  const docContext = {
@@ -1514,6 +1529,150 @@ function planReturnFix(root, parent, flat, doc, { sourceCode, printWidth }, sour
1514
1529
  }
1515
1530
  return { range: [root.range[0], root.range[1]], text: lines.join('\n') };
1516
1531
  }
1532
+ /** Every name a binding pattern introduces. */
1533
+ function collectPatternNames(pattern, names) {
1534
+ switch (pattern.type) {
1535
+ case utils_1.AST_NODE_TYPES.Identifier:
1536
+ names.add(pattern.name);
1537
+ return;
1538
+ case utils_1.AST_NODE_TYPES.ObjectPattern:
1539
+ for (const property of pattern.properties) {
1540
+ collectPatternNames(property.type === utils_1.AST_NODE_TYPES.RestElement
1541
+ ? property.argument
1542
+ : property.value, names);
1543
+ }
1544
+ return;
1545
+ case utils_1.AST_NODE_TYPES.ArrayPattern:
1546
+ for (const element of pattern.elements) {
1547
+ if (element) {
1548
+ collectPatternNames(element, names);
1549
+ }
1550
+ }
1551
+ return;
1552
+ case utils_1.AST_NODE_TYPES.AssignmentPattern:
1553
+ collectPatternNames(pattern.left, names);
1554
+ return;
1555
+ case utils_1.AST_NODE_TYPES.RestElement:
1556
+ collectPatternNames(pattern.argument, names);
1557
+ return;
1558
+ default:
1559
+ }
1560
+ }
1561
+ /**
1562
+ * The names already declared at the module's top level.
1563
+ *
1564
+ * An import is only safe to insert when the name is free: a second binding of a
1565
+ * name the file already declares — imported from anywhere, or declared locally —
1566
+ * is a redeclaration error, which is a worse outcome than the lint warning the
1567
+ * insertion was meant to avoid.
1568
+ */
1569
+ function moduleBindingNames(program) {
1570
+ const names = new Set();
1571
+ for (const statement of program.body) {
1572
+ const node = statement.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration ||
1573
+ statement.type === utils_1.AST_NODE_TYPES.ExportDefaultDeclaration
1574
+ ? statement.declaration
1575
+ : statement;
1576
+ if (!node) {
1577
+ continue;
1578
+ }
1579
+ if (node.type === utils_1.AST_NODE_TYPES.ImportDeclaration) {
1580
+ for (const specifier of node.specifiers) {
1581
+ names.add(specifier.local.name);
1582
+ }
1583
+ continue;
1584
+ }
1585
+ if (node.type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
1586
+ for (const declarator of node.declarations) {
1587
+ collectPatternNames(declarator.id, names);
1588
+ }
1589
+ continue;
1590
+ }
1591
+ if ((node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
1592
+ node.type === utils_1.AST_NODE_TYPES.ClassDeclaration ||
1593
+ node.type === utils_1.AST_NODE_TYPES.TSEnumDeclaration ||
1594
+ node.type === utils_1.AST_NODE_TYPES.TSInterfaceDeclaration ||
1595
+ node.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration ||
1596
+ node.type === utils_1.AST_NODE_TYPES.TSModuleDeclaration ||
1597
+ node.type === utils_1.AST_NODE_TYPES.TSDeclareFunction ||
1598
+ node.type === utils_1.AST_NODE_TYPES.TSImportEqualsDeclaration) &&
1599
+ node.id?.type === utils_1.AST_NODE_TYPES.Identifier) {
1600
+ names.add(node.id.name);
1601
+ }
1602
+ }
1603
+ return names;
1604
+ }
1605
+ /** A module specifier written as source text, with its quotes escaped. */
1606
+ function quoteModuleSpecifier(importPath) {
1607
+ return `'${importPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
1608
+ }
1609
+ /**
1610
+ * The edit that brings the emitted helper into scope, or null when nothing is
1611
+ * needed.
1612
+ *
1613
+ * A helper call the file cannot resolve trades a lint warning for an undefined
1614
+ * identifier, so emission and import travel together in ONE fixer. Four
1615
+ * outcomes are distinguished, because they are four different mistakes:
1616
+ *
1617
+ * * the name is already bound — insert nothing, whatever path it came from,
1618
+ * since a second binding would not compile;
1619
+ * * a value import from the same path already has a named-specifier list — join
1620
+ * it, so the file keeps one declaration per module;
1621
+ * * a file with imports gains a declaration after the LAST of them, which is
1622
+ * past every prologue by construction;
1623
+ * * a file with none defers to `importInsertion`, whose anchor owns the
1624
+ * placement a raw offset gets wrong: a `'use client'` demoted out of the
1625
+ * prologue, a `#!` pushed off line 1, or a line-binding suppression comment
1626
+ * severed from the line it covers.
1627
+ */
1628
+ function planImportInsertion(sourceCode, emptyCheckFix) {
1629
+ const { name, importPath } = emptyCheckFix;
1630
+ if (!importPath) {
1631
+ return null;
1632
+ }
1633
+ const program = sourceCode.ast;
1634
+ if (moduleBindingNames(program).has(name)) {
1635
+ return null;
1636
+ }
1637
+ const imports = program.body.filter((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
1638
+ for (const declaration of imports) {
1639
+ /** A type-only import carries no value binding for the call to resolve. */
1640
+ if (declaration.importKind === 'type' ||
1641
+ declaration.source.value !== importPath) {
1642
+ continue;
1643
+ }
1644
+ const specifiers = declaration.specifiers.filter((specifier) => specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier);
1645
+ const last = specifiers[specifiers.length - 1];
1646
+ if (!last) {
1647
+ continue;
1648
+ }
1649
+ /**
1650
+ * A specifier list Prettier has already broken keeps one name per row, so
1651
+ * joining it inline would emit a layout the next format run undoes.
1652
+ */
1653
+ const indent = declaration.loc.start.line === declaration.loc.end.line
1654
+ ? null
1655
+ : getLineIndent(last, sourceCode);
1656
+ const text = indent === null ? `, ${name}` : `,\n${indent}${name}`;
1657
+ return {
1658
+ offset: last.range[1],
1659
+ apply: (fixer) => fixer.insertTextAfter(last, text),
1660
+ };
1661
+ }
1662
+ const statement = `import { ${name} } from ${quoteModuleSpecifier(importPath)};`;
1663
+ const lastImport = imports[imports.length - 1];
1664
+ if (lastImport) {
1665
+ return {
1666
+ offset: lastImport.range[1],
1667
+ apply: (fixer) => fixer.insertTextAfter(lastImport, `\n${statement}`),
1668
+ };
1669
+ }
1670
+ const anchor = (0, importInsertion_1.importInsertionAnchor)(sourceCode);
1671
+ return {
1672
+ offset: anchor.kind === 'before' ? anchor.target.range[0] : anchor.index,
1673
+ apply: (fixer) => (0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, anchor, `${statement}\n`),
1674
+ };
1675
+ }
1517
1676
  exports.enforceEmptyObjectCheck = (0, createRule_1.createRule)({
1518
1677
  name: 'enforce-empty-object-check',
1519
1678
  meta: {
@@ -1538,6 +1697,20 @@ exports.enforceEmptyObjectCheck = (0, createRule_1.createRule)({
1538
1697
  type: 'array',
1539
1698
  items: { type: 'string' },
1540
1699
  },
1700
+ emptyCheckFix: {
1701
+ type: 'object',
1702
+ properties: {
1703
+ /**
1704
+ * A bare identifier, so the emitted call is the shape the
1705
+ * recognition arm accepts — anything else would leave `--fix`
1706
+ * rewriting text it reports again on the next pass.
1707
+ */
1708
+ name: { type: 'string', pattern: '^[A-Za-z_$][A-Za-z0-9_$]*$' },
1709
+ importPath: { type: 'string', minLength: 1 },
1710
+ },
1711
+ required: ['name'],
1712
+ additionalProperties: false,
1713
+ },
1541
1714
  printWidth: {
1542
1715
  type: 'number',
1543
1716
  minimum: 1,
@@ -1556,7 +1729,7 @@ exports.enforceEmptyObjectCheck = (0, createRule_1.createRule)({
1556
1729
  const parserServices = sourceCode.parserServices;
1557
1730
  const checker = parserServices?.program?.getTypeChecker();
1558
1731
  const options = context.options[0] ?? {};
1559
- const { objectNamePattern = [], ignoreInLoops = false, emptyCheckFunctions = [], } = options;
1732
+ const { objectNamePattern = [], ignoreInLoops = false, emptyCheckFunctions = [], emptyCheckFix, } = options;
1560
1733
  const printWidth = typeof options.printWidth === 'number' && options.printWidth > 0
1561
1734
  ? options.printWidth
1562
1735
  : DEFAULT_PRINT_WIDTH;
@@ -1564,9 +1737,16 @@ exports.enforceEmptyObjectCheck = (0, createRule_1.createRule)({
1564
1737
  ...DEFAULT_OBJECT_SUFFIXES,
1565
1738
  ...objectNamePattern,
1566
1739
  ]);
1740
+ /**
1741
+ * The emitted helper counts as a satisfying check even when it is absent
1742
+ * from `emptyCheckFunctions`. Without this the fixer would report its own
1743
+ * output, so `--fix` would rewrite the same guard on every pass — the
1744
+ * fixed point this option exists to restore (#2360).
1745
+ */
1567
1746
  const emptyCheckFunctionsSet = new Set([
1568
1747
  ...DEFAULT_EMPTY_CHECK_FUNCTIONS,
1569
1748
  ...emptyCheckFunctions,
1749
+ ...(emptyCheckFix ? [emptyCheckFix.name] : []),
1570
1750
  ]);
1571
1751
  const processedExpressions = new WeakSet();
1572
1752
  const processedNegations = new WeakSet();
@@ -1883,6 +2063,31 @@ exports.enforceEmptyObjectCheck = (0, createRule_1.createRule)({
1883
2063
  }
1884
2064
  return isObjectLikeName(identifier.name, patternSet);
1885
2065
  }
2066
+ /**
2067
+ * Whether a binding between the report site and the module scope would
2068
+ * capture the emitted helper call.
2069
+ *
2070
+ * A bare call resolves wherever the CALL sits, so an inner binding of the
2071
+ * same name silently takes it over — the rewrite compiles, reads
2072
+ * correctly, and calls something else entirely (#1455, #1456 are this
2073
+ * shape). No spelling of the call escapes that, so the fix is declined
2074
+ * and the report stands on its own; the message already says what to
2075
+ * write.
2076
+ *
2077
+ * The unconfigured emission is untouched by this: `Object` is a global
2078
+ * rather than a module binding, and its output is a compatibility
2079
+ * contract (#2360).
2080
+ */
2081
+ function emissionIsShadowed(node, name) {
2082
+ let scope = ASTHelpers_1.ASTHelpers.getScope(context, node);
2083
+ while (scope) {
2084
+ if (scope.variables.some((variable) => variable.name === name)) {
2085
+ return scope.type !== 'module' && scope.type !== 'global';
2086
+ }
2087
+ scope = scope.upper;
2088
+ }
2089
+ return false;
2090
+ }
1886
2091
  function reportNegation(node, identifier) {
1887
2092
  if (processedNegations.has(node)) {
1888
2093
  return;
@@ -1906,8 +2111,12 @@ exports.enforceEmptyObjectCheck = (0, createRule_1.createRule)({
1906
2111
  name: identifier.name,
1907
2112
  },
1908
2113
  fix(fixer) {
2114
+ if (emptyCheckFix && emissionIsShadowed(node, emptyCheckFix.name)) {
2115
+ return null;
2116
+ }
1909
2117
  const identifierText = sourceCode.getText(identifier);
1910
- const guard = `${node.operator}${identifierText} || Object.keys(${identifierText}).length === 0`;
2118
+ const emptyCheckText = buildEmptyCheckText(identifierText, emptyCheckFix);
2119
+ const guard = `${node.operator}${identifierText} || ${emptyCheckText}`;
1911
2120
  const needsParentheses = replacementNeedsParentheses(node, sourceCode);
1912
2121
  const replacement = needsParentheses ? `(${guard})` : guard;
1913
2122
  /**
@@ -1923,13 +2132,27 @@ exports.enforceEmptyObjectCheck = (0, createRule_1.createRule)({
1923
2132
  sourceCode,
1924
2133
  target: node,
1925
2134
  identifierText,
2135
+ emptyCheckText,
1926
2136
  needsParentheses,
1927
2137
  printWidth,
1928
2138
  });
1929
- if (widened) {
1930
- return fixer.replaceTextRange(widened.range, widened.text);
2139
+ const guardEdit = widened ?? {
2140
+ range: node.range,
2141
+ text: replacement,
2142
+ };
2143
+ const importFix = emptyCheckFix
2144
+ ? planImportInsertion(sourceCode, emptyCheckFix)
2145
+ : null;
2146
+ if (!importFix) {
2147
+ return fixer.replaceTextRange(guardEdit.range, guardEdit.text);
1931
2148
  }
1932
- return fixer.replaceText(node, replacement);
2149
+ const guardFix = {
2150
+ offset: guardEdit.range[0],
2151
+ apply: (target) => target.replaceTextRange(guardEdit.range, guardEdit.text),
2152
+ };
2153
+ return [importFix, guardFix]
2154
+ .sort((left, right) => left.offset - right.offset)
2155
+ .map((edit) => edit.apply(fixer));
1933
2156
  },
1934
2157
  });
1935
2158
  }
@@ -1 +1,8 @@
1
- export declare const noCompositingLayerProps: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"compositingLayer", [], import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
1
+ type Options = [
2
+ {
3
+ exemptInteractionStates?: boolean;
4
+ exemptStateTransitions?: boolean;
5
+ }
6
+ ];
7
+ export declare const noCompositingLayerProps: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"compositingLayer", Options, import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
8
+ export {};
@@ -15,6 +15,10 @@ function normalizePropertyName(name) {
15
15
  // Convert camelCase to kebab-case
16
16
  return toKebabCase(name).toLowerCase();
17
17
  }
18
+ // `scale`, `rotate` and `translate` are the individual transform properties.
19
+ // They promote a layer exactly as `transform` does, so leaving them out would
20
+ // let a declaration the exemptions below refuse under `transform` through
21
+ // under another spelling.
18
22
  const COMPOSITING_PROPERTIES = new Set([
19
23
  'filter',
20
24
  'backdrop-filter',
@@ -25,6 +29,21 @@ const COMPOSITING_PROPERTIES = new Set([
25
29
  'contain',
26
30
  'mix-blend-mode',
27
31
  'opacity',
32
+ 'scale',
33
+ 'rotate',
34
+ 'translate',
35
+ ]);
36
+ // The only properties the interaction-state and state-transition exemptions
37
+ // may clear. Every other compositing property (`filter`, `backdrop-filter`,
38
+ // `will-change`, `mix-blend-mode`, `perspective`, `backface-visibility`,
39
+ // `contain`) stays reported in every context: none of them is the cheap,
40
+ // compositor-only animation the exemptions exist to allow.
41
+ const EXEMPTABLE_PROPERTIES = new Set([
42
+ 'transform',
43
+ 'opacity',
44
+ 'scale',
45
+ 'rotate',
46
+ 'translate',
28
47
  ]);
29
48
  // Vendor-prefixed spellings name the same CSS property as their unprefixed
30
49
  // counterpart: `WebkitTransform` normalizes to `-webkit-transform`, `msTransform`
@@ -32,13 +51,10 @@ const COMPOSITING_PROPERTIES = new Set([
32
51
  function stripVendorPrefix(normalizedName) {
33
52
  return normalizedName.replace(/^-?(?:webkit|moz|ms|o)-/, '');
34
53
  }
35
- // 3D transform functions promote a layer, but only when applied through a
36
- // property whose value grammar accepts a transform function.
37
- const COMPOSITING_TRANSFORM_FUNCTIONS = [
38
- 'translate3d',
39
- 'scale3d',
40
- 'translateZ',
41
- ];
54
+ // 3D transform functions promote a layer on their own, whatever state the
55
+ // element is in, so they are never exempt. Matched case-insensitively and only
56
+ // as a function call, so a class name or prose containing the word is inert.
57
+ const THREE_D_TRANSFORM_FUNCTION = /\b(?:translate3d|translatez|scale3d|scalez|rotate3d|rotatex|rotatey|rotatez|matrix3d|perspective)\s*\(/i;
42
58
  // `transform` is the only property that takes a transform function as its
43
59
  // value, so it is the whole key gate for the value-driven arm. A value can only
44
60
  // act through a property that accepts it; ungated, any key at all was named as
@@ -58,6 +74,9 @@ const NON_COMPOSITING_VALUES = {
58
74
  filter: new Set(['none']),
59
75
  'backdrop-filter': new Set(['none']),
60
76
  transform: new Set(['none']),
77
+ scale: new Set(['none', '1']),
78
+ rotate: new Set(['none']),
79
+ translate: new Set(['none']),
61
80
  contain: new Set(['none']),
62
81
  perspective: new Set(['none']),
63
82
  'will-change': new Set(['auto', 'unset', 'initial', 'inherit', 'revert']),
@@ -67,6 +86,461 @@ const NON_COMPOSITING_VALUES = {
67
86
  // it. `inherit` is excluded: it can resolve to a blending value set higher up.
68
87
  'mix-blend-mode': new Set(['normal', 'initial', 'unset', 'revert']),
69
88
  };
89
+ // Stands in for the element the style object applies to while nested selector
90
+ // keys are composed, so "on the `&` compound or to its right" is decidable
91
+ // after `&` has been substituted away.
92
+ const ROOT_MARKER = '\u0000';
93
+ // Stands in for an interpolated `${...}` inside a selector key. It matches no
94
+ // qualifier pattern, so an unreadable fragment can never qualify a selector.
95
+ const INTERPOLATION_MARKER = '\u0001';
96
+ // A pseudo-class or class that one element holds at a time (the pointer's
97
+ // target, the pressed element, the focused element, and their ancestors). A
98
+ // transform under it can never promote a whole list at once.
99
+ const INTERACTION_PSEUDO_CLASS = /:(?:hover|active|focus|focus-visible|focus-within)(?![\w-])/i;
100
+ const INTERACTION_MUI_CLASS = /\.Mui-(?:focusVisible|focused|active)(?![\w-])/;
101
+ // A qualifier that flips at runtime rather than describing a static variant:
102
+ // MUI's global state classes (`.Mui-checked`, `.Mui-expanded`, ...), state
103
+ // pseudo-classes, and ARIA/data/boolean state attributes.
104
+ const STATE_PSEUDO_CLASS = /:(?:hover|active|focus|focus-visible|focus-within|checked|disabled|enabled|indeterminate|open|popover-open|target|visited|invalid|valid|user-invalid|user-valid|placeholder-shown|autofill|read-only|read-write|fullscreen)(?![\w-])/i;
105
+ const STATE_MUI_CLASS = /\.Mui-[a-z][\w]*(?![\w-])/;
106
+ const STATE_ATTRIBUTE = /\[\s*(?:aria-[\w-]+|data-[\w-]+|open|hidden|disabled|checked|selected)\s*(?:[~|^$*]?=[^\]]*)?\]/i;
107
+ const TRANSITION_PROPERTIES = new Set(['transition', 'transition-property']);
108
+ const TRANSITION_TIME = /^[+-]?(?:\d+\.?\d*|\.\d+)m?s$/;
109
+ const BARE_NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)$/;
110
+ const TRANSITION_KEYWORDS = new Set([
111
+ 'ease',
112
+ 'ease-in',
113
+ 'ease-out',
114
+ 'ease-in-out',
115
+ 'linear',
116
+ 'step-start',
117
+ 'step-end',
118
+ 'normal',
119
+ 'allow-discrete',
120
+ ]);
121
+ const GLOBAL_KEYWORDS = new Set([
122
+ 'inherit',
123
+ 'initial',
124
+ 'unset',
125
+ 'revert',
126
+ 'revert-layer',
127
+ ]);
128
+ // Lexical iteration: a state-driven transform declared inside one of these
129
+ // callbacks styles every rendered sibling, and many siblings can be "on" at
130
+ // once, so the state-transition exemption is withheld there.
131
+ const ITERATION_METHODS = new Set(['map', 'flatMap', 'forEach']);
132
+ // Render props the host calls once per item. Every `render*` prop is treated
133
+ // as one because the rule cannot tell `renderOption` from `renderInput`, and
134
+ // withholding the exemption only keeps the pre-existing report.
135
+ const ITEM_RENDER_PROP = /^(?:render[A-Z0-9_]\w*|itemContent|\w+Renderer)$/;
136
+ // A template literal with no substitutions spells a literal exactly, so it is
137
+ // read as one; any other non-literal value is unreadable (`null`).
138
+ function readStaticText(node) {
139
+ if (node.type === utils_1.AST_NODE_TYPES.Literal) {
140
+ return String(node.value);
141
+ }
142
+ if (node.type === utils_1.AST_NODE_TYPES.TemplateLiteral &&
143
+ node.expressions.length === 0) {
144
+ return node.quasis[0].value.cooked ?? node.quasis[0].value.raw;
145
+ }
146
+ return null;
147
+ }
148
+ // Every literal fragment an expression can evaluate to or embed, so a 3D
149
+ // transform function is found even inside a ternary or an interpolation.
150
+ function collectStaticFragments(node) {
151
+ switch (node.type) {
152
+ case utils_1.AST_NODE_TYPES.Literal:
153
+ return typeof node.value === 'string' ? [node.value] : [];
154
+ case utils_1.AST_NODE_TYPES.TemplateLiteral:
155
+ return [
156
+ ...node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw),
157
+ ...node.expressions.flatMap(collectStaticFragments),
158
+ ];
159
+ case utils_1.AST_NODE_TYPES.ConditionalExpression:
160
+ return [
161
+ ...collectStaticFragments(node.consequent),
162
+ ...collectStaticFragments(node.alternate),
163
+ ];
164
+ case utils_1.AST_NODE_TYPES.LogicalExpression:
165
+ return [
166
+ ...collectStaticFragments(node.left),
167
+ ...collectStaticFragments(node.right),
168
+ ];
169
+ case utils_1.AST_NODE_TYPES.TSAsExpression:
170
+ case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
171
+ case utils_1.AST_NODE_TYPES.TSNonNullExpression:
172
+ case utils_1.AST_NODE_TYPES.TSTypeAssertion:
173
+ return collectStaticFragments(node.expression);
174
+ default:
175
+ return [];
176
+ }
177
+ }
178
+ // Strip `!important` and normalize casing so reset/identity values written
179
+ // as e.g. `none !important` are still recognized as non-promoting.
180
+ function normalizePropertyValue(value) {
181
+ return value
182
+ .replace(/\s*!important\s*$/i, '')
183
+ .trim()
184
+ .toLowerCase();
185
+ }
186
+ // Splits on `separator` only outside parentheses, brackets and quotes, so
187
+ // `cubic-bezier(0.4, 0, 0.2, 1)` and `:is(a, b)` stay whole.
188
+ function splitTopLevel(text, isSeparator) {
189
+ const parts = [];
190
+ let depth = 0;
191
+ let quote = null;
192
+ let current = '';
193
+ for (const ch of text) {
194
+ if (quote) {
195
+ if (ch === quote)
196
+ quote = null;
197
+ current += ch;
198
+ continue;
199
+ }
200
+ if (ch === '"' || ch === "'") {
201
+ quote = ch;
202
+ }
203
+ else if (ch === '(' || ch === '[') {
204
+ depth += 1;
205
+ }
206
+ else if (ch === ')' || ch === ']') {
207
+ depth -= 1;
208
+ }
209
+ else if (depth === 0 && isSeparator(ch)) {
210
+ if (current.trim())
211
+ parts.push(current.trim());
212
+ current = '';
213
+ continue;
214
+ }
215
+ current += ch;
216
+ }
217
+ if (current.trim())
218
+ parts.push(current.trim());
219
+ return parts;
220
+ }
221
+ // Individual transform properties take a 3D form through their value grammar
222
+ // rather than a function: a third `translate`/`scale` component, or a
223
+ // `rotate` axis.
224
+ function isThreeDLiteral(propertyName, value) {
225
+ const tokens = splitTopLevel(normalizePropertyValue(value), (ch) => /\s/.test(ch));
226
+ if (propertyName === 'translate' || propertyName === 'scale') {
227
+ return tokens.length >= 3;
228
+ }
229
+ if (propertyName === 'rotate') {
230
+ return tokens.length >= 2;
231
+ }
232
+ return false;
233
+ }
234
+ // `:not(:hover)` matches every element that is NOT hovered, the opposite of
235
+ // singular, so a negated qualifier must never count.
236
+ function stripNegations(compound) {
237
+ let result = compound;
238
+ let start = result.search(/:not\(/i);
239
+ while (start !== -1) {
240
+ let depth = 0;
241
+ let end = start + ':not'.length;
242
+ for (; end < result.length; end += 1) {
243
+ if (result[end] === '(')
244
+ depth += 1;
245
+ if (result[end] === ')') {
246
+ depth -= 1;
247
+ if (depth === 0)
248
+ break;
249
+ }
250
+ }
251
+ result = result.slice(0, start) + result.slice(end + 1);
252
+ start = result.search(/:not\(/i);
253
+ }
254
+ return result;
255
+ }
256
+ // The compounds of a composed selector from the one holding the styled
257
+ // element rightward. A qualifier to the LEFT of that compound belongs to an
258
+ // ancestor (`.MuiList-root:hover &`), which matches every child at once.
259
+ function compoundsFromRoot(member) {
260
+ const compounds = splitTopLevel(member, (ch) => /[\s>+~]/.test(ch));
261
+ const rootIndex = compounds.findIndex((compound) => compound.includes(ROOT_MARKER));
262
+ return rootIndex === -1 ? [] : compounds.slice(rootIndex).map(stripNegations);
263
+ }
264
+ // Nested selector keys compose the way emotion composes them: `&` is replaced
265
+ // by the parent selector, a key opening with `:` attaches to the parent
266
+ // compound, and any other key is a descendant of it.
267
+ function composeSelectors(selectors) {
268
+ let members = [ROOT_MARKER];
269
+ for (const selector of selectors) {
270
+ const next = [];
271
+ for (const member of splitTopLevel(selector, (ch) => ch === ',')) {
272
+ for (const parent of members) {
273
+ if (member.includes('&')) {
274
+ next.push(member.split('&').join(parent));
275
+ }
276
+ else if (member.startsWith(':')) {
277
+ next.push(`${parent}${member}`);
278
+ }
279
+ else {
280
+ next.push(`${parent} ${member}`);
281
+ }
282
+ }
283
+ }
284
+ members = next;
285
+ }
286
+ return members;
287
+ }
288
+ function carriesInteraction(compound) {
289
+ return (INTERACTION_PSEUDO_CLASS.test(compound) ||
290
+ INTERACTION_MUI_CLASS.test(compound));
291
+ }
292
+ function carriesState(compound) {
293
+ return (carriesInteraction(compound) ||
294
+ STATE_PSEUDO_CLASS.test(compound) ||
295
+ STATE_MUI_CLASS.test(compound) ||
296
+ STATE_ATTRIBUTE.test(compound));
297
+ }
298
+ // Every comma-separated member of the composed selector must qualify: one
299
+ // unqualified member applies the declaration at rest.
300
+ function everyMemberQualifies(chain, qualifies) {
301
+ if (chain.hasUnreadableSelector || chain.selectors.length === 0) {
302
+ return false;
303
+ }
304
+ return composeSelectors(chain.selectors).every((member) => compoundsFromRoot(member).some(qualifies));
305
+ }
306
+ function readKeyName(key) {
307
+ if (key.type === utils_1.AST_NODE_TYPES.Identifier)
308
+ return key.name;
309
+ if (key.type === utils_1.AST_NODE_TYPES.Literal)
310
+ return String(key.value);
311
+ return null;
312
+ }
313
+ // `[theme.breakpoints.up('md')]` compiles to an `@media` query, so it is
314
+ // skipped exactly as a literal `@media` key is.
315
+ function isBreakpointKey(key) {
316
+ let current = key;
317
+ for (;;) {
318
+ switch (current.type) {
319
+ case utils_1.AST_NODE_TYPES.ChainExpression:
320
+ current = current.expression;
321
+ break;
322
+ case utils_1.AST_NODE_TYPES.TSNonNullExpression:
323
+ current = current.expression;
324
+ break;
325
+ case utils_1.AST_NODE_TYPES.CallExpression:
326
+ current = current.callee;
327
+ break;
328
+ case utils_1.AST_NODE_TYPES.MemberExpression:
329
+ if (current.property.type === utils_1.AST_NODE_TYPES.Identifier &&
330
+ current.property.name === 'breakpoints') {
331
+ return true;
332
+ }
333
+ current = current.object;
334
+ break;
335
+ default:
336
+ return false;
337
+ }
338
+ }
339
+ }
340
+ function classifyKey(property) {
341
+ const { key } = property;
342
+ if (key.type === utils_1.AST_NODE_TYPES.Literal && typeof key.value === 'string') {
343
+ return /^\s*@/.test(key.value)
344
+ ? { kind: 'at-rule' }
345
+ : { kind: 'selector', selector: key.value };
346
+ }
347
+ if (key.type === utils_1.AST_NODE_TYPES.TemplateLiteral) {
348
+ const selector = key.quasis
349
+ .map((quasi) => quasi.value.cooked ?? quasi.value.raw)
350
+ .join(INTERPOLATION_MARKER);
351
+ return /^\s*@/.test(selector)
352
+ ? { kind: 'at-rule' }
353
+ : { kind: 'selector', selector };
354
+ }
355
+ if (property.computed) {
356
+ return isBreakpointKey(key)
357
+ ? { kind: 'at-rule' }
358
+ : { kind: 'unreadable-selector' };
359
+ }
360
+ // A bare identifier key (`root`, `sx`, `style`, a slot name) names the
361
+ // element being styled rather than a selector below it.
362
+ return { kind: 'boundary' };
363
+ }
364
+ // Object entries of an `sx` array element, looking through the conditional
365
+ // wrappers MUI's array form is written with.
366
+ function objectsOfArrayEntry(node) {
367
+ if (!node)
368
+ return [];
369
+ switch (node.type) {
370
+ case utils_1.AST_NODE_TYPES.ObjectExpression:
371
+ return [node];
372
+ case utils_1.AST_NODE_TYPES.LogicalExpression:
373
+ return objectsOfArrayEntry(node.right);
374
+ case utils_1.AST_NODE_TYPES.ConditionalExpression:
375
+ return [
376
+ ...objectsOfArrayEntry(node.consequent),
377
+ ...objectsOfArrayEntry(node.alternate),
378
+ ];
379
+ case utils_1.AST_NODE_TYPES.TSAsExpression:
380
+ case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
381
+ return objectsOfArrayEntry(node.expression);
382
+ default:
383
+ return [];
384
+ }
385
+ }
386
+ // Climbs from a declaration to the root of the style object it belongs to,
387
+ // collecting what both exemptions read. The climb crosses only constructs that
388
+ // keep it inside one style value; anything else (a call argument, a JSX
389
+ // attribute, a variable declarator, a statement) ends it.
390
+ function climbStyleChain(property) {
391
+ const selectorsInnerFirst = [];
392
+ const objects = [];
393
+ let hasUnreadableSelector = false;
394
+ let isConditional = false;
395
+ let child = property;
396
+ let current = property.parent;
397
+ climb: while (current) {
398
+ switch (current.type) {
399
+ case utils_1.AST_NODE_TYPES.ObjectExpression:
400
+ objects.push(current);
401
+ break;
402
+ case utils_1.AST_NODE_TYPES.ArrayExpression:
403
+ for (const element of current.elements) {
404
+ if (element !== child)
405
+ objects.push(...objectsOfArrayEntry(element));
406
+ }
407
+ break;
408
+ case utils_1.AST_NODE_TYPES.Property: {
409
+ if (current.value !== child)
410
+ break climb;
411
+ const key = classifyKey(current);
412
+ if (key.kind === 'boundary')
413
+ break climb;
414
+ if (key.kind === 'selector')
415
+ selectorsInnerFirst.push(key.selector);
416
+ if (key.kind === 'unreadable-selector')
417
+ hasUnreadableSelector = true;
418
+ break;
419
+ }
420
+ case utils_1.AST_NODE_TYPES.ConditionalExpression:
421
+ if (current.test === child)
422
+ break climb;
423
+ isConditional = true;
424
+ break;
425
+ case utils_1.AST_NODE_TYPES.LogicalExpression:
426
+ isConditional = true;
427
+ break;
428
+ case utils_1.AST_NODE_TYPES.ArrowFunctionExpression:
429
+ if (current.body !== child)
430
+ break climb;
431
+ break;
432
+ case utils_1.AST_NODE_TYPES.SpreadElement:
433
+ case utils_1.AST_NODE_TYPES.TSAsExpression:
434
+ case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
435
+ case utils_1.AST_NODE_TYPES.TSNonNullExpression:
436
+ case utils_1.AST_NODE_TYPES.TSTypeAssertion:
437
+ break;
438
+ default:
439
+ break climb;
440
+ }
441
+ child = current;
442
+ current = current.parent;
443
+ }
444
+ return {
445
+ selectors: selectorsInnerFirst.reverse(),
446
+ hasUnreadableSelector,
447
+ objects,
448
+ isConditional,
449
+ };
450
+ }
451
+ // The properties a transition shorthand animates. An item naming no property
452
+ // animates `all`, which is the shorthand's initial `transition-property`.
453
+ function readTransitionShorthand(value) {
454
+ if (value === 'none')
455
+ return [];
456
+ return splitTopLevel(value, (ch) => ch === ',').map((item) => {
457
+ const tokens = splitTopLevel(item, (ch) => /\s/.test(ch));
458
+ const property = tokens.find((token) => !TRANSITION_TIME.test(token) &&
459
+ !BARE_NUMBER.test(token) &&
460
+ !TRANSITION_KEYWORDS.has(token) &&
461
+ !token.includes('('));
462
+ return property ?? 'all';
463
+ });
464
+ }
465
+ function transitionCovers(value, transitionProperty, target) {
466
+ // `isDragging ? 'none' : TRANSITIONS.transform` covers through the branch
467
+ // that animates.
468
+ if (value.type === utils_1.AST_NODE_TYPES.ConditionalExpression) {
469
+ return (transitionCovers(value.consequent, transitionProperty, target) ||
470
+ transitionCovers(value.alternate, transitionProperty, target));
471
+ }
472
+ const text = readStaticText(value);
473
+ // An expression the rule cannot read (`createTransition(...)`,
474
+ // `theme.transitions.create(...)`, a constant) is trusted to cover it.
475
+ if (text === null)
476
+ return true;
477
+ const normalized = normalizePropertyValue(text);
478
+ if (GLOBAL_KEYWORDS.has(normalized))
479
+ return true;
480
+ const animated = transitionProperty === 'transition'
481
+ ? readTransitionShorthand(normalized)
482
+ : splitTopLevel(normalized, (ch) => ch === ',');
483
+ return animated.some((name) => name === 'all' || stripVendorPrefix(name) === target);
484
+ }
485
+ function hasCoveringTransition(chain, target) {
486
+ return chain.objects.some((object) => object.properties.some((entry) => {
487
+ if (entry.type !== utils_1.AST_NODE_TYPES.Property || entry.computed) {
488
+ return false;
489
+ }
490
+ const name = readKeyName(entry.key);
491
+ if (name === null)
492
+ return false;
493
+ const transitionProperty = stripVendorPrefix(normalizePropertyName(name));
494
+ return (TRANSITION_PROPERTIES.has(transitionProperty) &&
495
+ transitionCovers(entry.value, transitionProperty, target));
496
+ }));
497
+ }
498
+ function isFunctionNode(node) {
499
+ return (node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
500
+ node.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
501
+ node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration);
502
+ }
503
+ function isIterationCallee(callee) {
504
+ if (callee.type !== utils_1.AST_NODE_TYPES.MemberExpression ||
505
+ callee.computed ||
506
+ callee.property.type !== utils_1.AST_NODE_TYPES.Identifier) {
507
+ return false;
508
+ }
509
+ if (ITERATION_METHODS.has(callee.property.name))
510
+ return true;
511
+ return (callee.property.name === 'from' &&
512
+ callee.object.type === utils_1.AST_NODE_TYPES.Identifier &&
513
+ callee.object.name === 'Array');
514
+ }
515
+ function isItemRenderProp(fn) {
516
+ const { parent } = fn;
517
+ if (parent?.type === utils_1.AST_NODE_TYPES.JSXExpressionContainer &&
518
+ parent.parent?.type === utils_1.AST_NODE_TYPES.JSXAttribute &&
519
+ parent.parent.name.type === utils_1.AST_NODE_TYPES.JSXIdentifier) {
520
+ return ITEM_RENDER_PROP.test(parent.parent.name.name);
521
+ }
522
+ if (parent?.type === utils_1.AST_NODE_TYPES.Property &&
523
+ parent.value === fn &&
524
+ parent.key.type === utils_1.AST_NODE_TYPES.Identifier) {
525
+ return ITEM_RENDER_PROP.test(parent.key.name);
526
+ }
527
+ return false;
528
+ }
529
+ function isInsideIteration(node) {
530
+ for (let current = node.parent; current; current = current.parent) {
531
+ if (!isFunctionNode(current))
532
+ continue;
533
+ const { parent } = current;
534
+ if (parent?.type === utils_1.AST_NODE_TYPES.CallExpression &&
535
+ parent.arguments.some((argument) => argument === current) &&
536
+ isIterationCallee(parent.callee)) {
537
+ return true;
538
+ }
539
+ if (isItemRenderProp(current))
540
+ return true;
541
+ }
542
+ return false;
543
+ }
70
544
  exports.noCompositingLayerProps = (0, createRule_1.createRule)({
71
545
  name: 'no-compositing-layer-props',
72
546
  meta: {
@@ -75,13 +549,35 @@ exports.noCompositingLayerProps = (0, createRule_1.createRule)({
75
549
  description: 'Discourage CSS properties that force GPU compositing layers (e.g., transform, filter, will-change). Extra layers consume GPU memory and split rendering work, which slows scrolling and animation when sprinkled across a page. The rule inspects inline style objects and MUI sx props so layer promotion stays intentional rather than incidental.',
76
550
  recommended: 'error',
77
551
  },
78
- schema: [],
552
+ schema: [
553
+ {
554
+ type: 'object',
555
+ properties: {
556
+ exemptInteractionStates: {
557
+ type: 'boolean',
558
+ default: true,
559
+ },
560
+ exemptStateTransitions: {
561
+ type: 'boolean',
562
+ default: true,
563
+ },
564
+ },
565
+ additionalProperties: false,
566
+ },
567
+ ],
79
568
  messages: {
80
- compositingLayer: 'CSS property "{{property}}" promotes this element to its own GPU compositing layer. Extra layers allocate GPU memory and isolate painting, which slows scrolling and animation when used broadly. Remove "{{property}}" or keep it only when the layer promotion is intentional and documented (e.g., eslint-disable with a comment).',
569
+ compositingLayer: "CSS property \"{{property}}\" promotes this element to its own GPU compositing layer. On a component repeated in a list or scroll view, one layer per row exhausts GPU memory and tears during scroll. Animate transform/opacity under an interaction state ('&:hover', '&:active', '&:focus-visible'), or drive a state-dependent value with a 'transition'; both are exempt. Otherwise remove it, or keep it with an eslint-disable-next-line comment giving the reason.",
81
570
  },
82
571
  },
83
- defaultOptions: [],
84
- create(context) {
572
+ defaultOptions: [
573
+ {
574
+ exemptInteractionStates: true,
575
+ exemptStateTransitions: true,
576
+ },
577
+ ],
578
+ create(context, [options]) {
579
+ const exemptInteractionStates = options.exemptInteractionStates !== false;
580
+ const exemptStateTransitions = options.exemptStateTransitions !== false;
85
581
  const seenNodes = new WeakSet();
86
582
  // The value-driven arm exists to catch a layer-promoting value written under
87
583
  // a property spelling COMPOSITING_PROPERTIES misses (a vendor prefix). It
@@ -90,15 +586,7 @@ exports.noCompositingLayerProps = (0, createRule_1.createRule)({
90
586
  // half is never a CSS property being reported, so it is never reported.
91
587
  function checkPropertyValue(normalizedName, value) {
92
588
  return (TRANSFORM_VALUE_PROPERTIES.has(stripVendorPrefix(normalizedName)) &&
93
- COMPOSITING_TRANSFORM_FUNCTIONS.some((fn) => value.includes(fn)));
94
- }
95
- // Strip `!important` and normalize casing so reset/identity values written
96
- // as e.g. `none !important` are still recognized as non-promoting.
97
- function normalizePropertyValue(value) {
98
- return value
99
- .replace(/\s*!important\s*$/i, '')
100
- .trim()
101
- .toLowerCase();
589
+ THREE_D_TRANSFORM_FUNCTION.test(value));
102
590
  }
103
591
  function checkProperty(propertyName, propertyValue) {
104
592
  const normalizedName = normalizePropertyName(propertyName);
@@ -127,6 +615,63 @@ exports.noCompositingLayerProps = (0, createRule_1.createRule)({
127
615
  }
128
616
  return false;
129
617
  }
618
+ // A responsive object or array holds one value per breakpoint, not per
619
+ // state, so it is state-driven only when no entry promotes a layer at rest.
620
+ function isStateDrivenValue(normalizedName, value) {
621
+ switch (value.type) {
622
+ case utils_1.AST_NODE_TYPES.TSAsExpression:
623
+ case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
624
+ case utils_1.AST_NODE_TYPES.TSNonNullExpression:
625
+ case utils_1.AST_NODE_TYPES.TSTypeAssertion:
626
+ return isStateDrivenValue(normalizedName, value.expression);
627
+ case utils_1.AST_NODE_TYPES.ObjectExpression:
628
+ return value.properties.every((entry) => entry.type !== utils_1.AST_NODE_TYPES.Property ||
629
+ isStateDrivenValue(normalizedName, entry.value));
630
+ case utils_1.AST_NODE_TYPES.ArrayExpression:
631
+ return value.elements.every((entry) => !entry ||
632
+ entry.type === utils_1.AST_NODE_TYPES.SpreadElement ||
633
+ isStateDrivenValue(normalizedName, entry));
634
+ default: {
635
+ const literal = readStaticText(value);
636
+ return literal === null || !checkProperty(normalizedName, literal);
637
+ }
638
+ }
639
+ }
640
+ // A 3D transform promotes a layer whatever state the element is in.
641
+ function writesThreeDTransform(normalizedName, value, literal) {
642
+ if (collectStaticFragments(value).some((fragment) => THREE_D_TRANSFORM_FUNCTION.test(fragment))) {
643
+ return true;
644
+ }
645
+ return literal !== null && isThreeDLiteral(normalizedName, literal);
646
+ }
647
+ // Both arms are list-safe by construction. A singular interaction state
648
+ // matches one element per pointer or focus however many siblings exist,
649
+ // and a CSS transition never runs on first paint, so a state-driven value
650
+ // promotes only the element whose state flipped, and only while it
651
+ // animates. The second arm is withheld inside lexical iteration, where
652
+ // many siblings can be "on" at once.
653
+ function isExempt(node, normalizedName, literal) {
654
+ if (!EXEMPTABLE_PROPERTIES.has(normalizedName))
655
+ return false;
656
+ if (!exemptInteractionStates && !exemptStateTransitions)
657
+ return false;
658
+ if (writesThreeDTransform(normalizedName, node.value, literal)) {
659
+ return false;
660
+ }
661
+ const chain = climbStyleChain(node);
662
+ if (exemptInteractionStates &&
663
+ everyMemberQualifies(chain, carriesInteraction)) {
664
+ return true;
665
+ }
666
+ if (!exemptStateTransitions)
667
+ return false;
668
+ const isStateDriven = (literal === null && isStateDrivenValue(normalizedName, node.value)) ||
669
+ chain.isConditional ||
670
+ everyMemberQualifies(chain, carriesState);
671
+ return (isStateDriven &&
672
+ hasCoveringTransition(chain, normalizedName) &&
673
+ !isInsideIteration(node));
674
+ }
130
675
  // A property key names a `@keyframes` at-rule when it is a string literal
131
676
  // (`'@keyframes spin'`) or a template literal whose leading text starts the
132
677
  // at-rule (`` [`@keyframes ${name}`] `` for a dynamically-named animation).
@@ -204,7 +749,6 @@ exports.noCompositingLayerProps = (0, createRule_1.createRule)({
204
749
  if (isInKeyframes(node))
205
750
  return;
206
751
  let propertyName = '';
207
- let propertyValue = '';
208
752
  // Get property name
209
753
  if (node.key.type === utils_1.AST_NODE_TYPES.Identifier) {
210
754
  propertyName = node.key.name;
@@ -212,19 +756,18 @@ exports.noCompositingLayerProps = (0, createRule_1.createRule)({
212
756
  else if (node.key.type === utils_1.AST_NODE_TYPES.Literal) {
213
757
  propertyName = String(node.key.value);
214
758
  }
215
- // Get property value if it's a string literal or numeric literal
216
- if (node.value.type === utils_1.AST_NODE_TYPES.Literal) {
217
- propertyValue = String(node.value.value);
218
- }
219
- if (checkProperty(propertyName, propertyValue)) {
220
- context.report({
221
- node,
222
- messageId: 'compositingLayer',
223
- data: {
224
- property: propertyName,
225
- },
226
- });
227
- }
759
+ const literal = readStaticText(node.value);
760
+ if (!checkProperty(propertyName, literal ?? ''))
761
+ return;
762
+ if (isExempt(node, normalizePropertyName(propertyName), literal))
763
+ return;
764
+ context.report({
765
+ node,
766
+ messageId: 'compositingLayer',
767
+ data: {
768
+ property: propertyName,
769
+ },
770
+ });
228
771
  }
229
772
  return {
230
773
  // Handle object literal properties (inline styles)
@@ -1 +1,9 @@
1
- export declare const noMarginProperties: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"noMarginProperties", [], import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
1
+ type Options = [
2
+ {
3
+ exemptZero?: boolean;
4
+ exemptAuto?: boolean;
5
+ exemptNegative?: boolean;
6
+ }
7
+ ];
8
+ export declare const noMarginProperties: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"noMarginProperties", Options, import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
9
+ export {};
@@ -65,6 +65,110 @@ const MARGIN_PROPERTIES = new Set([
65
65
  'ml',
66
66
  'm',
67
67
  ]);
68
+ const ZERO_LENGTH = /^[+-]?(?:0+\.?0*|\.0+)(?:[a-z]+|%)?$/;
69
+ const NEGATIVE_LENGTH = /^-(?:\d+\.?\d*|\.\d+)(?:[a-z]+|%)?$/;
70
+ function classifyMarginToken(token) {
71
+ if (token === 'auto')
72
+ return 'auto';
73
+ if (ZERO_LENGTH.test(token))
74
+ return 'zero';
75
+ if (NEGATIVE_LENGTH.test(token))
76
+ return 'negative';
77
+ return 'spacing';
78
+ }
79
+ /**
80
+ * A string value may be a 1-4 token `margin` shorthand (`'0 auto'`), so every
81
+ * token is classified; `!important` changes the cascade, not the value.
82
+ */
83
+ function classifyMarginString(value) {
84
+ const tokens = value
85
+ .replace(/\s*!important\s*$/i, '')
86
+ .trim()
87
+ .toLowerCase()
88
+ .split(/\s+/)
89
+ .filter(Boolean);
90
+ return tokens.length === 0 ? ['spacing'] : tokens.map(classifyMarginToken);
91
+ }
92
+ function classifyMarginNumber(value) {
93
+ if (value === 0)
94
+ return 'zero';
95
+ return value < 0 ? 'negative' : 'spacing';
96
+ }
97
+ /**
98
+ * Every kind a margin value can take, or `null` when any part of it is
99
+ * unreadable (a variable, a call, a template substitution). A conditional and
100
+ * MUI's responsive object and array forms yield the kinds of all their
101
+ * branches, since any one of them may render.
102
+ */
103
+ function classifyMarginValue(node) {
104
+ const value = unwrapAssertions(node);
105
+ switch (value.type) {
106
+ case utils_1.AST_NODE_TYPES.Literal:
107
+ if (typeof value.value === 'number') {
108
+ return [classifyMarginNumber(value.value)];
109
+ }
110
+ return typeof value.value === 'string'
111
+ ? classifyMarginString(value.value)
112
+ : null;
113
+ case utils_1.AST_NODE_TYPES.UnaryExpression:
114
+ if ((value.operator === '-' || value.operator === '+') &&
115
+ value.argument.type === utils_1.AST_NODE_TYPES.Literal &&
116
+ typeof value.argument.value === 'number') {
117
+ return [
118
+ classifyMarginNumber(value.operator === '-'
119
+ ? -value.argument.value
120
+ : value.argument.value),
121
+ ];
122
+ }
123
+ return null;
124
+ case utils_1.AST_NODE_TYPES.TemplateLiteral:
125
+ return value.expressions.length === 0
126
+ ? classifyMarginString(value.quasis[0].value.cooked ?? value.quasis[0].value.raw)
127
+ : null;
128
+ case utils_1.AST_NODE_TYPES.JSXExpressionContainer:
129
+ return value.expression.type === utils_1.AST_NODE_TYPES.JSXEmptyExpression
130
+ ? null
131
+ : classifyMarginValue(value.expression);
132
+ case utils_1.AST_NODE_TYPES.ConditionalExpression:
133
+ return combineMarginKinds([value.consequent, value.alternate]);
134
+ case utils_1.AST_NODE_TYPES.ObjectExpression:
135
+ return combineMarginKinds(value.properties.map((entry) => entry.type === utils_1.AST_NODE_TYPES.Property && !entry.computed
136
+ ? entry.value
137
+ : null));
138
+ case utils_1.AST_NODE_TYPES.ArrayExpression:
139
+ return combineMarginKinds(value.elements.filter((element) => element !== null));
140
+ default:
141
+ return null;
142
+ }
143
+ }
144
+ /**
145
+ * MUI's responsive array and object forms read a `null` entry as "skip this
146
+ * breakpoint" (`[null, 0]` sets a margin from `sm` up only), and `sx` drops a
147
+ * `null` value outright, so a `null` part renders no margin at all.
148
+ */
149
+ function isNullLiteral(node) {
150
+ const value = unwrapAssertions(node);
151
+ return value.type === utils_1.AST_NODE_TYPES.Literal && value.raw === 'null';
152
+ }
153
+ /**
154
+ * The kinds of every part of a composite value. A `null` part adds no kind,
155
+ * and a composite left with none (`[]`, `{}`, `[null, null]`) sets no margin
156
+ * that could earn an exemption, so it keeps the report like an unreadable one.
157
+ */
158
+ function combineMarginKinds(nodes) {
159
+ const kinds = [];
160
+ for (const node of nodes) {
161
+ if (!node || node.type === utils_1.AST_NODE_TYPES.SpreadElement)
162
+ return null;
163
+ if (isNullLiteral(node))
164
+ continue;
165
+ const nodeKinds = classifyMarginValue(node);
166
+ if (!nodeKinds)
167
+ return null;
168
+ kinds.push(...nodeKinds);
169
+ }
170
+ return kinds.length === 0 ? null : kinds;
171
+ }
68
172
  exports.noMarginProperties = (0, createRule_1.createRule)({
69
173
  name: 'no-margin-properties',
70
174
  meta: {
@@ -73,18 +177,61 @@ exports.noMarginProperties = (0, createRule_1.createRule)({
73
177
  description: 'Prevent margin properties (margin, marginLeft, marginRight, marginTop, marginBottom, mx, my, etc.) in MUI styling because margins fight container-controlled spacing, double gutters, and misaligned breakpoints; keep spacing centralized with padding, gap, or spacing props instead.',
74
178
  recommended: 'error',
75
179
  },
76
- schema: [],
180
+ schema: [
181
+ {
182
+ type: 'object',
183
+ properties: {
184
+ exemptZero: {
185
+ type: 'boolean',
186
+ default: true,
187
+ },
188
+ exemptAuto: {
189
+ type: 'boolean',
190
+ default: true,
191
+ },
192
+ exemptNegative: {
193
+ type: 'boolean',
194
+ default: true,
195
+ },
196
+ },
197
+ additionalProperties: false,
198
+ },
199
+ ],
77
200
  messages: {
78
201
  noMarginProperties: 'Margin property "{{property}}" in MUI styling fights container-controlled spacing (Stack/Grid spacing, gap, responsive gutters) and produces double gutters, misalignment, and overflow as layouts shift. Keep spacing inside the component with padding or let the parent handle separation via gap/spacing so layout remains predictable.',
79
202
  },
80
203
  },
81
- defaultOptions: [],
82
- create(context) {
204
+ defaultOptions: [
205
+ {
206
+ exemptZero: true,
207
+ exemptAuto: true,
208
+ exemptNegative: true,
209
+ },
210
+ ],
211
+ create(context, [options]) {
83
212
  const seenNodes = new WeakSet();
213
+ const exemptKinds = new Set();
214
+ if (options.exemptZero !== false)
215
+ exemptKinds.add('zero');
216
+ if (options.exemptAuto !== false)
217
+ exemptKinds.add('auto');
218
+ if (options.exemptNegative !== false)
219
+ exemptKinds.add('negative');
84
220
  function checkProperty(propertyName) {
85
221
  const normalizedName = normalizePropertyName(propertyName);
86
222
  return MARGIN_PROPERTIES.has(normalizedName);
87
223
  }
224
+ /**
225
+ * A value is exempt only when every part of it is a reset, `auto` or a
226
+ * negative offset whose exemption is on: one spacing token (`'0 8px'`) or
227
+ * one unreadable branch keeps the report.
228
+ */
229
+ function isExemptValue(value) {
230
+ if (!value)
231
+ return false;
232
+ const kinds = classifyMarginValue(value);
233
+ return !!kinds && kinds.every((kind) => exemptKinds.has(kind));
234
+ }
88
235
  /**
89
236
  * True if node is in a flagged MUI styling context (sx attribute, sx
90
237
  * object property, or css() call). Excludes createTheme styleOverrides:
@@ -159,7 +306,9 @@ exports.noMarginProperties = (0, createRule_1.createRule)({
159
306
  .join('');
160
307
  propertyName = quasis + expressions;
161
308
  }
162
- if (propertyName && checkProperty(propertyName)) {
309
+ if (propertyName &&
310
+ checkProperty(propertyName) &&
311
+ !isExemptValue(node.value)) {
163
312
  // Check if in MUI styling context
164
313
  if (isMuiStylingContext(node)) {
165
314
  context.report({
@@ -283,7 +432,9 @@ exports.noMarginProperties = (0, createRule_1.createRule)({
283
432
  else if (key.type === utils_1.AST_NODE_TYPES.Literal) {
284
433
  propertyName = String(key.value);
285
434
  }
286
- if (propertyName && checkProperty(propertyName)) {
435
+ if (propertyName &&
436
+ checkProperty(propertyName) &&
437
+ !isExemptValue(prop.value)) {
287
438
  // Check if this variable is used in an sx prop
288
439
  if (sourceText.includes(`sx={${variableName}}`) ||
289
440
  sourceText.includes(`sx={{ ...${variableName}`) ||
@@ -307,7 +458,7 @@ exports.noMarginProperties = (0, createRule_1.createRule)({
307
458
  if (attr.type === utils_1.AST_NODE_TYPES.JSXAttribute &&
308
459
  attr.name.type === utils_1.AST_NODE_TYPES.JSXIdentifier) {
309
460
  const attrName = attr.name.name;
310
- if (checkProperty(attrName)) {
461
+ if (checkProperty(attrName) && !isExemptValue(attr.value)) {
311
462
  context.report({
312
463
  node: attr,
313
464
  messageId: 'noMarginProperties',
@@ -352,7 +352,17 @@ function typeNodeExcludesProperty(node, propertyName, resolveAlias, seen = new S
352
352
  if (node.typeParameters?.params) {
353
353
  const argumentsAreTheType = closedLiteralCounts &&
354
354
  PROPERTY_PRESERVING_WRAPPERS.has(wrapperNameOf(node.typeName) ?? '');
355
- return node.typeParameters.params.some((param) => typeNodeExcludesProperty(param, propertyName, resolveAlias, seen, argumentsAreTheType));
355
+ if (node.typeParameters.params.some((param) => typeNodeExcludesProperty(param, propertyName, resolveAlias, seen, argumentsAreTheType))) {
356
+ return true;
357
+ }
358
+ // Arguments that prove nothing are not proof that the referenced alias
359
+ // proves nothing, so a reference carrying them still falls through to its
360
+ // own body. Returning here made `type P<T> = Omit<X, 'children'>` report
361
+ // when referenced as `P<string>` while the byte-identical unparameterized
362
+ // spelling passed. Type arguments are deliberately NOT substituted into
363
+ // the body: an unbound parameter there proves nothing, which is the
364
+ // conservative answer, and an undeclared wrapper still resolves to
365
+ // nothing and still reports.
356
366
  }
357
367
  // Keyed by position as well as by name: the same alias proves different
358
368
  // things in a props position than inside an arbitrary generic, so a visit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.21.17",
3
+ "version": "1.22.0",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,48 @@
1
1
  [
2
+ {
3
+ "version": "1.22.0",
4
+ "date": "2026-09-27T06:08:08.588Z",
5
+ "rules": [
6
+ {
7
+ "name": "no-compositing-layer-props",
8
+ "changeType": "feat",
9
+ "issues": [
10
+ 2383
11
+ ],
12
+ "summary": "exempt interaction-state and transition-toggled transform/opacity"
13
+ },
14
+ {
15
+ "name": "no-margin-properties",
16
+ "changeType": "feat",
17
+ "issues": [
18
+ 1593
19
+ ],
20
+ "summary": "skip null breakpoints in responsive margin values; exempt resets, auto and negative offsets"
21
+ }
22
+ ]
23
+ },
24
+ {
25
+ "version": "1.21.18",
26
+ "date": "2026-09-07T22:15:12.507Z",
27
+ "rules": [
28
+ {
29
+ "name": "enforce-empty-object-check",
30
+ "changeType": "fix",
31
+ "issues": [
32
+ 2360
33
+ ],
34
+ "summary": "emit a configurable empty-check helper (closes #2360)"
35
+ },
36
+ {
37
+ "name": "prevent-children-clobber",
38
+ "changeType": "fix",
39
+ "issues": [
40
+ 2361
41
+ ],
42
+ "summary": "resolve a parameterized props alias (closes #2361)"
43
+ }
44
+ ]
45
+ },
2
46
  {
3
47
  "version": "1.21.17",
4
48
  "date": "2026-09-07T12:35:50.668Z",