From 3e31ff9576d1b21dcb63d114e4142bbd534a5242 Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sat, 1 Aug 2026 06:30:35 +0800 Subject: [PATCH 1/2] =?UTF-8?q?refactor(kernel):=20dead-code-removal=20?= =?UTF-8?q?=E2=80=94=20=E5=88=A0=E9=99=A4=20FlowRun/broker/tdd-tool=20+=20?= =?UTF-8?q?=E6=8E=A5=E7=BA=BF=205=20=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=20+=20=E5=86=85=E7=BD=AE=20context/out-of-sc?= =?UTF-8?q?ope/handoff=20=E6=B8=85=E7=90=86=20+=20=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 - README.md | 53 +- README.zh.md | 21 +- assets/agents/dev-lifecycle.md | 2 +- assets/context/CONTEXT.md | 41 - assets/skills/flow-design/SKILL.md | 2 +- docs/dev/guides/contributing.md | 6 +- docs/dev/handoff/design-comment.md | 22 - docs/dev/out-of-scope.md | 40 - docs/guides/architecture.md | 159 +- docs/guides/configuration.md | 95 +- docs/guides/usage.md | 52 +- src/flowrun/adapter.ts | 350 ---- src/flowrun/audit.ts | 73 - src/flowrun/ci.ts | 247 --- src/flowrun/coverage.ts | 272 --- src/flowrun/digest.ts | 354 ---- src/flowrun/evaluator.ts | 258 --- src/flowrun/gate.ts | 169 -- src/flowrun/github.ts | 183 -- src/flowrun/index.ts | 107 -- src/flowrun/merge.ts | 226 --- src/flowrun/migration.ts | 310 ---- src/flowrun/resilience.ts | 101 - src/flowrun/state.ts | 451 ----- src/flowrun/transitions.ts | 219 --- src/flowrun/types.ts | 434 ----- src/flowrun/validator.ts | 253 --- src/kernel/review.ts | 105 ++ src/kernel/task.ts | 4 +- src/plugin/broker.ts | 146 -- src/plugin/server.ts | 280 +-- src/plugin/skills.ts | 8 +- src/plugin/tdd-tool.ts | 485 ----- src/util/paths.ts | 10 +- test/flowrun/adapter.test.ts | 200 -- test/flowrun/audit.test.ts | 54 - test/flowrun/ci.test.ts | 443 ----- test/flowrun/coverage.test.ts | 340 ---- test/flowrun/digest.test.ts | 282 --- test/flowrun/evaluator.test.ts | 598 ------ test/flowrun/finalize.test.ts | 187 -- .../fixtures/simple-vitest/package-lock.json | 1622 ----------------- .../fixtures/simple-vitest/package.json | 11 - .../flowrun/fixtures/simple-vitest/src/sum.ts | 3 - .../fixtures/simple-vitest/test/sum.test.ts | 12 - .../fixtures/simple-vitest/vitest.config.ts | 7 - .../flowrun/fixtures/v1-flowrun-complete.json | 69 - test/flowrun/fixtures/v1-task-merged.json | 47 - test/flowrun/fixtures/v1-task-pending.json | 39 - test/flowrun/fixtures/v1-task-reviewing.json | 47 - test/flowrun/fixtures/v1-task-running.json | 39 - test/flowrun/gate.test.ts | 267 --- test/flowrun/github.test.ts | 144 -- test/flowrun/merge.test.ts | 412 ----- test/flowrun/migration.test.ts | 296 --- test/flowrun/resilience.test.ts | 112 -- test/flowrun/state.test.ts | 843 --------- test/flowrun/types.test.ts | 66 - test/flowrun/validator.test.ts | 157 -- test/kernel/review.test.ts | 81 + test/plugin/broker.test.ts | 700 ------- test/plugin/server-flowrun.test.ts | 465 ----- test/plugin/server-permissions.test.ts | 13 +- test/plugin/task-control.test.ts | 4 +- test/plugin/tdd-tool.test.ts | 545 ------ test/prompts.test.ts | 27 +- test/skills.test.ts | 16 +- 68 files changed, 395 insertions(+), 13292 deletions(-) delete mode 100644 assets/context/CONTEXT.md delete mode 100644 docs/dev/handoff/design-comment.md delete mode 100644 docs/dev/out-of-scope.md delete mode 100644 src/flowrun/adapter.ts delete mode 100644 src/flowrun/audit.ts delete mode 100644 src/flowrun/ci.ts delete mode 100644 src/flowrun/coverage.ts delete mode 100644 src/flowrun/digest.ts delete mode 100644 src/flowrun/evaluator.ts delete mode 100644 src/flowrun/gate.ts delete mode 100644 src/flowrun/github.ts delete mode 100644 src/flowrun/index.ts delete mode 100644 src/flowrun/merge.ts delete mode 100644 src/flowrun/migration.ts delete mode 100644 src/flowrun/resilience.ts delete mode 100644 src/flowrun/state.ts delete mode 100644 src/flowrun/transitions.ts delete mode 100644 src/flowrun/types.ts delete mode 100644 src/flowrun/validator.ts create mode 100644 src/kernel/review.ts delete mode 100644 src/plugin/broker.ts delete mode 100644 src/plugin/tdd-tool.ts delete mode 100644 test/flowrun/adapter.test.ts delete mode 100644 test/flowrun/audit.test.ts delete mode 100644 test/flowrun/ci.test.ts delete mode 100644 test/flowrun/coverage.test.ts delete mode 100644 test/flowrun/digest.test.ts delete mode 100644 test/flowrun/evaluator.test.ts delete mode 100644 test/flowrun/finalize.test.ts delete mode 100644 test/flowrun/fixtures/simple-vitest/package-lock.json delete mode 100644 test/flowrun/fixtures/simple-vitest/package.json delete mode 100644 test/flowrun/fixtures/simple-vitest/src/sum.ts delete mode 100644 test/flowrun/fixtures/simple-vitest/test/sum.test.ts delete mode 100644 test/flowrun/fixtures/simple-vitest/vitest.config.ts delete mode 100644 test/flowrun/fixtures/v1-flowrun-complete.json delete mode 100644 test/flowrun/fixtures/v1-task-merged.json delete mode 100644 test/flowrun/fixtures/v1-task-pending.json delete mode 100644 test/flowrun/fixtures/v1-task-reviewing.json delete mode 100644 test/flowrun/fixtures/v1-task-running.json delete mode 100644 test/flowrun/gate.test.ts delete mode 100644 test/flowrun/github.test.ts delete mode 100644 test/flowrun/merge.test.ts delete mode 100644 test/flowrun/migration.test.ts delete mode 100644 test/flowrun/resilience.test.ts delete mode 100644 test/flowrun/state.test.ts delete mode 100644 test/flowrun/types.test.ts delete mode 100644 test/flowrun/validator.test.ts create mode 100644 test/kernel/review.test.ts delete mode 100644 test/plugin/broker.test.ts delete mode 100644 test/plugin/server-flowrun.test.ts delete mode 100644 test/plugin/tdd-tool.test.ts diff --git a/.gitignore b/.gitignore index 3be2277..3d7e22e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,3 @@ dist/ .worktree/ # git worktree 并行开发目录 .vitepress-dist/ .opencode -docs/dev/handoff/ # 临时产物(handoff、issue body 等) diff --git a/README.md b/README.md index 405d623..951ae5a 100644 --- a/README.md +++ b/README.md @@ -20,21 +20,19 @@ } ``` -Once started, the plugin automatically injects 9 slash commands, 9 flow skills, and 5 agents. +Once started, the plugin automatically injects 7 slash commands, 8 flow skills, 5 agents, and 5 deterministic lifecycle tools. ## Command Overview | Command | Stage | Output | |---------|-------|--------| -| `/setup` | Setup | `docs/` directory structure and environment validation | -| `/requirements` | Requirements | PRD → `docs/prd/` + GitHub Issue | -| `/design` | Design | Technical specification + ADR → `docs/dev/specs/` + `docs/adr/` | -| `/tasks` | Task decomposition | DAG tasks + Sub Issues → `docs/dev/tasks/` | -| `/code` | Coding | Branch + code + unit tests + PR | -| `/test` | Testing | Trigger CI + monitor + report | +| `/setup` | Setup | `docs/` directory structure, Project Profile, CI/release workflow validation | +| `/requirements` | Requirements | PRD → `docs/prd/` + Draft Parent Issue | +| `/design` | Design | Technical specification + necessary ADR → `docs/dev/specs/` + `docs/adr/` | +| `/tasks` | Task decomposition | DAG tasks + Sub Issues (GitHub 权威源) | +| `/code` | Coding | Task + Runtime TDD evidence + PR | | `/review` | Review | Dual-axis review + automatic merge | -| `/release` | ⚠️ Manual release | Version → Changelog → Release → npm publish | -| `/handoff` | Handoff | Package context for transfer across sessions | +| `/release` | ⚠️ Manual release | Version proposal → Release PR → tag push → workflow monitor | ## Quick Start @@ -60,19 +58,36 @@ npm install @devcxl/opencode-cabbage src/ # Thin TypeScript layer ├── index.ts # Plugin entry point ├── plugin.ts # Package root resolution -└── plugin/ - ├── server.ts # Main factory: injects skills, commands, and agents - ├── commands.ts # Command loader - ├── skills.ts # Skill loader - ├── prompts.ts # Prompt loader - ├── bootstrap.ts # Startup guidance - └── agents.ts # Agent injection +├── plugin/ # Loaders + tool factories +│ ├── server.ts # Main factory: injects skills, commands, agents, 5 lifecycle tools +│ ├── commands.ts # Command loader +│ ├── skills.ts # Skill loader +│ ├── prompts.ts # Prompt loader +│ ├── bootstrap.ts # Startup guidance +│ ├── agents.ts # Agent injection +│ ├── setup-control.ts # setup_control tool +│ ├── flow-control.ts # flow_control tool +│ ├── task-control.ts # task_control tool +│ ├── tdd-checkpoint.ts # tdd_checkpoint tool +│ └── release-control.ts # release_control tool +└── kernel/ # Deterministic thin kernel + ├── slug.ts # Functional slug derivation/validation + ├── records.ts # Flow/Task Record CRUD (GitHub authoritative) + ├── worktree.ts # Worktree lifecycle (no --force) + ├── tdd/ # Runtime TDD pure functions + evidence + ├── review.ts # Merge gates (CI/protection/risk/approval) + ├── release.ts # Version proposal + tag immutability + ├── context.ts # Root CONTEXT.md discovery/injection + ├── profile.ts # AGENTS.md Project Profile parsing + ├── session-index.ts # Flow→session index + takeover + ├── caller.ts # Caller role matrix + ├── permission.ts # Permission matching semantics + └── legacy.ts # Legacy FlowRun detection assets/ # Runtime assets -├── commands/ # 9 slash commands -├── skills/ # 9 flow-* skills +├── commands/ # 7 slash commands +├── skills/ # 8 flow-* skills ├── agents/ # 5 agent definitions -├── context/ # Domain glossary └── prompts/ # Guidance prompts and templates ``` diff --git a/README.zh.md b/README.zh.md index d660b3f..b6aa538 100644 --- a/README.zh.md +++ b/README.zh.md @@ -20,21 +20,19 @@ } ``` -插件启动后自动注入 9 个 slash command、9 个 flow skill、5 个 agent。 +插件启动后自动注入 7 个 slash command、8 个 flow skill、5 个 agent 与 5 个确定性生命周期工具。 ## 命令一览 | 命令 | 阶段 | 产出 | |------|------|------| -| `/setup` | 初始化 | `docs/` 目录结构、环境验证 | -| `/requirements` | 需求 | PRD → `docs/prd/` + GitHub Issue | -| `/design` | 设计 | 技术方案 + ADR → `docs/dev/specs/` + `docs/adr/` | -| `/tasks` | 任务拆解 | DAG 任务 + Sub Issues → `docs/dev/tasks/` | -| `/code` | 编码 | 分支 + 代码 + 单测 + PR | -| `/test` | 测试 | 触发 CI + 监控 + 汇报 | +| `/setup` | 初始化 | `docs/` 目录结构、Project Profile、CI/release workflow 校验 | +| `/requirements` | 需求 | PRD → `docs/prd/` + Draft Parent Issue | +| `/design` | 设计 | 技术方案 + 必要 ADR → `docs/dev/specs/` + `docs/adr/` | +| `/tasks` | 任务拆解 | DAG 任务 + Sub Issues(GitHub 权威源) | +| `/code` | 编码 | Task + Runtime TDD evidence + PR | | `/review` | 审查 | 双轴审查 + 自动合并 | -| `/release` | ⚠️ 手动发布 | 版本 → Changelog → Release → npm publish | -| `/handoff` | 交接 | 打包上下文,跨会话传递 | +| `/release` | ⚠️ 手动发布 | 版本提议 → Release PR → tag push → workflow 监控 | ## 快速开始 @@ -69,10 +67,9 @@ src/ # TypeScript 薄层 └── agents.ts # Agent 注入 assets/ # 运行时资源 -├── commands/ # 9 个 slash command -├── skills/ # 9 个 flow-* skill +├── commands/ # 7 个 slash command +├── skills/ # 8 个 flow-* skill ├── agents/ # 5 个 agent 定义 -├── context/ # 领域词汇表 └── prompts/ # 引导提示词 + 模板 ``` diff --git a/assets/agents/dev-lifecycle.md b/assets/agents/dev-lifecycle.md index 6d3ad4c..dfc1017 100644 --- a/assets/agents/dev-lifecycle.md +++ b/assets/agents/dev-lifecycle.md @@ -59,7 +59,7 @@ permission: - 开发文档 → `docs/dev/{api,db,guides}/` ### 子 agent 约束 -- 禁止在 `/tmp/` 下创建或调试文件,临时产物统一放在 `docs/dev/handoff/` 目录 +- 禁止在 `/tmp/` 下创建或调试文件;临时产物放入 worktree 内 - 文档产出必须遵循目录规范 --- diff --git a/assets/context/CONTEXT.md b/assets/context/CONTEXT.md deleted file mode 100644 index 40529cc..0000000 --- a/assets/context/CONTEXT.md +++ /dev/null @@ -1,41 +0,0 @@ -# opencode-cabbage Context - -## 领域术语 - -**Flow(流程):** -从需求到发布的完整开发阶段序列。一个 Flow 包含多个 Stage。 - -**Stage(阶段):** -Flow 中的一个步骤,由一个 slash command 触发。每个 Stage 产生一个或多个 Artifact。 - -**Artifact(产物):** -Stage 产出的文档——PRD、ADR、Task、设计文档等。存放在 `docs/` 下的对应子目录中。 - -**Issue Tree(问题树):** -由 Parent Issue 和 Sub Issues 组成的层级结构。Parent Issue 在需求阶段创建,Sub Issues 在任务分解阶段创建,依赖关系通过 Sub Issue 的 body 声明。 - -**GitHub Canonical Source(GitHub 权威源):** -所有工程状态以 GitHub 为准。Issues、PRs、CI checks、Releases 都在 GitHub 上管理,本地文档是辅助引用。 - -**Setup(初始化):** -首次使用前的准备流程。检测 gh CLI、配置 GitHub 仓库、创建 docs/ 目录。 - -**Handoff(交接):** -当上下文窗口压力过大或需要跨阶段传递进度时,将当前状态打包为 markdown 文件,在下一次交互时读取恢复。 - -## 术语关系 - -- 一个 **Flow** 包含多个有序 **Stage** -- 每个 **Stage** 产生一个或多个 **Artifact** -- **Artifact** 存储在 `docs/` 下的 **prd/**、**adr/**、**dev/** 等子目录 -- **Issue Tree** 是跨 Stage 的线索:Parent Issue → Sub Issues(含依赖关系) -- 所有 **Stage** 可能引用 GitHub 上的 **Canonical Source** -- **Setup** 必须在第一个 Flow 之前运行(或由 AI 在第一次使用时引导) -- **Handoff** 可在任意 Stage 之间使用 - -## 避免使用的术语 - -- **Ticket** — Decision Map 中的待决策项(Grilling / Research / Prototype),不是 GitHub Issue。 - 仅在需求澄清阶段使用,PRD 生成后销毁。与 Issue 有本质区别:Ticket 是临时待定问题,Issue 是持久化工作项。 -- **Backlog** — 避免歧义,直接指定 Issue 或 Issue list -- **Sprint** / **Epic** — 不使用,用 **Issue Tree** 层级替代 diff --git a/assets/skills/flow-design/SKILL.md b/assets/skills/flow-design/SKILL.md index c20e2c2..7310745 100644 --- a/assets/skills/flow-design/SKILL.md +++ b/assets/skills/flow-design/SKILL.md @@ -27,7 +27,7 @@ worktree 创建与 Planning PR 创建均由内核工具完成,创建动作全 ## CONTEXT.md 术语 -阅读项目根 CONTEXT.md 与 skills 内 `_context/CONTEXT.md` 了解领域术语; +阅读项目根 CONTEXT.md(领域术语权威,已自动注入内容与 digest); 设计中发现的新术语经用户确认后写入根 CONTEXT.md(术语权威)。 ## Output diff --git a/docs/dev/guides/contributing.md b/docs/dev/guides/contributing.md index 43e6411..56ce5ec 100644 --- a/docs/dev/guides/contributing.md +++ b/docs/dev/guides/contributing.md @@ -31,8 +31,4 @@ npm run typecheck # 类型检查 ## 发布 -```bash -npm version patch|minor|major -git push origin main --tags -npm publish -``` +发布走项目自身的 Release 流程:按 Profile 的版本规则更新版本文件 → 创建 Release PR → 人工批准合并 → 打 tag push → 由 `.github/workflows/release-publish.yml`(或项目配置的 release workflow)自动发布。不直接手动 `npm publish`。 diff --git a/docs/dev/handoff/design-comment.md b/docs/dev/handoff/design-comment.md deleted file mode 100644 index e09740e..0000000 --- a/docs/dev/handoff/design-comment.md +++ /dev/null @@ -1,22 +0,0 @@ -## 技术方案 - -三层改造:Plugin Runtime(P0 修复)→ Prompt 资产(Contract-first)→ 测试基础设施(Prompt Lint)。 - -关键设计决策: -- Stage Contract:约定式 Markdown(8 个固定段落) -- Task Manifest:manifest.yaml 作为依赖唯一权威源 -- Agent 能力矩阵:capabilities 字段 + permission 交叉验证 -- 环境探测:运行时 bash + Prompt 注入 -- Bootstrap:三层条件注入 -- FlowRun:先做 Spike,再决策 - -## ADR - -- [ADR 0006](/adr/2026-07-15-prompt-contract-first) — Prompt 资产 Contract-first 约定式 Markdown -- [ADR 0007](/adr/2026-07-15-flowrun-spike) — FlowRun 先做接入 Spike 再决定完整接入 - -## PR 分解 - -9 个 PR,4 个 Batch:角色/权限/Goal → 模板/引用/Worktree → Contract 迁移 → Lint + 文档同步。 - -完整文档:docs/dev/specs/prompt-contract-first-refactor.md diff --git a/docs/dev/out-of-scope.md b/docs/dev/out-of-scope.md deleted file mode 100644 index c6fecb9..0000000 --- a/docs/dev/out-of-scope.md +++ /dev/null @@ -1,40 +0,0 @@ -# Out of Scope - -## opencode-cabbage 配置指南与 GitHub Pages 自动部署 - -以下需求在访谈中明确排除,记录于此供后续参考: - -- API 文档自动生成(typedoc 等) — 项目当前无此需求 -- 多语言/国际化文档 — 非必要 -- 文档搜索功能 — 可通过 GitHub Pages 搜索替代 -- 版本化文档(多版本切换) — 待项目成熟后再考虑 - -## VitePress 文档站迁移 - -以下需求在访谈中明确排除,记录于此供后续参考: - -- 自定义 VitePress 主题 — 使用默认主题 -- 自定义域名配置 -- 多语言支持 -- 文档内容重写/重组 — 仅迁移,不修改内容 - -## 文档同步流程与任务目录重构 - -以下需求在访谈中明确排除,记录于此供后续参考: - -- 自动检测代码变更影响哪些文档(AI 辅助但最终人工判断) -- specs/ 技术方案自动更新 — 设计阶段产物,代码阶段不改 -- adr/ 架构决策记录自动更新 — 设计偏离时单独更新 -- task 文件修改 — 保留原始设计意图,变更记入 changelog - -## 提示词 Contract-first 重构 - -以下需求在访谈中明确排除,记录于此供后续参考: - -- 完整 FlowRun 接入上线 — 本轮仅 Spike + ADR,不做生产运行链修改 -- 行为场景测试(真实模型 API 端到端 Prompt 测试) — 本轮仅做静态 Lint,场景测试后续增强 -- 确定性引擎化(Git/GitHub/DAG/Gate 全部迁出 Prompt 到代码工具) — 方案 C 不在本轮 -- Release 流程(npm publish) — 始终为手动阶段 -- 新建独立 CLI 工具链 -- 非 opencode-cabbage 项目的 Prompt 规范制定 -- 当前 docs/ 目录下非 Skill/Agent/Command 文档的重构 diff --git a/docs/guides/architecture.md b/docs/guides/architecture.md index 94e914d..476c8f4 100644 --- a/docs/guides/architecture.md +++ b/docs/guides/architecture.md @@ -13,21 +13,19 @@ │ │ opencode-cabbage Plugin │ │ │ │ ┌─────────────┐ ┌─────────────┐ ┌───────────────┐ │ │ │ │ │ Config │ │ Events │ │ Tools │ │ │ -│ │ │ Injection │ │ Handler │ │ (goal) │ │ │ +│ │ │ Injection │ │ Handler │ │ (5 生命周期) │ │ │ │ │ └──────┬───────┘ └──────┬──────┘ └───────┬───────┘ │ │ │ │ │ │ │ │ │ │ │ ┌──────┴─────────────────┴──────────────────┴───────┐ │ │ -│ │ │ FlowRun 引擎(Spike 验证完成,阶段性接入中) │ │ │ -│ │ │ ┌─────────┐ ┌────────┐ ┌────────┐ ┌──────────┐ │ │ │ -│ │ │ │ Gate │ │ GitHub │ │ Merge │ │Resilience│ │ │ │ -│ │ │ │ 检查 │ │ 存储 │ │ 合并 │ │ 弹性 │ │ │ │ -│ │ │ └─────────┘ └────────┘ └────────┘ └──────────┘ │ │ │ +│ │ │ Thin Kernel(确定性薄内核) │ │ │ +│ │ │ slug records worktree tdd review release │ │ │ +│ │ │ context profile session-index permission │ │ │ │ │ └───────────────────────────────────────────────────┘ │ │ │ └───────────────────────────────────────────────────────┘ │ │ │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ Agent 团队 (5 agents + 1 goal-verify) │ │ -│ │ @dev-lifecycle → @architect → @developer │ │ +│ │ @dev-lifecycle → @architect → @developer │ │ │ │ → @reviewer → @goal-verify │ │ │ └───────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ @@ -41,7 +39,7 @@ TypeScript 薄层,职责: - **插件入口** (`index.ts`) — 导出 `{ id, server }` - **包路径解析** (`plugin.ts`) — 解析 npm 包根路径 -- **主工厂** (`plugin/server.ts`) — 创建 `Plugin` 接口,注入配置、注册事件、暴露 tool +- **主工厂** (`plugin/server.ts`) — 创建 `Plugin` 接口,注入配置、注册事件、暴露工具 ### 2. 加载器层 (`src/plugin/`) @@ -49,91 +47,74 @@ TypeScript 薄层,职责: | 加载器 | 职责 | |--------|------| -| `commands.ts` | 解析 markdown frontmatter,注册 9 个 slash command | -| `skills.ts` | 复制 skills 到临时目录,替换路径引用 | +| `commands.ts` | 解析 markdown frontmatter,注册 7 个 slash command | +| `skills.ts` | 复制 skills 到固定目录,替换路径引用 | | `agents.ts` | 解析 YAML frontmatter,注册 agent | | `prompts.ts` | 两级加载(项目 > 内置),提供 prompt 内容 | | `bootstrap.ts` | 加载启动引导 system prompt | -| `goal.ts` | Goal 状态机 + continuation 管理 | +| `goal.ts` | 最小 Goal 状态机(parentIssueNumber/status/continuationCount)+ continuation 管理 | -### 3. FlowRun 引擎 (`src/flowrun/`) +### 3. Thin Kernel(确定性薄内核,`src/kernel/`) -FlowRun 已实现完整的阶段/任务/Gate/Checkpoint 状态机,经 Spike 验证(2026-07-15),阶段性接入中。当前编排由 Goal 系统 + Agent 协作驱动,FlowRun 逐步接入 Continuation 状态摘要和 PR 合并路径验证。 +对模型不应自由决定的高风险流程规则实施确定性约束(ADR 0002): | 模块 | 职责 | |------|------| -| `types.ts` | 类型系统:FlowRun/Stage/Task/Checkpoint | -| `github.ts` | Issue CRUD + 标签管理 + 乐观锁 | -| `gate.ts` | 阶段/任务准入准出检查 | -| `merge.ts` | PR 合并 + 分支保护 + 回滚 | -| `transitions.ts` | 状态迁移函数(flowRunStart、flowRunFinalize 等) | -| `validator.ts` | JSON Schema 校验 | -| `resilience.ts` | 运行时检查 + 背压检测 | -| `audit.ts` | 审计评论发布 | +| `slug.ts` | Functional Slug 派生与校验(拒绝泛化名/非法格式) | +| `records.ts` | Flow/Task Record CRUD(GitHub Issues 权威源)+ evidence 受控 comment | +| `worktree.ts` | worktree 生命周期(创建/复用校验/销毁 preflight,无 --force) | +| `tdd/` | Runtime TDD 纯函数(state/adapter/digest/evaluator/evidence) | +| `review.ts` | 审查与合并门禁(CI/分支保护/风险双层/非作者 approval) | +| `release.ts` | 版本聚合/分类/提议 + tag 不可变校验(技术栈无关) | +| `context.ts` | 根 CONTEXT.md 发现/摘要/mtime 缓存刷新 | +| `profile.ts` | AGENTS.md Project Profile 区块解析 | +| `session-index.ts` | parentIssue→session 轻量索引 + 双 session 拒绝/takeover | +| `caller.ts` | 工具调用者校验原语(角色矩阵 + op 覆盖) | +| `permission.ts` | OpenCode permission 匹配语义(最后匹配优先、deny 置尾) | +| `legacy.ts` | 旧 FlowRun cabinet 检测(不迁移只提示) | ## 核心概念 -### Goal(目标) +### Flow / Task Record(GitHub 权威源) -Goal 是 flow 状态的载体,存储在 session metadata 中: +- 一个 **Flow** = 一个 GitHub Parent Issue(Flow Record),包含目标、验收摘要与阶段 checklist +- 一个 **Task** = 一个 GitHub Sub Issue(Task Record),包含验收标准、依赖、TDD 上下文块 +- PR 关联 Sub Issue,CI Checks 关联 PR;三者共同构成工程进度权威事实 +- **不持久化** FlowRun cabinet 或其他 JSON 状态——进度从 GitHub 实时推导 -```typescript -interface GoalData { - objective: string // 一句话目标 - completionCriterion: string // 完成标准 - status: "active" | "paused" | "complete" - continuationCount: number // 自动 continuation 次数 -} -``` - -### FlowRun(流程运行) +### 5 个生命周期工具 -FlowRun 是完整的流程状态机,存储在 GitHub Issue body 中: +| 工具 | 职责 | +|------|------| +| `setup_control` | 探测/校验(development-ready / release-ready)/ 生成 workflow 草案 / 确认 Profile | +| `flow_control` | Flow 生命周期(create/status/planning/stage/complete/cancel/takeover)+ legacy 检测 | +| `task_control` | Task 生命周期(create/start/submit/submit-review/merge/cancel/destroy/status) | +| `tdd_checkpoint` | Runtime TDD 证据(RED/GREEN/回归,工具亲执,缺证据拒 PR) | +| `release_control` | 版本提议 / Release PR / 合并打 tag / workflow 监控 | -```typescript -interface FlowRun { - flowRunId: string - repo: string - parentIssueNumber: number - status: "planned" | "running" | "blocked" | "merging" | "completed" | "cancelled" - stages: Record - tasks: Record - // ... -} -``` +### Stage(阶段) -FlowRun 通过 JSON 块持久化在 Issue body 中: +5 个逻辑阶段,每个有明确目标与完成条件: ``` - -```json -{ ... } +requirements → design → tasks → code → review ``` - -``` - -### Stage(阶段) -7 个有序阶段,每个有准入/准出条件: +Release 是独立人工流程,不属于每个 Flow 的 Stage。 -``` -requirements → design → tasks → code → test → review → merge -``` +### 权限模型 -### Task(任务) +- 高风险 git/gh 写操作(worktree 创建/销毁、push、PR、merge、Issue 关闭)只走生命周期工具 +- 模型可直接执行只读 git/gh 与本地 edit/add/commit +- Agent permission:allow 只读白名单 + deny 写操作置尾(最后匹配优先) +- 工具内 caller 校验(角色矩阵)+ config 层工具布尔双重门禁 +- 复用宿主 `gh auth`,不建设第二套凭据 -DAG 任务图,支持依赖关系和并行执行: +### Project Context 注入 -```typescript -interface TaskState { - id: string - dependsOn: string[] // 依赖的任务 ID - area: "developer" | "common" - parallelSafe: boolean // 是否可并行 - prNumber: number | null // 关联 PR - // ... -} -``` +- 根 `CONTEXT.md` 是领域术语权威 +- 插件在 Primary 会话与子 Agent 首消息自动注入内容与 digest(mtime 缓存刷新) +- 术语经用户确认后即时更新;Context 只保存领域语言,不承载实现细节 ## 事件驱动 @@ -141,8 +122,8 @@ interface TaskState { | 事件 | 处理 | |------|------| -| `session.status (idle)` | 自动 continuation(最多 50 次) | -| `session.status (error)` | 错误恢复(重试 3→跳过 2→暂停) | +| `session.status (idle)` | 自动 continuation(进度驱动:有进展继续,停滞 3 次暂停) | +| `session.status (error)` | 错误恢复(工具级瞬时重试,无 generic skip) | | `session.error` | 记录 abort session | | `message.updated (user)` | 重置 continuation 计数 | | `session.updated` | 清理已完成 session | @@ -151,7 +132,7 @@ interface TaskState { ### 自动 continuation -Plugin 在 AI idle 时自动注入 continuation prompt: +Plugin 在 AI idle 时自动注入 continuation prompt(含 Project Context 摘要): ``` Continue working toward the active goal. @@ -163,40 +144,28 @@ Continue working toward the active goal. ### 错误恢复 -``` -错误次数 0-2: 重试(不同方法) -错误次数 3-4: 跳过(继续剩余工作) -错误次数 5+: 暂停,等待用户介入 -``` +- Task 失败自动重试最多 3 次,仍失败标记 blocked 并停止下游;其他独立 Tasks 继续 +- Review 自动修复最多 3 轮 +- 连续 3 次 continuation 无可验证进展才暂停请求人工介入 ### 自动恢复 -插件重启时自动恢复上次未完成的 session: +插件重启时通过 session-index 自动恢复上次未完成的 session: ``` [auto-resume] Plugin restarted. Resuming previous goal: -Goal: ... +Flow # Status: active Continue working. ``` -### 背压检测 - -每次操作前检查: - -- GitHub API rate limit(< 100 则暂停) -- CI 队列长度(>= 10 则暂停) - -### 乐观锁 - -FlowRun 写入时检测 Issue body 是否被其他进程修改,防止并发冲突。 - ## 安全性 -- 子 agent 禁止调用 `goal({op:"create"|"pause"|"resume"|"cancel"})` — 生命周期操作限制在主 session -- 只有 `@goal-verify` 可以调用 `goal({op:"complete"})` — 防止过早完成 -- `goal({op:"complete"})` 内部验证 FlowRun 终态:如绑定 FlowRunRef,要求 FlowRun 必须先由 `flow_control({op:"run-finalize"})` 完成终态绑定 -- Agent 工具权限由 frontmatter 控制(reviewer 只读,developer 本地读写) +- 子 agent 禁止调用 Flow 生命周期写操作(caller 矩阵:developer 仅 tdd_checkpoint、architect 仅 status 只读、reviewer 无工具) +- 只有 `@goal-verify` 可以完成 Flow(`flow_control{complete-flow}`) +- TDD 硬门禁:缺 evidence 的 PR 被拒绝创建 +- 合并门禁:CI checks + 分支保护 + 风险双层判定(只升不降)+ 高风险需非作者人类 approval +- worktree 销毁 preflight:PR 合并 + 干净才自动;脏目录拒绝;无 `--force` ## 依赖关系 @@ -209,4 +178,4 @@ FlowRun 写入时检测 Issue body 是否被其他进程修改,防止并发冲 外部依赖: - `gh` CLI — GitHub 操作(Issues、PRs、CI、Releases) -- `npm` — 发布流程 +- 项目自身的 GitHub Actions release workflow — 实际发布(技术栈无关,不假设 npm) diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 8bda5f6..24a5ab8 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -44,33 +44,29 @@ ## 插件自动注入的配置 -### 9 个 Slash Command +### 7 个 Slash Command | 命令 | 触发 agent | 说明 | |------|-----------|------| -| `/setup` | — | 初始化:检测 gh CLI、配置 GitHub、创建 docs/ | -| `/requirements` | — | 需求访谈 → PRD → GitHub Issue | -| `/design` | `@architect` | 技术方案 + ADR | +| `/setup` | — | 初始化:检测 gh CLI、Project Profile、CI/release workflow | +| `/requirements` | — | 需求访谈 → PRD → Draft Parent Issue | +| `/design` | `@architect` | 技术方案 + 必要 ADR(planning worktree) | | `/tasks` | `@architect` | DAG 任务拆解 → Sub Issues | -| `/code` | — | 分支 → 编码 + 单测 → PR | -| `/test` | — | 触发 CI → 监控 → 汇报 | +| `/code` | — | Task 编码 + Runtime TDD → PR | | `/review` | `@reviewer` | 双轴审查 → 自动合并 | -| `/release` | — | 版本 → Changelog → Release → npm publish | -| `/handoff` | — | 打包上下文,跨会话传递 | +| `/release` | — | 版本提议 → Release PR → tag push → workflow 监控 | -每个 command 对应一个 `flow-*` skill,定义在 `assets/skills/` 目录中。 +每个 command 对应一个 `flow-*` skill,定义在 `assets/skills/` 目录中;所有命令共享单一 Stage contract(`assets/prompts/stage-contract.md`)。 ### 5 个 Agent | Agent | 模式 | 角色 | |-------|------|------| | `@dev-lifecycle` | primary | 全流程编排器,自动串联各阶段 | -| `@architect` | subagent | 架构设计、技术方案、DAG 拆解 | -| `@developer` | subagent | 技术栈无关代码 TDD 实现 | +| `@architect` | subagent | 架构设计、技术方案、DAG 拆解(仅 planning worktree 文档可写) | +| `@developer` | subagent | 技术栈无关代码 TDD 实现(仅 worktree 内可写) | | `@reviewer` | subagent | 只读代码审查,输出结构化报告 | -| `@goal-verify` | subagent | 独立验证 Goal 完成状态(唯一可调用 `goal({op:"complete"})`) | - -另有内置 Agent:`@architect`、`@developer`。 +| `@goal-verify` | subagent | 独立验证 Flow 完成状态(唯一可调用 `flow_control{complete-flow}`) | ### 9 个 Flow Skills @@ -81,41 +77,35 @@ | `flow-design` | `/design` | 技术设计 | | `flow-tasks` | `/tasks` | 任务拆解 | | `flow-code` | `/code` | 编码实现 | -| `flow-test` | `/test` | CI 测试 | +| `flow-tdd` | — | TDD 协议唯一来源(tdd_checkpoint op 映射) | | `flow-review` | `/review` | 代码审查 | | `flow-release` | `/release` | 发布 | -| `flow-handoff` | `/handoff` | 上下文交接 | - -Skills 被复制到系统临时目录(`/tmp/opencode-cabbage-skills-*`)中运行,不会污染项目目录。 -## FlowRun 引擎配置 +Skills 被复制到固定目录(`~/.config/opencode/cabbage/skills`)中运行,不会污染项目目录。 -FlowRun 是插件的自动编排引擎,状态存储在 GitHub Issue body 中。经 Spike 验证(2026-07-15),阶段性接入中。 +## Thin Kernel 工具 -### FlowRun 状态 +### 5 个生命周期工具 -``` -planned → running → blocked/merging → completed/cancelled -``` - -### `flow_control` 操作 - -| 操作 | 说明 | +| 工具 | 职责 | |------|------| -| `run-start` | 启动 FlowRun: planned → running,并绑定 Goal | -| `stage-start` | 启动阶段(requirements/design/tasks/code) | -| `stage-complete` | 完成阶段(requirements/design/tasks),验证所有 checkpoints | -| `task-start` | 启动任务(pending/ready → running),冻结 TDD policy | -| `run-finalize` | **终态绑定**:所有 Task merged 后,顺序标记 code→test→review→merge 为 pass,设置 FlowRun 为 completed。幂等。 | +| `setup_control` | probe / generate-workflows / confirm-profile(readiness 报告) | +| `flow_control` | create-flow / status / planning-start / planning-pr / stage / complete-flow / cancel / takeover | +| `task_control` | create-task / start-task / submit-task / submit-review / merge-task / cancel / destroy / status | +| `tdd_checkpoint` | cycle-start / red / green / final-regression / final-verification / abandon / status / not-applicable / exempt | +| `release_control` | propose-version / open-release-pr / merge-release-pr / monitor | -### 7 个阶段 +### 5 个 Stage -`requirements → design → tasks → code → test → review → merge` +`requirements → design → tasks → code → review` -每个阶段有准入/准出检查: +每个阶段有前置门禁(由工具校验): -- **准入**:前序阶段必须完成(status: pass) -- **准出**:所有 checkpoints 通过、required artifacts 就绪 +- **requirements**:需求基线须用户确认一次 +- **design**:产出技术方案 + 必要 ADR;高风险方案在 design→tasks 间暂停确认 +- **tasks**:Planning Baseline 合并后才能创建 Sub Issues +- **code**:Planning Baseline 已合并、依赖 Task 已 merged、并行 < 5 +- **review**:CI checks + 分支保护 + 风险双层判定 + 高风险非作者 approval ### Task DAG @@ -132,26 +122,26 @@ task-4: dependsOn: [task-2, task-3] # 依赖前两者都完成 ``` -### PR 合并检查点 +### 合并门禁 -每个 PR 必须通过 6 个检查点才能自动合并: +PR 合并前必须通过: | 检查点 | 说明 | |--------|------| -| localChecks | 本地代码检查(类型检查、lint、测试) | -| ciChecks | CI 流水线通过 | -| reviewerApproval | reviewer 审查通过 | -| goalVerification | goal-verify 验证通过 | -| branchProtection | main 分支保护规则满足 | -| mergeResult | 合并操作成功 | +| CI checks | 全部 SUCCESS(statusCheckRollup) | +| branchProtection | main 分支保护规则存在 | +| 风险双层判定 | 模型初判 + 内核按 diff 升级(只升不降) | +| 高风险 approval | 非作者人类 APPROVED review(高风险任务) | +| mergeResult | 合并操作成功(--match-head-commit) | ### 弹性配置 | 参数 | 默认值 | 说明 | |------|--------|------| -| maxRuntime | 86400000 (24h) | FlowRun 最大运行时间 | -| continuationCount | 50 | 自动 continuation 最大次数 | -| errorRetryCount | 3 → skip, 5 → pause | 错误重试策略 | +| 并行上限 | 5 | 同时运行的 Task 数 | +| Task 重试 | 3 | 失败自动重试次数 | +| Review 轮次 | 3 | 自动修复上限 | +| 停滞判定 | 3 | 连续无进展 continuation 次数后暂停 | | compactionThreshold | 20 | 每 20 次 continuation 自动 compact | ## 文档目录结构 @@ -164,14 +154,15 @@ docs/ ├── adr/ # 架构决策记录 ├── dev/ │ ├── specs/ # 技术方案 -│ ├── tasks/ # 任务定义 +│ ├── tasks/ # 任务定义(历史归档) │ ├── api/ # API 文档 │ ├── db/ # 数据库设计 -│ ├── guides/ # 开发指南 -│ └── handoff/ # 交接文档 +│ └── guides/ # 开发指南 └── index.md # 本站首页 ``` +工程状态以 GitHub Issues/PRs/Checks 为权威事实,`docs/dev/tasks/` 仅保留历史任务记录。 + ## 环境要求 | 工具 | 版本要求 | 用途 | diff --git a/docs/guides/usage.md b/docs/guides/usage.md index b138cd9..2fa2945 100644 --- a/docs/guides/usage.md +++ b/docs/guides/usage.md @@ -11,7 +11,7 @@ 按顺序逐一执行命令,适合需要精细控制的场景: ``` -/setup → /requirements → /design → /tasks → /code → /test → /review → /release +/setup → /requirements → /design → /tasks → /code → /review → /release ``` ### 自动模式 @@ -30,9 +30,12 @@ - gh CLI 是否安装并可执行 - GitHub 远程仓库是否配置 - 是否需要 GitHub CLI 认证 +- Project Profile(AGENTS.md)与 CI/release workflow **产出**: -- `docs/prd/`、`docs/adr/`、`docs/dev/{specs,tasks,api,db,guides,handoff}/` 目录 +- `docs/prd/`、`docs/adr/`、`docs/dev/{specs,tasks,api,db,guides}/` 目录 +- 根 `AGENTS.md` 的 Project Profile 区块 +- 缺失的 CI / release workflow 草案 **何时执行**:第一次使用插件时,或切换到一个新项目时。 @@ -145,22 +148,6 @@ acceptance: 用户可以通过邮箱+密码注册,收到验证邮件 --- -### `/test` — CI 测试 - -**用途**:触发 CI 流水线并监控结果。 - -**流程**: -1. 在已创建的 PR 上触发 CI -2. 监控 CI 运行状态 -3. 汇报测试结果 - -**监控内容**: -- CI 队列长度(背压检测) -- CI 运行状态 -- 测试通过/失败情况 - ---- - ### `/review` — 代码审查 **用途**:AI 双轴审查 PR 并自动合并。 @@ -195,22 +182,15 @@ acceptance: 用户可以通过邮箱+密码注册,收到验证邮件 **⚠️ 该阶段为手动触发**,不会在自动模式中执行。 -**流程**: -1. 版本号更新(`npm version patch|minor|major`) -2. 生成 Changelog -3. 创建 GitHub Release -4. npm publish - ---- - -### `/handoff` — 上下文交接 +**流程**(技术栈无关,按 Profile 的 Release 规则): +1. 调用 `release_control{propose-version}` 聚合上一 tag 后 main 的变更并提议 SemVer 版本(用户确认) +2. 调用 `release_control{open-release-pr}` 创建 Release PR(更新版本文件 + Release Notes) +3. CI 通过 + 人工批准后调用 `release_control{merge-release-pr}` 合并并打 tag +4. 调用 `release_control{monitor}` 监控项目 GitHub Actions release workflow,成功后发布完成 -**用途**:当上下文窗口压力过大或需要跨会话传递进度时使用。 +实际发布由项目自己的 GitHub Actions release workflow 执行,不假设 npm 或其他生态。 -**流程**: -1. 打包当前 FlowRun 状态 -2. 输出到 `docs/dev/handoff/` -3. 下次会话可读取恢复 +--- --- @@ -300,10 +280,4 @@ test: 添加用户模块单元测试 ### 版本发布 -```bash -npm version patch # bugfix -npm version minor # 新功能(向后兼容) -npm version major # 破坏性变更 -git push origin main --tags -npm publish -``` +发布遵循技术栈无关的 Release 流程(详见 `/release` 章节):按 Profile 的版本规则更新版本文件、创建 Release PR、人工批准后合并并打 tag,由项目自己的 GitHub Actions release workflow 完成实际发布。 diff --git a/src/flowrun/adapter.ts b/src/flowrun/adapter.ts deleted file mode 100644 index cbbb756..0000000 --- a/src/flowrun/adapter.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { createHash } from "node:crypto" -import { spawn } from "node:child_process" -import type { TddRunnerPolicy, TddCommandEvidence, TddFailureKind, VersionedDigest } from "./types.js" - -// ─── 类型 ─── - -export interface VitestOutput { - exitCode: number | null - testsCollected: number | null - testsFailed: number | null - stdout: string - stderr: string - timedOut: boolean -} - -/** classifyVitestFailure 的输入 */ -export interface FailureInput { - exitCode: number | null - testsCollected: number | null - testsFailed: number | null - stderr: string - timedOut?: boolean -} - -// ─── Selector 校验 ─── - -/** 危险字符正则 */ -const DANGEROUS_SELECTOR = /[|;&$()`\x00-\x08\x0b\x0c\x0e-\x1f\r\n]/ - -/** - * 校验 test selector 语法安全。 - * 拒绝 shell 元字符、命令替换、换行等。 - */ -export function validateSelector(selector: string): void { - if (!selector || !selector.trim()) { - throw new Error("Selector must not be empty") - } - if (DANGEROUS_SELECTOR.test(selector)) { - throw new Error(`Selector contains invalid or dangerous characters: ${JSON.stringify(selector)}`) - } -} - -// ─── 命令构建 ─── - -/** - * 将 baseCommand 字符串解析为 argv 数组并附加 selector。 - */ -export function buildVitestArgs(policy: TddRunnerPolicy, selector: string): string[] { - const args = policy.baseCommand.trim().split(/\s+/).filter(Boolean) - args.push(selector) - return args -} - -// ─── 进程执行 ─── - -/** - * 执行 vitest 命令并捕获输出。 - */ -export function executeVitest(args: string[], cwd: string, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - const child = spawn(args[0], args.slice(1), { - cwd, - env: { ...process.env }, - stdio: ["ignore", "pipe", "pipe"], - timeout: timeoutMs, - }) - - let stdout = "" - let stderr = "" - let timedOut = false - - child.stdout?.on("data", (chunk: Buffer) => { - stdout += chunk.toString("utf-8") - }) - - child.stderr?.on("data", (chunk: Buffer) => { - stderr += chunk.toString("utf-8") - }) - - child.on("error", (err: NodeJS.ErrnoException) => { - // spawn 本身失败(如找不到命令) - if (err.code === "ENOENT") { - resolve({ - exitCode: 127, - testsCollected: null, - testsFailed: null, - stdout, - stderr: `${stderr}\nCommand not found: ${args[0]}`, - timedOut: false, - }) - } else { - reject(err) - } - }) - - child.on("close", (code: number | null, signal: string | null) => { - timedOut = signal === "SIGTERM" || signal === "SIGKILL" - resolve({ - exitCode: code, - testsCollected: null, // will be enriched later - testsFailed: null, - stdout, - stderr, - timedOut, - }) - }) - }) -} - -// ─── 输出解析 ─── - -/** - * 从 vitest stdout 解析 JSON reporter 输出,提取测试统计数据。 - */ -function parseVitestJson(stdout: string): { testsCollected: number; testsFailed: number } | null { - // 在 stdout 中查找 vitest JSON reporter 的输出(通常是一个 JSON 对象) - // 支持多行 JSON 输出,查找以 "numTotalTests" 为特征的 JSON - const jsonMatch = stdout.match(/\{[\s\S]*"numTotalTests"\s*:\s*\d+[\s\S]*\}/) - if (!jsonMatch) return null - - try { - const json = JSON.parse(jsonMatch[0]) - const collected = typeof json.numTotalTests === "number" ? json.numTotalTests : null - const failed = typeof json.numFailedTests === "number" ? json.numFailedTests : null - if (collected === null || failed === null) return null - return { testsCollected: collected, testsFailed: failed } - } catch { - return null - } -} - -/** - * 从 stderr 解析 vitest 错误,提取测试计数(兜底解析)。 - */ -function parseVitestSummary(stderr: string): { testsCollected: number | null; testsFailed: number | null } { - // vitest verbose 输出: "Tests 2 passed (2) | 1 failed (1)" - // 或 "Tests 2 passed (2)" - const summaryMatch = stderr.match(/Tests\s+\d+\s+failed\s*\((\d+)\)/) - const totalMatch = stderr.match(/Tests\s+\d+\s+failed[\s\S]*?\|\s*(\d+)\s+passed/) - const passedMatch = stderr.match(/(\d+)\s+passed/) - - let testsCollected: number | null = null - let testsFailed: number | null = null - - if (summaryMatch) { - testsFailed = parseInt(summaryMatch[1], 10) - } else { - // 检查是否有 "No test files found" 或 "0 tests" - if (stderr.includes("No test files found") || stderr.includes("no tests found")) { - return { testsCollected: 0, testsFailed: 0 } - } - } - - // 尝试计算 collected = passed + failed - const totalPassedMatch = stderr.match(/(\d+)\s+passed/) - if (totalPassedMatch && testsFailed !== null) { - testsCollected = parseInt(totalPassedMatch[1], 10) + testsFailed - } else if (testsFailed === null) { - // 可能全部通过 - const allPassedMatch = stderr.match(/Tests\s+(\d+)\s+passed/) - if (allPassedMatch) { - testsCollected = parseInt(allPassedMatch[1], 10) - testsFailed = 0 - } - } - - return { testsCollected, testsFailed } -} - -// ─── 失败分类 ─── - -/** - * 根据 vitest 输出分类失败类型。 - * - * 规则: - * - timeout → "timeout" - * - 成功启动 + 收集测试 + assertion 失败 → "assertion"(RED) - * - 成功启动 + 目标代码不存在 → "missing-behavior"(RED) - * - config/transform/dep 错误 → "infrastructure" - * - 零测试收集 → "infrastructure" - */ -export function classifyVitestFailure(input: FailureInput): TddFailureKind | null { - // timeout - if (input.timedOut) return "timeout" - - // 测试全部通过 - if (input.exitCode === 0 && input.testsFailed === 0) return null - - // 退出码正常但无法判断 - if (input.exitCode === 0 && input.testsFailed === null) return null - - // 失败的退出码 - if (input.exitCode !== null && input.exitCode !== 0) { - const stderr = input.stderr - - // ── 基础设施错误 ── - - // 配置加载失败 - if (/failed to load config/i.test(stderr) || - /cannot find module.*vitest/i.test(stderr) || - /cannot find package.*vitest/i.test(stderr)) { - return "infrastructure" - } - - // Transform 错误 - if (/transform failed/i.test(stderr) || - /unexpected token/i.test(stderr)) { - return "infrastructure" - } - - // 依赖缺失(非实现代码的模块) - if (/cannot find module/i.test(stderr)) { - // 区分:如果缺失的模块匹配实现文件路径模式,则是 missing-behavior - // 否则是 infrastructure(缺少外部依赖) - const moduleMatch = stderr.match(/cannot find module\s+['"]([^'"]+)['"]/i) - if (moduleMatch) { - const missingModule = moduleMatch[1] - // 相对路径导入 → missing-behavior(目标代码不存在) - if (missingModule.startsWith(".") || missingModule.startsWith("/")) { - return "missing-behavior" - } - // 绝对包名 → infrastructure(缺少依赖) - return "infrastructure" - } - // 无法判断是什么模块,默认为 infrastructure - return "infrastructure" - } - - // 零测试收集 - if (input.testsCollected === 0) return "infrastructure" - - // ── assertion 失败 ── - // 需要 testsCollected > 0 AND testsFailed > 0 才算是 assertion - if (input.testsCollected !== null && input.testsCollected > 0 && - input.testsFailed !== null && input.testsFailed > 0) { - return "assertion" - } - - // ── 有测试收集但 testsFailed 为 0/null 且 exitCode 非零 ── 无法确定失败原因 - if (input.testsCollected !== null && input.testsCollected > 0) { - return "unknown" - } - - // 无法分类 - return "unknown" - } - - // 无退出码(被 kill 等) - if (input.exitCode === null && input.testsCollected !== null && input.testsCollected === 0) { - return "infrastructure" - } - - return "unknown" -} - -// ─── SHA-256 辅助 ─── - -function sha256Hex(content: string): string { - return createHash("sha256").update(content, "utf-8").digest("hex") -} - -// ─── 主函数 ─── - -/** - * 执行 RED check:运行 vitest focused test,返回 TddCommandEvidence。 - * - * @param policy - TDD runner policy(含 baseCommand、timeoutMs 等) - * @param selector - focused test selector(文件路径或 vitest flag) - * @param cwd - 工作目录 - */ -export async function executeRedCheck( - policy: TddRunnerPolicy, - selector: string, - cwd: string, -): Promise { - // 1. 校验 selector - validateSelector(selector) - - // 2. 构建命令 - const args = buildVitestArgs(policy, selector) - const commandStr = args.join(" ") - - // 3. 执行 - const startedAt = new Date().toISOString() - const startMs = Date.now() - const output = await executeVitest(args, cwd, policy.timeoutMs) - const durationMs = Date.now() - startMs - const finishedAt = new Date().toISOString() - - // 4. 解析 vitest 输出获取测试统计数据 - const jsonStats = parseVitestJson(output.stdout) - const summaryStats = parseVitestSummary(output.stderr) - - const testsCollected = jsonStats?.testsCollected ?? summaryStats.testsCollected ?? null - const testsFailed = jsonStats?.testsFailed ?? summaryStats.testsFailed ?? null - - // 5. 分类失败 - const failureKind = classifyVitestFailure({ - exitCode: output.exitCode, - testsCollected, - testsFailed, - stderr: output.stderr, - timedOut: output.timedOut, - }) - - // 6. 计算 outputDigest(stdout + stderr 的 SHA-256) - const outputDigest: VersionedDigest = { - algorithm: "sha256-output-v1", - value: sha256Hex(output.stdout + "\n" + output.stderr), - } - - // 7. workspaceDigest 和 executionInputDigest 留空(由调用方/broker 填充) - const placeholderDigest: VersionedDigest = { - algorithm: "sha256-content-v1", - value: "0".repeat(64), - } - - // 8. 构建 summary - let summary: string - if (output.timedOut) { - summary = `Test execution timed out after ${policy.timeoutMs}ms` - } else if (failureKind === "assertion") { - summary = `${testsFailed}/${testsCollected} tests failed (assertion)` - } else if (failureKind === "missing-behavior") { - summary = `Target code not found: ${selector}` - } else if (failureKind === "infrastructure") { - summary = `Infrastructure error: ${output.stderr.slice(0, 200)}` - } else if (output.exitCode === 0) { - summary = `${testsCollected ?? "?"} tests passed` - } else { - summary = `Exit code ${output.exitCode}: ${output.stderr.slice(0, 200)}` - } - - return { - command: commandStr, - testSelector: selector, - exitCode: output.exitCode, - failureKind, - testsCollected, - testsFailed, - startedAt, - finishedAt, - durationMs, - changedFiles: [], - outputDigest, - workspaceDigest: placeholderDigest, - executionInputDigest: placeholderDigest, - summary, - } -} diff --git a/src/flowrun/audit.ts b/src/flowrun/audit.ts deleted file mode 100644 index 4839939..0000000 --- a/src/flowrun/audit.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { FlowRun, FlowStage, TaskState } from "./types.js" -import { gh } from "../util/gh.js" -import { escapeShellArg } from "../util/shell.js" - -export async function postAuditComment(issueNumber: number, body: string): Promise { - try { - const escaped = escapeShellArg(body) - await gh(`issue comment ${issueNumber} --body '${escaped}'`) - return true - } catch { - return false - } -} - -export async function postPRComment(prNumber: number, body: string): Promise { - try { - const escaped = escapeShellArg(body) - await gh(`pr comment ${prNumber} --body '${escaped}'`) - return true - } catch { - return false - } -} - -export function buildStageAudit(flowRun: FlowRun, stage: FlowStage): string { - const s = flowRun.stages[stage] - if (!s) return `Stage "${stage}" not found` - - const lines: string[] = [ - `### Stage: ${stage}`, - `Status: ${s.status}`, - `Completed: ${s.completedAt ?? "not yet"}`, - ] - - if (s.checks.length > 0) { - lines.push("", "**Checks:**") - for (const check of s.checks) { - lines.push(`- ${check.name}: ${check.status}`) - for (const ev of check.evidence) { - lines.push(` - \`${ev.command}\` → exit ${ev.exitCode}: ${ev.summary}`) - } - } - } - - return lines.join("\n") -} - -export function buildMergeAudit(task: TaskState): string { - const lines: string[] = [ - `### Task: ${task.id}`, - `PR: #${task.prNumber}`, - `Status: ${task.status}`, - "", - "**Merge Checkpoints:**", - ] - - if (task.prCheckpoints) { - const cp = task.prCheckpoints - const gates: Record = { - localChecks: cp.localChecks.status, - ciChecks: cp.ciChecks.status, - reviewerApproval: cp.reviewerApproval.status, - goalVerification: cp.goalVerification.status, - branchProtection: cp.branchProtection.status, - mergeResult: cp.mergeResult.status, - } - for (const [name, status] of Object.entries(gates)) { - lines.push(`- ${name}: ${status}`) - } - } - - return lines.join("\n") -} diff --git a/src/flowrun/ci.ts b/src/flowrun/ci.ts deleted file mode 100644 index 227be58..0000000 --- a/src/flowrun/ci.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { createHash } from "node:crypto" -import type { - RepositoryQualityPolicy, TaskCommand, CoveragePolicy, -} from "./types.js" - -// ─── 类型 ─── - -/** 从 gh pr view --json statusCheckRollup 获取的 PR 检查结果 */ -export interface PRCheckResult { - context: string - state: "EXPECTED" | "ERROR" | "FAILURE" | "PENDING" | "SUCCESS" - app: { id: number; name: string } | null -} - -/** checkRequiredChecks 的返回结果 */ -export interface CICheckResult { - allPassed: boolean - missingContexts: string[] - failedContexts: string[] - pendingContexts: string[] - untrustedSources: Array<{ - context: string - expectedAppId: number - actualAppId: number | null - }> -} - -/** validateRequiredWorkflow 的返回结果 */ -export interface RequiredWorkflowValidation { - valid: boolean - errors: string[] -} - -// ─── checkRequiredChecks ─── - -/** - * 验证 PR 的状态检查是否满足 RepositoryQualityPolicy 中定义的 required checks。 - * - * 规则: - * - mode 为 "off" 时不校验,直接返回 allPassed: true - * - mode 为 "required" 时: - * 1. 每个 requiredChecks 中的 context 必须在 PR checks 中存在 - * 2. 状态必须为 SUCCESS - * 3. 发布该 check 的 GitHub App ID 必须与配置的 appId 匹配 - * (appId 为 0 时不校验来源) - */ -export function checkRequiredChecks( - policy: RepositoryQualityPolicy, - prChecks: PRCheckResult[], -): CICheckResult { - // mode off — 不增加额外约束 - if (policy.mode === "off") { - return { - allPassed: true, - missingContexts: [], - failedContexts: [], - pendingContexts: [], - untrustedSources: [], - } - } - - // mode required — 逐项验证 - const prCheckMap = new Map() - for (const c of prChecks) { - prCheckMap.set(c.context, c) - } - - const missingContexts: string[] = [] - const failedContexts: string[] = [] - const pendingContexts: string[] = [] - const untrustedSources: CICheckResult["untrustedSources"] = [] - - for (const required of policy.requiredChecks) { - const prCheck = prCheckMap.get(required.context) - - // 1. context 缺失 - if (!prCheck) { - missingContexts.push(required.context) - continue - } - - // 2. 状态检查 - if (prCheck.state === "SUCCESS") { - // 通过 — 继续检查来源 - } else if (prCheck.state === "PENDING" || prCheck.state === "EXPECTED") { - pendingContexts.push(required.context) - continue - } else { - // FAILURE, ERROR - failedContexts.push(required.context) - continue - } - - // 3. 来源验证(appId 为 0 时不校验) - if (required.appId !== 0) { - if (!prCheck.app || prCheck.app.id !== required.appId) { - untrustedSources.push({ - context: required.context, - expectedAppId: required.appId, - actualAppId: prCheck.app?.id ?? null, - }) - } - } - } - - const allPassed = - missingContexts.length === 0 && - failedContexts.length === 0 && - pendingContexts.length === 0 && - untrustedSources.length === 0 - - return { - allPassed, - missingContexts, - failedContexts, - pendingContexts, - untrustedSources, - } -} - -// ─── validateRequiredWorkflow ─── - -/** - * 验证 RepositoryQualityPolicy 的 requiredChecks 配置有效性。 - * - * 规则: - * - mode 为 "off" 时跳过所有校验 - * - mode 为 "required" 时: - * 1. requiredChecks 不能为空 - * 2. 每个 entry 必须有 workflowPath、workflowRef - * 3. 不允许重复的 context - */ -export function validateRequiredWorkflow( - policy: RepositoryQualityPolicy, -): RequiredWorkflowValidation { - // mode off — 无需校验 - if (policy.mode === "off") { - return { valid: true, errors: [] } - } - - const errors: string[] = [] - - // 1. requiredChecks 不能为空 - if (policy.requiredChecks.length === 0) { - errors.push("requiredChecks must not be empty when mode is 'required'") - return { valid: false, errors } - } - - // 2. 校验每个 entry - const seenContexts = new Set() - - for (let i = 0; i < policy.requiredChecks.length; i++) { - const entry = policy.requiredChecks[i] - const prefix = `requiredChecks[${i}]` - - // 检查 context 重复 - if (seenContexts.has(entry.context)) { - errors.push(`${prefix}: duplicate context "${entry.context}"`) - } - seenContexts.add(entry.context) - - // 检查 workflowPath - if (!entry.workflowPath) { - errors.push(`${prefix}: workflowPath must not be empty`) - } - - // 检查 workflowRef - if (!entry.workflowRef) { - errors.push(`${prefix}: workflowRef must not be empty`) - } - } - - return { valid: errors.length === 0, errors } -} - -// ─── checkBranchProtection ─── - -/** - * checkBranchProtection 委托给 merge.ts 中已有的实现。 - * 这里作为 re-export wrapper,保持 CI 模块的 API 统一性。 - */ - -export { checkBranchProtection } from "./merge.js" - -// ─── computeQualityContractDigest ─── - -/** - * JCS (JSON Canonicalization Scheme) 序列化: - * - 对象的 key 按 UTF-8 字节序排序 - * - 无多余空白 - * - 数字保留原始精度 - */ -function jcsStringify(value: unknown): string { - if (value === null) return "null" - if (typeof value === "boolean") return value ? "true" : "false" - if (typeof value === "number") { - if (Number.isFinite(value)) { - // 使用标准数字序列化 - return String(value) - } - return "null" - } - if (typeof value === "string") { - return JSON.stringify(value) - } - if (Array.isArray(value)) { - const items = value.map(jcsStringify) - return `[${items.join(",")}]` - } - if (typeof value === "object") { - // 按 key 排序 - const keys = Object.keys(value).sort((a, b) => { - const bufA = Buffer.from(a, "utf-8") - const bufB = Buffer.from(b, "utf-8") - return Buffer.compare(bufA, bufB) - }) - const pairs = keys.map(k => { - const v = (value as Record)[k] - return `${JSON.stringify(k)}:${jcsStringify(v)}` - }) - return `{${pairs.join(",")}}` - } - return "null" -} - -/** - * 计算质量契约摘要(Quality Contract Digest)。 - * - * 绑定 testCommands + verifyCommands + coveragePolicy, - * 使用 JCS (JSON Canonicalization Scheme) + SHA-256 生成确定性摘要。 - * - * 用于在 PR checkpoint 中记录期望的契约版本,确保 CI 执行时契约未被修改。 - */ -export function computeQualityContractDigest( - testCommands: TaskCommand[], - verifyCommands: TaskCommand[], - coveragePolicy: CoveragePolicy | null, -): string { - const contract = { - testCommands, - verifyCommands, - coveragePolicy, - } - - const json = jcsStringify(contract) - return createHash("sha256").update(json, "utf-8").digest("hex") -} diff --git a/src/flowrun/coverage.ts b/src/flowrun/coverage.ts deleted file mode 100644 index ac3bd16..0000000 --- a/src/flowrun/coverage.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { readFile, lstat, realpath } from "node:fs/promises" -import { resolve, isAbsolute, join, normalize } from "node:path" -import { createHash } from "node:crypto" -import type { CoverageEvidence } from "./types.js" - -// ─── 常量 ─── - -const DANGEROUS_PATH = /[\x00-\x1f\x7f]/ - -// ─── 路径安全校验 ─── - -/** - * 检验 coverage report 路径的安全性。 - * - * 规则: - * - 拒绝空路径 - * - 拒绝绝对路径 - * - 拒绝包含 null 字节等危险字符的路径 - * - 拒绝包含 `..` 路径遍历的路径 - * - * 注意:此函数仅检查路径结构,不处理 symlink。symlink 检查由 resolveCoveragePath 通过 realpath 完成。 - */ -export function validateCoveragePath(reportPath: string, sandboxRoot: string): void { - if (!reportPath || reportPath.trim() === "") { - throw new Error("Coverage report path is empty") - } - - if (DANGEROUS_PATH.test(reportPath)) { - throw new Error(`Coverage report path contains dangerous characters: ${reportPath}`) - } - - const normalized = normalize(reportPath) - - if (isAbsolute(normalized)) { - throw new Error("Coverage report path must be relative, got absolute path") - } - - // 拒绝路径穿越:以 .. 开头或包含 /../ - const segments = normalized.split("/") - if (segments.includes("..") || segments.includes("..\\")) { - throw new Error(`Coverage report path escapes sandbox: ${reportPath}`) - } - - // 拒绝 Windows 风格的路径穿越 - const winSegments = normalized.split("\\") - if (winSegments.includes("..")) { - throw new Error(`Coverage report path escapes sandbox: ${reportPath}`) - } -} - -/** - * 在 sandbox 内安全地解析 coverage report 路径。 - * - * 1. 先执行 validateCoveragePath 结构检查 - * 2. join sandbox + reportPath - * 3. resolve 后确保不逃逸 sandbox - * 4. (调用方负责)通过 realpath 检查 symlink 逃逸 - * - * 返回解析后的绝对路径。 - */ -export function resolveCoveragePath(reportPath: string, sandboxRoot: string): string { - // 步骤 1: 结构检查 - validateCoveragePath(reportPath, sandboxRoot) - - // 步骤 2: 拼接并解析 - const sandboxNorm = resolve(sandboxRoot) - const resolved = resolve(join(sandboxNorm, reportPath)) - - // 步骤 3: 确保未逃逸 sandbox - if (!resolved.startsWith(sandboxNorm + "/") && resolved !== sandboxNorm) { - throw new Error(`Coverage report path resolves outside sandbox: ${reportPath} → ${resolved}`) - } - - return resolved -} - -// ─── Coverage 报告解析 ─── - -interface RawCoverageSummary { - total?: { - lines?: { - total?: number - covered?: number - skipped?: number - pct?: unknown - } - [key: string]: unknown - } - [key: string]: unknown -} - -export interface ParsedCoverageData { - actual: number - metric: "lines" -} - -/** - * 解析 istanbul-json-summary 格式的 coverage 报告。 - * - * 抽取 `total.lines.pct`,执行严格的数值校验: - * - 拒绝缺少 `total.lines` 字段 - * - 拒绝 `pct` 为 non-number、NaN、`"Unknown"` 等非数值 - * - 拒绝 `pct` 超出 [0, 100] 范围 - * - * 返回 ParsedCoverageData 或 null(解析失败/数据无效)。 - */ -export async function parseCoverageReport( - reportPath: string, - metric: "lines", -): Promise { - let raw: string - try { - raw = await readFile(reportPath, "utf-8") - } catch { - return null - } - - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch { - return null - } - - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return null - } - - const report = parsed as RawCoverageSummary - - // 检查 total 对象 - const total = report.total - if (!total || typeof total !== "object") { - return null - } - - // 检查 lines 对象 - const lines = total.lines - if (!lines || typeof lines !== "object") { - return null - } - - // 提取并校验 pct - const pct = lines.pct - - // 拒绝 "Unknown" 字符串值 - if (typeof pct === "string") { - return null - } - - // 拒绝非 number 类型 - if (typeof pct !== "number") { - return null - } - - // 拒绝 NaN - if (isNaN(pct)) { - return null - } - - // 拒绝无穷大 - if (!isFinite(pct)) { - return null - } - - // 拒绝范围外数值 - if (pct < 0 || pct > 100) { - return null - } - - return { - actual: pct, - metric, - } -} - -// ─── Coverage Evidence 构建 ─── - -export interface BuildCoverageEvidenceInput { - actual: number | null - threshold: number - metric: "lines" - headSha: string - reportDigest: string | null -} - -/** - * 构建 CoverageEvidence。 - * - * - actual >= threshold → pass - * - actual < threshold → fail - * - actual 为 null → pending(报告解析失败或不存在) - */ -export function buildCoverageEvidence(input: BuildCoverageEvidenceInput): CoverageEvidence { - let status: CoverageEvidence["status"] - let summary: string - - if (input.actual === null) { - status = "pending" - summary = `Coverage report not available or could not be parsed for head ${input.headSha.slice(0, 7)}` - } else if (input.actual >= input.threshold) { - status = "pass" - summary = `Coverage ${input.actual}% meets threshold ${input.threshold}% (${input.metric}) at head ${input.headSha.slice(0, 7)}` - } else { - status = "fail" - summary = `Coverage ${input.actual}% below threshold ${input.threshold}% (${input.metric}) at head ${input.headSha.slice(0, 7)}` - } - - return { - status, - headSha: input.headSha, - actual: input.actual, - threshold: input.threshold, - metric: input.metric, - reportDigest: input.reportDigest, - summary, - } -} - -/** - * 简单的阈值校验(纯数字比较)。 - */ -export function checkCoverageThreshold(actual: number, threshold: number): boolean { - return actual >= threshold -} - -/** - * 对 coverage report 文件计算 digest。 - * 用于绑定 report 内容与 evidence。 - */ -export async function computeReportDigest(reportPath: string): Promise { - try { - const content = await readFile(reportPath) - const hash = createHash("sha256") - hash.update(content) - return hash.digest("hex") - } catch { - return null - } -} - -/** - * 检查 symlink 是否逃逸 sandbox。 - * 在 resolveCoveragePath 之后调用,对已解析路径做 realpath 检查。 - * - * 返回 safe absolute path,或抛出错误。 - */ -export async function verifySymlinkSafe( - resolvedPath: string, - sandboxRoot: string, -): Promise { - const sandboxNorm = resolve(sandboxRoot) - - let lstatResult - try { - lstatResult = await lstat(resolvedPath) - } catch { - // 文件不存在,由调用方处理 - return resolvedPath - } - - if (lstatResult.isSymbolicLink()) { - const real = await realpath(resolvedPath) - const realNorm = resolve(real) - if (!realNorm.startsWith(sandboxNorm + "/") && realNorm !== sandboxNorm) { - throw new Error(`Symlink escapes sandbox: ${resolvedPath} → ${realNorm}`) - } - return realNorm - } - - return resolvedPath -} diff --git a/src/flowrun/digest.ts b/src/flowrun/digest.ts deleted file mode 100644 index b877a85..0000000 --- a/src/flowrun/digest.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { createHash } from "node:crypto" -import { readFile, lstat, readdir, realpath } from "node:fs/promises" -import { join, relative, resolve, sep, isAbsolute } from "node:path" -import { execFileSync } from "node:child_process" -import type { VersionedDigest } from "./types.js" - -// ─── 类型 ─── - -export interface DigestOptions { - testFilePatterns: string[] - implementationFilePatterns: string[] - generatedArtifactPatterns: string[] -} - -// ─── 常量 ─── - -/** 始终忽略的目录 */ -const ALWAYS_IGNORED = new Set([ - ".git", - "node_modules", - "coverage", - "dist", - ".nyc_output", -]) - -/** 始终忽略的前缀(目录 + 分隔符) */ -function isAlwaysIgnored(relPath: string): boolean { - const parts = relPath.split(sep) - if (parts.length === 0) return false - const top = parts[0] - if (!top) return false - return ALWAYS_IGNORED.has(top) -} - -/** 危险字符正则:拒绝可能逃逸路径的字符 */ -const DANGEROUS_PATH = /[\x00-\x1f\x7f]/ - -// ─── Git 辅助 ─── - -function runGit(args: string[], cwd: string): string[] { - try { - const output = execFileSync("git", args, { - cwd, - encoding: "utf-8", - env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1" }, - maxBuffer: 10 * 1024 * 1024, - }) - return output.trim().split("\n").filter(Boolean) - } catch { - return [] - } -} - -/** 获取 Git 已跟踪的文件列表(repo-relative paths) */ -function getTrackedFiles(cwd: string): Set { - const lines = runGit(["ls-files", "--cached", "-z"], cwd) - if (lines.length === 0) return new Set() - // -z 输出的行以 null 分隔,但 execFileSync 返回时 null 被转为换行 - // 实际用 -z 时输出用 \0 分隔,这里按 \0 分割 - const raw = execFileSync("git", ["ls-files", "--cached", "-z"], { - cwd, encoding: "utf-8", - env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1" }, - maxBuffer: 10 * 1024 * 1024, - }) - return new Set(raw.split("\0").filter(Boolean)) -} - -/** 获取已跟踪但已删除的文件列表 */ -function getDeletedFiles(cwd: string): Set { - try { - const raw = execFileSync("git", ["ls-files", "--deleted", "-z"], { - cwd, encoding: "utf-8", - env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1" }, - maxBuffer: 10 * 1024 * 1024, - }) - return new Set(raw.split("\0").filter(Boolean)) - } catch { - return new Set() - } -} - -// ─── 模式匹配 ─── - -/** - * 简单的 glob 匹配(支持 **、*) - * 不做完整 micromatch,仅覆盖常见 pattern - */ -function matchPattern(filePath: string, pattern: string): boolean { - // 将路径分隔符统一为 / - const normalized = filePath.split(sep).join("/") - - // 构建正则 - let regexStr = "" - let i = 0 - while (i < pattern.length) { - if (pattern[i] === "*" && pattern[i + 1] === "*") { - // **:零或多个路径段 - i += 2 - // 跳过可选的 /(**/ 匹配零或多个目录) - if (i < pattern.length && pattern[i] === "/") { - regexStr += "(.*/)?" - i++ - } else { - // 独立的 **(末尾):匹配所有 - regexStr += ".*" - } - } else if (pattern[i] === "*") { - // *:匹配单段内的任意字符(除 /) - regexStr += "[^/]*" - i++ - } else if (pattern[i] === ".") { - regexStr += "\\." - i++ - } else if (pattern[i] === "?") { - regexStr += "[^/]" - i++ - } else { - // 转义正则特殊字符 - if ("+^$(){}[]|\\".includes(pattern[i])) { - regexStr += "\\" + pattern[i] - } else { - regexStr += pattern[i] - } - i++ - } - } - - const regex = new RegExp(`^${regexStr}$`) - return regex.test(normalized) -} - -function matchesAnyPattern(filePath: string, patterns: string[]): boolean { - return patterns.some(p => matchPattern(filePath, p)) -} - -// ─── 路径校验 ─── - -function validateRelPath(relPath: string): void { - // 拒绝包含危险字符的路径 - if (DANGEROUS_PATH.test(relPath)) { - throw new Error(`Dangerous characters in path: ${relPath}`) - } - // 拒绝以 .. 开头的路径 - const normalized = relPath.split(sep).join("/") - if (normalized.startsWith("..") || normalized.includes("/../")) { - throw new Error(`Path escapes worktree: ${relPath}`) - } - if (isAbsolute(relPath)) { - throw new Error(`Path must be relative: ${relPath}`) - } -} - -// ─── 文件扫描 ─── - -/** - * 递归列出目录下所有文件(repo-relative paths) - * 忽略 always-ignored 目录 - */ -async function scanDir(root: string, subDir: string = "", results: string[] = []): Promise { - const fullPath = subDir ? join(root, subDir) : root - let dirents - try { - dirents = await readdir(fullPath, { withFileTypes: true }) - } catch { - return results - } - - for (const entry of dirents) { - const relPath = subDir ? `${subDir}${sep}${entry.name}` : entry.name - const relPathSlash = relPath.split(sep).join("/") - - // 跳过 always-ignored 顶层目录 - if (isAlwaysIgnored(relPath)) continue - - if (entry.isDirectory()) { - await scanDir(root, relPath, results) - } else if (entry.isFile() || entry.isSymbolicLink()) { - results.push(relPathSlash) - } - } - - return results -} - -// ─── 获取文件信息 ─── - -interface FileEntry { - path: string - mode: number - content: Buffer | null // null = 删除标记 -} - -async function collectFileEntries( - root: string, - tracked: Set, - deleted: Set, - untrackedPaths: Set, - generatedPatterns: string[], -): Promise { - const entries: FileEntry[] = [] - - // 处理已跟踪文件 - for (const relPath of tracked) { - validateRelPath(relPath) - - // 跳过生成的 artifacts - if (matchesAnyPattern(relPath, generatedPatterns)) continue - // 跳过 always-ignored 目录 - if (isAlwaysIgnored(relPath)) continue - - // 已删除文件:使用删除标记 - if (deleted.has(relPath)) { - entries.push({ path: relPath, mode: 0, content: null }) - continue - } - - const absPath = join(root, relPath) - try { - const fileLstat = await lstat(absPath) - - // 检查 symlink 是否逃逸 - if (fileLstat.isSymbolicLink()) { - const resolved = await realpath(absPath) - const resolvedNorm = resolve(resolved) - const rootNorm = resolve(root) - if (!resolvedNorm.startsWith(rootNorm + sep) && resolvedNorm !== rootNorm) { - throw new Error(`Symlink escapes worktree: ${relPath} → ${resolved}`) - } - } - - if (fileLstat.isFile() || fileLstat.isSymbolicLink()) { - const content = await readFile(absPath) - // mode: 只保留权限位(低 9 位),用于区分可执行位 - const mode = fileLstat.mode & 0o777 - entries.push({ path: relPath, mode, content }) - } - // 目录、设备文件等跳过 - } catch (err: unknown) { - if (err instanceof Error && err.message.startsWith("Symlink escapes")) { - throw err - } - // 文件不存在(可能被外部删除),添加删除标记 - entries.push({ path: relPath, mode: 0, content: null }) - } - } - - // 处理 policy 范围内的 untracked 文件 - for (const relPath of untrackedPaths) { - validateRelPath(relPath) - - // 跳过已跟踪文件的重复 - if (tracked.has(relPath)) continue - // 跳过 always-ignored - if (isAlwaysIgnored(relPath)) continue - // 跳过生成的 artifacts - if (matchesAnyPattern(relPath, generatedPatterns)) continue - - const absPath = join(root, relPath) - try { - const fileLstat = await lstat(absPath) - - if (fileLstat.isSymbolicLink()) { - const resolved = await realpath(absPath) - const resolvedNorm = resolve(resolved) - const rootNorm = resolve(root) - if (!resolvedNorm.startsWith(rootNorm + sep) && resolvedNorm !== rootNorm) { - throw new Error(`Symlink escapes worktree: ${relPath} → ${resolved}`) - } - } - - if (fileLstat.isFile() || fileLstat.isSymbolicLink()) { - const content = await readFile(absPath) - const mode = fileLstat.mode & 0o777 - entries.push({ path: relPath, mode, content }) - } - } catch (err: unknown) { - if (err instanceof Error && err.message.startsWith("Symlink escapes")) { - throw err - } - // 无法读取的文件跳过 - } - } - - return entries -} - -// ─── 主函数 ─── - -/** - * 计算 worktree 的 Canonical Content Digest。 - * - * 规则: - * 1. repo-relative paths,按 UTF-8 字节序排序 - * 2. 纳入文件类型(含可执行位)、原始字节、删除标记 - * 3. 拒绝逃逸 worktree 的 symlink - * 4. 忽略 .git/、node_modules/、coverage/、dist/、policy 声明的 generated artifacts - * 5. tracked + policy 范围内 untracked 文件均纳入 - */ -export async function computeWorkspaceDigest( - root: string, - options: DigestOptions, -): Promise { - const rootNorm = resolve(root) - - // 1. 获取 Git 跟踪状态 - const tracked = getTrackedFiles(rootNorm) - const deleted = getDeletedFiles(rootNorm) - - // 2. 扫描文件系统获取 policy 范围内的 untracked 文件 - const allFiles = await scanDir(rootNorm) - const includedPatterns = [...options.testFilePatterns, ...options.implementationFilePatterns] - const untrackedPaths = new Set( - allFiles.filter(f => !tracked.has(f) && matchesAnyPattern(f, includedPatterns)) - ) - - // 3. 收集文件条目 - const entries = await collectFileEntries( - rootNorm, - tracked, - deleted, - untrackedPaths, - options.generatedArtifactPatterns, - ) - - // 4. 按 UTF-8 字节序排序 - entries.sort((a, b) => { - const bufA = Buffer.from(a.path, "utf-8") - const bufB = Buffer.from(b.path, "utf-8") - return Buffer.compare(bufA, bufB) - }) - - // 5. 构建 canonical 内容 - const hash = createHash("sha256") - for (const entry of entries) { - // path - hash.update(entry.path) - hash.update("\0") - // mode (8 进制字符串) - hash.update(entry.mode.toString(8)) - hash.update("\0") - // content 或删除标记 - if (entry.content === null) { - hash.update("\0DELETED\0") - } else { - hash.update(entry.content) - } - hash.update("\0") - } - - return { - algorithm: "sha256-content-v1", - value: hash.digest("hex"), - } -} diff --git a/src/flowrun/evaluator.ts b/src/flowrun/evaluator.ts deleted file mode 100644 index 2d79f09..0000000 --- a/src/flowrun/evaluator.ts +++ /dev/null @@ -1,258 +0,0 @@ -import type { - TddPolicy, - TddEvidence, - AcceptanceCriterion, -} from "./types.js" - -/** - * evaluateTddCompliance 的结果类型。 - * - * - status = pass → 合规(advisory 下缺口记 warning) - * - status = fail → 不合规,阻断 - * - status = waived → 豁免(bypass 模式下替代验证全部通过) - */ -export interface EvaluationResult { - status: "pass" | "fail" | "waived" - warnings: string[] -} - -/** - * 判定 TDD 模式是否合规的纯函数。 - * - * 规则(参见 Spec Section 4.3): - * - * **strict**: - * - 必须至少有一个 verification=tDD 的 criterion - * - 每个 TDD criterion 都必须有 status=pass 的 cycle 覆盖 - * - verification=regression 的 criterion 由 final regression 覆盖 - * - verification=manual 不允许在 strict 模式下使用 - * - final regression 和 final verification 必须 pass - * - * **relaxed**: - * - 仅要求 final regression 为 pass,不检查 cycle - * - final verification 必须 pass - * - * **bypass**: - * - 必须有 exception 且其 alternativeValidation 全部完成(evidence 中对应 status=pass) - * - final verification 仍必须 pass - * - 不要求 runner / cycle / regression - * - * **enforcement**: - * - advisory:缺失 evidence 仅产生 warning,不阻断(仍返回 pass/waived) - * - runtime:缺失 evidence 直接阻断(返回 fail) - */ -export function evaluateTddCompliance( - policy: TddPolicy, - evidence: TddEvidence, - criteria: AcceptanceCriterion[] -): EvaluationResult { - const warnings: string[] = [] - const isAdvisory = policy.enforcement === "advisory" - - // ── 通用检查:final verification ── - - const verificationOk = evidence.verification.status === "pass" - if (!verificationOk) { - if (isAdvisory) { - warnings.push("final verification 未通过(advisory 模式:仅记录 warning)") - } else { - warnings.push("final verification 未通过") - return { status: "fail", warnings } - } - } - - // ── bypass 模式 ── - - if (policy.mode === "bypass") { - return evaluateBypass(policy, evidence, isAdvisory, warnings, verificationOk) - } - - // ── strict 模式 ── - - if (policy.mode === "strict") { - return evaluateStrict(policy, evidence, criteria, isAdvisory, warnings, verificationOk) - } - - // ── relaxed 模式 ── - - return evaluateRelaxed(evidence, isAdvisory, warnings, verificationOk) -} - -// ── 私有辅助函数 ── - -function evaluateBypass( - policy: TddPolicy, - evidence: TddEvidence, - isAdvisory: boolean, - warnings: string[], - verificationOk: boolean -): EvaluationResult { - // bypass 必须有 exception - if (!policy.exception) { - if (isAdvisory) { - warnings.push("bypass 模式缺少 exception(advisory 模式:仅记录 warning)") - return { status: "waived", warnings } - } - warnings.push("bypass 模式缺少 exception") - return { status: "fail", warnings } - } - - // verification 失败时已在主函数中返回 fail,此处 verificationOk 保证为 true(或 advisory 已记 warning) - if (!verificationOk) { - // advisory 下 verification 失败已记 warning,继续检查替代验证 - // 统一返回 waived(advisory 不阻断) - if (isAdvisory) { - checkAltValidations(policy, evidence, isAdvisory, warnings) - return { status: "waived", warnings } - } - return { status: "fail", warnings } - } - - // 检查替代验证 - const altOk = checkAltValidations(policy, evidence, isAdvisory, warnings) - - if (isAdvisory) { - return { status: "waived", warnings } - } - - if (!altOk) { - return { status: "fail", warnings } - } - - return { status: "waived", warnings: warnings.filter(w => w.length > 0) } -} - -function evaluateStrict( - policy: TddPolicy, - evidence: TddEvidence, - criteria: AcceptanceCriterion[], - isAdvisory: boolean, - warnings: string[], - verificationOk: boolean -): EvaluationResult { - // verification 已前置检查,runtime 下失败则已在主函数返回 fail - if (!verificationOk && !isAdvisory) { - return { status: "fail", warnings } - } - - const tddCriteria = criteria.filter(c => c.verification === "tdd") - const regressionCriteria = criteria.filter(c => c.verification === "regression") - const manualCriteria = criteria.filter(c => c.verification === "manual") - - let hasFailure = false - - // strict 必须至少有一个 TDD criterion - if (tddCriteria.length === 0) { - if (isAdvisory) { - warnings.push("strict 模式要求至少一个 verification=tdd 的 criterion(advisory 模式:仅记录 warning)") - } else { - warnings.push("strict 模式要求至少一个 verification=tdd 的 criterion") - hasFailure = true - } - } - - // manual criterion 不允许在 strict 模式 - if (manualCriteria.length > 0) { - if (isAdvisory) { - warnings.push(`strict 模式不允许 verification=manual 的 criterion: ${manualCriteria.map(c => c.id).join(", ")}(advisory 模式:仅记录 warning)`) - } else { - warnings.push(`strict 模式不允许 verification=manual 的 criterion: ${manualCriteria.map(c => c.id).join(", ")}`) - hasFailure = true - } - } - - // 每个 TDD criterion 都需要有 pass cycle 覆盖 - for (const c of tddCriteria) { - const matchingCycles = evidence.cycles.filter(cycle => cycle.criterionId === c.id) - const hasPassCycle = matchingCycles.some(cycle => cycle.status === "pass") - - if (!hasPassCycle) { - if (isAdvisory) { - warnings.push(`TDD criterion "${c.id}" 缺少有效 cycle(advisory 模式:仅记录 warning)`) - } else { - warnings.push(`TDD criterion "${c.id}" 缺少有效 cycle`) - hasFailure = true - } - } - } - - // regression criterion 由 final regression 覆盖 - if (regressionCriteria.length > 0) { - const regressionOk = evidence.regression.status === "pass" - if (!regressionOk) { - if (isAdvisory) { - warnings.push("final regression 未通过,无法覆盖 regression criterion(advisory 模式:仅记录 warning)") - } else { - warnings.push("final regression 未通过,无法覆盖 regression criterion") - hasFailure = true - } - } - } - - if (isAdvisory) { - return { status: "pass", warnings } - } - - if (hasFailure) { - return { status: "fail", warnings } - } - - return { status: "pass", warnings } -} - -function evaluateRelaxed( - evidence: TddEvidence, - isAdvisory: boolean, - warnings: string[], - verificationOk: boolean -): EvaluationResult { - if (!verificationOk && !isAdvisory) { - return { status: "fail", warnings } - } - - const regressionOk = evidence.regression.status === "pass" - - if (!regressionOk) { - if (isAdvisory) { - warnings.push("relaxed 模式要求 final regression 通过(advisory 模式:仅记录 warning)") - return { status: "pass", warnings } - } - warnings.push("relaxed 模式要求 final regression 通过") - return { status: "fail", warnings } - } - - return { status: "pass", warnings } -} - -/** - * 检查 bypass 模式下所有 alternative validation 是否完成。 - * 返回 true 表示全部通过,false 表示有缺失或失败。 - */ -function checkAltValidations( - policy: TddPolicy, - evidence: TddEvidence, - isAdvisory: boolean, - warnings: string[] -): boolean { - if (!policy.exception) { - return false - } - - let allOk = true - - for (const av of policy.exception.alternativeValidation) { - const matchingEvidence = evidence.alternativeValidation.filter(ev => ev.validationId === av.validationId) - const hasPass = matchingEvidence.some(ev => ev.status === "pass") - - if (!hasPass) { - if (isAdvisory) { - warnings.push(`替代验证 "${av.validationId}" 未完成(advisory 模式:仅记录 warning)`) - } else { - warnings.push(`替代验证 "${av.validationId}" 未完成`) - allOk = false - } - } - } - - return allOk -} diff --git a/src/flowrun/gate.ts b/src/flowrun/gate.ts deleted file mode 100644 index b3a499b..0000000 --- a/src/flowrun/gate.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { - type FlowRun, type FlowStage, type TaskState, - FLOW_RUN_STAGES, TASK_STATUSES, -} from "./types.js" - -export interface GateResult { - allowed: boolean - reason?: string - missing: string[] - failedChecks: string[] -} - -function ok(): GateResult { - return { allowed: true, missing: [], failedChecks: [] } -} - -function block(reason: string, missing: string[] = [], failedChecks: string[] = []): GateResult { - return { allowed: false, reason, missing, failedChecks } -} - -export function canStartStage(flowRun: FlowRun, stage: FlowStage): GateResult { - const stageIdx = FLOW_RUN_STAGES.indexOf(stage) - if (stageIdx === -1) return block(`Unknown stage: ${stage}`) - - if (flowRun.status !== "running") { - return block(`FlowRun is ${flowRun.status}, not running`) - } - - const prevStages = FLOW_RUN_STAGES.slice(0, stageIdx) - for (const prev of prevStages) { - const s = flowRun.stages[prev] - if (s.status !== "pass") { - return block(`Prerequisite stage "${prev}" is not complete (status: ${s.status})`, [prev]) - } - } - - const stageState = flowRun.stages[stage] - if (!stageState) return block(`Stage state not found: ${stage}`) - - if (stageState.status === "pass") { - return block(`Stage "${stage}" already completed`) - } - - return ok() -} - -export function canCompleteStage(flowRun: FlowRun, stage: FlowStage): GateResult { - const stageState = flowRun.stages[stage] - if (!stageState) return block(`Stage state not found: ${stage}`) - - const missingArtifacts = stageState.requiredArtifacts.filter(a => !a) - const failedChecks = stageState.checks.filter(c => c.status === "fail").map(c => c.name) - - if (missingArtifacts.length > 0) { - return block(`Stage "${stage}" has missing required artifacts`, missingArtifacts) - } - - if (failedChecks.length > 0) { - return block(`Stage "${stage}" has failed checks`, [], failedChecks) - } - - const pendingChecks = stageState.checks.filter(c => c.status === "pending") - if (pendingChecks.length > 0) { - return block(`Stage "${stage}" has pending checks: ${pendingChecks.map(c => c.name).join(", ")}`) - } - - if (stage === "merge") { - return canMerge(flowRun) - } - - return ok() -} - -export function canStartTask(flowRun: FlowRun, taskId: string): GateResult { - const task = flowRun.tasks[taskId] - if (!task) return block(`Task not found: ${taskId}`) - - if (flowRun.status !== "running") { - return block(`FlowRun is ${flowRun.status}, not running`) - } - - const codeStage = flowRun.stages.code - if (codeStage.status !== "running" && codeStage.status !== "pass") { - return block("Code stage is not active") - } - - if (task.status === "merged") return block(`Task "${taskId}" already merged`) - if (task.status === "running") return block(`Task "${taskId}" already running`) - if (task.status === "blocked") return block(`Task "${taskId}" is blocked: ${task.blockedReason || "unknown reason"}`) - - for (const dep of task.dependsOn) { - const depTask = flowRun.tasks[dep] - if (!depTask) return block(`Dependency task "${dep}" not found`) - if (depTask.status !== "merged") { - return block(`Dependency "${dep}" is not merged (status: ${depTask.status})`, [], [dep]) - } - } - - return ok() -} - -export function canCompleteTask(flowRun: FlowRun, taskId: string): GateResult { - const task = flowRun.tasks[taskId] - if (!task) return block(`Task not found: ${taskId}`) - - if (task.status !== "running") { - return block(`Task "${taskId}" is not running (status: ${task.status})`) - } - - return ok() -} - -/** - * 检查 FlowRun 是否满足合并收尾条件。 - * - * 新语义(v2): - * - FlowRun 状态为 running 或 merging - * - 所有 Task 状态均为 merged - * - 不再要求 review Stage 为 pass(Task-local merge 已独立) - * - * 用于 flow_run finalize。 - */ -export function canMerge(flowRun: FlowRun): GateResult { - if (flowRun.status !== "running" && flowRun.status !== "merging") { - return block("FlowRun is not in a mergeable state") - } - - if (!allTasksComplete(flowRun)) { - return block("Not all tasks merged (finalize requires all tasks to be merged first)") - } - - return ok() -} - -export function allTasksComplete(flowRun: FlowRun): boolean { - const taskIds = Object.keys(flowRun.tasks) - if (taskIds.length === 0) return false - return taskIds.every(id => flowRun.tasks[id].status === "merged") -} - -export function getReadyTasks(flowRun: FlowRun): TaskState[] { - return Object.values(flowRun.tasks).filter(t => { - if (t.status !== "pending" && t.status !== "ready") return false - - const depsMet = t.dependsOn.every(dep => { - const depTask = flowRun.tasks[dep] - return depTask && depTask.status === "merged" - }) - - return depsMet - }) -} - -export function determineNextStage(flowRun: FlowRun): { stage: FlowStage | null; reason: string } { - for (const stage of FLOW_RUN_STAGES) { - const s = flowRun.stages[stage] - if (s.status === "pending" || s.status === "failed" || s.status === "blocked") { - const gate = canStartStage(flowRun, stage) - if (gate.allowed) { - return { stage, reason: `Stage "${stage}" is ready to start` } - } - return { stage: null, reason: `Cannot start "${stage}": ${gate.reason}` } - } - if (s.status === "running") { - return { stage, reason: `Stage "${stage}" is in progress` } - } - } - return { stage: null, reason: "All stages complete" } -} diff --git a/src/flowrun/github.ts b/src/flowrun/github.ts deleted file mode 100644 index 8becd5f..0000000 --- a/src/flowrun/github.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { gh } from "../util/gh.js" -import { escapeShellArg } from "../util/shell.js" -import { - type FlowRun, type FlowRunReadResult, - CABINET_START_MARKER, - CABINET_END_MARKER, - CURRENT_SCHEMA_VERSION, -} from "./types.js" -import { validateFlowRun } from "./validator.js" -import { migrateV1ToV2 } from "./migration.js" - -function buildFlowRunBlock(flowRun: FlowRun): string { - const json = JSON.stringify(flowRun, null, 2) - return `${CABINET_START_MARKER}\n\`\`\`json\n${json}\n\`\`\`\n${CABINET_END_MARKER}` -} - -/** - * 从 Issue body 中提取 FlowRun,执行完整 v1→v2 读取管线: - * JSON parse → detect version → migrate → validate → return FlowRunReadResult - */ -export function extractFlowRunFromBody(body: string): FlowRunReadResult { - const startIdx = body.indexOf(CABINET_START_MARKER) - const endIdx = body.indexOf(CABINET_END_MARKER) - if (startIdx === -1 || endIdx === -1) { - return { ok: false, code: "NOT_FOUND", errors: [{ path: "", message: "FlowRun markers not found in issue body" }] } - } - - const block = body.slice(startIdx + CABINET_START_MARKER.length, endIdx).trim() - - const jsonMatch = block.match(/```json\n?([\s\S]*?)```/) - if (!jsonMatch) { - return { ok: false, code: "INVALID_JSON", errors: [{ path: "", message: "JSON block not found" }] } - } - - let parsed: unknown - try { - parsed = JSON.parse(jsonMatch[1].trim()) - } catch { - return { ok: false, code: "INVALID_JSON", errors: [{ path: "", message: "Failed to parse FlowRun JSON" }] } - } - - // Step 1: version detection + migration - const migrationResult = migrateV1ToV2(parsed) - if (!migrationResult.ok) return migrationResult - - // Step 2: validate v2 - const { errors } = validateFlowRun(migrationResult.data) - if (errors.length > 0) { - return { ok: false, code: "VALIDATION_FAILED", errors } - } - - return { ok: true, data: migrationResult.data, migrated: migrationResult.migrated } -} - -export function replaceFlowRunInBody(body: string, flowRun: FlowRun): string { - const startIdx = body.indexOf(CABINET_START_MARKER) - const endIdx = body.indexOf(CABINET_END_MARKER) - - const block = buildFlowRunBlock(flowRun) - - if (startIdx === -1 || endIdx === -1) { - return body + "\n\n" + block - } - - return body.slice(0, startIdx) + block + body.slice(endIdx + CABINET_END_MARKER.length) -} - -export async function readFlowRun(issueNumber: number, ghEnv?: Record): Promise { - try { - const { stdout } = await gh(`issue view ${issueNumber} --json body --jq .body`, 30_000, ghEnv) - return extractFlowRunFromBody(stdout) - } catch { - return { ok: false, code: "NOT_FOUND", errors: [{ path: "", message: "Failed to read issue body" }] } - } -} - -export async function writeFlowRun(issueNumber: number, flowRun: FlowRun, ghEnv?: Record): Promise<{ success: boolean; error?: string }> { - try { - const { stdout } = await gh(`issue view ${issueNumber} --json body --jq .body`, 30_000, ghEnv) - const body = stdout - const newBody = replaceFlowRunInBody(body, flowRun) - - const escaped = escapeShellArg(newBody) - - await gh(`issue edit ${issueNumber} --body '${escaped}'`, 30_000, ghEnv) - return { success: true } - } catch (err) { - return { success: false, error: String(err) } - } -} - -export async function readFlowRunWithLock(issueNumber: number, ghEnv?: Record): Promise<{ flowRunResult: FlowRunReadResult; currentBody: string | null }> { - try { - const { stdout } = await gh(`issue view ${issueNumber} --json body --jq .body`, 30_000, ghEnv) - const flowRunResult = extractFlowRunFromBody(stdout) - return { flowRunResult, currentBody: stdout } - } catch { - return { flowRunResult: { ok: false, code: "NOT_FOUND", errors: [{ path: "", message: "Failed to read issue body" }] }, currentBody: null } - } -} - -export async function writeFlowRunWithLock( - issueNumber: number, - flowRun: FlowRun, - previousBody: string, - ghEnv?: Record, -): Promise<{ success: boolean; error?: string; conflict: boolean }> { - try { - const { stdout } = await gh(`issue view ${issueNumber} --json body --jq .body`, 30_000, ghEnv) - - if (previousBody !== stdout) { - return { success: false, error: "Conflict: body has changed since last read", conflict: true } - } - - const newBody = replaceFlowRunInBody(stdout, flowRun) - const escaped = escapeShellArg(newBody) - await gh(`issue edit ${issueNumber} --body '${escaped}'`, 30_000, ghEnv) - return { success: true, conflict: false } - } catch (err) { - return { success: false, error: String(err), conflict: false } - } -} - -export async function applyLabel(issueNumber: number, label: string, ghEnv?: Record): Promise { - try { - await gh(`issue edit ${issueNumber} --add-label '${label}'`, 30_000, ghEnv) - return true - } catch { - return false - } -} - -export async function removeLabel(issueNumber: number, label: string, ghEnv?: Record): Promise { - try { - await gh(`issue edit ${issueNumber} --remove-label '${label}'`, 30_000, ghEnv) - return true - } catch { - return false - } -} - -export function createInitialFlowRun( - flowRunId: string, - repo: string, - parentIssueNumber: number, -): FlowRun { - const now = new Date().toISOString() - const emptyStage = () => ({ - status: "pending" as const, - requiredArtifacts: [] as string[], - checks: [], - completedAt: null, - evidence: [], - }) - - return { - flowRunId, - repo, - parentIssueNumber, - status: "planned", - schemaVersion: CURRENT_SCHEMA_VERSION, - revision: 0, - stages: { - requirements: emptyStage(), - design: emptyStage(), - tasks: emptyStage(), - code: emptyStage(), - test: emptyStage(), - review: emptyStage(), - merge: emptyStage(), - }, - tasks: {}, - startedAt: now, - lastTickAt: now, - nextTickAfter: null, - maxRuntime: 86_400_000, - completedAt: null, - repositoryQualityPolicy: { - mode: "off", - requiredChecks: [], - }, - } -} diff --git a/src/flowrun/index.ts b/src/flowrun/index.ts deleted file mode 100644 index 964e5ef..0000000 --- a/src/flowrun/index.ts +++ /dev/null @@ -1,107 +0,0 @@ -export { - type FlowRun, type FlowRunStatus, type FlowStage, type TaskState, type TaskStatus, - type StageState, type StageStatus, type Checkpoint, type CheckpointStatus, - type EvidenceEntry, type PRCheckpoints, - FLOW_RUN_STAGES, FLOW_RUN_STATUSES, TASK_STATUSES, STAGE_STATUSES, CHECKPOINT_STATUSES, - CURRENT_SCHEMA_VERSION, DEFAULT_MAX_RUNTIME_MS, - CABINET_START_MARKER, CABINET_END_MARKER, LABEL_PREFIX, FLOW_RUN_LABELS, - // Schema v2 新增 - type AcceptanceCriterion, type TddMode, type TddEnforcement, - type TddRunnerPolicy, type TddApproval, type AlternativeValidation, - type TddException, type TddPolicy, - type TddFailureKind, type TddCommandEvidence, type VersionedDigest, - type TddTaskStartEvidence, type TaskExecutionBinding, - type TddCycleEvidence, type TddRegressionEvidence, - type FinalVerificationEvidence, type AlternativeValidationEvidence, - type TddReworkEvidence, type ReworkApproval, - type TddEvidence, type CoveragePolicy, type CoverageEvidence, - type TddComplianceCheckpoint, type RepositoryQualityPolicy, - type GoalFlowRunRef, type FlowRunReadResult, type TaskCommand, - type ValidationError, - type FlowControlRequest, type FlowControlResponse, type FlowControlOp, -} from "./types.js" - -export { - validateFlowRun, validateFlowRunDag, - type DagValidationError, -} from "./validator.js" - -export { - migrateV1ToV2, -} from "./migration.js" - -export { - extractFlowRunFromBody, replaceFlowRunInBody, - readFlowRun, writeFlowRun, - readFlowRunWithLock, writeFlowRunWithLock, - applyLabel, removeLabel, createInitialFlowRun, -} from "./github.js" - -export { - canStartStage, canCompleteStage, canStartTask, canCompleteTask, - canMerge, allTasksComplete, getReadyTasks, determineNextStage, - type GateResult, -} from "./gate.js" - -export { - flowRunStart, flowStageStart, flowStageComplete, flowTaskStart, - flowRunFinalize, - type TransitionError, type TransitionResult, -} from "./transitions.js" - -export { - checkBranchProtection, validateCheckpoint, validatePRCheckpoints, - canMergeTaskPR, mergePR, mergeTaskPR, createRevertPR, setMergeGhExecutor, - type MergeGateResult, -} from "./merge.js" - -export { - checkRuntime, hasRuntimeExpired, getRuntimeMs, - checkBackpressure, buildContinuationContext, - type RuntimeCheckResult, type BackpressureStatus, -} from "./resilience.js" - -export { - postAuditComment, postPRComment, - buildStageAudit, buildMergeAudit, -} from "./audit.js" - -export { - evaluateTddCompliance, - type EvaluationResult, -} from "./evaluator.js" - -export { - computeWorkspaceDigest, - type DigestOptions, -} from "./digest.js" - -export { - executeRedCheck, - validateSelector, - classifyVitestFailure, - buildVitestArgs, - type VitestOutput, - type FailureInput, -} from "./adapter.js" - -export { - validateCoveragePath, - resolveCoveragePath, - parseCoverageReport, - buildCoverageEvidence, - checkCoverageThreshold, - computeReportDigest, - verifySymlinkSafe, - type ParsedCoverageData, - type BuildCoverageEvidenceInput, -} from "./coverage.js" - -export { - checkRequiredChecks, - validateRequiredWorkflow, - computeQualityContractDigest, - type PRCheckResult, - type CICheckResult, - type RequiredWorkflowValidation, -} from "./ci.js" diff --git a/src/flowrun/merge.ts b/src/flowrun/merge.ts deleted file mode 100644 index fab6d7d..0000000 --- a/src/flowrun/merge.ts +++ /dev/null @@ -1,226 +0,0 @@ -import type { - PRCheckpoints, Checkpoint, TaskState, -} from "./types.js" - -// ─── 可替换的 gh executor(用于测试) ─── - -type GhFn = (args: string) => Promise<{ stdout: string; stderr: string }> - -let mergeGhExecutor: GhFn | null = null - -export function setMergeGhExecutor(fn: GhFn) { - mergeGhExecutor = fn -} - -async function gh(args: string): Promise<{ stdout: string; stderr: string }> { - if (mergeGhExecutor) { - return mergeGhExecutor(args) - } - const mod = await import("../util/gh.js") - return mod.gh(args) -} - -// ─── 类型 ─── - -export interface BranchProtectionStatus { - exists: boolean - requiredChecks: string[] - requiresPR: boolean - dismissesStale: boolean -} - -export interface MergeGateResult { - allowed: boolean - reason?: string - checkpointResults: Record -} - -// ─── Branch Protection ─── - -export async function checkBranchProtection(owner: string, repo: string): Promise { - try { - const { stdout } = await gh(`api repos/${owner}/${repo}/branches/main/protection`) - const data = JSON.parse(stdout) - - const checks: string[] = [] - const requiredPulls = data?.required_pull_request_reviews ?? null - const requiredChecks = data?.required_status_checks?.checks ?? [] - - for (const c of requiredChecks) { - checks.push(c.context) - } - - return { - exists: true, - requiredChecks: checks, - requiresPR: !!requiredPulls, - dismissesStale: requiredPulls?.dismiss_stale_reviews ?? false, - } - } catch { - return { - exists: false, - requiredChecks: [], - requiresPR: false, - dismissesStale: false, - } - } -} - -// ─── Checkpoint 验证 ─── - -export function validateCheckpoint(cp: Checkpoint): "pending" | "pass" | "fail" { - if (cp.status === "pass") return "pass" - if (cp.status === "fail") return "fail" - return "pending" -} - -export function validatePRCheckpoints(checkpoints: PRCheckpoints): MergeGateResult { - const gates: Record = { - localChecks: checkpoints.localChecks, - ciChecks: checkpoints.ciChecks, - reviewerApproval: checkpoints.reviewerApproval, - goalVerification: checkpoints.goalVerification, - branchProtection: checkpoints.branchProtection, - } - - const results: Record = {} - - for (const [name, cp] of Object.entries(gates)) { - results[name] = validateCheckpoint(cp) - } - - const failures = Object.entries(gates).filter(([_, cp]) => cp.status === "fail") - if (failures.length > 0) { - return { - allowed: false, - reason: `Failed checkpoints: ${failures.map(([k]) => k).join(", ")}`, - checkpointResults: results, - } - } - - const pending = Object.entries(gates).filter(([_, cp]) => cp.status === "pending") - if (pending.length > 0) { - return { - allowed: false, - reason: `Pending checkpoints: ${pending.map(([k]) => k).join(", ")}`, - checkpointResults: results, - } - } - - return { allowed: true, checkpointResults: results } -} - -// ─── Task-local Merge Gate ─── - -/** - * Task-local merge gate(不依赖全局 canMerge)。 - * - * 检查条件(按顺序): - * 1. Task 状态为 "reviewing" - * 2. 存在 PR Checkpoints - * 3. 所有 checkpoint 状态为 pass - * 4. TDD compliance 为 pass / waived / null(无 TDD 要求) - * 5. Branch Protection 已启用 - * - * 不要求全局 review Stage 为 pass。 - */ -export function canMergeTaskPR(task: TaskState, hasBranchProtection: boolean): MergeGateResult { - // 1. Task 状态检查 - if (task.status === "merged") { - return { allowed: false, reason: "Task already merged", checkpointResults: {} } - } - - if (task.status !== "reviewing") { - return { allowed: false, reason: `Task status is ${task.status}, expected reviewing`, checkpointResults: {} } - } - - // 2. PR Checkpoints 存在性 - if (!task.prCheckpoints) { - return { allowed: false, reason: "No PR checkpoints recorded", checkpointResults: {} } - } - - // 3. 标准 checkpoint 验证 - const prResult = validatePRCheckpoints(task.prCheckpoints) - if (!prResult.allowed) return prResult - - // 4. TDD compliance 验证 - const tdd = task.prCheckpoints.tddCompliance - if (tdd !== null && tdd.status === "fail") { - prResult.checkpointResults["tddCompliance"] = "fail" - return { - allowed: false, - reason: `TDD compliance failed: ${tdd.summary}`, - checkpointResults: prResult.checkpointResults, - } - } - // tdd === null → 无 TDD 要求,视为通过 - // tdd.status === "pass" / "waived" → 通过 - prResult.checkpointResults["tddCompliance"] = - tdd === null ? "skipped" : (tdd.status as "pass" | "fail" | "pending") - - // 5. Branch Protection - if (!hasBranchProtection) { - prResult.checkpointResults.branchProtection = "fail" - return { - allowed: false, - reason: "Branch protection not enabled on main", - checkpointResults: prResult.checkpointResults, - } - } - - prResult.checkpointResults.branchProtection = "pass" - return prResult -} - -// ─── PR 合并操作 ─── - -/** - * 标准化合并 PR(无安全检查,向后兼容)。 - */ -export async function mergePR(prNumber: number): Promise<{ success: boolean; error?: string }> { - try { - await gh(`pr merge ${prNumber} --squash --delete-branch`) - return { success: true } - } catch (err) { - return { success: false, error: String(err) } - } -} - -/** - * Task-local PR 合并(带 --match-head-commit 安全检查)。 - * - * 步骤: - * 1. 验证 verifiedSha 非空 - * 2. 使用 gh pr merge --squash --delete-branch --match-head-commit - * 3. 失败时返回错误 - */ -export async function mergeTaskPR(prNumber: number, verifiedSha: string): Promise<{ success: boolean; error?: string }> { - if (!verifiedSha) { - return { success: false, error: "verifiedSha is required for merge --match-head-commit" } - } - - try { - // --match-head-commit 确保只有 verified 的 commit 才会被合并 - await gh(`pr merge ${prNumber} --squash --delete-branch --match-head-commit ${verifiedSha}`) - return { success: true } - } catch (err) { - return { success: false, error: String(err) } - } -} - -// ─── Revert ─── - -export async function createRevertPR(prNumber: number, reason: string): Promise<{ prNumber?: number; error?: string }> { - try { - const { stdout } = await gh(`pr view ${prNumber} --json headRefName,headRepository,body,title`) - const pr = JSON.parse(stdout) - - const revertTitle = `revert: ${pr.title}` - const revertBody = `Reverts #${prNumber}\n\nReason: ${reason}\n\nAuto-generated by opencode-cabbage.` - - const { stdout: newPR } = await gh(`pr create --title '${revertTitle}' --body '${revertBody}' --base main --head main`) - return { prNumber: parseInt(newPR.trim().split("/").pop() || "0", 10) } - } catch (err) { - return { error: String(err) } - } -} diff --git a/src/flowrun/migration.ts b/src/flowrun/migration.ts deleted file mode 100644 index 38df200..0000000 --- a/src/flowrun/migration.ts +++ /dev/null @@ -1,310 +0,0 @@ -import type { - FlowRun, TaskState, FlowRunReadResult, AcceptanceCriterion, TddPolicy, - TddEvidence, TaskCommand, TddTaskStartEvidence, TddRegressionEvidence, - FinalVerificationEvidence, TddReworkEvidence, AlternativeValidationEvidence, - StageState, Checkpoint, EvidenceEntry, -} from "./types.js" -import { CURRENT_SCHEMA_VERSION } from "./types.js" - -type V1FlowRun = Record -type V1Task = Record - -/** - * v1→v2 幂等迁移器 - * - * 纯函数:输入 v1 FlowRun (unknown),输出 FlowRunReadResult。 - * - 如果输入已经是 v2 格式 → 直接返回(幂等) - * - 如果 schemaVersion > 2 → 返回 UNSUPPORTED_SCHEMA - * - 如果 schemaVersion 缺失/非整数/< 1 → 返回 VALIDATION_FAILED - * - 如果 schemaVersion === 1 → 执行迁移 - */ -export function migrateV1ToV2(flowRun: unknown): FlowRunReadResult { - if (!flowRun || typeof flowRun !== "object") { - return { ok: false, code: "INVALID_JSON", errors: [{ path: "", message: "FlowRun must be a non-null object" }] } - } - - const obj = flowRun as Record - - const version = obj.schemaVersion - if (typeof version !== "number" || !Number.isInteger(version) || version < 1) { - return { - ok: false, - code: "VALIDATION_FAILED", - errors: [{ path: "schemaVersion", message: `Must be a positive integer, got: ${version}` }], - } - } - - if (version > CURRENT_SCHEMA_VERSION) { - return { - ok: false, - code: "UNSUPPORTED_SCHEMA", - errors: [{ path: "schemaVersion", message: `Schema version ${version} is not supported (current: ${CURRENT_SCHEMA_VERSION})` }], - } - } - - // 已经是 v2,直接返回 - if (version === CURRENT_SCHEMA_VERSION) { - return { ok: true, data: obj as unknown as FlowRun, migrated: false } - } - - // v1 → v2 迁移 - try { - const migrated = doMigrateV1ToV2(obj) - return { ok: true, data: migrated, migrated: true } - } catch (err) { - return { - ok: false, - code: "MIGRATION_FAILED", - errors: [{ path: "", message: `Migration failed: ${String(err)}` }], - } - } -} - -function doMigrateV1ToV2(v1: V1FlowRun): FlowRun { - const tasks: Record = {} - - if (v1.tasks && typeof v1.tasks === "object") { - const v1Tasks = v1.tasks as Record - for (const [key, v1Task] of Object.entries(v1Tasks)) { - tasks[key] = migrateTaskV1ToV2(v1Task as V1Task, key) - } - } - - // v1 PR checkpoints 有旧的 PR 字段,需要补充 v2 的新字段 - // 这些在 migrateTaskV1ToV2 中处理 - - return { - flowRunId: String(v1.flowRunId ?? ""), - repo: String(v1.repo ?? ""), - parentIssueNumber: Number(v1.parentIssueNumber ?? 0), - status: String(v1.status ?? "planned") as FlowRun["status"], - schemaVersion: CURRENT_SCHEMA_VERSION, - revision: Number(v1.revision ?? 0), - stages: migrateStages(v1.stages), - tasks, - startedAt: typeof v1.startedAt === "string" ? v1.startedAt : null, - lastTickAt: typeof v1.lastTickAt === "string" ? v1.lastTickAt : null, - nextTickAfter: typeof v1.nextTickAfter === "string" ? v1.nextTickAfter : null, - maxRuntime: Number(v1.maxRuntime ?? 86_400_000), - completedAt: typeof v1.completedAt === "string" ? v1.completedAt : null, - repositoryQualityPolicy: { mode: "off", requiredChecks: [] }, - } as FlowRun -} - -function migrateStages(stages: unknown): FlowRun["stages"] { - function defaultStage(): StageState { - return { - status: "pending", - requiredArtifacts: [], - checks: [], - completedAt: null, - evidence: [], - } - } - - if (!stages || typeof stages !== "object") { - return { - requirements: defaultStage(), - design: defaultStage(), - tasks: defaultStage(), - code: defaultStage(), - test: defaultStage(), - review: defaultStage(), - merge: defaultStage(), - } - } - - const s = stages as Record - const stageKeys = ["requirements", "design", "tasks", "code", "test", "review", "merge"] - - const result: Record = {} - for (const key of stageKeys) { - const val = s[key] - if (val && typeof val === "object") { - const v = val as Record - result[key] = { - status: (typeof v.status === "string" && ["pending", "running", "pass", "failed", "blocked"].includes(v.status)) - ? v.status as StageState["status"] - : "pending", - requiredArtifacts: Array.isArray(v.requiredArtifacts) ? v.requiredArtifacts as string[] : [], - checks: Array.isArray(v.checks) ? v.checks as Checkpoint[] : [], - completedAt: typeof v.completedAt === "string" ? v.completedAt : null, - evidence: Array.isArray(v.evidence) ? v.evidence as EvidenceEntry[] : [], - } - } else { - result[key] = defaultStage() - } - } - - return result as FlowRun["stages"] -} - -/** - * 将单个 v1 Task 迁移为 v2 TaskState - */ -function migrateTaskV1ToV2(v1Task: V1Task, recordKey: string): TaskState { - const hasTestCommands = Array.isArray(v1Task.testCommands) && v1Task.testCommands.length > 0 - - // 迁移 testCommands: string[] → TaskCommand[] - const testCommands: TaskCommand[] = hasTestCommands - ? (v1Task.testCommands as string[]).map(cmd => ({ - command: cmd, - cwd: ".", - timeoutMs: 120_000, - env: {}, - })) - : [] - - // 迁移 acceptance → acceptanceCriteria - const v1Acceptance = typeof v1Task.acceptance === "string" ? v1Task.acceptance : "" - const acceptanceCriteria: AcceptanceCriterion[] = [ - { - id: "legacy-1", - description: v1Acceptance || "历史任务验收条件(v1 迁移)", - verification: hasTestCommands ? "regression" : "manual", - }, - ] - - // 构造 TDD policy(按 spec 10.2) - const tddPolicy: TddPolicy = { - mode: hasTestCommands ? "relaxed" : "bypass", - enforcement: "advisory", - runner: null, - testFilePatterns: [], - implementationFilePatterns: [], - generatedArtifactPatterns: [], - exception: { - reason: "legacy schema v1 migration", - alternativeValidation: hasTestCommands - ? testCommands.map((cmd, i) => ({ - validationId: `legacy-alt-${i + 1}`, - kind: "command" as const, - command: cmd, - })) - : [{ - validationId: "legacy-alt-1", - kind: "manual" as const, - description: "历史任务未记录自动验证", - }], - approval: { - kind: "legacy-migration", - fromSchemaVersion: 1, - }, - }, - source: { - manifestPath: "legacy:v1", - revisionSha: "migration:v1", - }, - } - - // 构造 TDD evidence(完整默认对象) - const tddEvidence: TddEvidence = { - revision: 0, - reworkRevision: 0, - status: "not-recorded", - taskStart: makeDefaultTaskStart(), - cycles: [], - regression: makeDefaultRegression(), - verification: makeDefaultVerification(), - alternativeValidation: [] as AlternativeValidationEvidence[], - reworks: [] as TddReworkEvidence[], - warnings: ["legacy schema v1 migration: evidence not recorded"], - updatedAt: null, - } - - // 迁移 PR checkpoints(保留旧字段 + 新增 null) - const v1PR = v1Task.prCheckpoints - const prCheckpoints = v1PR && typeof v1PR === "object" - ? migratePRCheckpoints(v1PR as Record) - : null - - return { - id: String(v1Task.id ?? recordKey), - name: String(v1Task.name ?? recordKey), - status: (typeof v1Task.status === "string" ? v1Task.status : "pending") as TaskState["status"], - dependsOn: Array.isArray(v1Task.dependsOn) ? v1Task.dependsOn as string[] : [], - area: String(v1Task.area ?? ""), - expectedFiles: Array.isArray(v1Task.expectedFiles) ? v1Task.expectedFiles as string[] : [], - parallelSafe: Boolean(v1Task.parallelSafe ?? false), - prNumber: typeof v1Task.prNumber === "number" ? v1Task.prNumber : null, - prCheckpoints, - blockedReason: typeof v1Task.blockedReason === "string" ? v1Task.blockedReason : null, - startedAt: typeof v1Task.startedAt === "string" ? v1Task.startedAt : null, - // v2 新字段 - acceptanceCriteria, - testCommands, - verifyCommands: [], - executionBinding: null, - tddPolicy, - tddEvidence, - coveragePolicy: null, - } -} - -function migratePRCheckpoints(v1PR: Record) { - function makeCheckpoint(name: string): Checkpoint { - return { - name, - status: "pending", - evidence: [], - } - } - - const getCheckpoint = (key: string): Checkpoint => { - const cp = v1PR[key] - if (cp && typeof cp === "object") { - const c = cp as Record - return { - name: String(c.name ?? key), - status: (typeof c.status === "string" && ["pending", "pass", "fail"].includes(c.status)) - ? c.status as Checkpoint["status"] - : "pending", - evidence: Array.isArray(c.evidence) ? c.evidence as EvidenceEntry[] : [], - } - } - return makeCheckpoint(key) - } - - return { - prNumber: Number(v1PR.prNumber ?? 0), - localChecks: getCheckpoint("localChecks"), - ciChecks: getCheckpoint("ciChecks"), - reviewerApproval: getCheckpoint("reviewerApproval"), - goalVerification: getCheckpoint("goalVerification"), - branchProtection: getCheckpoint("branchProtection"), - mergeResult: getCheckpoint("mergeResult"), - // v2 新增 - tddCompliance: null, - verification: null, - coverage: null, - qualityContractDigest: null, - } -} - -function makeDefaultTaskStart(): TddTaskStartEvidence { - return { - status: "pending", - headSha: null, - treeSha: null, - startedAt: null, - } -} - -function makeDefaultRegression(): TddRegressionEvidence { - return { - status: "pending", - headSha: null, - treeSha: null, - reworkRevision: 0, - runs: [], - } -} - -function makeDefaultVerification(): FinalVerificationEvidence { - return { - status: "pending", - headSha: null, - treeSha: null, - runs: [], - } -} diff --git a/src/flowrun/resilience.ts b/src/flowrun/resilience.ts deleted file mode 100644 index 84b400c..0000000 --- a/src/flowrun/resilience.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { type FlowRun, DEFAULT_MAX_RUNTIME_MS, FLOW_RUN_STAGES } from "./types.js" -import { gh } from "../util/gh.js" - -export function getRuntimeMs(flowRun: FlowRun): number { - if (!flowRun.startedAt) return 0 - return Date.now() - new Date(flowRun.startedAt).getTime() -} - -export function hasRuntimeExpired(flowRun: FlowRun): boolean { - const max = flowRun.maxRuntime || DEFAULT_MAX_RUNTIME_MS - return getRuntimeMs(flowRun) > max -} - -export interface RuntimeCheckResult { - expired: boolean - runtimeMs: number - maxRuntimeMs: number - remainingMs: number -} - -export function checkRuntime(flowRun: FlowRun): RuntimeCheckResult { - const runtimeMs = getRuntimeMs(flowRun) - const maxRuntimeMs = flowRun.maxRuntime || DEFAULT_MAX_RUNTIME_MS - return { - expired: runtimeMs > maxRuntimeMs, - runtimeMs, - maxRuntimeMs, - remainingMs: Math.max(0, maxRuntimeMs - runtimeMs), - } -} - -export interface BackpressureStatus { - canContinue: boolean - reason?: string - rateLimitRemaining: number | null - ciQueueLength: number | null -} - -export async function checkBackpressure(owner: string, repo: string): Promise { - const status: BackpressureStatus = { - canContinue: true, - rateLimitRemaining: null, - ciQueueLength: null, - } - - try { - const { stdout } = await gh(`api rate_limit --jq .rate.remaining`) - const remaining = parseInt(stdout.trim(), 10) - status.rateLimitRemaining = remaining - if (remaining < 100) { - status.canContinue = false - status.reason = `GitHub API rate limit low: ${remaining} remaining` - return status - } - } catch { - status.rateLimitRemaining = null - } - - try { - const { stdout } = await gh(`run list --repo ${owner}/${repo} --json status --jq '[.[] | select(.status == "queued" or .status == "in_progress")] | length'`) - const queueLen = parseInt(stdout.trim(), 10) - status.ciQueueLength = queueLen - if (queueLen >= 10) { - status.canContinue = false - status.reason = `CI queue too long: ${queueLen} queued/in_progress` - return status - } - } catch { - status.ciQueueLength = null - } - - return status -} - -export function buildContinuationContext(flowRun: FlowRun): string { - const parts: string[] = [ - `FlowRun: ${flowRun.flowRunId}`, - `Status: ${flowRun.status}`, - `Runtime: ${Math.round(getRuntimeMs(flowRun) / 60000)} minutes`, - "", - "## Stage Status", - ] - - for (const stage of FLOW_RUN_STAGES) { - const s = flowRun.stages[stage] - const checkSummary = s.checks - .map(c => `${c.name}=${c.status}`) - .join(", ") - parts.push(`- ${stage}: ${s.status}${checkSummary ? ` (${checkSummary})` : ""}`) - } - - const taskEntries = Object.entries(flowRun.tasks) - if (taskEntries.length > 0) { - parts.push("", "## Tasks") - for (const [id, task] of taskEntries) { - parts.push(`- ${id}: ${task.status}${task.prNumber ? ` PR #${task.prNumber}` : ""}`) - } - } - - return parts.join("\n") -} diff --git a/src/flowrun/state.ts b/src/flowrun/state.ts deleted file mode 100644 index 4b1d926..0000000 --- a/src/flowrun/state.ts +++ /dev/null @@ -1,451 +0,0 @@ -import type { - TddEvidence, - TddCycleEvidence, - TddCommandEvidence, - AcceptanceCriterion, - VersionedDigest, -} from "./types.js" - -// ─── 错误类型 ─── - -export interface StateError { - code: string - message: string -} - -export type StateResult = - | { ok: true; value: T } - | { ok: false; error: StateError } - -// ─── 辅助函数 ─── - -function findCycle(evidence: TddEvidence, cycleId: string): TddCycleEvidence | undefined { - return evidence.cycles.find(c => c.cycleId === cycleId) -} - -function replaceCycle(evidence: TddEvidence, updated: TddCycleEvidence): TddCycleEvidence[] { - return evidence.cycles.map(c => c.cycleId === updated.cycleId ? updated : c) -} - -function bumpRevision(evidence: TddEvidence): TddEvidence { - return { ...evidence, revision: evidence.revision + 1, updatedAt: new Date().toISOString() } -} - -/** - * 简单 glob 匹配(支持 *、**) - */ -function matchSimpleGlob(filePath: string, pattern: string): boolean { - const normalized = filePath.split("/").join("/") - let regexStr = "" - let i = 0 - while (i < pattern.length) { - if (pattern[i] === "*" && pattern[i + 1] === "*") { - i += 2 - if (i < pattern.length && pattern[i] === "/") { - regexStr += "(.*/)?" - i++ - } else { - regexStr += ".*" - } - } else if (pattern[i] === "*") { - regexStr += "[^/]*" - i++ - } else if (pattern[i] === ".") { - regexStr += "\\." - i++ - } else if (pattern[i] === "?") { - regexStr += "[^/]" - i++ - } else { - if ("+^$(){}[]|\\".includes(pattern[i])) { - regexStr += "\\" + pattern[i] - } else { - regexStr += pattern[i] - } - i++ - } - } - return new RegExp(`^${regexStr}$`).test(normalized) -} - -function matchesAnyPattern(path: string, patterns: string[]): boolean { - if (patterns.length === 0) return false - return patterns.some(p => matchSimpleGlob(path, p)) -} - -function digestEqual(a: VersionedDigest | null, b: VersionedDigest | null): boolean { - if (a === null || b === null) return a === b - return a.algorithm === b.algorithm && a.value === b.value -} - -/** - * 判断 RED failure 是否有效: - * 只有 assertion 和 missing-behavior 才是有效的 RED failure。 - */ -function isValidRedFailure(failureKind: string | null): boolean { - return failureKind === "assertion" || failureKind === "missing-behavior" -} - -// ─── 公开函数 ─── - -/** - * 创建初始 TddEvidence(status=not-recorded, revision=0)。 - */ -export function createTaskEvidence(): TddEvidence { - return { - revision: 0, - reworkRevision: 0, - status: "not-recorded", - taskStart: { status: "pending", headSha: null, treeSha: null, startedAt: null }, - cycles: [], - regression: { status: "pending", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, - verification: { status: "pending", headSha: null, treeSha: null, runs: [] }, - alternativeValidation: [], - reworks: [], - warnings: [], - updatedAt: null, - } -} - -/** - * 启动一个新的 TDD cycle。 - * - * 验证: - * - criterion 存在且 verification=tdd - * - testPaths 匹配 testFilePatterns - * - 同 cycleId + 同 workspace digest → 幂等返回原 evidence - * - 同 cycleId + 不同 workspace digest → CYCLE_CONFLICT - */ -export function startCycle( - evidence: TddEvidence, - cycleId: string, - criterionId: string, - testPaths: string[], - testSelector: string, - workspaceDigest: VersionedDigest, - acceptanceCriteria: AcceptanceCriterion[], - testFilePatterns: string[], -): StateResult<{ evidence: TddEvidence; cycle: TddCycleEvidence }> { - // ── 验证 criterion 存在且为 tdd ── - const criterion = acceptanceCriteria.find(c => c.id === criterionId) - if (!criterion || criterion.verification !== "tdd") { - return { - ok: false, - error: { code: "CRITERION_NOT_FOUND", message: `Criterion "${criterionId}" not found or not verification=tdd` }, - } - } - - // ── 验证 testPaths 匹配 testFilePatterns ── - for (const tp of testPaths) { - if (!matchesAnyPattern(tp, testFilePatterns)) { - return { - ok: false, - error: { code: "POLICY_INVALID", message: `Test path "${tp}" does not match testFilePatterns` }, - } - } - } - - // ── 检查已有 cycle ── - const existing = findCycle(evidence, cycleId) - if (existing) { - // 幂等:相同 workspace digest - if (digestEqual(existing.startWorkspaceDigest, workspaceDigest)) { - return { ok: true, value: { evidence, cycle: existing } } - } - // 冲突 - return { - ok: false, - error: { code: "CYCLE_CONFLICT", message: `Cycle "${cycleId}" already exists with different workspace digest` }, - } - } - - // ── 创建新 cycle ── - const cycle: TddCycleEvidence = { - cycleId, - criterionId, - reworkRevision: evidence.reworkRevision, - status: "started", - startWorkspaceDigest: workspaceDigest, - testFiles: testPaths, - redTestDigest: null, - redAttempts: [], - greenAttempts: [], - } - - const newEvidence = bumpRevision({ - ...evidence, - status: evidence.status === "not-recorded" ? "in-progress" : evidence.status, - cycles: [...evidence.cycles, cycle], - }) - - return { ok: true, value: { evidence: newEvidence, cycle } } -} - -/** - * 记录 RED 尝试。 - * - * 验证: - * - cycle 存在且 status ∈ {started, red} - * - failureKind 为 assertion 或 missing-behavior(有效 RED) - * - 幂等:相同 outputDigest → 返回原结果 - */ -export function recordRed( - evidence: TddEvidence, - cycleId: string, - redEvidence: TddCommandEvidence, -): StateResult<{ evidence: TddEvidence; cycle: TddCycleEvidence }> { - const existing = findCycle(evidence, cycleId) - if (!existing) { - return { - ok: false, - error: { code: "CYCLE_CONFLICT", message: `Cycle "${cycleId}" not found` }, - } - } - - // cycle 必须处于 started 或 red 状态 - if (existing.status !== "started" && existing.status !== "red") { - return { - ok: false, - error: { code: "INVALID_TRANSITION", message: `Cycle "${cycleId}" is in "${existing.status}" state, cannot record RED` }, - } - } - - // ── 失败分类校验 ── - const exitCode = redEvidence.exitCode - - // 测试通过(exitCode === 0)→ RED_EXPECTED_FAILURE - if (exitCode === 0) { - return { - ok: false, - error: { code: "RED_EXPECTED_FAILURE", message: "RED requires test failure (exitCode !== 0)" }, - } - } - - // timeout - if (redEvidence.failureKind === "timeout") { - return { - ok: false, - error: { code: "COMMAND_TIMEOUT", message: "Test execution timed out" }, - } - } - - // infrastructure 或 unknown → 不算 RED - if (!isValidRedFailure(redEvidence.failureKind)) { - return { - ok: false, - error: { code: "RED_INFRASTRUCTURE_FAILURE", message: `Failure kind "${redEvidence.failureKind}" is not a valid RED failure (expected assertion or missing-behavior)` }, - } - } - - // ── 幂等检查:相同 outputDigest ── - const dup = existing.redAttempts.find(a => digestEqual(a.outputDigest, redEvidence.outputDigest)) - if (dup) { - // 已经记录过相同的 RED,返回原 evidence(不修改) - return { ok: true, value: { evidence, cycle: existing } } - } - - // ── 记录 RED ── - const updatedCycle: TddCycleEvidence = { - ...existing, - status: "red", - redTestDigest: redEvidence.executionInputDigest, - redAttempts: [...existing.redAttempts, redEvidence], - } - - const newEvidence = bumpRevision({ - ...evidence, - cycles: replaceCycle(evidence, updatedCycle), - }) - - return { ok: true, value: { evidence: newEvidence, cycle: updatedCycle } } -} - -/** - * 记录 GREEN 尝试。 - * - * 验证: - * - cycle 存在且已有有效 RED(redTestDigest !== null) - * - GREEN exitCode === 0 - * - implementation files 有变化 - * - 幂等:相同 outputDigest → 返回原结果 - */ -export function recordGreen( - evidence: TddEvidence, - cycleId: string, - greenEvidence: TddCommandEvidence, - implementationFilesChanged: boolean, -): StateResult<{ evidence: TddEvidence; cycle: TddCycleEvidence }> { - const existing = findCycle(evidence, cycleId) - if (!existing) { - return { - ok: false, - error: { code: "CYCLE_CONFLICT", message: `Cycle "${cycleId}" not found` }, - } - } - - // 必须有 RED - if (existing.redTestDigest === null || existing.redAttempts.length === 0) { - return { - ok: false, - error: { code: "INVALID_TRANSITION", message: `Cycle "${cycleId}" has no RED recorded` }, - } - } - - // GREEN 测试必须通过 - if (greenEvidence.exitCode !== 0) { - return { - ok: false, - error: { code: "GREEN_EXPECTED_PASS", message: "GREEN requires all tests to pass (exitCode === 0)" }, - } - } - - // 必须有实现变化 - if (!implementationFilesChanged) { - return { - ok: false, - error: { code: "IMPLEMENTATION_CHANGE_REQUIRED", message: "GREEN requires implementation file changes" }, - } - } - - // ── 幂等检查:相同 outputDigest ── - const dup = existing.greenAttempts.find(a => digestEqual(a.outputDigest, greenEvidence.outputDigest)) - if (dup) { - return { ok: true, value: { evidence, cycle: existing } } - } - - // ── 记录 GREEN ── - const updatedCycle: TddCycleEvidence = { - ...existing, - status: "pass", - greenAttempts: [...existing.greenAttempts, greenEvidence], - } - - const newEvidence = bumpRevision({ - ...evidence, - cycles: replaceCycle(evidence, updatedCycle), - }) - - return { ok: true, value: { evidence: newEvidence, cycle: updatedCycle } } -} - -/** - * 记录最终 regression 结果。 - * - * 验证: - * - runs 非空 - * - 所有 run exitCode === 0 且无 timeout 则 pass,否则 fail - */ -export function recordFinalRegression( - evidence: TddEvidence, - headSha: string, - treeSha: string, - runs: TddCommandEvidence[], -): StateResult { - if (runs.length === 0) { - return { - ok: false, - error: { code: "REGRESSION_FAILED", message: "Regression requires at least one run" }, - } - } - - const allPass = runs.every(r => r.exitCode === 0 && r.failureKind !== "timeout") - const status = allPass ? "pass" : "fail" - - const newEvidence = bumpRevision({ - ...evidence, - regression: { - status, - headSha, - treeSha, - reworkRevision: evidence.reworkRevision, - runs, - }, - }) - - return { ok: true, value: newEvidence } -} - -/** - * 记录最终 verification 结果。 - * - * 规则: - * - runs 为空 → pass(未配置 verifyCommands) - * - 所有 run exitCode === 0 则 pass,否则 fail - */ -export function recordFinalVerification( - evidence: TddEvidence, - headSha: string, - treeSha: string, - runs: TddCommandEvidence[], -): StateResult { - if (runs.length === 0) { - // 未配置 verifyCommands,视为 pass - const newEvidence = bumpRevision({ - ...evidence, - verification: { - status: "pass", - headSha, - treeSha, - runs: [], - }, - }) - return { ok: true, value: newEvidence } - } - - const allPass = runs.every(r => r.exitCode === 0) - const status = allPass ? "pass" : "fail" - - const newEvidence = bumpRevision({ - ...evidence, - verification: { - status, - headSha, - treeSha, - runs, - }, - }) - - return { ok: true, value: newEvidence } -} - -/** - * 放弃一个 cycle。 - * - * 规则: - * - cycle 必须存在 - * - cycle 状态不能是 "pass"(已通过的 cycle 不能放弃) - * - 保留审计记录,reason 写入 warnings - */ -export function abandonCycle( - evidence: TddEvidence, - cycleId: string, - reason: string, -): StateResult { - const existing = findCycle(evidence, cycleId) - if (!existing) { - return { - ok: false, - error: { code: "CYCLE_CONFLICT", message: `Cycle "${cycleId}" not found` }, - } - } - - if (existing.status === "pass") { - return { - ok: false, - error: { code: "INVALID_TRANSITION", message: `Cannot abandon cycle "${cycleId}" that has already passed` }, - } - } - - const updatedCycle: TddCycleEvidence = { - ...existing, - status: "abandoned", - } - - const newEvidence = bumpRevision({ - ...evidence, - cycles: replaceCycle(evidence, updatedCycle), - warnings: [...evidence.warnings, reason], - }) - - return { ok: true, value: newEvidence } -} diff --git a/src/flowrun/transitions.ts b/src/flowrun/transitions.ts deleted file mode 100644 index e6d2e27..0000000 --- a/src/flowrun/transitions.ts +++ /dev/null @@ -1,219 +0,0 @@ -import type { - FlowRun, FlowStage, TaskState, TaskExecutionBinding, TddPolicy, - FlowRunStatus, StageStatus, TaskStatus, -} from "./types.js" -import { - canStartStage, canCompleteStage, canStartTask, canMerge, -} from "./gate.js" -import type { GateResult } from "./gate.js" - -// ─── 错误类型 ─── - -export interface TransitionError { - code: string - message: string -} - -export type TransitionResult = - | { ok: true; value: T } - | { ok: false; error: TransitionError } - -// ─── 受控 stage 集合 ─── - -/** flow_control 可启动的 stage */ -const STARTABLE_STAGES: FlowStage[] = ["requirements", "design", "tasks", "code"] - -/** flow_control 可完成的 stage(code/test/review/merge 由 finalize 完成) */ -const COMPLETABLE_STAGES: FlowStage[] = ["requirements", "design", "tasks"] - -function gateToError(gate: GateResult): TransitionError { - return { - code: "GATE_BLOCKED", - message: gate.reason ?? "Gate check failed", - } -} - -// ─── FlowRun 状态迁移 ─── - -/** - * 启动 FlowRun:planned → running - * - * 幂等:已经是 running 则直接返回(不报错)。 - */ -export function flowRunStart(flowRun: FlowRun): TransitionResult { - if (flowRun.status === "running") { - return { ok: true, value: flowRun } - } - if (flowRun.status !== "planned") { - return { - ok: false, - error: { code: "INVALID_TRANSITION", message: `FlowRun is ${flowRun.status}, not planned` }, - } - } - flowRun.status = "running" - flowRun.startedAt = new Date().toISOString() - return { ok: true, value: flowRun } -} - -// ─── Stage 状态迁移 ─── - -/** - * 启动 Stage:pending → running - * - * 约束: - * - 仅允许 requirements/design/tasks/code - * - 验证前置 stage 已 pass - * - * 幂等:已经是 running 则直接返回。 - */ -export function flowStageStart(flowRun: FlowRun, stage: FlowStage): TransitionResult { - if (!STARTABLE_STAGES.includes(stage)) { - return { - ok: false, - error: { code: "STAGE_NOT_CONTROLLABLE", message: `Stage "${stage}" cannot be started via flow_control` }, - } - } - - if (flowRun.stages[stage].status === "running") { - return { ok: true, value: flowRun } - } - - const gate = canStartStage(flowRun, stage) - if (!gate.allowed) { - return { ok: false, error: gateToError(gate) } - } - - flowRun.stages[stage].status = "running" - return { ok: true, value: flowRun } -} - -/** - * 完成 Stage:running → pass - * - * 约束: - * - 仅允许 requirements/design/tasks - * - code/test/review/merge 由 finalize 完成 - * - 验证 pending checks 均已 pass - * - * 幂等:已经是 pass 则直接返回。 - */ -export function flowStageComplete(flowRun: FlowRun, stage: FlowStage): TransitionResult { - if (!COMPLETABLE_STAGES.includes(stage)) { - return { - ok: false, - error: { code: "STAGE_NOT_CONTROLLABLE", message: `Stage "${stage}" completion is only via finalize` }, - } - } - - if (flowRun.stages[stage].status === "pass") { - return { ok: true, value: flowRun } - } - - const gate = canCompleteStage(flowRun, stage) - if (!gate.allowed) { - return { ok: false, error: gateToError(gate) } - } - - flowRun.stages[stage].status = "pass" - flowRun.stages[stage].completedAt = new Date().toISOString() - return { ok: true, value: flowRun } -} - -// ─── Task 状态迁移 ─── - -/** - * 启动 Task:pending/ready → running - * - * 步骤: - * 1. gate check:canStartTask(依赖已 merged、code stage 活跃等) - * 2. policy 冻结:如果传入 tddPolicy,则覆盖 TaskState.tddPolicy - * 3. 写入 executionBinding - * 4. status → running - * - * 幂等:已经是 running 则直接返回(相同 taskId)。 - */ -export function flowTaskStart( - flowRun: FlowRun, - taskId: string, - executionBinding: TaskExecutionBinding, - tddPolicy?: TddPolicy, -): TransitionResult<{ flowRun: FlowRun; task: TaskState }> { - const task = flowRun.tasks[taskId] - if (!task) { - return { - ok: false, - error: { code: "TASK_NOT_FOUND", message: `Task "${taskId}" not found in FlowRun` }, - } - } - - // 幂等:已经是 running - if (task.status === "running") { - // 更新 executionBinding(可能重连) - task.executionBinding = executionBinding - return { ok: true, value: { flowRun, task } } - } - - const gate = canStartTask(flowRun, taskId) - if (!gate.allowed) { - return { ok: false, error: gateToError(gate) } - } - - // 冻结 policy - if (tddPolicy) { - task.tddPolicy = tddPolicy - } - - // 写入 execution binding - task.executionBinding = executionBinding - task.status = "running" - task.startedAt = new Date().toISOString() - - return { ok: true, value: { flowRun, task } } -} - -// ─── FlowRun Finalize ─── - -/** - * FlowRun 终态绑定:所有 Task merged 后,顺序标记 code→test→review→merge 为 pass, - * 并将 FlowRun 状态设为 completed。 - * - * 规则: - * - canMerge() 必须通过(所有 Task merged + FlowRun status 为 running/merging) - * - 对 code/test/review/merge 四个 stage:若未 pass 则逐个标记 pass - * - FlowRun status → completed,设置 completedAt - * - * 幂等:已经是 completed 则直接返回(不修改任何字段)。 - */ -export function flowRunFinalize(flowRun: FlowRun): TransitionResult { - // 幂等:已 completed - if (flowRun.status === "completed") { - return { ok: true, value: flowRun } - } - - // 前置检查:canMerge(所有 Task merged + FlowRun 状态正确) - const mergeGate = canMerge(flowRun) - if (!mergeGate.allowed) { - return { ok: false, error: gateToError(mergeGate) } - } - - // 顺序标记 finalize stages - const finalizeStages: FlowStage[] = ["code", "test", "review", "merge"] - - for (const stage of finalizeStages) { - const stageState = flowRun.stages[stage] - if (!stageState) continue - - // 已经是 pass → 跳过 - if (stageState.status === "pass") continue - - // 标记 pass - stageState.status = "pass" - stageState.completedAt = new Date().toISOString() - } - - // FlowRun → completed - flowRun.status = "completed" - flowRun.completedAt = new Date().toISOString() - - return { ok: true, value: flowRun } -} diff --git a/src/flowrun/types.ts b/src/flowrun/types.ts deleted file mode 100644 index ce7ab48..0000000 --- a/src/flowrun/types.ts +++ /dev/null @@ -1,434 +0,0 @@ -export const FLOW_RUN_STAGES = ["requirements", "design", "tasks", "code", "test", "review", "merge"] as const -export type FlowStage = typeof FLOW_RUN_STAGES[number] - -export const FLOW_RUN_STATUSES = ["planned", "running", "blocked", "merging", "completed", "cancelled"] as const -export type FlowRunStatus = typeof FLOW_RUN_STATUSES[number] - -export const TASK_STATUSES = ["pending", "ready", "running", "blocked", "reviewing", "merged", "cancelled"] as const -export type TaskStatus = typeof TASK_STATUSES[number] - -export const STAGE_STATUSES = ["pending", "running", "pass", "failed", "blocked"] as const -export type StageStatus = typeof STAGE_STATUSES[number] - -export const CHECKPOINT_STATUSES = ["pending", "pass", "fail"] as const -export type CheckpointStatus = typeof CHECKPOINT_STATUSES[number] - -// ─── v1 兼容类型(保留) ─── - -export interface EvidenceEntry { - command: string - exitCode: number | null - summary: string - timestamp: string -} - -export interface Checkpoint { - name: string - status: CheckpointStatus - evidence: EvidenceEntry[] -} - -export interface StageState { - status: StageStatus - requiredArtifacts: string[] - checks: Checkpoint[] - completedAt: string | null - evidence: EvidenceEntry[] -} - -// ─── Schema v2 新增类型 ─── - -export interface AcceptanceCriterion { - id: string - description: string - verification: "tdd" | "regression" | "manual" -} - -export type TddMode = "strict" | "relaxed" | "bypass" -export type TddEnforcement = "advisory" | "runtime" - -export interface TddRunnerPolicy { - adapter: "vitest" - baseCommand: string - timeoutMs: number - executionInputPatterns: string[] -} - -export interface VersionedDigest { - algorithm: "sha256-content-v1" | "sha256-output-v1" | "git-tree-v1" - value: string -} - -export type TddApproval = - | { - kind: "planning-pr" - repo: string - prNumber: number - reviewId: number - approver: string - mergedCommitSha: string - policyDigest: string - } - | { - kind: "issue-comment" - repo: string - issueNumber: number - commentId: number - approver: string - commentBodyDigest: VersionedDigest - policyDigest: string - } - | { - kind: "legacy-migration" - fromSchemaVersion: 1 - } - -export interface TaskCommand { - command: string - cwd: string - timeoutMs: number - env: Record -} - -export type AlternativeValidation = - | { validationId: string; kind: "command"; command: TaskCommand } - | { validationId: string; kind: "manual"; description: string } - -export interface TddException { - reason: string - alternativeValidation: AlternativeValidation[] - approval: TddApproval -} - -export interface TddPolicy { - mode: TddMode - enforcement: TddEnforcement - runner: TddRunnerPolicy | null - testFilePatterns: string[] - implementationFilePatterns: string[] - generatedArtifactPatterns: string[] - exception: TddException | null - source: { - manifestPath: string - revisionSha: string - } -} - -export type TddFailureKind = - | "assertion" - | "missing-behavior" - | "infrastructure" - | "timeout" - | "unknown" - -export interface TddCommandEvidence { - command: string - testSelector: string | null - exitCode: number | null - failureKind: TddFailureKind | null - testsCollected: number | null - testsFailed: number | null - startedAt: string - finishedAt: string - durationMs: number - changedFiles: string[] - outputDigest: VersionedDigest - workspaceDigest: VersionedDigest - executionInputDigest: VersionedDigest - summary: string -} - -export interface TddTaskStartEvidence { - status: "pending" | "pass" | "fail" - headSha: string | null - treeSha: string | null - startedAt: string | null -} - -export interface TaskExecutionBinding { - branch: string - baseSha: string - startHeadSha: string - worktreeId: string - sessionId: string -} - -export interface TddCycleEvidence { - cycleId: string - criterionId: string - reworkRevision: number - status: "started" | "red" | "pass" | "failed" | "abandoned" - startWorkspaceDigest: VersionedDigest - testFiles: string[] - redTestDigest: VersionedDigest | null - redAttempts: TddCommandEvidence[] - greenAttempts: TddCommandEvidence[] -} - -export interface TddRegressionEvidence { - status: "pending" | "pass" | "fail" | "skipped" - headSha: string | null - treeSha: string | null - reworkRevision: number - runs: TddCommandEvidence[] -} - -export interface FinalVerificationEvidence { - status: "pending" | "pass" | "fail" - headSha: string | null - treeSha: string | null - runs: TddCommandEvidence[] -} - -export type AlternativeValidationEvidence = - | { - validationId: string - kind: "command" - status: "pass" | "fail" - headSha: string - treeSha: string - reworkRevision: number - evidence: TddCommandEvidence - } - | { - validationId: string - kind: "manual" - status: "pass" | "fail" - headSha: string - treeSha: string - reworkRevision: number - reviewRef: string - reviewer: string - contentDigest: VersionedDigest - policyDigest: string - summary: string - } - -export interface ReworkApproval { - reworkRevision: number - kind: "refactor" - headSha: string - treeSha: string - reviewerSessionId: string - reviewerMessageId: string - contentDigest: VersionedDigest - policyDigest: string -} - -export interface TddReworkEvidence { - reworkRevision: number - kind: "behavior" | "refactor" - affectedCriterionIds: string[] - status: "started" | "evidence-ready" | "pass" | "fail" - startHeadSha: string - approval: ReworkApproval | null -} - -export interface TddEvidence { - revision: number - reworkRevision: number - status: - | "not-recorded" - | "pending" - | "in-progress" - | "pass" - | "fail" - | "waived" - taskStart: TddTaskStartEvidence - cycles: TddCycleEvidence[] - regression: TddRegressionEvidence - verification: FinalVerificationEvidence - alternativeValidation: AlternativeValidationEvidence[] - reworks: TddReworkEvidence[] - warnings: string[] - updatedAt: string | null -} - -export interface CoveragePolicy { - command: string - threshold: number - report: { - format: "istanbul-json-summary" - path: string - metric: "lines" - } -} - -export interface CoverageEvidence { - status: "pending" | "pass" | "fail" - headSha: string - actual: number | null - threshold: number - metric: "lines" - reportDigest: string | null - summary: string -} - -export interface TddComplianceCheckpoint { - status: "pending" | "pass" | "fail" | "waived" - evidenceRevision: number - reworkRevision: number - headSha: string - treeSha: string - summary: string -} - -export interface PRCheckpoints { - prNumber: number - localChecks: Checkpoint - ciChecks: Checkpoint - reviewerApproval: Checkpoint - goalVerification: Checkpoint - branchProtection: Checkpoint - mergeResult: Checkpoint - // v2 新增 - tddCompliance: TddComplianceCheckpoint | null - verification: FinalVerificationEvidence | null - coverage: CoverageEvidence | null - qualityContractDigest: string | null -} - -export interface TaskState { - // 保留的 v1 字段 - id: string - name: string - status: TaskStatus - dependsOn: string[] - area: string - expectedFiles: string[] - parallelSafe: boolean - prNumber: number | null - prCheckpoints: PRCheckpoints | null - blockedReason: string | null - startedAt: string | null - - // v2 替换字段(acceptance → acceptanceCriteria,testCommands 类型变更) - acceptanceCriteria: AcceptanceCriterion[] - testCommands: TaskCommand[] - verifyCommands: TaskCommand[] - - // v2 新增字段 - executionBinding: TaskExecutionBinding | null - tddPolicy: TddPolicy - tddEvidence: TddEvidence - coveragePolicy: CoveragePolicy | null -} - -export interface RepositoryQualityPolicy { - mode: "off" | "required" - requiredChecks: Array<{ - context: string - appId: number - workflowPath: string - workflowRef: string - workflowBlobSha: string - }> -} - -export interface FlowRun { - flowRunId: string - repo: string - parentIssueNumber: number - status: FlowRunStatus - schemaVersion: number - revision: number - stages: Record - tasks: Record - startedAt: string | null - lastTickAt: string | null - nextTickAfter: string | null - maxRuntime: number - completedAt: string | null - // v2 新增 - repositoryQualityPolicy: RepositoryQualityPolicy -} - -export interface GoalFlowRunRef { - repo: string - parentIssueNumber: number - flowRunId: string -} - -// ─── flow_control 工具类型 ─── - -export type FlowControlOp = - | "run-start" - | "stage-start" - | "stage-complete" - | "task-start" - | "pr-create" - | "run-finalize" - -export interface FlowControlRequest { - op: FlowControlOp - parentIssueNumber: number - - // stage-start / stage-complete 用 - stage?: FlowStage - - // task-start 用 - taskId?: string - executionBinding?: TaskExecutionBinding - tddPolicy?: TddPolicy - - // pr-create 用 - currentHeadSha?: string - prTitle?: string - prBody?: string - baseBranch?: string -} - -export interface FlowControlResponse { - ok: boolean - error?: { - code: string - message: string - } - flowRunStatus?: FlowRunStatus - stage?: { - name: FlowStage - status: StageStatus - } - task?: { - taskId: string - status: TaskStatus - executionBinding?: TaskExecutionBinding | null - } - pr?: { - prNumber: number - prUrl: string - created: boolean - } -} - -export interface ValidationError { - path: string - message: string -} - -export type FlowRunReadResult = - | { ok: true; data: FlowRun; migrated: boolean } - | { - ok: false - code: "NOT_FOUND" | "INVALID_JSON" | "UNSUPPORTED_SCHEMA" | "MIGRATION_FAILED" | "VALIDATION_FAILED" - errors: ValidationError[] - } - -// ─── 常量 ─── - -export const CURRENT_SCHEMA_VERSION = 2 -export const DEFAULT_MAX_RUNTIME_MS = 86_400_000 - -export const CABINET_START_MARKER = "" -export const CABINET_END_MARKER = "" - -export const LABEL_PREFIX = "cabbage:" -export const FLOW_RUN_LABELS = { - flow: `${LABEL_PREFIX}flow`, - running: `${LABEL_PREFIX}running`, - blocked: `${LABEL_PREFIX}blocked`, - merging: `${LABEL_PREFIX}merging`, - completed: `${LABEL_PREFIX}completed`, - cancelled: `${LABEL_PREFIX}cancelled`, - resume: `${LABEL_PREFIX}resume`, -} as const diff --git a/src/flowrun/validator.ts b/src/flowrun/validator.ts deleted file mode 100644 index 4ffb743..0000000 --- a/src/flowrun/validator.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { - type FlowRun, type FlowStage, type TaskState, type StageState, type PRCheckpoints, - type FlowRunStatus, type TaskStatus, type StageStatus, - FLOW_RUN_STAGES, FLOW_RUN_STATUSES, TASK_STATUSES, STAGE_STATUSES, CHECKPOINT_STATUSES, - type ValidationError, -} from "./types.js" - -export type { ValidationError } from "./types.js" - -export function validateFlowRun(raw: unknown): { data: FlowRun | null; errors: ValidationError[] } { - const errors: ValidationError[] = [] - - if (!raw || typeof raw !== "object") { - errors.push({ path: "", message: "FlowRun must be a non-null object" }) - return { data: null, errors } - } - - const obj = raw as Record - - if (typeof obj.flowRunId !== "string" || !obj.flowRunId) { - errors.push({ path: "flowRunId", message: "Required string" }) - } - - if (typeof obj.repo !== "string" || !obj.repo) { - errors.push({ path: "repo", message: "Required string" }) - } - - if (typeof obj.parentIssueNumber !== "number" || obj.parentIssueNumber <= 0) { - errors.push({ path: "parentIssueNumber", message: "Required positive number" }) - } - - if (!FLOW_RUN_STATUSES.includes(obj.status as FlowRunStatus)) { - errors.push({ path: "status", message: `Must be one of: ${FLOW_RUN_STATUSES.join(", ")}` }) - } - - if (typeof obj.schemaVersion !== "number") { - errors.push({ path: "schemaVersion", message: "Required number" }) - } - - if (typeof obj.revision !== "number" || obj.revision < 0) { - errors.push({ path: "revision", message: "Required non-negative number" }) - } - - // v2: FlowRun 级 repositoryQualityPolicy - if (!obj.repositoryQualityPolicy || typeof obj.repositoryQualityPolicy !== "object") { - errors.push({ path: "repositoryQualityPolicy", message: "Required object" }) - } else { - const rqp = obj.repositoryQualityPolicy as Record - if (typeof rqp.mode !== "string" || !["off", "required"].includes(rqp.mode as string)) { - errors.push({ path: "repositoryQualityPolicy.mode", message: 'Must be "off" or "required"' }) - } - if (!Array.isArray(rqp.requiredChecks)) { - errors.push({ path: "repositoryQualityPolicy.requiredChecks", message: "Required array" }) - } - } - - validateStages(obj.stages, errors) - validateTasks(obj.tasks, errors) - - const data = errors.length === 0 ? (raw as FlowRun) : null - return { data, errors } -} - -function validateStages(stages: unknown, errors: ValidationError[]): boolean { - if (!stages || typeof stages !== "object") { - errors.push({ path: "stages", message: "Required object with stage keys" }) - return false - } - - const stageObj = stages as Record - - for (const key of FLOW_RUN_STAGES) { - const s = stageObj[key] - if (!s || typeof s !== "object") { - errors.push({ path: `stages.${key}`, message: "Missing or invalid stage object" }) - continue - } - validateStage(key as FlowStage, s as Record, errors) - } - - return true -} - -function validateStage(stageKey: string, stage: Record, errors: ValidationError[]) { - if (!STAGE_STATUSES.includes(stage.status as StageStatus)) { - errors.push({ path: `stages.${stageKey}.status`, message: "Invalid stage status" }) - } - if (!Array.isArray(stage.requiredArtifacts)) { - errors.push({ path: `stages.${stageKey}.requiredArtifacts`, message: "Required array" }) - } - if (typeof stage.completedAt !== "string" && stage.completedAt !== null) { - errors.push({ path: `stages.${stageKey}.completedAt`, message: "Must be string or null" }) - } - if (!Array.isArray(stage.checks)) { - errors.push({ path: `stages.${stageKey}.checks`, message: "Required array" }) - } - if (!Array.isArray(stage.evidence)) { - errors.push({ path: `stages.${stageKey}.evidence`, message: "Required array" }) - } -} - -function validateTasks(tasks: unknown, errors: ValidationError[]): boolean { - if (!tasks || typeof tasks !== "object") { - errors.push({ path: "tasks", message: "Required object with task keys" }) - return false - } - - const taskObj = tasks as Record - - for (const [key, value] of Object.entries(taskObj)) { - if (!value || typeof value !== "object") { - errors.push({ path: `tasks.${key}`, message: "Invalid task object" }) - continue - } - validateTask(key, value as Record, errors) - } - - return true -} - -function validateTask(taskKey: string, task: Record, errors: ValidationError[]) { - if (!task.id || typeof task.id !== "string") { - errors.push({ path: `tasks.${taskKey}.id`, message: "Required string" }) - } - if (!TASK_STATUSES.includes(task.status as TaskStatus)) { - errors.push({ path: `tasks.${taskKey}.status`, message: "Invalid task status" }) - } - if (!Array.isArray(task.dependsOn)) { - errors.push({ path: `tasks.${taskKey}.dependsOn`, message: "Required array" }) - } - if (!Array.isArray(task.expectedFiles)) { - errors.push({ path: `tasks.${taskKey}.expectedFiles`, message: "Required array" }) - } - if (!Array.isArray(task.testCommands)) { - errors.push({ path: `tasks.${taskKey}.testCommands`, message: "Required array" }) - } - // v2: acceptanceCriteria - if (!Array.isArray(task.acceptanceCriteria)) { - errors.push({ path: `tasks.${taskKey}.acceptanceCriteria`, message: "Required array" }) - } - // v2: verifyCommands - if (!Array.isArray(task.verifyCommands)) { - errors.push({ path: `tasks.${taskKey}.verifyCommands`, message: "Required array" }) - } - // v2: tddPolicy - if (!task.tddPolicy || typeof task.tddPolicy !== "object") { - errors.push({ path: `tasks.${taskKey}.tddPolicy`, message: "Required object" }) - } - // v2: tddEvidence - if (!task.tddEvidence || typeof task.tddEvidence !== "object") { - errors.push({ path: `tasks.${taskKey}.tddEvidence`, message: "Required object" }) - } -} - -// ─── DAG Validator ─── - -export interface DagValidationError { - path: string - message: string -} - -/** - * 验证 Task DAG: - * - Task ID 唯一 - * - record key 与 Task.id 一致 - * - 所有依赖存在 - * - 禁止自依赖 - * - 检测环路 - */ -export function validateFlowRunDag(tasks: Record): DagValidationError[] { - const errors: DagValidationError[] = [] - - // Task ID 唯一(Record key 天然唯一,检查 key 与 id 是否一致) - const seenIds = new Set() - for (const [key, task] of Object.entries(tasks)) { - if (key !== task.id) { - errors.push({ path: `tasks.${key}`, message: `Record key "${key}" does not match task.id "${task.id}"` }) - } - if (seenIds.has(task.id)) { - errors.push({ path: `tasks.${key}`, message: `Duplicate task id "${task.id}"` }) - } - seenIds.add(task.id) - } - - // 禁止自依赖 + 依赖存在 - for (const [key, task] of Object.entries(tasks)) { - for (const dep of task.dependsOn) { - if (dep === task.id) { - errors.push({ path: `tasks.${key}.dependsOn`, message: `Task "${task.id}" depends on itself` }) - } - if (!seenIds.has(dep)) { - errors.push({ path: `tasks.${key}.dependsOn`, message: `Dependency "${dep}" not found in tasks` }) - } - } - } - - // 环路检测(拓扑排序) - if (errors.length === 0) { - const cycleError = detectCycle(tasks, seenIds) - if (cycleError) { - errors.push(cycleError) - } - } - - return errors -} - -/** - * 拓扑排序检测环路,返回发现环路时的错误 - */ -function detectCycle(tasks: Record, allIds: Set): DagValidationError | null { - const inDegree = new Map() - const adjacency = new Map() - - for (const id of allIds) { - inDegree.set(id, 0) - adjacency.set(id, []) - } - - for (const task of Object.values(tasks)) { - for (const dep of task.dependsOn) { - adjacency.get(dep)!.push(task.id) - inDegree.set(task.id, (inDegree.get(task.id) ?? 0) + 1) - } - } - - const queue: string[] = [] - for (const [id, degree] of inDegree) { - if (degree === 0) queue.push(id) - } - - let visitedCount = 0 - while (queue.length > 0) { - const current = queue.shift()! - visitedCount++ - for (const neighbor of adjacency.get(current) ?? []) { - const newDegree = (inDegree.get(neighbor) ?? 1) - 1 - inDegree.set(neighbor, newDegree) - if (newDegree === 0) queue.push(neighbor) - } - } - - if (visitedCount !== allIds.size) { - // 找出环路中的节点 - const cycleNodes = [...allIds].filter(id => (inDegree.get(id) ?? 0) > 0) - return { - path: "tasks", - message: `Circular dependency detected involving: ${cycleNodes.join(", ")}`, - } - } - - return null -} diff --git a/src/kernel/review.ts b/src/kernel/review.ts new file mode 100644 index 0000000..736b5ed --- /dev/null +++ b/src/kernel/review.ts @@ -0,0 +1,105 @@ +/** + * 审查与合并 kernel 逻辑(spec §4.1,自 flowrun/merge.ts + ci.ts 迁入,逻辑不变)。 + * - mergeTaskPR:标准化合并 PR(--match-head-commit 防偷换) + * - checkBranchProtection:默认分支保护探测 + * - createRevertPR:回滚 PR + */ + +// ─── 可替换的 gh executor(用于测试) ─── + +export type GhFn = (args: string) => Promise<{ stdout: string; stderr: string }> + +let mergeGhExecutor: GhFn | null = null + +export function setMergeGhExecutor(fn: GhFn | null) { + mergeGhExecutor = fn +} + +async function gh(args: string): Promise<{ stdout: string; stderr: string }> { + if (mergeGhExecutor) { + return mergeGhExecutor(args) + } + const mod = await import("../util/gh.js") + return mod.gh(args) +} + +// ─── 类型 ─── + +export interface BranchProtectionStatus { + exists: boolean + requiredChecks: string[] + requiresPR: boolean + dismissesStale: boolean +} + +// ─── Branch Protection ─── + +export async function checkBranchProtection(owner: string, repo: string): Promise { + try { + const { stdout } = await gh(`api repos/${owner}/${repo}/branches/main/protection`) + const data = JSON.parse(stdout) + + const checks: string[] = [] + const requiredPulls = data?.required_pull_request_reviews ?? null + const requiredChecks = data?.required_status_checks?.checks ?? [] + + for (const c of requiredChecks) { + checks.push(c.context) + } + + return { + exists: true, + requiredChecks: checks, + requiresPR: !!requiredPulls, + dismissesStale: requiredPulls?.dismiss_stale_reviews ?? false, + } + } catch { + return { + exists: false, + requiredChecks: [], + requiresPR: false, + dismissesStale: false, + } + } +} + +// ─── PR 合并操作 ─── + +/** + * Task PR 合并(带 --match-head-commit 安全检查)。 + * + * 步骤: + * 1. 验证 verifiedSha 非空 + * 2. 使用 gh pr merge --squash --delete-branch --match-head-commit + * 3. 失败时返回错误 + */ +export async function mergeTaskPR(prNumber: number, verifiedSha: string): Promise<{ success: boolean; error?: string }> { + if (!verifiedSha) { + return { success: false, error: "verifiedSha is required for merge --match-head-commit" } + } + + try { + // --match-head-commit 确保只有 verified 的 commit 才会被合并 + await gh(`pr merge ${prNumber} --squash --delete-branch --match-head-commit ${verifiedSha}`) + return { success: true } + } catch (err) { + return { success: false, error: String(err) } + } +} + +// ─── Revert ─── + +export async function createRevertPR(prNumber: number, reason: string): Promise<{ prNumber?: number; error?: string }> { + try { + const { stdout } = await gh(`pr view ${prNumber} --json headRefName,headRepository,body,title`) + const pr = JSON.parse(stdout) + + const revertTitle = `revert: ${pr.title}` + const revertBody = `Reverts #${prNumber}\n\nReason: ${reason}\n\nAuto-generated by opencode-cabbage.` + + const { stdout: newPR } = await gh(`pr create --title '${revertTitle}' --body '${revertBody}' --base main --head main`) + return { prNumber: parseInt(newPR.trim().split("/").pop() || "0", 10) } + } catch (err) { + return { error: String(err) } + } +} diff --git a/src/kernel/task.ts b/src/kernel/task.ts index 69d10da..b6dc424 100644 --- a/src/kernel/task.ts +++ b/src/kernel/task.ts @@ -20,8 +20,8 @@ import { worktreeStart, destroyWorktree } from "./worktree.js" import { createTaskRecord, readFlowRecord, readTddEvidenceComment, extractEvidenceBlock } from "./records.js" import { captureWorkspaceBaseline, type WorkspaceBaseline } from "./tdd/evidence.js" import { evaluateTddCompliance } from "./tdd/evaluator.js" -// merge 门禁复用 flowrun/merge.ts 的既有实现(spec §4.1:mergeTaskPR / checkBranchProtection;批 15 统一迁 kernel/review.ts) -import { mergeTaskPR, checkBranchProtection } from "../flowrun/merge.js" +// merge 门禁复用 kernel/review.ts 的既有实现(spec §4.1:mergeTaskPR / checkBranchProtection) +import { mergeTaskPR, checkBranchProtection } from "./review.js" import type { AcceptanceCriterion, FinalVerificationEvidence, diff --git a/src/plugin/broker.ts b/src/plugin/broker.ts deleted file mode 100644 index 218cc2f..0000000 --- a/src/plugin/broker.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { FlowRun } from "../flowrun/types.js" -import { readFlowRunWithLock, writeFlowRunWithLock as ghWriteFlowRunWithLock } from "../flowrun/github.js" -import { KeyedMutex } from "../kernel/mutex.js" - -// ─── 类型 ─── - -export interface BrokerCredentials { - /** 独立 GitHub API token(不传入 Agent shell) */ - token: string -} - -export interface MutateResult { - flowRun: FlowRun - result: R - shouldPersist: boolean -} - -export type WriteResult = - | { ok: true; flowRun: FlowRun; result: R; persisted: boolean } - | { ok: false; code: "PERSIST_CONFLICT" | "READ_FAILED"; message: string } - -// ─── FlowBroker ─── - -/** - * 单进程 broker,负责: - * 1. 按 parentIssueNumber 的 keyed mutex 串行所有 FlowRun 写入 - * 2. read-modify-write 循环(乐观锁) - * 3. 检测外部 body 变化 → PERSIST_CONFLICT - * 4. 持有独立 GitHub API 凭证,Agent shell 不可访问 - * - * 所有 FlowRun/Task/Evidence 写入必须经过 broker,不直接暴露 GitHub API。 - * - * 凭证安全:credentials 存储在闭包变量中,不挂载在 this 上, - * JSON.stringify / Object.keys 无法访问。 - */ -export class FlowBroker { - private mutex = new KeyedMutex() - #credentials: BrokerCredentials | null - - /** - * @param credentials 可选。独立 GitHub API 凭证。 - * 传入时:所有 gh 操作使用该 token,而非 ambient 环境。 - * 不传时:降级使用 ambient 环境变量(GH_TOKEN etc.)。 - */ - constructor(credentials?: BrokerCredentials) { - this.#credentials = credentials ?? null - } - - /** - * 获取凭证对应的环境变量覆盖(仅内部使用)。 - * 返回 undefined 表示无独立凭证,应使用 ambient 环境。 - */ - private get ghEnv(): Record | undefined { - if (!this.#credentials) return undefined - return { - GH_TOKEN: this.#credentials.token, - GITHUB_TOKEN: this.#credentials.token, - } - } - - /** - * 验证 broker 持有的 GitHub 凭证是否有效。 - * - * 调用 `gh auth status` 检查 token 可用性。 - * 无凭证时返回 ok: false。 - */ - async verifyCredentials(): Promise<{ ok: boolean; message: string }> { - if (!this.#credentials) { - return { ok: false, message: "No broker credentials configured" } - } - try { - const { gh } = await import("../util/gh.js") - await gh("auth status", 15_000, this.ghEnv) - return { ok: true, message: "Broker credentials are valid" } - } catch (err) { - return { ok: false, message: `Broker credentials verification failed: ${String(err)}` } - } - } - - /** - * 将操作加入 keyed mutex 队列。 - * - * 用于需要串行化但对 FlowRun 无影响的通用操作。 - */ - async enqueue(parentIssueNumber: number, op: () => Promise): Promise { - return this.mutex.runExclusive(parentIssueNumber, op) - } - - /** - * read-modify-write 循环:读取 FlowRun → 应用 handler 变更 → 乐观锁写回。 - * - * - handler 返回 { flowRun, result, shouldPersist } - * - shouldPersist=false 时跳过写入(如校验失败) - * - 自动增加 revision 和时间戳 - * - body 不匹配时返回 PERSIST_CONFLICT - * - 使用 broker 独立凭证执行 gh 操作 - */ - async writeFlowRunWithLock( - issueNumber: number, - handler: (flowRun: FlowRun) => MutateResult, - ): Promise> { - return this.mutex.runExclusive(issueNumber, async () => { - // ── 读取(broker 凭证) ── - const { flowRunResult, currentBody } = await readFlowRunWithLock(issueNumber, this.ghEnv) - if (!flowRunResult.ok || currentBody === null) { - return { - ok: false as const, - code: "READ_FAILED" as const, - message: `Failed to read FlowRun from issue #${issueNumber}: ${flowRunResult.ok ? "missing body" : flowRunResult.code}`, - } - } - - // ── 修改 ── - const { flowRun: updated, result, shouldPersist } = handler(flowRunResult.data) - - if (!shouldPersist) { - return { - ok: true as const, - flowRun: flowRunResult.data, - result, - persisted: false, - } - } - - // ── 写入(乐观锁,broker 凭证) ── - updated.revision += 1 - updated.lastTickAt = new Date().toISOString() - - const writeResult = await ghWriteFlowRunWithLock(issueNumber, updated, currentBody, this.ghEnv) - if (!writeResult.success) { - return { - ok: false as const, - code: "PERSIST_CONFLICT" as const, - message: writeResult.error ?? "Issue body changed since last read — external modification detected", - } - } - - return { - ok: true as const, - flowRun: updated, - result, - persisted: true, - } - }) - } -} diff --git a/src/plugin/server.ts b/src/plugin/server.ts index 5f49a61..690552f 100644 --- a/src/plugin/server.ts +++ b/src/plugin/server.ts @@ -1,5 +1,4 @@ import type { Plugin } from "@opencode-ai/plugin" -import { tool } from "@opencode-ai/plugin/tool" import path from "node:path" import { initPrompts } from "./prompts.js" @@ -10,16 +9,11 @@ import { loadAgents } from "./agents.js" import { createAgentShellEnv } from "./shell.js" import { createGoalClient, createGoalTool, readGoal, writeGoal, MAX_CONTINUATIONS, continuationPrompt, formatGoal } from "./goal.js" import { readIndex, updateFlowSession } from "../kernel/session-index.js" -import { FlowBroker } from "./broker.js" -import { - flowRunStart, - flowStageStart, - flowStageComplete, - flowTaskStart, - flowRunFinalize, -} from "../flowrun/transitions.js" -import type { TaskExecutionBinding, FlowControlResponse } from "../flowrun/types.js" -import { readFlowRun } from "../flowrun/github.js" +import { createSetupControlTool } from "./setup-control.js" +import { createFlowControlTool } from "./flow-control.js" +import { createTaskControlTool } from "./task-control.js" +import { createTddCheckpointTool } from "./tdd-checkpoint.js" +import { createReleaseControlTool } from "./release-control.js" import { getContextBlock, formatContextBlock, CONTEXT_MARKER } from "../kernel/context.js" import type { ContextBlock } from "../kernel/context.js" import { readProjectProfile } from "../kernel/profile.js" @@ -218,269 +212,25 @@ function startPeriodicCleanup() { }, ABORTED_SESSION_CLEANUP_INTERVAL) } -// ─── Flow Control Tool ─── - -function createFlowControlTool( - broker: FlowBroker, -) { - return tool({ - description: `Control the FlowRun lifecycle: start a FlowRun, transition stages, start tasks, and finalize completed runs. - -Operations: -- run-start: Transition FlowRun from planned → running. Binds Goal to FlowRun. -- stage-start: Start a stage (requirements/design/tasks/code). Requires prerequisites met. -- stage-complete: Complete a stage (requirements/design/tasks). Requires all checks pass. -- task-start: Start a task (pending/ready → running). Freezes TDD policy and sets execution binding. -- run-finalize: Mark code→test→review→merge stages as pass, set FlowRun to completed. Requires all Tasks merged. Idempotent.`, - args: { - op: tool.schema.enum(["run-start", "stage-start", "stage-complete", "task-start", "run-finalize"]).describe("Flow control operation"), - parent_issue_number: tool.schema.number().describe("Parent GitHub Issue number containing the FlowRun"), - stage: tool.schema.enum(["requirements", "design", "tasks", "code", "test", "review", "merge"]).optional().describe("Stage name (for stage-start/stage-complete)"), - task_id: tool.schema.string().optional().describe("Task ID (for task-start)"), - execution_binding: tool.schema.object({ - branch: tool.schema.string(), - base_sha: tool.schema.string(), - start_head_sha: tool.schema.string(), - worktree_id: tool.schema.string(), - session_id: tool.schema.string(), - }).optional().describe("Execution binding for task-start"), - tdd_policy_json: tool.schema.string().optional().describe("JSON string of frozen TDD policy (for task-start)"), - }, - async execute(args, ctx) { - const op = args.op as string - const parentIssueNumber = args.parent_issue_number as number - - try { - switch (op) { - case "run-start": - return handleRunStart(broker, parentIssueNumber) - case "stage-start": - return handleStageStart(broker, parentIssueNumber, args.stage as string) - case "stage-complete": - return handleStageComplete(broker, parentIssueNumber, args.stage as string) - case "task-start": - return handleTaskStart(broker, parentIssueNumber, args.task_id as string, args.execution_binding as TaskExecutionBinding | undefined, args.tdd_policy_json as string | undefined) - case "run-finalize": - return handleRunFinalize(broker, parentIssueNumber) - default: - return errorResponse("UNKNOWN_OP", `Unknown operation: "${op}"`) - } - } catch (err) { - return errorResponse("INTERNAL_ERROR", String(err)) - } - }, - }) -} - -function okResponse(overrides: Partial = {}): string { - const resp: FlowControlResponse = { ok: true, ...overrides } - return JSON.stringify(resp, null, 2) -} - -function errorResponse(code: string, message: string): string { - const resp: FlowControlResponse = { ok: false, error: { code, message } } - return JSON.stringify(resp, null, 2) -} - -async function handleRunStart( - broker: FlowBroker, - parentIssueNumber: number, -): Promise { - // 通过 broker 执行状态迁移 - const writeResult = await broker.writeFlowRunWithLock(parentIssueNumber, (flowRun) => { - const res = flowRunStart(flowRun) - if (!res.ok) { - return { flowRun, result: res.error, shouldPersist: false } - } - return { flowRun: res.value, result: res.value.status, shouldPersist: true } - }) - - if (!writeResult.ok) { - return errorResponse(writeResult.code, writeResult.message) - } - - if (!writeResult.persisted) { - // 状态迁移被拒绝(如已是 running) - return errorResponse("INVALID_TRANSITION", "FlowRun is not in planned state") - } - - return okResponse({ - flowRunStatus: writeResult.flowRun.status, - }) -} - -async function handleStageStart( - broker: FlowBroker, - parentIssueNumber: number, - stageName: string, -): Promise { - if (!stageName) { - return errorResponse("POLICY_INVALID", "stage is required for stage-start") - } - - const writeResult = await broker.writeFlowRunWithLock(parentIssueNumber, (flowRun) => { - const res = flowStageStart(flowRun, stageName as any) - if (!res.ok) { - return { flowRun, result: res.error, shouldPersist: false } - } - return { - flowRun: res.value, - result: { stage: stageName, status: res.value.stages[stageName as keyof typeof res.value.stages].status }, - shouldPersist: true, - } - }) - - if (!writeResult.ok) { - return errorResponse(writeResult.code, writeResult.message) - } - - if (!writeResult.persisted && writeResult.result && typeof writeResult.result === "object" && "code" in writeResult.result) { - const err = writeResult.result as { code: string; message: string } - return errorResponse(err.code, err.message) - } - - return okResponse({ - flowRunStatus: writeResult.flowRun.status, - stage: writeResult.result as FlowControlResponse["stage"], - }) -} - -async function handleStageComplete( - broker: FlowBroker, - parentIssueNumber: number, - stageName: string, -): Promise { - if (!stageName) { - return errorResponse("POLICY_INVALID", "stage is required for stage-complete") - } - - const writeResult = await broker.writeFlowRunWithLock(parentIssueNumber, (flowRun) => { - const res = flowStageComplete(flowRun, stageName as any) - if (!res.ok) { - return { flowRun, result: res.error, shouldPersist: false } - } - return { - flowRun: res.value, - result: { stage: stageName, status: res.value.stages[stageName as keyof typeof res.value.stages].status }, - shouldPersist: true, - } - }) - - if (!writeResult.ok) { - return errorResponse(writeResult.code, writeResult.message) - } - - if (!writeResult.persisted && writeResult.result && typeof writeResult.result === "object" && "code" in writeResult.result) { - const err = writeResult.result as { code: string; message: string } - return errorResponse(err.code, err.message) - } - - return okResponse({ - flowRunStatus: writeResult.flowRun.status, - stage: writeResult.result as FlowControlResponse["stage"], - }) -} - -async function handleTaskStart( - broker: FlowBroker, - parentIssueNumber: number, - taskId: string, - executionBinding: TaskExecutionBinding | undefined, - tddPolicyJson: string | undefined, -): Promise { - if (!taskId) { - return errorResponse("POLICY_INVALID", "task_id is required for task-start") - } - - if (!executionBinding) { - return errorResponse("POLICY_INVALID", "execution_binding is required for task-start") - } - - // 解析 frozen policy - let tddPolicy = undefined - if (tddPolicyJson) { - try { - tddPolicy = JSON.parse(tddPolicyJson) - } catch { - return errorResponse("POLICY_INVALID", "tdd_policy_json is not valid JSON") - } - } - - const writeResult = await broker.writeFlowRunWithLock(parentIssueNumber, (flowRun) => { - const res = flowTaskStart(flowRun, taskId, executionBinding, tddPolicy) - if (!res.ok) { - return { flowRun, result: res.error, shouldPersist: false } - } - return { - flowRun: res.value.flowRun, - result: { - taskId, - status: res.value.task.status, - executionBinding: res.value.task.executionBinding, - }, - shouldPersist: true, - } - }) - - if (!writeResult.ok) { - return errorResponse(writeResult.code, writeResult.message) - } - - if (!writeResult.persisted && writeResult.result && typeof writeResult.result === "object" && "code" in writeResult.result) { - const err = writeResult.result as { code: string; message: string } - return errorResponse(err.code, err.message) - } - - return okResponse({ - flowRunStatus: writeResult.flowRun.status, - task: writeResult.result as FlowControlResponse["task"], - }) -} - -async function handleRunFinalize( - broker: FlowBroker, - parentIssueNumber: number, -): Promise { - const writeResult = await broker.writeFlowRunWithLock(parentIssueNumber, (flowRun) => { - const res = flowRunFinalize(flowRun) - if (!res.ok) { - return { flowRun, result: res.error, shouldPersist: false } - } - return { - flowRun: res.value, - result: { status: res.value.status, completedAt: res.value.completedAt }, - shouldPersist: true, - } - }) - - if (!writeResult.ok) { - return errorResponse(writeResult.code, writeResult.message) - } - - if (!writeResult.persisted && writeResult.result && typeof writeResult.result === "object" && "code" in writeResult.result) { - const err = writeResult.result as { code: string; message: string } - return errorResponse(err.code, err.message) - } - - return okResponse({ - flowRunStatus: writeResult.flowRun.status, - }) -} export function createOpencodeCabbage(packageRoot: string): Plugin { return async (ctx, _options) => { const sourceSkillsDir = path.join(packageRoot, "assets", "skills") - const contextDir = path.join(packageRoot, "assets", "context") const promptsDir = path.join(packageRoot, "assets", "prompts") const commandsDir = path.join(packageRoot, "assets", "commands") - const skillsDir = await setupSkillsDir(sourceSkillsDir, contextDir, promptsDir) + const skillsDir = await setupSkillsDir(sourceSkillsDir, promptsDir) const projectDir = ctx.worktree || ctx.directory const v1Client = (ctx.client as unknown as V1ClientContainer)._client const goalClient = createGoalClient(ctx.serverUrl, v1Client) const goalTool = createGoalTool(goalClient) - const broker = new FlowBroker() - const flowControlTool = createFlowControlTool(broker) + const sessionClient = goalClient + + const flowControlTool = createFlowControlTool({ projectDir, sessionClient }) + const taskControlTool = createTaskControlTool({ projectDir, sessionClient }) + const setupControlTool = createSetupControlTool({ projectDir, sessionClient }) + const tddCheckpointTool = createTddCheckpointTool({ projectDir, sessionClient }) + const releaseControlTool = createReleaseControlTool({ projectDir, sessionClient }) const agentsDir = path.join(packageRoot, "assets", "agents") @@ -494,6 +244,10 @@ export function createOpencodeCabbage(packageRoot: string): Plugin { tool: { goal: goalTool, flow_control: flowControlTool, + task_control: taskControlTool, + setup_control: setupControlTool, + tdd_checkpoint: tddCheckpointTool, + release_control: releaseControlTool, }, config: async (rawConfig) => { diff --git a/src/plugin/skills.ts b/src/plugin/skills.ts index 98f5e21..1d7c394 100644 --- a/src/plugin/skills.ts +++ b/src/plugin/skills.ts @@ -13,7 +13,7 @@ function resolveSkillsPath(): string { return path.join(homedir(), ".config", "opencode", "cabbage", "skills") } -export async function setupSkillsDir(sourceSkillsDir: string, contextDir?: string, promptsDir?: string): Promise { +export async function setupSkillsDir(sourceSkillsDir: string, promptsDir?: string): Promise { const destDir = resolveSkillsPath() // 清理旧内容,确保与插件版本一致 @@ -22,12 +22,6 @@ export async function setupSkillsDir(sourceSkillsDir: string, contextDir?: strin await cp(sourceSkillsDir, destDir, { recursive: true }) - if (contextDir) { - try { - await cp(contextDir, path.join(destDir, "_context"), { recursive: true }) - } catch {} - } - if (promptsDir) { try { await cp(promptsDir, path.join(destDir, "_prompts"), { recursive: true }) diff --git a/src/plugin/tdd-tool.ts b/src/plugin/tdd-tool.ts deleted file mode 100644 index 9906af9..0000000 --- a/src/plugin/tdd-tool.ts +++ /dev/null @@ -1,485 +0,0 @@ -import type { - TaskState, - TddEvidence, - TddCycleEvidence, - TddCommandEvidence, - VersionedDigest, -} from "../flowrun/types.js" -import { - startCycle, - recordRed, - recordGreen, - recordFinalRegression, - recordFinalVerification, - abandonCycle, - createTaskEvidence as createBaseEvidence, -} from "../flowrun/state.js" -import type { FlowBroker } from "./broker.js" - -// ─── 请求 / 响应类型 ─── - -export type TddCheckpointOp = - | "cycle-start" - | "red" - | "green" - | "final-regression" - | "final-verification" - | "status" - | "alternative-command" - | "abandon-cycle" - -export interface TddCheckpointRequest { - op: TddCheckpointOp - parentIssueNumber: number - taskId: string - - // cycle-start 用 - cycleId?: string - criterionId?: string - testPaths?: string[] - testSelector?: string - - // red 用(inline evidence for testing / mock) - redEvidence?: TddCommandEvidence - - // green 用(inline evidence for testing / mock) - greenEvidence?: TddCommandEvidence - - // abandon-cycle 用 - reason?: string - - // alternative-command 用 - validationId?: string - - // final-regression 用(inline for testing) - regressionRuns?: TddCommandEvidence[] - - // final-verification 用(inline for testing) - verificationRuns?: TddCommandEvidence[] -} - -export interface TddCheckpointResponse { - ok: boolean - evidence?: TddEvidence - cycle?: TddCycleEvidence - evidenceRevision?: number - error?: { - code: string - message: string - } -} - -/** - * Broker-aware 响应:包含冲突暂停标志。 - * PERSIST_CONFLICT 时 conflictPause=true,调用方应暂停 FlowRun。 - */ -export interface TddCheckpointBrokerResponse extends TddCheckpointResponse { - conflictPause: boolean - persisted: boolean -} - -// ─── 内部辅助 ─── - -const PLACEHOLDER_DIGEST: VersionedDigest = { - algorithm: "sha256-content-v1", - value: "0".repeat(64), -} - -/** - * 简单 glob 匹配(支持 *、**) - */ -function matchSimpleGlob(filePath: string, pattern: string): boolean { - const normalized = filePath.split("/").join("/") - let regexStr = "" - let i = 0 - while (i < pattern.length) { - if (pattern[i] === "*" && pattern[i + 1] === "*") { - i += 2 - if (i < pattern.length && pattern[i] === "/") { - regexStr += "(.*/)?" - i++ - } else { - regexStr += ".*" - } - } else if (pattern[i] === "*") { - regexStr += "[^/]*" - i++ - } else if (pattern[i] === ".") { - regexStr += "\\." - i++ - } else if (pattern[i] === "?") { - regexStr += "[^/]" - i++ - } else { - if ("+^$(){}[]|\\".includes(pattern[i])) { - regexStr += "\\" + pattern[i] - } else { - regexStr += pattern[i] - } - i++ - } - } - return new RegExp(`^${regexStr}$`).test(normalized) -} - -function matchesAnyPattern(path: string, patterns: string[]): boolean { - if (patterns.length === 0) return false - return patterns.some(p => matchSimpleGlob(path, p)) -} - -// ─── 重新导出 createTaskEvidence ─── - -export { createBaseEvidence as createTaskEvidence } - -// ─── 主处理函数 ─── - -/** - * 处理 tdd_checkpoint 工具请求。 - * - * 职责: - * - 验证 Task 状态(running + executionBinding) - * - 根据 op 分发到 state.ts 各纯函数 - * - 返回结构化 response(evidence revision + cycle/evidence) - * - * 注意:C2 只实现内存状态机。持久化由 C3 broker 负责。 - */ -export function handleTddCheckpoint( - req: TddCheckpointRequest, - task: TaskState, -): TddCheckpointResponse { - // ── 验证 Task 状态 ── - if (task.status !== "running") { - return { - ok: false, - error: { code: "TASK_NOT_RUNNING", message: `Task "${task.id}" is not running (status: ${task.status})` }, - } - } - - if (!task.executionBinding) { - return { - ok: false, - error: { code: "TASK_NOT_FOUND", message: `Task "${task.id}" has no execution binding` }, - } - } - - const evidence = task.tddEvidence - - // ── 分发 ── - - switch (req.op) { - case "status": - return { - ok: true, - evidence, - evidenceRevision: evidence.revision, - } - - case "cycle-start": - return handleCycleStart(req, task, evidence) - - case "red": - return handleRed(req, task, evidence) - - case "green": - return handleGreen(req, task, evidence) - - case "final-regression": - return handleFinalRegression(req, task, evidence) - - case "final-verification": - return handleFinalVerification(req, task, evidence) - - case "abandon-cycle": - return handleAbandonCycle(req, evidence) - - case "alternative-command": - // C2 暂不实现 alternative-command,由 C3 broker 处理 - return { - ok: false, - error: { code: "POLICY_INVALID", message: "alternative-command not implemented in C2" }, - } - - default: - return { - ok: false, - error: { code: "POLICY_INVALID", message: `Unknown op: "${(req as TddCheckpointRequest).op}"` }, - } - } -} - -function handleCycleStart(req: TddCheckpointRequest, task: TaskState, evidence: TddEvidence): TddCheckpointResponse { - const cycleId = req.cycleId - const criterionId = req.criterionId - if (!cycleId || !criterionId || !req.testPaths || !req.testSelector) { - return { - ok: false, - error: { code: "POLICY_INVALID", message: "cycle-start requires cycleId, criterionId, testPaths, testSelector" }, - } - } - - // workspace digest: 由 broker 计算,C2 阶段使用 placeholder - // (实际运行时 broker 调用 computeWorkspaceDigest 获取) - const wsDigest = PLACEHOLDER_DIGEST - - const result = startCycle( - evidence, - cycleId, - criterionId, - req.testPaths, - req.testSelector, - wsDigest, - task.acceptanceCriteria, - task.tddPolicy.testFilePatterns, - ) - - if (!result.ok) { - return { - ok: false, - error: { code: result.error.code, message: result.error.message }, - } - } - - // 更新 task.tddEvidence(内存变异,由调用方/C3 持久化) - task.tddEvidence = result.value.evidence - - return { - ok: true, - evidence: result.value.evidence, - cycle: result.value.cycle, - evidenceRevision: result.value.evidence.revision, - } -} - -function handleRed(req: TddCheckpointRequest, task: TaskState, evidence: TddEvidence): TddCheckpointResponse { - const cycleId = req.cycleId - if (!cycleId) { - return { - ok: false, - error: { code: "POLICY_INVALID", message: "red requires cycleId" }, - } - } - - // runner 必须存在 - if (!task.tddPolicy.runner) { - return { - ok: false, - error: { code: "RUNNER_UNSUPPORTED", message: "RED requires a configured test runner" }, - } - } - - // 使用内联 evidence(C2 测试模式)或由 broker 执行 vitest adapter - const redEvidence = req.redEvidence - if (!redEvidence) { - // 生产模式下由 broker 执行 vitest adapter 获取 evidence - // C2 阶段不直接执行命令 - return { - ok: false, - error: { code: "POLICY_INVALID", message: "red requires redEvidence (in testing mode) or broker execution" }, - } - } - - const result = recordRed(evidence, cycleId, redEvidence) - - if (!result.ok) { - return { - ok: false, - error: { code: result.error.code, message: result.error.message }, - } - } - - task.tddEvidence = result.value.evidence - - return { - ok: true, - evidence: result.value.evidence, - cycle: result.value.cycle, - evidenceRevision: result.value.evidence.revision, - } -} - -function handleGreen(req: TddCheckpointRequest, task: TaskState, evidence: TddEvidence): TddCheckpointResponse { - const cycleId = req.cycleId - if (!cycleId) { - return { - ok: false, - error: { code: "POLICY_INVALID", message: "green requires cycleId" }, - } - } - - if (!task.tddPolicy.runner) { - return { - ok: false, - error: { code: "RUNNER_UNSUPPORTED", message: "GREEN requires a configured test runner" }, - } - } - - const greenEvidence = req.greenEvidence - if (!greenEvidence) { - return { - ok: false, - error: { code: "POLICY_INVALID", message: "green requires greenEvidence (in testing mode) or broker execution" }, - } - } - - // 检查实现文件是否有变化 - const implPatterns = task.tddPolicy.implementationFilePatterns - const implFilesChanged = greenEvidence.changedFiles.some(f => matchesAnyPattern(f, implPatterns)) - - const result = recordGreen(evidence, cycleId, greenEvidence, implFilesChanged) - - if (!result.ok) { - return { - ok: false, - error: { code: result.error.code, message: result.error.message }, - } - } - - task.tddEvidence = result.value.evidence - - return { - ok: true, - evidence: result.value.evidence, - cycle: result.value.cycle, - evidenceRevision: result.value.evidence.revision, - } -} - -function handleFinalRegression(req: TddCheckpointRequest, task: TaskState, evidence: TddEvidence): TddCheckpointResponse { - // 必须有 testCommands - if (!req.regressionRuns && task.testCommands.length === 0) { - return { - ok: false, - error: { code: "REGRESSION_FAILED", message: "No test commands configured for final regression" }, - } - } - - // 使用内联 evidence 或占位 - const runs = req.regressionRuns ?? [] - - // headSha + treeSha 在生产模式下由 broker 通过 git 获取 - // C2 测试模式使用 placeholder - const headSha = "placeholder-head" - const treeSha = "placeholder-tree" - - const result = recordFinalRegression(evidence, headSha, treeSha, runs) - - if (!result.ok) { - return { - ok: false, - error: { code: result.error.code, message: result.error.message }, - } - } - - task.tddEvidence = result.value - - return { - ok: true, - evidence: result.value, - evidenceRevision: result.value.revision, - } -} - -function handleFinalVerification(req: TddCheckpointRequest, task: TaskState, evidence: TddEvidence): TddCheckpointResponse { - // 使用内联 evidence 或空数组(无 verifyCommands 时 pass) - const runs = req.verificationRuns ?? [] - - const headSha = "placeholder-head" - const treeSha = "placeholder-tree" - - const result = recordFinalVerification(evidence, headSha, treeSha, runs) - - if (!result.ok) { - return { - ok: false, - error: { code: result.error.code, message: result.error.message }, - } - } - - task.tddEvidence = result.value - - return { - ok: true, - evidence: result.value, - evidenceRevision: result.value.revision, - } -} - -function handleAbandonCycle(req: TddCheckpointRequest, evidence: TddEvidence): TddCheckpointResponse { - const cycleId = req.cycleId - const reason = req.reason ?? "no reason given" - - if (!cycleId) { - return { - ok: false, - error: { code: "POLICY_INVALID", message: "abandon-cycle requires cycleId" }, - } - } - - const result = abandonCycle(evidence, cycleId, reason) - - if (!result.ok) { - return { - ok: false, - error: { code: result.error.code, message: result.error.message }, - } - } - - return { - ok: true, - evidence: result.value, - evidenceRevision: result.value.revision, - } -} - -// ─── Broker-aware handler ─── - -/** - * 通过 FlowBroker 执行 tdd_checkpoint 操作。 - * - * 流程: - * 1. 通过 broker.writeFlowRunWithLock 读取 FlowRun - * 2. 提取目标 TaskState - * 3. 调用 handleTddCheckpoint 执行状态机转换 - * 4. 成功时持久化回 Issue(broker 自动处理 revision + 乐观锁) - * 5. PERSIST_CONFLICT 时设置 conflictPause=true - * - * @returns TddCheckpointBrokerResponse — 包含 conflictPause 标志 - */ -export async function handleTddCheckpointWithBroker( - broker: FlowBroker, - req: TddCheckpointRequest, -): Promise { - const writeResult = await broker.writeFlowRunWithLock(req.parentIssueNumber, (flowRun) => { - const task = flowRun.tasks[req.taskId] - if (!task) { - const resp: TddCheckpointResponse = { - ok: false, - error: { code: "TASK_NOT_FOUND", message: `Task "${req.taskId}" not found in FlowRun` }, - } - return { flowRun, result: resp, shouldPersist: false } - } - - const tddResp = handleTddCheckpoint(req, task) - return { flowRun, result: tddResp, shouldPersist: tddResp.ok } - }) - - if (!writeResult.ok) { - // PERSIST_CONFLICT or READ_FAILED - return { - ok: false, - conflictPause: writeResult.code === "PERSIST_CONFLICT", - persisted: false, - error: { - code: writeResult.code, - message: writeResult.message, - }, - } - } - - // 成功(或 shouldPersist=false) - return { - ...writeResult.result, - conflictPause: false, - persisted: writeResult.persisted, - } -} diff --git a/src/util/paths.ts b/src/util/paths.ts index bc2b0a7..88d4eac 100644 --- a/src/util/paths.ts +++ b/src/util/paths.ts @@ -59,14 +59,6 @@ export function devGuidesDir(projectDir: string) { return path.join(docsRoot(projectDir), "dev", "guides") } -export function devHandoffDir(projectDir: string) { - return path.join(docsRoot(projectDir), "dev", "handoff") -} - -export function outOfScopePath(projectDir: string) { - return path.join(docsRoot(projectDir), "dev", "out-of-scope.md") -} - export function prdPath(projectDir: string, name: string) { return path.join(prdDir(projectDir), `${slugify(name)}.md`) } @@ -84,7 +76,7 @@ export function pluginTemplateDir(projectDir: string) { return path.join(projectDir, ".opencode", PLUGIN_ID, "templates") } -const DOCS_SUBDIRS = ["prd", "adr", "dev/specs", "dev/tasks", "dev/api", "dev/db", "dev/guides", "dev/handoff"] +const DOCS_SUBDIRS = ["prd", "adr", "dev/specs", "dev/tasks", "dev/api", "dev/db", "dev/guides"] export async function ensureDocsStructure(projectDir: string) { const created: string[] = [] diff --git a/test/flowrun/adapter.test.ts b/test/flowrun/adapter.test.ts deleted file mode 100644 index 569e9ae..0000000 --- a/test/flowrun/adapter.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { describe, it, expect } from "vitest" -import type { TddRunnerPolicy, TddCommandEvidence } from "../../src/flowrun/types.js" -import { validateSelector, classifyVitestFailure, buildVitestArgs } from "../../src/flowrun/adapter.js" - -// ─── 辅助函数 ─── - -function makePolicy(overrides: Partial = {}): TddRunnerPolicy { - return { - adapter: "vitest", - baseCommand: "npx vitest run", - timeoutMs: 30000, - executionInputPatterns: ["package.json", "vitest.config.ts"], - ...overrides, - } -} - -// ─── selector 语法校验 ─── - -describe("validateSelector", () => { - it("accepts valid test file paths", () => { - expect(() => validateSelector("test/sum.test.ts")).not.toThrow() - expect(() => validateSelector("test/foo/bar.test.ts")).not.toThrow() - expect(() => validateSelector("src/__tests__/sum.test.ts")).not.toThrow() - }) - - it("accepts valid vitest selectors like --grep or --bail", () => { - expect(() => validateSelector("--bail=1")).not.toThrow() - expect(() => validateSelector("--reporter=verbose")).not.toThrow() - expect(() => validateSelector("--update")).not.toThrow() - }) - - it("rejects selector containing pipe", () => { - expect(() => validateSelector("test/sum.test.ts | cat")).toThrow(/selector|invalid|dangerous/i) - }) - - it("rejects selector containing semicolon", () => { - expect(() => validateSelector("test/sum.test.ts; rm -rf /")).toThrow(/selector|invalid|dangerous/i) - }) - - it("rejects selector containing ampersand", () => { - expect(() => validateSelector("test/sum.test.ts &")).toThrow(/selector|invalid|dangerous/i) - }) - - it("rejects selector containing dollar sign", () => { - expect(() => validateSelector("$(whoami)")).toThrow(/selector|invalid|dangerous/i) - }) - - it("rejects selector containing backtick", () => { - expect(() => validateSelector("`whoami`")).toThrow(/selector|invalid|dangerous/i) - }) - - it("rejects selector containing parentheses used for command substitution", () => { - expect(() => validateSelector("test/foo)(); rm -rf /")).toThrow(/selector|invalid|dangerous/i) - }) - - it("rejects empty selector", () => { - expect(() => validateSelector("")).toThrow(/selector|empty/i) - }) - - it("rejects whitespace-only selector", () => { - expect(() => validateSelector(" ")).toThrow(/selector|empty/i) - }) - - it("rejects selector with newline", () => { - expect(() => validateSelector("test/sum.test.ts\nrm -rf /")).toThrow(/selector|invalid|dangerous/i) - }) - - it("rejects selector with carriage return", () => { - expect(() => validateSelector("test/sum.test.ts\rrm -rf /")).toThrow(/selector|invalid|dangerous/i) - }) -}) - -// ─── vitest args 构建 ─── - -describe("buildVitestArgs", () => { - it("parses simple baseCommand and appends selector", () => { - const policy = makePolicy({ baseCommand: "npx vitest run" }) - const args = buildVitestArgs(policy, "test/sum.test.ts") - expect(args).toEqual(["npx", "vitest", "run", "test/sum.test.ts"]) - }) - - it("handles baseCommand with extra spaces", () => { - const policy = makePolicy({ baseCommand: " npx vitest run " }) - const args = buildVitestArgs(policy, "test/sum.test.ts") - expect(args[0]).toBe("npx") - }) - - it("appends selector after baseCommand args", () => { - const policy = makePolicy({ baseCommand: "npx vitest run --reporter=json" }) - const args = buildVitestArgs(policy, "test/sum.test.ts") - expect(args[args.length - 1]).toBe("test/sum.test.ts") - expect(args).toContain("--reporter=json") - }) -}) - -// ─── 失败分类 ─── - -describe("classifyVitestFailure", () => { - it("classifies assertion failure when tests collected and exit code non-zero", () => { - const result = classifyVitestFailure({ - exitCode: 1, - testsCollected: 3, - testsFailed: 1, - stderr: "AssertionError: expected 2 to be 3\n ❯ test/sum.test.ts:5:25", - }) - expect(result).toBe("assertion") - }) - - it("classifies missing-behavior when module resolution error for implementation file", () => { - const result = classifyVitestFailure({ - exitCode: 1, - testsCollected: 0, - testsFailed: 0, - stderr: "Error: Cannot find module '../src/sum.js' imported from test/sum.test.ts", - }) - expect(result).toBe("missing-behavior") - }) - - it("classifies as infrastructure for config error", () => { - const result = classifyVitestFailure({ - exitCode: 1, - testsCollected: 0, - testsFailed: 0, - stderr: "failed to load config from vitest.config.ts", - }) - expect(result).toBe("infrastructure") - }) - - it("classifies as infrastructure for dependency missing", () => { - const result = classifyVitestFailure({ - exitCode: 1, - testsCollected: 0, - testsFailed: 0, - stderr: "Error: Cannot find package 'vitest' imported from ...", - }) - expect(result).toBe("infrastructure") - }) - - it("classifies as infrastructure for transform error", () => { - const result = classifyVitestFailure({ - exitCode: 1, - testsCollected: 0, - testsFailed: 0, - stderr: "Transform failed with 1 error: src/broken.ts:1:0: ERROR: Unexpected token", - }) - expect(result).toBe("infrastructure") - }) - - it("classifies as infrastructure when zero tests collected", () => { - const result = classifyVitestFailure({ - exitCode: 1, - testsCollected: 0, - testsFailed: 0, - stderr: "No test files found matching the pattern", - }) - expect(result).toBe("infrastructure") - }) - - it("classifies as timeout when process timed out", () => { - const result = classifyVitestFailure({ - exitCode: null, // killed by timeout - testsCollected: null, - testsFailed: null, - stderr: "", - timedOut: true, - }) - expect(result).toBe("timeout") - }) - - it("returns null when all tests pass", () => { - const result = classifyVitestFailure({ - exitCode: 0, - testsCollected: 5, - testsFailed: 0, - stderr: "", - timedOut: false, - }) - expect(result).toBeNull() - }) - - it("returns unknown for unrecognized failure pattern", () => { - const result = classifyVitestFailure({ - exitCode: 1, - testsCollected: 1, - testsFailed: 0, // exit code 1 but no test failed — unusual - stderr: "Something went wrong", - }) - expect(result).toBe("unknown") - }) - - it("classifies infrastructure for cant find module to vitest deps (not implementation)", () => { - const result = classifyVitestFailure({ - exitCode: 1, - testsCollected: 0, - testsFailed: 0, - stderr: "Error: Cannot find module '@testing-library/react'", - }) - expect(result).toBe("infrastructure") - }) -}) diff --git a/test/flowrun/audit.test.ts b/test/flowrun/audit.test.ts deleted file mode 100644 index f766d89..0000000 --- a/test/flowrun/audit.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, it, expect } from "vitest" -import { buildStageAudit, buildMergeAudit } from "../../src/flowrun/audit.js" -import { createInitialFlowRun } from "../../src/flowrun/github.js" -import type { FlowRun, TaskState } from "../../src/flowrun/types.js" - -describe("buildStageAudit", () => { - it("includes stage info", () => { - const run = createInitialFlowRun("flow-o/r-1", "o/r", 1) as FlowRun - const audit = buildStageAudit(run, "requirements") - expect(audit).toContain("requirements") - expect(audit).toContain("pending") - }) - - it("includes check details", () => { - const run = createInitialFlowRun("id", "r", 1) as FlowRun - run.stages.requirements.checks = [ - { name: "prd-exists", status: "pass", evidence: [{ command: "ls docs/prd", exitCode: 0, summary: "found", timestamp: "now" }] }, - ] - const audit = buildStageAudit(run, "requirements") - expect(audit).toContain("prd-exists") - expect(audit).toContain("ls docs/prd") - }) - - it("handles unknown stage", () => { - const run = createInitialFlowRun("id", "r", 1) as FlowRun - const audit = buildStageAudit(run, "nonexistent") - expect(audit).toContain("not found") - }) -}) - -describe("buildMergeAudit", () => { - it("includes task info and checkpoints", () => { - const task: TaskState = { - id: "task-1", name: "Task 1", status: "merged", - dependsOn: [], area: "backend", expectedFiles: ["src/a.ts"], - testCommands: ["npm test"], acceptance: "Works", parallelSafe: true, - prNumber: 42, prCheckpoints: { - prNumber: 42, - localChecks: { name: "localChecks", status: "pass", evidence: [] }, - ciChecks: { name: "ciChecks", status: "pass", evidence: [] }, - reviewerApproval: { name: "reviewerApproval", status: "pass", evidence: [] }, - goalVerification: { name: "goalVerification", status: "pass", evidence: [] }, - branchProtection: { name: "branchProtection", status: "pass", evidence: [] }, - mergeResult: { name: "mergeResult", status: "pass", evidence: [] }, - }, - blockedReason: null, startedAt: "now", - } - const audit = buildMergeAudit(task) - expect(audit).toContain("task-1") - expect(audit).toContain("PR: #42") - expect(audit).toContain("localChecks") - expect(audit).toContain("goalVerification") - }) -}) diff --git a/test/flowrun/ci.test.ts b/test/flowrun/ci.test.ts deleted file mode 100644 index d899dca..0000000 --- a/test/flowrun/ci.test.ts +++ /dev/null @@ -1,443 +0,0 @@ -import { describe, it, expect } from "vitest" -import { - checkRequiredChecks, - validateRequiredWorkflow, - checkBranchProtection, - computeQualityContractDigest, -} from "../../src/flowrun/ci.js" -import type { - RepositoryQualityPolicy, TaskCommand, CoveragePolicy, -} from "../../src/flowrun/types.js" - -// ─── helpers ─── - -function makePolicy(overrides: Partial = {}): RepositoryQualityPolicy { - return { - mode: "required", - requiredChecks: [ - { context: "CI / Test", appId: 12345, workflowPath: ".github/workflows/ci.yml", workflowRef: "main", workflowBlobSha: "abc123" }, - { context: "CI / Lint", appId: 12345, workflowPath: ".github/workflows/lint.yml", workflowRef: "main", workflowBlobSha: "def456" }, - ], - ...overrides, - } -} - -function makeCheck(overrides: Record = {}) { - return { - context: "CI / Test", - state: "SUCCESS" as const, - app: { id: 12345, name: "GitHub Actions" }, - ...overrides, - } -} - -function noopCmd(): TaskCommand { - return { command: "echo ok", cwd: ".", timeoutMs: 30000, env: {} } -} - -function makeCoveragePolicy(overrides: Partial = {}): CoveragePolicy { - return { - command: "npm run coverage", - threshold: 80, - report: { format: "istanbul-json-summary", path: "coverage/coverage-summary.json", metric: "lines" }, - ...overrides, - } -} - -// ───────────────────────────────────────────────── -// checkRequiredChecks -// ───────────────────────────────────────────────── - -describe("checkRequiredChecks", () => { - // ✅ mode off — no-op(保持现有行为) - it("returns allPassed true when mode is off (no-op)", () => { - const policy = makePolicy({ mode: "off" }) - const result = checkRequiredChecks(policy, []) - expect(result.allPassed).toBe(true) - expect(result.missingContexts).toEqual([]) - expect(result.failedContexts).toEqual([]) - expect(result.untrustedSources).toEqual([]) - }) - - // ✅ mode off with empty requiredChecks - it("returns allPassed true when mode is off with empty requiredChecks", () => { - const policy: RepositoryQualityPolicy = { mode: "off", requiredChecks: [] } - const result = checkRequiredChecks(policy, []) - expect(result.allPassed).toBe(true) - }) - - // ✅ 正常路径:所有 required checks 通过 - it("returns allPassed true when all required checks pass with correct appId", () => { - const policy = makePolicy() - const prChecks = [ - makeCheck({ context: "CI / Test", state: "SUCCESS", app: { id: 12345, name: "GitHub Actions" } }), - makeCheck({ context: "CI / Lint", state: "SUCCESS", app: { id: 12345, name: "GitHub Actions" } }), - ] - const result = checkRequiredChecks(policy, prChecks) - expect(result.allPassed).toBe(true) - expect(result.missingContexts).toEqual([]) - expect(result.failedContexts).toEqual([]) - expect(result.pendingContexts).toEqual([]) - expect(result.untrustedSources).toEqual([]) - }) - - // ❌ 缺失 context - it("detects missing required check context", () => { - const policy = makePolicy() - const prChecks = [ - makeCheck({ context: "CI / Test", state: "SUCCESS", app: { id: 12345, name: "GitHub Actions" } }), - // CI / Lint 不存在 - ] - const result = checkRequiredChecks(policy, prChecks) - expect(result.allPassed).toBe(false) - expect(result.missingContexts).toContain("CI / Lint") - expect(result.missingContexts).toHaveLength(1) - }) - - // ❌ check 状态为 FAILURE - it("detects failed required check", () => { - const policy = makePolicy() - const prChecks = [ - makeCheck({ context: "CI / Test", state: "FAILURE", app: { id: 12345, name: "GitHub Actions" } }), - makeCheck({ context: "CI / Lint", state: "SUCCESS", app: { id: 12345, name: "GitHub Actions" } }), - ] - const result = checkRequiredChecks(policy, prChecks) - expect(result.allPassed).toBe(false) - expect(result.failedContexts).toContain("CI / Test") - expect(result.failedContexts).toHaveLength(1) - }) - - // ❌ check 状态为 ERROR - it("detects error state as failure", () => { - const policy = makePolicy() - const prChecks = [ - makeCheck({ context: "CI / Test", state: "ERROR", app: { id: 12345, name: "GitHub Actions" } }), - makeCheck({ context: "CI / Lint", state: "SUCCESS", app: { id: 12345, name: "GitHub Actions" } }), - ] - const result = checkRequiredChecks(policy, prChecks) - expect(result.allPassed).toBe(false) - expect(result.failedContexts).toContain("CI / Test") - }) - - // ❌ check 状态为 PENDING - it("detects pending required check", () => { - const policy = makePolicy() - const prChecks = [ - makeCheck({ context: "CI / Test", state: "PENDING", app: { id: 12345, name: "GitHub Actions" } }), - makeCheck({ context: "CI / Lint", state: "SUCCESS", app: { id: 12345, name: "GitHub Actions" } }), - ] - const result = checkRequiredChecks(policy, prChecks) - expect(result.allPassed).toBe(false) - expect(result.pendingContexts).toContain("CI / Test") - }) - - // ❌ EXPECTED state(GitHub 用 EXPECTED 表示 "等待中") - it("treats EXPECTED as pending", () => { - const policy = makePolicy() - const prChecks = [ - makeCheck({ context: "CI / Test", state: "EXPECTED", app: { id: 12345, name: "GitHub Actions" } }), - makeCheck({ context: "CI / Lint", state: "SUCCESS", app: { id: 12345, name: "GitHub Actions" } }), - ] - const result = checkRequiredChecks(policy, prChecks) - expect(result.allPassed).toBe(false) - expect(result.pendingContexts).toContain("CI / Test") - }) - - // ❌ 来源 appId 不匹配 — 拒绝自动合并 - it("rejects when source appId does not match expected", () => { - const policy = makePolicy() - const prChecks = [ - makeCheck({ context: "CI / Test", state: "SUCCESS", app: { id: 99999, name: "Untrusted App" } }), - makeCheck({ context: "CI / Lint", state: "SUCCESS", app: { id: 12345, name: "GitHub Actions" } }), - ] - const result = checkRequiredChecks(policy, prChecks) - expect(result.allPassed).toBe(false) - expect(result.untrustedSources).toHaveLength(1) - expect(result.untrustedSources[0]).toEqual({ - context: "CI / Test", - expectedAppId: 12345, - actualAppId: 99999, - }) - }) - - // ❌ 来源 app 为 null — 拒绝(无来源信息不可信) - it("rejects when source app is null (no source info)", () => { - const policy = makePolicy() - const prChecks = [ - makeCheck({ context: "CI / Test", state: "SUCCESS", app: null }), - makeCheck({ context: "CI / Lint", state: "SUCCESS", app: { id: 12345, name: "GitHub Actions" } }), - ] - const result = checkRequiredChecks(policy, prChecks) - expect(result.allPassed).toBe(false) - expect(result.untrustedSources).toHaveLength(1) - expect(result.untrustedSources[0].actualAppId).toBeNull() - }) - - // ✅ check 来源允许 null appId(GitHub 原生 check 没有 appId) - it("allows when source appId is null but no policy appId required", () => { - const policy = makePolicy({ - requiredChecks: [ - { context: "CI / Test", appId: 0, workflowPath: ".github/workflows/ci.yml", workflowRef: "main", workflowBlobSha: "abc123" }, - ], - }) - const prChecks = [ - makeCheck({ context: "CI / Test", state: "SUCCESS", app: null }), - ] - // appId 为 0 表示不校验来源 - const result = checkRequiredChecks(policy, prChecks) - expect(result.allPassed).toBe(true) - }) - - // ❌ check 有 app 但 app.id 不匹配(即使 app 存在) - it("rejects when check has app but app.id differs", () => { - const policy = makePolicy() - const prChecks = [ - makeCheck({ context: "CI / Test", state: "SUCCESS", app: { id: 54321, name: "Another CI" } }), - makeCheck({ context: "CI / Lint", state: "SUCCESS", app: { id: 12345, name: "GitHub Actions" } }), - ] - const result = checkRequiredChecks(policy, prChecks) - expect(result.allPassed).toBe(false) - expect(result.untrustedSources[0].expectedAppId).toBe(12345) - expect(result.untrustedSources[0].actualAppId).toBe(54321) - }) - - // ❌ 混合多重失败(missing + failed + untrusted)— 全部报告 - it("reports all failure types together", () => { - const policy = makePolicy({ - requiredChecks: [ - { context: "CI / Test", appId: 12345, workflowPath: ".github/workflows/ci.yml", workflowRef: "main", workflowBlobSha: "abc123" }, - { context: "CI / Lint", appId: 12345, workflowPath: ".github/workflows/lint.yml", workflowRef: "main", workflowBlobSha: "def456" }, - { context: "CI / Build", appId: 99999, workflowPath: ".github/workflows/build.yml", workflowRef: "main", workflowBlobSha: "ghi789" }, - ], - }) - const prChecks = [ - makeCheck({ context: "CI / Test", state: "FAILURE", app: { id: 12345, name: "GitHub Actions" } }), - // CI / Lint 缺失 - makeCheck({ context: "CI / Build", state: "SUCCESS", app: { id: 11111, name: "Wrong App" } }), - ] - const result = checkRequiredChecks(policy, prChecks) - expect(result.allPassed).toBe(false) - expect(result.failedContexts).toContain("CI / Test") - expect(result.missingContexts).toContain("CI / Lint") - expect(result.untrustedSources).toHaveLength(1) - expect(result.untrustedSources[0].context).toBe("CI / Build") - }) -}) - -// ───────────────────────────────────────────────── -// validateRequiredWorkflow -// ───────────────────────────────────────────────── - -describe("validateRequiredWorkflow", () => { - // ❌ required mode + 空 requiredChecks → 拒绝 - it("rejects required mode with empty requiredChecks", () => { - const policy: RepositoryQualityPolicy = { mode: "required", requiredChecks: [] } - const result = validateRequiredWorkflow(policy) - expect(result.valid).toBe(false) - expect(result.errors).toContain("requiredChecks must not be empty when mode is 'required'") - }) - - // ✅ required mode + non-empty requiredChecks → 通过 - it("accepts required mode with non-empty requiredChecks", () => { - const policy = makePolicy() - const result = validateRequiredWorkflow(policy) - expect(result.valid).toBe(true) - expect(result.errors).toEqual([]) - }) - - // ✅ off mode + 空 requiredChecks → 有效(无强制要求) - it("accepts off mode with empty requiredChecks", () => { - const policy: RepositoryQualityPolicy = { mode: "off", requiredChecks: [] } - const result = validateRequiredWorkflow(policy) - expect(result.valid).toBe(true) - expect(result.errors).toEqual([]) - }) - - // ❌ required mode + 缺少 workflowPath - it("rejects required check without workflowPath", () => { - const policy = makePolicy({ - requiredChecks: [ - { context: "CI / Test", appId: 12345, workflowPath: "", workflowRef: "main", workflowBlobSha: "abc" }, - ], - }) - const result = validateRequiredWorkflow(policy) - expect(result.valid).toBe(false) - expect(result.errors.some((e: string) => e.includes("workflowPath"))).toBe(true) - }) - - // ❌ required mode + 重复 context - it("rejects duplicate contexts", () => { - const policy = makePolicy({ - requiredChecks: [ - { context: "CI / Test", appId: 12345, workflowPath: ".github/workflows/ci.yml", workflowRef: "main", workflowBlobSha: "abc" }, - { context: "CI / Test", appId: 12345, workflowPath: ".github/workflows/ci2.yml", workflowRef: "main", workflowBlobSha: "def" }, - ], - }) - const result = validateRequiredWorkflow(policy) - expect(result.valid).toBe(false) - expect(result.errors.some((e: string) => e.includes("duplicate"))).toBe(true) - }) - - // ✅ off mode 不校验 workflowPath - it("skips workflow validation when mode is off", () => { - const policy: RepositoryQualityPolicy = { - mode: "off", - requiredChecks: [ - { context: "CI / Test", appId: 12345, workflowPath: "", workflowRef: "main", workflowBlobSha: "abc" }, - ], - } - const result = validateRequiredWorkflow(policy) - expect(result.valid).toBe(true) - }) - - // ✅ multiple valid checks - it("accepts multiple valid required checks", () => { - const policy = makePolicy({ - requiredChecks: [ - { context: "CI / Test", appId: 12345, workflowPath: ".github/workflows/ci.yml", workflowRef: "main", workflowBlobSha: "abc123" }, - { context: "CI / Lint", appId: 54321, workflowPath: ".github/workflows/lint.yml", workflowRef: "main", workflowBlobSha: "def456" }, - { context: "CI / Coverage", appId: 99999, workflowPath: ".github/workflows/coverage.yml", workflowRef: "main", workflowBlobSha: "ghi789" }, - ], - }) - const result = validateRequiredWorkflow(policy) - expect(result.valid).toBe(true) - }) - - // ❌ required mode + missing workflowRef - it("rejects required check with empty workflowRef", () => { - const policy = makePolicy({ - requiredChecks: [ - { context: "CI / Test", appId: 12345, workflowPath: ".github/workflows/ci.yml", workflowRef: "", workflowBlobSha: "abc" }, - ], - }) - const result = validateRequiredWorkflow(policy) - expect(result.valid).toBe(false) - expect(result.errors.some((e: string) => e.includes("workflowRef"))).toBe(true) - }) -}) - -// ───────────────────────────────────────────────── -// checkBranchProtection -// ───────────────────────────────────────────────── - -describe("checkBranchProtection", () => { - // 所有测试通过 mock gh executor - // 这里只测试纯函数逻辑;I/O 部分通过 setMergeGhExecutor 在 merge.test.ts 中已有覆盖 - // ci.ts 中的 checkBranchProtection 是 merge.ts 中已有函数的增强版 wrapper - - it("re-exports from merge module with correct signature", () => { - // 验证函数存在且可调用 - expect(typeof checkBranchProtection).toBe("function") - // 现有实现接受 (owner, repo) 两个参数,硬编码检查 main 分支 - expect(checkBranchProtection.length).toBe(2) - }) -}) - -// ───────────────────────────────────────────────── -// computeQualityContractDigest -// ───────────────────────────────────────────────── - -describe("computeQualityContractDigest", () => { - it("returns a hex string of correct length", () => { - const digest = computeQualityContractDigest( - [noopCmd()], - [], - null, - ) - expect(digest).toMatch(/^[a-f0-9]{64}$/) - }) - - it("is deterministic for same inputs", () => { - const a = computeQualityContractDigest([noopCmd()], [], null) - const b = computeQualityContractDigest([noopCmd()], [], null) - expect(a).toBe(b) - }) - - it("differs when test commands differ", () => { - const a = computeQualityContractDigest( - [{ command: "npm test", cwd: ".", timeoutMs: 30000, env: {} }], - [], - null, - ) - const b = computeQualityContractDigest( - [{ command: "npm test -- --coverage", cwd: ".", timeoutMs: 30000, env: {} }], - [], - null, - ) - expect(a).not.toBe(b) - }) - - it("differs when verify commands differ", () => { - const a = computeQualityContractDigest( - [], - [noopCmd()], - null, - ) - const b = computeQualityContractDigest( - [], - [{ command: "npx tsc --noEmit", cwd: ".", timeoutMs: 30000, env: {} }], - null, - ) - expect(a).not.toBe(b) - }) - - it("differs when coverage policy differs", () => { - const a = computeQualityContractDigest( - [], - [], - makeCoveragePolicy({ threshold: 80 }), - ) - const b = computeQualityContractDigest( - [], - [], - makeCoveragePolicy({ threshold: 90 }), - ) - expect(a).not.toBe(b) - }) - - it("differs when coverage policy is null vs present", () => { - const a = computeQualityContractDigest([], [], null) - const b = computeQualityContractDigest([], [], makeCoveragePolicy()) - expect(a).not.toBe(b) - }) - - it("is order-sensitive for commands", () => { - const a = computeQualityContractDigest( - [{ command: "npm run a", cwd: ".", timeoutMs: 30000, env: {} }, - { command: "npm run b", cwd: ".", timeoutMs: 30000, env: {} }], - [], - null, - ) - const b = computeQualityContractDigest( - [{ command: "npm run b", cwd: ".", timeoutMs: 30000, env: {} }, - { command: "npm run a", cwd: ".", timeoutMs: 30000, env: {} }], - [], - null, - ) - expect(a).not.toBe(b) - }) - - it("includes env in digest computation", () => { - const a = computeQualityContractDigest( - [{ command: "npm test", cwd: ".", timeoutMs: 30000, env: {} }], - [], - null, - ) - const b = computeQualityContractDigest( - [{ command: "npm test", cwd: ".", timeoutMs: 30000, env: { NODE_ENV: "test" } }], - [], - null, - ) - expect(a).not.toBe(b) - }) - - it("is consistent with JCS canonicalization (sorted keys)", () => { - // 验证 JSON 的 key 排序是确定的 - const cmd1: TaskCommand = { cwd: ".", command: "npm test", env: {}, timeoutMs: 30000 } - // cmd1 和 cmd2 的 key 顺序不同,但值相同 - const a = computeQualityContractDigest([cmd1], [], null) - const b = computeQualityContractDigest([cmd1], [], null) - // 确定性:相同对象多次计算应相同 - expect(a).toBe(b) - }) -}) diff --git a/test/flowrun/coverage.test.ts b/test/flowrun/coverage.test.ts deleted file mode 100644 index ede1b8a..0000000 --- a/test/flowrun/coverage.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { describe, it, expect } from "vitest" -import { writeFile, mkdir, rm } from "node:fs/promises" -import { resolve, join } from "node:path" -import { tmpdir } from "node:os" -import { randomUUID } from "node:crypto" -import type { CoverageEvidence, CoveragePolicy } from "../../src/flowrun/types.js" - -import { - validateCoveragePath, - parseCoverageReport, - buildCoverageEvidence, - checkCoverageThreshold, - resolveCoveragePath, -} from "../../src/flowrun/coverage.js" - -// ─── 辅助函数 ─── - -async function createTempDir(): Promise { - const dir = join(tmpdir(), `coverage-test-${randomUUID()}`) - await mkdir(dir, { recursive: true }) - return dir -} - -async function writeCoverageFile(dir: string, filename: string, content: object): Promise { - const filePath = join(dir, filename) - await writeFile(filePath, JSON.stringify(content)) - return filePath -} - -// ─── validateCoveragePath 测试 ─── - -describe("validateCoveragePath", () => { - it("accepts safe relative path inside sandbox", () => { - const sandbox = "/tmp/worktree/tdd-task-1" - expect(() => validateCoveragePath("coverage/coverage-summary.json", sandbox)).not.toThrow() - }) - - it("accepts nested relative path inside sandbox", () => { - const sandbox = "/tmp/worktree/tdd-task-1" - expect(() => validateCoveragePath("sub/dir/report.json", sandbox)).not.toThrow() - }) - - it("rejects path with .. traversal", () => { - const sandbox = "/tmp/worktree/tdd-task-1" - expect(() => validateCoveragePath("../etc/passwd", sandbox)).toThrow() - }) - - it("rejects path with .. in middle of path", () => { - const sandbox = "/tmp/worktree/tdd-task-1" - expect(() => validateCoveragePath("coverage/../../secret.txt", sandbox)).toThrow() - }) - - it("rejects absolute path", () => { - const sandbox = "/tmp/worktree/tdd-task-1" - expect(() => validateCoveragePath("/etc/passwd", sandbox)).toThrow() - }) - - it("rejects path with null bytes", () => { - const sandbox = "/tmp/worktree/tdd-task-1" - expect(() => validateCoveragePath("coverage/\0bad.json", sandbox)).toThrow() - }) - - it("rejects empty path", () => { - const sandbox = "/tmp/worktree/tdd-task-1" - expect(() => validateCoveragePath("", sandbox)).toThrow() - }) -}) - -// ─── resolveCoveragePath 测试 ─── - -describe("resolveCoveragePath", () => { - it("resolves relative path within sandbox", () => { - const sandbox = "/tmp/worktree/tdd-task-1" - const resolved = resolveCoveragePath("coverage/report.json", sandbox) - expect(resolved).toBe("/tmp/worktree/tdd-task-1/coverage/report.json") - }) - - it("throws when resolved path escapes sandbox", () => { - const sandbox = "/tmp/worktree/tdd-task-1" - expect(() => resolveCoveragePath("../outside.json", sandbox)).toThrow() - }) -}) - -// ─── parseCoverageReport 测试 ─── - -describe("parseCoverageReport", () => { - it("parses valid istanbul-json-summary report", async () => { - const dir = await createTempDir() - try { - const reportPath = await writeCoverageFile(dir, "coverage-summary.json", { - total: { - lines: { - total: 100, - covered: 85, - skipped: 0, - pct: 85.0, - }, - statements: { total: 110, covered: 95, skipped: 0, pct: 86.36 }, - functions: { total: 20, covered: 18, skipped: 0, pct: 90.0 }, - branches: { total: 30, covered: 24, skipped: 0, pct: 80.0 }, - }, - }) - - const result = await parseCoverageReport(reportPath, "lines") - - expect(result).not.toBeNull() - expect(result!.actual).toBe(85.0) - expect(result!.metric).toBe("lines") - } finally { - await rm(dir, { recursive: true, force: true }) - } - }) - - it("returns null when report file does not exist", async () => { - const result = await parseCoverageReport("/nonexistent/path/coverage-summary.json", "lines") - expect(result).toBeNull() - }) - - it("returns null when total.lines is missing", async () => { - const dir = await createTempDir() - try { - const reportPath = await writeCoverageFile(dir, "coverage-summary.json", { - total: { - statements: { total: 110, covered: 95, skipped: 0, pct: 86.36 }, - }, - }) - - const result = await parseCoverageReport(reportPath, "lines") - expect(result).toBeNull() - } finally { - await rm(dir, { recursive: true, force: true }) - } - }) - - it("returns null when lines.pct is NaN", async () => { - const dir = await createTempDir() - try { - const reportPath = await writeCoverageFile(dir, "coverage-summary.json", { - total: { - lines: { - total: 0, - covered: 0, - skipped: 0, - pct: "Unknown", - }, - }, - }) - - const result = await parseCoverageReport(reportPath, "lines") - expect(result).toBeNull() - } finally { - await rm(dir, { recursive: true, force: true }) - } - }) - - it("returns null when lines.pct is explicitly NaN", async () => { - const dir = await createTempDir() - try { - const reportPath = await writeCoverageFile(dir, "coverage-summary.json", { - total: { - lines: { - total: 0, - covered: 0, - skipped: 0, - pct: NaN, - }, - }, - }) - - const result = await parseCoverageReport(reportPath, "lines") - expect(result).toBeNull() - } finally { - await rm(dir, { recursive: true, force: true }) - } - }) - - it("returns null when pct is out of range (> 100)", async () => { - const dir = await createTempDir() - try { - const reportPath = await writeCoverageFile(dir, "coverage-summary.json", { - total: { - lines: { - total: 100, - covered: 200, - skipped: 0, - pct: 200.0, - }, - }, - }) - - const result = await parseCoverageReport(reportPath, "lines") - expect(result).toBeNull() - } finally { - await rm(dir, { recursive: true, force: true }) - } - }) - - it("returns null when pct is negative", async () => { - const dir = await createTempDir() - try { - const reportPath = await writeCoverageFile(dir, "coverage-summary.json", { - total: { - lines: { - total: 100, - covered: -10, - skipped: 0, - pct: -10.0, - }, - }, - }) - - const result = await parseCoverageReport(reportPath, "lines") - expect(result).toBeNull() - } finally { - await rm(dir, { recursive: true, force: true }) - } - }) - - it("returns null when report JSON is malformed", async () => { - const dir = await createTempDir() - try { - const filePath = join(dir, "coverage-summary.json") - await writeFile(filePath, "not valid json {{{") - - const result = await parseCoverageReport(filePath, "lines") - expect(result).toBeNull() - } finally { - await rm(dir, { recursive: true, force: true }) - } - }) - - it("returns null when total is not an object", async () => { - const dir = await createTempDir() - try { - const reportPath = await writeCoverageFile(dir, "coverage-summary.json", { - total: "not-an-object", - }) - - const result = await parseCoverageReport(reportPath, "lines") - expect(result).toBeNull() - } finally { - await rm(dir, { recursive: true, force: true }) - } - }) - - it("extracts correct pct from valid total.lines", async () => { - const dir = await createTempDir() - try { - const reportPath = await writeCoverageFile(dir, "coverage-summary.json", { - total: { - lines: { total: 50, covered: 45, skipped: 0, pct: 90.0 }, - }, - }) - - const result = await parseCoverageReport(reportPath, "lines") - expect(result).not.toBeNull() - expect(result!.actual).toBe(90.0) - } finally { - await rm(dir, { recursive: true, force: true }) - } - }) -}) - -// ─── buildCoverageEvidence 测试 ─── - -describe("buildCoverageEvidence", () => { - it("builds pass evidence when actual meets threshold", () => { - const result = buildCoverageEvidence({ - actual: 85.0, - threshold: 80.0, - metric: "lines", - headSha: "abc123", - reportDigest: "digest-abc", - }) - - expect(result.status).toBe("pass") - expect(result.actual).toBe(85.0) - expect(result.threshold).toBe(80.0) - expect(result.metric).toBe("lines") - expect(result.headSha).toBe("abc123") - expect(result.reportDigest).toBe("digest-abc") - expect(result.summary).toContain("85%") - }) - - it("builds fail evidence when actual is below threshold", () => { - const result = buildCoverageEvidence({ - actual: 75.0, - threshold: 80.0, - metric: "lines", - headSha: "abc123", - reportDigest: "digest-def", - }) - - expect(result.status).toBe("fail") - expect(result.actual).toBe(75.0) - expect(result.summary).toContain("75%") - expect(result.summary).toContain("80%") - }) - - it("builds pending evidence when actual is null", () => { - const result = buildCoverageEvidence({ - actual: null, - threshold: 80.0, - metric: "lines", - headSha: "abc123", - reportDigest: null, - }) - - expect(result.status).toBe("pending") - expect(result.actual).toBeNull() - expect(result.reportDigest).toBeNull() - }) - - it("exact match at threshold is pass", () => { - const result = buildCoverageEvidence({ - actual: 80.0, - threshold: 80.0, - metric: "lines", - headSha: "abc123", - reportDigest: "digest", - }) - - expect(result.status).toBe("pass") - }) -}) - -// ─── checkCoverageThreshold 测试 ─── - -describe("checkCoverageThreshold", () => { - it("pass when actual >= threshold", () => { - expect(checkCoverageThreshold(85, 80)).toBe(true) - expect(checkCoverageThreshold(80, 80)).toBe(true) - expect(checkCoverageThreshold(100, 80)).toBe(true) - }) - - it("fail when actual < threshold", () => { - expect(checkCoverageThreshold(75, 80)).toBe(false) - expect(checkCoverageThreshold(0, 80)).toBe(false) - }) -}) diff --git a/test/flowrun/digest.test.ts b/test/flowrun/digest.test.ts deleted file mode 100644 index d4376cd..0000000 --- a/test/flowrun/digest.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { describe, it, expect, beforeAll, afterAll } from "vitest" -import { mkdtempSync, writeFileSync, symlinkSync, chmodSync, rmSync, mkdirSync } from "node:fs" -import { join, dirname } from "node:path" -import { tmpdir } from "node:os" -import { execSync } from "node:child_process" -import { computeWorkspaceDigest, type DigestOptions } from "../../src/flowrun/digest.js" - -// ─── 辅助函数 ─── - -let tmpDir: string -let worktree: string - -function git(args: string, cwd: string = worktree): string { - try { - return execSync(`git ${args}`, { cwd, encoding: "utf-8", env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: tmpDir } }) - } catch { - return "" - } -} - -function setupGitRepo(): string { - const dir = mkdtempSync(join(tmpdir(), "cabbage-digest-test-")) - execSync("git init", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - execSync("git config user.email test@test.com", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - execSync("git config user.name test", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - return dir -} - -/** 写入文件前自动创建父目录 */ -function writeFile(filePath: string, content: string): void { - mkdirSync(dirname(filePath), { recursive: true }) - writeFileSync(filePath, content) -} - -function defaultOptions(overrides: Partial = {}): DigestOptions { - return { - testFilePatterns: ["test/**/*.ts"], - implementationFilePatterns: ["src/**/*.ts"], - generatedArtifactPatterns: [], - ...overrides, - } -} - -beforeAll(() => { - tmpDir = mkdtempSync(join(tmpdir(), "cabbage-digest-")) - worktree = setupGitRepo() -}) - -afterAll(() => { - try { rmSync(tmpDir, { recursive: true, force: true }) } catch { /* ignore */ } -}) - -// ─── 测试 ─── - -describe("computeWorkspaceDigest", () => { - it("returns a VersionedDigest with sha256-content-v1 algorithm", async () => { - writeFile(join(worktree, "README.md"), "hello") - git("add README.md") - git('commit -m "init"') - - const result = await computeWorkspaceDigest(worktree, defaultOptions()) - expect(result.algorithm).toBe("sha256-content-v1") - expect(result.value).toMatch(/^[a-f0-9]{64}$/) - }) - - it("produces deterministic digest for same workspace content", async () => { - // clean workspace with tracked file - const dir = setupGitRepo() - writeFile(join(dir, "src", "index.ts"), "export const x = 1") - writeFile(join(dir, "test", "index.test.ts"), 'import { x } from "../src/index.js"') - execSync("git add src/index.ts test/index.test.ts", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - execSync('git commit -m "init"', { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - - const opts = defaultOptions() - const d1 = await computeWorkspaceDigest(dir, opts) - const d2 = await computeWorkspaceDigest(dir, opts) - - expect(d1.value).toBe(d2.value) - - try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } - }) - - it("produces different digest when tracked file content changes", async () => { - const dir = setupGitRepo() - writeFile(join(dir, "src", "index.ts"), "export const x = 1") - execSync("git add src/index.ts", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - execSync('git commit -m "init"', { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - - const opts = defaultOptions() - const d1 = await computeWorkspaceDigest(dir, opts) - - writeFile(join(dir, "src", "index.ts"), "export const x = 2") - const d2 = await computeWorkspaceDigest(dir, opts) - - expect(d1.value).not.toBe(d2.value) - - try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } - }) - - it("includes executable bit in digest", async () => { - const dir = setupGitRepo() - writeFile(join(dir, "script.sh"), "#!/bin/sh\necho hi") - chmodSync(join(dir, "script.sh"), 0o755) - execSync("git add script.sh", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - execSync('git commit -m "init"', { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - - const d1 = await computeWorkspaceDigest(dir, defaultOptions()) - - chmodSync(join(dir, "script.sh"), 0o644) - const d2 = await computeWorkspaceDigest(dir, defaultOptions()) - - expect(d1.value).not.toBe(d2.value) - - try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } - }) - - it("includes delete marker for tracked but deleted files", async () => { - const dir = setupGitRepo() - writeFile(join(dir, "src", "index.ts"), "export const x = 1") - execSync("git add src/index.ts", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - execSync('git commit -m "init"', { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - - const opts = defaultOptions() - const d1 = await computeWorkspaceDigest(dir, opts) - - rmSync(join(dir, "src", "index.ts")) - const d2 = await computeWorkspaceDigest(dir, opts) - - // 删除文件 vs 存在文件,digest 应不同 - expect(d1.value).not.toBe(d2.value) - - try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } - }) - - it("ignores .git/ directory", async () => { - const dir = setupGitRepo() - writeFile(join(dir, "src", "index.ts"), "export const x = 1") - execSync("git add src/index.ts", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - execSync('git commit -m "init"', { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - - const d1 = await computeWorkspaceDigest(dir, defaultOptions()) - - // 修改 .git 内部文件不应影响 digest - writeFile(join(dir, ".git", "some-file"), "should be ignored") - const d2 = await computeWorkspaceDigest(dir, defaultOptions()) - - expect(d1.value).toBe(d2.value) - - try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } - }) - - it("ignores node_modules/ directory", async () => { - const dir = setupGitRepo() - writeFile(join(dir, "src", "index.ts"), "export const x = 1") - execSync("git add src/index.ts", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - execSync('git commit -m "init"', { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - - const d1 = await computeWorkspaceDigest(dir, defaultOptions()) - - mkdirSync(join(dir, "node_modules", "pkg"), { recursive: true }) - writeFile(join(dir, "node_modules", "pkg", "index.js"), "module.exports = {}") - const d2 = await computeWorkspaceDigest(dir, defaultOptions()) - - expect(d1.value).toBe(d2.value) - - try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } - }) - - it("ignores coverage/ and dist/ directories", async () => { - const dir = setupGitRepo() - writeFile(join(dir, "src", "index.ts"), "export const x = 1") - execSync("git add src/index.ts", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - execSync('git commit -m "init"', { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - - const d1 = await computeWorkspaceDigest(dir, defaultOptions()) - - mkdirSync(join(dir, "coverage"), { recursive: true }) - writeFile(join(dir, "coverage", "report.json"), "{}") - mkdirSync(join(dir, "dist"), { recursive: true }) - writeFile(join(dir, "dist", "index.js"), "x") - const d2 = await computeWorkspaceDigest(dir, defaultOptions()) - - expect(d1.value).toBe(d2.value) - - try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } - }) - - it("ignores generated artifacts declared in policy", async () => { - const dir = setupGitRepo() - writeFile(join(dir, "src", "index.ts"), "export const x = 1") - execSync("git add src/index.ts", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - execSync('git commit -m "init"', { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - - const opts = defaultOptions({ - generatedArtifactPatterns: ["coverage/**", "build/**"], - }) - const d1 = await computeWorkspaceDigest(dir, opts) - - mkdirSync(join(dir, "coverage"), { recursive: true }) - writeFile(join(dir, "coverage", "lcov.info"), "SF:src/index.ts") - mkdirSync(join(dir, "build"), { recursive: true }) - writeFile(join(dir, "build", "output.js"), "console.log(1)") - const d2 = await computeWorkspaceDigest(dir, opts) - - expect(d1.value).toBe(d2.value) - - try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } - }) - - it("includes policy-scoped untracked files", async () => { - const dir = setupGitRepo() - writeFile(join(dir, "src", "existing.ts"), "export const y = 2") - execSync("git add src/existing.ts", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - execSync('git commit -m "init"', { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - - const opts = defaultOptions() - const d1 = await computeWorkspaceDigest(dir, opts) - - // 添加一个匹配 implementationFilePatterns 的 untracked 文件 - writeFile(join(dir, "src", "new-feature.ts"), "export const z = 3") - const d2 = await computeWorkspaceDigest(dir, opts) - - expect(d1.value).not.toBe(d2.value) - - try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } - }) - - it("sorts paths by UTF-8 byte order", async () => { - // 验证排序对 digest 的影响:使用两个不同路径前缀确保一致性 - const dir = setupGitRepo() - writeFile(join(dir, "a.ts"), "a") - writeFile(join(dir, "b.ts"), "b") - execSync("git add a.ts b.ts", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - execSync('git commit -m "init"', { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - - // 即使文件系统返回不同顺序,digest 应一致 - const d1 = await computeWorkspaceDigest(dir, defaultOptions()) - const d2 = await computeWorkspaceDigest(dir, defaultOptions()) - - expect(d1.value).toBe(d2.value) - - try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } - }) - - it("rejects symlinks that escape the worktree", async () => { - const dir = setupGitRepo() - writeFile(join(dir, "src", "safe.ts"), "a") - execSync("git add src/safe.ts", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - execSync('git commit -m "init"', { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - - // 创建指向 worktree 外部的 symlink 并加入 git 跟踪 - const outsidePath = join(tmpdir(), "outside-file") - writeFileSync(outsidePath, "secret") - symlinkSync(outsidePath, join(dir, "src", "escape-link.ts")) - - // 确保 symlink 被 git 跟踪,这样 digest 必须处理它 - execSync("git add src/escape-link.ts", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - - await expect(computeWorkspaceDigest(dir, defaultOptions())).rejects.toThrow(/symlink|escape|outside/i) - - try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } - try { rmSync(outsidePath) } catch { /* ignore */ } - }) - - it("includes untracked test files matching testFilePatterns", async () => { - const dir = setupGitRepo() - writeFile(join(dir, "src", "lib.ts"), "export const fn = () => 1") - execSync("git add src/lib.ts", { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - execSync('git commit -m "init"', { cwd: dir, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: dir } }) - - const opts = defaultOptions() - const d1 = await computeWorkspaceDigest(dir, opts) - - // 添加 untracked 测试文件 - writeFile(join(dir, "test", "lib.test.ts"), 'import { fn } from "../src/lib.js";\ndescribe("test", () => { it("works", () => expect(fn()).toBe(1)) })') - const d2 = await computeWorkspaceDigest(dir, opts) - - expect(d1.value).not.toBe(d2.value) - - try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ } - }) -}) diff --git a/test/flowrun/evaluator.test.ts b/test/flowrun/evaluator.test.ts deleted file mode 100644 index 07c75f9..0000000 --- a/test/flowrun/evaluator.test.ts +++ /dev/null @@ -1,598 +0,0 @@ -import { describe, it, expect } from "vitest" -import type { - TddPolicy, - TddEvidence, - TddCycleEvidence, - TddRegressionEvidence, - FinalVerificationEvidence, - AlternativeValidationEvidence, - AcceptanceCriterion, - VersionedDigest, -} from "../../src/flowrun/types.js" -import { evaluateTddCompliance } from "../../src/flowrun/evaluator.js" - -// ─── 辅助工厂函数 ─── - -function emptyDigest(): VersionedDigest { - return { algorithm: "sha256-content-v1", value: "abc123" } -} - -function makePolicy(overrides: Partial): TddPolicy { - return { - mode: "strict", - enforcement: "advisory", - runner: null, - testFilePatterns: [], - implementationFilePatterns: [], - generatedArtifactPatterns: [], - exception: null, - source: { manifestPath: "test/manifest.yml", revisionSha: "sha1" }, - ...overrides, - } -} - -function makeEvidence(overrides: Partial = {}): TddEvidence { - return { - revision: 1, - reworkRevision: 0, - status: "in-progress", - taskStart: { status: "pass", headSha: "head1", treeSha: "tree1", startedAt: "2026-01-01T00:00:00Z" }, - cycles: [], - regression: { status: "pending", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, - verification: { status: "pending", headSha: null, treeSha: null, runs: [] }, - alternativeValidation: [], - reworks: [], - warnings: [], - updatedAt: "2026-01-01T00:00:00Z", - ...overrides, - } -} - -function makeCycle(criterionId: string, status: TddCycleEvidence["status"] = "pass"): TddCycleEvidence { - return { - cycleId: `cycle-${criterionId}`, - criterionId, - reworkRevision: 0, - status, - startWorkspaceDigest: emptyDigest(), - testFiles: [], - redTestDigest: emptyDigest(), - redAttempts: [], - greenAttempts: [], - } -} - -function makeRegression(status: TddRegressionEvidence["status"] = "pass"): TddRegressionEvidence { - return { - status, - headSha: "head1", - treeSha: "tree1", - reworkRevision: 0, - runs: [], - } -} - -function makeVerification(status: FinalVerificationEvidence["status"] = "pass"): FinalVerificationEvidence { - return { status, headSha: "head1", treeSha: "tree1", runs: [] } -} - -function makeAltEvidence(validationId: string, status: "pass" | "fail" = "pass"): AlternativeValidationEvidence { - return { - validationId, - kind: "command", - status, - headSha: "head1", - treeSha: "tree1", - reworkRevision: 0, - evidence: { - command: "test", - testSelector: null, - exitCode: 0, - failureKind: null, - testsCollected: 1, - testsFailed: 0, - startedAt: "2026-01-01T00:00:00Z", - finishedAt: "2026-01-01T00:00:01Z", - durationMs: 1000, - changedFiles: [], - outputDigest: emptyDigest(), - workspaceDigest: emptyDigest(), - executionInputDigest: emptyDigest(), - summary: "ok", - }, - } -} - -const tddCriterion: AcceptanceCriterion = { id: "AC-1", description: "tdd test", verification: "tdd" } -const regressionCriterion: AcceptanceCriterion = { id: "AC-2", description: "regression test", verification: "regression" } -const manualCriterion: AcceptanceCriterion = { id: "AC-3", description: "manual test", verification: "manual" } - -// ─── 测试 ─── - -describe("evaluateTddCompliance", () => { - // ── 基础类型安全 ── - - it("returns a well-structured result", () => { - const policy = makePolicy({ mode: "strict", enforcement: "advisory" }) - const evidence = makeEvidence({ - cycles: [makeCycle("AC-1")], - regression: makeRegression(), - verification: makeVerification(), - }) - const result = evaluateTddCompliance(policy, evidence, [tddCriterion]) - expect(result).toHaveProperty("status") - expect(result).toHaveProperty("warnings") - expect(["pass", "fail", "waived"]).toContain(result.status) - expect(Array.isArray(result.warnings)).toBe(true) - }) - - // ── strict + advisory ── - - it("strict + advisory + all cycles pass → pass with no warnings", () => { - const policy = makePolicy({ mode: "strict", enforcement: "advisory" }) - const evidence = makeEvidence({ - cycles: [makeCycle("AC-1")], - regression: makeRegression(), - verification: makeVerification(), - }) - const result = evaluateTddCompliance(policy, evidence, [tddCriterion, regressionCriterion]) - expect(result.status).toBe("pass") - expect(result.warnings).toEqual([]) - }) - - it("strict + advisory + missing cycle → pass with warnings", () => { - const policy = makePolicy({ mode: "strict", enforcement: "advisory" }) - const evidence = makeEvidence({ - cycles: [], - regression: makeRegression(), - verification: makeVerification(), - }) - const result = evaluateTddCompliance(policy, evidence, [tddCriterion]) - expect(result.status).toBe("pass") - expect(result.warnings.length).toBeGreaterThan(0) - expect(result.warnings.some((w: string) => w.includes("AC-1"))).toBe(true) - }) - - it("strict + advisory + regression fail → pass with warnings", () => { - const policy = makePolicy({ mode: "strict", enforcement: "advisory" }) - const evidence = makeEvidence({ - cycles: [makeCycle("AC-1")], - regression: makeRegression("fail"), - verification: makeVerification(), - }) - const result = evaluateTddCompliance(policy, evidence, [tddCriterion, regressionCriterion]) - expect(result.status).toBe("pass") - expect(result.warnings.length).toBeGreaterThan(0) - }) - - it("strict + advisory + verification fail → pass with warnings", () => { - const policy = makePolicy({ mode: "strict", enforcement: "advisory" }) - const evidence = makeEvidence({ - cycles: [makeCycle("AC-1")], - regression: makeRegression(), - verification: makeVerification("fail"), - }) - const result = evaluateTddCompliance(policy, evidence, [tddCriterion]) - expect(result.status).toBe("pass") - expect(result.warnings.length).toBeGreaterThan(0) - }) - - // ── strict + runtime ── - - it("strict + runtime + all criteria covered → pass", () => { - const policy = makePolicy({ mode: "strict", enforcement: "runtime" }) - const evidence = makeEvidence({ - cycles: [makeCycle("AC-1")], - regression: makeRegression(), - verification: makeVerification(), - }) - const result = evaluateTddCompliance(policy, evidence, [tddCriterion, regressionCriterion]) - expect(result.status).toBe("pass") - expect(result.warnings).toEqual([]) - }) - - it("strict + runtime + missing cycle for TDD criterion → fail", () => { - const policy = makePolicy({ mode: "strict", enforcement: "runtime" }) - const evidence = makeEvidence({ - cycles: [], - regression: makeRegression(), - verification: makeVerification(), - }) - const result = evaluateTddCompliance(policy, evidence, [tddCriterion]) - expect(result.status).toBe("fail") - expect(result.warnings.length).toBeGreaterThan(0) - }) - - it("strict + runtime + failed cycle → fail", () => { - const policy = makePolicy({ mode: "strict", enforcement: "runtime" }) - const evidence = makeEvidence({ - cycles: [makeCycle("AC-1", "failed")], - regression: makeRegression(), - verification: makeVerification(), - }) - const result = evaluateTddCompliance(policy, evidence, [tddCriterion]) - expect(result.status).toBe("fail") - }) - - it("strict + runtime + abandoned cycle → fail", () => { - const policy = makePolicy({ mode: "strict", enforcement: "runtime" }) - const evidence = makeEvidence({ - cycles: [makeCycle("AC-1", "abandoned")], - regression: makeRegression(), - verification: makeVerification(), - }) - const result = evaluateTddCompliance(policy, evidence, [tddCriterion]) - expect(result.status).toBe("fail") - }) - - it("strict + runtime + regression fail → fail", () => { - const policy = makePolicy({ mode: "strict", enforcement: "runtime" }) - const evidence = makeEvidence({ - cycles: [makeCycle("AC-1")], - regression: makeRegression("fail"), - verification: makeVerification(), - }) - const result = evaluateTddCompliance(policy, evidence, [tddCriterion, regressionCriterion]) - expect(result.status).toBe("fail") - }) - - it("strict + runtime + verification fail → fail", () => { - const policy = makePolicy({ mode: "strict", enforcement: "runtime" }) - const evidence = makeEvidence({ - cycles: [makeCycle("AC-1")], - regression: makeRegression(), - verification: makeVerification("fail"), - }) - const result = evaluateTddCompliance(policy, evidence, [tddCriterion]) - expect(result.status).toBe("fail") - }) - - it("strict + runtime + multiple TDD criteria, one uncovered → fail", () => { - const policy = makePolicy({ mode: "strict", enforcement: "runtime" }) - const criterion2: AcceptanceCriterion = { id: "AC-2", description: "second tdd", verification: "tdd" } - const evidence = makeEvidence({ - cycles: [makeCycle("AC-1")], - regression: makeRegression(), - verification: makeVerification(), - }) - const result = evaluateTddCompliance(policy, evidence, [tddCriterion, criterion2]) - expect(result.status).toBe("fail") - }) - - it("strict + runtime + multiple TDD criteria, all covered → pass", () => { - const policy = makePolicy({ mode: "strict", enforcement: "runtime" }) - const criterion2: AcceptanceCriterion = { id: "AC-2", description: "second tdd", verification: "tdd" } - const evidence = makeEvidence({ - cycles: [makeCycle("AC-1"), makeCycle("AC-2")], - regression: makeRegression(), - verification: makeVerification(), - }) - const result = evaluateTddCompliance(policy, evidence, [tddCriterion, criterion2]) - expect(result.status).toBe("pass") - }) - - // ── strict: no TDD criteria → fail ── - - it("strict + runtime + no TDD criteria → fail", () => { - const policy = makePolicy({ mode: "strict", enforcement: "runtime" }) - const evidence = makeEvidence({ - cycles: [], - regression: makeRegression(), - verification: makeVerification(), - }) - const result = evaluateTddCompliance(policy, evidence, [regressionCriterion]) - expect(result.status).toBe("fail") - }) - - // ── strict: manual criterion not allowed ── - - it("strict + runtime + has manual criterion → fail", () => { - const policy = makePolicy({ mode: "strict", enforcement: "runtime" }) - const evidence = makeEvidence({ - cycles: [makeCycle("AC-1")], - regression: makeRegression(), - verification: makeVerification(), - }) - const result = evaluateTddCompliance(policy, evidence, [tddCriterion, manualCriterion]) - expect(result.status).toBe("fail") - }) - - // ── relaxed + advisory ── - - it("relaxed + advisory + regression pass → pass (no cycles needed)", () => { - const policy = makePolicy({ mode: "relaxed", enforcement: "advisory" }) - const evidence = makeEvidence({ - cycles: [], - regression: makeRegression(), - verification: makeVerification(), - }) - const result = evaluateTddCompliance(policy, evidence, [tddCriterion, regressionCriterion]) - expect(result.status).toBe("pass") - expect(result.warnings).toEqual([]) - }) - - it("relaxed + advisory + regression fail → pass with warnings", () => { - const policy = makePolicy({ mode: "relaxed", enforcement: "advisory" }) - const evidence = makeEvidence({ - cycles: [], - regression: makeRegression("fail"), - verification: makeVerification(), - }) - const result = evaluateTddCompliance(policy, evidence, [regressionCriterion]) - expect(result.status).toBe("pass") - expect(result.warnings.length).toBeGreaterThan(0) - }) - - it("relaxed + advisory + pending regression → pass with warnings", () => { - const policy = makePolicy({ mode: "relaxed", enforcement: "advisory" }) - const evidence = makeEvidence({ - cycles: [], - regression: { status: "pending", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, - verification: makeVerification(), - }) - const result = evaluateTddCompliance(policy, evidence, [regressionCriterion]) - expect(result.status).toBe("pass") - expect(result.warnings.length).toBeGreaterThan(0) - }) - - // ── relaxed + runtime ── - - it("relaxed + runtime + regression pass → pass", () => { - const policy = makePolicy({ mode: "relaxed", enforcement: "runtime" }) - const evidence = makeEvidence({ - cycles: [], - regression: makeRegression(), - verification: makeVerification(), - }) - const result = evaluateTddCompliance(policy, evidence, [regressionCriterion]) - expect(result.status).toBe("pass") - }) - - it("relaxed + runtime + regression fail → fail", () => { - const policy = makePolicy({ mode: "relaxed", enforcement: "runtime" }) - const evidence = makeEvidence({ - cycles: [], - regression: makeRegression("fail"), - verification: makeVerification(), - }) - const result = evaluateTddCompliance(policy, evidence, [regressionCriterion]) - expect(result.status).toBe("fail") - }) - - it("relaxed + runtime + pending regression → fail", () => { - const policy = makePolicy({ mode: "relaxed", enforcement: "runtime" }) - const evidence = makeEvidence({ - cycles: [], - regression: { status: "pending", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, - verification: makeVerification(), - }) - const result = evaluateTddCompliance(policy, evidence, [regressionCriterion]) - expect(result.status).toBe("fail") - }) - - it("relaxed + runtime + verification fail → fail", () => { - const policy = makePolicy({ mode: "relaxed", enforcement: "runtime" }) - const evidence = makeEvidence({ - cycles: [], - regression: makeRegression(), - verification: makeVerification("fail"), - }) - const result = evaluateTddCompliance(policy, evidence, [regressionCriterion]) - expect(result.status).toBe("fail") - }) - - // ── bypass + advisory ── - - it("bypass + advisory + all alt validations done → waived", () => { - const policy = makePolicy({ - mode: "bypass", - enforcement: "advisory", - exception: { - reason: "visual only", - alternativeValidation: [{ validationId: "ALT-1", kind: "command", command: { command: "lint", cwd: ".", timeoutMs: 10000, env: {} } }], - approval: { kind: "legacy-migration", fromSchemaVersion: 1 }, - }, - }) - const evidence = makeEvidence({ - cycles: [], - regression: { status: "skipped", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, - verification: makeVerification(), - alternativeValidation: [makeAltEvidence("ALT-1")], - }) - const result = evaluateTddCompliance(policy, evidence, [manualCriterion]) - expect(result.status).toBe("waived") - }) - - it("bypass + advisory + missing alt validation → waived with warnings", () => { - const policy = makePolicy({ - mode: "bypass", - enforcement: "advisory", - exception: { - reason: "visual only", - alternativeValidation: [{ validationId: "ALT-1", kind: "command", command: { command: "lint", cwd: ".", timeoutMs: 10000, env: {} } }], - approval: { kind: "legacy-migration", fromSchemaVersion: 1 }, - }, - }) - const evidence = makeEvidence({ - cycles: [], - regression: { status: "skipped", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, - verification: makeVerification(), - alternativeValidation: [], - }) - const result = evaluateTddCompliance(policy, evidence, [manualCriterion]) - expect(result.status).toBe("waived") - expect(result.warnings.length).toBeGreaterThan(0) - }) - - // ── bypass + runtime ── - - it("bypass + runtime + all alt validations done → waived", () => { - const policy = makePolicy({ - mode: "bypass", - enforcement: "runtime", - exception: { - reason: "visual only", - alternativeValidation: [{ validationId: "ALT-1", kind: "command", command: { command: "lint", cwd: ".", timeoutMs: 10000, env: {} } }], - approval: { kind: "legacy-migration", fromSchemaVersion: 1 }, - }, - }) - const evidence = makeEvidence({ - cycles: [], - regression: { status: "skipped", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, - verification: makeVerification(), - alternativeValidation: [makeAltEvidence("ALT-1")], - }) - const result = evaluateTddCompliance(policy, evidence, [manualCriterion]) - expect(result.status).toBe("waived") - }) - - it("bypass + runtime + missing alt validation → fail", () => { - const policy = makePolicy({ - mode: "bypass", - enforcement: "runtime", - exception: { - reason: "visual only", - alternativeValidation: [{ validationId: "ALT-1", kind: "command", command: { command: "lint", cwd: ".", timeoutMs: 10000, env: {} } }], - approval: { kind: "legacy-migration", fromSchemaVersion: 1 }, - }, - }) - const evidence = makeEvidence({ - cycles: [], - regression: { status: "skipped", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, - verification: makeVerification(), - alternativeValidation: [], - }) - const result = evaluateTddCompliance(policy, evidence, [manualCriterion]) - expect(result.status).toBe("fail") - }) - - it("bypass + runtime + failed alt validation → fail", () => { - const policy = makePolicy({ - mode: "bypass", - enforcement: "runtime", - exception: { - reason: "visual only", - alternativeValidation: [{ validationId: "ALT-1", kind: "command", command: { command: "lint", cwd: ".", timeoutMs: 10000, env: {} } }], - approval: { kind: "legacy-migration", fromSchemaVersion: 1 }, - }, - }) - const evidence = makeEvidence({ - cycles: [], - regression: { status: "skipped", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, - verification: makeVerification(), - alternativeValidation: [makeAltEvidence("ALT-1", "fail")], - }) - const result = evaluateTddCompliance(policy, evidence, [manualCriterion]) - expect(result.status).toBe("fail") - }) - - it("bypass + runtime + no exception → fail", () => { - const policy = makePolicy({ mode: "bypass", enforcement: "runtime" }) - const evidence = makeEvidence({ - cycles: [], - regression: { status: "skipped", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, - verification: makeVerification(), - alternativeValidation: [], - }) - const result = evaluateTddCompliance(policy, evidence, [manualCriterion]) - expect(result.status).toBe("fail") - }) - - // ── bypass: verification must still pass ── - - it("bypass + runtime + verification fail → fail", () => { - const policy = makePolicy({ - mode: "bypass", - enforcement: "runtime", - exception: { - reason: "visual only", - alternativeValidation: [{ validationId: "ALT-1", kind: "command", command: { command: "lint", cwd: ".", timeoutMs: 10000, env: {} } }], - approval: { kind: "legacy-migration", fromSchemaVersion: 1 }, - }, - }) - const evidence = makeEvidence({ - cycles: [], - regression: { status: "skipped", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, - verification: makeVerification("fail"), - alternativeValidation: [makeAltEvidence("ALT-1")], - }) - const result = evaluateTddCompliance(policy, evidence, [manualCriterion]) - expect(result.status).toBe("fail") - }) - - // ── empty criteria array ── - - it("strict + runtime + empty criteria → fail", () => { - const policy = makePolicy({ mode: "strict", enforcement: "runtime" }) - const evidence = makeEvidence({ - cycles: [], - regression: makeRegression(), - verification: makeVerification(), - }) - const result = evaluateTddCompliance(policy, evidence, []) - expect(result.status).toBe("fail") - }) - - // ── relaxed mode: no regression criteria needed, just checks regression evidence ── - - it("relaxed + runtime + regression pass + no criteria → pass", () => { - const policy = makePolicy({ mode: "relaxed", enforcement: "runtime" }) - const evidence = makeEvidence({ - cycles: [], - regression: makeRegression(), - verification: makeVerification(), - }) - const result = evaluateTddCompliance(policy, evidence, []) - expect(result.status).toBe("pass") - }) - - // ── strict + advisory + all pass → no warnings ── - - it("strict + advisory + perfect evidence → pass with no warnings", () => { - const policy = makePolicy({ mode: "strict", enforcement: "advisory" }) - const evidence = makeEvidence({ - cycles: [makeCycle("AC-1")], - regression: makeRegression(), - verification: makeVerification(), - }) - const result = evaluateTddCompliance(policy, evidence, [tddCriterion]) - expect(result.status).toBe("pass") - expect(result.warnings).toEqual([]) - }) - - // ── relaxed + advisory + verification fail → warnings ── - - it("relaxed + advisory + verification fail → pass with warnings", () => { - const policy = makePolicy({ mode: "relaxed", enforcement: "advisory" }) - const evidence = makeEvidence({ - cycles: [], - regression: makeRegression(), - verification: makeVerification("fail"), - }) - const result = evaluateTddCompliance(policy, evidence, [regressionCriterion]) - expect(result.status).toBe("pass") - expect(result.warnings.length).toBeGreaterThan(0) - }) - - // ── bypass: no criteria needed ── - - it("bypass + runtime + all alt validations done + no criteria → waived", () => { - const policy = makePolicy({ - mode: "bypass", - enforcement: "runtime", - exception: { - reason: "visual only", - alternativeValidation: [{ validationId: "ALT-1", kind: "command", command: { command: "lint", cwd: ".", timeoutMs: 10000, env: {} } }], - approval: { kind: "legacy-migration", fromSchemaVersion: 1 }, - }, - }) - const evidence = makeEvidence({ - cycles: [], - regression: { status: "skipped", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, - verification: makeVerification(), - alternativeValidation: [makeAltEvidence("ALT-1")], - }) - const result = evaluateTddCompliance(policy, evidence, []) - expect(result.status).toBe("waived") - }) -}) diff --git a/test/flowrun/finalize.test.ts b/test/flowrun/finalize.test.ts deleted file mode 100644 index 366e595..0000000 --- a/test/flowrun/finalize.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { describe, it, expect } from "vitest" -import { flowRunFinalize } from "../../src/flowrun/transitions.js" -import { createInitialFlowRun } from "../../src/flowrun/github.js" -import type { FlowRun } from "../../src/flowrun/types.js" - -/** - * 辅助:创建 running 状态、code stage running 的基础 FlowRun - */ -function baseRun(overrides: Partial = {}): FlowRun { - const base = createInitialFlowRun("flow-o/r-1", "o/r", 1) - return { - ...base, - status: "running", - ...overrides, - } as FlowRun -} - -/** - * 辅助:设置指定 stage 的状态 - */ -function withStage(run: FlowRun, stage: string, status: string): FlowRun { - return { - ...run, - stages: { - ...run.stages, - [stage]: { ...run.stages[stage as keyof typeof run.stages], status }, - }, - } -} - -/** - * 辅助:添加一个 merged 状态的 task - */ -function withTaskMerged(run: FlowRun, taskId: string, dependsOn: string[] = []): FlowRun { - return { - ...run, - tasks: { - ...run.tasks, - [taskId]: { - id: taskId, - name: taskId, - status: "merged", - dependsOn, - expectedFiles: [`src/${taskId}.ts`], - }, - }, - } -} - -// ───────────────────────────────────────────── -// flowRunFinalize 测试 -// ───────────────────────────────────────────── - -describe("flowRunFinalize", () => { - it("正确推进 code→test→review→merge 四个 Stage 为 pass,并设为 completed", () => { - // 前置:所有 task merged - let run = baseRun() - run = withStage(run, "requirements", "pass") - run = withStage(run, "design", "pass") - run = withStage(run, "tasks", "pass") - run = withStage(run, "code", "running") - run = withStage(run, "test", "running") - run = withStage(run, "review", "running") - run = withStage(run, "merge", "pending") - run = withTaskMerged(run, "task-1") - - const result = flowRunFinalize(run) - - expect(result.ok).toBe(true) - const finalized = result.ok ? result.value : run - - // 四个 stage 全部 pass - expect(finalized.stages.code.status).toBe("pass") - expect(finalized.stages.test.status).toBe("pass") - expect(finalized.stages.review.status).toBe("pass") - expect(finalized.stages.merge.status).toBe("pass") - - // FlowRun status → completed - expect(finalized.status).toBe("completed") - // completedAt 已设置 - expect(finalized.completedAt).not.toBeNull() - expect(finalized.completedAt).toBeDefined() - }) - - it("当并非所有 Task 都 merged 时拒绝", () => { - let run = baseRun() - run = withStage(run, "code", "running") - run = withTaskMerged(run, "task-1") - // task-2 未 merged - run = { - ...run, - tasks: { - ...run.tasks, - "task-2": { - id: "task-2", - name: "task-2", - status: "running", - dependsOn: [], - expectedFiles: ["src/task-2.ts"], - }, - }, - } as FlowRun - - const result = flowRunFinalize(run) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error.code).toBe("GATE_BLOCKED") - expect(result.error.message).toContain("all tasks merged") - } - }) - - it("当 FlowRun 状态非 running 且非 merging 时拒绝", () => { - let run = baseRun({ status: "blocked" }) - run = withTaskMerged(run, "task-1") - - const result = flowRunFinalize(run) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error.code).toBe("GATE_BLOCKED") - } - }) - - it("幂等:已经是 completed 状态则直接返回(不变更)", () => { - let run = baseRun({ status: "completed", completedAt: "2025-01-01T00:00:00.000Z" }) - run = withStage(run, "code", "pass") - run = withStage(run, "test", "pass") - run = withStage(run, "review", "pass") - run = withStage(run, "merge", "pass") - run = withTaskMerged(run, "task-1") - - const result = flowRunFinalize(run) - expect(result.ok).toBe(true) - if (result.ok) { - // completedAt 不应变化 - expect(result.value.completedAt).toBe("2025-01-01T00:00:00.000Z") - expect(result.value.status).toBe("completed") - } - }) - - it("幂等:两次 finalize 结果一致", () => { - let run = baseRun() - run = withStage(run, "requirements", "pass") - run = withStage(run, "design", "pass") - run = withStage(run, "tasks", "pass") - run = withStage(run, "code", "running") - run = withStage(run, "test", "running") - run = withStage(run, "review", "running") - run = withStage(run, "merge", "pending") - run = withTaskMerged(run, "task-1") - - const result1 = flowRunFinalize(run) - expect(result1.ok).toBe(true) - if (!result1.ok) return - - // 第二次 finalize - const result2 = flowRunFinalize(result1.value) - expect(result2.ok).toBe(true) - if (!result2.ok) return - - // 状态一致 - expect(result2.value.status).toBe("completed") - expect(result2.value.completedAt).toBe(result1.value.completedAt) - }) - - it("跳过已经是 pass 的 Stage(不重复标记)", () => { - let run = baseRun() - run = withStage(run, "requirements", "pass") - run = withStage(run, "design", "pass") - run = withStage(run, "tasks", "pass") - run = withStage(run, "code", "pass") // 已 pass - run = withStage(run, "test", "running") - run = withStage(run, "review", "running") - run = withStage(run, "merge", "pending") - run = withTaskMerged(run, "task-1") - - const result = flowRunFinalize(run) - expect(result.ok).toBe(true) - if (result.ok) { - // code 保持原来的 completedAt(finalize 不会重新设置已有的) - expect(result.value.stages.code.status).toBe("pass") - expect(result.value.stages.test.status).toBe("pass") - expect(result.value.stages.review.status).toBe("pass") - expect(result.value.stages.merge.status).toBe("pass") - expect(result.value.status).toBe("completed") - } - }) -}) diff --git a/test/flowrun/fixtures/simple-vitest/package-lock.json b/test/flowrun/fixtures/simple-vitest/package-lock.json deleted file mode 100644 index 86b7a67..0000000 --- a/test/flowrun/fixtures/simple-vitest/package-lock.json +++ /dev/null @@ -1,1622 +0,0 @@ -{ - "name": "simple-vitest-fixture", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "simple-vitest-fixture", - "devDependencies": { - "vitest": "^3.2.4" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", - "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", - "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", - "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", - "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", - "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", - "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", - "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", - "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", - "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", - "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", - "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", - "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", - "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", - "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", - "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", - "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", - "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", - "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", - "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", - "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", - "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", - "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", - "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", - "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", - "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/expect": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", - "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", - "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.7", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", - "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", - "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.7", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", - "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.7", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", - "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", - "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.7", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", - "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.20", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", - "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.16", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/rollup": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", - "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.62.2", - "@rollup/rollup-android-arm64": "4.62.2", - "@rollup/rollup-darwin-arm64": "4.62.2", - "@rollup/rollup-darwin-x64": "4.62.2", - "@rollup/rollup-freebsd-arm64": "4.62.2", - "@rollup/rollup-freebsd-x64": "4.62.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", - "@rollup/rollup-linux-arm-musleabihf": "4.62.2", - "@rollup/rollup-linux-arm64-gnu": "4.62.2", - "@rollup/rollup-linux-arm64-musl": "4.62.2", - "@rollup/rollup-linux-loong64-gnu": "4.62.2", - "@rollup/rollup-linux-loong64-musl": "4.62.2", - "@rollup/rollup-linux-ppc64-gnu": "4.62.2", - "@rollup/rollup-linux-ppc64-musl": "4.62.2", - "@rollup/rollup-linux-riscv64-gnu": "4.62.2", - "@rollup/rollup-linux-riscv64-musl": "4.62.2", - "@rollup/rollup-linux-s390x-gnu": "4.62.2", - "@rollup/rollup-linux-x64-gnu": "4.62.2", - "@rollup/rollup-linux-x64-musl": "4.62.2", - "@rollup/rollup-openbsd-x64": "4.62.2", - "@rollup/rollup-openharmony-arm64": "4.62.2", - "@rollup/rollup-win32-arm64-msvc": "4.62.2", - "@rollup/rollup-win32-ia32-msvc": "4.62.2", - "@rollup/rollup-win32-x64-gnu": "4.62.2", - "@rollup/rollup-win32-x64-msvc": "4.62.2", - "fsevents": "~2.3.2" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/vite": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", - "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0 || ^0.28.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", - "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.7", - "@vitest/mocker": "3.2.7", - "@vitest/pretty-format": "^3.2.7", - "@vitest/runner": "3.2.7", - "@vitest/snapshot": "3.2.7", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.7", - "@vitest/ui": "3.2.7", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - } - } -} diff --git a/test/flowrun/fixtures/simple-vitest/package.json b/test/flowrun/fixtures/simple-vitest/package.json deleted file mode 100644 index 746a9a9..0000000 --- a/test/flowrun/fixtures/simple-vitest/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "simple-vitest-fixture", - "private": true, - "type": "module", - "scripts": { - "test": "vitest run" - }, - "devDependencies": { - "vitest": "^3.2.4" - } -} diff --git a/test/flowrun/fixtures/simple-vitest/src/sum.ts b/test/flowrun/fixtures/simple-vitest/src/sum.ts deleted file mode 100644 index da1a570..0000000 --- a/test/flowrun/fixtures/simple-vitest/src/sum.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function sum(a: number, b: number): number { - return a + b -} diff --git a/test/flowrun/fixtures/simple-vitest/test/sum.test.ts b/test/flowrun/fixtures/simple-vitest/test/sum.test.ts deleted file mode 100644 index 843de3e..0000000 --- a/test/flowrun/fixtures/simple-vitest/test/sum.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, it, expect } from "vitest" -import { sum } from "../src/sum.js" - -describe("sum", () => { - it("1 + 2 = 3", () => { - expect(sum(1, 2)).toBe(3) - }) - - it("0 + 0 = 0", () => { - expect(sum(0, 0)).toBe(0) - }) -}) diff --git a/test/flowrun/fixtures/simple-vitest/vitest.config.ts b/test/flowrun/fixtures/simple-vitest/vitest.config.ts deleted file mode 100644 index ab76a35..0000000 --- a/test/flowrun/fixtures/simple-vitest/vitest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "vitest/config" - -export default defineConfig({ - test: { - environment: "node", - }, -}) diff --git a/test/flowrun/fixtures/v1-flowrun-complete.json b/test/flowrun/fixtures/v1-flowrun-complete.json deleted file mode 100644 index b0751ec..0000000 --- a/test/flowrun/fixtures/v1-flowrun-complete.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "flowRunId": "flow-owner/repo-issue-104", - "repo": "owner/repo", - "parentIssueNumber": 104, - "status": "running", - "schemaVersion": 1, - "revision": 7, - "stages": { - "requirements": { "status": "pass", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "design": { "status": "pass", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "tasks": { "status": "pass", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "code": { "status": "running", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "test": { "status": "pending", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "review": { "status": "pending", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "merge": { "status": "pending", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] } - }, - "tasks": { - "task-1": { - "id": "task-1", - "name": "Task Alpha (with tests)", - "status": "running", - "dependsOn": [], - "area": "backend", - "expectedFiles": ["src/alpha.ts"], - "testCommands": ["npm test -- test/alpha.test.ts"], - "acceptance": "Alpha 功能验收通过", - "parallelSafe": true, - "prNumber": null, - "prCheckpoints": null, - "blockedReason": null, - "startedAt": "2026-07-20T10:00:00.000Z" - }, - "task-2": { - "id": "task-2", - "name": "Task Beta (blocked, no tests)", - "status": "blocked", - "dependsOn": ["task-1"], - "area": "backend", - "expectedFiles": ["src/beta.ts"], - "testCommands": [], - "acceptance": "视觉稿调整", - "parallelSafe": false, - "prNumber": null, - "prCheckpoints": null, - "blockedReason": "Waiting for design review", - "startedAt": null - }, - "task-3": { - "id": "task-3", - "name": "Task Gamma (cancelled)", - "status": "cancelled", - "dependsOn": ["task-1"], - "area": "frontend", - "expectedFiles": ["src/gamma.tsx"], - "testCommands": ["npm test -- test/gamma.test.tsx"], - "acceptance": "Gamma 功能(已取消)", - "parallelSafe": false, - "prNumber": null, - "prCheckpoints": null, - "blockedReason": null, - "startedAt": null - } - }, - "startedAt": "2026-07-20T10:00:00.000Z", - "lastTickAt": "2026-07-20T10:30:00.000Z", - "nextTickAfter": null, - "maxRuntime": 86400000, - "completedAt": null -} diff --git a/test/flowrun/fixtures/v1-task-merged.json b/test/flowrun/fixtures/v1-task-merged.json deleted file mode 100644 index 37fb8a0..0000000 --- a/test/flowrun/fixtures/v1-task-merged.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "flowRunId": "flow-owner/repo-issue-103", - "repo": "owner/repo", - "parentIssueNumber": 103, - "status": "running", - "schemaVersion": 1, - "revision": 6, - "stages": { - "requirements": { "status": "pass", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "design": { "status": "pass", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "tasks": { "status": "pass", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "code": { "status": "running", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "test": { "status": "pending", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "review": { "status": "pending", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "merge": { "status": "pending", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] } - }, - "tasks": { - "task-merged": { - "id": "task-merged", - "name": "Merged Task", - "status": "merged", - "dependsOn": [], - "area": "backend", - "expectedFiles": ["src/merged.ts"], - "testCommands": ["npm test -- test/merged.test.ts"], - "acceptance": "功能已合并并通过所有检查", - "parallelSafe": false, - "prNumber": 33, - "prCheckpoints": { - "prNumber": 33, - "localChecks": { "name": "localChecks", "status": "pass", "evidence": [] }, - "ciChecks": { "name": "ciChecks", "status": "pass", "evidence": [] }, - "reviewerApproval": { "name": "reviewerApproval", "status": "pass", "evidence": [] }, - "goalVerification": { "name": "goalVerification", "status": "pass", "evidence": [] }, - "branchProtection": { "name": "branchProtection", "status": "pass", "evidence": [] }, - "mergeResult": { "name": "mergeResult", "status": "pass", "evidence": [] } - }, - "blockedReason": null, - "startedAt": "2026-07-20T09:00:00.000Z" - } - }, - "startedAt": "2026-07-20T09:00:00.000Z", - "lastTickAt": "2026-07-20T10:30:00.000Z", - "nextTickAfter": null, - "maxRuntime": 86400000, - "completedAt": null -} diff --git a/test/flowrun/fixtures/v1-task-pending.json b/test/flowrun/fixtures/v1-task-pending.json deleted file mode 100644 index db63ea7..0000000 --- a/test/flowrun/fixtures/v1-task-pending.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "flowRunId": "flow-owner/repo-issue-100", - "repo": "owner/repo", - "parentIssueNumber": 100, - "status": "running", - "schemaVersion": 1, - "revision": 3, - "stages": { - "requirements": { "status": "pass", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "design": { "status": "pass", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "tasks": { "status": "pass", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "code": { "status": "running", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "test": { "status": "pending", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "review": { "status": "pending", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "merge": { "status": "pending", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] } - }, - "tasks": { - "task-pending": { - "id": "task-pending", - "name": "Pending Task", - "status": "pending", - "dependsOn": [], - "area": "backend", - "expectedFiles": ["src/example.ts"], - "testCommands": [], - "acceptance": "Should work correctly", - "parallelSafe": false, - "prNumber": null, - "prCheckpoints": null, - "blockedReason": null, - "startedAt": null - } - }, - "startedAt": "2026-07-20T10:00:00.000Z", - "lastTickAt": "2026-07-20T10:30:00.000Z", - "nextTickAfter": null, - "maxRuntime": 86400000, - "completedAt": null -} diff --git a/test/flowrun/fixtures/v1-task-reviewing.json b/test/flowrun/fixtures/v1-task-reviewing.json deleted file mode 100644 index 281ac2d..0000000 --- a/test/flowrun/fixtures/v1-task-reviewing.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "flowRunId": "flow-owner/repo-issue-102", - "repo": "owner/repo", - "parentIssueNumber": 102, - "status": "running", - "schemaVersion": 1, - "revision": 5, - "stages": { - "requirements": { "status": "pass", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "design": { "status": "pass", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "tasks": { "status": "pass", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "code": { "status": "running", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "test": { "status": "pending", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "review": { "status": "pending", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "merge": { "status": "pending", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] } - }, - "tasks": { - "task-reviewing": { - "id": "task-reviewing", - "name": "Reviewing Task", - "status": "reviewing", - "dependsOn": [], - "area": "frontend", - "expectedFiles": ["src/ui.tsx"], - "testCommands": ["npm test -- test/ui.test.tsx", "npm run typecheck"], - "acceptance": "UI 组件渲染正确且通过类型检查", - "parallelSafe": false, - "prNumber": 55, - "prCheckpoints": { - "prNumber": 55, - "localChecks": { "name": "localChecks", "status": "pass", "evidence": [] }, - "ciChecks": { "name": "ciChecks", "status": "pending", "evidence": [] }, - "reviewerApproval": { "name": "reviewerApproval", "status": "pending", "evidence": [] }, - "goalVerification": { "name": "goalVerification", "status": "pending", "evidence": [] }, - "branchProtection": { "name": "branchProtection", "status": "pending", "evidence": [] }, - "mergeResult": { "name": "mergeResult", "status": "pending", "evidence": [] } - }, - "blockedReason": null, - "startedAt": "2026-07-20T10:10:00.000Z" - } - }, - "startedAt": "2026-07-20T10:00:00.000Z", - "lastTickAt": "2026-07-20T10:30:00.000Z", - "nextTickAfter": null, - "maxRuntime": 86400000, - "completedAt": null -} diff --git a/test/flowrun/fixtures/v1-task-running.json b/test/flowrun/fixtures/v1-task-running.json deleted file mode 100644 index cf16e6d..0000000 --- a/test/flowrun/fixtures/v1-task-running.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "flowRunId": "flow-owner/repo-issue-101", - "repo": "owner/repo", - "parentIssueNumber": 101, - "status": "running", - "schemaVersion": 1, - "revision": 4, - "stages": { - "requirements": { "status": "pass", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "design": { "status": "pass", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "tasks": { "status": "pass", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "code": { "status": "running", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "test": { "status": "pending", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "review": { "status": "pending", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] }, - "merge": { "status": "pending", "requiredArtifacts": [], "checks": [], "completedAt": null, "evidence": [] } - }, - "tasks": { - "task-running": { - "id": "task-running", - "name": "Running Task", - "status": "running", - "dependsOn": [], - "area": "backend", - "expectedFiles": ["src/module.ts", "test/module.test.ts"], - "testCommands": ["npm test -- test/module.test.ts"], - "acceptance": "模块必须通过单元测试", - "parallelSafe": true, - "prNumber": null, - "prCheckpoints": null, - "blockedReason": null, - "startedAt": "2026-07-20T10:05:00.000Z" - } - }, - "startedAt": "2026-07-20T10:00:00.000Z", - "lastTickAt": "2026-07-20T10:30:00.000Z", - "nextTickAfter": null, - "maxRuntime": 86400000, - "completedAt": null -} diff --git a/test/flowrun/gate.test.ts b/test/flowrun/gate.test.ts deleted file mode 100644 index 2e76fce..0000000 --- a/test/flowrun/gate.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { describe, it, expect } from "vitest" -import { - canStartStage, canCompleteStage, canStartTask, canCompleteTask, - canMerge, allTasksComplete, getReadyTasks, -} from "../../src/flowrun/gate.js" -import { createInitialFlowRun } from "../../src/flowrun/github.js" -import type { FlowRun, FlowStage } from "../../src/flowrun/types.js" - -function runningRun(overrides: Partial = {}): FlowRun { - const base = createInitialFlowRun("flow-o/r-1", "o/r", 1) - return { ...base, status: "running", ...overrides } as FlowRun -} - -function withStage(run: FlowRun, stage: FlowStage, status: string): FlowRun { - return { - ...run, - stages: { - ...run.stages, - [stage]: { ...run.stages[stage], status }, - }, - } -} - -function withTask(run: FlowRun, task: Record): FlowRun { - return { - ...run, - tasks: { ...run.tasks, [task.id as string]: task as any }, - } -} - -describe("canStartStage", () => { - it("allows starting first stage when flow is running", () => { - const run = runningRun() - const result = canStartStage(run, "requirements") - expect(result.allowed).toBe(true) - }) - - it("allows starting second stage when first is complete", () => { - const run = withStage(runningRun(), "requirements", "pass") - expect(canStartStage(run, "design").allowed).toBe(true) - }) - - it("blocks when previous stage is pending", () => { - const run = runningRun() - const result = canStartStage(run, "design") - expect(result.allowed).toBe(false) - expect(result.reason).toContain("requirements") - }) - - it("blocks when flow is not running", () => { - const run = { ...runningRun(), status: "planned" as const } - const result = canStartStage(run, "requirements") - expect(result.allowed).toBe(false) - expect(result.reason).toContain("planned") - }) - - it("blocks when stage already completed", () => { - const run = withStage(runningRun(), "requirements", "pass") - const result = canStartStage(run, "requirements") - expect(result.allowed).toBe(false) - expect(result.reason).toContain("already completed") - }) - - it("blocks merge stage if review not complete", () => { - const run = withStage(runningRun(), "requirements", "pass") - const run2 = withStage(run, "design", "pass") - const run3 = withStage(run2, "tasks", "pass") - const run4 = withStage(run3, "code", "pass") - const run5 = withStage(run4, "test", "pass") - const run6 = withStage(run5, "review", "pending") - const result = canStartStage(run6, "merge") - expect(result.allowed).toBe(false) - }) -}) - -describe("canCompleteStage", () => { - it("allows completing stage with no artifacts and no checks", () => { - const run = withStage(runningRun(), "requirements", "running") - const result = canCompleteStage(run, "requirements") - expect(result.allowed).toBe(true) - }) - - it("blocks if checks have failures", () => { - const run = withStage(runningRun(), "requirements", "running") - run.stages.requirements.checks = [ - { name: "prd-exists", status: "fail", evidence: [{ command: "test", exitCode: 1, summary: "not found", timestamp: "now" }] }, - ] - const result = canCompleteStage(run, "requirements") - expect(result.allowed).toBe(false) - expect(result.failedChecks).toContain("prd-exists") - }) - - it("blocks if checks are still pending", () => { - const run = withStage(runningRun(), "requirements", "running") - run.stages.requirements.checks = [ - { name: "prd-exists", status: "pending", evidence: [] }, - ] - const result = canCompleteStage(run, "requirements") - expect(result.allowed).toBe(false) - expect(result.reason).toContain("pending") - }) -}) - -describe("canStartTask", () => { - it("allows starting a task with no dependencies", () => { - const run = withStage(runningRun(), "code", "running") - const run2 = withTask(run, { - id: "task-1", name: "Task 1", status: "pending", - dependsOn: [], expectedFiles: ["src/a.ts"], - } as any) - const result = canStartTask(run2, "task-1") - expect(result.allowed).toBe(true) - }) - - it("allows starting a task when dependencies are merged", () => { - const run = withStage(runningRun(), "code", "running") - const run2 = withTask(run, { - id: "task-1", name: "Task 1", status: "merged", - dependsOn: [], expectedFiles: ["src/a.ts"], - } as any) - const run3 = withTask(run2, { - id: "task-2", name: "Task 2", status: "pending", - dependsOn: ["task-1"], expectedFiles: ["src/b.ts"], - } as any) - const result = canStartTask(run3, "task-2") - expect(result.allowed).toBe(true) - }) - - it("blocks when dependency not merged", () => { - const run = withStage(runningRun(), "code", "running") - const run2 = withTask(run, { - id: "task-1", name: "Task 1", status: "running", - dependsOn: [], expectedFiles: ["src/a.ts"], - } as any) - const run3 = withTask(run2, { - id: "task-2", name: "Task 2", status: "pending", - dependsOn: ["task-1"], expectedFiles: ["src/b.ts"], - } as any) - const result = canStartTask(run3, "task-2") - expect(result.allowed).toBe(false) - expect(result.reason).toContain("task-1") - }) - - it("blocks when flow is not running", () => { - const run = { ...runningRun(), status: "blocked" as const } - const run2 = withStage(run, "code", "running") - const run3 = withTask(run2, { - id: "task-1", name: "Task 1", status: "pending", - dependsOn: [], expectedFiles: ["src/a.ts"], - } as any) - expect(canStartTask(run3, "task-1").allowed).toBe(false) - }) - - it("blocks if code stage is not active", () => { - const run = withStage(runningRun(), "code", "pending") - const run2 = withTask(run, { - id: "task-1", name: "Task 1", status: "pending", - dependsOn: [], expectedFiles: ["src/a.ts"], - } as any) - expect(canStartTask(run2, "task-1").allowed).toBe(false) - }) -}) - -describe("canCompleteTask", () => { - it("allows completing a running task", () => { - const run = withTask(runningRun(), { - id: "task-1", name: "Task 1", status: "running", - dependsOn: [], expectedFiles: ["src/a.ts"], - } as any) - expect(canCompleteTask(run, "task-1").allowed).toBe(true) - }) - - it("blocks completing a pending task", () => { - const run = withTask(runningRun(), { - id: "task-1", name: "Task 1", status: "pending", - dependsOn: [], expectedFiles: ["src/a.ts"], - } as any) - expect(canCompleteTask(run, "task-1").allowed).toBe(false) - }) - - it("returns error for non-existent task", () => { - const run = runningRun() - const result = canCompleteTask(run, "no-such-task") - expect(result.allowed).toBe(false) - expect(result.reason).toContain("not found") - }) -}) - -describe("canMerge", () => { - it("allows when flow is running and all tasks are merged", () => { - const run = withTask(runningRun(), { id: "a", status: "merged", dependsOn: [], expectedFiles: [] } as any) - expect(canMerge(run).allowed).toBe(true) - }) - - it("allows when flow is merging and all tasks merged", () => { - const run = withTask(runningRun(), { id: "a", status: "merged", dependsOn: [], expectedFiles: [] } as any) - const run2 = { ...run, status: "merging" as const } - expect(canMerge(run2).allowed).toBe(true) - }) - - it("blocks when some tasks not merged", () => { - const run = withTask(runningRun(), { id: "a", status: "merged", dependsOn: [], expectedFiles: [] } as any) - const run2 = withTask(run, { id: "b", status: "running", dependsOn: [], expectedFiles: [] } as any) - expect(canMerge(run2).allowed).toBe(false) - expect(canMerge(run2).reason).toContain("all tasks merged") - }) - - it("blocks when no tasks exist", () => { - expect(canMerge(runningRun()).allowed).toBe(false) - }) - - it("blocks when flow is not in mergeable state", () => { - const run = withTask(runningRun(), { id: "a", status: "merged", dependsOn: [], expectedFiles: [] } as any) - const run2 = { ...run, status: "completed" as const } - expect(canMerge(run2).allowed).toBe(false) - }) - - // 新语义:不再要求 review stage pass - it("allows merge even when review stage is not pass", () => { - const run = withTask(runningRun(), { id: "a", status: "merged", dependsOn: [], expectedFiles: [] } as any) - const run2 = withStage(run, "review", "pending") - // review stage pending 不应阻止 canMerge - expect(canMerge(run2).allowed).toBe(true) - }) -}) - -describe("allTasksComplete", () => { - it("returns true when all tasks are merged", () => { - const run = withTask(runningRun(), { id: "a", status: "merged", dependsOn: [], expectedFiles: [] } as any) - const run2 = withTask(run, { id: "b", status: "merged", dependsOn: [], expectedFiles: [] } as any) - expect(allTasksComplete(run2)).toBe(true) - }) - - it("returns false when any task is running", () => { - const run = withTask(runningRun(), { id: "a", status: "merged", dependsOn: [], expectedFiles: [] } as any) - const run2 = withTask(run, { id: "b", status: "running", dependsOn: [], expectedFiles: [] } as any) - expect(allTasksComplete(run2)).toBe(false) - }) - - it("returns false when no tasks exist", () => { - expect(allTasksComplete(runningRun())).toBe(false) - }) -}) - -describe("getReadyTasks", () => { - it("returns tasks whose dependencies are merged", () => { - const run = withTask(runningRun(), { id: "a", status: "merged", dependsOn: [], expectedFiles: [] } as any) - const run2 = withTask(run, { id: "b", status: "pending", dependsOn: ["a"], expectedFiles: [] } as any) - const ready = getReadyTasks(run2) - expect(ready).toHaveLength(1) - expect(ready[0].id).toBe("b") - }) - - it("excludes tasks with unmerged dependencies", () => { - const run = withTask(runningRun(), { id: "a", status: "merged", dependsOn: [], expectedFiles: [] } as any) - const run2 = withTask(run, { id: "b", status: "pending", dependsOn: ["a"], expectedFiles: [] } as any) - const run3 = withTask(run2, { id: "c", status: "pending", dependsOn: ["b"], expectedFiles: [] } as any) - const ready = getReadyTasks(run3) - expect(ready).toHaveLength(1) - expect(ready[0].id).toBe("b") - }) - - it("excludes already running tasks", () => { - const run = withTask(runningRun(), { id: "a", status: "running", dependsOn: [], expectedFiles: [] } as any) - expect(getReadyTasks(run)).toHaveLength(0) - }) -}) diff --git a/test/flowrun/github.test.ts b/test/flowrun/github.test.ts deleted file mode 100644 index c238ee5..0000000 --- a/test/flowrun/github.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest" -import fs from "node:fs" -import path from "node:path" -import os from "node:os" -import { - extractFlowRunFromBody, - replaceFlowRunInBody, - createInitialFlowRun, -} from "../../src/flowrun/github.js" -import { CABINET_START_MARKER, CABINET_END_MARKER, CURRENT_SCHEMA_VERSION } from "../../src/flowrun/types.js" -import { validateFlowRun } from "../../src/flowrun/validator.js" - -describe("extractFlowRunFromBody", () => { - it("extracts a valid FlowRun JSON from body", () => { - const flowRun = createInitialFlowRun("flow-owner/repo-issue-1", "owner/repo", 1) - const block = buildBlock(flowRun) - const body = `# Issue title\n\nSome description\n\n${block}` - const result = extractFlowRunFromBody(body) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.flowRunId).toBe("flow-owner/repo-issue-1") - expect(result.data.status).toBe("planned") - // v2: 新创建的 FlowRun 不会触发迁移(已经是 v2) - expect(result.migrated).toBe(false) - expect(result.data.repositoryQualityPolicy.mode).toBe("off") - } - }) - - it("returns NOT_FOUND when markers not found", () => { - const body = "# Just a normal issue\nNo flow run here" - const result = extractFlowRunFromBody(body) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.code).toBe("NOT_FOUND") - } - }) - - it("returns INVALID_JSON when JSON is invalid", () => { - const body = `# Issue\n\n${CABINET_START_MARKER}\n\`\`\`json\n{invalid}\n\`\`\`\n${CABINET_END_MARKER}` - const result = extractFlowRunFromBody(body) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.code).toBe("INVALID_JSON") - } - }) - - it("returns VALIDATION_FAILED when JSON fails schema validation", () => { - const body = `# Issue\n\n${CABINET_START_MARKER}\n\`\`\`json\n{"status": "invalid"}\n\`\`\`\n${CABINET_END_MARKER}` - const result = extractFlowRunFromBody(body) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.code).toBe("VALIDATION_FAILED") - } - }) - - it("extracts block with surrounding content", () => { - const flowRun = createInitialFlowRun("flow-a-b-2", "a/b", 2) - const block = buildBlock(flowRun) - const body = `# PRD\n\nSome content here\n\n${block}\n\n## Appendix` - const result = extractFlowRunFromBody(body) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.data.parentIssueNumber).toBe(2) - expect(result.data.repo).toBe("a/b") - } - }) -}) - -describe("replaceFlowRunInBody", () => { - it("replaces existing block in body", () => { - const old = createInitialFlowRun("flow-x-y-1", "x/y", 1) - const body = `# Issue\n\n${buildBlock(old)}\n\nFooter` - const updated = { ...old, revision: 1, status: "running" as const } - const newBody = replaceFlowRunInBody(body, updated) - - expect(newBody).toContain(CABINET_START_MARKER) - expect(newBody).toContain(CABINET_END_MARKER) - expect(newBody).toContain('"running"') - expect(newBody).toContain("Footer") - expect(newBody).not.toContain('"planned"') - }) - - it("appends block when no markers present", () => { - const flowRun = createInitialFlowRun("flow-a-b-3", "a/b", 3) - const body = "# Issue title" - const newBody = replaceFlowRunInBody(body, flowRun) - expect(newBody).toContain(CABINET_START_MARKER) - expect(newBody).toContain(CABINET_END_MARKER) - expect(newBody).toContain(flowRun.flowRunId) - }) - - it("replaces only one block when multiple markers exist", () => { - const flowRun = createInitialFlowRun("flow-x-y-5", "x/y", 5) - const body = `${CABINET_START_MARKER}\`\`\`json\n{"a":1}\n\`\`\`${CABINET_END_MARKER}\ndup\n${CABINET_START_MARKER}\`\`\`json\n{"b":2}\n\`\`\`${CABINET_END_MARKER}` - const newBody = replaceFlowRunInBody(body, flowRun) - expect(newBody).toContain(flowRun.flowRunId) - expect(newBody).not.toContain('{"a":1}') - expect(newBody).toContain("dup") - }) -}) - -describe("createInitialFlowRun", () => { - it("creates a FlowRun with planned status", () => { - const fr = createInitialFlowRun("flow-o/r-7", "o/r", 7) - expect(fr.flowRunId).toBe("flow-o/r-7") - expect(fr.repo).toBe("o/r") - expect(fr.parentIssueNumber).toBe(7) - expect(fr.status).toBe("planned") - expect(fr.schemaVersion).toBe(CURRENT_SCHEMA_VERSION) - expect(fr.revision).toBe(0) - expect(fr.startedAt).not.toBeNull() - expect(fr.maxRuntime).toBe(86_400_000) - // v2 - expect(fr.repositoryQualityPolicy.mode).toBe("off") - expect(fr.repositoryQualityPolicy.requiredChecks).toEqual([]) - }) - - it("initializes all 7 stages as pending", () => { - const fr = createInitialFlowRun("id", "r", 1) - const stages = ["requirements", "design", "tasks", "code", "test", "review", "merge"] as const - for (const s of stages) { - expect(fr.stages[s].status).toBe("pending") - expect(fr.stages[s].requiredArtifacts).toEqual([]) - expect(fr.stages[s].completedAt).toBeNull() - } - }) - - it("initializes empty tasks", () => { - const fr = createInitialFlowRun("id", "r", 1) - expect(fr.tasks).toEqual({}) - }) - - it("passes validation", () => { - const fr = createInitialFlowRun("flow-o/r-10", "o/r", 10) - const { data, errors } = validateFlowRun(fr) - expect(errors).toHaveLength(0) - expect(data).not.toBeNull() - }) -}) - -function buildBlock(flowRun: unknown): string { - const json = JSON.stringify(flowRun, null, 2) - return `${CABINET_START_MARKER}\n\`\`\`json\n${json}\n\`\`\`\n${CABINET_END_MARKER}` -} diff --git a/test/flowrun/merge.test.ts b/test/flowrun/merge.test.ts deleted file mode 100644 index fb7b874..0000000 --- a/test/flowrun/merge.test.ts +++ /dev/null @@ -1,412 +0,0 @@ -import { describe, it, expect, beforeAll } from "vitest" -import { - validateCheckpoint, validatePRCheckpoints, -} from "../../src/flowrun/merge.js" -import { createInitialFlowRun } from "../../src/flowrun/github.js" -import type { - PRCheckpoints, TaskState, Checkpoint, - TddComplianceCheckpoint, FlowRun, TaskCommand, -} from "../../src/flowrun/types.js" - -function passCP(name: string): Checkpoint { - return { name, status: "pass", evidence: [{ command: "test", exitCode: 0, summary: "ok", timestamp: "now" }] } -} - -function failCP(name: string): Checkpoint { - return { name, status: "fail", evidence: [{ command: "test", exitCode: 1, summary: "failed", timestamp: "now" }] } -} - -function pendingCP(name: string): Checkpoint { - return { name, status: "pending", evidence: [] } -} - -function tddPass(): TddComplianceCheckpoint { - return { - status: "pass", - evidenceRevision: 1, - reworkRevision: 0, - headSha: "abc123", - treeSha: "tree456", - summary: "TDD compliance verified", - } -} - -function tddFail(): TddComplianceCheckpoint { - return { - status: "fail", - evidenceRevision: 1, - reworkRevision: 0, - headSha: "abc123", - treeSha: "tree456", - summary: "TDD compliance failed", - } -} - -function noopCmd(): TaskCommand { - return { command: "echo ok", cwd: ".", timeoutMs: 30000, env: {} } -} - -function fullCheckpoints(overrides: Partial = {}): PRCheckpoints { - const base: PRCheckpoints = { - prNumber: 1, - localChecks: passCP("localChecks"), - ciChecks: passCP("ciChecks"), - reviewerApproval: passCP("reviewerApproval"), - goalVerification: passCP("goalVerification"), - branchProtection: passCP("branchProtection"), - mergeResult: pendingCP("mergeResult"), - tddCompliance: tddPass(), - verification: null, - coverage: null, - qualityContractDigest: null, - } - return { ...base, ...overrides } -} - -function makeTask(overrides: Partial = {}): TaskState { - return { - id: "task-1", - name: "Task 1", - status: "reviewing", - dependsOn: [], - area: "backend", - expectedFiles: ["src/a.ts"], - testCommands: [noopCmd()], - verifyCommands: [], - acceptanceCriteria: [], - parallelSafe: true, - prNumber: 1, - prCheckpoints: fullCheckpoints(), - blockedReason: null, - startedAt: "now", - executionBinding: null, - tddPolicy: { - mode: "strict", - enforcement: "advisory", - runner: null, - testFilePatterns: [], - implementationFilePatterns: [], - generatedArtifactPatterns: [], - exception: null, - source: { manifestPath: ".tdd.json", revisionSha: "abc" }, - }, - tddEvidence: { - revision: 1, - reworkRevision: 0, - status: "pass", - taskStart: { status: "pass", headSha: "abc", treeSha: "tree", startedAt: "now" }, - cycles: [], - regression: { status: "pass", headSha: "abc", treeSha: "tree", reworkRevision: 0, runs: [] }, - verification: { status: "pass", headSha: "abc", treeSha: "tree", runs: [] }, - alternativeValidation: [], - reworks: [], - warnings: [], - updatedAt: "now", - }, - coveragePolicy: null, - ...overrides, - } -} - -describe("validateCheckpoint", () => { - it("returns pass for pass status", () => { - expect(validateCheckpoint(passCP("x"))).toBe("pass") - }) - - it("returns fail for fail status", () => { - expect(validateCheckpoint(failCP("x"))).toBe("fail") - }) - - it("returns pending for pending status", () => { - expect(validateCheckpoint(pendingCP("x"))).toBe("pending") - }) -}) - -describe("validatePRCheckpoints", () => { - it("allows merge when all gates pass", () => { - const result = validatePRCheckpoints(fullCheckpoints()) - expect(result.allowed).toBe(true) - }) - - it("blocks when local checks fail", () => { - const result = validatePRCheckpoints(fullCheckpoints({ localChecks: failCP("localChecks") })) - expect(result.allowed).toBe(false) - expect(result.reason).toContain("localChecks") - }) - - it("blocks when CI checks fail", () => { - const result = validatePRCheckpoints(fullCheckpoints({ ciChecks: failCP("ciChecks") })) - expect(result.allowed).toBe(false) - expect(result.checkpointResults.ciChecks).toBe("fail") - }) - - it("blocks when reviewer not approved", () => { - const result = validatePRCheckpoints(fullCheckpoints({ reviewerApproval: failCP("reviewerApproval") })) - expect(result.allowed).toBe(false) - }) - - it("blocks when goal verification fails", () => { - const result = validatePRCheckpoints(fullCheckpoints({ goalVerification: failCP("goalVerification") })) - expect(result.allowed).toBe(false) - }) - - it("blocks when gates are pending", () => { - const result = validatePRCheckpoints(fullCheckpoints({ ciChecks: pendingCP("ciChecks") })) - expect(result.allowed).toBe(false) - expect(result.reason).toContain("ciChecks") - }) -}) - -// ───────────────────────────────────────────────── -// canMergeTaskPR — Task-local merge gate(不依赖全局 canMerge) -// ───────────────────────────────────────────────── - -describe("canMergeTaskPR", () => { - // canMergeTaskPR 还未实现 → 测试待 RED phase - // 当实现完成后这些测试会通过 - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let canMergeTaskPR: any - - beforeAll(async () => { - ;({ canMergeTaskPR } = await import("../../src/flowrun/merge.js")) - }) - - // ✅ 正常路径 - it("allows merge when all checks pass and branch protection exists", () => { - const task = makeTask() - const result = canMergeTaskPR(task, true) - expect(result.allowed).toBe(true) - }) - - // ❌ 阻断:Task 不是 reviewing - it("blocks when task is not reviewing", () => { - const task = makeTask({ status: "running" }) - const result = canMergeTaskPR(task, true) - expect(result.allowed).toBe(false) - expect(result.reason).toContain("running") - }) - - // ❌ 阻断:Task 已 merged - it("blocks when task already merged", () => { - const task = makeTask({ status: "merged" }) - const result = canMergeTaskPR(task, true) - expect(result.allowed).toBe(false) - expect(result.reason).toContain("merged") - }) - - // ❌ 阻断:无 PR checkpoints - it("blocks when no PR checkpoints", () => { - const task = makeTask({ prCheckpoints: null }) - const result = canMergeTaskPR(task, true) - expect(result.allowed).toBe(false) - expect(result.reason).toContain("No PR checkpoints") - }) - - // ❌ 阻断:CI fail - it("blocks when CI checks fail", () => { - const task = makeTask({ prCheckpoints: fullCheckpoints({ ciChecks: failCP("ciChecks") }) }) - const result = canMergeTaskPR(task, true) - expect(result.allowed).toBe(false) - expect(result.checkpointResults.ciChecks).toBe("fail") - }) - - // ❌ 阻断:review 未 approve - it("blocks when reviewer not approved", () => { - const task = makeTask({ prCheckpoints: fullCheckpoints({ reviewerApproval: failCP("reviewerApproval") }) }) - const result = canMergeTaskPR(task, true) - expect(result.allowed).toBe(false) - expect(result.checkpointResults.reviewerApproval).toBe("fail") - }) - - // ❌ 阻断:TDD compliance fail - it("blocks when TDD compliance is fail", () => { - const task = makeTask({ prCheckpoints: fullCheckpoints({ tddCompliance: tddFail() }) }) - const result = canMergeTaskPR(task, true) - expect(result.allowed).toBe(false) - expect(result.reason).toContain("TDD compliance") - }) - - // ✅ TDD compliance waived → pass - it("allows merge when TDD compliance is waived", () => { - const task = makeTask({ - prCheckpoints: fullCheckpoints({ - tddCompliance: { ...tddPass(), status: "waived" }, - }), - }) - const result = canMergeTaskPR(task, true) - expect(result.allowed).toBe(true) - }) - - // ✅ TDD compliance null → pass(无 TDD 要求) - it("allows merge when TDD compliance is null", () => { - const task = makeTask({ prCheckpoints: fullCheckpoints({ tddCompliance: null }) }) - const result = canMergeTaskPR(task, true) - expect(result.allowed).toBe(true) - }) - - // ❌ 阻断:Branch protection 未启用 - it("blocks when branch protection not enabled", () => { - const task = makeTask() - const result = canMergeTaskPR(task, false) - expect(result.allowed).toBe(false) - expect(result.reason).toContain("Branch protection") - }) - - // ✅ 关键:不依赖全局 review Stage - it("allows merge even when global review stage is not pass", () => { - const task = makeTask() - const result = canMergeTaskPR(task, true) - expect(result.allowed).toBe(true) - }) - - // ❌ 阻断:pending checkpoints - it("blocks when checkpoints are pending", () => { - const task = makeTask({ prCheckpoints: fullCheckpoints({ ciChecks: pendingCP("ciChecks") }) }) - const result = canMergeTaskPR(task, true) - expect(result.allowed).toBe(false) - expect(result.reason).toContain("ciChecks") - }) -}) - -// ───────────────────────────────────────────────── -// mergeTaskPR — 合并前 re-verify remote head + --match-head-commit -// ───────────────────────────────────────────────── - -describe("mergeTaskPR", () => { - let mergeTaskPR: typeof import("../../src/flowrun/merge.js").mergeTaskPR - let setMergeGhExecutor: typeof import("../../src/flowrun/merge.js").setMergeGhExecutor - - beforeAll(async () => { - const mod = await import("../../src/flowrun/merge.js") - mergeTaskPR = mod.mergeTaskPR - setMergeGhExecutor = mod.setMergeGhExecutor - }) - - it("requires verifiedSha and returns error if missing", async () => { - const result = await mergeTaskPR(1, "") - expect(result.success).toBe(false) - expect(result.error).toContain("verifiedSha is required") - }) - - it("attempts merge with --match-head-commit flag", async () => { - const calls: string[] = [] - setMergeGhExecutor(async (args) => { - calls.push(args) - return { stdout: "Merged", stderr: "" } - }) - - const result = await mergeTaskPR(42, "abc123def456") - expect(result.success).toBe(true) - // 验证使用了 --match-head-commit - expect(calls.some(c => c.includes("--match-head-commit") && c.includes("abc123def456"))).toBe(true) - }) - - it("blocks merge when gh fails", async () => { - setMergeGhExecutor(async () => { - throw new Error("Merge conflict") - }) - - const result = await mergeTaskPR(42, "abc123") - expect(result.success).toBe(false) - expect(result.error).toContain("Merge conflict") - }) -}) - -// ───────────────────────────────────────────────── -// 全链路:两个依赖 Task -// ───────────────────────────────────────────────── - -describe("two-task dependency chain", () => { - it("Task A merges first, then Task B becomes ready and merges, without global review gate", async () => { - const { canMergeTaskPR } = await import("../../src/flowrun/merge.js") - const { canStartTask } = await import("../../src/flowrun/gate.js") - - const flowRun = { - ...createInitialFlowRun("chain-flow", "o/r", 1), - status: "running", - } as FlowRun - - // Task A: 无依赖,已进入 reviewing - const taskA = makeTask({ - id: "task-a", - name: "Task A", - status: "reviewing", - dependsOn: [], - }) - - // Task B: 依赖 Task A,尚未 ready - const taskB = makeTask({ - id: "task-b", - name: "Task B", - status: "pending", - dependsOn: ["task-a"], - prNumber: null, - prCheckpoints: null, - }) - - flowRun.tasks["task-a"] = taskA - flowRun.tasks["task-b"] = taskB - - // 1. Task A 合并 — 不要求全局 review stage pass - const resultA = canMergeTaskPR(taskA, true) - expect(resultA.allowed).toBe(true) - - // 2. 模拟 Task A 合并成功 - const mergedTaskA: TaskState = { ...taskA, status: "merged" } - flowRun.tasks["task-a"] = mergedTaskA - - // 3. Task B 现在依赖满足 → 变为 ready - const updatedTaskB: TaskState = { ...taskB, status: "ready" as const } - flowRun.tasks["task-b"] = updatedTaskB - - // 4. Task B 启动 → running → reviewing(模拟 TDD 完成 + PR 创建) - const reviewingTaskB = makeTask({ - id: "task-b", - name: "Task B", - status: "reviewing", - dependsOn: ["task-a"], - }) - - // 5. Task B 合并 — 也不要求全局 review stage pass - const resultB = canMergeTaskPR(reviewingTaskB, true) - expect(resultB.allowed).toBe(true) - - // 6. 验证:全程 review stage 从未被设为 pass - expect(flowRun.stages.review.status).not.toBe("pass") - }) - - it("Task B blocks when Task A not yet merged", async () => { - const { canStartTask } = await import("../../src/flowrun/gate.js") - - const flowRun = { - ...createInitialFlowRun("chain-flow-2", "o/r", 2), - status: "running", - } as FlowRun - - // Task A 还在 running,未合并 - const taskA = makeTask({ - id: "task-a", - name: "Task A", - status: "running", - dependsOn: [], - }) - - // Task B 依赖 Task A - const taskB = makeTask({ - id: "task-b", - name: "Task B", - status: "pending", - dependsOn: ["task-a"], - prNumber: null, - prCheckpoints: null, - }) - - flowRun.tasks["task-a"] = taskA - flowRun.tasks["task-b"] = taskB - - // Task B 不能开始,因为 Task A 未 merged - const startResult = canStartTask(flowRun, "task-b") - expect(startResult.allowed).toBe(false) - }) -}) diff --git a/test/flowrun/migration.test.ts b/test/flowrun/migration.test.ts deleted file mode 100644 index 4628499..0000000 --- a/test/flowrun/migration.test.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { describe, it, expect } from "vitest" -import { migrateV1ToV2 } from "../../src/flowrun/migration.js" -import { CURRENT_SCHEMA_VERSION } from "../../src/flowrun/types.js" -import { validateFlowRun } from "../../src/flowrun/validator.js" -import { readFileSync } from "node:fs" -import { resolve } from "node:path" - -function loadFixture(name: string): unknown { - const raw = readFileSync(resolve(__dirname, "fixtures", name), "utf-8") - return JSON.parse(raw) -} - -describe("migrateV1ToV2", () => { - // ─── 基础验证 ─── - - it("rejects null / non-object", () => { - const result = migrateV1ToV2(null) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.code).toBe("INVALID_JSON") - } - }) - - it("rejects missing schemaVersion", () => { - const result = migrateV1ToV2({ flowRunId: "test" }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.code).toBe("VALIDATION_FAILED") - expect(result.errors.some(e => e.path === "schemaVersion")).toBe(true) - } - }) - - it("rejects non-integer schemaVersion", () => { - const result = migrateV1ToV2({ schemaVersion: 1.5 }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.code).toBe("VALIDATION_FAILED") - } - }) - - it("rejects schemaVersion < 1", () => { - const result = migrateV1ToV2({ schemaVersion: 0 }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.code).toBe("VALIDATION_FAILED") - } - }) - - it("rejects future schema (version > CURRENT)", () => { - const result = migrateV1ToV2({ schemaVersion: 99 }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.code).toBe("UNSUPPORTED_SCHEMA") - } - }) - - it("passes through v2 unchanged", () => { - const v2 = { - flowRunId: "test", - repo: "o/r", - parentIssueNumber: 1, - status: "planned", - schemaVersion: CURRENT_SCHEMA_VERSION, - revision: 0, - stages: { - requirements: { status: "pending", requiredArtifacts: [], checks: [], completedAt: null, evidence: [] }, - design: { status: "pending", requiredArtifacts: [], checks: [], completedAt: null, evidence: [] }, - tasks: { status: "pending", requiredArtifacts: [], checks: [], completedAt: null, evidence: [] }, - code: { status: "pending", requiredArtifacts: [], checks: [], completedAt: null, evidence: [] }, - test: { status: "pending", requiredArtifacts: [], checks: [], completedAt: null, evidence: [] }, - review: { status: "pending", requiredArtifacts: [], checks: [], completedAt: null, evidence: [] }, - merge: { status: "pending", requiredArtifacts: [], checks: [], completedAt: null, evidence: [] }, - }, - tasks: {}, - startedAt: null, - lastTickAt: null, - nextTickAfter: null, - maxRuntime: 86400000, - completedAt: null, - repositoryQualityPolicy: { mode: "off", requiredChecks: [] }, - } - const result = migrateV1ToV2(v2) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.migrated).toBe(false) - expect(result.data.schemaVersion).toBe(CURRENT_SCHEMA_VERSION) - } - }) - - // ─── v1 → v2 迁移:pending Task(无 testCommands) ─── - - it("migrates v1 pending task (no testCommands) to bypass + advisory", () => { - const fixture = loadFixture("v1-task-pending.json") - const result = migrateV1ToV2(fixture) - expect(result.ok).toBe(true) - if (!result.ok) return - - expect(result.migrated).toBe(true) - const fr = result.data - expect(fr.schemaVersion).toBe(CURRENT_SCHEMA_VERSION) - expect(fr.repositoryQualityPolicy.mode).toBe("off") - - const task = fr.tasks["task-pending"] - expect(task).toBeDefined() - expect(task.acceptanceCriteria).toHaveLength(1) - expect(task.acceptanceCriteria[0].id).toBe("legacy-1") - expect(task.acceptanceCriteria[0].verification).toBe("manual") - expect(task.testCommands).toHaveLength(0) - expect(task.verifyCommands).toHaveLength(0) - expect(task.executionBinding).toBeNull() - expect(task.coveragePolicy).toBeNull() - - // policy - expect(task.tddPolicy.mode).toBe("bypass") - expect(task.tddPolicy.enforcement).toBe("advisory") - expect(task.tddPolicy.runner).toBeNull() - expect(task.tddPolicy.exception).not.toBeNull() - expect(task.tddPolicy.exception!.approval.kind).toBe("legacy-migration") - if (task.tddPolicy.exception!.approval.kind === "legacy-migration") { - expect(task.tddPolicy.exception!.approval.fromSchemaVersion).toBe(1) - } - expect(task.tddPolicy.source.manifestPath).toBe("legacy:v1") - expect(task.tddPolicy.source.revisionSha).toBe("migration:v1") - - // evidence - expect(task.tddEvidence.status).toBe("not-recorded") - expect(task.tddEvidence.warnings).toContain("legacy schema v1 migration: evidence not recorded") - expect(task.tddEvidence.cycles).toHaveLength(0) - expect(task.tddEvidence.taskStart.status).toBe("pending") - - // validation - const { errors } = validateFlowRun(fr) - expect(errors).toHaveLength(0) - }) - - // ─── v1 → v2 迁移:running Task(有 testCommands) ─── - - it("migrates v1 running task (with testCommands) to relaxed + advisory", () => { - const fixture = loadFixture("v1-task-running.json") - const result = migrateV1ToV2(fixture) - expect(result.ok).toBe(true) - if (!result.ok) return - - expect(result.migrated).toBe(true) - const task = result.data.tasks["task-running"] - expect(task).toBeDefined() - expect(task.acceptanceCriteria[0].verification).toBe("regression") - expect(task.testCommands).toHaveLength(1) - expect(task.testCommands[0].command).toBe("npm test -- test/module.test.ts") - expect(task.testCommands[0].cwd).toBe(".") - expect(task.testCommands[0].timeoutMs).toBe(120_000) - expect(task.testCommands[0].env).toEqual({}) - - expect(task.tddPolicy.mode).toBe("relaxed") - expect(task.tddPolicy.enforcement).toBe("advisory") - - // exception: should have command-based alternative validation - expect(task.tddPolicy.exception).not.toBeNull() - expect(task.tddPolicy.exception!.alternativeValidation).toHaveLength(1) - expect(task.tddPolicy.exception!.alternativeValidation[0].kind).toBe("command") - - const { errors } = validateFlowRun(result.data) - expect(errors).toHaveLength(0) - }) - - // ─── v1 → v2 迁移:reviewing Task(有 PR checkpoints) ─── - - it("migrates v1 reviewing task preserving PR checkpoints", () => { - const fixture = loadFixture("v1-task-reviewing.json") - const result = migrateV1ToV2(fixture) - expect(result.ok).toBe(true) - if (!result.ok) return - - const task = result.data.tasks["task-reviewing"] - expect(task.status).toBe("reviewing") - expect(task.prNumber).toBe(55) - expect(task.prCheckpoints).not.toBeNull() - - const cp = task.prCheckpoints! - expect(cp.prNumber).toBe(55) - expect(cp.localChecks.status).toBe("pass") - expect(cp.ciChecks.status).toBe("pending") - expect(cp.tddCompliance).toBeNull() - expect(cp.verification).toBeNull() - expect(cp.coverage).toBeNull() - expect(cp.qualityContractDigest).toBeNull() - - // 有多个 testCommands,验证全部被迁移 - expect(task.testCommands).toHaveLength(2) - expect(task.testCommands[0].command).toContain("ui.test.tsx") - expect(task.testCommands[1].command).toContain("typecheck") - - const { errors } = validateFlowRun(result.data) - expect(errors).toHaveLength(0) - }) - - // ─── v1 → v2 迁移:merged Task ─── - - it("migrates v1 merged task without retroactive enforcement", () => { - const fixture = loadFixture("v1-task-merged.json") - const result = migrateV1ToV2(fixture) - expect(result.ok).toBe(true) - if (!result.ok) return - - const task = result.data.tasks["task-merged"] - expect(task.status).toBe("merged") - expect(task.tddPolicy.mode).toBe("relaxed") - expect(task.tddEvidence.status).toBe("not-recorded") - - const cp = task.prCheckpoints! - expect(cp.mergeResult.status).toBe("pass") - - const { errors } = validateFlowRun(result.data) - expect(errors).toHaveLength(0) - }) - - // ─── v1 → v2 迁移:complete flowrun(含 blocked + cancelled) ─── - - it("migrates v1 flowrun with blocked and cancelled tasks", () => { - const fixture = loadFixture("v1-flowrun-complete.json") - const result = migrateV1ToV2(fixture) - expect(result.ok).toBe(true) - if (!result.ok) return - - const fr = result.data - expect(Object.keys(fr.tasks)).toHaveLength(3) - - const task1 = fr.tasks["task-1"] - expect(task1.status).toBe("running") - expect(task1.tddPolicy.mode).toBe("relaxed") - - const task2 = fr.tasks["task-2"] - expect(task2.status).toBe("blocked") - expect(task2.blockedReason).toBe("Waiting for design review") - expect(task2.tddPolicy.mode).toBe("bypass") - - const task3 = fr.tasks["task-3"] - expect(task3.status).toBe("cancelled") - expect(task3.tddPolicy.mode).toBe("relaxed") - - const { errors } = validateFlowRun(fr) - expect(errors).toHaveLength(0) - }) - - // ─── 幂等性 ─── - - it("is idempotent: double migration yields same result", () => { - const fixture = loadFixture("v1-task-running.json") - const result1 = migrateV1ToV2(fixture) - expect(result1.ok).toBe(true) - if (!result1.ok) return - - const result2 = migrateV1ToV2(result1.data) - expect(result2.ok).toBe(true) - if (!result2.ok) return - - expect(result2.migrated).toBe(false) - expect(result2.data).toEqual(result1.data) - }) - - it("is idempotent: triple migration yields same result", () => { - const fixture = loadFixture("v1-task-merged.json") - const result1 = migrateV1ToV2(fixture) - expect(result1.ok).toBe(true) - if (!result1.ok) return - - const result2 = migrateV1ToV2(result1.data) - expect(result2.ok).toBe(true) - if (!result2.ok) return - - const result3 = migrateV1ToV2(result2.data) - expect(result3.ok).toBe(true) - if (!result3.ok) return - - expect(result3.migrated).toBe(false) - expect(result3.data).toEqual(result1.data) - }) - - // ─── Malformed schema ─── - - it("rejects malformed schema with string schemaVersion", () => { - const result = migrateV1ToV2({ schemaVersion: "1" }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.code).toBe("VALIDATION_FAILED") - } - }) - - it("rejects negative schemaVersion", () => { - const result = migrateV1ToV2({ schemaVersion: -1 }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.code).toBe("VALIDATION_FAILED") - } - }) -}) diff --git a/test/flowrun/resilience.test.ts b/test/flowrun/resilience.test.ts deleted file mode 100644 index 363d870..0000000 --- a/test/flowrun/resilience.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, it, expect } from "vitest" -import { - getRuntimeMs, hasRuntimeExpired, checkRuntime, - buildContinuationContext, -} from "../../src/flowrun/resilience.js" -import { determineNextStage } from "../../src/flowrun/gate.js" -import { createInitialFlowRun } from "../../src/flowrun/github.js" -import type { FlowRun } from "../../src/flowrun/types.js" - -function runWithStartedAt(startedAt: string | null): FlowRun { - return { ...createInitialFlowRun("flow-o/r-1", "o/r", 1), startedAt } as FlowRun -} - -describe("getRuntimeMs", () => { - it("returns 0 when startedAt is null", () => { - expect(getRuntimeMs(runWithStartedAt(null))).toBe(0) - }) - - it("returns a positive number when startedAt is set", () => { - const run = runWithStartedAt(new Date(Date.now() - 60_000).toISOString()) - expect(getRuntimeMs(run)).toBeGreaterThan(50_000) - expect(getRuntimeMs(run)).toBeLessThan(70_000) - }) -}) - -describe("hasRuntimeExpired", () => { - it("returns false for newly created run", () => { - expect(hasRuntimeExpired(createInitialFlowRun("id", "r", 1) as FlowRun)).toBe(false) - }) - - it("returns true when maxRuntime is exceeded", () => { - const run = { ...createInitialFlowRun("id", "r", 1), startedAt: new Date(Date.now() - 100_000).toISOString(), maxRuntime: 50_000 } as FlowRun - expect(hasRuntimeExpired(run)).toBe(true) - }) -}) - -describe("checkRuntime", () => { - it("returns remaining time", () => { - const run = { ...createInitialFlowRun("id", "r", 1), startedAt: new Date(Date.now() - 60_000).toISOString() } as FlowRun - const result = checkRuntime(run) - expect(result.expired).toBe(false) - expect(result.maxRuntimeMs).toBe(86_400_000) - expect(result.remainingMs).toBeGreaterThan(0) - }) - - it("detects expired runtime", () => { - const run = { ...createInitialFlowRun("id", "r", 1), startedAt: new Date(Date.now() - 100_000).toISOString(), maxRuntime: 10_000 } as FlowRun - const result = checkRuntime(run) - expect(result.expired).toBe(true) - expect(result.runtimeMs).toBeGreaterThan(90_000) - expect(result.remainingMs).toBe(0) - }) -}) - -describe("buildContinuationContext", () => { - it("includes flow run ID and status", () => { - const run = createInitialFlowRun("flow-o/r-7", "o/r", 7) as FlowRun - const ctx = buildContinuationContext(run) - expect(ctx).toContain("flow-o/r-7") - expect(ctx).toContain("planned") - }) - - it("includes all stages", () => { - const run = createInitialFlowRun("flow-o/r-1", "o/r", 1) as FlowRun - const ctx = buildContinuationContext(run) - expect(ctx).toContain("requirements") - expect(ctx).toContain("design") - expect(ctx).toContain("merge") - }) - - it("includes tasks when present", () => { - const run = createInitialFlowRun("flow-o/r-1", "o/r", 1) as FlowRun - run.tasks = { - "task-1": { id: "task-1", status: "running", name: "T1", dependsOn: [], area: "b", expectedFiles: [], testCommands: [], acceptance: "", parallelSafe: true, prNumber: 5, prCheckpoints: null, blockedReason: null, startedAt: "now" }, - } - const ctx = buildContinuationContext(run) - expect(ctx).toContain("task-1") - expect(ctx).toContain("PR #5") - }) -}) - -describe("determineNextStage", () => { - it("returns first pending stage when flow is running", () => { - const run = { ...createInitialFlowRun("flow-o/r-1", "o/r", 1), status: "running" } as FlowRun - const result = determineNextStage(run) - expect(result.stage).toBe("requirements") - }) - - it("returns null when flow is not running", () => { - const run = createInitialFlowRun("flow-o/r-1", "o/r", 1) as FlowRun - const result = determineNextStage(run) - expect(result.stage).toBeNull() - }) - - it("returns next stage when previous is complete", () => { - const run = createInitialFlowRun("id", "r", 1) as FlowRun - run.stages.requirements.status = "pass" - run.stages.design.status = "running" - const result = determineNextStage(run) - expect(result.stage).toBe("design") - }) - - it("returns null when all stages pass", () => { - const run = createInitialFlowRun("id", "r", 1) as FlowRun - for (const stage of ["requirements", "design", "tasks", "code", "test", "review", "merge"]) { - run.stages[stage as keyof typeof run.stages].status = "pass" - } - const result = determineNextStage(run) - expect(result.stage).toBeNull() - expect(result.reason).toContain("complete") - }) -}) diff --git a/test/flowrun/state.test.ts b/test/flowrun/state.test.ts deleted file mode 100644 index baf99e1..0000000 --- a/test/flowrun/state.test.ts +++ /dev/null @@ -1,843 +0,0 @@ -import { describe, it, expect } from "vitest" -import type { - TddEvidence, - TddCycleEvidence, - TddCommandEvidence, - AcceptanceCriterion, - VersionedDigest, -} from "../../src/flowrun/types.js" -import { - createTaskEvidence, - startCycle, - recordRed, - recordGreen, - recordFinalRegression, - recordFinalVerification, - abandonCycle, -} from "../../src/flowrun/state.js" - -// ─── 辅助工厂 ─── - -function emptyDigest(algorithm?: VersionedDigest["algorithm"]): VersionedDigest { - return { algorithm: algorithm ?? "sha256-content-v1", value: "a".repeat(64) } -} - -function makeCycleEvidence(overrides: Partial = {}): TddCycleEvidence { - return { - cycleId: "cycle-1", - criterionId: "AC-1", - reworkRevision: 0, - status: "started", - startWorkspaceDigest: emptyDigest(), - testFiles: ["test/foo.test.ts"], - redTestDigest: null, - redAttempts: [], - greenAttempts: [], - ...overrides, - } -} - -function makeCommandEvidence(overrides: Partial = {}): TddCommandEvidence { - return { - command: "npx vitest run test/foo.test.ts", - testSelector: "test/foo.test.ts", - exitCode: 1, - failureKind: "assertion", - testsCollected: 3, - testsFailed: 1, - startedAt: "2026-01-01T00:00:00Z", - finishedAt: "2026-01-01T00:00:01Z", - durationMs: 1000, - changedFiles: ["test/foo.test.ts"], - outputDigest: emptyDigest("sha256-output-v1"), - workspaceDigest: emptyDigest(), - executionInputDigest: emptyDigest(), - summary: "1/3 tests failed", - ...overrides, - } -} - -function passingEvidence(): TddCommandEvidence { - return makeCommandEvidence({ - exitCode: 0, - failureKind: null, - testsFailed: 0, - summary: "3/3 tests passed", - }) -} - -const tddCriterion: AcceptanceCriterion = { id: "AC-1", description: "TDD test", verification: "tdd" } -const regressionCriterion: AcceptanceCriterion = { id: "AC-2", description: "regression", verification: "regression" } - -const testFilePatterns = ["test/**/*.test.ts"] - -// ─── createTaskEvidence ─── - -describe("createTaskEvidence", () => { - it("returns initialized TddEvidence with revision=0 and status=not-recorded", () => { - const evidence = createTaskEvidence() - expect(evidence.revision).toBe(0) - expect(evidence.status).toBe("not-recorded") - expect(evidence.cycles).toEqual([]) - expect(evidence.taskStart.status).toBe("pending") - expect(evidence.regression.status).toBe("pending") - expect(evidence.verification.status).toBe("pending") - expect(evidence.warnings).toEqual([]) - expect(evidence.updatedAt).toBeNull() - }) -}) - -// ─── startCycle ─── - -describe("startCycle", () => { - it("creates a new cycle and adds it to evidence", () => { - const evidence = createTaskEvidence() - const result = startCycle( - evidence, - "cycle-1", - "AC-1", - ["test/foo.test.ts"], - "test/foo.test.ts", - emptyDigest(), - [tddCriterion], - testFilePatterns, - ) - - expect(result.ok).toBe(true) - if (!result.ok) throw new Error("expected ok") - - const { evidence: newEvidence, cycle } = result.value - expect(cycle.cycleId).toBe("cycle-1") - expect(cycle.criterionId).toBe("AC-1") - expect(cycle.status).toBe("started") - expect(cycle.testFiles).toEqual(["test/foo.test.ts"]) - expect(cycle.redTestDigest).toBeNull() - expect(cycle.redAttempts).toEqual([]) - expect(cycle.greenAttempts).toEqual([]) - - expect(newEvidence.cycles).toHaveLength(1) - expect(newEvidence.cycles[0].cycleId).toBe("cycle-1") - expect(newEvidence.revision).toBe(evidence.revision + 1) - }) - - it("sets evidence status to in-progress on first cycle", () => { - const evidence = createTaskEvidence() - const result = startCycle( - evidence, - "cycle-1", - "AC-1", - ["test/foo.test.ts"], - "test/foo.test.ts", - emptyDigest(), - [tddCriterion], - testFilePatterns, - ) - - expect(result.ok).toBe(true) - if (!result.ok) throw new Error("expected ok") - expect(result.value.evidence.status).toBe("in-progress") - }) - - it("fails when criterion does not exist", () => { - const evidence = createTaskEvidence() - const result = startCycle( - evidence, - "cycle-1", - "NONEXISTENT", - ["test/foo.test.ts"], - "test/foo.test.ts", - emptyDigest(), - [tddCriterion], - testFilePatterns, - ) - - expect(result.ok).toBe(false) - if (result.ok) throw new Error("expected error") - expect(result.error.code).toBe("CRITERION_NOT_FOUND") - }) - - it("fails when criterion is not verification=tdd", () => { - const evidence = createTaskEvidence() - const result = startCycle( - evidence, - "cycle-1", - "AC-2", - ["test/foo.test.ts"], - "test/foo.test.ts", - emptyDigest(), - [regressionCriterion], - testFilePatterns, - ) - - expect(result.ok).toBe(false) - if (result.ok) throw new Error("expected error") - expect(result.error.code).toBe("CRITERION_NOT_FOUND") - }) - - it("fails when testPaths do not match testFilePatterns", () => { - const evidence = createTaskEvidence() - const result = startCycle( - evidence, - "cycle-1", - "AC-1", - ["src/foo.ts"], // implementation file, not test pattern - "test/foo.test.ts", - emptyDigest(), - [tddCriterion], - testFilePatterns, - ) - - expect(result.ok).toBe(false) - if (result.ok) throw new Error("expected error") - expect(result.error.code).toBe("POLICY_INVALID") - }) - - it("is idempotent — same cycleId and workspace digest returns existing cycle", () => { - const evidence = createTaskEvidence() - const wsDigest = emptyDigest() - - const r1 = startCycle( - evidence, - "cycle-1", - "AC-1", - ["test/foo.test.ts"], - "test/foo.test.ts", - wsDigest, - [tddCriterion], - testFilePatterns, - ) - expect(r1.ok).toBe(true) - if (!r1.ok) throw new Error("expected ok") - - // Same call again with same workspace digest - const r2 = startCycle( - r1.value.evidence, - "cycle-1", - "AC-1", - ["test/foo.test.ts"], - "test/foo.test.ts", - wsDigest, - [tddCriterion], - testFilePatterns, - ) - - expect(r2.ok).toBe(true) - if (!r2.ok) throw new Error("expected ok") - // Should return the SAME evidence object (no revision change) - expect(r2.value.evidence).toBe(r1.value.evidence) - expect(r2.value.evidence.revision).toBe(r1.value.evidence.revision) - }) - - it("fails with CYCLE_CONFLICT when same cycleId but different params", () => { - const evidence = createTaskEvidence() - const wsDigest1 = emptyDigest() - - const r1 = startCycle( - evidence, - "cycle-1", - "AC-1", - ["test/foo.test.ts"], - "test/foo.test.ts", - wsDigest1, - [tddCriterion], - testFilePatterns, - ) - expect(r1.ok).toBe(true) - if (!r1.ok) throw new Error("expected ok") - - const wsDigest2 = { algorithm: "sha256-content-v1" as const, value: "b".repeat(64) } - const r2 = startCycle( - r1.value.evidence, - "cycle-1", - "AC-1", - ["test/foo.test.ts"], - "test/foo.test.ts", - wsDigest2, // different workspace digest - [tddCriterion], - testFilePatterns, - ) - - expect(r2.ok).toBe(false) - if (r2.ok) throw new Error("expected error") - expect(r2.error.code).toBe("CYCLE_CONFLICT") - }) -}) - -// ─── recordRed ─── - -describe("recordRed", () => { - it("records a RED attempt and advances cycle status to red", () => { - const evidence = createTaskEvidence() - const startResult = startCycle( - evidence, - "cycle-1", - "AC-1", - ["test/foo.test.ts"], - "test/foo.test.ts", - emptyDigest(), - [tddCriterion], - testFilePatterns, - ) - if (!startResult.ok) throw new Error("expected ok") - - const redEvidence = makeCommandEvidence({ - failureKind: "assertion", - exitCode: 1, - changedFiles: ["test/foo.test.ts"], - }) - const result = recordRed(startResult.value.evidence, "cycle-1", redEvidence) - - expect(result.ok).toBe(true) - if (!result.ok) throw new Error("expected ok") - - const { evidence: newEvidence, cycle } = result.value - expect(cycle.status).toBe("red") - expect(cycle.redAttempts).toHaveLength(1) - expect(cycle.redAttempts[0].failureKind).toBe("assertion") - expect(cycle.redTestDigest).toBe(redEvidence.executionInputDigest) - expect(newEvidence.revision).toBeGreaterThan(startResult.value.evidence.revision) - }) - - it("rejects when RED passes (exitCode 0)", () => { - const evidence = createTaskEvidence() - const startResult = startCycle( - evidence, "cycle-1", "AC-1", - ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), - [tddCriterion], testFilePatterns, - ) - if (!startResult.ok) throw new Error("expected ok") - - const redEvidence = makeCommandEvidence({ exitCode: 0, failureKind: null, testsFailed: 0 }) - const result = recordRed(startResult.value.evidence, "cycle-1", redEvidence) - - expect(result.ok).toBe(false) - if (result.ok) throw new Error("expected error") - expect(result.error.code).toBe("RED_EXPECTED_FAILURE") - }) - - it("rejects infrastructure failures as RED", () => { - const evidence = createTaskEvidence() - const startResult = startCycle( - evidence, "cycle-1", "AC-1", - ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), - [tddCriterion], testFilePatterns, - ) - if (!startResult.ok) throw new Error("expected ok") - - const redEvidence = makeCommandEvidence({ failureKind: "infrastructure", exitCode: 1 }) - const result = recordRed(startResult.value.evidence, "cycle-1", redEvidence) - - expect(result.ok).toBe(false) - if (result.ok) throw new Error("expected error") - expect(result.error.code).toBe("RED_INFRASTRUCTURE_FAILURE") - }) - - it("rejects timeout as RED", () => { - const evidence = createTaskEvidence() - const startResult = startCycle( - evidence, "cycle-1", "AC-1", - ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), - [tddCriterion], testFilePatterns, - ) - if (!startResult.ok) throw new Error("expected ok") - - const redEvidence = makeCommandEvidence({ failureKind: "timeout", exitCode: null }) - const result = recordRed(startResult.value.evidence, "cycle-1", redEvidence) - - expect(result.ok).toBe(false) - if (result.ok) throw new Error("expected error") - expect(result.error.code).toBe("COMMAND_TIMEOUT") - }) - - it("rejects unknown failure as RED", () => { - const evidence = createTaskEvidence() - const startResult = startCycle( - evidence, "cycle-1", "AC-1", - ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), - [tddCriterion], testFilePatterns, - ) - if (!startResult.ok) throw new Error("expected ok") - - const redEvidence = makeCommandEvidence({ failureKind: "unknown", exitCode: 1 }) - const result = recordRed(startResult.value.evidence, "cycle-1", redEvidence) - - expect(result.ok).toBe(false) - if (result.ok) throw new Error("expected error") - expect(result.error.code).toBe("RED_INFRASTRUCTURE_FAILURE") - }) - - it("accepts missing-behavior as valid RED", () => { - const evidence = createTaskEvidence() - const startResult = startCycle( - evidence, "cycle-1", "AC-1", - ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), - [tddCriterion], testFilePatterns, - ) - if (!startResult.ok) throw new Error("expected ok") - - const redEvidence = makeCommandEvidence({ failureKind: "missing-behavior", exitCode: 1, testsCollected: 0 }) - const result = recordRed(startResult.value.evidence, "cycle-1", redEvidence) - - expect(result.ok).toBe(true) - }) - - it("allows multiple RED attempts (retry on flaky test config)", () => { - const evidence = createTaskEvidence() - const startResult = startCycle( - evidence, "cycle-1", "AC-1", - ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), - [tddCriterion], testFilePatterns, - ) - if (!startResult.ok) throw new Error("expected ok") - - const red1 = makeCommandEvidence({ failureKind: "infrastructure", exitCode: 1 }) - // infrastructure is rejected, not stored in redAttempts - const r1 = recordRed(startResult.value.evidence, "cycle-1", red1) - expect(r1.ok).toBe(false) - - const red2 = makeCommandEvidence({ failureKind: "assertion", exitCode: 1 }) - const r2 = recordRed(startResult.value.evidence, "cycle-1", red2) - expect(r2.ok).toBe(true) - if (!r2.ok) throw new Error("expected ok") - - // Another assertion attempt (e.g., test adjusted) - const red3 = makeCommandEvidence({ - failureKind: "assertion", - exitCode: 1, - outputDigest: emptyDigest("sha256-output-v1"), - }) - // Override to have different output - red3.outputDigest = { algorithm: "sha256-output-v1", value: "b".repeat(64) } - const r3 = recordRed(r2.value.evidence, "cycle-1", red3) - expect(r3.ok).toBe(true) - if (!r3.ok) throw new Error("expected ok") - expect(r3.value.cycle.redAttempts).toHaveLength(2) - // redTestDigest should update to the latest valid RED - }) - - it("is idempotent for same RED evidence", () => { - const evidence = createTaskEvidence() - const startResult = startCycle( - evidence, "cycle-1", "AC-1", - ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), - [tddCriterion], testFilePatterns, - ) - if (!startResult.ok) throw new Error("expected ok") - - const redEvidence = makeCommandEvidence() - const r1 = recordRed(startResult.value.evidence, "cycle-1", redEvidence) - expect(r1.ok).toBe(true) - if (!r1.ok) throw new Error("expected ok") - - const r2 = recordRed(r1.value.evidence, "cycle-1", redEvidence) - expect(r2.ok).toBe(true) - if (!r2.ok) throw new Error("expected ok") - // Same evidence, should be same state (same revision) - expect(r2.value.evidence).toBe(r1.value.evidence) - expect(r2.value.cycle).toBe(r1.value.cycle) - }) - - it("fails when cycle does not exist", () => { - const evidence = createTaskEvidence() - const result = recordRed(evidence, "nonexistent", makeCommandEvidence()) - - expect(result.ok).toBe(false) - if (result.ok) throw new Error("expected error") - expect(result.error.code).toBe("CYCLE_CONFLICT") - }) -}) - -// ─── recordGreen ─── - -describe("recordGreen", () => { - function setupWithRed(): { evidence: TddEvidence } { - const evidence = createTaskEvidence() - const startResult = startCycle( - evidence, "cycle-1", "AC-1", - ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), - [tddCriterion], testFilePatterns, - ) - if (!startResult.ok) throw new Error("expected ok") - - const redEvidence = makeCommandEvidence() - const redResult = recordRed(startResult.value.evidence, "cycle-1", redEvidence) - if (!redResult.ok) throw new Error("expected ok") - - return { evidence: redResult.value.evidence } - } - - it("records GREEN and marks cycle as pass", () => { - const { evidence } = setupWithRed() - const greenEvidence = passingEvidence() - greenEvidence.changedFiles = ["src/foo.ts"] // implementation change - - const result = recordGreen(evidence, "cycle-1", greenEvidence, true) - - expect(result.ok).toBe(true) - if (!result.ok) throw new Error("expected ok") - - const { evidence: newEvidence, cycle } = result.value - expect(cycle.status).toBe("pass") - expect(cycle.greenAttempts).toHaveLength(1) - expect(cycle.greenAttempts[0].exitCode).toBe(0) - expect(newEvidence.revision).toBeGreaterThan(evidence.revision) - }) - - it("rejects GREEN when no RED has been recorded", () => { - const evidence = createTaskEvidence() - const startResult = startCycle( - evidence, "cycle-1", "AC-1", - ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), - [tddCriterion], testFilePatterns, - ) - if (!startResult.ok) throw new Error("expected ok") - - const greenEvidence = passingEvidence() - const result = recordGreen(startResult.value.evidence, "cycle-1", greenEvidence, true) - - expect(result.ok).toBe(false) - if (result.ok) throw new Error("expected error") - expect(result.error.code).toBe("INVALID_TRANSITION") - }) - - it("rejects GREEN when GREEN test still fails", () => { - const { evidence } = setupWithRed() - const greenEvidence = makeCommandEvidence({ exitCode: 1, failureKind: "assertion" }) - greenEvidence.changedFiles = ["src/foo.ts"] - - const result = recordGreen(evidence, "cycle-1", greenEvidence, true) - - expect(result.ok).toBe(false) - if (result.ok) throw new Error("expected error") - expect(result.error.code).toBe("GREEN_EXPECTED_PASS") - }) - - it("rejects GREEN when no implementation files changed", () => { - const { evidence } = setupWithRed() - const greenEvidence = passingEvidence() - - const result = recordGreen(evidence, "cycle-1", greenEvidence, false) - - expect(result.ok).toBe(false) - if (result.ok) throw new Error("expected error") - expect(result.error.code).toBe("IMPLEMENTATION_CHANGE_REQUIRED") - }) - - it("is idempotent for same GREEN evidence", () => { - const { evidence } = setupWithRed() - const greenEvidence = passingEvidence() - greenEvidence.changedFiles = ["src/foo.ts"] - - const r1 = recordGreen(evidence, "cycle-1", greenEvidence, true) - expect(r1.ok).toBe(true) - if (!r1.ok) throw new Error("expected ok") - - const r2 = recordGreen(r1.value.evidence, "cycle-1", greenEvidence, true) - expect(r2.ok).toBe(true) - if (!r2.ok) throw new Error("expected ok") - expect(r2.value.evidence).toBe(r1.value.evidence) - }) - - it("allows GREEN retries after failed GREEN attempt", () => { - const { evidence } = setupWithRed() - const failedGreen = makeCommandEvidence({ exitCode: 1, failureKind: "assertion" }) - failedGreen.changedFiles = ["src/foo.ts"] - - const r1 = recordGreen(evidence, "cycle-1", failedGreen, true) - expect(r1.ok).toBe(false) // GREEN failed - - const passGreen = passingEvidence() - passGreen.changedFiles = ["src/foo.ts"] - const r2 = recordGreen(evidence, "cycle-1", passGreen, true) - expect(r2.ok).toBe(true) - if (!r2.ok) throw new Error("expected ok") - expect(r2.value.cycle.greenAttempts).toHaveLength(1) - expect(r2.value.cycle.status).toBe("pass") - }) - - it("fails when cycle does not exist", () => { - const evidence = createTaskEvidence() - const result = recordGreen(evidence, "nonexistent", passingEvidence(), true) - - expect(result.ok).toBe(false) - if (result.ok) throw new Error("expected error") - expect(result.error.code).toBe("CYCLE_CONFLICT") - }) -}) - -// ─── recordFinalRegression ─── - -describe("recordFinalRegression", () => { - it("records regression as pass when all runs succeed", () => { - const evidence = createTaskEvidence() - const runs = [ - makeCommandEvidence({ exitCode: 0, failureKind: null, testsFailed: 0 }), - ] - - const result = recordFinalRegression(evidence, "headSha1", "treeSha1", runs) - - expect(result.ok).toBe(true) - if (!result.ok) throw new Error("expected ok") - - const newEvidence = result.value - expect(newEvidence.regression.status).toBe("pass") - expect(newEvidence.regression.headSha).toBe("headSha1") - expect(newEvidence.regression.treeSha).toBe("treeSha1") - expect(newEvidence.regression.runs).toEqual(runs) - }) - - it("records regression as fail when a run has non-zero exit code", () => { - const evidence = createTaskEvidence() - const runs = [ - makeCommandEvidence({ exitCode: 0, failureKind: null, testsFailed: 0 }), - makeCommandEvidence({ exitCode: 1, failureKind: "assertion", testsFailed: 1 }), - ] - - const result = recordFinalRegression(evidence, "headSha1", "treeSha1", runs) - - expect(result.ok).toBe(true) - if (!result.ok) throw new Error("expected ok") - expect(result.value.regression.status).toBe("fail") - }) - - it("records regression as fail when a run times out", () => { - const evidence = createTaskEvidence() - const runs = [ - makeCommandEvidence({ failureKind: "timeout", exitCode: null }), - ] - - const result = recordFinalRegression(evidence, "headSha1", "treeSha1", runs) - - expect(result.ok).toBe(true) - if (!result.ok) throw new Error("expected ok") - expect(result.value.regression.status).toBe("fail") - }) - - it("rejects empty runs", () => { - const evidence = createTaskEvidence() - const result = recordFinalRegression(evidence, "headSha1", "treeSha1", []) - - expect(result.ok).toBe(false) - if (result.ok) throw new Error("expected error") - expect(result.error.code).toBe("REGRESSION_FAILED") - }) -}) - -// ─── recordFinalVerification ─── - -describe("recordFinalVerification", () => { - it("records verification as pass when all runs succeed", () => { - const evidence = createTaskEvidence() - const runs = [ - makeCommandEvidence({ exitCode: 0, failureKind: null, testsFailed: 0 }), - ] - - const result = recordFinalVerification(evidence, "headSha1", "treeSha1", runs) - - expect(result.ok).toBe(true) - if (!result.ok) throw new Error("expected ok") - - const newEvidence = result.value - expect(newEvidence.verification.status).toBe("pass") - expect(newEvidence.verification.headSha).toBe("headSha1") - expect(newEvidence.verification.treeSha).toBe("treeSha1") - expect(newEvidence.verification.runs).toEqual(runs) - }) - - it("records verification as fail when a run fails", () => { - const evidence = createTaskEvidence() - const runs = [ - makeCommandEvidence({ exitCode: 1, failureKind: "unknown", testsFailed: null }), - ] - - const result = recordFinalVerification(evidence, "headSha1", "treeSha1", runs) - - expect(result.ok).toBe(true) - if (!result.ok) throw new Error("expected ok") - expect(result.value.verification.status).toBe("fail") - }) - - it("allows empty runs (no verifyCommands configured)", () => { - const evidence = createTaskEvidence() - const result = recordFinalVerification(evidence, "headSha1", "treeSha1", []) - - expect(result.ok).toBe(true) - if (!result.ok) throw new Error("expected ok") - expect(result.value.verification.status).toBe("pass") - }) -}) - -// ─── abandonCycle ─── - -describe("abandonCycle", () => { - it("abandons a cycle that was started", () => { - const evidence = createTaskEvidence() - const startResult = startCycle( - evidence, "cycle-1", "AC-1", - ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), - [tddCriterion], testFilePatterns, - ) - if (!startResult.ok) throw new Error("expected ok") - - const result = abandonCycle(startResult.value.evidence, "cycle-1", "test design wrong") - - expect(result.ok).toBe(true) - if (!result.ok) throw new Error("expected ok") - - const newEvidence = result.value - const cycle = newEvidence.cycles.find((c: TddCycleEvidence) => c.cycleId === "cycle-1") - expect(cycle).toBeDefined() - expect(cycle!.status).toBe("abandoned") - // warn 中应包含 reason - expect(newEvidence.warnings).toContain("test design wrong") - }) - - it("abandons a cycle that was in RED state", () => { - const evidence = createTaskEvidence() - const startResult = startCycle( - evidence, "cycle-1", "AC-1", - ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), - [tddCriterion], testFilePatterns, - ) - if (!startResult.ok) throw new Error("expected ok") - - const redEvidence = makeCommandEvidence() - const redResult = recordRed(startResult.value.evidence, "cycle-1", redEvidence) - if (!redResult.ok) throw new Error("expected ok") - - const result = abandonCycle(redResult.value.evidence, "cycle-1", "found design flaw") - - expect(result.ok).toBe(true) - if (!result.ok) throw new Error("expected ok") - expect(result.value.cycles[0].status).toBe("abandoned") - }) - - it("rejects abandoning a cycle that has already passed", () => { - const evidence = createTaskEvidence() - const startResult = startCycle( - evidence, "cycle-1", "AC-1", - ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), - [tddCriterion], testFilePatterns, - ) - if (!startResult.ok) throw new Error("expected ok") - - const redEvidence = makeCommandEvidence() - const redResult = recordRed(startResult.value.evidence, "cycle-1", redEvidence) - if (!redResult.ok) throw new Error("expected ok") - - const greenEvidence = passingEvidence() - greenEvidence.changedFiles = ["src/foo.ts"] - const greenResult = recordGreen(redResult.value.evidence, "cycle-1", greenEvidence, true) - if (!greenResult.ok) throw new Error("expected ok") - - // Now abandon a passed cycle - const result = abandonCycle(greenResult.value.evidence, "cycle-1", "oops") - expect(result.ok).toBe(false) - if (result.ok) throw new Error("expected error") - expect(result.error.code).toBe("INVALID_TRANSITION") - }) - - it("fails when cycle does not exist", () => { - const evidence = createTaskEvidence() - const result = abandonCycle(evidence, "nonexistent", "reason") - - expect(result.ok).toBe(false) - if (result.ok) throw new Error("expected error") - expect(result.error.code).toBe("CYCLE_CONFLICT") - }) - - it("after abandon, a new cycle with different id can be started", () => { - const evidence = createTaskEvidence() - const startResult = startCycle( - evidence, "cycle-1", "AC-1", - ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), - [tddCriterion], testFilePatterns, - ) - if (!startResult.ok) throw new Error("expected ok") - - const abandonResult = abandonCycle(startResult.value.evidence, "cycle-1", "restart") - if (!abandonResult.ok) throw new Error("expected ok") - - // Start a new cycle with different id - const restartResult = startCycle( - abandonResult.value, - "cycle-2", "AC-1", - ["test/foo-v2.test.ts"], "test/foo-v2.test.ts", emptyDigest(), - [tddCriterion], testFilePatterns, - ) - expect(restartResult.ok).toBe(true) - if (!restartResult.ok) throw new Error("expected ok") - - // Old cycle still abandoned - const oldCycle = restartResult.value.evidence.cycles.find((c: TddCycleEvidence) => c.cycleId === "cycle-1") - expect(oldCycle!.status).toBe("abandoned") - // New cycle started - const newCycle = restartResult.value.evidence.cycles.find((c: TddCycleEvidence) => c.cycleId === "cycle-2") - expect(newCycle!.status).toBe("started") - }) -}) - -// ─── 全生命周期 ─── - -describe("full lifecycle", () => { - it("startCycle → recordRed → recordGreen → complete", () => { - let evidence = createTaskEvidence() - expect(evidence.status).toBe("not-recorded") - - // 1. startCycle - const startResult = startCycle( - evidence, "cycle-1", "AC-1", - ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), - [tddCriterion], testFilePatterns, - ) - expect(startResult.ok).toBe(true) - if (!startResult.ok) throw new Error("expected ok") - evidence = startResult.value.evidence - expect(evidence.status).toBe("in-progress") - expect(evidence.cycles[0].status).toBe("started") - - // 2. recordRed - const redEvidence = makeCommandEvidence() - const redResult = recordRed(evidence, "cycle-1", redEvidence) - expect(redResult.ok).toBe(true) - if (!redResult.ok) throw new Error("expected ok") - evidence = redResult.value.evidence - expect(evidence.cycles[0].status).toBe("red") - expect(evidence.cycles[0].redAttempts).toHaveLength(1) - - // 3. recordGreen - const greenEvidence = passingEvidence() - greenEvidence.changedFiles = ["src/foo.ts"] - const greenResult = recordGreen(evidence, "cycle-1", greenEvidence, true) - expect(greenResult.ok).toBe(true) - if (!greenResult.ok) throw new Error("expected ok") - evidence = greenResult.value.evidence - expect(evidence.cycles[0].status).toBe("pass") - expect(evidence.cycles[0].greenAttempts).toHaveLength(1) - - // Evidence revision should have increased through the lifecycle - expect(evidence.revision).toBeGreaterThan(0) - }) - - it("revision increments on each state change", () => { - let evidence = createTaskEvidence() - - const s = startCycle( - evidence, "cycle-1", "AC-1", - ["test/foo.test.ts"], "test/foo.test.ts", emptyDigest(), - [tddCriterion], testFilePatterns, - ) - if (!s.ok) throw new Error("expected ok") - const revAfterStart = s.value.evidence.revision - - const r = recordRed(s.value.evidence, "cycle-1", makeCommandEvidence()) - if (!r.ok) throw new Error("expected ok") - const revAfterRed = r.value.evidence.revision - expect(revAfterRed).toBeGreaterThan(revAfterStart) - - const g = recordGreen(r.value.evidence, "cycle-1", { ...passingEvidence(), changedFiles: ["src/foo.ts"] }, true) - if (!g.ok) throw new Error("expected ok") - const revAfterGreen = g.value.evidence.revision - expect(revAfterGreen).toBeGreaterThan(revAfterRed) - }) -}) diff --git a/test/flowrun/types.test.ts b/test/flowrun/types.test.ts deleted file mode 100644 index 386d756..0000000 --- a/test/flowrun/types.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, it, expect } from "vitest" -import { - FLOW_RUN_STAGES, FLOW_RUN_STATUSES, TASK_STATUSES, STAGE_STATUSES, CHECKPOINT_STATUSES, - CABINET_START_MARKER, CABINET_END_MARKER, LABEL_PREFIX, FLOW_RUN_LABELS, - CURRENT_SCHEMA_VERSION, DEFAULT_MAX_RUNTIME_MS, -} from "../../src/flowrun/types.js" - -describe("constants", () => { - it("FLOW_RUN_STAGES has 7 stages without release", () => { - expect(FLOW_RUN_STAGES).toEqual([ - "requirements", "design", "tasks", "code", "test", "review", "merge", - ]) - }) - - it("FLOW_RUN_STATUSES defined", () => { - expect(FLOW_RUN_STATUSES).toContain("planned") - expect(FLOW_RUN_STATUSES).toContain("running") - expect(FLOW_RUN_STATUSES).toContain("blocked") - expect(FLOW_RUN_STATUSES).toContain("merging") - expect(FLOW_RUN_STATUSES).toContain("completed") - expect(FLOW_RUN_STATUSES).toContain("cancelled") - }) - - it("TASK_STATUSES includes merged", () => { - expect(TASK_STATUSES).toContain("merged") - expect(TASK_STATUSES).not.toContain("done") - }) - - it("STAGE_STATUSES defined", () => { - expect(STAGE_STATUSES).toContain("pending") - expect(STAGE_STATUSES).toContain("pass") - expect(STAGE_STATUSES).toContain("failed") - expect(STAGE_STATUSES).toContain("blocked") - }) - - it("CHECKPOINT_STATUSES defined", () => { - expect(CHECKPOINT_STATUSES).toEqual(["pending", "pass", "fail"]) - }) - - it("CURRENT_SCHEMA_VERSION is 2", () => { - expect(CURRENT_SCHEMA_VERSION).toBe(2) - }) - - it("DEFAULT_MAX_RUNTIME_MS is 24 hours", () => { - expect(DEFAULT_MAX_RUNTIME_MS).toBe(86_400_000) - }) - - it("markers defined", () => { - expect(CABINET_START_MARKER).toContain("cabbage-flow-run:start") - expect(CABINET_END_MARKER).toContain("cabbage-flow-run:end") - }) - - it("labels use cabbage: prefix", () => { - for (const [_, label] of Object.entries(FLOW_RUN_LABELS)) { - expect(label).toMatch(/^cabbage:/) - } - }) - - it("LABEL_PREFIX is cabbage:", () => { - expect(LABEL_PREFIX).toBe("cabbage:") - }) - - it("has resume label for explicit resume", () => { - expect(FLOW_RUN_LABELS.resume).toBe("cabbage:resume") - }) -}) diff --git a/test/flowrun/validator.test.ts b/test/flowrun/validator.test.ts deleted file mode 100644 index 470ca48..0000000 --- a/test/flowrun/validator.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { describe, it, expect } from "vitest" -import { validateFlowRunDag } from "../../src/flowrun/validator.js" -import type { TaskState } from "../../src/flowrun/types.js" - -function makeTask(id: string, dependsOn: string[] = [], overrides: Partial = {}): TaskState { - return { - id, - name: `Task ${id}`, - status: "pending", - dependsOn, - area: "backend", - expectedFiles: [], - parallelSafe: false, - prNumber: null, - prCheckpoints: null, - blockedReason: null, - startedAt: null, - // v2 fields - acceptanceCriteria: [], - testCommands: [], - verifyCommands: [], - executionBinding: null, - tddPolicy: { - mode: "bypass", - enforcement: "advisory", - runner: null, - testFilePatterns: [], - implementationFilePatterns: [], - generatedArtifactPatterns: [], - exception: { - reason: "test", - alternativeValidation: [], - approval: { kind: "legacy-migration", fromSchemaVersion: 1 }, - }, - source: { manifestPath: "test", revisionSha: "test" }, - }, - tddEvidence: { - revision: 0, - reworkRevision: 0, - status: "not-recorded", - taskStart: { status: "pending", headSha: null, treeSha: null, startedAt: null }, - cycles: [], - regression: { status: "pending", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, - verification: { status: "pending", headSha: null, treeSha: null, runs: [] }, - alternativeValidation: [], - reworks: [], - warnings: [], - updatedAt: null, - }, - coveragePolicy: null, - ...overrides, - } -} - -describe("validateFlowRunDag", () => { - it("accepts empty tasks", () => { - const errors = validateFlowRunDag({}) - expect(errors).toHaveLength(0) - }) - - it("accepts single task with no dependencies", () => { - const tasks: Record = { - "task-1": makeTask("task-1"), - } - const errors = validateFlowRunDag(tasks) - expect(errors).toHaveLength(0) - }) - - it("accepts linear dependency chain", () => { - const tasks: Record = { - "task-1": makeTask("task-1"), - "task-2": makeTask("task-2", ["task-1"]), - "task-3": makeTask("task-3", ["task-2"]), - } - const errors = validateFlowRunDag(tasks) - expect(errors).toHaveLength(0) - }) - - it("accepts diamond dependency", () => { - const tasks: Record = { - "task-a": makeTask("task-a"), - "task-b": makeTask("task-b", ["task-a"]), - "task-c": makeTask("task-c", ["task-a"]), - "task-d": makeTask("task-d", ["task-b", "task-c"]), - } - const errors = validateFlowRunDag(tasks) - expect(errors).toHaveLength(0) - }) - - // ─── Record key 与 task.id 一致 ─── - - it("rejects record key mismatch", () => { - const tasks: Record = { - "wrong-key": makeTask("task-1"), - } - const errors = validateFlowRunDag(tasks) - expect(errors.length).toBeGreaterThan(0) - expect(errors.some(e => e.path.startsWith("tasks.wrong-key") && e.message.includes("does not match"))).toBe(true) - }) - - // ─── 未知依赖 ─── - - it("rejects unknown dependency", () => { - const tasks: Record = { - "task-1": makeTask("task-1", ["task-nonexistent"]), - } - const errors = validateFlowRunDag(tasks) - expect(errors.length).toBeGreaterThan(0) - expect(errors.some(e => e.path.includes("dependsOn") && e.message.includes("not found"))).toBe(true) - }) - - // ─── 自依赖 ─── - - it("rejects self-dependency", () => { - const tasks: Record = { - "task-1": makeTask("task-1", ["task-1"]), - } - const errors = validateFlowRunDag(tasks) - expect(errors.length).toBeGreaterThan(0) - expect(errors.some(e => e.path.includes("dependsOn") && e.message.includes("depends on itself"))).toBe(true) - }) - - // ─── 环路 ─── - - it("rejects simple cycle (A → B → A)", () => { - const tasks: Record = { - "task-a": makeTask("task-a", ["task-b"]), - "task-b": makeTask("task-b", ["task-a"]), - } - const errors = validateFlowRunDag(tasks) - expect(errors.length).toBeGreaterThan(0) - expect(errors.some(e => e.message.includes("Circular dependency"))).toBe(true) - }) - - it("rejects three-node cycle (A → B → C → A)", () => { - const tasks: Record = { - "task-a": makeTask("task-a", ["task-c"]), - "task-b": makeTask("task-b", ["task-a"]), - "task-c": makeTask("task-c", ["task-b"]), - } - const errors = validateFlowRunDag(tasks) - expect(errors.length).toBeGreaterThan(0) - expect(errors.some(e => e.message.includes("Circular dependency"))).toBe(true) - }) - - // ─── 组合错误 ─── - - it("reports multiple errors", () => { - const tasks: Record = { - "task-1": makeTask("task-1", ["task-99"]), // unknown dep - "task-2": makeTask("task-2", ["task-2"]), // self-dep - "wrong-key": makeTask("task-3"), // key mismatch - } - const errors = validateFlowRunDag(tasks) - expect(errors.length).toBeGreaterThanOrEqual(2) - }) -}) diff --git a/test/kernel/review.test.ts b/test/kernel/review.test.ts new file mode 100644 index 0000000..756579e --- /dev/null +++ b/test/kernel/review.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeAll } from "vitest" +import { + mergeTaskPR, + checkBranchProtection, + setMergeGhExecutor, + type GhFn, +} from "../../src/kernel/review.js" + +// ─── checkBranchProtection ─── + +describe("checkBranchProtection", () => { + it("reports protection enabled when the API returns a protection config", async () => { + const calls: string[] = [] + setMergeGhExecutor((async (args: string) => { + calls.push(args) + return { + stdout: JSON.stringify({ + required_pull_request_reviews: { dismiss_stale_reviews: true }, + required_status_checks: { checks: [{ context: "CI" }, { context: "lint" }] }, + }), + stderr: "", + } + }) as GhFn) + + const result = await checkBranchProtection("owner", "repo") + expect(result.exists).toBe(true) + expect(result.requiredChecks).toEqual(["CI", "lint"]) + expect(result.requiresPR).toBe(true) + expect(result.dismissesStale).toBe(true) + expect(calls.some(c => c.includes("branches/main/protection"))).toBe(true) + }) + + it("reports protection disabled when the API call fails", async () => { + setMergeGhExecutor((async () => { + throw new Error("404") + }) as GhFn) + const result = await checkBranchProtection("owner", "repo") + expect(result.exists).toBe(false) + expect(result.requiredChecks).toEqual([]) + }) +}) + +// ─── mergeTaskPR ─── + +describe("mergeTaskPR", () => { + it("requires verifiedSha and returns error if missing", async () => { + const result = await mergeTaskPR(1, "") + expect(result.success).toBe(false) + expect(result.error).toContain("verifiedSha is required") + }) + + it("attempts merge with --match-head-commit flag", async () => { + const calls: string[] = [] + setMergeGhExecutor((async (args: string) => { + calls.push(args) + return { stdout: "Merged", stderr: "" } + }) as GhFn) + + const result = await mergeTaskPR(42, "abc123def456") + expect(result.success).toBe(true) + // 验证使用了 --match-head-commit + expect(calls.some(c => c.includes("--match-head-commit") && c.includes("abc123def456"))).toBe(true) + }) + + it("blocks merge when gh fails", async () => { + setMergeGhExecutor((async () => { + throw new Error("Merge conflict") + }) as GhFn) + + const result = await mergeTaskPR(42, "abc123") + expect(result.success).toBe(false) + expect(result.error).toContain("Merge conflict") + }) +}) + +beforeAll(() => { + // 默认 executor 恒抛错,避免真实调用宿主 gh + setMergeGhExecutor((async () => { + throw new Error("unexpected gh call") + }) as GhFn) +}) diff --git a/test/plugin/broker.test.ts b/test/plugin/broker.test.ts deleted file mode 100644 index f90a671..0000000 --- a/test/plugin/broker.test.ts +++ /dev/null @@ -1,700 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" -import { FlowBroker } from "../../src/plugin/broker.js" -import type { FlowRun, TaskState } from "../../src/flowrun/types.js" -import { CABINET_START_MARKER, CABINET_END_MARKER } from "../../src/flowrun/types.js" -import { createInitialFlowRun } from "../../src/flowrun/github.js" -import { createTaskEvidence } from "../../src/plugin/tdd-tool.js" - -// ─── Mock github module ─── - -const mockReadFlowRunWithLock = vi.fn() -const mockWriteFlowRunWithLock = vi.fn() - -vi.mock("../../src/flowrun/github.js", async () => { - const actual = await vi.importActual("../../src/flowrun/github.js") - return { - ...actual, - readFlowRunWithLock: (...args: unknown[]) => mockReadFlowRunWithLock(...args), - writeFlowRunWithLock: (...args: unknown[]) => mockWriteFlowRunWithLock(...args), - } -}) - -// ─── Mock gh util (for verifyCredentials tests) ─── - -const mockGhFn = vi.fn() - -vi.mock("../../src/util/gh.js", () => ({ - gh: (...args: unknown[]) => mockGhFn(...args), -})) - -// ─── 辅助函数 ─── - -function delay(ms: number): Promise { - return new Promise(r => setTimeout(r, ms)) -} - -function makeFlowRun(overrides: Partial = {}): FlowRun { - const fr = createInitialFlowRun("flow-o/r-1", "o/r", 1) - return { - ...fr, - status: "running", - revision: 0, - tasks: { - "task-1": makeTaskState(), - "task-2": makeTaskState({ id: "task-2", name: "Task 2" }), - }, - ...overrides, - } -} - -function makeTaskState(overrides: Partial = {}): TaskState { - return { - id: "task-1", - name: "Test Task", - status: "running", - dependsOn: [], - area: "backend", - expectedFiles: [], - parallelSafe: false, - prNumber: null, - prCheckpoints: null, - blockedReason: null, - startedAt: "2026-01-01T00:00:00Z", - acceptanceCriteria: [{ id: "AC-1", description: "TDD test", verification: "tdd" }], - testCommands: [{ command: "npm test", cwd: ".", timeoutMs: 30000, env: {} }], - verifyCommands: [{ command: "npm run typecheck", cwd: ".", timeoutMs: 30000, env: {} }], - executionBinding: { - branch: "feat/test-task", - baseSha: "baseSha1", - startHeadSha: "startHead1", - worktreeId: "worktree-1", - sessionId: "session-1", - }, - tddPolicy: { - mode: "strict", - enforcement: "runtime", - runner: { - adapter: "vitest", - baseCommand: "npx vitest run", - timeoutMs: 30000, - executionInputPatterns: ["package.json", "vitest.config.ts"], - }, - testFilePatterns: ["test/**/*.test.ts"], - implementationFilePatterns: ["src/**/*.ts"], - generatedArtifactPatterns: [], - exception: null, - source: { manifestPath: "test.yml", revisionSha: "sha1" }, - }, - tddEvidence: createTaskEvidence(), - coveragePolicy: null, - ...overrides, - } -} - -function mockIssueBody(flowRun: FlowRun): string { - const json = JSON.stringify(flowRun, null, 2) - return `# Issue Title\n\nSome description\n\n${CABINET_START_MARKER}\n\`\`\`json\n${json}\n\`\`\`\n${CABINET_END_MARKER}` -} - -function setupReadWrite(flowRun: FlowRun) { - const body = mockIssueBody(flowRun) - mockReadFlowRunWithLock.mockResolvedValue({ - flowRunResult: { ok: true, data: JSON.parse(JSON.stringify(flowRun)), migrated: false }, - currentBody: body, - }) - mockWriteFlowRunWithLock.mockResolvedValue({ success: true, conflict: false }) -} - -beforeEach(() => { - vi.clearAllMocks() -}) - -// ─── 独立凭证测试 ─── - -describe("FlowBroker — 独立凭证", () => { - it("接受 BrokerCredentials 构造参数", () => { - const broker = new FlowBroker({ token: "ghp_test123" }) - expect(broker).toBeDefined() - }) - - it("无凭证构造时正常运行(降级 ambient)", () => { - const broker = new FlowBroker() - expect(broker).toBeDefined() - }) - - it("broker token 不在 JSON 序列化输出中", () => { - const broker = new FlowBroker({ token: "ghp_secret_do_not_leak" }) - const serialized = JSON.stringify(broker) - expect(serialized).not.toContain("ghp_secret_do_not_leak") - }) - - it("broker token 不通过 Object.keys 暴露", () => { - const broker = new FlowBroker({ token: "ghp_secret" }) - const valueStr = JSON.stringify(Object.values(broker as unknown as Record)) - // Token 不应作为可枚举属性出现 - expect(valueStr).not.toContain("ghp_secret") - }) -}) - -describe("FlowBroker — verifyCredentials", () => { - it("无凭证时返回 ok: false", async () => { - const broker = new FlowBroker() - const result = await broker.verifyCredentials() - expect(result.ok).toBe(false) - expect(result.message).toContain("No broker credentials") - // 无凭证时不应调用 gh - expect(mockGhFn).not.toHaveBeenCalled() - }) - - it("凭证有效时返回 ok: true", async () => { - mockGhFn.mockResolvedValue({ stdout: "", stderr: "" }) - - const broker = new FlowBroker({ token: "ghp_valid_token" }) - const result = await broker.verifyCredentials() - - expect(result.ok).toBe(true) - expect(mockGhFn).toHaveBeenCalledWith( - "auth status", - 15_000, - expect.objectContaining({ GH_TOKEN: "ghp_valid_token", GITHUB_TOKEN: "ghp_valid_token" }), - ) - }) - - it("凭证无效时返回 ok: false", async () => { - mockGhFn.mockRejectedValue(new Error("Authentication failed")) - - const broker = new FlowBroker({ token: "ghp_invalid" }) - const result = await broker.verifyCredentials() - - expect(result.ok).toBe(false) - expect(result.message).toContain("failed") - }) -}) - -describe("FlowBroker — 凭证传递给 GitHub 操作", () => { - it("有凭证时 readFlowRunWithLock 收到 ghEnv", async () => { - const broker = new FlowBroker({ token: "ghp_broker" }) - const flowRun = makeFlowRun() - setupReadWrite(flowRun) - - await broker.writeFlowRunWithLock(1, (fr) => { - fr.status = "completed" - return { flowRun: fr, result: 42, shouldPersist: true } - }) - - expect(mockReadFlowRunWithLock).toHaveBeenCalledWith( - 1, - expect.objectContaining({ GH_TOKEN: "ghp_broker", GITHUB_TOKEN: "ghp_broker" }), - ) - }) - - it("有凭证时 writeFlowRunWithLock 收到 ghEnv", async () => { - const broker = new FlowBroker({ token: "ghp_broker" }) - const flowRun = makeFlowRun() - setupReadWrite(flowRun) - - await broker.writeFlowRunWithLock(1, (fr) => { - fr.status = "completed" - return { flowRun: fr, result: 42, shouldPersist: true } - }) - - expect(mockWriteFlowRunWithLock).toHaveBeenCalledWith( - 1, - expect.any(Object), // flowRun - expect.any(String), // currentBody - expect.objectContaining({ GH_TOKEN: "ghp_broker", GITHUB_TOKEN: "ghp_broker" }), - ) - }) - - it("无凭证时不传递 ghEnv(undefined)", async () => { - const broker = new FlowBroker() // no credentials - const flowRun = makeFlowRun() - setupReadWrite(flowRun) - - await broker.writeFlowRunWithLock(1, (fr) => { - fr.status = "completed" - return { flowRun: fr, result: 42, shouldPersist: true } - }) - - expect(mockReadFlowRunWithLock).toHaveBeenCalledWith(1, undefined) - expect(mockWriteFlowRunWithLock).toHaveBeenCalledWith( - 1, - expect.any(Object), - expect.any(String), - undefined, - ) - }) - - it("enqueue 操作不传递 ghEnv(enqueue 不调用 gh)", async () => { - const broker = new FlowBroker({ token: "ghp_broker" }) - let result = "" - await broker.enqueue(1, async () => { - result = "done" - return result - }) - expect(result).toBe("done") - // enqueue 不应调用任何 gh 操作 - expect(mockReadFlowRunWithLock).not.toHaveBeenCalled() - expect(mockWriteFlowRunWithLock).not.toHaveBeenCalled() - }) -}) - -// ─── Keyed Mutex 测试 ─── - -describe("FlowBroker — enqueue (keyed mutex)", () => { - it("串行化相同 parentIssueNumber 的操作", async () => { - const broker = new FlowBroker() - const order: number[] = [] - - const op1 = broker.enqueue(1, async () => { - order.push(1) - await delay(30) - order.push(2) - return "a" - }) - - const op2 = broker.enqueue(1, async () => { - order.push(3) - await delay(10) - order.push(4) - return "b" - }) - - const [r1, r2] = await Promise.all([op1, op2]) - expect(order).toEqual([1, 2, 3, 4]) - expect(r1).toBe("a") - expect(r2).toBe("b") - }) - - it("允许不同 parentIssueNumber 的操作并行", async () => { - const broker = new FlowBroker() - const order: number[] = [] - - const op1 = broker.enqueue(1, async () => { - order.push(1) - await delay(50) - order.push(2) - return "a" - }) - - const op2 = broker.enqueue(2, async () => { - order.push(3) - await delay(10) - order.push(4) - return "b" - }) - - const [r1, r2] = await Promise.all([op1, op2]) - // key 2 的操作应在 key 1 操作完成前完成 - expect(order).toEqual([1, 3, 4, 2]) - expect(r1).toBe("a") - expect(r2).toBe("b") - }) - - it("不同 keys 各自串行,互不干扰", async () => { - const broker = new FlowBroker() - const order: number[] = [] - - const k1a = broker.enqueue(1, async () => { - order.push(1) - await delay(30) - order.push(2) - }) - const k1b = broker.enqueue(1, async () => { - order.push(3) - await delay(10) - order.push(4) - }) - const k2a = broker.enqueue(2, async () => { - order.push(5) - await delay(30) - order.push(6) - }) - const k2b = broker.enqueue(2, async () => { - order.push(7) - await delay(10) - order.push(8) - }) - - await Promise.all([k1a, k1b, k2a, k2b]) - // key 1 内部: 1,2,3,4 顺序 - // key 2 内部: 5,6,7,8 顺序 - expect(order.indexOf(2)).toBeGreaterThan(order.indexOf(1)) - expect(order.indexOf(3)).toBeGreaterThan(order.indexOf(2)) - expect(order.indexOf(4)).toBeGreaterThan(order.indexOf(3)) - expect(order.indexOf(6)).toBeGreaterThan(order.indexOf(5)) - expect(order.indexOf(7)).toBeGreaterThan(order.indexOf(6)) - expect(order.indexOf(8)).toBeGreaterThan(order.indexOf(7)) - }) -}) - -// ─── writeFlowRunWithLock 测试 ─── - -describe("FlowBroker — writeFlowRunWithLock", () => { - it("成功执行 read-modify-write 循环", async () => { - const broker = new FlowBroker() - const flowRun = makeFlowRun() - setupReadWrite(flowRun) - - const result = await broker.writeFlowRunWithLock(1, (fr) => { - fr.status = "completed" - return { flowRun: fr, result: 42, shouldPersist: true } - }) - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.result).toBe(42) - expect(result.persisted).toBe(true) - } - - // 验证 readFlowRunWithLock 被调用 - expect(mockReadFlowRunWithLock).toHaveBeenCalledWith(1, undefined) - // 验证 writeFlowRunWithLock 被调用,且 revision 已 bump - expect(mockWriteFlowRunWithLock).toHaveBeenCalledTimes(1) - const writtenFlowRun = mockWriteFlowRunWithLock.mock.calls[0][1] as FlowRun - expect(writtenFlowRun.revision).toBe(flowRun.revision + 1) - expect(writtenFlowRun.status).toBe("completed") - }) - - it("shouldPersist=false 时不执行写入", async () => { - const broker = new FlowBroker() - const flowRun = makeFlowRun() - setupReadWrite(flowRun) - - const result = await broker.writeFlowRunWithLock(1, (fr) => { - return { flowRun: fr, result: "skipped", shouldPersist: false } - }) - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.result).toBe("skipped") - expect(result.persisted).toBe(false) - } - // 验证未调用 writeFlowRunWithLock - expect(mockWriteFlowRunWithLock).not.toHaveBeenCalled() - }) - - it("检测到 body 变化时返回 PERSIST_CONFLICT", async () => { - const broker = new FlowBroker() - const flowRun = makeFlowRun() - setupReadWrite(flowRun) - // 模拟写入冲突 - mockWriteFlowRunWithLock.mockResolvedValue({ success: false, error: "Conflict: body has changed", conflict: true }) - - const result = await broker.writeFlowRunWithLock(1, (fr) => { - fr.status = "completed" - return { flowRun: fr, result: null, shouldPersist: true } - }) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.code).toBe("PERSIST_CONFLICT") - } - }) - - it("读取失败时返回 READ_FAILED", async () => { - const broker = new FlowBroker() - mockReadFlowRunWithLock.mockResolvedValue({ - flowRunResult: { ok: false, code: "NOT_FOUND", errors: [{ path: "", message: "Not found" }] }, - currentBody: null, - }) - - const result = await broker.writeFlowRunWithLock(1, (fr) => { - return { flowRun: fr, result: null, shouldPersist: true } - }) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.code).toBe("READ_FAILED") - } - }) - - it("每次写入自动增加 revision 和时间戳", async () => { - const broker = new FlowBroker() - const flowRun = makeFlowRun({ revision: 5 }) - setupReadWrite(flowRun) - - await broker.writeFlowRunWithLock(1, (fr) => { - return { flowRun: fr, result: null, shouldPersist: true } - }) - - const written = mockWriteFlowRunWithLock.mock.calls[0][1] as FlowRun - expect(written.revision).toBe(6) - expect(written.lastTickAt).toBeDefined() - expect(new Date(written.lastTickAt!).getTime()).toBeGreaterThan(0) - }) -}) - -// ─── 并行写入互不覆盖测试 ─── - -describe("FlowBroker — 并行写入互不覆盖", () => { - it("两个并行操作修改不同 Task → 均持久化成功", async () => { - const broker = new FlowBroker() - const flowRun = makeFlowRun() - setupReadWrite(flowRun) - - // 模拟两次写入,每次返回不同的 body - let callCount = 0 - mockReadFlowRunWithLock.mockImplementation(async (issueNumber: number) => { - callCount++ - // 每次读取返回不同状态的 FlowRun(模拟前一次写入已生效) - const fr = JSON.parse(JSON.stringify(flowRun)) - if (callCount === 1) { - fr.revision = 0 - } else { - fr.revision = 1 - fr.tasks["task-1"].tddEvidence.revision = 1 - } - const body = mockIssueBody(fr) - return { flowRunResult: { ok: true, data: fr, migrated: false }, currentBody: body } - }) - - mockWriteFlowRunWithLock.mockResolvedValue({ success: true, conflict: false }) - - // 操作 1:修改 task-1 的 evidence - const op1 = broker.writeFlowRunWithLock(1, (fr) => { - fr.tasks["task-1"].tddEvidence = { - ...fr.tasks["task-1"].tddEvidence, - revision: 1, - status: "in-progress", - } - return { flowRun: fr, result: "op1", shouldPersist: true } - }) - - // 操作 2:修改 task-2 的 evidence - const op2 = broker.writeFlowRunWithLock(1, (fr) => { - fr.tasks["task-2"].tddEvidence = { - ...fr.tasks["task-2"].tddEvidence, - revision: 1, - status: "pass", - } - return { flowRun: fr, result: "op2", shouldPersist: true } - }) - - const [r1, r2] = await Promise.all([op1, op2]) - - // 两个操作都应成功(由于 keyed mutex 串行化 + 正确的 body 更新) - expect(r1.ok).toBe(true) - expect(r2.ok).toBe(true) - - // 验证 writeFlowRunWithLock 被调用两次 - expect(mockWriteFlowRunWithLock).toHaveBeenCalledTimes(2) - - // 第二次写入的 FlowRun 应包含两次修改的结果 - const secondWrite = mockWriteFlowRunWithLock.mock.calls[1][1] as FlowRun - expect(secondWrite.tasks["task-1"].tddEvidence.revision).toBe(1) - expect(secondWrite.tasks["task-2"].tddEvidence.revision).toBe(1) - }) -}) - -// ─── 冲突暂停测试 ─── - -describe("FlowBroker — 冲突暂停", () => { - it("检测到外部 revision 变化 → 返回 PERSIST_CONFLICT", async () => { - const broker = new FlowBroker() - const flowRun = makeFlowRun({ revision: 3 }) - setupReadWrite(flowRun) - - // 模拟 body 不匹配(外部修改) - mockWriteFlowRunWithLock.mockResolvedValue({ - success: false, - error: "Conflict: body has changed since last read", - conflict: true, - }) - - const result = await broker.writeFlowRunWithLock(1, (fr) => { - fr.tasks["task-1"].tddEvidence.status = "pass" - return { flowRun: fr, result: null, shouldPersist: true } - }) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.code).toBe("PERSIST_CONFLICT") - expect(result.message).toContain("changed") - } - - // 确认冲突后没有写入 - expect(mockWriteFlowRunWithLock).toHaveBeenCalledTimes(1) - }) - - it("冲突后 FlowRun 状态不受影响(原数据未写入)", async () => { - const broker = new FlowBroker() - const flowRun = makeFlowRun({ revision: 3, status: "running" }) - setupReadWrite(flowRun) - - // 第一次写入成功 - mockWriteFlowRunWithLock.mockResolvedValueOnce({ success: true, conflict: false }) - - const r1 = await broker.writeFlowRunWithLock(1, (fr) => { - fr.status = "blocked" - return { flowRun: fr, result: null, shouldPersist: true } - }) - expect(r1.ok).toBe(true) - - // 第二次写入冲突 - mockWriteFlowRunWithLock.mockResolvedValueOnce({ - success: false, - error: "Conflict", - conflict: true, - }) - - // 更新 body 为"已被外部修改"的版本 - const modifiedFR = makeFlowRun({ revision: 5, status: "cancelled" }) - const modifiedBody = mockIssueBody(modifiedFR) - mockReadFlowRunWithLock.mockResolvedValue({ - flowRunResult: { ok: true, data: JSON.parse(JSON.stringify(modifiedFR)), migrated: false }, - currentBody: modifiedBody, - }) - - const r2 = await broker.writeFlowRunWithLock(1, (fr) => { - fr.status = "completed" - return { flowRun: fr, result: null, shouldPersist: true } - }) - expect(r2.ok).toBe(false) - if (!r2.ok) { - expect(r2.code).toBe("PERSIST_CONFLICT") - } - }) -}) - -// ─── TDD checkpoint 集成测试 ─── - -describe("FlowBroker — tdd_checkpoint 集成", () => { - it("通过 broker 执行 cycle-start → 持久化 evidence", async () => { - const broker = new FlowBroker() - const flowRun = makeFlowRun() - setupReadWrite(flowRun) - - const { handleTddCheckpoint } = await import("../../src/plugin/tdd-tool.js") - - const result = await broker.writeFlowRunWithLock(1, (fr) => { - const task = fr.tasks["task-1"] - const resp = handleTddCheckpoint( - { - op: "cycle-start", - parentIssueNumber: 1, - taskId: "task-1", - cycleId: "cycle-1", - criterionId: "AC-1", - testPaths: ["test/foo.test.ts"], - testSelector: "test/foo.test.ts", - }, - task, - ) - return { flowRun: fr, result: resp, shouldPersist: resp.ok } - }) - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.result.ok).toBe(true) - expect(result.persisted).toBe(true) - // 验证 evidence 已更新 - expect(result.flowRun.tasks["task-1"].tddEvidence.cycles).toHaveLength(1) - expect(result.flowRun.tasks["task-1"].tddEvidence.cycles[0].cycleId).toBe("cycle-1") - } - }) - - it("handleTddCheckpoint 失败时不持久化", async () => { - const broker = new FlowBroker() - const flowRun = makeFlowRun() - setupReadWrite(flowRun) - - const { handleTddCheckpoint } = await import("../../src/plugin/tdd-tool.js") - - const result = await broker.writeFlowRunWithLock(1, (fr) => { - const task = fr.tasks["task-1"] - // 尝试一个不存在的 criterionId - const resp = handleTddCheckpoint( - { - op: "cycle-start", - parentIssueNumber: 1, - taskId: "task-1", - cycleId: "cycle-1", - criterionId: "NONEXISTENT", - testPaths: ["test/foo.test.ts"], - testSelector: "test/foo.test.ts", - }, - task, - ) - return { flowRun: fr, result: resp, shouldPersist: resp.ok } - }) - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.result.ok).toBe(false) - expect(result.result.error!.code).toBe("CRITERION_NOT_FOUND") - expect(result.persisted).toBe(false) - } - // 未写入 - expect(mockWriteFlowRunWithLock).not.toHaveBeenCalled() - }) -}) - -// ─── 乐观锁重试逻辑测试 ─── - -describe("FlowBroker — 乐观锁重试", () => { - it("enqueue 中的操作不会被并发打断", async () => { - const broker = new FlowBroker() - const flowRun = makeFlowRun() - setupReadWrite(flowRun) - - let insideCount = 0 - let maxInsideCount = 0 - - // 启动 3 个并行操作,但由于 keyed mutex,内部应串行 - const ops = [1, 2, 3].map(id => - broker.enqueue(1, async () => { - insideCount++ - maxInsideCount = Math.max(maxInsideCount, insideCount) - await delay(10) - insideCount-- - return id - }) - ) - - await Promise.all(ops) - // keyed mutex 保证同时只有一个操作在执行 - expect(maxInsideCount).toBe(1) - }) - - it("PERSIST_CONFLICT 后调用方可实现重试逻辑", async () => { - const broker = new FlowBroker() - const flowRun = makeFlowRun() - setupReadWrite(flowRun) - - // 第一次冲突,第二次成功 - mockWriteFlowRunWithLock - .mockResolvedValueOnce({ success: false, error: "Conflict", conflict: true }) - .mockResolvedValueOnce({ success: true, conflict: false }) - - let attempts = 0 - const maxRetries = 3 - - let finalResult: unknown = null - for (let i = 0; i < maxRetries; i++) { - attempts++ - const result = await broker.writeFlowRunWithLock(1, (fr) => { - fr.tasks["task-1"].tddEvidence.status = "pass" - return { flowRun: fr, result: "done", shouldPersist: true } - }) - - if (result.ok) { - finalResult = result - break - } - if (!result.ok && result.code === "PERSIST_CONFLICT") { - // 重试前刷新 mock(模拟重新读取) - const updatedFR = makeFlowRun({ revision: flowRun.revision + attempts }) - const body = mockIssueBody(updatedFR) - mockReadFlowRunWithLock.mockResolvedValue({ - flowRunResult: { ok: true, data: JSON.parse(JSON.stringify(updatedFR)), migrated: false }, - currentBody: body, - }) - } - } - - expect(attempts).toBe(2) - expect(finalResult).not.toBeNull() - expect((finalResult as { ok: boolean })?.ok).toBe(true) - }) -}) diff --git a/test/plugin/server-flowrun.test.ts b/test/plugin/server-flowrun.test.ts deleted file mode 100644 index ed0880f..0000000 --- a/test/plugin/server-flowrun.test.ts +++ /dev/null @@ -1,465 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" -import type { - FlowRun, FlowStage, TaskState, TaskExecutionBinding, - GoalFlowRunRef, -} from "../../src/flowrun/types.js" -import { CABINET_START_MARKER, CABINET_END_MARKER } from "../../src/flowrun/types.js" -import { createInitialFlowRun } from "../../src/flowrun/github.js" -import { - flowRunStart, - flowStageStart, - flowStageComplete, - flowTaskStart, -} from "../../src/flowrun/transitions.js" - -// ─── Mock github module(模拟 gh CLI) ─── - -const mockReadFlowRunWithLock = vi.fn() -const mockWriteFlowRunWithLock = vi.fn() - -vi.mock("../../src/flowrun/github.js", async () => { - const actual = await vi.importActual("../../src/flowrun/github.js") - return { - ...actual, - readFlowRunWithLock: (...args: unknown[]) => mockReadFlowRunWithLock(...args), - writeFlowRunWithLock: (...args: unknown[]) => mockWriteFlowRunWithLock(...args), - } -}) - -// ─── 辅助工厂函数 ─── - -function makeFlowRun(overrides: Partial = {}): FlowRun { - const fr = createInitialFlowRun("flow-o/r-1", "o/r", 1) - return { - ...fr, - status: "running", - revision: 0, - stages: { - requirements: { status: "pass", requiredArtifacts: [], checks: [], completedAt: "2026-01-01T00:00:00Z", evidence: [] }, - design: { status: "pass", requiredArtifacts: [], checks: [], completedAt: "2026-01-01T00:00:00Z", evidence: [] }, - tasks: { status: "pass", requiredArtifacts: [], checks: [], completedAt: "2026-01-01T00:00:00Z", evidence: [] }, - code: { status: "pending", requiredArtifacts: [], checks: [], completedAt: null, evidence: [] }, - test: { status: "pending", requiredArtifacts: [], checks: [], completedAt: null, evidence: [] }, - review: { status: "pending", requiredArtifacts: [], checks: [], completedAt: null, evidence: [] }, - merge: { status: "pending", requiredArtifacts: [], checks: [], completedAt: null, evidence: [] }, - }, - tasks: { - "task-1": makeTaskState("task-1", "Task 1", [], "pending"), - "task-2": makeTaskState("task-2", "Task 2", ["task-1"], "pending"), - "task-3": makeTaskState("task-3", "Task 3", [], "pending"), - }, - ...overrides, - } -} - -function makeTaskState( - id: string, - name: string, - dependsOn: string[] = [], - status: TaskState["status"] = "pending", -): TaskState { - return { - id, - name, - status, - dependsOn, - area: "backend", - expectedFiles: [], - parallelSafe: dependsOn.length === 0, - prNumber: null, - prCheckpoints: null, - blockedReason: null, - startedAt: null, - acceptanceCriteria: [{ id: "AC-1", description: "验收条件", verification: "tdd" }], - testCommands: [{ command: "npm test", cwd: ".", timeoutMs: 30000, env: {} }], - verifyCommands: [{ command: "npm run typecheck", cwd: ".", timeoutMs: 30000, env: {} }], - executionBinding: null, - tddPolicy: { - mode: "strict", - enforcement: "runtime", - runner: { - adapter: "vitest", - baseCommand: "npx vitest run", - timeoutMs: 30000, - executionInputPatterns: ["package.json", "vitest.config.ts"], - }, - testFilePatterns: ["test/**/*.test.ts"], - implementationFilePatterns: ["src/**/*.ts"], - generatedArtifactPatterns: [], - exception: null, - source: { manifestPath: "test.yml", revisionSha: "sha1" }, - }, - tddEvidence: { - revision: 0, - reworkRevision: 0, - status: "not-recorded", - taskStart: { status: "pending", headSha: null, treeSha: null, startedAt: null }, - cycles: [], - regression: { status: "pending", headSha: null, treeSha: null, reworkRevision: 0, runs: [] }, - verification: { status: "pending", headSha: null, treeSha: null, runs: [] }, - alternativeValidation: [], - reworks: [], - warnings: [], - updatedAt: null, - }, - coveragePolicy: null, - } -} - -function makeExecutionBinding(overrides: Partial = {}): TaskExecutionBinding { - return { - branch: "feat/test-branch", - baseSha: "baseSha123", - startHeadSha: "headSha123", - worktreeId: "worktree-1", - sessionId: "session-1", - ...overrides, - } -} - -function mockIssueBody(flowRun: FlowRun): string { - const json = JSON.stringify(flowRun, null, 2) - return `# Issue Title\n\nSome description\n\n${CABINET_START_MARKER}\n\`\`\`json\n${json}\n\`\`\`\n${CABINET_END_MARKER}` -} - -function setupReadWrite(flowRun: FlowRun) { - const body = mockIssueBody(flowRun) - mockReadFlowRunWithLock.mockResolvedValue({ - flowRunResult: { ok: true, data: JSON.parse(JSON.stringify(flowRun)), migrated: false }, - currentBody: body, - }) - mockWriteFlowRunWithLock.mockResolvedValue({ success: true, conflict: false }) -} - -beforeEach(() => { - vi.clearAllMocks() -}) - -// ─── 纯函数测试:transitions ─── - -describe("Transitions — flowRunStart", () => { - it("planned → running", () => { - const fr = makeFlowRun({ status: "planned" }) - const result = flowRunStart(fr) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.value.status).toBe("running") - expect(result.value.startedAt).toBeDefined() - } - }) - - it("幂等:已是 running 不报错", () => { - const fr = makeFlowRun({ status: "running" }) - const result = flowRunStart(fr) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.value.status).toBe("running") - } - }) - - it("拒绝非 planned 状态(completed/blocked 等)", () => { - const fr = makeFlowRun({ status: "completed" }) - const result = flowRunStart(fr) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error.code).toBe("INVALID_TRANSITION") - } - }) -}) - -describe("Transitions — flowStageStart", () => { - it("pending → running(requirements 阶段)", () => { - const fr = makeFlowRun({ status: "running" }) - fr.stages.requirements.status = "pending" - fr.stages.design.status = "pending" - fr.stages.tasks.status = "pending" - // 第一个 pending stage 是 requirements,前置条件为空 → 应允许 - const result = flowStageStart(fr, "requirements") - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.value.stages.requirements.status).toBe("running") - } - }) - - it("拒绝非受控 stage(test)", () => { - const fr = makeFlowRun() - const result = flowStageStart(fr, "test") - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error.code).toBe("STAGE_NOT_CONTROLLABLE") - } - }) - - it("拒绝:前置 stage 未 pass", () => { - const fr = makeFlowRun() - fr.stages.design.status = "pending" // 前置未通过 - fr.stages.tasks.status = "pending" - fr.stages.code.status = "pending" - // tasks 前置 design 为 pending → 应拒绝 - const result = flowStageStart(fr, "tasks") - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error.code).toBe("GATE_BLOCKED") - } - }) - - it("幂等:已是 running 不报错", () => { - const fr = makeFlowRun() - fr.stages.code.status = "running" - const result = flowStageStart(fr, "code") - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.value.stages.code.status).toBe("running") - } - }) -}) - -describe("Transitions — flowStageComplete", () => { - it("running → pass(requirements 阶段,无 required artifacts/checks)", () => { - const fr = makeFlowRun() - fr.stages.requirements.status = "running" - const result = flowStageComplete(fr, "requirements") - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.value.stages.requirements.status).toBe("pass") - } - }) - - it("拒绝非受控 stage(code 由 finalize 完成)", () => { - const fr = makeFlowRun() - fr.stages.code.status = "running" - const result = flowStageComplete(fr, "code") - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error.code).toBe("STAGE_NOT_CONTROLLABLE") - } - }) - - it("拒绝:有 pending checks", () => { - const fr = makeFlowRun() - fr.stages.tasks.status = "running" - fr.stages.tasks.checks = [{ name: "review-tasks", status: "pending", evidence: [] }] - const result = flowStageComplete(fr, "tasks") - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error.code).toBe("GATE_BLOCKED") - } - }) -}) - -describe("Transitions — flowTaskStart", () => { - it("pending → running(写入 executionBinding)", () => { - const fr = makeFlowRun() - fr.stages.code.status = "running" - const binding = makeExecutionBinding() - const result = flowTaskStart(fr, "task-1", binding) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.value.task.status).toBe("running") - expect(result.value.task.executionBinding).toEqual(binding) - expect(result.value.task.startedAt).toBeDefined() - } - }) - - it("拒绝:依赖未 merged", () => { - const fr = makeFlowRun() - fr.stages.code.status = "running" - // task-2 depends on task-1 which is still pending - const binding = makeExecutionBinding() - const result = flowTaskStart(fr, "task-2", binding) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error.code).toBe("GATE_BLOCKED") - } - }) - - it("允许:依赖已 merged", () => { - const fr = makeFlowRun() - fr.stages.code.status = "running" - fr.tasks["task-1"].status = "merged" - const binding = makeExecutionBinding() - const result = flowTaskStart(fr, "task-2", binding) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.value.task.status).toBe("running") - } - }) - - it("冻结 policy:传入 tddPolicy 覆盖现有", () => { - const fr = makeFlowRun() - fr.stages.code.status = "running" - const binding = makeExecutionBinding() - const frozenPolicy = { - ...fr.tasks["task-1"].tddPolicy, - mode: "relaxed" as const, - source: { manifestPath: "frozen/manifest.yml", revisionSha: "frozen-sha" }, - } - const result = flowTaskStart(fr, "task-1", binding, frozenPolicy) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.value.task.tddPolicy.mode).toBe("relaxed") - expect(result.value.task.tddPolicy.source.manifestPath).toBe("frozen/manifest.yml") - } - }) - - it("幂等:已是 running → 更新 executionBinding", () => { - const fr = makeFlowRun() - fr.stages.code.status = "running" - fr.tasks["task-1"].status = "running" - fr.tasks["task-1"].executionBinding = makeExecutionBinding({ sessionId: "old-session" }) - const newBinding = makeExecutionBinding({ sessionId: "new-session" }) - const result = flowTaskStart(fr, "task-1", newBinding) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.value.task.executionBinding?.sessionId).toBe("new-session") - } - }) - - it("TASK_NOT_FOUND:taskId 不存在", () => { - const fr = makeFlowRun() - const binding = makeExecutionBinding() - const result = flowTaskStart(fr, "nonexistent", binding) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error.code).toBe("TASK_NOT_FOUND") - } - }) -}) - -// ─── 集成测试:flow_control 工具(通过 FlowBroker) ─── - -describe("flow_control tool — run-start", () => { - it("通过 broker 启动 FlowRun:planned → running", async () => { - const { FlowBroker } = await import("../../src/plugin/broker.js") - const broker = new FlowBroker() - const fr = makeFlowRun({ status: "planned" }) - setupReadWrite(fr) - - const result = await broker.writeFlowRunWithLock(1, (flowRun) => { - const res = flowRunStart(flowRun) - if (res.ok) { - return { flowRun: res.value, result: res.value.status, shouldPersist: true } - } - return { flowRun, result: null, shouldPersist: false } - }) - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.result).toBe("running") - expect(result.persisted).toBe(true) - } - expect(mockWriteFlowRunWithLock).toHaveBeenCalledTimes(1) - }) -}) - -describe("flow_control tool — stage-start", () => { - it("通过 broker 启动 code stage", async () => { - const { FlowBroker } = await import("../../src/plugin/broker.js") - const broker = new FlowBroker() - const fr = makeFlowRun() - // all prerequisite stages pass, code is pending - fr.stages.code.status = "pending" - setupReadWrite(fr) - - const result = await broker.writeFlowRunWithLock(1, (flowRun) => { - const res = flowStageStart(flowRun, "code") - if (res.ok) { - const stageResult = { stage: "code", status: res.value.stages.code.status } - return { flowRun: res.value, result: stageResult, shouldPersist: true } - } - return { flowRun, result: null, shouldPersist: false } - }) - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.result).not.toBeNull() - const r = result.result as { stage: string; status: string } - expect(r.stage).toBe("code") - expect(r.status).toBe("running") - expect(result.persisted).toBe(true) - } - }) -}) - -describe("flow_control tool — task-start", () => { - it("通过 broker 启动 task 并写入 executionBinding", async () => { - const { FlowBroker } = await import("../../src/plugin/broker.js") - const broker = new FlowBroker() - const fr = makeFlowRun() - fr.stages.code.status = "running" - setupReadWrite(fr) - - const binding = makeExecutionBinding() - - const result = await broker.writeFlowRunWithLock(1, (flowRun) => { - const res = flowTaskStart(flowRun, "task-1", binding) - if (res.ok) { - const taskResult = { taskId: "task-1", status: res.value.task.status, executionBinding: res.value.task.executionBinding } - return { flowRun: res.value.flowRun, result: taskResult, shouldPersist: true } - } - return { flowRun, result: null, shouldPersist: false } - }) - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.result).not.toBeNull() - const r = result.result as { taskId: string; status: string; executionBinding: TaskExecutionBinding | null } - expect(r.taskId).toBe("task-1") - expect(r.status).toBe("running") - expect(r.executionBinding).toEqual(binding) - } - }) -}) - -describe("flow_control tool — PERSIST_CONFLICT 路径", () => { - it("写入冲突时返回错误", async () => { - const { FlowBroker } = await import("../../src/plugin/broker.js") - const broker = new FlowBroker() - const fr = makeFlowRun({ status: "planned" }) - setupReadWrite(fr) - mockWriteFlowRunWithLock.mockResolvedValue({ success: false, error: "Conflict", conflict: true }) - - const result = await broker.writeFlowRunWithLock(1, (flowRun) => { - const res = flowRunStart(flowRun) - if (res.ok) { - return { flowRun: res.value, result: res.value.status, shouldPersist: true } - } - return { flowRun, result: null, shouldPersist: false } - }) - - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.code).toBe("PERSIST_CONFLICT") - } - }) -}) - -// ─── GoalFlowRunRef 绑定测试 ─── - -describe("GoalFlowRunRef 绑定逻辑", () => { - it("未绑定 → 写入 flowRunRef", () => { - const sessionMetadata: Record = { goal: { objective: "test", completionCriterion: "pass", status: "active", continuationCount: 0 } } - const ref: GoalFlowRunRef = { repo: "o/r", parentIssueNumber: 1, flowRunId: "flow-o/r-1" } - - // 写入 - sessionMetadata.flowRunRef = ref - expect(sessionMetadata.flowRunRef).toEqual(ref) - }) - - it("已绑定同一 FlowRun → 幂等(不报错)", () => { - const ref: GoalFlowRunRef = { repo: "o/r", parentIssueNumber: 1, flowRunId: "flow-o/r-1" } - const existingRef: GoalFlowRunRef = { ...ref } - - const isSameRef = existingRef.flowRunId === ref.flowRunId && existingRef.parentIssueNumber === ref.parentIssueNumber && existingRef.repo === ref.repo - expect(isSameRef).toBe(true) - }) - - it("已绑定不同 FlowRun → GOAL_FLOW_CONFLICT", () => { - const ref: GoalFlowRunRef = { repo: "o/r", parentIssueNumber: 1, flowRunId: "flow-o/r-2" } - const existingRef: GoalFlowRunRef = { repo: "o/r", parentIssueNumber: 1, flowRunId: "flow-o/r-1" } - - const isSameRef = existingRef.flowRunId === ref.flowRunId && existingRef.parentIssueNumber === ref.parentIssueNumber && existingRef.repo === ref.repo - expect(isSameRef).toBe(false) - // 应返回 GOAL_FLOW_CONFLICT 错误 - }) -}) diff --git a/test/plugin/server-permissions.test.ts b/test/plugin/server-permissions.test.ts index 63f8075..81b728c 100644 --- a/test/plugin/server-permissions.test.ts +++ b/test/plugin/server-permissions.test.ts @@ -185,6 +185,7 @@ describe("server config hook — agent 注入(permission 规则,无 tools let tmpDir: string let plugin: { config: (config: unknown) => Promise + tool: Record } beforeEach(async () => { @@ -200,7 +201,7 @@ describe("server config hook — agent 注入(permission 规则,无 tools serverUrl: new URL("http://localhost:0"), } const hooks = await createOpencodeCabbage(projectRoot)(ctx as never, {}) - plugin = hooks as unknown as { config: (config: unknown) => Promise } + plugin = hooks as unknown as { config: (config: unknown) => Promise; tool: Record } }) afterEach(async () => { @@ -208,6 +209,16 @@ describe("server config hook — agent 注入(permission 规则,无 tools await rm(tmpDir, { recursive: true, force: true }) }) + it("注册 5 个生命周期工具 + goal(批 15 接线验证)", async () => { + const toolKeys = Object.keys(plugin.tool).sort() + expect(toolKeys).toEqual(["flow_control", "goal", "release_control", "setup_control", "task_control", "tdd_checkpoint"]) + for (const key of ["flow_control", "task_control", "setup_control", "tdd_checkpoint", "release_control"]) { + expect(plugin.tool[key], `${key} should be a tool definition`).toBeDefined() + } + // 旧 FlowRun 工具(run-* 版)已删除 + expect(plugin.tool).not.toHaveProperty("flowrun") + }) + it("注入的 agent 保留 permission 规则,不再注入 tools 布尔", async () => { const config: Record = { agent: {} } await plugin.config(config) diff --git a/test/plugin/task-control.test.ts b/test/plugin/task-control.test.ts index ae3f3d4..ae5a06f 100644 --- a/test/plugin/task-control.test.ts +++ b/test/plugin/task-control.test.ts @@ -29,7 +29,7 @@ import type { ProjectProfile } from "../../src/kernel/profile.js" import { createTaskControlTool, registerTaskControl } from "../../src/plugin/task-control.js" import { setRecordsGhExecutor } from "../../src/kernel/records.js" import { setWorktreeGitExecutor } from "../../src/kernel/worktree.js" -import { setMergeGhExecutor } from "../../src/flowrun/merge.js" +import { setMergeGhExecutor } from "../../src/kernel/review.js" import type { CallerSessionClient } from "../../src/kernel/caller.js" const primaryClient: CallerSessionClient = { @@ -103,7 +103,7 @@ function resetAllExecutors() { setTaskGitExecutor(null) setRecordsGhExecutor(null) setWorktreeGitExecutor(null) - // flowrun/merge.ts 的 setMergeGhExecutor 不接受 null,用恒抛错的 stub 兜底 + // kernel/review.ts 的 setMergeGhExecutor 接受 null,用恒抛错的 stub 兜底 setMergeGhExecutor(() => { throw new Error("unexpected merge gh") }) diff --git a/test/plugin/tdd-tool.test.ts b/test/plugin/tdd-tool.test.ts deleted file mode 100644 index 025d8d0..0000000 --- a/test/plugin/tdd-tool.test.ts +++ /dev/null @@ -1,545 +0,0 @@ -import { describe, it, expect } from "vitest" -import type { - TddEvidence, - TddCycleEvidence, - TddCommandEvidence, - TddPolicy, - TaskState, - AcceptanceCriterion, - VersionedDigest, - TaskExecutionBinding, -} from "../../src/flowrun/types.js" -import type { TddCheckpointRequest, TddCheckpointResponse } from "../../src/plugin/tdd-tool.js" -import { handleTddCheckpoint, createTaskEvidence } from "../../src/plugin/tdd-tool.js" - -// ─── 辅助工厂 ─── - -function emptyDigest(): VersionedDigest { - return { algorithm: "sha256-content-v1", value: "a".repeat(64) } -} - -function makeBinding(overrides: Partial = {}): TaskExecutionBinding { - return { - branch: "feat/test-task", - baseSha: "baseSha1", - startHeadSha: "startHead1", - worktreeId: "worktree-1", - sessionId: "session-1", - ...overrides, - } -} - -function makePolicy(overrides: Partial = {}): TddPolicy { - return { - mode: "strict", - enforcement: "runtime", - runner: { - adapter: "vitest", - baseCommand: "npx vitest run", - timeoutMs: 30000, - executionInputPatterns: ["package.json", "vitest.config.ts"], - }, - testFilePatterns: ["test/**/*.test.ts"], - implementationFilePatterns: ["src/**/*.ts"], - generatedArtifactPatterns: [], - exception: null, - source: { manifestPath: "test.yml", revisionSha: "sha1" }, - ...overrides, - } -} - -function makeTask(overrides: Partial = {}): TaskState { - return { - id: "task-1", - name: "Test Task", - status: "running", - dependsOn: [], - area: "backend", - expectedFiles: [], - parallelSafe: false, - prNumber: null, - prCheckpoints: null, - blockedReason: null, - startedAt: "2026-01-01T00:00:00Z", - acceptanceCriteria: [{ id: "AC-1", description: "TDD test", verification: "tdd" }], - testCommands: [{ command: "npm test", cwd: ".", timeoutMs: 30000, env: {} }], - verifyCommands: [{ command: "npm run typecheck", cwd: ".", timeoutMs: 30000, env: {} }], - executionBinding: makeBinding(), - tddPolicy: makePolicy(), - tddEvidence: createTaskEvidence(), - coveragePolicy: null, - ...overrides, - } -} - -function makeCommandEvidence(overrides: Partial = {}): TddCommandEvidence { - return { - command: "npx vitest run test/foo.test.ts", - testSelector: "test/foo.test.ts", - exitCode: 1, - failureKind: "assertion", - testsCollected: 3, - testsFailed: 1, - startedAt: "2026-01-01T00:00:00Z", - finishedAt: "2026-01-01T00:00:01Z", - durationMs: 1000, - changedFiles: ["test/foo.test.ts"], - outputDigest: { algorithm: "sha256-output-v1", value: "a".repeat(64) }, - workspaceDigest: emptyDigest(), - executionInputDigest: emptyDigest(), - summary: "1/3 tests failed", - ...overrides, - } -} - -function passingEvidence(): TddCommandEvidence { - return makeCommandEvidence({ exitCode: 0, failureKind: null, testsFailed: 0, summary: "3/3 tests passed" }) -} - -// ─── 基础验证 ─── - -describe("handleTddCheckpoint — validation", () => { - it("rejects when task is not running", () => { - const task = makeTask({ status: "pending" }) - const req: TddCheckpointRequest = { op: "status", parentIssueNumber: 1, taskId: "task-1" } - - const resp = handleTddCheckpoint(req, task) - expect(resp.ok).toBe(false) - expect(resp.error).toBeDefined() - expect(resp.error!.code).toBe("TASK_NOT_RUNNING") - }) - - it("rejects when task has no executionBinding", () => { - const task = makeTask({ executionBinding: null }) - const req: TddCheckpointRequest = { op: "status", parentIssueNumber: 1, taskId: "task-1" } - - const resp = handleTddCheckpoint(req, task) - expect(resp.ok).toBe(false) - expect(resp.error!.code).toBe("TASK_NOT_FOUND") - }) - - it("rejects unknown op", () => { - const task = makeTask() - const req = { op: "unknown-op" } as unknown as TddCheckpointRequest - - const resp = handleTddCheckpoint(req, task) - expect(resp.ok).toBe(false) - expect(resp.error!.code).toBe("POLICY_INVALID") - }) -}) - -// ─── status op ─── - -describe("handleTddCheckpoint — status", () => { - it("returns current evidence revision and status", () => { - const task = makeTask() - const req: TddCheckpointRequest = { op: "status", parentIssueNumber: 1, taskId: "task-1" } - - const resp = handleTddCheckpoint(req, task) - expect(resp.ok).toBe(true) - expect(resp.evidenceRevision).toBe(0) - expect(resp.evidence).toBeDefined() - expect(resp.evidence!.status).toBe("not-recorded") - }) -}) - -// ─── cycle-start op ─── - -describe("handleTddCheckpoint — cycle-start", () => { - it("creates a new cycle", () => { - const task = makeTask() - const req: TddCheckpointRequest = { - op: "cycle-start", - parentIssueNumber: 1, - taskId: "task-1", - cycleId: "cycle-1", - criterionId: "AC-1", - testPaths: ["test/foo.test.ts"], - testSelector: "test/foo.test.ts", - } - - const resp = handleTddCheckpoint(req, task) - expect(resp.ok).toBe(true) - expect(resp.cycle).toBeDefined() - expect(resp.cycle!.cycleId).toBe("cycle-1") - expect(resp.cycle!.status).toBe("started") - expect(resp.evidenceRevision).toBeGreaterThan(0) - }) - - it("rejects for non-existent criterion", () => { - const task = makeTask() - const req: TddCheckpointRequest = { - op: "cycle-start", - parentIssueNumber: 1, - taskId: "task-1", - cycleId: "cycle-1", - criterionId: "NONEXISTENT", - testPaths: ["test/foo.test.ts"], - testSelector: "test/foo.test.ts", - } - - const resp = handleTddCheckpoint(req, task) - expect(resp.ok).toBe(false) - expect(resp.error!.code).toBe("CRITERION_NOT_FOUND") - }) - - it("rejects test paths not matching patterns", () => { - const task = makeTask() - const req: TddCheckpointRequest = { - op: "cycle-start", - parentIssueNumber: 1, - taskId: "task-1", - cycleId: "cycle-1", - criterionId: "AC-1", - testPaths: ["src/foo.ts"], - testSelector: "src/foo.ts", - } - - const resp = handleTddCheckpoint(req, task) - expect(resp.ok).toBe(false) - expect(resp.error!.code).toBe("POLICY_INVALID") - }) -}) - -// ─── red op ─── - -describe("handleTddCheckpoint — red", () => { - function setupCycle(task?: TaskState): TaskState { - const t = task ?? makeTask() - const req: TddCheckpointRequest = { - op: "cycle-start", - parentIssueNumber: 1, - taskId: "task-1", - cycleId: "cycle-1", - criterionId: "AC-1", - testPaths: ["test/foo.test.ts"], - testSelector: "test/foo.test.ts", - } - const resp = handleTddCheckpoint(req, t) - if (!resp.ok) throw new Error(`cycle-start failed: ${resp.error?.message}`) - // Update task with new evidence - return { ...t, tddEvidence: resp.evidence! } - } - - it("rejects when task has no runner policy", () => { - // 先创建 cycle(不需要 runner) - const task = setupCycle() - // 然后去掉 runner 测试 RED - const taskNoRunner = { - ...task, - tddPolicy: makePolicy({ runner: null }), - } - const req: TddCheckpointRequest = { - op: "red", - parentIssueNumber: 1, - taskId: "task-1", - cycleId: "cycle-1", - } - - const resp = handleTddCheckpoint(req, taskNoRunner) - expect(resp.ok).toBe(false) - expect(resp.error!.code).toBe("RUNNER_UNSUPPORTED") - }) - - it("records RED when valid failure evidence provided", () => { - const task = setupCycle() - const req: TddCheckpointRequest = { - op: "red", - parentIssueNumber: 1, - taskId: "task-1", - cycleId: "cycle-1", - // In production, evidence comes from executing vitest - // Here we simulate by passing it inline for testing - redEvidence: makeCommandEvidence({ failureKind: "assertion" }), - } - - const resp = handleTddCheckpoint(req, task) - expect(resp.ok).toBe(true) - expect(resp.cycle!.status).toBe("red") - expect(resp.cycle!.redAttempts).toHaveLength(1) - }) - - it("rejects when RED passes (exitCode 0)", () => { - const task = setupCycle() - const req: TddCheckpointRequest = { - op: "red", - parentIssueNumber: 1, - taskId: "task-1", - cycleId: "cycle-1", - redEvidence: makeCommandEvidence({ exitCode: 0, failureKind: null }), - } - - const resp = handleTddCheckpoint(req, task) - expect(resp.ok).toBe(false) - expect(resp.error!.code).toBe("RED_EXPECTED_FAILURE") - }) - - it("rejects infrastructure failure as RED", () => { - const task = setupCycle() - const req: TddCheckpointRequest = { - op: "red", - parentIssueNumber: 1, - taskId: "task-1", - cycleId: "cycle-1", - redEvidence: makeCommandEvidence({ failureKind: "infrastructure" }), - } - - const resp = handleTddCheckpoint(req, task) - expect(resp.ok).toBe(false) - expect(resp.error!.code).toBe("RED_INFRASTRUCTURE_FAILURE") - }) -}) - -// ─── green op ─── - -describe("handleTddCheckpoint — green", () => { - function setupWithRed(task?: TaskState): TaskState { - const t = task ?? makeTask() - // cycle-start - let r = handleTddCheckpoint( - { op: "cycle-start", parentIssueNumber: 1, taskId: "task-1", cycleId: "cycle-1", criterionId: "AC-1", testPaths: ["test/foo.test.ts"], testSelector: "test/foo.test.ts" }, - t, - ) - if (!r.ok) throw new Error(`cycle-start failed: ${r.error?.message}`) - t.tddEvidence = r.evidence! - - // record red - r = handleTddCheckpoint( - { op: "red", parentIssueNumber: 1, taskId: "task-1", cycleId: "cycle-1", redEvidence: makeCommandEvidence() }, - t, - ) - if (!r.ok) throw new Error(`red failed: ${r.error?.message}`) - t.tddEvidence = r.evidence! - return t - } - - it("rejects when task has no runner policy", () => { - // 用有 runner 的 task 先记录 RED - const task = setupWithRed(makeTask()) - - // 然后去掉 runner 来测试 GREEN - const taskNoRunner = { - ...task, - tddPolicy: makePolicy({ runner: null }), - } - - const req: TddCheckpointRequest = { - op: "green", - parentIssueNumber: 1, - taskId: "task-1", - cycleId: "cycle-1", - } - - const resp = handleTddCheckpoint(req, taskNoRunner) - expect(resp.ok).toBe(false) - expect(resp.error!.code).toBe("RUNNER_UNSUPPORTED") - }) - - it("records GREEN when tests pass and implementation changed", () => { - const task = setupWithRed() - const greenEvidence = passingEvidence() - greenEvidence.changedFiles = ["src/foo.ts"] - - const req: TddCheckpointRequest = { - op: "green", - parentIssueNumber: 1, - taskId: "task-1", - cycleId: "cycle-1", - greenEvidence, - } - - const resp = handleTddCheckpoint(req, task) - expect(resp.ok).toBe(true) - expect(resp.cycle!.status).toBe("pass") - expect(resp.cycle!.greenAttempts).toHaveLength(1) - }) - - it("rejects when GREEN test still fails", () => { - const task = setupWithRed() - const greenEvidence = makeCommandEvidence({ exitCode: 1, failureKind: "assertion" }) - - const req: TddCheckpointRequest = { - op: "green", - parentIssueNumber: 1, - taskId: "task-1", - cycleId: "cycle-1", - greenEvidence, - } - - const resp = handleTddCheckpoint(req, task) - expect(resp.ok).toBe(false) - expect(resp.error!.code).toBe("GREEN_EXPECTED_PASS") - }) - - it("rejects when no implementation files changed", () => { - const task = setupWithRed() - const greenEvidence = passingEvidence() - greenEvidence.changedFiles = ["README.md"] // not matching implementationFilePatterns - - const req: TddCheckpointRequest = { - op: "green", - parentIssueNumber: 1, - taskId: "task-1", - cycleId: "cycle-1", - greenEvidence, - } - - const resp = handleTddCheckpoint(req, task) - expect(resp.ok).toBe(false) - expect(resp.error!.code).toBe("IMPLEMENTATION_CHANGE_REQUIRED") - }) -}) - -// ─── final-regression op ─── - -describe("handleTddCheckpoint — final-regression", () => { - it("rejects when no test commands configured", () => { - const task = makeTask({ testCommands: [] }) - const req: TddCheckpointRequest = { op: "final-regression", parentIssueNumber: 1, taskId: "task-1" } - - const resp = handleTddCheckpoint(req, task) - expect(resp.ok).toBe(false) - expect(resp.error!.code).toBe("REGRESSION_FAILED") - }) - - it("records regression runs (via inline evidence for testing)", () => { - const task = makeTask() - const req: TddCheckpointRequest = { - op: "final-regression", - parentIssueNumber: 1, - taskId: "task-1", - regressionRuns: [ - makeCommandEvidence({ exitCode: 0, failureKind: null, testsFailed: 0 }), - ], - } - - const resp = handleTddCheckpoint(req, task) - expect(resp.ok).toBe(true) - expect(resp.evidence!.regression.status).toBe("pass") - expect(resp.evidenceRevision).toBeGreaterThan(0) - }) - - it("records regression as fail when a run fails", () => { - const task = makeTask() - const req: TddCheckpointRequest = { - op: "final-regression", - parentIssueNumber: 1, - taskId: "task-1", - regressionRuns: [ - makeCommandEvidence({ exitCode: 1, failureKind: "assertion", testsFailed: 1 }), - ], - } - - const resp = handleTddCheckpoint(req, task) - expect(resp.ok).toBe(true) - expect(resp.evidence!.regression.status).toBe("fail") - }) -}) - -// ─── final-verification op ─── - -describe("handleTddCheckpoint — final-verification", () => { - it("records verification as pass when runs succeed", () => { - const task = makeTask() - const req: TddCheckpointRequest = { - op: "final-verification", - parentIssueNumber: 1, - taskId: "task-1", - verificationRuns: [ - makeCommandEvidence({ exitCode: 0, failureKind: null, testsFailed: 0 }), - ], - } - - const resp = handleTddCheckpoint(req, task) - expect(resp.ok).toBe(true) - expect(resp.evidence!.verification.status).toBe("pass") - }) - - it("records verification as fail when a run fails", () => { - const task = makeTask() - const req: TddCheckpointRequest = { - op: "final-verification", - parentIssueNumber: 1, - taskId: "task-1", - verificationRuns: [ - makeCommandEvidence({ exitCode: 1, failureKind: "unknown" }), - ], - } - - const resp = handleTddCheckpoint(req, task) - expect(resp.ok).toBe(true) - expect(resp.evidence!.verification.status).toBe("fail") - }) - - it("allows empty verification when no verifyCommands", () => { - const task = makeTask({ verifyCommands: [] }) - const req: TddCheckpointRequest = { - op: "final-verification", - parentIssueNumber: 1, - taskId: "task-1", - } - - const resp = handleTddCheckpoint(req, task) - expect(resp.ok).toBe(true) - expect(resp.evidence!.verification.status).toBe("pass") - }) -}) - -// ─── abandon-cycle op ─── - -describe("handleTddCheckpoint — abandon-cycle", () => { - it("abandons a started cycle", () => { - const task = makeTask() - // cycle-start first - let resp = handleTddCheckpoint( - { op: "cycle-start", parentIssueNumber: 1, taskId: "task-1", cycleId: "cycle-1", criterionId: "AC-1", testPaths: ["test/foo.test.ts"], testSelector: "test/foo.test.ts" }, - task, - ) - expect(resp.ok).toBe(true) - task.tddEvidence = resp.evidence! - - // abandon - resp = handleTddCheckpoint( - { op: "abandon-cycle", parentIssueNumber: 1, taskId: "task-1", cycleId: "cycle-1", reason: "wrong test design" }, - task, - ) - expect(resp.ok).toBe(true) - expect(resp.evidence!.cycles[0].status).toBe("abandoned") - expect(resp.evidence!.warnings).toContain("wrong test design") - }) - - it("rejects abandoning a non-existent cycle", () => { - const task = makeTask() - const resp = handleTddCheckpoint( - { op: "abandon-cycle", parentIssueNumber: 1, taskId: "task-1", cycleId: "nonexistent", reason: "oops" }, - task, - ) - - expect(resp.ok).toBe(false) - expect(resp.error!.code).toBe("CYCLE_CONFLICT") - }) -}) - -// ─── idempotency ─── - -describe("handleTddCheckpoint — idempotency", () => { - it("same cycle-start with same workspace digest returns same evidence revision", () => { - const task = makeTask() - const req: TddCheckpointRequest = { - op: "cycle-start", - parentIssueNumber: 1, taskId: "task-1", cycleId: "cycle-1", - criterionId: "AC-1", testPaths: ["test/foo.test.ts"], testSelector: "test/foo.test.ts", - } - - const r1 = handleTddCheckpoint(req, task) - expect(r1.ok).toBe(true) - const rev1 = r1.evidenceRevision - - // Update task with new evidence for idempotency check - const taskAfter = { ...task, tddEvidence: r1.evidence! } - const r2 = handleTddCheckpoint(req, taskAfter) - expect(r2.ok).toBe(true) - // Should be idempotent (same evidence, same revision) - expect(r2.evidenceRevision).toBe(rev1) - }) -}) diff --git a/test/prompts.test.ts b/test/prompts.test.ts index 2062a07..a6a3093 100644 --- a/test/prompts.test.ts +++ b/test/prompts.test.ts @@ -10,10 +10,9 @@ function assetPath(name: string): string { describe("setupSkillsDir with prompts", () => { it("copies _prompts into skills temp dir", async () => { const sourceSkills = assetPath("skills") - const contextDir = assetPath("context") const promptsDir = assetPath("prompts") - const destDir = await setupSkillsDir(sourceSkills, contextDir, promptsDir) + const destDir = await setupSkillsDir(sourceSkills, promptsDir) const promptsPath = path.join(destDir, "_prompts") await expect(access(promptsPath)).resolves.toBeUndefined() @@ -24,29 +23,17 @@ describe("setupSkillsDir with prompts", () => { expect(files).toContain("bootstrap.md") }) - it("copies _context into skills temp dir", async () => { + it("flow-design SKILL.md references project root CONTEXT.md(内置 _context 已删除)", async () => { const sourceSkills = assetPath("skills") - const contextDir = assetPath("context") - - const destDir = await setupSkillsDir(sourceSkills, contextDir) - - const contextPath = path.join(destDir, "_context") - await expect(access(contextPath)).resolves.toBeUndefined() - - const files = await readdir(contextPath) - expect(files).toContain("CONTEXT.md") - }) - - it("flow-design SKILL.md references _context/CONTEXT.md", async () => { - const sourceSkills = assetPath("skills") - const contextDir = assetPath("context") const promptsDir = assetPath("prompts") - const destDir = await setupSkillsDir(sourceSkills, contextDir, promptsDir) + const destDir = await setupSkillsDir(sourceSkills, promptsDir) const skillPath = path.join(destDir, "flow-design", "SKILL.md") const content = await readFile(skillPath, "utf8") - expect(content).toContain("_context/CONTEXT.md") + // 批 15:内置 context 注入删除,flow-design 应引用仓库根 CONTEXT.md + expect(content).not.toContain("_context/CONTEXT.md") + expect(content).toContain("CONTEXT.md") }) it("flow-requirements SKILL.md embeds decision-map and grilling concepts", async () => { @@ -63,7 +50,7 @@ describe("setupSkillsDir with prompts", () => { it("gracefully handles missing promptsDir", async () => { const sourceSkills = assetPath("skills") - const destDir = await setupSkillsDir(sourceSkills, undefined, "/nonexistent/prompts") + const destDir = await setupSkillsDir(sourceSkills, "/nonexistent/prompts") // Should not throw; _prompts dir simply won't exist expect(destDir).toBeTruthy() }) diff --git a/test/skills.test.ts b/test/skills.test.ts index d23f464..01865ef 100644 --- a/test/skills.test.ts +++ b/test/skills.test.ts @@ -41,20 +41,6 @@ describe("setupSkillsDir", () => { expect(content).toContain("# flow-test") }) - it("copies context directory when provided", async () => { - const srcDir = path.join(tmpDir, "skills-src") - fs.mkdirSync(srcDir, { recursive: true }) - createSkill(srcDir, "x", "# x") - - const contextDir = path.join(tmpDir, "context") - fs.mkdirSync(contextDir, { recursive: true }) - fs.writeFileSync(path.join(contextDir, "CONTEXT.md"), "# Context", "utf8") - - const result = await setupSkillsDir(srcDir, contextDir) - - expect(fs.existsSync(path.join(result, "_context", "CONTEXT.md"))).toBe(true) - }) - it("replaces .opencode/skills/ references in SKILL.md", async () => { const srcDir = path.join(tmpDir, "skills-src") fs.mkdirSync(srcDir, { recursive: true }) @@ -67,7 +53,7 @@ describe("setupSkillsDir", () => { expect(content).not.toContain(".opencode/skills/") }) - it("handles non-existent context dir gracefully", async () => { + it("handles non-existent prompts dir gracefully(签名不再接收 context)", async () => { const srcDir = path.join(tmpDir, "skills-src") fs.mkdirSync(srcDir, { recursive: true }) createSkill(srcDir, "a", "# a") From 06423109b5d86ad50f3c623ec433c94aae08e900 Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sat, 1 Aug 2026 06:39:17 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20README.zh.md=20=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=E7=AB=A0=E8=8A=82=E5=90=8C=E6=AD=A5=E8=96=84=E5=86=85=E6=A0=B8?= =?UTF-8?q?=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.zh.md | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/README.zh.md b/README.zh.md index b6aa538..7fd7b69 100644 --- a/README.zh.md +++ b/README.zh.md @@ -58,13 +58,31 @@ npm install @devcxl/opencode-cabbage src/ # TypeScript 薄层 ├── index.ts # 插件入口 ├── plugin.ts # 包路径解析 -└── plugin/ - ├── server.ts # 主工厂:注入 skills/commands/agents - ├── commands.ts # Command 加载器 - ├── skills.ts # Skill 加载器 - ├── prompts.ts # Prompt 加载器 - ├── bootstrap.ts # 启动引导 - └── agents.ts # Agent 注入 +├── plugin/ # 加载器 + 工具工厂 +│ ├── server.ts # 主工厂:注入 skills/commands/agents/5 个生命周期工具 +│ ├── commands.ts # Command 加载器 +│ ├── skills.ts # Skill 加载器 +│ ├── prompts.ts # Prompt 加载器 +│ ├── bootstrap.ts # 启动引导 +│ ├── agents.ts # Agent 注入 +│ ├── setup-control.ts # setup_control 工具 +│ ├── flow-control.ts # flow_control 工具 +│ ├── task-control.ts # task_control 工具 +│ ├── tdd-checkpoint.ts # tdd_checkpoint 工具 +│ └── release-control.ts # release_control 工具 +└── kernel/ # 确定性薄内核 + ├── slug.ts # Functional Slug 派生/校验 + ├── records.ts # Flow/Task Record CRUD(GitHub 权威源) + ├── worktree.ts # Worktree 生命周期(无 --force) + ├── tdd/ # Runtime TDD 纯函数 + evidence + ├── review.ts # 合并门禁(CI/分支保护/风险/approval) + ├── release.ts # 版本提议 + tag 不可变校验 + ├── context.ts # 根 CONTEXT.md 发现/注入 + ├── profile.ts # AGENTS.md Project Profile 解析 + ├── session-index.ts # Flow→session 索引 + takeover + ├── caller.ts # 调用者角色矩阵 + ├── permission.ts # 权限匹配语义 + └── legacy.ts # 旧 FlowRun 检测 assets/ # 运行时资源 ├── commands/ # 7 个 slash command