From e351baf10f06d2a1d1872bf2a6d7e9e709effed9 Mon Sep 17 00:00:00 2001 From: Brandon Keiji Date: Mon, 21 Apr 2025 08:02:11 -0700 Subject: [PATCH] feat: add custom eslint rule for cross-package imports (#77) --- .../no-relative-cross-package-imports.js | 159 ++++++++++++++++++ eslint.config.js | 32 ++++ package.json | 2 +- 3 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 eslint-rules/no-relative-cross-package-imports.js 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 .",