diff --git a/src/core/assembler.ts b/src/core/assembler.ts new file mode 100644 index 0000000..717c726 --- /dev/null +++ b/src/core/assembler.ts @@ -0,0 +1,102 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { RuneConfig } from "../types.ts"; +import { getChangeDir } from "./config.ts"; +import { parseTasks } from "./task-parser.ts"; + +export function assembleDiscussPrompt(config: RuneConfig): string { + const discuss = config.stages.discuss; + if (!discuss) throw new Error("discuss 阶段未配置"); + return discuss.prompt; +} + +export async function assemblePlanPrompt( + config: RuneConfig, + projectRoot: string, + changeName: string, +): Promise { + const plan = config.stages.plan; + if (!plan) throw new Error("plan 阶段未配置"); + + const changeDir = getChangeDir(projectRoot, changeName); + const parts: string[] = []; + + parts.push(`# 规划阶段:${changeName}\n`); + parts.push("请为当前变更生成以下文档:\n"); + + for (const doc of plan.documents) { + parts.push(`## 文档:${doc.name}.md`); + parts.push(doc.prompt); + + const docPath = join(changeDir, `${doc.name}.md`); + if (existsSync(docPath)) { + const existing = await readFile(docPath, "utf-8"); + parts.push(`\n### 已有内容(请在此基础上修订):\n${existing}`); + } + + if (doc.template) { + const rendered = doc.template.replace(/\{\{change-name\}\}/g, changeName); + parts.push(`\n### 格式模板:\n${rendered}`); + } + + parts.push(""); + } + + parts.push(`请将文档写入目录:${changeDir}`); + return parts.join("\n"); +} + +export async function assembleBuildPrompt( + config: RuneConfig, + projectRoot: string, + changeName: string, +): Promise { + const build = config.stages.build; + if (!build) throw new Error("build 阶段未配置"); + + const changeDir = getChangeDir(projectRoot, changeName); + const taskPath = join(changeDir, "task.md"); + + let taskContent: string; + try { + taskContent = await readFile(taskPath, "utf-8"); + } catch { + throw new Error(`task.md not found in ${changeDir}`); + } + + const tasks = parseTasks(taskContent); + const pendingTasks = tasks.filter((t) => !t.checked); + + if (pendingTasks.length === 0) { + return `所有任务已完成。变更 "${changeName}" 可以归档。`; + } + + const parts: string[] = []; + parts.push(`# 构建阶段:${changeName}\n`); + parts.push(build.prompt); + parts.push(`\n## 任务列表\n`); + parts.push(taskContent); + parts.push(`\n## 待执行任务(共 ${pendingTasks.length} 项)`); + for (const task of pendingTasks) { + parts.push(`- [ ] ${task.text}`); + } + parts.push( + `\n请从第一个待执行任务开始。完成后更新 ${taskPath} 中的 checkbox。`, + ); + + return parts.join("\n"); +} + +export function assembleArchivePrompt( + config: RuneConfig, + changeName: string, +): string { + const archive = config.stages.archive; + if (!archive) throw new Error("archive 阶段未配置"); + + const parts: string[] = []; + parts.push(`# 归档阶段:${changeName}\n`); + parts.push(archive.prompt); + return parts.join("\n"); +} diff --git a/tests/core/assembler.test.ts b/tests/core/assembler.test.ts new file mode 100644 index 0000000..5ea71e0 --- /dev/null +++ b/tests/core/assembler.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdir, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { + assembleDiscussPrompt, + assemblePlanPrompt, + assembleBuildPrompt, + assembleArchivePrompt, +} from "../../src/core/assembler.ts"; +import type { RuneConfig } from "../../src/types.ts"; +import { defaultConfig } from "../../src/defaults/config.ts"; + +const TMP_DIR = join(import.meta.dir, "__tmp_assembler_test__"); + +beforeEach(async () => { + await mkdir(TMP_DIR, { recursive: true }); +}); + +afterEach(async () => { + await rm(TMP_DIR, { recursive: true, force: true }); +}); + +describe("assembleDiscussPrompt", () => { + it("返回默认 discuss 提示词", () => { + const prompt = assembleDiscussPrompt(defaultConfig); + expect(prompt).toBeTruthy(); + expect(prompt).toContain("软件架构师"); + }); + + it("返回自定义 discuss 提示词", () => { + const config: RuneConfig = { + stages: { discuss: { prompt: "自定义讨论" } }, + }; + const prompt = assembleDiscussPrompt(config); + expect(prompt).toBe("自定义讨论"); + }); +}); + +describe("assemblePlanPrompt", () => { + it("包含变更名称和文档指引", async () => { + const prompt = await assemblePlanPrompt( + defaultConfig, + TMP_DIR, + "user-auth", + ); + expect(prompt).toContain("user-auth"); + expect(prompt).toContain("design"); + expect(prompt).toContain("task"); + expect(prompt).toContain("格式模板"); + }); + + it("包含已有文档内容(重复 plan 场景)", async () => { + const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth"); + await mkdir(changeDir, { recursive: true }); + await writeFile(join(changeDir, "design.md"), "# 已有设计"); + const prompt = await assemblePlanPrompt( + defaultConfig, + TMP_DIR, + "user-auth", + ); + expect(prompt).toContain("已有设计"); + expect(prompt).toContain("在此基础上修订"); + }); + + it("替换模板中的 {{change-name}}", async () => { + const prompt = await assemblePlanPrompt( + defaultConfig, + TMP_DIR, + "user-auth", + ); + expect(prompt).toContain("user-auth 设计文档"); + expect(prompt).toContain("user-auth 任务列表"); + expect(prompt).not.toContain("{{change-name}}"); + }); + + it("使用自定义 plan 配置", async () => { + const config: RuneConfig = { + stages: { + plan: { + documents: [ + { + name: "spec", + prompt: "生成规格", + template: "# {{change-name}} 规格", + }, + ], + }, + }, + }; + const prompt = await assemblePlanPrompt(config, TMP_DIR, "my-feature"); + expect(prompt).toContain("spec"); + expect(prompt).toContain("my-feature 规格"); + expect(prompt).not.toContain("design"); + }); +}); + +describe("assembleBuildPrompt", () => { + it("包含待执行任务列表", async () => { + const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth"); + await mkdir(changeDir, { recursive: true }); + await writeFile( + join(changeDir, "task.md"), + `- [x] 任务一\n- [ ] 任务二\n- [ ] 任务三`, + ); + const prompt = await assembleBuildPrompt( + defaultConfig, + TMP_DIR, + "user-auth", + ); + expect(prompt).toContain("任务二"); + expect(prompt).toContain("待执行任务"); + expect(prompt).toContain("共 2 项"); + }); + + it("所有任务完成时提示可归档", async () => { + const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth"); + await mkdir(changeDir, { recursive: true }); + await writeFile( + join(changeDir, "task.md"), + `- [x] 任务一\n- [x] 任务二`, + ); + const prompt = await assembleBuildPrompt( + defaultConfig, + TMP_DIR, + "user-auth", + ); + expect(prompt).toContain("已完成"); + expect(prompt).toContain("归档"); + }); + + it("task.md 不存在时抛出错误", async () => { + await expect( + assembleBuildPrompt(defaultConfig, TMP_DIR, "nonexistent"), + ).rejects.toThrow("task.md not found"); + }); +}); + +describe("assembleArchivePrompt", () => { + it("返回归档提示词", () => { + const prompt = assembleArchivePrompt(defaultConfig, "user-auth"); + expect(prompt).toContain("user-auth"); + expect(prompt).toContain("归档"); + }); +});