Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .oxlint-plugins/metamask-core.ts
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;
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"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "@metamask/without-exports",
"main": "./dist/index.js"
}
6 changes: 6 additions & 0 deletions .oxlint-plugins/rules/constants.ts
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';
112 changes: 112 additions & 0 deletions .oxlint-plugins/rules/no-barrel-files.test.ts
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, {

Copy link
Copy Markdown
Collaborator Author

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

Copy link
Copy Markdown
Member

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.

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);
}
195 changes: 195 additions & 0 deletions .oxlint-plugins/rules/no-barrel-files.ts
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('/');
}
Loading
Loading