feat: 新增 validateConfig 校验 depend 引用、自依赖和循环依赖
This commit is contained in:
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
|
|||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
import { parse as parseYaml } from "yaml";
|
import { parse as parseYaml } from "yaml";
|
||||||
import { defaultConfig } from "../defaults/config.ts";
|
import { defaultConfig } from "../defaults/config.ts";
|
||||||
|
import { ConfigError } from "../cli/errors.ts";
|
||||||
import type { RuneConfig } from "../types.ts";
|
import type { RuneConfig } from "../types.ts";
|
||||||
import { RUNE_DIR, CONFIG_FILE, CHANGES_DIR, ARCHIVE_DIR } 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<RuneConfig> {
|
export async function loadConfig(projectRoot: string): Promise<RuneConfig> {
|
||||||
const configPath = join(projectRoot, RUNE_DIR, CONFIG_FILE);
|
const configPath = join(projectRoot, RUNE_DIR, CONFIG_FILE);
|
||||||
|
let merged: RuneConfig;
|
||||||
try {
|
try {
|
||||||
const content = await readFile(configPath, "utf-8");
|
const content = await readFile(configPath, "utf-8");
|
||||||
const userConfig = parseYaml(content) as Partial<RuneConfig> | null;
|
const userConfig = parseYaml(content) as Partial<RuneConfig> | null;
|
||||||
return mergeConfig(userConfig ?? {});
|
merged = mergeConfig(userConfig ?? {});
|
||||||
} catch {
|
} 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<string>();
|
||||||
|
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(" → ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||||
import { mkdir, writeFile, rm } from "node:fs/promises";
|
import { mkdir, writeFile, rm } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
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__");
|
const TMP_DIR = join(import.meta.dir, "__tmp_config_test__");
|
||||||
|
|
||||||
@@ -103,3 +105,77 @@ describe("getRuneDir", () => {
|
|||||||
expect(getRuneDir("/project")).toBe(join("/project", ".rune"));
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user