eslint-plugin-mui-v7 1.2.1 → 1.6.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.
Files changed (4) hide show
  1. package/README.md +686 -322
  2. package/index.cjs +531 -82
  3. package/index.js +535 -82
  4. package/package.json +56 -55
package/index.cjs CHANGED
@@ -1,16 +1,22 @@
1
1
  /**
2
- * ESLint Plugin para MUI V7 - Foca em Breaking Changes (CommonJS version)
2
+ * ESLint Plugin for MUI V7 - Focuses on Breaking Changes
3
3
  *
4
- * Detecta automaticamente código que QUEBRA na migração V6 → V7
5
- * e fornece mensagens educativas para corrigir.
4
+ * Automatically detects code that BREAKS when migrating from V6 → V7
5
+ * and provides educational messages to fix it.
6
6
  *
7
- * @version 1.2.1
7
+ * @version 1.6.0
8
8
  * @created 2025-01-26
9
- * @updated 2025-10-30
9
+ * @updated 2025-11-14
10
10
  * @author Matheus (Koda AI Studio) + Claude Code
11
11
  */
12
12
 
13
+ const { readFileSync } = require('fs');
14
+ const { join } = require('path');
15
+ const packageJson = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf-8'));
16
+
13
17
  // Define moved components at module scope to avoid recreation on every rule invocation
18
+ // ⚠️ OFFICIAL LIST from MUI v7.0.0/lab-removed-components codemod
19
+ // Source: https://github.com/mui/material-ui/blob/master/packages/mui-codemod/README.md
14
20
  const MOVED_COMPONENTS = new Set([
15
21
  'Alert', 'AlertTitle',
16
22
  'Autocomplete',
@@ -19,11 +25,11 @@ const MOVED_COMPONENTS = new Set([
19
25
  'Rating',
20
26
  'Skeleton',
21
27
  'SpeedDial', 'SpeedDialAction', 'SpeedDialIcon',
22
- 'TabContext', 'TabList', 'TabPanel',
23
- 'Timeline', 'TimelineConnector', 'TimelineContent', 'TimelineDot',
24
- 'TimelineItem', 'TimelineOppositeContent', 'TimelineSeparator',
25
28
  'ToggleButton', 'ToggleButtonGroup',
26
- 'TreeView', 'TreeItem',
29
+ // ❌ NOT INCLUDED (still in @mui/lab or moved to MUI X):
30
+ // - TabContext, TabList, TabPanel → Still in @mui/lab
31
+ // - Timeline* (7 components) → Still in @mui/lab
32
+ // - TreeView, TreeItem → Moved to @mui/x-tree-view
27
33
  ]);
28
34
 
29
35
  const muiV7Rules = {
@@ -31,18 +37,18 @@ const muiV7Rules = {
31
37
  meta: {
32
38
  type: 'problem',
33
39
  docs: {
34
- description: 'Unstable_Grid2 foi promovido para Grid no MUI V7',
40
+ description: 'Unstable_Grid2 was promoted to Grid in MUI V7',
35
41
  category: 'Breaking Changes',
36
42
  recommended: true,
37
43
  },
38
44
  messages: {
39
- unstableGrid: '🚀 Unstable_Grid2 foi promovido para Grid estável no MUI V7!\n\n' +
40
- '🔧 Forma antiga (V6):\n' +
45
+ unstableGrid: '🚀 Unstable_Grid2 was promoted to stable Grid in MUI V7!\n\n' +
46
+ '🔧 Old way (V6):\n' +
41
47
  ' import Grid from "@mui/material/Unstable_Grid2"\n' +
42
48
  ' import Grid2 from "@mui/material/Unstable_Grid2"\n\n' +
43
- '✅ Forma nova (V7):\n' +
49
+ '✅ New way (V7):\n' +
44
50
  ' import { Grid } from "@mui/material"\n\n' +
45
- '💡 O Grid agora é estável e usa a prop `size`!',
51
+ '💡 Grid is now stable and uses the `size` prop!',
46
52
  },
47
53
  schema: [],
48
54
  fixable: 'code',
@@ -70,19 +76,19 @@ const muiV7Rules = {
70
76
  meta: {
71
77
  type: 'problem',
72
78
  docs: {
73
- description: 'Grid2 foi renomeado para Grid no MUI V7',
79
+ description: 'Grid2 was renamed to Grid in MUI V7',
74
80
  category: 'Breaking Changes',
75
81
  recommended: true,
76
82
  },
77
83
  messages: {
78
- grid2Import: '⚠️ Grid2 foi renomeado para Grid no MUI V7!\n\n' +
79
- '🔧 Forma antiga (V6):\n' +
84
+ grid2Import: '⚠️ Grid2 was renamed to Grid in MUI V7!\n\n' +
85
+ '🔧 Old way (V6):\n' +
80
86
  ' import Grid2 from "@mui/material/Grid2"\n' +
81
87
  ' import { grid2Classes } from "@mui/material/Grid2"\n\n' +
82
- '✅ Recomendado:\n' +
88
+ '✅ Recommended:\n' +
83
89
  ' import { Grid } from "@mui/material"\n' +
84
90
  ' import { gridClasses } from "@mui/material"\n\n' +
85
- '💡 O novo Grid é mais poderoso e usa a prop `size`!',
91
+ '💡 The new Grid is more powerful and uses the `size` prop!',
86
92
  },
87
93
  schema: [],
88
94
  fixable: 'code',
@@ -97,7 +103,30 @@ const muiV7Rules = {
97
103
  node,
98
104
  messageId: 'grid2Import',
99
105
  fix(fixer) {
100
- return fixer.replaceText(node.source, '"@mui/material"');
106
+ const fixes = [fixer.replaceText(node.source, '"@mui/material"')];
107
+
108
+ // Rename Grid2 → Grid and grid2Classes → gridClasses
109
+ node.specifiers.forEach(spec => {
110
+ if (spec.type === 'ImportDefaultSpecifier') {
111
+ // import Grid2 from '@mui/material/Grid2' → import { Grid } from '@mui/material'
112
+ const localName = spec.local.name;
113
+ if (localName === 'Grid2') {
114
+ fixes.push(fixer.replaceText(spec, '{ Grid }'));
115
+ } else {
116
+ // Keep the alias: import MyGrid from ... → import { Grid as MyGrid } from ...
117
+ fixes.push(fixer.replaceText(spec, `{ Grid as ${localName} }`));
118
+ }
119
+ } else if (spec.type === 'ImportSpecifier') {
120
+ const importedName = spec.imported.name;
121
+ if (importedName === 'grid2Classes') {
122
+ fixes.push(fixer.replaceText(spec.imported, 'gridClasses'));
123
+ } else if (importedName === 'Grid2') {
124
+ fixes.push(fixer.replaceText(spec.imported, 'Grid'));
125
+ }
126
+ }
127
+ });
128
+
129
+ return fixes;
101
130
  },
102
131
  });
103
132
  }
@@ -110,18 +139,18 @@ const muiV7Rules = {
110
139
  meta: {
111
140
  type: 'problem',
112
141
  docs: {
113
- description: 'Componentes movidos de @mui/lab para @mui/material',
142
+ description: 'Components moved from @mui/lab to @mui/material',
114
143
  category: 'Breaking Changes',
115
144
  recommended: true,
116
145
  },
117
146
  messages: {
118
- labImport: '✨ {{ count }} componente(s) movido(s) para @mui/material no V7!\n\n' +
119
- '🔧 Forma antiga (V6):\n' +
147
+ labImport: '✨ {{ count }} component(s) moved to @mui/material in V7!\n\n' +
148
+ '🔧 Old way (V6):\n' +
120
149
  ' import { {{ components }} } from "@mui/lab"\n\n' +
121
- '✅ Recomendado:\n' +
150
+ '✅ Recommended:\n' +
122
151
  ' import { {{ components }} } from "@mui/material"\n\n' +
123
- '📦 Todos os componentes movidos: Alert, Autocomplete, Pagination, Rating,\n' +
124
- ' Skeleton, SpeedDial, ToggleButton, AvatarGroup, e mais!',
152
+ '📦 All moved components: Alert, Autocomplete, Pagination, Rating,\n' +
153
+ ' Skeleton, SpeedDial, ToggleButton, AvatarGroup, and more!',
125
154
  },
126
155
  schema: [],
127
156
  fixable: 'code',
@@ -131,7 +160,7 @@ const muiV7Rules = {
131
160
  ImportDeclaration(node) {
132
161
  const source = node.source.value;
133
162
 
134
- // Detecta imports de @mui/lab
163
+ // Detect imports from @mui/lab
135
164
  if (source.startsWith('@mui/lab')) {
136
165
  // Collect ALL moved components (O(n) with Set.has O(1) lookup)
137
166
  const movedComponentsList = node.specifiers
@@ -161,38 +190,148 @@ const muiV7Rules = {
161
190
  meta: {
162
191
  type: 'problem',
163
192
  docs: {
164
- description: 'Grid não usa mais a prop `item`, agora usa `size`',
193
+ description: 'Grid no longer uses the `item` prop, now uses `size`',
165
194
  category: 'Breaking Changes',
166
195
  recommended: true,
167
196
  },
168
197
  messages: {
169
- gridItemProp: '🎯 Grid no MUI V7 não usa mais a prop `item`!\n\n' +
170
- '🔧 Forma antiga (V6):\n' +
171
- ' <Grid item xs={12} sm={6} md={4}>\n\n' +
172
- '✅ Forma nova (V7):\n' +
173
- ' <Grid size={{ xs: 12, sm: 6, md: 4 }}>\n\n' +
174
- '💡 A nova sintaxe é mais limpa e poderosa!\n' +
175
- ' Você pode usar: size, offset, spacing responsivo e mais.',
198
+ gridItemProp: '🎯 Grid in MUI V7 no longer uses the `item` prop!\n\n' +
199
+ '🔧 Old way (V6):\n' +
200
+ ' <Grid item xs={12} sm={6}>\n\n' +
201
+ '✅ New way (V7):\n' +
202
+ ' <Grid size={12}> or <Grid size={{ "{"}xs: 12, sm: 6{"}"} }}>\n\n' +
203
+ '💡 The new syntax is cleaner and more powerful!\n' +
204
+ ' You can use: size, offset, responsive spacing, and more.',
176
205
  },
177
206
  schema: [],
207
+ fixable: 'code',
178
208
  },
179
209
  create(context) {
210
+ const sourceCode = context.getSourceCode();
211
+
180
212
  return {
181
213
  JSXOpeningElement(node) {
182
- if (node.name.name === 'Grid') {
183
- const hasItemProp = node.attributes.some(
184
- attr => attr.type === 'JSXAttribute' && attr.name.name === 'item'
214
+ if (node.name?.name === 'Grid') {
215
+ // Check if it's a Grid container (should not report error)
216
+ const hasContainerProp = node.attributes.some(
217
+ attr => attr.type === 'JSXAttribute' && attr.name?.name === 'container'
218
+ );
219
+
220
+ // Grid container can have breakpoint props (xs, sm, etc) without issue
221
+ if (hasContainerProp) return;
222
+
223
+ const itemProp = node.attributes.find(
224
+ attr => attr.type === 'JSXAttribute' && attr.name?.name === 'item'
185
225
  );
186
226
 
187
- const hasBreakpointProps = node.attributes.some(
227
+ const breakpointProps = node.attributes.filter(
188
228
  attr => attr.type === 'JSXAttribute' &&
189
- ['xs', 'sm', 'md', 'lg', 'xl'].includes(attr.name.name)
229
+ ['xs', 'sm', 'md', 'lg', 'xl'].includes(attr.name?.name)
190
230
  );
191
231
 
192
- if (hasItemProp || hasBreakpointProps) {
232
+ if (itemProp || breakpointProps.length > 0) {
193
233
  context.report({
194
234
  node,
195
235
  messageId: 'gridItemProp',
236
+ fix(fixer) {
237
+ // 🔒 SAFETY CHECK: Don't autofix if there are spread props
238
+ // Spread props may contain item/xs/sm/etc and override our fix
239
+ const hasSpreadProps = node.attributes.some(
240
+ attr => attr.type === 'JSXSpreadAttribute'
241
+ );
242
+
243
+ if (hasSpreadProps) {
244
+ // Only report the problem, no autofix (too risky)
245
+ return null;
246
+ }
247
+
248
+ const fixes = [];
249
+ const propsToRemove = [];
250
+
251
+ // Add item prop for removal
252
+ if (itemProp) {
253
+ propsToRemove.push(itemProp);
254
+ }
255
+
256
+ // Only autofix breakpoints if they are simple literal values
257
+ if (breakpointProps.length > 0) {
258
+ // Check if all values are simple literals
259
+ const allSimpleLiterals = breakpointProps.every(prop => {
260
+ if (!prop.value) return false;
261
+ // Direct literal: xs="12" or xs={12}
262
+ if (prop.value.type === 'Literal') return true;
263
+ // JSXExpressionContainer with Literal: xs={12}
264
+ if (prop.value.type === 'JSXExpressionContainer' &&
265
+ prop.value.expression?.type === 'Literal') return true;
266
+ return false;
267
+ });
268
+
269
+ if (allSimpleLiterals) {
270
+ // Extract values
271
+ const breakpointValues = breakpointProps.map(prop => {
272
+ const name = prop.name.name;
273
+ let value;
274
+
275
+ if (prop.value.type === 'Literal') {
276
+ value = prop.value.value;
277
+ } else if (prop.value.type === 'JSXExpressionContainer') {
278
+ value = prop.value.expression.value;
279
+ }
280
+
281
+ return { name, value };
282
+ });
283
+
284
+ // Add breakpoint props for removal
285
+ propsToRemove.push(...breakpointProps);
286
+
287
+ // Remove all props (including spaces)
288
+ propsToRemove.forEach((prop, index) => {
289
+ const sourceCodeText = sourceCode.getText();
290
+ let start = prop.range[0];
291
+ let end = prop.range[1];
292
+
293
+ // Remove space before the prop
294
+ while (start > 0 && /\s/.test(sourceCodeText[start - 1])) {
295
+ start--;
296
+ }
297
+
298
+ fixes.push(fixer.removeRange([start, end]));
299
+ });
300
+
301
+ // Create the new size prop
302
+ let sizeValue;
303
+ if (breakpointValues.length === 1) {
304
+ // Simple case: size={12}
305
+ const { value } = breakpointValues[0];
306
+ sizeValue = `size={${JSON.stringify(value)}}`;
307
+ } else {
308
+ // Multiple breakpoints: size={{ xs: 12, sm: 6 }}
309
+ const objPairs = breakpointValues
310
+ .map(({ name, value }) => `${name}: ${JSON.stringify(value)}`)
311
+ .join(', ');
312
+ sizeValue = `size={{ ${objPairs} }}`;
313
+ }
314
+
315
+ // Insert the size prop after the opening tag
316
+ const insertPosition = node.name.range[1];
317
+ fixes.push(fixer.insertTextAfterRange([insertPosition, insertPosition], ` ${sizeValue}`));
318
+ }
319
+ } else if (itemProp) {
320
+ // Only remove the item prop (without adding size)
321
+ const sourceCodeText = sourceCode.getText();
322
+ let start = itemProp.range[0];
323
+ let end = itemProp.range[1];
324
+
325
+ // Remove space before the prop
326
+ while (start > 0 && /\s/.test(sourceCodeText[start - 1])) {
327
+ start--;
328
+ }
329
+
330
+ fixes.push(fixer.removeRange([start, end]));
331
+ }
332
+
333
+ return fixes.length > 0 ? fixes : null;
334
+ },
196
335
  });
197
336
  }
198
337
  }
@@ -205,54 +344,66 @@ const muiV7Rules = {
205
344
  meta: {
206
345
  type: 'problem',
207
346
  docs: {
208
- description: 'Detecta props depreciadas no MUI V7',
347
+ description: 'Detects deprecated props and components in MUI V7',
209
348
  category: 'Breaking Changes',
210
349
  recommended: true,
211
350
  },
212
351
  messages: {
213
- onBackdropClick: '🔄 Dialog.onBackdropClick foi removido no V7!\n\n' +
214
- '🔧 Forma antiga (V6):\n' +
215
- ' <Dialog onBackdropClick={handleClick}>\n\n' +
216
- '✅ Forma nova (V7):\n' +
217
- ' <Dialog onClose={(event, reason) => {\n' +
352
+ onBackdropClick: '🔄 {{ component }}.onBackdropClick was removed in V7!\n\n' +
353
+ '🔧 Old way (V6):\n' +
354
+ ' <{{ component }} onBackdropClick={handleClick}>\n\n' +
355
+ '✅ New way (V7):\n' +
356
+ ' <{{ component }} onClose={(event, reason) => {\n' +
218
357
  ' if (reason === "backdropClick") {\n' +
219
- ' // Sua lógica aqui\n' +
358
+ ' // Your logic here\n' +
220
359
  ' }\n' +
221
360
  ' }}>',
222
361
 
223
- inputLabelNormal: '📏 InputLabel.size="normal" foi renomeado!\n\n' +
224
- '🔧 Forma antiga (V6):\n' +
362
+ inputLabelNormal: '📏 InputLabel.size="normal" was renamed!\n\n' +
363
+ '🔧 Old way (V6):\n' +
225
364
  ' <InputLabel size="normal">\n\n' +
226
- '✅ Forma nova (V7):\n' +
365
+ '✅ New way (V7):\n' +
227
366
  ' <InputLabel size="medium">',
228
367
 
229
- hiddenComponent: '👻 Hidden component foi removido no V7!\n\n' +
230
- '🔧 Forma antiga (V6):\n' +
368
+ hiddenComponent: '👻 Hidden component was removed in V7!\n\n' +
369
+ '🔧 Old way (V6):\n' +
231
370
  ' <Hidden xlUp><Paper /></Hidden>\n\n' +
232
- '✅ Opção 1 - Use sx prop:\n' +
371
+ '✅ Option 1 - Use sx prop:\n' +
233
372
  ' <Paper sx={{ display: { xl: "none" } }} />\n\n' +
234
- '✅ Opção 2 - Use useMediaQuery:\n' +
373
+ '✅ Option 2 - Use useMediaQuery:\n' +
374
+ ' const hidden = useMediaQuery(theme => theme.breakpoints.up("xl"))\n' +
375
+ ' return hidden ? null : <Paper />',
376
+
377
+ pigmentHiddenComponent: '👻 PigmentHidden component was removed in V7!\n\n' +
378
+ '🔧 Old way (V6):\n' +
379
+ ' <PigmentHidden xlUp><Paper /></PigmentHidden>\n\n' +
380
+ '✅ Option 1 - Use sx prop:\n' +
381
+ ' <Paper sx={{ display: { xl: "none" } }} />\n\n' +
382
+ '✅ Option 2 - Use useMediaQuery:\n' +
235
383
  ' const hidden = useMediaQuery(theme => theme.breakpoints.up("xl"))\n' +
236
384
  ' return hidden ? null : <Paper />',
237
385
  },
238
386
  schema: [],
387
+ fixable: 'code',
239
388
  },
240
389
  create(context) {
241
390
  return {
242
391
  JSXOpeningElement(node) {
243
- const componentName = node.name.name;
392
+ const componentName = node.name?.name;
393
+ if (!componentName) return;
244
394
 
245
- // Dialog.onBackdropClick
246
- if (componentName === 'Dialog') {
395
+ // Dialog.onBackdropClick and Modal.onBackdropClick
396
+ if (componentName === 'Dialog' || componentName === 'Modal') {
247
397
  const hasOnBackdropClick = node.attributes.some(
248
398
  attr => attr.type === 'JSXAttribute' &&
249
- attr.name.name === 'onBackdropClick'
399
+ attr.name?.name === 'onBackdropClick'
250
400
  );
251
401
 
252
402
  if (hasOnBackdropClick) {
253
403
  context.report({
254
404
  node,
255
405
  messageId: 'onBackdropClick',
406
+ data: { component: componentName },
256
407
  });
257
408
  }
258
409
  }
@@ -261,13 +412,16 @@ const muiV7Rules = {
261
412
  if (componentName === 'InputLabel') {
262
413
  node.attributes.forEach(attr => {
263
414
  if (attr.type === 'JSXAttribute' &&
264
- attr.name.name === 'size' &&
415
+ attr.name?.name === 'size' &&
265
416
  attr.value &&
266
417
  attr.value.type === 'Literal' &&
267
418
  attr.value.value === 'normal') {
268
419
  context.report({
269
420
  node: attr,
270
421
  messageId: 'inputLabelNormal',
422
+ fix(fixer) {
423
+ return fixer.replaceText(attr.value, '"medium"');
424
+ },
271
425
  });
272
426
  }
273
427
  });
@@ -280,6 +434,151 @@ const muiV7Rules = {
280
434
  messageId: 'hiddenComponent',
281
435
  });
282
436
  }
437
+
438
+ // PigmentHidden component
439
+ if (componentName === 'PigmentHidden') {
440
+ context.report({
441
+ node,
442
+ messageId: 'pigmentHiddenComponent',
443
+ });
444
+ }
445
+ },
446
+ };
447
+ },
448
+ },
449
+
450
+ 'no-deprecated-imports': {
451
+ meta: {
452
+ type: 'problem',
453
+ docs: {
454
+ description: 'Detects deprecated imports in MUI V7',
455
+ category: 'Breaking Changes',
456
+ recommended: true,
457
+ },
458
+ messages: {
459
+ createMuiTheme: '🎨 createMuiTheme was removed in V7!\n\n' +
460
+ '🔧 Old way (V6):\n' +
461
+ ' import { createMuiTheme } from "@mui/material/styles"\n\n' +
462
+ '✅ New way (V7):\n' +
463
+ ' import { createTheme } from "@mui/material/styles"\n\n' +
464
+ '💡 The functionality is identical, only the name changed!',
465
+
466
+ experimentalStyled: '🎨 experimentalStyled was removed in V7!\n\n' +
467
+ '🔧 Old way (V6):\n' +
468
+ ' import { experimentalStyled } from "@mui/material/styles"\n\n' +
469
+ '✅ New way (V7):\n' +
470
+ ' import { styled } from "@mui/material/styles"\n\n' +
471
+ '💡 styled is now stable and fully supported!',
472
+
473
+ styledEngineProvider: '⚙️ StyledEngineProvider can no longer be imported from @mui/material!\n\n' +
474
+ '🔧 Old way (V6):\n' +
475
+ ' import { StyledEngineProvider } from "@mui/material"\n\n' +
476
+ '✅ New way (V7):\n' +
477
+ ' import { StyledEngineProvider } from "@mui/material/styles"\n\n' +
478
+ '💡 The correct location since V5 is @mui/material/styles!',
479
+ },
480
+ schema: [],
481
+ fixable: 'code',
482
+ },
483
+ create(context) {
484
+ return {
485
+ ImportDeclaration(node) {
486
+ const source = node.source.value;
487
+
488
+ // Detect StyledEngineProvider being imported from @mui/material (wrong)
489
+ if (source === '@mui/material') {
490
+ node.specifiers.forEach(spec => {
491
+ if (spec.type === 'ImportSpecifier' && spec.imported.name === 'StyledEngineProvider') {
492
+ context.report({
493
+ node,
494
+ messageId: 'styledEngineProvider',
495
+ fix(fixer) {
496
+ // Change the source from '@mui/material' to '@mui/material/styles'
497
+ return fixer.replaceText(node.source, '"@mui/material/styles"');
498
+ },
499
+ });
500
+ }
501
+ });
502
+ }
503
+
504
+ // Detect imports from @mui/material/styles
505
+ if (source === '@mui/material/styles' || source === '@mui/material') {
506
+ node.specifiers.forEach(spec => {
507
+ if (spec.type === 'ImportSpecifier') {
508
+ const importedName = spec.imported.name;
509
+
510
+ // createMuiTheme → createTheme
511
+ if (importedName === 'createMuiTheme') {
512
+ context.report({
513
+ node: spec,
514
+ messageId: 'createMuiTheme',
515
+ fix(fixer) {
516
+ return fixer.replaceText(spec.imported, 'createTheme');
517
+ },
518
+ });
519
+ }
520
+
521
+ // experimentalStyled → styled
522
+ if (importedName === 'experimentalStyled') {
523
+ context.report({
524
+ node: spec,
525
+ messageId: 'experimentalStyled',
526
+ fix(fixer) {
527
+ return fixer.replaceText(spec.imported, 'styled');
528
+ },
529
+ });
530
+ }
531
+ }
532
+ });
533
+ }
534
+ },
535
+ };
536
+ },
537
+ },
538
+
539
+ 'prefer-slots-api': {
540
+ meta: {
541
+ type: 'suggestion',
542
+ docs: {
543
+ description: 'Recommends using slots/slotProps instead of components/componentsProps',
544
+ category: 'Best Practices',
545
+ recommended: true,
546
+ },
547
+ messages: {
548
+ useSlots: '🔧 The components/componentsProps API was deprecated!\n\n' +
549
+ '⚠️ Old way (deprecated):\n' +
550
+ ' <TextField components={{"{"}...{"}"} componentsProps={{"{"}...{"}"} } />\n\n' +
551
+ '✅ New way (recommended):\n' +
552
+ ' <TextField slots={{"{"}...{"}"} slotProps={{"{"}...{"}"} } />\n\n' +
553
+ '💡 The new API is more consistent and flexible!',
554
+ },
555
+ schema: [],
556
+ fixable: 'code',
557
+ },
558
+ create(context) {
559
+ return {
560
+ JSXOpeningElement(node) {
561
+ const deprecatedProps = node.attributes.filter(
562
+ attr => attr.type === 'JSXAttribute' &&
563
+ (attr.name?.name === 'components' || attr.name?.name === 'componentsProps')
564
+ );
565
+
566
+ if (deprecatedProps.length > 0) {
567
+ context.report({
568
+ node,
569
+ messageId: 'useSlots',
570
+ fix(fixer) {
571
+ // Rename each deprecated prop
572
+ return deprecatedProps.map(attr => {
573
+ if (attr.name.name === 'components') {
574
+ return fixer.replaceText(attr.name, 'slots');
575
+ } else if (attr.name.name === 'componentsProps') {
576
+ return fixer.replaceText(attr.name, 'slotProps');
577
+ }
578
+ }).filter(Boolean);
579
+ },
580
+ });
581
+ }
283
582
  },
284
583
  };
285
584
  },
@@ -289,47 +588,48 @@ const muiV7Rules = {
289
588
  meta: {
290
589
  type: 'suggestion',
291
590
  docs: {
292
- description: 'Recomenda uso de theme.vars para CSS variables',
591
+ description: 'Recommends using theme.vars for CSS variables',
293
592
  category: 'Best Practices',
294
593
  recommended: false,
295
594
  },
296
595
  messages: {
297
- useThemeVars: '💡 Quando `cssVariables: true`, use theme.vars!\n\n' +
298
- '⚠️ Forma que NÃO muda com dark mode:\n' +
596
+ useThemeVars: '💡 When `cssVariables: true`, use theme.vars!\n\n' +
597
+ '⚠️ Way that does NOT change with dark mode:\n' +
299
598
  ' color: theme.palette.text.primary\n\n' +
300
- '✅ Forma que muda automaticamente:\n' +
599
+ '✅ Way that changes automatically:\n' +
301
600
  ' color: theme.vars.palette.text.primary\n\n' +
302
- '📚 Benefícios: Performance + Dark mode automático!',
601
+ '📚 Benefits: Performance + Automatic dark mode!',
303
602
  },
304
603
  schema: [],
604
+ fixable: 'code',
305
605
  },
306
606
  create(context) {
307
607
  const sourceCode = context.getSourceCode();
308
608
 
309
609
  /**
310
- * Verifica se o node está dentro de um ternário que checa theme.vars
311
- * Exemplo: theme.vars ? `${theme.vars.palette.primary.main}` : `${theme.palette.primary.main}`
610
+ * Check if the node is inside a ternary that checks theme.vars
611
+ * Example: theme.vars ? `${theme.vars.palette.primary.main}` : `${theme.palette.primary.main}`
312
612
  */
313
613
  function isInsideThemeVarsConditional(node) {
314
614
  let current = node;
315
615
  let depth = 0;
316
616
  const MAX_DEPTH = 10;
317
617
 
318
- // Sobe até MAX_DEPTH níveis na árvore AST procurando por ConditionalExpression
618
+ // Go up MAX_DEPTH levels in the AST tree looking for ConditionalExpression
319
619
  while (current.parent && depth < MAX_DEPTH) {
320
620
  current = current.parent;
321
621
  depth++;
322
622
 
323
- // Se encontrar um ternário (ConditionalExpression)
623
+ // If a ternary (ConditionalExpression) is found
324
624
  if (current.type === 'ConditionalExpression') {
325
625
  const test = current.test;
326
- // Verifica se o teste é "theme.vars"
626
+ // Check if the test is "theme.vars"
327
627
  if (
328
628
  test?.type === 'MemberExpression' &&
329
629
  test.object?.name === 'theme' &&
330
630
  test.property?.name === 'vars'
331
631
  ) {
332
- return true; // Ignora warnings quando dentro de ternário com theme.vars
632
+ return true; // Ignore warnings when inside ternary with theme.vars
333
633
  }
334
634
  }
335
635
  }
@@ -343,8 +643,8 @@ const muiV7Rules = {
343
643
  const sourceTextCache = new WeakMap();
344
644
 
345
645
  /**
346
- * Verifica se está dentro de uma função sx que usa theme.vars!
347
- * Exemplo: sx={(theme) => ({ background: `${theme.vars!.palette...}` })}
646
+ * Check if inside an sx function that uses theme.vars!
647
+ * Example: sx={(theme) => ({ background: `${theme.vars!.palette...}` })}
348
648
  */
349
649
  function isUsingNonNullAssertion(node) {
350
650
  if (!node.parent) return false;
@@ -355,52 +655,197 @@ const muiV7Rules = {
355
655
  sourceTextCache.set(node.parent, sourceText);
356
656
  }
357
657
 
358
- // Procura por theme.vars! (non-null assertion)
658
+ // Look for theme.vars! (non-null assertion)
359
659
  return sourceText.includes('theme.vars!');
360
660
  }
361
661
 
362
662
  return {
363
663
  MemberExpression(node) {
364
- // Detecta theme.palette.* (sem .vars)
664
+ // Detect theme.palette.* (without .vars)
365
665
  // Optimized: use optional chaining and early returns
366
666
  if (node.object?.type !== 'MemberExpression') return;
367
667
  if (node.object.object?.name !== 'theme') return;
368
668
  if (node.object.property?.name !== 'palette') return;
369
669
 
370
- // Verifica se não é theme.vars.palette
670
+ // Check it's not theme.vars.palette
371
671
  const parent = node.object.object;
372
672
  if (parent.type !== 'Identifier' || parent.name !== 'theme') return;
373
673
 
374
- // Ignora se estiver dentro de um ternário que checa theme.vars
674
+ // Ignore if inside a ternary that checks theme.vars
375
675
  if (isInsideThemeVarsConditional(node)) return;
376
676
 
377
- // Ignora se está usando theme.vars! (non-null assertion)
677
+ // Ignore if already using theme.vars! (non-null assertion)
378
678
  if (isUsingNonNullAssertion(node)) return;
379
679
 
380
680
  context.report({
381
681
  node,
382
682
  messageId: 'useThemeVars',
683
+ fix(fixer) {
684
+ // Transform: theme.palette.* → theme.vars.palette.*
685
+ // node.object.object is 'theme'
686
+ // node.object.property is 'palette'
687
+ const themeNode = node.object.object;
688
+
689
+ // Insert '.vars' after 'theme'
690
+ const insertPosition = themeNode.range[1];
691
+ return fixer.insertTextAfterRange(
692
+ [insertPosition, insertPosition],
693
+ '.vars'
694
+ );
695
+ },
383
696
  });
384
697
  },
385
698
  };
386
699
  },
387
700
  },
701
+
702
+ 'no-deep-imports': {
703
+ meta: {
704
+ type: 'problem',
705
+ docs: {
706
+ description: 'Deep imports break in MUI V7 due to new exports field',
707
+ category: 'Breaking Changes',
708
+ recommended: true,
709
+ },
710
+ messages: {
711
+ deepImport: '📦 Deep imports no longer work in MUI V7!\n\n' +
712
+ '🔧 Old way (V6):\n' +
713
+ ' import {{ importName }} from "{{ source }}"\n\n' +
714
+ '✅ New way (V7):\n' +
715
+ ' import { {{ importName }} } from "{{ suggestedSource }}"\n\n' +
716
+ '💡 MUI V7 uses exports field in package.json, which blocks deep imports.',
717
+ },
718
+ schema: [],
719
+ fixable: 'code',
720
+ },
721
+ create(context) {
722
+ return {
723
+ ImportDeclaration(node) {
724
+ const source = node.source.value;
725
+
726
+ // Detect MUI deep imports (more than one level deep)
727
+ // Example: @mui/material/styles/createTheme
728
+ const muiDeepImportRegex = /^@mui\/(material|system|joy)\/([^/]+)\/(.+)$/;
729
+ const match = source.match(muiDeepImportRegex);
730
+
731
+ if (match) {
732
+ const [, package_, componentDir, deepPath] = match;
733
+ const suggestedSource = `@mui/${package_}`;
734
+
735
+ // Infer component name from directory (e.g., Button from /Button/...)
736
+ const importName = componentDir;
737
+ const localName = node.specifiers[0]?.local?.name || importName;
738
+
739
+ context.report({
740
+ node,
741
+ messageId: 'deepImport',
742
+ data: {
743
+ source,
744
+ suggestedSource,
745
+ importName,
746
+ },
747
+ fix(fixer) {
748
+ const fixes = [fixer.replaceText(node.source, `"${suggestedSource}"`)];
749
+
750
+ // Convert default import to named import
751
+ if (node.specifiers[0]?.type === 'ImportDefaultSpecifier') {
752
+ // If the local name equals the imported name, use simple syntax
753
+ if (importName === localName) {
754
+ fixes.push(fixer.replaceText(node.specifiers[0], `{ ${importName} }`));
755
+ } else {
756
+ fixes.push(fixer.replaceText(node.specifiers[0], `{ ${importName} as ${localName} }`));
757
+ }
758
+ }
759
+
760
+ return fixes;
761
+ },
762
+ });
763
+ }
764
+ },
765
+ };
766
+ },
767
+ },
768
+
769
+ 'no-grid-legacy': {
770
+ meta: {
771
+ type: 'problem',
772
+ docs: {
773
+ description: 'Old Grid was renamed to GridLegacy in MUI V7',
774
+ category: 'Breaking Changes',
775
+ recommended: true,
776
+ },
777
+ messages: {
778
+ gridLegacyImport: '⚠️ The old Grid was deprecated and renamed to GridLegacy!\n\n' +
779
+ '🔧 You are using:\n' +
780
+ ' import Grid from "@mui/material/Grid"\n\n' +
781
+ '✅ Option 1 - Continue using old Grid (temporary):\n' +
782
+ ' import { GridLegacy as Grid } from "@mui/material"\n\n' +
783
+ '✅ Option 2 - Migrate to new Grid (recommended):\n' +
784
+ ' import { Grid } from "@mui/material"\n' +
785
+ ' // Use size={{ "{"}xs: 12{"}"} }} instead of item xs={12}\n\n' +
786
+ '💡 The new Grid is more powerful and uses the `size` prop!\n' +
787
+ ' See: https://mui.com/material-ui/migration/upgrade-to-grid-v2/',
788
+ },
789
+ schema: [],
790
+ fixable: 'code',
791
+ },
792
+ create(context) {
793
+ return {
794
+ ImportDeclaration(node) {
795
+ const source = node.source.value;
796
+
797
+ // Detect direct import of old Grid: import Grid from '@mui/material/Grid'
798
+ if (source === '@mui/material/Grid') {
799
+ context.report({
800
+ node,
801
+ messageId: 'gridLegacyImport',
802
+ fix(fixer) {
803
+ // Suggested fix: change to GridLegacy
804
+ const fixes = [fixer.replaceText(node.source, '"@mui/material"')];
805
+
806
+ // Rename default import to GridLegacy as Grid
807
+ if (node.specifiers.length > 0 && node.specifiers[0].type === 'ImportDefaultSpecifier') {
808
+ const localName = node.specifiers[0].local.name;
809
+ if (localName === 'Grid') {
810
+ fixes.push(fixer.replaceText(node.specifiers[0], '{ GridLegacy as Grid }'));
811
+ } else {
812
+ // Keep custom alias
813
+ fixes.push(fixer.replaceText(node.specifiers[0], `{ GridLegacy as ${localName} }`));
814
+ }
815
+ }
816
+
817
+ return fixes;
818
+ },
819
+ });
820
+ }
821
+ },
822
+ };
823
+ },
824
+ },
388
825
  };
389
826
 
390
- // Exporta o plugin (CommonJS)
827
+ // Export the plugin (ESM and CommonJS compatible)
391
828
  const plugin = {
829
+ meta: {
830
+ name: packageJson.name,
831
+ version: packageJson.version,
832
+ },
392
833
  rules: muiV7Rules,
393
834
  configs: {
394
835
  recommended: {
395
836
  plugins: ['mui-v7'],
396
837
  rules: {
397
- // Breaking changes - ERRORS (código quebra)
838
+ // Breaking changes - ERRORS (code breaks)
398
839
  'mui-v7/no-unstable-grid': 'error',
399
840
  'mui-v7/no-grid2-import': 'error',
400
841
  'mui-v7/no-grid-item-prop': 'error',
401
842
  'mui-v7/no-lab-imports': 'error',
402
843
  'mui-v7/no-deprecated-props': 'error',
403
- // Best practices - WARNINGS (sugestões)
844
+ 'mui-v7/no-deprecated-imports': 'error',
845
+ 'mui-v7/no-deep-imports': 'error',
846
+ 'mui-v7/no-grid-legacy': 'error',
847
+ // Best practices - WARNINGS (suggestions)
848
+ 'mui-v7/prefer-slots-api': 'warn',
404
849
  'mui-v7/prefer-theme-vars': 'warn',
405
850
  },
406
851
  },
@@ -413,7 +858,11 @@ const plugin = {
413
858
  'mui-v7/no-grid-item-prop': 'error',
414
859
  'mui-v7/no-lab-imports': 'error',
415
860
  'mui-v7/no-deprecated-props': 'error',
416
- // Best practices - ERRORS também no strict
861
+ 'mui-v7/no-deprecated-imports': 'error',
862
+ 'mui-v7/no-deep-imports': 'error',
863
+ 'mui-v7/no-grid-legacy': 'error',
864
+ // Best practices - ERRORS also in strict mode
865
+ 'mui-v7/prefer-slots-api': 'error',
417
866
  'mui-v7/prefer-theme-vars': 'error',
418
867
  },
419
868
  },