From fc772b935cc6f5e6d2ed6cedfc969014b5d2bebb Mon Sep 17 00:00:00 2001 From: dadachi Date: Tue, 21 Apr 2026 20:20:04 +0900 Subject: [PATCH] Day 5 (Rails slice): real Layer 2 via bin/rails routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills the runLayer2 stub. Strategy: `bin/rails routes` boots Rails fully (loads gems, resolves the class graph, compiles routes) and exits. No server spawn, no DB churn, no background processes to clean up — matches the spirit of "runtime check" without the operational cost. Three subprocess steps, each via `mise exec --` when mise is on PATH (auto-detected): 1. bundle check — skip (fast path) if gems are satisfied 2. bundle install — only runs when check fails (first-ever run or Ruby version change) 3. bin/rails routes — the actual Layer 2 assertion; exit 0 = pass mise wrapping is required because Node's spawn() doesn't trigger mise's directory-scoped Ruby switching. Without `mise exec`, the subprocess inherits the agent's default Ruby (may not match the generated project's .ruby-version) and bundler hard-fails on the mismatch. With `mise exec`, mise reads .ruby-version from cwd and resolves correctly. Perf on clinic-queue (freshly generated, clean gem cache): cold: 23.6s (full bundle install) warm: 1.3s (bundle check pass + routes) Layer2Input + Layer2Result reshaped: dropped the port / healthStatus fields (we're not booting a server) and added exitCode + command + durationMs to report what actually ran. Updated smoke test. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/validation/layer2.ts | 106 ++++++++++++++++++++++++++++++++++++--- tests/smoke.test.ts | 10 ++-- 2 files changed, 105 insertions(+), 11 deletions(-) diff --git a/src/validation/layer2.ts b/src/validation/layer2.ts index c9f83f6..b95fb19 100644 --- a/src/validation/layer2.ts +++ b/src/validation/layer2.ts @@ -1,17 +1,111 @@ +import { spawn } from "node:child_process"; +import { access } from "node:fs/promises"; +import { resolve } from "node:path"; + export type Layer2Input = { railsDir: string; - healthPath?: string; - bootTimeoutMs?: number; + timeoutMs?: number; }; export type Layer2Result = { pass: boolean; - port: number; - healthStatus: number | null; + command: string; + exitCode: number | null; + durationMs: number; stderrTail?: string; }; +const DEFAULT_TIMEOUT_MS = 300_000; + export async function runLayer2(input: Layer2Input): Promise { - void input; - throw new Error("runLayer2 not implemented: pick random port, spawn bin/rails server -p -b 127.0.0.1, poll healthPath (default /up) until 200 or bootTimeoutMs, then SIGTERM"); + const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const start = Date.now(); + + if (!(await exists(input.railsDir))) { + return { + pass: false, + command: "stat", + exitCode: null, + durationMs: Date.now() - start, + stderrTail: `railsDir does not exist: ${input.railsDir}`, + }; + } + + const useMise = await commandAvailable("mise"); + + const bundleCheck = await runRails(input.railsDir, ["bundle", "check"], timeoutMs, useMise); + if (bundleCheck.exitCode !== 0) { + const bundleInstall = await runRails(input.railsDir, ["bundle", "install"], timeoutMs, useMise); + if (bundleInstall.exitCode !== 0) { + return failed("bundle install", bundleInstall, start, useMise); + } + } + + const routes = await runRails(input.railsDir, ["bin/rails", "routes"], timeoutMs, useMise); + if (routes.exitCode !== 0) { + return failed("bin/rails routes", routes, start, useMise); + } + + return { + pass: true, + command: withPrefix("bin/rails routes", useMise), + exitCode: 0, + durationMs: Date.now() - start, + }; +} + +function failed(action: string, result: RunResult, start: number, useMise: boolean): Layer2Result { + return { + pass: false, + command: withPrefix(action, useMise), + exitCode: result.exitCode, + durationMs: Date.now() - start, + stderrTail: tail(result.stderr, 30), + }; +} + +function withPrefix(action: string, useMise: boolean): string { + return useMise ? `mise exec -- ${action}` : action; +} + +type RunResult = { exitCode: number | null; stderr: string }; + +function runRails(cwd: string, argv: readonly string[], timeoutMs: number, useMise: boolean): Promise { + const [command, ...rest] = useMise ? ["mise", "exec", "--", ...argv] : argv; + return new Promise((resolvePromise) => { + const child = spawn(command!, rest, { cwd }); + const stderrChunks: Buffer[] = []; + const timer = setTimeout(() => { child.kill("SIGTERM"); }, timeoutMs); + + child.stderr.on("data", (c: Buffer) => stderrChunks.push(c)); + child.on("close", (code) => { + clearTimeout(timer); + resolvePromise({ exitCode: code, stderr: Buffer.concat(stderrChunks).toString("utf8") }); + }); + child.on("error", () => { + clearTimeout(timer); + resolvePromise({ exitCode: null, stderr: Buffer.concat(stderrChunks).toString("utf8") }); + }); + }); +} + +async function commandAvailable(bin: string): Promise { + return new Promise((resolvePromise) => { + const c = spawn("which", [bin]); + c.on("close", (code) => resolvePromise(code === 0)); + c.on("error", () => resolvePromise(false)); + }); +} + +async function exists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +function tail(s: string, lines: number): string { + return s.split("\n").slice(-lines).join("\n"); } diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index 653375b..7ebe9e4 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -16,11 +16,11 @@ test("runLayer1 rejects until implemented", async () => { ); }); -test("runLayer2 rejects until implemented", async () => { - await assert.rejects( - runLayer2({ railsDir: "/tmp" }), - /not implemented/i, - ); +test("runLayer2 returns a failed result for a non-Rails directory", async () => { + const result = await runLayer2({ railsDir: "/tmp", timeoutMs: 10_000 }); + assert.equal(result.pass, false); + assert.equal(typeof result.command, "string"); + assert.equal(typeof result.durationMs, "number"); }); test("runLayer3 rejects until implemented", async () => {