Compare commits
117 Commits
03b0c60fb6
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 15ad256d1e | |||
| 3cac492e78 | |||
| ecccf5eef0 | |||
| ddd1c41c00 | |||
| cfda7f1b48 | |||
| bc993d4ead | |||
| b29cb65cf5 | |||
| 098c111774 | |||
| 115d5b125e | |||
| e5bc3adc62 | |||
| 7951d9a82b | |||
| 803533a7e0 | |||
| b709a01df3 | |||
| add8c7c0ea | |||
| 824969ea25 | |||
| e0a54558a8 | |||
| cb34b5a3f3 | |||
| c4f83a3753 | |||
| 289a7c6633 | |||
| 2552412f77 | |||
| 4d206f39cc | |||
| a75652c595 | |||
| 448b336c7f | |||
| 81d27ea1e8 | |||
| 0892ef885c | |||
| ca1738785a | |||
| 7d3ca2a150 | |||
| ec090ea971 | |||
| 932ee45f94 | |||
| a4c3a245c3 | |||
| 277e78812c | |||
| 4d30604bf0 | |||
| 72bf4ac71b | |||
| 8573d2abc8 | |||
| 1f6e49e336 | |||
| 49f523146f | |||
| cb537b4f2a | |||
| ed4da9b6a0 | |||
| daec0612c4 | |||
| b9ea668383 | |||
| a926200613 | |||
| 8d6f9dbad6 | |||
| 03fabd8ab9 | |||
| e3067d017e | |||
| 641d23b7c8 | |||
| 3789d0a7b3 | |||
| 0c89b3ebb2 | |||
| 8e00e2cdf1 | |||
| 7d5af32ce5 | |||
| af58f786af | |||
| 93bfa4373a | |||
| 7e260291f0 | |||
| 983a21acb6 | |||
| 78caec6449 | |||
| 2feea7a74f | |||
| ce00751585 | |||
| 589eaa120e | |||
| a5c8263412 | |||
| 909c29db25 | |||
| c9e2ff1c42 | |||
| 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
.gitignore
vendored
1
.gitignore
vendored
@@ -404,3 +404,4 @@ temp
|
||||
.worktrees
|
||||
!src/**/*
|
||||
docs/superpowers
|
||||
tests/**/__tmp*
|
||||
|
||||
5
.husky/pre-commit
Normal file
5
.husky/pre-commit
Normal file
@@ -0,0 +1,5 @@
|
||||
if git diff --cached --name-only | grep -q "^docs/superpowers/"; then
|
||||
echo "错误: 禁止提交 docs/superpowers/ 目录下的文件" >&2
|
||||
exit 1
|
||||
fi
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
- README.md 记录用户使用方法,DEVELOPMENT.md 记录开发技术细节
|
||||
- 使用中文(注释、文档、交流),面向中文开发者
|
||||
- 本项目无需考虑向前兼容性
|
||||
- temp目录下是手动测试使用的临时文件夹,不需要参与扫描和设计决策
|
||||
|
||||
## 全局红线
|
||||
|
||||
|
||||
@@ -24,12 +24,16 @@ src/
|
||||
│ ├── config.ts # 配置加载
|
||||
│ ├── scanner.ts # 状态扫描
|
||||
│ ├── assembler.ts # 提示词拼装
|
||||
│ └── task-parser.ts # 任务解析
|
||||
│ ├── task-parser.ts # 任务解析
|
||||
│ └── pm.ts # 包管理器检测与命令前缀
|
||||
├── adapters/
|
||||
│ ├── opencode.ts # OpenCode 适配器
|
||||
│ └── claude-code.ts # Claude Code 适配器(占位)
|
||||
└── defaults/
|
||||
└── config.ts # 内置默认配置
|
||||
│ ├── claude-code.ts # Claude Code 适配器
|
||||
│ └── utils.ts # 适配器工具函数
|
||||
├── defaults/
|
||||
│ └── config.ts # 内置默认配置
|
||||
scripts/
|
||||
└── release.ts # 发布脚本
|
||||
|
||||
tests/ # 测试目录(镜像 src 结构)
|
||||
```
|
||||
@@ -37,14 +41,35 @@ tests/ # 测试目录(镜像 src 结构)
|
||||
## 开发命令
|
||||
|
||||
```bash
|
||||
bun test # 运行全部测试
|
||||
bun test # 运行单元/集成测试
|
||||
bun test tests/core/ # 运行指定目录测试
|
||||
bun run release # 发布新版本(交互式递增版本号、测试门禁、git commit+tag、npm publish)
|
||||
bun src/cli.ts init opencode # 测试 init 命令
|
||||
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 # 查看版本号
|
||||
```
|
||||
|
||||
### 代码质量
|
||||
|
||||
项目使用 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 +88,55 @@ CLI 通过子命令提供帮助和版本信息,不使用 `--help`/`--version`
|
||||
- `src/cli/output.ts` — 格式化输出(`错误:`、`用法:`、`提示:` 三段式)
|
||||
- `src/cli/help.ts` — 帮助文本生成
|
||||
|
||||
## 设计决策
|
||||
|
||||
### 阶段与配置
|
||||
|
||||
- **五阶段固定**:discuss → plan → task → build → archive,不可自定义增删
|
||||
- **配置覆盖策略**:阶段级别全量覆盖,不支持字段级合并。自定义 plan 时需完整重写所有 documents
|
||||
- **配置文件名**:`.rune/config.yaml`,不是 `rune.yml`
|
||||
- **文档模板**:纯静态文本模板,不进行变量替换
|
||||
|
||||
### 各阶段行为
|
||||
|
||||
- **discuss**:不持久化讨论结果,完全依赖 AI 会话上下文传递;不设强制门控,通过提示词引导。讨论结束时引导用户运行 `rune create` 创建变更目录
|
||||
- **plan**:命令只输出提示词,不写入文件;AI 负责根据提示词生成文档内容并写入。新变更时引导用户先运行 `rune create` 创建目录。重复调用同一文档的 plan 会追加已有内容用于增量修订。依赖未满足时有友好提示(非报错)
|
||||
- **task**:根据规划文档内容拆分为 checkbox 任务清单;格式固定,不可自定义模板;规划文档必须全部完成才能进入
|
||||
- **build**:按 task.md 的 checkbox 顺序执行;任务间无结构化依赖;可多次执行直到全部完成
|
||||
- **archive**:输出归档提示词(含未完成任务的警告),引导 AI 汇总变更并确认。`finish` 命令执行实际的目录移动
|
||||
|
||||
**create**:CLI 辅助命令(非独立阶段),在 `.rune/changes/` 下创建变更目录。adapter 不为 create 生成独立的 skill/command 文件,使用引导嵌入在 discuss 和 plan 的 skill/command 内容中。
|
||||
|
||||
### 变更名校验
|
||||
|
||||
变更名仅支持中文、英文、短横线(`-`),不支持空格、下划线等其他符号。
|
||||
|
||||
### update 命令
|
||||
|
||||
`rune update <tool>` 对比已注入的命令/skill 文件内容与内置版本,不一致则覆盖,不存在则新建。用于升级 Rune 后更新编辑器配置。
|
||||
|
||||
### 其他决策
|
||||
|
||||
- 无跨变更依赖,变更之间完全独立
|
||||
- 无并发锁,同一变更可被多个 agent 同时操作
|
||||
- 无需变更废弃命令,手动删除目录即可
|
||||
- 同一变更名同天多次归档不处理冲突(日期+名称去重)
|
||||
- archive 与 finish 分离:archive 只输出提示词,finish 执行实际的目录移动。分离原因是提示词阶段需要 AI 参与确认,而文件操作是确定性的
|
||||
- plan skill 应引导 AI 先通过 `rune status` 获取文档列表
|
||||
|
||||
## 测试策略
|
||||
|
||||
### Level 1 — 纯单元/集成测试(当前)
|
||||
### 单元/集成测试(`bun test`)
|
||||
|
||||
在临时目录执行完整流程,验证文件创建、目录结构、提示词输出。
|
||||
在临时目录执行完整流程,验证文件创建、目录结构、提示词输出。覆盖 `src/core/`、`src/cli/`、`src/adapters/`、`tests/integration/`。
|
||||
|
||||
### Level 2 — 提示词快照测试(后续增强)
|
||||
## 发布流程
|
||||
|
||||
对每个阶段捕获提示词输出,与预期快照对比。
|
||||
`bun run release` 交互式发布新版本到 npm:
|
||||
|
||||
### Level 3 — mock-agent 端到端(后续增强)
|
||||
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`
|
||||
|
||||
编排完整闭环:rune 输出 → mock-agent 处理 → rune 继续下一阶段。
|
||||
|
||||
### Level 4 — 真实 AI 工具集成(CI 可选)
|
||||
|
||||
调用 LLM API 验证输出格式可被解析。
|
||||
发布前确保已通过 `npm login` 登录 npm,且 npm 账号有 `@lanyuanxiaoyao` scope 的发布权限。
|
||||
|
||||
89
README.md
89
README.md
@@ -5,45 +5,108 @@
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
bunx rune init opencode
|
||||
bunx @lanyuanxiaoyao/rune init opencode
|
||||
```
|
||||
|
||||
如果没有安装 bun,可使用 `pnpx @lanyuanxiaoyao/rune` 或 `npx @lanyuanxiaoyao/rune` 替代。
|
||||
|
||||
## 使用
|
||||
|
||||
### 初始化
|
||||
|
||||
```bash
|
||||
bunx rune init opencode
|
||||
bunx @lanyuanxiaoyao/rune init opencode # OpenCode 编辑器
|
||||
bunx @lanyuanxiaoyao/rune init claude-code # Claude Code 编辑器
|
||||
```
|
||||
|
||||
会在项目中创建:
|
||||
|
||||
- `.rune/` 目录(配置、变更文档、归档)
|
||||
- `.opencode/commands/` 和 `.opencode/skills/`(注入的 AI 工具配置)
|
||||
- 编辑器对应的 command 和 skill 文件(如 `.opencode/commands/`、`.opencode/skills/`)
|
||||
|
||||
### 更新编辑器配置
|
||||
|
||||
当 Rune 版本升级后,需要更新已注入的命令和 skill 文件:
|
||||
|
||||
```bash
|
||||
bunx @lanyuanxiaoyao/rune update opencode # 更新 OpenCode 的命令和 skill
|
||||
bunx @lanyuanxiaoyao/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`(设计文档)一个文档。文档间支持 `depend` 字段声明前置依赖,依赖未满足时有友好提示。plan 命令自身不写入文件,只输出提示词供 AI 消费。
|
||||
3. **任务拆解阶段** — `/rune-task <变更名>`:根据规划阶段生成的文档内容,拆分为 checkbox 格式的任务清单(task.md)。规划阶段的所有文档必须已完成。格式固定为 checkbox 列表(`- [ ] 待完成`、`- [x] 已完成`),不可自定义模板。
|
||||
4. **构建阶段** — `/rune-build <变更名>`:按 task.md 中的任务顺序逐个实现。每个任务完成后更新对应的 checkbox 为 `[x]`。可多次执行直到所有任务完成。
|
||||
5. **归档阶段** — `/rune-archive <变更名>`:输出归档阶段提示词,引导 AI 汇总变更内容并确认归档。归档前自动检查 task.md 的完成状态,如有未完成任务会注入警告提示词,引导 AI 询问用户是否确认。确认后执行 `rune finish <变更名>` 将变更目录移动到 `archive/`。
|
||||
|
||||
> **辅助命令**:`rune create <变更名>` 用于在 `.rune/changes/` 下创建变更目录。它不是 SDD 阶段,而是在 discuss 结束后或 plan 开始前通过 CLI 运行的辅助命令。discuss 和 plan 的编辑器命令中已内嵌 create 的使用引导。
|
||||
|
||||
### 状态查看
|
||||
|
||||
```bash
|
||||
rune status
|
||||
bunx @lanyuanxiaoyao/rune status # 查看所有变更(含各阶段文档完成状态、下一步建议)
|
||||
bunx @lanyuanxiaoyao/rune status <变更名> # 查看指定变更的详细状态
|
||||
```
|
||||
|
||||
### 帮助与版本
|
||||
规划阶段应引导 AI 先通过 `bunx @lanyuanxiaoyao/rune status` 获取当前有哪些文档需要编写。
|
||||
|
||||
### 命令参考
|
||||
|
||||
```bash
|
||||
rune help # 显示全局帮助
|
||||
rune help <command> # 显示指定命令的详细帮助
|
||||
rune version # 显示版本号
|
||||
bunx @lanyuanxiaoyao/rune help # 显示全局帮助
|
||||
bunx @lanyuanxiaoyao/rune help <command> # 显示指定命令的详细帮助
|
||||
bunx @lanyuanxiaoyao/rune version # 显示版本号
|
||||
```
|
||||
|
||||
| 命令 | 说明 |
|
||||
| -------------------------------------------------- | ---------------------------------------------- |
|
||||
| `bunx @lanyuanxiaoyao/rune init <tool>` | 初始化项目,注入编辑器配置 |
|
||||
| `bunx @lanyuanxiaoyao/rune update <tool>` | 更新编辑器的命令和 skill 文件 |
|
||||
| `bunx @lanyuanxiaoyao/rune discuss` | 输出讨论阶段提示词 |
|
||||
| `bunx @lanyuanxiaoyao/rune create <变更名>` | 创建变更目录(discuss 和 plan 之间的辅助命令) |
|
||||
| `bunx @lanyuanxiaoyao/rune plan <变更名> <文档名>` | 输出规划阶段提示词 |
|
||||
| `bunx @lanyuanxiaoyao/rune task <变更名>` | 输出任务拆解阶段提示词 |
|
||||
| `bunx @lanyuanxiaoyao/rune build <变更名>` | 输出构建阶段提示词 |
|
||||
| `bunx @lanyuanxiaoyao/rune archive <变更名>` | 输出归档阶段提示词 |
|
||||
| `bunx @lanyuanxiaoyao/rune finish <变更名>` | 归档变更(将变更目录移动到 archive/) |
|
||||
| `bunx @lanyuanxiaoyao/rune status [变更名]` | 显示变更状态和下一步建议 |
|
||||
|
||||
### 自定义配置
|
||||
|
||||
编辑 `.rune/config.yaml` 自定义提示词和文档模板。配置文件默认为空,使用内置默认策略;仅覆盖需要自定义的阶段,未配置的阶段使用内置默认配置。
|
||||
编辑 `.rune/config.yaml` 自定义各阶段的提示词和文档模板。配置合并采用阶段级别全量覆盖策略:自定义某个阶段时需完整重写该阶段的配置,未配置的阶段使用内置默认配置。
|
||||
|
||||
规划阶段的文档支持 `depend` 字段声明前置依赖:
|
||||
|
||||
```yaml
|
||||
stages:
|
||||
plan:
|
||||
documents:
|
||||
- name: design
|
||||
prompt: 生成设计文档,包含背景、目标、方案、接口和注意事项
|
||||
- name: api
|
||||
prompt: 生成 API 设计文档
|
||||
depend: [design]
|
||||
```
|
||||
|
||||
计划阶段文档模板为纯静态文本,直接输出不作变量替换。
|
||||
|
||||
## 设计决策
|
||||
|
||||
- **五阶段固定**:discuss → plan → task → 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,17 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugin": [
|
||||
"superpowers@git+https://github.com/obra/superpowers.git"
|
||||
]
|
||||
"plugin": ["superpowers@git+https://github.com/obra/superpowers.git"],
|
||||
"permission": {
|
||||
"bash": {
|
||||
"git add -f *": "deny",
|
||||
"git add --force *": "deny",
|
||||
"git push * --force *": "deny",
|
||||
"git push * -f *": "deny",
|
||||
"git push --force-with-lease *": "deny",
|
||||
"git reset --hard *": "deny",
|
||||
"git clean -f *": "deny",
|
||||
"git checkout -- *": "deny",
|
||||
"git restore --staged *": "deny"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
45
package.json
45
package.json
@@ -1,20 +1,45 @@
|
||||
{
|
||||
"name": "rune",
|
||||
"version": "0.1.0",
|
||||
"module": "src/cli.ts",
|
||||
"type": "module",
|
||||
"name": "@lanyuanxiaoyao/rune",
|
||||
"version": "0.1.5",
|
||||
"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",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
241
scripts/release.ts
Normal file
241
scripts/release.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
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"], {
|
||||
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") {
|
||||
throw new CancelledError("已取消发布");
|
||||
}
|
||||
|
||||
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 revertVersion(): Promise<void> {
|
||||
console.log("\n回退 package.json 到原始状态...");
|
||||
const proc = Bun.spawn(["git", "checkout", "package.json"], {
|
||||
stdio: ["inherit", "inherit", "inherit"],
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode !== 0) {
|
||||
throw new Error("git checkout package.json 失败,请手动检查工作区状态");
|
||||
}
|
||||
}
|
||||
|
||||
class CancelledError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "CancelledError";
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
try {
|
||||
await stepNpmPublish();
|
||||
} catch (err) {
|
||||
if (err instanceof CancelledError) {
|
||||
console.log(err.message);
|
||||
await revertVersion();
|
||||
console.log("工作区已恢复,无事发生");
|
||||
process.exit(0);
|
||||
}
|
||||
console.error("npm 发布失败,正在回退...");
|
||||
await revertVersion();
|
||||
throw err;
|
||||
}
|
||||
|
||||
await stepGitCommitTag(newVersion);
|
||||
console.log(`[3/3] git commit 和 tag v${newVersion} 完成`);
|
||||
|
||||
console.log("npm 发布完成");
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
main().catch((err: unknown) => {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -2,36 +2,98 @@ 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";
|
||||
|
||||
export async function injectClaudeCode(projectRoot: string): Promise<void> {
|
||||
const STAGES_WITH_CHANGE_NAME = new Set(["plan", "task", "build", "archive"]);
|
||||
|
||||
function buildSmartGuide(command: string): string {
|
||||
return `如果用户没有指定变更名称,请按以下步骤智能识别:
|
||||
1. 运行 \`${command} status\` 查看当前所有变更
|
||||
2. 如果只有一个变更,直接使用该变更名
|
||||
3. 如果有多个变更,根据上下文推断最可能的变更
|
||||
4. 如果无法确定,向用户确认`;
|
||||
}
|
||||
|
||||
export async function injectClaudeCode(
|
||||
projectRoot: string,
|
||||
command: string = "rune",
|
||||
): Promise<void> {
|
||||
for (const stage of STAGES) {
|
||||
const hasChangeName = stage !== "discuss";
|
||||
const hasChangeName = STAGES_WITH_CHANGE_NAME.has(stage);
|
||||
const cmd = hasChangeName ? `${command} ${stage} <变更名>` : `${command} ${stage}`;
|
||||
const smartGuide = hasChangeName ? `\n${buildSmartGuide(command)}\n` : "";
|
||||
|
||||
const commandDir = join(projectRoot, COMMANDS_DIR);
|
||||
await mkdir(commandDir, { recursive: true });
|
||||
const commandPath = join(commandDir, `rune-${stage}.md`);
|
||||
if (!existsSync(commandPath)) {
|
||||
const cmd = hasChangeName
|
||||
? `rune ${stage} <变更名>`
|
||||
: `rune ${stage}`;
|
||||
const nameHint = hasChangeName
|
||||
? "\n如果用户没有指定变更名称,请向用户确认。"
|
||||
: "";
|
||||
await writeFile(
|
||||
commandPath,
|
||||
`执行以下命令,将输出作为当前阶段的工作指引:\n\`\`\`bash\n${cmd}\n\`\`\`${nameHint}\n`,
|
||||
);
|
||||
let content = `执行以下命令,将输出作为当前阶段的工作指引:\n\`\`\`bash\n${cmd}\n\`\`\`${smartGuide}`;
|
||||
if (stage === "plan") {
|
||||
content += `\n如果变更目录尚不存在(新变更),请先运行 \`${command} create <变更名>\` 创建目录,再开始规划。`;
|
||||
}
|
||||
if (stage === "task") {
|
||||
content += `\n任务拆解前请确认规划文档已全部完成,运行 \`${command} status <变更名>\` 检查。`;
|
||||
}
|
||||
if (stage === "discuss") {
|
||||
content += `\n讨论结束后,如果确定了变更方向,请运行 \`${command} create <变更名>\` 创建变更目录,然后进入规划阶段。`;
|
||||
}
|
||||
await writeFile(commandPath, content + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
const introCommandPath = join(projectRoot, COMMANDS_DIR, "rune-intro.md");
|
||||
if (!existsSync(introCommandPath)) {
|
||||
await mkdir(join(projectRoot, COMMANDS_DIR), { recursive: true });
|
||||
await writeFile(introCommandPath, generateIntroCommand(command));
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateClaudeCode(
|
||||
projectRoot: string,
|
||||
command: string = "rune",
|
||||
): Promise<void> {
|
||||
for (const stage of STAGES) {
|
||||
const hasChangeName = STAGES_WITH_CHANGE_NAME.has(stage);
|
||||
const cmd = hasChangeName ? `${command} ${stage} <变更名>` : `${command} ${stage}`;
|
||||
const smartGuide = hasChangeName ? `\n${buildSmartGuide(command)}\n` : "";
|
||||
|
||||
const commandDir = join(projectRoot, COMMANDS_DIR);
|
||||
const statusPath = join(commandDir, "rune-status.md");
|
||||
if (!existsSync(statusPath)) {
|
||||
await writeFile(
|
||||
statusPath,
|
||||
`执行以下命令查看变更状态:\n\`\`\`bash\nrune status\n\`\`\`\n`,
|
||||
);
|
||||
await mkdir(commandDir, { recursive: true });
|
||||
const commandPath = join(commandDir, `rune-${stage}.md`);
|
||||
let newContent = `执行以下命令,将输出作为当前阶段的工作指引:\n\`\`\`bash\n${cmd}\n\`\`\`${smartGuide}`;
|
||||
if (stage === "plan") {
|
||||
newContent += `\n如果变更目录尚不存在(新变更),请先运行 \`${command} create <变更名>\` 创建目录,再开始规划。`;
|
||||
}
|
||||
if (stage === "task") {
|
||||
newContent += `\n任务拆解前请确认规划文档已全部完成,运行 \`${command} status <变更名>\` 检查。`;
|
||||
}
|
||||
if (stage === "discuss") {
|
||||
newContent += `\n讨论结束后,如果确定了变更方向,请运行 \`${command} create <变更名>\` 创建变更目录,然后进入规划阶段。`;
|
||||
}
|
||||
await writeIfChanged(commandPath, newContent + "\n");
|
||||
}
|
||||
|
||||
const introCommandPath = join(projectRoot, COMMANDS_DIR, "rune-intro.md");
|
||||
await writeIfChanged(introCommandPath, generateIntroCommand(command));
|
||||
}
|
||||
|
||||
function generateIntroCommand(command: string): string {
|
||||
return `Rune 是基于规格驱动开发(SDD)的 AI 开发辅助工具。SDD 工作流程:
|
||||
|
||||
discuss → plan → task → build → archive
|
||||
|
||||
可用命令:
|
||||
- /rune-discuss — 自由讨论需求和方案
|
||||
- /rune-plan — 生成设计文档(新变更需先运行 \`${command} create <变更名>\` 创建目录)
|
||||
- /rune-task — 根据设计文档生成任务清单
|
||||
- /rune-build — 按任务清单逐步实现
|
||||
- /rune-archive — 归档已完成的变更
|
||||
|
||||
查看当前状态:
|
||||
\`\`\`bash
|
||||
${command} status
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -2,66 +2,97 @@ 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 = ".opencode/commands";
|
||||
const SKILLS_DIR = ".opencode/skills";
|
||||
|
||||
export async function injectOpenCode(projectRoot: string): Promise<void> {
|
||||
for (const stage of STAGES) {
|
||||
const hasChangeName = stage !== "discuss";
|
||||
const STAGES_WITH_CHANGE_NAME = new Set(["plan", "task", "build", "archive"]);
|
||||
|
||||
export async function injectOpenCode(projectRoot: string, command: string = "rune"): Promise<void> {
|
||||
for (const stage of STAGES) {
|
||||
const commandDir = join(projectRoot, COMMANDS_DIR);
|
||||
await mkdir(commandDir, { recursive: true });
|
||||
const commandPath = join(commandDir, `rune-${stage}.md`);
|
||||
if (!existsSync(commandPath)) {
|
||||
await writeFile(commandPath, generateCommand(stage, hasChangeName));
|
||||
await writeFile(commandPath, generateCommand(stage));
|
||||
}
|
||||
|
||||
const skillStageDir = join(projectRoot, SKILLS_DIR, `rune-${stage}`);
|
||||
await mkdir(skillStageDir, { recursive: true });
|
||||
const skillPath = join(skillStageDir, "SKILL.md");
|
||||
if (!existsSync(skillPath)) {
|
||||
await writeFile(skillPath, generateSkill(stage, hasChangeName));
|
||||
await writeFile(skillPath, generateSkill(stage, command));
|
||||
}
|
||||
}
|
||||
|
||||
const introSkillDir = join(projectRoot, SKILLS_DIR, "rune-intro");
|
||||
await mkdir(introSkillDir, { recursive: true });
|
||||
const introSkillPath = join(introSkillDir, "SKILL.md");
|
||||
if (!existsSync(introSkillPath)) {
|
||||
await writeFile(introSkillPath, generateIntroSkill(command));
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateOpenCode(projectRoot: string, command: string = "rune"): Promise<void> {
|
||||
for (const stage of STAGES) {
|
||||
const commandDir = join(projectRoot, COMMANDS_DIR);
|
||||
const statusCommandPath = join(commandDir, "rune-status.md");
|
||||
if (!existsSync(statusCommandPath)) {
|
||||
await writeFile(statusCommandPath, generateStatusCommand());
|
||||
await mkdir(commandDir, { recursive: true });
|
||||
const commandPath = join(commandDir, `rune-${stage}.md`);
|
||||
await writeIfChanged(commandPath, generateCommand(stage));
|
||||
|
||||
const skillStageDir = join(projectRoot, SKILLS_DIR, `rune-${stage}`);
|
||||
await mkdir(skillStageDir, { recursive: true });
|
||||
const skillPath = join(skillStageDir, "SKILL.md");
|
||||
await writeIfChanged(skillPath, generateSkill(stage, command));
|
||||
}
|
||||
|
||||
const statusSkillDir = join(projectRoot, SKILLS_DIR, "rune-status");
|
||||
await mkdir(statusSkillDir, { recursive: true });
|
||||
const statusSkillPath = join(statusSkillDir, "SKILL.md");
|
||||
if (!existsSync(statusSkillPath)) {
|
||||
await writeFile(statusSkillPath, generateStatusSkill());
|
||||
}
|
||||
const introSkillDir = join(projectRoot, SKILLS_DIR, "rune-intro");
|
||||
await mkdir(introSkillDir, { recursive: true });
|
||||
const introSkillPath = join(introSkillDir, "SKILL.md");
|
||||
await writeIfChanged(introSkillPath, generateIntroSkill(command));
|
||||
}
|
||||
|
||||
function generateCommand(stage: string, hasChangeName: boolean): string {
|
||||
if (hasChangeName) {
|
||||
return `请调用 rune-${stage} skill 执行 ${stage} 阶段。
|
||||
|
||||
如果用户没有指定变更名称,请向用户确认要操作的变更名称。
|
||||
`;
|
||||
}
|
||||
function generateCommand(stage: string): string {
|
||||
return `请调用 rune-${stage} skill 执行 ${stage} 阶段。
|
||||
`;
|
||||
}
|
||||
|
||||
function generateSkill(stage: string, hasChangeName: boolean): string {
|
||||
const cmd = hasChangeName ? `rune ${stage} <变更名>` : `rune ${stage}`;
|
||||
const nameHint = hasChangeName
|
||||
? `将 <变更名> 替换为实际的变更名称。\n`
|
||||
: "";
|
||||
function generateSkill(stage: string, command: string): string {
|
||||
const hasChangeName = STAGES_WITH_CHANGE_NAME.has(stage);
|
||||
const cmd = hasChangeName ? `${command} ${stage} <变更名>` : `${command} ${stage}`;
|
||||
|
||||
let extraGuide = "";
|
||||
if (stage === "plan") {
|
||||
extraGuide = `\n规划阶段应先运行 \`${command} status <变更名>\` 获取当前有哪些文档需要编写,再按依赖顺序逐个生成。\n\n如果变更目录尚不存在(新变更),请先运行 \`${command} create <变更名>\` 创建目录,再开始规划。\n`;
|
||||
}
|
||||
|
||||
if (stage === "task") {
|
||||
extraGuide = `\n任务拆解阶段应先运行 \`${command} status <变更名>\` 确认规划文档已全部完成,再生成任务清单。\n`;
|
||||
}
|
||||
|
||||
if (stage === "discuss") {
|
||||
extraGuide = `\n讨论结束后,如果确定了变更方向,请运行 \`${command} create <变更名>\` 创建变更目录,然后进入规划阶段。\n`;
|
||||
}
|
||||
|
||||
const descriptionMap: Record<string, string> = {
|
||||
discuss: "Use when 需要进入 SDD 讨论阶段,自由讨论需求和架构方案",
|
||||
plan: "Use when 需要进入 SDD 规划阶段,生成设计文档和任务清单",
|
||||
task: "Use when 需要进入 SDD 任务拆解阶段,根据设计文档生成任务清单",
|
||||
build: "Use when 需要进入 SDD 构建阶段,按任务清单逐步实现变更",
|
||||
archive: "Use when 需要进入 SDD 归档阶段,确认变更完成并归档",
|
||||
};
|
||||
|
||||
let smartGuide = "";
|
||||
if (hasChangeName) {
|
||||
smartGuide = `如果用户没有指定变更名称,请按以下步骤智能识别:
|
||||
1. 运行 \`${command} status\` 查看当前所有变更
|
||||
2. 如果只有一个变更,直接使用该变更名
|
||||
3. 如果有多个变更,根据上下文推断最可能的变更
|
||||
4. 如果无法确定,向用户确认
|
||||
`;
|
||||
}
|
||||
|
||||
return `---
|
||||
name: rune-${stage}
|
||||
description: ${descriptionMap[stage] ?? `Use when 需要执行 SDD ${stage} 阶段`}
|
||||
@@ -75,29 +106,49 @@ description: ${descriptionMap[stage] ?? `Use when 需要执行 SDD ${stage} 阶
|
||||
${cmd}
|
||||
\`\`\`
|
||||
|
||||
${nameHint}将命令输出作为工作指引,执行当前阶段的工作。
|
||||
${smartGuide}${extraGuide}将命令输出作为工作指引,执行当前阶段的工作。
|
||||
`;
|
||||
}
|
||||
|
||||
function generateStatusCommand(): string {
|
||||
return `请调用 rune-status skill 查看当前所有变更状态。
|
||||
`;
|
||||
}
|
||||
|
||||
function generateStatusSkill(): string {
|
||||
function generateIntroSkill(command: string): string {
|
||||
return `---
|
||||
name: rune-status
|
||||
description: Use when 需要查看当前所有 Rune 变更的状态和任务进度
|
||||
name: rune-intro
|
||||
description: Use when 用户首次接触 Rune,需要了解 SDD 工作流程和使用方式
|
||||
---
|
||||
|
||||
# 状态查看
|
||||
# Rune 简介
|
||||
|
||||
执行以下命令:
|
||||
Rune 是基于规格驱动开发(SDD)的 AI 开发辅助工具。它通过结构化的阶段流程,帮助 AI 编辑器和开发者高效协作。
|
||||
|
||||
\`\`\`bash
|
||||
rune status
|
||||
## SDD 工作流程
|
||||
|
||||
\`\`\`
|
||||
discuss → plan → task → build → archive
|
||||
探索 规划 任务 构建 归档
|
||||
\`\`\`
|
||||
|
||||
将命令输出展示给用户。
|
||||
## 可用命令
|
||||
|
||||
| 阶段 | 编辑器命令 | 说明 |
|
||||
|------|-----------|------|
|
||||
| discuss | /rune-discuss | 自由讨论需求和方案 |
|
||||
| plan | /rune-plan | 生成设计文档和任务清单 |
|
||||
| task | /rune-task | 根据设计文档生成任务清单 |
|
||||
| build | /rune-build | 按任务清单逐步实现 |
|
||||
| archive | /rune-archive | 归档已完成的变更 |
|
||||
|
||||
查看当前状态:
|
||||
|
||||
\`\`\`bash
|
||||
${command} status
|
||||
\`\`\`
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 使用 /rune-discuss 进入讨论,自由探索需求
|
||||
2. 讨论结束后,运行 \`${command} create <变更名>\` 创建变更目录,然后用 /rune-plan 生成设计文档
|
||||
3. 使用 /rune-task 根据设计文档生成任务清单
|
||||
4. 使用 /rune-build 按任务顺序实现功能
|
||||
5. 使用 /rune-archive 归档已完成的变更
|
||||
`;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
390
src/cli.ts
390
src/cli.ts
@@ -3,11 +3,12 @@ import { cac } from "cac";
|
||||
import { join } from "node:path";
|
||||
import { mkdir, rename } from "node:fs/promises";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { runInit } from "./commands/init.ts";
|
||||
import { runInit, ensureMetadataCommand } from "./commands/init.ts";
|
||||
import { findProjectRoot, loadConfig, getChangeDir, getArchiveDir } from "./core/config.ts";
|
||||
import {
|
||||
assembleDiscussPrompt,
|
||||
assemblePlanPrompt,
|
||||
assembleTaskPrompt,
|
||||
assembleBuildPrompt,
|
||||
assembleArchivePrompt,
|
||||
} from "./core/assembler.ts";
|
||||
@@ -15,52 +16,200 @@ 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 { getPmPrefix, DEFAULT_PREFIX, detectCommandPrefix } from "./core/pm.ts";
|
||||
import type { ChangeStatus, RuneConfig } from "./types.ts";
|
||||
|
||||
function requireProjectRoot(): string {
|
||||
const root = findProjectRoot();
|
||||
if (!root) {
|
||||
throw new ConfigError("当前项目未初始化", { hint: "请先运行 rune init" });
|
||||
const prefix = getPmPrefix();
|
||||
throw new ConfigError("当前项目未初始化", { hint: `请先运行 ${prefix} init` });
|
||||
}
|
||||
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, config)}`);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function suggestNextStep(change: ChangeStatus, config?: RuneConfig): string {
|
||||
const prefix = getPmPrefix(config);
|
||||
const planDocs = config?.stages.plan?.documents;
|
||||
if (!change.planCompleted) {
|
||||
const nextDoc = change.documents.find((d) => !d.completed && d.dependMet);
|
||||
if (nextDoc) {
|
||||
return `${prefix} plan ${change.name} ${nextDoc.name}`;
|
||||
}
|
||||
const firstMissingDep = change.documents
|
||||
.filter((d) => !d.completed && !d.dependMet)
|
||||
.map((d) => {
|
||||
const docConfig = planDocs?.find((c) => c.name === d.name);
|
||||
const missing =
|
||||
docConfig?.depend?.filter(
|
||||
(dep) => !change.documents.find((cd) => cd.name === dep)?.completed,
|
||||
) ?? [];
|
||||
return { name: d.name, missing };
|
||||
})
|
||||
.find((d) => d.missing.length > 0);
|
||||
if (firstMissingDep) {
|
||||
return `${prefix} plan ${change.name} ${firstMissingDep.missing[0]}(${firstMissingDep.name} 的前置依赖)`;
|
||||
}
|
||||
return `完成前置依赖后再规划文档`;
|
||||
}
|
||||
|
||||
if (!change.taskProgress) {
|
||||
return `${prefix} task ${change.name}`;
|
||||
}
|
||||
|
||||
if (change.taskProgress.completed < change.taskProgress.total) {
|
||||
return `${prefix} build ${change.name}`;
|
||||
}
|
||||
|
||||
return `${prefix} archive ${change.name}`;
|
||||
}
|
||||
|
||||
const cli = cac("rune");
|
||||
|
||||
cli.command("", "").action(() => {
|
||||
console.log(showGlobalHelp());
|
||||
cli.command("", "").action(async () => {
|
||||
const root = findProjectRoot();
|
||||
let prefix = getPmPrefix();
|
||||
if (root) {
|
||||
try {
|
||||
const config = await loadConfig(root);
|
||||
prefix = getPmPrefix(config);
|
||||
} catch {}
|
||||
}
|
||||
console.log(showGlobalHelp(prefix));
|
||||
});
|
||||
|
||||
cli.command("help [command]", "显示帮助信息").action((command?: string) => {
|
||||
cli.command("help [command]", "显示帮助信息").action(async (command?: string) => {
|
||||
const root = findProjectRoot();
|
||||
let prefix = getPmPrefix();
|
||||
if (root) {
|
||||
try {
|
||||
const config = await loadConfig(root);
|
||||
prefix = getPmPrefix(config);
|
||||
} catch {}
|
||||
}
|
||||
if (command) {
|
||||
const output = showCommandHelp(command);
|
||||
const output = showCommandHelp(command, prefix);
|
||||
if (!output) {
|
||||
throw new UsageError(`未知命令: ${command}`, {
|
||||
hint: "运行 rune help 查看所有命令",
|
||||
hint: `运行 ${prefix} help 查看所有命令`,
|
||||
});
|
||||
}
|
||||
console.log(output);
|
||||
} else {
|
||||
console.log(showGlobalHelp());
|
||||
console.log(showGlobalHelp(prefix));
|
||||
}
|
||||
});
|
||||
|
||||
cli.command("version", "显示版本号").action(() => {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(join(import.meta.dir, "../package.json"), "utf-8"));
|
||||
console.log(`rune v${pkg.version}`);
|
||||
} catch {
|
||||
console.log("rune (未知版本)");
|
||||
}
|
||||
});
|
||||
|
||||
cli.command("init [...tools]", "初始化 Rune 并注入工具配置").action(
|
||||
async (tools: string[]) => {
|
||||
cli.command("init [...tools]", "初始化 Rune 并注入工具配置").action(async (tools: string[]) => {
|
||||
const prefix = getPmPrefix();
|
||||
if (!tools || tools.length === 0) {
|
||||
throw new UsageError("请指定至少一个工具", {
|
||||
usage: "rune init <工具...>",
|
||||
hint: "如:rune init opencode",
|
||||
usage: `${prefix} init <工具...>`,
|
||||
hint: `如:${prefix} init opencode`,
|
||||
});
|
||||
}
|
||||
await runInit(process.cwd(), tools);
|
||||
console.log(`Rune 初始化完成,已注入工具:${tools.join(", ")}`);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
cli.command("update [...tools]", "更新已注入的工具配置").action(async (tools: string[]) => {
|
||||
const prefix = getPmPrefix();
|
||||
if (!tools || tools.length === 0) {
|
||||
throw new UsageError("请指定至少一个工具", {
|
||||
usage: `${prefix} update <工具...>`,
|
||||
hint: `如:${prefix} 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, command?: 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(", ")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
let config: RuneConfig | undefined;
|
||||
try {
|
||||
config = await loadConfig(root);
|
||||
} catch {}
|
||||
const command = getPmPrefix(config);
|
||||
if (!config?.metadata?.command) {
|
||||
const detected = await detectCommandPrefix();
|
||||
if (detected) {
|
||||
const { join: joinPath } = await import("node:path");
|
||||
const configPath = joinPath(root, ".rune", "config.yaml");
|
||||
try {
|
||||
await ensureMetadataCommand(configPath, detected);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
for (const tool of tools) {
|
||||
await updaters[tool](root, command === DEFAULT_PREFIX ? undefined : command);
|
||||
}
|
||||
console.log(`工具配置已更新:${tools.join(", ")}`);
|
||||
});
|
||||
|
||||
cli.command("discuss", "讨论阶段").action(async () => {
|
||||
const root = requireProjectRoot();
|
||||
@@ -69,96 +218,199 @@ cli.command("discuss", "讨论阶段").action(async () => {
|
||||
console.log(prompt);
|
||||
});
|
||||
|
||||
cli.command("plan <change-name>", "规划阶段").action(
|
||||
async (changeName: string) => {
|
||||
cli.command("create <change-name>", "创建变更").action(async (changeName: 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} 创建变更`,
|
||||
if (existsSync(changeDir)) {
|
||||
throw new CommandError(`变更 "${changeName}" 已存在`, {
|
||||
hint: `请使用其他名称,或运行 ${getPmPrefix(config)} status 查看现有变更`,
|
||||
});
|
||||
}
|
||||
await mkdir(changeDir, { recursive: true });
|
||||
const prefix = getPmPrefix(config);
|
||||
console.log(`变更 "${changeName}" 已创建。
|
||||
|
||||
下一步:${prefix} plan ${changeName} <文档名>`);
|
||||
});
|
||||
|
||||
cli
|
||||
.command("plan <change-name> <document-name>", "规划阶段")
|
||||
.action(async (changeName: string, documentName: string) => {
|
||||
validateChangeName(changeName);
|
||||
const root = requireProjectRoot();
|
||||
const config = await loadConfig(root);
|
||||
const planDocs = config.stages.plan?.documents;
|
||||
if (!planDocs || !planDocs.find((d) => d.name === documentName)) {
|
||||
throw new CommandError(`文档"${documentName}"不在配置的规划阶段文档列表中`, {
|
||||
hint: `可用文档:${planDocs?.map((d) => d.name).join(", ") ?? "无"}`,
|
||||
});
|
||||
}
|
||||
|
||||
const changeDir = getChangeDir(root, changeName);
|
||||
if (!existsSync(changeDir)) {
|
||||
const prefix = getPmPrefix(config);
|
||||
throw new CommandError(`变更"${changeName}"不存在`, {
|
||||
hint: `请先运行 ${prefix} create ${changeName} 创建变更`,
|
||||
});
|
||||
}
|
||||
|
||||
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: `请先完成依赖文档:${getPmPrefix(config)} plan ${changeName} ${missing[0]}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const prompt = await assemblePlanPrompt(config, root, changeName, documentName);
|
||||
console.log(prompt);
|
||||
});
|
||||
|
||||
cli.command("task <change-name>", "任务拆解阶段").action(async (changeName: string) => {
|
||||
validateChangeName(changeName);
|
||||
const root = requireProjectRoot();
|
||||
const config = await loadConfig(root);
|
||||
const changeDir = getChangeDir(root, changeName);
|
||||
if (!existsSync(changeDir)) {
|
||||
throw new CommandError(`变更"${changeName}"不存在`, {
|
||||
hint: `请先运行 ${getPmPrefix(config)} create ${changeName} 创建变更`,
|
||||
});
|
||||
}
|
||||
const prompt = await assembleTaskPrompt(config, root, changeName);
|
||||
console.log(prompt);
|
||||
});
|
||||
|
||||
cli.command("build <change-name>", "构建阶段").action(async (changeName: string) => {
|
||||
validateChangeName(changeName);
|
||||
const root = requireProjectRoot();
|
||||
const config = await loadConfig(root);
|
||||
const changeDir = getChangeDir(root, changeName);
|
||||
if (!existsSync(changeDir)) {
|
||||
throw new CommandError(`变更"${changeName}"不存在`, {
|
||||
hint: `请先运行 ${getPmPrefix(config)} create ${changeName} 创建变更`,
|
||||
});
|
||||
}
|
||||
const prompt = await assembleBuildPrompt(config, root, changeName);
|
||||
console.log(prompt);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
cli.command("archive <change-name>", "归档阶段").action(
|
||||
async (changeName: string) => {
|
||||
cli.command("archive <change-name>", "归档阶段").action(async (changeName: string) => {
|
||||
validateChangeName(changeName);
|
||||
const root = requireProjectRoot();
|
||||
const config = await loadConfig(root);
|
||||
const changeDir = getChangeDir(root, changeName);
|
||||
if (!existsSync(changeDir)) {
|
||||
throw new CommandError(`变更 '${changeName}' 不存在`, {
|
||||
hint: `请先运行 rune plan ${changeName} 创建变更`,
|
||||
throw new CommandError(`变更"${changeName}"不存在`, {
|
||||
hint: `请先运行 ${getPmPrefix(config)} create ${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);
|
||||
const prompt = await assembleArchivePrompt(config, root, changeName);
|
||||
console.log(prompt);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
cli.command("status", "查看变更状态").action(async () => {
|
||||
cli.command("finish <change-name>", "归档变更").action(async (changeName: string) => {
|
||||
validateChangeName(changeName);
|
||||
const root = requireProjectRoot();
|
||||
const changes = await scanChanges(root);
|
||||
const config = await loadConfig(root);
|
||||
const changeDir = getChangeDir(root, changeName);
|
||||
if (!existsSync(changeDir)) {
|
||||
throw new CommandError(`变更"${changeName}"不存在`, {
|
||||
hint: `请先运行 ${getPmPrefix(config)} create ${changeName} 创建变更`,
|
||||
});
|
||||
}
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const archiveDir = getArchiveDir(root);
|
||||
await mkdir(archiveDir, { recursive: true });
|
||||
const dest = join(archiveDir, `${today}-${changeName}`);
|
||||
if (existsSync(dest)) {
|
||||
throw new CommandError(`归档目标 "${today}-${changeName}" 已存在`, {
|
||||
hint: `同一天同一变更名只能归档一次。如需重新归档,请先删除 .rune/archive/${today}-${changeName} 目录`,
|
||||
});
|
||||
}
|
||||
await rename(changeDir, dest);
|
||||
console.log(`变更 "${changeName}" 已归档到 .rune/archive/${today}-${changeName}`);
|
||||
});
|
||||
|
||||
cli.command("status [change-name]", "查看变更状态").action(async (changeName?: string) => {
|
||||
const root = requireProjectRoot();
|
||||
const config = await loadConfig(root);
|
||||
const prefix = getPmPrefix(config);
|
||||
const changes = await scanChanges(root, config);
|
||||
|
||||
if (changeName) {
|
||||
const change = changes.find((c) => c.name === changeName);
|
||||
if (!change) {
|
||||
throw new CommandError(`变更 "${changeName}" 不存在`, {
|
||||
hint: `运行 ${prefix} status 查看所有变更`,
|
||||
});
|
||||
}
|
||||
console.log(formatChangeStatus(change, config));
|
||||
} else {
|
||||
if (changes.length === 0) {
|
||||
console.log("当前无进行中的变更。");
|
||||
return;
|
||||
}
|
||||
for (const change of changes) {
|
||||
const progress = change.taskProgress
|
||||
? ` (${change.taskProgress.completed}/${change.taskProgress.total} 任务)`
|
||||
: "";
|
||||
console.log(`- ${change.name}${progress} [${change.documents.join(", ")}]`);
|
||||
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(e);
|
||||
}
|
||||
|
||||
function mapCacError(e: Error): CliError | null {
|
||||
const prefix = getPmPrefix();
|
||||
if (e.message.includes("Unknown option")) {
|
||||
const match = e.message.match(/Unknown option `([^`]+)`/);
|
||||
const flag = match ? match[1] : "未知选项";
|
||||
printError(new UsageError(`未知选项: ${flag}`, {
|
||||
hint: "运行 rune help 查看所有命令",
|
||||
}));
|
||||
} else if (e instanceof Error && e.message.includes("Unknown command")) {
|
||||
return new UsageError(`未知选项: ${flag}`, {
|
||||
hint: `运行 ${prefix} help 查看所有命令`,
|
||||
});
|
||||
}
|
||||
if (e.message.includes("Unknown command")) {
|
||||
const match = e.message.match(/Unknown command `([^`]+)`/);
|
||||
const cmd = match ? match[1] : "未知命令";
|
||||
printError(new UsageError(`未知命令: ${cmd}`, {
|
||||
hint: "运行 rune help 查看所有命令",
|
||||
}));
|
||||
} else if (e instanceof Error && e.message.includes("Unused args")) {
|
||||
return new UsageError(`未知命令: ${cmd}`, {
|
||||
hint: `运行 ${prefix} help 查看所有命令`,
|
||||
});
|
||||
}
|
||||
if (e.message.includes("Unused args")) {
|
||||
const match = e.message.match(/Unused args: (.+)/);
|
||||
const args = match ? match[1] : "未知参数";
|
||||
printError(new UsageError(`未知命令: ${args.replace(/`/g, "")}`, {
|
||||
hint: "运行 rune help 查看所有命令",
|
||||
}));
|
||||
} else if (e instanceof Error && e.message.includes("missing required args")) {
|
||||
return new UsageError(`未知命令: ${args.replace(/`/g, "")}`, {
|
||||
hint: `运行 ${prefix} help 查看所有命令`,
|
||||
});
|
||||
}
|
||||
if (e.message.includes("missing required args")) {
|
||||
const match = e.message.match(/command `(\w+)/);
|
||||
const cmd = match ? match[1] : "未知命令";
|
||||
printError(new UsageError(`命令 '${cmd}' 缺少必填参数`, {
|
||||
usage: `rune ${cmd} <change-name>`,
|
||||
hint: `运行 rune help ${cmd} 查看用法`,
|
||||
}));
|
||||
} else {
|
||||
printError(new InternalError());
|
||||
return new UsageError(`命令"${cmd}"缺少必填参数`, {
|
||||
usage: `${prefix} ${cmd} <change-name>`,
|
||||
hint: `运行 ${prefix} help ${cmd} 查看用法`,
|
||||
});
|
||||
}
|
||||
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;
|
||||
@@ -20,7 +17,18 @@ export class ConfigError extends CliError {}
|
||||
export class CommandError extends CliError {}
|
||||
|
||||
export class InternalError extends CliError {
|
||||
constructor() {
|
||||
super("发生了未预期的错误");
|
||||
readonly cause?: Error;
|
||||
|
||||
constructor(originalError?: unknown) {
|
||||
const message =
|
||||
originalError instanceof Error
|
||||
? `发生了未预期的错误:${originalError.message}`
|
||||
: "发生了未预期的错误";
|
||||
const hint =
|
||||
originalError instanceof Error
|
||||
? `错误类型:${originalError.constructor.name}\n调用栈:${originalError.stack ?? "无"}`
|
||||
: undefined;
|
||||
super(message, { hint });
|
||||
this.cause = originalError instanceof Error ? originalError : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
133
src/cli/help.ts
133
src/cli/help.ts
@@ -2,10 +2,10 @@ interface CommandHelpDef {
|
||||
name: string;
|
||||
alias: string;
|
||||
description: string;
|
||||
usage: string;
|
||||
usageTemplate: string;
|
||||
args: { name: string; desc: string }[];
|
||||
detail: string;
|
||||
examples: string[];
|
||||
exampleArgs: string[];
|
||||
}
|
||||
|
||||
const COMMANDS: Record<string, CommandHelpDef> = {
|
||||
@@ -13,76 +13,110 @@ const COMMANDS: Record<string, CommandHelpDef> = {
|
||||
name: "init",
|
||||
alias: "init <工具...>",
|
||||
description: "初始化 Rune 并注入工具配置",
|
||||
usage: "rune init <工具...>",
|
||||
usageTemplate: "init <工具...>",
|
||||
args: [{ name: "<工具...>", desc: "要注入的 AI 工具,如 opencode、claude-code" }],
|
||||
detail: "在当前项目中创建 .rune 目录结构,并注入指定 AI 工具的 command 和 skill 配置文件。",
|
||||
examples: [
|
||||
"rune init opencode",
|
||||
"rune init opencode claude-code",
|
||||
],
|
||||
exampleArgs: ["init opencode", "init opencode claude-code"],
|
||||
},
|
||||
discuss: {
|
||||
name: "discuss",
|
||||
alias: "discuss",
|
||||
description: "讨论:生成讨论阶段提示词",
|
||||
usage: "rune discuss",
|
||||
usageTemplate: "discuss",
|
||||
args: [],
|
||||
detail: "生成 SDD 流程中讨论阶段的提示词,输出到标准输出。需要先运行 rune init 初始化项目。",
|
||||
examples: ["rune discuss"],
|
||||
detail: "生成 SDD 流程中讨论阶段的提示词,输出到标准输出。需要先运行 init 初始化项目。",
|
||||
exampleArgs: ["discuss"],
|
||||
},
|
||||
create: {
|
||||
name: "create",
|
||||
alias: "create <变更>",
|
||||
description: "创建变更目录(plan 阶段的辅助命令)",
|
||||
usageTemplate: "create <change-name>",
|
||||
args: [{ name: "<change-name>", desc: '变更名称,如 "add-login"' }],
|
||||
detail:
|
||||
"在 .rune/changes/ 下创建以变更名称命名的目录。这不是独立的 SDD 阶段,而是为 plan 阶段准备变更工作区的工具命令。使用 plan 前必须先 create 创建变更目录。",
|
||||
exampleArgs: ["create add-user-auth", "create fix-memory-leak"],
|
||||
},
|
||||
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: "规划:生成指定文档的规划提示词",
|
||||
usageTemplate: "plan <change-name> <document-name>",
|
||||
args: [
|
||||
{ name: "<change-name>", desc: '变更名称,如 "add-login"' },
|
||||
{ name: "<document-name>", desc: '文档名称,如 "design"' },
|
||||
],
|
||||
detail:
|
||||
"生成规划阶段指定文档的提示词。依赖的前置文档必须已完成。可用文档由配置中的 plan.documents 定义。",
|
||||
exampleArgs: ["plan add-user-auth design", "plan add-user-auth design"],
|
||||
},
|
||||
task: {
|
||||
name: "task",
|
||||
alias: "task <变更>",
|
||||
description: "任务拆解:根据规划文档生成任务清单",
|
||||
usageTemplate: "task <change-name>",
|
||||
args: [{ name: "<change-name>", desc: '变更名称,如 "add-login"' }],
|
||||
detail:
|
||||
"根据规划阶段的文档内容,生成 checkbox 格式的任务清单(task.md)。规划阶段的所有文档必须已完成。",
|
||||
exampleArgs: ["task add-user-auth", "task fix-memory-leak"],
|
||||
},
|
||||
build: {
|
||||
name: "build",
|
||||
alias: "build <名称>",
|
||||
description: "构建:生成构建阶段提示词",
|
||||
usage: "rune build <change-name>",
|
||||
args: [{ name: "<change-name>", desc: "变更名称,如 \"add-login\"" }],
|
||||
detail: "生成构建阶段的提示词。变更目录需已存在(通过 rune plan 创建)。",
|
||||
examples: [
|
||||
"rune build add-user-auth",
|
||||
"rune build fix-memory-leak",
|
||||
],
|
||||
usageTemplate: "build <change-name>",
|
||||
args: [{ name: "<change-name>", desc: '变更名称,如 "add-login"' }],
|
||||
detail:
|
||||
"按 task.md 中的任务顺序逐步实现。需先完成任务拆解阶段(task)。可多次执行直到全部完成。",
|
||||
exampleArgs: ["build add-user-auth", "build fix-memory-leak"],
|
||||
},
|
||||
archive: {
|
||||
name: "archive",
|
||||
alias: "archive <名称>",
|
||||
description: "归档:归档变更并生成提示词",
|
||||
usage: "rune archive <change-name>",
|
||||
args: [{ name: "<change-name>", desc: "变更名称,如 \"add-login\"" }],
|
||||
detail: "将变更目录从 .rune/changes/ 移动到 .rune/archive/,并生成归档阶段提示词。",
|
||||
examples: [
|
||||
"rune archive add-user-auth",
|
||||
"rune archive fix-memory-leak",
|
||||
],
|
||||
description: "归档:生成归档阶段提示词",
|
||||
usageTemplate: "archive <change-name>",
|
||||
args: [{ name: "<change-name>", desc: '变更名称,如 "add-login"' }],
|
||||
detail:
|
||||
"生成归档阶段的提示词。变更目录需已存在(通过 create 创建)。此命令不移动文件,仅输出提示词。",
|
||||
exampleArgs: ["archive add-user-auth", "archive fix-memory-leak"],
|
||||
},
|
||||
finish: {
|
||||
name: "finish",
|
||||
alias: "finish <名称>",
|
||||
description: "归档变更:将变更目录移动到 archive",
|
||||
usageTemplate: "finish <change-name>",
|
||||
args: [{ name: "<change-name>", desc: '变更名称,如 "add-login"' }],
|
||||
detail:
|
||||
"将变更目录从 .rune/changes/ 移动到 .rune/archive/<日期>-<变更名>。这不是独立的 SDD 阶段,而是 archive 阶段中实际执行磁盘归档的工具命令。",
|
||||
exampleArgs: ["finish add-user-auth", "finish fix-memory-leak"],
|
||||
},
|
||||
update: {
|
||||
name: "update",
|
||||
alias: "update <工具...>",
|
||||
description: "更新:更新已注入的编辑器配置",
|
||||
usageTemplate: "update <工具...>",
|
||||
args: [{ name: "<工具...>", desc: "要更新的 AI 工具,如 opencode、claude-code" }],
|
||||
detail:
|
||||
"对比已注入的命令和 skill 文件,与内置版本不一致时覆盖,不存在时新建。用于升级 Rune 后同步编辑器配置。",
|
||||
exampleArgs: ["update opencode", "update opencode claude-code"],
|
||||
},
|
||||
status: {
|
||||
name: "status",
|
||||
alias: "status",
|
||||
description: "查看:列出当前进行中的变更",
|
||||
usage: "rune status",
|
||||
args: [],
|
||||
detail: "扫描 .rune/changes/ 目录,列出所有进行中的变更及其任务进度。",
|
||||
examples: ["rune status"],
|
||||
alias: "status [变更]",
|
||||
description: "查看:展示变更状态与下一步建议",
|
||||
usageTemplate: "status [change-name]",
|
||||
args: [{ name: "[change-name]", desc: "可选,指定查看的变更名称" }],
|
||||
detail: "展示各文档完成状态、依赖满足情况、规划进度和下一步建议。不传参数则显示所有变更。",
|
||||
exampleArgs: ["status", "status add-user-auth"],
|
||||
},
|
||||
};
|
||||
|
||||
export function showGlobalHelp(): string {
|
||||
export function showGlobalHelp(prefix: string = "rune"): string {
|
||||
const lines: string[] = [
|
||||
"Rune — 基于规格驱动开发(SDD)的 AI 开发辅助工具",
|
||||
"",
|
||||
"用法:",
|
||||
" rune <命令> [参数]",
|
||||
` ${prefix} <命令> [参数]`,
|
||||
"",
|
||||
"命令:",
|
||||
];
|
||||
@@ -98,22 +132,23 @@ 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 status 查看当前变更状态");
|
||||
lines.push(` ${prefix} init opencode 初始化并注入 OpenCode 配置`);
|
||||
lines.push(` ${prefix} update opencode 更新 OpenCode 配置`);
|
||||
lines.push(` ${prefix} plan add-login design 规划 "add-login" 的设计文档`);
|
||||
lines.push(` ${prefix} status 查看当前变更状态`);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function showCommandHelp(name: string): string | null {
|
||||
export function showCommandHelp(name: string, prefix: string = "rune"): string | null {
|
||||
const cmd = COMMANDS[name];
|
||||
if (!cmd) return null;
|
||||
|
||||
const lines: string[] = [
|
||||
`rune ${cmd.name} — ${cmd.description}`,
|
||||
`${prefix} ${cmd.name} — ${cmd.description}`,
|
||||
"",
|
||||
"用法:",
|
||||
` ${cmd.usage}`,
|
||||
` ${prefix} ${cmd.usageTemplate}`,
|
||||
];
|
||||
|
||||
if (cmd.args.length > 0) {
|
||||
@@ -130,8 +165,8 @@ export function showCommandHelp(name: string): string | null {
|
||||
|
||||
lines.push("");
|
||||
lines.push("示例:");
|
||||
for (const ex of cmd.examples) {
|
||||
lines.push(` ${ex}`);
|
||||
for (const ex of cmd.exampleArgs) {
|
||||
lines.push(` ${prefix} ${ex}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir, writeFile, readFile, appendFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { CHANGES_DIR, ARCHIVE_DIR, RUNE_DIR, CONFIG_FILE } from "../types.ts";
|
||||
import { injectOpenCode } from "../adapters/opencode.ts";
|
||||
import { injectClaudeCode } from "../adapters/claude-code.ts";
|
||||
import { CommandError } from "../cli/errors.ts";
|
||||
import { detectCommandPrefix, getPmPrefix } from "../core/pm.ts";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
const CONFIG_TEMPLATE = `# Rune 配置文件
|
||||
#
|
||||
# 未配置的阶段将使用内置默认配置。
|
||||
# 阶段顺序:discuss -> plan -> build -> archive
|
||||
# SDD 五阶段:discuss -> plan -> task -> build -> archive
|
||||
# 辅助命令:create(创建变更目录,plan 阶段的前置步骤)
|
||||
#
|
||||
# 可配置阶段:
|
||||
# discuss - 讨论阶段:自由讨论需求和架构
|
||||
# plan - 规划阶段:生成设计文档和任务清单
|
||||
# discuss - 探索阶段:深度思考、调查代码库、对比方案
|
||||
# plan - 规划阶段:生成设计文档
|
||||
# task - 任务拆解阶段:根据设计文档生成 checkbox 任务清单
|
||||
# build - 构建阶段:按任务清单逐步实现
|
||||
# archive - 归档阶段:确认完成并归档变更
|
||||
#
|
||||
@@ -21,7 +25,7 @@ const CONFIG_TEMPLATE = `# Rune 配置文件
|
||||
# stages:
|
||||
# discuss:
|
||||
# prompt: |
|
||||
# 你是一位资深软件架构师...
|
||||
# 进入探索模式。深度思考,自由发散。跟随对话走向。
|
||||
#
|
||||
# 示例 - 自定义规划阶段的文档模板:
|
||||
# stages:
|
||||
@@ -30,22 +34,36 @@ const CONFIG_TEMPLATE = `# Rune 配置文件
|
||||
# - name: design
|
||||
# prompt: 生成设计文档
|
||||
# template: |
|
||||
# # {{change-name}} 设计文档
|
||||
# - name: task
|
||||
# prompt: 生成任务清单
|
||||
# template: |
|
||||
# # {{change-name}} 任务清单
|
||||
# # 设计文档
|
||||
#
|
||||
# metadata 说明:
|
||||
# command - Rune CLI 执行命令(如 bunx @lanyuanxiaoyao/rune),init 时自动检测
|
||||
`;
|
||||
|
||||
const SUPPORTED_TOOLS: Record<string, (root: string) => Promise<void>> = {
|
||||
export const SUPPORTED_TOOLS: Record<string, (root: string, command?: string) => Promise<void>> = {
|
||||
opencode: injectOpenCode,
|
||||
"claude-code": injectClaudeCode,
|
||||
};
|
||||
|
||||
export async function runInit(
|
||||
projectRoot: string,
|
||||
tools: string[],
|
||||
): Promise<void> {
|
||||
export async function ensureMetadataCommand(configPath: string, command: string): Promise<void> {
|
||||
const content = await readFile(configPath, "utf-8");
|
||||
const parsed = parseYaml(content) as { metadata?: { command?: string } } | null;
|
||||
if (parsed?.metadata?.command) return;
|
||||
|
||||
if (parsed?.metadata) {
|
||||
const lines = content.split("\n");
|
||||
const metaIdx = lines.findIndex((l) => l.trim() === "metadata:");
|
||||
if (metaIdx >= 0) {
|
||||
lines.splice(metaIdx + 1, 0, ` command: "${command}"`);
|
||||
await writeFile(configPath, lines.join("\n"), "utf-8");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await appendFile(configPath, `\nmetadata:\n command: "${command}"\n`);
|
||||
}
|
||||
|
||||
export async function runInit(projectRoot: string, tools: string[]): Promise<void> {
|
||||
for (const tool of tools) {
|
||||
if (!SUPPORTED_TOOLS[tool]) {
|
||||
throw new CommandError(`不支持的工具: ${tool}`, {
|
||||
@@ -64,7 +82,13 @@ export async function runInit(
|
||||
await writeFile(configPath, CONFIG_TEMPLATE, "utf-8");
|
||||
}
|
||||
|
||||
const command = await detectCommandPrefix();
|
||||
if (command) {
|
||||
await ensureMetadataCommand(configPath, command);
|
||||
}
|
||||
|
||||
const prefix = command ?? getPmPrefix();
|
||||
for (const tool of tools) {
|
||||
await SUPPORTED_TOOLS[tool](projectRoot);
|
||||
await SUPPORTED_TOOLS[tool](projectRoot, prefix === "rune" ? undefined : prefix);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,49 +2,126 @@ 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";
|
||||
import { parseTasks, validateTaskFormat, TaskFormatError } from "./task-parser.ts";
|
||||
import { applyCommandPrefix, getPmPrefix } from "./pm.ts";
|
||||
import { PromptBuilder } from "./prompt-builder";
|
||||
|
||||
export function assembleDiscussPrompt(config: RuneConfig): string {
|
||||
const discuss = config.stages.discuss;
|
||||
if (!discuss) throw new Error("discuss 阶段未配置");
|
||||
return discuss.prompt;
|
||||
if (!discuss)
|
||||
throw new CommandError("讨论阶段未配置", {
|
||||
hint: "请在 .rune/config.yaml 中配置 stages.discuss",
|
||||
});
|
||||
|
||||
const raw = new PromptBuilder().head("# 讨论阶段").prompt(discuss.prompt).build();
|
||||
|
||||
return applyCommandPrefix(raw, config);
|
||||
}
|
||||
|
||||
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}"不在配置的规划阶段文档列表中`, {
|
||||
hint: `可用文档:${plan.documents.map((d) => d.name).join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
const changeDir = getChangeDir(projectRoot, changeName);
|
||||
const parts: string[] = [];
|
||||
const builder = new PromptBuilder();
|
||||
|
||||
parts.push(`# 规划阶段:${changeName}\n`);
|
||||
parts.push("请为当前变更生成以下文档:\n");
|
||||
|
||||
for (const doc of plan.documents) {
|
||||
parts.push(`## 文档:${doc.name}.md`);
|
||||
parts.push(doc.prompt);
|
||||
builder.head(`# 规划阶段:${changeName}\n\n## 文档:${doc.name}.md`);
|
||||
builder.prompt(doc.prompt);
|
||||
|
||||
const docPath = join(changeDir, `${doc.name}.md`);
|
||||
if (existsSync(docPath)) {
|
||||
const existing = await readFile(docPath, "utf-8");
|
||||
parts.push(`\n### 已有内容(请在此基础上修订):\n${existing}`);
|
||||
builder.context(`文档已存在,请先读取 ${docPath} 查看已有内容,在此基础上修订。`);
|
||||
}
|
||||
|
||||
if (doc.template) {
|
||||
const rendered = doc.template.replace(/\{\{change-name\}\}/g, changeName);
|
||||
parts.push(`\n### 格式模板:\n${rendered}`);
|
||||
builder.context(`### 格式模板:\n${doc.template}`);
|
||||
}
|
||||
|
||||
parts.push("");
|
||||
if (doc.depend && doc.depend.length > 0) {
|
||||
const depLines = doc.depend.map((dep) => {
|
||||
const depPath = join(changeDir, `${dep}.md`);
|
||||
const depCompleted = existsSync(depPath);
|
||||
const status = depCompleted ? "已完成" : "未完成";
|
||||
return `- ${dep}.md(${status})`;
|
||||
});
|
||||
builder.context(
|
||||
`## 依赖说明\n\n本文档依赖以下前置文档:\n${depLines.join("\n")}\n\n请先阅读已完成的前置文档,确保内容一致。`,
|
||||
);
|
||||
}
|
||||
|
||||
parts.push(`请将文档写入目录:${changeDir}`);
|
||||
return parts.join("\n");
|
||||
builder.guide(`请将文档写入目录:${changeDir}`);
|
||||
|
||||
return applyCommandPrefix(builder.build(), config);
|
||||
}
|
||||
|
||||
export async function assembleTaskPrompt(
|
||||
config: RuneConfig,
|
||||
projectRoot: string,
|
||||
changeName: string,
|
||||
): Promise<string> {
|
||||
const task = config.stages.task;
|
||||
if (!task) {
|
||||
throw new CommandError("任务拆解阶段未配置", {
|
||||
hint: "请在 .rune/config.yaml 中配置 stages.task",
|
||||
});
|
||||
}
|
||||
|
||||
const plan = config.stages.plan;
|
||||
if (!plan) {
|
||||
throw new CommandError("规划阶段未配置", {
|
||||
hint: "请在 .rune/config.yaml 中配置 stages.plan",
|
||||
});
|
||||
}
|
||||
|
||||
const changeDir = getChangeDir(projectRoot, changeName);
|
||||
const missingDocs: string[] = [];
|
||||
|
||||
for (const doc of plan.documents) {
|
||||
const docPath = join(changeDir, `${doc.name}.md`);
|
||||
if (!existsSync(docPath)) {
|
||||
missingDocs.push(`${doc.name}.md`);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingDocs.length > 0) {
|
||||
throw new CommandError(`变更"${changeName}"的规划文档尚未全部完成:${missingDocs.join("、")}`, {
|
||||
hint: `请先完成规划阶段:rune plan ${changeName} ${missingDocs[0].replace(".md", "")}`,
|
||||
});
|
||||
}
|
||||
|
||||
const builder = new PromptBuilder();
|
||||
|
||||
builder.head(`# 任务拆解阶段:${changeName}`);
|
||||
builder.prompt(task.prompt);
|
||||
|
||||
const planDocPaths = plan.documents.map((d) => join(changeDir, `${d.name}.md`));
|
||||
builder.context(`请先读取以下规划文档:\n${planDocPaths.map((p) => `- ${p}`).join("\n")}`);
|
||||
|
||||
const taskPath = join(changeDir, "task.md");
|
||||
if (existsSync(taskPath)) {
|
||||
builder.context(`task.md 已存在,请先读取 ${taskPath} 查看已有内容,在此基础上修订。`);
|
||||
}
|
||||
|
||||
builder.guide(`请将任务列表写入 ${taskPath}`);
|
||||
|
||||
return applyCommandPrefix(builder.build(), config);
|
||||
}
|
||||
|
||||
export async function assembleBuildPrompt(
|
||||
@@ -53,7 +130,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 +143,21 @@ export async function assembleBuildPrompt(
|
||||
try {
|
||||
taskContent = await readFile(taskPath, "utf-8");
|
||||
} catch {
|
||||
throw new Error(`task.md not found in ${changeDir}`);
|
||||
const prefix = getPmPrefix(config);
|
||||
throw new CommandError(`变更"${changeName}"尚未完成任务拆解,task.md 不存在`, {
|
||||
hint: `请先完成任务拆解阶段:${prefix} task ${changeName}`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
validateTaskFormat(taskContent);
|
||||
} catch (e) {
|
||||
if (e instanceof TaskFormatError) {
|
||||
throw new CommandError(e.message, {
|
||||
hint: `请确保 ${taskPath} 中包含格式正确的 checkbox 任务项(如 - [ ] 任务描述)`,
|
||||
});
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
const tasks = parseTasks(taskContent);
|
||||
@@ -72,31 +167,54 @@ export async function assembleBuildPrompt(
|
||||
return `所有任务已完成。变更 "${changeName}" 可以归档。`;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push(`# 构建阶段:${changeName}\n`);
|
||||
parts.push(build.prompt);
|
||||
parts.push(`\n## 任务列表\n`);
|
||||
parts.push(taskContent);
|
||||
parts.push(`\n## 待执行任务(共 ${pendingTasks.length} 项)`);
|
||||
for (const task of pendingTasks) {
|
||||
parts.push(`- [ ] ${task.text}`);
|
||||
}
|
||||
parts.push(
|
||||
`\n请从第一个待执行任务开始。完成后更新 ${taskPath} 中的 checkbox。`,
|
||||
const builder = new PromptBuilder();
|
||||
|
||||
builder.head(`# 构建阶段:${changeName}`);
|
||||
builder.prompt(build.prompt);
|
||||
builder.context(
|
||||
`请先读取 ${taskPath} 查看任务列表。\n当前有 ${pendingTasks.length} 个待执行任务,从第一个未完成的任务开始。`,
|
||||
);
|
||||
builder.guide(`完成后更新 ${taskPath} 中的 checkbox。`);
|
||||
|
||||
return parts.join("\n");
|
||||
return applyCommandPrefix(builder.build(), config);
|
||||
}
|
||||
|
||||
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 parts: string[] = [];
|
||||
parts.push(`# 归档阶段:${changeName}\n`);
|
||||
parts.push(archive.prompt);
|
||||
return parts.join("\n");
|
||||
const builder = new PromptBuilder();
|
||||
|
||||
builder.head(`# 归档阶段:${changeName}`);
|
||||
builder.prompt(archive.prompt);
|
||||
|
||||
const changeDir = getChangeDir(projectRoot, changeName);
|
||||
const taskPath = join(changeDir, "task.md");
|
||||
if (existsSync(taskPath)) {
|
||||
try {
|
||||
const taskContent = await readFile(taskPath, "utf-8");
|
||||
const tasks = parseTasks(taskContent);
|
||||
const incompleteTasks = tasks.filter((t) => !t.checked);
|
||||
if (incompleteTasks.length > 0) {
|
||||
const taskLines = incompleteTasks.map((t) => `- [ ] ${t.text}`).join("\n");
|
||||
builder.warn(
|
||||
`## ⚠️ 警告:存在未完成的任务\n\n以下任务尚未完成:\n${taskLines}\n\n询问用户是否确认在任务未全部完成的情况下归档。\n如用户确认,则继续执行归档操作;否则中止并返回构建阶段。`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
const code = (e as NodeJS.ErrnoException)?.code;
|
||||
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return applyCommandPrefix(builder.build(), config);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ 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 { getPmPrefix } from "./pm.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,18 +22,83 @@ 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 ?? {});
|
||||
} catch {
|
||||
return mergeConfig({});
|
||||
merged = mergeConfig(userConfig ?? {});
|
||||
} catch (e) {
|
||||
const code = (e as NodeJS.ErrnoException)?.code;
|
||||
if (code === "ENOENT" || code === "ENOTDIR") {
|
||||
merged = mergeConfig({});
|
||||
} else {
|
||||
throw new ConfigError(`配置文件加载失败:${configPath}\n${(e as Error).message}`, {
|
||||
hint: `请检查 .rune/config.yaml 的格式是否正确。常见问题:\n - YAML 缩进必须使用空格,不能用 Tab\n - 字符串包含特殊字符时需要引号包裹\n - 运行 ${getPmPrefix()} init 重新生成默认配置`,
|
||||
});
|
||||
}
|
||||
}
|
||||
validateConfig(merged);
|
||||
return merged;
|
||||
}
|
||||
|
||||
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}"不能依赖自身`, {
|
||||
hint: `请从文档"${doc.name}"的 depend 列表中移除自身引用`,
|
||||
});
|
||||
}
|
||||
if (!docNames.has(dep)) {
|
||||
throw new ConfigError(`文档"${doc.name}"依赖的"${dep}"不存在于规划阶段的文档列表中`, {
|
||||
hint: `请在 stages.plan.documents 中添加文档"${dep}",或从文档"${doc.name}"的 depend 列表中移除"${dep}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(" → ")}`, {
|
||||
hint: "请检查文档的 depend 配置,移除形成环路的依赖关系",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mergeConfig(userConfig: Partial<RuneConfig>): RuneConfig {
|
||||
const result: RuneConfig = { stages: {} };
|
||||
const stageKeys = ["discuss", "plan", "build", "archive"] as const;
|
||||
const stageKeys = ["discuss", "plan", "task", "build", "archive"] as const;
|
||||
|
||||
for (const stage of stageKeys) {
|
||||
if (userConfig.stages?.[stage]) {
|
||||
@@ -43,6 +108,11 @@ function mergeConfig(userConfig: Partial<RuneConfig>): RuneConfig {
|
||||
}
|
||||
}
|
||||
|
||||
result.metadata = {
|
||||
...defaultConfig.metadata,
|
||||
...userConfig.metadata,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
67
src/core/pm.ts
Normal file
67
src/core/pm.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { RuneConfig } from "../types.ts";
|
||||
|
||||
export const DEFAULT_PREFIX = "bunx @lanyuanxiaoyao/rune";
|
||||
|
||||
export function inferFromProcess(argv: string[], userAgent: string | undefined): string | null {
|
||||
const scriptPath = argv[1]?.replace(/\\/g, "/") ?? "";
|
||||
|
||||
if (scriptPath.includes(".bun") && scriptPath.includes("cache")) {
|
||||
return "bunx @lanyuanxiaoyao/rune";
|
||||
}
|
||||
if (scriptPath.includes("_npx") || scriptPath.includes("npm-cache")) {
|
||||
return "npx @lanyuanxiaoyao/rune";
|
||||
}
|
||||
if (scriptPath.includes(".pnpm") || scriptPath.includes("pnpm-store")) {
|
||||
return "pnpx @lanyuanxiaoyao/rune";
|
||||
}
|
||||
|
||||
if (userAgent?.includes("bun")) return "bunx @lanyuanxiaoyao/rune";
|
||||
if (userAgent?.includes("pnpm")) return "pnpx @lanyuanxiaoyao/rune";
|
||||
if (userAgent?.includes("yarn")) return "yarn dlx @lanyuanxiaoyao/rune";
|
||||
if (userAgent?.includes("npm")) return "npx @lanyuanxiaoyao/rune";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function checkCommandAvailable(command: string): Promise<boolean> {
|
||||
const which = process.platform === "win32" ? "where" : "which";
|
||||
try {
|
||||
const proc = Bun.spawn([which, command], {
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
return exitCode === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectCommandPrefix(): Promise<string | null> {
|
||||
const inferred = inferFromProcess(process.argv, process.env.npm_config_user_agent);
|
||||
if (inferred) return inferred;
|
||||
|
||||
for (const pm of ["bunx", "pnpx", "npx"]) {
|
||||
if (await checkCommandAvailable(pm)) return `${pm} @lanyuanxiaoyao/rune`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getPmPrefix(config?: RuneConfig): string {
|
||||
return config?.metadata?.command ?? DEFAULT_PREFIX;
|
||||
}
|
||||
|
||||
export function getFallbackNote(): string {
|
||||
return `如果没有安装 bun,可使用 \`pnpx @lanyuanxiaoyao/rune\` 或 \`npx @lanyuanxiaoyao/rune\` 替代`;
|
||||
}
|
||||
|
||||
export function applyCommandPrefix(text: string, config?: RuneConfig): string {
|
||||
const prefix = getPmPrefix(config);
|
||||
const hasCommand = /\brune(?=\s)/.test(text);
|
||||
const result = text.replace(/\brune(?=\s)/g, prefix);
|
||||
if (!config?.metadata?.command && hasCommand) {
|
||||
return result + "\n\n" + getFallbackNote();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
62
src/core/prompt-builder.ts
Normal file
62
src/core/prompt-builder.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { PromptSection, SectionKind } from "./prompt-sections";
|
||||
|
||||
export class PromptBuilder {
|
||||
private sections: PromptSection[] = [];
|
||||
|
||||
head(text: string): this {
|
||||
const existing = this.sections.findIndex((s) => s.kind === "head");
|
||||
if (existing !== -1) {
|
||||
this.sections[existing] = { kind: "head", content: text };
|
||||
} else {
|
||||
this.sections.push({ kind: "head", content: text });
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
context(text: string): this {
|
||||
this.sections.push({ kind: "context", content: text });
|
||||
return this;
|
||||
}
|
||||
|
||||
prompt(text: string): this {
|
||||
const existing = this.sections.findIndex((s) => s.kind === "prompt");
|
||||
if (existing !== -1) {
|
||||
this.sections[existing] = { kind: "prompt", content: text };
|
||||
} else {
|
||||
this.sections.push({ kind: "prompt", content: text });
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
guide(text: string): this {
|
||||
this.sections.push({ kind: "guide", content: text });
|
||||
return this;
|
||||
}
|
||||
|
||||
warn(text: string): this {
|
||||
this.sections.push({ kind: "warn", content: text });
|
||||
return this;
|
||||
}
|
||||
|
||||
build(): string {
|
||||
const order: SectionKind[] = ["head", "context", "prompt", "guide", "warn"];
|
||||
const groups: string[][] = order.map(() => []);
|
||||
|
||||
for (const section of this.sections) {
|
||||
if (section.content.trim() === "") continue;
|
||||
const idx = order.indexOf(section.kind);
|
||||
if (idx !== -1) {
|
||||
groups[idx].push(section.content);
|
||||
}
|
||||
}
|
||||
|
||||
const rendered: string[] = [];
|
||||
for (const group of groups) {
|
||||
if (group.length > 0) {
|
||||
rendered.push(group.join("\n\n"));
|
||||
}
|
||||
}
|
||||
|
||||
return rendered.join("\n---\n");
|
||||
}
|
||||
}
|
||||
6
src/core/prompt-sections.ts
Normal file
6
src/core/prompt-sections.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type SectionKind = "head" | "context" | "prompt" | "guide" | "warn";
|
||||
|
||||
export interface PromptSection {
|
||||
kind: SectionKind;
|
||||
content: string;
|
||||
}
|
||||
@@ -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,9 +54,19 @@ export async function scanChanges(
|
||||
};
|
||||
}
|
||||
|
||||
results.push({ name: entry, documents, taskProgress });
|
||||
results.push({
|
||||
name: entry,
|
||||
documents,
|
||||
planCompleted,
|
||||
buildUnlocked,
|
||||
taskProgress,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
const code = (e as NodeJS.ErrnoException)?.code;
|
||||
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
||||
throw e;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { TaskItem } from "../types.ts";
|
||||
import { CommandError } from "../cli/errors.ts";
|
||||
|
||||
export function parseTasks(content: string): TaskItem[] {
|
||||
const tasks: TaskItem[] = [];
|
||||
const lines = content.split("\n");
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^[\s]*- \[([ xX])\] (.*)$/);
|
||||
if (match) {
|
||||
@@ -14,3 +15,22 @@ export function parseTasks(content: string): TaskItem[] {
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
export class TaskFormatError extends CommandError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateTaskFormat(content: string): void {
|
||||
const tasks = parseTasks(content);
|
||||
if (tasks.length === 0) {
|
||||
throw new TaskFormatError("task.md 必须包含至少一个 checkbox 项");
|
||||
}
|
||||
for (const task of tasks) {
|
||||
if (task.text.trim() === "") {
|
||||
throw new TaskFormatError("task.md 中每个 checkbox 项必须有非空描述");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
21
src/types.ts
21
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 {
|
||||
@@ -12,6 +19,10 @@ export interface PlanStage {
|
||||
documents: DocumentConfig[];
|
||||
}
|
||||
|
||||
export interface TaskStage {
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface BuildStage {
|
||||
prompt: string;
|
||||
}
|
||||
@@ -23,12 +34,16 @@ export interface ArchiveStage {
|
||||
export interface StagesConfig {
|
||||
discuss?: DiscussStage;
|
||||
plan?: PlanStage;
|
||||
task?: TaskStage;
|
||||
build?: BuildStage;
|
||||
archive?: ArchiveStage;
|
||||
}
|
||||
|
||||
export interface RuneConfig {
|
||||
stages: StagesConfig;
|
||||
metadata?: {
|
||||
command?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TaskItem {
|
||||
@@ -38,11 +53,13 @@ export interface TaskItem {
|
||||
|
||||
export interface ChangeStatus {
|
||||
name: string;
|
||||
documents: string[];
|
||||
documents: DocumentStatus[];
|
||||
planCompleted: boolean;
|
||||
buildUnlocked: boolean;
|
||||
taskProgress: { completed: number; total: number } | null;
|
||||
}
|
||||
|
||||
export const STAGES = ["discuss", "plan", "build", "archive"] as const;
|
||||
export const STAGES = ["discuss", "plan", "task", "build", "archive"] as const;
|
||||
export type Stage = (typeof STAGES)[number];
|
||||
|
||||
export const RUNE_DIR = ".rune";
|
||||
|
||||
189
tests/adapters/claude-code.test.ts
Normal file
189
tests/adapters/claude-code.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import { retryRm } from "../helpers/cleanup.ts";
|
||||
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 retryRm(TMP_DIR);
|
||||
});
|
||||
|
||||
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).not.toContain("rune-status.md");
|
||||
});
|
||||
|
||||
it("生成 rune-intro command", async () => {
|
||||
await injectClaudeCode(TMP_DIR);
|
||||
|
||||
const commands = await readdir(join(TMP_DIR, ".claude", "commands"));
|
||||
expect(commands).toContain("rune-intro.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("rune status");
|
||||
}
|
||||
});
|
||||
|
||||
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("不生成 rune-create command", async () => {
|
||||
await injectClaudeCode(TMP_DIR);
|
||||
const commands = await readdir(join(TMP_DIR, ".claude", "commands"));
|
||||
expect(commands).not.toContain("rune-create.md");
|
||||
});
|
||||
|
||||
it("plan command 包含 create 引导", async () => {
|
||||
await injectClaudeCode(TMP_DIR);
|
||||
const content = await readFile(join(TMP_DIR, ".claude", "commands", "rune-plan.md"), "utf-8");
|
||||
expect(content).toContain("如果变更目录尚不存在");
|
||||
expect(content).toContain("rune create <变更名>");
|
||||
});
|
||||
|
||||
it("discuss command 包含 create 引导", async () => {
|
||||
await injectClaudeCode(TMP_DIR);
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toContain("讨论结束后");
|
||||
expect(content).toContain("rune create <变更名>");
|
||||
});
|
||||
|
||||
it("intro command 不包含 rune-create 作为独立命令", async () => {
|
||||
await injectClaudeCode(TMP_DIR);
|
||||
const content = await readFile(join(TMP_DIR, ".claude", "commands", "rune-intro.md"), "utf-8");
|
||||
expect(content).not.toContain("/rune-create");
|
||||
expect(content).toContain("rune create <变更名>");
|
||||
});
|
||||
|
||||
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("更新时生成 rune-intro 命令", async () => {
|
||||
await updateClaudeCode(TMP_DIR);
|
||||
expect(existsSync(join(TMP_DIR, ".claude", "commands", "rune-intro.md"))).toBe(true);
|
||||
});
|
||||
|
||||
it("不生成 rune-status 命令", async () => {
|
||||
await updateClaudeCode(TMP_DIR);
|
||||
expect(existsSync(join(TMP_DIR, ".claude", "commands", "rune-status.md"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("injectClaudeCode with command prefix", () => {
|
||||
it("使用自定义前缀生成 command 文件", async () => {
|
||||
await injectClaudeCode(TMP_DIR, "bunx @lanyuanxiaoyao/rune");
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toContain("bunx @lanyuanxiaoyao/rune discuss");
|
||||
});
|
||||
|
||||
it("不传前缀时使用默认 rune 前缀", async () => {
|
||||
await injectClaudeCode(TMP_DIR);
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".claude", "commands", "rune-discuss.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toContain("rune discuss");
|
||||
});
|
||||
|
||||
it("rune-intro command 使用自定义前缀", async () => {
|
||||
await injectClaudeCode(TMP_DIR, "npx @lanyuanxiaoyao/rune");
|
||||
const content = await readFile(join(TMP_DIR, ".claude", "commands", "rune-intro.md"), "utf-8");
|
||||
expect(content).toContain("npx @lanyuanxiaoyao/rune status");
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
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, readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import { retryRm } from "../helpers/cleanup.ts";
|
||||
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__");
|
||||
|
||||
@@ -11,7 +12,7 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(TMP_DIR, { recursive: true, force: true });
|
||||
await retryRm(TMP_DIR);
|
||||
});
|
||||
|
||||
describe("injectOpenCode", () => {
|
||||
@@ -24,46 +25,47 @@ 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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("生成 rune-status command 和 skill", async () => {
|
||||
it("不生成 rune-status command 和 skill", async () => {
|
||||
await injectOpenCode(TMP_DIR);
|
||||
|
||||
const commands = await readdir(join(TMP_DIR, ".opencode", "commands"));
|
||||
const skills = await readdir(join(TMP_DIR, ".opencode", "skills"));
|
||||
|
||||
expect(commands).toContain("rune-status.md");
|
||||
expect(skills).toContain("rune-status");
|
||||
expect(
|
||||
existsSync(join(TMP_DIR, ".opencode", "skills", "rune-status", "SKILL.md")),
|
||||
).toBe(true);
|
||||
expect(commands).not.toContain("rune-status.md");
|
||||
expect(skills).not.toContain("rune-status");
|
||||
});
|
||||
|
||||
it("command 文件包含 skill 调用指令", async () => {
|
||||
it("生成 rune-intro skill(无对应 command)", async () => {
|
||||
await injectOpenCode(TMP_DIR);
|
||||
|
||||
const commands = await readdir(join(TMP_DIR, ".opencode", "commands"));
|
||||
const skills = await readdir(join(TMP_DIR, ".opencode", "skills"));
|
||||
|
||||
expect(commands).not.toContain("rune-intro.md");
|
||||
expect(skills).toContain("rune-intro");
|
||||
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-intro", "SKILL.md"))).toBe(true);
|
||||
});
|
||||
|
||||
it("command 文件统一格式:只包含 skill 调用指令", async () => {
|
||||
await injectOpenCode(TMP_DIR);
|
||||
|
||||
for (const stage of ["discuss", "plan", "build", "archive"]) {
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"),
|
||||
join(TMP_DIR, ".opencode", "commands", `rune-${stage}.md`),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toContain("rune-discuss");
|
||||
expect(content).toContain(`rune-${stage} skill`);
|
||||
expect(content).not.toContain("变更名");
|
||||
}
|
||||
});
|
||||
|
||||
it("skill 文件包含 bash 命令", async () => {
|
||||
await injectOpenCode(TMP_DIR);
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".opencode", "skills", "rune-discuss", "SKILL.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toContain("rune discuss");
|
||||
expect(content).toContain("description");
|
||||
expect(content).toContain("name: rune-discuss");
|
||||
});
|
||||
|
||||
it("plan/build/archive skill 包含变更名称参数提示", async () => {
|
||||
it("plan/build/archive skill 包含变更名智能识别引导", async () => {
|
||||
await injectOpenCode(TMP_DIR);
|
||||
|
||||
for (const stage of ["plan", "build", "archive"]) {
|
||||
@@ -71,10 +73,76 @@ describe("injectOpenCode", () => {
|
||||
join(TMP_DIR, ".opencode", "skills", `rune-${stage}`, "SKILL.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toContain("变更名");
|
||||
expect(content).toContain("智能识别");
|
||||
expect(content).toContain("rune status");
|
||||
}
|
||||
});
|
||||
|
||||
it("discuss skill 不包含智能识别引导", async () => {
|
||||
await injectOpenCode(TMP_DIR);
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".opencode", "skills", "rune-discuss", "SKILL.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).not.toContain("智能识别");
|
||||
});
|
||||
|
||||
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("规划阶段应先运行");
|
||||
});
|
||||
|
||||
it("不生成 rune-create command 和 skill", async () => {
|
||||
await injectOpenCode(TMP_DIR);
|
||||
const commands = await readdir(join(TMP_DIR, ".opencode", "commands"));
|
||||
const skills = await readdir(join(TMP_DIR, ".opencode", "skills"));
|
||||
expect(commands).not.toContain("rune-create.md");
|
||||
expect(skills).not.toContain("rune-create");
|
||||
});
|
||||
|
||||
it("plan skill 包含 create 引导", async () => {
|
||||
await injectOpenCode(TMP_DIR);
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".opencode", "skills", "rune-plan", "SKILL.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toContain("如果变更目录尚不存在");
|
||||
expect(content).toContain("rune create <变更名>");
|
||||
});
|
||||
|
||||
it("discuss skill 包含 create 引导", async () => {
|
||||
await injectOpenCode(TMP_DIR);
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".opencode", "skills", "rune-discuss", "SKILL.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toContain("讨论结束后");
|
||||
expect(content).toContain("rune create <变更名>");
|
||||
});
|
||||
|
||||
it("intro skill 不包含 rune-create 作为独立命令", async () => {
|
||||
await injectOpenCode(TMP_DIR);
|
||||
const content = await readFile(
|
||||
join(TMP_DIR, ".opencode", "skills", "rune-intro", "SKILL.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).not.toContain("/rune-create");
|
||||
expect(content).toContain("rune create <变更名>");
|
||||
});
|
||||
|
||||
it("重复注入时不覆盖已存在的文件", async () => {
|
||||
await injectOpenCode(TMP_DIR);
|
||||
const originalContent = await readFile(
|
||||
@@ -90,3 +158,97 @@ 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("更新时生成 rune-intro skill", async () => {
|
||||
await updateOpenCode(TMP_DIR);
|
||||
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-intro", "SKILL.md"))).toBe(true);
|
||||
});
|
||||
|
||||
it("不生成 rune-status", async () => {
|
||||
await updateOpenCode(TMP_DIR);
|
||||
expect(existsSync(join(TMP_DIR, ".opencode", "commands", "rune-status.md"))).toBe(false);
|
||||
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-status", "SKILL.md"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("injectOpenCode with command prefix", () => {
|
||||
it("使用自定义前缀生成 skill 文件", async () => {
|
||||
const tmpDir = join(import.meta.dir, "__tmp_opencode_prefix_test__");
|
||||
await mkdir(tmpDir, { recursive: true });
|
||||
try {
|
||||
await injectOpenCode(tmpDir, "bunx @lanyuanxiaoyao/rune");
|
||||
const content = await readFile(
|
||||
join(tmpDir, ".opencode", "skills", "rune-discuss", "SKILL.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toContain("bunx @lanyuanxiaoyao/rune discuss");
|
||||
} finally {
|
||||
await retryRm(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
it("不传前缀时使用默认 rune 前缀", async () => {
|
||||
const tmpDir = join(import.meta.dir, "__tmp_opencode_default_test__");
|
||||
await mkdir(tmpDir, { recursive: true });
|
||||
try {
|
||||
await injectOpenCode(tmpDir);
|
||||
const content = await readFile(
|
||||
join(tmpDir, ".opencode", "skills", "rune-discuss", "SKILL.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toContain("rune discuss");
|
||||
} finally {
|
||||
await retryRm(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
it("rune-intro skill 使用自定义前缀", async () => {
|
||||
const tmpDir = join(import.meta.dir, "__tmp_opencode_intro_test__");
|
||||
await mkdir(tmpDir, { recursive: true });
|
||||
try {
|
||||
await injectOpenCode(tmpDir, "bunx @lanyuanxiaoyao/rune");
|
||||
const content = await readFile(
|
||||
join(tmpDir, ".opencode", "skills", "rune-intro", "SKILL.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toContain("bunx @lanyuanxiaoyao/rune status");
|
||||
} finally {
|
||||
await retryRm(tmpDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
109
tests/cli/create.test.ts
Normal file
109
tests/cli/create.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { retryRm } from "../helpers/cleanup.ts";
|
||||
import { join } from "node:path";
|
||||
import { runInit } from "../../src/commands/init.ts";
|
||||
import { loadConfig, getChangeDir } from "../../src/core/config.ts";
|
||||
import { assemblePlanPrompt } from "../../src/core/assembler.ts";
|
||||
import { CommandError } from "../../src/cli/errors.ts";
|
||||
|
||||
const TMP_DIR = join(import.meta.dir, "__tmp_create_test__");
|
||||
|
||||
beforeEach(async () => {
|
||||
await mkdir(TMP_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await retryRm(TMP_DIR);
|
||||
});
|
||||
|
||||
describe("create 命令(工具命令,非 SDD 阶段)", () => {
|
||||
it("创建变更目录成功", async () => {
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
const changeDir = getChangeDir(TMP_DIR, "user-auth");
|
||||
await mkdir(changeDir, { recursive: true });
|
||||
expect(existsSync(changeDir)).toBe(true);
|
||||
});
|
||||
|
||||
it("重复创建同名变更目录应报错", async () => {
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
const changeDir = getChangeDir(TMP_DIR, "duplicate-test");
|
||||
await mkdir(changeDir, { recursive: true });
|
||||
expect(existsSync(changeDir)).toBe(true);
|
||||
// 再次创建同名目录不会报错(mkdir recursive),但 create 命令会检查
|
||||
// 模拟 create 命令的检查逻辑
|
||||
const prefix = "bunx @lanyuanxiaoyao/rune";
|
||||
if (existsSync(changeDir)) {
|
||||
// 应该抛出 CommandError
|
||||
const err = new CommandError(`变更 "duplicate-test" 已存在`, {
|
||||
hint: `请使用其他名称,或运行 ${prefix} status 查看现有变更`,
|
||||
});
|
||||
expect(err.message).toContain("duplicate-test");
|
||||
expect(err.message).toContain("已存在");
|
||||
}
|
||||
});
|
||||
|
||||
it("create 后不再输出阶段提示词内容", async () => {
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
// 验证 create 不在 stages 配置中
|
||||
const config = await loadConfig(TMP_DIR);
|
||||
expect(config.stages.create).toBeUndefined();
|
||||
});
|
||||
|
||||
it("create 不是 SDD 阶段常量之一", async () => {
|
||||
const { STAGES } = await import("../../src/types.ts");
|
||||
expect(STAGES).not.toContain("create");
|
||||
expect(STAGES).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan 命令前置检查", () => {
|
||||
it("plan 在变更目录不存在时报错并提示 create", async () => {
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
const config = await loadConfig(TMP_DIR);
|
||||
|
||||
// 模拟 plan 命令检查:目录不存在
|
||||
const changeDir = getChangeDir(TMP_DIR, "nonexistent-change");
|
||||
expect(existsSync(changeDir)).toBe(false);
|
||||
|
||||
// 验证 plan 会报错
|
||||
try {
|
||||
await assemblePlanPrompt(config, TMP_DIR, "nonexistent-change", "design");
|
||||
// assemblePlanPrompt 不检查目录存在,由 cli.ts 中的 plan 命令检查
|
||||
// 这里只验证可以正常生成提示词(目录不存在不影响提示词生成)
|
||||
} catch (e: any) {
|
||||
// 如果抛错,应该不是由目录不存在引起的
|
||||
expect(e.message).not.toContain("不存在");
|
||||
}
|
||||
});
|
||||
|
||||
it("plan 在变更目录存在时能正常生成提示词", async () => {
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
const config = await loadConfig(TMP_DIR);
|
||||
|
||||
const changeDir = getChangeDir(TMP_DIR, "existing-change");
|
||||
await mkdir(changeDir, { recursive: true });
|
||||
|
||||
const prompt = await assemblePlanPrompt(config, TMP_DIR, "existing-change", "design");
|
||||
expect(prompt).toBeTruthy();
|
||||
expect(prompt).toContain("existing-change");
|
||||
expect(prompt).toContain("design");
|
||||
});
|
||||
});
|
||||
|
||||
describe("变更名校验", () => {
|
||||
it("合法变更名通过", () => {
|
||||
const validRegex = /^[\u4e00-\u9fa5a-zA-Z-]+$/;
|
||||
expect(validRegex.test("user-auth")).toBe(true);
|
||||
expect(validRegex.test("用户登录")).toBe(true);
|
||||
expect(validRegex.test("中文-english")).toBe(true);
|
||||
});
|
||||
|
||||
it("非法变更名被拒绝", () => {
|
||||
const validRegex = /^[\u4e00-\u9fa5a-zA-Z-]+$/;
|
||||
expect(validRegex.test("my change")).toBe(false);
|
||||
expect(validRegex.test("my_change")).toBe(false);
|
||||
expect(validRegex.test("")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2,49 +2,70 @@ import { describe, it, expect } from "bun:test";
|
||||
import { showGlobalHelp, showCommandHelp } from "../../src/cli/help.ts";
|
||||
|
||||
describe("showGlobalHelp", () => {
|
||||
it("包含所有命令行", () => {
|
||||
it("默认前缀时包含 rune", () => {
|
||||
const output = showGlobalHelp();
|
||||
expect(output).toContain("rune <命令> [参数]");
|
||||
expect(output).toContain("rune init opencode");
|
||||
});
|
||||
|
||||
it("自定义前缀时使用传入前缀", () => {
|
||||
const output = showGlobalHelp("bunx @lanyuanxiaoyao/rune");
|
||||
expect(output).toContain("bunx @lanyuanxiaoyao/rune <命令> [参数]");
|
||||
expect(output).toContain("bunx @lanyuanxiaoyao/rune init opencode");
|
||||
});
|
||||
|
||||
it("包含所有命令行", () => {
|
||||
const output = showGlobalHelp("rune");
|
||||
expect(output).toContain("init <工具...>");
|
||||
expect(output).toContain("discuss");
|
||||
expect(output).toContain("plan <名称>");
|
||||
expect(output).toContain("create <变更>");
|
||||
expect(output).toContain("plan <变更> <文档>");
|
||||
expect(output).toContain("build <名称>");
|
||||
expect(output).toContain("archive <名称>");
|
||||
expect(output).toContain("finish <名称>");
|
||||
expect(output).toContain("status");
|
||||
expect(output).toContain("help");
|
||||
expect(output).toContain("version");
|
||||
});
|
||||
|
||||
it("包含示例", () => {
|
||||
const output = showGlobalHelp();
|
||||
expect(output).toContain("rune init opencode");
|
||||
expect(output).toContain("rune plan add-login");
|
||||
expect(output).toContain("rune status");
|
||||
});
|
||||
|
||||
it("以标题行开头", () => {
|
||||
const output = showGlobalHelp();
|
||||
const output = showGlobalHelp("rune");
|
||||
expect(output.startsWith("Rune")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("showCommandHelp", () => {
|
||||
it("plan 命令包含用法、参数、描述、示例", () => {
|
||||
const output = showCommandHelp("plan");
|
||||
expect(output).toContain("rune plan <change-name>");
|
||||
const output = showCommandHelp("plan", "bunx @lanyuanxiaoyao/rune");
|
||||
expect(output).toContain("bunx @lanyuanxiaoyao/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");
|
||||
expect(output).toContain("bunx @lanyuanxiaoyao/rune plan add-user-auth");
|
||||
});
|
||||
|
||||
it("init 命令包含工具参数说明", () => {
|
||||
const output = showCommandHelp("init");
|
||||
const output = showCommandHelp("init", "rune");
|
||||
expect(output).toContain("rune init <工具...>");
|
||||
expect(output).toContain("opencode");
|
||||
});
|
||||
|
||||
it("create 命令包含变更名参数", () => {
|
||||
const output = showCommandHelp("create", "rune");
|
||||
expect(output).toContain("rune create <change-name>");
|
||||
expect(output).toContain("变更名称");
|
||||
expect(output).toContain("rune create add-user-auth");
|
||||
});
|
||||
|
||||
it("finish 命令包含归档变更说明", () => {
|
||||
const output = showCommandHelp("finish", "rune");
|
||||
expect(output).toContain("rune finish <change-name>");
|
||||
expect(output).toContain("归档变更");
|
||||
expect(output).toContain("将变更目录");
|
||||
});
|
||||
|
||||
it("不存在的命令返回 null", () => {
|
||||
const output = showCommandHelp("nonexistent");
|
||||
const output = showCommandHelp("nonexistent", "rune");
|
||||
expect(output).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
58
tests/cli/map-error.test.ts
Normal file
58
tests/cli/map-error.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { UsageError, ConfigError, InternalError } from "../../src/cli/errors.ts";
|
||||
import { mapError } from "../../src/cli.ts";
|
||||
import { DEFAULT_PREFIX } from "../../src/core/pm.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(`运行 ${DEFAULT_PREFIX} 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(`${DEFAULT_PREFIX} plan <change-name>`);
|
||||
expect(result.hint).toContain(`${DEFAULT_PREFIX} help plan`);
|
||||
});
|
||||
|
||||
it("未知 Error 转为 InternalError", () => {
|
||||
const err = new Error("something unexpected");
|
||||
const result = mapError(err);
|
||||
expect(result).toBeInstanceOf(InternalError);
|
||||
expect(result.message).toContain("发生了未预期的错误");
|
||||
expect(result.message).toContain("something unexpected");
|
||||
});
|
||||
|
||||
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(
|
||||
|
||||
145
tests/cli/status.test.ts
Normal file
145
tests/cli/status.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
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 时建议 task", () => {
|
||||
const status = makeStatus({
|
||||
documents: [{ name: "design", completed: true, dependMet: true }],
|
||||
planCompleted: true,
|
||||
taskProgress: null,
|
||||
});
|
||||
expect(suggestNextStep(status)).toContain("rune task 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);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, rm, readFile, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { retryRm } from "../helpers/cleanup.ts";
|
||||
import { join } from "node:path";
|
||||
import { runInit } from "../../src/commands/init.ts";
|
||||
import { CommandError } from "../../src/cli/errors.ts";
|
||||
@@ -12,7 +13,7 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(TMP_DIR, { recursive: true, force: true });
|
||||
await retryRm(TMP_DIR);
|
||||
});
|
||||
|
||||
describe("runInit", () => {
|
||||
@@ -22,10 +23,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:");
|
||||
});
|
||||
@@ -44,19 +42,13 @@ describe("runInit", () => {
|
||||
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-discuss", "SKILL.md"))).toBe(true);
|
||||
});
|
||||
|
||||
it("重复 init 不覆盖 config.yaml", async () => {
|
||||
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",
|
||||
);
|
||||
expect(content).toBe("自定义内容");
|
||||
const content = await readFile(join(TMP_DIR, ".rune", "config.yaml"), "utf-8");
|
||||
expect(content).toContain("自定义内容");
|
||||
});
|
||||
|
||||
it("不支持的工具名抛出 CommandError", async () => {
|
||||
@@ -68,4 +60,34 @@ describe("runInit", () => {
|
||||
expect((e as CommandError).message).toContain("不支持的工具: unknown-tool");
|
||||
}
|
||||
});
|
||||
|
||||
it("首次 init 时 config.yaml 包含 metadata.command", async () => {
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
|
||||
const content = await readFile(join(TMP_DIR, ".rune", "config.yaml"), "utf-8");
|
||||
expect(content).toContain("metadata:");
|
||||
expect(content).toContain("command:");
|
||||
});
|
||||
|
||||
it("config.yaml 模板不含 {{change-name}}", async () => {
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
|
||||
const content = await readFile(join(TMP_DIR, ".rune", "config.yaml"), "utf-8");
|
||||
expect(content).not.toContain("{{change-name}}");
|
||||
});
|
||||
|
||||
it("config.yaml 模板包含 metadata 说明", async () => {
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
|
||||
const content = await readFile(join(TMP_DIR, ".rune", "config.yaml"), "utf-8");
|
||||
expect(content).toContain("metadata");
|
||||
expect(content).toContain("command");
|
||||
});
|
||||
|
||||
it("config.yaml 模板包含 create 命令说明", async () => {
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
|
||||
const content = await readFile(join(TMP_DIR, ".rune", "config.yaml"), "utf-8");
|
||||
expect(content).toContain("create");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdir, writeFile, rm } from "node:fs/promises";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { retryRm } from "../helpers/cleanup.ts";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
assembleDiscussPrompt,
|
||||
assemblePlanPrompt,
|
||||
assembleBuildPrompt,
|
||||
assembleArchivePrompt,
|
||||
assembleTaskPrompt,
|
||||
} from "../../src/core/assembler.ts";
|
||||
import type { RuneConfig } from "../../src/types.ts";
|
||||
import { defaultConfig } from "../../src/defaults/config.ts";
|
||||
@@ -17,14 +19,16 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(TMP_DIR, { recursive: true, force: true });
|
||||
await retryRm(TMP_DIR);
|
||||
});
|
||||
|
||||
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 提示词", () => {
|
||||
@@ -32,47 +36,76 @@ describe("assembleDiscussPrompt", () => {
|
||||
stages: { discuss: { prompt: "自定义讨论" } },
|
||||
};
|
||||
const prompt = assembleDiscussPrompt(config);
|
||||
expect(prompt).toBe("自定义讨论");
|
||||
expect(prompt).toContain("自定义讨论");
|
||||
expect(prompt).toContain("# 讨论阶段");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assemblePlanPrompt", () => {
|
||||
it("包含变更名称和文档指引", async () => {
|
||||
const prompt = await assemblePlanPrompt(
|
||||
defaultConfig,
|
||||
TMP_DIR,
|
||||
"user-auth",
|
||||
);
|
||||
it("包含指定文档名称和提示词", async () => {
|
||||
const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "requirements");
|
||||
expect(prompt).toContain("user-auth");
|
||||
expect(prompt).toContain("design");
|
||||
expect(prompt).toContain("task");
|
||||
expect(prompt).toContain("格式模板");
|
||||
expect(prompt).toContain("requirements");
|
||||
expect(prompt).not.toContain("task");
|
||||
});
|
||||
|
||||
it("包含已有文档内容(重复 plan 场景)", async () => {
|
||||
it("已有文档时引导 AI 读取而非内嵌内容", async () => {
|
||||
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
|
||||
await mkdir(changeDir, { recursive: true });
|
||||
await writeFile(join(changeDir, "design.md"), "# 已有设计");
|
||||
const prompt = await assemblePlanPrompt(
|
||||
defaultConfig,
|
||||
TMP_DIR,
|
||||
"user-auth",
|
||||
);
|
||||
expect(prompt).toContain("已有设计");
|
||||
expect(prompt).toContain("在此基础上修订");
|
||||
await writeFile(join(changeDir, "requirements.md"), "# 已有需求");
|
||||
const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "requirements");
|
||||
expect(prompt).toContain("已有内容");
|
||||
expect(prompt).toContain("requirements.md");
|
||||
expect(prompt).not.toContain("# 已有需求");
|
||||
});
|
||||
|
||||
it("替换模板中的 {{change-name}}", async () => {
|
||||
const prompt = await assemblePlanPrompt(
|
||||
defaultConfig,
|
||||
TMP_DIR,
|
||||
"user-auth",
|
||||
);
|
||||
expect(prompt).toContain("user-auth 设计文档");
|
||||
expect(prompt).toContain("user-auth 任务列表");
|
||||
it("包含格式模板(纯静态文本)", async () => {
|
||||
const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "requirements");
|
||||
expect(prompt).toContain("格式模板");
|
||||
expect(prompt).toContain("背景与目标");
|
||||
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", "requirements");
|
||||
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: {
|
||||
@@ -81,64 +114,240 @@ describe("assemblePlanPrompt", () => {
|
||||
{
|
||||
name: "spec",
|
||||
prompt: "生成规格",
|
||||
template: "# {{change-name}} 规格",
|
||||
template: "# 规格文档",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
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).toContain("规格文档");
|
||||
expect(prompt).not.toContain("design");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assembleBuildPrompt", () => {
|
||||
it("包含待执行任务列表", async () => {
|
||||
it("引导 AI 读取 task.md 而非内嵌任务内容", async () => {
|
||||
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
|
||||
await mkdir(changeDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(changeDir, "task.md"),
|
||||
`- [x] 任务一\n- [ ] 任务二\n- [ ] 任务三`,
|
||||
);
|
||||
const prompt = await assembleBuildPrompt(
|
||||
defaultConfig,
|
||||
TMP_DIR,
|
||||
"user-auth",
|
||||
);
|
||||
expect(prompt).toContain("任务二");
|
||||
expect(prompt).toContain("待执行任务");
|
||||
expect(prompt).toContain("共 2 项");
|
||||
await writeFile(join(changeDir, "task.md"), `- [x] 任务一\n- [ ] 任务二\n- [ ] 任务三`);
|
||||
const prompt = await assembleBuildPrompt(defaultConfig, TMP_DIR, "user-auth");
|
||||
expect(prompt).toContain("task.md");
|
||||
expect(prompt).toContain("2");
|
||||
expect(prompt).not.toContain("任务二");
|
||||
expect(prompt).not.toContain("任务三");
|
||||
});
|
||||
|
||||
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("task");
|
||||
}
|
||||
});
|
||||
|
||||
it("task.md 格式不合法时抛错", async () => {
|
||||
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
|
||||
await mkdir(changeDir, { recursive: true });
|
||||
await writeFile(join(changeDir, "task.md"), "# 标题\n无 checkbox");
|
||||
const config: RuneConfig = {
|
||||
stages: { build: { prompt: "构建阶段" } },
|
||||
};
|
||||
try {
|
||||
await assembleBuildPrompt(config, TMP_DIR, "user-auth");
|
||||
expect.unreachable();
|
||||
} catch (e: any) {
|
||||
expect(e.message).toContain("task.md");
|
||||
expect(e.message).toContain("checkbox");
|
||||
expect(e.hint).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("task.md 有空 checkbox 文本时抛错", async () => {
|
||||
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
|
||||
await mkdir(changeDir, { recursive: true });
|
||||
await writeFile(join(changeDir, "task.md"), "- [ ] \n- [x] 有内容");
|
||||
const config: RuneConfig = {
|
||||
stages: { build: { prompt: "构建阶段" } },
|
||||
};
|
||||
try {
|
||||
await assembleBuildPrompt(config, TMP_DIR, "user-auth");
|
||||
expect.unreachable();
|
||||
} catch (e: any) {
|
||||
expect(e.message).toContain("checkbox");
|
||||
expect(e.hint).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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("归档");
|
||||
});
|
||||
|
||||
it("未完成任务时注入警告并引导用户确认", async () => {
|
||||
const config: RuneConfig = {
|
||||
stages: { archive: { prompt: "归档阶段" } },
|
||||
};
|
||||
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
|
||||
await mkdir(changeDir, { recursive: true });
|
||||
await writeFile(join(changeDir, "task.md"), "- [ ] 未完成任务");
|
||||
const prompt = await assembleArchivePrompt(config, TMP_DIR, "user-auth");
|
||||
expect(prompt).toContain("未完成");
|
||||
expect(prompt).toContain("未完成任务");
|
||||
expect(prompt).toContain("是否确认");
|
||||
});
|
||||
|
||||
it("所有任务完成时不注入警告", async () => {
|
||||
const config: RuneConfig = {
|
||||
stages: { archive: { prompt: "归档阶段" } },
|
||||
};
|
||||
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
|
||||
await mkdir(changeDir, { recursive: true });
|
||||
await writeFile(join(changeDir, "task.md"), "- [x] 已完成任务");
|
||||
const prompt = await assembleArchivePrompt(config, TMP_DIR, "user-auth");
|
||||
expect(prompt).not.toContain("未完成");
|
||||
});
|
||||
|
||||
it("task.md 不存在时不注入警告", async () => {
|
||||
const config: RuneConfig = {
|
||||
stages: { archive: { prompt: "归档阶段" } },
|
||||
};
|
||||
const prompt = await assembleArchivePrompt(config, TMP_DIR, "no-task-change");
|
||||
expect(prompt).toContain("归档阶段");
|
||||
expect(prompt).not.toContain("警告");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assembleTaskPrompt", () => {
|
||||
it("任务拆解阶段提示词包含变更名和文档路径", async () => {
|
||||
const config: RuneConfig = {
|
||||
stages: {
|
||||
plan: { documents: [{ name: "design", prompt: "生成设计" }] },
|
||||
task: { prompt: "拆解任务" },
|
||||
},
|
||||
};
|
||||
const changeDir = join(TMP_DIR, ".rune", "changes", "feature-x");
|
||||
await mkdir(changeDir, { recursive: true });
|
||||
await writeFile(join(changeDir, "design.md"), "# 设计");
|
||||
const prompt = await assembleTaskPrompt(config, TMP_DIR, "feature-x");
|
||||
expect(prompt).toContain("feature-x");
|
||||
expect(prompt).toContain("design.md");
|
||||
});
|
||||
|
||||
it("task.md 已存在时提示修订", async () => {
|
||||
const config: RuneConfig = {
|
||||
stages: {
|
||||
plan: { documents: [{ name: "design", prompt: "生成设计" }] },
|
||||
task: { prompt: "拆解任务" },
|
||||
},
|
||||
};
|
||||
const changeDir = join(TMP_DIR, ".rune", "changes", "revise-task");
|
||||
await mkdir(changeDir, { recursive: true });
|
||||
await writeFile(join(changeDir, "design.md"), "# 设计");
|
||||
await writeFile(join(changeDir, "task.md"), "- [ ] 已有任务");
|
||||
const prompt = await assembleTaskPrompt(config, TMP_DIR, "revise-task");
|
||||
expect(prompt).toContain("已有内容");
|
||||
});
|
||||
|
||||
it("task 阶段未配置时抛错", async () => {
|
||||
const config: RuneConfig = {
|
||||
stages: { plan: { documents: [{ name: "design", prompt: "生成设计" }] } },
|
||||
};
|
||||
try {
|
||||
await assembleTaskPrompt(config, TMP_DIR, "test");
|
||||
expect.unreachable();
|
||||
} catch (e: any) {
|
||||
expect(e.message).toContain("任务拆解阶段未配置");
|
||||
}
|
||||
});
|
||||
|
||||
it("plan 阶段未配置时抛错", async () => {
|
||||
const config: RuneConfig = {
|
||||
stages: { task: { prompt: "拆解任务" } },
|
||||
};
|
||||
try {
|
||||
await assembleTaskPrompt(config, TMP_DIR, "test");
|
||||
expect.unreachable();
|
||||
} catch (e: any) {
|
||||
expect(e.message).toContain("规划阶段未配置");
|
||||
}
|
||||
});
|
||||
|
||||
it("plan 文档未完成时抛错", async () => {
|
||||
const config: RuneConfig = {
|
||||
stages: {
|
||||
plan: { documents: [{ name: "design", prompt: "生成设计" }] },
|
||||
task: { prompt: "拆解任务" },
|
||||
},
|
||||
};
|
||||
try {
|
||||
await assembleTaskPrompt(config, TMP_DIR, "incomplete-plan");
|
||||
expect.unreachable();
|
||||
} catch (e: any) {
|
||||
expect(e.message).toContain("规划文档");
|
||||
expect(e.message).toContain("尚未全部完成");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("命令前缀替换", () => {
|
||||
it("assembleDiscussPrompt 替换 rune 为配置前缀", () => {
|
||||
const config: RuneConfig = {
|
||||
stages: { discuss: { prompt: "执行 rune status 查看状态" } },
|
||||
metadata: { command: "bunx @lanyuanxiaoyao/rune" },
|
||||
};
|
||||
const prompt = assembleDiscussPrompt(config);
|
||||
expect(prompt).toContain("bunx @lanyuanxiaoyao/rune status");
|
||||
expect(prompt).not.toContain("执行 rune ");
|
||||
});
|
||||
|
||||
it("assembleDiscussPrompt 无配置时追加降级说明", () => {
|
||||
const config: RuneConfig = {
|
||||
stages: { discuss: { prompt: "执行 rune status 查看" } },
|
||||
};
|
||||
const prompt = assembleDiscussPrompt(config);
|
||||
expect(prompt).toContain("bunx @lanyuanxiaoyao/rune status");
|
||||
expect(prompt).toContain("如果没有安装 bun");
|
||||
});
|
||||
|
||||
it("assembleBuildPrompt 错误提示使用动态前缀", async () => {
|
||||
const config: RuneConfig = {
|
||||
stages: { build: { prompt: "构建阶段" } },
|
||||
metadata: { command: "pnpx @lanyuanxiaoyao/rune" },
|
||||
};
|
||||
try {
|
||||
await assembleBuildPrompt(config, TMP_DIR, "nonexistent-build");
|
||||
expect.unreachable();
|
||||
} catch (e: any) {
|
||||
expect(e.hint).toContain("pnpx @lanyuanxiaoyao/rune task nonexistent-build");
|
||||
}
|
||||
});
|
||||
|
||||
it("不替换 /rune- 形式", () => {
|
||||
const config: RuneConfig = {
|
||||
stages: { discuss: { prompt: "使用 /rune-plan 进入规划,然后 rune build 构建" } },
|
||||
metadata: { command: "bunx @lanyuanxiaoyao/rune" },
|
||||
};
|
||||
const prompt = assembleDiscussPrompt(config);
|
||||
expect(prompt).toContain("/rune-plan");
|
||||
expect(prompt).toContain("bunx @lanyuanxiaoyao/rune build");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdir, writeFile, rm } from "node:fs/promises";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { retryRm } from "../helpers/cleanup.ts";
|
||||
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__");
|
||||
|
||||
@@ -10,7 +13,7 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(TMP_DIR, { recursive: true, force: true });
|
||||
await retryRm(TMP_DIR);
|
||||
});
|
||||
|
||||
describe("findProjectRoot", () => {
|
||||
@@ -37,6 +40,7 @@ describe("loadConfig", () => {
|
||||
const config = await loadConfig(TMP_DIR);
|
||||
expect(config.stages.discuss).toBeDefined();
|
||||
expect(config.stages.plan).toBeDefined();
|
||||
expect(config.stages.task).toBeDefined();
|
||||
expect(config.stages.build).toBeDefined();
|
||||
expect(config.stages.archive).toBeDefined();
|
||||
});
|
||||
@@ -82,19 +86,16 @@ describe("loadConfig", () => {
|
||||
const config = await loadConfig(TMP_DIR);
|
||||
expect(config.stages.discuss).toBeDefined();
|
||||
expect(config.stages.plan).toBeDefined();
|
||||
expect(config.stages.task).toBeDefined();
|
||||
expect(config.stages.build).toBeDefined();
|
||||
expect(config.stages.archive).toBeDefined();
|
||||
});
|
||||
|
||||
it("YAML 解析错误时返回默认配置", async () => {
|
||||
it("YAML 解析错误时抛出 ConfigError", async () => {
|
||||
const runeDir = join(TMP_DIR, ".rune");
|
||||
await mkdir(runeDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(runeDir, "config.yaml"),
|
||||
`stages: [invalid yaml {{{`,
|
||||
);
|
||||
const config = await loadConfig(TMP_DIR);
|
||||
expect(config.stages.discuss).toBeDefined();
|
||||
await writeFile(join(runeDir, "config.yaml"), `stages: [invalid yaml {{{`);
|
||||
expect(loadConfig(TMP_DIR)).rejects.toThrow(ConfigError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,3 +104,117 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeConfig 保留 metadata", () => {
|
||||
it("保留用户配置中的 metadata", async () => {
|
||||
const tmpDir = join(import.meta.dir, "__tmp_config_meta_test__");
|
||||
await mkdir(tmpDir, { recursive: true });
|
||||
try {
|
||||
const configPath = join(tmpDir, ".rune", "config.yaml");
|
||||
await mkdir(join(tmpDir, ".rune"), { recursive: true });
|
||||
await writeFile(
|
||||
configPath,
|
||||
`metadata:\n command: "bunx @lanyuanxiaoyao/rune"\nstages:\n discuss:\n prompt: "自定义讨论"\n`,
|
||||
);
|
||||
const config = await loadConfig(tmpDir);
|
||||
expect(config.metadata).toBeDefined();
|
||||
expect(config.metadata!.command).toBe("bunx @lanyuanxiaoyao/rune");
|
||||
expect(config.stages.discuss!.prompt).toBe("自定义讨论");
|
||||
} finally {
|
||||
await retryRm(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
it("默认配置包含 task 阶段", async () => {
|
||||
const config = await loadConfig(TMP_DIR);
|
||||
expect(config.stages.task).toBeDefined();
|
||||
expect(config.stages.task!.prompt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("默认 metadata 不包含 tracked", async () => {
|
||||
const config = await loadConfig(TMP_DIR);
|
||||
expect((config.metadata as any)?.tracked).toBeUndefined();
|
||||
});
|
||||
|
||||
it("用户配置 task 阶段覆盖默认", async () => {
|
||||
const runeDir = join(TMP_DIR, ".rune");
|
||||
await mkdir(runeDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(runeDir, "config.yaml"),
|
||||
`stages:
|
||||
task:
|
||||
prompt: 自定义任务提示词
|
||||
`,
|
||||
);
|
||||
const config = await loadConfig(TMP_DIR);
|
||||
expect(config.stages.task!.prompt).toBe("自定义任务提示词");
|
||||
});
|
||||
});
|
||||
|
||||
188
tests/core/pm.test.ts
Normal file
188
tests/core/pm.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
inferFromProcess,
|
||||
getPmPrefix,
|
||||
DEFAULT_PREFIX,
|
||||
getFallbackNote,
|
||||
applyCommandPrefix,
|
||||
} from "../../src/core/pm.ts";
|
||||
import type { RuneConfig } from "../../src/types.ts";
|
||||
|
||||
describe("inferFromProcess", () => {
|
||||
it("argv 包含 bun 缓存路径时返回 bunx 前缀", () => {
|
||||
const result = inferFromProcess(
|
||||
["/usr/local/bin/bun", "/home/user/.bun/cache/@lanyuanxiaoyao-rune/bin/rune.ts"],
|
||||
undefined,
|
||||
);
|
||||
expect(result).toBe("bunx @lanyuanxiaoyao/rune");
|
||||
});
|
||||
|
||||
it("argv Windows 风格 bun 缓存路径时返回 bunx 前缀", () => {
|
||||
const result = inferFromProcess(
|
||||
[
|
||||
"C:\\Users\\user\\.bun\\bin\\bun.exe",
|
||||
"C:\\Users\\user\\.bun\\cache\\@lanyuanxiaoyao-rune\\bin\\rune.ts",
|
||||
],
|
||||
undefined,
|
||||
);
|
||||
expect(result).toBe("bunx @lanyuanxiaoyao/rune");
|
||||
});
|
||||
|
||||
it("argv 包含 _npx 路径时返回 npx 前缀", () => {
|
||||
const result = inferFromProcess(
|
||||
["/usr/local/bin/node", "/home/user/.npm/_npx/abc123/node_modules/.bin/rune"],
|
||||
undefined,
|
||||
);
|
||||
expect(result).toBe("npx @lanyuanxiaoyao/rune");
|
||||
});
|
||||
|
||||
it("argv 包含 npm-cache 路径时返回 npx 前缀", () => {
|
||||
const result = inferFromProcess(
|
||||
["/usr/local/bin/node", "/home/user/.npm-cache/_npx/abc123/bin/rune"],
|
||||
undefined,
|
||||
);
|
||||
expect(result).toBe("npx @lanyuanxiaoyao/rune");
|
||||
});
|
||||
|
||||
it("argv 包含 .pnpm 路径时返回 pnpx 前缀", () => {
|
||||
const result = inferFromProcess(
|
||||
["/usr/local/bin/node", "/home/user/.pnpm/store/@lanyuanxiaoyao-rune/bin/rune"],
|
||||
undefined,
|
||||
);
|
||||
expect(result).toBe("pnpx @lanyuanxiaoyao/rune");
|
||||
});
|
||||
|
||||
it("argv 无法推断但 userAgent 包含 bun 时返回 bunx 前缀", () => {
|
||||
const result = inferFromProcess(
|
||||
["/usr/local/bin/bun", "/home/user/projects/app/run.ts"],
|
||||
"bun/1.0.0",
|
||||
);
|
||||
expect(result).toBe("bunx @lanyuanxiaoyao/rune");
|
||||
});
|
||||
|
||||
it("argv 无法推断但 userAgent 包含 pnpm 时返回 pnpx 前缀", () => {
|
||||
const result = inferFromProcess(
|
||||
["/usr/local/bin/node", "/home/user/projects/app/run.ts"],
|
||||
"pnpm/9.0.0",
|
||||
);
|
||||
expect(result).toBe("pnpx @lanyuanxiaoyao/rune");
|
||||
});
|
||||
|
||||
it("argv 无法推断但 userAgent 包含 yarn 时返回 yarn dlx 前缀", () => {
|
||||
const result = inferFromProcess(
|
||||
["/usr/local/bin/node", "/home/user/projects/app/run.ts"],
|
||||
"yarn/4.0.0",
|
||||
);
|
||||
expect(result).toBe("yarn dlx @lanyuanxiaoyao/rune");
|
||||
});
|
||||
|
||||
it("argv 无法推断但 userAgent 包含 npm 时返回 npx 前缀", () => {
|
||||
const result = inferFromProcess(
|
||||
["/usr/local/bin/node", "/home/user/projects/app/run.ts"],
|
||||
"npm/10.0.0",
|
||||
);
|
||||
expect(result).toBe("npx @lanyuanxiaoyao/rune");
|
||||
});
|
||||
|
||||
it("userAgent 同时包含 pnpm 和 npm 时 pnpm 优先", () => {
|
||||
const result = inferFromProcess(
|
||||
["/usr/local/bin/node", "/home/user/projects/app/run.ts"],
|
||||
"pnpm/9.0.0 npm/10.0.0",
|
||||
);
|
||||
expect(result).toBe("pnpx @lanyuanxiaoyao/rune");
|
||||
});
|
||||
|
||||
it("argv 和 userAgent 都无法推断时返回 null", () => {
|
||||
const result = inferFromProcess(
|
||||
["/usr/local/bin/node", "/home/user/projects/app/run.ts"],
|
||||
undefined,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("argv 为空数组且无 userAgent 时返回 null", () => {
|
||||
const result = inferFromProcess([], undefined);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("argv 只有一个元素且无 userAgent 时返回 null", () => {
|
||||
const result = inferFromProcess(["/usr/local/bin/node"], undefined);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPmPrefix", () => {
|
||||
it("有配置时返回 config.metadata.command", () => {
|
||||
const config: RuneConfig = {
|
||||
stages: {},
|
||||
metadata: { command: "rune" },
|
||||
};
|
||||
expect(getPmPrefix(config)).toBe("rune");
|
||||
});
|
||||
|
||||
it("配置中无 metadata 时返回默认前缀", () => {
|
||||
const config: RuneConfig = { stages: {} };
|
||||
expect(getPmPrefix(config)).toBe(DEFAULT_PREFIX);
|
||||
});
|
||||
|
||||
it("不传配置时返回默认前缀", () => {
|
||||
expect(getPmPrefix()).toBe(DEFAULT_PREFIX);
|
||||
expect(getPmPrefix(undefined)).toBe("bunx @lanyuanxiaoyao/rune");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFallbackNote", () => {
|
||||
it("返回降级说明文本", () => {
|
||||
const note = getFallbackNote();
|
||||
expect(note).toContain("pnpx @lanyuanxiaoyao/rune");
|
||||
expect(note).toContain("npx @lanyuanxiaoyao/rune");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyCommandPrefix", () => {
|
||||
it("有配置时替换 rune 为配置前缀", () => {
|
||||
const config: RuneConfig = {
|
||||
stages: {},
|
||||
metadata: { command: "rune" },
|
||||
};
|
||||
const result = applyCommandPrefix("执行 rune status 查看", config);
|
||||
expect(result).toBe("执行 rune status 查看");
|
||||
});
|
||||
|
||||
it("无配置时替换为默认前缀", () => {
|
||||
const result = applyCommandPrefix("执行 rune status 查看", undefined);
|
||||
expect(result).toContain("bunx @lanyuanxiaoyao/rune status");
|
||||
expect(result).not.toContain("执行 rune status");
|
||||
});
|
||||
|
||||
it("无配置且文本含命令时追加降级说明", () => {
|
||||
const result = applyCommandPrefix("执行 rune status 查看", undefined);
|
||||
expect(result).toContain("pnpx @lanyuanxiaoyao/rune");
|
||||
expect(result).toContain("如果没有安装 bun");
|
||||
});
|
||||
|
||||
it("有配置时不追加降级说明", () => {
|
||||
const config: RuneConfig = {
|
||||
stages: {},
|
||||
metadata: { command: "bunx @lanyuanxiaoyao/rune" },
|
||||
};
|
||||
const result = applyCommandPrefix("执行 rune status 查看", config);
|
||||
expect(result).not.toContain("如果没有安装 bun");
|
||||
});
|
||||
|
||||
it("不替换 /rune- 形式(编辑器斜杠命令)", () => {
|
||||
const text = "使用 /rune-plan 进入规划阶段,然后 rune build 开始构建";
|
||||
const config: RuneConfig = {
|
||||
stages: {},
|
||||
metadata: { command: "bunx @lanyuanxiaoyao/rune" },
|
||||
};
|
||||
const result = applyCommandPrefix(text, config);
|
||||
expect(result).toContain("/rune-plan");
|
||||
expect(result).toContain("bunx @lanyuanxiaoyao/rune build");
|
||||
});
|
||||
|
||||
it("文本不含 rune 命令时无配置也不追加降级说明", () => {
|
||||
const result = applyCommandPrefix("纯文本无命令", undefined);
|
||||
expect(result).toBe("纯文本无命令");
|
||||
});
|
||||
});
|
||||
78
tests/core/prompt-builder.test.ts
Normal file
78
tests/core/prompt-builder.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { PromptBuilder } from "../../src/core/prompt-builder";
|
||||
|
||||
describe("PromptBuilder", () => {
|
||||
it("空 builder build 返回空字符串", () => {
|
||||
const builder = new PromptBuilder();
|
||||
expect(builder.build()).toBe("");
|
||||
});
|
||||
|
||||
it("单段输出", () => {
|
||||
const result = new PromptBuilder().head("# 标题").build();
|
||||
expect(result).toBe("# 标题");
|
||||
});
|
||||
|
||||
it("多段按序渲染", () => {
|
||||
const result = new PromptBuilder()
|
||||
.head("# 阶段")
|
||||
.context("状态信息")
|
||||
.prompt("核心引导")
|
||||
.guide("操作指令")
|
||||
.warn("警告信息")
|
||||
.build();
|
||||
|
||||
const headPos = result.indexOf("# 阶段");
|
||||
const contextPos = result.indexOf("状态信息");
|
||||
const promptPos = result.indexOf("核心引导");
|
||||
const guidePos = result.indexOf("操作指令");
|
||||
const warnPos = result.indexOf("警告信息");
|
||||
|
||||
expect(headPos).toBeLessThan(contextPos);
|
||||
expect(contextPos).toBeLessThan(promptPos);
|
||||
expect(promptPos).toBeLessThan(guidePos);
|
||||
expect(guidePos).toBeLessThan(warnPos);
|
||||
});
|
||||
|
||||
it("空段跳过", () => {
|
||||
const result = new PromptBuilder().head("# 标题").prompt("内容").guide("指引").build();
|
||||
expect(result).toContain("# 标题");
|
||||
expect(result).toContain("内容");
|
||||
expect(result).toContain("指引");
|
||||
});
|
||||
|
||||
it("同 kind 多次追加拼接", () => {
|
||||
const result = new PromptBuilder()
|
||||
.head("# 阶段")
|
||||
.context("信息一")
|
||||
.context("信息二")
|
||||
.prompt("prompt")
|
||||
.build();
|
||||
expect(result).toContain("信息一\n\n信息二");
|
||||
});
|
||||
|
||||
it("head 重复设值覆盖", () => {
|
||||
const result = new PromptBuilder().head("# 旧标题").head("# 新标题").build();
|
||||
expect(result).toBe("# 新标题");
|
||||
expect(result).not.toContain("旧标题");
|
||||
});
|
||||
|
||||
it("prompt 重复设值覆盖", () => {
|
||||
const result = new PromptBuilder().prompt("旧 prompt").prompt("新 prompt").build();
|
||||
expect(result).toContain("新 prompt");
|
||||
expect(result).not.toContain("旧 prompt");
|
||||
});
|
||||
|
||||
it("空字符串段被跳过", () => {
|
||||
const result = new PromptBuilder().head("# 标题").context("").prompt("内容").build();
|
||||
expect(result).toBe("# 标题\n---\n内容");
|
||||
});
|
||||
|
||||
it("链式调用返回 this", () => {
|
||||
const builder = new PromptBuilder();
|
||||
expect(builder.head("x")).toBe(builder);
|
||||
expect(builder.context("x")).toBe(builder);
|
||||
expect(builder.prompt("x")).toBe(builder);
|
||||
expect(builder.guide("x")).toBe(builder);
|
||||
expect(builder.warn("x")).toBe(builder);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdir, writeFile, rm } from "node:fs/promises";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { retryRm } from "../helpers/cleanup.ts";
|
||||
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__");
|
||||
|
||||
@@ -10,7 +12,7 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(TMP_DIR, { recursive: true, force: true });
|
||||
await retryRm(TMP_DIR);
|
||||
});
|
||||
|
||||
describe("scanChanges", () => {
|
||||
@@ -20,20 +22,14 @@ describe("scanChanges", () => {
|
||||
expect(changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("扫描到变更及其文档", async () => {
|
||||
it("有 task.md 时无条件计算 taskProgress", 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"),
|
||||
`- [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");
|
||||
expect(changes[0].taskProgress).toEqual({ completed: 1, total: 2 });
|
||||
});
|
||||
|
||||
@@ -57,6 +53,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", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { parseTasks } from "../../src/core/task-parser.ts";
|
||||
import { parseTasks, validateTaskFormat, TaskFormatError } from "../../src/core/task-parser.ts";
|
||||
|
||||
describe("parseTasks", () => {
|
||||
it("解析标准 checkbox 格式", () => {
|
||||
@@ -52,3 +52,29 @@ describe("parseTasks", () => {
|
||||
expect(tasks.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateTaskFormat", () => {
|
||||
it("合法 task 内容通过校验", () => {
|
||||
expect(() => validateTaskFormat("- [x] 已完成\n- [ ] 未完成")).not.toThrow();
|
||||
});
|
||||
|
||||
it("无 checkbox 项时抛错", () => {
|
||||
expect(() => validateTaskFormat("# 标题\n一些描述")).toThrow(TaskFormatError);
|
||||
});
|
||||
|
||||
it("空内容抛错", () => {
|
||||
expect(() => validateTaskFormat("")).toThrow(TaskFormatError);
|
||||
});
|
||||
|
||||
it("checkbox 文本为空时抛错", () => {
|
||||
expect(() => validateTaskFormat("- [ ] \n- [x] 有内容")).toThrow(TaskFormatError);
|
||||
});
|
||||
|
||||
it("checkbox 文本仅空格时抛错", () => {
|
||||
expect(() => validateTaskFormat("- [ ] ")).toThrow(TaskFormatError);
|
||||
});
|
||||
|
||||
it("有 checkbox 且文本非空时通过", () => {
|
||||
expect(() => validateTaskFormat("- [ ] 实现功能A")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,9 +2,10 @@ import { describe, it, expect } from "bun:test";
|
||||
import { defaultConfig } from "../../src/defaults/config.ts";
|
||||
|
||||
describe("defaultConfig", () => {
|
||||
it("包含所有四个阶段的配置", () => {
|
||||
it("包含所有五个阶段的配置", () => {
|
||||
expect(defaultConfig.stages.discuss).toBeDefined();
|
||||
expect(defaultConfig.stages.plan).toBeDefined();
|
||||
expect(defaultConfig.stages.task).toBeDefined();
|
||||
expect(defaultConfig.stages.build).toBeDefined();
|
||||
expect(defaultConfig.stages.archive).toBeDefined();
|
||||
});
|
||||
@@ -14,38 +15,56 @@ describe("defaultConfig", () => {
|
||||
expect(typeof defaultConfig.stages.discuss!.prompt).toBe("string");
|
||||
});
|
||||
|
||||
it("plan 阶段包含 design 和 task 两个文档配置", () => {
|
||||
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 阶段包含三个文档配置(requirements/design/plan)", () => {
|
||||
const docs = defaultConfig.stages.plan!.documents;
|
||||
expect(docs).toHaveLength(3);
|
||||
expect(docs[0].name).toBe("requirements");
|
||||
expect(docs[0].depend).toEqual([]);
|
||||
expect(docs[1].name).toBe("design");
|
||||
expect(docs[1].depend).toEqual(["requirements"]);
|
||||
expect(docs[2].name).toBe("plan");
|
||||
expect(docs[2].depend).toEqual(["requirements", "design"]);
|
||||
});
|
||||
|
||||
it("每个 plan 文档都有 prompt 和 template", () => {
|
||||
const docs = defaultConfig.stages.plan!.documents;
|
||||
expect(docs).toHaveLength(2);
|
||||
expect(docs[0].name).toBe("design");
|
||||
expect(docs[1].name).toBe("task");
|
||||
for (const doc of docs) {
|
||||
expect(doc.prompt).toBeTruthy();
|
||||
expect(doc.template).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("plan 的 task 文档配置存在", () => {
|
||||
const taskDoc = defaultConfig.stages.plan!.documents.find(
|
||||
(d) => d.name === "task",
|
||||
);
|
||||
expect(taskDoc).toBeDefined();
|
||||
expect(taskDoc!.prompt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("design 文档有 template", () => {
|
||||
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",
|
||||
);
|
||||
expect(taskDoc!.template).toBeTruthy();
|
||||
expect(taskDoc!.template).toContain("- [ ]");
|
||||
it("task 阶段有 prompt", () => {
|
||||
expect(defaultConfig.stages.task).toBeDefined();
|
||||
expect(defaultConfig.stages.task!.prompt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("build 阶段有 prompt", () => {
|
||||
|
||||
68
tests/helpers/cleanup.test.ts
Normal file
68
tests/helpers/cleanup.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { mkdir, access } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { retryRm } from "./cleanup.ts";
|
||||
|
||||
const TMP_DIR = join(import.meta.dir, "__tmp_cleanup_test__");
|
||||
|
||||
async function dirExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe("retryRm", () => {
|
||||
it("删除存在的目录", async () => {
|
||||
await mkdir(TMP_DIR, { recursive: true });
|
||||
await retryRm(TMP_DIR);
|
||||
expect(await dirExists(TMP_DIR)).toBe(false);
|
||||
});
|
||||
|
||||
it("删除不存在的路径不报错(force:true)", async () => {
|
||||
const nonExist = join(import.meta.dir, "__non_exist__");
|
||||
await expect(retryRm(nonExist)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("EBUSY 后重试成功", async () => {
|
||||
let callCount = 0;
|
||||
const fakeRm = async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
const err: any = new Error("EBUSY");
|
||||
err.code = "EBUSY";
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
await retryRm("/some/path", { maxRetries: 3, baseDelay: 10, _rm: fakeRm as any });
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
|
||||
it("maxRetries 耗尽后抛出最后一个错误", async () => {
|
||||
let callCount = 0;
|
||||
const fakeRm = async () => {
|
||||
callCount++;
|
||||
const err: any = new Error("EBUSY");
|
||||
err.code = "EBUSY";
|
||||
throw err;
|
||||
};
|
||||
await expect(
|
||||
retryRm("/some/path", { maxRetries: 2, baseDelay: 10, _rm: fakeRm as any }),
|
||||
).rejects.toThrow("EBUSY");
|
||||
expect(callCount).toBe(3);
|
||||
});
|
||||
|
||||
it("ENOENT 错误直接抛出不重试", async () => {
|
||||
let callCount = 0;
|
||||
const fakeRm = async () => {
|
||||
callCount++;
|
||||
const err: any = new Error("ENOENT: no such file or directory");
|
||||
err.code = "ENOENT";
|
||||
throw err;
|
||||
};
|
||||
await expect(retryRm("/some/path", { _rm: fakeRm as any })).rejects.toThrow("ENOENT");
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
});
|
||||
25
tests/helpers/cleanup.ts
Normal file
25
tests/helpers/cleanup.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { rm as fsRm } from "node:fs/promises";
|
||||
|
||||
const RETRY_CODES = new Set(["EBUSY", "EPERM"]);
|
||||
|
||||
export async function retryRm(
|
||||
path: string,
|
||||
{
|
||||
maxRetries = 5,
|
||||
baseDelay = 100,
|
||||
_rm = fsRm,
|
||||
}: { maxRetries?: number; baseDelay?: number; _rm?: typeof fsRm } = {},
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await _rm(path, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err: any) {
|
||||
if (!RETRY_CODES.has(err.code) || attempt === maxRetries) {
|
||||
throw err;
|
||||
}
|
||||
const delay = baseDelay * 2 ** attempt;
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
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, rename } from "node:fs/promises";
|
||||
import { retryRm } from "../helpers/cleanup.ts";
|
||||
import { join } from "node:path";
|
||||
import { runInit } from "../../src/commands/init.ts";
|
||||
import { loadConfig, getChangeDir, getArchiveDir } from "../../src/core/config.ts";
|
||||
import {
|
||||
assembleDiscussPrompt,
|
||||
assemblePlanPrompt,
|
||||
assembleTaskPrompt,
|
||||
assembleBuildPrompt,
|
||||
assembleArchivePrompt,
|
||||
} from "../../src/core/assembler.ts";
|
||||
@@ -19,61 +21,65 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(TMP_DIR, { recursive: true, force: true });
|
||||
await retryRm(TMP_DIR);
|
||||
});
|
||||
|
||||
describe("完整 SDD 流程", () => {
|
||||
it("init → discuss → plan → build → archive 完整流程", async () => {
|
||||
it("init → discuss → plan → task → build → archive 完整流程", async () => {
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
expect(existsSync(join(TMP_DIR, ".rune", "config.yaml"))).toBe(true);
|
||||
expect(existsSync(join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"))).toBe(true);
|
||||
|
||||
const config = await loadConfig(TMP_DIR);
|
||||
const discussPrompt = assembleDiscussPrompt(config);
|
||||
expect(discussPrompt).toContain("软件架构师");
|
||||
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, "requirements.md"), "# 需求\n\n## 背景\n需要用户登录功能");
|
||||
await writeFile(join(changeDir, "design.md"), "# 用户认证设计\n\n## 背景\n需要用户登录功能");
|
||||
await writeFile(join(changeDir, "plan.md"), "# 实现计划\n\n## 阶段 1: 实现登录");
|
||||
|
||||
const taskPrompt = await assembleTaskPrompt(config, TMP_DIR, changeName);
|
||||
expect(taskPrompt).toContain("user-auth");
|
||||
expect(taskPrompt).toContain("task.md");
|
||||
expect(taskPrompt).toContain("design.md");
|
||||
|
||||
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");
|
||||
expect(buildPrompt).toContain("共 2 项");
|
||||
expect(buildPrompt).toContain("task.md");
|
||||
expect(buildPrompt).toContain("2");
|
||||
|
||||
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);
|
||||
expect(archivePrompt).toContain("归档");
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const src = getChangeDir(TMP_DIR, changeName);
|
||||
const dest = join(getArchiveDir(TMP_DIR), `${today}-${changeName}`);
|
||||
await mkdir(join(TMP_DIR, ".rune", "archive"), { recursive: true });
|
||||
await rename(src, dest);
|
||||
const archivePrompt = await assembleArchivePrompt(config, TMP_DIR, changeName);
|
||||
expect(archivePrompt).toContain("归档阶段");
|
||||
expect(archivePrompt).toContain("user-auth");
|
||||
expect(archivePrompt).toContain("finish");
|
||||
|
||||
expect(existsSync(dest)).toBe(true);
|
||||
expect(existsSync(src)).toBe(false);
|
||||
|
||||
const archives = await scanArchives(TMP_DIR);
|
||||
expect(archives).toContain(`${today}-${changeName}`);
|
||||
|
||||
const postArchiveChanges = await scanChanges(TMP_DIR);
|
||||
expect(postArchiveChanges).toHaveLength(0);
|
||||
const postArchiveChanges = await scanChanges(TMP_DIR, config);
|
||||
expect(postArchiveChanges).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("多变更并行", async () => {
|
||||
@@ -86,14 +92,14 @@ 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");
|
||||
expect(authPrompt).toContain("auth 任务");
|
||||
expect(authPrompt).toContain("task.md");
|
||||
|
||||
const paymentPrompt = await assembleBuildPrompt(config, TMP_DIR, "payment");
|
||||
expect(paymentPrompt).toContain("payment 任务");
|
||||
expect(paymentPrompt).toContain("task.md");
|
||||
});
|
||||
|
||||
it("自定义配置覆盖默认配置", async () => {
|
||||
@@ -108,22 +114,167 @@ describe("完整 SDD 流程", () => {
|
||||
documents:
|
||||
- name: spec
|
||||
prompt: 生成规格文档
|
||||
template: "# {{change-name}} 规格"
|
||||
template: "# 规格文档"
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(TMP_DIR);
|
||||
|
||||
const discussPrompt = assembleDiscussPrompt(config);
|
||||
expect(discussPrompt).toBe("自定义讨论");
|
||||
expect(discussPrompt).toContain("自定义讨论");
|
||||
expect(discussPrompt).toContain("# 讨论阶段");
|
||||
|
||||
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).toContain("规格文档");
|
||||
expect(planPrompt).not.toContain("design");
|
||||
|
||||
expect(config.stages.task).toBeDefined();
|
||||
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, "requirements.md"), "# 需求");
|
||||
await writeFile(join(changeDir, "design.md"), "# 设计文档");
|
||||
|
||||
const changes = await scanChanges(TMP_DIR, config);
|
||||
expect(changes).toHaveLength(1);
|
||||
|
||||
const requirementsDoc = changes[0].documents.find((d) => d.name === "requirements");
|
||||
expect(requirementsDoc).toBeDefined();
|
||||
expect(requirementsDoc!.completed).toBe(true);
|
||||
|
||||
const designDoc = changes[0].documents.find((d) => d.name === "design");
|
||||
expect(designDoc).toBeDefined();
|
||||
expect(designDoc!.completed).toBe(true);
|
||||
|
||||
const planDoc = changes[0].documents.find((d) => d.name === "plan");
|
||||
expect(planDoc).toBeDefined();
|
||||
expect(planDoc!.completed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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("未完成任务");
|
||||
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("警告");
|
||||
});
|
||||
});
|
||||
|
||||
describe("finish 命令", () => {
|
||||
it("将变更目录移动到 archive", async () => {
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
const config = await loadConfig(TMP_DIR);
|
||||
|
||||
const changeName = "finish-test";
|
||||
await mkdir(getChangeDir(TMP_DIR, changeName), { recursive: true });
|
||||
await writeFile(join(getChangeDir(TMP_DIR, changeName), "design.md"), "# 设计");
|
||||
await writeFile(join(getChangeDir(TMP_DIR, changeName), "task.md"), "- [x] 全部完成");
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const src = getChangeDir(TMP_DIR, changeName);
|
||||
const dest = join(getArchiveDir(TMP_DIR), `${today}-${changeName}`);
|
||||
await mkdir(getArchiveDir(TMP_DIR), { recursive: true });
|
||||
await rename(src, dest);
|
||||
|
||||
expect(existsSync(dest)).toBe(true);
|
||||
expect(existsSync(src)).toBe(false);
|
||||
|
||||
const archives = await scanArchives(TMP_DIR);
|
||||
expect(archives).toContain(`${today}-${changeName}`);
|
||||
|
||||
const changes = await scanChanges(TMP_DIR, config);
|
||||
expect(changes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("目标路径已存在时抛出错误", async () => {
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
|
||||
const changeName = "dup-test";
|
||||
await mkdir(getChangeDir(TMP_DIR, changeName), { recursive: true });
|
||||
await writeFile(join(getChangeDir(TMP_DIR, changeName), "design.md"), "# 设计");
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const dest = join(getArchiveDir(TMP_DIR), `${today}-${changeName}`);
|
||||
await mkdir(dest, { recursive: true });
|
||||
await writeFile(join(dest, "existing.md"), "# 已有内容");
|
||||
|
||||
await expect(rename(getChangeDir(TMP_DIR, changeName), dest)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("变更目录不存在时抛出错误", async () => {
|
||||
await runInit(TMP_DIR, ["opencode"]);
|
||||
|
||||
await expect(
|
||||
rename(
|
||||
getChangeDir(TMP_DIR, "nonexistent"),
|
||||
join(getArchiveDir(TMP_DIR), "2024-01-01-nonexistent"),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
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