feat: add custom eslint rule for cross-package imports (#77)
This commit is contained in:
parent
39bdedab9c
commit
e351baf10f
|
@ -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}'`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
|
@ -13,6 +13,17 @@ import prettierConfig from 'eslint-config-prettier';
|
||||||
import importPlugin from 'eslint-plugin-import';
|
import importPlugin from 'eslint-plugin-import';
|
||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
import licenseHeader from 'eslint-plugin-license-header';
|
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(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
|
@ -22,6 +33,7 @@ export default tseslint.config(
|
||||||
'eslint.config.js',
|
'eslint.config.js',
|
||||||
'packages/cli/dist/**',
|
'packages/cli/dist/**',
|
||||||
'packages/server/dist/**',
|
'packages/server/dist/**',
|
||||||
|
'eslint-rules/*',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
eslint.configs.recommended,
|
eslint.configs.recommended,
|
||||||
|
@ -163,4 +175,24 @@ export default tseslint.config(
|
||||||
},
|
},
|
||||||
// Prettier config must be last
|
// Prettier config must be last
|
||||||
prettierConfig,
|
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'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
"test": "npm run test --workspaces",
|
"test": "npm run test --workspaces",
|
||||||
"start": "scripts/start.sh",
|
"start": "scripts/start.sh",
|
||||||
"debug": "scripts/debug.sh",
|
"debug": "scripts/debug.sh",
|
||||||
"fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"lint": "eslint . --ext .ts,.tsx",
|
"lint": "eslint . --ext .ts,.tsx",
|
||||||
"typecheck": "tsc --noEmit --jsx react",
|
"typecheck": "tsc --noEmit --jsx react",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
|
|
Loading…
Reference in New Issue