Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/export-star-rewrites.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"debarrel": patch
---

Rewrite consumer imports that flow through bare `export * from "./x"` re-exports. Previously the semantic analyzer couldn't enumerate the wildcard's bindings, so the codemod silently left those imports pointing at the barrel; now the codemod manually walks the barrel's `export *` chain to find the declaring file. Also preserves the top-level `import type` modifier when every specifier in a type-only import is rewritten.
29 changes: 24 additions & 5 deletions codemods/debarrel/scripts/codemod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ const codemod: Codemod<Language> = async (root, options) => {
.find((c) => c.is("import_clause"));
if (!importClause) continue;

// Top-level `import type { … } from "…"` — preserved across rewrites so
// we don't downgrade a type-only import to a value import (which can
// break under --verbatimModuleSyntax / --isolatedModules when the
// resolved declarations are `export type` aliases).
const isTypeOnlyImport = importStmt.children().some((c) => c.is("type"));

const rewrites: SpecRewrite[] = [];
let totalSpecifiers = 0;

Expand All @@ -59,7 +65,13 @@ const codemod: Codemod<Language> = async (root, options) => {
if (!localBinding) continue;
const def = localBinding.definition();
if (!def) continue;
const rw = resolveSpecifier(localBinding, importPath, def);
const rw = resolveSpecifier(
localBinding,
importPath,
def,
filename,
relativeFilename,
);
if (rw) rewrites.push(rw);
}
}
Expand All @@ -75,7 +87,13 @@ const codemod: Codemod<Language> = async (root, options) => {
totalSpecifiers += 1;
const def = defaultIdent.definition();
if (def) {
const rw = resolveSpecifier(defaultIdent, importPath, def);
const rw = resolveSpecifier(
defaultIdent,
importPath,
def,
filename,
relativeFilename,
);
if (rw) rewrites.push(rw);
}
}
Expand All @@ -100,7 +118,7 @@ const codemod: Codemod<Language> = async (root, options) => {
// when the barrel import is the last/only import in the file.
const lines: string[] = [];
for (const [sourcePath, specs] of byPath) {
lines.push(buildImportText(sourcePath, specs, quoteChar));
lines.push(buildImportText(sourcePath, specs, quoteChar, isTypeOnlyImport));
}
edits.push(importStmt.replace(lines.join("\n")));
} else {
Expand All @@ -123,12 +141,13 @@ const codemod: Codemod<Language> = async (root, options) => {
}
const lines: string[] = [];
if (remainingSpecTexts.length > 0) {
const typeKeyword = isTypeOnlyImport ? "type " : "";
lines.push(
`import { ${remainingSpecTexts.join(", ")} } from ${quoteChar}${importPath}${quoteChar};`,
`import ${typeKeyword}{ ${remainingSpecTexts.join(", ")} } from ${quoteChar}${importPath}${quoteChar};`,
);
Comment on lines 143 to 147
}
for (const [sourcePath, specs] of byPath) {
lines.push(buildImportText(sourcePath, specs, quoteChar));
lines.push(buildImportText(sourcePath, specs, quoteChar, isTypeOnlyImport));
}
edits.push(importStmt.replace(lines.join("\n")));
}
Expand Down
170 changes: 170 additions & 0 deletions codemods/debarrel/scripts/utils/exportStar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import fs from "fs";
import path from "path";
import { parse, type SgNode, type SgRoot } from "codemod:ast-grep";
import type { Language } from "./language.ts";
import { getStringContent } from "./ast.ts";
import { isLocalRelativePath, resolveImportPath } from "./paths.ts";

// The semantic analyzer's `definition()` does not chase through bare
// `export * from "./y"` re-exports in this jssg runtime — for those
// specifiers `def.kind` is "import" and `def.root.filename()` is the
// importer itself. To find the symbol's actual source we walk the barrel
// chain ourselves: read each file off disk, parse it with ast-grep, and
// scan its top-level `export_statement` nodes.

function langForFile(filename: string): string {
const ext = path.extname(filename).toLowerCase();
if (ext === ".tsx" || ext === ".jsx") return "tsx";
if (ext === ".js" || ext === ".mjs" || ext === ".cjs") return "javascript";
return "typescript";
}

function parseFile(filename: string): SgRoot<Language> | null {
let source: string;
try {
source = fs.readFileSync(filename, "utf8");
} catch {
return null;
}
try {
return parse<Language>(langForFile(filename), source);
} catch {
return null;
}
}

interface ExportStatementShape {
hasNamespaceExport: boolean;
hasExportClause: boolean;
exportClause: SgNode<Language> | undefined;
sourceNode: SgNode<Language> | undefined;
declaration: SgNode<Language> | undefined;
}

function inspectExportStatement(stmt: SgNode<Language>): ExportStatementShape {
const children = stmt.children();
return {
hasNamespaceExport: children.some((c) => c.is("namespace_export")),
hasExportClause: children.some((c) => c.is("export_clause")),
exportClause: children.find((c) => c.is("export_clause")),
sourceNode: children.find((c) => c.is("string")),
declaration: children.find(
(c) =>
c.is("lexical_declaration") ||
c.is("function_declaration") ||
c.is("class_declaration") ||
c.is("type_alias_declaration") ||
c.is("interface_declaration") ||
c.is("enum_declaration"),
),
};
}

// `export * from "./y"` (and `export type * from "./y"`), but NOT
// `export * as Ns from "./y"` — namespace re-exports wrap their target in a
// single binding the semantic analyzer already resolves on its own.
function findExportStarSources(root: SgRoot<Language>): string[] {
const results = new Set<string>();
for (const stmt of root.root().children()) {
if (!stmt.is("export_statement")) continue;
const shape = inspectExportStatement(stmt);
if (!shape.sourceNode) continue;
if (shape.hasNamespaceExport || shape.hasExportClause) continue;
const sourcePath = getStringContent(shape.sourceNode);
if (sourcePath) results.add(sourcePath);
}
return [...results];
}

function declarationNameMatches(decl: SgNode<Language>, name: string): boolean {
if (decl.is("lexical_declaration")) {
for (const declarator of decl.findAll({
rule: { kind: "variable_declarator" },
})) {
const ident = declarator
.children()
.find((c) => c.is("identifier") || c.is("shorthand_property_identifier_pattern"));
if (ident && ident.text() === name) return true;
}
return false;
}
const ident = decl
.children()
.find((c) => c.is("identifier") || c.is("type_identifier"));
return ident !== undefined && ident.text() === name;
}

// True when `root` exposes `name` as a top-level export — either as a direct
// declaration (`export const NAME`, `export type NAME`, …) or inside an
// export clause (`export { …, NAME, … }`, possibly aliased, possibly
// re-exported from another module).
function fileDeclaresName(root: SgRoot<Language>, name: string): boolean {
for (const stmt of root.root().children()) {
if (!stmt.is("export_statement")) continue;
const shape = inspectExportStatement(stmt);

if (shape.exportClause) {
for (const spec of shape.exportClause.findAll({
rule: { kind: "export_specifier" },
})) {
const idents = spec.findAll({ rule: { kind: "identifier" } });
// `X` -> [X]; `X as Y` -> [X, Y]; exported name is the last identifier.
const exportedName = idents[idents.length - 1]?.text();
if (exportedName === name) return true;
}
continue;
}

if (shape.declaration && declarationNameMatches(shape.declaration, name)) {
return true;
}
}
return false;
}

/**
* Walk `barrelFile`'s bare `export * from "./y"` chain to find which file
* declares `name`. Returns the absolute path of that file, or null if `name`
* isn't reachable through any wildcard re-export.
*
* Stops at non-local re-export targets (e.g. workspace packages, node_modules)
* since those would route the import through a different package boundary
* the codemod isn't authorized to rewrite.
*/
export function findSymbolViaExportStar(
barrelFile: string,
name: string,
): string | null {
return walk(barrelFile, name, new Set(), 0);
}

function walk(
file: string,
name: string,
visited: Set<string>,
depth: number,
): string | null {
if (depth > 10) return null;
if (visited.has(file)) return null;
visited.add(file);

const root = parseFile(file);
if (!root) return null;

for (const subPath of findExportStarSources(root)) {
if (!isLocalRelativePath(subPath)) continue;
const targetFile = resolveImportPath(file, subPath);
if (!targetFile) continue;
const targetRoot = parseFile(targetFile);
if (!targetRoot) continue;
if (fileDeclaresName(targetRoot, name)) return targetFile;

// The first re-export hop didn't declare the symbol directly — keep
// walking that file's own `export *` chain. We don't try to follow
// named `export { X } from "./y"` re-exports here; those are
// single-hop by design, mirroring the existing named-reexport branch.
const nested = walk(targetFile, name, visited, depth + 1);
if (nested) return nested;
}
return null;
}
4 changes: 3 additions & 1 deletion codemods/debarrel/scripts/utils/imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export function buildImportText(
sourcePath: string,
specs: SpecRewrite[],
quoteChar: string,
typeOnly = false,
): string {
const parts: string[] = [];
const defaultSpec = specs.find((s) => s.importType === "default");
Expand All @@ -23,7 +24,8 @@ export function buildImportText(
);
parts.push(`{ ${specTexts.join(", ")} }`);
}
return `import ${parts.join(", ")} from ${quoteChar}${sourcePath}${quoteChar};`;
const typeKeyword = typeOnly ? "type " : "";
return `import ${typeKeyword}${parts.join(", ")} from ${quoteChar}${sourcePath}${quoteChar};`;
}

export function groupByPath(
Expand Down
28 changes: 28 additions & 0 deletions codemods/debarrel/scripts/utils/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,34 @@ export function isBarrelFile(filename: string): boolean {
return /^index\.(ts|tsx|js|jsx)$/.test(path.basename(filename));
}

const MODULE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"] as const;

/**
* Resolve a relative import path to the absolute file it points to on disk,
* mirroring Node/TS module resolution preferences:
* 1. `<resolved>.<ext>` (file form — preferred when both exist)
* 2. `<resolved>/index.<ext>` (directory's index form)
*
* Returns null when the import path isn't relative or no candidate exists.
*/
export function resolveImportPath(
importerFilename: string,
importPath: string,
): string | null {
if (!isLocalRelativePath(importPath)) return null;
const importerDir = path.dirname(importerFilename);
const resolved = path.resolve(importerDir, importPath);
for (const ext of MODULE_EXTENSIONS) {
const candidate = resolved + ext;
if (fileExists(candidate)) return candidate;
}
for (const ext of MODULE_EXTENSIONS) {
const candidate = path.join(resolved, `index${ext}`);
if (fileExists(candidate)) return candidate;
}
return null;
}

export function isInsideNodeModules(filename: string): boolean {
return (
filename.includes("/node_modules/") || filename.includes("\\node_modules\\")
Expand Down
Loading
Loading