Compare commits
10 Commits
44e41e496b
...
bafbbaa01e
| Author | SHA1 | Date | |
|---|---|---|---|
| bafbbaa01e | |||
| bf022f45d7 | |||
| bbf77ae2b1 | |||
| 4ea60f7679 | |||
| 97d64e48db | |||
| 0f356bc201 | |||
| b0c4ae3446 | |||
| 8e126a95c6 | |||
| 7530a5a743 | |||
| 6c2a229536 |
@@ -0,0 +1,57 @@
|
||||
# Rune 开发文档
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 运行时:Bun
|
||||
- 语言:TypeScript
|
||||
- CLI 框架:cac
|
||||
- YAML 解析:yaml
|
||||
- 测试:bun:test
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── cli.ts # CLI 入口
|
||||
├── types.ts # 类型定义
|
||||
├── commands/
|
||||
│ └── init.ts # init 命令
|
||||
├── core/
|
||||
│ ├── config.ts # 配置加载
|
||||
│ ├── scanner.ts # 状态扫描
|
||||
│ ├── assembler.ts # 提示词拼装
|
||||
│ └── task-parser.ts # 任务解析
|
||||
├── adapters/
|
||||
│ ├── opencode.ts # OpenCode 适配器
|
||||
│ └── claude-code.ts # Claude Code 适配器(占位)
|
||||
└── defaults/
|
||||
└── config.ts # 内置默认配置
|
||||
|
||||
tests/ # 测试目录(镜像 src 结构)
|
||||
```
|
||||
|
||||
## 开发命令
|
||||
|
||||
```bash
|
||||
bun test # 运行全部测试
|
||||
bun test tests/core/ # 运行指定目录测试
|
||||
bun src/cli.ts init opencode # 测试 init 命令
|
||||
```
|
||||
|
||||
## 测试策略
|
||||
|
||||
### Level 1 — 纯单元/集成测试(当前)
|
||||
|
||||
在临时目录执行完整流程,验证文件创建、目录结构、提示词输出。
|
||||
|
||||
### Level 2 — 提示词快照测试(后续增强)
|
||||
|
||||
对每个阶段捕获提示词输出,与预期快照对比。
|
||||
|
||||
### Level 3 — mock-agent 端到端(后续增强)
|
||||
|
||||
编排完整闭环:rune 输出 → mock-agent 处理 → rune 继续下一阶段。
|
||||
|
||||
### Level 4 — 真实 AI 工具集成(CI 可选)
|
||||
|
||||
调用 LLM API 验证输出格式可被解析。
|
||||
|
||||
45
README.md
45
README.md
@@ -0,0 +1,45 @@
|
||||
# Rune
|
||||
|
||||
基于 SDD(Spec-Driven Development)的命令行工具,通过向 AI 开发工具注入 command 和 skill,驱动结构化编码流程。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
bunx rune init opencode
|
||||
```
|
||||
|
||||
## 使用
|
||||
|
||||
### 初始化
|
||||
|
||||
```bash
|
||||
bunx rune init opencode
|
||||
```
|
||||
|
||||
会在项目中创建:
|
||||
- `.rune/` 目录(配置、变更文档、归档)
|
||||
- `.opencode/commands/` 和 `.opencode/skills/`(注入的 AI 工具配置)
|
||||
|
||||
### SDD 流程
|
||||
|
||||
1. `/rune-discuss` — 自由讨论需求
|
||||
2. `/rune-plan <变更名>` — 生成设计文档和任务列表
|
||||
3. `/rune-build <变更名>` — 按任务顺序编码实现
|
||||
4. `/rune-archive <变更名>` — 归档并清理
|
||||
|
||||
### 状态查看
|
||||
|
||||
```bash
|
||||
rune status
|
||||
```
|
||||
|
||||
### 自定义配置
|
||||
|
||||
编辑 `.rune/config.yaml` 自定义提示词和文档模板。配置文件默认为空,使用内置默认策略;仅覆盖需要自定义的阶段,未配置的阶段使用内置默认配置。
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
bun test # 运行测试
|
||||
bun src/cli.ts # 运行 CLI
|
||||
```
|
||||
|
||||
37
src/adapters/claude-code.ts
Normal file
37
src/adapters/claude-code.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { STAGES } from "../types.ts";
|
||||
|
||||
const COMMANDS_DIR = ".claude/commands";
|
||||
|
||||
export async function injectClaudeCode(projectRoot: string): Promise<void> {
|
||||
for (const stage of STAGES) {
|
||||
const hasChangeName = stage !== "discuss";
|
||||
|
||||
const commandDir = join(projectRoot, COMMANDS_DIR);
|
||||
await mkdir(commandDir, { recursive: true });
|
||||
const commandPath = join(commandDir, `rune-${stage}.md`);
|
||||
if (!existsSync(commandPath)) {
|
||||
const cmd = hasChangeName
|
||||
? `rune ${stage} <变更名>`
|
||||
: `rune ${stage}`;
|
||||
const nameHint = hasChangeName
|
||||
? "\n如果用户没有指定变更名称,请向用户确认。"
|
||||
: "";
|
||||
await writeFile(
|
||||
commandPath,
|
||||
`执行以下命令,将输出作为当前阶段的工作指引:\n\`\`\`bash\n${cmd}\n\`\`\`${nameHint}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const commandDir = join(projectRoot, COMMANDS_DIR);
|
||||
const statusPath = join(commandDir, "rune-status.md");
|
||||
if (!existsSync(statusPath)) {
|
||||
await writeFile(
|
||||
statusPath,
|
||||
`执行以下命令查看变更状态:\n\`\`\`bash\nrune status\n\`\`\`\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
94
src/adapters/opencode.ts
Normal file
94
src/adapters/opencode.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { STAGES } from "../types.ts";
|
||||
|
||||
const COMMANDS_DIR = ".opencode/commands";
|
||||
const SKILLS_DIR = ".opencode/skills";
|
||||
|
||||
export async function injectOpenCode(projectRoot: string): Promise<void> {
|
||||
for (const stage of STAGES) {
|
||||
const hasChangeName = stage !== "discuss";
|
||||
|
||||
const commandDir = join(projectRoot, COMMANDS_DIR);
|
||||
await mkdir(commandDir, { recursive: true });
|
||||
const commandPath = join(commandDir, `rune-${stage}.md`);
|
||||
if (!existsSync(commandPath)) {
|
||||
await writeFile(commandPath, generateCommand(stage, hasChangeName));
|
||||
}
|
||||
|
||||
const skillDir = join(projectRoot, SKILLS_DIR);
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
const skillPath = join(skillDir, `rune-${stage}.md`);
|
||||
if (!existsSync(skillPath)) {
|
||||
await writeFile(skillPath, generateSkill(stage, hasChangeName));
|
||||
}
|
||||
}
|
||||
|
||||
const commandDir = join(projectRoot, COMMANDS_DIR);
|
||||
const statusCommandPath = join(commandDir, "rune-status.md");
|
||||
if (!existsSync(statusCommandPath)) {
|
||||
await writeFile(statusCommandPath, generateStatusCommand());
|
||||
}
|
||||
|
||||
const skillDir = join(projectRoot, SKILLS_DIR);
|
||||
const statusSkillPath = join(skillDir, "rune-status.md");
|
||||
if (!existsSync(statusSkillPath)) {
|
||||
await writeFile(statusSkillPath, generateStatusSkill());
|
||||
}
|
||||
}
|
||||
|
||||
function generateCommand(stage: string, hasChangeName: boolean): string {
|
||||
if (hasChangeName) {
|
||||
return `请调用 rune-${stage} skill 执行 ${stage} 阶段。
|
||||
|
||||
如果用户没有指定变更名称,请向用户确认要操作的变更名称。
|
||||
`;
|
||||
}
|
||||
return `请调用 rune-${stage} skill 执行 ${stage} 阶段。
|
||||
`;
|
||||
}
|
||||
|
||||
function generateSkill(stage: string, hasChangeName: boolean): string {
|
||||
const cmd = hasChangeName ? `rune ${stage} <变更名>` : `rune ${stage}`;
|
||||
const nameHint = hasChangeName
|
||||
? `将 <变更名> 替换为实际的变更名称。\n`
|
||||
: "";
|
||||
|
||||
return `---
|
||||
description: Rune SDD ${stage} 阶段
|
||||
---
|
||||
|
||||
# ${stage} 阶段
|
||||
|
||||
执行以下命令获取工作指引:
|
||||
|
||||
\`\`\`bash
|
||||
${cmd}
|
||||
\`\`\`
|
||||
|
||||
${nameHint}将命令输出作为工作指引,执行当前阶段的工作。
|
||||
`;
|
||||
}
|
||||
|
||||
function generateStatusCommand(): string {
|
||||
return `请调用 rune-status skill 查看当前所有变更状态。
|
||||
`;
|
||||
}
|
||||
|
||||
function generateStatusSkill(): string {
|
||||
return `---
|
||||
description: 查看所有 Rune 变更状态
|
||||
---
|
||||
|
||||
# 状态查看
|
||||
|
||||
执行以下命令:
|
||||
|
||||
\`\`\`bash
|
||||
rune status
|
||||
\`\`\`
|
||||
|
||||
将命令输出展示给用户。
|
||||
`;
|
||||
}
|
||||
103
src/cli.ts
Normal file
103
src/cli.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bun
|
||||
import { cac } from "cac";
|
||||
import { join } from "node:path";
|
||||
import { mkdir, rename } from "node:fs/promises";
|
||||
import { runInit } from "./commands/init.ts";
|
||||
import { findProjectRoot, loadConfig, getChangeDir, getArchiveDir } from "./core/config.ts";
|
||||
import {
|
||||
assembleDiscussPrompt,
|
||||
assemblePlanPrompt,
|
||||
assembleBuildPrompt,
|
||||
assembleArchivePrompt,
|
||||
} from "./core/assembler.ts";
|
||||
import { scanChanges } from "./core/scanner.ts";
|
||||
|
||||
const cli = cac("rune");
|
||||
|
||||
cli.command("init [...tools]", "初始化 Rune 并注入工具配置").action(
|
||||
async (tools: string[]) => {
|
||||
if (!tools || tools.length === 0) {
|
||||
console.error("请指定至少一个工具,如:rune init opencode");
|
||||
process.exit(1);
|
||||
}
|
||||
await runInit(process.cwd(), tools);
|
||||
console.log(`Rune 初始化完成,已注入工具:${tools.join(", ")}`);
|
||||
},
|
||||
);
|
||||
|
||||
cli.command("discuss", "讨论阶段").action(async () => {
|
||||
const root = findProjectRoot();
|
||||
if (!root) {
|
||||
console.error("未找到 .rune 目录,请先运行 rune init");
|
||||
process.exit(1);
|
||||
}
|
||||
const config = await loadConfig(root);
|
||||
const prompt = assembleDiscussPrompt(config);
|
||||
console.log(prompt);
|
||||
});
|
||||
|
||||
cli
|
||||
.command("plan <change-name>", "规划阶段")
|
||||
.action(async (changeName: string) => {
|
||||
const root = findProjectRoot();
|
||||
if (!root) {
|
||||
console.error("未找到 .rune 目录,请先运行 rune init");
|
||||
process.exit(1);
|
||||
}
|
||||
await mkdir(getChangeDir(root, changeName), { recursive: true });
|
||||
const config = await loadConfig(root);
|
||||
const prompt = await assemblePlanPrompt(config, root, changeName);
|
||||
console.log(prompt);
|
||||
});
|
||||
|
||||
cli
|
||||
.command("build <change-name>", "构建阶段")
|
||||
.action(async (changeName: string) => {
|
||||
const root = findProjectRoot();
|
||||
if (!root) {
|
||||
console.error("未找到 .rune 目录,请先运行 rune init");
|
||||
process.exit(1);
|
||||
}
|
||||
const config = await loadConfig(root);
|
||||
const prompt = await assembleBuildPrompt(config, root, changeName);
|
||||
console.log(prompt);
|
||||
});
|
||||
|
||||
cli
|
||||
.command("archive <change-name>", "归档阶段")
|
||||
.action(async (changeName: string) => {
|
||||
const root = findProjectRoot();
|
||||
if (!root) {
|
||||
console.error("未找到 .rune 目录,请先运行 rune init");
|
||||
process.exit(1);
|
||||
}
|
||||
const config = await loadConfig(root);
|
||||
const prompt = assembleArchivePrompt(config, changeName);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const src = getChangeDir(root, changeName);
|
||||
const dest = join(getArchiveDir(root), `${today}-${changeName}`);
|
||||
await rename(src, dest);
|
||||
console.log(prompt);
|
||||
});
|
||||
|
||||
cli.command("status", "查看变更状态").action(async () => {
|
||||
const root = findProjectRoot();
|
||||
if (!root) {
|
||||
console.error("未找到 .rune 目录,请先运行 rune init");
|
||||
process.exit(1);
|
||||
}
|
||||
const changes = await scanChanges(root);
|
||||
if (changes.length === 0) {
|
||||
console.log("当前无进行中的变更。");
|
||||
return;
|
||||
}
|
||||
for (const change of changes) {
|
||||
const progress = change.taskProgress
|
||||
? ` (${change.taskProgress.completed}/${change.taskProgress.total} 任务)`
|
||||
: "";
|
||||
console.log(`- ${change.name}${progress} [${change.documents.join(", ")}]`);
|
||||
}
|
||||
});
|
||||
|
||||
cli.help();
|
||||
cli.parse();
|
||||
36
src/commands/init.ts
Normal file
36
src/commands/init.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { CHANGES_DIR, ARCHIVE_DIR, RUNE_DIR, CONFIG_FILE } from "../types.ts";
|
||||
import { injectOpenCode } from "../adapters/opencode.ts";
|
||||
import { injectClaudeCode } from "../adapters/claude-code.ts";
|
||||
|
||||
const SUPPORTED_TOOLS: Record<string, (root: string) => Promise<void>> = {
|
||||
opencode: injectOpenCode,
|
||||
"claude-code": injectClaudeCode,
|
||||
};
|
||||
|
||||
export async function runInit(
|
||||
projectRoot: string,
|
||||
tools: string[],
|
||||
): Promise<void> {
|
||||
for (const tool of tools) {
|
||||
if (!SUPPORTED_TOOLS[tool]) {
|
||||
throw new Error(`不支持的工具: ${tool}`);
|
||||
}
|
||||
}
|
||||
|
||||
const runeDir = join(projectRoot, RUNE_DIR);
|
||||
await mkdir(runeDir, { recursive: true });
|
||||
await mkdir(join(runeDir, CHANGES_DIR), { recursive: true });
|
||||
await mkdir(join(runeDir, ARCHIVE_DIR), { recursive: true });
|
||||
|
||||
const configPath = join(runeDir, CONFIG_FILE);
|
||||
if (!existsSync(configPath)) {
|
||||
await writeFile(configPath, "stages: {}\n", "utf-8");
|
||||
}
|
||||
|
||||
for (const tool of tools) {
|
||||
await SUPPORTED_TOOLS[tool](projectRoot);
|
||||
}
|
||||
}
|
||||
102
src/core/assembler.ts
Normal file
102
src/core/assembler.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { RuneConfig } from "../types.ts";
|
||||
import { getChangeDir } from "./config.ts";
|
||||
import { parseTasks } from "./task-parser.ts";
|
||||
|
||||
export function assembleDiscussPrompt(config: RuneConfig): string {
|
||||
const discuss = config.stages.discuss;
|
||||
if (!discuss) throw new Error("discuss 阶段未配置");
|
||||
return discuss.prompt;
|
||||
}
|
||||
|
||||
export async function assemblePlanPrompt(
|
||||
config: RuneConfig,
|
||||
projectRoot: string,
|
||||
changeName: string,
|
||||
): Promise<string> {
|
||||
const plan = config.stages.plan;
|
||||
if (!plan) throw new Error("plan 阶段未配置");
|
||||
|
||||
const changeDir = getChangeDir(projectRoot, changeName);
|
||||
const parts: string[] = [];
|
||||
|
||||
parts.push(`# 规划阶段:${changeName}\n`);
|
||||
parts.push("请为当前变更生成以下文档:\n");
|
||||
|
||||
for (const doc of plan.documents) {
|
||||
parts.push(`## 文档:${doc.name}.md`);
|
||||
parts.push(doc.prompt);
|
||||
|
||||
const docPath = join(changeDir, `${doc.name}.md`);
|
||||
if (existsSync(docPath)) {
|
||||
const existing = await readFile(docPath, "utf-8");
|
||||
parts.push(`\n### 已有内容(请在此基础上修订):\n${existing}`);
|
||||
}
|
||||
|
||||
if (doc.template) {
|
||||
const rendered = doc.template.replace(/\{\{change-name\}\}/g, changeName);
|
||||
parts.push(`\n### 格式模板:\n${rendered}`);
|
||||
}
|
||||
|
||||
parts.push("");
|
||||
}
|
||||
|
||||
parts.push(`请将文档写入目录:${changeDir}`);
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
export async function assembleBuildPrompt(
|
||||
config: RuneConfig,
|
||||
projectRoot: string,
|
||||
changeName: string,
|
||||
): Promise<string> {
|
||||
const build = config.stages.build;
|
||||
if (!build) throw new Error("build 阶段未配置");
|
||||
|
||||
const changeDir = getChangeDir(projectRoot, changeName);
|
||||
const taskPath = join(changeDir, "task.md");
|
||||
|
||||
let taskContent: string;
|
||||
try {
|
||||
taskContent = await readFile(taskPath, "utf-8");
|
||||
} catch {
|
||||
throw new Error(`task.md not found in ${changeDir}`);
|
||||
}
|
||||
|
||||
const tasks = parseTasks(taskContent);
|
||||
const pendingTasks = tasks.filter((t) => !t.checked);
|
||||
|
||||
if (pendingTasks.length === 0) {
|
||||
return `所有任务已完成。变更 "${changeName}" 可以归档。`;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push(`# 构建阶段:${changeName}\n`);
|
||||
parts.push(build.prompt);
|
||||
parts.push(`\n## 任务列表\n`);
|
||||
parts.push(taskContent);
|
||||
parts.push(`\n## 待执行任务(共 ${pendingTasks.length} 项)`);
|
||||
for (const task of pendingTasks) {
|
||||
parts.push(`- [ ] ${task.text}`);
|
||||
}
|
||||
parts.push(
|
||||
`\n请从第一个待执行任务开始。完成后更新 ${taskPath} 中的 checkbox。`,
|
||||
);
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
export function assembleArchivePrompt(
|
||||
config: RuneConfig,
|
||||
changeName: string,
|
||||
): string {
|
||||
const archive = config.stages.archive;
|
||||
if (!archive) throw new Error("archive 阶段未配置");
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push(`# 归档阶段:${changeName}\n`);
|
||||
parts.push(archive.prompt);
|
||||
return parts.join("\n");
|
||||
}
|
||||
@@ -25,12 +25,9 @@ export async function loadConfig(projectRoot: string): Promise<RuneConfig> {
|
||||
try {
|
||||
const content = await readFile(configPath, "utf-8");
|
||||
const userConfig = parseYaml(content) as Partial<RuneConfig> | null;
|
||||
if (!userConfig?.stages) {
|
||||
return structuredClone(defaultConfig);
|
||||
}
|
||||
return mergeConfig(userConfig);
|
||||
return mergeConfig(userConfig ?? {});
|
||||
} catch {
|
||||
return structuredClone(defaultConfig);
|
||||
return mergeConfig({});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
85
tests/adapters/opencode.test.ts
Normal file
85
tests/adapters/opencode.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, rm, readFile, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { injectOpenCode } from "../../src/adapters/opencode.ts";
|
||||
|
||||
const TMP_DIR = join(import.meta.dir, "__tmp_opencode_test__");
|
||||
|
||||
beforeEach(async () => {
|
||||
await mkdir(TMP_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(TMP_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("injectOpenCode", () => {
|
||||
it("生成 discuss、plan、build、archive 的 command 和 skill 文件", async () => {
|
||||
await injectOpenCode(TMP_DIR);
|
||||
|
||||
const commands = await readdir(join(TMP_DIR, ".opencode", "commands"));
|
||||
const skills = await readdir(join(TMP_DIR, ".opencode", "skills"));
|
||||
|
||||
for (const stage of ["discuss", "plan", "build", "archive"]) {
|
||||
expect(commands).toContain(`rune-${stage}.md`);
|
||||
expect(skills).toContain(`rune-${stage}.md`);
|
||||
}
|
||||
});
|
||||
|
||||
it("生成 rune-status command 和 skill", async () => {
|
||||
await injectOpenCode(TMP_DIR);
|
||||
|
||||
const commands = await readdir(join(TMP_DIR, ".opencode", "commands"));
|
||||
const skills = await readdir(join(TMP_DIR, ".opencode", "skills"));
|
||||
|
||||
expect(commands).toContain("rune-status.md");
|
||||
expect(skills).toContain("rune-status.md");
|
||||
});
|
||||
|
||||
it("command 文件包含 skill 调用指令", async () => {
|
||||
await injectOpenCode(TMP_DIR);
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toContain("rune-discuss");
|
||||
});
|
||||
|
||||
it("skill 文件包含 bash 命令", async () => {
|
||||
await injectOpenCode(TMP_DIR);
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".opencode", "skills", "rune-discuss.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toContain("rune discuss");
|
||||
expect(content).toContain("description");
|
||||
});
|
||||
|
||||
it("plan/build/archive skill 包含变更名称参数提示", async () => {
|
||||
await injectOpenCode(TMP_DIR);
|
||||
|
||||
for (const stage of ["plan", "build", "archive"]) {
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".opencode", "skills", `rune-${stage}.md`),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toContain("变更名");
|
||||
}
|
||||
});
|
||||
|
||||
it("重复注入时不覆盖已存在的文件", async () => {
|
||||
await injectOpenCode(TMP_DIR);
|
||||
const originalContent = await readFile(
|
||||
join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await injectOpenCode(TMP_DIR);
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toBe(originalContent);
|
||||
});
|
||||
});
|
||||
65
tests/commands/init.test.ts
Normal file
65
tests/commands/init.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, rm, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { runInit } from "../../src/commands/init.ts";
|
||||
|
||||
const TMP_DIR = join(import.meta.dir, "__tmp_init_test__");
|
||||
|
||||
beforeEach(async () => {
|
||||
await mkdir(TMP_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(TMP_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("runInit", () => {
|
||||
it("创建 .rune 目录和空 config.yaml", async () => {
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
|
||||
expect(existsSync(join(TMP_DIR, ".rune"))).toBe(true);
|
||||
expect(existsSync(join(TMP_DIR, ".rune", "config.yaml"))).toBe(true);
|
||||
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".rune", "config.yaml"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content.trim()).toBe("stages: {}");
|
||||
});
|
||||
|
||||
it("创建 .rune/changes 和 .rune/archive 目录", async () => {
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
|
||||
expect(existsSync(join(TMP_DIR, ".rune", "changes"))).toBe(true);
|
||||
expect(existsSync(join(TMP_DIR, ".rune", "archive"))).toBe(true);
|
||||
});
|
||||
|
||||
it("注入 OpenCode command 和 skill", async () => {
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
|
||||
expect(existsSync(join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"))).toBe(true);
|
||||
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-discuss.md"))).toBe(true);
|
||||
});
|
||||
|
||||
it("重复 init 不覆盖 config.yaml", async () => {
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
await writeFile(
|
||||
join(TMP_DIR, ".rune", "config.yaml"),
|
||||
"自定义内容",
|
||||
);
|
||||
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".rune", "config.yaml"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toBe("自定义内容");
|
||||
});
|
||||
|
||||
it("不支持的工具名抛出错误", async () => {
|
||||
await expect(runInit(TMP_DIR, ["unknown-tool"])).rejects.toThrow(
|
||||
"不支持的工具",
|
||||
);
|
||||
});
|
||||
});
|
||||
144
tests/core/assembler.test.ts
Normal file
144
tests/core/assembler.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdir, writeFile, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
assembleDiscussPrompt,
|
||||
assemblePlanPrompt,
|
||||
assembleBuildPrompt,
|
||||
assembleArchivePrompt,
|
||||
} from "../../src/core/assembler.ts";
|
||||
import type { RuneConfig } from "../../src/types.ts";
|
||||
import { defaultConfig } from "../../src/defaults/config.ts";
|
||||
|
||||
const TMP_DIR = join(import.meta.dir, "__tmp_assembler_test__");
|
||||
|
||||
beforeEach(async () => {
|
||||
await mkdir(TMP_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(TMP_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("assembleDiscussPrompt", () => {
|
||||
it("返回默认 discuss 提示词", () => {
|
||||
const prompt = assembleDiscussPrompt(defaultConfig);
|
||||
expect(prompt).toBeTruthy();
|
||||
expect(prompt).toContain("软件架构师");
|
||||
});
|
||||
|
||||
it("返回自定义 discuss 提示词", () => {
|
||||
const config: RuneConfig = {
|
||||
stages: { discuss: { prompt: "自定义讨论" } },
|
||||
};
|
||||
const prompt = assembleDiscussPrompt(config);
|
||||
expect(prompt).toBe("自定义讨论");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assemblePlanPrompt", () => {
|
||||
it("包含变更名称和文档指引", async () => {
|
||||
const prompt = await assemblePlanPrompt(
|
||||
defaultConfig,
|
||||
TMP_DIR,
|
||||
"user-auth",
|
||||
);
|
||||
expect(prompt).toContain("user-auth");
|
||||
expect(prompt).toContain("design");
|
||||
expect(prompt).toContain("task");
|
||||
expect(prompt).toContain("格式模板");
|
||||
});
|
||||
|
||||
it("包含已有文档内容(重复 plan 场景)", async () => {
|
||||
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
|
||||
await mkdir(changeDir, { recursive: true });
|
||||
await writeFile(join(changeDir, "design.md"), "# 已有设计");
|
||||
const prompt = await assemblePlanPrompt(
|
||||
defaultConfig,
|
||||
TMP_DIR,
|
||||
"user-auth",
|
||||
);
|
||||
expect(prompt).toContain("已有设计");
|
||||
expect(prompt).toContain("在此基础上修订");
|
||||
});
|
||||
|
||||
it("替换模板中的 {{change-name}}", async () => {
|
||||
const prompt = await assemblePlanPrompt(
|
||||
defaultConfig,
|
||||
TMP_DIR,
|
||||
"user-auth",
|
||||
);
|
||||
expect(prompt).toContain("user-auth 设计文档");
|
||||
expect(prompt).toContain("user-auth 任务列表");
|
||||
expect(prompt).not.toContain("{{change-name}}");
|
||||
});
|
||||
|
||||
it("使用自定义 plan 配置", async () => {
|
||||
const config: RuneConfig = {
|
||||
stages: {
|
||||
plan: {
|
||||
documents: [
|
||||
{
|
||||
name: "spec",
|
||||
prompt: "生成规格",
|
||||
template: "# {{change-name}} 规格",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const prompt = await assemblePlanPrompt(config, TMP_DIR, "my-feature");
|
||||
expect(prompt).toContain("spec");
|
||||
expect(prompt).toContain("my-feature 规格");
|
||||
expect(prompt).not.toContain("design");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assembleBuildPrompt", () => {
|
||||
it("包含待执行任务列表", async () => {
|
||||
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
|
||||
await mkdir(changeDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(changeDir, "task.md"),
|
||||
`- [x] 任务一\n- [ ] 任务二\n- [ ] 任务三`,
|
||||
);
|
||||
const prompt = await assembleBuildPrompt(
|
||||
defaultConfig,
|
||||
TMP_DIR,
|
||||
"user-auth",
|
||||
);
|
||||
expect(prompt).toContain("任务二");
|
||||
expect(prompt).toContain("待执行任务");
|
||||
expect(prompt).toContain("共 2 项");
|
||||
});
|
||||
|
||||
it("所有任务完成时提示可归档", async () => {
|
||||
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
|
||||
await mkdir(changeDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(changeDir, "task.md"),
|
||||
`- [x] 任务一\n- [x] 任务二`,
|
||||
);
|
||||
const prompt = await assembleBuildPrompt(
|
||||
defaultConfig,
|
||||
TMP_DIR,
|
||||
"user-auth",
|
||||
);
|
||||
expect(prompt).toContain("已完成");
|
||||
expect(prompt).toContain("归档");
|
||||
});
|
||||
|
||||
it("task.md 不存在时抛出错误", async () => {
|
||||
await expect(
|
||||
assembleBuildPrompt(defaultConfig, TMP_DIR, "nonexistent"),
|
||||
).rejects.toThrow("task.md not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assembleArchivePrompt", () => {
|
||||
it("返回归档提示词", () => {
|
||||
const prompt = assembleArchivePrompt(defaultConfig, "user-auth");
|
||||
expect(prompt).toContain("user-auth");
|
||||
expect(prompt).toContain("归档");
|
||||
});
|
||||
});
|
||||
@@ -75,6 +75,17 @@ describe("loadConfig", () => {
|
||||
expect(config.stages.plan!.documents[0].name).toBe("spec");
|
||||
});
|
||||
|
||||
it("空配置 stages: {} 时返回完整默认配置", async () => {
|
||||
const runeDir = join(TMP_DIR, ".rune");
|
||||
await mkdir(runeDir, { recursive: true });
|
||||
await writeFile(join(runeDir, "config.yaml"), "stages: {}");
|
||||
const config = await loadConfig(TMP_DIR);
|
||||
expect(config.stages.discuss).toBeDefined();
|
||||
expect(config.stages.plan).toBeDefined();
|
||||
expect(config.stages.build).toBeDefined();
|
||||
expect(config.stages.archive).toBeDefined();
|
||||
});
|
||||
|
||||
it("YAML 解析错误时返回默认配置", async () => {
|
||||
const runeDir = join(TMP_DIR, ".rune");
|
||||
await mkdir(runeDir, { recursive: true });
|
||||
|
||||
129
tests/integration/flow.test.ts
Normal file
129
tests/integration/flow.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, writeFile, rm, readFile, rename } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { runInit } from "../../src/commands/init.ts";
|
||||
import { loadConfig, getChangeDir, getArchiveDir } from "../../src/core/config.ts";
|
||||
import {
|
||||
assembleDiscussPrompt,
|
||||
assemblePlanPrompt,
|
||||
assembleBuildPrompt,
|
||||
assembleArchivePrompt,
|
||||
} from "../../src/core/assembler.ts";
|
||||
import { scanChanges, scanArchives } from "../../src/core/scanner.ts";
|
||||
|
||||
const TMP_DIR = join(import.meta.dir, "__tmp_flow_test__");
|
||||
|
||||
beforeEach(async () => {
|
||||
await mkdir(TMP_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(TMP_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("完整 SDD 流程", () => {
|
||||
it("init → discuss → plan → build → archive 完整流程", async () => {
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
expect(existsSync(join(TMP_DIR, ".rune", "config.yaml"))).toBe(true);
|
||||
expect(existsSync(join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"))).toBe(true);
|
||||
|
||||
const config = await loadConfig(TMP_DIR);
|
||||
const discussPrompt = assembleDiscussPrompt(config);
|
||||
expect(discussPrompt).toContain("软件架构师");
|
||||
|
||||
const changeName = "user-auth";
|
||||
await mkdir(getChangeDir(TMP_DIR, changeName), { recursive: true });
|
||||
const planPrompt = await assemblePlanPrompt(config, TMP_DIR, changeName);
|
||||
expect(planPrompt).toContain("user-auth");
|
||||
|
||||
const changeDir = getChangeDir(TMP_DIR, changeName);
|
||||
await writeFile(join(changeDir, "design.md"), "# 用户认证设计\n\n## 背景\n需要用户登录功能");
|
||||
await writeFile(join(changeDir, "task.md"), "- [ ] 实现登录 API\n- [ ] 编写登录测试");
|
||||
|
||||
const changes = await scanChanges(TMP_DIR);
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0].name).toBe("user-auth");
|
||||
expect(changes[0].taskProgress).toEqual({ completed: 0, total: 2 });
|
||||
|
||||
const buildPrompt = await assembleBuildPrompt(config, TMP_DIR, changeName);
|
||||
expect(buildPrompt).toContain("实现登录 API");
|
||||
expect(buildPrompt).toContain("共 2 项");
|
||||
|
||||
await writeFile(join(changeDir, "task.md"), "- [x] 实现登录 API\n- [x] 编写登录测试");
|
||||
|
||||
const updatedChanges = await scanChanges(TMP_DIR);
|
||||
expect(updatedChanges[0].taskProgress).toEqual({ completed: 2, total: 2 });
|
||||
|
||||
const buildPrompt2 = await assembleBuildPrompt(config, TMP_DIR, changeName);
|
||||
expect(buildPrompt2).toContain("已完成");
|
||||
|
||||
const archivePrompt = assembleArchivePrompt(config, changeName);
|
||||
expect(archivePrompt).toContain("归档");
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const src = getChangeDir(TMP_DIR, changeName);
|
||||
const dest = join(getArchiveDir(TMP_DIR), `${today}-${changeName}`);
|
||||
await mkdir(join(TMP_DIR, ".rune", "archive"), { recursive: true });
|
||||
await rename(src, dest);
|
||||
|
||||
expect(existsSync(dest)).toBe(true);
|
||||
expect(existsSync(src)).toBe(false);
|
||||
|
||||
const archives = await scanArchives(TMP_DIR);
|
||||
expect(archives).toContain(`${today}-${changeName}`);
|
||||
|
||||
const postArchiveChanges = await scanChanges(TMP_DIR);
|
||||
expect(postArchiveChanges).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("多变更并行", async () => {
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
const config = await loadConfig(TMP_DIR);
|
||||
|
||||
for (const name of ["auth", "payment"]) {
|
||||
const changeDir = getChangeDir(TMP_DIR, name);
|
||||
await mkdir(changeDir, { recursive: true });
|
||||
await writeFile(join(changeDir, "task.md"), `- [ ] ${name} 任务`);
|
||||
}
|
||||
|
||||
const changes = await scanChanges(TMP_DIR);
|
||||
expect(changes).toHaveLength(2);
|
||||
|
||||
const authPrompt = await assembleBuildPrompt(config, TMP_DIR, "auth");
|
||||
expect(authPrompt).toContain("auth 任务");
|
||||
|
||||
const paymentPrompt = await assembleBuildPrompt(config, TMP_DIR, "payment");
|
||||
expect(paymentPrompt).toContain("payment 任务");
|
||||
});
|
||||
|
||||
it("自定义配置覆盖默认配置", async () => {
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
|
||||
await writeFile(
|
||||
join(TMP_DIR, ".rune", "config.yaml"),
|
||||
`stages:
|
||||
discuss:
|
||||
prompt: 自定义讨论
|
||||
plan:
|
||||
documents:
|
||||
- name: spec
|
||||
prompt: 生成规格文档
|
||||
template: "# {{change-name}} 规格"
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(TMP_DIR);
|
||||
|
||||
const discussPrompt = assembleDiscussPrompt(config);
|
||||
expect(discussPrompt).toBe("自定义讨论");
|
||||
|
||||
await mkdir(getChangeDir(TMP_DIR, "test"), { recursive: true });
|
||||
const planPrompt = await assemblePlanPrompt(config, TMP_DIR, "test");
|
||||
expect(planPrompt).toContain("spec");
|
||||
expect(planPrompt).toContain("test 规格");
|
||||
expect(planPrompt).not.toContain("design");
|
||||
|
||||
expect(config.stages.build).toBeDefined();
|
||||
expect(config.stages.archive).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user