tooling: add unwrap-and-self-reexport + batch-unwrap-pr scripts (#22929)

This commit is contained in:
Kit Langton
2026-04-16 20:11:17 -04:00
committed by GitHub
parent 53dc7b1649
commit c0bfccc15e
2 changed files with 471 additions and 0 deletions

View File

@@ -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}`)
}

View File

@@ -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$/, "")}*`,
)