From ff65f80c9821a6b1c2e9b5c0496f3367d7c3a77d Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 14:25:15 +0800 Subject: [PATCH 01/28] refactor(oa): add strict shortcut response helpers --- internal/shortcut/oa/common.go | 327 +++++++++++++++++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 internal/shortcut/oa/common.go diff --git a/internal/shortcut/oa/common.go b/internal/shortcut/oa/common.go new file mode 100644 index 00000000..2c994b9c --- /dev/null +++ b/internal/shortcut/oa/common.go @@ -0,0 +1,327 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package oa + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +const oaCompositeReason = "Reviewed OA Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable identity checks, truthful pagination, output projection, and confirmation; no single MCP interface represents the complete command contract." + +type oaPageEvidence struct { + Known bool + HasMore bool + Next string +} + +func oaCollectionResult(collection, description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前响应中的有效审批记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格验证后的 OA 审批业务记录","additionalProperties":true}},"complete":{"type":"boolean","description":"服务端分页证据是否证明结果完整"}},"required":["count",%q,"complete"],"additionalProperties":true}`, + description, collection, description, collection, + )), + SensitivePaths: []string{collection + ".title", collection + ".originatorName", collection + ".formValueVOS", collection + ".userId"}, + } +} + +func oaObjectResult(description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格验证且身份匹配的 OA 审批对象","additionalProperties":true}},"required":["value"],"additionalProperties":false}`, + description, + )), + SensitivePaths: []string{"value.title", "value.processInstanceTitle", "value.formValueVOS", "value.originatorUserid", "value.operationRecords", "value.userId"}, + } +} + +func oaReadSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} + +func oaWriteSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"} +} + +func oaContract(command, description, intent string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl { + name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_") + cliPath := "oa " + command + return corecmd.ContractDecl{ + Description: description, + Result: result, + Pagination: pagination, + Parameters: params, + Identity: contract.ToolIdentitySpec{ + ProductID: "oa", Name: name, CanonicalPath: "oa." + name, + CLIPath: cliPath, PrimaryCLIPath: cliPath, + }, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: oaCompositeReason}, + Selection: contract.SelectionSpec{ + AgentSummary: description, + UseWhen: []string{intent}, + AvoidWhen: []string{"只需原始 OA MCP 响应时使用 oa approval 原子命令;缺少稳定实例、任务或表单身份时不要猜测"}, + Examples: examples, + }, + } +} + +func oaPagePagination(parameter string) *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, + CursorParameter: parameter, + MetaPath: contract.PaginationMetaPath, + EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + +func oaResponseError(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + +// oaRequireSuccess accepts only the two success encodings observed from the +// OA backend. Missing, false, null, and all other encodings fail closed. +func oaRequireSuccess(data map[string]any, operation string) error { + if len(data) == 0 { + return oaResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明 OA 调用成功或结果确实为空") + } + switch value := data["success"].(type) { + case bool: + if value { + return nil + } + case string: + if value == "true" { + return nil + } + } + if _, present := data["success"]; !present { + return oaResponseError(operation, "missing_success", "OA 响应缺少 success 业务状态") + } + message := oaFirstString(data, "errorMessage", "errorMsg", "message", "error") + if message == "" { + message = "OA 服务未明确返回成功状态" + } + return oaResponseError(operation, "remote_failure", message) +} + +func oaLookup(object map[string]any, path string) (any, bool) { + var current any = object + for _, segment := range strings.Split(path, ".") { + mapped, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = mapped[segment] + if !ok { + return nil, false + } + } + return current, true +} + +func oaRequireObject(data map[string]any, operation, path string) (map[string]any, error) { + if err := oaRequireSuccess(data, operation); err != nil { + return nil, err + } + value, present := oaLookup(data, path) + if !present || value == nil { + return nil, oaResponseError(operation, "missing_result", fmt.Sprintf("成功响应缺少非空 %s 对象", path)) + } + object, ok := value.(map[string]any) + if !ok || len(object) == 0 { + return nil, oaResponseError(operation, "malformed_result", fmt.Sprintf("响应 %s 应为非空对象,实际为 %T", path, value)) + } + return object, nil +} + +func oaRequireCollection(data map[string]any, operation, path string) ([]map[string]any, error) { + if err := oaRequireSuccess(data, operation); err != nil { + return nil, err + } + value, present := oaLookup(data, path) + if !present { + return nil, oaResponseError(operation, "missing_collection", fmt.Sprintf("成功响应缺少 %s 数组;不能把未知响应结构当作空结果", path)) + } + raw, ok := value.([]any) + if !ok { + return nil, oaResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value)) + } + items := make([]map[string]any, 0, len(raw)) + for index, item := range raw { + object, ok := item.(map[string]any) + if !ok || len(object) == 0 { + return nil, oaResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index)) + } + items = append(items, object) + } + return items, nil +} + +func oaFirstString(object map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := object[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func oaScalarString(value any) string { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case json.Number: + return typed.String() + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case int: + return strconv.Itoa(typed) + case int64: + return strconv.FormatInt(typed, 10) + default: + return "" + } +} + +func oaIdentity(object map[string]any, keys ...string) string { + for _, key := range keys { + if value := oaScalarString(object[key]); value != "" { + return value + } + } + return "" +} + +func oaRequireIdentity(object map[string]any, operation, expected string, keys ...string) error { + actual := oaIdentity(object, keys...) + if actual == "" { + return oaResponseError(operation, "missing_stable_id", "OA 业务对象缺少稳定 ID") + } + if expected != "" && actual != expected { + return oaResponseError(operation, "identity_mismatch", "OA 业务对象 ID 与请求目标不一致") + } + return nil +} + +func oaProjectForms(data map[string]any, operation, path string) ([]map[string]any, error) { + items, err := oaRequireCollection(data, operation, path) + if err != nil { + return nil, err + } + forms := make([]map[string]any, 0, len(items)) + for index, item := range items { + code := oaIdentity(item, "processCode") + name := oaFirstString(item, "processName", "name") + if code == "" || name == "" { + return nil, oaResponseError(operation, "malformed_item", fmt.Sprintf("表单结果第 %d 项缺少 processCode 或名称", index)) + } + row := map[string]any{"processCode": code, "name": name} + if icon := oaFirstString(item, "processIconUrl", "iconUrl"); icon != "" { + row["iconUrl"] = icon + } + forms = append(forms, row) + } + return forms, nil +} + +func oaProjectInstances(data map[string]any, operation, path string) ([]map[string]any, error) { + items, err := oaRequireCollection(data, operation, path) + if err != nil { + return nil, err + } + instances := make([]map[string]any, 0, len(items)) + for index, item := range items { + id := oaIdentity(item, "processInstanceId") + if id == "" { + return nil, oaResponseError(operation, "missing_item_identity", fmt.Sprintf("审批结果第 %d 项缺少 processInstanceId", index)) + } + row := map[string]any{"processInstanceId": id} + if title := oaFirstString(item, "title", "processInstanceTitle"); title != "" { + row["title"] = title + } + if status := oaScalarString(item["status"]); status != "" { + row["status"] = status + } + for _, key := range []string{"processCreateTime", "createTime"} { + if value, present := item[key]; present && value != nil { + row["createTime"] = value + break + } + } + instances = append(instances, row) + } + return instances, nil +} + +func oaHasMorePage(result map[string]any, operation string, itemCount, currentPage int) (oaPageEvidence, error) { + raw, present := result["hasMore"] + if !present { + // Reviewed OA zero-page encoding: get_done_tasks, + // get_submitted_instances, and get_noticed_instances omit hasMore only + // when result.values is an explicit empty array. No other missing + // pagination shape is accepted. + if itemCount == 0 { + return oaPageEvidence{Known: true}, nil + } + return oaPageEvidence{}, oaResponseError(operation, "missing_pagination", "非空审批列表缺少 hasMore,无法证明结果完整或提供续页凭据") + } + hasMore, ok := raw.(bool) + if !ok { + return oaPageEvidence{}, oaResponseError(operation, "malformed_pagination", "OA 响应 hasMore 应为布尔值") + } + page := oaPageEvidence{Known: true, HasMore: hasMore} + if hasMore { + if currentPage <= 0 { + return oaPageEvidence{}, oaResponseError(operation, "invalid_page", "当前页码无效,无法生成下一页凭据") + } + page.Next = strconv.Itoa(currentPage + 1) + } + return page, nil +} + +func outputOAPage(rt *shortcut.RuntimeContext, collection string, items []map[string]any, page oaPageEvidence) error { + if !page.Known { + return oaResponseError("oa/pagination", "missing_pagination", "响应缺少分页终态证据") + } + payload := map[string]any{"count": len(items), collection: items, "complete": !page.HasMore} + if !output.UsesUnifiedResult(rt.Command()) { + if page.HasMore { + payload["nextPage"] = page.Next + } + return rt.Output(payload) + } + pagination, err := output.NewPagination(!page.HasMore, page.Next) + if err != nil { + return oaResponseError("oa/pagination", "invalid_pagination", err.Error()) + } + meta := &output.Meta{Count: output.NewCount(len(items)), Pagination: pagination} + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) +} + +func validateOAPage(page, limit int) error { + if page <= 0 { + return apperrors.NewValidation("--page 必须大于 0") + } + if limit <= 0 || limit > 100 { + return apperrors.NewValidation("--limit 必须在 1 到 100 之间") + } + return nil +} From ba9c0f624e1c0d80f79db0c059996ca5aabe2479 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 14:58:27 +0800 Subject: [PATCH 02/28] feat(shortcut): harden OA workflows and availability --- docs/shortcut-downstream-requirements/oa.md | 90 +++ docs/shortcut-public-catalog.json | 51 +- internal/cli/command_path_fallbacks.json | 4 +- .../cli/command_path_fallbacks_generated.go | 4 +- internal/cli/command_path_fallbacks_test.go | 2 +- .../builtin/oa_semantic_catalog_test.go | 83 ++ internal/shortcut/oa/common.go | 85 +++ .../shortcut/oa/list_forms_projection_test.go | 82 -- internal/shortcut/oa/oa.go | 721 +++++------------- .../oa_strict_cross_platform_coverage_test.go | 228 ++++++ internal/shortcut/oa/smart.go | 269 +++++++ internal/shortcut/public_catalog_generated.go | 3 - internal/shortcut/semantic_catalog.go | 4 + internal/shortcut/semantic_catalog_oa.json | 85 +++ internal/shortcut/smart/approve.go | 277 ------- internal/shortcut/smart/done_approvals.go | 109 --- internal/shortcut/smart/my_initiated.go | 247 ------ internal/shortcut/smart/pending_approvals.go | 128 ---- scripts/gen_shortcut_public_catalog.py | 1 + 19 files changed, 1055 insertions(+), 1418 deletions(-) create mode 100644 docs/shortcut-downstream-requirements/oa.md create mode 100644 internal/shortcut/builtin/oa_semantic_catalog_test.go delete mode 100644 internal/shortcut/oa/list_forms_projection_test.go create mode 100644 internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go create mode 100644 internal/shortcut/oa/smart.go create mode 100644 internal/shortcut/semantic_catalog_oa.json delete mode 100644 internal/shortcut/smart/approve.go delete mode 100644 internal/shortcut/smart/done_approvals.go delete mode 100644 internal/shortcut/smart/my_initiated.go delete mode 100644 internal/shortcut/smart/pending_approvals.go diff --git a/docs/shortcut-downstream-requirements/oa.md b/docs/shortcut-downstream-requirements/oa.md new file mode 100644 index 00000000..3e5cdf11 --- /dev/null +++ b/docs/shortcut-downstream-requirements/oa.md @@ -0,0 +1,90 @@ +# OA Shortcut 下游能力需求 + +基线:`c0b286c1e10b6fcebdff4ed522d3b1cf30549866`;Comparator:`lark-cli 1.0.87`;审计日期:2026-08-18。 + +本文只记录脱敏的能力标签与聚合事实。没有资源 ID、业务标题、姓名、邮箱、精确业务时间、trace/request ID 或原始响应。 + +## 结论与 surface + +- 源码 Shortcut:10。 +- 公开:4(`+list-executed`、`+list-submitted`、`+my-initiated`、`+search-forms`)。 +- 隐藏:6,其中 unavailable 5;`+done-approvals` 是 available 但隐藏的兼容入口。 +- 所有 10 条均声明 `Contract`、`Safety`、`Result` 并启用 unified output;写入口在首次远端调用前由 Runtime 确认门禁阻断。 + +| Exact Shortcut 证明 | 标签 | 聚合事实 | +|---|---|---| +| `+list-executed` | PASS | 已知非空 7;保证零命中 0;分页终态明确 | +| `+list-submitted` | PASS | 已知非空 14;保证零命中 0;分页终态明确 | +| `+my-initiated` | PASS | 已知非空 14;保证零命中 0;不再回退原始响应 | +| `+search-forms` | PASS | 已知非空 2;保证零命中 0;显式完整结果 | +| `+done-approvals`(隐藏兼容) | PASS | 已知非空 7;首屏终态明确 | +| `+list-forms` fail-closed | PASS | 非空页缺 continuation 被拒绝;两个 cursor 的 93/93 项重叠 92 项 | +| `+approve-by` 确认门禁单测 | PASS | 确认前 0 调用;确认后固定为读待办、读任务、写审批、精确任务读回 | + +未列为 PASS 的能力没有安全的已知非空或可逆写 fixture,保持 unavailable;显式空结果不替代非空证明。 + +## Lark 1.0.87 用户任务映射 + +Lark approval 共 14 个任务;映射按用户结果而不是命令名判断。 + +| Lark 任务 | DWS 当前路线 | 分类 | 临时处置 | +|---|---|---|---| +| approvals get | `oa approval form-schema` | routed | 使用原子读取 | +| approvals search | `oa +search-forms` | covered | 公开 Shortcut | +| instances initiated | `oa +list-submitted` / `+my-initiated` | covered | 公开 Shortcut | +| instances get | `oa approval detail` | routed | 使用原子读取 | +| instances cancel | `oa approval revoke` | routed | 高风险原子写;无可逆 fixture,不新增公开 Shortcut | +| instances cc | `oa approval oa-cc-noticer` | routed | 无安全 fixture,保持原子入口 | +| instances create | `oa approval forecast-process` + `create-instance` | routed | 无可清理实例 fixture,不新增公开 Shortcut | +| tasks query | 四类列表与 `oa approval tasks` | partial | 已办/已发起公开;待办/抄送/任务 unavailable | +| tasks add_sign | `oa approval append-task` | routed | 无安全 fixture,保持原子入口 | +| tasks approve | `oa approval approve` / `+approve-by` | partial | Shortcut unavailable;原子写需用户确认 | +| tasks reject | `oa approval reject` | routed | 无安全 fixture,保持原子入口 | +| tasks remind | `ding-info` 后串联 DING 发送 | downstream_required | 缺少一个可验证的稳定催办回执合同 | +| tasks rollback | `revert-activities` + `revert-task` | routed | 无可恢复 fixture,保持原子入口 | +| tasks transfer | `redirect-task` | routed | 无安全 fixture,保持原子入口 | + +DWS 现有原子能力还覆盖审批记录、表单 Schema、流程预测、评论、可回退节点等产品原生任务。它们当前应由精确原子命令承担;在取得稳定 Result、fixture 与写后验证前,不为增加数量而包装 Shortcut。 + +## `DS-oa-001`:可证明前进的可见表单分页 + +- 优先级:P1;类型:adapter defect / contract insufficient;Owner:OA MCP adapter 与业务接口。 +- 影响:`oa +list-forms` 当前 unavailable。Exact Shortcut 与同参数 atomic/raw 均显示非空列表,但响应没有 `hasMore`、`nextCursor` 或等价 continuation;改变 cursor 后集合高度重叠,已排除 Shortcut 投影错误。 +- 所需合同: + - 请求明确 `cursor`(首次为 0)与 `pageSize`(1–100)。 + - 成功必须是布尔 `success=true`,并包含显式 `result.processCodeList` 数组。 + - 每个元素必须包含唯一、非空 `processCode` 和非空名称。 + - 必须返回布尔 `hasMore`;`hasMore=true` 时返回非空、与当前值不同且单调前进的 `nextCursor`;`hasMore=false` 时不得返回非空 continuation。 + - cursor 不得被忽略;重复页、回退游标与循环必须返回稳定非成功错误。 + - 明确页面大小上限、总页/总项边界和合法空首页语义。 +- 验收:Exact Shortcut 与 atomic/raw 对同一脱敏 fixture 逐页得到不重复稳定身份集合;已知非空和保证零命中均通过;缺失、重复、循环和错型 continuation 都返回非零。 +- 临时处置:`+list-forms` 保持 hidden/unavailable;需要精确定位定义时使用公开 `+search-forms`。 + +## `DS-oa-002`:可重复的审批读写 E2E fixture + +- 优先级:P1;类型:tenant-or-fixture;Owner:OA 测试租户/权限与测试数据平台。 +- 影响:待办、抄送、任务、同意/拒绝/撤回/转交/加签/退回/催办等用户任务不能完成安全 live proof。 +- 所需 fixture: + - 隔离测试身份与专用审批定义,可创建至少一条待办、一条抄送和一条已办实例。 + - 创建回执返回稳定实例 ID;任务查询返回稳定 taskId 和明确任务状态。 + - 写操作可在隔离资源上完成,且具有确定的恢复、撤回或自动到期路径。 + - fixture 权限最小化,支持在测试结束后验证零残留;不得复用真实业务审批。 +- 验收:每个 list/search 同时通过已知非空与保证零命中;每个写在确认前 0 调用,确认后只写一次,按同一实例和 taskId 精确读回终态并完成清理。 +- 临时处置:相关 Shortcut 保持 hidden/unavailable;精确原子写仅在用户明确确认并提供真实目标时执行,不记为自动化 E2E PASS。 + +## `DS-oa-003`:审批写入的稳定终态回执与读回语义 + +- 优先级:P1;类型:contract insufficient;Owner:OA 业务服务与 MCP adapter。 +- 影响:即使写调用返回 nominal success,也难以区分已提交、处理中、已生效与 commit-unknown,阻止安全公开 `+approve-by` 及其他复合写任务。 +- 所需合同: + - 写回执必须包含 `success=true`、稳定 `processInstanceId`、稳定 `taskId`、终态或 typed pending 状态及操作类型。 + - `success=false`、缺 success、空响应、错型 ID、业务错误码冲突必须非零;不得以请求 echo 作为成功证据。 + - 同一 taskId 的查询必须提供明确 pending/approved/rejected/transferred/rolled_back 状态,或明确从待处理集合移除的强一致性窗口。 + - 异步操作返回可执行查询动作、轮询边界和 terminal failure;超时保留 commit-unknown,禁止自动重试非幂等写。 + - 批量/多步操作提供逐项 ledger;任一失败不得返回整体 success。 +- 验收:Exact Shortcut 写入后以相同实例与 taskId 读回请求关键状态;回执缺失、读回不一致、异步超时和部分失败均机器可检测;安全 fixture 完成恢复与零残留。 +- 临时处置:`+approve-by` 保持 hidden/unavailable;当前实现即使写调用成功,读回无法证明时也返回非成功且标记 execution started。 + +## 超越 Lark 的机会 + +在上述两个下游合同和安全 fixture 就绪后,可把 DingTalk 原生的“流程预测→精确实例创建→节点/任务读回→操作记录审计”组合成一条带逐步 ledger 的 Golden Route。它应保持每一步稳定身份、支持 typed pending/partial/commit-unknown,并在创建后的任一步失败时给出可执行恢复动作;在此之前不发布该复合 Shortcut。 diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index a13ab19a..8499b8bc 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -3602,53 +3602,44 @@ "availability": "available" }, { - "suite": "read", - "service": "oa", - "command": "+list-cc", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "oa", "command": "+list-executed", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、result.values、稳定 processInstanceId 与 hasMore,并把下一页凭据发布到统一分页元数据;真实非空和保证零命中均已执行。", + "availability": "available" }, { - "suite": "read", - "service": "oa", - "command": "+list-forms", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "oa", - "command": "+list-pending", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "oa", "command": "+list-submitted", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格返回当前用户已发起审批实例及页完整性;缺集合、错型、坏元素和缺失分页均失败,真实非空和保证零命中均已执行。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "oa", "command": "+my-initiated", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "alias_internal", + "semantic_delta": "保留历史 initiated 字段与命令身份,同时复用已发起审批的严格响应和分页契约;空投影不再回退原始响应。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "oa", "command": "+search-forms", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "按关键字搜索可发起审批定义,严格要求显式 result 数组和稳定 processCode;已完成已知非空与保证零命中证明。", + "availability": "available" }, { "suite": "read", @@ -4089,4 +4080,4 @@ "availability": "available" } ] -} \ No newline at end of file +} diff --git a/internal/cli/command_path_fallbacks.json b/internal/cli/command_path_fallbacks.json index fdd58095..fea4b829 100644 --- a/internal/cli/command_path_fallbacks.json +++ b/internal/cli/command_path_fallbacks.json @@ -191,12 +191,12 @@ "from": "oa +list-processes", "mode": "ambiguous", "candidates": [ - "oa +list-forms", + "oa +search-forms", "oa +my-initiated", "oa approval list-initiated" ], "reviewed": true, - "review_reason": "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf." + "review_reason": "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search and initiated workflows." }, { "from": "chat +conversation-detail", diff --git a/internal/cli/command_path_fallbacks_generated.go b/internal/cli/command_path_fallbacks_generated.go index 83bcae9e..598cabb7 100644 --- a/internal/cli/command_path_fallbacks_generated.go +++ b/internal/cli/command_path_fallbacks_generated.go @@ -242,9 +242,9 @@ var generatedCommandPathFallbacks = []CommandPathFallback{ { From: "oa +list-processes", Mode: "ambiguous", - Candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}, + Candidates: []string{"oa +search-forms", "oa +my-initiated", "oa approval list-initiated"}, Reviewed: true, - ReviewReason: "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf.", + ReviewReason: "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search and initiated workflows.", }, } diff --git a/internal/cli/command_path_fallbacks_test.go b/internal/cli/command_path_fallbacks_test.go index 0a52ca9c..5136a8c0 100644 --- a/internal/cli/command_path_fallbacks_test.go +++ b/internal/cli/command_path_fallbacks_test.go @@ -172,7 +172,7 @@ func TestCrossPlatformCoverageCommandPathFallbackAuditCoverage(t *testing.T) { "chat +send-file": {"chat +messages-send", "chat message send"}, "chat +send-image": {"chat +messages-send", "chat message send"}, "chat +send-media": {"chat +messages-send", "chat message send"}, - "oa +list-processes": {"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}, + "oa +list-processes": {"oa +search-forms", "oa +my-initiated", "oa approval list-initiated"}, "doc +template": {"doc +template-list", "doc +template-search", "doc +create-from-template"}, "doc +version": {"doc +history-list", "doc +history-save", "doc +history-revert"}, } diff --git a/internal/shortcut/builtin/oa_semantic_catalog_test.go b/internal/shortcut/builtin/oa_semantic_catalog_test.go new file mode 100644 index 00000000..a9836911 --- /dev/null +++ b/internal/shortcut/builtin/oa_semantic_catalog_test.go @@ -0,0 +1,83 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageOASemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_oa.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "oa" { + registered[item.Command] = item + } + } + if source.Service != "oa" || len(registered) != 10 || len(source.Shortcuts) != 10 { + t.Fatalf("service/registered/catalog=%s/%d/%d, want oa/10/10", source.Service, len(registered), len(source.Shortcuts)) + } + public, unavailable := 0, 0 + var missing, stale []string + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + availability := record.Availability + if availability == "" { + availability = source.Availability + } + if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s semantic review facts drifted", command) + } + if item.Risk != record.Risk || item.Availability != availability { + t.Errorf("%s risk/availability=%q/%q want=%q/%q", command, item.Risk, item.Availability, record.Risk, availability) + } + if record.Public { + public++ + if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("oa", command) { + t.Errorf("%s public availability/visibility drift", command) + } + if item.Contract.Empty() || item.Contract.Result == nil || item.Safety.Effect == "" || item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s lacks public Contract/Safety/Result/unified output", command) + } + } else { + if !item.Hidden || shortcut.InPublicCatalog("oa", command) { + t.Errorf("%s nonpublic shortcut is visible", command) + } + if availability == shortcut.AvailabilityUnavailable { + unavailable++ + } + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) > 0 || len(stale) > 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } + if public != 4 || unavailable != 5 { + t.Fatalf("public/unavailable=%d/%d, want 4/5", public, unavailable) + } +} diff --git a/internal/shortcut/oa/common.go b/internal/shortcut/oa/common.go index 2c994b9c..aae53aae 100644 --- a/internal/shortcut/oa/common.go +++ b/internal/shortcut/oa/common.go @@ -46,6 +46,17 @@ func oaObjectResult(description string) *contract.ResultSpec { } } +func oaWriteResult(description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"processInstanceId":{"type":"string","description":"被处理审批实例的稳定 ID"},"taskId":{"type":"string","description":"被处理审批任务的稳定 ID"},"verified":{"type":"boolean","description":"写后精确任务读回是否证明目标任务不再待处理"}},"required":["processInstanceId","taskId","verified"],"additionalProperties":false}`, + description, + )), + SensitivePaths: []string{"processInstanceId", "taskId"}, + } +} + func oaReadSafety() contract.SafetySpec { return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} } @@ -96,6 +107,17 @@ func oaResponseError(operation, reason, message string) error { ) } +func oaPostWriteError(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("write_verification"), + apperrors.WithExecutionStarted(true), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + // oaRequireSuccess accepts only the two success encodings observed from the // OA backend. Missing, false, null, and all other encodings fail closed. func oaRequireSuccess(data map[string]any, operation string) error { @@ -260,6 +282,12 @@ func oaProjectInstances(data map[string]any, operation, path string) ([]map[stri if status := oaScalarString(item["status"]); status != "" { row["status"] = status } + if businessID := oaIdentity(item, "businessId"); businessID != "" { + row["businessId"] = businessID + } + if originator := oaFirstString(item, "originatorName", "originatorUserName"); originator != "" { + row["originatorName"] = originator + } for _, key := range []string{"processCreateTime", "createTime"} { if value, present := item[key]; present && value != nil { row["createTime"] = value @@ -271,6 +299,54 @@ func oaProjectInstances(data map[string]any, operation, path string) ([]map[stri return instances, nil } +func oaProjectTasks(data map[string]any, operation string) ([]map[string]any, error) { + items, err := oaRequireCollection(data, operation, "result.taskIdList") + if err != nil { + return nil, err + } + tasks := make([]map[string]any, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for index, item := range items { + id := oaIdentity(item, "taskId") + if id == "" { + return nil, oaResponseError(operation, "missing_item_identity", fmt.Sprintf("审批任务第 %d 项缺少 taskId", index)) + } + if _, duplicate := seen[id]; duplicate { + return nil, oaResponseError(operation, "duplicate_item_identity", "审批任务响应包含重复 taskId") + } + seen[id] = struct{}{} + tasks = append(tasks, map[string]any{"taskId": id}) + } + return tasks, nil +} + +func oaCursorPage(result map[string]any, operation string, current int) (oaPageEvidence, error) { + raw, present := result["hasMore"] + if !present { + return oaPageEvidence{}, oaResponseError(operation, "missing_pagination", "游标响应缺少 hasMore;不能把当前页当作完整结果") + } + hasMore, ok := raw.(bool) + if !ok { + return oaPageEvidence{}, oaResponseError(operation, "malformed_pagination", "OA 响应 hasMore 应为布尔值") + } + page := oaPageEvidence{Known: true, HasMore: hasMore} + next := oaScalarString(result["nextCursor"]) + if !hasMore { + if next != "" { + return oaPageEvidence{}, oaResponseError(operation, "conflicting_pagination", "hasMore=false 但 nextCursor 非空") + } + return page, nil + } + if next == "" { + return oaPageEvidence{}, oaResponseError(operation, "missing_next_cursor", "hasMore=true 但缺少 nextCursor") + } + if next == strconv.Itoa(current) { + return oaPageEvidence{}, oaResponseError(operation, "stalled_cursor", "nextCursor 与当前 cursor 相同") + } + page.Next = next + return page, nil +} + func oaHasMorePage(result map[string]any, operation string, itemCount, currentPage int) (oaPageEvidence, error) { raw, present := result["hasMore"] if !present { @@ -316,6 +392,15 @@ func outputOAPage(rt *shortcut.RuntimeContext, collection string, items []map[st return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) } +func outputOACompleteCollection(rt *shortcut.RuntimeContext, collection string, items []map[string]any) error { + payload := map[string]any{"count": len(items), collection: items, "complete": true} + if !output.UsesUnifiedResult(rt.Command()) { + return rt.Output(payload) + } + meta := &output.Meta{Count: output.NewCount(len(items))} + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) +} + func validateOAPage(page, limit int) error { if page <= 0 { return apperrors.NewValidation("--page 必须大于 0") diff --git a/internal/shortcut/oa/list_forms_projection_test.go b/internal/shortcut/oa/list_forms_projection_test.go deleted file mode 100644 index 21ce7df8..00000000 --- a/internal/shortcut/oa/list_forms_projection_test.go +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package oa - -import ( - "encoding/json" - "testing" -) - -// TestListFormsProjectProcessCodeListShape guards against the projection-data-loss -// class: list_user_visible_process nests the forms under result.processCodeList. -// The resolver MUST probe that exact key — otherwise the whole list silently -// projects to empty (exit 0, no error envelope) while the backend has data. -func TestListFormsProjectProcessCodeListShape(t *testing.T) { - // Faithful list_user_visible_process shape (as returned by the backend). - const raw = `{"result":{"processCodeList":[ - {"processCode":"PROC-1","processName":"leave","dirName":"attendance"}, - {"processCode":"PROC-2","processName":"check-in fix","dirName":"attendance"}, - {"processCode":"PROC-3","processName":"overtime","dirName":"attendance"} - ],"totalCount":-1},"success":true}` - var data map[string]any - if err := json.Unmarshal([]byte(raw), &data); err != nil { - t.Fatalf("unmarshal fixture: %v", err) - } - - forms := listFormsProject(data) - if len(forms) != 3 { - t.Fatalf("lower/upper mismatch: 3 forms in backend, projection returned %d (forms=%v)", len(forms), forms) - } - for _, f := range forms { - if f["processCode"] == nil || f["name"] == nil { - t.Fatalf("projected form missing processCode/name: %v", f) - } - } -} - -// TestListFormsProjectBareArrayShape ensures a bare top-level array still works. -func TestListFormsProjectBareArrayShape(t *testing.T) { - const raw = `{"result":[ - {"processCode":"PROC-9","processName":"generic approval"} - ]}` - var data map[string]any - if err := json.Unmarshal([]byte(raw), &data); err != nil { - t.Fatalf("unmarshal fixture: %v", err) - } - if forms := listFormsProject(data); len(forms) != 1 { - t.Fatalf("bare array shape: want 1 form, got %d (%v)", len(forms), forms) - } -} - -// TestOAInstanceResolveListValuesShape guards the shared resolver behind -// +list-pending / +list-executed / +list-submitted / +list-cc. Those approval -// instance tools nest the list under result.values; the resolver must probe -// "values" or all four commands silently return empty despite backend records. -func TestOAInstanceResolveListValuesShape(t *testing.T) { - const raw = `{"result":{"hasMore":false,"values":[ - {"processInstanceId":"i-1","title":"user leave"}, - {"processInstanceId":"i-2","title":"user reimbursement"} - ]}}` - var data map[string]any - if err := json.Unmarshal([]byte(raw), &data); err != nil { - t.Fatalf("unmarshal fixture: %v", err) - } - if got := oaInstanceResolveList(data); len(got) != 2 { - t.Fatalf("lower/upper mismatch: result.values has 2 entries, resolver returned %d", len(got)) - } - // End-to-end through a real projection that uses the shared resolver. - if instances := listSubmittedProject(data); len(instances) != 2 { - t.Fatalf("listSubmittedProject: want 2, got %d (%v)", len(instances), instances) - } -} diff --git a/internal/shortcut/oa/oa.go b/internal/shortcut/oa/oa.go index 80d1590d..31364c46 100644 --- a/internal/shortcut/oa/oa.go +++ b/internal/shortcut/oa/oa.go @@ -1,59 +1,75 @@ // Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +// Licensed under the Apache License, Version 2.0 -// Package oa registers declarative shortcuts for the DingTalk OA approval -// service, wrapping the raw MCP tools exposed by the dws oa helper. +// Package oa registers strict declarative shortcuts for DingTalk OA approval. package oa import ( - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "fmt" + "strconv" + "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) -// ListPending — 查询待我处理的审批 (list_pending_approvals) +func parseOAStringPage(rt *shortcut.RuntimeContext, name string, fallback int) (int, error) { + raw := rt.Str(name) + if raw == "" { + return fallback, nil + } + value, err := strconv.Atoi(raw) + if err != nil { + return 0, apperrors.NewValidation(fmt.Sprintf("--%s 必须是整数", name)) + } + return value, nil +} + +func oaInstancePage(rt *shortcut.RuntimeContext, tool string, params map[string]any, page int) error { + operation := "oa/" + tool + data, err := rt.CallMCPData("oa", tool, params) + if err != nil { + return err + } + instances, err := oaProjectInstances(data, operation, "result.values") + if err != nil { + return err + } + result, err := oaRequireObject(data, operation, "result") + if err != nil { + return err + } + evidence, err := oaHasMorePage(result, operation, len(instances), page) + if err != nil { + return err + } + return outputOAPage(rt, "instances", instances, evidence) +} + var ListPending = shortcut.Shortcut{ - Service: "oa", - Command: "+list-pending", - Product: "oa", - Description: "查询待我处理的审批(时间范围为 epoch 毫秒)", - Intent: "当你想知道自己当前有哪些审批还没处理、需要清理审批待办或按时间段/关键字盘点待审批单时使用;传入起止时间(epoch 毫秒,可选关键字与分页),返回待我审批的实例列表,是后续 +get 查详情、+approve/+reject 处理的入口。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_list_pending", - CanonicalPath: "oa.shortcut_list_pending", - CLIPath: "oa +list-pending", - PrimaryCLIPath: "oa +list-pending", + Service: "oa", Command: "+list-pending", Product: "oa", + Description: "查询待我处理的审批(时间范围为 epoch 毫秒)", + Intent: "按 epoch 毫秒时间范围查询当前用户待处理审批;只有显式成功、严格实例数组与可续页证据齐全时才返回结果。", + Risk: shortcut.RiskRead, + Safety: oaReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+list-pending", + "查询待我处理的审批(时间范围为 epoch 毫秒)", + "需要按时间范围读取待我审批的实例,并取得稳定 processInstanceId 时使用;没有安全非空待办 fixture 前不会进入公开发现。", + oaCollectionResult("instances", "严格验证的待处理审批实例页"), + oaPagePagination("page"), + []contract.ParamDecl{ + {Name: "start", Property: "starTime"}, + {Name: "end", Property: "endTime"}, + {Name: "page", Property: "pageNum"}, + {Name: "limit", Property: "pageSize"}, + {Name: "query", Property: "query"}, }, - Description: "查询待我处理的审批(时间范围为 epoch 毫秒)", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "查询待我处理的审批(时间范围为 epoch 毫秒)", - UseWhen: []string{"当你想知道自己当前有哪些审批还没处理、需要清理审批待办或按时间段/关键字盘点待审批单时使用;传入起止时间(epoch 毫秒,可选关键字与分页),返回待我审批的实例列表,是后续 +get 查详情、+approve/+reject 处理的入口。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws oa +list-pending --start 1741536000000 --end 1741622399000 --query 报销"}, - }, - }, + "dws oa +list-pending --start 1741536000000 --end 1741622399000 --page 1 --limit 20", + ), Flags: []shortcut.Flag{ {Name: "start", Type: shortcut.FlagInt, Desc: "开始时间(epoch 毫秒)", Required: true}, {Name: "end", Type: shortcut.FlagInt, Desc: "结束时间(epoch 毫秒)", Required: true}, @@ -61,12 +77,24 @@ var ListPending = shortcut.Shortcut{ {Name: "limit", Type: shortcut.FlagString, Desc: "每页大小"}, {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, }, - Tips: []string{`dws oa +list-pending --start 1741536000000 --end 1741622399000 --query 报销`}, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "starTime": rt.Int("start"), - "endTime": rt.Int("end"), + Tips: []string{`dws oa +list-pending --start 1741536000000 --end 1741622399000 --page 1 --limit 20`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("start") <= 0 || rt.Int("end") <= rt.Int("start") { + return apperrors.NewValidation("--start/--end 必须是递增的正整数 epoch 毫秒范围") } + page, err := parseOAStringPage(rt, "page", 1) + if err != nil { + return err + } + limit, err := parseOAStringPage(rt, "limit", 20) + if err != nil { + return err + } + return validateOAPage(page, limit) + }, + Execute: func(rt *shortcut.RuntimeContext) error { + page, _ := parseOAStringPage(rt, "page", 1) + params := map[string]any{"starTime": rt.Int("start"), "endTime": rt.Int("end")} if rt.Changed("page") { params["pageNum"] = rt.Str("page") } @@ -76,530 +104,149 @@ var ListPending = shortcut.Shortcut{ if rt.Changed("query") { params["query"] = rt.Str("query") } - data, err := rt.CallMCPData("oa", "list_pending_approvals", params) - if err != nil { - return err - } - instances := listPendingProject(data) - return rt.Output(map[string]any{"count": len(instances), "instances": instances}) + return oaInstancePage(rt, "list_pending_approvals", params, page) }, } -// listPendingProject reshapes the raw list_pending_approvals response into the -// same clean {processInstanceId, title, status, createTime} approval list as -// +list-executed/+list-submitted, so all approval-instance listings project -// identically. It reuses the shared oaInstance* defensive probes, tolerating -// response-shape and key-spelling drift. -func listPendingProject(data map[string]any) []map[string]any { - raw := oaInstanceResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if row := oaInstanceProjectItem(m); len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// Detail — 获取审批实例详情 (get_processInstance_detail) -// Approve — 同意审批 (approve_processInstance) -// Reject — 拒绝审批 (reject_processInstance) -// Revoke — 撤销已发起的审批 (revoke_processInstance) -// Records — 获取审批操作记录 (get_processInstance_records) -// ListInitiated — 查询审批模板下已发起的审批记录 (list_initiated_instances) -// ListTasks — 查询待我审批的任务 ID (list_pending_tasks) -// ListForms — 获取当前用户可见的审批表单列表 (list_user_visible_process) var ListForms = shortcut.Shortcut{ - Service: "oa", - Command: "+list-forms", - Product: "oa", - Description: "获取当前用户可见的审批表单列表", - Intent: "当你想浏览自己有权限发起哪些审批模板、或需要为 +list-initiated 等操作枚举 processCode 时使用;无需关键字,按游标分页返回当前用户可见的全部审批表单,适合不确定表单名称时先整体看一遍。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_list_forms", - CanonicalPath: "oa.shortcut_list_forms", - CLIPath: "oa +list-forms", - PrimaryCLIPath: "oa +list-forms", - }, - Description: "获取当前用户可见的审批表单列表", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "获取当前用户可见的审批表单列表", - UseWhen: []string{"当你想浏览自己有权限发起哪些审批模板、或需要为 +list-initiated 等操作枚举 processCode 时使用;无需关键字,按游标分页返回当前用户可见的全部审批表单,适合不确定表单名称时先整体看一遍。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws oa +list-forms --cursor 0 --limit 100"}, - }, - }, + Service: "oa", Command: "+list-forms", Product: "oa", + Description: "获取当前用户可见的审批表单列表", + Intent: "按服务端游标读取当前用户可发起的审批表单;缺少 hasMore/nextCursor 或游标不前进时失败,不把重复首页宣称为完整列表。", + Risk: shortcut.RiskRead, + Safety: oaReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+list-forms", "获取当前用户可见的审批表单列表", + "需要枚举可发起审批定义并取得稳定 processCode 时使用;当前下游不返回可验证 continuation,故保持 unavailable。", + oaCollectionResult("forms", "严格验证的可见审批表单页"), oaPagePagination("cursor"), + []contract.ParamDecl{{Name: "cursor", Property: "cursor"}, {Name: "limit", Property: "pageSize"}}, + "dws oa +list-forms --cursor 0 --limit 100", + ), Flags: []shortcut.Flag{ {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"}, {Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页大小,最大 100"}, }, Tips: []string{`dws oa +list-forms --cursor 0 --limit 100`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("cursor") < 0 { + return apperrors.NewValidation("--cursor 不能小于 0") + } + if rt.Int("limit") <= 0 || rt.Int("limit") > 100 { + return apperrors.NewValidation("--limit 必须在 1 到 100 之间") + } + return nil + }, Execute: func(rt *shortcut.RuntimeContext) error { - data, err := rt.CallMCPData("oa", "list_user_visible_process", map[string]any{ - "cursor": rt.Int("cursor"), - "pageSize": rt.Int("limit"), - }) + const operation = "oa/list_user_visible_process" + data, err := rt.CallMCPData("oa", "list_user_visible_process", map[string]any{"cursor": rt.Int("cursor"), "pageSize": rt.Int("limit")}) if err != nil { return err } - forms := listFormsProject(data) - return rt.Output(map[string]any{"count": len(forms), "forms": forms}) + forms, err := oaProjectForms(data, operation, "result.processCodeList") + if err != nil { + return err + } + result, err := oaRequireObject(data, operation, "result") + if err != nil { + return err + } + page, err := oaCursorPage(result, operation, rt.Int("cursor")) + if err != nil { + return err + } + return outputOAPage(rt, "forms", forms, page) }, } -// listFormsProject reshapes the raw list_user_visible_process response into a -// clean approval-form list ({processCode, name, iconUrl}) — the output-projection -// fidelity the framework applies to every list command. The list container and -// per-item field names are probed defensively across candidate keys so the -// projection tolerates response-shape drift rather than crashing or fabricating. -func listFormsProject(data map[string]any) []map[string]any { - raw := oaFormResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if row := oaFormProjectItem(m); len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// oaFormResolveList locates the form list inside a response, tolerating a bare -// top-level array or nesting one level under a common envelope key. -func oaFormResolveList(data map[string]any) []any { - if data == nil { - return []any{} - } - // list_user_visible_process nests the forms under result.processCodeList; - // probing must include that exact key (both at the top level in case the - // envelope is already unwrapped, and one level deeper under result/data) or - // the whole list silently projects to empty. - for _, key := range []string{"result", "data", "list", "items", "processList", "processCodeList", "forms"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "processList", "processCodeList", "forms", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// oaFormProjectItem picks the stable identity/label fields of a single approval -// form, probing candidate key spellings so it survives snake/camel drift. -func oaFormProjectItem(m map[string]any) map[string]any { - row := map[string]any{} - if v, ok := oaFormFirst(m, "processCode", "process_code", "code"); ok { - row["processCode"] = v - } - if v, ok := oaFormFirst(m, "name", "processName", "process_name", "flowTitle"); ok { - row["name"] = v - } - if v, ok := oaFormFirst(m, "iconUrl", "icon_url", "iconName"); ok { - row["iconUrl"] = v - } - return row -} - -// oaFormFirst returns the first present candidate key's value. -func oaFormFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - -// SearchForms — 按关键字模糊搜索可见审批表单 (search_form) var SearchForms = shortcut.Shortcut{ - Service: "oa", - Command: "+search-forms", - Product: "oa", - Description: "按关键字模糊搜索当前用户可见的审批表单", - Intent: "当你已知想找的审批大致名称(如「报销」「请假」)、想快速定位对应表单及其 processCode 时使用,比 +list-forms 全量列举更高效;传入关键字,返回名称或 processCode 匹配的表单,供后续 +list-initiated 按模板查询。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", + Service: "oa", Command: "+search-forms", Product: "oa", + Description: "按关键字模糊搜索当前用户可见的审批表单", + Intent: "已知审批定义关键字,需要取得一个或多个稳定 processCode 时使用;要无条件遍历全部定义不要使用本命令。", + Risk: shortcut.RiskRead, + Safety: oaReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+search-forms", "按关键字模糊搜索当前用户可见的审批表单", + "已知审批定义关键字,需要取得一个或多个稳定 processCode 时使用;要无条件遍历全部定义不要使用本命令。", + oaCollectionResult("forms", "严格验证的审批表单搜索结果"), nil, + []contract.ParamDecl{{Name: "query", Property: "query"}}, + "dws oa +search-forms --query 报销", + ), + Flags: []shortcut.Flag{{Name: "query", Type: shortcut.FlagString, Desc: "关键字(匹配 processCode 或表单名称)", Required: true}}, + Tips: []string{`dws oa +search-forms --query 报销`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Str("query") == "" { + return apperrors.NewValidation("--query 不能为空") + } + return nil }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_search_forms", - CanonicalPath: "oa.shortcut_search_forms", - CLIPath: "oa +search-forms", - PrimaryCLIPath: "oa +search-forms", - }, - Description: "按关键字模糊搜索当前用户可见的审批表单", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "按关键字模糊搜索当前用户可见的审批表单", - UseWhen: []string{"当你已知想找的审批大致名称(如「报销」「请假」)、想快速定位对应表单及其 processCode 时使用,比 +list-forms 全量列举更高效;传入关键字,返回名称或 processCode 匹配的表单,供后续 +list-initiated 按模板查询。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws oa +search-forms --query 报销"}, - }, - }, - Flags: []shortcut.Flag{ - {Name: "query", Type: shortcut.FlagString, Desc: "关键字(匹配 processCode 或表单名称)", Required: true}, - }, - Tips: []string{`dws oa +search-forms --query 报销`}, Execute: func(rt *shortcut.RuntimeContext) error { - data, err := rt.CallMCPData("oa", "search_form", map[string]any{ - "query": rt.Str("query"), - }) + const operation = "oa/search_form" + data, err := rt.CallMCPData("oa", "search_form", map[string]any{"query": rt.Str("query")}) if err != nil { return err } - forms := searchFormsProject(data) - return rt.Output(map[string]any{"count": len(forms), "forms": forms}) - }, -} - -// searchFormsProject reshapes the raw search_form response into the same clean -// {processCode, name, iconUrl} list as +list-forms, so both approval-form -// listings project identically. It reuses the shared oaForm* defensive probes, -// tolerating response-shape and key-spelling drift. -func searchFormsProject(data map[string]any) []map[string]any { - raw := oaFormResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if row := oaFormProjectItem(m); len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// DingInfo — 获取审批任务的被催办人 userId (oa_ding_user) -// ListExecuted — 获取当前用户已处理过的审批单列表 (get_done_tasks) -var ListExecuted = shortcut.Shortcut{ - Service: "oa", - Command: "+list-executed", - Product: "oa", - Description: "获取当前用户已经处理过的审批单列表", - Intent: "当你想回顾自己历史上审批过(已同意/拒绝等)的单子、做复盘或查找某条已办审批时使用,区别于 +list-pending 的待办;按页码/关键字分页返回我已处理的审批单。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_list_executed", - CanonicalPath: "oa.shortcut_list_executed", - CLIPath: "oa +list-executed", - PrimaryCLIPath: "oa +list-executed", - }, - Description: "获取当前用户已经处理过的审批单列表", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "获取当前用户已经处理过的审批单列表", - UseWhen: []string{"当你想回顾自己历史上审批过(已同意/拒绝等)的单子、做复盘或查找某条已办审批时使用,区别于 +list-pending 的待办;按页码/关键字分页返回我已处理的审批单。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws oa +list-executed --limit 20 --page 1 --query 报销"}, - }, - }, - Flags: []shortcut.Flag{ - {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"}, - {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"}, - {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, - }, - Tips: []string{`dws oa +list-executed --limit 20 --page 1 --query 报销`}, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "pageNumber": rt.Str("page"), - "pageSize": rt.Str("limit"), - } - if rt.Changed("query") { - params["query"] = rt.Str("query") - } - data, err := rt.CallMCPData("oa", "get_done_tasks", params) + forms, err := oaProjectForms(data, operation, "result") if err != nil { return err } - instances := listExecutedProject(data) - return rt.Output(map[string]any{"count": len(instances), "instances": instances}) + return outputOACompleteCollection(rt, "forms", forms) }, } -// listExecutedProject reshapes the raw get_done_tasks response into a clean -// approval-instance list ({processInstanceId, title, status, createTime}) — the -// the clean output projection applied to every list command. The -// list container and per-item field names are probed defensively across -// candidate keys so the projection tolerates response-shape drift rather than -// crashing or fabricating data. -func listExecutedProject(data map[string]any) []map[string]any { - raw := oaInstanceResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if row := oaInstanceProjectItem(m); len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// oaInstanceResolveList locates the approval-instance list inside a response, -// tolerating a bare top-level array or nesting one level under a common -// envelope key. -func oaInstanceResolveList(data map[string]any) []any { - if data == nil { - return []any{} - } - // The approval instance tools (list_pending_approvals, get_done_tasks, - // get_submitted_instances, get_noticed_instances) all nest the instance list - // under result.values; "values" MUST be in the probe set or every one of - // +list-pending / +list-executed / +list-submitted / +list-cc silently - // projects to empty despite the backend returning records. - for _, key := range []string{"result", "data", "list", "items", "values", "instances", "tasks"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "values", "instances", "tasks", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// oaInstanceProjectItem picks the stable identity/label fields of a single -// approval instance, probing candidate key spellings so it survives -// snake/camel drift. -func oaInstanceProjectItem(m map[string]any) map[string]any { - row := map[string]any{} - if v, ok := oaFormFirst(m, "processInstanceId", "process_instance_id", "instanceId", "id"); ok { - row["processInstanceId"] = v - } - if v, ok := oaFormFirst(m, "title", "processTitle", "process_title", "name"); ok { - row["title"] = v - } - if v, ok := oaFormFirst(m, "status", "result", "processResult"); ok { - row["status"] = v - } - if v, ok := oaFormFirst(m, "createTime", "create_time", "gmtCreate", "createdTime"); ok { - row["createTime"] = v - } - return row -} - -// ListSubmitted — 获取当前用户已发起的审批单列表 (get_submitted_instances) -var ListSubmitted = shortcut.Shortcut{ - Service: "oa", - Command: "+list-submitted", - Product: "oa", - Description: "获取当前用户已发起的审批单列表", - Intent: "当你想查看自己提交发起的审批单及其审批进度(如某笔报销/请假审到哪一步)时使用;按页码/关键字分页返回我发起的审批单,可据此决定是否 +revoke 撤销或催办。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_list_submitted", - CanonicalPath: "oa.shortcut_list_submitted", - CLIPath: "oa +list-submitted", - PrimaryCLIPath: "oa +list-submitted", +func oaNumberedInstanceShortcut(command, tool, description, intent string) shortcut.Shortcut { + declaration := shortcut.Shortcut{ + Service: "oa", Command: command, Product: "oa", + Description: description, Intent: intent, Risk: shortcut.RiskRead, + Safety: oaReadSafety(), OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract(command, description, intent, + oaCollectionResult("instances", description), oaPagePagination("page"), + []contract.ParamDecl{{Name: "page", Property: "pageNumber"}, {Name: "limit", Property: "pageSize"}, {Name: "query", Property: "query"}}, + "dws oa "+command+" --page 1 --limit 20"), + Flags: []shortcut.Flag{ + {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"}, + {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"}, + {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, }, - Description: "获取当前用户已发起的审批单列表", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "获取当前用户已发起的审批单列表", - UseWhen: []string{"当你想查看自己提交发起的审批单及其审批进度(如某笔报销/请假审到哪一步)时使用;按页码/关键字分页返回我发起的审批单,可据此决定是否 +revoke 撤销或催办。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws oa +list-submitted --limit 20 --page 1 --query 报销"}, - }, - }, - Flags: []shortcut.Flag{ - {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"}, - {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"}, - {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, - }, - Tips: []string{`dws oa +list-submitted --limit 20 --page 1 --query 报销`}, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "pageNumber": rt.Str("page"), - "pageSize": rt.Str("limit"), - } - if rt.Changed("query") { - params["query"] = rt.Str("query") - } - data, err := rt.CallMCPData("oa", "get_submitted_instances", params) + Tips: []string{"dws oa " + command + " --page 1 --limit 20"}, + } + declaration.Validate = func(rt *shortcut.RuntimeContext) error { + page, err := parseOAStringPage(rt, "page", 1) if err != nil { return err } - instances := listSubmittedProject(data) - return rt.Output(map[string]any{"count": len(instances), "instances": instances}) - }, -} - -// listSubmittedProject reshapes the raw get_submitted_instances response into -// the same clean {processInstanceId, title, status, createTime} approval list -// as +list-executed, so both instance listings project identically. It reuses -// the shared oaInstance* defensive probes, tolerating response-shape and -// key-spelling drift. -func listSubmittedProject(data map[string]any) []map[string]any { - raw := oaInstanceResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if row := oaInstanceProjectItem(m); len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// ListCc — 获取抄送当前用户的审批单列表 (get_noticed_instances) -var ListCc = shortcut.Shortcut{ - Service: "oa", - Command: "+list-cc", - Product: "oa", - Description: "获取抄送当前用户的审批单列表", - Intent: "当你想查看抄送给自己、需要知悉但无需审批的单子时使用;按页码/关键字分页返回抄送我的审批单列表,适合了解与自己相关但不用自己动手处理的审批动态。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_list_cc", - CanonicalPath: "oa.shortcut_list_cc", - CLIPath: "oa +list-cc", - PrimaryCLIPath: "oa +list-cc", - }, - Description: "获取抄送当前用户的审批单列表", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "获取抄送当前用户的审批单列表", - UseWhen: []string{"当你想查看抄送给自己、需要知悉但无需审批的单子时使用;按页码/关键字分页返回抄送我的审批单列表,适合了解与自己相关但不用自己动手处理的审批动态。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws oa +list-cc --limit 20 --page 1 --query 报销"}, - }, - }, - Flags: []shortcut.Flag{ - {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"}, - {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"}, - {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, - }, - Tips: []string{`dws oa +list-cc --limit 20 --page 1 --query 报销`}, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "pageNumber": rt.Str("page"), - "pageSize": rt.Str("limit"), - } - if rt.Changed("query") { - params["query"] = rt.Str("query") - } - data, err := rt.CallMCPData("oa", "get_noticed_instances", params) + limit, err := parseOAStringPage(rt, "limit", 20) if err != nil { return err } - instances := listCcProject(data) - return rt.Output(map[string]any{"count": len(instances), "instances": instances}) - }, -} - -// listCcProject reshapes the raw get_noticed_instances response into the same -// clean {processInstanceId, title, status, createTime} approval list as the -// other instance listings, so all approval-instance listings project -// identically. It reuses the shared oaInstance* defensive probes, tolerating -// response-shape and key-spelling drift. -func listCcProject(data map[string]any) []map[string]any { - raw := oaInstanceResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if row := oaInstanceProjectItem(m); len(row) > 0 { - out = append(out, row) - } + return validateOAPage(page, limit) } - return out + declaration.Execute = func(rt *shortcut.RuntimeContext) error { + page, _ := parseOAStringPage(rt, "page", 1) + params := map[string]any{"pageNumber": rt.Str("page"), "pageSize": rt.Str("limit")} + if query := strings.TrimSpace(rt.Str("query")); query != "" { + params["query"] = query + } + return oaInstancePage(rt, tool, params, page) + } + return declaration } -// RedirectTask — 转交审批任务给其他人 (redirect_task) -// Comment — 对审批实例添加评论 (dingflow_comments) -// CcNotice — 对审批实例进行抄送 (oa_cc_noticer) -// AppendTask — 对审批任务进行加签 (append_task) -// RevertActivities — 获取审批任务可回退的节点信息 (get_inst_revert_activities) -// RevertTask — 退回审批任务到指定节点 (revert_task) +var ListExecuted = oaNumberedInstanceShortcut( + "+list-executed", "get_done_tasks", "获取当前用户已经处理过的审批单列表", + "需要回顾当前用户已同意或拒绝过的审批实例时使用;与待办、已发起和抄送列表分开。", +) + +var ListSubmitted = oaNumberedInstanceShortcut( + "+list-submitted", "get_submitted_instances", "获取当前用户已发起的审批单列表", + "需要查看当前用户发起的审批实例和当前状态时使用;返回稳定 processInstanceId 与可续页证据。", +) + +var ListCc = oaNumberedInstanceShortcut( + "+list-cc", "get_noticed_instances", "获取抄送当前用户的审批单列表", + "需要查看抄送给当前用户的审批实例时使用;没有安全非空抄送 fixture 前不会进入公开发现。", +) + func init() { - shortcut.Register( - ListPending, - ListForms, - SearchForms, - ListExecuted, - ListSubmitted, - ListCc, - ) + shortcut.Register(ListPending, ListForms, SearchForms, ListExecuted, ListSubmitted, ListCc) } diff --git a/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..1e84ca4d --- /dev/null +++ b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go @@ -0,0 +1,228 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package oa + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type oaCoverageCaller struct { + responses map[string][]string + history []string + arguments []map[string]any +} + +func (caller *oaCoverageCaller) CallTool(_ context.Context, _, tool string, arguments map[string]any) (*edition.ToolResult, error) { + caller.history = append(caller.history, tool) + caller.arguments = append(caller.arguments, arguments) + queue := caller.responses[tool] + if len(queue) == 0 { + return nil, errors.New("missing OA fake response for " + tool) + } + caller.responses[tool] = queue[1:] + if queue[0] == "__ERROR__" { + return nil, errors.New("injected OA failure") + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil +} + +func (*oaCoverageCaller) Format() string { return "json" } +func (*oaCoverageCaller) DryRun() bool { return false } +func (*oaCoverageCaller) Fields() string { return "" } +func (*oaCoverageCaller) JQ() string { return "" } + +func runOACoverage(t *testing.T, declaration shortcut.Shortcut, caller *oaCoverageCaller, args ...string) (*cobra.Command, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + return cmd, cmd.Execute() +} + +func TestCrossPlatformCoverageOAContractsAreTypedAndUnified(t *testing.T) { + for _, declaration := range []shortcut.Shortcut{ + ListPending, ListForms, SearchForms, ListExecuted, ListSubmitted, + ListCc, PendingApprovals, DoneApprovals, Approve, MyInitiated, + } { + if declaration.Contract.Empty() || declaration.Contract.Result == nil { + t.Errorf("%s lacks Contract.Result", declaration.Command) + } + if declaration.Safety.Effect == "" || declaration.Safety.Confirmation == "" { + t.Errorf("%s lacks Safety", declaration.Command) + } + if declaration.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout) + } + } + if Approve.Safety.Confirmation != "user_required" || Approve.Risk != shortcut.RiskHighWrite { + t.Fatal("+approve-by must require explicit high-risk confirmation") + } + for _, declaration := range []shortcut.Shortcut{ListPending, ListForms, ListExecuted, ListSubmitted, ListCc, MyInitiated} { + if declaration.Contract.Pagination == nil { + t.Errorf("%s lacks Pagination", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageOAStrictResponseMatrix(t *testing.T) { + validEmpty := map[string]any{"success": "true", "result": map[string]any{"values": []any{}}} + items, err := oaProjectInstances(validEmpty, "oa/test", "result.values") + if err != nil || len(items) != 0 { + t.Fatalf("explicit empty must succeed: items=%v err=%v", items, err) + } + for name, fixture := range map[string]map[string]any{ + "empty": {}, + "missing success": {"result": map[string]any{"values": []any{}}}, + "false success": {"success": false, "result": map[string]any{"values": []any{}}}, + "missing collection": {"success": true, "result": map[string]any{}}, + "wrong collection": {"success": true, "result": map[string]any{"values": map[string]any{}}}, + "bad item": {"success": true, "result": map[string]any{"values": []any{"bad"}}}, + "missing identity": {"success": true, "result": map[string]any{"values": []any{map[string]any{"title": "fixture"}}}}, + } { + if projected, err := oaProjectInstances(fixture, "oa/test", "result.values"); err == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } + forms := map[string]any{"success": true, "result": map[string]any{"processCodeList": []any{map[string]any{"processCode": "p", "processName": "fixture"}}}} + if projected, err := oaProjectForms(forms, "oa/forms", "result.processCodeList"); err != nil || len(projected) != 1 { + t.Fatalf("strict forms projection=%#v err=%v", projected, err) + } + badForm := map[string]any{"success": true, "result": map[string]any{"processCodeList": []any{map[string]any{"processName": "fixture"}}}} + if projected, err := oaProjectForms(badForm, "oa/forms", "result.processCodeList"); err == nil { + t.Fatalf("form without processCode returned success: %#v", projected) + } + search := map[string]any{"success": true, "result": []any{map[string]any{"processCode": "p", "processName": "fixture"}}} + if projected, err := oaProjectForms(search, "oa/search", "result"); err != nil || len(projected) != 1 { + t.Fatalf("strict search projection=%#v err=%v", projected, err) + } +} + +func TestCrossPlatformCoverageOAPaginationFailsClosed(t *testing.T) { + if _, err := oaHasMorePage(map[string]any{}, "oa/page", 1, 1); err == nil { + t.Fatal("nonempty numbered page without hasMore was accepted") + } + if page, err := oaHasMorePage(map[string]any{}, "oa/page", 0, 1); err != nil || !page.Known || page.HasMore { + t.Fatalf("reviewed empty numbered terminal page=%+v err=%v", page, err) + } + if _, err := oaHasMorePage(map[string]any{"hasMore": "false"}, "oa/page", 0, 1); err == nil { + t.Fatal("wrong hasMore type was accepted") + } + page, err := oaHasMorePage(map[string]any{"hasMore": true}, "oa/page", 1, 2) + if err != nil || !page.HasMore || page.Next != "3" { + t.Fatalf("numbered continuation=%+v err=%v", page, err) + } + if _, err := oaCursorPage(map[string]any{}, "oa/cursor", 0); err == nil { + t.Fatal("cursor response without pagination was accepted") + } + if _, err := oaCursorPage(map[string]any{"hasMore": true}, "oa/cursor", 0); err == nil { + t.Fatal("hasMore without nextCursor was accepted") + } + if _, err := oaCursorPage(map[string]any{"hasMore": true, "nextCursor": float64(1)}, "oa/cursor", 1); err == nil { + t.Fatal("stalled cursor was accepted") + } + page, err = oaCursorPage(map[string]any{"hasMore": true, "nextCursor": float64(2)}, "oa/cursor", 1) + if err != nil || page.Next != "2" { + t.Fatalf("cursor continuation=%+v err=%v", page, err) + } +} + +func TestCrossPlatformCoverageMyInitiatedNeverFallsBackToRawResponse(t *testing.T) { + caller := &oaCoverageCaller{responses: map[string][]string{ + "get_submitted_instances": {`{"success":"true","result":{"values":[]}}`}, + }} + cmd, err := runOACoverage(t, MyInitiated, caller, "--page", "1", "--limit", "20") + if err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + cmd.SetOut(&stdout) + code, emitted, err := output.EmitStoredResult(cmd) + if err != nil || !emitted || code != 0 { + t.Fatalf("emit code=%d emitted=%v err=%v", code, emitted, err) + } + var envelope struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + if _, raw := envelope.Data["result"]; raw { + t.Fatalf("raw result leaked: %s", stdout.String()) + } + initiated, ok := envelope.Data["initiated"].([]any) + if !ok || len(initiated) != 0 || envelope.Data["complete"] != true { + t.Fatalf("strict empty initiated payload=%#v", envelope.Data) + } +} + +func TestCrossPlatformCoverageOAApproveConfirmationAndReadback(t *testing.T) { + unconfirmed := &oaCoverageCaller{responses: map[string][]string{}} + helpers.InitDepsForTest(t, unconfirmed) + root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + service := &cobra.Command{Use: "oa"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(Approve))) + root.AddCommand(service) + root.SetArgs([]string{"oa", "+approve-by", "--keyword", "fixture"}) + if err := root.Execute(); err == nil { + t.Fatal("unconfirmed approval unexpectedly succeeded") + } + if len(unconfirmed.history) != 0 { + t.Fatalf("unconfirmed approval made calls: %v", unconfirmed.history) + } + + confirmed := &oaCoverageCaller{responses: map[string][]string{ + "list_pending_approvals": {`{"success":true,"result":{"hasMore":false,"values":[{"processInstanceId":"instance-1","title":"fixture"}]}}`}, + "list_pending_tasks": { + `{"success":true,"result":{"taskIdList":[{"taskId":1}]}}`, + `{"success":true,"result":{"taskIdList":[]}}`, + }, + "approve_processInstance": {`{"success":true,"result":{"accepted":true}}`}, + }} + helpers.InitDepsForTest(t, confirmed) + confirmedRoot := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + confirmedRoot.PersistentFlags().Bool("yes", false, "") + confirmedRoot.PersistentFlags().Bool("dry-run", false, "") + confirmedRoot.PersistentFlags().String("format", "json", "") + confirmedCtx, _ := output.WithResultStore(context.Background()) + confirmedRoot.SetContext(confirmedCtx) + confirmedRoot.SetOut(io.Discard) + confirmedRoot.SetErr(io.Discard) + confirmedService := &cobra.Command{Use: "oa"} + confirmedService.AddCommand(corecmd.New(shortcut.FromShortcut(Approve))) + confirmedRoot.AddCommand(confirmedService) + confirmedRoot.SetArgs([]string{"oa", "+approve-by", "--keyword", "fixture", "--yes"}) + if err := confirmedRoot.Execute(); err != nil { + t.Fatal(err) + } + if got := strings.Join(confirmed.history, ","); got != "list_pending_approvals,list_pending_tasks,approve_processInstance,list_pending_tasks" { + t.Fatalf("call history=%s", got) + } + if len(confirmed.arguments) != 4 || confirmed.arguments[2]["processInstanceId"] != "instance-1" || confirmed.arguments[3]["processInstanceId"] != "instance-1" { + t.Fatalf("identity was not preserved: %#v", confirmed.arguments) + } +} diff --git a/internal/shortcut/oa/smart.go b/internal/shortcut/oa/smart.go new file mode 100644 index 00000000..90c86950 --- /dev/null +++ b/internal/shortcut/oa/smart.go @@ -0,0 +1,269 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package oa + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func oaFirstPageOnly(rt *shortcut.RuntimeContext, tool, collection string, params map[string]any) error { + operation := "oa/" + tool + data, err := rt.CallMCPData("oa", tool, params) + if err != nil { + return err + } + instances, err := oaProjectInstances(data, operation, "result.values") + if err != nil { + return err + } + result, err := oaRequireObject(data, operation, "result") + if err != nil { + return err + } + page, err := oaHasMorePage(result, operation, len(instances), 1) + if err != nil { + return err + } + if page.HasMore { + return oaResponseError(operation, "unavailable_continuation", "兼容 Shortcut 没有页码参数且结果仍有后续页;请改用对应可分页命令") + } + return outputOACompleteCollection(rt, collection, instances) +} + +var PendingApprovals = shortcut.Shortcut{ + Service: "oa", Command: "+pending", Product: "oa", + Description: "只读列出待我审批的审批任务并投影为可读列表(只看不批)", + Intent: "兼容入口:读取近三个月待处理审批的首个完整页;无法证明完整或缺少非空 fixture 时保持 unavailable。", + Risk: shortcut.RiskRead, + Safety: oaReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+pending", "只读列出待我审批的审批任务并投影为可读列表(只看不批)", + "兼容旧的待审批摘要入口;新调用优先使用支持显式时间和页码的 +list-pending。", + oaCollectionResult("pending", "严格验证的待审批摘要"), nil, + []contract.ParamDecl{{Name: "limit", Property: "pageSize"}}, "dws oa +pending --limit 10", + ), + Flags: []shortcut.Flag{{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)"}}, + Tips: []string{`dws oa +pending --limit 10`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Changed("limit") && (rt.Int("limit") <= 0 || rt.Int("limit") > 100) { + return apperrors.NewValidation("--limit 必须在 1 到 100 之间") + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + now := time.Now() + params := map[string]any{"starTime": float64(now.AddDate(0, 0, -90).UnixMilli()), "endTime": float64(now.UnixMilli())} + if rt.Changed("limit") { + params["pageSize"] = float64(rt.Int("limit")) + } + return oaFirstPageOnly(rt, "list_pending_approvals", "pending", params) + }, +} + +var DoneApprovals = shortcut.Shortcut{ + Service: "oa", Command: "+done-approvals", Product: "oa", + Description: "只读列出我已处理过的审批任务(审批历史)并投影为可读列表", + Intent: "兼容入口:读取我已处理审批的首个完整页;新调用优先使用可分页的 +list-executed。", + Risk: shortcut.RiskRead, + Safety: oaReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+done-approvals", "只读列出我已处理过的审批任务(审批历史)并投影为可读列表", + "兼容旧的已处理审批摘要入口;需要翻页或搜索时使用 +list-executed。", + oaCollectionResult("done", "严格验证的已处理审批摘要"), nil, + []contract.ParamDecl{{Name: "limit", Property: "pageSize"}}, "dws oa +done-approvals --limit 10", + ), + Flags: []shortcut.Flag{{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)"}}, + Tips: []string{`dws oa +done-approvals --limit 10`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Changed("limit") && (rt.Int("limit") <= 0 || rt.Int("limit") > 100) { + return apperrors.NewValidation("--limit 必须在 1 到 100 之间") + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + limit := 20 + if rt.Changed("limit") { + limit = rt.Int("limit") + } + return oaFirstPageOnly(rt, "get_done_tasks", "done", map[string]any{"pageNumber": float64(1), "pageSize": float64(limit)}) + }, +} + +var MyInitiated = shortcut.Shortcut{ + Service: "oa", Command: "+my-initiated", Product: "oa", + Description: "列出我发起(提交)的审批单据", + Intent: "需要兼容旧的 initiated 输出字段时使用;一般列表与分页可直接使用 +list-submitted。", + Risk: shortcut.RiskRead, + Safety: oaReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+my-initiated", "列出我发起(提交)的审批单据", + "需要兼容旧的 initiated 输出字段时使用;一般列表与分页可直接使用 +list-submitted。", + oaCollectionResult("initiated", "严格验证的已发起审批实例页"), oaPagePagination("page"), + []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "pageNumber"}, {Name: "limit", Property: "pageSize"}}, + "dws oa +my-initiated --page 1 --limit 20", "dws oa +my-initiated --query 报销", + ), + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索(可选)"}, + {Name: "page", Type: shortcut.FlagInt, Desc: "分页页码(可选,默认 1)", Default: "1"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页大小(可选,默认 20)", Default: "20"}, + }, + Tips: []string{`dws oa +my-initiated`, `dws oa +my-initiated --query 报销`, `dws oa +my-initiated --page 2 --limit 50`}, + Validate: func(rt *shortcut.RuntimeContext) error { return validateOAPage(rt.Int("page"), rt.Int("limit")) }, + Execute: func(rt *shortcut.RuntimeContext) error { + const operation = "oa/get_submitted_instances" + params := map[string]any{"pageNumber": float64(rt.Int("page")), "pageSize": float64(rt.Int("limit"))} + if query := rt.Str("query"); query != "" { + params["query"] = query + } + data, err := rt.CallMCPData("oa", "get_submitted_instances", params) + if err != nil { + return err + } + items, err := oaProjectInstances(data, operation, "result.values") + if err != nil { + return err + } + result, err := oaRequireObject(data, operation, "result") + if err != nil { + return err + } + page, err := oaHasMorePage(result, operation, len(items), rt.Int("page")) + if err != nil { + return err + } + return outputOAPage(rt, "initiated", items, page) + }, +} + +type oaApprovalMatch struct { + id string + title string +} + +func oaMatchApprovals(items []map[string]any, keyword string) []oaApprovalMatch { + needle := strings.ToLower(strings.TrimSpace(keyword)) + matches := make([]oaApprovalMatch, 0) + for _, item := range items { + id := oaIdentity(item, "processInstanceId") + title := oaFirstString(item, "title", "processInstanceTitle") + businessID := oaIdentity(item, "businessId") + if strings.Contains(strings.ToLower(title), needle) || strings.Contains(strings.ToLower(businessID), needle) || strings.EqualFold(id, needle) { + matches = append(matches, oaApprovalMatch{id: id, title: title}) + } + } + return matches +} + +var Approve = shortcut.Shortcut{ + Service: "oa", Command: "+approve-by", Product: "oa", + Description: "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)", + Intent: "高风险兼容编排:完整读取并唯一匹配待办、唯一解析 taskId、确认后同意,再精确读回该 taskId 已不在待处理集合;没有安全 fixture 前保持 unavailable。", + Risk: shortcut.RiskHighWrite, + Safety: oaWriteSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+approve-by", "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)", + "仅在用户明确确认同意、关键词唯一定位实例且任务读回可验证时使用;任何歧义、分页不完整或读回失败都会阻止成功。", + oaWriteResult("同意审批并通过精确任务读回验证"), nil, + []contract.ParamDecl{{Name: "keyword", Property: "query"}, {Name: "comment", Property: "remark"}}, + "dws oa +approve-by --keyword 报销", + ), + Flags: []shortcut.Flag{ + {Name: "keyword", Type: shortcut.FlagString, Desc: "待审批单据的单号或标题关键词", Required: true}, + {Name: "comment", Type: shortcut.FlagString, Desc: "审批意见(可选)"}, + }, + Tips: []string{`dws oa +approve-by --keyword 报销`, `dws oa +approve-by --keyword 出差单 --comment "同意"`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Str("keyword") == "" { + return apperrors.NewValidation("--keyword 不能为空") + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + keyword := rt.Str("keyword") + now := time.Now() + pending, err := rt.CallMCPData("oa", "list_pending_approvals", map[string]any{ + "starTime": float64(now.AddDate(0, 0, -90).UnixMilli()), "endTime": float64(now.UnixMilli()), "query": keyword, + }) + if err != nil { + return err + } + items, err := oaProjectInstances(pending, "oa/list_pending_approvals", "result.values") + if err != nil { + return err + } + result, err := oaRequireObject(pending, "oa/list_pending_approvals", "result") + if err != nil { + return err + } + page, err := oaHasMorePage(result, "oa/list_pending_approvals", len(items), 1) + if err != nil { + return err + } + if page.HasMore { + return oaResponseError("oa/list_pending_approvals", "ambiguous_incomplete_search", "待办搜索仍有后续页,无法证明关键词唯一") + } + matches := oaMatchApprovals(items, keyword) + if len(matches) != 1 { + return apperrors.NewValidation(fmt.Sprintf("关键词必须唯一匹配一条待审批实例,当前匹配 %d 条", len(matches))) + } + instanceID := matches[0].id + tasksData, err := rt.CallMCPData("oa", "list_pending_tasks", map[string]any{"processInstanceId": instanceID}) + if err != nil { + return err + } + tasks, err := oaProjectTasks(tasksData, "oa/list_pending_tasks") + if err != nil { + return err + } + if len(tasks) != 1 { + return apperrors.NewValidation(fmt.Sprintf("审批实例必须唯一对应一条待处理任务,当前为 %d 条", len(tasks))) + } + taskID := tasks[0]["taskId"].(string) + numericTaskID, err := strconv.ParseFloat(taskID, 64) + if err != nil { + return oaResponseError("oa/list_pending_tasks", "malformed_task_identity", "taskId 不是可写入审批接口的数字") + } + writeArgs := map[string]any{"processInstanceId": instanceID, "taskId": numericTaskID} + if comment := rt.Str("comment"); comment != "" { + writeArgs["remark"] = comment + } + receipt, err := rt.CallMCPWriteDataStrict("oa", "approve_processInstance", writeArgs) + if err != nil { + return err + } + if err := oaRequireSuccess(receipt, "oa/approve_processInstance"); err != nil { + return err + } + readback, err := rt.CallMCPData("oa", "list_pending_tasks", map[string]any{"processInstanceId": instanceID}) + if err != nil { + return oaPostWriteError("oa/list_pending_tasks", "readback_failed", "审批写入后无法读取任务状态;远端效果未知") + } + remaining, err := oaProjectTasks(readback, "oa/list_pending_tasks") + if err != nil { + return oaPostWriteError("oa/list_pending_tasks", "readback_malformed", "审批写入后的任务读回无法验证;远端效果未知") + } + for _, task := range remaining { + if task["taskId"] == taskID { + return oaPostWriteError("oa/list_pending_tasks", "write_not_observed", "审批任务写后读回仍处于待处理集合") + } + } + return rt.Output(map[string]any{"processInstanceId": instanceID, "taskId": taskID, "verified": true}) + }, +} + +func init() { + shortcut.Register(PendingApprovals, DoneApprovals, Approve, MyInitiated) +} diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index 51dd4a94..c1fa1f33 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -377,10 +377,7 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "minutes\u0000+update": {}, "minutes\u0000+upload": {}, "minutes\u0000+upload-and-analyze": {}, - "oa\u0000+list-cc": {}, "oa\u0000+list-executed": {}, - "oa\u0000+list-forms": {}, - "oa\u0000+list-pending": {}, "oa\u0000+list-submitted": {}, "oa\u0000+my-initiated": {}, "oa\u0000+search-forms": {}, diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go index 12a00442..35efab99 100644 --- a/internal/shortcut/semantic_catalog.go +++ b/internal/shortcut/semantic_catalog.go @@ -40,6 +40,9 @@ var attendanceSemanticCatalogJSON []byte //go:embed semantic_catalog_mail.json var mailSemanticCatalogJSON []byte +//go:embed semantic_catalog_oa.json +var oaSemanticCatalogJSON []byte + type semanticCatalogFile struct { Version int `json:"version"` Service string `json:"service"` @@ -69,6 +72,7 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs( todoSemanticCatalogJSON, attendanceSemanticCatalogJSON, mailSemanticCatalogJSON, + oaSemanticCatalogJSON, ) func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord { diff --git a/internal/shortcut/semantic_catalog_oa.json b/internal/shortcut/semantic_catalog_oa.json new file mode 100644 index 00000000..4cdb5696 --- /dev/null +++ b/internal/shortcut/semantic_catalog_oa.json @@ -0,0 +1,85 @@ +{ + "version": 1, + "service": "oa", + "default_availability": "unavailable", + "shortcuts": { + "+list-executed": { + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、result.values、稳定 processInstanceId 与 hasMore,并把下一页凭据发布到统一分页元数据;真实非空和保证零命中均已执行。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + }, + "+list-submitted": { + "disposition": "semantic_adapter", + "semantic_delta": "严格返回当前用户已发起审批实例及页完整性;缺集合、错型、坏元素和缺失分页均失败,真实非空和保证零命中均已执行。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + }, + "+my-initiated": { + "disposition": "alias_internal", + "semantic_delta": "保留历史 initiated 字段与命令身份,同时复用已发起审批的严格响应和分页契约;空投影不再回退原始响应。", + "risk": "read", + "availability": "available", + "primary": "+list-submitted", + "public": true, + "reviewed": true + }, + "+search-forms": { + "disposition": "semantic_adapter", + "semantic_delta": "按关键字搜索可发起审批定义,严格要求显式 result 数组和稳定 processCode;已完成已知非空与保证零命中证明。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + }, + "+done-approvals": { + "disposition": "alias_internal", + "semantic_delta": "保留旧的首屏已处理审批摘要;已验证严格非空结果,但不提供页码,新的 Agent 路由统一使用 +list-executed。", + "risk": "read", + "availability": "available", + "primary": "+list-executed", + "public": false, + "reviewed": true + }, + "+list-forms": { + "disposition": "semantic_adapter", + "semantic_delta": "真实非空页不返回 continuation,且 cursor 0/1 的集合高度重叠;严格实现拒绝缺失或停滞游标,下游修复前不可公开。", + "risk": "read", + "public": false, + "reviewed": true + }, + "+list-pending": { + "disposition": "semantic_adapter", + "semantic_delta": "严格待办投影与分页已实现,但当前安全账号只有显式空结果,缺少已知非空待审批 fixture。", + "risk": "read", + "public": false, + "reviewed": true + }, + "+pending": { + "disposition": "alias_internal", + "semantic_delta": "历史首屏待办摘要缺少页码且当前没有已知非空安全 fixture;新调用应使用 +list-pending,当前保持不可用。", + "risk": "read", + "primary": "+list-pending", + "public": false, + "reviewed": true + }, + "+list-cc": { + "disposition": "semantic_adapter", + "semantic_delta": "严格抄送列表与审核过的空页终态已实现,但当前没有已知非空抄送审批 fixture,不能完成 exact Shortcut 非空证明。", + "risk": "read", + "public": false, + "reviewed": true + }, + "+approve-by": { + "disposition": "primary_smart", + "semantic_delta": "唯一定位实例和任务、确认后写入、精确任务读回的严格编排已实现;缺少可撤销的待办审批 fixture 和可靠清理路径,保持不可用。", + "risk": "high-risk-write", + "public": false, + "reviewed": true + } + } +} diff --git a/internal/shortcut/smart/approve.go b/internal/shortcut/smart/approve.go deleted file mode 100644 index 9ad9a36b..00000000 --- a/internal/shortcut/smart/approve.go +++ /dev/null @@ -1,277 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package smart - -import ( - "fmt" - "strconv" - "strings" - "time" - - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" -) - -// Approve: agree to ONE of MY pending OA approvals by matching a keyword in its -// title / order number, in a single command (钉钉原生编排能力). -// -// Steps (all tool names + params copied verbatim from helpers/oa.go): -// -// 1. list my pending approvals via list_pending_approvals (starTime / endTime -// as float64 millis over a recent window, plus query=keyword for -// server-side narrowing — mirroring `dws oa approval list-pending`); -// -// 2. scan the returned instances and keep the ones whose title / order number -// contains --keyword: none → "没找到待审批单据", many → list candidates -// (标题 + processInstanceId) so the caller can be more specific, exactly one -// → take its processInstanceId; -// -// 3. resolve the pending taskId for that instance via list_pending_tasks -// (processInstanceId), then agree via approve_processInstance -// (processInstanceId + taskId as float64, optional remark) — -// mirroring `dws oa approval tasks` + `dws oa approval approve`. -// -// dws oa +approve-by --keyword 报销 -// dws oa +approve-by --keyword 出差单 --comment "同意" -var Approve = shortcut.Shortcut{ - Service: "oa", - Command: "+approve-by", - Product: "oa", - Description: "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)", - Intent: "当你只记得某张待你审批单据的标题或单号关键词、想直接把它审批通过,却不想先翻待办列表、" + - "复制审批实例 ID 再查任务 ID 时使用;内部先拉取你近三个月待处理的审批单,按标题/单号包含关键词匹配:" + - "没匹配到会提示「没找到待审批单据」,匹配到多条会列出候选(标题+实例ID)让你写得更精确," + - "唯一命中时先取出它的待审批任务 ID,再以「同意」提交。这会真实提交审批决策且不可撤回,请确认关键词足够精确。", - Risk: shortcut.RiskWrite, - Flags: []shortcut.Flag{ - {Name: "keyword", Type: shortcut.FlagString, Desc: "待审批单据的单号或标题关键词", Required: true}, - {Name: "comment", Type: shortcut.FlagString, Desc: "审批意见(可选)", Required: false}, - }, - Tips: []string{ - `dws oa +approve-by --keyword 报销`, - `dws oa +approve-by --keyword 出差单 --comment "同意"`, - }, - Execute: func(rt *shortcut.RuntimeContext) error { - keyword := strings.TrimSpace(rt.Str("keyword")) - if keyword == "" { - return apperrors.NewValidation("请用 --keyword 提供待审批单据的单号或标题关键词") - } - - // Step 1 — list my pending approvals. starTime/endTime are float64 - // milliseconds and query narrows server-side, mirroring - // helpers.list-pending (list_pending_approvals). Window: last ~90 days. - now := time.Now() - listArgs := map[string]any{ - "starTime": float64(now.AddDate(0, 0, -90).UnixMilli()), - "endTime": float64(now.UnixMilli()), - "query": keyword, - } - data, err := rt.CallMCPData("oa", "list_pending_approvals", listArgs) - if err != nil { - return err - } - - // Step 2 — match by title / order number substring. - matches := shortcutApproveMatch(data, keyword) - switch { - case len(matches) == 0: - return apperrors.NewValidation(fmt.Sprintf( - "没找到待审批单据:待我处理的审批里没有标题/单号包含 %q 的单据。", keyword)) - case len(matches) > 1: - return apperrors.NewValidation(fmt.Sprintf( - "%q 匹配到 %d 条待审批单据,请用更精确的关键词,或用 `dws oa approval approve --instance-id --task-id` 指定:%s", - keyword, len(matches), strings.Join(shortcutApproveLabels(matches), ";"))) - } - - instanceID := matches[0].instanceID - - // Step 3a — resolve the pending taskId for this instance via - // list_pending_tasks (processInstanceId), mirroring `dws oa approval tasks`. - tasksData, err := rt.CallMCPData("oa", "list_pending_tasks", map[string]any{ - "processInstanceId": instanceID, - }) - if err != nil { - return err - } - taskID := shortcutApproveTaskID(tasksData) - if taskID == "" { - return apperrors.NewValidation(fmt.Sprintf( - "没能拿到 %q 的待审批任务 ID,请用 `dws oa approval tasks --instance-id %s` 查看后手动 approve。", - matches[0].title, instanceID)) - } - - // Step 3b — agree. taskId is passed as float64 and remark is optional, - // mirroring helpers.approve (approve_processInstance). - taskIDNum, err := strconv.ParseFloat(taskID, 64) - if err != nil { - return apperrors.NewValidation(fmt.Sprintf("任务 ID %q 不是合法数字,无法审批。", taskID)) - } - approveArgs := map[string]any{ - "processInstanceId": instanceID, - "taskId": taskIDNum, - } - if c := strings.TrimSpace(rt.Str("comment")); c != "" { - approveArgs["remark"] = c - } - return rt.CallMCP("approve_processInstance", approveArgs) - }, -} - -// shortcutApproveInstance is the minimal identity we need from a pending-approval -// entry: its processInstanceId (fed to approve/tasks) and a human title/order -// number for matching + disambiguation. -type shortcutApproveInstance struct { - instanceID string - title string -} - -// shortcutApproveMatch walks a list_pending_approvals response and returns the -// instances whose title / order number contains keyword (case-insensitive). The -// gateway wraps the list under one of several common container keys, so we probe -// them defensively (mirroring latestMinutesItems / shortcutTodoCards). -func shortcutApproveMatch(data map[string]any, keyword string) []shortcutApproveInstance { - items := shortcutApproveItems(data) - kw := strings.ToLower(keyword) - var out []shortcutApproveInstance - seen := map[string]bool{} - for _, m := range items { - id := shortcutApproveInstanceID(m) - if id == "" || seen[id] { - continue - } - title := shortcutApproveTitle(m) - // Match on either the visible title/order number or the instance id. - if !strings.Contains(strings.ToLower(title), kw) && - !strings.Contains(strings.ToLower(id), kw) { - continue - } - seen[id] = true - if title == "" { - title = id - } - out = append(out, shortcutApproveInstance{instanceID: id, title: title}) - } - return out -} - -// shortcutApproveItems pulls the list of pending-approval entries out of the -// response, probing common container shapes before scanning for a bare list. -func shortcutApproveItems(data map[string]any) []map[string]any { - for _, key := range []string{"result", "list", "instances", "items", "data", "records", "processList"} { - if arr, ok := data[key].([]any); ok { - return shortcutApproveToMaps(arr) - } - if inner, ok := data[key].(map[string]any); ok { - for _, k2 := range []string{"list", "instances", "items", "records", "result", "processList"} { - if arr, ok := inner[k2].([]any); ok { - return shortcutApproveToMaps(arr) - } - } - } - } - return nil -} - -func shortcutApproveToMaps(arr []any) []map[string]any { - out := make([]map[string]any, 0, len(arr)) - for _, it := range arr { - if m, ok := it.(map[string]any); ok { - out = append(out, m) - } - } - return out -} - -// shortcutApproveInstanceID reads an entry's processInstanceId (the value the -// helper feeds to --instance-id), tolerating a few common field names and both -// string / numeric JSON encodings. -func shortcutApproveInstanceID(m map[string]any) string { - for _, key := range []string{"processInstanceId", "processInstanceID", "instanceId", "businessId"} { - if s := shortcutApproveStr(m[key]); s != "" { - return s - } - } - return "" -} - -// shortcutApproveTitle reads a human-facing title / order number for matching -// and disambiguation, probing common field names. -func shortcutApproveTitle(m map[string]any) string { - for _, key := range []string{"title", "processTitle", "name", "subject", "businessId", "processCode"} { - if s := shortcutApproveStr(m[key]); s != "" { - return s - } - } - return "" -} - -// shortcutApproveTaskID walks a list_pending_tasks response and returns the -// first pending taskId (the value the helper feeds to --task-id). -func shortcutApproveTaskID(data map[string]any) string { - items := shortcutApproveItems(data) - // list_pending_tasks may also return a bare {"result": [...]}/task list; the - // probe above covers those. Some gateways return a single task object. - if len(items) == 0 { - if id := shortcutApproveTaskIDField(data); id != "" { - return id - } - if r, ok := data["result"].(map[string]any); ok { - if id := shortcutApproveTaskIDField(r); id != "" { - return id - } - } - return "" - } - for _, m := range items { - if id := shortcutApproveTaskIDField(m); id != "" { - return id - } - } - return "" -} - -func shortcutApproveTaskIDField(m map[string]any) string { - for _, key := range []string{"taskId", "taskID", "activityId"} { - if s := shortcutApproveStr(m[key]); s != "" { - return s - } - } - return "" -} - -// shortcutApproveStr coerces a JSON value into a string, tolerating string and -// numeric encodings (taskId / instance id can arrive as either). -func shortcutApproveStr(v any) string { - switch t := v.(type) { - case string: - return t - case float64: - return strconv.FormatFloat(t, 'f', -1, 64) - case int64: - return strconv.FormatInt(t, 10) - } - return "" -} - -func shortcutApproveLabels(items []shortcutApproveInstance) []string { - out := make([]string, 0, len(items)) - for _, c := range items { - out = append(out, fmt.Sprintf("%s(instanceId=%s)", c.title, c.instanceID)) - } - return out -} - -func init() { - shortcut.Register(Approve) -} diff --git a/internal/shortcut/smart/done_approvals.go b/internal/shortcut/smart/done_approvals.go deleted file mode 100644 index f75b51fe..00000000 --- a/internal/shortcut/smart/done_approvals.go +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package smart - -import ( - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" -) - -// DoneApprovals: READ-ONLY list of the approval tasks I have already processed -// (my approval history), projected to clean fields. Unlike `oa +approve-by` -// (which agrees to an approval) or `oa +pending` (which lists what still waits -// for me), this shortcut only looks back at what I've already handled — it never -// approves / rejects / mutates anything. -// -// Steps (tool name + param keys copied verbatim from helpers/oa.go): -// -// 1. list my done approvals via get_done_tasks with pageNumber / pageSize as -// float64, mirroring `dws oa approval list-executed`; optional --limit maps -// to pageSize (float64). This tool takes NO time window (unlike -// list_pending_approvals) — only pageNumber/pageSize/query. -// -// 2. defensively unwrap the returned instance list (reusing shortcutApproveItems -// from approve.go, multiple candidate container keys + one nested level) and -// project each entry to a readable shape {title, originatorName, -// processInstanceId, createTime}, every field probed across candidate keys. -// -// 3. if nothing has been processed, report "没有已处理的审批记录" instead of an -// empty raw dump. -// -// dws oa +done-approvals -// dws oa +done-approvals --limit 10 -var DoneApprovals = shortcut.Shortcut{ - Service: "oa", - Command: "+done-approvals", - Product: "oa", - Description: "只读列出我已处理过的审批任务(审批历史)并投影为可读列表", - Intent: "当你只想快速回看「我已经处理过(同意/拒绝)」的审批任务历史——每条的标题、发起人、审批实例 ID 和创建时间——" + - "而不想拿到一大坨原始字段时使用;内部拉取你的已办审批单,再在本地投影出可读字段。" + - "这是纯只读操作,只做列出与本地投影,绝不会同意、拒绝或以任何方式提交/修改任何审批;" + - "若没有任何已处理记录则提示「没有已处理的审批记录」。", - Risk: shortcut.RiskRead, - Flags: []shortcut.Flag{ - {Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)", Required: false}, - }, - Tips: []string{ - `dws oa +done-approvals`, - `dws oa +done-approvals --limit 10`, - }, - Execute: func(rt *shortcut.RuntimeContext) error { - // Step 1 — list my done approvals. pageNumber/pageSize are float64. Keys - // copied verbatim from helpers.list-executed (get_done_tasks). This tool - // carries no time window; pageNumber defaults to the first page. - // pageSize is required by get_done_tasks — the 1:1 list-executed always - // sends one (a missing/zero pageSize triggers a backend business error), - // so default to 20 and let --limit override. - params := map[string]any{ - "pageNumber": float64(1), - "pageSize": float64(20), - } - if rt.Changed("limit") { - if n := rt.Int("limit"); n > 0 { - params["pageSize"] = float64(n) - } - } - data, err := rt.CallMCPData("oa", "get_done_tasks", params) - if err != nil { - return err - } - - // Step 2 — project entries. shortcutApproveItems (approve.go) defensively - // unwraps the list container (result/list/instances/items/data/records/ - // processList, incl. one nested level). - items := shortcutApproveItems(data) - results := make([]map[string]any, 0, len(items)) - for _, m := range items { - results = append(results, map[string]any{ - // shortcutApproveTitle probes title/processTitle/name/subject/… - "title": shortcutApproveTitle(m), - // shortcutApproveInstanceID probes processInstanceId/instanceId/… - "processInstanceId": shortcutApproveInstanceID(m), - "originatorName": pendingApprovalsOriginator(m), - "createTime": pendingApprovalsCreateTime(m), - }) - } - - // Step 3 — empty result guard. - if len(results) == 0 { - return apperrors.NewValidation("没有已处理的审批记录") - } - - return rt.Output(map[string]any{"count": len(results), "done": results}) - }, -} - -func init() { - shortcut.Register(DoneApprovals) -} diff --git a/internal/shortcut/smart/my_initiated.go b/internal/shortcut/smart/my_initiated.go deleted file mode 100644 index 195fb6a9..00000000 --- a/internal/shortcut/smart/my_initiated.go +++ /dev/null @@ -1,247 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package smart - -import ( - "strconv" - "strings" - - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" - - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" -) - -// MyInitiated: list the OA approval instances *I* have initiated (submitted), in -// one step. -// -// Steps: -// -// 1. call get_submitted_instances on the OA server — the exact tool + parameter -// names (pageNumber / pageSize as float64, optional query) used by -// helpers.approvalSubmittedListCmd ("dws oa approval list-submitted"). page -// and limit default to the same first-page values (page 1, limit 20). -// -// 2. defensively locate the instance list inside the response (probing common -// container keys, incl. a nested result/data object) and project each item -// down to {title, businessId, status, processInstanceId} with multiple -// candidate keys per field. When no recognisable list is found we print the -// raw payload so nothing is silently lost. -// -// 3. print via rt.Output so it honours --format / --jq / --fields. -// -// Read-only: it only lists and reshapes my submitted approvals, it never -// approves, rejects, revokes or mutates anything. -// -// dws oa +my-initiated -// dws oa +my-initiated --query 报销 -// dws oa +my-initiated --page 2 --limit 50 -var MyInitiated = shortcut.Shortcut{ - Service: "oa", - Command: "+my-initiated", - Product: "oa", - Description: "列出我发起(提交)的审批单据", - Intent: "当你想快速看清自己发起(提交)过哪些 OA 审批单据、方便跟进它们的进展时使用;" + - "内部直接拉取当前用户已发起的审批实例列表(等价于 dws oa approval list-submitted)," + - "再在本地把每条单据投影成标题、单号(businessId)、状态和审批实例 ID(processInstanceId) 四个关键字段。" + - "可用 --query 按关键字过滤、--page/--limit 翻页(默认第 1 页、每页 20 条)。" + - "这是纯只读操作,只做列表与本地投影,不会同意、拒绝、撤销或修改任何审批单据;若没有发起过审批则返回空列表。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_my_initiated", - CanonicalPath: "oa.shortcut_my_initiated", - CLIPath: "oa +my-initiated", - PrimaryCLIPath: "oa +my-initiated", - }, - Description: "列出我发起(提交)的审批单据", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "列出我发起(提交)的审批单据", - UseWhen: []string{"当你想快速看清自己发起(提交)过哪些 OA 审批单据、方便跟进它们的进展时使用;内部直接拉取当前用户已发起的审批实例列表(等价于 dws oa approval list-submitted),再在本地把每条单据投影成标题、单号(businessId)、状态和审批实例 ID(processInstanceId) 四个关键字段。可用 --query 按关键字过滤、--page/--limit 翻页(默认第 1 页、每页 20 条)。这是纯只读操作,只做列表与本地投影,不会同意、拒绝、撤销或修改任何审批单据;若没有发起过审批则返回空列表。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{ - "dws oa +my-initiated", - "dws oa +my-initiated --query 报销", - }, - }, - }, - Flags: []shortcut.Flag{ - {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索(可选)", Required: false}, - {Name: "page", Type: shortcut.FlagInt, Desc: "分页页码(可选,默认 1)", Default: "1", Required: false}, - {Name: "limit", Type: shortcut.FlagInt, Desc: "每页大小(可选,默认 20)", Default: "20", Required: false}, - }, - Tips: []string{ - `dws oa +my-initiated`, - `dws oa +my-initiated --query 报销`, - `dws oa +my-initiated --page 2 --limit 50`, - }, - Execute: func(rt *shortcut.RuntimeContext) error { - // Step 1 — list my submitted approval instances. pageNumber/pageSize are - // float64 and query is optional, mirroring helpers.approvalSubmittedListCmd. - page := rt.Int("page") - if page <= 0 { - page = 1 - } - limit := rt.Int("limit") - if limit <= 0 { - limit = 20 - } - params := map[string]any{ - "pageNumber": float64(page), - "pageSize": float64(limit), - } - if q := strings.TrimSpace(rt.Str("query")); q != "" { - params["query"] = q - } - - data, err := rt.CallMCPData("oa", "get_submitted_instances", params) - if err != nil { - return err - } - - // Step 2 — project the instance list; fall back to the raw payload when we - // cannot locate a recognisable list. - items := myInitiatedItems(data) - if len(items) == 0 { - return rt.Output(data) - } - results := make([]map[string]any, 0, len(items)) - for _, m := range items { - results = append(results, map[string]any{ - "title": myInitiatedTitle(m), - "businessId": myInitiatedBusinessID(m), - "status": myInitiatedStatus(m), - "processInstanceId": myInitiatedInstanceID(m), - }) - } - - // Step 3 — print the projected list. - return rt.Output(map[string]any{"initiated": results}) - }, -} - -// myInitiatedItems locates the instance list inside a get_submitted_instances -// response, probing common container keys at the top level and nested under a -// result/data object. Returns nil when no list is found. -func myInitiatedItems(data map[string]any) []map[string]any { - if data == nil { - return nil - } - keys := []string{"list", "instances", "processInstances", "items", "data", "records", "result", "rows"} - for _, key := range keys { - if arr, ok := data[key].([]any); ok { - return myInitiatedToMaps(arr) - } - if inner, ok := data[key].(map[string]any); ok { - for _, k2 := range []string{"list", "instances", "processInstances", "items", "data", "records", "rows"} { - if arr, ok := inner[k2].([]any); ok { - return myInitiatedToMaps(arr) - } - } - } - } - return nil -} - -func myInitiatedToMaps(arr []any) []map[string]any { - out := make([]map[string]any, 0, len(arr)) - for _, it := range arr { - if m, ok := it.(map[string]any); ok { - out = append(out, m) - } - } - return out -} - -// myInitiatedTitle reads an instance's human-readable title, tolerating the -// common title keys the gateway may use. -func myInitiatedTitle(m map[string]any) any { - for _, key := range []string{"title", "subject", "name", "processName", "formName", "instanceTitle"} { - if v := myInitiatedString(m[key]); v != "" { - return v - } - } - return nil -} - -// myInitiatedBusinessID reads an instance's business / order number, preferring -// the customer-visible business id and falling back to related identifiers. -func myInitiatedBusinessID(m map[string]any) any { - for _, key := range []string{"businessId", "bizId", "orderNo", "orderNumber", "number", "serialNumber"} { - if v := myInitiatedString(m[key]); v != "" { - return v - } - } - return nil -} - -// myInitiatedStatus reads an instance's current status/result, tolerating both -// the running status and the final result keys. -func myInitiatedStatus(m map[string]any) any { - for _, key := range []string{"status", "statusText", "processStatus", "instanceStatus", "result", "approvalResult"} { - if v := myInitiatedString(m[key]); v != "" { - return v - } - } - return nil -} - -// myInitiatedInstanceID reads an instance's processInstanceId, tolerating the -// common id keys. -func myInitiatedInstanceID(m map[string]any) any { - for _, key := range []string{"processInstanceId", "instanceId", "processInstanceID", "id", "bizId"} { - if v := myInitiatedString(m[key]); v != "" { - return v - } - } - return nil -} - -// myInitiatedString coerces a scalar JSON value to a trimmed string, returning -// "" for nil / non-scalar / empty values. -func myInitiatedString(v any) string { - switch typed := v.(type) { - case string: - return strings.TrimSpace(typed) - case float64: - // approval ids/numbers may arrive as JSON numbers; render without a - // trailing decimal point when integral. - return strconv.FormatFloat(typed, 'f', -1, 64) - case int64: - return strconv.FormatInt(typed, 10) - case int: - return strconv.Itoa(typed) - case bool: - if typed { - return "true" - } - return "false" - default: - return "" - } -} - -func init() { - shortcut.Register(MyInitiated) -} diff --git a/internal/shortcut/smart/pending_approvals.go b/internal/shortcut/smart/pending_approvals.go deleted file mode 100644 index 3c4e34bd..00000000 --- a/internal/shortcut/smart/pending_approvals.go +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package smart - -import ( - "time" - - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" -) - -// PendingApprovals: READ-ONLY list of the approvals waiting for ME, projected to -// clean fields. Unlike `oa +approve-by` (which actually agrees to an approval), -// this shortcut only looks — it never approves / rejects / mutates anything. -// -// Steps (tool name + param keys copied verbatim from helpers/oa.go): -// -// 1. list my pending approvals via list_pending_approvals with starTime / -// endTime as float64 milliseconds over a recent (~90 day) window, mirroring -// `dws oa approval list-pending`; optional --limit maps to pageSize (float64). -// -// 2. defensively unwrap the returned instance list (multiple candidate -// container keys, one nested level) and project each entry to a readable -// shape {title, originatorName, processInstanceId, createTime} — every field -// probed across several candidate keys. -// -// 3. if nothing is pending, report "当前没有待我审批的任务" instead of an empty -// raw dump. -// -// dws oa +pending -// dws oa +pending --limit 10 -var PendingApprovals = shortcut.Shortcut{ - Service: "oa", - Command: "+pending", - Product: "oa", - Description: "只读列出待我审批的审批任务并投影为可读列表(只看不批)", - Intent: "当你只想快速看一眼「待我审批」的审批任务清单——每条的标题、发起人、审批实例 ID 和创建时间——" + - "而不想拿到一大坨原始字段时使用;内部拉取你近三个月待处理的审批单,再在本地投影出可读字段。" + - "这是纯只读操作,只做列出与本地投影,绝不会同意、拒绝或以任何方式提交/修改审批(要一键通过请改用 `dws oa +approve-by`);" + - "若当前没有待你审批的任务则提示「当前没有待我审批的任务」。", - Risk: shortcut.RiskRead, - Flags: []shortcut.Flag{ - {Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)", Required: false}, - }, - Tips: []string{ - `dws oa +pending`, - `dws oa +pending --limit 10`, - }, - Execute: func(rt *shortcut.RuntimeContext) error { - // Step 1 — list my pending approvals. starTime/endTime are float64 - // milliseconds; pageSize is optional. Keys copied verbatim from - // helpers.list-pending (list_pending_approvals). Window: last ~90 days. - now := time.Now() - params := map[string]any{ - "starTime": float64(now.AddDate(0, 0, -90).UnixMilli()), - "endTime": float64(now.UnixMilli()), - } - if rt.Changed("limit") { - if n := rt.Int("limit"); n > 0 { - params["pageSize"] = float64(n) - } - } - data, err := rt.CallMCPData("oa", "list_pending_approvals", params) - if err != nil { - return err - } - - // Step 2 — project entries. shortcutApproveItems (approve.go) defensively - // unwraps the list container (result/list/instances/items/data/records/ - // processList, incl. one nested level). - items := shortcutApproveItems(data) - results := make([]map[string]any, 0, len(items)) - for _, m := range items { - results = append(results, map[string]any{ - // shortcutApproveTitle probes title/processTitle/name/subject/… - "title": shortcutApproveTitle(m), - // shortcutApproveInstanceID probes processInstanceId/instanceId/… - "processInstanceId": shortcutApproveInstanceID(m), - "originatorName": pendingApprovalsOriginator(m), - "createTime": pendingApprovalsCreateTime(m), - }) - } - - // Step 3 — empty result guard. - if len(results) == 0 { - return apperrors.NewValidation("当前没有待我审批的任务") - } - - return rt.Output(map[string]any{"count": len(results), "pending": results}) - }, -} - -// pendingApprovalsOriginator reads the initiator's display name, tolerating the -// common field names the gateway may use. -func pendingApprovalsOriginator(m map[string]any) string { - for _, key := range []string{"originatorName", "originatorUserName", "creatorName", "creator", "applicantName", "userName"} { - if s := shortcutApproveStr(m[key]); s != "" { - return s - } - } - return "" -} - -// pendingApprovalsCreateTime reads a pending approval's create time, returning -// the raw value (usually epoch millis) under whichever candidate key is present. -func pendingApprovalsCreateTime(m map[string]any) any { - for _, key := range []string{"createTime", "gmtCreate", "createTimeStr", "startTime", "createdAt"} { - if v, ok := m[key]; ok && v != nil { - return v - } - } - return nil -} - -func init() { - shortcut.Register(PendingApprovals) -} diff --git a/scripts/gen_shortcut_public_catalog.py b/scripts/gen_shortcut_public_catalog.py index 5c1e6546..41ae4e61 100644 --- a/scripts/gen_shortcut_public_catalog.py +++ b/scripts/gen_shortcut_public_catalog.py @@ -29,6 +29,7 @@ SEMANTIC_PATHS = [ ROOT / "internal" / "shortcut" / "semantic_catalog_todo.json", ROOT / "internal" / "shortcut" / "semantic_catalog_attendance.json", ROOT / "internal" / "shortcut" / "semantic_catalog_mail.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_oa.json", ] From 2cb83d388b11aad5231ae59b8780480975beccc0 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 16:10:54 +0800 Subject: [PATCH 03/28] fix(shortcut): publish OA validation constraints --- internal/shortcut/oa/oa.go | 14 +++++++++----- internal/shortcut/oa/smart.go | 18 +++++++++++------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/internal/shortcut/oa/oa.go b/internal/shortcut/oa/oa.go index 31364c46..e6dc70aa 100644 --- a/internal/shortcut/oa/oa.go +++ b/internal/shortcut/oa/oa.go @@ -77,7 +77,8 @@ var ListPending = shortcut.Shortcut{ {Name: "limit", Type: shortcut.FlagString, Desc: "每页大小"}, {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, }, - Tips: []string{`dws oa +list-pending --start 1741536000000 --end 1741622399000 --page 1 --limit 20`}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end", "page", "limit"}, Description: "--start/--end 必须是递增的正整数 epoch 毫秒;--page 必须大于 0;--limit 必须在 1-100"}}, + Tips: []string{`dws oa +list-pending --start 1741536000000 --end 1741622399000 --page 1 --limit 20`}, Validate: func(rt *shortcut.RuntimeContext) error { if rt.Int("start") <= 0 || rt.Int("end") <= rt.Int("start") { return apperrors.NewValidation("--start/--end 必须是递增的正整数 epoch 毫秒范围") @@ -126,7 +127,8 @@ var ListForms = shortcut.Shortcut{ {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"}, {Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页大小,最大 100"}, }, - Tips: []string{`dws oa +list-forms --cursor 0 --limit 100`}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"cursor", "limit"}, Description: "--cursor 不能小于 0;--limit 必须在 1-100"}}, + Tips: []string{`dws oa +list-forms --cursor 0 --limit 100`}, Validate: func(rt *shortcut.RuntimeContext) error { if rt.Int("cursor") < 0 { return apperrors.NewValidation("--cursor 不能小于 0") @@ -172,8 +174,9 @@ var SearchForms = shortcut.Shortcut{ []contract.ParamDecl{{Name: "query", Property: "query"}}, "dws oa +search-forms --query 报销", ), - Flags: []shortcut.Flag{{Name: "query", Type: shortcut.FlagString, Desc: "关键字(匹配 processCode 或表单名称)", Required: true}}, - Tips: []string{`dws oa +search-forms --query 报销`}, + Flags: []shortcut.Flag{{Name: "query", Type: shortcut.FlagString, Desc: "关键字(匹配 processCode 或表单名称)", Required: true}}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "--query 去除空白后不能为空"}}, + Tips: []string{`dws oa +search-forms --query 报销`}, Validate: func(rt *shortcut.RuntimeContext) error { if rt.Str("query") == "" { return apperrors.NewValidation("--query 不能为空") @@ -208,7 +211,8 @@ func oaNumberedInstanceShortcut(command, tool, description, intent string) short {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"}, {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, }, - Tips: []string{"dws oa " + command + " --page 1 --limit 20"}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"page", "limit"}, Description: "--page 必须大于 0;--limit 必须在 1-100"}}, + Tips: []string{"dws oa " + command + " --page 1 --limit 20"}, } declaration.Validate = func(rt *shortcut.RuntimeContext) error { page, err := parseOAStringPage(rt, "page", 1) diff --git a/internal/shortcut/oa/smart.go b/internal/shortcut/oa/smart.go index 90c86950..8acc5d14 100644 --- a/internal/shortcut/oa/smart.go +++ b/internal/shortcut/oa/smart.go @@ -52,8 +52,9 @@ var PendingApprovals = shortcut.Shortcut{ oaCollectionResult("pending", "严格验证的待审批摘要"), nil, []contract.ParamDecl{{Name: "limit", Property: "pageSize"}}, "dws oa +pending --limit 10", ), - Flags: []shortcut.Flag{{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)"}}, - Tips: []string{`dws oa +pending --limit 10`}, + Flags: []shortcut.Flag{{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)"}}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式 --limit 必须在 1-100"}}, + Tips: []string{`dws oa +pending --limit 10`}, Validate: func(rt *shortcut.RuntimeContext) error { if rt.Changed("limit") && (rt.Int("limit") <= 0 || rt.Int("limit") > 100) { return apperrors.NewValidation("--limit 必须在 1 到 100 之间") @@ -83,8 +84,9 @@ var DoneApprovals = shortcut.Shortcut{ oaCollectionResult("done", "严格验证的已处理审批摘要"), nil, []contract.ParamDecl{{Name: "limit", Property: "pageSize"}}, "dws oa +done-approvals --limit 10", ), - Flags: []shortcut.Flag{{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)"}}, - Tips: []string{`dws oa +done-approvals --limit 10`}, + Flags: []shortcut.Flag{{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)"}}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式 --limit 必须在 1-100"}}, + Tips: []string{`dws oa +done-approvals --limit 10`}, Validate: func(rt *shortcut.RuntimeContext) error { if rt.Changed("limit") && (rt.Int("limit") <= 0 || rt.Int("limit") > 100) { return apperrors.NewValidation("--limit 必须在 1 到 100 之间") @@ -119,8 +121,9 @@ var MyInitiated = shortcut.Shortcut{ {Name: "page", Type: shortcut.FlagInt, Desc: "分页页码(可选,默认 1)", Default: "1"}, {Name: "limit", Type: shortcut.FlagInt, Desc: "每页大小(可选,默认 20)", Default: "20"}, }, - Tips: []string{`dws oa +my-initiated`, `dws oa +my-initiated --query 报销`, `dws oa +my-initiated --page 2 --limit 50`}, - Validate: func(rt *shortcut.RuntimeContext) error { return validateOAPage(rt.Int("page"), rt.Int("limit")) }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"page", "limit"}, Description: "--page 必须大于 0;--limit 必须在 1-100"}}, + Tips: []string{`dws oa +my-initiated`, `dws oa +my-initiated --query 报销`, `dws oa +my-initiated --page 2 --limit 50`}, + Validate: func(rt *shortcut.RuntimeContext) error { return validateOAPage(rt.Int("page"), rt.Int("limit")) }, Execute: func(rt *shortcut.RuntimeContext) error { const operation = "oa/get_submitted_instances" params := map[string]any{"pageNumber": float64(rt.Int("page")), "pageSize": float64(rt.Int("limit"))} @@ -184,7 +187,8 @@ var Approve = shortcut.Shortcut{ {Name: "keyword", Type: shortcut.FlagString, Desc: "待审批单据的单号或标题关键词", Required: true}, {Name: "comment", Type: shortcut.FlagString, Desc: "审批意见(可选)"}, }, - Tips: []string{`dws oa +approve-by --keyword 报销`, `dws oa +approve-by --keyword 出差单 --comment "同意"`}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"keyword"}, Description: "--keyword 去除空白后不能为空,且必须唯一匹配完整待办集合中的一条实例"}}, + Tips: []string{`dws oa +approve-by --keyword 报销`, `dws oa +approve-by --keyword 出差单 --comment "同意"`}, Validate: func(rt *shortcut.RuntimeContext) error { if rt.Str("keyword") == "" { return apperrors.NewValidation("--keyword 不能为空") From 4da5a07d1dfdb9cf41ab71c2da37e1a87b981f2c Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 16:18:26 +0800 Subject: [PATCH 04/28] feat(shortcut): harden DING reads and availability --- docs/shortcut-downstream-requirements/ding.md | 78 +++++ docs/shortcut-public-catalog.json | 28 +- internal/app/schema_shortcut_contract_test.go | 2 +- .../builtin/ding_semantic_catalog_test.go | 83 +++++ internal/shortcut/ding/common.go | 315 ++++++++++++++++++ internal/shortcut/ding/ding.go | 303 +++++++---------- ...ing_strict_cross_platform_coverage_test.go | 231 +++++++++++++ internal/shortcut/public_catalog_generated.go | 3 - internal/shortcut/semantic_catalog.go | 4 + internal/shortcut/semantic_catalog_ding.json | 43 +++ scripts/gen_shortcut_public_catalog.py | 1 + 11 files changed, 880 insertions(+), 211 deletions(-) create mode 100644 docs/shortcut-downstream-requirements/ding.md create mode 100644 internal/shortcut/builtin/ding_semantic_catalog_test.go create mode 100644 internal/shortcut/ding/common.go create mode 100644 internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go create mode 100644 internal/shortcut/semantic_catalog_ding.json diff --git a/docs/shortcut-downstream-requirements/ding.md b/docs/shortcut-downstream-requirements/ding.md new file mode 100644 index 00000000..0d35fa99 --- /dev/null +++ b/docs/shortcut-downstream-requirements/ding.md @@ -0,0 +1,78 @@ +# DING Shortcut 下游业务能力需求规格 + +> 日期:2026-08-18 +> DWS 基线:`c0b286c1e10b6fcebdff4ed522d3b1cf30549866` +> 对比基线:lark-cli 1.0.87 +> 范围:Shortcut-only DING surface + +## 1. 执行摘要 + +- 源码 Shortcut:5;公开:1;unavailable:4。 +- 读取严格化:PASS。`+list` 与 `+receiver-status` 只接受显式布尔成功、准确结果路径、正确集合类型和合法元素。 +- 分页真实性:PASS(拒绝虚假成功)。`+list` 仅在 `hasMore` 与数值 `nextCursor` 一致且游标前进时发布续页信息;真实续页返回停滞游标,严格实现返回 `stalled_cursor`,因此该能力保持 unavailable。 +- 真实读取:PASS。`+list` 已执行已知非空页和保证零项页;`+receiver-status` 已对列表中稳定身份执行精确非空查询。 +- 写安全:PASS。3 个写 Shortcut 均需确认,确认前 0 次下游调用;因缺少可逆 fixture、精确接收人读回和零残留清理,确认后也由本地能力门禁停止。 +- 脱敏:PASS。本文仅保留聚合数量与 PASS 标签,不保存 ID、标题、人名、正文、原始响应或请求追踪信息。 + +## 2. 用户任务与 Lark 映射 + +| 用户任务 | DWS Shortcut | lark-cli 1.0.87 | 状态 | +|---|---|---|---| +| 查询 DING 历史并取得稳定身份 | `ding +list` | 无直接任务 | unavailable;真实续页游标停滞 | +| 查询 DING 接收状态 | `ding +receiver-status` | `im messages read_users` | PASS / public;平台身份模型不同 | +| 基于已有消息发送应用内加急 | `ding +send-by-message --type app` | `im messages urgent_app` | unavailable | +| 基于已有消息发送短信加急 | `ding +send-by-message --type sms` | `im messages urgent_sms` | unavailable | +| 基于已有消息发送电话加急 | `ding +send-by-message --type call` | `im messages urgent_phone` | unavailable | +| 以用户身份新建 DING | `ding +send-personal` | 无直接任务 | unavailable;DWS 产品原生机会 | +| 撤回用户身份 DING | `ding +recall-personal` | 无直接任务 | unavailable;DWS 产品原生机会 | + +## 3. 真实 E2E 聚合矩阵 + +| Exact Shortcut | 已知非空 | 保证零项 | 身份/分页 | 写后读回 | 清理 | 结论 | +|---|---:|---:|---|---|---|---| +| `+list` | PASS(50) | PASS(0) | PASS(严格拒绝);续页游标停滞 | 不适用 | 不适用 | unavailable | +| `+receiver-status` | PASS(1) | 不适用:精确 ID 查询 | PASS:全部返回项身份匹配 | 不适用 | 不适用 | public | +| `+send-personal` | 不适用 | 不适用 | 不适用 | BLOCKED | BLOCKED | unavailable | +| `+send-by-message` | 不适用 | 不适用 | 不适用 | BLOCKED | BLOCKED | unavailable | +| `+recall-personal` | 不适用 | 不适用 | 不适用 | BLOCKED | BLOCKED | unavailable | + +## 4. 下游需求 + +### DS-DING-001 — 稳定写回执与接收人身份 + +- 优先级:P1;类型:contract insufficient。 +- 影响:`+send-personal`、`+send-by-message`。 +- 需要明确布尔业务成功、稳定 `openDingId`、逐接收人稳定身份与受理状态;集合必须始终存在且类型固定,不能用空响应或仅 HTTP 成功表示业务成功。 +- 写后读取必须能按同一 `openDingId` 返回稳定接收人身份,以精确比对请求集合,而不是仅返回显示名。 +- 幂等键重复提交必须返回同一资源或稳定冲突语义,并标明是否已开始执行与是否可安全重试。 +- 验收:确认前 0 调用;确认后写入一次;按稳定身份精确读回全部接收人;清理后再次读取证明无残留。 + +### DS-DING-002 — 可验证撤回终态 + +- 优先级:P1;类型:missing capability。 +- 影响:`+recall-personal` 及两种发送能力的清理闭环。 +- 撤回应返回稳定目标身份与明确终态,或提供按该身份查询撤回状态的只读接口。 +- `success=true` 单一回执不足以证明接收端通知已撤回;需要可查询的 terminal 状态,并区分未开始、已完成、部分撤回与未知效果。 +- 验收:创建专用可回滚 fixture;精确撤回;按同一身份读回终态;重复撤回具有稳定幂等语义;无通知残留。 + +### DS-DING-003 — 游标边界语义 + +- 优先级:P2;类型:contract insufficient。 +- 影响:`+list` 的跨页聚合调用方。 +- 当前真实续页返回的 `nextCursor` 未严格前进,且基线相邻页还观察到 1 个稳定身份边界重复项。下游需声明 `nextCursor` 是包含式还是排除式边界,并保证续页凭据可以推进。 +- 验收:连续两页均返回合法分页事实;游标严格前进;跨页无重复,或合同明确要求调用方按稳定身份去重;终页明确 `hasMore=false` 且无虚假续页凭据。 + +## 5. Fixture、权限与清理 + +| 需求 | 安全资源模型 | 到期/回收 | 状态 | +|---|---|---|---| +| 应用内 DING 写 fixture | 专用测试接收人和无业务正文 | 用后立即撤回并读回终态 | BLOCKED | +| 短信/电话 DING fixture | 隔离租户、显式成本授权、专用号码 | 用后核对计费与通知残留 | BLOCKED | +| 消息转 DING fixture | 专用测试会话与可删除源消息 | 撤回 DING 后删除源消息 | BLOCKED | +| 接收状态读取 fixture | 由公开列表取得稳定 DING 身份 | 只读,无残留 | PASS | + +## 6. 安全与脱敏声明 + +- PASS:不含用户、组织、租户、profile、消息、会话或 DING 的真实 ID。 +- PASS:不含标题、正文、人名、邮箱、电话、精确业务时间、trace/request ID、token 或原始响应。 +- PASS:真实证据仅记录命令级标签和聚合计数;未将本地诊断响应提交到仓库。 diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index 8499b8bc..3df4be3e 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -2494,32 +2494,14 @@ "status": "real-ok" }, { - "suite": "read", - "service": "ding", - "command": "+list", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "write", - "service": "ding", - "command": "+recall-personal", - "risk": "high-risk-write", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "ding", "command": "+receiver-status", "risk": "read", - "status": "real-ok" - }, - { - "suite": "write", - "service": "ding", - "command": "+send-personal", - "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;exact Shortcut 已完成真实非空身份匹配证明。", + "availability": "available" }, { "suite": "semantic", diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index 82f982fb..68a1db5e 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -18,7 +18,7 @@ import ( const ( publicShortcutCount = 422 // schemaPublishedShortcutCount counts every delivered *.shortcut_* tool, - // including the hidden historical minutes.shortcut_minutes_search contract. + // including reviewed hidden compatibility and unavailable contracts. schemaPublishedShortcutCount = 447 // publiclyDeliveredShortcutCount is the public-catalog subset of that surface. publiclyDeliveredShortcutCount = 422 diff --git a/internal/shortcut/builtin/ding_semantic_catalog_test.go b/internal/shortcut/builtin/ding_semantic_catalog_test.go new file mode 100644 index 00000000..f944093d --- /dev/null +++ b/internal/shortcut/builtin/ding_semantic_catalog_test.go @@ -0,0 +1,83 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageDINGSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_ding.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "ding" { + registered[item.Command] = item + } + } + if source.Service != "ding" || len(registered) != 5 || len(source.Shortcuts) != 5 { + t.Fatalf("service/registered/catalog=%s/%d/%d, want ding/5/5", source.Service, len(registered), len(source.Shortcuts)) + } + public, unavailable := 0, 0 + var missing, stale []string + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + availability := record.Availability + if availability == "" { + availability = source.Availability + } + if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s semantic review facts drifted", command) + } + if item.Risk != record.Risk || item.Availability != availability { + t.Errorf("%s risk/availability=%q/%q want=%q/%q", command, item.Risk, item.Availability, record.Risk, availability) + } + if record.Public { + public++ + if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("ding", command) { + t.Errorf("%s public availability/visibility drift", command) + } + if item.Contract.Empty() || item.Contract.Result == nil || item.Safety.Effect == "" || item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s lacks public Contract/Safety/Result/unified output", command) + } + } else { + if !item.Hidden || shortcut.InPublicCatalog("ding", command) { + t.Errorf("%s nonpublic shortcut is visible", command) + } + if availability == shortcut.AvailabilityUnavailable { + unavailable++ + } + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) > 0 || len(stale) > 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } + if public != 1 || unavailable != 4 { + t.Fatalf("public/unavailable=%d/%d, want 1/4", public, unavailable) + } +} diff --git a/internal/shortcut/ding/common.go b/internal/shortcut/ding/common.go new file mode 100644 index 00000000..719b15eb --- /dev/null +++ b/internal/shortcut/ding/common.go @@ -0,0 +1,315 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package ding + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +const ( + dingCompositeReason = "Reviewed DING Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable DING identity checks, truthful pagination, and unified output projection." + dingWriteUnavailableReason = "DING mutation is unavailable to Agents until a dedicated reversible fixture proves confirmation-before-call, a stable write receipt, exact receiver/readback identity, and cleanup without residual notifications." +) + +type dingPageEvidence struct { + HasMore bool + Next string +} + +func dingReadSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} + +func dingWriteSafety(destructive bool) contract.SafetySpec { + effect, risk := "write", "medium" + if destructive { + effect, risk = "destructive", "high" + } + return contract.SafetySpec{Effect: effect, Risk: risk, Confirmation: "user_required", Idempotency: "unknown"} +} + +func dingListResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"严格验证的 DING 消息页","properties":{"count":{"type":"integer","description":"当前页有效 DING 消息数量"},"messages":{"type":"array","description":"严格验证并投影后的 DING 消息","items":{"type":"object","description":"一条具有稳定 openDingId 的 DING 消息","properties":{"openDingId":{"type":"string","description":"稳定 DING 消息 ID"},"content":{"type":"string","description":"DING 消息内容"},"sourceMessageId":{"type":"string","description":"作为 DING 来源的聊天消息 ID"},"sendTime":{"type":"string","description":"服务端返回的发送时间"},"senderNick":{"type":"string","description":"发送人显示名"}},"required":["openDingId"],"additionalProperties":false}},"complete":{"type":"boolean","description":"当前页是否具有服务端分页终态证据"}},"required":["count","messages","complete"],"additionalProperties":false}`), + SensitivePaths: []string{"messages.content", "messages.senderNick"}, + } +} + +func dingReceiverResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"指定 DING 的严格接收状态","properties":{"count":{"type":"integer","description":"经验证的接收状态数量"},"receivers":{"type":"array","description":"身份匹配的 DING 接收状态","items":{"type":"object","description":"一名接收人的 DING 确认状态","properties":{"openDingId":{"type":"string","description":"与请求一致的稳定 DING ID"},"confirmedStatus":{"type":"integer","description":"服务端 DING 确认状态码"},"receiverNick":{"type":"string","description":"接收人显示名"}},"required":["openDingId","confirmedStatus","receiverNick"],"additionalProperties":false}}},"required":["count","receivers"],"additionalProperties":false}`), + SensitivePaths: []string{"receivers.receiverNick"}, + } +} + +func dingPagination() *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, + CursorParameter: "cursor", + MetaPath: contract.PaginationMetaPath, + EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + +func dingContract(command, description, intent string, available bool, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl { + name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_") + cliPath := "ding " + command + availability, reason := contract.InterfaceAvailable, dingCompositeReason + if !available { + availability, reason = contract.InterfaceUnavailable, dingWriteUnavailableReason + } + return corecmd.ContractDecl{ + Description: description, + Result: result, + Pagination: pagination, + Parameters: params, + Identity: contract.ToolIdentitySpec{ + ProductID: "ding", Name: name, CanonicalPath: "ding." + name, + CLIPath: cliPath, PrimaryCLIPath: cliPath, + }, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: availability, Reason: reason}, + Selection: contract.SelectionSpec{ + AgentSummary: description, + UseWhen: []string{intent}, + AvoidWhen: []string{"普通聊天消息使用 chat;写能力在缺少可逆真实 fixture、精确读回或清理证据时不要执行"}, + Examples: examples, + }, + } +} + +func dingResponseError(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + +func dingRequireResult(data map[string]any, operation string) (map[string]any, error) { + if len(data) == 0 { + return nil, dingResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明 DING 调用成功或结果确实为空") + } + success, present := data["success"] + if !present { + return nil, dingResponseError(operation, "missing_success", "DING 响应缺少 success 业务状态") + } + succeeded, ok := success.(bool) + if !ok { + return nil, dingResponseError(operation, "invalid_success_type", fmt.Sprintf("DING 响应 success 应为布尔值,实际为 %T", success)) + } + if !succeeded { + message := "DING 服务明确返回失败" + for _, key := range []string{"errorMsg", "errorMessage", "message"} { + if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" { + message = strings.TrimSpace(value) + break + } + } + return nil, dingResponseError(operation, "remote_failure", message) + } + result, ok := data["result"].(map[string]any) + if !ok || result == nil { + return nil, dingResponseError(operation, "missing_result", "DING 成功响应缺少 result 对象") + } + return result, nil +} + +func dingRequireObjectCollection(result map[string]any, operation, field string) ([]map[string]any, error) { + raw, present := result[field] + if !present { + return nil, dingResponseError(operation, "missing_collection", fmt.Sprintf("DING 成功响应缺少 result.%s 数组", field)) + } + values, ok := raw.([]any) + if !ok { + return nil, dingResponseError(operation, "malformed_collection", fmt.Sprintf("DING result.%s 应为数组,实际为 %T", field, raw)) + } + items := make([]map[string]any, 0, len(values)) + for index, value := range values { + item, ok := value.(map[string]any) + if !ok || len(item) == 0 { + return nil, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING result.%s[%d] 不是非空对象", field, index)) + } + items = append(items, item) + } + return items, nil +} + +func dingRequiredString(item map[string]any, operation, field string, index int) (string, error) { + value, ok := item[field].(string) + value = strings.TrimSpace(value) + if !ok || value == "" { + return "", dingResponseError(operation, "missing_item_identity", fmt.Sprintf("DING 结果第 %d 项缺少非空 %s", index, field)) + } + return value, nil +} + +func dingOptionalString(item map[string]any, field string) (string, error) { + value, present := item[field] + if !present || value == nil { + return "", nil + } + text, ok := value.(string) + if !ok { + return "", fmt.Errorf("字段 %s 应为字符串,实际为 %T", field, value) + } + return strings.TrimSpace(text), nil +} + +func dingInteger(value any) (int64, bool) { + switch typed := value.(type) { + case float64: + if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed || typed > math.MaxInt64 || typed < math.MinInt64 { + return 0, false + } + return int64(typed), true + case int: + return int64(typed), true + case int64: + return typed, true + case json.Number: + parsed, err := typed.Int64() + return parsed, err == nil + default: + return 0, false + } +} + +func dingProjectMessages(data map[string]any, operation string) ([]map[string]any, dingPageEvidence, error) { + result, err := dingRequireResult(data, operation) + if err != nil { + return nil, dingPageEvidence{}, err + } + items, err := dingRequireObjectCollection(result, operation, "dingMessages") + if err != nil { + return nil, dingPageEvidence{}, err + } + messages := make([]map[string]any, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for index, item := range items { + id, identityErr := dingRequiredString(item, operation, "openDingId", index) + if identityErr != nil { + return nil, dingPageEvidence{}, identityErr + } + if _, duplicate := seen[id]; duplicate { + return nil, dingPageEvidence{}, dingResponseError(operation, "duplicate_item_identity", "当前 DING 页包含重复 openDingId") + } + seen[id] = struct{}{} + projected := map[string]any{"openDingId": id} + for source, target := range map[string]string{ + "dingContent": "content", "dingSourceOpenMessageId": "sourceMessageId", + "sendTime": "sendTime", "senderNick": "senderNick", + } { + value, valueErr := dingOptionalString(item, source) + if valueErr != nil { + return nil, dingPageEvidence{}, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING 结果第 %d 项%s", index, valueErr.Error())) + } + if value != "" { + projected[target] = value + } + } + messages = append(messages, projected) + } + hasMoreValue, present := result["hasMore"] + if !present { + return nil, dingPageEvidence{}, dingResponseError(operation, "missing_pagination", "DING 列表缺少 result.hasMore,不能宣称当前页完整") + } + hasMore, ok := hasMoreValue.(bool) + if !ok { + return nil, dingPageEvidence{}, dingResponseError(operation, "malformed_pagination", "DING result.hasMore 应为布尔值") + } + page := dingPageEvidence{HasMore: hasMore} + if !hasMore { + if next, exists := result["nextCursor"]; exists && next != nil { + if numeric, valid := dingInteger(next); !valid || numeric != 0 { + return nil, dingPageEvidence{}, dingResponseError(operation, "conflicting_pagination", "hasMore=false 但 nextCursor 仍表示后续页") + } + } + return messages, page, nil + } + if len(messages) == 0 { + return nil, dingPageEvidence{}, dingResponseError(operation, "empty_page_with_continuation", "DING 空页仍声明 hasMore=true,无法证明游标可安全推进") + } + next, valid := dingInteger(result["nextCursor"]) + if !valid || next <= 0 { + return nil, dingPageEvidence{}, dingResponseError(operation, "missing_next_cursor", "hasMore=true 时必须返回正整数 nextCursor") + } + page.Next = strconv.FormatInt(next, 10) + return messages, page, nil +} + +func dingProjectReceivers(data map[string]any, operation, expectedID string) ([]map[string]any, error) { + result, err := dingRequireResult(data, operation) + if err != nil { + return nil, err + } + items, err := dingRequireObjectCollection(result, operation, "receivers") + if err != nil { + return nil, err + } + if len(items) == 0 { + return nil, dingResponseError(operation, "empty_receiver_status", "DING 接收状态显式为空,无法证明目标 DING 的接收状态") + } + receivers := make([]map[string]any, 0, len(items)) + for index, item := range items { + id, identityErr := dingRequiredString(item, operation, "openDingId", index) + if identityErr != nil { + return nil, identityErr + } + if id != expectedID { + return nil, dingResponseError(operation, "identity_mismatch", "DING 接收状态的 openDingId 与请求目标不一致") + } + status, ok := dingInteger(item["confirmedStatus"]) + if !ok { + return nil, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING 接收状态第 %d 项 confirmedStatus 不是整数", index)) + } + nick, nickErr := dingRequiredString(item, operation, "receiverNick", index) + if nickErr != nil { + return nil, nickErr + } + receivers = append(receivers, map[string]any{"openDingId": id, "confirmedStatus": status, "receiverNick": nick}) + } + return receivers, nil +} + +func outputDingPage(rt *shortcut.RuntimeContext, messages []map[string]any, page dingPageEvidence) error { + payload := map[string]any{"count": len(messages), "messages": messages, "complete": !page.HasMore} + if !output.UsesUnifiedResult(rt.Command()) { + if page.HasMore { + payload["nextCursor"] = page.Next + } + return rt.Output(payload) + } + pagination, err := output.NewPagination(!page.HasMore, page.Next) + if err != nil { + return dingResponseError("im/list_ding_messages", "invalid_pagination", err.Error()) + } + meta := &output.Meta{Count: output.NewCount(len(messages)), Pagination: pagination} + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) +} + +func dingUnavailable(operation string) error { + return apperrors.NewDiscovery("该 DING 写 Shortcut 尚无可逆真实 fixture、精确接收人读回与零残留清理证据,当前不可执行", + apperrors.WithOperation(operation), + apperrors.WithOrigin("shortcut_registry"), + apperrors.WithFailureStage("capability_gate"), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(false), + apperrors.WithReason("write_fixture_unavailable"), + ) +} diff --git a/internal/shortcut/ding/ding.go b/internal/shortcut/ding/ding.go index e5e3bb2b..1698cdde 100644 --- a/internal/shortcut/ding/ding.go +++ b/internal/shortcut/ding/ding.go @@ -1,226 +1,161 @@ // Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +// Licensed under the Apache License, Version 2.0 -// Package ding holds the built-in DING (钉) shortcuts. Tool names and params are -// copied verbatim from internal/helpers/ding.go. Some tools route to the "im" -// MCP server (helper uses callMCPToolOnServer("im", ...)), reflected in Product. +// Package ding registers strict declarative shortcuts for DingTalk DING. package ding import ( - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "strconv" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) var List = shortcut.Shortcut{ Service: "ding", Command: "+list", Product: "im", - Description: "查询 DING 消息列表", Intent: "当你想查看当前身份收到或发出的 DING 消息、回顾有哪些强提醒或获取某条 DING 的 openDingId 以便后续查已读或撤回时使用;可选按类型过滤并用 cursor 翻页,只读不产生副作用。", Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "ding", - Name: "shortcut_list", - CanonicalPath: "ding.shortcut_list", - CLIPath: "ding +list", - PrimaryCLIPath: "ding +list", - }, - Description: "查询 DING 消息列表", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "查询 DING 消息列表", - UseWhen: []string{"当你想查看当前身份收到或发出的 DING 消息、回顾有哪些强提醒或获取某条 DING 的 openDingId 以便后续查已读或撤回时使用;可选按类型过滤并用 cursor 翻页,只读不产生副作用。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws ding +list"}, - }, - }, + Description: "查询 DING 消息列表", + Intent: "需要查看 DING 历史、取得稳定 openDingId,或按 ALL、UNREAD、SEND、NEW_COMMENT、DELETED 类型读取一页时使用。", + Risk: shortcut.RiskRead, + Safety: dingReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: dingContract( + "+list", "查询 DING 消息列表", + "需要查看 DING 历史、取得稳定 openDingId,或按 ALL、UNREAD、SEND、NEW_COMMENT、DELETED 类型读取一页时使用。", + true, dingListResult(), dingPagination(), + []contract.ParamDecl{{Name: "cursor", Property: "cursor"}, {Name: "type", Property: "type"}}, + "dws ding +list --type ALL", "dws ding +list --type DELETED", + ), Flags: []shortcut.Flag{ - {Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标 (可选)"}, - {Name: "type", Type: shortcut.FlagString, Default: "ALL", Desc: "类型 (可选,默认 ALL)"}, + {Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标,首次不传或传 0"}, + {Name: "type", Type: shortcut.FlagString, Default: "ALL", Desc: "DING 类型", Enum: []string{"ALL", "UNREAD", "SEND", "NEW_COMMENT", "DELETED"}}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"cursor"}, Description: "--cursor 不能小于 0,续页响应必须返回严格前进的正整数 nextCursor"}}, + Tips: []string{"dws ding +list --type ALL", "dws ding +list --type DELETED"}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("cursor") < 0 { + return apperrors.NewValidation("--cursor 不能小于 0") + } + return nil }, Execute: func(rt *shortcut.RuntimeContext) error { - // type 是服务端必填,空值会报错;不传或传空时兜底为 ALL(对齐 helper)。 - msgType := rt.Str("type") - if msgType == "" { - msgType = "ALL" - } - params := map[string]any{"type": msgType} + params := map[string]any{"type": rt.Str("type")} if rt.Changed("cursor") { params["cursor"] = rt.Int("cursor") } - return rt.CallMCP("list_ding_messages", params) + data, err := rt.CallMCPData("im", "list_ding_messages", params) + if err != nil { + return err + } + messages, page, err := dingProjectMessages(data, "im/list_ding_messages") + if err != nil { + return err + } + if page.HasMore { + current := int64(rt.Int("cursor")) + next, parseErr := strconv.ParseInt(page.Next, 10, 64) + if parseErr != nil || next <= current { + return dingResponseError("im/list_ding_messages", "stalled_cursor", "nextCursor 没有严格前进") + } + } + return outputDingPage(rt, messages, page) }, } var ReceiverStatus = shortcut.Shortcut{ Service: "ding", Command: "+receiver-status", Product: "im", - Description: "查询 DING 消息接收人已读状态", Intent: "当你发出一条 DING 后想确认每位接收人是否已读、追踪谁还没看到以便催办时使用;需提供该 DING 的 openDingId,返回各接收人的已读/未读状态,只读操作。", Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "ding", - Name: "shortcut_receiver_status", - CanonicalPath: "ding.shortcut_receiver_status", - CLIPath: "ding +receiver-status", - PrimaryCLIPath: "ding +receiver-status", - }, - Description: "查询 DING 消息接收人已读状态", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "查询 DING 消息接收人已读状态", - UseWhen: []string{"当你发出一条 DING 后想确认每位接收人是否已读、追踪谁还没看到以便催办时使用;需提供该 DING 的 openDingId,返回各接收人的已读/未读状态,只读操作。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws ding +receiver-status --ding-id "}, - }, - }, - Flags: []shortcut.Flag{ - {Name: "ding-id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}, - }, + Description: "查询 DING 消息接收人已读状态", + Intent: "已经从 +list 取得稳定 openDingId,需要确认该 DING 的接收状态时使用;这是精确 ID 查询,不是消息搜索。", + Risk: shortcut.RiskRead, + Safety: dingReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: dingContract( + "+receiver-status", "查询 DING 消息接收人已读状态", + "已经从 +list 取得稳定 openDingId,需要确认该 DING 的接收状态时使用;这是精确 ID 查询,不是消息搜索。", + true, dingReceiverResult(), nil, + []contract.ParamDecl{{Name: "ding-id", Property: "openDingId"}}, + "dws ding +receiver-status --ding-id ", + ), + Flags: []shortcut.Flag{{Name: "ding-id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}}, + Tips: []string{"dws ding +receiver-status --ding-id "}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("list_ding_receiver_status", map[string]any{ - "openDingId": rt.Str("ding-id"), - }) + const operation = "im/list_ding_receiver_status" + data, err := rt.CallMCPData("im", "list_ding_receiver_status", map[string]any{"openDingId": rt.Str("ding-id")}) + if err != nil { + return err + } + receivers, err := dingProjectReceivers(data, operation, rt.Str("ding-id")) + if err != nil { + return err + } + payload := map[string]any{"count": len(receivers), "receivers": receivers} + if !output.UsesUnifiedResult(rt.Command()) { + return rt.Output(payload) + } + meta := &output.Meta{Count: output.NewCount(len(receivers))} + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) }, } -var SendPersonal = shortcut.Shortcut{ - Service: "ding", Command: "+send-personal", Product: "im", - Description: "以本人身份发送 DING 给指定人", Intent: "当你想以自己(而非机器人)的身份直接给某些同事发 DING 强提醒,让对方看到是本人发起时使用;需提供接收人的 openDingTalkId 列表和内容,可选提醒方式与幂等 uuid,会真实向这些人发出 DING。", Risk: shortcut.RiskWrite, - Safety: contract.SafetySpec{ - Effect: "write", Risk: "medium", - Confirmation: "user_required", Idempotency: "unknown", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "ding", - Name: "shortcut_send_personal", - CanonicalPath: "ding.shortcut_send_personal", - CLIPath: "ding +send-personal", - PrimaryCLIPath: "ding +send-personal", - }, - Description: "以本人身份发送 DING 给指定人", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "以本人身份发送 DING 给指定人", - UseWhen: []string{"当你想以自己(而非机器人)的身份直接给某些同事发 DING 强提醒,让对方看到是本人发起时使用;需提供接收人的 openDingTalkId 列表和内容,可选提醒方式与幂等 uuid,会真实向这些人发出 DING。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws ding +send-personal --users --content "}, - }, - }, - Flags: []shortcut.Flag{ +var SendPersonal = unavailableDingWrite( + "+send-personal", "以本人身份发送 DING 给指定人", + "需要以当前用户身份向明确的 openDingTalkId 接收人发送 DING 时使用;下游提供精确回执、接收人读回和可验证清理前不可执行。", + shortcut.RiskWrite, false, + []shortcut.Flag{ {Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true}, {Name: "content", Type: shortcut.FlagString, Desc: "消息内容", Required: true}, - {Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式 remindType"}, - {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键 (可选)"}, + {Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式", Enum: []string{"app", "sms", "call"}}, + {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键"}, }, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "receiverOpenDingTalkIds": rt.StrSlice("users"), - "content": rt.Str("content"), - "remindType": rt.Str("type"), - } - if rt.Changed("uuid") { - params["uuid"] = rt.Str("uuid") - } - return rt.CallMCP("send_personal_ding", params) + []contract.ParamDecl{ + {Name: "users", Property: "receiverOpenDingTalkIds", InterfaceType: "array"}, + {Name: "content", Property: "content"}, {Name: "type", Property: "remindType"}, {Name: "uuid", Property: "uuid"}, }, -} + "dws ding +send-personal --users --content ", +) -var SendByMessage = shortcut.Shortcut{ - Service: "ding", Command: "+send-by-message", Product: "im", - Description: "针对某条消息发起 DING 提醒", Intent: "当群里已有一条聊天消息需要被重点跟进,你想直接把它变成 DING 去强提醒相关人(而不是另写新内容)时使用;需提供群 openConversationId、该消息 openMessageId 和接收人 openDingTalkId 列表,会真实基于这条消息发出 DING。", Risk: shortcut.RiskWrite, - Flags: []shortcut.Flag{ +var SendByMessage = unavailableDingWrite( + "+send-by-message", "针对某条消息发起 DING 提醒", + "需要把指定聊天消息转成应用内、短信或电话 DING 时使用;对应 lark-cli 的三种 urgent 任务,但安全 fixture 与精确清理闭环未具备。", + shortcut.RiskWrite, false, + []shortcut.Flag{ {Name: "group", Type: shortcut.FlagString, Desc: "openConversationId", Required: true}, {Name: "message-id", Type: shortcut.FlagString, Desc: "openMessageId", Required: true}, {Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true}, - {Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式 remindType"}, - {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键 (可选)"}, + {Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式", Enum: []string{"app", "sms", "call"}}, + {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键"}, }, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "openConversationId": rt.Str("group"), - "openMessageId": rt.Str("message-id"), - "receiverOpenDingTalkIds": rt.StrSlice("users"), - "remindType": rt.Str("type"), - } - if rt.Changed("uuid") { - params["uuid"] = rt.Str("uuid") - } - return rt.CallMCP("send_ding_by_message", params) + []contract.ParamDecl{ + {Name: "group", Property: "openConversationId"}, {Name: "message-id", Property: "openMessageId"}, + {Name: "users", Property: "receiverOpenDingTalkIds", InterfaceType: "array"}, + {Name: "type", Property: "remindType"}, {Name: "uuid", Property: "uuid"}, }, -} + "dws ding +send-by-message --group --message-id --users ", +) -var RecallPersonal = shortcut.Shortcut{ - Service: "ding", Command: "+recall-personal", Product: "im", - Description: "撤回本人发起的 DING", Intent: "当你以本人身份发出的某条 DING 发错人或内容有误、想收回时使用(对应 send-personal/send-by-message 发出的 DING);需提供该 DING 的 openDingId,会真实撤回它,接收人将不再看到该提醒。", Risk: shortcut.RiskHighWrite, - Safety: contract.SafetySpec{ - Effect: "destructive", Risk: "high", - Confirmation: "user_required", Idempotency: "unknown", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "ding", - Name: "shortcut_recall_personal", - CanonicalPath: "ding.shortcut_recall_personal", - CLIPath: "ding +recall-personal", - PrimaryCLIPath: "ding +recall-personal", +var RecallPersonal = unavailableDingWrite( + "+recall-personal", "撤回本人发起的 DING", + "需要撤回本人发出的 DING 时使用;必须先有稳定写回执,并能精确证明目标已撤回且没有残留通知,当前尚不满足。", + shortcut.RiskHighWrite, true, + []shortcut.Flag{{Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}}, + []contract.ParamDecl{{Name: "id", Property: "openDingId"}}, + "dws ding +recall-personal --id ", +) + +func unavailableDingWrite(command, description, intent string, risk shortcut.Risk, destructive bool, flags []shortcut.Flag, params []contract.ParamDecl, example string) shortcut.Shortcut { + return shortcut.Shortcut{ + Service: "ding", Command: command, Product: "im", + Description: description, Intent: intent, Risk: risk, + Safety: dingWriteSafety(destructive), OutputRollout: output.RolloutUnifiedActive, + Contract: dingContract(command, description, intent, false, nil, nil, params, example), + Flags: flags, Tips: []string{example}, + Execute: func(*shortcut.RuntimeContext) error { + return dingUnavailable("ding/" + command) }, - Description: "撤回本人发起的 DING", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "撤回本人发起的 DING", - UseWhen: []string{"当你以本人身份发出的某条 DING 发错人或内容有误、想收回时使用(对应 send-personal/send-by-message 发出的 DING);需提供该 DING 的 openDingId,会真实撤回它,接收人将不再看到该提醒。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws ding +recall-personal --id "}, - }, - }, - Flags: []shortcut.Flag{ - {Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}, - }, - Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("recall_personal_ding", map[string]any{ - "openDingId": rt.Str("id"), - }) - }, + } } func init() { - shortcut.Register( - List, - ReceiverStatus, - SendPersonal, - SendByMessage, - RecallPersonal, - ) + shortcut.Register(List, ReceiverStatus, SendPersonal, SendByMessage, RecallPersonal) } diff --git a/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..b6fba7eb --- /dev/null +++ b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go @@ -0,0 +1,231 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package ding + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type dingCoverageCaller struct { + responses map[string][]string + history []string +} + +func (caller *dingCoverageCaller) CallTool(_ context.Context, _, tool string, _ map[string]any) (*edition.ToolResult, error) { + caller.history = append(caller.history, tool) + queue := caller.responses[tool] + if len(queue) == 0 { + return nil, errors.New("missing DING fake response for " + tool) + } + caller.responses[tool] = queue[1:] + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil +} + +func (*dingCoverageCaller) Format() string { return "json" } +func (*dingCoverageCaller) DryRun() bool { return false } +func (*dingCoverageCaller) Fields() string { return "" } +func (*dingCoverageCaller) JQ() string { return "" } + +func runDingCoverage(t *testing.T, declaration shortcut.Shortcut, caller *dingCoverageCaller, args ...string) (*cobra.Command, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + return cmd, cmd.Execute() +} + +func TestCrossPlatformCoverageDINGContractsAreStrictTypedAndUnified(t *testing.T) { + for _, declaration := range []shortcut.Shortcut{List, ReceiverStatus, SendPersonal, SendByMessage, RecallPersonal} { + if declaration.Contract.Empty() { + t.Errorf("%s lacks Contract", declaration.Command) + } + if declaration.Safety.Effect == "" || declaration.Safety.Confirmation == "" { + t.Errorf("%s lacks Safety", declaration.Command) + } + if declaration.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout) + } + } + for _, declaration := range []shortcut.Shortcut{List, ReceiverStatus} { + if declaration.Contract.Result == nil { + t.Errorf("%s lacks Result", declaration.Command) + } + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" { + t.Errorf("%s is not interface-available", declaration.Command) + } + } + for _, declaration := range []shortcut.Shortcut{SendPersonal, SendByMessage, RecallPersonal} { + if declaration.Safety.Confirmation != "user_required" || declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "unavailable" { + t.Errorf("%s write safety/interface drift", declaration.Command) + } + } + if List.Contract.Pagination == nil { + t.Fatal("+list lacks cursor pagination") + } +} + +func TestCrossPlatformCoverageDINGListResponseMatrix(t *testing.T) { + valid := map[string]any{ + "success": true, + "result": map[string]any{ + "dingMessages": []any{map[string]any{"openDingId": "ding-1", "dingContent": "fixture"}}, + "hasMore": true, + "nextCursor": float64(2), + }, + } + messages, page, err := dingProjectMessages(valid, "im/list_ding_messages") + if err != nil || len(messages) != 1 || !page.HasMore || page.Next != "2" || messages[0]["content"] != "fixture" { + t.Fatalf("valid list projection messages=%#v page=%+v err=%v", messages, page, err) + } + empty := map[string]any{"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": false, "nextCursor": nil}} + if messages, page, err := dingProjectMessages(empty, "im/list_ding_messages"); err != nil || len(messages) != 0 || page.HasMore { + t.Fatalf("explicit terminal empty page messages=%#v page=%+v err=%v", messages, page, err) + } + fixtures := map[string]map[string]any{ + "empty": {}, + "missing success": {"result": map[string]any{"dingMessages": []any{}, "hasMore": false}}, + "wrong success": {"success": "true", "result": map[string]any{"dingMessages": []any{}, "hasMore": false}}, + "false success": {"success": false, "result": map[string]any{"dingMessages": []any{}, "hasMore": false}}, + "missing result": {"success": true}, + "missing collection": {"success": true, "result": map[string]any{"hasMore": false}}, + "wrong collection": {"success": true, "result": map[string]any{"dingMessages": map[string]any{}, "hasMore": false}}, + "bad item": {"success": true, "result": map[string]any{"dingMessages": []any{"bad"}, "hasMore": false}}, + "missing item id": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"dingContent": "fixture"}}, "hasMore": false}}, + "duplicate item id": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "same"}, map[string]any{"openDingId": "same"}}, "hasMore": false}}, + "missing pagination": {"success": true, "result": map[string]any{"dingMessages": []any{}}}, + "wrong pagination": {"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": "false"}}, + "empty continuation": {"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": true, "nextCursor": float64(2)}}, + "missing next cursor": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "ding-1"}}, "hasMore": true}}, + "wrong next cursor": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "ding-1"}}, "hasMore": true, "nextCursor": "2"}}, + "conflicting terminal": {"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": false, "nextCursor": float64(2)}}, + "wrong optional content": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "ding-1", "dingContent": float64(1)}}, "hasMore": false}}, + } + for name, fixture := range fixtures { + if projected, _, projectErr := dingProjectMessages(fixture, "im/list_ding_messages"); projectErr == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } +} + +func TestCrossPlatformCoverageDINGReceiverResponseMatrix(t *testing.T) { + valid := map[string]any{"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": float64(1), "receiverNick": "fixture"}}}} + items, err := dingProjectReceivers(valid, "im/list_ding_receiver_status", "ding-1") + if err != nil || len(items) != 1 || items[0]["confirmedStatus"] != int64(1) { + t.Fatalf("valid receiver projection=%#v err=%v", items, err) + } + for name, fixture := range map[string]map[string]any{ + "empty": {}, + "missing collection": {"success": true, "result": map[string]any{}}, + "empty collection": {"success": true, "result": map[string]any{"receivers": []any{}}}, + "bad item": {"success": true, "result": map[string]any{"receivers": []any{"bad"}}}, + "identity mismatch": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "other", "confirmedStatus": float64(1), "receiverNick": "fixture"}}}}, + "wrong status": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": "1", "receiverNick": "fixture"}}}}, + "missing receiver": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": float64(1)}}}}, + } { + if projected, projectErr := dingProjectReceivers(fixture, "im/list_ding_receiver_status", "ding-1"); projectErr == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } +} + +func TestCrossPlatformCoverageDINGExactReadShortcutsProjectUnifiedData(t *testing.T) { + listCaller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_messages": {`{"success":true,"result":{"dingMessages":[{"openDingId":"ding-1","dingContent":"fixture"}],"hasMore":false,"nextCursor":null}}`}, + }} + cmd, err := runDingCoverage(t, List, listCaller, "--type", "ALL") + if err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + cmd.SetOut(&stdout) + if code, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted || code != 0 { + t.Fatalf("emit=(%d,%t,%v)", code, emitted, emitErr) + } + var envelope map[string]any + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + data := envelope["data"].(map[string]any) + if _, leaked := data["result"]; leaked || data["count"] != float64(1) || data["complete"] != true { + t.Fatalf("list unified projection=%#v", data) + } + if strings.Join(listCaller.history, ",") != "list_ding_messages" { + t.Fatalf("list call history=%v", listCaller.history) + } + + receiverCaller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_receiver_status": {`{"success":true,"result":{"receivers":[{"openDingId":"ding-1","confirmedStatus":1,"receiverNick":"fixture"}]}}`}, + }} + if _, err := runDingCoverage(t, ReceiverStatus, receiverCaller, "--ding-id", "ding-1"); err != nil { + t.Fatal(err) + } + if strings.Join(receiverCaller.history, ",") != "list_ding_receiver_status" { + t.Fatalf("receiver call history=%v", receiverCaller.history) + } +} + +func TestCrossPlatformCoverageDINGUnavailableWritesNeverCallMCP(t *testing.T) { + cases := []struct { + declaration shortcut.Shortcut + args []string + }{ + {SendPersonal, []string{"--users", "D-fixture", "--content", "fixture"}}, + {SendByMessage, []string{"--group", "cid-fixture", "--message-id", "mid-fixture", "--users", "D-fixture"}}, + {RecallPersonal, []string{"--id", "ding-fixture"}}, + } + for _, test := range cases { + t.Run(test.declaration.Command, func(t *testing.T) { + unconfirmed := &dingCoverageCaller{responses: map[string][]string{}} + err := runDingRoot(t, test.declaration, unconfirmed, false, test.args...) + if err == nil || len(unconfirmed.history) != 0 { + t.Fatalf("unconfirmed error=%v calls=%v", err, unconfirmed.history) + } + confirmed := &dingCoverageCaller{responses: map[string][]string{}} + err = runDingRoot(t, test.declaration, confirmed, true, test.args...) + if err == nil || !strings.Contains(err.Error(), "当前不可执行") || len(confirmed.history) != 0 { + t.Fatalf("confirmed unavailable error=%v calls=%v", err, confirmed.history) + } + }) + } +} + +func runDingRoot(t *testing.T, declaration shortcut.Shortcut, caller *dingCoverageCaller, yes bool, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + service := &cobra.Command{Use: "ding"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration))) + root.AddCommand(service) + argv := []string{"ding", declaration.Command} + argv = append(argv, args...) + if yes { + argv = append(argv, "--yes") + } + root.SetArgs(argv) + return root.Execute() +} diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index c1fa1f33..edf28fd4 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -265,10 +265,7 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "devapp\u0000+version-status": {}, "devapp\u0000+webapp-config": {}, "devapp\u0000+webapp-get": {}, - "ding\u0000+list": {}, - "ding\u0000+recall-personal": {}, "ding\u0000+receiver-status": {}, - "ding\u0000+send-personal": {}, "doc\u0000+access-change": {}, "doc\u0000+access-grant": {}, "doc\u0000+access-revoke": {}, diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go index 35efab99..535a3f0d 100644 --- a/internal/shortcut/semantic_catalog.go +++ b/internal/shortcut/semantic_catalog.go @@ -43,6 +43,9 @@ var mailSemanticCatalogJSON []byte //go:embed semantic_catalog_oa.json var oaSemanticCatalogJSON []byte +//go:embed semantic_catalog_ding.json +var dingSemanticCatalogJSON []byte + type semanticCatalogFile struct { Version int `json:"version"` Service string `json:"service"` @@ -73,6 +76,7 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs( attendanceSemanticCatalogJSON, mailSemanticCatalogJSON, oaSemanticCatalogJSON, + dingSemanticCatalogJSON, ) func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord { diff --git a/internal/shortcut/semantic_catalog_ding.json b/internal/shortcut/semantic_catalog_ding.json new file mode 100644 index 00000000..46f34233 --- /dev/null +++ b/internal/shortcut/semantic_catalog_ding.json @@ -0,0 +1,43 @@ +{ + "version": 1, + "service": "ding", + "default_availability": "unavailable", + "shortcuts": { + "+list": { + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、result.dingMessages、稳定 openDingId 与 hasMore/nextCursor;exact Shortcut 已完成已知非空与保证零项证明,但真实续页返回停滞 cursor,严格实现拒绝虚假分页并保持不可用。", + "risk": "read", + "public": false, + "reviewed": true + }, + "+receiver-status": { + "disposition": "semantic_adapter", + "semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;exact Shortcut 已完成真实非空身份匹配证明。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + }, + "+send-personal": { + "disposition": "semantic_adapter", + "semantic_delta": "用户身份发送 DING 的原子能力存在,但下游没有同时提供稳定接收人回执、精确读回和可验证零残留清理;确认后也在本地 capability gate 停止。", + "risk": "write", + "public": false, + "reviewed": true + }, + "+send-by-message": { + "disposition": "semantic_adapter", + "semantic_delta": "覆盖基于消息的应用内、短信和电话 DING,映射 lark-cli 三种 urgent 任务;缺少可逆 fixture、稳定回执和精确清理证明,保持不可用。", + "risk": "write", + "public": false, + "reviewed": true + }, + "+recall-personal": { + "disposition": "semantic_adapter", + "semantic_delta": "个人 DING 撤回缺少能证明目标已撤回且通知无残留的精确终态读取;不接受单一 success 回执作为成功,保持不可用。", + "risk": "high-risk-write", + "public": false, + "reviewed": true + } + } +} diff --git a/scripts/gen_shortcut_public_catalog.py b/scripts/gen_shortcut_public_catalog.py index 41ae4e61..7d4fa695 100644 --- a/scripts/gen_shortcut_public_catalog.py +++ b/scripts/gen_shortcut_public_catalog.py @@ -30,6 +30,7 @@ SEMANTIC_PATHS = [ ROOT / "internal" / "shortcut" / "semantic_catalog_attendance.json", ROOT / "internal" / "shortcut" / "semantic_catalog_mail.json", ROOT / "internal" / "shortcut" / "semantic_catalog_oa.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_ding.json", ] From 161687ae4c9659cbdb87d14646d5d5ef9f208649 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 16:46:16 +0800 Subject: [PATCH 05/28] fix(shortcut): deliver OA validation evidence --- internal/app/command_path_fallback_e2e_test.go | 2 +- internal/shortcut/oa/oa.go | 13 ++++++++----- internal/shortcut/oa/smart.go | 13 ++++++++----- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/internal/app/command_path_fallback_e2e_test.go b/internal/app/command_path_fallback_e2e_test.go index 1532bd08..f5e083c3 100644 --- a/internal/app/command_path_fallback_e2e_test.go +++ b/internal/app/command_path_fallback_e2e_test.go @@ -372,7 +372,7 @@ func TestCrossPlatformCoverageReviewedAmbiguousCommandFallbackNeverDispatches(t {path: "chat +conversation-category-list", candidates: []string{"chat +category-list", "chat +category-list-conversations"}}, {path: "chat +conversation-group-list", candidates: []string{"chat +category-list-conversations", "chat +conversation-list"}}, {path: "chat +list-my-groups", candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"}}, - {path: "oa +list-processes", candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}}, + {path: "oa +list-processes", candidates: []string{"oa +search-forms", "oa +my-initiated", "oa approval list-initiated"}}, } for _, test := range tests { t.Run(test.path, func(t *testing.T) { diff --git a/internal/shortcut/oa/oa.go b/internal/shortcut/oa/oa.go index e6dc70aa..79bd12d6 100644 --- a/internal/shortcut/oa/oa.go +++ b/internal/shortcut/oa/oa.go @@ -174,7 +174,7 @@ var SearchForms = shortcut.Shortcut{ []contract.ParamDecl{{Name: "query", Property: "query"}}, "dws oa +search-forms --query 报销", ), - Flags: []shortcut.Flag{{Name: "query", Type: shortcut.FlagString, Desc: "关键字(匹配 processCode 或表单名称)", Required: true}}, + Flags: []shortcut.Flag{{Name: "query", Type: shortcut.FlagString, Desc: "关键字(匹配 processCode 或表单名称);去除空白后不能为空", Required: true}}, Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "--query 去除空白后不能为空"}}, Tips: []string{`dws oa +search-forms --query 报销`}, Validate: func(rt *shortcut.RuntimeContext) error { @@ -207,12 +207,15 @@ func oaNumberedInstanceShortcut(command, tool, description, intent string) short []contract.ParamDecl{{Name: "page", Property: "pageNumber"}, {Name: "limit", Property: "pageSize"}, {Name: "query", Property: "query"}}, "dws oa "+command+" --page 1 --limit 20"), Flags: []shortcut.Flag{ - {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"}, - {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"}, + {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码;--page 必须大于 0"}, + {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小;--limit 必须在 1-100"}, {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, }, - Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"page", "limit"}, Description: "--page 必须大于 0;--limit 必须在 1-100"}}, - Tips: []string{"dws oa " + command + " --page 1 --limit 20"}, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100"}, + }, + Tips: []string{"dws oa " + command + " --page 1 --limit 20"}, } declaration.Validate = func(rt *shortcut.RuntimeContext) error { page, err := parseOAStringPage(rt, "page", 1) diff --git a/internal/shortcut/oa/smart.go b/internal/shortcut/oa/smart.go index 8acc5d14..c62705b1 100644 --- a/internal/shortcut/oa/smart.go +++ b/internal/shortcut/oa/smart.go @@ -118,12 +118,15 @@ var MyInitiated = shortcut.Shortcut{ ), Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索(可选)"}, - {Name: "page", Type: shortcut.FlagInt, Desc: "分页页码(可选,默认 1)", Default: "1"}, - {Name: "limit", Type: shortcut.FlagInt, Desc: "每页大小(可选,默认 20)", Default: "20"}, + {Name: "page", Type: shortcut.FlagInt, Desc: "分页页码(可选,默认 1);--page 必须大于 0", Default: "1"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页大小(可选,默认 20);--limit 必须在 1-100", Default: "20"}, }, - Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"page", "limit"}, Description: "--page 必须大于 0;--limit 必须在 1-100"}}, - Tips: []string{`dws oa +my-initiated`, `dws oa +my-initiated --query 报销`, `dws oa +my-initiated --page 2 --limit 50`}, - Validate: func(rt *shortcut.RuntimeContext) error { return validateOAPage(rt.Int("page"), rt.Int("limit")) }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100"}, + }, + Tips: []string{`dws oa +my-initiated`, `dws oa +my-initiated --query 报销`, `dws oa +my-initiated --page 2 --limit 50`}, + Validate: func(rt *shortcut.RuntimeContext) error { return validateOAPage(rt.Int("page"), rt.Int("limit")) }, Execute: func(rt *shortcut.RuntimeContext) error { const operation = "oa/get_submitted_instances" params := map[string]any{"pageNumber": float64(rt.Int("page")), "pageSize": float64(rt.Int("limit"))} From c6417e35273c6b6477c738e606b2748b1042a6e5 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 17:09:34 +0800 Subject: [PATCH 06/28] feat(shortcut): harden Report reads and availability --- .../report.md | 93 ++++ docs/shortcut-public-catalog.json | 16 +- .../builtin/report_semantic_catalog_test.go | 83 +++ internal/shortcut/public_catalog_generated.go | 2 +- internal/shortcut/report/common.go | 477 ++++++++++++++++++ internal/shortcut/report/latest.go | 96 ++++ internal/shortcut/report/report.go | 465 +++++++++-------- .../report/report_list_projection_test.go | 38 -- ...ort_strict_cross_platform_coverage_test.go | 309 ++++++++++++ internal/shortcut/semantic_catalog.go | 4 + .../shortcut/semantic_catalog_report.json | 37 ++ internal/shortcut/smart/report_latest.go | 220 -------- scripts/gen_shortcut_public_catalog.py | 1 + 13 files changed, 1342 insertions(+), 499 deletions(-) create mode 100644 docs/shortcut-downstream-requirements/report.md create mode 100644 internal/shortcut/builtin/report_semantic_catalog_test.go create mode 100644 internal/shortcut/report/common.go create mode 100644 internal/shortcut/report/latest.go delete mode 100644 internal/shortcut/report/report_list_projection_test.go create mode 100644 internal/shortcut/report/report_strict_cross_platform_coverage_test.go create mode 100644 internal/shortcut/semantic_catalog_report.json delete mode 100644 internal/shortcut/smart/report_latest.go diff --git a/docs/shortcut-downstream-requirements/report.md b/docs/shortcut-downstream-requirements/report.md new file mode 100644 index 00000000..8456eb4b --- /dev/null +++ b/docs/shortcut-downstream-requirements/report.md @@ -0,0 +1,93 @@ +# Report Shortcut 下游业务能力需求规格 + +> 日期:2026-08-18 +> DWS 基线:`c0b286c1e10b6fcebdff4ed522d3b1cf30549866` +> 对比基线:lark-cli 1.0.87(无 Report 产品域) +> 范围:仅 Report Shortcut;证据只记录 PASS 标签与聚合事实 + +## 1. 执行摘要 + +- 源码 Shortcut 共 4 个;公开 2 个,unavailable 2 个。 +- `+template-search` 已完成严格模板集合、已知非空和保证零命中证明,可公开。 +- `+inbox-list` 已完成已知非空、保证零项、多页 cursor 严格前进、相邻页稳定身份无重复和明确耗尽证明,可公开。 +- `+outbox-list` 在多个有界窗口只得到合法零项结果;缺少已知非空证明,因此保持 unavailable。 +- `+report-latest` 依赖完整非空发件箱和精确详情读回;当前没有安全 fixture,因此保持 unavailable,且不再回退原始列表行。 +- Report 原子层具有模板、收件箱、发件箱、详情、统计和创建能力;创建缺少可验证删除/回收能力,本轮未执行写操作。 + +| ID | 优先级 | 类型 | 用户任务 | 当前处置 | 解锁的 Shortcut | +|---|---|---|---|---|---| +| `DS-Report-001` | P1 | tenant-or-fixture / missing capability | 读取自己发出的日志及最新详情 | hidden + unavailable | `+outbox-list`、`+report-latest` | + +## 2. 用户任务与证据矩阵 + +| 用户任务 | Shortcut | Lark CLI 对应 | Exact Shortcut 证据 | Atomic/raw 对照 | 结论 | +|---|---|---|---|---|---| +| 按名称搜索可用日志模板 | `+template-search` | 无 | `PASS-RPT-TEMPLATE-NONEMPTY`:1 项;`PASS-RPT-TEMPLATE-ZERO`:0 项 | `PASS-RPT-TEMPLATE-COLLECTION`:完整集合聚合数量 73 | available / public | +| 分页查看收到的日志 | `+inbox-list` | 无 | `PASS-RPT-INBOX-PAGES`:页大小依次为 20、20、20、20、12,随后明确耗尽;`PASS-RPT-INBOX-ZERO`:0 项 | `PASS-RPT-INBOX-CURSOR`:非终止 cursor 严格递增;首对相邻页稳定身份交集为 0 | available / public | +| 分页查看自己发出的日志 | `+outbox-list` | 无 | `PASS-RPT-OUTBOX-ZERO`:多个有界窗口均为 0 项 | `PASS-RPT-OUTBOX-TERMINAL`:零项页具有明确成功与终止证据 | unavailable | +| 读取自己最新提交的日志详情 | `+report-latest` | 无 | `PASS-RPT-LATEST-ZERO`:发件箱为空时在一次列表调用后停止 | `PASS-RPT-DETAIL-SHAPE`:独立详情能力可返回稳定身份与严格字段数组,但没有安全发件箱目标可串联 | unavailable | + +## 3. 已验证分页合同 + +- `PASS-RPT-INBOX-CURSOR`:所有非终止页均返回正整数、相对请求严格前进的 continuation。 +- `PASS-RPT-INBOX-PAGES`:exact Shortcut 沿服务端返回 cursor 读取 5 个非空页,聚合数量 92,末页明确 `hasMore=false`。 +- `PASS-RPT-INBOX-OVERLAP`:首对相邻非空页的稳定身份交集为 0。 +- `PASS-RPT-INBOX-ZERO`:独立有界查询返回合法 0 项、`complete=true` 与 endpoint exhausted。 +- 服务端在终止页仍回显整数 cursor;DWS 只把它当页面收据,不发布 `next_token`。错型或冲突 cursor 仍失败。 +- DWS 严格拒绝空响应、缺/错型 `success`、缺/错型集合、坏元素、重复身份、缺 continuation、空页续页和不前进 cursor。 + +## 4. 下游需求明细 + +### `DS-Report-001` — 提供可回收的发件箱 E2E fixture + +#### 用户结果 + +用户能够可靠列出自己发出的日志,并在完整候选集合中选出最新一篇,再按稳定 `reportId` 读取身份一致的详情。 + +#### 当前证据与归因 + +- `PASS-RPT-OUTBOX-ZERO`:多个独立有界窗口均为合法 0 项;这只能证明零结果合同,不能证明非空项目投影。 +- `PASS-RPT-LATEST-ZERO`:组合命令遇到空发件箱后停止,没有调用详情,也没有把空结果伪装成成功详情。 +- `PASS-RPT-DETAIL-SHAPE`:在独立安全读取中,详情结果具备稳定身份、时间字段与非空字段集合;统计读取也具备明确成功对象。 +- 原子层可以创建日志,但没有对应删除/回收能力。仅凭创建成功回执不能满足写后精确读回、清理和零残留要求,因此本轮没有创建测试数据。 + +#### 所需 fixture / 能力 + +- 首选:租户提供专用、无业务内容、定期自动过期的已发送日志 fixture,并保证测试身份可读取。 +- 或者:新增精确删除/回收能力,使测试可按创建回执中的稳定身份读回并在 `defer` 清理,最后证明发件箱与详情均无残留。 +- 非空发件箱响应必须包含布尔 `success=true`、精确集合、每项稳定 `reportId`、正整数 `createTime` 和真实分页终止证据。 +- 详情响应必须返回与请求完全一致的稳定身份;字段集合必须为数组,坏元素不能被跳过。 +- 若创建执行结果不确定,必须返回 commit-unknown 分类与安全的核查动作,不能建议盲目重试。 + +#### 验收标准 + +1. exact `+outbox-list` 同时证明已知非空与保证零项。 +2. 非空项目均有稳定身份与排序时间,分页遍历至终止且无重复。 +3. exact `+report-latest` 只调用一次完整列表和一次精确详情,详情身份与所选目标一致。 +4. 若用创建产生 fixture:确认前 0 次写调用;确认后稳定回执;按回执精确读回;无条件清理;最终证明零残留。 +5. 没有可验证清理能力时,不执行创建,并继续保持相关 Shortcut unavailable。 + +## 5. Lark 对齐与平台差异 + +lark-cli 1.0.87 没有 Report/日志产品域,因此 4 个 Report Shortcut 均无一对一 Lark 任务。它们是 DWS 的平台原生增量能力,不应虚构映射: + +| DWS 用户任务 | Lark 映射 | 推荐结论 | +|---|---|---| +| 搜索日志模板 | 无 | DWS 原生能力;公开 `+template-search` | +| 查看收到的日志 | 无 | DWS 原生能力;公开 `+inbox-list` | +| 查看发出的日志或最新详情 | 无 | DWS 原生能力;非空链路验证前保持 unavailable | + +## 6. 无需下游变更的上游修复 + +| Shortcut | 已完成修复 | 回归证据 | +|---|---|---| +| 全部 4 个 | 统一 Result/Safety/Contract、unified output;空响应、缺/错型 success、缺集合、坏元素与重复身份全部失败 | `PASS-RPT-STRICT-MATRIX` | +| `+inbox-list`、`+outbox-list` | 发布真实 cursor pagination;缺失、冲突、错型和不前进 cursor 全部失败;终止页不发布回显 cursor | `PASS-RPT-PAGINATION-MATRIX` | +| `+template-search` | 严格验证完整模板集合后本地筛选;不从坏元素或未知形状制造空结果 | `PASS-RPT-TEMPLATE-MATRIX` | +| `+report-latest` | 删除原始列表行回退;仅接受完整候选集合、可排序时间和身份匹配详情 | `PASS-RPT-LATEST-READBACK` | + +## 7. 安全与脱敏声明 + +- 本文不包含真实 ID、标题、人名、邮箱、正文、原始响应、trace/request ID、token 或签名 URL。 +- 所有真实证据只以 `PASS-RPT-*` 标签和聚合数量表达;不得用这些标签反查或推断具体资源。 +- 写能力因缺少可验证清理合同未执行;不存在为了测试而遗留的 Report 数据。 diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index 3df4be3e..eeb72aa2 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -3624,18 +3624,24 @@ "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "report", "command": "+inbox-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、result.report_list、稳定 reportId 与 hasMore/cursor;exact Shortcut 已完成已知非空、保证零项、cursor 严格前进、相邻页无重复及终止页证明。终止页回显 cursor 只作已验证收据且不发布 next_token。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "report", - "command": "+outbox-list", + "command": "+template-search", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;exact Shortcut 同时证明已知非空与保证零命中。", + "availability": "available" }, { "suite": "read", diff --git a/internal/shortcut/builtin/report_semantic_catalog_test.go b/internal/shortcut/builtin/report_semantic_catalog_test.go new file mode 100644 index 00000000..06ece7a0 --- /dev/null +++ b/internal/shortcut/builtin/report_semantic_catalog_test.go @@ -0,0 +1,83 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageReportSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_report.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "report" { + registered[item.Command] = item + } + } + if source.Service != "report" || len(registered) != 4 || len(source.Shortcuts) != 4 { + t.Fatalf("service/registered/catalog=%s/%d/%d, want report/4/4", source.Service, len(registered), len(source.Shortcuts)) + } + public, unavailable := 0, 0 + var missing, stale []string + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + availability := record.Availability + if availability == "" { + availability = source.Availability + } + if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s semantic review facts drifted", command) + } + if item.Risk != record.Risk || item.Availability != availability { + t.Errorf("%s risk/availability=%q/%q want=%q/%q", command, item.Risk, item.Availability, record.Risk, availability) + } + if record.Public { + public++ + if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("report", command) { + t.Errorf("%s public availability/visibility drift", command) + } + if item.Contract.Empty() || item.Contract.Result == nil || item.Safety.Effect == "" || item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s lacks public Contract/Safety/Result/unified output", command) + } + } else { + if !item.Hidden || shortcut.InPublicCatalog("report", command) { + t.Errorf("%s nonpublic shortcut is visible", command) + } + if availability == shortcut.AvailabilityUnavailable { + unavailable++ + } + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) > 0 || len(stale) > 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } + if public != 2 || unavailable != 2 { + t.Fatalf("public/unavailable=%d/%d, want 2/2", public, unavailable) + } +} diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index edf28fd4..9dc6742d 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -379,7 +379,7 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "oa\u0000+my-initiated": {}, "oa\u0000+search-forms": {}, "report\u0000+inbox-list": {}, - "report\u0000+outbox-list": {}, + "report\u0000+template-search": {}, "sheet\u0000+list-sheets": {}, "sheet\u0000+read": {}, "todo\u0000+assign": {}, diff --git a/internal/shortcut/report/common.go b/internal/shortcut/report/common.go new file mode 100644 index 00000000..2bac4e58 --- /dev/null +++ b/internal/shortcut/report/common.go @@ -0,0 +1,477 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package report + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +const reportCompositeReason = "Reviewed Report Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable report/template identities, truthful pagination, local filtering, output projection, and multi-step detail verification." + +type reportPageEvidence struct { + HasMore bool + Next string +} + +func reportReadSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} + +func reportCollectionResult(collection, description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前页严格验证的日志数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"具有稳定 reportId 的日志摘要","properties":{"reportId":{"type":"string","description":"稳定日志 ID"},"templateName":{"type":"string","description":"日志模板名称"},"creatorName":{"type":"string","description":"日志创建人显示名"},"creatorUserId":{"type":"string","description":"日志创建人稳定用户 ID"},"createTime":{"type":"integer","description":"日志创建时间毫秒值"},"modifiedTime":{"type":"integer","description":"日志修改时间毫秒值"}},"required":["reportId"],"additionalProperties":false}},"complete":{"type":"boolean","description":"服务端分页证据是否证明当前查询已结束"}},"required":["count",%q,"complete"],"additionalProperties":false}`, + description, collection, description, collection, + )), + SensitivePaths: []string{collection + ".templateName", collection + ".creatorName", collection + ".creatorUserId"}, + } +} + +func reportTemplateSearchResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"严格验证的日志模板搜索结果","properties":{"count":{"type":"integer","description":"匹配模板数量"},"templates":{"type":"array","description":"名称匹配且具有稳定 ID 的模板","items":{"type":"object","description":"一条日志模板摘要","properties":{"templateId":{"type":"string","description":"稳定模板 ID"},"name":{"type":"string","description":"模板名称"},"lastModifiedTime":{"type":"integer","description":"模板最后修改时间毫秒值"}},"required":["templateId","name"],"additionalProperties":false}}},"required":["count","templates"],"additionalProperties":false}`), + SensitivePaths: []string{"templates.name"}, + } +} + +func reportLatestResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"严格验证的最近日志详情","properties":{"report":{"type":"object","description":"稳定身份匹配的日志详情","properties":{"reportId":{"type":"string","description":"稳定日志 ID"},"title":{"type":"string","description":"日志标题"},"templateName":{"type":"string","description":"日志模板名称"},"creatorName":{"type":"string","description":"日志创建人显示名"},"createTime":{"type":"integer","description":"日志创建时间毫秒值"},"modifiedTime":{"type":"integer","description":"日志修改时间毫秒值"},"fields":{"type":"array","description":"严格验证的日志字段","items":{"type":"object","description":"日志字段值","properties":{"key":{"type":"string","description":"字段名称"},"value":{"type":"string","description":"字段内容"},"sort":{"type":"integer","description":"字段排序值"},"type":{"type":"integer","description":"字段类型码"}},"required":["key","value","sort","type"],"additionalProperties":false}}},"required":["reportId","fields"],"additionalProperties":false}},"required":["report"],"additionalProperties":false}`), + SensitivePaths: []string{"report.title", "report.templateName", "report.creatorName", "report.fields.value"}, + } +} + +func reportPagination() *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, + CursorParameter: "cursor", + MetaPath: contract.PaginationMetaPath, + EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + +func reportContract(command, description, intent string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl { + name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_") + cliPath := "report " + command + return corecmd.ContractDecl{ + Description: description, + Result: result, + Pagination: pagination, + Parameters: params, + Identity: contract.ToolIdentitySpec{ + ProductID: "report", Name: name, CanonicalPath: "report." + name, + CLIPath: cliPath, PrimaryCLIPath: cliPath, + }, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: reportCompositeReason}, + Selection: contract.SelectionSpec{ + AgentSummary: description, + UseWhen: []string{intent}, + AvoidWhen: []string{"钉钉在线文档使用 doc,待办使用 todo,OA 审批使用 oa;缺少稳定日志或模板身份时不要猜测"}, + Examples: examples, + }, + } +} + +func reportResponseError(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + +func reportRequireSuccess(data map[string]any, operation string) error { + if len(data) == 0 { + return reportResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明 Report 调用成功或结果确实为空") + } + raw, present := data["success"] + if !present { + return reportResponseError(operation, "missing_success", "Report 响应缺少 success 业务状态") + } + success, ok := raw.(bool) + if !ok { + return reportResponseError(operation, "invalid_success_type", fmt.Sprintf("Report success 应为布尔值,实际为 %T", raw)) + } + if success { + return nil + } + message := "Report 服务明确返回失败" + for _, key := range []string{"errorMsg", "errorMessage", "message"} { + if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" { + message = strings.TrimSpace(value) + break + } + } + return reportResponseError(operation, "remote_failure", message) +} + +func reportObjectCollection(value any, operation, path string) ([]map[string]any, error) { + raw, ok := value.([]any) + if !ok { + return nil, reportResponseError(operation, "malformed_collection", fmt.Sprintf("Report %s 应为数组,实际为 %T", path, value)) + } + items := make([]map[string]any, 0, len(raw)) + for index, item := range raw { + object, ok := item.(map[string]any) + if !ok || len(object) == 0 { + return nil, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report %s[%d] 不是非空对象", path, index)) + } + items = append(items, object) + } + return items, nil +} + +func reportListCollection(data map[string]any, operation string) ([]map[string]any, map[string]any, error) { + if err := reportRequireSuccess(data, operation); err != nil { + return nil, nil, err + } + result, present := data["result"] + if !present { + return nil, nil, reportResponseError(operation, "missing_collection", "Report 成功响应缺少 result 日志集合") + } + if _, ok := result.([]any); ok { + items, err := reportObjectCollection(result, operation, "result") + return items, data, err + } + container, ok := result.(map[string]any) + if !ok || container == nil { + return nil, nil, reportResponseError(operation, "malformed_collection", fmt.Sprintf("Report result 应为数组或带 report_list 的对象,实际为 %T", result)) + } + raw, present := container["report_list"] + if !present { + return nil, nil, reportResponseError(operation, "missing_collection", "Report 成功响应缺少 result.report_list 数组") + } + items, err := reportObjectCollection(raw, operation, "result.report_list") + return items, container, err +} + +func reportRequiredString(item map[string]any, operation string, index int, keys ...string) (string, error) { + var selected string + for _, key := range keys { + raw, present := item[key] + if !present || raw == nil { + continue + } + value, ok := raw.(string) + value = strings.TrimSpace(value) + if !ok || value == "" { + return "", reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 结果第 %d 项的 %s 必须是非空字符串", index, key)) + } + if selected != "" && selected != value { + return "", reportResponseError(operation, "conflicting_item_identity", fmt.Sprintf("Report 结果第 %d 项的身份字段冲突", index)) + } + selected = value + } + if selected == "" { + return "", reportResponseError(operation, "missing_item_identity", fmt.Sprintf("Report 结果第 %d 项缺少稳定身份", index)) + } + return selected, nil +} + +func reportOptionalString(item map[string]any, operation string, index int, keys ...string) (string, error) { + for _, key := range keys { + raw, present := item[key] + if !present || raw == nil { + continue + } + value, ok := raw.(string) + if !ok { + return "", reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 结果第 %d 项的 %s 应为字符串,实际为 %T", index, key, raw)) + } + return strings.TrimSpace(value), nil + } + return "", nil +} + +func reportInteger(value any) (int64, bool) { + switch typed := value.(type) { + case float64: + if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed || typed > math.MaxInt64 || typed < math.MinInt64 { + return 0, false + } + return int64(typed), true + case int: + return int64(typed), true + case int64: + return typed, true + case json.Number: + parsed, err := typed.Int64() + return parsed, err == nil + default: + return 0, false + } +} + +func reportOptionalInteger(item map[string]any, operation string, index int, keys ...string) (int64, bool, error) { + for _, key := range keys { + raw, present := item[key] + if !present || raw == nil { + continue + } + value, ok := reportInteger(raw) + if !ok { + return 0, false, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 结果第 %d 项的 %s 应为整数", index, key)) + } + return value, true, nil + } + return 0, false, nil +} + +func reportProjectEntries(data map[string]any, operation string) ([]map[string]any, reportPageEvidence, error) { + items, pageContainer, err := reportListCollection(data, operation) + if err != nil { + return nil, reportPageEvidence{}, err + } + entries := make([]map[string]any, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for index, item := range items { + id, identityErr := reportRequiredString(item, operation, index, "reportId", "report_id", "report_Id") + if identityErr != nil { + return nil, reportPageEvidence{}, identityErr + } + if _, duplicate := seen[id]; duplicate { + return nil, reportPageEvidence{}, reportResponseError(operation, "duplicate_item_identity", "当前 Report 页包含重复 reportId") + } + seen[id] = struct{}{} + projected := map[string]any{"reportId": id} + for target, aliases := range map[string][]string{ + "templateName": {"templateName", "template_name", "reportTemplateName", "report_template_name"}, + "creatorName": {"creatorName", "creator_name", "creatorUserName", "senderName", "authorName"}, + "creatorUserId": {"creatorUserId", "creator_user_id", "creatorId", "senderUserId", "userId"}, + } { + value, valueErr := reportOptionalString(item, operation, index, aliases...) + if valueErr != nil { + return nil, reportPageEvidence{}, valueErr + } + if value != "" { + projected[target] = value + } + } + for target, aliases := range map[string][]string{ + "createTime": {"createTime", "create_time", "gmtCreate", "sendTime"}, + "modifiedTime": {"modifiedTime", "modified_time", "gmtModified", "modifyTime"}, + } { + value, present, valueErr := reportOptionalInteger(item, operation, index, aliases...) + if valueErr != nil { + return nil, reportPageEvidence{}, valueErr + } + if present { + projected[target] = value + } + } + entries = append(entries, projected) + } + page, err := reportPage(data, pageContainer, operation, len(entries)) + if err != nil { + return nil, reportPageEvidence{}, err + } + return entries, page, nil +} + +func reportPage(data, result map[string]any, operation string, count int) (reportPageEvidence, error) { + raw, present := data["hasMore"] + if nested, exists := result["hasMore"]; exists { + if present && !sameReportScalar(raw, nested) { + return reportPageEvidence{}, reportResponseError(operation, "conflicting_pagination", "Report 顶层与 result.hasMore 冲突") + } + raw, present = nested, true + } + if !present { + return reportPageEvidence{}, reportResponseError(operation, "missing_pagination", "Report 列表缺少 hasMore,不能把当前页宣称为完整结果") + } + hasMore, ok := raw.(bool) + if !ok { + return reportPageEvidence{}, reportResponseError(operation, "malformed_pagination", "Report hasMore 应为布尔值") + } + page := reportPageEvidence{HasMore: hasMore} + next, nextPresent, err := reportNextCursor(data, result, operation) + if err != nil { + return reportPageEvidence{}, err + } + if !hasMore { + // The Report service echoes an integer cursor on terminal pages. It is a + // page receipt, not a continuation: hasMore=false is authoritative and + // the unified projection intentionally omits next_token. Parsing above + // still rejects wrong types and conflicting cursor fields. + return page, nil + } + if count == 0 { + return reportPageEvidence{}, reportResponseError(operation, "empty_page_with_continuation", "Report 空页仍声明 hasMore=true") + } + if !nextPresent || next <= 0 { + return reportPageEvidence{}, reportResponseError(operation, "missing_next_cursor", "hasMore=true 时必须返回正整数 continuation cursor") + } + page.Next = strconv.FormatInt(next, 10) + return page, nil +} + +func reportNextCursor(data, result map[string]any, operation string) (int64, bool, error) { + var selected int64 + found := false + for _, object := range []map[string]any{data, result} { + for _, key := range []string{"nextCursor", "cursor"} { + raw, present := object[key] + if !present || raw == nil { + continue + } + value, ok := reportInteger(raw) + if !ok { + return 0, false, reportResponseError(operation, "malformed_pagination", fmt.Sprintf("Report %s 应为整数", key)) + } + if found && selected != value { + return 0, false, reportResponseError(operation, "conflicting_pagination", "Report continuation cursor 字段冲突") + } + selected, found = value, true + } + } + return selected, found, nil +} + +func sameReportScalar(left, right any) bool { + leftJSON, leftErr := json.Marshal(left) + rightJSON, rightErr := json.Marshal(right) + return leftErr == nil && rightErr == nil && string(leftJSON) == string(rightJSON) +} + +func reportProjectTemplates(data map[string]any, operation string) ([]map[string]any, error) { + if err := reportRequireSuccess(data, operation); err != nil { + return nil, err + } + raw, present := data["items"] + if !present { + return nil, reportResponseError(operation, "missing_collection", "Report 模板响应缺少 items 数组") + } + items, err := reportObjectCollection(raw, operation, "items") + if err != nil { + return nil, err + } + templates := make([]map[string]any, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for index, item := range items { + id, identityErr := reportRequiredString(item, operation, index, "report_template_id") + if identityErr != nil { + return nil, identityErr + } + name, nameErr := reportRequiredString(item, operation, index, "report_template_name") + if nameErr != nil { + return nil, nameErr + } + if _, duplicate := seen[id]; duplicate { + return nil, reportResponseError(operation, "duplicate_item_identity", "Report 模板响应包含重复 templateId") + } + seen[id] = struct{}{} + projected := map[string]any{"templateId": id, "name": name} + modified, present, modifiedErr := reportOptionalInteger(item, operation, index, "last_modified_time") + if modifiedErr != nil { + return nil, modifiedErr + } + if present { + projected["lastModifiedTime"] = modified + } + templates = append(templates, projected) + } + return templates, nil +} + +func reportProjectDetail(data map[string]any, operation, expectedID string) (map[string]any, error) { + if err := reportRequireSuccess(data, operation); err != nil { + return nil, err + } + result, ok := data["result"].(map[string]any) + if !ok || len(result) == 0 { + return nil, reportResponseError(operation, "missing_result", "Report 详情成功响应缺少非空 result 对象") + } + id, err := reportRequiredString(result, operation, 0, "report_Id", "reportId", "report_id") + if err != nil { + return nil, err + } + if id != expectedID { + return nil, reportResponseError(operation, "identity_mismatch", "Report 详情 reportId 与请求目标不一致") + } + projected := map[string]any{"reportId": id} + for target, aliases := range map[string][]string{ + "title": {"report_name", "reportName", "title"}, + "templateName": {"report_template_name", "templateName"}, + "creatorName": {"creatorName", "senderName"}, + } { + value, valueErr := reportOptionalString(result, operation, 0, aliases...) + if valueErr != nil { + return nil, valueErr + } + if value != "" { + projected[target] = value + } + } + for target, aliases := range map[string][]string{ + "createTime": {"createTime", "create_time"}, "modifiedTime": {"modifiedTime", "modified_time"}, + } { + value, present, valueErr := reportOptionalInteger(result, operation, 0, aliases...) + if valueErr != nil { + return nil, valueErr + } + if present { + projected[target] = value + } + } + rawFields, present := result["report_content"] + if !present { + return nil, reportResponseError(operation, "missing_collection", "Report 详情缺少 result.report_content 数组") + } + fields, err := reportObjectCollection(rawFields, operation, "result.report_content") + if err != nil { + return nil, err + } + projectedFields := make([]map[string]any, 0, len(fields)) + for index, field := range fields { + key, keyErr := reportRequiredString(field, operation, index, "key") + if keyErr != nil { + return nil, keyErr + } + rawValue, valuePresent := field["value"] + value, valueOK := rawValue.(string) + if !valuePresent || !valueOK { + return nil, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 详情字段第 %d 项的 value 必须是字符串", index)) + } + sortValue, sortOK := reportInteger(field["sort"]) + typeValue, typeOK := reportInteger(field["type"]) + if !sortOK || !typeOK { + return nil, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 详情字段第 %d 项的 sort/type 必须是整数", index)) + } + projectedFields = append(projectedFields, map[string]any{"key": key, "value": value, "sort": sortValue, "type": typeValue}) + } + projected["fields"] = projectedFields + return projected, nil +} + +func outputReportPage(rt *shortcut.RuntimeContext, collection string, entries []map[string]any, page reportPageEvidence) error { + payload := map[string]any{"count": len(entries), collection: entries, "complete": !page.HasMore} + if !output.UsesUnifiedResult(rt.Command()) { + if page.HasMore { + payload["nextCursor"] = page.Next + } + return rt.Output(payload) + } + pagination, err := output.NewPagination(!page.HasMore, page.Next) + if err != nil { + return reportResponseError("report/pagination", "invalid_pagination", err.Error()) + } + meta := &output.Meta{Count: output.NewCount(len(entries)), Pagination: pagination} + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) +} diff --git a/internal/shortcut/report/latest.go b/internal/shortcut/report/latest.go new file mode 100644 index 00000000..86d6b644 --- /dev/null +++ b/internal/shortcut/report/latest.go @@ -0,0 +1,96 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package report + +import ( + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +var ReportLatest = shortcut.Shortcut{ + Service: "report", Command: "+report-latest", Product: "report", + Description: "读取我最近提交的一篇日志详情", + Intent: "只想查看自己最近 20 天提交的最新日志详情时使用;必须先证明发件箱结果完整、每项都有稳定 reportId 与创建时间,再按精确 ID 读回详情。", + Risk: shortcut.RiskRead, + Safety: reportReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: reportContract( + "+report-latest", "读取我最近提交的一篇日志详情", + "只想查看自己最近 20 天提交的最新日志详情时使用;必须先证明发件箱结果完整、每项都有稳定 reportId 与创建时间,再按精确 ID 读回详情。", + reportLatestResult(), nil, + []contract.ParamDecl{{Name: "keyword", Property: "report_template_name"}}, + "dws report +report-latest", "dws report +report-latest --keyword 周报", + ), + Flags: []shortcut.Flag{{Name: "keyword", Type: shortcut.FlagString, Desc: "按日志模板名称精确过滤"}}, + Tips: []string{"dws report +report-latest", "dws report +report-latest --keyword 周报"}, + Execute: func(rt *shortcut.RuntimeContext) error { + const listOperation = "report/get_send_report_list" + now := time.Now() + end := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()) + start := end.Add(-reportMaximumOutboxDays * 24 * time.Hour) + params := map[string]any{ + "cursor": 0, "size": 20, + "startTime": start.UnixMilli(), "endTime": end.UnixMilli(), + } + if keyword := strings.TrimSpace(rt.Str("keyword")); keyword != "" { + params["report_template_name"] = keyword + } + data, err := rt.CallMCPData("report", "get_send_report_list", params) + if err != nil { + return err + } + entries, page, err := reportProjectEntries(data, listOperation) + if err != nil { + return err + } + if page.HasMore { + return reportResponseError(listOperation, "incomplete_latest_candidates", "发件箱仍有后续页,不能从不完整集合宣称最新日志") + } + if len(entries) == 0 { + return apperrors.NewValidation("最近 20 天没有可验证的已发送日志", apperrors.WithReason("no_sent_report_fixture")) + } + latestID, err := reportLatestEntryID(entries, listOperation) + if err != nil { + return err + } + const detailOperation = "report/get_report_entry_details" + detailData, err := rt.CallMCPData("report", "get_report_entry_details", map[string]any{"report_id": latestID}) + if err != nil { + return err + } + detail, err := reportProjectDetail(detailData, detailOperation, latestID) + if err != nil { + return err + } + return rt.Output(map[string]any{"report": detail}) + }, +} + +func reportLatestEntryID(entries []map[string]any, operation string) (string, error) { + var latestID string + var latestTime int64 + for index, entry := range entries { + created, ok := entry["createTime"].(int64) + if !ok || created <= 0 { + return "", reportResponseError(operation, "missing_latest_order", "发件箱项目缺少正整数 createTime,不能证明哪一篇最新") + } + id, ok := entry["reportId"].(string) + if !ok || strings.TrimSpace(id) == "" { + return "", reportResponseError(operation, "missing_item_identity", "发件箱项目缺少稳定 reportId") + } + if index == 0 || created > latestTime { + latestID, latestTime = id, created + } + } + return latestID, nil +} + +func init() { + shortcut.Register(ReportLatest) +} diff --git a/internal/shortcut/report/report.go b/internal/shortcut/report/report.go index 28aa7938..d527597c 100644 --- a/internal/shortcut/report/report.go +++ b/internal/shortcut/report/report.go @@ -1,103 +1,126 @@ // Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +// Licensed under the Apache License, Version 2.0 -// Package report declares declarative shortcuts for the DingTalk -// "报告/日志" (OA 周报应用) MCP tools. Each shortcut is a thin wrapper over a -// single MCP tool call; tool names and parameter keys mirror the DWS report -// helper (internal/helpers/report.go) verbatim. +// Package report registers strict declarative shortcuts for DingTalk reports. package report import ( "fmt" + "strconv" "strings" "time" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) -// reportParseISOMillis parses an ISO-8601 timestamp (e.g. 2026-03-10T00:00:00+08:00) -// into Unix milliseconds, matching the helper's parseISOTimeToMillis behavior. -func reportParseISOMillis(name, s string) (int64, error) { - s = strings.TrimSpace(s) - t, err := time.Parse(time.RFC3339, s) +const ( + reportMaximumInboxDays = 180 + reportMaximumOutboxDays = 20 +) + +func reportParseISOMillis(name, value string) (int64, error) { + parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value)) if err != nil { - return 0, fmt.Errorf("--%s 时间格式无效,需 ISO-8601(如 2026-03-10T00:00:00+08:00): %v", name, err) + return 0, apperrors.NewValidation( + fmt.Sprintf("--%s 时间格式无效,需 ISO-8601(如 2026-03-10T00:00:00+08:00)", name), + apperrors.WithReason("invalid_time_format"), + ) } - return t.UnixMilli(), nil + return parsed.UnixMilli(), nil +} + +func reportValidatePage(cursor, size int) error { + if cursor < 0 { + return apperrors.NewValidation("--cursor 不能小于 0", apperrors.WithReason("invalid_cursor")) + } + if size < 1 || size > 20 { + return apperrors.NewValidation("--size 必须在 1 到 20 之间", apperrors.WithReason("invalid_page_size")) + } + return nil +} + +func reportValidateRange(startName, startValue, endName, endValue string, maximumDays int) (int64, int64, error) { + start, err := reportParseISOMillis(startName, startValue) + if err != nil { + return 0, 0, err + } + end, err := reportParseISOMillis(endName, endValue) + if err != nil { + return 0, 0, err + } + if end <= start { + return 0, 0, apperrors.NewValidation("结束时间必须晚于开始时间", apperrors.WithReason("invalid_time_range")) + } + if maximumDays > 0 && end-start > int64(maximumDays)*int64(24*time.Hour/time.Millisecond) { + return 0, 0, apperrors.NewValidation( + fmt.Sprintf("时间范围不能超过 %d 天", maximumDays), + apperrors.WithReason("time_range_too_large"), + ) + } + return start, end, nil +} + +func reportValidateContinuation(page reportPageEvidence, current int, operation string) error { + if !page.HasMore { + return nil + } + next, err := strconv.ParseInt(page.Next, 10, 64) + if err != nil || next <= int64(current) { + return reportResponseError(operation, "stalled_cursor", "Report continuation cursor 没有严格前进") + } + return nil } -// TemplateList lists the report templates available to the current user. -// TemplateGet reads a single report template's field definitions by name. -// InboxList lists reports received by the current user within a time window. var InboxList = shortcut.Shortcut{ - Service: "report", - Command: "+inbox-list", - Product: "report", - Description: "列出我收到的日报(按时间范围分页)", - Intent: "当你要查看下属或同事发给自己的日报周报、想在某个时间段内浏览或审阅收到的汇报时使用;输入起止时间(ISO-8601),可按发送人 staffId 过滤,分页返回收到的日报列表及其 reportId,供后续 +entry-get 读正文。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "report", - Name: "shortcut_inbox_list", - CanonicalPath: "report.shortcut_inbox_list", - CLIPath: "report +inbox-list", - PrimaryCLIPath: "report +inbox-list", + Service: "report", Command: "+inbox-list", Product: "report", + Description: "列出我收到的日志", + Intent: "需要按明确时间范围读取别人发给我的日志摘要并取得稳定 reportId 时使用;后端必须提供可验证的终止或严格前进 cursor。", + Risk: shortcut.RiskRead, + Safety: reportReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: reportContract( + "+inbox-list", "列出我收到的日志", + "需要按明确时间范围读取别人发给我的日志摘要并取得稳定 reportId 时使用;后端必须提供可验证的终止或严格前进 cursor。", + reportCollectionResult("reports", "严格验证的收件箱日志页"), reportPagination(), + []contract.ParamDecl{ + {Name: "start", Property: "startTime"}, {Name: "end", Property: "endTime"}, + {Name: "cursor", Property: "cursor"}, {Name: "size", Property: "size"}, + {Name: "sender-user-ids", Property: "senderUserIds", InterfaceType: "array"}, }, - Description: "列出我收到的日报(按时间范围分页)", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "列出我收到的日报(按时间范围分页)", - UseWhen: []string{"当你要查看下属或同事发给自己的日报周报、想在某个时间段内浏览或审阅收到的汇报时使用;输入起止时间(ISO-8601),可按发送人 staffId 过滤,分页返回收到的日报列表及其 reportId,供后续 +entry-get 读正文。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws report +inbox-list --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --cursor 0 --size 20"}, - }, - }, + "dws report +inbox-list --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --cursor 0 --size 20", + ), Flags: []shortcut.Flag{ - {Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00)", Required: true}, - {Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00)", Required: true}, - {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"}, - {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,最大 20"}, - {Name: "sender-user-ids", Type: shortcut.FlagStringSlice, Desc: "发送人 staffId 列表,逗号分隔,过滤指定发送人"}, + {Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601;结束时间必须晚于开始时间,跨度不得超过 180 天", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601;结束时间必须晚于开始时间,跨度不得超过 180 天", Required: true}, + {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标;--cursor 不能小于 0,续页 cursor 必须严格前进"}, + {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数;--size 必须在 1 到 20 之间"}, + {Name: "sender-user-ids", Type: shortcut.FlagStringSlice, Desc: "发送人 staffId 列表"}, }, - Tips: []string{ - `dws report +inbox-list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --cursor 0 --size 20`, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "结束时间必须晚于开始时间,跨度不得超过 180 天"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"cursor"}, Description: "--cursor 不能小于 0,续页 cursor 必须严格前进"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "--size 必须在 1 到 20 之间"}, }, - Execute: func(rt *shortcut.RuntimeContext) error { - startMs, err := reportParseISOMillis("start", rt.Str("start")) - if err != nil { + Tips: []string{`dws report +inbox-list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --cursor 0 --size 20`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := reportValidatePage(rt.Int("cursor"), rt.Int("size")); err != nil { return err } - endMs, err := reportParseISOMillis("end", rt.Str("end")) + _, _, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumInboxDays) + return err + }, + Execute: func(rt *shortcut.RuntimeContext) error { + const operation = "report/get_received_report_list" + start, end, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumInboxDays) if err != nil { return err } params := map[string]any{ - "startTime": startMs, - "endTime": endMs, - "cursor": rt.Int("cursor"), - "size": rt.Int("size"), + "startTime": start, "endTime": end, + "cursor": rt.Int("cursor"), "size": rt.Int("size"), } if rt.Changed("sender-user-ids") { params["senderUserIds"] = rt.StrSlice("sender-user-ids") @@ -106,192 +129,164 @@ var InboxList = shortcut.Shortcut{ if err != nil { return err } - reports := reportEntryListProject(data) - return rt.Output(map[string]any{"count": len(reports), "reports": reports}) + reports, page, err := reportProjectEntries(data, operation) + if err != nil { + return err + } + if err := reportValidateContinuation(page, rt.Int("cursor"), operation); err != nil { + return err + } + return outputReportPage(rt, "reports", reports, page) }, } -// reportEntryListProject reshapes the raw report-list responses -// (get_received_report_list / get_send_report_list) into a clean, stable list -// of report summaries — the output-projection fidelity the framework applies to -// every list command. Both the list container and the per-item field names are -// probed defensively across candidate keys so the projection tolerates -// response-shape drift and never fabricates data: an empty/unknown shape yields -// an empty list rather than a crash. -func reportEntryListProject(data map[string]any) []map[string]any { - raw := reportEntryListResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := reportEntryListFirst(m, "reportId", "report_id", "id"); ok { - row["reportId"] = v - } - if v, ok := reportEntryListFirst(m, "templateName", "template_name", "reportTemplateName"); ok { - row["templateName"] = v - } - if v, ok := reportEntryListFirst(m, "creatorName", "creator_name", "creatorUserName", "senderName", "authorName"); ok { - row["creatorName"] = v - } - if v, ok := reportEntryListFirst(m, "creatorUserId", "creator_user_id", "creatorId", "senderUserId", "userId"); ok { - row["creatorUserId"] = v - } - if v, ok := reportEntryListFirst(m, "createTime", "create_time", "gmtCreate", "sendTime"); ok { - row["createTime"] = v - } - if v, ok := reportEntryListFirst(m, "modifiedTime", "modified_time", "gmtModified", "modifyTime"); ok { - row["modifiedTime"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// reportEntryListResolveList locates the list payload inside the response, -// tolerating a bare top-level array or nesting one level under a common -// envelope key. -func reportEntryListResolveList(data map[string]any) []any { - if data == nil { - return []any{} - } - // get_received_report_list / get_send_report_list nest the list under - // result.report_list (snake_case); "report_list" MUST be probed — the - // camelCase "reportList" alone leaves +inbox-list / +outbox-list silently - // empty despite the backend returning reports. - for _, key := range []string{"result", "data", "list", "items", "report_list", "reportList", "records"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "report_list", "reportList", "records", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// reportEntryListFirst returns the first present candidate key's value. -func reportEntryListFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - -// OutboxList lists reports sent/created by the current user. var OutboxList = shortcut.Shortcut{ - Service: "report", - Command: "+outbox-list", - Product: "report", - Description: "列出我发出的日报(可选时间/模版名过滤)", - Intent: "当你要回顾自己写过、提交过的日报周报,比如确认某天是否已交、找回历史汇报内容或统计提交情况时使用;可按创建/修改时间范围和模版名过滤,分页返回自己发出的日报列表及 reportId,供后续 +entry-get 查看正文。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "report", - Name: "shortcut_outbox_list", - CanonicalPath: "report.shortcut_outbox_list", - CLIPath: "report +outbox-list", - PrimaryCLIPath: "report +outbox-list", + Service: "report", Command: "+outbox-list", Product: "report", + Description: "列出我发出的日志", + Intent: "需要按创建或修改时间回顾自己提交的日志并取得稳定 reportId 时使用;当前缺少可证明非空的真实发件箱 fixture。", + Risk: shortcut.RiskRead, + Safety: reportReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: reportContract( + "+outbox-list", "列出我发出的日志", + "需要按创建或修改时间回顾自己提交的日志并取得稳定 reportId 时使用;当前缺少可证明非空的真实发件箱 fixture。", + reportCollectionResult("reports", "严格验证的发件箱日志页"), reportPagination(), + []contract.ParamDecl{ + {Name: "cursor", Property: "cursor"}, {Name: "size", Property: "size"}, + {Name: "start", Property: "startTime"}, {Name: "end", Property: "endTime"}, + {Name: "modified-start", Property: "modifiedStartTime"}, {Name: "modified-end", Property: "modifiedEndTime"}, + {Name: "template-name", Property: "report_template_name"}, }, - Description: "列出我发出的日报(可选时间/模版名过滤)", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "列出我发出的日报(可选时间/模版名过滤)", - UseWhen: []string{"当你要回顾自己写过、提交过的日报周报,比如确认某天是否已交、找回历史汇报内容或统计提交情况时使用;可按创建/修改时间范围和模版名过滤,分页返回自己发出的日报列表及 reportId,供后续 +entry-get 查看正文。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{ - "dws report +outbox-list --cursor 0 --size 20", - "dws report +outbox-list --cursor 0 --size 20 --template-name \"日报\"", - }, - }, - }, + "dws report +outbox-list --cursor 0 --size 20", + ), Flags: []shortcut.Flag{ - {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"}, - {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,最大 20"}, - {Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601 (可选,服务端单次跨度上限 20 天)"}, - {Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601 (可选)"}, - {Name: "modified-start", Type: shortcut.FlagString, Desc: "修改开始时间 ISO-8601 (可选)"}, - {Name: "modified-end", Type: shortcut.FlagString, Desc: "修改结束时间 ISO-8601 (可选)"}, - {Name: "template-name", Type: shortcut.FlagString, Desc: "日志模板名称 (可选,不传查全部)"}, + {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标;--cursor 不能小于 0,续页 cursor 必须严格前进"}, + {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数;--size 必须在 1 到 20 之间"}, + {Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601;创建时间范围必须有效且不得超过 20 天"}, + {Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601;创建时间范围必须有效且不得超过 20 天"}, + {Name: "modified-start", Type: shortcut.FlagString, Desc: "修改开始时间 ISO-8601;修改时间必须成对提供、范围有效且不得超过 20 天"}, + {Name: "modified-end", Type: shortcut.FlagString, Desc: "修改结束时间 ISO-8601;修改时间必须成对提供、范围有效且不得超过 20 天"}, + {Name: "template-name", Type: shortcut.FlagString, Desc: "日志模板名称"}, }, - Tips: []string{ - `dws report +outbox-list --cursor 0 --size 20`, - `dws report +outbox-list --cursor 0 --size 20 --template-name "日报"`, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"cursor"}, Description: "--cursor 不能小于 0,续页 cursor 必须严格前进"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "--size 必须在 1 到 20 之间"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "创建时间范围必须有效且不得超过 20 天"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"modified-start", "modified-end"}, Description: "修改时间必须成对提供、范围有效且不得超过 20 天"}, }, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "cursor": rt.Int("cursor"), - "size": rt.Int("size"), + Tips: []string{"dws report +outbox-list --cursor 0 --size 20"}, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := reportValidatePage(rt.Int("cursor"), rt.Int("size")); err != nil { + return err } - if rt.Changed("start") { - ms, err := reportParseISOMillis("start", rt.Str("start")) - if err != nil { + if rt.Changed("start") && rt.Changed("end") { + if _, _, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumOutboxDays); err != nil { return err } - params["startTime"] = ms } - if rt.Changed("end") { - ms, err := reportParseISOMillis("end", rt.Str("end")) - if err != nil { - return err - } - params["endTime"] = ms + if rt.Changed("modified-start") != rt.Changed("modified-end") { + return apperrors.NewValidation("--modified-start 与 --modified-end 必须同时提供", apperrors.WithReason("incomplete_modified_range")) } if rt.Changed("modified-start") { - ms, err := reportParseISOMillis("modified-start", rt.Str("modified-start")) - if err != nil { - return err - } - params["modifiedStartTime"] = ms + _, _, err := reportValidateRange("modified-start", rt.Str("modified-start"), "modified-end", rt.Str("modified-end"), reportMaximumOutboxDays) + return err } - if rt.Changed("modified-end") { - ms, err := reportParseISOMillis("modified-end", rt.Str("modified-end")) - if err != nil { - return err - } - params["modifiedEndTime"] = ms + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + const operation = "report/get_send_report_list" + now := time.Now() + defaultEnd := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()) + startValue := defaultEnd.Add(-reportMaximumOutboxDays * 24 * time.Hour).Format(time.RFC3339) + endValue := defaultEnd.Format(time.RFC3339) + if rt.Changed("start") { + startValue = rt.Str("start") } - if rt.Changed("template-name") { - params["report_template_name"] = rt.Str("template-name") + if rt.Changed("end") { + endValue = rt.Str("end") + } + start, end, err := reportValidateRange("start", startValue, "end", endValue, reportMaximumOutboxDays) + if err != nil { + return err + } + params := map[string]any{ + "cursor": rt.Int("cursor"), "size": rt.Int("size"), + "startTime": start, "endTime": end, + } + if rt.Changed("modified-start") { + modifiedStart, modifiedEnd, rangeErr := reportValidateRange("modified-start", rt.Str("modified-start"), "modified-end", rt.Str("modified-end"), reportMaximumOutboxDays) + if rangeErr != nil { + return rangeErr + } + params["modifiedStartTime"], params["modifiedEndTime"] = modifiedStart, modifiedEnd + } + if name := strings.TrimSpace(rt.Str("template-name")); name != "" { + params["report_template_name"] = name } data, err := rt.CallMCPData("report", "get_send_report_list", params) if err != nil { return err } - reports := reportEntryListProject(data) - return rt.Output(map[string]any{"count": len(reports), "reports": reports}) + reports, page, err := reportProjectEntries(data, operation) + if err != nil { + return err + } + if err := reportValidateContinuation(page, rt.Int("cursor"), operation); err != nil { + return err + } + return outputReportPage(rt, "reports", reports, page) }, } -// EntryGet reads the full body of a single report by id. -// EntryStats reads the read-receipt statistics of a single report. -// EntrySubmit submits a new report against a template. +var TemplateSearch = shortcut.Shortcut{ + Service: "report", Command: "+template-search", Product: "report", + Description: "按名称搜索可用日志模板", + Intent: "需要从当前用户全部可用日志模板中按名称查找稳定 templateId,或在提交日志前确认模板是否存在时使用。", + Risk: shortcut.RiskRead, + Safety: reportReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: reportContract( + "+template-search", "按名称搜索可用日志模板", + "需要从当前用户全部可用日志模板中按名称查找稳定 templateId,或在提交日志前确认模板是否存在时使用。", + reportTemplateSearchResult(), nil, + []contract.ParamDecl{{Name: "query", Property: "report_template_name"}}, + "dws report +template-search --query 周报", + ), + Flags: []shortcut.Flag{{Name: "query", Type: shortcut.FlagString, Desc: "模板名称关键词,不区分大小写;--query 不能为空", Required: true}}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "--query 不能为空"}}, + Tips: []string{"dws report +template-search --query 周报"}, + Validate: func(rt *shortcut.RuntimeContext) error { + if strings.TrimSpace(rt.Str("query")) == "" { + return apperrors.NewValidation("--query 不能为空", apperrors.WithReason("empty_query")) + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + const operation = "report/get_available_report_templates" + data, err := rt.CallMCPData("report", "get_available_report_templates", map[string]any{}) + if err != nil { + return err + } + templates, err := reportProjectTemplates(data, operation) + if err != nil { + return err + } + query := strings.ToLower(strings.TrimSpace(rt.Str("query"))) + matches := make([]map[string]any, 0) + for _, template := range templates { + if strings.Contains(strings.ToLower(template["name"].(string)), query) { + matches = append(matches, template) + } + } + payload := map[string]any{"count": len(matches), "templates": matches} + if !output.UsesUnifiedResult(rt.Command()) { + return rt.Output(payload) + } + meta := &output.Meta{Count: output.NewCount(len(matches))} + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) + }, +} + func init() { - shortcut.Register( - InboxList, - OutboxList, - ) + shortcut.Register(InboxList, OutboxList, TemplateSearch) } diff --git a/internal/shortcut/report/report_list_projection_test.go b/internal/shortcut/report/report_list_projection_test.go deleted file mode 100644 index faa8c95e..00000000 --- a/internal/shortcut/report/report_list_projection_test.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package report - -import ( - "encoding/json" - "testing" -) - -// TestReportEntryListResolveListSnakeShape guards against projection-data-loss: -// get_received_report_list / get_send_report_list nest the list under -// result.report_list (snake_case); the resolver must probe "report_list" or -// +inbox-list / +outbox-list silently return empty despite the backend having -// reports. -func TestReportEntryListResolveListSnakeShape(t *testing.T) { - const raw = `{"result":{"report_list":[ - {"reportId":"r1","templateName":"daily report"}, - {"reportId":"r2","templateName":"weekly report"} - ]}}` - var data map[string]any - if err := json.Unmarshal([]byte(raw), &data); err != nil { - t.Fatalf("unmarshal fixture: %v", err) - } - if got := reportEntryListResolveList(data); len(got) != 2 { - t.Fatalf("lower/upper mismatch: result.report_list has 2 entries, resolver returned %d", len(got)) - } -} diff --git a/internal/shortcut/report/report_strict_cross_platform_coverage_test.go b/internal/shortcut/report/report_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..1c4a09be --- /dev/null +++ b/internal/shortcut/report/report_strict_cross_platform_coverage_test.go @@ -0,0 +1,309 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package report + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type reportCoverageCall struct { + tool string + args map[string]any +} + +type reportCoverageCaller struct { + responses map[string][]string + history []reportCoverageCall +} + +func (caller *reportCoverageCaller) CallTool(_ context.Context, _, tool string, args map[string]any) (*edition.ToolResult, error) { + caller.history = append(caller.history, reportCoverageCall{tool: tool, args: args}) + queue := caller.responses[tool] + if len(queue) == 0 { + return nil, errors.New("missing Report fake response for " + tool) + } + caller.responses[tool] = queue[1:] + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil +} + +func (*reportCoverageCaller) Format() string { return "json" } +func (*reportCoverageCaller) DryRun() bool { return false } +func (*reportCoverageCaller) Fields() string { return "" } +func (*reportCoverageCaller) JQ() string { return "" } + +func runReportCoverage(t *testing.T, declaration shortcut.Shortcut, caller *reportCoverageCaller, args ...string) (*cobra.Command, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + return cmd, cmd.Execute() +} + +func TestCrossPlatformCoverageReportContractsAreStrictTypedAndUnified(t *testing.T) { + declarations := []shortcut.Shortcut{InboxList, OutboxList, TemplateSearch, ReportLatest} + for _, declaration := range declarations { + if declaration.Contract.Empty() || declaration.Contract.Result == nil { + t.Errorf("%s lacks Contract/Result", declaration.Command) + } + if declaration.Safety.Effect != "read" || declaration.Safety.Confirmation != "not_required" { + t.Errorf("%s safety=%+v", declaration.Command, declaration.Safety) + } + if declaration.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout) + } + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" { + t.Errorf("%s interface=%+v", declaration.Command, declaration.Contract.Interface) + } + } + if InboxList.Contract.Pagination == nil || OutboxList.Contract.Pagination == nil { + t.Fatal("Report list shortcuts must publish cursor pagination") + } + if TemplateSearch.Contract.Pagination != nil || ReportLatest.Contract.Pagination != nil { + t.Fatal("non-paginated Report shortcuts published pagination") + } +} + +func TestCrossPlatformCoverageReportListResponseMatrix(t *testing.T) { + valid := map[string]any{ + "success": true, + "result": map[string]any{ + "report_list": []any{map[string]any{"reportId": "report-1", "templateName": "fixture", "createTime": float64(10)}}, + }, + "hasMore": false, + } + entries, page, err := reportProjectEntries(valid, "report/get_received_report_list") + if err != nil || len(entries) != 1 || page.HasMore || entries[0]["createTime"] != int64(10) { + t.Fatalf("valid projection entries=%#v page=%+v err=%v", entries, page, err) + } + empty := map[string]any{"success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": false, "cursor": nil} + if entries, page, err := reportProjectEntries(empty, "report/get_received_report_list"); err != nil || len(entries) != 0 || page.HasMore { + t.Fatalf("terminal empty entries=%#v page=%+v err=%v", entries, page, err) + } + terminalReceipt := map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}, "hasMore": false, "nextCursor": float64(1)} + if entries, page, err := reportProjectEntries(terminalReceipt, "report/get_received_report_list"); err != nil || len(entries) != 1 || page.HasMore || page.Next != "" { + t.Fatalf("terminal cursor receipt entries=%#v page=%+v err=%v", entries, page, err) + } + fixtures := map[string]map[string]any{ + "empty response": map[string]any{}, + "missing success": map[string]any{"result": map[string]any{"report_list": []any{}}, "hasMore": false}, + "wrong success": map[string]any{"success": "true", "result": map[string]any{"report_list": []any{}}, "hasMore": false}, + "remote failure": map[string]any{"success": false, "result": map[string]any{"report_list": []any{}}, "hasMore": false}, + "missing result": map[string]any{"success": true, "hasMore": false}, + "wrong result": map[string]any{"success": true, "result": "bad", "hasMore": false}, + "missing collection": map[string]any{"success": true, "result": map[string]any{}, "hasMore": false}, + "wrong collection": map[string]any{"success": true, "result": map[string]any{"report_list": map[string]any{}}, "hasMore": false}, + "bad item": map[string]any{"success": true, "result": map[string]any{"report_list": []any{"bad"}}, "hasMore": false}, + "empty item": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{}}}, "hasMore": false}, + "missing identity": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"createTime": float64(1)}}}, "hasMore": false}, + "wrong identity": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": float64(1)}}}, "hasMore": false}, + "duplicate identity": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "same"}, map[string]any{"reportId": "same"}}}, "hasMore": false}, + "wrong optional": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1", "createTime": "1"}}}, "hasMore": false}, + "missing pagination": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}}, + "wrong pagination": map[string]any{"success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": "false"}, + "empty continuation": map[string]any{"success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": true, "nextCursor": float64(2)}, + "missing continuation": map[string]any{ + "success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}, "hasMore": true, + }, + "wrong continuation": map[string]any{ + "success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}, "hasMore": true, "nextCursor": "2", + }, + "wrong terminal cursor": map[string]any{ + "success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": false, "nextCursor": "2", + }, + "conflicting has more": map[string]any{ + "success": true, "result": map[string]any{"report_list": []any{}, "hasMore": true}, "hasMore": false, + }, + "conflicting continuation": map[string]any{ + "success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}, "cursor": float64(3)}, "hasMore": true, "nextCursor": float64(2), + }, + } + for name, fixture := range fixtures { + if projected, _, projectErr := reportProjectEntries(fixture, "report/get_received_report_list"); projectErr == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } + if err := reportValidateContinuation(reportPageEvidence{HasMore: true, Next: "2"}, 2, "report/list"); err == nil { + t.Fatal("stalled continuation returned success") + } +} + +func TestCrossPlatformCoverageReportTemplateResponseMatrix(t *testing.T) { + valid := map[string]any{"success": true, "items": []any{ + map[string]any{"report_template_id": "template-1", "report_template_name": "Fixture Weekly", "last_modified_time": float64(2)}, + map[string]any{"report_template_id": "template-2", "report_template_name": "Fixture Daily"}, + }} + templates, err := reportProjectTemplates(valid, "report/get_available_report_templates") + if err != nil || len(templates) != 2 || templates[0]["lastModifiedTime"] != int64(2) { + t.Fatalf("valid templates=%#v err=%v", templates, err) + } + for name, fixture := range map[string]map[string]any{ + "empty response": {}, + "missing success": {"items": []any{}}, + "wrong success": {"success": float64(1), "items": []any{}}, + "missing collection": {"success": true}, + "wrong collection": {"success": true, "items": map[string]any{}}, + "bad item": {"success": true, "items": []any{"bad"}}, + "missing id": {"success": true, "items": []any{map[string]any{"report_template_name": "fixture"}}}, + "missing name": {"success": true, "items": []any{map[string]any{"report_template_id": "template-1"}}}, + "wrong name": {"success": true, "items": []any{map[string]any{"report_template_id": "template-1", "report_template_name": float64(1)}}}, + "duplicate id": {"success": true, "items": []any{map[string]any{"report_template_id": "same", "report_template_name": "one"}, map[string]any{"report_template_id": "same", "report_template_name": "two"}}}, + "wrong modified": {"success": true, "items": []any{map[string]any{"report_template_id": "template-1", "report_template_name": "fixture", "last_modified_time": "2"}}}, + } { + if projected, projectErr := reportProjectTemplates(fixture, "report/get_available_report_templates"); projectErr == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } + if projected, err := reportProjectTemplates(map[string]any{"success": true, "items": []any{}}, "report/templates"); err != nil || len(projected) != 0 { + t.Fatalf("explicit empty template collection=%#v err=%v", projected, err) + } +} + +func TestCrossPlatformCoverageReportDetailResponseMatrix(t *testing.T) { + valid := map[string]any{"success": true, "result": map[string]any{ + "report_Id": "report-2", "report_name": "fixture", "createTime": float64(2), + "report_content": []any{map[string]any{"key": "field", "value": "value", "sort": float64(1), "type": float64(2)}}, + }} + detail, err := reportProjectDetail(valid, "report/get_report_entry_details", "report-2") + if err != nil || detail["reportId"] != "report-2" || len(detail["fields"].([]map[string]any)) != 1 { + t.Fatalf("valid detail=%#v err=%v", detail, err) + } + for name, fixture := range map[string]map[string]any{ + "empty response": {}, + "missing result": {"success": true}, + "empty result": {"success": true, "result": map[string]any{}}, + "missing id": {"success": true, "result": map[string]any{"report_content": []any{}}}, + "identity mismatch": {"success": true, "result": map[string]any{"report_Id": "other", "report_content": []any{}}}, + "missing collection": {"success": true, "result": map[string]any{"report_Id": "report-2"}}, + "wrong collection": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": map[string]any{}}}, + "bad field": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": []any{"bad"}}}, + "missing field key": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": []any{map[string]any{"value": "value", "sort": float64(1), "type": float64(2)}}}}, + "wrong field type": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": []any{map[string]any{"key": "field", "value": "value", "sort": "1", "type": float64(2)}}}}, + } { + if projected, projectErr := reportProjectDetail(fixture, "report/get_report_entry_details", "report-2"); projectErr == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } +} + +func TestCrossPlatformCoverageReportExactShortcutsProjectUnifiedData(t *testing.T) { + templateCaller := &reportCoverageCaller{responses: map[string][]string{ + "get_available_report_templates": {`{"success":true,"items":[{"report_template_id":"template-1","report_template_name":"Fixture Weekly","last_modified_time":2},{"report_template_id":"template-2","report_template_name":"Fixture Daily"}]}`}, + }} + cmd, err := runReportCoverage(t, TemplateSearch, templateCaller, "--query", "weekly") + if err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + cmd.SetOut(&stdout) + if code, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted || code != 0 { + t.Fatalf("emit=(%d,%t,%v)", code, emitted, emitErr) + } + var envelope map[string]any + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + data := envelope["data"].(map[string]any) + if data["count"] != float64(1) || len(data["templates"].([]any)) != 1 { + t.Fatalf("template search data=%#v", data) + } + if _, leaked := data["items"]; leaked { + t.Fatalf("template search leaked raw collection: %#v", data) + } + + inboxCaller := &reportCoverageCaller{responses: map[string][]string{ + "get_received_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]},"hasMore":false,"cursor":null}`}, + }} + cmd, err = runReportCoverage(t, InboxList, inboxCaller, + "--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-02T00:00:00+08:00") + if err != nil { + t.Fatal(err) + } + stdout.Reset() + cmd.SetOut(&stdout) + if _, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted { + t.Fatalf("inbox emit=(%t,%v)", emitted, emitErr) + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + data = envelope["data"].(map[string]any) + if data["count"] != float64(1) || data["complete"] != true { + t.Fatalf("inbox data=%#v", data) + } +} + +func TestCrossPlatformCoverageReportLatestRequiresCompleteOrderedListAndExactReadback(t *testing.T) { + missingOrder := &reportCoverageCaller{responses: map[string][]string{ + "get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1"}]},"hasMore":false}`}, + }} + if _, err := runReportCoverage(t, ReportLatest, missingOrder); err == nil || len(missingOrder.history) != 1 { + t.Fatalf("missing order err=%v history=%v", err, missingOrder.history) + } + + incomplete := &reportCoverageCaller{responses: map[string][]string{ + "get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]},"hasMore":true,"nextCursor":20}`}, + }} + if _, err := runReportCoverage(t, ReportLatest, incomplete); err == nil || len(incomplete.history) != 1 { + t.Fatalf("incomplete err=%v history=%v", err, incomplete.history) + } + + caller := &reportCoverageCaller{responses: map[string][]string{ + "get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1},{"reportId":"report-2","createTime":2}]},"hasMore":false}`}, + "get_report_entry_details": {`{"success":true,"result":{"report_Id":"report-2","report_name":"fixture","createTime":2,"report_content":[{"key":"field","value":"value","sort":1,"type":2}]}}`}, + }} + cmd, err := runReportCoverage(t, ReportLatest, caller) + if err != nil { + t.Fatal(err) + } + if len(caller.history) != 2 || caller.history[0].tool != "get_send_report_list" || caller.history[1].tool != "get_report_entry_details" || caller.history[1].args["report_id"] != "report-2" { + t.Fatalf("exact call history=%#v", caller.history) + } + var stdout bytes.Buffer + cmd.SetOut(&stdout) + if _, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted { + t.Fatalf("latest emit=(%t,%v)", emitted, emitErr) + } + var envelope map[string]any + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + projected := envelope["data"].(map[string]any)["report"].(map[string]any) + _, leaked := projected["success"] + if projected["reportId"] != "report-2" || leaked { + t.Fatalf("latest projection=%s", stdout.String()) + } +} + +func TestCrossPlatformCoverageReportValidationRejectsInvalidRangesBeforeMCP(t *testing.T) { + tests := []struct { + declaration shortcut.Shortcut + args []string + }{ + {InboxList, []string{"--start", "2026-07-02T00:00:00+08:00", "--end", "2026-07-01T00:00:00+08:00"}}, + {InboxList, []string{"--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-02T00:00:00+08:00", "--size", "21"}}, + {OutboxList, []string{"--modified-start", "2026-07-01T00:00:00+08:00"}}, + } + for _, test := range tests { + caller := &reportCoverageCaller{responses: map[string][]string{}} + if _, err := runReportCoverage(t, test.declaration, caller, test.args...); err == nil || len(caller.history) != 0 { + t.Errorf("%s args=%v err=%v calls=%v", test.declaration.Command, test.args, err, caller.history) + } + } +} diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go index 535a3f0d..2542142f 100644 --- a/internal/shortcut/semantic_catalog.go +++ b/internal/shortcut/semantic_catalog.go @@ -46,6 +46,9 @@ var oaSemanticCatalogJSON []byte //go:embed semantic_catalog_ding.json var dingSemanticCatalogJSON []byte +//go:embed semantic_catalog_report.json +var reportSemanticCatalogJSON []byte + type semanticCatalogFile struct { Version int `json:"version"` Service string `json:"service"` @@ -77,6 +80,7 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs( mailSemanticCatalogJSON, oaSemanticCatalogJSON, dingSemanticCatalogJSON, + reportSemanticCatalogJSON, ) func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord { diff --git a/internal/shortcut/semantic_catalog_report.json b/internal/shortcut/semantic_catalog_report.json new file mode 100644 index 00000000..b1f6c45f --- /dev/null +++ b/internal/shortcut/semantic_catalog_report.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "service": "report", + "default_availability": "unavailable", + "shortcuts": { + "+inbox-list": { + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、result.report_list、稳定 reportId 与 hasMore/cursor;exact Shortcut 已完成已知非空、保证零项、cursor 严格前进、相邻页无重复及终止页证明。终止页回显 cursor 只作已验证收据且不发布 next_token。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + }, + "+outbox-list": { + "disposition": "semantic_adapter", + "semantic_delta": "严格验证发件箱集合、项目身份和分页终止证据;exact Shortcut 只证明多个时间窗口的零项结果,缺少已知非空 fixture,不能发布。", + "risk": "read", + "public": false, + "reviewed": true + }, + "+template-search": { + "disposition": "semantic_adapter", + "semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;exact Shortcut 同时证明已知非空与保证零命中。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + }, + "+report-latest": { + "disposition": "primary_smart", + "semantic_delta": "完整验证最近 20 天发件箱后按每项 createTime 选择最新稳定 reportId,再精确读回身份匹配的详情;当前账号没有非空发件箱 fixture,无法证明完整两步链路,保持不可用。", + "risk": "read", + "public": false, + "reviewed": true + } + } +} diff --git a/internal/shortcut/smart/report_latest.go b/internal/shortcut/smart/report_latest.go deleted file mode 100644 index c756573a..00000000 --- a/internal/shortcut/smart/report_latest.go +++ /dev/null @@ -1,220 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package smart - -import ( - "strconv" - "strings" - "time" - - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" -) - -// ReportLatest: fetch the detail of MY most recently submitted report (日志/汇报) -// in one step (DingTalk-native). -// -// Steps: -// -// 1. list the reports I sent via get_send_report_list. cursor/size/startTime/ -// endTime mirror helpers.runReportSent — the server caps a single query span -// at 20 days, so we default to the last-20-days window just like the helper. -// An optional --keyword is passed through as report_template_name (the same -// filter the helper exposes as --template-name). -// -// 2. locate the newest entry: the item with the largest create time -// (createTime/gmtCreate/sendTime), falling back to the first item that -// carries a reportId (lists come back newest-first). -// -// 3. print that report's body via get_report_entry_details (report_id param -// mirrors helpers.runReportDetail). If nothing carries a reportId we fall -// back to printing the picked list row via rt.Output. -// -// If I have not sent any report in the window it reports "暂无日志" instead of -// failing obscurely. -// -// dws report +report-latest -// dws report +report-latest --keyword 日报 -var ReportLatest = shortcut.Shortcut{ - Service: "report", - Command: "+report-latest", - Product: "report", - Description: "取我最新提交的一篇日志/汇报详情(钉钉原生)", - Intent: "当你只想快速看回自己最近发出的一篇日志/汇报,却不想先翻发件箱列表、复制 reportId 再查详情时使用;" + - "内部先列出你近期发出的日志(默认最近 20 天,服务端单次查询跨度上限 20 天;可用 --keyword 按日志模板名过滤)," + - "自动挑出创建时间最新的一条,再拉取它的正文详情(字段明细、发送人、时间、钉钉跳转链接等)。" + - "这是只读操作,不会创建或修改任何日志;若这段时间内你没有发出过日志则提示「暂无日志」。", - Risk: shortcut.RiskRead, - Flags: []shortcut.Flag{ - {Name: "keyword", Type: shortcut.FlagString, Desc: "按日志模板名过滤(可选,对应 report outbox list 的 --template-name)", Required: false}, - }, - Tips: []string{ - `dws report +report-latest`, - `dws report +report-latest --keyword 日报`, - }, - Execute: func(rt *shortcut.RuntimeContext) error { - // Step 1 — list the reports I sent. Mirror helpers.runReportSent: the - // server caps a single get_send_report_list query at a 20-day span, so - // default startTime/endTime to the last-20-days window. cursor/size are - // floats and report_template_name is the optional keyword filter. - now := time.Now() - startMs := now.AddDate(0, 0, -20).Truncate(24 * time.Hour).UnixMilli() - endMs := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).UnixMilli() - - listArgs := map[string]any{ - "cursor": float64(0), - "size": float64(20), - "startTime": float64(startMs), - "endTime": float64(endMs), - } - if kw := strings.TrimSpace(rt.Str("keyword")); kw != "" { - listArgs["report_template_name"] = kw - } - - data, err := rt.CallMCPData("report", "get_send_report_list", listArgs) - if err != nil { - return err - } - - // Step 2 — pick the newest sent report. - row, reportID := shortcutReportLatestPick(data) - if row == nil { - return apperrors.NewValidation("暂无日志") - } - - // Step 3 — print its detail (report_id mirrors helpers.runReportDetail). - // If we somehow could not read a reportId, fall back to the list row. - if reportID == "" { - return rt.Output(row) - } - return rt.CallMCP("get_report_entry_details", map[string]any{ - "report_id": reportID, - }) - }, -} - -// shortcutReportLatestPick walks a get_send_report_list response, finds the -// report entries, and returns the newest one (largest create time, falling back -// to the first entry that carries a reportId) together with its reportId. -// Returns (nil, "") when there are no entries. -func shortcutReportLatestPick(data map[string]any) (map[string]any, string) { - items := shortcutReportLatestItems(data) - if len(items) == 0 { - return nil, "" - } - - var firstItem map[string]any - firstID := "" - var bestItem map[string]any - bestID := "" - var bestTime int64 - haveTime := false - - for _, m := range items { - id := shortcutReportLatestID(m) - if id == "" { - continue - } - if firstItem == nil { - firstItem = m - firstID = id - } - if t, ok := shortcutReportLatestCreateMillis(m); ok { - if !haveTime || t > bestTime { - haveTime = true - bestTime = t - bestItem = m - bestID = id - } - } - } - - if haveTime && bestItem != nil { - return bestItem, bestID - } - if firstItem != nil { - return firstItem, firstID - } - // No entry carried a reportId; still surface the first raw item so the - // caller can print something rather than mis-report "暂无日志". - return items[0], "" -} - -// shortcutReportLatestItems pulls the report entries out of a -// get_send_report_list response, probing the same container keys the helper's -// findReportListItems uses (list/items/data/reports/records/report_list/ -// reportList nested under the top level or under result). -func shortcutReportLatestItems(data map[string]any) []map[string]any { - keys := []string{"list", "items", "data", "reports", "records", "report_list", "reportList", "result"} - for _, key := range keys { - if arr, ok := data[key].([]any); ok { - return shortcutReportLatestToMaps(arr) - } - if inner, ok := data[key].(map[string]any); ok { - for _, k2 := range []string{"list", "items", "data", "reports", "records", "report_list", "reportList"} { - if arr, ok := inner[k2].([]any); ok { - return shortcutReportLatestToMaps(arr) - } - } - } - } - return nil -} - -func shortcutReportLatestToMaps(arr []any) []map[string]any { - out := make([]map[string]any, 0, len(arr)) - for _, it := range arr { - if m, ok := it.(map[string]any); ok { - out = append(out, m) - } - } - return out -} - -// shortcutReportLatestID reads an entry's reportId, mirroring the key aliases in -// helpers.reportIDFromMap. -func shortcutReportLatestID(m map[string]any) string { - for _, key := range []string{"reportId", "reportID", "report_id", "report_Id", "report-id"} { - if id, ok := m[key].(string); ok { - if id = strings.TrimSpace(id); id != "" { - return id - } - } - } - return "" -} - -// shortcutReportLatestCreateMillis reads an entry's create time in millis, -// mirroring the create-time keys helpers.reportReadableCreateTimeMillis probes. -func shortcutReportLatestCreateMillis(m map[string]any) (int64, bool) { - for _, key := range []string{"createTime", "gmtCreate", "sendTime", "time", "modifiedTime"} { - switch v := m[key].(type) { - case float64: - if v > 0 { - return int64(v), true - } - case string: - if s := strings.TrimSpace(v); s != "" { - if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 { - return n, true - } - } - } - } - return 0, false -} - -func init() { - shortcut.Register(ReportLatest) -} diff --git a/scripts/gen_shortcut_public_catalog.py b/scripts/gen_shortcut_public_catalog.py index 7d4fa695..de3a1f2d 100644 --- a/scripts/gen_shortcut_public_catalog.py +++ b/scripts/gen_shortcut_public_catalog.py @@ -31,6 +31,7 @@ SEMANTIC_PATHS = [ ROOT / "internal" / "shortcut" / "semantic_catalog_mail.json", ROOT / "internal" / "shortcut" / "semantic_catalog_oa.json", ROOT / "internal" / "shortcut" / "semantic_catalog_ding.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_report.json", ] From 91974ce981e358871557a03dfebf68905bbe6e11 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 17:43:31 +0800 Subject: [PATCH 07/28] docs(shortcut): close OA residual audit gaps --- docs/shortcut-downstream-requirements/oa.md | 26 +++++++++++++++++---- internal/shortcut/semantic_catalog_oa.json | 6 ++--- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/docs/shortcut-downstream-requirements/oa.md b/docs/shortcut-downstream-requirements/oa.md index 3e5cdf11..aef56745 100644 --- a/docs/shortcut-downstream-requirements/oa.md +++ b/docs/shortcut-downstream-requirements/oa.md @@ -11,6 +11,21 @@ - 隐藏:6,其中 unavailable 5;`+done-approvals` 是 available 但隐藏的兼容入口。 - 所有 10 条均声明 `Contract`、`Safety`、`Result` 并启用 unified output;写入口在首次远端调用前由 Runtime 确认门禁阻断。 +### Residual-gap 分类 + +| Shortcut / 用户路线 | 最终分类 | 依据 | +|---|---|---| +| `+list-executed`、`+list-submitted`、`+my-initiated`、`+search-forms` | fully unlocked | exact 非空与保证零命中均通过;public | +| `+done-approvals` | routed | available 的隐藏兼容别名;新调用路由到 `+list-executed` | +| `+list-forms` | delivery hardened but downstream blocked | exact 与 raw 均缺 continuation,改变 cursor 后集合高度重叠 | +| `+list-pending` | delivery hardened but downstream blocked | exact 与 raw 均得到 `success=true`,但 `result.values` 为 `null` 而不是合法空数组 | +| `+pending` | unavailable | 隐藏兼容入口路由到同一受阻待办能力,没有独立安全 fixture | +| `+list-cc` | delivery hardened but downstream blocked | exact 与 raw 隔离到错型业务状态与 `null` 集合,不能解释为合法零项 | +| `+approve-by` | unavailable | 复合编排与确认门禁已实现,但没有隔离待办、终态读回和可恢复清理 fixture | +| 已知实例/任务的详情与写操作 | routed | 使用精确 OA 原子命令;必须由用户提供真实目标并单独确认,不等于 Shortcut 已解锁 | + +OA 不能作为整体宣称 fully unlocked:源码 10 条中仅 4 条 fully unlocked,1 条兼容路由,3 条下游阻塞,2 条 unavailable。 + | Exact Shortcut 证明 | 标签 | 聚合事实 | |---|---|---| | `+list-executed` | PASS | 已知非空 7;保证零命中 0;分页终态明确 | @@ -20,8 +35,11 @@ | `+done-approvals`(隐藏兼容) | PASS | 已知非空 7;首屏终态明确 | | `+list-forms` fail-closed | PASS | 非空页缺 continuation 被拒绝;两个 cursor 的 93/93 项重叠 92 项 | | `+approve-by` 确认门禁单测 | PASS | 确认前 0 调用;确认后固定为读待办、读任务、写审批、精确任务读回 | +| `+list-pending` fail-closed | PASS | exact 与 raw 均隔离到布尔成功、对象结果、`null` 集合;拒绝伪造空数组 | +| `+pending` fail-closed | PASS | 与主入口一致拒绝 `null` 集合;没有独立宽松回退 | +| `+list-cc` fail-closed | PASS | exact 与 raw 均拒绝错型业务状态及 `null` 集合 | -未列为 PASS 的能力没有安全的已知非空或可逆写 fixture,保持 unavailable;显式空结果不替代非空证明。 +未列为 fully unlocked 的能力没有安全的已知非空或可逆写 fixture,或下游响应合同本身不合法;`null` 集合不是显式空结果,不能替代合法零项证明。 ## Lark 1.0.87 用户任务映射 @@ -36,9 +54,9 @@ Lark approval 共 14 个任务;映射按用户结果而不是命令名判断 | instances cancel | `oa approval revoke` | routed | 高风险原子写;无可逆 fixture,不新增公开 Shortcut | | instances cc | `oa approval oa-cc-noticer` | routed | 无安全 fixture,保持原子入口 | | instances create | `oa approval forecast-process` + `create-instance` | routed | 无可清理实例 fixture,不新增公开 Shortcut | -| tasks query | 四类列表与 `oa approval tasks` | partial | 已办/已发起公开;待办/抄送/任务 unavailable | +| tasks query | 四类列表与 `oa approval tasks` | delivery hardened but downstream blocked | 已办/已发起公开;待办返回 `null` 集合,抄送返回错型状态与 `null` 集合 | | tasks add_sign | `oa approval append-task` | routed | 无安全 fixture,保持原子入口 | -| tasks approve | `oa approval approve` / `+approve-by` | partial | Shortcut unavailable;原子写需用户确认 | +| tasks approve | `oa approval approve` / `+approve-by` | routed / unavailable | 原子写需用户确认;复合 Shortcut unavailable | | tasks reject | `oa approval reject` | routed | 无安全 fixture,保持原子入口 | | tasks remind | `ding-info` 后串联 DING 发送 | downstream_required | 缺少一个可验证的稳定催办回执合同 | | tasks rollback | `revert-activities` + `revert-task` | routed | 无可恢复 fixture,保持原子入口 | @@ -63,7 +81,7 @@ DWS 现有原子能力还覆盖审批记录、表单 Schema、流程预测、评 ## `DS-oa-002`:可重复的审批读写 E2E fixture - 优先级:P1;类型:tenant-or-fixture;Owner:OA 测试租户/权限与测试数据平台。 -- 影响:待办、抄送、任务、同意/拒绝/撤回/转交/加签/退回/催办等用户任务不能完成安全 live proof。 +- 影响:待办、抄送、任务、同意/拒绝/撤回/转交/加签/退回/催办等用户任务不能完成安全 live proof。当前 raw 待办集合为 `null`;抄送同时存在错型业务状态与 `null` 集合,二者都不是合法空页。 - 所需 fixture: - 隔离测试身份与专用审批定义,可创建至少一条待办、一条抄送和一条已办实例。 - 创建回执返回稳定实例 ID;任务查询返回稳定 taskId 和明确任务状态。 diff --git a/internal/shortcut/semantic_catalog_oa.json b/internal/shortcut/semantic_catalog_oa.json index 4cdb5696..b853853c 100644 --- a/internal/shortcut/semantic_catalog_oa.json +++ b/internal/shortcut/semantic_catalog_oa.json @@ -54,14 +54,14 @@ }, "+list-pending": { "disposition": "semantic_adapter", - "semantic_delta": "严格待办投影与分页已实现,但当前安全账号只有显式空结果,缺少已知非空待审批 fixture。", + "semantic_delta": "严格待办投影与分页已实现;exact Shortcut 与同参数 atomic/raw 均隔离到 success=true 但 result.values=null,错型集合被拒绝,且当前没有安全非空待审批 fixture。", "risk": "read", "public": false, "reviewed": true }, "+pending": { "disposition": "alias_internal", - "semantic_delta": "历史首屏待办摘要缺少页码且当前没有已知非空安全 fixture;新调用应使用 +list-pending,当前保持不可用。", + "semantic_delta": "历史首屏待办入口路由到 +list-pending;同一真实响应的 result.values 为 null 而不是合法空数组,且当前没有已知非空安全 fixture,因此别名与主入口均不可用。", "risk": "read", "primary": "+list-pending", "public": false, @@ -69,7 +69,7 @@ }, "+list-cc": { "disposition": "semantic_adapter", - "semantic_delta": "严格抄送列表与审核过的空页终态已实现,但当前没有已知非空抄送审批 fixture,不能完成 exact Shortcut 非空证明。", + "semantic_delta": "严格抄送列表与分页校验已实现;exact Shortcut 和同参数 atomic/raw 隔离到错型业务状态与 null 集合,不能把响应解释为空页,且当前没有安全非空抄送 fixture。", "risk": "read", "public": false, "reviewed": true From 03001eb4e079584dbcd7542d7dc476f0ac5477fd Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 17:43:48 +0800 Subject: [PATCH 08/28] fix(shortcut): harden DING write routing evidence --- docs/shortcut-downstream-requirements/ding.md | 103 +++++++++--------- internal/helpers/ding.go | 22 +++- .../helpers/ding_personal_remind_type_test.go | 18 +++ internal/shortcut/ding/common.go | 4 +- internal/shortcut/ding/ding.go | 6 +- internal/shortcut/semantic_catalog_ding.json | 6 +- 6 files changed, 97 insertions(+), 62 deletions(-) create mode 100644 internal/helpers/ding_personal_remind_type_test.go diff --git a/docs/shortcut-downstream-requirements/ding.md b/docs/shortcut-downstream-requirements/ding.md index 0d35fa99..4c66a53c 100644 --- a/docs/shortcut-downstream-requirements/ding.md +++ b/docs/shortcut-downstream-requirements/ding.md @@ -5,74 +5,73 @@ > 对比基线:lark-cli 1.0.87 > 范围:Shortcut-only DING surface -## 1. 执行摘要 +## 1. 最终 surface - 源码 Shortcut:5;公开:1;unavailable:4。 -- 读取严格化:PASS。`+list` 与 `+receiver-status` 只接受显式布尔成功、准确结果路径、正确集合类型和合法元素。 -- 分页真实性:PASS(拒绝虚假成功)。`+list` 仅在 `hasMore` 与数值 `nextCursor` 一致且游标前进时发布续页信息;真实续页返回停滞游标,严格实现返回 `stalled_cursor`,因此该能力保持 unavailable。 -- 真实读取:PASS。`+list` 已执行已知非空页和保证零项页;`+receiver-status` 已对列表中稳定身份执行精确非空查询。 -- 写安全:PASS。3 个写 Shortcut 均需确认,确认前 0 次下游调用;因缺少可逆 fixture、精确接收人读回和零残留清理,确认后也由本地能力门禁停止。 -- 脱敏:PASS。本文仅保留聚合数量与 PASS 标签,不保存 ID、标题、人名、正文、原始响应或请求追踪信息。 +- 公开 `+receiver-status` fully unlocked;其余 4 条保持 fail-closed,不能把 DING 整体写效果称为完成。 +- 读取严格化:PASS。只接受显式布尔成功、准确结果路径、正确集合类型、合法元素和稳定 DING 身份。 +- 本地可修缺口:PASS。`app/sms/call` 已在原子 helper 映射为下游要求的 `APP/SMS/PHONE`,未知类型在调用前拒绝。 +- 写门禁:PASS。3 个写 Shortcut 均需用户确认,且当前 capability gate 不发起远端调用;隔离诊断只通过精确 atomic/raw 路径执行。 -## 2. 用户任务与 Lark 映射 +## 2. Residual-gap 分类 -| 用户任务 | DWS Shortcut | lark-cli 1.0.87 | 状态 | +| 能力 | 最终分类 | 归因 | Shortcut 状态 | |---|---|---|---| -| 查询 DING 历史并取得稳定身份 | `ding +list` | 无直接任务 | unavailable;真实续页游标停滞 | -| 查询 DING 接收状态 | `ding +receiver-status` | `im messages read_users` | PASS / public;平台身份模型不同 | -| 基于已有消息发送应用内加急 | `ding +send-by-message --type app` | `im messages urgent_app` | unavailable | -| 基于已有消息发送短信加急 | `ding +send-by-message --type sms` | `im messages urgent_sms` | unavailable | -| 基于已有消息发送电话加急 | `ding +send-by-message --type call` | `im messages urgent_phone` | unavailable | -| 以用户身份新建 DING | `ding +send-personal` | 无直接任务 | unavailable;DWS 产品原生机会 | -| 撤回用户身份 DING | `ding +recall-personal` | 无直接任务 | unavailable;DWS 产品原生机会 | +| `+receiver-status` | fully unlocked | exact 稳定 DING 身份读取返回 1 项且全部匹配 | public | +| `+list` | delivery hardened but downstream blocked | 真实 continuation 不前进并返回重复边界项;本地不能安全猜测跳游标或去重 | unavailable | +| `+send-personal --type app` | delivery hardened but downstream blocked | 稳定 DING 回执已证明;接收项没有稳定接收人身份,撤回无可查询终态 | unavailable | +| `+send-by-message --type app` | delivery hardened but downstream blocked | 源消息和 DING 稳定身份已证明;接收人与清理终态仍不可证明 | unavailable | +| `+send-personal/+send-by-message --type sms/call` | unavailable | 没有隔离号码、显式成本授权和通知残留核验 fixture,本轮未执行 | unavailable | +| `+recall-personal` | delivery hardened but downstream blocked | 撤回只返回布尔成功,不返回目标或终态;撤回后精确接收状态仍保留记录 | unavailable | +| Lark `urgent_app/sms/phone` 用户任务 | routed | 精确原子命令存在;仅在用户明确目标、提醒方式与成本后执行,不等于 Shortcut 已解锁 | 非公开 Shortcut 路线 | -## 3. 真实 E2E 聚合矩阵 +## 3. Lark 用户任务映射 -| Exact Shortcut | 已知非空 | 保证零项 | 身份/分页 | 写后读回 | 清理 | 结论 | -|---|---:|---:|---|---|---|---| -| `+list` | PASS(50) | PASS(0) | PASS(严格拒绝);续页游标停滞 | 不适用 | 不适用 | unavailable | -| `+receiver-status` | PASS(1) | 不适用:精确 ID 查询 | PASS:全部返回项身份匹配 | 不适用 | 不适用 | public | -| `+send-personal` | 不适用 | 不适用 | 不适用 | BLOCKED | BLOCKED | unavailable | -| `+send-by-message` | 不适用 | 不适用 | 不适用 | BLOCKED | BLOCKED | unavailable | -| `+recall-personal` | 不适用 | 不适用 | 不适用 | BLOCKED | BLOCKED | unavailable | +| 用户任务 | DWS 路线 | lark-cli 1.0.87 | 结论 | +|---|---|---|---| +| 查询 DING 历史 | `ding +list` | 无直接任务 | delivery hardened but downstream blocked | +| 查询 DING 接收状态 | `ding +receiver-status` | `im messages read_users` | fully unlocked;平台身份模型不同 | +| 基于消息发送应用内加急 | `ding message send-by-message --type app` | `im messages urgent_app` | routed;Shortcut unavailable | +| 基于消息发送短信加急 | `ding message send-by-message --type sms` | `im messages urgent_sms` | routed,需成本授权;Shortcut unavailable | +| 基于消息发送电话加急 | `ding message send-by-message --type call` | `im messages urgent_phone` | routed,需成本授权;Shortcut unavailable | +| 新建个人 DING | `ding message send-personal` | 无直接任务 | routed;Shortcut unavailable | +| 撤回个人 DING | `ding message recall-personal` | 无直接任务 | routed;Shortcut unavailable | -## 4. 下游需求 +## 4. Exact Shortcut 与隔离 raw 证据 -### DS-DING-001 — 稳定写回执与接收人身份 +| 能力 | Exact Shortcut | Atomic/raw 隔离事实 | 结论 | +|---|---|---|---| +| `+list` | 已知非空 50;保证零项 0;真实续页返回 `stalled_cursor` | continuation 页仍为 50 项、下一游标不前进、相邻稳定身份交集 1;尝试包含式边界后仍不前进 | downstream blocked | +| `+receiver-status` | 精确身份返回 1,全部 DING 身份匹配 | 集合为数组、业务成功为布尔值 | fully unlocked | +| `+send-personal` | capability gate,远端调用 0 | APP 自收件:稳定 DING 回执 1、接收状态 1、DING 身份匹配;稳定接收人身份字段 0 | downstream blocked | +| `+send-by-message` | capability gate,远端调用 0 | 自发源消息稳定任务/消息/会话身份均通过;APP DING 回执 1、接收状态 1;稳定接收人身份字段 0 | downstream blocked | +| `+recall-personal` | capability gate,远端调用 0 | 撤回业务成功为布尔值,但目标回执 0;撤回后精确接收状态仍为 1 | downstream blocked | + +隔离 APP fixture 撤回后,`SEND`、`DELETED`、`ALL` 首屏均未出现目标;但 `+list` 的 continuation 已被证明停滞,首屏缺席不能升级为全局零残留证明。源聊天消息也在执行撤回后仍可按稳定身份读取,因此不能把“记录仍可查询”擅自解释为已删除或未删除。 + +## 5. 下游需求 + +### `DS-DING-001` — 稳定接收人身份读回 - 优先级:P1;类型:contract insufficient。 -- 影响:`+send-personal`、`+send-by-message`。 -- 需要明确布尔业务成功、稳定 `openDingId`、逐接收人稳定身份与受理状态;集合必须始终存在且类型固定,不能用空响应或仅 HTTP 成功表示业务成功。 -- 写后读取必须能按同一 `openDingId` 返回稳定接收人身份,以精确比对请求集合,而不是仅返回显示名。 -- 幂等键重复提交必须返回同一资源或稳定冲突语义,并标明是否已开始执行与是否可安全重试。 -- 验收:确认前 0 调用;确认后写入一次;按稳定身份精确读回全部接收人;清理后再次读取证明无残留。 +- 已关闭部分:`PASS-DING-WRITE-RECEIPT`,两种 APP 发送路径均返回唯一稳定 `openDingId`;提醒类型枚举映射已在本地修正。 +- 剩余缺口:接收状态项只有 DING 身份、确认状态和显示名,没有可与请求 `openDingTalkId` 集合精确比对的稳定接收人身份。 +- 验收:返回逐接收人的稳定身份和 typed 受理状态;多接收人集合与请求集合精确一致;缺项、重复项、额外项和错型均机器可检测。 -### DS-DING-002 — 可验证撤回终态 +### `DS-DING-002` — 可查询撤回终态 - 优先级:P1;类型:missing capability。 -- 影响:`+recall-personal` 及两种发送能力的清理闭环。 -- 撤回应返回稳定目标身份与明确终态,或提供按该身份查询撤回状态的只读接口。 -- `success=true` 单一回执不足以证明接收端通知已撤回;需要可查询的 terminal 状态,并区分未开始、已完成、部分撤回与未知效果。 -- 验收:创建专用可回滚 fixture;精确撤回;按同一身份读回终态;重复撤回具有稳定幂等语义;无通知残留。 +- 当前事实:撤回响应不含目标身份或终态;精确接收状态在撤回后仍保留;列表首屏缺席不能越过坏分页证明全局终态。 +- 验收:按同一 `openDingId` 返回 typed `recalled` 终态或提供专用只读查询;重复撤回语义稳定;能够区分完成、处理中、部分撤回和 commit-unknown,并证明无残留通知。 -### DS-DING-003 — 游标边界语义 +### `DS-DING-003` — 游标边界与前进语义 - 优先级:P2;类型:contract insufficient。 -- 影响:`+list` 的跨页聚合调用方。 -- 当前真实续页返回的 `nextCursor` 未严格前进,且基线相邻页还观察到 1 个稳定身份边界重复项。下游需声明 `nextCursor` 是包含式还是排除式边界,并保证续页凭据可以推进。 -- 验收:连续两页均返回合法分页事实;游标严格前进;跨页无重复,或合同明确要求调用方按稳定身份去重;终页明确 `hasMore=false` 且无虚假续页凭据。 +- 当前事实:首屏 50 项;真实 continuation 再返回 50 项,下一游标不前进,跨页身份交集 1。对包含式边界加一仍返回不前进页面,排除安全的本地 `+1` 修补。 +- 验收:连续页凭据严格前进;跨页无重复,或合同明确包含式边界且可证明去重不遗漏;终页明确 `hasMore=false`。 -## 5. Fixture、权限与清理 +## 6. Fixture、安全与脱敏 -| 需求 | 安全资源模型 | 到期/回收 | 状态 | -|---|---|---|---| -| 应用内 DING 写 fixture | 专用测试接收人和无业务正文 | 用后立即撤回并读回终态 | BLOCKED | -| 短信/电话 DING fixture | 隔离租户、显式成本授权、专用号码 | 用后核对计费与通知残留 | BLOCKED | -| 消息转 DING fixture | 专用测试会话与可删除源消息 | 撤回 DING 后删除源消息 | BLOCKED | -| 接收状态读取 fixture | 由公开列表取得稳定 DING 身份 | 只读,无残留 | PASS | - -## 6. 安全与脱敏声明 - -- PASS:不含用户、组织、租户、profile、消息、会话或 DING 的真实 ID。 -- PASS:不含标题、正文、人名、邮箱、电话、精确业务时间、trace/request ID、token 或原始响应。 -- PASS:真实证据仅记录命令级标签和聚合计数;未将本地诊断响应提交到仓库。 +- PASS:APP 仅使用当前账号自收件、合成无业务正文与立即撤回;没有执行短信或电话提醒。 +- PASS:没有提交用户、组织、会话、消息、DING 的真实 ID,也没有标题、正文、人名、邮箱、电话、精确业务时间、trace/request ID、token 或原始响应。 +- PASS:文档只保留命令级标签与聚合计数;DING 写 Shortcut 仍为 unavailable,未把原子诊断回执当成公开能力完成。 diff --git a/internal/helpers/ding.go b/internal/helpers/ding.go index 79cda622..c3db7200 100644 --- a/internal/helpers/ding.go +++ b/internal/helpers/ding.go @@ -17,6 +17,16 @@ import ( // remindType: 服务端 API 1=应用内 2=短信 3=电话 var dingRemindTypeMap = map[string]int{"app": 1, "sms": 2, "call": 3} +var dingPersonalRemindTypeMap = map[string]string{"app": "APP", "sms": "SMS", "call": "PHONE"} + +func dingPersonalRemindType(value string) (string, error) { + remindType, ok := dingPersonalRemindTypeMap[strings.ToLower(strings.TrimSpace(value))] + if !ok { + return "", fmt.Errorf("--type must be one of app, sms, call") + } + return remindType, nil +} + func newDingCommand() *cobra.Command { // Product-level Agent routing Decl (migrated from selection/ding.json // products.ding). Catalog assembly stamps provenance contract_final. @@ -232,11 +242,15 @@ func newDingCommand() *cobra.Command { if err := validateRequiredFlags(cmd, "users", "content"); err != nil { return err } + remindType, err := dingPersonalRemindType(mustGetFlag(cmd, "type")) + if err != nil { + return err + } users := parseCSVValues(mustGetFlag(cmd, "users")) toolArgs := map[string]any{ "receiverOpenDingTalkIds": users, "content": mustGetFlag(cmd, "content"), - "remindType": mustGetFlag(cmd, "type"), + "remindType": remindType, } if v, _ := cmd.Flags().GetString("uuid"); v != "" { toolArgs["uuid"] = v @@ -262,12 +276,16 @@ func newDingCommand() *cobra.Command { if err := validateRequiredFlags(cmd, "group", "message-id", "users"); err != nil { return err } + remindType, err := dingPersonalRemindType(mustGetFlag(cmd, "type")) + if err != nil { + return err + } users := parseCSVValues(mustGetFlag(cmd, "users")) toolArgs := map[string]any{ "openConversationId": mustGetFlag(cmd, "group"), "openMessageId": mustGetFlag(cmd, "message-id"), "receiverOpenDingTalkIds": users, - "remindType": mustGetFlag(cmd, "type"), + "remindType": remindType, } if v, _ := cmd.Flags().GetString("uuid"); v != "" { toolArgs["uuid"] = v diff --git a/internal/helpers/ding_personal_remind_type_test.go b/internal/helpers/ding_personal_remind_type_test.go new file mode 100644 index 00000000..b94a323d --- /dev/null +++ b/internal/helpers/ding_personal_remind_type_test.go @@ -0,0 +1,18 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package helpers + +import "testing" + +func TestCrossPlatformCoverageDingPersonalRemindTypeMatchesMCPEnum(t *testing.T) { + for input, want := range map[string]string{"app": "APP", "sms": "SMS", "call": "PHONE", " APP ": "APP"} { + got, err := dingPersonalRemindType(input) + if err != nil || got != want { + t.Errorf("dingPersonalRemindType(%q)=(%q,%v), want %q", input, got, err, want) + } + } + if got, err := dingPersonalRemindType("push"); err == nil || got != "" { + t.Fatalf("unsupported remind type=(%q,%v)", got, err) + } +} diff --git a/internal/shortcut/ding/common.go b/internal/shortcut/ding/common.go index 719b15eb..bb45a46c 100644 --- a/internal/shortcut/ding/common.go +++ b/internal/shortcut/ding/common.go @@ -19,7 +19,7 @@ import ( const ( dingCompositeReason = "Reviewed DING Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable DING identity checks, truthful pagination, and unified output projection." - dingWriteUnavailableReason = "DING mutation is unavailable to Agents until a dedicated reversible fixture proves confirmation-before-call, a stable write receipt, exact receiver/readback identity, and cleanup without residual notifications." + dingWriteUnavailableReason = "DING mutation is unavailable to Agents until the downstream exposes stable receiver identities and a queryable recall terminal state; isolated self-fixtures already prove stable DING receipts but cannot prove those two facts." ) type dingPageEvidence struct { @@ -304,7 +304,7 @@ func outputDingPage(rt *shortcut.RuntimeContext, messages []map[string]any, page } func dingUnavailable(operation string) error { - return apperrors.NewDiscovery("该 DING 写 Shortcut 尚无可逆真实 fixture、精确接收人读回与零残留清理证据,当前不可执行", + return apperrors.NewDiscovery("该 DING 写 Shortcut 虽有稳定写回执,但下游没有稳定接收人身份和可查询撤回终态,当前不可执行", apperrors.WithOperation(operation), apperrors.WithOrigin("shortcut_registry"), apperrors.WithFailureStage("capability_gate"), diff --git a/internal/shortcut/ding/ding.go b/internal/shortcut/ding/ding.go index 1698cdde..196250cd 100644 --- a/internal/shortcut/ding/ding.go +++ b/internal/shortcut/ding/ding.go @@ -100,7 +100,7 @@ var ReceiverStatus = shortcut.Shortcut{ var SendPersonal = unavailableDingWrite( "+send-personal", "以本人身份发送 DING 给指定人", - "需要以当前用户身份向明确的 openDingTalkId 接收人发送 DING 时使用;下游提供精确回执、接收人读回和可验证清理前不可执行。", + "需要以当前用户身份向明确的 openDingTalkId 接收人发送 DING 时使用;下游提供稳定接收人身份与可查询撤回终态前不可执行。", shortcut.RiskWrite, false, []shortcut.Flag{ {Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true}, @@ -117,7 +117,7 @@ var SendPersonal = unavailableDingWrite( var SendByMessage = unavailableDingWrite( "+send-by-message", "针对某条消息发起 DING 提醒", - "需要把指定聊天消息转成应用内、短信或电话 DING 时使用;对应 lark-cli 的三种 urgent 任务,但安全 fixture 与精确清理闭环未具备。", + "需要把指定聊天消息转成应用内、短信或电话 DING 时使用;对应 lark-cli 的三种 urgent 任务,但接收人稳定身份与精确撤回终态仍缺失。", shortcut.RiskWrite, false, []shortcut.Flag{ {Name: "group", Type: shortcut.FlagString, Desc: "openConversationId", Required: true}, @@ -136,7 +136,7 @@ var SendByMessage = unavailableDingWrite( var RecallPersonal = unavailableDingWrite( "+recall-personal", "撤回本人发起的 DING", - "需要撤回本人发出的 DING 时使用;必须先有稳定写回执,并能精确证明目标已撤回且没有残留通知,当前尚不满足。", + "需要撤回本人发出的 DING 时使用;必须能按稳定目标精确读回撤回终态且证明没有残留通知,当前下游尚不满足。", shortcut.RiskHighWrite, true, []shortcut.Flag{{Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}}, []contract.ParamDecl{{Name: "id", Property: "openDingId"}}, diff --git a/internal/shortcut/semantic_catalog_ding.json b/internal/shortcut/semantic_catalog_ding.json index 46f34233..cfcea69d 100644 --- a/internal/shortcut/semantic_catalog_ding.json +++ b/internal/shortcut/semantic_catalog_ding.json @@ -20,21 +20,21 @@ }, "+send-personal": { "disposition": "semantic_adapter", - "semantic_delta": "用户身份发送 DING 的原子能力存在,但下游没有同时提供稳定接收人回执、精确读回和可验证零残留清理;确认后也在本地 capability gate 停止。", + "semantic_delta": "已修正 app/sms/call 到 APP/SMS/PHONE 的下游枚举映射;隔离自收件 APP fixture 返回稳定 openDingId 并可按该 ID 读回,但接收项只有显示名而没有稳定接收人身份,且撤回无可查询终态,保持不可用。", "risk": "write", "public": false, "reviewed": true }, "+send-by-message": { "disposition": "semantic_adapter", - "semantic_delta": "覆盖基于消息的应用内、短信和电话 DING,映射 lark-cli 三种 urgent 任务;缺少可逆 fixture、稳定回执和精确清理证明,保持不可用。", + "semantic_delta": "覆盖基于消息的应用内、短信和电话 DING并映射 lark-cli 三种 urgent 任务;隔离 APP fixture 已证明源消息、稳定 DING 回执和目标 DING 读回,但接收人身份与撤回终态仍不可验证,保持不可用。", "risk": "write", "public": false, "reviewed": true }, "+recall-personal": { "disposition": "semantic_adapter", - "semantic_delta": "个人 DING 撤回缺少能证明目标已撤回且通知无残留的精确终态读取;不接受单一 success 回执作为成功,保持不可用。", + "semantic_delta": "个人 DING 撤回返回布尔成功但不回显目标或终态;撤回后精确接收状态仍保留原记录,而消息列表分页已知不可靠,不能把首屏缺席解释为全局终态,保持不可用。", "risk": "high-risk-write", "public": false, "reviewed": true From 7bc56f3dea69ea422ef43ed917ebcea4a3879fc8 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 17:44:05 +0800 Subject: [PATCH 09/28] feat(shortcut): unlock Report outbox workflows --- .../report.md | 113 +++++++----------- .../builtin/report_semantic_catalog_test.go | 4 +- internal/shortcut/report/latest.go | 46 ++++++- internal/shortcut/report/report.go | 4 +- ...ort_strict_cross_platform_coverage_test.go | 15 ++- .../shortcut/semantic_catalog_report.json | 10 +- 6 files changed, 110 insertions(+), 82 deletions(-) diff --git a/docs/shortcut-downstream-requirements/report.md b/docs/shortcut-downstream-requirements/report.md index 8456eb4b..51a6cc80 100644 --- a/docs/shortcut-downstream-requirements/report.md +++ b/docs/shortcut-downstream-requirements/report.md @@ -5,89 +5,68 @@ > 对比基线:lark-cli 1.0.87(无 Report 产品域) > 范围:仅 Report Shortcut;证据只记录 PASS 标签与聚合事实 -## 1. 执行摘要 +## 1. 最终 surface -- 源码 Shortcut 共 4 个;公开 2 个,unavailable 2 个。 -- `+template-search` 已完成严格模板集合、已知非空和保证零命中证明,可公开。 -- `+inbox-list` 已完成已知非空、保证零项、多页 cursor 严格前进、相邻页稳定身份无重复和明确耗尽证明,可公开。 -- `+outbox-list` 在多个有界窗口只得到合法零项结果;缺少已知非空证明,因此保持 unavailable。 -- `+report-latest` 依赖完整非空发件箱和精确详情读回;当前没有安全 fixture,因此保持 unavailable,且不再回退原始列表行。 -- Report 原子层具有模板、收件箱、发件箱、详情、统计和创建能力;创建缺少可验证删除/回收能力,本轮未执行写操作。 +- 源码 Shortcut:4;公开:4;unavailable:0。 +- `+template-search`、`+inbox-list`、`+outbox-list`、`+report-latest` 均声明严格 Result/Safety/Contract,使用 unified output。 +- 30 个连续 19 天窗口覆盖 570 天发件箱历史:总项数 1、非空窗口 1、合同异常 0。该安全只读 fixture 关闭了原先“没有非空发件箱”的误判。 +- Report 创建原子能力仍缺少删除/回收能力,本轮没有执行 Report 写入;这不阻塞四条只读 Shortcut,但继续阻止新增写 Shortcut。 -| ID | 优先级 | 类型 | 用户任务 | 当前处置 | 解锁的 Shortcut | -|---|---|---|---|---|---| -| `DS-Report-001` | P1 | tenant-or-fixture / missing capability | 读取自己发出的日志及最新详情 | hidden + unavailable | `+outbox-list`、`+report-latest` | +## 2. Residual-gap 分类 -## 2. 用户任务与证据矩阵 +| 能力 | 最终分类 | PASS 证据 | 公开状态 | +|---|---|---|---| +| `+template-search` | fully unlocked | `PASS-RPT-TEMPLATE-NONEMPTY`:1;`PASS-RPT-TEMPLATE-ZERO`:0;原子完整集合 73 | public | +| `+inbox-list` | fully unlocked | `PASS-RPT-INBOX-PAGES`:20、20、20、20、12 后终止;`PASS-RPT-INBOX-ZERO`:0 | public | +| `+outbox-list` | fully unlocked | `PASS-RPT-OUTBOX-HISTORY`:30 个连续窗口、总项 1、非空窗口 1、合同异常 0;`PASS-RPT-OUTBOX-ZERO`:0 | public | +| `+report-latest` | fully unlocked | `PASS-RPT-LATEST-READBACK`:候选 1,详情身份精确一致,严格字段 3 | public | +| 模板读取、详情、统计 | routed | 使用现有精确原子读取;不重复包装 Shortcut | 非 Shortcut | +| 日志提交 | routed | 现有原子写可处理用户明确目标;无清理能力,不作为自动 E2E fixture | 非 Shortcut | +| Report Shortcut residual unavailable | unavailable | `PASS-RPT-SURFACE-CLOSURE`:源码 4 条中为 0 | 无 | -| 用户任务 | Shortcut | Lark CLI 对应 | Exact Shortcut 证据 | Atomic/raw 对照 | 结论 | -|---|---|---|---|---|---| -| 按名称搜索可用日志模板 | `+template-search` | 无 | `PASS-RPT-TEMPLATE-NONEMPTY`:1 项;`PASS-RPT-TEMPLATE-ZERO`:0 项 | `PASS-RPT-TEMPLATE-COLLECTION`:完整集合聚合数量 73 | available / public | -| 分页查看收到的日志 | `+inbox-list` | 无 | `PASS-RPT-INBOX-PAGES`:页大小依次为 20、20、20、20、12,随后明确耗尽;`PASS-RPT-INBOX-ZERO`:0 项 | `PASS-RPT-INBOX-CURSOR`:非终止 cursor 严格递增;首对相邻页稳定身份交集为 0 | available / public | -| 分页查看自己发出的日志 | `+outbox-list` | 无 | `PASS-RPT-OUTBOX-ZERO`:多个有界窗口均为 0 项 | `PASS-RPT-OUTBOX-TERMINAL`:零项页具有明确成功与终止证据 | unavailable | -| 读取自己最新提交的日志详情 | `+report-latest` | 无 | `PASS-RPT-LATEST-ZERO`:发件箱为空时在一次列表调用后停止 | `PASS-RPT-DETAIL-SHAPE`:独立详情能力可返回稳定身份与严格字段数组,但没有安全发件箱目标可串联 | unavailable | +Report 的 Shortcut surface 没有 “delivery hardened but downstream blocked” 项。未来若增加写 Shortcut,仍需先解决下文的写 fixture 生命周期,不得把四条读取已解锁解释为 Report 写能力也已完成。 -## 3. 已验证分页合同 +## 3. Exact Shortcut E2E 矩阵 -- `PASS-RPT-INBOX-CURSOR`:所有非终止页均返回正整数、相对请求严格前进的 continuation。 -- `PASS-RPT-INBOX-PAGES`:exact Shortcut 沿服务端返回 cursor 读取 5 个非空页,聚合数量 92,末页明确 `hasMore=false`。 -- `PASS-RPT-INBOX-OVERLAP`:首对相邻非空页的稳定身份交集为 0。 -- `PASS-RPT-INBOX-ZERO`:独立有界查询返回合法 0 项、`complete=true` 与 endpoint exhausted。 -- 服务端在终止页仍回显整数 cursor;DWS 只把它当页面收据,不发布 `next_token`。错型或冲突 cursor 仍失败。 -- DWS 严格拒绝空响应、缺/错型 `success`、缺/错型集合、坏元素、重复身份、缺 continuation、空页续页和不前进 cursor。 +| Exact Shortcut | 已知非空 | 保证零项/零命中 | 身份与分页 | 结论 | +|---|---:|---:|---|---| +| `+template-search` | PASS(1) | PASS(0) | 完整模板集合验证后本地筛选;稳定模板身份 | fully unlocked | +| `+inbox-list` | PASS(累计 92) | PASS(0) | 5 个非空页;cursor 严格前进;首对相邻页身份交集 0;终页明确耗尽 | fully unlocked | +| `+outbox-list` | PASS(1) | PASS(0) | 非空页 `complete=true` 且 endpoint exhausted;570 天有界扫描合同异常 0 | fully unlocked | +| `+report-latest` | PASS(候选 1) | 不适用:精确组合读取 | 显式不超过 20 天窗口;按唯一最高创建时间选取;详情身份精确匹配;字段集合 3 | fully unlocked | -## 4. 下游需求明细 +所有列表/搜索严格拒绝空响应、缺或错型 `success`、缺或错型集合、坏元素、重复身份、缺 continuation、空页续页和不前进 cursor。服务端在终页回显的整数 cursor 只作为页面收据,不发布为 `next_token`。 -### `DS-Report-001` — 提供可回收的发件箱 E2E fixture +## 4. 已关闭需求 -#### 用户结果 +### `DS-Report-001` — 非空发件箱与最新详情链路 -用户能够可靠列出自己发出的日志,并在完整候选集合中选出最新一篇,再按稳定 `reportId` 读取身份一致的详情。 +- 状态:`PASS-RPT-RESIDUAL-CLOSED`。 +- 归因修正:原先只检查近期窗口,错误地把账号判定为没有安全非空 fixture。扩大为连续 570 天有界 exact 扫描后发现 1 个非空窗口。 +- 验收事实:exact `+outbox-list` 的非空、零项、稳定身份和终止证据均通过;exact `+report-latest` 新增成对 `--start/--end` 参数后,在同一窗口完成一次完整列表与一次精确详情读取,身份一致。 +- 确定性:若最高 `createTime` 并列,命令拒绝任意选取;显式窗口缺一端、倒序或超过 20 天均在远端调用前失败。 -#### 当前证据与归因 +## 5. 未解决但不阻塞当前 Shortcut 的下游缺口 -- `PASS-RPT-OUTBOX-ZERO`:多个独立有界窗口均为合法 0 项;这只能证明零结果合同,不能证明非空项目投影。 -- `PASS-RPT-LATEST-ZERO`:组合命令遇到空发件箱后停止,没有调用详情,也没有把空结果伪装成成功详情。 -- `PASS-RPT-DETAIL-SHAPE`:在独立安全读取中,详情结果具备稳定身份、时间字段与非空字段集合;统计读取也具备明确成功对象。 -- 原子层可以创建日志,但没有对应删除/回收能力。仅凭创建成功回执不能满足写后精确读回、清理和零残留要求,因此本轮没有创建测试数据。 +### `DS-Report-002` — Report 写 fixture 生命周期 -#### 所需 fixture / 能力 +- 分类:routed;影响未来日志提交类 Shortcut,不影响当前四条只读 Shortcut。 +- 当前事实:原子层可提交日志,但没有按稳定回执删除、回收或证明自动过期的能力;因此本轮写调用数为 0。 +- 解锁条件:专用无业务内容 fixture,确认前 0 写调用,确认后稳定创建回执、精确读回、无条件清理,以及发件箱和详情的零残留证明。 -- 首选:租户提供专用、无业务内容、定期自动过期的已发送日志 fixture,并保证测试身份可读取。 -- 或者:新增精确删除/回收能力,使测试可按创建回执中的稳定身份读回并在 `defer` 清理,最后证明发件箱与详情均无残留。 -- 非空发件箱响应必须包含布尔 `success=true`、精确集合、每项稳定 `reportId`、正整数 `createTime` 和真实分页终止证据。 -- 详情响应必须返回与请求完全一致的稳定身份;字段集合必须为数组,坏元素不能被跳过。 -- 若创建执行结果不确定,必须返回 commit-unknown 分类与安全的核查动作,不能建议盲目重试。 +## 6. Lark 对齐 -#### 验收标准 +lark-cli 1.0.87 没有 Report/日志产品域,四条公开 Shortcut 都是 DWS 原生增量能力,不虚构一对一映射: -1. exact `+outbox-list` 同时证明已知非空与保证零项。 -2. 非空项目均有稳定身份与排序时间,分页遍历至终止且无重复。 -3. exact `+report-latest` 只调用一次完整列表和一次精确详情,详情身份与所选目标一致。 -4. 若用创建产生 fixture:确认前 0 次写调用;确认后稳定回执;按回执精确读回;无条件清理;最终证明零残留。 -5. 没有可验证清理能力时,不执行创建,并继续保持相关 Shortcut unavailable。 - -## 5. Lark 对齐与平台差异 - -lark-cli 1.0.87 没有 Report/日志产品域,因此 4 个 Report Shortcut 均无一对一 Lark 任务。它们是 DWS 的平台原生增量能力,不应虚构映射: - -| DWS 用户任务 | Lark 映射 | 推荐结论 | +| DWS 用户任务 | Lark 映射 | 结论 | |---|---|---| -| 搜索日志模板 | 无 | DWS 原生能力;公开 `+template-search` | -| 查看收到的日志 | 无 | DWS 原生能力;公开 `+inbox-list` | -| 查看发出的日志或最新详情 | 无 | DWS 原生能力;非空链路验证前保持 unavailable | +| 搜索日志模板 | 无 | DWS 原生;fully unlocked | +| 查看收到的日志 | 无 | DWS 原生;fully unlocked | +| 查看发出的日志 | 无 | DWS 原生;fully unlocked | +| 读取指定窗口内最新日志详情 | 无 | DWS 原生;fully unlocked | -## 6. 无需下游变更的上游修复 +## 7. 安全与脱敏 -| Shortcut | 已完成修复 | 回归证据 | -|---|---|---| -| 全部 4 个 | 统一 Result/Safety/Contract、unified output;空响应、缺/错型 success、缺集合、坏元素与重复身份全部失败 | `PASS-RPT-STRICT-MATRIX` | -| `+inbox-list`、`+outbox-list` | 发布真实 cursor pagination;缺失、冲突、错型和不前进 cursor 全部失败;终止页不发布回显 cursor | `PASS-RPT-PAGINATION-MATRIX` | -| `+template-search` | 严格验证完整模板集合后本地筛选;不从坏元素或未知形状制造空结果 | `PASS-RPT-TEMPLATE-MATRIX` | -| `+report-latest` | 删除原始列表行回退;仅接受完整候选集合、可排序时间和身份匹配详情 | `PASS-RPT-LATEST-READBACK` | - -## 7. 安全与脱敏声明 - -- 本文不包含真实 ID、标题、人名、邮箱、正文、原始响应、trace/request ID、token 或签名 URL。 -- 所有真实证据只以 `PASS-RPT-*` 标签和聚合数量表达;不得用这些标签反查或推断具体资源。 -- 写能力因缺少可验证清理合同未执行;不存在为了测试而遗留的 Report 数据。 +- PASS:本文不含真实 ID、标题、人名、邮箱、正文、精确业务时间、原始响应、trace/request ID、token 或签名 URL。 +- PASS:真实证据只保留命令级标签和聚合计数,无法反推出具体资源。 +- PASS:没有为测试创建 Report 数据,因此没有新增业务残留。 diff --git a/internal/shortcut/builtin/report_semantic_catalog_test.go b/internal/shortcut/builtin/report_semantic_catalog_test.go index 06ece7a0..7e2e66de 100644 --- a/internal/shortcut/builtin/report_semantic_catalog_test.go +++ b/internal/shortcut/builtin/report_semantic_catalog_test.go @@ -77,7 +77,7 @@ func TestCrossPlatformCoverageReportSemanticCatalogExactlyCoversRegisteredSurfac if len(missing) > 0 || len(stale) > 0 { t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) } - if public != 2 || unavailable != 2 { - t.Fatalf("public/unavailable=%d/%d, want 2/2", public, unavailable) + if public != 4 || unavailable != 0 { + t.Fatalf("public/unavailable=%d/%d, want 4/0", public, unavailable) } } diff --git a/internal/shortcut/report/latest.go b/internal/shortcut/report/latest.go index 86d6b644..0d49db9d 100644 --- a/internal/shortcut/report/latest.go +++ b/internal/shortcut/report/latest.go @@ -16,24 +16,51 @@ import ( var ReportLatest = shortcut.Shortcut{ Service: "report", Command: "+report-latest", Product: "report", Description: "读取我最近提交的一篇日志详情", - Intent: "只想查看自己最近 20 天提交的最新日志详情时使用;必须先证明发件箱结果完整、每项都有稳定 reportId 与创建时间,再按精确 ID 读回详情。", + Intent: "只想查看明确 20 天内自己提交的最新日志详情时使用;默认最近 20 天,也可成对指定创建时间窗,完整验证候选后按精确 reportId 读回详情。", Risk: shortcut.RiskRead, Safety: reportReadSafety(), OutputRollout: output.RolloutUnifiedActive, Contract: reportContract( "+report-latest", "读取我最近提交的一篇日志详情", - "只想查看自己最近 20 天提交的最新日志详情时使用;必须先证明发件箱结果完整、每项都有稳定 reportId 与创建时间,再按精确 ID 读回详情。", + "只想查看明确 20 天内自己提交的最新日志详情时使用;默认最近 20 天,也可成对指定创建时间窗,完整验证候选后按精确 reportId 读回详情。", reportLatestResult(), nil, - []contract.ParamDecl{{Name: "keyword", Property: "report_template_name"}}, - "dws report +report-latest", "dws report +report-latest --keyword 周报", + []contract.ParamDecl{ + {Name: "keyword", Property: "report_template_name"}, + {Name: "start", Property: "startTime"}, {Name: "end", Property: "endTime"}, + }, + "dws report +report-latest", `dws report +report-latest --start "2026-03-01T00:00:00+08:00" --end "2026-03-20T00:00:00+08:00"`, ), - Flags: []shortcut.Flag{{Name: "keyword", Type: shortcut.FlagString, Desc: "按日志模板名称精确过滤"}}, - Tips: []string{"dws report +report-latest", "dws report +report-latest --keyword 周报"}, + Flags: []shortcut.Flag{ + {Name: "keyword", Type: shortcut.FlagString, Desc: "按日志模板名称精确过滤"}, + {Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601;必须与 --end 同时提供,跨度不得超过 20 天"}, + {Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601;必须与 --start 同时提供,跨度不得超过 20 天"}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "--start 与 --end 必须同时提供,且创建时间范围必须有效并不得超过 20 天"}, + }, + Tips: []string{"dws report +report-latest", `dws report +report-latest --start "2026-03-01T00:00:00+08:00" --end "2026-03-20T00:00:00+08:00"`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Changed("start") != rt.Changed("end") { + return apperrors.NewValidation("--start 与 --end 必须同时提供", apperrors.WithReason("incomplete_creation_range")) + } + if rt.Changed("start") { + _, _, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumOutboxDays) + return err + } + return nil + }, Execute: func(rt *shortcut.RuntimeContext) error { const listOperation = "report/get_send_report_list" now := time.Now() end := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()) start := end.Add(-reportMaximumOutboxDays * 24 * time.Hour) + if rt.Changed("start") { + startMillis, endMillis, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumOutboxDays) + if err != nil { + return err + } + start, end = time.UnixMilli(startMillis), time.UnixMilli(endMillis) + } params := map[string]any{ "cursor": 0, "size": 20, "startTime": start.UnixMilli(), "endTime": end.UnixMilli(), @@ -75,6 +102,7 @@ var ReportLatest = shortcut.Shortcut{ func reportLatestEntryID(entries []map[string]any, operation string) (string, error) { var latestID string var latestTime int64 + latestCount := 0 for index, entry := range entries { created, ok := entry["createTime"].(int64) if !ok || created <= 0 { @@ -86,8 +114,14 @@ func reportLatestEntryID(entries []map[string]any, operation string) (string, er } if index == 0 || created > latestTime { latestID, latestTime = id, created + latestCount = 1 + } else if created == latestTime { + latestCount++ } } + if latestCount != 1 { + return "", reportResponseError(operation, "ambiguous_latest_order", "多篇日志具有相同的最高 createTime,不能确定唯一最新日志") + } return latestID, nil } diff --git a/internal/shortcut/report/report.go b/internal/shortcut/report/report.go index d527597c..7413db26 100644 --- a/internal/shortcut/report/report.go +++ b/internal/shortcut/report/report.go @@ -143,13 +143,13 @@ var InboxList = shortcut.Shortcut{ var OutboxList = shortcut.Shortcut{ Service: "report", Command: "+outbox-list", Product: "report", Description: "列出我发出的日志", - Intent: "需要按创建或修改时间回顾自己提交的日志并取得稳定 reportId 时使用;当前缺少可证明非空的真实发件箱 fixture。", + Intent: "需要按创建或修改时间回顾自己提交的日志并取得稳定 reportId 时使用;每次创建或修改时间窗不得超过 20 天。", Risk: shortcut.RiskRead, Safety: reportReadSafety(), OutputRollout: output.RolloutUnifiedActive, Contract: reportContract( "+outbox-list", "列出我发出的日志", - "需要按创建或修改时间回顾自己提交的日志并取得稳定 reportId 时使用;当前缺少可证明非空的真实发件箱 fixture。", + "需要按创建或修改时间回顾自己提交的日志并取得稳定 reportId 时使用;每次创建或修改时间窗不得超过 20 天。", reportCollectionResult("reports", "严格验证的发件箱日志页"), reportPagination(), []contract.ParamDecl{ {Name: "cursor", Property: "cursor"}, {Name: "size", Property: "size"}, diff --git a/internal/shortcut/report/report_strict_cross_platform_coverage_test.go b/internal/shortcut/report/report_strict_cross_platform_coverage_test.go index 1c4a09be..ad5459ce 100644 --- a/internal/shortcut/report/report_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/report/report_strict_cross_platform_coverage_test.go @@ -264,17 +264,28 @@ func TestCrossPlatformCoverageReportLatestRequiresCompleteOrderedListAndExactRea t.Fatalf("incomplete err=%v history=%v", err, incomplete.history) } + tied := &reportCoverageCaller{responses: map[string][]string{ + "get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":2},{"reportId":"report-2","createTime":2}]},"hasMore":false}`}, + }} + if _, err := runReportCoverage(t, ReportLatest, tied); err == nil || len(tied.history) != 1 { + t.Fatalf("tied latest err=%v history=%v", err, tied.history) + } + caller := &reportCoverageCaller{responses: map[string][]string{ "get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1},{"reportId":"report-2","createTime":2}]},"hasMore":false}`}, "get_report_entry_details": {`{"success":true,"result":{"report_Id":"report-2","report_name":"fixture","createTime":2,"report_content":[{"key":"field","value":"value","sort":1,"type":2}]}}`}, }} - cmd, err := runReportCoverage(t, ReportLatest, caller) + cmd, err := runReportCoverage(t, ReportLatest, caller, + "--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-20T00:00:00+08:00") if err != nil { t.Fatal(err) } if len(caller.history) != 2 || caller.history[0].tool != "get_send_report_list" || caller.history[1].tool != "get_report_entry_details" || caller.history[1].args["report_id"] != "report-2" { t.Fatalf("exact call history=%#v", caller.history) } + if caller.history[0].args["startTime"] != int64(1782835200000) || caller.history[0].args["endTime"] != int64(1784476800000) { + t.Fatalf("explicit range args=%#v", caller.history[0].args) + } var stdout bytes.Buffer cmd.SetOut(&stdout) if _, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted { @@ -299,6 +310,8 @@ func TestCrossPlatformCoverageReportValidationRejectsInvalidRangesBeforeMCP(t *t {InboxList, []string{"--start", "2026-07-02T00:00:00+08:00", "--end", "2026-07-01T00:00:00+08:00"}}, {InboxList, []string{"--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-02T00:00:00+08:00", "--size", "21"}}, {OutboxList, []string{"--modified-start", "2026-07-01T00:00:00+08:00"}}, + {ReportLatest, []string{"--start", "2026-07-01T00:00:00+08:00"}}, + {ReportLatest, []string{"--start", "2026-07-22T00:00:00+08:00", "--end", "2026-07-01T00:00:00+08:00"}}, } for _, test := range tests { caller := &reportCoverageCaller{responses: map[string][]string{}} diff --git a/internal/shortcut/semantic_catalog_report.json b/internal/shortcut/semantic_catalog_report.json index b1f6c45f..f80fd538 100644 --- a/internal/shortcut/semantic_catalog_report.json +++ b/internal/shortcut/semantic_catalog_report.json @@ -13,9 +13,10 @@ }, "+outbox-list": { "disposition": "semantic_adapter", - "semantic_delta": "严格验证发件箱集合、项目身份和分页终止证据;exact Shortcut 只证明多个时间窗口的零项结果,缺少已知非空 fixture,不能发布。", + "semantic_delta": "严格验证发件箱集合、稳定 reportId 和分页终止证据;exact Shortcut 已在连续有界历史窗口中同时证明已知非空与保证零项。", "risk": "read", - "public": false, + "availability": "available", + "public": true, "reviewed": true }, "+template-search": { @@ -28,9 +29,10 @@ }, "+report-latest": { "disposition": "primary_smart", - "semantic_delta": "完整验证最近 20 天发件箱后按每项 createTime 选择最新稳定 reportId,再精确读回身份匹配的详情;当前账号没有非空发件箱 fixture,无法证明完整两步链路,保持不可用。", + "semantic_delta": "完整验证默认最近 20 天或显式不超过 20 天的发件箱;要求唯一最高 createTime,按稳定 reportId 精确读回身份匹配且字段集合严格的详情。", "risk": "read", - "public": false, + "availability": "available", + "public": true, "reviewed": true } } From f6ad2fa01aa73bb5f519c9be53dcfe61e5ce7aaa Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 18:06:21 +0800 Subject: [PATCH 10/28] fix(shortcut): publish Report range constraints --- internal/shortcut/report/latest.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/shortcut/report/latest.go b/internal/shortcut/report/latest.go index 0d49db9d..1d633c5b 100644 --- a/internal/shortcut/report/latest.go +++ b/internal/shortcut/report/latest.go @@ -32,8 +32,8 @@ var ReportLatest = shortcut.Shortcut{ ), Flags: []shortcut.Flag{ {Name: "keyword", Type: shortcut.FlagString, Desc: "按日志模板名称精确过滤"}, - {Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601;必须与 --end 同时提供,跨度不得超过 20 天"}, - {Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601;必须与 --start 同时提供,跨度不得超过 20 天"}, + {Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601;--start 与 --end 必须同时提供,且创建时间范围必须有效并不得超过 20 天"}, + {Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601;--start 与 --end 必须同时提供,且创建时间范围必须有效并不得超过 20 天"}, }, Constraints: []shortcut.Constraint{ {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "--start 与 --end 必须同时提供,且创建时间范围必须有效并不得超过 20 天"}, From 2346dfba4e42660f832c5893b18e1cc5bad8b3f9 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 18:48:54 +0800 Subject: [PATCH 11/28] fix(shortcut): require OA zero-page pagination evidence --- docs/shortcut-downstream-requirements/oa.md | 37 ++++++++++++++----- .../builtin/oa_semantic_catalog_test.go | 4 +- internal/shortcut/oa/common.go | 11 +----- internal/shortcut/oa/oa.go | 2 +- .../oa_strict_cross_platform_coverage_test.go | 13 +++---- internal/shortcut/oa/smart.go | 6 +-- internal/shortcut/semantic_catalog_oa.json | 15 +++----- 7 files changed, 46 insertions(+), 42 deletions(-) diff --git a/docs/shortcut-downstream-requirements/oa.md b/docs/shortcut-downstream-requirements/oa.md index aef56745..4fb493ef 100644 --- a/docs/shortcut-downstream-requirements/oa.md +++ b/docs/shortcut-downstream-requirements/oa.md @@ -7,15 +7,17 @@ ## 结论与 surface - 源码 Shortcut:10。 -- 公开:4(`+list-executed`、`+list-submitted`、`+my-initiated`、`+search-forms`)。 -- 隐藏:6,其中 unavailable 5;`+done-approvals` 是 available 但隐藏的兼容入口。 +- 公开:1(`+search-forms`)。 +- 隐藏:9,其中 unavailable 8;`+done-approvals` 是 available 但隐藏的兼容入口。 - 所有 10 条均声明 `Contract`、`Safety`、`Result` 并启用 unified output;写入口在首次远端调用前由 Runtime 确认门禁阻断。 ### Residual-gap 分类 | Shortcut / 用户路线 | 最终分类 | 依据 | |---|---|---| -| `+list-executed`、`+list-submitted`、`+my-initiated`、`+search-forms` | fully unlocked | exact 非空与保证零命中均通过;public | +| `+search-forms` | fully unlocked | current HEAD exact/raw:已知非空 1、保证零命中 0,稳定身份集合一致;public | +| `+list-executed` | delivery hardened but downstream blocked | known-nonempty exact/raw 7 个稳定身份一致;guaranteed-zero raw 缺 `hasMore`,严格实现拒绝 | +| `+list-submitted`、`+my-initiated` | delivery hardened but downstream blocked | known-nonempty exact/raw 14 个稳定身份一致;guaranteed-zero raw 缺 `hasMore`,严格实现拒绝 | | `+done-approvals` | routed | available 的隐藏兼容别名;新调用路由到 `+list-executed` | | `+list-forms` | delivery hardened but downstream blocked | exact 与 raw 均缺 continuation,改变 cursor 后集合高度重叠 | | `+list-pending` | delivery hardened but downstream blocked | exact 与 raw 均得到 `success=true`,但 `result.values` 为 `null` 而不是合法空数组 | @@ -24,14 +26,22 @@ | `+approve-by` | unavailable | 复合编排与确认门禁已实现,但没有隔离待办、终态读回和可恢复清理 fixture | | 已知实例/任务的详情与写操作 | routed | 使用精确 OA 原子命令;必须由用户提供真实目标并单独确认,不等于 Shortcut 已解锁 | -OA 不能作为整体宣称 fully unlocked:源码 10 条中仅 4 条 fully unlocked,1 条兼容路由,3 条下游阻塞,2 条 unavailable。 +OA 不能作为整体宣称 fully unlocked:源码 10 条中仅 1 条 fully unlocked,1 条兼容路由,6 条 delivery hardened but downstream blocked,2 条业务能力 unavailable;Catalog unavailable 共 8 条。 + +### Current HEAD 公开 Shortcut 双层发布矩阵 + +| EVERY public Shortcut | Exact Shortcut | Owning atomic/raw | known-nonempty / guaranteed-zero | current HEAD 结论 | +|---|---|---|---|---| +| `oa +search-forms` | `dws oa +search-forms --query --format json` | `dws oa approval search-forms --query --format json` | 已知查询 1/1,稳定 `processCode` 集合相等;随机 UUID 查询 0/0 | `PASS-OA-SEARCH-DOUBLE`;fully unlocked | + +该搜索接口一次返回完整匹配集合,不声明 cursor;双层证据验证的是同场景完整集合、稳定身份和独立随机零命中,而不是把已知集合后的空页当零命中。实现与严格响应测试位于 `internal/shortcut/oa/oa.go`、`internal/shortcut/oa/common.go` 和 `internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go`。 | Exact Shortcut 证明 | 标签 | 聚合事实 | |---|---|---| -| `+list-executed` | PASS | 已知非空 7;保证零命中 0;分页终态明确 | -| `+list-submitted` | PASS | 已知非空 14;保证零命中 0;分页终态明确 | -| `+my-initiated` | PASS | 已知非空 14;保证零命中 0;不再回退原始响应 | -| `+search-forms` | PASS | 已知非空 2;保证零命中 0;显式完整结果 | +| `+list-executed` fail-closed | PASS | current HEAD known-nonempty exact/raw 为 7 且稳定身份集合一致;guaranteed-zero raw 缺 `hasMore`,exact 返回 `missing_pagination` | +| `+list-submitted` fail-closed | PASS | current HEAD known-nonempty exact/raw 为 14 且稳定身份集合一致;guaranteed-zero raw 缺 `hasMore`,exact 返回 `missing_pagination` | +| `+my-initiated` fail-closed | PASS | current HEAD known-nonempty exact/raw 为 14 且稳定身份集合一致;不回退 raw;guaranteed-zero 缺分页时失败 | +| `+search-forms` | PASS | current HEAD 已知非空 1、保证零命中 0;exact/raw 稳定身份集合一致 | | `+done-approvals`(隐藏兼容) | PASS | 已知非空 7;首屏终态明确 | | `+list-forms` fail-closed | PASS | 非空页缺 continuation 被拒绝;两个 cursor 的 93/93 项重叠 92 项 | | `+approve-by` 确认门禁单测 | PASS | 确认前 0 调用;确认后固定为读待办、读任务、写审批、精确任务读回 | @@ -49,12 +59,12 @@ Lark approval 共 14 个任务;映射按用户结果而不是命令名判断 |---|---|---|---| | approvals get | `oa approval form-schema` | routed | 使用原子读取 | | approvals search | `oa +search-forms` | covered | 公开 Shortcut | -| instances initiated | `oa +list-submitted` / `+my-initiated` | covered | 公开 Shortcut | +| instances initiated | `oa approval list-submitted` | routed | 两条 Shortcut 因 guaranteed-zero 缺分页证据而 unavailable;使用精确原子读取 | | instances get | `oa approval detail` | routed | 使用原子读取 | | instances cancel | `oa approval revoke` | routed | 高风险原子写;无可逆 fixture,不新增公开 Shortcut | | instances cc | `oa approval oa-cc-noticer` | routed | 无安全 fixture,保持原子入口 | | instances create | `oa approval forecast-process` + `create-instance` | routed | 无可清理实例 fixture,不新增公开 Shortcut | -| tasks query | 四类列表与 `oa approval tasks` | delivery hardened but downstream blocked | 已办/已发起公开;待办返回 `null` 集合,抄送返回错型状态与 `null` 集合 | +| tasks query | 四类列表与 `oa approval tasks` | delivery hardened but downstream blocked | 已办/已发起非空可读,但零命中缺分页;待办返回 `null` 集合,抄送返回错型状态与 `null` 集合 | | tasks add_sign | `oa approval append-task` | routed | 无安全 fixture,保持原子入口 | | tasks approve | `oa approval approve` / `+approve-by` | routed / unavailable | 原子写需用户确认;复合 Shortcut unavailable | | tasks reject | `oa approval reject` | routed | 无安全 fixture,保持原子入口 | @@ -103,6 +113,13 @@ DWS 现有原子能力还覆盖审批记录、表单 Schema、流程预测、评 - 验收:Exact Shortcut 写入后以相同实例与 taskId 读回请求关键状态;回执缺失、读回不一致、异步超时和部分失败均机器可检测;安全 fixture 完成恢复与零残留。 - 临时处置:`+approve-by` 保持 hidden/unavailable;当前实现即使写调用成功,读回无法证明时也返回非成功且标记 execution started。 +## `DS-oa-004`:编号列表零命中的分页终态 + +- 优先级:P1;类型:contract insufficient;Owner:OA MCP adapter 与业务接口。 +- 影响:`+list-executed`、`+list-submitted`、`+my-initiated`。current HEAD 的 known-nonempty exact/raw 分别以 7、14、14 个稳定身份完全对齐,且 raw 明确 `hasMore=false`;但 guaranteed-zero-query 的 raw 响应只返回显式空 `values`,缺少 `hasMore`。 +- 严格处置:空数组不推导分页终态。Shortcut 对缺 `hasMore` 返回 `missing_pagination`,三条命令均为 hidden/unavailable。 +- 验收:同一 guaranteed-zero-query 的 raw 响应必须返回布尔 `hasMore=false`;若将来允许 continuation,则必须提供可执行且严格前进的页码/游标,不能依赖集合长度推断。 + ## 超越 Lark 的机会 在上述两个下游合同和安全 fixture 就绪后,可把 DingTalk 原生的“流程预测→精确实例创建→节点/任务读回→操作记录审计”组合成一条带逐步 ledger 的 Golden Route。它应保持每一步稳定身份、支持 typed pending/partial/commit-unknown,并在创建后的任一步失败时给出可执行恢复动作;在此之前不发布该复合 Shortcut。 diff --git a/internal/shortcut/builtin/oa_semantic_catalog_test.go b/internal/shortcut/builtin/oa_semantic_catalog_test.go index a9836911..e5ae28f9 100644 --- a/internal/shortcut/builtin/oa_semantic_catalog_test.go +++ b/internal/shortcut/builtin/oa_semantic_catalog_test.go @@ -77,7 +77,7 @@ func TestCrossPlatformCoverageOASemanticCatalogExactlyCoversRegisteredSurface(t if len(missing) > 0 || len(stale) > 0 { t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) } - if public != 4 || unavailable != 5 { - t.Fatalf("public/unavailable=%d/%d, want 4/5", public, unavailable) + if public != 1 || unavailable != 8 { + t.Fatalf("public/unavailable=%d/%d, want 1/8", public, unavailable) } } diff --git a/internal/shortcut/oa/common.go b/internal/shortcut/oa/common.go index aae53aae..f51c11bd 100644 --- a/internal/shortcut/oa/common.go +++ b/internal/shortcut/oa/common.go @@ -347,17 +347,10 @@ func oaCursorPage(result map[string]any, operation string, current int) (oaPageE return page, nil } -func oaHasMorePage(result map[string]any, operation string, itemCount, currentPage int) (oaPageEvidence, error) { +func oaHasMorePage(result map[string]any, operation string, currentPage int) (oaPageEvidence, error) { raw, present := result["hasMore"] if !present { - // Reviewed OA zero-page encoding: get_done_tasks, - // get_submitted_instances, and get_noticed_instances omit hasMore only - // when result.values is an explicit empty array. No other missing - // pagination shape is accepted. - if itemCount == 0 { - return oaPageEvidence{Known: true}, nil - } - return oaPageEvidence{}, oaResponseError(operation, "missing_pagination", "非空审批列表缺少 hasMore,无法证明结果完整或提供续页凭据") + return oaPageEvidence{}, oaResponseError(operation, "missing_pagination", "审批列表缺少 hasMore,无法证明结果完整或提供续页凭据") } hasMore, ok := raw.(bool) if !ok { diff --git a/internal/shortcut/oa/oa.go b/internal/shortcut/oa/oa.go index 79bd12d6..13d3564e 100644 --- a/internal/shortcut/oa/oa.go +++ b/internal/shortcut/oa/oa.go @@ -41,7 +41,7 @@ func oaInstancePage(rt *shortcut.RuntimeContext, tool string, params map[string] if err != nil { return err } - evidence, err := oaHasMorePage(result, operation, len(instances), page) + evidence, err := oaHasMorePage(result, operation, page) if err != nil { return err } diff --git a/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go index 1e84ca4d..c63bbcd4 100644 --- a/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go @@ -116,16 +116,13 @@ func TestCrossPlatformCoverageOAStrictResponseMatrix(t *testing.T) { } func TestCrossPlatformCoverageOAPaginationFailsClosed(t *testing.T) { - if _, err := oaHasMorePage(map[string]any{}, "oa/page", 1, 1); err == nil { - t.Fatal("nonempty numbered page without hasMore was accepted") + if _, err := oaHasMorePage(map[string]any{}, "oa/page", 1); err == nil { + t.Fatal("numbered page without hasMore was accepted") } - if page, err := oaHasMorePage(map[string]any{}, "oa/page", 0, 1); err != nil || !page.Known || page.HasMore { - t.Fatalf("reviewed empty numbered terminal page=%+v err=%v", page, err) - } - if _, err := oaHasMorePage(map[string]any{"hasMore": "false"}, "oa/page", 0, 1); err == nil { + if _, err := oaHasMorePage(map[string]any{"hasMore": "false"}, "oa/page", 1); err == nil { t.Fatal("wrong hasMore type was accepted") } - page, err := oaHasMorePage(map[string]any{"hasMore": true}, "oa/page", 1, 2) + page, err := oaHasMorePage(map[string]any{"hasMore": true}, "oa/page", 2) if err != nil || !page.HasMore || page.Next != "3" { t.Fatalf("numbered continuation=%+v err=%v", page, err) } @@ -146,7 +143,7 @@ func TestCrossPlatformCoverageOAPaginationFailsClosed(t *testing.T) { func TestCrossPlatformCoverageMyInitiatedNeverFallsBackToRawResponse(t *testing.T) { caller := &oaCoverageCaller{responses: map[string][]string{ - "get_submitted_instances": {`{"success":"true","result":{"values":[]}}`}, + "get_submitted_instances": {`{"success":"true","result":{"values":[],"hasMore":false}}`}, }} cmd, err := runOACoverage(t, MyInitiated, caller, "--page", "1", "--limit", "20") if err != nil { diff --git a/internal/shortcut/oa/smart.go b/internal/shortcut/oa/smart.go index c62705b1..6b2ff1ed 100644 --- a/internal/shortcut/oa/smart.go +++ b/internal/shortcut/oa/smart.go @@ -29,7 +29,7 @@ func oaFirstPageOnly(rt *shortcut.RuntimeContext, tool, collection string, param if err != nil { return err } - page, err := oaHasMorePage(result, operation, len(instances), 1) + page, err := oaHasMorePage(result, operation, 1) if err != nil { return err } @@ -145,7 +145,7 @@ var MyInitiated = shortcut.Shortcut{ if err != nil { return err } - page, err := oaHasMorePage(result, operation, len(items), rt.Int("page")) + page, err := oaHasMorePage(result, operation, rt.Int("page")) if err != nil { return err } @@ -215,7 +215,7 @@ var Approve = shortcut.Shortcut{ if err != nil { return err } - page, err := oaHasMorePage(result, "oa/list_pending_approvals", len(items), 1) + page, err := oaHasMorePage(result, "oa/list_pending_approvals", 1) if err != nil { return err } diff --git a/internal/shortcut/semantic_catalog_oa.json b/internal/shortcut/semantic_catalog_oa.json index b853853c..262d6465 100644 --- a/internal/shortcut/semantic_catalog_oa.json +++ b/internal/shortcut/semantic_catalog_oa.json @@ -5,27 +5,24 @@ "shortcuts": { "+list-executed": { "disposition": "semantic_adapter", - "semantic_delta": "严格验证 success、result.values、稳定 processInstanceId 与 hasMore,并把下一页凭据发布到统一分页元数据;真实非空和保证零命中均已执行。", + "semantic_delta": "严格验证 success、result.values、稳定 processInstanceId 与 hasMore;known-nonempty exact/raw 的 7 个稳定身份一致,但 guaranteed-zero-query 的 raw 响应缺少 hasMore,收紧后拒绝并保持不可用。", "risk": "read", - "availability": "available", - "public": true, + "public": false, "reviewed": true }, "+list-submitted": { "disposition": "semantic_adapter", - "semantic_delta": "严格返回当前用户已发起审批实例及页完整性;缺集合、错型、坏元素和缺失分页均失败,真实非空和保证零命中均已执行。", + "semantic_delta": "严格返回已发起审批实例及页完整性;known-nonempty exact/raw 的 14 个稳定身份一致,但 guaranteed-zero-query 的 raw 响应缺少 hasMore,不能把空数组推断为终页,保持不可用。", "risk": "read", - "availability": "available", - "public": true, + "public": false, "reviewed": true }, "+my-initiated": { "disposition": "alias_internal", - "semantic_delta": "保留历史 initiated 字段与命令身份,同时复用已发起审批的严格响应和分页契约;空投影不再回退原始响应。", + "semantic_delta": "保留历史 initiated 字段且不回退原始响应;known-nonempty exact/raw 的 14 个稳定身份一致,但复用的 guaranteed-zero raw 响应缺少 hasMore,故与主列表一并保持不可用。", "risk": "read", - "availability": "available", "primary": "+list-submitted", - "public": true, + "public": false, "reviewed": true }, "+search-forms": { From d2cbd928e24316685d2d8087b046954b38949a30 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 18:49:42 +0800 Subject: [PATCH 12/28] docs(shortcut): record DING double-layer release proof --- docs/shortcut-downstream-requirements/ding.md | 8 ++++++++ internal/shortcut/semantic_catalog_ding.json | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/shortcut-downstream-requirements/ding.md b/docs/shortcut-downstream-requirements/ding.md index 4c66a53c..f37835cb 100644 --- a/docs/shortcut-downstream-requirements/ding.md +++ b/docs/shortcut-downstream-requirements/ding.md @@ -47,6 +47,14 @@ | `+send-by-message` | capability gate,远端调用 0 | 自发源消息稳定任务/消息/会话身份均通过;APP DING 回执 1、接收状态 1;稳定接收人身份字段 0 | downstream blocked | | `+recall-personal` | capability gate,远端调用 0 | 撤回业务成功为布尔值,但目标回执 0;撤回后精确接收状态仍为 1 | downstream blocked | +### Current HEAD 公开 Shortcut 双层发布矩阵 + +| EVERY public Shortcut | Exact Shortcut | Owning atomic/raw | 稳定身份与结果 | current HEAD 结论 | +|---|---|---|---|---| +| `ding +receiver-status` | `dws ding +receiver-status --ding-id --format json` | 先以 `dws ding message list --type ALL --format json` 取得同一稳定 ID,再执行 `dws ding message receiver-status --ding-id --format json` | 原子来源页 50;exact/raw 接收项均为 1;请求 DING ID、每项 DING ID 与完整接收行集合一致 | `PASS-DING-RECEIVER-DOUBLE`;fully unlocked | + +这是精确稳定 ID 读取,不是 list/search,因此不制造随机不存在 ID 的“零命中”。实现与严格响应测试位于 `internal/shortcut/ding/ding.go`、`internal/shortcut/ding/common.go` 和 `internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go`。 + 隔离 APP fixture 撤回后,`SEND`、`DELETED`、`ALL` 首屏均未出现目标;但 `+list` 的 continuation 已被证明停滞,首屏缺席不能升级为全局零残留证明。源聊天消息也在执行撤回后仍可按稳定身份读取,因此不能把“记录仍可查询”擅自解释为已删除或未删除。 ## 5. 下游需求 diff --git a/internal/shortcut/semantic_catalog_ding.json b/internal/shortcut/semantic_catalog_ding.json index cfcea69d..d1a12dfb 100644 --- a/internal/shortcut/semantic_catalog_ding.json +++ b/internal/shortcut/semantic_catalog_ding.json @@ -12,7 +12,7 @@ }, "+receiver-status": { "disposition": "semantic_adapter", - "semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;exact Shortcut 已完成真实非空身份匹配证明。", + "semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;current HEAD exact Shortcut 与 owning atomic/raw 的请求身份、1 项结果和完整接收行集合一致。", "risk": "read", "availability": "available", "public": true, From f4ad1a15f592371de27cbc75107c5614c1fa755b Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 18:50:28 +0800 Subject: [PATCH 13/28] docs(shortcut): record Report double-layer release proof --- .../shortcut-downstream-requirements/report.md | 18 +++++++++++------- internal/shortcut/semantic_catalog_report.json | 8 ++++---- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/shortcut-downstream-requirements/report.md b/docs/shortcut-downstream-requirements/report.md index 51a6cc80..9a83b9ff 100644 --- a/docs/shortcut-downstream-requirements/report.md +++ b/docs/shortcut-downstream-requirements/report.md @@ -28,14 +28,18 @@ Report 的 Shortcut surface 没有 “delivery hardened but downstream blocked ## 3. Exact Shortcut E2E 矩阵 -| Exact Shortcut | 已知非空 | 保证零项/零命中 | 身份与分页 | 结论 | -|---|---:|---:|---|---| -| `+template-search` | PASS(1) | PASS(0) | 完整模板集合验证后本地筛选;稳定模板身份 | fully unlocked | -| `+inbox-list` | PASS(累计 92) | PASS(0) | 5 个非空页;cursor 严格前进;首对相邻页身份交集 0;终页明确耗尽 | fully unlocked | -| `+outbox-list` | PASS(1) | PASS(0) | 非空页 `complete=true` 且 endpoint exhausted;570 天有界扫描合同异常 0 | fully unlocked | -| `+report-latest` | PASS(候选 1) | 不适用:精确组合读取 | 显式不超过 20 天窗口;按唯一最高创建时间选取;详情身份精确匹配;字段集合 3 | fully unlocked | +| EVERY public Shortcut | Exact Shortcut | Owning atomic/raw | current HEAD 双层证据 | 结论 | +|---|---|---|---|---| +| `report +template-search` | `dws report +template-search --query --format json` | `dws report template list --format json` 后对同一完整集合执行同语义本地筛选 | 已知 1/1,稳定模板 ID 集合一致;随机 UUID 0/0;原子完整集合 73 且接口无 cursor surface | `PASS-RPT-TEMPLATE-DOUBLE`;fully unlocked | +| `report +inbox-list` | `dws report +inbox-list --start --end --cursor 0 --size 20 --format json` | `dws report inbox list` 使用完全相同参数;从 `_internalDetailCommands` 取得同页精确 `reportId` | 已知 20/20,稳定 ID 集合一致,exact/raw next cursor 一致;独立未来范围 0/0 且双方明确终止 | `PASS-RPT-INBOX-DOUBLE`;fully unlocked | +| `report +outbox-list` | `dws report +outbox-list --start --end --cursor 0 --size 20 --format json` | `dws report outbox list` 使用完全相同参数;从 `_internalDetailCommands` 取得同页精确 `reportId` | 已知 1/1,稳定 ID 集合一致且双方明确终止;独立未来范围 0/0 且双方明确终止 | `PASS-RPT-OUTBOX-DOUBLE`;fully unlocked | +| `report +report-latest` | `dws report +report-latest --start --end --format json` | 同范围 `dws report outbox list`,再以所选稳定 ID 执行 `dws report entry get --report-id --format json` | exact ID、原子候选 ID、原子详情 ID 三者一致;详情字段计数均为 3,候选页明确终止 | `PASS-RPT-LATEST-DOUBLE`;fully unlocked | -所有列表/搜索严格拒绝空响应、缺或错型 `success`、缺或错型集合、坏元素、重复身份、缺 continuation、空页续页和不前进 cursor。服务端在终页回显的整数 cursor 只作为页面收据,不发布为 `next_token`。 +`+inbox-list` 的长期分页证据仍为 20、20、20、20、12 后明确终止,累计 92,首对相邻页身份交集 0;`+outbox-list` 的 fixture 扫描仍为 30 个连续 19 天窗口、总项 1、非空窗口 1、合同异常 0。这里的零项均来自独立保证零范围,不是已知非空集合后的空页。 + +所有列表/搜索严格拒绝空响应、缺或错型 `success`、缺或错型集合、坏元素和重复身份;带分页的列表还拒绝缺 continuation、空页续页和不前进 cursor。服务端在终页回显的整数 cursor 只作为页面收据,不发布为 `next_token`。 + +实现与严格响应测试位于 `internal/shortcut/report/report.go`、`internal/shortcut/report/latest.go`、`internal/shortcut/report/common.go` 和 `internal/shortcut/report/report_strict_cross_platform_coverage_test.go`;原子命令实现位于 `internal/helpers/report.go`。本文只保留命令模板、PASS 标签与聚合事实,运行时稳定 ID、标题和原始响应均未落盘。 ## 4. 已关闭需求 diff --git a/internal/shortcut/semantic_catalog_report.json b/internal/shortcut/semantic_catalog_report.json index f80fd538..59f11983 100644 --- a/internal/shortcut/semantic_catalog_report.json +++ b/internal/shortcut/semantic_catalog_report.json @@ -5,7 +5,7 @@ "shortcuts": { "+inbox-list": { "disposition": "semantic_adapter", - "semantic_delta": "严格验证 success、result.report_list、稳定 reportId 与 hasMore/cursor;exact Shortcut 已完成已知非空、保证零项、cursor 严格前进、相邻页无重复及终止页证明。终止页回显 cursor 只作已验证收据且不发布 next_token。", + "semantic_delta": "严格验证 success、result.report_list、稳定 reportId 与 hasMore/cursor;current HEAD exact 与 owning atomic 同场景已知页均为 20 项、稳定身份集合和 next cursor 一致,独立未来范围均为 0 且明确终止。终止页回显 cursor 只作已验证收据且不发布 next_token。", "risk": "read", "availability": "available", "public": true, @@ -13,7 +13,7 @@ }, "+outbox-list": { "disposition": "semantic_adapter", - "semantic_delta": "严格验证发件箱集合、稳定 reportId 和分页终止证据;exact Shortcut 已在连续有界历史窗口中同时证明已知非空与保证零项。", + "semantic_delta": "严格验证发件箱集合、稳定 reportId 和分页终止证据;current HEAD exact 与 owning atomic 同场景已知页均为 1 项且身份一致,独立未来范围均为 0 并明确终止。", "risk": "read", "availability": "available", "public": true, @@ -21,7 +21,7 @@ }, "+template-search": { "disposition": "semantic_adapter", - "semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;exact Shortcut 同时证明已知非空与保证零命中。", + "semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;current HEAD exact 与 owning atomic 完整集合过滤的已知结果均为 1 且身份一致,随机 UUID 查询均为 0。", "risk": "read", "availability": "available", "public": true, @@ -29,7 +29,7 @@ }, "+report-latest": { "disposition": "primary_smart", - "semantic_delta": "完整验证默认最近 20 天或显式不超过 20 天的发件箱;要求唯一最高 createTime,按稳定 reportId 精确读回身份匹配且字段集合严格的详情。", + "semantic_delta": "完整验证默认最近 20 天或显式不超过 20 天的发件箱;current HEAD exact 所选稳定 reportId 与 owning atomic 候选和精确详情身份一致,严格详情字段计数双层均为 3。", "risk": "read", "availability": "available", "public": true, From 8fa93ef030edb6ed42cab71e7431658a8478f390 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 18:56:16 +0800 Subject: [PATCH 14/28] fix(shortcut): retire OA discovery aliases after downgrade --- internal/cli/command_path_fallbacks.json | 4 ++-- internal/cli/command_path_fallbacks_generated.go | 4 ++-- internal/cli/command_path_fallbacks_test.go | 2 +- internal/cli/param_aliases_generated.go | 13 ------------- internal/cli/param_concepts.json | 3 +-- 5 files changed, 6 insertions(+), 20 deletions(-) diff --git a/internal/cli/command_path_fallbacks.json b/internal/cli/command_path_fallbacks.json index fea4b829..f2b3c930 100644 --- a/internal/cli/command_path_fallbacks.json +++ b/internal/cli/command_path_fallbacks.json @@ -192,11 +192,11 @@ "mode": "ambiguous", "candidates": [ "oa +search-forms", - "oa +my-initiated", + "oa approval list-submitted", "oa approval list-initiated" ], "reviewed": true, - "review_reason": "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search and initiated workflows." + "review_reason": "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation and removes +my-initiated from discovery because guaranteed-zero responses omit hasMore; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search or exact atomic initiated routes." }, { "from": "chat +conversation-detail", diff --git a/internal/cli/command_path_fallbacks_generated.go b/internal/cli/command_path_fallbacks_generated.go index 598cabb7..00f52d50 100644 --- a/internal/cli/command_path_fallbacks_generated.go +++ b/internal/cli/command_path_fallbacks_generated.go @@ -242,9 +242,9 @@ var generatedCommandPathFallbacks = []CommandPathFallback{ { From: "oa +list-processes", Mode: "ambiguous", - Candidates: []string{"oa +search-forms", "oa +my-initiated", "oa approval list-initiated"}, + Candidates: []string{"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"}, Reviewed: true, - ReviewReason: "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search and initiated workflows.", + ReviewReason: "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation and removes +my-initiated from discovery because guaranteed-zero responses omit hasMore; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search or exact atomic initiated routes.", }, } diff --git a/internal/cli/command_path_fallbacks_test.go b/internal/cli/command_path_fallbacks_test.go index 5136a8c0..1129efdd 100644 --- a/internal/cli/command_path_fallbacks_test.go +++ b/internal/cli/command_path_fallbacks_test.go @@ -172,7 +172,7 @@ func TestCrossPlatformCoverageCommandPathFallbackAuditCoverage(t *testing.T) { "chat +send-file": {"chat +messages-send", "chat message send"}, "chat +send-image": {"chat +messages-send", "chat message send"}, "chat +send-media": {"chat +messages-send", "chat message send"}, - "oa +list-processes": {"oa +search-forms", "oa +my-initiated", "oa approval list-initiated"}, + "oa +list-processes": {"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"}, "doc +template": {"doc +template-list", "doc +template-search", "doc +create-from-template"}, "doc +version": {"doc +history-list", "doc +history-save", "doc +history-revert"}, } diff --git a/internal/cli/param_aliases_generated.go b/internal/cli/param_aliases_generated.go index c64abdcb..b7bf4f50 100644 --- a/internal/cli/param_aliases_generated.go +++ b/internal/cli/param_aliases_generated.go @@ -6655,19 +6655,6 @@ var generatedParamAliases = []ParamAliasEntry{ CLIPath: "minutes upload create", Blocked: []string{"file", "file-path", "path", "template-ids"}, }, - { - CLIPath: "oa +list-executed", - Aliases: map[string]string{ - "max-result": "limit", - "max-results": "limit", - "page-size": "limit", - "per-page": "limit", - "size": "limit", - "take": "limit", - "top": "limit", - }, - Blocked: []string{"count", "cursor"}, - }, { CLIPath: "oa +search-forms", Aliases: map[string]string{ diff --git a/internal/cli/param_concepts.json b/internal/cli/param_concepts.json index c8688ca7..0563dead 100644 --- a/internal/cli/param_concepts.json +++ b/internal/cli/param_concepts.json @@ -11,7 +11,7 @@ "concepts": { "search_query": {"denotes":"search keyword string","canonical_hint":"query","members":["query","keyword","keywords","q","search-word"],"excludes":["name","subject","text","title"],"commands":["aitable +base-search","aitable +find-record","aitable +record-bulk-patch","aitable +record-query","aitable +template-search","aitable base search","aitable field search-options","aitable record list","aitable record query","aitable search","aitable template search","contact +dept-members","contact +resolve-dept","contact +search-user","doc +create-from-template","doc +find-doc","doc +search","doc +template-search","doc template search","drive +find-file","drive +search","drive +search-docs","drive search","mail +find-mail-user","mail user search","oa +search-forms","oa approval search-forms","calendar +book-search","calendar +search-event","minutes +latest","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes +transcript","todo +search","wiki +node-search","wiki +resolve-space","wiki +space-search","wiki node search","wiki space search"],"risk":"green"}, - "pagination_size": {"denotes":"returned item count upper bound","canonical_hint":"limit","members":["limit","size","page-size","max-results","max-result","take","top","per-page"],"excludes":["count","page","cursor"],"commands":["aitable +base-list","aitable +record-history-list","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable +workflow-list","aitable base list","aitable field search-options","aitable list","aitable record history-list","aitable record list","aitable record query","aitable record query-empty","aitable template search","aitable workflow history","aitable workflow list","calendar event list","chat message list","devdoc article search","doc +comment-list","doc +find-doc","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +search-docs","drive +star-list","drive +version-history","drive list","drive list-spaces","drive permission list","drive recent","drive recycle list","drive search","drive star list","mail thread list","oa +list-executed","calendar +agenda","calendar +room-groups","calendar +room-find","calendar +search-event","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes list all","minutes list mine","minutes list shared","minutes tag query","todo +list-comment","todo comment list","wiki +member-list","wiki +node-search","wiki +space-search","wiki feed list","wiki member list","wiki node list","wiki node search","wiki space list","wiki space search"],"risk":"green"}, + "pagination_size": {"denotes":"returned item count upper bound","canonical_hint":"limit","members":["limit","size","page-size","max-results","max-result","take","top","per-page"],"excludes":["count","page","cursor"],"commands":["aitable +base-list","aitable +record-history-list","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable +workflow-list","aitable base list","aitable field search-options","aitable list","aitable record history-list","aitable record list","aitable record query","aitable record query-empty","aitable template search","aitable workflow history","aitable workflow list","calendar event list","chat message list","devdoc article search","doc +comment-list","doc +find-doc","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +search-docs","drive +star-list","drive +version-history","drive list","drive list-spaces","drive permission list","drive recent","drive recycle list","drive search","drive star list","mail thread list","calendar +agenda","calendar +room-groups","calendar +room-find","calendar +search-event","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes list all","minutes list mine","minutes list shared","minutes tag query","todo +list-comment","todo comment list","wiki +member-list","wiki +node-search","wiki +space-search","wiki feed list","wiki member list","wiki node list","wiki node search","wiki space list","wiki space search"],"risk":"green"}, "page_number": {"denotes":"one-based page number","canonical_hint":"page","members":["page","page-no","current-page","page-num"],"excludes":["cursor","page-index","page-size","page-token"],"commands":["devdoc article search","todo +get-my-tasks","todo +list-comment","todo comment list","todo task list"],"risk":"green"}, "page_cursor": {"denotes":"pagination cursor/token","canonical_hint":"cursor","members":["cursor","next-cursor","page-token","next-token","next-page-token"],"excludes":["page","offset"],"commands":["aitable +base-list","aitable +base-search","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable base list","aitable base search","aitable list","aitable record list","aitable record query","aitable record query-empty","aitable search","aitable template search","calendar event list","doc +comment-list","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +star-list","drive +version-history","drive list","drive list-spaces","drive recent","drive recycle list","drive search","drive star list","calendar +agenda","calendar +search-event","minutes +detail","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes +transcript","wiki +feed-list","wiki +node-search","wiki feed list","wiki node list","wiki node search","wiki space list"],"risk":"green"}, "content_text": {"denotes":"text body content","canonical_hint":"text","members":["text","content","body"],"excludes":["title","name"],"commands":["doc +checkpoint-update","doc +comment-create","doc +comment-reply","doc +comment-update","doc +create","doc +doc-append","doc block insert","doc block update","doc comment create","doc comment create-inline","doc comment reply","doc comment update","doc create","minutes +summary","minutes update summary","todo +comment","todo comment add"],"risk":"green"}, @@ -458,7 +458,6 @@ {"command": "drive list", "emitted": "folder-id", "expect": "folder", "via": "concept:folder_id+morph", "occ": 2}, {"command": "mail thread list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size", "occ": 2}, {"command": "mail user search", "emitted": "query", "expect": "keyword", "via": "concept:search_query", "occ": 2}, - {"command": "oa +list-executed", "emitted": "take", "expect": "limit", "via": "concept:pagination_size", "occ": 2}, {"command": "oa approval search-forms", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 2}, {"command": "report list", "emitted": "from-date", "expect": "start", "via": "concept:time_start", "occ": 2}, {"command": "aitable +base-search", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 1}, From 1155b9b5c0c0547184dd58edff04cbafba7e3e35 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 18 Aug 2026 19:12:38 +0800 Subject: [PATCH 15/28] test(shortcut): align OA reviewed input fixtures --- internal/app/command_path_fallback_e2e_test.go | 2 +- internal/app/param_alias_payload_equivalence_test.go | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/app/command_path_fallback_e2e_test.go b/internal/app/command_path_fallback_e2e_test.go index f5e083c3..db8e6133 100644 --- a/internal/app/command_path_fallback_e2e_test.go +++ b/internal/app/command_path_fallback_e2e_test.go @@ -372,7 +372,7 @@ func TestCrossPlatformCoverageReviewedAmbiguousCommandFallbackNeverDispatches(t {path: "chat +conversation-category-list", candidates: []string{"chat +category-list", "chat +category-list-conversations"}}, {path: "chat +conversation-group-list", candidates: []string{"chat +category-list-conversations", "chat +conversation-list"}}, {path: "chat +list-my-groups", candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"}}, - {path: "oa +list-processes", candidates: []string{"oa +search-forms", "oa +my-initiated", "oa approval list-initiated"}}, + {path: "oa +list-processes", candidates: []string{"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"}}, } for _, test := range tests { t.Run(test.path, func(t *testing.T) { diff --git a/internal/app/param_alias_payload_equivalence_test.go b/internal/app/param_alias_payload_equivalence_test.go index 2d6ef0ba..cfd54910 100644 --- a/internal/app/param_alias_payload_equivalence_test.go +++ b/internal/app/param_alias_payload_equivalence_test.go @@ -226,7 +226,6 @@ var paramAliasCompleteCommands = map[string][]string{ "mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"}, "mail thread list": {"mail", "thread", "list", "--email", "fixture@example.com", "--folder", "folder-1", "--limit", "7"}, "mail user search": {"mail", "user", "search", "--keyword", "fixture"}, - "oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "1"}, "oa +search-forms": {"oa", "+search-forms", "--query", "fixture"}, "oa approval search-forms": {"oa", "approval", "search-forms", "--query", "fixture"}, "report list": {"report", "list", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-10T23:59:59+08:00"}, From db938778af1767333a0ec68ea43b69ee5e2383a2 Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 12:00:33 +0800 Subject: [PATCH 16/28] chore(shortcut): sync OA DING Report with current main --- docs/shortcut-public-catalog.json | 62 ++++++++----------- internal/app/schema_shortcut_contract_test.go | 6 +- internal/shortcut/public_catalog_generated.go | 5 +- skills/mono/SKILL.md | 6 +- skills/multi/dingtalk-misc/references/ding.md | 3 - skills/multi/dingtalk-misc/references/oa.md | 6 -- .../multi/dingtalk-misc/references/report.md | 6 +- 7 files changed, 38 insertions(+), 56 deletions(-) diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index eeb72aa2..6e6904cb 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -1,6 +1,6 @@ { - "generated_at": "2026-08-19T10:35:58.304269", - "count": 423, + "generated_at": "2026-08-20T11:51:44.156046", + "count": 416, "results": [ { "suite": "semantic", @@ -2500,7 +2500,7 @@ "risk": "read", "status": "reviewed_available", "disposition": "semantic_adapter", - "semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;exact Shortcut 已完成真实非空身份匹配证明。", + "semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;current HEAD exact Shortcut 与 owning atomic/raw 的请求身份、1 项结果和完整接收行集合一致。", "availability": "available" }, { @@ -3583,36 +3583,6 @@ "semantic_delta": "Completes local upload with compensation, waits for validated artifacts, and optionally orchestrates mind map and speaker insights without re-upload recovery hazards.", "availability": "available" }, - { - "suite": "semantic", - "service": "oa", - "command": "+list-executed", - "risk": "read", - "status": "reviewed_available", - "disposition": "semantic_adapter", - "semantic_delta": "严格验证 success、result.values、稳定 processInstanceId 与 hasMore,并把下一页凭据发布到统一分页元数据;真实非空和保证零命中均已执行。", - "availability": "available" - }, - { - "suite": "semantic", - "service": "oa", - "command": "+list-submitted", - "risk": "read", - "status": "reviewed_available", - "disposition": "semantic_adapter", - "semantic_delta": "严格返回当前用户已发起审批实例及页完整性;缺集合、错型、坏元素和缺失分页均失败,真实非空和保证零命中均已执行。", - "availability": "available" - }, - { - "suite": "semantic", - "service": "oa", - "command": "+my-initiated", - "risk": "read", - "status": "reviewed_available", - "disposition": "alias_internal", - "semantic_delta": "保留历史 initiated 字段与命令身份,同时复用已发起审批的严格响应和分页契约;空投影不再回退原始响应。", - "availability": "available" - }, { "suite": "semantic", "service": "oa", @@ -3630,7 +3600,27 @@ "risk": "read", "status": "reviewed_available", "disposition": "semantic_adapter", - "semantic_delta": "严格验证 success、result.report_list、稳定 reportId 与 hasMore/cursor;exact Shortcut 已完成已知非空、保证零项、cursor 严格前进、相邻页无重复及终止页证明。终止页回显 cursor 只作已验证收据且不发布 next_token。", + "semantic_delta": "严格验证 success、result.report_list、稳定 reportId 与 hasMore/cursor;current HEAD exact 与 owning atomic 同场景已知页均为 20 项、稳定身份集合和 next cursor 一致,独立未来范围均为 0 且明确终止。终止页回显 cursor 只作已验证收据且不发布 next_token。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "report", + "command": "+outbox-list", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证发件箱集合、稳定 reportId 和分页终止证据;current HEAD exact 与 owning atomic 同场景已知页均为 1 项且身份一致,独立未来范围均为 0 并明确终止。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "report", + "command": "+report-latest", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "完整验证默认最近 20 天或显式不超过 20 天的发件箱;current HEAD exact 所选稳定 reportId 与 owning atomic 候选和精确详情身份一致,严格详情字段计数双层均为 3。", "availability": "available" }, { @@ -3640,7 +3630,7 @@ "risk": "read", "status": "reviewed_available", "disposition": "semantic_adapter", - "semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;exact Shortcut 同时证明已知非空与保证零命中。", + "semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;current HEAD exact 与 owning atomic 完整集合过滤的已知结果均为 1 且身份一致,随机 UUID 查询均为 0。", "availability": "available" }, { @@ -4068,4 +4058,4 @@ "availability": "available" } ] -} +} \ No newline at end of file diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index 68a1db5e..ed89cc80 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -16,12 +16,12 @@ import ( ) const ( - publicShortcutCount = 422 + publicShortcutCount = 415 // schemaPublishedShortcutCount counts every delivered *.shortcut_* tool, // including reviewed hidden compatibility and unavailable contracts. - schemaPublishedShortcutCount = 447 + schemaPublishedShortcutCount = 453 // publiclyDeliveredShortcutCount is the public-catalog subset of that surface. - publiclyDeliveredShortcutCount = 422 + publiclyDeliveredShortcutCount = 415 ) func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) { diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index 9dc6742d..1d81b103 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -374,11 +374,10 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "minutes\u0000+update": {}, "minutes\u0000+upload": {}, "minutes\u0000+upload-and-analyze": {}, - "oa\u0000+list-executed": {}, - "oa\u0000+list-submitted": {}, - "oa\u0000+my-initiated": {}, "oa\u0000+search-forms": {}, "report\u0000+inbox-list": {}, + "report\u0000+outbox-list": {}, + "report\u0000+report-latest": {}, "report\u0000+template-search": {}, "sheet\u0000+list-sheets": {}, "sheet\u0000+read": {}, diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index d695e969..3d226752 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -49,13 +49,13 @@ cli_version: ">=1.0.15" | `chat` | 98 | `dingtalk-chat` | | `contact` | 14 | `dingtalk-contact` | | `devapp` | 19 | `dingtalk-misc` | -| `ding` | 4 | `dingtalk-misc` | +| `ding` | 1 | `dingtalk-misc` | | `doc` | 45 | `dingtalk-doc` | | `drive` | 28 | `dingtalk-drive` | | `mail` | 8 | `dingtalk-mail` | | `minutes` | 27 | `dingtalk-minutes` | -| `oa` | 7 | `dingtalk-misc` | -| `report` | 2 | `dingtalk-misc` | +| `oa` | 1 | `dingtalk-misc` | +| `report` | 4 | `dingtalk-misc` | | `sheet` | 2 | `dingtalk-misc` | | `todo` | 21 | `dingtalk-todo` | | `wiki` | 20 | `dingtalk-wiki` | diff --git a/skills/multi/dingtalk-misc/references/ding.md b/skills/multi/dingtalk-misc/references/ding.md index 5cce1ce6..d2485596 100644 --- a/skills/multi/dingtalk-misc/references/ding.md +++ b/skills/multi/dingtalk-misc/references/ding.md @@ -173,10 +173,7 @@ dws ding message recall-personal --id --format json | Shortcut | 风险 | 适用场景 | |---|---|---| -| `dws ding +list` | read | 查询 DING 消息列表 | -| `dws ding +recall-personal` | high-risk-write | 撤回本人发起的 DING | | `dws ding +receiver-status` | read | 查询 DING 消息接收人已读状态 | -| `dws ding +send-personal` | write | 以本人身份发送 DING 给指定人 | ## 意图表 diff --git a/skills/multi/dingtalk-misc/references/oa.md b/skills/multi/dingtalk-misc/references/oa.md index f526c23c..5044bd0c 100644 --- a/skills/multi/dingtalk-misc/references/oa.md +++ b/skills/multi/dingtalk-misc/references/oa.md @@ -963,12 +963,6 @@ dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1 | Shortcut | 风险 | 适用场景 | |---|---|---| -| `dws oa +list-cc` | read | 获取抄送当前用户的审批单列表 | -| `dws oa +list-executed` | read | 获取当前用户已经处理过的审批单列表 | -| `dws oa +list-forms` | read | 获取当前用户可见的审批表单列表 | -| `dws oa +list-pending` | read | 查询待我处理的审批(时间范围为 epoch 毫秒) | -| `dws oa +list-submitted` | read | 获取当前用户已发起的审批单列表 | -| `dws oa +my-initiated` | read | 列出我发起(提交)的审批单据 | | `dws oa +search-forms` | read | 按关键字模糊搜索当前用户可见的审批表单 | diff --git a/skills/multi/dingtalk-misc/references/report.md b/skills/multi/dingtalk-misc/references/report.md index 9ff940e9..cf73d9d5 100644 --- a/skills/multi/dingtalk-misc/references/report.md +++ b/skills/multi/dingtalk-misc/references/report.md @@ -449,8 +449,10 @@ dws report outbox list --cursor 0 --size 20 --format json | Shortcut | 风险 | 适用场景 | |---|---|---| -| `dws report +inbox-list` | read | 列出我收到的日报(按时间范围分页) | -| `dws report +outbox-list` | read | 列出我发出的日报(可选时间/模版名过滤) | +| `dws report +inbox-list` | read | 列出我收到的日志 | +| `dws report +outbox-list` | read | 列出我发出的日志 | +| `dws report +report-latest` | read | 读取我最近提交的一篇日志详情 | +| `dws report +template-search` | read | 按名称搜索可用日志模板 | ## 意图表 From 5bbaa304e3f87d87699d205012b289bc81a29a02 Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 12:17:07 +0800 Subject: [PATCH 17/28] docs(shortcut): refresh OA DING Report live evidence --- docs/shortcut-downstream-requirements/ding.md | 6 ++++-- docs/shortcut-downstream-requirements/oa.md | 16 ++++++++-------- docs/shortcut-downstream-requirements/report.md | 16 ++++++++-------- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/docs/shortcut-downstream-requirements/ding.md b/docs/shortcut-downstream-requirements/ding.md index f37835cb..d4e1b879 100644 --- a/docs/shortcut-downstream-requirements/ding.md +++ b/docs/shortcut-downstream-requirements/ding.md @@ -1,7 +1,7 @@ # DING Shortcut 下游业务能力需求规格 -> 日期:2026-08-18 -> DWS 基线:`c0b286c1e10b6fcebdff4ed522d3b1cf30549866` +> 日期:2026-08-20 +> 当前 main 基线:`b6eaf3c5af77b26cf1b1559fb7ea173fd0c5b971` > 对比基线:lark-cli 1.0.87 > 范围:Shortcut-only DING surface @@ -39,6 +39,8 @@ ## 4. Exact Shortcut 与隔离 raw 证据 +下表中 `+receiver-status` 是本轮 current-HEAD 发布证据;其余非公开入口为先前同租户隔离诊断,只用于说明下游缺口,不计入本轮公开能力 PASS。 + | 能力 | Exact Shortcut | Atomic/raw 隔离事实 | 结论 | |---|---|---|---| | `+list` | 已知非空 50;保证零项 0;真实续页返回 `stalled_cursor` | continuation 页仍为 50 项、下一游标不前进、相邻稳定身份交集 1;尝试包含式边界后仍不前进 | downstream blocked | diff --git a/docs/shortcut-downstream-requirements/oa.md b/docs/shortcut-downstream-requirements/oa.md index 4fb493ef..2eba3c1a 100644 --- a/docs/shortcut-downstream-requirements/oa.md +++ b/docs/shortcut-downstream-requirements/oa.md @@ -1,6 +1,6 @@ # OA Shortcut 下游能力需求 -基线:`c0b286c1e10b6fcebdff4ed522d3b1cf30549866`;Comparator:`lark-cli 1.0.87`;审计日期:2026-08-18。 +当前 main 基线:`b6eaf3c5af77b26cf1b1559fb7ea173fd0c5b971`;Comparator:`lark-cli 1.0.87`;审计日期:2026-08-20。 本文只记录脱敏的能力标签与聚合事实。没有资源 ID、业务标题、姓名、邮箱、精确业务时间、trace/request ID 或原始响应。 @@ -15,7 +15,7 @@ | Shortcut / 用户路线 | 最终分类 | 依据 | |---|---|---| -| `+search-forms` | fully unlocked | current HEAD exact/raw:已知非空 1、保证零命中 0,稳定身份集合一致;public | +| `+search-forms` | fully unlocked | current HEAD exact/raw:已知非空 2、保证零命中 0,稳定身份集合一致;public | | `+list-executed` | delivery hardened but downstream blocked | known-nonempty exact/raw 7 个稳定身份一致;guaranteed-zero raw 缺 `hasMore`,严格实现拒绝 | | `+list-submitted`、`+my-initiated` | delivery hardened but downstream blocked | known-nonempty exact/raw 14 个稳定身份一致;guaranteed-zero raw 缺 `hasMore`,严格实现拒绝 | | `+done-approvals` | routed | available 的隐藏兼容别名;新调用路由到 `+list-executed` | @@ -32,16 +32,16 @@ OA 不能作为整体宣称 fully unlocked:源码 10 条中仅 1 条 fully unl | EVERY public Shortcut | Exact Shortcut | Owning atomic/raw | known-nonempty / guaranteed-zero | current HEAD 结论 | |---|---|---|---|---| -| `oa +search-forms` | `dws oa +search-forms --query --format json` | `dws oa approval search-forms --query --format json` | 已知查询 1/1,稳定 `processCode` 集合相等;随机 UUID 查询 0/0 | `PASS-OA-SEARCH-DOUBLE`;fully unlocked | +| `oa +search-forms` | `dws oa +search-forms --query --format json` | `dws oa approval search-forms --query --format json` | 已知查询 2/2,稳定 `processCode` 集合相等;随机唯一查询 0/0 | `PASS-OA-SEARCH-DOUBLE`;fully unlocked | 该搜索接口一次返回完整匹配集合,不声明 cursor;双层证据验证的是同场景完整集合、稳定身份和独立随机零命中,而不是把已知集合后的空页当零命中。实现与严格响应测试位于 `internal/shortcut/oa/oa.go`、`internal/shortcut/oa/common.go` 和 `internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go`。 | Exact Shortcut 证明 | 标签 | 聚合事实 | |---|---|---| -| `+list-executed` fail-closed | PASS | current HEAD known-nonempty exact/raw 为 7 且稳定身份集合一致;guaranteed-zero raw 缺 `hasMore`,exact 返回 `missing_pagination` | -| `+list-submitted` fail-closed | PASS | current HEAD known-nonempty exact/raw 为 14 且稳定身份集合一致;guaranteed-zero raw 缺 `hasMore`,exact 返回 `missing_pagination` | -| `+my-initiated` fail-closed | PASS | current HEAD known-nonempty exact/raw 为 14 且稳定身份集合一致;不回退 raw;guaranteed-zero 缺分页时失败 | -| `+search-forms` | PASS | current HEAD 已知非空 1、保证零命中 0;exact/raw 稳定身份集合一致 | +| `+list-executed` fail-closed | PASS | 先前同租户隔离诊断中 known-nonempty exact/raw 为 7 且稳定身份集合一致;guaranteed-zero raw 缺 `hasMore`,exact 返回 `missing_pagination` | +| `+list-submitted` fail-closed | PASS | 先前同租户隔离诊断中 known-nonempty exact/raw 为 14 且稳定身份集合一致;guaranteed-zero raw 缺 `hasMore`,exact 返回 `missing_pagination` | +| `+my-initiated` fail-closed | PASS | 先前同租户隔离诊断中 known-nonempty exact/raw 为 14 且稳定身份集合一致;不回退 raw;guaranteed-zero 缺分页时失败 | +| `+search-forms` | PASS | current HEAD 已知非空 2、保证零命中 0;exact/raw 稳定身份集合一致 | | `+done-approvals`(隐藏兼容) | PASS | 已知非空 7;首屏终态明确 | | `+list-forms` fail-closed | PASS | 非空页缺 continuation 被拒绝;两个 cursor 的 93/93 项重叠 92 项 | | `+approve-by` 确认门禁单测 | PASS | 确认前 0 调用;确认后固定为读待办、读任务、写审批、精确任务读回 | @@ -116,7 +116,7 @@ DWS 现有原子能力还覆盖审批记录、表单 Schema、流程预测、评 ## `DS-oa-004`:编号列表零命中的分页终态 - 优先级:P1;类型:contract insufficient;Owner:OA MCP adapter 与业务接口。 -- 影响:`+list-executed`、`+list-submitted`、`+my-initiated`。current HEAD 的 known-nonempty exact/raw 分别以 7、14、14 个稳定身份完全对齐,且 raw 明确 `hasMore=false`;但 guaranteed-zero-query 的 raw 响应只返回显式空 `values`,缺少 `hasMore`。 +- 影响:`+list-executed`、`+list-submitted`、`+my-initiated`。先前同租户隔离诊断的 known-nonempty exact/raw 分别以 7、14、14 个稳定身份完全对齐,且 raw 明确 `hasMore=false`;但 guaranteed-zero-query 的 raw 响应只返回显式空 `values`,缺少 `hasMore`。这些非公开入口未计入本轮 current-HEAD 发布 PASS。 - 严格处置:空数组不推导分页终态。Shortcut 对缺 `hasMore` 返回 `missing_pagination`,三条命令均为 hidden/unavailable。 - 验收:同一 guaranteed-zero-query 的 raw 响应必须返回布尔 `hasMore=false`;若将来允许 continuation,则必须提供可执行且严格前进的页码/游标,不能依赖集合长度推断。 diff --git a/docs/shortcut-downstream-requirements/report.md b/docs/shortcut-downstream-requirements/report.md index 9a83b9ff..e3558f61 100644 --- a/docs/shortcut-downstream-requirements/report.md +++ b/docs/shortcut-downstream-requirements/report.md @@ -1,7 +1,7 @@ # Report Shortcut 下游业务能力需求规格 -> 日期:2026-08-18 -> DWS 基线:`c0b286c1e10b6fcebdff4ed522d3b1cf30549866` +> 日期:2026-08-20 +> 当前 main 基线:`b6eaf3c5af77b26cf1b1559fb7ea173fd0c5b971` > 对比基线:lark-cli 1.0.87(无 Report 产品域) > 范围:仅 Report Shortcut;证据只记录 PASS 标签与聚合事实 @@ -9,7 +9,7 @@ - 源码 Shortcut:4;公开:4;unavailable:0。 - `+template-search`、`+inbox-list`、`+outbox-list`、`+report-latest` 均声明严格 Result/Safety/Contract,使用 unified output。 -- 30 个连续 19 天窗口覆盖 570 天发件箱历史:总项数 1、非空窗口 1、合同异常 0。该安全只读 fixture 关闭了原先“没有非空发件箱”的误判。 +- 本轮从近期起有界扫描 15 个连续 20 天窗口:总项数 1、非空窗口 1、合同异常 0。该安全只读 fixture 关闭了原先“没有非空发件箱”的误判。 - Report 创建原子能力仍缺少删除/回收能力,本轮没有执行 Report 写入;这不阻塞四条只读 Shortcut,但继续阻止新增写 Shortcut。 ## 2. Residual-gap 分类 @@ -17,8 +17,8 @@ | 能力 | 最终分类 | PASS 证据 | 公开状态 | |---|---|---|---| | `+template-search` | fully unlocked | `PASS-RPT-TEMPLATE-NONEMPTY`:1;`PASS-RPT-TEMPLATE-ZERO`:0;原子完整集合 73 | public | -| `+inbox-list` | fully unlocked | `PASS-RPT-INBOX-PAGES`:20、20、20、20、12 后终止;`PASS-RPT-INBOX-ZERO`:0 | public | -| `+outbox-list` | fully unlocked | `PASS-RPT-OUTBOX-HISTORY`:30 个连续窗口、总项 1、非空窗口 1、合同异常 0;`PASS-RPT-OUTBOX-ZERO`:0 | public | +| `+inbox-list` | fully unlocked | `PASS-RPT-INBOX-PAGES`:16 页、总计 308 后终止;`PASS-RPT-INBOX-ZERO`:0 | public | +| `+outbox-list` | fully unlocked | `PASS-RPT-OUTBOX-HISTORY`:15 个连续窗口、总项 1、非空窗口 1、合同异常 0;`PASS-RPT-OUTBOX-ZERO`:0 | public | | `+report-latest` | fully unlocked | `PASS-RPT-LATEST-READBACK`:候选 1,详情身份精确一致,严格字段 3 | public | | 模板读取、详情、统计 | routed | 使用现有精确原子读取;不重复包装 Shortcut | 非 Shortcut | | 日志提交 | routed | 现有原子写可处理用户明确目标;无清理能力,不作为自动 E2E fixture | 非 Shortcut | @@ -31,11 +31,11 @@ Report 的 Shortcut surface 没有 “delivery hardened but downstream blocked | EVERY public Shortcut | Exact Shortcut | Owning atomic/raw | current HEAD 双层证据 | 结论 | |---|---|---|---|---| | `report +template-search` | `dws report +template-search --query --format json` | `dws report template list --format json` 后对同一完整集合执行同语义本地筛选 | 已知 1/1,稳定模板 ID 集合一致;随机 UUID 0/0;原子完整集合 73 且接口无 cursor surface | `PASS-RPT-TEMPLATE-DOUBLE`;fully unlocked | -| `report +inbox-list` | `dws report +inbox-list --start --end --cursor 0 --size 20 --format json` | `dws report inbox list` 使用完全相同参数;从 `_internalDetailCommands` 取得同页精确 `reportId` | 已知 20/20,稳定 ID 集合一致,exact/raw next cursor 一致;独立未来范围 0/0 且双方明确终止 | `PASS-RPT-INBOX-DOUBLE`;fully unlocked | +| `report +inbox-list` | `dws report +inbox-list --start --end --cursor 0 --size 20 --format json` | `dws report inbox list` 使用完全相同参数;从 `_internalDetailCommands` 取得同页精确 `reportId` | 16 页总计 308,逐页稳定 ID 集合与 next cursor 一致、全局身份无重复;独立未来范围 0/0 且双方明确终止 | `PASS-RPT-INBOX-DOUBLE`;fully unlocked | | `report +outbox-list` | `dws report +outbox-list --start --end --cursor 0 --size 20 --format json` | `dws report outbox list` 使用完全相同参数;从 `_internalDetailCommands` 取得同页精确 `reportId` | 已知 1/1,稳定 ID 集合一致且双方明确终止;独立未来范围 0/0 且双方明确终止 | `PASS-RPT-OUTBOX-DOUBLE`;fully unlocked | | `report +report-latest` | `dws report +report-latest --start --end --format json` | 同范围 `dws report outbox list`,再以所选稳定 ID 执行 `dws report entry get --report-id --format json` | exact ID、原子候选 ID、原子详情 ID 三者一致;详情字段计数均为 3,候选页明确终止 | `PASS-RPT-LATEST-DOUBLE`;fully unlocked | -`+inbox-list` 的长期分页证据仍为 20、20、20、20、12 后明确终止,累计 92,首对相邻页身份交集 0;`+outbox-list` 的 fixture 扫描仍为 30 个连续 19 天窗口、总项 1、非空窗口 1、合同异常 0。这里的零项均来自独立保证零范围,不是已知非空集合后的空页。 +`+inbox-list` 的 current-HEAD 分页证据为 16 页后明确终止,累计 308,逐页 exact/raw 身份一致且全局身份无重复;`+outbox-list` 的 fixture 扫描为 15 个连续 20 天窗口、总项 1、非空窗口 1、合同异常 0。这里的零项均来自独立保证零范围,不是已知非空集合后的空页。 所有列表/搜索严格拒绝空响应、缺或错型 `success`、缺或错型集合、坏元素和重复身份;带分页的列表还拒绝缺 continuation、空页续页和不前进 cursor。服务端在终页回显的整数 cursor 只作为页面收据,不发布为 `next_token`。 @@ -46,7 +46,7 @@ Report 的 Shortcut surface 没有 “delivery hardened but downstream blocked ### `DS-Report-001` — 非空发件箱与最新详情链路 - 状态:`PASS-RPT-RESIDUAL-CLOSED`。 -- 归因修正:原先只检查近期窗口,错误地把账号判定为没有安全非空 fixture。扩大为连续 570 天有界 exact 扫描后发现 1 个非空窗口。 +- 归因修正:原先只检查近期窗口,错误地把账号判定为没有安全非空 fixture。本轮从近期起按 20 天窗口有界扫描,在第 15 个窗口发现 1 个非空窗口。 - 验收事实:exact `+outbox-list` 的非空、零项、稳定身份和终止证据均通过;exact `+report-latest` 新增成对 `--start/--end` 参数后,在同一窗口完成一次完整列表与一次精确详情读取,身份一致。 - 确定性:若最高 `createTime` 并列,命令拒绝任意选取;显式窗口缺一端、倒序或超过 20 天均在远端调用前失败。 From fd2ed2174f00ec28049fe9bf9337b955e0dfce81 Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 12:20:51 +0800 Subject: [PATCH 18/28] chore(release): add OA DING Report fragment --- .changes/oa-ding-report-shortcuts.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/oa-ding-report-shortcuts.md diff --git a/.changes/oa-ding-report-shortcuts.md b/.changes/oa-ding-report-shortcuts.md new file mode 100644 index 00000000..c24c62e4 --- /dev/null +++ b/.changes/oa-ding-report-shortcuts.md @@ -0,0 +1,5 @@ +--- +category: Changed +--- + +- **OA, DING, and Report shortcuts** — hardens response, identity, pagination, and confirmation contracts; publishes verified form search, receiver status, and report read workflows while withholding shortcuts that lack trustworthy downstream evidence. From 3b5cb3b0cbf9c74dccb34685f843366f8086fc80 Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 14:01:14 +0800 Subject: [PATCH 19/28] test(shortcut): close OA DING Report coverage gaps --- ...ing_strict_cross_platform_coverage_test.go | 166 +++++- internal/shortcut/oa/oa.go | 10 +- .../oa_strict_cross_platform_coverage_test.go | 487 ++++++++++++++++++ internal/shortcut/oa/smart.go | 15 +- ...ort_strict_cross_platform_coverage_test.go | 303 +++++++++++ 5 files changed, 959 insertions(+), 22 deletions(-) diff --git a/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go index b6fba7eb..a5dace6e 100644 --- a/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "io" + "math" "strings" "testing" @@ -22,11 +23,17 @@ import ( type dingCoverageCaller struct { responses map[string][]string + failures map[string]error history []string + arguments []map[string]any } -func (caller *dingCoverageCaller) CallTool(_ context.Context, _, tool string, _ map[string]any) (*edition.ToolResult, error) { +func (caller *dingCoverageCaller) CallTool(_ context.Context, _, tool string, arguments map[string]any) (*edition.ToolResult, error) { caller.history = append(caller.history, tool) + caller.arguments = append(caller.arguments, arguments) + if err := caller.failures[tool]; err != nil { + return nil, err + } queue := caller.responses[tool] if len(queue) == 0 { return nil, errors.New("missing DING fake response for " + tool) @@ -52,6 +59,20 @@ func runDingCoverage(t *testing.T, declaration shortcut.Shortcut, caller *dingCo return cmd, cmd.Execute() } +func runDingCoverageOutput(t *testing.T, declaration shortcut.Shortcut, caller *dingCoverageCaller, args ...string) ([]byte, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + err := cmd.Execute() + return stdout.Bytes(), err +} + func TestCrossPlatformCoverageDINGContractsAreStrictTypedAndUnified(t *testing.T) { for _, declaration := range []shortcut.Shortcut{List, ReceiverStatus, SendPersonal, SendByMessage, RecallPersonal} { if declaration.Contract.Empty() { @@ -103,7 +124,7 @@ func TestCrossPlatformCoverageDINGListResponseMatrix(t *testing.T) { "empty": {}, "missing success": {"result": map[string]any{"dingMessages": []any{}, "hasMore": false}}, "wrong success": {"success": "true", "result": map[string]any{"dingMessages": []any{}, "hasMore": false}}, - "false success": {"success": false, "result": map[string]any{"dingMessages": []any{}, "hasMore": false}}, + "false success": {"success": false, "errorMsg": "fixture failure", "result": map[string]any{"dingMessages": []any{}, "hasMore": false}}, "missing result": {"success": true}, "missing collection": {"success": true, "result": map[string]any{"hasMore": false}}, "wrong collection": {"success": true, "result": map[string]any{"dingMessages": map[string]any{}, "hasMore": false}}, @@ -123,6 +144,40 @@ func TestCrossPlatformCoverageDINGListResponseMatrix(t *testing.T) { t.Errorf("%s returned success: %#v", name, projected) } } + if _, _, err := dingProjectMessages(map[string]any{"success": false, "errorMsg": "fixture failure"}, "im/list_ding_messages"); err == nil || !strings.Contains(err.Error(), "fixture failure") { + t.Fatalf("remote failure message was not preserved: %v", err) + } +} + +func TestCrossPlatformCoverageDINGIntegerRepresentations(t *testing.T) { + for name, test := range map[string]struct { + value any + want int64 + }{ + "float64": {value: float64(1), want: 1}, + "int": {value: int(2), want: 2}, + "int64": {value: int64(3), want: 3}, + "json number": {value: json.Number("4"), want: 4}, + } { + t.Run(name, func(t *testing.T) { + if got, ok := dingInteger(test.value); !ok || got != test.want { + t.Fatalf("dingInteger(%T(%v))=(%d,%t), want (%d,true)", test.value, test.value, got, ok, test.want) + } + }) + } + for name, value := range map[string]any{ + "nan": math.NaN(), + "fraction": 1.5, + "overflow": math.MaxFloat64, + "bad json number": json.Number("not-an-integer"), + "wrong type": "1", + } { + t.Run(name, func(t *testing.T) { + if got, ok := dingInteger(value); ok { + t.Fatalf("dingInteger(%T(%v))=(%d,true), want invalid", value, value, got) + } + }) + } } func TestCrossPlatformCoverageDINGReceiverResponseMatrix(t *testing.T) { @@ -136,6 +191,7 @@ func TestCrossPlatformCoverageDINGReceiverResponseMatrix(t *testing.T) { "missing collection": {"success": true, "result": map[string]any{}}, "empty collection": {"success": true, "result": map[string]any{"receivers": []any{}}}, "bad item": {"success": true, "result": map[string]any{"receivers": []any{"bad"}}}, + "missing item id": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"confirmedStatus": float64(1), "receiverNick": "fixture"}}}}, "identity mismatch": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "other", "confirmedStatus": float64(1), "receiverNick": "fixture"}}}}, "wrong status": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": "1", "receiverNick": "fixture"}}}}, "missing receiver": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": float64(1)}}}}, @@ -146,6 +202,112 @@ func TestCrossPlatformCoverageDINGReceiverResponseMatrix(t *testing.T) { } } +func TestCrossPlatformCoverageDINGListExecutionFailuresPaginationAndLegacy(t *testing.T) { + t.Run("negative cursor validates before call", func(t *testing.T) { + caller := &dingCoverageCaller{responses: map[string][]string{}} + if _, err := runDingCoverage(t, List, caller, "--cursor", "-1"); err == nil || len(caller.history) != 0 { + t.Fatalf("negative cursor error=%v calls=%v", err, caller.history) + } + }) + + t.Run("transport failure", func(t *testing.T) { + caller := &dingCoverageCaller{ + responses: map[string][]string{}, + failures: map[string]error{"list_ding_messages": errors.New("fixture transport failure")}, + } + if _, err := runDingCoverage(t, List, caller); err == nil || strings.Join(caller.history, ",") != "list_ding_messages" { + t.Fatalf("transport error=%v calls=%v", err, caller.history) + } + }) + + t.Run("projection failure", func(t *testing.T) { + caller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_messages": {`{"success":true,"result":{"dingMessages":[],"hasMore":true,"nextCursor":2}}`}, + }} + if _, err := runDingCoverage(t, List, caller); err == nil { + t.Fatal("malformed empty continuation returned success") + } + }) + + t.Run("stalled cursor", func(t *testing.T) { + caller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_messages": {`{"success":true,"result":{"dingMessages":[{"openDingId":"ding-1"}],"hasMore":true,"nextCursor":2}}`}, + }} + if _, err := runDingCoverage(t, List, caller, "--cursor", "2"); err == nil || !strings.Contains(err.Error(), "nextCursor") { + t.Fatalf("stalled cursor error=%v", err) + } + }) + + t.Run("advancing cursor legacy output", func(t *testing.T) { + legacy := List + legacy.OutputRollout = output.RolloutLegacyOnly + caller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_messages": {`{"success":true,"result":{"dingMessages":[{"openDingId":"ding-1"}],"hasMore":true,"nextCursor":3}}`}, + }} + stdout, err := runDingCoverageOutput(t, legacy, caller, "--cursor", "2") + if err != nil { + t.Fatalf("legacy advancing page: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(stdout, &payload); err != nil || payload["nextCursor"] != "3" || payload["complete"] != false { + t.Fatalf("legacy payload=%#v decode=%v", payload, err) + } + if len(caller.arguments) != 1 || caller.arguments[0]["cursor"] != 2 { + t.Fatalf("legacy cursor arguments=%#v", caller.arguments) + } + }) + + t.Run("invalid unified page evidence", func(t *testing.T) { + cmd := corecmd.New(shortcut.FromShortcut(List)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + rt := shortcut.RuntimeContextForTest(cmd, List) + if err := outputDingPage(rt, nil, dingPageEvidence{HasMore: true}); err == nil { + t.Fatal("continuation without next cursor returned success") + } + }) +} + +func TestCrossPlatformCoverageDINGReceiverExecutionFailuresAndLegacy(t *testing.T) { + t.Run("transport failure", func(t *testing.T) { + caller := &dingCoverageCaller{ + responses: map[string][]string{}, + failures: map[string]error{"list_ding_receiver_status": errors.New("fixture transport failure")}, + } + if _, err := runDingCoverage(t, ReceiverStatus, caller, "--ding-id", "ding-1"); err == nil { + t.Fatal("receiver transport failure returned success") + } + }) + + t.Run("projection failure", func(t *testing.T) { + caller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_receiver_status": {`{"success":true,"result":{"receivers":[]}}`}, + }} + if _, err := runDingCoverage(t, ReceiverStatus, caller, "--ding-id", "ding-1"); err == nil { + t.Fatal("empty receiver response returned success") + } + }) + + t.Run("legacy output", func(t *testing.T) { + legacy := ReceiverStatus + legacy.OutputRollout = output.RolloutLegacyOnly + caller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_receiver_status": {`{"success":true,"result":{"receivers":[{"openDingId":"ding-1","confirmedStatus":1,"receiverNick":"fixture"}]}}`}, + }} + stdout, err := runDingCoverageOutput(t, legacy, caller, "--ding-id", "ding-1") + if err != nil { + t.Fatalf("receiver legacy output: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(stdout, &payload); err != nil || payload["count"] != float64(1) { + t.Fatalf("receiver legacy payload=%#v decode=%v", payload, err) + } + if len(caller.arguments) != 1 || caller.arguments[0]["openDingId"] != "ding-1" { + t.Fatalf("receiver arguments=%#v", caller.arguments) + } + }) +} + func TestCrossPlatformCoverageDINGExactReadShortcutsProjectUnifiedData(t *testing.T) { listCaller := &dingCoverageCaller{responses: map[string][]string{ "list_ding_messages": {`{"success":true,"result":{"dingMessages":[{"openDingId":"ding-1","dingContent":"fixture"}],"hasMore":false,"nextCursor":null}}`}, diff --git a/internal/shortcut/oa/oa.go b/internal/shortcut/oa/oa.go index 13d3564e..1313a8b9 100644 --- a/internal/shortcut/oa/oa.go +++ b/internal/shortcut/oa/oa.go @@ -37,10 +37,7 @@ func oaInstancePage(rt *shortcut.RuntimeContext, tool string, params map[string] if err != nil { return err } - result, err := oaRequireObject(data, operation, "result") - if err != nil { - return err - } + result, _ := data["result"].(map[string]any) evidence, err := oaHasMorePage(result, operation, page) if err != nil { return err @@ -148,10 +145,7 @@ var ListForms = shortcut.Shortcut{ if err != nil { return err } - result, err := oaRequireObject(data, operation, "result") - if err != nil { - return err - } + result, _ := data["result"].(map[string]any) page, err := oaCursorPage(result, operation, rt.Int("cursor")) if err != nil { return err diff --git a/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go index c63bbcd4..57d783f7 100644 --- a/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "io" + "reflect" "strings" "testing" @@ -57,6 +58,24 @@ func runOACoverage(t *testing.T, declaration shortcut.Shortcut, caller *oaCovera return cmd, cmd.Execute() } +func runOAConfirmedCoverage(t *testing.T, caller *oaCoverageCaller, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + service := &cobra.Command{Use: "oa"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(Approve))) + root.AddCommand(service) + root.SetArgs(append([]string{"oa", "+approve-by"}, args...)) + return root.Execute() +} + func TestCrossPlatformCoverageOAContractsAreTypedAndUnified(t *testing.T) { for _, declaration := range []shortcut.Shortcut{ ListPending, ListForms, SearchForms, ListExecuted, ListSubmitted, @@ -223,3 +242,471 @@ func TestCrossPlatformCoverageOAApproveConfirmationAndReadback(t *testing.T) { t.Fatalf("identity was not preserved: %#v", confirmed.arguments) } } + +func TestCrossPlatformCoverageOACommonHelperBranches(t *testing.T) { + if oaObjectResult("fixture") == nil || oaPostWriteError("oa/test", "fixture", "fixture") == nil { + t.Fatal("typed object result and post-write error must be constructed") + } + + for name, fixture := range map[string]map[string]any{ + "false with backend message": {"success": false, "errorMessage": "fixture"}, + "false without message": {"success": false}, + "null success": {"success": nil}, + "wrong success string": {"success": "TRUE"}, + } { + if err := oaRequireSuccess(fixture, "oa/test"); err == nil { + t.Errorf("%s was accepted", name) + } + } + + if _, ok := oaLookup(map[string]any{"result": "bad"}, "result.values"); ok { + t.Fatal("lookup traversed a non-object") + } + if _, ok := oaLookup(map[string]any{"result": map[string]any{}}, "result.values"); ok { + t.Fatal("lookup accepted a missing segment") + } + for name, fixture := range map[string]map[string]any{ + "nil object": {"success": true, "result": nil}, + "wrong object": {"success": true, "result": "bad"}, + "empty object": {"success": true, "result": map[string]any{}}, + } { + if value, err := oaRequireObject(fixture, "oa/test", "result"); err == nil { + t.Errorf("%s returned object %#v", name, value) + } + } + if value, err := oaRequireObject(map[string]any{"success": false}, "oa/test", "result"); err == nil { + t.Fatalf("failed envelope returned object %#v", value) + } + object, err := oaRequireObject(map[string]any{"success": true, "result": map[string]any{"id": "fixture"}}, "oa/test", "result") + if err != nil || object["id"] != "fixture" { + t.Fatalf("valid object=%#v err=%v", object, err) + } + + for name, value := range map[string]any{ + "string": " fixture ", + "json number": json.Number("42"), + "float": float64(42), + "int": int(42), + "int64": int64(42), + "unsupported": true, + } { + got := oaScalarString(value) + if name == "unsupported" && got != "" { + t.Errorf("unsupported scalar=%q", got) + } + if name != "unsupported" && got == "" { + t.Errorf("%s scalar was empty", name) + } + } + + identityFixture := map[string]any{"id": int64(42)} + if err := oaRequireIdentity(identityFixture, "oa/test", "42", "id"); err != nil { + t.Fatal(err) + } + if err := oaRequireIdentity(map[string]any{}, "oa/test", "", "id"); err == nil { + t.Fatal("missing identity was accepted") + } + if err := oaRequireIdentity(identityFixture, "oa/test", "43", "id"); err == nil { + t.Fatal("mismatched identity was accepted") + } + + formsFixture := map[string]any{"success": true, "result": []any{map[string]any{ + "processCode": "p", "name": "fixture", "iconUrl": "icon", + }}} + forms, err := oaProjectForms(formsFixture, "oa/forms", "result") + if err != nil || forms[0]["iconUrl"] != "icon" { + t.Fatalf("fallback form fields=%#v err=%v", forms, err) + } + + instanceFixture := map[string]any{"success": true, "result": map[string]any{"values": []any{map[string]any{ + "processInstanceId": "i", "processInstanceTitle": "title", "status": json.Number("2"), + "businessId": 7, "originatorUserName": "owner", "createTime": int64(9), + }}}} + instances, err := oaProjectInstances(instanceFixture, "oa/instances", "result.values") + if err != nil { + t.Fatal(err) + } + wantInstance := map[string]any{"processInstanceId": "i", "title": "title", "status": "2", "businessId": "7", "originatorName": "owner", "createTime": int64(9)} + if !reflect.DeepEqual(instances[0], wantInstance) { + t.Fatalf("projected instance=%#v want=%#v", instances[0], wantInstance) + } + + for name, fixture := range map[string]map[string]any{ + "task projection error": {"success": true, "result": map[string]any{}}, + "missing task id": {"success": true, "result": map[string]any{"taskIdList": []any{map[string]any{"x": 1}}}}, + "duplicate task id": {"success": true, "result": map[string]any{"taskIdList": []any{ + map[string]any{"taskId": 1}, map[string]any{"taskId": 1}, + }}}, + } { + if tasks, err := oaProjectTasks(fixture, "oa/tasks"); err == nil { + t.Errorf("%s returned %#v", name, tasks) + } + } + + for name, fixture := range map[string]map[string]any{ + "wrong cursor hasMore": {"hasMore": "true"}, + "terminal conflict": {"hasMore": false, "nextCursor": 1}, + } { + if page, err := oaCursorPage(fixture, "oa/cursor", 0); err == nil { + t.Errorf("%s returned %+v", name, page) + } + } + terminal, err := oaCursorPage(map[string]any{"hasMore": false}, "oa/cursor", 0) + if err != nil || !terminal.Known || terminal.HasMore || terminal.Next != "" { + t.Fatalf("terminal cursor=%+v err=%v", terminal, err) + } + if _, err := oaHasMorePage(map[string]any{"hasMore": true}, "oa/page", 0); err == nil { + t.Fatal("continuation from invalid current page was accepted") + } + terminal, err = oaHasMorePage(map[string]any{"hasMore": false}, "oa/page", 1) + if err != nil || !terminal.Known || terminal.HasMore { + t.Fatalf("terminal numbered page=%+v err=%v", terminal, err) + } + + if err := validateOAPage(0, 1); err == nil { + t.Fatal("zero page was accepted") + } + if err := validateOAPage(1, 0); err == nil { + t.Fatal("zero limit was accepted") + } + if err := validateOAPage(1, 101); err == nil { + t.Fatal("oversized limit was accepted") + } +} + +func TestCrossPlatformCoverageOAOutputBranches(t *testing.T) { + legacyPage := shortcut.Shortcut{ + Service: "oa", Command: "+coverage-legacy-page", Product: "oa", Description: "fixture", Risk: shortcut.RiskRead, + Execute: func(rt *shortcut.RuntimeContext) error { + return outputOAPage(rt, "items", []map[string]any{{"id": "1"}}, oaPageEvidence{Known: true, HasMore: true, Next: "2"}) + }, + } + _, err := runOACoverage(t, legacyPage, &oaCoverageCaller{responses: map[string][]string{}}) + if err != nil { + t.Fatal(err) + } + unknownPage := legacyPage + unknownPage.Command = "+coverage-unknown-page" + unknownPage.Execute = func(rt *shortcut.RuntimeContext) error { + return outputOAPage(rt, "items", nil, oaPageEvidence{}) + } + if _, err := runOACoverage(t, unknownPage, &oaCoverageCaller{responses: map[string][]string{}}); err == nil { + t.Fatal("unknown pagination was accepted") + } + + legacyComplete := shortcut.Shortcut{ + Service: "oa", Command: "+coverage-legacy-complete", Product: "oa", Description: "fixture", Risk: shortcut.RiskRead, + Execute: func(rt *shortcut.RuntimeContext) error { return outputOACompleteCollection(rt, "items", nil) }, + } + if _, err := runOACoverage(t, legacyComplete, &oaCoverageCaller{responses: map[string][]string{}}); err != nil { + t.Fatal(err) + } + + unifiedComplete := legacyComplete + unifiedComplete.Command = "+coverage-unified-complete" + unifiedComplete.OutputRollout = output.RolloutUnifiedActive + if _, err := runOACoverage(t, unifiedComplete, &oaCoverageCaller{responses: map[string][]string{}}); err != nil { + t.Fatal(err) + } + + badUnifiedPage := legacyPage + badUnifiedPage.Command = "+coverage-unified-bad-page" + badUnifiedPage.OutputRollout = output.RolloutUnifiedActive + badUnifiedPage.Execute = func(rt *shortcut.RuntimeContext) error { + return outputOAPage(rt, "items", nil, oaPageEvidence{Known: true, HasMore: true}) + } + if _, err := runOACoverage(t, badUnifiedPage, &oaCoverageCaller{responses: map[string][]string{}}); err == nil { + t.Fatal("unified continuation without token was accepted") + } +} + +func TestCrossPlatformCoverageOAReadShortcutBranches(t *testing.T) { + expectError := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller { + t.Helper() + caller := &oaCoverageCaller{responses: responses} + if _, err := runOACoverage(t, declaration, caller, args...); err == nil { + t.Fatalf("%s unexpectedly succeeded with args %v", declaration.Command, args) + } + return caller + } + expectSuccess := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller { + t.Helper() + caller := &oaCoverageCaller{responses: responses} + if _, err := runOACoverage(t, declaration, caller, args...); err != nil { + t.Fatalf("%s failed with args %v: %v", declaration.Command, args, err) + } + return caller + } + + validPage := `{"success":true,"result":{"hasMore":false,"values":[{"processInstanceId":"i","title":"fixture"}]}}` + for name, args := range map[string][]string{ + "bad interval": {"--start", "2", "--end", "1"}, + "bad page": {"--start", "1", "--end", "2", "--page", "bad"}, + "bad limit": {"--start", "1", "--end", "2", "--limit", "bad"}, + "zero page": {"--start", "1", "--end", "2", "--page", "0"}, + } { + t.Run("list-pending validate "+name, func(t *testing.T) { + caller := expectError(t, ListPending, map[string][]string{}, args...) + if len(caller.history) != 0 { + t.Fatalf("validation made calls: %v", caller.history) + } + }) + } + t.Run("list-pending call failure", func(t *testing.T) { + expectError(t, ListPending, map[string][]string{"list_pending_approvals": {"__ERROR__"}}, "--start", "1", "--end", "2") + }) + t.Run("list-pending projection failure", func(t *testing.T) { + expectError(t, ListPending, map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"hasMore":false}}`}}, "--start", "1", "--end", "2") + }) + t.Run("list-pending null result fails closed", func(t *testing.T) { + expectError(t, ListPending, map[string][]string{"list_pending_approvals": {`{"success":true,"result":null}`}}, "--start", "1", "--end", "2") + }) + t.Run("list-pending pagination failure", func(t *testing.T) { + expectError(t, ListPending, map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"values":[]}}`}}, "--start", "1", "--end", "2") + }) + t.Run("list-pending full params", func(t *testing.T) { + caller := expectSuccess(t, ListPending, map[string][]string{"list_pending_approvals": {validPage}}, + "--start", "1", "--end", "2", "--page", "2", "--limit", "3", "--query", "fixture") + want := map[string]any{"starTime": 1, "endTime": 2, "pageNum": "2", "pageSize": "3", "query": "fixture"} + if !reflect.DeepEqual(caller.arguments[0], want) { + t.Fatalf("list pending params=%#v want=%#v", caller.arguments[0], want) + } + }) + + for name, args := range map[string][]string{ + "negative cursor": {"--cursor", "-1"}, + "zero limit": {"--limit", "0"}, + "oversized limit": {"--limit", "101"}, + } { + t.Run("list-forms validate "+name, func(t *testing.T) { + expectError(t, ListForms, map[string][]string{}, args...) + }) + } + t.Run("list-forms call failure", func(t *testing.T) { + expectError(t, ListForms, map[string][]string{"list_user_visible_process": {"__ERROR__"}}) + }) + t.Run("list-forms projection failure", func(t *testing.T) { + expectError(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":{"hasMore":false}}`}}) + }) + t.Run("list-forms null result fails closed", func(t *testing.T) { + expectError(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":null}`}}) + }) + t.Run("list-forms pagination failure", func(t *testing.T) { + expectError(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":{"processCodeList":[]}}`}}) + }) + t.Run("list-forms success", func(t *testing.T) { + expectSuccess(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":{"hasMore":true,"nextCursor":2,"processCodeList":[{"processCode":"p","processName":"fixture"}]}}`}}, "--cursor", "1", "--limit", "2") + }) + + t.Run("search empty query", func(t *testing.T) { + expectError(t, SearchForms, map[string][]string{}, "--query", " ") + }) + t.Run("search call failure", func(t *testing.T) { + expectError(t, SearchForms, map[string][]string{"search_form": {"__ERROR__"}}, "--query", "fixture") + }) + t.Run("search projection failure", func(t *testing.T) { + expectError(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":{}}`}}, "--query", "fixture") + }) + t.Run("search known nonempty", func(t *testing.T) { + expectSuccess(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":[{"processCode":"p","processName":"fixture"}]}`}}, "--query", "fixture") + }) + t.Run("search legitimate empty", func(t *testing.T) { + expectSuccess(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":[]}`}}, "--query", "guaranteed-zero-fixture") + }) + + for _, declaration := range []shortcut.Shortcut{ListExecuted, ListSubmitted, ListCc} { + declaration := declaration + tool := map[string]string{"+list-executed": "get_done_tasks", "+list-submitted": "get_submitted_instances", "+list-cc": "get_noticed_instances"}[declaration.Command] + t.Run(declaration.Command+" invalid page", func(t *testing.T) { + expectError(t, declaration, map[string][]string{}, "--page", "bad") + }) + t.Run(declaration.Command+" invalid limit", func(t *testing.T) { + expectError(t, declaration, map[string][]string{}, "--limit", "bad") + }) + t.Run(declaration.Command+" call failure", func(t *testing.T) { + expectError(t, declaration, map[string][]string{tool: {"__ERROR__"}}) + }) + t.Run(declaration.Command+" full params", func(t *testing.T) { + caller := expectSuccess(t, declaration, map[string][]string{tool: {validPage}}, "--page", "2", "--limit", "3", "--query", " fixture ") + want := map[string]any{"pageNumber": "2", "pageSize": "3", "query": "fixture"} + if !reflect.DeepEqual(caller.arguments[0], want) { + t.Fatalf("params=%#v want=%#v", caller.arguments[0], want) + } + }) + } +} + +func TestCrossPlatformCoverageOACompatibilityReadBranches(t *testing.T) { + expectError := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller { + t.Helper() + caller := &oaCoverageCaller{responses: responses} + if _, err := runOACoverage(t, declaration, caller, args...); err == nil { + t.Fatalf("%s unexpectedly succeeded with args %v", declaration.Command, args) + } + return caller + } + expectSuccess := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller { + t.Helper() + caller := &oaCoverageCaller{responses: responses} + if _, err := runOACoverage(t, declaration, caller, args...); err != nil { + t.Fatalf("%s failed with args %v: %v", declaration.Command, args, err) + } + return caller + } + terminal := `{"success":true,"result":{"hasMore":false,"values":[{"processInstanceId":"i","title":"fixture"}]}}` + + for _, declaration := range []shortcut.Shortcut{PendingApprovals, DoneApprovals} { + declaration := declaration + tool := map[string]string{"+pending": "list_pending_approvals", "+done-approvals": "get_done_tasks"}[declaration.Command] + t.Run(declaration.Command+" invalid limit", func(t *testing.T) { + caller := expectError(t, declaration, map[string][]string{}, "--limit", "0") + if len(caller.history) != 0 { + t.Fatalf("validation made calls: %v", caller.history) + } + }) + t.Run(declaration.Command+" call failure", func(t *testing.T) { + expectError(t, declaration, map[string][]string{tool: {"__ERROR__"}}) + }) + t.Run(declaration.Command+" projection failure", func(t *testing.T) { + expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":{"hasMore":false}}`}}) + }) + t.Run(declaration.Command+" null result fails closed", func(t *testing.T) { + expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":null}`}}) + }) + t.Run(declaration.Command+" pagination failure", func(t *testing.T) { + expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":{"values":[]}}`}}) + }) + t.Run(declaration.Command+" refuses incomplete first page", func(t *testing.T) { + expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":{"hasMore":true,"values":[]}}`}}) + }) + t.Run(declaration.Command+" default limit", func(t *testing.T) { + expectSuccess(t, declaration, map[string][]string{tool: {terminal}}) + }) + t.Run(declaration.Command+" explicit limit", func(t *testing.T) { + caller := expectSuccess(t, declaration, map[string][]string{tool: {terminal}}, "--limit", "3") + if caller.arguments[0]["pageSize"] != float64(3) { + t.Fatalf("explicit limit params=%#v", caller.arguments[0]) + } + }) + } + + for name, args := range map[string][]string{ + "bad page": {"--page", "0"}, + "bad limit": {"--limit", "101"}, + } { + t.Run("my-initiated validate "+name, func(t *testing.T) { + expectError(t, MyInitiated, map[string][]string{}, args...) + }) + } + t.Run("my-initiated call failure", func(t *testing.T) { + expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {"__ERROR__"}}) + }) + t.Run("my-initiated projection failure", func(t *testing.T) { + expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":{"hasMore":false}}`}}) + }) + t.Run("my-initiated null result fails closed", func(t *testing.T) { + expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":null}`}}) + }) + t.Run("my-initiated pagination failure", func(t *testing.T) { + expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":{"values":[]}}`}}) + }) + t.Run("my-initiated query and continuation", func(t *testing.T) { + caller := expectSuccess(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":{"hasMore":true,"values":[]}}`}}, "--query", "fixture", "--page", "2", "--limit", "3") + want := map[string]any{"pageNumber": float64(2), "pageSize": float64(3), "query": "fixture"} + if !reflect.DeepEqual(caller.arguments[0], want) { + t.Fatalf("my initiated params=%#v want=%#v", caller.arguments[0], want) + } + }) +} + +func TestCrossPlatformCoverageOARequiredFlagValidationClosures(t *testing.T) { + for _, tc := range []struct { + name string + flagName string + validate func(*shortcut.RuntimeContext) error + }{ + {name: "search forms", flagName: "query", validate: SearchForms.Validate}, + {name: "approve", flagName: "keyword", validate: Approve.Validate}, + } { + t.Run(tc.name, func(t *testing.T) { + declaration := shortcut.Shortcut{ + Service: "oa", Command: "+coverage-" + strings.ReplaceAll(tc.name, " ", "-"), Product: "oa", Description: "fixture", Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{{Name: tc.flagName, Type: shortcut.FlagString}}, + Execute: tc.validate, + } + caller := &oaCoverageCaller{responses: map[string][]string{}} + if _, err := runOACoverage(t, declaration, caller); err == nil { + t.Fatal("empty required semantic value was accepted") + } + if len(caller.history) != 0 { + t.Fatalf("validation made calls: %v", caller.history) + } + }) + } +} + +func TestCrossPlatformCoverageOAApproveFailureLedger(t *testing.T) { + pending := func(values string, hasMore bool) string { + return `{"success":true,"result":{"hasMore":` + map[bool]string{true: "true", false: "false"}[hasMore] + `,"values":` + values + `}}` + } + tasks := func(values string) string { + return `{"success":true,"result":{"taskIdList":` + values + `}}` + } + validPending := pending(`[{"processInstanceId":"instance-1","title":"fixture"}]`, false) + validTasks := tasks(`[{"taskId":1}]`) + + cases := []struct { + name string + responses map[string][]string + }{ + {name: "pending call failure", responses: map[string][]string{"list_pending_approvals": {"__ERROR__"}}}, + {name: "pending projection failure", responses: map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"hasMore":false}}`}}}, + {name: "pending null result", responses: map[string][]string{"list_pending_approvals": {`{"success":true,"result":null}`}}}, + {name: "pending pagination failure", responses: map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"values":[]}}`}}}, + {name: "pending is incomplete", responses: map[string][]string{"list_pending_approvals": {pending(`[{"processInstanceId":"instance-1","title":"fixture"}]`, true)}}}, + {name: "zero matches", responses: map[string][]string{"list_pending_approvals": {pending(`[{"processInstanceId":"instance-1","title":"other"}]`, false)}}}, + {name: "task call failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {"__ERROR__"}}}, + {name: "task projection failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {`{"success":true,"result":{}}`}}}, + {name: "task count is not one", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {tasks(`[]`)}}}, + {name: "task identity is not numeric", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {tasks(`[{"taskId":"not-numeric"}]`)}}}, + {name: "write call failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {"__ERROR__"}}}, + {name: "write business failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {`{"success":false}`}}}, + {name: "write missing success", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {`{"result":{"accepted":true}}`}}}, + {name: "write null success", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {`{"success":null}`}}}, + {name: "readback call failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks, "__ERROR__"}, "approve_processInstance": {`{"success":true}`}}}, + {name: "readback malformed", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks, `{"success":true,"result":{}}`}, "approve_processInstance": {`{"success":true}`}}}, + {name: "write not observed", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks, validTasks}, "approve_processInstance": {`{"success":true}`}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := &oaCoverageCaller{responses: tc.responses} + if err := runOAConfirmedCoverage(t, caller, "--keyword", "fixture", "--yes"); err == nil { + t.Fatalf("failure scenario succeeded; calls=%v", caller.history) + } + }) + } + + t.Run("empty keyword validates before calls", func(t *testing.T) { + caller := &oaCoverageCaller{responses: map[string][]string{}} + if err := runOAConfirmedCoverage(t, caller, "--keyword", " ", "--yes"); err == nil { + t.Fatal("blank keyword succeeded") + } + if len(caller.history) != 0 { + t.Fatalf("blank keyword made calls: %v", caller.history) + } + }) + + t.Run("comment is sent only after confirmation", func(t *testing.T) { + caller := &oaCoverageCaller{responses: map[string][]string{ + "list_pending_approvals": {validPending}, + "list_pending_tasks": {validTasks, tasks(`[]`)}, + "approve_processInstance": {`{"success":true}`}, + }} + if err := runOAConfirmedCoverage(t, caller, "--keyword", "fixture", "--comment", "approved", "--yes"); err != nil { + t.Fatal(err) + } + if caller.arguments[2]["remark"] != "approved" { + t.Fatalf("write args=%#v", caller.arguments[2]) + } + }) +} diff --git a/internal/shortcut/oa/smart.go b/internal/shortcut/oa/smart.go index 6b2ff1ed..134fc0fd 100644 --- a/internal/shortcut/oa/smart.go +++ b/internal/shortcut/oa/smart.go @@ -25,10 +25,7 @@ func oaFirstPageOnly(rt *shortcut.RuntimeContext, tool, collection string, param if err != nil { return err } - result, err := oaRequireObject(data, operation, "result") - if err != nil { - return err - } + result, _ := data["result"].(map[string]any) page, err := oaHasMorePage(result, operation, 1) if err != nil { return err @@ -141,10 +138,7 @@ var MyInitiated = shortcut.Shortcut{ if err != nil { return err } - result, err := oaRequireObject(data, operation, "result") - if err != nil { - return err - } + result, _ := data["result"].(map[string]any) page, err := oaHasMorePage(result, operation, rt.Int("page")) if err != nil { return err @@ -211,10 +205,7 @@ var Approve = shortcut.Shortcut{ if err != nil { return err } - result, err := oaRequireObject(pending, "oa/list_pending_approvals", "result") - if err != nil { - return err - } + result, _ := pending["result"].(map[string]any) page, err := oaHasMorePage(result, "oa/list_pending_approvals", 1) if err != nil { return err diff --git a/internal/shortcut/report/report_strict_cross_platform_coverage_test.go b/internal/shortcut/report/report_strict_cross_platform_coverage_test.go index ad5459ce..48fae36f 100644 --- a/internal/shortcut/report/report_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/report/report_strict_cross_platform_coverage_test.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "io" + "math" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" @@ -56,6 +57,20 @@ func runReportCoverage(t *testing.T, declaration shortcut.Shortcut, caller *repo return cmd, cmd.Execute() } +func directReportRuntime(t *testing.T, declaration shortcut.Shortcut, caller *reportCoverageCaller, args ...string) *shortcut.RuntimeContext { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + if err := cmd.Flags().Parse(args); err != nil { + t.Fatal(err) + } + return shortcut.RuntimeContextForTest(cmd, declaration) +} + func TestCrossPlatformCoverageReportContractsAreStrictTypedAndUnified(t *testing.T) { declarations := []shortcut.Shortcut{InboxList, OutboxList, TemplateSearch, ReportLatest} for _, declaration := range declarations { @@ -320,3 +335,291 @@ func TestCrossPlatformCoverageReportValidationRejectsInvalidRangesBeforeMCP(t *t } } } + +func TestCrossPlatformCoverageReportPrimitiveStrictnessAndProjectionBranches(t *testing.T) { + operation := "report/coverage" + if err := reportRequireSuccess(map[string]any{"success": false, "errorMessage": " rejected "}, operation); err == nil { + t.Fatal("remote failure with message returned success") + } + arrayEntries, container, err := reportListCollection(map[string]any{ + "success": true, + "result": []any{map[string]any{"report_id": "report-array"}}, + "hasMore": false, + }, operation) + if err != nil || len(arrayEntries) != 1 || container["hasMore"] != false { + t.Fatalf("array collection entries=%#v container=%#v err=%v", arrayEntries, container, err) + } + if _, err := reportRequiredString(map[string]any{"reportId": "one", "report_id": "two"}, operation, 0, "reportId", "report_id"); err == nil { + t.Fatal("conflicting aliases returned success") + } + if _, err := reportOptionalString(map[string]any{"name": 1}, operation, 0, "name"); err == nil { + t.Fatal("non-string optional value returned success") + } + + integerCases := []struct { + value any + want int64 + ok bool + }{ + {float64(7), 7, true}, {math.NaN(), 0, false}, {math.Inf(1), 0, false}, + {1.5, 0, false}, {int(8), 8, true}, {int64(9), 9, true}, + {json.Number("10"), 10, true}, {json.Number("bad"), 0, false}, {"11", 0, false}, + } + for _, test := range integerCases { + got, ok := reportInteger(test.value) + if got != test.want || ok != test.ok { + t.Errorf("reportInteger(%#v)=(%d,%t), want (%d,%t)", test.value, got, ok, test.want, test.ok) + } + } + + fullEntry := map[string]any{ + "success": true, + "result": map[string]any{ + "hasMore": true, + "nextCursor": json.Number("2"), + "report_list": []any{map[string]any{ + "report_id": "report-full", "report_template_name": "Weekly", + "senderName": "sender", "senderUserId": "user", "gmtCreate": int(12), "modifyTime": int64(13), + }}, + }, + } + entries, page, err := reportProjectEntries(fullEntry, operation) + if err != nil || len(entries) != 1 || !page.HasMore || page.Next != "2" || entries[0]["modifiedTime"] != int64(13) { + t.Fatalf("full entry entries=%#v page=%+v err=%v", entries, page, err) + } + if err := reportValidateContinuation(page, 1, operation); err != nil { + t.Fatalf("advancing continuation: %v", err) + } + if err := reportValidateContinuation(reportPageEvidence{}, 0, operation); err != nil { + t.Fatalf("terminal continuation: %v", err) + } + if err := reportValidateContinuation(reportPageEvidence{HasMore: true, Next: "bad"}, 0, operation); err == nil { + t.Fatal("malformed continuation returned success") + } + badOptional := map[string]any{ + "success": true, "hasMore": false, + "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1", "senderName": 1}}}, + } + if _, _, err := reportProjectEntries(badOptional, operation); err == nil { + t.Fatal("bad optional entry field returned success") + } + + completeDetail := map[string]any{"success": true, "result": map[string]any{ + "reportId": "report-detail", "reportName": "Title", "templateName": "Template", "senderName": "Creator", + "create_time": int(4), "modified_time": json.Number("5"), + "report_content": []any{map[string]any{"key": "field", "value": "", "sort": int(1), "type": int64(2)}}, + }} + detail, err := reportProjectDetail(completeDetail, operation, "report-detail") + if err != nil || detail["modifiedTime"] != int64(5) || len(detail["fields"].([]map[string]any)) != 1 { + t.Fatalf("complete detail=%#v err=%v", detail, err) + } + for name, fixture := range map[string]map[string]any{ + "bad optional string": {"success": true, "result": map[string]any{"reportId": "report-detail", "reportName": 1, "report_content": []any{}}}, + "bad optional integer": {"success": true, "result": map[string]any{"reportId": "report-detail", "create_time": "4", "report_content": []any{}}}, + "missing field value": {"success": true, "result": map[string]any{"reportId": "report-detail", "report_content": []any{map[string]any{"key": "field", "sort": 1, "type": 2}}}}, + } { + if projected, projectErr := reportProjectDetail(fixture, operation, "report-detail"); projectErr == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } +} + +func TestCrossPlatformCoverageReportValidationHelpersCoverAllContracts(t *testing.T) { + if _, err := reportParseISOMillis("start", "not-a-time"); err == nil { + t.Fatal("invalid ISO timestamp returned success") + } + if err := reportValidatePage(-1, 20); err == nil { + t.Fatal("negative cursor returned success") + } + if err := reportValidatePage(0, 0); err == nil { + t.Fatal("zero page size returned success") + } + if err := reportValidatePage(0, 20); err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name, start, end string + maximumDays int + }{ + {"bad start", "bad", "2026-07-02T00:00:00+08:00", 20}, + {"bad end", "2026-07-01T00:00:00+08:00", "bad", 20}, + {"too wide", "2026-07-01T00:00:00+08:00", "2026-08-01T00:00:00+08:00", 20}, + } { + if _, _, err := reportValidateRange("start", test.start, "end", test.end, test.maximumDays); err == nil { + t.Errorf("%s returned success", test.name) + } + } + if _, _, err := reportValidateRange("start", "2026-07-01T00:00:00+08:00", "end", "2026-08-01T00:00:00+08:00", 0); err != nil { + t.Fatalf("unbounded valid range: %v", err) + } +} + +func TestCrossPlatformCoverageReportExecutorsSuccessErrorsAndZeroCalls(t *testing.T) { + start := "2026-07-01T00:00:00+08:00" + end := "2026-07-02T00:00:00+08:00" + validList := `{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]} ,"hasMore":false}` + + inbox := &reportCoverageCaller{responses: map[string][]string{"get_received_report_list": {validList}}} + if _, err := runReportCoverage(t, InboxList, inbox, "--start", start, "--end", end, "--sender-user-ids", "user-1,user-2"); err != nil { + t.Fatal(err) + } + if len(inbox.history) != 1 || len(inbox.history[0].args["senderUserIds"].([]string)) != 2 { + t.Fatalf("inbox args/history=%#v", inbox.history) + } + for _, test := range []struct { + name string + caller *reportCoverageCaller + args []string + }{ + {"call error", &reportCoverageCaller{responses: map[string][]string{}}, []string{"--start", start, "--end", end}}, + {"projection error", &reportCoverageCaller{responses: map[string][]string{"get_received_report_list": {`{"success":true}`}}}, []string{"--start", start, "--end", end}}, + {"stalled cursor", &reportCoverageCaller{responses: map[string][]string{"get_received_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1"}]},"hasMore":true,"nextCursor":2}`}}}, []string{"--start", start, "--end", end, "--cursor", "2"}}, + } { + if _, err := runReportCoverage(t, InboxList, test.caller, test.args...); err == nil { + t.Errorf("inbox %s returned success", test.name) + } + } + directInbox := directReportRuntime(t, InboxList, &reportCoverageCaller{responses: map[string][]string{}}, "--start", "bad", "--end", end) + if err := InboxList.Execute(directInbox); err == nil { + t.Fatal("direct inbox invalid range returned success") + } + + outboxAll := &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-2","createTime":2}]},"hasMore":true,"nextCursor":3}`}}} + if _, err := runReportCoverage(t, OutboxList, outboxAll, + "--cursor", "2", "--start", start, "--end", end, + "--modified-start", start, "--modified-end", end, "--template-name", " Weekly "); err != nil { + t.Fatal(err) + } + if len(outboxAll.history) != 1 || outboxAll.history[0].args["report_template_name"] != "Weekly" || outboxAll.history[0].args["modifiedStartTime"] == nil { + t.Fatalf("outbox args/history=%#v", outboxAll.history) + } + defaultOutbox := &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {validList}}} + if _, err := runReportCoverage(t, OutboxList, defaultOutbox); err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name string + caller *reportCoverageCaller + args []string + }{ + {"call error", &reportCoverageCaller{responses: map[string][]string{}}, nil}, + {"projection error", &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {`{"success":true}`}}}, nil}, + {"stalled cursor", &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1"}]},"hasMore":true,"nextCursor":2}`}}}, []string{"--cursor", "2"}}, + } { + if _, err := runReportCoverage(t, OutboxList, test.caller, test.args...); err == nil { + t.Errorf("outbox %s returned success", test.name) + } + } + directOutbox := directReportRuntime(t, OutboxList, &reportCoverageCaller{responses: map[string][]string{}}, "--start", "bad", "--end", end) + if err := OutboxList.Execute(directOutbox); err == nil { + t.Fatal("direct outbox bad creation range returned success") + } + directOutbox = directReportRuntime(t, OutboxList, &reportCoverageCaller{responses: map[string][]string{}}, + "--modified-start", "bad", "--modified-end", end) + if err := OutboxList.Execute(directOutbox); err == nil { + t.Fatal("direct outbox bad modified range returned success") + } + + for _, test := range []struct { + name string + declaration shortcut.Shortcut + args []string + }{ + {"outbox invalid page", OutboxList, []string{"--size", "0"}}, + {"outbox invalid creation range", OutboxList, []string{"--start", "bad", "--end", end}}, + {"outbox valid modified range", OutboxList, []string{"--modified-start", start, "--modified-end", end}}, + {"template empty query", TemplateSearch, []string{"--query", " "}}, + } { + caller := &reportCoverageCaller{responses: map[string][]string{}} + _, err := runReportCoverage(t, test.declaration, caller, test.args...) + if test.name == "outbox valid modified range" { + if err == nil || len(caller.history) != 1 { + t.Errorf("%s err=%v history=%#v", test.name, err, caller.history) + } + continue + } + if err == nil || len(caller.history) != 0 { + t.Errorf("%s err=%v history=%#v", test.name, err, caller.history) + } + } + + for name, caller := range map[string]*reportCoverageCaller{ + "call error": {responses: map[string][]string{}}, + "projection error": {responses: map[string][]string{"get_available_report_templates": {`{"success":true}`}}}, + } { + if _, err := runReportCoverage(t, TemplateSearch, caller, "--query", "weekly"); err == nil { + t.Errorf("template %s returned success", name) + } + } + directTemplate := directReportRuntime(t, TemplateSearch, &reportCoverageCaller{responses: map[string][]string{}}, "--query", " ") + if err := TemplateSearch.Validate(directTemplate); err == nil { + t.Fatal("direct template whitespace query returned success") + } + legacyTemplate := TemplateSearch + legacyTemplate.OutputRollout = output.RolloutLegacyOnly + legacyTemplateCaller := &reportCoverageCaller{responses: map[string][]string{"get_available_report_templates": {`{"success":true,"items":[]}`}}} + if _, err := runReportCoverage(t, legacyTemplate, legacyTemplateCaller, "--query", "zero"); err != nil { + t.Fatalf("legacy template: %v", err) + } +} + +func TestCrossPlatformCoverageReportLatestExecutorRemainingBranches(t *testing.T) { + validList := `{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]},"hasMore":false}` + validDetail := `{"success":true,"result":{"report_Id":"report-1","report_content":[]}}` + defaultCaller := &reportCoverageCaller{responses: map[string][]string{ + "get_send_report_list": {validList}, "get_report_entry_details": {validDetail}, + }} + if _, err := runReportCoverage(t, ReportLatest, defaultCaller, "--keyword", " Weekly "); err != nil { + t.Fatal(err) + } + if defaultCaller.history[0].args["report_template_name"] != "Weekly" { + t.Fatalf("latest keyword args=%#v", defaultCaller.history[0].args) + } + + for name, caller := range map[string]*reportCoverageCaller{ + "list call error": {responses: map[string][]string{}}, + "list projection error": {responses: map[string][]string{ + "get_send_report_list": {`{"success":true}`}, + }}, + "empty candidates": {responses: map[string][]string{ + "get_send_report_list": {`{"success":true,"result":{"report_list":[]},"hasMore":false}`}, + }}, + "detail call error": {responses: map[string][]string{ + "get_send_report_list": {validList}, + }}, + "detail projection error": {responses: map[string][]string{ + "get_send_report_list": {validList}, "get_report_entry_details": {`{"success":true}`}, + }}, + } { + if _, err := runReportCoverage(t, ReportLatest, caller); err == nil { + t.Errorf("latest %s returned success", name) + } + } + directLatest := directReportRuntime(t, ReportLatest, &reportCoverageCaller{responses: map[string][]string{}}, + "--start", "bad", "--end", "2026-07-02T00:00:00+08:00") + if err := ReportLatest.Execute(directLatest); err == nil { + t.Fatal("direct latest invalid range returned success") + } + if _, err := reportLatestEntryID([]map[string]any{{"createTime": int64(1), "reportId": 1}}, "report/latest"); err == nil { + t.Fatal("non-string latest report identity returned success") + } +} + +func TestCrossPlatformCoverageReportOutputLegacyAndInvalidPagination(t *testing.T) { + command := &cobra.Command{Use: "report-page"} + command.SetOut(io.Discard) + command.SetContext(context.Background()) + declaration := shortcut.Shortcut{Service: "report", Product: "report"} + rt := shortcut.RuntimeContextForTest(command, declaration) + output.SetCommandRollout(command, output.RolloutLegacyOnly) + if err := outputReportPage(rt, "reports", []map[string]any{{"reportId": "report-1"}}, reportPageEvidence{HasMore: true, Next: "2"}); err != nil { + t.Fatalf("legacy report page: %v", err) + } + + ctx, _ := output.WithResultStore(context.Background()) + command.SetContext(ctx) + output.SetCommandRollout(command, output.RolloutUnifiedActive) + if err := outputReportPage(rt, "reports", nil, reportPageEvidence{HasMore: true}); err == nil { + t.Fatal("unified page without continuation returned success") + } +} From c0641dbf64cc5648b628727f1cc26a8cdb4c61e7 Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 16:07:59 +0800 Subject: [PATCH 20/28] fix(shortcut): preserve OA and DING CLI compatibility --- docs/shortcut-downstream-requirements/ding.md | 87 ------------ docs/shortcut-downstream-requirements/oa.md | 125 ------------------ .../report.md | 76 ----------- .../builtin/ding_semantic_catalog_test.go | 23 +++- .../builtin/oa_semantic_catalog_test.go | 26 +++- internal/shortcut/semantic_catalog_ding.json | 3 + internal/shortcut/semantic_catalog_oa.json | 6 + 7 files changed, 48 insertions(+), 298 deletions(-) delete mode 100644 docs/shortcut-downstream-requirements/ding.md delete mode 100644 docs/shortcut-downstream-requirements/oa.md delete mode 100644 docs/shortcut-downstream-requirements/report.md diff --git a/docs/shortcut-downstream-requirements/ding.md b/docs/shortcut-downstream-requirements/ding.md deleted file mode 100644 index d4e1b879..00000000 --- a/docs/shortcut-downstream-requirements/ding.md +++ /dev/null @@ -1,87 +0,0 @@ -# DING Shortcut 下游业务能力需求规格 - -> 日期:2026-08-20 -> 当前 main 基线:`b6eaf3c5af77b26cf1b1559fb7ea173fd0c5b971` -> 对比基线:lark-cli 1.0.87 -> 范围:Shortcut-only DING surface - -## 1. 最终 surface - -- 源码 Shortcut:5;公开:1;unavailable:4。 -- 公开 `+receiver-status` fully unlocked;其余 4 条保持 fail-closed,不能把 DING 整体写效果称为完成。 -- 读取严格化:PASS。只接受显式布尔成功、准确结果路径、正确集合类型、合法元素和稳定 DING 身份。 -- 本地可修缺口:PASS。`app/sms/call` 已在原子 helper 映射为下游要求的 `APP/SMS/PHONE`,未知类型在调用前拒绝。 -- 写门禁:PASS。3 个写 Shortcut 均需用户确认,且当前 capability gate 不发起远端调用;隔离诊断只通过精确 atomic/raw 路径执行。 - -## 2. Residual-gap 分类 - -| 能力 | 最终分类 | 归因 | Shortcut 状态 | -|---|---|---|---| -| `+receiver-status` | fully unlocked | exact 稳定 DING 身份读取返回 1 项且全部匹配 | public | -| `+list` | delivery hardened but downstream blocked | 真实 continuation 不前进并返回重复边界项;本地不能安全猜测跳游标或去重 | unavailable | -| `+send-personal --type app` | delivery hardened but downstream blocked | 稳定 DING 回执已证明;接收项没有稳定接收人身份,撤回无可查询终态 | unavailable | -| `+send-by-message --type app` | delivery hardened but downstream blocked | 源消息和 DING 稳定身份已证明;接收人与清理终态仍不可证明 | unavailable | -| `+send-personal/+send-by-message --type sms/call` | unavailable | 没有隔离号码、显式成本授权和通知残留核验 fixture,本轮未执行 | unavailable | -| `+recall-personal` | delivery hardened but downstream blocked | 撤回只返回布尔成功,不返回目标或终态;撤回后精确接收状态仍保留记录 | unavailable | -| Lark `urgent_app/sms/phone` 用户任务 | routed | 精确原子命令存在;仅在用户明确目标、提醒方式与成本后执行,不等于 Shortcut 已解锁 | 非公开 Shortcut 路线 | - -## 3. Lark 用户任务映射 - -| 用户任务 | DWS 路线 | lark-cli 1.0.87 | 结论 | -|---|---|---|---| -| 查询 DING 历史 | `ding +list` | 无直接任务 | delivery hardened but downstream blocked | -| 查询 DING 接收状态 | `ding +receiver-status` | `im messages read_users` | fully unlocked;平台身份模型不同 | -| 基于消息发送应用内加急 | `ding message send-by-message --type app` | `im messages urgent_app` | routed;Shortcut unavailable | -| 基于消息发送短信加急 | `ding message send-by-message --type sms` | `im messages urgent_sms` | routed,需成本授权;Shortcut unavailable | -| 基于消息发送电话加急 | `ding message send-by-message --type call` | `im messages urgent_phone` | routed,需成本授权;Shortcut unavailable | -| 新建个人 DING | `ding message send-personal` | 无直接任务 | routed;Shortcut unavailable | -| 撤回个人 DING | `ding message recall-personal` | 无直接任务 | routed;Shortcut unavailable | - -## 4. Exact Shortcut 与隔离 raw 证据 - -下表中 `+receiver-status` 是本轮 current-HEAD 发布证据;其余非公开入口为先前同租户隔离诊断,只用于说明下游缺口,不计入本轮公开能力 PASS。 - -| 能力 | Exact Shortcut | Atomic/raw 隔离事实 | 结论 | -|---|---|---|---| -| `+list` | 已知非空 50;保证零项 0;真实续页返回 `stalled_cursor` | continuation 页仍为 50 项、下一游标不前进、相邻稳定身份交集 1;尝试包含式边界后仍不前进 | downstream blocked | -| `+receiver-status` | 精确身份返回 1,全部 DING 身份匹配 | 集合为数组、业务成功为布尔值 | fully unlocked | -| `+send-personal` | capability gate,远端调用 0 | APP 自收件:稳定 DING 回执 1、接收状态 1、DING 身份匹配;稳定接收人身份字段 0 | downstream blocked | -| `+send-by-message` | capability gate,远端调用 0 | 自发源消息稳定任务/消息/会话身份均通过;APP DING 回执 1、接收状态 1;稳定接收人身份字段 0 | downstream blocked | -| `+recall-personal` | capability gate,远端调用 0 | 撤回业务成功为布尔值,但目标回执 0;撤回后精确接收状态仍为 1 | downstream blocked | - -### Current HEAD 公开 Shortcut 双层发布矩阵 - -| EVERY public Shortcut | Exact Shortcut | Owning atomic/raw | 稳定身份与结果 | current HEAD 结论 | -|---|---|---|---|---| -| `ding +receiver-status` | `dws ding +receiver-status --ding-id --format json` | 先以 `dws ding message list --type ALL --format json` 取得同一稳定 ID,再执行 `dws ding message receiver-status --ding-id --format json` | 原子来源页 50;exact/raw 接收项均为 1;请求 DING ID、每项 DING ID 与完整接收行集合一致 | `PASS-DING-RECEIVER-DOUBLE`;fully unlocked | - -这是精确稳定 ID 读取,不是 list/search,因此不制造随机不存在 ID 的“零命中”。实现与严格响应测试位于 `internal/shortcut/ding/ding.go`、`internal/shortcut/ding/common.go` 和 `internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go`。 - -隔离 APP fixture 撤回后,`SEND`、`DELETED`、`ALL` 首屏均未出现目标;但 `+list` 的 continuation 已被证明停滞,首屏缺席不能升级为全局零残留证明。源聊天消息也在执行撤回后仍可按稳定身份读取,因此不能把“记录仍可查询”擅自解释为已删除或未删除。 - -## 5. 下游需求 - -### `DS-DING-001` — 稳定接收人身份读回 - -- 优先级:P1;类型:contract insufficient。 -- 已关闭部分:`PASS-DING-WRITE-RECEIPT`,两种 APP 发送路径均返回唯一稳定 `openDingId`;提醒类型枚举映射已在本地修正。 -- 剩余缺口:接收状态项只有 DING 身份、确认状态和显示名,没有可与请求 `openDingTalkId` 集合精确比对的稳定接收人身份。 -- 验收:返回逐接收人的稳定身份和 typed 受理状态;多接收人集合与请求集合精确一致;缺项、重复项、额外项和错型均机器可检测。 - -### `DS-DING-002` — 可查询撤回终态 - -- 优先级:P1;类型:missing capability。 -- 当前事实:撤回响应不含目标身份或终态;精确接收状态在撤回后仍保留;列表首屏缺席不能越过坏分页证明全局终态。 -- 验收:按同一 `openDingId` 返回 typed `recalled` 终态或提供专用只读查询;重复撤回语义稳定;能够区分完成、处理中、部分撤回和 commit-unknown,并证明无残留通知。 - -### `DS-DING-003` — 游标边界与前进语义 - -- 优先级:P2;类型:contract insufficient。 -- 当前事实:首屏 50 项;真实 continuation 再返回 50 项,下一游标不前进,跨页身份交集 1。对包含式边界加一仍返回不前进页面,排除安全的本地 `+1` 修补。 -- 验收:连续页凭据严格前进;跨页无重复,或合同明确包含式边界且可证明去重不遗漏;终页明确 `hasMore=false`。 - -## 6. Fixture、安全与脱敏 - -- PASS:APP 仅使用当前账号自收件、合成无业务正文与立即撤回;没有执行短信或电话提醒。 -- PASS:没有提交用户、组织、会话、消息、DING 的真实 ID,也没有标题、正文、人名、邮箱、电话、精确业务时间、trace/request ID、token 或原始响应。 -- PASS:文档只保留命令级标签与聚合计数;DING 写 Shortcut 仍为 unavailable,未把原子诊断回执当成公开能力完成。 diff --git a/docs/shortcut-downstream-requirements/oa.md b/docs/shortcut-downstream-requirements/oa.md deleted file mode 100644 index 2eba3c1a..00000000 --- a/docs/shortcut-downstream-requirements/oa.md +++ /dev/null @@ -1,125 +0,0 @@ -# OA Shortcut 下游能力需求 - -当前 main 基线:`b6eaf3c5af77b26cf1b1559fb7ea173fd0c5b971`;Comparator:`lark-cli 1.0.87`;审计日期:2026-08-20。 - -本文只记录脱敏的能力标签与聚合事实。没有资源 ID、业务标题、姓名、邮箱、精确业务时间、trace/request ID 或原始响应。 - -## 结论与 surface - -- 源码 Shortcut:10。 -- 公开:1(`+search-forms`)。 -- 隐藏:9,其中 unavailable 8;`+done-approvals` 是 available 但隐藏的兼容入口。 -- 所有 10 条均声明 `Contract`、`Safety`、`Result` 并启用 unified output;写入口在首次远端调用前由 Runtime 确认门禁阻断。 - -### Residual-gap 分类 - -| Shortcut / 用户路线 | 最终分类 | 依据 | -|---|---|---| -| `+search-forms` | fully unlocked | current HEAD exact/raw:已知非空 2、保证零命中 0,稳定身份集合一致;public | -| `+list-executed` | delivery hardened but downstream blocked | known-nonempty exact/raw 7 个稳定身份一致;guaranteed-zero raw 缺 `hasMore`,严格实现拒绝 | -| `+list-submitted`、`+my-initiated` | delivery hardened but downstream blocked | known-nonempty exact/raw 14 个稳定身份一致;guaranteed-zero raw 缺 `hasMore`,严格实现拒绝 | -| `+done-approvals` | routed | available 的隐藏兼容别名;新调用路由到 `+list-executed` | -| `+list-forms` | delivery hardened but downstream blocked | exact 与 raw 均缺 continuation,改变 cursor 后集合高度重叠 | -| `+list-pending` | delivery hardened but downstream blocked | exact 与 raw 均得到 `success=true`,但 `result.values` 为 `null` 而不是合法空数组 | -| `+pending` | unavailable | 隐藏兼容入口路由到同一受阻待办能力,没有独立安全 fixture | -| `+list-cc` | delivery hardened but downstream blocked | exact 与 raw 隔离到错型业务状态与 `null` 集合,不能解释为合法零项 | -| `+approve-by` | unavailable | 复合编排与确认门禁已实现,但没有隔离待办、终态读回和可恢复清理 fixture | -| 已知实例/任务的详情与写操作 | routed | 使用精确 OA 原子命令;必须由用户提供真实目标并单独确认,不等于 Shortcut 已解锁 | - -OA 不能作为整体宣称 fully unlocked:源码 10 条中仅 1 条 fully unlocked,1 条兼容路由,6 条 delivery hardened but downstream blocked,2 条业务能力 unavailable;Catalog unavailable 共 8 条。 - -### Current HEAD 公开 Shortcut 双层发布矩阵 - -| EVERY public Shortcut | Exact Shortcut | Owning atomic/raw | known-nonempty / guaranteed-zero | current HEAD 结论 | -|---|---|---|---|---| -| `oa +search-forms` | `dws oa +search-forms --query --format json` | `dws oa approval search-forms --query --format json` | 已知查询 2/2,稳定 `processCode` 集合相等;随机唯一查询 0/0 | `PASS-OA-SEARCH-DOUBLE`;fully unlocked | - -该搜索接口一次返回完整匹配集合,不声明 cursor;双层证据验证的是同场景完整集合、稳定身份和独立随机零命中,而不是把已知集合后的空页当零命中。实现与严格响应测试位于 `internal/shortcut/oa/oa.go`、`internal/shortcut/oa/common.go` 和 `internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go`。 - -| Exact Shortcut 证明 | 标签 | 聚合事实 | -|---|---|---| -| `+list-executed` fail-closed | PASS | 先前同租户隔离诊断中 known-nonempty exact/raw 为 7 且稳定身份集合一致;guaranteed-zero raw 缺 `hasMore`,exact 返回 `missing_pagination` | -| `+list-submitted` fail-closed | PASS | 先前同租户隔离诊断中 known-nonempty exact/raw 为 14 且稳定身份集合一致;guaranteed-zero raw 缺 `hasMore`,exact 返回 `missing_pagination` | -| `+my-initiated` fail-closed | PASS | 先前同租户隔离诊断中 known-nonempty exact/raw 为 14 且稳定身份集合一致;不回退 raw;guaranteed-zero 缺分页时失败 | -| `+search-forms` | PASS | current HEAD 已知非空 2、保证零命中 0;exact/raw 稳定身份集合一致 | -| `+done-approvals`(隐藏兼容) | PASS | 已知非空 7;首屏终态明确 | -| `+list-forms` fail-closed | PASS | 非空页缺 continuation 被拒绝;两个 cursor 的 93/93 项重叠 92 项 | -| `+approve-by` 确认门禁单测 | PASS | 确认前 0 调用;确认后固定为读待办、读任务、写审批、精确任务读回 | -| `+list-pending` fail-closed | PASS | exact 与 raw 均隔离到布尔成功、对象结果、`null` 集合;拒绝伪造空数组 | -| `+pending` fail-closed | PASS | 与主入口一致拒绝 `null` 集合;没有独立宽松回退 | -| `+list-cc` fail-closed | PASS | exact 与 raw 均拒绝错型业务状态及 `null` 集合 | - -未列为 fully unlocked 的能力没有安全的已知非空或可逆写 fixture,或下游响应合同本身不合法;`null` 集合不是显式空结果,不能替代合法零项证明。 - -## Lark 1.0.87 用户任务映射 - -Lark approval 共 14 个任务;映射按用户结果而不是命令名判断。 - -| Lark 任务 | DWS 当前路线 | 分类 | 临时处置 | -|---|---|---|---| -| approvals get | `oa approval form-schema` | routed | 使用原子读取 | -| approvals search | `oa +search-forms` | covered | 公开 Shortcut | -| instances initiated | `oa approval list-submitted` | routed | 两条 Shortcut 因 guaranteed-zero 缺分页证据而 unavailable;使用精确原子读取 | -| instances get | `oa approval detail` | routed | 使用原子读取 | -| instances cancel | `oa approval revoke` | routed | 高风险原子写;无可逆 fixture,不新增公开 Shortcut | -| instances cc | `oa approval oa-cc-noticer` | routed | 无安全 fixture,保持原子入口 | -| instances create | `oa approval forecast-process` + `create-instance` | routed | 无可清理实例 fixture,不新增公开 Shortcut | -| tasks query | 四类列表与 `oa approval tasks` | delivery hardened but downstream blocked | 已办/已发起非空可读,但零命中缺分页;待办返回 `null` 集合,抄送返回错型状态与 `null` 集合 | -| tasks add_sign | `oa approval append-task` | routed | 无安全 fixture,保持原子入口 | -| tasks approve | `oa approval approve` / `+approve-by` | routed / unavailable | 原子写需用户确认;复合 Shortcut unavailable | -| tasks reject | `oa approval reject` | routed | 无安全 fixture,保持原子入口 | -| tasks remind | `ding-info` 后串联 DING 发送 | downstream_required | 缺少一个可验证的稳定催办回执合同 | -| tasks rollback | `revert-activities` + `revert-task` | routed | 无可恢复 fixture,保持原子入口 | -| tasks transfer | `redirect-task` | routed | 无安全 fixture,保持原子入口 | - -DWS 现有原子能力还覆盖审批记录、表单 Schema、流程预测、评论、可回退节点等产品原生任务。它们当前应由精确原子命令承担;在取得稳定 Result、fixture 与写后验证前,不为增加数量而包装 Shortcut。 - -## `DS-oa-001`:可证明前进的可见表单分页 - -- 优先级:P1;类型:adapter defect / contract insufficient;Owner:OA MCP adapter 与业务接口。 -- 影响:`oa +list-forms` 当前 unavailable。Exact Shortcut 与同参数 atomic/raw 均显示非空列表,但响应没有 `hasMore`、`nextCursor` 或等价 continuation;改变 cursor 后集合高度重叠,已排除 Shortcut 投影错误。 -- 所需合同: - - 请求明确 `cursor`(首次为 0)与 `pageSize`(1–100)。 - - 成功必须是布尔 `success=true`,并包含显式 `result.processCodeList` 数组。 - - 每个元素必须包含唯一、非空 `processCode` 和非空名称。 - - 必须返回布尔 `hasMore`;`hasMore=true` 时返回非空、与当前值不同且单调前进的 `nextCursor`;`hasMore=false` 时不得返回非空 continuation。 - - cursor 不得被忽略;重复页、回退游标与循环必须返回稳定非成功错误。 - - 明确页面大小上限、总页/总项边界和合法空首页语义。 -- 验收:Exact Shortcut 与 atomic/raw 对同一脱敏 fixture 逐页得到不重复稳定身份集合;已知非空和保证零命中均通过;缺失、重复、循环和错型 continuation 都返回非零。 -- 临时处置:`+list-forms` 保持 hidden/unavailable;需要精确定位定义时使用公开 `+search-forms`。 - -## `DS-oa-002`:可重复的审批读写 E2E fixture - -- 优先级:P1;类型:tenant-or-fixture;Owner:OA 测试租户/权限与测试数据平台。 -- 影响:待办、抄送、任务、同意/拒绝/撤回/转交/加签/退回/催办等用户任务不能完成安全 live proof。当前 raw 待办集合为 `null`;抄送同时存在错型业务状态与 `null` 集合,二者都不是合法空页。 -- 所需 fixture: - - 隔离测试身份与专用审批定义,可创建至少一条待办、一条抄送和一条已办实例。 - - 创建回执返回稳定实例 ID;任务查询返回稳定 taskId 和明确任务状态。 - - 写操作可在隔离资源上完成,且具有确定的恢复、撤回或自动到期路径。 - - fixture 权限最小化,支持在测试结束后验证零残留;不得复用真实业务审批。 -- 验收:每个 list/search 同时通过已知非空与保证零命中;每个写在确认前 0 调用,确认后只写一次,按同一实例和 taskId 精确读回终态并完成清理。 -- 临时处置:相关 Shortcut 保持 hidden/unavailable;精确原子写仅在用户明确确认并提供真实目标时执行,不记为自动化 E2E PASS。 - -## `DS-oa-003`:审批写入的稳定终态回执与读回语义 - -- 优先级:P1;类型:contract insufficient;Owner:OA 业务服务与 MCP adapter。 -- 影响:即使写调用返回 nominal success,也难以区分已提交、处理中、已生效与 commit-unknown,阻止安全公开 `+approve-by` 及其他复合写任务。 -- 所需合同: - - 写回执必须包含 `success=true`、稳定 `processInstanceId`、稳定 `taskId`、终态或 typed pending 状态及操作类型。 - - `success=false`、缺 success、空响应、错型 ID、业务错误码冲突必须非零;不得以请求 echo 作为成功证据。 - - 同一 taskId 的查询必须提供明确 pending/approved/rejected/transferred/rolled_back 状态,或明确从待处理集合移除的强一致性窗口。 - - 异步操作返回可执行查询动作、轮询边界和 terminal failure;超时保留 commit-unknown,禁止自动重试非幂等写。 - - 批量/多步操作提供逐项 ledger;任一失败不得返回整体 success。 -- 验收:Exact Shortcut 写入后以相同实例与 taskId 读回请求关键状态;回执缺失、读回不一致、异步超时和部分失败均机器可检测;安全 fixture 完成恢复与零残留。 -- 临时处置:`+approve-by` 保持 hidden/unavailable;当前实现即使写调用成功,读回无法证明时也返回非成功且标记 execution started。 - -## `DS-oa-004`:编号列表零命中的分页终态 - -- 优先级:P1;类型:contract insufficient;Owner:OA MCP adapter 与业务接口。 -- 影响:`+list-executed`、`+list-submitted`、`+my-initiated`。先前同租户隔离诊断的 known-nonempty exact/raw 分别以 7、14、14 个稳定身份完全对齐,且 raw 明确 `hasMore=false`;但 guaranteed-zero-query 的 raw 响应只返回显式空 `values`,缺少 `hasMore`。这些非公开入口未计入本轮 current-HEAD 发布 PASS。 -- 严格处置:空数组不推导分页终态。Shortcut 对缺 `hasMore` 返回 `missing_pagination`,三条命令均为 hidden/unavailable。 -- 验收:同一 guaranteed-zero-query 的 raw 响应必须返回布尔 `hasMore=false`;若将来允许 continuation,则必须提供可执行且严格前进的页码/游标,不能依赖集合长度推断。 - -## 超越 Lark 的机会 - -在上述两个下游合同和安全 fixture 就绪后,可把 DingTalk 原生的“流程预测→精确实例创建→节点/任务读回→操作记录审计”组合成一条带逐步 ledger 的 Golden Route。它应保持每一步稳定身份、支持 typed pending/partial/commit-unknown,并在创建后的任一步失败时给出可执行恢复动作;在此之前不发布该复合 Shortcut。 diff --git a/docs/shortcut-downstream-requirements/report.md b/docs/shortcut-downstream-requirements/report.md deleted file mode 100644 index e3558f61..00000000 --- a/docs/shortcut-downstream-requirements/report.md +++ /dev/null @@ -1,76 +0,0 @@ -# Report Shortcut 下游业务能力需求规格 - -> 日期:2026-08-20 -> 当前 main 基线:`b6eaf3c5af77b26cf1b1559fb7ea173fd0c5b971` -> 对比基线:lark-cli 1.0.87(无 Report 产品域) -> 范围:仅 Report Shortcut;证据只记录 PASS 标签与聚合事实 - -## 1. 最终 surface - -- 源码 Shortcut:4;公开:4;unavailable:0。 -- `+template-search`、`+inbox-list`、`+outbox-list`、`+report-latest` 均声明严格 Result/Safety/Contract,使用 unified output。 -- 本轮从近期起有界扫描 15 个连续 20 天窗口:总项数 1、非空窗口 1、合同异常 0。该安全只读 fixture 关闭了原先“没有非空发件箱”的误判。 -- Report 创建原子能力仍缺少删除/回收能力,本轮没有执行 Report 写入;这不阻塞四条只读 Shortcut,但继续阻止新增写 Shortcut。 - -## 2. Residual-gap 分类 - -| 能力 | 最终分类 | PASS 证据 | 公开状态 | -|---|---|---|---| -| `+template-search` | fully unlocked | `PASS-RPT-TEMPLATE-NONEMPTY`:1;`PASS-RPT-TEMPLATE-ZERO`:0;原子完整集合 73 | public | -| `+inbox-list` | fully unlocked | `PASS-RPT-INBOX-PAGES`:16 页、总计 308 后终止;`PASS-RPT-INBOX-ZERO`:0 | public | -| `+outbox-list` | fully unlocked | `PASS-RPT-OUTBOX-HISTORY`:15 个连续窗口、总项 1、非空窗口 1、合同异常 0;`PASS-RPT-OUTBOX-ZERO`:0 | public | -| `+report-latest` | fully unlocked | `PASS-RPT-LATEST-READBACK`:候选 1,详情身份精确一致,严格字段 3 | public | -| 模板读取、详情、统计 | routed | 使用现有精确原子读取;不重复包装 Shortcut | 非 Shortcut | -| 日志提交 | routed | 现有原子写可处理用户明确目标;无清理能力,不作为自动 E2E fixture | 非 Shortcut | -| Report Shortcut residual unavailable | unavailable | `PASS-RPT-SURFACE-CLOSURE`:源码 4 条中为 0 | 无 | - -Report 的 Shortcut surface 没有 “delivery hardened but downstream blocked” 项。未来若增加写 Shortcut,仍需先解决下文的写 fixture 生命周期,不得把四条读取已解锁解释为 Report 写能力也已完成。 - -## 3. Exact Shortcut E2E 矩阵 - -| EVERY public Shortcut | Exact Shortcut | Owning atomic/raw | current HEAD 双层证据 | 结论 | -|---|---|---|---|---| -| `report +template-search` | `dws report +template-search --query --format json` | `dws report template list --format json` 后对同一完整集合执行同语义本地筛选 | 已知 1/1,稳定模板 ID 集合一致;随机 UUID 0/0;原子完整集合 73 且接口无 cursor surface | `PASS-RPT-TEMPLATE-DOUBLE`;fully unlocked | -| `report +inbox-list` | `dws report +inbox-list --start --end --cursor 0 --size 20 --format json` | `dws report inbox list` 使用完全相同参数;从 `_internalDetailCommands` 取得同页精确 `reportId` | 16 页总计 308,逐页稳定 ID 集合与 next cursor 一致、全局身份无重复;独立未来范围 0/0 且双方明确终止 | `PASS-RPT-INBOX-DOUBLE`;fully unlocked | -| `report +outbox-list` | `dws report +outbox-list --start --end --cursor 0 --size 20 --format json` | `dws report outbox list` 使用完全相同参数;从 `_internalDetailCommands` 取得同页精确 `reportId` | 已知 1/1,稳定 ID 集合一致且双方明确终止;独立未来范围 0/0 且双方明确终止 | `PASS-RPT-OUTBOX-DOUBLE`;fully unlocked | -| `report +report-latest` | `dws report +report-latest --start --end --format json` | 同范围 `dws report outbox list`,再以所选稳定 ID 执行 `dws report entry get --report-id --format json` | exact ID、原子候选 ID、原子详情 ID 三者一致;详情字段计数均为 3,候选页明确终止 | `PASS-RPT-LATEST-DOUBLE`;fully unlocked | - -`+inbox-list` 的 current-HEAD 分页证据为 16 页后明确终止,累计 308,逐页 exact/raw 身份一致且全局身份无重复;`+outbox-list` 的 fixture 扫描为 15 个连续 20 天窗口、总项 1、非空窗口 1、合同异常 0。这里的零项均来自独立保证零范围,不是已知非空集合后的空页。 - -所有列表/搜索严格拒绝空响应、缺或错型 `success`、缺或错型集合、坏元素和重复身份;带分页的列表还拒绝缺 continuation、空页续页和不前进 cursor。服务端在终页回显的整数 cursor 只作为页面收据,不发布为 `next_token`。 - -实现与严格响应测试位于 `internal/shortcut/report/report.go`、`internal/shortcut/report/latest.go`、`internal/shortcut/report/common.go` 和 `internal/shortcut/report/report_strict_cross_platform_coverage_test.go`;原子命令实现位于 `internal/helpers/report.go`。本文只保留命令模板、PASS 标签与聚合事实,运行时稳定 ID、标题和原始响应均未落盘。 - -## 4. 已关闭需求 - -### `DS-Report-001` — 非空发件箱与最新详情链路 - -- 状态:`PASS-RPT-RESIDUAL-CLOSED`。 -- 归因修正:原先只检查近期窗口,错误地把账号判定为没有安全非空 fixture。本轮从近期起按 20 天窗口有界扫描,在第 15 个窗口发现 1 个非空窗口。 -- 验收事实:exact `+outbox-list` 的非空、零项、稳定身份和终止证据均通过;exact `+report-latest` 新增成对 `--start/--end` 参数后,在同一窗口完成一次完整列表与一次精确详情读取,身份一致。 -- 确定性:若最高 `createTime` 并列,命令拒绝任意选取;显式窗口缺一端、倒序或超过 20 天均在远端调用前失败。 - -## 5. 未解决但不阻塞当前 Shortcut 的下游缺口 - -### `DS-Report-002` — Report 写 fixture 生命周期 - -- 分类:routed;影响未来日志提交类 Shortcut,不影响当前四条只读 Shortcut。 -- 当前事实:原子层可提交日志,但没有按稳定回执删除、回收或证明自动过期的能力;因此本轮写调用数为 0。 -- 解锁条件:专用无业务内容 fixture,确认前 0 写调用,确认后稳定创建回执、精确读回、无条件清理,以及发件箱和详情的零残留证明。 - -## 6. Lark 对齐 - -lark-cli 1.0.87 没有 Report/日志产品域,四条公开 Shortcut 都是 DWS 原生增量能力,不虚构一对一映射: - -| DWS 用户任务 | Lark 映射 | 结论 | -|---|---|---| -| 搜索日志模板 | 无 | DWS 原生;fully unlocked | -| 查看收到的日志 | 无 | DWS 原生;fully unlocked | -| 查看发出的日志 | 无 | DWS 原生;fully unlocked | -| 读取指定窗口内最新日志详情 | 无 | DWS 原生;fully unlocked | - -## 7. 安全与脱敏 - -- PASS:本文不含真实 ID、标题、人名、邮箱、正文、精确业务时间、原始响应、trace/request ID、token 或签名 URL。 -- PASS:真实证据只保留命令级标签和聚合计数,无法反推出具体资源。 -- PASS:没有为测试创建 Report 数据,因此没有新增业务残留。 diff --git a/internal/shortcut/builtin/ding_semantic_catalog_test.go b/internal/shortcut/builtin/ding_semantic_catalog_test.go index f944093d..faf2408c 100644 --- a/internal/shortcut/builtin/ding_semantic_catalog_test.go +++ b/internal/shortcut/builtin/ding_semantic_catalog_test.go @@ -32,7 +32,12 @@ func TestCrossPlatformCoverageDINGSemanticCatalogExactlyCoversRegisteredSurface( if source.Service != "ding" || len(registered) != 5 || len(source.Shortcuts) != 5 { t.Fatalf("service/registered/catalog=%s/%d/%d, want ding/5/5", source.Service, len(registered), len(source.Shortcuts)) } - public, unavailable := 0, 0 + wantCompatibilityVisible := map[string]bool{ + "+list": true, + "+recall-personal": true, + "+send-personal": true, + } + public, unavailable, compatibilityVisible := 0, 0, 0 var missing, stale []string for command, item := range registered { record, ok := source.Shortcuts[command] @@ -59,13 +64,21 @@ func TestCrossPlatformCoverageDINGSemanticCatalogExactlyCoversRegisteredSurface( t.Errorf("%s lacks public Contract/Safety/Result/unified output", command) } } else { - if !item.Hidden || shortcut.InPublicCatalog("ding", command) { - t.Errorf("%s nonpublic shortcut is visible", command) + if record.CompatibilityVisible { + compatibilityVisible++ + if item.Hidden || !item.CompatibilityVisible || availability != shortcut.AvailabilityUnavailable || shortcut.InPublicCatalog("ding", command) { + t.Errorf("%s compatibility-visible boundary drift", command) + } + } else if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog("ding", command) { + t.Errorf("%s nonpublic shortcut visibility drift", command) } if availability == shortcut.AvailabilityUnavailable { unavailable++ } } + if record.CompatibilityVisible != wantCompatibilityVisible[command] { + t.Errorf("%s compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command]) + } } for command := range source.Shortcuts { if _, ok := registered[command]; !ok { @@ -77,7 +90,7 @@ func TestCrossPlatformCoverageDINGSemanticCatalogExactlyCoversRegisteredSurface( if len(missing) > 0 || len(stale) > 0 { t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) } - if public != 1 || unavailable != 4 { - t.Fatalf("public/unavailable=%d/%d, want 1/4", public, unavailable) + if public != 1 || unavailable != 4 || compatibilityVisible != len(wantCompatibilityVisible) { + t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/4/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible)) } } diff --git a/internal/shortcut/builtin/oa_semantic_catalog_test.go b/internal/shortcut/builtin/oa_semantic_catalog_test.go index e5ae28f9..b6e63d4b 100644 --- a/internal/shortcut/builtin/oa_semantic_catalog_test.go +++ b/internal/shortcut/builtin/oa_semantic_catalog_test.go @@ -32,7 +32,15 @@ func TestCrossPlatformCoverageOASemanticCatalogExactlyCoversRegisteredSurface(t if source.Service != "oa" || len(registered) != 10 || len(source.Shortcuts) != 10 { t.Fatalf("service/registered/catalog=%s/%d/%d, want oa/10/10", source.Service, len(registered), len(source.Shortcuts)) } - public, unavailable := 0, 0 + wantCompatibilityVisible := map[string]bool{ + "+list-cc": true, + "+list-executed": true, + "+list-forms": true, + "+list-pending": true, + "+list-submitted": true, + "+my-initiated": true, + } + public, unavailable, compatibilityVisible := 0, 0, 0 var missing, stale []string for command, item := range registered { record, ok := source.Shortcuts[command] @@ -59,13 +67,21 @@ func TestCrossPlatformCoverageOASemanticCatalogExactlyCoversRegisteredSurface(t t.Errorf("%s lacks public Contract/Safety/Result/unified output", command) } } else { - if !item.Hidden || shortcut.InPublicCatalog("oa", command) { - t.Errorf("%s nonpublic shortcut is visible", command) + if record.CompatibilityVisible { + compatibilityVisible++ + if item.Hidden || !item.CompatibilityVisible || availability != shortcut.AvailabilityUnavailable || shortcut.InPublicCatalog("oa", command) { + t.Errorf("%s compatibility-visible boundary drift", command) + } + } else if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog("oa", command) { + t.Errorf("%s nonpublic shortcut visibility drift", command) } if availability == shortcut.AvailabilityUnavailable { unavailable++ } } + if record.CompatibilityVisible != wantCompatibilityVisible[command] { + t.Errorf("%s compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command]) + } } for command := range source.Shortcuts { if _, ok := registered[command]; !ok { @@ -77,7 +93,7 @@ func TestCrossPlatformCoverageOASemanticCatalogExactlyCoversRegisteredSurface(t if len(missing) > 0 || len(stale) > 0 { t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) } - if public != 1 || unavailable != 8 { - t.Fatalf("public/unavailable=%d/%d, want 1/8", public, unavailable) + if public != 1 || unavailable != 8 || compatibilityVisible != len(wantCompatibilityVisible) { + t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/8/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible)) } } diff --git a/internal/shortcut/semantic_catalog_ding.json b/internal/shortcut/semantic_catalog_ding.json index d1a12dfb..d2abf1a8 100644 --- a/internal/shortcut/semantic_catalog_ding.json +++ b/internal/shortcut/semantic_catalog_ding.json @@ -8,6 +8,7 @@ "semantic_delta": "严格验证 success、result.dingMessages、稳定 openDingId 与 hasMore/nextCursor;exact Shortcut 已完成已知非空与保证零项证明,但真实续页返回停滞 cursor,严格实现拒绝虚假分页并保持不可用。", "risk": "read", "public": false, + "compatibility_visible": true, "reviewed": true }, "+receiver-status": { @@ -23,6 +24,7 @@ "semantic_delta": "已修正 app/sms/call 到 APP/SMS/PHONE 的下游枚举映射;隔离自收件 APP fixture 返回稳定 openDingId 并可按该 ID 读回,但接收项只有显示名而没有稳定接收人身份,且撤回无可查询终态,保持不可用。", "risk": "write", "public": false, + "compatibility_visible": true, "reviewed": true }, "+send-by-message": { @@ -37,6 +39,7 @@ "semantic_delta": "个人 DING 撤回返回布尔成功但不回显目标或终态;撤回后精确接收状态仍保留原记录,而消息列表分页已知不可靠,不能把首屏缺席解释为全局终态,保持不可用。", "risk": "high-risk-write", "public": false, + "compatibility_visible": true, "reviewed": true } } diff --git a/internal/shortcut/semantic_catalog_oa.json b/internal/shortcut/semantic_catalog_oa.json index 262d6465..7ebe91a6 100644 --- a/internal/shortcut/semantic_catalog_oa.json +++ b/internal/shortcut/semantic_catalog_oa.json @@ -8,6 +8,7 @@ "semantic_delta": "严格验证 success、result.values、稳定 processInstanceId 与 hasMore;known-nonempty exact/raw 的 7 个稳定身份一致,但 guaranteed-zero-query 的 raw 响应缺少 hasMore,收紧后拒绝并保持不可用。", "risk": "read", "public": false, + "compatibility_visible": true, "reviewed": true }, "+list-submitted": { @@ -15,6 +16,7 @@ "semantic_delta": "严格返回已发起审批实例及页完整性;known-nonempty exact/raw 的 14 个稳定身份一致,但 guaranteed-zero-query 的 raw 响应缺少 hasMore,不能把空数组推断为终页,保持不可用。", "risk": "read", "public": false, + "compatibility_visible": true, "reviewed": true }, "+my-initiated": { @@ -23,6 +25,7 @@ "risk": "read", "primary": "+list-submitted", "public": false, + "compatibility_visible": true, "reviewed": true }, "+search-forms": { @@ -47,6 +50,7 @@ "semantic_delta": "真实非空页不返回 continuation,且 cursor 0/1 的集合高度重叠;严格实现拒绝缺失或停滞游标,下游修复前不可公开。", "risk": "read", "public": false, + "compatibility_visible": true, "reviewed": true }, "+list-pending": { @@ -54,6 +58,7 @@ "semantic_delta": "严格待办投影与分页已实现;exact Shortcut 与同参数 atomic/raw 均隔离到 success=true 但 result.values=null,错型集合被拒绝,且当前没有安全非空待审批 fixture。", "risk": "read", "public": false, + "compatibility_visible": true, "reviewed": true }, "+pending": { @@ -69,6 +74,7 @@ "semantic_delta": "严格抄送列表与分页校验已实现;exact Shortcut 和同参数 atomic/raw 隔离到错型业务状态与 null 集合,不能把响应解释为空页,且当前没有安全非空抄送 fixture。", "risk": "read", "public": false, + "compatibility_visible": true, "reviewed": true }, "+approve-by": { From b19c52f61b50bd9b82eee5c71b38e9486b15a04c Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 16:20:01 +0800 Subject: [PATCH 21/28] fix(oa): make approval keyword normalization explicit --- internal/shortcut/oa/smart.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/shortcut/oa/smart.go b/internal/shortcut/oa/smart.go index 134fc0fd..cc409b9e 100644 --- a/internal/shortcut/oa/smart.go +++ b/internal/shortcut/oa/smart.go @@ -187,13 +187,14 @@ var Approve = shortcut.Shortcut{ Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"keyword"}, Description: "--keyword 去除空白后不能为空,且必须唯一匹配完整待办集合中的一条实例"}}, Tips: []string{`dws oa +approve-by --keyword 报销`, `dws oa +approve-by --keyword 出差单 --comment "同意"`}, Validate: func(rt *shortcut.RuntimeContext) error { - if rt.Str("keyword") == "" { + keyword := strings.TrimSpace(rt.Str("keyword")) + if keyword == "" { return apperrors.NewValidation("--keyword 不能为空") } return nil }, Execute: func(rt *shortcut.RuntimeContext) error { - keyword := rt.Str("keyword") + keyword := strings.TrimSpace(rt.Str("keyword")) now := time.Now() pending, err := rt.CallMCPData("oa", "list_pending_approvals", map[string]any{ "starTime": float64(now.AddDate(0, 0, -90).UnixMilli()), "endTime": float64(now.UnixMilli()), "query": keyword, From 56c5fb35b8f01fc1b61fb9d257ee36ce5a78cadd Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 16:49:32 +0800 Subject: [PATCH 22/28] chore(policy): retire completed flag migrations --- .../approved-flag-migrations-v1.json | 48 +++++++++++++++---- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json index c1474dd0..e00cf936 100644 --- a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json +++ b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json @@ -71,13 +71,29 @@ "command": "dws chat message send-by-bot", "legacy": { "name": "file-path", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "file"} + "before": { + "present": true, + "type": "string", + "scope": "local" + }, + "after": { + "present": true, + "type": "string", + "hidden": true, + "scope": "local", + "alias_of": "file" + } }, "canonical": { "name": "file", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} + "before": { + "present": false + }, + "after": { + "present": true, + "type": "string", + "scope": "local" + } }, "state": "pending", "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." @@ -86,13 +102,29 @@ "command": "dws chat message send-by-bot", "legacy": { "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} + "before": { + "present": true, + "type": "string", + "scope": "local" + }, + "after": { + "present": true, + "type": "string", + "hidden": true, + "scope": "local", + "alias_of": "content" + } }, "canonical": { "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} + "before": { + "present": false + }, + "after": { + "present": true, + "type": "string", + "scope": "local" + } }, "state": "pending", "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." From 5c9f7380120e7e81f07a8024d2725833c05406af Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 17:08:30 +0800 Subject: [PATCH 23/28] fix(shortcuts): preserve published schema bindings --- internal/shortcut/ding/ding.go | 22 +++++++++---------- ...ing_strict_cross_platform_coverage_test.go | 9 +++++--- internal/shortcut/oa/oa.go | 12 +++++----- internal/shortcut/oa/smart.go | 6 ++--- internal/shortcut/report/report.go | 8 +++---- 5 files changed, 30 insertions(+), 27 deletions(-) diff --git a/internal/shortcut/ding/ding.go b/internal/shortcut/ding/ding.go index 196250cd..d63ed23c 100644 --- a/internal/shortcut/ding/ding.go +++ b/internal/shortcut/ding/ding.go @@ -29,7 +29,7 @@ var List = shortcut.Shortcut{ ), Flags: []shortcut.Flag{ {Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标,首次不传或传 0"}, - {Name: "type", Type: shortcut.FlagString, Default: "ALL", Desc: "DING 类型", Enum: []string{"ALL", "UNREAD", "SEND", "NEW_COMMENT", "DELETED"}}, + {Name: "type", Type: shortcut.FlagString, Default: "ALL", Desc: "DING 类型:ALL、UNREAD、SEND、NEW_COMMENT 或 DELETED"}, }, Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"cursor"}, Description: "--cursor 不能小于 0,续页响应必须返回严格前进的正整数 nextCursor"}}, Tips: []string{"dws ding +list --type ALL", "dws ding +list --type DELETED"}, @@ -74,7 +74,7 @@ var ReceiverStatus = shortcut.Shortcut{ "+receiver-status", "查询 DING 消息接收人已读状态", "已经从 +list 取得稳定 openDingId,需要确认该 DING 的接收状态时使用;这是精确 ID 查询,不是消息搜索。", true, dingReceiverResult(), nil, - []contract.ParamDecl{{Name: "ding-id", Property: "openDingId"}}, + []contract.ParamDecl{{Name: "ding-id", Property: "dingId"}}, "dws ding +receiver-status --ding-id ", ), Flags: []shortcut.Flag{{Name: "ding-id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}}, @@ -101,16 +101,16 @@ var ReceiverStatus = shortcut.Shortcut{ var SendPersonal = unavailableDingWrite( "+send-personal", "以本人身份发送 DING 给指定人", "需要以当前用户身份向明确的 openDingTalkId 接收人发送 DING 时使用;下游提供稳定接收人身份与可查询撤回终态前不可执行。", - shortcut.RiskWrite, false, + shortcut.RiskWrite, false, true, []shortcut.Flag{ {Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true}, {Name: "content", Type: shortcut.FlagString, Desc: "消息内容", Required: true}, - {Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式", Enum: []string{"app", "sms", "call"}}, + {Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式:app、sms 或 call"}, {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键"}, }, []contract.ParamDecl{ - {Name: "users", Property: "receiverOpenDingTalkIds", InterfaceType: "array"}, - {Name: "content", Property: "content"}, {Name: "type", Property: "remindType"}, {Name: "uuid", Property: "uuid"}, + {Name: "users", Property: "users", InterfaceType: "array"}, + {Name: "content", Property: "content"}, {Name: "type", Property: "type"}, {Name: "uuid", Property: "uuid"}, }, "dws ding +send-personal --users --content ", ) @@ -118,7 +118,7 @@ var SendPersonal = unavailableDingWrite( var SendByMessage = unavailableDingWrite( "+send-by-message", "针对某条消息发起 DING 提醒", "需要把指定聊天消息转成应用内、短信或电话 DING 时使用;对应 lark-cli 的三种 urgent 任务,但接收人稳定身份与精确撤回终态仍缺失。", - shortcut.RiskWrite, false, + shortcut.RiskWrite, false, false, []shortcut.Flag{ {Name: "group", Type: shortcut.FlagString, Desc: "openConversationId", Required: true}, {Name: "message-id", Type: shortcut.FlagString, Desc: "openMessageId", Required: true}, @@ -137,18 +137,18 @@ var SendByMessage = unavailableDingWrite( var RecallPersonal = unavailableDingWrite( "+recall-personal", "撤回本人发起的 DING", "需要撤回本人发出的 DING 时使用;必须能按稳定目标精确读回撤回终态且证明没有残留通知,当前下游尚不满足。", - shortcut.RiskHighWrite, true, + shortcut.RiskHighWrite, true, true, []shortcut.Flag{{Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}}, - []contract.ParamDecl{{Name: "id", Property: "openDingId"}}, + []contract.ParamDecl{{Name: "id", Property: "id"}}, "dws ding +recall-personal --id ", ) -func unavailableDingWrite(command, description, intent string, risk shortcut.Risk, destructive bool, flags []shortcut.Flag, params []contract.ParamDecl, example string) shortcut.Shortcut { +func unavailableDingWrite(command, description, intent string, risk shortcut.Risk, destructive, schemaCompatible bool, flags []shortcut.Flag, params []contract.ParamDecl, example string) shortcut.Shortcut { return shortcut.Shortcut{ Service: "ding", Command: command, Product: "im", Description: description, Intent: intent, Risk: risk, Safety: dingWriteSafety(destructive), OutputRollout: output.RolloutUnifiedActive, - Contract: dingContract(command, description, intent, false, nil, nil, params, example), + Contract: dingContract(command, description, intent, schemaCompatible, nil, nil, params, example), Flags: flags, Tips: []string{example}, Execute: func(*shortcut.RuntimeContext) error { return dingUnavailable("ding/" + command) diff --git a/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go index a5dace6e..493993d4 100644 --- a/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go @@ -93,11 +93,14 @@ func TestCrossPlatformCoverageDINGContractsAreStrictTypedAndUnified(t *testing.T t.Errorf("%s is not interface-available", declaration.Command) } } - for _, declaration := range []shortcut.Shortcut{SendPersonal, SendByMessage, RecallPersonal} { - if declaration.Safety.Confirmation != "user_required" || declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "unavailable" { - t.Errorf("%s write safety/interface drift", declaration.Command) + for _, declaration := range []shortcut.Shortcut{SendPersonal, RecallPersonal} { + if declaration.Safety.Confirmation != "user_required" || declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" { + t.Errorf("%s compatibility-visible write safety/interface drift", declaration.Command) } } + if SendByMessage.Safety.Confirmation != "user_required" || SendByMessage.Contract.Interface == nil || SendByMessage.Contract.Interface.Availability != "unavailable" { + t.Errorf("%s write safety/interface drift", SendByMessage.Command) + } if List.Contract.Pagination == nil { t.Fatal("+list lacks cursor pagination") } diff --git a/internal/shortcut/oa/oa.go b/internal/shortcut/oa/oa.go index 1313a8b9..17154691 100644 --- a/internal/shortcut/oa/oa.go +++ b/internal/shortcut/oa/oa.go @@ -59,10 +59,10 @@ var ListPending = shortcut.Shortcut{ oaCollectionResult("instances", "严格验证的待处理审批实例页"), oaPagePagination("page"), []contract.ParamDecl{ - {Name: "start", Property: "starTime"}, - {Name: "end", Property: "endTime"}, - {Name: "page", Property: "pageNum"}, - {Name: "limit", Property: "pageSize"}, + {Name: "start", Property: "start"}, + {Name: "end", Property: "end"}, + {Name: "page", Property: "page"}, + {Name: "limit", Property: "limit"}, {Name: "query", Property: "query"}, }, "dws oa +list-pending --start 1741536000000 --end 1741622399000 --page 1 --limit 20", @@ -117,7 +117,7 @@ var ListForms = shortcut.Shortcut{ "+list-forms", "获取当前用户可见的审批表单列表", "需要枚举可发起审批定义并取得稳定 processCode 时使用;当前下游不返回可验证 continuation,故保持 unavailable。", oaCollectionResult("forms", "严格验证的可见审批表单页"), oaPagePagination("cursor"), - []contract.ParamDecl{{Name: "cursor", Property: "cursor"}, {Name: "limit", Property: "pageSize"}}, + []contract.ParamDecl{{Name: "cursor", Property: "cursor"}, {Name: "limit", Property: "limit"}}, "dws oa +list-forms --cursor 0 --limit 100", ), Flags: []shortcut.Flag{ @@ -198,7 +198,7 @@ func oaNumberedInstanceShortcut(command, tool, description, intent string) short Safety: oaReadSafety(), OutputRollout: output.RolloutUnifiedActive, Contract: oaContract(command, description, intent, oaCollectionResult("instances", description), oaPagePagination("page"), - []contract.ParamDecl{{Name: "page", Property: "pageNumber"}, {Name: "limit", Property: "pageSize"}, {Name: "query", Property: "query"}}, + []contract.ParamDecl{{Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}, {Name: "query", Property: "query"}}, "dws oa "+command+" --page 1 --limit 20"), Flags: []shortcut.Flag{ {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码;--page 必须大于 0"}, diff --git a/internal/shortcut/oa/smart.go b/internal/shortcut/oa/smart.go index cc409b9e..655b44bc 100644 --- a/internal/shortcut/oa/smart.go +++ b/internal/shortcut/oa/smart.go @@ -47,7 +47,7 @@ var PendingApprovals = shortcut.Shortcut{ "+pending", "只读列出待我审批的审批任务并投影为可读列表(只看不批)", "兼容旧的待审批摘要入口;新调用优先使用支持显式时间和页码的 +list-pending。", oaCollectionResult("pending", "严格验证的待审批摘要"), nil, - []contract.ParamDecl{{Name: "limit", Property: "pageSize"}}, "dws oa +pending --limit 10", + []contract.ParamDecl{{Name: "limit", Property: "limit"}}, "dws oa +pending --limit 10", ), Flags: []shortcut.Flag{{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)"}}, Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式 --limit 必须在 1-100"}}, @@ -79,7 +79,7 @@ var DoneApprovals = shortcut.Shortcut{ "+done-approvals", "只读列出我已处理过的审批任务(审批历史)并投影为可读列表", "兼容旧的已处理审批摘要入口;需要翻页或搜索时使用 +list-executed。", oaCollectionResult("done", "严格验证的已处理审批摘要"), nil, - []contract.ParamDecl{{Name: "limit", Property: "pageSize"}}, "dws oa +done-approvals --limit 10", + []contract.ParamDecl{{Name: "limit", Property: "limit"}}, "dws oa +done-approvals --limit 10", ), Flags: []shortcut.Flag{{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)"}}, Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式 --limit 必须在 1-100"}}, @@ -110,7 +110,7 @@ var MyInitiated = shortcut.Shortcut{ "+my-initiated", "列出我发起(提交)的审批单据", "需要兼容旧的 initiated 输出字段时使用;一般列表与分页可直接使用 +list-submitted。", oaCollectionResult("initiated", "严格验证的已发起审批实例页"), oaPagePagination("page"), - []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "pageNumber"}, {Name: "limit", Property: "pageSize"}}, + []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}}, "dws oa +my-initiated --page 1 --limit 20", "dws oa +my-initiated --query 报销", ), Flags: []shortcut.Flag{ diff --git a/internal/shortcut/report/report.go b/internal/shortcut/report/report.go index 7413db26..9fb8c52b 100644 --- a/internal/shortcut/report/report.go +++ b/internal/shortcut/report/report.go @@ -86,7 +86,7 @@ var InboxList = shortcut.Shortcut{ "需要按明确时间范围读取别人发给我的日志摘要并取得稳定 reportId 时使用;后端必须提供可验证的终止或严格前进 cursor。", reportCollectionResult("reports", "严格验证的收件箱日志页"), reportPagination(), []contract.ParamDecl{ - {Name: "start", Property: "startTime"}, {Name: "end", Property: "endTime"}, + {Name: "start", Property: "start"}, {Name: "end", Property: "end"}, {Name: "cursor", Property: "cursor"}, {Name: "size", Property: "size"}, {Name: "sender-user-ids", Property: "senderUserIds", InterfaceType: "array"}, }, @@ -153,9 +153,9 @@ var OutboxList = shortcut.Shortcut{ reportCollectionResult("reports", "严格验证的发件箱日志页"), reportPagination(), []contract.ParamDecl{ {Name: "cursor", Property: "cursor"}, {Name: "size", Property: "size"}, - {Name: "start", Property: "startTime"}, {Name: "end", Property: "endTime"}, - {Name: "modified-start", Property: "modifiedStartTime"}, {Name: "modified-end", Property: "modifiedEndTime"}, - {Name: "template-name", Property: "report_template_name"}, + {Name: "start", Property: "start"}, {Name: "end", Property: "end"}, + {Name: "modified-start", Property: "modifiedStart"}, {Name: "modified-end", Property: "modifiedEnd"}, + {Name: "template-name", Property: "templateName"}, }, "dws report +outbox-list --cursor 0 --size 20", ), From 33631502c968130f950f99089f83cb2082b63eb9 Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 20:58:02 +0800 Subject: [PATCH 24/28] fix(shortcuts): align unavailable writes and query validation --- internal/shortcut/ding/ding.go | 56 ++++++++++++-- ...ing_strict_cross_platform_coverage_test.go | 75 +++++++++++++------ internal/shortcut/oa/oa.go | 4 +- .../oa_strict_cross_platform_coverage_test.go | 10 ++- 4 files changed, 112 insertions(+), 33 deletions(-) diff --git a/internal/shortcut/ding/ding.go b/internal/shortcut/ding/ding.go index d63ed23c..8c923b92 100644 --- a/internal/shortcut/ding/ding.go +++ b/internal/shortcut/ding/ding.go @@ -6,6 +6,7 @@ package ding import ( "strconv" + "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" @@ -98,10 +99,10 @@ var ReceiverStatus = shortcut.Shortcut{ }, } -var SendPersonal = unavailableDingWrite( +var SendPersonal = compatibilityDingWrite( "+send-personal", "以本人身份发送 DING 给指定人", "需要以当前用户身份向明确的 openDingTalkId 接收人发送 DING 时使用;下游提供稳定接收人身份与可查询撤回终态前不可执行。", - shortcut.RiskWrite, false, true, + shortcut.RiskWrite, false, []shortcut.Flag{ {Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true}, {Name: "content", Type: shortcut.FlagString, Desc: "消息内容", Required: true}, @@ -113,12 +114,27 @@ var SendPersonal = unavailableDingWrite( {Name: "content", Property: "content"}, {Name: "type", Property: "type"}, {Name: "uuid", Property: "uuid"}, }, "dws ding +send-personal --users --content ", + func(rt *shortcut.RuntimeContext) error { + remindType, err := dingPersonalRemindType(rt.Str("type")) + if err != nil { + return err + } + params := map[string]any{ + "receiverOpenDingTalkIds": rt.StrSlice("users"), + "content": rt.Str("content"), + "remindType": remindType, + } + if rt.Changed("uuid") { + params["uuid"] = rt.Str("uuid") + } + return rt.CallMCP("send_personal_ding", params) + }, ) var SendByMessage = unavailableDingWrite( "+send-by-message", "针对某条消息发起 DING 提醒", "需要把指定聊天消息转成应用内、短信或电话 DING 时使用;对应 lark-cli 的三种 urgent 任务,但接收人稳定身份与精确撤回终态仍缺失。", - shortcut.RiskWrite, false, false, + shortcut.RiskWrite, false, []shortcut.Flag{ {Name: "group", Type: shortcut.FlagString, Desc: "openConversationId", Required: true}, {Name: "message-id", Type: shortcut.FlagString, Desc: "openMessageId", Required: true}, @@ -134,21 +150,34 @@ var SendByMessage = unavailableDingWrite( "dws ding +send-by-message --group --message-id --users ", ) -var RecallPersonal = unavailableDingWrite( +var RecallPersonal = compatibilityDingWrite( "+recall-personal", "撤回本人发起的 DING", "需要撤回本人发出的 DING 时使用;必须能按稳定目标精确读回撤回终态且证明没有残留通知,当前下游尚不满足。", - shortcut.RiskHighWrite, true, true, + shortcut.RiskHighWrite, true, []shortcut.Flag{{Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}}, []contract.ParamDecl{{Name: "id", Property: "id"}}, "dws ding +recall-personal --id ", + func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("recall_personal_ding", map[string]any{"openDingId": rt.Str("id")}) + }, ) -func unavailableDingWrite(command, description, intent string, risk shortcut.Risk, destructive, schemaCompatible bool, flags []shortcut.Flag, params []contract.ParamDecl, example string) shortcut.Shortcut { +func compatibilityDingWrite(command, description, intent string, risk shortcut.Risk, destructive bool, flags []shortcut.Flag, params []contract.ParamDecl, example string, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut { return shortcut.Shortcut{ Service: "ding", Command: command, Product: "im", Description: description, Intent: intent, Risk: risk, Safety: dingWriteSafety(destructive), OutputRollout: output.RolloutUnifiedActive, - Contract: dingContract(command, description, intent, schemaCompatible, nil, nil, params, example), + Contract: dingContract(command, description, intent, true, nil, nil, params, example), + Flags: flags, Tips: []string{example}, Execute: execute, + } +} + +func unavailableDingWrite(command, description, intent string, risk shortcut.Risk, destructive bool, flags []shortcut.Flag, params []contract.ParamDecl, example string) shortcut.Shortcut { + return shortcut.Shortcut{ + Service: "ding", Command: command, Product: "im", + Description: description, Intent: intent, Risk: risk, + Safety: dingWriteSafety(destructive), OutputRollout: output.RolloutUnifiedActive, + Contract: dingContract(command, description, intent, false, nil, nil, params, example), Flags: flags, Tips: []string{example}, Execute: func(*shortcut.RuntimeContext) error { return dingUnavailable("ding/" + command) @@ -156,6 +185,19 @@ func unavailableDingWrite(command, description, intent string, risk shortcut.Ris } } +func dingPersonalRemindType(value string) (string, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "app": + return "APP", nil + case "sms": + return "SMS", nil + case "call": + return "PHONE", nil + default: + return "", apperrors.NewValidation("--type 必须是 app、sms 或 call") + } +} + func init() { shortcut.Register(List, ReceiverStatus, SendPersonal, SendByMessage, RecallPersonal) } diff --git a/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go index 493993d4..90e1bc8f 100644 --- a/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go @@ -95,11 +95,11 @@ func TestCrossPlatformCoverageDINGContractsAreStrictTypedAndUnified(t *testing.T } for _, declaration := range []shortcut.Shortcut{SendPersonal, RecallPersonal} { if declaration.Safety.Confirmation != "user_required" || declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" { - t.Errorf("%s compatibility-visible write safety/interface drift", declaration.Command) + t.Errorf("%s compatibility write safety/interface drift", declaration.Command) } } if SendByMessage.Safety.Confirmation != "user_required" || SendByMessage.Contract.Interface == nil || SendByMessage.Contract.Interface.Availability != "unavailable" { - t.Errorf("%s write safety/interface drift", SendByMessage.Command) + t.Errorf("%s unavailable write safety/interface drift", SendByMessage.Command) } if List.Contract.Pagination == nil { t.Fatal("+list lacks cursor pagination") @@ -348,27 +348,58 @@ func TestCrossPlatformCoverageDINGExactReadShortcutsProjectUnifiedData(t *testin } func TestCrossPlatformCoverageDINGUnavailableWritesNeverCallMCP(t *testing.T) { - cases := []struct { - declaration shortcut.Shortcut - args []string - }{ - {SendPersonal, []string{"--users", "D-fixture", "--content", "fixture"}}, - {SendByMessage, []string{"--group", "cid-fixture", "--message-id", "mid-fixture", "--users", "D-fixture"}}, - {RecallPersonal, []string{"--id", "ding-fixture"}}, + args := []string{"--group", "cid-fixture", "--message-id", "mid-fixture", "--users", "D-fixture"} + unconfirmed := &dingCoverageCaller{responses: map[string][]string{}} + err := runDingRoot(t, SendByMessage, unconfirmed, false, args...) + if err == nil || len(unconfirmed.history) != 0 { + t.Fatalf("unconfirmed error=%v calls=%v", err, unconfirmed.history) } - for _, test := range cases { - t.Run(test.declaration.Command, func(t *testing.T) { - unconfirmed := &dingCoverageCaller{responses: map[string][]string{}} - err := runDingRoot(t, test.declaration, unconfirmed, false, test.args...) - if err == nil || len(unconfirmed.history) != 0 { - t.Fatalf("unconfirmed error=%v calls=%v", err, unconfirmed.history) - } - confirmed := &dingCoverageCaller{responses: map[string][]string{}} - err = runDingRoot(t, test.declaration, confirmed, true, test.args...) - if err == nil || !strings.Contains(err.Error(), "当前不可执行") || len(confirmed.history) != 0 { - t.Fatalf("confirmed unavailable error=%v calls=%v", err, confirmed.history) - } - }) + confirmed := &dingCoverageCaller{responses: map[string][]string{}} + err = runDingRoot(t, SendByMessage, confirmed, true, args...) + if err == nil || !strings.Contains(err.Error(), "当前不可执行") || len(confirmed.history) != 0 { + t.Fatalf("confirmed unavailable error=%v calls=%v", err, confirmed.history) + } +} + +func TestCrossPlatformCoverageDINGCompatibilityWritesRequireConfirmationAndExecute(t *testing.T) { + sendArgs := []string{"--users", "D-fixture", "--content", "fixture", "--type", "call", "--uuid", "fixture-uuid"} + unconfirmedSend := &dingCoverageCaller{responses: map[string][]string{}} + if err := runDingRoot(t, SendPersonal, unconfirmedSend, false, sendArgs...); err == nil || len(unconfirmedSend.history) != 0 { + t.Fatalf("unconfirmed send error=%v calls=%v", err, unconfirmedSend.history) + } + confirmedSend := &dingCoverageCaller{responses: map[string][]string{ + "send_personal_ding": {`{"success":true,"result":{"openDingId":"ding-fixture"}}`}, + }} + if err := runDingRoot(t, SendPersonal, confirmedSend, true, sendArgs...); err != nil { + t.Fatalf("confirmed send failed: %v", err) + } + if strings.Join(confirmedSend.history, ",") != "send_personal_ding" || len(confirmedSend.arguments) != 1 { + t.Fatalf("confirmed send calls=%v args=%v", confirmedSend.history, confirmedSend.arguments) + } + sendParams := confirmedSend.arguments[0] + users, _ := sendParams["receiverOpenDingTalkIds"].([]string) + if strings.Join(users, ",") != "D-fixture" || sendParams["content"] != "fixture" || sendParams["remindType"] != "PHONE" || sendParams["uuid"] != "fixture-uuid" { + t.Fatalf("confirmed send params=%#v", sendParams) + } + + invalidType := &dingCoverageCaller{responses: map[string][]string{}} + if err := runDingRoot(t, SendPersonal, invalidType, true, "--users", "D-fixture", "--content", "fixture", "--type", "other"); err == nil || len(invalidType.history) != 0 { + t.Fatalf("invalid type error=%v calls=%v", err, invalidType.history) + } + + recallArgs := []string{"--id", "ding-fixture"} + unconfirmedRecall := &dingCoverageCaller{responses: map[string][]string{}} + if err := runDingRoot(t, RecallPersonal, unconfirmedRecall, false, recallArgs...); err == nil || len(unconfirmedRecall.history) != 0 { + t.Fatalf("unconfirmed recall error=%v calls=%v", err, unconfirmedRecall.history) + } + confirmedRecall := &dingCoverageCaller{responses: map[string][]string{ + "recall_personal_ding": {`{"success":true,"result":true}`}, + }} + if err := runDingRoot(t, RecallPersonal, confirmedRecall, true, recallArgs...); err != nil { + t.Fatalf("confirmed recall failed: %v", err) + } + if strings.Join(confirmedRecall.history, ",") != "recall_personal_ding" || confirmedRecall.arguments[0]["openDingId"] != "ding-fixture" { + t.Fatalf("confirmed recall calls=%v args=%v", confirmedRecall.history, confirmedRecall.arguments) } } diff --git a/internal/shortcut/oa/oa.go b/internal/shortcut/oa/oa.go index 17154691..7e062b92 100644 --- a/internal/shortcut/oa/oa.go +++ b/internal/shortcut/oa/oa.go @@ -172,14 +172,14 @@ var SearchForms = shortcut.Shortcut{ Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "--query 去除空白后不能为空"}}, Tips: []string{`dws oa +search-forms --query 报销`}, Validate: func(rt *shortcut.RuntimeContext) error { - if rt.Str("query") == "" { + if strings.TrimSpace(rt.Str("query")) == "" { return apperrors.NewValidation("--query 不能为空") } return nil }, Execute: func(rt *shortcut.RuntimeContext) error { const operation = "oa/search_form" - data, err := rt.CallMCPData("oa", "search_form", map[string]any{"query": rt.Str("query")}) + data, err := rt.CallMCPData("oa", "search_form", map[string]any{"query": strings.TrimSpace(rt.Str("query"))}) if err != nil { return err } diff --git a/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go index 57d783f7..adcaca63 100644 --- a/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go @@ -499,7 +499,10 @@ func TestCrossPlatformCoverageOAReadShortcutBranches(t *testing.T) { }) t.Run("search empty query", func(t *testing.T) { - expectError(t, SearchForms, map[string][]string{}, "--query", " ") + caller := expectError(t, SearchForms, map[string][]string{}, "--query", " ") + if len(caller.history) != 0 { + t.Fatalf("blank query made calls: %v", caller.history) + } }) t.Run("search call failure", func(t *testing.T) { expectError(t, SearchForms, map[string][]string{"search_form": {"__ERROR__"}}, "--query", "fixture") @@ -508,7 +511,10 @@ func TestCrossPlatformCoverageOAReadShortcutBranches(t *testing.T) { expectError(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":{}}`}}, "--query", "fixture") }) t.Run("search known nonempty", func(t *testing.T) { - expectSuccess(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":[{"processCode":"p","processName":"fixture"}]}`}}, "--query", "fixture") + caller := expectSuccess(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":[{"processCode":"p","processName":"fixture"}]}`}}, "--query", " fixture ") + if got := caller.arguments[0]["query"]; got != "fixture" { + t.Fatalf("normalized query=%#v, want fixture", got) + } }) t.Run("search legitimate empty", func(t *testing.T) { expectSuccess(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":[]}`}}, "--query", "guaranteed-zero-fixture") From bda408d9662a32a5cf0a8acaeed7b6e6fd4d44d3 Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 21:10:45 +0800 Subject: [PATCH 25/28] test(ding): cover compatibility reminder mappings --- .../ding/ding_strict_cross_platform_coverage_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go index 90e1bc8f..f5340cf3 100644 --- a/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go @@ -362,6 +362,13 @@ func TestCrossPlatformCoverageDINGUnavailableWritesNeverCallMCP(t *testing.T) { } func TestCrossPlatformCoverageDINGCompatibilityWritesRequireConfirmationAndExecute(t *testing.T) { + for input, want := range map[string]string{"app": "APP", " sms ": "SMS", "call": "PHONE"} { + got, err := dingPersonalRemindType(input) + if err != nil || got != want { + t.Errorf("dingPersonalRemindType(%q)=(%q,%v), want (%q,nil)", input, got, err, want) + } + } + sendArgs := []string{"--users", "D-fixture", "--content", "fixture", "--type", "call", "--uuid", "fixture-uuid"} unconfirmedSend := &dingCoverageCaller{responses: map[string][]string{}} if err := runDingRoot(t, SendPersonal, unconfirmedSend, false, sendArgs...); err == nil || len(unconfirmedSend.history) != 0 { From 2d38beb7befd00727614af79e1c86134e61230ce Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 21:51:21 +0800 Subject: [PATCH 26/28] fix(shortcuts): separate compatibility visibility from availability --- .../builtin/ding_semantic_catalog_test.go | 11 ++++-- internal/shortcut/ding/common.go | 5 ++- internal/shortcut/ding/ding.go | 8 ++-- internal/shortcut/semantic_catalog.go | 11 +++--- .../semantic_catalog_coverage_test.go | 37 +++++++++++-------- internal/shortcut/semantic_catalog_ding.json | 6 ++- 6 files changed, 48 insertions(+), 30 deletions(-) diff --git a/internal/shortcut/builtin/ding_semantic_catalog_test.go b/internal/shortcut/builtin/ding_semantic_catalog_test.go index faf2408c..af96a75d 100644 --- a/internal/shortcut/builtin/ding_semantic_catalog_test.go +++ b/internal/shortcut/builtin/ding_semantic_catalog_test.go @@ -37,6 +37,11 @@ func TestCrossPlatformCoverageDINGSemanticCatalogExactlyCoversRegisteredSurface( "+recall-personal": true, "+send-personal": true, } + wantCompatibilityAvailability := map[string]shortcut.Availability{ + "+list": shortcut.AvailabilityUnavailable, + "+recall-personal": shortcut.AvailabilityAvailable, + "+send-personal": shortcut.AvailabilityAvailable, + } public, unavailable, compatibilityVisible := 0, 0, 0 var missing, stale []string for command, item := range registered { @@ -66,7 +71,7 @@ func TestCrossPlatformCoverageDINGSemanticCatalogExactlyCoversRegisteredSurface( } else { if record.CompatibilityVisible { compatibilityVisible++ - if item.Hidden || !item.CompatibilityVisible || availability != shortcut.AvailabilityUnavailable || shortcut.InPublicCatalog("ding", command) { + if item.Hidden || !item.CompatibilityVisible || availability != wantCompatibilityAvailability[command] || shortcut.InPublicCatalog("ding", command) { t.Errorf("%s compatibility-visible boundary drift", command) } } else if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog("ding", command) { @@ -90,7 +95,7 @@ func TestCrossPlatformCoverageDINGSemanticCatalogExactlyCoversRegisteredSurface( if len(missing) > 0 || len(stale) > 0 { t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) } - if public != 1 || unavailable != 4 || compatibilityVisible != len(wantCompatibilityVisible) { - t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/4/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible)) + if public != 1 || unavailable != 2 || compatibilityVisible != len(wantCompatibilityVisible) { + t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/2/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible)) } } diff --git a/internal/shortcut/ding/common.go b/internal/shortcut/ding/common.go index bb45a46c..13408567 100644 --- a/internal/shortcut/ding/common.go +++ b/internal/shortcut/ding/common.go @@ -18,8 +18,9 @@ import ( ) const ( - dingCompositeReason = "Reviewed DING Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable DING identity checks, truthful pagination, and unified output projection." - dingWriteUnavailableReason = "DING mutation is unavailable to Agents until the downstream exposes stable receiver identities and a queryable recall terminal state; isolated self-fixtures already prove stable DING receipts but cannot prove those two facts." + dingCompositeReason = "Reviewed DING Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable DING identity checks, truthful pagination, and unified output projection." + dingCompatibilityWriteReason = "Historical DING CLI write compatibility: the command remains executable behind user confirmation, but it is excluded from Agent public discovery because receiver identity and recall terminal-state verification are incomplete." + dingWriteUnavailableReason = "DING mutation is unavailable to Agents until the downstream exposes stable receiver identities and a queryable recall terminal state; isolated self-fixtures already prove stable DING receipts but cannot prove those two facts." ) type dingPageEvidence struct { diff --git a/internal/shortcut/ding/ding.go b/internal/shortcut/ding/ding.go index 8c923b92..ba2a19a5 100644 --- a/internal/shortcut/ding/ding.go +++ b/internal/shortcut/ding/ding.go @@ -101,7 +101,7 @@ var ReceiverStatus = shortcut.Shortcut{ var SendPersonal = compatibilityDingWrite( "+send-personal", "以本人身份发送 DING 给指定人", - "需要以当前用户身份向明确的 openDingTalkId 接收人发送 DING 时使用;下游提供稳定接收人身份与可查询撤回终态前不可执行。", + "仅为历史 CLI 兼容保留:明确确认后按原有参数发送;因接收人稳定身份与撤回终态尚不可验证,不进入 Agent 公共发现。", shortcut.RiskWrite, false, []shortcut.Flag{ {Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true}, @@ -152,7 +152,7 @@ var SendByMessage = unavailableDingWrite( var RecallPersonal = compatibilityDingWrite( "+recall-personal", "撤回本人发起的 DING", - "需要撤回本人发出的 DING 时使用;必须能按稳定目标精确读回撤回终态且证明没有残留通知,当前下游尚不满足。", + "仅为历史 CLI 兼容保留:明确确认后按稳定 openDingId 撤回;因终态与残留通知尚不可查询验证,不进入 Agent 公共发现。", shortcut.RiskHighWrite, true, []shortcut.Flag{{Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}}, []contract.ParamDecl{{Name: "id", Property: "id"}}, @@ -163,11 +163,13 @@ var RecallPersonal = compatibilityDingWrite( ) func compatibilityDingWrite(command, description, intent string, risk shortcut.Risk, destructive bool, flags []shortcut.Flag, params []contract.ParamDecl, example string, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut { + declaration := dingContract(command, description, intent, true, nil, nil, params, example) + declaration.Interface.Reason = dingCompatibilityWriteReason return shortcut.Shortcut{ Service: "ding", Command: command, Product: "im", Description: description, Intent: intent, Risk: risk, Safety: dingWriteSafety(destructive), OutputRollout: output.RolloutUnifiedActive, - Contract: dingContract(command, description, intent, true, nil, nil, params, example), + Contract: declaration, Flags: flags, Tips: []string{example}, Execute: execute, } } diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go index 2542142f..60aa42c6 100644 --- a/internal/shortcut/semantic_catalog.go +++ b/internal/shortcut/semantic_catalog.go @@ -142,11 +142,12 @@ func loadSemanticCatalog(raw []byte, out map[string]semanticCatalogRecord) { command, record.Availability)) } // A command that was already part of the visible CLI contract cannot be - // hidden in the same feature change merely because Agent publication is - // withdrawn. This narrow fact preserves historical discovery/argv while - // the unavailable Interface and public=false keep it out of Agent routes. - if record.CompatibilityVisible && (record.Public || record.Availability != AvailabilityUnavailable) { - panic(fmt.Sprintf("semantic catalog command %q can be compatibility-visible only when non-public and unavailable", command)) + // hidden merely because Agent publication is withdrawn. Compatibility + // visibility owns only historical CLI discovery; availability independently + // records whether that compatibility path still executes. public=false keeps + // both available and unavailable compatibility leaves out of Agent routes. + if record.CompatibilityVisible && record.Public { + panic(fmt.Sprintf("semantic catalog command %q cannot be both public and compatibility-visible", command)) } key := publicCatalogKey(source.Service, command) if _, exists := out[key]; exists { diff --git a/internal/shortcut/semantic_catalog_coverage_test.go b/internal/shortcut/semantic_catalog_coverage_test.go index d555aced..df822179 100644 --- a/internal/shortcut/semantic_catalog_coverage_test.go +++ b/internal/shortcut/semantic_catalog_coverage_test.go @@ -136,21 +136,6 @@ func TestCrossPlatformCoverageSemanticCatalogRejectsInvalidRecords(t *testing.T) } } }`, - "compatibility-visible available": `{ - "version": 1, - "service": "chat", - "default_availability": "available", - "shortcuts": { - "+messages": { - "disposition": "semantic_adapter", - "semantic_delta": "reviewed", - "risk": "read", - "public": false, - "compatibility_visible": true, - "reviewed": true - } - } - }`, } original := semanticCatalogJSON t.Cleanup(func() { semanticCatalogJSON = original }) @@ -210,6 +195,28 @@ func TestCrossPlatformCoverageCompatibilityVisibleStaysNonPublic(t *testing.T) { if InPublicCatalog(item.Service, item.Command) { t.Fatal("compatibility-visible unavailable shortcut entered the public catalog") } + + available := map[string]semanticCatalogRecord{} + loadSemanticCatalog([]byte(`{ + "version":1, + "service":"compatibility-test", + "default_availability":"unavailable", + "shortcuts":{ + "+legacy":{ + "disposition":"semantic_adapter", + "semantic_delta":"historical CLI path remains executable but is not Agent-public", + "risk":"write", + "availability":"available", + "public":false, + "compatibility_visible":true, + "reviewed":true + } + } + }`), available) + record := available[publicCatalogKey("compatibility-test", "+legacy")] + if record.Public || !record.CompatibilityVisible || record.Availability != AvailabilityAvailable { + t.Fatalf("available compatibility record = %#v", record) + } } func TestCrossPlatformCoverageRuntimeReadDataBranches(t *testing.T) { diff --git a/internal/shortcut/semantic_catalog_ding.json b/internal/shortcut/semantic_catalog_ding.json index d2abf1a8..7c1565d5 100644 --- a/internal/shortcut/semantic_catalog_ding.json +++ b/internal/shortcut/semantic_catalog_ding.json @@ -21,8 +21,9 @@ }, "+send-personal": { "disposition": "semantic_adapter", - "semantic_delta": "已修正 app/sms/call 到 APP/SMS/PHONE 的下游枚举映射;隔离自收件 APP fixture 返回稳定 openDingId 并可按该 ID 读回,但接收项只有显示名而没有稳定接收人身份,且撤回无可查询终态,保持不可用。", + "semantic_delta": "历史 CLI 兼容入口在用户确认后保持可执行,并修正 app/sms/call 到 APP/SMS/PHONE 的下游枚举映射;因接收项没有稳定接收人身份且撤回无可查询终态,不进入 Agent 公共发现。", "risk": "write", + "availability": "available", "public": false, "compatibility_visible": true, "reviewed": true @@ -36,8 +37,9 @@ }, "+recall-personal": { "disposition": "semantic_adapter", - "semantic_delta": "个人 DING 撤回返回布尔成功但不回显目标或终态;撤回后精确接收状态仍保留原记录,而消息列表分页已知不可靠,不能把首屏缺席解释为全局终态,保持不可用。", + "semantic_delta": "历史 CLI 兼容入口在用户确认后保持可执行;个人 DING 撤回只返回布尔成功且不回显终态,不能作为 Agent 可验证写能力,因此不进入公共发现。", "risk": "high-risk-write", + "availability": "available", "public": false, "compatibility_visible": true, "reviewed": true From 8cb0f644776206ccae3c68399532b153956b9f21 Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 22:15:27 +0800 Subject: [PATCH 27/28] fix(shortcut): reject backward OA cursors --- internal/shortcut/oa/common.go | 8 ++++++-- .../shortcut/oa/oa_strict_cross_platform_coverage_test.go | 6 ++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/shortcut/oa/common.go b/internal/shortcut/oa/common.go index f51c11bd..a1c77752 100644 --- a/internal/shortcut/oa/common.go +++ b/internal/shortcut/oa/common.go @@ -340,8 +340,12 @@ func oaCursorPage(result map[string]any, operation string, current int) (oaPageE if next == "" { return oaPageEvidence{}, oaResponseError(operation, "missing_next_cursor", "hasMore=true 但缺少 nextCursor") } - if next == strconv.Itoa(current) { - return oaPageEvidence{}, oaResponseError(operation, "stalled_cursor", "nextCursor 与当前 cursor 相同") + nextValue, err := strconv.Atoi(next) + if err != nil { + return oaPageEvidence{}, oaResponseError(operation, "malformed_pagination", "nextCursor 应为整数") + } + if nextValue <= current { + return oaPageEvidence{}, oaResponseError(operation, "stalled_cursor", "nextCursor 没有严格前进") } page.Next = next return page, nil diff --git a/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go index adcaca63..f19cdc13 100644 --- a/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go @@ -154,6 +154,12 @@ func TestCrossPlatformCoverageOAPaginationFailsClosed(t *testing.T) { if _, err := oaCursorPage(map[string]any{"hasMore": true, "nextCursor": float64(1)}, "oa/cursor", 1); err == nil { t.Fatal("stalled cursor was accepted") } + if _, err := oaCursorPage(map[string]any{"hasMore": true, "nextCursor": float64(1)}, "oa/cursor", 2); err == nil { + t.Fatal("backward cursor was accepted") + } + if _, err := oaCursorPage(map[string]any{"hasMore": true, "nextCursor": "not-a-number"}, "oa/cursor", 1); err == nil { + t.Fatal("non-integer cursor was accepted") + } page, err = oaCursorPage(map[string]any{"hasMore": true, "nextCursor": float64(2)}, "oa/cursor", 1) if err != nil || page.Next != "2" { t.Fatalf("cursor continuation=%+v err=%v", page, err) From 11a9ad5d491a6e140454539f2cbb06bc4a7a3332 Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 20 Aug 2026 23:03:35 +0800 Subject: [PATCH 28/28] fix(shortcut): align OA execution availability --- internal/app/oa_shortcut_contract_test.go | 35 +++++++++++++++++++ .../builtin/oa_semantic_catalog_test.go | 9 +++-- internal/shortcut/oa/common.go | 10 ++++-- internal/shortcut/oa/oa.go | 7 ++-- .../oa_strict_cross_platform_coverage_test.go | 4 +++ internal/shortcut/oa/smart.go | 8 +++-- internal/shortcut/semantic_catalog_oa.json | 24 ++++++++----- internal/shortcut/types.go | 3 +- 8 files changed, 82 insertions(+), 18 deletions(-) create mode 100644 internal/app/oa_shortcut_contract_test.go diff --git a/internal/app/oa_shortcut_contract_test.go b/internal/app/oa_shortcut_contract_test.go new file mode 100644 index 00000000..55b9f703 --- /dev/null +++ b/internal/app/oa_shortcut_contract_test.go @@ -0,0 +1,35 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package app + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" +) + +func TestOAFinalSchemaAvailabilityMatchesReviewedExecution(t *testing.T) { + snapshot := fullSchemaSnapshotForTest(t) + for _, canonical := range []string{ + "oa.shortcut_approve_by", + "oa.shortcut_done_approvals", + "oa.shortcut_list_cc", + "oa.shortcut_list_executed", + "oa.shortcut_list_forms", + "oa.shortcut_list_pending", + "oa.shortcut_list_submitted", + "oa.shortcut_my_initiated", + "oa.shortcut_pending", + "oa.shortcut_search_forms", + } { + tool, ok := snapshot.Tools[canonical] + if !ok { + t.Errorf("final Schema lacks OA tool %s", canonical) + continue + } + if got := tool["availability"]; got != contract.InterfaceAvailable { + t.Errorf("%s final availability=%v, want %q", canonical, got, contract.InterfaceAvailable) + } + } +} diff --git a/internal/shortcut/builtin/oa_semantic_catalog_test.go b/internal/shortcut/builtin/oa_semantic_catalog_test.go index b6e63d4b..9efc0bef 100644 --- a/internal/shortcut/builtin/oa_semantic_catalog_test.go +++ b/internal/shortcut/builtin/oa_semantic_catalog_test.go @@ -58,6 +58,9 @@ func TestCrossPlatformCoverageOASemanticCatalogExactlyCoversRegisteredSurface(t if item.Risk != record.Risk || item.Availability != availability { t.Errorf("%s risk/availability=%q/%q want=%q/%q", command, item.Risk, item.Availability, record.Risk, availability) } + if item.Contract.Interface == nil || item.Contract.Interface.Availability != string(availability) { + t.Errorf("%s final interface availability=%v, want %q", command, item.Contract.Interface, availability) + } if record.Public { public++ if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("oa", command) { @@ -69,7 +72,7 @@ func TestCrossPlatformCoverageOASemanticCatalogExactlyCoversRegisteredSurface(t } else { if record.CompatibilityVisible { compatibilityVisible++ - if item.Hidden || !item.CompatibilityVisible || availability != shortcut.AvailabilityUnavailable || shortcut.InPublicCatalog("oa", command) { + if item.Hidden || !item.CompatibilityVisible || availability != shortcut.AvailabilityAvailable || shortcut.InPublicCatalog("oa", command) { t.Errorf("%s compatibility-visible boundary drift", command) } } else if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog("oa", command) { @@ -93,7 +96,7 @@ func TestCrossPlatformCoverageOASemanticCatalogExactlyCoversRegisteredSurface(t if len(missing) > 0 || len(stale) > 0 { t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) } - if public != 1 || unavailable != 8 || compatibilityVisible != len(wantCompatibilityVisible) { - t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/8/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible)) + if public != 1 || unavailable != 0 || compatibilityVisible != len(wantCompatibilityVisible) { + t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/0/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible)) } } diff --git a/internal/shortcut/oa/common.go b/internal/shortcut/oa/common.go index a1c77752..6e09854c 100644 --- a/internal/shortcut/oa/common.go +++ b/internal/shortcut/oa/common.go @@ -65,9 +65,15 @@ func oaWriteSafety() contract.SafetySpec { return contract.SafetySpec{Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"} } -func oaContract(command, description, intent string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl { +func oaContract(command, description, intent string, available bool, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl { name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_") cliPath := "oa " + command + availability := contract.InterfaceUnavailable + interfaceReason := strings.TrimSpace(intent) + if available { + availability = contract.InterfaceAvailable + interfaceReason = oaCompositeReason + } return corecmd.ContractDecl{ Description: description, Result: result, @@ -77,7 +83,7 @@ func oaContract(command, description, intent string, result *contract.ResultSpec ProductID: "oa", Name: name, CanonicalPath: "oa." + name, CLIPath: cliPath, PrimaryCLIPath: cliPath, }, - Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: oaCompositeReason}, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: availability, Reason: interfaceReason}, Selection: contract.SelectionSpec{ AgentSummary: description, UseWhen: []string{intent}, diff --git a/internal/shortcut/oa/oa.go b/internal/shortcut/oa/oa.go index 7e062b92..b5ab751c 100644 --- a/internal/shortcut/oa/oa.go +++ b/internal/shortcut/oa/oa.go @@ -56,6 +56,7 @@ var ListPending = shortcut.Shortcut{ "+list-pending", "查询待我处理的审批(时间范围为 epoch 毫秒)", "需要按时间范围读取待我审批的实例,并取得稳定 processInstanceId 时使用;没有安全非空待办 fixture 前不会进入公开发现。", + true, oaCollectionResult("instances", "严格验证的待处理审批实例页"), oaPagePagination("page"), []contract.ParamDecl{ @@ -115,7 +116,8 @@ var ListForms = shortcut.Shortcut{ OutputRollout: output.RolloutUnifiedActive, Contract: oaContract( "+list-forms", "获取当前用户可见的审批表单列表", - "需要枚举可发起审批定义并取得稳定 processCode 时使用;当前下游不返回可验证 continuation,故保持 unavailable。", + "需要枚举可发起审批定义并取得稳定 processCode 时使用;当前下游不返回可验证 continuation,故不进入 Agent 公开发现。", + true, oaCollectionResult("forms", "严格验证的可见审批表单页"), oaPagePagination("cursor"), []contract.ParamDecl{{Name: "cursor", Property: "cursor"}, {Name: "limit", Property: "limit"}}, "dws oa +list-forms --cursor 0 --limit 100", @@ -164,6 +166,7 @@ var SearchForms = shortcut.Shortcut{ Contract: oaContract( "+search-forms", "按关键字模糊搜索当前用户可见的审批表单", "已知审批定义关键字,需要取得一个或多个稳定 processCode 时使用;要无条件遍历全部定义不要使用本命令。", + true, oaCollectionResult("forms", "严格验证的审批表单搜索结果"), nil, []contract.ParamDecl{{Name: "query", Property: "query"}}, "dws oa +search-forms --query 报销", @@ -196,7 +199,7 @@ func oaNumberedInstanceShortcut(command, tool, description, intent string) short Service: "oa", Command: command, Product: "oa", Description: description, Intent: intent, Risk: shortcut.RiskRead, Safety: oaReadSafety(), OutputRollout: output.RolloutUnifiedActive, - Contract: oaContract(command, description, intent, + Contract: oaContract(command, description, intent, true, oaCollectionResult("instances", description), oaPagePagination("page"), []contract.ParamDecl{{Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}, {Name: "query", Property: "query"}}, "dws oa "+command+" --page 1 --limit 20"), diff --git a/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go index f19cdc13..7df9f056 100644 --- a/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go @@ -94,6 +94,10 @@ func TestCrossPlatformCoverageOAContractsAreTypedAndUnified(t *testing.T) { if Approve.Safety.Confirmation != "user_required" || Approve.Risk != shortcut.RiskHighWrite { t.Fatal("+approve-by must require explicit high-risk confirmation") } + unavailable := oaContract("+fixture-unavailable", "fixture unavailable", "reviewed fixture is unavailable", false, oaCollectionResult("items", "fixture items"), nil, nil) + if unavailable.Interface == nil || unavailable.Interface.Availability != "unavailable" || unavailable.Interface.Reason != "reviewed fixture is unavailable" { + t.Fatalf("unavailable OA contract interface=%+v", unavailable.Interface) + } for _, declaration := range []shortcut.Shortcut{ListPending, ListForms, ListExecuted, ListSubmitted, ListCc, MyInitiated} { if declaration.Contract.Pagination == nil { t.Errorf("%s lacks Pagination", declaration.Command) diff --git a/internal/shortcut/oa/smart.go b/internal/shortcut/oa/smart.go index 655b44bc..0bacde28 100644 --- a/internal/shortcut/oa/smart.go +++ b/internal/shortcut/oa/smart.go @@ -39,13 +39,14 @@ func oaFirstPageOnly(rt *shortcut.RuntimeContext, tool, collection string, param var PendingApprovals = shortcut.Shortcut{ Service: "oa", Command: "+pending", Product: "oa", Description: "只读列出待我审批的审批任务并投影为可读列表(只看不批)", - Intent: "兼容入口:读取近三个月待处理审批的首个完整页;无法证明完整或缺少非空 fixture 时保持 unavailable。", + Intent: "兼容入口:读取近三个月待处理审批的首个完整页;无法证明完整或缺少非空 fixture 时不进入 Agent 公开发现。", Risk: shortcut.RiskRead, Safety: oaReadSafety(), OutputRollout: output.RolloutUnifiedActive, Contract: oaContract( "+pending", "只读列出待我审批的审批任务并投影为可读列表(只看不批)", "兼容旧的待审批摘要入口;新调用优先使用支持显式时间和页码的 +list-pending。", + true, oaCollectionResult("pending", "严格验证的待审批摘要"), nil, []contract.ParamDecl{{Name: "limit", Property: "limit"}}, "dws oa +pending --limit 10", ), @@ -78,6 +79,7 @@ var DoneApprovals = shortcut.Shortcut{ Contract: oaContract( "+done-approvals", "只读列出我已处理过的审批任务(审批历史)并投影为可读列表", "兼容旧的已处理审批摘要入口;需要翻页或搜索时使用 +list-executed。", + true, oaCollectionResult("done", "严格验证的已处理审批摘要"), nil, []contract.ParamDecl{{Name: "limit", Property: "limit"}}, "dws oa +done-approvals --limit 10", ), @@ -109,6 +111,7 @@ var MyInitiated = shortcut.Shortcut{ Contract: oaContract( "+my-initiated", "列出我发起(提交)的审批单据", "需要兼容旧的 initiated 输出字段时使用;一般列表与分页可直接使用 +list-submitted。", + true, oaCollectionResult("initiated", "严格验证的已发起审批实例页"), oaPagePagination("page"), []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}}, "dws oa +my-initiated --page 1 --limit 20", "dws oa +my-initiated --query 报销", @@ -169,13 +172,14 @@ func oaMatchApprovals(items []map[string]any, keyword string) []oaApprovalMatch var Approve = shortcut.Shortcut{ Service: "oa", Command: "+approve-by", Product: "oa", Description: "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)", - Intent: "高风险兼容编排:完整读取并唯一匹配待办、唯一解析 taskId、确认后同意,再精确读回该 taskId 已不在待处理集合;没有安全 fixture 前保持 unavailable。", + Intent: "高风险兼容编排:完整读取并唯一匹配待办、唯一解析 taskId、确认后同意,再精确读回该 taskId 已不在待处理集合;没有安全 fixture 前不进入 Agent 公开发现。", Risk: shortcut.RiskHighWrite, Safety: oaWriteSafety(), OutputRollout: output.RolloutUnifiedActive, Contract: oaContract( "+approve-by", "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)", "仅在用户明确确认同意、关键词唯一定位实例且任务读回可验证时使用;任何歧义、分页不完整或读回失败都会阻止成功。", + true, oaWriteResult("同意审批并通过精确任务读回验证"), nil, []contract.ParamDecl{{Name: "keyword", Property: "query"}, {Name: "comment", Property: "remark"}}, "dws oa +approve-by --keyword 报销", diff --git a/internal/shortcut/semantic_catalog_oa.json b/internal/shortcut/semantic_catalog_oa.json index 7ebe91a6..0aee7c46 100644 --- a/internal/shortcut/semantic_catalog_oa.json +++ b/internal/shortcut/semantic_catalog_oa.json @@ -5,24 +5,27 @@ "shortcuts": { "+list-executed": { "disposition": "semantic_adapter", - "semantic_delta": "严格验证 success、result.values、稳定 processInstanceId 与 hasMore;known-nonempty exact/raw 的 7 个稳定身份一致,但 guaranteed-zero-query 的 raw 响应缺少 hasMore,收紧后拒绝并保持不可用。", + "semantic_delta": "严格验证 success、result.values、稳定 processInstanceId 与 hasMore;known-nonempty exact/raw 的 7 个稳定身份一致,但 guaranteed-zero-query 的 raw 响应缺少 hasMore,收紧后拒绝,因此不进入 Agent 公开发现。", "risk": "read", + "availability": "available", "public": false, "compatibility_visible": true, "reviewed": true }, "+list-submitted": { "disposition": "semantic_adapter", - "semantic_delta": "严格返回已发起审批实例及页完整性;known-nonempty exact/raw 的 14 个稳定身份一致,但 guaranteed-zero-query 的 raw 响应缺少 hasMore,不能把空数组推断为终页,保持不可用。", + "semantic_delta": "严格返回已发起审批实例及页完整性;known-nonempty exact/raw 的 14 个稳定身份一致,但 guaranteed-zero-query 的 raw 响应缺少 hasMore,不能把空数组推断为终页,因此不进入 Agent 公开发现。", "risk": "read", + "availability": "available", "public": false, "compatibility_visible": true, "reviewed": true }, "+my-initiated": { "disposition": "alias_internal", - "semantic_delta": "保留历史 initiated 字段且不回退原始响应;known-nonempty exact/raw 的 14 个稳定身份一致,但复用的 guaranteed-zero raw 响应缺少 hasMore,故与主列表一并保持不可用。", + "semantic_delta": "保留历史 initiated 字段且不回退原始响应;known-nonempty exact/raw 的 14 个稳定身份一致,但复用的 guaranteed-zero raw 响应缺少 hasMore,故与主列表一并保持非公开。", "risk": "read", + "availability": "available", "primary": "+list-submitted", "public": false, "compatibility_visible": true, @@ -47,40 +50,45 @@ }, "+list-forms": { "disposition": "semantic_adapter", - "semantic_delta": "真实非空页不返回 continuation,且 cursor 0/1 的集合高度重叠;严格实现拒绝缺失或停滞游标,下游修复前不可公开。", + "semantic_delta": "真实非空页不返回 continuation,且 cursor 0/1 的集合高度重叠;严格实现拒绝缺失、畸形、停滞或倒退游标,下游修复前不进入 Agent 公开发现。", "risk": "read", + "availability": "available", "public": false, "compatibility_visible": true, "reviewed": true }, "+list-pending": { "disposition": "semantic_adapter", - "semantic_delta": "严格待办投影与分页已实现;exact Shortcut 与同参数 atomic/raw 均隔离到 success=true 但 result.values=null,错型集合被拒绝,且当前没有安全非空待审批 fixture。", + "semantic_delta": "严格待办投影与分页已实现;exact Shortcut 与同参数 atomic/raw 均隔离到 success=true 但 result.values=null,错型集合被拒绝,且当前没有安全非空待审批 fixture,因此不进入 Agent 公开发现。", "risk": "read", + "availability": "available", "public": false, "compatibility_visible": true, "reviewed": true }, "+pending": { "disposition": "alias_internal", - "semantic_delta": "历史首屏待办入口路由到 +list-pending;同一真实响应的 result.values 为 null 而不是合法空数组,且当前没有已知非空安全 fixture,因此别名与主入口均不可用。", + "semantic_delta": "历史首屏待办入口路由到 +list-pending;同一真实响应的 result.values 为 null 而不是合法空数组,且当前没有已知非空安全 fixture,因此别名与主入口均保持非公开。", "risk": "read", + "availability": "available", "primary": "+list-pending", "public": false, "reviewed": true }, "+list-cc": { "disposition": "semantic_adapter", - "semantic_delta": "严格抄送列表与分页校验已实现;exact Shortcut 和同参数 atomic/raw 隔离到错型业务状态与 null 集合,不能把响应解释为空页,且当前没有安全非空抄送 fixture。", + "semantic_delta": "严格抄送列表与分页校验已实现;exact Shortcut 和同参数 atomic/raw 隔离到错型业务状态与 null 集合,不能把响应解释为空页,且当前没有安全非空抄送 fixture,因此不进入 Agent 公开发现。", "risk": "read", + "availability": "available", "public": false, "compatibility_visible": true, "reviewed": true }, "+approve-by": { "disposition": "primary_smart", - "semantic_delta": "唯一定位实例和任务、确认后写入、精确任务读回的严格编排已实现;缺少可撤销的待办审批 fixture 和可靠清理路径,保持不可用。", + "semantic_delta": "唯一定位实例和任务、确认后写入、精确任务读回的严格编排已实现;缺少可撤销的待办审批 fixture 和可靠清理路径,因此不进入 Agent 公开发现。", "risk": "high-risk-write", + "availability": "available", "public": false, "reviewed": true } diff --git a/internal/shortcut/types.go b/internal/shortcut/types.go index 9d9f5836..602baf39 100644 --- a/internal/shortcut/types.go +++ b/internal/shortcut/types.go @@ -197,7 +197,8 @@ type Shortcut struct { Hidden bool // CompatibilityVisible preserves a historically visible CLI command while // keeping it out of the Agent/public Shortcut catalog. Such a command is - // shown only by `dws shortcut list --all` and must remain unavailable. + // shown only by `dws shortcut list --all`; Availability independently says + // whether the historical execution path remains callable. CompatibilityVisible bool // Disposition is the reviewed semantic relation to the Runtime Schema leaf // surface. It determines default Agent discovery independently from live