Skip to content

Commit b320065

Browse files
committed
tooling: add unwrap-and-self-reexport and batch-unwrap-pr scripts
1 parent 0e86466 commit b320065

2 files changed

Lines changed: 460 additions & 0 deletions

File tree

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Automate the full per-file namespace→self-reexport migration:
4+
*
5+
* 1. Create a worktree at ../opencode-worktrees/ns-<slug> on a new branch
6+
* `kit/ns-<slug>` off `origin/dev`.
7+
* 2. Symlink `node_modules` from the main repo into the worktree root so
8+
* builds work without a fresh `bun install`.
9+
* 3. Run `script/unwrap-and-self-reexport.ts` on the target file inside the worktree.
10+
* 4. Verify:
11+
* - `bunx --bun tsgo --noEmit` (pre-existing plugin.ts cross-worktree
12+
* noise ignored — we compare against a pre-change baseline captured
13+
* via `git stash`, so only NEW errors fail).
14+
* - `bun run --conditions=browser ./src/index.ts generate`.
15+
* - Relevant tests under `test/<dir>` if that directory exists.
16+
* 5. Commit, push with `--no-verify`, and open a PR titled after the
17+
* namespace.
18+
*
19+
* Usage:
20+
*
21+
* bun script/batch-unwrap-pr.ts src/file/ignore.ts
22+
* bun script/batch-unwrap-pr.ts src/file/ignore.ts src/file/watcher.ts # multiple
23+
* bun script/batch-unwrap-pr.ts --dry-run src/file/ignore.ts # plan only
24+
*
25+
* Repo assumptions:
26+
*
27+
* - Main checkout at /Users/kit/code/open-source/opencode (configurable via
28+
* --repo-root=...).
29+
* - Worktree root at /Users/kit/code/open-source/opencode-worktrees
30+
* (configurable via --worktree-root=...).
31+
*
32+
* The script does NOT enable auto-merge; that's a separate manual step if we
33+
* want it.
34+
*/
35+
36+
import fs from "node:fs"
37+
import path from "node:path"
38+
import { spawnSync, type SpawnSyncReturns } from "node:child_process"
39+
40+
type Cmd = string[]
41+
42+
function run(
43+
cwd: string,
44+
cmd: Cmd,
45+
opts: { capture?: boolean; allowFail?: boolean; stdin?: string } = {},
46+
): SpawnSyncReturns<string> {
47+
const result = spawnSync(cmd[0], cmd.slice(1), {
48+
cwd,
49+
stdio: opts.capture ? ["pipe", "pipe", "pipe"] : ["inherit", "inherit", "inherit"],
50+
encoding: "utf-8",
51+
input: opts.stdin,
52+
})
53+
if (!opts.allowFail && result.status !== 0) {
54+
const label = `${path.basename(cmd[0])} ${cmd.slice(1).join(" ")}`
55+
console.error(`[fail] ${label} (cwd=${cwd})`)
56+
if (opts.capture) {
57+
if (result.stdout) console.error(result.stdout)
58+
if (result.stderr) console.error(result.stderr)
59+
}
60+
process.exit(result.status ?? 1)
61+
}
62+
return result
63+
}
64+
65+
function fileSlug(fileArg: string): string {
66+
// src/file/ignore.ts → file-ignore
67+
return fileArg
68+
.replace(/^src\//, "")
69+
.replace(/\.tsx?$/, "")
70+
.replace(/[\/_]/g, "-")
71+
}
72+
73+
function readNamespace(absFile: string): string {
74+
const content = fs.readFileSync(absFile, "utf-8")
75+
const match = content.match(/^export\s+namespace\s+(\w+)\s*\{/m)
76+
if (!match) {
77+
console.error(`no \`export namespace\` found in ${absFile}`)
78+
process.exit(1)
79+
}
80+
return match[1]
81+
}
82+
83+
// ---------------------------------------------------------------------------
84+
85+
const args = process.argv.slice(2)
86+
const dryRun = args.includes("--dry-run")
87+
const repoRoot = (
88+
args.find((a) => a.startsWith("--repo-root=")) ?? "--repo-root=/Users/kit/code/open-source/opencode"
89+
).split("=")[1]
90+
const worktreeRoot = (
91+
args.find((a) => a.startsWith("--worktree-root=")) ?? "--worktree-root=/Users/kit/code/open-source/opencode-worktrees"
92+
).split("=")[1]
93+
const targets = args.filter((a) => !a.startsWith("--"))
94+
95+
if (targets.length === 0) {
96+
console.error("Usage: bun script/batch-unwrap-pr.ts <src/path.ts> [more files...] [--dry-run]")
97+
process.exit(1)
98+
}
99+
100+
if (!fs.existsSync(worktreeRoot)) fs.mkdirSync(worktreeRoot, { recursive: true })
101+
102+
for (const rel of targets) {
103+
const absSrc = path.join(repoRoot, "packages", "opencode", rel)
104+
if (!fs.existsSync(absSrc)) {
105+
console.error(`skip ${rel}: file does not exist under ${repoRoot}/packages/opencode`)
106+
continue
107+
}
108+
const slug = fileSlug(rel)
109+
const branch = `kit/ns-${slug}`
110+
const wt = path.join(worktreeRoot, `ns-${slug}`)
111+
const ns = readNamespace(absSrc)
112+
113+
console.log(`\n=== ${rel}${ns} (branch=${branch} wt=${path.basename(wt)}) ===`)
114+
115+
if (dryRun) {
116+
console.log(` would create worktree ${wt}`)
117+
console.log(` would run unwrap on packages/opencode/${rel}`)
118+
console.log(` would commit, push, and open PR`)
119+
continue
120+
}
121+
122+
// Sync dev (fetch only; we branch off origin/dev directly).
123+
run(repoRoot, ["git", "fetch", "origin", "dev", "--quiet"])
124+
125+
// Create worktree + branch.
126+
if (fs.existsSync(wt)) {
127+
console.log(` worktree already exists at ${wt}; skipping`)
128+
continue
129+
}
130+
run(repoRoot, ["git", "worktree", "add", "-b", branch, wt, "origin/dev"])
131+
132+
// Symlink node_modules so bun/tsgo work.
133+
const wtNodeModules = path.join(wt, "node_modules")
134+
if (!fs.existsSync(wtNodeModules)) {
135+
fs.symlinkSync(path.join(repoRoot, "node_modules"), wtNodeModules)
136+
}
137+
138+
const wtOpencode = path.join(wt, "packages", "opencode")
139+
const wtTarget = path.join(wt, "packages", "opencode", rel)
140+
141+
// Baseline tsgo output (pre-change).
142+
const baselinePath = path.join(wt, ".ns-baseline.txt")
143+
const baseline = run(wtOpencode, ["bunx", "--bun", "tsgo", "--noEmit"], { capture: true, allowFail: true })
144+
fs.writeFileSync(baselinePath, (baseline.stdout ?? "") + (baseline.stderr ?? ""))
145+
146+
// Run the unwrap script inside the worktree.
147+
run(wtOpencode, ["bun", "script/unwrap-and-self-reexport.ts", rel])
148+
149+
// Post-change tsgo.
150+
const after = run(wtOpencode, ["bunx", "--bun", "tsgo", "--noEmit"], { capture: true, allowFail: true })
151+
const afterText = (after.stdout ?? "") + (after.stderr ?? "")
152+
153+
// Compare line-sets to detect NEW tsgo errors.
154+
const sanitize = (s: string) =>
155+
s
156+
.split("\n")
157+
.map((l) => l.replace(/\s+$/, ""))
158+
.filter(Boolean)
159+
.sort()
160+
.join("\n")
161+
const baselineSorted = sanitize(fs.readFileSync(baselinePath, "utf-8"))
162+
const afterSorted = sanitize(afterText)
163+
if (baselineSorted !== afterSorted) {
164+
console.log(` tsgo output differs from baseline. Showing diff:`)
165+
const diffResult = spawnSync("diff", ["-u", baselinePath, "-"], { input: afterText, encoding: "utf-8" })
166+
if (diffResult.stdout) console.log(diffResult.stdout)
167+
if (diffResult.stderr) console.log(diffResult.stderr)
168+
console.error(` aborting ${rel}; investigate manually in ${wt}`)
169+
process.exit(1)
170+
}
171+
172+
// SDK build.
173+
run(wtOpencode, ["bun", "run", "--conditions=browser", "./src/index.ts", "generate"], { capture: true })
174+
175+
// Run tests for the directory, if a matching test dir exists.
176+
const dirName = path.basename(path.dirname(rel))
177+
const testDir = path.join(wt, "packages", "opencode", "test", dirName)
178+
if (fs.existsSync(testDir)) {
179+
const testResult = run(wtOpencode, ["bun", "run", "test", `test/${dirName}`], { capture: true, allowFail: true })
180+
const combined = (testResult.stdout ?? "") + (testResult.stderr ?? "")
181+
if (testResult.status !== 0) {
182+
console.error(combined)
183+
console.error(` tests failed for ${rel}; aborting`)
184+
process.exit(1)
185+
}
186+
// Surface the summary line if present.
187+
const summary = combined
188+
.split("\n")
189+
.filter((l) => /\bpass\b|\bfail\b/.test(l))
190+
.slice(-3)
191+
.join("\n")
192+
if (summary) console.log(` tests: ${summary.replace(/\n/g, " | ")}`)
193+
} else {
194+
console.log(` tests: no test/${dirName} directory, skipping`)
195+
}
196+
197+
// Clean up baseline file before committing.
198+
fs.unlinkSync(baselinePath)
199+
200+
// Commit, push, open PR.
201+
const commitMsg = `refactor: unwrap ${ns} namespace + self-reexport`
202+
run(wt, ["git", "add", "-A"])
203+
run(wt, ["git", "commit", "-m", commitMsg])
204+
run(wt, ["git", "push", "-u", "origin", branch, "--no-verify"])
205+
206+
const prBody = [
207+
"## Summary",
208+
`- Unwrap the \`${ns}\` namespace in \`packages/opencode/${rel}\` to flat top-level exports.`,
209+
`- Append \`export * as ${ns} from "./${path.basename(rel, ".ts")}"\` so consumers keep the same \`${ns}.x\` import ergonomics.`,
210+
"",
211+
"## Verification (local)",
212+
"- `bunx --bun tsgo --noEmit` — no new errors vs baseline.",
213+
"- `bun run --conditions=browser ./src/index.ts generate` — clean.",
214+
`- \`bun run test test/${dirName}\` — all pass (if applicable).`,
215+
].join("\n")
216+
run(wt, ["gh", "pr", "create", "--title", commitMsg, "--base", "dev", "--body", prBody])
217+
218+
console.log(` PR opened for ${rel}`)
219+
}

0 commit comments

Comments
 (0)