cloudflare/kumo
Publicmirrored fromhttps://github.com/cloudflare/kumoAvailable
lint/no-cross-package-imports.js
163lines · modecode
| 1 | import { defineRule } from "oxlint"; |
| 2 | |
| 3 | const RULE_NAME = "no-cross-package-imports"; |
| 4 | |
| 5 | // Known package directory names in this monorepo |
| 6 | const PACKAGE_DIRS = new Set(["kumo", "kumo-docs-astro", "kumo-figma"]); |
| 7 | |
| 8 | // Pattern to detect relative imports that traverse up to packages/ level |
| 9 | // and then into a sibling package directory. |
| 10 | // This looks for paths like: |
| 11 | // ../../kumo/... (from packages/kumo-docs-astro/src/foo.ts) |
| 12 | // ../../../kumo/... (from packages/kumo-docs-astro/src/deep/foo.ts) |
| 13 | // |
| 14 | // The key insight: we need enough "../" to escape the current package's src/ |
| 15 | // directory and land in packages/, then go into another package. |
| 16 | // |
| 17 | // We detect this by looking for patterns where a package name appears |
| 18 | // right after the "../" traversal sequence, which indicates leaving |
| 19 | // the current package and entering a sibling. |
| 20 | const CROSS_PACKAGE_PATTERN = /^((?:\.\.\/)+)([a-z0-9-]+)\//; |
| 21 | |
| 22 | /** |
| 23 | * Check if an import path is a cross-package relative import. |
| 24 | * Returns the package name if it is, null otherwise. |
| 25 | * |
| 26 | * We consider it a cross-package import if: |
| 27 | * 1. The path starts with "../" (going up from current directory) |
| 28 | * 2. After the "../" sequence, it immediately enters a known package directory |
| 29 | * 3. The number of "../" is >= 2 (minimum to escape src/ and reach packages/) |
| 30 | * |
| 31 | * This avoids false positives for local directories that happen to be named |
| 32 | * like packages (e.g., ./kumo/ or ../kumo/ within the same package). |
| 33 | */ |
| 34 | function getCrossPackageImport(importPath) { |
| 35 | if (!importPath || !importPath.startsWith("..")) { |
| 36 | return null; |
| 37 | } |
| 38 | |
| 39 | const match = importPath.match(CROSS_PACKAGE_PATTERN); |
| 40 | if (!match) { |
| 41 | return null; |
| 42 | } |
| 43 | |
| 44 | const traversal = match[1]; // The "../../../" part |
| 45 | const packageDir = match[2]; // The directory name after traversal |
| 46 | |
| 47 | // Count how many levels we're going up |
| 48 | const levelsUp = (traversal.match(/\.\.\//g) || []).length; |
| 49 | |
| 50 | // If we're only going up 1 level (../package/), it's likely a local |
| 51 | // directory within the same package. Cross-package imports typically |
| 52 | // need at least 2 levels (../../package/) to escape src/ and reach |
| 53 | // the packages/ directory. |
| 54 | if (levelsUp < 2) { |
| 55 | return null; |
| 56 | } |
| 57 | |
| 58 | // Check if the target directory is a known package |
| 59 | if (PACKAGE_DIRS.has(packageDir)) { |
| 60 | return packageDir; |
| 61 | } |
| 62 | |
| 63 | return null; |
| 64 | } |
| 65 | |
| 66 | /** |
| 67 | * Get the source value from an import declaration or expression. |
| 68 | */ |
| 69 | function getImportSource(node) { |
| 70 | // Static import: import x from "../kumo/foo" |
| 71 | if (node.source && node.source.type === "Literal") { |
| 72 | return node.source.value; |
| 73 | } |
| 74 | // Dynamic import: import("../kumo/foo") |
| 75 | if ( |
| 76 | node.type === "ImportExpression" && |
| 77 | node.source && |
| 78 | node.source.type === "Literal" |
| 79 | ) { |
| 80 | return node.source.value; |
| 81 | } |
| 82 | return null; |
| 83 | } |
| 84 | |
| 85 | export const noCrossPackageImportsRule = defineRule({ |
| 86 | meta: { |
| 87 | type: "problem", |
| 88 | docs: { |
| 89 | description: |
| 90 | "Disallow relative imports that reach into sibling packages in the monorepo", |
| 91 | }, |
| 92 | messages: { |
| 93 | [RULE_NAME]: |
| 94 | "Cross-package relative import detected. Import from '{{packageName}}' using its package name (e.g., '@cloudflare/{{packageName}}') instead of relative paths ('{{importPath}}').", |
| 95 | }, |
| 96 | schema: [], |
| 97 | }, |
| 98 | defaultOptions: [], |
| 99 | createOnce(context) { |
| 100 | function checkImport(node, importPath) { |
| 101 | const packageName = getCrossPackageImport(importPath); |
| 102 | if (packageName) { |
| 103 | context.report({ |
| 104 | node, |
| 105 | messageId: RULE_NAME, |
| 106 | data: { |
| 107 | packageName, |
| 108 | importPath, |
| 109 | }, |
| 110 | }); |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | return { |
| 115 | // Static imports: import x from "../kumo/foo" |
| 116 | ImportDeclaration(node) { |
| 117 | const source = getImportSource(node); |
| 118 | if (source) { |
| 119 | checkImport(node, source); |
| 120 | } |
| 121 | }, |
| 122 | |
| 123 | // Dynamic imports: import("../kumo/foo") |
| 124 | ImportExpression(node) { |
| 125 | const source = getImportSource(node); |
| 126 | if (source) { |
| 127 | checkImport(node, source); |
| 128 | } |
| 129 | }, |
| 130 | |
| 131 | // require() calls: require("../kumo/foo") |
| 132 | CallExpression(node) { |
| 133 | if ( |
| 134 | node.callee.type === "Identifier" && |
| 135 | node.callee.name === "require" && |
| 136 | node.arguments.length > 0 && |
| 137 | node.arguments[0].type === "Literal" && |
| 138 | typeof node.arguments[0].value === "string" |
| 139 | ) { |
| 140 | checkImport(node, node.arguments[0].value); |
| 141 | } |
| 142 | }, |
| 143 | |
| 144 | // export from: export { x } from "../kumo/foo" |
| 145 | ExportNamedDeclaration(node) { |
| 146 | if (node.source) { |
| 147 | const source = getImportSource(node); |
| 148 | if (source) { |
| 149 | checkImport(node, source); |
| 150 | } |
| 151 | } |
| 152 | }, |
| 153 | |
| 154 | // export * from "../kumo/foo" |
| 155 | ExportAllDeclaration(node) { |
| 156 | const source = getImportSource(node); |
| 157 | if (source) { |
| 158 | checkImport(node, source); |
| 159 | } |
| 160 | }, |
| 161 | }; |
| 162 | }, |
| 163 | }); |
| 164 | |