From c51f3e35cabb5cbb49d4fddc240b880c58286a97 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Apr 2026 22:48:40 -0400 Subject: [PATCH] chore: retire namespace migration tooling + document module shape (#23010) --- packages/opencode/AGENTS.md | 57 ++++ packages/opencode/script/batch-unwrap-pr.ts | 230 ------------- packages/opencode/script/collapse-barrel.ts | 161 --------- .../script/unwrap-and-self-reexport.ts | 246 -------------- packages/opencode/script/unwrap-namespace.ts | 305 ------------------ .../specs/effect/namespace-treeshake.md | 256 --------------- 6 files changed, 57 insertions(+), 1198 deletions(-) delete mode 100644 packages/opencode/script/batch-unwrap-pr.ts delete mode 100644 packages/opencode/script/collapse-barrel.ts delete mode 100644 packages/opencode/script/unwrap-and-self-reexport.ts delete mode 100644 packages/opencode/script/unwrap-namespace.ts delete mode 100644 packages/opencode/specs/effect/namespace-treeshake.md diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index 761b9b5c5e..d7fb844f0d 100644 --- a/packages/opencode/AGENTS.md +++ b/packages/opencode/AGENTS.md @@ -9,6 +9,63 @@ - **Output**: creates `migration/_/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()("@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 5730501412..0000000000 --- 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- on a new branch - * `kit/ns-` 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/` 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 { - 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 [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 05bb11589c..0000000000 --- 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 [--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 "./" 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 09256f3a51..0000000000 --- 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 "./"` 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 [--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 } -} -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() -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.` 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 45c16f6c73..0000000000 --- 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 .ts - * 5. Creates/updates index.ts with `export * as Foo from "./"` - * 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 [--dry-run] [--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; multi: Record> } -}> - -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() -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 ef78c762bb..0000000000 --- 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 [--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 "./"` 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 -``` - -### 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 -``` - -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.