mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-20 19:06:22 +00:00
tooling: add unwrap-and-self-reexport + batch-unwrap-pr scripts (#22929)
This commit is contained in:
230
packages/opencode/script/batch-unwrap-pr.ts
Normal file
230
packages/opencode/script/batch-unwrap-pr.ts
Normal 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}`)
|
||||
}
|
||||
241
packages/opencode/script/unwrap-and-self-reexport.ts
Normal file
241
packages/opencode/script/unwrap-and-self-reexport.ts
Normal 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$/, "")}*`,
|
||||
)
|
||||
Reference in New Issue
Block a user