Compare commits

..

83 Commits

Author SHA1 Message Date
1f6e49e336 feat: opencode/claude-code 统一 command 格式、智能识别引导、新增 intro/create、移除 status 2026-06-10 13:16:02 +08:00
49f523146f feat: help 新增 create 命令帮助,更新阶段顺序 2026-06-10 13:11:01 +08:00
cb537b4f2a docs: config.yaml 模板移除 {{change-name}},增加 metadata 说明和 create 阶段 2026-06-10 13:08:47 +08:00
ed4da9b6a0 feat: 新增 create 命令,移除 fallbackNote,plan 不再自动 mkdir 2026-06-10 13:06:30 +08:00
daec0612c4 feat: 新增 assembleCreatePrompt,plan/build/archive 不再内嵌文件内容 2026-06-10 13:02:16 +08:00
b9ea668383 feat: types 和 defaultConfig 增加 create 阶段 2026-06-10 12:55:56 +08:00
a926200613 chore: release v0.1.3 2026-06-10 09:37:39 +08:00
8d6f9dbad6 chore: 添加 docs/superpowers 提交防护和 git 强制操作权限限制 2026-06-10 09:35:10 +08:00
03fabd8ab9 chore: 移除 docs/superpowers 目录的 git 跟踪 2026-06-10 09:29:53 +08:00
e3067d017e chore: 移除所有 e2e 测试 2026-06-10 09:27:03 +08:00
641d23b7c8 feat: archive 阶段根据 tracked 分支处理 2026-06-10 09:16:57 +08:00
3789d0a7b3 feat: status 命令根据 tracked 决定是否扫描 task.md 2026-06-10 09:10:17 +08:00
0c89b3ebb2 feat: build 阶段根据 tracked 分支处理 2026-06-10 09:03:38 +08:00
8e00e2cdf1 feat: 新增 validateTaskFormat 校验函数 2026-06-10 08:56:44 +08:00
7d5af32ce5 feat: metadata.tracked 类型、validateConfig 校验与 mergeConfig 深合并 2026-06-10 08:52:18 +08:00
af58f786af docs: 新增 tracked 任务跟踪模式实现计划 2026-06-10 00:15:37 +08:00
93bfa4373a docs: 新增 tracked 任务跟踪模式设计文档 2026-06-10 00:11:05 +08:00
7e260291f0 fix: 修复 CRLF 换行文件下 parseTasks 解析失败导致任务进度误判 2026-06-09 22:47:39 +08:00
983a21acb6 docs: 更新安装说明和命令示例使用完整包名 2026-06-09 20:41:43 +08:00
78caec6449 feat: 提示词拼装使用动态命令前缀 2026-06-09 20:37:10 +08:00
2feea7a74f feat: CLI 展示文本使用动态命令前缀 2026-06-09 20:29:42 +08:00
ce00751585 feat: 帮助文本支持动态命令前缀 2026-06-09 20:22:44 +08:00
589eaa120e feat: init 命令检测包管理器并写入 metadata.command 2026-06-09 20:18:04 +08:00
a5c8263412 feat: 适配器支持动态命令前缀 2026-06-09 20:13:40 +08:00
909c29db25 feat: mergeConfig 保留 metadata 字段 2026-06-09 20:08:10 +08:00
c9e2ff1c42 feat: 增加 metadata 数据模型和包管理器检测核心函数 2026-06-09 20:04:23 +08:00
a99ebb81a3 chore: release v0.1.2 2026-06-09 18:36:42 +08:00
92c1fed5d5 chore: release v0.1.1 2026-06-09 18:36:02 +08:00
750480e30c fix: 工作区检查移到流程最开始,避免写版本号后才报错 2026-06-09 18:34:49 +08:00
075bdcdd54 fix: 测试通过后再写回版本号,避免测试失败时污染 package.json 2026-06-09 18:32:47 +08:00
682bdda3e5 refactor: 测试文件改为导入源码函数,添加 import.meta.main 保护 2026-06-09 18:23:52 +08:00
7ce344801f docs: 添加 release 发布流程说明到开发文档 2026-06-09 18:18:17 +08:00
9a2fde2cfa fix: 精确匹配 package.json 状态行,使用 annotated tag 2026-06-09 18:17:26 +08:00
307bdfc922 feat: 实现 git commit + tag 和 npm publish 步骤 2026-06-09 18:14:53 +08:00
1b69e454d7 feat: 实现测试门禁步骤 2026-06-09 18:09:52 +08:00
cecd7ab925 fix: 添加 package.json version 字段校验 2026-06-09 18:01:24 +08:00
da7770f76a feat: 实现版本递增与交互选择流程 2026-06-09 17:56:55 +08:00
1871d0b665 test: 修正小数测试命名为四段格式抛出异常 2026-06-09 17:50:31 +08:00
9cc0a96f68 test: 版本递增逻辑单元测试 2026-06-09 17:47:40 +08:00
58a771ebca feat: 添加 release 脚本骨架和版本递增逻辑 2026-06-09 17:39:59 +08:00
2efcff5742 docs: 添加 files 字段限定发布内容 2026-06-09 17:38:36 +08:00
088254ab0f chore: 更新包名为 @lanyuanxiaoyao/rune,移除 private,添加 release 脚本 2026-06-09 17:30:58 +08:00
59632b5312 refactor: tier 文件重命名为 agent-mock/agent-scenario/agent-llm 2026-06-09 16:52:47 +08:00
faefefda39 chore: e2e 测试与单元测试分离,通过脚本区分 2026-06-09 16:23:32 +08:00
073b9c1e47 feat: 第三期 — Tier 3 LLM-as-Judge 集成 2026-06-09 16:11:48 +08:00
bb7d5e740c feat: 第二期 — Tier 2 场景级 mock + 错误/流程/依赖测试 2026-06-09 15:52:01 +08:00
8739a404f6 docs: 更新 init 配置模板中的 discuss 阶段描述 2026-06-09 15:39:00 +08:00
662c66c08e test: 增强 discuss 默认提示词测试覆盖 2026-06-09 15:34:53 +08:00
6346398962 test: 更新 discuss 测试以匹配新提示词 2026-06-09 15:33:13 +08:00
77cd056492 fix: 修正护栏描述中的双重否定 2026-06-09 15:32:24 +08:00
4a253bbb72 feat: 升级 discuss 阶段提示词,引入探索模式立场 2026-06-09 15:26:39 +08:00
56f39d5f0a test: build 阶段端到端测试(5 个用例) 2026-06-09 15:17:23 +08:00
6214eedf4d test: plan 阶段端到端测试(8 个用例) 2026-06-09 15:17:09 +08:00
0d90e6b2a3 test: archive 阶段端到端测试(3 个用例) 2026-06-09 15:17:05 +08:00
ac16bfa383 feat: 实现 Tier 1 命令级 mock agent 2026-06-09 15:13:32 +08:00
2d5b40379f feat: 创建可复用断言工具集 2026-06-09 15:12:12 +08:00
4e736998c7 feat: 创建测试夹具工具函数 2026-06-09 15:11:50 +08:00
9b52b46d3e feat: 定义 AgentRunner 接口 2026-06-09 15:11:43 +08:00
5a7b8f1dcc fix: 修复 oxlint 报告的代码质量问题 2026-06-09 14:33:15 +08:00
6ebfe24921 docs: 补充 lint/format 开发文档 2026-06-09 14:30:27 +08:00
34974714a2 chore: 配置 pre-commit hook 运行 lint-staged 2026-06-09 14:28:54 +08:00
7ad5411e45 chore: 添加 lint/format/check scripts 和 lint-staged 配置 2026-06-09 14:26:42 +08:00
b82f1caf0b chore: 添加 .oxfmtrc.json 并格式化全部代码 2026-06-09 14:22:33 +08:00
ebd5bb4051 chore: 添加 .oxlintrc.json 配置 2026-06-09 14:19:58 +08:00
107cd4f711 chore: 安装 oxlint、oxfmt、husky、lint-staged 2026-06-09 14:13:42 +08:00
bfa0f29dd5 refactor: 修复代码审查发现的问题
- Bug修复: formatChangeStatus 使用实际配置而非 defaultConfig
- 统一 assembler 中所有错误抛出为 CommandError
- 提取 writeIfChanged 到 adapters/utils.ts,消除 claude-code/opencode 重复代码
- 导出 SUPPORTED_TOOLS,cli.ts update 命令复用同一工具注册表
- 提取 mapError/mapCacError 函数,支持单元测试
- 补充 claude-code 适配器测试(10 个用例)
- 补充 validateChangeName、formatChangeStatus、suggestNextStep、mapError 单元测试(18 个用例)
- 共新增 3 个测试文件,测试从 96 增至 133,全部通过
2026-06-09 12:57:28 +08:00
7b258f4d90 docs: 更新 README 和 DEVELOPMENT,补充设计决策说明 2026-06-09 12:40:35 +08:00
60493e4e47 fix: 合并重复的 node:fs/promises 导入 2026-06-09 12:39:47 +08:00
f257ccbe4a feat: 新增 rune update 命令用于更新编辑器配置 2026-06-09 12:39:10 +08:00
c45f6e1d45 feat: archive 阶段校验 task 完成状态,未完成时注入警告提示词 2026-06-09 12:36:19 +08:00
5705e59285 feat: plan skill 引导 AI 先通过 rune status 获取文档列表 2026-06-09 12:34:26 +08:00
da826e2029 feat: 变更名限制为中文、英文和短横线 2026-06-09 12:32:33 +08:00
0869014c3f fix: build 命令在 plan 未完成时给出友好提示而非未预期错误 2026-06-09 11:42:27 +08:00
27f19d8bdf docs: 更新 plan/status 命令文档,增加 depend 配置说明 2026-06-09 11:03:31 +08:00
5d5d5cdc92 test: 适配集成测试到新 plan/status API 签名,验证文档依赖状态 2026-06-09 11:00:09 +08:00
4ef172ff2f feat: status 命令支持可选 change-name 参数,展示详细状态与下一步建议 2026-06-09 10:56:55 +08:00
5799ab6978 feat: plan 命令新增 document-name 参数,校验依赖是否满足 2026-06-09 10:54:03 +08:00
160ec576e1 feat: 更新 plan 和 status 命令的帮助文本 2026-06-09 10:52:24 +08:00
636ca48b4c feat: 默认配置 task 文档增加 depend: [design],init 模板增加 depend 示例 2026-06-09 10:52:04 +08:00
ee01bd87ab feat: assemblePlanPrompt 改为按单文档组装,增加依赖说明 2026-06-09 10:49:02 +08:00
1c7a8b3322 feat: scanChanges 扩展返回 DocumentStatus、planCompleted、buildUnlocked 2026-06-09 10:43:25 +08:00
0d2b117680 feat: 新增 validateConfig 校验 depend 引用、自依赖和循环依赖 2026-06-09 10:40:07 +08:00
566a9d7255 feat: 扩展 DocumentConfig 增加 depend 字段,新增 DocumentStatus 类型,扩展 ChangeStatus 2026-06-09 10:36:43 +08:00
40 changed files with 3260 additions and 490 deletions

5
.husky/pre-commit Normal file
View 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
View 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
View 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"
}
}

View File

@@ -37,14 +37,35 @@ tests/ # 测试目录(镜像 src 结构)
## 开发命令
```bash
bun test # 运行全部测试
bun test tests/core/ # 运行指定目录测试
bun test # 运行单元/集成测试
bun test tests/core/ # 运行指定目录测试
bun run release # 发布新版本交互式递增版本号、测试门禁、git commit+tag、npm publish
bun src/cli.ts init opencode # 测试 init 命令
bun src/cli.ts help # 查看全局帮助
bun src/cli.ts help init # 查看 init 命令帮助
bun src/cli.ts version # 查看版本号
bun src/cli.ts plan <变更名> <文档名> # 测试 plan 命令
bun src/cli.ts status [变更名] # 测试 status 命令
bun src/cli.ts help # 查看全局帮助
bun src/cli.ts help init # 查看 init 命令帮助
bun src/cli.ts version # 查看版本号
```
### 代码质量
项目使用 oxlint 进行静态分析oxfmt 进行代码格式化,提交时通过 husky + lint-staged 自动检查。
```bash
bun lint # 静态分析所有文件
bun format # 格式化所有文件(写回)
bun format:check # 检查格式不写回CI 用)
bun check # 一键 lint + 格式检查
```
**配置文件:**
- `.oxlintrc.json` — oxlint 规则配置correctness + suspicious 全开style 选开)
- `.oxfmtrc.json` — oxfmt 格式设置双引号、分号、尾逗号、2 空格缩进、100 行宽、LF 换行)
**pre-commit hook** 提交时自动对 staged 文件运行 lint 和格式化lint 失败会阻止提交。
## CLI 交互架构
### 子命令
@@ -63,20 +84,51 @@ CLI 通过子命令提供帮助和版本信息,不使用 `--help`/`--version`
- `src/cli/output.ts` — 格式化输出(`错误:``用法:``提示:` 三段式)
- `src/cli/help.ts` — 帮助文本生成
## 设计决策
### 阶段与配置
- **四阶段固定**discuss → plan → build → archive不可自定义增删
- **配置覆盖策略**:阶段级别全量覆盖,不支持字段级合并。自定义 plan 时需完整重写所有 documents
- **配置文件名**`.rune/config.yaml`,不是 `rune.yml`
- **模板变量**:仅支持 `{{change-name}}`,不需要其他变量(信息已在上下文中)
### 各阶段行为
- **discuss**:不持久化讨论结果,完全依赖 AI 会话上下文传递;不设强制门控,通过提示词引导
- **plan**命令只输出提示词不写入文件AI 负责根据提示词生成文档内容并写入。重复调用同一文档的 plan 会追加已有内容用于增量修订。依赖未满足时有友好提示(非报错)
- **build**:按 task.md 的 checkbox 顺序执行;任务间无结构化依赖;可多次执行直到全部完成
- **archive**:归档前命令行校验 task 完成状态,未完成时在提示词中注入警告并引导 AI 询问用户
### 变更名校验
变更名仅支持中文、英文、短横线(`-`),不支持空格、下划线等其他符号。
### update 命令
`rune update <tool>` 对比已注入的命令/skill 文件内容与内置版本,不一致则覆盖,不存在则新建。用于升级 Rune 后更新编辑器配置。
### 其他决策
- 无跨变更依赖,变更之间完全独立
- 无并发锁,同一变更可被多个 agent 同时操作
- 无需变更废弃命令,手动删除目录即可
- 同一变更名同天多次归档不处理冲突(日期+名称去重)
- plan skill 应引导 AI 先通过 `rune status` 获取文档列表
## 测试策略
### Level 1 — 纯单元/集成测试(当前
### 单元/集成测试(`bun test`
在临时目录执行完整流程,验证文件创建、目录结构、提示词输出。
在临时目录执行完整流程,验证文件创建、目录结构、提示词输出。覆盖 `src/core/``src/cli/``src/adapters/``tests/integration/`
### Level 2 — 提示词快照测试(后续增强)
## 发布流程
对每个阶段捕获提示词输出,与预期快照对比。
`bun run release` 交互式发布新版本到 npm
### Level 3 — mock-agent 端到端(后续增强)
1. **版本递增**:选择 major/minor/patch确认后写回 package.json
2. **测试门禁**:执行 `bun test`,失败则终止
3. **Git 操作**:确认后执行 `git add` + `commit` + `tag`(仅本地,不推送)
4. **npm 发布**`bun publish --dry-run` 预览,确认后 `bun publish --access public`
编排完整闭环rune 输出 → mock-agent 处理 → rune 继续下一阶段。
### Level 4 — 真实 AI 工具集成CI 可选)
调用 LLM API 验证输出格式可被解析。
发布前确保已通过 `npm login` 登录 npm且 npm 账号有 `@lanyuanxiaoyao` scope 的发布权限。

View File

@@ -5,45 +5,102 @@
## 安装
```bash
bunx rune init opencode
bunx @lanyuanxiaoyao/rune init opencode
```
如果没有安装 bun可使用 `pnpx @lanyuanxiaoyao/rune``npx @lanyuanxiaoyao/rune` 替代。
## 使用
### 初始化
```bash
bunx rune init opencode
bunx @lanyuanxiaoyao/rune init opencode # OpenCode 编辑器
bunx @lanyuanxiaoyao/rune init claude-code # Claude Code 编辑器
```
会在项目中创建:
- `.rune/` 目录(配置、变更文档、归档)
- `.opencode/commands/``.opencode/skills/`(注入的 AI 工具配置
- 编辑器对应的 command 和 skill 文件(如 `.opencode/commands/``.opencode/skills/`
### 更新编辑器配置
当 Rune 版本升级后,需要更新已注入的命令和 skill 文件:
```bash
bunx @lanyuanxiaoyao/rune update opencode # 更新 OpenCode 的命令和 skill
bunx @lanyuanxiaoyao/rune update claude-code # 更新 Claude Code 的命令
```
更新策略:对比文件内容,不一致则用内置版本覆盖;不存在则新建。
### SDD 流程
1. `/rune-discuss` — 自由讨论需求
2. `/rune-plan <变更名>` — 生成设计文档和任务列表
3. `/rune-build <变更名>` — 按任务顺序编码实现
4. `/rune-archive <变更名>` — 归档并清理
SDD 工作流包含固定的四个阶段,不可自定义增删:
1. **讨论阶段**`/rune-discuss`:与 AI 自由讨论需求和方案。讨论结果保留在 AI 会话上下文中传递到后续阶段,不持久化到文件。结束前会引导是否进入规划阶段。
2. **规划阶段**`/rune-plan <变更名> <文档名>`:按配置的文档模板生成规划文档。变更名仅支持中文、英文和短横线(`-`)。默认包含 `design`(设计文档)和 `task`(任务清单,依赖 design两个文档。文档间支持 `depend` 字段声明前置依赖依赖未满足时有友好提示。plan 命令自身不写入文件,只输出提示词供 AI 消费。
3. **构建阶段**`/rune-build <变更名>`:按 task.md 中的任务顺序逐个实现。每个任务完成后更新对应的 checkbox 为 `[x]`。可多次执行直到所有任务完成。
4. **归档阶段**`/rune-archive <变更名>`:将变更目录移至 `archive/`。归档前自动检查 task.md 的完成状态,如有未完成任务会注入警告提示词,引导 AI 询问用户是否确认归档。
### 状态查看
```bash
rune status
bunx @lanyuanxiaoyao/rune status # 查看所有变更(含各阶段文档完成状态、下一步建议)
bunx @lanyuanxiaoyao/rune status <变更名> # 查看指定变更的详细状态
```
### 帮助与版本
规划阶段应引导 AI 先通过 `bunx @lanyuanxiaoyao/rune status` 获取当前有哪些文档需要编写。
### 命令参考
```bash
rune help # 显示全局帮助
rune help <command> # 显示指定命令的详细帮助
rune version # 显示版本号
bunx @lanyuanxiaoyao/rune help # 显示全局帮助
bunx @lanyuanxiaoyao/rune help <command> # 显示指定命令的详细帮助
bunx @lanyuanxiaoyao/rune version # 显示版本号
```
| 命令 | 说明 |
| -------------------------------------------------- | ----------------------------------------------- |
| `bunx @lanyuanxiaoyao/rune init <tool>` | 初始化项目,注入编辑器配置 |
| `bunx @lanyuanxiaoyao/rune update <tool>` | 更新编辑器的命令和 skill 文件 |
| `bunx @lanyuanxiaoyao/rune discuss` | 输出讨论阶段提示词 |
| `bunx @lanyuanxiaoyao/rune plan <变更名> <文档名>` | 输出规划阶段提示词 |
| `bunx @lanyuanxiaoyao/rune build <变更名>` | 输出构建阶段提示词 |
| `bunx @lanyuanxiaoyao/rune archive <变更名>` | 输出归档阶段提示词,同时移动变更目录到 archive/ |
| `bunx @lanyuanxiaoyao/rune status [变更名]` | 显示变更状态和下一步建议 |
### 自定义配置
编辑 `.rune/config.yaml` 自定义提示词和文档模板。配置文件默认为空,使用内置默认策略;仅覆盖需要自定义的阶段,未配置的阶段使用内置默认配置。
编辑 `.rune/config.yaml` 自定义各阶段的提示词和文档模板。配置合并采用阶段级别全量覆盖策略:自定义某个阶段时需完整重写该阶段的配置,未配置的阶段使用内置默认配置。
规划阶段的文档支持 `depend` 字段声明前置依赖,如 `task` 依赖 `design`
```yaml
stages:
plan:
documents:
- name: design
prompt: 生成设计文档,包含背景、目标、方案、接口和注意事项
- name: task
prompt: 生成任务清单,将设计拆分为可独立执行的小任务
depend: [design]
```
计划阶段文档模板支持 `{{change-name}}` 变量,运行时会替换为实际变更名。
## 设计决策
- **四阶段固定**discuss → plan → build → archive 不可自定义增删
- **配置覆盖策略**:阶段级别全量覆盖,不支持字段级合并
- **讨论结果不持久化**:完全依赖 AI 会话上下文传递
- **plan 不写文件**plan 命令只输出提示词,由 AI 负责写入文档
- **变更名限制**:仅支持中文、英文、短横线(`-`
- **同一变更名同天多次归档**:依靠日期+变更名去重,不做冲突处理
- **无跨变更依赖**:变更之间完全独立
- **无并发锁**:同一变更可被多个 AI agent 同时操作
- **无变更废弃命令**:用户手动删除 `.rune/changes/<变更名>/` 目录即可
## 开发

144
bun.lock
View File

@@ -10,6 +10,10 @@
},
"devDependencies": {
"@types/bun": "latest",
"husky": "^9.1.7",
"lint-staged": "^17.0.7",
"oxfmt": "^0.54.0",
"oxlint": "^1.69.0",
},
"peerDependencies": {
"typescript": "^5",
@@ -17,18 +21,158 @@
},
},
"packages": {
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.54.0", "", { "os": "android", "cpu": "arm" }, "sha512-NAtpl/SiaeU103e7/OmZw0MvUnsUUopW7hEm/ecegJg7YM0skQaA0IXEZoyTV6NUdiNPupdIUreRqUZTShbn/g=="],
"@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.54.0", "", { "os": "android", "cpu": "arm64" }, "sha512-B4VZfBUlKK1rmMChsssNZbkZjE8+FzG3avMjGgMDwbGxXRoXkoeXiAZ+78Oa+eyDPHvDCiUb4zH/vmCOUSafLQ=="],
"@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.54.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-i02vF75b+ePsQP3tHqSxVYI5S6b8X/xqdPu7/mDHXtpgXLTYXi3jJmfHU0j+dnZZDKaYTx/ioCK7QYJmtiJR2g=="],
"@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.54.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-8VMFvGvooXj7mswkbrhdVZ2/sgiDaBzWpkkbtO+qGDLV4EfJd67nQadHkQC0ZNbaWA9ajXfqI6i7PZLIeDzxEQ=="],
"@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.54.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-0cRHnp43WN1Jrc5s0BdbdKgR1XirdvHy7TAFi3JEsoEVQVJxTXMbpVd76sxXlgRswNMDhVFSJw+y7Eb8mEavFQ=="],
"@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-JyQAk3hK/OEtup7Rw6kZwfdzbKqTVD5jXXb8Xpfay29suwZyfBDMVW/bj4RqEPySYWc6zCp198pOluf8n5uYzg=="],
"@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-qnvLatTpM8vtvjOfcckBOzJjk+n6ce/wwpP8OFeUrD5aNLYcKyWAitwj+Rk3PK9jGanbZvKsJnv14JGQ6XqFdw=="],
"@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-SMkhnCzIYZYDk9vw3W/80eeYKmrMpGF0Giuxt4HruFlCH7jEtnPeb3SdQKMfgYi/dgtaf+hZAb5XWPYnxqCQ3w=="],
"@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-QrwJlBFFKnxOd95TAaszpMbZBLzMoYMpGaQTZF8oibacnF5rv8l12IhILhQRPmksWiBqg0YSe2Mnl7ayeJAHSA=="],
"@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.54.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WILatiol/TUHTlhod7R09+7Az/XlhKwmY1MHfLZNmewltPWNN/EwxP2rQSHahibZ/cB8gmckEBjBOByD+5bYsQ=="],
"@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-f05YMG4BH4G8S4ME6UM6fi1MnJ9094mrnvO5Pa4SJlMfWlUM+1/ZWMEF4NnjM7shZAvbHsHRuVYpUo0PHC4P9Q=="],
"@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-UfL+2hj1ClNqcCRT9s8vBU4axDpjxgVxX96G+9DYAYjoc5b0u15CJtn2jgsi9iM+EbGNc5CW1HVRgwVu76UsSA=="],
"@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.54.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-3/XZe931Hka+J6NjnaqJzYpsWWxDTuRdUdwSQHnOuJEgbC+SehIMFJS8hsEjV7LBhVSL2OCnRLvbVW8O97XIyw=="],
"@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Ik93RlObtu43GbxApafayFjwYE06L6Xr08cSwpBPYbDrLp2ReZx0Jm1DqwRyYRnukUJy+rK2WaEvUQOxdytU9Q=="],
"@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-yZcakmPlD86CNymknd7KfW+FH+qfbqJH+i0h69CYfV1+KMoVeM9UED+8+TDVoU4haxI0NxY7RPCvRLy3Sqd2Qg=="],
"@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.54.0", "", { "os": "none", "cpu": "arm64" }, "sha512-GiVBZNnEZnKu00f1jTg49nomv187d0GQX+O+ocykoLeiaALuEO+swoTehHn9TehTfi7V8H0i0e/yvUjCqnwk1w=="],
"@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.54.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-J0SSB8Z1Fre2sxRolYcW6Rl1RQmKdQ2hnHyq4YJrfBRiXTObLw4DXnIVraM/UyqGqwOi7yTrQA4VT7DPxlHVKA=="],
"@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.54.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-O61UDVj8zz6yXJjkHPf05VaMLOXmEF8P5kf/N0W7AQMmd6bcQogl+KJc7rMutKTL524oE9iH32JXZClBFmEQIg=="],
"@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-1MDpqJPiFqxWtIHas8vkb1VZ7f7eKyTffAwmO8isxQYMaG1OFKsH666BWLeXQLO+IWNfiMssLD55hbR1lIPTqg=="],
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.69.0", "", { "os": "android", "cpu": "arm" }, "sha512-DKQQbD5cZ/MYfDgDI7YGyGD9FSxABlsBsYFo5p26lloob543tP9+4N3guwdXIYJN+7HSZxLe8YJuwcOWw5qnHg=="],
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.69.0", "", { "os": "android", "cpu": "arm64" }, "sha512-lEhb+I5pr4inux+JFwfCa1HRq3Os7NirEFQ0H1I35SVEHPm6byX0Ah47xmRha3qi6LAkxUcxViL8o/9PivjzBg=="],
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.69.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GY2YE8lOZW59BW1Ia1y+1gR0XyjrZRvVWHAr8LGeGhYHE0OQJ/7cRKXTkx1P+E9/6awEc3SX8a68SFTjh/E//A=="],
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.69.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ax1oZnOjHX3LB7myQyHEaQkDwfLb6str3/nSP6O7EVUviQGNkEGzGV0EqcBJWK+Ufwx0l4xPgyYayurvhAdl2Q=="],
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.69.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kHWeHv4g2h8NY+mpCxzCtY4uerMJWTN/TSnNj1CPbakFpHEJ6cTya2wWV0pDSYWOJ2+0UiEbhn3AtXxHtsnKjg=="],
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.69.0", "", { "os": "linux", "cpu": "arm" }, "sha512-gq84vM1a1oEehXo27YCDzGVcxPsZDI1yswZwz2Da1/cbnWtrL16XZZnz0G/+gIU8edtHpfjxq5c+vWEHqJfWoQ=="],
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.69.0", "", { "os": "linux", "cpu": "arm" }, "sha512-kIqEa98JQ0VRyrcncxA417m2AzasqTlD+FyVT1AksjvjkqQcvm7pBWYvoW3/mpyOP2XYvi5nSCCTIe6De1yu5g=="],
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.69.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j+xYiXozxGWx2cpjCrwwGR4awTxPFsRv3JZrv23RCogEPMc4R7UqjHW47p/RG0aRlbWiROCJ8coUfCwy0dvzHA=="],
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.69.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-xEPpNppTfN1l/nM7gYSf9iocscu/as+p/7vxkLeLEKnYU+09Dm+5V6IhDYDh+Uz6FajEupWwCLt5SOG0y1PCKg=="],
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.69.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Ug0+eU7HJBlek+SjklYH62IlOMirEJsdxpihH0kSqX0XdrDD4NdHpQc10fK1JC35yn6KrrcN+uYzlHD38XAf8Q=="],
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.69.0", "", { "os": "linux", "cpu": "none" }, "sha512-iEyI3GIg0l/s3G4qy2TlaaWKdzj4PJJStwtlocpDTC00PY9hZueotf6OKUj9+yfQh0lrpBW/pLMgTztbAHKJEg=="],
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.69.0", "", { "os": "linux", "cpu": "none" }, "sha512-NjHjpiI4WIKSMwuoJSZi5VToPeoYOS1FR52HLIDG6lidMdqquusgtODb4iLk0+lb1q3Z0nv2/aPRcC/olmpQGg=="],
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.69.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Ai/prDewoItkDXbp38gwGZi41DycZbUTZJ3UidwoHgQC0/DaqC2TGdtBTQLJ6hSD+SAxASzh8+/eSBPmxfOacA=="],
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.69.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Gt3KHgp46mRKz4sJeaASmKvD8ayXookRw07RMf+NowhEztGGDZ7VrXpoW96XuKJLjFukWizOFVNjmYb/u7caNQ=="],
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.69.0", "", { "os": "linux", "cpu": "x64" }, "sha512-7tQhJ2+p/oHv1zcfnjYI7YVzC/7iBaVOfIvFYtxdJ5F45mWgEdrCyXZXZGfiLey5t/5JhOhsaMnnv1kAzckd7g=="],
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.69.0", "", { "os": "none", "cpu": "arm64" }, "sha512-vmWz6TKp/3hfA4lksR0zHBv/6xuX1jhym6eqOjdH2DXsDDHZWcp2f0KG0VCAnlVbIrjk29G4wAWMXb/Hn1YobA=="],
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.69.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-9RExaLgmaw6IoIkU9cTpT71mLfI0xZ86iZH8x518LVsOkjquJMYqb9P7KpC8lgd1t0Dxs41p2pxynq4XR3Ttzw=="],
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.69.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-1907kRPF8/PrcIw1E7LMs9JbVrpgnt/MvFdss3an8oDkYNAACXzTntV3t3869ZZhMZxb2AzRGbz1pA/jdFatXA=="],
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.69.0", "", { "os": "win32", "cpu": "x64" }, "sha512-w8SOXv3mT9Fi6jY8OXdXCfnvX/3KNLXGNr4HEz2TA7S4Mv/PYAOmpB8y/ge40mxvBMgGNaSaaDwZpAsQn7HtWA=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="],
"ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="],
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
"cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
"lint-staged": ["lint-staged@17.0.7", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA=="],
"listr2": ["listr2@10.2.1", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q=="],
"log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"oxfmt": ["oxfmt@0.54.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.54.0", "@oxfmt/binding-android-arm64": "0.54.0", "@oxfmt/binding-darwin-arm64": "0.54.0", "@oxfmt/binding-darwin-x64": "0.54.0", "@oxfmt/binding-freebsd-x64": "0.54.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.54.0", "@oxfmt/binding-linux-arm-musleabihf": "0.54.0", "@oxfmt/binding-linux-arm64-gnu": "0.54.0", "@oxfmt/binding-linux-arm64-musl": "0.54.0", "@oxfmt/binding-linux-ppc64-gnu": "0.54.0", "@oxfmt/binding-linux-riscv64-gnu": "0.54.0", "@oxfmt/binding-linux-riscv64-musl": "0.54.0", "@oxfmt/binding-linux-s390x-gnu": "0.54.0", "@oxfmt/binding-linux-x64-gnu": "0.54.0", "@oxfmt/binding-linux-x64-musl": "0.54.0", "@oxfmt/binding-openharmony-arm64": "0.54.0", "@oxfmt/binding-win32-arm64-msvc": "0.54.0", "@oxfmt/binding-win32-ia32-msvc": "0.54.0", "@oxfmt/binding-win32-x64-msvc": "0.54.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-DjnMwn7smSLF+Mc2+pRItnuPftm/dkUFpY/d4+33y9TfKrsHZo8GLhmUg9BrOIUEy94Rlom1Q11N6vuhE+e0oQ=="],
"oxlint": ["oxlint@1.69.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.69.0", "@oxlint/binding-android-arm64": "1.69.0", "@oxlint/binding-darwin-arm64": "1.69.0", "@oxlint/binding-darwin-x64": "1.69.0", "@oxlint/binding-freebsd-x64": "1.69.0", "@oxlint/binding-linux-arm-gnueabihf": "1.69.0", "@oxlint/binding-linux-arm-musleabihf": "1.69.0", "@oxlint/binding-linux-arm64-gnu": "1.69.0", "@oxlint/binding-linux-arm64-musl": "1.69.0", "@oxlint/binding-linux-ppc64-gnu": "1.69.0", "@oxlint/binding-linux-riscv64-gnu": "1.69.0", "@oxlint/binding-linux-riscv64-musl": "1.69.0", "@oxlint/binding-linux-s390x-gnu": "1.69.0", "@oxlint/binding-linux-x64-gnu": "1.69.0", "@oxlint/binding-linux-x64-musl": "1.69.0", "@oxlint/binding-openharmony-arm64": "1.69.0", "@oxlint/binding-win32-arm64-msvc": "1.69.0", "@oxlint/binding-win32-ia32-msvc": "1.69.0", "@oxlint/binding-win32-x64-msvc": "1.69.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-ypZkK/aDc5NQV8zIR6s2H2Tl3aNW8FmJ1m9+2qsaYuRenl8vgnHNCGwTHviWJdUQzglOlHFchgopdtGhSy17Rw=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="],
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
"string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="],
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
"log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
}
}

View File

@@ -1 +0,0 @@
export {};

View File

@@ -1,6 +1,17 @@
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
"superpowers@git+https://github.com/obra/superpowers.git"
]
"plugin": ["superpowers@git+https://github.com/obra/superpowers.git"],
"permission": {
"bash": {
"git add -f *": "deny",
"git add --force *": "deny",
"git push * --force *": "deny",
"git push * -f *": "deny",
"git push --force-with-lease *": "deny",
"git reset --hard *": "deny",
"git clean -f *": "deny",
"git checkout -- *": "deny",
"git restore --staged *": "deny"
}
}
}

View File

@@ -1,20 +1,45 @@
{
"name": "rune",
"version": "0.1.0",
"module": "src/cli.ts",
"type": "module",
"name": "@lanyuanxiaoyao/rune",
"version": "0.1.3",
"bin": {
"rune": "./src/cli.ts"
},
"private": true,
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
"files": [
"src",
"README.md"
],
"type": "module",
"module": "src/cli.ts",
"scripts": {
"prepare": "husky",
"test": "bun test",
"lint": "oxlint",
"format": "oxfmt .",
"format:check": "oxfmt --check .",
"check": "oxlint && oxfmt --check .",
"release": "bun run scripts/release.ts"
},
"dependencies": {
"cac": "^7.0.0",
"yaml": "^2.7.0"
},
"devDependencies": {
"@types/bun": "latest",
"husky": "^9.1.7",
"lint-staged": "^17.0.7",
"oxfmt": "^0.54.0",
"oxlint": "^1.69.0"
},
"peerDependencies": {
"typescript": "^5"
},
"lint-staged": {
"*.{ts,js,mjs,cjs}": [
"oxlint",
"oxfmt"
],
"*.{json,md,yaml,yml}": [
"oxfmt"
]
}
}

211
scripts/release.ts Normal file
View File

@@ -0,0 +1,211 @@
import { readFileSync, writeFileSync } from "node:fs";
import { createInterface } from "node:readline";
import { join } from "node:path";
export interface Semver {
major: number;
minor: number;
patch: number;
}
export type BumpType = "major" | "minor" | "patch";
export function parseSemver(version: string): Semver {
const parts = version.split(".");
if (parts.length !== 3) {
throw new Error(`无效的版本号格式: ${version}`);
}
const [major, minor, patch] = parts.map((p) => {
const n = Number(p);
if (Number.isNaN(n) || !Number.isInteger(n) || n < 0) {
throw new Error(`无效的版本号格式: ${version}`);
}
return n;
});
return { major: major!, minor: minor!, patch: patch! };
}
export function bumpVersion(current: string, type: BumpType): string {
const semver = parseSemver(current);
switch (type) {
case "major":
return `${semver.major + 1}.0.0`;
case "minor":
return `${semver.major}.${semver.minor + 1}.0`;
case "patch":
return `${semver.major}.${semver.minor}.${semver.patch + 1}`;
}
}
async function ask(query: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(query, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
async function selectBumpType(): Promise<BumpType> {
console.log("选择版本递增类型:");
console.log(" 1) major - 不兼容的 API 变更");
console.log(" 2) minor - 向下兼容的功能新增");
console.log(" 3) patch - 向下兼容的问题修正");
while (true) {
const answer = await ask("请输入 1/2/3 [3]: ");
const choice = answer || "3";
if (choice === "1") return "major";
if (choice === "2") return "minor";
if (choice === "3") return "patch";
console.log("无效选择,请输入 1、2 或 3");
}
}
async function stepBumpVersion(): Promise<string> {
const pkgPath = join(import.meta.dir, "..", "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version: string };
if (typeof pkg.version !== "string" || pkg.version.length === 0) {
throw new Error("package.json 中缺少有效的 version 字段");
}
const currentVersion = pkg.version;
const bumpType = await selectBumpType();
const newVersion = bumpVersion(currentVersion, bumpType);
const answer = await ask(`确认版本号 ${currentVersion}${newVersion}? [y/N]: `);
if (answer.toLowerCase() !== "y") {
console.log("已取消");
process.exit(0);
}
return newVersion;
}
async function runTests(): Promise<void> {
console.log("\n运行测试...");
const proc = Bun.spawn(["bun", "test"], {
stdio: ["inherit", "inherit", "inherit"],
});
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`测试失败 (exit code: ${exitCode}),已跳过 git 和发布步骤`);
}
}
async function checkWorkingTree(): Promise<void> {
const proc = Bun.spawn(["git", "status", "--porcelain"], {
stdio: ["inherit", "pipe", "inherit"],
});
const output = await new Response(proc.stdout).text();
const lines = output.trim().split("\n").filter(Boolean);
if (lines.length > 0) {
throw new Error("工作区有未提交变更,请先提交或清理后再运行 release");
}
}
async function stepGitCommitTag(version: string): Promise<void> {
console.log("\n准备提交:");
console.log(` git add package.json`);
console.log(` git commit -m "chore: release v${version}"`);
console.log(` git tag v${version}`);
const answer = await ask("确认执行以上 git 操作? [y/N]: ");
if (answer.toLowerCase() !== "y") {
console.log("已取消 git 操作");
process.exit(0);
}
// git add package.json
const addProc = Bun.spawn(["git", "add", "package.json"], {
stdio: ["inherit", "inherit", "inherit"],
});
const addExit = await addProc.exited;
if (addExit !== 0) {
throw new Error("git add 失败");
}
// git commit
const commitProc = Bun.spawn(["git", "commit", "-m", `chore: release v${version}`], {
stdio: ["inherit", "inherit", "inherit"],
});
const commitExit = await commitProc.exited;
if (commitExit !== 0) {
throw new Error("git commit 失败");
}
// git tag
const tagProc = Bun.spawn(
["git", "tag", "-a", `v${version}`, "-m", `chore: release v${version}`],
{
stdio: ["inherit", "inherit", "inherit"],
},
);
const tagExit = await tagProc.exited;
if (tagExit !== 0) {
throw new Error("git tag 失败");
}
console.log(`git commit 和 tag v${version} 已完成`);
}
async function stepNpmPublish(): Promise<void> {
console.log("\nnpm 发布预览:");
const dryRunProc = Bun.spawn(["bun", "publish", "--dry-run"], {
stdio: ["inherit", "inherit", "inherit"],
});
const dryRunExit = await dryRunProc.exited;
if (dryRunExit !== 0) {
throw new Error("npm publish --dry-run 失败");
}
const answer = await ask("确认发布到 npm? [y/N]: ");
if (answer.toLowerCase() !== "y") {
console.log("已取消发布");
process.exit(0);
}
const proc = Bun.spawn(["bun", "publish", "--access", "public"], {
stdio: ["inherit", "inherit", "inherit"],
});
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`npm publish 失败 (exit code: ${exitCode}),请检查 npm 登录状态 (npm whoami)`);
}
console.log("npm 发布成功");
}
function writeVersion(version: string): void {
const pkgPath = join(import.meta.dir, "..", "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version: string };
pkg.version = version;
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
}
async function main(): Promise<void> {
await checkWorkingTree();
const newVersion = await stepBumpVersion();
console.log(`目标版本: ${newVersion}`);
await runTests();
console.log("[1/3] 测试通过");
writeVersion(newVersion);
console.log(`[2/3] 版本号已更新: ${newVersion}`);
await stepGitCommitTag(newVersion);
console.log(`[3/3] git commit 和 tag v${newVersion} 完成`);
await stepNpmPublish();
console.log("npm 发布完成");
}
if (import.meta.main) {
main().catch((err: unknown) => {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
});
}

View File

@@ -2,36 +2,82 @@ import { existsSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { STAGES } from "../types.ts";
import { writeIfChanged } from "./utils.ts";
const COMMANDS_DIR = ".claude/commands";
export async function injectClaudeCode(projectRoot: string): Promise<void> {
const STAGES_WITH_CHANGE_NAME = new Set(["create", "plan", "build", "archive"]);
function buildSmartGuide(command: string): string {
return `如果用户没有指定变更名称,请按以下步骤智能识别:
1. 运行 \`${command} status\` 查看当前所有变更
2. 如果只有一个变更,直接使用该变更名
3. 如果有多个变更,根据上下文推断最可能的变更
4. 如果无法确定,向用户确认`;
}
export async function injectClaudeCode(
projectRoot: string,
command: string = "rune",
): Promise<void> {
for (const stage of STAGES) {
const hasChangeName = stage !== "discuss";
const hasChangeName = STAGES_WITH_CHANGE_NAME.has(stage);
const cmd = hasChangeName ? `${command} ${stage} <变更名>` : `${command} ${stage}`;
const smartGuide = hasChangeName ? `\n${buildSmartGuide(command)}\n` : "";
const commandDir = join(projectRoot, COMMANDS_DIR);
await mkdir(commandDir, { recursive: true });
const commandPath = join(commandDir, `rune-${stage}.md`);
if (!existsSync(commandPath)) {
const cmd = hasChangeName
? `rune ${stage} <变更名>`
: `rune ${stage}`;
const nameHint = hasChangeName
? "\n如果用户没有指定变更名称请向用户确认。"
: "";
await writeFile(
commandPath,
`执行以下命令,将输出作为当前阶段的工作指引:\n\`\`\`bash\n${cmd}\n\`\`\`${nameHint}\n`,
`执行以下命令,将输出作为当前阶段的工作指引:\n\`\`\`bash\n${cmd}\n\`\`\`${smartGuide}\n`,
);
}
}
const commandDir = join(projectRoot, COMMANDS_DIR);
const statusPath = join(commandDir, "rune-status.md");
if (!existsSync(statusPath)) {
await writeFile(
statusPath,
`执行以下命令查看变更状态:\n\`\`\`bash\nrune status\n\`\`\`\n`,
);
const introCommandPath = join(projectRoot, COMMANDS_DIR, "rune-intro.md");
if (!existsSync(introCommandPath)) {
await mkdir(join(projectRoot, COMMANDS_DIR), { recursive: true });
await writeFile(introCommandPath, generateIntroCommand(command));
}
}
export async function updateClaudeCode(
projectRoot: string,
command: string = "rune",
): Promise<void> {
for (const stage of STAGES) {
const hasChangeName = STAGES_WITH_CHANGE_NAME.has(stage);
const cmd = hasChangeName ? `${command} ${stage} <变更名>` : `${command} ${stage}`;
const smartGuide = hasChangeName ? `\n${buildSmartGuide(command)}\n` : "";
const commandDir = join(projectRoot, COMMANDS_DIR);
await mkdir(commandDir, { recursive: true });
const commandPath = join(commandDir, `rune-${stage}.md`);
const newContent = `执行以下命令,将输出作为当前阶段的工作指引:\n\`\`\`bash\n${cmd}\n\`\`\`${smartGuide}\n`;
await writeIfChanged(commandPath, newContent);
}
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 → create → plan → build → archive
可用命令:
- /rune-discuss — 自由讨论需求和方案
- /rune-create — 创建变更目录
- /rune-plan — 生成设计文档和任务清单
- /rune-build — 按任务清单逐步实现
- /rune-archive — 归档已完成的变更
查看当前状态:
\`\`\`bash
${command} status
\`\`\`
`;
}

View File

@@ -2,66 +2,89 @@ import { existsSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { STAGES } from "../types.ts";
import { writeIfChanged } from "./utils.ts";
const COMMANDS_DIR = ".opencode/commands";
const SKILLS_DIR = ".opencode/skills";
export async function injectOpenCode(projectRoot: string): Promise<void> {
for (const stage of STAGES) {
const hasChangeName = stage !== "discuss";
const STAGES_WITH_CHANGE_NAME = new Set(["create", "plan", "build", "archive"]);
export async function injectOpenCode(projectRoot: string, command: string = "rune"): Promise<void> {
for (const stage of STAGES) {
const commandDir = join(projectRoot, COMMANDS_DIR);
await mkdir(commandDir, { recursive: true });
const commandPath = join(commandDir, `rune-${stage}.md`);
if (!existsSync(commandPath)) {
await writeFile(commandPath, generateCommand(stage, hasChangeName));
await writeFile(commandPath, generateCommand(stage));
}
const skillStageDir = join(projectRoot, SKILLS_DIR, `rune-${stage}`);
await mkdir(skillStageDir, { recursive: true });
const skillPath = join(skillStageDir, "SKILL.md");
if (!existsSync(skillPath)) {
await writeFile(skillPath, generateSkill(stage, hasChangeName));
await writeFile(skillPath, generateSkill(stage, command));
}
}
const commandDir = join(projectRoot, COMMANDS_DIR);
const statusCommandPath = join(commandDir, "rune-status.md");
if (!existsSync(statusCommandPath)) {
await writeFile(statusCommandPath, generateStatusCommand());
}
const statusSkillDir = join(projectRoot, SKILLS_DIR, "rune-status");
await mkdir(statusSkillDir, { recursive: true });
const statusSkillPath = join(statusSkillDir, "SKILL.md");
if (!existsSync(statusSkillPath)) {
await writeFile(statusSkillPath, generateStatusSkill());
const introSkillDir = join(projectRoot, SKILLS_DIR, "rune-intro");
await mkdir(introSkillDir, { recursive: true });
const introSkillPath = join(introSkillDir, "SKILL.md");
if (!existsSync(introSkillPath)) {
await writeFile(introSkillPath, generateIntroSkill(command));
}
}
function generateCommand(stage: string, hasChangeName: boolean): string {
if (hasChangeName) {
return `请调用 rune-${stage} skill 执行 ${stage} 阶段。
export async function updateOpenCode(projectRoot: string, command: string = "rune"): Promise<void> {
for (const stage of STAGES) {
const commandDir = join(projectRoot, COMMANDS_DIR);
await mkdir(commandDir, { recursive: true });
const commandPath = join(commandDir, `rune-${stage}.md`);
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 introSkillDir = join(projectRoot, SKILLS_DIR, "rune-intro");
await mkdir(introSkillDir, { recursive: true });
const introSkillPath = join(introSkillDir, "SKILL.md");
await writeIfChanged(introSkillPath, generateIntroSkill(command));
}
function generateCommand(stage: string): string {
return `请调用 rune-${stage} skill 执行 ${stage} 阶段。
`;
}
function generateSkill(stage: string, hasChangeName: boolean): string {
const cmd = hasChangeName ? `rune ${stage} <变更名>` : `rune ${stage}`;
const nameHint = hasChangeName
? `将 <变更名> 替换为实际的变更名称。\n`
: "";
function generateSkill(stage: string, command: string): string {
const hasChangeName = STAGES_WITH_CHANGE_NAME.has(stage);
const cmd = hasChangeName ? `${command} ${stage} <变更名>` : `${command} ${stage}`;
let extraGuide = "";
if (stage === "plan") {
extraGuide = `\n规划阶段应先运行 \`${command} status <变更名>\` 获取当前有哪些文档需要编写,再按依赖顺序逐个生成。\n`;
}
const descriptionMap: Record<string, string> = {
discuss: "Use when 需要进入 SDD 讨论阶段,自由讨论需求和架构方案",
create: "Use when 需要创建变更目录,为 SDD 流程准备变更工作区",
plan: "Use when 需要进入 SDD 规划阶段,生成设计文档和任务清单",
build: "Use when 需要进入 SDD 构建阶段,按任务清单逐步实现变更",
archive: "Use when 需要进入 SDD 归档阶段,确认变更完成并归档",
};
let smartGuide = "";
if (hasChangeName) {
smartGuide = `如果用户没有指定变更名称,请按以下步骤智能识别:
1. 运行 \`${command} status\` 查看当前所有变更
2. 如果只有一个变更,直接使用该变更名
3. 如果有多个变更,根据上下文推断最可能的变更
4. 如果无法确定,向用户确认
`;
}
return `---
name: rune-${stage}
description: ${descriptionMap[stage] ?? `Use when 需要执行 SDD ${stage} 阶段`}
@@ -75,29 +98,49 @@ description: ${descriptionMap[stage] ?? `Use when 需要执行 SDD ${stage} 阶
${cmd}
\`\`\`
${nameHint}将命令输出作为工作指引,执行当前阶段的工作。
${smartGuide}${extraGuide}将命令输出作为工作指引,执行当前阶段的工作。
`;
}
function generateStatusCommand(): string {
return `请调用 rune-status skill 查看当前所有变更状态。
`;
}
function generateStatusSkill(): string {
function generateIntroSkill(command: string): string {
return `---
name: rune-status
description: Use when 需要查看当前所有 Rune 变更的状态和任务进度
name: rune-intro
description: Use when 用户首次接触 Rune需要了解 SDD 工作流程和使用方式
---
# 状态查看
# Rune 简介
执行以下命令:
Rune 是基于规格驱动开发SDD的 AI 开发辅助工具。它通过结构化的阶段流程,帮助 AI 编辑器和开发者高效协作。
\`\`\`bash
rune status
## SDD 工作流程
\`\`\`
discuss → create → plan → build → archive
探索 创建 规划 构建 归档
\`\`\`
将命令输出展示给用户。
## 可用命令
| 阶段 | 编辑器命令 | 说明 |
|------|-----------|------|
| discuss | /rune-discuss | 自由讨论需求和方案 |
| create | /rune-create | 创建变更目录 |
| plan | /rune-plan | 生成设计文档和任务清单 |
| build | /rune-build | 按任务清单逐步实现 |
| archive | /rune-archive | 归档已完成的变更 |
查看当前状态:
\`\`\`bash
${command} status
\`\`\`
## 快速开始
1. 使用 /rune-discuss 进入讨论,自由探索需求
2. 使用 /rune-create 创建变更目录
3. 使用 /rune-plan 生成设计文档和任务清单
4. 使用 /rune-build 按任务顺序实现功能
5. 使用 /rune-archive 归档已完成的变更
`;
}

13
src/adapters/utils.ts Normal file
View 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);
}

View File

@@ -7,6 +7,7 @@ import { runInit } from "./commands/init.ts";
import { findProjectRoot, loadConfig, getChangeDir, getArchiveDir } from "./core/config.ts";
import {
assembleDiscussPrompt,
assembleCreatePrompt,
assemblePlanPrompt,
assembleBuildPrompt,
assembleArchivePrompt,
@@ -15,32 +16,118 @@ import { scanChanges } from "./core/scanner.ts";
import { UsageError, ConfigError, CommandError, InternalError, CliError } from "./cli/errors.ts";
import { printError } from "./cli/output.ts";
import { showGlobalHelp, showCommandHelp } from "./cli/help.ts";
import { getPmPrefix, DEFAULT_PREFIX, detectCommandPrefix } from "./core/pm.ts";
import type { ChangeStatus, RuneConfig } from "./types.ts";
function requireProjectRoot(): string {
const root = findProjectRoot();
if (!root) {
throw new ConfigError("当前项目未初始化", { hint: "请先运行 rune init" });
const prefix = getPmPrefix();
throw new ConfigError("当前项目未初始化", { hint: `请先运行 ${prefix} init` });
}
return root;
}
export function validateChangeName(name: string): void {
if (!/^[\u4e00-\u9fa5a-zA-Z-]+$/.test(name)) {
throw new CommandError(`变更名 "${name}" 包含不支持的字符`, {
hint: "变更名仅支持中文、英文和短横线(-",
});
}
}
export function formatChangeStatus(change: ChangeStatus, config?: RuneConfig): string {
const lines: string[] = [];
lines.push(`变更:${change.name}`);
lines.push(" 规划阶段:");
const planDocs = config?.stages.plan?.documents;
for (const doc of change.documents) {
if (doc.completed) {
lines.push(` ${doc.name}.md ✓ 已完成`);
} else {
const docConfig = planDocs?.find((d) => d.name === doc.name);
const depInfo =
!doc.dependMet && docConfig?.depend?.length
? `(依赖 ${docConfig.depend.map((d) => `${d}.md`).join("、")}`
: "";
lines.push(` ${doc.name}.md ○ 待完成${depInfo}`);
}
}
const completedCount = change.documents.filter((d) => d.completed).length;
lines.push(` 规划进度:${completedCount}/${change.documents.length} 文档已完成`);
if (change.buildUnlocked) {
lines.push(" 构建阶段:已解锁");
} else {
lines.push(" 构建阶段:未解锁(需完成规划)");
}
if (change.taskProgress) {
lines.push(` 任务进度:${change.taskProgress.completed}/${change.taskProgress.total} 已完成`);
}
lines.push("");
lines.push(` 建议下一步:${suggestNextStep(change, config)}`);
return lines.join("\n");
}
export function suggestNextStep(change: ChangeStatus, config?: RuneConfig): string {
const prefix = getPmPrefix(config);
if (!change.planCompleted) {
const nextDoc = change.documents.find((d) => !d.completed && d.dependMet);
if (nextDoc) {
return `${prefix} plan ${change.name} ${nextDoc.name}`;
}
return `完成前置依赖后再规划文档`;
}
if (change.taskProgress && change.taskProgress.completed < change.taskProgress.total) {
return `${prefix} build ${change.name}`;
}
if (change.taskProgress && change.taskProgress.completed === change.taskProgress.total) {
return `${prefix} archive ${change.name}`;
}
return `${prefix} build ${change.name}`;
}
const cli = cac("rune");
cli.command("", "").action(() => {
console.log(showGlobalHelp());
cli.command("", "").action(async () => {
const root = findProjectRoot();
let prefix = getPmPrefix();
if (root) {
try {
const config = await loadConfig(root);
prefix = getPmPrefix(config);
} catch {}
}
console.log(showGlobalHelp(prefix));
});
cli.command("help [command]", "显示帮助信息").action((command?: string) => {
cli.command("help [command]", "显示帮助信息").action(async (command?: string) => {
const root = findProjectRoot();
let prefix = getPmPrefix();
if (root) {
try {
const config = await loadConfig(root);
prefix = getPmPrefix(config);
} catch {}
}
if (command) {
const output = showCommandHelp(command);
const output = showCommandHelp(command, prefix);
if (!output) {
throw new UsageError(`未知命令: ${command}`, {
hint: "运行 rune help 查看所有命令",
hint: `运行 ${prefix} help 查看所有命令`,
});
}
console.log(output);
} else {
console.log(showGlobalHelp());
console.log(showGlobalHelp(prefix));
}
});
@@ -49,18 +136,67 @@ cli.command("version", "显示版本号").action(() => {
console.log(`rune v${pkg.version}`);
});
cli.command("init [...tools]", "初始化 Rune 并注入工具配置").action(
async (tools: string[]) => {
if (!tools || tools.length === 0) {
throw new UsageError("请指定至少一个工具", {
usage: "rune init <工具...>",
hint: "如rune init opencode",
cli.command("init [...tools]", "初始化 Rune 并注入工具配置").action(async (tools: string[]) => {
const prefix = getPmPrefix();
if (!tools || tools.length === 0) {
throw new UsageError("请指定至少一个工具", {
usage: `${prefix} init <工具...>`,
hint: `如:${prefix} init opencode`,
});
}
await runInit(process.cwd(), tools);
console.log(`Rune 初始化完成,已注入工具:${tools.join(", ")}`);
});
cli.command("update [...tools]", "更新已注入的工具配置").action(async (tools: string[]) => {
const prefix = getPmPrefix();
if (!tools || tools.length === 0) {
throw new UsageError("请指定至少一个工具", {
usage: `${prefix} update <工具...>`,
hint: `如:${prefix} update opencode`,
});
}
const root = requireProjectRoot();
const { updateOpenCode } = await import("./adapters/opencode.ts");
const { updateClaudeCode } = await import("./adapters/claude-code.ts");
const { SUPPORTED_TOOLS } = await import("./commands/init.ts");
const updaters: Record<string, (root: string, command?: string) => Promise<void>> = {
opencode: updateOpenCode,
"claude-code": updateClaudeCode,
};
for (const tool of tools) {
if (!SUPPORTED_TOOLS[tool]) {
throw new CommandError(`不支持的工具: ${tool}`, {
hint: `支持的工具: ${Object.keys(SUPPORTED_TOOLS).join(", ")}`,
});
}
await runInit(process.cwd(), tools);
console.log(`Rune 初始化完成,已注入工具:${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 { readFile, appendFile } = await import("node:fs/promises");
const yaml = await import("yaml");
const { join: joinPath } = await import("node:path");
const configPath = joinPath(root, ".rune", "config.yaml");
try {
const content = await readFile(configPath, "utf-8");
const parsed = yaml.parse(content) as { metadata?: { command?: string } } | null;
if (!parsed?.metadata?.command) {
await appendFile(configPath, `\nmetadata:\n command: "${detected}"\n`);
}
} catch {}
}
}
for (const tool of tools) {
await updaters[tool](root, command === DEFAULT_PREFIX ? undefined : command);
}
console.log(`工具配置已更新:${tools.join(", ")}`);
});
cli.command("discuss", "讨论阶段").action(async () => {
const root = requireProjectRoot();
@@ -69,96 +205,166 @@ cli.command("discuss", "讨论阶段").action(async () => {
console.log(prompt);
});
cli.command("plan <change-name>", "规划阶段").action(
async (changeName: string) => {
const root = requireProjectRoot();
await mkdir(getChangeDir(root, changeName), { recursive: true });
const config = await loadConfig(root);
const prompt = await assemblePlanPrompt(config, root, changeName);
console.log(prompt);
},
);
cli.command("build <change-name>", "构建阶段").action(
async (changeName: string) => {
const root = requireProjectRoot();
const changeDir = getChangeDir(root, changeName);
if (!existsSync(changeDir)) {
throw new CommandError(`变更 '${changeName}' 不存在`, {
hint: `请先运行 rune plan ${changeName} 创建变更`,
});
}
const config = await loadConfig(root);
const prompt = await assembleBuildPrompt(config, root, changeName);
console.log(prompt);
},
);
cli.command("archive <change-name>", "归档阶段").action(
async (changeName: string) => {
const root = requireProjectRoot();
const changeDir = getChangeDir(root, changeName);
if (!existsSync(changeDir)) {
throw new CommandError(`变更 '${changeName}' 不存在`, {
hint: `请先运行 rune plan ${changeName} 创建变更`,
});
}
const config = await loadConfig(root);
const prompt = assembleArchivePrompt(config, changeName);
const today = new Date().toISOString().slice(0, 10);
const src = changeDir;
const dest = join(getArchiveDir(root), `${today}-${changeName}`);
await rename(src, dest);
console.log(prompt);
},
);
cli.command("status", "查看变更状态").action(async () => {
cli.command("create <change-name>", "创建变更").action(async (changeName: string) => {
validateChangeName(changeName);
const root = requireProjectRoot();
const changes = await scanChanges(root);
if (changes.length === 0) {
console.log("当前无进行中的变更。");
return;
const config = await loadConfig(root);
const changeDir = getChangeDir(root, changeName);
if (existsSync(changeDir)) {
throw new CommandError(`变更 "${changeName}" 已存在`, {
hint: `请使用其他名称,或运行 ${getPmPrefix(config)} status 查看现有变更`,
});
}
for (const change of changes) {
const progress = change.taskProgress
? ` (${change.taskProgress.completed}/${change.taskProgress.total} 任务)`
: "";
console.log(`- ${change.name}${progress} [${change.documents.join(", ")}]`);
await mkdir(changeDir, { recursive: true });
const prompt = assembleCreatePrompt(config);
console.log(prompt);
});
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}" 不在配置的 plan.documents 中`, {
hint: `可用文档:${planDocs?.map((d) => d.name).join(", ") ?? "无"}`,
});
}
const changeDir = getChangeDir(root, changeName);
if (!existsSync(changeDir)) {
const prefix = getPmPrefix(config);
throw new CommandError(`变更 '${changeName}' 不存在`, {
hint: `请先运行 ${prefix} create ${changeName} 创建变更`,
});
}
const doc = planDocs.find((d) => d.name === documentName)!;
if (doc.depend && doc.depend.length > 0) {
const missing = doc.depend.filter((dep) => !existsSync(join(changeDir, `${dep}.md`)));
if (missing.length > 0) {
throw new CommandError(
`文档 "${documentName}" 的前置依赖未满足:${missing.map((d) => `${d}.md`).join("、")} 尚未完成`,
{
hint: `请先完成依赖文档:${getPmPrefix(config)} plan ${changeName} ${missing[0]}`,
},
);
}
}
const prompt = await assemblePlanPrompt(config, root, changeName, documentName);
console.log(prompt);
});
cli.command("build <change-name>", "构建阶段").action(async (changeName: string) => {
validateChangeName(changeName);
const root = requireProjectRoot();
const changeDir = getChangeDir(root, changeName);
if (!existsSync(changeDir)) {
const prefix = getPmPrefix();
throw new CommandError(`变更 '${changeName}' 不存在`, {
hint: `请先运行 ${prefix} create ${changeName} 创建变更`,
});
}
const config = await loadConfig(root);
const prompt = await assembleBuildPrompt(config, root, changeName);
console.log(prompt);
});
cli.command("archive <change-name>", "归档阶段").action(async (changeName: string) => {
validateChangeName(changeName);
const root = requireProjectRoot();
const changeDir = getChangeDir(root, changeName);
if (!existsSync(changeDir)) {
const prefix = getPmPrefix();
throw new CommandError(`变更 '${changeName}' 不存在`, {
hint: `请先运行 ${prefix} create ${changeName} 创建变更`,
});
}
const config = await loadConfig(root);
const prompt = await assembleArchivePrompt(config, root, changeName);
const today = new Date().toISOString().slice(0, 10);
const src = changeDir;
const dest = join(getArchiveDir(root), `${today}-${changeName}`);
await rename(src, dest);
console.log(prompt);
});
cli.command("status [change-name]", "查看变更状态").action(async (changeName?: string) => {
const root = requireProjectRoot();
const config = await loadConfig(root);
const prefix = getPmPrefix(config);
const changes = await scanChanges(root, config);
if (changeName) {
const change = changes.find((c) => c.name === changeName);
if (!change) {
throw new CommandError(`变更 "${changeName}" 不存在`, {
hint: `运行 ${prefix} status 查看所有变更`,
});
}
console.log(formatChangeStatus(change, config));
} else {
if (changes.length === 0) {
console.log("当前无进行中的变更。");
return;
}
for (const change of changes) {
console.log(formatChangeStatus(change, config));
console.log("---\n");
}
}
});
function handleError(e: unknown): never {
export function mapError(e: unknown): CliError {
if (e instanceof CliError) {
printError(e);
} else if (e instanceof Error && e.message.includes("Unknown option")) {
return e;
}
if (e instanceof Error) {
const err = mapCacError(e);
if (err) return err;
}
return new InternalError();
}
function mapCacError(e: Error): CliError | null {
const prefix = getPmPrefix();
if (e.message.includes("Unknown option")) {
const match = e.message.match(/Unknown option `([^`]+)`/);
const flag = match ? match[1] : "未知选项";
printError(new UsageError(`未知选项: ${flag}`, {
hint: "运行 rune help 查看所有命令",
}));
} else if (e instanceof Error && e.message.includes("Unknown command")) {
return new UsageError(`未知选项: ${flag}`, {
hint: `运行 ${prefix} help 查看所有命令`,
});
}
if (e.message.includes("Unknown command")) {
const match = e.message.match(/Unknown command `([^`]+)`/);
const cmd = match ? match[1] : "未知命令";
printError(new UsageError(`未知命令: ${cmd}`, {
hint: "运行 rune help 查看所有命令",
}));
} else if (e instanceof Error && e.message.includes("Unused args")) {
return new UsageError(`未知命令: ${cmd}`, {
hint: `运行 ${prefix} help 查看所有命令`,
});
}
if (e.message.includes("Unused args")) {
const match = e.message.match(/Unused args: (.+)/);
const args = match ? match[1] : "未知参数";
printError(new UsageError(`未知命令: ${args.replace(/`/g, "")}`, {
hint: "运行 rune help 查看所有命令",
}));
} else if (e instanceof Error && e.message.includes("missing required args")) {
return new UsageError(`未知命令: ${args.replace(/`/g, "")}`, {
hint: `运行 ${prefix} help 查看所有命令`,
});
}
if (e.message.includes("missing required args")) {
const match = e.message.match(/command `(\w+)/);
const cmd = match ? match[1] : "未知命令";
printError(new UsageError(`命令 '${cmd}' 缺少必填参数`, {
usage: `rune ${cmd} <change-name>`,
hint: `运行 rune help ${cmd} 查看用法`,
}));
} else {
printError(new InternalError());
return new UsageError(`命令 '${cmd}' 缺少必填参数`, {
usage: `${prefix} ${cmd} <change-name>`,
hint: `运行 ${prefix} help ${cmd} 查看用法`,
});
}
return null;
}
function handleError(e: unknown): never {
printError(mapError(e));
}
process.on("unhandledRejection", (e) => {

View File

@@ -2,10 +2,7 @@ export class CliError extends Error {
readonly hint?: string;
readonly usage?: string;
constructor(
message: string,
opts?: { hint?: string; usage?: string },
) {
constructor(message: string, opts?: { hint?: string; usage?: string }) {
super(message);
this.name = this.constructor.name;
this.hint = opts?.hint;

View File

@@ -2,10 +2,10 @@ interface CommandHelpDef {
name: string;
alias: string;
description: string;
usage: string;
usageTemplate: string;
args: { name: string; desc: string }[];
detail: string;
examples: string[];
exampleArgs: string[];
}
const COMMANDS: Record<string, CommandHelpDef> = {
@@ -13,76 +13,87 @@ const COMMANDS: Record<string, CommandHelpDef> = {
name: "init",
alias: "init <工具...>",
description: "初始化 Rune 并注入工具配置",
usage: "rune init <工具...>",
usageTemplate: "init <工具...>",
args: [{ name: "<工具...>", desc: "要注入的 AI 工具,如 opencode、claude-code" }],
detail: "在当前项目中创建 .rune 目录结构,并注入指定 AI 工具的 command 和 skill 配置文件。",
examples: [
"rune init opencode",
"rune init opencode claude-code",
],
exampleArgs: ["init opencode", "init opencode claude-code"],
},
discuss: {
name: "discuss",
alias: "discuss",
description: "讨论:生成讨论阶段提示词",
usage: "rune discuss",
usageTemplate: "discuss",
args: [],
detail: "生成 SDD 流程中讨论阶段的提示词,输出到标准输出。需要先运行 rune init 初始化项目。",
examples: ["rune discuss"],
detail: "生成 SDD 流程中讨论阶段的提示词,输出到标准输出。需要先运行 init 初始化项目。",
exampleArgs: ["discuss"],
},
create: {
name: "create",
alias: "create <变更>",
description: "创建:创建变更目录",
usageTemplate: "create <change-name>",
args: [{ name: "<change-name>", desc: '变更名称,如 "add-login"' }],
detail: "在 .rune/changes/ 下创建以变更名称命名的目录,并生成创建阶段提示词。",
exampleArgs: ["create add-user-auth", "create fix-memory-leak"],
},
plan: {
name: "plan",
alias: "plan <名称>",
description: "规划:生成规划阶段提示词",
usage: "rune plan <change-name>",
args: [{ name: "<change-name>", desc: "变更名称,如 \"add-login\"" }],
detail: "生成规划阶段的提示词。变更目录将创建在 .rune/changes/<change-name>/ 下。",
examples: [
"rune plan add-user-auth",
"rune plan fix-memory-leak",
alias: "plan <变更> <文档>",
description: "规划:生成指定文档的规划提示词",
usageTemplate: "plan <change-name> <document-name>",
args: [
{ name: "<change-name>", desc: '变更名称,如 "add-login"' },
{ name: "<document-name>", desc: '文档名称,如 "design"、"task"' },
],
detail:
"生成规划阶段指定文档的提示词。依赖的前置文档必须已完成。可用文档由配置中的 plan.documents 定义。",
exampleArgs: ["plan add-user-auth design", "plan add-user-auth task"],
},
build: {
name: "build",
alias: "build <名称>",
description: "构建:生成构建阶段提示词",
usage: "rune build <change-name>",
args: [{ name: "<change-name>", desc: "变更名称,如 \"add-login\"" }],
detail: "生成构建阶段的提示词。变更目录需已存在(通过 rune plan 创建)。",
examples: [
"rune build add-user-auth",
"rune build fix-memory-leak",
],
usageTemplate: "build <change-name>",
args: [{ name: "<change-name>", desc: '变更名称,如 "add-login"' }],
detail: "生成构建阶段的提示词。变更目录需已存在(通过 create 创建)。",
exampleArgs: ["build add-user-auth", "build fix-memory-leak"],
},
archive: {
name: "archive",
alias: "archive <名称>",
description: "归档:归档变更并生成提示词",
usage: "rune archive <change-name>",
args: [{ name: "<change-name>", desc: "变更名称,如 \"add-login\"" }],
usageTemplate: "archive <change-name>",
args: [{ name: "<change-name>", desc: '变更名称,如 "add-login"' }],
detail: "将变更目录从 .rune/changes/ 移动到 .rune/archive/,并生成归档阶段提示词。",
examples: [
"rune archive add-user-auth",
"rune archive fix-memory-leak",
],
exampleArgs: ["archive add-user-auth", "archive fix-memory-leak"],
},
update: {
name: "update",
alias: "update <工具...>",
description: "更新:更新已注入的编辑器配置",
usageTemplate: "update <工具...>",
args: [{ name: "<工具...>", desc: "要更新的 AI 工具,如 opencode、claude-code" }],
detail:
"对比已注入的命令和 skill 文件,与内置版本不一致时覆盖,不存在时新建。用于升级 Rune 后同步编辑器配置。",
exampleArgs: ["update opencode", "update opencode claude-code"],
},
status: {
name: "status",
alias: "status",
description: "查看:列出当前进行中的变更",
usage: "rune status",
args: [],
detail: "扫描 .rune/changes/ 目录,列出所有进行中的变更及其任务进度。",
examples: ["rune status"],
alias: "status [变更]",
description: "查看:展示变更状态与下一步建议",
usageTemplate: "status [change-name]",
args: [{ name: "[change-name]", desc: "可选,指定查看的变更名称" }],
detail: "展示各文档完成状态、依赖满足情况、规划进度和下一步建议。不传参数则显示所有变更。",
exampleArgs: ["status", "status add-user-auth"],
},
};
export function showGlobalHelp(): string {
export function showGlobalHelp(prefix: string = "rune"): string {
const lines: string[] = [
"Rune — 基于规格驱动开发SDD的 AI 开发辅助工具",
"",
"用法:",
" rune <命令> [参数]",
` ${prefix} <命令> [参数]`,
"",
"命令:",
];
@@ -98,22 +109,23 @@ export function showGlobalHelp(): string {
lines.push("");
lines.push("示例:");
lines.push(" rune init opencode 初始化并注入 OpenCode 配置");
lines.push(" rune plan add-login 开始规划 \"add-login\" 变更");
lines.push(" rune status 查看当前变更状态");
lines.push(` ${prefix} init opencode 初始化并注入 OpenCode 配置`);
lines.push(` ${prefix} update opencode 更新 OpenCode 配置`);
lines.push(` ${prefix} plan add-login design 规划 "add-login" 的设计文档`);
lines.push(` ${prefix} status 查看当前变更状态`);
return lines.join("\n");
}
export function showCommandHelp(name: string): string | null {
export function showCommandHelp(name: string, prefix: string = "rune"): string | null {
const cmd = COMMANDS[name];
if (!cmd) return null;
const lines: string[] = [
`rune ${cmd.name}${cmd.description}`,
`${prefix} ${cmd.name}${cmd.description}`,
"",
"用法:",
` ${cmd.usage}`,
` ${prefix} ${cmd.usageTemplate}`,
];
if (cmd.args.length > 0) {
@@ -130,8 +142,8 @@ export function showCommandHelp(name: string): string | null {
lines.push("");
lines.push("示例:");
for (const ex of cmd.examples) {
lines.push(` ${ex}`);
for (const ex of cmd.exampleArgs) {
lines.push(` ${prefix} ${ex}`);
}
return lines.join("\n");

View File

@@ -1,18 +1,21 @@
import { existsSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { mkdir, writeFile, readFile, appendFile } from "node:fs/promises";
import { join } from "node:path";
import { CHANGES_DIR, ARCHIVE_DIR, RUNE_DIR, CONFIG_FILE } from "../types.ts";
import { injectOpenCode } from "../adapters/opencode.ts";
import { injectClaudeCode } from "../adapters/claude-code.ts";
import { CommandError } from "../cli/errors.ts";
import { detectCommandPrefix, getPmPrefix } from "../core/pm.ts";
import { parse as parseYaml } from "yaml";
const CONFIG_TEMPLATE = `# Rune 配置文件
#
# 未配置的阶段将使用内置默认配置。
# 阶段顺序discuss -> plan -> build -> archive
# 阶段顺序discuss -> create -> plan -> build -> archive
#
# 可配置阶段:
# discuss - 讨论阶段:自由讨论需求和架构
# discuss - 探索阶段:深度思考、调查代码库、对比方案
# create - 创建阶段:拟定变更名称并创建变更目录
# plan - 规划阶段:生成设计文档和任务清单
# build - 构建阶段:按任务清单逐步实现
# archive - 归档阶段:确认完成并归档变更
@@ -21,7 +24,7 @@ const CONFIG_TEMPLATE = `# Rune 配置文件
# stages:
# discuss:
# prompt: |
# 你是一位资深软件架构师...
# 进入探索模式。深度思考,自由发散。跟随对话走向。
#
# 示例 - 自定义规划阶段的文档模板:
# stages:
@@ -30,22 +33,42 @@ const CONFIG_TEMPLATE = `# Rune 配置文件
# - name: design
# prompt: 生成设计文档
# template: |
# # {{change-name}} 设计文档
# # 设计文档
# - name: task
# prompt: 生成任务清单
# depend: [design]
# template: |
# # {{change-name}} 任务清单
# # 任务清单
#
# metadata 说明:
# command - Rune CLI 执行命令(如 bunx @lanyuanxiaoyao/runeinit 时自动检测
# tracked - 是否启用任务追踪(默认 true开启时 plan.documents 必须包含 task 文档
`;
const SUPPORTED_TOOLS: Record<string, (root: string) => Promise<void>> = {
export const SUPPORTED_TOOLS: Record<string, (root: string, command?: string) => Promise<void>> = {
opencode: injectOpenCode,
"claude-code": injectClaudeCode,
};
export async function runInit(
projectRoot: string,
tools: string[],
): Promise<void> {
async function ensureMetadataCommand(configPath: string, command: string): Promise<void> {
const content = await readFile(configPath, "utf-8");
const parsed = parseYaml(content) as { metadata?: { command?: string } } | null;
if (parsed?.metadata?.command) return;
if (parsed?.metadata) {
const lines = content.split("\n");
const metaIdx = lines.findIndex((l) => l.trim() === "metadata:");
if (metaIdx >= 0) {
lines.splice(metaIdx + 1, 0, ` command: "${command}"`);
await writeFile(configPath, lines.join("\n"), "utf-8");
return;
}
}
await appendFile(configPath, `\nmetadata:\n command: "${command}"\n`);
}
export async function runInit(projectRoot: string, tools: string[]): Promise<void> {
for (const tool of tools) {
if (!SUPPORTED_TOOLS[tool]) {
throw new CommandError(`不支持的工具: ${tool}`, {
@@ -64,7 +87,13 @@ export async function runInit(
await writeFile(configPath, CONFIG_TEMPLATE, "utf-8");
}
const command = await detectCommandPrefix();
if (command) {
await ensureMetadataCommand(configPath, command);
}
const prefix = command ?? getPmPrefix();
for (const tool of tools) {
await SUPPORTED_TOOLS[tool](projectRoot);
await SUPPORTED_TOOLS[tool](projectRoot, prefix === "rune" ? undefined : prefix);
}
}

View File

@@ -2,49 +2,83 @@ import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { RuneConfig } from "../types.ts";
import { CommandError } from "../cli/errors.ts";
import { getChangeDir } from "./config.ts";
import { parseTasks } from "./task-parser.ts";
import { parseTasks, validateTaskFormat } from "./task-parser.ts";
import { applyCommandPrefix, getPmPrefix } from "./pm.ts";
export function assembleDiscussPrompt(config: RuneConfig): string {
const discuss = config.stages.discuss;
if (!discuss) throw new Error("discuss 阶段未配置");
return discuss.prompt;
if (!discuss)
throw new CommandError("讨论阶段未配置", {
hint: "请在 .rune/config.yaml 中配置 stages.discuss",
});
return applyCommandPrefix(discuss.prompt, config);
}
export function assembleCreatePrompt(config: RuneConfig): string {
const create = config.stages.create;
if (!create)
throw new CommandError("创建阶段未配置", {
hint: "请在 .rune/config.yaml 中配置 stages.create",
});
return applyCommandPrefix(create.prompt, config);
}
export async function assemblePlanPrompt(
config: RuneConfig,
projectRoot: string,
changeName: string,
documentName: string,
): Promise<string> {
const plan = config.stages.plan;
if (!plan) throw new Error("plan 阶段未配置");
if (!plan)
throw new CommandError("规划阶段未配置", {
hint: "请在 .rune/config.yaml 中配置 stages.plan",
});
const doc = plan.documents.find((d) => d.name === documentName);
if (!doc) {
throw new CommandError(`文档 "${documentName}" 不在配置的 plan.documents 中`, {
hint: `可用文档:${plan.documents.map((d) => d.name).join(", ")}`,
});
}
const changeDir = getChangeDir(projectRoot, changeName);
const parts: string[] = [];
parts.push(`# 规划阶段:${changeName}\n`);
parts.push("请为当前变更生成以下文档:\n");
parts.push(`# 规划阶段:${changeName}`);
parts.push("");
parts.push(`## 文档:${doc.name}.md`);
parts.push(doc.prompt);
for (const doc of plan.documents) {
parts.push(`## 文档:${doc.name}.md`);
parts.push(doc.prompt);
const docPath = join(changeDir, `${doc.name}.md`);
if (existsSync(docPath)) {
const existing = await readFile(docPath, "utf-8");
parts.push(`\n### 已有内容(请在此基础上修订):\n${existing}`);
}
if (doc.template) {
const rendered = doc.template.replace(/\{\{change-name\}\}/g, changeName);
parts.push(`\n### 格式模板:\n${rendered}`);
}
parts.push("");
const docPath = join(changeDir, `${doc.name}.md`);
if (existsSync(docPath)) {
parts.push(`\n文档已存在请先读取 ${docPath} 查看已有内容,在此基础上修订。`);
}
parts.push(`请将文档写入目录:${changeDir}`);
return parts.join("\n");
if (doc.template) {
const rendered = doc.template.replace(/\{\{change-name\}\}/g, changeName);
parts.push(`\n### 格式模板:\n${rendered}`);
}
if (doc.depend && doc.depend.length > 0) {
parts.push("\n---\n");
parts.push("## 依赖说明\n");
parts.push("本文档依赖以下前置文档:");
for (const dep of doc.depend) {
const depPath = join(changeDir, `${dep}.md`);
const depCompleted = existsSync(depPath);
const status = depCompleted ? "已完成" : "未完成";
parts.push(`- ${dep}.md${status}`);
}
parts.push("\n请先阅读已完成的前置文档确保内容一致。");
}
parts.push(`\n请将文档写入目录${changeDir}`);
return applyCommandPrefix(parts.join("\n"), config);
}
export async function assembleBuildPrompt(
@@ -53,7 +87,15 @@ export async function assembleBuildPrompt(
changeName: string,
): Promise<string> {
const build = config.stages.build;
if (!build) throw new Error("build 阶段未配置");
if (!build) {
throw new CommandError("构建阶段未配置", {
hint: "请在 .rune/config.yaml 中配置 stages.build",
});
}
if (!config.metadata?.tracked) {
return applyCommandPrefix(build.prompt, config);
}
const changeDir = getChangeDir(projectRoot, changeName);
const taskPath = join(changeDir, "task.md");
@@ -62,9 +104,14 @@ export async function assembleBuildPrompt(
try {
taskContent = await readFile(taskPath, "utf-8");
} catch {
throw new Error(`task.md not found in ${changeDir}`);
const prefix = getPmPrefix(config);
throw new CommandError(`变更 "${changeName}" 尚未完成规划task.md 不存在`, {
hint: `请先完成规划阶段:${prefix} plan ${changeName} task 生成任务文档`,
});
}
validateTaskFormat(taskContent);
const tasks = parseTasks(taskContent);
const pendingTasks = tasks.filter((t) => !t.checked);
@@ -75,28 +122,48 @@ export async function assembleBuildPrompt(
const parts: string[] = [];
parts.push(`# 构建阶段:${changeName}\n`);
parts.push(build.prompt);
parts.push(`\n## 任务列表\n`);
parts.push(taskContent);
parts.push(`\n## 待执行任务(共 ${pendingTasks.length} 项)`);
for (const task of pendingTasks) {
parts.push(`- [ ] ${task.text}`);
}
parts.push(
`\n请从第一个待执行任务开始。完成后更新 ${taskPath} 中的 checkbox。`,
);
parts.push(`\n请先读取 ${taskPath} 查看任务列表。`);
parts.push(`当前有 ${pendingTasks.length} 个待执行任务,从第一个未完成的任务开始。`);
parts.push(`完成后更新 ${taskPath} 中的 checkbox。`);
return parts.join("\n");
return applyCommandPrefix(parts.join("\n"), config);
}
export function assembleArchivePrompt(
export async function assembleArchivePrompt(
config: RuneConfig,
projectRoot: string,
changeName: string,
): string {
): Promise<string> {
const archive = config.stages.archive;
if (!archive) throw new Error("archive 阶段未配置");
if (!archive)
throw new CommandError("归档阶段未配置", {
hint: "请在 .rune/config.yaml 中配置 stages.archive",
});
const parts: string[] = [];
parts.push(`# 归档阶段:${changeName}\n`);
if (config.metadata?.tracked) {
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) {
parts.push("## ⚠️ 警告:存在未完成的任务\n");
parts.push(`请先读取 ${taskPath} 检查是否有未完成的任务。`);
parts.push("如有未完成任务,询问用户是否确认在任务未全部完成的情况下归档。");
parts.push("如用户确认,则继续归档;否则中止并返回构建阶段。");
parts.push("");
}
} catch {
// task.md 读取失败时不追加警告
}
}
}
parts.push(archive.prompt);
return parts.join("\n");
return applyCommandPrefix(parts.join("\n"), config);
}

View File

@@ -3,12 +3,11 @@ import { readFile } from "node:fs/promises";
import { join, dirname } from "node:path";
import { parse as parseYaml } from "yaml";
import { defaultConfig } from "../defaults/config.ts";
import { ConfigError } from "../cli/errors.ts";
import type { RuneConfig } from "../types.ts";
import { RUNE_DIR, CONFIG_FILE, CHANGES_DIR, ARCHIVE_DIR } from "../types.ts";
export function findProjectRoot(
startDir: string = process.cwd(),
): string | null {
export function findProjectRoot(startDir: string = process.cwd()): string | null {
let dir = startDir;
while (true) {
if (existsSync(join(dir, RUNE_DIR))) {
@@ -22,18 +21,77 @@ export function findProjectRoot(
export async function loadConfig(projectRoot: string): Promise<RuneConfig> {
const configPath = join(projectRoot, RUNE_DIR, CONFIG_FILE);
let merged: RuneConfig;
try {
const content = await readFile(configPath, "utf-8");
const userConfig = parseYaml(content) as Partial<RuneConfig> | null;
return mergeConfig(userConfig ?? {});
merged = mergeConfig(userConfig ?? {});
} catch {
return mergeConfig({});
merged = mergeConfig({});
}
validateConfig(merged);
return merged;
}
export function validateConfig(config: RuneConfig): void {
const plan = config.stages.plan;
if (!plan) return;
if (config.metadata?.tracked && plan) {
const hasTaskDoc = plan.documents.some((d) => d.name === "task");
if (!hasTaskDoc) {
throw new ConfigError('tracked 开启时 plan.documents 必须包含 name 为 "task" 的文档');
}
}
const docNames = new Set(plan.documents.map((d) => d.name));
for (const doc of plan.documents) {
if (!doc.depend || doc.depend.length === 0) continue;
for (const dep of doc.depend) {
if (dep === doc.name) {
throw new ConfigError(`文档 "${doc.name}" 不能依赖自身`);
}
if (!docNames.has(dep)) {
throw new ConfigError(`文档 "${doc.name}" 依赖 "${dep}" 不存在于 plan.documents 中`);
}
}
}
const visited = new Set<string>();
const path: string[] = [];
function hasCycle(name: string): boolean {
if (path.includes(name)) {
path.push(name);
return true;
}
if (visited.has(name)) return false;
visited.add(name);
path.push(name);
const doc = plan!.documents.find((d) => d.name === name);
if (doc?.depend) {
for (const dep of doc.depend) {
if (hasCycle(dep)) return true;
}
}
path.pop();
return false;
}
for (const doc of plan.documents) {
path.length = 0;
if (hasCycle(doc.name)) {
throw new ConfigError(`文档间存在循环依赖:${path.join(" → ")}`);
}
}
}
function mergeConfig(userConfig: Partial<RuneConfig>): RuneConfig {
const result: RuneConfig = { stages: {} };
const stageKeys = ["discuss", "plan", "build", "archive"] as const;
const stageKeys = ["discuss", "create", "plan", "build", "archive"] as const;
for (const stage of stageKeys) {
if (userConfig.stages?.[stage]) {
@@ -43,6 +101,11 @@ function mergeConfig(userConfig: Partial<RuneConfig>): RuneConfig {
}
}
result.metadata = {
...defaultConfig.metadata,
...userConfig.metadata,
};
return result;
}

58
src/core/pm.ts Normal file
View File

@@ -0,0 +1,58 @@
import type { RuneConfig } from "../types.ts";
export const DEFAULT_PREFIX = "bunx @lanyuanxiaoyao/rune";
export function inferFromEnvironment(
execPath: string,
userAgent: string | undefined,
): string | null {
if (execPath.includes("bun")) return "bunx @lanyuanxiaoyao/rune";
if (userAgent?.includes("pnpm")) return "pnpx @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> {
if (await checkCommandAvailable("rune")) return "rune";
const inferred = inferFromEnvironment(process.execPath, 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;
}

View File

@@ -1,14 +1,16 @@
import { readdir, stat, readFile } from "node:fs/promises";
import { join } from "node:path";
import type { ChangeStatus } from "../types.ts";
import type { ChangeStatus, DocumentStatus, RuneConfig } from "../types.ts";
import { getChangesDir, getArchiveDir } from "./config.ts";
import { parseTasks } from "./task-parser.ts";
export async function scanChanges(
projectRoot: string,
config?: RuneConfig,
): Promise<ChangeStatus[]> {
const changesDir = getChangesDir(projectRoot);
const results: ChangeStatus[] = [];
const planDocs = config?.stages.plan?.documents;
try {
const entries = await readdir(changesDir);
@@ -17,24 +19,52 @@ export async function scanChanges(
const entryStat = await stat(entryPath);
if (!entryStat.isDirectory()) continue;
const docs = await readdir(entryPath);
const documents = docs.filter((d) => d.endsWith(".md"));
const files = await readdir(entryPath);
const mdFiles = new Set(files.filter((f) => f.endsWith(".md")));
let taskProgress: { completed: number; total: number } | null = null;
const taskFile = docs.find((d) => d === "task.md");
if (taskFile) {
const content = await readFile(join(entryPath, taskFile), "utf-8");
const tasks = parseTasks(content);
taskProgress = {
completed: tasks.filter((t) => t.checked).length,
total: tasks.length,
};
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,
}));
}
results.push({ name: entry, documents, taskProgress });
const planCompleted = planDocs ? documents.every((d) => d.completed) : false;
const buildUnlocked = planCompleted;
let taskProgress: { completed: number; total: number } | null = null;
if (config?.metadata?.tracked) {
const taskFile = files.find((d) => d === "task.md");
if (taskFile) {
const content = await readFile(join(entryPath, taskFile), "utf-8");
const tasks = parseTasks(content);
taskProgress = {
completed: tasks.filter((t) => t.checked).length,
total: tasks.length,
};
}
}
results.push({
name: entry,
documents,
planCompleted,
buildUnlocked,
taskProgress,
});
}
} catch {
}
} catch {}
return results;
}

View File

@@ -2,7 +2,7 @@ import type { TaskItem } from "../types.ts";
export function parseTasks(content: string): TaskItem[] {
const tasks: TaskItem[] = [];
const lines = content.split("\n");
const lines = content.split(/\r?\n/);
for (const line of lines) {
const match = line.match(/^[\s]*- \[([ xX])\] (.*)$/);
if (match) {
@@ -14,3 +14,22 @@ export function parseTasks(content: string): TaskItem[] {
}
return tasks;
}
export class TaskFormatError extends Error {
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 项必须有非空描述");
}
}
}

View File

@@ -1,24 +1,255 @@
import type { RuneConfig } from "../types.ts";
export const defaultConfig: RuneConfig = {
metadata: {
tracked: true,
},
stages: {
discuss: {
prompt: `你是一位经验丰富的软件架构师和技术顾问
用户将与你讨论一个软件开发需求。
prompt: `进入探索模式。深度思考,自由发散。跟随对话走向
你的职责是:
1. 深入理解用户的需求背景和目标
2. 提出关键问题澄清模糊之处
3. 分析技术可行性和潜在风险
4. 提供专业的架构建议
**重要:探索模式是用于思考的,不是用于实现的。** 你可以读文件、搜索代码、调查代码库,但绝对不写代码或实现功能。如果用户要求你实现,提醒用户先退出探索模式,使用 /rune-plan <变更名> 进入规划阶段。
请与用户自由讨论,不要急于输出文档。讨论充分后,建议用户使用 /plan <变更名> 进入规划阶段。`,
**这是一套立场,而不是固定工作流。** 没有固定步骤,没有强制序列,没有必须输出。你是帮助用户探索的思考伙伴。
---
## 立场
- **好奇而非指令式** — 提出自然涌现的问题,不按脚本走
- **开放线索而非审问** — 同时呈现多个有趣的方向,让用户跟随共鸣的方向走。不要把用户推进单一的问题路径
- **可视化** — 感到有助于理清思路时,大量使用 ASCII 示意图
- **自适应** — 跟随有趣的线索,新信息出现时及时转向
- **耐心** — 不急于下结论,让问题的形状自然浮现
- **扎根代码** — 在相关时探索真实代码库,不要凭空理论
---
## 你可能做的事
取决于用户带来什么,你可能:
**探索问题空间**
- 提出从用户话语中自然涌现的澄清问题
- 挑战假设
- 重新框定问题
- 寻找类比
**调查代码库**
- 绘制与讨论相关的现有架构
- 找到集成点
- 识别已使用的模式
- 揭示隐藏的复杂度
**对比选项**
- 头脑风暴多种方案
- 构建对比表格
- 勾勒权衡
- 推荐路径(如果被问到)
**可视化**
\`\`\`
┌─────────────────────────────────────────┐
│ 大量使用 ASCII 示意图 │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ 状态 A │────────▶│ 状态 B │ │
│ └────────┘ └────────┘ │
│ │
│ 系统图、状态机、数据流、 │
│ 架构草图、依赖图、对比表格 │
│ │
└─────────────────────────────────────────┘
\`\`\`
**揭示风险和未知**
- 识别可能出错的地方
- 找到理解上的空白
- 建议探索性研究或调查
---
## Rune 感知
你可以使用 Rune 系统。自然地使用它,不要强行。
### 获取上下文
如果需要了解项目当前有哪些变更,可以自行执行:
\`\`\`bash
rune status
\`\`\`
这会告诉你:
- 是否有活跃的变更
- 变更的名称和状态
- 用户可能在做什么
如果存在相关变更,你可以读取变更目录下的 design.md / task.md 作为讨论参考。
### 讨论结果的处理
讨论中获得的洞察和结论**保留在会话上下文中**。不需要写入文件。
当讨论充分后,自然引导用户进入下一阶段:
- "这个想法已经足够清晰了,要开始规划吗?可以用 /rune-plan <变更名> <文档名> 进入规划阶段。"
---
## 你不必做的事
- 遵循固定脚本
- 每次都问相同的问题
- 产出特定文档
- 必须达成结论
- 在切线有价值时非得回到正题
- 保持简洁(这是思考时间)
---
## 结束探索
没有强制的结束方式。探索可能:
- **流入规划阶段**:讨论充分后建议使用 /rune-plan <变更名> <文档名>
- **获得清晰度**:用户得到所需,自行推进
- **稍后继续**"我们随时可以接着聊"
当感觉思路开始结晶时,可以给出总结:
\`\`\`
## 我们梳理清楚的内容
**问题**[已结晶的理解]
**方案**[如果有了方向]
**待澄清**[如果还有]
**下一步**(准备好时):
- 使用 /rune-plan <变更名> <文档名> 进入规划阶段
\`\`\`
但这个总结是可选的。有时候思考本身就是价值。
---
## 护栏
- **不实现** — 绝不写代码或实现功能
- **不假装理解** — 如果有不清楚的地方,深挖
- **不催促** — 探索是思考时间,不是任务时间
- **不强加结构** — 让模式自然浮现
- **不自动写文件** — 讨论结果保留在会话中,除非用户明确要求写入文件
- **要可视化** — 一张好图胜过千言万语
- **要探索代码库** — 让讨论扎根于现实
- **要质疑假设** — 包括用户的和自己的
---
## 典型场景
**用户带来模糊的想法:**
\`\`\`
用户:我在想加个实时协作功能
你:实时协作是个大课题。让我想想……
协作能力光谱
════════════════════════════════════════════
感知 协调 同步
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│ 在线状态│ │ 多人光标│ │ CRDT │
│ "3人在 │ │ 选择 │ │ 无冲突 │
│ 线" │ │ │ │ │
└────────┘ └────────┘ └────────┘
│ │ │
简单 中等 复杂
你心里想的是哪个方向?
\`\`\`
**用户带来具体问题:**
\`\`\`
用户:认证系统太乱了
你:[探索代码库]
┌─────────────────────────────────────────────┐
│ 当前认证流程 │
└─────────────────────────────────────────────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Google │ │ GitHub │ │ 邮箱 │
│ OAuth │ │ OAuth │ │ Magic │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└─────────────┼─────────────┘
┌───────────┐
│ Session │
└─────┬─────┘
┌───────────┐
│ 权限 │
└───────────┘
我看到三个缠结。哪个让你头疼?
\`\`\`
**用户对比选项:**
\`\`\`
用户:该用 MySQL 还是 PostgreSQL
你:泛泛的回答很无聊。具体上下文是什么?
用户:一个 CLI 工具,追踪本地开发环境
你:这改变一切。
┌─────────────────────────────────────────────────┐
│ CLI 工具数据存储 │
└─────────────────────────────────────────────────┘
关键约束:
• 无守护进程
• 必须离线工作
• 单用户
MySQL PostgreSQL
部署 独立服务 ✗ 独立服务 ✗
离线 否 ✗ 否 ✗
单文件 否 ✗ 否 ✗
两个都不合适。SQLite 才是正确答案。
除非……有同步组件?
\`\`\``,
},
create: {
prompt: `请根据讨论内容,拟定一个简短、有意义的变更名称,然后执行 create 命令创建变更目录。
要求:
- 变更名称应简洁明了,能概括变更的核心内容
- 仅支持中文、英文和短横线(-
- 创建成功后,引导用户使用 /rune-plan <变更名> <文档名> 进入规划阶段`,
},
plan: {
documents: [
{
name: "design",
prompt: `根据之前的讨论内容,生成一份设计文档
prompt: `先获取当前规划状态
建议执行: rune status <变更名>
请根据之前的讨论内容和状态输出,生成一份设计文档。
要求:
- 清晰描述背景和目标
@@ -42,7 +273,12 @@ export const defaultConfig: RuneConfig = {
},
{
name: "task",
prompt: `请根据设计文档,生成一份任务列表。
depend: ["design"],
prompt: `请先获取当前规划状态。
建议执行: rune status <变更名>
请根据设计文档,生成一份任务列表。
要求:
- 将设计拆分为可独立执行的小任务

View File

@@ -2,12 +2,23 @@ export interface DocumentConfig {
name: string;
prompt: string;
template?: string;
depend?: string[];
}
export interface DocumentStatus {
name: string;
completed: boolean;
dependMet: boolean;
}
export interface DiscussStage {
prompt: string;
}
export interface CreateStage {
prompt: string;
}
export interface PlanStage {
documents: DocumentConfig[];
}
@@ -22,6 +33,7 @@ export interface ArchiveStage {
export interface StagesConfig {
discuss?: DiscussStage;
create?: CreateStage;
plan?: PlanStage;
build?: BuildStage;
archive?: ArchiveStage;
@@ -29,6 +41,10 @@ export interface StagesConfig {
export interface RuneConfig {
stages: StagesConfig;
metadata?: {
command?: string;
tracked?: boolean;
};
}
export interface TaskItem {
@@ -38,11 +54,13 @@ export interface TaskItem {
export interface ChangeStatus {
name: string;
documents: string[];
documents: DocumentStatus[];
planCompleted: boolean;
buildUnlocked: boolean;
taskProgress: { completed: number; total: number } | null;
}
export const STAGES = ["discuss", "plan", "build", "archive"] as const;
export const STAGES = ["discuss", "create", "plan", "build", "archive"] as const;
export type Stage = (typeof STAGES)[number];
export const RUNE_DIR = ".rune";

View File

@@ -0,0 +1,158 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { existsSync } from "node:fs";
import { mkdir, rm, readFile, readdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { injectClaudeCode, updateClaudeCode } from "../../src/adapters/claude-code.ts";
const TMP_DIR = join(import.meta.dir, "__tmp_claude_code_test__");
beforeEach(async () => {
await mkdir(TMP_DIR, { recursive: true });
});
afterEach(async () => {
await rm(TMP_DIR, { recursive: true, force: true });
});
describe("injectClaudeCode", () => {
it("生成 discuss、create、plan、build、archive 的 command 文件", async () => {
await injectClaudeCode(TMP_DIR);
const commands = await readdir(join(TMP_DIR, ".claude", "commands"));
for (const stage of ["discuss", "create", "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("create/plan/build/archive command 包含变更名智能识别引导", async () => {
await injectClaudeCode(TMP_DIR);
for (const stage of ["create", "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("重复注入时不覆盖已存在的文件", 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");
});
});

View File

@@ -1,8 +1,8 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { existsSync } from "node:fs";
import { mkdir, rm, readFile, readdir } from "node:fs/promises";
import { mkdir, rm, readFile, readdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { injectOpenCode } from "../../src/adapters/opencode.ts";
import { injectOpenCode, updateOpenCode } from "../../src/adapters/opencode.ts";
const TMP_DIR = join(import.meta.dir, "__tmp_opencode_test__");
@@ -15,64 +15,93 @@ afterEach(async () => {
});
describe("injectOpenCode", () => {
it("生成 discuss、plan、build、archive 的 command 和 skill 文件", async () => {
it("生成 discuss、create、plan、build、archive 的 command 和 skill 文件", async () => {
await injectOpenCode(TMP_DIR);
const commands = await readdir(join(TMP_DIR, ".opencode", "commands"));
const skills = await readdir(join(TMP_DIR, ".opencode", "skills"));
for (const stage of ["discuss", "plan", "build", "archive"]) {
for (const stage of ["discuss", "create", "plan", "build", "archive"]) {
expect(commands).toContain(`rune-${stage}.md`);
expect(skills).toContain(`rune-${stage}`);
expect(
existsSync(join(TMP_DIR, ".opencode", "skills", `rune-${stage}`, "SKILL.md")),
).toBe(true);
expect(existsSync(join(TMP_DIR, ".opencode", "skills", `rune-${stage}`, "SKILL.md"))).toBe(
true,
);
}
});
it("生成 rune-status command 和 skill", async () => {
it("生成 rune-status command 和 skill", async () => {
await injectOpenCode(TMP_DIR);
const commands = await readdir(join(TMP_DIR, ".opencode", "commands"));
const skills = await readdir(join(TMP_DIR, ".opencode", "skills"));
expect(commands).toContain("rune-status.md");
expect(skills).toContain("rune-status");
expect(
existsSync(join(TMP_DIR, ".opencode", "skills", "rune-status", "SKILL.md")),
).toBe(true);
expect(commands).not.toContain("rune-status.md");
expect(skills).not.toContain("rune-status");
});
it("command 文件包含 skill 调用指令", async () => {
it("生成 rune-intro skill无对应 command", async () => {
await injectOpenCode(TMP_DIR);
const content = await readFile(
join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"),
"utf-8",
);
expect(content).toContain("rune-discuss");
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("skill 文件包含 bash 命令", async () => {
it("command 文件统一格式:只包含 skill 调用指令", async () => {
await injectOpenCode(TMP_DIR);
for (const stage of ["discuss", "create", "plan", "build", "archive"]) {
const content = await readFile(
join(TMP_DIR, ".opencode", "commands", `rune-${stage}.md`),
"utf-8",
);
expect(content).toContain(`rune-${stage} skill`);
expect(content).not.toContain("变更名");
}
});
it("create/plan/build/archive skill 包含变更名智能识别引导", async () => {
await injectOpenCode(TMP_DIR);
for (const stage of ["create", "plan", "build", "archive"]) {
const content = await readFile(
join(TMP_DIR, ".opencode", "skills", `rune-${stage}`, "SKILL.md"),
"utf-8",
);
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).toContain("rune discuss");
expect(content).toContain("description");
expect(content).toContain("name: rune-discuss");
expect(content).not.toContain("智能识别");
});
it("plan/build/archive skill 包含变更名称参数提示", async () => {
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");
});
for (const stage of ["plan", "build", "archive"]) {
const content = await readFile(
join(TMP_DIR, ".opencode", "skills", `rune-${stage}`, "SKILL.md"),
"utf-8",
);
expect(content).toContain("变更名");
}
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("重复注入时不覆盖已存在的文件", async () => {
@@ -90,3 +119,112 @@ describe("injectOpenCode", () => {
expect(content).toBe(originalContent);
});
});
describe("updateOpenCode", () => {
it("文件不存在时创建", async () => {
await updateOpenCode(TMP_DIR);
expect(existsSync(join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"))).toBe(true);
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-discuss", "SKILL.md"))).toBe(true);
});
it("文件存在且内容一致时不覆盖", async () => {
await injectOpenCode(TMP_DIR);
const originalContent = await readFile(
join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"),
"utf-8",
);
await updateOpenCode(TMP_DIR);
const content = await readFile(
join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"),
"utf-8",
);
expect(content).toBe(originalContent);
});
it("文件存在但内容不一致时覆盖", async () => {
await injectOpenCode(TMP_DIR);
await writeFile(join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"), "旧内容");
await updateOpenCode(TMP_DIR);
const content = await readFile(
join(TMP_DIR, ".opencode", "commands", "rune-discuss.md"),
"utf-8",
);
expect(content).not.toBe("旧内容");
expect(content).toContain("rune-discuss");
});
it("更新时生成 rune-intro skill", async () => {
await updateOpenCode(TMP_DIR);
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-intro", "SKILL.md"))).toBe(true);
});
it("不生成 rune-status", async () => {
await updateOpenCode(TMP_DIR);
expect(existsSync(join(TMP_DIR, ".opencode", "commands", "rune-status.md"))).toBe(false);
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-status", "SKILL.md"))).toBe(false);
});
});
describe("injectOpenCode with command prefix", () => {
it("使用自定义前缀生成 skill 文件", async () => {
const tmpDir = join(import.meta.dir, "__tmp_opencode_prefix_test__");
await mkdir(tmpDir, { recursive: true });
try {
await injectOpenCode(tmpDir, "bunx @lanyuanxiaoyao/rune");
const content = await readFile(
join(tmpDir, ".opencode", "skills", "rune-discuss", "SKILL.md"),
"utf-8",
);
expect(content).toContain("bunx @lanyuanxiaoyao/rune discuss");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
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 rm(tmpDir, { recursive: true, force: true });
}
});
it("create skill 使用自定义前缀", async () => {
const tmpDir = join(import.meta.dir, "__tmp_opencode_create_test__");
await mkdir(tmpDir, { recursive: true });
try {
await injectOpenCode(tmpDir, "pnpx @lanyuanxiaoyao/rune");
const content = await readFile(
join(tmpDir, ".opencode", "skills", "rune-create", "SKILL.md"),
"utf-8",
);
expect(content).toContain("pnpx @lanyuanxiaoyao/rune create");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
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 rm(tmpDir, { recursive: true, force: true });
}
});
});

View File

@@ -2,12 +2,24 @@ import { describe, it, expect } from "bun:test";
import { showGlobalHelp, showCommandHelp } from "../../src/cli/help.ts";
describe("showGlobalHelp", () => {
it("包含所有命令行", () => {
it("默认前缀时包含 rune", () => {
const output = showGlobalHelp();
expect(output).toContain("rune <命令> [参数]");
expect(output).toContain("rune init opencode");
});
it("自定义前缀时使用传入前缀", () => {
const output = showGlobalHelp("bunx @lanyuanxiaoyao/rune");
expect(output).toContain("bunx @lanyuanxiaoyao/rune <命令> [参数]");
expect(output).toContain("bunx @lanyuanxiaoyao/rune init opencode");
});
it("包含所有命令行", () => {
const output = showGlobalHelp("rune");
expect(output).toContain("init <工具...>");
expect(output).toContain("discuss");
expect(output).toContain("plan <名称>");
expect(output).toContain("create <变更>");
expect(output).toContain("plan <变更> <文档>");
expect(output).toContain("build <名称>");
expect(output).toContain("archive <名称>");
expect(output).toContain("status");
@@ -15,36 +27,37 @@ describe("showGlobalHelp", () => {
expect(output).toContain("version");
});
it("包含示例", () => {
const output = showGlobalHelp();
expect(output).toContain("rune init opencode");
expect(output).toContain("rune plan add-login");
expect(output).toContain("rune status");
});
it("以标题行开头", () => {
const output = showGlobalHelp();
const output = showGlobalHelp("rune");
expect(output.startsWith("Rune")).toBe(true);
});
});
describe("showCommandHelp", () => {
it("plan 命令包含用法、参数、描述、示例", () => {
const output = showCommandHelp("plan");
expect(output).toContain("rune plan <change-name>");
const output = showCommandHelp("plan", "bunx @lanyuanxiaoyao/rune");
expect(output).toContain("bunx @lanyuanxiaoyao/rune plan <change-name> <document-name>");
expect(output).toContain("<change-name>");
expect(output).toContain("<document-name>");
expect(output).toContain("规划阶段");
expect(output).toContain("rune plan add-user-auth");
expect(output).toContain("bunx @lanyuanxiaoyao/rune plan add-user-auth");
});
it("init 命令包含工具参数说明", () => {
const output = showCommandHelp("init");
const output = showCommandHelp("init", "rune");
expect(output).toContain("rune init <工具...>");
expect(output).toContain("opencode");
});
it("create 命令包含变更名参数", () => {
const output = showCommandHelp("create", "rune");
expect(output).toContain("rune create <change-name>");
expect(output).toContain("变更名称");
expect(output).toContain("rune create add-user-auth");
});
it("不存在的命令返回 null", () => {
const output = showCommandHelp("nonexistent");
const output = showCommandHelp("nonexistent", "rune");
expect(output).toBeNull();
});
});

View File

@@ -0,0 +1,57 @@
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).toBe("发生了未预期的错误");
});
it("非 Error 类型转为 InternalError", () => {
const result = mapError("字符串错误");
expect(result).toBeInstanceOf(InternalError);
});
});

View File

@@ -1,11 +1,6 @@
import { describe, it, expect } from "bun:test";
import { formatError } from "../../src/cli/output.ts";
import {
UsageError,
ConfigError,
CommandError,
InternalError,
} from "../../src/cli/errors.ts";
import { UsageError, ConfigError, InternalError } from "../../src/cli/errors.ts";
describe("formatError", () => {
it("只输出错误行(无 hint/usage", () => {
@@ -25,15 +20,13 @@ describe("formatError", () => {
usage: "rune plan <change-name>",
});
const output = formatError(err);
expect(output).toBe(
"错误: 缺少参数\n\n用法: rune plan <change-name>",
);
expect(output).toBe("错误: 缺少参数\n\n用法: rune plan <change-name>");
});
it("输出完整格式(错误 + 用法 + 提示)", () => {
const err = new UsageError("缺少必填参数 <change-name>", {
usage: "rune plan <change-name>",
hint: "请指定变更名称,如 \"add-login\"",
hint: '请指定变更名称,如 "add-login"',
});
const output = formatError(err);
expect(output).toBe(

148
tests/cli/status.test.ts Normal file
View File

@@ -0,0 +1,148 @@
import { describe, it, expect } from "bun:test";
import { formatChangeStatus, suggestNextStep } from "../../src/cli.ts";
import type { ChangeStatus, RuneConfig } from "../../src/types.ts";
function makeStatus(overrides: Partial<ChangeStatus> = {}): ChangeStatus {
return {
name: "test-change",
documents: [],
planCompleted: false,
buildUnlocked: false,
taskProgress: null,
...overrides,
};
}
describe("formatChangeStatus", () => {
it("显示变更名", () => {
const output = formatChangeStatus(makeStatus());
expect(output).toContain("test-change");
});
it("显示已完成和待完成文档", () => {
const status = makeStatus({
documents: [
{ name: "design", completed: true, dependMet: true },
{ name: "task", completed: false, dependMet: true },
],
});
const output = formatChangeStatus(status);
expect(output).toContain("design.md ✓ 已完成");
expect(output).toContain("task.md ○ 待完成");
});
it("显示文档依赖信息dependMet 为 false 且 config 中有依赖)", () => {
const status = makeStatus({
documents: [{ name: "task", completed: false, dependMet: false }],
});
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "design", prompt: "生成设计" },
{ name: "task", prompt: "生成任务", depend: ["design"] },
],
},
},
};
const output = formatChangeStatus(status, config);
expect(output).toContain("依赖 design.md");
});
it("dependMet 为 false 但无 config 时不显示文档依赖信息", () => {
const status = makeStatus({
documents: [{ name: "task", completed: false, dependMet: false }],
});
const output = formatChangeStatus(status);
expect(output).not.toContain("(依赖");
});
it("显示规划进度", () => {
const status = makeStatus({
documents: [
{ name: "design", completed: true, dependMet: true },
{ name: "task", completed: false, dependMet: true },
],
});
const output = formatChangeStatus(status);
expect(output).toContain("1/2 文档已完成");
});
it("规划完成时显示构建已解锁", () => {
const status = makeStatus({
documents: [
{ name: "design", completed: true, dependMet: true },
{ name: "task", completed: true, dependMet: true },
],
planCompleted: true,
buildUnlocked: true,
});
const output = formatChangeStatus(status);
expect(output).toContain("已解锁");
});
it("显示任务进度", () => {
const status = makeStatus({
taskProgress: { completed: 3, total: 5 },
});
const output = formatChangeStatus(status);
expect(output).toContain("3/5 已完成");
});
it("包含下一步建议", () => {
const output = formatChangeStatus(makeStatus());
expect(output).toContain("建议下一步");
});
});
describe("suggestNextStep", () => {
it("规划未完成时返回下一个可规划文档", () => {
const status = makeStatus({
documents: [{ name: "design", completed: false, dependMet: true }],
});
expect(suggestNextStep(status)).toContain("rune plan test-change design");
});
it("规划未完成且依赖未满足时提示完成前置依赖", () => {
const status = makeStatus({
documents: [{ name: "design", completed: false, dependMet: false }],
});
expect(suggestNextStep(status)).toBe("完成前置依赖后再规划文档");
});
it("规划完成且有未完成任务时建议 build", () => {
const status = makeStatus({
documents: [
{ name: "design", completed: true, dependMet: true },
{ name: "task", completed: true, dependMet: true },
],
planCompleted: true,
taskProgress: { completed: 2, total: 5 },
});
expect(suggestNextStep(status)).toContain("rune build test-change");
});
it("任务全部完成时建议 archive", () => {
const status = makeStatus({
documents: [
{ name: "design", completed: true, dependMet: true },
{ name: "task", completed: true, dependMet: true },
],
planCompleted: true,
taskProgress: { completed: 5, total: 5 },
});
expect(suggestNextStep(status)).toContain("rune archive test-change");
});
it("规划完成但无 taskProgress 时建议 build", () => {
const status = makeStatus({
documents: [
{ name: "design", completed: true, dependMet: true },
{ name: "task", completed: true, dependMet: true },
],
planCompleted: true,
taskProgress: null,
});
expect(suggestNextStep(status)).toContain("rune build test-change");
});
});

View 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);
});
});

View File

@@ -22,10 +22,7 @@ describe("runInit", () => {
expect(existsSync(join(TMP_DIR, ".rune"))).toBe(true);
expect(existsSync(join(TMP_DIR, ".rune", "config.yaml"))).toBe(true);
const content = await readFile(
join(TMP_DIR, ".rune", "config.yaml"),
"utf-8",
);
const content = await readFile(join(TMP_DIR, ".rune", "config.yaml"), "utf-8");
expect(content).toContain("# Rune 配置文件");
expect(content).toContain("stages:");
});
@@ -44,19 +41,13 @@ describe("runInit", () => {
expect(existsSync(join(TMP_DIR, ".opencode", "skills", "rune-discuss", "SKILL.md"))).toBe(true);
});
it("重复 init 不覆盖 config.yaml", async () => {
it("重复 init 不覆盖 config.yaml 已有内容", async () => {
await runInit(TMP_DIR, ["opencode"]);
await writeFile(
join(TMP_DIR, ".rune", "config.yaml"),
"自定义内容",
);
await writeFile(join(TMP_DIR, ".rune", "config.yaml"), "自定义内容");
await runInit(TMP_DIR, ["opencode"]);
const content = await readFile(
join(TMP_DIR, ".rune", "config.yaml"),
"utf-8",
);
expect(content).toBe("自定义内容");
const content = await readFile(join(TMP_DIR, ".rune", "config.yaml"), "utf-8");
expect(content).toContain("自定义内容");
});
it("不支持的工具名抛出 CommandError", async () => {
@@ -68,4 +59,34 @@ describe("runInit", () => {
expect((e as CommandError).message).toContain("不支持的工具: unknown-tool");
}
});
it("首次 init 时 config.yaml 包含 metadata.command", async () => {
await runInit(TMP_DIR, ["opencode"]);
const content = await readFile(join(TMP_DIR, ".rune", "config.yaml"), "utf-8");
expect(content).toContain("metadata:");
expect(content).toContain("command:");
});
it("config.yaml 模板不含 {{change-name}}", async () => {
await runInit(TMP_DIR, ["opencode"]);
const content = await readFile(join(TMP_DIR, ".rune", "config.yaml"), "utf-8");
expect(content).not.toContain("{{change-name}}");
});
it("config.yaml 模板包含 metadata 说明", async () => {
await runInit(TMP_DIR, ["opencode"]);
const content = await readFile(join(TMP_DIR, ".rune", "config.yaml"), "utf-8");
expect(content).toContain("metadata");
expect(content).toContain("tracked");
});
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");
});
});

View File

@@ -3,12 +3,14 @@ import { mkdir, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import {
assembleDiscussPrompt,
assembleCreatePrompt,
assemblePlanPrompt,
assembleBuildPrompt,
assembleArchivePrompt,
} from "../../src/core/assembler.ts";
import type { RuneConfig } from "../../src/types.ts";
import { defaultConfig } from "../../src/defaults/config.ts";
import { CommandError } from "../../src/cli/errors.ts";
const TMP_DIR = join(import.meta.dir, "__tmp_assembler_test__");
@@ -24,7 +26,9 @@ describe("assembleDiscussPrompt", () => {
it("返回默认 discuss 提示词", () => {
const prompt = assembleDiscussPrompt(defaultConfig);
expect(prompt).toBeTruthy();
expect(prompt).toContain("软件架构师");
expect(prompt).toContain("探索模式");
expect(prompt).toContain("立场");
expect(prompt).toContain("护栏");
});
it("返回自定义 discuss 提示词", () => {
@@ -36,43 +40,99 @@ describe("assembleDiscussPrompt", () => {
});
});
describe("assemblePlanPrompt", () => {
it("包含变更名称和文档指引", async () => {
const prompt = await assemblePlanPrompt(
defaultConfig,
TMP_DIR,
"user-auth",
);
expect(prompt).toContain("user-auth");
expect(prompt).toContain("design");
expect(prompt).toContain("task");
expect(prompt).toContain("格式模板");
describe("assembleCreatePrompt", () => {
it("返回默认 create 提示词", () => {
const prompt = assembleCreatePrompt(defaultConfig);
expect(prompt).toBeTruthy();
expect(prompt).toContain("变更名称");
expect(prompt).toContain("/rune-plan");
});
it("包含已有文档内容(重复 plan 场景)", async () => {
it("返回自定义 create 提示词", () => {
const config: RuneConfig = {
stages: { create: { prompt: "自定义创建" } },
};
const prompt = assembleCreatePrompt(config);
expect(prompt).toBe("自定义创建");
});
it("create 阶段未配置时抛出 CommandError", () => {
const config: RuneConfig = {
stages: { build: { prompt: "构建" } },
};
try {
assembleCreatePrompt(config);
expect.unreachable();
} catch (e) {
expect(e).toBeInstanceOf(CommandError);
}
});
});
describe("assemblePlanPrompt", () => {
it("包含指定文档名称和提示词", async () => {
const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "design");
expect(prompt).toContain("user-auth");
expect(prompt).toContain("design");
expect(prompt).not.toContain("task");
});
it("已有文档时引导 AI 读取而非内嵌内容", async () => {
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "design.md"), "# 已有设计");
const prompt = await assemblePlanPrompt(
defaultConfig,
TMP_DIR,
"user-auth",
);
expect(prompt).toContain("已有设计");
expect(prompt).toContain("在此基础上修订");
const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "design");
expect(prompt).toContain("已有内容");
expect(prompt).toContain("design.md");
expect(prompt).not.toContain("# 已有设计");
});
it("替换模板中的 {{change-name}}", async () => {
const prompt = await assemblePlanPrompt(
defaultConfig,
TMP_DIR,
"user-auth",
);
const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "design");
expect(prompt).toContain("user-auth 设计文档");
expect(prompt).toContain("user-auth 任务列表");
expect(prompt).not.toContain("{{change-name}}");
});
it("包含依赖说明(有依赖时)", async () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "design", prompt: "生成设计" },
{ name: "task", prompt: "生成任务", depend: ["design"] },
],
},
},
};
const prompt = await assemblePlanPrompt(config, TMP_DIR, "user-auth", "task");
expect(prompt).toContain("依赖说明");
expect(prompt).toContain("design.md");
});
it("无依赖时不包含依赖说明", async () => {
const prompt = await assemblePlanPrompt(defaultConfig, TMP_DIR, "user-auth", "design");
expect(prompt).not.toContain("依赖说明");
});
it("依赖说明标注完成状态", async () => {
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "design.md"), "# 设计");
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "design", prompt: "生成设计" },
{ name: "task", prompt: "生成任务", depend: ["design"] },
],
},
},
};
const prompt = await assemblePlanPrompt(config, TMP_DIR, "user-auth", "task");
expect(prompt).toContain("已完成");
});
it("使用自定义 plan 配置", async () => {
const config: RuneConfig = {
stages: {
@@ -87,7 +147,7 @@ describe("assemblePlanPrompt", () => {
},
},
};
const prompt = await assemblePlanPrompt(config, TMP_DIR, "my-feature");
const prompt = await assemblePlanPrompt(config, TMP_DIR, "my-feature", "spec");
expect(prompt).toContain("spec");
expect(prompt).toContain("my-feature 规格");
expect(prompt).not.toContain("design");
@@ -95,50 +155,169 @@ describe("assemblePlanPrompt", () => {
});
describe("assembleBuildPrompt", () => {
it("包含待执行任务列表", async () => {
it("引导 AI 读取 task.md 而非内嵌任务内容", async () => {
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true });
await writeFile(
join(changeDir, "task.md"),
`- [x] 任务一\n- [ ] 任务二\n- [ ] 任务三`,
);
const prompt = await assembleBuildPrompt(
defaultConfig,
TMP_DIR,
"user-auth",
);
expect(prompt).toContain("任务二");
expect(prompt).toContain("待执行任务");
expect(prompt).toContain("共 2 项");
await writeFile(join(changeDir, "task.md"), `- [x] 任务一\n- [ ] 任务二\n- [ ] 任务三`);
const prompt = await assembleBuildPrompt(defaultConfig, TMP_DIR, "user-auth");
expect(prompt).toContain("task.md");
expect(prompt).toContain("2");
expect(prompt).not.toContain("任务二");
expect(prompt).not.toContain("任务三");
});
it("所有任务完成时提示可归档", async () => {
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true });
await writeFile(
join(changeDir, "task.md"),
`- [x] 任务一\n- [x] 任务二`,
);
const prompt = await assembleBuildPrompt(
defaultConfig,
TMP_DIR,
"user-auth",
);
await writeFile(join(changeDir, "task.md"), `- [x] 任务一\n- [x] 任务二`);
const prompt = await assembleBuildPrompt(defaultConfig, TMP_DIR, "user-auth");
expect(prompt).toContain("已完成");
expect(prompt).toContain("归档");
});
it("task.md 不存在时抛出错误", async () => {
await expect(
assembleBuildPrompt(defaultConfig, TMP_DIR, "nonexistent"),
).rejects.toThrow("task.md not found");
it("task.md 不存在时抛出 CommandError 并附带提示", async () => {
try {
await assembleBuildPrompt(defaultConfig, TMP_DIR, "nonexistent");
expect.unreachable();
} catch (e: any) {
expect(e.message).toContain("尚未完成规划");
expect(e.message).toContain("nonexistent");
expect(e.hint).toContain("plan nonexistent");
}
});
it("tracked=false 时只输出通用提示词", async () => {
const config: RuneConfig = {
stages: { build: { prompt: "按规划文档逐步实现功能" } },
metadata: { tracked: false },
};
const prompt = await assembleBuildPrompt(config, TMP_DIR, "user-auth");
expect(prompt).toBe("按规划文档逐步实现功能");
});
it("tracked=true 且 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: "构建阶段" } },
metadata: { tracked: true },
};
try {
await assembleBuildPrompt(config, TMP_DIR, "user-auth");
expect.unreachable();
} catch (e: any) {
expect(e.message).toContain("task.md");
expect(e.message).toContain("checkbox");
}
});
it("tracked=true 且 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: "构建阶段" } },
metadata: { tracked: true },
};
try {
await assembleBuildPrompt(config, TMP_DIR, "user-auth");
expect.unreachable();
} catch (e: any) {
expect(e.message).toContain("checkbox");
}
});
});
describe("assembleArchivePrompt", () => {
it("返回归档提示词", () => {
const prompt = assembleArchivePrompt(defaultConfig, "user-auth");
it("返回归档提示词", async () => {
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true });
const prompt = await assembleArchivePrompt(defaultConfig, TMP_DIR, "user-auth");
expect(prompt).toContain("user-auth");
expect(prompt).toContain("归档");
});
it("tracked=false 时不读取 task.md只输出通用提示词", async () => {
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "task.md"), "- [ ] 未完成任务");
const config: RuneConfig = {
stages: { archive: { prompt: "确认归档" } },
metadata: { tracked: false },
};
const prompt = await assembleArchivePrompt(config, TMP_DIR, "user-auth");
expect(prompt).toContain("确认归档");
expect(prompt).not.toContain("未完成");
});
it("tracked=true 时引导 AI 检查 task.md 而非内嵌任务内容", async () => {
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "task.md"), "- [ ] 未完成任务");
const config: RuneConfig = {
stages: { archive: { prompt: "归档阶段" } },
metadata: { tracked: true },
};
const prompt = await assembleArchivePrompt(config, TMP_DIR, "user-auth");
expect(prompt).toContain("task.md");
expect(prompt).toContain("未完成");
expect(prompt).not.toContain("- [ ] 未完成任务");
});
it("tracked=true 且所有任务完成时不注入警告", async () => {
const changeDir = join(TMP_DIR, ".rune", "changes", "user-auth");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "task.md"), "- [x] 已完成任务");
const config: RuneConfig = {
stages: { archive: { prompt: "归档阶段" } },
metadata: { tracked: true },
};
const prompt = await assembleArchivePrompt(config, TMP_DIR, "user-auth");
expect(prompt).not.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", tracked: true },
};
try {
await assembleBuildPrompt(config, TMP_DIR, "nonexistent-build");
expect.unreachable();
} catch (e: any) {
expect(e.hint).toContain("pnpx @lanyuanxiaoyao/rune plan 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");
});
});

View File

@@ -1,7 +1,9 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { mkdir, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { loadConfig, findProjectRoot, getRuneDir } from "../../src/core/config.ts";
import { loadConfig, findProjectRoot, getRuneDir, validateConfig } from "../../src/core/config.ts";
import { ConfigError } from "../../src/cli/errors.ts";
import type { RuneConfig } from "../../src/types.ts";
const TMP_DIR = join(import.meta.dir, "__tmp_config_test__");
@@ -63,7 +65,9 @@ describe("loadConfig", () => {
await mkdir(runeDir, { recursive: true });
await writeFile(
join(runeDir, "config.yaml"),
`stages:
`metadata:
tracked: false
stages:
plan:
documents:
- name: spec
@@ -89,10 +93,7 @@ describe("loadConfig", () => {
it("YAML 解析错误时返回默认配置", async () => {
const runeDir = join(TMP_DIR, ".rune");
await mkdir(runeDir, { recursive: true });
await writeFile(
join(runeDir, "config.yaml"),
`stages: [invalid yaml {{{`,
);
await writeFile(join(runeDir, "config.yaml"), `stages: [invalid yaml {{{`);
const config = await loadConfig(TMP_DIR);
expect(config.stages.discuss).toBeDefined();
});
@@ -103,3 +104,191 @@ describe("getRuneDir", () => {
expect(getRuneDir("/project")).toBe(join("/project", ".rune"));
});
});
describe("validateConfig", () => {
it("正常配置不抛错", () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "design", prompt: "生成设计" },
{ name: "task", prompt: "生成任务", depend: ["design"] },
],
},
},
};
expect(() => validateConfig(config)).not.toThrow();
});
it("depend 引用不存在的文档时报错", () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [{ name: "task", prompt: "生成任务", depend: ["nonexistent"] }],
},
},
};
expect(() => validateConfig(config)).toThrow(ConfigError);
});
it("自依赖报错", () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [{ name: "design", prompt: "生成设计", depend: ["design"] }],
},
},
};
expect(() => validateConfig(config)).toThrow(ConfigError);
});
it("循环依赖报错", () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "a", prompt: "a", depend: ["b"] },
{ name: "b", prompt: "b", depend: ["a"] },
],
},
},
};
expect(() => validateConfig(config)).toThrow(ConfigError);
});
it("无 plan 阶段时不报错", () => {
const config: RuneConfig = { stages: {} };
expect(() => validateConfig(config)).not.toThrow();
});
it("depend 为空数组时不报错", () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [{ name: "design", prompt: "生成设计", depend: [] }],
},
},
};
expect(() => validateConfig(config)).not.toThrow();
});
it("tracked=true 时 plan.documents 必须包含 task 文档", () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [{ name: "design", prompt: "生成设计" }],
},
},
metadata: { tracked: true },
};
expect(() => validateConfig(config)).toThrow(ConfigError);
});
it("tracked=true 且 plan.documents 包含 task 时不报错", () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "design", prompt: "生成设计" },
{ name: "task", prompt: "生成任务" },
],
},
},
metadata: { tracked: true },
};
expect(() => validateConfig(config)).not.toThrow();
});
it("tracked=false 时 plan.documents 不包含 task 也不报错", () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [{ name: "design", prompt: "生成设计" }],
},
},
metadata: { tracked: false },
};
expect(() => validateConfig(config)).not.toThrow();
});
it("tracked 未配置时等同于 false不强制要求 task 文档", () => {
const config: RuneConfig = {
stages: {
plan: {
documents: [{ name: "design", prompt: "生成设计" }],
},
},
};
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 rm(tmpDir, { recursive: true, force: true });
}
});
it("无 metadata 时保留默认 metadatatracked: true", async () => {
const tmpDir = join(import.meta.dir, "__tmp_config_nometa_test__");
await mkdir(tmpDir, { recursive: true });
try {
const configPath = join(tmpDir, ".rune", "config.yaml");
await mkdir(join(tmpDir, ".rune"), { recursive: true });
await writeFile(configPath, `stages:\n discuss:\n prompt: "测试"\n`);
const config = await loadConfig(tmpDir);
expect(config.metadata?.tracked).toBe(true);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
it("用户 metadata 与默认 metadata 深合并", async () => {
const tmpDir = join(import.meta.dir, "__tmp_config_deep_merge__");
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: "rune"\nstages:\n discuss:\n prompt: "测试"\n`,
);
const config = await loadConfig(tmpDir);
expect(config.metadata?.command).toBe("rune");
expect(config.metadata?.tracked).toBe(true);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
it("用户 metadata.tracked 显式覆盖默认值", async () => {
const tmpDir = join(import.meta.dir, "__tmp_config_tracked_override__");
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 tracked: false\nstages:\n discuss:\n prompt: "测试"\n`,
);
const config = await loadConfig(tmpDir);
expect(config.metadata?.tracked).toBe(false);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
});

112
tests/core/pm.test.ts Normal file
View File

@@ -0,0 +1,112 @@
import { describe, it, expect } from "bun:test";
import {
inferFromEnvironment,
getPmPrefix,
DEFAULT_PREFIX,
getFallbackNote,
applyCommandPrefix,
} from "../../src/core/pm.ts";
import type { RuneConfig } from "../../src/types.ts";
describe("inferFromEnvironment", () => {
it("execPath 包含 bun 时返回 bunx 前缀", () => {
const result = inferFromEnvironment("/home/user/.bun/bin/bun", undefined);
expect(result).toBe("bunx @lanyuanxiaoyao/rune");
});
it("npm_config_user_agent 包含 pnpm 时返回 pnpx 前缀", () => {
const result = inferFromEnvironment("/usr/bin/node", "pnpm/9.0.0");
expect(result).toBe("pnpx @lanyuanxiaoyao/rune");
});
it("npm_config_user_agent 包含 npm 时返回 npx 前缀", () => {
const result = inferFromEnvironment("/usr/bin/node", "npm/10.0.0");
expect(result).toBe("npx @lanyuanxiaoyao/rune");
});
it("pnpm 优先于 npmuserAgent 同时包含两者时)", () => {
const result = inferFromEnvironment("/usr/bin/node", "pnpm/9.0.0 npm/10.0.0");
expect(result).toBe("pnpx @lanyuanxiaoyao/rune");
});
it("无法推断时返回 null", () => {
const result = inferFromEnvironment("/usr/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("纯文本无命令");
});
});

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { mkdir, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { scanChanges, scanArchives } from "../../src/core/scanner.ts";
import type { RuneConfig } from "../../src/types.ts";
const TMP_DIR = join(import.meta.dir, "__tmp_scanner_test__");
@@ -24,16 +25,27 @@ describe("scanChanges", () => {
const changesDir = join(TMP_DIR, ".rune", "changes");
await mkdir(join(changesDir, "user-auth"), { recursive: true });
await writeFile(join(changesDir, "user-auth", "design.md"), "# 设计");
await writeFile(
join(changesDir, "user-auth", "task.md"),
`- [x] 任务一\n- [ ] 任务二`,
);
await writeFile(join(changesDir, "user-auth", "task.md"), `- [x] 任务一\n- [ ] 任务二`);
const changes = await scanChanges(TMP_DIR);
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "design", prompt: "生成设计" },
{ name: "task", prompt: "生成任务" },
],
},
},
metadata: { tracked: true },
};
const changes = await scanChanges(TMP_DIR, config);
expect(changes).toHaveLength(1);
expect(changes[0].name).toBe("user-auth");
expect(changes[0].documents).toContain("design.md");
expect(changes[0].documents).toContain("task.md");
const docNames = changes[0].documents.map((d) => `${d.name}.md`);
expect(docNames).toContain("design.md");
expect(docNames).toContain("task.md");
expect(changes[0].planCompleted).toBe(true);
expect(changes[0].buildUnlocked).toBe(true);
expect(changes[0].taskProgress).toEqual({ completed: 1, total: 2 });
});
@@ -57,6 +69,146 @@ describe("scanChanges", () => {
const changes = await scanChanges(TMP_DIR);
expect(changes).toHaveLength(2);
});
it("返回 DocumentStatus 含 completed 和 dependMet", async () => {
const changesDir = join(TMP_DIR, ".rune", "changes");
await mkdir(join(changesDir, "user-auth"), { recursive: true });
await writeFile(join(changesDir, "user-auth", "design.md"), "# 设计");
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "design", prompt: "生成设计" },
{ name: "task", prompt: "生成任务", depend: ["design"] },
],
},
},
};
const changes = await scanChanges(TMP_DIR, config);
expect(changes).toHaveLength(1);
expect(changes[0].documents).toHaveLength(2);
expect(changes[0].documents[0]).toEqual({
name: "design",
completed: true,
dependMet: true,
});
expect(changes[0].documents[1]).toEqual({
name: "task",
completed: false,
dependMet: true,
});
expect(changes[0].planCompleted).toBe(false);
expect(changes[0].buildUnlocked).toBe(false);
});
it("depend 未满足时 dependMet 为 false", async () => {
const changesDir = join(TMP_DIR, ".rune", "changes");
await mkdir(join(changesDir, "user-auth"), { recursive: true });
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "design", prompt: "生成设计" },
{ name: "task", prompt: "生成任务", depend: ["design"] },
],
},
},
};
const changes = await scanChanges(TMP_DIR, config);
expect(changes[0].documents[1].dependMet).toBe(false);
});
it("所有文档完成时 planCompleted 和 buildUnlocked 为 true", async () => {
const changesDir = join(TMP_DIR, ".rune", "changes");
await mkdir(join(changesDir, "user-auth"), { recursive: true });
await writeFile(join(changesDir, "user-auth", "design.md"), "# 设计");
await writeFile(join(changesDir, "user-auth", "task.md"), "- [ ] 任务");
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "design", prompt: "生成设计" },
{ name: "task", prompt: "生成任务" },
],
},
},
};
const changes = await scanChanges(TMP_DIR, config);
expect(changes[0].planCompleted).toBe(true);
expect(changes[0].buildUnlocked).toBe(true);
});
it("无 config 时使用文件扫描兼容模式", async () => {
const changesDir = join(TMP_DIR, ".rune", "changes");
await mkdir(join(changesDir, "feature-a"), { recursive: true });
await writeFile(join(changesDir, "feature-a", "design.md"), "# 设计");
const changes = await scanChanges(TMP_DIR);
expect(changes).toHaveLength(1);
expect(changes[0].documents[0].name).toBe("design");
expect(changes[0].documents[0].completed).toBe(true);
expect(changes[0].planCompleted).toBe(false);
expect(changes[0].buildUnlocked).toBe(false);
});
it("tracked=false 时不扫描 task.mdtaskProgress 为 null", async () => {
const changesDir = join(TMP_DIR, ".rune", "changes");
await mkdir(join(changesDir, "test-change"), { recursive: true });
await writeFile(join(changesDir, "test-change", "design.md"), "# 设计");
await writeFile(join(changesDir, "test-change", "task.md"), "- [x] 完成\n- [ ] 未完成");
const config: RuneConfig = {
stages: {
plan: {
documents: [{ name: "design", prompt: "生成设计" }],
},
},
metadata: { tracked: false },
};
const results = await scanChanges(TMP_DIR, config);
expect(results[0].taskProgress).toBeNull();
});
it("tracked=true 时扫描 task.mdtaskProgress 有值", async () => {
const changesDir = join(TMP_DIR, ".rune", "changes");
await mkdir(join(changesDir, "test-change2"), { recursive: true });
await writeFile(join(changesDir, "test-change2", "design.md"), "# 设计");
await writeFile(join(changesDir, "test-change2", "task.md"), "- [x] 完成\n- [ ] 未完成");
const config: RuneConfig = {
stages: {
plan: {
documents: [
{ name: "design", prompt: "生成设计" },
{ name: "task", prompt: "生成任务" },
],
},
},
metadata: { tracked: true },
};
const results = await scanChanges(TMP_DIR, config);
expect(results[0].taskProgress).toEqual({ completed: 1, total: 2 });
});
it("tracked 未配置undefined时不扫描 task.md", async () => {
const changesDir = join(TMP_DIR, ".rune", "changes");
await mkdir(join(changesDir, "test-change3"), { recursive: true });
await writeFile(join(changesDir, "test-change3", "design.md"), "# 设计");
await writeFile(join(changesDir, "test-change3", "task.md"), "- [x] 完成");
const config: RuneConfig = {
stages: {
plan: {
documents: [{ name: "design", prompt: "生成设计" }],
},
},
};
const results = await scanChanges(TMP_DIR, config);
expect(results[0].taskProgress).toBeNull();
});
});
describe("scanArchives", () => {

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from "bun:test";
import { parseTasks } from "../../src/core/task-parser.ts";
import { parseTasks, validateTaskFormat, TaskFormatError } from "../../src/core/task-parser.ts";
describe("parseTasks", () => {
it("解析标准 checkbox 格式", () => {
@@ -52,3 +52,29 @@ describe("parseTasks", () => {
expect(tasks.length).toBe(3);
});
});
describe("validateTaskFormat", () => {
it("合法 task 内容通过校验", () => {
expect(() => validateTaskFormat("- [x] 已完成\n- [ ] 未完成")).not.toThrow();
});
it("无 checkbox 项时抛错", () => {
expect(() => validateTaskFormat("# 标题\n一些描述")).toThrow(TaskFormatError);
});
it("空内容抛错", () => {
expect(() => validateTaskFormat("")).toThrow(TaskFormatError);
});
it("checkbox 文本为空时抛错", () => {
expect(() => validateTaskFormat("- [ ] \n- [x] 有内容")).toThrow(TaskFormatError);
});
it("checkbox 文本仅空格时抛错", () => {
expect(() => validateTaskFormat("- [ ] ")).toThrow(TaskFormatError);
});
it("有 checkbox 且文本非空时通过", () => {
expect(() => validateTaskFormat("- [ ] 实现功能A")).not.toThrow();
});
});

View File

@@ -2,18 +2,52 @@ import { describe, it, expect } from "bun:test";
import { defaultConfig } from "../../src/defaults/config.ts";
describe("defaultConfig", () => {
it("包含所有个阶段的配置", () => {
it("包含所有个阶段的配置", () => {
expect(defaultConfig.stages.discuss).toBeDefined();
expect(defaultConfig.stages.create).toBeDefined();
expect(defaultConfig.stages.plan).toBeDefined();
expect(defaultConfig.stages.build).toBeDefined();
expect(defaultConfig.stages.archive).toBeDefined();
});
it("包含 create 阶段的配置", () => {
expect(defaultConfig.stages.create).toBeDefined();
expect(defaultConfig.stages.create!.prompt).toBeTruthy();
});
it("discuss 阶段有 prompt", () => {
expect(defaultConfig.stages.discuss!.prompt).toBeTruthy();
expect(typeof defaultConfig.stages.discuss!.prompt).toBe("string");
});
it("discuss 默认提示词包含关键章节", () => {
const prompt = defaultConfig.stages.discuss!.prompt;
expect(prompt).toContain("探索模式");
expect(prompt).toContain("立场");
expect(prompt).toContain("你可能做的事");
expect(prompt).toContain("Rune 感知");
expect(prompt).toContain("你不必做的事");
expect(prompt).toContain("结束探索");
expect(prompt).toContain("护栏");
expect(prompt).toContain("典型场景");
});
it("discuss 默认提示词不包含 OpenSpec 术语", () => {
const prompt = defaultConfig.stages.discuss!.prompt;
expect(prompt).not.toContain("openspec");
expect(prompt).not.toContain("/opsx:");
expect(prompt).not.toContain("proposal.md");
expect(prompt).not.toContain("specs/");
});
it("discuss 默认提示词包含正确 Rune 术语", () => {
const prompt = defaultConfig.stages.discuss!.prompt;
expect(prompt).toContain("/rune-plan");
expect(prompt).toContain("rune status");
expect(prompt).toContain("design.md");
expect(prompt).toContain("task.md");
});
it("plan 阶段包含 design 和 task 两个文档配置", () => {
const docs = defaultConfig.stages.plan!.documents;
expect(docs).toHaveLength(2);
@@ -25,25 +59,24 @@ describe("defaultConfig", () => {
});
it("plan 的 task 文档配置存在", () => {
const taskDoc = defaultConfig.stages.plan!.documents.find(
(d) => d.name === "task",
);
const taskDoc = defaultConfig.stages.plan!.documents.find((d) => d.name === "task");
expect(taskDoc).toBeDefined();
expect(taskDoc!.prompt).toBeTruthy();
});
it("task 文档依赖 design", () => {
const taskDoc = defaultConfig.stages.plan!.documents.find((d) => d.name === "task");
expect(taskDoc!.depend).toEqual(["design"]);
});
it("design 文档有 template", () => {
const designDoc = defaultConfig.stages.plan!.documents.find(
(d) => d.name === "design",
);
const designDoc = defaultConfig.stages.plan!.documents.find((d) => d.name === "design");
expect(designDoc!.template).toBeTruthy();
expect(designDoc!.template).toContain("{{change-name}}");
});
it("task 文档有 template", () => {
const taskDoc = defaultConfig.stages.plan!.documents.find(
(d) => d.name === "task",
);
const taskDoc = defaultConfig.stages.plan!.documents.find((d) => d.name === "task");
expect(taskDoc!.template).toBeTruthy();
expect(taskDoc!.template).toContain("- [ ]");
});

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { existsSync } from "node:fs";
import { mkdir, writeFile, rm, readFile, rename } from "node:fs/promises";
import { mkdir, writeFile, rm, rename } from "node:fs/promises";
import { join } from "node:path";
import { runInit } from "../../src/commands/init.ts";
import { loadConfig, getChangeDir, getArchiveDir } from "../../src/core/config.ts";
@@ -30,35 +30,40 @@ describe("完整 SDD 流程", () => {
const config = await loadConfig(TMP_DIR);
const discussPrompt = assembleDiscussPrompt(config);
expect(discussPrompt).toContain("软件架构师");
expect(discussPrompt).toContain("探索模式");
const changeName = "user-auth";
await mkdir(getChangeDir(TMP_DIR, changeName), { recursive: true });
const planPrompt = await assemblePlanPrompt(config, TMP_DIR, changeName);
const planPrompt = await assemblePlanPrompt(config, TMP_DIR, changeName, "design");
expect(planPrompt).toContain("user-auth");
const changeDir = getChangeDir(TMP_DIR, changeName);
await writeFile(join(changeDir, "design.md"), "# 用户认证设计\n\n## 背景\n需要用户登录功能");
await writeFile(join(changeDir, "task.md"), "- [ ] 实现登录 API\n- [ ] 编写登录测试");
const changes = await scanChanges(TMP_DIR);
const changes = await scanChanges(TMP_DIR, config);
expect(changes).toHaveLength(1);
expect(changes[0].name).toBe("user-auth");
expect(changes[0].taskProgress).toEqual({ completed: 0, total: 2 });
expect(changes[0].planCompleted).toBe(true);
expect(changes[0].buildUnlocked).toBe(true);
expect(changes[0].documents.length).toBeGreaterThan(0);
const buildPrompt = await assembleBuildPrompt(config, TMP_DIR, changeName);
expect(buildPrompt).toContain("实现登录 API");
expect(buildPrompt).toContain("共 2 项");
expect(buildPrompt).toContain("task.md");
expect(buildPrompt).toContain("2");
await writeFile(join(changeDir, "task.md"), "- [x] 实现登录 API\n- [x] 编写登录测试");
const updatedChanges = await scanChanges(TMP_DIR);
const updatedChanges = await scanChanges(TMP_DIR, config);
expect(updatedChanges[0].taskProgress).toEqual({ completed: 2, total: 2 });
expect(updatedChanges[0].planCompleted).toBe(true);
expect(updatedChanges[0].buildUnlocked).toBe(true);
const buildPrompt2 = await assembleBuildPrompt(config, TMP_DIR, changeName);
expect(buildPrompt2).toContain("已完成");
const archivePrompt = assembleArchivePrompt(config, changeName);
const archivePrompt = await assembleArchivePrompt(config, TMP_DIR, changeName);
expect(archivePrompt).toContain("归档");
const today = new Date().toISOString().slice(0, 10);
const src = getChangeDir(TMP_DIR, changeName);
@@ -72,7 +77,7 @@ describe("完整 SDD 流程", () => {
const archives = await scanArchives(TMP_DIR);
expect(archives).toContain(`${today}-${changeName}`);
const postArchiveChanges = await scanChanges(TMP_DIR);
const postArchiveChanges = await scanChanges(TMP_DIR, config);
expect(postArchiveChanges).toHaveLength(0);
});
@@ -86,14 +91,14 @@ describe("完整 SDD 流程", () => {
await writeFile(join(changeDir, "task.md"), `- [ ] ${name} 任务`);
}
const changes = await scanChanges(TMP_DIR);
const changes = await scanChanges(TMP_DIR, config);
expect(changes).toHaveLength(2);
const authPrompt = await assembleBuildPrompt(config, TMP_DIR, "auth");
expect(authPrompt).toContain("auth 任务");
expect(authPrompt).toContain("task.md");
const paymentPrompt = await assembleBuildPrompt(config, TMP_DIR, "payment");
expect(paymentPrompt).toContain("payment 任务");
expect(paymentPrompt).toContain("task.md");
});
it("自定义配置覆盖默认配置", async () => {
@@ -101,7 +106,9 @@ describe("完整 SDD 流程", () => {
await writeFile(
join(TMP_DIR, ".rune", "config.yaml"),
`stages:
`metadata:
tracked: false
stages:
discuss:
prompt: 自定义讨论
plan:
@@ -118,7 +125,7 @@ describe("完整 SDD 流程", () => {
expect(discussPrompt).toBe("自定义讨论");
await mkdir(getChangeDir(TMP_DIR, "test"), { recursive: true });
const planPrompt = await assemblePlanPrompt(config, TMP_DIR, "test");
const planPrompt = await assemblePlanPrompt(config, TMP_DIR, "test", "spec");
expect(planPrompt).toContain("spec");
expect(planPrompt).toContain("test 规格");
expect(planPrompt).not.toContain("design");
@@ -126,4 +133,91 @@ describe("完整 SDD 流程", () => {
expect(config.stages.build).toBeDefined();
expect(config.stages.archive).toBeDefined();
});
it("scanChanges 返回文档依赖状态", async () => {
await runInit(TMP_DIR, ["opencode"]);
const config = await loadConfig(TMP_DIR);
const changeDir = getChangeDir(TMP_DIR, "dep-test");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "design.md"), "# 设计文档");
const changes = await scanChanges(TMP_DIR, config);
expect(changes).toHaveLength(1);
const designDoc = changes[0].documents.find((d) => d.name === "design");
expect(designDoc).toBeDefined();
expect(designDoc!.completed).toBe(true);
const taskDoc = changes[0].documents.find((d) => d.name === "task");
expect(taskDoc).toBeDefined();
expect(taskDoc!.completed).toBe(false);
expect(taskDoc!.dependMet).toBe(true);
});
});
describe("变更名校验", () => {
it("合法变更名(中文、英文、短横线)通过校验", async () => {
await runInit(TMP_DIR, ["opencode"]);
const config = await loadConfig(TMP_DIR);
await mkdir(getChangeDir(TMP_DIR, "用户-login"), { recursive: true });
await writeFile(join(getChangeDir(TMP_DIR, "用户-login"), "design.md"), "# 设计");
await writeFile(join(getChangeDir(TMP_DIR, "用户-login"), "task.md"), "- [ ] 任务");
const prompt = await assemblePlanPrompt(config, TMP_DIR, "用户-login", "design");
expect(prompt).toContain("用户-login");
});
it("非法变更名(空格、下划线、特殊符号)被拒绝", () => {
const validRegex = /^[\u4e00-\u9fa5a-zA-Z-]+$/;
expect(validRegex.test("my change")).toBe(false);
expect(validRegex.test("my_change")).toBe(false);
expect(validRegex.test("my-change!")).toBe(false);
expect(validRegex.test("my.change")).toBe(false);
expect(validRegex.test("")).toBe(false);
});
});
describe("archive 校验", () => {
it("task 未全部完成时注入警告提示词", async () => {
await runInit(TMP_DIR, ["opencode"]);
const config = await loadConfig(TMP_DIR);
const changeDir = getChangeDir(TMP_DIR, "incomplete-task");
await mkdir(changeDir, { recursive: true });
await writeFile(join(changeDir, "design.md"), "# 设计");
await writeFile(join(changeDir, "task.md"), "- [x] 已完成任务\n- [ ] 未完成任务");
const prompt = await assembleArchivePrompt(config, TMP_DIR, "incomplete-task");
expect(prompt).toContain("警告");
expect(prompt).toContain("task.md");
expect(prompt).toContain("未完成");
expect(prompt).not.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("警告");
});
});

View 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("无效的版本号格式");
});
});