diff options
Diffstat (limited to 'eslint-rules/no-relative-cross-package-imports.js')
| -rw-r--r-- | eslint-rules/no-relative-cross-package-imports.js | 159 |
1 files changed, 159 insertions, 0 deletions
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 === '<input>' || + currentFilePath === '<text>' + ) { + // 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}'`); + }, + }); + } + }, + }; + }, +}; |
