summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-16 20:11:17 -0400
committerGitHub <[email protected]>2026-04-16 20:11:17 -0400
commitc0bfccc15ea6e2baea1d5b67f73d689317caa2af (patch)
treec647bbb6371a95f3445304382d48bf7df3627afe
parent53dc7b164940edbc5793bac83f91d7fca7b78fe5 (diff)
downloadopencode-c0bfccc15ea6e2baea1d5b67f73d689317caa2af.tar.gz
opencode-c0bfccc15ea6e2baea1d5b67f73d689317caa2af.zip
tooling: add unwrap-and-self-reexport + batch-unwrap-pr scripts (#22929)
-rw-r--r--packages/opencode/script/batch-unwrap-pr.ts230
-rw-r--r--packages/opencode/script/unwrap-and-self-reexport.ts241
2 files changed, 471 insertions, 0 deletions
diff --git a/packages/opencode/script/batch-unwrap-pr.ts b/packages/opencode/script/batch-unwrap-pr.ts
new file mode 100644
index 000000000..573050141
--- /dev/null
+++ b/packages/opencode/script/batch-unwrap-pr.ts
@@ -0,0 +1,230 @@
+#!/usr/bin/env bun
+/**
+ * Automate the full per-file namespace→self-reexport migration:
+ *
+ * 1. Create a worktree at ../opencode-worktrees/ns-<slug> on a new branch
+ * `kit/ns-<slug>` off `origin/dev`.
+ * 2. Symlink `node_modules` from the main repo into the worktree root so
+ * builds work without a fresh `bun install`.
+ * 3. Run `script/unwrap-and-self-reexport.ts` on the target file inside the worktree.
+ * 4. Verify:
+ * - `bunx --bun tsgo --noEmit` (pre-existing plugin.ts cross-worktree
+ * noise ignored — we compare against a pre-change baseline captured
+ * via `git stash`, so only NEW errors fail).
+ * - `bun run --conditions=browser ./src/index.ts generate`.
+ * - Relevant tests under `test/<dir>` if that directory exists.
+ * 5. Commit, push with `--no-verify`, and open a PR titled after the
+ * namespace.
+ *
+ * Usage:
+ *
+ * bun script/batch-unwrap-pr.ts src/file/ignore.ts
+ * bun script/batch-unwrap-pr.ts src/file/ignore.ts src/file/watcher.ts # multiple
+ * bun script/batch-unwrap-pr.ts --dry-run src/file/ignore.ts # plan only
+ *
+ * Repo assumptions:
+ *
+ * - Main checkout at /Users/kit/code/open-source/opencode (configurable via
+ * --repo-root=...).
+ * - Worktree root at /Users/kit/code/open-source/opencode-worktrees
+ * (configurable via --worktree-root=...).
+ *
+ * The script does NOT enable auto-merge; that's a separate manual step if we
+ * want it.
+ */
+
+import fs from "node:fs"
+import path from "node:path"
+import { spawnSync, type SpawnSyncReturns } from "node:child_process"
+
+type Cmd = string[]
+
+function run(
+ cwd: string,
+ cmd: Cmd,
+ opts: { capture?: boolean; allowFail?: boolean; stdin?: string } = {},
+): SpawnSyncReturns<string> {
+ const result = spawnSync(cmd[0], cmd.slice(1), {
+ cwd,
+ stdio: opts.capture ? ["pipe", "pipe", "pipe"] : ["inherit", "inherit", "inherit"],
+ encoding: "utf-8",
+ input: opts.stdin,
+ })
+ if (!opts.allowFail && result.status !== 0) {
+ const label = `${path.basename(cmd[0])} ${cmd.slice(1).join(" ")}`
+ console.error(`[fail] ${label} (cwd=${cwd})`)
+ if (opts.capture) {
+ if (result.stdout) console.error(result.stdout)
+ if (result.stderr) console.error(result.stderr)
+ }
+ process.exit(result.status ?? 1)
+ }
+ return result
+}
+
+function fileSlug(fileArg: string): string {
+ // src/file/ignore.ts → file-ignore
+ return fileArg
+ .replace(/^src\//, "")
+ .replace(/\.tsx?$/, "")
+ .replace(/[\/_]/g, "-")
+}
+
+function readNamespace(absFile: string): string {
+ const content = fs.readFileSync(absFile, "utf-8")
+ const match = content.match(/^export\s+namespace\s+(\w+)\s*\{/m)
+ if (!match) {
+ console.error(`no \`export namespace\` found in ${absFile}`)
+ process.exit(1)
+ }
+ return match[1]
+}
+
+// ---------------------------------------------------------------------------
+
+const args = process.argv.slice(2)
+const dryRun = args.includes("--dry-run")
+const repoRoot = (
+ args.find((a) => a.startsWith("--repo-root=")) ?? "--repo-root=/Users/kit/code/open-source/opencode"
+).split("=")[1]
+const worktreeRoot = (
+ args.find((a) => a.startsWith("--worktree-root=")) ?? "--worktree-root=/Users/kit/code/open-source/opencode-worktrees"
+).split("=")[1]
+const targets = args.filter((a) => !a.startsWith("--"))
+
+if (targets.length === 0) {
+ console.error("Usage: bun script/batch-unwrap-pr.ts <src/path.ts> [more files...] [--dry-run]")
+ process.exit(1)
+}
+
+if (!fs.existsSync(worktreeRoot)) fs.mkdirSync(worktreeRoot, { recursive: true })
+
+for (const rel of targets) {
+ const absSrc = path.join(repoRoot, "packages", "opencode", rel)
+ if (!fs.existsSync(absSrc)) {
+ console.error(`skip ${rel}: file does not exist under ${repoRoot}/packages/opencode`)
+ continue
+ }
+ const slug = fileSlug(rel)
+ const branch = `kit/ns-${slug}`
+ const wt = path.join(worktreeRoot, `ns-${slug}`)
+ const ns = readNamespace(absSrc)
+
+ console.log(`\n=== ${rel} → ${ns} (branch=${branch} wt=${path.basename(wt)}) ===`)
+
+ if (dryRun) {
+ console.log(` would create worktree ${wt}`)
+ console.log(` would run unwrap on packages/opencode/${rel}`)
+ console.log(` would commit, push, and open PR`)
+ continue
+ }
+
+ // Sync dev (fetch only; we branch off origin/dev directly).
+ run(repoRoot, ["git", "fetch", "origin", "dev", "--quiet"])
+
+ // Create worktree + branch.
+ if (fs.existsSync(wt)) {
+ console.log(` worktree already exists at ${wt}; skipping`)
+ continue
+ }
+ run(repoRoot, ["git", "worktree", "add", "-b", branch, wt, "origin/dev"])
+
+ // Symlink node_modules so bun/tsgo work without a full install.
+ // We link both the repo root and packages/opencode, since the opencode
+ // package has its own local node_modules (including bunfig.toml preload deps
+ // like @opentui/solid) that aren't hoisted to the root.
+ const wtRootNodeModules = path.join(wt, "node_modules")
+ if (!fs.existsSync(wtRootNodeModules)) {
+ fs.symlinkSync(path.join(repoRoot, "node_modules"), wtRootNodeModules)
+ }
+ const wtOpencode = path.join(wt, "packages", "opencode")
+ const wtOpencodeNodeModules = path.join(wtOpencode, "node_modules")
+ if (!fs.existsSync(wtOpencodeNodeModules)) {
+ fs.symlinkSync(path.join(repoRoot, "packages", "opencode", "node_modules"), wtOpencodeNodeModules)
+ }
+ const wtTarget = path.join(wt, "packages", "opencode", rel)
+
+ // Baseline tsgo output (pre-change).
+ const baselinePath = path.join(wt, ".ns-baseline.txt")
+ const baseline = run(wtOpencode, ["bunx", "--bun", "tsgo", "--noEmit"], { capture: true, allowFail: true })
+ fs.writeFileSync(baselinePath, (baseline.stdout ?? "") + (baseline.stderr ?? ""))
+
+ // Run the unwrap script from the MAIN repo checkout (where the tooling
+ // lives) targeting the worktree's file by absolute path. We run from the
+ // worktree root (not `packages/opencode`) to avoid triggering the
+ // bunfig.toml preload, which needs `@opentui/solid` that only the TUI
+ // workspace has installed.
+ const unwrapScript = path.join(repoRoot, "packages", "opencode", "script", "unwrap-and-self-reexport.ts")
+ run(wt, ["bun", unwrapScript, wtTarget])
+
+ // Post-change tsgo.
+ const after = run(wtOpencode, ["bunx", "--bun", "tsgo", "--noEmit"], { capture: true, allowFail: true })
+ const afterText = (after.stdout ?? "") + (after.stderr ?? "")
+
+ // Compare line-sets to detect NEW tsgo errors.
+ const sanitize = (s: string) =>
+ s
+ .split("\n")
+ .map((l) => l.replace(/\s+$/, ""))
+ .filter(Boolean)
+ .sort()
+ .join("\n")
+ const baselineSorted = sanitize(fs.readFileSync(baselinePath, "utf-8"))
+ const afterSorted = sanitize(afterText)
+ if (baselineSorted !== afterSorted) {
+ console.log(` tsgo output differs from baseline. Showing diff:`)
+ const diffResult = spawnSync("diff", ["-u", baselinePath, "-"], { input: afterText, encoding: "utf-8" })
+ if (diffResult.stdout) console.log(diffResult.stdout)
+ if (diffResult.stderr) console.log(diffResult.stderr)
+ console.error(` aborting ${rel}; investigate manually in ${wt}`)
+ process.exit(1)
+ }
+
+ // SDK build.
+ run(wtOpencode, ["bun", "run", "--conditions=browser", "./src/index.ts", "generate"], { capture: true })
+
+ // Run tests for the directory, if a matching test dir exists.
+ const dirName = path.basename(path.dirname(rel))
+ const testDir = path.join(wt, "packages", "opencode", "test", dirName)
+ if (fs.existsSync(testDir)) {
+ const testResult = run(wtOpencode, ["bun", "run", "test", `test/${dirName}`], { capture: true, allowFail: true })
+ const combined = (testResult.stdout ?? "") + (testResult.stderr ?? "")
+ if (testResult.status !== 0) {
+ console.error(combined)
+ console.error(` tests failed for ${rel}; aborting`)
+ process.exit(1)
+ }
+ // Surface the summary line if present.
+ const summary = combined
+ .split("\n")
+ .filter((l) => /\bpass\b|\bfail\b/.test(l))
+ .slice(-3)
+ .join("\n")
+ if (summary) console.log(` tests: ${summary.replace(/\n/g, " | ")}`)
+ } else {
+ console.log(` tests: no test/${dirName} directory, skipping`)
+ }
+
+ // Clean up baseline file before committing.
+ fs.unlinkSync(baselinePath)
+
+ // Commit, push, open PR.
+ const commitMsg = `refactor: unwrap ${ns} namespace + self-reexport`
+ run(wt, ["git", "add", "-A"])
+ run(wt, ["git", "commit", "-m", commitMsg])
+ run(wt, ["git", "push", "-u", "origin", branch, "--no-verify"])
+
+ const prBody = [
+ "## Summary",
+ `- Unwrap the \`${ns}\` namespace in \`packages/opencode/${rel}\` to flat top-level exports.`,
+ `- Append \`export * as ${ns} from "./${path.basename(rel, ".ts")}"\` so consumers keep the same \`${ns}.x\` import ergonomics.`,
+ "",
+ "## Verification (local)",
+ "- `bunx --bun tsgo --noEmit` — no new errors vs baseline.",
+ "- `bun run --conditions=browser ./src/index.ts generate` — clean.",
+ `- \`bun run test test/${dirName}\` — all pass (if applicable).`,
+ ].join("\n")
+ run(wt, ["gh", "pr", "create", "--title", commitMsg, "--base", "dev", "--body", prBody])
+
+ console.log(` PR opened for ${rel}`)
+}
diff --git a/packages/opencode/script/unwrap-and-self-reexport.ts b/packages/opencode/script/unwrap-and-self-reexport.ts
new file mode 100644
index 000000000..5ae703182
--- /dev/null
+++ b/packages/opencode/script/unwrap-and-self-reexport.ts
@@ -0,0 +1,241 @@
+#!/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.
+const basename = path.basename(absPath, ".ts")
+const assembled = [...before, ...rewrittenBody, ...after].join("\n")
+const trimmed = assembled.replace(/\s+$/g, "")
+const output = `${trimmed}\n\nexport * as ${nsName} from "./${basename}"\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 "./${basename}"`)
+ 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 "./${basename}"`)
+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$/, "")}*`,
+)