Repository navigation
chore: Ban barrel files and wildcard re-exports #10776
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mcmire
wants to merge
1
commit into
main
Choose a base branch
from
ban-barrel-exports-2
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import { eslintCompatPlugin } from '@oxlint/plugins'; | ||
|
|
||
| import { noBarrelFiles } from './rules/no-barrel-files.ts'; | ||
| import { noWildcardExports } from './rules/no-wildcard-exports.ts'; | ||
|
|
||
| /** | ||
| * Oxlint plugin containing custom rules for this monorepo. | ||
| * | ||
| * This is loaded via `jsPlugins` in `oxlint.config.ts`, and its rules are | ||
| * referenced there with the `metamask-core/` prefix. | ||
| */ | ||
| // We use `eslintCompatPlugin` as it's more performant (according to the Oxlint | ||
| // documentation, anyway). | ||
| const plugin = eslintCompatPlugin({ | ||
| meta: { | ||
| name: 'metamask-core', | ||
| }, | ||
| rules: { | ||
| 'no-wildcard-exports': noWildcardExports, | ||
| 'no-barrel-files': noBarrelFiles, | ||
| }, | ||
| }); | ||
|
|
||
| export default plugin; |
24 changes: 24 additions & 0 deletions
24
.oxlint-plugins/rules/__fixtures__/packages/with-subpath-exports/package.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| { | ||
| "name": "@metamask/with-subpath-exports", | ||
| "exports": { | ||
| ".": { | ||
| "types": "./dist/index.d.ts", | ||
| "default": "./dist/index.js" | ||
| }, | ||
| "./v2": { | ||
| "import": { | ||
| "types": "./dist/v2/index.d.mts", | ||
| "default": "./dist/v2/index.mjs" | ||
| }, | ||
| "require": { | ||
| "types": "./dist/v2/index.d.cts", | ||
| "default": "./dist/v2/index.cjs" | ||
| } | ||
| }, | ||
| "./utils/*": { | ||
| "types": "./dist/utils/*.d.ts", | ||
| "default": "./dist/utils/*.js" | ||
| }, | ||
| "./package.json": "./package.json" | ||
| } | ||
| } |
4 changes: 4 additions & 0 deletions
4
.oxlint-plugins/rules/__fixtures__/packages/without-exports/package.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| { | ||
| "name": "@metamask/without-exports", | ||
| "main": "./dist/index.js" | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| /** | ||
| * The location of the guidelines that explain how to resolve violations of | ||
| * the rules in this plugin. | ||
| */ | ||
| export const PACKAGE_GUIDELINES_PATH = | ||
| 'docs/code-guidelines/package-guidelines.md'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| import * as path from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import { RuleTester } from 'oxlint/plugins-dev'; | ||
|
|
||
| import { noBarrelFiles } from './no-barrel-files.ts'; | ||
|
|
||
| const FIXTURES_DIRECTORY = fileURLToPath( | ||
| new URL('__fixtures__', import.meta.url), | ||
| ); | ||
|
|
||
| // This is a bit strange but is recommended. | ||
| // See: https://oxc.rs/docs/guide/usage/linter/writing-js-plugins.html#writing-tests-for-custom-rules | ||
| RuleTester.describe = describe; | ||
| RuleTester.it = it; | ||
|
|
||
| const ruleTester = new RuleTester({ | ||
| cwd: FIXTURES_DIRECTORY, | ||
| languageOptions: { | ||
| sourceType: 'module', | ||
| }, | ||
| }); | ||
|
|
||
| ruleTester.run('no-barrel-files', noBarrelFiles, { | ||
| valid: [ | ||
| { | ||
| name: 'the root index file of a package', | ||
| filename: getFixturePath('packages/with-subpath-exports/src/index.ts'), | ||
| code: "export { foo } from './foo';", | ||
| }, | ||
| { | ||
| name: 'an index file that is the entrypoint for a subpath export', | ||
| filename: getFixturePath('packages/with-subpath-exports/src/v2/index.ts'), | ||
| code: "export { foo } from './foo';", | ||
| }, | ||
| { | ||
| name: 'a non-index file in a subdirectory', | ||
| filename: getFixturePath( | ||
| 'packages/with-subpath-exports/src/module/foo.ts', | ||
| ), | ||
| code: 'export const foo = 1;', | ||
| }, | ||
| { | ||
| name: 'a file matching a wildcard subpath export', | ||
| filename: getFixturePath( | ||
| 'packages/with-subpath-exports/src/utils/index.ts', | ||
| ), | ||
| code: 'export const foo = 1;', | ||
| }, | ||
| { | ||
| name: 'an index file outside of a package source directory', | ||
| filename: getFixturePath('scripts/module/index.ts'), | ||
| code: 'export const foo = 1;', | ||
| }, | ||
| { | ||
| name: 'an index file in a package without an `exports` field at its root', | ||
| filename: getFixturePath('packages/without-exports/src/index.ts'), | ||
| code: 'export const foo = 1;', | ||
| }, | ||
| ], | ||
|
|
||
| invalid: [ | ||
| { | ||
| name: 'an index file in a subdirectory of a package', | ||
| filename: getFixturePath( | ||
| 'packages/with-subpath-exports/src/module/index.ts', | ||
| ), | ||
| code: "export { foo } from './foo';", | ||
| errors: [{ messageId: 'noSubpathIndexFile' }], | ||
| }, | ||
| { | ||
| name: 'an index file nested several levels deep', | ||
| filename: getFixturePath( | ||
| 'packages/with-subpath-exports/src/v2/module/index.ts', | ||
| ), | ||
| code: "export { foo } from './foo';", | ||
| errors: [{ messageId: 'noSubpathIndexFile' }], | ||
| }, | ||
| { | ||
| name: 'an index file whose subdirectory is not exported', | ||
| filename: getFixturePath('packages/without-exports/src/module/index.ts'), | ||
| code: "export { foo } from './foo';", | ||
| errors: [{ messageId: 'noSubpathIndexFile' }], | ||
| }, | ||
| { | ||
| name: 'an index file with an extension other than `.ts`', | ||
| filename: getFixturePath( | ||
| 'packages/with-subpath-exports/src/module/index.mts', | ||
| ), | ||
| code: "export { foo } from './foo';", | ||
| errors: [{ messageId: 'noSubpathIndexFile' }], | ||
| }, | ||
| { | ||
| name: 'an index file with no contents', | ||
| filename: getFixturePath( | ||
| 'packages/with-subpath-exports/src/module/index.ts', | ||
| ), | ||
| code: '', | ||
| errors: [{ messageId: 'noSubpathIndexFile' }], | ||
| }, | ||
| ], | ||
| }); | ||
|
|
||
| /** | ||
| * Builds an absolute path to a file within the fixtures directory. | ||
| * | ||
| * @param relativePath - The path to the file, relative to the fixtures | ||
| * directory. | ||
| * @returns The absolute path. | ||
| */ | ||
| function getFixturePath(relativePath: string): string { | ||
| return path.join(FIXTURES_DIRECTORY, relativePath); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,195 @@ | ||
| import { defineRule } from '@oxlint/plugins'; | ||
| import type { VisitorWithHooks } from '@oxlint/plugins'; | ||
| import * as fs from 'node:fs'; | ||
| import * as path from 'node:path'; | ||
|
|
||
| import { PACKAGE_GUIDELINES_PATH } from './constants.ts'; | ||
|
|
||
| /** | ||
| * Matches the name of an index file, such as `index.ts` or `index.mjs`. | ||
| */ | ||
| const INDEX_FILE_NAME_PATTERN = /^index\.[cm]?[jt]sx?$/u; | ||
|
|
||
| /** | ||
| * Matches the extension of a built file that an `exports` target may point | ||
| * to, such as `.js` or `.d.mts`. | ||
| */ | ||
| const BUILT_FILE_EXTENSION_PATTERN = /(?:\.d)?\.[cm]?js$|\.d\.[cm]?ts$/u; | ||
|
|
||
| /** | ||
| * The prefix that `exports` targets for built files begin with. | ||
| */ | ||
| const BUILT_FILE_PREFIX = './dist/'; | ||
|
|
||
| /** | ||
| * Entrypoints for each package, keyed by the path to the package directory. | ||
| * | ||
| * Reading and parsing `package.json` for every linted file would be wasteful, | ||
| * so each package's entrypoints are computed once per lint run. | ||
| */ | ||
| const entrypointPatternsByPackageDirectory = new Map<string, RegExp[]>(); | ||
|
|
||
| /** | ||
| * Rule that bans `index` files in subdirectories of a package's `src/` | ||
| * directory, unless they serve as an entrypoint for a subpath export. | ||
| */ | ||
| export const noBarrelFiles = defineRule({ | ||
| meta: { | ||
| type: 'problem', | ||
| docs: { | ||
| description: | ||
| 'Disallow "barrel" files in subdirectories of a package, except for subpath entrypoints.', | ||
| }, | ||
| schema: [], | ||
| messages: { | ||
| noSubpathIndexFile: `"Barrel" files are not allowed in subdirectories of a package unless they are entrypoints listed under \`exports\` in \`package.json\`. Files like these hide the public surface area of a package. Export each symbol by name from the package's root \`index.ts\` instead. Learn more: ${PACKAGE_GUIDELINES_PATH}.`, | ||
| }, | ||
| }, | ||
|
|
||
| createOnce(context): VisitorWithHooks { | ||
| return { | ||
| Program(node) { | ||
| if (isDisallowedIndexFile(context.filename)) { | ||
| context.report({ node, messageId: 'noSubpathIndexFile' }); | ||
| } | ||
| }, | ||
| }; | ||
| }, | ||
| }); | ||
|
|
||
| /** | ||
| * Determines whether the given file is an index file that is neither the | ||
| * root index file of a package nor an entrypoint for a subpath export. | ||
| * | ||
| * @param filePath - The absolute path to the file being linted. | ||
| * @returns True if the file should be reported, false otherwise. | ||
| */ | ||
| function isDisallowedIndexFile(filePath: string): boolean { | ||
| if (!INDEX_FILE_NAME_PATTERN.test(path.basename(filePath))) { | ||
| return false; | ||
| } | ||
|
|
||
| const packageDirectory = findPackageDirectory(filePath); | ||
| if (packageDirectory === null) { | ||
| return false; | ||
| } | ||
|
|
||
| const sourcePath = toPosixPath( | ||
| path.relative(path.join(packageDirectory, 'src'), filePath), | ||
| ); | ||
| if (!sourcePath.includes('/')) { | ||
| return false; | ||
| } | ||
|
|
||
| const sourcePathWithoutExtension = sourcePath.replace(/\.[^./]+$/u, ''); | ||
| return !getEntrypointPatterns(packageDirectory).some((pattern) => | ||
| pattern.test(sourcePathWithoutExtension), | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Finds the package that the given file belongs to, as long as the file lives | ||
| * within the package's `src/` directory. | ||
| * | ||
| * @param filePath - The absolute path to the file being linted. | ||
| * @returns The absolute path to the package directory, or null if the file is | ||
| * not within the `src/` directory of a package. | ||
| */ | ||
| function findPackageDirectory(filePath: string): string | null { | ||
| let directory = path.dirname(filePath); | ||
|
|
||
| while (directory !== path.dirname(directory)) { | ||
| const parentDirectory = path.dirname(directory); | ||
| if ( | ||
| path.basename(directory) === 'src' && | ||
| fs.existsSync(path.join(parentDirectory, 'package.json')) | ||
| ) { | ||
| return parentDirectory; | ||
| } | ||
| directory = parentDirectory; | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Gets patterns that match the source files (relative to `src/`, without | ||
| * extensions) which serve as entrypoints for the given package. | ||
| * | ||
| * @param packageDirectory - The absolute path to the package directory. | ||
| * @returns The entrypoint patterns. | ||
| */ | ||
| function getEntrypointPatterns(packageDirectory: string): RegExp[] { | ||
| const cachedPatterns = | ||
| entrypointPatternsByPackageDirectory.get(packageDirectory); | ||
| if (cachedPatterns) { | ||
| return cachedPatterns; | ||
| } | ||
|
|
||
| const manifest: unknown = JSON.parse( | ||
| fs.readFileSync(path.join(packageDirectory, 'package.json'), 'utf8'), | ||
| ); | ||
| const exportsField = | ||
| typeof manifest === 'object' && manifest !== null && 'exports' in manifest | ||
| ? manifest.exports | ||
| : undefined; | ||
| const patterns = collectExportTargets(exportsField) | ||
| .filter((target) => target.startsWith(BUILT_FILE_PREFIX)) | ||
| .map((target) => | ||
| convertToSourcePattern( | ||
| target | ||
| .slice(BUILT_FILE_PREFIX.length) | ||
| .replace(BUILT_FILE_EXTENSION_PATTERN, ''), | ||
| ), | ||
| ); | ||
|
|
||
| entrypointPatternsByPackageDirectory.set(packageDirectory, patterns); | ||
| return patterns; | ||
| } | ||
|
|
||
| /** | ||
| * Collects every file path that an `exports` field in `package.json` points | ||
| * to, however deeply nested its conditions are. | ||
| * | ||
| * @param exportsValue - The `exports` field, or a value nested within it. | ||
| * @returns The file paths. | ||
| */ | ||
| function collectExportTargets(exportsValue: unknown): string[] { | ||
| if (typeof exportsValue === 'string') { | ||
| return [exportsValue]; | ||
| } | ||
|
|
||
| if (typeof exportsValue === 'object' && exportsValue !== null) { | ||
| return Object.values(exportsValue).flatMap(collectExportTargets); | ||
| } | ||
|
|
||
| return []; | ||
| } | ||
|
|
||
| /** | ||
| * Converts a path from an `exports` target, which may contain a `*` | ||
| * wildcard, into a pattern that matches source paths. | ||
| * | ||
| * @param targetPath - The path from the `exports` target, relative to `dist/` | ||
| * and without an extension. | ||
| * @returns The pattern. | ||
| */ | ||
| function convertToSourcePattern(targetPath: string): RegExp { | ||
| const escapedPath = targetPath | ||
| .split('*') | ||
| .map((segment) => segment.replace(/[.+?^${}()|[\]\\]/gu, '\\$&')) | ||
| .join('.+'); | ||
| return new RegExp(`^${escapedPath}$`, 'u'); | ||
| } | ||
|
|
||
| /** | ||
| * Converts a path that uses the separator for the current platform into one | ||
| * that uses forward slashes, so that it can be compared with paths in | ||
| * `package.json`. | ||
| * | ||
| * @param filePath - The path to convert. | ||
| * @returns The converted path. | ||
| */ | ||
| function toPosixPath(filePath: string): string { | ||
| return filePath.split(path.sep).join('/'); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I learned about Oxlint's RuleTester class while adding this: https://oxc.rs/docs/guide/usage/linter/writing-js-plugins.html#writing-tests-for-custom-rules
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice, we should add that to the Oxlint config repo.