Skip to content

Commit 25131d6

Browse files
authored
[code-infra] Add plugin to check for index file access (#46178)
1 parent e92232b commit 25131d6

File tree

8 files changed

+209
-2
lines changed

8 files changed

+209
-2
lines changed

.eslintrc.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,24 @@ module.exports = /** @type {Config} */ ({
280280
'id-denylist': ['error', 'e'],
281281
},
282282
overrides: [
283+
...['mui-material', 'mui-system', 'mui-utils', 'mui-lab', 'mui-utils', 'mui-styled-engine'].map(
284+
(packageName) => ({
285+
files: [`packages/${packageName}/src/**/*.?(c|m)[jt]s?(x)`],
286+
excludedFiles: ['*.test.*', '*.spec.*'],
287+
rules: {
288+
'material-ui/no-restricted-resolved-imports': [
289+
'error',
290+
[
291+
{
292+
pattern: `**/packages/${packageName}/src/index.*`,
293+
message:
294+
"Don't import from the package index. Import the specific module directly instead.",
295+
},
296+
],
297+
],
298+
},
299+
}),
300+
),
283301
{
284302
files: [
285303
// matching the pattern of the test runner

packages/eslint-plugin-material-ui/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
"description": "Custom eslint rules for Material UI.",
55
"main": "src/index.js",
66
"dependencies": {
7-
"emoji-regex": "^10.4.0"
7+
"emoji-regex": "^10.4.0",
8+
"eslint-module-utils": "^2.12.0",
9+
"minimatch": "^3.1.2"
810
},
911
"devDependencies": {
1012
"@types/eslint": "^8.56.12",

packages/eslint-plugin-material-ui/src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ module.exports.rules = {
99
'no-styled-box': require('./rules/no-styled-box'),
1010
'straight-quotes': require('./rules/straight-quotes'),
1111
'disallow-react-api-in-server-components': require('./rules/disallow-react-api-in-server-components'),
12+
'no-restricted-resolved-imports': require('./rules/no-restricted-resolved-imports'),
1213
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Main package entry point
2+
export { default as Button } from './components/Button';
3+
export { default as TextField } from './components/TextField';
4+
export { default as capitalize } from './utils/capitalize';
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
const path = require('path');
2+
const resolve = require('eslint-module-utils/resolve').default;
3+
const moduleVisitor = require('eslint-module-utils/moduleVisitor').default;
4+
const minimatch = require('minimatch');
5+
/**
6+
* @typedef {Object} PatternConfig
7+
* @property {string} pattern - The pattern to match against resolved imports
8+
* @property {string} [message] - Custom message to show when the pattern matches
9+
*/
10+
11+
/**
12+
* Creates an ESLint rule that restricts imports based on their resolved paths.
13+
* Works with both ESM (import) and CommonJS (require) imports.
14+
*
15+
* @type {import('eslint').Rule.RuleModule}
16+
*/
17+
const rule = {
18+
meta: {
19+
docs: {
20+
description: 'Disallow imports that resolve to certain patterns.',
21+
},
22+
messages: {
23+
restrictedResolvedImport:
24+
'Importing from "{{importSource}}" is restricted because it resolves to "{{resolvedPath}}", which matches the pattern "{{pattern}}".{{customMessage}}',
25+
},
26+
type: 'suggestion',
27+
schema: [
28+
{
29+
type: 'array',
30+
items: {
31+
type: 'object',
32+
properties: {
33+
pattern: { type: 'string' },
34+
message: { type: 'string' },
35+
},
36+
required: ['pattern'],
37+
additionalProperties: false,
38+
},
39+
},
40+
],
41+
},
42+
create(context) {
43+
const options = context.options[0] || [];
44+
45+
if (!Array.isArray(options) || options.length === 0) {
46+
return {};
47+
}
48+
49+
return moduleVisitor(
50+
(source, node) => {
51+
// Get the resolved path of the import
52+
const resolvedPath = resolve(source.value, context);
53+
54+
if (!resolvedPath) {
55+
return;
56+
}
57+
58+
// Normalize the resolved path to use forward slashes
59+
const normalizedPath = resolvedPath.split(path.sep).join('/');
60+
61+
// Check each pattern against the resolved path
62+
for (const option of options) {
63+
const { pattern, message = '' } = option;
64+
65+
if (minimatch(normalizedPath, pattern)) {
66+
context.report({
67+
node,
68+
messageId: 'restrictedResolvedImport',
69+
data: {
70+
importSource: source.value,
71+
resolvedPath: normalizedPath,
72+
pattern,
73+
customMessage: message ? ` ${message}` : '',
74+
},
75+
});
76+
77+
// Stop after first match
78+
break;
79+
}
80+
}
81+
},
82+
{ commonjs: true, es6: true },
83+
); // This handles both require() and import statements
84+
},
85+
};
86+
87+
module.exports = rule;
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
const eslint = require('eslint');
2+
const path = require('path');
3+
const rule = require('./no-restricted-resolved-imports');
4+
5+
// Get absolute paths for our fixtures
6+
const fixturesDir = path.resolve(__dirname, './__fixtures__/no-restricted-resolved-imports');
7+
const mockPackageDir = path.join(fixturesDir, 'mock-package');
8+
const badFilePath = path.join(mockPackageDir, 'src/components/ButtonGroup/index.js');
9+
const goodFilePath = path.join(mockPackageDir, 'src/components/GoodExample/index.js');
10+
11+
// Create a custom rule tester with the fixture's ESLint configuration
12+
const ruleTester = new eslint.RuleTester({
13+
parser: require.resolve('@typescript-eslint/parser'),
14+
parserOptions: {
15+
ecmaVersion: 2018,
16+
sourceType: 'module',
17+
},
18+
settings: {
19+
'import/resolver': {
20+
node: {
21+
extensions: ['.js', '.jsx', '.ts', '.tsx'],
22+
paths: [path.join(mockPackageDir, 'src')],
23+
moduleDirectory: ['node_modules', path.join(mockPackageDir, 'src')],
24+
},
25+
},
26+
},
27+
});
28+
29+
// ESLint requires the files to actually exist for the resolver to work
30+
// So we're using real files in the test fixtures
31+
ruleTester.run('no-restricted-resolved-imports', rule, {
32+
valid: [
33+
// No options provided - rule shouldn't apply
34+
{
35+
code: "import { Button } from '../../index';",
36+
filename: badFilePath,
37+
options: [],
38+
},
39+
// Empty options array - rule shouldn't apply
40+
{
41+
code: "import { Button } from '../../index';",
42+
filename: badFilePath,
43+
options: [[]],
44+
},
45+
// Good example - importing from the component directly
46+
{
47+
code: "import Button from '../Button';",
48+
filename: goodFilePath,
49+
options: [
50+
[
51+
{
52+
pattern: '**/mock-package/src/index.js',
53+
message: 'Import the specific module directly instead of from the package index.',
54+
},
55+
],
56+
],
57+
},
58+
],
59+
invalid: [
60+
// Bad example - importing from the package index
61+
{
62+
code: "import { Button } from '../../index';",
63+
filename: badFilePath,
64+
options: [
65+
[
66+
{
67+
pattern: '**/mock-package/src/index.js',
68+
message: 'Import the specific module directly instead of from the package index.',
69+
},
70+
],
71+
],
72+
errors: [
73+
{
74+
messageId: 'restrictedResolvedImport',
75+
},
76+
],
77+
},
78+
],
79+
});

packages/mui-material/src/Chip/Chip.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as React from 'react';
22
import { OverridableStringUnion } from '@mui/types';
33
import { SxProps } from '@mui/system';
4-
import { CreateSlotsAndSlotProps, SlotProps } from '..';
4+
import { CreateSlotsAndSlotProps, SlotProps } from '../utils/types';
55
import { Theme } from '../styles';
66
import { OverridableComponent, OverrideProps } from '../OverridableComponent';
77
import { ChipClasses } from './chipClasses';

pnpm-lock.yaml

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)