refactor(cmdcore): align leaf safety with schema contract
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 文件
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 门禁规划
|
||||
|
||||
|
||||
@@ -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 | 身份解析到活契约 |
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user