diff --git a/eslint-rules/no-relative-cross-package-imports.js b/eslint-rules/no-relative-cross-package-imports.js
new file mode 100644
index 00000000..ab3ed91c
--- /dev/null
+++ b/eslint-rules/no-relative-cross-package-imports.js
@@ -0,0 +1,159 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @fileoverview Disallows relative imports between specified monorepo packages.
+ */
+'use strict';
+
+import path from 'node:path';
+import fs from 'node:fs';
+
+/**
+ * Finds the package name by searching for the nearest `package.json` file
+ * in the directory hierarchy, starting from the given file's directory
+ * and moving upwards until the specified root directory is reached.
+ * It reads the `package.json` and extracts the `name` property.
+ *
+ * @requires module:path Node.js path module
+ * @requires module:fs Node.js fs module
+ *
+ * @param {string} filePath - The path (absolute or relative) to a file within the potential package structure.
+ * The search starts from the directory containing this file.
+ * @param {string} root - The absolute path to the root directory of the project/monorepo.
+ * The upward search stops when this directory is reached.
+ * @returns {string | undefined | null} The value of the `name` field from the first `package.json` found.
+ * Returns `undefined` if the `name` field doesn't exist in the found `package.json`.
+ * Returns `null` if no `package.json` is found before reaching the `root` directory.
+ * @throws {Error} Can throw an error if `fs.readFileSync` fails (e.g., permissions) or if `JSON.parse` fails on invalid JSON content.
+ */
+function findPackageName(filePath, root) {
+ let currentDir = path.dirname(path.resolve(filePath));
+ while (currentDir !== root) {
+ const parentDir = path.dirname(currentDir);
+ const packageJsonPath = path.join(currentDir, 'package.json');
+ if (fs.existsSync(packageJsonPath)) {
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
+ return pkg.name;
+ }
+
+ // Move up one level
+ currentDir = parentDir;
+ // Safety break if we somehow reached the root directly in the loop condition (less likely with path.resolve)
+ if (path.dirname(currentDir) === currentDir) break;
+ }
+
+ return null; // Not found within the expected structure
+}
+
+export default {
+ meta: {
+ type: 'problem',
+ docs: {
+ description: 'Disallow relative imports between packages.',
+ category: 'Best Practices',
+ recommended: 'error',
+ },
+ fixable: 'code',
+ schema: [
+ {
+ type: 'object',
+ properties: {
+ root: {
+ type: 'string',
+ description:
+ 'Absolute path to the root of all relevant packages to consider.',
+ },
+ },
+ required: ['root'],
+ additionalProperties: false,
+ },
+ ],
+ messages: {
+ noRelativePathsForCrossPackageImport:
+ "Relative import '{{importedPath}}' crosses package boundary from '{{importingPackage}}' to '{{importedPackage}}'. Use a direct package import ('{{importedPackage}}') instead.",
+ relativeImportIsInvalidPackage:
+ "Relative import '{{importedPath}}' does not reference a valid package. All source must be in a package directory.",
+ },
+ },
+
+ create(context) {
+ const options = context.options[0] || {};
+ const allPackagesRoot = options.root;
+
+ const currentFilePath = context.filename;
+ if (
+ !currentFilePath ||
+ currentFilePath === '' ||
+ currentFilePath === ''
+ ) {
+ // Skip if filename is not available (e.g., linting raw text)
+ return {};
+ }
+
+ const currentPackage = findPackageName(currentFilePath, allPackagesRoot);
+
+ // If the current file isn't inside a package structure, don't apply the rule
+ if (!currentPackage) {
+ return {};
+ }
+
+ return {
+ ImportDeclaration(node) {
+ const importingPackage = currentPackage;
+ const importedPath = node.source.value;
+
+ // Only interested in relative paths
+ if (
+ !importedPath ||
+ typeof importedPath !== 'string' ||
+ !importedPath.startsWith('.')
+ ) {
+ return;
+ }
+
+ // Resolve the absolute path of the imported module
+ const absoluteImportPath = path.resolve(
+ path.dirname(currentFilePath),
+ importedPath,
+ );
+
+ // Find the package information for the imported file
+ const importedPackage = findPackageName(
+ absoluteImportPath,
+ allPackagesRoot,
+ );
+
+ // If the imported file isn't in a recognized package, report issue
+ if (!importedPackage) {
+ context.report({
+ node: node.source,
+ messageId: 'relativeImportIsInvalidPackage',
+ data: { importedPath: importedPath },
+ });
+ return;
+ }
+
+ // The core check: Are the source and target packages different?
+ if (currentPackage !== importedPackage) {
+ // We found a relative import crossing package boundaries
+ context.report({
+ node: node.source, // Report the error on the source string literal
+ messageId: 'noRelativePathsForCrossPackageImport',
+ data: {
+ importedPath,
+ importedPackage,
+ importingPackage,
+ },
+ fix(fixer) {
+ return fixer.replaceText(node.source, `'${importedPackage}'`);
+ },
+ });
+ }
+ },
+ };
+ },
+};
diff --git a/eslint.config.js b/eslint.config.js
index a846d22d..16577628 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -13,6 +13,17 @@ import prettierConfig from 'eslint-config-prettier';
import importPlugin from 'eslint-plugin-import';
import globals from 'globals';
import licenseHeader from 'eslint-plugin-license-header';
+import noRelativeCrossPackageImports from './eslint-rules/no-relative-cross-package-imports.js';
+import path from 'node:path'; // Use node: prefix for built-ins
+import url from 'node:url';
+
+// --- ESM way to get __dirname ---
+const __filename = url.fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+// --- ---
+
+// Determine the monorepo root (assuming eslint.config.js is at the root)
+const projectRoot = __dirname;
export default tseslint.config(
{
@@ -22,6 +33,7 @@ export default tseslint.config(
'eslint.config.js',
'packages/cli/dist/**',
'packages/server/dist/**',
+ 'eslint-rules/*',
],
},
eslint.configs.recommended,
@@ -163,4 +175,24 @@ export default tseslint.config(
},
// Prettier config must be last
prettierConfig,
+ // Custom eslint rules for this repo
+ {
+ files: ['packages/**/*.{js,jsx,ts,tsx}'],
+ plugins: {
+ custom: {
+ rules: {
+ 'no-relative-cross-package-imports': noRelativeCrossPackageImports,
+ },
+ },
+ },
+ rules: {
+ // Enable and configure your custom rule
+ 'custom/no-relative-cross-package-imports': [
+ 'error',
+ {
+ root: path.join(projectRoot, 'packages'),
+ },
+ ],
+ },
+ },
);
diff --git a/package.json b/package.json
index f7011298..c73fa3e5 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
"test": "npm run test --workspaces",
"start": "scripts/start.sh",
"debug": "scripts/debug.sh",
- "fix": "eslint . --fix",
+ "lint:fix": "eslint . --fix",
"lint": "eslint . --ext .ts,.tsx",
"typecheck": "tsc --noEmit --jsx react",
"format": "prettier --write .",