refactor(cmdcore): align leaf safety with schema contract

This commit is contained in:
玉澜
2026-07-30 23:03:32 +08:00
parent 621229fca2
commit 340f01e95c
22 changed files with 510 additions and 560 deletions
+3 -3
View File
@@ -25,10 +25,10 @@ changes must not rewrite it mechanically.
- Framework definition: `docs/rfc-command-framework-convergence.md` **§5.0**
- Today: `helpers.LeafSpec` → `cmdcore.CommandSpec` (+ `Schema`) → `cmdcore.NewCommand`
- **Declare = final Schema source**: `Flags` / `Constraints` / `Risk` / `ConstParams` / `Schema` (ToolSpec groups)
- Light runtime write: only when `Schema` is set → convert once → `RegisterRuntimeContractFinal` (map store, no JSON/deep-clone); `Risk` alone stays `dws.schema.risk`. Assembly **pass-throughs** Final.
- **Declare = final Schema source**: `Flags` / `Constraints` / `Safety` / `ConstParams` / `Schema` (ToolSpec groups)
- `Safety` uses the existing `cli.SafetySpec` directly. Its `confirmation` drives the runtime gate; `effect` / `risk` / `idempotency` are published unchanged. When `Schema` is set, convert once → `RegisterRuntimeContractFinal` (map store, no JSON/deep-clone); assembly **pass-throughs** Final.
- **Execute** = hooks (`Validate` / `Call` / `RunE` / `PostMount`) — not a second surface authority
- Declaration path has **no reviewed parallel fields**; migration-only `runtime_gate` annotate until Safety/`Risk` is declared
- Declaration path has **no reviewed parallel fields**; migration-only `runtime_gate` annotate until `Safety` is declared
## flag / help / schema homology
+29 -60
View File
@@ -27,25 +27,19 @@
│ └────────────────────────────────────────┘ │
│ │
│ ┌─── 安全模型 ──────────────────────────────────────────────────┐ │
│ │ Safety cli.SafetySpec │ │
│ │ ├─ Effect (read / write / destructive) │ │
│ │ ├─ Risk (low / medium / high) │ │
│ │ ├─ Confirmation (not_required / user_required) ──▶ 运行时门 │ │
│ │ └─ Idempotency (idempotent / retryable / …) │ │
│ │ │ │
│ │ Risk (运行时) Safety (Schema) │ │
│ │ ┌──────────────┐ ┌───────────────┐ │ │
│ │ │ read │─────▶│ read │ low/idempotent │ │
│ │ │ write │─────▶│ write │ medium/unknown │ │
│ │ │ high-risk- │─────▶│ destructive │ high/unknown │ │
│ │ │ write │ │ │ │ │
│ │ └──────────────┘ │ high-write │ high/unknown │ │
│ │ │ └───────────────┘ │ │
│ │ │ SafetyDefault() ▲ │ │
│ │ └────────────────────┘ (Safety 为空时的推导) │ │
│ │ │ │
│ │ ConfirmFirst: bool (确认门顺序) │ │
│ │ 四字段彼此独立;同一值同时供运行时与 Schema 使用 │ │
│ │ ConfirmFirst: bool (只控制确认门顺序) │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─── Schema 声明 (Agent 可见的元数据) ──────────────────────────┐ │
│ │ SchemaDecl │ │
│ │ ├─ Title / Description │ │
│ │ ├─ SafetyDecl {Effect, Risk, Confirmation, Idempotency} │ │
│ │ ├─ DryRunDecl {PreviewKind, RemoteReads} │ │
│ │ ├─ InterfaceDecl {Mode, Availability, Reason, Ref} │ │
│ │ ├─ SelectionDecl {AgentSummary, UseWhen, AvoidWhen, │ │
@@ -74,11 +68,12 @@ CommandSpec ──── NewCommand() ────▶ cobra.Command ────
│ │ │
构建时检查: 注册产物: 执行上下文:
• validateDispatchDecl • Flags + Aliases • Str(name)
• validateSchemaDecl • Annotations (Schema) • Int(name)
• RegisterFlags • Long (约束 help) • Bool(name)
• ValidateConstraintDecls • RunE (管线) • StrSlice(name)
• embedContractIntoSchema • Changed(name)
• AnnotateConstraints • DryRun() / Yes()
• validateSafetySpec • Annotations (Schema) • Int(name)
• validateSchemaDecl • Long (约束 help) • Bool(name)
• RegisterFlags • RunE (管线) • StrSlice(name)
• ValidateConstraintDecls • Changed(name)
• embedContractIntoSchema • DryRun() / Yes()
• AnnotateConstraints
• PostMount
```
@@ -89,59 +84,33 @@ CommandSpec ──── NewCommand() ────▶ cobra.Command ────
| **CommandSpec** | struct | 一个命令的完整契约(声明 + 执行) |
| **FlagSpec** | struct | 一个参数的注册、回退链、绑定规则 |
| **Constraint** | struct | 参数间的关系约束 |
| **Risk** | enum | 运行时确认行为(是否提示用户) |
| **Safety** | enum | Schema 安全元数据等级(Agent 决策依据) |
| **Safety** | cli.SafetySpec | 运行时与 Schema 共用的安全契约 |
| **SchemaDecl** | struct | Agent 可见的完整工具规格声明 |
| **SelectionDecl** | struct | Agent 选择该工具的语义指引 |
| **InterfaceDecl** | struct | 工具的接口模式与可用性 |
| **DryRunDecl** | struct | dry-run 能力声明 |
| **SafetyDecl** | struct | Safety 枚举展开后的细粒度覆盖 |
| **IdentityDecl** | struct | 工具在注册表中的身份标识 |
| **PositionalDecl** | struct | 有序位置参数声明 |
| **Ctx** | struct | 执行上下文(类型安全的 flag 读取) |
| **NewCommand** | func | 统一构建器(声明 → cobra.Command) |
## Risk 枚举
## SafetySpec
控制**运行时确认行为**——是否在派发前提示用户确认。
`CommandSpec.Safety` 直接复用 `cli.SafetySpec`,没有 cmdcore 自定义 Risk/Safety 枚举,也没有 `SafetyDecl` 覆盖层:
| 值 | 含义 | 运行时行为 |
|----|------|-----------|
| `""` / `read` | 只读操作 | 不提示 |
| `write` | 变更状态 | 未加 `--yes` 时提示确认 |
| `high-risk-write` | 破坏性/不可逆 | 未加 `--yes` 时提示确认 |
## Safety 枚举
控制 **Schema 元数据等级**——告诉 Agent 操作对系统的影响程度。
| 值 | effect | risk | confirmation | idempotency |
|----|--------|------|--------------|-------------|
| `read` | read | low | not_required | idempotent |
| `write` | write | medium | user_required | unknown |
| `high-write` | write | high | user_required | unknown |
| `destructive` | destructive | high | user_required | unknown |
## Safety 优先级链
三层解析,先命中先生效:
```
SafetyDecl 显式字段 > Safety 枚举 > Risk.SafetyDefault()
(最高: 精确覆盖) (中: 等级快捷) (最低: 默认推导)
```go
Safety: cli.SafetySpec{
Effect: "write",
Risk: "high",
Confirmation: "user_required",
Idempotency: "unknown",
}
```
- **SafetyDecl 显式字段**:`SchemaDecl.Safety` 中非空字段直接使用
- **Safety 枚举**:`CommandSpec.Safety` 非空时按表展开填充空字段
- **Risk.SafetyDefault()**:Safety 为空时从 Risk 推导
推导映射(`Risk.SafetyDefault()`):
```
read → SafetyRead
write → SafetyWrite
high-risk-write → SafetyDestructive
```
- `Confirmation == "user_required"` 时执行确认;`--yes` 和 `--dry-run` 可跳过交互。
- `Effect`、`Risk`、`Idempotency` 不参与确认决策,也不会改写 `Confirmation`。
- 任意一个字段非空时,四个字段必须全部显式声明;构建时拒绝部分声明。
- 完全空值仅作为历史只读默认,最终发布为 `read/low/not_required/idempotent`。
## FlagSpec 有效值回退链
@@ -226,7 +195,7 @@ DryRunDecl{
|--------|----------|-------------|
| **Invoke** | 单步 MCP/后端调用 | 框架完成 required→constraint→validate→buildArgs→confirm,传入装配好的 toolArgs |
| **Orchestrate** | 多步编排 | 框架完成 required→constraint→validate→confirm,传入 Ctx 自行组装调用 |
| **RunE** | 逃生舱 | 框架只注册 flag 和 Schema,执行完全自定义 |
| **RunE** | 逃生舱 | 框架仍执行 Safety 确认,具体业务执行完全自定义 |
## 设计不变量
@@ -234,5 +203,5 @@ DryRunDecl{
2. **声明面绝不调用后端**——cmdcore 是 dispatch-agnostic
3. **执行面绝不发明 CLI 表面**——业务 flag 必须在 Flags 声明
4. **构建时拦截 > 运行时报错**——声明错误 panic 在注册阶段
5. **Safety 和 Risk 独立但可组合**——运行时行为与 Schema 元数据各管一摊
5. **SafetySpec 是单一事实源**——Confirmation 驱动运行时,其余字段原样进入 Schema
6. **声明即 review**——代码中的 Schema 经 code review 后直接投影,不依赖外部 hint 文件
+37 -36
View File
@@ -10,10 +10,10 @@
命令框架将 CLI 命令的**声明**与**执行**分离:
- **声明面** — 数据字段描述命令是什么(flag、约束、风险等级、Schema 元数据)
- **声明面** — 数据字段描述命令是什么(flag、约束、SafetySpec、Schema 元数据)
- **执行面** — 钩子函数描述命令做什么(校验、派发、编排)
框架负责:flag 注册、有效值回退链、required/约束校验、Risk 写确认、toolArgs 装配、Agent Runtime Schema 投影。
框架负责:flag 注册、有效值回退链、required/约束校验、SafetySpec 确认、toolArgs 装配、Agent Runtime Schema 投影。
## 核心类型
@@ -30,8 +30,7 @@ type CommandSpec struct {
Example string
Flags []FlagSpec
Constraints []Constraint
Risk Risk // 运行时确认行为
Safety Safety // Schema 元数据等级
Safety cli.SafetySpec // 运行时与 Schema 的单一安全来源
ConfirmFirst bool // 确认门先于参数校验
ConstParams map[string]any
Schema SchemaDecl // 完整 ToolSpec 载荷
@@ -47,32 +46,29 @@ type CommandSpec struct {
}
```
### Risk 与 Safety(独立枚举)
### SafetySpec(单一安全来源)
两个正交维度,组合使用:
`CommandSpec.Safety` 直接使用现有 Agent Runtime Schema 的 `cli.SafetySpec`:
| 枚举 | 职责 | 取值 |
|------|------|------|
| **Risk** | 运行时确认行为(是否弹 yes/no) | `read` / `write` / `high-risk-write` |
| **Safety** | Schema 元数据等级(Agent 看到的影响描述) | `read` / `write` / `high-write` / `destructive` |
| 字段 | 职责 |
|------|------|
| `Effect` | 操作影响:read / write / destructive |
| `Risk` | 风险等级:low / medium / high |
| `Confirmation` | 是否需要用户确认:not_required / user_required |
| `Idempotency` | 幂等性:idempotent / retryable / non_idempotent / unknown |
Safety 为空时,通过 `Risk.SafetyDefault()` 方法推导:
| Risk | → Safety 默认 | Schema 展开 |
|------|--------------|-------------|
| (空) / `read` | `read` | effect:read, risk:low, confirmation:not_required, idempotency:idempotent |
| `write` | `write` | effect:write, risk:medium, confirmation:user_required, idempotency:unknown |
| `high-risk-write` | `destructive` | effect:destructive, risk:high, confirmation:user_required, idempotency:unknown |
显式设置 Safety 可以覆盖默认推导:
四个字段彼此独立。框架只读取 `Confirmation` 决定运行时确认,其余字段原样发布到 Schema,不从一个字段机械推导另一个。非空 SafetySpec 必须一次声明完整:
```go
// create:运行时 high-risk 确认,但 Schema 标记为 high-write(不可逆但非破坏性)
Risk: LeafRiskHighWrite,
Safety: LeafSafetyHighWrite, // write/high 而非 destructive/high
Safety: cli.SafetySpec{
Effect: "write",
Risk: "high",
Confirmation: "user_required",
Idempotency: "unknown",
},
```
优先级链:`explicit SafetyDecl fields > Safety enum > Risk.SafetyDefault()`
完全空值保留历史只读默认 `read/low/not_required/idempotent`;不存在 Risk/Safety 枚举或覆盖优先级链。
### FlagSpec
@@ -123,19 +119,20 @@ flag 解析按以下顺序取值(先命中先生效):
`cmdcore.NewCommand(spec)` 执行以下构建时检查(失败则 panic):
1. **validateDispatchDecl** — 恰好一个执行体(Invoke/Orchestrate/RunE)
2. **validateSchemaDecl** — Schema 声明完整性(Description、AgentSummary、UseWhen、AvoidWhen、Examples、Safety、Interface)
3. **RegisterFlags** — flag + alias 注册到 cobra
4. **ValidateConstraintDecls** — 约束引用的 flag 必须存在
5. **embedContractIntoSchema** — 投影到 dws.schema.* annotations
6. **AnnotateConstraints** — 约束渲染到 --help
7. **PostMount** — 调用方的挂载收尾钩子
2. **validateSafetySpec** — 非空 SafetySpec 的四个独立字段必须完整
3. **validateSchemaDecl** — Schema 声明完整性(Description、AgentSummary、UseWhen、AvoidWhen、Examples、Interface)
4. **RegisterFlags** — flag + alias 注册到 cobra
5. **ValidateConstraintDecls** — 约束引用的 flag 必须存在
6. **embedContractIntoSchema** — 投影到 dws.schema.* annotations
7. **AnnotateConstraints** — 约束渲染到 --help
8. **PostMount** — 调用方的挂载收尾钩子
## 运行时流程
生成的 `RunE` 按以下顺序执行:
```
[ConfirmFirst? → ConfirmRisk] ← devapp 遗留语义:先确认后校验
[ConfirmFirst? → ConfirmSafety] ← 可选:先确认后校验
│
▼
ValidateRequired ← 有效值回退链校验
@@ -153,7 +150,7 @@ BuildArgs ← flag → toolArgs 装配
ConstParams 合并
│
▼
[!ConfirmFirst? → ConfirmRisk] ← 默认顺序:校验后确认
[!ConfirmFirst? → ConfirmSafety] ← 默认顺序:校验后确认
│
▼
Invoke(ctx, toolArgs) ← 单步派发
@@ -170,8 +167,10 @@ func newDevAppCreateCommand(runner executor.Runner) *cobra.Command {
Use: "create",
Short: "创建开放平台企业内部应用",
Tool: devAppCreateTool,
Risk: LeafRiskHighWrite,
Safety: LeafSafetyHighWrite,
Safety: cli.SafetySpec{
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
},
ConfirmFirst: true,
Flags: []LeafFlag{
{Name: "name", Usage: "应用名称 (必填)", Bind: "name",
@@ -208,6 +207,8 @@ spec := FromShortcut(Shortcut{
})
```
Shortcut 本次仍保留自身的 `Risk`。未接入 live mount 的 adapter 只在边界将它展开成完整 `cli.SafetySpec`;cmdcore/Leaf 不再保留该枚举。
## 文件结构
| 文件 | 职责 |
@@ -230,8 +231,8 @@ spec := FromShortcut(Shortcut{
## 设计原则
1. **声明 vs 执行分离** — Flags/Constraints/Risk/Safety/Schema 是声明;Invoke/Validate/PostMount 是执行
1. **声明 vs 执行分离** — Flags/Constraints/Safety/Schema 是声明;Invoke/Validate/PostMount 是执行
2. **单一数据源** — 一份声明驱动 --help、Schema、catalog、runtime 校验
3. **框架推导 > 手写** — Safety 从 Risk 推导、Example 从 Schema.Selection.Examples 继承
3. **安全字段不互推** — Confirmation 单独驱动确认,Effect/Risk/Idempotency 原样发布
4. **构建时拦截 > 运行时报错** — 声明不完整在命令注册时 panic,不等到用户触发
5. **向后兼容** — type alias 让现有 LeafSpec 调用方零改动
5. **边界兼容** — Shortcut 暂由 adapter 转换,Leaf 直接声明 SafetySpec
+11 -10
View File
@@ -20,18 +20,17 @@
```
CommandSpec (声明) → NewCommand() → cobra.Command
│
├── Risk enum (运行时确认)
├── Safety enum (Schema 元数据)
├── cli.SafetySpec (运行时 + Schema 单一安全来源)
├── FlagSpec[] (参数 + 回退链 + 绑定)
├── Constraint[] (互斥/至少一个)
├── SchemaDecl (Agent Selection/Safety/DryRun/Interface)
├── SchemaDecl (Agent Selection/DryRun/Interface)
│
└── Invoke / Orchestrate / RunE (执行)
```
**核心特点**:
- 声明与执行严格分离
- Risk(运行时)和 Safety(Schema)独立枚举
- SafetySpec 四个独立字段直接对齐 Agent Runtime Schema
- 有效值回退链:flag → alias → env → default
- 框架统一校验、装配、确认、投影
- Schema 从代码声明直接投影,无外部 hint 文件
@@ -82,7 +81,7 @@ API Discovery → 代码生成 → surface command
|------|-------------|----------|-----|
| 参数别名 + 环境变量回退 | ✅ FlagSpec.Aliases + EnvVar | ❌ 无 | ❌ 无 |
| 声明式约束 (互斥/至少一个) | ✅ Constraint[] | ❌ 只有 Validate hook | ✅ argparse group |
| Risk/Safety 分离 | ✅ 两个独立枚举 | ❌ 只有 Risk | ❌ 无 |
| 安全契约 | ✅ SafetySpec(effect/risk/confirmation/idempotency) | Risk | 无 |
| Schema 投影 (Agent metadata) | ✅ SchemaDecl 内建 | ⚠️ 运行时 introspection | ❌ 外挂 |
| 参数绑定 (flag name → API key) | ✅ FlagSpec.Bind | ❌ 手写 | ✅ 自动映射 |
| ConstParams (固定载荷) | ✅ | ❌ 手写在 Execute | ✅ 隐式 |
@@ -104,7 +103,7 @@ API Discovery → 代码生成 → surface command
|------|-------------|----------|-----|
| 工具发现 | `dws schema --all` (静态 catalog) | `--print-schema` (运行时) | API Discovery |
| 选择指引 | SelectionDecl (UseWhen/AvoidWhen) | Description + Tips | 无 |
| 安全声明 | Safety enum + SafetyDecl | Risk string | 无 |
| 安全声明 | cli.SafetySpec 直接声明 | Risk string | 无 |
| dry-run 能力声明 | DryRunDecl (reviewed) | DryRun hook 存在性 | 无 |
| 接口模式 | InterfaceDecl (local/pinned/composite) | 隐式 (全部 REST) | 隐式 (全部 REST) |
@@ -128,7 +127,7 @@ GWS: API Discovery JSON → 代码生成 → surface command
| 选择 | 理由 | 对比 |
|------|------|------|
| 框架装配参数 | 消除 N 个命令各写一份 toolArgs 装配 | lark-cli 每个 Execute 手动取 flag 值 |
| Risk + Safety 分离 | 运行时行为与 Schema 元数据独立演进 | lark-cli 只有 Risk 一个维度 |
| SafetySpec 单一来源 | confirmation 驱动运行时,其余字段原样发布且互不推导 | lark-cli 只有 Risk 一个维度 |
| 声明式约束 | 构建时校验合法性 + 投影到 Schema + 渲染帮助 | lark-cli 约束隐藏在 Validate 逻辑里 |
| Schema 构建时投影 | 离线 catalog 支持 Agent 批量发现 | lark-cli 需要逐个命令 introspection |
| 有效值回退链 | flag → alias → env 统一语义 | lark-cli 别名是独立 Flag 手动关联 |
@@ -170,8 +169,10 @@ NewLeafCommand(LeafSpec{
Use: "create",
Short: "创建应用",
Tool: "create_dev_app",
Risk: LeafRiskHighWrite,
Safety: LeafSafetyHighWrite,
Safety: cli.SafetySpec{
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
},
ConfirmFirst: true,
Flags: []LeafFlag{
{Name: "name", Usage: "应用名称", Bind: "name",
@@ -228,7 +229,7 @@ var CalendarCreate = common.Shortcut{
| 场景 | 最适合 | 原因 |
|------|--------|------|
| MCP 后端 + Agent Schema 投影 | **DWS cmdcore** | 内建 Schema 声明、Safety 分级、离线 catalog |
| MCP 后端 + Agent Schema 投影 | **DWS cmdcore** | 内建 Schema 声明、SafetySpec 契约、离线 catalog |
| REST API 直连 + OAuth scope 管理 | **lark-cli** | CallAPITyped + scope 预检 + DryRun API 计划 |
| API-first 大规模 surface 生成 | **GWS gcloud** | Discovery 驱动,一份 Schema 生成一切 |
| 多步编排 (跨服务链式调用) | **lark-cli** / DWS Orchestrate | lark 的 CallAPITyped 链式 + DWS 的 Orchestrate |
+28 -22
View File
@@ -12,7 +12,7 @@
1. cobra 实际注册的 flags / required / defaults;
2. `--help` 的 Flags 与「参数约束」段;
3. **嵌入后的** `dws schema` / Catalog(`schema_catalog` / agent metadata 生成物)中的 parameters、关系约束,以及显式 `Risk` 时的 confirmation/effect。
3. **嵌入后的** `dws schema` / Catalog(`schema_catalog` / agent metadata 生成物)中的 parameters、关系约束和 SafetySpec。
嵌入机制(已落地):
@@ -23,12 +23,12 @@ CommandSpec
dws.schema.contract=cmdcore
dws.schema.property / type / required (per flag)
dws.schema.constraints
dws.schema.risk (仅当 Risk 字段非空)
→ Schema 组装读取 native_annotation + Contract Risk overlay
→ RegisterRuntimeContractFinal(SafetySpec + SchemaDecl)
→ Schema 组装透传 Contract Final
→ go:embed schema_catalog / schema_agent_metadata
```
空 `Risk` **不**写入 `dws.schema.risk`,避免把默读盖掉今日 write-guard 叶子的 reviewed Safety。
cmdcore/Leaf 不再写 `dws.schema.risk`;SafetySpec 走类型化 Final 载荷,不使用字符串枚举注解。
### 1.1 硬规则:声明 = 最终数据源(Schema 透传)
@@ -38,7 +38,7 @@ CommandSpec
- 在声明体系里再挂「评审字段」并行权威;
- 用 hints/registry 盖写已声明字段。
迁移期未迁完的叶子可暂走旧组装路径;**新声明面不含 review_reason / reviewed 字段**。写命令未设 `Risk`/`Schema.Safety` 时,过渡期仍可用 `runtime_gate` annotate(`HOM-S2`)。
迁移期未迁完的叶子可暂走旧组装路径;**新声明面不含 review_reason / reviewed 字段**。写命令未设 `Safety` 时,过渡期仍可用 `runtime_gate` annotate(`HOM-S2`)。
**不采用**路径 B(以 `schema_mcp_metadata` 生成全部 CLI flag/help/schema)作为主权威。钉钉 MCP meta 不是飞书 OAPI:粒度与 CLI 特有语义(二选一、OmitEmpty、ConstParams、write guard)无法从裸 meta 推出;强行生成会违反「Schema 描述 CLI,不制造 CLI」。
@@ -68,7 +68,7 @@ CommandSpec
|---|---|---|---|
| `Flags[]`(`FlagSpec` / `LeafFlag`) | 用户可见参数面:名、类型、默认、必填、usage | 注册 cobra flag;装配 toolArgs | `dws.schema.property` / `type` / `required`;`--help` Flags |
| `Constraints[]` | 跨 flag 关系:`at_least_one` / `exactly_one` / `mutually_exclusive` | `ValidateConstraints` | `dws.schema.constraints`;`--help`「参数约束」 |
| `Risk`(**非空**) | 副作用与确认:`write` / `high-risk-write`(或显式 `read`) | `ConfirmRisk`(`--yes` / `--dry-run` 跳过) | `dws.schema.risk` → Safety overlay(`HOM-S1`) |
| `Safety`(`cli.SafetySpec`) | effect/risk/confirmation/idempotency 四个独立事实 | `confirmation=user_required` 时 `ConfirmSafety`;`--yes` / `--dry-run` 跳过 | 同一个 SafetySpec 原样进入 Contract Final(`HOM-S1`) |
| `ConstParams` | 固定载荷(不上 flag 表) | 并入 toolArgs;不满足 Required | **不**投影为用户 parameter |
| `Use` / `Short` / `Long` / `Example` | 命令身份文案与示例 | cobra 自身 | help;identity 仍以 registry 为准 |
@@ -88,30 +88,37 @@ CommandSpec
| `Validate` | 条件式/领域校验钩子 | 不得在此 `Flags().String(...)` 注册业务 flag;不得只靠钩子表达「必填/互斥」而不写 `Flags`/`Constraints` |
| `Call` / `Invoke` / `Orchestrate` | 执行体 | 不得 `params[k]=…` 装配业务参数(应在 `Flags`/`ConstParams`) |
| `PostMount` | 挂载后收尾(annotate、领域工具注入) | 不得注册业务 flag;分页等横切由领域工具注入并可走 annotate |
| `RunE` | 逃生舱(整段手写) | 表面事实仍须 Flags 声明,或对确认语义做 §1.3 标注 |
| `RunE` | 逃生舱(整段手写) | 表面事实仍须 Flags 声明;框架仍按 Safety 执行确认 |
| `Server` / `Tool` | MCP 路由 | 不构成 CLI parameter 声明 |
#### 1.2.3 最小声明示例
```go
// 读:无确认语义 → Risk 可空(不写 dws.schema.risk)
// 读:Safety 四字段显式对齐 Schema
NewLeafCommand(LeafSpec{
Use: "get", Short: "…", Tool: "…",
Safety: cli.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Flags: []LeafFlag{
{Name: "unified-app-id", Usage: "…", Bind: "unifiedAppId", Trim: true, Required: true},
},
Call: devAppCall(runner), PostMount: devAppMeta(tool),
})
// 写(升格 Risk = 声明确认):ConfirmRisk + Schema Safety 同源
// 写:同一个 SafetySpec 同时驱动确认与 Schema
NewLeafCommand(LeafSpec{
Use: "publish", Short: "…", Tool: "…",
Flags: []LeafFlag{ /* … */ },
Risk: LeafRiskWrite, // 声明:非空 → dws.schema.risk=write
Safety: cli.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Call: devAppCall(runner), PostMount: devAppMeta(tool),
})
// 写(今日未升格 Risk):确认走 annotate,见 §1.3 —— 不是「声明了写」
// 迁移期旧写命令:确认走 annotate,见 §1.3 —— 不是新声明面
NewLeafCommand(LeafSpec{
Use: "create", /* Flags… */,
Validate: func(cmd *cobra.Command, _ []string) error {
@@ -122,7 +129,7 @@ NewLeafCommand(LeafSpec{
})
```
**空 `Risk` 的含义**:运行时等同只读确认(不提示),且 **不**嵌入 `dws.schema.risk`。因此「会改状态却留空 Risk」**不是**合法声明;必须要么写非空 `Risk`,要么按 §1.3 标注 gate。
**空 `Safety` 的含义**:cmdcore 为兼容旧只读叶保留 `read/low/not_required/idempotent` 默认。因此「会改状态却留空 Safety」**不是**合法声明;新 Leaf 必须写完整 SafetySpec,未迁移旧路径则按 §1.3 标注 gate。
### 1.3 人工标注(annotate):声明的补充通道
@@ -130,10 +137,10 @@ NewLeafCommand(LeafSpec{
| 标注手段 | 典型值 | 何时用 |
|---|---|---|
| `cli.AnnotateRuntimeGate` / `devAppMetaWrite` | `dws.schema.runtime_gate=devAppRequireWriteGuard` | 写命令走 write-guard、尚未 `Risk` 升格(`HOM-S2`) |
| `cli.AnnotateRuntimeRisk` | `dws.schema.risk=…` | 非 Leaf 构建路径需手写嵌入 Risk(Leaf 应优先字段声明) |
| `cli.AnnotateRuntimeGate` / `devAppMetaWrite` | `dws.schema.runtime_gate=devAppRequireWriteGuard` | 尚未迁移到 SafetySpec 的旧写命令(`HOM-S2`) |
| `cli.AnnotateRuntimeRisk` | `dws.schema.risk=…` | Shortcut 暂存的旧兼容路径;cmdcore/Leaf 禁止新增 |
| `cli.AnnotateRuntimeFlag` / Constraints | 与 embed 同形 | 手写 cobra 叶补齐表面(长期应迁入 Contract) |
| reviewed `schema_hints/metadata` Safety | effect/risk/confirmation | 迁移期遗留;受管命令收敛后让位于 Risk/gate |
| reviewed `schema_hints/metadata` Safety | effect/risk/confirmation | 迁移期遗留;受管命令收敛后让位于 Safety/gate |
标注与声明冲突时:**Contract 声明胜**(路径 A)。标注不得发明未注册的 CLI flag。
@@ -149,8 +156,7 @@ NewLeafCommand(LeafSpec{
| Parameters.`interface_*` | 评审源 | MCP meta / bindings;**不造 flag** |
| Constraints | **声明** | `Constraints` |
| Positionals | **声明** 或显式 annotate | 目标 `Args`;禁止推断 |
| Safety.`effect/risk/confirmation` | **声明**非空 `Risk` **或** `runtime_gate` 标注(或迁移期 reviewed Safety) | 空 Risk 不嵌入 |
| Safety.`idempotency` | 评审源 | hints metadata |
| Safety.`effect/risk/confirmation/idempotency` | **声明**完整 `Safety`,或迁移期 `runtime_gate` / reviewed Safety | 四字段独立;不得互相推导 |
| DryRun | 评审源 | dry-run capabilities registry |
| Interface | 评审源 | MCP + agent metadata |
| Selection | 评审源 | `schema_hints/selection` |
@@ -169,7 +175,7 @@ NewLeafCommand(LeafSpec{
| use_when / avoid_when / examples / agent_summary 文案 | `schema_hints/selection` | Schema selection |
| RPC tool 形状、`interface_ref`、interface 描述 | `schema_mcp_metadata` + `schema_parameter_bindings` | Schema `interface_*` 字段;**不得创建 flag** |
| 参数描述 overlay(可选) | `schema_hints/metadata` 仅补充 usage 文案 | 与 Contract/cobra 冲突时 **Contract/cobra 胜** |
| 遗留 Safety 文案(迁移期) | 今日仍可读 `schema_hints/metadata` safety | 受管命令收敛后以 Contract.Risk / runtime_gate 为准(见 §4) |
| 遗留 Safety 文案(迁移期) | 今日仍可读 `schema_hints/metadata` safety | 受管命令收敛后以 Contract.Safety / runtime_gate 为准(见 §4) |
| dry-run 正能力 | reviewed dry-run registry | Schema `dry_run` |
| positionals | Contract Args / 显式 annotate | Schema `positionals` |
| FieldProvenance | 组装派生 | Schema provenance(与值一致) |
@@ -180,9 +186,9 @@ Identity 与 selection **刻意不**由 Contract 取代(RFC 决策 8 / schema
已具备:
- Flags / ConstParams / Constraints → 注册、校验、`ConstraintHelp`、运行时 `ConfirmRisk`(cmdcore);
- Flags / ConstParams / Constraints → 注册、校验与 `ConstraintHelp`;SafetySpec → 运行时 `ConfirmSafety`(cmdcore);
- Call / Execute 作为执行体;业务参数不得在 Call 内装配(helpers 门禁);
- **Contract → Schema 注解嵌入**(`embedContractIntoSchema`):`dws.schema.contract` / property / type / required / constraints;显式 Risk → `dws.schema.risk` 并在 Schema 组装时 overlay Safety。
- **Contract → Schema 嵌入**:参数/约束写原生 annotation,SafetySpec 与 SchemaDecl 注册为类型化 Contract Final 并由 Schema 组装透传。
仍缺:
@@ -192,8 +198,8 @@ Identity 与 selection **刻意不**由 Contract 取代(RFC 决策 8 / schema
已落地(写命令确认语义,`HOM-S2` 起点):
- `AnnotateRuntimeGate` / `dws.schema.runtime_gate`;Leaf `PostMount: devAppMetaWrite`;手写 delete/robot 等同路径显式标注;
- Schema 组装在无 Contract Risk 但有 gate 时 overlay `confirmation=user_required`(`applyContractGateToSafety`);
- AST/mount 测试:Validate 含 `devAppRequireWriteGuard` ⇒ PostMount 必须 `devAppMetaWrite`;挂载叶须 Risk 或 runtime_gate。
- Schema 组装在无 Contract Safety 但有 gate 时 overlay `confirmation=user_required`(`applyContractGateToSafety`);
- AST/mount 测试:新 Leaf 须声明完整 SafetySpec;尚未迁移的旧路径须有 runtime_gate。
## 4. Schema 投影与 Safety 门禁规划
+8 -8
View File
@@ -59,7 +59,7 @@ v4 的变更,全部来自第一轮独立评审并经过代码级验证:
13. 每一次受管的后端/输出副作用都必须由调用级许可(permit)把关;不要把裸输入流或裸后端 helper 暴露给受管 Handler。
14. 保持遗留行为 bug-for-bug 兼容,**除非**该行为本身是正确性缺陷。§3.4 的非交互确认要在差分门禁能够要求它之前修好,因为兼容门禁会保护它所度量的一切。
15. 把「没有可用的交互输入」当作一种独立的确认结果,与接受和拒绝都不同。
16. **flag / help / schema 参数面同源取路径 A,且 Contract 必须嵌入 Schema**:Contract(LeafSpec 门面)经 `cmdcore.NewCommand` 写入 cobra Schema 注解,供 catalog/agent metadata 组装消费;MCP meta 不得创建 CLI flag;1:1 MCP 透传仅作可选子集。**硬规则**:每份 help/Schema 事实须 **声明 OR 人工标注**,禁止纯推断。**声明** = `LeafSpec`/`CommandSpec` 数据字段(`Flags`/`Constraints`/非空 `Risk`/…),钩子不算声明;未设 `Risk` 的写命令须 `runtime_gate` 标注。见 [`flag-help-schema-homology.md`](flag-help-schema-homology.md) §1.1–§1.3。
16. **flag / help / schema 参数面同源取路径 A,且 Contract 必须嵌入 Schema**:Contract(LeafSpec 门面)经 `cmdcore.NewCommand` 写入 cobra Schema 注解并注册类型化 Final,供 catalog/agent metadata 组装消费;MCP meta 不得创建 CLI flag;1:1 MCP 透传仅作可选子集。**硬规则**:每份 help/Schema 事实须 **声明 OR 人工标注**,禁止纯推断。**声明** = `LeafSpec`/`CommandSpec` 数据字段(`Flags`/`Constraints`/`Safety`/…),钩子不算声明;未设完整 SafetySpec 的旧写命令须 `runtime_gate` 标注。见 [`flag-help-schema-homology.md`](flag-help-schema-homology.md) §1.1–§1.3。
16. 让 dry-run 输出与 `@file`/stdin 输入成为声明式的契约能力,而不是逐 handler 的代码,这样覆盖率就是框架的属性,而不依赖作者自觉。
17. 从 M1 起要求新命令使用新核心,使框架在 Shortcut 分批迁移之前就已改善代码库。
18. 把 LeafSpec 已上线的 `Bind → Call → callMCPTool` 路径视为「声明可以完成执行」的存在性证明:27 个源声明 / 28 条上线命令,生产环境 `RunE` 用量为零。Shortcut 应当获得同样的绑定词汇,而不是把每个 `Execute` 都包成 Handler。
@@ -156,7 +156,7 @@ return answer == "yes" || answer == "y"
影响范围与归类:
- 全部 168 个 write/high-risk Shortcut 受影响;read 命令从不提示。
- `cmdcore.ConfirmRisk` 已经读取 `cmd.InOrStdin()` 而非进程 stdin,因此它是可测试的;但它套用同一条「EOF 等于拒绝」的规则,所以一旦非交互父进程关闭输入,它受影响的程度完全相同。
- `cmdcore.ConfirmSafety` 读取 `cmd.InOrStdin()` 而非进程 stdin,因此它是可测试的;EOF 会显式返回 `confirmation_required`,不会把未执行误报为成功。
- 这**不是** §5.9 的拒绝策略问题。那个问题是「真实拒绝应当产生哪个退出码」。这里没有任何人拒绝过:拒绝分支在 CLI 最常被使用的那种环境里默认就可达。
- 由于这是用户可见的行为变更,它走 §9 的 approved delta 流程。该流程不依赖 Contract、解析器或 mcpbind,因此这项修复不受新核心的进度约束。
- **因此它作为独立 hotfix(§8 中的 H0)先于本 RFC 的实现序列发布,而不是排在架构工作的第四个 PR。** 这是一个已上线的数据完整性级缺陷:168 条写命令在 agent/CI 环境下静默丢弃写操作并报告成功。把它排在一套架构改造之后,等于让缺陷的存续时间取决于一个与它无关的项目的进度。本 RFC 记录它、给出修复形态与门禁,但**不持有它的排期**;即使本 RFC 被否决或推迟,H0 也应照发。
@@ -304,13 +304,13 @@ Definition(仅声明;不可编译)
1. 业务 flag 只出现在 `Flags`(或领域工具注入 + 同源允许的 annotate),不在 `PostMount`/`Validate` 里 `Flags().String`;
2. 业务参数只由 `Flags`/`ConstParams` 装配,不在 `Call`/`Execute` 里 `params[k]=…`;
3. 写副作用:要么非空 `Risk`(框架 `ConfirmRisk` + Schema overlay),要么显式 `runtime_gate` 标注(今日 write-guard);二者皆无则不合格;
3. 写副作用:新 Leaf 声明完整 `SafetySpec`(框架 `ConfirmSafety` + Schema Final);未迁移旧路径显式标注 `runtime_gate`;二者皆无则不合格;
4. Schema `ToolSpec` 全字段组均落在 §5.0.4 表中某一权威格,禁止无主字段。
#### 5.0.3 与目标 `Contract` 的对应
```text
今日 LeafSpec / CommandSpec.Flags|Constraints|Risk|…
今日 LeafSpec / CommandSpec.Flags|Constraints|Safety|…
≈ 目标 Definition.Contract
今日 Call / Invoke / Orchestrate / RunE
≈ 目标 mcpbind 派发 或 Handler(形态 1/2/3)
@@ -335,7 +335,7 @@ Definition(仅声明;不可编译)
| | `interface_description`, `interface_type`, `interface_default` | 评审源(interface) | `schema_mcp_metadata` + bindings | 否;**不得创建 CLI flag**(`HOM-I1`) |
| **Constraints** | `require_one_of`, `mutually_exclusive`, `require_together` | **声明** | `Constraints` → `AnnotateConstraints` | **是** |
| **Positionals** | 位置参数名/必填/说明 | **声明** 或显式 annotate | 目标 `Args`/`PositionalSpec`;今日少量 cobra Args + 注解 | 受管命令应声明,禁止推断 |
| **Safety** | `effect`, `risk`, `confirmation` | **声明**(非空 `Risk`)**或标注**(`runtime_gate`)或迁移期 reviewed Safety | `Risk` / `AnnotateRuntimeGate` / `schema_hints/metadata` safety | 写路径必须 declare 或 annotate;空 Risk 不嵌入 |
| **Safety** | `effect`, `risk`, `confirmation`, `idempotency` | **声明**(完整 `cli.SafetySpec`)**或标注**(`runtime_gate`)或迁移期 reviewed Safety | `Safety` / `AnnotateRuntimeGate` / `schema_hints/metadata` safety | 四字段独立;confirmation 单独驱动运行时 |
| | `idempotency` | 评审源(或未来 Contract) | reviewed metadata | 今日非框架声明;不得推断 |
| | `effect_source` / provenance | 组装派生物 | resolver 写入 `FieldProvenance` | 派生,不手写 |
| **DryRun** | `preview_kind`, `remote_reads` | 评审源 | `schema_dry_run_capabilities`(正能力声明) | 否;无条目 ≠ 推断「不支持」之外的假能力 |
@@ -350,7 +350,7 @@ Definition(仅声明;不可编译)
冲突规则(路径 A):
- parameters 的 type / required / default / name 集合:**Contract/cobra 胜** hints 与 MCP;
- Safety 的 confirmation/effect:**`Schema.Safety`(Final 声明)胜**;否则非空 Contract `Risk` 胜;再否则 `runtime_gate` 胜「假装 not_required」;最末迁移期 reviewed Safety。声明 Safety 存在时 gate 标注被忽略(声明 > 标注,§5.10);
- Safety 的 effect/risk/confirmation/idempotency:完整 Contract `Safety`(Final 声明)胜;否则 `runtime_gate` 可把旧路径的 confirmation 升为 user_required;最末迁移期 reviewed Safety。Safety 各字段之间不得互推(声明 > 标注,§5.10);
- Identity:**绑定 entry 胜**——Contract 声明必须与之一致,不一致组装报错,不得改写 canonical path;Selection 已声明则声明胜;Interface 不得发明 RPC。
### 5.1 Definition、Contract 与 Handler
@@ -980,7 +980,7 @@ func WithProjector(ResponseProjector) BindOption
- 编码通过 `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 冲突以及不兼容的默认值。
- `Bind` 拒绝 nil/typed-nil 的 Caller 或 ResultSink、未知键、重复后端键、类型不匹配、非法 include/effect 模式、Contract Safety.Effect/后端 effect 冲突以及不兼容的默认值。
- 绑定选项有不透明内部,且对内置全函数编码器与类型化载荷默认值仅有封闭、具名的构造函数。没有公开的 `func(string) (any, error)` 转换钩子。可失败的用户输入转换属于 Resolve 或 Validator,因此在 Risk 之前完成。
- 「全函数」指编码器对任意 T 值都不返回用户错误、也不 panic;内置编码器对其输入域有穷尽测试。
- 在成功的 Bind、cmdcore Compile 与 Resolve 之后,在派发器内物化载荷不能产生用户校验错误。它仍可能浮现内部不变量失败、后端失败或输出错误。
@@ -1146,7 +1146,7 @@ cmd.RunE = func(cmd *cobra.Command, args []string) error {
| 非 Shortcut 受管定义的 Cobra 命令 Hidden | 可执行 Contract | 挂载的命令可见性与声明匹配 |
| Shortcut 列表成员资格与语义 disposition | 经评审的 Shortcut 可见性解析器 | public/all 列表成员资格与经评审决策匹配 |
| Runtime Schema / Agent 暴露 | 经评审的 CommandRegistry 加精确排除 | 每个暴露叶子解析到活 Contract;排除显式且不重叠 |
| 运行时 Risk 与确认 | 可执行 Contract(非空 `Risk`)或显式 annotate(如 `runtime_gate`);见 §5.0 | 不得靠推断;空 Risk 不嵌入 schema risk;写命令须 Risk 或 gate;Safety 组装优先级 **`Schema.Safety`(Final 显式字段)> `CommandSpec.Safety` 枚举 tier > `Risk.SafetyDefault()` > `runtime_gate`**,高优先命中即忽略低优先;tier 填充含 idempotency(读 idempotent / 写 unknown),故仅声明 Risk/Safety 枚举即自足。**边界**:tier 恒可推导意味着写命令忘声明 `Risk` 会静默发布 read tier(且失去运行时确认)——写命令必须声明 `Risk`;`ConfirmFirst` 无 `Risk` 属 authoring 错误,构造期 panic |
| Safety 与运行时确认 | 可执行 Contract 的完整 `cli.SafetySpec`,或迁移期显式 annotate(如 `runtime_gate`);见 §5.0 | `confirmation` 单独驱动运行时门,`effect` / `risk` / `idempotency` 原样发布,禁止跨字段机械推导;任一 Safety 字段非空时四字段必须齐全,否则构造期 panic;`ConfirmFirst` 只在 `confirmation=user_required` 时合法 |
| 后端 product/tool/载荷绑定 | mcpbind + 后端元数据 | 每个绑定引用真实的 flag/属性 |
| Agent 选择文案(`use_when`、`avoid_when`、摘要) | 经评审的 hints/catalog | 身份解析到活契约 |
+6 -1
View File
@@ -388,8 +388,13 @@ func RuntimeContractGate(cmd *cobra.Command) (string, bool) {
}
// HasDeclaredOrAnnotatedConfirmation reports whether confirmation semantics are
// covered by Contract Risk or an explicit runtime_gate annotation.
// covered by a typed Contract SafetySpec, the legacy Shortcut Risk bridge, or
// an explicit runtime_gate annotation.
func HasDeclaredOrAnnotatedConfirmation(cmd *cobra.Command) bool {
if final, ok := RuntimeContractFinal(cmd); ok && final.Safety != nil &&
strings.TrimSpace(final.Safety.Confirmation) != "" {
return true
}
if _, ok := RuntimeContractRisk(cmd); ok {
return true
}
+101 -173
View File
@@ -14,12 +14,12 @@
// Package cmdcore is the shared, dispatch-agnostic base for building leaf
// commands. It concentrates flag registration, the alias/env/default effective
// value fallback chain, required validation, cross-flag constraint declaration
// checks + runtime enforcement, Risk-driven write confirmation, toolArgs
// checks + runtime enforcement, SafetySpec-driven confirmation, toolArgs
// assembly, and Agent Runtime Schema projection.
//
// Declaration vs execution (framework rule):
//
// - Declare = CommandSpec data fields (Flags, Constraints, non-empty Risk,
// - Declare = CommandSpec data fields (Flags, Constraints, Safety,
// ConstParams, Use/Short/Long/Example). NewCommand registers, validates,
// confirms, and embeds those facts into dws.schema.*.
// - Execute = Validate / Invoke / Orchestrate / RunE / PostMount. Hooks
@@ -36,12 +36,12 @@
// LeafSpec framework (internal/helpers) and, later, the Shortcut framework wrap
// these primitives and supply their own dispatch (single-step MCP / multi-step
// orchestration / escape hatch). Extracting the primitives here lets both
// frameworks share one flag + constraint + risk + schema base, differing only
// frameworks share one flag + constraint + safety + schema base, differing only
// in how they dispatch — the first step toward a single typed command registry.
//
// Behavioral contract: this package is a pure extraction of the logic that
// previously lived in internal/helpers/leaf.go, so flag registration, value
// fallback, required/constraint semantics, risk wording, and schema projection
// fallback, required/constraint semantics, confirmation behavior, and schema projection
// stay semantically identical. The evidence is split: check-generated-drift
// (catalog unchanged) proves only the build-time projection — identity, flags,
// help and annotations — while the runtime pipeline (required validation,
@@ -135,65 +135,6 @@ type FlagSpec struct {
SchemaDescription string // Schema description; empty uses Usage
}
// Risk declares a leaf command's side-effect level, driving pre-dispatch write
// confirmation. Values match the shortcut framework's Risk verbatim.
type Risk string
const (
// RiskRead is a read-only operation; never prompts. Empty value == RiskRead.
RiskRead Risk = "read"
// RiskWrite mutates state; prompts unless --yes.
RiskWrite Risk = "write"
// RiskHighWrite is a destructive/irreversible operation; prompts unless --yes.
RiskHighWrite Risk = "high-risk-write"
)
// Effective returns the effective risk, defaulting an empty value to read-only.
func (r Risk) Effective() Risk {
if r == "" {
return RiskRead
}
return r
}
// SafetyDefault returns the Safety tier this Risk level implies when no
// explicit Safety is declared. It is the composition seam: Risk drives
// runtime confirmation, Safety drives Schema metadata, and SafetyDefault
// bridges the two when only Risk is declared.
func (r Risk) SafetyDefault() Safety {
switch r.Effective() {
case RiskWrite:
return SafetyWrite
case RiskHighWrite:
return SafetyDestructive
default:
return SafetyRead
}
}
// Safety declares the Schema safety metadata tier, projected into
// agent-facing Schema independently of the runtime Risk. Empty == SafetyRead.
type Safety string
const (
// SafetyRead is a read-only operation: read/low/not_required/idempotent.
SafetyRead Safety = "read"
// SafetyWrite is a reversible mutation: write/medium/user_required/unknown.
SafetyWrite Safety = "write"
// SafetyHighWrite is a hard-to-reverse mutation: write/high/user_required/unknown.
SafetyHighWrite Safety = "high-write"
// SafetyDestructive is a destructive operation: destructive/high/user_required/unknown.
SafetyDestructive Safety = "destructive"
)
// Effective returns the effective safety tier, defaulting empty to read.
func (s Safety) Effective() Safety {
if s == "" {
return SafetyRead
}
return s
}
// ConstraintKind is the type of a cross-flag relationship constraint. Values
// match the shortcut framework's ConstraintKind verbatim.
type ConstraintKind string
@@ -226,20 +167,20 @@ type Constraint struct {
//
// Declaration surface is the final Schema data source for managed leaves:
//
// Flags (+ parameter Schema fields), Constraints, Risk, ConstParams,
// Flags (+ parameter Schema fields), Constraints, Safety, ConstParams,
// Use/Short/Long/Example, Schema (ToolSpec groups)
//
// Schema assembly pass-throughs embedded dws.schema.* — no reviewed/hints
// parallel authority for declared fields. Empty Risk means runtime read
// confirmation (no prompt) and does NOT embed dws.schema.risk unless
// Schema.Safety supplies risk/confirmation.
// parallel authority for declared fields. Safety uses cli.SafetySpec directly:
// confirmation drives the runtime gate, while effect/risk/idempotency are
// published unchanged. No safety field is inferred from another.
//
// Execution surface (hooks — not declaration):
//
// - RunE — full escape hatch: the framework only registers flags/constraints/
// help and hands control over.
// - Invoke — single-step: runs after required/constraint/Validate checks, args
// assembly and the Risk write-confirmation, receiving the assembled toolArgs.
// assembly and the Safety confirmation gate, receiving the assembled toolArgs.
// - Orchestrate — multi-step: runs after the same checks and confirmation but
// receives only the Ctx, so it can chain several backend calls itself.
// - Validate / PostMount — orchestration only; must not register business flags
@@ -256,12 +197,12 @@ type CommandSpec struct {
Flags []FlagSpec
Constraints []Constraint
Risk Risk
// Safety declares the Schema safety tier independently of the runtime
// Risk. Empty falls back to Risk.SafetyDefault(); explicit SafetyDecl
// string fields always win over the tier fill.
Safety Safety
// ConfirmFirst runs the Risk write-confirmation before required/constraint/
// Safety is the command's single safety source. The same cli.SafetySpec is
// used for runtime confirmation and the published Schema. A completely
// empty value keeps the historical read-only default; a non-empty value
// must declare effect/risk/confirmation/idempotency together.
Safety cli.SafetySpec
// ConfirmFirst runs the Safety confirmation before required/constraint/
// Validate checks instead of after them. Use it where the legacy semantics
// were guard-first (a write without --yes fails fast with
// confirmation_required regardless of parameter completeness). The default
@@ -368,8 +309,8 @@ func (c *Ctx) Yes() bool { return BoolFlag(c.cmd, "yes") }
// NewCommand builds a cobra command from a CommandSpec. It is the single
// orchestration path: dispatch declaration check → flag registration →
// constraint declaration checks → Runtime Schema projection → constraint help →
// PostMount → (RunE escape) → generated RunE{ [ConfirmFirst: ConfirmRisk →]
// required → constraints → Validate → BuildArgs → ConfirmRisk →
// PostMount → generated RunE{ [ConfirmFirst: ConfirmSafety →]
// required → constraints → Validate → BuildArgs → ConfirmSafety →
// Invoke/Orchestrate }.
//
// Behavior matches the former helpers.NewLeafCommand, which always dispatched
@@ -379,6 +320,7 @@ func (c *Ctx) Yes() bool { return BoolFlag(c.cmd, "yes") }
// included — and then silently exit 0 having done nothing.
func NewCommand(spec CommandSpec) *cobra.Command {
validateDispatchDecl(spec)
validateSafetySpec(spec)
validateSchemaDecl(spec)
// Help prose inherits the declaration when not authored separately:
// Selection.Examples (already contract-validated against the real flags)
@@ -404,12 +346,17 @@ func NewCommand(spec CommandSpec) *cobra.Command {
spec.PostMount(cmd)
}
if spec.RunE != nil {
cmd.RunE = spec.RunE
cmd.RunE = func(cmd *cobra.Command, args []string) error {
if err := ConfirmSafety(cmd, spec.Safety); err != nil {
return err
}
return spec.RunE(cmd, args)
}
return cmd
}
cmd.RunE = func(cmd *cobra.Command, args []string) error {
if spec.ConfirmFirst {
if err := ConfirmRisk(cmd, spec.Risk); err != nil {
if err := ConfirmSafety(cmd, spec.Safety); err != nil {
return err
}
}
@@ -427,7 +374,7 @@ func NewCommand(spec CommandSpec) *cobra.Command {
ctx := newCtx(cmd, args, spec.Flags)
if spec.Orchestrate != nil {
if !spec.ConfirmFirst {
if err := ConfirmRisk(cmd, spec.Risk); err != nil {
if err := ConfirmSafety(cmd, spec.Safety); err != nil {
return err
}
}
@@ -441,7 +388,7 @@ func NewCommand(spec CommandSpec) *cobra.Command {
toolArgs[key] = value
}
if !spec.ConfirmFirst {
if err := ConfirmRisk(cmd, spec.Risk); err != nil {
if err := ConfirmSafety(cmd, spec.Safety); err != nil {
return err
}
}
@@ -470,17 +417,51 @@ func validateDispatchDecl(spec CommandSpec) {
"command %q must declare exactly one of RunE/Invoke/Orchestrate, got %d",
spec.Use, declared))
}
// ConfirmFirst only makes sense on a Risk-driven confirmation flow. A
// command with ConfirmFirst but no Risk is always an authoring mistake —
// and for a declared-Schema write it would also silently publish the read
// safety tier (empty Risk infers SafetyRead).
if spec.ConfirmFirst && strings.TrimSpace(string(spec.Risk)) == "" {
// ConfirmFirst only changes the ordering of a declared confirmation gate.
if spec.ConfirmFirst && strings.TrimSpace(spec.Safety.Confirmation) != "user_required" {
panic(fmt.Sprintf(
"command %q sets ConfirmFirst but declares no Risk: ConfirmFirst orders the Risk-driven confirmation and is meaningless without one",
"command %q sets ConfirmFirst but Safety.Confirmation is not user_required",
spec.Use))
}
}
// validateSafetySpec rejects partial safety declarations. A zero value remains
// the historical read-only default, but once any field is authored all four
// independent Schema dimensions must be explicit.
func validateSafetySpec(spec CommandSpec) {
safety := spec.Safety
fields := []struct {
name string
value string
}{
{"Safety.Effect", safety.Effect},
{"Safety.Risk", safety.Risk},
{"Safety.Confirmation", safety.Confirmation},
{"Safety.Idempotency", safety.Idempotency},
}
declared := false
for _, field := range fields {
if strings.TrimSpace(field.value) != "" {
declared = true
break
}
}
if !declared {
return
}
missing := make([]string, 0, len(fields))
for _, field := range fields {
if strings.TrimSpace(field.value) == "" {
missing = append(missing, field.name)
}
}
if len(missing) > 0 {
panic(fmt.Sprintf(
"command %q declares partial SafetySpec; missing %s: safety fields are independent and are never inferred from one another",
spec.Use, strings.Join(missing, ", ")))
}
}
// RegisterFlags registers every flag (plus hidden aliases and MarkFlagRequired)
// declared by the spec set onto cmd.
func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
@@ -835,8 +816,9 @@ func ValidateConstraints(cmd *cobra.Command, flags []FlagSpec, constraints []Con
return nil
}
// ConfirmRisk reproduces the shortcut framework's confirmRisk write-confirmation
// prompt wording (command path substitutes Service+Command). Semantics:
// ConfirmSafety enforces the command's declared confirmation requirement.
// Effect, risk and idempotency are metadata only and never imply confirmation.
// Semantics:
//
// - read-only, --dry-run, or --yes → nil (proceed);
// - interactive yes/y → nil;
@@ -844,8 +826,9 @@ func ValidateConstraints(cmd *cobra.Command, flags []FlagSpec, constraints []Con
// - no interactive answer (EOF / closed stdin) → confirmation_required.
//
// EOF must not be treated as decline: that silently drops writes in agent/CI.
func ConfirmRisk(cmd *cobra.Command, risk Risk) error {
if risk.Effective() == RiskRead || BoolFlag(cmd, "dry-run") || BoolFlag(cmd, "yes") {
func ConfirmSafety(cmd *cobra.Command, safety cli.SafetySpec) error {
if strings.TrimSpace(safety.Confirmation) != "user_required" ||
BoolFlag(cmd, "dry-run") || BoolFlag(cmd, "yes") {
return nil
}
// Only print the interactive prompt on a real terminal. In non-interactive
@@ -855,7 +838,11 @@ func ConfirmRisk(cmd *cobra.Command, risk Risk) error {
// answer (printf 'yes\n' | cmd) is still honored: the read happens either
// way, only the prompt print is terminal-gated.
if stdinIsTerminal(cmd.InOrStdin()) {
fmt.Fprintf(cmd.ErrOrStderr(), "即将执行 %s(%s),确认继续?(yes/no): ", cmd.CommandPath(), risk.Effective())
fmt.Fprintf(
cmd.ErrOrStderr(),
"即将执行 %s(effect=%s, risk=%s),确认继续?(yes/no): ",
cmd.CommandPath(), strings.TrimSpace(safety.Effect), strings.TrimSpace(safety.Risk),
)
}
reader := bufio.NewReader(cmd.InOrStdin())
answer, err := reader.ReadString('\n')
@@ -887,7 +874,7 @@ func stdinIsTerminal(in io.Reader) bool {
func confirmationRequiredError(operation string) error {
return apperrors.NewValidation(
fmt.Sprintf("%s 是写操作,当前环境无法交互确认;加 --dry-run 预览,或确认后加 --yes 执行", operation),
fmt.Sprintf("%s 需要用户确认,当前环境无法交互;加 --dry-run 预览,或确认后加 --yes 执行", operation),
apperrors.WithReason("confirmation_required"),
apperrors.WithHint("非交互环境(agent/CI)必须显式传入 --yes,不能依赖 stdin 提示"),
apperrors.WithActions("确认目标与变更影响", "以相同参数追加 --yes 执行"),
@@ -921,9 +908,6 @@ func BoolFlag(cmd *cobra.Command, name string) bool {
// annotations; declared fields do not compete with reviewed hints.
func embedContractIntoSchema(cmd *cobra.Command, spec CommandSpec) {
cli.AnnotateRuntimeContract(cmd)
if strings.TrimSpace(string(spec.Risk)) != "" {
cli.AnnotateRuntimeRisk(cmd, string(spec.Risk.Effective()))
}
required := make([]string, 0, len(spec.Flags))
for _, flag := range spec.Flags {
name := strings.TrimSpace(flag.Name)
@@ -960,8 +944,7 @@ func embedContractIntoSchema(cmd *cobra.Command, spec CommandSpec) {
}
// embedSchemaDecl does a light runtime write: only when SchemaDecl is authored,
// convert once and RegisterRuntimeContractFinal. Risk-only leaves keep
// dws.schema.risk from embedContractIntoSchema — no Final registration.
// convert once and RegisterRuntimeContractFinal.
func embedSchemaDecl(cmd *cobra.Command, spec CommandSpec) {
schema := spec.Schema
if schema.empty() {
@@ -970,7 +953,7 @@ func embedSchemaDecl(cmd *cobra.Command, spec CommandSpec) {
payload := cli.ContractFinalPayload{
Title: firstNonEmpty(schema.Title, spec.Short),
Description: firstNonEmpty(schema.Description, spec.Long),
Safety: schemaSafetyFromDecl(spec.Risk, spec.Safety, schema.Safety),
Safety: schemaSafetyFromDecl(spec.Safety),
}
if n := len(schema.Positionals); n > 0 {
payload.Positionals = make([]cli.RuntimeSchemaPositional, n)
@@ -1031,85 +1014,30 @@ func firstNonEmpty(values ...string) string {
return ""
}
// schemaSafetyFromDecl composes the Schema safety metadata from three
// sources, in precedence order: explicit SafetyDecl string fields (highest)
// > the declared Safety tier enum > Risk.SafetyDefault() (lowest). The tier
// fill also covers Idempotency (idempotent for reads, unknown for writes),
// so a plain Risk/Safety enum declaration is self-sufficient.
func schemaSafetyFromDecl(risk Risk, safety Safety, decl SafetyDecl) *cli.SafetySpec {
out := cli.SafetySpec{
Effect: strings.TrimSpace(decl.Effect),
Risk: strings.TrimSpace(decl.Risk),
Confirmation: strings.TrimSpace(decl.Confirmation),
Idempotency: strings.TrimSpace(decl.Idempotency),
}
tier := safety
if tier == "" {
tier = risk.SafetyDefault()
}
switch tier.Effective() {
case SafetyWrite:
if out.Effect == "" {
out.Effect = "write"
}
if out.Risk == "" {
out.Risk = "medium"
}
if out.Confirmation == "" {
out.Confirmation = "user_required"
}
if out.Idempotency == "" {
out.Idempotency = "unknown"
}
case SafetyHighWrite:
if out.Effect == "" {
out.Effect = "write"
}
if out.Risk == "" {
out.Risk = "high"
}
if out.Confirmation == "" {
out.Confirmation = "user_required"
}
if out.Idempotency == "" {
out.Idempotency = "unknown"
}
case SafetyDestructive:
if out.Effect == "" {
out.Effect = "destructive"
}
if out.Risk == "" {
out.Risk = "high"
}
if out.Confirmation == "" {
out.Confirmation = "user_required"
}
if out.Idempotency == "" {
out.Idempotency = "unknown"
}
default: // SafetyRead
if out.Effect == "" {
out.Effect = "read"
}
if out.Risk == "" {
out.Risk = "low"
}
if out.Confirmation == "" {
out.Confirmation = "not_required"
}
if out.Idempotency == "" {
out.Idempotency = "idempotent"
}
}
// effect_source is assembly-derived for every Contract-declared safety,
// whether it came from the Risk shorthand or an explicit SchemaDecl; the
// declaration in code is the source of truth. The tier fill above always
// produces a complete block (read tier by default), so the payload is
// never nil for a declared Schema.
// schemaSafetyFromDecl copies the single command SafetySpec into the final
// Schema payload. The zero value keeps the historical read-only default.
func schemaSafetyFromDecl(safety cli.SafetySpec) *cli.SafetySpec {
out := effectiveSafetySpec(safety)
out.EffectSource = "cmdcore.contract"
return &out
}
func effectiveSafetySpec(safety cli.SafetySpec) cli.SafetySpec {
out := cli.SafetySpec{
Effect: strings.TrimSpace(safety.Effect),
Risk: strings.TrimSpace(safety.Risk),
Confirmation: strings.TrimSpace(safety.Confirmation),
Idempotency: strings.TrimSpace(safety.Idempotency),
}
if out.Effect == "" && out.Risk == "" && out.Confirmation == "" && out.Idempotency == "" {
out.Effect = "read"
out.Risk = "low"
out.Confirmation = "not_required"
out.Idempotency = "idempotent"
}
return out
}
func flagKindSchemaType(kind FlagKind) string {
switch kind {
case KindInt:
+57 -44
View File
@@ -19,6 +19,7 @@ import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -34,6 +35,27 @@ func newTestCommand() *cobra.Command {
return cmd
}
func testReadSafety() cli.SafetySpec {
return cli.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
}
}
func testWriteSafety() cli.SafetySpec {
return cli.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
}
}
func testDestructiveSafety() cli.SafetySpec {
return cli.SafetySpec{
Effect: "destructive", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
}
}
// ── flag registration ──────────────────────────────────────────────
func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
@@ -538,19 +560,10 @@ func TestCrossPlatformCoverageValidateConstraints(t *testing.T) {
}
}
// ── risk confirmation ──────────────────────────────────────────────
// ── safety confirmation ────────────────────────────────────────────
func TestCrossPlatformCoverageRiskEffective(t *testing.T) {
if Risk("").Effective() != RiskRead {
t.Fatal("empty risk must default to read")
}
if RiskWrite.Effective() != RiskWrite || RiskHighWrite.Effective() != RiskHighWrite {
t.Fatal("explicit risk must be preserved")
}
}
func TestCrossPlatformCoverageConfirmRisk(t *testing.T) {
newRiskCmd := func(stdin string) *cobra.Command {
func TestCrossPlatformCoverageConfirmSafety(t *testing.T) {
newSafetyCmd := func(stdin string) *cobra.Command {
cmd := newTestCommand()
cmd.PersistentFlags().Bool("yes", false, "")
cmd.PersistentFlags().Bool("dry-run", false, "")
@@ -559,45 +572,45 @@ func TestCrossPlatformCoverageConfirmRisk(t *testing.T) {
return cmd
}
// read-only never prompts
if err := ConfirmRisk(newRiskCmd(""), RiskRead); err != nil {
t.Fatal("read risk must pass")
// Confirmation is the only field that drives the gate.
if err := ConfirmSafety(newSafetyCmd(""), testReadSafety()); err != nil {
t.Fatal("not_required safety must pass")
}
if err := ConfirmRisk(newRiskCmd(""), ""); err != nil {
t.Fatal("empty risk must pass as read")
if err := ConfirmSafety(newSafetyCmd(""), cli.SafetySpec{Effect: "destructive", Risk: "high"}); err != nil {
t.Fatal("effect/risk must not imply confirmation")
}
// --yes and --dry-run bypass the prompt
yes := newRiskCmd("")
yes := newSafetyCmd("")
_ = yes.PersistentFlags().Set("yes", "true")
if err := ConfirmRisk(yes, RiskHighWrite); err != nil {
if err := ConfirmSafety(yes, testDestructiveSafety()); err != nil {
t.Fatal("--yes must bypass")
}
dry := newRiskCmd("")
dry := newSafetyCmd("")
_ = dry.PersistentFlags().Set("dry-run", "true")
if err := ConfirmRisk(dry, RiskWrite); err != nil {
if err := ConfirmSafety(dry, testWriteSafety()); err != nil {
t.Fatal("--dry-run must bypass")
}
// interactive accept / decline
for _, answer := range []string{"yes\n", "y\n", "YES\n"} {
if err := ConfirmRisk(newRiskCmd(answer), RiskWrite); err != nil {
if err := ConfirmSafety(newSafetyCmd(answer), testWriteSafety()); err != nil {
t.Fatalf("answer %q must confirm, got %v", answer, err)
}
}
for _, answer := range []string{"no\n", "\n", "maybe\n"} {
err := ConfirmRisk(newRiskCmd(answer), RiskHighWrite)
err := ConfirmSafety(newSafetyCmd(answer), testDestructiveSafety())
if err == nil || !strings.Contains(err.Error(), "用户取消了操作") {
t.Fatalf("answer %q must decline with cancel, got %v", answer, err)
}
}
// EOF / closed stdin is ConfirmUnavailable, not decline
err := ConfirmRisk(newRiskCmd(""), RiskWrite)
err := ConfirmSafety(newSafetyCmd(""), testWriteSafety())
var appErr *apperrors.Error
if !errors.As(err, &appErr) || appErr.Reason != "confirmation_required" {
t.Fatalf("EOF must be confirmation_required, got %#v", err)
}
}
func TestCrossPlatformCoverageConfirmRiskSuppressesPromptOffTerminal(t *testing.T) {
func TestCrossPlatformCoverageConfirmSafetySuppressesPromptOffTerminal(t *testing.T) {
// Non-terminal stdin (buffer/pipe/EOF): the interactive prompt line must
// not pollute stderr — the structured error carries the semantics. Piped
// answers still confirm.
@@ -607,7 +620,7 @@ func TestCrossPlatformCoverageConfirmRiskSuppressesPromptOffTerminal(t *testing.
cmd.PersistentFlags().Bool("dry-run", false, "")
cmd.SetIn(strings.NewReader(""))
cmd.SetErr(&stderr)
if err := ConfirmRisk(cmd, RiskWrite); err == nil {
if err := ConfirmSafety(cmd, testWriteSafety()); err == nil {
t.Fatal("EOF must fail closed")
}
if got := stderr.String(); got != "" {
@@ -619,7 +632,7 @@ func TestCrossPlatformCoverageConfirmRiskSuppressesPromptOffTerminal(t *testing.
piped.PersistentFlags().Bool("yes", false, "")
piped.SetIn(strings.NewReader("yes\n"))
piped.SetErr(&answered)
if err := ConfirmRisk(piped, RiskWrite); err != nil {
if err := ConfirmSafety(piped, testWriteSafety()); err != nil {
t.Fatalf("piped answer must confirm, got %v", err)
}
if got := answered.String(); got != "" {
@@ -872,9 +885,9 @@ func TestCrossPlatformCoverageNewCommandStopsOnFailures(t *testing.T) {
func TestCrossPlatformCoverageNewCommandDeclineCancels(t *testing.T) {
dispatched := false
cmd := NewCommand(CommandSpec{
Use: "risky",
Risk: RiskHighWrite,
Flags: []FlagSpec{{Name: "x", Usage: "X"}},
Use: "risky",
Safety: testDestructiveSafety(),
Flags: []FlagSpec{{Name: "x", Usage: "X"}},
Invoke: func(*Ctx, map[string]any) error {
dispatched = true
return nil
@@ -913,7 +926,7 @@ func TestCrossPlatformCoverageNewCommandRequiresExactlyOneDispatcher(t *testing.
// Zero dispatchers: a spec with no runnable body must never reach run time,
// where it would have prompted for confirmation and then exited 0 doing nothing.
mustPanic("no dispatcher", CommandSpec{Use: "bare", Risk: RiskHighWrite, Flags: flags},
mustPanic("no dispatcher", CommandSpec{Use: "bare", Safety: testDestructiveSafety(), Flags: flags},
"must declare exactly one of RunE/Invoke/Orchestrate, got 0")
// Two competing dispatchers are equally a programming error.
@@ -930,14 +943,14 @@ func TestCrossPlatformCoverageNewCommandRequiresExactlyOneDispatcher(t *testing.
Invoke: func(*Ctx, map[string]any) error { return nil },
}, "got 2")
// ConfirmFirst without Risk orders a confirmation that does not exist — and
// for declared-Schema writes it would also publish the read safety tier.
mustPanic("confirmFirst without risk", CommandSpec{
// ConfirmFirst without a user_required confirmation orders a gate that
// does not exist.
mustPanic("confirmFirst without confirmation", CommandSpec{
Use: "guarded",
Flags: flags,
ConfirmFirst: true,
Invoke: func(*Ctx, map[string]any) error { return nil },
}, "ConfirmFirst but declares no Risk")
}, "Safety.Confirmation is not user_required")
}
func TestCrossPlatformCoverageNewCommandOrchestrateDispatch(t *testing.T) {
@@ -1021,7 +1034,7 @@ func TestCrossPlatformCoverageNewCommandOrchestrateHonorsConfirmation(t *testing
build := func() *cobra.Command {
cmd := NewCommand(CommandSpec{
Use: "risky-orch",
Risk: RiskHighWrite,
Safety: testDestructiveSafety(),
Flags: []FlagSpec{{Name: "x", Usage: "X"}},
Orchestrate: func(*Ctx) error { ran = true; return nil },
})
@@ -1053,18 +1066,18 @@ func TestCrossPlatformCoverageNewCommandOrchestrateHonorsConfirmation(t *testing
}
}
func TestNewCommandEmbedsContractRiskIntoSchema(t *testing.T) {
func TestNewCommandEmbedsContractFlagsWithoutLegacyRiskAnnotation(t *testing.T) {
cmd := NewCommand(CommandSpec{
Use: "wipe",
Risk: RiskHighWrite,
Use: "wipe",
Safety: testDestructiveSafety(),
Flags: []FlagSpec{
{Name: "id", Usage: "ID", Required: true, Bind: "versionId", Kind: KindString},
{Name: "count", Usage: "N", Kind: KindInt, Default: "1"},
},
Invoke: func(*Ctx, map[string]any) error { return nil },
})
if cmd.Annotations["dws.schema.risk"] != string(RiskHighWrite) {
t.Fatalf("risk annotation = %q", cmd.Annotations["dws.schema.risk"])
if _, ok := cmd.Annotations["dws.schema.risk"]; ok {
t.Fatal("cmdcore must not emit the legacy dws.schema.risk annotation")
}
id := cmd.Flags().Lookup("id")
if id.Annotations["dws.schema.required"][0] != "true" {
@@ -1076,16 +1089,16 @@ func TestNewCommandEmbedsContractRiskIntoSchema(t *testing.T) {
if cmd.Flags().Lookup("count").Annotations["dws.schema.type"][0] != "integer" {
t.Fatalf("count type = %#v", cmd.Flags().Lookup("count").Annotations["dws.schema.type"])
}
// Empty Risk must not stamp a read default that would clobber write-guard leaves.
// Empty Safety must not stamp the removed Risk annotation.
plain := NewCommand(CommandSpec{
Use: "list",
Invoke: func(*Ctx, map[string]any) error { return nil },
})
if _, ok := plain.Annotations["dws.schema.risk"]; ok {
t.Fatal("empty Risk must not embed dws.schema.risk")
t.Fatal("empty Safety must not embed dws.schema.risk")
}
if plain.Annotations["dws.schema.contract"] != "cmdcore" {
t.Fatal("contract marker still required when Risk empty")
t.Fatal("contract marker still required when Safety empty")
}
}
+2 -23
View File
@@ -31,7 +31,6 @@ type SchemaDecl struct {
Title string
Description string
Positionals []PositionalDecl
Safety SafetyDecl
DryRun *DryRunDecl
Interface *InterfaceDecl
Selection SelectionDecl
@@ -48,16 +47,6 @@ type PositionalDecl struct {
Index int
}
// SafetyDecl is the final Schema Safety payload. Any field left empty is
// filled from the CommandSpec.Safety tier (or Risk.SafetyDefault() when
// Safety is unset) at embed time, including Idempotency.
type SafetyDecl struct {
Effect string
Risk string
Confirmation string
Idempotency string
}
// DryRunDecl is a positive --dry-run capability declaration for Schema.
type DryRunDecl struct {
PreviewKind string
@@ -128,14 +117,8 @@ func validateSchemaDecl(spec CommandSpec) {
missing = append(missing, "Schema.Selection.Examples")
}
// Interface is an unconditional catalog required key for declared tools
// (no hints fallback). Safety needs no completeness check: the enum tier
// (CommandSpec.Safety, falling back to Risk.SafetyDefault()) fills every
// Safety field including Idempotency, so any declared Schema projects a
// complete safety block by construction. The flip side of always-on
// inference: a WRITE command must declare Risk (which also drives runtime
// confirmation) — an empty Risk publishes the read tier. The detectable
// half of that mistake (ConfirmFirst without Risk) is caught by
// validateDispatchDecl.
// (no hints fallback). CommandSpec.Safety is validated separately and is
// the single source for both runtime confirmation and the Schema block.
if iface := spec.Schema.Interface; iface == nil ||
strings.TrimSpace(iface.Mode) == "" || strings.TrimSpace(iface.Availability) == "" {
missing = append(missing, "Schema.Interface (mode/availability)")
@@ -158,10 +141,6 @@ func (s SchemaDecl) empty() bool {
if len(s.Positionals) > 0 {
return false
}
if strings.TrimSpace(s.Safety.Effect) != "" || strings.TrimSpace(s.Safety.Risk) != "" ||
strings.TrimSpace(s.Safety.Confirmation) != "" || strings.TrimSpace(s.Safety.Idempotency) != "" {
return false
}
if s.DryRun != nil && strings.TrimSpace(s.DryRun.PreviewKind) != "" {
return false
}
+38 -29
View File
@@ -32,12 +32,14 @@ func TestNewCommandEmbedsFullSchemaDeclAsFinalSource(t *testing.T) {
RequiredWhen: "when x", SchemaDescription: "schema desc",
},
},
Risk: RiskWrite,
Safety: cli.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "retryable",
},
Schema: SchemaDecl{
Title: "Create Title",
Description: "Create Desc",
Positionals: []PositionalDecl{{Name: "id", Required: true, Index: 0}},
Safety: SafetyDecl{Idempotency: "retryable"},
DryRun: &DryRunDecl{PreviewKind: "invocation", RemoteReads: true},
Interface: &InterfaceDecl{
Mode: "mcp", Availability: "available",
@@ -120,18 +122,15 @@ func TestNewCommandPanicsOnPartialSchemaDecl(t *testing.T) {
}, "Schema.Selection.AgentSummary"},
{"missing examples", SchemaDecl{
Description: "d",
Safety: SafetyDecl{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Interface: &InterfaceDecl{Mode: "mcp", Availability: "available", ProductID: "dev", RPCName: "get_thing"},
Selection: SelectionDecl{AgentSummary: "s", UseWhen: []string{"u"}, AvoidWhen: []string{"a"}},
}, "Schema.Selection.Examples"},
{"missing interface", SchemaDecl{
Description: "d",
Safety: SafetyDecl{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Selection: SelectionDecl{AgentSummary: "s", UseWhen: []string{"u"}, AvoidWhen: []string{"a"}, Examples: []string{"dws x"}},
}, "Schema.Interface"},
{"composite without reason", SchemaDecl{
Description: "d",
Safety: SafetyDecl{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Interface: &InterfaceDecl{Mode: "composite", Availability: "available"},
Selection: SelectionDecl{AgentSummary: "s", UseWhen: []string{"u"}, AvoidWhen: []string{"a"}, Examples: []string{"dws x"}},
}, "Schema.Interface.Reason"},
@@ -159,7 +158,6 @@ func TestNewCommandPanicsOnPartialSchemaDecl(t *testing.T) {
func TestNewCommandDerivesHelpExampleFromDeclaredSelection(t *testing.T) {
schema := SchemaDecl{
Description: "desc",
Safety: SafetyDecl{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Interface: &InterfaceDecl{Mode: "mcp", Availability: "available", ProductID: "dev", RPCName: "create_thing"},
Selection: SelectionDecl{
AgentSummary: "summary",
@@ -171,6 +169,7 @@ func TestNewCommandDerivesHelpExampleFromDeclaredSelection(t *testing.T) {
cmd := NewCommand(CommandSpec{
Use: "create",
Short: "short",
Safety: testWriteSafety(),
Schema: schema,
Invoke: func(*Ctx, map[string]any) error { return nil },
})
@@ -184,6 +183,7 @@ func TestNewCommandDerivesHelpExampleFromDeclaredSelection(t *testing.T) {
Use: "create",
Short: "short",
Example: " dws create --custom",
Safety: testWriteSafety(),
Schema: schema,
Invoke: func(*Ctx, map[string]any) error { return nil },
})
@@ -192,7 +192,7 @@ func TestNewCommandDerivesHelpExampleFromDeclaredSelection(t *testing.T) {
}
}
func TestNewCommandSafetyTierComposition(t *testing.T) {
func TestNewCommandSafetySpecPassThrough(t *testing.T) {
schema := func() SchemaDecl {
return SchemaDecl{
Description: "desc",
@@ -211,29 +211,21 @@ func TestNewCommandSafetyTierComposition(t *testing.T) {
return final.Safety
}
// Risk-only: tier inferred from Risk.SafetyDefault(), including idempotency.
if got := build(CommandSpec{Use: "w", Short: "w", Risk: RiskWrite, Schema: schema(),
Invoke: func(*Ctx, map[string]any) error { return nil }}); got.Effect != "write" || got.Risk != "medium" ||
got.Confirmation != "user_required" || got.Idempotency != "unknown" {
t.Fatalf("RiskWrite default tier = %#v", got)
declared := cli.SafetySpec{
Effect: "write", Risk: "low",
Confirmation: "not_required", Idempotency: "non_idempotent",
}
// Safety enum overrides the Risk default (high-write over destructive).
if got := build(CommandSpec{Use: "w", Short: "w", Risk: RiskHighWrite, Safety: SafetyHighWrite, Schema: schema(),
Invoke: func(*Ctx, map[string]any) error { return nil }}); got.Effect != "write" || got.Risk != "high" {
t.Fatalf("explicit Safety tier must win over Risk default, = %#v", got)
if got := build(CommandSpec{Use: "w", Short: "w", Safety: declared, Schema: schema(),
Invoke: func(*Ctx, map[string]any) error { return nil }}); got.Effect != declared.Effect ||
got.Risk != declared.Risk || got.Confirmation != declared.Confirmation ||
got.Idempotency != declared.Idempotency {
t.Fatalf("SafetySpec must pass through without cross-field inference: %#v", got)
}
// Explicit SafetyDecl string fields win over the tier fill.
s := schema()
s.Safety = SafetyDecl{Effect: "destructive"}
if got := build(CommandSpec{Use: "w", Short: "w", Risk: RiskWrite, Schema: s,
Invoke: func(*Ctx, map[string]any) error { return nil }}); got.Effect != "destructive" || got.Risk != "medium" {
t.Fatalf("SafetyDecl field must win over tier fill, = %#v", got)
}
// Empty Risk + empty Safety infers the read tier.
// A wholly empty declaration preserves the historical read-only default.
if got := build(CommandSpec{Use: "r", Short: "r", Schema: schema(),
Invoke: func(*Ctx, map[string]any) error { return nil }}); got.Effect != "read" || got.Risk != "low" ||
got.Confirmation != "not_required" || got.Idempotency != "idempotent" {
t.Fatalf("empty Risk/Safety must infer read tier, = %#v", got)
t.Fatalf("empty Safety must use read default, = %#v", got)
}
}
@@ -242,13 +234,30 @@ func TestSchemaDeclEmptySkipsFinal(t *testing.T) {
Use: "get",
Short: "g",
Flags: []FlagSpec{{Name: "id", Usage: "id"}},
Risk: RiskWrite, // Risk alone is annotation-only; Final needs Schema
Safety: testWriteSafety(),
Invoke: func(*Ctx, map[string]any) error { return nil },
})
if cli.HasRuntimeContractFinal(cmd) {
t.Fatal("Risk without Schema must not register Final (keep runtime write light)")
t.Fatal("Safety without Schema must not register Final (keep runtime write light)")
}
if _, ok := cli.RuntimeContractRisk(cmd); !ok {
t.Fatal("Risk must still embed dws.schema.risk")
if _, ok := cmd.Annotations["dws.schema.risk"]; ok {
t.Fatal("Safety must not use the removed dws.schema.risk annotation")
}
}
func TestNewCommandRejectsPartialSafetySpec(t *testing.T) {
defer func() {
recovered := recover()
if recovered == nil {
t.Fatal("partial SafetySpec must panic at construction")
}
if msg, _ := recovered.(string); !strings.Contains(msg, "Safety.Confirmation") {
t.Fatalf("panic = %v, want missing Safety.Confirmation", recovered)
}
}()
NewCommand(CommandSpec{
Use: "partial",
Safety: cli.SafetySpec{Effect: "write", Risk: "medium", Idempotency: "unknown"},
Invoke: func(*Ctx, map[string]any) error { return nil },
})
}
+67 -56
View File
@@ -18,7 +18,7 @@ import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cmdcore"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
@@ -260,6 +260,7 @@ func newDevAppEventListCommand(runner executor.Runner) *cobra.Command {
Short: "查询应用已订阅的事件列表",
Example: " dws dev app event list --unified-app-id UNIFIED_APP_ID --page-size 20 --format json",
Tool: devAppEventListTool,
Safety: devAppSafetyRead(),
Flags: []LeafFlag{
{Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"},
{Name: "keyword", Usage: "事件搜索关键词,支持按事件码或事件名称模糊匹配", Bind: "keyword", Trim: true, OmitEmpty: true},
@@ -286,8 +287,7 @@ func newDevAppEventSubscribeCommand(runner executor.Runner) *cobra.Command {
Short: "订阅应用事件回调",
Example: " dws dev app event subscribe --unified-app-id UNIFIED_APP_ID --event-codes bpms_task_change --dry-run --format json",
Tool: devAppEventSubscribeTool,
Risk: LeafRiskWrite,
Safety: LeafSafetyWrite,
Safety: devAppSafetyWrite(),
// devapp 旧版写守卫为 guard-first:确认门先于参数校验。
ConfirmFirst: true,
Flags: []LeafFlag{
@@ -316,8 +316,7 @@ func newDevAppEventUnsubscribeCommand(runner executor.Runner) *cobra.Command {
Short: "取消订阅应用事件",
Example: " dws dev app event unsubscribe --unified-app-id UNIFIED_APP_ID --event-codes bpms_task_change --dry-run --format json",
Tool: devAppEventUnsubscribeTool,
Risk: LeafRiskWrite,
Safety: LeafSafetyWrite,
Safety: devAppSafetyWrite(),
// devapp 旧版写守卫为 guard-first:确认门先于参数校验。
ConfirmFirst: true,
Flags: []LeafFlag{
@@ -346,6 +345,7 @@ func newDevAppListCommand(runner executor.Runner) *cobra.Command {
Short: "查询开放平台企业内部应用列表",
Example: " dws dev app list --name DemoApp --page-size 20 --format json",
Tool: devAppListTool,
Safety: devAppSafetyRead(),
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},
@@ -379,6 +379,7 @@ func newDevAppGetCommand(runner executor.Runner) *cobra.Command {
Short: "查询开放平台企业内部应用详情",
Example: " dws dev app get --unified-app-id UNIFIED_APP_ID --format json\n dws dev app get --app-key APP_KEY --format json",
Tool: devAppGetTool,
Safety: devAppSafetyRead(),
Flags: []LeafFlag{
{Name: "unified-app-id", Usage: "开放平台统一应用 ID(与 --app-key 二选一)", Bind: "unifiedAppId", Trim: true, OmitEmpty: true},
{Name: "app-key", Usage: "按 appKey/clientId 查询应用详情(与 --unified-app-id 二选一)", Bind: "appKey", Trim: true, OmitEmpty: true},
@@ -411,8 +412,7 @@ func newDevAppCreateCommand(runner executor.Runner) *cobra.Command {
Short: "创建开放平台企业内部应用",
Example: " dws dev app create --name DemoApp --desc 内部应用 --dry-run --format json",
Tool: devAppCreateTool,
Risk: LeafRiskHighWrite,
Safety: LeafSafetyHighWrite,
Safety: devAppSafetyHighWrite(),
// devapp 旧版写守卫为 guard-first:确认门先于参数校验。
ConfirmFirst: true,
Flags: []LeafFlag{
@@ -443,8 +443,7 @@ func newDevAppUpdateCommand(runner executor.Runner) *cobra.Command {
Long: "修改开放平台企业内部应用基础信息:名称、描述或图标至少提供一项。",
Example: " dws dev app update --unified-app-id UNIFIED_APP_ID --name DemoApp2 --dry-run --format json",
Tool: devAppUpdateTool,
Risk: LeafRiskWrite,
Safety: LeafSafetyWrite,
Safety: devAppSafetyWrite(),
// devapp 旧版写守卫为 guard-first:确认门先于参数校验。
ConfirmFirst: true,
Flags: []LeafFlag{
@@ -478,6 +477,7 @@ func newDevAppCredentialsGetCommand(runner executor.Runner) *cobra.Command {
Short: "读取开放平台应用凭证",
Example: " dws dev app credentials get --unified-app-id UNIFIED_APP_ID --format json",
Tool: devAppCredentialsGetTool,
Safety: devAppSafetyRead(),
Flags: []LeafFlag{
{Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"},
},
@@ -502,8 +502,7 @@ func newDevAppLifecycleCommand(runner executor.Runner, use, short, tool string,
Use: use,
Short: short,
Tool: tool,
Risk: LeafRiskWrite,
Safety: LeafSafetyWrite,
Safety: devAppSafetyWrite(),
// devapp 旧版写守卫为 guard-first:确认门先于参数校验。
ConfirmFirst: true,
Flags: []LeafFlag{
@@ -529,16 +528,14 @@ func newDevAppLifecycleCommand(runner executor.Runner, use, short, tool string,
// (gh repo delete, gcloud).
//
// LeafSpec+RunE:confirm-name 的 get-then-compare 多步二次确认属多步编排,
// 由自定义 RunE 承载;确认门与 Schema 全部声明化(Risk/Schema),RunE 内手动
// 调 cmdcore.ConfirmRisk(RunE 逃逸舱内框架不自动跑确认)。
// 由自定义 RunE 承载;框架仍按同一 SafetySpec 执行确认门并发布 Schema。
func newDevAppDeleteCommand(runner executor.Runner) *cobra.Command {
return NewLeafCommand(LeafSpec{
Use: "delete",
Short: "删除开放平台企业内部应用(不可逆,需 --confirm-name 二次确认)",
Example: " dws dev app delete --unified-app-id UNIFIED_APP_ID --confirm-name 应用名 --yes --format json",
Tool: devAppDeleteTool,
Risk: LeafRiskHighWrite,
Safety: LeafSafetyDestructive,
Safety: devAppSafetyDestructive(),
Flags: []LeafFlag{
{Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"},
{Name: "confirm-name", Usage: "二次确认:必须与被删应用的名称一致(不可逆操作的防误删)", Bind: "confirmName", Trim: true, OmitEmpty: true},
@@ -555,9 +552,6 @@ func newDevAppDeleteCommand(runner executor.Runner) *cobra.Command {
},
},
RunE: func(cmd *cobra.Command, args []string) error {
if err := cmdcore.ConfirmRisk(cmd, cmdcore.RiskHighWrite); err != nil {
return err
}
appID, err := requiredDevAppUnifiedID(cmd)
if err != nil {
return err
@@ -636,6 +630,7 @@ func newDevAppWebappGetCommand(runner executor.Runner) *cobra.Command {
Short: "查询网页应用配置",
Example: " dws dev app webapp get --unified-app-id UNIFIED_APP_ID --format json",
Tool: devAppWebappGetTool,
Safety: devAppSafetyRead(),
Flags: []LeafFlag{
{Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"},
},
@@ -661,8 +656,7 @@ func newDevAppWebappConfigCommand(runner executor.Runner) *cobra.Command {
Short: "配置网页应用能力",
Example: " dws dev app webapp config --unified-app-id UNIFIED_APP_ID --homepage-url https://example.com --dry-run --format json",
Tool: devAppWebappSetTool,
Risk: LeafRiskWrite,
Safety: LeafSafetyWrite,
Safety: devAppSafetyWrite(),
// devapp 旧版写守卫为 guard-first:确认门先于参数校验。
ConfirmFirst: true,
Flags: []LeafFlag{
@@ -697,6 +691,7 @@ func newDevAppPermissionListCommand(runner executor.Runner) *cobra.Command {
Short: "查询开放平台应用权限列表",
Example: " dws dev app permission list --unified-app-id UNIFIED_APP_ID --keyword 通讯录 --page-size 20 --format json",
Tool: devAppPermissionListTool,
Safety: devAppSafetyRead(),
// 命令级别名 "search" 由 PostMount 设回(LeafSpec 无 Command Aliases 字段)。
Flags: []LeafFlag{
{Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"},
@@ -731,8 +726,7 @@ func newDevAppPermissionAddCommand(runner executor.Runner) *cobra.Command {
Short: "申请开放平台应用权限点",
Example: " dws dev app permission add --unified-app-id UNIFIED_APP_ID --scope-values Contact.User.mobile --dry-run --format json",
Tool: devAppPermissionAddTool,
Risk: LeafRiskWrite,
Safety: LeafSafetyWrite,
Safety: devAppSafetyWrite(),
// devapp 旧版写守卫为 guard-first:确认门先于参数校验。
ConfirmFirst: true,
Flags: []LeafFlag{
@@ -761,8 +755,7 @@ func newDevAppPermissionRemoveCommand(runner executor.Runner) *cobra.Command {
Short: "取消开放平台应用权限点",
Example: " dws dev app permission remove --unified-app-id UNIFIED_APP_ID --scope-values Contact.User.mobile,qyapi_robot_sendmsg --dry-run --format json",
Tool: devAppPermissionRmTool,
Risk: LeafRiskWrite,
Safety: LeafSafetyWrite,
Safety: devAppSafetyWrite(),
// devapp 旧版写守卫为 guard-first:确认门先于参数校验。
ConfirmFirst: true,
Flags: []LeafFlag{
@@ -791,6 +784,7 @@ func newDevAppMemberListCommand(runner executor.Runner) *cobra.Command {
Short: "查询开放平台应用成员",
Example: " dws dev app member list --unified-app-id <unifiedAppId>",
Tool: devAppMemberListTool,
Safety: devAppSafetyRead(),
Flags: []LeafFlag{
{Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"},
},
@@ -816,8 +810,7 @@ func newDevAppMemberAddCommand(runner executor.Runner) *cobra.Command {
Short: "添加开放平台应用成员",
Example: " dws dev app member add --unified-app-id <unifiedAppId> --user-ids userId1,userId2 --member-type DEVELOPER --dry-run",
Tool: devAppMemberAddTool,
Risk: LeafRiskWrite,
Safety: LeafSafetyWrite,
Safety: devAppSafetyWrite(),
// devapp 旧版写守卫为 guard-first:确认门先于参数校验。
ConfirmFirst: true,
Flags: []LeafFlag{
@@ -855,8 +848,7 @@ func newDevAppMemberRemoveCommand(runner executor.Runner) *cobra.Command {
Short: "移除开放平台应用成员",
Example: " dws dev app member remove --unified-app-id <unifiedAppId> --user-ids userId1,userId2 --member-type DEVELOPER --dry-run",
Tool: devAppMemberRemoveTool,
Risk: LeafRiskWrite,
Safety: LeafSafetyWrite,
Safety: devAppSafetyWrite(),
// devapp 旧版写守卫为 guard-first:确认门先于参数校验。
ConfirmFirst: true,
Flags: []LeafFlag{
@@ -895,8 +887,7 @@ func newDevAppSecurityConfigCommand(runner executor.Runner) *cobra.Command {
Example: " dws dev app security config --unified-app-id <unifiedAppId> " +
"--ip-whitelist 192.0.2.10 --redirect-urls https://callback.example.invalid/callback --sso-urls https://sso.example.invalid/sso --dry-run",
Tool: devAppSecurityConfigTool,
Risk: LeafRiskWrite,
Safety: LeafSafetyWrite,
Safety: devAppSafetyWrite(),
// devapp 旧版写守卫为 guard-first:确认门先于参数校验。
ConfirmFirst: true,
Flags: []LeafFlag{
@@ -929,17 +920,15 @@ func newDevAppSecurityConfigCommand(runner executor.Runner) *cobra.Command {
// ---------------------------------------------------------------------------
// LeafSpec+RunE:devAppRobotCreateParams 的 icon/preview 空串占位与失败重试
// taskId 编排由自定义 RunE 承载;确认门与 Schema 声明化,RunE 内手动
// ConfirmRisk(RunE 逃逸舱内框架不自动跑确认与必填校验,必填仍由
// devAppRobotCreateParams 报错,Required 标记仅用于 Schema/help 投影)。
// taskId 编排由自定义 RunE 承载;框架按 SafetySpec 执行确认门,必填仍由
// devAppRobotCreateParams 报错,Required 标记仅用于 Schema/help 投影。
func newDevAppRobotSubmitCommand(runner executor.Runner) *cobra.Command {
return NewLeafCommand(LeafSpec{
Use: "submit",
Short: "异步提交钉钉智能体机器人创建任务(支持失败重试)",
Example: " dws dev app robot submit --name 我的智能体 --robot-name 小助手 --desc \"处理审批问答\" --dry-run --format json",
Tool: devAppRobotSubmitTool,
Risk: LeafRiskHighWrite,
Safety: LeafSafetyHighWrite,
Safety: devAppSafetyHighWrite(),
// devapp 旧版写守卫为 guard-first:确认门先于参数校验。
ConfirmFirst: true,
Flags: []LeafFlag{
@@ -962,9 +951,6 @@ func newDevAppRobotSubmitCommand(runner executor.Runner) *cobra.Command {
},
},
RunE: func(cmd *cobra.Command, args []string) error {
if err := cmdcore.ConfirmRisk(cmd, cmdcore.RiskWrite); err != nil {
return err
}
params, err := devAppRobotCreateParams(cmd)
if err != nil {
return err
@@ -990,6 +976,7 @@ func newDevAppRobotResultCommand(runner executor.Runner) *cobra.Command {
Short: "查询机器人异步创建任务结果",
Example: " dws dev app robot result --task-id TASK_ID --format json",
Tool: devAppRobotResultTool,
Safety: devAppSafetyRead(),
Flags: []LeafFlag{
{Name: "task-id", Usage: "提交创建任务时返回的 taskId (必填)", Bind: "taskId", Trim: true, Required: true, RequiredHint: "--task-id 为必填"},
},
@@ -1015,6 +1002,7 @@ func newDevAppRobotConfigGetCommand(runner executor.Runner) *cobra.Command {
Short: "查询现有应用的机器人配置",
Example: " dws dev app robot get --unified-app-id UNIFIED_APP_ID --format json",
Tool: devAppRobotConfigGetTool,
Safety: devAppSafetyRead(),
Flags: []LeafFlag{
{Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"},
},
@@ -1041,16 +1029,15 @@ func newDevAppRobotConfigGetCommand(runner executor.Runner) *cobra.Command {
// `enable` (pure enable, no config fields) is a separate command.
//
// LeafSpec+RunE:devAppRobotConfigParams 的 mode enum 校验、Bool Changed 语义、
// skills 列表、i18n JSON 解析与至少一项计数由自定义 RunE 承载;确认门与
// Schema 声明化,RunE 内手动 ConfirmRisk。
// skills 列表、i18n JSON 解析与至少一项计数由自定义 RunE 承载;框架按
// SafetySpec 执行确认门并发布 Schema。
func newDevAppRobotConfigCommand(runner executor.Runner) *cobra.Command {
return NewLeafCommand(LeafSpec{
Use: "config",
Short: "创建或更新现有应用的机器人配置(upsert)",
Example: " dws dev app robot config --unified-app-id UNIFIED_APP_ID --name 小助手 --brief 审批助手 --dry-run --format json",
Tool: devAppRobotConfigUpsertTool,
Risk: LeafRiskWrite,
Safety: LeafSafetyWrite,
Safety: devAppSafetyWrite(),
// devapp 旧版写守卫为 guard-first:确认门先于参数校验。
ConfirmFirst: true,
Flags: []LeafFlag{
@@ -1081,9 +1068,6 @@ func newDevAppRobotConfigCommand(runner executor.Runner) *cobra.Command {
},
},
RunE: func(cmd *cobra.Command, args []string) error {
if err := cmdcore.ConfirmRisk(cmd, cmdcore.RiskWrite); err != nil {
return err
}
appID, err := requiredDevAppUnifiedID(cmd)
if err != nil {
return err
@@ -1109,8 +1093,7 @@ func newDevAppRobotEnableCommand(runner executor.Runner) *cobra.Command {
Short: "启用现有应用机器人能力(纯启用,无需配置字段)",
Example: " dws dev app robot enable --unified-app-id UNIFIED_APP_ID --dry-run --format json",
Tool: devAppRobotEnableTool,
Risk: LeafRiskWrite,
Safety: LeafSafetyWrite,
Safety: devAppSafetyWrite(),
// devapp 旧版写守卫为 guard-first:确认门先于参数校验。
ConfirmFirst: true,
Flags: []LeafFlag{
@@ -1138,8 +1121,7 @@ func newDevAppRobotOfflineCommand(runner executor.Runner) *cobra.Command {
Short: "停用现有应用的机器人能力",
Example: " dws dev app robot disable --unified-app-id UNIFIED_APP_ID --dry-run --format json",
Tool: devAppRobotOfflineTool,
Risk: LeafRiskWrite,
Safety: LeafSafetyWrite,
Safety: devAppSafetyWrite(),
// devapp 旧版写守卫为 guard-first:确认门先于参数校验。
ConfirmFirst: true,
Flags: []LeafFlag{
@@ -1252,8 +1234,7 @@ func newDevAppVersionCreateCommand(runner executor.Runner) *cobra.Command {
Short: "基于当前配置创建应用新版本",
Example: " dws dev app version create --unified-app-id UNIFIED_APP_ID --desc \"新增机器人能力\" --dry-run --format json",
Tool: devAppVersionCreateTool,
Risk: LeafRiskHighWrite,
Safety: LeafSafetyHighWrite,
Safety: devAppSafetyHighWrite(),
// devapp 旧版写守卫为 guard-first:确认门先于参数校验。
ConfirmFirst: true,
Flags: []LeafFlag{
@@ -1283,6 +1264,7 @@ func newDevAppVersionListCommand(runner executor.Runner) *cobra.Command {
Short: "分页查询应用版本列表",
Example: " dws dev app version list --unified-app-id UNIFIED_APP_ID --page-size 20 --format json",
Tool: devAppVersionListTool,
Safety: devAppSafetyRead(),
Flags: []LeafFlag{
{Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"},
},
@@ -1308,6 +1290,7 @@ func newDevAppVersionGetCommand(runner executor.Runner) *cobra.Command {
Short: "查询指定版本详情",
Example: " dws dev app version get --unified-app-id UNIFIED_APP_ID --version-id VERSION_ID --format json",
Tool: devAppVersionDetailTool,
Safety: devAppSafetyRead(),
Flags: []LeafFlag{
{Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"},
{Name: "version-id", Usage: "版本 ID (必填)", Bind: "versionId", Trim: true, Required: true, RequiredHint: "--version-id 为必填"},
@@ -1334,6 +1317,7 @@ func newDevAppVersionCheckApprovalCommand(runner executor.Runner) *cobra.Command
Short: "预检版本发布是否需要审批(不实际发布)",
Example: " dws dev app version check-approval --unified-app-id UNIFIED_APP_ID --version-id VERSION_ID --format json",
Tool: devAppVersionPublishTool,
Safety: devAppSafetyRead(),
Flags: []LeafFlag{
{Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"},
{Name: "version-id", Usage: "版本 ID (必填)", Bind: "versionId", Trim: true, Required: true, RequiredHint: "--version-id 为必填"},
@@ -1350,8 +1334,7 @@ func newDevAppVersionPublishCommand(runner executor.Runner) *cobra.Command {
Short: "发布指定版本(含高敏权限需 --confirmed-sensitive)",
Example: " dws dev app version publish --unified-app-id UNIFIED_APP_ID --version-id VERSION_ID --dry-run --format json",
Tool: devAppVersionPublishTool,
Risk: LeafRiskHighWrite,
Safety: LeafSafetyDestructive,
Safety: devAppSafetyDestructive(),
// devapp 旧版写守卫为 guard-first:确认门先于参数校验。
ConfirmFirst: true,
Flags: []LeafFlag{
@@ -1383,6 +1366,7 @@ func newDevAppVersionStatusCommand(runner executor.Runner) *cobra.Command {
Short: "查询版本发布/审批状态",
Example: " dws dev app version status --unified-app-id UNIFIED_APP_ID --version-id VERSION_ID --format json",
Tool: devAppVersionStatusTool,
Safety: devAppSafetyRead(),
Flags: []LeafFlag{
{Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"},
{Name: "version-id", Usage: "版本 ID (必填)", Bind: "versionId", Trim: true, Required: true, RequiredHint: "--version-id 为必填"},
@@ -1452,8 +1436,35 @@ func devAppMeta(tool string) func(*cobra.Command) {
// InterfaceDecl.Reason 的最终发布值。
const devAppCompositeInterfaceReason = "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command."
// devapp 不写共享 Safety 常量:Safety 元数据由各叶的 LeafSafety 枚举驱动
// (读叶留空由框架按 LeafRiskRead 推导),字段值全部来自 cmdcore 的 tier 填充。
// devapp 的 SafetySpec 直接对齐 Agent Runtime Schema。函数每次返回一个值
// 副本,避免共享可变状态;四个字段彼此独立,不从 effect 或 risk 推导。
func devAppSafetyRead() cli.SafetySpec {
return cli.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
}
}
func devAppSafetyWrite() cli.SafetySpec {
return cli.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
}
}
func devAppSafetyHighWrite() cli.SafetySpec {
return cli.SafetySpec{
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
}
}
func devAppSafetyDestructive() cli.SafetySpec {
return cli.SafetySpec{
Effect: "destructive", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
}
}
// devAppDryRun 是 devapp 全树共用的 dry_run 最终声明:本地拼装调用预览
// (invocation preview),dry-run 不发起任何远端读。
@@ -1974,7 +1985,7 @@ func transformDevAppScopeValues(raw string) (any, error) {
// 应用定位:写操作统一只用 --unified-app-id;dev app get 额外支持只读 --app-key。
// --name 定位已下线(列表搜索的 --name/--app-key 是过滤参数、不在此列)。
// 写叶确认门已声明化为 LeafRiskWrite/LeafRiskHighWrite + 框架 ConfirmRisk。
// 写叶确认门已声明化为 SafetySpec.Confirmation=user_required。
func devAppStringFlag(cmd *cobra.Command, name string) string {
value, _ := cmd.Flags().GetString(name)
+1 -1
View File
@@ -79,7 +79,7 @@ func TestDevAppLifecycleLeafWriteGuardAndArgs(t *testing.T) {
if err := cmd.Flags().Set("unified-app-id", "APP-9"); err != nil {
t.Fatal(err)
}
// 无 --yes / --dry-run:LeafRiskWrite 声明的 ConfirmRisk 必须拦下。
// 无 --yes / --dry-run:SafetySpec 的 user_required 确认必须拦下。
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("RunE() without --yes: want write-guard error, got nil")
}
@@ -21,8 +21,8 @@ import (
"github.com/spf13/cobra"
)
// Homology §1.1: confirmation facts are Contract-declared (Risk) or explicitly
// annotated (runtime_gate). Write-guard leaves must not rely on inference alone.
// Homology §1.1: confirmation facts are Contract-declared SafetySpec values or
// explicitly annotated runtime gates. Write leaves must not rely on inference.
func TestDevAppWriteLeavesDeclareOrAnnotateConfirmation(t *testing.T) {
root := newDevAppCommand(&captureRunner{})
want := map[string]struct{}{
@@ -58,16 +58,16 @@ func TestDevAppWriteLeavesDeclareOrAnnotateConfirmation(t *testing.T) {
walk(child, path)
continue
}
// devapp 全树已声明化:确认事实只能来自 Contract Risk,
// devapp 全树已声明化:确认事实只能来自 Contract SafetySpec,
// 任何 runtime_gate 标注都是回归(声明 > 标注,旧路径专用)。
if gate, hasGate := cli.RuntimeContractGate(child); hasGate {
t.Errorf("%s: unexpected runtime_gate %q; devapp leaves declare Contract Risk", path, gate)
t.Errorf("%s: unexpected runtime_gate %q; devapp leaves declare Contract SafetySpec", path, gate)
}
if _, ok := want[path]; !ok {
continue
}
if !cli.HasDeclaredOrAnnotatedConfirmation(child) {
t.Errorf("%s: missing Contract Risk or runtime_gate annotation", path)
t.Errorf("%s: missing Contract SafetySpec or runtime_gate annotation", path)
}
delete(want, path)
}
+3 -3
View File
@@ -343,7 +343,7 @@ func TestDevAppRobotAndVersionWritesRequireGuard(t *testing.T) {
if err == nil {
t.Fatal("Execute() error = nil, want write guard")
}
if !strings.Contains(err.Error(), "写操作") {
if !strings.Contains(err.Error(), "需要用户确认") {
t.Fatalf("error = %q, want write guard", err.Error())
}
if runner.last.Tool != "" {
@@ -1010,7 +1010,7 @@ func TestDevAppMemberAndSecurityRequireWriteGuard(t *testing.T) {
if err == nil {
t.Fatal("Execute() error = nil, want write guard")
}
if !strings.Contains(err.Error(), "写操作") {
if !strings.Contains(err.Error(), "需要用户确认") {
t.Fatalf("error = %q, want write guard", err.Error())
}
if runner.last.Tool != "" {
@@ -1064,7 +1064,7 @@ func TestEveryDevAppWriteCommandRequiresGuard(t *testing.T) {
if appErr.Reason != "confirmation_required" {
t.Fatalf("error reason = %q, want confirmation_required", appErr.Reason)
}
for _, marker := range []string{"写操作", "--dry-run", "--yes"} {
for _, marker := range []string{"需要用户确认", "--dry-run", "--yes"} {
if !strings.Contains(err.Error(), marker) {
t.Fatalf("error = %q, want %q write-guard marker", err.Error(), marker)
}
+9 -43
View File
@@ -16,6 +16,7 @@ package helpers
import (
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cmdcore"
)
@@ -23,7 +24,7 @@ import (
//
// 声明 vs 执行(框架规则,详见 RFC §5.0 / 同源文档 §1.2):
//
// - 声明 = LeafSpec 数据字段:Flags、Constraints、非空 Risk、ConstParams、
// - 声明 = LeafSpec 数据字段:Flags、Constraints、Safety、ConstParams、
// Use/Short/Long/Example。经 FromLeafSpec → cmdcore.NewCommand 注册并
// 嵌入 dws.schema.*。
// - 执行 = Validate / Call / RunE / PostMount。钩子消费已装配参数,不得
@@ -34,7 +35,7 @@ import (
// 装配与投影。默认派发走 MCP 直连;非 MCP 命令通过 Call 注入派发器。复杂
// 命令可用 RunE 逃生舱。
//
// 收敛纪律(Phase 2):flag 注册、有效值回退链、required/约束、Risk 写确认、
// 收敛纪律(Phase 2):flag 注册、有效值回退链、required/约束、Safety 确认、
// toolArgs 装配、Schema 投影与帮助渲染均在 internal/cmdcore;本文件只做
// LeafSpec→CommandSpec 映射与 dispatch 闭包。等价性由 catalog 漂移门禁与
// leaf/risk/约束单测共同兜底。
@@ -62,34 +63,6 @@ const (
// (cmdcore.FlagSpec 的别名,字段含义见 cmdcore 定义)。
type LeafFlag = cmdcore.FlagSpec
// LeafRisk 声明叶子命令的副作用等级(cmdcore.Risk 的别名)。取值与 shortcut
// 框架的 Risk 逐字一致。
type LeafRisk = cmdcore.Risk
const (
// LeafRiskRead 只读操作,从不提示。空值即视为 LeafRiskRead。
LeafRiskRead = cmdcore.RiskRead
// LeafRiskWrite 变更状态;未加 --yes 时提示确认。
LeafRiskWrite = cmdcore.RiskWrite
// LeafRiskHighWrite 破坏性/不可逆操作;未加 --yes 时提示确认。
LeafRiskHighWrite = cmdcore.RiskHighWrite
)
// LeafSafety 声明 Schema 安全元数据等级(cmdcore.Safety 的别名)。独立于
// Risk(运行时确认),描述操作对系统状态的影响程度;空值时框架从 Risk 推导。
type LeafSafety = cmdcore.Safety
const (
// LeafSafetyRead 只读操作:read/low/not_required/idempotent。
LeafSafetyRead = cmdcore.SafetyRead
// LeafSafetyWrite 可逆写操作:write/medium/user_required/unknown。
LeafSafetyWrite = cmdcore.SafetyWrite
// LeafSafetyHighWrite 难撤回写操作:write/high/user_required/unknown。
LeafSafetyHighWrite = cmdcore.SafetyHighWrite
// LeafSafetyDestructive 破坏性操作:destructive/high/user_required/unknown。
LeafSafetyDestructive = cmdcore.SafetyDestructive
)
// LeafConstraintKind 是跨 flag 关系约束的类型(cmdcore.ConstraintKind 的
// 别名)。取值与 shortcut 框架的 ConstraintKind 逐字一致。
type LeafConstraintKind = cmdcore.ConstraintKind
@@ -115,7 +88,6 @@ type LeafSchema = cmdcore.SchemaDecl
// SchemaDecl 嵌套类型别名(与 LeafSchema 配套使用)。
type (
LeafPositionalDecl = cmdcore.PositionalDecl
LeafSafetyDecl = cmdcore.SafetyDecl
LeafDryRunDecl = cmdcore.DryRunDecl
LeafInterfaceDecl = cmdcore.InterfaceDecl
LeafSelectionDecl = cmdcore.SelectionDecl
@@ -125,7 +97,7 @@ type (
// LeafSpec 是命令框架的 Leaf 声明门面(映射为 cmdcore.CommandSpec)。
//
// 声明面 = Schema 最终数据源:Flags(含 parameter Schema 字段)、Constraints、
// Risk、ConstParams、Use/Short/Long/Example、Schema(ToolSpec 各组)。
// Safety、ConstParams、Use/Short/Long/Example、Schema(ToolSpec 各组)。
// Schema 组装透传嵌入值,声明路径不再引入评审并行字段。
//
// 执行面(不算声明):Validate、Call、RunE、PostMount;Server/Tool 仅路由。
@@ -145,14 +117,9 @@ type LeafSpec struct {
// 仍放 Validate 钩子(钩子本身不是约束声明)。
Constraints []LeafConstraint
// Risk 声明副作用等级,驱动 ConfirmRisk(对齐 shortcut)。非空时嵌入
// dws.schema.risk 并合成 Schema.Safety。
Risk LeafRisk
// Safety 声明 Schema 安全元数据等级,独立于运行时 Risk。为空时框架从
// Risk 推导(Risk.SafetyDefault);显式设置时 Safety 驱动 Schema 元数据,
// Risk 仍驱动运行时确认行为。
Safety LeafSafety
// Safety 直接使用 Agent Runtime Schema 的安全模型。Confirmation 驱动
// 运行时确认,其余字段原样进入 Schema;字段之间不做机械推导。
Safety cli.SafetySpec
// ConfirmFirst 为 true 时确认门先于 required/约束/Validate 校验执行
//(devapp 旧版写守卫语义:写命令未带 --yes 时快速失败
@@ -185,7 +152,7 @@ type LeafSpec struct {
// NewLeafCommand 按 LeafSpec 构建叶子命令:经 FromLeafSpec 归一为
// cmdcore.CommandSpec 后交由统一构建器 cmdcore.NewCommand 编排(flag 注册、
// 约束声明检查、Schema 投影、帮助渲染、required/约束校验、Risk 写确认、
// 约束声明检查、Schema 投影、帮助渲染、required/约束校验、Safety 确认、
// toolArgs 装配)。本函数只保留 LeafSpec→CommandSpec 的映射与 MCP dispatch
// 闭包(callMCPTool/OnServer/Call)。所有 LeafSpec 命令(含 devapp 全部叶子)
// 由此统一流经 cmdcore 单一 spec 路径。
@@ -194,7 +161,7 @@ func NewLeafCommand(spec LeafSpec) *cobra.Command {
}
// FromLeafSpec 把 LeafSpec 归一为统一的 cmdcore.CommandSpec。契约字段
// (Flags/Constraints/Risk)与编排钩子(Validate/PostMount/RunE)直接透传;
// (Flags/Constraints/Safety)与编排钩子(Validate/PostMount/RunE)直接透传;
// dispatch 收敛为一个闭包:Call 优先,其次显式 Server 路由,最后按 product
// 自动路由。RunE 逃生舱存在时不设 Dispatch(与旧行为一致)。
func FromLeafSpec(spec LeafSpec) cmdcore.CommandSpec {
@@ -205,7 +172,6 @@ func FromLeafSpec(spec LeafSpec) cmdcore.CommandSpec {
Example: spec.Example,
Flags: spec.Flags,
Constraints: spec.Constraints,
Risk: spec.Risk,
Safety: spec.Safety,
ConfirmFirst: spec.ConfirmFirst,
ConstParams: spec.ConstParams,
+48 -26
View File
@@ -23,6 +23,7 @@ import (
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cmdcore"
)
@@ -479,13 +480,34 @@ func TestCrossPlatformCoverageLeafSpecCorePaths(t *testing.T) {
var errTransformTest = errors.New("transform failed")
func leafRiskSpec(risk LeafRisk, called *bool) LeafSpec {
func leafTestReadSafety() cli.SafetySpec {
return cli.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
}
}
func leafTestWriteSafety() cli.SafetySpec {
return cli.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
}
}
func leafTestDestructiveSafety() cli.SafetySpec {
return cli.SafetySpec{
Effect: "destructive", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
}
}
func leafSafetySpec(safety cli.SafetySpec, called *bool) LeafSpec {
return LeafSpec{
Use: "danger",
Short: "危险",
Tool: "danger_thing",
Risk: risk,
Flags: []LeafFlag{{Name: "id", Usage: "ID"}},
Use: "danger",
Short: "危险",
Tool: "danger_thing",
Safety: safety,
Flags: []LeafFlag{{Name: "id", Usage: "ID"}},
Call: func(*cobra.Command, string, map[string]any) error {
*called = true
return nil
@@ -493,10 +515,10 @@ func leafRiskSpec(risk LeafRisk, called *bool) LeafSpec {
}
}
func leafRiskRun(t *testing.T, risk LeafRisk, stdin string, args ...string) (bool, error) {
func leafSafetyRun(t *testing.T, safety cli.SafetySpec, stdin string, args ...string) (bool, error) {
t.Helper()
called := false
cmd := NewLeafCommand(leafRiskSpec(risk, &called))
cmd := NewLeafCommand(leafSafetySpec(safety, &called))
// 注入根级 --yes/--dry-run 持久 flag,模拟真实根命令。
cmd.PersistentFlags().Bool("yes", false, "")
cmd.PersistentFlags().Bool("dry-run", false, "")
@@ -509,15 +531,15 @@ func leafRiskRun(t *testing.T, risk LeafRisk, stdin string, args ...string) (boo
return called, cmd.Execute()
}
// TestCrossPlatformCoverageLeafRiskUnderRootGlobalFlags mirrors the production
// TestCrossPlatformCoverageLeafSafetyUnderRootGlobalFlags mirrors the production
// shape: the ROOT command owns the global --yes/--dry-run persistent flags and
// the leaf is a child, so the inherited / root-persistent lookup path is what
// resolves them (leafRiskRun registers them on the leaf itself, where
// resolves them (leafSafetyRun registers them on the leaf itself, where
// cmd.Root() == cmd).
func TestCrossPlatformCoverageLeafRiskUnderRootGlobalFlags(t *testing.T) {
func TestCrossPlatformCoverageLeafSafetyUnderRootGlobalFlags(t *testing.T) {
run := func(globalFlag string, stdin string) (bool, error) {
called := false
leaf := NewLeafCommand(leafRiskSpec(LeafRiskHighWrite, &called))
leaf := NewLeafCommand(leafSafetySpec(leafTestDestructiveSafety(), &called))
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
@@ -553,20 +575,20 @@ func TestCrossPlatformCoverageLeafRiskUnderRootGlobalFlags(t *testing.T) {
}
}
func TestCrossPlatformCoverageLeafRiskReadNeverPrompts(t *testing.T) {
func TestCrossPlatformCoverageLeafSafetyReadNeverPrompts(t *testing.T) {
// 只读:无 stdin 也直接派发。
if called, err := leafRiskRun(t, LeafRiskRead, "", "--id", "x"); err != nil || !called {
t.Fatalf("read risk err = %v called = %v", err, called)
if called, err := leafSafetyRun(t, leafTestReadSafety(), "", "--id", "x"); err != nil || !called {
t.Fatalf("read safety err = %v called = %v", err, called)
}
// 空 Risk 等价只读。
if called, err := leafRiskRun(t, "", "", "--id", "x"); err != nil || !called {
t.Fatalf("empty risk err = %v called = %v", err, called)
// 空 Safety 保留只读默认。
if called, err := leafSafetyRun(t, cli.SafetySpec{}, "", "--id", "x"); err != nil || !called {
t.Fatalf("empty safety err = %v called = %v", err, called)
}
}
func TestCrossPlatformCoverageLeafRiskWriteConfirmation(t *testing.T) {
func TestCrossPlatformCoverageLeafSafetyWriteConfirmation(t *testing.T) {
// 拒绝:不派发,返回取消错误。
called, err := leafRiskRun(t, LeafRiskWrite, "no\n", "--id", "x")
called, err := leafSafetyRun(t, leafTestWriteSafety(), "no\n", "--id", "x")
if called {
t.Fatal("declined write should not dispatch")
}
@@ -574,22 +596,22 @@ func TestCrossPlatformCoverageLeafRiskWriteConfirmation(t *testing.T) {
t.Fatalf("declined write err = %v", err)
}
// 同意:派发。
if called, err := leafRiskRun(t, LeafRiskWrite, "yes\n", "--id", "x"); err != nil || !called {
if called, err := leafSafetyRun(t, leafTestWriteSafety(), "yes\n", "--id", "x"); err != nil || !called {
t.Fatalf("confirmed write err = %v called = %v", err, called)
}
// 高危同样走确认链。
if called, err := leafRiskRun(t, LeafRiskHighWrite, "y\n", "--id", "x"); err != nil || !called {
if called, err := leafSafetyRun(t, leafTestDestructiveSafety(), "y\n", "--id", "x"); err != nil || !called {
t.Fatalf("confirmed high-write err = %v called = %v", err, called)
}
}
func TestCrossPlatformCoverageLeafRiskYesAndDryRunBypass(t *testing.T) {
func TestCrossPlatformCoverageLeafSafetyYesAndDryRunBypass(t *testing.T) {
// --yes 跳过提示直接派发(无 stdin)。
if called, err := leafRiskRun(t, LeafRiskHighWrite, "", "--id", "x", "--yes"); err != nil || !called {
if called, err := leafSafetyRun(t, leafTestDestructiveSafety(), "", "--id", "x", "--yes"); err != nil || !called {
t.Fatalf("--yes bypass err = %v called = %v", err, called)
}
// --dry-run 跳过提示直接派发(无 stdin)。
if called, err := leafRiskRun(t, LeafRiskWrite, "", "--id", "x", "--dry-run"); err != nil || !called {
if called, err := leafSafetyRun(t, leafTestWriteSafety(), "", "--id", "x", "--dry-run"); err != nil || !called {
t.Fatalf("--dry-run bypass err = %v called = %v", err, called)
}
}
@@ -618,7 +640,7 @@ func TestCrossPlatformCoverageLeafYesFlagAndIntEdges(t *testing.T) {
// 写风险但全局无 --yes flag 注册:leafYesFlag 各 getter 均报错走兜底 false,
// 于是进入提示;stdin "no" 取消。
called := false
noYes := NewLeafCommand(leafRiskSpec(LeafRiskWrite, &called))
noYes := NewLeafCommand(leafSafetySpec(leafTestWriteSafety(), &called))
noYes.SilenceErrors = true
noYes.SilenceUsage = true
noYes.SetIn(strings.NewReader("no\n"))
+3 -4
View File
@@ -97,13 +97,12 @@ func TestLeafSpecCallDoesNotAssembleBusinessParams(t *testing.T) {
}
// TestLeafSpecNoLegacyWriteGuard pins the migration end-state: devapp leaves
// declare Contract Risk (ConfirmRisk by the framework, or a manual call inside
// a RunE escape hatch); the legacy devAppRequireWriteGuard helper is gone and
// must not be reintroduced in LeafSpec Validate hooks.
// declare SafetySpec.Confirmation and the framework applies it even around a
// RunE escape hatch; the legacy devAppRequireWriteGuard helper is gone.
func TestLeafSpecNoLegacyWriteGuard(t *testing.T) {
for _, call := range leafSpecCompositeLits(t) {
if validate := fieldExpr(call, "Validate"); validate != nil && astCallsIdent(validate, "devAppRequireWriteGuard") {
t.Errorf("%s: LeafSpec must not call devAppRequireWriteGuard; declare Risk instead", call.pos)
t.Errorf("%s: LeafSpec must not call devAppRequireWriteGuard; declare Safety.Confirmation instead", call.pos)
}
}
}
+27 -3
View File
@@ -17,15 +17,16 @@ import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cmdcore"
)
// adapter.go bridges the Shortcut framework toward the unified cmdcore typed
// spec. FromShortcut maps a Shortcut's SHARED BASE (flags, constraints, risk,
// spec. FromShortcut maps a Shortcut's SHARED BASE (flags, constraints, safety,
// help identity) into a cmdcore.CommandSpec so both frameworks can eventually
// share one flag + constraint + risk base.
// share one flag + constraint + safety base.
//
// Scope (Phase 2): this is the typed seam only — it is NOT wired into the live
// mount() path. The Shortcut runtime keeps its own mount/RuntimeContext/Execute
@@ -67,7 +68,7 @@ func FromShortcut(s Shortcut) cmdcore.CommandSpec {
Long: shortcutIntentProse(s),
Flags: fromShortcutFlags(s.Flags),
Constraints: fromShortcutConstraints(s.Constraints),
Risk: cmdcore.Risk(s.risk()),
Safety: shortcutSafetySpec(s.risk()),
// Multi-step body: cmdcore stays backend-agnostic, so the shortcut's own
// RuntimeContext — which owns CallMCPData/CallMCPWriteData/Output — is
// built here from the Ctx's command.
@@ -81,6 +82,29 @@ func FromShortcut(s Shortcut) cmdcore.CommandSpec {
}
}
// shortcutSafetySpec is the temporary compatibility boundary while the live
// Shortcut framework still owns its legacy Risk enum. cmdcore and Leaf do not
// retain that enum: the adapter expands it once into the existing Schema model.
func shortcutSafetySpec(risk Risk) cli.SafetySpec {
switch risk {
case RiskWrite:
return cli.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
}
case RiskHighWrite:
return cli.SafetySpec{
Effect: "destructive", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
}
default:
return cli.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
}
}
}
// shortcutIntentProse returns just the intent/description prose that precedes
// the 参数约束 section in shortcutLongHelp, so the constraint section is rendered
// exactly once (by cmdcore.NewCommand).
+6 -4
View File
@@ -58,8 +58,9 @@ func TestCrossPlatformCoverageFromShortcutMapsSharedBase(t *testing.T) {
if strings.Contains(cs.Long, "参数约束") {
t.Fatalf("Long must not pre-render 参数约束: %q", cs.Long)
}
if cs.Risk != cmdcore.RiskHighWrite {
t.Fatalf("risk = %q, want high-risk-write", cs.Risk)
if cs.Safety.Effect != "destructive" || cs.Safety.Risk != "high" ||
cs.Safety.Confirmation != "user_required" || cs.Safety.Idempotency != "unknown" {
t.Fatalf("adapter safety = %#v, want destructive/high/user_required/unknown", cs.Safety)
}
if cs.Orchestrate == nil {
t.Fatal("multi-step Execute must project into Orchestrate")
@@ -165,7 +166,8 @@ func TestCrossPlatformCoverageFromShortcutEmpty(t *testing.T) {
if cs.Constraints != nil {
t.Fatalf("empty constraints = %#v, want nil", cs.Constraints)
}
if cs.Risk != cmdcore.RiskRead {
t.Fatalf("default risk = %q, want read", cs.Risk)
if cs.Safety.Effect != "read" || cs.Safety.Risk != "low" ||
cs.Safety.Confirmation != "not_required" || cs.Safety.Idempotency != "idempotent" {
t.Fatalf("default safety = %#v, want read/low/not_required/idempotent", cs.Safety)
}
}
@@ -1,8 +1,9 @@
#!/bin/sh
set -eu
# Ensure catalog confirmation=user_required exactly matches metadata
# runtime_gate != none across schema_hints/metadata/*.json.
# Ensure catalog confirmation=user_required exactly matches executable truth:
# typed cmdcore Contract SafetySpec declarations plus migration-only metadata
# runtime_gate != none entries.
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
cd "$ROOT"
@@ -27,7 +28,17 @@ jq -r '
| to_entries[]
| select((.value.runtime_gate // "none") != "none")
| .key
' "$metadata_dir"/*.json | sort -u >"$tmp/truth_gated"
' "$metadata_dir"/*.json >"$tmp/metadata_gated"
jq -r '
.tools
| to_entries[]
| select(.value.confirmation == "user_required")
| select(.value.field_provenance.confirmation.source == "cmdcore.contract")
| .key
' "$catalog" >"$tmp/contract_gated"
cat "$tmp/metadata_gated" "$tmp/contract_gated" | sort -u >"$tmp/truth_gated"
jq -r '
.tools
@@ -37,8 +48,8 @@ jq -r '
' "$catalog" | sort >"$tmp/catalog_required"
if ! cmp -s "$tmp/truth_gated" "$tmp/catalog_required"; then
printf '%s\n' 'catalog confirmation=user_required differs from schema_hints/metadata runtime_gate!=none set' >&2
printf '%s\n' 'update internal/cli/schema_hints/metadata/<product>.json runtime_gate/confirmation, then regenerate schema' >&2
printf '%s\n' 'catalog confirmation=user_required differs from Contract SafetySpec + metadata runtime_gate truth' >&2
printf '%s\n' 'declare cmdcore Safety.Confirmation or update migration-only metadata runtime_gate, then regenerate schema' >&2
diff -u "$tmp/truth_gated" "$tmp/catalog_required" || true
exit 1
fi
@@ -63,6 +74,7 @@ jq -r --slurpfile catalog "$catalog" '
| .value as $gate
| $tools[$canonical] as $tool
| select($tool != null)
| select($tool.field_provenance.confirmation.source != "cmdcore.contract")
| select(
if $gate == "none" then
$tool.confirmation != "not_required" or $tool.risk == "high" or $tool.effect == "destructive"
+4 -1
View File
@@ -144,7 +144,10 @@ if ! jq -e --arg registry_count "$registry_count" '
elif .interface_mode == "composite" then
.interface_ref == null and ((.interface_reason // "") | length) > 0
else false end) and
(((.agent_source_refs // []) | map(test("schema_hints/selection/")) | any))
(
((.agent_source_refs // []) | map(test("schema_hints/selection/")) | any) or
.field_provenance.agent_summary.source == "cmdcore.contract"
)
)
' "$catalog" >/dev/null; then
printf '%s\n' 'schema tools must have complete Agent summary/effect/safety metadata' >&2