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
61 changes: 61 additions & 0 deletions codemods/debarrel/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,66 @@
# debarrel

## 0.7.11

### Patch Changes

- Parse JSONC `tsconfig.json` files (comments and trailing commas) so tsconfig path aliases resolve on real-world configs.
- Route semantic-analyzer-resolved rewrites through `buildRewriteFromTarget` so default imports and barrel metrics stay correct.
- Distinguish `export { default } from "./x"` from `export { Foo as default }` when choosing import type.
- Cache workspace source file listings during namespace-importer detection.
- Use `path.relative` instead of a hand-rolled relative path helper.

## 0.7.10

### Patch Changes

- Commit barrel renames when a barrel file has no import edits (fixes `.tsx` barrels being skipped when `edits` is empty).
- Add Sentry-shaped test fixtures for `sentry/stories` and `sentry/icons` namespace barrel preservation.

## 0.7.9

### Patch Changes

- Preserve namespace-imported barrels when tsconfig aliases share the package name (e.g. `sentry/stories` with `sentry/*` paths) by inverse-mapping alias strings and matching import paths directly.
- Scan `.mdx` files for namespace imports when deciding whether to keep a barrel.
- Resolve tsconfig and workspace roots with absolute paths during namespace-importer detection.

## 0.7.8

### Patch Changes

- Resolve barrel symbols using the imported name (`Foo` in `import { Foo as Bar }`), not the local alias, so folder barrels and wildcard aliases debarrel correctly.
- Replace `path.relative` with portable helpers for the JSSG runtime when computing paths from a barrel directory to its exports.
- Normalize barrel paths when detecting namespace importers so barrels are preserved reliably.

## 0.7.7

### Patch Changes

- Fix double `type` keyword in rewritten `import type { … }` statements when specifiers use inline `type` qualifiers.
- Resolve imports against `index.barrel.bak.*` when a barrel has already been renamed in the same pass, so consumers processed later still rewrite correctly.
- Match namespace-importer barrels by directory path, not only exact barrel file path.
- Rewrite relative folder imports (e.g. `../textarea`) to the concrete module when the directory barrel is removed.

## 0.7.6

### Patch Changes

- Fix invalid `import type { type Foo }` output when rewriting top-level `import type` statements. Inline `type` qualifiers are now only emitted for mixed value/type imports split across paths.
- Preserve barrel files that are namespace-imported (`import * as Ns from "…"`), since those imports cannot be debarreled to a single module.

## 0.7.5

### Patch Changes

- Rewrite default imports that flow through `export { Foo as default }` barrel re-exports when the semantic analyzer cannot resolve the binding. Also walk explicit `export { … } from` re-exports before falling back to `export *` chains, and preserve inline `import { type Foo }` specifiers when splitting partial barrel imports.

## 0.7.4

### Patch Changes

- Fix debarreling of tsconfig/webpack subpath aliases whose prefix matches the workspace `package.json` name (e.g. `myapp/widgets` when the package is named `myapp`). Previously the package-boundary guard treated every such import as a root package import and skipped rewriting, leaving consumers pointing at deleted barrel files. Also resolve alias imports when walking `export *` barrels and when semantic analysis resolves through a barrel to the source file.

## 0.7.3

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion codemods/debarrel/codemod.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
schema_version: "1.0"

name: "debarrel"
version: "0.7.3"
version: "0.7.11"
description: "Debarrel JS/TS codebases. Removing barrel files and replacing import statements."
author: "Mo Mohebifar <mo@codemod.com>"
license: "MIT"
Expand Down
2 changes: 1 addition & 1 deletion codemods/debarrel/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "debarrel",
"version": "0.7.3",
"version": "0.7.11",
"description": "Debarrel JS/TS codebases. Removing barrel files and replacing import statements.",
"type": "module",
"scripts": {
Expand Down
43 changes: 34 additions & 9 deletions codemods/debarrel/scripts/codemod.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import type { Codemod, Edit, GetSelector } from "codemod:ast-grep";
import type { Codemod, Edit, GetSelector, SgNode } from "codemod:ast-grep";
import { useMetricAtom } from "codemod:metrics";
import path from "path";
import type { Language } from "./utils/language.ts";
import { getStringContent } from "./utils/ast.ts";
import { getStringContent, getImportSpecifierNames } from "./utils/ast.ts";
import {
hasPackageJson,
isBarrelFile,
isInsideNodeModules,
isNextPagesApiRoute,
isPackageEntrypoint,
} from "./utils/paths.ts";
import { barrelHasNamespaceImporters } from "./utils/exportStar.ts";
import { isPureBarrel } from "./utils/barrel.ts";
import { resolveSpecifier, type SpecRewrite } from "./utils/specifiers.ts";
import { buildImportText, groupByPath } from "./utils/imports.ts";
Expand Down Expand Up @@ -48,6 +49,9 @@ const codemod: Codemod<Language> = async (root, options) => {
// resolved declarations are `export type` aliases).
const isTypeOnlyImport = importStmt.children().some((c) => c.is("type"));

const isTypeOnlySpecifier = (spec: SgNode<Language>) =>
spec.children().some((c) => c.is("type"));

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

Expand All @@ -61,19 +65,30 @@ const codemod: Codemod<Language> = async (root, options) => {
});
totalSpecifiers += specifiers.length;
for (const spec of specifiers) {
const identifiers = spec.findAll({ rule: { kind: "identifier" } });
const localBinding = identifiers[identifiers.length - 1];
const names = getImportSpecifierNames(spec);
if (!names) continue;
const { importedName, localName: consumerName } = names;
const localBinding = spec
.findAll({ rule: { kind: "identifier" } })
.at(-1);
if (!localBinding) continue;
const def = localBinding.definition();
if (!def) continue;
const rw = resolveSpecifier(
localBinding,
importedName,
consumerName,
importPath,
def,
filename,
relativeFilename,
false,
);
if (rw) rewrites.push(rw);
if (rw) {
if (!isTypeOnlyImport && isTypeOnlySpecifier(spec)) {
rw.typeOnly = true;
}
rewrites.push(rw);
}
}
}

Expand All @@ -89,11 +104,13 @@ const codemod: Codemod<Language> = async (root, options) => {
const def = defaultIdent.definition();
if (def) {
const rw = resolveSpecifier(
defaultIdent,
defaultIdent.text(),
defaultIdent.text(),
importPath,
def,
filename,
relativeFilename,
true,
);
if (rw) rewrites.push(rw);
}
Expand Down Expand Up @@ -159,19 +176,27 @@ const codemod: Codemod<Language> = async (root, options) => {
// Barrel rename — skip files inside node_modules or inside a package
// when the barrel is an actual package entrypoint (renaming it would break
// consumers importing via the package name).
let barrelRenamed = false;
if (
isBarrelFile(filename) &&
!isInsideNodeModules(filename) &&
!isNextPagesApiRoute(filename) &&
(!hasPackageJson(filename) || !isPackageEntrypoint(filename))
) {
const { pure, hasWildcards } = isPureBarrel(rootNode);
if (pure && !hasWildcards) {
if (
pure &&
!hasWildcards &&
!barrelHasNamespaceImporters(filename)
) {
root.rename(`index.barrel.bak${path.extname(filename)}`);
barrelRenamed = true;
}
}

if (edits.length === 0) return null;
if (edits.length === 0) {
return barrelRenamed ? rootNode.commitEdits([]) : null;
}
return rootNode.commitEdits(edits);
};

Expand Down
11 changes: 11 additions & 0 deletions codemods/debarrel/scripts/utils/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,14 @@ export function getStringContent(node: SgNode<Language>): string | null {
const fragment = node.find({ rule: { kind: "string_fragment" } });
return fragment ? fragment.text() : null;
}

Comment thread
alexbit-codemod marked this conversation as resolved.
/** Imported binding vs local alias for `import { Foo as Bar }`. */
export function getImportSpecifierNames(
spec: SgNode<Language>,
): { importedName: string; localName: string } | null {
const identifiers = spec.findAll({ rule: { kind: "identifier" } });
if (identifiers.length === 0) return null;
const importedName = identifiers[0]!.text();
const localName = identifiers[identifiers.length - 1]!.text();
return { importedName, localName };
}
16 changes: 16 additions & 0 deletions codemods/debarrel/scripts/utils/barrel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,17 @@ export interface BarrelExportInfo {
/**
* Parse a barrel file's export_statement to extract the re-export source and local name.
*/
export interface ParseBarrelExportOptions {
/** True when the consumer uses `import Binding from "…"` syntax. */
isDefaultImport?: boolean;
}

export function parseBarrelExport(
exportStmt: SgNode<Language>,
consumerImportName: string,
options: ParseBarrelExportOptions = {},
): BarrelExportInfo | null {
const { isDefaultImport = false } = options;
const children = exportStmt.children();
const sourceNode = children.find((c) => c.is("string"));
const exportClause = children.find((c) => c.is("export_clause"));
Expand Down Expand Up @@ -51,6 +58,15 @@ export function parseBarrelExport(
importType: localName === "default" ? "default" : "named",
};
}
if (isDefaultImport && exportedName === "default") {
return {
sourceFromBarrel: sourcePath,
localName: localName ?? consumerImportName,
// `export { default } from "./x"` re-exports a default binding;
// `export { Foo as default }` exposes a named symbol as default.
importType: localName === "default" ? "default" : "named",
};
}
}
}

Expand Down
107 changes: 106 additions & 1 deletion codemods/debarrel/scripts/utils/exportStar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,17 @@ 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";
import { parseBarrelExport } from "./barrel.ts";
import {
fileHasMdxNamespaceImportFrom,
findWorkspaceSourceRoot,
getAliasImportPathsForBarrel,
isLocalRelativePath,
normalizeAbsolutePath,
resolveImportPath,
resolveModuleImportPath,
getProjectSourceFiles,
} from "./paths.ts";

// The semantic analyzer's `definition()` does not chase through bare
// `export * from "./y"` re-exports in this jssg runtime — for those
Expand Down Expand Up @@ -138,6 +148,101 @@ export function findSymbolViaExportStar(
return walk(barrelFile, name, new Set(), 0);
}

export interface BarrelReexportMatch {
targetFile: string;
localName: string;
importType: "default" | "named";
}

/**
* Walk `barrelFile`'s explicit `export { … } from "./y"` re-exports to find
* which file provides `name` for a consumer import. Used when the semantic
* analyzer can't resolve the binding (e.g. default imports through
* `export { Foo as default }` re-exports).
*/
export function findSymbolViaBarrelReexports(
barrelFile: string,
consumerName: string,
isDefaultImport: boolean,
): BarrelReexportMatch | null {
const root = parseFile(barrelFile);
if (!root) return null;

for (const stmt of root.root().children()) {
if (!stmt.is("export_statement")) continue;
// Namespace re-exports (`export * as Ns from "./y"`) are not debarreled here.
if (stmt.children().some((c) => c.is("namespace_export"))) continue;
const info = parseBarrelExport(stmt, consumerName, { isDefaultImport });
if (!info || info.importType === "namespace") continue;
const targetFile = resolveImportPath(barrelFile, info.sourceFromBarrel);
if (!targetFile) continue;
return {
targetFile,
localName: info.localName,
importType: info.importType,
};
}
return null;
}

function barrelDirectory(filePath: string): string | null {
const base = path.basename(filePath);
if (!/^index(\.barrel\.bak)?\.(ts|tsx|js|jsx)$/.test(base)) return null;
return path.resolve(path.dirname(filePath));
}

function barrelPathsMatch(left: string, right: string): boolean {
const leftDir = barrelDirectory(left);
const rightDir = barrelDirectory(right);
if (leftDir && rightDir) return leftDir === rightDir;
return path.resolve(left) === path.resolve(right);
}

/**
* True when any source file in the workspace namespace-imports `barrelFile`.
* Namespace imports cannot be debarreled to a single module, so the barrel must
* be kept when this returns true.
*/
export function barrelHasNamespaceImporters(barrelFile: string): boolean {
const workspaceRoot = findWorkspaceSourceRoot(barrelFile);
const normalizedBarrel = normalizeAbsolutePath(barrelFile, workspaceRoot);
const aliasImportPaths = new Set(getAliasImportPathsForBarrel(barrelFile));

for (const file of getProjectSourceFiles(workspaceRoot)) {
if (path.resolve(file) === path.resolve(normalizedBarrel)) continue;

if (fileHasMdxNamespaceImportFrom(file, aliasImportPaths)) {
return true;
}

const root = parseFile(file);
if (!root) continue;

for (const importStmt of root.root().findAll({
rule: { kind: "import_statement" },
})) {
const importClause = importStmt
.children()
.find((c) => c.is("import_clause"));
if (!importClause?.find({ rule: { kind: "namespace_import" } })) continue;

const sourceNode = importStmt.children().find((c) => c.is("string"));
const importPath = sourceNode ? getStringContent(sourceNode) : null;
if (!importPath) continue;

if (aliasImportPaths.has(importPath)) {
return true;
}

const resolved = resolveModuleImportPath(file, importPath);
if (resolved && barrelPathsMatch(resolved, normalizedBarrel)) {
return true;
}
}
}
return false;
}

function walk(
file: string,
name: string,
Expand Down
24 changes: 18 additions & 6 deletions codemods/debarrel/scripts/utils/imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,27 @@ export function buildImportText(
parts.push(`* as ${ns.consumerName}`);
}
const namedSpecs = specs.filter((s) => s.importType === "named");
const allNamedAreTypeOnly =
namedSpecs.length > 0 &&
!defaultSpec &&
specs.every((s) => s.importType !== "namespace") &&
namedSpecs.every((s) => s.typeOnly);
if (namedSpecs.length > 0) {
const specTexts = namedSpecs.map((s) =>
s.localName !== s.consumerName
? `${s.localName} as ${s.consumerName}`
: s.consumerName,
);
const specTexts = namedSpecs.map((s) => {
// Top-level `import type`, all-type-only imports, and inline `type` on
// specifiers are mutually exclusive — combining them yields invalid
// `import type { type Foo }`.
const typePrefix =
!typeOnly && !allNamedAreTypeOnly && s.typeOnly ? "type " : "";
const binding =
s.localName !== s.consumerName
? `${s.localName} as ${s.consumerName}`
: s.consumerName;
return `${typePrefix}${binding}`;
});
parts.push(`{ ${specTexts.join(", ")} }`);
}
const typeKeyword = typeOnly ? "type " : "";
const typeKeyword = typeOnly || allNamedAreTypeOnly ? "type " : "";
return `import ${typeKeyword}${parts.join(", ")} from ${quoteChar}${sourcePath}${quoteChar};`;
}

Expand Down
Loading
Loading