浏览代码

feat: namespace → flat export migration (Bus proof-of-concept) (#22685)

Kit Langton 5 天之前
父节点
当前提交
02f2cf439e

+ 190 - 0
packages/opencode/script/unwrap-namespace.ts

@@ -0,0 +1,190 @@
+#!/usr/bin/env bun
+/**
+ * Unwrap a TypeScript `export namespace` into flat exports + barrel.
+ *
+ * Usage:
+ *   bun script/unwrap-namespace.ts src/bus/index.ts
+ *   bun script/unwrap-namespace.ts src/bus/index.ts --dry-run
+ *
+ * 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. If the file is index.ts, renames it to <lowercase-name>.ts
+ *   4. Creates/updates index.ts with `export * as Foo from "./<file>"`
+ *   5. Prints the import rewrite commands to run across the codebase
+ *
+ * Does NOT auto-rewrite imports — prints the commands so you can review them.
+ *
+ * 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 filePath = args.find((a) => !a.startsWith("--"))
+
+if (!filePath) {
+  console.error("Usage: bun script/unwrap-namespace.ts <file> [--dry-run]")
+  process.exit(1)
+}
+
+const absPath = path.resolve(filePath)
+if (!fs.existsSync(absPath)) {
+  console.error(`File not found: ${absPath}`)
+  process.exit(1)
+}
+
+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" },
+)
+
+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 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 // 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)`)
+
+// 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)
+  return line // don't touch lines that aren't indented (shouldn't happen)
+})
+
+const newContent = [...before, ...dedented, ...after].join("\n")
+
+// Figure out file naming
+const dir = path.dirname(absPath)
+const basename = path.basename(absPath, ".ts")
+const isIndex = basename === "index"
+
+// The implementation file name (lowercase namespace name if currently index.ts)
+const implName = isIndex ? nsName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase() : basename
+const implFile = path.join(dir, `${implName}.ts`)
+const indexFile = path.join(dir, "index.ts")
+
+// The barrel line
+const barrelLine = `export * as ${nsName} from "./${implName}"\n`
+
+console.log("")
+if (isIndex) {
+  console.log(`Plan: rename ${basename}.ts → ${implName}.ts, create new index.ts barrel`)
+} else {
+  console.log(`Plan: rewrite ${basename}.ts in place, create index.ts barrel`)
+}
+console.log("")
+
+if (dryRun) {
+  console.log("--- DRY RUN ---")
+  console.log("")
+  console.log(`=== ${implName}.ts (first 30 lines) ===`)
+  newContent
+    .split("\n")
+    .slice(0, 30)
+    .forEach((l, i) => console.log(`  ${i + 1}: ${l}`))
+  console.log("  ...")
+  console.log("")
+  console.log(`=== index.ts ===`)
+  console.log(`  ${barrelLine.trim()}`)
+} else {
+  // Write the implementation file
+  if (isIndex) {
+    // Rename: write new content to implFile, then overwrite index.ts with barrel
+    fs.writeFileSync(implFile, newContent)
+    fs.writeFileSync(indexFile, barrelLine)
+    console.log(`Wrote ${implName}.ts (${newContent.split("\n").length} lines)`)
+    console.log(`Wrote index.ts (barrel)`)
+  } else {
+    // Rewrite in place, create index.ts
+    fs.writeFileSync(absPath, newContent)
+    if (fs.existsSync(indexFile)) {
+      // Append to existing barrel
+      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)`)
+  }
+}
+
+// Print the import rewrite guidance
+const relDir = path.relative(path.resolve("src"), dir)
+
+console.log("")
+console.log("=== Import rewrites ===")
+console.log("")
+
+if (!isIndex) {
+  // Non-index files: imports like "../provider/provider" need to become "../provider"
+  const oldTail = `${relDir}/${basename}`
+
+  console.log(`# Find all imports to rewrite:`)
+  console.log(`rg 'from.*${oldTail}' src/ --files-with-matches`)
+  console.log("")
+
+  // Auto-rewrite with sed (safe: only rewrites the import path, not other occurrences)
+  console.log("# Auto-rewrite (review diff afterward):")
+  console.log(`rg -l 'from.*${oldTail}' src/ | xargs sed -i '' 's|${oldTail}"|${relDir}"|g'`)
+  console.log("")
+  console.log("# What changes:")
+  console.log(`#   import { ${nsName} } from ".../${oldTail}"`)
+  console.log(`#   import { ${nsName} } from ".../${relDir}"`)
+} else {
+  console.log("# File was index.ts — import paths already resolve correctly.")
+  console.log("# No import rewrites needed!")
+}
+
+console.log("")
+console.log("=== Verify ===")
+console.log("")
+console.log("bun typecheck     # from packages/opencode")
+console.log("bun run test      # run tests")

+ 444 - 0
packages/opencode/specs/effect/namespace-treeshake.md

@@ -0,0 +1,444 @@
+# Namespace → flat export migration
+
+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.
+
+## What changes and what doesn't
+
+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
+// BEFORE — points at the file directly
+import { Provider } from "../provider/provider"
+
+// AFTER — resolves to provider/index.ts, same Provider namespace
+import { Provider } from "../provider"
+```
+
+## 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
+// 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
+```
+
+### 2. Barrel file uses `export * as` (not `export namespace`)
+
+```ts
+// index.ts
+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
+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
+bun script/unwrap-namespace.ts <file> [--dry-run]
+```
+
+The script uses ast-grep for accurate AST-based namespace boundary detection
+(no false matches from braces in strings/templates/comments), then:
+
+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
+
+### Walkthrough: converting a module
+
+Using `Provider` as an example:
+
+```bash
+# 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
+
+Going forward:
+
+- **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.

+ 192 - 0
packages/opencode/src/bus/bus.ts

@@ -0,0 +1,192 @@
+import z from "zod"
+import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
+import { EffectBridge } from "@/effect/bridge"
+import { Log } from "../util/log"
+import { BusEvent } from "./bus-event"
+import { GlobalBus } from "./global"
+import { WorkspaceContext } from "@/control-plane/workspace-context"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRuntime } from "@/effect/run-service"
+
+const log = Log.create({ service: "bus" })
+
+export const InstanceDisposed = BusEvent.define(
+  "server.instance.disposed",
+  z.object({
+    directory: z.string(),
+  }),
+)
+
+type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
+  type: D["type"]
+  properties: z.infer<D["properties"]>
+}
+
+type State = {
+  wildcard: PubSub.PubSub<Payload>
+  typed: Map<string, PubSub.PubSub<Payload>>
+}
+
+export interface Interface {
+  readonly publish: <D extends BusEvent.Definition>(
+    def: D,
+    properties: z.output<D["properties"]>,
+  ) => Effect.Effect<void>
+  readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
+  readonly subscribeAll: () => Stream.Stream<Payload>
+  readonly subscribeCallback: <D extends BusEvent.Definition>(
+    def: D,
+    callback: (event: Payload<D>) => unknown,
+  ) => Effect.Effect<() => void>
+  readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const state = yield* InstanceState.make<State>(
+      Effect.fn("Bus.state")(function* (ctx) {
+        const wildcard = yield* PubSub.unbounded<Payload>()
+        const typed = new Map<string, PubSub.PubSub<Payload>>()
+
+        yield* Effect.addFinalizer(() =>
+          Effect.gen(function* () {
+            // Publish InstanceDisposed before shutting down so subscribers see it
+            yield* PubSub.publish(wildcard, {
+              type: InstanceDisposed.type,
+              properties: { directory: ctx.directory },
+            })
+            yield* PubSub.shutdown(wildcard)
+            for (const ps of typed.values()) {
+              yield* PubSub.shutdown(ps)
+            }
+          }),
+        )
+
+        return { wildcard, typed }
+      }),
+    )
+
+    function getOrCreate<D extends BusEvent.Definition>(state: State, def: D) {
+      return Effect.gen(function* () {
+        let ps = state.typed.get(def.type)
+        if (!ps) {
+          ps = yield* PubSub.unbounded<Payload>()
+          state.typed.set(def.type, ps)
+        }
+        return ps as unknown as PubSub.PubSub<Payload<D>>
+      })
+    }
+
+    function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
+      return Effect.gen(function* () {
+        const s = yield* InstanceState.get(state)
+        const payload: Payload = { type: def.type, properties }
+        log.info("publishing", { type: def.type })
+
+        const ps = s.typed.get(def.type)
+        if (ps) yield* PubSub.publish(ps, payload)
+        yield* PubSub.publish(s.wildcard, payload)
+
+        const dir = yield* InstanceState.directory
+        const context = yield* InstanceState.context
+        const workspace = yield* InstanceState.workspaceID
+
+        GlobalBus.emit("event", {
+          directory: dir,
+          project: context.project.id,
+          workspace,
+          payload,
+        })
+      })
+    }
+
+    function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
+      log.info("subscribing", { type: def.type })
+      return Stream.unwrap(
+        Effect.gen(function* () {
+          const s = yield* InstanceState.get(state)
+          const ps = yield* getOrCreate(s, def)
+          return Stream.fromPubSub(ps)
+        }),
+      ).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
+    }
+
+    function subscribeAll(): Stream.Stream<Payload> {
+      log.info("subscribing", { type: "*" })
+      return Stream.unwrap(
+        Effect.gen(function* () {
+          const s = yield* InstanceState.get(state)
+          return Stream.fromPubSub(s.wildcard)
+        }),
+      ).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
+    }
+
+    function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
+      return Effect.gen(function* () {
+        log.info("subscribing", { type })
+        const bridge = yield* EffectBridge.make()
+        const scope = yield* Scope.make()
+        const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
+
+        yield* Scope.provide(scope)(
+          Stream.fromSubscription(subscription).pipe(
+            Stream.runForEach((msg) =>
+              Effect.tryPromise({
+                try: () => Promise.resolve().then(() => callback(msg)),
+                catch: (cause) => {
+                  log.error("subscriber failed", { type, cause })
+                },
+              }).pipe(Effect.ignore),
+            ),
+            Effect.forkScoped,
+          ),
+        )
+
+        return () => {
+          log.info("unsubscribing", { type })
+          bridge.fork(Scope.close(scope, Exit.void))
+        }
+      })
+    }
+
+    const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusEvent.Definition>(
+      def: D,
+      callback: (event: Payload<D>) => unknown,
+    ) {
+      const s = yield* InstanceState.get(state)
+      const ps = yield* getOrCreate(s, def)
+      return yield* on(ps, def.type, callback)
+    })
+
+    const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
+      const s = yield* InstanceState.get(state)
+      return yield* on(s.wildcard, "*", callback)
+    })
+
+    return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
+  }),
+)
+
+export const defaultLayer = layer
+
+const { runPromise, runSync } = makeRuntime(Service, layer)
+
+// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
+// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
+export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
+  return runPromise((svc) => svc.publish(def, properties))
+}
+
+export function subscribe<D extends BusEvent.Definition>(
+  def: D,
+  callback: (event: { type: D["type"]; properties: z.infer<D["properties"]> }) => unknown,
+) {
+  return runSync((svc) => svc.subscribeCallback(def, callback))
+}
+
+export function subscribeAll(callback: (event: any) => unknown) {
+  return runSync((svc) => svc.subscribeAllCallback(callback))
+}

+ 1 - 194
packages/opencode/src/bus/index.ts

@@ -1,194 +1 @@
-import z from "zod"
-import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
-import { EffectBridge } from "@/effect/bridge"
-import { Log } from "../util/log"
-import { BusEvent } from "./bus-event"
-import { GlobalBus } from "./global"
-import { WorkspaceContext } from "@/control-plane/workspace-context"
-import { InstanceState } from "@/effect/instance-state"
-import { makeRuntime } from "@/effect/run-service"
-
-export namespace Bus {
-  const log = Log.create({ service: "bus" })
-
-  export const InstanceDisposed = BusEvent.define(
-    "server.instance.disposed",
-    z.object({
-      directory: z.string(),
-    }),
-  )
-
-  type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
-    type: D["type"]
-    properties: z.infer<D["properties"]>
-  }
-
-  type State = {
-    wildcard: PubSub.PubSub<Payload>
-    typed: Map<string, PubSub.PubSub<Payload>>
-  }
-
-  export interface Interface {
-    readonly publish: <D extends BusEvent.Definition>(
-      def: D,
-      properties: z.output<D["properties"]>,
-    ) => Effect.Effect<void>
-    readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
-    readonly subscribeAll: () => Stream.Stream<Payload>
-    readonly subscribeCallback: <D extends BusEvent.Definition>(
-      def: D,
-      callback: (event: Payload<D>) => unknown,
-    ) => Effect.Effect<() => void>
-    readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
-  }
-
-  export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
-
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const state = yield* InstanceState.make<State>(
-        Effect.fn("Bus.state")(function* (ctx) {
-          const wildcard = yield* PubSub.unbounded<Payload>()
-          const typed = new Map<string, PubSub.PubSub<Payload>>()
-
-          yield* Effect.addFinalizer(() =>
-            Effect.gen(function* () {
-              // Publish InstanceDisposed before shutting down so subscribers see it
-              yield* PubSub.publish(wildcard, {
-                type: InstanceDisposed.type,
-                properties: { directory: ctx.directory },
-              })
-              yield* PubSub.shutdown(wildcard)
-              for (const ps of typed.values()) {
-                yield* PubSub.shutdown(ps)
-              }
-            }),
-          )
-
-          return { wildcard, typed }
-        }),
-      )
-
-      function getOrCreate<D extends BusEvent.Definition>(state: State, def: D) {
-        return Effect.gen(function* () {
-          let ps = state.typed.get(def.type)
-          if (!ps) {
-            ps = yield* PubSub.unbounded<Payload>()
-            state.typed.set(def.type, ps)
-          }
-          return ps as unknown as PubSub.PubSub<Payload<D>>
-        })
-      }
-
-      function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
-        return Effect.gen(function* () {
-          const s = yield* InstanceState.get(state)
-          const payload: Payload = { type: def.type, properties }
-          log.info("publishing", { type: def.type })
-
-          const ps = s.typed.get(def.type)
-          if (ps) yield* PubSub.publish(ps, payload)
-          yield* PubSub.publish(s.wildcard, payload)
-
-          const dir = yield* InstanceState.directory
-          const context = yield* InstanceState.context
-          const workspace = yield* InstanceState.workspaceID
-
-          GlobalBus.emit("event", {
-            directory: dir,
-            project: context.project.id,
-            workspace,
-            payload,
-          })
-        })
-      }
-
-      function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
-        log.info("subscribing", { type: def.type })
-        return Stream.unwrap(
-          Effect.gen(function* () {
-            const s = yield* InstanceState.get(state)
-            const ps = yield* getOrCreate(s, def)
-            return Stream.fromPubSub(ps)
-          }),
-        ).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
-      }
-
-      function subscribeAll(): Stream.Stream<Payload> {
-        log.info("subscribing", { type: "*" })
-        return Stream.unwrap(
-          Effect.gen(function* () {
-            const s = yield* InstanceState.get(state)
-            return Stream.fromPubSub(s.wildcard)
-          }),
-        ).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
-      }
-
-      function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
-        return Effect.gen(function* () {
-          log.info("subscribing", { type })
-          const bridge = yield* EffectBridge.make()
-          const scope = yield* Scope.make()
-          const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
-
-          yield* Scope.provide(scope)(
-            Stream.fromSubscription(subscription).pipe(
-              Stream.runForEach((msg) =>
-                Effect.tryPromise({
-                  try: () => Promise.resolve().then(() => callback(msg)),
-                  catch: (cause) => {
-                    log.error("subscriber failed", { type, cause })
-                  },
-                }).pipe(Effect.ignore),
-              ),
-              Effect.forkScoped,
-            ),
-          )
-
-          return () => {
-            log.info("unsubscribing", { type })
-            bridge.fork(Scope.close(scope, Exit.void))
-          }
-        })
-      }
-
-      const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusEvent.Definition>(
-        def: D,
-        callback: (event: Payload<D>) => unknown,
-      ) {
-        const s = yield* InstanceState.get(state)
-        const ps = yield* getOrCreate(s, def)
-        return yield* on(ps, def.type, callback)
-      })
-
-      const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
-        const s = yield* InstanceState.get(state)
-        return yield* on(s.wildcard, "*", callback)
-      })
-
-      return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
-    }),
-  )
-
-  export const defaultLayer = layer
-
-  const { runPromise, runSync } = makeRuntime(Service, layer)
-
-  // runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
-  // Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
-  export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
-    return runPromise((svc) => svc.publish(def, properties))
-  }
-
-  export function subscribe<D extends BusEvent.Definition>(
-    def: D,
-    callback: (event: { type: D["type"]; properties: z.infer<D["properties"]> }) => unknown,
-  ) {
-    return runSync((svc) => svc.subscribeCallback(def, callback))
-  }
-
-  export function subscribeAll(callback: (event: any) => unknown) {
-    return runSync((svc) => svc.subscribeAllCallback(callback))
-  }
-}
+export * as Bus from "./bus"