|
|
@@ -1,499 +1,161 @@
|
|
|
-# Namespace → flat export migration
|
|
|
+# Namespace → self-reexport 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.
|
|
|
+Migrate `export namespace` to flat module exports with a self-referential
|
|
|
+`export * as` at the bottom of each file. No barrel files.
|
|
|
|
|
|
-## What changes and what doesn't
|
|
|
+## The pattern
|
|
|
|
|
|
-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"`:
|
|
|
+Each module file has flat exports plus one line at the bottom that re-exports
|
|
|
+itself as a namespace:
|
|
|
|
|
|
```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:
|
|
|
+// config/config.ts
|
|
|
+import { Log } from "../util/log"
|
|
|
|
|
|
-```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
|
|
|
+export interface Info { model: string }
|
|
|
+export function load(): Info { ... }
|
|
|
+export const JsonError = NamedError.create(...)
|
|
|
|
|
|
-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)
|
|
|
+// Self-reexport: creates a named `Config` export that consumers can import
|
|
|
+export * as Config from "./config"
|
|
|
```
|
|
|
|
|
|
-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`)
|
|
|
+Consumers import the namespace by name — editors auto-import this like any
|
|
|
+named export:
|
|
|
|
|
|
```ts
|
|
|
-// index.ts
|
|
|
-export * as Effect from "./Effect.ts"
|
|
|
-export * as Schema from "./Schema.ts"
|
|
|
-export * as Stream from "./Stream.ts"
|
|
|
-// ~134 modules
|
|
|
+import { Config } from "../config/config"
|
|
|
+Config.load()
|
|
|
+Config.JsonError.isInstance(x)
|
|
|
```
|
|
|
|
|
|
-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.
|
|
|
+## Why this pattern
|
|
|
|
|
|
-### 3. `sideEffects: []` and deep imports
|
|
|
+We tested every option with Bun. Three things matter: tree-shaking, circular
|
|
|
+imports, and editor autocomplete.
|
|
|
|
|
|
-```jsonc
|
|
|
-// package.json
|
|
|
-{ "sideEffects": [] }
|
|
|
```
|
|
|
+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)
|
|
|
|
|
|
-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
|
|
|
+B. import * as Bar from "./bar" (direct, no barrel)
|
|
|
+ Runtime: only bar loaded ✅
|
|
|
+ Bundled: only bar loaded ✅
|
|
|
+ Autocomplete: broken (editors can't auto-import) ❌
|
|
|
|
|
|
-```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
|
|
|
+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) ✅
|
|
|
```
|
|
|
|
|
|
-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:
|
|
|
+The self-reexport gives us tree-shaking + autocomplete + no barrels.
|
|
|
|
|
|
-```
|
|
|
-# 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
|
|
|
-```
|
|
|
+### Bundle overhead
|
|
|
|
|
|
-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"
|
|
|
+The self-reexport adds ~240 bytes per module (an `Object.defineProperty`
|
|
|
+wrapper). At 100 modules that's ~24KB — irrelevant for a CLI binary.
|
|
|
|
|
|
-// AFTER — resolves to provider/index.ts, same Provider object
|
|
|
-import { Provider } from "../provider"
|
|
|
-```
|
|
|
+### The `Foo.Foo.Foo` thing
|
|
|
|
|
|
-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.
|
|
|
+`Config.Config.Config.load()` compiles and runs. It's a harmless side effect
|
|
|
+of self-referential modules. Nobody would write it.
|
|
|
|
|
|
-**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.
|
|
|
+## Why barrel files don't work
|
|
|
|
|
|
-**Order of conversion** (by risk / size, do small modules first):
|
|
|
+Barrel files (`index.ts` with `export * as`) have two problems:
|
|
|
|
|
|
-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`
|
|
|
+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).
|
|
|
|
|
|
-### Phase 2: Build configuration
|
|
|
+2. **Circular import risk.** Sibling files can't import through their own
|
|
|
+ barrel, and cross-directory barrel cycles cause runtime `ReferenceError`.
|
|
|
|
|
|
-After the module structure supports tree-shaking:
|
|
|
+## The migration
|
|
|
|
|
|
-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.
|
|
|
+There are two tasks:
|
|
|
|
|
|
-## Automation
|
|
|
+### Task 1: Convert remaining `export namespace` files (~50)
|
|
|
|
|
|
-The transformation is scripted. From `packages/opencode`:
|
|
|
+For each file:
|
|
|
|
|
|
-```bash
|
|
|
-bun script/unwrap-namespace.ts <file> [--dry-run]
|
|
|
-```
|
|
|
+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.
|
|
|
|
|
|
-The script uses ast-grep for accurate AST-based namespace boundary detection
|
|
|
-(no false matches from braces in strings/templates/comments), then:
|
|
|
+### Task 2: Fix already-converted files (~32 barrel dirs)
|
|
|
|
|
|
-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
|
|
|
+These were converted in the earlier barrel-based migration. Each directory
|
|
|
+has an `index.ts` barrel and flat-exported source files. To migrate:
|
|
|
|
|
|
-### Walkthrough: converting a module
|
|
|
+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)
|
|
|
|
|
|
-Using `Provider` as an example:
|
|
|
+### Automation
|
|
|
|
|
|
```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'
|
|
|
+# 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
|
|
|
|
|
|
-# 4. Verify
|
|
|
-bun typecheck
|
|
|
-bun run test
|
|
|
+# 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
|
|
|
```
|
|
|
|
|
|
-**What changes on disk:**
|
|
|
+The script handles both cases:
|
|
|
|
|
|
-```
|
|
|
-# 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
|
|
|
-```
|
|
|
+- **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
|
|
|
|
|
|
-**What changes in consumer code:**
|
|
|
+### Verification
|
|
|
|
|
|
-```ts
|
|
|
-// BEFORE
|
|
|
-import { Provider } from "../provider/provider"
|
|
|
+After any conversion:
|
|
|
|
|
|
-// AFTER — shorter path, same Provider object
|
|
|
-import { Provider } from "../provider"
|
|
|
+```bash
|
|
|
+bunx --bun tsgo --noEmit # typecheck
|
|
|
+bun run --conditions=browser ./src/index.ts generate # circular import check
|
|
|
```
|
|
|
|
|
|
-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.
|
|
|
-
|
|
|
-## Circular import rules
|
|
|
-
|
|
|
-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.
|
|
|
+- **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"`
|
|
|
|
|
|
-### Rule 1: Sibling files never import through their own barrel
|
|
|
+## Remaining work
|
|
|
|
|
|
-Files in the same directory must import directly from the source file, never
|
|
|
-through `"."` or `"@/<own-dir>"`:
|
|
|
+### Unconverted (~50 namespaces):
|
|
|
|
|
|
-```ts
|
|
|
-// BAD — circular: index.ts re-exports both files, so A → index → B → index → A
|
|
|
-import { Sibling } from "."
|
|
|
-
|
|
|
-// GOOD — direct, no cycle
|
|
|
-import * as Sibling from "./sibling"
|
|
|
-```
|
|
|
+**Session directory (14)** — deep cross-directory cycles currently via barrel:
|
|
|
|
|
|
-### Rule 2: Cross-directory imports must not form cycles through barrels
|
|
|
+- SessionRunState, SystemPrompt, Message, SessionRetry, SessionProcessor,
|
|
|
+ SessionRevert, Instruction, SessionSummary, Todo, LLM, SessionStatus,
|
|
|
+ SessionCompaction, SessionPrompt, MessageV2
|
|
|
|
|
|
-If `src/lsp/lsp.ts` imports `Config` from `"../config"`, and
|
|
|
-`src/config/config.ts` imports `LSPServer` from `"../lsp"`, that's a cycle:
|
|
|
+**Special cases:**
|
|
|
|
|
|
-```
|
|
|
-lsp/lsp.ts → config/index.ts → config/config.ts → lsp/index.ts → lsp/lsp.ts 💥
|
|
|
-```
|
|
|
+- `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.
|
|
|
|
|
|
-Fix by importing the specific file, breaking the cycle:
|
|
|
+**Other standalone modules** (~30 across server/, cli/, plugin/, etc.)
|
|
|
|
|
|
-```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
|
|
|
-```
|
|
|
+### Already converted (32 barrel dirs) — need retrofit:
|
|
|
|
|
|
-If this completes without `ReferenceError`, the module graph is safe.
|
|
|
+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.
|