Bladeren bron

chore: retire namespace migration tooling + document module shape (#23010)

Kit Langton 1 dag geleden
bovenliggende
commit
c51f3e35ca

+ 57 - 0
packages/opencode/AGENTS.md

@@ -9,6 +9,63 @@
 - **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
 - **Tests**: migration tests should read the per-folder layout (no `_journal.json`).
 
+# Module shape
+
+Do not use `export namespace Foo { ... }` for module organization. It is not
+standard ESM, it prevents tree-shaking, and it breaks Node's native TypeScript
+runner. Use flat top-level exports combined with a self-reexport at the bottom
+of the file:
+
+```ts
+// src/foo/foo.ts
+export interface Interface { ... }
+export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
+export const layer = Layer.effect(Service, ...)
+export const defaultLayer = layer.pipe(...)
+
+export * as Foo from "./foo"
+```
+
+Consumers import the namespace projection:
+
+```ts
+import { Foo } from "@/foo/foo"
+
+yield * Foo.Service
+Foo.layer
+Foo.defaultLayer
+```
+
+Namespace-private helpers stay as non-exported top-level declarations in the
+same file — they remain inaccessible to consumers (they are not projected by
+`export * as`) but are usable by the file's own code.
+
+## When the file is an `index.ts`
+
+If the module is `foo/index.ts` (single-namespace directory), use `"."` for
+the self-reexport source rather than `"./index"`:
+
+```ts
+// src/foo/index.ts
+export const thing = ...
+
+export * as Foo from "."
+```
+
+## Multi-sibling directories
+
+For directories with several independent modules (e.g. `src/session/`,
+`src/config/`), keep each sibling as its own file with its own self-reexport,
+and do not add a barrel `index.ts`. Consumers import the specific sibling:
+
+```ts
+import { SessionRetry } from "@/session/retry"
+import { SessionStatus } from "@/session/status"
+```
+
+Barrels in multi-sibling directories force every import through the barrel to
+evaluate every sibling, which defeats tree-shaking and slows module load.
+
 # opencode Effect rules
 
 Use these rules when writing or migrating Effect code.

+ 0 - 230
packages/opencode/script/batch-unwrap-pr.ts

@@ -1,230 +0,0 @@
-#!/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}`)
-}

+ 0 - 161
packages/opencode/script/collapse-barrel.ts

@@ -1,161 +0,0 @@
-#!/usr/bin/env bun
-/**
- * Collapse a single-namespace barrel directory into a dir/index.ts module.
- *
- * Given a directory `src/foo/` that contains:
- *
- *   - `index.ts`  (exactly `export * as Foo from "./foo"`)
- *   - `foo.ts`    (the real implementation)
- *   - zero or more sibling files
- *
- * this script:
- *
- *   1. Deletes the old `index.ts` barrel.
- *   2. `git mv`s `foo.ts` → `index.ts` so the implementation IS the directory entry.
- *   3. Appends `export * as Foo from "."` to the new `index.ts`.
- *   4. Rewrites any same-directory sibling `*.ts` files that imported
- *      `./foo` (with or without the namespace name) to import `"."` instead.
- *
- * Consumer files outside the directory keep importing from the directory
- * (`"@/foo"` / `"../foo"` / etc.) and continue to work, because
- * `dir/index.ts` now provides the `Foo` named export directly.
- *
- * Usage:
- *
- *   bun script/collapse-barrel.ts src/bus
- *   bun script/collapse-barrel.ts src/bus --dry-run
- *
- * Notes:
- *
- *   - Only works on directories whose barrel is a single
- *     `export * as Name from "./file"` line. Refuses otherwise.
- *   - Refuses if the implementation file name already conflicts with
- *     `index.ts`.
- *   - Safe to run repeatedly: a second run on an already-collapsed dir
- *     will exit with a clear message.
- */
-
-import fs from "node:fs"
-import path from "node:path"
-import { spawnSync } from "node:child_process"
-
-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/collapse-barrel.ts <dir> [--dry-run]")
-  process.exit(1)
-}
-
-const dir = path.resolve(targetArg)
-const indexPath = path.join(dir, "index.ts")
-
-if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
-  console.error(`Not a directory: ${dir}`)
-  process.exit(1)
-}
-if (!fs.existsSync(indexPath)) {
-  console.error(`No index.ts in ${dir}`)
-  process.exit(1)
-}
-
-// Validate barrel shape.
-const indexContent = fs.readFileSync(indexPath, "utf-8").trim()
-const match = indexContent.match(/^export\s+\*\s+as\s+(\w+)\s+from\s+["']\.\/([^"']+)["']\s*;?\s*$/)
-if (!match) {
-  console.error(`Not a simple single-namespace barrel:\n${indexContent}`)
-  process.exit(1)
-}
-const namespaceName = match[1]
-const implRel = match[2].replace(/\.ts$/, "")
-const implPath = path.join(dir, `${implRel}.ts`)
-
-if (!fs.existsSync(implPath)) {
-  console.error(`Implementation file not found: ${implPath}`)
-  process.exit(1)
-}
-
-if (implRel === "index") {
-  console.error(`Nothing to do — impl file is already index.ts`)
-  process.exit(0)
-}
-
-console.log(`Collapsing ${path.relative(process.cwd(), dir)}`)
-console.log(`  namespace: ${namespaceName}`)
-console.log(`  impl file: ${implRel}.ts → index.ts`)
-
-// Figure out which sibling files need rewriting.
-const siblings = fs
-  .readdirSync(dir)
-  .filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"))
-  .filter((f) => f !== "index.ts" && f !== `${implRel}.ts`)
-  .map((f) => path.join(dir, f))
-
-type SiblingEdit = { file: string; content: string }
-const siblingEdits: SiblingEdit[] = []
-
-for (const sibling of siblings) {
-  const content = fs.readFileSync(sibling, "utf-8")
-  // Match any import or re-export referring to "./<implRel>" inside this directory.
-  const siblingRegex = new RegExp(`(from\\s*["'])\\.\\/${implRel.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&")}(["'])`, "g")
-  if (!siblingRegex.test(content)) continue
-  const updated = content.replace(siblingRegex, `$1.$2`)
-  siblingEdits.push({ file: sibling, content: updated })
-}
-
-if (siblingEdits.length > 0) {
-  console.log(`  sibling rewrites: ${siblingEdits.length}`)
-  for (const edit of siblingEdits) {
-    console.log(`    ${path.relative(process.cwd(), edit.file)}`)
-  }
-} else {
-  console.log(`  sibling rewrites: none`)
-}
-
-if (dryRun) {
-  console.log(`\n(dry run) would:`)
-  console.log(`  - delete ${path.relative(process.cwd(), indexPath)}`)
-  console.log(`  - git mv ${path.relative(process.cwd(), implPath)} ${path.relative(process.cwd(), indexPath)}`)
-  console.log(`  - append \`export * as ${namespaceName} from "."\` to the new index.ts`)
-  for (const edit of siblingEdits) {
-    console.log(`  - rewrite sibling: ${path.relative(process.cwd(), edit.file)}`)
-  }
-  process.exit(0)
-}
-
-// Apply: remove the old barrel, git-mv the impl onto it, then rewrite content.
-// We can't git-mv on top of an existing tracked file, so we remove the barrel first.
-function runGit(...cmd: string[]) {
-  const res = spawnSync("git", cmd, { stdio: "inherit" })
-  if (res.status !== 0) {
-    console.error(`git ${cmd.join(" ")} failed`)
-    process.exit(res.status ?? 1)
-  }
-}
-
-// Step 1: remove the barrel
-runGit("rm", "-f", indexPath)
-
-// Step 2: rename the impl file into index.ts
-runGit("mv", implPath, indexPath)
-
-// Step 3: append the self-reexport to the new index.ts
-const newContent = fs.readFileSync(indexPath, "utf-8")
-const trimmed = newContent.endsWith("\n") ? newContent : newContent + "\n"
-fs.writeFileSync(indexPath, `${trimmed}\nexport * as ${namespaceName} from "."\n`)
-console.log(`  appended: export * as ${namespaceName} from "."`)
-
-// Step 4: rewrite siblings
-for (const edit of siblingEdits) {
-  fs.writeFileSync(edit.file, edit.content)
-}
-if (siblingEdits.length > 0) {
-  console.log(`  rewrote ${siblingEdits.length} sibling file(s)`)
-}
-
-console.log(`\nDone. Verify with:`)
-console.log(`  cd packages/opencode`)
-console.log(`  bunx --bun tsgo --noEmit`)
-console.log(`  bun run --conditions=browser ./src/index.ts generate`)
-console.log(`  bun run test`)

+ 0 - 246
packages/opencode/script/unwrap-and-self-reexport.ts

@@ -1,246 +0,0 @@
-#!/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.
-//
-// When the file is itself `index.ts`, prefer `"."` over `"./index"` — both are
-// valid but `"."` matches the existing convention in the codebase (e.g.
-// pty/index.ts, file/index.ts, etc.) and avoids referencing "index" literally.
-const basename = path.basename(absPath, ".ts")
-const reexportSource = basename === "index" ? "." : `./${basename}`
-const assembled = [...before, ...rewrittenBody, ...after].join("\n")
-const trimmed = assembled.replace(/\s+$/g, "")
-const output = `${trimmed}\n\nexport * as ${nsName} from "${reexportSource}"\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 "${reexportSource}"`)
-  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 "${reexportSource}"`)
-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$/, "")}*`,
-)

+ 0 - 305
packages/opencode/script/unwrap-namespace.ts

@@ -1,305 +0,0 @@
-#!/usr/bin/env bun
-/**
- * Unwrap a TypeScript `export namespace` into flat exports + barrel.
- *
- * Usage:
- *   bun script/unwrap-namespace.ts src/bus/index.ts
- *   bun script/unwrap-namespace.ts src/bus/index.ts --dry-run
- *   bun script/unwrap-namespace.ts src/pty/index.ts --name service   # avoid collision with pty.ts
- *
- * What it does:
- *   1. Reads the file and finds the `export namespace Foo { ... }` block
- *      (uses ast-grep for accurate AST-based boundary detection)
- *   2. Removes the namespace wrapper and dedents the body
- *   3. Fixes self-references (e.g. Config.PermissionAction → PermissionAction)
- *   4. If the file is index.ts, renames it to <lowercase-name>.ts
- *   5. Creates/updates index.ts with `export * as Foo from "./<file>"`
- *   6. Rewrites import paths across src/, test/, and script/
- *   7. Fixes sibling imports within the same directory
- *
- * Requires: ast-grep (`brew install ast-grep` or `cargo install ast-grep`)
- */
-
-import path from "path"
-import fs from "fs"
-
-const args = process.argv.slice(2)
-const dryRun = args.includes("--dry-run")
-const nameFlag = args.find((a, i) => args[i - 1] === "--name")
-const filePath = args.find((a) => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--name")
-
-if (!filePath) {
-  console.error("Usage: bun script/unwrap-namespace.ts <file> [--dry-run] [--name <impl-name>]")
-  process.exit(1)
-}
-
-const absPath = path.resolve(filePath)
-if (!fs.existsSync(absPath)) {
-  console.error(`File not found: ${absPath}`)
-  process.exit(1)
-}
-
-const src = fs.readFileSync(absPath, "utf-8")
-const lines = src.split("\n")
-
-// Use ast-grep to find the namespace boundaries accurately.
-// This avoids false matches from braces in strings, templates, comments, etc.
-const astResult = Bun.spawnSync(
-  ["ast-grep", "run", "--pattern", "export namespace $NAME { $$$BODY }", "--lang", "typescript", "--json", absPath],
-  { stdout: "pipe", stderr: "pipe" },
-)
-
-if (astResult.exitCode !== 0) {
-  console.error("ast-grep failed:", astResult.stderr.toString())
-  process.exit(1)
-}
-
-const matches = JSON.parse(astResult.stdout.toString()) as Array<{
-  text: string
-  range: { start: { line: number; column: number }; end: { line: number; column: number } }
-  metaVariables: { single: Record<string, { text: string }>; multi: Record<string, Array<{ text: string }>> }
-}>
-
-if (matches.length === 0) {
-  console.error("No `export namespace Foo { ... }` found in file")
-  process.exit(1)
-}
-
-if (matches.length > 1) {
-  console.error(`Found ${matches.length} namespaces — this script handles one at a time`)
-  console.error("Namespaces found:")
-  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 nsLine = match.range.start.line // 0-indexed
-const closeLine = match.range.end.line // 0-indexed, the line with closing `}`
-
-console.log(`Found: export namespace ${nsName} { ... }`)
-console.log(`  Lines ${nsLine + 1}–${closeLine + 1} (${closeLine - nsLine + 1} lines)`)
-
-// Build the new file content:
-// 1. Everything before the namespace declaration (imports, etc.)
-// 2. The namespace body, dedented by one level (2 spaces)
-// 3. Everything after the closing brace (rare, but possible)
-const before = lines.slice(0, nsLine)
-const body = lines.slice(nsLine + 1, closeLine)
-const after = lines.slice(closeLine + 1)
-
-// Dedent: remove exactly 2 leading spaces from each line
-const dedented = body.map((line) => {
-  if (line === "") return ""
-  if (line.startsWith("  ")) return line.slice(2)
-  return line
-})
-
-let newContent = [...before, ...dedented, ...after].join("\n")
-
-// --- Fix self-references ---
-// After unwrapping, references like `Config.PermissionAction` inside the same file
-// need to become just `PermissionAction`. Only fix code positions, not strings.
-const exportedNames = new Set<string>()
-const exportRegex = /export\s+(?:const|function|class|interface|type|enum|abstract\s+class)\s+(\w+)/g
-for (const line of dedented) {
-  for (const m of line.matchAll(exportRegex)) exportedNames.add(m[1])
-}
-const reExportRegex = /export\s*\{\s*([^}]+)\}/g
-for (const line of dedented) {
-  for (const m of line.matchAll(reExportRegex)) {
-    for (const name of m[1].split(",")) {
-      const trimmed = name
-        .trim()
-        .split(/\s+as\s+/)
-        .pop()!
-        .trim()
-      if (trimmed) exportedNames.add(trimmed)
-    }
-  }
-}
-
-let selfRefCount = 0
-if (exportedNames.size > 0) {
-  const fixedLines = newContent.split("\n").map((line) => {
-    // Split line into string-literal and code segments to avoid replacing inside strings
-    const segments: Array<{ text: string; isString: boolean }> = []
-    let i = 0
-    let current = ""
-    let inString: string | null = null
-
-    while (i < line.length) {
-      const ch = line[i]
-      if (inString) {
-        current += ch
-        if (ch === "\\" && i + 1 < line.length) {
-          current += line[i + 1]
-          i += 2
-          continue
-        }
-        if (ch === inString) {
-          segments.push({ text: current, isString: true })
-          current = ""
-          inString = null
-        }
-        i++
-        continue
-      }
-      if (ch === '"' || ch === "'" || ch === "`") {
-        if (current) segments.push({ text: current, isString: false })
-        current = ch
-        inString = ch
-        i++
-        continue
-      }
-      if (ch === "/" && i + 1 < line.length && line[i + 1] === "/") {
-        current += line.slice(i)
-        segments.push({ text: current, isString: true })
-        current = ""
-        i = line.length
-        continue
-      }
-      current += ch
-      i++
-    }
-    if (current) segments.push({ text: current, isString: !!inString })
-
-    return segments
-      .map((seg) => {
-        if (seg.isString) return seg.text
-        let result = seg.text
-        for (const name of exportedNames) {
-          const pattern = `${nsName}.${name}`
-          while (result.includes(pattern)) {
-            const idx = result.indexOf(pattern)
-            const charBefore = idx > 0 ? result[idx - 1] : " "
-            const charAfter = idx + pattern.length < result.length ? result[idx + pattern.length] : " "
-            if (/\w/.test(charBefore) || /\w/.test(charAfter)) break
-            result = result.slice(0, idx) + name + result.slice(idx + pattern.length)
-            selfRefCount++
-          }
-        }
-        return result
-      })
-      .join("")
-  })
-  newContent = fixedLines.join("\n")
-}
-
-// Figure out file naming
-const dir = path.dirname(absPath)
-const basename = path.basename(absPath, ".ts")
-const isIndex = basename === "index"
-const implName = nameFlag ?? (isIndex ? nsName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase() : basename)
-const implFile = path.join(dir, `${implName}.ts`)
-const indexFile = path.join(dir, "index.ts")
-const barrelLine = `export * as ${nsName} from "./${implName}"\n`
-
-console.log("")
-if (isIndex) {
-  console.log(`Plan: rename ${basename}.ts → ${implName}.ts, create new index.ts barrel`)
-} else {
-  console.log(`Plan: rewrite ${basename}.ts in place, create index.ts barrel`)
-}
-if (selfRefCount > 0) console.log(`Fixed ${selfRefCount} self-reference(s) (${nsName}.X → X)`)
-console.log("")
-
-if (dryRun) {
-  console.log("--- DRY RUN ---")
-  console.log("")
-  console.log(`=== ${implName}.ts (first 30 lines) ===`)
-  newContent
-    .split("\n")
-    .slice(0, 30)
-    .forEach((l, i) => console.log(`  ${i + 1}: ${l}`))
-  console.log("  ...")
-  console.log("")
-  console.log(`=== index.ts ===`)
-  console.log(`  ${barrelLine.trim()}`)
-  console.log("")
-  if (!isIndex) {
-    const relDir = path.relative(path.resolve("src"), dir)
-    console.log(`=== Import rewrites (would apply) ===`)
-    console.log(`  ${relDir}/${basename}" → ${relDir}" across src/, test/, script/`)
-  } else {
-    console.log("No import rewrites needed (was index.ts)")
-  }
-} else {
-  if (isIndex) {
-    fs.writeFileSync(implFile, newContent)
-    fs.writeFileSync(indexFile, barrelLine)
-    console.log(`Wrote ${implName}.ts (${newContent.split("\n").length} lines)`)
-    console.log(`Wrote index.ts (barrel)`)
-  } else {
-    fs.writeFileSync(absPath, newContent)
-    if (fs.existsSync(indexFile)) {
-      const existing = fs.readFileSync(indexFile, "utf-8")
-      if (!existing.includes(`export * as ${nsName}`)) {
-        fs.appendFileSync(indexFile, barrelLine)
-        console.log(`Appended to existing index.ts`)
-      } else {
-        console.log(`index.ts already has ${nsName} export`)
-      }
-    } else {
-      fs.writeFileSync(indexFile, barrelLine)
-      console.log(`Wrote index.ts (barrel)`)
-    }
-    console.log(`Rewrote ${basename}.ts (${newContent.split("\n").length} lines)`)
-  }
-
-  // --- Rewrite import paths across src/, test/, script/ ---
-  const relDir = path.relative(path.resolve("src"), dir)
-  if (!isIndex) {
-    const oldTail = `${relDir}/${basename}`
-    const searchDirs = ["src", "test", "script"].filter((d) => fs.existsSync(d))
-    const rgResult = Bun.spawnSync(["rg", "-l", `from.*${oldTail}"`, ...searchDirs], {
-      stdout: "pipe",
-      stderr: "pipe",
-    })
-    const filesToRewrite = rgResult.stdout
-      .toString()
-      .trim()
-      .split("\n")
-      .filter((f) => f.length > 0)
-
-    if (filesToRewrite.length > 0) {
-      console.log(`\nRewriting imports in ${filesToRewrite.length} file(s)...`)
-      for (const file of filesToRewrite) {
-        const content = fs.readFileSync(file, "utf-8")
-        fs.writeFileSync(file, content.replaceAll(`${oldTail}"`, `${relDir}"`))
-      }
-      console.log(`  Done: ${oldTail}" → ${relDir}"`)
-    } else {
-      console.log("\nNo import rewrites needed")
-    }
-  } else {
-    console.log("\nNo import rewrites needed (was index.ts)")
-  }
-
-  // --- Fix sibling imports within the same directory ---
-  const siblingFiles = fs.readdirSync(dir).filter((f) => {
-    if (!f.endsWith(".ts")) return false
-    if (f === "index.ts" || f === `${implName}.ts`) return false
-    return true
-  })
-
-  let siblingFixCount = 0
-  for (const sibFile of siblingFiles) {
-    const sibPath = path.join(dir, sibFile)
-    const content = fs.readFileSync(sibPath, "utf-8")
-    const pattern = new RegExp(`from\\s+["']\\./${basename}["']`, "g")
-    if (pattern.test(content)) {
-      fs.writeFileSync(sibPath, content.replace(pattern, `from "."`))
-      siblingFixCount++
-    }
-  }
-  if (siblingFixCount > 0) {
-    console.log(`Fixed ${siblingFixCount} sibling import(s) in ${path.basename(dir)}/ (./${basename} → .)`)
-  }
-}
-
-console.log("")
-console.log("=== Verify ===")
-console.log("")
-console.log("bunx --bun tsgo --noEmit   # typecheck")
-console.log("bun run test               # run tests")

+ 0 - 256
packages/opencode/specs/effect/namespace-treeshake.md

@@ -1,256 +0,0 @@
-# Namespace → self-reexport migration
-
-Migrate every `export namespace Foo { ... }` to flat top-level exports plus a
-single self-reexport line at the bottom of the same file:
-
-```ts
-export * as Foo from "./foo"
-```
-
-No barrel `index.ts` files. No cross-directory indirection. Consumers keep the
-exact same `import { Foo } from "../foo/foo"` ergonomics.
-
-## Why this pattern
-
-We tested three options against Bun, esbuild, Rollup (what Vite uses under the
-hood), Bun's runtime, and Node's native TypeScript runner.
-
-```
-                                       heavy.ts loaded?
-                                       A. namespace   B. barrel   C. self-reexport
-Bun bundler                            YES            YES         no
-esbuild                                YES            YES         no
-Rollup (Vite)                          YES            YES         no
-Bun runtime                            YES            YES         no
-Node --experimental-strip-types        SYNTAX ERROR   YES         no
-```
-
-- **`export namespace`** compiles to an IIFE. Bundlers see one opaque function
-  call and can't analyze what's used. Node's native TS runner rejects the
-  syntax outright: `SyntaxError: TypeScript namespace declaration is not
-supported in strip-only mode`.
-- **Barrel `index.ts`** files (`export * as Foo from "./foo"` in a separate
-  file) force every re-exported sibling to evaluate when you import one name.
-  Siblings with side effects (top-level imports of SDKs, etc.) always load.
-- **Self-reexport** keeps the file as plain ESM. Bundlers see static named
-  exports. The module is only pulled in when something actually imports from
-  it. There is no barrel hop, so no sibling contamination and no circular
-  import hazard.
-
-Bundle overhead for the self-reexport wrapper is roughly 240 bytes per module
-(`Object.defineProperty` namespace proxy). At ~100 modules that's ~24KB —
-negligible for a CLI binary.
-
-## The pattern
-
-### Before
-
-```ts
-// src/permission/arity.ts
-export namespace BashArity {
-  export function prefix(tokens: string[]) { ... }
-}
-```
-
-### After
-
-```ts
-// src/permission/arity.ts
-export function prefix(tokens: string[]) { ... }
-
-export * as BashArity from "./arity"
-```
-
-Consumers don't change at all:
-
-```ts
-import { BashArity } from "@/permission/arity"
-BashArity.prefix(...) // still works
-```
-
-Editors still auto-import `BashArity` like any named export, because the file
-does have a named `BashArity` export at the module top level.
-
-### Odd but harmless
-
-`BashArity.BashArity.BashArity.prefix(...)` compiles and runs because the
-namespace contains a re-export of itself. Nobody would write that. Not a
-problem.
-
-## Why this is different from what we tried first
-
-An earlier pass used sibling barrel files (`index.ts` with `export * as ...`).
-That turned out to be wrong for our constraints:
-
-1. The barrel file always loads all its sibling modules when you import
-   through it, even if you only need one. For our CLI this is exactly the
-   cost we're trying to avoid.
-2. Barrel + sibling imports made it very easy to accidentally create circular
-   imports that only surface as `ReferenceError` at runtime, not at
-   typecheck.
-
-The self-reexport has none of those issues. There is no indirection. The
-file and the namespace are the same unit.
-
-## Why this matters for startup
-
-The worst import chain in the codebase looks like:
-
-```
-src/index.ts
-  └── FormatError from src/cli/error.ts
-        ├── { Provider } from provider/provider.ts     (~1700 lines)
-        │     ├── 20+ @ai-sdk/* packages
-        │     ├── @aws-sdk/credential-providers
-        │     ├── google-auth-library
-        │     └── more
-        ├── { Config } from config/config.ts           (~1600 lines)
-        └── { MCP } from mcp/mcp.ts                    (~900 lines)
-```
-
-All of that currently gets pulled in just to do `.isInstance()` on a handful
-of error classes. The namespace IIFE shape is the main reason bundlers cannot
-strip the unused parts. Self-reexport + flat ESM fixes it.
-
-## Automation
-
-From `packages/opencode`:
-
-```bash
-bun script/unwrap-namespace.ts <file> [--dry-run]
-```
-
-The script:
-
-1. Uses ast-grep to locate the `export namespace Foo { ... }` block accurately.
-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`.
-5. Appends `export * as Foo from "./<basename>"` at the bottom of the file.
-6. Never creates a barrel `index.ts`.
-
-### Typical flow for one file
-
-```bash
-# 1. Preview
-bun script/unwrap-namespace.ts src/permission/arity.ts --dry-run
-
-# 2. Apply
-bun script/unwrap-namespace.ts src/permission/arity.ts
-
-# 3. Verify
-cd packages/opencode
-bunx --bun tsgo --noEmit
-bun run --conditions=browser ./src/index.ts generate
-bun run test <affected test files>
-```
-
-### Consumer imports usually don't need to change
-
-Most consumers already import straight from the file, e.g.:
-
-```ts
-import { BashArity } from "@/permission/arity"
-import { Config } from "@/config/config"
-```
-
-Because the file itself now does `export * as Foo from "./foo"`, those imports
-keep working with zero edits.
-
-The only edits needed are when a consumer was importing through a previous
-barrel (`"@/config"` or `"../config"` resolving to `config/index.ts`). In
-that case, repoint it at the file:
-
-```ts
-// before
-import { Config } from "@/config"
-
-// after
-import { Config } from "@/config/config"
-```
-
-### Dynamic imports in tests
-
-If a test did `const { Foo } = await import("../../src/x/y")`, the destructure
-still works because of the self-reexport. No change required.
-
-## Verification checklist (per PR)
-
-Run all of these locally before pushing:
-
-```bash
-cd packages/opencode
-bunx --bun tsgo --noEmit
-bun run --conditions=browser ./src/index.ts generate
-bun run test <affected test files>
-```
-
-Also do a quick grep in `src/`, `test/`, and `script/` to make sure no
-consumer is still importing the namespace from an old barrel path that no
-longer exports it.
-
-The SDK build step (`bun run --conditions=browser ./src/index.ts generate`)
-evaluates every module eagerly and is the most reliable way to catch circular
-import regressions at runtime — the typechecker does not catch these.
-
-## Rules for new code
-
-- No new `export namespace`.
-- Every module directory has a single canonical file — typically
-  `dir/index.ts` — with flat top-level exports and a self-reexport at the
-  bottom:
-  `export * as Foo from "."`
-- Consumers import from the directory:
-  `import { Foo } from "@/dir"` or `import { Foo } from "../dir"`.
-- No sibling barrel files. If a directory has multiple independent
-  namespaces, they each get their own file (e.g. `config/config.ts`,
-  `config/plugin.ts`) and their own self-reexport; the `index.ts` in that
-  directory stays minimal or does not exist.
-- If a file needs a sibling, import the sibling file directly:
-  `import * as Sibling from "./sibling"`, not `from "."`.
-
-### Why `dir/index.ts` + `"."` is fine for us
-
-A single-file module (e.g. `pty/`) can live entirely in `dir/index.ts`
-with `export * as Foo from "."` at the bottom. Consumers write the
-short form:
-
-```ts
-import { Pty } from "@/pty"
-```
-
-This works in Bun runtime, Bun build, esbuild, and Rollup. It does NOT
-work under Node's `--experimental-strip-types` runner:
-
-```
-node --experimental-strip-types entry.ts
-  ERR_UNSUPPORTED_DIR_IMPORT: Directory import '/.../pty' is not supported
-```
-
-Node requires an explicit file or a `package.json#exports` map for ESM.
-We don't care about that target right now because the opencode CLI is
-built with Bun and the web apps are built with Vite/Rollup. If we ever
-want to run raw `.ts` through Node, we'll need to either use explicit
-`.ts` extensions everywhere or add per-directory `package.json` exports
-maps.
-
-### When NOT to collapse to `index.ts`
-
-Some directories contain multiple independent namespaces where
-`dir/index.ts` would be misleading. Examples:
-
-- `config/` has `Config`, `ConfigPaths`, `ConfigMarkdown`, `ConfigPlugin`,
-  `ConfigKeybinds`. Each lives in its own file with its own self-reexport
-  (`config/config.ts`, `config/plugin.ts`, etc.). Consumers import the
-  specific one: `import { ConfigPlugin } from "@/config/plugin"`.
-- Same shape for `session/`, `server/`, etc.
-
-Collapsing one of those into `index.ts` would mean picking a single
-"canonical" namespace for the directory, which breaks the symmetry and
-hides the other files.
-
-## Scope
-
-There are still dozens of `export namespace` files left across the codebase.
-Each one is its own small PR. Do them one at a time, verified locally, rather
-than batching by directory.