summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-16 22:48:40 -0400
committerGitHub <[email protected]>2026-04-17 02:48:40 +0000
commitc51f3e35cabb5cbb49d4fddc240b880c58286a97 (patch)
tree3765757c543de54557dec12cff7642c4a802818e
parent7b3bb9a76181c478553b9319b734354f988cdac3 (diff)
downloadopencode-c51f3e35cabb5cbb49d4fddc240b880c58286a97.tar.gz
opencode-c51f3e35cabb5cbb49d4fddc240b880c58286a97.zip
chore: retire namespace migration tooling + document module shape (#23010)
-rw-r--r--packages/opencode/AGENTS.md57
-rw-r--r--packages/opencode/script/batch-unwrap-pr.ts230
-rw-r--r--packages/opencode/script/collapse-barrel.ts161
-rw-r--r--packages/opencode/script/unwrap-and-self-reexport.ts246
-rw-r--r--packages/opencode/script/unwrap-namespace.ts305
-rw-r--r--packages/opencode/specs/effect/namespace-treeshake.md256
6 files changed, 57 insertions, 1198 deletions
diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md
index 761b9b5c5..d7fb844f0 100644
--- a/packages/opencode/AGENTS.md
+++ b/packages/opencode/AGENTS.md
@@ -9,6 +9,63 @@
- **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
- **Tests**: migration tests should read the per-folder layout (no `_journal.json`).
+# Module shape
+
+Do not use `export namespace Foo { ... }` for module organization. It is not
+standard ESM, it prevents tree-shaking, and it breaks Node's native TypeScript
+runner. Use flat top-level exports combined with a self-reexport at the bottom
+of the file:
+
+```ts
+// src/foo/foo.ts
+export interface Interface { ... }
+export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
+export const layer = Layer.effect(Service, ...)
+export const defaultLayer = layer.pipe(...)
+
+export * as Foo from "./foo"
+```
+
+Consumers import the namespace projection:
+
+```ts
+import { Foo } from "@/foo/foo"
+
+yield * Foo.Service
+Foo.layer
+Foo.defaultLayer
+```
+
+Namespace-private helpers stay as non-exported top-level declarations in the
+same file — they remain inaccessible to consumers (they are not projected by
+`export * as`) but are usable by the file's own code.
+
+## When the file is an `index.ts`
+
+If the module is `foo/index.ts` (single-namespace directory), use `"."` for
+the self-reexport source rather than `"./index"`:
+
+```ts
+// src/foo/index.ts
+export const thing = ...
+
+export * as Foo from "."
+```
+
+## Multi-sibling directories
+
+For directories with several independent modules (e.g. `src/session/`,
+`src/config/`), keep each sibling as its own file with its own self-reexport,
+and do not add a barrel `index.ts`. Consumers import the specific sibling:
+
+```ts
+import { SessionRetry } from "@/session/retry"
+import { SessionStatus } from "@/session/status"
+```
+
+Barrels in multi-sibling directories force every import through the barrel to
+evaluate every sibling, which defeats tree-shaking and slows module load.
+
# opencode Effect rules
Use these rules when writing or migrating Effect code.
diff --git a/packages/opencode/script/batch-unwrap-pr.ts b/packages/opencode/script/batch-unwrap-pr.ts
deleted file mode 100644
index 573050141..000000000
--- a/packages/opencode/script/batch-unwrap-pr.ts
+++ /dev/null
@@ -1,230 +0,0 @@
-#!/usr/bin/env bun
-/**
- * Automate the full per-file namespace→self-reexport migration:
- *
- * 1. Create a worktree at ../opencode-worktrees/ns-<slug> on a new branch
- * `kit/ns-<slug>` off `origin/dev`.
- * 2. Symlink `node_modules` from the main repo into the worktree root so
- * builds work without a fresh `bun install`.
- * 3. Run `script/unwrap-and-self-reexport.ts` on the target file inside the worktree.
- * 4. Verify:
- * - `bunx --bun tsgo --noEmit` (pre-existing plugin.ts cross-worktree
- * noise ignored — we compare against a pre-change baseline captured
- * via `git stash`, so only NEW errors fail).
- * - `bun run --conditions=browser ./src/index.ts generate`.
- * - Relevant tests under `test/<dir>` if that directory exists.
- * 5. Commit, push with `--no-verify`, and open a PR titled after the
- * namespace.
- *
- * Usage:
- *
- * bun script/batch-unwrap-pr.ts src/file/ignore.ts
- * bun script/batch-unwrap-pr.ts src/file/ignore.ts src/file/watcher.ts # multiple
- * bun script/batch-unwrap-pr.ts --dry-run src/file/ignore.ts # plan only
- *
- * Repo assumptions:
- *
- * - Main checkout at /Users/kit/code/open-source/opencode (configurable via
- * --repo-root=...).
- * - Worktree root at /Users/kit/code/open-source/opencode-worktrees
- * (configurable via --worktree-root=...).
- *
- * The script does NOT enable auto-merge; that's a separate manual step if we
- * want it.
- */
-
-import fs from "node:fs"
-import path from "node:path"
-import { spawnSync, type SpawnSyncReturns } from "node:child_process"
-
-type Cmd = string[]
-
-function run(
- cwd: string,
- cmd: Cmd,
- opts: { capture?: boolean; allowFail?: boolean; stdin?: string } = {},
-): SpawnSyncReturns<string> {
- const result = spawnSync(cmd[0], cmd.slice(1), {
- cwd,
- stdio: opts.capture ? ["pipe", "pipe", "pipe"] : ["inherit", "inherit", "inherit"],
- encoding: "utf-8",
- input: opts.stdin,
- })
- if (!opts.allowFail && result.status !== 0) {
- const label = `${path.basename(cmd[0])} ${cmd.slice(1).join(" ")}`
- console.error(`[fail] ${label} (cwd=${cwd})`)
- if (opts.capture) {
- if (result.stdout) console.error(result.stdout)
- if (result.stderr) console.error(result.stderr)
- }
- process.exit(result.status ?? 1)
- }
- return result
-}
-
-function fileSlug(fileArg: string): string {
- // src/file/ignore.ts → file-ignore
- return fileArg
- .replace(/^src\//, "")
- .replace(/\.tsx?$/, "")
- .replace(/[\/_]/g, "-")
-}
-
-function readNamespace(absFile: string): string {
- const content = fs.readFileSync(absFile, "utf-8")
- const match = content.match(/^export\s+namespace\s+(\w+)\s*\{/m)
- if (!match) {
- console.error(`no \`export namespace\` found in ${absFile}`)
- process.exit(1)
- }
- return match[1]
-}
-
-// ---------------------------------------------------------------------------
-
-const args = process.argv.slice(2)
-const dryRun = args.includes("--dry-run")
-const repoRoot = (
- args.find((a) => a.startsWith("--repo-root=")) ?? "--repo-root=/Users/kit/code/open-source/opencode"
-).split("=")[1]
-const worktreeRoot = (
- args.find((a) => a.startsWith("--worktree-root=")) ?? "--worktree-root=/Users/kit/code/open-source/opencode-worktrees"
-).split("=")[1]
-const targets = args.filter((a) => !a.startsWith("--"))
-
-if (targets.length === 0) {
- console.error("Usage: bun script/batch-unwrap-pr.ts <src/path.ts> [more files...] [--dry-run]")
- process.exit(1)
-}
-
-if (!fs.existsSync(worktreeRoot)) fs.mkdirSync(worktreeRoot, { recursive: true })
-
-for (const rel of targets) {
- const absSrc = path.join(repoRoot, "packages", "opencode", rel)
- if (!fs.existsSync(absSrc)) {
- console.error(`skip ${rel}: file does not exist under ${repoRoot}/packages/opencode`)
- continue
- }
- const slug = fileSlug(rel)
- const branch = `kit/ns-${slug}`
- const wt = path.join(worktreeRoot, `ns-${slug}`)
- const ns = readNamespace(absSrc)
-
- console.log(`\n=== ${rel} → ${ns} (branch=${branch} wt=${path.basename(wt)}) ===`)
-
- if (dryRun) {
- console.log(` would create worktree ${wt}`)
- console.log(` would run unwrap on packages/opencode/${rel}`)
- console.log(` would commit, push, and open PR`)
- continue
- }
-
- // Sync dev (fetch only; we branch off origin/dev directly).
- run(repoRoot, ["git", "fetch", "origin", "dev", "--quiet"])
-
- // Create worktree + branch.
- if (fs.existsSync(wt)) {
- console.log(` worktree already exists at ${wt}; skipping`)
- continue
- }
- run(repoRoot, ["git", "worktree", "add", "-b", branch, wt, "origin/dev"])
-
- // Symlink node_modules so bun/tsgo work without a full install.
- // We link both the repo root and packages/opencode, since the opencode
- // package has its own local node_modules (including bunfig.toml preload deps
- // like @opentui/solid) that aren't hoisted to the root.
- const wtRootNodeModules = path.join(wt, "node_modules")
- if (!fs.existsSync(wtRootNodeModules)) {
- fs.symlinkSync(path.join(repoRoot, "node_modules"), wtRootNodeModules)
- }
- const wtOpencode = path.join(wt, "packages", "opencode")
- const wtOpencodeNodeModules = path.join(wtOpencode, "node_modules")
- if (!fs.existsSync(wtOpencodeNodeModules)) {
- fs.symlinkSync(path.join(repoRoot, "packages", "opencode", "node_modules"), wtOpencodeNodeModules)
- }
- const wtTarget = path.join(wt, "packages", "opencode", rel)
-
- // Baseline tsgo output (pre-change).
- const baselinePath = path.join(wt, ".ns-baseline.txt")
- const baseline = run(wtOpencode, ["bunx", "--bun", "tsgo", "--noEmit"], { capture: true, allowFail: true })
- fs.writeFileSync(baselinePath, (baseline.stdout ?? "") + (baseline.stderr ?? ""))
-
- // Run the unwrap script from the MAIN repo checkout (where the tooling
- // lives) targeting the worktree's file by absolute path. We run from the
- // worktree root (not `packages/opencode`) to avoid triggering the
- // bunfig.toml preload, which needs `@opentui/solid` that only the TUI
- // workspace has installed.
- const unwrapScript = path.join(repoRoot, "packages", "opencode", "script", "unwrap-and-self-reexport.ts")
- run(wt, ["bun", unwrapScript, wtTarget])
-
- // Post-change tsgo.
- const after = run(wtOpencode, ["bunx", "--bun", "tsgo", "--noEmit"], { capture: true, allowFail: true })
- const afterText = (after.stdout ?? "") + (after.stderr ?? "")
-
- // Compare line-sets to detect NEW tsgo errors.
- const sanitize = (s: string) =>
- s
- .split("\n")
- .map((l) => l.replace(/\s+$/, ""))
- .filter(Boolean)
- .sort()
- .join("\n")
- const baselineSorted = sanitize(fs.readFileSync(baselinePath, "utf-8"))
- const afterSorted = sanitize(afterText)
- if (baselineSorted !== afterSorted) {
- console.log(` tsgo output differs from baseline. Showing diff:`)
- const diffResult = spawnSync("diff", ["-u", baselinePath, "-"], { input: afterText, encoding: "utf-8" })
- if (diffResult.stdout) console.log(diffResult.stdout)
- if (diffResult.stderr) console.log(diffResult.stderr)
- console.error(` aborting ${rel}; investigate manually in ${wt}`)
- process.exit(1)
- }
-
- // SDK build.
- run(wtOpencode, ["bun", "run", "--conditions=browser", "./src/index.ts", "generate"], { capture: true })
-
- // Run tests for the directory, if a matching test dir exists.
- const dirName = path.basename(path.dirname(rel))
- const testDir = path.join(wt, "packages", "opencode", "test", dirName)
- if (fs.existsSync(testDir)) {
- const testResult = run(wtOpencode, ["bun", "run", "test", `test/${dirName}`], { capture: true, allowFail: true })
- const combined = (testResult.stdout ?? "") + (testResult.stderr ?? "")
- if (testResult.status !== 0) {
- console.error(combined)
- console.error(` tests failed for ${rel}; aborting`)
- process.exit(1)
- }
- // Surface the summary line if present.
- const summary = combined
- .split("\n")
- .filter((l) => /\bpass\b|\bfail\b/.test(l))
- .slice(-3)
- .join("\n")
- if (summary) console.log(` tests: ${summary.replace(/\n/g, " | ")}`)
- } else {
- console.log(` tests: no test/${dirName} directory, skipping`)
- }
-
- // Clean up baseline file before committing.
- fs.unlinkSync(baselinePath)
-
- // Commit, push, open PR.
- const commitMsg = `refactor: unwrap ${ns} namespace + self-reexport`
- run(wt, ["git", "add", "-A"])
- run(wt, ["git", "commit", "-m", commitMsg])
- run(wt, ["git", "push", "-u", "origin", branch, "--no-verify"])
-
- const prBody = [
- "## Summary",
- `- Unwrap the \`${ns}\` namespace in \`packages/opencode/${rel}\` to flat top-level exports.`,
- `- Append \`export * as ${ns} from "./${path.basename(rel, ".ts")}"\` so consumers keep the same \`${ns}.x\` import ergonomics.`,
- "",
- "## Verification (local)",
- "- `bunx --bun tsgo --noEmit` — no new errors vs baseline.",
- "- `bun run --conditions=browser ./src/index.ts generate` — clean.",
- `- \`bun run test test/${dirName}\` — all pass (if applicable).`,
- ].join("\n")
- run(wt, ["gh", "pr", "create", "--title", commitMsg, "--base", "dev", "--body", prBody])
-
- console.log(` PR opened for ${rel}`)
-}
diff --git a/packages/opencode/script/collapse-barrel.ts b/packages/opencode/script/collapse-barrel.ts
deleted file mode 100644
index 05bb11589..000000000
--- a/packages/opencode/script/collapse-barrel.ts
+++ /dev/null
@@ -1,161 +0,0 @@
-#!/usr/bin/env bun
-/**
- * Collapse a single-namespace barrel directory into a dir/index.ts module.
- *
- * Given a directory `src/foo/` that contains:
- *
- * - `index.ts` (exactly `export * as Foo from "./foo"`)
- * - `foo.ts` (the real implementation)
- * - zero or more sibling files
- *
- * this script:
- *
- * 1. Deletes the old `index.ts` barrel.
- * 2. `git mv`s `foo.ts` → `index.ts` so the implementation IS the directory entry.
- * 3. Appends `export * as Foo from "."` to the new `index.ts`.
- * 4. Rewrites any same-directory sibling `*.ts` files that imported
- * `./foo` (with or without the namespace name) to import `"."` instead.
- *
- * Consumer files outside the directory keep importing from the directory
- * (`"@/foo"` / `"../foo"` / etc.) and continue to work, because
- * `dir/index.ts` now provides the `Foo` named export directly.
- *
- * Usage:
- *
- * bun script/collapse-barrel.ts src/bus
- * bun script/collapse-barrel.ts src/bus --dry-run
- *
- * Notes:
- *
- * - Only works on directories whose barrel is a single
- * `export * as Name from "./file"` line. Refuses otherwise.
- * - Refuses if the implementation file name already conflicts with
- * `index.ts`.
- * - Safe to run repeatedly: a second run on an already-collapsed dir
- * will exit with a clear message.
- */
-
-import fs from "node:fs"
-import path from "node:path"
-import { spawnSync } from "node:child_process"
-
-const args = process.argv.slice(2)
-const dryRun = args.includes("--dry-run")
-const targetArg = args.find((a) => !a.startsWith("--"))
-
-if (!targetArg) {
- console.error("Usage: bun script/collapse-barrel.ts <dir> [--dry-run]")
- process.exit(1)
-}
-
-const dir = path.resolve(targetArg)
-const indexPath = path.join(dir, "index.ts")
-
-if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
- console.error(`Not a directory: ${dir}`)
- process.exit(1)
-}
-if (!fs.existsSync(indexPath)) {
- console.error(`No index.ts in ${dir}`)
- process.exit(1)
-}
-
-// Validate barrel shape.
-const indexContent = fs.readFileSync(indexPath, "utf-8").trim()
-const match = indexContent.match(/^export\s+\*\s+as\s+(\w+)\s+from\s+["']\.\/([^"']+)["']\s*;?\s*$/)
-if (!match) {
- console.error(`Not a simple single-namespace barrel:\n${indexContent}`)
- process.exit(1)
-}
-const namespaceName = match[1]
-const implRel = match[2].replace(/\.ts$/, "")
-const implPath = path.join(dir, `${implRel}.ts`)
-
-if (!fs.existsSync(implPath)) {
- console.error(`Implementation file not found: ${implPath}`)
- process.exit(1)
-}
-
-if (implRel === "index") {
- console.error(`Nothing to do — impl file is already index.ts`)
- process.exit(0)
-}
-
-console.log(`Collapsing ${path.relative(process.cwd(), dir)}`)
-console.log(` namespace: ${namespaceName}`)
-console.log(` impl file: ${implRel}.ts → index.ts`)
-
-// Figure out which sibling files need rewriting.
-const siblings = fs
- .readdirSync(dir)
- .filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"))
- .filter((f) => f !== "index.ts" && f !== `${implRel}.ts`)
- .map((f) => path.join(dir, f))
-
-type SiblingEdit = { file: string; content: string }
-const siblingEdits: SiblingEdit[] = []
-
-for (const sibling of siblings) {
- const content = fs.readFileSync(sibling, "utf-8")
- // Match any import or re-export referring to "./<implRel>" inside this directory.
- const siblingRegex = new RegExp(`(from\\s*["'])\\.\\/${implRel.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&")}(["'])`, "g")
- if (!siblingRegex.test(content)) continue
- const updated = content.replace(siblingRegex, `$1.$2`)
- siblingEdits.push({ file: sibling, content: updated })
-}
-
-if (siblingEdits.length > 0) {
- console.log(` sibling rewrites: ${siblingEdits.length}`)
- for (const edit of siblingEdits) {
- console.log(` ${path.relative(process.cwd(), edit.file)}`)
- }
-} else {
- console.log(` sibling rewrites: none`)
-}
-
-if (dryRun) {
- console.log(`\n(dry run) would:`)
- console.log(` - delete ${path.relative(process.cwd(), indexPath)}`)
- console.log(` - git mv ${path.relative(process.cwd(), implPath)} ${path.relative(process.cwd(), indexPath)}`)
- console.log(` - append \`export * as ${namespaceName} from "."\` to the new index.ts`)
- for (const edit of siblingEdits) {
- console.log(` - rewrite sibling: ${path.relative(process.cwd(), edit.file)}`)
- }
- process.exit(0)
-}
-
-// Apply: remove the old barrel, git-mv the impl onto it, then rewrite content.
-// We can't git-mv on top of an existing tracked file, so we remove the barrel first.
-function runGit(...cmd: string[]) {
- const res = spawnSync("git", cmd, { stdio: "inherit" })
- if (res.status !== 0) {
- console.error(`git ${cmd.join(" ")} failed`)
- process.exit(res.status ?? 1)
- }
-}
-
-// Step 1: remove the barrel
-runGit("rm", "-f", indexPath)
-
-// Step 2: rename the impl file into index.ts
-runGit("mv", implPath, indexPath)
-
-// Step 3: append the self-reexport to the new index.ts
-const newContent = fs.readFileSync(indexPath, "utf-8")
-const trimmed = newContent.endsWith("\n") ? newContent : newContent + "\n"
-fs.writeFileSync(indexPath, `${trimmed}\nexport * as ${namespaceName} from "."\n`)
-console.log(` appended: export * as ${namespaceName} from "."`)
-
-// Step 4: rewrite siblings
-for (const edit of siblingEdits) {
- fs.writeFileSync(edit.file, edit.content)
-}
-if (siblingEdits.length > 0) {
- console.log(` rewrote ${siblingEdits.length} sibling file(s)`)
-}
-
-console.log(`\nDone. Verify with:`)
-console.log(` cd packages/opencode`)
-console.log(` bunx --bun tsgo --noEmit`)
-console.log(` bun run --conditions=browser ./src/index.ts generate`)
-console.log(` bun run test`)
diff --git a/packages/opencode/script/unwrap-and-self-reexport.ts b/packages/opencode/script/unwrap-and-self-reexport.ts
deleted file mode 100644
index 09256f3a5..000000000
--- a/packages/opencode/script/unwrap-and-self-reexport.ts
+++ /dev/null
@@ -1,246 +0,0 @@
-#!/usr/bin/env bun
-/**
- * Unwrap a single `export namespace` in a file into flat top-level exports
- * plus a self-reexport at the bottom of the same file.
- *
- * Usage:
- *
- * bun script/unwrap-and-self-reexport.ts src/file/ignore.ts
- * bun script/unwrap-and-self-reexport.ts src/file/ignore.ts --dry-run
- *
- * Input file shape:
- *
- * // imports ...
- *
- * export namespace FileIgnore {
- * export function ...(...) { ... }
- * const helper = ...
- * }
- *
- * Output shape:
- *
- * // imports ...
- *
- * export function ...(...) { ... }
- * const helper = ...
- *
- * export * as FileIgnore from "./ignore"
- *
- * What the script does:
- *
- * 1. Uses ast-grep to locate the single `export namespace Foo { ... }` block.
- * 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`
- * (but only for names that are actually exported from the namespace —
- * non-exported members get the same treatment so references remain valid).
- * 5. Appends `export * as Foo from "./<basename>"` at the end of the file.
- *
- * What it does NOT do:
- *
- * - Does not create or modify barrel `index.ts` files.
- * - Does not rewrite any consumer imports. Consumers already import from
- * the file path itself (e.g. `import { FileIgnore } from "../file/ignore"`);
- * the self-reexport keeps that import working unchanged.
- * - Does not handle files with more than one `export namespace` declaration.
- * The script refuses that case.
- *
- * Requires: ast-grep (`brew install ast-grep`).
- */
-
-import fs from "node:fs"
-import path from "node:path"
-
-const args = process.argv.slice(2)
-const dryRun = args.includes("--dry-run")
-const targetArg = args.find((a) => !a.startsWith("--"))
-
-if (!targetArg) {
- console.error("Usage: bun script/unwrap-and-self-reexport.ts <file> [--dry-run]")
- process.exit(1)
-}
-
-const absPath = path.resolve(targetArg)
-if (!fs.existsSync(absPath) || !fs.statSync(absPath).isFile()) {
- console.error(`Not a file: ${absPath}`)
- process.exit(1)
-}
-
-// Locate the namespace block with ast-grep (accurate AST boundaries).
-const ast = Bun.spawnSync(
- ["ast-grep", "run", "--pattern", "export namespace $NAME { $$$BODY }", "--lang", "typescript", "--json", absPath],
- { stdout: "pipe", stderr: "pipe" },
-)
-if (ast.exitCode !== 0) {
- console.error("ast-grep failed:", ast.stderr.toString())
- process.exit(1)
-}
-
-type AstMatch = {
- range: { start: { line: number; column: number }; end: { line: number; column: number } }
- metaVariables: { single: Record<string, { text: string }> }
-}
-const matches = JSON.parse(ast.stdout.toString()) as AstMatch[]
-if (matches.length === 0) {
- console.error(`No \`export namespace\` found in ${path.relative(process.cwd(), absPath)}`)
- process.exit(1)
-}
-if (matches.length > 1) {
- console.error(`File has ${matches.length} \`export namespace\` declarations — this script handles one per file.`)
- 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 startLine = match.range.start.line
-const endLine = match.range.end.line
-
-const original = fs.readFileSync(absPath, "utf-8")
-const lines = original.split("\n")
-
-// Split the file into before/body/after.
-const before = lines.slice(0, startLine)
-const body = lines.slice(startLine + 1, endLine)
-const after = lines.slice(endLine + 1)
-
-// Dedent body by one indent level (2 spaces).
-const dedented = body.map((line) => {
- if (line === "") return ""
- if (line.startsWith(" ")) return line.slice(2)
- return line
-})
-
-// Collect all top-level declared identifiers inside the namespace body so we can
-// rewrite `Foo.X` → `X` when X is one of them. We gather BOTH exported and
-// non-exported names because the namespace body might reference its own
-// non-exported helpers via `Foo.helper` too.
-const declaredNames = new Set<string>()
-const declRe =
- /^\s*(?:export\s+)?(?:abstract\s+)?(?:async\s+)?(?:const|let|var|function|class|interface|type|enum)\s+(\w+)/
-for (const line of dedented) {
- const m = line.match(declRe)
- if (m) declaredNames.add(m[1])
-}
-// Also capture `export { X, Y }` re-exports inside the namespace.
-const reExportRe = /export\s*\{\s*([^}]+)\}/g
-for (const line of dedented) {
- for (const reExport of line.matchAll(reExportRe)) {
- for (const part of reExport[1].split(",")) {
- const name = part
- .trim()
- .split(/\s+as\s+/)
- .pop()!
- .trim()
- if (name) declaredNames.add(name)
- }
- }
-}
-
-// Rewrite `Foo.X` → `X` inside the body, avoiding matches in strings, comments,
-// templates. We walk the line char-by-char rather than using a regex so we can
-// skip over those segments cleanly.
-let rewriteCount = 0
-function rewriteLine(line: string): string {
- const out: string[] = []
- let i = 0
- let stringQuote: string | null = null
- while (i < line.length) {
- const ch = line[i]
- // String / template literal pass-through.
- if (stringQuote) {
- out.push(ch)
- if (ch === "\\" && i + 1 < line.length) {
- out.push(line[i + 1])
- i += 2
- continue
- }
- if (ch === stringQuote) stringQuote = null
- i++
- continue
- }
- if (ch === '"' || ch === "'" || ch === "`") {
- stringQuote = ch
- out.push(ch)
- i++
- continue
- }
- // Line comment: emit the rest of the line untouched.
- if (ch === "/" && line[i + 1] === "/") {
- out.push(line.slice(i))
- i = line.length
- continue
- }
- // Block comment: emit until "*/" if present on same line; else rest of line.
- if (ch === "/" && line[i + 1] === "*") {
- const end = line.indexOf("*/", i + 2)
- if (end === -1) {
- out.push(line.slice(i))
- i = line.length
- } else {
- out.push(line.slice(i, end + 2))
- i = end + 2
- }
- continue
- }
- // Try to match `Foo.<identifier>` at this position.
- if (line.startsWith(nsName + ".", i)) {
- // Make sure the char before is NOT a word character (otherwise we'd be in the middle of another identifier).
- const prev = i === 0 ? "" : line[i - 1]
- if (!/\w/.test(prev)) {
- const after = line.slice(i + nsName.length + 1)
- const nameMatch = after.match(/^([A-Za-z_$][\w$]*)/)
- if (nameMatch && declaredNames.has(nameMatch[1])) {
- out.push(nameMatch[1])
- i += nsName.length + 1 + nameMatch[1].length
- rewriteCount++
- continue
- }
- }
- }
- out.push(ch)
- i++
- }
- return out.join("")
-}
-const rewrittenBody = dedented.map(rewriteLine)
-
-// Assemble the new file. Collapse multiple trailing blank lines so the
-// self-reexport sits cleanly at the end.
-//
-// When the file is itself `index.ts`, prefer `"."` over `"./index"` — both are
-// valid but `"."` matches the existing convention in the codebase (e.g.
-// pty/index.ts, file/index.ts, etc.) and avoids referencing "index" literally.
-const basename = path.basename(absPath, ".ts")
-const reexportSource = basename === "index" ? "." : `./${basename}`
-const assembled = [...before, ...rewrittenBody, ...after].join("\n")
-const trimmed = assembled.replace(/\s+$/g, "")
-const output = `${trimmed}\n\nexport * as ${nsName} from "${reexportSource}"\n`
-
-if (dryRun) {
- console.log(`--- dry run: ${path.relative(process.cwd(), absPath)} ---`)
- console.log(`namespace: ${nsName}`)
- console.log(`body lines: ${body.length}`)
- console.log(`declared names: ${Array.from(declaredNames).join(", ") || "(none)"}`)
- console.log(`self-refs rewr: ${rewriteCount}`)
- console.log(`self-reexport: export * as ${nsName} from "${reexportSource}"`)
- console.log(`output preview (last 10 lines):`)
- const outputLines = output.split("\n")
- for (const l of outputLines.slice(Math.max(0, outputLines.length - 10))) {
- console.log(` ${l}`)
- }
- process.exit(0)
-}
-
-fs.writeFileSync(absPath, output)
-console.log(`unwrapped ${path.relative(process.cwd(), absPath)} → ${nsName}`)
-console.log(` body lines: ${body.length}`)
-console.log(` self-refs rewr: ${rewriteCount}`)
-console.log(` self-reexport: export * as ${nsName} from "${reexportSource}"`)
-console.log("")
-console.log("Next: verify with")
-console.log(" bunx --bun tsgo --noEmit")
-console.log(" bun run --conditions=browser ./src/index.ts generate")
-console.log(
- ` bun run test test/${path.relative(path.join(path.dirname(absPath), "..", ".."), absPath).replace(/\.ts$/, "")}*`,
-)
diff --git a/packages/opencode/script/unwrap-namespace.ts b/packages/opencode/script/unwrap-namespace.ts
deleted file mode 100644
index 45c16f6c7..000000000
--- a/packages/opencode/script/unwrap-namespace.ts
+++ /dev/null
@@ -1,305 +0,0 @@
-#!/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
- * bun script/unwrap-namespace.ts src/pty/index.ts --name service # avoid collision with pty.ts
- *
- * What it does:
- * 1. Reads the file and finds the `export namespace Foo { ... }` block
- * (uses ast-grep for accurate AST-based boundary detection)
- * 2. Removes the namespace wrapper and dedents the body
- * 3. Fixes self-references (e.g. Config.PermissionAction → PermissionAction)
- * 4. If the file is index.ts, renames it to <lowercase-name>.ts
- * 5. Creates/updates index.ts with `export * as Foo from "./<file>"`
- * 6. Rewrites import paths across src/, test/, and script/
- * 7. Fixes sibling imports within the same directory
- *
- * Requires: ast-grep (`brew install ast-grep` or `cargo install ast-grep`)
- */
-
-import path from "path"
-import fs from "fs"
-
-const args = process.argv.slice(2)
-const dryRun = args.includes("--dry-run")
-const nameFlag = args.find((a, i) => args[i - 1] === "--name")
-const filePath = args.find((a) => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--name")
-
-if (!filePath) {
- console.error("Usage: bun script/unwrap-namespace.ts <file> [--dry-run] [--name <impl-name>]")
- 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
-})
-
-let newContent = [...before, ...dedented, ...after].join("\n")
-
-// --- Fix self-references ---
-// After unwrapping, references like `Config.PermissionAction` inside the same file
-// need to become just `PermissionAction`. Only fix code positions, not strings.
-const exportedNames = new Set<string>()
-const exportRegex = /export\s+(?:const|function|class|interface|type|enum|abstract\s+class)\s+(\w+)/g
-for (const line of dedented) {
- for (const m of line.matchAll(exportRegex)) exportedNames.add(m[1])
-}
-const reExportRegex = /export\s*\{\s*([^}]+)\}/g
-for (const line of dedented) {
- for (const m of line.matchAll(reExportRegex)) {
- for (const name of m[1].split(",")) {
- const trimmed = name
- .trim()
- .split(/\s+as\s+/)
- .pop()!
- .trim()
- if (trimmed) exportedNames.add(trimmed)
- }
- }
-}
-
-let selfRefCount = 0
-if (exportedNames.size > 0) {
- const fixedLines = newContent.split("\n").map((line) => {
- // Split line into string-literal and code segments to avoid replacing inside strings
- const segments: Array<{ text: string; isString: boolean }> = []
- let i = 0
- let current = ""
- let inString: string | null = null
-
- while (i < line.length) {
- const ch = line[i]
- if (inString) {
- current += ch
- if (ch === "\\" && i + 1 < line.length) {
- current += line[i + 1]
- i += 2
- continue
- }
- if (ch === inString) {
- segments.push({ text: current, isString: true })
- current = ""
- inString = null
- }
- i++
- continue
- }
- if (ch === '"' || ch === "'" || ch === "`") {
- if (current) segments.push({ text: current, isString: false })
- current = ch
- inString = ch
- i++
- continue
- }
- if (ch === "/" && i + 1 < line.length && line[i + 1] === "/") {
- current += line.slice(i)
- segments.push({ text: current, isString: true })
- current = ""
- i = line.length
- continue
- }
- current += ch
- i++
- }
- if (current) segments.push({ text: current, isString: !!inString })
-
- return segments
- .map((seg) => {
- if (seg.isString) return seg.text
- let result = seg.text
- for (const name of exportedNames) {
- const pattern = `${nsName}.${name}`
- while (result.includes(pattern)) {
- const idx = result.indexOf(pattern)
- const charBefore = idx > 0 ? result[idx - 1] : " "
- const charAfter = idx + pattern.length < result.length ? result[idx + pattern.length] : " "
- if (/\w/.test(charBefore) || /\w/.test(charAfter)) break
- result = result.slice(0, idx) + name + result.slice(idx + pattern.length)
- selfRefCount++
- }
- }
- return result
- })
- .join("")
- })
- newContent = fixedLines.join("\n")
-}
-
-// Figure out file naming
-const dir = path.dirname(absPath)
-const basename = path.basename(absPath, ".ts")
-const isIndex = basename === "index"
-const implName = nameFlag ?? (isIndex ? nsName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase() : basename)
-const implFile = path.join(dir, `${implName}.ts`)
-const indexFile = path.join(dir, "index.ts")
-const barrelLine = `export * as ${nsName} from "./${implName}"\n`
-
-console.log("")
-if (isIndex) {
- console.log(`Plan: rename ${basename}.ts → ${implName}.ts, create new index.ts barrel`)
-} else {
- console.log(`Plan: rewrite ${basename}.ts in place, create index.ts barrel`)
-}
-if (selfRefCount > 0) console.log(`Fixed ${selfRefCount} self-reference(s) (${nsName}.X → X)`)
-console.log("")
-
-if (dryRun) {
- console.log("--- 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()}`)
- console.log("")
- if (!isIndex) {
- const relDir = path.relative(path.resolve("src"), dir)
- console.log(`=== Import rewrites (would apply) ===`)
- console.log(` ${relDir}/${basename}" → ${relDir}" across src/, test/, script/`)
- } else {
- console.log("No import rewrites needed (was index.ts)")
- }
-} else {
- if (isIndex) {
- fs.writeFileSync(implFile, newContent)
- fs.writeFileSync(indexFile, barrelLine)
- console.log(`Wrote ${implName}.ts (${newContent.split("\n").length} lines)`)
- console.log(`Wrote index.ts (barrel)`)
- } else {
- fs.writeFileSync(absPath, newContent)
- if (fs.existsSync(indexFile)) {
- const existing = fs.readFileSync(indexFile, "utf-8")
- if (!existing.includes(`export * as ${nsName}`)) {
- fs.appendFileSync(indexFile, barrelLine)
- console.log(`Appended to existing index.ts`)
- } else {
- console.log(`index.ts already has ${nsName} export`)
- }
- } else {
- fs.writeFileSync(indexFile, barrelLine)
- console.log(`Wrote index.ts (barrel)`)
- }
- console.log(`Rewrote ${basename}.ts (${newContent.split("\n").length} lines)`)
- }
-
- // --- Rewrite import paths across src/, test/, script/ ---
- const relDir = path.relative(path.resolve("src"), dir)
- if (!isIndex) {
- const oldTail = `${relDir}/${basename}`
- const searchDirs = ["src", "test", "script"].filter((d) => fs.existsSync(d))
- const rgResult = Bun.spawnSync(["rg", "-l", `from.*${oldTail}"`, ...searchDirs], {
- stdout: "pipe",
- stderr: "pipe",
- })
- const filesToRewrite = rgResult.stdout
- .toString()
- .trim()
- .split("\n")
- .filter((f) => f.length > 0)
-
- if (filesToRewrite.length > 0) {
- console.log(`\nRewriting imports in ${filesToRewrite.length} file(s)...`)
- for (const file of filesToRewrite) {
- const content = fs.readFileSync(file, "utf-8")
- fs.writeFileSync(file, content.replaceAll(`${oldTail}"`, `${relDir}"`))
- }
- console.log(` Done: ${oldTail}" → ${relDir}"`)
- } else {
- console.log("\nNo import rewrites needed")
- }
- } else {
- console.log("\nNo import rewrites needed (was index.ts)")
- }
-
- // --- Fix sibling imports within the same directory ---
- const siblingFiles = fs.readdirSync(dir).filter((f) => {
- if (!f.endsWith(".ts")) return false
- if (f === "index.ts" || f === `${implName}.ts`) return false
- return true
- })
-
- let siblingFixCount = 0
- for (const sibFile of siblingFiles) {
- const sibPath = path.join(dir, sibFile)
- const content = fs.readFileSync(sibPath, "utf-8")
- const pattern = new RegExp(`from\\s+["']\\./${basename}["']`, "g")
- if (pattern.test(content)) {
- fs.writeFileSync(sibPath, content.replace(pattern, `from "."`))
- siblingFixCount++
- }
- }
- if (siblingFixCount > 0) {
- console.log(`Fixed ${siblingFixCount} sibling import(s) in ${path.basename(dir)}/ (./${basename} → .)`)
- }
-}
-
-console.log("")
-console.log("=== Verify ===")
-console.log("")
-console.log("bunx --bun tsgo --noEmit # typecheck")
-console.log("bun run test # run tests")
diff --git a/packages/opencode/specs/effect/namespace-treeshake.md b/packages/opencode/specs/effect/namespace-treeshake.md
deleted file mode 100644
index ef78c762b..000000000
--- a/packages/opencode/specs/effect/namespace-treeshake.md
+++ /dev/null
@@ -1,256 +0,0 @@
-# Namespace → self-reexport migration
-
-Migrate every `export namespace Foo { ... }` to flat top-level exports plus a
-single self-reexport line at the bottom of the same file:
-
-```ts
-export * as Foo from "./foo"
-```
-
-No barrel `index.ts` files. No cross-directory indirection. Consumers keep the
-exact same `import { Foo } from "../foo/foo"` ergonomics.
-
-## Why this pattern
-
-We tested three options against Bun, esbuild, Rollup (what Vite uses under the
-hood), Bun's runtime, and Node's native TypeScript runner.
-
-```
- 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
-```
-
-- **`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.
-
-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.
-
-## The pattern
-
-### Before
-
-```ts
-// src/permission/arity.ts
-export namespace BashArity {
- export function prefix(tokens: string[]) { ... }
-}
-```
-
-### After
-
-```ts
-// src/permission/arity.ts
-export function prefix(tokens: string[]) { ... }
-
-export * as BashArity from "./arity"
-```
-
-Consumers don't change at all:
-
-```ts
-import { BashArity } from "@/permission/arity"
-BashArity.prefix(...) // still works
-```
-
-Editors still auto-import `BashArity` like any named export, because the file
-does have a named `BashArity` export at the module top level.
-
-### Odd but harmless
-
-`BashArity.BashArity.BashArity.prefix(...)` compiles and runs because the
-namespace contains a re-export of itself. Nobody would write that. Not a
-problem.
-
-## Why this is different from what we tried first
-
-An earlier pass used sibling barrel files (`index.ts` with `export * as ...`).
-That turned out to be wrong for our constraints:
-
-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.
-
-The self-reexport has none of those issues. There is no indirection. The
-file and the namespace are the same unit.
-
-## Why this matters for startup
-
-The worst import chain in the codebase looks like:
-
-```
-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)
-```
-
-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
-
-From `packages/opencode`:
-
-```bash
-bun script/unwrap-namespace.ts <file> [--dry-run]
-```
-
-The script:
-
-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`.
-
-### Typical flow for one file
-
-```bash
-# 1. Preview
-bun script/unwrap-namespace.ts src/permission/arity.ts --dry-run
-
-# 2. Apply
-bun script/unwrap-namespace.ts src/permission/arity.ts
-
-# 3. Verify
-cd packages/opencode
-bunx --bun tsgo --noEmit
-bun run --conditions=browser ./src/index.ts generate
-bun run test <affected test files>
-```
-
-### Consumer imports usually don't need to change
-
-Most consumers already import straight from the file, e.g.:
-
-```ts
-import { BashArity } from "@/permission/arity"
-import { Config } from "@/config/config"
-```
-
-Because the file itself now does `export * as Foo from "./foo"`, those imports
-keep working with zero edits.
-
-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
-// before
-import { Config } from "@/config"
-
-// after
-import { Config } from "@/config/config"
-```
-
-### Dynamic imports in tests
-
-If a test did `const { Foo } = await import("../../src/x/y")`, the destructure
-still works because of the self-reexport. No change required.
-
-## Verification checklist (per PR)
-
-Run all of these locally before pushing:
-
-```bash
-cd packages/opencode
-bunx --bun tsgo --noEmit
-bun run --conditions=browser ./src/index.ts generate
-bun run test <affected test files>
-```
-
-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.
-
-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.
-
-## Rules for new code
-
-- No new `export namespace`.
-- Every module directory has a single canonical file — typically
- `dir/index.ts` — with flat top-level exports and a self-reexport at the
- bottom:
- `export * as Foo from "."`
-- Consumers import from the directory:
- `import { Foo } from "@/dir"` or `import { Foo } from "../dir"`.
-- No sibling barrel files. If a directory has multiple independent
- namespaces, they each get their own file (e.g. `config/config.ts`,
- `config/plugin.ts`) and their own self-reexport; the `index.ts` in that
- directory stays minimal or does not exist.
-- If a file needs a sibling, import the sibling file directly:
- `import * as Sibling from "./sibling"`, not `from "."`.
-
-### Why `dir/index.ts` + `"."` is fine for us
-
-A single-file module (e.g. `pty/`) can live entirely in `dir/index.ts`
-with `export * as Foo from "."` at the bottom. Consumers write the
-short form:
-
-```ts
-import { Pty } from "@/pty"
-```
-
-This works in Bun runtime, Bun build, esbuild, and Rollup. It does NOT
-work under Node's `--experimental-strip-types` runner:
-
-```
-node --experimental-strip-types entry.ts
- ERR_UNSUPPORTED_DIR_IMPORT: Directory import '/.../pty' is not supported
-```
-
-Node requires an explicit file or a `package.json#exports` map for ESM.
-We don't care about that target right now because the opencode CLI is
-built with Bun and the web apps are built with Vite/Rollup. If we ever
-want to run raw `.ts` through Node, we'll need to either use explicit
-`.ts` extensions everywhere or add per-directory `package.json` exports
-maps.
-
-### When NOT to collapse to `index.ts`
-
-Some directories contain multiple independent namespaces where
-`dir/index.ts` would be misleading. Examples:
-
-- `config/` has `Config`, `ConfigPaths`, `ConfigMarkdown`, `ConfigPlugin`,
- `ConfigKeybinds`. Each lives in its own file with its own self-reexport
- (`config/config.ts`, `config/plugin.ts`, etc.). Consumers import the
- specific one: `import { ConfigPlugin } from "@/config/plugin"`.
-- Same shape for `session/`, `server/`, etc.
-
-Collapsing one of those into `index.ts` would mean picking a single
-"canonical" namespace for the directory, which breaks the symmetry and
-hides the other files.
-
-## Scope
-
-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.