Skip to content

Commit e0d71f1

Browse files
authored
tooling: add collapse-barrel.ts for single-namespace barrel migration (#22887)
1 parent 1c33b86 commit e0d71f1

1 file changed

Lines changed: 161 additions & 0 deletions

File tree

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Collapse a single-namespace barrel directory into a dir/index.ts module.
4+
*
5+
* Given a directory `src/foo/` that contains:
6+
*
7+
* - `index.ts` (exactly `export * as Foo from "./foo"`)
8+
* - `foo.ts` (the real implementation)
9+
* - zero or more sibling files
10+
*
11+
* this script:
12+
*
13+
* 1. Deletes the old `index.ts` barrel.
14+
* 2. `git mv`s `foo.ts` → `index.ts` so the implementation IS the directory entry.
15+
* 3. Appends `export * as Foo from "."` to the new `index.ts`.
16+
* 4. Rewrites any same-directory sibling `*.ts` files that imported
17+
* `./foo` (with or without the namespace name) to import `"."` instead.
18+
*
19+
* Consumer files outside the directory keep importing from the directory
20+
* (`"@/foo"` / `"../foo"` / etc.) and continue to work, because
21+
* `dir/index.ts` now provides the `Foo` named export directly.
22+
*
23+
* Usage:
24+
*
25+
* bun script/collapse-barrel.ts src/bus
26+
* bun script/collapse-barrel.ts src/bus --dry-run
27+
*
28+
* Notes:
29+
*
30+
* - Only works on directories whose barrel is a single
31+
* `export * as Name from "./file"` line. Refuses otherwise.
32+
* - Refuses if the implementation file name already conflicts with
33+
* `index.ts`.
34+
* - Safe to run repeatedly: a second run on an already-collapsed dir
35+
* will exit with a clear message.
36+
*/
37+
38+
import fs from "node:fs"
39+
import path from "node:path"
40+
import { spawnSync } from "node:child_process"
41+
42+
const args = process.argv.slice(2)
43+
const dryRun = args.includes("--dry-run")
44+
const targetArg = args.find((a) => !a.startsWith("--"))
45+
46+
if (!targetArg) {
47+
console.error("Usage: bun script/collapse-barrel.ts <dir> [--dry-run]")
48+
process.exit(1)
49+
}
50+
51+
const dir = path.resolve(targetArg)
52+
const indexPath = path.join(dir, "index.ts")
53+
54+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
55+
console.error(`Not a directory: ${dir}`)
56+
process.exit(1)
57+
}
58+
if (!fs.existsSync(indexPath)) {
59+
console.error(`No index.ts in ${dir}`)
60+
process.exit(1)
61+
}
62+
63+
// Validate barrel shape.
64+
const indexContent = fs.readFileSync(indexPath, "utf-8").trim()
65+
const match = indexContent.match(/^export\s+\*\s+as\s+(\w+)\s+from\s+["']\.\/([^"']+)["']\s*;?\s*$/)
66+
if (!match) {
67+
console.error(`Not a simple single-namespace barrel:\n${indexContent}`)
68+
process.exit(1)
69+
}
70+
const namespaceName = match[1]
71+
const implRel = match[2].replace(/\.ts$/, "")
72+
const implPath = path.join(dir, `${implRel}.ts`)
73+
74+
if (!fs.existsSync(implPath)) {
75+
console.error(`Implementation file not found: ${implPath}`)
76+
process.exit(1)
77+
}
78+
79+
if (implRel === "index") {
80+
console.error(`Nothing to do — impl file is already index.ts`)
81+
process.exit(0)
82+
}
83+
84+
console.log(`Collapsing ${path.relative(process.cwd(), dir)}`)
85+
console.log(` namespace: ${namespaceName}`)
86+
console.log(` impl file: ${implRel}.ts → index.ts`)
87+
88+
// Figure out which sibling files need rewriting.
89+
const siblings = fs
90+
.readdirSync(dir)
91+
.filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"))
92+
.filter((f) => f !== "index.ts" && f !== `${implRel}.ts`)
93+
.map((f) => path.join(dir, f))
94+
95+
type SiblingEdit = { file: string; content: string }
96+
const siblingEdits: SiblingEdit[] = []
97+
98+
for (const sibling of siblings) {
99+
const content = fs.readFileSync(sibling, "utf-8")
100+
// Match any import or re-export referring to "./<implRel>" inside this directory.
101+
const siblingRegex = new RegExp(`(from\\s*["'])\\.\\/${implRel.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&")}(["'])`, "g")
102+
if (!siblingRegex.test(content)) continue
103+
const updated = content.replace(siblingRegex, `$1.$2`)
104+
siblingEdits.push({ file: sibling, content: updated })
105+
}
106+
107+
if (siblingEdits.length > 0) {
108+
console.log(` sibling rewrites: ${siblingEdits.length}`)
109+
for (const edit of siblingEdits) {
110+
console.log(` ${path.relative(process.cwd(), edit.file)}`)
111+
}
112+
} else {
113+
console.log(` sibling rewrites: none`)
114+
}
115+
116+
if (dryRun) {
117+
console.log(`\n(dry run) would:`)
118+
console.log(` - delete ${path.relative(process.cwd(), indexPath)}`)
119+
console.log(` - git mv ${path.relative(process.cwd(), implPath)} ${path.relative(process.cwd(), indexPath)}`)
120+
console.log(` - append \`export * as ${namespaceName} from "."\` to the new index.ts`)
121+
for (const edit of siblingEdits) {
122+
console.log(` - rewrite sibling: ${path.relative(process.cwd(), edit.file)}`)
123+
}
124+
process.exit(0)
125+
}
126+
127+
// Apply: remove the old barrel, git-mv the impl onto it, then rewrite content.
128+
// We can't git-mv on top of an existing tracked file, so we remove the barrel first.
129+
function runGit(...cmd: string[]) {
130+
const res = spawnSync("git", cmd, { stdio: "inherit" })
131+
if (res.status !== 0) {
132+
console.error(`git ${cmd.join(" ")} failed`)
133+
process.exit(res.status ?? 1)
134+
}
135+
}
136+
137+
// Step 1: remove the barrel
138+
runGit("rm", "-f", indexPath)
139+
140+
// Step 2: rename the impl file into index.ts
141+
runGit("mv", implPath, indexPath)
142+
143+
// Step 3: append the self-reexport to the new index.ts
144+
const newContent = fs.readFileSync(indexPath, "utf-8")
145+
const trimmed = newContent.endsWith("\n") ? newContent : newContent + "\n"
146+
fs.writeFileSync(indexPath, `${trimmed}\nexport * as ${namespaceName} from "."\n`)
147+
console.log(` appended: export * as ${namespaceName} from "."`)
148+
149+
// Step 4: rewrite siblings
150+
for (const edit of siblingEdits) {
151+
fs.writeFileSync(edit.file, edit.content)
152+
}
153+
if (siblingEdits.length > 0) {
154+
console.log(` rewrote ${siblingEdits.length} sibling file(s)`)
155+
}
156+
157+
console.log(`\nDone. Verify with:`)
158+
console.log(` cd packages/opencode`)
159+
console.log(` bunx --bun tsgo --noEmit`)
160+
console.log(` bun run --conditions=browser ./src/index.ts generate`)
161+
console.log(` bun run test`)

0 commit comments

Comments
 (0)