refactor: 修复代码审查发现的问题

- Bug修复: formatChangeStatus 使用实际配置而非 defaultConfig
- 统一 assembler 中所有错误抛出为 CommandError
- 提取 writeIfChanged 到 adapters/utils.ts,消除 claude-code/opencode 重复代码
- 导出 SUPPORTED_TOOLS,cli.ts update 命令复用同一工具注册表
- 提取 mapError/mapCacError 函数,支持单元测试
- 补充 claude-code 适配器测试(10 个用例)
- 补充 validateChangeName、formatChangeStatus、suggestNextStep、mapError 单元测试(18 个用例)
- 共新增 3 个测试文件,测试从 96 增至 133,全部通过
This commit is contained in:
2026-06-09 12:57:28 +08:00
parent 7b258f4d90
commit bfa0f29dd5
10 changed files with 459 additions and 60 deletions

View File

@@ -1,7 +1,8 @@
import { existsSync } from "node:fs";
import { mkdir, writeFile, readFile } from "node:fs/promises";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { STAGES } from "../types.ts";
import { writeIfChanged } from "./utils.ts";
const COMMANDS_DIR = ".claude/commands";
@@ -50,21 +51,9 @@ export async function updateClaudeCode(projectRoot: string): Promise<void> {
? "\n如果用户没有指定变更名称请向用户确认。"
: "";
const newContent = `执行以下命令,将输出作为当前阶段的工作指引:\n\`\`\`bash\n${cmd}\n\`\`\`${nameHint}\n`;
await writeIfChangedClaude(commandPath, newContent);
await writeIfChanged(commandPath, newContent);
}
const statusPath = join(projectRoot, COMMANDS_DIR, "rune-status.md");
await writeIfChangedClaude(statusPath, `执行以下命令查看变更状态:\n\`\`\`bash\nrune status\n\`\`\`\n`);
}
async function writeIfChangedClaude(filePath: string, newContent: string): Promise<void> {
try {
const existing = await readFile(filePath, "utf-8");
if (existing === newContent) {
return;
}
} catch {
// 文件不存在,创建
}
await writeFile(filePath, newContent);
await writeIfChanged(statusPath, `执行以下命令查看变更状态:\n\`\`\`bash\nrune status\n\`\`\`\n`);
}

View File

@@ -1,5 +1,5 @@
import { existsSync } from "node:fs";
import { mkdir, writeFile, readFile } from "node:fs/promises";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { STAGES } from "../types.ts";
@@ -66,17 +66,7 @@ export async function updateOpenCode(projectRoot: string): Promise<void> {
await writeIfChanged(statusSkillPath, generateStatusSkill());
}
async function writeIfChanged(filePath: string, newContent: string): Promise<void> {
try {
const existing = await readFile(filePath, "utf-8");
if (existing === newContent) {
return;
}
} catch {
// 文件不存在,创建
}
await writeFile(filePath, newContent);
}
import { writeIfChanged } from "./utils.ts";
function generateCommand(stage: string, hasChangeName: boolean): string {
if (hasChangeName) {

13
src/adapters/utils.ts Normal file
View File

@@ -0,0 +1,13 @@
import { readFile, writeFile } from "node:fs/promises";
export async function writeIfChanged(filePath: string, newContent: string): Promise<void> {
try {
const existing = await readFile(filePath, "utf-8");
if (existing === newContent) {
return;
}
} catch {
// 文件不存在,创建
}
await writeFile(filePath, newContent);
}

View File

@@ -15,8 +15,8 @@ import { scanChanges } from "./core/scanner.ts";
import { UsageError, ConfigError, CommandError, InternalError, CliError } from "./cli/errors.ts";
import { printError } from "./cli/output.ts";
import { showGlobalHelp, showCommandHelp } from "./cli/help.ts";
import type { ChangeStatus } from "./types.ts";
import { defaultConfig } from "./defaults/config.ts";
import type { ChangeStatus, RuneConfig } from "./types.ts";
function requireProjectRoot(): string {
const root = findProjectRoot();
@@ -26,7 +26,7 @@ function requireProjectRoot(): string {
return root;
}
function validateChangeName(name: string): void {
export function validateChangeName(name: string): void {
if (!/^[\u4e00-\u9fa5a-zA-Z-]+$/.test(name)) {
throw new CommandError(
`变更名 "${name}" 包含不支持的字符`,
@@ -35,16 +35,17 @@ function validateChangeName(name: string): void {
}
}
function formatChangeStatus(change: ChangeStatus): string {
export function formatChangeStatus(change: ChangeStatus, config?: RuneConfig): string {
const lines: string[] = [];
lines.push(`变更:${change.name}`);
lines.push(" 规划阶段:");
const planDocs = config?.stages.plan?.documents;
for (const doc of change.documents) {
if (doc.completed) {
lines.push(` ${doc.name}.md ✓ 已完成`);
} else {
const docConfig = defaultConfig.stages.plan?.documents.find((d) => d.name === doc.name);
const docConfig = planDocs?.find((d) => d.name === doc.name);
const depInfo = !doc.dependMet && docConfig?.depend?.length
? `(依赖 ${docConfig.depend.map((d) => `${d}.md`).join("、")}`
: "";
@@ -71,7 +72,7 @@ function formatChangeStatus(change: ChangeStatus): string {
return lines.join("\n");
}
function suggestNextStep(change: ChangeStatus): string {
export function suggestNextStep(change: ChangeStatus): string {
if (!change.planCompleted) {
const nextDoc = change.documents.find((d) => !d.completed && d.dependMet);
if (nextDoc) {
@@ -140,19 +141,20 @@ cli.command("update [...tools]", "更新已注入的工具配置").action(
const root = requireProjectRoot();
const { updateOpenCode } = await import("./adapters/opencode.ts");
const { updateClaudeCode } = await import("./adapters/claude-code.ts");
const validators: Record<string, (root: string) => Promise<void>> = {
const { SUPPORTED_TOOLS } = await import("./commands/init.ts");
const updaters: Record<string, (root: string) => Promise<void>> = {
opencode: updateOpenCode,
"claude-code": updateClaudeCode,
};
for (const tool of tools) {
if (!validators[tool]) {
if (!SUPPORTED_TOOLS[tool]) {
throw new CommandError(`不支持的工具: ${tool}`, {
hint: `支持的工具: ${Object.keys(validators).join(", ")}`,
hint: `支持的工具: ${Object.keys(SUPPORTED_TOOLS).join(", ")}`,
});
}
}
for (const tool of tools) {
await validators[tool](root);
await updaters[tool](root);
}
console.log(`工具配置已更新:${tools.join(", ")}`);
},
@@ -252,51 +254,66 @@ cli.command("status [change-name]", "查看变更状态").action(
hint: "运行 rune status 查看所有变更",
});
}
console.log(formatChangeStatus(change));
console.log(formatChangeStatus(change, config));
} else {
if (changes.length === 0) {
console.log("当前无进行中的变更。");
return;
}
for (const change of changes) {
console.log(formatChangeStatus(change));
console.log(formatChangeStatus(change, config));
console.log("---\n");
}
}
},
);
function handleError(e: unknown): never {
export function mapError(e: unknown): CliError {
if (e instanceof CliError) {
printError(e);
} else if (e instanceof Error && e.message.includes("Unknown option")) {
return e;
}
if (e instanceof Error) {
const err = mapCacError(e);
if (err) return err;
}
return new InternalError();
}
function mapCacError(e: Error): CliError | null {
if (e.message.includes("Unknown option")) {
const match = e.message.match(/Unknown option `([^`]+)`/);
const flag = match ? match[1] : "未知选项";
printError(new UsageError(`未知选项: ${flag}`, {
return new UsageError(`未知选项: ${flag}`, {
hint: "运行 rune help 查看所有命令",
}));
} else if (e instanceof Error && e.message.includes("Unknown command")) {
});
}
if (e.message.includes("Unknown command")) {
const match = e.message.match(/Unknown command `([^`]+)`/);
const cmd = match ? match[1] : "未知命令";
printError(new UsageError(`未知命令: ${cmd}`, {
return new UsageError(`未知命令: ${cmd}`, {
hint: "运行 rune help 查看所有命令",
}));
} else if (e instanceof Error && e.message.includes("Unused args")) {
});
}
if (e.message.includes("Unused args")) {
const match = e.message.match(/Unused args: (.+)/);
const args = match ? match[1] : "未知参数";
printError(new UsageError(`未知命令: ${args.replace(/`/g, "")}`, {
return new UsageError(`未知命令: ${args.replace(/`/g, "")}`, {
hint: "运行 rune help 查看所有命令",
}));
} else if (e instanceof Error && e.message.includes("missing required args")) {
});
}
if (e.message.includes("missing required args")) {
const match = e.message.match(/command `(\w+)/);
const cmd = match ? match[1] : "未知命令";
printError(new UsageError(`命令 '${cmd}' 缺少必填参数`, {
return new UsageError(`命令 '${cmd}' 缺少必填参数`, {
usage: `rune ${cmd} <change-name>`,
hint: `运行 rune help ${cmd} 查看用法`,
}));
} else {
printError(new InternalError());
});
}
return null;
}
function handleError(e: unknown): never {
printError(mapError(e));
}
process.on("unhandledRejection", (e) => {

View File

@@ -38,7 +38,7 @@ const CONFIG_TEMPLATE = `# Rune 配置文件
# # {{change-name}} 任务清单
`;
const SUPPORTED_TOOLS: Record<string, (root: string) => Promise<void>> = {
export const SUPPORTED_TOOLS: Record<string, (root: string) => Promise<void>> = {
opencode: injectOpenCode,
"claude-code": injectClaudeCode,
};

View File

@@ -8,7 +8,9 @@ import { parseTasks } from "./task-parser.ts";
export function assembleDiscussPrompt(config: RuneConfig): string {
const discuss = config.stages.discuss;
if (!discuss) throw new Error("discuss 阶段未配置");
if (!discuss) throw new CommandError("讨论阶段未配置", {
hint: "请在 .rune/config.yaml 中配置 stages.discuss",
});
return discuss.prompt;
}
@@ -19,11 +21,15 @@ export async function assemblePlanPrompt(
documentName: string,
): Promise<string> {
const plan = config.stages.plan;
if (!plan) throw new Error("plan 阶段未配置");
if (!plan) throw new CommandError("规划阶段未配置", {
hint: "请在 .rune/config.yaml 中配置 stages.plan",
});
const doc = plan.documents.find((d) => d.name === documentName);
if (!doc) {
throw new Error(`文档 "${documentName}" 不在配置的 plan.documents 中`);
throw new CommandError(`文档 "${documentName}" 不在配置的 plan.documents 中`, {
hint: `可用文档:${plan.documents.map((d) => d.name).join(", ")}`,
});
}
const changeDir = getChangeDir(projectRoot, changeName);
@@ -117,7 +123,9 @@ export async function assembleArchivePrompt(
changeName: string,
): Promise<string> {
const archive = config.stages.archive;
if (!archive) throw new Error("archive 阶段未配置");
if (!archive) throw new CommandError("归档阶段未配置", {
hint: "请在 .rune/config.yaml 中配置 stages.archive",
});
const changeDir = getChangeDir(projectRoot, changeName);
const taskPath = join(changeDir, "task.md");