From 58abcdf1ed9e11430e479db7f39dea25270a6be5 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 18 Mar 2026 13:07:33 -0600 Subject: [PATCH 1/7] Prohibit cross-package relative imports There are some packages which import files from other packages using relative imports. This won't work in production code, and TypeScript will type the imports as `any`, which will damage type-safety upstream. This commit adds an ESLint rule which checks to ensure that cross-package relative imports are not being used and suggests that the engineer directly import the package instead. There are some existing lint violations, so this commit also instructs ESLint to suppress them. --- eslint-suppressions.json | 21 +++ eslint.config.mjs | 22 +++ .../no-cross-package-relative-imports.mjs | 128 ++++++++++++++++++ 3 files changed, 171 insertions(+) create mode 100644 scripts/eslint-rules/no-cross-package-relative-imports.mjs diff --git a/eslint-suppressions.json b/eslint-suppressions.json index e1ad67225a3..486fade2828 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -90,6 +90,11 @@ "count": 1 } }, + "packages/account-tree-controller/src/types.ts": { + "local/no-cross-package-relative-imports": { + "count": 1 + } + }, "packages/account-tree-controller/tests/mockMessenger.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 1 @@ -127,6 +132,11 @@ "count": 6 } }, + "packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts": { + "local/no-cross-package-relative-imports": { + "count": 1 + } + }, "packages/assets-controllers/jest.environment.js": { "n/prefer-global/text-decoder": { "count": 1 @@ -251,6 +261,9 @@ "id-length": { "count": 1 }, + "local/no-cross-package-relative-imports": { + "count": 1 + }, "no-negated-condition": { "count": 1 }, @@ -258,6 +271,11 @@ "count": 2 } }, + "packages/assets-controllers/src/NftDetectionController.ts": { + "local/no-cross-package-relative-imports": { + "count": 1 + } + }, "packages/assets-controllers/src/RatesController/RatesController.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 1 @@ -614,6 +632,9 @@ "packages/bridge-status-controller/src/utils/transaction.ts": { "@typescript-eslint/prefer-nullish-coalescing": { "count": 1 + }, + "local/no-cross-package-relative-imports": { + "count": 1 } }, "packages/chain-agnostic-permission/src/caip25Permission.ts": { diff --git a/eslint.config.mjs b/eslint.config.mjs index a124c6f9948..d7fd9942c55 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,6 +3,10 @@ import jest from '@metamask/eslint-config-jest'; import nodejs from '@metamask/eslint-config-nodejs'; import typescript from '@metamask/eslint-config-typescript'; +// ESLint can load this file. +// eslint-disable-next-line import-x/extensions +import noCrossPackageRelativeImports from './scripts/eslint-rules/no-cross-package-relative-imports.mjs'; + const NODE_LTS_VERSION = 22; const config = createConfig([ @@ -53,6 +57,24 @@ const config = createConfig([ ecmaVersion: 2020, }, }, + { + // Prohibit relative imports that cross package boundaries in non-test files. + // This is like the `no-relative-packages` rule in the `import-x` plugin, + // but does not suggest that engineers use subpath imports that may or may + // not exist in order to correct lint violations. + files: ['packages/*/src/**/*.ts'], + ignores: ['**/*.test.ts', '**/tests/**/*.ts'], + plugins: { + local: { + rules: { + 'no-cross-package-relative-imports': noCrossPackageRelativeImports, + }, + }, + }, + rules: { + 'local/no-cross-package-relative-imports': 'error', + }, + }, { files: ['**/*.ts'], extends: [typescript], diff --git a/scripts/eslint-rules/no-cross-package-relative-imports.mjs b/scripts/eslint-rules/no-cross-package-relative-imports.mjs new file mode 100644 index 00000000000..fb839b1cb30 --- /dev/null +++ b/scripts/eslint-rules/no-cross-package-relative-imports.mjs @@ -0,0 +1,128 @@ +/** + * ESLint rule: no-cross-package-relative-imports + * + * Forbids relative imports that resolve to a file in a different package + * (i.e. a different nearest `package.json`). The auto-fixer rewrites the + * import to the target package's name, without appending a deep sub-path. + * + * For example, the following + * + * import type { MultichainAccountServiceWalletStatusChangeEvent } from '../../multichain-account-service/src/types'; + * + * would be auto-fixed to: + * + * import type { MultichainAccountServiceWalletStatusChangeEvent } from '@metamask/multichain-account-service'; + */ + +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; + +/** + * Walk up from a directory until a `package.json` is found. + * + * @param {string} startingDirectory - The directory to start searching from. + * @returns {string | undefined} The name of a package if one is found, or + * `undefined` otherwise. + */ +function findPackage(startingDirectory) { + let workingDirectory = startingDirectory; + + while (true) { + const pkgPath = join(workingDirectory, 'package.json'); + try { + // ESLint rules must be synchronous. + // eslint-disable-next-line n/no-sync + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + // Assume that all packages have names + return pkg.name; + } catch { + // No valid package.json here. Keep walking. + } + const parentDirectory = dirname(workingDirectory); + if (parentDirectory === workingDirectory) { + return undefined; + } + workingDirectory = parentDirectory; + } +} + +/** @type {import('eslint').Rule.RuleModule} */ +const rule = { + meta: { + type: 'problem', + docs: { + description: + 'Forbid relative imports that cross package boundaries in a monorepo', + }, + fixable: 'code', + messages: { + noRelativeCrossPackage: + "Do not use a relative path to import files from other packages. Use a known export as declared in the package's manifest.\n" + + "For example, in this case, try: `import { ... } from '{{ importedPackageName }}'`", + }, + schema: [], + }, + + create(context) { + /** + * Check a single import/re-export source literal. + * + * @param {import('estree').Literal | null} sourceNode - The source literal + * node from an import or export declaration. + */ + function check(sourceNode) { + if (!sourceNode) { + return; + } + + const importPath = sourceNode.value; + + // We only care about relative imports + if (!importPath.startsWith('.')) { + return; + } + + const currentDirectory = dirname( + context.physicalFilename ?? context.filename, + ); + const currentPackageName = findPackage(currentDirectory); + const importedPackageName = findPackage( + dirname(resolve(currentDirectory, importPath)), + ); + + if (!currentPackageName || !importedPackageName) { + return; + } + + // It's okay to use a relative path to import a file from our own package + if (currentPackageName === importedPackageName) { + return; + } + + context.report({ + node: sourceNode, + messageId: 'noRelativeCrossPackage', + data: { + importedPackageName, + }, + fix(fixer) { + return fixer.replaceText(sourceNode, `'${importedPackageName}'`); + }, + }); + } + + return { + ImportDeclaration(node) { + check(node.source); + }, + ExportNamedDeclaration(node) { + check(node.source); + }, + ExportAllDeclaration(node) { + check(node.source); + }, + }; + }, +}; + +export default rule; From ebb0ad218c30336df7af0f5ce58d08bda650c672 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 18 Mar 2026 13:18:11 -0600 Subject: [PATCH 2/7] Update suppressions file --- eslint-suppressions.json | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 486fade2828..eb7e341bfc7 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1710,6 +1710,16 @@ "count": 2 } }, + "packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts": { + "local/no-cross-package-relative-imports": { + "count": 1 + } + }, + "packages/transaction-pay-controller/src/utils/source-amounts.ts": { + "local/no-cross-package-relative-imports": { + "count": 1 + } + }, "packages/user-operation-controller/src/UserOperationController.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 6 @@ -1814,4 +1824,4 @@ "count": 1 } } -} +} \ No newline at end of file From ec3ac248cbdacda48f37094400ccdbbc5ef76693 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 18 Mar 2026 13:33:05 -0600 Subject: [PATCH 3/7] Fix Prettier violation --- eslint-suppressions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index eb7e341bfc7..63095c8bd70 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1824,4 +1824,4 @@ "count": 1 } } -} \ No newline at end of file +} From 65ad80e33df616dbc53d032588191c1d8c2e1589 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 18 Mar 2026 14:36:01 -0600 Subject: [PATCH 4/7] Use no-relative-packages instead of a custom rule --- eslint-suppressions.json | 14 +- eslint.config.mjs | 20 +-- .../no-cross-package-relative-imports.mjs | 128 ------------------ 3 files changed, 11 insertions(+), 151 deletions(-) delete mode 100644 scripts/eslint-rules/no-cross-package-relative-imports.mjs diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 63095c8bd70..f1a268032ce 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -91,7 +91,7 @@ } }, "packages/account-tree-controller/src/types.ts": { - "local/no-cross-package-relative-imports": { + "import-x/no-relative-packages": { "count": 1 } }, @@ -133,7 +133,7 @@ } }, "packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts": { - "local/no-cross-package-relative-imports": { + "import-x/no-relative-packages": { "count": 1 } }, @@ -261,7 +261,7 @@ "id-length": { "count": 1 }, - "local/no-cross-package-relative-imports": { + "import-x/no-relative-packages": { "count": 1 }, "no-negated-condition": { @@ -272,7 +272,7 @@ } }, "packages/assets-controllers/src/NftDetectionController.ts": { - "local/no-cross-package-relative-imports": { + "import-x/no-relative-packages": { "count": 1 } }, @@ -633,7 +633,7 @@ "@typescript-eslint/prefer-nullish-coalescing": { "count": 1 }, - "local/no-cross-package-relative-imports": { + "import-x/no-relative-packages": { "count": 1 } }, @@ -1711,12 +1711,12 @@ } }, "packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts": { - "local/no-cross-package-relative-imports": { + "import-x/no-relative-packages": { "count": 1 } }, "packages/transaction-pay-controller/src/utils/source-amounts.ts": { - "local/no-cross-package-relative-imports": { + "import-x/no-relative-packages": { "count": 1 } }, diff --git a/eslint.config.mjs b/eslint.config.mjs index d7fd9942c55..a70959143e8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,10 +3,6 @@ import jest from '@metamask/eslint-config-jest'; import nodejs from '@metamask/eslint-config-nodejs'; import typescript from '@metamask/eslint-config-typescript'; -// ESLint can load this file. -// eslint-disable-next-line import-x/extensions -import noCrossPackageRelativeImports from './scripts/eslint-rules/no-cross-package-relative-imports.mjs'; - const NODE_LTS_VERSION = 22; const config = createConfig([ @@ -58,21 +54,13 @@ const config = createConfig([ }, }, { - // Prohibit relative imports that cross package boundaries in non-test files. - // This is like the `no-relative-packages` rule in the `import-x` plugin, - // but does not suggest that engineers use subpath imports that may or may - // not exist in order to correct lint violations. + // Prohibit relative imports that cross package boundaries in non-test + // files. The rule resolves each import to an absolute path, finds the + // nearest package.json for both sides, and reports when they differ. files: ['packages/*/src/**/*.ts'], ignores: ['**/*.test.ts', '**/tests/**/*.ts'], - plugins: { - local: { - rules: { - 'no-cross-package-relative-imports': noCrossPackageRelativeImports, - }, - }, - }, rules: { - 'local/no-cross-package-relative-imports': 'error', + 'import-x/no-relative-packages': 'error', }, }, { diff --git a/scripts/eslint-rules/no-cross-package-relative-imports.mjs b/scripts/eslint-rules/no-cross-package-relative-imports.mjs deleted file mode 100644 index fb839b1cb30..00000000000 --- a/scripts/eslint-rules/no-cross-package-relative-imports.mjs +++ /dev/null @@ -1,128 +0,0 @@ -/** - * ESLint rule: no-cross-package-relative-imports - * - * Forbids relative imports that resolve to a file in a different package - * (i.e. a different nearest `package.json`). The auto-fixer rewrites the - * import to the target package's name, without appending a deep sub-path. - * - * For example, the following - * - * import type { MultichainAccountServiceWalletStatusChangeEvent } from '../../multichain-account-service/src/types'; - * - * would be auto-fixed to: - * - * import type { MultichainAccountServiceWalletStatusChangeEvent } from '@metamask/multichain-account-service'; - */ - -import { readFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; - -/** - * Walk up from a directory until a `package.json` is found. - * - * @param {string} startingDirectory - The directory to start searching from. - * @returns {string | undefined} The name of a package if one is found, or - * `undefined` otherwise. - */ -function findPackage(startingDirectory) { - let workingDirectory = startingDirectory; - - while (true) { - const pkgPath = join(workingDirectory, 'package.json'); - try { - // ESLint rules must be synchronous. - // eslint-disable-next-line n/no-sync - const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); - // Assume that all packages have names - return pkg.name; - } catch { - // No valid package.json here. Keep walking. - } - const parentDirectory = dirname(workingDirectory); - if (parentDirectory === workingDirectory) { - return undefined; - } - workingDirectory = parentDirectory; - } -} - -/** @type {import('eslint').Rule.RuleModule} */ -const rule = { - meta: { - type: 'problem', - docs: { - description: - 'Forbid relative imports that cross package boundaries in a monorepo', - }, - fixable: 'code', - messages: { - noRelativeCrossPackage: - "Do not use a relative path to import files from other packages. Use a known export as declared in the package's manifest.\n" + - "For example, in this case, try: `import { ... } from '{{ importedPackageName }}'`", - }, - schema: [], - }, - - create(context) { - /** - * Check a single import/re-export source literal. - * - * @param {import('estree').Literal | null} sourceNode - The source literal - * node from an import or export declaration. - */ - function check(sourceNode) { - if (!sourceNode) { - return; - } - - const importPath = sourceNode.value; - - // We only care about relative imports - if (!importPath.startsWith('.')) { - return; - } - - const currentDirectory = dirname( - context.physicalFilename ?? context.filename, - ); - const currentPackageName = findPackage(currentDirectory); - const importedPackageName = findPackage( - dirname(resolve(currentDirectory, importPath)), - ); - - if (!currentPackageName || !importedPackageName) { - return; - } - - // It's okay to use a relative path to import a file from our own package - if (currentPackageName === importedPackageName) { - return; - } - - context.report({ - node: sourceNode, - messageId: 'noRelativeCrossPackage', - data: { - importedPackageName, - }, - fix(fixer) { - return fixer.replaceText(sourceNode, `'${importedPackageName}'`); - }, - }); - } - - return { - ImportDeclaration(node) { - check(node.source); - }, - ExportNamedDeclaration(node) { - check(node.source); - }, - ExportAllDeclaration(node) { - check(node.source); - }, - }; - }, -}; - -export default rule; From b9e30ceba363aa31456f6af8dc5907101252489c Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 19 Mar 2026 08:25:06 -0600 Subject: [PATCH 5/7] Prune suppression --- eslint-suppressions.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index f1a268032ce..dd762bd60c5 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -90,11 +90,6 @@ "count": 1 } }, - "packages/account-tree-controller/src/types.ts": { - "import-x/no-relative-packages": { - "count": 1 - } - }, "packages/account-tree-controller/tests/mockMessenger.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 1 From 6319b4166b30dfa412480a4209c7232cb773c815 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 19 Mar 2026 08:26:34 -0600 Subject: [PATCH 6/7] Remove outdated comment, and move the rule down --- eslint.config.mjs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index a70959143e8..28e0663b418 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -53,16 +53,6 @@ const config = createConfig([ ecmaVersion: 2020, }, }, - { - // Prohibit relative imports that cross package boundaries in non-test - // files. The rule resolves each import to an absolute path, finds the - // nearest package.json for both sides, and reports when they differ. - files: ['packages/*/src/**/*.ts'], - ignores: ['**/*.test.ts', '**/tests/**/*.ts'], - rules: { - 'import-x/no-relative-packages': 'error', - }, - }, { files: ['**/*.ts'], extends: [typescript], @@ -154,6 +144,14 @@ const config = createConfig([ sourceType: 'module', }, }, + // Prevent cross-package imports + { + files: ['packages/*/src/**/*.ts'], + ignores: ['**/*.test.ts', '**/tests/**/*.ts'], + rules: { + 'import-x/no-relative-packages': 'error', + }, + }, { files: ['packages/foundryup/**/*.{js,ts}'], rules: { From c1a516eab30945c507ee4d622394ab7da3fa55b2 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 19 Mar 2026 08:26:56 -0600 Subject: [PATCH 7/7] Update comment --- eslint.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 28e0663b418..76bbdf4f3d4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -144,7 +144,7 @@ const config = createConfig([ sourceType: 'module', }, }, - // Prevent cross-package imports + // Prevent cross-package relative imports { files: ['packages/*/src/**/*.ts'], ignores: ['**/*.test.ts', '**/tests/**/*.ts'],