Compare commits

...
Sign in to create a new pull request.

2 commits

Author SHA1 Message Date
Kit Langton
2bf5407657 refactor: drop namespace proof-of-concept code changes 2026-04-16 11:18:33 -04:00
Kit Langton
95f90ebfc8 refactor: migrate namespace tooling to self-reexport pattern 2026-04-16 11:17:10 -04:00
2 changed files with 356 additions and 552 deletions

View file

@ -1,21 +1,26 @@
#!/usr/bin/env bun #!/usr/bin/env bun
/** /**
* Unwrap a TypeScript `export namespace` into flat exports + barrel. * Unwrap a TypeScript `export namespace` into flat exports with self-reexport.
* *
* Usage: * Usage:
* bun script/unwrap-namespace.ts src/bus/index.ts * bun script/unwrap-namespace.ts src/session/session.ts # convert namespace
* bun script/unwrap-namespace.ts src/bus/index.ts --dry-run * bun script/unwrap-namespace.ts src/session/session.ts --dry-run
* bun script/unwrap-namespace.ts src/pty/index.ts --name service # avoid collision with pty.ts * 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
* *
* What it does: * Default mode:
* 1. Reads the file and finds the `export namespace Foo { ... }` block * 1. Finds `export namespace Foo { ... }` (ast-grep)
* (uses ast-grep for accurate AST-based boundary detection) * 2. Removes wrapper, dedents body, fixes self-references
* 2. Removes the namespace wrapper and dedents the body * 3. Appends `export * as Foo from "./file"` to the file (self-reexport)
* 3. Fixes self-references (e.g. Config.PermissionAction PermissionAction) * 4. Rewrites consumer imports to point at the file directly
* 4. If the file is index.ts, renames it to <lowercase-name>.ts *
* 5. Creates/updates index.ts with `export * as Foo from "./<file>"` * Retrofit mode (--retrofit):
* 6. Rewrites import paths across src/, test/, and script/ * File already has flat exports (from previous barrel migration).
* 7. Fixes sibling imports within the same directory * 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`) * Requires: ast-grep (`brew install ast-grep` or `cargo install ast-grep`)
*/ */
@ -25,11 +30,12 @@ import fs from "fs"
const args = process.argv.slice(2) const args = process.argv.slice(2)
const dryRun = args.includes("--dry-run") const dryRun = args.includes("--dry-run")
const retrofit = args.includes("--retrofit")
const nameFlag = args.find((a, i) => args[i - 1] === "--name") const nameFlag = args.find((a, i) => args[i - 1] === "--name")
const filePath = args.find((a) => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--name") const filePath = args.find((a) => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--name")
if (!filePath) { if (!filePath) {
console.error("Usage: bun script/unwrap-namespace.ts <file> [--dry-run] [--name <impl-name>]") console.error("Usage: bun script/unwrap-namespace.ts <file> [--dry-run] [--name <impl>] [--retrofit]")
process.exit(1) process.exit(1)
} }
@ -39,11 +45,76 @@ if (!fs.existsSync(absPath)) {
process.exit(1) 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 src = fs.readFileSync(absPath, "utf-8")
const lines = src.split("\n") const lines = src.split("\n")
// Use ast-grep to find the namespace boundaries accurately.
// This avoids false matches from braces in strings, templates, comments, etc.
const astResult = Bun.spawnSync( const astResult = Bun.spawnSync(
["ast-grep", "run", "--pattern", "export namespace $NAME { $$$BODY }", "--lang", "typescript", "--json", absPath], ["ast-grep", "run", "--pattern", "export namespace $NAME { $$$BODY }", "--lang", "typescript", "--json", absPath],
{ stdout: "pipe", stderr: "pipe" }, { stdout: "pipe", stderr: "pipe" },
@ -61,34 +132,29 @@ const matches = JSON.parse(astResult.stdout.toString()) as Array<{
}> }>
if (matches.length === 0) { if (matches.length === 0) {
console.error("No `export namespace Foo { ... }` found in file") console.error("No `export namespace Foo { ... }` found. Use --retrofit for already-converted files.")
process.exit(1) process.exit(1)
} }
if (matches.length > 1) { if (matches.length > 1) {
console.error(`Found ${matches.length} namespaces — this script handles one at a time`) console.error(`Found ${matches.length} namespaces — this script handles one at a time`)
console.error("Namespaces found:")
for (const m of matches) console.error(` ${m.metaVariables.single.NAME.text} (line ${m.range.start.line + 1})`) for (const m of matches) console.error(` ${m.metaVariables.single.NAME.text} (line ${m.range.start.line + 1})`)
process.exit(1) process.exit(1)
} }
const match = matches[0] const match = matches[0]
const nsName = match.metaVariables.single.NAME.text const nsName = match.metaVariables.single.NAME.text
const nsLine = match.range.start.line // 0-indexed const nsLine = match.range.start.line
const closeLine = match.range.end.line // 0-indexed, the line with closing `}` const closeLine = match.range.end.line
console.log(`Found: export namespace ${nsName} { ... }`) console.log(`Found: export namespace ${nsName} { ... }`)
console.log(` Lines ${nsLine + 1}${closeLine + 1} (${closeLine - nsLine + 1} lines)`) console.log(` Lines ${nsLine + 1}${closeLine + 1} (${closeLine - nsLine + 1} lines)`)
// Build the new file content: // Unwrap: remove namespace wrapper, dedent body
// 1. Everything before the namespace declaration (imports, etc.)
// 2. The namespace body, dedented by one level (2 spaces)
// 3. Everything after the closing brace (rare, but possible)
const before = lines.slice(0, nsLine) const before = lines.slice(0, nsLine)
const body = lines.slice(nsLine + 1, closeLine) const body = lines.slice(nsLine + 1, closeLine)
const after = lines.slice(closeLine + 1) const after = lines.slice(closeLine + 1)
// Dedent: remove exactly 2 leading spaces from each line
const dedented = body.map((line) => { const dedented = body.map((line) => {
if (line === "") return "" if (line === "") return ""
if (line.startsWith(" ")) return line.slice(2) if (line.startsWith(" ")) return line.slice(2)
@ -97,9 +163,7 @@ const dedented = body.map((line) => {
let newContent = [...before, ...dedented, ...after].join("\n") let newContent = [...before, ...dedented, ...after].join("\n")
// --- Fix self-references --- // Fix self-references (Foo.Bar → Bar when Bar is exported from this file)
// After unwrapping, references like `Config.PermissionAction` inside the same file
// need to become just `PermissionAction`. Only fix code positions, not strings.
const exportedNames = new Set<string>() const exportedNames = new Set<string>()
const exportRegex = /export\s+(?:const|function|class|interface|type|enum|abstract\s+class)\s+(\w+)/g const exportRegex = /export\s+(?:const|function|class|interface|type|enum|abstract\s+class)\s+(\w+)/g
for (const line of dedented) { for (const line of dedented) {
@ -122,7 +186,6 @@ for (const line of dedented) {
let selfRefCount = 0 let selfRefCount = 0
if (exportedNames.size > 0) { if (exportedNames.size > 0) {
const fixedLines = newContent.split("\n").map((line) => { const fixedLines = newContent.split("\n").map((line) => {
// Split line into string-literal and code segments to avoid replacing inside strings
const segments: Array<{ text: string; isString: boolean }> = [] const segments: Array<{ text: string; isString: boolean }> = []
let i = 0 let i = 0
let current = "" let current = ""
@ -186,120 +249,199 @@ if (exportedNames.size > 0) {
newContent = fixedLines.join("\n") newContent = fixedLines.join("\n")
} }
// Figure out file naming // Handle index.ts rename
const dir = path.dirname(absPath)
const basename = path.basename(absPath, ".ts")
const isIndex = basename === "index" const isIndex = basename === "index"
const implName = nameFlag ?? (isIndex ? nsName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase() : basename) const implName = nameFlag ?? (isIndex ? nsName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase() : basename)
const implFile = path.join(dir, `${implName}.ts`) const implFile = isIndex ? path.join(dir, `${implName}.ts`) : absPath
const indexFile = path.join(dir, "index.ts")
const barrelLine = `export * as ${nsName} from "./${implName}"\n` // Add self-reexport at the bottom
const selfReexport = `export * as ${nsName} from "./${implName}"`
if (!newContent.endsWith("\n")) newContent += "\n"
newContent += selfReexport + "\n"
console.log("") console.log("")
if (isIndex) { if (isIndex) {
console.log(`Plan: rename ${basename}.ts → ${implName}.ts, create new index.ts barrel`) console.log(`Plan: rename index.ts → ${implName}.ts, add self-reexport`)
} else { } else {
console.log(`Plan: rewrite ${basename}.ts in place, create index.ts barrel`) console.log(`Plan: unwrap in place, add self-reexport`)
} }
if (selfRefCount > 0) console.log(`Fixed ${selfRefCount} self-reference(s) (${nsName}.X → X)`) if (selfRefCount > 0) console.log(`Fixed ${selfRefCount} self-reference(s) (${nsName}.X → X)`)
console.log("")
if (dryRun) { if (dryRun) {
console.log("")
console.log("--- DRY RUN ---") console.log("--- DRY RUN ---")
console.log("") console.log("")
console.log(`=== ${implName}.ts (first 30 lines) ===`) console.log(`=== ${implName}.ts (first 20 lines) ===`)
newContent newContent
.split("\n") .split("\n")
.slice(0, 30) .slice(0, 20)
.forEach((l, i) => console.log(` ${i + 1}: ${l}`)) .forEach((l, i) => console.log(` ${i + 1}: ${l}`))
console.log(" ...") console.log(" ...")
console.log("") console.log("")
console.log(`=== index.ts ===`) console.log(`=== last 5 lines ===`)
console.log(` ${barrelLine.trim()}`) const allLines = newContent.split("\n")
allLines.slice(-5).forEach((l, i) => console.log(` ${allLines.length - 4 + i}: ${l}`))
console.log("") console.log("")
if (!isIndex) { rewriteConsumers(nsName, implFile, implName, dir)
const relDir = path.relative(path.resolve("src"), dir)
console.log(`=== Import rewrites (would apply) ===`)
console.log(` ${relDir}/${basename}" → ${relDir}" across src/, test/, script/`)
} else {
console.log("No import rewrites needed (was index.ts)")
}
} else { } else {
if (isIndex) { if (isIndex) {
fs.writeFileSync(implFile, newContent) fs.writeFileSync(implFile, newContent)
fs.writeFileSync(indexFile, barrelLine) fs.unlinkSync(absPath)
console.log(`Wrote ${implName}.ts (${newContent.split("\n").length} lines)`) console.log(`Renamed to ${implName}.ts (${newContent.split("\n").length} lines)`)
console.log(`Wrote index.ts (barrel)`)
} else { } else {
fs.writeFileSync(absPath, newContent) fs.writeFileSync(absPath, newContent)
if (fs.existsSync(indexFile)) {
const existing = fs.readFileSync(indexFile, "utf-8")
if (!existing.includes(`export * as ${nsName}`)) {
fs.appendFileSync(indexFile, barrelLine)
console.log(`Appended to existing index.ts`)
} else {
console.log(`index.ts already has ${nsName} export`)
}
} else {
fs.writeFileSync(indexFile, barrelLine)
console.log(`Wrote index.ts (barrel)`)
}
console.log(`Rewrote ${basename}.ts (${newContent.split("\n").length} lines)`) console.log(`Rewrote ${basename}.ts (${newContent.split("\n").length} lines)`)
} }
rewriteConsumers(nsName, implFile, implName, dir)
// --- Rewrite import paths across src/, test/, script/ ---
const relDir = path.relative(path.resolve("src"), dir)
if (!isIndex) {
const oldTail = `${relDir}/${basename}`
const searchDirs = ["src", "test", "script"].filter((d) => fs.existsSync(d))
const rgResult = Bun.spawnSync(["rg", "-l", `from.*${oldTail}"`, ...searchDirs], {
stdout: "pipe",
stderr: "pipe",
})
const filesToRewrite = rgResult.stdout
.toString()
.trim()
.split("\n")
.filter((f) => f.length > 0)
if (filesToRewrite.length > 0) {
console.log(`\nRewriting imports in ${filesToRewrite.length} file(s)...`)
for (const file of filesToRewrite) {
const content = fs.readFileSync(file, "utf-8")
fs.writeFileSync(file, content.replaceAll(`${oldTail}"`, `${relDir}"`))
}
console.log(` Done: ${oldTail}" → ${relDir}"`)
} else {
console.log("\nNo import rewrites needed")
}
} else {
console.log("\nNo import rewrites needed (was index.ts)")
}
// --- Fix sibling imports within the same directory ---
const siblingFiles = fs.readdirSync(dir).filter((f) => {
if (!f.endsWith(".ts")) return false
if (f === "index.ts" || f === `${implName}.ts`) return false
return true
})
let siblingFixCount = 0
for (const sibFile of siblingFiles) {
const sibPath = path.join(dir, sibFile)
const content = fs.readFileSync(sibPath, "utf-8")
const pattern = new RegExp(`from\\s+["']\\./${basename}["']`, "g")
if (pattern.test(content)) {
fs.writeFileSync(sibPath, content.replace(pattern, `from "."`))
siblingFixCount++
}
}
if (siblingFixCount > 0) {
console.log(`Fixed ${siblingFixCount} sibling import(s) in ${path.basename(dir)}/ (./${basename} → .)`)
}
} }
console.log("") // ---------------------------------------------------------------------------
console.log("=== Verify ===") // Consumer import rewriting (shared by default + retrofit mode)
console.log("") // ---------------------------------------------------------------------------
console.log("bunx --bun tsgo --noEmit # typecheck")
console.log("bun run test # run tests") 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
}

View file

@ -1,499 +1,161 @@
# Namespace → flat export migration # Namespace → self-reexport migration
Migrate `export namespace` to the `export * as` / flat-export pattern used by Migrate `export namespace` to flat module exports with a self-referential
effect-smol. Primary goal: tree-shakeability. Secondary: consistency with Effect `export * as` at the bottom of each file. No barrel files.
conventions, LLM-friendliness for future migrations.
## What changes and what doesn't ## The pattern
The **consumer API stays the same**. You still write `Provider.ModelNotFoundError`, Each module file has flat exports plus one line at the bottom that re-exports
`Config.JsonError`, `Bus.publish`, etc. The namespace ergonomics are preserved. itself as a namespace:
What changes is **how** the namespace is constructed — the TypeScript
`export namespace` keyword is replaced by `export * as` in a barrel file. This
is a mechanical change: unwrap the namespace body into flat exports, add a
one-line barrel. Consumers that import `{ Provider }` don't notice.
Import paths actually get **nicer**. Today most consumers import from the
explicit file (`"../provider/provider"`). After the migration, each module has a
barrel `index.ts`, so imports become `"../provider"` or `"@/provider"`:
```ts ```ts
// BEFORE — points at the file directly // config/config.ts
import { Provider } from "../provider/provider" import { Log } from "../util/log"
// AFTER — resolves to provider/index.ts, same Provider namespace export interface Info { model: string }
import { Provider } from "../provider" export function load(): Info { ... }
export const JsonError = NamedError.create(...)
// Self-reexport: creates a named `Config` export that consumers can import
export * as Config from "./config"
``` ```
## Why this matters right now Consumers import the namespace by name — editors auto-import this like any
named export:
The CLI binary startup time (TOI) is too slow. Profiling shows we're loading
massive dependency graphs that are never actually used at runtime — because
bundlers cannot tree-shake TypeScript `export namespace` bodies.
### The problem in one sentence
`cli/error.ts` needs 6 lightweight `.isInstance()` checks on error classes, but
importing `{ Provider }` from `provider.ts` forces the bundler to include **all
20+ `@ai-sdk/*` packages**, `@aws-sdk/credential-providers`,
`google-auth-library`, and every other top-level import in that 1709-line file.
### Why `export namespace` defeats tree-shaking
TypeScript compiles `export namespace Foo { ... }` to an IIFE:
```js
// TypeScript output
export var Provider;
(function (Provider) {
Provider.ModelNotFoundError = NamedError.create(...)
// ... 1600 more lines of assignments ...
})(Provider || (Provider = {}))
```
This is **opaque to static analysis**. The bundler sees one big function call
whose return value populates an object. It cannot determine which properties are
used downstream, so it keeps everything. Every `import` statement at the top of
`provider.ts` executes unconditionally — that's 20+ AI SDK packages loaded into
memory just so the CLI can check `Provider.ModelNotFoundError.isInstance(x)`.
### What `export * as` does differently
`export * as Provider from "./provider"` compiles to a static re-export. The
bundler knows the exact shape of `Provider` at compile time — it's the named
export list of `./provider.ts`. When it sees `Provider.ModelNotFoundError` used
but `Provider.layer` unused, it can trace that `ModelNotFoundError` doesn't
reference `createAnthropic` or any AI SDK import, and drop them. The namespace
object still exists at runtime — same API — but the bundler can see inside it.
### Concrete impact
The worst import chain in the codebase:
```
src/index.ts (entry point)
└── FormatError from src/cli/error.ts
├── { Provider } from provider/provider.ts (1709 lines)
│ ├── 20+ @ai-sdk/* packages
│ ├── @aws-sdk/credential-providers
│ ├── google-auth-library
│ ├── gitlab-ai-provider, venice-ai-sdk-provider
│ └── fuzzysort, remeda, etc.
├── { Config } from config/config.ts (1663 lines)
│ ├── jsonc-parser
│ ├── LSPServer (all server definitions)
│ └── Plugin, Auth, Env, Account, etc.
└── { MCP } from mcp/index.ts (930 lines)
├── @modelcontextprotocol/sdk (3 transports)
└── open (browser launcher)
```
All of this gets pulled in to check `.isInstance()` on 6 error classes — code
that needs maybe 200 bytes total. This inflates the binary, increases startup
memory, and slows down initial module evaluation.
### Why this also hurts memory
Every module-level import is eagerly evaluated. Even with Bun's fast module
loader, evaluating 20+ AI SDK factory functions, the AWS credential chain, and
Google's auth library allocates objects, closures, and prototype chains that
persist for the lifetime of the process. Most CLI commands never use a provider
at all.
## What effect-smol does
effect-smol achieves tree-shakeable namespaced APIs via three structural choices.
### 1. Each module is a separate file with flat named exports
```ts ```ts
// Effect.ts — no namespace wrapper, just flat exports import { Config } from "../config/config"
export const gen: { ... } = internal.gen Config.load()
export const fail: <E>(error: E) => Effect<never, E> = internal.fail Config.JsonError.isInstance(x)
export const succeed: <A>(value: A) => Effect<A> = internal.succeed
// ... 230+ individual named exports
``` ```
### 2. Barrel file uses `export * as` (not `export namespace`) ## Why this pattern
```ts We tested every option with Bun. Three things matter: tree-shaking, circular
// index.ts imports, and editor autocomplete.
export * as Effect from "./Effect.ts"
export * as Schema from "./Schema.ts"
export * as Stream from "./Stream.ts"
// ~134 modules
```
This creates a namespace-like API (`Effect.gen`, `Schema.parse`) but the
bundler knows the **exact shape** at compile time — it's the static export list
of that file. It can trace property accesses (`Effect.gen` → keep `gen`,
drop `timeout` if unused). With `export namespace`, the IIFE is opaque and
nothing can be dropped.
### 3. `sideEffects: []` and deep imports
```jsonc
// package.json
{ "sideEffects": [] }
```
Plus `"./*": "./src/*.ts"` in the exports map, enabling
`import * as Effect from "effect/Effect"` to bypass the barrel entirely.
### 4. Errors as flat exports, not class declarations
```ts
// Cause.ts
export const NoSuchElementErrorTypeId = core.NoSuchElementErrorTypeId
export interface NoSuchElementError extends YieldableError { ... }
export const NoSuchElementError: new(msg?: string) => NoSuchElementError = core.NoSuchElementError
export const isNoSuchElementError: (u: unknown) => u is NoSuchElementError = core.isNoSuchElementError
```
Each error is 4 independent exports: TypeId, interface, constructor (as const),
type guard. All individually shakeable.
## The plan
The core migration is **Phase 1** — convert `export namespace` to
`export * as`. Once that's done, the bundler can tree-shake individual exports
within each module. You do NOT need to break things into subfiles for
tree-shaking to work — the bundler traces which exports you actually access on
the namespace object and drops the rest, including their transitive imports.
Splitting errors/schemas into separate files (Phase 0) is optional — it's a
lower-risk warmup step that can be done before or after the main conversion, and
it provides extra resilience against bundler edge cases. But the big win comes
from Phase 1.
### Phase 0 (optional): Pre-split errors into subfiles
This is a low-risk warmup that provides immediate benefit even before the full
`export * as` conversion. It's optional because Phase 1 alone is sufficient for
tree-shaking. But it's a good starting point if you want incremental progress:
**For each namespace that defines errors** (15 files, ~30 error classes total):
1. Create a sibling `errors.ts` file (e.g. `provider/errors.ts`) with the error
definitions as top-level named exports:
```ts
// provider/errors.ts
import z from "zod"
import { NamedError } from "@opencode-ai/shared/util/error"
import { ProviderID, ModelID } from "./schema"
export const ModelNotFoundError = NamedError.create(
"ProviderModelNotFoundError",
z.object({
providerID: ProviderID.zod,
modelID: ModelID.zod,
suggestions: z.array(z.string()).optional(),
}),
)
export const InitError = NamedError.create("ProviderInitError", z.object({ providerID: ProviderID.zod }))
```
2. In the namespace file, re-export from the errors file to maintain backward
compatibility:
```ts
// provider/provider.ts — inside the namespace
export { ModelNotFoundError, InitError } from "./errors"
```
3. Update `cli/error.ts` (and any other light consumers) to import directly:
```ts
// BEFORE
import { Provider } from "../provider/provider"
Provider.ModelNotFoundError.isInstance(input)
// AFTER
import { ModelNotFoundError as ProviderModelNotFoundError } from "../provider/errors"
ProviderModelNotFoundError.isInstance(input)
```
**Files to split (Phase 0):**
| Current file | New errors file | Errors to extract |
| ----------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `provider/provider.ts` | `provider/errors.ts` | ModelNotFoundError, InitError |
| `provider/auth.ts` | `provider/auth-errors.ts` | OauthMissing, OauthCodeMissing, OauthCallbackFailed, ValidationFailed |
| `config/config.ts` | (already has `config/paths.ts`) | ConfigDirectoryTypoError → move to paths.ts |
| `config/markdown.ts` | `config/markdown-errors.ts` | FrontmatterError |
| `mcp/index.ts` | `mcp/errors.ts` | Failed |
| `session/message-v2.ts` | `session/message-errors.ts` | OutputLengthError, AbortedError, StructuredOutputError, AuthError, APIError, ContextOverflowError |
| `session/message.ts` | (shares with message-v2) | OutputLengthError, AuthError |
| `cli/ui.ts` | `cli/ui-errors.ts` | CancelledError |
| `skill/index.ts` | `skill/errors.ts` | InvalidError, NameMismatchError |
| `worktree/index.ts` | `worktree/errors.ts` | NotGitError, NameGenerationFailedError, CreateFailedError, StartCommandFailedError, RemoveFailedError, ResetFailedError |
| `storage/storage.ts` | `storage/errors.ts` | NotFoundError |
| `npm/index.ts` | `npm/errors.ts` | InstallFailedError |
| `ide/index.ts` | `ide/errors.ts` | AlreadyInstalledError, InstallFailedError |
| `lsp/client.ts` | `lsp/errors.ts` | InitializeError |
### Phase 1: The real migration — `export namespace``export * as`
This is the phase that actually fixes tree-shaking. For each module:
1. **Unwrap** the `export namespace Foo { ... }` — remove the namespace wrapper,
keep all the members as top-level `export const` / `export function` / etc.
2. **Rename** the file if it's currently `index.ts` (e.g. `bus/index.ts`
`bus/bus.ts`), so the barrel can take `index.ts`.
3. **Create the barrel** `index.ts` with one line: `export * as Foo from "./foo"`
The file structure change for a module that's currently a single file:
``` ```
# BEFORE A. Barrel (export * as Foo + Bar from index.ts)
provider/ Runtime: foo LOADED even though only Bar used ❌
provider.ts ← 1709-line file with `export namespace Provider { ... }` Bundled: foo LOADED if it has side effects ❌
Autocomplete: works (named export from barrel)
# AFTER B. import * as Bar from "./bar" (direct, no barrel)
provider/ Runtime: only bar loaded ✅
index.ts ← NEW: `export * as Provider from "./provider"` Bundled: only bar loaded ✅
provider.ts ← SAME file, same name, just unwrap the namespace Autocomplete: broken (editors can't auto-import) ❌
C. Self-reexport: export * as Bar from "./bar" inside bar.ts
Runtime: only bar loaded ✅
Bundled: only bar loaded ✅
Autocomplete: works (named export from file) ✅
``` ```
And the code change is purely removing the wrapper: The self-reexport gives us tree-shaking + autocomplete + no barrels.
```ts ### Bundle overhead
// BEFORE: provider/provider.ts
export namespace Provider {
export class Service extends Context.Service<...>()("@opencode/Provider") {}
export const layer = Layer.effect(Service, ...)
export const ModelNotFoundError = NamedError.create(...)
export function parseModel(model: string) { ... }
}
// AFTER: provider/provider.ts — identical exports, no namespace keyword The self-reexport adds ~240 bytes per module (an `Object.defineProperty`
export class Service extends Context.Service<...>()("@opencode/Provider") {} wrapper). At 100 modules that's ~24KB — irrelevant for a CLI binary.
export const layer = Layer.effect(Service, ...)
export const ModelNotFoundError = NamedError.create(...)
export function parseModel(model: string) { ... }
```
```ts ### The `Foo.Foo.Foo` thing
// NEW: provider/index.ts
export * as Provider from "./provider"
```
Consumer code barely changes — import path gets shorter: `Config.Config.Config.load()` compiles and runs. It's a harmless side effect
of self-referential modules. Nobody would write it.
```ts ## Why barrel files don't work
// BEFORE
import { Provider } from "../provider/provider"
// AFTER — resolves to provider/index.ts, same Provider object Barrel files (`index.ts` with `export * as`) have two problems:
import { Provider } from "../provider"
```
All access like `Provider.ModelNotFoundError`, `Provider.Service`, 1. **Bun loads all re-exported modules** when you import through a barrel,
`Provider.layer` works exactly as before. The difference is invisible to even if you only use one. This happens at both runtime and bundle time
consumers but lets the bundler see inside the namespace. for modules with side effects (which ours have — top-level imports).
**Once this is done, you don't need to break anything into subfiles for 2. **Circular import risk.** Sibling files can't import through their own
tree-shaking.** The bundler traces that `Provider.ModelNotFoundError` only barrel, and cross-directory barrel cycles cause runtime `ReferenceError`.
depends on `NamedError` + `zod` + the schema file, and drops
`Provider.layer` + all 20 AI SDK imports when they're unused. This works because
`export * as` gives the bundler a static export list it can do inner-graph
analysis on — it knows which exports reference which imports.
**Order of conversion** (by risk / size, do small modules first): ## The migration
1. Tiny utilities: `Archive`, `Color`, `Token`, `Rpc`, `LocalContext` (~7-66 lines each) There are two tasks:
2. Small services: `Auth`, `Env`, `BusEvent`, `SessionStatus`, `SessionRunState`, `Editor`, `Selection` (~25-91 lines)
3. Medium services: `Bus`, `Format`, `FileTime`, `FileWatcher`, `Command`, `Question`, `Permission`, `Vcs`, `Project`
4. Large services: `Config`, `Provider`, `MCP`, `Session`, `SessionProcessor`, `SessionPrompt`, `ACP`
### Phase 2: Build configuration ### Task 1: Convert remaining `export namespace` files (~50)
After the module structure supports tree-shaking: For each file:
1. Add `"sideEffects": []` to `packages/opencode/package.json` (or 1. Remove the `export namespace Foo {` wrapper and closing `}`
`"sideEffects": false`) — this is safe because our services use explicit 2. Dedent the body
layer composition, not import-time side effects. 3. Add `export * as Foo from "./file"` at the bottom
2. Verify Bun's bundler respects the new structure. If Bun's tree-shaking is 4. Rewrite consumer imports: `import { Foo } from "..."` stays the same
insufficient, evaluate whether the compiled binary path needs an esbuild if the path already points at the file. If it points at a barrel,
pre-pass. change it to point at the file directly.
3. Consider adding `/*#__PURE__*/` annotations to `NamedError.create(...)` calls
— these are factory functions that return classes, and bundlers may not know
they're side-effect-free without the annotation.
## Automation ### Task 2: Fix already-converted files (~32 barrel dirs)
The transformation is scripted. From `packages/opencode`: These were converted in the earlier barrel-based migration. Each directory
has an `index.ts` barrel and flat-exported source files. To migrate:
1. Add `export * as Foo from "./file"` to the bottom of each source file
2. Change consumers from `import { Foo } from "../dir"` (barrel) to
`import { Foo } from "../dir/file"` (direct)
3. The barrel `index.ts` can be deleted or left in place (harmless once
nothing imports through it)
### Automation
```bash ```bash
bun script/unwrap-namespace.ts <file> [--dry-run] # Convert an unconverted namespace file:
bun script/unwrap-namespace.ts src/session/session.ts --dry-run
bun script/unwrap-namespace.ts src/session/session.ts
# Retrofit an already-converted file (add self-reexport + fix consumers):
bun script/unwrap-namespace.ts src/config/config.ts --retrofit --dry-run
bun script/unwrap-namespace.ts src/config/config.ts --retrofit
``` ```
The script uses ast-grep for accurate AST-based namespace boundary detection The script handles both cases:
(no false matches from braces in strings/templates/comments), then:
1. Removes the `export namespace Foo {` line and its closing `}` - **Default mode**: unwraps namespace + adds self-reexport + rewrites imports
2. Dedents the body by one indent level (2 spaces) - **Retrofit mode** (`--retrofit`): file already has flat exports, just adds
3. If the file is `index.ts`, renames it to `<name>.ts` and creates a new the self-reexport line and rewrites consumers from barrel to direct
`index.ts` barrel
4. If the file is NOT `index.ts`, rewrites it in place and creates `index.ts`
5. Prints the exact commands to find and rewrite import paths
### Walkthrough: converting a module ### Verification
Using `Provider` as an example: After any conversion:
```bash ```bash
# 1. Preview what will change bunx --bun tsgo --noEmit # typecheck
bun script/unwrap-namespace.ts src/provider/provider.ts --dry-run bun run --conditions=browser ./src/index.ts generate # circular import check
# 2. Apply the transformation
bun script/unwrap-namespace.ts src/provider/provider.ts
# 3. Rewrite import paths (script prints the exact command)
rg -l 'from.*provider/provider' src/ | xargs sed -i '' 's|provider/provider"|provider"|g'
# 4. Verify
bun typecheck
bun run test
``` ```
**What changes on disk:**
```
# BEFORE
provider/
provider.ts ← 1709 lines, `export namespace Provider { ... }`
# AFTER
provider/
index.ts ← NEW: `export * as Provider from "./provider"`
provider.ts ← same file, namespace unwrapped to flat exports
```
**What changes in consumer code:**
```ts
// BEFORE
import { Provider } from "../provider/provider"
// AFTER — shorter path, same Provider object
import { Provider } from "../provider"
```
All property access (`Provider.Service`, `Provider.ModelNotFoundError`, etc.)
stays identical.
### Two cases the script handles
**Case A: file is NOT `index.ts`** (e.g. `provider/provider.ts`)
- Rewrites the file in place (unwrap + dedent)
- Creates `provider/index.ts` as the barrel
- Import paths change: `"../provider/provider"``"../provider"`
**Case B: file IS `index.ts`** (e.g. `bus/index.ts`)
- Renames `index.ts``bus.ts` (kebab-case of namespace name)
- Creates new `index.ts` as the barrel
- **No import rewrites needed**`"@/bus"` already resolves to `bus/index.ts`
## Do I need to split errors/schemas into subfiles?
**No.** Once you do the `export * as` conversion, the bundler can tree-shake
individual exports within the file. If `cli/error.ts` only accesses
`Provider.ModelNotFoundError`, the bundler traces that `ModelNotFoundError`
doesn't reference `createAnthropic` and drops the AI SDK imports.
Splitting into subfiles (errors.ts, schema.ts) is still a fine idea for **code
organization** — smaller files are easier to read and review. But it's not
required for tree-shaking. The `export * as` conversion alone is sufficient.
The one case where subfile splitting provides extra tree-shake value is if an
imported package has module-level side effects that the bundler can't prove are
unused. In practice this is rare — most npm packages are side-effect-free — and
adding `"sideEffects": []` to package.json handles the common cases.
## Scope
| Metric | Count |
| ----------------------------------------------- | --------------- |
| Files with `export namespace` | 106 |
| Total namespace declarations | 118 (12 nested) |
| Files with `NamedError.create` inside namespace | 15 |
| Total error classes to extract | ~30 |
| Files using `export * as` today | 0 |
Phase 1 (the `export * as` conversion) is the main change. It's mechanical and
LLM-friendly but touches every import site, so it should be done module by
module with type-checking between each step. Each module is an independent PR.
## Rules for new code ## Rules for new code
Going forward: - **No `export namespace`.** Use flat named exports.
- **No barrel `index.ts` for internal code.**
- **Every module file gets a self-reexport** at the bottom:
`export * as Foo from "./foo"`
- **Consumers import the namespace by name:**
`import { Foo } from "../path/to/foo"`
- **No new `export namespace`**. Use a file with flat named exports and ## Remaining work
`export * as` in the barrel.
- Keep the service, layer, errors, schemas, and runtime wiring together in one
file if you want — that's fine now. The `export * as` barrel makes everything
individually shakeable regardless of file structure.
- If a file grows large enough that it's hard to navigate, split by concern
(errors.ts, schema.ts, etc.) for readability. Not for tree-shaking — the
bundler handles that.
## Circular import rules ### Unconverted (~50 namespaces):
Barrel files (`index.ts` with `export * as`) introduce circular import risks. **Session directory (14)** — deep cross-directory cycles currently via barrel:
These cause `ReferenceError: Cannot access 'X' before initialization` at
runtime — not caught by the type checker.
### Rule 1: Sibling files never import through their own barrel - SessionRunState, SystemPrompt, Message, SessionRetry, SessionProcessor,
SessionRevert, Instruction, SessionSummary, Todo, LLM, SessionStatus,
SessionCompaction, SessionPrompt, MessageV2
Files in the same directory must import directly from the source file, never **Special cases:**
through `"."` or `"@/<own-dir>"`:
```ts - `flag/flag.ts` — uses `Object.defineProperty(Flag, ...)`, needs restructuring
// BAD — circular: index.ts re-exports both files, so A → index → B → index → A - `account/repo.ts` — ast-grep fails, needs manual conversion
import { Sibling } from "." - `v2/` (multi-namespace files) — SessionEvent (5 nested), etc.
// GOOD — direct, no cycle **Other standalone modules** (~30 across server/, cli/, plugin/, etc.)
import * as Sibling from "./sibling"
```
### Rule 2: Cross-directory imports must not form cycles through barrels ### Already converted (32 barrel dirs) — need retrofit:
If `src/lsp/lsp.ts` imports `Config` from `"../config"`, and config, provider, bus, mcp, effect, util, file, tool, storage, lsp,
`src/config/config.ts` imports `LSPServer` from `"../lsp"`, that's a cycle: project, plugin, permission, skill, auth, env, worktree, ide, snapshot,
installation, pty, share, cli/cmd/tui/util, plugin/github-copilot, etc.
```
lsp/lsp.ts → config/index.ts → config/config.ts → lsp/index.ts → lsp/lsp.ts 💥
```
Fix by importing the specific file, breaking the cycle:
```ts
// In config/config.ts — import directly, not through the lsp barrel
import * as LSPServer from "../lsp/server"
```
### Why the type checker doesn't catch this
TypeScript resolves types lazily — it doesn't evaluate module-scope
expressions. The `ReferenceError` only happens at runtime when a module-scope
`const` or function call accesses a value from a circular dependency that
hasn't finished initializing. The SDK build step (`bun run --conditions=browser
./src/index.ts generate`) is the reliable way to catch these because it
evaluates all modules eagerly.
### How to verify
After any namespace conversion, run:
```bash
cd packages/opencode
bun run --conditions=browser ./src/index.ts generate
```
If this completes without `ReferenceError`, the module graph is safe.