Kaynağa Gözat

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

Kit Langton 4 gün önce
ebeveyn
işleme
c0bfccc15e

+ 230 - 0
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}`)
+}

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