fix: 错误系统重构、消除静默吞错、update 修复、文档同步
This commit is contained in:
@@ -24,12 +24,16 @@ src/
|
|||||||
│ ├── config.ts # 配置加载
|
│ ├── config.ts # 配置加载
|
||||||
│ ├── scanner.ts # 状态扫描
|
│ ├── scanner.ts # 状态扫描
|
||||||
│ ├── assembler.ts # 提示词拼装
|
│ ├── assembler.ts # 提示词拼装
|
||||||
│ └── task-parser.ts # 任务解析
|
│ ├── task-parser.ts # 任务解析
|
||||||
|
│ └── pm.ts # 包管理器检测与命令前缀
|
||||||
├── adapters/
|
├── adapters/
|
||||||
│ ├── opencode.ts # OpenCode 适配器
|
│ ├── opencode.ts # OpenCode 适配器
|
||||||
│ └── claude-code.ts # Claude Code 适配器(占位)
|
│ ├── claude-code.ts # Claude Code 适配器
|
||||||
└── defaults/
|
│ └── utils.ts # 适配器工具函数
|
||||||
└── config.ts # 内置默认配置
|
├── defaults/
|
||||||
|
│ └── config.ts # 内置默认配置
|
||||||
|
scripts/
|
||||||
|
└── release.ts # 发布脚本
|
||||||
|
|
||||||
tests/ # 测试目录(镜像 src 结构)
|
tests/ # 测试目录(镜像 src 结构)
|
||||||
```
|
```
|
||||||
@@ -98,7 +102,7 @@ CLI 通过子命令提供帮助和版本信息,不使用 `--help`/`--version`
|
|||||||
- **discuss**:不持久化讨论结果,完全依赖 AI 会话上下文传递;不设强制门控,通过提示词引导。讨论结束时引导用户运行 `rune create` 创建变更目录
|
- **discuss**:不持久化讨论结果,完全依赖 AI 会话上下文传递;不设强制门控,通过提示词引导。讨论结束时引导用户运行 `rune create` 创建变更目录
|
||||||
- **plan**:命令只输出提示词,不写入文件;AI 负责根据提示词生成文档内容并写入。新变更时引导用户先运行 `rune create` 创建目录。重复调用同一文档的 plan 会追加已有内容用于增量修订。依赖未满足时有友好提示(非报错)
|
- **plan**:命令只输出提示词,不写入文件;AI 负责根据提示词生成文档内容并写入。新变更时引导用户先运行 `rune create` 创建目录。重复调用同一文档的 plan 会追加已有内容用于增量修订。依赖未满足时有友好提示(非报错)
|
||||||
- **build**:按 task.md 的 checkbox 顺序执行;任务间无结构化依赖;可多次执行直到全部完成
|
- **build**:按 task.md 的 checkbox 顺序执行;任务间无结构化依赖;可多次执行直到全部完成
|
||||||
- **archive**:归档前命令行校验 task 完成状态,未完成时在提示词中注入警告并引导 AI 询问用户
|
- **archive**:输出归档提示词(含未完成任务的警告),引导 AI 汇总变更并确认。`finish` 命令执行实际的目录移动
|
||||||
|
|
||||||
**create**:CLI 辅助命令(非独立阶段),在 `.rune/changes/` 下创建变更目录。adapter 不为 create 生成独立的 skill/command 文件,使用引导嵌入在 discuss 和 plan 的 skill/command 内容中。
|
**create**:CLI 辅助命令(非独立阶段),在 `.rune/changes/` 下创建变更目录。adapter 不为 create 生成独立的 skill/command 文件,使用引导嵌入在 discuss 和 plan 的 skill/command 内容中。
|
||||||
|
|
||||||
@@ -116,6 +120,7 @@ CLI 通过子命令提供帮助和版本信息,不使用 `--help`/`--version`
|
|||||||
- 无并发锁,同一变更可被多个 agent 同时操作
|
- 无并发锁,同一变更可被多个 agent 同时操作
|
||||||
- 无需变更废弃命令,手动删除目录即可
|
- 无需变更废弃命令,手动删除目录即可
|
||||||
- 同一变更名同天多次归档不处理冲突(日期+名称去重)
|
- 同一变更名同天多次归档不处理冲突(日期+名称去重)
|
||||||
|
- archive 与 finish 分离:archive 只输出提示词,finish 执行实际的目录移动。分离原因是提示词阶段需要 AI 参与确认,而文件操作是确定性的
|
||||||
- plan skill 应引导 AI 先通过 `rune status` 获取文档列表
|
- plan skill 应引导 AI 先通过 `rune status` 获取文档列表
|
||||||
|
|
||||||
## 测试策略
|
## 测试策略
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -42,7 +42,7 @@ SDD 工作流包含固定的四个阶段,不可自定义增删:
|
|||||||
1. **讨论阶段** — `/rune-discuss`:与 AI 自由讨论需求和方案。讨论结果保留在 AI 会话上下文中传递到后续阶段,不持久化到文件。结束前会引导是否进入规划阶段。
|
1. **讨论阶段** — `/rune-discuss`:与 AI 自由讨论需求和方案。讨论结果保留在 AI 会话上下文中传递到后续阶段,不持久化到文件。结束前会引导是否进入规划阶段。
|
||||||
2. **规划阶段** — `/rune-plan <变更名> <文档名>`:按配置的文档模板生成规划文档。变更名仅支持中文、英文和短横线(`-`)。默认包含 `design`(设计文档)和 `task`(任务清单,依赖 design)两个文档。文档间支持 `depend` 字段声明前置依赖,依赖未满足时有友好提示。plan 命令自身不写入文件,只输出提示词供 AI 消费。
|
2. **规划阶段** — `/rune-plan <变更名> <文档名>`:按配置的文档模板生成规划文档。变更名仅支持中文、英文和短横线(`-`)。默认包含 `design`(设计文档)和 `task`(任务清单,依赖 design)两个文档。文档间支持 `depend` 字段声明前置依赖,依赖未满足时有友好提示。plan 命令自身不写入文件,只输出提示词供 AI 消费。
|
||||||
3. **构建阶段** — `/rune-build <变更名>`:按 task.md 中的任务顺序逐个实现。每个任务完成后更新对应的 checkbox 为 `[x]`。可多次执行直到所有任务完成。
|
3. **构建阶段** — `/rune-build <变更名>`:按 task.md 中的任务顺序逐个实现。每个任务完成后更新对应的 checkbox 为 `[x]`。可多次执行直到所有任务完成。
|
||||||
4. **归档阶段** — `/rune-archive <变更名>`:将变更目录移至 `archive/`。归档前自动检查 task.md 的完成状态,如有未完成任务会注入警告提示词,引导 AI 询问用户是否确认归档。
|
4. **归档阶段** — `/rune-archive <变更名>`:输出归档阶段提示词,引导 AI 汇总变更内容并确认归档。归档前自动检查 task.md 的完成状态,如有未完成任务会注入警告提示词,引导 AI 询问用户是否确认。确认后执行 `rune finish <变更名>` 将变更目录移动到 `archive/`。
|
||||||
|
|
||||||
> **辅助命令**:`rune create <变更名>` 用于在 `.rune/changes/` 下创建变更目录。它不是 SDD 阶段,而是在 discuss 结束后或 plan 开始前通过 CLI 运行的辅助命令。discuss 和 plan 的编辑器命令中已内嵌 create 的使用引导。
|
> **辅助命令**:`rune create <变更名>` 用于在 `.rune/changes/` 下创建变更目录。它不是 SDD 阶段,而是在 discuss 结束后或 plan 开始前通过 CLI 运行的辅助命令。discuss 和 plan 的编辑器命令中已内嵌 create 的使用引导。
|
||||||
|
|
||||||
@@ -63,16 +63,17 @@ bunx @lanyuanxiaoyao/rune help <command> # 显示指定命令的详细帮
|
|||||||
bunx @lanyuanxiaoyao/rune version # 显示版本号
|
bunx @lanyuanxiaoyao/rune version # 显示版本号
|
||||||
```
|
```
|
||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
| -------------------------------------------------- | ----------------------------------------------- |
|
| -------------------------------------------------- | ---------------------------------------------- |
|
||||||
| `bunx @lanyuanxiaoyao/rune init <tool>` | 初始化项目,注入编辑器配置 |
|
| `bunx @lanyuanxiaoyao/rune init <tool>` | 初始化项目,注入编辑器配置 |
|
||||||
| `bunx @lanyuanxiaoyao/rune update <tool>` | 更新编辑器的命令和 skill 文件 |
|
| `bunx @lanyuanxiaoyao/rune update <tool>` | 更新编辑器的命令和 skill 文件 |
|
||||||
| `bunx @lanyuanxiaoyao/rune discuss` | 输出讨论阶段提示词 |
|
| `bunx @lanyuanxiaoyao/rune discuss` | 输出讨论阶段提示词 |
|
||||||
| `bunx @lanyuanxiaoyao/rune create <变更名>` | 创建变更目录(discuss 和 plan 之间的辅助命令) |
|
| `bunx @lanyuanxiaoyao/rune create <变更名>` | 创建变更目录(discuss 和 plan 之间的辅助命令) |
|
||||||
| `bunx @lanyuanxiaoyao/rune plan <变更名> <文档名>` | 输出规划阶段提示词 |
|
| `bunx @lanyuanxiaoyao/rune plan <变更名> <文档名>` | 输出规划阶段提示词 |
|
||||||
| `bunx @lanyuanxiaoyao/rune build <变更名>` | 输出构建阶段提示词 |
|
| `bunx @lanyuanxiaoyao/rune build <变更名>` | 输出构建阶段提示词 |
|
||||||
| `bunx @lanyuanxiaoyao/rune archive <变更名>` | 输出归档阶段提示词,同时移动变更目录到 archive/ |
|
| `bunx @lanyuanxiaoyao/rune archive <变更名>` | 输出归档阶段提示词 |
|
||||||
| `bunx @lanyuanxiaoyao/rune status [变更名]` | 显示变更状态和下一步建议 |
|
| `bunx @lanyuanxiaoyao/rune finish <变更名>` | 归档变更(将变更目录移动到 archive/) |
|
||||||
|
| `bunx @lanyuanxiaoyao/rune status [变更名]` | 显示变更状态和下一步建议 |
|
||||||
|
|
||||||
### 自定义配置
|
### 自定义配置
|
||||||
|
|
||||||
|
|||||||
34
src/cli.ts
34
src/cli.ts
@@ -3,7 +3,7 @@ import { cac } from "cac";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { mkdir, rename } from "node:fs/promises";
|
import { mkdir, rename } from "node:fs/promises";
|
||||||
import { readFileSync, existsSync } from "node:fs";
|
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 { findProjectRoot, loadConfig, getChangeDir, getArchiveDir } from "./core/config.ts";
|
||||||
import {
|
import {
|
||||||
assembleDiscussPrompt,
|
assembleDiscussPrompt,
|
||||||
@@ -131,8 +131,12 @@ cli.command("help [command]", "显示帮助信息").action(async (command?: stri
|
|||||||
});
|
});
|
||||||
|
|
||||||
cli.command("version", "显示版本号").action(() => {
|
cli.command("version", "显示版本号").action(() => {
|
||||||
const pkg = JSON.parse(readFileSync(join(import.meta.dir, "../package.json"), "utf-8"));
|
try {
|
||||||
console.log(`rune v${pkg.version}`);
|
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[]) => {
|
cli.command("init [...tools]", "初始化 Rune 并注入工具配置").action(async (tools: string[]) => {
|
||||||
@@ -178,16 +182,10 @@ cli.command("update [...tools]", "更新已注入的工具配置").action(async
|
|||||||
if (!config?.metadata?.command) {
|
if (!config?.metadata?.command) {
|
||||||
const detected = await detectCommandPrefix();
|
const detected = await detectCommandPrefix();
|
||||||
if (detected) {
|
if (detected) {
|
||||||
const { readFile, appendFile } = await import("node:fs/promises");
|
|
||||||
const yaml = await import("yaml");
|
|
||||||
const { join: joinPath } = await import("node:path");
|
const { join: joinPath } = await import("node:path");
|
||||||
const configPath = joinPath(root, ".rune", "config.yaml");
|
const configPath = joinPath(root, ".rune", "config.yaml");
|
||||||
try {
|
try {
|
||||||
const content = await readFile(configPath, "utf-8");
|
await ensureMetadataCommand(configPath, detected);
|
||||||
const parsed = yaml.parse(content) as { metadata?: { command?: string } } | null;
|
|
||||||
if (!parsed?.metadata?.command) {
|
|
||||||
await appendFile(configPath, `\nmetadata:\n command: "${detected}"\n`);
|
|
||||||
}
|
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,7 +227,7 @@ cli
|
|||||||
const config = await loadConfig(root);
|
const config = await loadConfig(root);
|
||||||
const planDocs = config.stages.plan?.documents;
|
const planDocs = config.stages.plan?.documents;
|
||||||
if (!planDocs || !planDocs.find((d) => d.name === documentName)) {
|
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(", ") ?? "无"}`,
|
hint: `可用文档:${planDocs?.map((d) => d.name).join(", ") ?? "无"}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -237,7 +235,7 @@ cli
|
|||||||
const changeDir = getChangeDir(root, changeName);
|
const changeDir = getChangeDir(root, changeName);
|
||||||
if (!existsSync(changeDir)) {
|
if (!existsSync(changeDir)) {
|
||||||
const prefix = getPmPrefix(config);
|
const prefix = getPmPrefix(config);
|
||||||
throw new CommandError(`变更 '${changeName}' 不存在`, {
|
throw new CommandError(`变更"${changeName}"不存在`, {
|
||||||
hint: `请先运行 ${prefix} create ${changeName} 创建变更`,
|
hint: `请先运行 ${prefix} create ${changeName} 创建变更`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -247,7 +245,7 @@ cli
|
|||||||
const missing = doc.depend.filter((dep) => !existsSync(join(changeDir, `${dep}.md`)));
|
const missing = doc.depend.filter((dep) => !existsSync(join(changeDir, `${dep}.md`)));
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
throw new CommandError(
|
throw new CommandError(
|
||||||
`文档 "${documentName}" 的前置依赖未满足:${missing.map((d) => `${d}.md`).join("、")} 尚未完成`,
|
`文档"${documentName}"的前置依赖未满足:${missing.map((d) => `${d}.md`).join("、")} 尚未完成`,
|
||||||
{
|
{
|
||||||
hint: `请先完成依赖文档:${getPmPrefix(config)} plan ${changeName} ${missing[0]}`,
|
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);
|
const changeDir = getChangeDir(root, changeName);
|
||||||
if (!existsSync(changeDir)) {
|
if (!existsSync(changeDir)) {
|
||||||
const prefix = getPmPrefix();
|
const prefix = getPmPrefix();
|
||||||
throw new CommandError(`变更 '${changeName}' 不存在`, {
|
throw new CommandError(`变更"${changeName}"不存在`, {
|
||||||
hint: `请先运行 ${prefix} create ${changeName} 创建变更`,
|
hint: `请先运行 ${prefix} create ${changeName} 创建变更`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -280,7 +278,7 @@ cli.command("archive <change-name>", "归档阶段").action(async (changeName: s
|
|||||||
const changeDir = getChangeDir(root, changeName);
|
const changeDir = getChangeDir(root, changeName);
|
||||||
if (!existsSync(changeDir)) {
|
if (!existsSync(changeDir)) {
|
||||||
const prefix = getPmPrefix();
|
const prefix = getPmPrefix();
|
||||||
throw new CommandError(`变更 '${changeName}' 不存在`, {
|
throw new CommandError(`变更"${changeName}"不存在`, {
|
||||||
hint: `请先运行 ${prefix} create ${changeName} 创建变更`,
|
hint: `请先运行 ${prefix} create ${changeName} 创建变更`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -295,7 +293,7 @@ cli.command("finish <change-name>", "归档变更").action(async (changeName: st
|
|||||||
const changeDir = getChangeDir(root, changeName);
|
const changeDir = getChangeDir(root, changeName);
|
||||||
if (!existsSync(changeDir)) {
|
if (!existsSync(changeDir)) {
|
||||||
const prefix = getPmPrefix();
|
const prefix = getPmPrefix();
|
||||||
throw new CommandError(`变更 '${changeName}' 不存在`, {
|
throw new CommandError(`变更"${changeName}"不存在`, {
|
||||||
hint: `请先运行 ${prefix} create ${changeName} 创建变更`,
|
hint: `请先运行 ${prefix} create ${changeName} 创建变更`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -344,7 +342,7 @@ export function mapError(e: unknown): CliError {
|
|||||||
const err = mapCacError(e);
|
const err = mapCacError(e);
|
||||||
if (err) return err;
|
if (err) return err;
|
||||||
}
|
}
|
||||||
return new InternalError();
|
return new InternalError(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapCacError(e: Error): CliError | null {
|
function mapCacError(e: Error): CliError | null {
|
||||||
@@ -373,7 +371,7 @@ function mapCacError(e: Error): CliError | null {
|
|||||||
if (e.message.includes("missing required args")) {
|
if (e.message.includes("missing required args")) {
|
||||||
const match = e.message.match(/command `(\w+)/);
|
const match = e.message.match(/command `(\w+)/);
|
||||||
const cmd = match ? match[1] : "未知命令";
|
const cmd = match ? match[1] : "未知命令";
|
||||||
return new UsageError(`命令 '${cmd}' 缺少必填参数`, {
|
return new UsageError(`命令"${cmd}"缺少必填参数`, {
|
||||||
usage: `${prefix} ${cmd} <change-name>`,
|
usage: `${prefix} ${cmd} <change-name>`,
|
||||||
hint: `运行 ${prefix} help ${cmd} 查看用法`,
|
hint: `运行 ${prefix} help ${cmd} 查看用法`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,18 @@ export class ConfigError extends CliError {}
|
|||||||
export class CommandError extends CliError {}
|
export class CommandError extends CliError {}
|
||||||
|
|
||||||
export class InternalError extends CliError {
|
export class InternalError extends CliError {
|
||||||
constructor() {
|
readonly cause?: Error;
|
||||||
super("发生了未预期的错误");
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export const SUPPORTED_TOOLS: Record<string, (root: string, command?: string) =>
|
|||||||
"claude-code": injectClaudeCode,
|
"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 content = await readFile(configPath, "utf-8");
|
||||||
const parsed = parseYaml(content) as { metadata?: { command?: string } } | null;
|
const parsed = parseYaml(content) as { metadata?: { command?: string } } | null;
|
||||||
if (parsed?.metadata?.command) return;
|
if (parsed?.metadata?.command) return;
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export async function assemblePlanPrompt(
|
|||||||
|
|
||||||
const doc = plan.documents.find((d) => d.name === documentName);
|
const doc = plan.documents.find((d) => d.name === documentName);
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
throw new CommandError(`文档 "${documentName}" 不在配置的 plan.documents 中`, {
|
throw new CommandError(`文档"${documentName}"不在配置的规划阶段文档列表中`, {
|
||||||
hint: `可用文档:${plan.documents.map((d) => d.name).join(", ")}`,
|
hint: `可用文档:${plan.documents.map((d) => d.name).join(", ")}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -95,7 +95,7 @@ export async function assembleBuildPrompt(
|
|||||||
taskContent = await readFile(taskPath, "utf-8");
|
taskContent = await readFile(taskPath, "utf-8");
|
||||||
} catch {
|
} catch {
|
||||||
const prefix = getPmPrefix(config);
|
const prefix = getPmPrefix(config);
|
||||||
throw new CommandError(`变更 "${changeName}" 尚未完成规划,task.md 不存在`, {
|
throw new CommandError(`变更"${changeName}"尚未完成规划,task.md 不存在`, {
|
||||||
hint: `请先完成规划阶段:${prefix} plan ${changeName} task 生成任务文档`,
|
hint: `请先完成规划阶段:${prefix} plan ${changeName} task 生成任务文档`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -161,8 +161,11 @@ export async function assembleArchivePrompt(
|
|||||||
parts.push("如用户确认,则继续执行归档操作;否则中止并返回构建阶段。");
|
parts.push("如用户确认,则继续执行归档操作;否则中止并返回构建阶段。");
|
||||||
parts.push("");
|
parts.push("");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// task.md 读取失败时不追加警告
|
const code = (e as NodeJS.ErrnoException)?.code;
|
||||||
|
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 { ConfigError } from "../cli/errors.ts";
|
||||||
|
import { getPmPrefix } from "./pm.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";
|
||||||
|
|
||||||
@@ -26,8 +27,15 @@ export async function loadConfig(projectRoot: string): Promise<RuneConfig> {
|
|||||||
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;
|
||||||
merged = mergeConfig(userConfig ?? {});
|
merged = mergeConfig(userConfig ?? {});
|
||||||
} catch {
|
} catch (e) {
|
||||||
merged = mergeConfig({});
|
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);
|
validateConfig(merged);
|
||||||
return merged;
|
return merged;
|
||||||
@@ -37,10 +45,15 @@ export function validateConfig(config: RuneConfig): void {
|
|||||||
const plan = config.stages.plan;
|
const plan = config.stages.plan;
|
||||||
if (!plan) return;
|
if (!plan) return;
|
||||||
|
|
||||||
if (config.metadata?.tracked && plan) {
|
if (config.metadata?.tracked) {
|
||||||
const hasTaskDoc = plan.documents.some((d) => d.name === "task");
|
const hasTaskDoc = plan.documents.some((d) => d.name === "task");
|
||||||
if (!hasTaskDoc) {
|
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) {
|
for (const dep of doc.depend) {
|
||||||
if (dep === doc.name) {
|
if (dep === doc.name) {
|
||||||
throw new ConfigError(`文档 "${doc.name}" 不能依赖自身`);
|
throw new ConfigError(`文档"${doc.name}"不能依赖自身`, {
|
||||||
|
hint: `请从文档"${doc.name}"的 depend 列表中移除自身引用`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (!docNames.has(dep)) {
|
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) {
|
for (const doc of plan.documents) {
|
||||||
path.length = 0;
|
path.length = 0;
|
||||||
if (hasCycle(doc.name)) {
|
if (hasCycle(doc.name)) {
|
||||||
throw new ConfigError(`文档间存在循环依赖:${path.join(" → ")}`);
|
throw new ConfigError(`文档间存在循环依赖:${path.join(" → ")}`, {
|
||||||
|
hint: "请检查文档的 depend 配置,移除形成环路的依赖关系",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export function inferFromEnvironment(
|
|||||||
): string | null {
|
): string | null {
|
||||||
if (execPath.includes("bun")) return "bunx @lanyuanxiaoyao/rune";
|
if (execPath.includes("bun")) return "bunx @lanyuanxiaoyao/rune";
|
||||||
if (userAgent?.includes("pnpm")) return "pnpx @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";
|
if (userAgent?.includes("npm")) return "npx @lanyuanxiaoyao/rune";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,12 @@ export async function scanChanges(
|
|||||||
taskProgress,
|
taskProgress,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch (e) {
|
||||||
|
const code = (e as NodeJS.ErrnoException)?.code;
|
||||||
|
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { TaskItem } from "../types.ts";
|
import type { TaskItem } from "../types.ts";
|
||||||
|
import { CommandError } from "../cli/errors.ts";
|
||||||
|
|
||||||
export function parseTasks(content: string): TaskItem[] {
|
export function parseTasks(content: string): TaskItem[] {
|
||||||
const tasks: TaskItem[] = [];
|
const tasks: TaskItem[] = [];
|
||||||
@@ -15,7 +16,7 @@ export function parseTasks(content: string): TaskItem[] {
|
|||||||
return tasks;
|
return tasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TaskFormatError extends Error {
|
export class TaskFormatError extends CommandError {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = this.constructor.name;
|
this.name = this.constructor.name;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ describe("mapError", () => {
|
|||||||
const err = new Error("missing required args for command `plan`");
|
const err = new Error("missing required args for command `plan`");
|
||||||
const result = mapError(err);
|
const result = mapError(err);
|
||||||
expect(result).toBeInstanceOf(UsageError);
|
expect(result).toBeInstanceOf(UsageError);
|
||||||
expect(result.message).toBe("命令 'plan' 缺少必填参数");
|
expect(result.message).toBe(`命令"plan"缺少必填参数`);
|
||||||
expect(result.usage).toBe(`${DEFAULT_PREFIX} plan <change-name>`);
|
expect(result.usage).toBe(`${DEFAULT_PREFIX} plan <change-name>`);
|
||||||
expect(result.hint).toContain(`${DEFAULT_PREFIX} help plan`);
|
expect(result.hint).toContain(`${DEFAULT_PREFIX} help plan`);
|
||||||
});
|
});
|
||||||
@@ -47,7 +47,8 @@ describe("mapError", () => {
|
|||||||
const err = new Error("something unexpected");
|
const err = new Error("something unexpected");
|
||||||
const result = mapError(err);
|
const result = mapError(err);
|
||||||
expect(result).toBeInstanceOf(InternalError);
|
expect(result).toBeInstanceOf(InternalError);
|
||||||
expect(result.message).toBe("发生了未预期的错误");
|
expect(result.message).toContain("发生了未预期的错误");
|
||||||
|
expect(result.message).toContain("something unexpected");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("非 Error 类型转为 InternalError", () => {
|
it("非 Error 类型转为 InternalError", () => {
|
||||||
|
|||||||
@@ -90,12 +90,11 @@ stages:
|
|||||||
expect(config.stages.archive).toBeDefined();
|
expect(config.stages.archive).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("YAML 解析错误时返回默认配置", async () => {
|
it("YAML 解析错误时抛出 ConfigError", async () => {
|
||||||
const runeDir = join(TMP_DIR, ".rune");
|
const runeDir = join(TMP_DIR, ".rune");
|
||||||
await mkdir(runeDir, { recursive: true });
|
await mkdir(runeDir, { recursive: true });
|
||||||
await writeFile(join(runeDir, "config.yaml"), `stages: [invalid yaml {{{`);
|
await writeFile(join(runeDir, "config.yaml"), `stages: [invalid yaml {{{`);
|
||||||
const config = await loadConfig(TMP_DIR);
|
expect(loadConfig(TMP_DIR)).rejects.toThrow(ConfigError);
|
||||||
expect(config.stages.discuss).toBeDefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user