From 1cc1b05b13bf0779d75b0ec9f097722b5ea82a2e Mon Sep 17 00:00:00 2001 From: dadachi Date: Tue, 21 Apr 2026 12:38:45 +0900 Subject: [PATCH] Implement Day-3a: real Rails worker with Ruby AST rename helper - scripts/ruby/rename.rb: takes a rename plan + root on stdin, rewrites file contents AND renames files/dirs for PascalCase / snake_case / plural / SCREAMING variants. Uses a letter-aware boundary `(? ./out//rails via fs.cp with recursive filter (skips .git, node_modules, tmp, log, vendor/bundle, sockets/FIFOs); invokes rename.rb; runs `git init -q -b main` on the result. isStub("rails") gates the existing stub path for CI. Verified against walk-in clinic queue spec (stub planner for determinism): 305 files scanned, 125 changed, 2220 substitutions, 49 file/dir renames. ruby -c confirms syntax validity on renamed models; routes.rb, migrations, controllers, policies, serializers all rename coherently. No leftover Shop/Shopkeeper tokens. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/ruby/rename.rb | 119 ++++++++++++++++++++++++++++++++++++ src/agents/workers/rails.ts | 97 ++++++++++++++++++++++++++++- src/ruby.ts | 28 +++++++++ 3 files changed, 242 insertions(+), 2 deletions(-) create mode 100755 scripts/ruby/rename.rb create mode 100644 src/ruby.ts diff --git a/scripts/ruby/rename.rb b/scripts/ruby/rename.rb new file mode 100755 index 0000000..055f7ab --- /dev/null +++ b/scripts/ruby/rename.rb @@ -0,0 +1,119 @@ +#!/usr/bin/env ruby +# +# Apply a rename plan to a Rails project tree. +# Stdin: { "renamePlan": [{"from":"Shop","to":"Clinic"}, ...], "root": "/abs/path" } +# Stdout: { "files_scanned": N, "files_changed": N, "substitutions": N, "files_renamed": N } +# +# Rewrites file content AND renames files/directories for every +# PascalCase / snake_case / plural variant of each rename pair. +# Word-boundary matching via Regexp; crude-but-sufficient English +# pluralization. Skips .git, node_modules, tmp/, log/, vendor/bundle/. + +require "json" + +input = JSON.parse($stdin.read) +plan = input.fetch("renamePlan") +root = input.fetch("root") + +stats = { files_scanned: 0, files_changed: 0, substitutions: 0, files_renamed: 0 } + +SKIP_DIR_SEGMENTS = %w[.git node_modules tmp log].freeze +SKIP_SUBPATHS = %w[vendor/bundle].freeze +TEXT_EXTS = %w[.rb .erb .yml .yaml .json .md .gemspec .rake .ru .txt .sample .example .conf .html .css .scss .js .mjs .tt .lock].freeze +TEXT_BASENAMES = %w[Gemfile Gemfile.lock Rakefile Procfile Procfile.dev .gitignore .env.sample .ruby-version .node-version config.ru Dockerfile].freeze + +def pluralize(word) + if word.end_with?("y") && word.length > 1 && !%w[a e i o u].include?(word[-2]) + word[0..-2] + "ies" + elsif word.end_with?("s", "sh", "ch", "x", "z") + word + "es" + else + word + "s" + end +end + +def snake_case(pascal) + pascal.gsub(/([a-z\d])([A-Z])/, '\1_\2') + .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') + .downcase +end + +def build_patterns(from, to) + from_snake = snake_case(from) + to_snake = snake_case(to) + + # Ruby's \b treats `_` as a word char, so \bshop\b doesn't fire + # inside `shop_id` or `accounts_shopkeeper`. Use a letter-aware + # boundary instead: "no letter on either side of the match." + # Order matters: plural forms first so e.g. "Shops" isn't + # partially-matched as "Shop" + residual "s". + [ + [/(? => new Promise((r) => { setTimeout(r, ms); }); +type RenameStats = { + files_scanned: number; + files_changed: number; + substitutions: number; + files_renamed: number; +}; + +const SKIP_RELATIVE_PATHS = ["/.git", "/node_modules", "/tmp", "/log", "/vendor/bundle"]; export async function runRailsWorker(domain: DomainSpec): Promise { + if (isStub("rails")) { + return runStubRailsWorker(domain); + } + + const substrate = process.env['NATEMPLATE_API']; + if (!substrate) { + throw new Error("rails worker: NATEMPLATE_API env var is not set; see CLAUDE.md Substrate section"); + } + + const outDir = resolve(process.cwd(), "out", domain.slug, "rails"); + + trace("rails", `copying substrate from ${substrate} to ${outDir}`); + await prepareFresh(outDir); + await copyFiltered(substrate, outDir); + + const plan = domain.renamePlan.map((p) => `${p.from}->${p.to}`).join(", "); + trace("rails", `running scripts/ruby/rename.rb: ${plan}`); + + const renameStats = await runRuby<{ renamePlan: readonly RenamePair[]; root: string }, RenameStats>( + "rename.rb", + { renamePlan: domain.renamePlan, root: outDir }, + ); + + trace( + "rails", + `scanned ${renameStats.files_scanned} files, changed ${renameStats.files_changed}, ${renameStats.substitutions} substitutions, ${renameStats.files_renamed} file/dir renames`, + ); + + await initGit(outDir); + trace("rails", `done (out/${domain.slug}/rails)`); + + return { + platform: "rails", + outDir: `./out/${domain.slug}/rails`, + filesTouched: renameStats.files_changed + renameStats.files_renamed, + }; +} + +async function prepareFresh(dir: string): Promise { + try { + await stat(dir); + await rm(dir, { recursive: true, force: true }); + } catch { + // dest doesn't exist — nothing to clean + } + await mkdir(dir, { recursive: true }); +} + +async function copyFiltered(src: string, dest: string): Promise { + await cp(src, dest, { + recursive: true, + force: true, + filter: async (source: string) => { + const rel = source.slice(src.length); + if (SKIP_RELATIVE_PATHS.some((p) => rel === p || rel.startsWith(`${p}/`))) return false; + try { + const s = await lstat(source); + if (s.isSocket() || s.isFIFO() || s.isBlockDevice() || s.isCharacterDevice()) return false; + } catch { + return false; + } + return true; + }, + }); +} + +async function initGit(dir: string): Promise { + await new Promise((resolvePromise, rejectPromise) => { + const child = spawn("git", ["init", "-q", "-b", "main"], { cwd: dir }); + child.on("close", (code) => { + if (code === 0) resolvePromise(); + else rejectPromise(new Error(`git init exited ${code}`)); + }); + child.on("error", rejectPromise); + }); +} + +const delay = (ms: number): Promise => new Promise((r) => { setTimeout(r, ms); }); + +async function runStubRailsWorker(domain: DomainSpec): Promise { const plan = domain.renamePlan.map((p) => `${p.from}->${p.to}`).join(", "); + trace("rails", "(stub mode)"); trace("rails", `copying substrate for ${domain.displayName}`); await delay(200); trace("rails", `renaming Ruby symbols: ${plan}`); diff --git a/src/ruby.ts b/src/ruby.ts new file mode 100644 index 0000000..4e0d2f0 --- /dev/null +++ b/src/ruby.ts @@ -0,0 +1,28 @@ +import { spawn } from "node:child_process"; +import { resolve } from "node:path"; + +export async function runRuby( + scriptName: string, + input: TInput, +): Promise { + const scriptPath = resolve(process.cwd(), "scripts/ruby", scriptName); + const child = spawn("ruby", [scriptPath]); + + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + + child.stdout.on("data", (c: Buffer) => stdoutChunks.push(c)); + child.stderr.on("data", (c: Buffer) => stderrChunks.push(c)); + + child.stdin.write(JSON.stringify(input)); + child.stdin.end(); + + const code: number = await new Promise((r) => { child.on("close", r); }); + if (code !== 0) { + const stderr = Buffer.concat(stderrChunks).toString("utf8"); + throw new Error(`ruby script ${scriptName} exited ${code}: ${stderr}`); + } + + const stdout = Buffer.concat(stdoutChunks).toString("utf8"); + return JSON.parse(stdout) as TOutput; +}