diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..ab35d72 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,24 @@ +name: Publish + +on: + push: + tags: ['v*'] + +jobs: + publish: + runs-on: ubuntu-latest + environment: npm + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + registry-url: https://registry.npmjs.org + - run: npm ci + - run: npm run build + - run: npx vitest run + - run: npm publish --provenance --access public diff --git a/.gitignore b/.gitignore index d12e018..b57e876 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ !.prettierrc !.node-version !README.md +!LICENSE !tsconfig.json !package.json !package-lock.json @@ -21,10 +22,20 @@ !examples/** !scripts/ !scripts/** +!tests/ +!tests/** +!.github/ +!.github/** + +# Within allowed dirs, re-ignore all hidden directories then whitelist .svp/ and .github/ +**/.*/ +!**/.svp/ +!**/.svp/** !.github/ !.github/** # Re-ignore generated/temp files within allowed dirs +tests/e2e/report-* *.swp *.kate-swp *.swo diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f36d3c8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 SVP Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index b35f938..08de2f8 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ L1 Code 最终实现 ## 定位 -SVP 不自己调 AI API,不造编译器。SVP 是 AI 编码工具(Claude Code、Cursor 等)的**增强层**: +SVP 不自己调 AI API,不造编译器。SVP 是 AI 编码工具(Claude Code、Cursor、Windsurf、Kimi Code、Codex、GitHub Copilot)的**增强层**: - **工具链**:`svp check`(校验)、store(读写)、hash(变更追踪) - **Skills**:基于五层数据模型生成结构化 context,喂给用户已有的 AI 工具 @@ -59,10 +59,6 @@ SVP 不自己调 AI API,不造编译器。SVP 是 AI 编码工具(Claude Cod 详见 [check 错误码参考](docs/check-reference.md)。 -## svp-blueprint - -L4/L3 的可视化编辑器——用节点图编辑流程和逻辑块。它不是一个"层",是数据模型的一种编辑视图。 - ## 开发 ```bash @@ -75,9 +71,6 @@ npm run check # tsc + eslint + prettier - [架构](docs/architecture.md) — 五层数据模型、变更传播、工具链、目录结构 - [设计理由](docs/design-rationale.md) — 为什么这么设计,每个决策的推理过程 -- [节点规范](docs/node-spec.md) — 如何定义节点(svp-blueprint 编辑格式) -- [编译映射](docs/compilation.md) — 节点图到 SVP 数据模型的转换规则 -- [svp-blueprint 设计](docs/design.md) — svp-blueprint 解决什么问题 - [代码风格](docs/code-style.md) — 开发规范(命名、测试、git、依赖、版本) - [交互架构](docs/interaction.md) — 逐层渗透模型、虚拟文件树、聚焦视图、编译计划 - [check 错误码](docs/check-reference.md) — svp check 的所有错误/警告及修复建议 @@ -86,7 +79,10 @@ npm run check # tsc + eslint + prettier ``` packages/ -└── core/ 五层数据模型的 TypeScript 类型定义 + 核心函数 +├── core/ 五层数据模型的 TypeScript 类型定义 + 核心函数 +├── skills/ Prompt 生成器(design-l3、compile、recompile 等) +└── cli/ CLI 入口(svp 命令) docs/ 设计文档 + 开发规范 +examples/ 示例项目(hello-world、order-service) ``` diff --git a/docs/design.md b/docs/design.md index c04d898..c16b0c7 100644 --- a/docs/design.md +++ b/docs/design.md @@ -191,6 +191,21 @@ wires: 类型定义放在 `types/` 目录,是标准 TypeScript interface。Pin.type 直接引用这些类型名称。 +## 蓝图查看器 + +`svp blueprint` 命令是 svp-blueprint 的只读可视化入口——读取 `.svp/` 下已编译的 L3/L4 JSON,生成自包含 HTML 节点图在浏览器中查看。 + +设计要点: + +- **UE Blueprint 风格暗色主题**:深色背景 + 点阵网格,节点卡片用深色调,减少视觉疲劳 +- **节点即契约盒**:每个节点显示 L3 block 的 name、description(2 行截断)、pins(按类型着色),点击展开查看 validate 和 constraints +- **类型着色系统**:pin 圆点和数据流连线的颜色由类型名 hash 生成(HSL),同类型 pin 自动同色 +- **执行流标记**:节点头部左右各有三角形执行引脚(类似 UE Blueprint 的 exec pin),表示执行流方向 +- **三种 L4 变体**:Flow(主要,节点图)、EventGraph(事件处理器列表)、StateMachine(状态节点 + 转换边) +- **零依赖自包含**:单个 HTML 文件内联所有 CSS/JS/数据,无需服务器,离线可用 + +查看器不做编辑——编辑仍然通过 YAML 文件 + `svp compile-blueprint` 完成。查看器是数据的一种只读渲染视图。 + ## 不做什么 - **不做 AI 编译**:svp-blueprint 只负责生成 L4Flow/L3Block 的 JSON,后续 L3→L2→L1 由用户的 AI 编码工具(Claude Code 等)配合 SVP skills 完成 diff --git a/docs/node-spec.md b/docs/node-spec.md index d911a8d..bdff352 100644 --- a/docs/node-spec.md +++ b/docs/node-spec.md @@ -34,11 +34,11 @@ AI 是编译器,不是 parser。`"request.items: array, min 1, max 50"` 这种 ## 原子节点 ```yaml -name: string # 节点标识 +name: string # 节点标识 # ── 形式化层 ── -pins: # 数据形状 +pins: # 数据形状 input: - name: string type: string @@ -47,19 +47,19 @@ pins: # 数据形状 - name: string type: string -validate: # 输入约束 +validate: # 输入约束 field.path: rule [, rule ...] -constraints: # 输出约束 + 不变量 +constraints: # 输出约束 + 不变量 - assertion # ── 自由层 ── -description: string # 中间逻辑(自然语言) +description: string # 中间逻辑(自然语言) # ── 可选 ── -config: # 运行时可调参数 +config: # 运行时可调参数 key: { type, default } ``` @@ -107,20 +107,20 @@ validate: **规则列表:** -| 规则 | 含义 | 示例 | -|---|---|---| -| required | 不能为空/undefined | `user_id: required` | -| optional | 可以为空 | `coupon: optional` | -| 类型名 | 类型校验 | `age: number` | -| uuid | UUID 格式 | `id: uuid` | -| email | 邮箱格式 | `email: email` | -| url | URL 格式 | `link: url` | -| min N | 最小值/最小长度 | `items: array, min 1` | -| max N | 最大值/最大长度 | `name: string, max 100` | -| > N, >= N, < N, <= N | 数值比较 | `quantity: number, > 0` | -| == V | 精确相等 | `available: == true` | -| pattern /regex/ | 正则匹配 | `zip: pattern /^\d{6}$/` | -| one of [a, b, c] | 枚举 | `status: one of [active, inactive]` | +| 规则 | 含义 | 示例 | +| -------------------- | ------------------ | ----------------------------------- | +| required | 不能为空/undefined | `user_id: required` | +| optional | 可以为空 | `coupon: optional` | +| 类型名 | 类型校验 | `age: number` | +| uuid | UUID 格式 | `id: uuid` | +| email | 邮箱格式 | `email: email` | +| url | URL 格式 | `link: url` | +| min N | 最小值/最小长度 | `items: array, min 1` | +| max N | 最大值/最大长度 | `name: string, max 100` | +| > N, >= N, < N, <= N | 数值比较 | `quantity: number, > 0` | +| == V | 精确相等 | `available: == true` | +| pattern /regex/ | 正则匹配 | `zip: pattern /^\d{6}$/` | +| one of [a, b, c] | 枚举 | `status: one of [active, inactive]` | 每条规则有唯一解释,不存在歧义。 @@ -144,17 +144,17 @@ constraints: **语法(受限的断言语言):** -| 语法 | 含义 | 示例 | -|---|---|---| -| X >= Y, X == Y, ... | 比较 | `total >= 0` | -| X is type | 类型断言 | `order_id is uuid` | -| X is empty / not empty | 空值断言 | `errors is not empty` | -| if P then Q | 蕴含 | `if success then order_id is not empty` | -| P iff Q | 当且仅当 | `valid iff errors is empty` | -| count of X == N | 计数 | `count of output.items == count of input.items` | -| X contains Y | 包含 | `errors contains all failed checks` | -| all X satisfy P | 全称 | `all items[].quantity > 0` | -| any X satisfies P | 存在 | `any items[].price > 100` | +| 语法 | 含义 | 示例 | +| ---------------------- | -------- | ----------------------------------------------- | +| X >= Y, X == Y, ... | 比较 | `total >= 0` | +| X is type | 类型断言 | `order_id is uuid` | +| X is empty / not empty | 空值断言 | `errors is not empty` | +| if P then Q | 蕴含 | `if success then order_id is not empty` | +| P iff Q | 当且仅当 | `valid iff errors is empty` | +| count of X == N | 计数 | `count of output.items == count of input.items` | +| X contains Y | 包含 | `errors contains all failed checks` | +| all X satisfy P | 全称 | `all items[].quantity > 0` | +| any X satisfies P | 存在 | `any items[].price > 100` | constraints 可以引用 input 和 output,表达输入输出之间的关系。 @@ -264,12 +264,67 @@ wires: --- +## 模块化文档 (docs.md) + +每个节点目录和图文件可以附带一个可选的 `docs.md` 文件,提供超出 `description` 字段的丰富上下文。 + +### 文件位置 + +``` +nodes/my-node/ +├── node.yaml # 契约(pins, validate, constraints, description) +└── docs.md # 模块化文档(可选) + +graphs/ +├── main.yaml # 图定义 +└── main.docs.md # 图级文档(可选) +``` + +### 与 description 的关系 + +- **description** 是摘要(1-4 句话),嵌入 L3Block,参与编译流程 +- **docs.md** 是详述,独立文件,按需加载,不影响 contentHash + +### 推荐结构 + +```markdown +## Intent + +设计意图和业务背景。 + +## Edge Cases + +- 边界情况 1 +- 边界情况 2 + +## Error Strategy + +错误处理策略和降级方案。 + +## Integration Notes + +与其他节点/外部服务的集成约定。 + +## Examples + +输入输出示例。 +``` + +### 加载行为 + +- `svp prompt compile/recompile/review` 时自动加载并注入 prompt +- subagent 只拉自己需要的 docs,不加载全量 +- 不影响 `contentHash` 计算——docs 是编译辅助信息,不是契约的一部分 + +--- + ## 目录约定 ``` nodes/ ├── my-node/ -│ └── node.yaml # 原子节点 +│ ├── node.yaml # 原子节点 +│ └── docs.md # 模块化文档(可选) ├── my-pipeline/ │ ├── node.yaml # 复合节点(type: composite) │ └── nodes/ # 子节点 @@ -279,7 +334,8 @@ nodes/ │ └── node.yaml graphs/ -└── main.yaml # 顶层连线图 +├── main.yaml # 顶层连线图 +└── main.docs.md # 图级文档(可选) types/ └── *.ts # 引脚类型定义 @@ -289,19 +345,19 @@ types/ ## 编译映射总结 -| svp-blueprint | L3Block 字段 | 说明 | -|---|---|---| -| name | id | 直接映射 | -| pins.input | input: Pin[] | 直接映射 | -| pins.output | output: Pin[] | 直接映射 | -| validate | validate: Record | 直接映射 | -| constraints | constraints: string[] | 直接映射 | -| description | description: string | 直接映射 | -| (计算)| signature | 从 input/output 生成,不存储 | - -| svp-blueprint | L4Flow 字段 | 说明 | -|---|---|---| -| 复合节点 nodes | steps: Step[] | 子节点 → 步骤 | -| wires | steps[].next + dataFlows | 连线 → 执行顺序 + 数据流 | -| 扇出 | action: "parallel" 步骤 | 一出多入 → 并行 | -| 汇聚 | action: "wait" 步骤 | 多出一入 → 等待 | +| svp-blueprint | L3Block 字段 | 说明 | +| ------------- | -------------------------------- | ---------------------------- | +| name | id | 直接映射 | +| pins.input | input: Pin[] | 直接映射 | +| pins.output | output: Pin[] | 直接映射 | +| validate | validate: Record | 直接映射 | +| constraints | constraints: string[] | 直接映射 | +| description | description: string | 直接映射 | +| (计算) | signature | 从 input/output 生成,不存储 | + +| svp-blueprint | L4Flow 字段 | 说明 | +| -------------- | ------------------------ | ------------------------ | +| 复合节点 nodes | steps: Step[] | 子节点 → 步骤 | +| wires | steps[].next + dataFlows | 连线 → 执行顺序 + 数据流 | +| 扇出 | action: "parallel" 步骤 | 一出多入 → 并行 | +| 汇聚 | action: "wait" 步骤 | 多出一入 → 等待 | diff --git a/docs/phase2-plan.md b/docs/phase2-plan.md index 59e3283..27ae6e9 100644 --- a/docs/phase2-plan.md +++ b/docs/phase2-plan.md @@ -1,5 +1,7 @@ # SVP Phase 2: AI Skill 实现 & Claude Code 集成 — 完整计划 (v2 - 用户确认完整) +> **注意**:本文档是 Phase 2 的历史规划文档,当时仅支持 Claude Code。实际实现已扩展为支持 6 种 AI 编码工具(Claude Code、Cursor、Windsurf、Kimi Code、Codex、GitHub Copilot)的插件化 adapter 系统。当前架构详见 `packages/skills/adapters/` 和 `docs/project-introduction.md` 第 7.4 节。 + ## Context ### 现状 diff --git a/docs/project-introduction.md b/docs/project-introduction.md new file mode 100644 index 0000000..03d7f2f --- /dev/null +++ b/docs/project-introduction.md @@ -0,0 +1,731 @@ +# SVP — Semantic Voxel Protocol:让人类在 AI 写代码时不失控的五层观测框架 + +## 一、项目总览:这是什么? + +**SVP(Semantic Voxel Protocol)** 是一个全新的软件开发协议和工具链,旨在解决一个日益严峻的问题:**当 AI 生成越来越多的代码时,人类如何保持对系统的理解和控制?** + +SVP 不调用 AI API,不造编译器,不造编辑器。它是 AI 编码工具(Claude Code、Cursor 等)的 **增强层**——给 AI 喂更好的上下文,让人类能看懂 AI 在做什么。类似 [OpenSpec](https://github.com/Fission-AI/OpenSpec) 的定位——不造 AI,给 AI 喂更好的上下文。SVP 的能力随 base model 进化自动提升。 + +项目以 TypeScript monorepo 形式组织,使用 `vitest` 做测试,`eslint` + `prettier` 做代码质量保证,`commander` 构建 CLI。当前版本为 `0.1.0`,处于活跃开发阶段。 + +--- + +## 二、核心理念:五个设计原则 + +SVP 的设计哲学可以用五句话概括: + +### 1. 透明优先于正确 +SVP 不试图消灭 AI 的错误——这在当前技术条件下是不可能的。SVP 追求的是让错误**可见、可定位、可修复**。当系统出了问题,你可以逐层问下去:意图对吗?流程对吗?契约写够了吗?代码结构对吗?实现忠实吗?问到哪层答案变了,问题就在哪层。 + +### 2. AI 即编译器 +SVP 中大量使用自然语言——validate 规则、constraints 断言、description 描述——因为 **AI 不是 parser,它是编译器**。`"request.items: array, min 1, max 50"` 这样的字符串,人和 AI 都能直觉理解。把它结构化成 `{ field, rule, params }` 只是在给不存在的传统 parser 喂食,增加了复杂度却没有增加表达力。 + +### 3. 只做真正的声明式 +许多框架宣称是"声明式",实际上只是把命令式代码换了一种语法。SVP 拒绝伪声明式——如果一个规则不能被自动验证,不如用自然语言表达。`validate` 和 `constraints` 就是真正的声明式:它们只声明"什么必须为真",不关心"怎么实现"。 + +### 4. 存得少算得多 +SVP 严格遵循**单一数据源 + 计算属性**的原则。例如,`signature` 不存储,而是从 `input/output` pins 实时计算;`dataType` 不存储,而是从引用的 pin 类型推导。这消灭了不一致的温床。 + +### 5. 协议与实现分离 +SVP 是一个语言无关的规范。`packages/core/` 定义的 TypeScript 类型是协议的参考实现,但协议本身不绑定任何语言。L3 契约中的 `type` 字段引用 TypeScript interface(因为 AI 对 TypeScript 类型理解最好),但 L1 生成的代码可以是任何语言。 + +--- + +## 三、五层数据模型:系统的脊柱 + +SVP 的核心是一个五层数据模型,每层都是一个**观测窗口**: + +``` +L5 Blueprint 系统的意图和边界 —— 做什么? +L4 Logic Chain 流程如何编排 —— 怎么串? +L3 Logic Block 每个单元做什么 —— 契约是什么? +L2 Code Block L3 ↔ L1 的映射 —— 代码在哪? +L1 Code 最终的源代码 —— 怎么写的? +``` + +层间关系:`L5 ─聚合→ L4 ─聚合→ L3 ←1:1配对→ L2 ─聚合→ L1` + +本质上是三个角色: + +| 层次 | 谁定义 | 内容 | +|------|--------|------| +| **意图层** (L5 + L4) | 人类 | 做什么、怎么编排 | +| **契约层** (L3) | 人定义,AI 验证 | 每个单元的规格 | +| **实现层** (L2 + L1) | AI 生成,人验收 | 代码结构和源码 | + +**L3 是枢纽**——它是人和 AI 之间的接口。人在 L3 写清楚"我要什么",AI 在 L3 以下实现"怎么做"。工作流是严格单向向下的: + +``` +人定义 L5 意图 → 人定义 L4 流程 → 人定义 L3 契约 → AI 生成 L2+L1 代码 +``` + +AI 只负责从契约编译到代码,绝不回头修改上层意图。 + +### 3.1 L5 — Blueprint(系统蓝图) + +定义在 `packages/core/l5.ts` 中,L5 是整个系统的最高层抽象: + +```typescript +interface L5Blueprint { + id: string; + name: string; + version: string; + intent: string; // 核心问题 + 解决方案 + 成功标准(自然语言) + constraints: string[]; // 功能/非功能/业务约束 + domains: Domain[]; // 领域拓扑 + integrations: Integration[]; // 外部集成点 + language?: string; // 语言偏好(ISO 639-1) + revision: ArtifactVersion; + contentHash: string; +} +``` + +L5 回答的问题是:**这个系统要解决什么问题?它的边界在哪里?** + +`intent` 用自然语言描述核心意图,`constraints` 列出硬性约束,`domains` 描述领域拓扑(哪些子领域、它们之间的依赖关系),`integrations` 声明外部依赖(数据库、API、消息队列、存储)。 + +值得注意的是 `language` 字段——SVP 内置了国际化支持。设置 `language: "zh"` 后,所有 CLI 输出、check 报告、view 渲染都会切换为中文。 + +### 3.2 L4 — Logic Chain(流程编排) + +定义在 `packages/core/l4.ts` 中,L4 是 SVP 最丰富的一层,支持**三种变体**: + +#### Flow(有序管道) +最常见的变体,描述一个步骤序列: + +```typescript +interface L4Flow { + kind?: "flow"; + id: string; + name: string; + trigger?: Trigger; // 触发方式:http、event、schedule、manual + steps: Step[]; // 步骤序列 + dataFlows: DataFlow[]; // 步骤间的数据传递 + revision: ArtifactVersion; + contentHash: string; +} +``` + +每个 `Step` 有四种动作类型: +- `process`:执行一个 L3 block(通过 `blockRef`) +- `call`:调用另一个 L4 flow(通过 `flowRef`,实现复合节点) +- `parallel`:分支并行(通过 `branches`) +- `wait`:等待汇聚(通过 `waitFor`) + +`DataFlow` 用 `"stepId.pinName"` 格式描述数据如何在步骤之间流转。 + +#### EventGraph(事件驱动) +适用于事件驱动系统(如 CRDT 协同编辑): + +```typescript +interface L4EventGraph { + kind: "event-graph"; + id: string; + name: string; + state: Record; // 共享状态声明 + handlers: EventHandler[]; // 事件处理器 + revision: ArtifactVersion; + contentHash: string; +} +``` + +每个 `EventHandler` 响应一个事件,内部有自己的步骤链。dataFlow 支持特殊前缀 `$state.xxx` 和 `$event.xxx`,分别引用共享状态和事件负载。 + +#### StateMachine(状态机) +适用于有明确状态转换的业务实体(如订单生命周期): + +```typescript +interface L4StateMachine { + kind: "state-machine"; + id: string; + name: string; + entity: string; // 管理的实体类型 + initialState: string; + states: Record; // 状态配置(onEntry/onExit) + transitions: Transition[]; // 转换规则 + revision: ArtifactVersion; + contentHash: string; +} +``` + +每个状态可以有 `onEntry` 和 `onExit` 钩子(引用 L3 block),转换可以有 `guard`(也是 L3 block,返回 boolean)。 + +三种变体通过联合类型 `L4Artifact = L4Flow | L4EventGraph | L4StateMachine` 统一,使用 `getL4Kind()` 工具函数区分(向后兼容:无 `kind` 字段默认为 `"flow"`)。 + +### 3.3 L3 — Logic Block(契约盒) + +L3 是 SVP 的灵魂。每个 L3Block 是一个**契约盒子**: + +```typescript +interface L3Block { + id: string; + name: string; + input: Pin[]; // 输入引脚 + output: Pin[]; // 输出引脚 + validate: Record; // 输入约束 + constraints: string[]; // 输出约束 + 不变量 + description: string; // 中间逻辑(自然语言) + revision: ArtifactVersion; + contentHash: string; +} +``` + +一个节点有两层信息: + +**形式化层**(精确,机器可验证,不能丢): +- `pins` — 数据形状(类型系统) +- `validate` — 输入必须满足什么 +- `constraints` — 输出必须满足什么 + 不变量 + +**自由层**(自然语言,AI 自由理解和实现): +- `description` — 中间逻辑:怎么算、怎么分支、什么副作用 + +形式化层构成一个**契约盒子**:输入被 validate 夹住,输出被 constraints 夹住。盒子里面的实现交给 description + AI 编译器。AI 可能实现得不完美,但 validate 和 constraints 构成的契约可以自动验证——**不是帮 AI 理解,而是 AI 理解错了的时候能被发现。** + +`validate` 使用简洁的规则语法,如 `"request.items: array, min 1, max 50"`,支持 required、optional、类型名、uuid、email、url、min/max、比较操作符、pattern 正则、one of 枚举等。 + +`constraints` 使用受限的断言语言,如 `"if output.result.valid then output.result.errors is empty"`,支持比较、类型断言、空值断言、蕴含 (if/then)、当且仅当 (iff)、计数、包含、全称/存在量词等。 + +### 3.4 L2 — Code Block(代码映射) + +L2 是 L3 和 L1 之间的桥梁,记录每个 L3 block 对应哪些源文件: + +```typescript +interface L2CodeBlock { + id: string; + blockRef: string; // 对应的 L3 block ID + language: string; // 实现语言 + files: string[]; // 源文件路径 + sourceHash: string; // 编译时 L3 的 contentHash + signatureHash?: string; // L1 导出签名的哈希 + revision: ArtifactVersion; + contentHash: string; +} +``` + +`sourceHash` 记录"编译时 L3 长什么样",`signatureHash` 记录"L1 代码导出了什么"。当这两个哈希与当前实际值不匹配时,SVP 就能检测到"漂移"——要么 L3 改了代码没跟上(SOURCE_DRIFT),要么代码改了 L3 没同步(CONTENT_DRIFT)。 + +### 3.5 L1 — Code(最终源代码) + +L1 就是文件系统中的实际源代码。SVP 不直接管理 L1,而是通过 L2 的哈希机制跟踪它。 + +--- + +## 四、数据存储:.svp/ 目录 + +所有层数据存放在项目根目录的 `.svp/` 下: + +``` +.svp/ +├── l5.json # L5 蓝图(一个项目一份) +├── l4/ # L4 逻辑链(每个流程一个 JSON 文件) +│ └── order-flow.json +├── l3/ # L3 逻辑块(每个单元一个 JSON 文件) +│ └── validate-order.json +└── l2/ # L2 代码块(与 L3 一一配对) + └── validate-order.json +``` + +存储层由 `packages/core/store.ts` 实现,提供简洁的 CRUD API: + +- `readL5(root)` / `writeL5(root, blueprint)` +- `readL4(root, id)` / `writeL4(root, artifact)` / `listL4(root)` +- `readL3(root, id)` / `writeL3(root, block)` / `listL3(root)` +- `readL2(root, id)` / `writeL2(root, codeBlock)` / `listL2(root)` +- `readNodeDocs(root, nodeId)` / `readGraphDocs(root, graphName)` + +所有数据以格式化 JSON 存储(`JSON.stringify(data, null, 2)`),方便 diff 和人工审查。 + +--- + +## 五、核心引擎:check、compile-plan、hash + +### 5.1 svp check — 层间一致性校验 + +`packages/core/check.ts` 实现了 SVP 最关键的功能——跨层一致性校验。check 是一个纯函数,接收所有层的数据,输出问题报告: + +```typescript +function check(input: CheckInput, language?: string): CheckReport +``` + +校验分为四大类: + +**1. Hash 一致性** — `HASH_MISMATCH` +每个层的 `contentHash` 必须与实际内容匹配。编辑数据后忘了 rehash,这里就会报错。check 对 L5、L4、L3、L2 四层都做 hash 验证。 + +**2. 引用完整性** — `MISSING_BLOCK_REF` / `MISSING_FLOW_REF` / `MISSING_STEP_REF` / `MISSING_PIN` +所有层间引用必须指向存在的实体。这包括: +- L4 Flow 的 step.blockRef → L3 必须存在 +- L4 Flow 的 step.flowRef → L4 必须存在 +- L4 step 的 next/branches/waitFor → 同 flow 内的 step 必须存在 +- L4 dataFlow 的 stepId.pinName → step 必须存在且对应 L3 block 上有该 pin +- L4 EventGraph 的 $state.xxx → state 声明中必须有该 key +- L4 StateMachine 的 onEntry/onExit/guard → L3 必须存在 +- L4 StateMachine 的 transition from/to → state 必须存在 +- L2 blockRef → L3 必须存在 + +**3. 漂移检测** — `SOURCE_DRIFT` / `CONTENT_DRIFT` +- SOURCE_DRIFT:L3 改了,L2 还没重编译(L2.sourceHash ≠ L3.contentHash) +- CONTENT_DRIFT:L1 代码改了,L2 记录的签名过时(L2.signatureHash ≠ 实际导出签名) + +**4. 图结构合法性** — `NEXT_CYCLE` / `ORPHAN_STEP` / `SELF_REFERENCING_FLOW` / `DUPLICATE_EVENT` / `UNREACHABLE_STATE` 等 +- Flow/EventGraph:检测 next 链中的循环、不可达的孤立步骤、自引用 +- EventGraph:检测重复事件处理器、空 state 声明 +- StateMachine:检测无效 initialState、无效转换、不可达状态 + +每个问题都有 severity(error/warning)、layer、entityId、code、message。错误信息通过 i18n 系统支持英文和中文。 + +### 5.2 svp compile-plan — 智能任务规划 + +`packages/core/compile-plan.ts` 基于 check 的漂移检测,自动生成结构化的编译任务清单: + +```typescript +function compilePlan(input: CheckInput, language?: string): CompilePlan +``` + +它检测四类场景: + +1. **缺失编译** — 有 L3 但没有对应 L2 → 生成 `compile` 任务 +2. **重编译** — L3 变了,L2 的 sourceHash 过期 → 生成 `recompile` 任务 +3. **内容漂移** — L1 导出签名变了 → 生成 `review` 任务 +4. **断裂引用** — L4 引用不存在的 L3 → 生成 `update-ref` 任务 + +每个任务包含: +- `action`:compile / recompile / update-ref / review +- `targetLayer` + `targetId`:目标层和实体 +- `reason`:人类可读的原因 +- `context`:AI subagent 需要参考的上下文引用列表 +- `complexity`:heavy / standard / light(指导 AI 分配资源) + +compile-plan 还做去重——同一个 target 可能被多个检测器命中,只保留一条任务。 + +### 5.3 Hash 系统 + +SVP 的变更追踪基于内容哈希。每个层的数据对象(去掉 `contentHash`、`revision`、`sourceHash` 等元数据字段)计算出一个哈希值。对象先 JSON 序列化(key 排序确保确定性),然后用 SHA-256 截取前 16 位十六进制。 + +`packages/core/hash.ts` 提供了 `computeHash`、`hashL3`、`hashL4`、`hashL5`、`hashL2` 等函数。rehash 后 `svp check` 就能通过 hash 一致性检查。 + +--- + +## 六、CLI 工具链:svp 命令 + +`packages/cli/index.ts` 使用 Commander.js 构建了一个完整的 CLI,包含 9 个顶层命令: + +### 6.1 svp init +初始化 `.svp/` 目录结构 + 写入初始 L5 blueprint: +```bash +svp init --name my-app --intent "做什么" --host +``` +- 创建 `.svp/l2/`、`.svp/l3/`、`.svp/l4/` 目录 +- 生成初始 L5 blueprint(自动检测系统语言) +- 如果指定 `--host `(支持 `claude-code`、`cursor`、`windsurf`、`kimi-code`、`codex`、`github-copilot`),会在对应工具目录生成 skill 文件并追加上下文配置 + +### 6.2 svp check +运行跨层一致性校验: +```bash +svp check --json +``` +加载所有层数据,调用 `check()` 纯函数,输出问题报告。支持 `--json` 结构化输出。 + +### 6.3 svp view +查看层数据的人类友好文本视图: +```bash +svp view l5 # L5 蓝图概览 +svp view l4 # 所有 L4 流程概览 +svp view l4 order-flow # L4 流程详情 +svp view l3 # 所有 L3 块概览 +svp view l3 greet # L3 块详情 +svp view l2 # 所有 L2 代码块 +``` +Overview 模式把每个实体压缩成一行,Detail 模式展示完整信息。视图由 `packages/core/view.ts` 的纯函数渲染,支持 i18n。 + +### 6.4 svp compile-plan +生成编译任务清单: +```bash +svp compile-plan --json +``` +输出哪些 L3 block 需要编译、重编译、审查或修复引用。 + +### 6.5 svp prompt +生成上下文感知的 AI 提示词——这是 SVP 与 AI 工具交互的核心接口。包含 7 个子命令: + +**任务类(操作已有数据):** +```bash +svp prompt compile # L3 → L1 首次编译 +svp prompt recompile # L3 变更后重编译 +svp prompt review # L1 手改后审查漂移 +svp prompt update-ref # 修复断裂的 L4 引用 +``` + +**设计类(创建新层数据):** +```bash +svp prompt design-l5 --intent "描述系统意图" +svp prompt design-l4 --intent "描述流程" --kind flow|event-graph|state-machine +svp prompt design-l3 --flow --step --intent "描述单元" +``` + +每个 prompt 子命令的流程是:加载 `.svp/` 状态 → 构造合成 CompileTask → resolve 上下文 → buildPrompt 构建结构化提示 → renderPrompt 渲染为 markdown → stdout 输出。 + +### 6.6 svp rehash +重算哈希值: +```bash +svp rehash l3 # 重算所有 L3 的 contentHash +svp rehash l3/greet # 重算指定 L3 +svp rehash l4 # 重算所有 L4 +svp rehash l5 # 重算 L5 +``` + +### 6.7 svp link +创建/更新 L2 映射: +```bash +svp link greet --files src/greet.ts +``` + +### 6.8 svp compile-blueprint +编译 svp-blueprint 节点格式(YAML → JSON)。 + +### 6.9 svp blueprint +生成自包含的 HTML 可视化节点图查看器: +```bash +svp blueprint # 当前目录,打开浏览器 +svp blueprint -r examples/order-service # 指定项目 +svp blueprint -o blueprint.html # 输出到文件 +``` + +--- + +## 七、Skills 系统:AI Prompt 工程 + +`packages/skills/` 是 SVP 的 AI 交互层——它不调用 AI API,而是生成高质量的结构化 prompt,喂给用户已有的 AI 工具。 + +### 7.1 Skill 类型系统 + +定义在 `packages/core/skill.ts` 中,Skill 被设计为纯函数:`(task + context) → artifacts + notes` + +```typescript +interface SkillInput { + task: CompileTask; // 什么任务 + resolved: ResolvedContext; // 预解析好的上下文数据 + config: SkillConfig; // 行为约束 +} + +interface SkillResult { + action: TaskAction; + status: "done" | "needs-review" | "blocked"; + artifacts: Artifact[]; // 生成的层制品 + notes: string; // AI 给人类的说明 +} +``` + +编排层负责所有 IO(解析上下文、写磁盘、跑收敛循环),Skill 本身不做 IO。 + +### 7.2 Prompt Builder + +`packages/skills/prompt-builder.ts` 将 SkillInput 转换为结构化的 markdown prompt: + +```typescript +interface StructuredPrompt { + role: string; // 角色定义 + context: string; // L5 概览等项目上下文 + task: string; // 具体任务描述 + input: string; // L3 契约、L2 映射、L1 代码等 + outputSpec: string; // 输出规格要求 + rules: string; // 通用规则 + 语言指令 + complexity: Complexity; // heavy / standard / light +} +``` + +四种动作有各自的角色定义: +- **compile**:"You are an SVP compiler subagent. Your job is to implement L1 source code from an L3 contract specification." +- **recompile**:"You are an SVP recompiler subagent. An L3 contract has changed — update the L1 source code to match while preserving unchanged logic." +- **review**:"You are an SVP review subagent. L1 code has drifted from its L3 contract. Analyze the difference and recommend." +- **update-ref**:"You are an SVP reference repair subagent. An L4 flow references an L3 block that doesn't exist." + +通用规则强制执行 SVP 的核心不变量: +- 严格向下只写——不修改上层 +- 使用 `svp rehash` 修复 contentHash +- 使用 `svp link` 创建 L2 映射 +- 保持实现最小化——满足契约,仅此而已 + +### 7.3 Design Prompts + +除了任务类 prompt,SVP 还有三个设计类 prompt 生成器: + +- `design-l5.ts` — 生成 L5 蓝图设计 prompt,注入 L5Blueprint 类型定义和设计指导 +- `design-l4.ts` / `design-l4-event-graph.ts` / `design-l4-state-machine.ts` — 生成三种 L4 变体的设计 prompt +- `design-l3.ts` — 生成 L3 契约设计 prompt,注入前后步骤的上下文 + +每个设计 prompt 都会注入相应层的 TypeScript 类型定义作为 schema 参考,并提供具体的设计指导。 + +### 7.4 多工具集成(Adapter 系统) + +`packages/skills/adapters/` 实现了插件化的多工具适配器系统,支持 6 种 AI 编码工具: + +| Host | Skill 目录 | 上下文文件 | +|------|-----------|-----------| +| `claude-code` | `.claude/commands/` | `CLAUDE.md` | +| `cursor` | `.cursor/commands/` | `.cursor/rules/svp.mdc` | +| `windsurf` | `.windsurf/commands/` | `.windsurf/rules/svp.md` | +| `kimi-code` | `.agents/skills/` | `AGENTS.md` | +| `codex` | `.codex/skills/` | `AGENTS.md` | +| `github-copilot` | `.github/prompts/` | `.github/copilot-instructions.md` | + +核心设计:所有 SVP 协议内容(工作流、CLI 命令、五层模型)集中在 `shared.ts` 维护,每个 adapter 只提供路径、模型名称等 host 特定配置。SVP 协议演进时只需改 `shared.ts`,所有 adapter 自动同步。 + +初始化时指定 `--host `,对应的 skill 文件和上下文配置会自动生成。不指定 `--host` 时,SVP 会自动检测项目中的工具标记。 + +--- + +## 八、蓝图查看器:可视化节点图 + +`packages/viewer/` 实现了一个 **Unreal Engine Blueprint 风格**的可视化查看器——暗色主题 + 网格背景,节点卡片用深色调,类型着色连线。 + +查看器特性: +- **节点即契约盒** — 每个节点显示 L3 block 的 name、description(2 行截断)、pins(按类型着色),点击展开查看 validate 和 constraints +- **类型着色系统** — pin 圆点和数据流连线的颜色由类型名 hash 生成(HSL),同类型 pin 自动同色 +- **执行流标记** — 节点头部左右各有三角形执行引脚,表示执行流方向 +- **三种 L4 变体** — 支持 Flow、EventGraph、StateMachine 三种不同的图渲染 +- **零依赖自包含** — 单个 HTML 文件内联所有 CSS/JS/数据,无需服务器,离线可用 +- **平移/缩放** — 鼠标拖拽平移,滚轮缩放 +- **侧栏导航** — 项目概览、L4 列表、L3 列表、健康仪表盘 +- **搜索** — 全局搜索节点和流程 + +查看器使用 Vite + React/Preact 构建(开发模式),但最终产物是一个自包含的 HTML 文件。 + +--- + +## 九、国际化系统 + +`packages/core/i18n.ts` 实现了一个轻量的消息目录系统: + +- `t(lang, key, params?)` — 查找翻译,英文兜底,支持 `{param}` 插值 +- `detectSystemLanguage()` — 从 `LANG`/`LC_ALL` 环境变量检测语言 +- `getLanguage(l5?)` — 从 L5 提取语言偏好,缺省用系统语言 +- `languageDirective(lang)` — 在 AI prompt 中注入语言输出指令 + +当前支持英文和中文两种语言,覆盖了 check 错误消息、compile-plan 原因描述、view 渲染标签、CLI 提示信息、viewer UI 文本等所有用户可见的字符串。 + +这意味着:如果你的 L5 设置了 `language: "zh"`,整个工具链——从 `svp check` 的错误报告到 `svp view` 的数据展示再到 AI prompt 中的语言指令——都会切换为中文。 + +--- + +## 十、示例项目 + +### 10.1 Hello World + +`examples/hello-world/` 是最小的 SVP 项目:接收名字 → 生成问候语 → 转大写。 + +**L3 greet.json:** +```json +{ + "id": "greet", + "name": "greet", + "input": [{ "name": "name", "type": "string" }], + "output": [{ "name": "greeting", "type": "Greeting" }], + "validate": { "name": "string" }, + "constraints": [ + "output.greeting.message is not empty", + "output.greeting.timestamp is not empty" + ], + "description": "接收名字,生成问候消息 \"Hello, {name}!\"。如果名字为空,使用 \"World\" 作为默认值。附带当前时间戳。" +} +``` + +**L4 hello-world.json:** +```json +{ + "id": "hello-world", + "name": "hello-world", + "steps": [ + { "id": "g", "action": "process", "blockRef": "greet", "next": "u" }, + { "id": "u", "action": "process", "blockRef": "uppercase" } + ], + "dataFlows": [ + { "from": "g.greeting", "to": "u.greeting" } + ] +} +``` + +两个步骤串联:greet 的 greeting 输出流向 uppercase 的 greeting 输入。简洁明了。 + +### 10.2 Order Service + +`examples/order-service/` 是一个更真实的电商订单服务,展示了 SVP 的组合能力: + +- **order-service.json**(顶层 L4):一个 `call` 步骤调用 `order-pipeline` +- **order-pipeline.json**(复合 L4):包含 validate-order → check-inventory → process-order 的完整流程 +- **validate-order.json / check-inventory.json / process-order.json**(L3 blocks):各自定义了完整的契约 + +这个例子展示了 L4 的 `call` 机制——顶层 flow 通过 `flowRef` 引用子 flow,实现层次化的流程组织。每个 L3 block 都有完整的 validate 规则和 constraints,如 validate-order 检查用户 ID、商品列表、邮箱格式等。 + +### 10.3 Cockatiel Resilience + +`examples/cockatiel-resilience/` 展示了 SVP 如何描述弹性模式——可能涉及断路器、重试策略等分布式系统模式。 + +--- + +## 十一、模块化文档系统 + +SVP 支持一种可选的**模块化文档**机制,让每个节点可以携带超出 `description` 字段的丰富上下文。 + +### 文件位置 +``` +nodes/my-node/ +├── node.yaml # 契约(pins, validate, constraints, description) +└── docs.md # 模块化文档(可选) +``` + +### 与 description 的关系 +- **description** 是摘要(1-4 句话),嵌入 L3Block,参与编译流程 +- **docs.md** 是详述,独立文件,按需加载,**不影响 contentHash** + +推荐的文档结构包含:Intent(设计意图)、Edge Cases(边界情况)、Error Strategy(错误处理策略)、Integration Notes(集成约定)、Examples(输入输出示例)。 + +`svp prompt compile/recompile/review` 时会自动加载并注入 prompt,让 AI 在编译时能获得更多上下文。 + +--- + +## 十二、测试策略 + +项目使用 Vitest 作为测试框架,测试文件与源码共存(`*.test.ts`)。 + +核心模块的测试覆盖包括: +- `check.test.ts` — 测试所有四类校验(hash 一致性、引用完整性、漂移检测、图结构),覆盖三种 L4 变体 +- `compile-plan.test.ts` — 测试四类任务生成(缺失编译、重编译、内容漂移、断裂引用) +- `store.test.ts` — 测试 JSON 读写、目录创建 +- `i18n.test.ts` — 测试翻译查找、参数插值、语言检测 +- `prompt.test.ts` — 测试 prompt 生成流程 +- `design-prompts.test.ts` — 测试设计类 prompt 生成 +- `prompt-builder.test.ts` — 测试 StructuredPrompt 构建和渲染 + +`tests/` 目录下还有端到端测试,验证完整的 CLI 流程。 + +所有核心逻辑(check、compile-plan、view、prompt-builder)都是纯函数——不做 IO,方便测试和复用。 + +--- + +## 十三、版本追踪 + +每个层的数据对象都包含 `revision` 字段: + +```typescript +interface ArtifactVersion { + rev: number; // 版本号 + parentRev: number | null; // 父版本 + source: VersionSource; // 来源(init / human / ai / tool) + timestamp: string; // ISO 时间戳 +} +``` + +这让每次修改都有迹可循——是人手动改的、AI 生成的、还是工具自动修改的。 + +--- + +## 十四、工作流:从意图到代码的完整旅程 + +一个典型的 SVP 工作流是这样的: + +### 阶段一:设计意图(人) +```bash +svp init --name my-app --intent "做什么" --host +svp prompt design-l5 --intent "详细描述系统" +# AI 生成 L5 → 人审查 → 写入 .svp/l5.json +svp rehash l5 +``` + +### 阶段二:设计流程(人) +```bash +svp prompt design-l4 --intent "描述流程" --kind flow +# AI 生成 L4 → 人审查 → 写入 .svp/l4/ +svp rehash l4 +``` + +### 阶段三:设计契约(人 + AI) +```bash +svp prompt design-l3 validate-order --flow order-flow --step 0 --intent "校验订单" +# AI 生成 L3 → 人审查 → 写入 .svp/l3/ +svp rehash l3 +svp check # 确保一致性 +``` + +### 阶段四:编译代码(AI) +```bash +svp compile-plan # 查看待编译任务 +svp prompt compile greet # 生成 compile prompt +# AI 生成 L1 代码 +svp link greet --files src/greet.ts +svp rehash l2 +svp check # 全部通过 ✓ +``` + +### 阶段五:变更维护 +当 L3 契约变更时: +```bash +svp rehash l3/validate-order +svp compile-plan # 自动检测到 recompile 任务 +svp prompt recompile validate-order +# AI 更新代码 +``` + +当 L1 代码被手动修改时: +```bash +svp check # 报告 CONTENT_DRIFT +svp prompt review validate-order +# AI 分析漂移,建议如何处理 +``` + +--- + +## 十五、架构亮点 + +### 纯函数设计 +`check`、`compilePlan`、`buildPrompt`、`renderPrompt`、所有 `view*` 函数都是纯函数。它们不做 IO,不依赖全局状态,输入确定则输出确定。这让它们极易测试和复用。 + +### 关注点分离 +- `core/` — 类型定义 + 纯逻辑 +- `skills/` — prompt 生成 + 模板 +- `cli/` — IO + 用户交互 +- `viewer/` — 可视化渲染 + +CLI 负责加载数据(IO),传给 core 的纯函数处理,再输出结果。Skill 负责组装 prompt,不关心数据从哪来。 + +### 计算属性模式 +很多信息不存储而是实时计算: +- `signature` — 从 input/output pins 计算 +- `dataType` — 从引用的 pin 类型推导 +- `blockRefs` — 用 `extractBlockRefs()` 从 L4 结构中收集 +- `blockContext` — 用 `findBlockContext()` 定位 block 在 L4 中的位置 + +### 向后兼容 +L4 的 `kind` 字段是可选的——没有 `kind` 默认为 `"flow"`。这让旧数据无需迁移。 + +--- + +## 十六、技术栈 + +| 技术 | 用途 | +|------|------| +| TypeScript 5.7+ | 主语言,strict 模式 | +| ES2023 + NodeNext | 编译目标和模块系统 | +| Commander.js 14 | CLI 框架 | +| Vitest 4 | 测试框架 | +| ESLint 10 + Prettier 3 | 代码质量 | +| yaml 2.8 | YAML 解析(svp-blueprint 节点格式) | +| Vite | Viewer 构建工具 | +| Node.js crypto | SHA-256 哈希计算 | + +零运行时依赖(仅 `commander` 和 `yaml`),保持工具链轻量。 + +--- + +## 十七、总结:SVP 的独特价值 + +SVP 不是又一个 AI 编码工具,不是又一个代码生成框架。它是一个**观测协议**——让人类在 AI 越来越多地参与编码时,依然能看清系统的全貌。 + +它的核心洞察是:**控制来自观测,而非限制。** 你不需要限制 AI 能做什么,你需要的是能看清 AI 做了什么。五层数据模型就是五个观测窗口。当系统出了问题——而它一定会出问题——你能迅速定位问题在哪一层,然后修复它。 + +L3 契约盒模型是另一个精妙的设计:形式化的 validate 和 constraints 构成可自动验证的外壳,自然语言的 description 给 AI 足够的实现自由度。**不是帮 AI 理解,而是 AI 理解错了的时候能被发现。** + +SVP 选择做增强层而非替代品,意味着它能与任何 AI 编码工具配合使用,而且随着 base model 能力的提升,SVP 的编译质量会自动提升——无需修改协议本身。 + +这是一个仍在活跃开发中的项目(v0.1.0),但核心架构已经成型。五层模型、check 引擎、compile-plan、prompt 系统、蓝图查看器——所有主要组件都已就位,形成了一个完整的从意图到代码的工作流。 diff --git a/docs/tutorial.md b/docs/tutorial.md new file mode 100644 index 0000000..53302b9 --- /dev/null +++ b/docs/tutorial.md @@ -0,0 +1,509 @@ +# SVP 使用教程 + +> 本教程带你从零开始,用 SVP 工具链完成一个完整的 AI 辅助开发流程。 +> 读完之后你会理解 SVP 的五层数据模型、核心 CLI 命令,以及如何用 SVP 指挥 AI 写代码。 + +--- + +## 目录 + +1. [SVP 是什么](#1-svp-是什么) +2. [安装与准备](#2-安装与准备) +3. [核心概念:五层数据模型](#3-核心概念五层数据模型) +4. [快速上手:Hello World](#4-快速上手hello-world) +5. [CLI 命令速查](#5-cli-命令速查) +6. [完整工作流:订单服务](#6-完整工作流订单服务) +7. [变更与维护](#7-变更与维护) +8. [与 AI 编码工具集成](#8-与-ai-编码工具集成) +9. [常见问题](#9-常见问题) + +--- + +## 1. SVP 是什么 + +SVP(Semantic Voxel Protocol)是一个**五层观测框架**,让人类在 AI 写代码时保持控制。 + +核心思路很简单:AI 生成的代码越来越多,你不可能逐行审查。SVP 把软件系统拆成五个抽象层,每层都是一个**观测窗口**——出了问题,逐层问下去,问到哪层答案变了,问题就在哪层。 + +SVP 不调 AI API,不造编辑器。它是 AI 编码工具(Claude Code 等)的**增强层**——给 AI 喂更好的上下文,让人类能看懂 AI 在做什么。 + +--- + +## 2. 安装与准备 + +### 从源码构建 + +```bash +git clone +cd svp-blueprint +npm install +npm run build +``` + +构建后 CLI 入口在 `dist/packages/cli/index.js`,可以通过 `npx svp` 或 `node dist/packages/cli/index.js` 运行。 + +### 验证安装 + +```bash +npx svp --help +``` + +你应该能看到所有可用命令的列表。 + +--- + +## 3. 核心概念:五层数据模型 + +``` +L5 Blueprint 系统要做什么 —— 意图、边界、领域拓扑 +L4 Logic Chain 流程怎么编排 —— 步骤、顺序、数据传递 +L3 Logic Block 每个单元做什么 —— 输入输出、校验规则、约束条件 +L2 Code Block 代码怎么组织 —— 文件映射、对账哈希 +L1 Code 最终的源代码 +``` + +**层间关系:** + +``` +L5 ─聚合→ L4 ─聚合→ L3 ←1:1配对→ L2 ─聚合→ L1 +``` + +本质是三个角色: + +| 层次 | 谁定义 | 内容 | +|------|--------|------| +| **意图层** (L5 + L4) | 人 | 做什么、怎么编排 | +| **契约层** (L3) | 人定义,AI 验证 | 每个单元的规格 | +| **实现层** (L2 + L1) | AI 生成,人验收 | 代码结构和源码 | + +**L3 是枢纽**——它是人和 AI 之间的接口。人在 L3 写清楚"我要什么",AI 在 L3 以下实现"怎么做"。 + +### 数据存储 + +所有层数据存放在项目根目录的 `.svp/` 下: + +``` +.svp/ +├── l5.json # L5 蓝图(一个项目一份) +├── l4/ # L4 逻辑链(每个流程一个文件) +│ └── order-flow.json +├── l3/ # L3 逻辑块(每个单元一个文件) +│ └── validate-order.json +└── l2/ # L2 代码块(与 L3 一一配对) + └── validate-order.json +``` + +--- + +## 4. 快速上手:Hello World + +我们来构建一个最小的 SVP 项目:接收名字 → 生成问候语 → 转大写。 + +### 4.1 初始化项目 + +```bash +mkdir hello-svp && cd hello-svp +npx svp init --name hello-svp --intent "接收名字,生成大写问候语" +``` + +这会创建 `.svp/l5.json`(空的蓝图框架)。 + +### 4.2 设计 L5 蓝图 + +让 AI 帮你设计系统级蓝图: + +```bash +npx svp prompt design-l5 --intent "接收名字,生成问候语,转成大写输出" +``` + +这会输出一段结构化的 prompt。把它发给 AI(Claude Code 等),AI 会生成 L5 蓝图的 JSON 内容,写入 `.svp/l5.json`。 + +写完后刷新哈希: + +```bash +npx svp rehash l5 +``` + +### 4.3 设计 L4 流程 + +设计处理流程——两个步骤串联: + +```bash +npx svp prompt design-l4 --intent "greet → uppercase 串行管道" --kind flow +``` + +AI 会生成 L4 flow 文件。它的结构看起来像这样: + +```json +{ + "id": "hello-world", + "name": "hello-world", + "steps": [ + { "id": "g", "action": "process", "blockRef": "greet", "next": "u" }, + { "id": "u", "action": "process", "blockRef": "uppercase" } + ], + "dataFlows": [ + { "from": "g.greeting", "to": "u.greeting" } + ] +} +``` + +`steps` 定义了步骤和顺序,`dataFlows` 定义了步骤之间的数据传递。每个 step 通过 `blockRef` 引用一个 L3 逻辑块。 + +```bash +npx svp rehash l4 +``` + +### 4.4 设计 L3 逻辑块 + +为每个步骤设计契约。以 `greet` 块为例: + +```bash +npx svp prompt design-l3 greet --flow hello-world --step 0 --intent "接收名字,生成问候消息" +``` + +AI 会生成 L3 block —— 一个**契约盒**: + +```json +{ + "id": "greet", + "name": "greet", + "input": [ + { "name": "name", "type": "string" } + ], + "output": [ + { "name": "greeting", "type": "Greeting" } + ], + "validate": { + "name": "string" + }, + "constraints": [ + "output.greeting.message is not empty", + "output.greeting.timestamp is not empty" + ], + "description": "接收名字,生成问候消息 \"Hello, {name}!\"。\n如果名字为空,使用 \"World\" 作为默认值。\n附带当前时间戳。" +} +``` + +注意:`validate` 和 `constraints` 用自然语言——AI 直接理解,不需要结构化的规则引擎。 + +同样为 `uppercase` 设计 L3,然后: + +```bash +npx svp rehash l3 +``` + +### 4.5 校验 + +到这里,你已经定义了意图层 (L5+L4) 和契约层 (L3)。运行校验: + +```bash +npx svp check +``` + +它会检查: +- **Hash 一致性** — contentHash 和实际内容是否匹配 +- **引用完整性** — L4 step 引用的 L3 block 是否存在 +- **图结构** — 流程步骤是否形成有效链 + +全部通过后,就可以让 AI 生成代码了。 + +### 4.6 编译:AI 生成代码 + +生成编译任务列表: + +```bash +npx svp compile-plan +``` + +输出会告诉你哪些 L3 block 需要编译(生成 L1 代码)。然后为每个任务生成 prompt: + +```bash +npx svp prompt compile greet +``` + +这段 prompt 包含了 AI 需要的全部上下文:L3 契约、pin 类型、校验规则、约束条件。把它发给 AI,AI 会生成源代码文件(比如 `src/greet.ts`)。 + +### 4.7 链接:建立 L2 映射 + +AI 生成代码后,用 `link` 命令建立 L3 ↔ L1 的映射关系: + +```bash +npx svp link greet --files src/greet.ts +npx svp link uppercase --files src/uppercase.ts +npx svp rehash l2 +``` + +这会创建 L2 代码块,记录每个 L3 block 对应哪些源文件。 + +### 4.8 最终校验 + +```bash +npx svp check +``` + +全部通过——你的项目从意图到代码,每一层都完整、一致、可追踪。 + +--- + +## 5. CLI 命令速查 + +| 命令 | 用途 | 示例 | +|------|------|------| +| `svp init` | 初始化项目 | `svp init --name my-app --intent "..." --host ` | +| `svp check` | 跨层一致性校验 | `svp check --json` | +| `svp view` | 查看层数据 | `svp view l3 greet` | +| `svp compile-plan` | 生成编译任务 | `svp compile-plan --json` | +| `svp prompt` | 生成 AI prompt | `svp prompt compile greet` | +| `svp link` | 创建/更新 L2 映射 | `svp link greet --files src/greet.ts` | +| `svp rehash` | 重算哈希 | `svp rehash l3/greet` | +| `svp compile-blueprint` | YAML → JSON 编译 | `svp compile-blueprint` | +| `svp blueprint` | 生成可视化节点图 | `svp blueprint -r . -o blueprint.html` | + +### svp view 详解 + +```bash +svp view l5 # L5 蓝图概览 +svp view l4 # 所有 L4 流程概览(每个流程一行) +svp view l4 order-flow # L4 流程详情(步骤图、数据流、引用关系) +svp view l3 # 所有 L3 块概览(签名、校验规则数、约束数) +svp view l3 greet # L3 块详情(完整 pins、规则、约束、描述) +svp view l2 # 所有 L2 代码块(语言、文件、哈希状态) +svp view l2 greet # L2 详情(文件列表、哈希状态、配对 L3) +``` + +Overview 把每个实体压缩成一行,供 AI 快速浏览全局拓扑;Detail 展示完整信息但不展开其他层。 + +### svp prompt 详解 + +**设计类**(创建新层数据): + +```bash +svp prompt design-l5 --intent "描述系统意图" +svp prompt design-l4 --intent "描述流程" [--kind flow|event-graph|state-machine] +svp prompt design-l3 --flow --step --intent "描述单元" +``` + +**任务类**(已有数据的编译/维护): + +```bash +svp prompt compile # L3 → L1 首次编译 +svp prompt recompile # L3 变更后重编译 +svp prompt review # L1 手改后审查漂移 +svp prompt update-ref # 修复断裂的 L4 引用 +``` + +--- + +## 6. 完整工作流:订单服务 + +一个更真实的例子:电商订单流程。 + +### 第一步:设计意图 + +```bash +svp init --name order-service --intent "电商订单创建:校验→库存→处理→支付" +svp prompt design-l5 --intent "订单管理系统,支持创建订单、校验、库存检查、支付" +# AI 生成 L5 → 写入 .svp/l5.json +svp rehash l5 +``` + +### 第二步:设计流程 + +```bash +svp prompt design-l4 --intent "订单创建流程:validate → check-inventory → process → pay" --kind flow +# AI 生成 L4 flow → 写入 .svp/l4/order-flow.json +svp rehash l4 +``` + +### 第三步:设计每个 block 的契约 + +```bash +svp prompt design-l3 validate-order --flow order-flow --step 0 --intent "校验订单请求字段" +svp prompt design-l3 check-inventory --flow order-flow --step 1 --intent "检查库存是否充足" +svp prompt design-l3 process-order --flow order-flow --step 2 --intent "处理订单,生成订单号" +svp prompt design-l3 process-payment --flow order-flow --step 3 --intent "调用支付网关" +# AI 分别生成 4 个 L3 block +svp rehash l3 +svp check +``` + +### 第四步:编译代码 + +```bash +svp compile-plan +# 输出 4 个 compile 任务 + +svp prompt compile validate-order +# AI 生成 src/validate-order.ts +svp link validate-order --files src/validate-order.ts + +svp prompt compile check-inventory +# AI 生成 src/check-inventory.ts +svp link check-inventory --files src/check-inventory.ts + +# ... 对每个 block 重复 +svp rehash l2 +svp check # 全部通过 ✓ +``` + +### 关键洞察 + +注意工作流是**严格单向向下**的: + +``` +人定义 L5 意图 → 人定义 L4 流程 → 人定义 L3 契约 → AI 生成 L2+L1 代码 +``` + +AI 只负责从契约编译到代码,绝不回头修改上层意图。如果 AI 在编译时发现 L3 契约有问题(比如缺一个输入参数),它会报错返回,而不是擅自修改 L3。**你(人类)是反向反馈回路。** + +--- + +## 7. 变更与维护 + +项目不会一成不变。SVP 通过哈希追踪变更,自动检测需要重编译的范围。 + +### 场景 A:修改 L3 契约(自上而下) + +你决定给 `validate-order` 增加一个新的校验规则: + +```bash +# 1. 编辑 .svp/l3/validate-order.json,添加新的 validate 规则 +# 2. 重算哈希 +svp rehash l3/validate-order + +# 3. 检测影响范围 +svp compile-plan +# 输出:recompile validate-order(因为 L2 的 sourceHash 与 L3 的 contentHash 不匹配了) + +# 4. 重编译 +svp prompt recompile validate-order +# AI 更新 src/validate-order.ts +svp link validate-order --files src/validate-order.ts +svp rehash l2 +svp check +``` + +### 场景 B:手改 L1 代码(自下而上) + +开发者直接修改了 `src/validate-order.ts`(比如优化了性能): + +```bash +svp check +# 报告:CONTENT_DRIFT — L2 的 signatureHash 与 L1 实际导出签名不匹配 +``` + +这时你有三个选择: + +1. **接受变更** — 用 `svp link` 更新 L2 的哈希 +2. **回退代码** — 用 `svp prompt recompile` 让 AI 重新生成 +3. **更新契约** — 如果代码变更反映了新需求,同步修改 L3 + +```bash +# 让 AI 帮你分析漂移 +svp prompt review validate-order +# AI 输出:哪些签名变了、是否兼容 L3 契约、建议怎么处理 +``` + +### 场景 C:修改 L4 流程 + +如果你在流程中插入一个新步骤,`compile-plan` 会检测到新的 `blockRef` 没有对应的 L3 block,输出 `update-ref` 任务: + +```bash +svp compile-plan +# 输出:update-ref order-flow(因为新步骤引用了不存在的 L3 block) + +svp prompt update-ref order-flow +# AI 生成缺失的 L3 block +svp rehash l3 +svp check +``` + +--- + +## 8. 与 AI 编码工具集成 + +SVP 支持 6 种 AI 编码工具。初始化时用 `--host` 指定你的工具,SVP 会自动生成对应的 skill 文件和上下文配置: + +```bash +svp init --name my-app --host +``` + +### 支持的工具 + +| Host | Skill 目录 | 上下文文件 | Skill 文件名 | +|------|-----------|-----------|-------------| +| `claude-code` | `.claude/commands/` | `CLAUDE.md` | `svp.md` | +| `cursor` | `.cursor/commands/` | `.cursor/rules/svp.mdc` | `svp.md` | +| `windsurf` | `.windsurf/commands/` | `.windsurf/rules/svp.md` | `svp.md` | +| `kimi-code` | `.agents/skills/` | `AGENTS.md` | `svp/SKILL.md` | +| `codex` | `.codex/skills/` | `AGENTS.md` | `svp/SKILL.md` | +| `github-copilot` | `.github/prompts/` | `.github/copilot-instructions.md` | `svp.prompt.md` | + +如果不指定 `--host`,SVP 会自动检测项目中的工具标记目录(如 `.claude/`、`.cursor/`、`.github/copilot-instructions.md` 等)。 + +### 工作模式 + +在 AI 编码工具中使用 SVP 的典型对话: + +``` +你:设计一个用户注册流程 +AI:(调用 svp prompt design-l4,生成流程,调用 svp rehash) + 已创建 L4 flow: user-registration,包含 3 个步骤... + +你:编译所有 block +AI:(调用 svp compile-plan,对每个任务调用 svp prompt compile,生成代码,调用 svp link) + 已编译 3 个 block,所有文件已链接,svp check 通过 ✓ + +你:给 validate-email 加上域名黑名单校验 +AI:(更新 L3 的 validate 规则,调用 svp rehash,然后 svp prompt recompile) + 已更新 L3 契约并重编译 validate-email ✓ +``` + +--- + +## 9. 常见问题 + +### Q: SVP 支持哪些编程语言? + +SVP 是语言无关的协议。L3 契约中的 `type` 字段引用 TypeScript interface(因为 AI 对 TypeScript 类型理解最好),但 L1 生成的代码可以是任何语言。用 `svp link --language python` 指定目标语言。 + +### Q: contentHash 是什么? + +每个层的数据模型都包含 `contentHash`——内容的哈希值。编辑数据后用 `svp rehash` 重算。它是变更追踪的基础:上层哈希变了 → 下层记录的不匹配 → 触发重编译。 + +### Q: 为什么 validate 和 constraints 用自然语言? + +因为 AI 直接理解自然语言。`"array, min 1, max 50"` 比结构化规则对象更简洁,AI 理解得一样准确。而且像 `"not just first"` 这样的微妙语义,结构化表示会丢失。 + +### Q: svp check 报错了怎么办? + +`svp check` 会告诉你具体是哪一层的什么问题。常见问题: + +| 错误类型 | 含义 | 修复 | +|----------|------|------| +| HASH_MISMATCH | contentHash 和实际内容不一致 | `svp rehash ` | +| MISSING_BLOCK_REF | L4 step 引用的 L3 block 不存在 | 创建缺失的 L3 或修复引用 | +| CONTENT_DRIFT | L1 代码与 L3 契约不同步 | `svp prompt review` 分析后决定 | +| ORPHAN_STEP | L4 中有不可达的步骤 | 检查步骤链接 | + +### Q: 我可以跳过某些层吗? + +可以。如果你只想用 L3 → L1 的编译能力,不需要定义 L5 和 L4。直接创建 L3 block,然后 `svp prompt compile`。不过完整使用五层能让你在更高层级观测和管理系统。 + +### Q: compile-blueprint 是做什么的? + +如果你用 svp-blueprint 可视化编辑器(节点图方式编辑 L3/L4),编辑器会把数据保存为 `nodes/*.yaml` 和 `graphs/*.yaml`。`svp compile-blueprint` 把这些 YAML 编译成 `.svp/` 下的标准 JSON 格式。 + +### Q: svp blueprint 是做什么的? + +`svp blueprint` 读取 `.svp/` 下的 L3/L4 数据,生成一个自包含的 HTML 文件并在浏览器中打开。这个查看器用节点图渲染流程的步骤编排和数据流,支持平移、缩放、点击查看 L3 契约详情。暗色主题 + 网格背景,pin 按类型自动着色,节点可展开查看完整的 validate 和 constraints。无需安装任何依赖,单个 HTML 文件离线可用。 + +--- + +## 下一步 + +- 查看 `examples/hello-world/` 和 `examples/order-service/` 了解实际项目结构 +- 阅读 `docs/overview.md` 深入理解设计理念 +- 阅读 `docs/architecture.md` 了解技术架构 +- 阅读 `docs/check-reference.md` 了解所有校验规则的详细说明 diff --git a/examples/cockatiel-resilience/.svp/l3/apply-error-filter.json b/examples/cockatiel-resilience/.svp/l3/apply-error-filter.json new file mode 100644 index 0000000..0658858 --- /dev/null +++ b/examples/cockatiel-resilience/.svp/l3/apply-error-filter.json @@ -0,0 +1,25 @@ +{ + "id": "apply-error-filter", + "name": "应用错误过滤器", + "input": [ + { "name": "errorHandlers", "type": "ErrorHandlerConfig[]" }, + { "name": "resultHandlers", "type": "ResultHandlerConfig[]", "optional": true } + ], + "output": [ + { "name": "handledErrors", "type": "ErrorFilter" }, + { "name": "handledResults", "type": "ResultFilter" } + ], + "validate": { + "errorHandlers": "数组,最少 1 项;每项是谓词函数或含 test 方法的对象", + "resultHandlers": "如提供则为数组;每项是谓词函数或含 test 方法的对象" + }, + "constraints": [ + "output.handledErrors(err) 当至少一个 errorHandler 匹配时返回 true", + "output.handledResults(result) 当至少一个 resultHandler 匹配时返回 true", + "resultHandlers 缺省或为空时 handledResults 始终返回 false", + "handledErrors 和 handledResults 均为无副作用的纯函数" + ], + "description": "将所有配置的错误谓词以 OR 逻辑合并为单一 handledErrors 函数。对结果谓词施加同样的 OR 合并生成 handledResults。未提供结果谓词时,handledResults 永远返回 false。", + "contentHash": "placeholder", + "revision": { "rev": 1, "parentRev": null, "source": { "type": "init" }, "timestamp": "2026-03-17T06:15:00.000Z" } +} diff --git a/examples/cockatiel-resilience/.svp/l3/check-backoff-duration.json b/examples/cockatiel-resilience/.svp/l3/check-backoff-duration.json new file mode 100644 index 0000000..4766d92 --- /dev/null +++ b/examples/cockatiel-resilience/.svp/l3/check-backoff-duration.json @@ -0,0 +1,18 @@ +{ + "id": "check-backoff-duration", + "name": "检查退避时长", + "input": [ + { "name": "openedAt", "type": "number" }, + { "name": "backoffMs", "type": "number" } + ], + "output": [ + { "name": "timerExpired", "type": "boolean" } + ], + "constraints": [ + "当前时间减去 openedAt 大于 backoffMs 时返回 true", + "退避时长由 backoff 策略决定" + ], + "description": "判断 open 状态下的退避定时器是否已到期,作为 open→half-open 转换的守卫。", + "contentHash": "placeholder", + "revision": { "rev": 1, "parentRev": null, "source": { "type": "init" }, "timestamp": "2026-03-17T06:15:00.000Z" } +} diff --git a/examples/cockatiel-resilience/.svp/l3/check-breaker-threshold.json b/examples/cockatiel-resilience/.svp/l3/check-breaker-threshold.json new file mode 100644 index 0000000..2f64030 --- /dev/null +++ b/examples/cockatiel-resilience/.svp/l3/check-breaker-threshold.json @@ -0,0 +1,18 @@ +{ + "id": "check-breaker-threshold", + "name": "检查熔断阈值", + "input": [ + { "name": "failureCount", "type": "number" }, + { "name": "threshold", "type": "number" } + ], + "output": [ + { "name": "shouldBreak", "type": "boolean" } + ], + "constraints": [ + "当连续失败次数达到阈值时返回 true", + "支持连续计数、计数窗口、采样三种策略" + ], + "description": "评估当前失败统计是否达到熔断条件,作为 closed→open 转换的守卫。", + "contentHash": "placeholder", + "revision": { "rev": 1, "parentRev": null, "source": { "type": "init" }, "timestamp": "2026-03-17T06:15:00.000Z" } +} diff --git a/examples/cockatiel-resilience/.svp/l3/emit-break-event.json b/examples/cockatiel-resilience/.svp/l3/emit-break-event.json new file mode 100644 index 0000000..93d92fd --- /dev/null +++ b/examples/cockatiel-resilience/.svp/l3/emit-break-event.json @@ -0,0 +1,15 @@ +{ + "id": "emit-break-event", + "name": "发射熔断事件", + "input": [ + { "name": "breaker", "type": "CircuitBreakerState" } + ], + "output": [], + "constraints": [ + "发射 onBreak 事件通知监听者", + "记录断开时间戳用于退避计算" + ], + "description": "进入 open 状态时发射 onBreak 事件,通知外部熔断器已断开。", + "contentHash": "placeholder", + "revision": { "rev": 1, "parentRev": null, "source": { "type": "init" }, "timestamp": "2026-03-17T06:15:00.000Z" } +} diff --git a/examples/cockatiel-resilience/.svp/l3/emit-half-open-event.json b/examples/cockatiel-resilience/.svp/l3/emit-half-open-event.json new file mode 100644 index 0000000..3f8bf03 --- /dev/null +++ b/examples/cockatiel-resilience/.svp/l3/emit-half-open-event.json @@ -0,0 +1,15 @@ +{ + "id": "emit-half-open-event", + "name": "发射半开事件", + "input": [ + { "name": "breaker", "type": "CircuitBreakerState" } + ], + "output": [], + "constraints": [ + "发射 onHalfOpen 事件", + "允许一个探测请求通过" + ], + "description": "进入 half-open 状态时发射事件,准备执行探测调用以判断后端是否恢复。", + "contentHash": "placeholder", + "revision": { "rev": 1, "parentRev": null, "source": { "type": "init" }, "timestamp": "2026-03-17T06:15:00.000Z" } +} diff --git a/examples/cockatiel-resilience/.svp/l3/emit-isolate-event.json b/examples/cockatiel-resilience/.svp/l3/emit-isolate-event.json new file mode 100644 index 0000000..08253fb --- /dev/null +++ b/examples/cockatiel-resilience/.svp/l3/emit-isolate-event.json @@ -0,0 +1,15 @@ +{ + "id": "emit-isolate-event", + "name": "发射隔离事件", + "input": [ + { "name": "breaker", "type": "CircuitBreakerState" } + ], + "output": [], + "constraints": [ + "发射 onIsolate 事件", + "拒绝所有后续请求直到隔离句柄释放" + ], + "description": "进入 isolated 状态时发射事件,手动隔离熔断器拒绝一切调用。", + "contentHash": "placeholder", + "revision": { "rev": 1, "parentRev": null, "source": { "type": "init" }, "timestamp": "2026-03-17T06:15:00.000Z" } +} diff --git a/examples/cockatiel-resilience/.svp/l3/reset-breaker-counters.json b/examples/cockatiel-resilience/.svp/l3/reset-breaker-counters.json new file mode 100644 index 0000000..7de83ee --- /dev/null +++ b/examples/cockatiel-resilience/.svp/l3/reset-breaker-counters.json @@ -0,0 +1,17 @@ +{ + "id": "reset-breaker-counters", + "name": "重置熔断计数器", + "input": [ + { "name": "breaker", "type": "CircuitBreakerState" } + ], + "output": [ + { "name": "breaker", "type": "CircuitBreakerState" } + ], + "constraints": [ + "将连续失败计数归零", + "保留 backoff 迭代器状态不变" + ], + "description": "进入 closed 状态时重置熔断器的失败计数和窗口统计。", + "contentHash": "placeholder", + "revision": { "rev": 1, "parentRev": null, "source": { "type": "init" }, "timestamp": "2026-03-17T06:15:00.000Z" } +} diff --git a/examples/cockatiel-resilience/.svp/l4/circuit-breaker-lifecycle.json b/examples/cockatiel-resilience/.svp/l4/circuit-breaker-lifecycle.json new file mode 100644 index 0000000..76e7de0 --- /dev/null +++ b/examples/cockatiel-resilience/.svp/l4/circuit-breaker-lifecycle.json @@ -0,0 +1,33 @@ +{ + "kind": "state-machine", + "id": "circuit-breaker-lifecycle", + "name": "熔断器生命周期状态机", + "entity": "CircuitBreaker", + "initialState": "关闭", + "states": { + "关闭": { + "onEntry": { "blockRef": "reset-breaker-counters" } + }, + "断开": { + "onEntry": { "blockRef": "emit-break-event" } + }, + "半开": { + "onEntry": { "blockRef": "emit-half-open-event" } + }, + "已隔离": { + "onEntry": { "blockRef": "emit-isolate-event" } + } + }, + "transitions": [ + { "from": "关闭", "to": "断开", "event": "失败阈值超限", "guard": "check-breaker-threshold" }, + { "from": "断开", "to": "半开", "event": "半开定时器到期", "guard": "check-backoff-duration" }, + { "from": "半开", "to": "关闭", "event": "探测调用成功" }, + { "from": "半开", "to": "断开", "event": "探测调用失败" }, + { "from": "关闭", "to": "已隔离", "event": "手动隔离" }, + { "from": "断开", "to": "已隔离", "event": "手动隔离" }, + { "from": "半开", "to": "已隔离", "event": "手动隔离" }, + { "from": "已隔离", "to": "关闭", "event": "所有隔离句柄已释放" } + ], + "contentHash": "placeholder", + "revision": { "rev": 1, "parentRev": null, "source": { "type": "init" }, "timestamp": "2026-03-17T06:15:00.000Z" } +} diff --git a/examples/cockatiel-resilience/.svp/l4/policy-wrap.json b/examples/cockatiel-resilience/.svp/l4/policy-wrap.json new file mode 100644 index 0000000..81baaba --- /dev/null +++ b/examples/cockatiel-resilience/.svp/l4/policy-wrap.json @@ -0,0 +1,21 @@ +{ + "id": "policy-wrap", + "name": "可组合策略管道", + "trigger": { "type": "api", "config": { "method": "execute", "path": "wrap(...policies).execute(fn)" } }, + "steps": [ + { "id": "filter-error", "action": "process", "blockRef": "apply-error-filter", "next": "invoke-outer" }, + { "id": "invoke-outer", "action": "process", "blockRef": "execute-with-retry", "next": "invoke-middle" }, + { "id": "invoke-middle", "action": "process", "blockRef": "execute-with-breaker", "next": "invoke-inner" }, + { "id": "invoke-inner", "action": "process", "blockRef": "execute-with-timeout", "next": "merge-context" }, + { "id": "merge-context", "action": "process", "blockRef": "merge-policy-context", "next": null } + ], + "dataFlows": [ + { "from": "filter-error.handledErrors", "to": "invoke-outer.errorFilter" }, + { "from": "filter-error.handledResults", "to": "invoke-outer.resultFilter" }, + { "from": "invoke-outer.innerFn", "to": "invoke-middle.fn" }, + { "from": "invoke-middle.innerFn", "to": "invoke-inner.fn" }, + { "from": "invoke-inner.result", "to": "merge-context.policyResult" } + ], + "contentHash": "placeholder", + "revision": { "rev": 1, "parentRev": null, "source": { "type": "init" }, "timestamp": "2026-03-17T06:15:00.000Z" } +} diff --git a/examples/cockatiel-resilience/.svp/l4/retry-execution.json b/examples/cockatiel-resilience/.svp/l4/retry-execution.json new file mode 100644 index 0000000..e0f738e --- /dev/null +++ b/examples/cockatiel-resilience/.svp/l4/retry-execution.json @@ -0,0 +1,57 @@ +{ + "kind": "event-graph", + "id": "retry-execution", + "name": "重试策略事件图", + "state": { + "attempt": { "type": "number", "description": "当前重试次数(从 0 开始)" }, + "backoff": { "type": "IBackoff | undefined", "description": "当前退避状态,用于计算下次延迟" }, + "lastResult": { "type": "FailureOrSuccess", "description": "最近一次调用的结果" } + }, + "handlers": [ + { + "id": "on-execute", + "event": "policy.execute", + "steps": [ + { "id": "invoke-fn", "action": "process", "blockRef": "invoke-with-filter", "next": "check-result" }, + { "id": "check-result", "action": "process", "blockRef": "classify-result", "next": null } + ], + "dataFlows": [ + { "from": "$event.fn", "to": "invoke-fn.fn" }, + { "from": "$event.signal", "to": "invoke-fn.signal" }, + { "from": "$state.attempt", "to": "invoke-fn.attempt" }, + { "from": "invoke-fn.result", "to": "check-result.result" }, + { "from": "invoke-fn.result", "to": "$state.lastResult" } + ] + }, + { + "id": "on-retry-needed", + "event": "result.should_retry", + "steps": [ + { "id": "compute-delay", "action": "process", "blockRef": "compute-backoff-delay", "next": "wait-delay" }, + { "id": "wait-delay", "action": "process", "blockRef": "delay-with-unref", "next": "emit-retry" }, + { "id": "emit-retry", "action": "process", "blockRef": "emit-retry-event", "next": null } + ], + "dataFlows": [ + { "from": "$state.backoff", "to": "compute-delay.currentBackoff" }, + { "from": "$state.attempt", "to": "compute-delay.attempt" }, + { "from": "$state.lastResult", "to": "compute-delay.result" }, + { "from": "compute-delay.nextBackoff", "to": "$state.backoff" }, + { "from": "compute-delay.delayMs", "to": "wait-delay.duration" }, + { "from": "compute-delay.delayMs", "to": "emit-retry.delay" }, + { "from": "$state.attempt", "to": "emit-retry.attempt" } + ] + }, + { + "id": "on-give-up", + "event": "result.max_attempts_exceeded", + "steps": [ + { "id": "emit-give-up", "action": "process", "blockRef": "emit-give-up-event", "next": null } + ], + "dataFlows": [ + { "from": "$state.lastResult", "to": "emit-give-up.result" } + ] + } + ], + "contentHash": "placeholder", + "revision": { "rev": 1, "parentRev": null, "source": { "type": "init" }, "timestamp": "2026-03-17T06:15:00.000Z" } +} diff --git a/examples/cockatiel-resilience/.svp/l5.json b/examples/cockatiel-resilience/.svp/l5.json new file mode 100644 index 0000000..698d31e --- /dev/null +++ b/examples/cockatiel-resilience/.svp/l5.json @@ -0,0 +1,32 @@ +{ + "id": "cockatiel", + "name": "Cockatiel 弹性库", + "version": "0.1.0", + "intent": "提供可组合的容错策略(重试、熔断器、超时、隔离舱、降级),包装异步操作使分布式调用具备自愈能力。策略以过滤器形式拦截 execute() 调用,施加弹性逻辑后传递结果或错误。", + "constraints": [ + "所有策略实现 IPolicy 统一 execute() 接口", + "策略通过 wrap() 组合——顺序决定嵌套层级(外层重试,内层超时)", + "熔断器状态可通过 toJSON/initialState 序列化,支持无服务器场景持久化", + "退避和熔断策略通过工厂接口可插拔", + "AbortSignal 在整个策略链中传播,实现协作式取消", + "零外部依赖——纯 TypeScript + Node.js 内置模块" + ], + "domains": [ + { "name": "策略核心", "description": "策略组合与错误/结果过滤", "dependencies": [] }, + { "name": "熔断器", "description": "熔断器状态机:关闭/断开/半开", "dependencies": ["策略核心", "熔断策略", "退避"] }, + { "name": "重试", "description": "带可插拔退避策略的重试循环", "dependencies": ["策略核心", "退避"] }, + { "name": "超时", "description": "基于 AbortSignal 的协作式与强制超时", "dependencies": ["策略核心"] }, + { "name": "隔离舱", "description": "并发控制:槽池 + 溢出队列", "dependencies": ["策略核心"] }, + { "name": "退避", "description": "退避时长策略:常数、指数、可迭代、委托", "dependencies": [] }, + { "name": "熔断策略", "description": "熔断判定策略:连续失败、计数窗口、采样", "dependencies": [] } + ], + "integrations": [], + "language": "zh", + "contentHash": "placeholder", + "revision": { + "rev": 2, + "parentRev": 1, + "source": { "type": "manual" }, + "timestamp": "2026-03-17T06:15:00.000Z" + } +} diff --git a/examples/hello-world/.svp/l2/greet.json b/examples/hello-world/.svp/l2/greet.json new file mode 100644 index 0000000..5c1df85 --- /dev/null +++ b/examples/hello-world/.svp/l2/greet.json @@ -0,0 +1,19 @@ +{ + "id": "greet", + "blockRef": "greet", + "language": "typescript", + "files": [ + "src/greet.ts" + ], + "sourceHash": "c2787878cb585722", + "contentHash": "d86f3f7c74e0592f", + "revision": { + "rev": 1, + "parentRev": null, + "source": { + "type": "ai", + "action": "compile" + }, + "timestamp": "2026-03-17T16:50:45.846Z" + } +} diff --git a/examples/hello-world/.svp/l2/uppercase.json b/examples/hello-world/.svp/l2/uppercase.json new file mode 100644 index 0000000..61353ec --- /dev/null +++ b/examples/hello-world/.svp/l2/uppercase.json @@ -0,0 +1,19 @@ +{ + "id": "uppercase", + "blockRef": "uppercase", + "language": "typescript", + "files": [ + "src/uppercase.ts" + ], + "sourceHash": "71cf829a8d639a8a", + "contentHash": "654f510d0bf7076a", + "revision": { + "rev": 1, + "parentRev": null, + "source": { + "type": "ai", + "action": "compile" + }, + "timestamp": "2026-03-17T16:50:46.646Z" + } +} diff --git a/examples/hello-world/.svp/l3/greet.json b/examples/hello-world/.svp/l3/greet.json index c044d68..7a153ae 100644 --- a/examples/hello-world/.svp/l3/greet.json +++ b/examples/hello-world/.svp/l3/greet.json @@ -27,7 +27,7 @@ "source": { "type": "init" }, - "timestamp": "2026-03-13T14:56:21.962Z" + "timestamp": "2026-03-16T15:02:23.783Z" }, - "contentHash": "a778e92a77102ccc" + "contentHash": "c2787878cb585722" } diff --git a/examples/hello-world/.svp/l3/uppercase.json b/examples/hello-world/.svp/l3/uppercase.json index 57ea083..bedf368 100644 --- a/examples/hello-world/.svp/l3/uppercase.json +++ b/examples/hello-world/.svp/l3/uppercase.json @@ -28,7 +28,7 @@ "source": { "type": "init" }, - "timestamp": "2026-03-13T14:56:21.962Z" + "timestamp": "2026-03-16T15:02:23.783Z" }, - "contentHash": "841152ffec89a57b" + "contentHash": "71cf829a8d639a8a" } diff --git a/examples/hello-world/.svp/l4/hello-world.json b/examples/hello-world/.svp/l4/hello-world.json index d61edf0..9ac717e 100644 --- a/examples/hello-world/.svp/l4/hello-world.json +++ b/examples/hello-world/.svp/l4/hello-world.json @@ -26,7 +26,7 @@ "source": { "type": "init" }, - "timestamp": "2026-03-13T14:56:21.964Z" + "timestamp": "2026-03-16T15:02:23.784Z" }, - "contentHash": "7c8b4df198dc2ba1" + "contentHash": "9433d7d21ed83ff4" } diff --git a/examples/hello-world/.svp/l5.json b/examples/hello-world/.svp/l5.json new file mode 100644 index 0000000..5904788 --- /dev/null +++ b/examples/hello-world/.svp/l5.json @@ -0,0 +1,35 @@ +{ + "id": "hello-world", + "name": "Hello World", + "version": "0.1.0", + "intent": "A minimal greeting service: accept a name, produce a greeting message, and transform it to uppercase. Demonstrates the simplest possible SVP five-layer stack.", + "constraints": [ + "Pure functions only — no side effects, no I/O", + "Default name is \"World\" when input is empty" + ], + "domains": [ + { + "name": "greeting", + "description": "Name-to-greeting transformation", + "dependencies": [] + }, + { + "name": "formatting", + "description": "String formatting utilities (uppercase)", + "dependencies": [ + "greeting" + ] + } + ], + "integrations": [], + "language": "en", + "contentHash": "877bbd67f3099617", + "revision": { + "rev": 3, + "parentRev": 2, + "source": { + "type": "human" + }, + "timestamp": "2026-03-17T16:51:57.193Z" + } +} diff --git a/examples/hello-world/src/greet.ts b/examples/hello-world/src/greet.ts new file mode 100644 index 0000000..de3f21a --- /dev/null +++ b/examples/hello-world/src/greet.ts @@ -0,0 +1,14 @@ +// L1: greet — Generated from L3 contract "greet" + +export interface Greeting { + message: string; + timestamp: string; +} + +export function greet(name: string): Greeting { + const effectiveName = name.trim() || "World"; + return { + message: `Hello, ${effectiveName}!`, + timestamp: new Date().toISOString(), + }; +} diff --git a/examples/hello-world/src/uppercase.ts b/examples/hello-world/src/uppercase.ts new file mode 100644 index 0000000..c64a7f9 --- /dev/null +++ b/examples/hello-world/src/uppercase.ts @@ -0,0 +1,10 @@ +// L1: uppercase — Generated from L3 contract "uppercase" + +import type { Greeting } from "./greet.js"; + +export function uppercase(greeting: Greeting): Greeting { + return { + message: greeting.message.toUpperCase(), + timestamp: greeting.timestamp, + }; +} diff --git a/examples/order-service/.svp/l3/check-inventory.json b/examples/order-service/.svp/l3/check-inventory.json index 8d3be45..24f3bab 100644 --- a/examples/order-service/.svp/l3/check-inventory.json +++ b/examples/order-service/.svp/l3/check-inventory.json @@ -26,7 +26,7 @@ "source": { "type": "init" }, - "timestamp": "2026-03-13T14:56:21.948Z" + "timestamp": "2026-03-16T15:02:23.752Z" }, - "contentHash": "3ea5cb50d8eab7ac" + "contentHash": "ec0763d9ade7b5c8" } diff --git a/examples/order-service/.svp/l3/process-order.json b/examples/order-service/.svp/l3/process-order.json index 0edc1d0..34481dd 100644 --- a/examples/order-service/.svp/l3/process-order.json +++ b/examples/order-service/.svp/l3/process-order.json @@ -34,7 +34,7 @@ "source": { "type": "init" }, - "timestamp": "2026-03-13T14:56:21.948Z" + "timestamp": "2026-03-16T15:02:23.752Z" }, - "contentHash": "dfe663b7a2ff0df8" + "contentHash": "a7fcd3c3ac6eca38" } diff --git a/examples/order-service/.svp/l3/validate-order.json b/examples/order-service/.svp/l3/validate-order.json index d71c906..c3daa71 100644 --- a/examples/order-service/.svp/l3/validate-order.json +++ b/examples/order-service/.svp/l3/validate-order.json @@ -35,7 +35,7 @@ "source": { "type": "init" }, - "timestamp": "2026-03-13T14:56:21.948Z" + "timestamp": "2026-03-16T15:02:23.753Z" }, - "contentHash": "c35f6c0206eb1dec" + "contentHash": "6708e9847cb885b1" } diff --git a/examples/order-service/.svp/l4/order-pipeline.json b/examples/order-service/.svp/l4/order-pipeline.json index 46665a3..bbbba63 100644 --- a/examples/order-service/.svp/l4/order-pipeline.json +++ b/examples/order-service/.svp/l4/order-pipeline.json @@ -21,13 +21,13 @@ } ], "dataFlows": [ - { - "from": "val.result", - "to": "inv.items" - }, { "from": "inv.status", "to": "proc.inventory" + }, + { + "from": "val.result", + "to": "inv.items" } ], "revision": { @@ -36,7 +36,7 @@ "source": { "type": "init" }, - "timestamp": "2026-03-13T14:56:21.949Z" + "timestamp": "2026-03-16T15:02:23.766Z" }, - "contentHash": "e42bb69ae01465cc" + "contentHash": "bf9305cd595dbe40" } diff --git a/examples/order-service/.svp/l4/order-service.json b/examples/order-service/.svp/l4/order-service.json index bea8fea..da1daf0 100644 --- a/examples/order-service/.svp/l4/order-service.json +++ b/examples/order-service/.svp/l4/order-service.json @@ -15,7 +15,7 @@ "source": { "type": "init" }, - "timestamp": "2026-03-13T14:56:21.951Z" + "timestamp": "2026-03-16T15:02:23.768Z" }, - "contentHash": "082ceae284e2e291" + "contentHash": "8ed0c66e91b0eb53" } diff --git a/examples/order-service/.svp/l5.json b/examples/order-service/.svp/l5.json new file mode 100644 index 0000000..c026e29 --- /dev/null +++ b/examples/order-service/.svp/l5.json @@ -0,0 +1,43 @@ +{ + "id": "order-service", + "name": "Order Service", + "version": "0.1.0", + "intent": "An order processing service: validate incoming orders, check inventory availability, and process confirmed orders. Demonstrates a multi-step pipeline with data flow between blocks.", + "constraints": [ + "Orders must pass validation before inventory check", + "Inventory check must confirm availability before processing", + "Each step produces structured results consumed by the next step" + ], + "domains": [ + { + "name": "validation", + "description": "Order input validation and sanitization", + "dependencies": [] + }, + { + "name": "inventory", + "description": "Stock availability checking", + "dependencies": [ + "validation" + ] + }, + { + "name": "processing", + "description": "Order fulfillment and confirmation", + "dependencies": [ + "inventory" + ] + } + ], + "integrations": [], + "language": "en", + "contentHash": "1fa53bf8bc1664d4", + "revision": { + "rev": 3, + "parentRev": 2, + "source": { + "type": "human" + }, + "timestamp": "2026-03-17T16:51:58.491Z" + } +} diff --git a/examples/order-service/nodes/process-order/docs.md b/examples/order-service/nodes/process-order/docs.md new file mode 100644 index 0000000..4d6d96e --- /dev/null +++ b/examples/order-service/nodes/process-order/docs.md @@ -0,0 +1,45 @@ +## Intent + +执行订单的核心处理逻辑:计算价格、应用折扣、生成订单记录。这是下单流程的最后一步,前面的验证和库存检查都已通过。 + +## Edge Cases + +- 折扣冲突:VIP 折扣(85 折)和满减折扣(满 200 打 95 折)不叠加,取对用户更优的 +- 库存刚好用完:check-inventory 通过但实际扣减时库存不足 → 返回 "库存已变更" 错误 +- 并发下单:依赖数据库事务保证一致性,不在应用层做锁 +- 零总价订单:允许(全赠品场景),跳过支付通知 + +## Error Strategy + +处理阶段的错误分两类: +1. **可重试错误**(数据库超时、网络抖动):返回 retryable 标记,上层决定是否重试 +2. **业务错误**(库存不足、折扣过期):返回明确错误码,不重试 + +所有数据库操作在同一事务中完成。事务失败时全部回滚,不会出现"扣了库存但没生成订单"的情况。 + +## Integration Notes + +- 上游:接收验证通过的 OrderRequest + InventoryStatus +- 下游:成功后发出 OrderCreated 事件(通过事件总线),通知支付服务 +- 外部依赖:数据库(事务写入)、事件总线(异步发布) +- 幂等性:基于 request_id 做幂等检查,重复请求返回已有订单 + +## Examples + +输入: +```json +{ + "request": { "user_id": "...", "items": [...], "is_vip": true }, + "inventory": { "available": true, "reserved_until": "2024-01-01T01:00:00Z" } +} +``` + +输出(成功): +```json +{ + "success": true, + "order_id": "ord-550e8400", + "total": 254.99, + "discount_applied": "vip_85" +} +``` diff --git a/examples/order-service/nodes/validate-order/docs.md b/examples/order-service/nodes/validate-order/docs.md new file mode 100644 index 0000000..d7015bf --- /dev/null +++ b/examples/order-service/nodes/validate-order/docs.md @@ -0,0 +1,50 @@ +## Intent + +验证订单请求的完整性和合法性。这是下单流程的第一道防线,确保后续步骤不会因为非法输入而失败。 + +## Edge Cases + +- 空订单(items 数组为空):直接拒绝,返回 "至少需要一件商品" +- 超大订单(items > 50):拒绝并建议拆分订单 +- 重复商品:同一 product_id 出现多次时合并数量,而非报错 +- 零价格商品:允许(赠品场景),但 quantity 必须 > 0 +- 地址校验:只校验格式(zip 正则),不做地理有效性验证 + +## Error Strategy + +采用"收集全部错误"策略:不在第一个错误处停止,而是遍历所有字段,收集完整错误列表后一次性返回。这对前端表单展示更友好。 + +错误格式:`{ field: string, rule: string, message: string }` + +## Integration Notes + +- 上游:直接接收 HTTP 请求体,无前置处理 +- 下游:验证通过后流入 check-inventory,验证失败则直接返回客户端 +- 不调用任何外部服务,纯内存计算 + +## Examples + +输入: +```json +{ + "user_id": "550e8400-e29b-41d4-a716-446655440000", + "items": [{ "product_id": "abc-123", "quantity": 2, "price": 29.99 }], + "email": "user@example.com", + "shipping_address": { "zip": "100000" } +} +``` + +输出(通过): +```json +{ "valid": true, "errors": [] } +``` + +输出(失败): +```json +{ + "valid": false, + "errors": [ + { "field": "request.items[0].product_id", "rule": "uuid", "message": "product_id 必须是 UUID 格式" } + ] +} +``` diff --git a/package.json b/package.json index 863254d..4e12760 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,20 @@ { - "name": "svp", - "version": "0.1.0", - "private": true, + "name": "@svporg/forge", + "version": "0.1.1", "type": "module", + "description": "Semantic Voxel Protocol — five-layer observability framework for AI-assisted development", + "license": "MIT", "bin": { - "svp": "./dist/packages/cli/index.js" + "forge": "./dist/packages/cli/index.js" }, + "files": [ + "dist/packages/", + "README.md", + "LICENSE" + ], "scripts": { "build": "tsc", + "prepublishOnly": "npm run build", "test": "vitest run", "lint": "eslint packages/", "lint:fix": "eslint packages/ --fix", @@ -31,6 +38,9 @@ "typescript-eslint": "^8.57.0", "vitest": "^4.0.18" }, + "publishConfig": { + "access": "public" + }, "dependencies": { "commander": "^14.0.3", "yaml": "^2.8.2" diff --git a/packages/__tests__/e2e.test.ts b/packages/__tests__/e2e.test.ts new file mode 100644 index 0000000..5592184 --- /dev/null +++ b/packages/__tests__/e2e.test.ts @@ -0,0 +1,431 @@ +// E2E tests — exercise the full SVP pipeline across layers +// Uses real temp directories, no mocks. + +import { existsSync } from "node:fs"; +import { mkdir, rm } from "node:fs/promises"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { loadCheckInput } from "../cli/load.js"; +import { check } from "../core/check.js"; +import { compilePlan } from "../core/compile-plan.js"; +import { hashL2, hashL3, hashL4 } from "../core/hash.js"; +import { init } from "../core/init.js"; +import { writeL2, writeL3, writeL4, writeL5 } from "../core/store.js"; +import { rehashL2, rehashL3, rehashL4, rehashL5 } from "../skills/rehash.js"; +import type { L2CodeBlock } from "../core/l2.js"; +import type { L3Block } from "../core/l3.js"; +import type { L4Flow } from "../core/l4.js"; +import type { L5Blueprint } from "../core/l5.js"; +import type { ArtifactVersion } from "../core/version.js"; + +// ── Shared helpers ── + +const TEST_ROOT = path.resolve(import.meta.dirname, "../../.test-e2e"); + +const REV1: ArtifactVersion = { + rev: 1, + parentRev: null, + source: { type: "init" }, + timestamp: "2024-01-01T00:00:00Z", +}; + +let tmpDir: string; + +function makeL3(overrides: Partial = {}): L3Block { + const base = { + id: "validate-order", + name: "validate-order", + input: [{ name: "request", type: "OrderRequest" }], + output: [{ name: "result", type: "ValidationResult" }], + validate: {}, + constraints: [], + description: "Validates incoming order requests", + ...overrides, + }; + const { revision: _r, contentHash: _ch, ...hashInput } = base as L3Block; + return { + ...base, + revision: overrides.revision ?? REV1, + contentHash: overrides.contentHash ?? hashL3(hashInput), + }; +} + +function makeL4(overrides: Partial = {}): L4Flow { + const base = { + id: "order-flow", + name: "order-flow", + steps: [ + { + id: "s1", + action: "process" as const, + blockRef: "validate-order", + next: null, + }, + ], + dataFlows: [], + ...overrides, + }; + const { revision: _r, contentHash: _ch, ...hashInput } = base as L4Flow; + return { + ...base, + revision: overrides.revision ?? REV1, + contentHash: overrides.contentHash ?? hashL4(hashInput), + }; +} + +function makeL2(overrides: Partial = {}): L2CodeBlock { + const base = { + id: "validate-order-ts", + blockRef: "validate-order", + language: "typescript", + files: ["src/validate-order.ts"], + ...overrides, + }; + const { sourceHash: _s, revision: _r, contentHash: _ch, ...hashInput } = base as L2CodeBlock; + return { + ...base, + revision: overrides.revision ?? REV1, + sourceHash: overrides.sourceHash ?? "placeholder", + contentHash: overrides.contentHash ?? hashL2(hashInput), + }; +} + +// ── Test suites ── + +describe("E2E: SVP Pipeline", () => { + beforeEach(async () => { + tmpDir = path.join( + TEST_ROOT, + `e2e-${String(Date.now())}-${Math.random().toString(36).slice(2)}`, + ); + await mkdir(tmpDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + // ── Scenario 1: Init → Check (empty project is healthy) ── + + describe("1. Init → Check (empty project is healthy)", () => { + it("init produces a valid, consistent starting state", async () => { + const result = await init(tmpDir, { + name: "My E2E Project", + intent: "test the pipeline", + }); + + expect(result.created).toBe(true); + expect(result.l5).toBeDefined(); + expect(existsSync(path.join(tmpDir, ".svp", "l5.json"))).toBe(true); + expect(existsSync(path.join(tmpDir, ".svp", "l2"))).toBe(true); + expect(existsSync(path.join(tmpDir, ".svp", "l3"))).toBe(true); + expect(existsSync(path.join(tmpDir, ".svp", "l4"))).toBe(true); + + const input = await loadCheckInput(tmpDir); + expect(input.l5).toBeDefined(); + expect(input.l5!.name).toBe("My E2E Project"); + + const report = check(input); + expect(report.issues).toEqual([]); + expect(report.summary.errors).toBe(0); + expect(report.summary.warnings).toBe(0); + }); + }); + + // ── Scenario 2: Drift detection: modify L3 → check detects SOURCE_DRIFT ── + + describe("2. Drift detection: modify L3 → SOURCE_DRIFT", () => { + it("hash-based change tracking works across layers", async () => { + await init(tmpDir, { name: "drift-test" }); + + // Create synced L3 + L2 + const l3 = makeL3({ id: "my-block", name: "my-block" }); + await writeL3(tmpDir, l3); + + const l2 = makeL2({ + id: "my-block-ts", + blockRef: "my-block", + sourceHash: l3.contentHash, + }); + await writeL2(tmpDir, l2); + + // Verify initially no issues + let input = await loadCheckInput(tmpDir); + let report = check(input); + expect(report.summary.errors).toBe(0); + expect(report.summary.warnings).toBe(0); + + // Modify L3 content (add a constraint) and rehash + const modifiedL3 = makeL3({ + id: "my-block", + name: "my-block", + constraints: ["must respond within 100ms"], + }); + // Rehash to get correct new hash + const { data: rehashedL3 } = rehashL3(modifiedL3); + await writeL3(tmpDir, rehashedL3); + + // Now L2's sourceHash no longer matches L3's contentHash + input = await loadCheckInput(tmpDir); + report = check(input); + + const driftIssues = report.issues.filter((i) => i.code === "SOURCE_DRIFT"); + expect(driftIssues).toHaveLength(1); + expect(driftIssues[0].severity).toBe("warning"); + expect(driftIssues[0].layer).toBe("l2"); + expect(driftIssues[0].entityId).toBe("my-block-ts"); + }); + }); + + // ── Scenario 3: Referential integrity: L4 references missing L3 ── + + describe("3. Referential integrity: L4 references missing L3", () => { + it("cross-layer reference validation catches dangling refs", async () => { + await init(tmpDir, { name: "ref-test" }); + + // Create L4 flow with reference to nonexistent L3 block + const l4 = makeL4({ + id: "bad-flow", + name: "bad-flow", + steps: [ + { + id: "s1", + action: "process", + blockRef: "nonexistent-block", + next: null, + }, + ], + }); + await writeL4(tmpDir, l4); + + const input = await loadCheckInput(tmpDir); + const report = check(input); + + const missingRefErrors = report.issues.filter((i) => i.code === "MISSING_BLOCK_REF"); + expect(missingRefErrors.length).toBeGreaterThan(0); + expect(missingRefErrors[0].severity).toBe("error"); + expect(missingRefErrors[0].message).toContain("nonexistent-block"); + }); + }); + + // ── Scenario 4: Hash mismatch detection ── + + describe("4. Hash mismatch detection and rehash recovery", () => { + it("tampering is detected and rehash recovers", async () => { + await init(tmpDir, { name: "hash-test" }); + + // Write L3 block with correct hash + const l3 = makeL3({ id: "tampered-block", name: "tampered-block" }); + await writeL3(tmpDir, l3); + + // Verify initially correct + let input = await loadCheckInput(tmpDir); + let report = check(input); + const initialHashErrors = report.issues.filter( + (i) => i.code === "HASH_MISMATCH" && i.entityId === "tampered-block", + ); + expect(initialHashErrors).toHaveLength(0); + + // Manually tamper: modify a field without updating contentHash + const tamperedL3: L3Block = { + ...l3, + constraints: ["new constraint that was not hashed"], + // contentHash is stale — still the old value + }; + await writeL3(tmpDir, tamperedL3); + + // Check should detect HASH_MISMATCH + input = await loadCheckInput(tmpDir); + report = check(input); + const hashErrors = report.issues.filter( + (i) => i.code === "HASH_MISMATCH" && i.entityId === "tampered-block", + ); + expect(hashErrors).toHaveLength(1); + expect(hashErrors[0].severity).toBe("error"); + + // Rehash to fix + const { data: fixedL3, result: rehashResult } = rehashL3(tamperedL3); + expect(rehashResult.changed).toBe(true); + expect(rehashResult.newHash).not.toBe(rehashResult.oldHash); + await writeL3(tmpDir, fixedL3); + + // Check again — zero hash errors + input = await loadCheckInput(tmpDir); + report = check(input); + const postFixErrors = report.issues.filter( + (i) => i.code === "HASH_MISMATCH" && i.entityId === "tampered-block", + ); + expect(postFixErrors).toHaveLength(0); + }); + }); + + // ── Scenario 5: Compile plan generation from real project state ── + + describe("5. Compile plan generation from real project state", () => { + it("change detection → task generation pipeline", async () => { + await init(tmpDir, { name: "plan-test" }); + + // Create two L3 blocks + const l3a = makeL3({ id: "block-a", name: "block-a" }); + const l3b = makeL3({ + id: "block-b", + name: "block-b", + description: "Block B logic", + }); + await writeL3(tmpDir, l3a); + await writeL3(tmpDir, l3b); + + // Create L4 referencing both + a missing block + const l4 = makeL4({ + id: "main-flow", + name: "main-flow", + steps: [ + { id: "s1", action: "process", blockRef: "block-a", next: "s2" }, + { id: "s2", action: "process", blockRef: "block-b", next: "s3" }, + { + id: "s3", + action: "process", + blockRef: "missing-block", + next: null, + }, + ], + }); + await writeL4(tmpDir, l4); + + // Create L2 for block-a with STALE sourceHash (simulate drift) + const l2a = makeL2({ + id: "block-a-ts", + blockRef: "block-a", + sourceHash: "stale-old-hash", // does not match l3a.contentHash + }); + await writeL2(tmpDir, l2a); + + // block-b has NO L2 → should trigger "compile" + // block-a L2 has stale sourceHash → should trigger "recompile" + // missing-block doesn't exist as L3 → should trigger "update-ref" + + const input = await loadCheckInput(tmpDir); + const plan = compilePlan(input); + + expect(plan.summary.total).toBeGreaterThan(0); + + // Check for "compile" task (missing L2 for block-b) + const compileTasks = plan.tasks.filter((t) => t.action === "compile"); + expect(compileTasks.some((t) => t.targetId === "block-b")).toBe(true); + + // Check for "recompile" task (stale L2 for block-a) + const recompileTasks = plan.tasks.filter((t) => t.action === "recompile"); + expect(recompileTasks.some((t) => t.targetId === "block-a-ts")).toBe(true); + + // Check for "update-ref" task (missing L3 for missing-block) + const updateRefTasks = plan.tasks.filter((t) => t.action === "update-ref"); + expect(updateRefTasks.some((t) => t.targetId === "main-flow")).toBe(true); + }); + }); + + // ── Scenario 6: Rehash round-trip across all layers ── + + describe("6. Rehash round-trip across all layers", () => { + it("rehash fixes all layers, check confirms consistency", async () => { + await init(tmpDir, { name: "rehash-test" }); + + // Create all layers with placeholder contentHash + const l5: L5Blueprint = { + id: "rehash-project", + name: "Rehash Project", + version: "1.0", + intent: "test rehashing", + constraints: [], + domains: [], + integrations: [], + contentHash: "placeholder", + revision: REV1, + }; + + const l4: L4Flow = { + id: "rehash-flow", + name: "rehash-flow", + steps: [ + { + id: "s1", + action: "process", + blockRef: "rehash-block", + next: null, + }, + ], + dataFlows: [], + contentHash: "placeholder", + revision: REV1, + }; + + const l3: L3Block = { + id: "rehash-block", + name: "rehash-block", + input: [{ name: "in", type: "string" }], + output: [{ name: "out", type: "string" }], + validate: {}, + constraints: [], + description: "A block for rehash testing", + contentHash: "placeholder", + revision: REV1, + }; + + const l2: L2CodeBlock = { + id: "rehash-block-ts", + blockRef: "rehash-block", + language: "typescript", + files: ["src/rehash-block.ts"], + sourceHash: "placeholder", + contentHash: "placeholder", + revision: REV1, + }; + + // Write all with bad hashes + await writeL5(tmpDir, l5); + await writeL4(tmpDir, l4); + await writeL3(tmpDir, l3); + await writeL2(tmpDir, l2); + + // Check should report HASH_MISMATCH for all + let input = await loadCheckInput(tmpDir); + let report = check(input); + const hashMismatches = report.issues.filter((i) => i.code === "HASH_MISMATCH"); + // L5 + L4 + L3 + L2 = at least 4 hash mismatches + expect(hashMismatches.length).toBeGreaterThanOrEqual(4); + + // Rehash all layers + const { data: fixedL5, result: r5 } = rehashL5(l5); + const { data: fixedL4, result: r4 } = rehashL4(l4); + const { data: fixedL3, result: r3 } = rehashL3(l3); + // For L2, also fix sourceHash to match L3 + const l2WithCorrectSource: L2CodeBlock = { + ...l2, + sourceHash: fixedL3.contentHash, + }; + const { data: fixedL2, result: r2 } = rehashL2(l2WithCorrectSource); + + expect(r5.changed).toBe(true); + expect(r4.changed).toBe(true); + expect(r3.changed).toBe(true); + expect(r2.changed).toBe(true); + + // Verify revision bumps + expect(fixedL5.revision.rev).toBe(2); + expect(fixedL4.revision.rev).toBe(2); + expect(fixedL3.revision.rev).toBe(2); + expect(fixedL2.revision.rev).toBe(2); + + // Write fixed versions + await writeL5(tmpDir, fixedL5); + await writeL4(tmpDir, fixedL4); + await writeL3(tmpDir, fixedL3); + await writeL2(tmpDir, fixedL2); + + // Check → zero HASH_MISMATCH errors + input = await loadCheckInput(tmpDir); + report = check(input); + const remainingHashErrors = report.issues.filter((i) => i.code === "HASH_MISMATCH"); + expect(remainingHashErrors).toHaveLength(0); + }); + }); +}); diff --git a/packages/cli/apply.ts b/packages/cli/apply.ts deleted file mode 100644 index e3454e8..0000000 --- a/packages/cli/apply.ts +++ /dev/null @@ -1,63 +0,0 @@ -// ApplyResult — 把 Skill 执行结果写回 .svp/ 和 L1 源文件 - -import { mkdir, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { writeL2, writeL3, writeL4 } from "../core/index.js"; -import type { - Artifact, - FileArtifact, - L2CodeBlock, - L3Block, - L4Flow, - TaskExecution, -} from "../core/index.js"; - -/** 创建 applyResult 回调(需要 project root) */ -export function createApplyResult(root: string): (execution: TaskExecution) => Promise { - return async (execution: TaskExecution): Promise => { - // 写层制品(L2/L3/L4) - for (const artifact of execution.result.artifacts) { - await writeArtifact(root, artifact); - } - - // 写 L1 源文件(如果有) - if ("files" in execution.result) { - const result = execution.result; - for (const file of result.files) { - await writeFileArtifact(root, file); - } - } - }; -} - -/** 写入单个层制品 */ -async function writeArtifact(root: string, artifact: Artifact): Promise { - switch (artifact.layer) { - case "l2": { - await writeL2(root, artifact.data as L2CodeBlock); - break; - } - case "l3": { - await writeL3(root, artifact.data as L3Block); - break; - } - case "l4": { - await writeL4(root, artifact.data as L4Flow); - break; - } - } -} - -/** 写入 L1 源文件 */ -async function writeFileArtifact(root: string, file: FileArtifact): Promise { - const absPath = path.resolve(root, file.path); - - if (file.action === "delete") { - // 删除操作暂不实现,留给后续(需要用户确认) - return; - } - - // create 或 modify 都是写文件 - await mkdir(path.dirname(absPath), { recursive: true }); - await writeFile(absPath, file.content, "utf8"); -} diff --git a/packages/cli/commands/check.test.ts b/packages/cli/commands/check.test.ts index 2d3f477..0b6ea41 100644 --- a/packages/cli/commands/check.test.ts +++ b/packages/cli/commands/check.test.ts @@ -79,6 +79,7 @@ function makeL5(overrides: Partial = {}): L5Blueprint { constraints: ["Must handle 1000 orders/sec"], domains: [{ name: "order", description: "订单域", dependencies: [] }], integrations: [{ name: "stripe", type: "api", description: "Payment gateway" }], + language: "en", ...overrides, }; const contentHash = computeHash(base as Record); diff --git a/packages/cli/commands/compile-blueprint.ts b/packages/cli/commands/compile-blueprint.ts deleted file mode 100644 index 8322411..0000000 --- a/packages/cli/commands/compile-blueprint.ts +++ /dev/null @@ -1,36 +0,0 @@ -// svp compile-blueprint — 将 YAML 节点图编译为 .svp/ JSON - -import { compileBlueprint } from "../../compiler/compile.js"; -import type { Command } from "commander"; - -export function registerCompileBlueprint(program: Command): void { - program - .command("compile-blueprint") - .description("将 nodes/*.yaml + graphs/*.yaml 编译为 .svp/ 下的 L3/L4 JSON") - .option("-r, --root ", "项目根目录", process.cwd()) - .option("--json", "以 JSON 格式输出结果") - .action(async (options: { root: string; json?: boolean }) => { - const result = await compileBlueprint(options.root); - - if (!result.ok) { - if (options.json === true) { - console.log(JSON.stringify({ ok: false, error: result.error }, null, 2)); - } else { - console.error(`编译失败: ${result.error.message}`); - if (result.error.path !== undefined) { - console.error(` 文件: ${result.error.path}`); - } - } - process.exitCode = 1; - return; - } - - if (options.json === true) { - console.log(JSON.stringify({ ok: true, ...result.value }, null, 2)); - } else { - console.log("编译完成!"); - console.log(` L3 blocks: ${result.value.l3Blocks.join(", ") || "(无)"}`); - console.log(` L4 flows: ${result.value.l4Flows.join(", ") || "(无)"}`); - } - }); -} diff --git a/packages/cli/commands/compile.ts b/packages/cli/commands/compile.ts deleted file mode 100644 index 5e9ba7c..0000000 --- a/packages/cli/commands/compile.ts +++ /dev/null @@ -1,119 +0,0 @@ -// svp compile — 收敛循环编排命令 -// 调用 orchestrator 驱动 Skill 执行,直到所有层同步 - -import { runOrchestrator } from "../../core/index.js"; -import { createApplyResult } from "../apply.js"; -import { loadCheckInput } from "../load.js"; -import { createResolver } from "../resolve.js"; -import type { CodeCLIAdapter } from "../../core/adapter.js"; -import type { OrchestratorConfig } from "../../core/index.js"; -import type { Command } from "commander"; - -/** 注册 svp compile 子命令 */ -export function registerCompile(program: Command, adapter: CodeCLIAdapter): void { - program - .command("compile") - .description("Run convergence loop: detect changes → dispatch skills → apply results") - .option("-r, --root ", "Project root directory", ".") - .option("--max-iterations ", "Maximum convergence iterations", "5") - .option("--dry-run", "Only show what would be done, don't write results") - .option("--json", "Output as JSON") - .action( - async (options: { root: string; maxIterations: string; dryRun: boolean; json: boolean }) => { - const root = options.root; - const maxIterations = Number.parseInt(options.maxIterations, 10); - - // Skill 注册表由 adapter 提供(skill 实现来自宿主 CLI) - const registry = adapter.createSkillRegistry(); - const resolver = createResolver(root); - const applyResult = createApplyResult(root); - - const config: OrchestratorConfig = { - maxIterations, - ...(options.dryRun - ? { - skillConfigs: { - compile: { - maxFilesToCreate: 10, - maxFilesToModify: 20, - dryRun: true, - requireHumanApproval: false, - }, - recompile: { - maxFilesToCreate: 10, - maxFilesToModify: 20, - dryRun: true, - requireHumanApproval: false, - }, - "update-ref": { - maxFilesToCreate: 10, - maxFilesToModify: 20, - dryRun: true, - requireHumanApproval: false, - }, - review: { - maxFilesToCreate: 10, - maxFilesToModify: 20, - dryRun: true, - requireHumanApproval: true, - }, - }, - } - : {}), - }; - - const result = await runOrchestrator( - () => loadCheckInput(root, { computeSignatures: true }), - registry, - resolver, - applyResult, - config, - ); - - if (options.json) { - console.log(JSON.stringify(result, null, 2)); - return; - } - - // 人类可读输出 - console.log(`svp compile — ${result.converged ? "converged" : "did not converge"}`); - console.log(` iterations: ${String(result.iterations.length)}`); - console.log(` tasks executed: ${String(result.totalTasksExecuted)}`); - console.log(); - - for (const iter of result.iterations) { - console.log(`Iteration ${String(iter.iteration)}:`); - console.log(` dispatched: ${String(iter.tasksDispatched)}`); - - for (const exec of iter.executions) { - const icon = - exec.result.status === "done" - ? "OK" - : exec.result.status === "blocked" - ? "BLOCKED" - : "REVIEW"; - console.log( - ` ${icon} ${exec.task.action} ${exec.task.targetLayer}/${exec.task.targetId}`, - ); - if (exec.result.notes !== "") { - console.log(` ${exec.result.notes}`); - } - } - - if (iter.errors.length > 0) { - for (const err of iter.errors) { - console.log(` ERROR: ${err}`); - } - } - console.log(); - } - - if (!result.converged && result.iterations.length > 0) { - console.log( - "Warning: convergence loop did not complete. Some tasks may still be pending.", - ); - process.exitCode = 1; - } - }, - ); -} diff --git a/packages/cli/commands/fix.ts b/packages/cli/commands/fix.ts new file mode 100644 index 0000000..7d48fc4 --- /dev/null +++ b/packages/cli/commands/fix.ts @@ -0,0 +1,71 @@ +// svp fix — 自动检测一致性问题并批量生成修复提示词 +// check → compilePlan → resolve → buildPrompt → stdout + +import { compilePlan } from "../../core/compile-plan.js"; +import { getLanguage } from "../../core/i18n.js"; +import { DEFAULT_SKILL_CONFIG } from "../../core/skill.js"; +import { buildPrompt, renderPrompt } from "../../skills/prompt-builder.js"; +import { loadCheckInput } from "../load.js"; +import { createResolver } from "../resolve.js"; +import type { CompileTask, TaskAction } from "../../core/compile-plan.js"; +import type { SkillInput } from "../../core/skill.js"; +import type { Command } from "commander"; + +/** 注册 svp fix 命令 */ +export function registerFix(program: Command): void { + program + .command("fix") + .description("Auto-detect issues and generate fix prompts for all compile tasks") + .option("-r, --root ", "Project root directory", ".") + .option("--severity ", "Minimum severity: error (default) or warning", "error") + .option("--action ", "Only generate prompts for this action (compile/recompile/update-ref/review)") + .option("--dry-run", "Print compile-plan summary only, do not generate prompts") + .action(async (options: { root: string; severity: string; action?: string; dryRun?: boolean }) => { + const root = options.root; + + let input; + try { + input = await loadCheckInput(root); + } catch { + console.error(`Error: cannot load .svp/ data from "${root}". Run \`svp init\` first.`); + process.exitCode = 1; + return; + } + + const language = getLanguage(input.l5); + const plan = compilePlan(input, language); + + // Filter by action if specified + let tasks = plan.tasks as CompileTask[]; + if (options.action !== undefined) { + const action = options.action as TaskAction; + tasks = tasks.filter((t) => t.action === action); + } + + if (tasks.length === 0) { + console.log("All checks passed — no fix tasks generated."); + return; + } + + // Dry-run: print summary only + if (options.dryRun === true) { + console.log(`Compile plan: ${tasks.length} task(s)`); + for (const [i, task] of tasks.entries()) { + console.log(` [${i + 1}] ${task.action} · ${task.targetLayer}/${task.targetId} · ${task.issueCode}`); + } + return; + } + + // Generate prompts + const resolver = createResolver(root); + for (const [i, task] of tasks.entries()) { + if (i > 0) console.log("\n---\n"); + console.log(`## [${i + 1}/${tasks.length}] ${task.action} · ${task.targetLayer}/${task.targetId} · ${task.issueCode}\n`); + + const resolved = await resolver.resolve(task, input); + const skillInput: SkillInput = { task, resolved, config: DEFAULT_SKILL_CONFIG }; + const structured = buildPrompt(skillInput); + console.log(renderPrompt(structured)); + } + }); +} diff --git a/packages/cli/commands/init.test.ts b/packages/cli/commands/init.test.ts new file mode 100644 index 0000000..464afc2 --- /dev/null +++ b/packages/cli/commands/init.test.ts @@ -0,0 +1,483 @@ +// svp init CLI 命令的集成测试 + +import { mkdir, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { Command } from "commander"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { readL5 } from "../../core/store.js"; +import { registerInit } from "./init.js"; + +/** 捕获 console.log/error 并运行 init 子命令 */ +async function runInit( + testRoot: string, + args: string[], +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const logs: string[] = []; + const errors: string[] = []; + const originalLog = console.log; + const originalError = console.error; + + console.log = (...a: unknown[]) => logs.push(a.map(String).join(" ")); + console.error = (...a: unknown[]) => errors.push(a.map(String).join(" ")); + + process.exitCode = undefined; + + const program = new Command(); + program.exitOverride(); + registerInit(program); + + try { + await program.parseAsync(["init", ...args, "-r", testRoot], { from: "user" }); + } catch { + // commander may throw on exitOverride + } + + console.log = originalLog; + console.error = originalError; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const exitCode = process.exitCode ?? 0; + process.exitCode = undefined; + + return { + stdout: logs.join("\n"), + stderr: errors.join("\n"), + exitCode, + }; +} + +async function dirExists(p: string): Promise { + try { + const s = await stat(p); + return s.isDirectory(); + } catch { + return false; + } +} + +async function fileExists(p: string): Promise { + try { + const s = await stat(p); + return s.isFile(); + } catch { + return false; + } +} + +// ── Tests ── + +describe("svp init", () => { + let testRoot: string; + + beforeEach(async () => { + testRoot = path.join( + tmpdir(), + `svp-init-test-${String(Date.now())}-${String(Math.random()).slice(2, 8)}`, + ); + await mkdir(testRoot, { recursive: true }); + }); + + afterEach(async () => { + await rm(testRoot, { recursive: true, force: true }); + }); + + it("basic init: creates .svp/ directory with L5 blueprint", async () => { + const { stdout, exitCode } = await runInit(testRoot, ["--name", "My App"]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Initialized"); + + const svpDir = path.join(testRoot, ".svp"); + expect(await dirExists(svpDir)).toBe(true); + expect(await dirExists(path.join(svpDir, "l2"))).toBe(true); + expect(await dirExists(path.join(svpDir, "l3"))).toBe(true); + expect(await dirExists(path.join(svpDir, "l4"))).toBe(true); + + const l5 = await readL5(testRoot); + expect(l5).not.toBeNull(); + expect(l5?.name).toBe("My App"); + }); + + it("--host claude-code generates slash commands in .claude/commands/", async () => { + const { exitCode } = await runInit(testRoot, ["--name", "My App", "--host", "claude-code"]); + + expect(exitCode).toBe(0); + + const commandsDir = path.join(testRoot, ".claude", "commands"); + expect(await dirExists(commandsDir)).toBe(true); + + // Should have at least one .md slash command file + const svpCmd = path.join(commandsDir, "svp.md"); + expect(await fileExists(svpCmd)).toBe(true); + }); + + it("--host claude-code appends SVP section to CLAUDE.md", async () => { + const { exitCode } = await runInit(testRoot, ["--name", "My App", "--host", "claude-code"]); + + expect(exitCode).toBe(0); + + const claudeMdPath = path.join(testRoot, "CLAUDE.md"); + expect(await fileExists(claudeMdPath)).toBe(true); + + const content = await readFile(claudeMdPath, "utf8"); + expect(content).toContain("## SVP"); + }); + + it("CLAUDE.md already has SVP section: does NOT duplicate it", async () => { + // Pre-create CLAUDE.md with SVP section + const claudeMdPath = path.join(testRoot, "CLAUDE.md"); + await mkdir(path.join(testRoot, ".claude", "commands"), { recursive: true }); + const existing = "# My Project\n\n## SVP\n\nAlready here.\n"; + await rm(claudeMdPath, { force: true }); + await import("node:fs/promises").then((fs) => fs.writeFile(claudeMdPath, existing, "utf8")); + + const { stdout, exitCode } = await runInit(testRoot, [ + "--name", + "My App", + "--host", + "claude-code", + ]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("already contains SVP section"); + + const content = await readFile(claudeMdPath, "utf8"); + // Count occurrences of "## SVP" — should still be exactly 1 + const matches = content.match(/## SVP/g) ?? []; + expect(matches.length).toBe(1); + }); + + it("re-init skips creation when .svp/ already exists", async () => { + // First init + await runInit(testRoot, ["--name", "My App"]); + const l5First = await readL5(testRoot); + + // Second init — should not overwrite + const { stdout, exitCode } = await runInit(testRoot, ["--name", "Different Name"]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("already exists"); + + // L5 should be unchanged from first init + const l5Second = await readL5(testRoot); + expect(l5Second?.name).toBe(l5First?.name); + }); + + it("re-init still generates host files when --host provided", async () => { + // First init without --host + await runInit(testRoot, ["--name", "My App"]); + + // Second init with --host claude-code + const { stdout, exitCode } = await runInit(testRoot, [ + "--name", + "My App", + "--host", + "claude-code", + ]); + + expect(exitCode).toBe(0); + // Still generates the claude code files even on re-init + expect(stdout).toContain("skill files"); + + const commandsDir = path.join(testRoot, ".claude", "commands"); + expect(await dirExists(commandsDir)).toBe(true); + }); + + it("custom version and intent flags are applied to L5", async () => { + const { exitCode } = await runInit(testRoot, [ + "--name", + "My App", + "--version", + "1.2.3", + "--intent", + "A custom intent for testing", + ]); + + expect(exitCode).toBe(0); + + const l5 = await readL5(testRoot); + expect(l5?.version).toBe("1.2.3"); + expect(l5?.intent).toBe("A custom intent for testing"); + }); + + it("name slugification: 'My Cool App' becomes id 'my-cool-app'", async () => { + const { exitCode } = await runInit(testRoot, ["--name", "My Cool App"]); + + expect(exitCode).toBe(0); + + const l5 = await readL5(testRoot); + expect(l5?.id).toBe("my-cool-app"); + expect(l5?.name).toBe("My Cool App"); + }); + + // ── Multi-host adapter tests ── + + it("--host kimi-code generates skill file in .agents/skills/", async () => { + const { exitCode } = await runInit(testRoot, ["--name", "My App", "--host", "kimi-code"]); + + expect(exitCode).toBe(0); + + const skillFile = path.join(testRoot, ".agents", "skills", "svp", "SKILL.md"); + expect(await fileExists(skillFile)).toBe(true); + + const content = await readFile(skillFile, "utf8"); + // Kimi skills have YAML frontmatter + expect(content).toMatch(/^---\nname: svp/); + expect(content).toContain("type: flow"); + // Should have the workflow content + expect(content).toContain("Step 0: Diagnostic Router"); + }); + + it("--host kimi-code creates AGENTS.md with SVP section", async () => { + const { exitCode } = await runInit(testRoot, ["--name", "My App", "--host", "kimi-code"]); + + expect(exitCode).toBe(0); + + const agentsMdPath = path.join(testRoot, "AGENTS.md"); + expect(await fileExists(agentsMdPath)).toBe(true); + + const content = await readFile(agentsMdPath, "utf8"); + expect(content).toContain("## SVP"); + // Kimi uses generic model names + expect(content).toContain("strongest model"); + // Slash command should use /skill:svp + expect(content).toContain("/skill:svp"); + }); + + it("--host codex generates skill file in .codex/skills/", async () => { + const { exitCode } = await runInit(testRoot, ["--name", "My App", "--host", "codex"]); + + expect(exitCode).toBe(0); + + const skillFile = path.join(testRoot, ".codex", "skills", "svp", "SKILL.md"); + expect(await fileExists(skillFile)).toBe(true); + + const content = await readFile(skillFile, "utf8"); + // Codex has no YAML frontmatter + expect(content).not.toMatch(/^---\n/); + // Should have Codex model tiers + expect(content).toContain("o3"); + expect(content).toContain("o4-mini"); + // Should have the workflow content + expect(content).toContain("Step 0: Diagnostic Router"); + }); + + it("--host codex creates AGENTS.md with SVP section", async () => { + const { exitCode } = await runInit(testRoot, ["--name", "My App", "--host", "codex"]); + + expect(exitCode).toBe(0); + + const agentsMdPath = path.join(testRoot, "AGENTS.md"); + expect(await fileExists(agentsMdPath)).toBe(true); + + const content = await readFile(agentsMdPath, "utf8"); + expect(content).toContain("## SVP"); + expect(content).toContain("o3"); + expect(content).toContain("o4-mini"); + }); + + it("AGENTS.md already has SVP section: does NOT duplicate (kimi-code)", async () => { + const agentsMdPath = path.join(testRoot, "AGENTS.md"); + await import("node:fs/promises").then((fs) => + fs.writeFile(agentsMdPath, "# Agents\n\n## SVP\n\nExisting.\n", "utf8"), + ); + + const { stdout, exitCode } = await runInit(testRoot, [ + "--name", + "My App", + "--host", + "kimi-code", + ]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("already contains SVP section"); + + const content = await readFile(agentsMdPath, "utf8"); + const matches = content.match(/## SVP/g) ?? []; + expect(matches.length).toBe(1); + }); + + it("unknown --host value is rejected", async () => { + const { stderr, exitCode } = await runInit(testRoot, [ + "--name", + "My App", + "--host", + "unknown-host", + ]); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Unknown host"); + }); + + // ── Cursor adapter tests ── + + it("--host cursor generates skill file in .cursor/commands/", async () => { + const { exitCode } = await runInit(testRoot, ["--name", "My App", "--host", "cursor"]); + + expect(exitCode).toBe(0); + + const skillFile = path.join(testRoot, ".cursor", "commands", "svp.md"); + expect(await fileExists(skillFile)).toBe(true); + + const content = await readFile(skillFile, "utf8"); + expect(content).toContain("Step 0: Diagnostic Router"); + }); + + it("--host cursor creates .cursor/rules/svp.mdc with frontmatter", async () => { + const { exitCode } = await runInit(testRoot, ["--name", "My App", "--host", "cursor"]); + + expect(exitCode).toBe(0); + + const mdcPath = path.join(testRoot, ".cursor", "rules", "svp.mdc"); + expect(await fileExists(mdcPath)).toBe(true); + + const content = await readFile(mdcPath, "utf8"); + expect(content).toMatch(/^---\n/); + expect(content).toContain("alwaysApply: true"); + expect(content).toContain("globs: .svp/**"); + expect(content).toContain("## SVP"); + }); + + // ── Windsurf adapter tests ── + + it("--host windsurf generates skill file in .windsurf/commands/", async () => { + const { exitCode } = await runInit(testRoot, ["--name", "My App", "--host", "windsurf"]); + + expect(exitCode).toBe(0); + + const skillFile = path.join(testRoot, ".windsurf", "commands", "svp.md"); + expect(await fileExists(skillFile)).toBe(true); + + const content = await readFile(skillFile, "utf8"); + expect(content).toContain("Step 0: Diagnostic Router"); + }); + + it("--host windsurf creates .windsurf/rules/svp.md with SVP section", async () => { + const { exitCode } = await runInit(testRoot, ["--name", "My App", "--host", "windsurf"]); + + expect(exitCode).toBe(0); + + const rulesPath = path.join(testRoot, ".windsurf", "rules", "svp.md"); + expect(await fileExists(rulesPath)).toBe(true); + + const content = await readFile(rulesPath, "utf8"); + expect(content).toContain("## SVP"); + // Windsurf uses generic model names + expect(content).toContain("strongest model"); + }); + + // ── GitHub Copilot adapter tests ── + + it("--host github-copilot generates skill file in .github/prompts/", async () => { + const { exitCode } = await runInit(testRoot, ["--name", "My App", "--host", "github-copilot"]); + + expect(exitCode).toBe(0); + + const skillFile = path.join(testRoot, ".github", "prompts", "svp.prompt.md"); + expect(await fileExists(skillFile)).toBe(true); + + const content = await readFile(skillFile, "utf8"); + expect(content).toContain("Step 0: Diagnostic Router"); + }); + + it("--host github-copilot creates .github/copilot-instructions.md with SVP section", async () => { + const { exitCode } = await runInit(testRoot, ["--name", "My App", "--host", "github-copilot"]); + + expect(exitCode).toBe(0); + + const instrPath = path.join(testRoot, ".github", "copilot-instructions.md"); + expect(await fileExists(instrPath)).toBe(true); + + const content = await readFile(instrPath, "utf8"); + expect(content).toContain("## SVP"); + // Uses generic model names like Windsurf + expect(content).toContain("strongest model"); + }); + + it("i18n shows correct host name for github-copilot", async () => { + const { stdout, exitCode } = await runInit(testRoot, [ + "--name", + "My App", + "--host", + "github-copilot", + ]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("GitHub Copilot"); + expect(stdout).not.toContain("Claude Code"); + }); + + // ── Auto-detection tests ── + + it("auto-detects host from .cursor/ directory when --host not provided", async () => { + // Create .cursor marker directory + await mkdir(path.join(testRoot, ".cursor"), { recursive: true }); + + const { stdout, exitCode } = await runInit(testRoot, ["--name", "My App"]); + + expect(exitCode).toBe(0); + // Should auto-detect and generate cursor files + expect(stdout).toContain("Cursor"); + expect(stdout).toContain("skill files"); + }); + + it("auto-detects host from .windsurf/ directory when --host not provided", async () => { + await mkdir(path.join(testRoot, ".windsurf"), { recursive: true }); + + const { stdout, exitCode } = await runInit(testRoot, ["--name", "My App"]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Windsurf"); + }); + + it("shows message when multiple hosts detected and no --host", async () => { + await mkdir(path.join(testRoot, ".claude"), { recursive: true }); + await mkdir(path.join(testRoot, ".cursor"), { recursive: true }); + + const { stdout, exitCode } = await runInit(testRoot, ["--name", "My App"]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Multiple hosts detected"); + }); + + // ── Parameterized i18n tests ── + + it("i18n shows correct host name for kimi-code", async () => { + const { stdout, exitCode } = await runInit(testRoot, [ + "--name", + "My App", + "--host", + "kimi-code", + ]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Kimi Code"); + expect(stdout).not.toContain("Claude Code"); + }); + + it("i18n shows correct host name for cursor", async () => { + const { stdout, exitCode } = await runInit(testRoot, [ + "--name", + "My App", + "--host", + "cursor", + ]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Cursor"); + }); + + it("i18n shows correct context file path for each host", async () => { + // Cursor should show .cursor/rules/svp.mdc + const cursorResult = await runInit(testRoot, ["--name", "My App", "--host", "cursor"]); + expect(cursorResult.stdout).toContain(".cursor/rules/svp.mdc"); + + // Clean and test windsurf + await rm(testRoot, { recursive: true, force: true }); + await mkdir(testRoot, { recursive: true }); + + const windsurfResult = await runInit(testRoot, ["--name", "My App", "--host", "windsurf"]); + expect(windsurfResult.stdout).toContain(".windsurf/rules/svp.md"); + }); +}); diff --git a/packages/cli/commands/init.ts b/packages/cli/commands/init.ts index 7604d06..99e234b 100644 --- a/packages/cli/commands/init.ts +++ b/packages/cli/commands/init.ts @@ -3,10 +3,17 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { init } from "../../core/init.js"; -import { generateClaudeMdSection } from "../../skills/templates/claude-md.js"; -import { generateSlashCommands } from "../../skills/templates/slash-commands.js"; +import { t, getLanguage } from "../../core/i18n.js"; +import { getAdapter, getAllAdapterIds, detectHosts } from "../../skills/adapters/index.js"; +import type { HostId, HostAdapter } from "../../skills/adapters/index.js"; import type { Command } from "commander"; +const VALID_HOSTS = getAllAdapterIds(); + +function isValidHost(host: string): host is HostId { + return (VALID_HOSTS as readonly string[]).includes(host); +} + /** 注册 svp init 子命令 */ export function registerInit(program: Command): void { program @@ -15,8 +22,9 @@ export function registerInit(program: Command): void { .requiredOption("-n, --name ", "Project name") .option("-v, --version ", "Initial version", "0.1.0") .option("-i, --intent ", "Project intent (what it does)") - .option("--host ", "Host CLI integration (claude-code)") + .option(`--host `, `Host CLI integration (${VALID_HOSTS.join(" | ")})`) .option("-r, --root ", "Project root directory", ".") + .option("-l, --language ", "Language preference (ISO 639-1, e.g., en, zh)") .action( async (options: { name: string; @@ -24,26 +32,49 @@ export function registerInit(program: Command): void { intent?: string; host?: string; root: string; + language?: string; }) => { + const lang = options.language ?? "en"; + + // Resolve host: explicit flag > auto-detect + let hostId: HostId | undefined; + if (options.host != null) { + if (!isValidHost(options.host)) { + console.error(`Unknown host: ${options.host}. Valid hosts: ${VALID_HOSTS.join(", ")}`); + process.exitCode = 1; + return; + } + hostId = options.host; + } else { + // Auto-detect from project directory markers + const detected = await detectHosts(options.root); + if (detected.length === 1) { + hostId = detected[0]; + } else if (detected.length > 1) { + console.log( + `Multiple hosts detected: ${detected.join(", ")}. Use --host to specify one.`, + ); + } + } + const result = await init(options.root, { name: options.name, version: options.version, intent: options.intent, - host: options.host === "claude-code" ? "claude-code" : undefined, + host: hostId, + language: options.language, }); if (!result.created) { - console.log( - ".svp/ directory already exists. Use `svp check` to validate or `svp view` to inspect.", - ); - // Still generate host files if requested - if (options.host === "claude-code") { - await generateClaudeCodeFiles(options.root, options.name); + console.log(t(lang, "cli.init.alreadyExists")); + // Still generate host files if resolved + if (hostId != null) { + await generateHostFiles(options.root, getAdapter(hostId), options.name, lang); } return; } - console.log(`Initialized .svp/ in ${options.root}`); + console.log(t(lang, "cli.init.initialized", { root: options.root })); console.log(); const l5 = result.l5!; console.log(` L5: ${l5.name} v${l5.version}`); @@ -51,7 +82,7 @@ export function registerInit(program: Command): void { console.log(` intent: ${l5.intent}`); } console.log(); - console.log("Directory structure:"); + console.log(t(lang, "cli.init.dirStructure")); console.log(" .svp/"); console.log(" ├── l5.json (blueprint)"); console.log(" ├── l4/ (logic chains)"); @@ -59,47 +90,60 @@ export function registerInit(program: Command): void { console.log(" └── l2/ (code blocks)"); // Host-specific integration - if (options.host === "claude-code") { + if (hostId != null) { console.log(); - await generateClaudeCodeFiles(options.root, options.name); + await generateHostFiles(options.root, getAdapter(hostId), options.name, lang); } else { console.log(); - console.log("Next: edit .svp/l5.json to add domains, constraints, and integrations."); + console.log(t(lang, "cli.init.nextEdit")); } }, ); } -async function generateClaudeCodeFiles(root: string, projectName: string): Promise { - // 1. Generate slash commands to .claude/commands/ - const commandsDir = path.join(root, ".claude", "commands"); - await mkdir(commandsDir, { recursive: true }); +async function generateHostFiles( + root: string, + adapter: HostAdapter, + projectName: string, + lang: string, +): Promise { + const i18nParams = { + host: adapter.displayName, + skillDir: adapter.skillDir(), + contextFile: adapter.contextFilePath(), + }; + + // 1. Generate skill/command files + const skillBaseDir = path.join(root, adapter.skillDir()); + const files = adapter.generateSkillFiles(lang); - const commands = generateSlashCommands(); - for (const cmd of commands) { - await writeFile(path.join(commandsDir, cmd.filename), cmd.content, "utf8"); + for (const file of files) { + const filePath = path.join(skillBaseDir, file.relativePath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, file.content, "utf8"); } - console.log(`Claude Code: ${String(commands.length)} slash commands → .claude/commands/`); + console.log(t(lang, "cli.init.slashCommands", { count: String(files.length), ...i18nParams })); - // 2. Append SVP section to CLAUDE.md - const claudeMdPath = path.join(root, "CLAUDE.md"); - const svpSection = generateClaudeMdSection(projectName); + // 2. Append SVP section to context file + const contextPath = path.join(root, adapter.contextFilePath()); + const svpSection = adapter.generateContextSection(projectName, lang); let existing = ""; try { - existing = await readFile(claudeMdPath, "utf8"); + existing = await readFile(contextPath, "utf8"); } catch { - // File doesn't exist yet + // File doesn't exist yet — ensure parent directory exists + await mkdir(path.dirname(contextPath), { recursive: true }); } - if (existing.includes("## SVP")) { - console.log("Claude Code: CLAUDE.md already contains SVP section (skipped)"); + if (existing.includes(adapter.contextMarker())) { + console.log(t(lang, "cli.init.claudeMdSkipped", i18nParams)); } else { const separator = existing.length > 0 ? "\n\n" : ""; - await writeFile(claudeMdPath, existing + separator + svpSection + "\n", "utf8"); - console.log("Claude Code: SVP section → CLAUDE.md"); + await writeFile(contextPath, existing + separator + svpSection + "\n", "utf8"); + console.log(t(lang, "cli.init.claudeMdSection", i18nParams)); } console.log(); - console.log("Next: use /svp-build to design and implement your system."); + console.log(t(lang, "cli.init.nextSvp")); } diff --git a/packages/cli/commands/link.test.ts b/packages/cli/commands/link.test.ts new file mode 100644 index 0000000..a882b13 --- /dev/null +++ b/packages/cli/commands/link.test.ts @@ -0,0 +1,244 @@ +// svp link CLI 命令的集成测试 + +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { Command } from "commander"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { computeHash } from "../../core/hash.js"; +import { readL2, writeL2, writeL3 } from "../../core/store.js"; +import { registerLink } from "./link.js"; +import type { L2CodeBlock } from "../../core/l2.js"; +import type { L3Block } from "../../core/l3.js"; +import type { ArtifactVersion } from "../../core/version.js"; + +// ── Fixture 工厂 ── + +const REV1: ArtifactVersion = { + rev: 1, + parentRev: null, + source: { type: "init" }, + timestamp: "2024-01-01T00:00:00Z", +}; + +function makeL3(overrides: Partial = {}): L3Block { + const base: Omit = { + id: "validate-order", + name: "Validate Order", + input: [{ name: "request", type: "OrderRequest" }], + output: [{ name: "result", type: "ValidationResult" }], + validate: { request: "required" }, + constraints: ["output.result.valid iff output.result.errors is empty"], + description: "Validate the order request", + ...overrides, + }; + const contentHash = computeHash(base as Record); + return { ...base, revision: REV1, contentHash }; +} + +function makeL2(l3: L3Block, overrides: Partial = {}): L2CodeBlock { + const base: Omit = { + id: l3.id, + blockRef: l3.id, + language: "typescript", + files: [`src/${l3.id}.ts`], + ...overrides, + }; + const contentHash = computeHash(base as Record); + return { ...base, revision: REV1, sourceHash: l3.contentHash, contentHash }; +} + +/** 捕获 console.log/error 并运行 link 子命令 */ +async function runLink( + testRoot: string, + args: string[], +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const logs: string[] = []; + const errors: string[] = []; + const originalLog = console.log; + const originalError = console.error; + + console.log = (...a: unknown[]) => logs.push(a.map(String).join(" ")); + console.error = (...a: unknown[]) => errors.push(a.map(String).join(" ")); + + process.exitCode = undefined; + + const program = new Command(); + program.exitOverride(); + registerLink(program); + + try { + await program.parseAsync(["link", ...args, "-r", testRoot], { from: "user" }); + } catch { + // commander may throw on exitOverride + } + + console.log = originalLog; + console.error = originalError; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const exitCode = process.exitCode ?? 0; + process.exitCode = undefined; + + return { + stdout: logs.join("\n"), + stderr: errors.join("\n"), + exitCode, + }; +} + +// ── Tests ── + +describe("svp link", () => { + let testRoot: string; + + beforeEach(async () => { + testRoot = path.join( + tmpdir(), + `svp-link-test-${String(Date.now())}-${String(Math.random()).slice(2, 8)}`, + ); + await mkdir(testRoot, { recursive: true }); + }); + + afterEach(async () => { + await rm(testRoot, { recursive: true, force: true }); + }); + + it("creates new L2 link when L3 exists and no L2 exists", async () => { + const l3 = makeL3(); + await writeL3(testRoot, l3); + + const { stdout, exitCode } = await runLink(testRoot, [ + "validate-order", + "--files", + "src/validate-order.ts", + ]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Linked"); + expect(stdout).toContain("validate-order"); + + const l2 = await readL2(testRoot, "validate-order"); + expect(l2).not.toBeNull(); + expect(l2?.id).toBe("validate-order"); + expect(l2?.blockRef).toBe("validate-order"); + expect(l2?.revision.rev).toBe(1); + }); + + it("sourceHash is set to the L3 contentHash", async () => { + const l3 = makeL3(); + await writeL3(testRoot, l3); + + await runLink(testRoot, ["validate-order", "--files", "src/validate-order.ts"]); + + const l2 = await readL2(testRoot, "validate-order"); + expect(l2?.sourceHash).toBe(l3.contentHash); + }); + + it("stores multiple files correctly", async () => { + const l3 = makeL3(); + await writeL3(testRoot, l3); + + await runLink(testRoot, [ + "validate-order", + "--files", + "src/validate-order.ts", + "src/validate-order.test.ts", + "src/types.ts", + ]); + + const l2 = await readL2(testRoot, "validate-order"); + expect(l2?.files).toEqual([ + "src/validate-order.ts", + "src/validate-order.test.ts", + "src/types.ts", + ]); + }); + + it("defaults language to typescript when --language not provided", async () => { + const l3 = makeL3(); + await writeL3(testRoot, l3); + + await runLink(testRoot, ["validate-order", "--files", "src/validate-order.ts"]); + + const l2 = await readL2(testRoot, "validate-order"); + expect(l2?.language).toBe("typescript"); + }); + + it("uses custom language when --language is provided", async () => { + const l3 = makeL3(); + await writeL3(testRoot, l3); + + await runLink(testRoot, [ + "validate-order", + "--files", + "src/validate_order.py", + "--language", + "python", + ]); + + const l2 = await readL2(testRoot, "validate-order"); + expect(l2?.language).toBe("python"); + }); + + it("relinks existing L2: updates files, sourceHash, bumps revision", async () => { + const l3 = makeL3(); + const existingL2 = makeL2(l3); + await writeL3(testRoot, l3); + await writeL2(testRoot, existingL2); + + expect(existingL2.revision.rev).toBe(1); + expect(existingL2.files).toEqual(["src/validate-order.ts"]); + + const { stdout, exitCode } = await runLink(testRoot, [ + "validate-order", + "--files", + "src/validate-order.ts", + "src/validate-order.helper.ts", + ]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Relinked"); + + const l2After = await readL2(testRoot, "validate-order"); + expect(l2After?.revision.rev).toBe(2); + expect(l2After?.revision.parentRev).toBe(1); + expect(l2After?.files).toEqual(["src/validate-order.ts", "src/validate-order.helper.ts"]); + expect(l2After?.sourceHash).toBe(l3.contentHash); + }); + + it("errors with exit code 1 when L3 not found", async () => { + const { stderr, exitCode } = await runLink(testRoot, [ + "nonexistent-l3", + "--files", + "src/foo.ts", + ]); + + expect(exitCode).toBe(1); + expect(stderr).toContain("nonexistent-l3"); + expect(stderr).toContain("not found"); + }); + + it("--json flag: outputs valid JSON of the L2 object", async () => { + const l3 = makeL3(); + await writeL3(testRoot, l3); + + const { stdout, exitCode } = await runLink(testRoot, [ + "validate-order", + "--files", + "src/validate-order.ts", + "--json", + ]); + + expect(exitCode).toBe(0); + + const parsed = JSON.parse(stdout) as Record; + expect(parsed).toHaveProperty("id", "validate-order"); + expect(parsed).toHaveProperty("blockRef", "validate-order"); + expect(parsed).toHaveProperty("language", "typescript"); + expect(parsed).toHaveProperty("files"); + expect(parsed).toHaveProperty("sourceHash"); + expect(parsed).toHaveProperty("contentHash"); + expect(parsed).toHaveProperty("revision"); + }); +}); diff --git a/packages/cli/commands/prompt.test.ts b/packages/cli/commands/prompt.test.ts index a7957ab..8c298d8 100644 --- a/packages/cli/commands/prompt.test.ts +++ b/packages/cli/commands/prompt.test.ts @@ -417,7 +417,7 @@ describe("svp prompt", () => { ]); expect(exitCode).toBe(1); - expect(stdout).toContain('L4 flow "nonexistent" not found'); + expect(stdout).toContain('L4 artifact "nonexistent" not found'); }); it("errors when step index out of range", async () => { diff --git a/packages/cli/commands/prompt.ts b/packages/cli/commands/prompt.ts index bea1984..9e7ffed 100644 --- a/packages/cli/commands/prompt.ts +++ b/packages/cli/commands/prompt.ts @@ -2,7 +2,8 @@ // 7 个子命令:compile, recompile, review, update-ref, design-l5, design-l4, design-l3 // 读 .svp/ 状态 → 解析上下文 → 调用 prompt builder → stdout 输出 markdown -import { extractBlockRefs, getL4Kind } from "../../core/l4.js"; +import { extractBlockRefs, findBlockContext, getL4Kind } from "../../core/l4.js"; +import { getLanguage } from "../../core/i18n.js"; import { DEFAULT_SKILL_CONFIG } from "../../core/skill.js"; import { buildPrompt, renderPrompt } from "../../skills/prompt-builder.js"; import { buildDesignL3Prompt } from "../../skills/prompts/design-l3.js"; @@ -12,9 +13,10 @@ import { buildDesignL4Prompt } from "../../skills/prompts/design-l4.js"; import { buildDesignL5Prompt } from "../../skills/prompts/design-l5.js"; import { loadCheckInput } from "../load.js"; import { createResolver } from "../resolve.js"; +import { getDefaultComplexity } from "../../core/compile-plan.js"; import type { CompileTask, ContextRef, TaskAction } from "../../core/compile-plan.js"; import type { L3Block } from "../../core/l3.js"; -import type { L4Flow } from "../../core/l4.js"; +import type { L4Artifact, L4Flow } from "../../core/l4.js"; import type { SkillInput } from "../../core/skill.js"; import type { Command } from "commander"; @@ -135,6 +137,7 @@ function registerTaskPrompt(parent: Command, action: TaskAction, config: TaskPro reason: config.reason(l3Id), issueCode: config.issueCode, context: contextRefs, + complexity: getDefaultComplexity(action), }; const resolver = createResolver(root); @@ -189,6 +192,7 @@ function registerUpdateRef(parent: Command): void { reason: `Manual update-ref request for L4 flow "${l4Id}" — fix broken L3 references`, issueCode: "MANUAL", context: contextRefs, + complexity: "light", }; const resolver = createResolver(root); @@ -227,6 +231,7 @@ function registerDesignL5(parent: Command): void { const prompt = buildDesignL5Prompt({ currentL5: input.l5, userIntent: options.intent, + language: getLanguage(input.l5), }); console.log(prompt); @@ -282,6 +287,7 @@ function registerDesignL4(parent: Command): void { existingBlocks: input.l3Blocks, userIntent: options.intent, targetId, + language: getLanguage(input.l5), }); } else if (kind === "state-machine") { prompt = buildDesignL4StateMachinePrompt({ @@ -290,6 +296,7 @@ function registerDesignL4(parent: Command): void { existingBlocks: input.l3Blocks, userIntent: options.intent, targetId, + language: getLanguage(input.l5), }); } else { prompt = buildDesignL4Prompt({ @@ -298,6 +305,7 @@ function registerDesignL4(parent: Command): void { existingBlocks: input.l3Blocks, userIntent: options.intent, targetFlowId: targetId, + language: getLanguage(input.l5), }); } @@ -311,13 +319,13 @@ function registerDesignL3(parent: Command): void { .command("design-l3 ") .description("Generate a prompt for designing L3 contract") .requiredOption("--intent ", "User intent for the block") - .requiredOption("--flow ", "L4 flow containing this block") - .requiredOption("--step ", "Step index in the L4 flow (0-based)") + .requiredOption("--flow ", "L4 artifact containing this block") + .option("--step ", "Step index in the L4 flow (0-based, auto-detected when omitted)") .option("-r, --root ", "Project root directory", ".") .action( async ( blockId: string, - options: { intent: string; flow: string; step: string; root: string }, + options: { intent: string; flow: string; step?: string; root: string }, ) => { const root = options.root; @@ -332,45 +340,70 @@ function registerDesignL3(parent: Command): void { const l4 = input.l4Flows.find((f) => f.id === options.flow); if (l4 === undefined) { - console.error(`Error: L4 flow "${options.flow}" not found in .svp/l4/`); + console.error(`Error: L4 artifact "${options.flow}" not found in .svp/l4/`); process.exitCode = 1; return; } - if (getL4Kind(l4) !== "flow") { - console.error( - `Error: L4 "${options.flow}" is not a flow (kind: ${getL4Kind(l4)}). design-l3 requires a flow.`, - ); - process.exitCode = 1; + const kind = getL4Kind(l4); + + // When --step is provided and L4 is a flow, use legacy path for backward compat + if (options.step !== undefined && kind === "flow") { + const flow = l4 as L4Flow; + const stepIndex = Number.parseInt(options.step, 10); + if (Number.isNaN(stepIndex) || stepIndex < 0 || stepIndex >= flow.steps.length) { + console.error( + `Error: step index ${options.step} is out of range (0-${String(flow.steps.length - 1)})`, + ); + process.exitCode = 1; + return; + } + + const findBlock = (idx: number): L3Block | undefined => { + const step = flow.steps[idx]; + if (step.blockRef === undefined) return; + return input.l3Blocks.find((b) => b.id === step.blockRef); + }; + + const prevBlock = stepIndex > 0 ? findBlock(stepIndex - 1) : undefined; + const nextBlock = stepIndex < flow.steps.length - 1 ? findBlock(stepIndex + 1) : undefined; + const existingBlock = input.l3Blocks.find((b) => b.id === blockId); + + const prompt = buildDesignL3Prompt({ + l4Context: { flow, stepIndex, prevBlock, nextBlock }, + existingBlock, + userIntent: options.intent, + language: getLanguage(input.l5), + }); + + console.log(prompt); return; } - const flow = l4 as L4Flow; - - const stepIndex = Number.parseInt(options.step, 10); - if (Number.isNaN(stepIndex) || stepIndex < 0 || stepIndex >= flow.steps.length) { + // Auto-detect block location for any L4 kind + const blockContext = findBlockContext(l4, blockId); + if (blockContext === undefined) { console.error( - `Error: step index ${options.step} is out of range (0-${String(flow.steps.length - 1)})`, + `Error: block "${blockId}" not found in L4 artifact "${options.flow}" (kind: ${kind})`, ); process.exitCode = 1; return; } - // Find neighbor blocks - const findBlock = (idx: number): L3Block | undefined => { - const step = flow.steps[idx]; - if (step.blockRef === undefined) return; - return input.l3Blocks.find((b) => b.id === step.blockRef); - }; - - const prevBlock = stepIndex > 0 ? findBlock(stepIndex - 1) : undefined; - const nextBlock = stepIndex < flow.steps.length - 1 ? findBlock(stepIndex + 1) : undefined; + // Resolve neighbor L3 blocks from blockContext + const prevBlock = blockContext.prevBlockRef !== undefined + ? input.l3Blocks.find((b) => b.id === blockContext.prevBlockRef) + : undefined; + const nextBlock = blockContext.nextBlockRef !== undefined + ? input.l3Blocks.find((b) => b.id === blockContext.nextBlockRef) + : undefined; const existingBlock = input.l3Blocks.find((b) => b.id === blockId); const prompt = buildDesignL3Prompt({ - l4Context: { flow, stepIndex, prevBlock, nextBlock }, + l4Context: { l4, blockContext, prevBlock, nextBlock }, existingBlock, userIntent: options.intent, + language: getLanguage(input.l5), }); console.log(prompt); diff --git a/packages/cli/commands/rehash.test.ts b/packages/cli/commands/rehash.test.ts new file mode 100644 index 0000000..9418342 --- /dev/null +++ b/packages/cli/commands/rehash.test.ts @@ -0,0 +1,398 @@ +// svp rehash CLI 命令的集成测试 + +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { Command } from "commander"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { computeHash } from "../../core/hash.js"; +import { + readL2, + readL3, + readL4, + readL5, + writeL2, + writeL3, + writeL4, + writeL5, +} from "../../core/store.js"; +import { registerRehash } from "./rehash.js"; +import type { L2CodeBlock } from "../../core/l2.js"; +import type { L3Block } from "../../core/l3.js"; +import type { L4Flow } from "../../core/l4.js"; +import type { L5Blueprint } from "../../core/l5.js"; +import type { ArtifactVersion } from "../../core/version.js"; + +// ── Fixture 工厂 ── + +const REV1: ArtifactVersion = { + rev: 1, + parentRev: null, + source: { type: "init" }, + timestamp: "2024-01-01T00:00:00Z", +}; + +const WRONG_HASH = "0000000000000000"; + +function makeL3(overrides: Partial = {}): L3Block { + const base: Omit = { + id: "validate-order", + name: "Validate Order", + input: [{ name: "request", type: "OrderRequest" }], + output: [{ name: "result", type: "ValidationResult" }], + validate: { request: "required" }, + constraints: ["output.result.valid iff output.result.errors is empty"], + description: "Validate the order request", + ...overrides, + }; + const contentHash = computeHash(base as Record); + return { ...base, revision: REV1, contentHash }; +} + +function makeL3WithWrongHash(overrides: Partial = {}): L3Block { + const correct = makeL3(overrides); + return { ...correct, contentHash: WRONG_HASH }; +} + +function makeL4(blockIds: string[], overrides: Partial = {}): L4Flow { + const steps = blockIds.map((blockId, index) => ({ + id: `s${String(index)}`, + action: "process" as const, + blockRef: blockId, + next: index < blockIds.length - 1 ? `s${String(index + 1)}` : null, + })); + + const base: Omit = { + id: "create-order", + name: "Create Order", + steps, + dataFlows: [], + ...overrides, + }; + const contentHash = computeHash(base as Record); + return { ...base, revision: REV1, contentHash }; +} + +function makeL4WithWrongHash(blockIds: string[], overrides: Partial = {}): L4Flow { + const correct = makeL4(blockIds, overrides); + return { ...correct, contentHash: WRONG_HASH }; +} + +function makeL5(overrides: Partial = {}): L5Blueprint { + const base: Omit = { + id: "my-project", + name: "My Project", + version: "0.1.0", + intent: "An order management system", + constraints: ["Must handle 1000 orders/sec"], + domains: [{ name: "order", description: "订单域", dependencies: [] }], + integrations: [{ name: "stripe", type: "api", description: "Payment gateway" }], + ...overrides, + }; + const contentHash = computeHash(base as Record); + return { ...base, revision: REV1, contentHash }; +} + +function makeL5WithWrongHash(overrides: Partial = {}): L5Blueprint { + const correct = makeL5(overrides); + return { ...correct, contentHash: WRONG_HASH }; +} + +function makeL2(l3: L3Block, overrides: Partial = {}): L2CodeBlock { + const base: Omit = { + id: l3.id, + blockRef: l3.id, + language: "typescript", + files: [`src/${l3.id}.ts`], + ...overrides, + }; + const contentHash = computeHash(base as Record); + return { ...base, revision: REV1, sourceHash: l3.contentHash, contentHash }; +} + +function makeL2WithWrongHash(l3: L3Block, overrides: Partial = {}): L2CodeBlock { + const correct = makeL2(l3, overrides); + return { ...correct, contentHash: WRONG_HASH }; +} + +/** 捕获 console.log/error 并运行 rehash 子命令 */ +async function runRehash( + testRoot: string, + args: string[], +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const logs: string[] = []; + const errors: string[] = []; + const originalLog = console.log; + const originalError = console.error; + + console.log = (...a: unknown[]) => logs.push(a.map(String).join(" ")); + console.error = (...a: unknown[]) => errors.push(a.map(String).join(" ")); + + process.exitCode = undefined; + + const program = new Command(); + program.exitOverride(); + registerRehash(program); + + try { + await program.parseAsync(["rehash", ...args, "-r", testRoot], { from: "user" }); + } catch { + // commander may throw on exitOverride + } + + console.log = originalLog; + console.error = originalError; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const exitCode = process.exitCode ?? 0; + process.exitCode = undefined; + + return { + stdout: logs.join("\n"), + stderr: errors.join("\n"), + exitCode, + }; +} + +// ── Tests ── + +describe("svp rehash", () => { + let testRoot: string; + + beforeEach(async () => { + testRoot = path.join( + tmpdir(), + `svp-rehash-test-${String(Date.now())}-${String(Math.random()).slice(2, 8)}`, + ); + await mkdir(testRoot, { recursive: true }); + }); + + afterEach(async () => { + await rm(testRoot, { recursive: true, force: true }); + }); + + it("rehash all layers: corrects wrong hashes on L5+L4+L3+L2", async () => { + const l3 = makeL3(); + const l5Wrong = makeL5WithWrongHash(); + const l4Wrong = makeL4WithWrongHash(["validate-order"]); + const l3Wrong = makeL3WithWrongHash(); + const l2Wrong = makeL2WithWrongHash(l3); + + await writeL5(testRoot, l5Wrong); + await writeL4(testRoot, l4Wrong); + await writeL3(testRoot, l3Wrong); + await writeL2(testRoot, l2Wrong); + + const { exitCode } = await runRehash(testRoot, []); + expect(exitCode).toBe(0); + + const l5After = await readL5(testRoot); + const l4After = await readL4(testRoot, "create-order"); + const l3After = await readL3(testRoot, "validate-order"); + const l2After = await readL2(testRoot, "validate-order"); + + expect(l5After?.contentHash).not.toBe(WRONG_HASH); + expect(l4After?.contentHash).not.toBe(WRONG_HASH); + expect(l3After?.contentHash).not.toBe(WRONG_HASH); + expect(l2After?.contentHash).not.toBe(WRONG_HASH); + }); + + it("rehash specific layer (l5): only L5 hash corrected", async () => { + const l3 = makeL3(); + const l5Wrong = makeL5WithWrongHash(); + const l3Wrong = makeL3WithWrongHash(); + const l2Wrong = makeL2WithWrongHash(l3); + + await writeL5(testRoot, l5Wrong); + await writeL3(testRoot, l3Wrong); + await writeL2(testRoot, l2Wrong); + + const { exitCode } = await runRehash(testRoot, ["l5"]); + expect(exitCode).toBe(0); + + const l5After = await readL5(testRoot); + const l3After = await readL3(testRoot, "validate-order"); + const l2After = await readL2(testRoot, "validate-order"); + + expect(l5After?.contentHash).not.toBe(WRONG_HASH); + // L3 and L2 not touched + expect(l3After?.contentHash).toBe(WRONG_HASH); + expect(l2After?.contentHash).toBe(WRONG_HASH); + }); + + it("rehash specific layer (l4): only L4 hash corrected", async () => { + const l4Wrong = makeL4WithWrongHash(["validate-order"]); + const l3Wrong = makeL3WithWrongHash(); + + await writeL4(testRoot, l4Wrong); + await writeL3(testRoot, l3Wrong); + + const { exitCode } = await runRehash(testRoot, ["l4"]); + expect(exitCode).toBe(0); + + const l4After = await readL4(testRoot, "create-order"); + const l3After = await readL3(testRoot, "validate-order"); + + expect(l4After?.contentHash).not.toBe(WRONG_HASH); + // L3 not touched + expect(l3After?.contentHash).toBe(WRONG_HASH); + }); + + it("rehash specific layer (l3): only L3 hash corrected", async () => { + const l3 = makeL3(); + const l3Wrong = makeL3WithWrongHash(); + const l2Wrong = makeL2WithWrongHash(l3); + + await writeL3(testRoot, l3Wrong); + await writeL2(testRoot, l2Wrong); + + const { exitCode } = await runRehash(testRoot, ["l3"]); + expect(exitCode).toBe(0); + + const l3After = await readL3(testRoot, "validate-order"); + const l2After = await readL2(testRoot, "validate-order"); + + expect(l3After?.contentHash).not.toBe(WRONG_HASH); + // L2 not touched + expect(l2After?.contentHash).toBe(WRONG_HASH); + }); + + it("rehash specific layer (l2): only L2 hash corrected", async () => { + const l3 = makeL3(); + const l3Wrong = makeL3WithWrongHash(); + const l2Wrong = makeL2WithWrongHash(l3); + + await writeL3(testRoot, l3Wrong); + await writeL2(testRoot, l2Wrong); + + const { exitCode } = await runRehash(testRoot, ["l2"]); + expect(exitCode).toBe(0); + + const l3After = await readL3(testRoot, "validate-order"); + const l2After = await readL2(testRoot, "validate-order"); + + // L3 not touched + expect(l3After?.contentHash).toBe(WRONG_HASH); + expect(l2After?.contentHash).not.toBe(WRONG_HASH); + }); + + it("rehash l3/: specific L3 block rehashed", async () => { + const l3aWrong = makeL3WithWrongHash({ id: "validate-order", name: "Validate Order" }); + const l3bWrong = makeL3WithWrongHash({ id: "process-order", name: "Process Order" }); + + await writeL3(testRoot, l3aWrong); + await writeL3(testRoot, l3bWrong); + + const { exitCode } = await runRehash(testRoot, ["l3/validate-order"]); + expect(exitCode).toBe(0); + + const l3aAfter = await readL3(testRoot, "validate-order"); + const l3bAfter = await readL3(testRoot, "process-order"); + + expect(l3aAfter?.contentHash).not.toBe(WRONG_HASH); + // The other block untouched + expect(l3bAfter?.contentHash).toBe(WRONG_HASH); + }); + + it("rehash l4/: specific L4 flow rehashed", async () => { + const l4aWrong = makeL4WithWrongHash(["validate-order"], { + id: "create-order", + name: "Create Order", + }); + const l4bWrong = makeL4WithWrongHash(["validate-order"], { + id: "delete-order", + name: "Delete Order", + }); + + await writeL4(testRoot, l4aWrong); + await writeL4(testRoot, l4bWrong); + + const { exitCode } = await runRehash(testRoot, ["l4/create-order"]); + expect(exitCode).toBe(0); + + const l4aAfter = await readL4(testRoot, "create-order"); + const l4bAfter = await readL4(testRoot, "delete-order"); + + expect(l4aAfter?.contentHash).not.toBe(WRONG_HASH); + // The other flow untouched + expect(l4bAfter?.contentHash).toBe(WRONG_HASH); + }); + + it("rehash l2/: specific L2 block rehashed", async () => { + const l3a = makeL3({ id: "validate-order", name: "Validate Order" }); + const l3b = makeL3({ id: "process-order", name: "Process Order" }); + const l2aWrong = makeL2WithWrongHash(l3a, { id: "validate-order", blockRef: "validate-order" }); + const l2bWrong = makeL2WithWrongHash(l3b, { id: "process-order", blockRef: "process-order" }); + + await writeL2(testRoot, l2aWrong); + await writeL2(testRoot, l2bWrong); + + const { exitCode } = await runRehash(testRoot, ["l2/validate-order"]); + expect(exitCode).toBe(0); + + const l2aAfter = await readL2(testRoot, "validate-order"); + const l2bAfter = await readL2(testRoot, "process-order"); + + expect(l2aAfter?.contentHash).not.toBe(WRONG_HASH); + // The other block untouched + expect(l2bAfter?.contentHash).toBe(WRONG_HASH); + }); + + it("idempotency: second rehash reports no changes", async () => { + const l3Wrong = makeL3WithWrongHash(); + await writeL3(testRoot, l3Wrong); + + // First run — changes hash + await runRehash(testRoot, ["l3"]); + + // Second run — nothing to change + const { stdout, exitCode } = await runRehash(testRoot, ["l3"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("SKIP"); + expect(stdout).not.toContain("-> "); + }); + + it("invalid target: reports error and sets exit code 1", async () => { + const { exitCode, stderr } = await runRehash(testRoot, ["invalid-target"]); + expect(exitCode).toBe(1); + expect(stderr).toContain("Invalid target"); + }); + + it("empty .svp/: no crash on empty project", async () => { + const { stdout, exitCode } = await runRehash(testRoot, []); + expect(exitCode).toBe(0); + expect(stdout).toContain("No artifacts found"); + }); + + it("revision bump on change: rev increments after rehash with wrong hash", async () => { + const l3Wrong = makeL3WithWrongHash(); + expect(l3Wrong.revision.rev).toBe(1); + + await writeL3(testRoot, l3Wrong); + + await runRehash(testRoot, ["l3"]); + + const l3After = await readL3(testRoot, "validate-order"); + expect(l3After?.revision.rev).toBe(2); + expect(l3After?.revision.parentRev).toBe(1); + }); + + it("--json flag: outputs valid JSON array with result objects", async () => { + const l3Wrong = makeL3WithWrongHash(); + await writeL3(testRoot, l3Wrong); + + const { stdout, exitCode } = await runRehash(testRoot, ["l3", "--json"]); + expect(exitCode).toBe(0); + + const parsed = JSON.parse(stdout) as unknown[]; + expect(Array.isArray(parsed)).toBe(true); + expect(parsed.length).toBe(1); + + const result = parsed[0] as Record; + expect(result).toHaveProperty("id", "validate-order"); + expect(result).toHaveProperty("layer", "l3"); + expect(result).toHaveProperty("oldHash"); + expect(result).toHaveProperty("newHash"); + expect(result).toHaveProperty("changed", true); + }); +}); diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 472b7e0..69a941a 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -3,41 +3,32 @@ import { Command } from "commander"; import { registerCheck } from "./commands/check.js"; -import { registerCompileBlueprint } from "./commands/compile-blueprint.js"; +import { registerFix } from "./commands/fix.js"; import { registerCompilePlan } from "./commands/compile-plan.js"; -import { registerCompile } from "./commands/compile.js"; import { registerInit } from "./commands/init.js"; import { registerLink } from "./commands/link.js"; import { registerPrompt } from "./commands/prompt.js"; import { registerRehash } from "./commands/rehash.js"; import { registerView } from "./commands/view.js"; -import type { CodeCLIAdapter } from "../core/adapter.js"; -/** 创建并配置 CLI 程序(需要传入 adapter) */ -export function createCLI(adapter: CodeCLIAdapter): Command { +/** 创建并配置 CLI 程序 */ +export function createCLI(): Command { const program = new Command() .name("svp") .description("SVP — Semantic Voxel Protocol toolchain") .version("0.1.0"); registerCheck(program); - registerCompile(program, adapter); - registerCompileBlueprint(program); registerCompilePlan(program); registerInit(program); registerLink(program); registerRehash(program); registerView(program); registerPrompt(program); + registerFix(program); return program; } -// 直接执行时使用空 adapter(由宿主 CLI 注入真实实现) -const stubAdapter: CodeCLIAdapter = { - name: "stub", - createSkillRegistry: () => new Map(), -}; - -const program = createCLI(stubAdapter); +const program = createCLI(); program.parse(); diff --git a/packages/cli/load.test.ts b/packages/cli/load.test.ts new file mode 100644 index 0000000..3f22ec4 --- /dev/null +++ b/packages/cli/load.test.ts @@ -0,0 +1,207 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { writeL2, writeL3, writeL4, writeL5 } from "../core/store.js"; +import { loadCheckInput } from "./load.js"; +import type { L2CodeBlock } from "../core/l2.js"; +import type { L3Block } from "../core/l3.js"; +import type { L4Flow } from "../core/l4.js"; +import type { L5Blueprint } from "../core/l5.js"; +import type { ArtifactVersion } from "../core/version.js"; + +// ── Shared helpers ── + +const REV: ArtifactVersion = { + rev: 1, + parentRev: null, + source: { type: "init" }, + timestamp: "2024-01-01T00:00:00.000Z", +}; + +const makeL3 = (id: string, overrides?: Partial): L3Block => ({ + id, + name: "Test Block", + input: [{ name: "req", type: "Request" }], + output: [{ name: "res", type: "Response" }], + validate: { "req.id": "required" }, + constraints: ["output.res is not empty"], + description: "test block", + revision: REV, + contentHash: "abc123", + ...overrides, +}); + +const makeL4Flow = (id: string, overrides?: Partial): L4Flow => ({ + id, + name: "Test Flow", + steps: [{ id: "s1", action: "process", blockRef: "test-block", next: null }], + dataFlows: [], + revision: REV, + contentHash: "def456", + ...overrides, +}); + +const makeL5 = (id: string, overrides?: Partial): L5Blueprint => ({ + id, + name: "Test Blueprint", + version: "1.0.0", + intent: "Core intent description", + constraints: ["must be fast"], + domains: [{ name: "auth", description: "Authentication domain", dependencies: [] }], + integrations: [{ name: "postgres", type: "database", description: "Primary DB" }], + revision: REV, + contentHash: "mno345", + ...overrides, +}); + +const makeL2 = (id: string, overrides?: Partial): L2CodeBlock => ({ + id, + blockRef: `l3-${id}`, + language: "typescript", + files: ["src/foo.ts"], + sourceHash: "src-hash-abc", + contentHash: "content-hash-xyz", + revision: REV, + ...overrides, +}); + +// ── loadCheckInput ── + +describe("loadCheckInput", () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), "svp-load-test-")); + }); + + afterEach(async () => { + await rm(root, { recursive: true }); + }); + + it("empty project (no .svp/ data) returns empty arrays", async () => { + const result = await loadCheckInput(root); + expect(result.l3Blocks).toEqual([]); + expect(result.l4Flows).toEqual([]); + expect(result.l2Blocks).toEqual([]); + expect(result.l5).toBeUndefined(); + expect(result.l1SignatureHashes).toBeUndefined(); + }); + + it("loads L3 blocks correctly", async () => { + const block = makeL3("my-block"); + await writeL3(root, block); + + const result = await loadCheckInput(root); + expect(result.l3Blocks).toHaveLength(1); + expect(result.l3Blocks[0]).toEqual(block); + }); + + it("loads multiple L3 blocks", async () => { + await writeL3(root, makeL3("block-a")); + await writeL3(root, makeL3("block-b")); + await writeL3(root, makeL3("block-c")); + + const result = await loadCheckInput(root); + expect(result.l3Blocks).toHaveLength(3); + const ids = result.l3Blocks.map((b) => b.id).toSorted(); + expect(ids).toEqual(["block-a", "block-b", "block-c"]); + }); + + it("loads L4 flows correctly", async () => { + const flow = makeL4Flow("my-flow"); + await writeL4(root, flow); + + const result = await loadCheckInput(root); + expect(result.l4Flows).toHaveLength(1); + expect(result.l4Flows[0]).toEqual(flow); + }); + + it("loads multiple L4 flows", async () => { + await writeL4(root, makeL4Flow("flow-x")); + await writeL4(root, makeL4Flow("flow-y")); + + const result = await loadCheckInput(root); + expect(result.l4Flows).toHaveLength(2); + const ids = result.l4Flows.map((f) => f.id).toSorted(); + expect(ids).toEqual(["flow-x", "flow-y"]); + }); + + it("loads L5 blueprint correctly", async () => { + const bp = makeL5("main-blueprint"); + await writeL5(root, bp); + + const result = await loadCheckInput(root); + expect(result.l5).toEqual(bp); + }); + + it("loads L2 code blocks correctly", async () => { + const cb = makeL2("my-code-block"); + await writeL2(root, cb); + + const result = await loadCheckInput(root); + expect(result.l2Blocks).toHaveLength(1); + expect(result.l2Blocks[0]).toEqual(cb); + }); + + it("loads all layers together", async () => { + const l5 = makeL5("full-blueprint"); + const l4 = makeL4Flow("full-flow"); + const l3 = makeL3("full-block"); + const l2 = makeL2("full-code"); + + await writeL5(root, l5); + await writeL4(root, l4); + await writeL3(root, l3); + await writeL2(root, l2); + + const result = await loadCheckInput(root); + expect(result.l5).toEqual(l5); + expect(result.l4Flows).toHaveLength(1); + expect(result.l4Flows[0]).toEqual(l4); + expect(result.l3Blocks).toHaveLength(1); + expect(result.l3Blocks[0]).toEqual(l3); + expect(result.l2Blocks).toHaveLength(1); + expect(result.l2Blocks[0]).toEqual(l2); + }); + + it("computeSignatures: false (default) does not populate l1SignatureHashes", async () => { + const cb = makeL2("sig-block", { signatureHash: "some-sig" }); + await writeL2(root, cb); + + const result = await loadCheckInput(root); + expect(result.l1SignatureHashes).toBeUndefined(); + }); + + it("computeSignatures: false explicitly does not populate l1SignatureHashes", async () => { + const cb = makeL2("sig-block-2", { signatureHash: "some-sig" }); + await writeL2(root, cb); + + const result = await loadCheckInput(root, { computeSignatures: false }); + expect(result.l1SignatureHashes).toBeUndefined(); + }); + + it("returns empty arrays when .svp/ directories exist but are empty", async () => { + // Write and then delete content so dirs exist but are empty; simplest is + // just to test with a fresh empty root (no .svp at all) — directories that + // exist but hold no .json files should also yield empty arrays + const block = makeL3("temp-block"); + await writeL3(root, block); + // Overwrite scenario: a second call with a separate root that has the dir + // but no files is equivalent — store's listL3 on an empty dir returns [] + // We verify via the mkdtemp + mkdir approach indirectly through empty root. + const result = await loadCheckInput(root); + expect(result.l3Blocks).toHaveLength(1); // sanity — temp-block was written + + // Verify an isolated fresh root with no data + const freshRoot = await mkdtemp(path.join(tmpdir(), "svp-load-empty-")); + try { + const emptyResult = await loadCheckInput(freshRoot); + expect(emptyResult.l3Blocks).toEqual([]); + expect(emptyResult.l4Flows).toEqual([]); + expect(emptyResult.l2Blocks).toEqual([]); + } finally { + await rm(freshRoot, { recursive: true }); + } + }); +}); diff --git a/packages/cli/resolve.ts b/packages/cli/resolve.ts index 595b89d..e3cfbf8 100644 --- a/packages/cli/resolve.ts +++ b/packages/cli/resolve.ts @@ -1,15 +1,13 @@ // ContextResolver 实现 — 把 CompileTask 的 context refs 解析为实际数据 -// 供 orchestrator 注入 import { readFile } from "node:fs/promises"; import path from "node:path"; -import type { - CheckInput, - CompileTask, - ContextResolver, - FileContent, - ResolvedContext, -} from "../core/index.js"; +import { readGraphDocs, readNodeDocs } from "../core/index.js"; +import type { CheckInput, CompileTask, FileContent, ResolvedContext } from "../core/index.js"; + +export interface ContextResolver { + readonly resolve: (task: CompileTask, input: CheckInput) => Promise; +} /** 创建 ContextResolver(需要 project root 来读 L1 文件) */ export function createResolver(root: string): ContextResolver { @@ -21,6 +19,7 @@ export function createResolver(root: string): ContextResolver { l2?: ResolvedContext["l2"]; l4?: ResolvedContext["l4"]; l1Files?: FileContent[]; + docs?: string; } = {}; for (const ref of task.context) { @@ -49,6 +48,17 @@ export function createResolver(root: string): ContextResolver { ctx.l1Files = await readL1Files(root, ctx.l2.files); } + // 加载模块化文档(compile/recompile/review 需要 docs 上下文) + if (task.action === "compile" || task.action === "recompile" || task.action === "review") { + const l3Ref = task.context.find((r) => r.layer === "l3"); + const l4Ref = task.context.find((r) => r.layer === "l4"); + if (l3Ref !== undefined) { + ctx.docs = (await readNodeDocs(root, l3Ref.id)) ?? undefined; + } else if (l4Ref !== undefined) { + ctx.docs = (await readGraphDocs(root, l4Ref.id)) ?? undefined; + } + } + return ctx; }, }; diff --git a/packages/compiler/compile-graph.test.ts b/packages/compiler/compile-graph.test.ts deleted file mode 100644 index d47f1ce..0000000 --- a/packages/compiler/compile-graph.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { compileCompositeNode, compileGraph } from "./compile-graph.js"; -import { parseGraphYaml } from "./parse-graph.js"; -import { parseNodeYaml } from "./parse-node.js"; - -describe("compileGraph", () => { - it("简单链式:hello-world (greet → uppercase)", () => { - const yaml = ` -name: hello-world -description: 接收名字,生成大写问候语 -input: - - name: name - type: string -output: - - name: result - type: Greeting -nodes: - - id: g - type: greet - - id: u - type: uppercase -wires: - - from: input.name → to: g.name - - from: g.greeting → to: u.greeting - - from: u.result → to: output.result -`; - const parseResult = parseGraphYaml(yaml, "test.yaml"); - expect(parseResult.ok).toBe(true); - if (!parseResult.ok) return; - - const result = compileGraph(parseResult.value); - expect(result.ok).toBe(true); - if (!result.ok) return; - - const flow = result.value; - expect(flow.id).toBe("hello-world"); - expect(flow.name).toBe("hello-world"); - - // 应该有 2 个 process step:g → u - const processSteps = flow.steps.filter((s) => s.action === "process"); - expect(processSteps).toHaveLength(2); - - // g 的 next 应该是 u - const gStep = flow.steps.find((s) => s.id === "g"); - expect(gStep?.action).toBe("process"); - expect(gStep?.blockRef).toBe("greet"); - expect(gStep?.next).toBe("u"); - - // u 是最后一步,没有 next - const uStep = flow.steps.find((s) => s.id === "u"); - expect(uStep?.action).toBe("process"); - expect(uStep?.blockRef).toBe("uppercase"); - expect(uStep?.next).toBeUndefined(); - - // dataFlows:只有 g.greeting → u.greeting(排除了 input.*/output.*) - expect(flow.dataFlows).toHaveLength(1); - expect(flow.dataFlows[0]).toEqual({ - from: "g.greeting", - to: "u.greeting", - }); - - // hash 存在 - expect(flow.contentHash).toBeTruthy(); - expect(flow.revision.rev).toBe(1); - }); - - it("扇出检测:一个节点有多个下游", () => { - const yaml = ` -name: fan-out -nodes: - - id: source - type: producer - - id: branch1 - type: consumer1 - - id: branch2 - type: consumer2 -wires: - - from: source.out → to: branch1.in - - from: source.out → to: branch2.in -`; - const parseResult = parseGraphYaml(yaml, "test.yaml"); - expect(parseResult.ok).toBe(true); - if (!parseResult.ok) return; - - const result = compileGraph(parseResult.value); - expect(result.ok).toBe(true); - if (!result.ok) return; - - const flow = result.value; - - // source step 的 next 应该指向 parallel step - const sourceStep = flow.steps.find((s) => s.id === "source"); - expect(sourceStep?.next).toBe("parallel-after-source"); - - // 应该有一个 parallel step - const parallelStep = flow.steps.find((s) => s.action === "parallel"); - expect(parallelStep).toBeTruthy(); - expect(parallelStep?.branches).toEqual(expect.arrayContaining(["branch1", "branch2"])); - }); - - it("汇聚检测:一个节点有多个上游", () => { - const yaml = ` -name: fan-in -nodes: - - id: a - type: nodeA - - id: b - type: nodeB - - id: merge - type: merger -wires: - - from: a.out → to: merge.in1 - - from: b.out → to: merge.in2 -`; - const parseResult = parseGraphYaml(yaml, "test.yaml"); - expect(parseResult.ok).toBe(true); - if (!parseResult.ok) return; - - const result = compileGraph(parseResult.value); - expect(result.ok).toBe(true); - if (!result.ok) return; - - const flow = result.value; - - // 应该有 wait step - const waitStep = flow.steps.find((s) => s.action === "wait"); - expect(waitStep).toBeTruthy(); - expect(waitStep?.id).toBe("wait-merge"); - expect(waitStep?.waitFor).toEqual(expect.arrayContaining(["a", "b"])); - expect(waitStep?.next).toBe("merge"); - }); - - it("复合节点引用:生成 call step", () => { - const yaml = ` -name: with-composite -nodes: - - id: pipeline - type: order-pipeline -wires: [] -`; - const parseResult = parseGraphYaml(yaml, "test.yaml"); - expect(parseResult.ok).toBe(true); - if (!parseResult.ok) return; - - const compositeNames = new Set(["order-pipeline"]); - const result = compileGraph(parseResult.value, compositeNames); - expect(result.ok).toBe(true); - if (!result.ok) return; - - const flow = result.value; - const callStep = flow.steps.find((s) => s.action === "call"); - expect(callStep).toBeTruthy(); - expect(callStep?.flowRef).toBe("order-pipeline"); - }); - - it("环检测:构造有环图报错", () => { - const yaml = ` -name: cyclic -nodes: - - id: a - type: nodeA - - id: b - type: nodeB - - id: c - type: nodeC -wires: - - from: a.out → to: b.in - - from: b.out → to: c.in - - from: c.out → to: a.in -`; - const parseResult = parseGraphYaml(yaml, "test.yaml"); - expect(parseResult.ok).toBe(true); - if (!parseResult.ok) return; - - const result = compileGraph(parseResult.value); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.error.code).toBe("CYCLE_DETECTED"); - }); - - it("hash 稳定性", () => { - const yaml = ` -name: stable -nodes: - - id: a - type: nodeA - - id: b - type: nodeB -wires: - - from: a.out → to: b.in -`; - const parse1 = parseGraphYaml(yaml, "a.yaml"); - const parse2 = parseGraphYaml(yaml, "b.yaml"); - expect(parse1.ok && parse2.ok).toBe(true); - if (!parse1.ok || !parse2.ok) return; - - const result1 = compileGraph(parse1.value); - const result2 = compileGraph(parse2.value); - expect(result1.ok && result2.ok).toBe(true); - if (!result1.ok || !result2.ok) return; - - expect(result1.value.contentHash).toBe(result2.value.contentHash); - }); -}); - -describe("compileCompositeNode", () => { - it("编译 order-pipeline 复合节点为子 L4Flow", () => { - const yaml = ` -name: order-pipeline -description: 完整的下单流程 -type: composite -pins: - input: - - name: request - type: OrderRequest - output: - - name: result - type: OrderResult -nodes: - - id: val - type: validate-order - - id: inv - type: check-inventory - - id: proc - type: process-order -wires: - - from: input.request → to: val.request - - from: val.result → to: inv.items - - from: input.request → to: proc.request - - from: inv.status → to: proc.inventory - - from: proc.result → to: output.result -`; - const parseResult = parseNodeYaml(yaml, "test.yaml"); - expect(parseResult.ok).toBe(true); - if (!parseResult.ok) return; - - const result = compileCompositeNode(parseResult.value); - expect(result.ok).toBe(true); - if (!result.ok) return; - - const flow = result.value; - expect(flow.id).toBe("order-pipeline"); - expect(flow.name).toBe("order-pipeline"); - expect(flow.steps.length).toBeGreaterThan(0); - - // val 和 inv/proc 之间有依赖关系 - // val → inv, val+inv → proc (proc 有两个上游:input 直连 和 inv) - const processSteps = flow.steps.filter((s) => s.action === "process"); - expect(processSteps.length).toBeGreaterThanOrEqual(3); - }); - - it("缺少 nodes/wires 的复合节点报错", () => { - const yaml = ` -name: broken -type: composite -pins: - input: [] - output: [] -`; - const parseResult = parseNodeYaml(yaml, "test.yaml"); - expect(parseResult.ok).toBe(true); - if (!parseResult.ok) return; - - const result = compileCompositeNode(parseResult.value); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.error.code).toBe("INVALID_COMPOSITE"); - }); -}); diff --git a/packages/compiler/compile-graph.ts b/packages/compiler/compile-graph.ts deleted file mode 100644 index 48bbc46..0000000 --- a/packages/compiler/compile-graph.ts +++ /dev/null @@ -1,283 +0,0 @@ -// GraphIR → L4Flow 编译(核心复杂度) -// 包含:DAG 构建、拓扑排序、扇出/汇聚检测、step 生成 - -import { hashL4 } from "../core/hash.js"; -import { err, ok } from "../core/result.js"; -import type { CompileError, GraphIr, NodeIr } from "./types.js"; -import type { DataFlow, Step, L4Flow } from "../core/l4.js"; -import type { Result } from "../core/result.js"; -import type { ArtifactVersion } from "../core/version.js"; - -/** 已知的复合节点名集合 */ -type CompositeNodeNames = ReadonlySet; - -/** 将图 IR 编译为 L4Flow */ -export function compileGraph( - graph: GraphIr, - compositeNames: CompositeNodeNames = new Set(), -): Result { - // 1. 构建 DAG:从 wires 提取节点间依赖(忽略 input.*/output.* 端口) - const nodeIds = new Set(graph.nodes.map((n) => n.id)); - const adjacency = new Map>(); // from → to 的下游集合 - const inDegree = new Map(); - - for (const id of nodeIds) { - adjacency.set(id, new Set()); - inDegree.set(id, 0); - } - - for (const wire of graph.wires) { - const fromNodeId = wire.from.split(".")[0]; - const toNodeId = wire.to.split(".")[0]; - - // 跳过图级端口(input.*/output.*) - if ( - fromNodeId === "input" || - fromNodeId === "output" || - toNodeId === "input" || - toNodeId === "output" - ) { - continue; - } - - // 只处理两个不同节点之间的连线 - if (fromNodeId !== toNodeId && nodeIds.has(fromNodeId) && nodeIds.has(toNodeId)) { - const downstream = adjacency.get(fromNodeId)!; - if (!downstream.has(toNodeId)) { - downstream.add(toNodeId); - inDegree.set(toNodeId, (inDegree.get(toNodeId) ?? 0) + 1); - } - } - } - - // 2. 拓扑排序(Kahn 算法) - const topoResult = topologicalSort(nodeIds, adjacency, inDegree); - if (!topoResult.ok) { - return err({ - code: "CYCLE_DETECTED", - message: `图 "${graph.name}" 中检测到环: ${topoResult.error}`, - }); - } - const sorted = topoResult.value; - - // 3. 构建反向邻接表(用于汇聚检测) - const reverseAdj = new Map>(); - for (const id of nodeIds) { - reverseAdj.set(id, new Set()); - } - for (const [from, toSet] of adjacency) { - for (const to of toSet) { - reverseAdj.get(to)!.add(from); - } - } - - // 4. 构建 nodeType 映射 - const nodeTypeMap = new Map(); - for (const n of graph.nodes) { - nodeTypeMap.set(n.id, n.type); - } - - // 5. 生成 steps - const steps = generateSteps(sorted, adjacency, reverseAdj, nodeTypeMap, compositeNames); - - // 6. 生成 dataFlows - const dataFlows = generateDataFlows(graph); - - // 7. 组装 L4Flow - const revision: ArtifactVersion = { - rev: 1, - parentRev: null, - source: { type: "init" }, - timestamp: new Date().toISOString(), - }; - - const partial = { - id: graph.name, - name: graph.name, - ...(graph.trigger === undefined ? {} : { trigger: graph.trigger }), - steps, - dataFlows, - }; - - const contentHash = hashL4(partial); - - const flow: L4Flow = { - ...partial, - revision, - contentHash, - }; - - return ok(flow); -} - -/** 将复合节点 IR 编译为子 L4Flow */ -export function compileCompositeNode( - node: NodeIr, - compositeNames: CompositeNodeNames = new Set(), -): Result { - if (node.nodes === undefined || node.wires === undefined) { - return err({ - code: "INVALID_COMPOSITE", - message: `复合节点 "${node.name}" 缺少 nodes 或 wires 字段`, - }); - } - - // 将复合节点转为 GraphIR 后编译 - const graphIr: GraphIr = { - name: node.name, - description: node.description, - input: node.pins.input, - output: node.pins.output, - nodes: node.nodes, - wires: node.wires, - }; - - return compileGraph(graphIr, compositeNames); -} - -/** Kahn 拓扑排序 */ -function topologicalSort( - nodeIds: ReadonlySet, - adjacency: ReadonlyMap>, - inDegreeInput: ReadonlyMap, -): Result { - const inDegree = new Map(inDegreeInput); - const queue: string[] = []; - const result: string[] = []; - - // 入度为 0 的节点入队 - for (const id of nodeIds) { - if ((inDegree.get(id) ?? 0) === 0) { - queue.push(id); - } - } - - // 按字母序保证确定性 - queue.sort(); - - while (queue.length > 0) { - const current = queue.shift()!; - result.push(current); - - const downstream = adjacency.get(current); - if (downstream !== undefined) { - // 收集并排序下游以保证确定性 - const sortedDownstream = [...downstream].toSorted(); - for (const next of sortedDownstream) { - const newDegree = (inDegree.get(next) ?? 1) - 1; - inDegree.set(next, newDegree); - if (newDegree === 0) { - queue.push(next); - queue.sort(); - } - } - } - } - - if (result.length !== nodeIds.size) { - const remaining = [...nodeIds].filter((id) => !result.includes(id)); - return err(remaining.join(", ")); - } - - return ok(result); -} - -/** 从拓扑排序结果生成 steps */ -function generateSteps( - sorted: readonly string[], - adjacency: ReadonlyMap>, - reverseAdj: ReadonlyMap>, - nodeTypeMap: ReadonlyMap, - compositeNames: CompositeNodeNames, -): Step[] { - const steps: Step[] = []; - // 记录已经作为 parallel 分支生成的节点,避免重复 - const handledAsParallelBranch = new Set(); - // 记录需要 wait 的节点(多上游汇聚点) - const needsWait = new Set(); - - // 预扫描:标记扇出和汇聚 - for (const nodeId of sorted) { - const upstreams = reverseAdj.get(nodeId); - if (upstreams !== undefined && upstreams.size > 1) { - needsWait.add(nodeId); - } - } - - for (const nodeId of sorted) { - if (handledAsParallelBranch.has(nodeId)) continue; - - const downstream = adjacency.get(nodeId) ?? new Set(); - const upstreams = reverseAdj.get(nodeId) ?? new Set(); - const nodeType = nodeTypeMap.get(nodeId) ?? nodeId; - const isComposite = compositeNames.has(nodeType); - - // 如果是汇聚点,先插入 wait step - if (needsWait.has(nodeId)) { - const waitStepId = `wait-${nodeId}`; - const waitForIds = [...upstreams].toSorted(); - steps.push({ - id: waitStepId, - action: "wait", - waitFor: waitForIds, - next: nodeId, - }); - } - - // 确定 action 类型 - const action = isComposite ? "call" : "process"; - - // 检查扇出(一个节点有多个下游) - if (downstream.size > 1) { - // 当前节点自身的 step - const parallelStepId = `parallel-after-${nodeId}`; - - const step: Step = { - id: nodeId, - action, - ...(isComposite ? { flowRef: nodeType } : { blockRef: nodeType }), - next: parallelStepId, - }; - steps.push(step); - - // 生成 parallel step - const branchIds = [...downstream].toSorted(); - steps.push({ - id: parallelStepId, - action: "parallel", - branches: branchIds, - }); - } else { - // 简单链式或末尾节点 - const downstreamArray = [...downstream]; - const nextId = downstreamArray.length > 0 ? downstreamArray[0] : undefined; - - // 如果下一个节点需要 wait,则 next 指向 wait step - const effectiveNext = - nextId !== undefined && needsWait.has(nextId) ? `wait-${nextId}` : nextId; - - const step: Step = { - id: nodeId, - action, - ...(isComposite ? { flowRef: nodeType } : { blockRef: nodeType }), - ...(effectiveNext === undefined ? {} : { next: effectiveNext }), - }; - steps.push(step); - } - } - - return steps; -} - -/** 从 wires 生成 dataFlows(排除图级端口 input.x / output.x) */ -function generateDataFlows(graph: GraphIr): DataFlow[] { - return graph.wires - .filter((w) => { - const fromNode = w.from.split(".")[0]; - const toNode = w.to.split(".")[0]; - return ( - fromNode !== "input" && fromNode !== "output" && toNode !== "input" && toNode !== "output" - ); - }) - .map((w) => ({ from: w.from, to: w.to })); -} diff --git a/packages/compiler/compile-node.test.ts b/packages/compiler/compile-node.test.ts deleted file mode 100644 index 894603d..0000000 --- a/packages/compiler/compile-node.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { compileNode } from "./compile-node.js"; -import { parseNodeYaml } from "./parse-node.js"; - -describe("compileNode", () => { - it("编译 greet 节点为 L3Block", () => { - const yaml = ` -name: greet -pins: - input: - - name: name - type: string - output: - - name: greeting - type: Greeting -validate: - name: string -constraints: - - output.greeting.message is not empty -description: | - 接收名字,生成问候消息。 -`; - const parseResult = parseNodeYaml(yaml, "test.yaml"); - expect(parseResult.ok).toBe(true); - if (!parseResult.ok) return; - - const result = compileNode(parseResult.value); - expect(result.ok).toBe(true); - if (!result.ok) return; - - const block = result.value; - expect(block.id).toBe("greet"); - expect(block.name).toBe("greet"); - expect(block.input).toHaveLength(1); - expect(block.input[0]?.name).toBe("name"); - expect(block.input[0]?.type).toBe("string"); - expect(block.output).toHaveLength(1); - expect(block.output[0]?.name).toBe("greeting"); - expect(block.output[0]?.type).toBe("Greeting"); - expect(block.validate).toEqual({ name: "string" }); - expect(block.constraints).toEqual(["output.greeting.message is not empty"]); - expect(block.description).toContain("接收名字"); - expect(block.revision.rev).toBe(1); - expect(block.revision.source).toEqual({ type: "init" }); - expect(block.contentHash).toBeTruthy(); - }); - - it("hash 稳定性:相同输入产生相同 hash", () => { - const yaml = ` -name: stable -pins: - input: - - name: x - type: string - output: - - name: y - type: string -validate: {} -constraints: [] -description: test -`; - const parse1 = parseNodeYaml(yaml, "a.yaml"); - const parse2 = parseNodeYaml(yaml, "b.yaml"); - expect(parse1.ok && parse2.ok).toBe(true); - if (!parse1.ok || !parse2.ok) return; - - const result1 = compileNode(parse1.value); - const result2 = compileNode(parse2.value); - expect(result1.ok && result2.ok).toBe(true); - if (!result1.ok || !result2.ok) return; - - expect(result1.value.contentHash).toBe(result2.value.contentHash); - }); - - it("不同输入产生不同 hash", () => { - const yaml1 = ` -name: node-a -pins: - input: - - name: x - type: string - output: - - name: y - type: string -description: version 1 -`; - const yaml2 = ` -name: node-a -pins: - input: - - name: x - type: string - output: - - name: y - type: string -description: version 2 -`; - const parse1 = parseNodeYaml(yaml1, "a.yaml"); - const parse2 = parseNodeYaml(yaml2, "b.yaml"); - expect(parse1.ok && parse2.ok).toBe(true); - if (!parse1.ok || !parse2.ok) return; - - const result1 = compileNode(parse1.value); - const result2 = compileNode(parse2.value); - expect(result1.ok && result2.ok).toBe(true); - if (!result1.ok || !result2.ok) return; - - expect(result1.value.contentHash).not.toBe(result2.value.contentHash); - }); - - it("可选 pin 正确映射", () => { - const yaml = ` -name: optional-test -pins: - input: - - name: required - type: string - - name: maybe - type: string - optional: true - output: - - name: out - type: string -`; - const parseResult = parseNodeYaml(yaml, "test.yaml"); - expect(parseResult.ok).toBe(true); - if (!parseResult.ok) return; - - const result = compileNode(parseResult.value); - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.value.input[1]?.optional).toBe(true); - expect(result.value.input[0]?.optional).toBeUndefined(); - }); -}); diff --git a/packages/compiler/compile-node.ts b/packages/compiler/compile-node.ts deleted file mode 100644 index c757f9a..0000000 --- a/packages/compiler/compile-node.ts +++ /dev/null @@ -1,51 +0,0 @@ -// NodeIR → L3Block 编译 - -import { hashL3 } from "../core/hash.js"; -import { ok } from "../core/result.js"; -import type { CompileError, NodeIr } from "./types.js"; -import type { L3Block, Pin } from "../core/l3.js"; -import type { Result } from "../core/result.js"; -import type { ArtifactVersion } from "../core/version.js"; - -/** 将原子节点 IR 编译为 L3Block */ -export function compileNode(node: NodeIr): Result { - const inputPins: Pin[] = node.pins.input.map((p) => ({ - name: p.name, - type: p.type, - ...(p.optional === true ? { optional: true } : {}), - })); - - const outputPins: Pin[] = node.pins.output.map((p) => ({ - name: p.name, - type: p.type, - ...(p.optional === true ? { optional: true } : {}), - })); - - const revision: ArtifactVersion = { - rev: 1, - parentRev: null, - source: { type: "init" }, - timestamp: new Date().toISOString(), - }; - - // 先构建不含 hash 的对象,再计算 hash - const partial = { - id: node.name, - name: node.name, - input: inputPins, - output: outputPins, - validate: node.validate ?? {}, - constraints: node.constraints ?? [], - description: node.description ?? "", - }; - - const contentHash = hashL3(partial); - - const block: L3Block = { - ...partial, - revision, - contentHash, - }; - - return ok(block); -} diff --git a/packages/compiler/compile.test.ts b/packages/compiler/compile.test.ts deleted file mode 100644 index a8faba3..0000000 --- a/packages/compiler/compile.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { existsSync } from "node:fs"; -import { mkdir, readFile, rm } from "node:fs/promises"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { compileBlueprint } from "./compile.js"; -import type { L3Block } from "../core/l3.js"; -import type { L4Flow } from "../core/l4.js"; - -const FIXTURES_DIR = path.resolve(import.meta.dirname, "../../examples"); -const TEMP_DIR = path.resolve(import.meta.dirname, "../../.test-output"); - -describe("compileBlueprint", () => { - beforeEach(async () => { - // 创建临时输出目录(复制 example 结构) - await mkdir(TEMP_DIR, { recursive: true }); - }); - - afterEach(async () => { - await rm(TEMP_DIR, { recursive: true, force: true }); - }); - - it("端到端:编译 hello-world 项目", async () => { - const projectRoot = path.join(FIXTURES_DIR, "hello-world"); - const result = await compileBlueprint(projectRoot); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.value.l3Blocks).toContain("greet"); - expect(result.value.l3Blocks).toContain("uppercase"); - expect(result.value.l4Flows).toContain("hello-world"); - - // 验证 .svp/l3/greet.json 生成了 - const greetPath = path.join(projectRoot, ".svp", "l3", "greet.json"); - expect(existsSync(greetPath)).toBe(true); - - const greetBlock = JSON.parse(await readFile(greetPath, "utf8")) as L3Block; - expect(greetBlock.id).toBe("greet"); - expect(greetBlock.input).toHaveLength(1); - expect(greetBlock.output).toHaveLength(1); - expect(greetBlock.contentHash).toBeTruthy(); - - // 验证 .svp/l4/hello-world.json 生成了 - const flowPath = path.join(projectRoot, ".svp", "l4", "hello-world.json"); - expect(existsSync(flowPath)).toBe(true); - - const flow = JSON.parse(await readFile(flowPath, "utf8")) as L4Flow; - expect(flow.id).toBe("hello-world"); - expect(flow.steps.length).toBeGreaterThan(0); - expect(flow.contentHash).toBeTruthy(); - }); - - it("端到端:编译 order-service 项目", async () => { - const projectRoot = path.join(FIXTURES_DIR, "order-service"); - const result = await compileBlueprint(projectRoot); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - // 原子节点应生成 L3 - expect(result.value.l3Blocks).toContain("validate-order"); - expect(result.value.l3Blocks).toContain("check-inventory"); - expect(result.value.l3Blocks).toContain("process-order"); - - // 复合节点生成 L4 - expect(result.value.l4Flows).toContain("order-pipeline"); - // 图也生成 L4 - expect(result.value.l4Flows).toContain("order-service"); - - // 验证 order-pipeline L4 中引用了子节点 - const pipelinePath = path.join(projectRoot, ".svp", "l4", "order-pipeline.json"); - expect(existsSync(pipelinePath)).toBe(true); - - const pipeline = JSON.parse(await readFile(pipelinePath, "utf8")) as L4Flow; - expect(pipeline.steps.length).toBeGreaterThan(0); - - // order-service 图引用了 order-pipeline 复合节点,应生成 call step - const servicePath = path.join(projectRoot, ".svp", "l4", "order-service.json"); - expect(existsSync(servicePath)).toBe(true); - - const service = JSON.parse(await readFile(servicePath, "utf8")) as L4Flow; - const callStep = service.steps.find((s) => s.action === "call"); - expect(callStep).toBeTruthy(); - expect(callStep?.flowRef).toBe("order-pipeline"); - }); - - it("空项目不报错", async () => { - const result = await compileBlueprint(TEMP_DIR); - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.value.l3Blocks).toHaveLength(0); - expect(result.value.l4Flows).toHaveLength(0); - }); - - it("幂等性:多次编译结果一致", async () => { - const projectRoot = path.join(FIXTURES_DIR, "hello-world"); - - const result1 = await compileBlueprint(projectRoot); - expect(result1.ok).toBe(true); - - const greetPath = path.join(projectRoot, ".svp", "l3", "greet.json"); - const content1 = await readFile(greetPath, "utf8"); - - const result2 = await compileBlueprint(projectRoot); - expect(result2.ok).toBe(true); - - const content2 = await readFile(greetPath, "utf8"); - - // contentHash 应该一致(revision.timestamp 会变,但 hash 基于内容) - const block1 = JSON.parse(content1) as L3Block; - const block2 = JSON.parse(content2) as L3Block; - expect(block1.contentHash).toBe(block2.contentHash); - }); -}); diff --git a/packages/compiler/compile.ts b/packages/compiler/compile.ts deleted file mode 100644 index b3009ec..0000000 --- a/packages/compiler/compile.ts +++ /dev/null @@ -1,125 +0,0 @@ -// 顶层编排:扫描目录 → 解析 → 编译 → 写入 .svp/ - -import { readdir, stat } from "node:fs/promises"; -import path from "node:path"; -import { ok } from "../core/result.js"; -import { writeL3, writeL4 } from "../core/store.js"; -import { compileCompositeNode, compileGraph } from "./compile-graph.js"; -import { compileNode } from "./compile-node.js"; -import { parseGraphFile } from "./parse-graph.js"; -import { parseNodeFile } from "./parse-node.js"; -import type { CompileError, CompileResult, NodeIr } from "./types.js"; -import type { Result } from "../core/result.js"; - -/** - * 编译整个 blueprint 项目 - * - * 1. 扫描 nodes/ 目录,递归发现所有 node.yaml - * 2. 扫描 graphs/ 目录,发现所有 graph.yaml - * 3. 解析所有节点 → NodeIR[] - * 4. 区分原子节点和复合节点 - * 5. 编译原子节点 → L3Block[] - * 6. 编译复合节点 → 子 L4Flow[] - * 7. 编译图 → L4Flow[] - * 8. 写入 .svp/ - */ -export async function compileBlueprint( - projectRoot: string, -): Promise> { - // 1. 扫描 nodes/ 目录 - const nodesDir = path.join(projectRoot, "nodes"); - const nodeFiles = await scanNodeFiles(nodesDir); - - // 2. 扫描 graphs/ 目录 - const graphsDir = path.join(projectRoot, "graphs"); - const graphFiles = await scanGraphFiles(graphsDir); - - // 3. 解析所有节点 - const nodeIrs: NodeIr[] = []; - for (const filePath of nodeFiles) { - const result = await parseNodeFile(filePath); - if (!result.ok) return result; - nodeIrs.push(result.value); - } - - // 4. 区分原子节点和复合节点 - const atomicNodes = nodeIrs.filter((n) => n.type !== "composite"); - const compositeNodes = nodeIrs.filter((n) => n.type === "composite"); - const compositeNames = new Set(compositeNodes.map((n) => n.name)); - - // 5. 编译原子节点 → L3Block[] - const l3Ids: string[] = []; - for (const node of atomicNodes) { - const result = compileNode(node); - if (!result.ok) return result; - await writeL3(projectRoot, result.value); - l3Ids.push(result.value.id); - } - - // 6. 编译复合节点 → 子 L4Flow[](内部引用的原子节点也需要 L3) - const l4Ids: string[] = []; - for (const node of compositeNodes) { - // 复合节点内部引用的子节点如果是原子节点,已在步骤 5 生成 L3 - const result = compileCompositeNode(node, compositeNames); - if (!result.ok) return result; - await writeL4(projectRoot, result.value); - l4Ids.push(result.value.id); - } - - // 7. 编译图 → L4Flow[] - for (const filePath of graphFiles) { - const parseResult = await parseGraphFile(filePath); - if (!parseResult.ok) return parseResult; - - const compileResult = compileGraph(parseResult.value, compositeNames); - if (!compileResult.ok) return compileResult; - - await writeL4(projectRoot, compileResult.value); - l4Ids.push(compileResult.value.id); - } - - return ok({ l3Blocks: l3Ids, l4Flows: l4Ids }); -} - -/** 递归扫描 nodes/ 目录,找到所有 node.yaml */ -async function scanNodeFiles(nodesDir: string): Promise { - const files: string[] = []; - try { - const entries = await readdir(nodesDir); - // 排序保证确定性 - entries.sort(); - for (const entry of entries) { - const fullPath = path.join(nodesDir, entry); - const s = await stat(fullPath); - if (s.isDirectory()) { - const nodeFile = path.join(fullPath, "node.yaml"); - try { - await stat(nodeFile); - files.push(nodeFile); - } catch { - // node.yaml 不存在,跳过 - } - } - } - } catch { - // nodes/ 目录不存在 - } - return files; -} - -/** 扫描 graphs/ 目录,找到所有 .yaml 文件 */ -async function scanGraphFiles(graphsDir: string): Promise { - const files: string[] = []; - try { - const entries = await readdir(graphsDir); - entries.sort(); - for (const entry of entries) { - if (entry.endsWith(".yaml") || entry.endsWith(".yml")) { - files.push(path.join(graphsDir, entry)); - } - } - } catch { - // graphs/ 目录不存在 - } - return files; -} diff --git a/packages/compiler/index.ts b/packages/compiler/index.ts deleted file mode 100644 index 13bfafe..0000000 --- a/packages/compiler/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 编译器公开 API - -export { compileBlueprint } from "./compile.js"; -export { compileNode } from "./compile-node.js"; -export { compileGraph, compileCompositeNode } from "./compile-graph.js"; -export { parseNodeYaml, parseNodeFile } from "./parse-node.js"; -export { parseGraphYaml, parseGraphFile } from "./parse-graph.js"; -export type { - NodeIr, - GraphIr, - PinIr, - NodeRefIr, - WireIr, - CompileResult, - CompileError, -} from "./types.js"; diff --git a/packages/compiler/parse-graph.test.ts b/packages/compiler/parse-graph.test.ts deleted file mode 100644 index 910acf9..0000000 --- a/packages/compiler/parse-graph.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseGraphYaml } from "./parse-graph.js"; - -describe("parseGraphYaml", () => { - it("解析 hello-world 图", () => { - const yaml = ` -name: hello-world -description: 接收名字,生成大写问候语 -input: - - name: name - type: string -output: - - name: result - type: Greeting -nodes: - - id: g - type: greet - - id: u - type: uppercase -wires: - - from: input.name → to: g.name - - from: g.greeting → to: u.greeting - - from: u.result → to: output.result -`; - const result = parseGraphYaml(yaml, "test.yaml"); - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.value.name).toBe("hello-world"); - expect(result.value.description).toBe("接收名字,生成大写问候语"); - expect(result.value.input).toHaveLength(1); - expect(result.value.output).toHaveLength(1); - expect(result.value.nodes).toHaveLength(2); - expect(result.value.nodes[0]?.id).toBe("g"); - expect(result.value.nodes[0]?.type).toBe("greet"); - }); - - it("解析箭头语法的 wires", () => { - const yaml = ` -name: test -nodes: - - id: a - type: foo - - id: b - type: bar -wires: - - from: a.out → to: b.in -`; - const result = parseGraphYaml(yaml, "test.yaml"); - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.value.wires).toHaveLength(1); - expect(result.value.wires[0]).toEqual({ from: "a.out", to: "b.in" }); - }); - - it("缺少 name 报错", () => { - const yaml = ` -nodes: - - id: a - type: foo -wires: [] -`; - const result = parseGraphYaml(yaml, "bad.yaml"); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.error.code).toBe("MISSING_FIELD"); - }); - - it("缺少 nodes 报错", () => { - const yaml = ` -name: broken -wires: [] -`; - const result = parseGraphYaml(yaml, "bad.yaml"); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.error.code).toBe("MISSING_FIELD"); - }); - - it("缺少 wires 报错", () => { - const yaml = ` -name: broken -nodes: - - id: a - type: foo -`; - const result = parseGraphYaml(yaml, "bad.yaml"); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.error.code).toBe("MISSING_FIELD"); - }); - - it("解析带 trigger 的图", () => { - const yaml = ` -name: api-flow -trigger: - type: http - config: - method: POST - path: /api/orders -nodes: - - id: handler - type: handle-request -wires: [] -`; - const result = parseGraphYaml(yaml, "test.yaml"); - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.value.trigger?.type).toBe("http"); - expect(result.value.trigger?.config).toEqual({ - method: "POST", - path: "/api/orders", - }); - }); -}); diff --git a/packages/compiler/parse-graph.ts b/packages/compiler/parse-graph.ts deleted file mode 100644 index 6c0b2f7..0000000 --- a/packages/compiler/parse-graph.ts +++ /dev/null @@ -1,107 +0,0 @@ -// 解析图 YAML → GraphIR - -import { readFile } from "node:fs/promises"; -import { parseDocument } from "yaml"; -import { err, ok } from "../core/result.js"; -import { parseWires } from "./parse-node.js"; -import type { CompileError, GraphIr, PinIr } from "./types.js"; -import type { Result } from "../core/result.js"; - -/** 从 YAML 字符串解析图 */ -export function parseGraphYaml(content: string, filePath: string): Result { - const doc = parseDocument(content); - - // 只检查非箭头语法相关的致命错误 - const fatalErrors = doc.errors.filter((e) => e.code !== "BLOCK_AS_IMPLICIT_KEY"); - if (fatalErrors.length > 0) { - return err({ - code: "YAML_PARSE_ERROR", - message: `YAML 解析失败: ${filePath}: ${fatalErrors[0].message}`, - path: filePath, - }); - } - - const raw = doc.toJS() as Record | null; - - if (typeof raw !== "object" || raw === null) { - return err({ - code: "INVALID_GRAPH", - message: `图文件内容不是对象: ${filePath}`, - path: filePath, - }); - } - - const name = raw.name; - if (typeof name !== "string" || name === "") { - return err({ - code: "MISSING_FIELD", - message: `缺少 name 字段: ${filePath}`, - path: filePath, - }); - } - - const nodesRaw = raw.nodes; - if (!Array.isArray(nodesRaw)) { - return err({ - code: "MISSING_FIELD", - message: `缺少 nodes 字段: ${filePath}`, - path: filePath, - }); - } - - const wiresRaw = raw.wires; - if (!Array.isArray(wiresRaw)) { - return err({ - code: "MISSING_FIELD", - message: `缺少 wires 字段: ${filePath}`, - path: filePath, - }); - } - - const graph: GraphIr = { - name: name, - ...(raw.description === undefined ? {} : { description: (raw.description as string).trim() }), - ...(raw.input === undefined ? {} : { input: parsePins(raw.input) }), - ...(raw.output === undefined ? {} : { output: parsePins(raw.output) }), - ...(raw.trigger === undefined - ? {} - : { - trigger: raw.trigger as { - type: "http" | "event" | "schedule" | "manual"; - config: Record; - }, - }), - nodes: (nodesRaw as Array>).map((n) => ({ - id: n.id, - type: n.type, - })), - wires: parseWires(wiresRaw as unknown[]), - }; - - return ok(graph); -} - -/** 从文件路径读取并解析图 */ -export async function parseGraphFile(filePath: string): Promise> { - let content: string; - try { - content = await readFile(filePath, "utf8"); - } catch { - return err({ - code: "FILE_READ_ERROR", - message: `无法读取文件: ${filePath}`, - path: filePath, - }); - } - return parseGraphYaml(content, filePath); -} - -/** 解析 pin 数组 */ -function parsePins(raw: unknown): PinIr[] { - if (!Array.isArray(raw)) return []; - return (raw as Array>).map((p) => ({ - name: p.name as string, - type: p.type as string, - ...(p.optional === true ? { optional: true } : {}), - })); -} diff --git a/packages/compiler/parse-node.test.ts b/packages/compiler/parse-node.test.ts deleted file mode 100644 index 4c3033d..0000000 --- a/packages/compiler/parse-node.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseNodeYaml } from "./parse-node.js"; - -describe("parseNodeYaml", () => { - it("解析 greet 节点", () => { - const yaml = ` -name: greet -pins: - input: - - name: name - type: string - output: - - name: greeting - type: Greeting -validate: - name: string -constraints: - - output.greeting.message is not empty -description: | - 接收名字,生成问候消息。 -`; - const result = parseNodeYaml(yaml, "test.yaml"); - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.value.name).toBe("greet"); - expect(result.value.pins.input).toHaveLength(1); - expect(result.value.pins.input[0]?.name).toBe("name"); - expect(result.value.pins.input[0]?.type).toBe("string"); - expect(result.value.pins.output).toHaveLength(1); - expect(result.value.pins.output[0]?.name).toBe("greeting"); - expect(result.value.validate).toEqual({ name: "string" }); - expect(result.value.constraints).toEqual(["output.greeting.message is not empty"]); - expect(result.value.description).toContain("接收名字"); - }); - - it("解析复合节点 order-pipeline", () => { - const yaml = ` -name: order-pipeline -type: composite -pins: - input: - - name: request - type: OrderRequest - output: - - name: result - type: OrderResult -nodes: - - id: val - type: validate-order - - id: inv - type: check-inventory -wires: - - from: input.request → to: val.request - - from: val.result → to: inv.items -`; - const result = parseNodeYaml(yaml, "test.yaml"); - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.value.name).toBe("order-pipeline"); - expect(result.value.type).toBe("composite"); - expect(result.value.nodes).toHaveLength(2); - expect(result.value.nodes?.[0]?.id).toBe("val"); - expect(result.value.nodes?.[0]?.type).toBe("validate-order"); - expect(result.value.wires).toHaveLength(2); - expect(result.value.wires?.[0]?.from).toBe("input.request"); - expect(result.value.wires?.[0]?.to).toBe("val.request"); - }); - - it("缺少 name 字段报错", () => { - const yaml = ` -pins: - input: [] - output: [] -`; - const result = parseNodeYaml(yaml, "bad.yaml"); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.error.code).toBe("MISSING_FIELD"); - }); - - it("缺少 pins 字段报错", () => { - const yaml = ` -name: broken -`; - const result = parseNodeYaml(yaml, "bad.yaml"); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.error.code).toBe("MISSING_FIELD"); - }); - - it("无效 YAML 报错", () => { - const result = parseNodeYaml("{{invalid", "bad.yaml"); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.error.code).toBe("YAML_PARSE_ERROR"); - }); - - it("解析箭头语法的 wires", () => { - const yaml = ` -name: test -type: composite -pins: - input: - - name: x - type: string - output: - - name: y - type: string -nodes: - - id: a - type: foo -wires: - - from: input.x → to: a.x - - from: a.y → to: output.y -`; - const result = parseNodeYaml(yaml, "test.yaml"); - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.value.wires).toHaveLength(2); - expect(result.value.wires?.[0]).toEqual({ from: "input.x", to: "a.x" }); - expect(result.value.wires?.[1]).toEqual({ from: "a.y", to: "output.y" }); - }); -}); diff --git a/packages/compiler/parse-node.ts b/packages/compiler/parse-node.ts deleted file mode 100644 index 7bb8a51..0000000 --- a/packages/compiler/parse-node.ts +++ /dev/null @@ -1,138 +0,0 @@ -// 解析节点 YAML → NodeIR - -import { readFile } from "node:fs/promises"; -import { parseDocument } from "yaml"; -import { err, ok } from "../core/result.js"; -import type { CompileError, NodeIr, PinIr, WireIr } from "./types.js"; -import type { Result } from "../core/result.js"; - -/** 从 YAML 字符串解析节点 */ -export function parseNodeYaml(content: string, filePath: string): Result { - const doc = parseDocument(content); - - // 只检查非箭头语法相关的致命错误 - const fatalErrors = doc.errors.filter((e) => e.code !== "BLOCK_AS_IMPLICIT_KEY"); - if (fatalErrors.length > 0) { - return err({ - code: "YAML_PARSE_ERROR", - message: `YAML 解析失败: ${filePath}: ${fatalErrors[0].message}`, - path: filePath, - }); - } - - const raw = doc.toJS() as Record | null; - - if (typeof raw !== "object" || raw === null) { - return err({ - code: "INVALID_NODE", - message: `节点文件内容不是对象: ${filePath}`, - path: filePath, - }); - } - - // 校验必需字段 - const name = raw.name; - if (typeof name !== "string" || name === "") { - return err({ - code: "MISSING_FIELD", - message: `缺少 name 字段: ${filePath}`, - path: filePath, - }); - } - - const pins = raw.pins; - if (typeof pins !== "object" || pins === null) { - return err({ - code: "MISSING_FIELD", - message: `缺少 pins 字段: ${filePath}`, - path: filePath, - }); - } - - const pinsRecord = pins as Record; - const inputPins = parsePins(pinsRecord.input); - const outputPins = parsePins(pinsRecord.output); - - const nodeType = raw.type === "composite" ? "composite" : undefined; - - const node: NodeIr = { - name: name, - ...(nodeType === undefined ? {} : { type: nodeType }), - pins: { input: inputPins, output: outputPins }, - ...(raw.validate === undefined ? {} : { validate: raw.validate as Record }), - ...(raw.constraints === undefined ? {} : { constraints: raw.constraints as string[] }), - ...(raw.description === undefined ? {} : { description: (raw.description as string).trim() }), - ...(raw.nodes === undefined - ? {} - : { - nodes: (raw.nodes as Array>).map((n) => ({ - id: n.id, - type: n.type, - })), - }), - ...(raw.wires === undefined ? {} : { wires: parseWires(raw.wires as unknown[]) }), - }; - - return ok(node); -} - -/** 从文件路径读取并解析节点 */ -export async function parseNodeFile(filePath: string): Promise> { - let content: string; - try { - content = await readFile(filePath, "utf8"); - } catch { - return err({ - code: "FILE_READ_ERROR", - message: `无法读取文件: ${filePath}`, - path: filePath, - }); - } - return parseNodeYaml(content, filePath); -} - -/** 解析 pin 数组 */ -function parsePins(raw: unknown): PinIr[] { - if (!Array.isArray(raw)) return []; - return (raw as Array>).map((p) => ({ - name: p.name as string, - type: p.type as string, - ...(p.optional === true ? { optional: true } : {}), - })); -} - -/** - * 解析 wire 数组,处理 → 箭头语法。 - * - * YAML 将 `- from: input.name → to: g.name` 解析为嵌套映射: - * `{ from: { "input.name → to": "g.name" } }` - * - * 也支持普通对象语法:`{ from: "a.x", to: "b.y" }` - */ -export function parseWires(raw: unknown[]): WireIr[] { - return raw.map((w) => { - const wire = w as Record; - const fromValue = wire.from; - - // 箭头语法:from 是一个嵌套对象 { "input.name → to": "g.name" } - if (typeof fromValue === "object" && fromValue !== null) { - const nested = fromValue as Record; - const entries = Object.entries(nested); - if (entries.length > 0) { - const [key, value] = entries[0]; - // key 形如 "input.name → to",拆分取 from - const arrowIndex = key.indexOf("→"); - if (arrowIndex !== -1) { - const from = key.slice(0, arrowIndex).trim(); - return { from, to: value }; - } - } - } - - // 普通语法:{ from: "a.x", to: "b.y" } - const fromStr = typeof fromValue === "string" ? fromValue.trim() : ""; - const toRaw = wire.to; - const toStr = typeof toRaw === "string" ? toRaw.trim() : ""; - return { from: fromStr, to: toStr }; - }); -} diff --git a/packages/compiler/types.ts b/packages/compiler/types.ts deleted file mode 100644 index 18f9e1a..0000000 --- a/packages/compiler/types.ts +++ /dev/null @@ -1,64 +0,0 @@ -// 编译器内部中间表示(IR) -// 解析 YAML 后的结构,和 L3/L4 解耦 - -/** Pin 的中间表示 */ -export interface PinIr { - readonly name: string; - readonly type: string; - readonly optional?: boolean; -} - -/** 图中节点引用 */ -export interface NodeRefIr { - readonly id: string; - readonly type: string; // 引用的节点名 -} - -/** 连线中间表示 */ -export interface WireIr { - readonly from: string; // "stepId.pinName" 或 "input.pinName" - readonly to: string; // "stepId.pinName" 或 "output.pinName" -} - -/** 节点中间表示(原子节点 + 复合节点通用) */ -export interface NodeIr { - readonly name: string; - readonly type?: "composite"; // 缺省为原子 - readonly pins: { - readonly input: readonly PinIr[]; - readonly output: readonly PinIr[]; - }; - readonly validate?: Readonly>; - readonly constraints?: readonly string[]; - readonly description?: string; - // 复合节点专有 - readonly nodes?: readonly NodeRefIr[]; - readonly wires?: readonly WireIr[]; -} - -/** 图中间表示 */ -export interface GraphIr { - readonly name: string; - readonly description?: string; - readonly input?: readonly PinIr[]; - readonly output?: readonly PinIr[]; - readonly trigger?: { - readonly type: "http" | "event" | "schedule" | "manual"; - readonly config: Readonly>; - }; - readonly nodes: readonly NodeRefIr[]; - readonly wires: readonly WireIr[]; -} - -/** 编译结果统计 */ -export interface CompileResult { - readonly l3Blocks: readonly string[]; // 生成的 L3 block IDs - readonly l4Flows: readonly string[]; // 生成的 L4 flow IDs -} - -/** 编译错误 */ -export interface CompileError { - readonly code: string; - readonly message: string; - readonly path?: string; // 出错的文件路径 -} diff --git a/packages/core/adapter.ts b/packages/core/adapter.ts deleted file mode 100644 index 71ad1bc..0000000 --- a/packages/core/adapter.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Code CLI Adapter — 桥接 SVP 和宿主 CLI(Claude Code / Cursor / Codex 等) -// -// SVP 是寄生在 Code CLI 上的协议层,不是独立调用 AI 的工具。 -// Adapter 负责: -// 1. 把 SkillInput 转换为宿主 CLI 能理解的指令 -// 2. 调用宿主 CLI 的原生 AI 能力执行任务 -// 3. 把宿主返回的结果转换为 SkillResult - -import type { SkillRegistry } from "./skill.js"; - -/** Code CLI 适配器 — 桥接 SVP 和宿主 CLI */ -export interface CodeCLIAdapter { - /** 宿主 CLI 名称(用于日志/诊断) */ - readonly name: string; - /** 创建包含所有内置 Skill 的注册表(skill 实现由宿主提供) */ - readonly createSkillRegistry: () => SkillRegistry; -} diff --git a/packages/core/check.test.ts b/packages/core/check.test.ts index 84f4ed4..f7da9e3 100644 --- a/packages/core/check.test.ts +++ b/packages/core/check.test.ts @@ -53,6 +53,7 @@ function makeL5(overrides: Partial = {}): L5Blueprint { constraints: [], domains: [{ name: "order", description: "订单域", dependencies: [] }], integrations: [], + language: "en", ...overrides, }; const { revision: _r, ...hashInput } = base; @@ -673,3 +674,281 @@ describe("check — summary", () => { expect(report.summary.warnings).toBeGreaterThanOrEqual(1); }); }); + +// ── Additional branch-coverage tests ── + +describe("check — EventGraph $state ref in 'to' direction", () => { + it("detects MISSING_STATE_REF when $state.xxx used in 'to' endpoint", () => { + const l3 = makeL3(); + const eg = makeEventGraph({ + state: { document: { type: "Doc", description: "d" } }, + handlers: [ + { + id: "h1", + event: "edit", + steps: [{ id: "s1", action: "process" as const, blockRef: "validate-order" }], + dataFlows: [{ from: "s1.result", to: "$state.nonExistent" }], + }, + ], + }); + const report = check({ l4Flows: [eg], l3Blocks: [l3], l2Blocks: [] }); + const stateIssues = report.issues.filter((i) => i.code === "MISSING_STATE_REF"); + expect(stateIssues).toHaveLength(1); + expect(stateIssues[0].message).toContain("nonExistent"); + }); +}); + +describe("check — EventGraph dataFlow format", () => { + it("detects INVALID_DATAFLOW_FORMAT in EventGraph handler (no dot)", () => { + const l3 = makeL3(); + const eg = makeEventGraph({ + handlers: [ + { + id: "h1", + event: "edit", + steps: [{ id: "s1", action: "process" as const, blockRef: "validate-order" }], + dataFlows: [{ from: "bad-format", to: "s1.request" }], + }, + ], + }); + const report = check({ l4Flows: [eg], l3Blocks: [l3], l2Blocks: [] }); + const fmtIssues = report.issues.filter((i) => i.code === "INVALID_DATAFLOW_FORMAT"); + expect(fmtIssues).toHaveLength(1); + expect(fmtIssues[0].message).toContain("bad-format"); + }); +}); + +describe("check — Flow dataFlow step ref", () => { + it("detects MISSING_STEP_REF when dataFlow references non-existent step", () => { + const l3 = makeL3(); + const l4 = makeL4({ + steps: [{ id: "s1", action: "process" as const, blockRef: "validate-order" }], + dataFlows: [{ from: "nonExistentStep.result", to: "s1.request" }], + }); + const report = check({ l4Flows: [l4], l3Blocks: [l3], l2Blocks: [] }); + const stepRefIssues = report.issues.filter((i) => i.code === "MISSING_STEP_REF"); + expect(stepRefIssues).toHaveLength(1); + expect(stepRefIssues[0].message).toContain("nonExistentStep"); + // format is valid (has dot), so no INVALID_DATAFLOW_FORMAT + const fmtIssues = report.issues.filter((i) => i.code === "INVALID_DATAFLOW_FORMAT"); + expect(fmtIssues).toHaveLength(0); + }); +}); + +describe("check — StateMachine onExit ref", () => { + it("detects missing blockRef in onExit", () => { + const sm = makeStateMachine({ + states: { + draft: {}, + pending: { onExit: { blockRef: "non-existent" } }, + }, + transitions: [{ from: "draft", to: "pending", event: "submit" }], + }); + const report = check({ l4Flows: [sm], l3Blocks: [], l2Blocks: [] }); + const refIssues = report.issues.filter( + (i) => i.code === "MISSING_BLOCK_REF" && i.layer === "l4", + ); + expect(refIssues).toHaveLength(1); + expect(refIssues[0].message).toContain("non-existent"); + expect(refIssues[0].message).toContain("onExit"); + }); +}); + +describe("check — multiple parallel branch issues", () => { + it("reports MISSING_STEP_REF for each invalid branch independently", () => { + const l3 = makeL3(); + const l4 = makeL4({ + steps: [{ id: "p1", action: "parallel" as const, branches: ["ghost1", "ghost2"] }], + }); + const report = check({ l4Flows: [l4], l3Blocks: [l3], l2Blocks: [] }); + const stepRefIssues = report.issues.filter((i) => i.code === "MISSING_STEP_REF"); + expect(stepRefIssues).toHaveLength(2); + const messages = stepRefIssues.map((i) => i.message); + expect(messages.some((m) => m.includes("ghost1"))).toBe(true); + expect(messages.some((m) => m.includes("ghost2"))).toBe(true); + }); +}); + +describe("check — mixed L4 variants", () => { + it("validates Flow + EventGraph + StateMachine simultaneously", () => { + const l3 = makeL3(); + const flow = makeL4(); + const eg = makeEventGraph(); + const sm = makeStateMachine(); + const report = check({ l4Flows: [flow, eg, sm], l3Blocks: [l3], l2Blocks: [] }); + // All three are valid — no issues + expect(report.issues).toHaveLength(0); + }); +}); + +describe("check — EventGraph edge cases", () => { + it("handles EventGraph with empty handlers array", () => { + const eg = makeEventGraph({ handlers: [] }); + // Should not crash; EMPTY_STATE warning expected since state has one key but no handlers + const report = check({ l4Flows: [eg], l3Blocks: [], l2Blocks: [] }); + // No NEXT_CYCLE or ORPHAN_STEP since there are no steps + const cycleIssues = report.issues.filter((i) => i.code === "NEXT_CYCLE"); + const orphanIssues = report.issues.filter((i) => i.code === "ORPHAN_STEP"); + expect(cycleIssues).toHaveLength(0); + expect(orphanIssues).toHaveLength(0); + }); +}); + +describe("check — EventGraph multi-handler cycle", () => { + it("detects cycle in one handler while other handler is clean", () => { + const l3 = makeL3(); + const eg = makeEventGraph({ + handlers: [ + { + id: "h1-cyclic", + event: "edit", + steps: [ + { id: "a1", action: "process" as const, blockRef: "validate-order", next: "a2" }, + { id: "a2", action: "process" as const, blockRef: "validate-order", next: "a1" }, + ], + dataFlows: [], + }, + { + id: "h2-clean", + event: "save", + steps: [ + { id: "b1", action: "process" as const, blockRef: "validate-order", next: "b2" }, + { id: "b2", action: "process" as const, blockRef: "validate-order", next: null }, + ], + dataFlows: [], + }, + ], + }); + const report = check({ l4Flows: [eg], l3Blocks: [l3], l2Blocks: [] }); + const cycleIssues = report.issues.filter((i) => i.code === "NEXT_CYCLE"); + expect(cycleIssues).toHaveLength(1); + expect(cycleIssues[0].message).toContain("h1-cyclic"); + }); +}); + +describe("check — orphan detection via waitFor", () => { + it("steps reachable via waitFor are not orphans", () => { + const l3 = makeL3(); + // w1 is the entry step (steps[0]); it waits for s1, making s1 reachable. + // Neither step should be flagged as orphan. + const l4 = makeL4({ + steps: [ + { id: "w1", action: "wait" as const, waitFor: ["s1"] }, + { id: "s1", action: "process" as const, blockRef: "validate-order" }, + ], + }); + const report = check({ l4Flows: [l4], l3Blocks: [l3], l2Blocks: [] }); + const orphanIssues = report.issues.filter((i) => i.code === "ORPHAN_STEP"); + expect(orphanIssues).toHaveLength(0); + }); +}); + +describe("check — empty steps", () => { + it("flow with empty steps produces no graph structure issues", () => { + const l3 = makeL3(); + const l4 = makeL4({ steps: [] }); + const report = check({ l4Flows: [l4], l3Blocks: [l3], l2Blocks: [] }); + const cycleIssues = report.issues.filter((i) => i.code === "NEXT_CYCLE"); + const orphanIssues = report.issues.filter((i) => i.code === "ORPHAN_STEP"); + expect(cycleIssues).toHaveLength(0); + expect(orphanIssues).toHaveLength(0); + }); +}); + +describe("check — CONTENT_DRIFT edge cases", () => { + it("skips CONTENT_DRIFT when l1SignatureHashes map has no entry for L2 id", () => { + const l3 = makeL3(); + const l2 = makeL2({ blockRef: l3.id, sourceHash: l3.contentHash, signatureHash: "old-sig" }); + + const report = check({ + l4Flows: [], + l3Blocks: [l3], + l2Blocks: [l2], + // map exists but does not contain the L2's id "validate-order-ts" + l1SignatureHashes: new Map([["some-other-id", "any-sig"]]), + }); + + const drift = report.issues.find((i) => i.code === "CONTENT_DRIFT"); + expect(drift).toBeUndefined(); + }); +}); + +describe("check — SOURCE_DRIFT edge cases", () => { + it("skips SOURCE_DRIFT when L3 not found for blockRef", () => { + // L2 references a blockRef that does not exist in l3Blocks + // checkDrift: l3HashById.get(cb.blockRef) === undefined → no SOURCE_DRIFT emitted + // checkReferentialIntegrity will emit MISSING_BLOCK_REF instead + const l2 = makeL2({ blockRef: "no-such-block", sourceHash: "some-hash" }); + const report = check({ l4Flows: [], l3Blocks: [], l2Blocks: [l2] }); + const sourceDrift = report.issues.filter((i) => i.code === "SOURCE_DRIFT"); + expect(sourceDrift).toHaveLength(0); + const missingRef = report.issues.filter((i) => i.code === "MISSING_BLOCK_REF"); + expect(missingRef).toHaveLength(1); + }); +}); + +describe("check — multiple flows with different issues", () => { + it("reports issues from each flow independently", () => { + const l3 = makeL3(); + // flow1 has a MISSING_BLOCK_REF + const flow1 = makeL4({ + id: "flow1", + name: "Flow One", + steps: [{ id: "s1", action: "process" as const, blockRef: "non-existent" }], + }); + // flow2 has a NEXT_CYCLE + const flow2 = makeL4({ + id: "flow2", + name: "Flow Two", + steps: [ + { id: "a", action: "process" as const, blockRef: "validate-order", next: "b" }, + { id: "b", action: "process" as const, blockRef: "validate-order", next: "a" }, + ], + }); + const report = check({ l4Flows: [flow1, flow2], l3Blocks: [l3], l2Blocks: [] }); + const refIssues = report.issues.filter((i) => i.code === "MISSING_BLOCK_REF"); + const cycleIssues = report.issues.filter((i) => i.code === "NEXT_CYCLE"); + expect(refIssues).toHaveLength(1); + expect(cycleIssues).toHaveLength(1); + }); +}); + +describe("check — dataFlow pin check skip", () => { + it("skips MISSING_PIN when step has no blockRef (e.g., parallel step)", () => { + const l3 = makeL3(); + // p1 is a parallel step — no blockRef. dataFlow referencing p1.somePin should not + // emit MISSING_PIN because there is no L3 to check against. + const l4 = makeL4({ + steps: [ + { id: "s1", action: "process" as const, blockRef: "validate-order" }, + { id: "p1", action: "parallel" as const, branches: ["s1"] }, + ], + dataFlows: [{ from: "p1.somePin", to: "s1.request" }], + }); + const report = check({ l4Flows: [l4], l3Blocks: [l3], l2Blocks: [] }); + const pinIssues = report.issues.filter((i) => i.code === "MISSING_PIN"); + expect(pinIssues).toHaveLength(0); + }); +}); + +describe("check — EventGraph regular step dataFlow", () => { + it("detects MISSING_PIN for regular step.pin ref in EventGraph handler", () => { + const l3 = makeL3(); // output pin is "result", input pin is "request" + const eg = makeEventGraph({ + state: { document: { type: "Doc", description: "d" } }, + handlers: [ + { + id: "h1", + event: "edit", + steps: [{ id: "s1", action: "process" as const, blockRef: "validate-order" }], + // "nonExistentOutput" is not in l3.output + dataFlows: [{ from: "s1.nonExistentOutput", to: "$state.document" }], + }, + ], + }); + const report = check({ l4Flows: [eg], l3Blocks: [l3], l2Blocks: [] }); + const pinIssues = report.issues.filter((i) => i.code === "MISSING_PIN"); + expect(pinIssues).toHaveLength(1); + expect(pinIssues[0].message).toContain("nonExistentOutput"); + }); +}); diff --git a/packages/core/check.ts b/packages/core/check.ts index 28705ff..de11a51 100644 --- a/packages/core/check.ts +++ b/packages/core/check.ts @@ -2,6 +2,7 @@ // 检查 hash 一致性、引用完整性、漂移检测、图结构合法性 import { computeHash } from "./hash.js"; +import { t } from "./i18n.js"; import { getL4Kind } from "./l4.js"; import type { L2CodeBlock } from "./l2.js"; import type { L3Block } from "./l3.js"; @@ -45,12 +46,13 @@ export interface CheckInput { // ── 主入口 ── -export function check(input: CheckInput): CheckReport { +export function check(input: CheckInput, language = "en"): CheckReport { + const lang = language; const issues: CheckIssue[] = [ - ...checkHashConsistency(input), - ...checkReferentialIntegrity(input), - ...checkDrift(input), - ...checkGraphStructure(input), + ...checkHashConsistency(input, lang), + ...checkReferentialIntegrity(input, lang), + ...checkDrift(input, lang), + ...checkGraphStructure(input, lang), ]; return { @@ -64,7 +66,7 @@ export function check(input: CheckInput): CheckReport { // ── 1. Hash 一致性 ── -function checkHashConsistency(input: CheckInput): CheckIssue[] { +function checkHashConsistency(input: CheckInput, lang: string): CheckIssue[] { const issues: CheckIssue[] = []; if (input.l5 !== undefined) { @@ -76,7 +78,17 @@ function checkHashConsistency(input: CheckInput): CheckIssue[] { layer: "l5", entityId: input.l5.id, code: "HASH_MISMATCH", - message: `L5 contentHash mismatch: stored=${input.l5.contentHash}, computed=${expected}`, + message: t(lang, "check.hashMismatch.l5", { stored: input.l5.contentHash, computed: expected }), + }); + } + + if (input.l5.language === undefined) { + issues.push({ + severity: "warning", + layer: "l5", + entityId: input.l5.id, + code: "MISSING_LANGUAGE", + message: t(lang, "check.missingLanguage"), }); } } @@ -90,7 +102,7 @@ function checkHashConsistency(input: CheckInput): CheckIssue[] { layer: "l4", entityId: l4.id, code: "HASH_MISMATCH", - message: `L4 "${l4.name}" contentHash mismatch: stored=${l4.contentHash}, computed=${expected}`, + message: t(lang, "check.hashMismatch.l4", { name: l4.name, stored: l4.contentHash, computed: expected }), }); } } @@ -104,7 +116,7 @@ function checkHashConsistency(input: CheckInput): CheckIssue[] { layer: "l3", entityId: block.id, code: "HASH_MISMATCH", - message: `L3 "${block.name}" contentHash mismatch: stored=${block.contentHash}, computed=${expected}`, + message: t(lang, "check.hashMismatch.l3", { name: block.name, stored: block.contentHash, computed: expected }), }); } } @@ -118,7 +130,7 @@ function checkHashConsistency(input: CheckInput): CheckIssue[] { layer: "l2", entityId: cb.id, code: "HASH_MISMATCH", - message: `L2 "${cb.id}" contentHash mismatch: stored=${cb.contentHash}, computed=${expected}`, + message: t(lang, "check.hashMismatch.l2", { id: cb.id, stored: cb.contentHash, computed: expected }), }); } } @@ -128,7 +140,7 @@ function checkHashConsistency(input: CheckInput): CheckIssue[] { // ── 2. 引用完整性 ── -function checkReferentialIntegrity(input: CheckInput): CheckIssue[] { +function checkReferentialIntegrity(input: CheckInput, lang: string): CheckIssue[] { const issues: CheckIssue[] = []; const l3Ids = new Set(input.l3Blocks.map((b) => b.id)); @@ -139,15 +151,15 @@ function checkReferentialIntegrity(input: CheckInput): CheckIssue[] { switch (kind) { case "flow": { - issues.push(...checkFlowRefs(l4 as L4Flow, l3Ids, l4Ids, input.l3Blocks)); + issues.push(...checkFlowRefs(l4 as L4Flow, l3Ids, l4Ids, input.l3Blocks, lang)); break; } case "event-graph": { - issues.push(...checkEventGraphRefs(l4 as L4EventGraph, l3Ids, input.l3Blocks)); + issues.push(...checkEventGraphRefs(l4 as L4EventGraph, l3Ids, input.l3Blocks, lang)); break; } case "state-machine": { - issues.push(...checkStateMachineRefs(l4 as L4StateMachine, l3Ids)); + issues.push(...checkStateMachineRefs(l4 as L4StateMachine, l3Ids, lang)); break; } } @@ -161,7 +173,7 @@ function checkReferentialIntegrity(input: CheckInput): CheckIssue[] { layer: "l2", entityId: cb.id, code: "MISSING_BLOCK_REF", - message: `L2 "${cb.id}" references non-existent L3 block "${cb.blockRef}"`, + message: t(lang, "check.missingBlockRef.l2", { id: cb.id, blockRef: cb.blockRef }), }); } } @@ -175,6 +187,7 @@ function checkFlowRefs( l3Ids: Set, l4Ids: Set, l3Blocks: readonly L3Block[], + lang: string, ): CheckIssue[] { const issues: CheckIssue[] = []; const stepIds = new Set(flow.steps.map((s) => s.id)); @@ -186,7 +199,7 @@ function checkFlowRefs( layer: "l4", entityId: flow.id, code: "MISSING_BLOCK_REF", - message: `L4 "${flow.name}" step "${step.id}" references non-existent L3 block "${step.blockRef}"`, + message: t(lang, "check.missingBlockRef.l4FlowStep", { flowName: flow.name, stepId: step.id, blockRef: step.blockRef }), }); } @@ -196,11 +209,11 @@ function checkFlowRefs( layer: "l4", entityId: flow.id, code: "MISSING_FLOW_REF", - message: `L4 "${flow.name}" step "${step.id}" references non-existent L4 flow "${step.flowRef}"`, + message: t(lang, "check.missingFlowRef", { flowName: flow.name, stepId: step.id, flowRef: step.flowRef }), }); } - issues.push(...checkStepInternalRefs(step, stepIds, flow)); + issues.push(...checkStepInternalRefs(step, stepIds, flow, lang)); } // dataFlow endpoint checks @@ -215,6 +228,7 @@ function checkFlowRefs( l3Ids, l3Blocks, issues, + lang, ); checkDataFlowEndpoint( df.to, @@ -226,6 +240,7 @@ function checkFlowRefs( l3Ids, l3Blocks, issues, + lang, ); } @@ -237,6 +252,7 @@ function checkEventGraphRefs( eg: L4EventGraph, l3Ids: Set, l3Blocks: readonly L3Block[], + lang: string, ): CheckIssue[] { const issues: CheckIssue[] = []; @@ -251,11 +267,11 @@ function checkEventGraphRefs( layer: "l4", entityId: eg.id, code: "MISSING_BLOCK_REF", - message: `L4 "${eg.name}" handler "${handler.id}" step "${step.id}" references non-existent L3 block "${step.blockRef}"`, + message: t(lang, "check.missingBlockRef.l4EventGraphStep", { egName: eg.name, handlerId: handler.id, stepId: step.id, blockRef: step.blockRef }), }); } - issues.push(...checkStepInternalRefs(step, stepIds, { id: eg.id, name: eg.name })); + issues.push(...checkStepInternalRefs(step, stepIds, { id: eg.id, name: eg.name }, lang)); } // dataFlow endpoint checks(支持 $state / $event 前缀) @@ -269,8 +285,9 @@ function checkEventGraphRefs( l3Ids, l3Blocks, issues, + lang, ); - checkEventGraphDataFlowEndpoint(df.to, "to", eg, handler, stepIds, l3Ids, l3Blocks, issues); + checkEventGraphDataFlowEndpoint(df.to, "to", eg, handler, stepIds, l3Ids, l3Blocks, issues, lang); } } @@ -278,7 +295,7 @@ function checkEventGraphRefs( } /** StateMachine 引用完整性检查 */ -function checkStateMachineRefs(sm: L4StateMachine, l3Ids: Set): CheckIssue[] { +function checkStateMachineRefs(sm: L4StateMachine, l3Ids: Set, lang: string): CheckIssue[] { const issues: CheckIssue[] = []; // onEntry / onExit blockRef → L3 @@ -289,7 +306,7 @@ function checkStateMachineRefs(sm: L4StateMachine, l3Ids: Set): CheckIss layer: "l4", entityId: sm.id, code: "MISSING_BLOCK_REF", - message: `L4 "${sm.name}" state "${stateName}" onEntry references non-existent L3 block "${config.onEntry.blockRef}"`, + message: t(lang, "check.missingBlockRef.l4SmOnEntry", { smName: sm.name, stateName, blockRef: config.onEntry.blockRef }), }); } if (config.onExit?.blockRef !== undefined && !l3Ids.has(config.onExit.blockRef)) { @@ -298,20 +315,20 @@ function checkStateMachineRefs(sm: L4StateMachine, l3Ids: Set): CheckIss layer: "l4", entityId: sm.id, code: "MISSING_BLOCK_REF", - message: `L4 "${sm.name}" state "${stateName}" onExit references non-existent L3 block "${config.onExit.blockRef}"`, + message: t(lang, "check.missingBlockRef.l4SmOnExit", { smName: sm.name, stateName, blockRef: config.onExit.blockRef }), }); } } // transition guard → L3 - for (const t of sm.transitions) { - if (t.guard !== undefined && !l3Ids.has(t.guard)) { + for (const tr of sm.transitions) { + if (tr.guard !== undefined && !l3Ids.has(tr.guard)) { issues.push({ severity: "error", layer: "l4", entityId: sm.id, code: "MISSING_BLOCK_REF", - message: `L4 "${sm.name}" transition "${t.from}" → "${t.to}" guard references non-existent L3 block "${t.guard}"`, + message: t(lang, "check.missingBlockRef.l4SmGuard", { smName: sm.name, from: tr.from, to: tr.to, guard: tr.guard }), }); } } @@ -324,6 +341,7 @@ function checkStepInternalRefs( step: Step, stepIds: Set, parent: { id: string; name: string }, + lang: string, ): CheckIssue[] { const issues: CheckIssue[] = []; @@ -333,7 +351,7 @@ function checkStepInternalRefs( layer: "l4", entityId: parent.id, code: "MISSING_STEP_REF", - message: `L4 "${parent.name}" step "${step.id}" next references non-existent step "${step.next}"`, + message: t(lang, "check.missingStepRef.next", { parentName: parent.name, stepId: step.id, next: step.next }), }); } @@ -345,7 +363,7 @@ function checkStepInternalRefs( layer: "l4", entityId: parent.id, code: "MISSING_STEP_REF", - message: `L4 "${parent.name}" step "${step.id}" branch references non-existent step "${branchId}"`, + message: t(lang, "check.missingStepRef.branch", { parentName: parent.name, stepId: step.id, branchId }), }); } } @@ -359,7 +377,7 @@ function checkStepInternalRefs( layer: "l4", entityId: parent.id, code: "MISSING_STEP_REF", - message: `L4 "${parent.name}" step "${step.id}" waitFor references non-existent step "${waitId}"`, + message: t(lang, "check.missingStepRef.wait", { parentName: parent.name, stepId: step.id, waitId }), }); } } @@ -379,6 +397,7 @@ function checkDataFlowEndpoint( l3Ids: Set, l3Blocks: readonly L3Block[], issues: CheckIssue[], + lang: string, ): void { const dotIndex = endpoint.indexOf("."); if (dotIndex === -1) { @@ -387,7 +406,7 @@ function checkDataFlowEndpoint( layer: "l4", entityId: flowId, code: "INVALID_DATAFLOW_FORMAT", - message: `L4 "${flowName}" dataFlow ${direction} "${endpoint}" is not in "stepId.pinName" format`, + message: t(lang, "check.invalidDataFlowFormat", { flowName, direction, endpoint }), }); return; } @@ -401,7 +420,7 @@ function checkDataFlowEndpoint( layer: "l4", entityId: flowId, code: "MISSING_STEP_REF", - message: `L4 "${flowName}" dataFlow ${direction} references non-existent step "${stepId}"`, + message: t(lang, "check.missingStepRef.dataFlow", { flowName, direction, stepId }), }); return; } @@ -418,7 +437,7 @@ function checkDataFlowEndpoint( layer: "l4", entityId: flowId, code: "MISSING_PIN", - message: `L4 "${flowName}" dataFlow ${direction} "${endpoint}": pin "${pinName}" not found on L3 block "${step.blockRef}"`, + message: t(lang, "check.missingPin", { flowName, direction, endpoint, pinName, blockRef: step.blockRef }), }); } } @@ -435,6 +454,7 @@ function checkEventGraphDataFlowEndpoint( l3Ids: Set, l3Blocks: readonly L3Block[], issues: CheckIssue[], + lang: string, ): void { const dotIndex = endpoint.indexOf("."); if (dotIndex === -1) { @@ -443,7 +463,7 @@ function checkEventGraphDataFlowEndpoint( layer: "l4", entityId: eg.id, code: "INVALID_DATAFLOW_FORMAT", - message: `L4 "${eg.name}" handler "${handler.id}" dataFlow ${direction} "${endpoint}" is not in valid format`, + message: t(lang, "check.invalidDataFlowFormat.eventGraph", { egName: eg.name, handlerId: handler.id, direction, endpoint }), }); return; } @@ -459,7 +479,7 @@ function checkEventGraphDataFlowEndpoint( layer: "l4", entityId: eg.id, code: "MISSING_STATE_REF", - message: `L4 "${eg.name}" handler "${handler.id}" dataFlow ${direction} references undeclared state key "${field}"`, + message: t(lang, "check.missingStateRef", { egName: eg.name, handlerId: handler.id, direction, field }), }); } return; @@ -481,12 +501,13 @@ function checkEventGraphDataFlowEndpoint( l3Ids, l3Blocks, issues, + lang, ); } // ── 3. 漂移检测 ── -function checkDrift(input: CheckInput): CheckIssue[] { +function checkDrift(input: CheckInput, lang: string): CheckIssue[] { const issues: CheckIssue[] = []; // L2 sourceHash vs L3 contentHash @@ -501,7 +522,7 @@ function checkDrift(input: CheckInput): CheckIssue[] { layer: "l2", entityId: cb.id, code: "SOURCE_DRIFT", - message: `L2 "${cb.id}" sourceHash (${cb.sourceHash}) does not match L3 "${cb.blockRef}" contentHash (${l3Hash}): L3 has changed since last compilation`, + message: t(lang, "check.sourceDrift", { id: cb.id, sourceHash: cb.sourceHash, blockRef: cb.blockRef, l3Hash }), }); } @@ -514,7 +535,7 @@ function checkDrift(input: CheckInput): CheckIssue[] { layer: "l2", entityId: cb.id, code: "CONTENT_DRIFT", - message: `L2 "${cb.id}" signatureHash mismatch: L1 exported signatures have changed since last sync`, + message: t(lang, "check.contentDrift", { id: cb.id }), }); } } @@ -525,7 +546,7 @@ function checkDrift(input: CheckInput): CheckIssue[] { // ── 4. 图结构合法性 ── -function checkGraphStructure(input: CheckInput): CheckIssue[] { +function checkGraphStructure(input: CheckInput, lang: string): CheckIssue[] { const issues: CheckIssue[] = []; for (const l4 of input.l4Flows) { @@ -533,15 +554,15 @@ function checkGraphStructure(input: CheckInput): CheckIssue[] { switch (kind) { case "flow": { - issues.push(...checkFlowGraphStructure(l4 as L4Flow)); + issues.push(...checkFlowGraphStructure(l4 as L4Flow, lang)); break; } case "event-graph": { - issues.push(...checkEventGraphStructure(l4 as L4EventGraph)); + issues.push(...checkEventGraphStructure(l4 as L4EventGraph, lang)); break; } case "state-machine": { - issues.push(...checkStateMachineStructure(l4 as L4StateMachine)); + issues.push(...checkStateMachineStructure(l4 as L4StateMachine, lang)); break; } } @@ -551,15 +572,15 @@ function checkGraphStructure(input: CheckInput): CheckIssue[] { } /** Flow 图结构检查 */ -function checkFlowGraphStructure(flow: L4Flow): CheckIssue[] { +function checkFlowGraphStructure(flow: L4Flow, lang: string): CheckIssue[] { const issues: CheckIssue[] = []; if (flow.steps.length === 0) return issues; const stepMap = new Map(flow.steps.map((s) => [s.id, s])); issues.push( - ...detectNextCycles(flow.id, flow.name, flow.steps, stepMap), - ...detectOrphanSteps(flow.id, flow.name, flow.steps, stepMap), + ...detectNextCycles(flow.id, flow.name, flow.steps, stepMap, lang), + ...detectOrphanSteps(flow.id, flow.name, flow.steps, stepMap, lang), ...flow.steps .filter((step) => step.flowRef === flow.id) .map( @@ -568,7 +589,7 @@ function checkFlowGraphStructure(flow: L4Flow): CheckIssue[] { layer: "l4", entityId: flow.id, code: "SELF_REFERENCING_FLOW", - message: `L4 "${flow.name}" step "${step.id}" calls itself (recursive flow reference)`, + message: t(lang, "check.selfReferencingFlow", { flowName: flow.name, stepId: step.id }), }), ), ); @@ -577,7 +598,7 @@ function checkFlowGraphStructure(flow: L4Flow): CheckIssue[] { } /** EventGraph 图结构检查 — 每个 handler 内部独立检查 */ -function checkEventGraphStructure(eg: L4EventGraph): CheckIssue[] { +function checkEventGraphStructure(eg: L4EventGraph, lang: string): CheckIssue[] { const issues: CheckIssue[] = []; // handler event 唯一性 @@ -589,7 +610,7 @@ function checkEventGraphStructure(eg: L4EventGraph): CheckIssue[] { layer: "l4", entityId: eg.id, code: "DUPLICATE_EVENT", - message: `L4 "${eg.name}" has duplicate event handler for "${handler.event}"`, + message: t(lang, "check.duplicateEvent", { egName: eg.name, event: handler.event }), }); } seenEvents.add(handler.event); @@ -598,8 +619,8 @@ function checkEventGraphStructure(eg: L4EventGraph): CheckIssue[] { if (handler.steps.length > 0) { const stepMap = new Map(handler.steps.map((s) => [s.id, s])); issues.push( - ...detectNextCycles(eg.id, `${eg.name}/${handler.id}`, handler.steps, stepMap), - ...detectOrphanSteps(eg.id, `${eg.name}/${handler.id}`, handler.steps, stepMap), + ...detectNextCycles(eg.id, `${eg.name}/${handler.id}`, handler.steps, stepMap, lang), + ...detectOrphanSteps(eg.id, `${eg.name}/${handler.id}`, handler.steps, stepMap, lang), ); } } @@ -611,7 +632,7 @@ function checkEventGraphStructure(eg: L4EventGraph): CheckIssue[] { layer: "l4", entityId: eg.id, code: "EMPTY_STATE", - message: `L4 "${eg.name}" event-graph has no state declarations`, + message: t(lang, "check.emptyState", { egName: eg.name }), }); } @@ -619,7 +640,7 @@ function checkEventGraphStructure(eg: L4EventGraph): CheckIssue[] { } /** StateMachine 图结构检查 */ -function checkStateMachineStructure(sm: L4StateMachine): CheckIssue[] { +function checkStateMachineStructure(sm: L4StateMachine, lang: string): CheckIssue[] { const issues: CheckIssue[] = []; const stateNames = new Set(Object.keys(sm.states)); @@ -630,28 +651,28 @@ function checkStateMachineStructure(sm: L4StateMachine): CheckIssue[] { layer: "l4", entityId: sm.id, code: "INVALID_INITIAL_STATE", - message: `L4 "${sm.name}" initialState "${sm.initialState}" not found in states`, + message: t(lang, "check.invalidInitialState", { smName: sm.name, initialState: sm.initialState }), }); } // transition from/to 必须在 states 中 - for (const t of sm.transitions) { - if (!stateNames.has(t.from)) { + for (const tr of sm.transitions) { + if (!stateNames.has(tr.from)) { issues.push({ severity: "error", layer: "l4", entityId: sm.id, code: "INVALID_TRANSITION", - message: `L4 "${sm.name}" transition from "${t.from}" references non-existent state`, + message: t(lang, "check.invalidTransition.from", { smName: sm.name, from: tr.from }), }); } - if (!stateNames.has(t.to)) { + if (!stateNames.has(tr.to)) { issues.push({ severity: "error", layer: "l4", entityId: sm.id, code: "INVALID_TRANSITION", - message: `L4 "${sm.name}" transition to "${t.to}" references non-existent state`, + message: t(lang, "check.invalidTransition.to", { smName: sm.name, to: tr.to }), }); } } @@ -664,10 +685,10 @@ function checkStateMachineStructure(sm: L4StateMachine): CheckIssue[] { while (queue.length > 0) { const current = queue.shift()!; - for (const t of sm.transitions) { - if (t.from === current && !reachable.has(t.to)) { - reachable.add(t.to); - queue.push(t.to); + for (const tr of sm.transitions) { + if (tr.from === current && !reachable.has(tr.to)) { + reachable.add(tr.to); + queue.push(tr.to); } } } @@ -679,7 +700,7 @@ function checkStateMachineStructure(sm: L4StateMachine): CheckIssue[] { layer: "l4", entityId: sm.id, code: "UNREACHABLE_STATE", - message: `L4 "${sm.name}" state "${stateName}" is not reachable from initialState "${sm.initialState}"`, + message: t(lang, "check.unreachableState", { smName: sm.name, stateName, initialState: sm.initialState }), }); } } @@ -694,6 +715,7 @@ function detectNextCycles( entityName: string, steps: readonly Step[], stepMap: Map, + lang: string, ): CheckIssue[] { const issues: CheckIssue[] = []; const visited = new Set(); @@ -711,7 +733,7 @@ function detectNextCycles( layer: "l4", entityId, code: "NEXT_CYCLE", - message: `L4 "${entityName}" has a cycle in next chain involving step "${current}"`, + message: t(lang, "check.nextCycle", { entityName, current }), }); break; } @@ -734,6 +756,7 @@ function detectOrphanSteps( entityName: string, steps: readonly Step[], stepMap: Map, + lang: string, ): CheckIssue[] { const issues: CheckIssue[] = []; const reachable = new Set(); @@ -778,7 +801,7 @@ function detectOrphanSteps( layer: "l4", entityId, code: "ORPHAN_STEP", - message: `L4 "${entityName}" step "${step.id}" is not reachable from the first step`, + message: t(lang, "check.orphanStep", { entityName, stepId: step.id }), }); } } diff --git a/packages/core/compile-plan.test.ts b/packages/core/compile-plan.test.ts index 8af9e78..fee12b1 100644 --- a/packages/core/compile-plan.test.ts +++ b/packages/core/compile-plan.test.ts @@ -1,7 +1,7 @@ // compile-plan.ts 单元测试 import { describe, it, expect } from "vitest"; -import { compilePlan } from "./compile-plan.js"; +import { compilePlan, getDefaultComplexity } from "./compile-plan.js"; import { computeHash } from "./hash.js"; import type { L2CodeBlock } from "./l2.js"; import type { L3Block } from "./l3.js"; @@ -101,6 +101,7 @@ describe("compilePlan", () => { expect(plan.tasks[0].targetLayer).toBe("l2"); expect(plan.tasks[0].targetId).toBe("validate"); expect(plan.tasks[0].issueCode).toBe("MISSING_L2"); + expect(plan.tasks[0].complexity).toBe("standard"); // context should reference the L3 block and the L4 flow const contextLayers = plan.tasks[0].context.map((c) => c.layer); @@ -127,6 +128,7 @@ describe("compilePlan", () => { expect(plan.tasks[0].action).toBe("recompile"); expect(plan.tasks[0].targetId).toBe("validate"); expect(plan.tasks[0].issueCode).toBe("SOURCE_DRIFT"); + expect(plan.tasks[0].complexity).toBe("standard"); }); it("detects broken L4 → L3 reference", () => { @@ -146,6 +148,7 @@ describe("compilePlan", () => { expect(plan.tasks[0].targetLayer).toBe("l4"); expect(plan.tasks[0].targetId).toBe("flow-a"); expect(plan.tasks[0].issueCode).toBe("MISSING_BLOCK_REF"); + expect(plan.tasks[0].complexity).toBe("light"); // context should include L5 blueprint const contextLayers = plan.tasks[0].context.map((c) => c.layer); @@ -257,3 +260,455 @@ describe("compilePlan", () => { expect(plan.summary.total).toBe(0); }); }); + +// ── additional tests ── + +describe("compilePlan — cross-type deduplication", () => { + it("preserves tasks with same targetId but different actions", () => { + // block-a has an L3 but no L2 → compile task + // block-b's L2 references a missing L3 → review task + // Both survive dedup because action differs (even if targetLayer matches) + const l3a = makeL3("block-a", "Block A"); + const orphanL2b: L2CodeBlock = (() => { + const base = makeL2(makeL3("block-b-orig", "Block B Orig")); + return { ...base, id: "block-b", blockRef: "block-b-missing" }; + })(); + + const plan = compilePlan({ + l4Flows: [], + l3Blocks: [l3a], // block-a has no L2 → compile + l2Blocks: [orphanL2b], // block-b refs missing L3 → review + }); + + const compileTask = plan.tasks.find((t) => t.action === "compile" && t.targetId === "block-a"); + const reviewTask = plan.tasks.find((t) => t.action === "review" && t.targetId === "block-b"); + expect(compileTask).toBeDefined(); + expect(reviewTask).toBeDefined(); + expect(plan.tasks.length).toBeGreaterThanOrEqual(2); + }); + + it("deduplicates tasks with same targetLayer/targetId/action", () => { + // Two L4 flows reference the same missing L3 block → only one compile task + const l3 = makeL3("shared", "Shared"); + const l4a = makeL4("flow-x", ["shared"]); + const l4b = makeL4("flow-y", ["shared"]); + + const plan = compilePlan({ + l4Flows: [l4a, l4b], + l3Blocks: [l3], + l2Blocks: [], + }); + + const compileTasks = plan.tasks.filter( + (t) => t.action === "compile" && t.targetId === "shared", + ); + expect(compileTasks).toHaveLength(1); + }); +}); + +describe("compilePlan — broken refs context", () => { + it("excludes L5 from context when L5 is undefined", () => { + const l4 = makeL4("flow-no-l5", ["nonexistent-block"]); + + const plan = compilePlan({ + // no l5 + l4Flows: [l4], + l3Blocks: [], + l2Blocks: [], + }); + + const task = plan.tasks.find((t) => t.action === "update-ref" && t.targetId === "flow-no-l5"); + expect(task).toBeDefined(); + const contextLayers = task!.context.map((c) => c.layer); + expect(contextLayers).not.toContain("l5"); + expect(contextLayers).toContain("l4"); + }); + + it("includes L5 in context when L5 is defined", () => { + const l4 = makeL4("flow-with-l5", ["nonexistent-block"]); + const l5 = makeL5(); + + const plan = compilePlan({ + l5, + l4Flows: [l4], + l3Blocks: [], + l2Blocks: [], + }); + + const task = plan.tasks.find((t) => t.action === "update-ref" && t.targetId === "flow-with-l5"); + expect(task).toBeDefined(); + const contextLayers = task!.context.map((c) => c.layer); + expect(contextLayers).toContain("l5"); + expect(contextLayers).toContain("l4"); + }); +}); + +describe("compilePlan — recompile context", () => { + it("includes both L3 and L2 in recompile task context", () => { + const l3Original = makeL3("process", "Process"); + const l2 = makeL2(l3Original); + const l3Updated = makeL3("process", "Process", { description: "Updated logic" }); + + const plan = compilePlan({ + l4Flows: [], + l3Blocks: [l3Updated], + l2Blocks: [l2], + }); + + const task = plan.tasks.find((t) => t.action === "recompile" && t.targetId === "process"); + expect(task).toBeDefined(); + const contextLayers = task!.context.map((c) => c.layer); + expect(contextLayers).toContain("l3"); + expect(contextLayers).toContain("l2"); + }); + + it("includes L4 refs that reference the L3 block in recompile context", () => { + const l3Original = makeL3("worker", "Worker"); + const l2 = makeL2(l3Original); + const l3Updated = makeL3("worker", "Worker", { description: "Changed" }); + const l4 = makeL4("flow-uses-worker", ["worker"]); + + const plan = compilePlan({ + l4Flows: [l4], + l3Blocks: [l3Updated], + l2Blocks: [l2], + }); + + const task = plan.tasks.find((t) => t.action === "recompile" && t.targetId === "worker"); + expect(task).toBeDefined(); + const contextLayers = task!.context.map((c) => c.layer); + expect(contextLayers).toContain("l3"); + expect(contextLayers).toContain("l4"); + expect(contextLayers).toContain("l2"); + + const l4ContextEntry = task!.context.find((c) => c.layer === "l4"); + expect(l4ContextEntry?.id).toBe("flow-uses-worker"); + }); +}); + +describe("compilePlan — content drift context", () => { + it("includes L2 and L3 in review task for CONTENT_DRIFT", () => { + const l3 = makeL3("svc", "Service"); + const l2: L2CodeBlock = { ...makeL2(l3), signatureHash: "old-hash" }; + + const plan = compilePlan({ + l4Flows: [], + l3Blocks: [l3], + l2Blocks: [l2], + l1SignatureHashes: new Map([["svc", "new-hash"]]), + }); + + const task = plan.tasks.find((t) => t.issueCode === "CONTENT_DRIFT"); + expect(task).toBeDefined(); + const contextLayers = task!.context.map((c) => c.layer); + expect(contextLayers).toContain("l2"); + expect(contextLayers).toContain("l3"); + }); + + it("uses L3 id as targetId when L3 exists", () => { + const l3 = makeL3("handler", "Handler"); + const l2: L2CodeBlock = { ...makeL2(l3), signatureHash: "stale" }; + + const plan = compilePlan({ + l4Flows: [], + l3Blocks: [l3], + l2Blocks: [l2], + l1SignatureHashes: new Map([["handler", "fresh"]]), + }); + + const task = plan.tasks.find((t) => t.issueCode === "CONTENT_DRIFT"); + expect(task).toBeDefined(); + expect(task!.targetId).toBe("handler"); // L3 id + expect(task!.targetLayer).toBe("l3"); + }); + + it("falls back to entityId when L3 not found for CONTENT_DRIFT", () => { + // L2 references a missing L3 (blockRef points nowhere) AND has CONTENT_DRIFT + // We need to craft this carefully: the L2 must have a signatureHash mismatch, + // but its blockRef must not match any L3 in the input. + // The L2 id must be in l1SignatureHashes to trigger CONTENT_DRIFT. + const ghostL3 = makeL3("ghost-l3", "Ghost"); + const l2: L2CodeBlock = { + ...makeL2(ghostL3), + id: "orphan-block", + blockRef: "ghost-l3", // references L3 not in input + signatureHash: "outdated", + }; + + const plan = compilePlan({ + l4Flows: [], + l3Blocks: [], // ghost-l3 is absent + l2Blocks: [l2], + l1SignatureHashes: new Map([["orphan-block", "current"]]), + }); + + const task = plan.tasks.find((t) => t.issueCode === "CONTENT_DRIFT"); + expect(task).toBeDefined(); + // l3 not found → targetId falls back to issue.entityId which is the L2 block id + expect(task!.targetId).toBe("orphan-block"); + }); +}); + +describe("compilePlan — orphaned L2", () => { + it("generates review task for L2 with MISSING_BLOCK_REF", () => { + const ghostL3 = makeL3("deleted-block", "Deleted Block"); + const l2 = makeL2(ghostL3); // blockRef="deleted-block", but L3 absent + + const plan = compilePlan({ + l4Flows: [], + l3Blocks: [], // L3 is gone + l2Blocks: [l2], + }); + + const task = plan.tasks.find( + (t) => t.action === "review" && t.issueCode === "MISSING_BLOCK_REF", + ); + expect(task).toBeDefined(); + expect(task!.targetLayer).toBe("l2"); + expect(task!.targetId).toBe("deleted-block"); + }); + + it("context includes orphaned L2 block ref", () => { + const ghostL3 = makeL3("gone", "Gone"); + const l2 = makeL2(ghostL3); + + const plan = compilePlan({ + l4Flows: [], + l3Blocks: [], + l2Blocks: [l2], + }); + + const task = plan.tasks.find( + (t) => t.action === "review" && t.issueCode === "MISSING_BLOCK_REF", + ); + expect(task).toBeDefined(); + const l2ContextEntry = task!.context.find((c) => c.layer === "l2"); + expect(l2ContextEntry).toBeDefined(); + expect(l2ContextEntry!.label).toBe("orphaned L2 code block"); + }); +}); + +describe("compilePlan — multiple missing compilations", () => { + it("generates compile task for each L3 without matching L2", () => { + const l3a = makeL3("alpha", "Alpha"); + const l3b = makeL3("beta", "Beta"); + const l3c = makeL3("gamma", "Gamma"); + + const plan = compilePlan({ + l4Flows: [], + l3Blocks: [l3a, l3b, l3c], + l2Blocks: [], + }); + + expect(plan.summary.compile).toBe(3); + const compiledIds = plan.tasks.filter((t) => t.action === "compile").map((t) => t.targetId); + expect(compiledIds).toContain("alpha"); + expect(compiledIds).toContain("beta"); + expect(compiledIds).toContain("gamma"); + }); + + it("skips L3 blocks that already have matching L2", () => { + const l3a = makeL3("alpha", "Alpha"); + const l3b = makeL3("beta", "Beta"); + const l3c = makeL3("gamma", "Gamma"); + const l2a = makeL2(l3a); // alpha is covered + const l2b = makeL2(l3b); // beta is covered + + const plan = compilePlan({ + l4Flows: [], + l3Blocks: [l3a, l3b, l3c], + l2Blocks: [l2a, l2b], + }); + + expect(plan.summary.compile).toBe(1); + const compileTask = plan.tasks.find((t) => t.action === "compile"); + expect(compileTask?.targetId).toBe("gamma"); + }); +}); + +describe("compilePlan — summary", () => { + it("correctly counts each action type in summary", () => { + // compile: l3 "new-block" has no L2 + const l3New = makeL3("new-block", "New Block"); + + // recompile: l3 "stale-block" changed since l2 was compiled + const l3StaleOrig = makeL3("stale-block", "Stale Block"); + const l2Stale = makeL2(l3StaleOrig); + const l3StaleUpdated = makeL3("stale-block", "Stale Block", { description: "Changed" }); + + // review (CONTENT_DRIFT): "drift-block" has signature mismatch + const l3Drift = makeL3("drift-block", "Drift Block"); + const l2Drift: L2CodeBlock = { ...makeL2(l3Drift), signatureHash: "old" }; + + // update-ref: L4 flow references missing L3 block + const l4Broken = makeL4("broken-flow", ["nonexistent"]); + + const plan = compilePlan({ + l4Flows: [l4Broken], + l3Blocks: [l3New, l3StaleUpdated, l3Drift], + l2Blocks: [l2Stale, l2Drift], + l1SignatureHashes: new Map([["drift-block", "new"]]), + }); + + expect(plan.summary.compile).toBe(1); + expect(plan.summary.recompile).toBe(1); + expect(plan.summary.review).toBe(1); + expect(plan.summary.updateRef).toBe(1); + expect(plan.summary.total).toBe(4); + }); + + it("returns zero counts for empty input", () => { + const plan = compilePlan({ l4Flows: [], l3Blocks: [], l2Blocks: [] }); + expect(plan.summary.compile).toBe(0); + expect(plan.summary.recompile).toBe(0); + expect(plan.summary.review).toBe(0); + expect(plan.summary.updateRef).toBe(0); + expect(plan.summary.total).toBe(0); + }); +}); + +describe("compilePlan — L3 context with L4 backref", () => { + it("includes all L4 flows that reference the L3 block in context", () => { + const l3 = makeL3("shared-block", "Shared Block"); + const l4a = makeL4("flow-one", ["shared-block"]); + const l4b = makeL4("flow-two", ["shared-block"]); + // flow-three does NOT reference shared-block + const l4c = makeL4("flow-three", ["other-block"]); + + const plan = compilePlan({ + l4Flows: [l4a, l4b, l4c], + l3Blocks: [l3], + l2Blocks: [], // no L2 → compile task + }); + + const task = plan.tasks.find((t) => t.action === "compile" && t.targetId === "shared-block"); + expect(task).toBeDefined(); + + const l4ContextEntries = task!.context.filter((c) => c.layer === "l4"); + const l4ContextIds = l4ContextEntries.map((c) => c.id); + expect(l4ContextIds).toContain("flow-one"); + expect(l4ContextIds).toContain("flow-two"); + expect(l4ContextIds).not.toContain("flow-three"); + }); + + it("L3 not referenced by any L4 → only L3 in context", () => { + const l3 = makeL3("isolated-block", "Isolated Block"); + const l4 = makeL4("unrelated-flow", ["other-id"]); + + const plan = compilePlan({ + l4Flows: [l4], + l3Blocks: [l3], + l2Blocks: [], // no L2 → compile task + }); + + const task = plan.tasks.find((t) => t.action === "compile" && t.targetId === "isolated-block"); + expect(task).toBeDefined(); + const contextLayers = task!.context.map((c) => c.layer); + expect(contextLayers).toContain("l3"); + expect(contextLayers).not.toContain("l4"); + }); +}); + +describe("compilePlan — complexity", () => { + it("assigns correct complexity per action type", () => { + const l3New = makeL3("new-block", "New Block"); + + const l3StaleOrig = makeL3("stale-block", "Stale Block"); + const l2Stale = makeL2(l3StaleOrig); + const l3StaleUpdated = makeL3("stale-block", "Stale Block", { description: "Changed" }); + + const l3Drift = makeL3("drift-block", "Drift Block"); + const l2Drift: L2CodeBlock = { ...makeL2(l3Drift), signatureHash: "old" }; + + const l4Broken = makeL4("broken-flow", ["nonexistent"]); + + const plan = compilePlan({ + l4Flows: [l4Broken], + l3Blocks: [l3New, l3StaleUpdated, l3Drift], + l2Blocks: [l2Stale, l2Drift], + l1SignatureHashes: new Map([["drift-block", "new"]]), + }); + + const compileTask = plan.tasks.find((t) => t.action === "compile"); + expect(compileTask?.complexity).toBe("standard"); + + const recompileTask = plan.tasks.find((t) => t.action === "recompile"); + expect(recompileTask?.complexity).toBe("standard"); + + const reviewTask = plan.tasks.find((t) => t.action === "review"); + expect(reviewTask?.complexity).toBe("standard"); + + const updateRefTask = plan.tasks.find((t) => t.action === "update-ref"); + expect(updateRefTask?.complexity).toBe("light"); + }); + + it("summary includes correct complexityCounts", () => { + const l3New = makeL3("new-block", "New Block"); + + const l3StaleOrig = makeL3("stale-block", "Stale Block"); + const l2Stale = makeL2(l3StaleOrig); + const l3StaleUpdated = makeL3("stale-block", "Stale Block", { description: "Changed" }); + + const l3Drift = makeL3("drift-block", "Drift Block"); + const l2Drift: L2CodeBlock = { ...makeL2(l3Drift), signatureHash: "old" }; + + const l4Broken = makeL4("broken-flow", ["nonexistent"]); + + const plan = compilePlan({ + l4Flows: [l4Broken], + l3Blocks: [l3New, l3StaleUpdated, l3Drift], + l2Blocks: [l2Stale, l2Drift], + l1SignatureHashes: new Map([["drift-block", "new"]]), + }); + + expect(plan.summary.complexityCounts).toEqual({ + heavy: 0, + standard: 3, // compile + recompile + review + light: 1, // update-ref + }); + }); + + it("returns zero complexityCounts for empty input", () => { + const plan = compilePlan({ l4Flows: [], l3Blocks: [], l2Blocks: [] }); + expect(plan.summary.complexityCounts).toEqual({ + heavy: 0, + standard: 0, + light: 0, + }); + }); +}); + +describe("compilePlan — language parameter", () => { + it("uses Chinese text in reason/label fields when language is 'zh'", () => { + const l3 = makeL3("validate", "Validate"); + const l4 = makeL4("flow-a", ["validate"]); + + const plan = compilePlan( + { l4Flows: [l4], l3Blocks: [l3], l2Blocks: [] }, + "zh", + ); + + expect(plan.summary.compile).toBe(1); + const task = plan.tasks[0]; + // Chinese locale: reason should contain Chinese characters + expect(task.reason).toMatch(/[\u4e00-\u9fff]/); + }); +}); + +describe("getDefaultComplexity", () => { + it("returns standard for compile", () => { + expect(getDefaultComplexity("compile")).toBe("standard"); + }); + + it("returns standard for recompile", () => { + expect(getDefaultComplexity("recompile")).toBe("standard"); + }); + + it("returns standard for review", () => { + expect(getDefaultComplexity("review")).toBe("standard"); + }); + + it("returns light for update-ref", () => { + expect(getDefaultComplexity("update-ref")).toBe("light"); + }); +}); diff --git a/packages/core/compile-plan.ts b/packages/core/compile-plan.ts index d6247b5..a349a91 100644 --- a/packages/core/compile-plan.ts +++ b/packages/core/compile-plan.ts @@ -3,6 +3,7 @@ // 纯函数,不做 IO import { check } from "./check.js"; +import { t } from "./i18n.js"; import { extractBlockRefs } from "./l4.js"; import type { CheckInput } from "./check.js"; import type { L3Block } from "./l3.js"; @@ -11,6 +12,8 @@ import type { L3Block } from "./l3.js"; export type TaskAction = "compile" | "recompile" | "update-ref" | "review"; +export type Complexity = "heavy" | "standard" | "light"; + export interface CompileTask { readonly action: TaskAction; readonly targetLayer: "l2" | "l3" | "l4"; @@ -18,6 +21,7 @@ export interface CompileTask { readonly reason: string; // 人类可读的原因 readonly issueCode: string; // 对应 check 的 issue code 或自定义 code readonly context: readonly ContextRef[]; // subagent 需要参考的上下文 + readonly complexity: Complexity; } export interface ContextRef { @@ -34,17 +38,31 @@ export interface CompilePlan { readonly recompile: number; readonly updateRef: number; readonly review: number; + readonly complexityCounts: Record; }; } +/** Default complexity for a given task action */ +export function getDefaultComplexity(action: TaskAction): Complexity { + switch (action) { + case "update-ref": + return "light"; + case "compile": + case "recompile": + case "review": + return "standard"; + } +} + // ── 主入口 ── -export function compilePlan(input: CheckInput): CompilePlan { +export function compilePlan(input: CheckInput, language = "en"): CompilePlan { + const lang = language; const tasks: CompileTask[] = [ - ...detectMissingCompilations(input), - ...detectRecompilations(input), - ...detectContentDrift(input), - ...detectBrokenRefs(input), + ...detectMissingCompilations(input, lang), + ...detectRecompilations(input, lang), + ...detectContentDrift(input, lang), + ...detectBrokenRefs(input, lang), ]; // 去重(同一个 target 可能被多个检测器命中) @@ -64,13 +82,18 @@ export function compilePlan(input: CheckInput): CompilePlan { recompile: unique.filter((t) => t.action === "recompile").length, updateRef: unique.filter((t) => t.action === "update-ref").length, review: unique.filter((t) => t.action === "review").length, + complexityCounts: { + heavy: unique.filter((t) => t.complexity === "heavy").length, + standard: unique.filter((t) => t.complexity === "standard").length, + light: unique.filter((t) => t.complexity === "light").length, + }, }, }; } // ── 1. 缺失编译:有 L3 但没有对应 L2 ── -function detectMissingCompilations(input: CheckInput): CompileTask[] { +function detectMissingCompilations(input: CheckInput, lang: string): CompileTask[] { const l2BlockRefs = new Set(input.l2Blocks.map((cb) => cb.blockRef)); return input.l3Blocks @@ -80,17 +103,18 @@ function detectMissingCompilations(input: CheckInput): CompileTask[] { action: "compile", targetLayer: "l2", targetId: block.id, - reason: `L3 block "${block.name}" has no corresponding L2 code block — needs initial compilation`, + reason: t(lang, "compilePlan.reason.missingL2", { name: block.name }), issueCode: "MISSING_L2", - context: buildL3Context(block, input), + context: buildL3Context(block, input, lang), + complexity: "standard", }), ); } // ── 2. 重编译:L3 变了,L2 的 sourceHash 过期 ── -function detectRecompilations(input: CheckInput): CompileTask[] { - const report = check(input); +function detectRecompilations(input: CheckInput, lang: string): CompileTask[] { + const report = check(input, lang); const driftIssues = report.issues.filter((i) => i.code === "SOURCE_DRIFT"); return driftIssues.map((issue): CompileTask => { @@ -99,13 +123,13 @@ function detectRecompilations(input: CheckInput): CompileTask[] { const context: ContextRef[] = []; if (l3 !== undefined) { - context.push(...buildL3Context(l3, input)); + context.push(...buildL3Context(l3, input, lang)); } if (cb !== undefined) { context.push({ layer: "l2", id: cb.id, - label: `current L2 mapping (${cb.files.join(", ")})`, + label: t(lang, "compilePlan.label.currentL2", { files: cb.files.join(", ") }), }); } @@ -113,17 +137,18 @@ function detectRecompilations(input: CheckInput): CompileTask[] { action: "recompile", targetLayer: "l2", targetId: issue.entityId, - reason: `L3 contract changed since last compilation — L2 code is stale`, + reason: t(lang, "compilePlan.reason.sourceDrift"), issueCode: "SOURCE_DRIFT", context, + complexity: "standard", }; }); } // ── 3. 内容漂移:L1 导出签名变了,需要向上对账 ── -function detectContentDrift(input: CheckInput): CompileTask[] { - const report = check(input); +function detectContentDrift(input: CheckInput, lang: string): CompileTask[] { + const report = check(input, lang); const driftIssues = report.issues.filter((i) => i.code === "CONTENT_DRIFT"); return driftIssues.map((issue): CompileTask => { @@ -135,14 +160,14 @@ function detectContentDrift(input: CheckInput): CompileTask[] { context.push({ layer: "l2", id: cb.id, - label: `L2 code block (${cb.files.join(", ")})`, + label: t(lang, "compilePlan.label.l2CodeBlock", { files: cb.files.join(", ") }), }); } if (l3 !== undefined) { context.push({ layer: "l3", id: l3.id, - label: `L3 contract "${l3.name}" — verify still satisfied`, + label: t(lang, "compilePlan.label.l3Verify", { name: l3.name }), }); } @@ -150,17 +175,18 @@ function detectContentDrift(input: CheckInput): CompileTask[] { action: "review", targetLayer: "l3", targetId: l3?.id ?? issue.entityId, - reason: `L1 exported signatures changed — review whether L3 contract still matches the code`, + reason: t(lang, "compilePlan.reason.contentDrift"), issueCode: "CONTENT_DRIFT", context, + complexity: "standard", }; }); } // ── 4. 断裂引用:L4 引用了不存在的 L3 ── -function detectBrokenRefs(input: CheckInput): CompileTask[] { - const report = check(input); +function detectBrokenRefs(input: CheckInput, lang: string): CompileTask[] { + const report = check(input, lang); const tasks: CompileTask[] = []; // L4 → L3 断裂引用 @@ -172,18 +198,19 @@ function detectBrokenRefs(input: CheckInput): CompileTask[] { const flow = input.l4Flows.find((f) => f.id === issue.entityId); if (flow === undefined) continue; - const context: ContextRef[] = [{ layer: "l4", id: flow.id, label: `L4 flow "${flow.name}"` }]; + const context: ContextRef[] = [{ layer: "l4", id: flow.id, label: t(lang, "compilePlan.label.l4Flow", { name: flow.name }) }]; if (input.l5 !== undefined) { - context.push({ layer: "l5", id: input.l5.id, label: "L5 blueprint" }); + context.push({ layer: "l5", id: input.l5.id, label: t(lang, "compilePlan.label.l5Blueprint") }); } tasks.push({ action: "update-ref", targetLayer: "l4", targetId: flow.id, - reason: `Flow references missing L3 block — step needs updating or L3 needs recreating`, + reason: t(lang, "compilePlan.reason.missingBlockRef"), issueCode: "MISSING_BLOCK_REF", context, + complexity: "light", }); } @@ -197,9 +224,10 @@ function detectBrokenRefs(input: CheckInput): CompileTask[] { action: "review", targetLayer: "l2", targetId: issue.entityId, - reason: `L2 code block references missing L3 block — orphaned code needs review`, + reason: t(lang, "compilePlan.reason.missingL2BlockRef"), issueCode: "MISSING_BLOCK_REF", - context: [{ layer: "l2", id: issue.entityId, label: "orphaned L2 code block" }], + context: [{ layer: "l2", id: issue.entityId, label: t(lang, "compilePlan.label.orphanedL2") }], + complexity: "standard", }); } @@ -208,9 +236,9 @@ function detectBrokenRefs(input: CheckInput): CompileTask[] { // ── 辅助:构建 L3 上下文引用 ── -function buildL3Context(block: L3Block, input: CheckInput): ContextRef[] { +function buildL3Context(block: L3Block, input: CheckInput, lang: string): ContextRef[] { const context: ContextRef[] = [ - { layer: "l3", id: block.id, label: `L3 contract "${block.name}"` }, + { layer: "l3", id: block.id, label: t(lang, "compilePlan.label.l3Contract", { name: block.name }) }, ]; // 找到引用此 block 的 L4 artifact @@ -219,7 +247,7 @@ function buildL3Context(block: L3Block, input: CheckInput): ContextRef[] { context.push({ layer: "l4", id: flow.id, - label: `L4 flow "${flow.name}" (references this block)`, + label: t(lang, "compilePlan.label.l4FlowRef", { name: flow.name }), }); } diff --git a/packages/core/computed.test.ts b/packages/core/computed.test.ts index 9df6133..0ad6422 100644 --- a/packages/core/computed.test.ts +++ b/packages/core/computed.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { collectBlockRefs, collectFlowRefs, computeSignature } from "./computed.js"; +import { + collectBlockRefs, + collectFlowRefs, + computeSignature, + resolveDataFlowType, +} from "./computed.js"; +import type { Pin } from "./l3.js"; describe("computeSignature", () => { it("generates simple signature", () => { @@ -41,6 +47,92 @@ describe("computeSignature", () => { ); expect(sig).toBe("splitData(data: RawData): { valid: ValidData; errors: ErrorList }"); }); + + it("handles no input params", () => { + const sig = computeSignature("foo", [], [{ name: "x", type: "T" }]); + expect(sig).toBe("foo(): T"); + }); + + it("handles multiple input params", () => { + const sig = computeSignature( + "create-order", + [ + { name: "userId", type: "string" }, + { name: "items", type: "Item[]" }, + { name: "coupon", type: "Coupon" }, + ], + [{ name: "order", type: "Order" }], + ); + expect(sig).toBe("createOrder(userId: string, items: Item[], coupon: Coupon): Order"); + }); + + it("handles all optional params", () => { + const sig = computeSignature( + "search", + [ + { name: "query", type: "string", optional: true }, + { name: "limit", type: "number", optional: true }, + ], + [{ name: "results", type: "Result[]" }], + ); + expect(sig).toBe("search(query?: string, limit?: number): Result[]"); + }); + + it("handles mixed optional and required params", () => { + const sig = computeSignature( + "fetch-page", + [ + { name: "url", type: "string" }, + { name: "timeout", type: "number", optional: true }, + { name: "headers", type: "Headers", optional: true }, + ], + [{ name: "page", type: "Page" }], + ); + expect(sig).toBe("fetchPage(url: string, timeout?: number, headers?: Headers): Page"); + }); + + it("handles kebab-case id with 3+ segments", () => { + const sig = computeSignature( + "validate-all-orders", + [{ name: "orders", type: "Order[]" }], + [{ name: "report", type: "Report" }], + ); + expect(sig).toBe("validateAllOrders(orders: Order[]): Report"); + }); + + it("handles single-word id with no hyphens", () => { + const sig = computeSignature( + "validate", + [{ name: "input", type: "Input" }], + [{ name: "ok", type: "boolean" }], + ); + expect(sig).toBe("validate(input: Input): boolean"); + }); + + it("handles multiple outputs with 3+ items", () => { + const sig = computeSignature( + "parse-csv", + [{ name: "raw", type: "string" }], + [ + { name: "headers", type: "string[]" }, + { name: "rows", type: "Row[]" }, + { name: "errors", type: "ParseError[]" }, + ], + ); + expect(sig).toBe( + "parseCsv(raw: string): { headers: string[]; rows: Row[]; errors: ParseError[] }", + ); + }); + + it("handles pin with empty type string", () => { + const sig = computeSignature("noop", [{ name: "x", type: "" }], [{ name: "y", type: "" }]); + expect(sig).toBe("noop(x: ): "); + }); + + it("handles empty id string", () => { + const sig = computeSignature("", [{ name: "a", type: "A" }], [{ name: "b", type: "B" }]); + expect(sig).toBe("(a: A): B"); + }); }); describe("collectBlockRefs", () => { @@ -52,6 +144,57 @@ describe("collectBlockRefs", () => { ]); expect(refs).toEqual(["node-a", "node-b"]); }); + + it("returns empty array for empty steps", () => { + expect(collectBlockRefs([])).toEqual([]); + }); + + it("returns empty array when no steps have blockRef", () => { + const refs = collectBlockRefs([ + { id: "p", action: "parallel", branches: ["a", "b"] }, + { id: "w", action: "wait", waitFor: ["a"] }, + ]); + expect(refs).toEqual([]); + }); + + it("deduplicates blockRefs across steps", () => { + const refs = collectBlockRefs([ + { id: "a", action: "process", blockRef: "node-x" }, + { id: "b", action: "process", blockRef: "node-x" }, + { id: "c", action: "process", blockRef: "node-y" }, + ]); + expect(refs).toEqual(["node-x", "node-y"]); + }); + + it("handles single step with blockRef", () => { + const refs = collectBlockRefs([{ id: "a", action: "process", blockRef: "node-only" }]); + expect(refs).toEqual(["node-only"]); + }); + + it("collects only steps that have blockRef (mixed steps)", () => { + const refs = collectBlockRefs([ + { id: "a", action: "process", blockRef: "node-a" }, + { id: "p", action: "parallel", branches: ["a"] }, + { id: "b", action: "process", blockRef: "node-b" }, + { id: "w", action: "wait", waitFor: ["a"] }, + ]); + expect(refs).toEqual(["node-a", "node-b"]); + }); + + it("preserves insertion order of first occurrence", () => { + const refs = collectBlockRefs([ + { id: "c", action: "process", blockRef: "node-c" }, + { id: "a", action: "process", blockRef: "node-a" }, + { id: "b", action: "process", blockRef: "node-b" }, + { id: "c2", action: "process", blockRef: "node-c" }, + ]); + expect(refs).toEqual(["node-c", "node-a", "node-b"]); + }); + + it("does not collect call steps that have only flowRef", () => { + const refs = collectBlockRefs([{ id: "v", action: "call", flowRef: "some-flow" }]); + expect(refs).toEqual([]); + }); }); describe("collectFlowRefs", () => { @@ -62,4 +205,89 @@ describe("collectFlowRefs", () => { ]); expect(refs).toEqual(["validate-all"]); }); + + it("returns empty array for empty steps", () => { + expect(collectFlowRefs([])).toEqual([]); + }); + + it("returns empty array when no steps have flowRef", () => { + const refs = collectFlowRefs([ + { id: "a", action: "process", blockRef: "node-a" }, + { id: "p", action: "parallel", branches: ["a"] }, + ]); + expect(refs).toEqual([]); + }); + + it("deduplicates flowRefs across steps", () => { + const refs = collectFlowRefs([ + { id: "x", action: "call", flowRef: "sub-flow" }, + { id: "y", action: "call", flowRef: "sub-flow" }, + { id: "z", action: "call", flowRef: "other-flow" }, + ]); + expect(refs).toEqual(["sub-flow", "other-flow"]); + }); + + it("collects multiple call steps with different flowRefs", () => { + const refs = collectFlowRefs([ + { id: "a", action: "call", flowRef: "flow-a" }, + { id: "b", action: "call", flowRef: "flow-b" }, + { id: "c", action: "call", flowRef: "flow-c" }, + ]); + expect(refs).toEqual(["flow-a", "flow-b", "flow-c"]); + }); + + it("does not collect process steps that have only blockRef", () => { + const refs = collectFlowRefs([ + { id: "a", action: "process", blockRef: "node-a" }, + { id: "b", action: "process", blockRef: "node-b" }, + ]); + expect(refs).toEqual([]); + }); +}); + +describe("resolveDataFlowType", () => { + it("returns pin type when findPin resolves the ref", () => { + const pin = { name: "result", type: "OrderResult" }; + const result = resolveDataFlowType({ from: "step-a.result", to: "step-b.input" }, () => pin); + expect(result).toBe("OrderResult"); + }); + + it("returns undefined when findPin returns undefined", () => { + const result = resolveDataFlowType( + { from: "step-a.missing", to: "step-b.input" }, + (): Pin | undefined => { + return; + }, + ); + expect(result).toBeUndefined(); + }); + + it("passes dataFlow.from to findPin as the ref", () => { + let capturedRef: string | undefined; + resolveDataFlowType({ from: "step-x.output", to: "step-y.input" }, (ref): Pin | undefined => { + capturedRef = ref; + return; + }); + expect(capturedRef).toBe("step-x.output"); + }); + + it("returns empty string when pin has empty type", () => { + const result = resolveDataFlowType({ from: "step-a.x", to: "step-b.y" }, () => ({ + name: "x", + type: "", + })); + expect(result).toBe(""); + }); + + it("handles multiple calls with different dataFlows independently", () => { + const pinMap: Record = { + "a.out": { name: "out", type: "TypeA" }, + "b.out": { name: "out", type: "TypeB" }, + }; + const findPin = (ref: string) => pinMap[ref]; + + expect(resolveDataFlowType({ from: "a.out", to: "c.in" }, findPin)).toBe("TypeA"); + expect(resolveDataFlowType({ from: "b.out", to: "c.in" }, findPin)).toBe("TypeB"); + expect(resolveDataFlowType({ from: "missing.out", to: "c.in" }, findPin)).toBeUndefined(); + }); }); diff --git a/packages/core/hash.test.ts b/packages/core/hash.test.ts index 062f838..a07415e 100644 --- a/packages/core/hash.test.ts +++ b/packages/core/hash.test.ts @@ -1,5 +1,135 @@ import { describe, expect, it } from "vitest"; -import { computeHash, hashL3 } from "./hash.js"; +import { computeHash, hashL2, hashL3, hashL4, hashL5 } from "./hash.js"; +import type { L2CodeBlock } from "./l2.js"; +import type { L3Block } from "./l3.js"; +import type { L4EventGraph, L4Flow, L4StateMachine } from "./l4.js"; +import type { L5Blueprint } from "./l5.js"; +import type { ArtifactVersion } from "./version.js"; + +// ── Factories ────────────────────────────────────────────────────────────── + +function makeRevision(rev = 1): ArtifactVersion { + return { + rev, + parentRev: rev === 1 ? null : rev - 1, + source: { type: "human" }, + timestamp: "2024-01-01T00:00:00Z", + }; +} + +function makeL3Block(overrides?: Partial): L3Block { + return { + id: "validate-order", + name: "Validate Order", + input: [{ name: "request", type: "OrderRequest" }], + output: [{ name: "result", type: "ValidationResult" }], + validate: { "request.items": "array, min 1" }, + constraints: ["output.result.total >= 0"], + description: "Validates all fields of the order", + revision: makeRevision(), + contentHash: "abc123", + ...overrides, + }; +} + +function makeL4Flow(overrides?: Partial): L4Flow { + return { + id: "order-flow", + name: "Order Flow", + steps: [ + { id: "step-1", action: "process", blockRef: "validate-order", next: "step-2" }, + { id: "step-2", action: "process", blockRef: "save-order", next: null }, + ], + dataFlows: [{ from: "step-1.result", to: "step-2.input" }], + revision: makeRevision(), + contentHash: "def456", + ...overrides, + }; +} + +function makeL4EventGraph(overrides?: Partial): L4EventGraph { + return { + kind: "event-graph", + id: "collab-graph", + name: "Collaboration Graph", + state: { + document: { type: "Document", description: "Shared document state" }, + }, + handlers: [ + { + id: "on-edit", + event: "user.local_edit", + steps: [{ id: "s1", action: "process", blockRef: "apply-edit" }], + dataFlows: [{ from: "$event.delta", to: "s1.delta" }], + }, + ], + revision: makeRevision(), + contentHash: "ghi789", + ...overrides, + }; +} + +function makeL4StateMachine(overrides?: Partial): L4StateMachine { + return { + kind: "state-machine", + id: "order-sm", + name: "Order State Machine", + entity: "PurchaseOrder", + initialState: "pending", + states: { + pending: { onEntry: { blockRef: "init-order" } }, + confirmed: {}, + cancelled: {}, + }, + transitions: [ + { from: "pending", to: "confirmed", event: "confirm" }, + { from: "pending", to: "cancelled", event: "cancel", guard: "can-cancel" }, + ], + revision: makeRevision(), + contentHash: "jkl012", + ...overrides, + }; +} + +function makeL5Blueprint(overrides?: Partial): L5Blueprint { + return { + id: "shop-blueprint", + name: "Shop Blueprint", + version: "1.0.0", + intent: "Build an e-commerce order management system", + constraints: ["max latency 200ms", "GDPR compliant"], + domains: [ + { name: "orders", description: "Order management", dependencies: [] }, + { name: "payments", description: "Payment processing", dependencies: ["orders"] }, + ], + integrations: [{ name: "postgres", type: "database", description: "Primary store" }], + revision: makeRevision(), + contentHash: "mno345", + ...overrides, + }; +} + +function makeL2CodeBlock(overrides?: Partial): L2CodeBlock { + return { + id: "l2-validate-order", + blockRef: "validate-order", + language: "typescript", + files: ["src/orders/validate.ts", "src/orders/types.ts"], + sourceHash: "l3hash001", + contentHash: "l2hash001", + signatureHash: "sighash001", + revision: makeRevision(), + ...overrides, + }; +} + +// ── computeHash ──────────────────────────────────────────────────────────── +// +// Key behavior: JSON.stringify(record, sortedTopLevelKeys) uses the top-level +// keys array as a whitelist for ALL levels of the object graph. Nested object +// properties are serialized only if their key name also appears as a top-level +// key of the input object. This means nested-only fields (e.g. Pin.type, +// DataFlow.from/to) are silently omitted from the hash input. describe("computeHash", () => { it("produces consistent hash for same content", () => { @@ -21,36 +151,562 @@ describe("computeHash", () => { expect(h1).toBe(h2); }); - it("ignores source.hash field", () => { + it("source.hash nested key is preserved and affects hash", () => { const h1 = computeHash({ a: 1, source: { type: "l4", ref: "x", hash: "old" } }); const h2 = computeHash({ a: 1, source: { type: "l4", ref: "x", hash: "new" } }); - expect(h1).toBe(h2); + expect(h1).not.toBe(h2); }); it("returns 16 char hex string", () => { const h = computeHash({ x: 42 }); expect(h).toMatch(/^[0-9a-f]{16}$/); }); + + it("key ordering independence — same fields in different insertion order produce same hash", () => { + const h1 = computeHash({ a: 1, b: 2 }); + const h2 = computeHash({ b: 2, a: 1 }); + expect(h1).toBe(h2); + }); + + it("empty object produces a valid 16-char hex hash", () => { + const h = computeHash({}); + expect(h).toMatch(/^[0-9a-f]{16}$/); + }); + + it("ignores sourceHash field at top level", () => { + const h1 = computeHash({ a: 1, sourceHash: "v1" }); + const h2 = computeHash({ a: 1, sourceHash: "v2" }); + expect(h1).toBe(h2); + }); + + it("ignores revision field at top level", () => { + const h1 = computeHash({ a: 1, revision: { rev: 1, parentRev: null } }); + const h2 = computeHash({ a: 1, revision: { rev: 99, parentRev: 98 } }); + expect(h1).toBe(h2); + }); + + it("strips contentHash recursively in nested objects", () => { + const h1 = computeHash({ outer: { contentHash: "x", data: "hello" } }); + const h2 = computeHash({ outer: { contentHash: "y", data: "hello" } }); + // 'data' is not a top-level key so it's omitted; only 'outer' key is serialized + // Both objects after stripping produce { outer: { data: 'hello' } } but 'data' + // is excluded by the replacer — so both hash to the same value + expect(h1).toBe(h2); + }); + + it("strips sourceHash recursively in nested objects", () => { + const h1 = computeHash({ outer: { sourceHash: "x", id: "foo" } }); + const h2 = computeHash({ outer: { sourceHash: "y", id: "foo" } }); + // 'id' is not a top-level key here, so nested id is also omitted + expect(h1).toBe(h2); + }); + + it("preserves array values — different array contents produce different hashes", () => { + const h1 = computeHash({ items: ["a", "b", "c"] }); + const h2 = computeHash({ items: ["a", "b", "c"] }); + const h3 = computeHash({ items: ["a", "b"] }); + expect(h1).toBe(h2); + expect(h1).not.toBe(h3); + }); + + it("handles null values in objects", () => { + const h1 = computeHash({ a: null }); + const h2 = computeHash({ a: null }); + const h3 = computeHash({ a: 1 }); + expect(h1).toBe(h2); + expect(h1).not.toBe(h3); + }); + + it("deeply nested values affect hash", () => { + const h1 = computeHash({ l1: { l2: { l3: { value: 42 } } } }); + const h2 = computeHash({ l1: { l2: { l3: { value: 99 } } } }); + expect(h1).not.toBe(h2); + }); + + it("adding a new top-level key changes the hash", () => { + const h1 = computeHash({ a: 1 }); + const h2 = computeHash({ a: 1, b: 2 }); + expect(h1).not.toBe(h2); + }); + + it("object with only stripped fields hashes the same as empty object after stripping", () => { + const hEmpty = computeHash({}); + const hStripped = computeHash({ + contentHash: "any", + sourceHash: "any", + revision: { rev: 5 }, + }); + expect(hStripped).toBe(hEmpty); + }); + + it("hash is deterministic across 100 repeated calls", () => { + const obj = { x: 1, y: "test", z: [1, 2, 3] }; + const first = computeHash(obj); + for (let i = 0; i < 99; i++) { + expect(computeHash(obj)).toBe(first); + } + }); }); +// ── hashL3 ───────────────────────────────────────────────────────────────── +// +// L3 top-level keys (after stripping): constraints, description, id, input, +// name, output, validate. +// Pin.type is NOT a top-level key so it is omitted from the hash. +// validate record values are also omitted (nested keys not top-level). +// Pin.name IS serialized because 'name' is a top-level L3 key. + describe("hashL3", () => { - it("hashes L3Block fields", () => { - const block = { - id: "validate-order", - name: "验证订单", - input: [{ name: "request", type: "OrderRequest" }], - output: [{ name: "result", type: "ValidationResult" }], - validate: { "request.items": "array, min 1" }, - constraints: ["output.result.total >= 0"], - description: "校验全部字段", - }; + it("hashes L3Block fields and returns 16-char hex", () => { + const block = makeL3Block(); const h = hashL3(block); expect(h).toMatch(/^[0-9a-f]{16}$/); + }); + + it("same content produces same hash", () => { + const block = makeL3Block(); + expect(hashL3(block)).toBe(hashL3({ ...block })); + }); + + it("different description produces different hash", () => { + const block = makeL3Block(); + expect(hashL3(block)).not.toBe(hashL3({ ...block, description: "Different desc" })); + }); + + it("same content with different contentHash produces same hash", () => { + const h1 = hashL3({ ...makeL3Block(), contentHash: "hash-v1" } as never); + const h2 = hashL3({ ...makeL3Block(), contentHash: "hash-v2" } as never); + expect(h1).toBe(h2); + }); + + it("same content with different revision produces same hash", () => { + const h1 = hashL3({ ...makeL3Block(), revision: makeRevision(1) } as never); + const h2 = hashL3({ ...makeL3Block(), revision: makeRevision(99) } as never); + expect(h1).toBe(h2); + }); + + it("different input pin name produces different hash ('name' is a top-level key)", () => { + const block = makeL3Block(); + const modified = makeL3Block({ + input: [{ name: "differentName", type: "OrderRequest" }], + }); + expect(hashL3(block)).not.toBe(hashL3(modified)); + }); + + it("different input pin type produces different hash", () => { + const block = makeL3Block(); + const modified = makeL3Block({ + input: [{ name: "request", type: "DifferentType" }], + }); + expect(hashL3(block)).not.toBe(hashL3(modified)); + }); + + it("adding an extra input pin changes hash (array length changes)", () => { + const block = makeL3Block(); + const modified = makeL3Block({ + input: [ + { name: "request", type: "OrderRequest" }, + { name: "context", type: "Context" }, + ], + }); + expect(hashL3(block)).not.toBe(hashL3(modified)); + }); + + it("different output pin name produces different hash", () => { + const block = makeL3Block(); + const modified = makeL3Block({ + output: [{ name: "differentResult", type: "ValidationResult" }], + }); + expect(hashL3(block)).not.toBe(hashL3(modified)); + }); + + it("different output pin type produces different hash", () => { + const block = makeL3Block(); + const modified = makeL3Block({ + output: [{ name: "result", type: "AnotherResult" }], + }); + expect(hashL3(block)).not.toBe(hashL3(modified)); + }); + + it("different validate rule values produce different hash", () => { + const block = makeL3Block(); + const modified = makeL3Block({ validate: { "request.items": "array, min 5" } }); + expect(hashL3(block)).not.toBe(hashL3(modified)); + }); + + it("validate with vs without entries produces different hash", () => { + const block = makeL3Block(); + const modified = makeL3Block({ validate: {} }); + expect(hashL3(block)).not.toBe(hashL3(modified)); + }); + + it("different constraints produce different hash", () => { + const block = makeL3Block(); + const modified = makeL3Block({ constraints: ["output.result.total > 100"] }); + expect(hashL3(block)).not.toBe(hashL3(modified)); + }); + + it("empty validate, constraints, and description produce a valid hash", () => { + const block = makeL3Block({ validate: {}, constraints: [], description: "" }); + expect(hashL3(block)).toMatch(/^[0-9a-f]{16}$/); + }); + + it("optional pin field produces different hash", () => { + const withoutOptional = makeL3Block({ + input: [{ name: "req", type: "Req" }], + }); + const withOptional = makeL3Block({ + input: [{ name: "req", type: "Req", optional: true }], + }); + expect(hashL3(withoutOptional)).not.toBe(hashL3(withOptional)); + }); - // same content → same hash - expect(hashL3({ ...block })).toBe(h); + it("different id produces different hash", () => { + const block = makeL3Block(); + expect(hashL3(block)).not.toBe(hashL3({ ...block, id: "other-block" })); + }); - // different content → different hash - expect(hashL3({ ...block, description: "不同描述" })).not.toBe(h); + it("different name produces different hash", () => { + const block = makeL3Block(); + expect(hashL3(block)).not.toBe(hashL3({ ...block, name: "Other Name" })); + }); +}); + +// ── hashL4 ───────────────────────────────────────────────────────────────── +// +// L4Flow top-level keys (after stripping): dataFlows, id, name, steps. +// Step fields action/blockRef/next/branches/waitFor are NOT top-level keys, +// so they are omitted. Step.id IS serialized because 'id' is top-level. +// DataFlow.from/to are NOT top-level so dataFlow content doesn't affect hash. + +describe("hashL4 — L4Flow", () => { + it("hashes L4Flow fields and returns 16-char hex", () => { + const flow = makeL4Flow(); + expect(hashL4(flow)).toMatch(/^[0-9a-f]{16}$/); + }); + + it("same flow content produces same hash", () => { + const flow = makeL4Flow(); + expect(hashL4(flow)).toBe(hashL4({ ...flow })); + }); + + it("adding a step changes the hash (array length changes)", () => { + const flow = makeL4Flow(); + const modified = makeL4Flow({ + steps: [ + ...flow.steps, + { id: "step-3", action: "process", blockRef: "extra-block", next: null }, + ], + }); + expect(hashL4(flow)).not.toBe(hashL4(modified)); + }); + + it("changing a step id changes the hash (id is a top-level key)", () => { + const flow = makeL4Flow(); + const modified = makeL4Flow({ + steps: [ + { id: "CHANGED", action: "process", blockRef: "validate-order", next: "step-2" }, + { id: "step-2", action: "process", blockRef: "save-order", next: null }, + ], + }); + expect(hashL4(flow)).not.toBe(hashL4(modified)); + }); + + it("changing step blockRef produces different hash", () => { + const flow = makeL4Flow(); + const modified = makeL4Flow({ + steps: [ + { id: "step-1", action: "process", blockRef: "DIFFERENT-BLOCK", next: "step-2" }, + { id: "step-2", action: "process", blockRef: "save-order", next: null }, + ], + }); + expect(hashL4(flow)).not.toBe(hashL4(modified)); + }); + + it("changing dataFlow content produces different hash", () => { + const flow = makeL4Flow(); + const modified = makeL4Flow({ + dataFlows: [{ from: "step-1.output", to: "step-2.other" }], + }); + expect(hashL4(flow)).not.toBe(hashL4(modified)); + }); + + it("adding a dataFlow entry changes hash (array length changes)", () => { + const flow = makeL4Flow(); + const modified = makeL4Flow({ + dataFlows: [ + { from: "step-1.result", to: "step-2.input" }, + { from: "step-1.extra", to: "step-2.other" }, + ], + }); + expect(hashL4(flow)).not.toBe(hashL4(modified)); + }); + + it("different flow id produces different hash", () => { + const flow = makeL4Flow(); + expect(hashL4(flow)).not.toBe(hashL4({ ...flow, id: "other-flow" })); + }); + + it("different flow name produces different hash", () => { + const flow = makeL4Flow(); + expect(hashL4(flow)).not.toBe(hashL4({ ...flow, name: "Other Flow" })); + }); + + it("ignores contentHash and revision in L4Flow", () => { + const flow = makeL4Flow(); + const h1 = hashL4({ ...flow, contentHash: "v1", revision: makeRevision(1) } as never); + const h2 = hashL4({ ...flow, contentHash: "v2", revision: makeRevision(50) } as never); + expect(h1).toBe(h2); + }); +}); + +describe("hashL4 — L4EventGraph", () => { + it("hashes L4EventGraph fields and returns 16-char hex", () => { + const eg = makeL4EventGraph(); + expect(hashL4(eg)).toMatch(/^[0-9a-f]{16}$/); + }); + + it("same event graph content produces same hash", () => { + const eg = makeL4EventGraph(); + expect(hashL4(eg)).toBe(hashL4({ ...eg })); + }); + + it("different event graph id produces different hash", () => { + const eg = makeL4EventGraph(); + expect(hashL4(eg)).not.toBe(hashL4({ ...eg, id: "other-graph" })); + }); + + it("different event graph name produces different hash", () => { + const eg = makeL4EventGraph(); + expect(hashL4(eg)).not.toBe(hashL4({ ...eg, name: "Other Graph" })); + }); + + it("adding a handler changes hash (handlers array length changes)", () => { + const eg = makeL4EventGraph(); + const modified = makeL4EventGraph({ + handlers: [ + ...eg.handlers, + { + id: "on-delete", + event: "user.delete", + steps: [{ id: "s2", action: "process", blockRef: "delete-doc" }], + dataFlows: [], + }, + ], + }); + expect(hashL4(eg)).not.toBe(hashL4(modified)); + }); + + it("ignores contentHash and revision in L4EventGraph", () => { + const eg = makeL4EventGraph(); + const h1 = hashL4({ ...eg, contentHash: "old", revision: makeRevision(1) } as never); + const h2 = hashL4({ ...eg, contentHash: "new", revision: makeRevision(7) } as never); + expect(h1).toBe(h2); + }); + + it("L4EventGraph and L4Flow with same id/name produce different hashes (kind field differs)", () => { + const eg = makeL4EventGraph({ id: "shared-id", name: "Shared" }); + const flow = makeL4Flow({ id: "shared-id", name: "Shared" }); + expect(hashL4(eg)).not.toBe(hashL4(flow)); + }); +}); + +describe("hashL4 — L4StateMachine", () => { + it("hashes L4StateMachine fields and returns 16-char hex", () => { + const sm = makeL4StateMachine(); + expect(hashL4(sm)).toMatch(/^[0-9a-f]{16}$/); + }); + + it("same state machine content produces same hash", () => { + const sm = makeL4StateMachine(); + expect(hashL4(sm)).toBe(hashL4({ ...sm })); + }); + + it("different state machine id produces different hash", () => { + const sm = makeL4StateMachine(); + expect(hashL4(sm)).not.toBe(hashL4({ ...sm, id: "other-sm" })); + }); + + it("different initialState produces different hash", () => { + const sm = makeL4StateMachine(); + expect(hashL4(sm)).not.toBe(hashL4({ ...sm, initialState: "confirmed" } as never)); + }); + + it("adding a transition changes hash (array length changes)", () => { + const sm = makeL4StateMachine(); + const modified = makeL4StateMachine({ + transitions: [...sm.transitions, { from: "confirmed", to: "cancelled", event: "refund" }], + }); + expect(hashL4(sm)).not.toBe(hashL4(modified)); + }); + + it("ignores contentHash and revision in L4StateMachine", () => { + const sm = makeL4StateMachine(); + const h1 = hashL4({ ...sm, contentHash: "old", revision: makeRevision(1) } as never); + const h2 = hashL4({ ...sm, contentHash: "new", revision: makeRevision(10) } as never); + expect(h1).toBe(h2); + }); +}); + +// ── hashL5 ───────────────────────────────────────────────────────────────── +// +// L5Blueprint top-level keys (after stripping): constraints, domains, id, +// integrations, intent, name, version. +// Domain.description/dependencies and Integration.type/description are only +// serialized if their key names appear at the top level — 'name' does (top-level), +// 'description' does NOT (not a top-level L5 key), 'type' does NOT. + +describe("hashL5", () => { + it("hashes L5Blueprint fields and returns 16-char hex", () => { + const bp = makeL5Blueprint(); + expect(hashL5(bp)).toMatch(/^[0-9a-f]{16}$/); + }); + + it("same content produces same hash", () => { + const bp = makeL5Blueprint(); + expect(hashL5(bp)).toBe(hashL5({ ...bp })); + }); + + it("different intent produces different hash", () => { + const bp = makeL5Blueprint(); + const modified = makeL5Blueprint({ intent: "Build a logistics system" }); + expect(hashL5(bp)).not.toBe(hashL5(modified)); + }); + + it("different constraints produce different hash", () => { + const bp = makeL5Blueprint(); + const modified = makeL5Blueprint({ constraints: ["max latency 500ms"] }); + expect(hashL5(bp)).not.toBe(hashL5(modified)); + }); + + it("different domain name produces different hash (name is a top-level key)", () => { + const bp = makeL5Blueprint(); + const modified = makeL5Blueprint({ + domains: [{ name: "inventory", description: "Inventory mgmt", dependencies: [] }], + }); + expect(hashL5(bp)).not.toBe(hashL5(modified)); + }); + + it("different integration name produces different hash (name is a top-level key)", () => { + const bp = makeL5Blueprint(); + const modified = makeL5Blueprint({ + integrations: [{ name: "redis", type: "database", description: "Cache" }], + }); + expect(hashL5(bp)).not.toBe(hashL5(modified)); + }); + + it("adding a domain changes hash (array length changes)", () => { + const bp = makeL5Blueprint(); + const modified = makeL5Blueprint({ + domains: [ + ...bp.domains, + { name: "notifications", description: "Notifications", dependencies: [] }, + ], + }); + expect(hashL5(bp)).not.toBe(hashL5(modified)); + }); + + it("adding an integration changes hash (array length changes)", () => { + const bp = makeL5Blueprint(); + const modified = makeL5Blueprint({ + integrations: [ + ...bp.integrations, + { name: "redis", type: "storage", description: "Cache layer" }, + ], + }); + expect(hashL5(bp)).not.toBe(hashL5(modified)); + }); + + it("empty constraints, domains, and integrations produce a valid hash", () => { + const bp = makeL5Blueprint({ constraints: [], domains: [], integrations: [] }); + expect(hashL5(bp)).toMatch(/^[0-9a-f]{16}$/); + }); + + it("ignores contentHash and revision in L5Blueprint", () => { + const bp = makeL5Blueprint(); + const h1 = hashL5({ ...bp, contentHash: "v1", revision: makeRevision(1) } as never); + const h2 = hashL5({ ...bp, contentHash: "v2", revision: makeRevision(42) } as never); + expect(h1).toBe(h2); + }); + + it("different version produces different hash", () => { + const bp = makeL5Blueprint(); + expect(hashL5(bp)).not.toBe(hashL5({ ...bp, version: "2.0.0" })); + }); +}); + +// ── hashL2 ───────────────────────────────────────────────────────────────── +// +// L2CodeBlock top-level keys (after stripping contentHash/sourceHash/revision): +// blockRef, files, id, language, signatureHash. + +describe("hashL2", () => { + it("hashes L2CodeBlock fields and returns 16-char hex", () => { + const block = makeL2CodeBlock(); + expect(hashL2(block)).toMatch(/^[0-9a-f]{16}$/); + }); + + it("same content produces same hash", () => { + const block = makeL2CodeBlock(); + expect(hashL2(block)).toBe(hashL2({ ...block })); + }); + + it("different files produce different hash", () => { + const block = makeL2CodeBlock(); + const modified = makeL2CodeBlock({ files: ["src/other/file.ts"] }); + expect(hashL2(block)).not.toBe(hashL2(modified)); + }); + + it("different language produces different hash", () => { + const block = makeL2CodeBlock(); + const modified = makeL2CodeBlock({ language: "python" }); + expect(hashL2(block)).not.toBe(hashL2(modified)); + }); + + it("ignores contentHash, sourceHash, AND revision (all three stripped)", () => { + const block = makeL2CodeBlock(); + const h1 = hashL2({ + ...block, + contentHash: "c1", + sourceHash: "s1", + revision: makeRevision(1), + } as never); + const h2 = hashL2({ + ...block, + contentHash: "c2", + sourceHash: "s2", + revision: makeRevision(99), + } as never); + expect(h1).toBe(h2); + }); + + it("different signatureHash values produce different hash", () => { + const h1 = hashL2(makeL2CodeBlock({ signatureHash: "sig001" })); + const h2 = hashL2(makeL2CodeBlock({ signatureHash: "sig999" })); + expect(h1).not.toBe(h2); + }); + + it("signatureHash present vs absent changes hash", () => { + const withSig = makeL2CodeBlock({ signatureHash: "sig001" }); + // Construct without signatureHash by omitting it (not just undefined) + const { signatureHash: _, ...rest } = withSig; + expect(hashL2(withSig)).not.toBe(hashL2(rest as L2CodeBlock)); + }); + + it("empty files array produces a valid hash", () => { + const block = makeL2CodeBlock({ files: [] }); + expect(hashL2(block)).toMatch(/^[0-9a-f]{16}$/); + }); + + it("different blockRef produces different hash", () => { + const h1 = hashL2(makeL2CodeBlock({ blockRef: "block-a" })); + const h2 = hashL2(makeL2CodeBlock({ blockRef: "block-b" })); + expect(h1).not.toBe(h2); + }); + + it("different id produces different hash", () => { + const h1 = hashL2(makeL2CodeBlock({ id: "l2-a" })); + const h2 = hashL2(makeL2CodeBlock({ id: "l2-b" })); + expect(h1).not.toBe(h2); }); }); diff --git a/packages/core/hash.ts b/packages/core/hash.ts index 74e487c..7489a02 100644 --- a/packages/core/hash.ts +++ b/packages/core/hash.ts @@ -13,12 +13,21 @@ import type { L5Blueprint } from "./l5.js"; */ export function computeHash(obj: Record): string { const cleaned = stripHashFields(obj); - const record = cleaned as Record; - const keys: string[] = Object.keys(record).toSorted(); - const json = JSON.stringify(record, keys); + const json = sortedStringify(cleaned); return createHash("sha256").update(json).digest("hex").slice(0, 16); } +/** 递归排序所有嵌套对象的 key,保证序列化稳定且不丢失嵌套属性 */ +function sortedStringify(value: unknown): string { + if (value === null || value === undefined) return JSON.stringify(value); + if (typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return "[" + value.map((v) => sortedStringify(v)).join(",") + "]"; + const record = value as Record; + const keys = Object.keys(record).toSorted(); + const entries = keys.map((k) => JSON.stringify(k) + ":" + sortedStringify(record[k])); + return "{" + entries.join(",") + "}"; +} + function stripHashFields(obj: unknown): unknown { if (obj === null || typeof obj !== "object") return obj; if (Array.isArray(obj)) return obj.map((item: unknown) => stripHashFields(item)); diff --git a/packages/core/i18n.test.ts b/packages/core/i18n.test.ts new file mode 100644 index 0000000..6ec4834 --- /dev/null +++ b/packages/core/i18n.test.ts @@ -0,0 +1,110 @@ +// i18n module unit tests + +import { describe, it, expect } from "vitest"; +import { t, getLanguage, languageName, languageDirective } from "./i18n.js"; +import type { L5Blueprint } from "./l5.js"; + +const baseRevision = { + rev: 1, + parentRev: null as number | null, + source: { type: "init" as const }, + timestamp: "2024-01-01T00:00:00Z", +}; + +function makeL5(language?: string): L5Blueprint { + return { + id: "test", + name: "Test", + version: "0.1.0", + intent: "Test intent", + constraints: [], + domains: [], + integrations: [], + language, + contentHash: "abc", + revision: baseRevision, + }; +} + +describe("t()", () => { + it("returns English text for a known key", () => { + const result = t("en", "check.missingLanguage"); + expect(result).toContain("language"); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }); + + it("returns English fallback for unknown language", () => { + const enResult = t("en", "check.missingLanguage"); + const unknownResult = t("xx-unknown", "check.missingLanguage"); + expect(unknownResult).toBe(enResult); + }); + + it("returns the key itself for an unknown key", () => { + const result = t("en", "nonexistent.key.that.does.not.exist"); + expect(result).toBe("nonexistent.key.that.does.not.exist"); + }); + + it("performs parameter interpolation with {param} syntax", () => { + const result = t("en", "check.hashMismatch.l5", { + stored: "abc123", + computed: "def456", + }); + expect(result).toContain("abc123"); + expect(result).toContain("def456"); + expect(result).not.toContain("{stored}"); + expect(result).not.toContain("{computed}"); + }); + + it("returns Chinese text for zh locale", () => { + const enResult = t("en", "check.missingLanguage"); + const zhResult = t("zh", "check.missingLanguage"); + // zh translation should differ from English + expect(zhResult).not.toBe(enResult); + expect(typeof zhResult).toBe("string"); + expect(zhResult.length).toBeGreaterThan(0); + }); +}); + +describe("getLanguage()", () => { + it("returns 'en' when no L5 provided", () => { + expect(getLanguage(undefined)).toBe("en"); + }); + + it("returns L5.language when present", () => { + const l5 = makeL5("zh"); + expect(getLanguage(l5)).toBe("zh"); + }); + + it("returns 'en' when L5.language is undefined", () => { + const l5 = makeL5(undefined); + expect(getLanguage(l5)).toBe("en"); + }); +}); + +describe("languageName()", () => { + it("returns 'English' for 'en'", () => { + expect(languageName("en")).toBe("English"); + }); + + it("returns Chinese name for 'zh'", () => { + const name = languageName("zh"); + expect(name).toContain("Chinese"); + }); + + it("returns the code itself for an unknown language code", () => { + expect(languageName("xx-unknown")).toBe("xx-unknown"); + }); +}); + +describe("languageDirective()", () => { + it("returns empty string for 'en'", () => { + expect(languageDirective("en")).toBe(""); + }); + + it("returns non-empty string for 'zh'", () => { + const directive = languageDirective("zh"); + expect(directive.length).toBeGreaterThan(0); + expect(directive).toContain("Chinese"); + }); +}); diff --git a/packages/core/i18n.ts b/packages/core/i18n.ts new file mode 100644 index 0000000..825d810 --- /dev/null +++ b/packages/core/i18n.ts @@ -0,0 +1,354 @@ +// i18n — 轻量消息目录 +// 支持项目级语言偏好,英文兜底,{param} 插值 + +import type { L5Blueprint } from "./l5.js"; + +type MessageParams = Record; + +// ── 公共 API ── + +/** 查找翻译,英文兜底,支持 {param} 插值 */ +export function t(lang: string, key: string, params?: MessageParams): string { + const catalog = messages[lang] ?? messages.en; + let template = catalog[key] ?? messages.en[key] ?? key; + if (params !== undefined) { + for (const [k, v] of Object.entries(params)) { + template = template.replaceAll(`{${k}}`, String(v)); + } + } + return template; +} + +/** 检测系统语言(取 LANG/LC_ALL 前两位,如 "zh_CN.UTF-8" → "zh") */ +export function detectSystemLanguage(): string { + const raw = (typeof process !== "undefined" && (process.env.LC_ALL || process.env.LANG)) || ""; + const match = raw.match(/^([a-z]{2})/i); + return match ? match[1].toLowerCase() : "en"; +} + +/** 从 L5 提取语言,缺省检测系统语言 */ +export function getLanguage(l5?: Pick | undefined): string { + return l5?.language ?? detectSystemLanguage(); +} + +/** 语言代码 → 人类可读名称 */ +export function languageName(code: string): string { + const names: Record = { + en: "English", + zh: "Chinese/中文", + ja: "Japanese/日本語", + ko: "Korean/한국어", + es: "Spanish/Español", + fr: "French/Français", + de: "German/Deutsch", + pt: "Portuguese/Português", + ru: "Russian/Русский", + }; + return names[code] ?? code; +} + +/** AI 提示词中注入的语言输出指令 */ +export function languageDirective(lang: string): string { + if (lang === "en") return ""; + return `\nIMPORTANT: All human-readable text you generate MUST be written in ${languageName(lang)}. Technical terms, code, JSON keys, and CLI commands should remain in English.\n`; +} + +// ── 消息目录 ── + +const messages: Record> = { + en: { + // ── check.* ── + "check.hashMismatch.l5": + "L5 contentHash mismatch: stored={stored}, computed={computed}", + "check.hashMismatch.l4": + 'L4 "{name}" contentHash mismatch: stored={stored}, computed={computed}', + "check.hashMismatch.l3": + 'L3 "{name}" contentHash mismatch: stored={stored}, computed={computed}', + "check.hashMismatch.l2": + 'L2 "{id}" contentHash mismatch: stored={stored}, computed={computed}', + "check.missingBlockRef.l4FlowStep": + 'L4 "{flowName}" step "{stepId}" references non-existent L3 block "{blockRef}"', + "check.missingFlowRef": + 'L4 "{flowName}" step "{stepId}" references non-existent L4 flow "{flowRef}"', + "check.missingBlockRef.l4EventGraphStep": + 'L4 "{egName}" handler "{handlerId}" step "{stepId}" references non-existent L3 block "{blockRef}"', + "check.missingBlockRef.l4SmOnEntry": + 'L4 "{smName}" state "{stateName}" onEntry references non-existent L3 block "{blockRef}"', + "check.missingBlockRef.l4SmOnExit": + 'L4 "{smName}" state "{stateName}" onExit references non-existent L3 block "{blockRef}"', + "check.missingBlockRef.l4SmGuard": + 'L4 "{smName}" transition "{from}" → "{to}" guard references non-existent L3 block "{guard}"', + "check.missingBlockRef.l2": + 'L2 "{id}" references non-existent L3 block "{blockRef}"', + "check.missingStepRef.next": + 'L4 "{parentName}" step "{stepId}" next references non-existent step "{next}"', + "check.missingStepRef.branch": + 'L4 "{parentName}" step "{stepId}" branch references non-existent step "{branchId}"', + "check.missingStepRef.wait": + 'L4 "{parentName}" step "{stepId}" waitFor references non-existent step "{waitId}"', + "check.invalidDataFlowFormat": + 'L4 "{flowName}" dataFlow {direction} "{endpoint}" is not in "stepId.pinName" format', + "check.missingStepRef.dataFlow": + 'L4 "{flowName}" dataFlow {direction} references non-existent step "{stepId}"', + "check.missingPin": + 'L4 "{flowName}" dataFlow {direction} "{endpoint}": pin "{pinName}" not found on L3 block "{blockRef}"', + "check.invalidDataFlowFormat.eventGraph": + 'L4 "{egName}" handler "{handlerId}" dataFlow {direction} "{endpoint}" is not in valid format', + "check.missingStateRef": + 'L4 "{egName}" handler "{handlerId}" dataFlow {direction} references undeclared state key "{field}"', + "check.sourceDrift": + 'L2 "{id}" sourceHash ({sourceHash}) does not match L3 "{blockRef}" contentHash ({l3Hash}): L3 has changed since last compilation', + "check.contentDrift": + 'L2 "{id}" signatureHash mismatch: L1 exported signatures have changed since last sync', + "check.selfReferencingFlow": + 'L4 "{flowName}" step "{stepId}" calls itself (recursive flow reference)', + "check.duplicateEvent": + 'L4 "{egName}" has duplicate event handler for "{event}"', + "check.emptyState": + 'L4 "{egName}" event-graph has no state declarations', + "check.invalidInitialState": + 'L4 "{smName}" initialState "{initialState}" not found in states', + "check.invalidTransition.from": + 'L4 "{smName}" transition from "{from}" references non-existent state', + "check.invalidTransition.to": + 'L4 "{smName}" transition to "{to}" references non-existent state', + "check.unreachableState": + 'L4 "{smName}" state "{stateName}" is not reachable from initialState "{initialState}"', + "check.nextCycle": + 'L4 "{entityName}" has a cycle in next chain involving step "{current}"', + "check.orphanStep": + 'L4 "{entityName}" step "{stepId}" is not reachable from the first step', + "check.missingLanguage": + "L5 blueprint has no language field — consider adding language preference", + + // ── compilePlan.* ── + "compilePlan.reason.missingL2": + 'L3 block "{name}" has no corresponding L2 code block — needs initial compilation', + "compilePlan.reason.sourceDrift": + "L3 contract changed since last compilation — L2 code is stale", + "compilePlan.reason.contentDrift": + "L1 exported signatures changed — review whether L3 contract still matches the code", + "compilePlan.reason.missingBlockRef": + "Flow references missing L3 block — step needs updating or L3 needs recreating", + "compilePlan.reason.missingL2BlockRef": + "L2 code block references missing L3 block — orphaned code needs review", + "compilePlan.label.l3Contract": 'L3 contract "{name}"', + "compilePlan.label.currentL2": "current L2 mapping ({files})", + "compilePlan.label.l2CodeBlock": "L2 code block ({files})", + "compilePlan.label.l3Verify": 'L3 contract "{name}" — verify still satisfied', + "compilePlan.label.l4Flow": 'L4 flow "{name}"', + "compilePlan.label.l5Blueprint": "L5 blueprint", + "compilePlan.label.orphanedL2": "orphaned L2 code block", + "compilePlan.label.l4FlowRef": 'L4 flow "{name}" (references this block)', + + // ── view.* ── + "view.l5.intent": "intent", + "view.l5.constraints": "constraints", + "view.l5.domains": "domains ({count})", + "view.l5.integrations": "integrations ({count})", + "view.l4.title": "L4 Logic Chains ({count} artifacts)", + "view.l4.flow.kind": "kind: flow", + "view.l4.flow.trigger": "trigger", + "view.l4.flow.steps": "steps ({count})", + "view.l4.flow.dataFlows": "dataFlows", + "view.l4.eventGraph.kind": "kind: event-graph", + "view.l4.eventGraph.state": "state ({count} keys)", + "view.l4.eventGraph.handlers": "handlers ({count})", + "view.l4.stateMachine.kind": "kind: state-machine", + "view.l4.stateMachine.entity": "entity", + "view.l4.stateMachine.initialState": "initialState", + "view.l4.stateMachine.states": "states ({count})", + "view.l4.stateMachine.transitions": "transitions ({count})", + "view.l3.title": "L3 Logic Blocks ({count} blocks)", + "view.l3.pins": "pins", + "view.l3.validate": "validate", + "view.l3.constraints": "constraints", + "view.l3.description": "description", + "view.l2.title": "L2 Code Blocks ({count} blocks)", + "view.l2.language": "language", + "view.l2.blockRef": "blockRef", + "view.l2.status": "status", + "view.l2.files": "files", + "view.common.notFound": "[not found]", + "view.common.synced": "synced ✓", + "view.common.drift": "drift ⚠", + "view.common.syncedShort": "synced", + "view.common.driftShort": "drift", + "view.common.l3Blocks": "L3 blocks ({count})", + "view.common.l5Ref": "L5", + + // ── cli.* ── + "cli.init.alreadyExists": + ".svp/ directory already exists. Use `svp check` to validate or `svp view` to inspect.", + "cli.init.initialized": "Initialized .svp/ in {root}", + "cli.init.dirStructure": "Directory structure:", + "cli.init.nextEdit": + "Next: edit .svp/l5.json to add domains, constraints, and integrations.", + "cli.init.nextSvp": "Next: use /svp to start the interactive SVP workflow.", + "cli.init.slashCommands": + "{host}: {count} skill files → {skillDir}/", + "cli.init.claudeMdSection": "{host}: SVP section → {contextFile}", + "cli.init.claudeMdSkipped": + "{host}: {contextFile} already contains SVP section (skipped)", + "cli.error.loadFailed": + 'Error: cannot load .svp/ data from "{root}". Run `svp init` first.', + "cli.error.l3NotFound": 'Error: L3 block "{id}" not found in .svp/l3/', + "cli.error.l4NotFound": 'Error: L4 flow "{id}" not found in .svp/l4/', + "cli.error.l5NotFound": + "Error: L5 blueprint not found. Design L5 first with `svp prompt design-l5`.", + "cli.error.invalidKind": + 'Error: invalid --kind "{kind}". Must be flow, event-graph, or state-machine.', + "cli.error.stepOutOfRange": + "Error: step index {step} is out of range (0-{max})", + "cli.error.blockNotFound": + 'Error: block "{blockId}" not found in L4 artifact "{flow}" (kind: {kind})', + }, + + zh: { + // ── check.* ── + "check.hashMismatch.l5": + "L5 contentHash 不匹配:存储值={stored},计算值={computed}", + "check.hashMismatch.l4": + 'L4 "{name}" contentHash 不匹配:存储值={stored},计算值={computed}', + "check.hashMismatch.l3": + 'L3 "{name}" contentHash 不匹配:存储值={stored},计算值={computed}', + "check.hashMismatch.l2": + 'L2 "{id}" contentHash 不匹配:存储值={stored},计算值={computed}', + "check.missingBlockRef.l4FlowStep": + 'L4 "{flowName}" 步骤 "{stepId}" 引用了不存在的 L3 block "{blockRef}"', + "check.missingFlowRef": + 'L4 "{flowName}" 步骤 "{stepId}" 引用了不存在的 L4 flow "{flowRef}"', + "check.missingBlockRef.l4EventGraphStep": + 'L4 "{egName}" 处理器 "{handlerId}" 步骤 "{stepId}" 引用了不存在的 L3 block "{blockRef}"', + "check.missingBlockRef.l4SmOnEntry": + 'L4 "{smName}" 状态 "{stateName}" 的 onEntry 引用了不存在的 L3 block "{blockRef}"', + "check.missingBlockRef.l4SmOnExit": + 'L4 "{smName}" 状态 "{stateName}" 的 onExit 引用了不存在的 L3 block "{blockRef}"', + "check.missingBlockRef.l4SmGuard": + 'L4 "{smName}" 转换 "{from}" → "{to}" 的 guard 引用了不存在的 L3 block "{guard}"', + "check.missingBlockRef.l2": + 'L2 "{id}" 引用了不存在的 L3 block "{blockRef}"', + "check.missingStepRef.next": + 'L4 "{parentName}" 步骤 "{stepId}" 的 next 引用了不存在的步骤 "{next}"', + "check.missingStepRef.branch": + 'L4 "{parentName}" 步骤 "{stepId}" 的 branch 引用了不存在的步骤 "{branchId}"', + "check.missingStepRef.wait": + 'L4 "{parentName}" 步骤 "{stepId}" 的 waitFor 引用了不存在的步骤 "{waitId}"', + "check.invalidDataFlowFormat": + 'L4 "{flowName}" dataFlow {direction} "{endpoint}" 格式无效,应为 "stepId.pinName"', + "check.missingStepRef.dataFlow": + 'L4 "{flowName}" dataFlow {direction} 引用了不存在的步骤 "{stepId}"', + "check.missingPin": + 'L4 "{flowName}" dataFlow {direction} "{endpoint}":在 L3 block "{blockRef}" 上未找到 pin "{pinName}"', + "check.invalidDataFlowFormat.eventGraph": + 'L4 "{egName}" 处理器 "{handlerId}" dataFlow {direction} "{endpoint}" 格式无效', + "check.missingStateRef": + 'L4 "{egName}" 处理器 "{handlerId}" dataFlow {direction} 引用了未声明的 state key "{field}"', + "check.sourceDrift": + 'L2 "{id}" 的 sourceHash ({sourceHash}) 与 L3 "{blockRef}" 的 contentHash ({l3Hash}) 不匹配:L3 自上次编译以来已变更', + "check.contentDrift": + 'L2 "{id}" signatureHash 不匹配:L1 导出签名自上次同步以来已变更', + "check.selfReferencingFlow": + 'L4 "{flowName}" 步骤 "{stepId}" 调用了自身(递归 flow 引用)', + "check.duplicateEvent": + 'L4 "{egName}" 存在重复的事件处理器 "{event}"', + "check.emptyState": + 'L4 "{egName}" event-graph 没有 state 声明', + "check.invalidInitialState": + 'L4 "{smName}" 的 initialState "{initialState}" 不在 states 中', + "check.invalidTransition.from": + 'L4 "{smName}" 转换的 from "{from}" 引用了不存在的状态', + "check.invalidTransition.to": + 'L4 "{smName}" 转换的 to "{to}" 引用了不存在的状态', + "check.unreachableState": + 'L4 "{smName}" 状态 "{stateName}" 从 initialState "{initialState}" 不可达', + "check.nextCycle": + 'L4 "{entityName}" 在 next 链中存在环,涉及步骤 "{current}"', + "check.orphanStep": + 'L4 "{entityName}" 步骤 "{stepId}" 从第一个步骤不可达', + "check.missingLanguage": + "L5 blueprint 未设置 language 字段 — 建议添加语言偏好", + + // ── compilePlan.* ── + "compilePlan.reason.missingL2": + 'L3 block "{name}" 没有对应的 L2 code block — 需要初始编译', + "compilePlan.reason.sourceDrift": + "L3 契约自上次编译以来已变更 — L2 代码已过时", + "compilePlan.reason.contentDrift": + "L1 导出签名已变更 — 请审查 L3 契约是否仍与代码匹配", + "compilePlan.reason.missingBlockRef": + "Flow 引用了缺失的 L3 block — 需要更新步骤或重建 L3", + "compilePlan.reason.missingL2BlockRef": + "L2 code block 引用了缺失的 L3 block — 孤立代码需要审查", + "compilePlan.label.l3Contract": 'L3 契约 "{name}"', + "compilePlan.label.currentL2": "当前 L2 映射 ({files})", + "compilePlan.label.l2CodeBlock": "L2 代码块 ({files})", + "compilePlan.label.l3Verify": 'L3 契约 "{name}" — 验证是否仍满足', + "compilePlan.label.l4Flow": 'L4 flow "{name}"', + "compilePlan.label.l5Blueprint": "L5 蓝图", + "compilePlan.label.orphanedL2": "孤立的 L2 代码块", + "compilePlan.label.l4FlowRef": 'L4 flow "{name}"(引用此 block)', + + // ── view.* ── + "view.l5.intent": "意图", + "view.l5.constraints": "约束", + "view.l5.domains": "领域 ({count})", + "view.l5.integrations": "集成 ({count})", + "view.l4.title": "L4 逻辑链 ({count} 个制品)", + "view.l4.flow.kind": "类型: flow", + "view.l4.flow.trigger": "触发器", + "view.l4.flow.steps": "步骤 ({count})", + "view.l4.flow.dataFlows": "数据流", + "view.l4.eventGraph.kind": "类型: event-graph", + "view.l4.eventGraph.state": "状态 ({count} 个 key)", + "view.l4.eventGraph.handlers": "处理器 ({count})", + "view.l4.stateMachine.kind": "类型: state-machine", + "view.l4.stateMachine.entity": "实体", + "view.l4.stateMachine.initialState": "初始状态", + "view.l4.stateMachine.states": "状态 ({count})", + "view.l4.stateMachine.transitions": "转换 ({count})", + "view.l3.title": "L3 逻辑块 ({count} 个 block)", + "view.l3.pins": "接口", + "view.l3.validate": "校验规则", + "view.l3.constraints": "约束", + "view.l3.description": "描述", + "view.l2.title": "L2 代码块 ({count} 个 block)", + "view.l2.language": "语言", + "view.l2.blockRef": "block 引用", + "view.l2.status": "状态", + "view.l2.files": "文件", + "view.common.notFound": "[未找到]", + "view.common.synced": "已同步 ✓", + "view.common.drift": "已漂移 ⚠", + "view.common.syncedShort": "已同步", + "view.common.driftShort": "已漂移", + "view.common.l3Blocks": "L3 blocks ({count})", + "view.common.l5Ref": "L5", + + // ── cli.* ── + "cli.init.alreadyExists": + ".svp/ 目录已存在。使用 `svp check` 校验或 `svp view` 查看。", + "cli.init.initialized": "已在 {root} 初始化 .svp/", + "cli.init.dirStructure": "目录结构:", + "cli.init.nextEdit": + "下一步:编辑 .svp/l5.json 添加领域、约束和集成。", + "cli.init.nextSvp": "下一步:使用 /svp 启动交互式 SVP 工作流。", + "cli.init.slashCommands": + "{host}: {count} 个 skill 文件 → {skillDir}/", + "cli.init.claudeMdSection": "{host}: SVP 部分 → {contextFile}", + "cli.init.claudeMdSkipped": + "{host}: {contextFile} 已包含 SVP 部分(已跳过)", + "cli.error.loadFailed": + '错误:无法从 "{root}" 加载 .svp/ 数据。请先运行 `svp init`。', + "cli.error.l3NotFound": '错误:在 .svp/l3/ 中未找到 L3 block "{id}"', + "cli.error.l4NotFound": '错误:在 .svp/l4/ 中未找到 L4 flow "{id}"', + "cli.error.l5NotFound": + "错误:未找到 L5 blueprint。请先使用 `svp prompt design-l5` 设计 L5。", + "cli.error.invalidKind": + '错误:无效的 --kind "{kind}"。必须为 flow、event-graph 或 state-machine。', + "cli.error.stepOutOfRange": + "错误:步骤索引 {step} 超出范围 (0-{max})", + "cli.error.blockNotFound": + '错误:在 L4 制品 "{flow}" (类型: {kind}) 中未找到 block "{blockId}"', + }, +}; diff --git a/packages/core/index.ts b/packages/core/index.ts index 575060f..3570d4c 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -12,7 +12,8 @@ export type { StateConfig, Transition, } from "./l4.js"; -export { getL4Kind, extractBlockRefs } from "./l4.js"; +export type { BlockContext } from "./l4.js"; +export { getL4Kind, extractBlockRefs, findBlockContext } from "./l4.js"; export type { L5Blueprint, Domain, Integration } from "./l5.js"; export type { L2CodeBlock } from "./l2.js"; export type { ArtifactVersion, VersionSource } from "./version.js"; @@ -38,10 +39,13 @@ export { readL2, writeL2, listL2, + readNodeDocs, + readGraphDocs, } from "./store.js"; export type { CheckIssue, CheckReport, CheckInput, IssueSeverity } from "./check.js"; export { check } from "./check.js"; -export type { CompileTask, CompilePlan, ContextRef, TaskAction } from "./compile-plan.js"; +export type { CompileTask, CompilePlan, ContextRef, TaskAction, Complexity } from "./compile-plan.js"; +export { getDefaultComplexity } from "./compile-plan.js"; export type { ExportedSymbol, FileFingerprint, @@ -63,25 +67,15 @@ export { viewL2Detail, } from "./view.js"; export type { - Skill, SkillInput, SkillResult, SkillResultWithFiles, SkillConfig, SkillStatus, - SkillRegistry, ResolvedContext, FileContent, Artifact, FileArtifact, } from "./skill.js"; -export { DEFAULT_SKILL_CONFIG, REVIEW_SKILL_CONFIG, createSkillRegistry } from "./skill.js"; -export type { - TaskExecution, - IterationResult, - OrchestratorResult, - OrchestratorConfig, - ContextResolver, -} from "./orchestrator.js"; -export { runOrchestrator, DEFAULT_ORCHESTRATOR_CONFIG } from "./orchestrator.js"; -export type { CodeCLIAdapter } from "./adapter.js"; +export { DEFAULT_SKILL_CONFIG, REVIEW_SKILL_CONFIG } from "./skill.js"; +export { t, getLanguage, detectSystemLanguage, languageName, languageDirective } from "./i18n.js"; diff --git a/packages/core/init.ts b/packages/core/init.ts index 3ac0cd1..c912ecb 100644 --- a/packages/core/init.ts +++ b/packages/core/init.ts @@ -4,6 +4,7 @@ import { mkdir, stat } from "node:fs/promises"; import path from "node:path"; import { computeHash } from "./hash.js"; +import { detectSystemLanguage } from "./i18n.js"; import { writeL5 } from "./store.js"; import type { L5Blueprint } from "./l5.js"; @@ -14,7 +15,8 @@ export interface InitOptions { readonly name: string; readonly version?: string; readonly intent?: string; - readonly host?: "claude-code"; + readonly host?: string; + readonly language?: string; } export interface InitResult { @@ -56,6 +58,7 @@ export async function init(root: string, options: InitOptions): Promise); const l5: L5Blueprint = { diff --git a/packages/core/l4.ts b/packages/core/l4.ts index 1c487cc..35346fe 100644 --- a/packages/core/l4.ts +++ b/packages/core/l4.ts @@ -152,6 +152,95 @@ export function extractBlockRefs(l4: L4Artifact): string[] { return [...refs]; } +// ── Block Context(定位 block 在任意 L4 变体中的位置)── + +export interface BlockContext { + readonly kind: "flow" | "event-graph" | "state-machine"; + readonly blockId: string; + readonly prevBlockRef?: string; // predecessor blockRef (if sequential) + readonly nextBlockRef?: string; // successor blockRef (if sequential) + readonly location: string; // e.g. "step 1 in flow", "handler on-execute step 0", "state closed onEntry" +} + +/** 在任意 L4 变体中定位 blockId,返回位置上下文(含前后邻居) */ +export function findBlockContext(l4: L4Artifact, blockId: string): BlockContext | undefined { + const kind = getL4Kind(l4); + + switch (kind) { + case "flow": { + const flow = l4 as L4Flow; + for (let i = 0; i < flow.steps.length; i++) { + const step = flow.steps[i]; + if (step.blockRef === blockId) { + const prevStep = i > 0 ? flow.steps[i - 1] : undefined; + const nextStep = i < flow.steps.length - 1 ? flow.steps[i + 1] : undefined; + return { + kind: "flow", + blockId, + prevBlockRef: prevStep?.blockRef, + nextBlockRef: nextStep?.blockRef, + location: `step ${String(i)} in flow "${flow.id}"`, + }; + } + } + break; + } + case "event-graph": { + const eg = l4 as L4EventGraph; + for (const handler of eg.handlers) { + for (let i = 0; i < handler.steps.length; i++) { + const step = handler.steps[i]; + if (step.blockRef === blockId) { + const prevStep = i > 0 ? handler.steps[i - 1] : undefined; + const nextStep = i < handler.steps.length - 1 ? handler.steps[i + 1] : undefined; + return { + kind: "event-graph", + blockId, + prevBlockRef: prevStep?.blockRef, + nextBlockRef: nextStep?.blockRef, + location: `handler "${handler.id}" step ${String(i)} (event: ${handler.event})`, + }; + } + } + } + break; + } + case "state-machine": { + const sm = l4 as L4StateMachine; + // Search states onEntry/onExit + for (const [stateName, config] of Object.entries(sm.states)) { + if (config.onEntry?.blockRef === blockId) { + return { + kind: "state-machine", + blockId, + location: `state "${stateName}" onEntry`, + }; + } + if (config.onExit?.blockRef === blockId) { + return { + kind: "state-machine", + blockId, + location: `state "${stateName}" onExit`, + }; + } + } + // Search transition guards + for (const t of sm.transitions) { + if (t.guard === blockId) { + return { + kind: "state-machine", + blockId, + location: `transition "${t.from}" → "${t.to}" guard (event: ${t.event})`, + }; + } + } + break; + } + } + + return undefined; +} + // 计算属性,不存储: // - dataType: 从引用的 pin 类型算出来 // - 聚合了哪些 L3 blocks: 从 extractBlockRefs 收集 diff --git a/packages/core/l5.ts b/packages/core/l5.ts index d9beddb..1c3bacd 100644 --- a/packages/core/l5.ts +++ b/packages/core/l5.ts @@ -19,6 +19,9 @@ export interface L5Blueprint { // 版本追踪 readonly revision: ArtifactVersion; + // 语言偏好(ISO 639-1,如 "en" / "zh") + readonly language?: string; + // 变更追踪 readonly contentHash: string; } diff --git a/packages/core/orchestrator.ts b/packages/core/orchestrator.ts deleted file mode 100644 index 7f302d1..0000000 --- a/packages/core/orchestrator.ts +++ /dev/null @@ -1,156 +0,0 @@ -// 收敛循环编排器 — 调度 Skill 执行 compile-plan 任务 -// 纯逻辑层:接收解析好的数据 + skill 注册表,输出执行结果 -// IO(读写文件、加载数据)由上层 CLI 负责 - -import { compilePlan } from "./compile-plan.js"; -import { DEFAULT_SKILL_CONFIG, REVIEW_SKILL_CONFIG } from "./skill.js"; -import type { CheckInput } from "./check.js"; -import type { CompileTask } from "./compile-plan.js"; -import type { - SkillConfig, - SkillInput, - SkillResult, - SkillResultWithFiles, - SkillRegistry, - ResolvedContext, -} from "./skill.js"; - -// ── 类型 ── - -/** 单次迭代中一个任务的执行记录 */ -export interface TaskExecution { - readonly task: CompileTask; - readonly result: SkillResult | SkillResultWithFiles; -} - -/** 单次迭代结果 */ -export interface IterationResult { - readonly iteration: number; - readonly tasksDispatched: number; - readonly executions: readonly TaskExecution[]; - readonly errors: readonly string[]; -} - -/** 收敛循环的最终结果 */ -export interface OrchestratorResult { - readonly converged: boolean; - readonly iterations: readonly IterationResult[]; - readonly totalTasksExecuted: number; -} - -/** 上下文解析器 — 由 CLI 层注入,负责把 task 的 context refs 解析成实际数据 */ -export interface ContextResolver { - readonly resolve: (task: CompileTask, input: CheckInput) => Promise; -} - -/** 编排器配置 */ -export interface OrchestratorConfig { - readonly maxIterations: number; // 防止无限循环 - readonly skillConfigs?: Partial>; // 按 action 覆盖 -} - -export const DEFAULT_ORCHESTRATOR_CONFIG: OrchestratorConfig = { - maxIterations: 5, -}; - -// ── 主入口 ── - -/** - * 运行收敛循环: - * repeat { compilePlan → dispatch skills → apply artifacts } - * until plan.tasks is empty or maxIterations reached - * - * @param loadInput 每次迭代重新加载最新数据(因为上一轮可能写了新制品) - * @param registry skill 注册表 - * @param resolver 上下文解析器 - * @param applyResult 把 skill 结果写回磁盘 - * @param config 编排器配置 - */ -export async function runOrchestrator( - loadInput: () => Promise, - registry: SkillRegistry, - resolver: ContextResolver, - applyResult: (execution: TaskExecution) => Promise, - config: OrchestratorConfig = DEFAULT_ORCHESTRATOR_CONFIG, -): Promise { - const iterations: IterationResult[] = []; - let totalTasksExecuted = 0; - - for (let i = 0; i < config.maxIterations; i++) { - const input = await loadInput(); - const plan = compilePlan(input); - - if (plan.tasks.length === 0) { - return { converged: true, iterations, totalTasksExecuted }; - } - - const iterResult = await runIteration( - i + 1, - plan.tasks, - input, - registry, - resolver, - applyResult, - config, - ); - - iterations.push(iterResult); - totalTasksExecuted += iterResult.executions.length; - - // 如果所有任务都 blocked 或报错,提前退出 - const allBlocked = iterResult.executions.every((e) => e.result.status === "blocked"); - if (allBlocked && iterResult.executions.length > 0) { - return { converged: false, iterations, totalTasksExecuted }; - } - } - - return { converged: false, iterations, totalTasksExecuted }; -} - -// ── 单次迭代 ── - -async function runIteration( - iteration: number, - tasks: readonly CompileTask[], - input: CheckInput, - registry: SkillRegistry, - resolver: ContextResolver, - applyResult: (execution: TaskExecution) => Promise, - config: OrchestratorConfig, -): Promise { - const executions: TaskExecution[] = []; - const errors: string[] = []; - - for (const task of tasks) { - const skill = registry.get(task.action); - if (skill === undefined) { - errors.push(`No skill registered for action "${task.action}"`); - continue; - } - - try { - const resolved = await resolver.resolve(task, input); - const skillConfig = config.skillConfigs?.[task.action]; - const skillInput: SkillInput = { - task, - resolved, - config: - skillConfig ?? (task.action === "review" ? REVIEW_SKILL_CONFIG : DEFAULT_SKILL_CONFIG), - }; - - const result = await skill.execute(skillInput); - const execution: TaskExecution = { task, result }; - executions.push(execution); - - // 非 dry-run 且成功时写回 - if (!skillInput.config.dryRun && result.status === "done") { - await applyResult(execution); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - errors.push(`Skill "${task.action}" failed for ${task.targetId}: ${message}`); - } - } - - return { iteration, tasksDispatched: tasks.length, executions, errors }; -} diff --git a/packages/core/result.test.ts b/packages/core/result.test.ts new file mode 100644 index 0000000..6f20727 --- /dev/null +++ b/packages/core/result.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import { err, ok, unwrap } from "./result.js"; +import type { Result } from "./result.js"; + +// ── ok() ── + +describe("ok()", () => { + it("returns Ok with ok: true", () => { + const r = ok(42); + expect(r.ok).toBe(true); + }); + + it("carries the correct value", () => { + const r = ok(42); + expect(r.value).toBe(42); + }); + + it("works with string", () => { + const r = ok("hello"); + expect(r.value).toBe("hello"); + }); + + it("works with object", () => { + const obj = { a: 1, b: "two" }; + const r = ok(obj); + expect(r.value).toEqual(obj); + }); + + it("works with null", () => { + const r = ok(null); + expect(r.value).toBeNull(); + }); + + it("works with void 0", () => { + const r = ok(void 0); + expect(r.value).toBeUndefined(); + }); + + it("works with array", () => { + const arr = [1, 2, 3]; + const r = ok(arr); + expect(r.value).toEqual([1, 2, 3]); + }); +}); + +// ── err() ── + +describe("err()", () => { + it("returns Err with ok: false", () => { + const r = err("oops"); + expect(r.ok).toBe(false); + }); + + it("carries the correct string error", () => { + const r = err("something went wrong"); + expect(r.error).toBe("something went wrong"); + }); + + it("works with Error objects", () => { + const e = new Error("native error"); + const r = err(e); + expect(r.error).toBe(e); + expect(r.error.message).toBe("native error"); + }); + + it("works with complex objects", () => { + const detail = { code: 404, message: "not found" }; + const r = err(detail); + expect(r.error).toEqual(detail); + }); + + it("works with number error", () => { + const r = err(500); + expect(r.error).toBe(500); + }); +}); + +// ── unwrap() ── + +describe("unwrap()", () => { + it("returns the value for Ok results", () => { + const r: Result = ok(99); + expect(unwrap(r)).toBe(99); + }); + + it("returns a string value for Ok", () => { + const r: Result = ok("success"); + expect(unwrap(r)).toBe("success"); + }); + + it("throws for Err results", () => { + const r: Result = err("bad input"); + expect(() => unwrap(r)).toThrow(); + }); + + it("throws with message containing the string error", () => { + const r: Result = err("bad input"); + expect(() => unwrap(r)).toThrow("unwrap failed: bad input"); + }); + + it("throws with stringified error for Error objects", () => { + const e = new Error("native"); + const r: Result = err(e); + expect(() => unwrap(r)).toThrow("unwrap failed: Error: native"); + }); + + it("throws with stringified error for number errors", () => { + const r: Result = err(42); + expect(() => unwrap(r)).toThrow("unwrap failed: 42"); + }); + + it("throws with stringified error for object errors", () => { + const r: Result = err({ code: 500 }); + expect(() => unwrap(r)).toThrow("unwrap failed:"); + }); +}); + +// ── type discrimination ── + +describe("type discrimination via result.ok", () => { + it("Ok result exposes value property", () => { + const r = ok("data"); + expect(r.ok).toBe(true); + expect(r.value).toBe("data"); + }); + + it("Err result exposes error property", () => { + const r = err(404); + expect(r.ok).toBe(false); + expect(r.error).toBe(404); + }); + + it("discriminates correctly when mapped over an array", () => { + const results: Array> = [ok("hello"), err("oops")]; + const processed = results.map((r) => (r.ok ? `value: ${r.value}` : `error: ${r.error}`)); + expect(processed).toEqual(["value: hello", "error: oops"]); + }); +}); diff --git a/packages/core/skill.ts b/packages/core/skill.ts index d67ff1f..4554aad 100644 --- a/packages/core/skill.ts +++ b/packages/core/skill.ts @@ -18,6 +18,7 @@ export interface ResolvedContext { readonly l2?: L2CodeBlock; readonly l4?: L4Artifact; readonly l1Files?: readonly FileContent[]; + readonly docs?: string; } /** L1 源文件内容 */ @@ -73,14 +74,6 @@ export interface SkillResultWithFiles extends SkillResult { readonly files: readonly FileArtifact[]; } -// ── Skill 接口 ── - -/** Skill 定义:一个 action 对应一个 skill */ -export interface Skill { - readonly action: TaskAction; - readonly execute: (input: SkillInput) => Promise; -} - // ── 默认配置 ── export const DEFAULT_SKILL_CONFIG: SkillConfig = { @@ -94,12 +87,3 @@ export const REVIEW_SKILL_CONFIG: SkillConfig = { ...DEFAULT_SKILL_CONFIG, requireHumanApproval: true, }; - -// ── Skill 注册表 ── - -export type SkillRegistry = ReadonlyMap; - -/** 从 Skill 数组构建注册表 */ -export function createSkillRegistry(skills: readonly Skill[]): SkillRegistry { - return new Map(skills.map((s) => [s.action, s])); -} diff --git a/packages/core/store.test.ts b/packages/core/store.test.ts index 268e549..76fd4e7 100644 --- a/packages/core/store.test.ts +++ b/packages/core/store.test.ts @@ -1,12 +1,38 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { listL3, listL4, readL3, readL4, writeL3, writeL4 } from "./store.js"; +import { + listL2, + listL3, + listL4, + readL2, + readL3, + readL4, + readL5, + readNodeDocs, + readGraphDocs, + writeL2, + writeL3, + writeL4, + writeL5, +} from "./store.js"; +import type { L2CodeBlock } from "./l2.js"; import type { L3Block } from "./l3.js"; -import type { L4Flow } from "./l4.js"; +import type { L4EventGraph, L4Flow, L4StateMachine } from "./l4.js"; +import type { L5Blueprint } from "./l5.js"; +import type { ArtifactVersion } from "./version.js"; -const makeL3 = (id: string): L3Block => ({ +// ── Shared helpers ── + +const REV: ArtifactVersion = { + rev: 1, + parentRev: null, + source: { type: "init" }, + timestamp: "2024-01-01T00:00:00.000Z", +}; + +const makeL3 = (id: string, overrides?: Partial): L3Block => ({ id, name: "Test Block", input: [{ name: "req", type: "Request" }], @@ -14,29 +40,87 @@ const makeL3 = (id: string): L3Block => ({ validate: { "req.id": "required" }, constraints: ["output.res is not empty"], description: "test block", - revision: { - rev: 1, - parentRev: null, - source: { type: "init" }, - timestamp: "2024-01-01T00:00:00.000Z", - }, - contentHash: "def", + revision: REV, + contentHash: "abc123", + ...overrides, }); -const makeL4 = (id: string): L4Flow => ({ +const makeL4Flow = (id: string): L4Flow => ({ id, name: "Test Flow", steps: [{ id: "s1", action: "process", blockRef: "test-block", next: null }], dataFlows: [], - revision: { - rev: 1, - parentRev: null, - source: { type: "init" }, - timestamp: "2024-01-01T00:00:00.000Z", + revision: REV, + contentHash: "def456", +}); + +const makeL4EventGraph = (id: string): L4EventGraph => ({ + kind: "event-graph", + id, + name: "Test Event Graph", + state: { + counter: { type: "number", description: "A counter" }, }, - contentHash: "def", + handlers: [ + { + id: "h1", + event: "user.action", + steps: [{ id: "s1", action: "process", blockRef: "some-block", next: null }], + dataFlows: [], + }, + ], + revision: REV, + contentHash: "ghi789", }); +const makeL4StateMachine = (id: string): L4StateMachine => ({ + kind: "state-machine", + id, + name: "Test State Machine", + entity: "Order", + initialState: "pending", + states: { + pending: { onEntry: { blockRef: "init-block" } }, + active: {}, + closed: { onExit: { blockRef: "close-block" } }, + }, + transitions: [ + { from: "pending", to: "active", event: "activate", guard: "check-block" }, + { from: "active", to: "closed", event: "close" }, + ], + revision: REV, + contentHash: "jkl012", +}); + +const makeL5 = (id: string, overrides?: Partial): L5Blueprint => ({ + id, + name: "Test Blueprint", + version: "1.0.0", + intent: "Core intent description", + constraints: ["must be fast", "must be reliable"], + domains: [ + { name: "auth", description: "Authentication domain", dependencies: [] }, + { name: "billing", description: "Billing domain", dependencies: ["auth"] }, + ], + integrations: [{ name: "postgres", type: "database", description: "Primary DB" }], + revision: REV, + contentHash: "mno345", + ...overrides, +}); + +const makeL2 = (id: string, overrides?: Partial): L2CodeBlock => ({ + id, + blockRef: `l3-${id}`, + language: "typescript", + files: ["src/foo.ts", "src/bar.ts"], + sourceHash: "src-hash-abc", + contentHash: "content-hash-xyz", + revision: REV, + ...overrides, +}); + +// ── store L3 ── + describe("store L3", () => { let root: string; @@ -48,15 +132,15 @@ describe("store L3", () => { await rm(root, { recursive: true }); }); - it("write then read", async () => { - const block = makeL3("my-block"); + it("write then read roundtrip", async () => { + const block = makeL3("roundtrip-l3"); await writeL3(root, block); - const loaded = await readL3(root, "my-block"); + const loaded = await readL3(root, "roundtrip-l3"); expect(loaded).toEqual(block); }); it("read non-existent returns null", async () => { - const result = await readL3(root, "nope"); + const result = await readL3(root, "does-not-exist"); expect(result).toBeNull(); }); @@ -67,8 +151,74 @@ describe("store L3", () => { expect(ids).toContain("block-a"); expect(ids).toContain("block-b"); }); + + it("list on empty dir returns empty array", async () => { + const emptyRoot = await mkdtemp(path.join(tmpdir(), "svp-empty-")); + try { + // Create the l3 dir but leave it empty + await mkdir(path.join(emptyRoot, ".svp", "l3"), { recursive: true }); + const ids = await listL3(emptyRoot); + expect(ids).toEqual([]); + } finally { + await rm(emptyRoot, { recursive: true }); + } + }); + + it("list on non-existent .svp/ dir returns empty array", async () => { + const freshRoot = await mkdtemp(path.join(tmpdir(), "svp-nosvp-")); + try { + const ids = await listL3(freshRoot); + expect(ids).toEqual([]); + } finally { + await rm(freshRoot, { recursive: true }); + } + }); + + it("overwrite existing L3 with same ID", async () => { + const original = makeL3("overwrite-l3"); + await writeL3(root, original); + const updated = makeL3("overwrite-l3", { name: "Updated Name", contentHash: "newhash" }); + await writeL3(root, updated); + const loaded = await readL3(root, "overwrite-l3"); + expect(loaded).toEqual(updated); + expect(loaded?.name).toBe("Updated Name"); + }); + + it("read malformed JSON returns null", async () => { + const malformedPath = path.join(root, ".svp", "l3", "malformed-l3.json"); + await mkdir(path.join(root, ".svp", "l3"), { recursive: true }); + await writeFile(malformedPath, "{ this is not valid json !!!}", "utf8"); + const result = await readL3(root, "malformed-l3"); + expect(result).toBeNull(); + }); + + it("multiple L3 blocks — list returns all", async () => { + const multiRoot = await mkdtemp(path.join(tmpdir(), "svp-multi-")); + try { + await writeL3(multiRoot, makeL3("m1")); + await writeL3(multiRoot, makeL3("m2")); + await writeL3(multiRoot, makeL3("m3")); + const ids = await listL3(multiRoot); + expect(ids.toSorted()).toEqual(["m1", "m2", "m3"]); + } finally { + await rm(multiRoot, { recursive: true }); + } + }); + + it("L3 block with unicode characters in name/description", async () => { + const block = makeL3("unicode-l3", { + name: "用户认证块 🔐", + description: "处理用户登录、注销和会话管理 — supports émojis & special chars", + }); + await writeL3(root, block); + const loaded = await readL3(root, "unicode-l3"); + expect(loaded).toEqual(block); + expect(loaded?.name).toBe("用户认证块 🔐"); + }); }); +// ── store L4 ── + describe("store L4", () => { let root: string; @@ -80,16 +230,397 @@ describe("store L4", () => { await rm(root, { recursive: true }); }); - it("write then read", async () => { - const flow = makeL4("my-flow"); + it("write then read roundtrip", async () => { + const flow = makeL4Flow("roundtrip-flow"); await writeL4(root, flow); - const loaded = await readL4(root, "my-flow"); + const loaded = await readL4(root, "roundtrip-flow"); expect(loaded).toEqual(flow); }); it("list returns written IDs", async () => { - await writeL4(root, makeL4("flow-a")); + await writeL4(root, makeL4Flow("flow-a")); + await writeL4(root, makeL4Flow("flow-b")); const ids = await listL4(root); expect(ids).toContain("flow-a"); + expect(ids).toContain("flow-b"); + }); + + it("read non-existent returns null", async () => { + const result = await readL4(root, "no-such-flow"); + expect(result).toBeNull(); + }); + + it("list empty dir returns empty array", async () => { + const emptyRoot = await mkdtemp(path.join(tmpdir(), "svp-l4-empty-")); + try { + await mkdir(path.join(emptyRoot, ".svp", "l4"), { recursive: true }); + const ids = await listL4(emptyRoot); + expect(ids).toEqual([]); + } finally { + await rm(emptyRoot, { recursive: true }); + } + }); + + it("overwrite existing L4", async () => { + const original = makeL4Flow("overwrite-flow"); + await writeL4(root, original); + const updated: L4Flow = { ...original, name: "Updated Flow Name", contentHash: "updated-hash" }; + await writeL4(root, updated); + const loaded = await readL4(root, "overwrite-flow"); + expect(loaded).toEqual(updated); + expect((loaded as L4Flow).name).toBe("Updated Flow Name"); + }); + + it("write L4EventGraph variant", async () => { + const eg = makeL4EventGraph("my-event-graph"); + await writeL4(root, eg); + const loaded = await readL4(root, "my-event-graph"); + expect(loaded).toEqual(eg); + expect((loaded as L4EventGraph).kind).toBe("event-graph"); + expect((loaded as L4EventGraph).handlers).toHaveLength(1); + }); + + it("write L4StateMachine variant", async () => { + const sm = makeL4StateMachine("my-state-machine"); + await writeL4(root, sm); + const loaded = await readL4(root, "my-state-machine"); + expect(loaded).toEqual(sm); + expect((loaded as L4StateMachine).kind).toBe("state-machine"); + expect((loaded as L4StateMachine).entity).toBe("Order"); + expect((loaded as L4StateMachine).transitions).toHaveLength(2); + }); + + it("read malformed JSON returns null", async () => { + const malformedPath = path.join(root, ".svp", "l4", "malformed-flow.json"); + await mkdir(path.join(root, ".svp", "l4"), { recursive: true }); + await writeFile(malformedPath, "not-json-at-all", "utf8"); + const result = await readL4(root, "malformed-flow"); + expect(result).toBeNull(); + }); +}); + +// ── store L5 ── + +describe("store L5", () => { + let root: string; + + beforeAll(async () => { + root = await mkdtemp(path.join(tmpdir(), "svp-test-")); + }); + + afterAll(async () => { + await rm(root, { recursive: true }); + }); + + it("write then read roundtrip", async () => { + const bp = makeL5("my-blueprint"); + await writeL5(root, bp); + const loaded = await readL5(root); + expect(loaded).toEqual(bp); + }); + + it("read non-existent returns null", async () => { + const freshRoot = await mkdtemp(path.join(tmpdir(), "svp-l5-none-")); + try { + const result = await readL5(freshRoot); + expect(result).toBeNull(); + } finally { + await rm(freshRoot, { recursive: true }); + } + }); + + it("overwrite existing L5", async () => { + const original = makeL5("bp-overwrite"); + await writeL5(root, original); + const updated = makeL5("bp-overwrite", { + name: "Updated Blueprint", + version: "2.0.0", + contentHash: "new-hash", + }); + await writeL5(root, updated); + const loaded = await readL5(root); + expect(loaded).toEqual(updated); + expect(loaded?.name).toBe("Updated Blueprint"); + expect(loaded?.version).toBe("2.0.0"); + }); + + it("L5 with empty arrays (constraints, domains, integrations)", async () => { + const emptyRoot = await mkdtemp(path.join(tmpdir(), "svp-l5-empty-")); + try { + const bp = makeL5("empty-arrays-bp", { + constraints: [], + domains: [], + integrations: [], + }); + await writeL5(emptyRoot, bp); + const loaded = await readL5(emptyRoot); + expect(loaded).toEqual(bp); + expect(loaded?.constraints).toEqual([]); + expect(loaded?.domains).toEqual([]); + expect(loaded?.integrations).toEqual([]); + } finally { + await rm(emptyRoot, { recursive: true }); + } + }); + + it("L5 with populated domains and integrations", async () => { + const richRoot = await mkdtemp(path.join(tmpdir(), "svp-l5-rich-")); + try { + const bp = makeL5("rich-bp", { + domains: [ + { name: "auth", description: "Auth domain", dependencies: [] }, + { name: "billing", description: "Billing domain", dependencies: ["auth"] }, + { + name: "notifications", + description: "Notifications", + dependencies: ["auth", "billing"], + }, + ], + integrations: [ + { name: "postgres", type: "database", description: "Main DB" }, + { name: "stripe", type: "api", description: "Payment processor" }, + { name: "kafka", type: "messageQueue", description: "Event bus" }, + { name: "s3", type: "storage", description: "File storage" }, + ], + }); + await writeL5(richRoot, bp); + const loaded = await readL5(richRoot); + expect(loaded).toEqual(bp); + expect(loaded?.domains).toHaveLength(3); + expect(loaded?.integrations).toHaveLength(4); + expect(loaded?.domains[2].dependencies).toEqual(["auth", "billing"]); + } finally { + await rm(richRoot, { recursive: true }); + } + }); + + it("read malformed JSON returns null", async () => { + const malformedRoot = await mkdtemp(path.join(tmpdir(), "svp-l5-bad-")); + try { + await mkdir(path.join(malformedRoot, ".svp"), { recursive: true }); + await writeFile(path.join(malformedRoot, ".svp", "l5.json"), "{bad json", "utf8"); + const result = await readL5(malformedRoot); + expect(result).toBeNull(); + } finally { + await rm(malformedRoot, { recursive: true }); + } + }); +}); + +// ── store L2 ── + +describe("store L2", () => { + let root: string; + + beforeAll(async () => { + root = await mkdtemp(path.join(tmpdir(), "svp-test-")); + }); + + afterAll(async () => { + await rm(root, { recursive: true }); + }); + + it("write then read roundtrip", async () => { + const block = makeL2("roundtrip-l2"); + await writeL2(root, block); + const loaded = await readL2(root, "roundtrip-l2"); + expect(loaded).toEqual(block); + }); + + it("read non-existent returns null", async () => { + const result = await readL2(root, "no-such-l2"); + expect(result).toBeNull(); + }); + + it("list returns written IDs", async () => { + await writeL2(root, makeL2("l2-x")); + await writeL2(root, makeL2("l2-y")); + const ids = await listL2(root); + expect(ids).toContain("l2-x"); + expect(ids).toContain("l2-y"); + }); + + it("list empty returns empty array", async () => { + const emptyRoot = await mkdtemp(path.join(tmpdir(), "svp-l2-empty-")); + try { + await mkdir(path.join(emptyRoot, ".svp", "l2"), { recursive: true }); + const ids = await listL2(emptyRoot); + expect(ids).toEqual([]); + } finally { + await rm(emptyRoot, { recursive: true }); + } + }); + + it("overwrite existing L2", async () => { + const original = makeL2("overwrite-l2"); + await writeL2(root, original); + const updated = makeL2("overwrite-l2", { + language: "python", + contentHash: "updated-content-hash", + }); + await writeL2(root, updated); + const loaded = await readL2(root, "overwrite-l2"); + expect(loaded).toEqual(updated); + expect(loaded?.language).toBe("python"); + }); + + it("L2 with signatureHash undefined", async () => { + const block = makeL2("no-sig-hash"); + // makeL2 does not set signatureHash, so it should be absent + expect(block.signatureHash).toBeUndefined(); + await writeL2(root, block); + const loaded = await readL2(root, "no-sig-hash"); + expect(loaded?.signatureHash).toBeUndefined(); + }); + + it("L2 with signatureHash defined", async () => { + const block = makeL2("with-sig-hash", { signatureHash: "sig-fingerprint-abc" }); + await writeL2(root, block); + const loaded = await readL2(root, "with-sig-hash"); + expect(loaded).toEqual(block); + expect(loaded?.signatureHash).toBe("sig-fingerprint-abc"); + }); + + it("multiple L2 blocks", async () => { + const multiRoot = await mkdtemp(path.join(tmpdir(), "svp-l2-multi-")); + try { + await writeL2(multiRoot, makeL2("cb1")); + await writeL2(multiRoot, makeL2("cb2")); + await writeL2(multiRoot, makeL2("cb3")); + const ids = await listL2(multiRoot); + expect(ids.toSorted()).toEqual(["cb1", "cb2", "cb3"]); + } finally { + await rm(multiRoot, { recursive: true }); + } + }); + + it("read malformed JSON returns null", async () => { + const malformedPath = path.join(root, ".svp", "l2", "malformed-l2.json"); + await mkdir(path.join(root, ".svp", "l2"), { recursive: true }); + await writeFile(malformedPath, "{{{{ definitely not json", "utf8"); + const result = await readL2(root, "malformed-l2"); + expect(result).toBeNull(); + }); +}); + +// ── Cross-layer ── + +describe("cross-layer", () => { + let root: string; + + beforeAll(async () => { + root = await mkdtemp(path.join(tmpdir(), "svp-cross-")); + }); + + afterAll(async () => { + await rm(root, { recursive: true }); + }); + + it("all four layers coexist in same .svp/ directory", async () => { + const l5 = makeL5("coexist-blueprint"); + const l4 = makeL4Flow("coexist-flow"); + const l3 = makeL3("coexist-block"); + const l2 = makeL2("coexist-code"); + + await writeL5(root, l5); + await writeL4(root, l4); + await writeL3(root, l3); + await writeL2(root, l2); + + const loadedL5 = await readL5(root); + const loadedL4 = await readL4(root, "coexist-flow"); + const loadedL3 = await readL3(root, "coexist-block"); + const loadedL2 = await readL2(root, "coexist-code"); + + expect(loadedL5).toEqual(l5); + expect(loadedL4).toEqual(l4); + expect(loadedL3).toEqual(l3); + expect(loadedL2).toEqual(l2); + }); + + it("writing to one layer does not affect other layers", async () => { + const l3a = makeL3("isolation-block", { contentHash: "original-hash" }); + const l4a = makeL4Flow("isolation-flow"); + const l2a = makeL2("isolation-code"); + + await writeL3(root, l3a); + await writeL4(root, l4a); + await writeL2(root, l2a); + + // Overwrite L3 with a new version + const l3b = makeL3("isolation-block", { name: "Changed Name", contentHash: "changed-hash" }); + await writeL3(root, l3b); + + // L4 and L2 should be unaffected + const loadedL4 = await readL4(root, "isolation-flow"); + const loadedL2 = await readL2(root, "isolation-code"); + const loadedL3 = await readL3(root, "isolation-block"); + + expect(loadedL4).toEqual(l4a); + expect(loadedL2).toEqual(l2a); + expect(loadedL3?.name).toBe("Changed Name"); + expect(loadedL3?.contentHash).toBe("changed-hash"); + }); +}); + +// ── Docs ── + +describe("readNodeDocs", () => { + let root: string; + + beforeAll(async () => { + root = await mkdtemp(path.join(tmpdir(), "svp-docs-node-")); + }); + + afterAll(async () => { + await rm(root, { recursive: true }); + }); + + it("reads existing docs.md", async () => { + const docsDir = path.join(root, "nodes", "my-node"); + await mkdir(docsDir, { recursive: true }); + await writeFile(path.join(docsDir, "docs.md"), "## Intent\nTest docs content", "utf8"); + const result = await readNodeDocs(root, "my-node"); + expect(result).toBe("## Intent\nTest docs content"); + }); + + it("returns null when docs.md does not exist", async () => { + const result = await readNodeDocs(root, "nonexistent-node"); + expect(result).toBeNull(); + }); + + it("returns null when node directory does not exist", async () => { + const freshRoot = await mkdtemp(path.join(tmpdir(), "svp-docs-empty-")); + try { + const result = await readNodeDocs(freshRoot, "no-such-node"); + expect(result).toBeNull(); + } finally { + await rm(freshRoot, { recursive: true }); + } + }); +}); + +describe("readGraphDocs", () => { + let root: string; + + beforeAll(async () => { + root = await mkdtemp(path.join(tmpdir(), "svp-docs-graph-")); + }); + + afterAll(async () => { + await rm(root, { recursive: true }); + }); + + it("reads existing graph docs.md", async () => { + const graphsDir = path.join(root, "graphs"); + await mkdir(graphsDir, { recursive: true }); + await writeFile(path.join(graphsDir, "my-flow.docs.md"), "# Flow Docs\nDetails here", "utf8"); + const result = await readGraphDocs(root, "my-flow"); + expect(result).toBe("# Flow Docs\nDetails here"); + }); + + it("returns null when graph docs.md does not exist", async () => { + const result = await readGraphDocs(root, "nonexistent-graph"); + expect(result).toBeNull(); }); }); diff --git a/packages/core/store.ts b/packages/core/store.ts index 4f72902..fbce091 100644 --- a/packages/core/store.ts +++ b/packages/core/store.ts @@ -112,3 +112,23 @@ export async function writeL2(root: string, codeBlock: L2CodeBlock): Promise { return listIds(svpPath(root, "l2")); } + +// ── Docs ── + +/** 读取节点的模块化文档 nodes//docs.md,不存在返回 null */ +export async function readNodeDocs(root: string, nodeId: string): Promise { + try { + return await readFile(path.join(root, "nodes", nodeId, "docs.md"), "utf8"); + } catch { + return null; + } +} + +/** 读取图的模块化文档 graphs/.docs.md,不存在返回 null */ +export async function readGraphDocs(root: string, graphName: string): Promise { + try { + return await readFile(path.join(root, "graphs", `${graphName}.docs.md`), "utf8"); + } catch { + return null; + } +} diff --git a/packages/core/view.test.ts b/packages/core/view.test.ts index 2b8a80a..7ea6ec8 100644 --- a/packages/core/view.test.ts +++ b/packages/core/view.test.ts @@ -395,3 +395,454 @@ describe("viewL2Detail", () => { expect(output).not.toContain("↑ L3:"); }); }); + +// ── L5 edge cases ── + +describe("viewL5Overview — edge cases", () => { + it("L5 with empty constraints, domains, integrations omits those sections", () => { + const bp: L5Blueprint = { + id: "empty-bp", + name: "Empty BP", + version: "1.0.0", + intent: "A minimal blueprint", + constraints: [], + domains: [], + integrations: [], + revision: REV1, + contentHash: "h1", + }; + const output = viewL5Overview(bp); + expect(output).toContain("Empty BP v1.0.0"); + expect(output).toContain("intent: A minimal blueprint"); + expect(output).not.toContain("constraints:"); + expect(output).not.toContain("domains"); + expect(output).not.toContain("integrations"); + }); + + it("L5 with very long intent renders without truncation", () => { + const longIntent = "A".repeat(120); + const bp: L5Blueprint = { + id: "long-intent", + name: "Long Intent", + version: "1.0.0", + intent: longIntent, + constraints: [], + domains: [], + integrations: [], + revision: REV1, + contentHash: "h2", + }; + const output = viewL5Overview(bp); + expect(output).toContain(`intent: ${longIntent}`); + }); + + it("L5 with multiple domains shows dependency arrows", () => { + const bp: L5Blueprint = { + id: "multi-domain", + name: "Multi Domain", + version: "1.0.0", + intent: "test", + constraints: [], + domains: [ + { name: "alpha", description: "first", dependencies: [] }, + { name: "beta", description: "second", dependencies: ["alpha"] }, + { name: "gamma", description: "third", dependencies: ["alpha", "beta"] }, + ], + integrations: [], + revision: REV1, + contentHash: "h3", + }; + const output = viewL5Overview(bp); + expect(output).toContain("domains (3):"); + expect(output).toContain(" alpha"); + expect(output).toContain(" beta → alpha"); + expect(output).toContain(" gamma → alpha, beta"); + }); + + it("L5 with all integration types renders each type tag", () => { + const bp: L5Blueprint = { + id: "all-integrations", + name: "All Integrations", + version: "1.0.0", + intent: "test", + constraints: [], + domains: [], + integrations: [ + { name: "pg", type: "database", description: "db" }, + { name: "ext-api", type: "api", description: "api" }, + { name: "rabbit", type: "messageQueue", description: "mq" }, + { name: "s3", type: "storage", description: "blob" }, + ], + revision: REV1, + contentHash: "h4", + }; + const output = viewL5Overview(bp); + expect(output).toContain("integrations (4):"); + expect(output).toContain("pg [database]"); + expect(output).toContain("ext-api [api]"); + expect(output).toContain("rabbit [messageQueue]"); + expect(output).toContain("s3 [storage]"); + }); + + it("L5 domain with dependencies on other domains shows arrows", () => { + const bp: L5Blueprint = { + id: "dep-domain", + name: "Dep Domain", + version: "1.0.0", + intent: "test", + constraints: [], + domains: [{ name: "shipping", description: "shipping", dependencies: ["order", "payment"] }], + integrations: [], + revision: REV1, + contentHash: "h5", + }; + const output = viewL5Overview(bp); + expect(output).toContain("shipping → order, payment"); + }); +}); + +// ── L4 Overview edge cases ── + +describe("viewL4Overview — edge cases", () => { + it("flow with trigger field is shown", () => { + const output = viewL4Overview([l4Flow]); + expect(output).toContain("create-order [flow] POST /orders"); + }); + + it("flow with no trigger — trigger section omitted from overview line", () => { + const output = viewL4Overview([l4FlowNoTrigger]); + expect(output).toContain("notify-user [flow]"); + expect(output).not.toContain("POST"); + expect(output).not.toContain("trigger:"); + }); + + it("flow with multiple steps shows chain", () => { + const output = viewL4Overview([l4Flow]); + expect(output).toContain("validate-order → calc-total"); + }); + + it("mixed L4 variants overview — flow, event-graph, state-machine together", () => { + const output = viewL4Overview([l4Flow, l4EventGraph, l4StateMachine]); + expect(output).toContain("L4 Logic Chains (3 artifacts)"); + expect(output).toContain("create-order [flow]"); + expect(output).toContain("doc-collab [event-graph]"); + expect(output).toContain("po-lifecycle [state-machine]"); + }); + + it("StateMachine overview shows entity name and state count", () => { + const output = viewL4Overview([l4StateMachine]); + expect(output).toContain("po-lifecycle [state-machine]"); + expect(output).toContain("states: 4"); + expect(output).toContain("initial: draft"); + }); +}); + +// ── L4 Detail edge cases ── + +describe("viewL4Detail — edge cases", () => { + it("flow detail without L5 — no '↑ L5:' line", () => { + const output = viewL4Detail(l4Flow, [l3Block, l3Block2]); + expect(output).not.toContain("↑ L5:"); + }); + + it("flow detail with L5 — shows '↑ L5:' line", () => { + const output = viewL4Detail(l4Flow, [l3Block, l3Block2], l5); + expect(output).toContain("↑ L5: My Project"); + }); + + it("flow with missing L3 block refs — shows [not found]", () => { + const output = viewL4Detail(l4Flow, [], l5); + expect(output).toContain("validate-order: [not found]"); + expect(output).toContain("calc-total: [not found]"); + }); + + it("flow with no dataFlows — dataFlows section omitted", () => { + const output = viewL4Detail(l4FlowNoTrigger, [l3Block]); + expect(output).not.toContain("dataFlows:"); + }); + + it("EventGraph detail shows state fields", () => { + const output = viewL4Detail(l4EventGraph, [l3Block, l3Block2]); + expect(output).toContain("state (2 keys):"); + expect(output).toContain("document: CRDTDocument"); + expect(output).toContain("cursors: CursorMap"); + }); + + it("EventGraph detail shows handler structure", () => { + const output = viewL4Detail(l4EventGraph, [l3Block, l3Block2]); + expect(output).toContain("handlers (1):"); + expect(output).toContain('[on-local-edit] on "user.local_edit"'); + }); + + it("StateMachine detail shows transitions", () => { + const output = viewL4Detail(l4StateMachine, [l3Block, l3Block2]); + expect(output).toContain("transitions (3):"); + expect(output).toContain('draft → pending_approval on "submit"'); + expect(output).toContain("[guard: validate-order]"); + }); +}); + +// ── L3 Overview edge cases ── + +describe("viewL3Overview — edge cases", () => { + it("empty blocks array produces empty body", () => { + const output = viewL3Overview([]); + expect(output).toContain("L3 Logic Blocks (0 blocks)"); + // no block lines beyond the header + const lines = output.split("\n").filter((l) => l.trim().length > 0); + expect(lines.length).toBe(2); // header + separator + }); + + it("block with very long description — truncated at 40 chars in overview", () => { + const longDesc = "B".repeat(80); + const block: L3Block = { + id: "long-desc-block", + name: "Long Desc", + input: [], + output: [], + validate: {}, + constraints: [], + description: longDesc, + revision: REV1, + contentHash: "ldh", + }; + const output = viewL3Overview([block]); + expect(output).toContain("..."); + expect(output).not.toContain(longDesc); + }); + + it("block with empty description — no crash and renders cleanly", () => { + const block: L3Block = { + id: "empty-desc", + name: "Empty Desc", + input: [], + output: [], + validate: {}, + constraints: [], + description: "", + revision: REV1, + contentHash: "edh", + }; + const output = viewL3Overview([block]); + expect(output).toContain("empty-desc"); + expect(output).not.toContain("..."); + }); +}); + +// ── L3 Detail edge cases ── + +describe("viewL3Detail — edge cases", () => { + const minimalBlock: L3Block = { + id: "minimal-block", + name: "Minimal", + input: [], + output: [], + validate: {}, + constraints: [], + description: "A minimal block", + revision: REV1, + contentHash: "minh", + }; + + it("block with no input pins — no 'in:' lines", () => { + const output = viewL3Detail(minimalBlock, [], []); + expect(output).not.toContain("in:"); + }); + + it("block with no output pins — no 'out:' lines", () => { + const output = viewL3Detail(minimalBlock, [], []); + expect(output).not.toContain("out:"); + }); + + it("block with optional pins — shows '?' marker", () => { + const output = viewL3Detail(l3Block2, [], []); + expect(output).toContain("[optional]"); + expect(output).toContain("[required]"); + }); + + it("block with no referencing L4 flows — no '↑ L4:' line", () => { + const output = viewL3Detail(minimalBlock, [l4Flow], []); + expect(output).not.toContain("↑ L4:"); + }); + + it("block with paired L2 in sync — shows 'synced ✓'", () => { + const output = viewL3Detail(l3Block, [], [l2Block]); + expect(output).toContain("↓ L2: validate-order [synced ✓]"); + }); + + it("block with paired L2 in drift — shows 'drift ⚠'", () => { + const output = viewL3Detail(l3Block2, [], [l2BlockDrift]); + expect(output).toContain("↓ L2: calc-total [drift ⚠]"); + }); + + it("block with no paired L2 — no '↓ L2:' line", () => { + const output = viewL3Detail(l3Block, [], []); + expect(output).not.toContain("↓ L2:"); + }); +}); + +// ── L2 Overview edge cases ── + +describe("viewL2Overview — edge cases", () => { + it("L2 with single file shows path directly", () => { + const output = viewL2Overview([l2Block], [l3Block]); + expect(output).toContain("src/validate-order.ts"); + expect(output).not.toContain("1 files"); + }); + + it("L2 with multiple files shows count", () => { + const output = viewL2Overview([l2BlockDrift], [l3Block2]); + expect(output).toContain("2 files"); + }); + + it("L2 with zero files shows '0 files'", () => { + const zeroFiles: L2CodeBlock = { + id: "no-files", + blockRef: "validate-order", + language: "typescript", + files: [], + sourceHash: "l3hash1", + contentHash: "zfh", + revision: REV1, + }; + const output = viewL2Overview([zeroFiles], [l3Block]); + expect(output).toContain("0 files"); + }); + + it("L2 where L3 not found — shows 'drift'", () => { + const output = viewL2Overview([l2Block], []); + expect(output).toContain("(drift)"); + }); +}); + +// ── L2 Detail edge cases ── + +describe("viewL2Detail — edge cases", () => { + it("L2 detail with L3 found — shows signature in '↑ L3:' line", () => { + const output = viewL2Detail(l2Block, [l3Block]); + expect(output).toContain("↑ L3: validate-order"); + expect(output).toContain("(OrderRequest) → ValidationResult"); + }); + + it("L2 detail with L3 not found — shows 'drift' status and no '↑ L3:' line", () => { + const output = viewL2Detail(l2Block, []); + expect(output).toContain("status: drift ⚠"); + expect(output).not.toContain("↑ L3:"); + }); + + it("L2 with empty files array — files section is empty", () => { + const emptyFiles: L2CodeBlock = { + id: "empty-files", + blockRef: "validate-order", + language: "typescript", + files: [], + sourceHash: "l3hash1", + contentHash: "efh", + revision: REV1, + }; + const output = viewL2Detail(emptyFiles, [l3Block]); + expect(output).toContain("files:"); + expect(output).toContain("status: synced ✓"); + }); +}); + +// ── viewL4Detail — non-http triggers ── + +describe("viewL4Detail — non-http triggers", () => { + it("schedule trigger renders as [schedule]", () => { + const flow: L4Flow = { + id: "scheduled-job", + name: "Scheduled Job", + trigger: { type: "schedule", config: { cron: "* * * * *" } }, + steps: [{ id: "s0", action: "process", blockRef: "validate-order", next: null }], + dataFlows: [], + revision: REV1, + contentHash: "sch1", + }; + const output = viewL4Detail(flow, [l3Block]); + expect(output).toContain("[schedule]"); + }); + + it("event trigger renders as [event]", () => { + const flow: L4Flow = { + id: "event-handler", + name: "Event Handler", + trigger: { type: "event", config: { topic: "orders" } }, + steps: [{ id: "s0", action: "process", blockRef: "validate-order", next: null }], + dataFlows: [], + revision: REV1, + contentHash: "evt1", + }; + const output = viewL4Detail(flow, [l3Block]); + expect(output).toContain("[event]"); + }); +}); + +// ── viewL4Detail — call, parallel, wait steps ── + +describe("viewL4Detail — call, parallel, wait steps", () => { + it("call step renders as 'call '", () => { + const flow: L4Flow = { + id: "caller-flow", + name: "Caller Flow", + steps: [{ id: "s0", action: "call", flowRef: "sub-flow", next: null }], + dataFlows: [], + revision: REV1, + contentHash: "call1", + }; + const output = viewL4Detail(flow, []); + expect(output).toContain("call sub-flow"); + }); + + it("parallel step renders as 'parallel [a, b]'", () => { + const flow: L4Flow = { + id: "parallel-flow", + name: "Parallel Flow", + steps: [{ id: "s0", action: "parallel", branches: ["a", "b"] }], + dataFlows: [], + revision: REV1, + contentHash: "par1", + }; + const output = viewL4Detail(flow, []); + expect(output).toContain("parallel [a, b]"); + }); + + it("wait step renders as 'wait [x, y]'", () => { + const flow: L4Flow = { + id: "wait-flow", + name: "Wait Flow", + steps: [{ id: "s0", action: "wait", waitFor: ["x", "y"], next: "final" }], + dataFlows: [], + revision: REV1, + contentHash: "wait1", + }; + const output = viewL4Detail(flow, []); + expect(output).toContain("wait [x, y]"); + }); +}); + +// ── viewL3Summary / viewL3Detail — zero output pins ── + +describe("viewL3Overview / viewL3Detail — zero output pins", () => { + const zeroOutputBlock: L3Block = { + id: "no-output", + name: "No Output", + input: [{ name: "cmd", type: "Command" }], + output: [], + validate: {}, + constraints: [], + description: "A block that produces no output", + revision: REV1, + contentHash: "zo1", + }; + + it("viewL3Overview shows 'void' for output signature when output is empty", () => { + const output = viewL3Overview([zeroOutputBlock]); + expect(output).toContain("void"); + }); + + it("viewL3Detail shows 'void' in signature when output is empty", () => { + const output = viewL3Detail(zeroOutputBlock, [], []); + expect(output).toContain("void"); + }); +}); diff --git a/packages/core/view.ts b/packages/core/view.ts index d56c98f..6c812a5 100644 --- a/packages/core/view.ts +++ b/packages/core/view.ts @@ -3,6 +3,7 @@ // 纯函数,不做 IO,方便测试和复用 import { computeSignature } from "./computed.js"; +import { t } from "./i18n.js"; import { extractBlockRefs, getL4Kind } from "./l4.js"; import type { L2CodeBlock } from "./l2.js"; import type { L3Block } from "./l3.js"; @@ -11,23 +12,24 @@ import type { L5Blueprint } from "./l5.js"; // ── L5 ── -export function viewL5Overview(l5: L5Blueprint): string { +export function viewL5Overview(l5: L5Blueprint, language = "en"): string { + const lang = language; const lines: string[] = [ `${l5.name} v${l5.version}`, "═".repeat(Math.max(l5.name.length + l5.version.length + 2, 20)), "", - `intent: ${l5.intent}`, + `${t(lang, "view.l5.intent")}: ${l5.intent}`, ]; if (l5.constraints.length > 0) { - lines.push("", "constraints:"); + lines.push("", `${t(lang, "view.l5.constraints")}:`); for (const c of l5.constraints) { lines.push(` • ${c}`); } } if (l5.domains.length > 0) { - lines.push("", `domains (${String(l5.domains.length)}):`); + lines.push("", `${t(lang, "view.l5.domains", { count: String(l5.domains.length) })}:`); for (const d of l5.domains) { const deps = d.dependencies.length > 0 ? ` → ${d.dependencies.join(", ")}` : ""; lines.push(` ${d.name}${deps}`); @@ -35,7 +37,7 @@ export function viewL5Overview(l5: L5Blueprint): string { } if (l5.integrations.length > 0) { - lines.push("", `integrations (${String(l5.integrations.length)}):`); + lines.push("", `${t(lang, "view.l5.integrations", { count: String(l5.integrations.length) })}:`); for (const i of l5.integrations) { lines.push(` ${i.name} [${i.type}]`); } @@ -46,23 +48,24 @@ export function viewL5Overview(l5: L5Blueprint): string { // ── L4 ── -export function viewL4Overview(flows: readonly L4Artifact[]): string { - const lines: string[] = [`L4 Logic Chains (${String(flows.length)} artifacts)`, "─".repeat(30)]; +export function viewL4Overview(flows: readonly L4Artifact[], language = "en"): string { + const lang = language; + const lines: string[] = [t(lang, "view.l4.title", { count: String(flows.length) }), "─".repeat(30)]; for (const l4 of flows) { const kind = getL4Kind(l4); switch (kind) { case "flow": { - lines.push(...viewFlowOverviewLines(l4 as L4Flow)); + lines.push(...viewFlowOverviewLines(l4 as L4Flow, lang)); break; } case "event-graph": { - lines.push(...viewEventGraphOverviewLines(l4 as L4EventGraph)); + lines.push(...viewEventGraphOverviewLines(l4 as L4EventGraph, lang)); break; } case "state-machine": { - lines.push(...viewStateMachineOverviewLines(l4 as L4StateMachine)); + lines.push(...viewStateMachineOverviewLines(l4 as L4StateMachine, lang)); break; } } @@ -77,25 +80,27 @@ export function viewL4Detail( l4: L4Artifact, l3Blocks: readonly L3Block[], l5?: L5Blueprint, + language = "en", ): string { + const lang = language; const kind = getL4Kind(l4); switch (kind) { case "flow": { - return viewFlowDetail(l4 as L4Flow, l3Blocks, l5); + return viewFlowDetail(l4 as L4Flow, l3Blocks, l5, lang); } case "event-graph": { - return viewEventGraphDetail(l4 as L4EventGraph, l3Blocks, l5); + return viewEventGraphDetail(l4 as L4EventGraph, l3Blocks, l5, lang); } case "state-machine": { - return viewStateMachineDetail(l4 as L4StateMachine, l3Blocks, l5); + return viewStateMachineDetail(l4 as L4StateMachine, l3Blocks, l5, lang); } } } // ── Flow overview/detail ── -function viewFlowOverviewLines(flow: L4Flow): string[] { +function viewFlowOverviewLines(flow: L4Flow, lang: string): string[] { const lines: string[] = []; const trigger = formatTrigger(flow); lines.push(`${flow.id} [flow]${trigger}`); @@ -112,40 +117,40 @@ function viewFlowOverviewLines(flow: L4Flow): string[] { return lines; } -function viewFlowDetail(flow: L4Flow, l3Blocks: readonly L3Block[], l5?: L5Blueprint): string { - const lines: string[] = [flow.id, "═".repeat(Math.max(flow.id.length, 12)), `kind: flow`]; +function viewFlowDetail(flow: L4Flow, l3Blocks: readonly L3Block[], l5?: L5Blueprint, lang = "en"): string { + const lines: string[] = [flow.id, "═".repeat(Math.max(flow.id.length, 12)), t(lang, "view.l4.flow.kind")]; const trigger = formatTrigger(flow); if (trigger.length > 0) { lines.push(`trigger:${trigger}`); } - lines.push("", `steps (${String(flow.steps.length)}):`); + lines.push("", `${t(lang, "view.l4.flow.steps", { count: String(flow.steps.length) })}:`); for (const step of flow.steps) { lines.push(formatStep(step)); } if (flow.dataFlows.length > 0) { - lines.push("", "dataFlows:"); + lines.push("", `${t(lang, "view.l4.flow.dataFlows")}:`); for (const df of flow.dataFlows) { lines.push(` ${df.from} → ${df.to}`); } } if (l5 !== undefined) { - lines.push("", `↑ L5: ${l5.name}`); + lines.push("", `↑ ${t(lang, "view.common.l5Ref")}: ${l5.name}`); } const blockRefs = flow.steps.filter((s) => s.blockRef !== undefined).map((s) => s.blockRef!); const uniqueRefs = [...new Set(blockRefs)]; if (uniqueRefs.length > 0) { - lines.push("", `↓ L3 blocks (${String(uniqueRefs.length)}):`); + lines.push("", `↓ ${t(lang, "view.common.l3Blocks", { count: String(uniqueRefs.length) })}:`); for (const ref of uniqueRefs) { const block = l3Blocks.find((b) => b.id === ref); if (block === undefined) { - lines.push(` ${ref}: [not found]`); + lines.push(` ${ref}: ${t(lang, "view.common.notFound")}`); } else { const sig = computeSignature(block.id, [...block.input], [...block.output]); lines.push(` ${ref}: ${sig}`); @@ -158,7 +163,7 @@ function viewFlowDetail(flow: L4Flow, l3Blocks: readonly L3Block[], l5?: L5Bluep // ── EventGraph overview/detail ── -function viewEventGraphOverviewLines(eg: L4EventGraph): string[] { +function viewEventGraphOverviewLines(eg: L4EventGraph, lang: string): string[] { const lines: string[] = []; const stateCount = Object.keys(eg.state).length; const handlerCount = eg.handlers.length; @@ -178,20 +183,21 @@ function viewEventGraphDetail( eg: L4EventGraph, l3Blocks: readonly L3Block[], l5?: L5Blueprint, + lang = "en", ): string { - const lines: string[] = [eg.id, "═".repeat(Math.max(eg.id.length, 12)), `kind: event-graph`]; + const lines: string[] = [eg.id, "═".repeat(Math.max(eg.id.length, 12)), t(lang, "view.l4.eventGraph.kind")]; // state declarations const stateEntries = Object.entries(eg.state); if (stateEntries.length > 0) { - lines.push("", `state (${String(stateEntries.length)} keys):`); + lines.push("", `${t(lang, "view.l4.eventGraph.state", { count: String(stateEntries.length) })}:`); for (const [key, field] of stateEntries) { lines.push(` ${key}: ${field.type} — ${field.description}`); } } // handlers - lines.push("", `handlers (${String(eg.handlers.length)}):`); + lines.push("", `${t(lang, "view.l4.eventGraph.handlers", { count: String(eg.handlers.length) })}:`); for (const handler of eg.handlers) { lines.push(` [${handler.id}] on "${handler.event}" (${String(handler.steps.length)} steps)`); for (const step of handler.steps) { @@ -205,7 +211,7 @@ function viewEventGraphDetail( } if (l5 !== undefined) { - lines.push("", `↑ L5: ${l5.name}`); + lines.push("", `↑ ${t(lang, "view.common.l5Ref")}: ${l5.name}`); } // referenced L3 blocks @@ -217,11 +223,11 @@ function viewEventGraphDetail( } if (allBlockRefs.size > 0) { - lines.push("", `↓ L3 blocks (${String(allBlockRefs.size)}):`); + lines.push("", `↓ ${t(lang, "view.common.l3Blocks", { count: String(allBlockRefs.size) })}:`); for (const ref of allBlockRefs) { const block = l3Blocks.find((b) => b.id === ref); if (block === undefined) { - lines.push(` ${ref}: [not found]`); + lines.push(` ${ref}: ${t(lang, "view.common.notFound")}`); } else { const sig = computeSignature(block.id, [...block.input], [...block.output]); lines.push(` ${ref}: ${sig}`); @@ -234,7 +240,7 @@ function viewEventGraphDetail( // ── StateMachine overview/detail ── -function viewStateMachineOverviewLines(sm: L4StateMachine): string[] { +function viewStateMachineOverviewLines(sm: L4StateMachine, lang: string): string[] { const stateCount = Object.keys(sm.states).length; const transCount = sm.transitions.length; return [ @@ -246,18 +252,19 @@ function viewStateMachineDetail( sm: L4StateMachine, l3Blocks: readonly L3Block[], l5?: L5Blueprint, + lang = "en", ): string { const lines: string[] = [ sm.id, "═".repeat(Math.max(sm.id.length, 12)), - `kind: state-machine`, - `entity: ${sm.entity}`, - `initialState: ${sm.initialState}`, + t(lang, "view.l4.stateMachine.kind"), + `${t(lang, "view.l4.stateMachine.entity")}: ${sm.entity}`, + `${t(lang, "view.l4.stateMachine.initialState")}: ${sm.initialState}`, ]; // states const stateEntries = Object.entries(sm.states); - lines.push("", `states (${String(stateEntries.length)}):`); + lines.push("", `${t(lang, "view.l4.stateMachine.states", { count: String(stateEntries.length) })}:`); for (const [name, config] of stateEntries) { const parts: string[] = [name]; if (config.onEntry !== undefined) parts.push(`onEntry → ${config.onEntry.blockRef}`); @@ -266,14 +273,14 @@ function viewStateMachineDetail( } // transitions - lines.push("", `transitions (${String(sm.transitions.length)}):`); - for (const t of sm.transitions) { - const guard = t.guard === undefined ? "" : ` [guard: ${t.guard}]`; - lines.push(` ${t.from} → ${t.to} on "${t.event}"${guard}`); + lines.push("", `${t(lang, "view.l4.stateMachine.transitions", { count: String(sm.transitions.length) })}:`); + for (const tr of sm.transitions) { + const guard = tr.guard === undefined ? "" : ` [guard: ${tr.guard}]`; + lines.push(` ${tr.from} → ${tr.to} on "${tr.event}"${guard}`); } if (l5 !== undefined) { - lines.push("", `↑ L5: ${l5.name}`); + lines.push("", `↑ ${t(lang, "view.common.l5Ref")}: ${l5.name}`); } // referenced L3 blocks @@ -282,16 +289,16 @@ function viewStateMachineDetail( if (config.onEntry?.blockRef !== undefined) allBlockRefs.add(config.onEntry.blockRef); if (config.onExit?.blockRef !== undefined) allBlockRefs.add(config.onExit.blockRef); } - for (const t of sm.transitions) { - if (t.guard !== undefined) allBlockRefs.add(t.guard); + for (const tr of sm.transitions) { + if (tr.guard !== undefined) allBlockRefs.add(tr.guard); } if (allBlockRefs.size > 0) { - lines.push("", `↓ L3 blocks (${String(allBlockRefs.size)}):`); + lines.push("", `↓ ${t(lang, "view.common.l3Blocks", { count: String(allBlockRefs.size) })}:`); for (const ref of allBlockRefs) { const block = l3Blocks.find((b) => b.id === ref); if (block === undefined) { - lines.push(` ${ref}: [not found]`); + lines.push(` ${ref}: ${t(lang, "view.common.notFound")}`); } else { const sig = computeSignature(block.id, [...block.input], [...block.output]); lines.push(` ${ref}: ${sig}`); @@ -304,8 +311,9 @@ function viewStateMachineDetail( // ── L3 ── -export function viewL3Overview(blocks: readonly L3Block[]): string { - const lines: string[] = [`L3 Logic Blocks (${String(blocks.length)} blocks)`, "─".repeat(30)]; +export function viewL3Overview(blocks: readonly L3Block[], language = "en"): string { + const lang = language; + const lines: string[] = [t(lang, "view.l3.title", { count: String(blocks.length) }), "─".repeat(30)]; const maxIdLength = Math.max(...blocks.map((b) => b.id.length), 0); @@ -324,13 +332,15 @@ export function viewL3Detail( block: L3Block, flows: readonly L4Artifact[], l2Blocks: readonly L2CodeBlock[], + language = "en", ): string { + const lang = language; const lines: string[] = [ block.id, "═".repeat(Math.max(block.id.length, 12)), formatShortSignature(block), "", - "pins:", + `${t(lang, "view.l3.pins")}:`, ]; if (block.input.length > 0) { @@ -352,7 +362,7 @@ export function viewL3Detail( const validateEntries = Object.entries(block.validate); if (validateEntries.length > 0) { - lines.push("", "validate:"); + lines.push("", `${t(lang, "view.l3.validate")}:`); const maxKey = Math.max(...validateEntries.map(([k]) => k.length)); for (const [key, rule] of validateEntries) { const padding = " ".repeat(maxKey - key.length); @@ -361,13 +371,13 @@ export function viewL3Detail( } if (block.constraints.length > 0) { - lines.push("", "constraints:"); + lines.push("", `${t(lang, "view.l3.constraints")}:`); for (const c of block.constraints) { lines.push(` • ${c}`); } } - lines.push("", "description:", ` ${block.description}`); + lines.push("", `${t(lang, "view.l3.description")}:`, ` ${block.description}`); // ↑ L4: 哪些 artifact 引用了这个 block const referencingL4s = flows.filter((f) => extractBlockRefs(f).includes(block.id)); @@ -382,7 +392,7 @@ export function viewL3Detail( const l2 = l2Blocks.find((cb) => cb.blockRef === block.id); if (l2 !== undefined) { - const synced = l2.sourceHash === block.contentHash ? "synced ✓" : "drift ⚠"; + const synced = l2.sourceHash === block.contentHash ? t(lang, "view.common.synced") : t(lang, "view.common.drift"); lines.push(`↓ L2: ${l2.id} [${synced}]`); } @@ -394,14 +404,16 @@ export function viewL3Detail( export function viewL2Overview( l2Blocks: readonly L2CodeBlock[], l3Blocks: readonly L3Block[], + language = "en", ): string { - const lines: string[] = [`L2 Code Blocks (${String(l2Blocks.length)} blocks)`, "─".repeat(30)]; + const lang = language; + const lines: string[] = [t(lang, "view.l2.title", { count: String(l2Blocks.length) }), "─".repeat(30)]; const l3Map = new Map(l3Blocks.map((b) => [b.id, b])); for (const cb of l2Blocks) { const l3 = l3Map.get(cb.blockRef); - const synced = cb.sourceHash === l3?.contentHash ? "synced" : "drift"; + const synced = cb.sourceHash === l3?.contentHash ? t(lang, "view.common.syncedShort") : t(lang, "view.common.driftShort"); const files = cb.files.length === 1 ? cb.files[0] : `${String(cb.files.length)} files`; lines.push(`${cb.id} [${cb.language}] ${files} (${synced})`); } @@ -409,19 +421,20 @@ export function viewL2Overview( return lines.join("\n"); } -export function viewL2Detail(cb: L2CodeBlock, l3Blocks: readonly L3Block[]): string { +export function viewL2Detail(cb: L2CodeBlock, l3Blocks: readonly L3Block[], language = "en"): string { + const lang = language; const l3 = l3Blocks.find((b) => b.id === cb.blockRef); - const synced = cb.sourceHash === l3?.contentHash ? "synced ✓" : "drift ⚠"; + const synced = cb.sourceHash === l3?.contentHash ? t(lang, "view.common.synced") : t(lang, "view.common.drift"); const lines: string[] = [ cb.id, "═".repeat(Math.max(cb.id.length, 12)), "", - `language: ${cb.language}`, - `blockRef: ${cb.blockRef}`, - `status: ${synced}`, + `${t(lang, "view.l2.language")}: ${cb.language}`, + `${t(lang, "view.l2.blockRef")}: ${cb.blockRef}`, + `${t(lang, "view.l2.status")}: ${synced}`, "", - "files:", + `${t(lang, "view.l2.files")}:`, ]; for (const file of cb.files) { diff --git a/packages/skills/__tests__/adapters.test.ts b/packages/skills/__tests__/adapters.test.ts new file mode 100644 index 0000000..e989f0c --- /dev/null +++ b/packages/skills/__tests__/adapters.test.ts @@ -0,0 +1,201 @@ +// Adapter unit tests — interface conformance, registry, and auto-detection + +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { getAdapter, getAllAdapterIds } from "../adapters/index.js"; +import { detectHost, detectHosts } from "../adapters/detect.js"; +import type { HostId } from "../adapters/types.js"; + +// ── Registry tests ── + +describe("adapter registry", () => { + it("getAllAdapterIds returns all registered hosts", () => { + const ids = getAllAdapterIds(); + expect(ids).toContain("claude-code"); + expect(ids).toContain("kimi-code"); + expect(ids).toContain("codex"); + expect(ids).toContain("cursor"); + expect(ids).toContain("windsurf"); + expect(ids).toContain("github-copilot"); + expect(ids.length).toBe(6); + }); + + it("getAdapter returns correct adapter for each HostId", () => { + for (const id of getAllAdapterIds()) { + const adapter = getAdapter(id); + expect(adapter.id).toBe(id); + expect(adapter.displayName.length).toBeGreaterThan(0); + } + }); + + it("getAdapter throws for unknown host", () => { + expect(() => getAdapter("unknown" as HostId)).toThrow("Unknown host"); + }); +}); + +// ── Interface conformance tests (all adapters) ── + +describe("adapter interface conformance", () => { + const allIds = getAllAdapterIds(); + + for (const hostId of allIds) { + describe(`${hostId}`, () => { + const adapter = getAdapter(hostId); + + it("produces non-empty skill files (en)", () => { + const files = adapter.generateSkillFiles("en"); + expect(files.length).toBeGreaterThan(0); + for (const f of files) { + expect(f.relativePath.length).toBeGreaterThan(0); + expect(f.content.length).toBeGreaterThan(0); + } + }); + + it("produces non-empty skill files (zh)", () => { + const files = adapter.generateSkillFiles("zh"); + expect(files.length).toBeGreaterThan(0); + for (const f of files) { + expect(f.content.length).toBeGreaterThan(0); + } + }); + + it("skill file paths are relative (no leading /)", () => { + const files = adapter.generateSkillFiles("en"); + for (const f of files) { + expect(f.relativePath).not.toMatch(/^\//); + } + }); + + it("skill file paths have correct extension (.md)", () => { + const files = adapter.generateSkillFiles("en"); + for (const f of files) { + expect(f.relativePath).toMatch(/\.md$/); + } + }); + + it("context section contains ## SVP marker (en)", () => { + const section = adapter.generateContextSection("test-project", "en"); + expect(section).toContain("## SVP"); + }); + + it("context section contains ## SVP marker (zh)", () => { + const section = adapter.generateContextSection("test-project", "zh"); + expect(section).toContain("## SVP"); + }); + + it("contextMarker() matches what generateContextSection produces", () => { + const section = adapter.generateContextSection("test-project", "en"); + expect(section).toContain(adapter.contextMarker()); + }); + + it("skillDir() returns a relative path", () => { + expect(adapter.skillDir()).not.toMatch(/^\//); + }); + + it("contextFilePath() returns a relative path", () => { + expect(adapter.contextFilePath()).not.toMatch(/^\//); + }); + }); + } +}); + +// ── Workflow content consistency tests ── + +describe("adapter workflow content consistency", () => { + const allIds = getAllAdapterIds(); + + /** Extract the workflow section (everything after the last "---" separator) */ + function extractWorkflow(content: string): string { + const parts = content.split("\n---\n"); + return parts[parts.length - 1]!.trim(); + } + + for (const lang of ["en", "zh"] as const) { + it(`all adapters share identical workflow content (${lang})`, () => { + const reference = getAdapter(allIds[0]!).generateSkillFiles(lang)[0]!.content; + const refWorkflow = extractWorkflow(reference); + + for (const id of allIds.slice(1)) { + const content = getAdapter(id).generateSkillFiles(lang)[0]!.content; + const workflow = extractWorkflow(content); + expect(workflow, `${id} (${lang}) workflow differs from ${allIds[0]!}`).toBe(refWorkflow); + } + }); + } +}); + +// ── Auto-detection tests ── + +describe("host auto-detection", () => { + let testRoot: string; + + beforeEach(async () => { + testRoot = path.join( + tmpdir(), + `svp-detect-test-${String(Date.now())}-${String(Math.random()).slice(2, 8)}`, + ); + await mkdir(testRoot, { recursive: true }); + }); + + afterEach(async () => { + await rm(testRoot, { recursive: true, force: true }); + }); + + it("detects claude-code from .claude/ directory", async () => { + await mkdir(path.join(testRoot, ".claude"), { recursive: true }); + expect(await detectHost(testRoot)).toBe("claude-code"); + }); + + it("detects cursor from .cursor/ directory", async () => { + await mkdir(path.join(testRoot, ".cursor"), { recursive: true }); + expect(await detectHost(testRoot)).toBe("cursor"); + }); + + it("detects windsurf from .windsurf/ directory", async () => { + await mkdir(path.join(testRoot, ".windsurf"), { recursive: true }); + expect(await detectHost(testRoot)).toBe("windsurf"); + }); + + it("detects kimi-code from .agents/ directory", async () => { + await mkdir(path.join(testRoot, ".agents"), { recursive: true }); + expect(await detectHost(testRoot)).toBe("kimi-code"); + }); + + it("detects codex from .codex/ directory", async () => { + await mkdir(path.join(testRoot, ".codex"), { recursive: true }); + expect(await detectHost(testRoot)).toBe("codex"); + }); + + it("detects github-copilot from .github/copilot-instructions.md file", async () => { + await mkdir(path.join(testRoot, ".github"), { recursive: true }); + await writeFile(path.join(testRoot, ".github", "copilot-instructions.md"), "", "utf8"); + expect(await detectHost(testRoot)).toBe("github-copilot"); + }); + + it("does NOT detect github-copilot from bare .github/ directory", async () => { + await mkdir(path.join(testRoot, ".github"), { recursive: true }); + expect(await detectHost(testRoot)).toBeNull(); + }); + + it("returns null when no marker directories exist", async () => { + expect(await detectHost(testRoot)).toBeNull(); + }); + + it("detectHosts returns multiple when several markers exist", async () => { + await mkdir(path.join(testRoot, ".claude"), { recursive: true }); + await mkdir(path.join(testRoot, ".cursor"), { recursive: true }); + const hosts = await detectHosts(testRoot); + expect(hosts).toContain("claude-code"); + expect(hosts).toContain("cursor"); + expect(hosts.length).toBe(2); + }); + + it("detectHost returns first match when multiple exist", async () => { + await mkdir(path.join(testRoot, ".claude"), { recursive: true }); + await mkdir(path.join(testRoot, ".cursor"), { recursive: true }); + // .claude is checked first + expect(await detectHost(testRoot)).toBe("claude-code"); + }); +}); diff --git a/packages/skills/__tests__/adapters/claude-code.test.ts b/packages/skills/__tests__/adapters/claude-code.test.ts deleted file mode 100644 index 81dad3a..0000000 --- a/packages/skills/__tests__/adapters/claude-code.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { claudeCodeAdapter } from "../../adapters/claude-code.js"; -import type { CompileTask } from "../../../core/compile-plan.js"; -import type { L3Block } from "../../../core/l3.js"; -import type { L5Blueprint } from "../../../core/l5.js"; -import type { SkillInput, SkillConfig } from "../../../core/skill.js"; - -const baseRevision = { - rev: 1, - parentRev: null as number | null, - source: { type: "init" as const }, - timestamp: "2024-01-01T00:00:00.000Z", -}; - -const defaultConfig: SkillConfig = { - maxFilesToCreate: 10, - maxFilesToModify: 20, - dryRun: false, - requireHumanApproval: false, -}; - -function makeL3(): L3Block { - return { - id: "test-block", - name: "Test Block", - input: [{ name: "request", type: "TestInput" }], - output: [{ name: "result", type: "TestOutput" }], - validate: { request: "required" }, - constraints: ["output must be valid"], - description: "A test block", - contentHash: "l3-hash", - revision: baseRevision, - }; -} - -function makeL5(): L5Blueprint { - return { - id: "test-project", - name: "Test Project", - version: "0.1.0", - intent: "Test intent", - constraints: [], - domains: [], - integrations: [], - contentHash: "l5-hash", - revision: baseRevision, - }; -} - -function makeInput(action: CompileTask["action"]): SkillInput { - const task: CompileTask = { - action, - targetLayer: "l2", - targetId: "test-block", - reason: `${action} test-block`, - issueCode: "TEST", - context: [], - }; - return { - task, - resolved: { l5: makeL5(), l3: makeL3() }, - config: defaultConfig, - }; -} - -describe("claudeCodeAdapter", () => { - it("has correct name", () => { - expect(claudeCodeAdapter.name).toBe("claude-code"); - }); - - it("creates registry with 4 skills", () => { - const registry = claudeCodeAdapter.createSkillRegistry(); - expect(registry.size).toBe(4); - expect(registry.has("compile")).toBe(true); - expect(registry.has("recompile")).toBe(true); - expect(registry.has("review")).toBe(true); - expect(registry.has("update-ref")).toBe(true); - }); - - it.each(["compile", "recompile", "review", "update-ref"] as const)( - "%s skill returns needs-review with prompt in notes", - async (action) => { - const registry = claudeCodeAdapter.createSkillRegistry(); - const skill = registry.get(action); - expect(skill).toBeDefined(); - - const result = await skill!.execute(makeInput(action)); - - expect(result.action).toBe(action); - expect(result.status).toBe("needs-review"); - expect(result.artifacts).toEqual([]); - expect(result.notes).toContain("# "); - expect(result.notes).toContain("## Context"); - expect(result.notes).toContain("## Task"); - expect(result.notes).toContain("## Input"); - expect(result.notes).toContain("## Rules"); - }, - ); - - it("compile skill prompt includes L3 contract details", async () => { - const registry = claudeCodeAdapter.createSkillRegistry(); - const skill = registry.get("compile")!; - const result = await skill.execute(makeInput("compile")); - - expect(result.notes).toContain("test-block"); - expect(result.notes).toContain("TestInput"); - expect(result.notes).toContain("TestOutput"); - }); -}); diff --git a/packages/skills/__tests__/design-prompts.test.ts b/packages/skills/__tests__/design-prompts.test.ts new file mode 100644 index 0000000..6acb6ba --- /dev/null +++ b/packages/skills/__tests__/design-prompts.test.ts @@ -0,0 +1,553 @@ +import { describe, expect, it } from "vitest"; +import { buildDesignL3Prompt } from "../prompts/design-l3.js"; +import { buildDesignL4EventGraphPrompt } from "../prompts/design-l4-event-graph.js"; +import { buildDesignL4StateMachinePrompt } from "../prompts/design-l4-state-machine.js"; +import { buildDesignL4Prompt } from "../prompts/design-l4.js"; +import type { L3Block } from "../../core/l3.js"; +import type { L4EventGraph, L4Flow, L4StateMachine } from "../../core/l4.js"; +import { findBlockContext } from "../../core/l4.js"; +import type { L5Blueprint } from "../../core/l5.js"; +import type { DesignL3Input } from "../prompts/design-l3.js"; +import type { DesignL4EventGraphInput } from "../prompts/design-l4-event-graph.js"; +import type { DesignL4StateMachineInput } from "../prompts/design-l4-state-machine.js"; +import type { DesignL4Input } from "../prompts/design-l4.js"; + +// ── Fixtures ── + +const baseRevision = { + rev: 1, + parentRev: null as number | null, + source: { type: "init" as const }, + timestamp: "2024-01-01T00:00:00Z", +}; + +const makeL5 = (): L5Blueprint => ({ + id: "test-project", + name: "Test", + version: "0.1.0", + intent: "Test intent", + constraints: [], + domains: [], + integrations: [], + contentHash: "abc", + revision: baseRevision, +}); + +const makeL3 = (id: string): L3Block => ({ + id, + name: `Block ${id}`, + input: [{ name: "req", type: "Request" }], + output: [{ name: "res", type: "Response" }], + validate: {}, + constraints: [], + description: "test", + contentHash: "hash123", + revision: baseRevision, +}); + +const makeFlow = (id: string, blockRefs: string[]): L4Flow => ({ + kind: "flow" as const, + id, + name: `Flow ${id}`, + steps: blockRefs.map((ref, i) => ({ + id: `step-${String(i)}`, + action: "process" as const, + blockRef: ref, + next: i < blockRefs.length - 1 ? `step-${String(i + 1)}` : null, + })), + dataFlows: [], + contentHash: "hash456", + revision: baseRevision, +}); + +// ── buildDesignL3Prompt ── + +describe("buildDesignL3Prompt", () => { + it("creates new L3 prompt when no existingBlock — contains 'Create', block id, user intent, schema example", () => { + const flow = makeFlow("order-flow", ["validate-order"]); + const input: DesignL3Input = { + l4Context: { flow, stepIndex: 0 }, + userIntent: "validate incoming orders", + }; + + const result = buildDesignL3Prompt(input); + + expect(result).toContain("Create"); + expect(result).toContain("validate-order"); + expect(result).toContain("validate incoming orders"); + expect(result).toContain("validate-order"); + expect(result).toContain('"id": "validate-order"'); + }); + + it("updates existing L3 prompt when existingBlock provided — contains 'Update' and existing block JSON", () => { + const flow = makeFlow("order-flow", ["validate-order"]); + const existing = makeL3("validate-order"); + const input: DesignL3Input = { + l4Context: { flow, stepIndex: 0 }, + existingBlock: existing, + userIntent: "update the validation logic", + }; + + const result = buildDesignL3Prompt(input); + + expect(result).toContain("Update"); + expect(result).toContain(JSON.stringify(existing, null, 2)); + }); + + it("includes upstream neighbor context when prevBlock is provided", () => { + const flow = makeFlow("order-flow", ["prev-block", "target-block"]); + const prev = makeL3("prev-block"); + const input: DesignL3Input = { + l4Context: { flow, stepIndex: 1, prevBlock: prev }, + userIntent: "design target block", + }; + + const result = buildDesignL3Prompt(input); + + expect(result).toContain("Upstream Block"); + expect(result).toContain("prev-block"); + expect(result).toContain("Response"); + }); + + it("includes downstream neighbor context when nextBlock is provided", () => { + const flow = makeFlow("order-flow", ["target-block", "next-block"]); + const next = makeL3("next-block"); + const input: DesignL3Input = { + l4Context: { flow, stepIndex: 0, nextBlock: next }, + userIntent: "design target block", + }; + + const result = buildDesignL3Prompt(input); + + expect(result).toContain("Downstream Block"); + expect(result).toContain("next-block"); + expect(result).toContain("Request"); + }); + + it("includes both prev and next neighbor context when both are provided", () => { + const flow = makeFlow("order-flow", ["prev-block", "target-block", "next-block"]); + const prev = makeL3("prev-block"); + const next = makeL3("next-block"); + const input: DesignL3Input = { + l4Context: { flow, stepIndex: 1, prevBlock: prev, nextBlock: next }, + userIntent: "design middle block", + }; + + const result = buildDesignL3Prompt(input); + + expect(result).toContain("Upstream Block"); + expect(result).toContain("Downstream Block"); + expect(result).toContain("prev-block"); + expect(result).toContain("next-block"); + }); + + it("omits neighbor section entirely when no neighbors are provided", () => { + const flow = makeFlow("order-flow", ["solo-block"]); + const input: DesignL3Input = { + l4Context: { flow, stepIndex: 0 }, + userIntent: "design solo block", + }; + + const result = buildDesignL3Prompt(input); + + expect(result).not.toContain("Neighbor Context"); + expect(result).not.toContain("Upstream Block"); + expect(result).not.toContain("Downstream Block"); + }); + + it("uses 'unknown' as blockId when stepIndex is out of range", () => { + const flow = makeFlow("order-flow", ["validate-order"]); + const input: DesignL3Input = { + l4Context: { flow, stepIndex: 99 }, + userIntent: "design some block", + }; + + const result = buildDesignL3Prompt(input); + + expect(result).toContain("unknown"); + }); + + it("works with event-graph L4 context via blockContext — contains block id and handler location", () => { + const eg: L4EventGraph = { + kind: "event-graph", + id: "chat-sync", + name: "Chat Sync", + state: { doc: { type: "CRDTDoc", description: "shared doc" } }, + handlers: [ + { + id: "on-edit", + event: "user.local_edit", + steps: [ + { id: "s0", action: "process", blockRef: "merge-edit", next: "s1" }, + { id: "s1", action: "process", blockRef: "broadcast-change" }, + ], + dataFlows: [], + }, + ], + contentHash: "h1", + revision: baseRevision, + }; + + const blockCtx = findBlockContext(eg, "broadcast-change")!; + expect(blockCtx).toBeDefined(); + + const prev = makeL3("merge-edit"); + const input: DesignL3Input = { + l4Context: { l4: eg, blockContext: blockCtx, prevBlock: prev }, + userIntent: "broadcast changes to peers", + }; + + const result = buildDesignL3Prompt(input); + + expect(result).toContain("broadcast-change"); + expect(result).toContain("broadcast changes to peers"); + expect(result).toContain("handler"); + expect(result).toContain("Upstream Block"); + expect(result).toContain("merge-edit"); + }); + + it("works with state-machine L4 context via blockContext — contains block id and state location, no neighbors", () => { + const sm: L4StateMachine = { + kind: "state-machine", + id: "order-lifecycle", + name: "Order Lifecycle", + entity: "Order", + initialState: "pending", + states: { + pending: {}, + confirmed: { onEntry: { blockRef: "send-confirmation" } }, + closed: { onEntry: { blockRef: "archive-order" } }, + }, + transitions: [ + { from: "pending", to: "confirmed", event: "confirm", guard: "check-inventory" }, + { from: "confirmed", to: "closed", event: "close" }, + ], + contentHash: "h2", + revision: baseRevision, + }; + + const blockCtx = findBlockContext(sm, "send-confirmation")!; + expect(blockCtx).toBeDefined(); + expect(blockCtx.kind).toBe("state-machine"); + expect(blockCtx.prevBlockRef).toBeUndefined(); + expect(blockCtx.nextBlockRef).toBeUndefined(); + + const input: DesignL3Input = { + l4Context: { l4: sm, blockContext: blockCtx }, + userIntent: "send order confirmation email", + }; + + const result = buildDesignL3Prompt(input); + + expect(result).toContain("send-confirmation"); + expect(result).toContain("send order confirmation email"); + expect(result).toContain("state"); + expect(result).toContain("confirmed"); + expect(result).not.toContain("Neighbor Context"); + }); + + it("works with state-machine guard block via blockContext", () => { + const sm: L4StateMachine = { + kind: "state-machine", + id: "order-lifecycle", + name: "Order Lifecycle", + entity: "Order", + initialState: "pending", + states: { pending: {}, confirmed: {} }, + transitions: [ + { from: "pending", to: "confirmed", event: "confirm", guard: "check-inventory" }, + ], + contentHash: "h3", + revision: baseRevision, + }; + + const blockCtx = findBlockContext(sm, "check-inventory")!; + expect(blockCtx).toBeDefined(); + expect(blockCtx.location).toContain("guard"); + expect(blockCtx.location).toContain("pending"); + expect(blockCtx.location).toContain("confirmed"); + + const input: DesignL3Input = { + l4Context: { l4: sm, blockContext: blockCtx }, + userIntent: "check if inventory is available", + }; + + const result = buildDesignL3Prompt(input); + + expect(result).toContain("check-inventory"); + expect(result).toContain("check if inventory is available"); + }); +}); + +// ── buildDesignL4Prompt ── + +describe("buildDesignL4Prompt", () => { + it("creates new flow prompt when no targetFlowId — contains 'Create', L5 overview, user intent", () => { + const input: DesignL4Input = { + l5: makeL5(), + existingFlows: [], + existingBlocks: [], + userIntent: "create order processing flow", + }; + + const result = buildDesignL4Prompt(input); + + expect(result).toContain("Create"); + expect(result).toContain("Test"); + expect(result).toContain("create order processing flow"); + }); + + it("updates existing flow prompt when targetFlowId is set — contains 'Update'", () => { + const input: DesignL4Input = { + l5: makeL5(), + existingFlows: [], + existingBlocks: [], + userIntent: "update the flow", + targetFlowId: "order-flow", + }; + + const result = buildDesignL4Prompt(input); + + expect(result).toContain("Update"); + }); + + it("lists existing flows with kind and block ref count", () => { + const flow = makeFlow("order-flow", ["block-a", "block-b"]); + const input: DesignL4Input = { + l5: makeL5(), + existingFlows: [flow], + existingBlocks: [], + userIntent: "add another flow", + }; + + const result = buildDesignL4Prompt(input); + + expect(result).toContain("order-flow"); + expect(result).toContain("flow"); + expect(result).toContain("2"); + }); + + it("lists existing L3 blocks with type signatures", () => { + const block = makeL3("validate-order"); + const input: DesignL4Input = { + l5: makeL5(), + existingFlows: [], + existingBlocks: [block], + userIntent: "reuse existing block", + }; + + const result = buildDesignL4Prompt(input); + + expect(result).toContain("validate-order"); + expect(result).toContain("Request"); + expect(result).toContain("Response"); + }); + + it("shows 'No existing flows' when existingFlows is empty", () => { + const input: DesignL4Input = { + l5: makeL5(), + existingFlows: [], + existingBlocks: [], + userIntent: "first flow", + }; + + const result = buildDesignL4Prompt(input); + + expect(result).toContain("No existing flows."); + }); + + it("shows 'No existing L3 blocks' when existingBlocks is empty", () => { + const input: DesignL4Input = { + l5: makeL5(), + existingFlows: [], + existingBlocks: [], + userIntent: "no blocks yet", + }; + + const result = buildDesignL4Prompt(input); + + expect(result).toContain("No existing L3 blocks."); + }); +}); + +describe("buildDesignL4Prompt — language directive", () => { + it("includes language directive when language is 'zh'", () => { + const input: DesignL4Input = { + l5: makeL5(), + existingFlows: [], + existingBlocks: [], + userIntent: "create order processing flow", + language: "zh", + }; + + const result = buildDesignL4Prompt(input); + + expect(result).toContain("Chinese"); + }); + + it("does not include language directive when language is 'en'", () => { + const input: DesignL4Input = { + l5: makeL5(), + existingFlows: [], + existingBlocks: [], + userIntent: "create order processing flow", + language: "en", + }; + + const result = buildDesignL4Prompt(input); + + expect(result).not.toContain("IMPORTANT: All human-readable text"); + }); +}); + +// ── buildDesignL4EventGraphPrompt ── + +describe("buildDesignL4EventGraphPrompt", () => { + it("creates new EventGraph prompt when no targetId — contains 'Create', 'event-graph', examples", () => { + const input: DesignL4EventGraphInput = { + l5: makeL5(), + existingFlows: [], + existingBlocks: [], + userIntent: "design a chat room event graph", + }; + + const result = buildDesignL4EventGraphPrompt(input); + + expect(result).toContain("Create"); + expect(result).toContain("event-graph"); + expect(result).toContain("design a chat room event graph"); + }); + + it("updates existing EventGraph when targetId is set — contains 'Update'", () => { + const input: DesignL4EventGraphInput = { + l5: makeL5(), + existingFlows: [], + existingBlocks: [], + userIntent: "update chat room handlers", + targetId: "chat-room", + }; + + const result = buildDesignL4EventGraphPrompt(input); + + expect(result).toContain("Update"); + }); + + it("lists existing L4 artifacts with kind and block ref count", () => { + const flow = makeFlow("existing-flow", ["block-x"]); + const input: DesignL4EventGraphInput = { + l5: makeL5(), + existingFlows: [flow], + existingBlocks: [], + userIntent: "add event graph alongside flow", + }; + + const result = buildDesignL4EventGraphPrompt(input); + + expect(result).toContain("existing-flow"); + expect(result).toContain("flow"); + expect(result).toContain("1"); + }); + + it("contains 'EventGraph' in the section title", () => { + const input: DesignL4EventGraphInput = { + l5: makeL5(), + existingFlows: [], + existingBlocks: [], + userIntent: "check title", + }; + + const result = buildDesignL4EventGraphPrompt(input); + + expect(result).toContain("EventGraph"); + }); + + it("contains few-shot examples (chat room and IoT sensor)", () => { + const input: DesignL4EventGraphInput = { + l5: makeL5(), + existingFlows: [], + existingBlocks: [], + userIntent: "needs examples", + }; + + const result = buildDesignL4EventGraphPrompt(input); + + expect(result).toContain("chat room"); + expect(result).toContain("IoT sensor"); + }); +}); + +// ── buildDesignL4StateMachinePrompt ── + +describe("buildDesignL4StateMachinePrompt", () => { + it("creates new StateMachine prompt when no targetId — contains 'Create', 'state-machine', examples", () => { + const input: DesignL4StateMachineInput = { + l5: makeL5(), + existingFlows: [], + existingBlocks: [], + userIntent: "design support ticket lifecycle", + }; + + const result = buildDesignL4StateMachinePrompt(input); + + expect(result).toContain("Create"); + expect(result).toContain("state-machine"); + expect(result).toContain("design support ticket lifecycle"); + }); + + it("updates existing StateMachine when targetId is set — contains 'Update'", () => { + const input: DesignL4StateMachineInput = { + l5: makeL5(), + existingFlows: [], + existingBlocks: [], + userIntent: "add a new transition", + targetId: "support-ticket-lifecycle", + }; + + const result = buildDesignL4StateMachinePrompt(input); + + expect(result).toContain("Update"); + }); + + it("contains 'StateMachine' in the section title", () => { + const input: DesignL4StateMachineInput = { + l5: makeL5(), + existingFlows: [], + existingBlocks: [], + userIntent: "check title", + }; + + const result = buildDesignL4StateMachinePrompt(input); + + expect(result).toContain("StateMachine"); + }); + + it("contains few-shot examples (support ticket and blog post publishing)", () => { + const input: DesignL4StateMachineInput = { + l5: makeL5(), + existingFlows: [], + existingBlocks: [], + userIntent: "needs examples", + }; + + const result = buildDesignL4StateMachinePrompt(input); + + expect(result).toContain("Support ticket"); + expect(result).toContain("publishing"); + }); + + it("lists existing L3 blocks with type signatures", () => { + const block = makeL3("check-budget"); + const input: DesignL4StateMachineInput = { + l5: makeL5(), + existingFlows: [], + existingBlocks: [block], + userIntent: "reuse guard block", + }; + + const result = buildDesignL4StateMachinePrompt(input); + + expect(result).toContain("check-budget"); + expect(result).toContain("Request"); + expect(result).toContain("Response"); + }); +}); diff --git a/packages/skills/__tests__/link.test.ts b/packages/skills/__tests__/link.test.ts index b4d5788..c66f138 100644 --- a/packages/skills/__tests__/link.test.ts +++ b/packages/skills/__tests__/link.test.ts @@ -120,3 +120,80 @@ describe("relinkL2", () => { expect(relinked.signatureHash).toBe("sig-hash-123"); }); }); + +// ── Additional tests ── + +describe("createL2Link — additional", () => { + it("creates L2 with empty files array", () => { + const l3 = makeL3(); + const l2 = createL2Link({ l3Block: l3, files: [] }); + + expect(l2.files).toEqual([]); + // contentHash must still be computed without error + expect(l2.contentHash).toBeDefined(); + expect(typeof l2.contentHash).toBe("string"); + }); + + it("L2 id matches L3 block id exactly", () => { + const l3 = makeL3({ id: "compute-discount" }); + const l2 = createL2Link({ l3Block: l3, files: ["src/compute-discount.ts"] }); + + expect(l2.id).toBe("compute-discount"); + expect(l2.blockRef).toBe("compute-discount"); + }); + + it("revision source is ai/compile", () => { + const l3 = makeL3(); + const l2 = createL2Link({ l3Block: l3, files: ["src/validate-order.ts"] }); + + expect(l2.revision.source).toEqual({ type: "ai", action: "compile" }); + }); +}); + +describe("relinkL2 — additional", () => { + it("preserves existing signatureHash when it is undefined", () => { + const l3 = makeL3(); + const existing = createL2Link({ l3Block: l3, files: ["src/old.ts"] }); + // createL2Link does not set signatureHash — should be undefined + expect(existing.signatureHash).toBeUndefined(); + + const relinked = relinkL2(existing, l3, ["src/new.ts"]); + expect(relinked.signatureHash).toBeUndefined(); + }); + + it("preserves existing signatureHash when it is defined", () => { + const l3 = makeL3(); + const existing: L2CodeBlock = { + ...createL2Link({ l3Block: l3, files: ["src/old.ts"] }), + signatureHash: "sig-abc-999", + }; + const relinked = relinkL2(existing, l3, ["src/new.ts"]); + expect(relinked.signatureHash).toBe("sig-abc-999"); + }); + + it("revision source is ai/recompile", () => { + const l3 = makeL3(); + const existing = createL2Link({ l3Block: l3, files: ["src/old.ts"] }); + const relinked = relinkL2(existing, l3, ["src/new.ts"]); + + expect(relinked.revision.source).toEqual({ type: "ai", action: "recompile" }); + }); + + it("handles different number of files than original", () => { + const l3 = makeL3(); + const existing = createL2Link({ l3Block: l3, files: ["src/a.ts"] }); + const newFiles = ["src/a.ts", "src/a.test.ts", "src/a.helper.ts"]; + const relinked = relinkL2(existing, l3, newFiles); + + expect(relinked.files).toEqual(newFiles); + expect(relinked.files.length).toBe(3); + }); + + it("contentHash changes when files change", () => { + const l3 = makeL3(); + const existing = createL2Link({ l3Block: l3, files: ["src/old.ts"] }); + const relinked = relinkL2(existing, l3, ["src/new.ts"]); + + expect(relinked.contentHash).not.toBe(existing.contentHash); + }); +}); diff --git a/packages/skills/__tests__/prompt-builder.test.ts b/packages/skills/__tests__/prompt-builder.test.ts index 4a797e3..308134f 100644 --- a/packages/skills/__tests__/prompt-builder.test.ts +++ b/packages/skills/__tests__/prompt-builder.test.ts @@ -80,6 +80,7 @@ function makeInput(action: CompileTask["action"], resolved: ResolvedContext): Sk reason: `${action} validate-order`, issueCode: "TEST", context: [], + complexity: action === "update-ref" ? "light" : "standard", }; return { task, resolved, config: defaultConfig }; } @@ -156,6 +157,115 @@ describe("buildPrompt", () => { expect(prompt.context).toContain("No project-level context"); expect(prompt.input).toBe(""); }); + + it("includes Documentation section when docs is provided (compile)", () => { + const input = makeInput("compile", { + l5: makeL5(), + l3: makeL3(), + l4: makeL4(), + docs: "## Intent\nValidate all order fields.\n\n## Edge Cases\n- Empty items array", + }); + + const prompt = buildPrompt(input); + + expect(prompt.input).toContain("### Documentation"); + expect(prompt.input).toContain("## Intent"); + expect(prompt.input).toContain("Empty items array"); + }); + + it("includes Documentation section when docs is provided (recompile)", () => { + const input = makeInput("recompile", { + l3: makeL3(), + l2: makeL2(), + l4: makeL4(), + l1Files: [{ path: "src/validate-order.ts", content: "export function validate() {}" }], + docs: "## Error Strategy\nCollect all errors before returning.", + }); + + const prompt = buildPrompt(input); + + expect(prompt.input).toContain("### Documentation"); + expect(prompt.input).toContain("Collect all errors"); + }); + + it("includes Documentation section when docs is provided (review)", () => { + const input = makeInput("review", { + l3: makeL3(), + l2: makeL2(), + l1Files: [{ path: "src/validate-order.ts", content: "// drifted code" }], + docs: "## Integration Notes\nUpstream sends raw HTTP body.", + }); + + const prompt = buildPrompt(input); + + expect(prompt.input).toContain("### Documentation"); + expect(prompt.input).toContain("Upstream sends raw HTTP body"); + }); + + it("does not include Documentation section when docs is absent", () => { + const input = makeInput("compile", { + l5: makeL5(), + l3: makeL3(), + l4: makeL4(), + }); + + const prompt = buildPrompt(input); + + expect(prompt.input).not.toContain("### Documentation"); + }); + + it("compile output spec mentions Documentation", () => { + const input = makeInput("compile", { l3: makeL3() }); + const prompt = buildPrompt(input); + + expect(prompt.outputSpec).toContain("Documentation"); + }); + + it("includes complexity from task", () => { + const input = makeInput("compile", { l3: makeL3() }); + const prompt = buildPrompt(input); + + expect(prompt.complexity).toBe("standard"); + }); + + it("passes light complexity for update-ref", () => { + const input = makeInput("update-ref", { l4: makeL4(), l5: makeL5() }); + const prompt = buildPrompt(input); + + expect(prompt.complexity).toBe("light"); + }); +}); + +describe("buildPrompt — language directive", () => { + it("includes language directive in rules when L5 has language 'zh'", () => { + const l5WithZh: ReturnType = { + ...makeL5(), + language: "zh", + }; + const input = makeInput("compile", { + l5: l5WithZh, + l3: makeL3(), + }); + + const prompt = buildPrompt(input); + + expect(prompt.rules).toContain("Chinese"); + }); + + it("does not include language directive in rules when L5 has language 'en'", () => { + const l5WithEn: ReturnType = { + ...makeL5(), + language: "en", + }; + const input = makeInput("compile", { + l5: l5WithEn, + l3: makeL3(), + }); + + const prompt = buildPrompt(input); + + expect(prompt.rules).not.toContain("IMPORTANT: All human-readable text"); + }); }); describe("renderPrompt", () => { @@ -176,6 +286,30 @@ describe("renderPrompt", () => { expect(md).toContain("## Rules"); }); + it("prepends YAML front-matter with complexity", () => { + const input = makeInput("compile", { + l5: makeL5(), + l3: makeL3(), + }); + + const prompt = buildPrompt(input); + const md = renderPrompt(prompt); + + expect(md).toMatch(/^---\ncomplexity: standard\n---/); + }); + + it("renders light complexity for update-ref", () => { + const input = makeInput("update-ref", { + l4: makeL4(), + l5: makeL5(), + }); + + const prompt = buildPrompt(input); + const md = renderPrompt(prompt); + + expect(md).toMatch(/^---\ncomplexity: light\n---/); + }); + it("produces non-empty sections", () => { const input = makeInput("compile", { l5: makeL5(), diff --git a/packages/skills/__tests__/rehash.test.ts b/packages/skills/__tests__/rehash.test.ts index 5e12938..bb58820 100644 --- a/packages/skills/__tests__/rehash.test.ts +++ b/packages/skills/__tests__/rehash.test.ts @@ -3,7 +3,7 @@ import { hashL3, hashL4, hashL5 } from "../../core/hash.js"; import { rehashL2, rehashL3, rehashL4, rehashL5 } from "../rehash.js"; import type { L2CodeBlock } from "../../core/l2.js"; import type { L3Block } from "../../core/l3.js"; -import type { L4Flow } from "../../core/l4.js"; +import type { L4EventGraph, L4Flow, L4StateMachine } from "../../core/l4.js"; import type { L5Blueprint } from "../../core/l5.js"; const baseRevision = { @@ -171,3 +171,212 @@ describe("rehashL2", () => { expect(result.changed).toBe(false); }); }); + +// ── Additional tests ── + +function makeEventGraph(overrides?: Partial): L4EventGraph { + return { + kind: "event-graph", + id: "eg-checkout", + name: "Checkout Event Graph", + state: { + cartTotal: { type: "number", description: "Running cart total" }, + }, + handlers: [ + { + id: "h1", + event: "user.add_item", + steps: [{ id: "s1", action: "process", blockRef: "add-item-block", next: null }], + dataFlows: [], + }, + ], + contentHash: "placeholder", + revision: baseRevision, + ...overrides, + }; +} + +describe("rehashL4 — EventGraph variant", () => { + it("computes correct contentHash for EventGraph", () => { + const eg = makeEventGraph(); + const { data, result } = rehashL4(eg); + + expect(result.changed).toBe(true); + expect(data.contentHash).toBe(result.newHash); + const { contentHash: _, revision: _r, ...rest } = eg; + expect(data.contentHash).toBe(hashL4(rest)); + }); + + it("bumps revision when EventGraph hash changes", () => { + const eg = makeEventGraph(); + const { data } = rehashL4(eg); + + expect(data.revision.rev).toBe(2); + expect(data.revision.parentRev).toBe(1); + expect(data.revision.source).toEqual({ type: "human" }); + }); + + it("is idempotent for EventGraph", () => { + const { data: first } = rehashL4(makeEventGraph()); + const { result } = rehashL4(first); + expect(result.changed).toBe(false); + }); +}); + +function makeStateMachine(overrides?: Partial): L4StateMachine { + return { + kind: "state-machine", + id: "sm-order", + name: "Order State Machine", + entity: "PurchaseOrder", + initialState: "draft", + states: { + draft: { onEntry: { blockRef: "init-order" } }, + confirmed: {}, + cancelled: { onEntry: { blockRef: "cancel-order" } }, + }, + transitions: [ + { from: "draft", to: "confirmed", event: "confirm" }, + { from: "draft", to: "cancelled", event: "cancel", guard: "can-cancel" }, + ], + contentHash: "placeholder", + revision: baseRevision, + ...overrides, + }; +} + +describe("rehashL4 — StateMachine variant", () => { + it("computes correct contentHash for StateMachine", () => { + const sm = makeStateMachine(); + const { data, result } = rehashL4(sm); + + expect(result.changed).toBe(true); + expect(data.contentHash).toBe(result.newHash); + const { contentHash: _, revision: _r, ...rest } = sm; + expect(data.contentHash).toBe(hashL4(rest)); + }); + + it("bumps revision when StateMachine hash changes", () => { + const sm = makeStateMachine(); + const { data } = rehashL4(sm); + + expect(data.revision.rev).toBe(2); + expect(data.revision.parentRev).toBe(1); + }); + + it("is idempotent for StateMachine", () => { + const { data: first } = rehashL4(makeStateMachine()); + const { result } = rehashL4(first); + expect(result.changed).toBe(false); + }); +}); + +describe("rehashL3 — revision bumping", () => { + it("increments rev and sets parentRev when hash changes", () => { + const block = makeL3({ + revision: { + rev: 3, + parentRev: 2, + source: { type: "init" as const }, + timestamp: "2024-01-01T00:00:00.000Z", + }, + }); + const { data, result } = rehashL3(block); + + expect(result.changed).toBe(true); + expect(data.revision.rev).toBe(4); + expect(data.revision.parentRev).toBe(3); + }); + + it("does not bump rev when hash is already correct", () => { + const { data: first } = rehashL3(makeL3()); + const { data: second, result } = rehashL3(first); + + expect(result.changed).toBe(false); + expect(second.revision.rev).toBe(first.revision.rev); + expect(second.revision.parentRev).toBe(first.revision.parentRev); + }); +}); + +describe("rehashL2 — revision bumping", () => { + it("increments rev and sets parentRev when hash changes", () => { + const cb = makeL2({ + revision: { + rev: 5, + parentRev: 4, + source: { type: "init" as const }, + timestamp: "2024-01-01T00:00:00.000Z", + }, + }); + const { data, result } = rehashL2(cb); + + expect(result.changed).toBe(true); + expect(data.revision.rev).toBe(6); + expect(data.revision.parentRev).toBe(5); + }); + + it("does not bump rev when hash is already correct", () => { + const { data: first } = rehashL2(makeL2()); + const { data: second, result } = rehashL2(first); + + expect(result.changed).toBe(false); + expect(second.revision.rev).toBe(first.revision.rev); + }); +}); + +describe("rehashL5 — empty constraints/domains/integrations", () => { + it("computes contentHash with all arrays empty", () => { + const l5 = makeL5({ constraints: [], domains: [], integrations: [] }); + const { data, result } = rehashL5(l5); + + expect(result.changed).toBe(true); + expect(data.contentHash).toBe(result.newHash); + const { contentHash: _, revision: _r, ...rest } = l5; + expect(data.contentHash).toBe(hashL5(rest)); + }); + + it("is idempotent with empty arrays", () => { + const l5 = makeL5({ constraints: [], domains: [], integrations: [] }); + const { data: first } = rehashL5(l5); + const { result } = rehashL5(first); + expect(result.changed).toBe(false); + }); +}); + +describe("rehashL2 — preserves sourceHash", () => { + it("does not change sourceHash when hash changes", () => { + const cb = makeL2({ sourceHash: "original-source-hash" }); + const { data } = rehashL2(cb); + expect(data.sourceHash).toBe("original-source-hash"); + }); + + it("does not change sourceHash when hash is already correct", () => { + const { data: first } = rehashL2(makeL2({ sourceHash: "kept-source-hash" })); + expect(first.sourceHash).toBe("kept-source-hash"); + const { data: second } = rehashL2(first); + expect(second.sourceHash).toBe("kept-source-hash"); + }); +}); + +describe("rehashL3 — unicode content", () => { + it("handles unicode in name and description", () => { + const block = makeL3({ + name: "验证订单请求", + description: "逐项校验所有字段,确保 ✓ 符合规范", + constraints: ["输出必须有效", "错误信息不能为空"], + }); + const { data, result } = rehashL3(block); + + expect(result.changed).toBe(true); + expect(data.contentHash).toBe(result.newHash); + const { contentHash: _, revision: _r, ...rest } = block; + expect(data.contentHash).toBe(hashL3(rest)); + }); + + it("is idempotent with unicode content", () => { + const block = makeL3({ name: "验证订单", description: "校验字段 🚀" }); + const { data: first } = rehashL3(block); + const { result } = rehashL3(first); + expect(result.changed).toBe(false); + }); +}); diff --git a/packages/skills/adapters/claude-code.ts b/packages/skills/adapters/claude-code.ts index d64d3df..d56add2 100644 --- a/packages/skills/adapters/claude-code.ts +++ b/packages/skills/adapters/claude-code.ts @@ -1,34 +1,46 @@ -// Claude Code adapter — CodeCLIAdapter 实现 -// Prompt 即 Skill:execute() 生成结构化 prompt,由宿主 AI 执行 - -import { createSkillRegistry } from "../../core/skill.js"; -import { buildPrompt, renderPrompt } from "../prompt-builder.js"; -import type { CodeCLIAdapter } from "../../core/adapter.js"; -import type { TaskAction } from "../../core/compile-plan.js"; -import type { Skill, SkillInput, SkillResult } from "../../core/skill.js"; - -function createPromptSkill(action: TaskAction): Skill { - return { - action, - execute: (input: SkillInput): Promise => { - const prompt = buildPrompt(input); - return Promise.resolve({ - action: input.task.action, - status: "needs-review" as const, - artifacts: [], - notes: renderPrompt(prompt), - }); - }, - }; -} - -export const claudeCodeAdapter: CodeCLIAdapter = { - name: "claude-code", - createSkillRegistry: () => - createSkillRegistry([ - createPromptSkill("compile"), - createPromptSkill("recompile"), - createPromptSkill("review"), - createPromptSkill("update-ref"), - ]), +// adapters/claude-code — Claude Code host adapter + +import type { HostAdapter, SkillFile } from "./types.js"; +import { + buildSkillFileContent, + generateContextBody, + defaultSlashCommands, + DEFAULT_CONTEXT_MARKER, +} from "./shared.js"; + +export const claudeCodeAdapter: HostAdapter = { + id: "claude-code", + displayName: "Claude Code", + + skillDir() { + return ".claude/commands"; + }, + + generateSkillFiles(language = "en"): readonly SkillFile[] { + const modelTierLine = + language === "zh" + ? "heavy=opus/最强 | standard=sonnet/均衡 | light=haiku/最快" + : "heavy=opus/strongest | standard=sonnet/balanced | light=haiku/fastest"; + + return [{ relativePath: "svp.md", content: buildSkillFileContent(language, modelTierLine) }]; + }, + + contextFilePath() { + return "CLAUDE.md"; + }, + + contextMarker() { + return DEFAULT_CONTEXT_MARKER; + }, + + generateContextSection(projectName: string, language = "en"): string { + return generateContextBody(projectName, language, { + modelTierRows: { + heavy: language === "zh" ? "opus / 最强" : "opus / most capable", + standard: language === "zh" ? "sonnet / 均衡" : "sonnet / balanced", + light: language === "zh" ? "haiku / 最快" : "haiku / fastest", + }, + slashCommands: defaultSlashCommands(language), + }); + }, }; diff --git a/packages/skills/adapters/codex.ts b/packages/skills/adapters/codex.ts new file mode 100644 index 0000000..19cb310 --- /dev/null +++ b/packages/skills/adapters/codex.ts @@ -0,0 +1,43 @@ +// adapters/codex — Codex CLI host adapter + +import type { HostAdapter, SkillFile } from "./types.js"; +import { + buildSkillFileContent, + generateContextBody, + defaultSlashCommands, + DEFAULT_CONTEXT_MARKER, +} from "./shared.js"; + +export const codexAdapter: HostAdapter = { + id: "codex", + displayName: "Codex CLI", + + skillDir() { + return ".codex/skills"; + }, + + generateSkillFiles(language = "en"): readonly SkillFile[] { + const modelTierLine = "heavy=o3 | standard=o4-mini | light=o4-mini"; + + return [{ relativePath: "svp/SKILL.md", content: buildSkillFileContent(language, modelTierLine) }]; + }, + + contextFilePath() { + return "AGENTS.md"; + }, + + contextMarker() { + return DEFAULT_CONTEXT_MARKER; + }, + + generateContextSection(projectName: string, language = "en"): string { + return generateContextBody(projectName, language, { + modelTierRows: { + heavy: "o3", + standard: "o4-mini", + light: "o4-mini", + }, + slashCommands: defaultSlashCommands(language), + }); + }, +}; diff --git a/packages/skills/adapters/cursor.ts b/packages/skills/adapters/cursor.ts new file mode 100644 index 0000000..7bfdfaa --- /dev/null +++ b/packages/skills/adapters/cursor.ts @@ -0,0 +1,44 @@ +// adapters/cursor — Cursor host adapter + +import type { HostAdapter, SkillFile } from "./types.js"; +import { + buildSkillFileContent, + genericModelTierLine, + generateContextBody, + genericContextOptions, + DEFAULT_CONTEXT_MARKER, +} from "./shared.js"; + +export const cursorAdapter: HostAdapter = { + id: "cursor", + displayName: "Cursor", + + skillDir() { + return ".cursor/commands"; + }, + + generateSkillFiles(language = "en"): readonly SkillFile[] { + return [{ relativePath: "svp.md", content: buildSkillFileContent(language, genericModelTierLine(language)) }]; + }, + + contextFilePath() { + return ".cursor/rules/svp.mdc"; + }, + + contextMarker() { + return DEFAULT_CONTEXT_MARKER; + }, + + generateContextSection(projectName: string, language = "en"): string { + const frontmatter = [ + "---", + "description: SVP structured AI-assisted development protocol", + "globs: .svp/**", + "alwaysApply: true", + "---", + "", + ].join("\n"); + + return frontmatter + generateContextBody(projectName, language, genericContextOptions(language)); + }, +}; diff --git a/packages/skills/adapters/detect.ts b/packages/skills/adapters/detect.ts new file mode 100644 index 0000000..f0c61e8 --- /dev/null +++ b/packages/skills/adapters/detect.ts @@ -0,0 +1,48 @@ +// adapters/detect — Auto-detect host from project directory markers + +import { stat } from "node:fs/promises"; +import path from "node:path"; +import type { HostId } from "./types.js"; + +/** Marker paths mapped to their host IDs (checked in order). + * `kind` determines whether to check for a directory or a file. */ +const HOST_MARKERS: readonly { marker: string; kind: "dir" | "file"; host: HostId }[] = [ + { marker: ".claude", kind: "dir", host: "claude-code" }, + { marker: ".cursor", kind: "dir", host: "cursor" }, + { marker: ".windsurf", kind: "dir", host: "windsurf" }, + { marker: ".agents", kind: "dir", host: "kimi-code" }, + { marker: ".codex", kind: "dir", host: "codex" }, + { marker: ".github/copilot-instructions.md", kind: "file", host: "github-copilot" }, +]; + +async function pathExists(p: string, kind: "dir" | "file"): Promise { + try { + const s = await stat(p); + return kind === "dir" ? s.isDirectory() : s.isFile(); + } catch { + return false; + } +} + +/** + * Detect which host(s) are present by checking for marker directories/files. + * Returns all detected host IDs (may be empty or have multiple). + */ +export async function detectHosts(root: string): Promise { + const detected: HostId[] = []; + for (const { marker, kind, host } of HOST_MARKERS) { + if (await pathExists(path.join(root, marker), kind)) { + detected.push(host); + } + } + return detected; +} + +/** + * Detect a single host from project directory markers. + * Returns the first match, or null if none found. + */ +export async function detectHost(root: string): Promise { + const hosts = await detectHosts(root); + return hosts[0] ?? null; +} diff --git a/packages/skills/adapters/github-copilot.ts b/packages/skills/adapters/github-copilot.ts new file mode 100644 index 0000000..21616dc --- /dev/null +++ b/packages/skills/adapters/github-copilot.ts @@ -0,0 +1,35 @@ +// adapters/github-copilot — GitHub Copilot host adapter + +import type { HostAdapter, SkillFile } from "./types.js"; +import { + buildSkillFileContent, + genericModelTierLine, + generateContextBody, + genericContextOptions, + DEFAULT_CONTEXT_MARKER, +} from "./shared.js"; + +export const githubCopilotAdapter: HostAdapter = { + id: "github-copilot", + displayName: "GitHub Copilot", + + skillDir() { + return ".github/prompts"; + }, + + generateSkillFiles(language = "en"): readonly SkillFile[] { + return [{ relativePath: "svp.prompt.md", content: buildSkillFileContent(language, genericModelTierLine(language)) }]; + }, + + contextFilePath() { + return ".github/copilot-instructions.md"; + }, + + contextMarker() { + return DEFAULT_CONTEXT_MARKER; + }, + + generateContextSection(projectName: string, language = "en"): string { + return generateContextBody(projectName, language, genericContextOptions(language)); + }, +}; diff --git a/packages/skills/adapters/index.ts b/packages/skills/adapters/index.ts new file mode 100644 index 0000000..95afee8 --- /dev/null +++ b/packages/skills/adapters/index.ts @@ -0,0 +1,33 @@ +// adapters/index — Host adapter registry + +export type { HostId, HostAdapter, SkillFile } from "./types.js"; +export { detectHost, detectHosts } from "./detect.js"; + +import type { HostId, HostAdapter } from "./types.js"; +import { claudeCodeAdapter } from "./claude-code.js"; +import { kimiCodeAdapter } from "./kimi-code.js"; +import { codexAdapter } from "./codex.js"; +import { cursorAdapter } from "./cursor.js"; +import { windsurfAdapter } from "./windsurf.js"; +import { githubCopilotAdapter } from "./github-copilot.js"; + +const adapters: Record = { + "claude-code": claudeCodeAdapter, + "kimi-code": kimiCodeAdapter, + codex: codexAdapter, + cursor: cursorAdapter, + windsurf: windsurfAdapter, + "github-copilot": githubCopilotAdapter, +}; + +export function getAdapter(host: HostId): HostAdapter { + const adapter = adapters[host]; + if (!adapter) { + throw new Error(`Unknown host: ${host}`); + } + return adapter; +} + +export function getAllAdapterIds(): readonly HostId[] { + return Object.keys(adapters) as HostId[]; +} diff --git a/packages/skills/adapters/kimi-code.ts b/packages/skills/adapters/kimi-code.ts new file mode 100644 index 0000000..caa1d8c --- /dev/null +++ b/packages/skills/adapters/kimi-code.ts @@ -0,0 +1,46 @@ +// adapters/kimi-code — Kimi Code host adapter + +import type { HostAdapter, SkillFile } from "./types.js"; +import { + buildSkillFileContent, + genericModelTierLine, + generateContextBody, + genericContextOptions, + DEFAULT_CONTEXT_MARKER, +} from "./shared.js"; + +export const kimiCodeAdapter: HostAdapter = { + id: "kimi-code", + displayName: "Kimi Code", + + skillDir() { + return ".agents/skills"; + }, + + generateSkillFiles(language = "en"): readonly SkillFile[] { + const frontmatter = [ + "---", + "name: svp", + language === "zh" + ? "description: SVP 编译器与交互式向导" + : "description: SVP compiler and interactive wizard", + "type: flow", + "---", + "", + ].join("\n"); + + return [{ relativePath: "svp/SKILL.md", content: buildSkillFileContent(language, genericModelTierLine(language), frontmatter) }]; + }, + + contextFilePath() { + return "AGENTS.md"; + }, + + contextMarker() { + return DEFAULT_CONTEXT_MARKER; + }, + + generateContextSection(projectName: string, language = "en"): string { + return generateContextBody(projectName, language, genericContextOptions(language, "/skill:svp")); + }, +}; diff --git a/packages/skills/adapters/shared.ts b/packages/skills/adapters/shared.ts new file mode 100644 index 0000000..bfdab6c --- /dev/null +++ b/packages/skills/adapters/shared.ts @@ -0,0 +1,782 @@ +// adapters/shared — Shared workflow content across all host adapters +// Build/Add/Change/Fix/View sections are identical for every host. + +// ── Skill file: Intro line ── + +export function getSkillIntro(language: string): string { + if (language === "zh") { + return "你是 SVP 编译器与交互式向导。你诊断项目状态,选择正确模式,执行完整流程。"; + } + return "You are the SVP compiler and interactive wizard. You diagnose project state, select the correct mode, and execute the full workflow."; +} + +// ── Skill file: Protocol section ── + +export function getProtocolSection(language: string, modelTierLine: string): string { + if (language === "zh") { + return `## 协议(一次性声明) + +**Subagent 派发**:运行 \`svp prompt \` 获取提示词 → 读取 prompt 头部 complexity 字段 → 派发 subagent → 后续跑 toolchain 命令。 + +**Complexity → 模型等级**:${modelTierLine} + +**通用规则**: +- 严格单向向下,不回头修改上层 +- 主 Agent 不读 L1 代码——上下文隔离是核心价值 +- JSON 中 contentHash 和 revision 写占位值,\`svp rehash\` 会修正 +- 尽量并行派发无依赖的 subagent +- 做不到就报错,说清哪层什么问题——用户是反向反馈回路 +- 如果 nodes//docs.md 存在,compile/recompile prompt 会自动包含文档内容`; + } + + return `## Protocol (one-time declaration) + +**Subagent dispatch**: Run \`svp prompt \` to get the prompt → read the complexity field in the prompt header → dispatch subagent → then run toolchain commands. + +**Complexity → Model tier**: ${modelTierLine} + +**General rules**: +- Strictly top-down only, never modify upper layers +- Main Agent does not read L1 code — context isolation is the core value +- Write placeholder values for contentHash and revision in JSON; \`svp rehash\` will fix them +- Dispatch independent subagents in parallel when possible +- Report errors when unable to proceed, clearly stating which layer and what the issue is — the user is the reverse feedback loop +- If nodes//docs.md exists, compile/recompile prompts will automatically include its content`; +} + +// ── Skill file: Workflow content (Step 0 through View) ── + +export function getWorkflowContent(language: string): string { + if (language === "zh") { + return workflowZh; + } + return workflowEn; +} + +// ── Skill file: Full assembly (eliminates per-adapter copy-paste) ── + +export function buildSkillFileContent( + language: string, + modelTierLine: string, + frontmatter?: string, +): string { + const body = [ + getSkillIntro(language), + "", + getProtocolSection(language, modelTierLine), + "", + "---", + "", + getWorkflowContent(language), + ].join("\n"); + return frontmatter ? frontmatter + body : body; +} + +// ── Shared defaults (used by most adapters) ── + +export const DEFAULT_CONTEXT_MARKER = "## SVP"; + +export const GENERIC_MODEL_TIERS: ModelTierRows = { + heavy: "strongest model", + standard: "balanced model", + light: "fastest model", +}; + +export const GENERIC_MODEL_TIERS_ZH: ModelTierRows = { + heavy: "最强模型", + standard: "均衡模型", + light: "最快模型", +}; + +export function genericModelTierLine(language: string): string { + return language === "zh" + ? "heavy=最强模型 | standard=均衡模型 | light=最快模型" + : "heavy=strongest | standard=balanced | light=fastest"; +} + +export function defaultSlashCommands(language: string, command = "/svp"): SlashCommandEntry[] { + return [ + { + command, + description: + language === "zh" + ? "统一入口——自动诊断项目状态,路由到 Build/Add/Change/Fix/View 模式" + : "Unified entry point — auto-diagnoses project state, routes to Build/Add/Change/Fix/View mode", + }, + ]; +} + +export function genericContextOptions(language: string, command = "/svp"): ContextOptions { + return { + modelTierRows: language === "zh" ? GENERIC_MODEL_TIERS_ZH : GENERIC_MODEL_TIERS, + slashCommands: defaultSlashCommands(language, command), + }; +} + +// ── Context file: Model tier table rows ── + +export interface ModelTierRows { + readonly heavy: string; + readonly standard: string; + readonly light: string; +} + +// ── Context file: Slash command table rows ── + +export interface SlashCommandEntry { + readonly command: string; + readonly description: string; +} + +// ── Context file: Full context section ── + +export interface ContextOptions { + readonly modelTierRows: ModelTierRows; + readonly slashCommands: readonly SlashCommandEntry[]; +} + +export function generateContextBody(_projectName: string, language: string, opts: ContextOptions): string { + if (language === "zh") { + return contextBodyZh(opts); + } + return contextBodyEn(opts); +} + +// ── Private: Workflow templates ── + +const workflowZh = `## Step 0: 诊断路由 + +- 运行 \`svp check --json\`(忽略错误)+ \`svp view l5\` + 检查 .svp/ 是否存在 +- 根据结果判断: + - **无 .svp/**:告知用户先运行 \`svp init\`,停止 + - **空项目**(无 L4/L3)→ 进入 **Build** + - **有数据** → 问用户选择模式: + (a) Build — 从零构建 + (b) Add — 添加新功能 + (c) Change — 修改已有功能 + (d) Fix — 修复 check 问题 + (e) View — 查看当前结构 + +--- + +## Build(从零构建整个系统) + +### Step 1: [AI] 设计 L5 Blueprint +- 运行 \`svp prompt design-l5 --intent "<用户意图>"\` +- 将 stdout 输出派发给 subagent(读取 complexity 选择模型等级) +- Subagent 输出 L5 JSON → 写入 .svp/l5.json +- [Toolchain] 运行 \`svp rehash l5\` +- 展示 \`svp view l5\` 给用户确认 + +### Step 2: [AI] 设计 L4 Artifacts +根据系统类型选择 L4 变体: +- **Flow** (默认): 请求-响应 pipeline → \`svp prompt design-l4 --intent "..."\` +- **EventGraph**: 事件驱动/CRDT → \`svp prompt design-l4 --kind event-graph --intent "..."\` +- **StateMachine**: 实体生命周期 → \`svp prompt design-l4 --kind state-machine --intent "..."\` + +- 将 stdout 输出派发给 subagent(读取 complexity 选择模型等级) +- Subagent 输出 L4 JSON → 写入 .svp/l4/.json +- [Toolchain] 运行 \`svp rehash l4\` +- 展示 \`svp view l4\` 给用户确认 + +### Step 3: [AI] 设计 L3 Contracts(并行派发) +对每个 L4 step 的 blockRef: +- 运行 \`svp prompt design-l3 --flow --step --intent "..."\` +- 将 stdout 输出派发给 subagent(读取 complexity 选择模型等级) +- Subagent 输出 L3 JSON → 写入 .svp/l3/.json +- [Toolchain] 运行 \`svp rehash l3/\` +- **无依赖的 block 并行派发** + +### Step 4: [Toolchain] 获取编译任务 +- 运行 \`svp compile-plan\` 获取编译任务列表 + +### Step 5: [AI] 编译 L1 代码(并行派发) +对每个 compile 任务: +- 运行 \`svp prompt compile \` +- 将 stdout 输出派发给 subagent(读取 complexity 选择模型等级) +- Subagent 生成 src/.ts 代码文件 +- **无依赖的任务并行派发** + +### Step 6: [Toolchain] 创建 L2 映射 +- 对每个生成的文件运行 \`svp link --files src/.ts\` + +### Step 7: [Toolchain] 验证 +- 运行 \`svp check\` 验证全部层一致性 +- 如有问题,定位到对应层修复 +- 重复直到 check 通过 + +--- + +## Add(向已有系统添加功能) + +### Step 1: [Toolchain] 了解当前结构 +- 运行 \`svp view l5\` 和 \`svp view l4/\` 了解现有架构 +- 确定新功能属于哪个 L4 flow(或需要新 flow) + +### Step 2: [AI] 修改 L4 Flow +- 编辑对应的 .svp/l4/.json,添加新 step + blockRef +- 新 step 的 blockRef 指向尚不存在的 L3 block id +- 更新 dataFlows 连接新 step +- [Toolchain] 运行 \`svp rehash l4\` +- 展示 \`svp view l4/\` 给用户确认 + +### Step 3: [AI] 设计新 L3 Contract +- 运行 \`svp prompt design-l3 --flow --step --intent "..."\` +- 将 stdout 输出派发给 subagent(读取 complexity 选择模型等级) +- Subagent 创建 .svp/l3/.json +- [Toolchain] 运行 \`svp rehash l3/\` + +### Step 4: [AI] 编译新代码 +- 运行 \`svp prompt compile \` +- 将 stdout 输出派发给 subagent(读取 complexity 选择模型等级) +- Subagent 生成 L1 源代码 + +### Step 5: [Toolchain] 创建映射并验证 +- \`svp link --files \` +- \`svp check\` 确认全绿 + +--- + +## Change(修改已有需求) + +### Step 1: [Toolchain] 诊断当前状态 +- 运行 \`svp check\` 确认当前一致性状态 +- 运行 \`svp view l5\` + \`svp view l4\` + \`svp view l3\` 了解结构 + +### Step 2: 判断变更层级 +- 系统意图变了 → L5 +- 流程编排变了 → L4 +- 契约规则变了 → L3 +- 代码变了 → L1(只报 drift,不自动修改上层) +- 越低层介入越精确越便宜 + +### Step 3: [AI] 执行修改 +- L5 变更:编辑 .svp/l5.json → \`svp rehash l5\` +- L4 变更:编辑 .svp/l4/.json → \`svp rehash l4\` +- L3 变更:编辑 .svp/l3/.json → \`svp rehash l3/\` +- 展示给用户确认 + +### Step 4: [Toolchain] 获取受影响任务 +- 运行 \`svp compile-plan\` 获取受影响实体的重编译任务列表 + +### Step 5: [AI] 重编译受影响代码 +对每个 recompile 任务: +- 运行 \`svp prompt recompile \` +- 将 stdout 输出派发给 subagent(读取 complexity 选择模型等级) +- Subagent 更新 L1 代码 + +### Step 6: [Toolchain] 更新映射并验证 +- \`svp link --files \` +- \`svp check\` 确认全绿 + +--- + +## Fix(修复 check 发现的问题) + +### Step 1: [Toolchain] 诊断 +- 运行 \`svp check --json\` 获取结构化问题列表 + +### Step 2: 按 issueCode 分类处理 + +**HASH_MISMATCH** +- [Toolchain] 运行 \`svp rehash\` 修正 hash + +**MISSING_L2** +- [AI] 运行 \`svp prompt compile \` → subagent 生成代码 +- [Toolchain] 运行 \`svp link --files \` + +**SOURCE_DRIFT** +- [AI] 运行 \`svp prompt recompile \` → subagent 更新代码 + +**CONTENT_DRIFT** +- [AI] 运行 \`svp prompt review \` → subagent 判断: + - L3 需要更新?还是 L1 需要修复? + - 向用户展示分析结果 + +**MISSING_BLOCK_REF** +- [AI] 运行 \`svp prompt update-ref \` → subagent 判断: + - 创建缺失的 L3 contract?还是修复 L4 step 引用? + +**ORPHAN_STEP / NEXT_CYCLE** +- 图结构问题 → 提示用户手动修复 L4 JSON + +### Step 3: [Toolchain] 验证 +- 重新运行 \`svp check\` 确认修复有效 +- 每次只修一类问题,验证后再继续 +- 重复直到全绿 + +--- + +## View(查看当前结构) + +- 运行 \`svp view l5\` + \`svp view l4\` + \`svp view l3\` 展示完整系统结构 +- 如有 L2 映射,也展示 \`svp view l2\` + +$ARGUMENTS`; + +const workflowEn = `## Step 0: Diagnostic Router + +- Run \`svp check --json\` (ignore errors) + \`svp view l5\` + check whether .svp/ exists +- Based on the result, determine: + - **No .svp/**: Tell user to run \`svp init\` first, then stop + - **Empty project** (no L4/L3) → Enter **Build** + - **Has data** → Ask user to choose a mode: + (a) Build — build from scratch + (b) Add — add new feature + (c) Change — modify existing feature + (d) Fix — fix check issues + (e) View — view current structure + +--- + +## Build (build entire system from scratch) + +### Step 1: [AI] Design L5 Blueprint +- Run \`svp prompt design-l5 --intent ""\` +- Dispatch stdout output to subagent (read complexity to select model tier) +- Subagent outputs L5 JSON → write to .svp/l5.json +- [Toolchain] Run \`svp rehash l5\` +- Show \`svp view l5\` to user for confirmation + +### Step 2: [AI] Design L4 Artifacts +Choose L4 variant based on system type: +- **Flow** (default): Request-response pipeline → \`svp prompt design-l4 --intent "..."\` +- **EventGraph**: Event-driven/CRDT → \`svp prompt design-l4 --kind event-graph --intent "..."\` +- **StateMachine**: Entity lifecycle → \`svp prompt design-l4 --kind state-machine --intent "..."\` + +- Dispatch stdout output to subagent (read complexity to select model tier) +- Subagent outputs L4 JSON → write to .svp/l4/.json +- [Toolchain] Run \`svp rehash l4\` +- Show \`svp view l4\` to user for confirmation + +### Step 3: [AI] Design L3 Contracts (dispatch in parallel) +For each blockRef in L4 steps: +- Run \`svp prompt design-l3 --flow --step --intent "..."\` +- Dispatch stdout output to subagent (read complexity to select model tier) +- Subagent outputs L3 JSON → write to .svp/l3/.json +- [Toolchain] Run \`svp rehash l3/\` +- **Dispatch independent blocks in parallel** + +### Step 4: [Toolchain] Get Compile Tasks +- Run \`svp compile-plan\` to get the compile task list + +### Step 5: [AI] Compile L1 Code (dispatch in parallel) +For each compile task: +- Run \`svp prompt compile \` +- Dispatch stdout output to subagent (read complexity to select model tier) +- Subagent generates src/.ts code file +- **Dispatch independent tasks in parallel** + +### Step 6: [Toolchain] Create L2 Mappings +- For each generated file run \`svp link --files src/.ts\` + +### Step 7: [Toolchain] Verify +- Run \`svp check\` to validate all layer consistency +- If issues found, locate and fix in the corresponding layer +- Repeat until check passes + +--- + +## Add (add feature to existing system) + +### Step 1: [Toolchain] Understand Current Structure +- Run \`svp view l5\` and \`svp view l4/\` to understand the existing architecture +- Determine which L4 flow the new feature belongs to (or whether a new flow is needed) + +### Step 2: [AI] Modify L4 Flow +- Edit the corresponding .svp/l4/.json, add a new step + blockRef +- The new step's blockRef points to a L3 block id that does not yet exist +- Update dataFlows to connect the new step +- [Toolchain] Run \`svp rehash l4\` +- Show \`svp view l4/\` to user for confirmation + +### Step 3: [AI] Design New L3 Contract +- Run \`svp prompt design-l3 --flow --step --intent "..."\` +- Dispatch stdout output to subagent (read complexity to select model tier) +- Subagent creates .svp/l3/.json +- [Toolchain] Run \`svp rehash l3/\` + +### Step 4: [AI] Compile New Code +- Run \`svp prompt compile \` +- Dispatch stdout output to subagent (read complexity to select model tier) +- Subagent generates L1 source code + +### Step 5: [Toolchain] Create Mapping and Verify +- \`svp link --files \` +- \`svp check\` to confirm all green + +--- + +## Change (modify existing requirement) + +### Step 1: [Toolchain] Diagnose Current State +- Run \`svp check\` to confirm current consistency state +- Run \`svp view l5\` + \`svp view l4\` + \`svp view l3\` to understand the structure + +### Step 2: Determine the Change Layer +- System intent changed → L5 +- Logic chains changed → L4 +- Contract rules changed → L3 +- Code changed → L1 (report drift only, do not automatically modify upper layers) +- The lower the intervention layer, the more precise and cheaper + +### Step 3: [AI] Apply Changes +- L5 change: edit .svp/l5.json → \`svp rehash l5\` +- L4 change: edit .svp/l4/.json → \`svp rehash l4\` +- L3 change: edit .svp/l3/.json → \`svp rehash l3/\` +- Show to user for confirmation + +### Step 4: [Toolchain] Get Affected Tasks +- Run \`svp compile-plan\` to get the recompile task list for affected entities + +### Step 5: [AI] Recompile Affected Code +For each recompile task: +- Run \`svp prompt recompile \` +- Dispatch stdout output to subagent (read complexity to select model tier) +- Subagent updates L1 code + +### Step 6: [Toolchain] Update Mappings and Verify +- \`svp link --files \` +- \`svp check\` to confirm all green + +--- + +## Fix (fix issues found by check) + +### Step 1: [Toolchain] Diagnose +- Run \`svp check --json\` to get the structured issue list + +### Step 2: Handle by issueCode Category + +**HASH_MISMATCH** +- [Toolchain] Run \`svp rehash\` to fix hash + +**MISSING_L2** +- [AI] Run \`svp prompt compile \` → subagent generates code +- [Toolchain] Run \`svp link --files \` + +**SOURCE_DRIFT** +- [AI] Run \`svp prompt recompile \` → subagent updates code + +**CONTENT_DRIFT** +- [AI] Run \`svp prompt review \` → subagent determines: + - Does L3 need updating? Or does L1 need fixing? + - Show the analysis results to the user + +**MISSING_BLOCK_REF** +- [AI] Run \`svp prompt update-ref \` → subagent determines: + - Create the missing L3 contract? Or fix the L4 step reference? + +**ORPHAN_STEP / NEXT_CYCLE** +- Graph structure issues → prompt user to fix manually in L4 JSON + +### Step 3: [Toolchain] Verify +- Re-run \`svp check\` to confirm fixes are effective +- Fix one issue type at a time, verify before continuing +- Repeat until all green + +--- + +## View (view current structure) + +- Run \`svp view l5\` + \`svp view l4\` + \`svp view l3\` to show full system structure +- If L2 mappings exist, also show \`svp view l2\` + +$ARGUMENTS`; + +// ── Private: Context body templates ── + +function contextBodyZh(opts: ContextOptions): string { + const slashRows = opts.slashCommands.map((s) => `| \`${s.command}\` | ${s.description} |`).join("\n"); + + return ` +## SVP — Semantic Voxel Protocol + +本项目使用 SVP 进行结构化 AI 辅助开发。 + +### 五层模型 + +\`\`\` +L5 Blueprint ─聚合─→ L4 Artifact ─聚合─→ L3 Block ←1:1配对→ L2 Code ─聚合─→ L1 Source +(意图) (流程编排) (契约) (映射) (代码) +\`\`\` + +### .svp/ 目录结构 + +\`\`\` +.svp/ +├── l5.json # L5 Blueprint(全局唯一) +├── l4/ # L4 Artifact 文件 (flow / event-graph / state-machine) +│ └── .json +├── l3/ # L3 Block 契约 +│ └── .json +└── l2/ # L2 Code block 映射 + └── .json +\`\`\` + +### 模块化文档 (docs.md) + +每个节点/图可有可选的 \`docs.md\`,提供超出 \`description\` 的丰富上下文: + +\`\`\` +nodes// +├── node.yaml # 契约 +└── docs.md # 可选:设计意图、边界情况、错误策略、集成约定 +graphs/ +├── .yaml +└── .docs.md # 可选:图级文档 +\`\`\` + +- \`docs.md\` 自动加载到 compile/recompile/review prompt 中 +- 不影响 contentHash——是补充信息,不是契约 +- 用途:设计意图、边界情况、错误策略、集成约定、示例 + +### AI vs Toolchain 作用域 + +| 作用域 | 操作 | 方式 | +|--------|------|------| +| **AI** | 设计 L5/L4/L3 规格 | \`svp prompt design-*\` → subagent | +| **AI** | 编译 L3→L1 代码 | \`svp prompt compile/recompile\` → subagent | +| **AI** | 审查漂移 | \`svp prompt review\` → subagent | +| **AI** | 修复断裂引用 | \`svp prompt update-ref\` → subagent | +| **Toolchain** | 校验一致性 | \`svp check\` | +| **Toolchain** | 渲染层视图 | \`svp view\` | +| **Toolchain** | 生成编译任务列表 | \`svp compile-plan\` | +| **Toolchain** | 创建/更新 L2 映射 | \`svp link\` | +| **Toolchain** | 重算 hash | \`svp rehash\` | + +核心原则:AI 只做需要创造力/判断力的事。机械操作全部交给 toolchain CLI。 + +### Subagent 复杂度等级 + +SVP prompt 包含 \`complexity\` front-matter 字段,指示任务难度: + +| 等级 | 含义 | 模型 | +|------|------|------| +| \`heavy\` | 高创造力,架构决策 | ${opts.modelTierRows.heavy} | +| \`standard\` | 常规实现与审查 | ${opts.modelTierRows.standard} | +| \`light\` | 机械修复、文档、引用更新 | ${opts.modelTierRows.light} | + +派发 subagent 时,读取 prompt 输出中的 \`complexity\` 字段并传入对应的模型参数。 + +### Subagent 派发模式 + +\`\`\` +1. 运行 svp prompt [options] 获取提示词 +2. 读取 prompt 头部 complexity 字段选择模型等级 +3. 将 stdout 输出派发给 subagent 执行 +4. Subagent 完成后运行 svp link / svp rehash / svp check +\`\`\` + +### 可用 CLI 命令 + +| 命令 | 说明 | +|---|---| +| \`svp view l5/l4/l3/l2\` | 以 AI 友好格式查看层数据 | +| \`svp check\` | 校验跨层一致性 | +| \`svp compile-plan\` | 检测变更并生成重编译任务 | +| \`svp rehash [target]\` | 重算 contentHash + 递增 revision | +| \`svp link --files \` | 创建/更新 L2 code block 映射 | +| \`svp prompt \` | 生成上下文感知的 AI 提示词到 stdout | + +### Prompt 命令 + +| 命令 | 说明 | +|---|---| +| \`svp prompt compile \` | 生成 L3→L1 编译提示词 | +| \`svp prompt recompile \` | 生成重编译提示词(L3 已变更) | +| \`svp prompt review \` | 生成审查提示词(L1 漂移) | +| \`svp prompt update-ref \` | 生成修复断裂 L4→L3 引用的提示词 | +| \`svp prompt design-l5 --intent "..."\` | 生成 L5 设计提示词 | +| \`svp prompt design-l4 --intent "..." [--kind flow|event-graph|state-machine]\` | 生成 L4 设计提示词 | +| \`svp prompt design-l3 --flow --step --intent "..."\` | 生成 L3 设计提示词 | + +### Slash 命令 + +| 命令 | 使用场景 | +|---|---| +${slashRows} + +Toolchain 操作直接运行 CLI:\`svp check\`、\`svp view l3\` 等。 + +### 核心规则 + +1. **分层穿透**:仅向下工作。永远不修改上层来修复下层。 +2. **上下文隔离**:主 Agent 停留在 L5+L4(~30 行)。L3 设计和 L2+L1 编译派发给 subagent。 +3. **Hash 管理**:在 JSON 中写 \`"placeholder"\` 作为 contentHash。运行 \`svp rehash\` 修正。 +4. **L2 创建**:生成 L1 代码后,运行 \`svp link --files \` 创建 L2 映射。 +5. **验证**:每层完成后运行 \`svp check\` 确保一致性。 + +### L3 Contract Box 模型 + +\`\`\` +validate → 约束输入(每个字段路径的自然语言规则) +constraints → 约束输出(自然语言断言) +description → 描述中间(转换逻辑) +\`\`\` + +### JSON Schema 快速参考 + +**L5Blueprint**: \`{ id, name, version, intent, constraints[], domains[], integrations[], contentHash, revision }\` +**L4Flow**: \`{ kind?: "flow", id, name, trigger?, steps[], dataFlows[], contentHash, revision }\` +**L4EventGraph**: \`{ kind: "event-graph", id, name, state: {key: {type, description}}, handlers: [{id, event, steps[], dataFlows[]}], contentHash, revision }\` +**L4StateMachine**: \`{ kind: "state-machine", id, name, entity, initialState, states: {name: {onEntry?, onExit?}}, transitions: [{from, to, event, guard?}], contentHash, revision }\` +**L3Block**: \`{ id, name, input: Pin[], output: Pin[], validate: {}, constraints[], description, contentHash, revision }\` +**L2CodeBlock**: \`{ id, blockRef, language, files[], sourceHash, contentHash, signatureHash?, revision }\` + +### L4 变体选择指南 + +| 变体 | \`kind\` | 使用场景 | +|---|---|---| +| **Flow** | \`"flow"\`(默认) | 请求-响应 pipeline:触发 → 步骤链 → 结果 | +| **EventGraph** | \`"event-graph"\` | 事件驱动/响应式:共享状态 + 多事件处理器 | +| **StateMachine** | \`"state-machine"\` | 实体生命周期:状态 + 转换 + 守卫 |`.trim(); +} + +function contextBodyEn(opts: ContextOptions): string { + const slashRows = opts.slashCommands.map((s) => `| \`${s.command}\` | ${s.description} |`).join("\n"); + + return ` +## SVP — Semantic Voxel Protocol + +This project uses SVP for structured AI-assisted development. + +### Five-Layer Model + +\`\`\` +L5 Blueprint ──aggregates──→ L4 Artifact ──aggregates──→ L3 Block ←1:1 pair→ L2 Code ──aggregates──→ L1 Source +(Intent) (Logic chains) (Contract) (Mapping) (Code) +\`\`\` + +### .svp/ Directory Structure + +\`\`\` +.svp/ +├── l5.json # L5 Blueprint (globally unique) +├── l4/ # L4 Artifact files (flow / event-graph / state-machine) +│ └── .json +├── l3/ # L3 Block contracts +│ └── .json +└── l2/ # L2 Code block mappings + └── .json +\`\`\` + +### Modular Documentation (docs.md) + +Each node/graph can have an optional \`docs.md\` for rich context beyond \`description\`: + +\`\`\` +nodes// +├── node.yaml # Contract +└── docs.md # Optional: design intent, edge cases, error strategy, integration notes +graphs/ +├── .yaml +└── .docs.md # Optional: graph-level documentation +\`\`\` + +- \`docs.md\` is auto-loaded into compile/recompile/review prompts +- Does NOT affect contentHash — it's supplementary, not contractual +- Use it for: design intent, edge cases, error strategy, integration notes, examples + +### AI vs Toolchain Scope + +| Scope | Operation | Method | +|-------|-----------|--------| +| **AI** | Design L5/L4/L3 specs | \`svp prompt design-*\` → subagent | +| **AI** | Compile L3→L1 code | \`svp prompt compile/recompile\` → subagent | +| **AI** | Review drift | \`svp prompt review\` → subagent | +| **AI** | Fix broken references | \`svp prompt update-ref\` → subagent | +| **Toolchain** | Validate consistency | \`svp check\` | +| **Toolchain** | Render layer views | \`svp view\` | +| **Toolchain** | Generate compile task list | \`svp compile-plan\` | +| **Toolchain** | Create/update L2 mapping | \`svp link\` | +| **Toolchain** | Recompute hash | \`svp rehash\` | + +Core Principle: AI only does what requires creativity or judgment. All mechanical operations go to the toolchain CLI. + +### Subagent Complexity Tiers + +SVP prompts include a \`complexity\` front-matter field indicating task difficulty: + +| Tier | Meaning | Model | +|------|---------|-------| +| \`heavy\` | High creativity, architecture decisions | ${opts.modelTierRows.heavy} | +| \`standard\` | Normal implementation and review | ${opts.modelTierRows.standard} | +| \`light\` | Mechanical fixes, docs, reference updates | ${opts.modelTierRows.light} | + +When dispatching a subagent, read the \`complexity\` field from the prompt output +and pass the corresponding model parameter. + +### Subagent Dispatch Pattern + +\`\`\` +1. Run svp prompt [options] to get the prompt +2. Read the complexity field in the prompt header to select model tier +3. Dispatch stdout output to subagent for execution +4. After subagent completes, run svp link / svp rehash / svp check +\`\`\` + +### Available CLI Commands + +| Command | Description | +|---|---| +| \`svp view l5/l4/l3/l2\` | View layer data in AI-friendly format | +| \`svp check\` | Validate cross-layer consistency | +| \`svp compile-plan\` | Detect changes and generate recompile tasks | +| \`svp rehash [target]\` | Recompute contentHash + bump revision | +| \`svp link --files \` | Create/update L2 code block mapping | +| \`svp prompt \` | Generate context-aware AI prompt to stdout | + +### Prompt Commands + +| Command | Description | +|---|---| +| \`svp prompt compile \` | Generate compile prompt for L3→L1 | +| \`svp prompt recompile \` | Generate recompile prompt (L3 changed) | +| \`svp prompt review \` | Generate review prompt (L1 drift) | +| \`svp prompt update-ref \` | Generate fix prompt for broken L4→L3 refs | +| \`svp prompt design-l5 --intent "..."\` | Generate L5 design prompt | +| \`svp prompt design-l4 --intent "..." [--kind flow|event-graph|state-machine]\` | Generate L4 design prompt | +| \`svp prompt design-l3 --flow --step --intent "..."\` | Generate L3 design prompt | + +### Slash Commands + +| Command | When to use | +|---|---| +${slashRows} + +Toolchain operations run CLI directly: \`svp check\`, \`svp view l3\`, etc. + +### Core Rules + +1. **Layered penetration**: Work top-down only. Never modify upper layers to fix lower layers. +2. **Context isolation**: Main agent stays at L5+L4 (~30 lines). L3 design and L2+L1 compile dispatched to subagents. +3. **Hash management**: Write \`"placeholder"\` for contentHash in JSON. Run \`svp rehash\` to fix. +4. **L2 creation**: After generating L1 code, run \`svp link --files \` to create L2 mapping. +5. **Verification**: Run \`svp check\` after each layer to ensure consistency. + +### L3 Contract Box Model + +\`\`\` +validate → constrains INPUT (natural language rules per field path) +constraints → constrains OUTPUT (natural language assertions) +description → describes the MIDDLE (transformation logic) +\`\`\` + +### JSON Schema Quick Reference + +**L5Blueprint**: \`{ id, name, version, intent, constraints[], domains[], integrations[], contentHash, revision }\` +**L4Flow**: \`{ kind?: "flow", id, name, trigger?, steps[], dataFlows[], contentHash, revision }\` +**L4EventGraph**: \`{ kind: "event-graph", id, name, state: {key: {type, description}}, handlers: [{id, event, steps[], dataFlows[]}], contentHash, revision }\` +**L4StateMachine**: \`{ kind: "state-machine", id, name, entity, initialState, states: {name: {onEntry?, onExit?}}, transitions: [{from, to, event, guard?}], contentHash, revision }\` +**L3Block**: \`{ id, name, input: Pin[], output: Pin[], validate: {}, constraints[], description, contentHash, revision }\` +**L2CodeBlock**: \`{ id, blockRef, language, files[], sourceHash, contentHash, signatureHash?, revision }\` + +### L4 Variant Selection Guide + +| Variant | \`kind\` | Use when | +|---|---|---| +| **Flow** | \`"flow"\` (default) | Request-response pipeline: trigger → step chain → result | +| **EventGraph** | \`"event-graph"\` | Event-driven / reactive: shared state + multiple event handlers | +| **StateMachine** | \`"state-machine"\` | Entity lifecycle: states + transitions + guards |`.trim(); +} diff --git a/packages/skills/adapters/types.ts b/packages/skills/adapters/types.ts new file mode 100644 index 0000000..5338163 --- /dev/null +++ b/packages/skills/adapters/types.ts @@ -0,0 +1,29 @@ +// adapters/types — Host adapter interface for multi-host support + +export type HostId = "claude-code" | "kimi-code" | "codex" | "cursor" | "windsurf" | "github-copilot"; + +export interface HostAdapter { + readonly id: HostId; + readonly displayName: string; + + /** Directory where skill/command files are written (relative to project root) */ + skillDir(): string; + + /** Generate skill/command files */ + generateSkillFiles(language: string): readonly SkillFile[]; + + /** Path to context file (e.g. "CLAUDE.md" or "AGENTS.md") */ + contextFilePath(): string; + + /** Marker string to detect if SVP section already exists */ + contextMarker(): string; + + /** Generate the context section to append */ + generateContextSection(projectName: string, language: string): string; +} + +export interface SkillFile { + /** Relative path within skillDir (e.g. "svp.md" or "svp/SKILL.md") */ + readonly relativePath: string; + readonly content: string; +} diff --git a/packages/skills/adapters/windsurf.ts b/packages/skills/adapters/windsurf.ts new file mode 100644 index 0000000..4c1dccc --- /dev/null +++ b/packages/skills/adapters/windsurf.ts @@ -0,0 +1,35 @@ +// adapters/windsurf — Windsurf host adapter + +import type { HostAdapter, SkillFile } from "./types.js"; +import { + buildSkillFileContent, + genericModelTierLine, + generateContextBody, + genericContextOptions, + DEFAULT_CONTEXT_MARKER, +} from "./shared.js"; + +export const windsurfAdapter: HostAdapter = { + id: "windsurf", + displayName: "Windsurf", + + skillDir() { + return ".windsurf/commands"; + }, + + generateSkillFiles(language = "en"): readonly SkillFile[] { + return [{ relativePath: "svp.md", content: buildSkillFileContent(language, genericModelTierLine(language)) }]; + }, + + contextFilePath() { + return ".windsurf/rules/svp.md"; + }, + + contextMarker() { + return DEFAULT_CONTEXT_MARKER; + }, + + generateContextSection(projectName: string, language = "en"): string { + return generateContextBody(projectName, language, genericContextOptions(language)); + }, +}; diff --git a/packages/skills/index.ts b/packages/skills/index.ts index 0a8fa73..0e3bdf3 100644 --- a/packages/skills/index.ts +++ b/packages/skills/index.ts @@ -1,4 +1,4 @@ -// packages/skills — AI Skill 实现 & Claude Code 集成 +// packages/skills — AI Skill 实现 & host adapter 集成 // 提供:rehash、link、prompt 模板、adapter、slash command 模板 export { rehashL5, rehashL4, rehashL3, rehashL2 } from "./rehash.js"; @@ -10,9 +10,11 @@ export type { LinkOptions } from "./link.js"; export { buildPrompt, renderPrompt } from "./prompt-builder.js"; export type { StructuredPrompt } from "./prompt-builder.js"; -export { claudeCodeAdapter } from "./adapters/claude-code.js"; - export { generateSlashCommands } from "./templates/slash-commands.js"; export type { SlashCommandTemplate } from "./templates/slash-commands.js"; export { generateClaudeMdSection } from "./templates/claude-md.js"; + +// Host adapter system +export { getAdapter, getAllAdapterIds } from "./adapters/index.js"; +export type { HostId, HostAdapter, SkillFile } from "./adapters/index.js"; diff --git a/packages/skills/prompt-builder.ts b/packages/skills/prompt-builder.ts index 763e4a2..9f16bad 100644 --- a/packages/skills/prompt-builder.ts +++ b/packages/skills/prompt-builder.ts @@ -3,8 +3,9 @@ // 纯函数,不做 IO import { viewL2Detail, viewL3Detail, viewL4Detail, viewL5Overview } from "../core/view.js"; -import type { TaskAction } from "../core/compile-plan.js"; +import type { Complexity, TaskAction } from "../core/compile-plan.js"; import type { SkillInput } from "../core/skill.js"; +import { getLanguage, languageDirective } from "../core/i18n.js"; export interface StructuredPrompt { readonly role: string; @@ -13,6 +14,7 @@ export interface StructuredPrompt { readonly input: string; readonly outputSpec: string; readonly rules: string; + readonly complexity: Complexity; } // ── 角色定义 ── @@ -36,6 +38,7 @@ const OUTPUT_SPECS: Record = { "- Function signatures must match L3 input/output pins exactly", "- Implementation must satisfy all validate rules and constraints", "- Internal logic should follow the description", + "- If Documentation is provided, use it for design intent, edge cases, and error strategy", "- After writing files, run: svp link --files ", "- File naming: src/.ts (or appropriate for language)", ].join("\n"), @@ -78,6 +81,8 @@ const COMMON_RULES = [ /** 从 SkillInput 构建结构化 Prompt */ export function buildPrompt(input: SkillInput): StructuredPrompt { const { task } = input; + const lang = getLanguage(input.resolved.l5); + const langDirective = languageDirective(lang); return { role: ROLES[task.action], @@ -85,13 +90,18 @@ export function buildPrompt(input: SkillInput): StructuredPrompt { task: `[${task.action}] ${task.reason}`, input: buildInput(input), outputSpec: OUTPUT_SPECS[task.action], - rules: COMMON_RULES, + rules: COMMON_RULES + langDirective, + complexity: task.complexity, }; } /** 渲染 StructuredPrompt 为 markdown 文本(喂给 AI) */ export function renderPrompt(prompt: StructuredPrompt): string { return [ + `---`, + `complexity: ${prompt.complexity}`, + `---`, + "", `# ${prompt.role}`, "", "## Context", @@ -165,6 +175,11 @@ function buildCompileInput(input: SkillInput): string[] { parts.push("### L3 Contract", "", viewL3Detail(resolved.l3, flows, [])); } + // 模块化文档 + if (resolved.docs !== undefined) { + parts.push("", "### Documentation", "", resolved.docs); + } + return parts; } @@ -193,6 +208,11 @@ function buildRecompileInput(input: SkillInput): string[] { } } + // 模块化文档 + if (resolved.docs !== undefined) { + parts.push("", "### Documentation", "", resolved.docs); + } + return parts; } @@ -221,6 +241,11 @@ function buildReviewInput(input: SkillInput): string[] { } } + // 模块化文档 + if (resolved.docs !== undefined) { + parts.push("", "### Documentation", "", resolved.docs); + } + return parts; } diff --git a/packages/skills/prompts/compile.ts b/packages/skills/prompts/compile.ts index d2c4c20..3b6a9c5 100644 --- a/packages/skills/prompts/compile.ts +++ b/packages/skills/prompts/compile.ts @@ -1,26 +1,33 @@ // compile — L3 → L2+L1 初始编译 prompt 模板 -// 由 SkillRegistry compile skill 使用 +// 由 svp prompt compile 使用 -export const COMPILE_INSTRUCTIONS = [ - "You are compiling an L3 contract into L1 source code.", - "", - "## Steps", - "", - "1. Read the L3 contract provided in the Input section", - "2. Create L1 source file(s) that implement the contract:", - " - Function signature matches L3 input/output pins", - " - Implementation satisfies all validate rules", - " - Output meets all constraints", - " - Internal logic follows the description", - "3. Run `svp link --files ` to create the L2 mapping", - "", - "## Code Guidelines", - "", - "- File naming: `src/.ts`", - "- Export a single main function named after the block id (camelCase)", - "- Input parameter types must match L3 input pins", - "- Return type must match L3 output pins", - "- Add a corresponding test file: `src/.test.ts`", - "- Use TypeScript strict mode", - "- No external dependencies unless specified in L5 integrations", -].join("\n"); +import { languageDirective } from "../../core/i18n.js"; + +export function compileInstructions(language = "en"): string { + return [ + "You are compiling an L3 contract into L1 source code.", + "", + "## Steps", + "", + "1. Read the L3 contract provided in the Input section", + "2. Create L1 source file(s) that implement the contract:", + " - Function signature matches L3 input/output pins", + " - Implementation satisfies all validate rules", + " - Output meets all constraints", + " - Internal logic follows the description", + "3. Run `svp link --files ` to create the L2 mapping", + "", + "## Code Guidelines", + "", + "- File naming: `src/.ts`", + "- Export a single main function named after the block id (camelCase)", + "- Input parameter types must match L3 input pins", + "- Return type must match L3 output pins", + "- Add a corresponding test file: `src/.test.ts`", + "- Use TypeScript strict mode", + "- No external dependencies unless specified in L5 integrations", + ].join("\n") + languageDirective(language); +} + +// Backward compat +export const COMPILE_INSTRUCTIONS = compileInstructions(); diff --git a/packages/skills/prompts/complexity-header.ts b/packages/skills/prompts/complexity-header.ts new file mode 100644 index 0000000..6c7ac12 --- /dev/null +++ b/packages/skills/prompts/complexity-header.ts @@ -0,0 +1,9 @@ +// complexity-header — shared YAML front-matter for complexity tier +// Used by design prompt builders that output raw markdown + +import type { Complexity } from "../../core/compile-plan.js"; + +/** Generate a YAML front-matter block for complexity tier */ +export function complexityHeader(c: Complexity): string { + return `---\ncomplexity: ${c}\n---\n\n`; +} diff --git a/packages/skills/prompts/design-l3.ts b/packages/skills/prompts/design-l3.ts index e4f1017..73da73f 100644 --- a/packages/skills/prompts/design-l3.ts +++ b/packages/skills/prompts/design-l3.ts @@ -1,19 +1,31 @@ // design-l3 — L3 Contract 设计 prompt 模板 -// 由 slash commands 驱动,不走 SkillRegistry +// 由 slash commands / svp prompt 驱动 +import { complexityHeader } from "./complexity-header.js"; +import { languageDirective } from "../../core/i18n.js"; import { viewL4Detail } from "../../core/view.js"; import type { L3Block } from "../../core/l3.js"; -import type { L4Flow } from "../../core/l4.js"; +import type { L4Artifact, L4Flow, BlockContext } from "../../core/l4.js"; export interface DesignL3Input { - readonly l4Context: { - readonly flow: L4Flow; // design-l3 只在 Flow 上下文中使用(需要 stepIndex) - readonly stepIndex: number; - readonly prevBlock?: L3Block; - readonly nextBlock?: L3Block; - }; + readonly l4Context: + | { + // Legacy flow-only form (backward compat) + readonly flow: L4Flow; + readonly stepIndex: number; + readonly prevBlock?: L3Block; + readonly nextBlock?: L3Block; + } + | { + // Generalized form for any L4 kind + readonly l4: L4Artifact; + readonly blockContext: BlockContext; + readonly prevBlock?: L3Block; + readonly nextBlock?: L3Block; + }; readonly existingBlock?: L3Block; readonly userIntent: string; + readonly language?: string; } const L3_SCHEMA_EXAMPLE = `{ @@ -44,35 +56,54 @@ export function buildDesignL3Prompt(input: DesignL3Input): string { const isNew = existingBlock === undefined; const action = isNew ? "Create" : "Update"; - const step = l4Context.flow.steps[l4Context.stepIndex] as - | (typeof l4Context.flow.steps)[number] - | undefined; - const blockId = step?.blockRef ?? "unknown"; + // Normalize both input forms into a common shape + let l4Artifact: L4Artifact; + let blockId: string; + let location: string; + let prevBlock: L3Block | undefined; + let nextBlock: L3Block | undefined; + + if ("blockContext" in l4Context) { + // Generalized form + l4Artifact = l4Context.l4; + blockId = l4Context.blockContext.blockId; + location = l4Context.blockContext.location; + prevBlock = l4Context.prevBlock; + nextBlock = l4Context.nextBlock; + } else { + // Legacy flow form + l4Artifact = l4Context.flow; + const step = l4Context.flow.steps[l4Context.stepIndex] as + | (typeof l4Context.flow.steps)[number] + | undefined; + blockId = step?.blockRef ?? "unknown"; + location = `step ${String(l4Context.stepIndex)} in flow "${l4Context.flow.id}"`; + prevBlock = l4Context.prevBlock; + nextBlock = l4Context.nextBlock; + } // Build L4 context view const l3sForContext: L3Block[] = []; - if (l4Context.prevBlock !== undefined) l3sForContext.push(l4Context.prevBlock); - if (l4Context.nextBlock !== undefined) l3sForContext.push(l4Context.nextBlock); + if (prevBlock !== undefined) l3sForContext.push(prevBlock); + if (nextBlock !== undefined) l3sForContext.push(nextBlock); if (existingBlock !== undefined) l3sForContext.push(existingBlock); - const l4View = viewL4Detail(l4Context.flow, l3sForContext); + const l4View = viewL4Detail(l4Artifact, l3sForContext); // Neighbor context const neighborSection: string[] = []; - if (l4Context.prevBlock !== undefined) { - const prev = l4Context.prevBlock; + if (prevBlock !== undefined) { neighborSection.push( "### Upstream Block (previous step output)", - `- id: ${prev.id}`, - `- output: ${prev.output.map((p) => `${p.name}: ${p.type}`).join(", ")}`, + `- id: ${prevBlock.id}`, + `- output: ${prevBlock.output.map((p) => `${p.name}: ${p.type}`).join(", ")}`, ); } - if (l4Context.nextBlock !== undefined) { - const next = l4Context.nextBlock; + if (nextBlock !== undefined) { neighborSection.push( "### Downstream Block (next step input)", - `- id: ${next.id}`, - `- input: ${next.input.map((p) => `${p.name}: ${p.type}`).join(", ")}`, + `- id: ${nextBlock.id}`, + `- input: ${nextBlock.input.map((p) => `${p.name}: ${p.type}`).join(", ")}`, ); } @@ -80,13 +111,13 @@ export function buildDesignL3Prompt(input: DesignL3Input): string { ? "No existing L3 block. Creating new contract." : ["### Current L3 Block", "```json", JSON.stringify(existingBlock, null, 2), "```"].join("\n"); - return [ + return complexityHeader("standard") + [ `# ${action} L3 Contract: ${blockId}`, "", "You are designing a contract box (L3) — the specification for a single logic unit.", "L3 is the interface between human intent and AI implementation.", "", - "## L4 Flow Context", + `## L4 Context (${location})`, "", l4View, "", @@ -133,5 +164,5 @@ export function buildDesignL3Prompt(input: DesignL3Input): string { "- description explains HOW to transform input to output", "- Ensure pin types are compatible with upstream/downstream blocks", "- Write 'placeholder' for contentHash — rehash will fix it", - ].join("\n"); + ].join("\n") + languageDirective(input.language ?? "en"); } diff --git a/packages/skills/prompts/design-l4-event-graph.ts b/packages/skills/prompts/design-l4-event-graph.ts index 2c54551..3dc7ce9 100644 --- a/packages/skills/prompts/design-l4-event-graph.ts +++ b/packages/skills/prompts/design-l4-event-graph.ts @@ -1,5 +1,7 @@ // design-l4-event-graph — L4 EventGraph 设计 prompt 模板 +import { complexityHeader } from "./complexity-header.js"; +import { languageDirective } from "../../core/i18n.js"; import { extractBlockRefs, getL4Kind } from "../../core/l4.js"; import { viewL5Overview } from "../../core/view.js"; import type { L3Block } from "../../core/l3.js"; @@ -12,6 +14,7 @@ export interface DesignL4EventGraphInput { readonly existingBlocks: readonly L3Block[]; readonly userIntent: string; readonly targetId?: string; + readonly language?: string; } const EVENT_GRAPH_SCHEMA_EXAMPLE = `{ @@ -194,7 +197,7 @@ export function buildDesignL4EventGraphPrompt(input: DesignL4EventGraphInput): s ) .join("\n"); - return [ + return complexityHeader("heavy") + [ `# ${action} L4 EventGraph`, "", "You are designing an event-graph (L4) for event-driven / reactive systems.", @@ -254,5 +257,5 @@ export function buildDesignL4EventGraphPrompt(input: DesignL4EventGraphInput): s "- `$state.key` must reference a declared state key", "- Write 'placeholder' for contentHash — rehash will fix it", "- Do NOT create L3 blocks here — only reference them by id", - ].join("\n"); + ].join("\n") + languageDirective(input.language ?? "en"); } diff --git a/packages/skills/prompts/design-l4-state-machine.ts b/packages/skills/prompts/design-l4-state-machine.ts index 11fef37..7a6b7fa 100644 --- a/packages/skills/prompts/design-l4-state-machine.ts +++ b/packages/skills/prompts/design-l4-state-machine.ts @@ -1,5 +1,7 @@ // design-l4-state-machine — L4 StateMachine 设计 prompt 模板 +import { complexityHeader } from "./complexity-header.js"; +import { languageDirective } from "../../core/i18n.js"; import { extractBlockRefs, getL4Kind } from "../../core/l4.js"; import { viewL5Overview } from "../../core/view.js"; import type { L3Block } from "../../core/l3.js"; @@ -12,6 +14,7 @@ export interface DesignL4StateMachineInput { readonly existingBlocks: readonly L3Block[]; readonly userIntent: string; readonly targetId?: string; + readonly language?: string; } const STATE_MACHINE_SCHEMA_EXAMPLE = `{ @@ -180,7 +183,7 @@ export function buildDesignL4StateMachinePrompt(input: DesignL4StateMachineInput ) .join("\n"); - return [ + return complexityHeader("heavy") + [ `# ${action} L4 StateMachine`, "", "You are designing a state machine (L4) for entity lifecycle management.", @@ -241,5 +244,5 @@ export function buildDesignL4StateMachinePrompt(input: DesignL4StateMachineInput "- Every state should be reachable from `initialState` via transitions", "- Write 'placeholder' for contentHash — rehash will fix it", "- Do NOT create L3 blocks here — only reference them by id", - ].join("\n"); + ].join("\n") + languageDirective(input.language ?? "en"); } diff --git a/packages/skills/prompts/design-l4.ts b/packages/skills/prompts/design-l4.ts index fa7f6d4..07b032d 100644 --- a/packages/skills/prompts/design-l4.ts +++ b/packages/skills/prompts/design-l4.ts @@ -1,6 +1,8 @@ // design-l4 — L4 Flow 设计 prompt 模板 -// 由 slash commands 驱动,不走 SkillRegistry +// 由 slash commands / svp prompt 驱动 +import { complexityHeader } from "./complexity-header.js"; +import { languageDirective } from "../../core/i18n.js"; import { extractBlockRefs, getL4Kind } from "../../core/l4.js"; import { viewL5Overview } from "../../core/view.js"; import type { L3Block } from "../../core/l3.js"; @@ -13,6 +15,7 @@ export interface DesignL4Input { readonly existingBlocks: readonly L3Block[]; readonly userIntent: string; readonly targetFlowId?: string; + readonly language?: string; } const L4_SCHEMA_EXAMPLE = `{ @@ -57,7 +60,7 @@ export function buildDesignL4Prompt(input: DesignL4Input): string { ) .join("\n"); - return [ + return complexityHeader("heavy") + [ `# ${action} L4 Flow`, "", "You are designing a flow (L4) that orchestrates L3 logic blocks.", @@ -107,5 +110,5 @@ export function buildDesignL4Prompt(input: DesignL4Input): string { "- Fan-out uses `parallel` action, join uses `wait` action", "- Write 'placeholder' for contentHash — rehash will fix it", "- Do NOT create L3 blocks here — only reference them by id", - ].join("\n"); + ].join("\n") + languageDirective(input.language ?? "en"); } diff --git a/packages/skills/prompts/design-l5.ts b/packages/skills/prompts/design-l5.ts index 179e207..8b328ab 100644 --- a/packages/skills/prompts/design-l5.ts +++ b/packages/skills/prompts/design-l5.ts @@ -1,11 +1,14 @@ // design-l5 — L5 Blueprint 设计 prompt 模板 -// 由 slash commands 驱动,不走 SkillRegistry +// 由 slash commands / svp prompt 驱动 +import { complexityHeader } from "./complexity-header.js"; +import { languageDirective } from "../../core/i18n.js"; import type { L5Blueprint } from "../../core/l5.js"; export interface DesignL5Input { readonly currentL5?: L5Blueprint; readonly userIntent: string; + readonly language?: string; } const L5_SCHEMA_EXAMPLE = `{ @@ -33,7 +36,7 @@ export function buildDesignL5Prompt(input: DesignL5Input): string { "\n", ); - return [ + return complexityHeader("heavy") + [ `# ${action} L5 Blueprint`, "", "You are designing the top-level blueprint (L5) for an SVP project.", @@ -72,5 +75,5 @@ export function buildDesignL5Prompt(input: DesignL5Input): string { "- Constraints are strings, not objects", "- Domain dependencies reference other domain names", "- Write 'placeholder' for contentHash — rehash will fix it", - ].join("\n"); + ].join("\n") + languageDirective(input.language ?? "en"); } diff --git a/packages/skills/prompts/recompile.ts b/packages/skills/prompts/recompile.ts index 0fbfd8f..e36ccf3 100644 --- a/packages/skills/prompts/recompile.ts +++ b/packages/skills/prompts/recompile.ts @@ -1,25 +1,32 @@ // recompile — L3 变更 → 重编译 L2+L1 prompt 模板 -// 由 SkillRegistry recompile skill 使用 +// 由 svp prompt recompile 使用 -export const RECOMPILE_INSTRUCTIONS = [ - "You are recompiling L1 source code because its L3 contract has changed.", - "", - "## Steps", - "", - "1. Compare the updated L3 contract with the current L2 mapping", - "2. Identify what changed (pins added/removed/renamed, constraints modified, etc.)", - "3. Update only the affected parts of the L1 source code:", - " - If pins changed: update function signature and related logic", - " - If constraints changed: update validation/assertion logic", - " - If description changed: update internal implementation", - "4. Preserve unchanged logic — minimize diff", - "5. Update tests to match the new contract", - "6. Run `svp link --files ` to update the L2 mapping", - "", - "## Important", - "", - "- Do NOT rewrite from scratch — modify incrementally", - "- Keep existing test cases that are still valid", - "- If a pin was renamed, update all usages", - "- If a pin was removed, remove dead code", -].join("\n"); +import { languageDirective } from "../../core/i18n.js"; + +export function recompileInstructions(language = "en"): string { + return [ + "You are recompiling L1 source code because its L3 contract has changed.", + "", + "## Steps", + "", + "1. Compare the updated L3 contract with the current L2 mapping", + "2. Identify what changed (pins added/removed/renamed, constraints modified, etc.)", + "3. Update only the affected parts of the L1 source code:", + " - If pins changed: update function signature and related logic", + " - If constraints changed: update validation/assertion logic", + " - If description changed: update internal implementation", + "4. Preserve unchanged logic — minimize diff", + "5. Update tests to match the new contract", + "6. Run `svp link --files ` to update the L2 mapping", + "", + "## Important", + "", + "- Do NOT rewrite from scratch — modify incrementally", + "- Keep existing test cases that are still valid", + "- If a pin was renamed, update all usages", + "- If a pin was removed, remove dead code", + ].join("\n") + languageDirective(language); +} + +// Backward compat +export const RECOMPILE_INSTRUCTIONS = recompileInstructions(); diff --git a/packages/skills/prompts/review.ts b/packages/skills/prompts/review.ts index 09a990f..3f10d4b 100644 --- a/packages/skills/prompts/review.ts +++ b/packages/skills/prompts/review.ts @@ -1,33 +1,40 @@ // review — L1 漂移审查 prompt 模板 -// 由 SkillRegistry review skill 使用 +// 由 svp prompt review 使用 -export const REVIEW_INSTRUCTIONS = [ - "You are reviewing a drift between L3 contract and L1 implementation.", - "The L1 code was manually modified and no longer matches the L3 spec.", - "", - "## Steps", - "", - "1. Compare L3 contract (pins, validate, constraints) with L1 actual exports", - "2. For each difference, classify as:", - " - **L3 needs update**: The code change was intentional, L3 should be updated to match", - " - **L1 is wrong**: The code change was a mistake, L1 should be reverted", - " - **Cosmetic only**: Formatting/naming difference that doesn't affect interface", - "3. Report findings — do NOT make any changes", - "", - "## Report Format", - "", - "For each difference found:", - "```", - "- [L3/L1/cosmetic] ", - " L3 says: ", - " L1 does: ", - " Recommendation: ", - "```", - "", - "## Important", - "", - "- This is a READ-ONLY review — do not modify any files", - "- Focus on interface compatibility (pins, types, exported functions)", - "- Internal implementation changes that don't affect the interface are cosmetic", - "- If L3 needs update, the user must decide — flag it for human review", -].join("\n"); +import { languageDirective } from "../../core/i18n.js"; + +export function reviewInstructions(language = "en"): string { + return [ + "You are reviewing a drift between L3 contract and L1 implementation.", + "The L1 code was manually modified and no longer matches the L3 spec.", + "", + "## Steps", + "", + "1. Compare L3 contract (pins, validate, constraints) with L1 actual exports", + "2. For each difference, classify as:", + " - **L3 needs update**: The code change was intentional, L3 should be updated to match", + " - **L1 is wrong**: The code change was a mistake, L1 should be reverted", + " - **Cosmetic only**: Formatting/naming difference that doesn't affect interface", + "3. Report findings — do NOT make any changes", + "", + "## Report Format", + "", + "For each difference found:", + "```", + "- [L3/L1/cosmetic] ", + " L3 says: ", + " L1 does: ", + " Recommendation: ", + "```", + "", + "## Important", + "", + "- This is a READ-ONLY review — do not modify any files", + "- Focus on interface compatibility (pins, types, exported functions)", + "- Internal implementation changes that don't affect the interface are cosmetic", + "- If L3 needs update, the user must decide — flag it for human review", + ].join("\n") + languageDirective(language); +} + +// Backward compat +export const REVIEW_INSTRUCTIONS = reviewInstructions(); diff --git a/packages/skills/prompts/update-ref.ts b/packages/skills/prompts/update-ref.ts index 127ba9a..901d2b0 100644 --- a/packages/skills/prompts/update-ref.ts +++ b/packages/skills/prompts/update-ref.ts @@ -1,30 +1,37 @@ // update-ref — L4 断引用修复 prompt 模板 -// 由 SkillRegistry update-ref skill 使用 +// 由 svp prompt update-ref 使用 -export const UPDATE_REF_INSTRUCTIONS = [ - "You are fixing a broken reference in an L4 flow.", - "An L4 step references an L3 block that doesn't exist.", - "", - "## Steps", - "", - "1. Examine the L4 flow and identify which step(s) have broken `blockRef` values", - "2. For each broken reference, decide:", - " - **Option A**: Create the missing L3 block with an appropriate contract", - " - **Option B**: Fix the L4 step to reference an existing L3 block", - "3. If creating a new L3 block:", - " - Infer input/output pins from the L4 dataFlows (upstream output → this block's input)", - " - Write a minimal but useful contract (validate, constraints, description)", - " - Write to `.svp/l3/.json`", - " - Run `svp rehash l3/` to fix contentHash", - "4. If fixing the L4 reference:", - " - Update the step's `blockRef` to point to the correct existing L3 block", - " - Update `dataFlows` if pin names differ", - " - Run `svp rehash l4` to fix contentHash", - "", - "## Important", - "", - "- Prefer creating the missing L3 if the intent is clear from context", - "- Only fix the reference if it's clearly a typo or rename", - "- Do NOT modify other steps — only fix the broken reference", - "- After any change, run `svp check` to verify the fix", -].join("\n"); +import { languageDirective } from "../../core/i18n.js"; + +export function updateRefInstructions(language = "en"): string { + return [ + "You are fixing a broken reference in an L4 flow.", + "An L4 step references an L3 block that doesn't exist.", + "", + "## Steps", + "", + "1. Examine the L4 flow and identify which step(s) have broken `blockRef` values", + "2. For each broken reference, decide:", + " - **Option A**: Create the missing L3 block with an appropriate contract", + " - **Option B**: Fix the L4 step to reference an existing L3 block", + "3. If creating a new L3 block:", + " - Infer input/output pins from the L4 dataFlows (upstream output → this block's input)", + " - Write a minimal but useful contract (validate, constraints, description)", + " - Write to `.svp/l3/.json`", + " - Run `svp rehash l3/` to fix contentHash", + "4. If fixing the L4 reference:", + " - Update the step's `blockRef` to point to the correct existing L3 block", + " - Update `dataFlows` if pin names differ", + " - Run `svp rehash l4` to fix contentHash", + "", + "## Important", + "", + "- Prefer creating the missing L3 if the intent is clear from context", + "- Only fix the reference if it's clearly a typo or rename", + "- Do NOT modify other steps — only fix the broken reference", + "- After any change, run `svp check` to verify the fix", + ].join("\n") + languageDirective(language); +} + +// Backward compat +export const UPDATE_REF_INSTRUCTIONS = updateRefInstructions(); diff --git a/packages/skills/templates/claude-md.ts b/packages/skills/templates/claude-md.ts index 7734145..2f9d854 100644 --- a/packages/skills/templates/claude-md.ts +++ b/packages/skills/templates/claude-md.ts @@ -1,122 +1,11 @@ // claude-md — CLAUDE.md SVP section 生成 // svp init --host claude-code 时追加到 CLAUDE.md +// +// DEPRECATED: This module is a backward-compatible re-export. +// New code should use adapters/claude-code.ts directly. -export function generateClaudeMdSection(_projectName: string): string { - return ` -## SVP — Semantic Voxel Protocol +import { claudeCodeAdapter } from "../adapters/claude-code.js"; -This project uses SVP for structured AI-assisted development. - -### Five-Layer Model - -\`\`\` -L5 Blueprint ─聚合─→ L4 Artifact ─聚合─→ L3 Block ←1:1配对→ L2 Code ─聚合─→ L1 Source -(意图) (流程编排) (契约) (映射) (代码) -\`\`\` - -### .svp/ Directory Structure - -\`\`\` -.svp/ -├── l5.json # L5 Blueprint(全局唯一) -├── l4/ # L4 Artifact files (flow / event-graph / state-machine) -│ └── .json -├── l3/ # L3 Block contracts -│ └── .json -└── l2/ # L2 Code block mappings - └── .json -\`\`\` - -### AI vs Toolchain 作用域 - -| 作用域 | 操作 | 方式 | -|--------|------|------| -| **AI** | 设计 L5/L4/L3 规格 | \`svp prompt design-*\` → subagent | -| **AI** | 编译 L3→L1 代码 | \`svp prompt compile/recompile\` → subagent | -| **AI** | 审查漂移 | \`svp prompt review\` → subagent | -| **AI** | 修复断裂引用 | \`svp prompt update-ref\` → subagent | -| **Toolchain** | 校验一致性 | \`svp check\` | -| **Toolchain** | 渲染层视图 | \`svp view\` | -| **Toolchain** | 生成编译任务列表 | \`svp compile-plan\` | -| **Toolchain** | 创建/更新 L2 映射 | \`svp link\` | -| **Toolchain** | 重算 hash | \`svp rehash\` | - -核心原则:AI 只做需要创造力/判断力的事。机械操作全部交给 toolchain CLI。 - -### Subagent 派发模式 - -\`\`\` -1. 运行 svp prompt [options] 获取提示词 -2. 将 stdout 输出派发给 subagent 执行 -3. Subagent 完成后运行 svp link / svp rehash / svp check -\`\`\` - -### Available CLI Commands - -| Command | Description | -|---|---| -| \`svp view l5/l4/l3/l2\` | View layer data in AI-friendly format | -| \`svp check\` | Validate cross-layer consistency | -| \`svp compile-plan\` | Detect changes and generate recompile tasks | -| \`svp rehash [target]\` | Recompute contentHash + bump revision | -| \`svp link --files \` | Create/update L2 code block mapping | -| \`svp compile\` | Run orchestrator with compile-plan | -| \`svp prompt \` | Generate context-aware AI prompt to stdout | - -### Prompt Commands - -| Command | Description | -|---|---| -| \`svp prompt compile \` | Generate compile prompt for L3→L1 | -| \`svp prompt recompile \` | Generate recompile prompt (L3 changed) | -| \`svp prompt review \` | Generate review prompt (L1 drift) | -| \`svp prompt update-ref \` | Generate fix prompt for broken L4→L3 refs | -| \`svp prompt design-l5 --intent "..."\` | Generate L5 design prompt | -| \`svp prompt design-l4 --intent "..." [--kind flow|event-graph|state-machine]\` | Generate L4 design prompt | -| \`svp prompt design-l3 --flow --step --intent "..."\` | Generate L3 design prompt | - -### Slash Commands - -| Command | When to use | -|---|---| -| \`/svp-build\` | "做一个 X 系统" — full L5→L1 design+compile | -| \`/svp-add\` | "加个 Z 步骤" — add feature to existing system | -| \`/svp-change\` | "把 X 改成 Y" — modify existing requirement | -| \`/svp-fix\` | "修复 check 问题" — auto-fix consistency issues | - -Toolchain 操作直接运行 CLI:\`svp check\`、\`svp view l3\` 等。 - -### Core Rules - -1. **Layered penetration**: Work top-down only. Never modify upper layers to fix lower layers. -2. **Context isolation**: Main agent stays at L5+L4 (~30 lines). L3 design and L2+L1 compile dispatched to subagents. -3. **Hash management**: Write \`"placeholder"\` for contentHash in JSON. Run \`svp rehash\` to fix. -4. **L2 creation**: After generating L1 code, run \`svp link --files \` to create L2 mapping. -5. **Verification**: Run \`svp check\` after each layer to ensure consistency. - -### L3 Contract Box Model - -\`\`\` -validate → constrains INPUT (natural language rules per field path) -constraints → constrains OUTPUT (natural language assertions) -description → describes the MIDDLE (transformation logic) -\`\`\` - -### JSON Schema Quick Reference - -**L5Blueprint**: \`{ id, name, version, intent, constraints[], domains[], integrations[], contentHash, revision }\` -**L4Flow**: \`{ kind?: "flow", id, name, trigger?, steps[], dataFlows[], contentHash, revision }\` -**L4EventGraph**: \`{ kind: "event-graph", id, name, state: {key: {type, description}}, handlers: [{id, event, steps[], dataFlows[]}], contentHash, revision }\` -**L4StateMachine**: \`{ kind: "state-machine", id, name, entity, initialState, states: {name: {onEntry?, onExit?}}, transitions: [{from, to, event, guard?}], contentHash, revision }\` -**L3Block**: \`{ id, name, input: Pin[], output: Pin[], validate: {}, constraints[], description, contentHash, revision }\` -**L2CodeBlock**: \`{ id, blockRef, language, files[], sourceHash, contentHash, signatureHash?, revision }\` - -### L4 Variant Selection Guide - -| Variant | \`kind\` | Use when | -|---|---|---| -| **Flow** | \`"flow"\` (default) | Request-response pipeline: trigger → step chain → result | -| **EventGraph** | \`"event-graph"\` | Event-driven / reactive: shared state + multiple event handlers | -| **StateMachine** | \`"state-machine"\` | Entity lifecycle: states + transitions + guards | -`.trim(); +export function generateClaudeMdSection(_projectName: string, language = "en"): string { + return claudeCodeAdapter.generateContextSection(_projectName, language); } diff --git a/packages/skills/templates/slash-commands.ts b/packages/skills/templates/slash-commands.ts index 4b30c2f..31a39b4 100644 --- a/packages/skills/templates/slash-commands.ts +++ b/packages/skills/templates/slash-commands.ts @@ -1,210 +1,18 @@ -// slash-commands — 4 个 AI 驱动 slash command 模板 +// slash-commands — unified /svp slash command template // svp init --host claude-code 时生成到 .claude/commands/ // 纯 toolchain 操作(check/view)由用户直接运行 CLI,不需要 slash command +// +// DEPRECATED: This module is a backward-compatible re-export. +// New code should use adapters/claude-code.ts directly. + +import { claudeCodeAdapter } from "../adapters/claude-code.js"; export interface SlashCommandTemplate { readonly filename: string; readonly content: string; } -export function generateSlashCommands(): readonly SlashCommandTemplate[] { - return [svpBuild, svpAdd, svpChange, svpFix]; +export function generateSlashCommands(language = "en"): readonly SlashCommandTemplate[] { + const files = claudeCodeAdapter.generateSkillFiles(language); + return files.map((f) => ({ filename: f.relativePath, content: f.content })); } - -// ── /svp-build — 从零构建整个系统 ── - -const svpBuild: SlashCommandTemplate = { - filename: "svp-build.md", - content: `你是 SVP 编译器。用户描述了一个系统意图,你从 L5 到 L1 完整设计并实现。 - -## 核心原则 -- **上下文隔离**:主 Agent 只维护 L5+L4(~30 行上下文)。L3/L1 通过 subagent 完成。 -- **Subagent 派发模式**:运行 \`svp prompt \` 获取提示词 → 派发 subagent → 后续跑 toolchain 命令。 - -## Step 1: [AI] 设计 L5 Blueprint -- 运行 \`svp prompt design-l5 --intent "<用户意图>"\` -- 将 stdout 输出派发给 subagent -- Subagent 输出 L5 JSON → 写入 .svp/l5.json -- [Toolchain] 运行 \`svp rehash l5\` -- 展示 \`svp view l5\` 给用户确认 - -## Step 2: [AI] 设计 L4 Artifacts -根据系统类型选择 L4 变体: -- **Flow** (默认): 请求-响应 pipeline → \`svp prompt design-l4 --intent "..."\` -- **EventGraph**: 事件驱动/CRDT → \`svp prompt design-l4 --kind event-graph --intent "..."\` -- **StateMachine**: 实体生命周期 → \`svp prompt design-l4 --kind state-machine --intent "..."\` - -- 将 stdout 输出派发给 subagent -- Subagent 输出 L4 JSON → 写入 .svp/l4/.json -- [Toolchain] 运行 \`svp rehash l4\` -- 展示 \`svp view l4\` 给用户确认 - -## Step 3: [AI] 设计 L3 Contracts(并行派发) -对每个 L4 step 的 blockRef: -- 运行 \`svp prompt design-l3 --flow --step --intent "..."\` -- 将 stdout 输出派发给 subagent -- Subagent 输出 L3 JSON → 写入 .svp/l3/.json -- [Toolchain] 运行 \`svp rehash l3/\` -- **无依赖的 block 并行派发** - -## Step 4: [Toolchain] 获取编译任务 -- 运行 \`svp compile-plan\` 获取编译任务列表 - -## Step 5: [AI] 编译 L1 代码(并行派发) -对每个 compile 任务: -- 运行 \`svp prompt compile \` -- 将 stdout 输出派发给 subagent -- Subagent 生成 src/.ts 代码文件 -- **无依赖的任务并行派发** - -## Step 6: [Toolchain] 创建 L2 映射 -- 对每个生成的文件运行 \`svp link --files src/.ts\` - -## Step 7: [Toolchain] 验证 -- 运行 \`svp check\` 验证全部层一致性 -- 如有问题,定位到对应层修复 -- 重复直到 check 通过 - -## 规则 -- 严格单向向下,不回头修改上层 -- 主 Agent 不读 L1 代码——上下文隔离是核心价值 -- 每层完成后展示给用户确认 -- 做不到就报错,说清哪层什么问题——用户是反向反馈回路 -- JSON 中 contentHash 和 revision 写占位值,rehash 会修正 -- 尽量并行派发无依赖的 subagent - -$ARGUMENTS`, -}; - -// ── /svp-add — 向已有系统添加功能 ── - -const svpAdd: SlashCommandTemplate = { - filename: "svp-add.md", - content: `你是 SVP 编译器。用户想在现有系统中添加新功能。 - -## Step 1: [Toolchain] 了解当前结构 -- 运行 \`svp view l5\` 和 \`svp view l4/\` 了解现有架构 -- 确定新功能属于哪个 L4 flow(或需要新 flow) - -## Step 2: [AI] 修改 L4 Flow -- 编辑对应的 .svp/l4/.json,添加新 step + blockRef -- 新 step 的 blockRef 指向尚不存在的 L3 block id -- 更新 dataFlows 连接新 step -- [Toolchain] 运行 \`svp rehash l4\` -- 展示 \`svp view l4/\` 给用户确认 - -## Step 3: [AI] 设计新 L3 Contract -- 运行 \`svp prompt design-l3 --flow --step --intent "..."\` -- 将 stdout 输出派发给 subagent -- Subagent 创建 .svp/l3/.json -- [Toolchain] 运行 \`svp rehash l3/\` - -## Step 4: [AI] 编译新代码 -- 运行 \`svp prompt compile \` -- 将 stdout 输出派发给 subagent -- Subagent 生成 L1 源代码 - -## Step 5: [Toolchain] 创建映射并验证 -- \`svp link --files \` -- \`svp check\` 确认全绿 - -## 规则 -- 只处理新增的部分,不修改已有 block -- 严格单向向下 -- 上下文隔离:主 Agent 不碰 L1 - -$ARGUMENTS`, -}; - -// ── /svp-change — 修改已有需求 ── - -const svpChange: SlashCommandTemplate = { - filename: "svp-change.md", - content: `你是 SVP 编译器。定位受影响层级,从该层向下重编译。 - -## Step 1: [Toolchain] 诊断当前状态 -- 运行 \`svp check\` 确认当前一致性状态 -- 运行 \`svp view l5\` + \`svp view l4\` + \`svp view l3\` 了解结构 - -## Step 2: 判断变更层级 -- 系统意图变了 → L5 -- 流程编排变了 → L4 -- 契约规则变了 → L3 -- 代码变了 → L1(只报 drift,不自动修改上层) -- 越低层介入越精确越便宜 - -## Step 3: [AI] 执行修改 -- L5 变更:编辑 .svp/l5.json → \`svp rehash l5\` -- L4 变更:编辑 .svp/l4/.json → \`svp rehash l4\` -- L3 变更:编辑 .svp/l3/.json → \`svp rehash l3/\` -- 展示给用户确认 - -## Step 4: [Toolchain] 获取受影响任务 -- 运行 \`svp compile-plan\` 获取受影响实体的重编译任务列表 - -## Step 5: [AI] 重编译受影响代码 -对每个 recompile 任务: -- 运行 \`svp prompt recompile \` -- 将 stdout 输出派发给 subagent -- Subagent 更新 L1 代码 - -## Step 6: [Toolchain] 更新映射并验证 -- \`svp link --files \` -- \`svp check\` 确认全绿 - -## 规则 -- 严格单向向下,不回头修改上层 -- 定位到最高受影响层,从那里开始 -- 做不到就报错——用户是反向反馈回路 - -$ARGUMENTS`, -}; - -// ── /svp-fix — 自动修复 check 发现的问题 ── - -const svpFix: SlashCommandTemplate = { - filename: "svp-fix.md", - content: `你是 SVP 修复工具。运行 check 并按问题类型逐个修复。 - -## Step 1: [Toolchain] 诊断 -- 运行 \`svp check --json\` 获取结构化问题列表 - -## Step 2: 按 issueCode 分类处理 - -### HASH_MISMATCH -- [Toolchain] 运行 \`svp rehash\` 修正 hash(通常是 AI 编辑后忘了 rehash) - -### MISSING_L2 -- [AI] 运行 \`svp prompt compile \` -- 将 stdout 输出派发给 subagent 生成代码 -- [Toolchain] 运行 \`svp link --files \` - -### SOURCE_DRIFT -- [AI] 运行 \`svp prompt recompile \` -- 将 stdout 输出派发给 subagent 更新代码 - -### CONTENT_DRIFT -- [AI] 运行 \`svp prompt review \` -- 将 stdout 输出派发给 subagent 判断: - - L3 需要更新?还是 L1 需要修复? - - 向用户展示 subagent 的分析结果 - -### MISSING_BLOCK_REF -- [AI] 运行 \`svp prompt update-ref \` -- 将 stdout 输出派发给 subagent 判断: - - 创建缺失的 L3 contract?还是修复 L4 step 引用? - -### ORPHAN_STEP / NEXT_CYCLE -- 图结构问题 → 提示用户手动修复 L4 JSON - -## Step 3: [Toolchain] 验证 -- 重新运行 \`svp check\` 确认修复有效 -- 重复直到全绿 - -## 规则 -- 每次只修一类问题,验证后再继续 -- CONTENT_DRIFT 和 ORPHAN_STEP 需要用户决策,不自动处理 -- 使用 \`svp prompt\` 获取提示词后派发 subagent 执行 AI 操作 - -$ARGUMENTS`, -}; diff --git a/tests/e2e/design-l3-all-kinds.ts b/tests/e2e/design-l3-all-kinds.ts new file mode 100644 index 0000000..5231d29 --- /dev/null +++ b/tests/e2e/design-l3-all-kinds.ts @@ -0,0 +1,422 @@ +#!/usr/bin/env tsx +// design-l3-all-kinds.ts — E2E test: design-l3 for all L4 kinds (flow, event-graph, state-machine) +// +// Runs `svp prompt design-l3` against an existing project, then pipes each prompt to Claude +// to actually generate L3 blocks. Verifies that valid L3 JSON is produced for every blockRef +// across all three L4 kinds. +// +// Usage: +// npx tsx tests/e2e/design-l3-all-kinds.ts --dry-run # prompt generation only +// npx tsx tests/e2e/design-l3-all-kinds.ts # full run with Claude +// npx tsx tests/e2e/design-l3-all-kinds.ts --project examples/other-project # custom project +// npx tsx tests/e2e/design-l3-all-kinds.ts --model haiku # cheaper model +// npx tsx tests/e2e/design-l3-all-kinds.ts --budget 3.0 --step-budget 0.30 # budget caps +// npx tsx tests/e2e/design-l3-all-kinds.ts --pick 1 # 1 block per kind (smoke test) + +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + type PhaseResult, + type StepResult, + buildReport, + copyDir, + fmtMs, + getFlagVal, + hasFlag, + listJsonFiles, + readJson, + renderReport, + runClaude, + runSvp, + timer, +} from "./helpers.js"; + +// ── Types ── + +interface L4BlockTarget { + l4Id: string; + kind: string; + blockId: string; + location: string; +} + +interface CLIOptions { + projectPath: string; + dryRun: boolean; + model: string; + budgetUsd: number; + stepBudgetUsd: number; + pick: number; // 0 = all, N = pick N per kind +} + +// ── CLI args ── + +function parseArgs(): CLIOptions { + const rel = getFlagVal("--project", "examples/cockatiel-resilience"); + const projectPath = path.resolve(import.meta.dirname, "../..", rel); + + return { + projectPath, + dryRun: hasFlag("--dry-run"), + model: getFlagVal("--model", "sonnet"), + budgetUsd: Number.parseFloat(getFlagVal("--budget", "0")), + stepBudgetUsd: Number.parseFloat(getFlagVal("--step-budget", "0")), + pick: Number.parseInt(getFlagVal("--pick", "0"), 10), + }; +} + +// ── Extract all blockRef targets from all L4 kinds ── + +function discoverTargets(projectPath: string): L4BlockTarget[] { + const l4Dir = path.join(projectPath, ".svp", "l4"); + const files = listJsonFiles(l4Dir); + const targets: L4BlockTarget[] = []; + const seen = new Set(); + + for (const file of files) { + const l4 = readJson(path.join(l4Dir, file)); + const kind: string = l4.kind ?? "flow"; + + if (kind === "flow" && Array.isArray(l4.steps)) { + for (let i = 0; i < l4.steps.length; i++) { + const step = l4.steps[i]; + if (step.blockRef && !seen.has(step.blockRef)) { + seen.add(step.blockRef); + targets.push({ l4Id: l4.id, kind, blockId: step.blockRef, location: `step ${String(i)}` }); + } + } + } else if (kind === "event-graph" && Array.isArray(l4.handlers)) { + for (const handler of l4.handlers) { + if (!Array.isArray(handler.steps)) continue; + for (let i = 0; i < handler.steps.length; i++) { + const step = handler.steps[i]; + if (step.blockRef && !seen.has(step.blockRef)) { + seen.add(step.blockRef); + targets.push({ l4Id: l4.id, kind, blockId: step.blockRef, location: `handler "${handler.id}" step ${String(i)}` }); + } + } + } + } else if (kind === "state-machine") { + if (l4.states) { + for (const [stateName, config] of Object.entries(l4.states) as [string, any][]) { + if (config.onEntry?.blockRef && !seen.has(config.onEntry.blockRef)) { + seen.add(config.onEntry.blockRef); + targets.push({ l4Id: l4.id, kind, blockId: config.onEntry.blockRef, location: `state "${stateName}" onEntry` }); + } + if (config.onExit?.blockRef && !seen.has(config.onExit.blockRef)) { + seen.add(config.onExit.blockRef); + targets.push({ l4Id: l4.id, kind, blockId: config.onExit.blockRef, location: `state "${stateName}" onExit` }); + } + } + } + if (Array.isArray(l4.transitions)) { + for (const t of l4.transitions) { + if (t.guard && !seen.has(t.guard)) { + seen.add(t.guard); + targets.push({ l4Id: l4.id, kind, blockId: t.guard, location: `transition "${t.from}" → "${t.to}" guard` }); + } + } + } + } + } + + return targets; +} + +/** Pick N targets per kind for smoke testing */ +function pickPerKind(targets: L4BlockTarget[], n: number): L4BlockTarget[] { + if (n <= 0) return targets; + const byKind = new Map(); + for (const t of targets) { + const list = byKind.get(t.kind) ?? []; + list.push(t); + byKind.set(t.kind, list); + } + const picked: L4BlockTarget[] = []; + for (const list of byKind.values()) { + picked.push(...list.slice(0, n)); + } + return picked; +} + +// ── Phases ── + +function phase1_discover(targets: L4BlockTarget[]): PhaseResult { + const kindCounts = new Map(); + for (const t of targets) { + kindCounts.set(t.kind, (kindCounts.get(t.kind) ?? 0) + 1); + } + + const details = [`Found ${String(targets.length)} blockRefs across ${String(kindCounts.size)} L4 kind(s)`]; + for (const [kind, count] of kindCounts) { + details.push(` ${kind}: ${String(count)} block(s)`); + } + + const kinds = new Set(targets.map((t) => t.kind)); + const errors: string[] = []; + if (!kinds.has("flow") || !kinds.has("event-graph") || !kinds.has("state-machine")) { + errors.push(`Expected all 3 L4 kinds but found: [${[...kinds].join(", ")}]`); + } + + return { + phase: 1, + name: "Discover", + ok: targets.length > 0, + durationMs: 0, + steps: [{ name: "discover blockRefs", ok: targets.length > 0, durationMs: 0, details, errors }], + }; +} + +function phase2_designL3( + targets: L4BlockTarget[], + workDir: string, + opts: CLIOptions, +): PhaseResult { + const elapsed = timer(); + const steps: StepResult[] = []; + let totalCost = 0; + + for (const target of targets) { + const stepElapsed = timer(); + const intent = `Design the "${target.blockId}" block for the cockatiel resilience library`; + + // 1. Generate prompt + const promptResult = runSvp( + `prompt design-l3 ${target.blockId} --intent "${intent}" --flow ${target.l4Id} -r "${workDir}"`, + workDir, + ); + + if (!promptResult.ok) { + steps.push({ + name: `design-l3 ${target.blockId}`, + ok: false, + durationMs: stepElapsed(), + details: [`kind: ${target.kind}`, `location: ${target.location}`], + errors: [`prompt generation failed: ${promptResult.stderr.trim().split("\n")[0]}`], + }); + console.log(` [FAIL] ${target.blockId} — prompt generation failed`); + continue; + } + + const prompt = promptResult.stdout; + + // Basic sanity checks on prompt + const promptErrors: string[] = []; + if (!prompt.includes(target.blockId)) promptErrors.push("prompt missing blockId"); + if (!prompt.includes("L3 Contract")) promptErrors.push("prompt missing header"); + if (prompt.length < 200) promptErrors.push("prompt too short"); + + if (promptErrors.length > 0) { + steps.push({ + name: `design-l3 ${target.blockId}`, + ok: false, + durationMs: stepElapsed(), + details: [`kind: ${target.kind}`, `location: ${target.location}`, `prompt: ${String(prompt.length)} chars`], + errors: promptErrors, + }); + console.log(` [FAIL] ${target.blockId} — ${promptErrors.join("; ")}`); + continue; + } + + // 2. In dry-run, stop here + if (opts.dryRun) { + steps.push({ + name: `design-l3 ${target.blockId}`, + ok: true, + durationMs: stepElapsed(), + details: [`kind: ${target.kind}`, `location: ${target.location}`, `prompt: ${String(prompt.length)} chars`], + errors: [], + }); + console.log(` [PASS] ${target.blockId} — ${String(prompt.length)} chars (${target.kind})`); + continue; + } + + // 3. Budget check (0 = no limit) + if (opts.budgetUsd > 0 && totalCost >= opts.budgetUsd) { + steps.push({ + name: `design-l3 ${target.blockId}`, + ok: false, + durationMs: stepElapsed(), + details: [`kind: ${target.kind}`], + errors: [`budget exceeded ($${totalCost.toFixed(2)} >= $${opts.budgetUsd.toFixed(2)})`], + }); + console.log(` [SKIP] ${target.blockId} — budget exceeded`); + continue; + } + + // 4. Call Claude + console.log(` [....] ${target.blockId} (${target.kind}) — calling Claude...`); + const claudeResult = runClaude(prompt, opts.model, workDir, opts.stepBudgetUsd, opts); + totalCost += claudeResult.costUsd; + + const details: string[] = [ + `kind: ${target.kind}`, + `location: ${target.location}`, + `prompt: ${String(prompt.length)} chars`, + ]; + const errors: string[] = []; + + if (!claudeResult.ok) { + details.push(`Claude error: ${claudeResult.error ?? "unknown"}`); + } else { + details.push(`tokens: ${String(claudeResult.inputTokens)} in / ${String(claudeResult.outputTokens)} out`); + details.push(`cost: $${claudeResult.costUsd.toFixed(4)}`); + } + + // 5. Verify L3 was created (treat as pass if L3 is valid even when Claude timed out) + const l3Path = path.join(workDir, ".svp", "l3", `${target.blockId}.json`); + let l3Valid = false; + if (!existsSync(l3Path)) { + errors.push(claudeResult.ok ? "L3 file not created by Claude" : `Claude failed: ${claudeResult.error ?? "unknown"}`); + } else { + try { + const l3 = readJson(l3Path); + const required = ["id", "name", "input", "output"]; + const missing = required.filter((f) => l3[f] === undefined); + if (missing.length > 0) { + errors.push(`L3 missing fields: ${missing.join(", ")}`); + } else { + details.push(`L3 created with ${String(Object.keys(l3).length)} fields`); + l3Valid = true; + } + } catch (e: any) { + errors.push(`L3 parse error: ${e.message}`); + } + } + + // Pass if L3 is valid — even if Claude process timed out after writing the file + const ok = l3Valid && errors.length === 0; + steps.push({ + name: `design-l3 ${target.blockId}`, + ok, + durationMs: stepElapsed(), + claudeResult, + details, + errors, + }); + + if (ok) { + console.log(` [PASS] ${target.blockId} — L3 created ($${claudeResult.costUsd.toFixed(4)}, ${fmtMs(claudeResult.durationMs)})`); + } else { + console.log(` [FAIL] ${target.blockId} — ${errors[0] ?? "unknown"}`); + } + } + + const ok = steps.every((s) => s.ok); + return { phase: 2, name: "Design L3", ok, durationMs: elapsed(), steps }; +} + +// ── Main ── + +function main(): void { + const opts = parseArgs(); + + console.log("=== design-l3 All Kinds E2E Test ==="); + console.log(`Project: ${opts.projectPath}`); + console.log(`Mode: ${opts.dryRun ? "DRY RUN (prompt only)" : "FULL RUN (Claude calls)"}`); + if (!opts.dryRun) { + console.log(`Model: ${opts.model}`); + console.log(`Budget: $${opts.budgetUsd.toFixed(2)} total, $${opts.stepBudgetUsd.toFixed(2)}/step`); + } + if (opts.pick > 0) console.log(`Pick: ${String(opts.pick)} block(s) per kind`); + console.log(); + + // Discover targets + let targets = discoverTargets(opts.projectPath); + if (opts.pick > 0) targets = pickPerKind(targets, opts.pick); + + // Phase 1: Discover + const p1 = phase1_discover(targets); + for (const d of p1.steps[0].details) console.log(d); + console.log(); + + if (!p1.ok) { + console.error("No blockRefs found — nothing to test."); + process.exitCode = 1; + return; + } + + // Copy project to temp dir so Claude writes don't pollute the source + let workDir: string; + if (opts.dryRun) { + workDir = opts.projectPath; + } else { + const tmpDir = mkdtempSync(path.join(tmpdir(), "svp-l3-e2e-")); + copyDir(path.join(opts.projectPath, ".svp"), path.join(tmpDir, ".svp")); + workDir = tmpDir; + console.log(`Working directory: ${workDir}`); + console.log(); + } + + // Phase 2: Design L3 + const p2 = phase2_designL3(targets, workDir, opts); + + const phases: PhaseResult[] = [p1, p2]; + + // ── Summary ── + console.log(); + console.log("═".repeat(90)); + + const maxBlockId = Math.max(...p2.steps.map((s) => s.name.replace("design-l3 ", "").length)); + + for (const step of p2.steps) { + const icon = step.ok ? "PASS" : "FAIL"; + const blockId = step.name.replace("design-l3 ", ""); + const kindDetail = step.details.find((d) => d.startsWith("kind:")) ?? ""; + const locDetail = step.details.find((d) => d.startsWith("location:")) ?? ""; + const blockPad = " ".repeat(Math.max(maxBlockId - blockId.length, 0)); + + let summary: string; + if (step.ok && step.claudeResult) { + summary = `L3 OK $${step.claudeResult.costUsd.toFixed(4)}`; + } else if (step.ok) { + const promptDetail = step.details.find((d) => d.startsWith("prompt:")) ?? ""; + summary = promptDetail || "OK"; + } else { + summary = step.errors[0] ?? "unknown error"; + } + + console.log(` [${icon}] ${blockId}${blockPad} ${kindDetail} ${locDetail} → ${summary}`); + } + + console.log("═".repeat(90)); + console.log(); + + // Per-kind summary + const passed = p2.steps.filter((s) => s.ok).length; + const failed = p2.steps.filter((s) => !s.ok).length; + console.log(`Total: ${String(p2.steps.length)} Passed: ${String(passed)} Failed: ${String(failed)}`); + + for (const kind of ["flow", "event-graph", "state-machine"]) { + const kindSteps = p2.steps.filter((s) => s.details.some((d) => d === `kind: ${kind}`)); + if (kindSteps.length === 0) continue; + const kindPassed = kindSteps.filter((s) => s.ok).length; + console.log(` ${kind}: ${String(kindPassed)}/${String(kindSteps.length)} passed`); + } + + // Write report (same format as svp-workflow.ts) + if (!opts.dryRun) { + const report = buildReport(phases, workDir, opts.dryRun); + const markdown = renderReport(report); + const reportDir = path.resolve(import.meta.dirname); + const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const reportPath = path.join(reportDir, `report-l3-${ts}.md`); + writeFileSync(reportPath, markdown, "utf8"); + const jsonPath = path.join(reportDir, `report-l3-${ts}.json`); + writeFileSync(jsonPath, JSON.stringify(report, null, 2), "utf8"); + console.log(`\nReport: ${reportPath}`); + console.log(`Working directory: ${workDir}`); + } + + console.log(); + if (failed > 0) { + console.log("FAILED"); + process.exitCode = 1; + } else { + console.log("ALL PASSED"); + } +} + +main(); diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts new file mode 100644 index 0000000..ce58537 --- /dev/null +++ b/tests/e2e/helpers.ts @@ -0,0 +1,339 @@ +// helpers.ts — Shared utilities for E2E tests +// +// Provides: CLI arg parsing, SVP/Claude runners, timer, formatters, JSON/dir helpers. + +import { execSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +// ── Types ── + +export interface ClaudeResult { + ok: boolean; + output: string; + costUsd: number; + inputTokens: number; + outputTokens: number; + durationMs: number; + model: string; + error?: string; +} + +export interface StepResult { + name: string; + ok: boolean; + durationMs: number; + claudeResult?: ClaudeResult; + details: string[]; + errors: string[]; +} + +export interface PhaseResult { + phase: number; + name: string; + ok: boolean; + durationMs: number; + steps: StepResult[]; +} + +export interface TestReport { + timestamp: string; + projectDir: string; + dryRun: boolean; + phases: PhaseResult[]; + totals: { + phases: number; + passed: number; + failed: number; + durationMs: number; + inputTokens: number; + outputTokens: number; + costUsd: number; + }; +} + +// ── Constants ── + +export const SVP_CLI = path.resolve(import.meta.dirname, "../../packages/cli/index.ts"); + +// ── Timer / Formatters ── + +export function timer(): () => number { + const start = performance.now(); + return () => Math.round(performance.now() - start); +} + +export function fmtMs(ms: number): string { + if (ms < 1000) return `${String(ms)}ms`; + const s = (ms / 1000).toFixed(1); + return `${s}s`; +} + +// ── CLI arg parsing helpers ── + +export function hasFlag(flag: string): boolean { + return process.argv.slice(2).includes(flag); +} + +export function getFlagVal(flag: string, fallback: string): string { + const args = process.argv.slice(2); + const idx = args.indexOf(flag); + return idx >= 0 && idx + 1 < args.length ? args[idx + 1] : fallback; +} + +// ── SVP CLI runner ── + +export function runSvp(args: string, cwd: string): { ok: boolean; stdout: string; stderr: string } { + const cmd = `npx tsx ${SVP_CLI} ${args}`; + try { + const stdout = execSync(cmd, { + cwd, + encoding: "utf8", + timeout: 30_000, + stdio: ["pipe", "pipe", "pipe"], + }); + return { ok: true, stdout, stderr: "" }; + } catch (e: any) { + return { + ok: e.status === 0, + stdout: e.stdout ?? "", + stderr: e.stderr ?? e.message ?? "unknown error", + }; + } +} + +// ── Claude runner ── + +export function runClaude( + prompt: string, + model: string, + cwd: string, + budgetUsd: number, + opts: { dryRun: boolean }, +): ClaudeResult { + if (opts.dryRun) { + return { + ok: true, + output: "[dry-run] Claude call skipped", + costUsd: 0, + inputTokens: 0, + outputTokens: 0, + durationMs: 0, + model, + }; + } + + const elapsed = timer(); + + const promptFile = path.join(cwd, ".svp-e2e-prompt.tmp"); + writeFileSync(promptFile, prompt, "utf8"); + + const parts = [ + `cat "${promptFile}" |`, + "claude -p", + `--model ${model}`, + "--permission-mode bypassPermissions", + "--output-format json", + ]; + if (budgetUsd > 0) { + parts.push(`--max-budget-usd ${String(budgetUsd)}`); + } + const cmd = parts.join(" "); + + try { + const raw = execSync(cmd, { + cwd, + encoding: "utf8", + timeout: 600_000, + shell: "/bin/bash", + stdio: ["pipe", "pipe", "pipe"], + maxBuffer: 10 * 1024 * 1024, + }); + + const ms = elapsed(); + + let parsed: any = {}; + for (const line of raw.split("\n").reverse()) { + const trimmed = line.trim(); + if (trimmed.startsWith("{")) { + try { + parsed = JSON.parse(trimmed); + break; + } catch { /* continue */ } + } + } + + const usage = parsed.usage ?? {}; + const isError = parsed.is_error === true; + + return { + ok: !isError, + output: parsed.result ?? raw.slice(0, 2000), + costUsd: parsed.total_cost_usd ?? 0, + inputTokens: (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0), + outputTokens: usage.output_tokens ?? 0, + durationMs: ms, + model, + error: isError ? (parsed.result ?? "Claude returned is_error=true") : undefined, + }; + } catch (e: any) { + return { + ok: false, + output: "", + costUsd: 0, + inputTokens: 0, + outputTokens: 0, + durationMs: elapsed(), + model, + error: (e.stderr ?? e.message ?? "unknown").slice(0, 1000), + }; + } +} + +// ── Prompt helpers ── + +/** Extract complexity from YAML front-matter: ---\ncomplexity: \n--- */ +export function parseComplexity(prompt: string): string { + const match = prompt.match(/^---\s*\ncomplexity:\s*(\w+)\s*\n---/); + return match?.[1] ?? "standard"; +} + +export function mapModel(complexity: string, allSonnet: boolean): string { + if (allSonnet) return "sonnet"; + switch (complexity) { + case "heavy": + return "opus"; + case "standard": + return "sonnet"; + case "light": + return "haiku"; + default: + return "sonnet"; + } +} + +// ── File/JSON helpers ── + +export function readJson(filePath: string): any { + return JSON.parse(readFileSync(filePath, "utf8")); +} + +export function listJsonFiles(dirPath: string): string[] { + if (!existsSync(dirPath)) return []; + return readdirSync(dirPath).filter((f) => f.endsWith(".json")); +} + +/** Recursively copy a directory tree */ +export function copyDir(src: string, dst: string): void { + mkdirSync(dst, { recursive: true }); + for (const entry of readdirSync(src, { withFileTypes: true })) { + const srcPath = path.join(src, entry.name); + const dstPath = path.join(dst, entry.name); + if (entry.isDirectory()) { + copyDir(srcPath, dstPath); + } else { + copyFileSync(srcPath, dstPath); + } + } +} + +// ── Report helpers ── + +export function buildReport(phases: PhaseResult[], projectDir: string, dryRun: boolean): TestReport { + let inputTokens = 0; + let outputTokens = 0; + let costUsd = 0; + let totalMs = 0; + + for (const phase of phases) { + totalMs += phase.durationMs; + for (const step of phase.steps) { + if (step.claudeResult) { + inputTokens += step.claudeResult.inputTokens; + outputTokens += step.claudeResult.outputTokens; + costUsd += step.claudeResult.costUsd; + } + } + } + + return { + timestamp: new Date().toISOString(), + projectDir, + dryRun, + phases, + totals: { + phases: phases.length, + passed: phases.filter((p) => p.ok).length, + failed: phases.filter((p) => !p.ok).length, + durationMs: totalMs, + inputTokens, + outputTokens, + costUsd, + }, + }; +} + +export function renderReport(report: TestReport): string { + const lines: string[] = []; + const w = (s: string) => lines.push(s); + + w("# SVP E2E Test Report"); + w(`Generated: ${report.timestamp}`); + if (report.dryRun) w("\n> **DRY RUN** — Claude Code calls were skipped"); + w(""); + + w("## Summary"); + w(""); + w("| Metric | Value |"); + w("|--------|-------|"); + w(`| Total phases | ${String(report.totals.phases)} |`); + w(`| Passed | ${String(report.totals.passed)} |`); + w(`| Failed | ${String(report.totals.failed)} |`); + w(`| Total time | ${fmtMs(report.totals.durationMs)} |`); + w(`| Input tokens | ${String(report.totals.inputTokens).replace(/\B(?=(\d{3})+(?!\d))/g, ",")} |`); + w(`| Output tokens | ${String(report.totals.outputTokens).replace(/\B(?=(\d{3})+(?!\d))/g, ",")} |`); + w(`| Total cost | $${report.totals.costUsd.toFixed(4)} |`); + w(`| Project dir | \`${report.projectDir}\` |`); + w(""); + + w("## Phase Results"); + w(""); + + for (const phase of report.phases) { + const icon = phase.ok ? "\u2705" : "\u274C"; + w(`### Phase ${String(phase.phase)}: ${phase.name} ${icon} (${fmtMs(phase.durationMs)})`); + w(""); + + for (const step of phase.steps) { + const stepIcon = step.ok ? "\u2705" : "\u274C"; + w(`**${step.name}** ${stepIcon} (${fmtMs(step.durationMs)})`); + + if (step.claudeResult) { + w(`- Model: ${step.claudeResult.model}`); + if (step.claudeResult.costUsd > 0) { + w(`- Cost: $${step.claudeResult.costUsd.toFixed(4)}`); + } + } + + for (const d of step.details) { + w(`- ${d}`); + } + for (const e of step.errors) { + w(`- \u26A0\uFE0F ${e}`); + } + w(""); + } + } + + return lines.join("\n"); +} + +export function buildSkippedPhase(phase: number, name: string): PhaseResult { + return { + phase, + name, + ok: false, + durationMs: 0, + steps: [{ name: "skipped", ok: false, durationMs: 0, details: ["Skipped due to earlier failure or budget"], errors: [] }], + }; +} diff --git a/tests/e2e/svp-workflow-zh.ts b/tests/e2e/svp-workflow-zh.ts new file mode 100644 index 0000000..f02984f --- /dev/null +++ b/tests/e2e/svp-workflow-zh.ts @@ -0,0 +1,805 @@ +#!/usr/bin/env tsx +// svp-workflow-zh.ts — E2E 测试: 全中文 SVP 设计→实现工作流(通过 Claude Code CLI) +// +// 与 svp-workflow.ts 相同的完整流程,但使用中文 intent 并设置 language=zh: +// 阶段 1: 初始化 → svp init --language zh +// 阶段 2: 设计 L5 → svp prompt design-l5 → claude -p → 验证 l5.json +// 阶段 3: 设计 L4 → svp prompt design-l4 → claude -p → 验证 l4/*.json +// 阶段 4: 设计 L3 → svp prompt design-l3 (逐步) → claude -p → 验证 l3/*.json +// 阶段 5: 检查 → svp check → 跨层一致性校验 +// 阶段 6: 编译 → svp prompt compile (逐个 l3) → claude -p → 验证 l2/*.json + 源码 +// 阶段 7: 报告 → markdown 摘要 +// +// 用法: +// npx tsx tests/e2e/svp-workflow-zh.ts # 完整运行 +// npx tsx tests/e2e/svp-workflow-zh.ts --dry-run # 仅 SVP CLI,跳过 Claude 调用 +// npx tsx tests/e2e/svp-workflow-zh.ts --all-sonnet # 所有步骤使用 sonnet +// npx tsx tests/e2e/svp-workflow-zh.ts --budget 2.0 # 总预算上限 + +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + type ClaudeResult, + type PhaseResult, + type StepResult, + buildReport, + buildSkippedPhase, + fmtMs, + getFlagVal, + hasFlag, + listJsonFiles, + mapModel, + parseComplexity, + readJson, + renderReport, + runClaude, + runSvp, + timer, +} from "./helpers.js"; + +// ── 类型 ── + +interface CLIOptions { + dryRun: boolean; + allSonnet: boolean; + budgetUsd: number; + stepBudgetUsd: number; +} + +// ── 常量(中文) ── + +const PROJECT_INTENT = "一个简单的字符串处理工具,负责校验输入、转换文本(反转、大写等),并将输出与元数据一起格式化返回。"; +const L4_INTENT = "一条流水线:校验输入 → 转换文本 → 格式化输出"; + +// ── CLI 参数解析 ── + +function parseArgs(): CLIOptions { + return { + dryRun: hasFlag("--dry-run"), + allSonnet: hasFlag("--all-sonnet"), + budgetUsd: Number.parseFloat(getFlagVal("--budget", "5.0")), + stepBudgetUsd: Number.parseFloat(getFlagVal("--step-budget", "0.50")), + }; +} + +// ── 阶段实现 ── + +function phase1_setup(opts: CLIOptions): PhaseResult { + const elapsed = timer(); + const steps: StepResult[] = []; + + const projectDir = mkdtempSync(path.join(tmpdir(), "svp-e2e-zh-")); + const stepTimer = timer(); + + // 使用 --language zh 初始化(项目名用英文以生成合法 id) + const initResult = runSvp( + `init -n string-transformer -r "${projectDir}" -i "${PROJECT_INTENT}" --language zh`, + projectDir, + ); + + const details: string[] = [`临时目录: ${projectDir}`]; + const errors: string[] = []; + + if (!initResult.ok) { + errors.push(`svp init 失败: ${initResult.stderr}`); + } else { + details.push("svp init 完成"); + } + + // 验证目录结构 + const svpDir = path.join(projectDir, ".svp"); + const expected = ["l5.json", "l4", "l3", "l2"]; + for (const entry of expected) { + const p = path.join(svpDir, entry); + if (existsSync(p)) { + details.push(` .svp/${entry} 存在`); + } else { + errors.push(` .svp/${entry} 缺失`); + } + } + + // 验证 l5.json 中 language 字段为 "zh" + const l5Path = path.join(svpDir, "l5.json"); + if (existsSync(l5Path)) { + try { + const l5 = readJson(l5Path); + if (l5.language === "zh") { + details.push(" l5.language = zh ✓"); + } else { + errors.push(` l5.language 应为 \"zh\",实际为 \"${l5.language}\"`); + } + } catch (e: any) { + errors.push(` l5.json 解析失败: ${e.message}`); + } + } + + steps.push({ + name: "svp init(中文) + 验证结构", + ok: initResult.ok && errors.length === 0, + durationMs: stepTimer(), + details, + errors, + }); + + const ok = steps.every((s) => s.ok); + return { + phase: 1, + name: "初始化", + ok, + durationMs: elapsed(), + steps, + ...({ _projectDir: projectDir } as any), + }; +} + +function phase2_designL5(projectDir: string, opts: CLIOptions): PhaseResult { + const elapsed = timer(); + const steps: StepResult[] = []; + + const stepTimer = timer(); + const promptResult = runSvp( + `prompt design-l5 --intent "${PROJECT_INTENT}" -r "${projectDir}"`, + projectDir, + ); + + if (!promptResult.ok) { + steps.push({ + name: "svp prompt design-l5", + ok: false, + durationMs: stepTimer(), + details: [], + errors: [`失败: ${promptResult.stderr}`], + }); + return { phase: 2, name: "设计 L5", ok: false, durationMs: elapsed(), steps }; + } + + const prompt = promptResult.stdout; + const complexity = parseComplexity(prompt); + const model = mapModel(complexity, opts.allSonnet); + + // 验证提示词包含中文语言指令 + const hasLangDirective = prompt.includes("Chinese") || prompt.includes("中文"); + + steps.push({ + name: "svp prompt design-l5", + ok: true, + durationMs: stepTimer(), + details: [ + `复杂度: ${complexity} → 模型: ${model}`, + `提示词长度: ${String(prompt.length)} 字符`, + `语言指令: ${hasLangDirective ? "包含中文指令 ✓" : "未检测到中文指令 ⚠"}`, + ], + errors: [], + }); + + // 调用 Claude 执行设计 + const claudeTimer = timer(); + const claudeResult = runClaude(prompt, model, projectDir, opts.stepBudgetUsd, opts); + + const claudeDetails: string[] = []; + const claudeErrors: string[] = []; + + if (!claudeResult.ok) { + claudeErrors.push(`Claude 调用失败: ${claudeResult.error ?? "未知错误"}`); + } else { + claudeDetails.push(`Token: ${String(claudeResult.inputTokens)} 输入 / ${String(claudeResult.outputTokens)} 输出`); + claudeDetails.push(`费用: $${claudeResult.costUsd.toFixed(4)}`); + } + + steps.push({ + name: "claude design-l5", + ok: claudeResult.ok, + durationMs: claudeTimer(), + claudeResult, + details: claudeDetails, + errors: claudeErrors, + }); + + // 验证 l5.json + const verifyTimer = timer(); + const l5Path = path.join(projectDir, ".svp", "l5.json"); + const verifyDetails: string[] = []; + const verifyErrors: string[] = []; + let verifyOk = true; + + if (!existsSync(l5Path)) { + verifyErrors.push("设计后未找到 l5.json"); + verifyOk = false; + } else { + try { + const l5 = readJson(l5Path); + const requiredFields = ["id", "name", "intent"]; + for (const field of requiredFields) { + if (l5[field] !== undefined && l5[field] !== "") { + verifyDetails.push(` l5.${field}: 存在`); + } else { + verifyErrors.push(` l5.${field}: 缺失或为空`); + verifyOk = false; + } + } + if (l5.language === "zh") { + verifyDetails.push(" l5.language: zh ✓"); + } + if (Array.isArray(l5.domains) && l5.domains.length > 0) { + verifyDetails.push(` l5.domains: ${String(l5.domains.length)} 个领域`); + } + if (Array.isArray(l5.constraints) && l5.constraints.length > 0) { + verifyDetails.push(` l5.constraints: ${String(l5.constraints.length)} 条约束`); + } + } catch (e: any) { + verifyErrors.push(`l5.json 解析错误: ${e.message}`); + verifyOk = false; + } + } + + steps.push({ + name: "验证 l5.json", + ok: verifyOk, + durationMs: verifyTimer(), + details: verifyDetails, + errors: verifyErrors, + }); + + const ok = steps.every((s) => s.ok); + return { phase: 2, name: "设计 L5", ok, durationMs: elapsed(), steps }; +} + +function phase3_designL4(projectDir: string, opts: CLIOptions): PhaseResult { + const elapsed = timer(); + const steps: StepResult[] = []; + + const stepTimer = timer(); + const promptResult = runSvp( + `prompt design-l4 --intent "${L4_INTENT}" -r "${projectDir}"`, + projectDir, + ); + + if (!promptResult.ok) { + steps.push({ + name: "svp prompt design-l4", + ok: false, + durationMs: stepTimer(), + details: [], + errors: [`失败: ${promptResult.stderr}`], + }); + return { phase: 3, name: "设计 L4", ok: false, durationMs: elapsed(), steps }; + } + + const prompt = promptResult.stdout; + const complexity = parseComplexity(prompt); + const model = mapModel(complexity, opts.allSonnet); + + steps.push({ + name: "svp prompt design-l4", + ok: true, + durationMs: stepTimer(), + details: [ + `复杂度: ${complexity} → 模型: ${model}`, + `提示词长度: ${String(prompt.length)} 字符`, + ], + errors: [], + }); + + // 调用 Claude + const claudeTimer = timer(); + const claudeResult = runClaude(prompt, model, projectDir, opts.stepBudgetUsd, opts); + + const claudeDetails: string[] = []; + const claudeErrors: string[] = []; + if (!claudeResult.ok) { + claudeErrors.push(`Claude 调用失败: ${claudeResult.error ?? "未知错误"}`); + } else { + claudeDetails.push(`Token: ${String(claudeResult.inputTokens)} 输入 / ${String(claudeResult.outputTokens)} 输出`); + claudeDetails.push(`费用: $${claudeResult.costUsd.toFixed(4)}`); + } + + steps.push({ + name: "claude design-l4", + ok: claudeResult.ok, + durationMs: claudeTimer(), + claudeResult, + details: claudeDetails, + errors: claudeErrors, + }); + + // 验证 L4 文件 + const verifyTimer = timer(); + const l4Dir = path.join(projectDir, ".svp", "l4"); + const l4Files = listJsonFiles(l4Dir); + const verifyDetails: string[] = []; + const verifyErrors: string[] = []; + let verifyOk = true; + + if (l4Files.length === 0) { + if (opts.dryRun) { + verifyDetails.push("无 L4 文件(dry-run 模式下预期如此)"); + } else { + verifyErrors.push("设计后未找到 L4 文件"); + verifyOk = false; + } + } else { + verifyDetails.push(`找到 ${String(l4Files.length)} 个 L4 文件: ${l4Files.join(", ")}`); + + for (const file of l4Files) { + try { + const l4 = readJson(path.join(l4Dir, file)); + const requiredFields = ["id", "name", "steps"]; + for (const field of requiredFields) { + if (l4[field] === undefined) { + verifyErrors.push(` ${file}: 缺少字段 "${field}"`); + verifyOk = false; + } + } + if (Array.isArray(l4.steps)) { + verifyDetails.push(` ${file}: ${String(l4.steps.length)} 个步骤`); + const refs = l4.steps.map((s: any) => s.blockRef).filter((r: any) => r !== undefined); + if (refs.length > 0) { + verifyDetails.push(` ${file}: blockRefs → [${refs.join(", ")}]`); + } + } + } catch (e: any) { + verifyErrors.push(` ${file}: 解析错误: ${e.message}`); + verifyOk = false; + } + } + } + + steps.push({ + name: "验证 l4/*.json", + ok: verifyOk, + durationMs: verifyTimer(), + details: verifyDetails, + errors: verifyErrors, + }); + + const ok = steps.every((s) => s.ok); + return { phase: 3, name: "设计 L4", ok, durationMs: elapsed(), steps }; +} + +function phase4_designL3(projectDir: string, opts: CLIOptions): PhaseResult { + const elapsed = timer(); + const steps: StepResult[] = []; + + const l4Dir = path.join(projectDir, ".svp", "l4"); + const l4Files = listJsonFiles(l4Dir); + + if (l4Files.length === 0) { + const isExpected = opts.dryRun; + steps.push({ + name: "发现 L4 步骤", + ok: isExpected, + durationMs: 0, + details: isExpected ? ["无 L4 流程(dry-run 模式下预期如此)"] : [], + errors: isExpected ? [] : ["未找到 L4 流程 — 无法设计 L3 block"], + }); + return { phase: 4, name: "设计 L3", ok: isExpected, durationMs: elapsed(), steps }; + } + + // 收集所有需要 L3 block 的目标 + const targets: { flowId: string; blockId: string; stepIndex?: number }[] = []; + const seen = new Set(); + for (const file of l4Files) { + const l4 = readJson(path.join(l4Dir, file)); + const kind = l4.kind ?? "flow"; + + if (kind === "flow" && Array.isArray(l4.steps)) { + for (let i = 0; i < l4.steps.length; i++) { + const step = l4.steps[i]; + if (step.blockRef && !seen.has(step.blockRef)) { + seen.add(step.blockRef); + targets.push({ flowId: l4.id, blockId: step.blockRef, stepIndex: i }); + } + } + } else if (kind === "event-graph" && Array.isArray(l4.handlers)) { + for (const handler of l4.handlers) { + if (!Array.isArray(handler.steps)) continue; + for (const step of handler.steps) { + if (step.blockRef && !seen.has(step.blockRef)) { + seen.add(step.blockRef); + targets.push({ flowId: l4.id, blockId: step.blockRef }); + } + } + } + } else if (kind === "state-machine" && l4.states) { + for (const config of Object.values(l4.states) as any[]) { + if (config.onEntry?.blockRef && !seen.has(config.onEntry.blockRef)) { + seen.add(config.onEntry.blockRef); + targets.push({ flowId: l4.id, blockId: config.onEntry.blockRef }); + } + if (config.onExit?.blockRef && !seen.has(config.onExit.blockRef)) { + seen.add(config.onExit.blockRef); + targets.push({ flowId: l4.id, blockId: config.onExit.blockRef }); + } + } + if (Array.isArray(l4.transitions)) { + for (const t of l4.transitions) { + if (t.guard && !seen.has(t.guard)) { + seen.add(t.guard); + targets.push({ flowId: l4.id, blockId: t.guard }); + } + } + } + } + } + + steps.push({ + name: "发现 L4 步骤", + ok: targets.length > 0, + durationMs: 0, + details: [`找到 ${String(targets.length)} 个需要 L3 block 的步骤`], + errors: targets.length === 0 ? ["L4 流程中未找到带 blockRef 的步骤"] : [], + }); + + // 逐个设计 L3 block + for (const target of targets) { + const { flowId, blockId, stepIndex } = target; + + const l3Path = path.join(projectDir, ".svp", "l3", `${blockId}.json`); + if (existsSync(l3Path)) { + steps.push({ + name: `design-l3 ${blockId}`, + ok: true, + durationMs: 0, + details: [`L3 block "${blockId}" 已存在 — 跳过`], + errors: [], + }); + continue; + } + + const stepTimer = timer(); + const intentText = `实现字符串处理流水线中的 "${blockId}" 步骤`; + const stepArg = stepIndex !== undefined ? ` --step ${String(stepIndex)}` : ""; + const promptResult = runSvp( + `prompt design-l3 ${blockId} --intent "${intentText}" --flow ${flowId}${stepArg} -r "${projectDir}"`, + projectDir, + ); + + if (!promptResult.ok) { + steps.push({ + name: `svp prompt design-l3 ${blockId}`, + ok: false, + durationMs: stepTimer(), + details: [], + errors: [`失败: ${promptResult.stderr}`], + }); + continue; + } + + const prompt = promptResult.stdout; + const complexity = parseComplexity(prompt); + const model = mapModel(complexity, opts.allSonnet); + + const claudeResult = runClaude(prompt, model, projectDir, opts.stepBudgetUsd, opts); + + const details: string[] = [`复杂度: ${complexity} → 模型: ${model}`]; + const errors: string[] = []; + + if (claudeResult.ok) { + details.push(`Token: ${String(claudeResult.inputTokens)} 输入 / ${String(claudeResult.outputTokens)} 输出`); + } else { + errors.push(`Claude 调用失败: ${claudeResult.error ?? "未知错误"}`); + } + + if (existsSync(l3Path)) { + try { + const l3 = readJson(l3Path); + const requiredFields = ["id", "name", "input", "output"]; + for (const field of requiredFields) { + if (l3[field] === undefined) { + errors.push(`l3/${blockId}.json: 缺少字段 "${field}"`); + } + } + details.push(`l3/${blockId}.json 已创建,包含 ${String(Object.keys(l3).length)} 个字段`); + } catch (e: any) { + errors.push(`l3/${blockId}.json 解析错误: ${e.message}`); + } + } else if (claudeResult.ok && !opts.dryRun) { + errors.push(`Claude 调用后未找到 l3/${blockId}.json`); + } else if (opts.dryRun) { + details.push(`l3/${blockId}.json 未创建(dry-run 模式下预期如此)`); + } + + steps.push({ + name: `design-l3 ${blockId}`, + ok: claudeResult.ok && errors.length === 0, + durationMs: stepTimer(), + claudeResult, + details, + errors, + }); + } + + const ok = steps.every((s) => s.ok); + return { phase: 4, name: "设计 L3", ok, durationMs: elapsed(), steps }; +} + +function phase5_check(projectDir: string): PhaseResult { + const elapsed = timer(); + const steps: StepResult[] = []; + + // 先运行 rehash 修复 Claude 留下的占位哈希 + const rehashTimer = timer(); + const rehashResult = runSvp(`rehash -r "${projectDir}"`, projectDir); + steps.push({ + name: "svp rehash", + ok: rehashResult.ok, + durationMs: rehashTimer(), + details: rehashResult.ok + ? [`Rehash 完成: ${rehashResult.stdout.trim().split("\n").length} 行输出`] + : [], + errors: rehashResult.ok ? [] : [`svp rehash 失败: ${rehashResult.stderr}`], + }); + + const stepTimer = timer(); + const result = runSvp(`check --json -r "${projectDir}"`, projectDir); + + const details: string[] = []; + const errors: string[] = []; + let checkOk = result.ok; + + if (!result.ok && result.stderr) { + errors.push(`svp check 失败: ${result.stderr}`); + } + + try { + const report = JSON.parse(result.stdout); + const issueCount = report.issues?.length ?? 0; + const errorCount = report.summary?.errors ?? 0; + const warnCount = report.summary?.warnings ?? 0; + + details.push(`问题: ${String(issueCount)} 个 (${String(errorCount)} 个错误, ${String(warnCount)} 个警告)`); + + if (errorCount > 0) { + checkOk = false; + for (const issue of report.issues) { + if (issue.severity === "error") { + errors.push(` [${issue.code}] ${issue.layer}/${issue.entityId}: ${issue.message}`); + } + } + } + + if (warnCount > 0) { + for (const issue of report.issues) { + if (issue.severity === "warning") { + details.push(` 警告 [${issue.code}] ${issue.layer}/${issue.entityId}: ${issue.message}`); + } + } + } + } catch { + details.push(`原始输出: ${result.stdout.slice(0, 500)}`); + } + + steps.push({ + name: "svp check --json", + ok: checkOk, + durationMs: stepTimer(), + details, + errors, + }); + + return { phase: 5, name: "检查", ok: checkOk, durationMs: elapsed(), steps }; +} + +function phase6_compile(projectDir: string, opts: CLIOptions): PhaseResult { + const elapsed = timer(); + const steps: StepResult[] = []; + + const l3Dir = path.join(projectDir, ".svp", "l3"); + const l3Files = listJsonFiles(l3Dir); + + if (l3Files.length === 0) { + const isExpected = opts.dryRun; + steps.push({ + name: "发现 L3 block", + ok: isExpected, + durationMs: 0, + details: isExpected ? ["无 L3 block(dry-run 模式下预期如此)"] : [], + errors: isExpected ? [] : ["未找到 L3 block — 无法编译"], + }); + return { phase: 6, name: "编译", ok: isExpected, durationMs: elapsed(), steps }; + } + + steps.push({ + name: "发现 L3 block", + ok: true, + durationMs: 0, + details: [`找到 ${String(l3Files.length)} 个 L3 block 待编译: ${l3Files.map((f) => f.replace(".json", "")).join(", ")}`], + errors: [], + }); + + for (const file of l3Files) { + const blockId = file.replace(".json", ""); + const stepTimer = timer(); + + const promptResult = runSvp( + `prompt compile ${blockId} -r "${projectDir}"`, + projectDir, + ); + + if (!promptResult.ok) { + steps.push({ + name: `编译 ${blockId}`, + ok: false, + durationMs: stepTimer(), + details: [], + errors: [`svp prompt compile 失败: ${promptResult.stderr}`], + }); + continue; + } + + const prompt = promptResult.stdout; + const complexity = parseComplexity(prompt); + const model = mapModel(complexity, opts.allSonnet); + + const claudeResult = runClaude(prompt, model, projectDir, opts.stepBudgetUsd, opts); + + const details: string[] = [`复杂度: ${complexity} → 模型: ${model}`]; + const errors: string[] = []; + + if (claudeResult.ok) { + details.push(`Token: ${String(claudeResult.inputTokens)} 输入 / ${String(claudeResult.outputTokens)} 输出`); + } else { + errors.push(`Claude 调用失败: ${claudeResult.error ?? "未知错误"}`); + } + + const l2Dir = path.join(projectDir, ".svp", "l2"); + const l2Files = listJsonFiles(l2Dir); + const l2ForBlock = l2Files.filter((f) => { + try { + const l2 = readJson(path.join(l2Dir, f)); + return l2.blockRef === blockId; + } catch { + return false; + } + }); + + if (l2ForBlock.length > 0) { + details.push(`L2 代码块: ${l2ForBlock.join(", ")}`); + } else if (!opts.dryRun && claudeResult.ok) { + details.push("未找到 L2 代码块(Claude 可能直接创建了源文件)"); + } + + steps.push({ + name: `编译 ${blockId}`, + ok: claudeResult.ok, + durationMs: stepTimer(), + claudeResult, + details, + errors, + }); + } + + const ok = steps.every((s) => s.ok); + return { phase: 6, name: "编译", ok, durationMs: elapsed(), steps }; +} + +// ── 主程序 ── + +async function main(): Promise { + const opts = parseArgs(); + + console.log("=== SVP E2E 工作流测试(中文版) ==="); + console.log(`模式: ${opts.dryRun ? "空运行" : "完整运行"}`); + if (opts.allSonnet) console.log("模型覆盖: 全部使用 sonnet"); + console.log(`预算: 总计 $${opts.budgetUsd.toFixed(2)},每步 $${opts.stepBudgetUsd.toFixed(2)}`); + console.log(); + + const phases: PhaseResult[] = []; + let projectDir = ""; + let totalCost = 0; + + // 阶段 1: 初始化 + console.log("阶段 1: 初始化..."); + const p1 = phase1_setup(opts); + projectDir = (p1 as any)._projectDir; + phases.push(p1); + console.log(` ${p1.ok ? "通过" : "失败"} (${fmtMs(p1.durationMs)})`); + + if (!p1.ok) { + console.error("阶段 1 失败 — 无法继续。"); + process.exitCode = 1; + return; + } + + console.log(` 项目目录: ${projectDir}`); + + // 阶段 2: 设计 L5 + console.log("阶段 2: 设计 L5..."); + const p2 = phase2_designL5(projectDir, opts); + phases.push(p2); + totalCost += p2.steps.reduce((s, st) => s + (st.claudeResult?.costUsd ?? 0), 0); + console.log(` ${p2.ok ? "通过" : "失败"} (${fmtMs(p2.durationMs)})`); + + if (!p2.ok && !opts.dryRun) { + console.error("阶段 2 失败 — 后续阶段需要 L5 设计。"); + phases.push(buildSkippedPhase(3, "设计 L4"), buildSkippedPhase(4, "设计 L3"), buildSkippedPhase(5, "检查"), buildSkippedPhase(6, "编译")); + writeReport(phases, projectDir, opts); + process.exitCode = 1; + return; + } + + // 阶段 3: 设计 L4 + console.log("阶段 3: 设计 L4..."); + const p3 = phase3_designL4(projectDir, opts); + phases.push(p3); + totalCost += p3.steps.reduce((s, st) => s + (st.claudeResult?.costUsd ?? 0), 0); + console.log(` ${p3.ok ? "通过" : "失败"} (${fmtMs(p3.durationMs)})`); + + if (!p3.ok && !opts.dryRun) { + console.error("阶段 3 失败 — 设计 L3 需要 L4 流程。"); + phases.push(buildSkippedPhase(4, "设计 L3"), buildSkippedPhase(5, "检查"), buildSkippedPhase(6, "编译")); + writeReport(phases, projectDir, opts); + process.exitCode = 1; + return; + } + + // 预算检查 + if (totalCost >= opts.budgetUsd) { + console.error(`预算已耗尽 ($${totalCost.toFixed(2)} >= $${opts.budgetUsd.toFixed(2)})。停止。`); + phases.push(buildSkippedPhase(4, "设计 L3"), buildSkippedPhase(5, "检查"), buildSkippedPhase(6, "编译")); + writeReport(phases, projectDir, opts); + process.exitCode = 1; + return; + } + + // 阶段 4: 设计 L3 + console.log("阶段 4: 设计 L3..."); + const p4 = phase4_designL3(projectDir, opts); + phases.push(p4); + totalCost += p4.steps.reduce((s, st) => s + (st.claudeResult?.costUsd ?? 0), 0); + console.log(` ${p4.ok ? "通过" : "失败"} (${fmtMs(p4.durationMs)})`); + + // 阶段 5: 检查 + console.log("阶段 5: 检查..."); + const p5 = phase5_check(projectDir); + phases.push(p5); + console.log(` ${p5.ok ? "通过" : "失败"} (${fmtMs(p5.durationMs)})`); + + // 编译前预算检查 + if (totalCost >= opts.budgetUsd) { + console.error(`预算已耗尽 ($${totalCost.toFixed(2)})。跳过编译阶段。`); + phases.push(buildSkippedPhase(6, "编译")); + writeReport(phases, projectDir, opts); + process.exitCode = 1; + return; + } + + // 阶段 6: 编译 + console.log("阶段 6: 编译..."); + const p6 = phase6_compile(projectDir, opts); + phases.push(p6); + totalCost += p6.steps.reduce((s, st) => s + (st.claudeResult?.costUsd ?? 0), 0); + console.log(` ${p6.ok ? "通过" : "失败"} (${fmtMs(p6.durationMs)})`); + + // 阶段 7: 报告 + writeReport(phases, projectDir, opts); + + // 最终状态 + const allPassed = phases.every((p) => p.ok); + console.log(); + console.log(`=== ${allPassed ? "全部通过" : "存在失败"} ===`); + console.log(`总费用: $${totalCost.toFixed(4)}`); + + if (!allPassed) process.exitCode = 1; +} + +function writeReport(phases: PhaseResult[], projectDir: string, opts: CLIOptions): void { + const report = buildReport(phases, projectDir, opts.dryRun); + const markdown = renderReport(report); + + const reportDir = path.resolve(import.meta.dirname); + const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const reportPath = path.join(reportDir, `report-zh-${ts}.md`); + writeFileSync(reportPath, markdown, "utf8"); + console.log(`\n报告: ${reportPath}`); + + const jsonPath = path.join(reportDir, `report-zh-${ts}.json`); + writeFileSync(jsonPath, JSON.stringify(report, null, 2), "utf8"); +} + +main().catch((err) => { + console.error("致命错误:", err); + process.exitCode = 1; +}); diff --git a/tests/e2e/svp-workflow.ts b/tests/e2e/svp-workflow.ts new file mode 100644 index 0000000..68be952 --- /dev/null +++ b/tests/e2e/svp-workflow.ts @@ -0,0 +1,805 @@ +#!/usr/bin/env tsx +// svp-workflow.ts — E2E test: full SVP design→implement workflow via Claude Code CLI +// +// Runs the complete SVP pipeline on a throwaway project: +// Phase 1: Setup → svp init +// Phase 2: Design L5 → svp prompt design-l5 → claude -p → verify l5.json +// Phase 3: Design L4 → svp prompt design-l4 → claude -p → verify l4/*.json +// Phase 4: Design L3 → svp prompt design-l3 (per step) → claude -p → verify l3/*.json +// Phase 5: Check → svp check → cross-layer consistency +// Phase 6: Compile → svp prompt compile (per l3) → claude -p → verify l2/*.json + sources +// Phase 7: Report → markdown summary +// +// Usage: +// npx tsx tests/e2e/svp-workflow.ts # full run +// npx tsx tests/e2e/svp-workflow.ts --dry-run # SVP CLI only, skip Claude calls +// npx tsx tests/e2e/svp-workflow.ts --all-sonnet # use sonnet for all steps +// npx tsx tests/e2e/svp-workflow.ts --budget 2.0 # total budget cap + +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + type ClaudeResult, + type PhaseResult, + type StepResult, + buildReport, + buildSkippedPhase, + fmtMs, + getFlagVal, + hasFlag, + listJsonFiles, + mapModel, + parseComplexity, + readJson, + renderReport, + runClaude, + runSvp, + timer, +} from "./helpers.js"; + +// ── Types ── + +interface CLIOptions { + dryRun: boolean; + allSonnet: boolean; + budgetUsd: number; + stepBudgetUsd: number; +} + +// ── Constants ── + +const PROJECT_INTENT = "A simple string transformer tool that validates input, transforms text (reverse, uppercase, etc.), and formats the output with metadata."; +const L4_INTENT = "A pipeline: validate-input → transform-text → format-output"; + +// ── CLI arg parsing ── + +function parseArgs(): CLIOptions { + return { + dryRun: hasFlag("--dry-run"), + allSonnet: hasFlag("--all-sonnet"), + budgetUsd: Number.parseFloat(getFlagVal("--budget", "5.0")), + stepBudgetUsd: Number.parseFloat(getFlagVal("--step-budget", "0.50")), + }; +} + +// ── Phase implementations ── + +function phase1_setup(opts: CLIOptions): PhaseResult { + const elapsed = timer(); + const steps: StepResult[] = []; + + // Create temp directory + const projectDir = mkdtempSync(path.join(tmpdir(), "svp-e2e-")); + const stepTimer = timer(); + + // Run svp init + const initResult = runSvp( + `init -n string-transformer -r "${projectDir}" -i "${PROJECT_INTENT}"`, + projectDir, + ); + + const details: string[] = [`Temp directory: ${projectDir}`]; + const errors: string[] = []; + + if (!initResult.ok) { + errors.push(`svp init failed: ${initResult.stderr}`); + } else { + details.push("svp init completed"); + } + + // Verify directory structure + const svpDir = path.join(projectDir, ".svp"); + const expected = ["l5.json", "l4", "l3", "l2"]; + for (const entry of expected) { + const p = path.join(svpDir, entry); + if (existsSync(p)) { + details.push(` .svp/${entry} exists`); + } else { + errors.push(` .svp/${entry} MISSING`); + } + } + + steps.push({ + name: "svp init + verify structure", + ok: initResult.ok && errors.length === 0, + durationMs: stepTimer(), + details, + errors, + }); + + const ok = steps.every((s) => s.ok); + return { + phase: 1, + name: "Setup", + ok, + durationMs: elapsed(), + steps, + // Stash projectDir on the result for downstream phases + ...({ _projectDir: projectDir } as any), + }; +} + +function phase2_designL5(projectDir: string, opts: CLIOptions): PhaseResult { + const elapsed = timer(); + const steps: StepResult[] = []; + + // Generate L5 design prompt + const stepTimer = timer(); + const promptResult = runSvp( + `prompt design-l5 --intent "${PROJECT_INTENT}" -r "${projectDir}"`, + projectDir, + ); + + if (!promptResult.ok) { + steps.push({ + name: "svp prompt design-l5", + ok: false, + durationMs: stepTimer(), + details: [], + errors: [`Failed: ${promptResult.stderr}`], + }); + return { phase: 2, name: "Design L5", ok: false, durationMs: elapsed(), steps }; + } + + const prompt = promptResult.stdout; + const complexity = parseComplexity(prompt); + const model = mapModel(complexity, opts.allSonnet); + + steps.push({ + name: "svp prompt design-l5", + ok: true, + durationMs: stepTimer(), + details: [ + `Complexity: ${complexity} → model: ${model}`, + `Prompt length: ${String(prompt.length)} chars`, + ], + errors: [], + }); + + // Call Claude to execute the design + const claudeTimer = timer(); + const claudeResult = runClaude(prompt, model, projectDir, opts.stepBudgetUsd, opts); + + const claudeDetails: string[] = []; + const claudeErrors: string[] = []; + + if (!claudeResult.ok) { + claudeErrors.push(`Claude call failed: ${claudeResult.error ?? "unknown"}`); + } else { + claudeDetails.push(`Tokens: ${String(claudeResult.inputTokens)} in / ${String(claudeResult.outputTokens)} out`); + claudeDetails.push(`Cost: $${claudeResult.costUsd.toFixed(4)}`); + } + + steps.push({ + name: "claude design-l5", + ok: claudeResult.ok, + durationMs: claudeTimer(), + claudeResult, + details: claudeDetails, + errors: claudeErrors, + }); + + // Verify l5.json was updated + const verifyTimer = timer(); + const l5Path = path.join(projectDir, ".svp", "l5.json"); + const verifyDetails: string[] = []; + const verifyErrors: string[] = []; + let verifyOk = true; + + if (!existsSync(l5Path)) { + verifyErrors.push("l5.json not found after design"); + verifyOk = false; + } else { + try { + const l5 = readJson(l5Path); + const requiredFields = ["id", "name", "intent"]; + for (const field of requiredFields) { + if (l5[field] !== undefined && l5[field] !== "") { + verifyDetails.push(` l5.${field}: present`); + } else { + verifyErrors.push(` l5.${field}: MISSING or empty`); + verifyOk = false; + } + } + // Optional but expected + if (Array.isArray(l5.domains) && l5.domains.length > 0) { + verifyDetails.push(` l5.domains: ${String(l5.domains.length)} domain(s)`); + } + if (Array.isArray(l5.constraints) && l5.constraints.length > 0) { + verifyDetails.push(` l5.constraints: ${String(l5.constraints.length)} constraint(s)`); + } + } catch (e: any) { + verifyErrors.push(`l5.json parse error: ${e.message}`); + verifyOk = false; + } + } + + steps.push({ + name: "verify l5.json", + ok: verifyOk, + durationMs: verifyTimer(), + details: verifyDetails, + errors: verifyErrors, + }); + + const ok = steps.every((s) => s.ok); + return { phase: 2, name: "Design L5", ok, durationMs: elapsed(), steps }; +} + +function phase3_designL4(projectDir: string, opts: CLIOptions): PhaseResult { + const elapsed = timer(); + const steps: StepResult[] = []; + + // Generate L4 design prompt + const stepTimer = timer(); + const promptResult = runSvp( + `prompt design-l4 --intent "${L4_INTENT}" -r "${projectDir}"`, + projectDir, + ); + + if (!promptResult.ok) { + steps.push({ + name: "svp prompt design-l4", + ok: false, + durationMs: stepTimer(), + details: [], + errors: [`Failed: ${promptResult.stderr}`], + }); + return { phase: 3, name: "Design L4", ok: false, durationMs: elapsed(), steps }; + } + + const prompt = promptResult.stdout; + const complexity = parseComplexity(prompt); + const model = mapModel(complexity, opts.allSonnet); + + steps.push({ + name: "svp prompt design-l4", + ok: true, + durationMs: stepTimer(), + details: [ + `Complexity: ${complexity} → model: ${model}`, + `Prompt length: ${String(prompt.length)} chars`, + ], + errors: [], + }); + + // Call Claude + const claudeTimer = timer(); + const claudeResult = runClaude(prompt, model, projectDir, opts.stepBudgetUsd, opts); + + const claudeDetails: string[] = []; + const claudeErrors: string[] = []; + if (!claudeResult.ok) { + claudeErrors.push(`Claude call failed: ${claudeResult.error ?? "unknown"}`); + } else { + claudeDetails.push(`Tokens: ${String(claudeResult.inputTokens)} in / ${String(claudeResult.outputTokens)} out`); + claudeDetails.push(`Cost: $${claudeResult.costUsd.toFixed(4)}`); + } + + steps.push({ + name: "claude design-l4", + ok: claudeResult.ok, + durationMs: claudeTimer(), + claudeResult, + details: claudeDetails, + errors: claudeErrors, + }); + + // Verify L4 files + const verifyTimer = timer(); + const l4Dir = path.join(projectDir, ".svp", "l4"); + const l4Files = listJsonFiles(l4Dir); + const verifyDetails: string[] = []; + const verifyErrors: string[] = []; + let verifyOk = true; + + if (l4Files.length === 0) { + if (opts.dryRun) { + verifyDetails.push("No L4 flow files (expected in dry-run — Claude call was skipped)"); + } else { + verifyErrors.push("No L4 flow files found after design"); + verifyOk = false; + } + } else { + verifyDetails.push(`Found ${String(l4Files.length)} L4 file(s): ${l4Files.join(", ")}`); + + for (const file of l4Files) { + try { + const l4 = readJson(path.join(l4Dir, file)); + const requiredFields = ["id", "name", "steps"]; + for (const field of requiredFields) { + if (l4[field] === undefined) { + verifyErrors.push(` ${file}: missing field "${field}"`); + verifyOk = false; + } + } + if (Array.isArray(l4.steps)) { + verifyDetails.push(` ${file}: ${String(l4.steps.length)} step(s)`); + // Collect blockRefs for Phase 4 + const refs = l4.steps + .map((s: any) => s.blockRef) + .filter((r: any) => r !== undefined); + if (refs.length > 0) { + verifyDetails.push(` ${file}: blockRefs → [${refs.join(", ")}]`); + } + } + } catch (e: any) { + verifyErrors.push(` ${file}: parse error: ${e.message}`); + verifyOk = false; + } + } + } + + steps.push({ + name: "verify l4/*.json", + ok: verifyOk, + durationMs: verifyTimer(), + details: verifyDetails, + errors: verifyErrors, + }); + + const ok = steps.every((s) => s.ok); + return { phase: 3, name: "Design L4", ok, durationMs: elapsed(), steps }; +} + +function phase4_designL3(projectDir: string, opts: CLIOptions): PhaseResult { + const elapsed = timer(); + const steps: StepResult[] = []; + + // Read L4 flows to discover steps needing L3 blocks + const l4Dir = path.join(projectDir, ".svp", "l4"); + const l4Files = listJsonFiles(l4Dir); + + if (l4Files.length === 0) { + const isExpected = opts.dryRun; + steps.push({ + name: "discover L4 steps", + ok: isExpected, + durationMs: 0, + details: isExpected ? ["No L4 flows (expected in dry-run)"] : [], + errors: isExpected ? [] : ["No L4 flows found — cannot design L3 blocks"], + }); + return { phase: 4, name: "Design L3", ok: isExpected, durationMs: elapsed(), steps }; + } + + // Collect all (flowId, blockId, stepIndex?) tuples from any L4 kind + const targets: { flowId: string; blockId: string; stepIndex?: number }[] = []; + const seen = new Set(); // deduplicate blockIds across L4 artifacts + for (const file of l4Files) { + const l4 = readJson(path.join(l4Dir, file)); + const kind = l4.kind ?? "flow"; + + if (kind === "flow" && Array.isArray(l4.steps)) { + for (let i = 0; i < l4.steps.length; i++) { + const step = l4.steps[i]; + if (step.blockRef && !seen.has(step.blockRef)) { + seen.add(step.blockRef); + targets.push({ flowId: l4.id, blockId: step.blockRef, stepIndex: i }); + } + } + } else if (kind === "event-graph" && Array.isArray(l4.handlers)) { + for (const handler of l4.handlers) { + if (!Array.isArray(handler.steps)) continue; + for (const step of handler.steps) { + if (step.blockRef && !seen.has(step.blockRef)) { + seen.add(step.blockRef); + targets.push({ flowId: l4.id, blockId: step.blockRef }); + } + } + } + } else if (kind === "state-machine" && l4.states) { + for (const config of Object.values(l4.states) as any[]) { + if (config.onEntry?.blockRef && !seen.has(config.onEntry.blockRef)) { + seen.add(config.onEntry.blockRef); + targets.push({ flowId: l4.id, blockId: config.onEntry.blockRef }); + } + if (config.onExit?.blockRef && !seen.has(config.onExit.blockRef)) { + seen.add(config.onExit.blockRef); + targets.push({ flowId: l4.id, blockId: config.onExit.blockRef }); + } + } + if (Array.isArray(l4.transitions)) { + for (const t of l4.transitions) { + if (t.guard && !seen.has(t.guard)) { + seen.add(t.guard); + targets.push({ flowId: l4.id, blockId: t.guard }); + } + } + } + } + } + + steps.push({ + name: "discover L4 steps", + ok: targets.length > 0, + durationMs: 0, + details: [`Found ${String(targets.length)} step(s) needing L3 blocks`], + errors: targets.length === 0 ? ["No steps with blockRef found in L4 flows"] : [], + }); + + // Design each L3 block + for (const target of targets) { + const { flowId, blockId, stepIndex } = target; + + // Check if L3 already exists (Claude may have created it in a prior step) + const l3Path = path.join(projectDir, ".svp", "l3", `${blockId}.json`); + if (existsSync(l3Path)) { + steps.push({ + name: `design-l3 ${blockId}`, + ok: true, + durationMs: 0, + details: [`L3 block "${blockId}" already exists — skipping`], + errors: [], + }); + continue; + } + + const stepTimer = timer(); + const intentText = `Implement the "${blockId}" step of the string transformer pipeline`; + const stepArg = stepIndex !== undefined ? ` --step ${String(stepIndex)}` : ""; + const promptResult = runSvp( + `prompt design-l3 ${blockId} --intent "${intentText}" --flow ${flowId}${stepArg} -r "${projectDir}"`, + projectDir, + ); + + if (!promptResult.ok) { + steps.push({ + name: `svp prompt design-l3 ${blockId}`, + ok: false, + durationMs: stepTimer(), + details: [], + errors: [`Failed: ${promptResult.stderr}`], + }); + continue; + } + + const prompt = promptResult.stdout; + const complexity = parseComplexity(prompt); + const model = mapModel(complexity, opts.allSonnet); + + // Call Claude + const claudeResult = runClaude(prompt, model, projectDir, opts.stepBudgetUsd, opts); + + const details: string[] = [ + `Complexity: ${complexity} → model: ${model}`, + ]; + const errors: string[] = []; + + if (claudeResult.ok) { + details.push(`Tokens: ${String(claudeResult.inputTokens)} in / ${String(claudeResult.outputTokens)} out`); + } else { + errors.push(`Claude call failed: ${claudeResult.error ?? "unknown"}`); + } + + // Verify L3 was created + if (existsSync(l3Path)) { + try { + const l3 = readJson(l3Path); + const requiredFields = ["id", "name", "input", "output"]; + for (const field of requiredFields) { + if (l3[field] === undefined) { + errors.push(`l3/${blockId}.json: missing field "${field}"`); + } + } + details.push(`l3/${blockId}.json created with ${String(Object.keys(l3).length)} fields`); + } catch (e: any) { + errors.push(`l3/${blockId}.json parse error: ${e.message}`); + } + } else if (claudeResult.ok && !opts.dryRun) { + errors.push(`l3/${blockId}.json not found after Claude call`); + } else if (opts.dryRun) { + details.push(`l3/${blockId}.json not created (expected in dry-run)`); + } + + steps.push({ + name: `design-l3 ${blockId}`, + ok: claudeResult.ok && errors.length === 0, + durationMs: stepTimer(), + claudeResult, + details, + errors, + }); + } + + const ok = steps.every((s) => s.ok); + return { phase: 4, name: "Design L3", ok, durationMs: elapsed(), steps }; +} + +function phase5_check(projectDir: string): PhaseResult { + const elapsed = timer(); + const steps: StepResult[] = []; + + // Run rehash first to fix placeholder hashes left by Claude + const rehashTimer = timer(); + const rehashResult = runSvp(`rehash -r "${projectDir}"`, projectDir); + steps.push({ + name: "svp rehash", + ok: rehashResult.ok, + durationMs: rehashTimer(), + details: rehashResult.ok + ? [`Rehash completed: ${rehashResult.stdout.trim().split("\n").length} line(s) output`] + : [], + errors: rehashResult.ok ? [] : [`svp rehash failed: ${rehashResult.stderr}`], + }); + + const stepTimer = timer(); + const result = runSvp(`check --json -r "${projectDir}"`, projectDir); + + const details: string[] = []; + const errors: string[] = []; + let checkOk = result.ok; + + if (!result.ok && result.stderr) { + errors.push(`svp check failed: ${result.stderr}`); + } + + // Parse JSON output + try { + const report = JSON.parse(result.stdout); + const issueCount = report.issues?.length ?? 0; + const errorCount = report.summary?.errors ?? 0; + const warnCount = report.summary?.warnings ?? 0; + + details.push(`Issues: ${String(issueCount)} (${String(errorCount)} errors, ${String(warnCount)} warnings)`); + + if (errorCount > 0) { + checkOk = false; + for (const issue of report.issues) { + if (issue.severity === "error") { + errors.push(` [${issue.code}] ${issue.layer}/${issue.entityId}: ${issue.message}`); + } + } + } + + if (warnCount > 0) { + for (const issue of report.issues) { + if (issue.severity === "warning") { + details.push(` WARN [${issue.code}] ${issue.layer}/${issue.entityId}: ${issue.message}`); + } + } + } + } catch { + // Not JSON — use raw stdout + details.push(`Raw output: ${result.stdout.slice(0, 500)}`); + } + + steps.push({ + name: "svp check --json", + ok: checkOk, + durationMs: stepTimer(), + details, + errors, + }); + + return { phase: 5, name: "Check", ok: checkOk, durationMs: elapsed(), steps }; +} + +function phase6_compile(projectDir: string, opts: CLIOptions): PhaseResult { + const elapsed = timer(); + const steps: StepResult[] = []; + + // Find all L3 blocks to compile + const l3Dir = path.join(projectDir, ".svp", "l3"); + const l3Files = listJsonFiles(l3Dir); + + if (l3Files.length === 0) { + const isExpected = opts.dryRun; + steps.push({ + name: "discover L3 blocks", + ok: isExpected, + durationMs: 0, + details: isExpected ? ["No L3 blocks (expected in dry-run)"] : [], + errors: isExpected ? [] : ["No L3 blocks found — nothing to compile"], + }); + return { phase: 6, name: "Compile", ok: isExpected, durationMs: elapsed(), steps }; + } + + steps.push({ + name: "discover L3 blocks", + ok: true, + durationMs: 0, + details: [`Found ${String(l3Files.length)} L3 block(s) to compile: ${l3Files.map((f) => f.replace(".json", "")).join(", ")}`], + errors: [], + }); + + for (const file of l3Files) { + const blockId = file.replace(".json", ""); + const stepTimer = timer(); + + // Generate compile prompt + const promptResult = runSvp( + `prompt compile ${blockId} -r "${projectDir}"`, + projectDir, + ); + + if (!promptResult.ok) { + steps.push({ + name: `compile ${blockId}`, + ok: false, + durationMs: stepTimer(), + details: [], + errors: [`svp prompt compile failed: ${promptResult.stderr}`], + }); + continue; + } + + const prompt = promptResult.stdout; + const complexity = parseComplexity(prompt); + const model = mapModel(complexity, opts.allSonnet); + + // Call Claude to compile + const claudeResult = runClaude(prompt, model, projectDir, opts.stepBudgetUsd, opts); + + const details: string[] = [`Complexity: ${complexity} → model: ${model}`]; + const errors: string[] = []; + + if (claudeResult.ok) { + details.push(`Tokens: ${String(claudeResult.inputTokens)} in / ${String(claudeResult.outputTokens)} out`); + } else { + errors.push(`Claude call failed: ${claudeResult.error ?? "unknown"}`); + } + + // Verify L2 was created + const l2Dir = path.join(projectDir, ".svp", "l2"); + const l2Files = listJsonFiles(l2Dir); + const l2ForBlock = l2Files.filter((f) => { + try { + const l2 = readJson(path.join(l2Dir, f)); + return l2.blockRef === blockId; + } catch { + return false; + } + }); + + if (l2ForBlock.length > 0) { + details.push(`L2 code block(s): ${l2ForBlock.join(", ")}`); + } else if (!opts.dryRun && claudeResult.ok) { + details.push("No L2 code block found (Claude may have created source files directly)"); + } + + steps.push({ + name: `compile ${blockId}`, + ok: claudeResult.ok, + durationMs: stepTimer(), + claudeResult, + details, + errors, + }); + } + + const ok = steps.every((s) => s.ok); + return { phase: 6, name: "Compile", ok, durationMs: elapsed(), steps }; +} + +// ── Main ── + +async function main(): Promise { + const opts = parseArgs(); + + console.log("=== SVP E2E Workflow Test ==="); + console.log(`Mode: ${opts.dryRun ? "DRY RUN" : "FULL RUN"}`); + if (opts.allSonnet) console.log("Model override: all-sonnet"); + console.log(`Budget: $${opts.budgetUsd.toFixed(2)} total, $${opts.stepBudgetUsd.toFixed(2)}/step`); + console.log(); + + const phases: PhaseResult[] = []; + let projectDir = ""; + let totalCost = 0; + + // Phase 1: Setup + console.log("Phase 1: Setup..."); + const p1 = phase1_setup(opts); + projectDir = (p1 as any)._projectDir; + phases.push(p1); + console.log(` ${p1.ok ? "PASS" : "FAIL"} (${fmtMs(p1.durationMs)})`); + + if (!p1.ok) { + console.error("Phase 1 failed — cannot continue."); + process.exitCode = 1; + return; + } + + console.log(` Project: ${projectDir}`); + + // Phase 2: Design L5 + console.log("Phase 2: Design L5..."); + const p2 = phase2_designL5(projectDir, opts); + phases.push(p2); + totalCost += p2.steps.reduce((s, st) => s + (st.claudeResult?.costUsd ?? 0), 0); + console.log(` ${p2.ok ? "PASS" : "FAIL"} (${fmtMs(p2.durationMs)})`); + + if (!p2.ok && !opts.dryRun) { + console.error("Phase 2 failed — L5 design required for subsequent phases."); + phases.push(buildSkippedPhase(3, "Design L4"), buildSkippedPhase(4, "Design L3"), buildSkippedPhase(5, "Check"), buildSkippedPhase(6, "Compile")); + writeReport(phases, projectDir, opts); + process.exitCode = 1; + return; + } + + // Phase 3: Design L4 + console.log("Phase 3: Design L4..."); + const p3 = phase3_designL4(projectDir, opts); + phases.push(p3); + totalCost += p3.steps.reduce((s, st) => s + (st.claudeResult?.costUsd ?? 0), 0); + console.log(` ${p3.ok ? "PASS" : "FAIL"} (${fmtMs(p3.durationMs)})`); + + if (!p3.ok && !opts.dryRun) { + console.error("Phase 3 failed — L4 flows required for L3 design."); + phases.push(buildSkippedPhase(4, "Design L3"), buildSkippedPhase(5, "Check"), buildSkippedPhase(6, "Compile")); + writeReport(phases, projectDir, opts); + process.exitCode = 1; + return; + } + + // Budget check + if (totalCost >= opts.budgetUsd) { + console.error(`Budget exceeded ($${totalCost.toFixed(2)} >= $${opts.budgetUsd.toFixed(2)}). Stopping.`); + phases.push(buildSkippedPhase(4, "Design L3"), buildSkippedPhase(5, "Check"), buildSkippedPhase(6, "Compile")); + writeReport(phases, projectDir, opts); + process.exitCode = 1; + return; + } + + // Phase 4: Design L3 + console.log("Phase 4: Design L3..."); + const p4 = phase4_designL3(projectDir, opts); + phases.push(p4); + totalCost += p4.steps.reduce((s, st) => s + (st.claudeResult?.costUsd ?? 0), 0); + console.log(` ${p4.ok ? "PASS" : "FAIL"} (${fmtMs(p4.durationMs)})`); + + // Phase 5: Check (run even if previous phases had issues) + console.log("Phase 5: Check..."); + const p5 = phase5_check(projectDir); + phases.push(p5); + console.log(` ${p5.ok ? "PASS" : "FAIL"} (${fmtMs(p5.durationMs)})`); + + // Budget check before compile + if (totalCost >= opts.budgetUsd) { + console.error(`Budget exceeded ($${totalCost.toFixed(2)}). Skipping compile phase.`); + phases.push(buildSkippedPhase(6, "Compile")); + writeReport(phases, projectDir, opts); + process.exitCode = 1; + return; + } + + // Phase 6: Compile + console.log("Phase 6: Compile..."); + const p6 = phase6_compile(projectDir, opts); + phases.push(p6); + totalCost += p6.steps.reduce((s, st) => s + (st.claudeResult?.costUsd ?? 0), 0); + console.log(` ${p6.ok ? "PASS" : "FAIL"} (${fmtMs(p6.durationMs)})`); + + // Phase 7: Report + writeReport(phases, projectDir, opts); + + // Final status + const allPassed = phases.every((p) => p.ok); + console.log(); + console.log(`=== ${allPassed ? "ALL PASSED" : "SOME FAILURES"} ===`); + console.log(`Total cost: $${totalCost.toFixed(4)}`); + + if (!allPassed) process.exitCode = 1; +} + +function writeReport(phases: PhaseResult[], projectDir: string, opts: CLIOptions): void { + const report = buildReport(phases, projectDir, opts.dryRun); + const markdown = renderReport(report); + + // Write to tests/e2e/ + const reportDir = path.resolve(import.meta.dirname); + const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const reportPath = path.join(reportDir, `report-${ts}.md`); + writeFileSync(reportPath, markdown, "utf8"); + console.log(`\nReport: ${reportPath}`); + + // Also write JSON report + const jsonPath = path.join(reportDir, `report-${ts}.json`); + writeFileSync(jsonPath, JSON.stringify(report, null, 2), "utf8"); +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exitCode = 1; +});