diff --git a/packages/opencode/script/batch-unwrap-pr.ts b/packages/opencode/script/batch-unwrap-pr.ts new file mode 100644 index 0000000000..5730501412 --- /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- 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/unwrap-and-self-reexport.ts b/packages/opencode/script/unwrap-and-self-reexport.ts new file mode 100644 index 0000000000..5ae703182e --- /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 "./"` 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. +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$/, "")}*`, +)