diff --git a/.changeset/beige-sheep-draw.md b/.changeset/beige-sheep-draw.md new file mode 100644 index 0000000..c9dbd57 --- /dev/null +++ b/.changeset/beige-sheep-draw.md @@ -0,0 +1,5 @@ +--- +"debarrel": patch +--- + +Fix debarreling codemod's issue with alias imports diff --git a/codemods/debarrel/scripts/codemod.ts b/codemods/debarrel/scripts/codemod.ts index d191b2a..16907bc 100644 --- a/codemods/debarrel/scripts/codemod.ts +++ b/codemods/debarrel/scripts/codemod.ts @@ -7,6 +7,7 @@ import { hasPackageJson, isBarrelFile, isInsideNodeModules, + isPackageEntrypoint, } from "./utils/paths.ts"; import { isPureBarrel } from "./utils/barrel.ts"; import { resolveSpecifier, type SpecRewrite } from "./utils/specifiers.ts"; @@ -136,12 +137,12 @@ const codemod: Codemod = async (root, options) => { rewriteMockCalls(rootNode, barrelRewrites, edits); // Barrel rename — skip files inside node_modules or inside a package - // (renaming a package entry point would break consumers importing via - // the package name). + // when the barrel is an actual package entrypoint (renaming it would break + // consumers importing via the package name). if ( isBarrelFile(filename) && !isInsideNodeModules(filename) && - !hasPackageJson(filename) + (!hasPackageJson(filename) || !isPackageEntrypoint(filename)) ) { const { pure, hasWildcards } = isPureBarrel(rootNode); if (pure && !hasWildcards) { diff --git a/codemods/debarrel/scripts/utils/paths.ts b/codemods/debarrel/scripts/utils/paths.ts index b94ff49..df12297 100644 --- a/codemods/debarrel/scripts/utils/paths.ts +++ b/codemods/debarrel/scripts/utils/paths.ts @@ -69,12 +69,94 @@ function fileExists(filePath: string): boolean { } } -export function hasPackageJson(filename: string): boolean { +function readJsonFile(filePath: string): unknown | null { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return null; + } +} + +function normalizePackageTarget(target: string): string { + return target.replace(/\\/g, "/").replace(/^\.\//, ""); +} + +function collectExportTargets(value: unknown, targets: string[]): void { + if (typeof value === "string") { + targets.push(normalizePackageTarget(value)); + return; + } + + if (!value || typeof value !== "object") return; + + if (Array.isArray(value)) { + for (const item of value) collectExportTargets(item, targets); + return; + } + + for (const nested of Object.values(value)) { + collectExportTargets(nested, targets); + } +} + +export function findNearestPackageJson(filename: string): string | null { let dir = path.dirname(filename); const root = path.parse(dir).root || "/"; - while (dir !== root) { - if (fileExists(path.join(dir, "package.json"))) return true; + while (true) { + const packageJsonPath = path.join(dir, "package.json"); + if (fileExists(packageJsonPath)) return packageJsonPath; + if (dir === root) return null; dir = path.dirname(dir); } - return false; +} + +export function getPackageName(filename: string): string | null { + const packageJsonPath = findNearestPackageJson(filename); + if (!packageJsonPath) return null; + const parsed = readJsonFile(packageJsonPath); + if (!parsed || typeof parsed !== "object") return null; + const name = (parsed as { name?: unknown }).name; + return typeof name === "string" && name.length > 0 ? name : null; +} + +export function hasPackageJson(filename: string): boolean { + return findNearestPackageJson(filename) !== null; +} + +export function isPackageEntrypoint(filename: string): boolean { + const packageJsonPath = findNearestPackageJson(filename); + if (!packageJsonPath) return false; + + const parsed = readJsonFile(packageJsonPath); + if (!parsed || typeof parsed !== "object") return false; + + const packageDir = path.dirname(packageJsonPath); + const relativeFilename = path + .relative(packageDir, filename) + .replace(/\\/g, "/"); + if (!relativeFilename || relativeFilename.startsWith("../")) return false; + + const manifest = parsed as { + main?: unknown; + module?: unknown; + types?: unknown; + typings?: unknown; + exports?: unknown; + }; + const entrypoints: string[] = []; + + for (const field of [ + manifest.main, + manifest.module, + manifest.types, + manifest.typings, + ]) { + if (typeof field === "string") { + entrypoints.push(normalizePackageTarget(field)); + } + } + + collectExportTargets(manifest.exports, entrypoints); + + return entrypoints.includes(relativeFilename); } diff --git a/codemods/debarrel/scripts/utils/specifiers.ts b/codemods/debarrel/scripts/utils/specifiers.ts index 491bc0c..2dd8da3 100644 --- a/codemods/debarrel/scripts/utils/specifiers.ts +++ b/codemods/debarrel/scripts/utils/specifiers.ts @@ -2,7 +2,7 @@ import type { SgNode, SgRoot } from "codemod:ast-grep"; import type { Language } from "./language.ts"; import { getStringContent } from "./ast.ts"; import { - hasPackageJson, + getPackageName, isBarrelFile, isInsideNodeModules, isLocalRelativePath, @@ -10,6 +10,16 @@ import { } from "./paths.ts"; import { parseBarrelExport } from "./barrel.ts"; +function getImportPackageName(importPath: string): string | null { + if (importPath.startsWith("@")) { + const segments = importPath.split("/"); + return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : null; + } + + const [packageName] = importPath.split("/"); + return packageName || null; +} + export interface SpecRewrite { consumerName: string; newImportPath: string; @@ -34,12 +44,16 @@ export function resolveSpecifier( // package.json "exports" that would break if we change the import subpath. if (isInsideNodeModules(def.root.filename())) return null; - // For non-relative imports (package names, aliases), skip if the resolved - // file lives inside a package (has a package.json ancestor). The package's - // "exports" field controls valid subpaths — rewriting the import could - // produce a path that isn't exported (e.g. @acme/validators → @acme/validators/foo). - if (!isLocalRelativePath(importPath) && hasPackageJson(def.root.filename())) { - return null; + // For non-relative imports, only preserve the package boundary when the + // resolved file belongs to the same named package as the import specifier. + // This keeps tsconfig aliases like `~/foo` or `@acme/pkg/*` rewriteable + // even when the surrounding repo has an unrelated package.json. + if (!isLocalRelativePath(importPath)) { + const packageName = getPackageName(def.root.filename()); + const importPackage = getImportPackageName(importPath); + if (packageName && importPackage === packageName) { + return null; + } } if (isBarrelFile(def.root.filename())) { diff --git a/codemods/debarrel/tests/tsconfig-alias-paths/expected/package.json b/codemods/debarrel/tests/tsconfig-alias-paths/expected/package.json new file mode 100644 index 0000000..e640dcb --- /dev/null +++ b/codemods/debarrel/tests/tsconfig-alias-paths/expected/package.json @@ -0,0 +1,3 @@ +{ + "name": "fixture-app" +} diff --git a/codemods/debarrel/tests/tsconfig-alias-paths/input/package.json b/codemods/debarrel/tests/tsconfig-alias-paths/input/package.json new file mode 100644 index 0000000..e640dcb --- /dev/null +++ b/codemods/debarrel/tests/tsconfig-alias-paths/input/package.json @@ -0,0 +1,3 @@ +{ + "name": "fixture-app" +}