Skip to content

Commit 02f2cf4

Browse files
authored
feat: namespace → flat export migration (Bus proof-of-concept) (anomalyco#22685)
1 parent 6d42f97 commit 02f2cf4

4 files changed

Lines changed: 827 additions & 194 deletions

File tree

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Unwrap a TypeScript `export namespace` into flat exports + barrel.
4+
*
5+
* Usage:
6+
* bun script/unwrap-namespace.ts src/bus/index.ts
7+
* bun script/unwrap-namespace.ts src/bus/index.ts --dry-run
8+
*
9+
* What it does:
10+
* 1. Reads the file and finds the `export namespace Foo { ... }` block
11+
* (uses ast-grep for accurate AST-based boundary detection)
12+
* 2. Removes the namespace wrapper and dedents the body
13+
* 3. If the file is index.ts, renames it to <lowercase-name>.ts
14+
* 4. Creates/updates index.ts with `export * as Foo from "./<file>"`
15+
* 5. Prints the import rewrite commands to run across the codebase
16+
*
17+
* Does NOT auto-rewrite imports — prints the commands so you can review them.
18+
*
19+
* Requires: ast-grep (`brew install ast-grep` or `cargo install ast-grep`)
20+
*/
21+
22+
import path from "path"
23+
import fs from "fs"
24+
25+
const args = process.argv.slice(2)
26+
const dryRun = args.includes("--dry-run")
27+
const filePath = args.find((a) => !a.startsWith("--"))
28+
29+
if (!filePath) {
30+
console.error("Usage: bun script/unwrap-namespace.ts <file> [--dry-run]")
31+
process.exit(1)
32+
}
33+
34+
const absPath = path.resolve(filePath)
35+
if (!fs.existsSync(absPath)) {
36+
console.error(`File not found: ${absPath}`)
37+
process.exit(1)
38+
}
39+
40+
const src = fs.readFileSync(absPath, "utf-8")
41+
const lines = src.split("\n")
42+
43+
// Use ast-grep to find the namespace boundaries accurately.
44+
// This avoids false matches from braces in strings, templates, comments, etc.
45+
const astResult = Bun.spawnSync(
46+
["ast-grep", "run", "--pattern", "export namespace $NAME { $$$BODY }", "--lang", "typescript", "--json", absPath],
47+
{ stdout: "pipe", stderr: "pipe" },
48+
)
49+
50+
if (astResult.exitCode !== 0) {
51+
console.error("ast-grep failed:", astResult.stderr.toString())
52+
process.exit(1)
53+
}
54+
55+
const matches = JSON.parse(astResult.stdout.toString()) as Array<{
56+
text: string
57+
range: { start: { line: number; column: number }; end: { line: number; column: number } }
58+
metaVariables: { single: Record<string, { text: string }>; multi: Record<string, Array<{ text: string }>> }
59+
}>
60+
61+
if (matches.length === 0) {
62+
console.error("No `export namespace Foo { ... }` found in file")
63+
process.exit(1)
64+
}
65+
66+
if (matches.length > 1) {
67+
console.error(`Found ${matches.length} namespaces — this script handles one at a time`)
68+
console.error("Namespaces found:")
69+
for (const m of matches) console.error(` ${m.metaVariables.single.NAME.text} (line ${m.range.start.line + 1})`)
70+
process.exit(1)
71+
}
72+
73+
const match = matches[0]
74+
const nsName = match.metaVariables.single.NAME.text
75+
const nsLine = match.range.start.line // 0-indexed
76+
const closeLine = match.range.end.line // 0-indexed, the line with closing `}`
77+
78+
console.log(`Found: export namespace ${nsName} { ... }`)
79+
console.log(` Lines ${nsLine + 1}${closeLine + 1} (${closeLine - nsLine + 1} lines)`)
80+
81+
// Build the new file content:
82+
// 1. Everything before the namespace declaration (imports, etc.)
83+
// 2. The namespace body, dedented by one level (2 spaces)
84+
// 3. Everything after the closing brace (rare, but possible)
85+
const before = lines.slice(0, nsLine)
86+
const body = lines.slice(nsLine + 1, closeLine)
87+
const after = lines.slice(closeLine + 1)
88+
89+
// Dedent: remove exactly 2 leading spaces from each line
90+
const dedented = body.map((line) => {
91+
if (line === "") return ""
92+
if (line.startsWith(" ")) return line.slice(2)
93+
return line // don't touch lines that aren't indented (shouldn't happen)
94+
})
95+
96+
const newContent = [...before, ...dedented, ...after].join("\n")
97+
98+
// Figure out file naming
99+
const dir = path.dirname(absPath)
100+
const basename = path.basename(absPath, ".ts")
101+
const isIndex = basename === "index"
102+
103+
// The implementation file name (lowercase namespace name if currently index.ts)
104+
const implName = isIndex ? nsName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase() : basename
105+
const implFile = path.join(dir, `${implName}.ts`)
106+
const indexFile = path.join(dir, "index.ts")
107+
108+
// The barrel line
109+
const barrelLine = `export * as ${nsName} from "./${implName}"\n`
110+
111+
console.log("")
112+
if (isIndex) {
113+
console.log(`Plan: rename ${basename}.ts → ${implName}.ts, create new index.ts barrel`)
114+
} else {
115+
console.log(`Plan: rewrite ${basename}.ts in place, create index.ts barrel`)
116+
}
117+
console.log("")
118+
119+
if (dryRun) {
120+
console.log("--- DRY RUN ---")
121+
console.log("")
122+
console.log(`=== ${implName}.ts (first 30 lines) ===`)
123+
newContent
124+
.split("\n")
125+
.slice(0, 30)
126+
.forEach((l, i) => console.log(` ${i + 1}: ${l}`))
127+
console.log(" ...")
128+
console.log("")
129+
console.log(`=== index.ts ===`)
130+
console.log(` ${barrelLine.trim()}`)
131+
} else {
132+
// Write the implementation file
133+
if (isIndex) {
134+
// Rename: write new content to implFile, then overwrite index.ts with barrel
135+
fs.writeFileSync(implFile, newContent)
136+
fs.writeFileSync(indexFile, barrelLine)
137+
console.log(`Wrote ${implName}.ts (${newContent.split("\n").length} lines)`)
138+
console.log(`Wrote index.ts (barrel)`)
139+
} else {
140+
// Rewrite in place, create index.ts
141+
fs.writeFileSync(absPath, newContent)
142+
if (fs.existsSync(indexFile)) {
143+
// Append to existing barrel
144+
const existing = fs.readFileSync(indexFile, "utf-8")
145+
if (!existing.includes(`export * as ${nsName}`)) {
146+
fs.appendFileSync(indexFile, barrelLine)
147+
console.log(`Appended to existing index.ts`)
148+
} else {
149+
console.log(`index.ts already has ${nsName} export`)
150+
}
151+
} else {
152+
fs.writeFileSync(indexFile, barrelLine)
153+
console.log(`Wrote index.ts (barrel)`)
154+
}
155+
console.log(`Rewrote ${basename}.ts (${newContent.split("\n").length} lines)`)
156+
}
157+
}
158+
159+
// Print the import rewrite guidance
160+
const relDir = path.relative(path.resolve("src"), dir)
161+
162+
console.log("")
163+
console.log("=== Import rewrites ===")
164+
console.log("")
165+
166+
if (!isIndex) {
167+
// Non-index files: imports like "../provider/provider" need to become "../provider"
168+
const oldTail = `${relDir}/${basename}`
169+
170+
console.log(`# Find all imports to rewrite:`)
171+
console.log(`rg 'from.*${oldTail}' src/ --files-with-matches`)
172+
console.log("")
173+
174+
// Auto-rewrite with sed (safe: only rewrites the import path, not other occurrences)
175+
console.log("# Auto-rewrite (review diff afterward):")
176+
console.log(`rg -l 'from.*${oldTail}' src/ | xargs sed -i '' 's|${oldTail}"|${relDir}"|g'`)
177+
console.log("")
178+
console.log("# What changes:")
179+
console.log(`# import { ${nsName} } from ".../${oldTail}"`)
180+
console.log(`# import { ${nsName} } from ".../${relDir}"`)
181+
} else {
182+
console.log("# File was index.ts — import paths already resolve correctly.")
183+
console.log("# No import rewrites needed!")
184+
}
185+
186+
console.log("")
187+
console.log("=== Verify ===")
188+
console.log("")
189+
console.log("bun typecheck # from packages/opencode")
190+
console.log("bun run test # run tests")

0 commit comments

Comments
 (0)