cloudaxe-opencode/packages/opencode/script/unwrap-namespace.ts

447 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 <file> [--dry-run] [--name <impl>] [--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<string, string> {
const map: Record<string, string> = {}
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<string, { text: string }>; multi: Record<string, Array<{ text: string }>> }
}>
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<string>()
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
}