#!/usr/bin/env bun /** * Unwrap a TypeScript `export namespace` into flat exports with self-reexport. * * Usage: * bun script/unwrap-namespace.ts src/session/session.ts # convert namespace * bun script/unwrap-namespace.ts src/session/session.ts --dry-run * bun script/unwrap-namespace.ts src/pty/index.ts --name service # avoid filename collision * bun script/unwrap-namespace.ts src/config/config.ts --retrofit # already flat, add self-reexport * * Default mode: * 1. Finds `export namespace Foo { ... }` (ast-grep) * 2. Removes wrapper, dedents body, fixes self-references * 3. Appends `export * as Foo from "./file"` to the file (self-reexport) * 4. Rewrites consumer imports to point at the file directly * * Retrofit mode (--retrofit): * File already has flat exports (from previous barrel migration). * 1. Reads the barrel index.ts to find the namespace name * 2. Adds `export * as Foo from "./file"` to the source file * 3. Rewrites consumers from barrel import to direct file import * * Does NOT create barrel index.ts files. * * Requires: ast-grep (`brew install ast-grep` or `cargo install ast-grep`) */ import path from "path" import fs from "fs" const args = process.argv.slice(2) const dryRun = args.includes("--dry-run") const retrofit = args.includes("--retrofit") const nameFlag = args.find((a, i) => args[i - 1] === "--name") const filePath = args.find((a) => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--name") if (!filePath) { console.error("Usage: bun script/unwrap-namespace.ts [--dry-run] [--name ] [--retrofit]") process.exit(1) } const absPath = path.resolve(filePath) if (!fs.existsSync(absPath)) { console.error(`File not found: ${absPath}`) process.exit(1) } const srcRoot = path.resolve("src") const dir = path.dirname(absPath) const basename = path.basename(absPath, ".ts") // --------------------------------------------------------------------------- // Barrel map: parse an index.ts to get namespace→file mapping // --------------------------------------------------------------------------- function parseBarrelMap(indexPath: string): Record { const map: Record = {} if (!fs.existsSync(indexPath)) return map const content = fs.readFileSync(indexPath, "utf-8") const re = /export\s+\*\s+as\s+(\w+)\s+from\s+["']\.\/([^"']+)["']/g for (const m of content.matchAll(re)) { map[m[1]] = m[2].replace(/\.ts$/, "") } return map } // --------------------------------------------------------------------------- // Retrofit mode: file is already flat, just add self-reexport + fix imports // --------------------------------------------------------------------------- if (retrofit) { const indexFile = path.join(dir, "index.ts") const barrelMap = parseBarrelMap(indexFile) // Find this file's namespace name from the barrel const relName = basename let nsName: string | undefined for (const [ns, file] of Object.entries(barrelMap)) { if (file === relName) { nsName = ns break } } if (!nsName) { console.error(`Could not find namespace for ${basename}.ts in ${indexFile}`) console.error("Barrel map:", barrelMap) process.exit(1) } console.log(`Retrofit: ${basename}.ts → add self-reexport as ${nsName}`) // Check if self-reexport already exists const content = fs.readFileSync(absPath, "utf-8") const selfReexport = `export * as ${nsName} from "./${basename}"` if (content.includes(selfReexport)) { console.log("Self-reexport already present, skipping file modification") } else if (!dryRun) { const trimmed = content.endsWith("\n") ? content : content + "\n" fs.writeFileSync(absPath, trimmed + selfReexport + "\n") console.log(`Added: ${selfReexport}`) } else { console.log(`Would add: ${selfReexport}`) } // Now rewrite consumers (same logic as default mode, below) rewriteConsumers(nsName, absPath, basename, dir) process.exit(0) } // --------------------------------------------------------------------------- // Default mode: unwrap namespace // --------------------------------------------------------------------------- const src = fs.readFileSync(absPath, "utf-8") const lines = src.split("\n") const astResult = Bun.spawnSync( ["ast-grep", "run", "--pattern", "export namespace $NAME { $$$BODY }", "--lang", "typescript", "--json", absPath], { stdout: "pipe", stderr: "pipe" }, ) if (astResult.exitCode !== 0) { console.error("ast-grep failed:", astResult.stderr.toString()) process.exit(1) } const matches = JSON.parse(astResult.stdout.toString()) as Array<{ text: string range: { start: { line: number; column: number }; end: { line: number; column: number } } metaVariables: { single: Record; multi: Record> } }> if (matches.length === 0) { console.error("No `export namespace Foo { ... }` found. Use --retrofit for already-converted files.") process.exit(1) } if (matches.length > 1) { console.error(`Found ${matches.length} namespaces — this script handles one at a time`) for (const m of matches) console.error(` ${m.metaVariables.single.NAME.text} (line ${m.range.start.line + 1})`) process.exit(1) } const match = matches[0] const nsName = match.metaVariables.single.NAME.text const nsLine = match.range.start.line const closeLine = match.range.end.line console.log(`Found: export namespace ${nsName} { ... }`) console.log(` Lines ${nsLine + 1}–${closeLine + 1} (${closeLine - nsLine + 1} lines)`) // Unwrap: remove namespace wrapper, dedent body const before = lines.slice(0, nsLine) const body = lines.slice(nsLine + 1, closeLine) const after = lines.slice(closeLine + 1) const dedented = body.map((line) => { if (line === "") return "" if (line.startsWith(" ")) return line.slice(2) return line }) let newContent = [...before, ...dedented, ...after].join("\n") // Fix self-references (Foo.Bar → Bar when Bar is exported from this file) const exportedNames = new Set() const exportRegex = /export\s+(?:const|function|class|interface|type|enum|abstract\s+class)\s+(\w+)/g for (const line of dedented) { for (const m of line.matchAll(exportRegex)) exportedNames.add(m[1]) } const reExportRegex = /export\s*\{\s*([^}]+)\}/g for (const line of dedented) { for (const m of line.matchAll(reExportRegex)) { for (const name of m[1].split(",")) { const trimmed = name .trim() .split(/\s+as\s+/) .pop()! .trim() if (trimmed) exportedNames.add(trimmed) } } } let selfRefCount = 0 if (exportedNames.size > 0) { const fixedLines = newContent.split("\n").map((line) => { const segments: Array<{ text: string; isString: boolean }> = [] let i = 0 let current = "" let inString: string | null = null while (i < line.length) { const ch = line[i] if (inString) { current += ch if (ch === "\\" && i + 1 < line.length) { current += line[i + 1] i += 2 continue } if (ch === inString) { segments.push({ text: current, isString: true }) current = "" inString = null } i++ continue } if (ch === '"' || ch === "'" || ch === "`") { if (current) segments.push({ text: current, isString: false }) current = ch inString = ch i++ continue } if (ch === "/" && i + 1 < line.length && line[i + 1] === "/") { current += line.slice(i) segments.push({ text: current, isString: true }) current = "" i = line.length continue } current += ch i++ } if (current) segments.push({ text: current, isString: !!inString }) return segments .map((seg) => { if (seg.isString) return seg.text let result = seg.text for (const name of exportedNames) { const pattern = `${nsName}.${name}` while (result.includes(pattern)) { const idx = result.indexOf(pattern) const charBefore = idx > 0 ? result[idx - 1] : " " const charAfter = idx + pattern.length < result.length ? result[idx + pattern.length] : " " if (/\w/.test(charBefore) || /\w/.test(charAfter)) break result = result.slice(0, idx) + name + result.slice(idx + pattern.length) selfRefCount++ } } return result }) .join("") }) newContent = fixedLines.join("\n") } // Handle index.ts rename const isIndex = basename === "index" const implName = nameFlag ?? (isIndex ? nsName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase() : basename) const implFile = isIndex ? path.join(dir, `${implName}.ts`) : absPath // Add self-reexport at the bottom const selfReexport = `export * as ${nsName} from "./${implName}"` if (!newContent.endsWith("\n")) newContent += "\n" newContent += selfReexport + "\n" console.log("") if (isIndex) { console.log(`Plan: rename index.ts → ${implName}.ts, add self-reexport`) } else { console.log(`Plan: unwrap in place, add self-reexport`) } if (selfRefCount > 0) console.log(`Fixed ${selfRefCount} self-reference(s) (${nsName}.X → X)`) if (dryRun) { console.log("") console.log("--- DRY RUN ---") console.log("") console.log(`=== ${implName}.ts (first 20 lines) ===`) newContent .split("\n") .slice(0, 20) .forEach((l, i) => console.log(` ${i + 1}: ${l}`)) console.log(" ...") console.log("") console.log(`=== last 5 lines ===`) const allLines = newContent.split("\n") allLines.slice(-5).forEach((l, i) => console.log(` ${allLines.length - 4 + i}: ${l}`)) console.log("") rewriteConsumers(nsName, implFile, implName, dir) } else { if (isIndex) { fs.writeFileSync(implFile, newContent) fs.unlinkSync(absPath) console.log(`Renamed to ${implName}.ts (${newContent.split("\n").length} lines)`) } else { fs.writeFileSync(absPath, newContent) console.log(`Rewrote ${basename}.ts (${newContent.split("\n").length} lines)`) } rewriteConsumers(nsName, implFile, implName, dir) } // --------------------------------------------------------------------------- // Consumer import rewriting (shared by default + retrofit mode) // --------------------------------------------------------------------------- function rewriteConsumers(nsName: string, implFile: string, implName: string, dir: string) { const relImplFromSrc = path.relative(srcRoot, implFile).replace(/\.ts$/, "") const barrelMap = parseBarrelMap(path.join(dir, "index.ts")) // Find all files that reference the namespace name const searchDirs = ["src", "test", "script"].filter((d) => fs.existsSync(d)) const rgResult = Bun.spawnSync(["rg", "-l", nsName, ...searchDirs, "--type", "ts"], { stdout: "pipe", stderr: "pipe", }) const candidates = rgResult.stdout .toString() .trim() .split("\n") .filter((f) => f.length > 0) let totalChanges = 0 const changedFiles: string[] = [] for (const file of candidates) { const absFile = path.resolve(file) if (absFile === path.resolve(implFile) || absFile === path.resolve(absPath)) continue let content = fs.readFileSync(file, "utf-8") let changes = 0 // Match: import { Foo } or import { Foo, Bar } or import type { Foo } const importRe = /^(import\s+(?:type\s+)?)\{\s*([^}]+)\}\s*from\s*["']([^"']+)["']/gm content = content.replace(importRe, (original, prefix: string, names: string, importPath: string) => { const nameList = names .split(",") .map((n) => n.trim()) .filter(Boolean) // Check if this namespace is among the imported names const nsEntry = nameList.find((n) => n.split(/\s+as\s+/)[0].trim() === nsName) if (!nsEntry) return original // Check if this import resolves to our directory (barrel) or our file const resolved = resolveImportPath(importPath, file) if (!resolved) return original const resolvedAbs = path.resolve(resolved) const isBarrelImport = resolvedAbs === dir || resolvedAbs === path.join(dir, "index.ts") || resolvedAbs === path.join(dir, "index") const isDirectImport = resolvedAbs === implFile.replace(/\.ts$/, "") || resolvedAbs === implFile if (!isBarrelImport && !isDirectImport) return original // If it's already a direct import with just this name, nothing to change if (isDirectImport && nameList.length === 1) return original // Build the correct import path for the impl file const newImportPath = computeImportPath(file, implFile) if (nameList.length === 1) { // Simple: just repoint to the file changes++ return `${prefix}{ ${nsEntry} } from "${newImportPath}"` } // Multi-import: split into separate lines const newLines: string[] = [] for (const n of nameList) { const imported = n.split(/\s+as\s+/)[0].trim() if (imported === nsName) { newLines.push(`${prefix}{ ${n} } from "${newImportPath}"`) changes++ } else if (barrelMap[imported]) { // Another namespace from the same barrel const otherFile = path.join(dir, barrelMap[imported] + ".ts") const otherPath = computeImportPath(file, otherFile) newLines.push(`${prefix}{ ${n} } from "${otherPath}"`) changes++ } else { // Unknown — keep original path newLines.push(`${prefix}{ ${n} } from "${importPath}"`) } } return newLines.join("\n") }) // Fix dynamic imports: const { Foo } = await import("...") const dynRe = new RegExp( `(const|let|var)\\s+\\{\\s*${nsName}\\s*\\}\\s*=\\s*await\\s+import\\(\\s*["']([^"']+)["']\\s*\\)`, "g", ) content = content.replace(dynRe, (original, decl, importPath) => { const resolved = resolveImportPath(importPath, file) if (!resolved) return original const resolvedAbs = path.resolve(resolved) const isTarget = resolvedAbs === dir || resolvedAbs === path.join(dir, "index.ts") || resolvedAbs === path.join(dir, "index") || resolvedAbs === implFile.replace(/\.ts$/, "") || resolvedAbs === implFile if (!isTarget) return original const newPath = computeImportPath(file, implFile) changes++ return `${decl} ${nsName} = await import("${newPath}")` }) if (changes > 0) { if (!dryRun) fs.writeFileSync(file, content) changedFiles.push(file) totalChanges += changes } } console.log("") if (totalChanges > 0) { console.log(`${dryRun ? "Would rewrite" : "Rewrote"} ${totalChanges} import(s) in ${changedFiles.length} file(s):`) for (const f of changedFiles) console.log(` ${f}`) } else { console.log("No import rewrites needed") } console.log("") console.log("=== Verify ===") console.log("") console.log("bunx --bun tsgo --noEmit # typecheck") console.log("bun run --conditions=browser ./src/index.ts generate # circular import check") } // --------------------------------------------------------------------------- // Path utilities // --------------------------------------------------------------------------- function resolveImportPath(importPath: string, fromFile: string): string | null { if (importPath.startsWith("@/")) return path.join(srcRoot, importPath.slice(2)) if (importPath.startsWith(".")) return path.resolve(path.dirname(fromFile), importPath) return null } function computeImportPath(fromFile: string, toFile: string): string { const fromAbs = path.resolve(fromFile) if (fromAbs.startsWith(srcRoot + "/")) { return `@/${path.relative(srcRoot, toFile).replace(/\.ts$/, "")}` } let rel = path.relative(path.dirname(fromAbs), toFile).replace(/\.ts$/, "") if (!rel.startsWith(".")) rel = "./" + rel return rel }