diff --git a/src/core/config.ts b/src/core/config.ts index e4d3a00..504e96c 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises"; import { join, dirname } from "node:path"; import { parse as parseYaml } from "yaml"; import { defaultConfig } from "../defaults/config.ts"; +import { ConfigError } from "../cli/errors.ts"; import type { RuneConfig } from "../types.ts"; import { RUNE_DIR, CONFIG_FILE, CHANGES_DIR, ARCHIVE_DIR } from "../types.ts"; @@ -22,12 +23,68 @@ export function findProjectRoot( export async function loadConfig(projectRoot: string): Promise { const configPath = join(projectRoot, RUNE_DIR, CONFIG_FILE); + let merged: RuneConfig; try { const content = await readFile(configPath, "utf-8"); const userConfig = parseYaml(content) as Partial | null; - return mergeConfig(userConfig ?? {}); + merged = mergeConfig(userConfig ?? {}); } catch { - return mergeConfig({}); + merged = mergeConfig({}); + } + validateConfig(merged); + return merged; +} + +export function validateConfig(config: RuneConfig): void { + const plan = config.stages.plan; + if (!plan) return; + + const docNames = new Set(plan.documents.map((d) => d.name)); + + for (const doc of plan.documents) { + if (!doc.depend || doc.depend.length === 0) continue; + + for (const dep of doc.depend) { + if (dep === doc.name) { + throw new ConfigError(`文档 "${doc.name}" 不能依赖自身`); + } + if (!docNames.has(dep)) { + throw new ConfigError( + `文档 "${doc.name}" 依赖 "${dep}" 不存在于 plan.documents 中`, + ); + } + } + } + + const visited = new Set(); + const path: string[] = []; + + function hasCycle(name: string): boolean { + if (path.includes(name)) { + path.push(name); + return true; + } + if (visited.has(name)) return false; + visited.add(name); + path.push(name); + + const doc = plan!.documents.find((d) => d.name === name); + if (doc?.depend) { + for (const dep of doc.depend) { + if (hasCycle(dep)) return true; + } + } + path.pop(); + return false; + } + + for (const doc of plan.documents) { + path.length = 0; + if (hasCycle(doc.name)) { + throw new ConfigError( + `文档间存在循环依赖:${path.join(" → ")}`, + ); + } } } diff --git a/tests/core/config.test.ts b/tests/core/config.test.ts index 7d719c3..1f9f379 100644 --- a/tests/core/config.test.ts +++ b/tests/core/config.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { mkdir, writeFile, rm } from "node:fs/promises"; import { join } from "node:path"; -import { loadConfig, findProjectRoot, getRuneDir } from "../../src/core/config.ts"; +import { loadConfig, findProjectRoot, getRuneDir, validateConfig } from "../../src/core/config.ts"; +import { ConfigError } from "../../src/cli/errors.ts"; +import type { RuneConfig } from "../../src/types.ts"; const TMP_DIR = join(import.meta.dir, "__tmp_config_test__"); @@ -103,3 +105,77 @@ describe("getRuneDir", () => { expect(getRuneDir("/project")).toBe(join("/project", ".rune")); }); }); + +describe("validateConfig", () => { + it("正常配置不抛错", () => { + const config: RuneConfig = { + stages: { + plan: { + documents: [ + { name: "design", prompt: "生成设计" }, + { name: "task", prompt: "生成任务", depend: ["design"] }, + ], + }, + }, + }; + expect(() => validateConfig(config)).not.toThrow(); + }); + + it("depend 引用不存在的文档时报错", () => { + const config: RuneConfig = { + stages: { + plan: { + documents: [ + { name: "task", prompt: "生成任务", depend: ["nonexistent"] }, + ], + }, + }, + }; + expect(() => validateConfig(config)).toThrow(ConfigError); + }); + + it("自依赖报错", () => { + const config: RuneConfig = { + stages: { + plan: { + documents: [ + { name: "design", prompt: "生成设计", depend: ["design"] }, + ], + }, + }, + }; + expect(() => validateConfig(config)).toThrow(ConfigError); + }); + + it("循环依赖报错", () => { + const config: RuneConfig = { + stages: { + plan: { + documents: [ + { name: "a", prompt: "a", depend: ["b"] }, + { name: "b", prompt: "b", depend: ["a"] }, + ], + }, + }, + }; + expect(() => validateConfig(config)).toThrow(ConfigError); + }); + + it("无 plan 阶段时不报错", () => { + const config: RuneConfig = { stages: {} }; + expect(() => validateConfig(config)).not.toThrow(); + }); + + it("depend 为空数组时不报错", () => { + const config: RuneConfig = { + stages: { + plan: { + documents: [ + { name: "design", prompt: "生成设计", depend: [] }, + ], + }, + }, + }; + expect(() => validateConfig(config)).not.toThrow(); + }); +});