Compare commits
57 Commits
03b0c60fb6
...
a99ebb81a3
| Author | SHA1 | Date | |
|---|---|---|---|
| a99ebb81a3 | |||
| 92c1fed5d5 | |||
| 750480e30c | |||
| 075bdcdd54 | |||
| 682bdda3e5 | |||
| 7ce344801f | |||
| 9a2fde2cfa | |||
| 307bdfc922 | |||
| 1b69e454d7 | |||
| cecd7ab925 | |||
| da7770f76a | |||
| 1871d0b665 | |||
| 9cc0a96f68 | |||
| 58a771ebca | |||
| 2efcff5742 | |||
| 088254ab0f | |||
| 59632b5312 | |||
| faefefda39 | |||
| 073b9c1e47 | |||
| bb7d5e740c | |||
| 8739a404f6 | |||
| 662c66c08e | |||
| 6346398962 | |||
| 77cd056492 | |||
| 4a253bbb72 | |||
| 56f39d5f0a | |||
| 6214eedf4d | |||
| 0d90e6b2a3 | |||
| ac16bfa383 | |||
| 2d5b40379f | |||
| 4e736998c7 | |||
| 9b52b46d3e | |||
| 5a7b8f1dcc | |||
| 6ebfe24921 | |||
| 34974714a2 | |||
| 7ad5411e45 | |||
| b82f1caf0b | |||
| ebd5bb4051 | |||
| 107cd4f711 | |||
| bfa0f29dd5 | |||
| 7b258f4d90 | |||
| 60493e4e47 | |||
| f257ccbe4a | |||
| c45f6e1d45 | |||
| 5705e59285 | |||
| da826e2029 | |||
| 0869014c3f | |||
| 27f19d8bdf | |||
| 5d5d5cdc92 | |||
| 4ef172ff2f | |||
| 5799ab6978 | |||
| 160ec576e1 | |||
| 636ca48b4c | |||
| ee01bd87ab | |||
| 1c7a8b3322 | |||
| 0d2b117680 | |||
| 566a9d7255 |
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
bun lint-staged
|
||||
9
.oxfmtrc.json
Normal file
9
.oxfmtrc.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"printWidth": 100,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
15
.oxlintrc.json
Normal file
15
.oxlintrc.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"suspicious": "error"
|
||||
},
|
||||
"rules": {
|
||||
"eslint/no-var": "error",
|
||||
"eslint/prefer-const": ["error", { "destructuring": "all" }],
|
||||
"eslint/eqeqeq": ["error", "always"],
|
||||
"eslint/no-throw-literal": "error",
|
||||
"eslint/no-debugger": "error",
|
||||
"eslint/no-template-curly-in-string": "error"
|
||||
}
|
||||
}
|
||||
106
DEVELOPMENT.md
106
DEVELOPMENT.md
@@ -37,14 +37,47 @@ tests/ # 测试目录(镜像 src 结构)
|
||||
## 开发命令
|
||||
|
||||
```bash
|
||||
bun test # 运行全部测试
|
||||
bun test tests/core/ # 运行指定目录测试
|
||||
bun test # 运行单元/集成测试(排除 agent e2e 测试)
|
||||
bun run test:e2e # 运行 agent 端到端测试(Tier 1 + 2,< 5s)
|
||||
bun run test:e2e:llm # 运行 LLM-as-Judge 测试(Tier 3,需设置环境变量)
|
||||
bun test tests/core/ # 运行指定目录测试
|
||||
bun run release # 发布新版本(交互式递增版本号、测试门禁、git commit+tag、npm publish)
|
||||
bun src/cli.ts init opencode # 测试 init 命令
|
||||
bun src/cli.ts help # 查看全局帮助
|
||||
bun src/cli.ts help init # 查看 init 命令帮助
|
||||
bun src/cli.ts version # 查看版本号
|
||||
bun src/cli.ts plan <变更名> <文档名> # 测试 plan 命令
|
||||
bun src/cli.ts status [变更名] # 测试 status 命令
|
||||
bun src/cli.ts help # 查看全局帮助
|
||||
bun src/cli.ts help init # 查看 init 命令帮助
|
||||
bun src/cli.ts version # 查看版本号
|
||||
```
|
||||
|
||||
### Tier 3 LLM-as-Judge 环境变量
|
||||
|
||||
```bash
|
||||
export RUNE_E2E_LLM_API_KEY="your-api-key" # 必填
|
||||
export RUNE_E2E_LLM_PROVIDER="openai" # 可选,默认 openai
|
||||
export RUNE_E2E_LLM_MODEL="gpt-4o-mini" # 可选,默认 gpt-4o-mini
|
||||
export RUNE_E2E_LLM_BASE_URL="https://..." # 可选,自定义 endpoint
|
||||
bun run test:e2e:llm
|
||||
```
|
||||
|
||||
### 代码质量
|
||||
|
||||
项目使用 oxlint 进行静态分析,oxfmt 进行代码格式化,提交时通过 husky + lint-staged 自动检查。
|
||||
|
||||
```bash
|
||||
bun lint # 静态分析所有文件
|
||||
bun format # 格式化所有文件(写回)
|
||||
bun format:check # 检查格式不写回(CI 用)
|
||||
bun check # 一键 lint + 格式检查
|
||||
```
|
||||
|
||||
**配置文件:**
|
||||
|
||||
- `.oxlintrc.json` — oxlint 规则配置(correctness + suspicious 全开,style 选开)
|
||||
- `.oxfmtrc.json` — oxfmt 格式设置(双引号、分号、尾逗号、2 空格缩进、100 行宽、LF 换行)
|
||||
|
||||
**pre-commit hook:** 提交时自动对 staged 文件运行 lint 和格式化,lint 失败会阻止提交。
|
||||
|
||||
## CLI 交互架构
|
||||
|
||||
### 子命令
|
||||
@@ -63,20 +96,67 @@ CLI 通过子命令提供帮助和版本信息,不使用 `--help`/`--version`
|
||||
- `src/cli/output.ts` — 格式化输出(`错误:`、`用法:`、`提示:` 三段式)
|
||||
- `src/cli/help.ts` — 帮助文本生成
|
||||
|
||||
## 设计决策
|
||||
|
||||
### 阶段与配置
|
||||
|
||||
- **四阶段固定**:discuss → plan → build → archive,不可自定义增删
|
||||
- **配置覆盖策略**:阶段级别全量覆盖,不支持字段级合并。自定义 plan 时需完整重写所有 documents
|
||||
- **配置文件名**:`.rune/config.yaml`,不是 `rune.yml`
|
||||
- **模板变量**:仅支持 `{{change-name}}`,不需要其他变量(信息已在上下文中)
|
||||
|
||||
### 各阶段行为
|
||||
|
||||
- **discuss**:不持久化讨论结果,完全依赖 AI 会话上下文传递;不设强制门控,通过提示词引导
|
||||
- **plan**:命令只输出提示词,不写入文件;AI 负责根据提示词生成文档内容并写入。重复调用同一文档的 plan 会追加已有内容用于增量修订。依赖未满足时有友好提示(非报错)
|
||||
- **build**:按 task.md 的 checkbox 顺序执行;任务间无结构化依赖;可多次执行直到全部完成
|
||||
- **archive**:归档前命令行校验 task 完成状态,未完成时在提示词中注入警告并引导 AI 询问用户
|
||||
|
||||
### 变更名校验
|
||||
|
||||
变更名仅支持中文、英文、短横线(`-`),不支持空格、下划线等其他符号。
|
||||
|
||||
### update 命令
|
||||
|
||||
`rune update <tool>` 对比已注入的命令/skill 文件内容与内置版本,不一致则覆盖,不存在则新建。用于升级 Rune 后更新编辑器配置。
|
||||
|
||||
### 其他决策
|
||||
|
||||
- 无跨变更依赖,变更之间完全独立
|
||||
- 无并发锁,同一变更可被多个 agent 同时操作
|
||||
- 无需变更废弃命令,手动删除目录即可
|
||||
- 同一变更名同天多次归档不处理冲突(日期+名称去重)
|
||||
- plan skill 应引导 AI 先通过 `rune status` 获取文档列表
|
||||
|
||||
## 测试策略
|
||||
|
||||
### Level 1 — 纯单元/集成测试(当前)
|
||||
### 单元/集成测试(`bun test`)
|
||||
|
||||
在临时目录执行完整流程,验证文件创建、目录结构、提示词输出。
|
||||
在临时目录执行完整流程,验证文件创建、目录结构、提示词输出。覆盖 `src/core/`、`src/cli/`、`src/adapters/`、`tests/integration/`。
|
||||
|
||||
### Level 2 — 提示词快照测试(后续增强)
|
||||
### Agent 端到端测试(`bun run test:e2e`)
|
||||
|
||||
对每个阶段捕获提示词输出,与预期快照对比。
|
||||
位于 `tests/agent/`,灰度盒测试互补现有白盒测试,三层架构:
|
||||
|
||||
### Level 3 — mock-agent 端到端(后续增强)
|
||||
| Tier | 说明 | 触发 |
|
||||
| -------------- | ---------------------------- | ------------------------------ |
|
||||
| 1 命令级 mock | 每命令预设行为,CI 快速门禁 | `bun run test:e2e` |
|
||||
| 2 场景级 mock | 行为重写,覆盖边界和错误恢复 | `bun run test:e2e` |
|
||||
| 3 LLM-as-Judge | 调用 LLM API 验证提示词质量 | `bun run test:e2e:llm`(手动) |
|
||||
|
||||
编排完整闭环:rune 输出 → mock-agent 处理 → rune 继续下一阶段。
|
||||
运行策略:
|
||||
|
||||
### Level 4 — 真实 AI 工具集成(CI 可选)
|
||||
- `bun test`(pre-commit 用):Tier 1 + 2 **不参与**,仅跑单元/集成
|
||||
- `bun run test:e2e`:Tier 1 + 2(< 5s)
|
||||
- `bun run test:e2e:llm`:Tier 3(手动触发,需 `RUNE_E2E_LLM_API_KEY`)
|
||||
|
||||
调用 LLM API 验证输出格式可被解析。
|
||||
## 发布流程
|
||||
|
||||
`bun run release` 交互式发布新版本到 npm:
|
||||
|
||||
1. **版本递增**:选择 major/minor/patch,确认后写回 package.json
|
||||
2. **测试门禁**:执行 `bun test`,失败则终止
|
||||
3. **Git 操作**:确认后执行 `git add` + `commit` + `tag`(仅本地,不推送)
|
||||
4. **npm 发布**:`bun publish --dry-run` 预览,确认后 `bun publish --access public`
|
||||
|
||||
发布前确保已通过 `npm login` 登录 npm,且 npm 账号有 `@lanyuanxiaoyao` scope 的发布权限。
|
||||
|
||||
79
README.md
79
README.md
@@ -13,37 +13,92 @@ bunx rune init opencode
|
||||
### 初始化
|
||||
|
||||
```bash
|
||||
bunx rune init opencode
|
||||
bunx rune init opencode # OpenCode 编辑器
|
||||
bunx rune init claude-code # Claude Code 编辑器
|
||||
```
|
||||
|
||||
会在项目中创建:
|
||||
|
||||
- `.rune/` 目录(配置、变更文档、归档)
|
||||
- `.opencode/commands/` 和 `.opencode/skills/`(注入的 AI 工具配置)
|
||||
- 编辑器对应的 command 和 skill 文件(如 `.opencode/commands/`、`.opencode/skills/`)
|
||||
|
||||
### 更新编辑器配置
|
||||
|
||||
当 Rune 版本升级后,需要更新已注入的命令和 skill 文件:
|
||||
|
||||
```bash
|
||||
bunx rune update opencode # 更新 OpenCode 的命令和 skill
|
||||
bunx rune update claude-code # 更新 Claude Code 的命令
|
||||
```
|
||||
|
||||
更新策略:对比文件内容,不一致则用内置版本覆盖;不存在则新建。
|
||||
|
||||
### SDD 流程
|
||||
|
||||
1. `/rune-discuss` — 自由讨论需求
|
||||
2. `/rune-plan <变更名>` — 生成设计文档和任务列表
|
||||
3. `/rune-build <变更名>` — 按任务顺序编码实现
|
||||
4. `/rune-archive <变更名>` — 归档并清理
|
||||
SDD 工作流包含固定的四个阶段,不可自定义增删:
|
||||
|
||||
1. **讨论阶段** — `/rune-discuss`:与 AI 自由讨论需求和方案。讨论结果保留在 AI 会话上下文中传递到后续阶段,不持久化到文件。结束前会引导是否进入规划阶段。
|
||||
2. **规划阶段** — `/rune-plan <变更名> <文档名>`:按配置的文档模板生成规划文档。变更名仅支持中文、英文和短横线(`-`)。默认包含 `design`(设计文档)和 `task`(任务清单,依赖 design)两个文档。文档间支持 `depend` 字段声明前置依赖,依赖未满足时有友好提示。plan 命令自身不写入文件,只输出提示词供 AI 消费。
|
||||
3. **构建阶段** — `/rune-build <变更名>`:按 task.md 中的任务顺序逐个实现。每个任务完成后更新对应的 checkbox 为 `[x]`。可多次执行直到所有任务完成。
|
||||
4. **归档阶段** — `/rune-archive <变更名>`:将变更目录移至 `archive/`。归档前自动检查 task.md 的完成状态,如有未完成任务会注入警告提示词,引导 AI 询问用户是否确认归档。
|
||||
|
||||
### 状态查看
|
||||
|
||||
```bash
|
||||
rune status
|
||||
rune status # 查看所有变更(含各阶段文档完成状态、下一步建议)
|
||||
rune status <变更名> # 查看指定变更的详细状态
|
||||
```
|
||||
|
||||
### 帮助与版本
|
||||
规划阶段应引导 AI 先通过 `rune status` 获取当前有哪些文档需要编写。
|
||||
|
||||
### 命令参考
|
||||
|
||||
```bash
|
||||
rune help # 显示全局帮助
|
||||
rune help <command> # 显示指定命令的详细帮助
|
||||
rune version # 显示版本号
|
||||
rune help # 显示全局帮助
|
||||
rune help <command> # 显示指定命令的详细帮助
|
||||
rune version # 显示版本号
|
||||
```
|
||||
|
||||
| 命令 | 说明 |
|
||||
| ----------------------------- | ----------------------------------------------- |
|
||||
| `rune init <tool>` | 初始化项目,注入编辑器配置 |
|
||||
| `rune update <tool>` | 更新编辑器的命令和 skill 文件 |
|
||||
| `rune discuss` | 输出讨论阶段提示词 |
|
||||
| `rune plan <变更名> <文档名>` | 输出规划阶段提示词 |
|
||||
| `rune build <变更名>` | 输出构建阶段提示词 |
|
||||
| `rune archive <变更名>` | 输出归档阶段提示词,同时移动变更目录到 archive/ |
|
||||
| `rune status [变更名]` | 显示变更状态和下一步建议 |
|
||||
|
||||
### 自定义配置
|
||||
|
||||
编辑 `.rune/config.yaml` 自定义提示词和文档模板。配置文件默认为空,使用内置默认策略;仅覆盖需要自定义的阶段,未配置的阶段使用内置默认配置。
|
||||
编辑 `.rune/config.yaml` 自定义各阶段的提示词和文档模板。配置合并采用阶段级别全量覆盖策略:自定义某个阶段时需完整重写该阶段的配置,未配置的阶段使用内置默认配置。
|
||||
|
||||
规划阶段的文档支持 `depend` 字段声明前置依赖,如 `task` 依赖 `design`:
|
||||
|
||||
```yaml
|
||||
stages:
|
||||
plan:
|
||||
documents:
|
||||
- name: design
|
||||
prompt: 生成设计文档,包含背景、目标、方案、接口和注意事项
|
||||
- name: task
|
||||
prompt: 生成任务清单,将设计拆分为可独立执行的小任务
|
||||
depend: [design]
|
||||
```
|
||||
|
||||
计划阶段文档模板支持 `{{change-name}}` 变量,运行时会替换为实际变更名。
|
||||
|
||||
## 设计决策
|
||||
|
||||
- **四阶段固定**:discuss → plan → build → archive 不可自定义增删
|
||||
- **配置覆盖策略**:阶段级别全量覆盖,不支持字段级合并
|
||||
- **讨论结果不持久化**:完全依赖 AI 会话上下文传递
|
||||
- **plan 不写文件**:plan 命令只输出提示词,由 AI 负责写入文档
|
||||
- **变更名限制**:仅支持中文、英文、短横线(`-`)
|
||||
- **同一变更名同天多次归档**:依靠日期+变更名去重,不做冲突处理
|
||||
- **无跨变更依赖**:变更之间完全独立
|
||||
- **无并发锁**:同一变更可被多个 AI agent 同时操作
|
||||
- **无变更废弃命令**:用户手动删除 `.rune/changes/<变更名>/` 目录即可
|
||||
|
||||
## 开发
|
||||
|
||||
|
||||
144
bun.lock
144
bun.lock
@@ -10,6 +10,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^17.0.7",
|
||||
"oxfmt": "^0.54.0",
|
||||
"oxlint": "^1.69.0",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
@@ -17,18 +21,158 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.54.0", "", { "os": "android", "cpu": "arm" }, "sha512-NAtpl/SiaeU103e7/OmZw0MvUnsUUopW7hEm/ecegJg7YM0skQaA0IXEZoyTV6NUdiNPupdIUreRqUZTShbn/g=="],
|
||||
|
||||
"@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.54.0", "", { "os": "android", "cpu": "arm64" }, "sha512-B4VZfBUlKK1rmMChsssNZbkZjE8+FzG3avMjGgMDwbGxXRoXkoeXiAZ+78Oa+eyDPHvDCiUb4zH/vmCOUSafLQ=="],
|
||||
|
||||
"@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.54.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-i02vF75b+ePsQP3tHqSxVYI5S6b8X/xqdPu7/mDHXtpgXLTYXi3jJmfHU0j+dnZZDKaYTx/ioCK7QYJmtiJR2g=="],
|
||||
|
||||
"@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.54.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-8VMFvGvooXj7mswkbrhdVZ2/sgiDaBzWpkkbtO+qGDLV4EfJd67nQadHkQC0ZNbaWA9ajXfqI6i7PZLIeDzxEQ=="],
|
||||
|
||||
"@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.54.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-0cRHnp43WN1Jrc5s0BdbdKgR1XirdvHy7TAFi3JEsoEVQVJxTXMbpVd76sxXlgRswNMDhVFSJw+y7Eb8mEavFQ=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-JyQAk3hK/OEtup7Rw6kZwfdzbKqTVD5jXXb8Xpfay29suwZyfBDMVW/bj4RqEPySYWc6zCp198pOluf8n5uYzg=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-qnvLatTpM8vtvjOfcckBOzJjk+n6ce/wwpP8OFeUrD5aNLYcKyWAitwj+Rk3PK9jGanbZvKsJnv14JGQ6XqFdw=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-SMkhnCzIYZYDk9vw3W/80eeYKmrMpGF0Giuxt4HruFlCH7jEtnPeb3SdQKMfgYi/dgtaf+hZAb5XWPYnxqCQ3w=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-QrwJlBFFKnxOd95TAaszpMbZBLzMoYMpGaQTZF8oibacnF5rv8l12IhILhQRPmksWiBqg0YSe2Mnl7ayeJAHSA=="],
|
||||
|
||||
"@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.54.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WILatiol/TUHTlhod7R09+7Az/XlhKwmY1MHfLZNmewltPWNN/EwxP2rQSHahibZ/cB8gmckEBjBOByD+5bYsQ=="],
|
||||
|
||||
"@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-f05YMG4BH4G8S4ME6UM6fi1MnJ9094mrnvO5Pa4SJlMfWlUM+1/ZWMEF4NnjM7shZAvbHsHRuVYpUo0PHC4P9Q=="],
|
||||
|
||||
"@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-UfL+2hj1ClNqcCRT9s8vBU4axDpjxgVxX96G+9DYAYjoc5b0u15CJtn2jgsi9iM+EbGNc5CW1HVRgwVu76UsSA=="],
|
||||
|
||||
"@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.54.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-3/XZe931Hka+J6NjnaqJzYpsWWxDTuRdUdwSQHnOuJEgbC+SehIMFJS8hsEjV7LBhVSL2OCnRLvbVW8O97XIyw=="],
|
||||
|
||||
"@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Ik93RlObtu43GbxApafayFjwYE06L6Xr08cSwpBPYbDrLp2ReZx0Jm1DqwRyYRnukUJy+rK2WaEvUQOxdytU9Q=="],
|
||||
|
||||
"@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-yZcakmPlD86CNymknd7KfW+FH+qfbqJH+i0h69CYfV1+KMoVeM9UED+8+TDVoU4haxI0NxY7RPCvRLy3Sqd2Qg=="],
|
||||
|
||||
"@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.54.0", "", { "os": "none", "cpu": "arm64" }, "sha512-GiVBZNnEZnKu00f1jTg49nomv187d0GQX+O+ocykoLeiaALuEO+swoTehHn9TehTfi7V8H0i0e/yvUjCqnwk1w=="],
|
||||
|
||||
"@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.54.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-J0SSB8Z1Fre2sxRolYcW6Rl1RQmKdQ2hnHyq4YJrfBRiXTObLw4DXnIVraM/UyqGqwOi7yTrQA4VT7DPxlHVKA=="],
|
||||
|
||||
"@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.54.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-O61UDVj8zz6yXJjkHPf05VaMLOXmEF8P5kf/N0W7AQMmd6bcQogl+KJc7rMutKTL524oE9iH32JXZClBFmEQIg=="],
|
||||
|
||||
"@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-1MDpqJPiFqxWtIHas8vkb1VZ7f7eKyTffAwmO8isxQYMaG1OFKsH666BWLeXQLO+IWNfiMssLD55hbR1lIPTqg=="],
|
||||
|
||||
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.69.0", "", { "os": "android", "cpu": "arm" }, "sha512-DKQQbD5cZ/MYfDgDI7YGyGD9FSxABlsBsYFo5p26lloob543tP9+4N3guwdXIYJN+7HSZxLe8YJuwcOWw5qnHg=="],
|
||||
|
||||
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.69.0", "", { "os": "android", "cpu": "arm64" }, "sha512-lEhb+I5pr4inux+JFwfCa1HRq3Os7NirEFQ0H1I35SVEHPm6byX0Ah47xmRha3qi6LAkxUcxViL8o/9PivjzBg=="],
|
||||
|
||||
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.69.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GY2YE8lOZW59BW1Ia1y+1gR0XyjrZRvVWHAr8LGeGhYHE0OQJ/7cRKXTkx1P+E9/6awEc3SX8a68SFTjh/E//A=="],
|
||||
|
||||
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.69.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ax1oZnOjHX3LB7myQyHEaQkDwfLb6str3/nSP6O7EVUviQGNkEGzGV0EqcBJWK+Ufwx0l4xPgyYayurvhAdl2Q=="],
|
||||
|
||||
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.69.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kHWeHv4g2h8NY+mpCxzCtY4uerMJWTN/TSnNj1CPbakFpHEJ6cTya2wWV0pDSYWOJ2+0UiEbhn3AtXxHtsnKjg=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.69.0", "", { "os": "linux", "cpu": "arm" }, "sha512-gq84vM1a1oEehXo27YCDzGVcxPsZDI1yswZwz2Da1/cbnWtrL16XZZnz0G/+gIU8edtHpfjxq5c+vWEHqJfWoQ=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.69.0", "", { "os": "linux", "cpu": "arm" }, "sha512-kIqEa98JQ0VRyrcncxA417m2AzasqTlD+FyVT1AksjvjkqQcvm7pBWYvoW3/mpyOP2XYvi5nSCCTIe6De1yu5g=="],
|
||||
|
||||
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.69.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j+xYiXozxGWx2cpjCrwwGR4awTxPFsRv3JZrv23RCogEPMc4R7UqjHW47p/RG0aRlbWiROCJ8coUfCwy0dvzHA=="],
|
||||
|
||||
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.69.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-xEPpNppTfN1l/nM7gYSf9iocscu/as+p/7vxkLeLEKnYU+09Dm+5V6IhDYDh+Uz6FajEupWwCLt5SOG0y1PCKg=="],
|
||||
|
||||
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.69.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Ug0+eU7HJBlek+SjklYH62IlOMirEJsdxpihH0kSqX0XdrDD4NdHpQc10fK1JC35yn6KrrcN+uYzlHD38XAf8Q=="],
|
||||
|
||||
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.69.0", "", { "os": "linux", "cpu": "none" }, "sha512-iEyI3GIg0l/s3G4qy2TlaaWKdzj4PJJStwtlocpDTC00PY9hZueotf6OKUj9+yfQh0lrpBW/pLMgTztbAHKJEg=="],
|
||||
|
||||
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.69.0", "", { "os": "linux", "cpu": "none" }, "sha512-NjHjpiI4WIKSMwuoJSZi5VToPeoYOS1FR52HLIDG6lidMdqquusgtODb4iLk0+lb1q3Z0nv2/aPRcC/olmpQGg=="],
|
||||
|
||||
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.69.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Ai/prDewoItkDXbp38gwGZi41DycZbUTZJ3UidwoHgQC0/DaqC2TGdtBTQLJ6hSD+SAxASzh8+/eSBPmxfOacA=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.69.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Gt3KHgp46mRKz4sJeaASmKvD8ayXookRw07RMf+NowhEztGGDZ7VrXpoW96XuKJLjFukWizOFVNjmYb/u7caNQ=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.69.0", "", { "os": "linux", "cpu": "x64" }, "sha512-7tQhJ2+p/oHv1zcfnjYI7YVzC/7iBaVOfIvFYtxdJ5F45mWgEdrCyXZXZGfiLey5t/5JhOhsaMnnv1kAzckd7g=="],
|
||||
|
||||
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.69.0", "", { "os": "none", "cpu": "arm64" }, "sha512-vmWz6TKp/3hfA4lksR0zHBv/6xuX1jhym6eqOjdH2DXsDDHZWcp2f0KG0VCAnlVbIrjk29G4wAWMXb/Hn1YobA=="],
|
||||
|
||||
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.69.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-9RExaLgmaw6IoIkU9cTpT71mLfI0xZ86iZH8x518LVsOkjquJMYqb9P7KpC8lgd1t0Dxs41p2pxynq4XR3Ttzw=="],
|
||||
|
||||
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.69.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-1907kRPF8/PrcIw1E7LMs9JbVrpgnt/MvFdss3an8oDkYNAACXzTntV3t3869ZZhMZxb2AzRGbz1pA/jdFatXA=="],
|
||||
|
||||
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.69.0", "", { "os": "win32", "cpu": "x64" }, "sha512-w8SOXv3mT9Fi6jY8OXdXCfnvX/3KNLXGNr4HEz2TA7S4Mv/PYAOmpB8y/ge40mxvBMgGNaSaaDwZpAsQn7HtWA=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="],
|
||||
|
||||
"ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="],
|
||||
|
||||
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
|
||||
|
||||
"cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
|
||||
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
|
||||
|
||||
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
|
||||
|
||||
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
|
||||
|
||||
"lint-staged": ["lint-staged@17.0.7", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA=="],
|
||||
|
||||
"listr2": ["listr2@10.2.1", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q=="],
|
||||
|
||||
"log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
|
||||
|
||||
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
||||
|
||||
"onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
||||
|
||||
"oxfmt": ["oxfmt@0.54.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.54.0", "@oxfmt/binding-android-arm64": "0.54.0", "@oxfmt/binding-darwin-arm64": "0.54.0", "@oxfmt/binding-darwin-x64": "0.54.0", "@oxfmt/binding-freebsd-x64": "0.54.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.54.0", "@oxfmt/binding-linux-arm-musleabihf": "0.54.0", "@oxfmt/binding-linux-arm64-gnu": "0.54.0", "@oxfmt/binding-linux-arm64-musl": "0.54.0", "@oxfmt/binding-linux-ppc64-gnu": "0.54.0", "@oxfmt/binding-linux-riscv64-gnu": "0.54.0", "@oxfmt/binding-linux-riscv64-musl": "0.54.0", "@oxfmt/binding-linux-s390x-gnu": "0.54.0", "@oxfmt/binding-linux-x64-gnu": "0.54.0", "@oxfmt/binding-linux-x64-musl": "0.54.0", "@oxfmt/binding-openharmony-arm64": "0.54.0", "@oxfmt/binding-win32-arm64-msvc": "0.54.0", "@oxfmt/binding-win32-ia32-msvc": "0.54.0", "@oxfmt/binding-win32-x64-msvc": "0.54.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-DjnMwn7smSLF+Mc2+pRItnuPftm/dkUFpY/d4+33y9TfKrsHZo8GLhmUg9BrOIUEy94Rlom1Q11N6vuhE+e0oQ=="],
|
||||
|
||||
"oxlint": ["oxlint@1.69.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.69.0", "@oxlint/binding-android-arm64": "1.69.0", "@oxlint/binding-darwin-arm64": "1.69.0", "@oxlint/binding-darwin-x64": "1.69.0", "@oxlint/binding-freebsd-x64": "1.69.0", "@oxlint/binding-linux-arm-gnueabihf": "1.69.0", "@oxlint/binding-linux-arm-musleabihf": "1.69.0", "@oxlint/binding-linux-arm64-gnu": "1.69.0", "@oxlint/binding-linux-arm64-musl": "1.69.0", "@oxlint/binding-linux-ppc64-gnu": "1.69.0", "@oxlint/binding-linux-riscv64-gnu": "1.69.0", "@oxlint/binding-linux-riscv64-musl": "1.69.0", "@oxlint/binding-linux-s390x-gnu": "1.69.0", "@oxlint/binding-linux-x64-gnu": "1.69.0", "@oxlint/binding-linux-x64-musl": "1.69.0", "@oxlint/binding-openharmony-arm64": "1.69.0", "@oxlint/binding-win32-arm64-msvc": "1.69.0", "@oxlint/binding-win32-ia32-msvc": "1.69.0", "@oxlint/binding-win32-x64-msvc": "1.69.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-ypZkK/aDc5NQV8zIR6s2H2Tl3aNW8FmJ1m9+2qsaYuRenl8vgnHNCGwTHviWJdUQzglOlHFchgopdtGhSy17Rw=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||
|
||||
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="],
|
||||
|
||||
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
|
||||
|
||||
"string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
|
||||
|
||||
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="],
|
||||
|
||||
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||
|
||||
"log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
|
||||
|
||||
"log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
||||
|
||||
"log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugin": [
|
||||
"superpowers@git+https://github.com/obra/superpowers.git"
|
||||
]
|
||||
"plugin": ["superpowers@git+https://github.com/obra/superpowers.git"]
|
||||
}
|
||||
|
||||
47
package.json
47
package.json
@@ -1,20 +1,47 @@
|
||||
{
|
||||
"name": "rune",
|
||||
"version": "0.1.0",
|
||||
"module": "src/cli.ts",
|
||||
"type": "module",
|
||||
"name": "@lanyuanxiaoyao/rune",
|
||||
"version": "0.1.2",
|
||||
"bin": {
|
||||
"rune": "./src/cli.ts"
|
||||
},
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
"files": [
|
||||
"src",
|
||||
"README.md"
|
||||
],
|
||||
"type": "module",
|
||||
"module": "src/cli.ts",
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
"test": "bun test --path-ignore-patterns 'tests/agent/**'",
|
||||
"test:e2e": "bun test tests/agent/ --path-ignore-patterns 'e2e-llm-judge*'",
|
||||
"test:e2e:llm": "bun test tests/agent/e2e-llm-judge.test.ts --timeout 120000",
|
||||
"lint": "oxlint",
|
||||
"format": "oxfmt .",
|
||||
"format:check": "oxfmt --check .",
|
||||
"check": "oxlint && oxfmt --check .",
|
||||
"release": "bun run scripts/release.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"cac": "^7.0.0",
|
||||
"yaml": "^2.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^17.0.7",
|
||||
"oxfmt": "^0.54.0",
|
||||
"oxlint": "^1.69.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,js,mjs,cjs}": [
|
||||
"oxlint",
|
||||
"oxfmt"
|
||||
],
|
||||
"*.{json,md,yaml,yml}": [
|
||||
"oxfmt"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
211
scripts/release.ts
Normal file
211
scripts/release.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { createInterface } from "node:readline";
|
||||
import { join } from "node:path";
|
||||
|
||||
export interface Semver {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
}
|
||||
|
||||
export type BumpType = "major" | "minor" | "patch";
|
||||
|
||||
export function parseSemver(version: string): Semver {
|
||||
const parts = version.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new Error(`无效的版本号格式: ${version}`);
|
||||
}
|
||||
const [major, minor, patch] = parts.map((p) => {
|
||||
const n = Number(p);
|
||||
if (Number.isNaN(n) || !Number.isInteger(n) || n < 0) {
|
||||
throw new Error(`无效的版本号格式: ${version}`);
|
||||
}
|
||||
return n;
|
||||
});
|
||||
return { major: major!, minor: minor!, patch: patch! };
|
||||
}
|
||||
|
||||
export function bumpVersion(current: string, type: BumpType): string {
|
||||
const semver = parseSemver(current);
|
||||
switch (type) {
|
||||
case "major":
|
||||
return `${semver.major + 1}.0.0`;
|
||||
case "minor":
|
||||
return `${semver.major}.${semver.minor + 1}.0`;
|
||||
case "patch":
|
||||
return `${semver.major}.${semver.minor}.${semver.patch + 1}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function ask(query: string): Promise<string> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
rl.question(query, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function selectBumpType(): Promise<BumpType> {
|
||||
console.log("选择版本递增类型:");
|
||||
console.log(" 1) major - 不兼容的 API 变更");
|
||||
console.log(" 2) minor - 向下兼容的功能新增");
|
||||
console.log(" 3) patch - 向下兼容的问题修正");
|
||||
|
||||
while (true) {
|
||||
const answer = await ask("请输入 1/2/3 [3]: ");
|
||||
const choice = answer || "3";
|
||||
if (choice === "1") return "major";
|
||||
if (choice === "2") return "minor";
|
||||
if (choice === "3") return "patch";
|
||||
console.log("无效选择,请输入 1、2 或 3");
|
||||
}
|
||||
}
|
||||
|
||||
async function stepBumpVersion(): Promise<string> {
|
||||
const pkgPath = join(import.meta.dir, "..", "package.json");
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version: string };
|
||||
if (typeof pkg.version !== "string" || pkg.version.length === 0) {
|
||||
throw new Error("package.json 中缺少有效的 version 字段");
|
||||
}
|
||||
const currentVersion = pkg.version;
|
||||
|
||||
const bumpType = await selectBumpType();
|
||||
const newVersion = bumpVersion(currentVersion, bumpType);
|
||||
|
||||
const answer = await ask(`确认版本号 ${currentVersion} → ${newVersion}? [y/N]: `);
|
||||
if (answer.toLowerCase() !== "y") {
|
||||
console.log("已取消");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
return newVersion;
|
||||
}
|
||||
|
||||
async function runTests(): Promise<void> {
|
||||
console.log("\n运行测试...");
|
||||
const proc = Bun.spawn(["bun", "test", "--path-ignore-patterns", "tests/agent/**"], {
|
||||
stdio: ["inherit", "inherit", "inherit"],
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`测试失败 (exit code: ${exitCode}),已跳过 git 和发布步骤`);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkWorkingTree(): Promise<void> {
|
||||
const proc = Bun.spawn(["git", "status", "--porcelain"], {
|
||||
stdio: ["inherit", "pipe", "inherit"],
|
||||
});
|
||||
const output = await new Response(proc.stdout).text();
|
||||
const lines = output.trim().split("\n").filter(Boolean);
|
||||
if (lines.length > 0) {
|
||||
throw new Error("工作区有未提交变更,请先提交或清理后再运行 release");
|
||||
}
|
||||
}
|
||||
|
||||
async function stepGitCommitTag(version: string): Promise<void> {
|
||||
console.log("\n准备提交:");
|
||||
console.log(` git add package.json`);
|
||||
console.log(` git commit -m "chore: release v${version}"`);
|
||||
console.log(` git tag v${version}`);
|
||||
|
||||
const answer = await ask("确认执行以上 git 操作? [y/N]: ");
|
||||
if (answer.toLowerCase() !== "y") {
|
||||
console.log("已取消 git 操作");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// git add package.json
|
||||
const addProc = Bun.spawn(["git", "add", "package.json"], {
|
||||
stdio: ["inherit", "inherit", "inherit"],
|
||||
});
|
||||
const addExit = await addProc.exited;
|
||||
if (addExit !== 0) {
|
||||
throw new Error("git add 失败");
|
||||
}
|
||||
|
||||
// git commit
|
||||
const commitProc = Bun.spawn(["git", "commit", "-m", `chore: release v${version}`], {
|
||||
stdio: ["inherit", "inherit", "inherit"],
|
||||
});
|
||||
const commitExit = await commitProc.exited;
|
||||
if (commitExit !== 0) {
|
||||
throw new Error("git commit 失败");
|
||||
}
|
||||
|
||||
// git tag
|
||||
const tagProc = Bun.spawn(
|
||||
["git", "tag", "-a", `v${version}`, "-m", `chore: release v${version}`],
|
||||
{
|
||||
stdio: ["inherit", "inherit", "inherit"],
|
||||
},
|
||||
);
|
||||
const tagExit = await tagProc.exited;
|
||||
if (tagExit !== 0) {
|
||||
throw new Error("git tag 失败");
|
||||
}
|
||||
|
||||
console.log(`git commit 和 tag v${version} 已完成`);
|
||||
}
|
||||
|
||||
async function stepNpmPublish(): Promise<void> {
|
||||
console.log("\nnpm 发布预览:");
|
||||
const dryRunProc = Bun.spawn(["bun", "publish", "--dry-run"], {
|
||||
stdio: ["inherit", "inherit", "inherit"],
|
||||
});
|
||||
const dryRunExit = await dryRunProc.exited;
|
||||
if (dryRunExit !== 0) {
|
||||
throw new Error("npm publish --dry-run 失败");
|
||||
}
|
||||
|
||||
const answer = await ask("确认发布到 npm? [y/N]: ");
|
||||
if (answer.toLowerCase() !== "y") {
|
||||
console.log("已取消发布");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const proc = Bun.spawn(["bun", "publish", "--access", "public"], {
|
||||
stdio: ["inherit", "inherit", "inherit"],
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`npm publish 失败 (exit code: ${exitCode}),请检查 npm 登录状态 (npm whoami)`);
|
||||
}
|
||||
|
||||
console.log("npm 发布成功");
|
||||
}
|
||||
|
||||
function writeVersion(version: string): void {
|
||||
const pkgPath = join(import.meta.dir, "..", "package.json");
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version: string };
|
||||
pkg.version = version;
|
||||
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await checkWorkingTree();
|
||||
|
||||
const newVersion = await stepBumpVersion();
|
||||
console.log(`目标版本: ${newVersion}`);
|
||||
|
||||
await runTests();
|
||||
console.log("[1/3] 测试通过");
|
||||
|
||||
writeVersion(newVersion);
|
||||
console.log(`[2/3] 版本号已更新: ${newVersion}`);
|
||||
|
||||
await stepGitCommitTag(newVersion);
|
||||
console.log(`[3/3] git commit 和 tag v${newVersion} 完成`);
|
||||
|
||||
await stepNpmPublish();
|
||||
console.log("npm 发布完成");
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
main().catch((err: unknown) => {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
|
||||
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";
|
||||
|
||||
@@ -13,12 +14,8 @@ export async function injectClaudeCode(projectRoot: string): Promise<void> {
|
||||
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如果用户没有指定变更名称,请向用户确认。"
|
||||
: "";
|
||||
const cmd = hasChangeName ? `rune ${stage} <变更名>` : `rune ${stage}`;
|
||||
const nameHint = hasChangeName ? "\n如果用户没有指定变更名称,请向用户确认。" : "";
|
||||
await writeFile(
|
||||
commandPath,
|
||||
`执行以下命令,将输出作为当前阶段的工作指引:\n\`\`\`bash\n${cmd}\n\`\`\`${nameHint}\n`,
|
||||
@@ -29,9 +26,23 @@ export async function injectClaudeCode(projectRoot: string): Promise<void> {
|
||||
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`,
|
||||
);
|
||||
await writeFile(statusPath, `执行以下命令查看变更状态:\n\`\`\`bash\nrune status\n\`\`\`\n`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateClaudeCode(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`);
|
||||
const cmd = hasChangeName ? `rune ${stage} <变更名>` : `rune ${stage}`;
|
||||
const nameHint = hasChangeName ? "\n如果用户没有指定变更名称,请向用户确认。" : "";
|
||||
const newContent = `执行以下命令,将输出作为当前阶段的工作指引:\n\`\`\`bash\n${cmd}\n\`\`\`${nameHint}\n`;
|
||||
await writeIfChanged(commandPath, newContent);
|
||||
}
|
||||
|
||||
const statusPath = join(projectRoot, COMMANDS_DIR, "rune-status.md");
|
||||
await writeIfChanged(statusPath, `执行以下命令查看变更状态:\n\`\`\`bash\nrune status\n\`\`\`\n`);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,35 @@ export async function injectOpenCode(projectRoot: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateOpenCode(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`);
|
||||
const newCommand = generateCommand(stage, hasChangeName);
|
||||
await writeIfChanged(commandPath, newCommand);
|
||||
|
||||
const skillStageDir = join(projectRoot, SKILLS_DIR, `rune-${stage}`);
|
||||
await mkdir(skillStageDir, { recursive: true });
|
||||
const skillPath = join(skillStageDir, "SKILL.md");
|
||||
const newSkill = generateSkill(stage, hasChangeName);
|
||||
await writeIfChanged(skillPath, newSkill);
|
||||
}
|
||||
|
||||
const commandDir = join(projectRoot, COMMANDS_DIR);
|
||||
const statusCommandPath = join(commandDir, "rune-status.md");
|
||||
await writeIfChanged(statusCommandPath, generateStatusCommand());
|
||||
|
||||
const statusSkillDir = join(projectRoot, SKILLS_DIR, "rune-status");
|
||||
await mkdir(statusSkillDir, { recursive: true });
|
||||
const statusSkillPath = join(statusSkillDir, "SKILL.md");
|
||||
await writeIfChanged(statusSkillPath, generateStatusSkill());
|
||||
}
|
||||
|
||||
import { writeIfChanged } from "./utils.ts";
|
||||
|
||||
function generateCommand(stage: string, hasChangeName: boolean): string {
|
||||
if (hasChangeName) {
|
||||
return `请调用 rune-${stage} skill 执行 ${stage} 阶段。
|
||||
@@ -52,9 +81,13 @@ function generateCommand(stage: string, hasChangeName: boolean): string {
|
||||
|
||||
function generateSkill(stage: string, hasChangeName: boolean): string {
|
||||
const cmd = hasChangeName ? `rune ${stage} <变更名>` : `rune ${stage}`;
|
||||
const nameHint = hasChangeName
|
||||
? `将 <变更名> 替换为实际的变更名称。\n`
|
||||
: "";
|
||||
const nameHint = hasChangeName ? `将 <变更名> 替换为实际的变更名称。\n` : "";
|
||||
|
||||
let extraGuide = "";
|
||||
if (stage === "plan") {
|
||||
extraGuide = `\n规划阶段应先运行 \`rune status <变更名>\` 获取当前有哪些文档需要编写,再按依赖顺序逐个生成。\n`;
|
||||
}
|
||||
|
||||
const descriptionMap: Record<string, string> = {
|
||||
discuss: "Use when 需要进入 SDD 讨论阶段,自由讨论需求和架构方案",
|
||||
plan: "Use when 需要进入 SDD 规划阶段,生成设计文档和任务清单",
|
||||
@@ -75,7 +108,7 @@ description: ${descriptionMap[stage] ?? `Use when 需要执行 SDD ${stage} 阶
|
||||
${cmd}
|
||||
\`\`\`
|
||||
|
||||
${nameHint}将命令输出作为工作指引,执行当前阶段的工作。
|
||||
${nameHint}${extraGuide}将命令输出作为工作指引,执行当前阶段的工作。
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
13
src/adapters/utils.ts
Normal file
13
src/adapters/utils.ts
Normal 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);
|
||||
}
|
||||
285
src/cli.ts
285
src/cli.ts
@@ -15,6 +15,7 @@ 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, RuneConfig } from "./types.ts";
|
||||
|
||||
function requireProjectRoot(): string {
|
||||
const root = findProjectRoot();
|
||||
@@ -24,6 +25,72 @@ function requireProjectRoot(): string {
|
||||
return root;
|
||||
}
|
||||
|
||||
export function validateChangeName(name: string): void {
|
||||
if (!/^[\u4e00-\u9fa5a-zA-Z-]+$/.test(name)) {
|
||||
throw new CommandError(`变更名 "${name}" 包含不支持的字符`, {
|
||||
hint: "变更名仅支持中文、英文和短横线(-)",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 = planDocs?.find((d) => d.name === doc.name);
|
||||
const depInfo =
|
||||
!doc.dependMet && docConfig?.depend?.length
|
||||
? `(依赖 ${docConfig.depend.map((d) => `${d}.md`).join("、")})`
|
||||
: "";
|
||||
lines.push(` ${doc.name}.md ○ 待完成${depInfo}`);
|
||||
}
|
||||
}
|
||||
|
||||
const completedCount = change.documents.filter((d) => d.completed).length;
|
||||
lines.push(` 规划进度:${completedCount}/${change.documents.length} 文档已完成`);
|
||||
|
||||
if (change.buildUnlocked) {
|
||||
lines.push(" 构建阶段:已解锁");
|
||||
} else {
|
||||
lines.push(" 构建阶段:未解锁(需完成规划)");
|
||||
}
|
||||
|
||||
if (change.taskProgress) {
|
||||
lines.push(` 任务进度:${change.taskProgress.completed}/${change.taskProgress.total} 已完成`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(` 建议下一步:${suggestNextStep(change)}`);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function suggestNextStep(change: ChangeStatus): string {
|
||||
if (!change.planCompleted) {
|
||||
const nextDoc = change.documents.find((d) => !d.completed && d.dependMet);
|
||||
if (nextDoc) {
|
||||
return `rune plan ${change.name} ${nextDoc.name}`;
|
||||
}
|
||||
return `完成前置依赖后再规划文档`;
|
||||
}
|
||||
|
||||
if (change.taskProgress && change.taskProgress.completed < change.taskProgress.total) {
|
||||
return `rune build ${change.name}`;
|
||||
}
|
||||
|
||||
if (change.taskProgress && change.taskProgress.completed === change.taskProgress.total) {
|
||||
return `rune archive ${change.name}`;
|
||||
}
|
||||
|
||||
return `rune build ${change.name}`;
|
||||
}
|
||||
|
||||
const cli = cac("rune");
|
||||
|
||||
cli.command("", "").action(() => {
|
||||
@@ -49,18 +116,44 @@ cli.command("version", "显示版本号").action(() => {
|
||||
console.log(`rune v${pkg.version}`);
|
||||
});
|
||||
|
||||
cli.command("init [...tools]", "初始化 Rune 并注入工具配置").action(
|
||||
async (tools: string[]) => {
|
||||
if (!tools || tools.length === 0) {
|
||||
throw new UsageError("请指定至少一个工具", {
|
||||
usage: "rune init <工具...>",
|
||||
hint: "如:rune init opencode",
|
||||
cli.command("init [...tools]", "初始化 Rune 并注入工具配置").action(async (tools: string[]) => {
|
||||
if (!tools || tools.length === 0) {
|
||||
throw new UsageError("请指定至少一个工具", {
|
||||
usage: "rune init <工具...>",
|
||||
hint: "如:rune init opencode",
|
||||
});
|
||||
}
|
||||
await runInit(process.cwd(), tools);
|
||||
console.log(`Rune 初始化完成,已注入工具:${tools.join(", ")}`);
|
||||
});
|
||||
|
||||
cli.command("update [...tools]", "更新已注入的工具配置").action(async (tools: string[]) => {
|
||||
if (!tools || tools.length === 0) {
|
||||
throw new UsageError("请指定至少一个工具", {
|
||||
usage: "rune update <工具...>",
|
||||
hint: "如:rune update opencode",
|
||||
});
|
||||
}
|
||||
const root = requireProjectRoot();
|
||||
const { updateOpenCode } = await import("./adapters/opencode.ts");
|
||||
const { updateClaudeCode } = await import("./adapters/claude-code.ts");
|
||||
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 (!SUPPORTED_TOOLS[tool]) {
|
||||
throw new CommandError(`不支持的工具: ${tool}`, {
|
||||
hint: `支持的工具: ${Object.keys(SUPPORTED_TOOLS).join(", ")}`,
|
||||
});
|
||||
}
|
||||
await runInit(process.cwd(), tools);
|
||||
console.log(`Rune 初始化完成,已注入工具:${tools.join(", ")}`);
|
||||
},
|
||||
);
|
||||
}
|
||||
for (const tool of tools) {
|
||||
await updaters[tool](root);
|
||||
}
|
||||
console.log(`工具配置已更新:${tools.join(", ")}`);
|
||||
});
|
||||
|
||||
cli.command("discuss", "讨论阶段").action(async () => {
|
||||
const root = requireProjectRoot();
|
||||
@@ -69,96 +162,142 @@ cli.command("discuss", "讨论阶段").action(async () => {
|
||||
console.log(prompt);
|
||||
});
|
||||
|
||||
cli.command("plan <change-name>", "规划阶段").action(
|
||||
async (changeName: string) => {
|
||||
cli
|
||||
.command("plan <change-name> <document-name>", "规划阶段")
|
||||
.action(async (changeName: string, documentName: string) => {
|
||||
validateChangeName(changeName);
|
||||
const root = requireProjectRoot();
|
||||
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 = requireProjectRoot();
|
||||
const changeDir = getChangeDir(root, changeName);
|
||||
if (!existsSync(changeDir)) {
|
||||
throw new CommandError(`变更 '${changeName}' 不存在`, {
|
||||
hint: `请先运行 rune plan ${changeName} 创建变更`,
|
||||
const planDocs = config.stages.plan?.documents;
|
||||
if (!planDocs || !planDocs.find((d) => d.name === documentName)) {
|
||||
throw new CommandError(`文档 "${documentName}" 不在配置的 plan.documents 中`, {
|
||||
hint: `可用文档:${planDocs?.map((d) => d.name).join(", ") ?? "无"}`,
|
||||
});
|
||||
}
|
||||
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 = requireProjectRoot();
|
||||
const changeDir = getChangeDir(root, changeName);
|
||||
if (!existsSync(changeDir)) {
|
||||
throw new CommandError(`变更 '${changeName}' 不存在`, {
|
||||
hint: `请先运行 rune plan ${changeName} 创建变更`,
|
||||
});
|
||||
}
|
||||
const config = await loadConfig(root);
|
||||
const prompt = assembleArchivePrompt(config, changeName);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const src = changeDir;
|
||||
const dest = join(getArchiveDir(root), `${today}-${changeName}`);
|
||||
await rename(src, dest);
|
||||
console.log(prompt);
|
||||
},
|
||||
);
|
||||
await mkdir(changeDir, { recursive: true });
|
||||
|
||||
cli.command("status", "查看变更状态").action(async () => {
|
||||
const doc = planDocs.find((d) => d.name === documentName)!;
|
||||
if (doc.depend && doc.depend.length > 0) {
|
||||
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("、")} 尚未完成`,
|
||||
{
|
||||
hint: `请先完成依赖文档:rune plan ${changeName} ${missing[0]}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const prompt = await assemblePlanPrompt(config, root, changeName, documentName);
|
||||
console.log(prompt);
|
||||
});
|
||||
|
||||
cli.command("build <change-name>", "构建阶段").action(async (changeName: string) => {
|
||||
validateChangeName(changeName);
|
||||
const root = requireProjectRoot();
|
||||
const changes = await scanChanges(root);
|
||||
if (changes.length === 0) {
|
||||
console.log("当前无进行中的变更。");
|
||||
return;
|
||||
const changeDir = getChangeDir(root, changeName);
|
||||
if (!existsSync(changeDir)) {
|
||||
throw new CommandError(`变更 '${changeName}' 不存在`, {
|
||||
hint: `请先运行 rune plan ${changeName} 创建变更`,
|
||||
});
|
||||
}
|
||||
for (const change of changes) {
|
||||
const progress = change.taskProgress
|
||||
? ` (${change.taskProgress.completed}/${change.taskProgress.total} 任务)`
|
||||
: "";
|
||||
console.log(`- ${change.name}${progress} [${change.documents.join(", ")}]`);
|
||||
const config = await loadConfig(root);
|
||||
const prompt = await assembleBuildPrompt(config, root, changeName);
|
||||
console.log(prompt);
|
||||
});
|
||||
|
||||
cli.command("archive <change-name>", "归档阶段").action(async (changeName: string) => {
|
||||
validateChangeName(changeName);
|
||||
const root = requireProjectRoot();
|
||||
const changeDir = getChangeDir(root, changeName);
|
||||
if (!existsSync(changeDir)) {
|
||||
throw new CommandError(`变更 '${changeName}' 不存在`, {
|
||||
hint: `请先运行 rune plan ${changeName} 创建变更`,
|
||||
});
|
||||
}
|
||||
const config = await loadConfig(root);
|
||||
const prompt = await assembleArchivePrompt(config, root, changeName);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const src = changeDir;
|
||||
const dest = join(getArchiveDir(root), `${today}-${changeName}`);
|
||||
await rename(src, dest);
|
||||
console.log(prompt);
|
||||
});
|
||||
|
||||
cli.command("status [change-name]", "查看变更状态").action(async (changeName?: string) => {
|
||||
const root = requireProjectRoot();
|
||||
const config = await loadConfig(root);
|
||||
const changes = await scanChanges(root, config);
|
||||
|
||||
if (changeName) {
|
||||
const change = changes.find((c) => c.name === changeName);
|
||||
if (!change) {
|
||||
throw new CommandError(`变更 "${changeName}" 不存在`, {
|
||||
hint: "运行 rune status 查看所有变更",
|
||||
});
|
||||
}
|
||||
console.log(formatChangeStatus(change, config));
|
||||
} else {
|
||||
if (changes.length === 0) {
|
||||
console.log("当前无进行中的变更。");
|
||||
return;
|
||||
}
|
||||
for (const change of changes) {
|
||||
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) => {
|
||||
|
||||
@@ -2,10 +2,7 @@ export class CliError extends Error {
|
||||
readonly hint?: string;
|
||||
readonly usage?: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
opts?: { hint?: string; usage?: string },
|
||||
) {
|
||||
constructor(message: string, opts?: { hint?: string; usage?: string }) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
this.hint = opts?.hint;
|
||||
|
||||
@@ -16,10 +16,7 @@ const COMMANDS: Record<string, CommandHelpDef> = {
|
||||
usage: "rune init <工具...>",
|
||||
args: [{ name: "<工具...>", desc: "要注入的 AI 工具,如 opencode、claude-code" }],
|
||||
detail: "在当前项目中创建 .rune 目录结构,并注入指定 AI 工具的 command 和 skill 配置文件。",
|
||||
examples: [
|
||||
"rune init opencode",
|
||||
"rune init opencode claude-code",
|
||||
],
|
||||
examples: ["rune init opencode", "rune init opencode claude-code"],
|
||||
},
|
||||
discuss: {
|
||||
name: "discuss",
|
||||
@@ -32,48 +29,53 @@ const COMMANDS: Record<string, CommandHelpDef> = {
|
||||
},
|
||||
plan: {
|
||||
name: "plan",
|
||||
alias: "plan <名称>",
|
||||
description: "规划:生成规划阶段提示词",
|
||||
usage: "rune plan <change-name>",
|
||||
args: [{ name: "<change-name>", desc: "变更名称,如 \"add-login\"" }],
|
||||
detail: "生成规划阶段的提示词。变更目录将创建在 .rune/changes/<change-name>/ 下。",
|
||||
examples: [
|
||||
"rune plan add-user-auth",
|
||||
"rune plan fix-memory-leak",
|
||||
alias: "plan <变更> <文档>",
|
||||
description: "规划:生成指定文档的规划提示词",
|
||||
usage: "rune plan <change-name> <document-name>",
|
||||
args: [
|
||||
{ name: "<change-name>", desc: '变更名称,如 "add-login"' },
|
||||
{ name: "<document-name>", desc: '文档名称,如 "design"、"task"' },
|
||||
],
|
||||
detail:
|
||||
"生成规划阶段指定文档的提示词。依赖的前置文档必须已完成。可用文档由配置中的 plan.documents 定义。",
|
||||
examples: ["rune plan add-user-auth design", "rune plan add-user-auth task"],
|
||||
},
|
||||
build: {
|
||||
name: "build",
|
||||
alias: "build <名称>",
|
||||
description: "构建:生成构建阶段提示词",
|
||||
usage: "rune build <change-name>",
|
||||
args: [{ name: "<change-name>", desc: "变更名称,如 \"add-login\"" }],
|
||||
args: [{ name: "<change-name>", desc: '变更名称,如 "add-login"' }],
|
||||
detail: "生成构建阶段的提示词。变更目录需已存在(通过 rune plan 创建)。",
|
||||
examples: [
|
||||
"rune build add-user-auth",
|
||||
"rune build fix-memory-leak",
|
||||
],
|
||||
examples: ["rune build add-user-auth", "rune build fix-memory-leak"],
|
||||
},
|
||||
archive: {
|
||||
name: "archive",
|
||||
alias: "archive <名称>",
|
||||
description: "归档:归档变更并生成提示词",
|
||||
usage: "rune archive <change-name>",
|
||||
args: [{ name: "<change-name>", desc: "变更名称,如 \"add-login\"" }],
|
||||
args: [{ name: "<change-name>", desc: '变更名称,如 "add-login"' }],
|
||||
detail: "将变更目录从 .rune/changes/ 移动到 .rune/archive/,并生成归档阶段提示词。",
|
||||
examples: [
|
||||
"rune archive add-user-auth",
|
||||
"rune archive fix-memory-leak",
|
||||
],
|
||||
examples: ["rune archive add-user-auth", "rune archive fix-memory-leak"],
|
||||
},
|
||||
update: {
|
||||
name: "update",
|
||||
alias: "update <工具...>",
|
||||
description: "更新:更新已注入的编辑器配置",
|
||||
usage: "rune update <工具...>",
|
||||
args: [{ name: "<工具...>", desc: "要更新的 AI 工具,如 opencode、claude-code" }],
|
||||
detail:
|
||||
"对比已注入的命令和 skill 文件,与内置版本不一致时覆盖,不存在时新建。用于升级 Rune 后同步编辑器配置。",
|
||||
examples: ["rune update opencode", "rune update opencode claude-code"],
|
||||
},
|
||||
status: {
|
||||
name: "status",
|
||||
alias: "status",
|
||||
description: "查看:列出当前进行中的变更",
|
||||
usage: "rune status",
|
||||
args: [],
|
||||
detail: "扫描 .rune/changes/ 目录,列出所有进行中的变更及其任务进度。",
|
||||
examples: ["rune status"],
|
||||
alias: "status [变更]",
|
||||
description: "查看:展示变更状态与下一步建议",
|
||||
usage: "rune status [change-name]",
|
||||
args: [{ name: "[change-name]", desc: "可选,指定查看的变更名称" }],
|
||||
detail: "展示各文档完成状态、依赖满足情况、规划进度和下一步建议。不传参数则显示所有变更。",
|
||||
examples: ["rune status", "rune status add-user-auth"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -99,7 +101,8 @@ export function showGlobalHelp(): string {
|
||||
lines.push("");
|
||||
lines.push("示例:");
|
||||
lines.push(" rune init opencode 初始化并注入 OpenCode 配置");
|
||||
lines.push(" rune plan add-login 开始规划 \"add-login\" 变更");
|
||||
lines.push(" rune update opencode 更新 OpenCode 配置");
|
||||
lines.push(' rune plan add-login design 规划 "add-login" 的设计文档');
|
||||
lines.push(" rune status 查看当前变更状态");
|
||||
|
||||
return lines.join("\n");
|
||||
@@ -109,12 +112,7 @@ export function showCommandHelp(name: string): string | null {
|
||||
const cmd = COMMANDS[name];
|
||||
if (!cmd) return null;
|
||||
|
||||
const lines: string[] = [
|
||||
`rune ${cmd.name} — ${cmd.description}`,
|
||||
"",
|
||||
"用法:",
|
||||
` ${cmd.usage}`,
|
||||
];
|
||||
const lines: string[] = [`rune ${cmd.name} — ${cmd.description}`, "", "用法:", ` ${cmd.usage}`];
|
||||
|
||||
if (cmd.args.length > 0) {
|
||||
lines.push("");
|
||||
|
||||
@@ -12,7 +12,7 @@ const CONFIG_TEMPLATE = `# Rune 配置文件
|
||||
# 阶段顺序:discuss -> plan -> build -> archive
|
||||
#
|
||||
# 可配置阶段:
|
||||
# discuss - 讨论阶段:自由讨论需求和架构
|
||||
# discuss - 探索阶段:深度思考、调查代码库、对比方案
|
||||
# plan - 规划阶段:生成设计文档和任务清单
|
||||
# build - 构建阶段:按任务清单逐步实现
|
||||
# archive - 归档阶段:确认完成并归档变更
|
||||
@@ -21,7 +21,7 @@ const CONFIG_TEMPLATE = `# Rune 配置文件
|
||||
# stages:
|
||||
# discuss:
|
||||
# prompt: |
|
||||
# 你是一位资深软件架构师...
|
||||
# 进入探索模式。深度思考,自由发散。跟随对话走向。
|
||||
#
|
||||
# 示例 - 自定义规划阶段的文档模板:
|
||||
# stages:
|
||||
@@ -33,19 +33,17 @@ const CONFIG_TEMPLATE = `# Rune 配置文件
|
||||
# # {{change-name}} 设计文档
|
||||
# - name: task
|
||||
# prompt: 生成任务清单
|
||||
# depend: [design]
|
||||
# template: |
|
||||
# # {{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,
|
||||
};
|
||||
|
||||
export async function runInit(
|
||||
projectRoot: string,
|
||||
tools: string[],
|
||||
): Promise<void> {
|
||||
export async function runInit(projectRoot: string, tools: string[]): Promise<void> {
|
||||
for (const tool of tools) {
|
||||
if (!SUPPORTED_TOOLS[tool]) {
|
||||
throw new CommandError(`不支持的工具: ${tool}`, {
|
||||
|
||||
@@ -2,12 +2,16 @@ import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { RuneConfig } from "../types.ts";
|
||||
import { CommandError } from "../cli/errors.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 阶段未配置");
|
||||
if (!discuss)
|
||||
throw new CommandError("讨论阶段未配置", {
|
||||
hint: "请在 .rune/config.yaml 中配置 stages.discuss",
|
||||
});
|
||||
return discuss.prompt;
|
||||
}
|
||||
|
||||
@@ -15,35 +19,56 @@ export async function assemblePlanPrompt(
|
||||
config: RuneConfig,
|
||||
projectRoot: string,
|
||||
changeName: string,
|
||||
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 CommandError(`文档 "${documentName}" 不在配置的 plan.documents 中`, {
|
||||
hint: `可用文档:${plan.documents.map((d) => d.name).join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
const changeDir = getChangeDir(projectRoot, changeName);
|
||||
const parts: string[] = [];
|
||||
|
||||
parts.push(`# 规划阶段:${changeName}\n`);
|
||||
parts.push("请为当前变更生成以下文档:\n");
|
||||
parts.push(`# 规划阶段:${changeName}`);
|
||||
parts.push("");
|
||||
parts.push(`## 文档:${doc.name}.md`);
|
||||
parts.push(doc.prompt);
|
||||
|
||||
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("");
|
||||
const docPath = join(changeDir, `${doc.name}.md`);
|
||||
if (existsSync(docPath)) {
|
||||
const existing = await readFile(docPath, "utf-8");
|
||||
parts.push(`\n### 已有内容(请在此基础上修订):\n${existing}`);
|
||||
}
|
||||
|
||||
parts.push(`请将文档写入目录:${changeDir}`);
|
||||
if (doc.template) {
|
||||
const rendered = doc.template.replace(/\{\{change-name\}\}/g, changeName);
|
||||
parts.push(`\n### 格式模板:\n${rendered}`);
|
||||
}
|
||||
|
||||
if (doc.depend && doc.depend.length > 0) {
|
||||
parts.push("\n---\n");
|
||||
parts.push("## 依赖说明\n");
|
||||
parts.push("本文档依赖以下前置文档:");
|
||||
|
||||
for (const dep of doc.depend) {
|
||||
const depPath = join(changeDir, `${dep}.md`);
|
||||
const depCompleted = existsSync(depPath);
|
||||
const status = depCompleted ? "已完成" : "未完成";
|
||||
parts.push(`- ${dep}.md(${status})`);
|
||||
}
|
||||
|
||||
parts.push("\n请先阅读已完成的前置文档,确保内容一致。");
|
||||
}
|
||||
|
||||
parts.push(`\n请将文档写入目录:${changeDir}`);
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
@@ -53,7 +78,11 @@ export async function assembleBuildPrompt(
|
||||
changeName: string,
|
||||
): Promise<string> {
|
||||
const build = config.stages.build;
|
||||
if (!build) throw new Error("build 阶段未配置");
|
||||
if (!build) {
|
||||
throw new CommandError("构建阶段未配置", {
|
||||
hint: "请在 .rune/config.yaml 中配置 stages.build",
|
||||
});
|
||||
}
|
||||
|
||||
const changeDir = getChangeDir(projectRoot, changeName);
|
||||
const taskPath = join(changeDir, "task.md");
|
||||
@@ -62,7 +91,9 @@ export async function assembleBuildPrompt(
|
||||
try {
|
||||
taskContent = await readFile(taskPath, "utf-8");
|
||||
} catch {
|
||||
throw new Error(`task.md not found in ${changeDir}`);
|
||||
throw new CommandError(`变更 "${changeName}" 尚未完成规划,task.md 不存在`, {
|
||||
hint: `请先完成规划阶段:rune plan ${changeName} 生成所有规划文档`,
|
||||
});
|
||||
}
|
||||
|
||||
const tasks = parseTasks(taskContent);
|
||||
@@ -81,22 +112,47 @@ export async function assembleBuildPrompt(
|
||||
for (const task of pendingTasks) {
|
||||
parts.push(`- [ ] ${task.text}`);
|
||||
}
|
||||
parts.push(
|
||||
`\n请从第一个待执行任务开始。完成后更新 ${taskPath} 中的 checkbox。`,
|
||||
);
|
||||
parts.push(`\n请从第一个待执行任务开始。完成后更新 ${taskPath} 中的 checkbox。`);
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
export function assembleArchivePrompt(
|
||||
export async function assembleArchivePrompt(
|
||||
config: RuneConfig,
|
||||
projectRoot: string,
|
||||
changeName: string,
|
||||
): 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");
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push(`# 归档阶段:${changeName}\n`);
|
||||
|
||||
try {
|
||||
const taskContent = await readFile(taskPath, "utf-8");
|
||||
const tasks = parseTasks(taskContent);
|
||||
const incompleteTasks = tasks.filter((t) => !t.checked);
|
||||
if (incompleteTasks.length > 0) {
|
||||
parts.push("## ⚠️ 警告:存在未完成的任务\n");
|
||||
parts.push(`以下 ${incompleteTasks.length} 个任务尚未完成:`);
|
||||
for (const t of incompleteTasks) {
|
||||
parts.push(`- [ ] ${t.text}`);
|
||||
}
|
||||
parts.push("");
|
||||
parts.push("请询问用户是否确认在任务未全部完成的情况下归档。");
|
||||
parts.push("如用户确认,则继续归档;否则中止并返回构建阶段。");
|
||||
parts.push("");
|
||||
}
|
||||
} catch {
|
||||
// task.md 不存在时不追加警告
|
||||
}
|
||||
|
||||
parts.push(archive.prompt);
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
@@ -3,12 +3,11 @@ import { readFile } from "node:fs/promises";
|
||||
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 type { RuneConfig } from "../types.ts";
|
||||
import { RUNE_DIR, CONFIG_FILE, CHANGES_DIR, ARCHIVE_DIR } from "../types.ts";
|
||||
|
||||
export function findProjectRoot(
|
||||
startDir: string = process.cwd(),
|
||||
): string | null {
|
||||
export function findProjectRoot(startDir: string = process.cwd()): string | null {
|
||||
let dir = startDir;
|
||||
while (true) {
|
||||
if (existsSync(join(dir, RUNE_DIR))) {
|
||||
@@ -22,12 +21,64 @@ export function findProjectRoot(
|
||||
|
||||
export async function loadConfig(projectRoot: string): Promise<RuneConfig> {
|
||||
const configPath = join(projectRoot, RUNE_DIR, CONFIG_FILE);
|
||||
let merged: RuneConfig;
|
||||
try {
|
||||
const content = await readFile(configPath, "utf-8");
|
||||
const userConfig = parseYaml(content) as Partial<RuneConfig> | null;
|
||||
return mergeConfig(userConfig ?? {});
|
||||
merged = mergeConfig(userConfig ?? {});
|
||||
} 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,14 +1,16 @@
|
||||
import { readdir, stat, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { ChangeStatus } from "../types.ts";
|
||||
import type { ChangeStatus, DocumentStatus, RuneConfig } from "../types.ts";
|
||||
import { getChangesDir, getArchiveDir } from "./config.ts";
|
||||
import { parseTasks } from "./task-parser.ts";
|
||||
|
||||
export async function scanChanges(
|
||||
projectRoot: string,
|
||||
config?: RuneConfig,
|
||||
): Promise<ChangeStatus[]> {
|
||||
const changesDir = getChangesDir(projectRoot);
|
||||
const results: ChangeStatus[] = [];
|
||||
const planDocs = config?.stages.plan?.documents;
|
||||
|
||||
try {
|
||||
const entries = await readdir(changesDir);
|
||||
@@ -17,11 +19,32 @@ export async function scanChanges(
|
||||
const entryStat = await stat(entryPath);
|
||||
if (!entryStat.isDirectory()) continue;
|
||||
|
||||
const docs = await readdir(entryPath);
|
||||
const documents = docs.filter((d) => d.endsWith(".md"));
|
||||
const files = await readdir(entryPath);
|
||||
const mdFiles = new Set(files.filter((f) => f.endsWith(".md")));
|
||||
|
||||
let documents: DocumentStatus[];
|
||||
|
||||
if (planDocs) {
|
||||
documents = planDocs.map((docConfig) => {
|
||||
const fileName = `${docConfig.name}.md`;
|
||||
const completed = mdFiles.has(fileName);
|
||||
const deps = docConfig.depend ?? [];
|
||||
const dependMet = deps.every((dep) => mdFiles.has(`${dep}.md`));
|
||||
return { name: docConfig.name, completed, dependMet };
|
||||
});
|
||||
} else {
|
||||
documents = Array.from(mdFiles).map((fileName) => ({
|
||||
name: fileName.replace(/\.md$/, ""),
|
||||
completed: true,
|
||||
dependMet: true,
|
||||
}));
|
||||
}
|
||||
|
||||
const planCompleted = planDocs ? documents.every((d) => d.completed) : false;
|
||||
const buildUnlocked = planCompleted;
|
||||
|
||||
let taskProgress: { completed: number; total: number } | null = null;
|
||||
const taskFile = docs.find((d) => d === "task.md");
|
||||
const taskFile = files.find((d) => d === "task.md");
|
||||
if (taskFile) {
|
||||
const content = await readFile(join(entryPath, taskFile), "utf-8");
|
||||
const tasks = parseTasks(content);
|
||||
@@ -31,10 +54,15 @@ export async function scanChanges(
|
||||
};
|
||||
}
|
||||
|
||||
results.push({ name: entry, documents, taskProgress });
|
||||
results.push({
|
||||
name: entry,
|
||||
documents,
|
||||
planCompleted,
|
||||
buildUnlocked,
|
||||
taskProgress,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -3,22 +3,242 @@ import type { RuneConfig } from "../types.ts";
|
||||
export const defaultConfig: RuneConfig = {
|
||||
stages: {
|
||||
discuss: {
|
||||
prompt: `你是一位经验丰富的软件架构师和技术顾问。
|
||||
用户将与你讨论一个软件开发需求。
|
||||
prompt: `进入探索模式。深度思考,自由发散。跟随对话走向。
|
||||
|
||||
你的职责是:
|
||||
1. 深入理解用户的需求背景和目标
|
||||
2. 提出关键问题澄清模糊之处
|
||||
3. 分析技术可行性和潜在风险
|
||||
4. 提供专业的架构建议
|
||||
**重要:探索模式是用于思考的,不是用于实现的。** 你可以读文件、搜索代码、调查代码库,但绝对不写代码或实现功能。如果用户要求你实现,提醒用户先退出探索模式,使用 /rune-plan <变更名> 进入规划阶段。
|
||||
|
||||
请与用户自由讨论,不要急于输出文档。讨论充分后,建议用户使用 /plan <变更名> 进入规划阶段。`,
|
||||
**这是一套立场,而不是固定工作流。** 没有固定步骤,没有强制序列,没有必须输出。你是帮助用户探索的思考伙伴。
|
||||
|
||||
---
|
||||
|
||||
## 立场
|
||||
|
||||
- **好奇而非指令式** — 提出自然涌现的问题,不按脚本走
|
||||
- **开放线索而非审问** — 同时呈现多个有趣的方向,让用户跟随共鸣的方向走。不要把用户推进单一的问题路径
|
||||
- **可视化** — 感到有助于理清思路时,大量使用 ASCII 示意图
|
||||
- **自适应** — 跟随有趣的线索,新信息出现时及时转向
|
||||
- **耐心** — 不急于下结论,让问题的形状自然浮现
|
||||
- **扎根代码** — 在相关时探索真实代码库,不要凭空理论
|
||||
|
||||
---
|
||||
|
||||
## 你可能做的事
|
||||
|
||||
取决于用户带来什么,你可能:
|
||||
|
||||
**探索问题空间**
|
||||
- 提出从用户话语中自然涌现的澄清问题
|
||||
- 挑战假设
|
||||
- 重新框定问题
|
||||
- 寻找类比
|
||||
|
||||
**调查代码库**
|
||||
- 绘制与讨论相关的现有架构
|
||||
- 找到集成点
|
||||
- 识别已使用的模式
|
||||
- 揭示隐藏的复杂度
|
||||
|
||||
**对比选项**
|
||||
- 头脑风暴多种方案
|
||||
- 构建对比表格
|
||||
- 勾勒权衡
|
||||
- 推荐路径(如果被问到)
|
||||
|
||||
**可视化**
|
||||
\`\`\`
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 大量使用 ASCII 示意图 │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────┐ ┌────────┐ │
|
||||
│ │ 状态 A │────────▶│ 状态 B │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ 系统图、状态机、数据流、 │
|
||||
│ 架构草图、依赖图、对比表格 │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
\`\`\`
|
||||
|
||||
**揭示风险和未知**
|
||||
- 识别可能出错的地方
|
||||
- 找到理解上的空白
|
||||
- 建议探索性研究或调查
|
||||
|
||||
---
|
||||
|
||||
## Rune 感知
|
||||
|
||||
你可以使用 Rune 系统。自然地使用它,不要强行。
|
||||
|
||||
### 获取上下文
|
||||
|
||||
如果需要了解项目当前有哪些变更,可以自行执行:
|
||||
\`\`\`bash
|
||||
rune status
|
||||
\`\`\`
|
||||
|
||||
这会告诉你:
|
||||
- 是否有活跃的变更
|
||||
- 变更的名称和状态
|
||||
- 用户可能在做什么
|
||||
|
||||
如果存在相关变更,你可以读取变更目录下的 design.md / task.md 作为讨论参考。
|
||||
|
||||
### 讨论结果的处理
|
||||
|
||||
讨论中获得的洞察和结论**保留在会话上下文中**。不需要写入文件。
|
||||
|
||||
当讨论充分后,自然引导用户进入下一阶段:
|
||||
- "这个想法已经足够清晰了,要开始规划吗?可以用 /rune-plan <变更名> <文档名> 进入规划阶段。"
|
||||
|
||||
---
|
||||
|
||||
## 你不必做的事
|
||||
|
||||
- 遵循固定脚本
|
||||
- 每次都问相同的问题
|
||||
- 产出特定文档
|
||||
- 必须达成结论
|
||||
- 在切线有价值时非得回到正题
|
||||
- 保持简洁(这是思考时间)
|
||||
|
||||
---
|
||||
|
||||
## 结束探索
|
||||
|
||||
没有强制的结束方式。探索可能:
|
||||
|
||||
- **流入规划阶段**:讨论充分后建议使用 /rune-plan <变更名> <文档名>
|
||||
- **获得清晰度**:用户得到所需,自行推进
|
||||
- **稍后继续**:"我们随时可以接着聊"
|
||||
|
||||
当感觉思路开始结晶时,可以给出总结:
|
||||
|
||||
\`\`\`
|
||||
## 我们梳理清楚的内容
|
||||
|
||||
**问题**:[已结晶的理解]
|
||||
|
||||
**方案**:[如果有了方向]
|
||||
|
||||
**待澄清**:[如果还有]
|
||||
|
||||
**下一步**(准备好时):
|
||||
- 使用 /rune-plan <变更名> <文档名> 进入规划阶段
|
||||
\`\`\`
|
||||
|
||||
但这个总结是可选的。有时候思考本身就是价值。
|
||||
|
||||
---
|
||||
|
||||
## 护栏
|
||||
|
||||
- **不实现** — 绝不写代码或实现功能
|
||||
- **不假装理解** — 如果有不清楚的地方,深挖
|
||||
- **不催促** — 探索是思考时间,不是任务时间
|
||||
- **不强加结构** — 让模式自然浮现
|
||||
- **不自动写文件** — 讨论结果保留在会话中,除非用户明确要求写入文件
|
||||
- **要可视化** — 一张好图胜过千言万语
|
||||
- **要探索代码库** — 让讨论扎根于现实
|
||||
- **要质疑假设** — 包括用户的和自己的
|
||||
|
||||
---
|
||||
|
||||
## 典型场景
|
||||
|
||||
**用户带来模糊的想法:**
|
||||
\`\`\`
|
||||
用户:我在想加个实时协作功能
|
||||
|
||||
你:实时协作是个大课题。让我想想……
|
||||
|
||||
协作能力光谱
|
||||
════════════════════════════════════════════
|
||||
|
||||
感知 协调 同步
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌────────┐ ┌────────┐ ┌────────┐
|
||||
│ 在线状态│ │ 多人光标│ │ CRDT │
|
||||
│ "3人在 │ │ 选择 │ │ 无冲突 │
|
||||
│ 线" │ │ │ │ │
|
||||
└────────┘ └────────┘ └────────┘
|
||||
│ │ │
|
||||
简单 中等 复杂
|
||||
|
||||
你心里想的是哪个方向?
|
||||
\`\`\`
|
||||
|
||||
**用户带来具体问题:**
|
||||
\`\`\`
|
||||
用户:认证系统太乱了
|
||||
|
||||
你:[探索代码库]
|
||||
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 当前认证流程 │
|
||||
└─────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────┼─────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ Google │ │ GitHub │ │ 邮箱 │
|
||||
│ OAuth │ │ OAuth │ │ Magic │
|
||||
└────┬────┘ └────┬────┘ └────┬────┘
|
||||
│ │ │
|
||||
└─────────────┼─────────────┘
|
||||
▼
|
||||
┌───────────┐
|
||||
│ Session │
|
||||
└─────┬─────┘
|
||||
│
|
||||
▼
|
||||
┌───────────┐
|
||||
│ 权限 │
|
||||
└───────────┘
|
||||
|
||||
我看到三个缠结。哪个让你头疼?
|
||||
\`\`\`
|
||||
|
||||
**用户对比选项:**
|
||||
\`\`\`
|
||||
用户:该用 MySQL 还是 PostgreSQL?
|
||||
|
||||
你:泛泛的回答很无聊。具体上下文是什么?
|
||||
|
||||
用户:一个 CLI 工具,追踪本地开发环境
|
||||
|
||||
你:这改变一切。
|
||||
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ CLI 工具数据存储 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
关键约束:
|
||||
• 无守护进程
|
||||
• 必须离线工作
|
||||
• 单用户
|
||||
|
||||
MySQL PostgreSQL
|
||||
部署 独立服务 ✗ 独立服务 ✗
|
||||
离线 否 ✗ 否 ✗
|
||||
单文件 否 ✗ 否 ✗
|
||||
|
||||
两个都不合适。SQLite 才是正确答案。
|
||||
|
||||
除非……有同步组件?
|
||||
\`\`\``,
|
||||
},
|
||||
plan: {
|
||||
documents: [
|
||||
{
|
||||
name: "design",
|
||||
prompt: `请根据之前的讨论内容,生成一份设计文档。
|
||||
prompt: `请先获取当前规划状态。
|
||||
|
||||
建议执行: rune status <变更名>
|
||||
|
||||
请根据之前的讨论内容和状态输出,生成一份设计文档。
|
||||
|
||||
要求:
|
||||
- 清晰描述背景和目标
|
||||
@@ -42,7 +262,12 @@ export const defaultConfig: RuneConfig = {
|
||||
},
|
||||
{
|
||||
name: "task",
|
||||
prompt: `请根据设计文档,生成一份任务列表。
|
||||
depend: ["design"],
|
||||
prompt: `请先获取当前规划状态。
|
||||
|
||||
建议执行: rune status <变更名>
|
||||
|
||||
请根据设计文档,生成一份任务列表。
|
||||
|
||||
要求:
|
||||
- 将设计拆分为可独立执行的小任务
|
||||
|
||||
11
src/types.ts
11
src/types.ts
@@ -2,6 +2,13 @@ export interface DocumentConfig {
|
||||
name: string;
|
||||
prompt: string;
|
||||
template?: string;
|
||||
depend?: string[];
|
||||
}
|
||||
|
||||
export interface DocumentStatus {
|
||||
name: string;
|
||||
completed: boolean;
|
||||
dependMet: boolean;
|
||||
}
|
||||
|
||||
export interface DiscussStage {
|
||||
@@ -38,7 +45,9 @@ export interface TaskItem {
|
||||
|
||||
export interface ChangeStatus {
|
||||
name: string;
|
||||
documents: string[];
|
||||
documents: DocumentStatus[];
|
||||
planCompleted: boolean;
|
||||
buildUnlocked: boolean;
|
||||
taskProgress: { completed: number; total: number } | null;
|
||||
}
|
||||
|
||||
|
||||
120
tests/adapters/claude-code.test.ts
Normal file
120
tests/adapters/claude-code.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, rm, readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { injectClaudeCode, updateClaudeCode } from "../../src/adapters/claude-code.ts";
|
||||
|
||||
const TMP_DIR = join(import.meta.dir, "__tmp_claude_code_test__");
|
||||
|
||||
beforeEach(async () => {
|
||||
await mkdir(TMP_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(TMP_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("injectClaudeCode", () => {
|
||||
it("生成 discuss、plan、build、archive 的 command 文件", async () => {
|
||||
await injectClaudeCode(TMP_DIR);
|
||||
|
||||
const commands = await readdir(join(TMP_DIR, ".claude", "commands"));
|
||||
for (const stage of ["discuss", "plan", "build", "archive"]) {
|
||||
expect(commands).toContain(`rune-${stage}.md`);
|
||||
}
|
||||
});
|
||||
|
||||
it("生成 rune-status command 文件", async () => {
|
||||
await injectClaudeCode(TMP_DIR);
|
||||
|
||||
const commands = await readdir(join(TMP_DIR, ".claude", "commands"));
|
||||
expect(commands).toContain("rune-status.md");
|
||||
});
|
||||
|
||||
it("command 文件包含 bash 命令", async () => {
|
||||
await injectClaudeCode(TMP_DIR);
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toContain("rune discuss");
|
||||
expect(content).toContain("```bash");
|
||||
});
|
||||
|
||||
it("plan/build/archive command 包含变更名称提示", async () => {
|
||||
await injectClaudeCode(TMP_DIR);
|
||||
|
||||
for (const stage of ["plan", "build", "archive"]) {
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".claude", "commands", `rune-${stage}.md`),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toContain("变更名");
|
||||
expect(content).toContain("如果用户没有指定变更名称");
|
||||
}
|
||||
});
|
||||
|
||||
it("discuss command 不包含变更名称提示", async () => {
|
||||
await injectClaudeCode(TMP_DIR);
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).not.toContain("如果用户没有指定变更名称");
|
||||
});
|
||||
|
||||
it("重复注入时不覆盖已存在的文件", async () => {
|
||||
await injectClaudeCode(TMP_DIR);
|
||||
const originalContent = await readFile(
|
||||
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await injectClaudeCode(TMP_DIR);
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toBe(originalContent);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateClaudeCode", () => {
|
||||
it("文件不存在时创建", async () => {
|
||||
await updateClaudeCode(TMP_DIR);
|
||||
expect(existsSync(join(TMP_DIR, ".claude", "commands", "rune-discuss.md"))).toBe(true);
|
||||
});
|
||||
|
||||
it("文件存在且内容一致时不覆盖", async () => {
|
||||
await injectClaudeCode(TMP_DIR);
|
||||
const originalContent = await readFile(
|
||||
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await updateClaudeCode(TMP_DIR);
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toBe(originalContent);
|
||||
});
|
||||
|
||||
it("文件存在但内容不一致时覆盖", async () => {
|
||||
await injectClaudeCode(TMP_DIR);
|
||||
await writeFile(join(TMP_DIR, ".claude", "commands", "rune-discuss.md"), "旧内容");
|
||||
|
||||
await updateClaudeCode(TMP_DIR);
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).not.toBe("旧内容");
|
||||
expect(content).toContain("rune discuss");
|
||||
});
|
||||
|
||||
it("更新 status 命令", async () => {
|
||||
await updateClaudeCode(TMP_DIR);
|
||||
expect(existsSync(join(TMP_DIR, ".claude", "commands", "rune-status.md"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, rm, readFile, readdir } from "node:fs/promises";
|
||||
import { mkdir, rm, readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { injectOpenCode } from "../../src/adapters/opencode.ts";
|
||||
import { injectOpenCode, updateOpenCode } from "../../src/adapters/opencode.ts";
|
||||
|
||||
const TMP_DIR = join(import.meta.dir, "__tmp_opencode_test__");
|
||||
|
||||
@@ -24,9 +24,9 @@ describe("injectOpenCode", () => {
|
||||
for (const stage of ["discuss", "plan", "build", "archive"]) {
|
||||
expect(commands).toContain(`rune-${stage}.md`);
|
||||
expect(skills).toContain(`rune-${stage}`);
|
||||
expect(
|
||||
existsSync(join(TMP_DIR, ".opencode", "skills", `rune-${stage}`, "SKILL.md")),
|
||||
).toBe(true);
|
||||
expect(existsSync(join(TMP_DIR, ".opencode", "skills", `rune-${stage}`, "SKILL.md"))).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -38,9 +38,7 @@ describe("injectOpenCode", () => {
|
||||
|
||||
expect(commands).toContain("rune-status.md");
|
||||
expect(skills).toContain("rune-status");
|
||||
expect(
|
||||
existsSync(join(TMP_DIR, ".opencode", "skills", "rune-status", "SKILL.md")),
|
||||
).toBe(true);
|
||||
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-status", "SKILL.md"))).toBe(true);
|
||||
});
|
||||
|
||||
it("command 文件包含 skill 调用指令", async () => {
|
||||
@@ -75,6 +73,24 @@ describe("injectOpenCode", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("plan skill 包含运行 status 的引导", async () => {
|
||||
await injectOpenCode(TMP_DIR);
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".opencode", "skills", "rune-plan", "SKILL.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toContain("rune status");
|
||||
});
|
||||
|
||||
it("discuss skill 不包含 status 引导", async () => {
|
||||
await injectOpenCode(TMP_DIR);
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".opencode", "skills", "rune-discuss", "SKILL.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).not.toContain("rune status");
|
||||
});
|
||||
|
||||
it("重复注入时不覆盖已存在的文件", async () => {
|
||||
await injectOpenCode(TMP_DIR);
|
||||
const originalContent = await readFile(
|
||||
@@ -90,3 +106,45 @@ describe("injectOpenCode", () => {
|
||||
expect(content).toBe(originalContent);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateOpenCode", () => {
|
||||
it("文件不存在时创建", async () => {
|
||||
await updateOpenCode(TMP_DIR);
|
||||
expect(existsSync(join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"))).toBe(true);
|
||||
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-discuss", "SKILL.md"))).toBe(true);
|
||||
});
|
||||
|
||||
it("文件存在且内容一致时不覆盖", async () => {
|
||||
await injectOpenCode(TMP_DIR);
|
||||
const originalContent = await readFile(
|
||||
join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await updateOpenCode(TMP_DIR);
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toBe(originalContent);
|
||||
});
|
||||
|
||||
it("文件存在但内容不一致时覆盖", async () => {
|
||||
await injectOpenCode(TMP_DIR);
|
||||
await writeFile(join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"), "旧内容");
|
||||
|
||||
await updateOpenCode(TMP_DIR);
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).not.toBe("旧内容");
|
||||
expect(content).toContain("rune-discuss");
|
||||
});
|
||||
|
||||
it("更新 status 命令和 skill", async () => {
|
||||
await updateOpenCode(TMP_DIR);
|
||||
expect(existsSync(join(TMP_DIR, ".opencode", "commands", "rune-status.md"))).toBe(true);
|
||||
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-status", "SKILL.md"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
268
tests/agent/agent-llm.ts
Normal file
268
tests/agent/agent-llm.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import type { RuneConfig } from "../../src/types.ts";
|
||||
import type { AgentRunner, AgentResult } from "./runner.ts";
|
||||
import {
|
||||
assemblePlanPrompt,
|
||||
assembleBuildPrompt,
|
||||
assembleArchivePrompt,
|
||||
} from "../../src/core/assembler.ts";
|
||||
|
||||
export interface LLMAction {
|
||||
type: "write_file" | "check_task" | "done";
|
||||
path?: string;
|
||||
content?: string;
|
||||
taskIndex?: number;
|
||||
}
|
||||
|
||||
export interface LLMPlan {
|
||||
actions: LLMAction[];
|
||||
}
|
||||
|
||||
export function isLLMAvailable(): boolean {
|
||||
return !!process.env.RUNE_E2E_LLM_API_KEY;
|
||||
}
|
||||
|
||||
export function getLLMEnv() {
|
||||
return {
|
||||
provider: process.env.RUNE_E2E_LLM_PROVIDER || "openai",
|
||||
model: process.env.RUNE_E2E_LLM_MODEL || "gpt-4o-mini",
|
||||
apiKey: process.env.RUNE_E2E_LLM_API_KEY || "",
|
||||
baseUrl: process.env.RUNE_E2E_LLM_BASE_URL || "https://api.openai.com/v1",
|
||||
};
|
||||
}
|
||||
|
||||
async function callLLM(prompt: string): Promise<LLMPlan> {
|
||||
const { provider, model, apiKey, baseUrl } = getLLMEnv();
|
||||
|
||||
if (provider === "openai" || provider === "openrouter") {
|
||||
const response = await fetch(`${baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `你是一个自动化构建工具,负责根据提示词生成精确的文件操作计划。
|
||||
|
||||
请严格按以下 JSON 格式输出行动计划(不要包含其他内容):
|
||||
{
|
||||
"actions": [
|
||||
{ "type": "write_file", "path": "相对路径", "content": "文件内容" },
|
||||
{ "type": "check_task", "taskIndex": 0 }
|
||||
]
|
||||
}
|
||||
|
||||
可用的 action 类型:
|
||||
- write_file: 写入文件,path 和 content 必填
|
||||
- check_task: 标记任务为已完成,taskIndex 是任务列表中从 0 开始的索引
|
||||
- done: 表示所有操作已完成
|
||||
|
||||
根据提示词要求,生成完整的操作计划。不要跳过任何步骤。`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
temperature: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`LLM API error ${response.status}: ${body}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const text = data.choices?.[0]?.message?.content || "";
|
||||
return parseLLMResponse(text);
|
||||
}
|
||||
|
||||
if (provider === "anthropic") {
|
||||
const response = await fetch(`https://api.anthropic.com/v1/messages`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: 4096,
|
||||
system: `你是一个自动化构建工具,负责根据提示词生成精确的文件操作计划。
|
||||
|
||||
请严格按以下 JSON 格式输出行动计划(不要包含其他内容):
|
||||
{
|
||||
"actions": [
|
||||
{ "type": "write_file", "path": "相对路径", "content": "文件内容" },
|
||||
{ "type": "check_task", "taskIndex": 0 }
|
||||
]
|
||||
}
|
||||
|
||||
可用的 action 类型:
|
||||
- write_file: 写入文件,path 和 content 必填
|
||||
- check_task: 标记任务为已完成,taskIndex 是任务列表中从 0 开始的索引
|
||||
- done: 表示所有操作已完成
|
||||
|
||||
根据提示词要求,生成完整的操作计划。不要跳过任何步骤。`,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
temperature: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`LLM API error ${response.status}: ${body}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const text = data.content?.[0]?.text || "";
|
||||
return parseLLMResponse(text);
|
||||
}
|
||||
|
||||
throw new Error(`不支持的 LLM provider: ${provider}`);
|
||||
}
|
||||
|
||||
function parseLLMResponse(text: string): LLMPlan {
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) {
|
||||
throw new Error(`LLM 输出中未找到 JSON: ${text.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const plan: LLMPlan = JSON.parse(jsonMatch[0]);
|
||||
if (!plan.actions || !Array.isArray(plan.actions)) {
|
||||
throw new Error("LLM 输出缺少 actions 数组");
|
||||
}
|
||||
return plan;
|
||||
} catch (e) {
|
||||
throw new Error(`LLM 输出 JSON 解析失败: ${text.slice(0, 200)}`, { cause: e });
|
||||
}
|
||||
}
|
||||
|
||||
import { mkdir, writeFile, readFile, rename } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { getChangeDir, getArchiveDir } from "../../src/core/config.ts";
|
||||
import { parseTasks } from "../../src/core/task-parser.ts";
|
||||
|
||||
async function executeActions(projectDir: string, plan: LLMPlan): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
|
||||
for (const action of plan.actions) {
|
||||
switch (action.type) {
|
||||
case "write_file": {
|
||||
if (!action.path || action.content === undefined) {
|
||||
throw new Error("write_file action 缺少 path 或 content");
|
||||
}
|
||||
const fullPath = join(projectDir, action.path);
|
||||
await mkdir(join(fullPath, ".."), { recursive: true });
|
||||
await writeFile(fullPath, action.content);
|
||||
files.push(action.path);
|
||||
break;
|
||||
}
|
||||
case "check_task": {
|
||||
if (action.taskIndex === undefined) {
|
||||
throw new Error("check_task action 缺少 taskIndex");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "done":
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function extractTaskCheckActions(plan: LLMPlan): number[] {
|
||||
return plan.actions
|
||||
.filter((a) => a.type === "check_task" && a.taskIndex !== undefined)
|
||||
.map((a) => a.taskIndex!);
|
||||
}
|
||||
|
||||
export class LLMJudgeRunner implements AgentRunner {
|
||||
readonly tier = 3;
|
||||
|
||||
async runPlan(
|
||||
projectDir: string,
|
||||
changeName: string,
|
||||
docName: string,
|
||||
config: RuneConfig,
|
||||
): Promise<AgentResult> {
|
||||
if (!isLLMAvailable()) {
|
||||
throw new Error("RUNE_E2E_LLM_PROVIDER 和 RUNE_E2E_LLM_API_KEY 未设置");
|
||||
}
|
||||
|
||||
const prompt = await assemblePlanPrompt(config, projectDir, changeName, docName);
|
||||
const plan = await callLLM(prompt);
|
||||
|
||||
return {
|
||||
files: [],
|
||||
rawPlan: plan,
|
||||
};
|
||||
}
|
||||
|
||||
async runBuild(projectDir: string, changeName: string, config: RuneConfig): Promise<AgentResult> {
|
||||
if (!isLLMAvailable()) {
|
||||
throw new Error("RUNE_E2E_LLM_PROVIDER 和 RUNE_E2E_LLM_API_KEY 未设置");
|
||||
}
|
||||
|
||||
const changeDir = getChangeDir(projectDir, changeName);
|
||||
const prompt = await assembleBuildPrompt(config, projectDir, changeName);
|
||||
const plan = await callLLM(prompt);
|
||||
|
||||
const files = await executeActions(projectDir, plan);
|
||||
|
||||
const taskIndices = extractTaskCheckActions(plan);
|
||||
if (taskIndices.length > 0) {
|
||||
const taskPath = join(changeDir, "task.md");
|
||||
let taskContent = await readFile(taskPath, "utf-8");
|
||||
const tasks = parseTasks(taskContent);
|
||||
|
||||
for (const index of taskIndices) {
|
||||
if (index < tasks.length) {
|
||||
const task = tasks[index];
|
||||
const oldLine = `- [ ] ${task.text}`;
|
||||
const newLine = `- [x] ${task.text}`;
|
||||
taskContent = taskContent.replace(oldLine, newLine);
|
||||
}
|
||||
}
|
||||
|
||||
await writeFile(taskPath, taskContent);
|
||||
files.push("task.md");
|
||||
}
|
||||
|
||||
return {
|
||||
files: [...new Set(files)],
|
||||
rawPlan: plan,
|
||||
};
|
||||
}
|
||||
|
||||
async runArchive(
|
||||
projectDir: string,
|
||||
changeName: string,
|
||||
config: RuneConfig,
|
||||
): Promise<AgentResult> {
|
||||
if (!isLLMAvailable()) {
|
||||
throw new Error("RUNE_E2E_LLM_PROVIDER 和 RUNE_E2E_LLM_API_KEY 未设置");
|
||||
}
|
||||
|
||||
const prompt = await assembleArchivePrompt(config, projectDir, changeName);
|
||||
const changeDir = getChangeDir(projectDir, changeName);
|
||||
const plan = await callLLM(prompt);
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const archiveDir = getArchiveDir(projectDir);
|
||||
await mkdir(archiveDir, { recursive: true });
|
||||
const dest = join(archiveDir, `${today}-${changeName}`);
|
||||
await rename(changeDir, dest);
|
||||
|
||||
return {
|
||||
files: [],
|
||||
rawPlan: plan,
|
||||
};
|
||||
}
|
||||
}
|
||||
119
tests/agent/agent-mock.ts
Normal file
119
tests/agent/agent-mock.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { mkdir, writeFile, readFile, rename } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { RuneConfig, DocumentConfig } from "../../src/types.ts";
|
||||
import type { AgentRunner, AgentResult } from "./runner.ts";
|
||||
import { getChangeDir, getArchiveDir } from "../../src/core/config.ts";
|
||||
import { parseTasks } from "../../src/core/task-parser.ts";
|
||||
|
||||
export class CommandLevelRunner implements AgentRunner {
|
||||
readonly tier = 1;
|
||||
|
||||
async runPlan(
|
||||
projectDir: string,
|
||||
changeName: string,
|
||||
docName: string,
|
||||
config: RuneConfig,
|
||||
): Promise<AgentResult> {
|
||||
const changeDir = getChangeDir(projectDir, changeName);
|
||||
await mkdir(changeDir, { recursive: true });
|
||||
|
||||
const planStage = config.stages.plan;
|
||||
if (!planStage) {
|
||||
throw new Error("plan 阶段未配置");
|
||||
}
|
||||
|
||||
const docConfig = planStage.documents.find((d) => d.name === docName);
|
||||
if (!docConfig) {
|
||||
throw new Error(`文档 "${docName}" 未在 plan.documents 中配置`);
|
||||
}
|
||||
|
||||
const content = this.renderDocument(docConfig, changeName);
|
||||
const filePath = join(changeDir, `${docName}.md`);
|
||||
await writeFile(filePath, content);
|
||||
|
||||
return { files: [`${docName}.md`] };
|
||||
}
|
||||
|
||||
async runBuild(
|
||||
projectDir: string,
|
||||
changeName: string,
|
||||
_config: RuneConfig,
|
||||
): Promise<AgentResult> {
|
||||
const changeDir = getChangeDir(projectDir, changeName);
|
||||
const taskPath = join(changeDir, "task.md");
|
||||
|
||||
let taskContent: string;
|
||||
try {
|
||||
taskContent = await readFile(taskPath, "utf-8");
|
||||
} catch {
|
||||
throw new Error(`变更 "${changeName}" 的 task.md 不存在,请先完成规划`);
|
||||
}
|
||||
|
||||
const tasks = parseTasks(taskContent);
|
||||
const pending = tasks.filter((t) => !t.checked);
|
||||
|
||||
if (pending.length === 0) {
|
||||
return { files: [] };
|
||||
}
|
||||
|
||||
const files: string[] = [];
|
||||
for (const task of pending) {
|
||||
const oldLine = `- [ ] ${task.text}`;
|
||||
const newLine = `- [x] ${task.text}`;
|
||||
taskContent = taskContent.replace(oldLine, newLine);
|
||||
const implFile = `${task.text
|
||||
.replace(/[^a-zA-Z\u4e00-\u9fa5]+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.toLowerCase()}.ts`;
|
||||
await writeFile(join(changeDir, implFile), `// ${task.text}\n`);
|
||||
files.push(implFile);
|
||||
}
|
||||
|
||||
await writeFile(taskPath, taskContent);
|
||||
files.push("task.md");
|
||||
|
||||
return { files };
|
||||
}
|
||||
|
||||
async runArchive(
|
||||
projectDir: string,
|
||||
changeName: string,
|
||||
_config: RuneConfig,
|
||||
): Promise<AgentResult> {
|
||||
const changeDir = getChangeDir(projectDir, changeName);
|
||||
const taskPath = join(changeDir, "task.md");
|
||||
|
||||
try {
|
||||
const taskContent = await readFile(taskPath, "utf-8");
|
||||
const tasks = parseTasks(taskContent);
|
||||
const pending = tasks.filter((t) => !t.checked);
|
||||
|
||||
if (pending.length > 0) {
|
||||
throw new Error(`变更 "${changeName}" 存在 ${pending.length} 个未完成任务,无法归档`);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes("未完成任务")) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const archiveDir = getArchiveDir(projectDir);
|
||||
await mkdir(archiveDir, { recursive: true });
|
||||
const dest = join(archiveDir, `${today}-${changeName}`);
|
||||
await rename(changeDir, dest);
|
||||
|
||||
return { files: [] };
|
||||
}
|
||||
|
||||
private renderDocument(doc: DocumentConfig, changeName: string): string {
|
||||
if (doc.template) {
|
||||
return doc.template.replace(/\{\{change-name\}\}/g, changeName) + "\n";
|
||||
}
|
||||
return `# ${doc.name}\n\n${doc.prompt}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
export function createRunner(): AgentRunner {
|
||||
return new CommandLevelRunner();
|
||||
}
|
||||
69
tests/agent/agent-scenario.ts
Normal file
69
tests/agent/agent-scenario.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { RuneConfig } from "../../src/types.ts";
|
||||
import type { AgentRunner, AgentResult } from "./runner.ts";
|
||||
import { CommandLevelRunner } from "./agent-mock.ts";
|
||||
|
||||
export type PlanOverride = (
|
||||
projectDir: string,
|
||||
changeName: string,
|
||||
docName: string,
|
||||
config: RuneConfig,
|
||||
) => Promise<AgentResult>;
|
||||
|
||||
export type BuildOverride = (
|
||||
projectDir: string,
|
||||
changeName: string,
|
||||
config: RuneConfig,
|
||||
) => Promise<AgentResult>;
|
||||
|
||||
export type ArchiveOverride = (
|
||||
projectDir: string,
|
||||
changeName: string,
|
||||
config: RuneConfig,
|
||||
) => Promise<AgentResult>;
|
||||
|
||||
export interface ScenarioOverrides {
|
||||
plan?: PlanOverride;
|
||||
build?: BuildOverride;
|
||||
archive?: ArchiveOverride;
|
||||
}
|
||||
|
||||
export class ScenarioRunner implements AgentRunner {
|
||||
readonly tier = 2;
|
||||
private defaults: CommandLevelRunner;
|
||||
private overrides: ScenarioOverrides;
|
||||
|
||||
constructor(defaults: CommandLevelRunner, overrides: ScenarioOverrides = {}) {
|
||||
this.defaults = defaults;
|
||||
this.overrides = overrides;
|
||||
}
|
||||
|
||||
async runPlan(
|
||||
projectDir: string,
|
||||
changeName: string,
|
||||
docName: string,
|
||||
config: RuneConfig,
|
||||
): Promise<AgentResult> {
|
||||
if (this.overrides.plan) {
|
||||
return this.overrides.plan(projectDir, changeName, docName, config);
|
||||
}
|
||||
return this.defaults.runPlan(projectDir, changeName, docName, config);
|
||||
}
|
||||
|
||||
async runBuild(projectDir: string, changeName: string, config: RuneConfig): Promise<AgentResult> {
|
||||
if (this.overrides.build) {
|
||||
return this.overrides.build(projectDir, changeName, config);
|
||||
}
|
||||
return this.defaults.runBuild(projectDir, changeName, config);
|
||||
}
|
||||
|
||||
async runArchive(
|
||||
projectDir: string,
|
||||
changeName: string,
|
||||
config: RuneConfig,
|
||||
): Promise<AgentResult> {
|
||||
if (this.overrides.archive) {
|
||||
return this.overrides.archive(projectDir, changeName, config);
|
||||
}
|
||||
return this.defaults.runArchive(projectDir, changeName, config);
|
||||
}
|
||||
}
|
||||
65
tests/agent/e2e-archive.test.ts
Normal file
65
tests/agent/e2e-archive.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createRunner } from "./agent-mock.ts";
|
||||
import {
|
||||
setupTempDir,
|
||||
cleanupTempDir,
|
||||
getTempDir,
|
||||
createFreshProject,
|
||||
writeDoc,
|
||||
changeFileExists,
|
||||
} from "./fixtures.ts";
|
||||
import { scanChanges, scanArchives } from "../../src/core/scanner.ts";
|
||||
import { getChangeDir } from "../../src/core/config.ts";
|
||||
|
||||
describe("e2e: archive 阶段", () => {
|
||||
let runner: ReturnType<typeof createRunner>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupTempDir();
|
||||
runner = createRunner();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTempDir();
|
||||
});
|
||||
|
||||
it("全部任务完成时变更移至 archive/", async () => {
|
||||
const config = await createFreshProject();
|
||||
await writeDoc("ready", "task", "- [x] 任务\n");
|
||||
|
||||
await runner.runArchive(getTempDir(), "ready", config);
|
||||
|
||||
expect(changeFileExists("ready", "task.md")).toBe(false);
|
||||
|
||||
const archives = await scanArchives(getTempDir());
|
||||
expect(archives.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const changes = await scanChanges(getTempDir(), config);
|
||||
expect(changes.find((c) => c.name === "ready")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("任务未完成时阻止归档", async () => {
|
||||
const config = await createFreshProject();
|
||||
await writeDoc("pending", "task", "- [ ] 未完成\n");
|
||||
|
||||
await expect(runner.runArchive(getTempDir(), "pending", config)).rejects.toThrow("未完成任务");
|
||||
|
||||
expect(changeFileExists("pending", "task.md")).toBe(true);
|
||||
});
|
||||
|
||||
it("无 task.md 时允许归档", async () => {
|
||||
const config = await createFreshProject();
|
||||
const changeDir = getChangeDir(getTempDir(), "no-task");
|
||||
await mkdir(changeDir, { recursive: true });
|
||||
await writeFile(join(changeDir, "design.md"), "# 设计\n");
|
||||
|
||||
await runner.runArchive(getTempDir(), "no-task", config);
|
||||
|
||||
expect(changeFileExists("no-task", "design.md")).toBe(false);
|
||||
|
||||
const archives = await scanArchives(getTempDir());
|
||||
expect(archives.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
96
tests/agent/e2e-build.test.ts
Normal file
96
tests/agent/e2e-build.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createRunner } from "./agent-mock.ts";
|
||||
import {
|
||||
setupTempDir,
|
||||
cleanupTempDir,
|
||||
getTempDir,
|
||||
createFreshProject,
|
||||
writeDoc,
|
||||
} from "./fixtures.ts";
|
||||
import { scanChanges } from "../../src/core/scanner.ts";
|
||||
import { getChangeDir } from "../../src/core/config.ts";
|
||||
|
||||
describe("e2e: build 阶段", () => {
|
||||
let runner: ReturnType<typeof createRunner>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupTempDir();
|
||||
runner = createRunner();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTempDir();
|
||||
});
|
||||
|
||||
it("单任务执行,勾选并产出文件", async () => {
|
||||
const config = await createFreshProject();
|
||||
await writeDoc("auth", "task", "- [ ] 实现登录 API\n");
|
||||
|
||||
const result = await runner.runBuild(getTempDir(), "auth", config);
|
||||
|
||||
expect(result.files).toContain("task.md");
|
||||
expect(result.files.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const tasks = await scanChanges(getTempDir(), config);
|
||||
expect(tasks[0].taskProgress).toEqual({ completed: 1, total: 1 });
|
||||
});
|
||||
|
||||
it("多任务按顺序逐个勾选", async () => {
|
||||
const config = await createFreshProject();
|
||||
await writeDoc("multi-task", "task", `- [ ] 任务A\n- [ ] 任务B\n- [ ] 任务C\n`);
|
||||
|
||||
const result = await runner.runBuild(getTempDir(), "multi-task", config);
|
||||
|
||||
expect(result.files).toHaveLength(4);
|
||||
expect(result.files).toContain("task.md");
|
||||
|
||||
const taskContent = readFileSync(
|
||||
join(getChangeDir(getTempDir(), "multi-task"), "task.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(taskContent).toContain("- [x] 任务A");
|
||||
expect(taskContent).toContain("- [x] 任务B");
|
||||
expect(taskContent).toContain("- [x] 任务C");
|
||||
|
||||
const tasks = await scanChanges(getTempDir(), config);
|
||||
expect(tasks[0].taskProgress).toEqual({ completed: 3, total: 3 });
|
||||
});
|
||||
|
||||
it("空 task 清单时 taskProgress 提示无任务", async () => {
|
||||
const config = await createFreshProject();
|
||||
await writeDoc("empty", "task", "\n");
|
||||
|
||||
const changes = await scanChanges(getTempDir(), config);
|
||||
const emptyChange = changes.find((c) => c.name === "empty");
|
||||
expect(emptyChange).toBeDefined();
|
||||
if (emptyChange) {
|
||||
expect(emptyChange.taskProgress).toEqual({ completed: 0, total: 0 });
|
||||
}
|
||||
});
|
||||
|
||||
it("plan 未完成时 build 不可用", async () => {
|
||||
const config = await createFreshProject();
|
||||
|
||||
await expect(runner.runBuild(getTempDir(), "no-task", config)).rejects.toThrow(
|
||||
"task.md 不存在",
|
||||
);
|
||||
});
|
||||
|
||||
it("任务全部完成后状态为已完成", async () => {
|
||||
const config = await createFreshProject();
|
||||
await writeDoc("done", "task", "- [x] 已完成任务\n");
|
||||
|
||||
const result = await runner.runBuild(getTempDir(), "done", config);
|
||||
|
||||
expect(result.files).toEqual([]);
|
||||
|
||||
const tasks = await scanChanges(getTempDir(), config);
|
||||
const doneChange = tasks.find((c) => c.name === "done");
|
||||
expect(doneChange).toBeDefined();
|
||||
if (doneChange) {
|
||||
expect(doneChange.taskProgress).toEqual({ completed: 1, total: 1 });
|
||||
}
|
||||
});
|
||||
});
|
||||
178
tests/agent/e2e-depend.test.ts
Normal file
178
tests/agent/e2e-depend.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { createRunner } from "./agent-mock.ts";
|
||||
import { ScenarioRunner, type BuildOverride } from "./agent-scenario.ts";
|
||||
import {
|
||||
setupTempDir,
|
||||
cleanupTempDir,
|
||||
getTempDir,
|
||||
createFreshProject,
|
||||
writeDoc,
|
||||
} from "./fixtures.ts";
|
||||
import { scanChanges } from "../../src/core/scanner.ts";
|
||||
import { validateConfig } from "../../src/core/config.ts";
|
||||
import { ConfigError } from "../../src/cli/errors.ts";
|
||||
import type { RuneConfig } from "../../src/types.ts";
|
||||
|
||||
describe("e2e: 文档依赖", () => {
|
||||
let runner: ReturnType<typeof createRunner>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupTempDir();
|
||||
runner = createRunner();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTempDir();
|
||||
});
|
||||
|
||||
it("依赖文档按顺序创建(A → B → C 链式依赖)", async () => {
|
||||
const config: RuneConfig = {
|
||||
stages: {
|
||||
plan: {
|
||||
documents: [
|
||||
{ name: "a", prompt: "文档 A" },
|
||||
{ name: "b", prompt: "文档 B", depend: ["a"] },
|
||||
{ name: "c", prompt: "文档 C", depend: ["b"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await createFreshProject();
|
||||
await runner.runPlan(getTempDir(), "chain", "a", config);
|
||||
|
||||
let changes = await scanChanges(getTempDir(), config);
|
||||
expect(changes).toHaveLength(1);
|
||||
|
||||
const chain = changes[0]!;
|
||||
const docsA = chain.documents;
|
||||
const aDoc = docsA.find((d) => d.name === "a")!;
|
||||
const bDoc = docsA.find((d) => d.name === "b")!;
|
||||
const cDoc = docsA.find((d) => d.name === "c")!;
|
||||
|
||||
expect(aDoc.completed).toBe(true);
|
||||
expect(aDoc.dependMet).toBe(true);
|
||||
expect(bDoc.completed).toBe(false);
|
||||
// a.md 已存在,所以 b 的依赖已满足
|
||||
expect(bDoc.dependMet).toBe(true);
|
||||
expect(cDoc.completed).toBe(false);
|
||||
// c 依赖 b,b.md 不存在,所以 dependMet=false
|
||||
expect(cDoc.dependMet).toBe(false);
|
||||
expect(chain.planCompleted).toBe(false);
|
||||
|
||||
await runner.runPlan(getTempDir(), "chain", "b", config);
|
||||
changes = await scanChanges(getTempDir(), config);
|
||||
const chain2 = changes[0]!;
|
||||
const docsB = chain2.documents;
|
||||
const bDoc2 = docsB.find((d) => d.name === "b")!;
|
||||
const cDoc2 = docsB.find((d) => d.name === "c")!;
|
||||
|
||||
expect(bDoc2.completed).toBe(true);
|
||||
expect(bDoc2.dependMet).toBe(true);
|
||||
expect(cDoc2.completed).toBe(false);
|
||||
// b.md 已存在,c 的依赖现在满足
|
||||
expect(cDoc2.dependMet).toBe(true);
|
||||
expect(chain2.planCompleted).toBe(false);
|
||||
|
||||
await runner.runPlan(getTempDir(), "chain", "c", config);
|
||||
changes = await scanChanges(getTempDir(), config);
|
||||
const chain3 = changes[0]!;
|
||||
const docsC = chain3.documents;
|
||||
|
||||
expect(docsC.every((d) => d.completed)).toBe(true);
|
||||
expect(docsC.every((d) => d.dependMet)).toBe(true);
|
||||
expect(chain3.planCompleted).toBe(true);
|
||||
expect(chain3.buildUnlocked).toBe(true);
|
||||
});
|
||||
|
||||
it("引用不存在文档的依赖被校验拒绝", () => {
|
||||
const config: RuneConfig = {
|
||||
stages: {
|
||||
plan: {
|
||||
documents: [{ name: "design", prompt: "设计", depend: ["ghost"] }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
validateConfig(config);
|
||||
// 不应该走到这里
|
||||
expect(true).toBe(false);
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ConfigError);
|
||||
expect((e as ConfigError).message).toContain("ghost");
|
||||
}
|
||||
});
|
||||
|
||||
it("依赖链断开时 planCompleted 仍为 false", async () => {
|
||||
const config: RuneConfig = {
|
||||
stages: {
|
||||
plan: {
|
||||
documents: [
|
||||
{ name: "design", prompt: "设计" },
|
||||
{ name: "task", prompt: "任务", depend: ["design"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await createFreshProject();
|
||||
await writeDoc("broken", "task", "# 任务\n");
|
||||
|
||||
const changes = await scanChanges(getTempDir(), config);
|
||||
expect(changes).toHaveLength(1);
|
||||
|
||||
const broken = changes[0]!;
|
||||
const taskDoc = broken.documents.find((d) => d.name === "task")!;
|
||||
const designDoc = broken.documents.find((d) => d.name === "design")!;
|
||||
|
||||
expect(taskDoc.completed).toBe(true);
|
||||
expect(taskDoc.dependMet).toBe(false);
|
||||
expect(designDoc.completed).toBe(false);
|
||||
expect(broken.planCompleted).toBe(false);
|
||||
expect(broken.buildUnlocked).toBe(false);
|
||||
});
|
||||
|
||||
it("依赖满足后才允许 build", async () => {
|
||||
const config: RuneConfig = {
|
||||
stages: {
|
||||
plan: {
|
||||
documents: [
|
||||
{ name: "design", prompt: "设计" },
|
||||
{ name: "task", prompt: "任务", depend: ["design"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await createFreshProject();
|
||||
const baseRunner = createRunner();
|
||||
|
||||
const buildWithDependCheck: BuildOverride = async (projectDir, changeName, cfg) => {
|
||||
const changes = await scanChanges(projectDir, cfg);
|
||||
const change = changes.find((c) => c.name === changeName);
|
||||
if (!change) throw new Error(`变更 "${changeName}" 不存在`);
|
||||
if (!change.planCompleted) {
|
||||
throw new Error(`变更 "${changeName}" 的 plan 阶段未完成`);
|
||||
}
|
||||
return baseRunner.runBuild(projectDir, changeName, cfg);
|
||||
};
|
||||
|
||||
const scenarioRunner = new ScenarioRunner(baseRunner, { build: buildWithDependCheck });
|
||||
|
||||
await writeDoc("build-dep", "task", "# 任务\n- [ ] do something\n");
|
||||
|
||||
// 依赖未满足时 build 应抛出错误
|
||||
await expect(scenarioRunner.runBuild(getTempDir(), "build-dep", config)).rejects.toThrow(
|
||||
"plan 阶段未完成",
|
||||
);
|
||||
|
||||
await runner.runPlan(getTempDir(), "build-dep", "design", config);
|
||||
|
||||
const changesAfter = await scanChanges(getTempDir(), config);
|
||||
expect(changesAfter[0]!.planCompleted).toBe(true);
|
||||
|
||||
// 依赖满足后 build 应正常执行
|
||||
await expect(scenarioRunner.runBuild(getTempDir(), "build-dep", config)).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
126
tests/agent/e2e-error.test.ts
Normal file
126
tests/agent/e2e-error.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { CommandLevelRunner } from "./agent-mock.ts";
|
||||
import { ScenarioRunner } from "./agent-scenario.ts";
|
||||
import type { PlanOverride } from "./agent-scenario.ts";
|
||||
import {
|
||||
setupTempDir,
|
||||
cleanupTempDir,
|
||||
getTempDir,
|
||||
createFreshProject,
|
||||
writeDoc,
|
||||
changeFileExists,
|
||||
} from "./fixtures.ts";
|
||||
import { assertNoFile } from "./validators.ts";
|
||||
import { scanChanges } from "../../src/core/scanner.ts";
|
||||
import { loadConfig } from "../../src/core/config.ts";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const brokenPlan: PlanOverride = async (_projectDir, changeName, docName, _cfg) => {
|
||||
const wrongDir = join(getTempDir(), "wrong-dir");
|
||||
await mkdir(wrongDir, { recursive: true });
|
||||
await writeFile(join(wrongDir, `${docName}.md`), "some content\n");
|
||||
|
||||
return {
|
||||
files: [],
|
||||
missed: [`${docName}.md`],
|
||||
};
|
||||
};
|
||||
|
||||
const emptyPlan: PlanOverride = async (_projectDir, _changeName, _docName, _cfg) => {
|
||||
return { files: [] };
|
||||
};
|
||||
|
||||
describe("e2e: 错误场景", () => {
|
||||
beforeEach(async () => {
|
||||
await setupTempDir();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTempDir();
|
||||
});
|
||||
|
||||
it("agent 文件写错路径", async () => {
|
||||
const config = await createFreshProject();
|
||||
|
||||
const errorRunner = new ScenarioRunner(new CommandLevelRunner(), { plan: brokenPlan });
|
||||
const result = await errorRunner.runPlan(getTempDir(), "wrong-path", "design", config);
|
||||
|
||||
expect(result.files).toHaveLength(0);
|
||||
expect(result.missed).toEqual(["design.md"]);
|
||||
assertNoFile(getTempDir(), ".rune/changes/wrong-path/design.md");
|
||||
});
|
||||
|
||||
it("agent 跳过依赖文档", async () => {
|
||||
const config = await createFreshProject();
|
||||
|
||||
const skipDepsPlan: PlanOverride = async (_projectDir, changeName, docName, _cfg) => {
|
||||
const standardRunner = new CommandLevelRunner();
|
||||
|
||||
if (docName === "task") {
|
||||
return standardRunner.runPlan(getTempDir(), changeName, "task", config);
|
||||
}
|
||||
|
||||
if (docName === "design") {
|
||||
return standardRunner.runPlan(getTempDir(), changeName, "design", config);
|
||||
}
|
||||
|
||||
return { files: [] };
|
||||
};
|
||||
|
||||
const errorRunner = new ScenarioRunner(new CommandLevelRunner(), { plan: skipDepsPlan });
|
||||
|
||||
await errorRunner.runPlan(getTempDir(), "skip-deps", "task", config);
|
||||
|
||||
expect(changeFileExists("skip-deps", "task.md")).toBe(true);
|
||||
|
||||
let changes = await scanChanges(getTempDir(), config);
|
||||
expect(changes).toHaveLength(1);
|
||||
|
||||
const taskDoc = changes[0]!.documents.find((d) => d.name === "task");
|
||||
expect(taskDoc).toBeDefined();
|
||||
expect(taskDoc!.completed).toBe(true);
|
||||
expect(taskDoc!.dependMet).toBe(false);
|
||||
|
||||
const designDoc = changes[0]!.documents.find((d) => d.name === "design");
|
||||
expect(designDoc).toBeDefined();
|
||||
expect(designDoc!.completed).toBe(false);
|
||||
|
||||
await errorRunner.runPlan(getTempDir(), "skip-deps", "design", config);
|
||||
expect(changeFileExists("skip-deps", "design.md")).toBe(true);
|
||||
|
||||
changes = await scanChanges(getTempDir(), config);
|
||||
const taskDoc2 = changes[0]!.documents.find((d) => d.name === "task");
|
||||
expect(taskDoc2!.dependMet).toBe(true);
|
||||
});
|
||||
|
||||
it("agent 创建空文件", async () => {
|
||||
const config = await createFreshProject();
|
||||
|
||||
await writeDoc("empty-file", "design", "");
|
||||
|
||||
const errorRunner = new ScenarioRunner(new CommandLevelRunner(), { plan: emptyPlan });
|
||||
const result = await errorRunner.runPlan(getTempDir(), "empty-file", "design", config);
|
||||
|
||||
expect(changeFileExists("empty-file", "design.md")).toBe(true);
|
||||
|
||||
const filePath = join(getTempDir(), ".rune/changes/empty-file/design.md");
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
expect(content).toBe("");
|
||||
expect(result.files).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("config.yaml 语法错误", async () => {
|
||||
const runeDir = join(getTempDir(), ".rune");
|
||||
await mkdir(runeDir, { recursive: true });
|
||||
|
||||
const invalidYaml = "stages\n plan documents\n";
|
||||
await writeFile(join(runeDir, "config.yaml"), invalidYaml, "utf-8");
|
||||
|
||||
const loadedConfig = await loadConfig(getTempDir());
|
||||
expect(loadedConfig).toBeDefined();
|
||||
expect(loadedConfig.stages.plan).toBeDefined();
|
||||
expect(loadedConfig.stages.plan!.documents).toBeDefined();
|
||||
});
|
||||
});
|
||||
118
tests/agent/e2e-flow.test.ts
Normal file
118
tests/agent/e2e-flow.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { createRunner } from "./agent-mock.ts";
|
||||
import {
|
||||
setupTempDir,
|
||||
cleanupTempDir,
|
||||
getTempDir,
|
||||
createFreshProject,
|
||||
changeFileExists,
|
||||
} from "./fixtures.ts";
|
||||
import { assertAllTasksDone } from "./validators.ts";
|
||||
import { scanChanges, scanArchives } from "../../src/core/scanner.ts";
|
||||
import { assembleDiscussPrompt } from "../../src/core/assembler.ts";
|
||||
import { validateChangeName } from "../../src/cli.ts";
|
||||
|
||||
describe("e2e: 全流程", () => {
|
||||
let runner: ReturnType<typeof createRunner>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupTempDir();
|
||||
runner = createRunner();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTempDir();
|
||||
});
|
||||
|
||||
it("完整四阶段流程(discuss → plan → build → archive)", async () => {
|
||||
const config = await createFreshProject();
|
||||
|
||||
const discussPrompt = assembleDiscussPrompt(config);
|
||||
expect(typeof discussPrompt).toBe("string");
|
||||
expect(discussPrompt.length).toBeGreaterThan(0);
|
||||
|
||||
await runner.runPlan(getTempDir(), "full-flow", "design", config);
|
||||
await runner.runPlan(getTempDir(), "full-flow", "task", config);
|
||||
|
||||
let changes = await scanChanges(getTempDir(), config);
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0]!.name).toBe("full-flow");
|
||||
expect(changes[0]!.planCompleted).toBe(true);
|
||||
expect(changes[0]!.buildUnlocked).toBe(true);
|
||||
|
||||
const buildResult = await runner.runBuild(getTempDir(), "full-flow", config);
|
||||
expect(buildResult.files.length).toBeGreaterThan(0);
|
||||
|
||||
changes = await scanChanges(getTempDir(), config);
|
||||
expect(changes).toHaveLength(1);
|
||||
assertAllTasksDone(changes[0]!);
|
||||
|
||||
await runner.runArchive(getTempDir(), "full-flow", config);
|
||||
|
||||
expect(changeFileExists("full-flow", "task.md")).toBe(false);
|
||||
|
||||
const archives = await scanArchives(getTempDir());
|
||||
expect(archives.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
changes = await scanChanges(getTempDir(), config);
|
||||
expect(changes.find((c) => c.name === "full-flow")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("多变更并行互不干扰", async () => {
|
||||
const config = await createFreshProject();
|
||||
|
||||
await runner.runPlan(getTempDir(), "变更A", "design", config);
|
||||
await runner.runPlan(getTempDir(), "变更A", "task", config);
|
||||
|
||||
await runner.runPlan(getTempDir(), "变更B", "design", config);
|
||||
await runner.runPlan(getTempDir(), "变更B", "task", config);
|
||||
|
||||
let changes = await scanChanges(getTempDir(), config);
|
||||
expect(changes).toHaveLength(2);
|
||||
|
||||
const changeA = changes.find((c) => c.name === "变更A");
|
||||
const changeB = changes.find((c) => c.name === "变更B");
|
||||
expect(changeA).toBeDefined();
|
||||
expect(changeB).toBeDefined();
|
||||
expect(changeA!.planCompleted).toBe(true);
|
||||
expect(changeB!.planCompleted).toBe(true);
|
||||
|
||||
await runner.runBuild(getTempDir(), "变更A", config);
|
||||
await runner.runBuild(getTempDir(), "变更B", config);
|
||||
|
||||
changes = await scanChanges(getTempDir(), config);
|
||||
expect(changes).toHaveLength(2);
|
||||
|
||||
assertAllTasksDone(changes.find((c) => c.name === "变更A")!);
|
||||
assertAllTasksDone(changes.find((c) => c.name === "变更B")!);
|
||||
|
||||
expect(changes[0]!.taskProgress?.completed).toBe(changes[0]!.taskProgress?.total);
|
||||
expect(changes[1]!.taskProgress?.completed).toBe(changes[1]!.taskProgress?.total);
|
||||
});
|
||||
|
||||
describe("变更名非法字符拒绝", () => {
|
||||
it("空字符串抛出错误", () => {
|
||||
expect(() => validateChangeName("")).toThrow();
|
||||
});
|
||||
|
||||
it("包含 / 抛出错误", () => {
|
||||
expect(() => validateChangeName("变更/A")).toThrow();
|
||||
});
|
||||
|
||||
it("包含 . 抛出错误", () => {
|
||||
expect(() => validateChangeName("变更.A")).toThrow();
|
||||
});
|
||||
|
||||
it("合法中文名称不抛出错误", () => {
|
||||
expect(() => validateChangeName("变更名")).not.toThrow();
|
||||
});
|
||||
|
||||
it("合法英文名称不抛出错误", () => {
|
||||
expect(() => validateChangeName("my-change")).not.toThrow();
|
||||
});
|
||||
|
||||
it("合法短横线名称不抛出错误", () => {
|
||||
expect(() => validateChangeName("abc-def-xyz")).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
56
tests/agent/e2e-llm-judge.test.ts
Normal file
56
tests/agent/e2e-llm-judge.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { LLMJudgeRunner, isLLMAvailable } from "./agent-llm.ts";
|
||||
import {
|
||||
setupTempDir,
|
||||
cleanupTempDir,
|
||||
getTempDir,
|
||||
createFreshProject,
|
||||
writeDoc,
|
||||
} from "./fixtures.ts";
|
||||
|
||||
const tier3Available = isLLMAvailable();
|
||||
|
||||
if (!tier3Available) {
|
||||
console.log("RUNE_E2E_LLM_ 环境变量未配置,Tier 3 测试已跳过");
|
||||
}
|
||||
|
||||
describe("e2e: Tier 3", () => {
|
||||
const runner = new LLMJudgeRunner();
|
||||
const testFn = tier3Available ? it : it.skip;
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupTempDir();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTempDir();
|
||||
});
|
||||
|
||||
testFn(
|
||||
"plan: 单文档输出有效行动计划",
|
||||
async () => {
|
||||
const config = await createFreshProject();
|
||||
const result = await runner.runPlan(getTempDir(), "user-auth", "design", config);
|
||||
|
||||
expect(result.rawPlan).toBeDefined();
|
||||
expect(result.rawPlan).toHaveProperty("actions");
|
||||
const plan = result.rawPlan as { actions: unknown[] };
|
||||
expect(plan.actions.length).toBeGreaterThan(0);
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
|
||||
testFn(
|
||||
"build: 单任务输出有效行动计划",
|
||||
async () => {
|
||||
const config = await createFreshProject();
|
||||
await writeDoc("auth", "task", "- [ ] 实现登录 API\n");
|
||||
|
||||
const result = await runner.runBuild(getTempDir(), "auth", config);
|
||||
|
||||
expect(result.rawPlan).toBeDefined();
|
||||
expect(result.rawPlan).toHaveProperty("actions");
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
});
|
||||
132
tests/agent/e2e-plan.test.ts
Normal file
132
tests/agent/e2e-plan.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { createRunner } from "./agent-mock.ts";
|
||||
import {
|
||||
setupTempDir,
|
||||
cleanupTempDir,
|
||||
getTempDir,
|
||||
createFreshProject,
|
||||
changeFileExists,
|
||||
} from "./fixtures.ts";
|
||||
import { assertDocCreated, assertDocContains, assertConfigInvalid } from "./validators.ts";
|
||||
import { scanChanges } from "../../src/core/scanner.ts";
|
||||
import type { RuneConfig } from "../../src/types.ts";
|
||||
|
||||
describe("e2e: plan 阶段", () => {
|
||||
let runner: ReturnType<typeof createRunner>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupTempDir();
|
||||
runner = createRunner();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTempDir();
|
||||
});
|
||||
|
||||
it("单文档按模板生成在正确路径", async () => {
|
||||
const config = await createFreshProject();
|
||||
const result = await runner.runPlan(getTempDir(), "user-auth", "design", config);
|
||||
|
||||
expect(result.files).toContain("design.md");
|
||||
assertDocCreated(getTempDir(), "user-auth", "design");
|
||||
assertDocContains(getTempDir(), "user-auth", "design", "user-auth");
|
||||
});
|
||||
|
||||
it("多文档无依赖时均生成", async () => {
|
||||
const config = await createFreshProject();
|
||||
await runner.runPlan(getTempDir(), "my-change", "design", config);
|
||||
await runner.runPlan(getTempDir(), "my-change", "task", config);
|
||||
|
||||
expect(changeFileExists("my-change", "design.md")).toBe(true);
|
||||
expect(changeFileExists("my-change", "task.md")).toBe(true);
|
||||
|
||||
const changes = await scanChanges(getTempDir(), config);
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0]!.planCompleted).toBe(true);
|
||||
expect(changes[0]!.buildUnlocked).toBe(true);
|
||||
});
|
||||
|
||||
it("模板变量 {{change-name}} 被替换", async () => {
|
||||
const config = await createFreshProject();
|
||||
await runner.runPlan(getTempDir(), "用户-login", "design", config);
|
||||
|
||||
assertDocContains(getTempDir(), "用户-login", "design", "用户-login 设计文档");
|
||||
});
|
||||
|
||||
it("使用自定义 plan 配置(单文档 spec)", async () => {
|
||||
const config: RuneConfig = {
|
||||
stages: {
|
||||
plan: {
|
||||
documents: [{ name: "spec", prompt: "生成规格", template: "# {{change-name}} 规格\n" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
await createFreshProject(["opencode"]);
|
||||
const result = await runner.runPlan(getTempDir(), "my-feature", "spec", config);
|
||||
|
||||
expect(result.files).toContain("spec.md");
|
||||
assertDocContains(getTempDir(), "my-feature", "spec", "my-feature 规格");
|
||||
});
|
||||
|
||||
it("多文档有依赖时 planCompleted 全完成后才为 true", async () => {
|
||||
const config = await createFreshProject();
|
||||
|
||||
await runner.runPlan(getTempDir(), "dep-test", "design", config);
|
||||
|
||||
let changes = await scanChanges(getTempDir(), config);
|
||||
expect(changes[0]!.planCompleted).toBe(false);
|
||||
|
||||
await runner.runPlan(getTempDir(), "dep-test", "task", config);
|
||||
|
||||
changes = await scanChanges(getTempDir(), config);
|
||||
expect(changes[0]!.planCompleted).toBe(true);
|
||||
expect(changes[0]!.buildUnlocked).toBe(true);
|
||||
});
|
||||
|
||||
it("已有变更再次 plan 另一个文档(重复 plan)", async () => {
|
||||
const config: RuneConfig = {
|
||||
stages: {
|
||||
plan: {
|
||||
documents: [
|
||||
{ name: "design", prompt: "生成设计", template: "# 设计 #\n" },
|
||||
{ name: "task", prompt: "生成任务", template: "# 任务 #\n" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await runner.runPlan(getTempDir(), "multi", "design", config);
|
||||
assertDocCreated(getTempDir(), "multi", "design");
|
||||
|
||||
const result = await runner.runPlan(getTempDir(), "multi", "task", config);
|
||||
expect(result.files).toContain("task.md");
|
||||
|
||||
const changes = await scanChanges(getTempDir(), config);
|
||||
expect(changes[0]!.planCompleted).toBe(true);
|
||||
});
|
||||
|
||||
it("循环依赖配置被校验拒绝", () => {
|
||||
const config: RuneConfig = {
|
||||
stages: {
|
||||
plan: {
|
||||
documents: [
|
||||
{ name: "a", prompt: "a", depend: ["b"] },
|
||||
{ name: "b", prompt: "b", depend: ["a"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
assertConfigInvalid(config);
|
||||
});
|
||||
|
||||
it("自依赖配置被校验拒绝", () => {
|
||||
const config: RuneConfig = {
|
||||
stages: {
|
||||
plan: {
|
||||
documents: [{ name: "design", prompt: "设计", depend: ["design"] }],
|
||||
},
|
||||
},
|
||||
};
|
||||
assertConfigInvalid(config);
|
||||
});
|
||||
});
|
||||
46
tests/agent/fixtures.ts
Normal file
46
tests/agent/fixtures.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, writeFile, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { runInit } from "../../src/commands/init.ts";
|
||||
import { loadConfig, getChangeDir } from "../../src/core/config.ts";
|
||||
import type { RuneConfig } from "../../src/types.ts";
|
||||
|
||||
const TMP_DIR = join(import.meta.dir, "__tmp_agent_test__");
|
||||
|
||||
export function getTempDir(): string {
|
||||
return TMP_DIR;
|
||||
}
|
||||
|
||||
export async function setupTempDir(): Promise<void> {
|
||||
await rm(TMP_DIR, { recursive: true, force: true });
|
||||
await mkdir(TMP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
export async function cleanupTempDir(): Promise<void> {
|
||||
await rm(TMP_DIR, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export async function createFreshProject(editors: string[] = ["opencode"]): Promise<RuneConfig> {
|
||||
await runInit(TMP_DIR, editors);
|
||||
return loadConfig(TMP_DIR);
|
||||
}
|
||||
|
||||
export async function createChangeDir(changeName: string): Promise<string> {
|
||||
const dir = getChangeDir(TMP_DIR, changeName);
|
||||
await mkdir(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
export async function writeDoc(
|
||||
changeName: string,
|
||||
docName: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const dir = getChangeDir(TMP_DIR, changeName);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, `${docName}.md`), content);
|
||||
}
|
||||
|
||||
export function changeFileExists(changeName: string, fileName: string): boolean {
|
||||
return existsSync(join(getChangeDir(TMP_DIR, changeName), fileName));
|
||||
}
|
||||
19
tests/agent/runner.ts
Normal file
19
tests/agent/runner.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { RuneConfig } from "../../src/types.ts";
|
||||
|
||||
export interface AgentResult {
|
||||
files: string[];
|
||||
missed?: string[];
|
||||
rawPlan?: unknown;
|
||||
}
|
||||
|
||||
export interface AgentRunner {
|
||||
readonly tier: number;
|
||||
runPlan(
|
||||
projectDir: string,
|
||||
changeName: string,
|
||||
docName: string,
|
||||
config: RuneConfig,
|
||||
): Promise<AgentResult>;
|
||||
runBuild(projectDir: string, changeName: string, config: RuneConfig): Promise<AgentResult>;
|
||||
runArchive(projectDir: string, changeName: string, config: RuneConfig): Promise<AgentResult>;
|
||||
}
|
||||
64
tests/agent/validators.ts
Normal file
64
tests/agent/validators.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { expect } from "bun:test";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { ChangeStatus } from "../../src/types.ts";
|
||||
import { validateConfig } from "../../src/core/config.ts";
|
||||
import type { RuneConfig } from "../../src/types.ts";
|
||||
|
||||
export function assertFileExists(projectDir: string, relativePath: string): void {
|
||||
expect(existsSync(join(projectDir, relativePath))).toBe(true);
|
||||
}
|
||||
|
||||
export function assertNoFile(projectDir: string, relativePath: string): void {
|
||||
expect(existsSync(join(projectDir, relativePath))).toBe(false);
|
||||
}
|
||||
|
||||
export function assertDirExists(projectDir: string, relativePath: string): void {
|
||||
expect(existsSync(join(projectDir, relativePath))).toBe(true);
|
||||
}
|
||||
|
||||
export function assertFileContains(
|
||||
projectDir: string,
|
||||
relativePath: string,
|
||||
expected: string,
|
||||
): void {
|
||||
const content = readFileSync(join(projectDir, relativePath), "utf-8");
|
||||
expect(content).toContain(expected);
|
||||
}
|
||||
|
||||
export function assertDocCreated(projectDir: string, changeName: string, docName: string): void {
|
||||
assertFileExists(projectDir, `.rune/changes/${changeName}/${docName}.md`);
|
||||
const content = readFileSync(
|
||||
join(projectDir, `.rune/changes/${changeName}/${docName}.md`),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
export function assertDocContains(
|
||||
projectDir: string,
|
||||
changeName: string,
|
||||
docName: string,
|
||||
expected: string,
|
||||
): void {
|
||||
assertFileContains(projectDir, `.rune/changes/${changeName}/${docName}.md`, expected);
|
||||
}
|
||||
|
||||
export function assertAllTasksDone(change: ChangeStatus): void {
|
||||
expect(change.taskProgress).not.toBeNull();
|
||||
if (change.taskProgress) {
|
||||
expect(change.taskProgress.completed).toBe(change.taskProgress.total);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertTaskProgress(change: ChangeStatus, completed: number, total: number): void {
|
||||
expect(change.taskProgress).toEqual({ completed, total });
|
||||
}
|
||||
|
||||
export function assertConfigValid(config: RuneConfig): void {
|
||||
expect(() => validateConfig(config)).not.toThrow();
|
||||
}
|
||||
|
||||
export function assertConfigInvalid(config: RuneConfig): void {
|
||||
expect(() => validateConfig(config)).toThrow();
|
||||
}
|
||||
@@ -7,7 +7,7 @@ describe("showGlobalHelp", () => {
|
||||
expect(output).toContain("rune <命令> [参数]");
|
||||
expect(output).toContain("init <工具...>");
|
||||
expect(output).toContain("discuss");
|
||||
expect(output).toContain("plan <名称>");
|
||||
expect(output).toContain("plan <变更> <文档>");
|
||||
expect(output).toContain("build <名称>");
|
||||
expect(output).toContain("archive <名称>");
|
||||
expect(output).toContain("status");
|
||||
@@ -18,7 +18,7 @@ describe("showGlobalHelp", () => {
|
||||
it("包含示例", () => {
|
||||
const output = showGlobalHelp();
|
||||
expect(output).toContain("rune init opencode");
|
||||
expect(output).toContain("rune plan add-login");
|
||||
expect(output).toContain("rune plan add-login design");
|
||||
expect(output).toContain("rune status");
|
||||
});
|
||||
|
||||
@@ -31,8 +31,9 @@ describe("showGlobalHelp", () => {
|
||||
describe("showCommandHelp", () => {
|
||||
it("plan 命令包含用法、参数、描述、示例", () => {
|
||||
const output = showCommandHelp("plan");
|
||||
expect(output).toContain("rune plan <change-name>");
|
||||
expect(output).toContain("rune plan <change-name> <document-name>");
|
||||
expect(output).toContain("<change-name>");
|
||||
expect(output).toContain("<document-name>");
|
||||
expect(output).toContain("规划阶段");
|
||||
expect(output).toContain("rune plan add-user-auth");
|
||||
});
|
||||
|
||||
56
tests/cli/map-error.test.ts
Normal file
56
tests/cli/map-error.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { UsageError, ConfigError, InternalError } from "../../src/cli/errors.ts";
|
||||
import { mapError } from "../../src/cli.ts";
|
||||
|
||||
describe("mapError", () => {
|
||||
it("CliError 原样返回", () => {
|
||||
const err = new ConfigError("未初始化");
|
||||
const result = mapError(err);
|
||||
expect(result).toBe(err);
|
||||
expect(result.message).toBe("未初始化");
|
||||
});
|
||||
|
||||
it("Unknown option 转为 UsageError", () => {
|
||||
const err = new Error("Unknown option `--badflag`");
|
||||
const result = mapError(err);
|
||||
expect(result).toBeInstanceOf(UsageError);
|
||||
expect(result.message).toBe("未知选项: --badflag");
|
||||
expect(result.hint).toBe("运行 rune help 查看所有命令");
|
||||
});
|
||||
|
||||
it("Unknown command 转为 UsageError", () => {
|
||||
const err = new Error("Unknown command `foo`");
|
||||
const result = mapError(err);
|
||||
expect(result).toBeInstanceOf(UsageError);
|
||||
expect(result.message).toBe("未知命令: foo");
|
||||
});
|
||||
|
||||
it("Unused args 转为 UsageError", () => {
|
||||
const err = new Error("Unused args: --extra");
|
||||
const result = mapError(err);
|
||||
expect(result).toBeInstanceOf(UsageError);
|
||||
expect(result.message).toContain("未知命令");
|
||||
expect(result.message).toContain("--extra");
|
||||
});
|
||||
|
||||
it("missing required args 转为 UsageError", () => {
|
||||
const err = new Error("missing required args for command `plan`");
|
||||
const result = mapError(err);
|
||||
expect(result).toBeInstanceOf(UsageError);
|
||||
expect(result.message).toBe("命令 'plan' 缺少必填参数");
|
||||
expect(result.usage).toBe("rune plan <change-name>");
|
||||
expect(result.hint).toContain("rune help plan");
|
||||
});
|
||||
|
||||
it("未知 Error 转为 InternalError", () => {
|
||||
const err = new Error("something unexpected");
|
||||
const result = mapError(err);
|
||||
expect(result).toBeInstanceOf(InternalError);
|
||||
expect(result.message).toBe("发生了未预期的错误");
|
||||
});
|
||||
|
||||
it("非 Error 类型转为 InternalError", () => {
|
||||
const result = mapError("字符串错误");
|
||||
expect(result).toBeInstanceOf(InternalError);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,6 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { formatError } from "../../src/cli/output.ts";
|
||||
import {
|
||||
UsageError,
|
||||
ConfigError,
|
||||
CommandError,
|
||||
InternalError,
|
||||
} from "../../src/cli/errors.ts";
|
||||
import { UsageError, ConfigError, InternalError } from "../../src/cli/errors.ts";
|
||||
|
||||
describe("formatError", () => {
|
||||
it("只输出错误行(无 hint/usage)", () => {
|
||||
@@ -25,15 +20,13 @@ describe("formatError", () => {
|
||||
usage: "rune plan <change-name>",
|
||||
});
|
||||
const output = formatError(err);
|
||||
expect(output).toBe(
|
||||
"错误: 缺少参数\n\n用法: rune plan <change-name>",
|
||||
);
|
||||
expect(output).toBe("错误: 缺少参数\n\n用法: rune plan <change-name>");
|
||||
});
|
||||
|
||||
it("输出完整格式(错误 + 用法 + 提示)", () => {
|
||||
const err = new UsageError("缺少必填参数 <change-name>", {
|
||||
usage: "rune plan <change-name>",
|
||||
hint: "请指定变更名称,如 \"add-login\"",
|
||||
hint: '请指定变更名称,如 "add-login"',
|
||||
});
|
||||
const output = formatError(err);
|
||||
expect(output).toBe(
|
||||
|
||||
148
tests/cli/status.test.ts
Normal file
148
tests/cli/status.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { formatChangeStatus, suggestNextStep } from "../../src/cli.ts";
|
||||
import type { ChangeStatus, RuneConfig } from "../../src/types.ts";
|
||||
|
||||
function makeStatus(overrides: Partial<ChangeStatus> = {}): ChangeStatus {
|
||||
return {
|
||||
name: "test-change",
|
||||
documents: [],
|
||||
planCompleted: false,
|
||||
buildUnlocked: false,
|
||||
taskProgress: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("formatChangeStatus", () => {
|
||||
it("显示变更名", () => {
|
||||
const output = formatChangeStatus(makeStatus());
|
||||
expect(output).toContain("test-change");
|
||||
});
|
||||
|
||||
it("显示已完成和待完成文档", () => {
|
||||
const status = makeStatus({
|
||||
documents: [
|
||||
{ name: "design", completed: true, dependMet: true },
|
||||
{ name: "task", completed: false, dependMet: true },
|
||||
],
|
||||
});
|
||||
const output = formatChangeStatus(status);
|
||||
expect(output).toContain("design.md ✓ 已完成");
|
||||
expect(output).toContain("task.md ○ 待完成");
|
||||
});
|
||||
|
||||
it("显示文档依赖信息(dependMet 为 false 且 config 中有依赖)", () => {
|
||||
const status = makeStatus({
|
||||
documents: [{ name: "task", completed: false, dependMet: false }],
|
||||
});
|
||||
const config: RuneConfig = {
|
||||
stages: {
|
||||
plan: {
|
||||
documents: [
|
||||
{ name: "design", prompt: "生成设计" },
|
||||
{ name: "task", prompt: "生成任务", depend: ["design"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const output = formatChangeStatus(status, config);
|
||||
expect(output).toContain("依赖 design.md");
|
||||
});
|
||||
|
||||
it("dependMet 为 false 但无 config 时不显示文档依赖信息", () => {
|
||||
const status = makeStatus({
|
||||
documents: [{ name: "task", completed: false, dependMet: false }],
|
||||
});
|
||||
const output = formatChangeStatus(status);
|
||||
expect(output).not.toContain("(依赖");
|
||||
});
|
||||
|
||||
it("显示规划进度", () => {
|
||||
const status = makeStatus({
|
||||
documents: [
|
||||
{ name: "design", completed: true, dependMet: true },
|
||||
{ name: "task", completed: false, dependMet: true },
|
||||
],
|
||||
});
|
||||
const output = formatChangeStatus(status);
|
||||
expect(output).toContain("1/2 文档已完成");
|
||||
});
|
||||
|
||||
it("规划完成时显示构建已解锁", () => {
|
||||
const status = makeStatus({
|
||||
documents: [
|
||||
{ name: "design", completed: true, dependMet: true },
|
||||
{ name: "task", completed: true, dependMet: true },
|
||||
],
|
||||
planCompleted: true,
|
||||
buildUnlocked: true,
|
||||
});
|
||||
const output = formatChangeStatus(status);
|
||||
expect(output).toContain("已解锁");
|
||||
});
|
||||
|
||||
it("显示任务进度", () => {
|
||||
const status = makeStatus({
|
||||
taskProgress: { completed: 3, total: 5 },
|
||||
});
|
||||
const output = formatChangeStatus(status);
|
||||
expect(output).toContain("3/5 已完成");
|
||||
});
|
||||
|
||||
it("包含下一步建议", () => {
|
||||
const output = formatChangeStatus(makeStatus());
|
||||
expect(output).toContain("建议下一步");
|
||||
});
|
||||
});
|
||||
|
||||
describe("suggestNextStep", () => {
|
||||
it("规划未完成时返回下一个可规划文档", () => {
|
||||
const status = makeStatus({
|
||||
documents: [{ name: "design", completed: false, dependMet: true }],
|
||||
});
|
||||
expect(suggestNextStep(status)).toContain("rune plan test-change design");
|
||||
});
|
||||
|
||||
it("规划未完成且依赖未满足时提示完成前置依赖", () => {
|
||||
const status = makeStatus({
|
||||
documents: [{ name: "design", completed: false, dependMet: false }],
|
||||
});
|
||||
expect(suggestNextStep(status)).toBe("完成前置依赖后再规划文档");
|
||||
});
|
||||
|
||||
it("规划完成且有未完成任务时建议 build", () => {
|
||||
const status = makeStatus({
|
||||
documents: [
|
||||
{ name: "design", completed: true, dependMet: true },
|
||||
{ name: "task", completed: true, dependMet: true },
|
||||
],
|
||||
planCompleted: true,
|
||||
taskProgress: { completed: 2, total: 5 },
|
||||
});
|
||||
expect(suggestNextStep(status)).toContain("rune build test-change");
|
||||
});
|
||||
|
||||
it("任务全部完成时建议 archive", () => {
|
||||
const status = makeStatus({
|
||||
documents: [
|
||||
{ name: "design", completed: true, dependMet: true },
|
||||
{ name: "task", completed: true, dependMet: true },
|
||||
],
|
||||
planCompleted: true,
|
||||
taskProgress: { completed: 5, total: 5 },
|
||||
});
|
||||
expect(suggestNextStep(status)).toContain("rune archive test-change");
|
||||
});
|
||||
|
||||
it("规划完成但无 taskProgress 时建议 build", () => {
|
||||
const status = makeStatus({
|
||||
documents: [
|
||||
{ name: "design", completed: true, dependMet: true },
|
||||
{ name: "task", completed: true, dependMet: true },
|
||||
],
|
||||
planCompleted: true,
|
||||
taskProgress: null,
|
||||
});
|
||||
expect(suggestNextStep(status)).toContain("rune build test-change");
|
||||
});
|
||||
});
|
||||
37
tests/cli/validate.test.ts
Normal file
37
tests/cli/validate.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { validateChangeName } from "../../src/cli.ts";
|
||||
import { CommandError } from "../../src/cli/errors.ts";
|
||||
|
||||
describe("validateChangeName", () => {
|
||||
it("英文名通过", () => {
|
||||
expect(() => validateChangeName("user-auth")).not.toThrow();
|
||||
expect(() => validateChangeName("addLogin")).not.toThrow();
|
||||
});
|
||||
|
||||
it("中文名通过", () => {
|
||||
expect(() => validateChangeName("用户登录")).not.toThrow();
|
||||
expect(() => validateChangeName("修复内存泄漏")).not.toThrow();
|
||||
});
|
||||
|
||||
it("中英混合通过", () => {
|
||||
expect(() => validateChangeName("用户-login")).not.toThrow();
|
||||
});
|
||||
|
||||
it("空格不通过", () => {
|
||||
expect(() => validateChangeName("my change")).toThrow(CommandError);
|
||||
});
|
||||
|
||||
it("下划线不通过", () => {
|
||||
expect(() => validateChangeName("my_change")).toThrow(CommandError);
|
||||
});
|
||||
|
||||
it("特殊符号不通过", () => {
|
||||
expect(() => validateChangeName("my-change!")).toThrow(CommandError);
|
||||
expect(() => validateChangeName("my.change")).toThrow(CommandError);
|
||||
expect(() => validateChangeName("my@change")).toThrow(CommandError);
|
||||
});
|
||||
|
||||
it("空字符串不通过", () => {
|
||||
expect(() => validateChangeName("")).toThrow(CommandError);
|
||||
});
|
||||
});
|
||||
@@ -22,10 +22,7 @@ describe("runInit", () => {
|
||||
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",
|
||||
);
|
||||
const content = await readFile(join(TMP_DIR, ".rune", "config.yaml"), "utf-8");
|
||||
expect(content).toContain("# Rune 配置文件");
|
||||
expect(content).toContain("stages:");
|
||||
});
|
||||
@@ -46,16 +43,10 @@ describe("runInit", () => {
|
||||
|
||||
it("重复 init 不覆盖 config.yaml", async () => {
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
await writeFile(
|
||||
join(TMP_DIR, ".rune", "config.yaml"),
|
||||
"自定义内容",
|
||||
);
|
||||
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",
|
||||
);
|
||||
const content = await readFile(join(TMP_DIR, ".rune", "config.yaml"), "utf-8");
|
||||
expect(content).toBe("自定义内容");
|
||||
});
|
||||
|
||||
|
||||
@@ -24,7 +24,9 @@ describe("assembleDiscussPrompt", () => {
|
||||
it("返回默认 discuss 提示词", () => {
|
||||
const prompt = assembleDiscussPrompt(defaultConfig);
|
||||
expect(prompt).toBeTruthy();
|
||||
expect(prompt).toContain("软件架构师");
|
||||
expect(prompt).toContain("探索模式");
|
||||
expect(prompt).toContain("立场");
|
||||
expect(prompt).toContain("护栏");
|
||||
});
|
||||
|
||||
it("返回自定义 discuss 提示词", () => {
|
||||
@@ -37,42 +39,68 @@ describe("assembleDiscussPrompt", () => {
|
||||
});
|
||||
|
||||
describe("assemblePlanPrompt", () => {
|
||||
it("包含变更名称和文档指引", async () => {
|
||||
const prompt = await assemblePlanPrompt(
|
||||
defaultConfig,
|
||||
TMP_DIR,
|
||||
"user-auth",
|
||||
);
|
||||
it("包含指定文档名称和提示词", async () => {
|
||||
const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "design");
|
||||
expect(prompt).toContain("user-auth");
|
||||
expect(prompt).toContain("design");
|
||||
expect(prompt).toContain("task");
|
||||
expect(prompt).toContain("格式模板");
|
||||
expect(prompt).not.toContain("task");
|
||||
});
|
||||
|
||||
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",
|
||||
);
|
||||
const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "design");
|
||||
expect(prompt).toContain("已有设计");
|
||||
expect(prompt).toContain("在此基础上修订");
|
||||
});
|
||||
|
||||
it("替换模板中的 {{change-name}}", async () => {
|
||||
const prompt = await assemblePlanPrompt(
|
||||
defaultConfig,
|
||||
TMP_DIR,
|
||||
"user-auth",
|
||||
);
|
||||
const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "design");
|
||||
expect(prompt).toContain("user-auth 设计文档");
|
||||
expect(prompt).toContain("user-auth 任务列表");
|
||||
expect(prompt).not.toContain("{{change-name}}");
|
||||
});
|
||||
|
||||
it("包含依赖说明(有依赖时)", async () => {
|
||||
const config: RuneConfig = {
|
||||
stages: {
|
||||
plan: {
|
||||
documents: [
|
||||
{ name: "design", prompt: "生成设计" },
|
||||
{ name: "task", prompt: "生成任务", depend: ["design"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const prompt = await assemblePlanPrompt(config, TMP_DIR, "user-auth", "task");
|
||||
expect(prompt).toContain("依赖说明");
|
||||
expect(prompt).toContain("design.md");
|
||||
});
|
||||
|
||||
it("无依赖时不包含依赖说明", async () => {
|
||||
const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "design");
|
||||
expect(prompt).not.toContain("依赖说明");
|
||||
});
|
||||
|
||||
it("依赖说明标注完成状态", async () => {
|
||||
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
|
||||
await mkdir(changeDir, { recursive: true });
|
||||
await writeFile(join(changeDir, "design.md"), "# 设计");
|
||||
|
||||
const config: RuneConfig = {
|
||||
stages: {
|
||||
plan: {
|
||||
documents: [
|
||||
{ name: "design", prompt: "生成设计" },
|
||||
{ name: "task", prompt: "生成任务", depend: ["design"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const prompt = await assemblePlanPrompt(config, TMP_DIR, "user-auth", "task");
|
||||
expect(prompt).toContain("已完成");
|
||||
});
|
||||
|
||||
it("使用自定义 plan 配置", async () => {
|
||||
const config: RuneConfig = {
|
||||
stages: {
|
||||
@@ -87,7 +115,7 @@ describe("assemblePlanPrompt", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
const prompt = await assemblePlanPrompt(config, TMP_DIR, "my-feature");
|
||||
const prompt = await assemblePlanPrompt(config, TMP_DIR, "my-feature", "spec");
|
||||
expect(prompt).toContain("spec");
|
||||
expect(prompt).toContain("my-feature 规格");
|
||||
expect(prompt).not.toContain("design");
|
||||
@@ -98,15 +126,8 @@ 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",
|
||||
);
|
||||
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 项");
|
||||
@@ -115,29 +136,29 @@ 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- [x] 任务二`,
|
||||
);
|
||||
const prompt = await assembleBuildPrompt(
|
||||
defaultConfig,
|
||||
TMP_DIR,
|
||||
"user-auth",
|
||||
);
|
||||
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");
|
||||
it("task.md 不存在时抛出 CommandError 并附带提示", async () => {
|
||||
try {
|
||||
await assembleBuildPrompt(defaultConfig, TMP_DIR, "nonexistent");
|
||||
expect.unreachable();
|
||||
} catch (e: any) {
|
||||
expect(e.message).toContain("尚未完成规划");
|
||||
expect(e.message).toContain("nonexistent");
|
||||
expect(e.hint).toContain("rune plan");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("assembleArchivePrompt", () => {
|
||||
it("返回归档提示词", () => {
|
||||
const prompt = assembleArchivePrompt(defaultConfig, "user-auth");
|
||||
it("返回归档提示词", async () => {
|
||||
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
|
||||
await mkdir(changeDir, { recursive: true });
|
||||
const prompt = await assembleArchivePrompt(defaultConfig, TMP_DIR, "user-auth");
|
||||
expect(prompt).toContain("user-auth");
|
||||
expect(prompt).toContain("归档");
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdir, writeFile, rm } from "node:fs/promises";
|
||||
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__");
|
||||
|
||||
@@ -89,10 +91,7 @@ describe("loadConfig", () => {
|
||||
it("YAML 解析错误时返回默认配置", async () => {
|
||||
const runeDir = join(TMP_DIR, ".rune");
|
||||
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(config.stages.discuss).toBeDefined();
|
||||
});
|
||||
@@ -103,3 +102,71 @@ describe("getRuneDir", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdir, writeFile, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { scanChanges, scanArchives } from "../../src/core/scanner.ts";
|
||||
import type { RuneConfig } from "../../src/types.ts";
|
||||
|
||||
const TMP_DIR = join(import.meta.dir, "__tmp_scanner_test__");
|
||||
|
||||
@@ -24,16 +25,16 @@ describe("scanChanges", () => {
|
||||
const changesDir = join(TMP_DIR, ".rune", "changes");
|
||||
await mkdir(join(changesDir, "user-auth"), { recursive: true });
|
||||
await writeFile(join(changesDir, "user-auth", "design.md"), "# 设计");
|
||||
await writeFile(
|
||||
join(changesDir, "user-auth", "task.md"),
|
||||
`- [x] 任务一\n- [ ] 任务二`,
|
||||
);
|
||||
await writeFile(join(changesDir, "user-auth", "task.md"), `- [x] 任务一\n- [ ] 任务二`);
|
||||
|
||||
const changes = await scanChanges(TMP_DIR);
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0].name).toBe("user-auth");
|
||||
expect(changes[0].documents).toContain("design.md");
|
||||
expect(changes[0].documents).toContain("task.md");
|
||||
const docNames = changes[0].documents.map((d) => `${d.name}.md`);
|
||||
expect(docNames).toContain("design.md");
|
||||
expect(docNames).toContain("task.md");
|
||||
expect(changes[0].planCompleted).toBe(false);
|
||||
expect(changes[0].buildUnlocked).toBe(false);
|
||||
expect(changes[0].taskProgress).toEqual({ completed: 1, total: 2 });
|
||||
});
|
||||
|
||||
@@ -57,6 +58,93 @@ describe("scanChanges", () => {
|
||||
const changes = await scanChanges(TMP_DIR);
|
||||
expect(changes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("返回 DocumentStatus 含 completed 和 dependMet", async () => {
|
||||
const changesDir = join(TMP_DIR, ".rune", "changes");
|
||||
await mkdir(join(changesDir, "user-auth"), { recursive: true });
|
||||
await writeFile(join(changesDir, "user-auth", "design.md"), "# 设计");
|
||||
|
||||
const config: RuneConfig = {
|
||||
stages: {
|
||||
plan: {
|
||||
documents: [
|
||||
{ name: "design", prompt: "生成设计" },
|
||||
{ name: "task", prompt: "生成任务", depend: ["design"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const changes = await scanChanges(TMP_DIR, config);
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0].documents).toHaveLength(2);
|
||||
expect(changes[0].documents[0]).toEqual({
|
||||
name: "design",
|
||||
completed: true,
|
||||
dependMet: true,
|
||||
});
|
||||
expect(changes[0].documents[1]).toEqual({
|
||||
name: "task",
|
||||
completed: false,
|
||||
dependMet: true,
|
||||
});
|
||||
expect(changes[0].planCompleted).toBe(false);
|
||||
expect(changes[0].buildUnlocked).toBe(false);
|
||||
});
|
||||
|
||||
it("depend 未满足时 dependMet 为 false", async () => {
|
||||
const changesDir = join(TMP_DIR, ".rune", "changes");
|
||||
await mkdir(join(changesDir, "user-auth"), { recursive: true });
|
||||
|
||||
const config: RuneConfig = {
|
||||
stages: {
|
||||
plan: {
|
||||
documents: [
|
||||
{ name: "design", prompt: "生成设计" },
|
||||
{ name: "task", prompt: "生成任务", depend: ["design"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const changes = await scanChanges(TMP_DIR, config);
|
||||
expect(changes[0].documents[1].dependMet).toBe(false);
|
||||
});
|
||||
|
||||
it("所有文档完成时 planCompleted 和 buildUnlocked 为 true", async () => {
|
||||
const changesDir = join(TMP_DIR, ".rune", "changes");
|
||||
await mkdir(join(changesDir, "user-auth"), { recursive: true });
|
||||
await writeFile(join(changesDir, "user-auth", "design.md"), "# 设计");
|
||||
await writeFile(join(changesDir, "user-auth", "task.md"), "- [ ] 任务");
|
||||
|
||||
const config: RuneConfig = {
|
||||
stages: {
|
||||
plan: {
|
||||
documents: [
|
||||
{ name: "design", prompt: "生成设计" },
|
||||
{ name: "task", prompt: "生成任务" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const changes = await scanChanges(TMP_DIR, config);
|
||||
expect(changes[0].planCompleted).toBe(true);
|
||||
expect(changes[0].buildUnlocked).toBe(true);
|
||||
});
|
||||
|
||||
it("无 config 时使用文件扫描兼容模式", async () => {
|
||||
const changesDir = join(TMP_DIR, ".rune", "changes");
|
||||
await mkdir(join(changesDir, "feature-a"), { recursive: true });
|
||||
await writeFile(join(changesDir, "feature-a", "design.md"), "# 设计");
|
||||
|
||||
const changes = await scanChanges(TMP_DIR);
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0].documents[0].name).toBe("design");
|
||||
expect(changes[0].documents[0].completed).toBe(true);
|
||||
expect(changes[0].planCompleted).toBe(false);
|
||||
expect(changes[0].buildUnlocked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scanArchives", () => {
|
||||
|
||||
@@ -14,6 +14,34 @@ describe("defaultConfig", () => {
|
||||
expect(typeof defaultConfig.stages.discuss!.prompt).toBe("string");
|
||||
});
|
||||
|
||||
it("discuss 默认提示词包含关键章节", () => {
|
||||
const prompt = defaultConfig.stages.discuss!.prompt;
|
||||
expect(prompt).toContain("探索模式");
|
||||
expect(prompt).toContain("立场");
|
||||
expect(prompt).toContain("你可能做的事");
|
||||
expect(prompt).toContain("Rune 感知");
|
||||
expect(prompt).toContain("你不必做的事");
|
||||
expect(prompt).toContain("结束探索");
|
||||
expect(prompt).toContain("护栏");
|
||||
expect(prompt).toContain("典型场景");
|
||||
});
|
||||
|
||||
it("discuss 默认提示词不包含 OpenSpec 术语", () => {
|
||||
const prompt = defaultConfig.stages.discuss!.prompt;
|
||||
expect(prompt).not.toContain("openspec");
|
||||
expect(prompt).not.toContain("/opsx:");
|
||||
expect(prompt).not.toContain("proposal.md");
|
||||
expect(prompt).not.toContain("specs/");
|
||||
});
|
||||
|
||||
it("discuss 默认提示词包含正确 Rune 术语", () => {
|
||||
const prompt = defaultConfig.stages.discuss!.prompt;
|
||||
expect(prompt).toContain("/rune-plan");
|
||||
expect(prompt).toContain("rune status");
|
||||
expect(prompt).toContain("design.md");
|
||||
expect(prompt).toContain("task.md");
|
||||
});
|
||||
|
||||
it("plan 阶段包含 design 和 task 两个文档配置", () => {
|
||||
const docs = defaultConfig.stages.plan!.documents;
|
||||
expect(docs).toHaveLength(2);
|
||||
@@ -25,25 +53,24 @@ describe("defaultConfig", () => {
|
||||
});
|
||||
|
||||
it("plan 的 task 文档配置存在", () => {
|
||||
const taskDoc = defaultConfig.stages.plan!.documents.find(
|
||||
(d) => d.name === "task",
|
||||
);
|
||||
const taskDoc = defaultConfig.stages.plan!.documents.find((d) => d.name === "task");
|
||||
expect(taskDoc).toBeDefined();
|
||||
expect(taskDoc!.prompt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("task 文档依赖 design", () => {
|
||||
const taskDoc = defaultConfig.stages.plan!.documents.find((d) => d.name === "task");
|
||||
expect(taskDoc!.depend).toEqual(["design"]);
|
||||
});
|
||||
|
||||
it("design 文档有 template", () => {
|
||||
const designDoc = defaultConfig.stages.plan!.documents.find(
|
||||
(d) => d.name === "design",
|
||||
);
|
||||
const designDoc = defaultConfig.stages.plan!.documents.find((d) => d.name === "design");
|
||||
expect(designDoc!.template).toBeTruthy();
|
||||
expect(designDoc!.template).toContain("{{change-name}}");
|
||||
});
|
||||
|
||||
it("task 文档有 template", () => {
|
||||
const taskDoc = defaultConfig.stages.plan!.documents.find(
|
||||
(d) => d.name === "task",
|
||||
);
|
||||
const taskDoc = defaultConfig.stages.plan!.documents.find((d) => d.name === "task");
|
||||
expect(taskDoc!.template).toBeTruthy();
|
||||
expect(taskDoc!.template).toContain("- [ ]");
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { mkdir, writeFile, rm, 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";
|
||||
@@ -30,21 +30,24 @@ describe("完整 SDD 流程", () => {
|
||||
|
||||
const config = await loadConfig(TMP_DIR);
|
||||
const discussPrompt = assembleDiscussPrompt(config);
|
||||
expect(discussPrompt).toContain("软件架构师");
|
||||
expect(discussPrompt).toContain("探索模式");
|
||||
|
||||
const changeName = "user-auth";
|
||||
await mkdir(getChangeDir(TMP_DIR, changeName), { recursive: true });
|
||||
const planPrompt = await assemblePlanPrompt(config, TMP_DIR, changeName);
|
||||
const planPrompt = await assemblePlanPrompt(config, TMP_DIR, changeName, "design");
|
||||
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);
|
||||
const changes = await scanChanges(TMP_DIR, config);
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0].name).toBe("user-auth");
|
||||
expect(changes[0].taskProgress).toEqual({ completed: 0, total: 2 });
|
||||
expect(changes[0].planCompleted).toBe(true);
|
||||
expect(changes[0].buildUnlocked).toBe(true);
|
||||
expect(changes[0].documents.length).toBeGreaterThan(0);
|
||||
|
||||
const buildPrompt = await assembleBuildPrompt(config, TMP_DIR, changeName);
|
||||
expect(buildPrompt).toContain("实现登录 API");
|
||||
@@ -52,13 +55,15 @@ describe("完整 SDD 流程", () => {
|
||||
|
||||
await writeFile(join(changeDir, "task.md"), "- [x] 实现登录 API\n- [x] 编写登录测试");
|
||||
|
||||
const updatedChanges = await scanChanges(TMP_DIR);
|
||||
const updatedChanges = await scanChanges(TMP_DIR, config);
|
||||
expect(updatedChanges[0].taskProgress).toEqual({ completed: 2, total: 2 });
|
||||
expect(updatedChanges[0].planCompleted).toBe(true);
|
||||
expect(updatedChanges[0].buildUnlocked).toBe(true);
|
||||
|
||||
const buildPrompt2 = await assembleBuildPrompt(config, TMP_DIR, changeName);
|
||||
expect(buildPrompt2).toContain("已完成");
|
||||
|
||||
const archivePrompt = assembleArchivePrompt(config, changeName);
|
||||
const archivePrompt = await assembleArchivePrompt(config, TMP_DIR, changeName);
|
||||
expect(archivePrompt).toContain("归档");
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const src = getChangeDir(TMP_DIR, changeName);
|
||||
@@ -72,7 +77,7 @@ describe("完整 SDD 流程", () => {
|
||||
const archives = await scanArchives(TMP_DIR);
|
||||
expect(archives).toContain(`${today}-${changeName}`);
|
||||
|
||||
const postArchiveChanges = await scanChanges(TMP_DIR);
|
||||
const postArchiveChanges = await scanChanges(TMP_DIR, config);
|
||||
expect(postArchiveChanges).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -86,7 +91,7 @@ describe("完整 SDD 流程", () => {
|
||||
await writeFile(join(changeDir, "task.md"), `- [ ] ${name} 任务`);
|
||||
}
|
||||
|
||||
const changes = await scanChanges(TMP_DIR);
|
||||
const changes = await scanChanges(TMP_DIR, config);
|
||||
expect(changes).toHaveLength(2);
|
||||
|
||||
const authPrompt = await assembleBuildPrompt(config, TMP_DIR, "auth");
|
||||
@@ -118,7 +123,7 @@ describe("完整 SDD 流程", () => {
|
||||
expect(discussPrompt).toBe("自定义讨论");
|
||||
|
||||
await mkdir(getChangeDir(TMP_DIR, "test"), { recursive: true });
|
||||
const planPrompt = await assemblePlanPrompt(config, TMP_DIR, "test");
|
||||
const planPrompt = await assemblePlanPrompt(config, TMP_DIR, "test", "spec");
|
||||
expect(planPrompt).toContain("spec");
|
||||
expect(planPrompt).toContain("test 规格");
|
||||
expect(planPrompt).not.toContain("design");
|
||||
@@ -126,4 +131,89 @@ describe("完整 SDD 流程", () => {
|
||||
expect(config.stages.build).toBeDefined();
|
||||
expect(config.stages.archive).toBeDefined();
|
||||
});
|
||||
|
||||
it("scanChanges 返回文档依赖状态", async () => {
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
const config = await loadConfig(TMP_DIR);
|
||||
|
||||
const changeDir = getChangeDir(TMP_DIR, "dep-test");
|
||||
await mkdir(changeDir, { recursive: true });
|
||||
await writeFile(join(changeDir, "design.md"), "# 设计文档");
|
||||
|
||||
const changes = await scanChanges(TMP_DIR, config);
|
||||
expect(changes).toHaveLength(1);
|
||||
|
||||
const designDoc = changes[0].documents.find((d) => d.name === "design");
|
||||
expect(designDoc).toBeDefined();
|
||||
expect(designDoc!.completed).toBe(true);
|
||||
|
||||
const taskDoc = changes[0].documents.find((d) => d.name === "task");
|
||||
expect(taskDoc).toBeDefined();
|
||||
expect(taskDoc!.completed).toBe(false);
|
||||
expect(taskDoc!.dependMet).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("变更名校验", () => {
|
||||
it("合法变更名(中文、英文、短横线)通过校验", async () => {
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
const config = await loadConfig(TMP_DIR);
|
||||
await mkdir(getChangeDir(TMP_DIR, "用户-login"), { recursive: true });
|
||||
await writeFile(join(getChangeDir(TMP_DIR, "用户-login"), "design.md"), "# 设计");
|
||||
await writeFile(join(getChangeDir(TMP_DIR, "用户-login"), "task.md"), "- [ ] 任务");
|
||||
const prompt = await assemblePlanPrompt(config, TMP_DIR, "用户-login", "design");
|
||||
expect(prompt).toContain("用户-login");
|
||||
});
|
||||
|
||||
it("非法变更名(空格、下划线、特殊符号)被拒绝", () => {
|
||||
const validRegex = /^[\u4e00-\u9fa5a-zA-Z-]+$/;
|
||||
expect(validRegex.test("my change")).toBe(false);
|
||||
expect(validRegex.test("my_change")).toBe(false);
|
||||
expect(validRegex.test("my-change!")).toBe(false);
|
||||
expect(validRegex.test("my.change")).toBe(false);
|
||||
expect(validRegex.test("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("archive 校验", () => {
|
||||
it("task 未全部完成时注入警告提示词", async () => {
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
const config = await loadConfig(TMP_DIR);
|
||||
|
||||
const changeDir = getChangeDir(TMP_DIR, "incomplete-task");
|
||||
await mkdir(changeDir, { recursive: true });
|
||||
await writeFile(join(changeDir, "design.md"), "# 设计");
|
||||
await writeFile(join(changeDir, "task.md"), "- [x] 已完成任务\n- [ ] 未完成任务");
|
||||
|
||||
const prompt = await assembleArchivePrompt(config, TMP_DIR, "incomplete-task");
|
||||
expect(prompt).toContain("警告");
|
||||
expect(prompt).toContain("未完成任务");
|
||||
expect(prompt).toContain("是否确认");
|
||||
});
|
||||
|
||||
it("task 全部完成时不注入警告", async () => {
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
const config = await loadConfig(TMP_DIR);
|
||||
|
||||
const changeDir = getChangeDir(TMP_DIR, "complete-task");
|
||||
await mkdir(changeDir, { recursive: true });
|
||||
await writeFile(join(changeDir, "design.md"), "# 设计");
|
||||
await writeFile(join(changeDir, "task.md"), "- [x] 已完成任务");
|
||||
|
||||
const prompt = await assembleArchivePrompt(config, TMP_DIR, "complete-task");
|
||||
expect(prompt).not.toContain("警告");
|
||||
expect(prompt).toContain("归档阶段");
|
||||
});
|
||||
|
||||
it("task.md 不存在时不追加警告", async () => {
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
const config = await loadConfig(TMP_DIR);
|
||||
|
||||
const changeDir = getChangeDir(TMP_DIR, "no-task");
|
||||
await mkdir(changeDir, { recursive: true });
|
||||
await writeFile(join(changeDir, "design.md"), "# 设计");
|
||||
|
||||
const prompt = await assembleArchivePrompt(config, TMP_DIR, "no-task");
|
||||
expect(prompt).not.toContain("警告");
|
||||
});
|
||||
});
|
||||
|
||||
55
tests/scripts/release.test.ts
Normal file
55
tests/scripts/release.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { parseSemver, bumpVersion } from "../../scripts/release";
|
||||
|
||||
describe("parseSemver", () => {
|
||||
it("解析标准版本号 1.2.3", () => {
|
||||
expect(parseSemver("1.2.3")).toEqual({ major: 1, minor: 2, patch: 3 });
|
||||
});
|
||||
it("解析 0.0.0", () => {
|
||||
expect(parseSemver("0.0.0")).toEqual({ major: 0, minor: 0, patch: 0 });
|
||||
});
|
||||
it("解析大版本号 99.999.1", () => {
|
||||
expect(parseSemver("99.999.1")).toEqual({ major: 99, minor: 999, patch: 1 });
|
||||
});
|
||||
it("非三位格式抛出异常", () => {
|
||||
expect(() => parseSemver("1.2")).toThrow("无效的版本号格式");
|
||||
expect(() => parseSemver("1.2.3.4")).toThrow("无效的版本号格式");
|
||||
});
|
||||
it("非数字格式抛出异常", () => {
|
||||
expect(() => parseSemver("a.b.c")).toThrow("无效的版本号格式");
|
||||
expect(() => parseSemver("1.2.3-beta")).toThrow("无效的版本号格式");
|
||||
});
|
||||
it("负数抛出异常", () => {
|
||||
expect(() => parseSemver("-1.2.3")).toThrow("无效的版本号格式");
|
||||
});
|
||||
it("四段格式抛出异常", () => {
|
||||
expect(() => parseSemver("1.5.3.2")).toThrow("无效的版本号格式");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bumpVersion", () => {
|
||||
it("major 递增:0.1.0 → 1.0.0", () => {
|
||||
expect(bumpVersion("0.1.0", "major")).toBe("1.0.0");
|
||||
});
|
||||
it("minor 递增:0.1.0 → 0.2.0", () => {
|
||||
expect(bumpVersion("0.1.0", "minor")).toBe("0.2.0");
|
||||
});
|
||||
it("patch 递增:0.1.0 → 0.1.1", () => {
|
||||
expect(bumpVersion("0.1.0", "patch")).toBe("0.1.1");
|
||||
});
|
||||
it("major 递增低位归零:1.5.3 → 2.0.0", () => {
|
||||
expect(bumpVersion("1.5.3", "major")).toBe("2.0.0");
|
||||
});
|
||||
it("minor 递增 patch 归零:1.5.3 → 1.6.0", () => {
|
||||
expect(bumpVersion("1.5.3", "minor")).toBe("1.6.0");
|
||||
});
|
||||
it("patch 递增不影响高位:1.5.3 → 1.5.4", () => {
|
||||
expect(bumpVersion("1.5.3", "patch")).toBe("1.5.4");
|
||||
});
|
||||
it("大数字递增正确", () => {
|
||||
expect(bumpVersion("99.999.1", "minor")).toBe("99.1000.0");
|
||||
});
|
||||
it("无效版本号抛出异常", () => {
|
||||
expect(() => bumpVersion("invalid", "minor")).toThrow("无效的版本号格式");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user