Sprint 004 핵심 구현 — F3 (QA 자동 라우팅 누락) + F4 (핸드오프 불안정) 해결:
Config (범용):
- src/config/schema.ts — Zod RailsConfig (pipeline/agents/discord)
- src/config/loader.ts — YAML + 환경변수 interpolation (${VAR})
- rails.config.example.yaml — 샘플 설정
Handoff:
- src/handoff/message.ts — HandoffMessage discriminated union (plan/implement/review/deploy)
- src/handoff/transport.ts — SisterTransport 인터페이스
- src/handoff/mock-transport.ts — 시나리오 override 가능한 mock
- src/handoff/discord-transport.ts — encodeInvokeMarker / decodeResultMarker
(HTML 주석 + json 블록 — 자매는 LLM 우회 파서로 처리)
DiscordPoster 인터페이스 주입으로 discord.js 와 독립 테스트 가능
Orchestrator:
- src/orchestrator/runner.ts — runPipeline E2E
state → stage 매핑 → transport.invoke → HandoffMessage → FSM 이벤트
타임아웃/에러는 ERROR 이벤트로 변환해 FSM 에 위임
CLI:
- rails run <project> [-r requirements] [-c config.yaml] [--mock]
Tests (16 신규, 57 total pass):
- HandoffMessage discriminated union 검증
- MockTransport 기본/오버라이드 시나리오
- Discord marker encode/decode round-trip
- DiscordTransport with fake poster
- Config loader YAML + 환경변수 interpolation
검증: tsc --noEmit ✓ | vitest 57/57 ✓ | build ✓ | rails run --help ✓
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
58 lines
1.8 KiB
TypeScript
58 lines
1.8 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
import { resolve } from "node:path";
|
|
import { parse as parseYaml } from "yaml";
|
|
import { RailsConfig, DEFAULT_CONFIG } from "./schema.js";
|
|
import type { RailsConfig as Config } from "./schema.js";
|
|
import { childLogger } from "../logger.js";
|
|
|
|
const log = childLogger({ module: "config-loader" });
|
|
|
|
/**
|
|
* Resolve ${VAR_NAME} patterns in string values against process.env.
|
|
* Returns the original string if no variable reference.
|
|
*/
|
|
function interpolate(value: unknown, env: Record<string, string>): unknown {
|
|
if (typeof value === "string") {
|
|
return value.replace(/\$\{([A-Z_][A-Z0-9_]*)\}/g, (_, name: string) => {
|
|
return env[name] ?? "";
|
|
});
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.map((v) => interpolate(v, env));
|
|
}
|
|
if (value && typeof value === "object") {
|
|
const result: Record<string, unknown> = {};
|
|
for (const [k, v] of Object.entries(value)) {
|
|
result[k] = interpolate(v, env);
|
|
}
|
|
return result;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export async function loadConfig(
|
|
configPath?: string,
|
|
env: Record<string, string> = process.env as Record<string, string>,
|
|
): Promise<Config> {
|
|
if (!configPath) {
|
|
log.info("No config file specified, using defaults");
|
|
return DEFAULT_CONFIG;
|
|
}
|
|
|
|
const fullPath = resolve(configPath);
|
|
try {
|
|
const raw = await readFile(fullPath, "utf8");
|
|
const parsed = parseYaml(raw) as unknown;
|
|
const interpolated = interpolate(parsed, env);
|
|
const config = RailsConfig.parse(interpolated);
|
|
log.info({ path: fullPath }, "Config loaded");
|
|
return config;
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
log.warn({ path: fullPath }, "Config file not found, using defaults");
|
|
return DEFAULT_CONFIG;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|