Compare commits
126 Commits
bafbbaa01e
...
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 | |||
| 03b0c60fb6 | |||
| c924e17253 | |||
| 92dbffbec0 | |||
| 11819a270b | |||
| c4511ca825 | |||
| c63912dc0d | |||
| 1fbec93d55 | |||
| 50456188a0 | |||
| dab63975f5 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -404,3 +404,4 @@ temp
|
|||||||
.worktrees
|
.worktrees
|
||||||
!src/**/*
|
!src/**/*
|
||||||
docs/superpowers
|
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 记录开发技术细节
|
- README.md 记录用户使用方法,DEVELOPMENT.md 记录开发技术细节
|
||||||
- 使用中文(注释、文档、交流),面向中文开发者
|
- 使用中文(注释、文档、交流),面向中文开发者
|
||||||
- 本项目无需考虑向前兼容性
|
- 本项目无需考虑向前兼容性
|
||||||
|
- temp目录下是手动测试使用的临时文件夹,不需要参与扫描和设计决策
|
||||||
|
|
||||||
## 全局红线
|
## 全局红线
|
||||||
|
|
||||||
@@ -22,3 +23,4 @@
|
|||||||
- 单个文件或目录只分配给一个 subagent,不重复分配;subagent 输出文件路径、行号和问题摘要,不输出大段源码
|
- 单个文件或目录只分配给一个 subagent,不重复分配;subagent 输出文件路径、行号和问题摘要,不输出大段源码
|
||||||
- 主 agent 负责最终结论:去重、交叉验证、合并同根因问题
|
- 主 agent 负责最终结论:去重、交叉验证、合并同根因问题
|
||||||
- 优先使用提问工具对用户确认
|
- 优先使用提问工具对用户确认
|
||||||
|
- 禁止提交docs/superpowers目录和目录下的设计文档,不需要留存
|
||||||
|
|||||||
115
DEVELOPMENT.md
115
DEVELOPMENT.md
@@ -13,6 +13,10 @@
|
|||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── cli.ts # CLI 入口
|
├── cli.ts # CLI 入口
|
||||||
|
├── cli/
|
||||||
|
│ ├── errors.ts # CLI 错误定义(CliError 层级)
|
||||||
|
│ ├── output.ts # 格式化输出(错误/用法/提示)
|
||||||
|
│ └── help.ts # 帮助文本生成
|
||||||
├── types.ts # 类型定义
|
├── types.ts # 类型定义
|
||||||
├── commands/
|
├── commands/
|
||||||
│ └── init.ts # init 命令
|
│ └── init.ts # init 命令
|
||||||
@@ -20,12 +24,16 @@ src/
|
|||||||
│ ├── config.ts # 配置加载
|
│ ├── config.ts # 配置加载
|
||||||
│ ├── scanner.ts # 状态扫描
|
│ ├── scanner.ts # 状态扫描
|
||||||
│ ├── assembler.ts # 提示词拼装
|
│ ├── assembler.ts # 提示词拼装
|
||||||
│ └── task-parser.ts # 任务解析
|
│ ├── task-parser.ts # 任务解析
|
||||||
|
│ └── pm.ts # 包管理器检测与命令前缀
|
||||||
├── adapters/
|
├── adapters/
|
||||||
│ ├── opencode.ts # OpenCode 适配器
|
│ ├── opencode.ts # OpenCode 适配器
|
||||||
│ └── claude-code.ts # Claude Code 适配器(占位)
|
│ ├── claude-code.ts # Claude Code 适配器
|
||||||
└── defaults/
|
│ └── utils.ts # 适配器工具函数
|
||||||
└── config.ts # 内置默认配置
|
├── defaults/
|
||||||
|
│ └── config.ts # 内置默认配置
|
||||||
|
scripts/
|
||||||
|
└── release.ts # 发布脚本
|
||||||
|
|
||||||
tests/ # 测试目录(镜像 src 结构)
|
tests/ # 测试目录(镜像 src 结构)
|
||||||
```
|
```
|
||||||
@@ -33,25 +41,102 @@ tests/ # 测试目录(镜像 src 结构)
|
|||||||
## 开发命令
|
## 开发命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun test # 运行全部测试
|
bun test # 运行单元/集成测试
|
||||||
bun test tests/core/ # 运行指定目录测试
|
bun test tests/core/ # 运行指定目录测试
|
||||||
|
bun run release # 发布新版本(交互式递增版本号、测试门禁、git commit+tag、npm publish)
|
||||||
bun src/cli.ts init opencode # 测试 init 命令
|
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 交互架构
|
||||||
|
|
||||||
|
### 子命令
|
||||||
|
|
||||||
|
CLI 通过子命令提供帮助和版本信息,不使用 `--help`/`--version` 标志:
|
||||||
|
|
||||||
|
- `rune help` — 显示全局帮助(可用命令列表)
|
||||||
|
- `rune help <command>` — 显示指定命令的详细用法
|
||||||
|
- `rune version` — 显示版本号
|
||||||
|
|
||||||
|
### 错误处理
|
||||||
|
|
||||||
|
错误消息采用结构化格式,相关代码位于:
|
||||||
|
|
||||||
|
- `src/cli/errors.ts` — `CliError` 错误层级(未知命令、缺少参数等)
|
||||||
|
- `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 继续下一阶段。
|
发布前确保已通过 `npm login` 登录 npm,且 npm 账号有 `@lanyuanxiaoyao` scope 的发布权限。
|
||||||
|
|
||||||
### Level 4 — 真实 AI 工具集成(CI 可选)
|
|
||||||
|
|
||||||
调用 LLM API 验证输出格式可被解析。
|
|
||||||
|
|||||||
89
README.md
89
README.md
@@ -5,37 +5,108 @@
|
|||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bunx rune init opencode
|
bunx @lanyuanxiaoyao/rune init opencode
|
||||||
```
|
```
|
||||||
|
|
||||||
|
如果没有安装 bun,可使用 `pnpx @lanyuanxiaoyao/rune` 或 `npx @lanyuanxiaoyao/rune` 替代。
|
||||||
|
|
||||||
## 使用
|
## 使用
|
||||||
|
|
||||||
### 初始化
|
### 初始化
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bunx rune init opencode
|
bunx @lanyuanxiaoyao/rune init opencode # OpenCode 编辑器
|
||||||
|
bunx @lanyuanxiaoyao/rune init claude-code # Claude Code 编辑器
|
||||||
```
|
```
|
||||||
|
|
||||||
会在项目中创建:
|
会在项目中创建:
|
||||||
|
|
||||||
- `.rune/` 目录(配置、变更文档、归档)
|
- `.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 流程
|
### SDD 流程
|
||||||
|
|
||||||
1. `/rune-discuss` — 自由讨论需求
|
SDD 工作流包含固定的五个阶段,不可自定义增删:
|
||||||
2. `/rune-plan <变更名>` — 生成设计文档和任务列表
|
|
||||||
3. `/rune-build <变更名>` — 按任务顺序编码实现
|
1. **讨论阶段** — `/rune-discuss`:与 AI 自由讨论需求和方案。讨论结果保留在 AI 会话上下文中传递到后续阶段,不持久化到文件。结束前会引导是否进入规划阶段。
|
||||||
4. `/rune-archive <变更名>` — 归档并清理
|
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
|
```bash
|
||||||
rune status
|
bunx @lanyuanxiaoyao/rune status # 查看所有变更(含各阶段文档完成状态、下一步建议)
|
||||||
|
bunx @lanyuanxiaoyao/rune status <变更名> # 查看指定变更的详细状态
|
||||||
```
|
```
|
||||||
|
|
||||||
|
规划阶段应引导 AI 先通过 `bunx @lanyuanxiaoyao/rune status` 获取当前有哪些文档需要编写。
|
||||||
|
|
||||||
|
### 命令参考
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"lint-staged": "^17.0.7",
|
||||||
|
"oxfmt": "^0.54.0",
|
||||||
|
"oxlint": "^1.69.0",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
@@ -17,18 +21,158 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"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/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=="],
|
"@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=="],
|
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||||
|
|
||||||
"cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"plugin": [
|
"plugin": ["superpowers@git+https://github.com/obra/superpowers.git"],
|
||||||
"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",
|
"name": "@lanyuanxiaoyao/rune",
|
||||||
"version": "0.1.0",
|
"version": "0.1.5",
|
||||||
"module": "src/cli.ts",
|
|
||||||
"type": "module",
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"rune": "./src/cli.ts"
|
"rune": "./src/cli.ts"
|
||||||
},
|
},
|
||||||
"private": true,
|
"files": [
|
||||||
"devDependencies": {
|
"src",
|
||||||
"@types/bun": "latest"
|
"README.md"
|
||||||
},
|
],
|
||||||
"peerDependencies": {
|
"type": "module",
|
||||||
"typescript": "^5"
|
"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": {
|
"dependencies": {
|
||||||
"cac": "^7.0.0",
|
"cac": "^7.0.0",
|
||||||
"yaml": "^2.7.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 { mkdir, writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { STAGES } from "../types.ts";
|
import { STAGES } from "../types.ts";
|
||||||
|
import { writeIfChanged } from "./utils.ts";
|
||||||
|
|
||||||
const COMMANDS_DIR = ".claude/commands";
|
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) {
|
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);
|
const commandDir = join(projectRoot, COMMANDS_DIR);
|
||||||
await mkdir(commandDir, { recursive: true });
|
await mkdir(commandDir, { recursive: true });
|
||||||
const commandPath = join(commandDir, `rune-${stage}.md`);
|
const commandPath = join(commandDir, `rune-${stage}.md`);
|
||||||
if (!existsSync(commandPath)) {
|
if (!existsSync(commandPath)) {
|
||||||
const cmd = hasChangeName
|
let content = `执行以下命令,将输出作为当前阶段的工作指引:\n\`\`\`bash\n${cmd}\n\`\`\`${smartGuide}`;
|
||||||
? `rune ${stage} <变更名>`
|
if (stage === "plan") {
|
||||||
: `rune ${stage}`;
|
content += `\n如果变更目录尚不存在(新变更),请先运行 \`${command} create <变更名>\` 创建目录,再开始规划。`;
|
||||||
const nameHint = hasChangeName
|
}
|
||||||
? "\n如果用户没有指定变更名称,请向用户确认。"
|
if (stage === "task") {
|
||||||
: "";
|
content += `\n任务拆解前请确认规划文档已全部完成,运行 \`${command} status <变更名>\` 检查。`;
|
||||||
await writeFile(
|
}
|
||||||
commandPath,
|
if (stage === "discuss") {
|
||||||
`执行以下命令,将输出作为当前阶段的工作指引:\n\`\`\`bash\n${cmd}\n\`\`\`${nameHint}\n`,
|
content += `\n讨论结束后,如果确定了变更方向,请运行 \`${command} create <变更名>\` 创建变更目录,然后进入规划阶段。`;
|
||||||
);
|
}
|
||||||
|
await writeFile(commandPath, content + "\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const commandDir = join(projectRoot, COMMANDS_DIR);
|
const introCommandPath = join(projectRoot, COMMANDS_DIR, "rune-intro.md");
|
||||||
const statusPath = join(commandDir, "rune-status.md");
|
if (!existsSync(introCommandPath)) {
|
||||||
if (!existsSync(statusPath)) {
|
await mkdir(join(projectRoot, COMMANDS_DIR), { recursive: true });
|
||||||
await writeFile(
|
await writeFile(introCommandPath, generateIntroCommand(command));
|
||||||
statusPath,
|
|
||||||
`执行以下命令查看变更状态:\n\`\`\`bash\nrune status\n\`\`\`\n`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
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,61 +2,100 @@ import { existsSync } from "node:fs";
|
|||||||
import { mkdir, writeFile } from "node:fs/promises";
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { STAGES } from "../types.ts";
|
import { STAGES } from "../types.ts";
|
||||||
|
import { writeIfChanged } from "./utils.ts";
|
||||||
|
|
||||||
const COMMANDS_DIR = ".opencode/commands";
|
const COMMANDS_DIR = ".opencode/commands";
|
||||||
const SKILLS_DIR = ".opencode/skills";
|
const SKILLS_DIR = ".opencode/skills";
|
||||||
|
|
||||||
export async function injectOpenCode(projectRoot: string): Promise<void> {
|
const STAGES_WITH_CHANGE_NAME = new Set(["plan", "task", "build", "archive"]);
|
||||||
for (const stage of STAGES) {
|
|
||||||
const hasChangeName = stage !== "discuss";
|
|
||||||
|
|
||||||
|
export async function injectOpenCode(projectRoot: string, command: string = "rune"): Promise<void> {
|
||||||
|
for (const stage of STAGES) {
|
||||||
const commandDir = join(projectRoot, COMMANDS_DIR);
|
const commandDir = join(projectRoot, COMMANDS_DIR);
|
||||||
await mkdir(commandDir, { recursive: true });
|
await mkdir(commandDir, { recursive: true });
|
||||||
const commandPath = join(commandDir, `rune-${stage}.md`);
|
const commandPath = join(commandDir, `rune-${stage}.md`);
|
||||||
if (!existsSync(commandPath)) {
|
if (!existsSync(commandPath)) {
|
||||||
await writeFile(commandPath, generateCommand(stage, hasChangeName));
|
await writeFile(commandPath, generateCommand(stage));
|
||||||
}
|
}
|
||||||
|
|
||||||
const skillDir = join(projectRoot, SKILLS_DIR);
|
const skillStageDir = join(projectRoot, SKILLS_DIR, `rune-${stage}`);
|
||||||
await mkdir(skillDir, { recursive: true });
|
await mkdir(skillStageDir, { recursive: true });
|
||||||
const skillPath = join(skillDir, `rune-${stage}.md`);
|
const skillPath = join(skillStageDir, "SKILL.md");
|
||||||
if (!existsSync(skillPath)) {
|
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 commandDir = join(projectRoot, COMMANDS_DIR);
|
||||||
const statusCommandPath = join(commandDir, "rune-status.md");
|
await mkdir(commandDir, { recursive: true });
|
||||||
if (!existsSync(statusCommandPath)) {
|
const commandPath = join(commandDir, `rune-${stage}.md`);
|
||||||
await writeFile(statusCommandPath, generateStatusCommand());
|
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 skillDir = join(projectRoot, SKILLS_DIR);
|
const introSkillDir = join(projectRoot, SKILLS_DIR, "rune-intro");
|
||||||
const statusSkillPath = join(skillDir, "rune-status.md");
|
await mkdir(introSkillDir, { recursive: true });
|
||||||
if (!existsSync(statusSkillPath)) {
|
const introSkillPath = join(introSkillDir, "SKILL.md");
|
||||||
await writeFile(statusSkillPath, generateStatusSkill());
|
await writeIfChanged(introSkillPath, generateIntroSkill(command));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateCommand(stage: string, hasChangeName: boolean): string {
|
function generateCommand(stage: string): string {
|
||||||
|
return `请调用 rune-${stage} skill 执行 ${stage} 阶段。
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
if (hasChangeName) {
|
||||||
return `请调用 rune-${stage} skill 执行 ${stage} 阶段。
|
smartGuide = `如果用户没有指定变更名称,请按以下步骤智能识别:
|
||||||
|
1. 运行 \`${command} status\` 查看当前所有变更
|
||||||
如果用户没有指定变更名称,请向用户确认要操作的变更名称。
|
2. 如果只有一个变更,直接使用该变更名
|
||||||
|
3. 如果有多个变更,根据上下文推断最可能的变更
|
||||||
|
4. 如果无法确定,向用户确认
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
return `请调用 rune-${stage} skill 执行 ${stage} 阶段。
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateSkill(stage: string, hasChangeName: boolean): string {
|
|
||||||
const cmd = hasChangeName ? `rune ${stage} <变更名>` : `rune ${stage}`;
|
|
||||||
const nameHint = hasChangeName
|
|
||||||
? `将 <变更名> 替换为实际的变更名称。\n`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return `---
|
return `---
|
||||||
description: Rune SDD ${stage} 阶段
|
name: rune-${stage}
|
||||||
|
description: ${descriptionMap[stage] ?? `Use when 需要执行 SDD ${stage} 阶段`}
|
||||||
---
|
---
|
||||||
|
|
||||||
# ${stage} 阶段
|
# ${stage} 阶段
|
||||||
@@ -67,28 +106,49 @@ description: Rune SDD ${stage} 阶段
|
|||||||
${cmd}
|
${cmd}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
${nameHint}将命令输出作为工作指引,执行当前阶段的工作。
|
${smartGuide}${extraGuide}将命令输出作为工作指引,执行当前阶段的工作。
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateStatusCommand(): string {
|
function generateIntroSkill(command: string): string {
|
||||||
return `请调用 rune-status skill 查看当前所有变更状态。
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateStatusSkill(): string {
|
|
||||||
return `---
|
return `---
|
||||||
description: 查看所有 Rune 变更状态
|
name: rune-intro
|
||||||
|
description: Use when 用户首次接触 Rune,需要了解 SDD 工作流程和使用方式
|
||||||
---
|
---
|
||||||
|
|
||||||
# 状态查看
|
# Rune 简介
|
||||||
|
|
||||||
执行以下命令:
|
Rune 是基于规格驱动开发(SDD)的 AI 开发辅助工具。它通过结构化的阶段流程,帮助 AI 编辑器和开发者高效协作。
|
||||||
|
|
||||||
\`\`\`bash
|
## SDD 工作流程
|
||||||
rune status
|
|
||||||
|
\`\`\`
|
||||||
|
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);
|
||||||
|
}
|
||||||
437
src/cli.ts
437
src/cli.ts
@@ -2,102 +2,423 @@
|
|||||||
import { cac } from "cac";
|
import { cac } from "cac";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { mkdir, rename } from "node:fs/promises";
|
import { mkdir, rename } from "node:fs/promises";
|
||||||
import { runInit } from "./commands/init.ts";
|
import { readFileSync, existsSync } from "node:fs";
|
||||||
|
import { runInit, ensureMetadataCommand } from "./commands/init.ts";
|
||||||
import { findProjectRoot, loadConfig, getChangeDir, getArchiveDir } from "./core/config.ts";
|
import { findProjectRoot, loadConfig, getChangeDir, getArchiveDir } from "./core/config.ts";
|
||||||
import {
|
import {
|
||||||
assembleDiscussPrompt,
|
assembleDiscussPrompt,
|
||||||
assemblePlanPrompt,
|
assemblePlanPrompt,
|
||||||
|
assembleTaskPrompt,
|
||||||
assembleBuildPrompt,
|
assembleBuildPrompt,
|
||||||
assembleArchivePrompt,
|
assembleArchivePrompt,
|
||||||
} from "./core/assembler.ts";
|
} from "./core/assembler.ts";
|
||||||
import { scanChanges } from "./core/scanner.ts";
|
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) {
|
||||||
|
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");
|
const cli = cac("rune");
|
||||||
|
|
||||||
cli.command("init [...tools]", "初始化 Rune 并注入工具配置").action(
|
cli.command("", "").action(async () => {
|
||||||
async (tools: string[]) => {
|
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(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, prefix);
|
||||||
|
if (!output) {
|
||||||
|
throw new UsageError(`未知命令: ${command}`, {
|
||||||
|
hint: `运行 ${prefix} help 查看所有命令`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(output);
|
||||||
|
} else {
|
||||||
|
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[]) => {
|
||||||
|
const prefix = getPmPrefix();
|
||||||
if (!tools || tools.length === 0) {
|
if (!tools || tools.length === 0) {
|
||||||
console.error("请指定至少一个工具,如:rune init opencode");
|
throw new UsageError("请指定至少一个工具", {
|
||||||
process.exit(1);
|
usage: `${prefix} init <工具...>`,
|
||||||
|
hint: `如:${prefix} init opencode`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
await runInit(process.cwd(), tools);
|
await runInit(process.cwd(), tools);
|
||||||
console.log(`Rune 初始化完成,已注入工具:${tools.join(", ")}`);
|
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 () => {
|
cli.command("discuss", "讨论阶段").action(async () => {
|
||||||
const root = findProjectRoot();
|
const root = requireProjectRoot();
|
||||||
if (!root) {
|
|
||||||
console.error("未找到 .rune 目录,请先运行 rune init");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
const config = await loadConfig(root);
|
const config = await loadConfig(root);
|
||||||
const prompt = assembleDiscussPrompt(config);
|
const prompt = assembleDiscussPrompt(config);
|
||||||
console.log(prompt);
|
console.log(prompt);
|
||||||
});
|
});
|
||||||
|
|
||||||
cli
|
cli.command("create <change-name>", "创建变更").action(async (changeName: string) => {
|
||||||
.command("plan <change-name>", "规划阶段")
|
validateChangeName(changeName);
|
||||||
.action(async (changeName: string) => {
|
const root = requireProjectRoot();
|
||||||
const root = findProjectRoot();
|
|
||||||
if (!root) {
|
|
||||||
console.error("未找到 .rune 目录,请先运行 rune init");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
await mkdir(getChangeDir(root, changeName), { recursive: true });
|
|
||||||
const config = await loadConfig(root);
|
const config = await loadConfig(root);
|
||||||
const prompt = await assemblePlanPrompt(config, root, changeName);
|
const changeDir = getChangeDir(root, 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);
|
console.log(prompt);
|
||||||
});
|
});
|
||||||
|
|
||||||
cli
|
cli.command("task <change-name>", "任务拆解阶段").action(async (changeName: string) => {
|
||||||
.command("build <change-name>", "构建阶段")
|
validateChangeName(changeName);
|
||||||
.action(async (changeName: string) => {
|
const root = requireProjectRoot();
|
||||||
const root = findProjectRoot();
|
|
||||||
if (!root) {
|
|
||||||
console.error("未找到 .rune 目录,请先运行 rune init");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
const config = await loadConfig(root);
|
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);
|
const prompt = await assembleBuildPrompt(config, root, changeName);
|
||||||
console.log(prompt);
|
console.log(prompt);
|
||||||
});
|
});
|
||||||
|
|
||||||
cli
|
cli.command("archive <change-name>", "归档阶段").action(async (changeName: string) => {
|
||||||
.command("archive <change-name>", "归档阶段")
|
validateChangeName(changeName);
|
||||||
.action(async (changeName: string) => {
|
const root = requireProjectRoot();
|
||||||
const root = findProjectRoot();
|
|
||||||
if (!root) {
|
|
||||||
console.error("未找到 .rune 目录,请先运行 rune init");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
const config = await loadConfig(root);
|
const config = await loadConfig(root);
|
||||||
const prompt = assembleArchivePrompt(config, changeName);
|
const changeDir = getChangeDir(root, changeName);
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
if (!existsSync(changeDir)) {
|
||||||
const src = getChangeDir(root, changeName);
|
throw new CommandError(`变更"${changeName}"不存在`, {
|
||||||
const dest = join(getArchiveDir(root), `${today}-${changeName}`);
|
hint: `请先运行 ${getPmPrefix(config)} create ${changeName} 创建变更`,
|
||||||
await rename(src, dest);
|
|
||||||
console.log(prompt);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
cli.command("status", "查看变更状态").action(async () => {
|
|
||||||
const root = findProjectRoot();
|
|
||||||
if (!root) {
|
|
||||||
console.error("未找到 .rune 目录,请先运行 rune init");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
const changes = await scanChanges(root);
|
const prompt = await assembleArchivePrompt(config, root, changeName);
|
||||||
|
console.log(prompt);
|
||||||
|
});
|
||||||
|
|
||||||
|
cli.command("finish <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 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) {
|
if (changes.length === 0) {
|
||||||
console.log("当前无进行中的变更。");
|
console.log("当前无进行中的变更。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
const progress = change.taskProgress
|
console.log(formatChangeStatus(change, config));
|
||||||
? ` (${change.taskProgress.completed}/${change.taskProgress.total} 任务)`
|
console.log("---\n");
|
||||||
: "";
|
}
|
||||||
console.log(`- ${change.name}${progress} [${change.documents.join(", ")}]`);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
cli.help();
|
export function mapError(e: unknown): CliError {
|
||||||
cli.parse();
|
if (e instanceof CliError) {
|
||||||
|
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] : "未知选项";
|
||||||
|
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] : "未知命令";
|
||||||
|
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] : "未知参数";
|
||||||
|
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] : "未知命令";
|
||||||
|
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) => {
|
||||||
|
handleError(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
cli.parse();
|
||||||
|
} catch (e) {
|
||||||
|
handleError(e);
|
||||||
|
}
|
||||||
|
|||||||
34
src/cli/errors.ts
Normal file
34
src/cli/errors.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export class CliError extends Error {
|
||||||
|
readonly hint?: string;
|
||||||
|
readonly usage?: string;
|
||||||
|
|
||||||
|
constructor(message: string, opts?: { hint?: string; usage?: string }) {
|
||||||
|
super(message);
|
||||||
|
this.name = this.constructor.name;
|
||||||
|
this.hint = opts?.hint;
|
||||||
|
this.usage = opts?.usage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UsageError extends CliError {}
|
||||||
|
|
||||||
|
export class ConfigError extends CliError {}
|
||||||
|
|
||||||
|
export class CommandError extends CliError {}
|
||||||
|
|
||||||
|
export class InternalError extends CliError {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
173
src/cli/help.ts
Normal file
173
src/cli/help.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
interface CommandHelpDef {
|
||||||
|
name: string;
|
||||||
|
alias: string;
|
||||||
|
description: string;
|
||||||
|
usageTemplate: string;
|
||||||
|
args: { name: string; desc: string }[];
|
||||||
|
detail: string;
|
||||||
|
exampleArgs: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMANDS: Record<string, CommandHelpDef> = {
|
||||||
|
init: {
|
||||||
|
name: "init",
|
||||||
|
alias: "init <工具...>",
|
||||||
|
description: "初始化 Rune 并注入工具配置",
|
||||||
|
usageTemplate: "init <工具...>",
|
||||||
|
args: [{ name: "<工具...>", desc: "要注入的 AI 工具,如 opencode、claude-code" }],
|
||||||
|
detail: "在当前项目中创建 .rune 目录结构,并注入指定 AI 工具的 command 和 skill 配置文件。",
|
||||||
|
exampleArgs: ["init opencode", "init opencode claude-code"],
|
||||||
|
},
|
||||||
|
discuss: {
|
||||||
|
name: "discuss",
|
||||||
|
alias: "discuss",
|
||||||
|
description: "讨论:生成讨论阶段提示词",
|
||||||
|
usageTemplate: "discuss",
|
||||||
|
args: [],
|
||||||
|
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: "规划:生成指定文档的规划提示词",
|
||||||
|
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: "构建:生成构建阶段提示词",
|
||||||
|
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: "归档:生成归档阶段提示词",
|
||||||
|
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: "查看:展示变更状态与下一步建议",
|
||||||
|
usageTemplate: "status [change-name]",
|
||||||
|
args: [{ name: "[change-name]", desc: "可选,指定查看的变更名称" }],
|
||||||
|
detail: "展示各文档完成状态、依赖满足情况、规划进度和下一步建议。不传参数则显示所有变更。",
|
||||||
|
exampleArgs: ["status", "status add-user-auth"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function showGlobalHelp(prefix: string = "rune"): string {
|
||||||
|
const lines: string[] = [
|
||||||
|
"Rune — 基于规格驱动开发(SDD)的 AI 开发辅助工具",
|
||||||
|
"",
|
||||||
|
"用法:",
|
||||||
|
` ${prefix} <命令> [参数]`,
|
||||||
|
"",
|
||||||
|
"命令:",
|
||||||
|
];
|
||||||
|
|
||||||
|
const entries = Object.values(COMMANDS);
|
||||||
|
const maxAliasLen = Math.max(...entries.map((c) => c.alias.length));
|
||||||
|
for (const cmd of entries) {
|
||||||
|
lines.push(` ${cmd.alias.padEnd(maxAliasLen)} ${cmd.description}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(` ${"help".padEnd(maxAliasLen)} 显示帮助信息`);
|
||||||
|
lines.push(` ${"version".padEnd(maxAliasLen)} 显示版本号`);
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
lines.push("示例:");
|
||||||
|
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, prefix: string = "rune"): string | null {
|
||||||
|
const cmd = COMMANDS[name];
|
||||||
|
if (!cmd) return null;
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
`${prefix} ${cmd.name} — ${cmd.description}`,
|
||||||
|
"",
|
||||||
|
"用法:",
|
||||||
|
` ${prefix} ${cmd.usageTemplate}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (cmd.args.length > 0) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("参数:");
|
||||||
|
for (const arg of cmd.args) {
|
||||||
|
lines.push(` ${arg.name} ${arg.desc}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
lines.push("描述:");
|
||||||
|
lines.push(` ${cmd.detail}`);
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
lines.push("示例:");
|
||||||
|
for (const ex of cmd.exampleArgs) {
|
||||||
|
lines.push(` ${prefix} ${ex}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
20
src/cli/output.ts
Normal file
20
src/cli/output.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { CliError } from "./errors.ts";
|
||||||
|
|
||||||
|
export function formatError(error: CliError): string {
|
||||||
|
const parts: string[] = [`错误: ${error.message}`];
|
||||||
|
|
||||||
|
if (error.usage) {
|
||||||
|
parts.push(`用法: ${error.usage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.hint) {
|
||||||
|
parts.push(`提示: ${error.hint}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function printError(error: CliError): never {
|
||||||
|
process.stderr.write(formatError(error) + "\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -1,22 +1,74 @@
|
|||||||
import { existsSync } from "node:fs";
|
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 { join } from "node:path";
|
||||||
import { CHANGES_DIR, ARCHIVE_DIR, RUNE_DIR, CONFIG_FILE } from "../types.ts";
|
import { CHANGES_DIR, ARCHIVE_DIR, RUNE_DIR, CONFIG_FILE } from "../types.ts";
|
||||||
import { injectOpenCode } from "../adapters/opencode.ts";
|
import { injectOpenCode } from "../adapters/opencode.ts";
|
||||||
import { injectClaudeCode } from "../adapters/claude-code.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 SUPPORTED_TOOLS: Record<string, (root: string) => Promise<void>> = {
|
const CONFIG_TEMPLATE = `# Rune 配置文件
|
||||||
|
#
|
||||||
|
# 未配置的阶段将使用内置默认配置。
|
||||||
|
# SDD 五阶段:discuss -> plan -> task -> build -> archive
|
||||||
|
# 辅助命令:create(创建变更目录,plan 阶段的前置步骤)
|
||||||
|
#
|
||||||
|
# 可配置阶段:
|
||||||
|
# discuss - 探索阶段:深度思考、调查代码库、对比方案
|
||||||
|
# plan - 规划阶段:生成设计文档
|
||||||
|
# task - 任务拆解阶段:根据设计文档生成 checkbox 任务清单
|
||||||
|
# build - 构建阶段:按任务清单逐步实现
|
||||||
|
# archive - 归档阶段:确认完成并归档变更
|
||||||
|
#
|
||||||
|
# 示例 - 自定义讨论阶段提示词:
|
||||||
|
# stages:
|
||||||
|
# discuss:
|
||||||
|
# prompt: |
|
||||||
|
# 进入探索模式。深度思考,自由发散。跟随对话走向。
|
||||||
|
#
|
||||||
|
# 示例 - 自定义规划阶段的文档模板:
|
||||||
|
# stages:
|
||||||
|
# plan:
|
||||||
|
# documents:
|
||||||
|
# - name: design
|
||||||
|
# prompt: 生成设计文档
|
||||||
|
# template: |
|
||||||
|
# # 设计文档
|
||||||
|
#
|
||||||
|
# metadata 说明:
|
||||||
|
# command - Rune CLI 执行命令(如 bunx @lanyuanxiaoyao/rune),init 时自动检测
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SUPPORTED_TOOLS: Record<string, (root: string, command?: string) => Promise<void>> = {
|
||||||
opencode: injectOpenCode,
|
opencode: injectOpenCode,
|
||||||
"claude-code": injectClaudeCode,
|
"claude-code": injectClaudeCode,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function runInit(
|
export async function ensureMetadataCommand(configPath: string, command: string): Promise<void> {
|
||||||
projectRoot: string,
|
const content = await readFile(configPath, "utf-8");
|
||||||
tools: string[],
|
const parsed = parseYaml(content) as { metadata?: { command?: string } } | null;
|
||||||
): Promise<void> {
|
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) {
|
for (const tool of tools) {
|
||||||
if (!SUPPORTED_TOOLS[tool]) {
|
if (!SUPPORTED_TOOLS[tool]) {
|
||||||
throw new Error(`不支持的工具: ${tool}`);
|
throw new CommandError(`不支持的工具: ${tool}`, {
|
||||||
|
hint: `支持的工具: ${Object.keys(SUPPORTED_TOOLS).join(", ")}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,10 +79,16 @@ export async function runInit(
|
|||||||
|
|
||||||
const configPath = join(runeDir, CONFIG_FILE);
|
const configPath = join(runeDir, CONFIG_FILE);
|
||||||
if (!existsSync(configPath)) {
|
if (!existsSync(configPath)) {
|
||||||
await writeFile(configPath, "stages: {}\n", "utf-8");
|
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) {
|
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 { readFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { RuneConfig } from "../types.ts";
|
import type { RuneConfig } from "../types.ts";
|
||||||
|
import { CommandError } from "../cli/errors.ts";
|
||||||
import { getChangeDir } from "./config.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 {
|
export function assembleDiscussPrompt(config: RuneConfig): string {
|
||||||
const discuss = config.stages.discuss;
|
const discuss = config.stages.discuss;
|
||||||
if (!discuss) throw new Error("discuss 阶段未配置");
|
if (!discuss)
|
||||||
return discuss.prompt;
|
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(
|
export async function assemblePlanPrompt(
|
||||||
config: RuneConfig,
|
config: RuneConfig,
|
||||||
projectRoot: string,
|
projectRoot: string,
|
||||||
changeName: string,
|
changeName: string,
|
||||||
|
documentName: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const plan = config.stages.plan;
|
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 changeDir = getChangeDir(projectRoot, changeName);
|
||||||
const parts: string[] = [];
|
const builder = new PromptBuilder();
|
||||||
|
|
||||||
parts.push(`# 规划阶段:${changeName}\n`);
|
builder.head(`# 规划阶段:${changeName}\n\n## 文档:${doc.name}.md`);
|
||||||
parts.push("请为当前变更生成以下文档:\n");
|
builder.prompt(doc.prompt);
|
||||||
|
|
||||||
for (const doc of plan.documents) {
|
|
||||||
parts.push(`## 文档:${doc.name}.md`);
|
|
||||||
parts.push(doc.prompt);
|
|
||||||
|
|
||||||
const docPath = join(changeDir, `${doc.name}.md`);
|
const docPath = join(changeDir, `${doc.name}.md`);
|
||||||
if (existsSync(docPath)) {
|
if (existsSync(docPath)) {
|
||||||
const existing = await readFile(docPath, "utf-8");
|
builder.context(`文档已存在,请先读取 ${docPath} 查看已有内容,在此基础上修订。`);
|
||||||
parts.push(`\n### 已有内容(请在此基础上修订):\n${existing}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (doc.template) {
|
if (doc.template) {
|
||||||
const rendered = doc.template.replace(/\{\{change-name\}\}/g, changeName);
|
builder.context(`### 格式模板:\n${doc.template}`);
|
||||||
parts.push(`\n### 格式模板:\n${rendered}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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}`);
|
builder.guide(`请将文档写入目录:${changeDir}`);
|
||||||
return parts.join("\n");
|
|
||||||
|
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(
|
export async function assembleBuildPrompt(
|
||||||
@@ -53,7 +130,11 @@ export async function assembleBuildPrompt(
|
|||||||
changeName: string,
|
changeName: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const build = config.stages.build;
|
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 changeDir = getChangeDir(projectRoot, changeName);
|
||||||
const taskPath = join(changeDir, "task.md");
|
const taskPath = join(changeDir, "task.md");
|
||||||
@@ -62,7 +143,21 @@ export async function assembleBuildPrompt(
|
|||||||
try {
|
try {
|
||||||
taskContent = await readFile(taskPath, "utf-8");
|
taskContent = await readFile(taskPath, "utf-8");
|
||||||
} catch {
|
} 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);
|
const tasks = parseTasks(taskContent);
|
||||||
@@ -72,31 +167,54 @@ export async function assembleBuildPrompt(
|
|||||||
return `所有任务已完成。变更 "${changeName}" 可以归档。`;
|
return `所有任务已完成。变更 "${changeName}" 可以归档。`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parts: string[] = [];
|
const builder = new PromptBuilder();
|
||||||
parts.push(`# 构建阶段:${changeName}\n`);
|
|
||||||
parts.push(build.prompt);
|
builder.head(`# 构建阶段:${changeName}`);
|
||||||
parts.push(`\n## 任务列表\n`);
|
builder.prompt(build.prompt);
|
||||||
parts.push(taskContent);
|
builder.context(
|
||||||
parts.push(`\n## 待执行任务(共 ${pendingTasks.length} 项)`);
|
`请先读取 ${taskPath} 查看任务列表。\n当前有 ${pendingTasks.length} 个待执行任务,从第一个未完成的任务开始。`,
|
||||||
for (const task of pendingTasks) {
|
|
||||||
parts.push(`- [ ] ${task.text}`);
|
|
||||||
}
|
|
||||||
parts.push(
|
|
||||||
`\n请从第一个待执行任务开始。完成后更新 ${taskPath} 中的 checkbox。`,
|
|
||||||
);
|
);
|
||||||
|
builder.guide(`完成后更新 ${taskPath} 中的 checkbox。`);
|
||||||
|
|
||||||
return parts.join("\n");
|
return applyCommandPrefix(builder.build(), config);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assembleArchivePrompt(
|
export async function assembleArchivePrompt(
|
||||||
config: RuneConfig,
|
config: RuneConfig,
|
||||||
|
projectRoot: string,
|
||||||
changeName: string,
|
changeName: string,
|
||||||
): string {
|
): Promise<string> {
|
||||||
const archive = config.stages.archive;
|
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[] = [];
|
const builder = new PromptBuilder();
|
||||||
parts.push(`# 归档阶段:${changeName}\n`);
|
|
||||||
parts.push(archive.prompt);
|
builder.head(`# 归档阶段:${changeName}`);
|
||||||
return parts.join("\n");
|
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 { join, dirname } from "node:path";
|
||||||
import { parse as parseYaml } from "yaml";
|
import { parse as parseYaml } from "yaml";
|
||||||
import { defaultConfig } from "../defaults/config.ts";
|
import { defaultConfig } from "../defaults/config.ts";
|
||||||
|
import { ConfigError } from "../cli/errors.ts";
|
||||||
|
import { getPmPrefix } from "./pm.ts";
|
||||||
import type { RuneConfig } from "../types.ts";
|
import type { RuneConfig } from "../types.ts";
|
||||||
import { RUNE_DIR, CONFIG_FILE, CHANGES_DIR, ARCHIVE_DIR } from "../types.ts";
|
import { RUNE_DIR, CONFIG_FILE, CHANGES_DIR, ARCHIVE_DIR } from "../types.ts";
|
||||||
|
|
||||||
export function findProjectRoot(
|
export function findProjectRoot(startDir: string = process.cwd()): string | null {
|
||||||
startDir: string = process.cwd(),
|
|
||||||
): string | null {
|
|
||||||
let dir = startDir;
|
let dir = startDir;
|
||||||
while (true) {
|
while (true) {
|
||||||
if (existsSync(join(dir, RUNE_DIR))) {
|
if (existsSync(join(dir, RUNE_DIR))) {
|
||||||
@@ -22,18 +22,83 @@ export function findProjectRoot(
|
|||||||
|
|
||||||
export async function loadConfig(projectRoot: string): Promise<RuneConfig> {
|
export async function loadConfig(projectRoot: string): Promise<RuneConfig> {
|
||||||
const configPath = join(projectRoot, RUNE_DIR, CONFIG_FILE);
|
const configPath = join(projectRoot, RUNE_DIR, CONFIG_FILE);
|
||||||
|
let merged: RuneConfig;
|
||||||
try {
|
try {
|
||||||
const content = await readFile(configPath, "utf-8");
|
const content = await readFile(configPath, "utf-8");
|
||||||
const userConfig = parseYaml(content) as Partial<RuneConfig> | null;
|
const userConfig = parseYaml(content) as Partial<RuneConfig> | null;
|
||||||
return mergeConfig(userConfig ?? {});
|
merged = mergeConfig(userConfig ?? {});
|
||||||
} catch {
|
} catch (e) {
|
||||||
return mergeConfig({});
|
const code = (e as NodeJS.ErrnoException)?.code;
|
||||||
|
if (code === "ENOENT" || code === "ENOTDIR") {
|
||||||
|
merged = mergeConfig({});
|
||||||
|
} else {
|
||||||
|
throw new ConfigError(`配置文件加载失败:${configPath}\n${(e as Error).message}`, {
|
||||||
|
hint: `请检查 .rune/config.yaml 的格式是否正确。常见问题:\n - YAML 缩进必须使用空格,不能用 Tab\n - 字符串包含特殊字符时需要引号包裹\n - 运行 ${getPmPrefix()} init 重新生成默认配置`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validateConfig(merged);
|
||||||
|
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 {
|
function mergeConfig(userConfig: Partial<RuneConfig>): RuneConfig {
|
||||||
const result: RuneConfig = { stages: {} };
|
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) {
|
for (const stage of stageKeys) {
|
||||||
if (userConfig.stages?.[stage]) {
|
if (userConfig.stages?.[stage]) {
|
||||||
@@ -43,6 +108,11 @@ function mergeConfig(userConfig: Partial<RuneConfig>): RuneConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result.metadata = {
|
||||||
|
...defaultConfig.metadata,
|
||||||
|
...userConfig.metadata,
|
||||||
|
};
|
||||||
|
|
||||||
return result;
|
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 { readdir, stat, readFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
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 { getChangesDir, getArchiveDir } from "./config.ts";
|
||||||
import { parseTasks } from "./task-parser.ts";
|
import { parseTasks } from "./task-parser.ts";
|
||||||
|
|
||||||
export async function scanChanges(
|
export async function scanChanges(
|
||||||
projectRoot: string,
|
projectRoot: string,
|
||||||
|
config?: RuneConfig,
|
||||||
): Promise<ChangeStatus[]> {
|
): Promise<ChangeStatus[]> {
|
||||||
const changesDir = getChangesDir(projectRoot);
|
const changesDir = getChangesDir(projectRoot);
|
||||||
const results: ChangeStatus[] = [];
|
const results: ChangeStatus[] = [];
|
||||||
|
const planDocs = config?.stages.plan?.documents;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entries = await readdir(changesDir);
|
const entries = await readdir(changesDir);
|
||||||
@@ -17,11 +19,32 @@ export async function scanChanges(
|
|||||||
const entryStat = await stat(entryPath);
|
const entryStat = await stat(entryPath);
|
||||||
if (!entryStat.isDirectory()) continue;
|
if (!entryStat.isDirectory()) continue;
|
||||||
|
|
||||||
const docs = await readdir(entryPath);
|
const files = await readdir(entryPath);
|
||||||
const documents = docs.filter((d) => d.endsWith(".md"));
|
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;
|
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) {
|
if (taskFile) {
|
||||||
const content = await readFile(join(entryPath, taskFile), "utf-8");
|
const content = await readFile(join(entryPath, taskFile), "utf-8");
|
||||||
const tasks = parseTasks(content);
|
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;
|
return results;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { TaskItem } from "../types.ts";
|
import type { TaskItem } from "../types.ts";
|
||||||
|
import { CommandError } from "../cli/errors.ts";
|
||||||
|
|
||||||
export function parseTasks(content: string): TaskItem[] {
|
export function parseTasks(content: string): TaskItem[] {
|
||||||
const tasks: TaskItem[] = [];
|
const tasks: TaskItem[] = [];
|
||||||
const lines = content.split("\n");
|
const lines = content.split(/\r?\n/);
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const match = line.match(/^[\s]*- \[([ xX])\] (.*)$/);
|
const match = line.match(/^[\s]*- \[([ xX])\] (.*)$/);
|
||||||
if (match) {
|
if (match) {
|
||||||
@@ -14,3 +15,22 @@ export function parseTasks(content: string): TaskItem[] {
|
|||||||
}
|
}
|
||||||
return tasks;
|
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;
|
name: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
template?: string;
|
template?: string;
|
||||||
|
depend?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentStatus {
|
||||||
|
name: string;
|
||||||
|
completed: boolean;
|
||||||
|
dependMet: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiscussStage {
|
export interface DiscussStage {
|
||||||
@@ -12,6 +19,10 @@ export interface PlanStage {
|
|||||||
documents: DocumentConfig[];
|
documents: DocumentConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TaskStage {
|
||||||
|
prompt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BuildStage {
|
export interface BuildStage {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
}
|
}
|
||||||
@@ -23,12 +34,16 @@ export interface ArchiveStage {
|
|||||||
export interface StagesConfig {
|
export interface StagesConfig {
|
||||||
discuss?: DiscussStage;
|
discuss?: DiscussStage;
|
||||||
plan?: PlanStage;
|
plan?: PlanStage;
|
||||||
|
task?: TaskStage;
|
||||||
build?: BuildStage;
|
build?: BuildStage;
|
||||||
archive?: ArchiveStage;
|
archive?: ArchiveStage;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RuneConfig {
|
export interface RuneConfig {
|
||||||
stages: StagesConfig;
|
stages: StagesConfig;
|
||||||
|
metadata?: {
|
||||||
|
command?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskItem {
|
export interface TaskItem {
|
||||||
@@ -38,11 +53,13 @@ export interface TaskItem {
|
|||||||
|
|
||||||
export interface ChangeStatus {
|
export interface ChangeStatus {
|
||||||
name: string;
|
name: string;
|
||||||
documents: string[];
|
documents: DocumentStatus[];
|
||||||
|
planCompleted: boolean;
|
||||||
|
buildUnlocked: boolean;
|
||||||
taskProgress: { completed: number; total: number } | null;
|
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 type Stage = (typeof STAGES)[number];
|
||||||
|
|
||||||
export const RUNE_DIR = ".rune";
|
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 { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||||
import { existsSync } from "node:fs";
|
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 { 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__");
|
const TMP_DIR = join(import.meta.dir, "__tmp_opencode_test__");
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ beforeEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await rm(TMP_DIR, { recursive: true, force: true });
|
await retryRm(TMP_DIR);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("injectOpenCode", () => {
|
describe("injectOpenCode", () => {
|
||||||
@@ -23,51 +24,125 @@ describe("injectOpenCode", () => {
|
|||||||
|
|
||||||
for (const stage of ["discuss", "plan", "build", "archive"]) {
|
for (const stage of ["discuss", "plan", "build", "archive"]) {
|
||||||
expect(commands).toContain(`rune-${stage}.md`);
|
expect(commands).toContain(`rune-${stage}.md`);
|
||||||
expect(skills).toContain(`rune-${stage}.md`);
|
expect(skills).toContain(`rune-${stage}`);
|
||||||
|
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);
|
await injectOpenCode(TMP_DIR);
|
||||||
|
|
||||||
const commands = await readdir(join(TMP_DIR, ".opencode", "commands"));
|
const commands = await readdir(join(TMP_DIR, ".opencode", "commands"));
|
||||||
const skills = await readdir(join(TMP_DIR, ".opencode", "skills"));
|
const skills = await readdir(join(TMP_DIR, ".opencode", "skills"));
|
||||||
|
|
||||||
expect(commands).toContain("rune-status.md");
|
expect(commands).not.toContain("rune-status.md");
|
||||||
expect(skills).toContain("rune-status.md");
|
expect(skills).not.toContain("rune-status");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("command 文件包含 skill 调用指令", async () => {
|
it("生成 rune-intro skill(无对应 command)", async () => {
|
||||||
await injectOpenCode(TMP_DIR);
|
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(
|
const content = await readFile(
|
||||||
join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"),
|
join(TMP_DIR, ".opencode", "commands", `rune-${stage}.md`),
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
expect(content).toContain("rune-discuss");
|
expect(content).toContain(`rune-${stage} skill`);
|
||||||
|
expect(content).not.toContain("变更名");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skill 文件包含 bash 命令", async () => {
|
it("plan/build/archive skill 包含变更名智能识别引导", async () => {
|
||||||
await injectOpenCode(TMP_DIR);
|
|
||||||
const content = await readFile(
|
|
||||||
join(TMP_DIR, ".opencode", "skills", "rune-discuss.md"),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
expect(content).toContain("rune discuss");
|
|
||||||
expect(content).toContain("description");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("plan/build/archive skill 包含变更名称参数提示", async () => {
|
|
||||||
await injectOpenCode(TMP_DIR);
|
await injectOpenCode(TMP_DIR);
|
||||||
|
|
||||||
for (const stage of ["plan", "build", "archive"]) {
|
for (const stage of ["plan", "build", "archive"]) {
|
||||||
const content = await readFile(
|
const content = await readFile(
|
||||||
join(TMP_DIR, ".opencode", "skills", `rune-${stage}.md`),
|
join(TMP_DIR, ".opencode", "skills", `rune-${stage}`, "SKILL.md"),
|
||||||
"utf-8",
|
"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 () => {
|
it("重复注入时不覆盖已存在的文件", async () => {
|
||||||
await injectOpenCode(TMP_DIR);
|
await injectOpenCode(TMP_DIR);
|
||||||
const originalContent = await readFile(
|
const originalContent = await readFile(
|
||||||
@@ -83,3 +158,97 @@ describe("injectOpenCode", () => {
|
|||||||
expect(content).toBe(originalContent);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
55
tests/cli/errors.test.ts
Normal file
55
tests/cli/errors.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it, expect } from "bun:test";
|
||||||
|
import {
|
||||||
|
CliError,
|
||||||
|
UsageError,
|
||||||
|
ConfigError,
|
||||||
|
CommandError,
|
||||||
|
InternalError,
|
||||||
|
} from "../../src/cli/errors.ts";
|
||||||
|
|
||||||
|
describe("CliError 类层次", () => {
|
||||||
|
it("UsageError 携带 message 和可选 hint", () => {
|
||||||
|
const err = new UsageError("缺少参数", { hint: "请提供参数" });
|
||||||
|
expect(err).toBeInstanceOf(CliError);
|
||||||
|
expect(err).toBeInstanceOf(UsageError);
|
||||||
|
expect(err.message).toBe("缺少参数");
|
||||||
|
expect(err.hint).toBe("请提供参数");
|
||||||
|
expect(err.usage).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("UsageError 携带 message、hint、usage", () => {
|
||||||
|
const err = new UsageError("缺少参数", {
|
||||||
|
hint: "请提供参数",
|
||||||
|
usage: "rune plan <change-name>",
|
||||||
|
});
|
||||||
|
expect(err.usage).toBe("rune plan <change-name>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ConfigError 只带 message", () => {
|
||||||
|
const err = new ConfigError("未初始化");
|
||||||
|
expect(err).toBeInstanceOf(CliError);
|
||||||
|
expect(err).toBeInstanceOf(ConfigError);
|
||||||
|
expect(err.message).toBe("未初始化");
|
||||||
|
expect(err.hint).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CommandError 带 message 和 hint", () => {
|
||||||
|
const err = new CommandError("变更不存在", { hint: "请先运行 rune plan" });
|
||||||
|
expect(err).toBeInstanceOf(CliError);
|
||||||
|
expect(err.message).toBe("变更不存在");
|
||||||
|
expect(err.hint).toBe("请先运行 rune plan");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("InternalError 用默认 message", () => {
|
||||||
|
const err = new InternalError();
|
||||||
|
expect(err).toBeInstanceOf(CliError);
|
||||||
|
expect(err.message).toBe("发生了未预期的错误");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("可以用 name 区分错误类型", () => {
|
||||||
|
expect(new UsageError("a").name).toBe("UsageError");
|
||||||
|
expect(new ConfigError("b").name).toBe("ConfigError");
|
||||||
|
expect(new CommandError("c").name).toBe("CommandError");
|
||||||
|
expect(new InternalError().name).toBe("InternalError");
|
||||||
|
});
|
||||||
|
});
|
||||||
71
tests/cli/help.test.ts
Normal file
71
tests/cli/help.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect } from "bun:test";
|
||||||
|
import { showGlobalHelp, showCommandHelp } from "../../src/cli/help.ts";
|
||||||
|
|
||||||
|
describe("showGlobalHelp", () => {
|
||||||
|
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("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("rune");
|
||||||
|
expect(output.startsWith("Rune")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("showCommandHelp", () => {
|
||||||
|
it("plan 命令包含用法、参数、描述、示例", () => {
|
||||||
|
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("bunx @lanyuanxiaoyao/rune plan add-user-auth");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("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", "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);
|
||||||
|
});
|
||||||
|
});
|
||||||
42
tests/cli/output.test.ts
Normal file
42
tests/cli/output.test.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it, expect } from "bun:test";
|
||||||
|
import { formatError } from "../../src/cli/output.ts";
|
||||||
|
import { UsageError, ConfigError, InternalError } from "../../src/cli/errors.ts";
|
||||||
|
|
||||||
|
describe("formatError", () => {
|
||||||
|
it("只输出错误行(无 hint/usage)", () => {
|
||||||
|
const err = new ConfigError("未初始化");
|
||||||
|
const output = formatError(err);
|
||||||
|
expect(output).toBe("错误: 未初始化");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("输出错误行 + 提示行", () => {
|
||||||
|
const err = new ConfigError("未初始化", { hint: "请先运行 rune init" });
|
||||||
|
const output = formatError(err);
|
||||||
|
expect(output).toBe("错误: 未初始化\n\n提示: 请先运行 rune init");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("输出错误行 + 用法行", () => {
|
||||||
|
const err = new UsageError("缺少参数", {
|
||||||
|
usage: "rune plan <change-name>",
|
||||||
|
});
|
||||||
|
const output = formatError(err);
|
||||||
|
expect(output).toBe("错误: 缺少参数\n\n用法: rune plan <change-name>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("输出完整格式(错误 + 用法 + 提示)", () => {
|
||||||
|
const err = new UsageError("缺少必填参数 <change-name>", {
|
||||||
|
usage: "rune plan <change-name>",
|
||||||
|
hint: '请指定变更名称,如 "add-login"',
|
||||||
|
});
|
||||||
|
const output = formatError(err);
|
||||||
|
expect(output).toBe(
|
||||||
|
'错误: 缺少必填参数 <change-name>\n\n用法: rune plan <change-name>\n\n提示: 请指定变更名称,如 "add-login"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("InternalError 输出固定消息", () => {
|
||||||
|
const err = new InternalError();
|
||||||
|
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,8 +1,10 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||||
import { existsSync } from "node:fs";
|
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 { join } from "node:path";
|
||||||
import { runInit } from "../../src/commands/init.ts";
|
import { runInit } from "../../src/commands/init.ts";
|
||||||
|
import { CommandError } from "../../src/cli/errors.ts";
|
||||||
|
|
||||||
const TMP_DIR = join(import.meta.dir, "__tmp_init_test__");
|
const TMP_DIR = join(import.meta.dir, "__tmp_init_test__");
|
||||||
|
|
||||||
@@ -11,7 +13,7 @@ beforeEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await rm(TMP_DIR, { recursive: true, force: true });
|
await retryRm(TMP_DIR);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("runInit", () => {
|
describe("runInit", () => {
|
||||||
@@ -21,11 +23,9 @@ describe("runInit", () => {
|
|||||||
expect(existsSync(join(TMP_DIR, ".rune"))).toBe(true);
|
expect(existsSync(join(TMP_DIR, ".rune"))).toBe(true);
|
||||||
expect(existsSync(join(TMP_DIR, ".rune", "config.yaml"))).toBe(true);
|
expect(existsSync(join(TMP_DIR, ".rune", "config.yaml"))).toBe(true);
|
||||||
|
|
||||||
const content = await readFile(
|
const content = await readFile(join(TMP_DIR, ".rune", "config.yaml"), "utf-8");
|
||||||
join(TMP_DIR, ".rune", "config.yaml"),
|
expect(content).toContain("# Rune 配置文件");
|
||||||
"utf-8",
|
expect(content).toContain("stages:");
|
||||||
);
|
|
||||||
expect(content.trim()).toBe("stages: {}");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("创建 .rune/changes 和 .rune/archive 目录", async () => {
|
it("创建 .rune/changes 和 .rune/archive 目录", async () => {
|
||||||
@@ -39,27 +39,55 @@ describe("runInit", () => {
|
|||||||
await runInit(TMP_DIR, ["opencode"]);
|
await runInit(TMP_DIR, ["opencode"]);
|
||||||
|
|
||||||
expect(existsSync(join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"))).toBe(true);
|
expect(existsSync(join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"))).toBe(true);
|
||||||
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-discuss.md"))).toBe(true);
|
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 runInit(TMP_DIR, ["opencode"]);
|
||||||
await writeFile(
|
await writeFile(join(TMP_DIR, ".rune", "config.yaml"), "自定义内容");
|
||||||
join(TMP_DIR, ".rune", "config.yaml"),
|
|
||||||
"自定义内容",
|
|
||||||
);
|
|
||||||
|
|
||||||
await runInit(TMP_DIR, ["opencode"]);
|
await runInit(TMP_DIR, ["opencode"]);
|
||||||
const content = await readFile(
|
const content = await readFile(join(TMP_DIR, ".rune", "config.yaml"), "utf-8");
|
||||||
join(TMP_DIR, ".rune", "config.yaml"),
|
expect(content).toContain("自定义内容");
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
expect(content).toBe("自定义内容");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("不支持的工具名抛出错误", async () => {
|
it("不支持的工具名抛出 CommandError", async () => {
|
||||||
await expect(runInit(TMP_DIR, ["unknown-tool"])).rejects.toThrow(
|
try {
|
||||||
"不支持的工具",
|
await runInit(TMP_DIR, ["unknown-tool"]);
|
||||||
);
|
expect.unreachable("应抛出错误");
|
||||||
|
} catch (e) {
|
||||||
|
expect(e).toBeInstanceOf(CommandError);
|
||||||
|
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 { 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 { join } from "node:path";
|
||||||
import {
|
import {
|
||||||
assembleDiscussPrompt,
|
assembleDiscussPrompt,
|
||||||
assemblePlanPrompt,
|
assemblePlanPrompt,
|
||||||
assembleBuildPrompt,
|
assembleBuildPrompt,
|
||||||
assembleArchivePrompt,
|
assembleArchivePrompt,
|
||||||
|
assembleTaskPrompt,
|
||||||
} from "../../src/core/assembler.ts";
|
} from "../../src/core/assembler.ts";
|
||||||
import type { RuneConfig } from "../../src/types.ts";
|
import type { RuneConfig } from "../../src/types.ts";
|
||||||
import { defaultConfig } from "../../src/defaults/config.ts";
|
import { defaultConfig } from "../../src/defaults/config.ts";
|
||||||
@@ -17,14 +19,16 @@ beforeEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await rm(TMP_DIR, { recursive: true, force: true });
|
await retryRm(TMP_DIR);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("assembleDiscussPrompt", () => {
|
describe("assembleDiscussPrompt", () => {
|
||||||
it("返回默认 discuss 提示词", () => {
|
it("返回默认 discuss 提示词", () => {
|
||||||
const prompt = assembleDiscussPrompt(defaultConfig);
|
const prompt = assembleDiscussPrompt(defaultConfig);
|
||||||
expect(prompt).toBeTruthy();
|
expect(prompt).toBeTruthy();
|
||||||
expect(prompt).toContain("软件架构师");
|
expect(prompt).toContain("探索模式");
|
||||||
|
expect(prompt).toContain("立场");
|
||||||
|
expect(prompt).toContain("护栏");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("返回自定义 discuss 提示词", () => {
|
it("返回自定义 discuss 提示词", () => {
|
||||||
@@ -32,47 +36,76 @@ describe("assembleDiscussPrompt", () => {
|
|||||||
stages: { discuss: { prompt: "自定义讨论" } },
|
stages: { discuss: { prompt: "自定义讨论" } },
|
||||||
};
|
};
|
||||||
const prompt = assembleDiscussPrompt(config);
|
const prompt = assembleDiscussPrompt(config);
|
||||||
expect(prompt).toBe("自定义讨论");
|
expect(prompt).toContain("自定义讨论");
|
||||||
|
expect(prompt).toContain("# 讨论阶段");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("assemblePlanPrompt", () => {
|
describe("assemblePlanPrompt", () => {
|
||||||
it("包含变更名称和文档指引", async () => {
|
it("包含指定文档名称和提示词", async () => {
|
||||||
const prompt = await assemblePlanPrompt(
|
const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "requirements");
|
||||||
defaultConfig,
|
|
||||||
TMP_DIR,
|
|
||||||
"user-auth",
|
|
||||||
);
|
|
||||||
expect(prompt).toContain("user-auth");
|
expect(prompt).toContain("user-auth");
|
||||||
expect(prompt).toContain("design");
|
expect(prompt).toContain("requirements");
|
||||||
expect(prompt).toContain("task");
|
expect(prompt).not.toContain("task");
|
||||||
expect(prompt).toContain("格式模板");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("包含已有文档内容(重复 plan 场景)", async () => {
|
it("已有文档时引导 AI 读取而非内嵌内容", async () => {
|
||||||
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
|
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
|
||||||
await mkdir(changeDir, { recursive: true });
|
await mkdir(changeDir, { recursive: true });
|
||||||
await writeFile(join(changeDir, "design.md"), "# 已有设计");
|
await writeFile(join(changeDir, "requirements.md"), "# 已有需求");
|
||||||
const prompt = await assemblePlanPrompt(
|
const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "requirements");
|
||||||
defaultConfig,
|
expect(prompt).toContain("已有内容");
|
||||||
TMP_DIR,
|
expect(prompt).toContain("requirements.md");
|
||||||
"user-auth",
|
expect(prompt).not.toContain("# 已有需求");
|
||||||
);
|
|
||||||
expect(prompt).toContain("已有设计");
|
|
||||||
expect(prompt).toContain("在此基础上修订");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("替换模板中的 {{change-name}}", async () => {
|
it("包含格式模板(纯静态文本)", async () => {
|
||||||
const prompt = await assemblePlanPrompt(
|
const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "requirements");
|
||||||
defaultConfig,
|
expect(prompt).toContain("格式模板");
|
||||||
TMP_DIR,
|
expect(prompt).toContain("背景与目标");
|
||||||
"user-auth",
|
|
||||||
);
|
|
||||||
expect(prompt).toContain("user-auth 设计文档");
|
|
||||||
expect(prompt).toContain("user-auth 任务列表");
|
|
||||||
expect(prompt).not.toContain("{{change-name}}");
|
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 () => {
|
it("使用自定义 plan 配置", async () => {
|
||||||
const config: RuneConfig = {
|
const config: RuneConfig = {
|
||||||
stages: {
|
stages: {
|
||||||
@@ -81,64 +114,240 @@ describe("assemblePlanPrompt", () => {
|
|||||||
{
|
{
|
||||||
name: "spec",
|
name: "spec",
|
||||||
prompt: "生成规格",
|
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("spec");
|
||||||
expect(prompt).toContain("my-feature 规格");
|
expect(prompt).toContain("规格文档");
|
||||||
expect(prompt).not.toContain("design");
|
expect(prompt).not.toContain("design");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("assembleBuildPrompt", () => {
|
describe("assembleBuildPrompt", () => {
|
||||||
it("包含待执行任务列表", async () => {
|
it("引导 AI 读取 task.md 而非内嵌任务内容", async () => {
|
||||||
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
|
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
|
||||||
await mkdir(changeDir, { recursive: true });
|
await mkdir(changeDir, { recursive: true });
|
||||||
await writeFile(
|
await writeFile(join(changeDir, "task.md"), `- [x] 任务一\n- [ ] 任务二\n- [ ] 任务三`);
|
||||||
join(changeDir, "task.md"),
|
const prompt = await assembleBuildPrompt(defaultConfig, TMP_DIR, "user-auth");
|
||||||
`- [x] 任务一\n- [ ] 任务二\n- [ ] 任务三`,
|
expect(prompt).toContain("task.md");
|
||||||
);
|
expect(prompt).toContain("2");
|
||||||
const prompt = await assembleBuildPrompt(
|
expect(prompt).not.toContain("任务二");
|
||||||
defaultConfig,
|
expect(prompt).not.toContain("任务三");
|
||||||
TMP_DIR,
|
|
||||||
"user-auth",
|
|
||||||
);
|
|
||||||
expect(prompt).toContain("任务二");
|
|
||||||
expect(prompt).toContain("待执行任务");
|
|
||||||
expect(prompt).toContain("共 2 项");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("所有任务完成时提示可归档", async () => {
|
it("所有任务完成时提示可归档", async () => {
|
||||||
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
|
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
|
||||||
await mkdir(changeDir, { recursive: true });
|
await mkdir(changeDir, { recursive: true });
|
||||||
await writeFile(
|
await writeFile(join(changeDir, "task.md"), `- [x] 任务一\n- [x] 任务二`);
|
||||||
join(changeDir, "task.md"),
|
const prompt = await assembleBuildPrompt(defaultConfig, TMP_DIR, "user-auth");
|
||||||
`- [x] 任务一\n- [x] 任务二`,
|
|
||||||
);
|
|
||||||
const prompt = await assembleBuildPrompt(
|
|
||||||
defaultConfig,
|
|
||||||
TMP_DIR,
|
|
||||||
"user-auth",
|
|
||||||
);
|
|
||||||
expect(prompt).toContain("已完成");
|
expect(prompt).toContain("已完成");
|
||||||
expect(prompt).toContain("归档");
|
expect(prompt).toContain("归档");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("task.md 不存在时抛出错误", async () => {
|
it("task.md 不存在时抛出 CommandError 并附带提示", async () => {
|
||||||
await expect(
|
try {
|
||||||
assembleBuildPrompt(defaultConfig, TMP_DIR, "nonexistent"),
|
await assembleBuildPrompt(defaultConfig, TMP_DIR, "nonexistent");
|
||||||
).rejects.toThrow("task.md not found");
|
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", () => {
|
describe("assembleArchivePrompt", () => {
|
||||||
it("返回归档提示词", () => {
|
it("返回归档提示词", async () => {
|
||||||
const prompt = assembleArchivePrompt(defaultConfig, "user-auth");
|
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("user-auth");
|
||||||
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"), "- [ ] 未完成任务");
|
||||||
|
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 { 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 { 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__");
|
const TMP_DIR = join(import.meta.dir, "__tmp_config_test__");
|
||||||
|
|
||||||
@@ -10,7 +13,7 @@ beforeEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await rm(TMP_DIR, { recursive: true, force: true });
|
await retryRm(TMP_DIR);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("findProjectRoot", () => {
|
describe("findProjectRoot", () => {
|
||||||
@@ -37,6 +40,7 @@ describe("loadConfig", () => {
|
|||||||
const config = await loadConfig(TMP_DIR);
|
const config = await loadConfig(TMP_DIR);
|
||||||
expect(config.stages.discuss).toBeDefined();
|
expect(config.stages.discuss).toBeDefined();
|
||||||
expect(config.stages.plan).toBeDefined();
|
expect(config.stages.plan).toBeDefined();
|
||||||
|
expect(config.stages.task).toBeDefined();
|
||||||
expect(config.stages.build).toBeDefined();
|
expect(config.stages.build).toBeDefined();
|
||||||
expect(config.stages.archive).toBeDefined();
|
expect(config.stages.archive).toBeDefined();
|
||||||
});
|
});
|
||||||
@@ -82,24 +86,135 @@ describe("loadConfig", () => {
|
|||||||
const config = await loadConfig(TMP_DIR);
|
const config = await loadConfig(TMP_DIR);
|
||||||
expect(config.stages.discuss).toBeDefined();
|
expect(config.stages.discuss).toBeDefined();
|
||||||
expect(config.stages.plan).toBeDefined();
|
expect(config.stages.plan).toBeDefined();
|
||||||
|
expect(config.stages.task).toBeDefined();
|
||||||
expect(config.stages.build).toBeDefined();
|
expect(config.stages.build).toBeDefined();
|
||||||
expect(config.stages.archive).toBeDefined();
|
expect(config.stages.archive).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("YAML 解析错误时返回默认配置", async () => {
|
it("YAML 解析错误时抛出 ConfigError", async () => {
|
||||||
const runeDir = join(TMP_DIR, ".rune");
|
const runeDir = join(TMP_DIR, ".rune");
|
||||||
await mkdir(runeDir, { recursive: true });
|
await mkdir(runeDir, { recursive: true });
|
||||||
await writeFile(
|
await writeFile(join(runeDir, "config.yaml"), `stages: [invalid yaml {{{`);
|
||||||
join(runeDir, "config.yaml"),
|
expect(loadConfig(TMP_DIR)).rejects.toThrow(ConfigError);
|
||||||
`stages: [invalid yaml {{{`,
|
|
||||||
);
|
|
||||||
const config = await loadConfig(TMP_DIR);
|
|
||||||
expect(config.stages.discuss).toBeDefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getRuneDir", () => {
|
describe("getRuneDir", () => {
|
||||||
it("返回 .rune 目录路径", () => {
|
it("返回 .rune 目录路径", () => {
|
||||||
expect(getRuneDir("/project")).toBe("/project/.rune");
|
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 { 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 { join } from "node:path";
|
||||||
import { scanChanges, scanArchives } from "../../src/core/scanner.ts";
|
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__");
|
const TMP_DIR = join(import.meta.dir, "__tmp_scanner_test__");
|
||||||
|
|
||||||
@@ -10,7 +12,7 @@ beforeEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await rm(TMP_DIR, { recursive: true, force: true });
|
await retryRm(TMP_DIR);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("scanChanges", () => {
|
describe("scanChanges", () => {
|
||||||
@@ -20,20 +22,14 @@ describe("scanChanges", () => {
|
|||||||
expect(changes).toEqual([]);
|
expect(changes).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("扫描到变更及其文档", async () => {
|
it("有 task.md 时无条件计算 taskProgress", async () => {
|
||||||
const changesDir = join(TMP_DIR, ".rune", "changes");
|
const changesDir = join(TMP_DIR, ".rune", "changes");
|
||||||
await mkdir(join(changesDir, "user-auth"), { recursive: true });
|
await mkdir(join(changesDir, "user-auth"), { recursive: true });
|
||||||
await writeFile(join(changesDir, "user-auth", "design.md"), "# 设计");
|
await writeFile(join(changesDir, "user-auth", "design.md"), "# 设计");
|
||||||
await writeFile(
|
await writeFile(join(changesDir, "user-auth", "task.md"), `- [x] 任务一\n- [ ] 任务二`);
|
||||||
join(changesDir, "user-auth", "task.md"),
|
|
||||||
`- [x] 任务一\n- [ ] 任务二`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const changes = await scanChanges(TMP_DIR);
|
const changes = await scanChanges(TMP_DIR);
|
||||||
expect(changes).toHaveLength(1);
|
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 });
|
expect(changes[0].taskProgress).toEqual({ completed: 1, total: 2 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,6 +53,93 @@ describe("scanChanges", () => {
|
|||||||
const changes = await scanChanges(TMP_DIR);
|
const changes = await scanChanges(TMP_DIR);
|
||||||
expect(changes).toHaveLength(2);
|
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", () => {
|
describe("scanArchives", () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "bun:test";
|
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", () => {
|
describe("parseTasks", () => {
|
||||||
it("解析标准 checkbox 格式", () => {
|
it("解析标准 checkbox 格式", () => {
|
||||||
@@ -52,3 +52,29 @@ describe("parseTasks", () => {
|
|||||||
expect(tasks.length).toBe(3);
|
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";
|
import { defaultConfig } from "../../src/defaults/config.ts";
|
||||||
|
|
||||||
describe("defaultConfig", () => {
|
describe("defaultConfig", () => {
|
||||||
it("包含所有四个阶段的配置", () => {
|
it("包含所有五个阶段的配置", () => {
|
||||||
expect(defaultConfig.stages.discuss).toBeDefined();
|
expect(defaultConfig.stages.discuss).toBeDefined();
|
||||||
expect(defaultConfig.stages.plan).toBeDefined();
|
expect(defaultConfig.stages.plan).toBeDefined();
|
||||||
|
expect(defaultConfig.stages.task).toBeDefined();
|
||||||
expect(defaultConfig.stages.build).toBeDefined();
|
expect(defaultConfig.stages.build).toBeDefined();
|
||||||
expect(defaultConfig.stages.archive).toBeDefined();
|
expect(defaultConfig.stages.archive).toBeDefined();
|
||||||
});
|
});
|
||||||
@@ -14,38 +15,56 @@ describe("defaultConfig", () => {
|
|||||||
expect(typeof defaultConfig.stages.discuss!.prompt).toBe("string");
|
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;
|
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) {
|
for (const doc of docs) {
|
||||||
expect(doc.prompt).toBeTruthy();
|
expect(doc.prompt).toBeTruthy();
|
||||||
|
expect(doc.template).toBeTruthy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("plan 的 task 文档配置存在", () => {
|
it("task 阶段有 prompt", () => {
|
||||||
const taskDoc = defaultConfig.stages.plan!.documents.find(
|
expect(defaultConfig.stages.task).toBeDefined();
|
||||||
(d) => d.name === "task",
|
expect(defaultConfig.stages.task!.prompt).toBeTruthy();
|
||||||
);
|
|
||||||
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("build 阶段有 prompt", () => {
|
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 { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||||
import { existsSync } from "node:fs";
|
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 { join } from "node:path";
|
||||||
import { runInit } from "../../src/commands/init.ts";
|
import { runInit } from "../../src/commands/init.ts";
|
||||||
import { loadConfig, getChangeDir, getArchiveDir } from "../../src/core/config.ts";
|
import { loadConfig, getChangeDir, getArchiveDir } from "../../src/core/config.ts";
|
||||||
import {
|
import {
|
||||||
assembleDiscussPrompt,
|
assembleDiscussPrompt,
|
||||||
assemblePlanPrompt,
|
assemblePlanPrompt,
|
||||||
|
assembleTaskPrompt,
|
||||||
assembleBuildPrompt,
|
assembleBuildPrompt,
|
||||||
assembleArchivePrompt,
|
assembleArchivePrompt,
|
||||||
} from "../../src/core/assembler.ts";
|
} from "../../src/core/assembler.ts";
|
||||||
@@ -19,61 +21,65 @@ beforeEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await rm(TMP_DIR, { recursive: true, force: true });
|
await retryRm(TMP_DIR);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("完整 SDD 流程", () => {
|
describe("完整 SDD 流程", () => {
|
||||||
it("init → discuss → plan → build → archive 完整流程", async () => {
|
it("init → discuss → plan → task → build → archive 完整流程", async () => {
|
||||||
await runInit(TMP_DIR, ["opencode"]);
|
await runInit(TMP_DIR, ["opencode"]);
|
||||||
expect(existsSync(join(TMP_DIR, ".rune", "config.yaml"))).toBe(true);
|
expect(existsSync(join(TMP_DIR, ".rune", "config.yaml"))).toBe(true);
|
||||||
expect(existsSync(join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"))).toBe(true);
|
expect(existsSync(join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"))).toBe(true);
|
||||||
|
|
||||||
const config = await loadConfig(TMP_DIR);
|
const config = await loadConfig(TMP_DIR);
|
||||||
const discussPrompt = assembleDiscussPrompt(config);
|
const discussPrompt = assembleDiscussPrompt(config);
|
||||||
expect(discussPrompt).toContain("软件架构师");
|
expect(discussPrompt).toContain("探索模式");
|
||||||
|
|
||||||
const changeName = "user-auth";
|
const changeName = "user-auth";
|
||||||
await mkdir(getChangeDir(TMP_DIR, changeName), { recursive: true });
|
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");
|
expect(planPrompt).toContain("user-auth");
|
||||||
|
|
||||||
const changeDir = getChangeDir(TMP_DIR, changeName);
|
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, "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- [ ] 编写登录测试");
|
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).toHaveLength(1);
|
||||||
expect(changes[0].name).toBe("user-auth");
|
expect(changes[0].name).toBe("user-auth");
|
||||||
expect(changes[0].taskProgress).toEqual({ completed: 0, total: 2 });
|
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);
|
const buildPrompt = await assembleBuildPrompt(config, TMP_DIR, changeName);
|
||||||
expect(buildPrompt).toContain("实现登录 API");
|
expect(buildPrompt).toContain("task.md");
|
||||||
expect(buildPrompt).toContain("共 2 项");
|
expect(buildPrompt).toContain("2");
|
||||||
|
|
||||||
await writeFile(join(changeDir, "task.md"), "- [x] 实现登录 API\n- [x] 编写登录测试");
|
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].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);
|
const buildPrompt2 = await assembleBuildPrompt(config, TMP_DIR, changeName);
|
||||||
expect(buildPrompt2).toContain("已完成");
|
expect(buildPrompt2).toContain("已完成");
|
||||||
|
|
||||||
const archivePrompt = assembleArchivePrompt(config, changeName);
|
const archivePrompt = await assembleArchivePrompt(config, TMP_DIR, changeName);
|
||||||
expect(archivePrompt).toContain("归档");
|
expect(archivePrompt).toContain("归档阶段");
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
expect(archivePrompt).toContain("user-auth");
|
||||||
const src = getChangeDir(TMP_DIR, changeName);
|
expect(archivePrompt).toContain("finish");
|
||||||
const dest = join(getArchiveDir(TMP_DIR), `${today}-${changeName}`);
|
|
||||||
await mkdir(join(TMP_DIR, ".rune", "archive"), { recursive: true });
|
|
||||||
await rename(src, dest);
|
|
||||||
|
|
||||||
expect(existsSync(dest)).toBe(true);
|
const postArchiveChanges = await scanChanges(TMP_DIR, config);
|
||||||
expect(existsSync(src)).toBe(false);
|
expect(postArchiveChanges).toHaveLength(1);
|
||||||
|
|
||||||
const archives = await scanArchives(TMP_DIR);
|
|
||||||
expect(archives).toContain(`${today}-${changeName}`);
|
|
||||||
|
|
||||||
const postArchiveChanges = await scanChanges(TMP_DIR);
|
|
||||||
expect(postArchiveChanges).toHaveLength(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("多变更并行", async () => {
|
it("多变更并行", async () => {
|
||||||
@@ -86,14 +92,14 @@ describe("完整 SDD 流程", () => {
|
|||||||
await writeFile(join(changeDir, "task.md"), `- [ ] ${name} 任务`);
|
await writeFile(join(changeDir, "task.md"), `- [ ] ${name} 任务`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const changes = await scanChanges(TMP_DIR);
|
const changes = await scanChanges(TMP_DIR, config);
|
||||||
expect(changes).toHaveLength(2);
|
expect(changes).toHaveLength(2);
|
||||||
|
|
||||||
const authPrompt = await assembleBuildPrompt(config, TMP_DIR, "auth");
|
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");
|
const paymentPrompt = await assembleBuildPrompt(config, TMP_DIR, "payment");
|
||||||
expect(paymentPrompt).toContain("payment 任务");
|
expect(paymentPrompt).toContain("task.md");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("自定义配置覆盖默认配置", async () => {
|
it("自定义配置覆盖默认配置", async () => {
|
||||||
@@ -108,22 +114,167 @@ describe("完整 SDD 流程", () => {
|
|||||||
documents:
|
documents:
|
||||||
- name: spec
|
- name: spec
|
||||||
prompt: 生成规格文档
|
prompt: 生成规格文档
|
||||||
template: "# {{change-name}} 规格"
|
template: "# 规格文档"
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const config = await loadConfig(TMP_DIR);
|
const config = await loadConfig(TMP_DIR);
|
||||||
|
|
||||||
const discussPrompt = assembleDiscussPrompt(config);
|
const discussPrompt = assembleDiscussPrompt(config);
|
||||||
expect(discussPrompt).toBe("自定义讨论");
|
expect(discussPrompt).toContain("自定义讨论");
|
||||||
|
expect(discussPrompt).toContain("# 讨论阶段");
|
||||||
|
|
||||||
await mkdir(getChangeDir(TMP_DIR, "test"), { recursive: true });
|
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("spec");
|
||||||
expect(planPrompt).toContain("test 规格");
|
expect(planPrompt).toContain("规格文档");
|
||||||
expect(planPrompt).not.toContain("design");
|
expect(planPrompt).not.toContain("design");
|
||||||
|
|
||||||
|
expect(config.stages.task).toBeDefined();
|
||||||
expect(config.stages.build).toBeDefined();
|
expect(config.stages.build).toBeDefined();
|
||||||
expect(config.stages.archive).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