From 41fdf79aea97d06eca5f853953d666ff79ea439a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Thu, 30 Jul 2026 14:30:20 +0800 Subject: [PATCH] refactor(leaf): declare params on LeafSpec; keep Call as execution Lift business flags/const params out of Call/PostMount, add typed flag defaults and policy gates, and realign the RFC acceptance bar to "no Execute/Call body exists only to assemble params". Co-authored-by: Cursor --- docs/rfc-command-framework-convergence.md | 1963 +++++++++++++++++---- internal/cmdcore/cmdcore.go | 40 +- internal/cmdcore/cmdcore_test.go | 64 + internal/helpers/devapp.go | 196 +- internal/helpers/devapp_cursor.go | 40 +- internal/helpers/devapp_leaf_test.go | 8 +- internal/helpers/leaf.go | 16 +- internal/helpers/leaf_policy_test.go | 165 ++ 8 files changed, 1999 insertions(+), 493 deletions(-) create mode 100644 internal/helpers/leaf_policy_test.go diff --git a/docs/rfc-command-framework-convergence.md b/docs/rfc-command-framework-convergence.md index 6a3fcfe7..a3d5ffe4 100644 --- a/docs/rfc-command-framework-convergence.md +++ b/docs/rfc-command-framework-convergence.md @@ -1,422 +1,1709 @@ -# RFC: Converging the DWS command frameworks onto one typed base +# RFC:将 DWS 命令执行收敛到一套类型化契约 -**Status**: draft, seeking review -**Scope**: `internal/cmdcore`, `internal/helpers` (LeafSpec), `internal/shortcut` -**Related PR**: #830 (landed the shared base; supersedes #827 and #829) +- **状态**:draft v5,征求设计评审 +- **范围**:`internal/cmdcore`、`internal/helpers`(`LeafSpec`)、`internal/shortcut` +- **实现基线**:PR #830 的 `6fb08c9e` +- **应丢弃的原型**:`0f08484d`(`Invoke` / `Orchestrate` / `Ctx`) -This document is self-contained: a reviewer without repository access should be -able to judge the design from it. +本文档取代收敛方案的前三版草案。它保留了 PR #830 中有价值的部分——一份声明同时驱动运行时校验、Runtime Schema 和帮助信息,外加严格的漂移门禁——但不把原型提交引入的派发 API 固化下来。 + +v5 的变更,全部来自第二轮独立评审并经过代码级复核。它们修的是 v4 自己引入的规范矛盾,因此逐条列出而不是折进正文: + +- §5.11.1 新增:dry-run 计划的**内部表示与渲染形态分开上线**。v4 声称 M1「无上线输出变化」,但 28 条 Leaf 命令今天的 `--dry-run` 输出是 `executor.Result` 信封,与 `DryRunPlan{Summary, Steps[]}` 按构造不可能字节等价,与 M1 的零增量门禁直接冲突。M1 现在交付一个逐字复刻遗留信封的兼容渲染器;切换到原生计划渲染是后续 approved delta。M0 新增第 12 项交付物固定该信封 golden。 +- §M4 明确 `Args` 等函数值字段比较的是**规范化后的有效元数标签,而不是字段身份**。遗留 `mount()` 不设 `Args`(nil),声明侧映射为非 nil 的 `ArgsArbitrary`;二者运行时等价,但反射式比较不等,会让第一个分批卡在假阳性上。§M5 的「三表面字节相等」按同一定义收窄。 +- §5.7 取消「`Definition.Effects` 必须与 `CallSpec.Effect` 一致」这条自相矛盾的规则。派发器需要 `{CallSpec.Effect, EffectOutput}` 两个许可,而 §5.5 规定未声明的 effect 是内部安全错误,于是旧规则同时要求作者写两项、又要求它与单数字段「一致」。形态 1/2 现在完全不手写 `Effects`,由 `Bind` 推导;手写只属于形态 3。 +- §5.4 显式记录 `Get` **保留零占位返回、以毒化取代静默**这次原则调整,并在 §4.3 指明那里批评的是静默而非零值本身。v4 静默放松了早期草案的「绝不降级」原则,却让 §4.3 读起来仍持旧立场。 +- §5.7 定义 `ResponseProjector` 返回 error 的**归类规则**(`apperrors` 原样透传,裸 error 落入现状同一类别,错误发生在 `ResultSink.Write` 之前)。此前未定义,而 M5b 阶段 A 要求错误类别/消息/退出码逐条对等,该门禁按构造无法满足。 +- §3.4 的修复**脱离本 RFC 成为独立 hotfix H0**(§8),不再排在架构序列的第四个 PR,§11 决策 5 也不再阻塞本 RFC 的任何里程碑。一个已上线的数据完整性缺陷不应由一套架构改造持有排期。 +- §M4 新增**绝对性能预算**。此前只有逐 PR 相对阈值,10 个 PR 各 9.9% 可累积到约 2.6 倍而永不触发门禁。相对阈值退为附加门禁。 +- §5.11 的预览文件计数由「3 个包中的 10 个文件」更正为「2 个包中的 9 个文件」;旧数字把框架自身的 `runner.go` 算作了命令声明。这是本 RFC 第二次出现同类计数偏差,因此 M0 交付物 1/10 落地前,所有章节引用的计数一律标注暂定。 + +v4 的变更,全部来自第一轮独立评审并经过代码级验证: + +- §3.4 记录了一个**当前已上线的缺陷**:在非交互进程中,write/high-risk 类 Shortcut 会静默丢弃写操作并以 0 退出。bug-for-bug 兼容**明确不覆盖**这一条。 +- §5.9 把「拒绝(decline)兼容」的适用范围收窄为真实的交互式拒绝,使差分门禁无法把该缺陷固化为必需行为。 +- §5.3 新增 flag 级的 `Input`(`@file` / stdin),§5.11 新增声明式 dry-run 计划。两者都是契约层面的能力,而非逐个 handler 的口头约定;两者都借鉴自一个同类 CLI,该 CLI 已在全量命令上提供这两项能力。 +- M0 新增**减法普查**:在 M7 假定所有手写命令都必须迁移之前,先把它们分类为 删除 / 迁移 / 能力受阻。 +- M1 新增**入口门禁**:新命令必须走新核心,使该里程碑独立于 Shortcut 分批迁移就能产生价值。 +- M2 在本就要重写该调用点的接缝工作中,顺带修复非交互确认。 +- §3.5 与 §5.1 记录了声明与执行之间真正的分界:对全部 376 个 Shortcut `Execute` 函数体的 AST 普查表明,大多数命令的参数装配本可由声明表达。`Definition` 去掉强制 Handler 字段,「可执行」成为一个独立类型;mcpbind 新增响应投影钩子。验收标准不是删光执行体,而是**没有 `Execute`/`Call` 函数体只为装配参数而存在**;执行体仍是一等扩展点(与 LeafSpec 的 `Call`、同类 CLI 一致)。 + +核心决策是: + +> 收敛到一套可执行的 CLI **契约**,而不是一个万能结构体,也不是「一次后端调用」与「多次后端调用」之间的区分。 + +声明与执行在**框架边界**处分离,而不是靠强迫每条命令各自持有一个 Handler 来分离。一条受管命令声明的是 CLI 表面、校验规则,以及——当它只有一次后端调用时——调用绑定。固定流水线由框架执行。只有在控制流无法声明时才存在命令式 `Handler`。MCP 载荷绑定与响应投影都不放在 `cmdcore` 内。裸 Cobra 命令被明确排除在受管命令保证之外。现存裸命令通过清单方式豁免;只有新增的裸注册才需要迁移元数据。 --- -## 1. Context +## 1. 决策摘要 -`dws` (DingTalk Workspace CLI) is a Go/Cobra CLI whose commands are thin wrappers -that dispatch a JSON-RPC `tools/call` to DingTalk MCP servers. It targets both -humans and AI agents, so every command also publishes an Agent-visible "Runtime -Schema" (parameters, constraints, safety/confirmation semantics). +1. 把命令定义拆分为纯 CLI `Contract` 和一个**由类型安装、而非以可空字段声明**的执行所有者。`Definition` 本身不可编译;Handler 是控制流无法声明时的逃生口。 +2. 每个 flag 恰好解析一次,得到不可变的类型化 `ResolvedValues` 快照,且发生在任何校验或执行之前。 +3. 让 required 检查、enum 检查、关系约束、自定义校验器、MCP 绑定与 handler 全部消费同一份快照。 +4. 在把全部 LeafSpec 命令迁移到 `mcpbind` 的同一个合并边界内,从核心契约中移除 `BuildArgs` 与 MCP 形状的字段。 +5. 不要把单步与多步执行建模成两种不同的核心派发器。改用三种声明形态:仅调用绑定;调用绑定加一个纯响应投影器;命令式 Handler。 +6. 不允许受管命令通过 `RunE` 绕过 required/约束/Risk 的强制执行。裸 Cobra 命令走一条独立且命名诚实的路径。 +7. 把取消表示为类型化的结果/哨兵值。迁移期间保持当前退出行为;收敛方向另行决策。 +8. 逐字段定义 Runtime Schema 的归属权。不要用源码声明取代经过评审的身份/导航注册表。 +9. 只有在全部 376 个内置命令都通过完整的结构对等与差分运行时对等之后,才把 Shortcut 接入生产。 +10. 保留 `LeafSpec` 作为面向 MCP 的门面,直到有证据表明它不再产生价值。语义收敛并不要求删除每一个适配器类型。 +11. 迁移期间对遗留 Shortcut 的默认值强制转换保持 bug-for-bug 兼容。修正畸形的 int/bool/list 默认值属于独立的行为变更。 +12. 从规范表面生成 Catalog 与接口产物,并显式采用 `auto(checked-in manifest)` 执行配置,同时独立证明 `旧遗留表面 == Contract 表面 == 规范表面`。 +13. 每一次受管的后端/输出副作用都必须由调用级许可(permit)把关;不要把裸输入流或裸后端 helper 暴露给受管 Handler。 +14. 保持遗留行为 bug-for-bug 兼容,**除非**该行为本身是正确性缺陷。§3.4 的非交互确认要在差分门禁能够要求它之前修好,因为兼容门禁会保护它所度量的一切。 +15. 把「没有可用的交互输入」当作一种独立的确认结果,与接受和拒绝都不同。 +16. 让 dry-run 输出与 `@file`/stdin 输入成为声明式的契约能力,而不是逐 handler 的代码,这样覆盖率就是框架的属性,而不依赖作者自觉。 +17. 从 M1 起要求新命令使用新核心,使框架在 Shortcut 分批迁移之前就已改善代码库。 +18. 把 LeafSpec 已上线的 `Bind → Call → callMCPTool` 路径视为「声明可以完成执行」的存在性证明:27 个源声明 / 28 条上线命令,生产环境 `RunE` 用量为零。Shortcut 应当获得同样的绑定词汇,而不是把每个 `Execute` 都包成 Handler。 +19. 用**残留的手写执行体数量**衡量迁移是否成功(当前为 376 中的约 31 条:多步加纯本地),而不是看是否每条命令都有非 nil 的 Handler。 -### 1.1 Three command-definition systems (measured) +--- -| System | Count | Shape | Notes | +## 2. 目标与非目标 + +### 2.1 目标 + +- 为 flag、类型化默认值、required 语义、enum、关系约束、位置参数、帮助信息和运行时 Risk 提供一份权威的可执行契约。 +- 校验、约束、绑定与 handler 观察到的是完全相同的已解析值。 +- 单次调用与多步命令都走同一套受管预检。单次调用命令优先使用声明驱动的派发(形态 1/2);多步与纯本地命令保留命令式 Handler(形态 3)。 +- 静态内置命令在测试/启动阶段快速失败;动态或用户自定义命令返回可操作的构造错误,而不是 panic。 +- LeafSpec 与 Shortcut 可以通过新核心编译,且不改变各自当前的对外行为。 +- 每一次迁移都由结构快照和带后端记录的差分测试背书,而不仅仅是生成物的 catalog 漂移。 +- 上线过程具备仅作用于执行的新旧选择器、一份签入的精确键清单,以及经过测试的同二进制回滚。 + +### 2.2 非目标 + +- 本 RFC 不改变用户拒绝时的退出码。 +- 不在同一个项目内迁移所有手写命令。 +- 不要求用一个 Go 结构体同时承载 CLI、后端、发现和经评审的语义元数据。 +- 不移除经评审的 CommandRegistry 或 schema 评审层。 +- 不主张「生成的 catalog 字节一致」就能证明运行时等价。 +- 不在 `LeafSpec` 或 `PostMount` 的真实能力获得具名替代之前删除它们。 + +--- + +## 3. 现状证据与修正后的范围 + +### 3.1 命令体系 + +| 体系 | 当前数量 | 说明 | +|---|---:|---| +| 手写 Cobra 命令 | 1195(暂定普查值) | 先前的 AST 计数尚无签入的复现脚本;M0 使其可审计 | +| Shortcut | 376 | 208 read、142 write、26 high-risk-write | +| 公开 Shortcut 列表 | 265 | 默认 `shortcut list`;`shortcut list --all` 覆盖全部 376 | +| LeafSpec | 27 个源声明 / 28 条上线命令 | 目前全部属于 devapp;其中一个生命周期定义被实例化两次 | + +在分母定义清楚之前,不要给这些数字附加百分比。这三行是不同的命令表面,并不自动构成一个互斥的总体。 + +### 3.2 重要的兼容性事实 + +- 12 个生产 Shortcut 使用 `Validate`;其对外行为覆盖 14 条 `ConstraintCustom` 声明。 +- 干净的内置注册表中,有 78 个带非空默认值的 flag 实例,分布在 53 条命令上。它们由 70 个字面量构造点展开而来,因为共享的 flag 模板被复用。78 个之中,有 52 个 flag(分布在 39 条命令)的默认值为非字符串类型(`int`、`bool` 或 `string-slice`)。 +- 默认的公开列表输出 73 个带默认值的 flag;`--all` 输出全部 78 个。用户自定义 Shortcut 属于另一个运行时总体。 +- 干净的内置命令树有 17 个隐藏兼容 flag。两种 list 模式都刻意序列化零个隐藏 flag,因此 list 输出不能作为完整的编译器基线。 +- 文本搜索在 16 个 Shortcut 文件中找到 19 处 `time.Now()` 匹配,其中 1 处在注释里。18 处可执行调用由 15 个文件中的 16 处 handler 调用加 2 处用量记录调用组成。handler 取时间必须使用 Runtime 注入的 Clock,而记录器接收自己独立的 Clock 依赖。 +- 只有 168 个 write/high-risk Shortcut 能走到确认-拒绝分支。改变拒绝策略并不影响全部 376 条命令。 +- 265 条公开 Shortcut 列表项与 Runtime Schema 的 Shortcut 叶子不是同一总体:经评审的注册表当前有 210 个 `+shortcut` 路径。余下 55 条需要精确的、经评审的排除项,而不是隐式缺席;两个数字不得互相替代。 +- 27 个 LeafSpec 源声明**每一个**都使用 `PostMount`: + - 15 个源声明直接使用 `devAppMeta` 包装器; + - 12 个使用内联闭包; + - 运行时变成 16 个直接包装器加 12 个闭包,因为一个生命周期定义被实例化两次; + - 全部 28 条上线命令都调用 `devAppLeafMeta`; + - 4 条额外添加 cursor flag; + - 内联闭包还覆盖命令别名、兼容/隐藏 flag、事件码、scope、成员、安全设置和一个 bool flag。 +- `devAppLeafMeta` 做的事不止 Schema 身份:它还设置 `NoArgs`、`DisableAutoGenTag`、遗留偏好和后端注解。 +- 生产环境的 Leaf 声明使用了一个长兼容别名,且 `EnvVar` 与 `RequiredHint` 字段用量为零。全部 27 个都使用 Call;生产环境的 `RunE`、声明式 Risk/Constraints/Required 以及 MarkRequired 均未被使用。这些框架特性属于兼容测试面,不能作为 Leaf 广泛使用它们的证据。 + +以上「源码 / 上线」的区分是针对当前提交的审计事实,不是永久的规划常量。M0 会签入复现脚本,并使其成为后续里程碑的权威依据。 + +### 3.3 PR #830 证明了什么 + +PR #830 为项目提供了一个有用的共享基座和针对它的直接测试。其零漂移结果,是对这些产物所覆盖的生成表面的有效证据。它的包级测试覆盖了 LeafSpec 运行时流水线。 + +它**没有**证明 `FromShortcut` 可以替换上线的 `mount()`,因为该适配器并未上线,且仍然丢失运行时语义。本地的 `Invoke`/`Orchestrate` 原型让这套投影可被调用,但没有填补那些语义缺口。 + +### 3.4 一个兼容策略绝不应保留的已上线缺陷 + +§3.2 中其他所有行为都是有意保留的。这一条不是兼容性事实,而是一个已上线的正确性缺陷;必须在此点名,否则 §5.4.1 与 M3 会迫使新编译器复现它。 + +遗留的确认提示直接读取进程 stdin,没有任何交互性检查: + +```go +// internal/shortcut/runner.go +reader := bufio.NewReader(os.Stdin) +answer, _ := reader.ReadString('\n') +answer = strings.TrimSpace(strings.ToLower(answer)) +return answer == "yes" || answer == "y" +``` + +在非交互进程中——agent、流水线、CI,或任何没有附加终端的父进程——该读取会在 EOF 处立即返回。空答案与用户键入 "no" 无法区分,于是 runner 走拒绝分支并返回 `nil`。在当前分支上用一条真实的 write Shortcut、关闭 stdin 实测: + +| 调用方式 | 退出码 | stdout | 是否触达后端 | |---|---:|---|---| -| Handwritten `cobra.Command` | **1195** | `dws ` | 96% of commands; `internal/helpers/*.go`, 7.3万 lines | -| Shortcut | **376** | `dws +` | Declarative + **multi-step orchestration** | -| LeafSpec | **27** | `dws ` | Declarative, single-step; only `devapp` | +| write Shortcut,stdin 关闭 | 0 | 0 字节 | 否 | +| 同一命令加 `--yes` | 1 | 错误载荷 | 是 | -Duplication measured inside the handwritten commands: `mustGetFlag` ×500, -`callMCPTool*` ×469, `flagOrFallback` ×306, required validation ×262, -`AttachRuntimeSchema`/`AnnotateRuntimeConstraints` ×165 each. +第二行是因为测量沙箱中一个无关的环境原因而失败的,而这恰恰使它更有说明力:真正执行了的命令报告了自己的失败,而什么都没做的命令报告了成功。提示语走 stderr,因此一个读取 stdout 和退出码的调用方——这正是非交互消费者的常规契约——看到的是一次干净的成功,而该操作从未发生。这种沉默恰好发生在无法挽回的那个方向上:真实的后端失败始终可见,唯独「什么都没发生」不可见。 -A static AST census classified the 1195 handwritten commands by how they could -migrate: +影响范围与归类: -| Class | Count | Criterion | Disposition | -|---|---:|---|---| -| GROUP | 594 | no MCP call, no loop/IO (pure parent) | not migrated | -| **A** pure CRUD | **141** | exactly 1 MCP call, no loop/IO/confirm | 1:1 migratable | -| **B** confirm/fallback | **92** | 1 MCP call + confirm/dry-run/alias fallback | migratable | -| **C** multi-step / special IO | **368** | 0 or ≥2 MCP calls, loops, upload/poll | needs multi-step support | +- 全部 168 个 write/high-risk Shortcut 受影响;read 命令从不提示。 +- `cmdcore.ConfirmRisk` 已经读取 `cmd.InOrStdin()` 而非进程 stdin,因此它是可测试的;但它套用同一条「EOF 等于拒绝」的规则,所以一旦非交互父进程关闭输入,它受影响的程度完全相同。 +- 这**不是** §5.9 的拒绝策略问题。那个问题是「真实拒绝应当产生哪个退出码」。这里没有任何人拒绝过:拒绝分支在 CLI 最常被使用的那种环境里默认就可达。 +- 由于这是用户可见的行为变更,它走 §9 的 approved delta 流程。该流程不依赖 Contract、解析器或 mcpbind,因此这项修复不受新核心的进度约束。 +- **因此它作为独立 hotfix(§8 中的 H0)先于本 RFC 的实现序列发布,而不是排在架构工作的第四个 PR。** 这是一个已上线的数据完整性级缺陷:168 条写命令在 agent/CI 环境下静默丢弃写操作并报告成功。把它排在一套架构改造之后,等于让缺陷的存续时间取决于一个与它无关的项目的进度。本 RFC 记录它、给出修复形态与门禁,但**不持有它的排期**;即使本 RFC 被否决或推迟,H0 也应照发。 -So 233/601 (38.8%) of commands with a body are migratable **today**; the 368 -C-class ones require the base to model multi-step orchestration. +M2 在确认提示的接缝工作中修复它;§5.9 记录了为什么差分对等不得要求旧行为;§11 决策 5 记录产品对信号形式的选择。 -### 1.2 What already landed (PR #830) +### 3.5 376 个 Shortcut `Execute` 函数体实际在做什么 -1. `LeafSpec` gained a declarative contract: `at_least_one` / `exactly_one` / - `mutually_exclusive` constraints, `bool` + `string-slice` flag kinds, and - `Risk`-driven write confirmation honoring `--dry-run` / `--yes` (including - root-injected globals, via a 3-level flag lookup). -2. `internal/cmdcore` — a dispatch-agnostic shared base extracted **verbatim** - from `leaf.go`: flag registration, the `explicit → alias → env → default` - value chain, required validation, constraint declaration checks + runtime - enforcement, Risk confirmation, `toolArgs` assembly, Runtime Schema - projection, help rendering. -3. `cmdcore.CommandSpec` + `cmdcore.NewCommand` — one typed definition and one - orchestration path; dispatch became a spec field. -4. `helpers.FromLeafSpec`; `NewLeafCommand` is now a one-line mapping. All - LeafSpec commands (incl. all 27 devapp leaves) flow through the unified spec. -5. `shortcut.FromShortcut` — a typed seam projecting a Shortcut's shared base - into a `CommandSpec`, deliberately **not** wired into the live `mount()`. +LeafSpec 已经在生产中证明了声明驱动的执行:27 个源定义每一个都使用 `Call`,生产环境 `RunE` 用量为零。框架路径是 `BuildArgs → Call → callMCPTool`。Shortcut 的 API 恰好相反:`Execute` 是必填的,而 `Flag` 没有 `Bind`/`Transform`/`OmitEmpty` 字段,于是每条内置命令无论是否需要,都要付出一个手写函数体的代价。 -### 1.3 Verification discipline (already enforced, non-negotiable) +对全部 376 个内置 `Execute` 函数字面量做 AST 普查,划出一条架构分界线:函数体是否消费后端响应? -- **`check-generated-drift`**: regenerate the Agent metadata + command catalog - and byte-compare against the committed artifacts. For a refactor, a - byte-identical catalog (840 tools, identical hashes) is the equivalence proof - for the **build-time projection** — identity, flags, help, annotations. -- **Runtime semantics are NOT covered by drift.** Required validation, args - assembly, confirmation and dispatch order are evidenced only by unit tests. - (An earlier version of this work overstated drift as proof of runtime - behavior; that claim was corrected.) -- **100% changed-code coverage** gate. Important subtlety: CI runs - `go test -coverprofile` **without `-coverpkg`**, so each package is measured - only by its *own* tests. A new package whose logic is exercised only from a - consumer package will fail the gate (this actually happened: `cmdcore` - self-coverage was 31.5%, CI changed-code coverage 40.5%, plus an overall - regression — fixed by giving `cmdcore` its own exhaustive tests). -- **Interface compatibility**: the public Cobra command surface is diffed - against both the PR merge-base and the latest stable GA tag. +| 形态 | 数量 | 占比 | 声明需要什么 | +|---|---:|---:|---| +| 单次调用,直接 `return` 调用结果 | 237 | 63% | 仅需调用绑定;零代码 | +| 单次调用,消费响应后再整形/输出 | 108 | 29% | 调用绑定 + 一个纯投影器 | +| 两次及以上后端调用 | 21 | 5.6% | 命令式 Handler | +| 零后端调用(纯本地) | 10 | 2.7% | 命令式 Handler,或一项具名的本地能力 | + +这些数字在 **P0 签入普查脚本之前均为暂定值**,与 1195 这个命令数一样(见 M0 交付物 10)。此处引用它们,是因为它们改变了目标 API 的形状;但 §10 的残留标准是对照签入的 golden 来判定的,而不是对照本表。 + +**376 中有 345 条(92%)只发起一次后端调用。** 21 条多步命令需要框架无法声明的控制流。10 条纯本地命令属于另一种情况:它们完全不需要后端调用,因此当下归为形态 3,但一旦 M7 为它们所用的本地能力命名,就可能变为可声明。这两组都没有被断言为永久命令式。 + +在 237 个直接返回的函数体中,有 108 个整体就是一条语句,形如 `return rt.CallMCP(tool, map[…]{…})`。余下 119 个包含条件判断;主流形态是 `if rt.Changed("limit") { params["limit"] = … }`,这正好对应 `OmitEmpty` / `PresenceExplicit` / `IncludeMode`——LeafSpec 和 §5.7 的绑定规则早已具备的词汇。 + +那 108 个消费响应者,正是「声明等于执行」并非免费的原因。外层包装是高度重复的——65 处输出 `{count, list}`——但字段映射不是:存在 82 个各自独立的 `*Project` helper,且出现频次最高者连同自身定义也不过 5 次,实际复用更低。因此形态 2 的诚实目标不是一套通用的投影 DSL,而是一个纯 `func(response) any`,不触碰调用、确认、输出写入器或错误归类。`ResultSink` 负责写出,不负责整形。 + +对目标 API 的推论:如果要求每个受管定义都有非 nil 的 `Handler`,就会把 376 个 `Execute` 函数体变成 376 个 Handler,却未必消掉「只为装配参数而存在」的函数体。那样只统一了表面与安全检查,却丢掉了本 RFC 所声称的分离。因此 `Definition` 不强制 Handler(见 §5.1):可声明的装配走绑定;真正的业务控制流留在执行体。验收标准是**没有函数体只为装配参数而存在**,而不是把执行体压到某个残留数量。§3.5 的三形态表保留为声明覆盖度参考,不再作为「删除目标」。 --- -## 2. Design review findings +## 4. 为什么最初的 S1 模型不是目标架构 -An adversarial review found **no BLOCKERs**: the Phase-1 extraction was -mechanically verified as a verbatim move, and the unified `BoolFlag` was proven -equivalent to the two readers it replaced. The following are the structural -issues that motivate this plan. (Items marked ✅ were already fixed in #830.) - -### Structural - -**F1. `Dispatch` is single-step biased; the type permits an unrunnable value.** -```go -Dispatch func(cmd *cobra.Command, args []string, toolArgs map[string]any) error -``` -This presumes "assemble `toolArgs` → make one call". A Shortcut's -`Execute(rt *RuntimeContext)` is arbitrary multi-step orchestration and **cannot -be expressed**. Consequently `FromShortcut` returned a spec that could not run. -✅ Partially mitigated in #830 by making `NewCommand` reject such a spec — but -that is a patch, not a model fix. **A type that can be constructed into a -permanently unusable state means the model is missing a dimension.** - -**F2. The base leaks the MCP payload shape.** -`BuildArgs → map[string]any` is the MCP `tools/call` argument shape, yet it lives -in a package documented as dispatch-agnostic. `devapp` dispatches through -`executor.Runner` and is still handed a `toolArgs` map. - -**F3. The same field name carries two semantics.** -- `Required`: cmdcore accepts *any effective value* (including registration - default and env fallback); Shortcut requires the flag to be `Changed`. -- Decline behavior: LeafSpec/cmdcore return a typed validation error (**exit ≠ - 0**); Shortcut's `mount()` returns `nil` (**exit 0**). - -These are semantic splits inside a "shared" base — documentation cannot fix them. - -**F4. The constraint model is incomplete and asymmetric with the Schema.** - -| Capability | Runtime Schema | `cmdcore.Constraint` | -|---|---|---| -| `mutually_exclusive` | ✅ | ✅ | -| `require_one_of` | ✅ | ✅ (`at_least_one` / `exactly_one`) | -| **`require_together`** | ✅ | ❌ | -| `enum` | ✅ (`AnnotateRuntimeFlagEnum`) | ❌ | - -Anything unrepresentable falls back to the imperative `Validate` hook, which -**loses the Schema projection and help rendering** — destroying the main value of -the design ("declare once, take effect in three places"). - -**F5. Too many hooks; `PostMount` is a back door.** -Extension points: `Validate` + `PostMount` + `RunE` + dispatch. `PostMount(cmd)` -can rewrite anything on the built Cobra command (args, annotations, flags), so -any declarative guarantee can be bypassed — which is precisely why drift became -the only safety net. Measured real usage is narrow: `devAppLeafMeta` ×8 (attach -schema identity) and `registerDevAppCursorFlags` ×4 (pagination flags). - -### Secondary - -**F6. `LeafSpec` is now vestigial** — 179 lines that are mostly comments and type -aliases; the only substantive own fields are the three dispatch fields -(`Server`/`Tool`/`Call`). It is a pure forwarding layer kept for 27 call sites. - -**F7. `FlagSpec` has 14 fields with three "two ways to do one thing" pairs**: -`Default` vs `ArgDefault`, `OmitEmpty` vs `Transform`-returning-nil, `Required` -vs `MarkRequired`. This accreted from per-command special cases rather than a -clean model. - -**F8. "Single registry" currently covers ~2%** of commands. The 368 C-class -handwritten commands are exactly the ones the current `CommandSpec` cannot -express, so F1 is a prerequisite for coverage, not an optional polish. - -**F9. The Schema still has two sources.** `AnnotateConstraints` projects only two -constraint kinds; `required` / `enum` / `require_together` still come from the -separately reviewed registry + hint files. The declarative spec is not yet the -single source of parameter truth. - -### What is worth preserving - -- **Declare once → runtime validation + Schema projection + `--help`** (the core - value). -- **Drift-as-equivalence-proof** — makes a 400+ command migration verifiable. -- **Type aliases for zero-churn migration** (`type LeafFlag = cmdcore.FlagSpec`). -- **Dispatch as a spec property** rather than a framework identity. - ---- - -## 3. Plan - -### Dependency graph -``` -S1 dispatch layering + Ctx ──┬─→ S3 constraints/enum ──┐ - │ ├─→ S8 Shortcut live-wire → S9 Schema single source -S2 semantics unification ────┘ │ - (needs a product decision) │ -S4 MCP shape push-down → S5 FlagSpec orthogonalization → S6 narrow PostMount → S7 delete LeafSpec -``` -S1/S2/S3 are **hard prerequisites** for S8. S4→S7 is an independent cleanup -chain that can proceed in parallel. - ---- - -### S1 — Dispatch layering + unified `Ctx` (biggest lever) - -Fixes **F1**; unblocks the 368 C-class commands and S8. +原型新增了: ```go -// cmdcore: framework-neutral execution context (knows nothing about MCP) -type Ctx struct { /* cmd, args, declared flags */ } -func (c *Ctx) Command() *cobra.Command -func (c *Ctx) Args() []string -func (c *Ctx) Str(name string) string // reuses alias → env → default chain -func (c *Ctx) Int(name string) int -func (c *Ctx) Bool(name string) bool -func (c *Ctx) StrSlice(name string) []string -func (c *Ctx) Changed(name string) bool -func (c *Ctx) DryRun() bool -func (c *Ctx) Yes() bool - -// CommandSpec: exactly one of the three, validated at CONSTRUCTION time -RunE func(cmd *cobra.Command, args []string) error // escape hatch -Invoke func(c *Ctx, toolArgs map[string]any) error // single-step -Orchestrate func(c *Ctx) error // multi-step +RunE func(*cobra.Command, []string) error +Invoke func(*Ctx, map[string]any) error +Orchestrate func(*Ctx) error ``` -Notes: -- `Ctx` accessors honor the declared fallback chain, so a body reads exactly the - value the required/constraint checks saw. This is **stronger** than Shortcut's - `RuntimeContext`, which reads flags bare. -- **Layering rule**: MCP capability stays out of `cmdcore`. - `shortcut.RuntimeContext` keeps `CallMCPData`/`CallMCPWriteData`/`Output`; the - `FromShortcut` adapter builds it from `Ctx.Command()` inside `Orchestrate`. - (Embedding `*cmdcore.Ctx` into `RuntimeContext` is deferred to S8 to avoid - changing 376 shortcuts' flag-reading behavior in this step.) -- Construction-time `panic` replaces the run-time error added in #830, - consistent with the existing `ValidateConstraintDecls` philosophy: a spec with - no runnable body (or two competing ones) is a programming error that every - test and startup path trips immediately. +它不稳定,有四个原因。 -**Gates**: drift zero; leaf suites green; three construction-panic cases; -`Orchestrate` confirmation/decline cases; `Ctx` accessor cases. -**Risk**: low (existing dispatch shifts to `Invoke`). -**Effort**: 1 PR (medium). *Status: in progress on the branch.* +### 4.1 `Invoke` 是由下一步就要移除的 MCP 泄漏所定义的 + +`Invoke` 与 `Orchestrate` 之间唯一本质的区别,是 `cmdcore` 会在 `Invoke` 之前构建 `map[string]any`。一旦载荷绑定移出核心,两种形态都变成 `func(*Runtime) error`。 + +因此旧的 S4 并不是一条独立的清理链。载荷绑定的分离与 handler 模型必须一起设计。 + +### 4.2 `RunE` 会让已声明的安全契约变成假话 + +当前的 `RunE` 逃生路径会注册并投影 flag、约束与 Risk,然后绕过它们的运行时强制执行。于是一条命令可以对外宣称「需要确认」,却从不确认。 + +裸命令有时是必要的,但不能把它们表示成好像也获得了受管保证。 + +### 4.3 `Ctx` 没有提供唯一的已解析真值 + +原型对未知 flag 和整数解析错误静默返回零值。`Orchestrate` 还跳过了 `BuildArgs`,而后者目前正是某些解析/转换错误浮现的地方。因此非法的环境变量/默认值数据会以 `0` 的形式抵达 handler。 + +此处的缺陷是**静默**,而不是零返回本身。§5.4 的 `Get` 仍返回零占位,但那次读取会毒化整个调用并在四个强制检查点被拦下;两者的区别不在返回值形状,而在于坏读取能否走到一次 effect 前面。该原则调整在 §5.4 中显式记录。 + +正确的原语是一份已校验的快照,而不是一组建立在可变 Cobra flag set 之上的尽力而为的 getter。 + +### 4.4 「恰好一个可空函数」是校验,不是类型保证 + +构造期的基数检查有用,但三个可空函数仍然允许非法取值。`FromShortcut` 还可能围绕一个为 nil 的 `Shortcut.Execute` 生成非 nil 的包装器,于是基数检查通过,执行却在之后失败。 + +因此稳定 API 不靠「更好的基数检查」来解决这个问题,而是**按类型**分离声明与可执行性:`Definition` 不可编译,而获得可编译的 `*Executable` 的每一条途径都恰好安装一个执行所有者(见 §3.5、§5.1)。框架派发器覆盖了常见情形,因此多数命令完全不需要 Handler。 --- -### S2 — Semantics unification (contains a **decision that needs sign-off**) +## 5. 目标架构 -Fixes **F3**. +```text +Definition(仅声明;不可编译) + ├── Contract ── compile ──> Cobra 表面 + Runtime Schema 注解 + ├── Validators + ├── CompileFacts + └── Effects + │ + │ 恰好一个构造函数安装执行所有者 + ├── mcpbind.Bind(def, CallSpec[, WithProjector]) ──> *Executable + └── cmdcore.WithHandler(def, Handler[, WithPlan]) ──> *Executable + │ + v +解析 argv + → 一次性解析全部取值 + → required / enum / 关系校验 + → 自定义校验器 + → Risk 确认 + → 执行(三者恰好其一): + ├── 形态 1:mcpbind 派发器 ── 编码 → 一次后端调用 → ResultSink + ├── 形态 2:同上,再经 ResponseProjector → ResultSink + └── 形态 3:Handler.Execute(Runtime) ── 零/一/多次后端调用 + +裸 Cobra 命令 ──> 独立的遗留/裸构造器,无受管保证 +``` + +三种声明形态取代「每条命令都有一个 Handler」: + +| 形态 | 声明内容 | 命令式代码 | 当前普查数 | +|---|---|---|---:| +| 1. 绑定调用 | Contract + mcpbind `CallSpec` | 无 | 237 | +| 2. 绑定调用 + 投影 | 形态 1 + 纯 `ResponseProjector` | 仅 data→data | 108 | +| 3. 工作流 | Contract + `Handler` | 完整 Execute 函数体 | 31 | + +形态 1 和 2 正是 LeafSpec 已经在全部 28 条上线命令上交付的东西(形态 1;生产环境尚无响应投影器)。形态 3 保留给 21 条多步与 10 条纯本地 Shortcut,以及未来任何提示内容或调用扇出无法声明的命令。 + +### 5.1 Definition、Contract 与 Handler + +下面的 API 草图固定包边界;具体命名可在实现阶段调整。 -**S2a. `Required`** — make the divergence explicit instead of implicit: ```go -type RequiredMode int +// Definition 仅是声明。它没有 Handler 字段,因此「已声明但没有执行所有者」 +// 是不可表示的,而不仅仅是被拒绝。 +type Definition struct { + Contract Contract + Validators []ValidatorSpec + Facts CompileFacts + // Effects 由形态 3 作者声明。形态 1/2 留零值:mcpbind.Bind 从 CallSpec + // 推导 {CallSpec.Effect, EffectOutput},非零值在那里是构造错误(§5.7)。 + Effects []EffectKind +} + +// Executable 把一份冻结的 Definition 与恰好一个执行所有者配对。所有字段均不导出, +// 唯一构造函数是 mcpbind.Bind(形态 1/2)与 cmdcore.WithHandler(形态 3)。 +// 「无所有者」与「双所有者」都无法表达,因此 §4.4 对基数检查的批评不会在此重现。 +type Executable struct { + definition Definition + owner executionOwner + plan Planner +} + +type HandlerOption func(*handlerConfig) error + +// WithPlan 提供 §5.11 的 dry-run 计划;write/high-risk 的形态 3 handler 必须声明。 +// 形态 1/2 的计划从 CallSpec 推导。 +func WithPlan(Planner) HandlerOption + +func WithHandler( + def Definition, + h Handler, + opts ...HandlerOption, +) (*Executable, error) + +type Contract struct { + Use string + Short string + Long string + Example string + + Hidden bool + Aliases []string + DisableAutoGenTag bool + + Args ArgsSpec + Flags []FlagSpec + Constraints []Constraint + Risk Risk +} + +type RuleID string + +type Risk uint8 + const ( - RequiredAnyEffectiveValue RequiredMode = iota // = today's LeafSpec behavior (default) - RequiredMustBePassed // = today's Shortcut behavior (Changed) + RiskInvalid Risk = iota + RiskRead + RiskWrite + RiskHighRiskWrite ) + +type ConstraintKind uint8 + +const ( + ConstraintInvalid ConstraintKind = iota + ConstraintAtLeastOne + ConstraintExactlyOne + ConstraintMutuallyExclusive + ConstraintRequireTogether + ConstraintCustom +) + +type ArgsPolicy uint8 + +const ( + ArgsInvalid ArgsPolicy = iota + ArgsArbitrary + ArgsNoArgs + ArgsDeclarative +) + +type ArgsSpec struct { + Policy ArgsPolicy + Positionals []PositionalSpec +} + +type PositionalSpec struct { + Name string + Description string + Required bool + Variadic bool +} + +type Constraint struct { + ID RuleID + Kind ConstraintKind + Flags []string + Presence PresenceMode + Description string +} + +type Handler interface { + Execute(*Runtime) error +} + +type ContractBoundHandler interface { + Handler + ValidateContract(Contract) error +} + +type CompileFacts struct { + Annotations []AnnotationFact +} + +type AnnotationFact struct { + Key string + Value string +} + +type HandlerFunc func(*Runtime) error + +func (f HandlerFunc) Execute(rt *Runtime) error { return f(rt) } + +type ValidatorSpec struct { + // ValidatorID 标识可执行的校验钩子。它只运行一次。 + ValidatorID string + Covers []RuleID + Validate func(ValidationContext) error +} ``` -During migration both modes coexist; at S8 each shortcut flag maps to -`MustBePassed`, so behavior is unchanged. -**S2b. Decline behavior — ⚠️ user-visible contract change.** -LeafSpec returns an error (exit ≠ 0); Shortcut returns `nil` (exit 0). Options: -1. Converge on **error** (declining a destructive operation should exit non-zero). - Changes the exit code of 376 shortcuts → needs product sign-off + CHANGELOG. -2. Carry a `DeclineBehavior` field through migration and converge later. -3. Converge on `nil` (weakens the atomic-command contract; not recommended). +规则: -**S8 must not start before this decision is made.** +- 执行所有权由**类型表达,而不是可空性检查**。`Definition` 不可编译;只有 `*Executable` 可以。两个构造函数各自恰好安装一个所有者,且都不接受 `*Executable`,因此双重绑定与无所有者编译都不可表示。 +- 唯一残留的非法值是 nil 或零值 `*Executable`,Compile 会像拒绝 `RiskInvalid` 与 `PresenceInvalid` 一样拒绝它。这是一次平凡检查,而不是函数基数组合矩阵——这正是 §4.4 所划的区别。 +- 因为 `Executable` 持有调用方无法触及的冻结深拷贝,§5.7 的 Contract digest 不再是防止 Bind 后篡改的机制,而仅作为针对内部 bug 的纵深防御。 +- 优先形态 1,然后形态 2,再形态 3。不得仅为获得 `*Executable` 而把单次调用函数体包成 Handler。237 个直接返回的 Shortcut 完成迁移的标志是删除其 `Execute` 字段,而不是改名。 +- 当 Handler 受契约绑定时,`Compile` 调用 `ValidateContract`。不匹配是构造错误,不是延后的运行时不变量。 +- `CompileFacts` 是封闭的、纯数据贡献通道。Compile 校验注解命名空间、重复键以及与 Contract/注册表事实的冲突。它不能注册 flag、替换 `RunE` 或修改 Cobra。 +- `Contract` 不包含 MCP server/tool/载荷字段。 +- `Definition.Effects` 是封闭的执行能力声明,独立于纯 CLI Contract。Compile 拒绝重复、非法 kind 以及 Risk/effect 冲突。它由**形态 3** 的作者手写;形态 1/2 留零值,由 `mcpbind.Bind` 从 `CallSpec` 推导(§5.7)。因此这个字段永远只有一个写入者,绝不出现「作者声明一份、绑定推导一份、再检查两者是否一致」的三方结构。 +- `RiskInvalid` 被拒绝。遗留适配器把空遗留值显式映射为 RiskRead;新 Contract 中的省略绝不能静默关闭确认。 +- 每条自定义 `Constraint` 都有稳定且非空的 `RuleID`。每条 `RequiredWhen` 也有自己的 `RuleID`。 +- 四种关系类 ConstraintKind 是声明式的,不得被 `ValidatorSpec.Covers` 命名。`ConstraintCustom` 与 RequiredWhen 是仅有的命令式规则类;它们需要 ID,且恰好由一个 Validator 覆盖。未知/非法 kind 导致构造失败。 +- `ValidatorSpec.Covers` 命名规则 ID,而不是复制规则元数据。命令式规则未被覆盖、被多个校验器覆盖、或被无处引用时,Compile 失败。即便覆盖多条规则,校验器也只运行一次。这表示今天 12 个钩子覆盖 14 条自定义约束且无重复执行。 +- 在一个 Definition 内,自定义 Constraint 与 RequiredWhen 的 RuleID 唯一。`Covers` 只能命名这些命令式规则 ID;每个目标必须存在,每条命令式规则必须恰好被覆盖一次,每个 ValidatorSpec 必须有非空 `ValidatorID`、非空 `Covers` 和非 nil 函数。现存 14 条自定义声明获得签入的语义 ID;编译器不得从切片位置合成不稳定的序号 ID。遗留迁移期间这些 ID 是适配器侧元数据或非序列化字段,且不得改变 `shortcut list` JSON。跨命令审计身份是 `(CommandKey, RuleID)`;RuleID 不必全局唯一。 +- `RequiredWhen` 规则只作为 RequiredWhen 元数据投影。覆盖其 ID 并不同时发布自定义约束,因此 Schema/帮助不会把同一语义规则渲染两次。 +- 校验器接收只读上下文,无输出或后端能力。因此它们不能在 Risk 确认之前有意执行副作用。 +- 规则的 ID/描述及其覆盖 ValidatorID 对帮助、审计与覆盖率夹具可用。仅当 Schema 线缆有诚实表示时才投影到 Runtime Schema;任意自定义规则不得被误标为受支持的关系约束。 +- 位置参数与元数是声明式的。真正需要自定义 Cobra 解析的命令在其行为被建模之前保持为裸命令。 +- 命令式 `Handler` 不编码执行发起多少次后端调用;形态 1/2 在构造上始终是一次调用。 +- 所有可从本地 CLI/配置状态判定的解析、规范化、校验与可失败转换,必须在 Risk 确认前完成。形态 3 Handler 可在确认后执行依赖后端的校验,当该事实无法本地获知时。它必须在第一次写之前完成必要的读与检查,然后可在不写的情况下返回类型化的校验/业务错误。例如,devapp delete 可先确认 Risk,再拉取应用名,比对 `--confirm-name`,然后才发起 delete。 +- **提示内容**必须由后端读派生的命令不适合这条单次确认流水线,在单独评审的分阶段确认模型出现之前保持为裸命令。 -**Gates**: cases for both modes; Shortcut's existing suites unchanged. -**Effort**: 1 PR (small) + 1 decision. +`ArgsSpec.Policy` 有非法零值,因此省略不能静默改变行为。当前 Shortcut 命令映射为 `ArgsArbitrary`,因为遗留 `mount` 让 Cobra `Args` 为 nil;全部 28 条当前 Leaf 挂载命令映射为 `ArgsNoArgs`。对 `ArgsNoArgs` 与 `ArgsArbitrary`,Compile 要求声明字段 `Positionals` 为空;这并不意味着 Arbitrary 拒绝运行时 argv。`ArgsDeclarative` 要求至少一个位置参数,只允许在必填项之后出现可选项,且至多一个可变参数位于最后一槽。必填可变参数接受一或多个值;可选可变参数接受零或多个。 ---- - -### S3 — Complete the constraint model and add `enum` - -Fixes **F4**; restores "declare once → three effects". - -| Addition | Runtime validation | Schema projection | `--help` | -|---|---|---|---| -| `RequireTogether` constraint | ✅ | `RuntimeSchemaConstraints.RequireTogether` (exists) | ✅ | -| `FlagSpec.Enum` | ✅ | `cli.AnnotateRuntimeFlagEnum` (exists) | `(可选值: a, b)` | -| `Required` projection | — | `cli.AnnotateRuntimeRequiredFlags` (exists) | `(必填)` | - -All three annotation APIs already exist in `internal/cli`, so this is wiring, not -new Schema surface. - -**By-product**: the base can derive the `必填 / 可选值` help decoration itself, so -`FromShortcut` no longer needs to borrow Shortcut's `flagHelp` (removing a -coupling that caused a real bug — see §4). - -**Gates**: drift zero (**new annotations must appear only on commands that -declare them; all existing commands unchanged**) + runtime/help/schema cases for -each addition. -**Risk**: medium (touches Schema annotations; drift is the guard). -**Effort**: 1 PR (medium). - ---- - -### S4 — Push the MCP shape out of the base - -Fixes **F2**. Do it while only 27 call sites exist. +### 5.2 构造 API ```go -// base: CLI-facing contract -FlagSpec{ Name, Usage, Kind, Default, Aliases, EnvVar, Required, RequiredMode, Enum, Trim } -// MCP binding layer: payload shaping -ArgBinding{ Flag, Bind string; Transform func(string)(any,error); OmitEmpty bool; ArgDefault string } +type Clock interface { + Now() time.Time +} + +type CompileDeps struct { + Env EnvReader + Clock Clock + Confirmer Confirmer +} + +type BuildIssue struct { + Path string + Code string + Message string +} + +type BuildError struct { + Issues []BuildIssue +} + +func (e *BuildError) Error() string + +func Compile( + exe *Executable, + deps CompileDeps, +) (*cobra.Command, error) + +func MustCompile(exe *Executable, deps CompileDeps) *cobra.Command ``` -`BuildArgs` moves to the MCP binding layer (`helpers` or `cmdcore/mcpbind`). -**Gates**: drift zero + per-key comparison of devapp's 27 commands' `toolArgs`. -**Effort**: 1 PR (medium-large; rewrites 27 call sites). +- `Compile` 校验名称、重复 flag、别名、类型化默认值、约束、校验器 ID/覆盖、规则描述、位置参数、Risk 与 required 依赖。它拒绝 nil/零值 `*Executable`;所有者存在性在其余情况下由构造保证,typed-nil 所有者在两个构造函数内部被拒绝。 +- `MustCompile` 仅用于发行版拥有的静态定义,并委托给 `Compile`。 +- 用户自定义/运行时加载的 Shortcut 定义必须使用返回错误的路径。非法用户输入不得使 CLI 崩溃。 +- 构造错误被确定性排序并聚合进 `BuildError`,以便作者一次修完所有声明问题。 +- Compile 在入口处深拷贝并冻结全部 Contract 切片/映射/默认值;Compile 之后的调用方篡改不能改变已挂载表面或受契约绑定的 Handler digest。 +- 环境与时间是注入依赖。解析不直接调用 `os.Getenv`,测试可提供封闭、确定的来源。 ---- +### 5.3 带类型键的纯 CLI flag -### S5 — Orthogonalize `FlagSpec` +```go +type ValueType interface { + ~string | ~int | ~bool | ~[]string +} -Fixes **F7**. +// Key 字段是私有的。作者创建一次键,在 Contract 中声明, +// 并在校验器/绑定/handler 中复用同一类型键。 +type Key[T ValueType] struct { + name string + kind FlagKind +} -| Redundancy | Resolution | +func StringKey(name string) Key[string] +func IntKey(name string) Key[int] +func BoolKey(name string) Key[bool] +func StringsKey(name string) Key[[]string] + +type FlagSpec struct { + Name string + Usage string + Kind FlagKind + Default DefaultValue // 类型化;由 StringDefault/IntDefault/... 创建 + + Aliases []AliasSpec + EnvVar string + Hidden bool + Trim bool + Empty EmptyPolicy + // 显式空字符串/列表是停止解析还是回落到别名/环境/默认值。 + // Leaf 与 Shortcut 今天行为不同,因此不能继续隐式处理。 + EmptyInput EmptyInputPolicy + + Required Requirement + Enum []string + + // Input 声明 flag 本身之外的额外取值来源:`@path` 读文件,`-` 读 stdin。 + // 为空表示仅使用 flag 值。 + Input []InputSource + + Format string + Example string + RequiredWhen *ConditionalRequirement +} + +type InputSource uint8 + +const ( + InputSourceInvalid InputSource = iota + InputFile // @path + InputStdin // - +) + +type Requirement struct { + Enabled bool + Presence PresenceMode +} + +type ConditionalRequirement struct { + ID RuleID + Description string +} + +type PresenceMode uint8 + +const ( + PresenceInvalid PresenceMode = iota + // 显式主/别名输入;bool/int 的 false/0 算存在, + // 而 string/list 仍要求有意义的内容。 + PresenceExplicit + // 显式主/别名输入或环境变量;排除注册默认值。 + PresenceSupplied + // 已解析的有效值也可来自类型化默认值。 + PresenceEffective +) + +func (k Key[T]) Declare(opts ...FlagOption[T]) FlagSpec +``` + +`DefaultValue` 有不导出的内部与类型化构造函数。`Compile` 拒绝 kind 与 flag 不匹配的默认值。非法静态默认值永不到达运行时。 + +类型键避免在每个 handler 内再次书写 flag 名与期望的 Go 类型。异构的 Contract 仍存储具体的 `FlagSpec` 值;`Key[T].Declare` 产生一个,而 handler 保留类型键。 + +`EmptyPolicy` 按 kind 定义何为有意义内容。`EmptyInputPolicy` 单独定义回落行为。这保留了显式 `false`/`0` 算存在、空白字符串或空列表不算存在等情况,而无需在解析器中硬编码框架特定行为。 + +只要启用了 Required 或关系约束,就拒绝 `PresenceInvalid`。适配器必须显式选择模式;省略的零值不能静默选择遗留语义。 + +只有 Contract 声明了该关系时,别名才贡献到逻辑键。现存 Shortcut 隐藏兼容 flag 保持为独立键,除非其上线校验与绑定语义已把它们当作别名。 + +`RequiredWhen` 不是纯文案。Compile 要求非空 ID/描述以及恰好一个 `ValidatorSpec.Covers` 引用;Runtime Schema 接收描述,而该校验器在运行时强制条件。 + +`Input` 是声明式的,因为替代方案是每个 handler 各自重实现 `@file`/stdin 解析与路径校验。它在 Resolve 内、任何校验之前解析,因此不可读路径、路径穿越尝试或同一命令行中两个 flag 争抢 stdin,都作为普通输入校验失败,从而发生在 Risk 确认之前。规则: + +- 多个 flag 可声明 `InputStdin`,但每次调用最多消费一次;同一命令行中第二个 `-` 是校验错误,而不是静默空读。 +- 文件内容受与字面量值相同的 `Trim`/`Empty` 策略约束,因此 `Input` 只改变值的来源,不改变其他语义。 +- 路径解析复用现有的本地文件 effect 边界(§5.5.2);它不是新的临时文件 API。 +- 构造时拒绝 `InputSourceInvalid`。 +- 当前没有任何 Shortcut 或 Leaf 声明 `Input`,因此 M1 增加能力且零上线表面变化。让现有命令采用它属于 §9 下的用户可见变更。 + +核心 FlagSpec 故意没有: + +- `Bind`; +- `ArgDefault`; +- `Transform`; +- `OmitEmpty`。 + +那些是载荷绑定的关切。 + +### 5.4 一次性解析 + +```go +type ValueSource uint8 + +const ( + SourceUnset ValueSource = iota + SourcePrimary + SourceAlias + SourceEnv + SourceDefault +) + +type ResolvedValues struct { + // 私有异构存储;构造后不可变 +} + +type ValueView interface { + // 封闭;仅由 ValidationContext 与 Runtime 实现 + valueView() +} + +type ValueMeta struct { + Source ValueSource + SourceName string + Explicit bool + Supplied bool + Present bool +} + +func Get[T ValueType](v ValueView, key Key[T]) (T, ValueMeta) +``` + +性质: + +- 没有任何公开 API 把异构存储暴露为 `any`,或返回原始 ResolvedValues 容器。校验器、handler 与 mcpbind 都通过 `Get[T]` 读取值;`ValueMeta` 单独携带来源/存在性事实。 +- `any` 仅保留在天生异构的后端载荷/结果与输出边界;它从不作为已解析 CLI 值的 API。 +- 解析顺序被声明并测试:显式主 → 显式别名 → 环境 → 类型化默认值。 +- 整数环境值在解析期间解析。非法值在校验器或 `Handler.Execute` 之前返回校验错误。 +- 未声明键与错误 kind 访问会用类型化的编程错误毒化本次调用。`Get` 可为控制流返回零占位,但流水线在每个 Validator 之后、提示之前、每个受管 effect 之前以及 Handler 返回之后检查毒化。它绝不能静默授权已批准的后端/输出适配器。 +- **这是相对本 RFC 早期草案的一次有意原则调整,在此明确记录,而不是静默放松。** 早期草案要求「绝不降级为 `""`/`0`/`false`/`nil`」并让 `Get` 返回三值 `(T, ValueMeta, error)`。本版保留双值签名与零占位返回,同时把 §4.3 对原型的批评精确化:那里的缺陷是**静默**,不是零返回本身——原型的零值一路抵达 handler 且无人知情,也没有任何东西阻止它授权一次后端写。因此代价从「每个读取点一个 error 分支」换成「毒化标记 + §5.6 的四个强制检查点 + 许可即时铸造与原子消费」。这个取舍用调用点工效换取「坏读取绝不能授权 effect」由流水线而非作者纪律来保证。若评审认为四个检查点不足以覆盖每条 effect 路径,退路是给 `Get` 加回 error 返回值,而不是放宽毒化检查;这一点列入 §12 检查清单。 +- 切片规范化与字符串 trim 只发生一次。 +- 引用值在快照入口与出口被拷贝;`Get` 对切片返回拷贝,因此调用方不能篡改后续阶段观察到的数据。 +- Required 检查与约束显式选择 `PresenceExplicit`、`PresenceSupplied` 或 `PresenceEffective`。仅有 Required 语义不够;约束需要同样的选择。现存 Leaf 解析器能力接受环境值但对约束排除注册默认值,这就是 `PresenceSupplied`;生产 Leaf 声明当前 `EnvVar` 字段为零。因此该模式是由直接测试覆盖的兼容保证,不是广泛使用环境回落的证据。 +- 快照不可变,因此校验与执行不能观察到不同的 flag 状态。 + +#### 5.4.1 遗留 Shortcut 默认值编解码 + +类型化默认值并不授权清理现存强制转换。Shortcut 编译器使用显式兼容编解码,复现 `registerFlags`: + +| 遗留 flag kind | 兼容规则 | |---|---| -| `Default` vs `ArgDefault` | S4 moves `ArgDefault` into `ArgBinding` — resolved naturally | -| `OmitEmpty` vs `Transform`-nil | keep `OmitEmpty` (declarative wins); document `Transform`-nil as the general escape | -| `Required` vs `MarkRequired` | merge into `Required` + `EnforceBy{Framework,Cobra}`; `Cobra` preserves the catalog `required` hard floor | +| string | 精确保留原始默认值 | +| int | 空或畸形文本经 `atoiDefault` 变为 `0` | +| bool | 仅精确字符串 `"true"` 变为 true;`"True"` 与 `"1"` 变为 false | +| string slice | 对整串两端 trim 空白;空变为 nil,否则在该 trim 后的字符串上按逗号拆分,不做逐元素 trim 或 CSV 解码 | -**Gates**: drift zero (the `MarkRequired` → catalog `required` projection must be -byte-identical). -**Effort**: 1 PR (small-medium). +声明 golden 同时记录原始默认值与挂载后的 Cobra `DefValue`/有效类型值。迁移期间,差分不匹配是阻塞项;不能因为新行为看起来更干净就自动接受。 + +当前全部 78 个内置默认值都能按其声明 kind 成功解析。畸形值兼容风险主要来自用户自定义 v1 YAML,但遗留解码器仍保持显式,并为畸形 int、非精确 bool 文本以及空白/带引号的切片值提供夹具。 + +新的 Contract 原生定义使用严格的类型化构造函数。改变遗留内置或用户自定义 Shortcut 的强制转换,需要独立的、用户可见的变更,并附兼容分析、CHANGELOG 与更新后的夹具。 + +### 5.5 Runtime + +```go +type InvocationIO struct { + In io.Reader + Out io.Writer + Err io.Writer +} + +type InvocationOptions struct { + DryRun bool + Yes bool + Format string + JQ string + Fields []string +} + +type Confirmer interface { + Confirm( + context.Context, + ConfirmationRequest, + InvocationIO, + ) (ConfirmDecision, error) +} + +type Runtime struct { + // 字段不导出 +} + +type ValidationContext interface { + ValueView + Context() context.Context + Args() []string + Clock() Clock +} + +type EffectKind uint8 + +const ( + EffectInvalid EffectKind = iota + EffectBackendRead + EffectBackendWrite + EffectNetworkRead + EffectLocalRead + EffectLocalWrite + EffectOutput +) + +type EffectPermit struct { + // 字段不导出;携带调用上下文与 effect 作用域数据 +} + +type ContextPolicy uint8 + +const ( + ContextInvalid ContextPolicy = iota + ContextPropagate + ShortcutLegacyDetached // 仅临时迁移策略 +) + +func (r *Runtime) Context() context.Context +func (r *Runtime) Args() []string +func (r *Runtime) DryRun() bool +func (r *Runtime) Yes() bool +func (r *Runtime) Options() InvocationOptions +func (r *Runtime) Clock() Clock +func (r *Runtime) Permit(EffectKind) (EffectPermit, error) +``` + +`Runtime` 包含 CLI 执行能力,不包含 MCP 能力。 + +Runtime IO 按每次调用从已编译的 Cobra 命令经由 `cmd.InOrStdin()`、`cmd.OutOrStdout()` 与 `cmd.ErrOrStderr()` 解析。`CompileDeps` 不缓存 reader 或 writer,因此后续 `SetIn`/`SetOut`/`SetErr` 调用与测试隔离继续有效。 + +编译器安装的 `RunE` 是唯一保留 Cobra 命令的受管层。解析后它创建 `InvocationIO`,并把继承的 `dry-run`、`yes`、`format`、`jq` 与 `fields` 快照进 `InvocationOptions`。CompileDeps、Confirmer、Handler、Caller 与 ResultSink 不得捕获 Cobra 流或稍后重读这些 flag。尤其是,遗留 Shortcut 提示对 `os.Stdin` 的直接读取必须在差分测试前迁到 InvocationIO,且 `Output` 必须消费快照后的输出选项,而不是调用 `output.WriteCommandPayload(rt.cmd, ...)`。 + +Runtime 上不暴露裸输入/输出流。InvocationIO 仅传递给框架 Confirmer 与框架拥有的 sink 工厂。这阻止受管 Handler 实现未经评审的「后端读 → 自定义提示 → 输入循环」;此类命令在分阶段确认被建模之前保持为裸命令。 + +每一个获批的后端、网络、本地文件或输出 effect 都把 `EffectPermit` 传给其具名适配器。`Runtime.Permit` 拒绝非法或未声明的 kind、被毒化的调用、`ContextPropagate` 下已取消的上下文,以及 dry-run 下的后端写。适配器在交接时重新检查携带的上下文与毒化状态,并用该上下文执行操作。许可是一次性的,且在交接前即时铸造;原子消费拒绝零、过期、复用、错误调用或错误 kind 的令牌。因此后续的坏 Get 不能躲在更早铸造的许可之后。 + +仅当 M2 选择 bug-for-bug 兼容时,才允许临时且显式的 `ShortcutLegacyDetached` 策略,并随遗留执行器一并移除。ResultSink 通过许可接收输出作用域的 writer 与快照后的输出选项,从不通过 Cobra。 + +Dry-run 从不授予真正的 EffectBackendWrite 许可。兼容适配器必须保留更具体的已上线行为:直通 CallMCP/mcpbind 写通过 EffectOutput 发出精确的 tool/args 预览且不调用 Caller;CallMCPWriteData 保持失败闭合;已声明的后端读仍可获得 EffectBackendRead。本地/网络 dry-run 行为是能力特定的,必须匹配其差分夹具。 + +RiskRead 禁止 EffectBackendWrite。RiskWrite 与 RiskHighRiskWrite 可在确认后允许后端读,再跟写/输出。网络读与本地读/写需要各自显式的 Definition.Effects 与具名适配器;它们从不从 Risk 推断。未声明的 effect 是内部安全错误。 + +受管命令包不得导入裸后端 helper,也不得直接读取 `os.Stdin`/全局 writer;策略分析器强制该依赖边界。Go Handler 仍是受信任代码,但受支持的受管 effect 路径由机制把关,而不是仅依赖文案。 + +`ContextInvalid` 被拒绝。Contract 原生与 Leaf 定义始终使用 `ContextPropagate`;只有 Shortcut 迁移适配器可选择 M2 记录的临时分离策略。 + +注入的 Clock 有意保持最小(`Now`),并使当前基于时间的工作流可确定。仅当迁移后的轮询工作流需要时,才把感知上下文的 Sleeper 作为单独具名能力加入;handler 不直接调用 `time.Now`/`time.Sleep`。 + +在适配当前 helper 期间,可存在临时的 `Command() *cobra.Command` 兼容访问器,但每一次使用都必须登记。它不是首选 API,因为它在解析之后重新打开可变的 Cobra 状态。 + +#### 5.5.1 Shortcut 兼容 Runtime + +后端中立性适用于 `cmdcore.Runtime`,而非每一个领域适配器。今天的 376 个 `Shortcut.Execute` 函数体依赖 Shortcut 拥有的、带 MCP、输出与便利方法的 `RuntimeContext`。契约编译器在不可变核心 Runtime 之上重建该门面,**作为迁移桥接,而非终态**: + +```go +// package shortcut,不是 package cmdcore +type RuntimeDeps struct { + Caller Caller + Sink ResultSink + Tagger AIMessageTagger +} + +func adaptHandler( + s Shortcut, + deps RuntimeDeps, +) cmdcore.Handler { + return cmdcore.HandlerFunc(func(core *cmdcore.Runtime) error { + rt := newRuntimeContext(core, s, deps) + return s.Execute(rt) + }) +} +``` + +该闭包是形态 3 Handler 包装器。它不把 Caller 或 MCP 载荷形状加入 Contract 或核心 Runtime。在双执行器窗口期间,376 个 Execute 函数体可保持不变,以便差分对等可度量。那是脚手架。按 §3.5 / §5.1,形态 1 与形态 2 的 Shortcut 离开该适配器:删除其 `Execute` 字段,执行迁到 `mcpbind.Bind`(需要时加 `WithProjector`)。只有残留的多步与纯本地集合保留 `adaptHandler`。以仍调用 `s.Execute` 的 376 个包装器结束迁移,不满足 §10 的残留标准。 + +其 `RuntimeContext` 门面委托给内部运行时值源。v2 源使用 ResolvedValues,且绝不能读 Cobra;临时、隔离的遗留源可继续读取规范命令,使同二进制回滚是真实的而非名义的。 + +方法迁移是显式的: + +| 现有 `shortcut.RuntimeContext` 表面 | v2 来源 | +|---|---| +| `Str`、`Int`、`Bool`、`StrSlice` | 来自 `ResolvedValues` 的类型化 `cmdcore.Get` | +| `StrFirst` | 按已声明名称顺序取第一个非空值 | +| `IntFirst` | 显式兼容键的 `ValueMeta` 优先,然后是主有效值 | +| `Changed`、约束 helper 与 `RangeInt` | `ValueMeta` 与同一份不可变值 | +| `DryRun`、`Yes`、参数与 Clock | 核心 Runtime | +| `CallMCP`、`CallMCPData`、`CallMCPWriteData` | 注入的 Shortcut `Caller`,带读/写 EffectPermit,保留 dry-run 策略 | +| `Output` | 注入的 `ResultSink`,带输出 EffectPermit 与快照选项 | +| `AddAIMessageTag` | 注入/打标适配器,保留当前版本行为 | +| `Command` | 仅临时、已登记的逃生口;任何 flag/值读取都不得依赖它 | + +M3 按此表审计全部 23 个当前方法/helper。两个直接消费 `Command().Context()` 的站点迁到 `Runtime.Context()`,并移除公开的 Command 逃生口。在回滚窗口期间,只有具名的 `legacyRuntimeSource` 可保留 Cobra 指针;v2 Runtime、Validate/Execute 函数体、Caller 与 ResultSink 不得保留。M6 删除这最后一个来源。 + +Shortcut Caller 方法接受显式上下文。目标策略传递 `Runtime.Context()`。迁移期间,§M2 要么在单独获批的变更中更新遗留行为,要么在两侧观测中安装显式临时的 `ShortcutLegacyDetached` 策略;不得在编译器重构内不可见地改变取消语义。 + +#### 5.5.2 具名的网络与本地文件 effect + +RuntimeContext 方法清单不是完整的 effect 清单。例如 `chat +messages-resource-download` 在 `CallMCP*` 之外调用 `http.Client.Do` 并创建/重命名/删除本地文件。M0 审计每个 Handler 上的直接网络、文件系统、子进程与全局 IO effect。 + +已知的资源下载行为移到 Shortcut 拥有的 `ResourceDownloader` 之后。其适配器: + +- 接收 Runtime.Context 与类型化输入; +- 在每一类操作前立即请求已声明的 EffectLocalRead、EffectNetworkRead 与 EffectLocalWrite 许可; +- 保留路径/禁止覆盖校验、HTTPS/重定向限制、临时文件清理与原子发布行为; +- 在 dry-run 下不执行任何网络或文件系统操作,并通过 EffectOutput 发出精确的既有计划。 + +Execute 函数体使用该具名适配器,而不是直接使用 `net/http` 或 `os`。M0 发现的任何额外直接 effect 要么获得类似的窄能力,要么保持遗留/裸;全部 376 条的验收标准不能靠把此类调用藏出清单来达成。 + +### 5.6 受管执行流水线 + +对每条受管命令: + +1. Cobra 解析 flag 并检查位置参数元数。 +2. `Resolve` 构建不可变的类型化快照。 +3. Required 与 Enum 规则校验快照。 +4. 声明式约束校验快照。 +5. 被覆盖的命令式校验器按声明顺序各运行一次。 +6. 检查值访问毒化与执行上下文。 +7. 运行 Risk 确认。 +8. 再次检查值访问毒化与执行上下文。 +9. 执行所有者运行:形态 1/2 派发器或形态 3 Handler,经由许可把关的 effect。 +10. 返回值与任何已记录的编程/effect 错误合并。 + +任何受管定义都不能退出步骤 2–9。形态 1/2 没有作者 Handler;派发器仍受同一流水线约束。 + +核心不构建后端载荷。它不关心步骤 9 发起零次、一次还是多次后端调用。 + +步骤 6/8 的上下文部分遵循显式 ContextPolicy。`ContextPropagate` 在 `ctx.Err` 时失败;临时的 `ShortcutLegacyDetached` 在双执行器窗口期间复现遗留取消行为,同时仍对值毒化失败。M6 移除分离分支。 + +RiskRead 无需提示。Dry-run 跳过 write/high-risk 提示,但保持写许可禁用,使执行所有者只能渲染兼容预览;Yes 跳过提示并许可已确认的执行路径。 + +步骤 9 中依赖后端的状态守卫不是框架 Validator,即便为兼容性其不匹配以校验错误类别呈现。它可在确认后读,但必须在第一次变更前完成。形态 1/2 在构造上没有此类守卫;形态 3 可以有。 + +### 5.7 MCP 绑定适配器 + +`internal/cmdcore/mcpbind`(或具有相同依赖方向的兄弟包)拥有载荷塑形: + +```go +type Binding struct { + // 私有声明 +} + +type plan struct { + // 私有;包含已校验 Contract 的规范 digest +} + +type IncludeMode uint8 + +const ( + IncludeInvalid IncludeMode = iota + WhenExplicit + WhenSupplied + WhenEffective + Always +) + +func Param[T cmdcore.ValueType]( + backendName string, + key cmdcore.Key[T], + include IncludeMode, + opts ...BindingOption[T], +) Binding + +type CallSpec struct { + Product string + Server string + Tool string + Effect cmdcore.EffectKind + Bindings []Binding +} + +type Caller interface { + Call( + cmdcore.EffectPermit, + CallRequest, + ) (CallResult, error) +} + +type ResultSink interface { + Write(cmdcore.EffectPermit, any) error +} + +// ResponseProjector 在 ResultSink.Write 之前整形单个 CallResult。 +// 它是纯的:无 IO、无后端调用、无确认、无格式猜测。 +type ResponseProjector func(CallResult) (any, error) + +func IdentityProjector() ResponseProjector + +func Uppercase() BindingOption[string] +func PayloadDefault[T cmdcore.ValueType](T) BindingOption[T] + +func Bind( + base cmdcore.Definition, + call CallSpec, + caller Caller, + sink ResultSink, + opts ...BindOption, +) (*cmdcore.Executable, error) + +func WithProjector(ResponseProjector) BindOption +``` + +规则: + +- `Bind` 是形态 1/2 唯一的公开装配 API。它相对 `base.Contract` 校验每个绑定键,合并后端事实,安装框架派发器作为执行所有者,并返回 `*Executable`。调用方不能把从 Contract A 编译的 Plan 与 Definition B 分开组合。 +- `Bind` 接受 `Definition`,从不接受 `*Executable`;`cmdcore.WithHandler` 亦然。声明驱动派发与命令式 Handler 之间的互斥因此是结构性的;无需经评审的覆盖或运行时检查。 +- **形态 1/2 不手写 `Definition.Effects`。** `Bind` 从 `CallSpec` 推导出封闭集合 `{CallSpec.Effect, EffectOutput}` 并把它安装进 `*Executable`。派发器随后申请的正是这两个许可(见下文),因此 §5.5 的「未声明的 effect 是内部安全错误」不会被派发器自己的路径触发。作者在形态 1/2 定义上把 `Effects` 设为非零是 `Bind` 错误,不是需要与 `CallSpec.Effect` 比对的「一致性」检查——同一事实只有一个来源,因为只有一处写它。要求作者写 `[后端 effect, EffectOutput]` 再检查它「与单数 `CallSpec.Effect` 一致」是自相矛盾的规则,会稳定产出 376 份错误声明。 +- 手写 `Effects` 只属于形态 3(`cmdcore.WithHandler`),那里没有 `CallSpec` 可推导,且多步/本地 effect 集合本就无法从任何单一事实导出。§5.5.2 的具名网络/本地文件 effect 因此始终是形态 3 的显式声明。 +- `Bind` 仍按 §5.5 校验推导结果:`CallSpec.Effect` 必须与 Contract 的 `Risk` 相容——`RiskRead` 不得推导出 `EffectBackendWrite`——否则返回构造错误。推导免除的是作者的重复劳动,不是校验。 +- 派发器保留其校验过的深拷贝 Contract 的规范 digest,并实现 `ContractBoundHandler`。由于 `Executable` 持有冻结副本且调用方无法触及,该 digest 是针对内部 bug 的纵深防御,而不是防止调用方篡改的保证。 +- 编码通过 `Param[T]` 捕获的类型键经 `cmdcore.Get[T]` 读取;它从不接收原始字符串或公开 `any` 值。 +- 包含/省略通过 `IncludeMode` 显式表达,并与编码正交。flag kind 从不静默决定是否发送 `0`、`false` 或空值。Shortcut 模式 `if rt.Changed(name) { params[…] = … }` 映射为 `WhenExplicit` / `WhenSupplied`;它不得继续以手写形式留在形态 1 候选内。 +- 仅载荷默认值留在绑定中,且从不满足 CLI required 规则。 +- `Bind` 拒绝 nil/typed-nil 的 Caller 或 ResultSink、未知键、重复后端键、类型不匹配、非法 include/effect 模式、Contract Risk/后端 effect 冲突以及不兼容的默认值。 +- 绑定选项有不透明内部,且对内置全函数编码器与类型化载荷默认值仅有封闭、具名的构造函数。没有公开的 `func(string) (any, error)` 转换钩子。可失败的用户输入转换属于 Resolve 或 Validator,因此在 Risk 之前完成。 +- 「全函数」指编码器对任意 T 值都不返回用户错误、也不 panic;内置编码器对其输入域有穷尽测试。 +- 在成功的 Bind、cmdcore Compile 与 Resolve 之后,在派发器内物化载荷不能产生用户校验错误。它仍可能浮现内部不变量失败、后端失败或输出错误。 +- 私有派发器在 Caller 与 ResultSink 之前立即请求 `CallSpec.Effect` 与 EffectOutput 许可。两个接口都不接受裸上下文或 writer,因此上下文、dry-run 与毒化调用检查不会被意外跳过。 +- dry-run 下派发器根本不被调用。§5.11 拥有该路径:框架从 `CallSpec` 推导计划,交由当前渲染器渲染后返回——M1 是复刻遗留信封的兼容渲染器,原生计划渲染是后续 approved delta(§5.11.1)。没有第二条、派发器本地的预览分支。预览载荷/文本仍是每条形态 1/2 命令差分夹具的一部分。 +- 输出委托给显式的 `ResultSink`;适配器不写全局 stdout,也不猜测格式。 +- **响应投影是第三条声明腿。** `ResultSink` 写出一个值;`ResponseProjector` 从 `CallResult` 产生该值。投影器: + - 仅接收调用结果并返回 `(any, error)`; + - 不得调用 Caller、Confirmer、依赖 Clock 的副作用,或写 Output; + - 可选:省略即形态 1(`IdentityProjector`); + - 是今天调用 `CallMCPData` 再调用本地 `*Project` helper 的 108 个单次调用 Shortcut 的迁移目标; + - 返回的 `error` 由框架**按既有分类透传,不重新包装**:`apperrors` 类型化错误原样保留其类别、消息与退出码;非 `apperrors` 的裸 error 映射到今天同一批 `*Project` helper 在 `Execute` 内返回同类 error 时所落入的那个类别。这条映射不是新政策,而是对现状的固定;M0 交付物 8 把这些边缘错误的当前类别记录为 golden,M5b 阶段 A 的逐条对等对照它比较。没有这条规则,M5b 阶段 A 那句「错误类别、消息与退出码逐条对等」按构造无法满足,因为框架侧的归类未定义; + - 其 error 发生在 `ResultSink.Write` 之前,因此可观测顺序是「后端调用已发生、输出未写」。这与今天 `CallMCPData` 成功后 `*Project` 失败的行为相同,并在差分夹具中显式覆盖,而不是留给实现推断; + - **不是**通用投影 DSL。外层 `{count, list}` 信封以后可获得封闭 helper;在复用证据足够之前,82 个独立字段映射保持为具名 Go 函数。 +- `Bind` 把后端身份注解合并进 `Definition.Facts`;cmdcore 投影这些事实而不导入 mcpbind。`CallSpec` 本身永不进入 cmdcore,也不涉及任意 `PostMount`。 + +当前生产 Leaf 总体只有两个 transform;二者都是大写字符串且不会失败。它们映射为全函数内置编码器。可失败的 transform 夹具仍是遗留路径的测试,不带入新的 mcpbind API。 + +LeafSpec 可保留为构造 Contract 加 mcpbind 派发器的小门面(今天是形态 1)。当第一个带响应投影器的 Shortcut 迁移时加入形态 2;Leaf 不需要投影器来证明路径。仅当门面没有工效或评审价值时才应移除。 + +### 5.8 裸 Cobra 逃生路径 + +裸命令使用独立 API,例如: + +```go +type RawMeta struct { + Owner string + Reason string + TrackingIssue string +} + +func RegisterRaw( + build func() *cobra.Command, + meta RawMeta, +) (*cobra.Command, error) +``` + +裸命令: + +- 不宣称 cmdcore 强制执行 Required/Constraint/Risk; +- 在迁移前保留各自经评审的 Schema 元数据; +- 不计入已收敛的受管定义。 + +该 API 仅对基线之后的**新增**静态裸叶子强制。现存手写叶子从挂载树清单(如 `test/fixtures/raw-command-baseline.json`)豁免;它们不被机械改写,也不各自需要新 issue。 + +策略是基于集合的,而不是基于计数的: + +```text +currentRawKeys ⊆ grandfatheredRawKeys ∪ reviewedExceptions +``` + +- 基线记录可执行的非受管叶子,而非纯分组节点。 +- 新的裸键需要带非空 owner/reason/tracking issue 的 `RegisterRaw` 以及经评审的例外。 +- 重命名是删除加新增;新键需要获批的重映射。 +- 用户自定义/动态命令有单独清单。 +- 迁移/移除豁免键时在同一 PR 中从基线删除它,使其不能在未经新裸评审的情况下再次出现。不允许静默替换。 + +这比在受管 Contract 内放一个 `RunE` 字段更诚实。 + +基线允许有永久非零下界。子集规则防止未经评审的裸增长;它**不**承诺每个豁免命令最终都会变成受管。像 devapp delete 的先 get 再 compare 这类依赖后端的检查,可在 §5.1 下受管,当它们在通用 Risk 确认之后、第一次写之前运行时。实际提示必须由后端状态构造的工作流仍保持为裸命令。 + +未来设计可增加经审计的只读预检并返回 `ConfirmPlan`,但须先定义后端读分类、提示构造、TOCTOU 行为与重放/取消语义。那超出本 RFC。 + +### 5.9 取消与拒绝 + +确认返回类型化的哨兵/结果: + +```go +type ConfirmDecision uint8 + +const ( + ConfirmInvalid ConfirmDecision = iota + ConfirmAccepted + ConfirmDeclined + // ConfirmUnavailable 表示没有可用的交互输入,这不是拒绝。见 §3.4。 + ConfirmUnavailable +) + +type cancellationKind uint8 + +const ( + cancellationInvalid cancellationKind = iota + cancellationUserDeclined +) + +type cancellationError struct { + kind cancellationKind +} + +func (e cancellationError) Error() string + +var errUserDeclined error = cancellationError{kind: cancellationUserDeclined} + +func IsUserDeclined(err error) bool +``` + +两个枚举都有非法零值,哨兵是不导出的不可变值。Confirmer 返回 `ConfirmDecision`;受管核心把 `ConfirmDeclined` 转为哨兵。调用方只能通过以 `errors.Is` 实现的 `IsUserDeclined` 归类它。在目标状态,根应用把它映射为渲染与退出码。 + +Confirmer 结果处理是确定的:非 nil 错误优先;nil 错误加 `ConfirmInvalid` 或未知值变为类型化内部错误;仅 `ConfirmAccepted` 继续;`ConfirmDeclined` 变为哨兵;`ConfirmUnavailable` 变为携带命令路径与 `--yes` 补救提示的类型化 `confirmation_required` 错误。不需要确认的 Risk 级别不调用 Confirmer。 + +确认消费 `context.Context` 与 Runtime 输入/输出;不得直接读取 `os.Stdin`。`context.Canceled` 与 `context.DeadlineExceeded` 仍与用户回答 "no" 不同。 + +关闭的输入流也与用户回答 "no" 不同。Confirmer 分类三种情况且从不合并它们: + +| 情形 | Decision | 受管结果 | +|---|---|---| +| 交互式拒绝 | `ConfirmDeclined` | 用户拒绝哨兵 | +| 交互式接受,或 `--yes` | `ConfirmAccepted` | Handler 运行 | +| 无可用交互输入(EOF / 非终端) | `ConfirmUnavailable` | 类型化 `confirmation_required` 错误,绝不是拒绝哨兵 | + +`ConfirmUnavailable` 之所以存在,是因为当前实现把 EOF 当作拒绝,从而对从未运行的写报告成功(§3.4)。下面的遗留拒绝映射器仅适用于真实拒绝;任何映射器都不得把 `ConfirmUnavailable` 变成 `nil`。这是有意的 approved delta,而不是留给门禁去发现的兼容性破口。 +拒绝保证 Handler 不被调用且后端调用计数保持为零。在 Handler 交接前观察到的取消有同样保证。交接后竞态的取消经每个许可与后端请求传递;它不做「已开始的远程操作总能被召回」这种不可能的声称。 + +迁移行为: + +- 临时结果映射器在编译器边界包装命令:在受管执行返回之后、根看到结果之前。 +- Shortcut 编译器的映射器仅把匹配 `IsUserDeclined` 的错误变为 `nil`,仅为真实交互拒绝保留今天的「拒绝即成功」行为,别无其他。 +- Leaf 编译器的映射器仅把该哨兵变为今天的类型化校验错误,保留退出码 3。 +- 映射器按编译器族选择一次。它不存储在 Contract、mcpbind 或单条命令声明中,并在产品决策后删除。 +- 在产品负责人选择目标行为并记录精确退出码之前,不做收敛。返回校验错误当前意味着退出码 3,而不仅仅是「非零」。 + +策略是封闭枚举,不是任意回调: + +```go +type LegacyDeclinePolicy uint8 + +const ( + LegacyDeclineInvalid LegacyDeclinePolicy = iota + PropagateDecline + ShortcutDeclineAsSuccess + LeafDeclineAsValidation +) + +cmd.RunE = func(cmd *cobra.Command, args []string) error { + err := executeManaged(cmd, args, definition) + return mapManagedOutcome(err, familyPolicy) +} +``` + +构造时拒绝 `LegacyDeclineInvalid`;任何编译器族都不会意外得到零值策略。 + +这在技术上位于根退出映射之前: + +```text +受管流水线返回用户拒绝哨兵 +→ 编译器安装的 RunE 把它映射为 nil 或校验错误 +→ 根接收映射后的结果 +→ 根把任何剩余错误映射为进程退出码 +``` + +编译器不分配整数退出码;它只在现有根映射器运行前保留遗留族结果。 + +### 5.10 Runtime Schema 字段权威 + +终态是**按字段组**一份权威,而不是一个全局注册表: + +| 字段组 | 权威 | 必需的一致性检查 | +|---|---|---| +| 稳定规范身份、导航与精确排除 | 经评审的 CommandRegistry | 每个注册表项绑定一次;每个公开 Shortcut 有一个绑定或一个经评审的排除 | +| 活 CLI 名称、kind、默认值、帮助、required、enum、约束与位置参数 | 可执行 Contract | 挂载的 Cobra 表面与 Runtime Schema 调用事实匹配 | +| Flag 级 Hidden | 可执行 Contract | 挂载的 flag 可见性与感知隐藏的 Schema 投影匹配 | +| Shortcut 定义的 Cobra 命令 Hidden | 经评审的 Shortcut 可见性解析器:语义 catalog 优先,签入的公开 catalog 回落 | 解析器在 Contract 编译前应用可见性;Contract 携带解析值但不能覆盖它 | +| 非 Shortcut 受管定义的 Cobra 命令 Hidden | 可执行 Contract | 挂载的命令可见性与声明匹配 | +| Shortcut 列表成员资格与语义 disposition | 经评审的 Shortcut 可见性解析器 | public/all 列表成员资格与经评审决策匹配 | +| Runtime Schema / Agent 暴露 | 经评审的 CommandRegistry 加精确排除 | 每个暴露叶子解析到活 Contract;排除显式且不重叠 | +| 运行时 Risk 与确认 | 可执行 Contract,经评审的安全批准 | 经评审的 risk 与可执行 risk 不能不一致 | +| 后端 product/tool/载荷绑定 | mcpbind + 后端元数据 | 每个绑定引用真实的 flag/属性 | +| Agent 选择文案(`use_when`、`avoid_when`、摘要) | 经评审的 hints/catalog | 身份解析到活契约 | + +冲突在 CI 中失败闭合。手工 hints 可纠正文案或后端事实,但不得静默覆盖可执行 CLI 行为。 + +该权威表不取代现有的多源 Schema 解析器。它使每个字段组的优先级显式:Contract 供给可执行/原生层,而经评审的覆盖、版本化绑定、排除与后端元数据保留其声明职责。 + +### 5.11 声明式 dry-run 计划 + +Dry-run 安全性已由框架强制,不是缺口。遗留 runner 在 `--dry-run` 下拒绝非读工具,`CallMCPWriteData` 无条件拒绝,§5.6 保持写许可禁用。今天没有任何 handler 能在 dry run 期间写。 + +缺口是有用性,且按构造就不均匀: + +| Handler 形态 | 当前 `--dry-run` 结果 | +|---|---| +| 单次直通调用 | 框架自动渲染结构化计划(工具加已解析参数) | +| 无手写预览的多步 handler | 校验错误,指示作者渲染预览 | + +在当前注册表上测量(下列计数**暂定**,与 §3.5 一样以 M0 交付物 1/10 签入的脚本与 golden 为准):168 个 write/high-risk 命令分布在 12 个服务,79 个源文件在 17 个包中使用多步 helper,而只有 9 个源文件显式分支 `DryRun()` 来渲染预览——分布在 2 个包:`internal/shortcut/chat` 5 个、`internal/shortcut/smart` 4 个。本 RFC 早期版本报的是「3 个包中的 10 个文件」,那次计数错误地把 `internal/shortcut/runner.go` 算作了命令声明;它是框架自身,`DryRun()` 在其中被定义并用于失败闭合,而不是某条命令在渲染预览。评审复核了这个数字,此处按复核结果更正。这类偏差本身就是 M0 交付物 1/10 必须先落地的理由。 + +因此对多数多步写命令,`--dry-run` 在用户请求计划时返回错误。那是安全且无用的,且对差分门禁不可见,因为两侧编译器会复现同一错误。 + +因此计划以数据形式归属执行所有者: + +```go +// 计划不是 Definition 字段:它属于执行所有者。形态 1/2 在 mcpbind.Bind +// 内从 CallSpec 推导;形态 3 通过 cmdcore.WithHandler(def, h, cmdcore.WithPlan(p)) +// 提供。对 write/high-risk 形态 3 handler 必需,其余可选。 + +type Planner interface { + // Plan 在与 Validator 相同的只读预检能力下运行: + // 已解析值、参数与 Clock,无写许可,无输出 writer。 + Plan(ValidationContext) (DryRunPlan, error) +} + +type DryRunPlan struct { + Summary string + Steps []PlannedStep +} + +type PlannedStep struct { + Product string + Server string + Tool string + Params map[string]any + // Conditional 标记依赖先前步骤响应、因此无法在执行前完全解析的步骤。 + Conditional bool + Note string +} +``` + +规则: + +- 由框架而非 handler 渲染计划,并遵守 `--format`/`--jq`/`--fields`。Handler 停止手写打印预览。`DryRunPlan` 是数据,渲染器是可替换策略:框架同时提供一个复刻遗留信封的兼容渲染器和一个原生计划渲染器,切换渲染器是 approved delta 而不是里程碑内的隐式变更(§5.11.1)。 +- 形态 1 与 2 自动从已编译的 `CallSpec` 推导计划。这些命令的作者不声明 `Plan`;dry-run 是绑定的属性,这正是 Leaf 已有有用 dry-run 输出且零逐命令预览代码的原因。 +- 形态 3(命令式 Handler)在命令为 write/high-risk 时需要显式 `Planner`:框架不能从单个 CallSpec 发明多步参数。 +- dry run 执行 `Plan` 并返回;它从不进入 Handler 或形态 1/2 派发器调用路径。这比禁用写许可更强,因为它也移除了预览曾经做的读调用。 +- `Conditional` 存在是为了让计划对尚无法知道的步骤保持诚实,而不是发明参数。隐瞒扇出的计划比错误更糟。 +- 因为计划构造是预检,它可以在 Risk 确认前作为普通输入校验失败,从而保持 §5.1 的顺序规则完整。 + +#### 5.11.1 内部表示与渲染形态分开上线 + +计划的**内部表示**和它的**渲染形态**必须分两步上线,因为二者的兼容性含义不同。把二者绑在 M1 会同时违反 M1 的零增量门禁。 + +今天 28 条 Leaf 命令的 `--dry-run` 输出并不是这里的 `DryRunPlan`,而是遗留执行器的信封 `executor.Result`: + +```text +{"invocation": {kind, stage, implemented, dry_run, canonical_product, + tool, canonical_path, legacy_path, params}, + "response": {dry_run, note, request /* 完整 JSON-RPC 信封 */}} +``` + +它与 `DryRunPlan{Summary, Steps[]}` 结构不同,按构造不可能字节等价。因此: + +- **M1 交付机制、自动 mcpbind 计划推导,以及一个逐字复刻上述遗留信封的渲染器。** 28 条 Leaf 命令是首批用户,上线输出零变化:变的是计划的内部来源,不是用户看到的字节。渲染器可替换是这里的设计要点——`DryRunPlan` 是数据,渲染是策略。M1 的零增量定位与回滚窗口因此完整保留,不为一个展示格式让路。M1 门禁比较该渲染器输出与 M0 记录的 28 条命令 dry-run golden,要求字节相等。 +- **把 Leaf 的 dry-run 输出切到 `DryRunPlan` 的原生渲染形态是 §9 下的 approved delta,不属于 M1。** 它与下一条捆绑在同一里程碑交付,届时一次性给出前后夹具、CHANGELOG 与更新后的差分期望。在此之前,原生渲染器只经直接测试覆盖,不挂到任何上线命令上。 +- 策略门禁——每个 write/high-risk **形态 3** 受管定义声明 `Plan`——随命令迁移逐条可强制。形态 1/2 在构造上已覆盖。该门禁在遗留 Shortcut 分批上线前无法覆盖全部 168 条,因此覆盖率按里程碑报告,而不是事先声称。 +- 用声明计划替换遗留手写预览,属于 §9 下的用户可见输出变更;上述 9 个预览文件各自以 approved delta 迁移。 --- -### S6 — Narrow `PostMount` +## 6. 能力对等矩阵 -Fixes **F5**. Measured usage is only two kinds: -- `devAppLeafMeta(cmd, tool)` ×8 → named field `SchemaIdentity{Product, Tool, Source}` -- `registerDevAppCursorFlags(cmd)` ×4 → named `ExtraFlags []FlagSpec` / `Pagination` +在每一行都被建模或在构造时被显式拒绝之前,Shortcut 不能接入生产。 -`PostMount` survives as an explicitly labelled escape hatch with a call-site -inventory (migration-era asset). - -**Gates**: drift zero. **Effort**: 1 PR (small). +| 能力 | 上线 Shortcut/Leaf 行为 | 所需 v2 状态 | +|---|---|---| +| 命令 Use/Short/Long | 上线 | 精确快照对等 | +| 命令 Example/Tips | Shortcut 把 Tips 渲染为 Example | 已建模 | +| Shortcut 命令 Hidden | 经评审的可见性解析器是有效权威;Contract 携带解析值 | 编译器在 Contract 校验前应用语义 catalog/公开回落可见性 | +| 其他受管命令 Hidden | 声明拥有 | 在 Contract 中建模 | +| 命令别名 | 由 devapp PostMount 使用 | 已建模 | +| 位置参数元数/schema | Cobra Args + Runtime Schema 位置参数;当前全部 Leaf 命令为 `NoArgs` | 已建模;工作流 handler 可读 `Runtime.Args`,但本 RFC 中 mcpbind 仍仅限 flag 键 | +| String/int/bool/list flag | 上线 | 已建模 | +| 类型化默认值 | 使用 bool/int/list 默认值 | 类型化,精确 DefValue 对等 | +| Flag Hidden | 兼容别名依赖它 | 已建模 + 感知隐藏的 schema | +| Flag 别名/环境回落 | 框架能力;生产 Leaf 使用一个别名且 `EnvVar`/`RequiredHint` 声明为零 | 保留解析器能力与直接兼容测试 | +| Shorthand/deprecation/NoOpt | 当前 Shortcut/Leaf 总体无自定义 shorthand 或 deprecation;bool `NoOptDefVal` 由 Cobra 派生 | 快照断言精确空/派生值;受影响手写命令迁移前 M7 必须建模这些字段 | +| Required | 框架语义当前不同 | 显式 PresenceMode | +| Enum | Shortcut 强制 string/list enum | 运行时 + 帮助 + schema | +| AtLeastOne/ExactlyOne/MutuallyExclusive | 上线 | 运行时 + 帮助 + schema | +| RequireTogether | Schema/helper 能力存在 | 声明式支持 | +| RequiredWhen | Runtime Schema 能力存在 | 已建模元数据 + 校验器链接 | +| Range 与条件校验 | 当前命令式 | 已发布 ValidatorSpec | +| ConstraintCustom | 14 条声明 | 已发布 ValidatorSpec;永不丢弃 | +| 隐藏 flag 约束折叠 | Shortcut 仅投影公开事实 | 精确投影对等 | +| Risk/yes/dry-run | 上线 | 公共流水线 | +| Dry-run 预览内容 | 单次调用 handler 自动(遗留 `executor.Result` 信封);79 个多步文件中仅 9 个手写渲染,其余报错 | 声明式 `Plan`,框架渲染;M1 用复刻遗留信封的兼容渲染器,原生渲染是后续 approved delta(§5.11、§5.11.1) | +| 来自 `@file`/stdin 的 flag 值 | 两套框架均不可用 | 声明式 `Input`;M1 无上线命令采用(§5.3) | +| 拒绝结果 | 框架不同 | 类型化哨兵 + 兼容策略 | +| 非交互确认 | 缺陷:EOF 被当作拒绝,退出 0,写被丢弃 | `ConfirmUnavailable` → `confirmation_required`;由独立 hotfix H0 先发,approved delta 而非对等(§3.4、§8) | +| Validate 顺序/错误文案 | 已上线行为 | 差分对等 | +| 后端调用参数/顺序 | 已上线行为 | 记录器对等 | +| 用户自定义 Shortcut | 运行时加载 | 返回错误的编译器,对等前保持遗留 | +| PostMount | 27 个源赋值 / 28 条上线命令 | 移除前先具名能力 | +| 声明 vs 执行 | Leaf:Bind+Call 执行且零 RunE;Shortcut:全部 376 条要求 Execute | 三种形态(§3.5、§5.1);仅形态 3 需要 Handler | +| 响应投影 | 108 个 Shortcut 在一次调用后整形;82 个独立 helper;ResultSink 只写 | mcpbind.Bind 上的 `ResponseProjector`(§5.7);本 RFC 无投影 DSL | --- -### S7 — Delete `LeafSpec` +## 7. 迁移计划 -Fixes **F6**. Convert devapp's 27 sites to `CommandSpec` + an MCP dispatch -helper; delete `LeafSpec`, the type aliases, `FromLeafSpec`, `NewLeafCommand`. +旧的 S1–S9 序列由 M0–M7 取代。关键路径是:证据 → 原子 Leaf 采纳 → 确定的遗留接缝 → Shortcut 桥接 → 差分门禁 → 仅执行上线 → 声明形态转换。 -**Gates**: drift zero + interface-compatibility dual baseline (the command -surface must not change). -**Effort**: 1 PR (medium). +最后一步不是可选润色。M0–M5 结束时每条 Shortcut 仍通过兼容包装器执行手写函数体;M5b 才把统一契约变成删除执行代码,而 §10 是对照其结果写的。 + +### M0 — 可复现证据与基线 + +交付物: + +1. 签入 AST 普查脚本及其机器可读输出,并绑定到源提交。 +2. 发布完整分类列表,而不只是聚合计数。 +3. 捕获结构快照: + - 全部 376 个内置(`shortcut list --all`,不是默认的 265); + - 全部已挂载的活 Cobra 叶子; + - 全部 27 个 LeafSpec 源定义与 28 条上线命令; + - Runtime Schema 输出。 +4. 增加独立声明 golden,例如 `test/fixtures/shortcut-v1-contract.json`。它记录每个 Shortcut 的每个字段,包括隐藏 flag、Tips、`HasValidate` 与 `HasExecute`。若两侧编译器意外同向漂移,这是历史锚点。 +5. 分别记录源码与上线总体:70 个非空默认值字面量构造点对 78 个挂载的带默认值 flag、17 个生产隐藏 flag,以及 §3.2 中精确的 Clock 清单。 +6. 给全部 14 条当前自定义规则稳定语义 ID,并签入规则到校验器的覆盖 golden。 +7. 签入完整 PostMount 能力清单与豁免的裸叶子键集。 +8. 记录边缘情况的当前错误类别/消息与后端调用行为,包括畸形遗留默认值强制转换。 +9. 把手写叶子分类为四种 disposition 之一,并与普查一并签入:**delete**(死亡、被取代或从未调用)、**fold**(可由 flag 表达的兄弟薄变体)、**migrate**(普通受管叶子),或 **capability-blocked**(按 M7 命名缺失的 Runtime 能力)。普查计数手写命令有多少;该分类决定项目打算保留多少。迁移成本与幸存者成正比,因此减法是 M7 范围的规划输入,而不是事后清理。此处不断言任何数字:分类由同一可复现脚本产出,暂定数会重犯下文指出的错误。 +10. 签入 §3.5 Execute 形态普查的**脚本与** golden:对每个内置 Shortcut,记录其 `Execute` 是形态 1 候选(直接返回的单次调用)、形态 2 候选(单次调用 + 消费响应)、形态 3 多步,还是形态 3 纯本地。§3.5 的表在此项落入 P0 前为暂定;此后 golden 是 M5b 候选列表与 §10 残留标准的权威。分类器必须像每个其他 M0 产物一样,可由另一开发者从干净检出重跑。 +11. 在 `MustCompile` 进入根路径前建立性能基线,**并在同一处签入由这些基线导出的绝对预算数字**(§M4),而不只是噪声地板;缺少绝对预算时逐 PR 相对阈值可被累积绕过: + - `dws version` 与 `dws --help` 的冷子进程启动; + - 每个门禁平台上的发行二进制大小与冷进程峰值 RSS; + - 发行版与空配置的正常根构造; + - 全部 376 个 Shortcut 的遗留构造; + - 28 条上线 Leaf 命令的既有构建/执行路径。 +12. 签入 28 条上线 Leaf 命令的 `--dry-run` 输出 golden,逐字保留当前 `executor.Result` 信封(§5.11.1)。M1 的兼容渲染器对照它比较;没有这份 golden,「dry-run 零增量」无法被证明,只能被声称。 + +M0 不能对 Contract 编译或受管 Resolve 做基准,因为这些组件尚不存在。M1 记录首个 cmdcore/mcpbind 组件基线;M3 记录首个 376 定义 Contract 编译器/桥接基线。在此之前,冷启动/根/遗留端到端测量是跨实现回归门禁。 + +在本步骤可复现之前,暂定的 1195/141/92/368/594 普查不用作硬规划输入。 + +现有接口快照覆盖全部 376 个 Shortcut 节点,但省略了 `Short`、`Long`、`Example`、flag usage 与 Schema 注解。M0 扩展而非静默重解释该夹具。 + +现有完整 Shortcut 假后端测试是有用种子:今天 375 个定义到达已记录的后端调用,1 个被校验有意阻塞。更丰富的基线必须保留该区分,并仍拒绝静默成功。 + +**门禁**:另一开发者可从干净检出重跑普查与快照并得到相同结果。在评判实现变更前记录基准噪声地板与预算。 + +### M1 — 原子核心、mcpbind 与 Leaf 采纳 + +这是一个合并边界,即便拆成可评审提交也是如此。不要合并一个休眠的第二核心,再等后续 PR 才在活命令上证明它。 + +一起实现: + +- Contract、Definition 与可选的 Handler 接口; +- 三种声明形态:mcpbind 派发器(形态 1)、派发器加 `ResponseProjector`(形态 2),以及命令式 Handler(形态 3); +- ArgsSpec、类型化默认值、ResolvedValues、ValueSource 与 PresenceMode; +- 受管执行流水线、RuleID/ValidatorSpec 与类型化取消; +- EffectPermit 加受管包导入/IO 策略分析器; +- 返回错误的 Compile、静态 MustCompile 与豁免的裸路径; +- 带全函数编码器与可选投影器的 mcpbind; +- 全部 27 个 LeafSpec 源定义,产出 28 条形态 1 上线命令。 + +在移除 PostMount 之前,建模其实际能力: + +- `ArgsNoArgs`、`DisableAutoGenTag` 与命令别名; +- 后端身份注解与遗留偏好; +- 额外未绑定 flag 与隐藏兼容别名; +- 四条带 cursor 的命令。 + +LeafSpec 可保留为 Contract + mcpbind 之上的领域门面。在同一合并边界内,移除旧核心流水线及其 MCP 泄漏: + +- `CommandSpec` 与原型 `Ctx`/`Invoke`/`Orchestrate`; +- `BuildArgs`; +- 核心的 `Bind`、`ArgDefault`、`Transform` 与 `OmitEmpty` 字段。 + +M1 有意采用穷尽对等加发布/回退流程,而不是同二进制 Leaf 选择器。总体固定且可枚举(一个文件中 27 个静态声明、28 条挂载命令,全部经 Call),而真正的选择器必须保留或复制完整的过时解析器、校验与 BuildArgs 路径。那会与上述原子删除矛盾,并加倍活安全面。合并必须有文档化的一步发布回滚流程。 + +此处「回滚」指重新部署保留的上一 GA 二进制/制品,而不是在 P2/P3 依赖其 API 之后选择性 revert P1。P1 在依赖里程碑之前接受隔离的候选发布/dogfood 浸泡,记录发布负责人与接受的回滚 SLO,保留先前制品,并执行回滚演练。Git revert 仅在依赖变更合并前有效。 + +若产品策略反而要求同二进制 Leaf 回滚,唯一诚实的替代是冻结的 `leaflegacy` 实现,且 BuildArgs 在其稳定窗口结束前只是被移动而非删除。这两种声称不能共存。 + +M1 还关闭入口,这使该里程碑本身就有价值,而不是仅作为 M3–M5 的脚手架。每个**新的**受管叶子必须通过新核心声明: + +- `make policy` 在新增的静态 `&cobra.Command{}` 可执行叶子上失败,除非它是带经评审例外的 `RegisterRaw` 项,或纯分组节点; +- 门禁对照 M0 豁免键集比较,因此只约束新代码,从不要求现有命令迁移; +- 新的 write/high-risk 定义从一开始就获得 `ConfirmUnavailable` 处理与 §5.11 `Plan`,因此在遗留分批仍在迁移时,§3.4 类缺陷不能被重新引入。 + +没有该门禁,框架收益要到 M5 之后才出现,而手写总体在 M0–M4 期间继续增长并放大 M7。 + +**门禁**: + +- cmdcore 与 mcpbind 直接测试,变更代码覆盖率 100%; +- 非法静态/动态定义在命令可执行前失败; +- 未知/错误 kind 访问返回编程错误;mcpbind 与 Shortcut 兼容桥在后端/输出副作用前失败闭合; +- 任何执行所有者(派发器或 Handler)都不得在 required/enum/constraint/validator/Risk 检查之前运行; +- `Definition` 不可编译,且没有不经恰好一个执行所有者就能得到 `*Executable` 的公开路径;Compile 拒绝 nil/零值; +- 受管包不能调用裸后端/stdio 路径,且获批的 Caller/ResultSink 实现拒绝非法许可; +- 全部 28 条命令的结构对等,包括帮助、Schema、注解、别名、隐藏 flag、cursor flag 与 enable/disable 实例化; +- 精确的 product/server/tool/类型化载荷、错误类别/消息/顺序、确认、拒绝、dry-run、输出与零调用对等; +- 两个大写编码器与含版本发布的写路径有显式夹具; +- Resolve、受管 no-op 与 mcpbind 的首个组件基线,同时 M0 端到端启动/根预算仍适用; +- 入口门禁对故意添加的裸可执行叶子夹具失败,对等价受管声明通过; +- `Input` 与 `Plan` 有直接测试——不可读路径、重复 stdin 声明、条件步骤渲染——即便尚无上线命令声明 `Input`; +- 28 条 Leaf 命令的 `--dry-run` 输出经兼容渲染器后与 M0 记录的遗留 `executor.Result` 信封字节相等(§5.11.1);原生计划渲染器只有直接测试,不挂到任何上线命令; +- Catalog/Schema/接口零增量,**dry-run 输出亦零增量**。M1 不接受有意的表面增量。 + +### M2 — 确定的遗留 Shortcut 接缝 + +在不改变其生产语义的前提下,使遗留实现可测试: + +- 向 15 个文件中的 16 个 handler 调用点注入 Runtime Clock; +- 向两处用量记录调用注入单独 Clock; +- 用按调用的 Cobra 输入替换遗留提示对 `os.Stdin` 的直接读取,并保留其当前 stderr 提示; +- 以 §3.4 已由独立 hotfix H0 修复为前提(§8):M2 以**修复后**的行为作为差分基线,并且在重写该确认调用点时不得回退到「EOF 等于拒绝」。若 H0 因故未能先落地,M2 必须先落地它,且它仍是单独 PR 加单独 CHANGELOG,不与接缝重构混在同一提交; +- 通过调用接缝捕获输出/全局选项,供 M3 稍后以 `InvocationOptions` 支撑; +- 生产使用真实 Clock 与正常 Cobra 流,测试使用固定依赖。 + +不要把上下文传播静默折进这次重构。当前 Shortcut 后端 helper 以 `context.Background` 分离,而目标使用 Runtime.Context。在 M3 之前,选择并记录下列两策略之一: + +1. 首选:单独的、用户可见的 P2b 把遗留实现改为传播命令上下文,带产品批准、CHANGELOG 与取消测试;然后差分两侧都使用纠正后的行为; +2. 兼容:两侧在上线期间使用显式 `ShortcutLegacyDetached` 策略,上下文传播作为独立变更稍后交付。 + +这是单独里程碑,因为把用作差分基线的代码变更与引入新编译器放在同一 PR,会使失败含义不清。 + +§3.4 修复不属于 M2,而是独立 hotfix H0,理由见 §3.4 末段。它仍必须早于 M3:一旦差分门禁存在,旧行为会成为两侧的必需输出,缺陷会被 CI 锁死;在门禁建成前修复,比事后给 168 条命令批例外更便宜。M2 与它的关系只有一条——M2 以修复后的行为为基线,并且不得在重写调用点时把它改回去。 + +**门禁**:在真实依赖下,遗留结构输出、错误、提示与后端观测保持不变(H0 已落地的 §3.4 行为即为基线,M2 自身不引入任何有意增量);固定 Clock/IO 夹具确定,且 `SetIn`/`SetOut`/`SetErr` 隔离有效。取消观测匹配所选且显式记录的策略。关闭 stdin 的写调用为非零且零后端调用,交互式 "no" 保持当前结果,`--yes` 不受影响。 + +### M3 — Shortcut Contract 编译器与兼容桥 + +把表面编译与执行选择分开: + +```go +type SurfaceCompiler interface { + CompileSurface(Shortcut) (*cobra.Command, error) +} + +type ExecutorMode string + +const ( + ModeLegacy ExecutorMode = "legacy" + ModeV2 ExecutorMode = "v2" + ModeAuto ExecutorMode = "auto" +) + +type ExecutorKind uint8 + +const ( + ExecutorInvalid ExecutorKind = iota + ExecutorLegacy + ExecutorV2 +) + +type ExecutorSelector interface { + For(CommandKey) (ExecutorKind, error) +} +``` + +在 M3 期间,`legacySurfaceCompiler` 仍是生产路径与独立对等预言机。P4 切换到规范表面后它仅用于测试。`contractSurfaceCompiler` 构建候选规范 Cobra 表面。运行时选择单独设计,且不得决定命令字段。 + +在 `cmdcore.Runtime + ResolvedValues + RuntimeDeps` 之上实现 §5.5.1 Shortcut 兼容 Runtime。其签入的方法清单覆盖全部 23 个当前方法/helper: + +- 类型化访问器与 `Changed` 读取不可变快照; +- `StrFirst` 保留第一个非空顺序,而 `IntFirst` 在主有效值之前保留显式兼容键优先; +- 声明式关系 helper 迁入核心;其余兼容校验器读取同一快照; +- DryRun、Yes、参数、Context、Clock 与输出选项来自核心 Runtime; +- CallMCP/Data/WriteData、JSON/错误行为、AI 打标与 Output 位于注入的 Shortcut 适配器,不在 cmdcore; +- 当前两处 `Command().Context()` 调用迁到 `Runtime.Context()`;Validate、Execute 与共享适配器不得读 Cobra flag 或流; +- 隔离的 `legacyRuntimeSource` 仅为广告的遗留执行器保留旧的 Cobra 背书访问器,并随其删除。 + +兼容门面为动态 v1 定义保留字符串名 getter,其名称不能全部由静态 Go 键表示。因其旧签名不能返回错误,未知/错误 kind 访问记录粘性内部错误。流水线在每个兼容 Validator 之后、Risk 提示之前检查它(§5.6 步骤 6);桥接也在每个后端/输出 effect 之前与 Handler 返回之后检查它。静默零值永不能授权工作。AST 夹具验证每个内置字面量名称与 kind。 + +12 个遗留 `Validate(*RuntimeContext)` 钩子与 Execute 分开包装。其校验门面由 ValidationContext 与能力拒绝的 Caller/ResultSink 实现支撑;任何尝试的 MCP/输出方法在调用或写之前返回类型化能力错误。这保留旧函数体而不授予 Risk 前的 effect。每个钩子有零调用/零输出测试。它们以后可直接迁到 ValidatorSpec,但该源码改写不得藏在编译器管道内。 + +契约编译器还必须携带类型化/遗留默认值、Args 策略、PresenceMode、Enum、Tips、解析后的命令可见性、感知隐藏的 Schema 投影、全部自定义校验器、精确校验顺序/错误文案以及族级拒绝兼容。 + +影子模式下生产继续挂载并执行遗留命令。测试编译两侧表面;生产从不执行两侧 handler,尤其是写。 + +**门禁**:全部 376 个内置在无丢弃能力的情况下编译;23/23 Runtime 方法有 v2 非 Cobra 映射;直接 `Command()` 使用归零;全部剩余 Cobra 值读取限制在已检查的遗留源白名单内;全部 12 个校验门面证明零 effect 能力;用户自定义非法声明返回 BuildError 而非 panic。本里程碑记录首个 Contract 编译/桥接组件基准基线;M0 端到端预算仍适用。 + +### M4 — 结构、差分运行时与性能门禁 + +#### 结构快照 + +至少比较: + +- Use、Short、Long、Example; +- Hidden、Aliases、Args/元数、DisableAutoGenTag; +- 每个 flag 的名称、类型、DefValue、NoOptDefVal、usage、Hidden、注解、deprecation 与补全元数据; +- 命令注解与 Runtime Schema 约束; +- 可见帮助输出。 + +`Args` 比较的是**规范化后的有效元数行为,而不是字段身份**。遗留 `mount()` 构造 `&cobra.Command{}` 时只设 `Use`/`Short`/`Long`/`Hidden`,从不设 `Args`,因此 `cmd.Args == nil`(Cobra 对无子命令的叶子按 `ArbitraryArgs` 处理);而 §5.1 把同一条命令映射为显式的 `ArgsArbitrary`,即一个非 nil 函数。两侧运行时行为相同,但任何反射式结构比较都不相等。快照因此把 `Args` 记录为封闭的规范化标签(如 `arbitrary` / `none` / `exact:N` / `minimum:N`),遗留侧由「`Args` 是否为 nil + 是否有子命令」推导,声明侧由 ArgsSpec 推导;比较发生在标签上。直接对函数值用 `reflect.DeepEqual` 会让第一个分批卡在一个假阳性上。同理,§M5 的三表面「字节相等」指序列化后的帮助/Schema/Catalog/结构快照字节,其中结构快照使用这里的规范化标签;它不是对 `cobra.Command` 做字段身份比较。 + +#### 差分运行时 + +对这些组合分别对记录假后端运行;从不调用真实写 API: + +1. 遗留表面 + 遗留执行器(历史基线); +2. 规范 Contract 表面 + 遗留执行器(实际回滚路径); +3. 规范 Contract 表面 + v2 执行器; +4. 规范表面 + 使用精确清单的 `auto`。 + +组合 2 是强制的:它证明遗留执行器仍 bug-for-bug 观察 Contract 注册的 pflag/默认值状态,而不是仅证明两个孤立编译器彼此一致。 + +比较: + +- 错误 nil/非 nil、结构化类别、精确消息与顺序; +- 确认提示与拒绝结果; +- dry-run / yes 行为; +- 有序后端调用轨迹; +- product、server、tool 与载荷; +- 确定性规范化后的输出载荷; +- 校验/拒绝时无调用。 + +记录器保留类型化载荷,并在任何 JSON 渲染前使用 `reflect.DeepEqual`,因此 `int`→`float64` 或 `bool`→`string` 的变化不能躲在等价 JSON 文本之后。 + +```go +type Observation struct { + Panic string + Error ErrorObservation // 具体类型/类别/原因/退出/消息 + Stdout string + Stderr string + Calls []CallObservation // 有序 product/server/tool/类型化参数 +} +``` + +生成用例覆盖: + +- 缺失与仅空白的 required 值; +- 类型化默认值对显式值; +- 显式 false 与零; +- 隐藏别名与环境回落; +- 非法整数环境/默认值; +- 每个 enum 值加非法值; +- 每个关系约束的零/一/多成员; +- 自定义校验器; +- read/write/high-risk 确认路径; +- 预取消上下文与等待确认时的取消,对照显式 M2 取消策略; +- 畸形遗留默认值与每个全函数 mcpbind 编码器; +- 位置参数与 NoArgs。 + +扩展而非替换现有的 `TestAllShortcutsAssemble`:它已有假后端与全注册表迭代。 + +12 个 Validate 钩子各有策展失败夹具,14 条自定义约束每一条都由稳定 RuleID 与至少一个夹具命名。依赖时间的 handler 使用注入 Clock;门禁不得因规范化掉整段时间字段而掩盖真实增量。 + +对两侧修订都存在的基准,性能运行在同一 worker 上交错跑 merge-base 与 head: + +- 预构建冷二进制,至少 30 个样本,比较启动 p50/p95; +- 比较发行二进制字节与冷进程峰值 RSS; +- Go 基准使用 `-benchmem -count=10` 与成对 `benchstat`; +- 根构造、全定义编译、Resolve 与受管 no-op 执行报告时间与分配。 + +**逐 PR 相对阈值。** M0 记录环境噪声地板后,time/op、B/op 或 allocs/op 上统计显著且超过 10% 的回归阻塞 PR,除非绝对变化低于该记录地板。冷启动 p95 超过 10% 也阻塞;二进制大小或平台特定峰值 RSS 超过 10% 需要显式性能批准。生产 `cmd_init` 遥测是 dogfood 信号,不是 CI 的替代。新引入的组件基准在 M1 或 M3 建立基线,并在后续 PR 成为增量门禁;它不假装有 merge-base 对应物。 + +**绝对预算,作为真正的上限。** 只有相对阈值会产生棘轮效应:约 400 条静态定义在 init 期 `MustCompile`,加上 M1 的入口门禁持续新增受管命令,10 个 PR 各 9.9% 可以累积到约 2.6 倍而永不触发逐 PR 门禁。因此签入一组固定的绝对上限,以 M0 交付物 11 的基线为锚: + +- `dws version` 与 `dws --help` 冷子进程 p95 不超过 M0 基线 p95 的 1.25 倍; +- 每个门禁平台的发行二进制大小与冷进程峰值 RSS 各有绝对字节上限。 + +任何 PR 越过绝对上限即阻塞,**即便其自身增量低于 10%**;相对阈值退为附加门禁,用于抓住单次跳变。提高绝对预算本身是一次显式决策,需要性能批准、记录在案的理由,以及与基线一并更新的签入数字——预算可以调,但只能被看见地调。 + +**门禁**:全部 376 个内置加代表性用户自定义 Shortcut 夹具零未解释结构或运行时增量,且全部性能预算通过。 + +### M5 — 基于风险的上线与回滚 + +在第一批之前,把内置生产构造切换到一个规范的、由 Contract 构建的 Cobra 表面。其 `RunE` 包含两条兼容执行路径,并咨询**仅执行**选择器。选择器从不选择构造哪些 flag、帮助、注解或 Schema 事实。 + +这解决了 Catalog/选择器冲突。建议名 `DWS_SHORTCUT_COMPILER` 被拒绝,因为它暗示构造变更。使用发布作用域的 `DWS_SHORTCUT_EXECUTOR=legacy|v2|auto`: + +- `legacy`:每个内置执行遗留流水线; +- `v2`:每个内置为 CI、dogfood 与复现执行受管流水线; +- `auto`:使用签入的精确 `(service, command)` 执行清单。 + +规范 `RunE` 在 Cobra 解析固定表面之后执行选择: + +```go +cmd.RunE = func(c *cobra.Command, args []string) error { + executor, err := selector.For(key) + if err != nil { + return err + } + switch executor { + case ExecutorLegacy: + return executeLegacy(c, args, shortcut, legacyDeps) + case ExecutorV2: + return executeManaged(c, args, definition, managedDeps) + default: + return errInvalidExecutorMode + } +} +``` + +只有 `executeLegacy` 可实例化 `legacyRuntimeSource`;受管分支立即捕获 InvocationIO/选项并解析类型化值。 + +生产根始终挂载同一规范表面。Schema 生成器(`NewSchemaSourceRootCommand`)、Catalog 生成器与接口快照任务接收显式 `auto(checked-in manifest)` 选项,且从不读取环境变量。上线前,CI 独立构建旧遗留表面、Contract 表面与生产规范表面,并要求完整的 Cobra/帮助/Schema/Catalog 字节相等: + +```text +遗留表面 == v2 Contract 表面 == 规范 auto 表面 +``` + +这里的相等按 §M4 的定义执行:序列化后的帮助、Schema、Catalog 与结构快照字节相等,其中 `Args` 等函数值字段以规范化的有效元数标签参与比较。不存在对 `cobra.Command` 的反射式字段身份比较。 + +因此生成漂移仍是实际发布命令表面的证明。它有意**不是**所选执行器行为相同的证明;差分门禁提供该证据。尽管表面字节相同,选择器仍必要,因为运行时校验、确认、后端调用与输出可能不同。 + +建议分批: + +1. 至多十个精确键的有界读密集金丝雀; +2. 其余隐藏/只读内置,按有界键批次; +3. 公开只读内置; +4. 拒绝策略签字后的 write/high-risk 内置; +5. 用户自定义 Shortcut 最后,经其单独的运行时加载编译路径。 + +选择器解析返回错误。非法值绝不能静默变成 `auto`。若当前根构造器不能返回错误,它挂载规范树以使帮助仍可用,然后在受管执行、确认或任何后端调用之前返回类型化配置错误。 + +`auto` 在调用前解析为封闭的 `ExecutorKind`(`legacy|v2`);`ExecutorAuto` 不是可执行 kind。签入清单是内置上的全量精确映射: + +```text +keys(manifest) == BuiltInCommandKeys +value(manifest[key]) ∈ {legacy, v2} +``` + +缺失、未知或重复键与非法值是构造/配置错误。新内置在没有显式初始清单项时不能合并。全部内置 v2 编译已在 RunE 能选择它之前成功,且没有从失败的 v2 执行到遗留的按调用回退。 + +运行时加载的用户自定义 Shortcut 有意不在该静态映射内。它们使用单独校验的 `DWS_USER_SHORTCUT_EXECUTOR=legacy|v2` 策略:在 P6b 前遗留保持默认;CI/dogfood 可强制 v2;仅在代表性与畸形 YAML 夹具通过两侧编译器后,P6b 才改变默认。稳定窗口内同二进制 `legacy` 覆盖仍保留。缺失的内置清单项绝不能与此动态回退混淆。 + +其回滚路径在第一批之前测试。 + +每个分批 PR 包含: + +- 执行清单变更; +- 该批与全注册表的结构与差分结果; +- 零漂移证据; +- 回滚测试。 + +禁止执行清单变更改变 Catalog 或任何 Cobra 表面字节。有意的用户可见表面变更是单独 PR,使用正常兼容/CHANGELOG/产品决策流程,并在广告回滚期间必须被两侧执行器支持。 + +M5 期间不改写任何声明;仅执行器切换是本里程碑的迁移。声明形态转换是 M5b 的单独工作,发生在一批在 v2 上稳定之后。晋升要求全注册表强制 v2 CI 对等,以及无未解释观测增量的 dogfood 浸泡。上线按版本/分批;本仓库没有远程百分比上线机制。 + +在全部 376 个内置使用 v2 之后,至少保留遗留执行器与 kill switch 一个完整稳定发布(建议最少 30 天)。先切换默认;在稍后的清理发布中删除遗留。 + +在该窗口期间,每条 Shortcut 声明仍必须在两侧执行器下编译并通过差分测试。CI 拒绝会使广告的 `legacy` 回滚路径不可用的 v2-only 声明或特性。 + +### M5b — 声明形态转换(形态 3 → 形态 1/2) + +M0–M5 统一了表面、预检与执行器,但让每条 Shortcut 仍经 §5.5.1 `adaptHandler` 包装器执行——即全部 376 条都是形态 3。停在那里不能满足任何 §10 残留标准,并恰好交付 §3.5 所拒绝的结果:376 个手写函数体被改名而非移除。本里程碑才是声明/执行分离真正兑现的地方。 + +转换有意**不**与 M5 的执行器切换捆在一起。改变哪个执行器运行与改变声明说什么是不同风险;把它们放在同一 PR 会使差分失败含义不清,理由与 M2 独立于 M3 相同。 + +它分两阶段运行,因为广告的 `legacy` 回滚需要 `Shortcut.Execute` 在 M6 之前保持可调用: + +**阶段 A — 声明调用,保留函数体。** 对每个形态 1/2 候选,添加 `CallSpec`(形态 2 加 `WithProjector`),并把 v2 执行器经 `mcpbind.Bind` 而不是 `adaptHandler` 路由。`Execute` 留在声明中并继续服务遗留执行器,因此上述双执行器约束仍成立,且没有任何声明变成 v2-only。 + +本阶段有异常强的预言机:差分门禁现在把同一命令的遗留 `Execute` 函数体与新的声明式派发比较。等价性是度量出来的,而不是断言的。每条命令必需的对等: + +- 每种支持的 `--format`/`--jq`/`--fields` 下的 stdout/stderr 字节; +- 后端 product/server/tool、载荷键/值与调用顺序; +- 校验与后端失败的错误类别、消息与退出码; +- dry-run 计划输出(§5.11),对这些命令从遗留单次调用预览迁到从 `CallSpec` 推导的计划; +- 拒绝与 `ConfirmUnavailable` 时零后端调用。 + +转换在定义上是行为保持的。任何增量都是要修的转换缺陷,而不是要记录的 approved delta——与 §3.4 不同,此处无意改变可观测行为。 + +**阶段 B — 删除函数体。** 在 M6 移除遗留执行器之后,已转换命令的 `Execute` 字段没有剩余调用者并被删除,连同阶段 A 移入 `ResponseProjector` 的投影 helper。在此之前它们在 v2 路径上已死但对回滚仍活,CI 必须断言二者仍可达。 + +排序与记账: + +- 先转换形态 1 再形态 2:237 条命令只需要 `CallSpec`,而形态 2 还需要其投影器被评审为纯函数。 +- 按服务分批,顺序与 M5 所用分批相同,使回归被熟悉的爆炸半径约束。 +- 候选列表是 M0 交付物 10 的 golden。转换期间被证明误分类的命令在评审下更新 golden;§10 对照的是 golden,而不是 §3.5 的暂定表。 +- 抵抗转换的候选保持形态 3,并带记录原因与具名缺失能力。因此残留可能超过暂定的 31;仅当每个例外被单独解释时这才可接受,而不是静默容忍。 + +**门禁**:对每条已转换命令,相对遗留函数体的差分对等在上述列表上字节精确;每 PR 报告已转换计数、残留形态 3 计数以及每个残留的原因;在遗留执行器仍能选择它时,不删除任何 `Execute` 字段。 + +### M6 — 移除遗留路径并收窄钩子 + +在每批已上线且回滚窗口结束后: + +- 移除遗留表面预言机与执行流水线; +- 运行 M5b 阶段 B:删除每条已转换命令的 `Execute` 字段与被取代的投影 helper,这仅在没有任何执行器能选择遗留函数体时才安全; +- 移除 `legacyRuntimeSource`,使 Shortcut 不再有 Cobra 值读取; +- 若选择了兼容策略,移除 `ShortcutLegacyDetached`; +- 移除执行器环境开关与签入的上线清单; +- 在产品决策后移除临时拒绝兼容模式; +- 用具名字段替换剩余 PostMount 清单; +- 决定 LeafSpec 是否仍是有用的 MCP 门面; +- 仅在适配器零调用者且零独特语义时删除它们。 + +删除 LeafSpec 是要评估的结果,不是成功指标。 + +### M7 — 手写命令与 Schema 权威清理 + +对照稳定 Contract API 重跑可复现普查。 + +- 先经 mcpbind 迁移简单命令。 +- 经同一 Handler 接口迁移多步命令;不要创建第二个「编排器」框架。 +- 把纯分组节点保持为 Cobra 父节点。 +- 按缺失的 Runtime 能力分类特殊 IO/上传/轮询命令,然后只增加普遍有用的能力。 +- 不要求豁免的裸清单归零。有状态的、由后端派生的提示与真正自定义的 Cobra/IO 工作流可保持为经评审的永久下界。 +- 在迁移任何受影响的手写命令之前,显式建模 Shorthand、Deprecated、ShorthandDeprecated 与类型化 NoOptDefault。在 mcpbind 消费位置参数值之前,增加类型化位置键;本 RFC 的 mcpbind 仅限 flag 键。 + +Schema 清理是单独项目: + +- 使用 §5.10 按字段分配权威; +- 仅在冲突检查表明冗余来源不增加评审价值后移除它们; +- 保留经评审的身份/导航注册表。 --- -### S8 — Live-wire Shortcut (prerequisites: S1 + S2 + S3) +## 8. PR 切片 -Migrate per service package (20 packages, 1 PR each), ordered -`usage` (smallest) → `todo`/`contact` → … → `chat`/`aitable` (largest). +实现由能力驱动,而不是由服务计数驱动。 -**Per-PR gates** -1. **Generalize the existing mount-equivalence test into a table over - `allShortcuts`**: for every registered shortcut, assert - `cmdcore.NewCommand(FromShortcut(s))` and `mount(s)` agree byte-for-byte on - flag set / types / usage / `Long` / annotations. ← the most important guard. -2. `shortcut list` output byte-identical (265 entries). -3. `dws schema --all` byte-identical. -4. drift zero + 100% changed-code coverage. +H0 不属于本 RFC 的实现序列,列在此处只为记录其与 M2/M3 的时序约束。它不依赖 P0,也不依赖任何新核心代码。 -**Still unmodeled before S8** (must be represented or explicitly rejected): -`Flag.Hidden` (no `CommandSpec` field), `Shortcut.Tips → Example`, -`ConstraintCustom`. +| PR | 范围 | 上线行为变更 | +|---|---|---| +| **H0**(独立 hotfix,先发) | §3.4 非交互确认修复。唯一前置是 §11 决策 5;不依赖 P0–P1,必须早于 P3 | write/high-risk 命令在非交互进程中大声失败而不是退出 0;单独 CHANGELOG 变更 | +| P0 | RFC v5、可复现普查、减法分类、声明/裸/PostMount 快照、28 条 Leaf 的 dry-run golden 与性能基线加绝对预算 | 无 | +| P1 | 原子 Contract/解析器/mcpbind + 27 个 Leaf 定义 / 28 条上线命令;删除 BuildArgs/原型;入口门禁 | 无 | +| P2a | 遗留 Shortcut Clock、InvocationIO 与确定基线接缝 | 无 | +| P2b | 可选的首选上下文传播纠正(若获批) | 仅取消语义;单独 CHANGELOG 变更 | +| P3 | Contract 表面编译器 + 影子模式下的 23 方法 RuntimeContext 桥 | 无 | +| P4a | 完整结构/运行时/性能门禁,仍在遗留生产构造上 | 无 | +| P4b | 规范表面 + 执行选择器,全量清单初始全为遗留 | 无 | +| P5 | 隐藏/只读与公开/只读执行分批 | 仅执行器选择 | +| P6a | 拒绝决策后的 write/high-risk 执行分批 | 仅执行器选择 | +| P6b | 用户自定义上线;开始强制双执行器稳定窗口 | 仅执行器选择 | +| P6d | M5b 阶段 A:按服务声明形态转换,v2 侧从 `adaptHandler` 切换到 mcpbind 派发 | 无;相对遗留函数体的差分对等字节精确 | +| P6c | 一个稳定发布 / 至少 30 天后:移除遗留,运行 M5b 阶段 B(删除已转换 `Execute` 函数体),收窄钩子 | 清理 | +| P7 | 手写迁移与 Schema 权威工作 | 单独项目 | -**Effort**: ~20 PRs. +PR #830 可携带 RFC 供评审,但不得把 `Invoke`/`Orchestrate` 原型原样推送为已接受的 P1 API。 --- -### S9 — Schema single source (endgame) +## 9. 每 PR 验证 -Fixes **F9**. Make the declarative spec the only source of parameter facts -(`required` / `enum` / `require_together` / constraints); `schema_hints` retains -only Agent selection prose (`agent_summary` / `use_when` / `avoid_when`). +每个行为保持的实现 PR 必须通过通用门禁: -**Risk**: high (touches the Schema generation pipeline's division of -responsibility with the reviewed registry). **Effort**: separate project. +1. `go test -count=1 ./...` +2. 相关竞态套件与跨平台编译测试; +3. 变更代码覆盖率 100%,且每个新包有直接测试; +4. `make policy`; +5. `make skill-command-integrity`; +6. 生成漂移检查; +7. `dws schema --all` 字节比较; +8. `dws shortcut list --all` 字节比较; +9. 相对 merge-base 与最新稳定 GA 的接口兼容; +10. 该阶段可用的每个基准:M0 端到端指标立即对照 merge-base,而新的 M1/M3 组件基准仅在其首次记录基线后成为增量门禁; +11. 干净、最新的合并修订,且每个必需远程检查为绿。 + +当其基础设施存在时,附加门禁变为强制: + +| 阶段 | 额外必需证明 | +|---|---| +| H0 | 全部 168 个 write/high-risk 命令的关闭 stdin 写夹具,交互拒绝与 `--yes` 夹具不变,以及已记录的 §11 决策 5 退出信号 | +| P0 | 普查/声明/裸 golden、28 条 Leaf 的 dry-run 信封 golden、绝对性能预算数字,均可从干净检出复现 | +| P1 | 直接解析器/流水线/typed-nil/effect 测试、穷尽 28 命令对等、隔离浸泡与先前制品回滚演练 | +| P2a | 确定的 Clock/InvocationIO 测试与遗留观测对等 | +| P2b | 若选择,获批的上下文传播决策与精确取消夹具;否则显式分离策略对等 | +| P3 | 376 个定义编译、23/23 v2 RuntimeContext 映射、无直接 Command 使用,且 Cobra 读取限制在遗留源白名单内 | +| P4a 及以后 | 全部 376 个内置加代表性用户自定义夹具的完整 Cobra/帮助/Schema 结构与运行时观测 | +| P4b–P6b | `legacy`、`v2` 与 `auto` 执行器测试、全量精确清单审计、三表面字节相等与同二进制 Shortcut 回滚 | +| P6b | 单独用户自定义执行器策略、动态/畸形夹具与同二进制用户自定义回滚 | +| P6d | 每命令遗留函数体 vs 声明式派发对等(输出字节、载荷/顺序、错误类别、dry-run 计划、零调用路径);已转换与残留形态 3 计数及每残留原因;`Execute` 仍可被遗留执行器到达 | +| P6c | 文档化的稳定发布/30 天窗口、双执行器证据、无剩余回滚依赖,以及对每个已删除 `Execute` 函数体零剩余调用者 | + +有意的用户可见变更——**approved delta**——不折进迁移分批。它使用单独 PR,带: + +- 精确的、经评审的前后夹具; +- CHANGELOG 条目; +- 产品决策; +- 更新后的差分期望。 + +最后一项使 approved delta 不同于对等失败:差分期望在评审下被有意编辑,而不是压制门禁。§3.4 是第一个此类增量。 + +任何测试都不得仅因 Schema/catalog 漂移为零就声称运行时等价。 --- -## 4. Bugs this design review already caught (evidence the gates matter) +## 10. 验收标准 -Fixed in #830 after review: -- A spec with neither `RunE` nor `Dispatch` ran the whole pipeline — **including - the write-confirmation prompt** — then silently exited 0 having done nothing. -- `FromShortcut` **double-rendered** the `参数约束` help section (`shortcutLongHelp` - already appends it; `NewCommand` appended `ConstraintHelp` again) and **dropped** - the `必填 / 可选值` usage decoration. Both would have silently changed help and - catalog output at S8. -- A new **mount-equivalence test** (projected flag set/types/usage + rendered - `Long` vs live `mount(s)`) now catches this whole class; it is the seed of the - S8 gate. +收敛项目仅在以下条件全部满足时完成: + +- 全部 376 个内置 Shortcut 与受支持的用户自定义形态经受管 Contract 路径编译; +- 全部 27 个 LeafSpec 定义(产出 28 条上线命令)经 Contract 路径(或保留的 LeafSpec 门面)编译且无行为增量;LeafSpec 门面在有证据前不必删除; +- 结构与差分运行时对等零未解释增量; +- 非法 Contract 或 mcpbind 输入从不产生可执行命令; +- 任何受管定义都不能绕过 Required/Enum/Constraint/Validator/Risk; +- 每条命令式规则由稳定 RuleID 恰好认领一次并被测试; +- 每个 PostMount 能力有具名表示,或留在显式裸清单中;`PostMount` 不得注册业务 flag; +- 新旧 Shortcut 执行器回滚经测试,且遗留执行器可移除; +- Runtime Schema 权威按字段文档化,冲突失败闭合; +- 启动、构造、编译与按调用性能预算通过,**包括 §M4 的绝对冷启动/二进制大小/峰值 RSS 上限**,而不只是逐 PR 相对阈值; +- 全部必需本地与远程门禁为绿; +- **没有 `Execute`/`Call` 函数体只为装配参数而存在**:可声明的 flag→载荷、常量、包含策略、表面元数据一律走声明;门禁可证明 Call/Execute 字面量不再做 `params[k]=…` 类装配(具名工具注入如 cursor 除外)。执行体本身是一等扩展点,允许存在,不计入「残留」,不要求逐条审批理由。把 376 个 Execute 改名为 376 个 Handler 却仍只做参数装配,**不**满足本标准;反过来,保留真正的业务/投影/多步执行体**不**扣分。 + +1195 暂定手写命令普查的迁移是后续项目,不是 Shortcut/Leaf 收敛的条件。 --- -## 5. Handwritten commands (1195) +## 11. 仍需的产品决策 -- After **S1**, the 368 C-class commands become expressible for the first time - (via `Orchestrate`). -- The 233 A/B-class commands are migratable today, but should be scheduled - **after S4/S5**, otherwise they get migrated onto the old `FlagSpec` shape and - then rewritten again. -- The 594 GROUP nodes need no migration. +前三个决策不阻塞 M0–M4,但阻塞受影响的上线。决策 4 必须在 M3 差分运行时工作前做出。决策 5 是独立 hotfix H0 的唯一前置,与本 RFC 的里程碑无依赖关系,且应当被当作最紧急的一项来处理,因为缺陷正在生产环境静默丢弃写操作。 + +1. **拒绝退出行为**:保留退出 0、使用既有非零类别,还是增加专用取消码?建议:保留类型化、不导出的用户拒绝哨兵,在重构期间保持当前行为,并在单独的用户可见变更中决定目标。 +2. **长期存在性语义**:兼容迁移后,Required/约束应收敛到显式还是有效存在?建议:二者都可声明表达;不要把来源语义藏在框架特定代码中。 +3. **LeafSpec 门面**:保留面向领域的 MCP 构建器,还是要求直接使用 Contract + mcpbind?建议:保留到可与真实迁移代码比较调用点工效与维护成本为止。 +4. **遗留 Shortcut 取消**:在编译器比较前纠正当前分离的 `context.Background` 行为,还是在上线期间保留它?建议:在单独 P2b 中纠正,带产品批准与 CHANGELOG;永不把该变更藏在 P3 内。 +5. **非交互确认信号**(§3.4):`ConfirmUnavailable` 应采取哪种可观测形式——专用退出码,还是带机器可读 `confirmation_required` 原因的既有非零类别?建议:为「需要确认」保留专用退出码,因为调用方必须区分「拒绝在没有 `--yes` 时运行」与「运行了但失败」,复用校验类别会使二者无法区分。该决策只阻塞 hotfix H0,不阻塞本 RFC 的任何里程碑;反过来,H0 也不应等待本 RFC 的任何进展。不能仅因这个决策未定就让当前行为继续原样发布——若决策在一周内未定,按建议方案先发 H0,退出码语义作为后续可调整项记录在 CHANGELOG 中,因为「大声失败但退出码待定」严格优于「静默报成功」。 --- -## 6. Global discipline (per PR, non-negotiable) +## 12. 评审者检查清单 -1. `check-generated-drift` zero drift = build-time projection equivalence proof. -2. Runtime semantics can only be proven by unit tests — drift cannot see them. -3. 100% changed-code coverage, and **a new package must have its own direct - tests** (never rely on cross-package `coverpkg`; this was a real CI failure). -4. Anything touching a user-visible contract (exit codes, flag names, help text) - requires a CHANGELOG entry and an explicit human decision. +评审者应质疑: ---- - -## 7. Questions for the reviewer - -1. **S2b**: should declining a risky operation exit non-zero (unify on error), or - should Shortcut's exit-0 behavior be preserved? This is the only user-visible - contract change in the plan. -2. **S1**: is `Invoke` / `Orchestrate` / `RunE` with a construction-time - "exactly one" rule the right factoring, or should dispatch be an interface - (e.g. `Dispatcher` with two implementations) instead of three nullable - function fields? -3. **S4**: is splitting `FlagSpec` (CLI-facing) from `ArgBinding` (payload-facing) - worth the churn on 27 call sites now, versus keeping one struct and accepting - the leak? -4. **S8 ordering**: is per-service migration with a byte-identity gate the right - risk posture for 376 shipped shortcuts, or is a parallel "shadow mode" - (build both, compare at run time) warranted? -5. Is **S9** worth attempting at all, given the reviewed-registry design - intentionally separates identity/safety review from code? +1. Contract 字段是否真正后端中立。 +2. 每个当前值来源与类型化默认值是否由 ResolvedValues 表示。 +3. PresenceMode 对 Required 与约束是否足够。 +4. 裸命令是否可能意外声称受管安全保证。 +5. 自定义校验器在帮助/Schema 中是否仍可发现。 +6. 差分门禁比较的是行为,还是仅命令元数据。 +7. 用户自定义命令是否会在非法声明上 panic。 +8. 每个 Runtime Schema 字段是否恰好有一个声明权威。 +9. 上线与回滚是否经实际的规范表面与执行选择器拓扑运作。 +10. 任何提议的删除是否移除了有用的领域门面,而不仅仅是重复。 +11. 任何被 bug-for-bug 保留的行为是否其实是缺陷,以及兼容门禁是否会使其永久化(§3.4)。 +12. 确认结果是否仍可在没有人类的情况下到达,以及调用方在那时观察到什么。 +13. 声明的 dry-run 计划是否对无法预先解析的步骤保持诚实,而不是打印发明的参数。 +14. 该计划是否在 M5 之前就交付任何价值,还是只在最后一批之后。 +15. 是否仍存在只为装配参数而写的 `Execute`/`Call` 函数体;要求每个受管定义都有非 nil Handler 是否只会改名这些装配体;形态 1/2 表是否被误当成「必须删光执行体」的指标(§3.5、§5.7、§10)。 +16. `ResponseProjector` 是否保持为纯 data→data 钩子,还是静默长成带 IO 与后端调用的第二个 Handler。 +17. `Get` 的零占位返回加毒化,是否真的等价于「坏读取绝不能授权 effect」;§5.6 的四个检查点是否覆盖了每一条 effect 路径,还是需要给 `Get` 加回 error 返回(§4.3、§5.4)。 +18. 「M1 无上线输出变化」是否对每一类输出都成立,包括 dry-run 信封;兼容渲染器是否真的逐字复刻而不是「结构等价」(§5.11.1)。 +19. 差分门禁比较的每一个字段,是否都在「有效行为」而不是「字段身份」的层面定义;有多少字段是函数值(§M4)。 +20. 每条门禁是否既有相对阈值又有绝对上限,还是可以被逐 PR 的小增量累积绕过(§M4)。 +21. 每一条要求「逐条对等」的门禁,其比较对象的语义是否已在别处定义;有没有哪条门禁按构造无法满足(§5.7 投影器错误归类、M5b 阶段 A)。 +22. 本 RFC 引用的每个计数,是否已由签入脚本产出,还是仍是暂定值(§3.5、§5.11)。 diff --git a/internal/cmdcore/cmdcore.go b/internal/cmdcore/cmdcore.go index 0cfe1201..e573b929 100644 --- a/internal/cmdcore/cmdcore.go +++ b/internal/cmdcore/cmdcore.go @@ -76,7 +76,7 @@ type FlagSpec struct { Name string // flag name (kebab-case) Usage string // registration usage text Kind FlagKind // value type, defaults to KindString - Default string // registration default (only KindString uses it at registration; as a fallback-chain tail it applies to all kinds without shadowing aliases/env) + Default string // registration default for every Kind; also the fallback-chain tail when aliases/env are empty // Required, when true, validates a non-empty effective value in RunE. Plain // Required flags aggregate into a cmdutil.ValidateRequiredFlags-compatible @@ -91,8 +91,10 @@ type FlagSpec struct { Aliases []string // hidden aliases, registered with the main flag's Kind; used in order when the main flag is not explicitly provided EnvVar string // environment variable consulted when the effective value is empty (an integer flag's env value must be parseable) // ArgDefault covers the case where the registration default is empty but - // toolArgs still needs a fallback; used as the arg value when the effective - // value is empty. + // toolArgs still needs a fallback. For KindString it is used when the + // effective value is empty. For KindInt it is also the floor: when the + // resolved integer is < 1, ArgDefault is emitted instead (cursor page-size + // semantics). ArgDefault string // Bind is the toolArgs key; empty uses Name. Bind string @@ -181,12 +183,16 @@ type CommandSpec struct { Flags []FlagSpec Constraints []Constraint Risk Risk + // ConstParams are fixed toolArgs merged after flag assembly (e.g. precheckOnly). + // They are not flags and never satisfy Required. + ConstParams map[string]any // Validate is the cross-flag validation hook, run after required/constraint // checks and before args assembly; nil skips it. Validate func(cmd *cobra.Command, args []string) error // PostMount adjusts the built command after flag registration and before - // RunE is set (Args/DisableAutoGenTag/annotate/…); always runs. + // RunE is set (Args/DisableAutoGenTag/annotate/…); always runs. Business + // flags belong in Flags, not here. PostMount func(cmd *cobra.Command) // RunE fully replaces the generated body (escape hatch). RunE func(cmd *cobra.Command, args []string) error @@ -326,6 +332,9 @@ func NewCommand(spec CommandSpec) *cobra.Command { if err != nil { return err } + for key, value := range spec.ConstParams { + toolArgs[key] = value + } if !ConfirmRisk(cmd, spec.Risk) { return apperrors.NewValidation("用户取消了操作") } @@ -373,15 +382,20 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) { } } -// RegisterFlag registers one flag by Kind; the registration default is only used -// by KindString (other kinds treat Default purely as a fallback-chain tail, -// matching the existing KindInt behavior). +// RegisterFlag registers one flag by Kind. Default is applied at registration +// for every kind so --help DefValue matches the declared fallback. func RegisterFlag(cmd *cobra.Command, kind FlagKind, name, def, usage string) { switch kind { case KindInt: - cmd.Flags().Int(name, 0, usage) + defInt := 0 + if def != "" { + if v, err := strconv.Atoi(def); err == nil { + defInt = v + } + } + cmd.Flags().Int(name, defInt, usage) case KindBool: - cmd.Flags().Bool(name, false, usage) + cmd.Flags().Bool(name, def == "true", usage) case KindStringSlice: cmd.Flags().StringSlice(name, nil, usage) default: @@ -478,6 +492,14 @@ func BuildArgs(cmd *cobra.Command, flags []FlagSpec) (map[string]any, error) { if err != nil { return nil, err } + // ArgDefault floors values < 1 (cursor page-size: 0/-1 → default). + if v < 1 && flag.ArgDefault != "" { + parsed, err := strconv.ParseInt(flag.ArgDefault, 10, 64) + if err != nil { + return nil, fmt.Errorf("flag --%s: invalid ArgDefault %q", flag.Name, flag.ArgDefault) + } + v = parsed + } // Keep "non-zero only" (putInt semantics). if v != 0 { toolArgs[bind] = int(v) diff --git a/internal/cmdcore/cmdcore_test.go b/internal/cmdcore/cmdcore_test.go index 101f1b63..18c0fd08 100644 --- a/internal/cmdcore/cmdcore_test.go +++ b/internal/cmdcore/cmdcore_test.go @@ -1001,3 +1001,67 @@ func TestCrossPlatformCoverageNewCommandOrchestrateHonorsConfirmation(t *testing t.Fatal("confirmed orchestrate did not run") } } + +func TestRegisterFlagTypedDefaults(t *testing.T) { + cmd := newTestCommand() + RegisterFlag(cmd, KindInt, "page-size", "20", "page") + RegisterFlag(cmd, KindBool, "flag", "true", "bool") + if def, _ := cmd.Flags().GetInt("page-size"); def != 20 { + t.Fatalf("int default = %d, want 20", def) + } + if def, _ := cmd.Flags().GetBool("flag"); !def { + t.Fatal("bool default = false, want true") + } + if got := cmd.Flags().Lookup("page-size").DefValue; got != "20" { + t.Fatalf("page-size DefValue = %q, want 20", got) + } +} + +func TestBuildArgsIntArgDefaultFloor(t *testing.T) { + flags := []FlagSpec{{ + Name: "page-size", Usage: "P", Kind: KindInt, + Default: "20", ArgDefault: "20", Bind: "pageSize", + }} + cmd := newTestCommand() + RegisterFlags(cmd, flags) + + args, err := BuildArgs(cmd, flags) + if err != nil { + t.Fatal(err) + } + if args["pageSize"] != 20 { + t.Fatalf("unset pageSize = %#v, want 20", args["pageSize"]) + } + + _ = cmd.Flags().Set("page-size", "0") + args, err = BuildArgs(cmd, flags) + if err != nil { + t.Fatal(err) + } + if args["pageSize"] != 20 { + t.Fatalf("zero pageSize = %#v, want floor 20", args["pageSize"]) + } +} + +func TestNewCommandMergesConstParams(t *testing.T) { + var got map[string]any + cmd := NewCommand(CommandSpec{ + Use: "pub", + Flags: []FlagSpec{{Name: "id", Usage: "ID", Bind: "versionId", Trim: true}}, + ConstParams: map[string]any{"precheckOnly": false}, + Invoke: func(_ *Ctx, toolArgs map[string]any) error { + got = toolArgs + return nil + }, + }) + _ = cmd.Flags().Set("id", "V1") + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatal(err) + } + if got["versionId"] != "V1" { + t.Fatalf("versionId = %#v", got["versionId"]) + } + if got["precheckOnly"] != false { + t.Fatalf("precheckOnly = %#v, want false", got["precheckOnly"]) + } +} diff --git a/internal/helpers/devapp.go b/internal/helpers/devapp.go index 66b98a46..ce1a68e5 100644 --- a/internal/helpers/devapp.go +++ b/internal/helpers/devapp.go @@ -249,7 +249,7 @@ func newDevAppEventListCommand(runner executor.Runner) *cobra.Command { Use: "list", Short: "查询应用已订阅的事件列表", Example: " dws dev app event list --unified-app-id UNIFIED_APP_ID --page-size 20 --format json", - Tool: devAppEventListTool, + Tool: devAppEventListTool, Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true}, {Name: "keyword", Usage: "事件搜索关键词,支持按事件码或事件名称模糊匹配", Bind: "keyword", Trim: true, OmitEmpty: true}, @@ -258,12 +258,8 @@ func newDevAppEventListCommand(runner executor.Runner) *cobra.Command { _, err := requiredDevAppUnifiedID(cmd) return err }, - // cursor/pageSize 由 devAppApplyCursorParams 注入(page-size 默认 20、floor 20)。 - Call: devAppCallCursor(runner), - PostMount: func(cmd *cobra.Command) { - devAppLeafMeta(cmd, devAppEventListTool) - registerDevAppCursorFlags(cmd) - }, + Call: devAppCallCursor(runner), + PostMount: devAppMetaCursor(devAppEventListTool), }) } @@ -275,6 +271,7 @@ func newDevAppEventSubscribeCommand(runner executor.Runner) *cobra.Command { Tool: devAppEventSubscribeTool, Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true}, + {Name: "event-codes", Usage: "事件码,多个用逗号或分号分隔", Bind: "eventCodes", Trim: true, Transform: transformDevAppListParam}, }, Validate: func(cmd *cobra.Command, args []string) error { if err := devAppRequireWriteGuard(cmd, "event subscribe"); err != nil { @@ -288,15 +285,8 @@ func newDevAppEventSubscribeCommand(runner executor.Runner) *cobra.Command { } return nil }, - // eventCodes 数组由 Call 解析注入(与手写 params["eventCodes"] 等价)。 - Call: func(cmd *cobra.Command, tool string, params map[string]any) error { - params["eventCodes"] = parseDevAppListFlag(cmd, "event-codes") - return runDevAppTool(runner, cmd, tool, params) - }, - PostMount: func(cmd *cobra.Command) { - devAppLeafMeta(cmd, devAppEventSubscribeTool) - cmd.Flags().String("event-codes", "", "事件码,多个用逗号或分号分隔") - }, + Call: devAppCall(runner), + PostMount: devAppMeta(devAppEventSubscribeTool), }) } @@ -308,6 +298,7 @@ func newDevAppEventUnsubscribeCommand(runner executor.Runner) *cobra.Command { Tool: devAppEventUnsubscribeTool, Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true}, + {Name: "event-codes", Usage: "事件码,多个用逗号或分号分隔", Bind: "eventCodes", Trim: true, Transform: transformDevAppListParam}, }, Validate: func(cmd *cobra.Command, args []string) error { if err := devAppRequireWriteGuard(cmd, "event unsubscribe"); err != nil { @@ -321,14 +312,8 @@ func newDevAppEventUnsubscribeCommand(runner executor.Runner) *cobra.Command { } return nil }, - Call: func(cmd *cobra.Command, tool string, params map[string]any) error { - params["eventCodes"] = parseDevAppListFlag(cmd, "event-codes") - return runDevAppTool(runner, cmd, tool, params) - }, - PostMount: func(cmd *cobra.Command) { - devAppLeafMeta(cmd, devAppEventUnsubscribeTool) - cmd.Flags().String("event-codes", "", "事件码,多个用逗号或分号分隔") - }, + Call: devAppCall(runner), + PostMount: devAppMeta(devAppEventUnsubscribeTool), }) } @@ -337,7 +322,7 @@ func newDevAppListCommand(runner executor.Runner) *cobra.Command { Use: "list", Short: "查询开放平台企业内部应用列表", Example: " dws dev app list --name DemoApp --page-size 20 --format json", - Tool: devAppListTool, + Tool: devAppListTool, Flags: []LeafFlag{ {Name: "name", Usage: "应用名称关键词", Bind: "name", Trim: true, OmitEmpty: true, Aliases: []string{"keyword"}}, {Name: "app-key", Usage: "按 appKey/clientId 过滤", Bind: "appKey", Trim: true, OmitEmpty: true}, @@ -349,13 +334,8 @@ func newDevAppListCommand(runner executor.Runner) *cobra.Command { {Name: "sort-type", Usage: "排序字段,如 gmt_modified", Bind: "sortType", Trim: true, OmitEmpty: true}, {Name: "sort-order", Usage: "排序方向 asc 或 desc", Bind: "sortOrder", Trim: true, OmitEmpty: true}, }, - // cursor/pageSize 由 devAppApplyCursorParams 注入(退役 devAppApplyCursorParams 之外, - // 本命令同时退役 devAppPutString/devAppPutInt/devAppFlagOrFallback 全套手搓 helper)。 - Call: devAppCallCursor(runner), - PostMount: func(cmd *cobra.Command) { - devAppLeafMeta(cmd, devAppListTool) - registerDevAppCursorFlags(cmd) - }, + Call: devAppCallCursor(runner), + PostMount: devAppMetaCursor(devAppListTool), }) } @@ -629,7 +609,7 @@ func newDevAppPermissionListCommand(runner executor.Runner) *cobra.Command { Use: "list", Short: "查询开放平台应用权限列表", Example: " dws dev app permission list --unified-app-id UNIFIED_APP_ID --keyword 通讯录 --page-size 20 --format json", - Tool: devAppPermissionListTool, + Tool: devAppPermissionListTool, // 命令级别名 "search" 由 PostMount 设回(LeafSpec 无 Command Aliases 字段)。 Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true}, @@ -646,8 +626,7 @@ func newDevAppPermissionListCommand(runner executor.Runner) *cobra.Command { Call: devAppCallCursor(runner), PostMount: func(cmd *cobra.Command) { cmd.Aliases = []string{"search"} - devAppLeafMeta(cmd, devAppPermissionListTool) - registerDevAppCursorFlags(cmd) + devAppMetaCursor(devAppPermissionListTool)(cmd) }, }) } @@ -660,6 +639,7 @@ func newDevAppPermissionAddCommand(runner executor.Runner) *cobra.Command { Tool: devAppPermissionAddTool, Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true}, + {Name: "scope-values", Usage: "权限点 scopeValue,多个用逗号或分号分隔", Bind: "scopeValues", Trim: true, Transform: transformDevAppScopeValues}, }, Validate: func(cmd *cobra.Command, args []string) error { if err := devAppRequireWriteGuard(cmd, "permission add"); err != nil { @@ -673,15 +653,8 @@ func newDevAppPermissionAddCommand(runner executor.Runner) *cobra.Command { } return nil }, - // scope-values 经 devAppPermissionScopes 解析为 []string,由 Call 注入。 - Call: func(cmd *cobra.Command, tool string, params map[string]any) error { - params["scopeValues"] = devAppPermissionScopes(cmd) - return runDevAppTool(runner, cmd, tool, params) - }, - PostMount: func(cmd *cobra.Command) { - devAppLeafMeta(cmd, devAppPermissionAddTool) - cmd.Flags().String("scope-values", "", "权限点 scopeValue,多个用逗号或分号分隔") - }, + Call: devAppCall(runner), + PostMount: devAppMeta(devAppPermissionAddTool), }) } @@ -693,6 +666,7 @@ func newDevAppPermissionRemoveCommand(runner executor.Runner) *cobra.Command { Tool: devAppPermissionRmTool, Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true}, + {Name: "scope-values", Usage: "待取消权限点 scopeValue,多个用逗号或分号分隔", Bind: "scopeValues", Trim: true, Transform: transformDevAppScopeValues}, }, Validate: func(cmd *cobra.Command, args []string) error { if err := devAppRequireWriteGuard(cmd, "permission remove"); err != nil { @@ -706,14 +680,8 @@ func newDevAppPermissionRemoveCommand(runner executor.Runner) *cobra.Command { } return nil }, - Call: func(cmd *cobra.Command, tool string, params map[string]any) error { - params["scopeValues"] = devAppPermissionScopes(cmd) - return runDevAppTool(runner, cmd, tool, params) - }, - PostMount: func(cmd *cobra.Command) { - devAppLeafMeta(cmd, devAppPermissionRmTool) - cmd.Flags().String("scope-values", "", "待取消权限点 scopeValue,多个用逗号或分号分隔") - }, + Call: devAppCall(runner), + PostMount: devAppMeta(devAppPermissionRmTool), }) } @@ -745,6 +713,7 @@ func newDevAppMemberAddCommand(runner executor.Runner) *cobra.Command { Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true}, {Name: "member-type", Usage: "成员类型,如 DEVELOPER (必填)", Bind: "memberType", Trim: true}, + {Name: "user-ids", Usage: "成员 userId 列表,多个用逗号分隔 (必填)", Bind: "userIds", Trim: true, Aliases: []string{"member-user-ids"}, Transform: transformDevAppListParam}, }, Validate: func(cmd *cobra.Command, args []string) error { if err := devAppRequireWriteGuard(cmd, op); err != nil { @@ -761,18 +730,8 @@ func newDevAppMemberAddCommand(runner executor.Runner) *cobra.Command { } return nil }, - // userIds 由 Call 解析注入(user-ids / member-user-ids 别名回退)。 - Call: func(cmd *cobra.Command, tool string, params map[string]any) error { - users, _ := requiredDevAppUsers(cmd) - params["userIds"] = users - return runDevAppTool(runner, cmd, tool, params) - }, - PostMount: func(cmd *cobra.Command) { - devAppLeafMeta(cmd, devAppMemberAddTool) - cmd.Flags().String("user-ids", "", "成员 userId 列表,多个用逗号分隔 (必填)") - cmd.Flags().String("member-user-ids", "", "成员 userId 列表,多个用逗号分隔 (兼容旧参数)") - _ = cmd.Flags().MarkHidden("member-user-ids") - }, + Call: devAppCall(runner), + PostMount: devAppMeta(devAppMemberAddTool), }) } @@ -786,6 +745,7 @@ func newDevAppMemberRemoveCommand(runner executor.Runner) *cobra.Command { Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true}, {Name: "member-type", Usage: "成员类型,如 DEVELOPER (必填)", Bind: "memberType", Trim: true}, + {Name: "user-ids", Usage: "成员 userId 列表,多个用逗号分隔 (必填)", Bind: "userIds", Trim: true, Aliases: []string{"member-user-ids"}, Transform: transformDevAppListParam}, }, Validate: func(cmd *cobra.Command, args []string) error { if err := devAppRequireWriteGuard(cmd, op); err != nil { @@ -802,17 +762,8 @@ func newDevAppMemberRemoveCommand(runner executor.Runner) *cobra.Command { } return nil }, - Call: func(cmd *cobra.Command, tool string, params map[string]any) error { - users, _ := requiredDevAppUsers(cmd) - params["userIds"] = users - return runDevAppTool(runner, cmd, tool, params) - }, - PostMount: func(cmd *cobra.Command) { - devAppLeafMeta(cmd, devAppMemberRemoveTool) - cmd.Flags().String("user-ids", "", "成员 userId 列表,多个用逗号分隔 (必填)") - cmd.Flags().String("member-user-ids", "", "成员 userId 列表,多个用逗号分隔 (兼容旧参数)") - _ = cmd.Flags().MarkHidden("member-user-ids") - }, + Call: devAppCall(runner), + PostMount: devAppMeta(devAppMemberRemoveTool), }) } @@ -826,6 +777,9 @@ func newDevAppSecurityConfigCommand(runner executor.Runner) *cobra.Command { Tool: devAppSecurityConfigTool, Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true}, + {Name: "ip-whitelist", Usage: "出口 IP 白名单,多个用逗号或分号分隔(整组覆盖,非追加)", Bind: "ipWhitelist", Trim: true, Transform: transformDevAppListParam}, + {Name: "redirect-urls", Usage: "登录重定向 URL,多个用逗号或分号分隔(整组覆盖,非追加)", Bind: "redirectUrls", Trim: true, Transform: transformDevAppListParam}, + {Name: "sso-urls", Usage: "端内免登地址,多个用逗号或分号分隔(整组覆盖,非追加)", Bind: "ssoUrls", Trim: true, Transform: transformDevAppListParam}, }, Validate: func(cmd *cobra.Command, args []string) error { if err := devAppRequireWriteGuard(cmd, op); err != nil { @@ -842,25 +796,8 @@ func newDevAppSecurityConfigCommand(runner executor.Runner) *cobra.Command { } return nil }, - // 三个列表 flag 经 parseDevAppListFlag 解析,非空才入参(与手写一致)。 - Call: func(cmd *cobra.Command, tool string, params map[string]any) error { - if v := parseDevAppListFlag(cmd, "ip-whitelist"); len(v) > 0 { - params["ipWhitelist"] = v - } - if v := parseDevAppListFlag(cmd, "redirect-urls"); len(v) > 0 { - params["redirectUrls"] = v - } - if v := parseDevAppListFlag(cmd, "sso-urls"); len(v) > 0 { - params["ssoUrls"] = v - } - return runDevAppTool(runner, cmd, tool, params) - }, - PostMount: func(cmd *cobra.Command) { - devAppLeafMeta(cmd, devAppSecurityConfigTool) - cmd.Flags().String("ip-whitelist", "", "出口 IP 白名单,多个用逗号或分号分隔(整组覆盖,非追加)") - cmd.Flags().String("redirect-urls", "", "登录重定向 URL,多个用逗号或分号分隔(整组覆盖,非追加)") - cmd.Flags().String("sso-urls", "", "端内免登地址,多个用逗号或分号分隔(整组覆盖,非追加)") - }, + Call: devAppCall(runner), + PostMount: devAppMeta(devAppSecurityConfigTool), }) } @@ -1173,7 +1110,7 @@ func newDevAppVersionListCommand(runner executor.Runner) *cobra.Command { Use: "list", Short: "分页查询应用版本列表", Example: " dws dev app version list --unified-app-id UNIFIED_APP_ID --page-size 20 --format json", - Tool: devAppVersionListTool, + Tool: devAppVersionListTool, Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true}, }, @@ -1181,11 +1118,8 @@ func newDevAppVersionListCommand(runner executor.Runner) *cobra.Command { _, err := requiredDevAppUnifiedID(cmd) return err }, - Call: devAppCallCursor(runner), - PostMount: func(cmd *cobra.Command) { - devAppLeafMeta(cmd, devAppVersionListTool) - registerDevAppCursorFlags(cmd) - }, + Call: devAppCallCursor(runner), + PostMount: devAppMetaCursor(devAppVersionListTool), }) } @@ -1218,15 +1152,12 @@ func newDevAppVersionCheckApprovalCommand(runner executor.Runner) *cobra.Command {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true}, {Name: "version-id", Usage: "版本 ID (必填)", Bind: "versionId", Trim: true}, }, + ConstParams: map[string]any{"precheckOnly": true}, Validate: func(cmd *cobra.Command, args []string) error { _, err := devAppVersionLocator(cmd) return err }, - // 复用 publish 工具的服务端预检模式:precheckOnly=true 只返回审批要求,不发布。 - Call: func(cmd *cobra.Command, tool string, params map[string]any) error { - params["precheckOnly"] = true - return runDevAppTool(runner, cmd, tool, params) - }, + Call: devAppCall(runner), PostMount: devAppMeta(devAppVersionPublishTool), }) } @@ -1241,7 +1172,9 @@ func newDevAppVersionPublishCommand(runner executor.Runner) *cobra.Command { {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true}, {Name: "version-id", Usage: "版本 ID (必填)", Bind: "versionId", Trim: true}, {Name: "approver-user-id", Usage: "灰度选人模式下指定审批人 userId", Bind: "approverUserId", Trim: true, OmitEmpty: true}, + {Name: "confirmed-sensitive", Usage: "确认发布包含高敏权限的版本", Kind: LeafBool, Bind: "confirmedSensitive"}, }, + ConstParams: map[string]any{"precheckOnly": false}, Validate: func(cmd *cobra.Command, args []string) error { if err := devAppRequireWriteGuard(cmd, "version publish"); err != nil { return err @@ -1251,20 +1184,8 @@ func newDevAppVersionPublishCommand(runner executor.Runner) *cobra.Command { } return nil }, - // precheckOnly=false(真发布)+ confirmed-sensitive 的 Changed() 语义由 Call 处理 - //(框架无 LeafBool/Changed 语义,Bool flag 在 PostMount 注册)。 - Call: func(cmd *cobra.Command, tool string, params map[string]any) error { - params["precheckOnly"] = false - if cmd.Flags().Changed("confirmed-sensitive") { - value, _ := cmd.Flags().GetBool("confirmed-sensitive") - params["confirmedSensitive"] = value - } - return runDevAppTool(runner, cmd, tool, params) - }, - PostMount: func(cmd *cobra.Command) { - devAppLeafMeta(cmd, devAppVersionPublishTool) - cmd.Flags().Bool("confirmed-sensitive", false, "确认发布包含高敏权限的版本") - }, + Call: devAppCall(runner), + PostMount: devAppMeta(devAppVersionPublishTool), }) } @@ -1338,20 +1259,13 @@ func devAppLeafMeta(cmd *cobra.Command, tool string) { } // devAppCall 返回统一派发闭包(替代各命令重复的 Call: runDevAppTool 透传)。 +// 参数装配由 Flags/ConstParams 完成;本闭包只负责执行与响应处理。 func devAppCall(runner executor.Runner) func(*cobra.Command, string, map[string]any) error { return func(cmd *cobra.Command, tool string, params map[string]any) error { return runDevAppTool(runner, cmd, tool, params) } } -// devAppCallCursor 同上,但先经 devAppApplyCursorParams 注入 cursor/pageSize。 -func devAppCallCursor(runner executor.Runner) func(*cobra.Command, string, map[string]any) error { - return func(cmd *cobra.Command, tool string, params map[string]any) error { - devAppApplyCursorParams(cmd, params) - return runDevAppTool(runner, cmd, tool, params) - } -} - // devAppMeta 返回纯收尾 PostMount 闭包(无额外 flag 的命令用)。 func devAppMeta(tool string) func(*cobra.Command) { return func(cmd *cobra.Command) { devAppLeafMeta(cmd, tool) } @@ -1866,6 +1780,34 @@ func splitDevAppList(raw string) []string { return values } +// transformDevAppListParam splits a comma/semicolon list for LeafFlag.Transform. +// Empty input returns nil so the key is omitted from toolArgs. +func transformDevAppListParam(raw string) (any, error) { + values := splitDevAppList(raw) + if len(values) == 0 { + return nil, nil + } + return values, nil +} + +// transformDevAppScopeValues preserves the double-split used by +// devAppPermissionScopes (each comma-separated token may itself be a list). +func transformDevAppScopeValues(raw string) (any, error) { + values := splitDevAppList(raw) + out := make([]string, 0, len(values)) + for _, value := range values { + for _, part := range splitDevAppList(value) { + if part != "" { + out = append(out, part) + } + } + } + if len(out) == 0 { + return nil, nil + } + return out, nil +} + // 应用定位:写操作统一只用 --unified-app-id;dev app get 额外支持只读 --app-key。 // --name 定位已下线(列表搜索的 --name/--app-key 是过滤参数、不在此列)。 // 写操作与其它 app 作用域命令共用 addDevAppUnifiedIDFlag + requiredDevAppUnifiedID。 diff --git a/internal/helpers/devapp_cursor.go b/internal/helpers/devapp_cursor.go index 6117067a..a88ce751 100644 --- a/internal/helpers/devapp_cursor.go +++ b/internal/helpers/devapp_cursor.go @@ -13,16 +13,20 @@ package helpers -import "github.com/spf13/cobra" +import ( + "github.com/spf13/cobra" -// devapp_cursor wires cursor pagination as a PASS-THROUGH: the CLI forwards -// --cursor and --page-size straight into the upstream params, and the upstream -// returns nextCursor/hasMore in its response. No synthesis, no offset/page -// translation, no encode/decode in the CLI — the upstream tools own the cursor -// contract (see docs/cursor-pagination-design.md and docs/upstream-todo.md). -// Until the upstream tools accept `cursor`/`pageSize` and return `nextCursor`, -// paging is in the "pending integration" state, same as the precheckOnly -// rename — no anti-corruption layer bridges the gap. + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" +) + +// Cursor pagination is a devapp list tool, not a LeafSpec declaration. +// List factories compose registerDevAppCursorFlags (PostMount) with +// devAppCallCursor (Call). LeafSpec itself has no Cursor field and does not +// list --cursor/--page-size in Flags. +// +// Semantics stay PASS-THROUGH: the CLI forwards cursor/pageSize into upstream +// params and the upstream returns nextCursor/hasMore. No synthesis, no +// offset/page translation — see docs/cursor-pagination-design.md. // registerDevAppCursorFlags adds the two cursor flags every list/search command // exposes. pageSize defaults to 20. @@ -32,7 +36,7 @@ func registerDevAppCursorFlags(cmd *cobra.Command) { } // devAppApplyCursorParams forwards cursor/pageSize into params as-is. cursor is -// only set when non-empty (first page omits it). +// only set when non-empty (first page omits it). size < 1 floors to 20. func devAppApplyCursorParams(cmd *cobra.Command, params map[string]any) { if cur := devAppStringFlag(cmd, "cursor"); cur != "" { params["cursor"] = cur @@ -43,3 +47,19 @@ func devAppApplyCursorParams(cmd *cobra.Command, params map[string]any) { } params["pageSize"] = size } + +// devAppCallCursor is the list execution body: apply cursor params, then run. +func devAppCallCursor(runner executor.Runner) func(*cobra.Command, string, map[string]any) error { + return func(cmd *cobra.Command, tool string, params map[string]any) error { + devAppApplyCursorParams(cmd, params) + return runDevAppTool(runner, cmd, tool, params) + } +} + +// devAppMetaCursor is PostMount for list leaves: surface meta + cursor flags. +func devAppMetaCursor(tool string) func(*cobra.Command) { + return func(cmd *cobra.Command) { + devAppLeafMeta(cmd, tool) + registerDevAppCursorFlags(cmd) + } +} diff --git a/internal/helpers/devapp_leaf_test.go b/internal/helpers/devapp_leaf_test.go index 0a7a8e56..a49eb0bc 100644 --- a/internal/helpers/devapp_leaf_test.go +++ b/internal/helpers/devapp_leaf_test.go @@ -283,7 +283,7 @@ func TestDevAppListLeaf(t *testing.T) { } } -// TestDevAppEventListLeaf 验证 cursor/pageSize 经 devAppApplyCursorParams 注入。 +// TestDevAppEventListLeaf 验证 cursor/pageSize 经 devAppCallCursor 工具注入。 func TestDevAppEventListLeaf(t *testing.T) { r := &fakeDevAppRunner{} cmd := newDevAppEventListCommand(r) @@ -294,9 +294,9 @@ func TestDevAppEventListLeaf(t *testing.T) { if v, _ := r.got.Params["unifiedAppId"].(string); v != "APP-E" { t.Fatalf("unifiedAppId = %q, want APP-E", v) } - // page-size 默认 20,经 devAppApplyCursorParams 注入为 float64(20)。 - if _, present := r.got.Params["pageSize"]; !present { - t.Fatal("pageSize missing, want injected by devAppApplyCursorParams") + // page-size 默认 20,由 registerDevAppCursorFlags + CallCursor 注入。 + if v, present := r.got.Params["pageSize"]; !present || v != 20 { + t.Fatalf("pageSize = %v, want 20 from cursor tool", v) } // cursor 默认空:不入参。 if _, present := r.got.Params["cursor"]; present { diff --git a/internal/helpers/leaf.go b/internal/helpers/leaf.go index 982f9b93..83ec6848 100644 --- a/internal/helpers/leaf.go +++ b/internal/helpers/leaf.go @@ -117,10 +117,14 @@ type LeafSpec struct { // 则中止且不派发。 Risk LeafRisk - // Call 是可插拔派发函数,非空时替代默认的 callMCPTool/callMCPToolOnServer。 - // 供非 MCP 直连命令(如 devapp 走 executor.Runner)复用本框架:调用方用 - // 闭包捕获自己的 runner/派发器即可。签名与默认路径一致——收到框架装配好 - // 的 toolArgs,自行派发。 + // ConstParams 是与 flag 无关的固定载荷(如 precheckOnly),在 flag 装配 + // 之后并入 toolArgs。它们从不满足 Required。 + ConstParams map[string]any + + // Call 是执行体切面:非空时替代默认的 callMCPTool/callMCPToolOnServer。 + // 供非 MCP 直连命令(如 devapp 走 executor.Runner)注入派发与响应处理。 + // 收到的 toolArgs 已由 Flags/ConstParams 装配完成;Call 不应再写业务参数。 + // 分页等横切能力由领域工具(如 devAppCallCursor)在执行侧处理,不进声明。 Call func(cmd *cobra.Command, tool string, args map[string]any) error // Validate 是跨 flag 校验钩子(如时间区间、互斥关系),在 required @@ -133,7 +137,8 @@ type LeafSpec struct { // PostMount 在 flag 注册完成之后、RunE 设定之前对构建好的 cmd 做最终 // 调整(设置 Args/DisableAutoGenTag、调用 annotate/preferLegacy 等)。 - // 无论是否使用 RunE 逃生舱都会执行。对标 lark shortcut 的 PostMount。 + // 业务 flag 必须写在 Flags 里;分页 flag 由领域工具在 PostMount 注入, + // 不作为 LeafSpec 声明字段。 PostMount func(cmd *cobra.Command) } @@ -160,6 +165,7 @@ func FromLeafSpec(spec LeafSpec) cmdcore.CommandSpec { Flags: spec.Flags, Constraints: spec.Constraints, Risk: spec.Risk, + ConstParams: spec.ConstParams, Validate: spec.Validate, PostMount: spec.PostMount, RunE: spec.RunE, diff --git a/internal/helpers/leaf_policy_test.go b/internal/helpers/leaf_policy_test.go new file mode 100644 index 00000000..dad267b0 --- /dev/null +++ b/internal/helpers/leaf_policy_test.go @@ -0,0 +1,165 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// TestLeafSpecPostMountDoesNotRegisterBusinessFlags keeps business flags in +// Flags. PostMount may set surface metadata and may call domain tools such as +// registerDevAppCursorFlags (pagination is a tool, not a LeafSpec field). +func TestLeafSpecPostMountDoesNotRegisterBusinessFlags(t *testing.T) { + for _, call := range leafSpecCompositeLits(t) { + post := fieldExpr(call, "PostMount") + if post == nil { + continue + } + ast.Inspect(post, func(n ast.Node) bool { + expr, ok := n.(*ast.CallExpr) + if !ok { + return true + } + fun, ok := expr.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + switch fun.Sel.Name { + case "String", "Bool", "Int", "StringSlice", "Int64", "Float64": + inner, ok := fun.X.(*ast.CallExpr) + if !ok { + return true + } + innerSel, ok := inner.Fun.(*ast.SelectorExpr) + if ok && innerSel.Sel.Name == "Flags" { + t.Errorf("%s: PostMount registers business flag via Flags().%s; declare it in Flags instead (cursor tools use registerDevAppCursorFlags)", + call.pos, fun.Sel.Name) + } + } + return true + }) + } +} + +// TestLeafSpecCallDoesNotAssembleBusinessParams keeps Call as an execution body. +// Business binding belongs in Flags/ConstParams. Named helpers like +// devAppCall / devAppCallCursor may apply domain tooling (including cursor). +func TestLeafSpecCallDoesNotAssembleBusinessParams(t *testing.T) { + for _, call := range leafSpecCompositeLits(t) { + body := fieldExpr(call, "Call") + if body == nil { + continue + } + // Named helpers (devAppCall / devAppCallCursor) are execution tools. + if _, ok := body.(*ast.CallExpr); ok { + continue + } + if ident, ok := body.(*ast.Ident); ok { + if strings.HasPrefix(ident.Name, "devAppCall") { + continue + } + } + ast.Inspect(body, func(n ast.Node) bool { + assign, ok := n.(*ast.AssignStmt) + if !ok { + return true + } + for _, lhs := range assign.Lhs { + index, ok := lhs.(*ast.IndexExpr) + if !ok { + continue + } + if ident, ok := index.X.(*ast.Ident); ok && ident.Name == "params" { + t.Errorf("%s: Call assigns params[...]; move business binding to Flags/ConstParams", call.pos) + } + } + return true + }) + } +} + +type leafSpecLit struct { + pos string + lit *ast.CompositeLit +} + +func leafSpecCompositeLits(t *testing.T) []leafSpecLit { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + dir := filepath.Dir(file) + fset := token.NewFileSet() + pkgs, err := parser.ParseDir(fset, dir, func(info os.FileInfo) bool { + name := info.Name() + return strings.HasSuffix(name, ".go") && !strings.HasSuffix(name, "_test.go") + }, 0) + if err != nil { + t.Fatalf("parse helpers: %v", err) + } + var out []leafSpecLit + for _, pkg := range pkgs { + for _, f := range pkg.Files { + ast.Inspect(f, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + fun, ok := call.Fun.(*ast.Ident) + if !ok || fun.Name != "NewLeafCommand" || len(call.Args) != 1 { + return true + } + lit, ok := call.Args[0].(*ast.CompositeLit) + if !ok { + return true + } + if typ, ok := lit.Type.(*ast.Ident); !ok || typ.Name != "LeafSpec" { + return true + } + out = append(out, leafSpecLit{ + pos: fset.Position(lit.Pos()).String(), + lit: lit, + }) + return true + }) + } + } + if len(out) == 0 { + t.Fatal("no NewLeafCommand(LeafSpec{...}) literals found") + } + return out +} + +func fieldExpr(call leafSpecLit, name string) ast.Expr { + for _, elt := range call.lit.Elts { + kv, ok := elt.(*ast.KeyValueExpr) + if !ok { + continue + } + key, ok := kv.Key.(*ast.Ident) + if !ok || key.Name != name { + continue + } + return kv.Value + } + return nil +}