diff --git a/src/core/scanner.ts b/src/core/scanner.ts index 1ade412..5c339b5 100644 --- a/src/core/scanner.ts +++ b/src/core/scanner.ts @@ -1,14 +1,16 @@ import { readdir, stat, readFile } from "node:fs/promises"; import { join } from "node:path"; -import type { ChangeStatus } from "../types.ts"; +import type { ChangeStatus, DocumentStatus, RuneConfig } from "../types.ts"; import { getChangesDir, getArchiveDir } from "./config.ts"; import { parseTasks } from "./task-parser.ts"; export async function scanChanges( projectRoot: string, + config?: RuneConfig, ): Promise { const changesDir = getChangesDir(projectRoot); const results: ChangeStatus[] = []; + const planDocs = config?.stages.plan?.documents; try { const entries = await readdir(changesDir); @@ -17,11 +19,34 @@ export async function scanChanges( const entryStat = await stat(entryPath); if (!entryStat.isDirectory()) continue; - const docs = await readdir(entryPath); - const documents = docs.filter((d) => d.endsWith(".md")); + const files = await readdir(entryPath); + const mdFiles = new Set(files.filter((f) => f.endsWith(".md"))); + + let documents: DocumentStatus[]; + + if (planDocs) { + documents = planDocs.map((docConfig) => { + const fileName = `${docConfig.name}.md`; + const completed = mdFiles.has(fileName); + const deps = docConfig.depend ?? []; + const dependMet = + deps.length === 0 || + deps.every((dep) => mdFiles.has(`${dep}.md`)); + return { name: docConfig.name, completed, dependMet }; + }); + } else { + documents = Array.from(mdFiles).map((fileName) => ({ + name: fileName.replace(/\.md$/, ""), + completed: true, + dependMet: true, + })); + } + + const planCompleted = planDocs ? documents.every((d) => d.completed) : false; + const buildUnlocked = planCompleted; let taskProgress: { completed: number; total: number } | null = null; - const taskFile = docs.find((d) => d === "task.md"); + const taskFile = files.find((d) => d === "task.md"); if (taskFile) { const content = await readFile(join(entryPath, taskFile), "utf-8"); const tasks = parseTasks(content); @@ -31,7 +56,13 @@ export async function scanChanges( }; } - results.push({ name: entry, documents, taskProgress }); + results.push({ + name: entry, + documents, + planCompleted, + buildUnlocked, + taskProgress, + }); } } catch { } diff --git a/tests/core/scanner.test.ts b/tests/core/scanner.test.ts index 42ff04a..bd186eb 100644 --- a/tests/core/scanner.test.ts +++ b/tests/core/scanner.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { mkdir, writeFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { scanChanges, scanArchives } from "../../src/core/scanner.ts"; +import type { RuneConfig } from "../../src/types.ts"; const TMP_DIR = join(import.meta.dir, "__tmp_scanner_test__"); @@ -32,8 +33,11 @@ describe("scanChanges", () => { const changes = await scanChanges(TMP_DIR); expect(changes).toHaveLength(1); expect(changes[0].name).toBe("user-auth"); - expect(changes[0].documents).toContain("design.md"); - expect(changes[0].documents).toContain("task.md"); + const docNames = changes[0].documents.map((d) => `${d.name}.md`); + expect(docNames).toContain("design.md"); + expect(docNames).toContain("task.md"); + expect(changes[0].planCompleted).toBe(false); + expect(changes[0].buildUnlocked).toBe(false); expect(changes[0].taskProgress).toEqual({ completed: 1, total: 2 }); }); @@ -57,6 +61,93 @@ describe("scanChanges", () => { const changes = await scanChanges(TMP_DIR); expect(changes).toHaveLength(2); }); + + it("返回 DocumentStatus 含 completed 和 dependMet", async () => { + const changesDir = join(TMP_DIR, ".rune", "changes"); + await mkdir(join(changesDir, "user-auth"), { recursive: true }); + await writeFile(join(changesDir, "user-auth", "design.md"), "# 设计"); + + const config: RuneConfig = { + stages: { + plan: { + documents: [ + { name: "design", prompt: "生成设计" }, + { name: "task", prompt: "生成任务", depend: ["design"] }, + ], + }, + }, + }; + + const changes = await scanChanges(TMP_DIR, config); + expect(changes).toHaveLength(1); + expect(changes[0].documents).toHaveLength(2); + expect(changes[0].documents[0]).toEqual({ + name: "design", + completed: true, + dependMet: true, + }); + expect(changes[0].documents[1]).toEqual({ + name: "task", + completed: false, + dependMet: true, + }); + expect(changes[0].planCompleted).toBe(false); + expect(changes[0].buildUnlocked).toBe(false); + }); + + it("depend 未满足时 dependMet 为 false", async () => { + const changesDir = join(TMP_DIR, ".rune", "changes"); + await mkdir(join(changesDir, "user-auth"), { recursive: true }); + + const config: RuneConfig = { + stages: { + plan: { + documents: [ + { name: "design", prompt: "生成设计" }, + { name: "task", prompt: "生成任务", depend: ["design"] }, + ], + }, + }, + }; + + const changes = await scanChanges(TMP_DIR, config); + expect(changes[0].documents[1].dependMet).toBe(false); + }); + + it("所有文档完成时 planCompleted 和 buildUnlocked 为 true", async () => { + const changesDir = join(TMP_DIR, ".rune", "changes"); + await mkdir(join(changesDir, "user-auth"), { recursive: true }); + await writeFile(join(changesDir, "user-auth", "design.md"), "# 设计"); + await writeFile(join(changesDir, "user-auth", "task.md"), "- [ ] 任务"); + + const config: RuneConfig = { + stages: { + plan: { + documents: [ + { name: "design", prompt: "生成设计" }, + { name: "task", prompt: "生成任务" }, + ], + }, + }, + }; + + const changes = await scanChanges(TMP_DIR, config); + expect(changes[0].planCompleted).toBe(true); + expect(changes[0].buildUnlocked).toBe(true); + }); + + it("无 config 时使用文件扫描兼容模式", async () => { + const changesDir = join(TMP_DIR, ".rune", "changes"); + await mkdir(join(changesDir, "feature-a"), { recursive: true }); + await writeFile(join(changesDir, "feature-a", "design.md"), "# 设计"); + + const changes = await scanChanges(TMP_DIR); + expect(changes).toHaveLength(1); + expect(changes[0].documents[0].name).toBe("design"); + expect(changes[0].documents[0].completed).toBe(true); + expect(changes[0].planCompleted).toBe(false); + expect(changes[0].buildUnlocked).toBe(false); + }); }); describe("scanArchives", () => {