mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-04-21 05:10:58 +08:00
chore: retire namespace migration tooling + document module shape (#23010)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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`)
|
||||
@@ -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$/, "")}*`,
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user