unwrap-namespace.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  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. * bun script/unwrap-namespace.ts src/pty/index.ts --name service # avoid collision with pty.ts
  9. *
  10. * What it does:
  11. * 1. Reads the file and finds the `export namespace Foo { ... }` block
  12. * (uses ast-grep for accurate AST-based boundary detection)
  13. * 2. Removes the namespace wrapper and dedents the body
  14. * 3. Fixes self-references (e.g. Config.PermissionAction → PermissionAction)
  15. * 4. If the file is index.ts, renames it to <lowercase-name>.ts
  16. * 5. Creates/updates index.ts with `export * as Foo from "./<file>"`
  17. * 6. Rewrites import paths across src/, test/, and script/
  18. * 7. Fixes sibling imports within the same directory
  19. *
  20. * Requires: ast-grep (`brew install ast-grep` or `cargo install ast-grep`)
  21. */
  22. import path from "path"
  23. import fs from "fs"
  24. const args = process.argv.slice(2)
  25. const dryRun = args.includes("--dry-run")
  26. const nameFlag = args.find((a, i) => args[i - 1] === "--name")
  27. const filePath = args.find((a) => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--name")
  28. if (!filePath) {
  29. console.error("Usage: bun script/unwrap-namespace.ts <file> [--dry-run] [--name <impl-name>]")
  30. process.exit(1)
  31. }
  32. const absPath = path.resolve(filePath)
  33. if (!fs.existsSync(absPath)) {
  34. console.error(`File not found: ${absPath}`)
  35. process.exit(1)
  36. }
  37. const src = fs.readFileSync(absPath, "utf-8")
  38. const lines = src.split("\n")
  39. // Use ast-grep to find the namespace boundaries accurately.
  40. // This avoids false matches from braces in strings, templates, comments, etc.
  41. const astResult = Bun.spawnSync(
  42. ["ast-grep", "run", "--pattern", "export namespace $NAME { $$$BODY }", "--lang", "typescript", "--json", absPath],
  43. { stdout: "pipe", stderr: "pipe" },
  44. )
  45. if (astResult.exitCode !== 0) {
  46. console.error("ast-grep failed:", astResult.stderr.toString())
  47. process.exit(1)
  48. }
  49. const matches = JSON.parse(astResult.stdout.toString()) as Array<{
  50. text: string
  51. range: { start: { line: number; column: number }; end: { line: number; column: number } }
  52. metaVariables: { single: Record<string, { text: string }>; multi: Record<string, Array<{ text: string }>> }
  53. }>
  54. if (matches.length === 0) {
  55. console.error("No `export namespace Foo { ... }` found in file")
  56. process.exit(1)
  57. }
  58. if (matches.length > 1) {
  59. console.error(`Found ${matches.length} namespaces — this script handles one at a time`)
  60. console.error("Namespaces found:")
  61. for (const m of matches) console.error(` ${m.metaVariables.single.NAME.text} (line ${m.range.start.line + 1})`)
  62. process.exit(1)
  63. }
  64. const match = matches[0]
  65. const nsName = match.metaVariables.single.NAME.text
  66. const nsLine = match.range.start.line // 0-indexed
  67. const closeLine = match.range.end.line // 0-indexed, the line with closing `}`
  68. console.log(`Found: export namespace ${nsName} { ... }`)
  69. console.log(` Lines ${nsLine + 1}–${closeLine + 1} (${closeLine - nsLine + 1} lines)`)
  70. // Build the new file content:
  71. // 1. Everything before the namespace declaration (imports, etc.)
  72. // 2. The namespace body, dedented by one level (2 spaces)
  73. // 3. Everything after the closing brace (rare, but possible)
  74. const before = lines.slice(0, nsLine)
  75. const body = lines.slice(nsLine + 1, closeLine)
  76. const after = lines.slice(closeLine + 1)
  77. // Dedent: remove exactly 2 leading spaces from each line
  78. const dedented = body.map((line) => {
  79. if (line === "") return ""
  80. if (line.startsWith(" ")) return line.slice(2)
  81. return line
  82. })
  83. let newContent = [...before, ...dedented, ...after].join("\n")
  84. // --- Fix self-references ---
  85. // After unwrapping, references like `Config.PermissionAction` inside the same file
  86. // need to become just `PermissionAction`. Only fix code positions, not strings.
  87. const exportedNames = new Set<string>()
  88. const exportRegex = /export\s+(?:const|function|class|interface|type|enum|abstract\s+class)\s+(\w+)/g
  89. for (const line of dedented) {
  90. for (const m of line.matchAll(exportRegex)) exportedNames.add(m[1])
  91. }
  92. const reExportRegex = /export\s*\{\s*([^}]+)\}/g
  93. for (const line of dedented) {
  94. for (const m of line.matchAll(reExportRegex)) {
  95. for (const name of m[1].split(",")) {
  96. const trimmed = name
  97. .trim()
  98. .split(/\s+as\s+/)
  99. .pop()!
  100. .trim()
  101. if (trimmed) exportedNames.add(trimmed)
  102. }
  103. }
  104. }
  105. let selfRefCount = 0
  106. if (exportedNames.size > 0) {
  107. const fixedLines = newContent.split("\n").map((line) => {
  108. // Split line into string-literal and code segments to avoid replacing inside strings
  109. const segments: Array<{ text: string; isString: boolean }> = []
  110. let i = 0
  111. let current = ""
  112. let inString: string | null = null
  113. while (i < line.length) {
  114. const ch = line[i]
  115. if (inString) {
  116. current += ch
  117. if (ch === "\\" && i + 1 < line.length) {
  118. current += line[i + 1]
  119. i += 2
  120. continue
  121. }
  122. if (ch === inString) {
  123. segments.push({ text: current, isString: true })
  124. current = ""
  125. inString = null
  126. }
  127. i++
  128. continue
  129. }
  130. if (ch === '"' || ch === "'" || ch === "`") {
  131. if (current) segments.push({ text: current, isString: false })
  132. current = ch
  133. inString = ch
  134. i++
  135. continue
  136. }
  137. if (ch === "/" && i + 1 < line.length && line[i + 1] === "/") {
  138. current += line.slice(i)
  139. segments.push({ text: current, isString: true })
  140. current = ""
  141. i = line.length
  142. continue
  143. }
  144. current += ch
  145. i++
  146. }
  147. if (current) segments.push({ text: current, isString: !!inString })
  148. return segments
  149. .map((seg) => {
  150. if (seg.isString) return seg.text
  151. let result = seg.text
  152. for (const name of exportedNames) {
  153. const pattern = `${nsName}.${name}`
  154. while (result.includes(pattern)) {
  155. const idx = result.indexOf(pattern)
  156. const charBefore = idx > 0 ? result[idx - 1] : " "
  157. const charAfter = idx + pattern.length < result.length ? result[idx + pattern.length] : " "
  158. if (/\w/.test(charBefore) || /\w/.test(charAfter)) break
  159. result = result.slice(0, idx) + name + result.slice(idx + pattern.length)
  160. selfRefCount++
  161. }
  162. }
  163. return result
  164. })
  165. .join("")
  166. })
  167. newContent = fixedLines.join("\n")
  168. }
  169. // Figure out file naming
  170. const dir = path.dirname(absPath)
  171. const basename = path.basename(absPath, ".ts")
  172. const isIndex = basename === "index"
  173. const implName = nameFlag ?? (isIndex ? nsName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase() : basename)
  174. const implFile = path.join(dir, `${implName}.ts`)
  175. const indexFile = path.join(dir, "index.ts")
  176. const barrelLine = `export * as ${nsName} from "./${implName}"\n`
  177. console.log("")
  178. if (isIndex) {
  179. console.log(`Plan: rename ${basename}.ts → ${implName}.ts, create new index.ts barrel`)
  180. } else {
  181. console.log(`Plan: rewrite ${basename}.ts in place, create index.ts barrel`)
  182. }
  183. if (selfRefCount > 0) console.log(`Fixed ${selfRefCount} self-reference(s) (${nsName}.X → X)`)
  184. console.log("")
  185. if (dryRun) {
  186. console.log("--- DRY RUN ---")
  187. console.log("")
  188. console.log(`=== ${implName}.ts (first 30 lines) ===`)
  189. newContent
  190. .split("\n")
  191. .slice(0, 30)
  192. .forEach((l, i) => console.log(` ${i + 1}: ${l}`))
  193. console.log(" ...")
  194. console.log("")
  195. console.log(`=== index.ts ===`)
  196. console.log(` ${barrelLine.trim()}`)
  197. console.log("")
  198. if (!isIndex) {
  199. const relDir = path.relative(path.resolve("src"), dir)
  200. console.log(`=== Import rewrites (would apply) ===`)
  201. console.log(` ${relDir}/${basename}" → ${relDir}" across src/, test/, script/`)
  202. } else {
  203. console.log("No import rewrites needed (was index.ts)")
  204. }
  205. } else {
  206. if (isIndex) {
  207. fs.writeFileSync(implFile, newContent)
  208. fs.writeFileSync(indexFile, barrelLine)
  209. console.log(`Wrote ${implName}.ts (${newContent.split("\n").length} lines)`)
  210. console.log(`Wrote index.ts (barrel)`)
  211. } else {
  212. fs.writeFileSync(absPath, newContent)
  213. if (fs.existsSync(indexFile)) {
  214. const existing = fs.readFileSync(indexFile, "utf-8")
  215. if (!existing.includes(`export * as ${nsName}`)) {
  216. fs.appendFileSync(indexFile, barrelLine)
  217. console.log(`Appended to existing index.ts`)
  218. } else {
  219. console.log(`index.ts already has ${nsName} export`)
  220. }
  221. } else {
  222. fs.writeFileSync(indexFile, barrelLine)
  223. console.log(`Wrote index.ts (barrel)`)
  224. }
  225. console.log(`Rewrote ${basename}.ts (${newContent.split("\n").length} lines)`)
  226. }
  227. // --- Rewrite import paths across src/, test/, script/ ---
  228. const relDir = path.relative(path.resolve("src"), dir)
  229. if (!isIndex) {
  230. const oldTail = `${relDir}/${basename}`
  231. const searchDirs = ["src", "test", "script"].filter((d) => fs.existsSync(d))
  232. const rgResult = Bun.spawnSync(["rg", "-l", `from.*${oldTail}"`, ...searchDirs], {
  233. stdout: "pipe",
  234. stderr: "pipe",
  235. })
  236. const filesToRewrite = rgResult.stdout
  237. .toString()
  238. .trim()
  239. .split("\n")
  240. .filter((f) => f.length > 0)
  241. if (filesToRewrite.length > 0) {
  242. console.log(`\nRewriting imports in ${filesToRewrite.length} file(s)...`)
  243. for (const file of filesToRewrite) {
  244. const content = fs.readFileSync(file, "utf-8")
  245. fs.writeFileSync(file, content.replaceAll(`${oldTail}"`, `${relDir}"`))
  246. }
  247. console.log(` Done: ${oldTail}" → ${relDir}"`)
  248. } else {
  249. console.log("\nNo import rewrites needed")
  250. }
  251. } else {
  252. console.log("\nNo import rewrites needed (was index.ts)")
  253. }
  254. // --- Fix sibling imports within the same directory ---
  255. const siblingFiles = fs.readdirSync(dir).filter((f) => {
  256. if (!f.endsWith(".ts")) return false
  257. if (f === "index.ts" || f === `${implName}.ts`) return false
  258. return true
  259. })
  260. let siblingFixCount = 0
  261. for (const sibFile of siblingFiles) {
  262. const sibPath = path.join(dir, sibFile)
  263. const content = fs.readFileSync(sibPath, "utf-8")
  264. const pattern = new RegExp(`from\\s+["']\\./${basename}["']`, "g")
  265. if (pattern.test(content)) {
  266. fs.writeFileSync(sibPath, content.replace(pattern, `from "."`))
  267. siblingFixCount++
  268. }
  269. }
  270. if (siblingFixCount > 0) {
  271. console.log(`Fixed ${siblingFixCount} sibling import(s) in ${path.basename(dir)}/ (./${basename} → .)`)
  272. }
  273. }
  274. console.log("")
  275. console.log("=== Verify ===")
  276. console.log("")
  277. console.log("bunx --bun tsgo --noEmit # typecheck")
  278. console.log("bun run test # run tests")