fix: 错误系统重构、消除静默吞错、update 修复、文档同步

This commit is contained in:
2026-06-10 21:04:34 +08:00
parent 4d206f39cc
commit 2552412f77
12 changed files with 99 additions and 55 deletions

View File

@@ -3,7 +3,7 @@ import { cac } from "cac";
import { join } from "node:path";
import { mkdir, rename } from "node:fs/promises";
import { readFileSync, existsSync } from "node:fs";
import { runInit } from "./commands/init.ts";
import { runInit, ensureMetadataCommand } from "./commands/init.ts";
import { findProjectRoot, loadConfig, getChangeDir, getArchiveDir } from "./core/config.ts";
import {
assembleDiscussPrompt,
@@ -131,8 +131,12 @@ cli.command("help [command]", "显示帮助信息").action(async (command?: stri
});
cli.command("version", "显示版本号").action(() => {
const pkg = JSON.parse(readFileSync(join(import.meta.dir, "../package.json"), "utf-8"));
console.log(`rune v${pkg.version}`);
try {
const pkg = JSON.parse(readFileSync(join(import.meta.dir, "../package.json"), "utf-8"));
console.log(`rune v${pkg.version}`);
} catch {
console.log("rune (未知版本)");
}
});
cli.command("init [...tools]", "初始化 Rune 并注入工具配置").action(async (tools: string[]) => {
@@ -178,16 +182,10 @@ cli.command("update [...tools]", "更新已注入的工具配置").action(async
if (!config?.metadata?.command) {
const detected = await detectCommandPrefix();
if (detected) {
const { readFile, appendFile } = await import("node:fs/promises");
const yaml = await import("yaml");
const { join: joinPath } = await import("node:path");
const configPath = joinPath(root, ".rune", "config.yaml");
try {
const content = await readFile(configPath, "utf-8");
const parsed = yaml.parse(content) as { metadata?: { command?: string } } | null;
if (!parsed?.metadata?.command) {
await appendFile(configPath, `\nmetadata:\n command: "${detected}"\n`);
}
await ensureMetadataCommand(configPath, detected);
} catch {}
}
}
@@ -229,7 +227,7 @@ cli
const config = await loadConfig(root);
const planDocs = config.stages.plan?.documents;
if (!planDocs || !planDocs.find((d) => d.name === documentName)) {
throw new CommandError(`文档 "${documentName}" 不在配置的 plan.documents `, {
throw new CommandError(`文档"${documentName}"不在配置的规划阶段文档列表`, {
hint: `可用文档:${planDocs?.map((d) => d.name).join(", ") ?? "无"}`,
});
}
@@ -237,7 +235,7 @@ cli
const changeDir = getChangeDir(root, changeName);
if (!existsSync(changeDir)) {
const prefix = getPmPrefix(config);
throw new CommandError(`变更 '${changeName}' 不存在`, {
throw new CommandError(`变更"${changeName}"不存在`, {
hint: `请先运行 ${prefix} create ${changeName} 创建变更`,
});
}
@@ -247,7 +245,7 @@ cli
const missing = doc.depend.filter((dep) => !existsSync(join(changeDir, `${dep}.md`)));
if (missing.length > 0) {
throw new CommandError(
`文档 "${documentName}" 的前置依赖未满足:${missing.map((d) => `${d}.md`).join("、")} 尚未完成`,
`文档"${documentName}"的前置依赖未满足:${missing.map((d) => `${d}.md`).join("、")} 尚未完成`,
{
hint: `请先完成依赖文档:${getPmPrefix(config)} plan ${changeName} ${missing[0]}`,
},
@@ -265,7 +263,7 @@ cli.command("build <change-name>", "构建阶段").action(async (changeName: str
const changeDir = getChangeDir(root, changeName);
if (!existsSync(changeDir)) {
const prefix = getPmPrefix();
throw new CommandError(`变更 '${changeName}' 不存在`, {
throw new CommandError(`变更"${changeName}"不存在`, {
hint: `请先运行 ${prefix} create ${changeName} 创建变更`,
});
}
@@ -280,7 +278,7 @@ cli.command("archive <change-name>", "归档阶段").action(async (changeName: s
const changeDir = getChangeDir(root, changeName);
if (!existsSync(changeDir)) {
const prefix = getPmPrefix();
throw new CommandError(`变更 '${changeName}' 不存在`, {
throw new CommandError(`变更"${changeName}"不存在`, {
hint: `请先运行 ${prefix} create ${changeName} 创建变更`,
});
}
@@ -295,7 +293,7 @@ cli.command("finish <change-name>", "归档变更").action(async (changeName: st
const changeDir = getChangeDir(root, changeName);
if (!existsSync(changeDir)) {
const prefix = getPmPrefix();
throw new CommandError(`变更 '${changeName}' 不存在`, {
throw new CommandError(`变更"${changeName}"不存在`, {
hint: `请先运行 ${prefix} create ${changeName} 创建变更`,
});
}
@@ -344,7 +342,7 @@ export function mapError(e: unknown): CliError {
const err = mapCacError(e);
if (err) return err;
}
return new InternalError();
return new InternalError(e);
}
function mapCacError(e: Error): CliError | null {
@@ -373,7 +371,7 @@ function mapCacError(e: Error): CliError | null {
if (e.message.includes("missing required args")) {
const match = e.message.match(/command `(\w+)/);
const cmd = match ? match[1] : "未知命令";
return new UsageError(`命令 '${cmd}' 缺少必填参数`, {
return new UsageError(`命令"${cmd}"缺少必填参数`, {
usage: `${prefix} ${cmd} <change-name>`,
hint: `运行 ${prefix} help ${cmd} 查看用法`,
});

View File

@@ -17,7 +17,18 @@ export class ConfigError extends CliError {}
export class CommandError extends CliError {}
export class InternalError extends CliError {
constructor() {
super("发生了未预期的错误");
readonly cause?: Error;
constructor(originalError?: unknown) {
const message =
originalError instanceof Error
? `发生了未预期的错误:${originalError.message}`
: "发生了未预期的错误";
const hint =
originalError instanceof Error
? `错误类型:${originalError.constructor.name}\n调用栈${originalError.stack ?? "无"}`
: undefined;
super(message, { hint });
this.cause = originalError instanceof Error ? originalError : undefined;
}
}

View File

@@ -50,7 +50,7 @@ export const SUPPORTED_TOOLS: Record<string, (root: string, command?: string) =>
"claude-code": injectClaudeCode,
};
async function ensureMetadataCommand(configPath: string, command: string): Promise<void> {
export async function ensureMetadataCommand(configPath: string, command: string): Promise<void> {
const content = await readFile(configPath, "utf-8");
const parsed = parseYaml(content) as { metadata?: { command?: string } } | null;
if (parsed?.metadata?.command) return;

View File

@@ -30,7 +30,7 @@ export async function assemblePlanPrompt(
const doc = plan.documents.find((d) => d.name === documentName);
if (!doc) {
throw new CommandError(`文档 "${documentName}" 不在配置的 plan.documents `, {
throw new CommandError(`文档"${documentName}"不在配置的规划阶段文档列表`, {
hint: `可用文档:${plan.documents.map((d) => d.name).join(", ")}`,
});
}
@@ -95,7 +95,7 @@ export async function assembleBuildPrompt(
taskContent = await readFile(taskPath, "utf-8");
} catch {
const prefix = getPmPrefix(config);
throw new CommandError(`变更 "${changeName}" 尚未完成规划task.md 不存在`, {
throw new CommandError(`变更"${changeName}"尚未完成规划task.md 不存在`, {
hint: `请先完成规划阶段:${prefix} plan ${changeName} task 生成任务文档`,
});
}
@@ -161,8 +161,11 @@ export async function assembleArchivePrompt(
parts.push("如用户确认,则继续执行归档操作;否则中止并返回构建阶段。");
parts.push("");
}
} catch {
// task.md 读取失败时不追加警告
} catch (e) {
const code = (e as NodeJS.ErrnoException)?.code;
if (code !== "ENOENT" && code !== "ENOTDIR") {
throw e;
}
}
}
}

View File

@@ -4,6 +4,7 @@ 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 { getPmPrefix } from "./pm.ts";
import type { RuneConfig } from "../types.ts";
import { RUNE_DIR, CONFIG_FILE, CHANGES_DIR, ARCHIVE_DIR } from "../types.ts";
@@ -26,8 +27,15 @@ export async function loadConfig(projectRoot: string): Promise<RuneConfig> {
const content = await readFile(configPath, "utf-8");
const userConfig = parseYaml(content) as Partial<RuneConfig> | null;
merged = mergeConfig(userConfig ?? {});
} catch {
merged = mergeConfig({});
} catch (e) {
const code = (e as NodeJS.ErrnoException)?.code;
if (code === "ENOENT" || code === "ENOTDIR") {
merged = mergeConfig({});
} else {
throw new ConfigError(`配置文件加载失败:${configPath}\n${(e as Error).message}`, {
hint: `请检查 .rune/config.yaml 的格式是否正确。常见问题:\n - YAML 缩进必须使用空格,不能用 Tab\n - 字符串包含特殊字符时需要引号包裹\n - 运行 ${getPmPrefix()} init 重新生成默认配置`,
});
}
}
validateConfig(merged);
return merged;
@@ -37,10 +45,15 @@ export function validateConfig(config: RuneConfig): void {
const plan = config.stages.plan;
if (!plan) return;
if (config.metadata?.tracked && plan) {
if (config.metadata?.tracked) {
const hasTaskDoc = plan.documents.some((d) => d.name === "task");
if (!hasTaskDoc) {
throw new ConfigError('tracked 开启时 plan.documents 必须包含 name 为 "task" 的文档');
throw new ConfigError(
"配置校验失败开启了任务追踪metadata.tracked但规划阶段的文档列表中没有 task 文档",
{
hint: `请在 .rune/config.yaml 的 stages.plan.documents 中添加 task 文档:\n - name: task\n prompt: 生成任务清单\n depend: [design]`,
},
);
}
}
@@ -51,10 +64,14 @@ export function validateConfig(config: RuneConfig): void {
for (const dep of doc.depend) {
if (dep === doc.name) {
throw new ConfigError(`文档 "${doc.name}" 不能依赖自身`);
throw new ConfigError(`文档"${doc.name}"不能依赖自身`, {
hint: `请从文档"${doc.name}"的 depend 列表中移除自身引用`,
});
}
if (!docNames.has(dep)) {
throw new ConfigError(`文档 "${doc.name}" 依赖 "${dep}" 不存在于 plan.documents `);
throw new ConfigError(`文档"${doc.name}"依赖"${dep}"不存在于规划阶段的文档列表`, {
hint: `请在 stages.plan.documents 中添加文档"${dep}",或从文档"${doc.name}"的 depend 列表中移除"${dep}"`,
});
}
}
}
@@ -84,7 +101,9 @@ export function validateConfig(config: RuneConfig): void {
for (const doc of plan.documents) {
path.length = 0;
if (hasCycle(doc.name)) {
throw new ConfigError(`文档间存在循环依赖:${path.join(" → ")}`);
throw new ConfigError(`文档间存在循环依赖:${path.join(" → ")}`, {
hint: "请检查文档的 depend 配置,移除形成环路的依赖关系",
});
}
}
}

View File

@@ -8,6 +8,7 @@ export function inferFromEnvironment(
): string | null {
if (execPath.includes("bun")) return "bunx @lanyuanxiaoyao/rune";
if (userAgent?.includes("pnpm")) return "pnpx @lanyuanxiaoyao/rune";
if (userAgent?.includes("yarn")) return "yarn dlx @lanyuanxiaoyao/rune";
if (userAgent?.includes("npm")) return "npx @lanyuanxiaoyao/rune";
return null;
}

View File

@@ -64,7 +64,12 @@ export async function scanChanges(
taskProgress,
});
}
} catch {}
} catch (e) {
const code = (e as NodeJS.ErrnoException)?.code;
if (code !== "ENOENT" && code !== "ENOTDIR") {
throw e;
}
}
return results;
}

View File

@@ -1,4 +1,5 @@
import type { TaskItem } from "../types.ts";
import { CommandError } from "../cli/errors.ts";
export function parseTasks(content: string): TaskItem[] {
const tasks: TaskItem[] = [];
@@ -15,7 +16,7 @@ export function parseTasks(content: string): TaskItem[] {
return tasks;
}
export class TaskFormatError extends Error {
export class TaskFormatError extends CommandError {
constructor(message: string) {
super(message);
this.name = this.constructor.name;