summaryrefslogtreecommitdiff
path: root/eslint-rules/no-relative-cross-package-imports.js
blob: ab3ed91ccb5a38330e311b181125cd47ceb9f293 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
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}'`);
            },
          });
        }
      },
    };
  },
};