Skip to content

Commit 9e07386

Browse files
committed
[code-infra] Add plugin to check for index file access
1 parent 9c6871d commit 9e07386

File tree

5 files changed

+196
-0
lines changed

5 files changed

+196
-0
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}/**/*.?(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/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: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
const path = require('path');
2+
const resolve = require('eslint-module-utils/resolve').default;
3+
const moduleVisitor = require('eslint-module-utils/moduleVisitor').default;
4+
/**
5+
* @typedef {Object} PatternConfig
6+
* @property {string} pattern - The pattern to match against resolved imports
7+
* @property {string} [message] - Custom message to show when the pattern matches
8+
*/
9+
10+
/**
11+
* Creates an ESLint rule that restricts imports based on their resolved paths.
12+
* Works with both ESM (import) and CommonJS (require) imports.
13+
*
14+
* @type {import('eslint').Rule.RuleModule}
15+
*/
16+
const rule = {
17+
meta: {
18+
docs: {
19+
description: 'Disallow imports that resolve to certain patterns.',
20+
},
21+
messages: {
22+
restrictedResolvedImport:
23+
'Importing from "{{importSource}}" is restricted because it resolves to "{{resolvedPath}}", which matches the pattern "{{pattern}}".{{customMessage}}',
24+
},
25+
type: 'suggestion',
26+
schema: [
27+
{
28+
type: 'array',
29+
items: {
30+
type: 'object',
31+
properties: {
32+
pattern: { type: 'string' },
33+
message: { type: 'string' },
34+
},
35+
required: ['pattern'],
36+
additionalProperties: false,
37+
},
38+
},
39+
],
40+
},
41+
create(context) {
42+
const options = context.options[0] || [];
43+
44+
if (!Array.isArray(options) || options.length === 0) {
45+
return {};
46+
}
47+
48+
return moduleVisitor(
49+
(source, node) => {
50+
// Get the resolved path of the import
51+
const resolvedPath = resolve(source.value, context);
52+
53+
if (!resolvedPath) {
54+
return;
55+
}
56+
57+
// Normalize the resolved path to use forward slashes
58+
const normalizedPath = resolvedPath.split(path.sep).join('/');
59+
60+
// Check each pattern against the resolved path
61+
for (const option of options) {
62+
const { pattern, message = '' } = option;
63+
64+
// Convert the pattern to a regex
65+
// Escape special characters and convert * to .*
66+
const regexPattern = new RegExp(
67+
pattern
68+
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape special regex chars except *
69+
.replace(/\*/g, '.*'), // Convert * to .* for wildcard matching
70+
);
71+
72+
if (regexPattern.test(normalizedPath)) {
73+
context.report({
74+
node,
75+
messageId: 'restrictedResolvedImport',
76+
data: {
77+
importSource: source.value,
78+
resolvedPath: normalizedPath,
79+
pattern,
80+
customMessage: message ? ` ${message}` : '',
81+
},
82+
});
83+
84+
// Stop after first match
85+
break;
86+
}
87+
}
88+
},
89+
{ commonjs: true, es6: true },
90+
); // This handles both require() and import statements
91+
},
92+
};
93+
94+
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+
});

0 commit comments

Comments
 (0)