Compare commits

..
Author SHA1 Message Date
Kit Langton d1d90c27b1 refactor: switch MCP to direct self-reexport imports 2026-04-16 11:33:44 -04:00
12 changed files with 566 additions and 369 deletions
+111 -253
View File
@@ -1,26 +1,21 @@
#!/usr/bin/env bun
/**
* Unwrap a TypeScript `export namespace` into flat exports with self-reexport.
* Unwrap a TypeScript `export namespace` into flat exports + barrel.
*
* 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
* bun script/unwrap-namespace.ts src/bus/index.ts
* bun script/unwrap-namespace.ts src/bus/index.ts --dry-run
* bun script/unwrap-namespace.ts src/pty/index.ts --name service # avoid collision with pty.ts
*
* 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.
* What it does:
* 1. Reads the file and finds the `export namespace Foo { ... }` block
* (uses ast-grep for accurate AST-based boundary detection)
* 2. Removes the namespace wrapper and dedents the body
* 3. Fixes self-references (e.g. Config.PermissionAction → PermissionAction)
* 4. If the file is index.ts, renames it to <lowercase-name>.ts
* 5. Creates/updates index.ts with `export * as Foo from "./<file>"`
* 6. Rewrites import paths across src/, test/, and script/
* 7. Fixes sibling imports within the same directory
*
* Requires: ast-grep (`brew install ast-grep` or `cargo install ast-grep`)
*/
@@ -30,12 +25,11 @@ 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]")
console.error("Usage: bun script/unwrap-namespace.ts <file> [--dry-run] [--name <impl-name>]")
process.exit(1)
}
@@ -45,76 +39,11 @@ if (!fs.existsSync(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")
// Use ast-grep to find the namespace boundaries accurately.
// This avoids false matches from braces in strings, templates, comments, etc.
const astResult = Bun.spawnSync(
["ast-grep", "run", "--pattern", "export namespace $NAME { $$$BODY }", "--lang", "typescript", "--json", absPath],
{ stdout: "pipe", stderr: "pipe" },
@@ -132,29 +61,34 @@ const matches = JSON.parse(astResult.stdout.toString()) as Array<{
}>
if (matches.length === 0) {
console.error("No `export namespace Foo { ... }` found. Use --retrofit for already-converted files.")
console.error("No `export namespace Foo { ... }` found in file")
process.exit(1)
}
if (matches.length > 1) {
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})`)
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
const nsLine = match.range.start.line // 0-indexed
const closeLine = match.range.end.line // 0-indexed, the line with closing `}`
console.log(`Found: export namespace ${nsName} { ... }`)
console.log(` Lines ${nsLine + 1}${closeLine + 1} (${closeLine - nsLine + 1} lines)`)
// Unwrap: remove namespace wrapper, dedent body
// Build the new file content:
// 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 body = lines.slice(nsLine + 1, closeLine)
const after = lines.slice(closeLine + 1)
// Dedent: remove exactly 2 leading spaces from each line
const dedented = body.map((line) => {
if (line === "") return ""
if (line.startsWith(" ")) return line.slice(2)
@@ -163,7 +97,9 @@ const dedented = body.map((line) => {
let newContent = [...before, ...dedented, ...after].join("\n")
// Fix self-references (Foo.Bar → Bar when Bar is exported from this file)
// --- Fix self-references ---
// 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 exportRegex = /export\s+(?:const|function|class|interface|type|enum|abstract\s+class)\s+(\w+)/g
for (const line of dedented) {
@@ -186,6 +122,7 @@ for (const line of dedented) {
let selfRefCount = 0
if (exportedNames.size > 0) {
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 }> = []
let i = 0
let current = ""
@@ -249,199 +186,120 @@ if (exportedNames.size > 0) {
newContent = fixedLines.join("\n")
}
// Handle index.ts rename
// Figure out file naming
const dir = path.dirname(absPath)
const basename = path.basename(absPath, ".ts")
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"
const implFile = path.join(dir, `${implName}.ts`)
const indexFile = path.join(dir, "index.ts")
const barrelLine = `export * as ${nsName} from "./${implName}"\n`
console.log("")
if (isIndex) {
console.log(`Plan: rename index.ts → ${implName}.ts, add self-reexport`)
console.log(`Plan: rename ${basename}.ts → ${implName}.ts, create new index.ts barrel`)
} else {
console.log(`Plan: unwrap in place, add self-reexport`)
console.log(`Plan: rewrite ${basename}.ts in place, create index.ts barrel`)
}
if (selfRefCount > 0) console.log(`Fixed ${selfRefCount} self-reference(s) (${nsName}.X → X)`)
console.log("")
if (dryRun) {
console.log("")
console.log("--- DRY RUN ---")
console.log("")
console.log(`=== ${implName}.ts (first 20 lines) ===`)
console.log(`=== ${implName}.ts (first 30 lines) ===`)
newContent
.split("\n")
.slice(0, 20)
.slice(0, 30)
.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(`=== index.ts ===`)
console.log(` ${barrelLine.trim()}`)
console.log("")
rewriteConsumers(nsName, implFile, implName, dir)
if (!isIndex) {
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 {
if (isIndex) {
fs.writeFileSync(implFile, newContent)
fs.unlinkSync(absPath)
console.log(`Renamed to ${implName}.ts (${newContent.split("\n").length} lines)`)
fs.writeFileSync(indexFile, barrelLine)
console.log(`Wrote ${implName}.ts (${newContent.split("\n").length} lines)`)
console.log(`Wrote index.ts (barrel)`)
} else {
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)`)
}
rewriteConsumers(nsName, implFile, implName, dir)
}
// ---------------------------------------------------------------------------
// Consumer import rewriting (shared by default + retrofit mode)
// ---------------------------------------------------------------------------
// --- 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)
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"))
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)")
}
// 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",
// --- 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
})
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
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++
}
}
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")
if (siblingFixCount > 0) {
console.log(`Fixed ${siblingFixCount} sibling import(s) in ${path.basename(dir)}/ (./${basename} → .)`)
}
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
}
console.log("")
console.log("=== Verify ===")
console.log("")
console.log("bunx --bun tsgo --noEmit # typecheck")
console.log("bun run test # run tests")
@@ -1,161 +1,499 @@
# Namespace → self-reexport migration
# Namespace → flat export migration
Migrate `export namespace` to flat module exports with a self-referential
`export * as` at the bottom of each file. No barrel files.
Migrate `export namespace` to the `export * as` / flat-export pattern used by
effect-smol. Primary goal: tree-shakeability. Secondary: consistency with Effect
conventions, LLM-friendliness for future migrations.
## The pattern
## What changes and what doesn't
Each module file has flat exports plus one line at the bottom that re-exports
itself as a namespace:
The **consumer API stays the same**. You still write `Provider.ModelNotFoundError`,
`Config.JsonError`, `Bus.publish`, etc. The namespace ergonomics are preserved.
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
// config/config.ts
import { Log } from "../util/log"
// BEFORE — points at the file directly
import { Provider } from "../provider/provider"
export interface Info { model: string }
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"
// AFTER — resolves to provider/index.ts, same Provider namespace
import { Provider } from "../provider"
```
Consumers import the namespace by name — editors auto-import this like any
named export:
## Why this matters right now
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
import { Config } from "../config/config"
Config.load()
Config.JsonError.isInstance(x)
// Effect.ts — no namespace wrapper, just flat exports
export const gen: { ... } = internal.gen
export const fail: <E>(error: E) => Effect<never, E> = internal.fail
export const succeed: <A>(value: A) => Effect<A> = internal.succeed
// ... 230+ individual named exports
```
## Why this pattern
### 2. Barrel file uses `export * as` (not `export namespace`)
We tested every option with Bun. Three things matter: tree-shaking, circular
imports, and editor autocomplete.
```
A. Barrel (export * as Foo + Bar from index.ts)
Runtime: foo LOADED even though only Bar used ❌
Bundled: foo LOADED if it has side effects ❌
Autocomplete: works (named export from barrel)
B. import * as Bar from "./bar" (direct, no barrel)
Runtime: only bar loaded ✅
Bundled: only bar loaded ✅
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) ✅
```ts
// index.ts
export * as Effect from "./Effect.ts"
export * as Schema from "./Schema.ts"
export * as Stream from "./Stream.ts"
// ~134 modules
```
The self-reexport gives us tree-shaking + autocomplete + no barrels.
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.
### Bundle overhead
### 3. `sideEffects: []` and deep imports
The self-reexport adds ~240 bytes per module (an `Object.defineProperty`
wrapper). At 100 modules that's ~24KB — irrelevant for a CLI binary.
```jsonc
// package.json
{ "sideEffects": [] }
```
### The `Foo.Foo.Foo` thing
Plus `"./*": "./src/*.ts"` in the exports map, enabling
`import * as Effect from "effect/Effect"` to bypass the barrel entirely.
`Config.Config.Config.load()` compiles and runs. It's a harmless side effect
of self-referential modules. Nobody would write it.
### 4. Errors as flat exports, not class declarations
## Why barrel files don't work
```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
```
Barrel files (`index.ts` with `export * as`) have two problems:
Each error is 4 independent exports: TypeId, interface, constructor (as const),
type guard. All individually shakeable.
1. **Bun loads all re-exported modules** when you import through a barrel,
even if you only use one. This happens at both runtime and bundle time
for modules with side effects (which ours have — top-level imports).
## The plan
2. **Circular import risk.** Sibling files can't import through their own
barrel, and cross-directory barrel cycles cause runtime `ReferenceError`.
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.
## The migration
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.
There are two tasks:
### Phase 0 (optional): Pre-split errors into subfiles
### Task 1: Convert remaining `export namespace` files (~50)
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 file:
**For each namespace that defines errors** (15 files, ~30 error classes total):
1. Remove the `export namespace Foo {` wrapper and closing `}`
2. Dedent the body
3. Add `export * as Foo from "./file"` at the bottom
4. Rewrite consumer imports: `import { Foo } from "..."` stays the same
if the path already points at the file. If it points at a barrel,
change it to point at the file directly.
1. Create a sibling `errors.ts` file (e.g. `provider/errors.ts`) with the error
definitions as top-level named exports:
### Task 2: Fix already-converted files (~32 barrel dirs)
```ts
// provider/errors.ts
import z from "zod"
import { NamedError } from "@opencode-ai/shared/util/error"
import { ProviderID, ModelID } from "./schema"
These were converted in the earlier barrel-based migration. Each directory
has an `index.ts` barrel and flat-exported source files. To migrate:
export const ModelNotFoundError = NamedError.create(
"ProviderModelNotFoundError",
z.object({
providerID: ProviderID.zod,
modelID: ModelID.zod,
suggestions: z.array(z.string()).optional(),
}),
)
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)
export const InitError = NamedError.create("ProviderInitError", z.object({ providerID: ProviderID.zod }))
```
### Automation
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
provider/
provider.ts ← 1709-line file with `export namespace Provider { ... }`
# AFTER
provider/
index.ts ← NEW: `export * as Provider from "./provider"`
provider.ts ← SAME file, same name, just unwrap the namespace
```
And the code change is purely removing the wrapper:
```ts
// 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
export class Service extends Context.Service<...>()("@opencode/Provider") {}
export const layer = Layer.effect(Service, ...)
export const ModelNotFoundError = NamedError.create(...)
export function parseModel(model: string) { ... }
```
```ts
// NEW: provider/index.ts
export * as Provider from "./provider"
```
Consumer code barely changes — import path gets shorter:
```ts
// BEFORE
import { Provider } from "../provider/provider"
// AFTER — resolves to provider/index.ts, same Provider object
import { Provider } from "../provider"
```
All access like `Provider.ModelNotFoundError`, `Provider.Service`,
`Provider.layer` works exactly as before. The difference is invisible to
consumers but lets the bundler see inside the namespace.
**Once this is done, you don't need to break anything into subfiles for
tree-shaking.** The bundler traces that `Provider.ModelNotFoundError` only
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):
1. Tiny utilities: `Archive`, `Color`, `Token`, `Rpc`, `LocalContext` (~7-66 lines each)
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
After the module structure supports tree-shaking:
1. Add `"sideEffects": []` to `packages/opencode/package.json` (or
`"sideEffects": false`) — this is safe because our services use explicit
layer composition, not import-time side effects.
2. Verify Bun's bundler respects the new structure. If Bun's tree-shaking is
insufficient, evaluate whether the compiled binary path needs an esbuild
pre-pass.
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
The transformation is scripted. From `packages/opencode`:
```bash
# 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
bun script/unwrap-namespace.ts <file> [--dry-run]
```
The script handles both cases:
The script uses ast-grep for accurate AST-based namespace boundary detection
(no false matches from braces in strings/templates/comments), then:
- **Default mode**: unwraps namespace + adds self-reexport + rewrites imports
- **Retrofit mode** (`--retrofit`): file already has flat exports, just adds
the self-reexport line and rewrites consumers from barrel to direct
1. Removes the `export namespace Foo {` line and its closing `}`
2. Dedents the body by one indent level (2 spaces)
3. If the file is `index.ts`, renames it to `<name>.ts` and creates a new
`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
### Verification
### Walkthrough: converting a module
After any conversion:
Using `Provider` as an example:
```bash
bunx --bun tsgo --noEmit # typecheck
bun run --conditions=browser ./src/index.ts generate # circular import check
# 1. Preview what will change
bun script/unwrap-namespace.ts src/provider/provider.ts --dry-run
# 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
- **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"`
Going forward:
## Remaining work
- **No new `export namespace`**. Use a file with flat named exports and
`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.
### Unconverted (~50 namespaces):
## Circular import rules
**Session directory (14)** — deep cross-directory cycles currently via barrel:
Barrel files (`index.ts` with `export * as`) introduce circular import risks.
These cause `ReferenceError: Cannot access 'X' before initialization` at
runtime — not caught by the type checker.
- SessionRunState, SystemPrompt, Message, SessionRetry, SessionProcessor,
SessionRevert, Instruction, SessionSummary, Todo, LLM, SessionStatus,
SessionCompaction, SessionPrompt, MessageV2
### Rule 1: Sibling files never import through their own barrel
**Special cases:**
Files in the same directory must import directly from the source file, never
through `"."` or `"@/<own-dir>"`:
- `flag/flag.ts` — uses `Object.defineProperty(Flag, ...)`, needs restructuring
- `account/repo.ts` — ast-grep fails, needs manual conversion
- `v2/` (multi-namespace files) — SessionEvent (5 nested), etc.
```ts
// BAD — circular: index.ts re-exports both files, so A → index → B → index → A
import { Sibling } from "."
**Other standalone modules** (~30 across server/, cli/, plugin/, etc.)
// GOOD — direct, no cycle
import * as Sibling from "./sibling"
```
### Already converted (32 barrel dirs) — need retrofit:
### Rule 2: Cross-directory imports must not form cycles through barrels
config, provider, bus, mcp, effect, util, file, tool, storage, lsp,
project, plugin, permission, skill, auth, env, worktree, ide, snapshot,
installation, pty, share, cli/cmd/tui/util, plugin/github-copilot, etc.
If `src/lsp/lsp.ts` imports `Config` from `"../config"`, and
`src/config/config.ts` imports `LSPServer` from `"../lsp"`, that's a cycle:
```
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.
+1 -1
View File
@@ -4,7 +4,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import * as prompts from "@clack/prompts"
import { UI } from "../ui"
import { MCP } from "../../mcp"
import { MCP } from "../../mcp/mcp"
import { McpAuth } from "../../mcp/auth"
import { McpOAuthProvider } from "../../mcp/oauth-provider"
import { Config } from "../../config"
+1 -1
View File
@@ -6,7 +6,7 @@ import { SessionID, MessageID } from "@/session/schema"
import { Effect, Layer, Context } from "effect"
import z from "zod"
import { Config } from "../config"
import { MCP } from "../mcp"
import { MCP } from "../mcp/mcp"
import { Skill } from "../skill"
import PROMPT_INITIALIZE from "./template/initialize.txt"
import PROMPT_REVIEW from "./template/review.txt"
+1 -1
View File
@@ -34,7 +34,7 @@ import { SessionPrompt } from "@/session/prompt"
import { Instruction } from "@/session/instruction"
import { LLM } from "@/session/llm"
import { LSP } from "@/lsp"
import { MCP } from "@/mcp"
import { MCP } from "@/mcp/mcp"
import { McpAuth } from "@/mcp/auth"
import { Command } from "@/command"
import { Truncate } from "@/tool"
-1
View File
@@ -1 +0,0 @@
export * as MCP from "./mcp"
+2
View File
@@ -922,3 +922,5 @@ export const defaultLayer = layer.pipe(
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
)
export * as MCP from "./mcp"
@@ -6,7 +6,7 @@ import { ToolRegistry } from "../../tool"
import { Worktree } from "../../worktree"
import { Instance } from "../../project/instance"
import { Project } from "../../project"
import { MCP } from "../../mcp"
import { MCP } from "../../mcp/mcp"
import { Session } from "../../session"
import { Config } from "../../config"
import { ConsoleState } from "../../config/console-state"
+1 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { MCP } from "../../mcp"
import { MCP } from "../../mcp/mcp"
import { Config } from "../../config"
import { AppRuntime } from "../../effect/app-runtime"
import { errors } from "../error"
+1 -1
View File
@@ -20,7 +20,7 @@ import PROMPT_PLAN from "../session/prompt/plan.txt"
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
import MAX_STEPS from "../session/prompt/max-steps.txt"
import { ToolRegistry } from "../tool"
import { MCP } from "../mcp"
import { MCP } from "../mcp/mcp"
import { LSP } from "../lsp"
import { FileTime } from "../file/time"
import { Flag } from "../flag/flag"
@@ -9,7 +9,7 @@ import { Command } from "../../src/command"
import { Config } from "../../src/config"
import { FileTime } from "../../src/file/time"
import { LSP } from "../../src/lsp"
import { MCP } from "../../src/mcp"
import { MCP } from "../../src/mcp/mcp"
import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "../../src/provider"
@@ -35,7 +35,7 @@ import { Command } from "../../src/command"
import { Config } from "../../src/config"
import { FileTime } from "../../src/file/time"
import { LSP } from "../../src/lsp"
import { MCP } from "../../src/mcp"
import { MCP } from "../../src/mcp/mcp"
import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "../../src/provider"