Kaynağa Gözat

refactor: unwrap BashArity namespace to flat exports + self-reexport (#22874)

Kit Langton 4 ay önce
ebeveyn
işleme
219b473e66

+ 137 - 424
packages/opencode/specs/effect/namespace-treeshake.md

@@ -1,499 +1,212 @@
-# 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.
-
-## 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"`:
+Migrate every `export namespace Foo { ... }` to flat top-level exports plus a
+single self-reexport line at the bottom of the same file:
 
 
 ```ts
 ```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 = {}))
+export * as Foo from "./foo"
 ```
 ```
 
 
-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
+No barrel `index.ts` files. No cross-directory indirection. Consumers keep the
+exact same `import { Foo } from "../foo/foo"` ergonomics.
 
 
-`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.
+## Why this pattern
 
 
-### Concrete impact
-
-The worst import chain in the codebase:
+We tested three options against Bun, esbuild, Rollup (what Vite uses under the
+hood), Bun's runtime, and Node's native TypeScript runner.
 
 
 ```
 ```
-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)
+                                       heavy.ts loaded?
+                                       A. namespace   B. barrel   C. self-reexport
+Bun bundler                            YES            YES         no
+esbuild                                YES            YES         no
+Rollup (Vite)                          YES            YES         no
+Bun runtime                            YES            YES         no
+Node --experimental-strip-types        SYNTAX ERROR   YES         no
 ```
 ```
 
 
-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
+- **`export namespace`** compiles to an IIFE. Bundlers see one opaque function
+  call and can't analyze what's used. Node's native TS runner rejects the
+  syntax outright: `SyntaxError: TypeScript namespace declaration is not
+supported in strip-only mode`.
+- **Barrel `index.ts`** files (`export * as Foo from "./foo"` in a separate
+  file) force every re-exported sibling to evaluate when you import one name.
+  Siblings with side effects (top-level imports of SDKs, etc.) always load.
+- **Self-reexport** keeps the file as plain ESM. Bundlers see static named
+  exports. The module is only pulled in when something actually imports from
+  it. There is no barrel hop, so no sibling contamination and no circular
+  import hazard.
 
 
-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.
+Bundle overhead for the self-reexport wrapper is roughly 240 bytes per module
+(`Object.defineProperty` namespace proxy). At ~100 modules that's ~24KB —
+negligible for a CLI binary.
 
 
-## What effect-smol does
+## The pattern
 
 
-effect-smol achieves tree-shakeable namespaced APIs via three structural choices.
-
-### 1. Each module is a separate file with flat named exports
+### Before
 
 
 ```ts
 ```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": [] }
+// src/permission/arity.ts
+export namespace BashArity {
+  export function prefix(tokens: string[]) { ... }
+}
 ```
 ```
 
 
-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
+### After
 
 
 ```ts
 ```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:
+// src/permission/arity.ts
+export function prefix(tokens: string[]) { ... }
 
 
-```
-# 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
+export * as BashArity from "./arity"
 ```
 ```
 
 
-And the code change is purely removing the wrapper:
+Consumers don't change at all:
 
 
 ```ts
 ```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) { ... }
+import { BashArity } from "@/permission/arity"
+BashArity.prefix(...) // still works
 ```
 ```
 
 
-```ts
-// NEW: provider/index.ts
-export * as Provider from "./provider"
-```
+Editors still auto-import `BashArity` like any named export, because the file
+does have a named `BashArity` export at the module top level.
 
 
-Consumer code barely changes — import path gets shorter:
+### Odd but harmless
 
 
-```ts
-// BEFORE
-import { Provider } from "../provider/provider"
+`BashArity.BashArity.BashArity.prefix(...)` compiles and runs because the
+namespace contains a re-export of itself. Nobody would write that. Not a
+problem.
 
 
-// AFTER — resolves to provider/index.ts, same Provider object
-import { Provider } from "../provider"
-```
+## Why this is different from what we tried first
 
 
-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.
+An earlier pass used sibling barrel files (`index.ts` with `export * as ...`).
+That turned out to be wrong for our constraints:
 
 
-**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.
+1. The barrel file always loads all its sibling modules when you import
+   through it, even if you only need one. For our CLI this is exactly the
+   cost we're trying to avoid.
+2. Barrel + sibling imports made it very easy to accidentally create circular
+   imports that only surface as `ReferenceError` at runtime, not at
+   typecheck.
 
 
-**Order of conversion** (by risk / size, do small modules first):
+The self-reexport has none of those issues. There is no indirection. The
+file and the namespace are the same unit.
 
 
-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`
+## Why this matters for startup
 
 
-### Phase 2: Build configuration
+The worst import chain in the codebase looks like:
 
 
-After the module structure supports tree-shaking:
+```
+src/index.ts
+  └── FormatError from src/cli/error.ts
+        ├── { Provider } from provider/provider.ts     (~1700 lines)
+        │     ├── 20+ @ai-sdk/* packages
+        │     ├── @aws-sdk/credential-providers
+        │     ├── google-auth-library
+        │     └── more
+        ├── { Config } from config/config.ts           (~1600 lines)
+        └── { MCP } from mcp/mcp.ts                    (~900 lines)
+```
 
 
-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.
+All of that currently gets pulled in just to do `.isInstance()` on a handful
+of error classes. The namespace IIFE shape is the main reason bundlers cannot
+strip the unused parts. Self-reexport + flat ESM fixes it.
 
 
 ## Automation
 ## Automation
 
 
-The transformation is scripted. From `packages/opencode`:
+From `packages/opencode`:
 
 
 ```bash
 ```bash
 bun script/unwrap-namespace.ts <file> [--dry-run]
 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:
+The script:
 
 
-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
+1. Uses ast-grep to locate the `export namespace Foo { ... }` block accurately.
+2. Removes the `export namespace Foo {` line and the matching closing `}`.
+3. Dedents the body by one indent level (2 spaces).
+4. Rewrites `Foo.Bar` self-references inside the file to just `Bar`.
+5. Appends `export * as Foo from "./<basename>"` at the bottom of the file.
+6. Never creates a barrel `index.ts`.
 
 
-### Walkthrough: converting a module
-
-Using `Provider` as an example:
+### Typical flow for one file
 
 
 ```bash
 ```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
+# 1. Preview
+bun script/unwrap-namespace.ts src/permission/arity.ts --dry-run
 
 
-# 3. Rewrite import paths (script prints the exact command)
-rg -l 'from.*provider/provider' src/ | xargs sed -i '' 's|provider/provider"|provider"|g'
+# 2. Apply
+bun script/unwrap-namespace.ts src/permission/arity.ts
 
 
-# 4. Verify
-bun typecheck
-bun run test
+# 3. Verify
+cd packages/opencode
+bunx --bun tsgo --noEmit
+bun run --conditions=browser ./src/index.ts generate
+bun run test <affected test files>
 ```
 ```
 
 
-**What changes on disk:**
+### Consumer imports usually don't need to change
 
 
-```
-# 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:**
+Most consumers already import straight from the file, e.g.:
 
 
 ```ts
 ```ts
-// BEFORE
-import { Provider } from "../provider/provider"
-
-// AFTER — shorter path, same Provider object
-import { Provider } from "../provider"
+import { BashArity } from "@/permission/arity"
+import { Config } from "@/config/config"
 ```
 ```
 
 
-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
+Because the file itself now does `export * as Foo from "./foo"`, those imports
+keep working with zero edits.
 
 
-| 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.
-
-### Rule 1: Sibling files never import through their own barrel
-
-Files in the same directory must import directly from the source file, never
-through `"."` or `"@/<own-dir>"`:
+The only edits needed are when a consumer was importing through a previous
+barrel (`"@/config"` or `"../config"` resolving to `config/index.ts`). In
+that case, repoint it at the file:
 
 
 ```ts
 ```ts
-// BAD — circular: index.ts re-exports both files, so A → index → B → index → A
-import { Sibling } from "."
+// before
+import { Config } from "@/config"
 
 
-// GOOD — direct, no cycle
-import * as Sibling from "./sibling"
+// after
+import { Config } from "@/config/config"
 ```
 ```
 
 
-### Rule 2: Cross-directory imports must not form cycles through barrels
+### Dynamic imports in tests
 
 
-If `src/lsp/lsp.ts` imports `Config` from `"../config"`, and
-`src/config/config.ts` imports `LSPServer` from `"../lsp"`, that's a cycle:
+If a test did `const { Foo } = await import("../../src/x/y")`, the destructure
+still works because of the self-reexport. No change required.
 
 
-```
-lsp/lsp.ts → config/index.ts → config/config.ts → lsp/index.ts → lsp/lsp.ts 💥
-```
+## Verification checklist (per PR)
 
 
-Fix by importing the specific file, breaking the cycle:
+Run all of these locally before pushing:
 
 
-```ts
-// In config/config.ts — import directly, not through the lsp barrel
-import * as LSPServer from "../lsp/server"
+```bash
+cd packages/opencode
+bunx --bun tsgo --noEmit
+bun run --conditions=browser ./src/index.ts generate
+bun run test <affected test files>
 ```
 ```
 
 
-### Why the type checker doesn't catch this
+Also do a quick grep in `src/`, `test/`, and `script/` to make sure no
+consumer is still importing the namespace from an old barrel path that no
+longer exports it.
 
 
-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.
+The SDK build step (`bun run --conditions=browser ./src/index.ts generate`)
+evaluates every module eagerly and is the most reliable way to catch circular
+import regressions at runtime — the typechecker does not catch these.
 
 
-### How to verify
+## Rules for new code
 
 
-After any namespace conversion, run:
+- No new `export namespace`.
+- Every module file that wants a namespace gets a self-reexport at the
+  bottom:
+  `export * as Foo from "./foo"`
+- Consumers import from the file itself:
+  `import { Foo } from "../path/to/foo"`
+- No new barrel `index.ts` files for internal code.
+- If a file needs a sibling, import the sibling file directly:
+  `import * as Sibling from "./sibling"`, not `from "."`.
 
 
-```bash
-cd packages/opencode
-bun run --conditions=browser ./src/index.ts generate
-```
+## Scope
 
 
-If this completes without `ReferenceError`, the module graph is safe.
+There are still dozens of `export namespace` files left across the codebase.
+Each one is its own small PR. Do them one at a time, verified locally, rather
+than batching by directory.

+ 148 - 148
packages/opencode/src/permission/arity.ts

@@ -1,15 +1,14 @@
-export namespace BashArity {
-  export function prefix(tokens: string[]) {
-    for (let len = tokens.length; len > 0; len--) {
-      const prefix = tokens.slice(0, len).join(" ")
-      const arity = ARITY[prefix]
-      if (arity !== undefined) return tokens.slice(0, arity)
-    }
-    if (tokens.length === 0) return []
-    return tokens.slice(0, 1)
+export function prefix(tokens: string[]) {
+  for (let len = tokens.length; len > 0; len--) {
+    const prefix = tokens.slice(0, len).join(" ")
+    const arity = ARITY[prefix]
+    if (arity !== undefined) return tokens.slice(0, arity)
   }
   }
+  if (tokens.length === 0) return []
+  return tokens.slice(0, 1)
+}
 
 
-  /* Generated with following prompt:
+/* Generated with following prompt:
 You are generating a dictionary of command-prefix arities for bash-style commands.
 You are generating a dictionary of command-prefix arities for bash-style commands.
 This dictionary is used to identify the "human-understandable command" from an input shell command.### **RULES (follow strictly)**1. Each entry maps a **command prefix string → number**, representing how many **tokens** define the command.
 This dictionary is used to identify the "human-understandable command" from an input shell command.### **RULES (follow strictly)**1. Each entry maps a **command prefix string → number**, representing how many **tokens** define the command.
 2. **Flags NEVER count as tokens**. Only subcommands count.
 2. **Flags NEVER count as tokens**. Only subcommands count.
@@ -22,142 +21,143 @@ This dictionary is used to identify the "human-understandable command" from an i
 * `npm run dev` → `npm run dev` (because `npm run` has arity 3)
 * `npm run dev` → `npm run dev` (because `npm run` has arity 3)
 * `python script.py` → `python script.py` (default: whole input, not in dictionary)### **Now generate the dictionary.**
 * `python script.py` → `python script.py` (default: whole input, not in dictionary)### **Now generate the dictionary.**
 */
 */
-  const ARITY: Record<string, number> = {
-    cat: 1, // cat file.txt
-    cd: 1, // cd /path/to/dir
-    chmod: 1, // chmod 755 script.sh
-    chown: 1, // chown user:group file.txt
-    cp: 1, // cp source.txt dest.txt
-    echo: 1, // echo "hello world"
-    env: 1, // env
-    export: 1, // export PATH=/usr/bin
-    grep: 1, // grep pattern file.txt
-    kill: 1, // kill 1234
-    killall: 1, // killall process
-    ln: 1, // ln -s source target
-    ls: 1, // ls -la
-    mkdir: 1, // mkdir new-dir
-    mv: 1, // mv old.txt new.txt
-    ps: 1, // ps aux
-    pwd: 1, // pwd
-    rm: 1, // rm file.txt
-    rmdir: 1, // rmdir empty-dir
-    sleep: 1, // sleep 5
-    source: 1, // source ~/.bashrc
-    tail: 1, // tail -f log.txt
-    touch: 1, // touch file.txt
-    unset: 1, // unset VAR
-    which: 1, // which node
-    aws: 3, // aws s3 ls
-    az: 3, // az storage blob list
-    bazel: 2, // bazel build
-    brew: 2, // brew install node
-    bun: 2, // bun install
-    "bun run": 3, // bun run dev
-    "bun x": 3, // bun x vite
-    cargo: 2, // cargo build
-    "cargo add": 3, // cargo add tokio
-    "cargo run": 3, // cargo run main
-    cdk: 2, // cdk deploy
-    cf: 2, // cf push app
-    cmake: 2, // cmake build
-    composer: 2, // composer require laravel
-    consul: 2, // consul members
-    "consul kv": 3, // consul kv get config/app
-    crictl: 2, // crictl ps
-    deno: 2, // deno run server.ts
-    "deno task": 3, // deno task dev
-    doctl: 3, // doctl kubernetes cluster list
-    docker: 2, // docker run nginx
-    "docker builder": 3, // docker builder prune
-    "docker compose": 3, // docker compose up
-    "docker container": 3, // docker container ls
-    "docker image": 3, // docker image prune
-    "docker network": 3, // docker network inspect
-    "docker volume": 3, // docker volume ls
-    eksctl: 2, // eksctl get clusters
-    "eksctl create": 3, // eksctl create cluster
-    firebase: 2, // firebase deploy
-    flyctl: 2, // flyctl deploy
-    gcloud: 3, // gcloud compute instances list
-    gh: 3, // gh pr list
-    git: 2, // git checkout main
-    "git config": 3, // git config user.name
-    "git remote": 3, // git remote add origin
-    "git stash": 3, // git stash pop
-    go: 2, // go build
-    gradle: 2, // gradle build
-    helm: 2, // helm install mychart
-    heroku: 2, // heroku logs
-    hugo: 2, // hugo new site blog
-    ip: 2, // ip link show
-    "ip addr": 3, // ip addr show
-    "ip link": 3, // ip link set eth0 up
-    "ip netns": 3, // ip netns exec foo bash
-    "ip route": 3, // ip route add default via 1.1.1.1
-    kind: 2, // kind delete cluster
-    "kind create": 3, // kind create cluster
-    kubectl: 2, // kubectl get pods
-    "kubectl kustomize": 3, // kubectl kustomize overlays/dev
-    "kubectl rollout": 3, // kubectl rollout restart deploy/api
-    kustomize: 2, // kustomize build .
-    make: 2, // make build
-    mc: 2, // mc ls myminio
-    "mc admin": 3, // mc admin info myminio
-    minikube: 2, // minikube start
-    mongosh: 2, // mongosh test
-    mysql: 2, // mysql -u root
-    mvn: 2, // mvn compile
-    ng: 2, // ng generate component home
-    npm: 2, // npm install
-    "npm exec": 3, // npm exec vite
-    "npm init": 3, // npm init vue
-    "npm run": 3, // npm run dev
-    "npm view": 3, // npm view react version
-    nvm: 2, // nvm use 18
-    nx: 2, // nx build
-    openssl: 2, // openssl genrsa 2048
-    "openssl req": 3, // openssl req -new -key key.pem
-    "openssl x509": 3, // openssl x509 -in cert.pem
-    pip: 2, // pip install numpy
-    pipenv: 2, // pipenv install flask
-    pnpm: 2, // pnpm install
-    "pnpm dlx": 3, // pnpm dlx create-next-app
-    "pnpm exec": 3, // pnpm exec vite
-    "pnpm run": 3, // pnpm run dev
-    poetry: 2, // poetry add requests
-    podman: 2, // podman run alpine
-    "podman container": 3, // podman container ls
-    "podman image": 3, // podman image prune
-    psql: 2, // psql -d mydb
-    pulumi: 2, // pulumi up
-    "pulumi stack": 3, // pulumi stack output
-    pyenv: 2, // pyenv install 3.11
-    python: 2, // python -m venv env
-    rake: 2, // rake db:migrate
-    rbenv: 2, // rbenv install 3.2.0
-    "redis-cli": 2, // redis-cli ping
-    rustup: 2, // rustup update
-    serverless: 2, // serverless invoke
-    sfdx: 3, // sfdx force:org:list
-    skaffold: 2, // skaffold dev
-    sls: 2, // sls deploy
-    sst: 2, // sst deploy
-    swift: 2, // swift build
-    systemctl: 2, // systemctl restart nginx
-    terraform: 2, // terraform apply
-    "terraform workspace": 3, // terraform workspace select prod
-    tmux: 2, // tmux new -s dev
-    turbo: 2, // turbo run build
-    ufw: 2, // ufw allow 22
-    vault: 2, // vault login
-    "vault auth": 3, // vault auth list
-    "vault kv": 3, // vault kv get secret/api
-    vercel: 2, // vercel deploy
-    volta: 2, // volta install node
-    wp: 2, // wp plugin install
-    yarn: 2, // yarn add react
-    "yarn dlx": 3, // yarn dlx create-react-app
-    "yarn run": 3, // yarn run dev
-  }
+const ARITY: Record<string, number> = {
+  cat: 1, // cat file.txt
+  cd: 1, // cd /path/to/dir
+  chmod: 1, // chmod 755 script.sh
+  chown: 1, // chown user:group file.txt
+  cp: 1, // cp source.txt dest.txt
+  echo: 1, // echo "hello world"
+  env: 1, // env
+  export: 1, // export PATH=/usr/bin
+  grep: 1, // grep pattern file.txt
+  kill: 1, // kill 1234
+  killall: 1, // killall process
+  ln: 1, // ln -s source target
+  ls: 1, // ls -la
+  mkdir: 1, // mkdir new-dir
+  mv: 1, // mv old.txt new.txt
+  ps: 1, // ps aux
+  pwd: 1, // pwd
+  rm: 1, // rm file.txt
+  rmdir: 1, // rmdir empty-dir
+  sleep: 1, // sleep 5
+  source: 1, // source ~/.bashrc
+  tail: 1, // tail -f log.txt
+  touch: 1, // touch file.txt
+  unset: 1, // unset VAR
+  which: 1, // which node
+  aws: 3, // aws s3 ls
+  az: 3, // az storage blob list
+  bazel: 2, // bazel build
+  brew: 2, // brew install node
+  bun: 2, // bun install
+  "bun run": 3, // bun run dev
+  "bun x": 3, // bun x vite
+  cargo: 2, // cargo build
+  "cargo add": 3, // cargo add tokio
+  "cargo run": 3, // cargo run main
+  cdk: 2, // cdk deploy
+  cf: 2, // cf push app
+  cmake: 2, // cmake build
+  composer: 2, // composer require laravel
+  consul: 2, // consul members
+  "consul kv": 3, // consul kv get config/app
+  crictl: 2, // crictl ps
+  deno: 2, // deno run server.ts
+  "deno task": 3, // deno task dev
+  doctl: 3, // doctl kubernetes cluster list
+  docker: 2, // docker run nginx
+  "docker builder": 3, // docker builder prune
+  "docker compose": 3, // docker compose up
+  "docker container": 3, // docker container ls
+  "docker image": 3, // docker image prune
+  "docker network": 3, // docker network inspect
+  "docker volume": 3, // docker volume ls
+  eksctl: 2, // eksctl get clusters
+  "eksctl create": 3, // eksctl create cluster
+  firebase: 2, // firebase deploy
+  flyctl: 2, // flyctl deploy
+  gcloud: 3, // gcloud compute instances list
+  gh: 3, // gh pr list
+  git: 2, // git checkout main
+  "git config": 3, // git config user.name
+  "git remote": 3, // git remote add origin
+  "git stash": 3, // git stash pop
+  go: 2, // go build
+  gradle: 2, // gradle build
+  helm: 2, // helm install mychart
+  heroku: 2, // heroku logs
+  hugo: 2, // hugo new site blog
+  ip: 2, // ip link show
+  "ip addr": 3, // ip addr show
+  "ip link": 3, // ip link set eth0 up
+  "ip netns": 3, // ip netns exec foo bash
+  "ip route": 3, // ip route add default via 1.1.1.1
+  kind: 2, // kind delete cluster
+  "kind create": 3, // kind create cluster
+  kubectl: 2, // kubectl get pods
+  "kubectl kustomize": 3, // kubectl kustomize overlays/dev
+  "kubectl rollout": 3, // kubectl rollout restart deploy/api
+  kustomize: 2, // kustomize build .
+  make: 2, // make build
+  mc: 2, // mc ls myminio
+  "mc admin": 3, // mc admin info myminio
+  minikube: 2, // minikube start
+  mongosh: 2, // mongosh test
+  mysql: 2, // mysql -u root
+  mvn: 2, // mvn compile
+  ng: 2, // ng generate component home
+  npm: 2, // npm install
+  "npm exec": 3, // npm exec vite
+  "npm init": 3, // npm init vue
+  "npm run": 3, // npm run dev
+  "npm view": 3, // npm view react version
+  nvm: 2, // nvm use 18
+  nx: 2, // nx build
+  openssl: 2, // openssl genrsa 2048
+  "openssl req": 3, // openssl req -new -key key.pem
+  "openssl x509": 3, // openssl x509 -in cert.pem
+  pip: 2, // pip install numpy
+  pipenv: 2, // pipenv install flask
+  pnpm: 2, // pnpm install
+  "pnpm dlx": 3, // pnpm dlx create-next-app
+  "pnpm exec": 3, // pnpm exec vite
+  "pnpm run": 3, // pnpm run dev
+  poetry: 2, // poetry add requests
+  podman: 2, // podman run alpine
+  "podman container": 3, // podman container ls
+  "podman image": 3, // podman image prune
+  psql: 2, // psql -d mydb
+  pulumi: 2, // pulumi up
+  "pulumi stack": 3, // pulumi stack output
+  pyenv: 2, // pyenv install 3.11
+  python: 2, // python -m venv env
+  rake: 2, // rake db:migrate
+  rbenv: 2, // rbenv install 3.2.0
+  "redis-cli": 2, // redis-cli ping
+  rustup: 2, // rustup update
+  serverless: 2, // serverless invoke
+  sfdx: 3, // sfdx force:org:list
+  skaffold: 2, // skaffold dev
+  sls: 2, // sls deploy
+  sst: 2, // sst deploy
+  swift: 2, // swift build
+  systemctl: 2, // systemctl restart nginx
+  terraform: 2, // terraform apply
+  "terraform workspace": 3, // terraform workspace select prod
+  tmux: 2, // tmux new -s dev
+  turbo: 2, // turbo run build
+  ufw: 2, // ufw allow 22
+  vault: 2, // vault login
+  "vault auth": 3, // vault auth list
+  "vault kv": 3, // vault kv get secret/api
+  vercel: 2, // vercel deploy
+  volta: 2, // volta install node
+  wp: 2, // wp plugin install
+  yarn: 2, // yarn add react
+  "yarn dlx": 3, // yarn dlx create-react-app
+  "yarn run": 3, // yarn run dev
 }
 }
+
+export * as BashArity from "./arity"