Skip to content

Commit 7022a5a

Browse files
authored
Merge pull request #30 from codemod/fix/fix-alias-handling
feat: enhance alias handling and package entry point detection in debarrel codemod
2 parents b6d79a6 + 38aa0e7 commit 7022a5a

6 files changed

Lines changed: 122 additions & 14 deletions

File tree

.changeset/beige-sheep-draw.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"debarrel": patch
3+
---
4+
5+
Fix debarreling codemod's issue with alias imports

codemods/debarrel/scripts/codemod.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
hasPackageJson,
88
isBarrelFile,
99
isInsideNodeModules,
10+
isPackageEntrypoint,
1011
} from "./utils/paths.ts";
1112
import { isPureBarrel } from "./utils/barrel.ts";
1213
import { resolveSpecifier, type SpecRewrite } from "./utils/specifiers.ts";
@@ -136,12 +137,12 @@ const codemod: Codemod<Language> = async (root, options) => {
136137
rewriteMockCalls(rootNode, barrelRewrites, edits);
137138

138139
// Barrel rename — skip files inside node_modules or inside a package
139-
// (renaming a package entry point would break consumers importing via
140-
// the package name).
140+
// when the barrel is an actual package entrypoint (renaming it would break
141+
// consumers importing via the package name).
141142
if (
142143
isBarrelFile(filename) &&
143144
!isInsideNodeModules(filename) &&
144-
!hasPackageJson(filename)
145+
(!hasPackageJson(filename) || !isPackageEntrypoint(filename))
145146
) {
146147
const { pure, hasWildcards } = isPureBarrel(rootNode);
147148
if (pure && !hasWildcards) {

codemods/debarrel/scripts/utils/paths.ts

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,94 @@ function fileExists(filePath: string): boolean {
6969
}
7070
}
7171

72-
export function hasPackageJson(filename: string): boolean {
72+
function readJsonFile(filePath: string): unknown | null {
73+
try {
74+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
75+
} catch {
76+
return null;
77+
}
78+
}
79+
80+
function normalizePackageTarget(target: string): string {
81+
return target.replace(/\\/g, "/").replace(/^\.\//, "");
82+
}
83+
84+
function collectExportTargets(value: unknown, targets: string[]): void {
85+
if (typeof value === "string") {
86+
targets.push(normalizePackageTarget(value));
87+
return;
88+
}
89+
90+
if (!value || typeof value !== "object") return;
91+
92+
if (Array.isArray(value)) {
93+
for (const item of value) collectExportTargets(item, targets);
94+
return;
95+
}
96+
97+
for (const nested of Object.values(value)) {
98+
collectExportTargets(nested, targets);
99+
}
100+
}
101+
102+
export function findNearestPackageJson(filename: string): string | null {
73103
let dir = path.dirname(filename);
74104
const root = path.parse(dir).root || "/";
75-
while (dir !== root) {
76-
if (fileExists(path.join(dir, "package.json"))) return true;
105+
while (true) {
106+
const packageJsonPath = path.join(dir, "package.json");
107+
if (fileExists(packageJsonPath)) return packageJsonPath;
108+
if (dir === root) return null;
77109
dir = path.dirname(dir);
78110
}
79-
return false;
111+
}
112+
113+
export function getPackageName(filename: string): string | null {
114+
const packageJsonPath = findNearestPackageJson(filename);
115+
if (!packageJsonPath) return null;
116+
const parsed = readJsonFile(packageJsonPath);
117+
if (!parsed || typeof parsed !== "object") return null;
118+
const name = (parsed as { name?: unknown }).name;
119+
return typeof name === "string" && name.length > 0 ? name : null;
120+
}
121+
122+
export function hasPackageJson(filename: string): boolean {
123+
return findNearestPackageJson(filename) !== null;
124+
}
125+
126+
export function isPackageEntrypoint(filename: string): boolean {
127+
const packageJsonPath = findNearestPackageJson(filename);
128+
if (!packageJsonPath) return false;
129+
130+
const parsed = readJsonFile(packageJsonPath);
131+
if (!parsed || typeof parsed !== "object") return false;
132+
133+
const packageDir = path.dirname(packageJsonPath);
134+
const relativeFilename = path
135+
.relative(packageDir, filename)
136+
.replace(/\\/g, "/");
137+
if (!relativeFilename || relativeFilename.startsWith("../")) return false;
138+
139+
const manifest = parsed as {
140+
main?: unknown;
141+
module?: unknown;
142+
types?: unknown;
143+
typings?: unknown;
144+
exports?: unknown;
145+
};
146+
const entrypoints: string[] = [];
147+
148+
for (const field of [
149+
manifest.main,
150+
manifest.module,
151+
manifest.types,
152+
manifest.typings,
153+
]) {
154+
if (typeof field === "string") {
155+
entrypoints.push(normalizePackageTarget(field));
156+
}
157+
}
158+
159+
collectExportTargets(manifest.exports, entrypoints);
160+
161+
return entrypoints.includes(relativeFilename);
80162
}

codemods/debarrel/scripts/utils/specifiers.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,24 @@ import type { SgNode, SgRoot } from "codemod:ast-grep";
22
import type { Language } from "./language.ts";
33
import { getStringContent } from "./ast.ts";
44
import {
5-
hasPackageJson,
5+
getPackageName,
66
isBarrelFile,
77
isInsideNodeModules,
88
isLocalRelativePath,
99
joinImportPaths,
1010
} from "./paths.ts";
1111
import { parseBarrelExport } from "./barrel.ts";
1212

13+
function getImportPackageName(importPath: string): string | null {
14+
if (importPath.startsWith("@")) {
15+
const segments = importPath.split("/");
16+
return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : null;
17+
}
18+
19+
const [packageName] = importPath.split("/");
20+
return packageName || null;
21+
}
22+
1323
export interface SpecRewrite {
1424
consumerName: string;
1525
newImportPath: string;
@@ -34,12 +44,16 @@ export function resolveSpecifier(
3444
// package.json "exports" that would break if we change the import subpath.
3545
if (isInsideNodeModules(def.root.filename())) return null;
3646

37-
// For non-relative imports (package names, aliases), skip if the resolved
38-
// file lives inside a package (has a package.json ancestor). The package's
39-
// "exports" field controls valid subpaths — rewriting the import could
40-
// produce a path that isn't exported (e.g. @acme/validators → @acme/validators/foo).
41-
if (!isLocalRelativePath(importPath) && hasPackageJson(def.root.filename())) {
42-
return null;
47+
// For non-relative imports, only preserve the package boundary when the
48+
// resolved file belongs to the same named package as the import specifier.
49+
// This keeps tsconfig aliases like `~/foo` or `@acme/pkg/*` rewriteable
50+
// even when the surrounding repo has an unrelated package.json.
51+
if (!isLocalRelativePath(importPath)) {
52+
const packageName = getPackageName(def.root.filename());
53+
const importPackage = getImportPackageName(importPath);
54+
if (packageName && importPackage === packageName) {
55+
return null;
56+
}
4357
}
4458

4559
if (isBarrelFile(def.root.filename())) {
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"name": "fixture-app"
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"name": "fixture-app"
3+
}

0 commit comments

Comments
 (0)