Compare commits

...
Author SHA1 Message Date
赤川 6288199a93 Merge branch 'main' into codex/fix-report-requiredness-governance 2026-08-21 11:05:33 +08:00
赤川 5b34ed1a7e Merge pull request #1084 from DingTalk-Real-AI/codex/docs-dws-cli-open
docs: 公告 DWS CLI 全面开放
2026-08-21 11:02:36 +08:00
chichuan 3d9a469347 docs: announce DWS CLI availability 2026-08-21 11:01:08 +08:00
github-actions[bot] e0c49377d6 Merge pull request #1074 from DingTalk-Real-AI/codex/investigate-calendar-todo-comment-regressions
fix: harden calendar todo and comment shortcuts
2026-08-21 10:30:04 +08:00
Dennis 35d6f47bd6 Merge remote-tracking branch 'origin/main' into codex/investigate-calendar-todo-comment-regressions 2026-08-21 08:17:52 +08:00
github-actions[bot] 765b961f4d Merge pull request #1071 from DingTalk-Real-AI/codex/fix-stable-active-fragments
fix(release): consume post-beta fragments in stable seals
2026-08-21 01:06:18 +08:00
Dennis 1c8b83ec2f Merge remote-tracking branch 'origin/main' into codex/investigate-calendar-todo-comment-regressions 2026-08-20 23:58:13 +08:00
Dennis bb6f470df5 test: address shortcut regression review 2026-08-20 23:56:08 +08:00
Dennis 23940e4752 revert: keep coverage shard output compact 2026-08-20 22:24:17 +08:00
Dennis bbaf033618 ci: stream app coverage progress 2026-08-20 22:14:35 +08:00
chichuan fe66ac18a4 feat(policy): govern flag requiredness changes 2026-08-20 21:19:55 +08:00
Dennis 378b9f67a2 Merge remote-tracking branch 'origin/main' into codex/investigate-calendar-todo-comment-regressions 2026-08-20 20:41:56 +08:00
Dennis e6b06b561d Merge remote-tracking branch 'origin/main' into codex/investigate-calendar-todo-comment-regressions 2026-08-20 19:50:43 +08:00
Dennis e6fe475aea chore: retire consumed chat flag migration 2026-08-20 16:44:07 +08:00
Dennis 61f8140f91 test: close shortcut regression coverage gaps 2026-08-20 16:38:05 +08:00
Dennis fbcd8887ee fix: harden calendar todo and comment shortcuts 2026-08-20 16:09:18 +08:00
26 changed files with 1021 additions and 41 deletions
@@ -0,0 +1,7 @@
---
category: Fixed
---
- **Calendar empty windows** (#1074) — returns a legitimate empty result when the service emits its exact exhausted empty-event sentinel.
- **Task update verification** (#1074) — compares due-time readback as exact milliseconds so committed updates are no longer reported as failures.
- **Comment reaction validation** (#1074) — narrows accepted reaction input to reviewed DingTalk emoji names and rejects Unicode emoji and unsupported names such as `like` and `heart` before the RPC.
+1 -1
View File
@@ -19,7 +19,7 @@
</p>
> [!IMPORTANT]
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
> **钉钉 DWS CLI 已全面开放,欢迎使用**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
>
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws 开源沟通群二维码" width="150">
+41 -5
View File
@@ -1,6 +1,11 @@
# CLI Help / Schema 兼容迁移治理
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口。迁移必须保持原 flag 的 requiredness:optional 只能迁到 optional,required 只能迁到 required。它只解决这一种精确变更,不是通用 breaking-change 豁免。
本文定义两种受控 flag 迁移:
1. `flag_rename`:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口;rename 必须保持原 flag 的 requiredness,optional 只能迁到 optional,required 只能迁到 required。
2. `requiredness_change`:同一个公开 flag 从 optional 精确提升为 required;flag 的名称、类型、作用域、可见性、shorthand、`no_opt` 与 alias 关系必须保持不变。
两种原语都只放行清单精确登记的变化,不是通用 breaking-change 豁免,也不得在同一 command/flag 上叠加以绕过 rename 的 requiredness 保持规则。
同一套 base-owned lifecycle 也治理两类跨命令迁移:旧命令保留执行能力但从 Help / Schema 导航隐藏,并迁到新的公开命令路径;或把旧命令中的一个可选 flag 拆成新的专用命令。跨命令迁移只允许清单精确声明的 `command_became_hidden` / `flag_became_hidden` 及其 Schema 投影,不是通用 command-path breaking-change 豁免。
@@ -44,7 +49,9 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json
scripts/policy/interface-migrations/approved-command-migrations-v1.json
```
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。历史未声明 `kind` 的记录按 `flag_rename` 解释;新增同名 requiredness 迁移必须显式写 `kind: requiredness_change` 和单一 `flag` before/after。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
首次引入一个旧 merge-base 不认识的新 `kind` 时,机制 PR 不得同时写入该 kind 的 pending 记录,因为旧的 base-owned 严格解析器会拒绝未知字段。必须先合入 parser、lifecycle、CLI/Schema adapter 与 hostile tests;待这些实现成为新的 merge-base authority 后,再用独立治理审批 PR 新增 pending,最后才由产品 PR 消费。
## 跨命令迁移原语
@@ -103,12 +110,12 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
## 两阶段迁移与回执清理
每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期:
rename 以 `(kind, command, legacy flag, canonical flag)` 为唯一精确键;requiredness change 以 `(kind, command, flag)` 为唯一精确键。二者经历同一生命周期:
| 阶段 | PR 可以做什么 | 必须满足的快照状态 |
|---|---|---|
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 的 requiredness 与 legacy 迁移前完全一致 |
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | rename 的 legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`,canonical requiredness 保持不变;requiredness change 只把同名 flag 从 optional 提升为 required |
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
| 4. 惰性保留或清理 | 当 merge-base 和 stable 都已经是 `after`,该记录不再提供任何授权;后续 PR 可以原样保留或删除 | 两份参考快照均精确匹配 `after`;保留时仍必须是不可改写的 `consumed`,接口偏离 `after` 继续失败 |
@@ -157,6 +164,27 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
产品迁移 PR 必须保持同一条记录的命令、flag、before/after 和 reason 不变,只把 `pending` 改成 `consumed`。
同名 flag requiredness 迁移的清单结构如下;示例不代表已经审批:
```json
{
"version": 1,
"migrations": [
{
"kind": "requiredness_change",
"command": "dws report entry submit",
"flag": {
"name": "to-user-ids",
"before": {"present": true, "type": "string", "scope": "local"},
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
},
"state": "pending",
"reason": "Reject report submissions that have no visible recipient."
}
]
}
```
## `alias_of` 是框架来源的受评审关系证据
`alias_of` 不是 Schema 同义词、参数概念词典或任意文字声明。它只能由 `FlagSpec.Aliases` 写入,并与内部 origin `corecmd.flag_spec_aliases.v1` 成对出现;每次 Interface Integrity 都会在已提交的 detached candidate 上执行源码门禁,禁止其他生产文件写入或复刻这些 evidence token。Interface Snapshot 会验证:
@@ -177,10 +205,11 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
## 豁免边界
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下三类预期 finding:
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
2. required legacy 被新增的 required canonical 替代时产生的 `required_flag_added`;如果 canonical 在 before 阶段只是 hidden 占位符,则允许它在转为公开拼写时继承 legacy 的 requiredness。已有的 visible canonical 不允许借 rename 改变 requiredness。
3. `requiredness_change` 中同名 flag 从 optional 提升为 required 时产生的 `flag_became_required`。
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
@@ -188,6 +217,7 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
- flag 类型或迁移记录中的 scope、shorthand、`no_opt` 漂移;
- `alias_of` 缺失、指向变化或 alias chain;
- 命令路径及任何无关的阻塞性接口变化;
- requiredness change 同时发生的 rename、隐藏、类型、scope、shorthand、`no_opt` 或 alias 漂移;
- 不精确、部分完成、超出记录范围的 surface 变化。
## Schema 投影边界
@@ -215,6 +245,12 @@ adapter 先构造经过上述验证的历史 contract 副本,再调用原 Sche
canonical-only `after` 状态时不需要再次投影;adapter 保持 baseline 不变,由原 checker
验证 candidate 是否仍与该 canonical contract 兼容。
`requiredness_change` 的 Schema adapter 只把历史同名 parameter 的 `required` 与
`cli_required` 提升到 candidate 的 `true` 值,并要求 candidate 两者都为 `true`。parameter
不存在、tool/path 不匹配时不制造 Schema surface;type、property、interface type、default、
format、enum、`required_when`、constraints、positionals 与 safety 等全部字段仍交给原 checker,
任何不相干漂移继续阻塞。
## 本地验证
先确保 merge-base 和 stable tag 已在本地,然后运行与 CI 相同的权威门禁:
+56
View File
@@ -0,0 +1,56 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Package commentreaction validates the reviewed DingTalk names accepted by
// comment emoji replies. The names mirror the bundled default emoji catalog;
// arbitrary text and raw Unicode emoji must never be persisted as reactions.
package commentreaction
import (
"strings"
"unicode/utf8"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
// Validate rejects values outside DingTalk's reviewed default reaction-name
// catalog. Names are case-sensitive because they are sent verbatim to the
// doc-comment service.
func Validate(value string) error {
name := strings.TrimSpace(value)
if value != name || !utf8.ValidString(name) || !supported(name) {
return apperrors.NewValidation(
"--reaction/--content 必须是受支持的钉钉表情名称(如 憨笑、鼓掌、比心、赞),不要传 Unicode Emoji 或任意文本",
apperrors.WithReason("unsupported_comment_reaction"),
)
}
return nil
}
func supported(name string) bool {
switch name {
case "微笑", "可爱", "憨笑", "色", "发呆", "老板", "傻笑", "流泪", "害羞", "闭嘴",
"睡", "大哭", "尴尬", "感谢", "拒绝", "赞", "鼓掌", "打招呼", "666", "抱拳",
"握手", "OK", "胜利", "向左", "向右", "向上", "向下", "来呀", "一点点", "捏住",
"比心", "送花花", "加油干", "调皮", "大笑", "惊讶", "流汗", "奋斗", "口罩", "生病",
"吐", "难过", "抓狂", "右哼哼", "太阳", "月亮", "强", "弱", "彩带", "蛋糕",
"骷髅", "撇嘴", "鄙视", "嘘", "思考", "亲亲", "无奈", "感冒", "对不起", "再见",
"投降", "哼", "欠扁", "拜托", "可怜", "舒服", "爱意", "财迷", "迷惑", "委屈",
"灵感", "天使", "鬼脸", "凄凉", "郁闷", "坏笑", "算账", "PK", "忍者", "衰",
"炸弹", "笑哭", "嘿嘿", "捂脸哭", "抠鼻", "流鼻血", "呲牙", "吃瓜", "彩虹", "耶",
"发怒", "捂眼睛", "推眼镜", "暗中观察", "脑暴", "冷笑", "热", "开心", "惊喜", "回头",
"白眼", "一团乱麻", "黑眼圈", "裂开", "恭喜", "费解", "收到", "快来", "敲打", "捧脸",
"Get", "客服", "AR", "小蜜蜂", "虎虎生威", "兔飞猛进", "龙头老大", "蛇来运转", "马上来财", "专注",
"忙疯了", "等一等", "一脸苦笑", "王之蔑视", "洪荒之力", "向左看", "向右看", "YYDS", "这边请", "弹射下班",
"退退退", "在吗", "让人头大", "摊手", "抱抱", "举手", "开车", "抱大腿", "跪了", "鞠躬",
"选我", "元气满满", "会议", "猫咪", "二哈", "狗子", "三多", "承让", "撒花", "礼物",
"生日快乐", "爱心", "心碎", "嘴唇", "鲜花", "残花", "干杯", "咖啡", "奶茶", "茶",
"OKR", "KPI", "100分", "对勾", "打叉", "气泡", "加一", "Done", "钉子", "出差",
"高铁", "火箭", "邮件", "文档", "演示", "表格", "废纸篓", "手机", "时间", "静音",
"公文包", "地球", "碳减排", "回收标志", "幼苗", "红包", "锦鲤", "福", "灯笼", "爆竹",
"烟花", "恭喜发财", "月饼", "鸡腿", "休假", "火", "点赞", "平安健康", "定胜":
return true
default:
return false
}
}
+19
View File
@@ -0,0 +1,19 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package commentreaction
import "testing"
func TestCrossPlatformCoverageValidate(t *testing.T) {
for _, value := range []string{"憨笑", "鼓掌", "比心", "赞", "OK", "Done", "平安健康"} {
if err := Validate(value); err != nil {
t.Errorf("supported reaction %q rejected: %v", value, err)
}
}
for _, value := range []string{"", "😄", "👏", "�", "garbled", "乱码", "憨笑\n"} {
if err := Validate(value); err == nil {
t.Errorf("unsupported reaction %q accepted", value)
}
}
}
+4
View File
@@ -18,6 +18,7 @@ import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
@@ -231,6 +232,9 @@ Unicode Emoji。例如用户要求 😄 时传“憨笑”,要求 👏 时传
if err := validateRequiredFlags(cmd, "comment-key", "reaction"); err != nil {
return err
}
if err := commentreaction.Validate(mustGetFlag(cmd, "reaction")); err != nil {
return err
}
return callMCPToolOnServer(commentServer, "reply_comment", map[string]any{
"nodeId": nodeID,
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
+23
View File
@@ -135,6 +135,29 @@ func TestCrossPlatformCoverageCommentReactReplyForcesEmojiTrue(t *testing.T) {
}
}
func TestCrossPlatformCoverageCommentReactionsRejectUnsupportedValuesBeforeRPC(t *testing.T) {
for _, surface := range []string{"doc", "sheet"} {
for _, test := range []struct {
name string
args []string
}{
{name: "react unicode", args: []string{"comment", "react-reply", "--node", "node-1", "--comment-key", "comment-1", "--reaction", "😄"}},
{name: "react garbage", args: []string{"comment", "react-reply", "--node", "node-1", "--comment-key", "comment-1", "--reaction", "乱码"}},
{name: "reply unicode", args: []string{"comment", "reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "👏", "--emoji"}},
} {
t.Run(surface+"/"+test.name, func(t *testing.T) {
caller := &docCommentMutationCaller{}
if err := executeCommentBaseCommand(t, caller, surface, test.args...); err == nil {
t.Fatal("unsupported reaction accepted")
}
if len(caller.calls) != 0 {
t.Fatalf("unsupported reaction reached RPC: %#v", caller.calls)
}
})
}
}
}
func TestCrossPlatformCoverageCommentReactReplyGuidesDingTalkEmojiNames(t *testing.T) {
for _, surface := range []struct {
name string
+4
View File
@@ -15,6 +15,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
"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"
@@ -3220,6 +3221,9 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
}
if v, _ := cmd.Flags().GetBool("emoji"); v {
if err := commentreaction.Validate(mustGetFlag(cmd, "content")); err != nil {
return err
}
groupMentions, err := commentGroupMentionIDs(cmd)
if err != nil {
return err
+3 -3
View File
@@ -219,7 +219,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t
err := executeDocGroupMentionCommand(
t,
caller,
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like",
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞",
"--emoji", "--mentioned-open-conversation-id", "oc-1",
)
if err == nil || !strings.Contains(err.Error(), "emoji replies do not support group mentions") {
@@ -235,7 +235,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t
err := executeDocGroupMentionCommand(
t,
caller,
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like",
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞",
"--emoji", "--mentioned-open-conversation-id", " ",
)
if err == nil || !strings.Contains(err.Error(), "must not be empty") {
@@ -251,7 +251,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t
err := executeDocGroupMentionCommand(
t,
caller,
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like",
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞",
"--emoji",
)
if err != nil {
+4
View File
@@ -1,6 +1,7 @@
package helpers
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
@@ -154,6 +155,9 @@ func newSheetCommentCmd() *cobra.Command {
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
}
if v, _ := cmd.Flags().GetBool("emoji"); v {
if err := commentreaction.Validate(mustGetFlag(cmd, "content")); err != nil {
return err
}
toolArgs["emoji"] = true
}
if v, _ := cmd.Flags().GetString("mention"); v != "" {
@@ -93,7 +93,7 @@ func TestCrossPlatformCoverageSheetCommentCommands(t *testing.T) {
"comment", "reply",
"--node", "node-1",
"--comment-key", "ck-1",
"--content", "heart",
"--content", "比心",
"--emoji",
"--mention", "uid1",
},
@@ -102,7 +102,7 @@ func TestCrossPlatformCoverageSheetCommentCommands(t *testing.T) {
toolName: "reply_comment",
args: map[string]any{
"nodeId": "node-1",
"content": "heart",
"content": "比心",
"replyCommentKey": "ck-1",
"emoji": true,
"mentionedUserIds": []string{"uid1"},
+125 -18
View File
@@ -17,6 +17,9 @@ const FlagMigrationManifestVersion = 1
const (
FlagMigrationPending = "pending"
FlagMigrationConsumed = "consumed"
FlagMigrationRename = "flag_rename"
FlagMigrationRequirednessChange = "requiredness_change"
)
type FlagMigrationManifest struct {
@@ -25,11 +28,13 @@ type FlagMigrationManifest struct {
}
type FlagMigration struct {
Command string `json:"command"`
Legacy FlagMigrationSide `json:"legacy"`
Canonical FlagMigrationSide `json:"canonical"`
State string `json:"state"`
Reason string `json:"reason"`
Kind string `json:"kind,omitempty"`
Command string `json:"command"`
Legacy FlagMigrationSide `json:"legacy"`
Canonical FlagMigrationSide `json:"canonical"`
Flag *FlagMigrationSide `json:"flag,omitempty"`
State string `json:"state"`
Reason string `json:"reason"`
}
type FlagMigrationSide struct {
@@ -287,6 +292,23 @@ func (m FlagMigrationManifest) Validate() error {
}
legacyTargets[legacyKey] = migration.Canonical.Name
}
for _, requiredness := range m.Migrations {
if requiredness.EffectiveKind() != FlagMigrationRequirednessChange {
continue
}
for _, rename := range m.Migrations {
if rename.EffectiveKind() != FlagMigrationRename || rename.Command != requiredness.Command {
continue
}
if rename.Legacy.Name == requiredness.Flag.Name || rename.Canonical.Name == requiredness.Flag.Name {
return fmt.Errorf(
"flag requiredness migration %s overlaps rename migration %s",
requiredness.displayKey(),
rename.displayKey(),
)
}
}
}
return nil
}
@@ -294,15 +316,6 @@ func (m FlagMigration) validate() error {
if !isExactCommandPath(m.Command) {
return fmt.Errorf("command must be an exact command path rooted at dws: %q", m.Command)
}
if !isExactFlagName(m.Legacy.Name) {
return fmt.Errorf("legacy name must be an exact legacy flag: %q", m.Legacy.Name)
}
if !isExactFlagName(m.Canonical.Name) {
return fmt.Errorf("canonical name must be an exact canonical flag: %q", m.Canonical.Name)
}
if m.Legacy.Name == m.Canonical.Name {
return fmt.Errorf("legacy and canonical flags must differ: --%s", m.Legacy.Name)
}
if strings.TrimSpace(m.Reason) == "" {
return fmt.Errorf("migration must include a non-empty reason")
}
@@ -312,6 +325,30 @@ func (m FlagMigration) validate() error {
if m.State != FlagMigrationPending && m.State != FlagMigrationConsumed {
return fmt.Errorf("invalid state %q", m.State)
}
switch m.EffectiveKind() {
case FlagMigrationRename:
return m.validateRename()
case FlagMigrationRequirednessChange:
return m.validateRequirednessChange()
default:
return fmt.Errorf("invalid kind %q", m.Kind)
}
}
func (m FlagMigration) validateRename() error {
if m.Flag != nil {
return fmt.Errorf("flag rename must not declare flag requiredness fields")
}
if !isExactFlagName(m.Legacy.Name) {
return fmt.Errorf("legacy name must be an exact legacy flag: %q", m.Legacy.Name)
}
if !isExactFlagName(m.Canonical.Name) {
return fmt.Errorf("canonical name must be an exact canonical flag: %q", m.Canonical.Name)
}
if m.Legacy.Name == m.Canonical.Name {
return fmt.Errorf("legacy and canonical flags must differ: --%s", m.Legacy.Name)
}
if err := m.Legacy.Before.validate("legacy before"); err != nil {
return err
}
@@ -392,6 +429,45 @@ func (m FlagMigration) validate() error {
return nil
}
func (m FlagMigration) validateRequirednessChange() error {
if m.Legacy != (FlagMigrationSide{}) || m.Canonical != (FlagMigrationSide{}) {
return fmt.Errorf("flag requiredness migration must not declare legacy or canonical rename fields")
}
if m.Flag == nil || !isExactFlagName(m.Flag.Name) {
return fmt.Errorf("flag name must be an exact flag: %q", flagMigrationSideName(m.Flag))
}
if err := m.Flag.Before.validate("flag before"); err != nil {
return err
}
if err := m.Flag.After.validate("flag after"); err != nil {
return err
}
if !m.Flag.Before.Present || !m.Flag.After.Present || m.Flag.Before.Required || !m.Flag.After.Required {
return fmt.Errorf("flag requiredness migration must change exactly from optional to required")
}
if m.Flag.Before.Hidden || m.Flag.After.Hidden {
return fmt.Errorf("flag requiredness migration flag must stay visible")
}
if m.Flag.Before.AliasOf != "" || m.Flag.After.AliasOf != "" {
return fmt.Errorf("flag requiredness migration must not declare alias_of")
}
before := m.Flag.Before
after := m.Flag.After
before.Required = false
after.Required = false
if before != after {
return fmt.Errorf("flag requiredness migration must preserve every flag attribute except requiredness")
}
return nil
}
func flagMigrationSideName(side *FlagMigrationSide) string {
if side == nil {
return ""
}
return side.Name
}
func (s FlagMigrationState) validate(label string) error {
if !s.Present {
if s.Type != "" || s.Required || s.Hidden || s.Shorthand != "" || s.NoOpt != "" || s.Scope != "" || s.AliasOf != "" {
@@ -409,7 +485,19 @@ func (s FlagMigrationState) validate(label string) error {
}
func (m FlagMigration) key() string {
return m.Command + "\x00" + m.Legacy.Name + "\x00" + m.Canonical.Name
if m.EffectiveKind() == FlagMigrationRequirednessChange {
return m.EffectiveKind() + "\x00" + m.Command + "\x00" + flagMigrationSideName(m.Flag)
}
return m.EffectiveKind() + "\x00" + m.Command + "\x00" + m.Legacy.Name + "\x00" + m.Canonical.Name
}
// EffectiveKind keeps manifests written before kinds were introduced valid.
// An omitted kind is the original flag rename primitive.
func (m FlagMigration) EffectiveKind() string {
if m.Kind == "" {
return FlagMigrationRename
}
return m.Kind
}
func isExactCommandPath(path string) bool {
@@ -428,9 +516,9 @@ func isExactFlagName(name string) bool {
}
// CompareAllWithFlagMigrations applies the ordinary compatibility policy and
// then consumes only exact, merge-base-owned flag migrations. Candidate-owned
// records participate in the lifecycle check, but never authorize their own
// interface change.
// then consumes only exact, merge-base-owned flag rename or requiredness
// migrations. Candidate-owned records participate in the lifecycle check, but
// never authorize their own interface change.
func CompareAllWithFlagMigrations(
current Snapshot,
references map[string]Snapshot,
@@ -640,6 +728,16 @@ func matchFlagMigrationPhase(snapshot Snapshot, migration FlagMigration) flagMig
if !exists {
return flagMigrationPartial
}
if migration.EffectiveKind() == FlagMigrationRequirednessChange {
state := flagMigrationStateForCommand(command, flagMigrationSideName(migration.Flag))
if state == migration.Flag.Before {
return flagMigrationBefore
}
if state == migration.Flag.After {
return flagMigrationAfter
}
return flagMigrationPartial
}
legacy := flagMigrationStateForCommand(command, migration.Legacy.Name)
canonical := flagMigrationStateForCommand(command, migration.Canonical.Name)
if legacy == migration.Legacy.Before && canonical == migration.Canonical.Before {
@@ -694,6 +792,12 @@ func flagMigrationAuthorizesChange(
matchFlagMigrationPhase(current, migration) != flagMigrationAfter {
continue
}
if migration.EffectiveKind() == FlagMigrationRequirednessChange {
if change.Flag == migration.Flag.Name && change.Kind == "flag_became_required" {
return true
}
continue
}
if change.Flag == migration.Legacy.Name && change.Kind == "flag_became_hidden" {
return true
}
@@ -717,5 +821,8 @@ func flagMigrationAuthorizesChange(
}
func (m FlagMigration) displayKey() string {
if m.EffectiveKind() == FlagMigrationRequirednessChange {
return fmt.Sprintf("%q --%s optional -> required", m.Command, flagMigrationSideName(m.Flag))
}
return fmt.Sprintf("%q --%s -> --%s", m.Command, m.Legacy.Name, m.Canonical.Name)
}
@@ -38,6 +38,23 @@ const validFlagMigrationManifestJSON = `{
]
}`
const validRequirednessMigrationManifestJSON = `{
"version": 1,
"migrations": [
{
"kind": "requiredness_change",
"command": "dws report entry submit",
"flag": {
"name": "to-user-ids",
"before": {"present": true, "type": "string", "scope": "local"},
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
},
"state": "pending",
"reason": "Reject report submissions that have no visible recipient."
}
]
}`
func optionalFlagMigrationManifestJSON() string {
manifest := strings.Replace(
validFlagMigrationManifestJSON,
@@ -153,6 +170,9 @@ func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t *
if _, err := ReadFlagMigrationManifest(strings.NewReader(hiddenCanonicalFlagMigrationManifestJSON())); err != nil {
t.Fatalf("ReadFlagMigrationManifest(hidden canonical promotion) error = %v", err)
}
if _, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON)); err != nil {
t.Fatalf("ReadFlagMigrationManifest(requiredness change) error = %v", err)
}
tests := []struct {
name string
@@ -246,6 +266,229 @@ func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t *
}
}
func TestCrossPlatformCoverageRequirednessMigrationRejectsInexactContracts(t *testing.T) {
tests := []struct {
name string
input string
wantErr string
}{
{
name: "unknown kind",
input: strings.Replace(validRequirednessMigrationManifestJSON, `"requiredness_change"`, `"anything"`, 1),
wantErr: "invalid kind",
},
{
name: "missing flag name",
input: strings.Replace(validRequirednessMigrationManifestJSON, `"to-user-ids"`, `""`, 1),
wantErr: "exact flag",
},
{
name: "invalid before state",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"before": {"present": true, "type": "string", "scope": "local"}`,
`"before": {"present": true, "type": "string", "scope": "anything"}`,
1,
),
wantErr: "flag before present state has invalid scope",
},
{
name: "invalid after state",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"after": {"present": true, "type": "string", "required": true, "scope": "anything"}`,
1,
),
wantErr: "flag after present state has invalid scope",
},
{
name: "before already required",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"before": {"present": true, "type": "string", "scope": "local"}`,
`"before": {"present": true, "type": "string", "required": true, "scope": "local"}`,
1,
),
wantErr: "optional to required",
},
{
name: "after remains optional",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"after": {"present": true, "type": "string", "scope": "local"}`,
1,
),
wantErr: "optional to required",
},
{
name: "type drift",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"after": {"present": true, "type": "stringSlice", "required": true, "scope": "local"}`,
1,
),
wantErr: "must preserve every flag attribute except requiredness",
},
{
name: "visibility drift",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"after": {"present": true, "type": "string", "required": true, "hidden": true, "scope": "local"}`,
1,
),
wantErr: "must stay visible",
},
{
name: "alias relation",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"after": {"present": true, "type": "string", "required": true, "scope": "local", "alias_of": "other"}`,
1,
),
wantErr: "must not declare alias_of",
},
{
name: "rename fields cannot be combined",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"flag": {`,
`"legacy": {"name":"old","before":{"present":false},"after":{"present":false}}, "flag": {`,
1,
),
wantErr: "must not declare legacy or canonical",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := ReadFlagMigrationManifest(strings.NewReader(test.input))
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("ReadFlagMigrationManifest() error = %v, want %q", err, test.wantErr)
}
})
}
missingFlag := FlagMigration{
Kind: FlagMigrationRequirednessChange,
Command: "dws report entry submit",
State: FlagMigrationPending,
Reason: "Missing exact flag state.",
}
if err := missingFlag.validate(); err == nil || !strings.Contains(err.Error(), "exact flag") {
t.Fatalf("missing requiredness flag error = %v", err)
}
rename, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON))
if err != nil {
t.Fatal(err)
}
rename.Migrations[0].Kind = FlagMigrationRename
rename.Migrations[0].Flag = &FlagMigrationSide{
Name: "unrelated",
Before: FlagMigrationState{Present: true, Type: "string", Scope: "local"},
After: FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"},
}
if err := rename.Validate(); err == nil || !strings.Contains(err.Error(), "must not declare flag requiredness fields") {
t.Fatalf("rename with requiredness fields error = %v", err)
}
}
func TestCrossPlatformCoverageRequirednessMigrationLifecycleAndAuthorization(t *testing.T) {
pending, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON))
if err != nil {
t.Fatal(err)
}
consumed := pending
consumed.Migrations = append([]FlagMigration(nil), pending.Migrations...)
consumed.Migrations[0].State = FlagMigrationConsumed
migration := pending.Migrations[0]
before := requirednessMigrationSnapshot(migration, false)
after := requirednessMigrationSnapshot(migration, true)
ordinary := Compare(after, before, "merge-base")
if !hasFlagChange(ordinary.Blocking, "flag_became_required", migration.Command, migration.Flag.Name) {
t.Fatalf("fixture did not produce flag_became_required: %#v", ordinary.Blocking)
}
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
pending,
consumed,
)
if err != nil || !report.Compatible {
t.Fatalf("governed requiredness change = (%#v, %v), want compatible", report, err)
}
partial := after
partial.Commands = append([]Command(nil), after.Commands...)
partial.Commands[len(partial.Commands)-1].LocalFlags = append(
[]Flag(nil),
after.Commands[len(after.Commands)-1].LocalFlags...,
)
partial.Commands[len(partial.Commands)-1].LocalFlags[0].Type = "stringSlice"
if phase := matchFlagMigrationPhase(partial, migration); phase != flagMigrationPartial {
t.Fatalf("drifted requiredness migration phase = %s, want partial", phase)
}
beforeWithUnrelated := before
beforeWithUnrelated.Commands = append([]Command(nil), before.Commands...)
beforeWithUnrelated.Commands[len(beforeWithUnrelated.Commands)-1].LocalFlags = append(
beforeWithUnrelated.Commands[len(beforeWithUnrelated.Commands)-1].LocalFlags,
Flag{Name: "unrelated", Type: "string"},
)
afterWithUnrelated := after
afterWithUnrelated.Commands = append([]Command(nil), after.Commands...)
afterWithUnrelated.Commands[len(afterWithUnrelated.Commands)-1].LocalFlags = append(
afterWithUnrelated.Commands[len(afterWithUnrelated.Commands)-1].LocalFlags,
Flag{Name: "unrelated", Type: "string", Required: true},
)
report, err = CompareAllWithFlagMigrations(
afterWithUnrelated,
map[string]Snapshot{"merge-base": beforeWithUnrelated, "stable": beforeWithUnrelated},
pending,
consumed,
)
if err != nil || report.Compatible || !hasFlagChange(report.Comparisons[0].Blocking, "flag_became_required", migration.Command, "unrelated") {
t.Fatalf("unrelated requiredness change was hidden: report=%#v err=%v", report, err)
}
empty := FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}
if _, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
empty,
pending,
); err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") {
t.Fatalf("candidate self-authorization error = %v", err)
}
}
func requirednessMigrationSnapshot(migration FlagMigration, after bool) Snapshot {
state := migration.Flag.Before
if after {
state = migration.Flag.After
}
flag := Flag{
Name: migration.Flag.Name,
Type: state.Type,
Required: state.Required,
Hidden: state.Hidden,
Shorthand: state.Shorthand,
NoOpt: state.NoOpt,
}
command := testCommandWithFlagScopes(migration.Command, nil, nil)
if state.Scope == "inherited" {
command.InheritedFlags = []Flag{flag}
} else {
command.LocalFlags = []Flag{flag}
}
return testSnapshot(testCommand("dws"), command)
}
func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactContracts(t *testing.T) {
manifest, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON))
if err != nil {
@@ -282,6 +525,22 @@ func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactCon
if err == nil || !strings.Contains(err.Error(), "trailing") {
t.Fatalf("trailing JSON error = %v", err)
}
requiredness, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON))
if err != nil {
t.Fatal(err)
}
overlap := manifest
overlap.Migrations = append([]FlagMigration(nil), manifest.Migrations...)
rename := overlap.Migrations[0]
rename.Command = requiredness.Migrations[0].Command
rename.Canonical.Name = requiredness.Migrations[0].Flag.Name
rename.Legacy.After.AliasOf = rename.Canonical.Name
overlap.Migrations[0] = rename
overlap.Migrations = append(overlap.Migrations, requiredness.Migrations[0])
if err := overlap.Validate(); err == nil || !strings.Contains(err.Error(), "overlaps rename migration") {
t.Fatalf("requiredness and rename overlap error = %v", err)
}
}
func TestCrossPlatformCoverageAuthorizeFlagMigrationsReturnsExactBaseOwnedApproval(t *testing.T) {
@@ -72,14 +72,24 @@ func TestCrossPlatformCoverageCalendarListRequiresExplicitCollectionAndPaginatio
if err != nil || len(events) != 0 || !page.Known || page.HasMore {
t.Fatalf("explicit empty: events=%v page=%+v err=%v", events, page, err)
}
var serviceEmptySentinel map[string]any
if err := json.Unmarshal([]byte(`{"success":true,"result":{"events":[{"attendees":null,"categories":null,"meetingRooms":null,"reminders":null}],"hasMore":false}}`), &serviceEmptySentinel); err != nil {
t.Fatal(err)
}
events, page, err = eventListProject(serviceEmptySentinel)
if err != nil || events == nil || len(events) != 0 || !page.Known || page.HasMore {
t.Fatalf("service empty sentinel: events=%#v page=%+v err=%v", events, page, err)
}
for name, payload := range map[string]string{
"missing collection": `{"success":true,"result":{"hasMore":false}}`,
"bad collection": `{"success":true,"result":{"events":{}}}`,
"bad item": `{"success":true,"result":{"events":["bad"],"hasMore":false}}`,
"empty item": `{"success":true,"result":{"events":[{}],"hasMore":false}}`,
"missing pagination": `{"success":true,"result":{"events":[]}}`,
"missing next cursor": `{"success":true,"result":{"events":[],"hasMore":true}}`,
"missing collection": `{"success":true,"result":{"hasMore":false}}`,
"bad collection": `{"success":true,"result":{"events":{}}}`,
"bad item": `{"success":true,"result":{"events":["bad"],"hasMore":false}}`,
"empty item": `{"success":true,"result":{"events":[{}],"hasMore":false}}`,
"unknown null item": `{"success":true,"result":{"events":[{"summary":null}],"hasMore":false}}`,
"sentinel with cursor": `{"success":true,"result":{"events":[{"attendees":null}],"hasMore":false,"nextCursor":"unexpected"}}`,
"missing pagination": `{"success":true,"result":{"events":[]}}`,
"missing next cursor": `{"success":true,"result":{"events":[],"hasMore":true}}`,
} {
t.Run(name, func(t *testing.T) {
var data map[string]any
+4
View File
@@ -16,6 +16,7 @@ import (
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"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/calendarcompat"
)
const calendarCompositeReason = "Reviewed Calendar Shortcut composite: the executable CLI owns strict response validation, truthful pagination, optional multi-step writes, read-back verification, and confirmation; no single MCP interface represents the complete command contract."
@@ -205,6 +206,9 @@ func requireCalendarCollection(data map[string]any, operation string, keys ...st
if !ok {
return nil, nil, calendarResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 字段不是数组", key))
}
if key == "events" {
items, _ = calendarcompat.NormalizeTerminalEmptyEvents(items, container)
}
if err := validateCalendarCollectionItems(items, operation, key); err != nil {
return nil, nil, err
}
@@ -0,0 +1,48 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Package calendarcompat contains narrow compatibility adapters for observed
// Calendar wire shapes shared by the calendar and smart Shortcut packages.
package calendarcompat
// NormalizeTerminalEmptyEvents recognizes the Calendar service's legacy empty
// page sentinel. Some deployments encode an exhausted empty page as one object
// whose collection fields are all null instead of events:[]. Only that exact
// terminal shape is normalized; arbitrary id-less or mixed event rows remain
// available to the strict caller validation and must fail closed.
func NormalizeTerminalEmptyEvents(items []any, container map[string]any) ([]any, bool) {
if len(items) != 1 || container == nil {
return items, false
}
hasMore, ok := container["hasMore"].(bool)
if !ok || hasMore || hasNonEmptyCursor(container) {
return items, false
}
placeholder, ok := items[0].(map[string]any)
knownNullFields := map[string]struct{}{
"attendees": {},
"categories": {},
"meetingRooms": {},
"reminders": {},
}
if !ok || len(placeholder) != len(knownNullFields) {
return items, false
}
for key, value := range placeholder {
if _, known := knownNullFields[key]; !known || value != nil {
return items, false
}
}
return []any{}, true
}
func hasNonEmptyCursor(container map[string]any) bool {
for _, key := range []string{"nextCursor", "nextToken", "pageToken"} {
value, present := container[key]
if !present || value == nil || value == "" {
continue
}
return true
}
return false
}
@@ -0,0 +1,44 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package calendarcompat
import "testing"
func TestCrossPlatformCoverageNormalizeTerminalEmptyEvents(t *testing.T) {
sentinel := []any{map[string]any{
"attendees": nil, "categories": nil, "meetingRooms": nil, "reminders": nil,
}}
if got, ok := NormalizeTerminalEmptyEvents(sentinel, map[string]any{"hasMore": false}); !ok || got == nil || len(got) != 0 {
t.Fatalf("terminal sentinel = %#v, %v; want explicit empty slice", got, ok)
}
for name, items := range map[string][]any{
"empty object": {map[string]any{}},
"missing fields": {map[string]any{"attendees": nil}},
"unknown field": {map[string]any{
"attendees": nil, "categories": nil, "meetingRooms": nil, "summary": nil,
}},
"non-null field": {map[string]any{
"attendees": []any{}, "categories": nil, "meetingRooms": nil, "reminders": nil,
}},
"mixed rows": {sentinel[0], map[string]any{"id": "event-1"}},
} {
t.Run(name, func(t *testing.T) {
if _, ok := NormalizeTerminalEmptyEvents(items, map[string]any{"hasMore": false}); ok {
t.Fatalf("invalid sentinel accepted: %#v", items)
}
})
}
for name, container := range map[string]map[string]any{
"missing terminal evidence": {},
"more pages": {"hasMore": true},
"cursor": {"hasMore": false, "nextCursor": "next"},
} {
t.Run(name, func(t *testing.T) {
if _, ok := NormalizeTerminalEmptyEvents(sentinel, container); ok {
t.Fatalf("non-terminal sentinel accepted with %#v", container)
}
})
}
}
+4
View File
@@ -19,6 +19,7 @@ package doc
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
"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/localio"
@@ -564,6 +565,9 @@ var CommentReply = shortcut.Shortcut{
"replyCommentKey": rt.Str("comment-key"),
}
if rt.Bool("emoji") {
if err := commentreaction.Validate(rt.Str("content")); err != nil {
return err
}
params["emoji"] = true
}
if rt.Changed("mention") {
@@ -132,6 +132,31 @@ func runDocCoveragePath(t *testing.T, declaration shortcut.Shortcut, caller *doc
return root.Execute()
}
func TestCrossPlatformCoverageCommentReplyRejectsUnsupportedEmojiBeforeRPC(t *testing.T) {
for _, content := range []string{"😄", "乱码"} {
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
err := runDocCoverage(t, CommentReply, caller,
"--node", "node-1", "--comment-key", "comment-1",
"--content", content, "--emoji", "--yes")
if err == nil {
t.Fatalf("unsupported reaction %q accepted", content)
}
if caller.calls != 0 {
t.Fatalf("unsupported reaction %q reached RPC %d time(s)", content, caller.calls)
}
}
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
if err := runDocCoverage(t, CommentReply, caller,
"--node", "node-1", "--comment-key", "comment-1",
"--content", "鼓掌", "--emoji", "--yes"); err != nil {
t.Fatal(err)
}
if caller.calls != 1 || caller.history[0].params["emoji"] != true {
t.Fatalf("valid reaction call = %#v", caller.history)
}
}
func TestCrossPlatformCoverageRevisionSelectionAndKeywordUseLiveShapes(t *testing.T) {
revisionPayload := map[string]any{"data": map[string]any{"revision": json.Number("9")}}
if got, ok := nestedRevision(revisionPayload); !ok || got != 9 {
@@ -23,6 +23,7 @@ import (
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"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/calendarcompat"
)
const calendarSmartMaxPages = 20
@@ -215,6 +216,7 @@ func calendarSmartEventPage(data map[string]any) ([]map[string]any, bool, string
if !hasMore && next != "" {
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "inconsistent_pagination", "服务端返回 hasMore=false 但 nextCursor 非空")
}
list, _ = calendarcompat.NormalizeTerminalEmptyEvents(list, result)
events := make([]map[string]any, 0, len(list))
for index, item := range list {
event, ok := item.(map[string]any)
@@ -148,6 +148,18 @@ func TestCrossPlatformCoverageCalendarSmartStrictCollections(t *testing.T) {
if err != nil || items == nil || len(items) != 0 || hasMore || next != "" {
t.Fatalf("explicit empty page items=%#v hasMore=%v next=%q err=%v", items, hasMore, next, err)
}
items, hasMore, next, err = calendarSmartEventPage(map[string]any{
"success": true,
"result": map[string]any{
"events": []any{map[string]any{
"attendees": nil, "categories": nil, "meetingRooms": nil, "reminders": nil,
}},
"hasMore": false,
},
})
if err != nil || items == nil || len(items) != 0 || hasMore || next != "" {
t.Fatalf("service empty sentinel items=%#v hasMore=%v next=%q err=%v", items, hasMore, next, err)
}
invalidPages := []map[string]any{
{},
@@ -157,6 +169,7 @@ func TestCrossPlatformCoverageCalendarSmartStrictCollections(t *testing.T) {
{"success": true, "result": map[string]any{"events": []any{}, "hasMore": true}},
{"success": true, "result": map[string]any{"events": []any{}, "hasMore": false, "nextCursor": "unexpected"}},
{"success": true, "result": map[string]any{"events": []any{map[string]any{"id": "event-placeholder"}}, "hasMore": false}},
{"success": true, "result": map[string]any{"events": []any{map[string]any{"summary": nil}}, "hasMore": false}},
{"success": true, "result": map[string]any{"events": []any{"bad-item"}, "hasMore": false}},
}
for index, page := range invalidPages {
+44 -1
View File
@@ -6,6 +6,7 @@ package todo
import (
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"time"
@@ -159,13 +160,55 @@ func updateTodo(rt *shortcut.RuntimeContext) error {
if key == "taskId" {
continue
}
if fmt.Sprint(detail[key]) != fmt.Sprint(expected) {
if !todoUpdateFieldMatches(key, detail[key], expected) {
return todoResponseError("todo/update_todo_task", "verification_mismatch", "更新后读回字段 "+key+" 不一致")
}
}
return rt.Output(map[string]any{"taskId": taskID, "verified": true, "todo": detail})
}
func todoUpdateFieldMatches(key string, actual, expected any) bool {
switch key {
case "dueTime", "priority":
actualNumber, actualOK := todoExactInteger(actual)
expectedNumber, expectedOK := todoExactInteger(expected)
return actualOK && expectedOK && actualNumber == expectedNumber
case "subject":
actualText, actualOK := actual.(string)
expectedText, expectedOK := expected.(string)
return actualOK && expectedOK && actualText == expectedText
default:
return false
}
}
func todoExactInteger(value any) (int64, bool) {
switch typed := value.(type) {
case int:
return int64(typed), true
case int64:
return typed, true
case float64:
return todoExactFloat(typed)
case json.Number:
parsed, err := typed.Int64()
return parsed, err == nil
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
return parsed, err == nil
default:
return 0, false
}
}
func todoExactFloat(value float64) (int64, bool) {
if math.IsNaN(value) || math.IsInf(value, 0) || math.Trunc(value) != value ||
value < float64(math.MinInt64) || value >= float64(math.MaxInt64) {
return 0, false
}
return int64(value), true
}
var Update = shortcut.Shortcut{
OutputRollout: output.RolloutUnifiedActive,
Service: "todo", Command: "+update", Product: "todo",
@@ -8,6 +8,7 @@ import (
"encoding/json"
"errors"
"io"
"math"
"strconv"
"strings"
"testing"
@@ -313,11 +314,44 @@ func TestCrossPlatformCoverageTodoCreateAndUpdate(t *testing.T) {
}
updateSuccess := &todoCoverageCaller{responses: map[string][]string{
"update_todo_task": {`{"success":true}`},
"get_todo_detail": {`{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"x","priority":40}}}`},
"get_todo_detail": {`{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"x","priority":40,"dueTime":1786932000000}}}`},
}}
if err := runTodoCoverage(t, Update, updateSuccess, "--task-id", "task-1", "--title", "x", "--priority", "40", "--yes"); err != nil {
if err := runTodoCoverage(t, Update, updateSuccess, "--task-id", "task-1", "--title", "x", "--priority", "40", "--due", todoCoverageTime, "--yes"); err != nil {
t.Fatal(err)
}
if todoUpdateFieldMatches("dueTime", float64(1786932000000), int64(1786932000001)) {
t.Fatal("different dueTime milliseconds matched")
}
for _, tc := range []struct {
value any
want int64
ok bool
}{
{value: int(40), want: 40, ok: true},
{value: int64(1786932000000), want: 1786932000000, ok: true},
{value: float64(40), want: 40, ok: true},
{value: json.Number("40"), want: 40, ok: true},
{value: " 40 ", want: 40, ok: true},
{value: float64(math.MinInt64), want: math.MinInt64, ok: true},
{value: 1786932000000.5},
{value: math.NaN()},
{value: math.Inf(1)},
{value: math.Inf(-1)},
{value: float64(math.MaxInt64)},
{value: 1e300},
{value: -1e300},
{value: json.Number("not-a-number")},
{value: "not-a-number"},
{value: true},
} {
got, ok := todoExactInteger(tc.value)
if got != tc.want || ok != tc.ok {
t.Fatalf("todoExactInteger(%#v) = %d/%v, want %d/%v", tc.value, got, ok, tc.want, tc.ok)
}
}
if todoUpdateFieldMatches("unknown", 1, 1) {
t.Fatal("unknown update field matched")
}
}
func TestCrossPlatformCoverageTodoDoneBranches(t *testing.T) {
@@ -1080,6 +1080,29 @@ func TestCrossPlatformCoverageSchemaCommandMigrationLineagePreservesOrdinaryChec
t.Fatalf("lineage hid positional drift: %s", failures)
}
requiredness := interfacesnapshot.FlagMigration{
Kind: interfacesnapshot.FlagMigrationRequirednessChange,
Command: "dws report cli-only",
Flag: &interfacesnapshot.FlagMigrationSide{
Name: "recipient",
Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Scope: "local"},
After: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"},
},
State: interfacesnapshot.FlagMigrationConsumed,
Reason: "Exercise requiredness receipts beside command lineage without treating them as renames.",
}
flagsWithRequiredness := append([]interfacesnapshot.FlagMigration(nil), schemaCommandLineageFlagManifest().Migrations...)
flagsWithRequiredness = append(flagsWithRequiredness, requiredness)
if _, err := normalizeSchemaCommandMigrationLineage(
stable,
base,
current,
flagsWithRequiredness,
schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations,
); err != nil {
t.Fatalf("requiredness receipt interfered with command lineage: %v", err)
}
// Multiple historical names may converge only when every predecessor carries
// the exact same contract and every receipt is already consumed.
stable, base, current = schemaCommandLineageContracts()
+76 -3
View File
@@ -1261,9 +1261,9 @@ type schemaToolRef struct {
}
// normalizeSchemaFlagMigrations projects only an already-authorized CLI flag
// rename onto a cloned historical Schema contract. The ordinary compatibility
// checker still makes the final decision; this adapter never drops findings by
// matching their rendered text.
// rename or requiredness change onto a cloned historical Schema contract. The
// ordinary compatibility checker still makes the final decision; this adapter
// never drops findings by matching their rendered text.
func normalizeSchemaFlagMigrations(
baseline schemaContract,
current schemaContract,
@@ -1273,6 +1273,14 @@ func normalizeSchemaFlagMigrations(
renamesByTool := map[schemaToolRef]map[string]string{}
for _, migration := range migrations {
if migration.EffectiveKind() == interfacesnapshot.FlagMigrationRequirednessChange {
var err error
normalized, err = normalizeSchemaFlagRequirednessMigration(normalized, baseline, current, migration)
if err != nil {
return schemaContract{}, err
}
continue
}
primaryPath := strings.TrimPrefix(migration.Command, "dws ")
matches := schemaToolsByPrimaryPath(baseline, primaryPath)
if len(matches) == 0 {
@@ -1394,6 +1402,65 @@ func normalizeSchemaFlagMigrations(
return normalized, nil
}
func normalizeSchemaFlagRequirednessMigration(
normalized schemaContract,
baseline schemaContract,
current schemaContract,
migration interfacesnapshot.FlagMigration,
) (schemaContract, error) {
primaryPath := strings.TrimPrefix(migration.Command, "dws ")
matches := schemaToolsByPrimaryPath(baseline, primaryPath)
if len(matches) == 0 {
// A reviewed CLI-only command has no Schema compatibility surface.
return normalized, nil
}
if len(matches) != 1 {
return schemaContract{}, fmt.Errorf(
"approved flag requiredness migration %q matches %d historical Schema tools",
migration.Command,
len(matches),
)
}
ref := matches[0]
oldTool := baseline.Products[ref.productID].Tools[ref.toolID]
oldParameter, existed := oldTool.Parameters[migration.Flag.Name]
if !existed {
// CLI requiredness is not authority to create or mutate an unrelated
// historical Schema parameter.
return normalized, nil
}
newProduct, productExists := current.Products[ref.productID]
newTool, toolExists := newProduct.Tools[ref.toolID]
if !productExists || !toolExists || newTool.PrimaryCLIPath != primaryPath {
// Preserve the baseline so the ordinary checker reports the missing tool
// or primary path change.
return normalized, nil
}
newParameter, exists := newTool.Parameters[migration.Flag.Name]
if !exists {
// Preserve the baseline so the ordinary checker reports parameter loss.
return normalized, nil
}
if !newParameter.Required || !newParameter.CLIRequired {
return schemaContract{}, fmt.Errorf(
"approved flag requiredness migration %q Schema parameter %q must be required and cli_required",
migration.Command,
migration.Flag.Name,
)
}
normalizedProduct := normalized.Products[ref.productID]
normalizedTool := normalizedProduct.Tools[ref.toolID]
normalizedParameter := oldParameter
normalizedParameter.Required = newParameter.Required
normalizedParameter.CLIRequired = newParameter.CLIRequired
normalizedTool.Parameters[migration.Flag.Name] = normalizedParameter
normalizedProduct.Tools[ref.toolID] = normalizedTool
normalized.Products[ref.productID] = normalizedProduct
return normalized, nil
}
// normalizeSchemaCommandMigrationLineage composes two independently reviewed
// migration receipts without inventing a second alias authority. A consumed
// flag migration may supply the historical predecessor of a command_move
@@ -1482,6 +1549,9 @@ func stageSchemaCommandMigrationPredecessors(
for _, parameter := range migration.Schema.Parameters {
if _, direct := oldTool.Parameters[parameter.From]; direct {
for _, flagMigration := range flagMigrations {
if flagMigration.EffectiveKind() != interfacesnapshot.FlagMigrationRename {
continue
}
if flagMigration.Command != migration.Legacy.Command ||
flagMigration.Canonical.Name != parameter.From {
continue
@@ -1501,6 +1571,9 @@ func stageSchemaCommandMigrationPredecessors(
predecessors := make([]interfacesnapshot.FlagMigration, 0, 1)
for _, flagMigration := range flagMigrations {
if flagMigration.EffectiveKind() != interfacesnapshot.FlagMigrationRename {
continue
}
if flagMigration.Command != migration.Legacy.Command ||
flagMigration.Canonical.Name != parameter.From {
continue
+138
View File
@@ -1229,6 +1229,144 @@ func TestCrossPlatformCoverageSchemaFlagMigrationNormalizesExactRename(t *testin
}
}
func TestCrossPlatformCoverageSchemaFlagRequirednessMigration(t *testing.T) {
baseline := schemaContract{
Version: schemaContractVersion,
Products: map[string]productSchema{
"report": {
Tools: map[string]toolSchema{
"report.entry_submit": {
PrimaryCLIPath: "report entry submit",
Parameters: map[string]parameterSchema{
"to-user-ids": {Type: `"string"`, Property: "toUserIds", InterfaceType: "string"},
},
},
},
},
},
}
current := cloneContract(baseline)
product := current.Products["report"]
tool := product.Tools["report.entry_submit"]
parameter := tool.Parameters["to-user-ids"]
parameter.Required = true
parameter.CLIRequired = true
tool.Parameters["to-user-ids"] = parameter
product.Tools["report.entry_submit"] = tool
current.Products["report"] = product
migration := interfacesnapshot.FlagMigration{
Kind: interfacesnapshot.FlagMigrationRequirednessChange,
Command: "dws report entry submit",
Flag: &interfacesnapshot.FlagMigrationSide{
Name: "to-user-ids",
Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Scope: "local"},
After: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"},
},
State: interfacesnapshot.FlagMigrationConsumed,
Reason: "Reject report submissions that have no visible recipient.",
}
normalized, err := normalizeSchemaFlagMigrations(baseline, current, []interfacesnapshot.FlagMigration{migration})
if err != nil {
t.Fatal(err)
}
if failures := checkCompatibility(normalized, current); len(failures) != 0 {
t.Fatalf("exact requiredness migration should pass after normalization: %v", failures)
}
t.Run("non-requiredness drift remains blocked", func(t *testing.T) {
drifted := cloneContract(current)
product := drifted.Products["report"]
tool := product.Tools["report.entry_submit"]
parameter := tool.Parameters["to-user-ids"]
parameter.Type = `"array"`
tool.Parameters["to-user-ids"] = parameter
product.Tools["report.entry_submit"] = tool
drifted.Products["report"] = product
normalized, err := normalizeSchemaFlagMigrations(baseline, drifted, []interfacesnapshot.FlagMigration{migration})
if err != nil {
t.Fatal(err)
}
if failures := strings.Join(checkCompatibility(normalized, drifted), "\n"); !strings.Contains(failures, "changed type") {
t.Fatalf("requiredness migration hid type drift: %s", failures)
}
})
t.Run("partial Schema projection fails closed", func(t *testing.T) {
partial := cloneContract(current)
product := partial.Products["report"]
tool := product.Tools["report.entry_submit"]
parameter := tool.Parameters["to-user-ids"]
parameter.CLIRequired = false
tool.Parameters["to-user-ids"] = parameter
product.Tools["report.entry_submit"] = tool
partial.Products["report"] = product
if _, err := normalizeSchemaFlagMigrations(baseline, partial, []interfacesnapshot.FlagMigration{migration}); err == nil || !strings.Contains(err.Error(), "required and cli_required") {
t.Fatalf("partial requiredness projection error = %v", err)
}
})
t.Run("CLI-only migration does not manufacture Schema", func(t *testing.T) {
cliOnly := migration
cliOnly.Command = "dws report cli-only"
normalized, err := normalizeSchemaFlagMigrations(baseline, current, []interfacesnapshot.FlagMigration{cliOnly})
if err != nil || !reflect.DeepEqual(normalized, baseline) {
t.Fatalf("CLI-only requiredness migration = %#v, %v", normalized, err)
}
})
t.Run("duplicate historical path fails closed", func(t *testing.T) {
duplicate := cloneContract(baseline)
product := duplicate.Products["report"]
product.Tools["report.duplicate"] = product.Tools["report.entry_submit"]
duplicate.Products["report"] = product
if _, err := normalizeSchemaFlagMigrations(duplicate, current, []interfacesnapshot.FlagMigration{migration}); err == nil || !strings.Contains(err.Error(), "matches 2") {
t.Fatalf("duplicate requiredness Schema path error = %v", err)
}
})
t.Run("missing historical parameter is not authority", func(t *testing.T) {
missing := cloneContract(baseline)
product := missing.Products["report"]
tool := product.Tools["report.entry_submit"]
delete(tool.Parameters, "to-user-ids")
product.Tools["report.entry_submit"] = tool
missing.Products["report"] = product
normalized, err := normalizeSchemaFlagMigrations(missing, current, []interfacesnapshot.FlagMigration{migration})
if err != nil || !reflect.DeepEqual(normalized, missing) {
t.Fatalf("missing historical requiredness parameter = %#v, %v", normalized, err)
}
})
t.Run("missing current tool stays visible to ordinary checker", func(t *testing.T) {
missing := cloneContract(current)
delete(missing.Products, "report")
normalized, err := normalizeSchemaFlagMigrations(baseline, missing, []interfacesnapshot.FlagMigration{migration})
if err != nil {
t.Fatal(err)
}
if failures := checkCompatibility(normalized, missing); len(failures) == 0 {
t.Fatal("requiredness migration hid missing current Schema tool")
}
})
t.Run("missing current parameter stays visible to ordinary checker", func(t *testing.T) {
missing := cloneContract(current)
product := missing.Products["report"]
tool := product.Tools["report.entry_submit"]
delete(tool.Parameters, "to-user-ids")
product.Tools["report.entry_submit"] = tool
missing.Products["report"] = product
normalized, err := normalizeSchemaFlagMigrations(baseline, missing, []interfacesnapshot.FlagMigration{migration})
if err != nil {
t.Fatal(err)
}
if failures := strings.Join(checkCompatibility(normalized, missing), "\n"); !strings.Contains(failures, "lost parameter") {
t.Fatalf("requiredness migration hid missing current parameter: %s", failures)
}
})
}
func TestCrossPlatformCoverageSchemaFlagMigrationRejectsSemanticDrift(t *testing.T) {
tests := []struct {
name string